← back to Cli Printing Press
feat(cli): multi-spec composition with --spec repetition
bb12a6054bf480199989fab7538aad57ce9f0d7c · 2026-03-24 00:12:36 -0700 · Matt Van Horn
Accept multiple --spec flags to merge specs into one CLI. Each spec's
resources become top-level commands. --name required for multi-spec.
Single --spec backwards compatible. Auth prefers OAuth2 when available.
Files touched
Diff
commit bb12a6054bf480199989fab7538aad57ce9f0d7c
Author: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
Date: Tue Mar 24 00:12:36 2026 -0700
feat(cli): multi-spec composition with --spec repetition
Accept multiple --spec flags to merge specs into one CLI. Each spec's
resources become top-level commands. --name required for multi-spec.
Single --spec backwards compatible. Auth prefers OAuth2 when available.
---
internal/cli/root.go | 97 ++++++++++++++++++++++++++++++++++++++++++----------
1 file changed, 78 insertions(+), 19 deletions(-)
diff --git a/internal/cli/root.go b/internal/cli/root.go
index 66d66767..4ba34f49 100644
--- a/internal/cli/root.go
+++ b/internal/cli/root.go
@@ -36,7 +36,8 @@ func Execute() error {
}
func newGenerateCmd() *cobra.Command {
- var specFile string
+ var specFiles []string
+ var cliName string
var outputDir string
var validate bool
var refresh bool
@@ -45,34 +46,38 @@ func newGenerateCmd() *cobra.Command {
Use: "generate",
Short: "Generate a Go CLI project from an API spec",
RunE: func(cmd *cobra.Command, args []string) error {
- if specFile == "" {
+ if len(specFiles) == 0 {
return fmt.Errorf("--spec is required")
}
- var (
- data []byte
- err error
- )
- if strings.HasPrefix(specFile, "http://") || strings.HasPrefix(specFile, "https://") {
- data, err = fetchOrCacheSpec(specFile, refresh)
+ var specs []*spec.APISpec
+ for _, specFile := range specFiles {
+ data, err := readSpec(specFile, refresh)
if err != nil {
- return fmt.Errorf("fetching spec from URL: %w", err)
+ return fmt.Errorf("reading spec %s: %w", specFile, err)
+ }
+
+ var apiSpec *spec.APISpec
+ if openapi.IsOpenAPI(data) {
+ apiSpec, err = openapi.Parse(data)
+ } else {
+ apiSpec, err = spec.ParseBytes(data)
}
- } else {
- data, err = os.ReadFile(specFile)
if err != nil {
- return fmt.Errorf("reading spec file: %w", err)
+ return fmt.Errorf("parsing spec %s: %w", specFile, err)
}
+
+ specs = append(specs, apiSpec)
}
var apiSpec *spec.APISpec
- if openapi.IsOpenAPI(data) {
- apiSpec, err = openapi.Parse(data)
+ if len(specs) == 1 {
+ apiSpec = specs[0]
} else {
- apiSpec, err = spec.ParseBytes(data)
- }
- if err != nil {
- return fmt.Errorf("parsing spec: %w", err)
+ if cliName == "" {
+ return fmt.Errorf("--name is required when using multiple specs")
+ }
+ apiSpec = mergeSpecs(specs, cliName)
}
if outputDir == "" {
@@ -99,7 +104,8 @@ func newGenerateCmd() *cobra.Command {
},
}
- cmd.Flags().StringVar(&specFile, "spec", "", "Path to API spec (internal YAML or OpenAPI 3.0+)")
+ cmd.Flags().StringSliceVar(&specFiles, "spec", nil, "Path or URL to API spec (can be repeated)")
+ cmd.Flags().StringVar(&cliName, "name", "", "CLI name (required when using multiple specs)")
cmd.Flags().StringVar(&outputDir, "output", "", "Output directory (default: <name>-cli)")
cmd.Flags().BoolVar(&validate, "validate", true, "Run quality gates on the generated project")
cmd.Flags().BoolVar(&refresh, "refresh", false, "Refresh cached remote spec before generating")
@@ -107,6 +113,59 @@ func newGenerateCmd() *cobra.Command {
return cmd
}
+func readSpec(specFile string, refresh bool) ([]byte, error) {
+ if strings.HasPrefix(specFile, "http://") || strings.HasPrefix(specFile, "https://") {
+ return fetchOrCacheSpec(specFile, refresh)
+ }
+
+ return os.ReadFile(specFile)
+}
+
+func mergeSpecs(specs []*spec.APISpec, name string) *spec.APISpec {
+ if len(specs) == 1 {
+ return specs[0]
+ }
+
+ merged := &spec.APISpec{
+ Name: name,
+ Description: "Combined CLI for multiple API services",
+ Version: specs[0].Version,
+ BaseURL: specs[0].BaseURL,
+ BasePath: specs[0].BasePath,
+ Auth: specs[0].Auth,
+ Config: spec.ConfigSpec{
+ Format: "toml",
+ Path: fmt.Sprintf("~/.config/%s-cli/config.toml", name),
+ },
+ Resources: map[string]spec.Resource{},
+ Types: map[string]spec.TypeDef{},
+ }
+
+ for _, s := range specs {
+ for resourceName, resource := range s.Resources {
+ key := resourceName
+ if _, exists := merged.Resources[key]; exists {
+ key = s.Name + "-" + resourceName
+ }
+ merged.Resources[key] = resource
+ }
+
+ for typeName, typeDef := range s.Types {
+ key := typeName
+ if _, exists := merged.Types[key]; exists {
+ key = s.Name + "-" + typeName
+ }
+ merged.Types[key] = typeDef
+ }
+
+ if s.Auth.AuthorizationURL != "" && merged.Auth.AuthorizationURL == "" {
+ merged.Auth = s.Auth
+ }
+ }
+
+ return merged
+}
+
func fetchOrCacheSpec(specURL string, refresh bool) ([]byte, error) {
sum := sha256.Sum256([]byte(specURL))
cacheKey := hex.EncodeToString(sum[:])
← dacda31f feat(generator): OAuth2 auth flow for generated CLIs
·
back to Cli Printing Press
·
docs(plans): autonomous CLI pipeline plan and status updates 9cfae3e4 →