diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 3b5fa46..5d3947e 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -99,7 +99,7 @@ OpenCLI Specification is a declarative, language-agnostic document specification - `go.mod` - Go module definition - `go.work` - Workspace configuration - `Makefile` - Build targets (test, gen-docs, release) -- `opencli.ocs.yaml` - Self-documenting spec for this project +- `ocli.ocs.yaml` - Self-documenting spec for this project - `spec.schema.json` - JSON Schema that defines the OpenCLI Specification. This is the central pillar of the repository. All of the other packages and documentation serves this specification. ## Key Functionality @@ -120,6 +120,9 @@ OpenCLI Specification is a declarative, language-agnostic document specification ## Important Notes -- The project is self-documenting: `opencli.ocs.yaml` describes the CLI itself +- The project is self-documenting: `ocli.ocs.yaml` describes the CLI itself - Generated code uses `gencli` package and directory naming convention to avoid conflicts. We can safely regenerate the code in the repo without fear of clobbering any of the logic - Web app uses Next.js 16 App Router with static export +- You should always prioritize human-readable code that is maintainable and testable. Strive for elegance over cleverness +- Don't guess about how newer libraries work based on old training data. Look up the docs via go.pkg.dev or using the built in websearch tool call. +- For temporary, test, scratch files always use the subdirectory `./.scratch` within the workspace. And be sure to clean up after yourself when done. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index da9bbc1..973f97f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -80,7 +80,7 @@ To keep the project reliable, we require all code changes to be accompanied by a The code, examples, schema, and web editor are all implemented within this same repository so you can evaluate the ecosystem end to end without context switching. If you are only exploring, start with `README.md` and `examples/`; if you are validating behavior, use the build and test targets in this document. -- `spec/`, `opencli.ocs.yaml`, `spec.schema.json`: Core OpenCLI spec types, canonical example spec, and JSON Schema. +- `spec/`, `ocli.ocs.yaml`, `spec.schema.json`: Core OpenCLI spec types, canonical example spec, and JSON Schema. - `cmd/`: Entry points for executables (Cobra CLI and WASM target). - `internal/`: Internal CLI implementation details and supporting utilities. - `codec/`: Spec encode/decode logic and fixtures. diff --git a/Makefile b/Makefile index b625bce..21f40d0 100644 --- a/Makefile +++ b/Makefile @@ -10,21 +10,23 @@ test: generate version = $(shell git describe --tags HEAD) + .PHONY: gen-docs gen-docs: generate @go run cmd/ocli/main.go gen docs \ --format markdown \ --out ./docs \ - opencli.ocs.yaml + ocli.ocs.yaml @go run cmd/ocli/main.go gen docs \ --format html-embed \ --out ./web/public \ - opencli.ocs.yaml + ocli.ocs.yaml @go run cmd/ocli/main.go gen docs \ --format man \ --out ./docs \ - opencli.ocs.yaml - mkdir -p build && mv docs/opencli.ocs.1 build/ocli.1 + ocli.ocs.yaml + mkdir -p build && mv docs/ocli.ocs.1 build/ocli.1 + .PHONY: gen-examples gen-examples: generate @@ -44,6 +46,17 @@ gen-examples: generate --framework cobra \ --out ./examples/code/cobra/pleasantries/internal \ ./examples/pleasantries-cli.ocs.yaml + @go run cmd/ocli/main.go gen cli \ + --framework urfavecli \ + --out ./examples/code/urfavecli/pleasantries/internal \ + ./examples/pleasantries-cli.ocs.yaml + +.PHONY: gen-ocli +gen-ocli: + @go run cmd/ocli/main.go gen cli \ + --framework cobra \ + --out ./internal/cli \ + ./ocli.ocs.yaml .PHONY: release release: gen-docs gen-examples diff --git a/README.md b/README.md index ae0aef8..6ea1d2c 100644 --- a/README.md +++ b/README.md @@ -62,7 +62,7 @@ OpenCLI specification is a document specification that can be used to describe C Use the CLI to validate specs, generate docs and generate boilerplate code. - [Markdown Docs](https://github.com/bcdxn/opencli/blob/main/docs/opencli.ocs.md) -- [OpenCLI Spec-compliant Document](https://github.com/bcdxn/opencli/blob/main/opencli.ocs.yaml) +- [OpenCLI Spec-compliant Document](https://github.com/bcdxn/opencli/blob/main/ocli.ocs.yaml) ## Live Editor diff --git a/codec/codec.go b/codec/codec.go index 5493e6e..3112356 100644 --- a/codec/codec.go +++ b/codec/codec.go @@ -10,7 +10,9 @@ import ( "encoding/json" "errors" "fmt" + "math" "regexp" + "strconv" "strings" "github.com/bcdxn/opencli/internal/ds" @@ -46,7 +48,10 @@ func UnmarshalJSON(data []byte) (*spec.Document, error) { return nil, err } - doc := buildSpecDoc(rawDoc) + doc, err := buildSpecDoc(&rawDoc) + if err != nil { + return nil, fmt.Errorf("error building OpenCLI spec document: %w", err) + } return doc, nil } @@ -69,16 +74,29 @@ func UnmarshalYAML(data []byte) (*spec.Document, error) { return nil, err } - doc := buildSpecDoc(rawDoc) + doc, err := buildSpecDoc(&rawDoc) + if err != nil { + return nil, fmt.Errorf("error building OpenCLI spec document: %w", err) + } return doc, nil } -func buildSpecDoc(rawDoc rawDocument) *spec.Document { +func buildSpecDoc(rawDoc *rawDocument) (*spec.Document, error) { // Build hierarchical data structure var doc spec.Document doc.Global = rawDoc.Global + if doc.Global != nil { + // Global flags are not part of the command Trie, so postProcessing never + // reaches them. Normalize their defaults here to keep every flag default + // in canonical Go types regardless of where it is declared. + for i := range doc.Global.Flags { + if err := normalizeOneFlagDefault(&doc.Global.Flags[i]); err != nil { + return nil, fmt.Errorf("global flag: %w", err) + } + } + } doc.Info = rawDoc.Info doc.Install = rawDoc.Install doc.OpenCLIVersion = rawDoc.OpenCLIVersion @@ -87,9 +105,11 @@ func buildSpecDoc(rawDoc rawDocument) *spec.Document { insertCommand(&doc, rawCmd.Key, rawCmd.Value) } // run post processing to add/update values after building hierarchical command structure - postProcessing(&doc, &rawDoc) + if err := postProcessing(&doc); err != nil { + return nil, err + } - return &doc + return &doc, nil } // MarshalJSON encodes a spec.Document into JSON bytes. @@ -220,18 +240,18 @@ func indexOfSubcommand(cmd *spec.CommandItem, segment string) int { // postProcessing is applied to the Document which traverses the command Items, // processing each command in the Trie. -func postProcessing(doc *spec.Document, rawDoc *rawDocument) { +func postProcessing(doc *spec.Document) error { if doc.Commands == nil { - return + return nil } - postProcessingDFS(doc.Commands, rawDoc) + return postProcessingDFS(doc.Commands) } // postProcessingDFS is a recursive function that processes each command item. -func postProcessingDFS(node *spec.CommandItem, rawDoc *rawDocument) { +func postProcessingDFS(node *spec.CommandItem) error { if node == nil { - return + return nil } // process node @@ -269,9 +289,242 @@ func postProcessingDFS(node *spec.CommandItem, rawDoc *rawDocument) { node.VisibleChildrenFlags = node.VisibleChildren && visibleChildrenFlags(node) // 7. Add the arguments modifiers addModifiers(node) + // 8. Normalize flag default values to canonical Go types so downstream + // consumers (code generation, docs) don't depend on decoder-specific types + if err := normalizeFlagDefaults(node); err != nil { + return fmt.Errorf("command %q: %w", node.CommandLine, err) + } // iterate through the node's subcommands and process each child recursively for _, child := range node.Commands { - postProcessingDFS(child, rawDoc) + if err := postProcessingDFS(child); err != nil { + return err + } + } + + return nil +} + +// normalizeFlagDefaults coerces every flag default value in a command to a +// canonical Go type based on the declared flag type. Decoders produce different +// concrete types for the same literal (e.g. goccy/go-yaml decodes integers as +// uint64 while encoding/json uses float64), so downstream consumers must not +// rely on the raw decoded type. Canonical forms: string -> string, integer -> +// int64, number -> float64, boolean -> bool. Variadic flags may carry list +// defaults (rejected by schema validation but still decodable); their elements +// are coerced to []string, []int64, []float64, or []bool respectively so the +// emitters can render them without panicking on decoder-specific shapes. A +// default whose value cannot be represented as its declared type is a decode +// error rather than something silently dropped downstream. +func normalizeFlagDefaults(node *spec.CommandItem) error { + if node == nil { + return nil + } + + for i := range node.Flags { + if err := normalizeOneFlagDefault(&node.Flags[i]); err != nil { + return err + } + } + + return nil +} + +// normalizeOneFlagDefault coerces a single flag's default value to its canonical +// Go type based on the declared flag type. It is shared by command flags (via +// normalizeFlagDefaults) and global flags, which live outside the command Trie. +func normalizeOneFlagDefault(flag *spec.FlagItem) error { + var ( + norm any + err error + ) + switch flag.Type { + case "string": + norm, err = toStringDefault(flag.Default, flag.Name) + case "integer": + norm, err = toInt64Default(flag.Default, flag.Name) + case "number": + norm, err = toFloat64Default(flag.Default, flag.Name) + case "boolean": + norm, err = toBoolDefault(flag.Default, flag.Name) + default: + // Unknown or unset type: leave the value untouched. + return nil + } + if err != nil { + return err + } + flag.Default = norm + + return nil +} + +func toStringDefault(val any, name string) (any, error) { + switch v := val.(type) { + case nil: + return nil, nil + case []any: + out := make([]string, 0, len(v)) + for _, e := range v { + s, ok := coerceString(e) + if !ok { + return nil, fmt.Errorf("flag %q has a non-string element in its default list", name) + } + out = append(out, s) + } + return out, nil + case string: + return v, nil + default: + s, ok := coerceString(val) + if !ok { + return nil, fmt.Errorf("flag %q has a non-string default value", name) + } + return s, nil + } +} + +func toInt64Default(val any, name string) (any, error) { + switch v := val.(type) { + case nil: + return nil, nil + case []any: + out := make([]int64, 0, len(v)) + for _, e := range v { + n, ok := coerceInt64(e) + if !ok { + return nil, fmt.Errorf("flag %q has a non-integer element in its default list", name) + } + out = append(out, n) + } + return out, nil + default: + n, ok := coerceInt64(val) + if !ok { + return nil, fmt.Errorf("flag %q has an invalid integer default value", name) + } + return n, nil + } +} + +func toFloat64Default(val any, name string) (any, error) { + switch v := val.(type) { + case nil: + return nil, nil + case []any: + out := make([]float64, 0, len(v)) + for _, e := range v { + f, ok := coerceFloat64(e) + if !ok { + return nil, fmt.Errorf("flag %q has a non-numeric element in its default list", name) + } + out = append(out, f) + } + return out, nil + default: + f, ok := coerceFloat64(val) + if !ok { + return nil, fmt.Errorf("flag %q has an invalid number default value", name) + } + return f, nil + } +} + +func toBoolDefault(val any, name string) (any, error) { + switch v := val.(type) { + case nil: + return nil, nil + case []any: + out := make([]bool, 0, len(v)) + for _, e := range v { + b, ok := coerceBool(e) + if !ok { + return nil, fmt.Errorf("flag %q has a non-boolean element in its default list", name) + } + out = append(out, b) + } + return out, nil + default: + b, ok := coerceBool(val) + if !ok { + return nil, fmt.Errorf("flag %q has an invalid boolean default value", name) + } + return b, nil + } +} + +// coerceString converts a decoded scalar to its string form. Decoders may yield +// strings directly or numeric/boolean scalars for loosely-typed documents. +func coerceString(val any) (string, bool) { + switch v := val.(type) { + case nil: + return "", false + case string: + return v, true + case int64: + return strconv.FormatInt(v, 10), true + case uint64: + return strconv.FormatUint(v, 10), true + case float64: + if math.Trunc(v) == v && !math.IsInf(v, 0) { + return strconv.FormatInt(int64(v), 10), true + } + return strconv.FormatFloat(v, 'f', -1, 64), true + case bool: + return strconv.FormatBool(v), true + default: + return "", false + } +} + +// coerceInt64 converts a decoded scalar to int64. Both decoders may represent +// integers as uint64 (goccy/go-yaml) or float64 (encoding/json). +func coerceInt64(val any) (int64, bool) { + switch v := val.(type) { + case nil: + return 0, false + case int64: + return v, true + case uint64: + if v > math.MaxInt64 { + return 0, false + } + return int64(v), true + case float64: + if v != math.Trunc(v) || v < math.MinInt64 || v > math.MaxInt64 { + return 0, false + } + return int64(v), true + default: + return 0, false + } +} + +// coerceFloat64 converts a decoded scalar to float64. +func coerceFloat64(val any) (float64, bool) { + switch v := val.(type) { + case nil: + return 0, false + case int64: + return float64(v), true + case uint64: + return float64(v), true + case float64: + return v, true + default: + return 0, false + } +} + +// coerceBool converts a decoded scalar to bool. Only real booleans are accepted; +// coercing numbers or strings would silently change the flag's semantics. +func coerceBool(val any) (bool, bool) { + switch v := val.(type) { + case nil: + return false, false + case bool: + return v, true + default: + return false, false } } diff --git a/codec/default_test.go b/codec/default_test.go new file mode 100644 index 0000000..05d08f5 --- /dev/null +++ b/codec/default_test.go @@ -0,0 +1,388 @@ +package codec_test + +import ( + "reflect" + "strings" + "testing" + + "github.com/bcdxn/opencli/codec" + "github.com/bcdxn/opencli/spec" +) + +// defaultTestSpecYAML builds a minimal single-command spec with one flag. The +// fragment is the body of that flag entry (name, type, variadic, default...). +func defaultTestSpecYAML(t *testing.T, frag string) []byte { + t.Helper() + + doc := strings.Join([]string{ + "opencliVersion: 1.0.0-alpha.13", + "info:", + " title: default test", + " version: 0.1.0", + " binary: deftest", + "commands:", + " root:", + " flags:", + // the fragment's continuation lines are pre-indented to align under "name:" + " - " + frag, + }, "\n") + + return []byte(doc) +} + +// defaultTestSpecJSON builds the JSON equivalent of a single-flag spec. +func defaultTestSpecJSON(t *testing.T, flagObj string) []byte { + t.Helper() + + doc := `{"opencliVersion":"1.0.0-alpha.13","info":{"title":"default test","version":"0.1.0","binary":"deftest"},"commands":{"root":{"flags":[` + flagObj + `]}}}` + + return []byte(doc) +} + +// globalFlagSpecYAML builds a minimal spec with one flag in the global section. +func globalFlagSpecYAML(t *testing.T, frag string) []byte { + t.Helper() + + doc := strings.Join([]string{ + "opencliVersion: 1.0.0-alpha.13", + "info:", + " title: default test", + " version: 0.1.0", + " binary: deftest", + "global:", + " flags:", + // the fragment's continuation lines are pre-indented to align under "name:" + " - " + frag, + "commands:", + " root: {}", + }, "\n") + + return []byte(doc) +} + +// globalFlagSpecJSON builds a minimal spec with one flag in the global section. +func globalFlagSpecJSON(t *testing.T, flagObj string) []byte { + t.Helper() + + doc := `{"opencliVersion":"1.0.0-alpha.13","info":{"title":"default test","version":"0.1.0","binary":"deftest"},"global":{"flags":[` + flagObj + `]},"commands":{"root":{}}}` + + return []byte(doc) +} + +// rootFlagDefault returns the Default value of the named flag on the root command. +func rootFlagDefault(t *testing.T, d *spec.Document, name string) any { + t.Helper() + + if d.Commands == nil || len(d.Commands.Flags) == 0 { + t.Fatal("expected a root command with flags") + } + + for _, f := range d.Commands.Flags { + if f.Name == name { + return f.Default + } + } + + t.Fatalf("flag %q not found on root command", name) + + return nil +} + +// globalFlagDefault returns the Default value of the named flag in the global section. +func globalFlagDefault(t *testing.T, d *spec.Document, name string) any { + t.Helper() + + if d.Global == nil || len(d.Global.Flags) == 0 { + t.Fatal("expected a global section with flags") + } + + for _, f := range d.Global.Flags { + if f.Name == name { + return f.Default + } + } + + t.Fatalf("flag %q not found in the global section", name) + + return nil +} + +// TestUnmarshalFlagDefaultNormalization verifies that flag default values are +// coerced to canonical Go types regardless of the source format. goccy/go-yaml +// decodes integers as uint64 while encoding/json uses float64; both must end up +// as int64 for integer flags so downstream emitters see one stable shape. +func TestUnmarshalFlagDefaultNormalization(t *testing.T) { + cases := []struct { + name string + yamlFrag string // flag fragment, e.g.: `name: n\n type: number` + jsonObj string // JSON object for the same flag + wantVal any // expected normalized value (nil means "no default") + }{ + { + name: "integer from yaml is int64", + yamlFrag: "name: n\n type: integer\n default: 42", + jsonObj: `{"name":"n","type":"integer","default":42}`, + wantVal: int64(42), + }, + { + name: "number from yaml is float64", + yamlFrag: "name: n\n type: number\n default: 3.5", + jsonObj: `{"name":"n","type":"number","default":3.5}`, + wantVal: 3.5, + }, + { + name: "string stays string", + yamlFrag: "name: n\n type: string\n default: hello", + jsonObj: `{"name":"n","type":"string","default":"hello"}`, + wantVal: "hello", + }, + { + name: "boolean stays bool", + yamlFrag: "name: n\n type: boolean\n default: true", + jsonObj: `{"name":"n","type":"boolean","default":true}`, + wantVal: true, + }, + { + name: "no default stays nil", + yamlFrag: "name: n\n type: integer", + jsonObj: `{"name":"n","type":"integer"}`, + wantVal: nil, + }, + } + + for _, tc := range cases { + t.Run(tc.name+" (yaml)", func(t *testing.T) { + d, err := codec.UnmarshalYAML(defaultTestSpecYAML(t, tc.yamlFrag)) + if err != nil { + t.Fatalf("unmarshal yaml failed: %v", err) + } + + got := rootFlagDefault(t, d, "n") + if !reflect.DeepEqual(got, tc.wantVal) { + t.Errorf("yaml default = %#v (%T), want %#v (%T)", got, got, tc.wantVal, tc.wantVal) + } + }) + + t.Run(tc.name+" (json)", func(t *testing.T) { + d, err := codec.UnmarshalJSON(defaultTestSpecJSON(t, tc.jsonObj)) + if err != nil { + t.Fatalf("unmarshal json failed: %v", err) + } + + got := rootFlagDefault(t, d, "n") + if !reflect.DeepEqual(got, tc.wantVal) { + t.Errorf("json default = %#v (%T), want %#v (%T)", got, got, tc.wantVal, tc.wantVal) + } + }) + } +} + +// TestUnmarshalVariadicFlagDefaultNormalization covers list defaults on +// variadic flags. The schema currently rejects these (default is oneOf scalars +// only), but the codec decodes them anyway and must produce canonical typed +// slices rather than decoder-specific []interface{} shapes that panic in the +// emitters. +func TestUnmarshalVariadicFlagDefaultNormalization(t *testing.T) { + cases := []struct { + name string + yamlFrag string + jsonObj string + wantVal any + }{ + { + name: "integer list", + yamlFrag: "name: n\n type: integer\n variadic: true\n default:\n - 1\n - 2\n - 3", + jsonObj: `{"name":"n","type":"integer","variadic":true,"default":[1,2,3]}`, + wantVal: []int64{1, 2, 3}, + }, + { + name: "number list", + yamlFrag: "name: n\n type: number\n variadic: true\n default:\n - 1.5\n - 2.5", + jsonObj: `{"name":"n","type":"number","variadic":true,"default":[1.5,2.5]}`, + wantVal: []float64{1.5, 2.5}, + }, + { + name: "string list", + yamlFrag: "name: n\n type: string\n variadic: true\n default:\n - a\n - b", + jsonObj: `{"name":"n","type":"string","variadic":true,"default":["a","b"]}`, + wantVal: []string{"a", "b"}, + }, + { + name: "boolean list", + yamlFrag: "name: n\n type: boolean\n variadic: true\n default:\n - true\n - false", + jsonObj: `{"name":"n","type":"boolean","variadic":true,"default":[true,false]}`, + wantVal: []bool{true, false}, + }, + } + + for _, tc := range cases { + t.Run(tc.name+" (yaml)", func(t *testing.T) { + d, err := codec.UnmarshalYAML(defaultTestSpecYAML(t, tc.yamlFrag)) + if err != nil { + t.Fatalf("unmarshal yaml failed: %v", err) + } + + got := rootFlagDefault(t, d, "n") + if !reflect.DeepEqual(got, tc.wantVal) { + t.Errorf("yaml default = %#v (%T), want %#v (%T)", got, got, tc.wantVal, tc.wantVal) + } + }) + + t.Run(tc.name+" (json)", func(t *testing.T) { + d, err := codec.UnmarshalJSON(defaultTestSpecJSON(t, tc.jsonObj)) + if err != nil { + t.Fatalf("unmarshal json failed: %v", err) + } + + got := rootFlagDefault(t, d, "n") + if !reflect.DeepEqual(got, tc.wantVal) { + t.Errorf("json default = %#v (%T), want %#v (%T)", got, got, tc.wantVal, tc.wantVal) + } + }) + } +} + +// TestUnmarshalFlagDefaultTypeMismatch verifies that a default value which +// cannot be represented as the flag's declared type is reported at decode time +// instead of being silently dropped or mis-emitted downstream. +func TestUnmarshalFlagDefaultTypeMismatch(t *testing.T) { + cases := []struct { + name string + yamlFrag string + jsonObj string + }{ + { + name: "string default on integer flag", + yamlFrag: "name: n\n type: integer\n default: not-a-number", + jsonObj: `{"name":"n","type":"integer","default":"not-a-number"}`, + }, + { + name: "string default on boolean flag", + yamlFrag: "name: n\n type: boolean\n default: maybe", + jsonObj: `{"name":"n","type":"boolean","default":"maybe"}`, + }, + } + + for _, tc := range cases { + t.Run(tc.name+" (yaml)", func(t *testing.T) { + if _, err := codec.UnmarshalYAML(defaultTestSpecYAML(t, tc.yamlFrag)); err == nil { + t.Error("expected an error for a type-mismatched flag default, got nil") + } else if !strings.Contains(err.Error(), `flag "n"`) { + t.Errorf("error should name the offending flag: %v", err) + } + }) + + t.Run(tc.name+" (json)", func(t *testing.T) { + if _, err := codec.UnmarshalJSON(defaultTestSpecJSON(t, tc.jsonObj)); err == nil { + t.Error("expected an error for a type-mismatched flag default, got nil") + } else if !strings.Contains(err.Error(), `flag "n"`) { + t.Errorf("error should name the offending flag: %v", err) + } + }) + } +} + +// TestUnmarshalGlobalFlagDefaultNormalization verifies that global flag default +// values are coerced to canonical Go types just like command-level flags. Global +// flags live outside the command Trie, so they must be normalized explicitly at +// decode time; otherwise decoder-native types (uint64 from YAML, float64 from +// JSON) leak into code generation and produce wrong or uncompilable defaults. +func TestUnmarshalGlobalFlagDefaultNormalization(t *testing.T) { + cases := []struct { + name string + yamlFrag string // flag fragment, e.g.: `name: n\n type: number` + jsonObj string // JSON object for the same flag + wantVal any // expected normalized value (nil means "no default") + }{ + { + name: "integer from yaml is int64", + yamlFrag: "name: n\n type: integer\n default: 30", + jsonObj: `{"name":"n","type":"integer","default":30}`, + wantVal: int64(30), + }, + { + name: "number from yaml is float64", + yamlFrag: "name: n\n type: number\n default: 2.5", + jsonObj: `{"name":"n","type":"number","default":2.5}`, + wantVal: 2.5, + }, + { + name: "string stays string", + yamlFrag: "name: n\n type: string\n default: hello", + jsonObj: `{"name":"n","type":"string","default":"hello"}`, + wantVal: "hello", + }, + { + name: "boolean stays bool", + yamlFrag: "name: n\n type: boolean\n default: true", + jsonObj: `{"name":"n","type":"boolean","default":true}`, + wantVal: true, + }, + { + name: "no default stays nil", + yamlFrag: "name: n\n type: integer", + jsonObj: `{"name":"n","type":"integer"}`, + wantVal: nil, + }, + } + + for _, tc := range cases { + t.Run(tc.name+" (yaml)", func(t *testing.T) { + d, err := codec.UnmarshalYAML(globalFlagSpecYAML(t, tc.yamlFrag)) + if err != nil { + t.Fatalf("unmarshal yaml failed: %v", err) + } + + got := globalFlagDefault(t, d, "n") + if !reflect.DeepEqual(got, tc.wantVal) { + t.Errorf("yaml default = %#v (%T), want %#v (%T)", got, got, tc.wantVal, tc.wantVal) + } + }) + + t.Run(tc.name+" (json)", func(t *testing.T) { + d, err := codec.UnmarshalJSON(globalFlagSpecJSON(t, tc.jsonObj)) + if err != nil { + t.Fatalf("unmarshal json failed: %v", err) + } + + got := globalFlagDefault(t, d, "n") + if !reflect.DeepEqual(got, tc.wantVal) { + t.Errorf("json default = %#v (%T), want %#v (%T)", got, got, tc.wantVal, tc.wantVal) + } + }) + } +} + +// TestUnmarshalGlobalFlagDefaultTypeMismatch verifies that a global flag whose +// default cannot be represented as its declared type is reported at decode time. +func TestUnmarshalGlobalFlagDefaultTypeMismatch(t *testing.T) { + cases := []struct { + name string + yamlFrag string + jsonObj string + }{ + { + name: "string default on integer flag", + yamlFrag: "name: n\n type: integer\n default: not-a-number", + jsonObj: `{"name":"n","type":"integer","default":"not-a-number"}`, + }, + } + + for _, tc := range cases { + t.Run(tc.name+" (yaml)", func(t *testing.T) { + if _, err := codec.UnmarshalYAML(globalFlagSpecYAML(t, tc.yamlFrag)); err == nil { + t.Error("expected an error for a type-mismatched global flag default, got nil") + } else if !strings.Contains(err.Error(), `flag "n"`) { + t.Errorf("error should name the offending flag: %v", err) + } + }) + + t.Run(tc.name+" (json)", func(t *testing.T) { + if _, err := codec.UnmarshalJSON(globalFlagSpecJSON(t, tc.jsonObj)); err == nil { + t.Error("expected an error for a type-mismatched global flag default, got nil") + } else if !strings.Contains(err.Error(), `flag "n"`) { + t.Errorf("error should name the offending flag: %v", err) + } + }) + } +} diff --git a/docs/actions-implementation.md b/docs/actions-implementation.md new file mode 100644 index 0000000..6f89dfc --- /dev/null +++ b/docs/actions-implementation.md @@ -0,0 +1,143 @@ +# Implementing an `ActionsInterface` command function + +When you generate a CLI from an OpenCLI spec, codegen emits an `ActionsInterface` whose methods are the real work of your program. This document shows how to read the three kinds of data available inside one such implementation: + +- **positional args**: passed as a generated `Args` struct parameter (method name + `Args`) +- **command flags**: passed as a generated `Flags` struct parameter (method name + `Flags`) +- **global (root-level) flags**: _not_ a method parameter; retrieved from the + `context.Context` in Go frameworks, or via module accessors in Yargs + +The examples below are based on this spec fragment: + +```yaml +info: + binary: example +global: + flags: + - name: debug + aliases: [v] + type: boolean + - name: timeout + type: integer + default: 30 +commands: + # Root group; the binary segment is part of every method name. + example {command} [flags]: + kind: group + + example cmd --count [flags]: + args: + - name: recipient + type: string + flags: + - name: count + type: integer +``` + +Each leaf gets one method named by PascalCase-joining every path segment _including the binary_ — so `example cmd` becomes `ExampleCmd`, with parameter types `ExampleCmdArgs` / `ExampleCmdFlags`. In Go (identical for Cobra and urfave/cli v3): + +```go +type ActionsInterface interface { + ExampleCmd(ctx context.Context, args ExampleCmdArgs, flags ExampleCmdFlags) error + // other actions... +} +``` + +and its parameter types: + +```go +type ExampleCmdArgs struct { + Recipient string +} + +type ExampleCmdFlags struct { + Count int64 +} + +type GlobalFlags struct { + Debug bool + Timeout int64 +} +``` + +## Go (Cobra & urfave/cli v3) — global flags on the context + +The generated command handler builds a `GlobalFlags` value and injects it into the context before calling your action, so you never receive globals as an argument. Instead, retrieve them with the exported helper from the same package: + +```go +// ExampleCmd implements gencli.ActionsInterface for the "example cmd" leaf. +func (a *app) ExampleCmd( + ctx context.Context, + args gencli.ExampleCmdArgs, + flags gencli.ExampleCmdFlags, +) error { + // Positional arguments arrive as a struct parameter. + recipient := args.Recipient + // Command-level flags also arrive as a struct parameter. + count := flags.Count + // Global flags are NOT a method parameter — read them from ctx. + global := gencli.GlobalFlagsFromContext(ctx) + if global.Debug { + fmt.Fprintf(a.IOStreams().Err, "sending %d message(s) to %s\n", count, recipient) + } + timeout := time.Duration(global.Timeout) * time.Second + + // do stuff... + return nil +} +``` + +Notes: + +- `GlobalFlagsFromContext` returns zero values if no globals were set (e.g. when calling an action directly from a test without wrapping the context). If you need to distinguish "absent" from "zero", wrap it yourself with `gencli.WithGlobalFlags(ctx, g)` in tests — that's what the generated handler does at runtime. +- The parameter types and context helpers all live in the _generated_ package (in its params file). Your implementation lives elsewhere, so everything is referenced through that package's import name — `gencli.ExampleCmdArgs`, `gencli.GlobalFlagsFromContext(ctx)`, etc. + +## TypeScript (Yargs) — module-level accessors + +Yargs has no context object, so codegen uses an exported pair of accessor functions instead. The generated handler calls `setGlobalFlags(...)` immediately before invoking your action; inside the action you read them with `getGlobalFlags()`: + +```ts +// actions.ts and params.ts are generated side by side; import both. +import type { ActionsInterface } from "./actions"; +import { + getGlobalFlags, + type ExampleCmdArgs, + type ExampleCmdFlags, +} from "./params"; + +export class App implements ActionsInterface { + async ExampleCmd( + args: ExampleCmdArgs, + flags: ExampleCmdFlags, + ): Promise { + // Positional arguments arrive as a struct parameter (fields may be undefined). + const recipient = args.recipient; + // Command-level flags also arrive as a struct parameter. + const count = flags.count ?? 1; // number | undefined — apply your own fallback + // Global flags come from the module accessor, not the method signature. + const global = getGlobalFlags(); + if (global.debug) { + console.error(`sending ${count} message(s) to ${recipient}`); + } + const timeoutMs = (global.timeout ?? 30) * 1000; + + // do stuff... + } +} +``` + +Notes: + +- Yargs parameter fields are typed `T | undefined` even for required args, so + guard with `??` or an explicit check rather than assuming presence. +- The accessor is a module-level singleton set per invocation by the generated + handler — it's safe under normal sequential CLI use (the same pattern codegen + already uses for config loading). + +## Quick reference + +| Data | Go | JS/TS | +| --------------- | -------------------------------------- | ---------------------------------- | +| Positional args | `args Args` method parameter | `args: Args` parameter | +| Command flags | `flags Flags` method parameter | `flags: Flags` parameter | +| Global flags | `gencli.GlobalFlagsFromContext(ctx)` | `getGlobalFlags()` from `./params` | diff --git a/docs/opencli.ocs.md b/docs/ocli.ocs.md similarity index 100% rename from docs/opencli.ocs.md rename to docs/ocli.ocs.md diff --git a/examples/code/README.md b/examples/code/README.md index 15ea48f..5e7d0b2 100644 --- a/examples/code/README.md +++ b/examples/code/README.md @@ -2,26 +2,32 @@ These examples contain code generated by OpenCLI along with minimal implementations required for the corresponding `ActionInterface` -## Cobra (Go) +To regenerate code you can run: ```sh -# from repository root -ocli gen cli \ - --framework cobra \ - --out ./examples/code/cobra/pleasantries/internal \ - ./examples/pleasantries-cli.ocs.yaml +make gen-examples ``` -Files output to `examples/code/cobra/pleasantries` +You will see the examples for each supported framework: -## Yargs (Typescript) +#### Cobra (Go) ```sh -# from repository root -ocli gen cli \ - --framework yargs \ - --out ./examples/code/yargs/pleasantries/src \ - ./examples/pleasantries-cli.ocs.yaml +$ cd ./examples/code/cobra/pleasantries +$ GOWORK=off go run main.go --help ``` -Files output to `examples/code/yargs/pleasantries` +#### urfave/cli (Go) + +```sh +$ cd ./examples/code/urfavecli/pleasantries +$ GOWORK=off go run main.go --help +``` + +#### Yargs (Typescript) + +```sh +$ cd ./examples/code/yargs/pleasantries +$ npm i +$ npm run dev -- --help +``` diff --git a/examples/code/cobra/pleasantries/internal/gencli/run.go b/examples/code/cobra/pleasantries/internal/gencli/run.gen.go similarity index 100% rename from examples/code/cobra/pleasantries/internal/gencli/run.go rename to examples/code/cobra/pleasantries/internal/gencli/run.gen.go diff --git a/examples/code/gencli/actions.gen.go b/examples/code/gencli/actions.gen.go deleted file mode 100644 index 85dd8f7..0000000 --- a/examples/code/gencli/actions.gen.go +++ /dev/null @@ -1,36 +0,0 @@ -// Code generated by github.com/bcdxn/opencli@unknown version DO NOT EDIT. -package gencli - -import ( - "context" - - "github.com/bcdxn/opencli/spec" -) - -// ActionsInterface defines all actions the petstore CLI supports. -type ActionsInterface interface { - PetstoreList(ctx context.Context, args PetstoreListArgs) error - PetstorePetAdd(ctx context.Context, args PetstorePetAddArgs, flags PetstorePetAddFlags) error - PetstorePetUpdate(ctx context.Context, args PetstorePetUpdateArgs, flags PetstorePetUpdateFlags) error - PetstorePetFindByStatus(ctx context.Context, flags PetstorePetFindByStatusFlags) error - PetstorePetFindByTags(ctx context.Context, flags PetstorePetFindByTagsFlags) error - PetstorePetGet(ctx context.Context, flags PetstorePetGetFlags) error - PetstorePetUpdateForm(ctx context.Context, flags PetstorePetUpdateFormFlags) error - PetstorePetDelete(ctx context.Context, flags PetstorePetDeleteFlags) error - PetstorePetUploadImage(ctx context.Context, args PetstorePetUploadImageArgs, flags PetstorePetUploadImageFlags) error - PetstoreStoreInventory(ctx context.Context) error - PetstoreStoreOrderPlace(ctx context.Context, args PetstoreStoreOrderPlaceArgs, flags PetstoreStoreOrderPlaceFlags) error - PetstoreStoreOrderGet(ctx context.Context, flags PetstoreStoreOrderGetFlags) error - PetstoreStoreOrderDelete(ctx context.Context, args PetstoreStoreOrderDeleteArgs) error - PetstoreUserCreate(ctx context.Context, args PetstoreUserCreateArgs, flags PetstoreUserCreateFlags) error - PetstoreUserCreateWithList(ctx context.Context, args PetstoreUserCreateWithListArgs) error - PetstoreUserLogin(ctx context.Context, flags PetstoreUserLoginFlags) error - PetstoreUserLogout(ctx context.Context) error - PetstoreUserGet(ctx context.Context, flags PetstoreUserGetFlags) error - PetstoreUserUpdate(ctx context.Context, flags PetstoreUserUpdateFlags) error - PetstoreUserDelete(ctx context.Context, flags PetstoreUserDeleteFlags) error - HelpFunc(cmd *spec.CommandItem) - UsageFunc(cmd *spec.CommandItem) error - IOStreams() IOStreams - Version() string -} diff --git a/examples/code/gencli/cmd_petstore.gen.go b/examples/code/gencli/cmd_petstore.gen.go deleted file mode 100644 index d1b0985..0000000 --- a/examples/code/gencli/cmd_petstore.gen.go +++ /dev/null @@ -1,62 +0,0 @@ -// Code generated by github.com/bcdxn/opencli@unknown version DO NOT EDIT. -package gencli - -import ( - "github.com/bcdxn/opencli/spec" - "github.com/spf13/cobra" -) - -func NewCmdPetstore(a ActionsInterface) *cobra.Command { - command := &cobra.Command{ - Use: "petstore", - Short: "An example CLI Document describing operations a petstore CLI may provide.", - Long: "`petstore` is an example command line interface designed to make working with the\n[PetStore API](https://petstore3.swagger.io) easier. It provides a number of\ncapabilities, including:\n\n- validating request parameters\n- executing API requests\n- parsing the API response\n- handling API errors\n\nThe commands are documented below. You can also find out more about each\ncommand using the contextual `--help` flag. e.g.:\n\n```sh\npetstore --help\n```\n", - Args: cobra.NoArgs, - RunE: func(c *cobra.Command, args []string) error { - return BadUserInput("subcommand is required", func() error { - return a.UsageFunc(getSpecPetstoreCmd()) - }) - }, - } - command.SilenceErrors = true - command.SilenceUsage = true - command.AddCommand(NewCmdPetstoreList(a)) - command.AddCommand(NewCmdPetstorePet(a)) - command.AddCommand(NewCmdPetstoreStore(a)) - command.AddCommand(NewCmdPetstoreUser(a)) - command.SetHelpFunc(func(_ *cobra.Command, _ []string) { - a.HelpFunc(getSpecPetstoreCmd()) - }) - command.SetUsageFunc(func(_ *cobra.Command) error { - return a.UsageFunc(getSpecPetstoreCmd()) - }) - - return command -} - -func getSpecPetstoreCmd() *spec.CommandItem { - return &spec.CommandItem{ - Segment: "petstore", - CommandLine: "petstore", - Summary: "An example CLI Document describing operations a petstore CLI may provide.", - Description: "`petstore` is an example command line interface designed to make working with the\n[PetStore API](https://petstore3.swagger.io) easier. It provides a number of\ncapabilities, including:\n\n- validating request parameters\n- executing API requests\n- parsing the API response\n- handling API errors\n\nThe commands are documented below. You can also find out more about each\ncommand using the contextual `--help` flag. e.g.:\n\n```sh\npetstore --help\n```\n", - VisibleChildren: true, - VisibleArgs: false, - VisibleFlags: false, - CommandModifiers: []string{ - "{command}", - }, - ArgsModifiers: []string{ - "", - }, - FlagsModifiers: []string{ - "[flags]", - }, - Commands: []*spec.CommandItem{ - {Segment: "list", Summary: "List all endpoints available"}, - {Segment: "pet", Summary: "A collection of commands for managing pets"}, - {Segment: "store", Summary: "A collection of commands for store operations"}, - {Segment: "user", Summary: ""}, - }, - } -} diff --git a/examples/code/gencli/cmd_petstore_list.gen.go b/examples/code/gencli/cmd_petstore_list.gen.go deleted file mode 100644 index 19c602c..0000000 --- a/examples/code/gencli/cmd_petstore_list.gen.go +++ /dev/null @@ -1,51 +0,0 @@ -// Code generated by github.com/bcdxn/opencli@unknown version DO NOT EDIT. -package gencli - -import ( - "github.com/bcdxn/opencli/spec" - "github.com/spf13/cobra" -) - -func NewCmdPetstoreList(a ActionsInterface) *cobra.Command { - command := &cobra.Command{ - Use: "list", - Short: "List all endpoints available", - Long: "", - RunE: func(c *cobra.Command, args []string) error { - cmdArgs := PetstoreListArgs{} - if len(args) > 0 { - cmdArgs.HttpArguments = args[0] - } - return a.PetstoreList(c.Context(), cmdArgs) - }, - } - command.SilenceErrors = true - command.SilenceUsage = true - command.SetHelpFunc(func(_ *cobra.Command, _ []string) { - a.HelpFunc(getSpecPetstoreListCmd()) - }) - command.SetUsageFunc(func(_ *cobra.Command) error { - return a.UsageFunc(getSpecPetstoreListCmd()) - }) - - return command -} - -func getSpecPetstoreListCmd() *spec.CommandItem { - return &spec.CommandItem{ - Segment: "list", - CommandLine: "petstore list", - Summary: "List all endpoints available", - Description: "", - VisibleChildren: false, - VisibleArgs: false, - VisibleFlags: false, - PassthroughArgsModifiers: []string{ - "--", - "", - }, - Args: []spec.ArgumentItem{ - {Name: "http-arguments", Summary: "additional arguments to pass to underlying HTTP client"}, - }, - } -} diff --git a/examples/code/gencli/cmd_petstore_pet.gen.go b/examples/code/gencli/cmd_petstore_pet.gen.go deleted file mode 100644 index 5eea47f..0000000 --- a/examples/code/gencli/cmd_petstore_pet.gen.go +++ /dev/null @@ -1,70 +0,0 @@ -// Code generated by github.com/bcdxn/opencli@unknown version DO NOT EDIT. -package gencli - -import ( - "github.com/bcdxn/opencli/spec" - "github.com/spf13/cobra" -) - -func NewCmdPetstorePet(a ActionsInterface) *cobra.Command { - command := &cobra.Command{ - Use: "pet", - Short: "A collection of commands for managing pets", - Long: "", - Args: cobra.NoArgs, - RunE: func(c *cobra.Command, args []string) error { - return BadUserInput("subcommand is required", func() error { - return a.UsageFunc(getSpecPetstorePetCmd()) - }) - }, - } - command.SilenceErrors = true - command.SilenceUsage = true - command.AddCommand(NewCmdPetstorePetAdd(a)) - command.AddCommand(NewCmdPetstorePetUpdate(a)) - command.AddCommand(NewCmdPetstorePetFindByStatus(a)) - command.AddCommand(NewCmdPetstorePetFindByTags(a)) - command.AddCommand(NewCmdPetstorePetGet(a)) - command.AddCommand(NewCmdPetstorePetUpdateForm(a)) - command.AddCommand(NewCmdPetstorePetDelete(a)) - command.AddCommand(NewCmdPetstorePetUploadImage(a)) - command.SetHelpFunc(func(_ *cobra.Command, _ []string) { - a.HelpFunc(getSpecPetstorePetCmd()) - }) - command.SetUsageFunc(func(_ *cobra.Command) error { - return a.UsageFunc(getSpecPetstorePetCmd()) - }) - - return command -} - -func getSpecPetstorePetCmd() *spec.CommandItem { - return &spec.CommandItem{ - Segment: "pet", - CommandLine: "petstore pet", - Summary: "A collection of commands for managing pets", - Description: "", - VisibleChildren: true, - VisibleArgs: false, - VisibleFlags: false, - CommandModifiers: []string{ - "{command}", - }, - ArgsModifiers: []string{ - "", - }, - FlagsModifiers: []string{ - "[flags]", - }, - Commands: []*spec.CommandItem{ - {Segment: "add", Summary: "Add a new pet to the store"}, - {Segment: "update", Summary: "Update an existing pet"}, - {Segment: "find-by-status", Summary: "Find pets by status"}, - {Segment: "find-by-tags", Summary: "Find pets by tags"}, - {Segment: "get", Summary: "Find pet by ID"}, - {Segment: "update-form", Summary: "Update a pet using form data"}, - {Segment: "delete", Summary: "Delete a pet"}, - {Segment: "upload-image", Summary: "Upload an image for a pet"}, - }, - } -} diff --git a/examples/code/gencli/cmd_petstore_pet_add.gen.go b/examples/code/gencli/cmd_petstore_pet_add.gen.go deleted file mode 100644 index f9c4cf9..0000000 --- a/examples/code/gencli/cmd_petstore_pet_add.gen.go +++ /dev/null @@ -1,78 +0,0 @@ -// Code generated by github.com/bcdxn/opencli@unknown version DO NOT EDIT. -package gencli - -import ( - "github.com/bcdxn/opencli/spec" - "github.com/spf13/cobra" -) - -func NewCmdPetstorePetAdd(a ActionsInterface) *cobra.Command { - var flagName string - var flagPhotoUrls []string - var flagStatus string - var flagTag []string - command := &cobra.Command{ - Use: "add", - Short: "Add a new pet to the store", - Long: "Create a new pet in the store using a JSON payload or explicit flags.", - RunE: func(c *cobra.Command, args []string) error { - cmdArgs := PetstorePetAddArgs{} - if len(args) > 0 { - cmdArgs.PathToReqBody = args[0] - } - cmdFlags := PetstorePetAddFlags{ - Name: flagName, - PhotoUrls: flagPhotoUrls, - Status: PetstorePetAddStatus(flagStatus), - Tag: flagTag, - } - if cmdFlags.Status != "" && !cmdFlags.Status.IsValid() { - return BadUserInput("invalid value for --status flag: "+string(cmdFlags.Status), func() error { - return a.UsageFunc(getSpecPetstorePetAddCmd()) - }) - } - return a.PetstorePetAdd(c.Context(), cmdArgs, cmdFlags) - }, - } - command.SilenceErrors = true - command.SilenceUsage = true - command.Flags().StringVarP(&flagName, "name", "n", "", "The name of the pet") - command.Flags().StringArrayVarP(&flagPhotoUrls, "photo-urls", "p", []string{}, "A list of photo URLs to display for the pet") - command.Flags().StringVarP(&flagStatus, "status", "", "", "The pet status in the store") - command.Flags().StringArrayVarP(&flagTag, "tag", "", []string{}, "Tag to assign to the pet for grouping/sorting") - command.SetHelpFunc(func(_ *cobra.Command, _ []string) { - a.HelpFunc(getSpecPetstorePetAddCmd()) - }) - command.SetUsageFunc(func(_ *cobra.Command) error { - return a.UsageFunc(getSpecPetstorePetAddCmd()) - }) - - return command -} - -func getSpecPetstorePetAddCmd() *spec.CommandItem { - return &spec.CommandItem{ - Segment: "add", - CommandLine: "petstore pet add", - Summary: "Add a new pet to the store", - Description: "Create a new pet in the store using a JSON payload or explicit flags.", - VisibleChildren: false, - VisibleArgs: true, - VisibleFlags: true, - ArgsModifiers: []string{ - "", - }, - FlagsModifiers: []string{ - "[flags]", - }, - Args: []spec.ArgumentItem{ - {Name: "path-to-req-body", Summary: "The path to a JSON file containing the new pet payload"}, - }, - Flags: []spec.FlagItem{ - {Name: "name", Summary: "The name of the pet"}, - {Name: "photo-urls", Summary: "A list of photo URLs to display for the pet"}, - {Name: "status", Summary: "The pet status in the store"}, - {Name: "tag", Summary: "Tag to assign to the pet for grouping/sorting"}, - }, - } -} diff --git a/examples/code/gencli/cmd_petstore_pet_delete.gen.go b/examples/code/gencli/cmd_petstore_pet_delete.gen.go deleted file mode 100644 index 3c8f7ec..0000000 --- a/examples/code/gencli/cmd_petstore_pet_delete.gen.go +++ /dev/null @@ -1,55 +0,0 @@ -// Code generated by github.com/bcdxn/opencli@unknown version DO NOT EDIT. -package gencli - -import ( - "github.com/bcdxn/opencli/spec" - "github.com/spf13/cobra" -) - -func NewCmdPetstorePetDelete(a ActionsInterface) *cobra.Command { - var flagId int64 - var flagApiKey string - command := &cobra.Command{ - Use: "delete", - Short: "Delete a pet", - Long: "", - RunE: func(c *cobra.Command, args []string) error { - cmdFlags := PetstorePetDeleteFlags{ - Id: flagId, - ApiKey: flagApiKey, - } - return a.PetstorePetDelete(c.Context(), cmdFlags) - }, - } - command.SilenceErrors = true - command.SilenceUsage = true - command.Flags().Int64VarP(&flagId, "id", "", 0, "The ID of the pet to delete") - command.Flags().StringVarP(&flagApiKey, "api-key", "", "", "API key header used to authorize the delete request") - command.SetHelpFunc(func(_ *cobra.Command, _ []string) { - a.HelpFunc(getSpecPetstorePetDeleteCmd()) - }) - command.SetUsageFunc(func(_ *cobra.Command) error { - return a.UsageFunc(getSpecPetstorePetDeleteCmd()) - }) - - return command -} - -func getSpecPetstorePetDeleteCmd() *spec.CommandItem { - return &spec.CommandItem{ - Segment: "delete", - CommandLine: "petstore pet delete", - Summary: "Delete a pet", - Description: "", - VisibleChildren: false, - VisibleArgs: false, - VisibleFlags: true, - FlagsModifiers: []string{ - "[flags]", - }, - Flags: []spec.FlagItem{ - {Name: "id", Summary: "The ID of the pet to delete"}, - {Name: "api-key", Summary: "API key header used to authorize the delete request"}, - }, - } -} diff --git a/examples/code/gencli/cmd_petstore_pet_findbystatus.gen.go b/examples/code/gencli/cmd_petstore_pet_findbystatus.gen.go deleted file mode 100644 index 07781f5..0000000 --- a/examples/code/gencli/cmd_petstore_pet_findbystatus.gen.go +++ /dev/null @@ -1,56 +0,0 @@ -// Code generated by github.com/bcdxn/opencli@unknown version DO NOT EDIT. -package gencli - -import ( - "github.com/bcdxn/opencli/spec" - "github.com/spf13/cobra" -) - -func NewCmdPetstorePetFindByStatus(a ActionsInterface) *cobra.Command { - var flagStatus string - command := &cobra.Command{ - Use: "find-by-status", - Short: "Find pets by status", - Long: "", - RunE: func(c *cobra.Command, args []string) error { - cmdFlags := PetstorePetFindByStatusFlags{ - Status: PetstorePetFindByStatusStatus(flagStatus), - } - if cmdFlags.Status != "" && !cmdFlags.Status.IsValid() { - return BadUserInput("invalid value for --status flag: "+string(cmdFlags.Status), func() error { - return a.UsageFunc(getSpecPetstorePetFindByStatusCmd()) - }) - } - return a.PetstorePetFindByStatus(c.Context(), cmdFlags) - }, - } - command.SilenceErrors = true - command.SilenceUsage = true - command.Flags().StringVarP(&flagStatus, "status", "", "", "The status to filter pets by") - command.SetHelpFunc(func(_ *cobra.Command, _ []string) { - a.HelpFunc(getSpecPetstorePetFindByStatusCmd()) - }) - command.SetUsageFunc(func(_ *cobra.Command) error { - return a.UsageFunc(getSpecPetstorePetFindByStatusCmd()) - }) - - return command -} - -func getSpecPetstorePetFindByStatusCmd() *spec.CommandItem { - return &spec.CommandItem{ - Segment: "find-by-status", - CommandLine: "petstore pet find-by-status", - Summary: "Find pets by status", - Description: "", - VisibleChildren: false, - VisibleArgs: false, - VisibleFlags: true, - FlagsModifiers: []string{ - "[flags]", - }, - Flags: []spec.FlagItem{ - {Name: "status", Summary: "The status to filter pets by"}, - }, - } -} diff --git a/examples/code/gencli/cmd_petstore_pet_findbytags.gen.go b/examples/code/gencli/cmd_petstore_pet_findbytags.gen.go deleted file mode 100644 index 4303d92..0000000 --- a/examples/code/gencli/cmd_petstore_pet_findbytags.gen.go +++ /dev/null @@ -1,51 +0,0 @@ -// Code generated by github.com/bcdxn/opencli@unknown version DO NOT EDIT. -package gencli - -import ( - "github.com/bcdxn/opencli/spec" - "github.com/spf13/cobra" -) - -func NewCmdPetstorePetFindByTags(a ActionsInterface) *cobra.Command { - var flagTags []string - command := &cobra.Command{ - Use: "find-by-tags", - Short: "Find pets by tags", - Long: "", - RunE: func(c *cobra.Command, args []string) error { - cmdFlags := PetstorePetFindByTagsFlags{ - Tags: flagTags, - } - return a.PetstorePetFindByTags(c.Context(), cmdFlags) - }, - } - command.SilenceErrors = true - command.SilenceUsage = true - command.Flags().StringArrayVarP(&flagTags, "tags", "", []string{}, "The tags to filter pets by") - command.SetHelpFunc(func(_ *cobra.Command, _ []string) { - a.HelpFunc(getSpecPetstorePetFindByTagsCmd()) - }) - command.SetUsageFunc(func(_ *cobra.Command) error { - return a.UsageFunc(getSpecPetstorePetFindByTagsCmd()) - }) - - return command -} - -func getSpecPetstorePetFindByTagsCmd() *spec.CommandItem { - return &spec.CommandItem{ - Segment: "find-by-tags", - CommandLine: "petstore pet find-by-tags", - Summary: "Find pets by tags", - Description: "", - VisibleChildren: false, - VisibleArgs: false, - VisibleFlags: true, - FlagsModifiers: []string{ - "[flags]", - }, - Flags: []spec.FlagItem{ - {Name: "tags", Summary: "The tags to filter pets by"}, - }, - } -} diff --git a/examples/code/gencli/cmd_petstore_pet_get.gen.go b/examples/code/gencli/cmd_petstore_pet_get.gen.go deleted file mode 100644 index 85e8c6e..0000000 --- a/examples/code/gencli/cmd_petstore_pet_get.gen.go +++ /dev/null @@ -1,51 +0,0 @@ -// Code generated by github.com/bcdxn/opencli@unknown version DO NOT EDIT. -package gencli - -import ( - "github.com/bcdxn/opencli/spec" - "github.com/spf13/cobra" -) - -func NewCmdPetstorePetGet(a ActionsInterface) *cobra.Command { - var flagId int64 - command := &cobra.Command{ - Use: "get", - Short: "Find pet by ID", - Long: "", - RunE: func(c *cobra.Command, args []string) error { - cmdFlags := PetstorePetGetFlags{ - Id: flagId, - } - return a.PetstorePetGet(c.Context(), cmdFlags) - }, - } - command.SilenceErrors = true - command.SilenceUsage = true - command.Flags().Int64VarP(&flagId, "id", "", 0, "The ID of the pet to retrieve") - command.SetHelpFunc(func(_ *cobra.Command, _ []string) { - a.HelpFunc(getSpecPetstorePetGetCmd()) - }) - command.SetUsageFunc(func(_ *cobra.Command) error { - return a.UsageFunc(getSpecPetstorePetGetCmd()) - }) - - return command -} - -func getSpecPetstorePetGetCmd() *spec.CommandItem { - return &spec.CommandItem{ - Segment: "get", - CommandLine: "petstore pet get", - Summary: "Find pet by ID", - Description: "", - VisibleChildren: false, - VisibleArgs: false, - VisibleFlags: true, - FlagsModifiers: []string{ - "[flags]", - }, - Flags: []spec.FlagItem{ - {Name: "id", Summary: "The ID of the pet to retrieve"}, - }, - } -} diff --git a/examples/code/gencli/cmd_petstore_pet_update.gen.go b/examples/code/gencli/cmd_petstore_pet_update.gen.go deleted file mode 100644 index aa40d46..0000000 --- a/examples/code/gencli/cmd_petstore_pet_update.gen.go +++ /dev/null @@ -1,78 +0,0 @@ -// Code generated by github.com/bcdxn/opencli@unknown version DO NOT EDIT. -package gencli - -import ( - "github.com/bcdxn/opencli/spec" - "github.com/spf13/cobra" -) - -func NewCmdPetstorePetUpdate(a ActionsInterface) *cobra.Command { - var flagName string - var flagStatus string - var flagPhotoUrls []string - var flagTags []string - command := &cobra.Command{ - Use: "update", - Short: "Update an existing pet", - Long: "Update a pet using a JSON payload or explicit flags.", - RunE: func(c *cobra.Command, args []string) error { - cmdArgs := PetstorePetUpdateArgs{} - if len(args) > 0 { - cmdArgs.PathToReqBody = args[0] - } - cmdFlags := PetstorePetUpdateFlags{ - Name: flagName, - Status: PetstorePetUpdateStatus(flagStatus), - PhotoUrls: flagPhotoUrls, - Tags: flagTags, - } - if cmdFlags.Status != "" && !cmdFlags.Status.IsValid() { - return BadUserInput("invalid value for --status flag: "+string(cmdFlags.Status), func() error { - return a.UsageFunc(getSpecPetstorePetUpdateCmd()) - }) - } - return a.PetstorePetUpdate(c.Context(), cmdArgs, cmdFlags) - }, - } - command.SilenceErrors = true - command.SilenceUsage = true - command.Flags().StringVarP(&flagName, "name", "n", "", "The updated name of the pet") - command.Flags().StringVarP(&flagStatus, "status", "", "", "The updated pet status") - command.Flags().StringArrayVarP(&flagPhotoUrls, "photo-urls", "p", []string{}, "A list of photo URLs to display for the pet") - command.Flags().StringArrayVarP(&flagTags, "tags", "", []string{}, "Tags to assign to the pet") - command.SetHelpFunc(func(_ *cobra.Command, _ []string) { - a.HelpFunc(getSpecPetstorePetUpdateCmd()) - }) - command.SetUsageFunc(func(_ *cobra.Command) error { - return a.UsageFunc(getSpecPetstorePetUpdateCmd()) - }) - - return command -} - -func getSpecPetstorePetUpdateCmd() *spec.CommandItem { - return &spec.CommandItem{ - Segment: "update", - CommandLine: "petstore pet update", - Summary: "Update an existing pet", - Description: "Update a pet using a JSON payload or explicit flags.", - VisibleChildren: false, - VisibleArgs: true, - VisibleFlags: true, - ArgsModifiers: []string{ - "", - }, - FlagsModifiers: []string{ - "[flags]", - }, - Args: []spec.ArgumentItem{ - {Name: "path-to-req-body", Summary: "The path to a JSON file containing the updated pet payload"}, - }, - Flags: []spec.FlagItem{ - {Name: "name", Summary: "The updated name of the pet"}, - {Name: "status", Summary: "The updated pet status"}, - {Name: "photo-urls", Summary: "A list of photo URLs to display for the pet"}, - {Name: "tags", Summary: "Tags to assign to the pet"}, - }, - } -} diff --git a/examples/code/gencli/cmd_petstore_pet_updateform.gen.go b/examples/code/gencli/cmd_petstore_pet_updateform.gen.go deleted file mode 100644 index 4c07230..0000000 --- a/examples/code/gencli/cmd_petstore_pet_updateform.gen.go +++ /dev/null @@ -1,64 +0,0 @@ -// Code generated by github.com/bcdxn/opencli@unknown version DO NOT EDIT. -package gencli - -import ( - "github.com/bcdxn/opencli/spec" - "github.com/spf13/cobra" -) - -func NewCmdPetstorePetUpdateForm(a ActionsInterface) *cobra.Command { - var flagId int64 - var flagName string - var flagStatus string - command := &cobra.Command{ - Use: "update-form", - Short: "Update a pet using form data", - Long: "", - RunE: func(c *cobra.Command, args []string) error { - cmdFlags := PetstorePetUpdateFormFlags{ - Id: flagId, - Name: flagName, - Status: PetstorePetUpdateFormStatus(flagStatus), - } - if cmdFlags.Status != "" && !cmdFlags.Status.IsValid() { - return BadUserInput("invalid value for --status flag: "+string(cmdFlags.Status), func() error { - return a.UsageFunc(getSpecPetstorePetUpdateFormCmd()) - }) - } - return a.PetstorePetUpdateForm(c.Context(), cmdFlags) - }, - } - command.SilenceErrors = true - command.SilenceUsage = true - command.Flags().Int64VarP(&flagId, "id", "", 0, "The ID of the pet to update") - command.Flags().StringVarP(&flagName, "name", "", "", "The new name for the pet") - command.Flags().StringVarP(&flagStatus, "status", "", "", "The new status for the pet") - command.SetHelpFunc(func(_ *cobra.Command, _ []string) { - a.HelpFunc(getSpecPetstorePetUpdateFormCmd()) - }) - command.SetUsageFunc(func(_ *cobra.Command) error { - return a.UsageFunc(getSpecPetstorePetUpdateFormCmd()) - }) - - return command -} - -func getSpecPetstorePetUpdateFormCmd() *spec.CommandItem { - return &spec.CommandItem{ - Segment: "update-form", - CommandLine: "petstore pet update-form", - Summary: "Update a pet using form data", - Description: "", - VisibleChildren: false, - VisibleArgs: false, - VisibleFlags: true, - FlagsModifiers: []string{ - "[flags]", - }, - Flags: []spec.FlagItem{ - {Name: "id", Summary: "The ID of the pet to update"}, - {Name: "name", Summary: "The new name for the pet"}, - {Name: "status", Summary: "The new status for the pet"}, - }, - } -} diff --git a/examples/code/gencli/cmd_petstore_pet_uploadimage.gen.go b/examples/code/gencli/cmd_petstore_pet_uploadimage.gen.go deleted file mode 100644 index f256b09..0000000 --- a/examples/code/gencli/cmd_petstore_pet_uploadimage.gen.go +++ /dev/null @@ -1,65 +0,0 @@ -// Code generated by github.com/bcdxn/opencli@unknown version DO NOT EDIT. -package gencli - -import ( - "github.com/bcdxn/opencli/spec" - "github.com/spf13/cobra" -) - -func NewCmdPetstorePetUploadImage(a ActionsInterface) *cobra.Command { - var flagId int64 - var flagAdditionalMetadata string - command := &cobra.Command{ - Use: "upload-image", - Short: "Upload an image for a pet", - Long: "Upload a binary image file for the specified pet.", - RunE: func(c *cobra.Command, args []string) error { - cmdArgs := PetstorePetUploadImageArgs{} - if len(args) > 0 { - cmdArgs.PathToFile = args[0] - } - cmdFlags := PetstorePetUploadImageFlags{ - Id: flagId, - AdditionalMetadata: flagAdditionalMetadata, - } - return a.PetstorePetUploadImage(c.Context(), cmdArgs, cmdFlags) - }, - } - command.SilenceErrors = true - command.SilenceUsage = true - command.Flags().Int64VarP(&flagId, "id", "", 0, "The ID of the pet") - command.Flags().StringVarP(&flagAdditionalMetadata, "additionalMetadata", "", "", "Additional metadata to store with the uploaded image") - command.SetHelpFunc(func(_ *cobra.Command, _ []string) { - a.HelpFunc(getSpecPetstorePetUploadImageCmd()) - }) - command.SetUsageFunc(func(_ *cobra.Command) error { - return a.UsageFunc(getSpecPetstorePetUploadImageCmd()) - }) - - return command -} - -func getSpecPetstorePetUploadImageCmd() *spec.CommandItem { - return &spec.CommandItem{ - Segment: "upload-image", - CommandLine: "petstore pet upload-image", - Summary: "Upload an image for a pet", - Description: "Upload a binary image file for the specified pet.", - VisibleChildren: false, - VisibleArgs: true, - VisibleFlags: true, - ArgsModifiers: []string{ - "", - }, - FlagsModifiers: []string{ - "[flags]", - }, - Args: []spec.ArgumentItem{ - {Name: "path-to-file", Summary: "The path to the file to upload"}, - }, - Flags: []spec.FlagItem{ - {Name: "id", Summary: "The ID of the pet"}, - {Name: "additionalMetadata", Summary: "Additional metadata to store with the uploaded image"}, - }, - } -} diff --git a/examples/code/gencli/cmd_petstore_store.gen.go b/examples/code/gencli/cmd_petstore_store.gen.go deleted file mode 100644 index 9130d26..0000000 --- a/examples/code/gencli/cmd_petstore_store.gen.go +++ /dev/null @@ -1,58 +0,0 @@ -// Code generated by github.com/bcdxn/opencli@unknown version DO NOT EDIT. -package gencli - -import ( - "github.com/bcdxn/opencli/spec" - "github.com/spf13/cobra" -) - -func NewCmdPetstoreStore(a ActionsInterface) *cobra.Command { - command := &cobra.Command{ - Use: "store", - Short: "A collection of commands for store operations", - Long: "", - Args: cobra.NoArgs, - RunE: func(c *cobra.Command, args []string) error { - return BadUserInput("subcommand is required", func() error { - return a.UsageFunc(getSpecPetstoreStoreCmd()) - }) - }, - } - command.SilenceErrors = true - command.SilenceUsage = true - command.AddCommand(NewCmdPetstoreStoreInventory(a)) - command.AddCommand(NewCmdPetstoreStoreOrder(a)) - command.SetHelpFunc(func(_ *cobra.Command, _ []string) { - a.HelpFunc(getSpecPetstoreStoreCmd()) - }) - command.SetUsageFunc(func(_ *cobra.Command) error { - return a.UsageFunc(getSpecPetstoreStoreCmd()) - }) - - return command -} - -func getSpecPetstoreStoreCmd() *spec.CommandItem { - return &spec.CommandItem{ - Segment: "store", - CommandLine: "petstore store", - Summary: "A collection of commands for store operations", - Description: "", - VisibleChildren: true, - VisibleArgs: false, - VisibleFlags: false, - CommandModifiers: []string{ - "{command}", - }, - ArgsModifiers: []string{ - "", - }, - FlagsModifiers: []string{ - "[flags]", - }, - Commands: []*spec.CommandItem{ - {Segment: "inventory", Summary: "Returns pet inventories by status"}, - {Segment: "order", Summary: "A collection of commands for purchase orders"}, - }, - } -} diff --git a/examples/code/gencli/cmd_petstore_store_inventory.gen.go b/examples/code/gencli/cmd_petstore_store_inventory.gen.go deleted file mode 100644 index b5d674b..0000000 --- a/examples/code/gencli/cmd_petstore_store_inventory.gen.go +++ /dev/null @@ -1,40 +0,0 @@ -// Code generated by github.com/bcdxn/opencli@unknown version DO NOT EDIT. -package gencli - -import ( - "github.com/bcdxn/opencli/spec" - "github.com/spf13/cobra" -) - -func NewCmdPetstoreStoreInventory(a ActionsInterface) *cobra.Command { - command := &cobra.Command{ - Use: "inventory", - Short: "Returns pet inventories by status", - Long: "", - RunE: func(c *cobra.Command, args []string) error { - return a.PetstoreStoreInventory(c.Context()) - }, - } - command.SilenceErrors = true - command.SilenceUsage = true - command.SetHelpFunc(func(_ *cobra.Command, _ []string) { - a.HelpFunc(getSpecPetstoreStoreInventoryCmd()) - }) - command.SetUsageFunc(func(_ *cobra.Command) error { - return a.UsageFunc(getSpecPetstoreStoreInventoryCmd()) - }) - - return command -} - -func getSpecPetstoreStoreInventoryCmd() *spec.CommandItem { - return &spec.CommandItem{ - Segment: "inventory", - CommandLine: "petstore store inventory", - Summary: "Returns pet inventories by status", - Description: "", - VisibleChildren: false, - VisibleArgs: false, - VisibleFlags: false, - } -} diff --git a/examples/code/gencli/cmd_petstore_store_order.gen.go b/examples/code/gencli/cmd_petstore_store_order.gen.go deleted file mode 100644 index fd66d75..0000000 --- a/examples/code/gencli/cmd_petstore_store_order.gen.go +++ /dev/null @@ -1,60 +0,0 @@ -// Code generated by github.com/bcdxn/opencli@unknown version DO NOT EDIT. -package gencli - -import ( - "github.com/bcdxn/opencli/spec" - "github.com/spf13/cobra" -) - -func NewCmdPetstoreStoreOrder(a ActionsInterface) *cobra.Command { - command := &cobra.Command{ - Use: "order", - Short: "A collection of commands for purchase orders", - Long: "", - Args: cobra.NoArgs, - RunE: func(c *cobra.Command, args []string) error { - return BadUserInput("subcommand is required", func() error { - return a.UsageFunc(getSpecPetstoreStoreOrderCmd()) - }) - }, - } - command.SilenceErrors = true - command.SilenceUsage = true - command.AddCommand(NewCmdPetstoreStoreOrderPlace(a)) - command.AddCommand(NewCmdPetstoreStoreOrderGet(a)) - command.AddCommand(NewCmdPetstoreStoreOrderDelete(a)) - command.SetHelpFunc(func(_ *cobra.Command, _ []string) { - a.HelpFunc(getSpecPetstoreStoreOrderCmd()) - }) - command.SetUsageFunc(func(_ *cobra.Command) error { - return a.UsageFunc(getSpecPetstoreStoreOrderCmd()) - }) - - return command -} - -func getSpecPetstoreStoreOrderCmd() *spec.CommandItem { - return &spec.CommandItem{ - Segment: "order", - CommandLine: "petstore store order", - Summary: "A collection of commands for purchase orders", - Description: "", - VisibleChildren: true, - VisibleArgs: false, - VisibleFlags: false, - CommandModifiers: []string{ - "{command}", - }, - ArgsModifiers: []string{ - "", - }, - FlagsModifiers: []string{ - "[flags]", - }, - Commands: []*spec.CommandItem{ - {Segment: "place", Summary: "Place an order for a pet"}, - {Segment: "get", Summary: "Find purchase order by ID"}, - {Segment: "delete", Summary: "Delete purchase order by ID"}, - }, - } -} diff --git a/examples/code/gencli/cmd_petstore_store_order_delete.gen.go b/examples/code/gencli/cmd_petstore_store_order_delete.gen.go deleted file mode 100644 index a003000..0000000 --- a/examples/code/gencli/cmd_petstore_store_order_delete.gen.go +++ /dev/null @@ -1,50 +0,0 @@ -// Code generated by github.com/bcdxn/opencli@unknown version DO NOT EDIT. -package gencli - -import ( - "github.com/bcdxn/opencli/spec" - "github.com/spf13/cobra" -) - -func NewCmdPetstoreStoreOrderDelete(a ActionsInterface) *cobra.Command { - command := &cobra.Command{ - Use: "delete", - Short: "Delete purchase order by ID", - Long: "", - RunE: func(c *cobra.Command, args []string) error { - cmdArgs := PetstoreStoreOrderDeleteArgs{} - if len(args) > 0 { - cmdArgs.Id = args[0] - } - return a.PetstoreStoreOrderDelete(c.Context(), cmdArgs) - }, - } - command.SilenceErrors = true - command.SilenceUsage = true - command.SetHelpFunc(func(_ *cobra.Command, _ []string) { - a.HelpFunc(getSpecPetstoreStoreOrderDeleteCmd()) - }) - command.SetUsageFunc(func(_ *cobra.Command) error { - return a.UsageFunc(getSpecPetstoreStoreOrderDeleteCmd()) - }) - - return command -} - -func getSpecPetstoreStoreOrderDeleteCmd() *spec.CommandItem { - return &spec.CommandItem{ - Segment: "delete", - CommandLine: "petstore store order delete", - Summary: "Delete purchase order by ID", - Description: "", - VisibleChildren: false, - VisibleArgs: true, - VisibleFlags: false, - ArgsModifiers: []string{ - "", - }, - Args: []spec.ArgumentItem{ - {Name: "id", Summary: "The ID of the order to delete"}, - }, - } -} diff --git a/examples/code/gencli/cmd_petstore_store_order_get.gen.go b/examples/code/gencli/cmd_petstore_store_order_get.gen.go deleted file mode 100644 index 1d442a4..0000000 --- a/examples/code/gencli/cmd_petstore_store_order_get.gen.go +++ /dev/null @@ -1,51 +0,0 @@ -// Code generated by github.com/bcdxn/opencli@unknown version DO NOT EDIT. -package gencli - -import ( - "github.com/bcdxn/opencli/spec" - "github.com/spf13/cobra" -) - -func NewCmdPetstoreStoreOrderGet(a ActionsInterface) *cobra.Command { - var flagId int64 - command := &cobra.Command{ - Use: "get", - Short: "Find purchase order by ID", - Long: "", - RunE: func(c *cobra.Command, args []string) error { - cmdFlags := PetstoreStoreOrderGetFlags{ - Id: flagId, - } - return a.PetstoreStoreOrderGet(c.Context(), cmdFlags) - }, - } - command.SilenceErrors = true - command.SilenceUsage = true - command.Flags().Int64VarP(&flagId, "id", "", 0, "The ID of the order to retrieve") - command.SetHelpFunc(func(_ *cobra.Command, _ []string) { - a.HelpFunc(getSpecPetstoreStoreOrderGetCmd()) - }) - command.SetUsageFunc(func(_ *cobra.Command) error { - return a.UsageFunc(getSpecPetstoreStoreOrderGetCmd()) - }) - - return command -} - -func getSpecPetstoreStoreOrderGetCmd() *spec.CommandItem { - return &spec.CommandItem{ - Segment: "get", - CommandLine: "petstore store order get", - Summary: "Find purchase order by ID", - Description: "", - VisibleChildren: false, - VisibleArgs: false, - VisibleFlags: true, - FlagsModifiers: []string{ - "[flags]", - }, - Flags: []spec.FlagItem{ - {Name: "id", Summary: "The ID of the order to retrieve"}, - }, - } -} diff --git a/examples/code/gencli/cmd_petstore_store_order_place.gen.go b/examples/code/gencli/cmd_petstore_store_order_place.gen.go deleted file mode 100644 index d592fb0..0000000 --- a/examples/code/gencli/cmd_petstore_store_order_place.gen.go +++ /dev/null @@ -1,78 +0,0 @@ -// Code generated by github.com/bcdxn/opencli@unknown version DO NOT EDIT. -package gencli - -import ( - "github.com/bcdxn/opencli/spec" - "github.com/spf13/cobra" -) - -func NewCmdPetstoreStoreOrderPlace(a ActionsInterface) *cobra.Command { - var flagPetId int64 - var flagQuantity int64 - var flagStatus string - var flagComplete bool - command := &cobra.Command{ - Use: "place", - Short: "Place an order for a pet", - Long: "Create a purchase order using a JSON payload or explicit flags.", - RunE: func(c *cobra.Command, args []string) error { - cmdArgs := PetstoreStoreOrderPlaceArgs{} - if len(args) > 0 { - cmdArgs.PathToOrderBody = args[0] - } - cmdFlags := PetstoreStoreOrderPlaceFlags{ - PetId: flagPetId, - Quantity: flagQuantity, - Status: PetstoreStoreOrderPlaceStatus(flagStatus), - Complete: flagComplete, - } - if cmdFlags.Status != "" && !cmdFlags.Status.IsValid() { - return BadUserInput("invalid value for --status flag: "+string(cmdFlags.Status), func() error { - return a.UsageFunc(getSpecPetstoreStoreOrderPlaceCmd()) - }) - } - return a.PetstoreStoreOrderPlace(c.Context(), cmdArgs, cmdFlags) - }, - } - command.SilenceErrors = true - command.SilenceUsage = true - command.Flags().Int64VarP(&flagPetId, "pet-id", "", 0, "The ID of the pet to order") - command.Flags().Int64VarP(&flagQuantity, "quantity", "", 0, "How many pets to order") - command.Flags().StringVarP(&flagStatus, "status", "", "", "The order status") - command.Flags().BoolVarP(&flagComplete, "complete", "", false, "Whether the order has been completed") - command.SetHelpFunc(func(_ *cobra.Command, _ []string) { - a.HelpFunc(getSpecPetstoreStoreOrderPlaceCmd()) - }) - command.SetUsageFunc(func(_ *cobra.Command) error { - return a.UsageFunc(getSpecPetstoreStoreOrderPlaceCmd()) - }) - - return command -} - -func getSpecPetstoreStoreOrderPlaceCmd() *spec.CommandItem { - return &spec.CommandItem{ - Segment: "place", - CommandLine: "petstore store order place", - Summary: "Place an order for a pet", - Description: "Create a purchase order using a JSON payload or explicit flags.", - VisibleChildren: false, - VisibleArgs: true, - VisibleFlags: true, - ArgsModifiers: []string{ - "", - }, - FlagsModifiers: []string{ - "[flags]", - }, - Args: []spec.ArgumentItem{ - {Name: "path-to-order-body", Summary: "The path to a JSON file containing the order payload"}, - }, - Flags: []spec.FlagItem{ - {Name: "pet-id", Summary: "The ID of the pet to order"}, - {Name: "quantity", Summary: "How many pets to order"}, - {Name: "status", Summary: "The order status"}, - {Name: "complete", Summary: "Whether the order has been completed"}, - }, - } -} diff --git a/examples/code/gencli/cmd_petstore_user.gen.go b/examples/code/gencli/cmd_petstore_user.gen.go deleted file mode 100644 index 781e409..0000000 --- a/examples/code/gencli/cmd_petstore_user.gen.go +++ /dev/null @@ -1,68 +0,0 @@ -// Code generated by github.com/bcdxn/opencli@unknown version DO NOT EDIT. -package gencli - -import ( - "github.com/bcdxn/opencli/spec" - "github.com/spf13/cobra" -) - -func NewCmdPetstoreUser(a ActionsInterface) *cobra.Command { - command := &cobra.Command{ - Use: "user", - Short: "", - Long: "", - Args: cobra.NoArgs, - RunE: func(c *cobra.Command, args []string) error { - return BadUserInput("subcommand is required", func() error { - return a.UsageFunc(getSpecPetstoreUserCmd()) - }) - }, - } - command.SilenceErrors = true - command.SilenceUsage = true - command.AddCommand(NewCmdPetstoreUserCreate(a)) - command.AddCommand(NewCmdPetstoreUserCreateWithList(a)) - command.AddCommand(NewCmdPetstoreUserLogin(a)) - command.AddCommand(NewCmdPetstoreUserLogout(a)) - command.AddCommand(NewCmdPetstoreUserGet(a)) - command.AddCommand(NewCmdPetstoreUserUpdate(a)) - command.AddCommand(NewCmdPetstoreUserDelete(a)) - command.SetHelpFunc(func(_ *cobra.Command, _ []string) { - a.HelpFunc(getSpecPetstoreUserCmd()) - }) - command.SetUsageFunc(func(_ *cobra.Command) error { - return a.UsageFunc(getSpecPetstoreUserCmd()) - }) - - return command -} - -func getSpecPetstoreUserCmd() *spec.CommandItem { - return &spec.CommandItem{ - Segment: "user", - CommandLine: "petstore user", - Summary: "", - Description: "", - VisibleChildren: true, - VisibleArgs: false, - VisibleFlags: false, - CommandModifiers: []string{ - "{command}", - }, - ArgsModifiers: []string{ - "", - }, - FlagsModifiers: []string{ - "[flags]", - }, - Commands: []*spec.CommandItem{ - {Segment: "create", Summary: "Create a user"}, - {Segment: "create-with-list", Summary: "Create multiple users with a list"}, - {Segment: "login", Summary: "Log in a user"}, - {Segment: "logout", Summary: "Log out the current user"}, - {Segment: "get", Summary: "Get user by username"}, - {Segment: "update", Summary: "Update a user"}, - {Segment: "delete", Summary: "Delete a user"}, - }, - } -} diff --git a/examples/code/gencli/cmd_petstore_user_create.gen.go b/examples/code/gencli/cmd_petstore_user_create.gen.go deleted file mode 100644 index f5794fa..0000000 --- a/examples/code/gencli/cmd_petstore_user_create.gen.go +++ /dev/null @@ -1,85 +0,0 @@ -// Code generated by github.com/bcdxn/opencli@unknown version DO NOT EDIT. -package gencli - -import ( - "github.com/bcdxn/opencli/spec" - "github.com/spf13/cobra" -) - -func NewCmdPetstoreUserCreate(a ActionsInterface) *cobra.Command { - var flagUsername string - var flagFirstName string - var flagLastName string - var flagEmail string - var flagPassword string - var flagPhone string - var flagStatus int64 - command := &cobra.Command{ - Use: "create", - Short: "Create a user", - Long: "Create a new user using a JSON payload or explicit flags.", - RunE: func(c *cobra.Command, args []string) error { - cmdArgs := PetstoreUserCreateArgs{} - if len(args) > 0 { - cmdArgs.PathToUserBody = args[0] - } - cmdFlags := PetstoreUserCreateFlags{ - Username: flagUsername, - FirstName: flagFirstName, - LastName: flagLastName, - Email: flagEmail, - Password: flagPassword, - Phone: flagPhone, - Status: flagStatus, - } - return a.PetstoreUserCreate(c.Context(), cmdArgs, cmdFlags) - }, - } - command.SilenceErrors = true - command.SilenceUsage = true - command.Flags().StringVarP(&flagUsername, "username", "", "", "The user's username") - command.Flags().StringVarP(&flagFirstName, "firstName", "", "", "The user's first name") - command.Flags().StringVarP(&flagLastName, "lastName", "", "", "The user's last name") - command.Flags().StringVarP(&flagEmail, "email", "", "", "The user's email address") - command.Flags().StringVarP(&flagPassword, "password", "", "", "The user's password") - command.Flags().StringVarP(&flagPhone, "phone", "", "", "The user's phone number") - command.Flags().Int64VarP(&flagStatus, "status", "", 0, "The user's status") - command.SetHelpFunc(func(_ *cobra.Command, _ []string) { - a.HelpFunc(getSpecPetstoreUserCreateCmd()) - }) - command.SetUsageFunc(func(_ *cobra.Command) error { - return a.UsageFunc(getSpecPetstoreUserCreateCmd()) - }) - - return command -} - -func getSpecPetstoreUserCreateCmd() *spec.CommandItem { - return &spec.CommandItem{ - Segment: "create", - CommandLine: "petstore user create", - Summary: "Create a user", - Description: "Create a new user using a JSON payload or explicit flags.", - VisibleChildren: false, - VisibleArgs: true, - VisibleFlags: true, - ArgsModifiers: []string{ - "", - }, - FlagsModifiers: []string{ - "[flags]", - }, - Args: []spec.ArgumentItem{ - {Name: "path-to-user-body", Summary: "The path to a JSON file containing the user payload"}, - }, - Flags: []spec.FlagItem{ - {Name: "username", Summary: "The user's username"}, - {Name: "firstName", Summary: "The user's first name"}, - {Name: "lastName", Summary: "The user's last name"}, - {Name: "email", Summary: "The user's email address"}, - {Name: "password", Summary: "The user's password"}, - {Name: "phone", Summary: "The user's phone number"}, - {Name: "status", Summary: "The user's status"}, - }, - } -} diff --git a/examples/code/gencli/cmd_petstore_user_createwithlist.gen.go b/examples/code/gencli/cmd_petstore_user_createwithlist.gen.go deleted file mode 100644 index 0dfb301..0000000 --- a/examples/code/gencli/cmd_petstore_user_createwithlist.gen.go +++ /dev/null @@ -1,50 +0,0 @@ -// Code generated by github.com/bcdxn/opencli@unknown version DO NOT EDIT. -package gencli - -import ( - "github.com/bcdxn/opencli/spec" - "github.com/spf13/cobra" -) - -func NewCmdPetstoreUserCreateWithList(a ActionsInterface) *cobra.Command { - command := &cobra.Command{ - Use: "create-with-list", - Short: "Create multiple users with a list", - Long: "Create multiple users using a JSON array payload.", - RunE: func(c *cobra.Command, args []string) error { - cmdArgs := PetstoreUserCreateWithListArgs{} - if len(args) > 0 { - cmdArgs.PathToUsersBody = args[0] - } - return a.PetstoreUserCreateWithList(c.Context(), cmdArgs) - }, - } - command.SilenceErrors = true - command.SilenceUsage = true - command.SetHelpFunc(func(_ *cobra.Command, _ []string) { - a.HelpFunc(getSpecPetstoreUserCreateWithListCmd()) - }) - command.SetUsageFunc(func(_ *cobra.Command) error { - return a.UsageFunc(getSpecPetstoreUserCreateWithListCmd()) - }) - - return command -} - -func getSpecPetstoreUserCreateWithListCmd() *spec.CommandItem { - return &spec.CommandItem{ - Segment: "create-with-list", - CommandLine: "petstore user create-with-list", - Summary: "Create multiple users with a list", - Description: "Create multiple users using a JSON array payload.", - VisibleChildren: false, - VisibleArgs: true, - VisibleFlags: false, - ArgsModifiers: []string{ - "", - }, - Args: []spec.ArgumentItem{ - {Name: "path-to-users-body", Summary: "The path to a JSON file containing the user list payload"}, - }, - } -} diff --git a/examples/code/gencli/cmd_petstore_user_delete.gen.go b/examples/code/gencli/cmd_petstore_user_delete.gen.go deleted file mode 100644 index 010483b..0000000 --- a/examples/code/gencli/cmd_petstore_user_delete.gen.go +++ /dev/null @@ -1,52 +0,0 @@ -// Code generated by github.com/bcdxn/opencli@unknown version DO NOT EDIT. -package gencli - -import ( - "github.com/bcdxn/opencli/spec" - "github.com/spf13/cobra" -) - -func NewCmdPetstoreUserDelete(a ActionsInterface) *cobra.Command { - var flagUsername string - command := &cobra.Command{ - Use: "delete", - Short: "Delete a user", - Long: "", - RunE: func(c *cobra.Command, args []string) error { - cmdFlags := PetstoreUserDeleteFlags{ - Username: flagUsername, - } - return a.PetstoreUserDelete(c.Context(), cmdFlags) - }, - } - command.SilenceErrors = true - command.SilenceUsage = true - command.Hidden = true - command.Flags().StringVarP(&flagUsername, "username", "", "", "The username of the user to delete") - command.SetHelpFunc(func(_ *cobra.Command, _ []string) { - a.HelpFunc(getSpecPetstoreUserDeleteCmd()) - }) - command.SetUsageFunc(func(_ *cobra.Command) error { - return a.UsageFunc(getSpecPetstoreUserDeleteCmd()) - }) - - return command -} - -func getSpecPetstoreUserDeleteCmd() *spec.CommandItem { - return &spec.CommandItem{ - Segment: "delete", - CommandLine: "petstore user delete", - Summary: "Delete a user", - Description: "", - VisibleChildren: false, - VisibleArgs: false, - VisibleFlags: true, - FlagsModifiers: []string{ - "[flags]", - }, - Flags: []spec.FlagItem{ - {Name: "username", Summary: "The username of the user to delete"}, - }, - } -} diff --git a/examples/code/gencli/cmd_petstore_user_get.gen.go b/examples/code/gencli/cmd_petstore_user_get.gen.go deleted file mode 100644 index 53fa326..0000000 --- a/examples/code/gencli/cmd_petstore_user_get.gen.go +++ /dev/null @@ -1,51 +0,0 @@ -// Code generated by github.com/bcdxn/opencli@unknown version DO NOT EDIT. -package gencli - -import ( - "github.com/bcdxn/opencli/spec" - "github.com/spf13/cobra" -) - -func NewCmdPetstoreUserGet(a ActionsInterface) *cobra.Command { - var flagUsername string - command := &cobra.Command{ - Use: "get", - Short: "Get user by username", - Long: "", - RunE: func(c *cobra.Command, args []string) error { - cmdFlags := PetstoreUserGetFlags{ - Username: flagUsername, - } - return a.PetstoreUserGet(c.Context(), cmdFlags) - }, - } - command.SilenceErrors = true - command.SilenceUsage = true - command.Flags().StringVarP(&flagUsername, "username", "", "", "The username to look up") - command.SetHelpFunc(func(_ *cobra.Command, _ []string) { - a.HelpFunc(getSpecPetstoreUserGetCmd()) - }) - command.SetUsageFunc(func(_ *cobra.Command) error { - return a.UsageFunc(getSpecPetstoreUserGetCmd()) - }) - - return command -} - -func getSpecPetstoreUserGetCmd() *spec.CommandItem { - return &spec.CommandItem{ - Segment: "get", - CommandLine: "petstore user get", - Summary: "Get user by username", - Description: "", - VisibleChildren: false, - VisibleArgs: false, - VisibleFlags: true, - FlagsModifiers: []string{ - "[flags]", - }, - Flags: []spec.FlagItem{ - {Name: "username", Summary: "The username to look up"}, - }, - } -} diff --git a/examples/code/gencli/cmd_petstore_user_login.gen.go b/examples/code/gencli/cmd_petstore_user_login.gen.go deleted file mode 100644 index 012b7d6..0000000 --- a/examples/code/gencli/cmd_petstore_user_login.gen.go +++ /dev/null @@ -1,55 +0,0 @@ -// Code generated by github.com/bcdxn/opencli@unknown version DO NOT EDIT. -package gencli - -import ( - "github.com/bcdxn/opencli/spec" - "github.com/spf13/cobra" -) - -func NewCmdPetstoreUserLogin(a ActionsInterface) *cobra.Command { - var flagUsername string - var flagPassword string - command := &cobra.Command{ - Use: "login", - Short: "Log in a user", - Long: "", - RunE: func(c *cobra.Command, args []string) error { - cmdFlags := PetstoreUserLoginFlags{ - Username: flagUsername, - Password: flagPassword, - } - return a.PetstoreUserLogin(c.Context(), cmdFlags) - }, - } - command.SilenceErrors = true - command.SilenceUsage = true - command.Flags().StringVarP(&flagUsername, "username", "", "", "The user's username") - command.Flags().StringVarP(&flagPassword, "password", "", "", "The user's password") - command.SetHelpFunc(func(_ *cobra.Command, _ []string) { - a.HelpFunc(getSpecPetstoreUserLoginCmd()) - }) - command.SetUsageFunc(func(_ *cobra.Command) error { - return a.UsageFunc(getSpecPetstoreUserLoginCmd()) - }) - - return command -} - -func getSpecPetstoreUserLoginCmd() *spec.CommandItem { - return &spec.CommandItem{ - Segment: "login", - CommandLine: "petstore user login", - Summary: "Log in a user", - Description: "", - VisibleChildren: false, - VisibleArgs: false, - VisibleFlags: true, - FlagsModifiers: []string{ - "[flags]", - }, - Flags: []spec.FlagItem{ - {Name: "username", Summary: "The user's username"}, - {Name: "password", Summary: "The user's password"}, - }, - } -} diff --git a/examples/code/gencli/cmd_petstore_user_logout.gen.go b/examples/code/gencli/cmd_petstore_user_logout.gen.go deleted file mode 100644 index d0d2d6f..0000000 --- a/examples/code/gencli/cmd_petstore_user_logout.gen.go +++ /dev/null @@ -1,40 +0,0 @@ -// Code generated by github.com/bcdxn/opencli@unknown version DO NOT EDIT. -package gencli - -import ( - "github.com/bcdxn/opencli/spec" - "github.com/spf13/cobra" -) - -func NewCmdPetstoreUserLogout(a ActionsInterface) *cobra.Command { - command := &cobra.Command{ - Use: "logout", - Short: "Log out the current user", - Long: "", - RunE: func(c *cobra.Command, args []string) error { - return a.PetstoreUserLogout(c.Context()) - }, - } - command.SilenceErrors = true - command.SilenceUsage = true - command.SetHelpFunc(func(_ *cobra.Command, _ []string) { - a.HelpFunc(getSpecPetstoreUserLogoutCmd()) - }) - command.SetUsageFunc(func(_ *cobra.Command) error { - return a.UsageFunc(getSpecPetstoreUserLogoutCmd()) - }) - - return command -} - -func getSpecPetstoreUserLogoutCmd() *spec.CommandItem { - return &spec.CommandItem{ - Segment: "logout", - CommandLine: "petstore user logout", - Summary: "Log out the current user", - Description: "", - VisibleChildren: false, - VisibleArgs: false, - VisibleFlags: false, - } -} diff --git a/examples/code/gencli/cmd_petstore_user_update.gen.go b/examples/code/gencli/cmd_petstore_user_update.gen.go deleted file mode 100644 index c800d70..0000000 --- a/examples/code/gencli/cmd_petstore_user_update.gen.go +++ /dev/null @@ -1,75 +0,0 @@ -// Code generated by github.com/bcdxn/opencli@unknown version DO NOT EDIT. -package gencli - -import ( - "github.com/bcdxn/opencli/spec" - "github.com/spf13/cobra" -) - -func NewCmdPetstoreUserUpdate(a ActionsInterface) *cobra.Command { - var flagUsername string - var flagFirstName string - var flagLastName string - var flagEmail string - var flagPassword string - var flagPhone string - var flagUserStatus int64 - command := &cobra.Command{ - Use: "update", - Short: "Update a user", - Long: "Update a user's information using a JSON payload or explicit flags.", - RunE: func(c *cobra.Command, args []string) error { - cmdFlags := PetstoreUserUpdateFlags{ - Username: flagUsername, - FirstName: flagFirstName, - LastName: flagLastName, - Email: flagEmail, - Password: flagPassword, - Phone: flagPhone, - UserStatus: flagUserStatus, - } - return a.PetstoreUserUpdate(c.Context(), cmdFlags) - }, - } - command.SilenceErrors = true - command.SilenceUsage = true - command.Flags().StringVarP(&flagUsername, "username", "", "", "The username of the user to update") - command.Flags().StringVarP(&flagFirstName, "firstName", "", "", "The user's first name") - command.Flags().StringVarP(&flagLastName, "lastName", "", "", "The user's last name") - command.Flags().StringVarP(&flagEmail, "email", "", "", "The user's email address") - command.Flags().StringVarP(&flagPassword, "password", "", "", "The user's password") - command.Flags().StringVarP(&flagPhone, "phone", "", "", "The user's phone number") - command.Flags().Int64VarP(&flagUserStatus, "userStatus", "", 0, "The user's status") - command.SetHelpFunc(func(_ *cobra.Command, _ []string) { - a.HelpFunc(getSpecPetstoreUserUpdateCmd()) - }) - command.SetUsageFunc(func(_ *cobra.Command) error { - return a.UsageFunc(getSpecPetstoreUserUpdateCmd()) - }) - - return command -} - -func getSpecPetstoreUserUpdateCmd() *spec.CommandItem { - return &spec.CommandItem{ - Segment: "update", - CommandLine: "petstore user update", - Summary: "Update a user", - Description: "Update a user's information using a JSON payload or explicit flags.", - VisibleChildren: false, - VisibleArgs: false, - VisibleFlags: true, - FlagsModifiers: []string{ - "[flags]", - }, - Flags: []spec.FlagItem{ - {Name: "username", Summary: "The username of the user to update"}, - {Name: "firstName", Summary: "The user's first name"}, - {Name: "lastName", Summary: "The user's last name"}, - {Name: "email", Summary: "The user's email address"}, - {Name: "password", Summary: "The user's password"}, - {Name: "phone", Summary: "The user's phone number"}, - {Name: "userStatus", Summary: "The user's status"}, - }, - } -} diff --git a/examples/code/gencli/params.gen.go b/examples/code/gencli/params.gen.go deleted file mode 100644 index 3ddc337..0000000 --- a/examples/code/gencli/params.gen.go +++ /dev/null @@ -1,228 +0,0 @@ -// Code generated by github.com/bcdxn/opencli@unknown version DO NOT EDIT. -package gencli - -// PetstoreListArgs holds the positional arguments for the PetstoreList action. -type PetstoreListArgs struct { - HttpArguments string -} - -// PetstorePetAddStatus represents the allowed values for the Status flag. -type PetstorePetAddStatus string - -const ( - PetstorePetAddStatusAvailable PetstorePetAddStatus = "available" - PetstorePetAddStatusPending PetstorePetAddStatus = "pending" - PetstorePetAddStatusSold PetstorePetAddStatus = "sold" -) - -func (v PetstorePetAddStatus) IsValid() bool { - switch v { - case PetstorePetAddStatusAvailable, PetstorePetAddStatusPending, PetstorePetAddStatusSold: - return true - } - return false -} - -// PetstorePetAddArgs holds the positional arguments for the PetstorePetAdd action. -type PetstorePetAddArgs struct { - PathToReqBody string -} - -// PetstorePetAddFlags holds the flag values for the PetstorePetAdd action. -type PetstorePetAddFlags struct { - Name string - PhotoUrls []string - Status PetstorePetAddStatus - Tag []string -} - -// PetstorePetUpdateStatus represents the allowed values for the Status flag. -type PetstorePetUpdateStatus string - -const ( - PetstorePetUpdateStatusAvailable PetstorePetUpdateStatus = "available" - PetstorePetUpdateStatusPending PetstorePetUpdateStatus = "pending" - PetstorePetUpdateStatusSold PetstorePetUpdateStatus = "sold" -) - -func (v PetstorePetUpdateStatus) IsValid() bool { - switch v { - case PetstorePetUpdateStatusAvailable, PetstorePetUpdateStatusPending, PetstorePetUpdateStatusSold: - return true - } - return false -} - -// PetstorePetUpdateArgs holds the positional arguments for the PetstorePetUpdate action. -type PetstorePetUpdateArgs struct { - PathToReqBody string -} - -// PetstorePetUpdateFlags holds the flag values for the PetstorePetUpdate action. -type PetstorePetUpdateFlags struct { - Name string - Status PetstorePetUpdateStatus - PhotoUrls []string - Tags []string -} - -// PetstorePetFindByStatusStatus represents the allowed values for the Status flag. -type PetstorePetFindByStatusStatus string - -const ( - PetstorePetFindByStatusStatusAvailable PetstorePetFindByStatusStatus = "available" - PetstorePetFindByStatusStatusPending PetstorePetFindByStatusStatus = "pending" - PetstorePetFindByStatusStatusSold PetstorePetFindByStatusStatus = "sold" -) - -func (v PetstorePetFindByStatusStatus) IsValid() bool { - switch v { - case PetstorePetFindByStatusStatusAvailable, PetstorePetFindByStatusStatusPending, PetstorePetFindByStatusStatusSold: - return true - } - return false -} - -// PetstorePetFindByStatusFlags holds the flag values for the PetstorePetFindByStatus action. -type PetstorePetFindByStatusFlags struct { - Status PetstorePetFindByStatusStatus -} - -// PetstorePetFindByTagsFlags holds the flag values for the PetstorePetFindByTags action. -type PetstorePetFindByTagsFlags struct { - Tags []string -} - -// PetstorePetGetFlags holds the flag values for the PetstorePetGet action. -type PetstorePetGetFlags struct { - Id int64 -} - -// PetstorePetUpdateFormStatus represents the allowed values for the Status flag. -type PetstorePetUpdateFormStatus string - -const ( - PetstorePetUpdateFormStatusAvailable PetstorePetUpdateFormStatus = "available" - PetstorePetUpdateFormStatusPending PetstorePetUpdateFormStatus = "pending" - PetstorePetUpdateFormStatusSold PetstorePetUpdateFormStatus = "sold" -) - -func (v PetstorePetUpdateFormStatus) IsValid() bool { - switch v { - case PetstorePetUpdateFormStatusAvailable, PetstorePetUpdateFormStatusPending, PetstorePetUpdateFormStatusSold: - return true - } - return false -} - -// PetstorePetUpdateFormFlags holds the flag values for the PetstorePetUpdateForm action. -type PetstorePetUpdateFormFlags struct { - Id int64 - Name string - Status PetstorePetUpdateFormStatus -} - -// PetstorePetDeleteFlags holds the flag values for the PetstorePetDelete action. -type PetstorePetDeleteFlags struct { - Id int64 - ApiKey string -} - -// PetstorePetUploadImageArgs holds the positional arguments for the PetstorePetUploadImage action. -type PetstorePetUploadImageArgs struct { - PathToFile string -} - -// PetstorePetUploadImageFlags holds the flag values for the PetstorePetUploadImage action. -type PetstorePetUploadImageFlags struct { - Id int64 - AdditionalMetadata string -} - -// PetstoreStoreOrderPlaceStatus represents the allowed values for the Status flag. -type PetstoreStoreOrderPlaceStatus string - -const ( - PetstoreStoreOrderPlaceStatusPlaced PetstoreStoreOrderPlaceStatus = "placed" - PetstoreStoreOrderPlaceStatusApproved PetstoreStoreOrderPlaceStatus = "approved" - PetstoreStoreOrderPlaceStatusDelivered PetstoreStoreOrderPlaceStatus = "delivered" -) - -func (v PetstoreStoreOrderPlaceStatus) IsValid() bool { - switch v { - case PetstoreStoreOrderPlaceStatusPlaced, PetstoreStoreOrderPlaceStatusApproved, PetstoreStoreOrderPlaceStatusDelivered: - return true - } - return false -} - -// PetstoreStoreOrderPlaceArgs holds the positional arguments for the PetstoreStoreOrderPlace action. -type PetstoreStoreOrderPlaceArgs struct { - PathToOrderBody string -} - -// PetstoreStoreOrderPlaceFlags holds the flag values for the PetstoreStoreOrderPlace action. -type PetstoreStoreOrderPlaceFlags struct { - PetId int64 - Quantity int64 - Status PetstoreStoreOrderPlaceStatus - Complete bool -} - -// PetstoreStoreOrderGetFlags holds the flag values for the PetstoreStoreOrderGet action. -type PetstoreStoreOrderGetFlags struct { - Id int64 -} - -// PetstoreStoreOrderDeleteArgs holds the positional arguments for the PetstoreStoreOrderDelete action. -type PetstoreStoreOrderDeleteArgs struct { - Id string -} - -// PetstoreUserCreateArgs holds the positional arguments for the PetstoreUserCreate action. -type PetstoreUserCreateArgs struct { - PathToUserBody string -} - -// PetstoreUserCreateFlags holds the flag values for the PetstoreUserCreate action. -type PetstoreUserCreateFlags struct { - Username string - FirstName string - LastName string - Email string - Password string - Phone string - Status int64 -} - -// PetstoreUserCreateWithListArgs holds the positional arguments for the PetstoreUserCreateWithList action. -type PetstoreUserCreateWithListArgs struct { - PathToUsersBody string -} - -// PetstoreUserLoginFlags holds the flag values for the PetstoreUserLogin action. -type PetstoreUserLoginFlags struct { - Username string - Password string -} - -// PetstoreUserGetFlags holds the flag values for the PetstoreUserGet action. -type PetstoreUserGetFlags struct { - Username string -} - -// PetstoreUserUpdateFlags holds the flag values for the PetstoreUserUpdate action. -type PetstoreUserUpdateFlags struct { - Username string - FirstName string - LastName string - Email string - Password string - Phone string - UserStatus int64 -} - -// PetstoreUserDeleteFlags holds the flag values for the PetstoreUserDelete action. -type PetstoreUserDeleteFlags struct { - Username string -} diff --git a/examples/code/urfavecli/pleasantries/go.mod b/examples/code/urfavecli/pleasantries/go.mod new file mode 100644 index 0000000..9611347 --- /dev/null +++ b/examples/code/urfavecli/pleasantries/go.mod @@ -0,0 +1,39 @@ +module pleasantriescli + +go 1.26.6 + +require ( + charm.land/glamour/v2 v2.0.1 + charm.land/lipgloss/v2 v2.0.6 + github.com/bcdxn/opencli v1.2.0 + github.com/urfave/cli/v3 v3.11.0 + golang.org/x/term v0.45.0 +) + +require ( + github.com/alecthomas/chroma/v2 v2.27.0 // indirect + github.com/aymerick/douceur v0.2.0 // indirect + github.com/charmbracelet/colorprofile v0.4.3 // indirect + github.com/charmbracelet/ultraviolet v0.0.0-20260811164956-006e29f97886 // indirect + github.com/charmbracelet/x/ansi v0.11.8 // indirect + github.com/charmbracelet/x/exp/slice v0.0.0-20260713092006-0d683c34c74b // indirect + github.com/charmbracelet/x/term v0.2.2 // indirect + github.com/charmbracelet/x/termios v0.1.1 // indirect + github.com/charmbracelet/x/windows v0.2.2 // indirect + github.com/clipperhouse/displaywidth v0.11.0 // indirect + github.com/clipperhouse/uax29/v2 v2.7.0 // indirect + github.com/dlclark/regexp2/v2 v2.2.1 // indirect + github.com/gorilla/css v1.0.1 // indirect + github.com/lucasb-eyer/go-colorful v1.4.1 // indirect + github.com/mattn/go-runewidth v0.0.24 // indirect + github.com/microcosm-cc/bluemonday v1.0.27 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + github.com/yuin/goldmark v1.8.4 // indirect + github.com/yuin/goldmark-emoji v1.0.6 // indirect + golang.org/x/net v0.57.0 // indirect + golang.org/x/sync v0.22.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/text v0.40.0 // indirect +) diff --git a/examples/code/urfavecli/pleasantries/go.sum b/examples/code/urfavecli/pleasantries/go.sum new file mode 100644 index 0000000..93fc83b --- /dev/null +++ b/examples/code/urfavecli/pleasantries/go.sum @@ -0,0 +1,80 @@ +charm.land/glamour/v2 v2.0.1 h1:xl+r00A4aJWU0z8fgwKd9fQQ4rsphqGUzuEiXZP5n+c= +charm.land/glamour/v2 v2.0.1/go.mod h1:jo9z8XqVKPeEFMVdvCRLGk++RyJ3CdUwgNr7EvXLw3k= +charm.land/lipgloss/v2 v2.0.6 h1:EaGKeuA8FvF+v2BT5VmZd2LoYLaMZJXA5n34th8nCIQ= +charm.land/lipgloss/v2 v2.0.6/go.mod h1:ipDDJNSGa1hlwDtSfW1s2/xR8Vdhbut4PXh2zEKZd0Q= +github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= +github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= +github.com/alecthomas/chroma/v2 v2.27.0 h1:FodwmyOBgJULFYmDqibcp9pvfDLWdtPRh9v/r5BXYZs= +github.com/alecthomas/chroma/v2 v2.27.0/go.mod h1:NjJ3ciIgrqBNeIkWZ4e46nseoLDslxU1LmfCoL+wcY8= +github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs= +github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= +github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o= +github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w= +github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= +github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= +github.com/bcdxn/opencli v1.2.0 h1:oxns1ZekHXGn9+pTRmvb3hBa9oLOD+J9hojbRVvJSPE= +github.com/bcdxn/opencli v1.2.0/go.mod h1:75kAGRwf+9Bl9lwIgFVmad06/aofBHWMXsOlFPsGnLA= +github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q= +github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q= +github.com/charmbracelet/ultraviolet v0.0.0-20260811164956-006e29f97886 h1:rdnVWKgJpTVXKuKuJyxDJ+NFJdUaUqGvyGy61OcvlbA= +github.com/charmbracelet/ultraviolet v0.0.0-20260811164956-006e29f97886/go.mod h1:nAw0d9PhFp1qdzi2xhQU5YOu5sVpDIHWlaW2Uz/bCro= +github.com/charmbracelet/x/ansi v0.11.8 h1:JMFwp0CgDC2+jcOB162HH5k7I3FVbgFSMMYg7dSPBQQ= +github.com/charmbracelet/x/ansi v0.11.8/go.mod h1:ZNN+3mXny/516oTQPLMPIBeSINvNJJQ8uQXDgbeJxY0= +github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA= +github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I= +github.com/charmbracelet/x/exp/slice v0.0.0-20260713092006-0d683c34c74b h1:N0srud4Ch13fFAjgMlnKR2h41/2ISRbbzCJpTXGcNR0= +github.com/charmbracelet/x/exp/slice v0.0.0-20260713092006-0d683c34c74b/go.mod h1:vqEfX6xzqW1pKKZUUiFOKg0OQ7bCh54Q2vR/tserrRA= +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= +github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= +github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= +github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM= +github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k= +github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8= +github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= +github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= +github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dlclark/regexp2/v2 v2.2.1 h1:mf4KkFUj0gJuarK8P+LgiS+Lit7m9N1yAwEfPbee7R0= +github.com/dlclark/regexp2/v2 v2.2.1/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU= +github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= +github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= +github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= +github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= +github.com/lucasb-eyer/go-colorful v1.4.1 h1:1EO+WB73+EH8EVbzlrG3KLAfEypQWVHIBqlTf+2hNss= +github.com/lucasb-eyer/go-colorful v1.4.1/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-runewidth v0.0.24 h1:cpokDiIn0MGnhdHwuWnJBITySJ20QyNGnY2kR/ay2DU= +github.com/mattn/go-runewidth v0.0.24/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= +github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/urfave/cli/v3 v3.11.0 h1:P/euJp99kb9p0tlVY+iYTLYYTAQlfl0hR2gUO1Img1Q= +github.com/urfave/cli/v3 v3.11.0/go.mod h1:ysVLtOEmg2tOy6PknnYVhDoouyC/6N42TMeoMzskhso= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +github.com/yuin/goldmark v1.8.4 h1:oat/nd3U6NeQqFEL3xpEJq7d7c86NI+DbSNGAs4xnjA= +github.com/yuin/goldmark v1.8.4/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= +github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs= +github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= +golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= +golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= +golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= +golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= +golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/examples/code/urfavecli/pleasantries/internal/cli/actions.go b/examples/code/urfavecli/pleasantries/internal/cli/actions.go new file mode 100644 index 0000000..0ceed3a --- /dev/null +++ b/examples/code/urfavecli/pleasantries/internal/cli/actions.go @@ -0,0 +1,55 @@ +package cli + +import ( + "context" + "fmt" + "pleasantriescli/internal/gencli" + + "github.com/bcdxn/opencli/spec" +) + +// Ensure we conform to the generated ActionsInterface +var _ gencli.ActionsInterface = (*Actions)(nil) + +func NewActions(version string) *Actions { + return &Actions{ + IOS: gencli.DefaultIOS(), + version: version, + } +} + +// Actions implements the gencli Actions interface and can be passed via the gencli.Factory +type Actions struct { + IOS gencli.IOStreams + version string +} + +func (a Actions) PleasantriesGreet(ctx context.Context, args gencli.PleasantriesGreetArgs, flags gencli.PleasantriesGreetFlags) error { + if flags.Language == gencli.PleasantriesGreetLanguageEnglish { + fmt.Println("Hello", args.Name) + } else { + fmt.Println("Hola", args.Name) + } + return nil +} +func (a Actions) PleasantriesFarewell(ctx context.Context, args gencli.PleasantriesFarewellArgs, flags gencli.PleasantriesFarewellFlags) error { + if flags.Language == gencli.PleasantriesFarewellLanguageEnglish { + fmt.Println("Good bye", args.Name) + } else { + fmt.Println("Adios", args.Name) + } + return nil +} + +func (a Actions) HelpFunc(cmd *spec.CommandItem) { + gencli.DefaultHelpFunc(a, cmd) +} +func (a Actions) UsageFunc(cmd *spec.CommandItem) error { + return gencli.DefaultUsageFunc(a, cmd) +} +func (a Actions) IOStreams() gencli.IOStreams { + return gencli.DefaultIOS() +} +func (a Actions) Version() string { + return a.version +} diff --git a/examples/code/urfavecli/pleasantries/internal/gencli/actions.gen.go b/examples/code/urfavecli/pleasantries/internal/gencli/actions.gen.go new file mode 100644 index 0000000..cd22b85 --- /dev/null +++ b/examples/code/urfavecli/pleasantries/internal/gencli/actions.gen.go @@ -0,0 +1,18 @@ +// Code generated by github.com/bcdxn/opencli@unknown version DO NOT EDIT. +package gencli + +import ( + "context" + + "github.com/bcdxn/opencli/spec" +) + +// ActionsInterface defines all actions the pleasantries CLI supports. +type ActionsInterface interface { + PleasantriesGreet(ctx context.Context, args PleasantriesGreetArgs, flags PleasantriesGreetFlags) error + PleasantriesFarewell(ctx context.Context, args PleasantriesFarewellArgs, flags PleasantriesFarewellFlags) error + HelpFunc(cmd *spec.CommandItem) + UsageFunc(cmd *spec.CommandItem) error + IOStreams() IOStreams + Version() string +} diff --git a/examples/code/urfavecli/pleasantries/internal/gencli/cmd_pleasantries.gen.go b/examples/code/urfavecli/pleasantries/internal/gencli/cmd_pleasantries.gen.go new file mode 100644 index 0000000..8827010 --- /dev/null +++ b/examples/code/urfavecli/pleasantries/internal/gencli/cmd_pleasantries.gen.go @@ -0,0 +1,51 @@ +// Code generated by github.com/bcdxn/opencli@unknown version DO NOT EDIT. +package gencli + +import ( + "context" + "github.com/bcdxn/opencli/spec" + "github.com/urfave/cli/v3" +) + +func NewCmdPleasantries(a ActionsInterface) *cli.Command { + cmd := &cli.Command{ + Name: "pleasantries", + Usage: "A fun CLI to greet or bid farewell", + Description: "", + Metadata: map[string]any{"spec_cmd": getSpecPleasantriesCmd()}, + Action: func(ctx context.Context, c *cli.Command) error { + return BadUserInput("subcommand is required", func() error { + return a.UsageFunc(getSpecPleasantriesCmd()) + }) + }, + } + cmd.Commands = append(cmd.Commands, NewCmdPleasantriesGreet(a)) + cmd.Commands = append(cmd.Commands, NewCmdPleasantriesFarewell(a)) + + return cmd +} + +func getSpecPleasantriesCmd() *spec.CommandItem { + return &spec.CommandItem{ + Segment: "pleasantries", + CommandLine: "pleasantries", + Summary: "A fun CLI to greet or bid farewell", + Description: "", + VisibleChildren: true, + VisibleArgs: false, + VisibleFlags: false, + CommandModifiers: []string{ + "{command}", + }, + ArgsModifiers: []string{ + "", + }, + FlagsModifiers: []string{ + "[flags]", + }, + Commands: []*spec.CommandItem{ + {Segment: "greet", Summary: "Say hello"}, + {Segment: "farewell", Summary: "Say goodbye"}, + }, + } +} diff --git a/examples/code/urfavecli/pleasantries/internal/gencli/cmd_pleasantries_farewell.gen.go b/examples/code/urfavecli/pleasantries/internal/gencli/cmd_pleasantries_farewell.gen.go new file mode 100644 index 0000000..8891510 --- /dev/null +++ b/examples/code/urfavecli/pleasantries/internal/gencli/cmd_pleasantries_farewell.gen.go @@ -0,0 +1,63 @@ +// Code generated by github.com/bcdxn/opencli@unknown version DO NOT EDIT. +package gencli + +import ( + "context" + "github.com/bcdxn/opencli/spec" + "github.com/urfave/cli/v3" +) + +func NewCmdPleasantriesFarewell(a ActionsInterface) *cli.Command { + cmd := &cli.Command{ + Name: "farewell", + Usage: "Say goodbye", + Description: "", + Metadata: map[string]any{"spec_cmd": getSpecPleasantriesFarewellCmd()}, + Action: func(ctx context.Context, c *cli.Command) error { + cmdArgs := PleasantriesFarewellArgs{} + if len(c.Args().Slice()) > 0 { + cmdArgs.Name = c.Args().Slice()[0] + } + cmdFlags := PleasantriesFarewellFlags{ + Language: PleasantriesFarewellLanguage(c.String("language")), + } + if cmdFlags.Language != "" && !cmdFlags.Language.IsValid() { + return BadUserInput("invalid value for --language flag: "+string(cmdFlags.Language), func() error { + return a.UsageFunc(getSpecPleasantriesFarewellCmd()) + }) + } + return a.PleasantriesFarewell(ctx, cmdArgs, cmdFlags) + }, + } + cmd.Flags = append(cmd.Flags, &cli.StringFlag{ + Name: "language", + Value: "english", + Usage: "The language of the farewell", + }) + + return cmd +} + +func getSpecPleasantriesFarewellCmd() *spec.CommandItem { + return &spec.CommandItem{ + Segment: "farewell", + CommandLine: "pleasantries farewell", + Summary: "Say goodbye", + Description: "", + VisibleChildren: false, + VisibleArgs: true, + VisibleFlags: true, + ArgsModifiers: []string{ + "", + }, + FlagsModifiers: []string{ + "[flags]", + }, + Args: []spec.ArgumentItem{ + {Name: "name", Summary: "A name to include in the farewell"}, + }, + Flags: []spec.FlagItem{ + {Name: "language", Summary: "The language of the farewell"}, + }, + } +} diff --git a/examples/code/urfavecli/pleasantries/internal/gencli/cmd_pleasantries_greet.gen.go b/examples/code/urfavecli/pleasantries/internal/gencli/cmd_pleasantries_greet.gen.go new file mode 100644 index 0000000..8d253bc --- /dev/null +++ b/examples/code/urfavecli/pleasantries/internal/gencli/cmd_pleasantries_greet.gen.go @@ -0,0 +1,63 @@ +// Code generated by github.com/bcdxn/opencli@unknown version DO NOT EDIT. +package gencli + +import ( + "context" + "github.com/bcdxn/opencli/spec" + "github.com/urfave/cli/v3" +) + +func NewCmdPleasantriesGreet(a ActionsInterface) *cli.Command { + cmd := &cli.Command{ + Name: "greet", + Usage: "Say hello", + Description: "", + Metadata: map[string]any{"spec_cmd": getSpecPleasantriesGreetCmd()}, + Action: func(ctx context.Context, c *cli.Command) error { + cmdArgs := PleasantriesGreetArgs{} + if len(c.Args().Slice()) > 0 { + cmdArgs.Name = c.Args().Slice()[0] + } + cmdFlags := PleasantriesGreetFlags{ + Language: PleasantriesGreetLanguage(c.String("language")), + } + if cmdFlags.Language != "" && !cmdFlags.Language.IsValid() { + return BadUserInput("invalid value for --language flag: "+string(cmdFlags.Language), func() error { + return a.UsageFunc(getSpecPleasantriesGreetCmd()) + }) + } + return a.PleasantriesGreet(ctx, cmdArgs, cmdFlags) + }, + } + cmd.Flags = append(cmd.Flags, &cli.StringFlag{ + Name: "language", + Value: "english", + Usage: "The language of the greeting", + }) + + return cmd +} + +func getSpecPleasantriesGreetCmd() *spec.CommandItem { + return &spec.CommandItem{ + Segment: "greet", + CommandLine: "pleasantries greet", + Summary: "Say hello", + Description: "", + VisibleChildren: false, + VisibleArgs: true, + VisibleFlags: true, + ArgsModifiers: []string{ + "", + }, + FlagsModifiers: []string{ + "[flags]", + }, + Args: []spec.ArgumentItem{ + {Name: "name", Summary: "A name to include in the greeting"}, + }, + Flags: []spec.FlagItem{ + {Name: "language", Summary: "The language of the greeting"}, + }, + } +} diff --git a/examples/code/gencli/errors.gen.go b/examples/code/urfavecli/pleasantries/internal/gencli/errors.gen.go similarity index 100% rename from examples/code/gencli/errors.gen.go rename to examples/code/urfavecli/pleasantries/internal/gencli/errors.gen.go diff --git a/examples/code/urfavecli/pleasantries/internal/gencli/help.gen.go b/examples/code/urfavecli/pleasantries/internal/gencli/help.gen.go new file mode 100644 index 0000000..00eb52f --- /dev/null +++ b/examples/code/urfavecli/pleasantries/internal/gencli/help.gen.go @@ -0,0 +1,428 @@ +// Code generated by github.com/bcdxn/opencli@unknown version DO NOT EDIT. +package gencli + +import ( + "fmt" + "io" + "strings" + + "charm.land/glamour/v2" + "charm.land/lipgloss/v2" + "github.com/bcdxn/opencli/spec" + "github.com/urfave/cli/v3" +) + +var maxWidth = 100 + +var mdFormatting = []byte(`{ + "document": { + "block_prefix": "\n", + "block_suffix": "\n", + "margin": 0 + }, + "heading": { + "block_suffix": "\n", + "bold": true + }, + "h1": { + "prefix": " ", + "suffix": " ", + "bold": true + }, + "h2": { + "prefix": "## ", + "bold": true + }, + "emph": { + "underline": true + }, + "strong": { + "bold": true + }, + "link": { + "underline": true + }, + "code_block": { + "margin": 2 + }, + "list": { + "indent": 2 + }, + "item": { + "prefix": "• " + } +}`) + +var lightTheme = []byte(`{ + "document": { + "color": "236" + }, + "heading": { + "color": "239" + }, + "h1": { + "color": "236", + "background_color": "252" + }, + "h2": { + "color": "238" + }, + "link": { + "color": "31" + }, + "code": { + "color": "167", + "background_color": "254" + }, + "code_block": { + "color": "244" + } +}`) + +var darkTheme = []byte(`{ + "document": { + "color": "251" + }, + "heading": { + "color": "250" + }, + "h1": { + "color": "252", + "background_color": "238" + }, + "h2": { + "color": "250" + }, + "link": { + "color": "110" + }, + "code": { + "color": "180", + "background_color": "237" + }, + "code_block": { + "color": "246" + } +}`) + +var noPadding = []byte(`{ + "document": { + "block_prefix": "", + "block_suffix": "", + "margin": 0 + } +}`) + +var bold = lipgloss.NewStyle().Bold(true) + +func markdownTheme(a ActionsInterface) []byte { + if a.IOStreams().TerminalTheme() == "dark" { + return darkTheme + } + + return lightTheme +} + +// DefaultHelpFunc renders contextual help for cmd to the actions IOStreams output. +func DefaultHelpFunc(a ActionsInterface, cmd *spec.CommandItem) { + stdout := a.IOStreams().Out() + w, _, _ := a.IOStreams().TerminalSize() + r, _ := glamour.NewTermRenderer( + glamour.WithStylesFromJSONBytes(mdFormatting), + glamour.WithStylesFromJSONBytes(markdownTheme(a)), + glamour.WithWordWrap(w), + ) + + desc := []string{cmd.Summary} + if cmd.Description != "" { + desc = append(desc, cmd.Description) + } + + formattedDesc, err := r.Render(strings.Join(desc, "\n\n")) + if err != nil { + panic(err) + } + + lipgloss.Fprint(stdout, formattedDesc) + lipgloss.Fprint(stdout, bold.Render("USAGE:")) + lipgloss.Fprint(stdout, useLine(cmd)) + + if cmd.VisibleChildren { + lipgloss.Fprintf(stdout, "\n%s\n", bold.Render("AVAILABLE COMMANDS")) + lipgloss.Fprint(stdout, availableCommands(a, cmd)) + } + + if cmd.VisibleArgs { + lipgloss.Fprintf(stdout, "\n\n%s\n", bold.Render("ARGUMENTS")) + lipgloss.Fprint(a.IOStreams().Out(), availableArgs(a, cmd)) + } + + if cmd.VisibleFlags { + lipgloss.Fprintf(stdout, "\n\n%s\n", bold.Render("FLAGS")) + lipgloss.Fprint(stdout, availableFlags(a, cmd)) + } +} + +// DefaultUsageFunc renders contextual usage for cmd to the actions IOStreams output. +func DefaultUsageFunc(a ActionsInterface, cmd *spec.CommandItem) error { + stdout := a.IOStreams().Out() + + lipgloss.Fprint(stdout, bold.Render("USAGE:")) + lipgloss.Fprint(stdout, useLine(cmd)) + + if cmd.VisibleChildren { + lipgloss.Fprintf(stdout, "\n%s\n", bold.Render("AVAILABLE COMMANDS")) + lipgloss.Fprint(stdout, availableCommands(a, cmd)) + } + + if cmd.VisibleArgs { + lipgloss.Fprintf(stdout, "\n\n%s\n", bold.Render("ARGUMENTS")) + lipgloss.Fprint(a.IOStreams().Out(), availableArgs(a, cmd)) + } + + if cmd.VisibleFlags { + lipgloss.Fprintf(stdout, "\n\n%s\n", bold.Render("FLAGS")) + lipgloss.Fprint(stdout, availableFlags(a, cmd)) + lipgloss.Fprint(stdout, "\n") + } + + return nil +} + +func useLine(cmd *spec.CommandItem) string { + line := []string{cmd.CommandLine} + + if len(cmd.CommandModifiers) > 0 { + line = append(line, strings.Join(cmd.CommandModifiers, " ")) + } + if len(cmd.ArgsModifiers) > 0 { + line = append(line, strings.Join(cmd.ArgsModifiers, " ")) + } + if len(cmd.FlagsModifiers) > 0 { + line = append(line, strings.Join(cmd.FlagsModifiers, " ")) + } + + return fmt.Sprintf("\n %s\n", strings.Join(line, " ")) +} + +func availableCommands(a ActionsInterface, cmd *spec.CommandItem) string { + w, _, _ := a.IOStreams().TerminalSize() + if w > maxWidth { + w = maxWidth + } + + names := []string{} + for _, subcmd := range cmd.Commands { + names = append(names, subcmd.Segment) + } + + leftColWidth := columnWidth(names) + 3 + + r, err := glamour.NewTermRenderer( + glamour.WithStylesFromJSONBytes(mdFormatting), + glamour.WithStylesFromJSONBytes(markdownTheme(a)), + glamour.WithStylesFromJSONBytes(noPadding), + glamour.WithWordWrap(w-leftColWidth), + ) + if err != nil { + panic(err) + } + + leftStyle := lipgloss.NewStyle(). + Width(leftColWidth). + PaddingRight(1). + PaddingLeft(2) + + rightStyle := lipgloss.NewStyle(). + Width(w - leftColWidth) + + rows := []string{} + for _, subcmd := range cmd.Commands { + formattedName := leftStyle.Render(subcmd.Segment) + formattedSummary, err := r.Render(subcmd.Summary) + if err != nil { + panic(err) + } + + formattedSummary = strings.TrimSuffix(formattedSummary, "\n") + formattedSummary = rightStyle.Render(formattedSummary) + rows = append(rows, lipgloss.JoinHorizontal( + lipgloss.Top, + formattedName, + formattedSummary, + )) + } + + return lipgloss.JoinVertical(lipgloss.Top, rows...) +} + +func availableArgs(a ActionsInterface, cmd *spec.CommandItem) string { + w, _, _ := a.IOStreams().TerminalSize() + if w > maxWidth { + w = maxWidth + } + + names := []string{} + for _, arg := range cmd.Args { + names = append(names, fmt.Sprintf("<%s>", arg.Name)) + } + + leftColWidth := columnWidth(names) + 3 + + r, err := glamour.NewTermRenderer( + glamour.WithStylesFromJSONBytes(mdFormatting), + glamour.WithStylesFromJSONBytes(markdownTheme(a)), + glamour.WithStylesFromJSONBytes(noPadding), + glamour.WithWordWrap(w-leftColWidth), + ) + if err != nil { + panic(err) + } + + leftStyle := lipgloss.NewStyle(). + Width(leftColWidth). + PaddingRight(1). + PaddingLeft(2) + + rightStyle := lipgloss.NewStyle(). + Width(w - leftColWidth) + + rows := []string{} + for _, arg := range cmd.Args { + formattedName := leftStyle.Render(fmt.Sprintf("<%s>", arg.Name)) + formattedSummary, err := r.Render(arg.Summary) + if err != nil { + panic(err) + } + + formattedSummary = strings.TrimSuffix(formattedSummary, "\n") + formattedSummary = rightStyle.Render(formattedSummary) + rows = append(rows, lipgloss.JoinHorizontal( + lipgloss.Top, + formattedName, + formattedSummary, + )) + } + + return lipgloss.JoinVertical(lipgloss.Top, rows...) +} + +func availableFlags(a ActionsInterface, cmd *spec.CommandItem) string { + w, _, _ := a.IOStreams().TerminalSize() + if w > maxWidth { + w = maxWidth + } + + names := []string{} + for _, flag := range cmd.Flags { + names = append(names, flagNameWithAliases(flag)) + } + + leftColWidth := columnWidth(names) + 3 + + r, err := glamour.NewTermRenderer( + glamour.WithStylesFromJSONBytes(mdFormatting), + glamour.WithStylesFromJSONBytes(markdownTheme(a)), + glamour.WithStylesFromJSONBytes(noPadding), + glamour.WithWordWrap(w-leftColWidth), + ) + if err != nil { + panic(err) + } + + leftStyle := lipgloss.NewStyle(). + Width(leftColWidth). + PaddingRight(1). + PaddingLeft(2) + + rightStyle := lipgloss.NewStyle(). + Width(w - leftColWidth) + + rows := []string{} + for _, flag := range cmd.Flags { + formattedName := leftStyle.Render(flagNameWithAliases(flag)) + formattedSummary, err := r.Render(flag.Summary) + if err != nil { + panic(err) + } + + formattedSummary = strings.TrimSuffix(formattedSummary, "\n") + formattedSummary = rightStyle.Render(formattedSummary) + rows = append(rows, lipgloss.JoinHorizontal( + lipgloss.Top, + formattedName, + formattedSummary, + )) + } + + return lipgloss.JoinVertical(lipgloss.Top, rows...) +} + +func flagNameWithAliases(flag spec.FlagItem) string { + flagWithAliases := []string{fmt.Sprintf("--%s", flag.Name)} + for _, alias := range flag.Aliases { + flagWithAliases = append(flagWithAliases, fmt.Sprintf("-%s", alias)) + } + return strings.Join(flagWithAliases, " ") +} + +func columnWidth(rows []string) int { + max := 0 + for _, row := range rows { + if len(row) > max { + max = len(row) + } + } + return max +} + +// urfaveHelpTemplate is the custom help template for urfave/cli commands. +// It mirrors the section structure of our DefaultHelpFunc output (DESCRIPTION, USAGE, +// AVAILABLE COMMANDS, FLAGS). The actual rendering is handled by our custom HelpPrinter +// override which uses glamour/lipgloss for styled terminal output. +const urfaveHelpTemplate = `{{- if .Description}} + +DESCRIPTION: + {{wrap .Description 3}} +{{end}}{{- if .UsageText}}{{else}} + +USAGE: + {{.FullName}}{{if .VisibleFlags}}} [flags]{{end}}}{{if .VisibleCommands}}} [command]{{end}}}{{if .ArgsUsage}}} {{.ArgsUsage}}{{else}}}{{if .Arguments}}} [arguments]{{end}}{{end}}{{end}} +{{- if .VisibleCommands}} + +AVAILABLE COMMANDS:{{range .VisibleCommands}} + {{.Name}} - {{.Usage}} +{{end}} +{{end}}}{{- if .VisibleFlags}} + +FLAGS:{{range .VisibleFlags}} + {{.String}} +{{end}} +{{end}}` + +// SetupUrfaveHelpPrinter overrides the urfave/cli HelpPrinter to use our custom +// glamour-based help rendering pipeline. The override captures the ActionsInterface +// and delegates to DefaultHelpFunc for styled output when a spec.CommandItem is +// available in the command's metadata. +func SetupUrfaveHelpPrinter(a ActionsInterface) { + cli.RootCommandHelpTemplate = urfaveHelpTemplate + cli.CommandHelpTemplate = urfaveHelpTemplate + cli.SubcommandHelpTemplate = urfaveHelpTemplate + + cli.HelpPrinter = func(w io.Writer, templ string, data any) { + cmd, ok := data.(*cli.Command) + if !ok { + cli.DefaultPrintHelp(w, templ, data) + return + } + + // Extract the spec.CommandItem from metadata and render with our custom help function + if specCmd, ok := cmd.Metadata["spec_cmd"].(*spec.CommandItem); ok { + DefaultHelpFunc(a, specCmd) + return + } + + // Fallback to default urfave/cli rendering when spec metadata is not available + cli.DefaultPrintHelp(w, templ, data) + } +} diff --git a/examples/code/gencli/iostreams.gen.go b/examples/code/urfavecli/pleasantries/internal/gencli/iostreams.gen.go similarity index 100% rename from examples/code/gencli/iostreams.gen.go rename to examples/code/urfavecli/pleasantries/internal/gencli/iostreams.gen.go diff --git a/examples/code/urfavecli/pleasantries/internal/gencli/params.gen.go b/examples/code/urfavecli/pleasantries/internal/gencli/params.gen.go new file mode 100644 index 0000000..e91bb74 --- /dev/null +++ b/examples/code/urfavecli/pleasantries/internal/gencli/params.gen.go @@ -0,0 +1,54 @@ +// Code generated by github.com/bcdxn/opencli@unknown version DO NOT EDIT. +package gencli + +// PleasantriesGreetLanguage represents the allowed values for the Language flag. +type PleasantriesGreetLanguage string + +const ( + PleasantriesGreetLanguageEnglish PleasantriesGreetLanguage = "english" + PleasantriesGreetLanguageSpanish PleasantriesGreetLanguage = "spanish" +) + +func (v PleasantriesGreetLanguage) IsValid() bool { + switch v { + case PleasantriesGreetLanguageEnglish, PleasantriesGreetLanguageSpanish: + return true + } + return false +} + +// PleasantriesGreetArgs holds the positional arguments for the PleasantriesGreet action. +type PleasantriesGreetArgs struct { + Name string +} + +// PleasantriesGreetFlags holds the flag values for the PleasantriesGreet action. +type PleasantriesGreetFlags struct { + Language PleasantriesGreetLanguage +} + +// PleasantriesFarewellLanguage represents the allowed values for the Language flag. +type PleasantriesFarewellLanguage string + +const ( + PleasantriesFarewellLanguageEnglish PleasantriesFarewellLanguage = "english" + PleasantriesFarewellLanguageSpanish PleasantriesFarewellLanguage = "spanish" +) + +func (v PleasantriesFarewellLanguage) IsValid() bool { + switch v { + case PleasantriesFarewellLanguageEnglish, PleasantriesFarewellLanguageSpanish: + return true + } + return false +} + +// PleasantriesFarewellArgs holds the positional arguments for the PleasantriesFarewell action. +type PleasantriesFarewellArgs struct { + Name string +} + +// PleasantriesFarewellFlags holds the flag values for the PleasantriesFarewell action. +type PleasantriesFarewellFlags struct { + Language PleasantriesFarewellLanguage +} diff --git a/examples/code/gencli/run.go b/examples/code/urfavecli/pleasantries/internal/gencli/run.gen.go similarity index 67% rename from examples/code/gencli/run.go rename to examples/code/urfavecli/pleasantries/internal/gencli/run.gen.go index 889f43f..e7c744f 100644 --- a/examples/code/gencli/run.go +++ b/examples/code/urfavecli/pleasantries/internal/gencli/run.gen.go @@ -5,17 +5,21 @@ import ( "context" "errors" "fmt" + "os" ) -// Run executes the root cobra command and returns an exit code. +// Run executes the root urfave command and returns an exit code. func Run(ctx context.Context, actions ActionsInterface) int { + // Setup custom help printer with glamour/lipgloss rendering + SetupUrfaveHelpPrinter(actions) + // Instantiate root command - rootCmd := NewCmdPetstore(actions) + rootCmd := NewCmdPleasantries(actions) // Add version for `--version` flag rootCmd.Version = actions.Version() // Run the CLI - if _, err := rootCmd.ExecuteContextC(ctx); err != nil { + if err := rootCmd.Run(ctx, os.Args); err != nil { fmt.Fprintf(actions.IOStreams().Out(), "error: %v\n\n", err.Error()) if cliErr, ok := errors.AsType[*CLIError](err); ok { diff --git a/examples/code/urfavecli/pleasantries/main.go b/examples/code/urfavecli/pleasantries/main.go new file mode 100644 index 0000000..2660825 --- /dev/null +++ b/examples/code/urfavecli/pleasantries/main.go @@ -0,0 +1,18 @@ +package main + +import ( + "context" + "os" + + "pleasantriescli/internal/cli" + "pleasantriescli/internal/gencli" +) + +// Version is set by goreleaser (ldflags) during build process +var version = "DEV" + +func main() { + actions := cli.NewActions(version) + code := gencli.Run(context.Background(), actions) + os.Exit(code) +} diff --git a/examples/code/yargs/pleasantries/src/gencli/help.ts b/examples/code/yargs/pleasantries/src/gencli/help.ts index bb1f77e..e176dd8 100644 --- a/examples/code/yargs/pleasantries/src/gencli/help.ts +++ b/examples/code/yargs/pleasantries/src/gencli/help.ts @@ -79,11 +79,11 @@ function appendArgSections( if (cmd.args?.length) { sections.push({ header: "ARGUMENTS", - optionList: cmd.args.map((a) => ({ + content: cmd.args.map((a) => ({ name: a.name, description: escapeChalk(a.summary), })), - } as commandLineUsage.OptionList); + } as commandLineUsage.Content); } } diff --git a/examples/code/yargs/pleasantries/src/gencli/run.ts b/examples/code/yargs/pleasantries/src/gencli/run.ts index 8c83e7e..6a1d69f 100644 --- a/examples/code/yargs/pleasantries/src/gencli/run.ts +++ b/examples/code/yargs/pleasantries/src/gencli/run.ts @@ -1,5 +1,6 @@ // Code generated by github.com/bcdxn/opencli@unknown version DO NOT EDIT. import yargs from "yargs"; +import { hideBin } from "yargs/helpers"; import { newPleasantriesGreetCmd } from "./cmd-pleasantries-greet"; import { newPleasantriesFarewellCmd } from "./cmd-pleasantries-farewell"; import { ActionsInterface } from "./actions"; @@ -8,10 +9,10 @@ import { CliError, ExitCode } from "./errors"; import { defaultHelpFn, defaultUsageFn } from "./help"; export async function run( - yargsInstance: yargs.Argv<{}>, + argv: string[], actions: ActionsInterface, ): Promise { - await yargsInstance + await yargs(hideBin(argv)) .scriptName("pleasantries") .help(false) .command(newPleasantriesGreetCmd(actions)) diff --git a/examples/code/yargs/pleasantries/src/index.ts b/examples/code/yargs/pleasantries/src/index.ts index 30d0e44..7bfc386 100644 --- a/examples/code/yargs/pleasantries/src/index.ts +++ b/examples/code/yargs/pleasantries/src/index.ts @@ -1,14 +1,12 @@ #!/usr/bin/env node -import yargs from "yargs"; -import { hideBin } from "yargs/helpers"; import { run } from "./gencli/run"; import { Actions } from "./actions"; // Parse arguments using yargs async function main() { const actions = new Actions(); - await run(yargs(hideBin(process.argv)), actions); + await run(process.argv, actions); } main().catch((err) => { diff --git a/gen/cli.go b/gen/cli.go index dfc633e..d9f25ce 100644 --- a/gen/cli.go +++ b/gen/cli.go @@ -67,6 +67,10 @@ func CLI(doc *spec.Document, options ...GenCLIOption) (map[string][]byte, error) return nil, fmt.Errorf("provided specification document was nil") } + if err := validateNoGroupFlagsArgs(doc); err != nil { + return nil, err + } + if bi, ok := debug.ReadBuildInfo(); ok { if bi.Main.Version != "" { moduleVersion = bi.Main.Version @@ -97,6 +101,38 @@ func CLI(doc *spec.Document, options ...GenCLIOption) (map[string][]byte, error) return nil, fmt.Errorf("unsupported CLI framework: %s", opts.Framework) } +// validateNoGroupFlagsArgs ensures no group command declares arguments or flags. +// Group commands are pure containers for subcommands; generated code has nowhere +// to read their args/flags from as opencli spec does not support the concept of +// persisted flags outside of global flags (and the cobra template would emit flag +// bindings referencing undeclared variables). This mirrors the rule enforced by the +// validate package so generation fails cleanly even when callers skip validation, +// using the same group predicate as walkCmdTree. +// This could be something we remove in the future if we want to support persisted flags +func validateNoGroupFlagsArgs(doc *spec.Document) error { + var walk func(cmd *spec.CommandItem) error + walk = func(cmd *spec.CommandItem) error { + if cmd == nil { + return nil + } + isGroup := cmd.Kind == spec.CommandKindGroup || len(cmd.Commands) > 0 + if isGroup && (len(cmd.Flags) > 0 || len(cmd.Args) > 0) { + name := cmd.CommandLine + if name == "" { + name = cmd.Segment + } + return fmt.Errorf("command %q: group commands cannot have arguments or flags", name) + } + for _, subcmd := range cmd.Commands { + if err := walk(subcmd); err != nil { + return err + } + } + return nil + } + return walk(doc.Commands) +} + /* Functional Options ------------------------------------------------------------------------------------------------- */ diff --git a/gen/cli_cobra.go b/gen/cli_cobra.go index 69ea63f..77db3f9 100644 --- a/gen/cli_cobra.go +++ b/gen/cli_cobra.go @@ -6,6 +6,7 @@ import ( "fmt" "go/format" "path/filepath" + "strconv" "strings" "text/template" @@ -20,6 +21,16 @@ type cliAllCommandsTmplData struct { LeafCommands []cliCmdEntry ExitCodes []spec.ExitCode GlobalFlags []cobraFlagEntry + // Config file paths from global.config (only formats that are declared) + ConfigJSON string + ConfigTOML string + ConfigYAML string + // HasAltSources is true if any flag declares an alternative source, which + // requires emitting gencli/config.gen.go and calling loadConfig at startup. + HasAltSources bool + // HasFileAltSource is true if any flag declares a $FILE alternative source, + // which requires the generated config code to import a JSONPath library. + HasFileAltSource bool } // cliCmdEntry holds the pre-computed template data for a single leaf command. @@ -53,6 +64,7 @@ type cobraCommandFileTmplData struct { ChildImports []subCmdImport CobraArgs []cobraArgEntry CobraFlags []cobraFlagEntry + GlobalFlags []cobraFlagEntry // non-help/version global flags, shared by all leaf commands } // subCmdImport holds data needed to call a child command constructor (same package, no import). @@ -82,6 +94,7 @@ type cobraFlagEntry struct { TypeName string // non-empty when the struct field uses a generated type (needs cast) Shorthand string // first single-char alias, or empty string ExtraAliases []string // aliases not used as shorthand; mapped via SetNormalizeFunc + AltSources []spec.AlternativeSource } //go:embed templates/code/cobra @@ -108,8 +121,12 @@ func genCLICobra(doc *spec.Document, opts *genCLIOptions) (map[string][]byte, er var exitCodes []spec.ExitCode var globalFlags []cobraFlagEntry + configJSON, configTOML, configYAML := "", "", "" if doc.Global != nil { exitCodes = doc.Global.ExitCodes + configJSON = doc.Global.Config.JSON + configTOML = doc.Global.Config.TOML + configYAML = doc.Global.Config.YAML for _, flag := range doc.Global.Flags { if flag.Name == "help" || flag.Name == "version" { continue @@ -125,17 +142,33 @@ func genCLICobra(doc *spec.Document, opts *genCLIOptions) (map[string][]byte, er Summary: flag.Summary, Shorthand: shorthand, ExtraAliases: extraAliases, + AltSources: flag.AltSources, }) } } + // Track alternative-source usage across all flags so we know whether to emit + // gencli/config.gen.go (and call loadConfig from run.tmpl). A $FILE source + // additionally requires the generated config code to import a JSONPath library. + hasAltSources, hasFileAltSource := scanCobraAltSources(globalFlags) + for i := range cmdFiles { + cmdHasAlt, cmdHasFile := scanCobraAltSources(cmdFiles[i].CobraFlags) + hasAltSources = hasAltSources || cmdHasAlt + hasFileAltSource = hasFileAltSource || cmdHasFile + } + allCmdsData := cliAllCommandsTmplData{ - ModuleVersion: opts.ModuleVersion, - Binary: binary, - BinaryPascal: binaryPascal, - LeafCommands: leafCommands, - ExitCodes: exitCodes, - GlobalFlags: globalFlags, + ModuleVersion: opts.ModuleVersion, + Binary: binary, + BinaryPascal: binaryPascal, + LeafCommands: leafCommands, + ExitCodes: exitCodes, + GlobalFlags: globalFlags, + ConfigJSON: configJSON, + ConfigTOML: configTOML, + ConfigYAML: configYAML, + HasFileAltSource: hasFileAltSource, + HasAltSources: hasAltSources, } funcMap := cobraTemplateFuncMap() @@ -146,11 +179,18 @@ func genCLICobra(doc *spec.Document, opts *genCLIOptions) (map[string][]byte, er } gencliFiles := []gencliFile{ {"gencli/actions.gen.go", "templates/code/cobra/gencli/actions.tmpl"}, + // Only emitted when at least one flag declares alternative sources, so that + // specs without this feature produce no extra files or imports. + } + if hasAltSources { + gencliFiles = append(gencliFiles, gencliFile{"gencli/config.gen.go", "templates/code/cobra/gencli/config.tmpl"}) + } + gencliFiles = append(gencliFiles, []gencliFile{ {"gencli/errors.gen.go", "templates/code/cobra/gencli/errors.tmpl"}, {"gencli/help.gen.go", "templates/code/cobra/gencli/help.tmpl"}, {"gencli/iostreams.gen.go", "templates/code/cobra/gencli/iostreams.tmpl"}, {"gencli/params.gen.go", "templates/code/cobra/gencli/params.tmpl"}, - } + }...) for _, f := range gencliFiles { content, err := renderCobraTemplate(f.tmplPath, funcMap, allCmdsData) @@ -166,15 +206,16 @@ func genCLICobra(doc *spec.Document, opts *genCLIOptions) (map[string][]byte, er runContent, err := renderCobraTemplate("templates/code/cobra/gencli/run.tmpl", funcMap, allCmdsData) if err != nil { - return nil, fmt.Errorf("rendering gencli/run.go: %w", err) + return nil, fmt.Errorf("rendering gencli/run.gen.go: %w", err) } formattedRun, err := format.Source(runContent) if err != nil { - return nil, fmt.Errorf("formatting gencli/run.go: %w\nsource:\n%s", err, runContent) + return nil, fmt.Errorf("formatting gencli/run.gen.go: %w\nsource:\n%s", err, runContent) } - out["gencli/run.go"] = formattedRun + out["gencli/run.gen.go"] = formattedRun for _, cmdFile := range cmdFiles { + cmdFile.GlobalFlags = globalFlags content, err := renderCobraTemplate("templates/code/cobra/gencli/command.tmpl", funcMap, cmdFile) if err != nil { return nil, fmt.Errorf("rendering %s: %w", cmdFile.OutPath, err) @@ -308,6 +349,7 @@ func walkCmdTree( TypeName: flagTypeName, Shorthand: shorthand, ExtraAliases: extraAliases, + AltSources: flag.AltSources, }) } @@ -381,7 +423,65 @@ func cobraTemplateFuncMap() template.FuncMap { } return false }, + // resolveFlagValue returns the Go expression that yields a flag's value inside + // RunE. Without alternative sources it is just the bound variable (optionally cast + // to a generated choices type). With alternative sources it calls the matching + // resolver from gencli/config.gen.go, which prefers the CLI value when the flag was + // set on the command line and otherwise falls back to env/config in declared order. + "resolveFlagValue": func(f cobraFlagEntry) string { + if len(f.AltSources) == 0 { + return plainCobraFlagExpr(f) + } + resolver, ok := cobraCliAltSourceResolvers[f.GoType] + if !ok { + return plainCobraFlagExpr(f) + } + expr := fmt.Sprintf("%s(c.Flags(), %q, %s)", resolver, f.FlagName, formatAltSources(f.AltSources)) + if f.TypeName != "" { + expr = fmt.Sprintf("%s(%s)", f.TypeName, expr) + } + return expr + }, + } +} + +// plainCobraFlagExpr returns the expression for a flag with no alternative sources: the bound +// variable, cast to its generated choices type when one exists. +func plainCobraFlagExpr(f cobraFlagEntry) string { + if f.TypeName != "" { + return fmt.Sprintf("%s(%s)", f.TypeName, f.VarName) } + return f.VarName +} + +// scanCobraAltSources reports whether any flag in the slice declares an alternative +// source (hasAlt), and specifically whether any of them is a $FILE source +// (hasFile). A $FILE source requires the generated config code to import a JSONPath library. +func scanCobraAltSources(flags []cobraFlagEntry) (hasAlt, hasFile bool) { + for _, f := range flags { + if len(f.AltSources) > 0 { + hasAlt = true + } + for _, src := range f.AltSources { + if src.Type == "$FILE" { + hasFile = true + } + } + } + return hasAlt, hasFile +} + +// cobraCliAltSourceResolvers maps a Go flag type to the generated resolver function in +// gencli/config.gen.go that falls back to alternative sources when the flag is not set. +var cobraCliAltSourceResolvers = map[string]string{ + "string": "resolveStringFlag", + "int64": "resolveInt64Flag", + "bool": "resolveBoolFlag", + "float64": "resolveFloat64Flag", + "[]string": "resolveStringSliceFlag", + "[]int64": "resolveInt64SliceFlag", + "[]bool": "resolveBoolSliceFlag", + "[]float64": "resolveFloat64SliceFlag", } // cobraBindFn returns the cobra Flags() method name for the given spec type and variadic flag. @@ -413,7 +513,7 @@ func cobraBindFn(t string, variadic bool) string { // cobraDefaultVal returns the Go literal for the default value of a cobra flag. func cobraDefaultVal(val any, t string, variadic bool) string { - switch val.(type) { + switch v := val.(type) { case string: return fmt.Sprintf("\"%s\"", strings.ReplaceAll(fmt.Sprintf("%s", val), "\"", "\\\"")) case int, int32, int64: @@ -422,6 +522,30 @@ func cobraDefaultVal(val any, t string, variadic bool) string { return fmt.Sprintf("%f", val) case bool: return fmt.Sprintf("%t", val) + case []string: + parts := make([]string, len(v)) + for i, s := range v { + parts[i] = fmt.Sprintf("\"%s\"", strings.ReplaceAll(s, "\"", "\\\"")) + } + return "[]string{" + strings.Join(parts, ", ") + "}" + case []int64: + parts := make([]string, len(v)) + for i, n := range v { + parts[i] = strconv.FormatInt(n, 10) + } + return "[]int64{" + strings.Join(parts, ", ") + "}" + case []float64: + parts := make([]string, len(v)) + for i, f := range v { + parts[i] = strconv.FormatFloat(f, 'f', -1, 64) + } + return "[]float64{" + strings.Join(parts, ", ") + "}" + case []bool: + parts := make([]string, len(v)) + for i, b := range v { + parts[i] = strconv.FormatBool(b) + } + return "[]bool{" + strings.Join(parts, ", ") + "}" } // no default was provided in the spec, use a zero value for cobra diff --git a/gen/cli_globalflags_test.go b/gen/cli_globalflags_test.go new file mode 100644 index 0000000..18c6d33 --- /dev/null +++ b/gen/cli_globalflags_test.go @@ -0,0 +1,78 @@ +package gen + +import ( + "bytes" + _ "embed" + "os" + "path/filepath" + "testing" + + "github.com/bcdxn/opencli/codec" +) + +// globalFlagsYAML is a minimal spec that declares genuine (non-help/version) +// global flags plus one leaf per action-call shape, so the generated output for +// every framework exercises the context-injection / setGlobalFlags code paths. +// +//go:embed testdata/globalflags-cli.ocs.yaml +var globalFlagsYAML []byte + +// TestCLI_GlobalFlags generates the global-flags fixture for each framework and +// compares against goldens under testdata//globalflags/. This locks in +// the non-breaking "inject into context / setGlobalFlags" pattern: action method +// signatures carry no global param, while handlers inject via WithGlobalFlags (Go) +// or call setGlobalFlags before invoking the action (Yargs). +func TestCLI_GlobalFlags(t *testing.T) { + doc, err := codec.UnmarshalYAML(globalFlagsYAML) + if err != nil { + t.Fatalf("unexpected error unmarshaling global-flags fixture: %v", err) + } + + for _, framework := range []CLIFramework{CobraFramework, UrfaveCliFramework, YargsFramework} { + fwDir := globalFlagsGoldenDir(framework) // cobra | urfavecli | yargs + t.Run(string(framework), func(t *testing.T) { + files, err := CLI(doc, GenCLIWithFramework(framework)) + if err != nil { + t.Fatalf("unexpected error generating %s CLI: %v", framework, err) + } + if len(files) == 0 { + t.Fatal("expected generated files but got none") + } + + for relPath, content := range files { + goldenPath := filepath.Join("testdata", fwDir, "globalflags", relPath) + + if *update { + if err := os.MkdirAll(filepath.Dir(goldenPath), 0755); err != nil { + t.Fatalf("failed to create golden dir for %s: %v", goldenPath, err) + } + if err := os.WriteFile(goldenPath, content, 0644); err != nil { + t.Fatalf("failed to write golden file %s: %v", goldenPath, err) + } + continue + } + + expected, err := os.ReadFile(goldenPath) + if err != nil { + t.Fatalf("failed to read golden file %s (run with -update to generate): %v", goldenPath, err) + } + + if !bytes.Equal(content, expected) { + t.Errorf("generated file %s does not match golden file", relPath) + } + } + }) + } +} + +// globalFlagsGoldenDir maps a framework to its golden-file directory name. +func globalFlagsGoldenDir(f CLIFramework) string { + switch f { + case CobraFramework: + return "cobra" + case UrfaveCliFramework: + return "urfavecli" + default: // YargsFramework + return "yargs" + } +} diff --git a/gen/cli_group_test.go b/gen/cli_group_test.go new file mode 100644 index 0000000..3c5c0a8 --- /dev/null +++ b/gen/cli_group_test.go @@ -0,0 +1,136 @@ +package gen + +import ( + "strings" + "testing" + + "github.com/bcdxn/opencli/codec" + "github.com/bcdxn/opencli/spec" +) + +// groupTestDoc builds a minimal spec document whose root command is the given +// item. Used to exercise generation-time rejection of args/flags on groups, +// which validation forbids but callers may skip (e.g., ocli gen cli). +func groupTestDoc(root *spec.CommandItem) *spec.Document { + return &spec.Document{ + OpenCLIVersion: "1.0.0-alpha.13", + Info: spec.Info{Title: "Group Test CLI", Binary: "grouptest"}, + Commands: root, + } +} + +func groupTestFlag(name string) []spec.FlagItem { + return []spec.FlagItem{{Name: name}} +} + +// TestCLI_GroupWithFlagsRejected ensures a group command that declares flags is +// rejected at generation time for every framework. The cobra template used to +// emit flag bindings referencing undeclared variables in this case, producing +// code that did not compile; the guard now fails cleanly instead. +func TestCLI_GroupWithFlagsRejected(t *testing.T) { + // Explicit kind: group with a flag on an explicit-kind root command. + explicit := groupTestDoc(&spec.CommandItem{ + Segment: "grouptest", + Kind: spec.CommandKindGroup, + Commands: []*spec.CommandItem{{Segment: "leaf"}}, + }) + explicit.Commands.Flags = groupTestFlag("count") + + // Implicit group (no kind) with a flag on the parent that has children. + implicit := groupTestDoc(&spec.CommandItem{ + Segment: "grouptest", + Commands: []*spec.CommandItem{{Segment: "leaf"}}, + }) + implicit.Commands.Flags = groupTestFlag("count") + + for _, framework := range []CLIFramework{CobraFramework, UrfaveCliFramework, YargsFramework} { + t.Run(string(framework), func(t *testing.T) { + for name, doc := range map[string]*spec.Document{"explicit-kind": explicit, "implicit-group": implicit} { + if _, err := CLI(doc, GenCLIWithFramework(framework)); err == nil { + t.Errorf("%s: expected error for group command with flags, got none", name) + } else if !strings.Contains(err.Error(), "group commands cannot have arguments or flags") { + t.Errorf("%s: unexpected error message %q", name, err) + } + } + }) + } +} + +// TestCLI_GroupWithArgsRejected ensures a group command that declares positional +// arguments is rejected at generation time, mirroring the validation rule. +func TestCLI_GroupWithArgsRejected(t *testing.T) { + doc := groupTestDoc(&spec.CommandItem{ + Segment: "grouptest", + Kind: spec.CommandKindGroup, + Commands: []*spec.CommandItem{{Segment: "leaf"}}, + }) + doc.Commands.Args = []spec.ArgumentItem{{Name: "name"}} + + for _, framework := range []CLIFramework{CobraFramework, UrfaveCliFramework, YargsFramework} { + if _, err := CLI(doc, GenCLIWithFramework(framework)); err == nil { + t.Errorf("expected error for group command with arguments (framework %s), got none", framework) + } else if !strings.Contains(err.Error(), "group commands cannot have arguments or flags") { + t.Errorf("unexpected error message: %q", err) + } + } +} + +// TestCLI_GroupWithNestedGroupRejected ensures the guard recurses into nested +// groups, not just the root command. +func TestCLI_GroupWithNestedGroupRejected(t *testing.T) { + doc := groupTestDoc(&spec.CommandItem{ + Segment: "grouptest", + Commands: []*spec.CommandItem{{ + Segment: "mid", + Kind: spec.CommandKindGroup, + Flags: groupTestFlag("count"), + Commands: []*spec.CommandItem{{Segment: "leaf"}}, + }}, + }) + + if _, err := CLI(doc, GenCLIWithFramework(CobraFramework)); err == nil { + t.Fatal("expected error for nested group command with flags, got none") + } else if !strings.Contains(err.Error(), `"mid"`) && !strings.Contains(err.Error(), "grouptest mid") { + t.Errorf("error should name the offending group: %q", err) + } +} + +// TestCLI_ValidGroupStillGenerates ensures flag-free groups (the common case, e.g. +// petstore's bare `kind: group` commands) still generate successfully and are not +// over-rejected by the guard. +func TestCLI_ValidGroupStillGenerates(t *testing.T) { + doc := groupTestDoc(&spec.CommandItem{ + Segment: "grouptest", + Kind: spec.CommandKindGroup, + Commands: []*spec.CommandItem{{ + Segment: "leaf", + Flags: groupTestFlag("count"), // flags on the LEAF are fine + }}, + }) + + for _, framework := range []CLIFramework{CobraFramework, UrfaveCliFramework, YargsFramework} { + files, err := CLI(doc, GenCLIWithFramework(framework)) + if err != nil { + t.Errorf("framework %s: unexpected error for valid group spec: %v", framework, err) + continue + } + if len(files) == 0 { + t.Errorf("framework %s: expected generated files but got none", framework) + } + } + + // The embedded petstore example (the golden source) has bare groups and must + // still generate without error. + petstore, err := codec.UnmarshalYAML(exampleYAML) + if err != nil { + t.Fatalf("unexpected error unmarshaling example OpenCLI doc: %v", err) + } + for _, framework := range []CLIFramework{CobraFramework, UrfaveCliFramework, YargsFramework} { + files, err := CLI(petstore, GenCLIWithFramework(framework)) + if err != nil { + t.Errorf("framework %s: unexpected error for petstore spec: %v", framework, err) + } else if len(files) == 0 { + t.Errorf("framework %s: expected generated files but got none", framework) + } + } +} diff --git a/gen/cli_test.go b/gen/cli_test.go index e032ff3..302e7ed 100644 --- a/gen/cli_test.go +++ b/gen/cli_test.go @@ -6,6 +6,7 @@ import ( "flag" "os" "path/filepath" + "slices" "testing" "github.com/bcdxn/opencli/codec" @@ -163,6 +164,12 @@ func TestCobraDefaultVal(t *testing.T) { {"bool_true", true, "", false, `true`}, {"bool_false", false, "", false, `false`}, + // Variadic defaults (canonical shapes produced by the codec) + {"variadic_string_list", []string{"a", "b"}, "string", true, `[]string{"a", "b"}`}, + {"variadic_integer_list", []int64{1, 2, 3}, "integer", true, `[]int64{1, 2, 3}`}, + {"variadic_number_list", []float64{1.5, 2.5}, "number", true, `[]float64{1.5, 2.5}`}, + {"variadic_boolean_list", []bool{true, false}, "boolean", true, `[]bool{true, false}`}, + // Variadic zero values {"variadic_string", nil, "string", true, `[]string{}`}, {"variadic_integer", nil, "integer", true, `[]int64{}`}, @@ -267,6 +274,44 @@ func TestUrfaveCliZeroValue(t *testing.T) { } } +func TestUrfaveCliDefaultVal(t *testing.T) { + tests := []struct { + name string + val any + t string + variadic bool + want string + }{ + // Provided scalars (canonical shapes produced by the codec) + {"string_val", "hello", "", false, `"hello"`}, + {"int64_val", int64(42), "", false, `42`}, + {"uint64_val", uint64(42), "", false, `42`}, + {"float_val", float64(3.5), "", false, `3.500000`}, + {"bool_true", true, "", false, `true`}, + + // Variadic defaults (canonical shapes produced by the codec) + {"variadic_string_list", []string{"a", "b"}, "string", true, `[]string{"a", "b"}`}, + {"variadic_integer_list", []int64{1, 2, 3}, "integer", true, `[]int64{1, 2, 3}`}, + {"variadic_number_list", []float64{1.5, 2.5}, "number", true, `[]float64{1.5, 2.5}`}, + {"variadic_boolean_list", []bool{true, false}, "boolean", true, `[]bool{true, false}`}, + + // No default -> zero value + {"zero_string", nil, "string", false, `""`}, + {"zero_integer", nil, "integer", false, `0`}, + {"zero_boolean", nil, "boolean", false, `false`}, + {"zero_number", nil, "number", false, `0.0`}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := urfaveCliDefaultVal(tt.val, tt.t, tt.variadic) + if got != tt.want { + t.Errorf("urfaveCliDefaultVal(%v, %q, %v) = %q, want %q", tt.val, tt.t, tt.variadic, got, tt.want) + } + }) + } +} + func TestYargsDefaultVal(t *testing.T) { tests := []struct { name string @@ -280,6 +325,13 @@ func TestYargsDefaultVal(t *testing.T) { {"float", float64(3.14), `3.140000`}, {"bool_true", true, `true`}, {"bool_false", false, `false`}, + + // Variadic defaults (canonical shapes produced by the codec) + {"string_list", []string{"a", "b"}, `["a", "b"]`}, + {"int64_list", []int64{1, 2, 3}, `[1, 2, 3]`}, + {"float_list", []float64{1.5, 2.5}, `[1.5, 2.5]`}, + {"bool_list", []bool{true, false}, `[true, false]`}, + {"nil", nil, ``}, } @@ -380,3 +432,414 @@ func TestDocs_HTMLPage(t *testing.T) { t.Fatal("expected HTML output") } } + +func TestCobraPlainFlagExpr(t *testing.T) { + tests := []struct { + name string + f cobraFlagEntry + want string + }{ + {"bare_var", cobraFlagEntry{VarName: "flagUsername"}, "flagUsername"}, + {"cast_to_type", cobraFlagEntry{VarName: "flagStatus", TypeName: "PetstoreStatus"}, "PetstoreStatus(flagStatus)"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := plainCobraFlagExpr(tt.f); got != tt.want { + t.Errorf("plainCobraFlagExpr(%+v) = %q, want %q", tt.f, got, tt.want) + } + }) + } +} + +// TestCobraResolveFlagValue exercises the resolveFlagValue template function exactly as the +// command.tmpl uses it (pulled from cobraTemplateFuncMap so there is a single source of truth). +func TestCobraResolveFlagValue(t *testing.T) { + resolve := func(f cobraFlagEntry) string { + fn, ok := cobraTemplateFuncMap()["resolveFlagValue"].(func(cobraFlagEntry) string) + if !ok { + t.Fatal("resolveFlagValue not found in cobra template func map") + } + return fn(f) + } + + envUser := spec.AlternativeSource{Type: "$ENV", Property: "PETSTORE_USER"} + fileAuth := spec.AlternativeSource{Type: "$FILE", Property: "$.auth.user"} + + tests := []struct { + name string + f cobraFlagEntry + want string + }{ + // No alternative sources -> plain bound variable (optionally cast). + {"no_alt_bare", cobraFlagEntry{VarName: "flagUsername"}, "flagUsername"}, + {"no_alt_cast", cobraFlagEntry{VarName: "flagStatus", TypeName: "PetstoreStatus"}, "PetstoreStatus(flagStatus)"}, + + // Single $ENV source. + {"env_only", + cobraFlagEntry{VarName: "flagUsername", FlagName: "username", GoType: "string", AltSources: []spec.AlternativeSource{envUser}}, + `resolveStringFlag(c.Flags(), "username", []AltSource{{Type: "$ENV", Property: "PETSTORE_USER"}})`}, + + // Single $FILE source. + {"file_only", + cobraFlagEntry{VarName: "flagUsername", FlagName: "username", GoType: "string", AltSources: []spec.AlternativeSource{fileAuth}}, + `resolveStringFlag(c.Flags(), "username", []AltSource{{Type: "$FILE", Property: "$.auth.user"}})`}, + + // Mixed sources preserve declared order ($ENV before $FILE). + {"env_then_file_order", + cobraFlagEntry{VarName: "flagUsername", FlagName: "username", GoType: "string", AltSources: []spec.AlternativeSource{envUser, fileAuth}}, + `resolveStringFlag(c.Flags(), "username", []AltSource{{Type: "$ENV", Property: "PETSTORE_USER"}, {Type: "$FILE", Property: "$.auth.user"}})`}, + + // Resolver is selected by Go type; a generated choices type wraps the call. + {"int64_env", + cobraFlagEntry{VarName: "flagLimit", FlagName: "limit", GoType: "int64", AltSources: []spec.AlternativeSource{{Type: "$ENV", Property: "LIMIT"}}}, + `resolveInt64Flag(c.Flags(), "limit", []AltSource{{Type: "$ENV", Property: "LIMIT"}})`}, + + {"bool_env_cast", + cobraFlagEntry{VarName: "flagVerbose", FlagName: "verbose", GoType: "bool", TypeName: "Verbosity", AltSources: []spec.AlternativeSource{{Type: "$ENV", Property: "VERBOSE"}}}, + `Verbosity(resolveBoolFlag(c.Flags(), "verbose", []AltSource{{Type: "$ENV", Property: "VERBOSE"}}))`}, + + {"float64_env", + cobraFlagEntry{VarName: "flagRate", FlagName: "rate", GoType: "float64", AltSources: []spec.AlternativeSource{{Type: "$FILE", Property: "$.rate"}}}, + `resolveFloat64Flag(c.Flags(), "rate", []AltSource{{Type: "$FILE", Property: "$.rate"}})`}, + + // Variadic types map to their slice resolvers (GetStringArray-backed for strings). + {"string_slice_env", + cobraFlagEntry{VarName: "flagTags", FlagName: "tags", GoType: "[]string", AltSources: []spec.AlternativeSource{{Type: "$ENV", Property: "TAGS"}}}, + `resolveStringSliceFlag(c.Flags(), "tags", []AltSource{{Type: "$ENV", Property: "TAGS"}})`}, + + {"int64_slice_env", + cobraFlagEntry{VarName: "flagIds", FlagName: "ids", GoType: "[]int64", AltSources: []spec.AlternativeSource{{Type: "$FILE", Property: "$.ids"}}}, + `resolveInt64SliceFlag(c.Flags(), "ids", []AltSource{{Type: "$FILE", Property: "$.ids"}})`}, + + {"bool_slice_env", + cobraFlagEntry{VarName: "flagFlags", FlagName: "flags", GoType: "[]bool", AltSources: []spec.AlternativeSource{{Type: "$ENV", Property: "FLAGS"}}}, + `resolveBoolSliceFlag(c.Flags(), "flags", []AltSource{{Type: "$ENV", Property: "FLAGS"}})`}, + + {"float64_slice_env", + cobraFlagEntry{VarName: "flagRates", FlagName: "rates", GoType: "[]float64", AltSources: []spec.AlternativeSource{{Type: "$FILE", Property: "$.rates"}}}, + `resolveFloat64SliceFlag(c.Flags(), "rates", []AltSource{{Type: "$FILE", Property: "$.rates"}})`}, + + // Unknown Go type with alt sources falls back to the plain expression (defensive). + {"unknown_type_falls_back", + cobraFlagEntry{VarName: "flagWeird", FlagName: "weird", GoType: "int32", AltSources: []spec.AlternativeSource{envUser}}, + "flagWeird"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := resolve(tt.f); got != tt.want { + t.Errorf("resolveFlagValue(%+v)\n = %q\nwant %q", tt.f, got, tt.want) + } + }) + } +} + +// TestCobraScanAltSources verifies the has-alt / has-file scan that gates emission of +// gencli/config.gen.go and its JSONPath import. +func TestCobraScanAltSources(t *testing.T) { + tests := []struct { + name string + flags []cobraFlagEntry + wantHas bool + wantFile bool + }{ + {"none", nil, false, false}, + { + "env_only", + []cobraFlagEntry{{AltSources: []spec.AlternativeSource{{Type: "$ENV", Property: "X"}}}}, + true, false, + }, + { + "file_only", + []cobraFlagEntry{{AltSources: []spec.AlternativeSource{{Type: "$FILE", Property: "$.x"}}}}, + true, true, + }, + { + "mixed_across_flags", + []cobraFlagEntry{ + {}, // no alt sources + {AltSources: []spec.AlternativeSource{{Type: "$ENV", Property: "X"}, {Type: "$FILE", Property: "$.y"}}}, + }, + true, true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + hasAlt, hasFile := scanCobraAltSources(tt.flags) + if hasAlt != tt.wantHas || hasFile != tt.wantFile { + t.Errorf("scanCobraAltSources(%+v) = (%v, %v), want (%v, %v)", tt.flags, hasAlt, hasFile, tt.wantHas, tt.wantFile) + } + }) + } +} + +// TestUrfaveCliScanAltSources verifies the has-alt / has-file scan that gates emission of +// gencli/config.gen.go and its JSONPath import for the urfave/cli framework. +func TestUrfaveCliScanAltSources(t *testing.T) { + tests := []struct { + name string + flags []urfaveCliFlagEntry + wantHas bool + wantFile bool + }{ + {"none", nil, false, false}, + { + "env_only", + []urfaveCliFlagEntry{{AltSources: []spec.AlternativeSource{{Type: "$ENV", Property: "X"}}}}, + true, false, + }, + { + "file_only", + []urfaveCliFlagEntry{{AltSources: []spec.AlternativeSource{{Type: "$FILE", Property: "$.x"}}}}, + true, true, + }, + { + "mixed_across_flags", + []urfaveCliFlagEntry{ + {}, // no alt sources + {AltSources: []spec.AlternativeSource{{Type: "$ENV", Property: "X"}, {Type: "$FILE", Property: "$.y"}}}, + }, + true, true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + hasAlt, hasFile := scanUrfaveCliAltSources(tt.flags) + if hasAlt != tt.wantHas || hasFile != tt.wantFile { + t.Errorf("scanUrfaveCliAltSources(%+v) = (%v, %v), want (%v, %v)", tt.flags, hasAlt, hasFile, tt.wantHas, tt.wantFile) + } + }) + } +} + +// TestCLI_UrfaveCli_ConfigEmissionConditional verifies that gencli/config.gen.go is only +// emitted when at least one flag declares an alternative source. Specs without this feature +// must produce no extra config file (and therefore no unused imports), matching the cobra +// framework's behavior. +func TestCLI_UrfaveCli_ConfigEmissionConditional(t *testing.T) { + generate := func(t *testing.T, docYAML string) map[string][]byte { + t.Helper() + doc, err := codec.UnmarshalYAML([]byte(docYAML)) + if err != nil { + t.Fatalf("unexpected error unmarshaling doc: %v", err) + } + files, err := CLI( + doc, + GenCLIWithFramework(UrfaveCliFramework), + ) + if err != nil { + t.Fatalf("unexpected error generating UrfaveCli CLI: %v", err) + } + return files + } + + fileNames := func(files map[string][]byte) []string { + names := make([]string, 0, len(files)) + for n := range files { + names = append(names, n) + } + return names + } + + t.Run("no_alt_sources", func(t *testing.T) { + files := generate(t, `opencliVersion: 1.0.0-alpha.13 +info: + title: minimal cli for alt-source emission test + binary: minicli +commands: + minicli greet [flags]: + summary: say hello +`) + + if _, hasConfig := files["gencli/config.gen.go"]; hasConfig { + t.Errorf("config.gen.go should not be emitted without alternative sources; generated files: %v", fileNames(files)) + } + + runContent, ok := files["gencli/run.gen.go"] + if !ok { + t.Fatal("expected gencli/run.gen.go in output") + } + if bytes.Contains(runContent, []byte("loadConfig()")) { + t.Error("run.gen.go should not call loadConfig without alternative sources") + } + }) + + t.Run("with_alt_sources", func(t *testing.T) { + files := generate(t, `opencliVersion: 1.0.0-alpha.13 +info: + title: minimal cli for alt-source emission test + binary: minicli +commands: + minicli greet [flags]: + summary: say hello + flags: + - name: username + type: string + alternativeSources: + - type: $ENV + property: MINI_USER +`) + + if _, hasConfig := files["gencli/config.gen.go"]; !hasConfig { + t.Errorf("expected config.gen.go when alt sources present; generated files: %v", fileNames(files)) + } + + runContent, ok := files["gencli/run.gen.go"] + if !ok { + t.Fatal("expected gencli/run.gen.go in output") + } + if !bytes.Contains(runContent, []byte("loadConfig()")) { + t.Error("expected run.gen.go to call loadConfig when alt sources present") + } + }) +} + +// TestYargsScanAltSources verifies the has-alt / has-file scan that gates emission of +// gencli/config.ts and its JSONPath import for the yargs framework. +func TestYargsScanAltSources(t *testing.T) { + tests := []struct { + name string + flags []yargsFlagEntry + wantHas bool + wantFile bool + }{ + {"none", nil, false, false}, + { + "env_only", + []yargsFlagEntry{{AltSources: []spec.AlternativeSource{{Type: "$ENV", Property: "X"}}}}, + true, false, + }, + { + "file_only", + []yargsFlagEntry{{AltSources: []spec.AlternativeSource{{Type: "$FILE", Property: "$.x"}}}}, + true, true, + }, + { + "mixed_across_flags", + []yargsFlagEntry{ + {}, // no alt sources + {AltSources: []spec.AlternativeSource{{Type: "$ENV", Property: "X"}, {Type: "$FILE", Property: "$.y"}}}, + }, + true, true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + hasAlt, hasFile := scanYargsAltSources(tt.flags) + if hasAlt != tt.wantHas || hasFile != tt.wantFile { + t.Errorf("scanYargsAltSources(%+v) = (%v, %v), want (%v, %v)", tt.flags, hasAlt, hasFile, tt.wantHas, tt.wantFile) + } + }) + } +} + +// TestYargsAltSourceNames verifies the accepted-option-name list passed to the generated +// wasSetOnCli scanner: field name first (used to read argv), then raw name and shorthand +// when present, then extra aliases — with duplicates collapsed. The shorthand must be +// included so that a flag set via its single-character form is still detected as CLI-set. +func TestYargsAltSourceNames(t *testing.T) { + tests := []struct { + name string + flag yargsFlagEntry + want []string + }{ + {"field_only", yargsFlagEntry{FieldName: "verbose"}, []string{"verbose"}}, + {"kebab_raw_name", yargsFlagEntry{FieldName: "dryRun", RawName: "dry-run"}, []string{"dryRun", "dry-run"}}, + {"shorthand_included", yargsFlagEntry{FieldName: "verbose", RawName: "verbose", Shorthand: "v"}, []string{"verbose", "v"}}, + {"all_names_deduped", yargsFlagEntry{FieldName: "output", RawName: "out-file", Shorthand: "o", ExtraAliases: []string{"dest"}}, []string{"output", "out-file", "o", "dest"}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := yargsAltSourceNames(tt.flag) + if !slices.Equal(got, tt.want) { + t.Errorf("yargsAltSourceNames(%+v) = %v, want %v", tt.flag, got, tt.want) + } + }) + } +} + +// TestCLI_Yargs_ConfigEmissionConditional verifies that gencli/config.ts is only emitted when +// at least one flag declares an alternative source. Specs without this feature must produce no +// extra config file (and therefore no unused imports), matching the cobra/urfave behavior. +func TestCLI_Yargs_ConfigEmissionConditional(t *testing.T) { + generate := func(t *testing.T, docYAML string) map[string][]byte { + t.Helper() + doc, err := codec.UnmarshalYAML([]byte(docYAML)) + if err != nil { + t.Fatalf("unexpected error unmarshaling doc: %v", err) + } + files, err := CLI( + doc, + GenCLIWithFramework(YargsFramework), + ) + if err != nil { + t.Fatalf("unexpected error generating Yargs CLI: %v", err) + } + return files + } + + fileNames := func(files map[string][]byte) []string { + names := make([]string, 0, len(files)) + for n := range files { + names = append(names, n) + } + return names + } + + t.Run("no_alt_sources", func(t *testing.T) { + files := generate(t, `opencliVersion: 1.0.0-alpha.13 +info: + title: minimal cli for alt-source emission test + binary: minicli +commands: + minicli greet [flags]: + summary: say hello +`) + + if _, hasConfig := files["gencli/config.ts"]; hasConfig { + t.Errorf("config.ts should not be emitted without alternative sources; generated files: %v", fileNames(files)) + } + + runContent, ok := files["gencli/run.ts"] + if !ok { + t.Fatal("expected gencli/run.ts in output") + } + if bytes.Contains(runContent, []byte("loadConfig()")) { + t.Error("run.ts should not call loadConfig without alternative sources") + } + }) + + t.Run("with_alt_sources", func(t *testing.T) { + files := generate(t, `opencliVersion: 1.0.0-alpha.13 +info: + title: minimal cli for alt-source emission test + binary: minicli +commands: + minicli greet [flags]: + summary: say hello + flags: + - name: username + type: string + alternativeSources: + - type: $ENV + property: MINI_USER +`) + + if _, hasConfig := files["gencli/config.ts"]; !hasConfig { + t.Errorf("expected config.ts when alt sources present; generated files: %v", fileNames(files)) + } + + runContent, ok := files["gencli/run.ts"] + if !ok { + t.Fatal("expected gencli/run.ts in output") + } + if !bytes.Contains(runContent, []byte("loadConfig()")) { + t.Error("expected run.ts to call loadConfig when alt sources present") + } + }) +} diff --git a/gen/cli_urfave_cli.go b/gen/cli_urfave_cli.go index a590075..ac4e9ac 100644 --- a/gen/cli_urfave_cli.go +++ b/gen/cli_urfave_cli.go @@ -6,6 +6,7 @@ import ( "fmt" "go/format" "path/filepath" + "strconv" "strings" "text/template" @@ -20,6 +21,16 @@ type urfaveCliAllCommandsTmplData struct { LeafCommands []cliCmdEntry ExitCodes []spec.ExitCode GlobalFlags []urfaveCliFlagEntry + // Config file paths from global.config (only formats that are declared) + ConfigJSON string + ConfigTOML string + ConfigYAML string + // HasAltSources is true if any flag declares an alternative source, which + // requires emitting gencli/config.gen.go and calling loadConfig at startup. + HasAltSources bool + // HasFileAltSource is true if any flag declares a $FILE alternative source, + // which requires the generated config code to import a JSONPath library. + HasFileAltSource bool } // urfaveCliCommandFileTmplData is the template data passed to command.tmpl. @@ -30,6 +41,7 @@ type urfaveCliCommandFileTmplData struct { ChildImports []subCmdImport UrfaveArgs []urfaveCliArgEntry UrfaveFlags []urfaveCliFlagEntry + GlobalFlags []urfaveCliFlagEntry // non-help/version global flags, shared by all leaf commands } // urfaveCliArgEntry describes how to bind a positional argument in an urfave command. @@ -41,6 +53,7 @@ type urfaveCliArgEntry struct { } // urfaveCliFlagEntry describes how to bind a flag in an urfave command. +// AltSources reuses spec.AlternativeSource directly (no need for a parallel type). type urfaveCliFlagEntry struct { FieldName string FlagName string @@ -51,6 +64,7 @@ type urfaveCliFlagEntry struct { TypeName string // non-empty when the struct field uses a generated type (needs cast) Aliases []string // all aliases (urfave uses Aliases []string, not separate shorthand) Accessor string // e.g. "String", "Int64", "Bool", "Float64", "StringSlice", etc. + AltSources []spec.AlternativeSource } //go:embed templates/code/urfavecli @@ -77,8 +91,12 @@ func genCLIUrfaveCli(doc *spec.Document, opts *genCLIOptions) (map[string][]byte var exitCodes []spec.ExitCode var globalFlags []urfaveCliFlagEntry + var configJSON, configTOML, configYAML string if doc.Global != nil { exitCodes = doc.Global.ExitCodes + configJSON = doc.Global.Config.JSON + configTOML = doc.Global.Config.TOML + configYAML = doc.Global.Config.YAML for _, flag := range doc.Global.Flags { if flag.Name == "help" || flag.Name == "version" { continue @@ -92,17 +110,33 @@ func genCLIUrfaveCli(doc *spec.Document, opts *genCLIOptions) (map[string][]byte Summary: flag.Summary, Aliases: flag.Aliases, Accessor: urfaveCliAccessor(flag.Type, flag.Variadic), + AltSources: flag.AltSources, }) } } + // Track alternative-source usage across all flags so we know whether to emit + // gencli/config.gen.go (and call loadConfig from run.tmpl). A $FILE source + // additionally requires the generated config code to import a JSONPath library. + hasAltSources, hasFileAltSource := scanUrfaveCliAltSources(globalFlags) + for i := range cmdFiles { + cmdHasAlt, cmdHasFile := scanUrfaveCliAltSources(cmdFiles[i].UrfaveFlags) + hasAltSources = hasAltSources || cmdHasAlt + hasFileAltSource = hasFileAltSource || cmdHasFile + } + allCmdsData := urfaveCliAllCommandsTmplData{ - ModuleVersion: opts.ModuleVersion, - Binary: binary, - BinaryPascal: binaryPascal, - LeafCommands: leafCommands, - ExitCodes: exitCodes, - GlobalFlags: globalFlags, + ModuleVersion: opts.ModuleVersion, + Binary: binary, + BinaryPascal: binaryPascal, + LeafCommands: leafCommands, + ExitCodes: exitCodes, + GlobalFlags: globalFlags, + ConfigJSON: configJSON, + ConfigTOML: configTOML, + ConfigYAML: configYAML, + HasAltSources: hasAltSources, + HasFileAltSource: hasFileAltSource, } funcMap := urfaveCliTemplateFuncMap() @@ -113,13 +147,19 @@ func genCLIUrfaveCli(doc *spec.Document, opts *genCLIOptions) (map[string][]byte } gencliFiles := []gencliFile{ {"gencli/actions.gen.go", "templates/code/urfavecli/gencli/actions.tmpl"}, + // Only emitted when at least one flag declares alternative sources, so that + // specs without this feature produce no extra files or imports. + } + if hasAltSources { + gencliFiles = append(gencliFiles, gencliFile{"gencli/config.gen.go", "templates/code/urfavecli/gencli/config.tmpl"}) + } + gencliFiles = append(gencliFiles, []gencliFile{ {"gencli/errors.gen.go", "templates/code/urfavecli/gencli/errors.tmpl"}, {"gencli/help.gen.go", "templates/code/urfavecli/gencli/help.tmpl"}, {"gencli/iostreams.gen.go", "templates/code/urfavecli/gencli/iostreams.tmpl"}, {"gencli/params.gen.go", "templates/code/urfavecli/gencli/params.tmpl"}, {"gencli/run.gen.go", "templates/code/urfavecli/gencli/run.tmpl"}, - } - + }...) for _, f := range gencliFiles { content, err := renderUrfaveCliTemplate(f.tmplPath, funcMap, allCmdsData) if err != nil { @@ -133,6 +173,7 @@ func genCLIUrfaveCli(doc *spec.Document, opts *genCLIOptions) (map[string][]byte } for _, cmdFile := range cmdFiles { + cmdFile.GlobalFlags = globalFlags content, err := renderUrfaveCliTemplate("templates/code/urfavecli/gencli/command.tmpl", funcMap, cmdFile) if err != nil { return nil, fmt.Errorf("rendering %s: %w", cmdFile.OutPath, err) @@ -147,6 +188,24 @@ func genCLIUrfaveCli(doc *spec.Document, opts *genCLIOptions) (map[string][]byte return out, nil } +// scanUrfaveCliAltSources reports whether any flag in the slice declares an +// alternative source (hasAlt), and specifically whether any of them is a $FILE +// source (hasFile). A $FILE source requires the generated config code to import +// a JSONPath library. +func scanUrfaveCliAltSources(flags []urfaveCliFlagEntry) (hasAlt, hasFile bool) { + for _, f := range flags { + if len(f.AltSources) > 0 { + hasAlt = true + } + for _, src := range f.AltSources { + if src.Type == "$FILE" { + hasFile = true + } + } + } + return hasAlt, hasFile +} + // walkUrfaveCliCmdTree recursively collects template data for all commands in the tree. func walkUrfaveCliCmdTree( doc *spec.Document, @@ -268,6 +327,7 @@ func walkUrfaveCliCmdTree( TypeName: flagTypeName, Aliases: flag.Aliases, Accessor: urfaveCliAccessor(flag.Type, flag.Variadic), + AltSources: flag.AltSources, }) } @@ -333,9 +393,46 @@ func urfaveCliTemplateFuncMap() template.FuncMap { "goString": func(s string) string { return fmt.Sprintf("%q", s) }, + "resolveFlagValue": func(f urfaveCliFlagEntry) string { + // No alternative sources — read the value straight from the CLI. + if len(f.AltSources) == 0 { + if f.TypeName != "" { + return fmt.Sprintf("%s(c.%s(%q))", f.TypeName, f.Accessor, f.FlagName) + } + return fmt.Sprintf("c.%s(%q)", f.Accessor, f.FlagName) + } + // Alternative sources present — fall back to env/config only when the + // flag was not provided on the command line (c.IsSet). + resolver, ok := urfaveCliAltSourceResolvers[f.GoType] + if !ok { + if f.TypeName != "" { + return fmt.Sprintf("%s(c.%s(%q))", f.TypeName, f.Accessor, f.FlagName) + } + return fmt.Sprintf("c.%s(%q)", f.Accessor, f.FlagName) + } + srcs := formatAltSources(f.AltSources) + expr := fmt.Sprintf("%s(c.IsSet(%q), c.%s(%q), %s)", resolver, f.FlagName, f.Accessor, f.FlagName, srcs) + if f.TypeName != "" { + expr = fmt.Sprintf("%s(%s)", f.TypeName, expr) + } + return expr + }, } } +// urfaveCliAltSourceResolvers maps a Go flag type to the generated resolver +// function that falls back to alternative sources when the flag is not set. +var urfaveCliAltSourceResolvers = map[string]string{ + "string": "resolveStringFlag", + "int64": "resolveInt64Flag", + "bool": "resolveBoolFlag", + "float64": "resolveFloat64Flag", + "[]string": "resolveStringSliceFlag", + "[]int64": "resolveInt64SliceFlag", + "[]bool": "resolveBoolSliceFlag", + "[]float64": "resolveFloat64SliceFlag", +} + // urfaveCliFlagStruct returns the urfave/cli v3 flag struct type for the given spec type. func urfaveCliFlagStruct(t string, variadic bool) string { if variadic { @@ -417,7 +514,7 @@ func urfaveCliZeroValue(t string, variadic bool) string { // urfaveCliDefaultVal returns the Go literal for the default value of an urfave flag. func urfaveCliDefaultVal(val any, t string, variadic bool) string { switch slice := val.(type) { - // handle slice types first + // handle slice types first; the codec normalizes list defaults to these shapes case []string: var elems []string for _, v := range slice { @@ -425,33 +522,32 @@ func urfaveCliDefaultVal(val any, t string, variadic bool) string { } return fmt.Sprintf("[]string{%s}", strings.Join(elems, ", ")) - case []int: + case []int64: var elems []string for _, v := range slice { - elems = append(elems, fmt.Sprintf("%d", v)) + elems = append(elems, strconv.FormatInt(v, 10)) } return fmt.Sprintf("[]int64{%s}", strings.Join(elems, ", ")) case []float64: var elems []string for _, v := range slice { - // %g prints the most compact representation of a float - elems = append(elems, fmt.Sprintf("%g", v)) + elems = append(elems, strconv.FormatFloat(v, 'f', -1, 64)) } return fmt.Sprintf("[]float64{%s}", strings.Join(elems, ", ")) case []bool: var elems []string for _, v := range slice { - elems = append(elems, fmt.Sprintf("%t", v)) + elems = append(elems, strconv.FormatBool(v)) } return fmt.Sprintf("[]bool{%s}", strings.Join(elems, ", ")) - // handle non-slice scalars + // handle non-slice scalars; the codec normalizes these to string/int64/float64/bool case string: return fmt.Sprintf("%q", val) - case int: + case int, int32, int64, uint64: return fmt.Sprintf("%d", val) - case float64: + case float32, float64: return fmt.Sprintf("%f", val) case bool: return fmt.Sprintf("%t", val) @@ -459,7 +555,20 @@ func urfaveCliDefaultVal(val any, t string, variadic bool) string { return urfaveCliZeroValue(t, variadic) default: - // should never panic because the spec will have been validated before generation is run - panic(fmt.Sprintf("unsupported type: must be a slice of string, int, float64, or bool - %T", val)) + // should never panic because the codec normalizes defaults before generation is run + panic(fmt.Sprintf("unsupported type: must be a slice of string, int64, float64, or bool - %T", val)) + } +} + +// formatAltSources formats a slice of spec.AlternativeSource as a Go composite +// literal of the generated AltSource type, for use in generated template code. +func formatAltSources(sources []spec.AlternativeSource) string { + if len(sources) == 0 { + return "nil" + } + var parts []string + for _, s := range sources { + parts = append(parts, fmt.Sprintf("{Type: %q, Property: %q}", s.Type, s.Property)) } + return fmt.Sprintf("[]AltSource{%s}", strings.Join(parts, ", ")) } diff --git a/gen/cli_yargs.go b/gen/cli_yargs.go index 82e93b6..cfd5b40 100644 --- a/gen/cli_yargs.go +++ b/gen/cli_yargs.go @@ -6,6 +6,7 @@ import ( "fmt" "path/filepath" "sort" + "strconv" "strings" "text/template" @@ -21,6 +22,17 @@ type yargsAllCmdsTmplData struct { RootImport yargsChildImport // import stub for the root command (used by run.ts) ChildImports []yargsChildImport // direct children of root (used by run.ts) ExitCodes []spec.ExitCode + GlobalFlags []yargsFlagEntry + // Config file paths from global.config (only formats that are declared) + ConfigJSON string + ConfigTOML string + ConfigYAML string + // HasAltSources is true if any flag declares an alternative source, which + // requires emitting gencli/config.ts and calling loadConfig at startup. + HasAltSources bool + // HasFileAltSource is true if any flag declares a $FILE alternative source, + // which requires the generated config code to import a JSONPath library. + HasFileAltSource bool } // yargsCmdEntry holds pre-computed data for a single leaf command (used by actions/params). @@ -57,6 +69,8 @@ type yargsCommandFileTmplData struct { ChildImports []yargsChildImport YargsArgs []yargsArgEntry YargsFlags []yargsFlagEntry + GlobalFlags []yargsFlagEntry // non-help/version global flags, shared by all leaf commands + ConfigImports []string // unique resolver names from gencli/config.ts used by this command's alt-source flags } // yargsChildImport holds data for importing and registering a child command module. @@ -80,16 +94,19 @@ type yargsArgEntry struct { // yargsFlagEntry describes how to bind an option/flag in a yargs command. type yargsFlagEntry struct { - FieldName string // camelCase field on argv - RawName string // unmodified name from spec, used for .option() and the local argv interface - TSType string - IsRequired bool - IsVariadic bool - TypeName string // non-empty when field uses generated enum type - Choices []yargsChoiceEntry - Shorthand string - ExtraAliases []string - Default string // TypeScript literal or empty + FieldName string // camelCase field on argv + RawName string // unmodified name from spec, used for .option() and the local argv interface + TSType string + IsRequired bool + IsVariadic bool + TypeName string // non-empty when field uses generated enum type + Choices []yargsChoiceEntry + Shorthand string + ExtraAliases []string + Default string // TypeScript literal or empty + Summary string // flag summary (used for global option registration) + VariadicCoerce string // pre-rendered JS arrow coercing array elements for non-string variadics ("" otherwise) + AltSources []spec.AlternativeSource } //go:embed templates/code/yargs @@ -116,12 +133,54 @@ func genCLIYargs(doc *spec.Document, opts *genCLIOptions) (map[string][]byte, er rootChildImports := yargsBuildChildImports(rootCmd.Commands, []string{binary}) + var globalFlags []yargsFlagEntry + configJSON, configTOML, configYAML := "", "", "" + if doc.Global != nil { + configJSON = doc.Global.Config.JSON + configTOML = doc.Global.Config.TOML + configYAML = doc.Global.Config.YAML + for _, flag := range doc.Global.Flags { + if flag.Name == "help" || flag.Name == "version" { + continue + } + shorthand, extraAliases := splitAliases(flag.Aliases) + globalFlags = append(globalFlags, yargsFlagEntry{ + FieldName: toCamelCase(flag.Name), + RawName: flag.Name, + TSType: toTSType(flag.Type, flag.Variadic), + IsRequired: flag.Required, + IsVariadic: flag.Variadic, + Shorthand: shorthand, + ExtraAliases: extraAliases, + Default: yargsDefaultVal(flag.Default), + Summary: flag.Summary, + AltSources: flag.AltSources, + }) + } + } + + // Track alternative-source usage across all flags so we know whether to emit + // gencli/config.ts (and call loadConfig from run.tmpl). A $FILE source + // additionally requires the generated config code to import a JSONPath library. + hasAltSources, hasFileAltSource := scanYargsAltSources(globalFlags) + for i := range cmdFiles { + cmdHasAlt, cmdHasFile := scanYargsAltSources(cmdFiles[i].YargsFlags) + hasAltSources = hasAltSources || cmdHasAlt + hasFileAltSource = hasFileAltSource || cmdHasFile + } + allCmdsData := yargsAllCmdsTmplData{ - ModuleVersion: opts.ModuleVersion, - Binary: binary, - BinaryPascal: binaryPascal, - LeafCommands: leafCommands, - ChildImports: rootChildImports, + ModuleVersion: opts.ModuleVersion, + Binary: binary, + BinaryPascal: binaryPascal, + LeafCommands: leafCommands, + ChildImports: rootChildImports, + GlobalFlags: globalFlags, + ConfigJSON: configJSON, + ConfigTOML: configTOML, + ConfigYAML: configYAML, + HasAltSources: hasAltSources, + HasFileAltSource: hasFileAltSource, RootImport: yargsChildImport{ FuncName: yargsCommandFuncName([]string{binary}), FileName: yargsCommandFileName([]string{binary}), @@ -141,12 +200,19 @@ func genCLIYargs(doc *spec.Document, opts *genCLIOptions) (map[string][]byte, er } supportFiles := []gencliFile{ {"gencli/actions.ts", "templates/code/yargs/gencli/actions.tmpl"}, - {"gencli/params.ts", "templates/code/yargs/gencli/params.tmpl"}, - {"gencli/errors.ts", "templates/code/yargs/gencli/errors.tmpl"}, - {"gencli/help.ts", "templates/code/yargs/gencli/help.tmpl"}, - {"gencli/types.ts", "templates/code/yargs/gencli/types.tmpl"}, - {"gencli/run.ts", "templates/code/yargs/gencli/run.tmpl"}, + // Only emitted when at least one flag declares alternative sources, so that + // specs without this feature produce no extra files or imports. } + if hasAltSources { + supportFiles = append(supportFiles, gencliFile{"gencli/config.ts", "templates/code/yargs/gencli/config.tmpl"}) + } + supportFiles = append(supportFiles, + gencliFile{"gencli/params.ts", "templates/code/yargs/gencli/params.tmpl"}, + gencliFile{"gencli/errors.ts", "templates/code/yargs/gencli/errors.tmpl"}, + gencliFile{"gencli/help.ts", "templates/code/yargs/gencli/help.tmpl"}, + gencliFile{"gencli/types.ts", "templates/code/yargs/gencli/types.tmpl"}, + gencliFile{"gencli/run.ts", "templates/code/yargs/gencli/run.tmpl"}, + ) for _, f := range supportFiles { content, err := renderYargsTemplate(f.tmplPath, funcMap, allCmdsData) if err != nil { @@ -156,6 +222,35 @@ func genCLIYargs(doc *spec.Document, opts *genCLIOptions) (map[string][]byte, er } sort.Slice(cmdFiles, func(i, j int) bool { return cmdFiles[i].OutPath < cmdFiles[j].OutPath }) + for i := range cmdFiles { + cmdFiles[i].GlobalFlags = globalFlags + } + // Collect the resolver functions each command file needs from gencli/config.ts, + // based on its own flags plus any alt-source global flags (which every leaf + // command handler also resolves). + hasGlobalAlt, _ := scanYargsAltSources(globalFlags) + for i := range cmdFiles { + seen := make(map[string]bool) + var imports []string + addResolver := func(f yargsFlagEntry) { + if len(f.AltSources) == 0 { + return + } + if r, ok := yargsAltSourceResolvers[f.TSType]; ok && !seen[r] { + seen[r] = true + imports = append(imports, r) + } + } + for _, f := range cmdFiles[i].YargsFlags { + addResolver(f) + } + if hasGlobalAlt { + for _, f := range globalFlags { + addResolver(f) + } + } + cmdFiles[i].ConfigImports = imports + } for _, cmdFile := range cmdFiles { content, err := renderYargsTemplate("templates/code/yargs/gencli/command.tmpl", funcMap, cmdFile) if err != nil { @@ -304,16 +399,18 @@ func walkYargsCmdTree( } specFlags = append(specFlags, specFlagEntry{Name: flag.Name, Summary: flag.Summary, Aliases: extraAliases}) yargsFlags = append(yargsFlags, yargsFlagEntry{ - FieldName: toCamelCase(flag.Name), - RawName: flag.Name, - TSType: toTSType(flag.Type, flag.Variadic), - IsRequired: flag.Required, - IsVariadic: flag.Variadic, - TypeName: flagTypeName, - Choices: choices, - Shorthand: shorthand, - ExtraAliases: extraAliases, - Default: yargsDefaultVal(flag.Default), + FieldName: toCamelCase(flag.Name), + RawName: flag.Name, + TSType: toTSType(flag.Type, flag.Variadic), + IsRequired: flag.Required, + IsVariadic: flag.Variadic, + VariadicCoerce: yargsVariadicCoerce(flag.Type), + TypeName: flagTypeName, + Choices: choices, + Shorthand: shorthand, + ExtraAliases: extraAliases, + Default: yargsDefaultVal(flag.Default), + AltSources: flag.AltSources, }) } @@ -395,9 +492,114 @@ func yargsTemplateFuncMap() template.FuncMap { return result }, "joinStrings": strings.Join, + // resolveFlagValue returns the expression that yields a flag's value in + // generated command code. Without alternative sources it reads the parsed + // argv field directly; with alt-sources it calls the type-specific resolver + // from gencli/config.ts, which prefers an explicit CLI value and otherwise + // falls back to env/config in declared order. + "resolveFlagValue": func(f yargsFlagEntry) string { + if len(f.AltSources) == 0 { + return plainYargsFlagExpr(f) + } + resolver, ok := yargsAltSourceResolvers[f.TSType] + if !ok { + return plainYargsFlagExpr(f) + } + names := formatTSAltNames(yargsAltSourceNames(f)) + expr := fmt.Sprintf("%s(argv, %s, %s)", resolver, names, formatTSAltSources(f.AltSources)) + if f.TypeName != "" { + expr = fmt.Sprintf("%s(%s)", f.TypeName, expr) + } + return expr + }, } } +// plainYargsFlagExpr returns the expression for a flag with no alternative sources: +// the parsed argv field cast to its generated type (choices enum when one exists, else +// the base TS type). This mirrors the pre-alt-source template exactly so that specs +// without alternative sources produce byte-identical output. +func plainYargsFlagExpr(f yargsFlagEntry) string { + if f.TypeName != "" { + return fmt.Sprintf("argv.%s as %s", f.FieldName, f.TypeName) + } + return fmt.Sprintf("argv.%s as %s", f.FieldName, f.TSType) +} + +// scanYargsAltSources reports whether any flag in the slice declares an alternative +// source (hasAlt), and specifically whether any of them is a $FILE source +// (hasFile). A $FILE source requires the generated config code to import a JSONPath library. +func scanYargsAltSources(flags []yargsFlagEntry) (hasAlt, hasFile bool) { + for _, f := range flags { + if len(f.AltSources) > 0 { + hasAlt = true + } + for _, src := range f.AltSources { + if src.Type == "$FILE" { + hasFile = true + } + } + } + return hasAlt, hasFile +} + +// yargsAltSourceResolvers maps a TypeScript flag type to the generated resolver +// function in gencli/config.ts that falls back to alternative sources when the +// flag was not provided on the command line. +var yargsAltSourceResolvers = map[string]string{ + "string": "resolveStringFlag", + "number": "resolveNumberFlag", + "boolean": "resolveBoolFlag", + "string[]": "resolveStringSliceFlag", + "number[]": "resolveNumberSliceFlag", + "boolean[]": "resolveBoolSliceFlag", +} + +// formatTSAltSources formats a slice of spec.AlternativeSource as a TypeScript array +// literal of the generated AltSource type, for use in generated template code. +func formatTSAltSources(sources []spec.AlternativeSource) string { + if len(sources) == 0 { + return "[]" + } + parts := make([]string, 0, len(sources)) + for _, s := range sources { + parts = append(parts, fmt.Sprintf("{ type: %q, property: %q }", s.Type, s.Property)) + } + return "[" + strings.Join(parts, ", ") + "]" +} + +// yargsAltSourceNames returns every option spelling that can reference the flag on the +// command line: its camelCase field name (always first — used to read argv), its raw +// spec name when different (yargs accepts kebab-case too), and all aliases. The generated +// wasSetOnCli scanner matches process.argv tokens against this list, mirroring how pflag's +// Changed and urfave/cli's IsSet account for shorthands and aliases. +func yargsAltSourceNames(f yargsFlagEntry) []string { + seen := make(map[string]bool) + var names []string + add := func(s string) { + if s != "" && !seen[s] { + seen[s] = true + names = append(names, s) + } + } + add(f.FieldName) + add(f.RawName) + add(f.Shorthand) + for _, a := range f.ExtraAliases { + add(a) + } + return names +} + +// formatTSAltNames formats the accepted option-name list as a TypeScript string array literal. +func formatTSAltNames(names []string) string { + parts := make([]string, 0, len(names)) + for _, n := range names { + parts = append(parts, fmt.Sprintf("%q", n)) + } + return "[" + strings.Join(parts, ", ") + "]" +} + // yargsSpecFuncName returns the help-data factory function name for a command. // ["petstore","pet","add"] -> "getPetstorePetAddCmdHelpData" func yargsSpecFuncName(segments []string) string { @@ -453,12 +655,56 @@ func yargsCommandDSL(cmd *spec.CommandItem) string { return strings.Join(cmdDSL, " ") } -// yargsDefaultVal returns a TypeScript literal default value for a flag. +// yargsVariadicCoerce returns a pre-rendered JavaScript arrow function that maps +// the array elements of a non-string variadic flag to their proper JS type, or "" +// when no coercion is needed (non-variadics and string variadics). Yargs has no +// native typed-array support: with only `type: "array"`, elements arrive as +// strings (and the parser's numeric heuristic misses non-integers), so we coerce +// explicitly. The Array.isArray guard keeps absent flags undefined rather than +// turning them into empty arrays. +func yargsVariadicCoerce(t string) string { + switch t { + case "integer", "number": + return "(v) => Array.isArray(v) ? v.map(Number) : v" + case "boolean": + return `(v) => Array.isArray(v) ? v.map((x) => x === true || x === "true") : v` + default: + return "" + } +} + +// yargsDefaultVal returns a TypeScript literal default value for a flag. The +// codec normalizes defaults to string/int64/float64/bool (and typed slices for +// variadic flags); the other scalar cases are kept as defensive fallbacks. func yargsDefaultVal(val any) string { - switch val.(type) { + switch v := val.(type) { + case []string: + parts := make([]string, len(v)) + for i, s := range v { + parts[i] = fmt.Sprintf("\"%s\"", strings.ReplaceAll(s, "\"", "\\\"")) + } + return "[" + strings.Join(parts, ", ") + "]" + case []int64: + parts := make([]string, len(v)) + for i, n := range v { + parts[i] = strconv.FormatInt(n, 10) + } + return "[" + strings.Join(parts, ", ") + "]" + case []float64: + parts := make([]string, len(v)) + for i, f := range v { + parts[i] = strconv.FormatFloat(f, 'f', -1, 64) + } + return "[" + strings.Join(parts, ", ") + "]" + case []bool: + parts := make([]string, len(v)) + for i, b := range v { + parts[i] = strconv.FormatBool(b) + } + return "[" + strings.Join(parts, ", ") + "]" case string: return fmt.Sprintf("\"%s\"", strings.ReplaceAll(fmt.Sprintf("%s", val), "\"", "\\\"")) - case int, int32, int64: + case int, int32, int64, uint64: return fmt.Sprintf("%d", val) case float32, float64: return fmt.Sprintf("%f", val) diff --git a/gen/cli_yargs_variadic_test.go b/gen/cli_yargs_variadic_test.go new file mode 100644 index 0000000..d3a4268 --- /dev/null +++ b/gen/cli_yargs_variadic_test.go @@ -0,0 +1,73 @@ +package gen + +import ( + "strings" + "testing" + + "github.com/bcdxn/opencli/spec" +) + +// TestCLI_YargsVariadicNonString ensures that variadic flags with non-string types +// generate correct TypeScript: the local argv interface uses the proper array type +// (number[], boolean[]) instead of hardcoded string[], and the .option() builder +// emits a coerce function rather than string:true so runtime values are correctly +// typed. String variadics must remain byte-identical to prior output. +func TestCLI_YargsVariadicNonString(t *testing.T) { + doc := &spec.Document{ + OpenCLIVersion: "1.0.0-alpha.13", + Info: spec.Info{Title: "VarTest CLI", Binary: "vartest"}, + Commands: &spec.CommandItem{ + Segment: "greet", + Flags: []spec.FlagItem{ + {Name: "ids", Type: "integer", Variadic: true}, + {Name: "names", Type: "string", Variadic: true}, + {Name: "verbose", Type: "boolean"}, // non-variadic control + }, + }, + } + + files, err := CLI(doc, GenCLIWithFramework(YargsFramework)) + if err != nil { + t.Fatalf("unexpected error generating yargs output: %v", err) + } + + var all strings.Builder + for _, content := range files { + all.Write(content) + all.WriteByte('\n') + } + got := all.String() + + // Integer variadic: interface must say number[] (not string[]) and builder must coerce. + if !strings.Contains(got, `number[] | undefined`) { + t.Error("expected 'number[] | undefined' in local argv interface for integer variadic flag") + } + if strings.Contains(got, `"ids"?: string[]`) { + t.Error("integer variadic flag must NOT be typed as string[] in the local argv interface") + } + expectedCoerce := `coerce: (v) => Array.isArray(v) ? v.map(Number) : v` + if !strings.Contains(got, expectedCoerce) { + t.Errorf("expected coerce function %q for integer variadic flag", expectedCoerce) + } + + // String variadic: must remain string[] | undefined AND keep string:true (golden parity). + if !strings.Contains(got, `string[] | undefined`) { + t.Error("expected 'string[] | undefined' in local argv interface for string variadic flag") + } + if !strings.Contains(got, "string: true,") { + t.Error("string variadic flag must still emit 'string: true,' in .option() builder (golden parity)") + } + + // The integer variadic must NOT have string:true on it. Verify by checking that + // the coerce line is present and there's no "string: true" immediately after a + // type:"array" for ids. Simplest check: count occurrences of 'string: true,' — + // should be exactly 1 (only from the names flag). + if n := strings.Count(got, "string: true,"); n != 1 { + t.Errorf("expected exactly 1 occurrence of 'string: true,' (for string variadic only), got %d", n) + } + + // Non-variadic boolean control: should render as plain boolean (no array suffix). + if !strings.Contains(got, `"verbose"?: boolean;`) { + t.Error("expected non-variadic boolean flag to render as 'boolean' in interface") + } +} diff --git a/gen/templates/code/cobra/gencli/actions.tmpl b/gen/templates/code/cobra/gencli/actions.tmpl index 0a75df0..c92b00c 100644 --- a/gen/templates/code/cobra/gencli/actions.tmpl +++ b/gen/templates/code/cobra/gencli/actions.tmpl @@ -10,7 +10,7 @@ import ( // ActionsInterface defines all actions the {{.Binary}} CLI supports. type ActionsInterface interface { {{- range .LeafCommands}} - {{.MethodName}}(ctx context.Context, {{if .Args}}args {{.ArgsTypeName}}, {{end}}{{if .Flags}}flags {{.FlagsTypeName}}{{end}}) error + {{.MethodName}}(ctx context.Context{{if .Args}}, args {{.ArgsTypeName}}{{end}}{{if .Flags}}, flags {{.FlagsTypeName}}{{end}}) error {{- end}} HelpFunc(cmd *spec.CommandItem) UsageFunc(cmd *spec.CommandItem) error diff --git a/gen/templates/code/cobra/gencli/command.tmpl b/gen/templates/code/cobra/gencli/command.tmpl index 095ec15..49d663f 100644 --- a/gen/templates/code/cobra/gencli/command.tmpl +++ b/gen/templates/code/cobra/gencli/command.tmpl @@ -6,7 +6,7 @@ import ( "github.com/spf13/cobra" {{- if hasExtraAliases .CobraFlags}} "github.com/spf13/pflag" -{{- end}} +{{- end -}} ) func {{.FuncName}}(a ActionsInterface) *cobra.Command { @@ -49,7 +49,7 @@ func {{.FuncName}}(a ActionsInterface) *cobra.Command { {{- if .CobraFlags}} cmdFlags := {{.FlagsTypeName}}{ {{- range .CobraFlags}} - {{.FieldName}}: {{if .TypeName}}{{.TypeName}}({{.VarName}}){{else}}{{.VarName}}{{end}}, + {{.FieldName}}: {{resolveFlagValue .}}, {{- end}} } {{- range .CobraFlags}} @@ -62,7 +62,17 @@ func {{.FuncName}}(a ActionsInterface) *cobra.Command { {{- end}} {{- end}} {{- end}} +{{- if .GlobalFlags}} + ctx := c.Context() + ctx = WithGlobalFlags(ctx, GlobalFlags{ +{{- range .GlobalFlags}} + {{.FieldName}}: {{resolveFlagValue .}}, +{{- end}} + }) + return a.{{.MethodName}}(ctx, {{if and .CobraArgs .CobraFlags}}cmdArgs, cmdFlags{{else if .CobraArgs}}cmdArgs{{else if .CobraFlags}}cmdFlags{{end}}) +{{- else}} return a.{{.MethodName}}(c.Context(), {{if and .CobraArgs .CobraFlags}}cmdArgs, cmdFlags{{else if .CobraArgs}}cmdArgs{{else if .CobraFlags}}cmdFlags{{end}}) +{{- end}} }, {{- end}} } @@ -74,9 +84,11 @@ func {{.FuncName}}(a ActionsInterface) *cobra.Command { {{- range .ChildImports}} command.AddCommand({{.FuncName}}(a)) {{- end}} +{{- if not .IsGroup}} {{- range .CobraFlags}} command.Flags().{{.CobraBindFn}}(&{{.VarName}}, {{goString .FlagName}}, {{goString .Shorthand}}, {{.Default}}, {{goString .Summary}}) {{- end}} +{{- end}} {{- if hasExtraAliases .CobraFlags}} command.Flags().SetNormalizeFunc(func(_ *pflag.FlagSet, name string) pflag.NormalizedName { switch name { diff --git a/gen/templates/code/cobra/gencli/config.tmpl b/gen/templates/code/cobra/gencli/config.tmpl new file mode 100644 index 0000000..9780866 --- /dev/null +++ b/gen/templates/code/cobra/gencli/config.tmpl @@ -0,0 +1,334 @@ +// Code generated by github.com/bcdxn/opencli@{{.ModuleVersion}} DO NOT EDIT. +package gencli + +import ( +{{- if .ConfigJSON}} + "encoding/json" +{{- end}} + "math" + "os" + "path/filepath" + "strconv" + "strings" +{{- if .ConfigYAML}} + "gopkg.in/yaml.v3" +{{- end}} +{{- if .ConfigTOML}} + "github.com/BurntSushi/toml" +{{- end}} +{{- if .HasFileAltSource}} + "github.com/ohler55/ojg/jp" +{{- end}} + + "github.com/spf13/pflag" +) + +// AltSource is a single alternative source for a flag value: either an +// environment variable ($ENV) or a property in the config file ($FILE). +type AltSource struct { + Type string // "$ENV" or "$FILE" + Property string // env var name, or JSONPath into the config file +} + +// globalConfig holds the parsed config file data as a nested map. It is loaded +// lazily once via loadConfig at startup and shared by all $FILE lookups. +var globalConfig map[string]any + +// expandTilde expands a leading tilde (~) in the path to the user's home directory. +func expandTilde(path string) string { + if !strings.HasPrefix(path, "~") { + return path + } + home, err := os.UserHomeDir() + if err != nil { + return path + } + if path == "~" { + return home + } + return filepath.Join(home, path[1:]) +} + +// loadConfig loads the config file from the first available path (JSON > YAML > TOML). +func loadConfig() { + if globalConfig != nil { + return + } + globalConfig = make(map[string]any) + +{{- if .ConfigJSON}} + // Try JSON config first + if data, err := os.ReadFile(expandTilde({{goString .ConfigJSON}})); err == nil { + var cfg map[string]any + if err := json.Unmarshal(data, &cfg); err == nil { + globalConfig = cfg + return + } + } +{{- end}} +{{- if .ConfigYAML}} + // Try YAML config + if data, err := os.ReadFile(expandTilde({{goString .ConfigYAML}})); err == nil { + var cfg map[string]any + if err := yaml.Unmarshal(data, &cfg); err == nil { + globalConfig = cfg + return + } + } +{{- end}} +{{- if .ConfigTOML}} + // Try TOML config + if data, err := os.ReadFile(expandTilde({{goString .ConfigTOML}})); err == nil { + var cfg map[string]any + if _, err := toml.Decode(string(data), &cfg); err == nil { + globalConfig = cfg + return + } + } +{{- end}} +} + +{{- if .HasFileAltSource}} +// resolveJSONPath resolves a JSONPath expression against the global config using +// the ohler55/ojg JSONPath implementation. It returns the value at the path, or nil +// if the path does not match (or no config was loaded). +func resolveJSONPath(expr string) any { + if globalConfig == nil { + return nil + } + p, err := jp.ParseString(expr) + if err != nil { + return nil + } + return p.First(globalConfig) +} +{{- end}} + +// altSourceValue returns the raw value from a single alternative source, or nil if +// the source yields nothing. $ENV sources yield the environment variable's string +// value; $FILE sources yield the value at the given JSONPath. +func altSourceValue(src AltSource) any { + switch src.Type { + case "$ENV": + if val := os.Getenv(src.Property); val != "" { + return val + } +{{- if .HasFileAltSource}} + case "$FILE": + if val := resolveJSONPath(src.Property); val != nil { + return val + } +{{- end}} + } + return nil +} + +// altSourceItems returns the raw element values from a single alternative source for +// a variadic flag. $ENV sources are comma-separated; $FILE sources are arrays (a +// single value is treated as a one-element list). +func altSourceItems(src AltSource) []any { + switch src.Type { + case "$ENV": + variable := os.Getenv(src.Property) + if variable == "" { + return nil + } + parts := strings.Split(variable, ",") + items := make([]any, 0, len(parts)) + for _, p := range parts { + if trimmed := strings.TrimSpace(p); trimmed != "" { + items = append(items, trimmed) + } + } + return items +{{- if .HasFileAltSource}} + case "$FILE": + switch v := resolveJSONPath(src.Property).(type) { + case []any: + return v + case nil: + return nil + default: + return []any{v} + } +{{- end}} + } + return nil +} + +// Coercion helpers convert a raw source value to a concrete Go type. Each returns +// ok=false when the value cannot be interpreted as the target type, so resolvers can +// skip it and try the next alternative source in order. + +func toString(v any) (string, bool) { + if s, ok := v.(string); ok && s != "" { + return s, true + } + return "", false +} + +func toInt64(v any) (int64, bool) { + switch n := v.(type) { + case int64: + return n, true + case float64: + if n != math.Trunc(n) || n < math.MinInt64 || n > math.MaxInt64 { + return 0, false + } + return int64(n), true + case string: + if i, err := strconv.ParseInt(n, 10, 64); err == nil { + return i, true + } + } + return 0, false +} + +func toBool(v any) (bool, bool) { + switch b := v.(type) { + case bool: + return b, true + case string: + if parsed, err := strconv.ParseBool(b); err == nil { + return parsed, true + } + } + return false, false +} + +func toFloat64(v any) (float64, bool) { + switch n := v.(type) { + case float64: + return n, true + case int64: + return float64(n), true + case string: + if parsed, err := strconv.ParseFloat(n, 64); err == nil { + return parsed, true + } + } + return 0, false +} + +/* +Flag Resolver Functions + +These functions are called from generated command code to resolve flag values. When the +flag was provided on the command line (fs.Changed) its CLI value is used as-is; otherwise +the value is resolved from the flag's alternative sources (environment variables, config +file) in the order they are declared, falling back to the flag's bound default when no +source yields a usable result. The pflag getters return that bound default for unchanged +flags (or the zero value if none was declared). The merged pflag.FlagSet passed in already +contains both local and inherited persistent flags at RunE time. +*/ + +// resolveStringFlag resolves a string flag from the CLI or its alternative sources, falling +// back to the bound default when no source yields a usable result. +func resolveStringFlag(fs *pflag.FlagSet, name string, sources []AltSource) string { + if fs.Changed(name) { + val, _ := fs.GetString(name) + return val + } + for _, src := range sources { + if v, ok := toString(altSourceValue(src)); ok { + return v + } + } + defaultVal, _ := fs.GetString(name) + return defaultVal +} + +// resolveInt64Flag resolves an int64 flag from the CLI or its alternative sources, falling +// back to the bound default when no source yields a usable result. +func resolveInt64Flag(fs *pflag.FlagSet, name string, sources []AltSource) int64 { + if fs.Changed(name) { + val, _ := fs.GetInt64(name) + return val + } + for _, src := range sources { + if v, ok := toInt64(altSourceValue(src)); ok { + return v + } + } + defaultVal, _ := fs.GetInt64(name) + return defaultVal +} + +// resolveBoolFlag resolves a bool flag from the CLI or its alternative sources. An explicit +// --flag=false on the command line is honored (fs.Changed); omitting the flag consults the +// alternative sources, matching urfave/cli behavior for cross-framework parity. Falls back +// to the bound default when no source yields a usable result. +func resolveBoolFlag(fs *pflag.FlagSet, name string, sources []AltSource) bool { + if fs.Changed(name) { + val, _ := fs.GetBool(name) + return val + } + for _, src := range sources { + if v, ok := toBool(altSourceValue(src)); ok { + return v + } + } + defaultVal, _ := fs.GetBool(name) + return defaultVal +} + +// resolveFloat64Flag resolves a float64 flag from the CLI or its alternative sources, falling +// back to the bound default when no source yields a usable result. +func resolveFloat64Flag(fs *pflag.FlagSet, name string, sources []AltSource) float64 { + if fs.Changed(name) { + val, _ := fs.GetFloat64(name) + return val + } + for _, src := range sources { + if v, ok := toFloat64(altSourceValue(src)); ok { + return v + } + } + defaultVal, _ := fs.GetFloat64(name) + return defaultVal +} + +// resolveSliceFlag resolves a variadic flag from the CLI or its alternative sources. When +// the flag was not set on the command line it returns the first source that yields at least +// one value coercible to T; otherwise the bound default (an empty slice if none was declared). +// get is the bound pflag getter for the concrete element type (e.g. fs.GetStringArray). +func resolveSliceFlag[T any](fs *pflag.FlagSet, name string, sources []AltSource, get func(string) ([]T, error), coerce func(any) (T, bool)) []T { + if fs.Changed(name) { + val, _ := get(name) + return val + } + for _, src := range sources { + items := altSourceItems(src) + result := make([]T, 0, len(items)) + for _, item := range items { + if v, ok := coerce(item); ok { + result = append(result, v) + } + } + if len(result) > 0 { + return result + } + } + defaultVal, _ := get(name) + return defaultVal +} + +// resolveStringSliceFlag resolves a string slice flag from the CLI or its alternative sources. +func resolveStringSliceFlag(fs *pflag.FlagSet, name string, sources []AltSource) []string { + return resolveSliceFlag(fs, name, sources, fs.GetStringArray, toString) +} + +// resolveInt64SliceFlag resolves an int64 slice flag from the CLI or its alternative sources. +func resolveInt64SliceFlag(fs *pflag.FlagSet, name string, sources []AltSource) []int64 { + return resolveSliceFlag(fs, name, sources, fs.GetInt64Slice, toInt64) +} + +// resolveBoolSliceFlag resolves a bool slice flag from the CLI or its alternative sources. +func resolveBoolSliceFlag(fs *pflag.FlagSet, name string, sources []AltSource) []bool { + return resolveSliceFlag(fs, name, sources, fs.GetBoolSlice, toBool) +} + +// resolveFloat64SliceFlag resolves a float64 slice flag from the CLI or its alternative sources. +func resolveFloat64SliceFlag(fs *pflag.FlagSet, name string, sources []AltSource) []float64 { + return resolveSliceFlag(fs, name, sources, fs.GetFloat64Slice, toFloat64) +} diff --git a/gen/templates/code/cobra/gencli/params.tmpl b/gen/templates/code/cobra/gencli/params.tmpl index b3bcdb9..5465fc4 100644 --- a/gen/templates/code/cobra/gencli/params.tmpl +++ b/gen/templates/code/cobra/gencli/params.tmpl @@ -1,5 +1,9 @@ // Code generated by github.com/bcdxn/opencli@{{.ModuleVersion}} DO NOT EDIT. package gencli +{{if $.GlobalFlags}} + +import "context" +{{end -}} {{range .LeafCommands}} {{- range .Args}}{{if .TypeName}}{{$t := .TypeName}} // {{$t}} represents the allowed values for the {{.FieldName}} argument. @@ -52,3 +56,28 @@ type {{.FlagsTypeName}} struct { {{- end}} } {{end}}{{end}} +{{if $.GlobalFlags}} + +// GlobalFlags holds the global (root-level) flag values shared by every action. +type GlobalFlags struct { +{{- range $.GlobalFlags}} + {{.FieldName}} {{.GoType}} +{{- end}} +} + +// globalFlagsKey is an unexported context key so only this package can set or read the value. +type globalFlagsKey struct{} + +// WithGlobalFlags returns a copy of ctx carrying the given GlobalFlags for actions to retrieve via GlobalFlagsFromContext. +func WithGlobalFlags(ctx context.Context, g GlobalFlags) context.Context { + return context.WithValue(ctx, globalFlagsKey{}, g) +} + +// GlobalFlagsFromContext retrieves the GlobalFlags injected by the generated command handler; it returns zero values if none were set. +func GlobalFlagsFromContext(ctx context.Context) GlobalFlags { + if v, ok := ctx.Value(globalFlagsKey{}).(GlobalFlags); ok { + return v + } + return GlobalFlags{} +} +{{end}} diff --git a/gen/templates/code/cobra/gencli/run.tmpl b/gen/templates/code/cobra/gencli/run.tmpl index c697d16..56d9526 100644 --- a/gen/templates/code/cobra/gencli/run.tmpl +++ b/gen/templates/code/cobra/gencli/run.tmpl @@ -10,10 +10,15 @@ import ( {{- end}} ) +{{- range .GlobalFlags}} +var {{.VarName}} {{.GoType}} // global flag, bound to the root command's persistent flags in Run() +{{- end}} // Run executes the root cobra command and returns an exit code. func Run(ctx context.Context, actions ActionsInterface) int { -{{- range .GlobalFlags}} - var {{.VarName}} {{.GoType}} +{{- if .HasAltSources}} + // Load config file from disk (JSON/YAML/TOML) for $FILE alternative sources + loadConfig() + {{- end}} // Instantiate root command rootCmd := NewCmd{{.BinaryPascal}}(actions) diff --git a/gen/templates/code/urfavecli/gencli/actions.tmpl b/gen/templates/code/urfavecli/gencli/actions.tmpl index eecec12..8c129c7 100644 --- a/gen/templates/code/urfavecli/gencli/actions.tmpl +++ b/gen/templates/code/urfavecli/gencli/actions.tmpl @@ -10,7 +10,7 @@ import ( // ActionsInterface defines all actions the {{.Binary}} CLI supports. type ActionsInterface interface { {{- range .LeafCommands}} - {{.MethodName}}(ctx context.Context, {{if .Args}}args {{.ArgsTypeName}}, {{end}}{{if .Flags}}flags {{.FlagsTypeName}}{{end}}) error + {{.MethodName}}(ctx context.Context{{if .Args}}, args {{.ArgsTypeName}}{{end}}{{if .Flags}}, flags {{.FlagsTypeName}}{{end}}) error {{- end}} HelpFunc(cmd *spec.CommandItem) UsageFunc(cmd *spec.CommandItem) error diff --git a/gen/templates/code/urfavecli/gencli/command.tmpl b/gen/templates/code/urfavecli/gencli/command.tmpl index ce5d043..5589e64 100644 --- a/gen/templates/code/urfavecli/gencli/command.tmpl +++ b/gen/templates/code/urfavecli/gencli/command.tmpl @@ -42,7 +42,7 @@ func {{.FuncName}}(a ActionsInterface) *cli.Command { {{- if .UrfaveFlags}} cmdFlags := {{.FlagsTypeName}}{ {{- range .UrfaveFlags}} - {{.FieldName}}: {{if .TypeName}}{{.TypeName}}(c.{{.Accessor}}("{{.FlagName}}")){{else}}c.{{.Accessor}}("{{.FlagName}}"){{end}}, + {{.FieldName}}: {{resolveFlagValue .}}, {{- end}} } {{- range .UrfaveFlags}} @@ -54,6 +54,13 @@ func {{.FuncName}}(a ActionsInterface) *cli.Command { } {{- end}} {{- end}} +{{- end}} +{{- if .GlobalFlags}} + ctx = WithGlobalFlags(ctx, GlobalFlags{ +{{- range .GlobalFlags}} + {{.FieldName}}: {{resolveFlagValue .}}, +{{- end}} + }) {{- end}} return a.{{.MethodName}}(ctx, {{if and .UrfaveArgs .UrfaveFlags}}cmdArgs, cmdFlags{{else if .UrfaveArgs}}cmdArgs{{else if .UrfaveFlags}}cmdFlags{{end}}) }, diff --git a/gen/templates/code/urfavecli/gencli/config.tmpl b/gen/templates/code/urfavecli/gencli/config.tmpl new file mode 100644 index 0000000..175677a --- /dev/null +++ b/gen/templates/code/urfavecli/gencli/config.tmpl @@ -0,0 +1,318 @@ +// Code generated by github.com/bcdxn/opencli@{{.ModuleVersion}} DO NOT EDIT. +package gencli + +import ( +{{- if .ConfigJSON}} + "encoding/json" +{{- end}} + "math" + "os" + "path/filepath" + "strconv" + "strings" +{{- if .ConfigYAML}} + "gopkg.in/yaml.v3" +{{- end}} +{{- if .ConfigTOML}} + "github.com/BurntSushi/toml" +{{- end}} +{{- if .HasFileAltSource}} + "github.com/ohler55/ojg/jp" +{{- end}} +) + +// AltSource is a single alternative source for a flag value: either an +// environment variable ($ENV) or a property in the config file ($FILE). +type AltSource struct { + Type string // "$ENV" or "$FILE" + Property string // env var name, or JSONPath into the config file +} + +// globalConfig holds the parsed config file data as a nested map. +var globalConfig map[string]any + +// expandTilde expands a leading tilde (~) in the path to the user's home directory. +func expandTilde(path string) string { + if !strings.HasPrefix(path, "~") { + return path + } + home, err := os.UserHomeDir() + if err != nil { + return path + } + if path == "~" { + return home + } + return filepath.Join(home, path[1:]) +} + +// loadConfig loads the config file from the first available path (JSON > YAML > TOML). +func loadConfig() { + if globalConfig != nil { + return + } + globalConfig = make(map[string]any) + +{{- if .ConfigJSON}} + // Try JSON config first + if data, err := os.ReadFile(expandTilde({{goString .ConfigJSON}})); err == nil { + var cfg map[string]any + if err := json.Unmarshal(data, &cfg); err == nil { + globalConfig = cfg + return + } + } +{{- end}} +{{- if .ConfigYAML}} + // Try YAML config + if data, err := os.ReadFile(expandTilde({{goString .ConfigYAML}})); err == nil { + var cfg map[string]any + if err := yaml.Unmarshal(data, &cfg); err == nil { + globalConfig = cfg + return + } + } +{{- end}} +{{- if .ConfigTOML}} + // Try TOML config + if data, err := os.ReadFile(expandTilde({{goString .ConfigTOML}})); err == nil { + var cfg map[string]any + if _, err := toml.Decode(string(data), &cfg); err == nil { + globalConfig = cfg + return + } + } +{{- end}} +} + +{{- if .HasFileAltSource}} +// resolveJSONPath resolves a JSONPath expression against the global config +// using the ohler55/ojg JSONPath implementation. It returns the value at the +// path, or nil if the path does not match. +func resolveJSONPath(expr string) any { + if globalConfig == nil { + return nil + } + p, err := jp.ParseString(expr) + if err != nil { + return nil + } + return p.First(globalConfig) +} +{{- end}} + +// altSourceValue returns the raw value from a single alternative source, or nil +// if the source yields nothing. $ENV sources yield the environment variable's +// string value; $FILE sources yield the value at the given JSONPath. +func altSourceValue(src AltSource) any { + switch src.Type { + case "$ENV": + if val := os.Getenv(src.Property); val != "" { + return val + } +{{- if .HasFileAltSource}} + case "$FILE": + if val := resolveJSONPath(src.Property); val != nil { + return val + } +{{- end}} + } + return nil +} + +// altSourceItems returns the raw element values from a single alternative +// source for a variadic flag. $ENV sources are comma-separated; $FILE sources +// are arrays (a single value is treated as a one-element list). +func altSourceItems(src AltSource) []any { + switch src.Type { + case "$ENV": + variable := os.Getenv(src.Property) + if variable == "" { + return nil + } + parts := strings.Split(variable, ",") + items := make([]any, 0, len(parts)) + for _, p := range parts { + if trimmed := strings.TrimSpace(p); trimmed != "" { + items = append(items, trimmed) + } + } + return items +{{- if .HasFileAltSource}} + case "$FILE": + switch v := resolveJSONPath(src.Property).(type) { + case []any: + return v + case nil: + return nil + default: + return []any{v} + } +{{- end}} + } + return nil +} + +// Coercion helpers convert a raw source value to a concrete Go type. Each +// returns ok=false when the value cannot be interpreted as the target type. + +func toString(v any) (string, bool) { + if s, ok := v.(string); ok && s != "" { + return s, true + } + return "", false +} + +func toInt64(v any) (int64, bool) { + switch n := v.(type) { + case int64: + return n, true + case float64: + if n != math.Trunc(n) || n < math.MinInt64 || n > math.MaxInt64 { + return 0, false + } + return int64(n), true + case string: + if i, err := strconv.ParseInt(n, 10, 64); err == nil { + return i, true + } + } + return 0, false +} + +func toBool(v any) (bool, bool) { + switch b := v.(type) { + case bool: + return b, true + case string: + if parsed, err := strconv.ParseBool(b); err == nil { + return parsed, true + } + } + return false, false +} + +func toFloat64(v any) (float64, bool) { + switch n := v.(type) { + case float64: + return n, true + case int64: + return float64(n), true + case string: + if parsed, err := strconv.ParseFloat(n, 64); err == nil { + return parsed, true + } + } + return 0, false +} + +/* +Flag Resolver Functions + +These functions are called from generated command code to resolve flag values. +When the flag was provided on the command line (set is true) the CLI value is +used as-is; otherwise the value is resolved from the flag's alternative sources +(environment variables, config file) in the order they are declared, falling back +to cliVal when no source yields a usable result. For an unset flag the caller passes +the bound default via the command accessor (or the zero value if none was declared), +so this fallback honors any default declared in the spec. +*/ + +// resolveStringFlag resolves a string flag from the CLI or its alternative sources, falling +// back to the bound default when no source yields a usable result. +func resolveStringFlag(set bool, cliVal string, sources []AltSource) string { + if set { + return cliVal + } + for _, src := range sources { + if v, ok := toString(altSourceValue(src)); ok { + return v + } + } + return cliVal +} + +// resolveInt64Flag resolves an int64 flag from the CLI or its alternative sources, falling +// back to the bound default when no source yields a usable result. +func resolveInt64Flag(set bool, cliVal int64, sources []AltSource) int64 { + if set { + return cliVal + } + for _, src := range sources { + if v, ok := toInt64(altSourceValue(src)); ok { + return v + } + } + return cliVal +} + +// resolveBoolFlag resolves a bool flag from the CLI or its alternative sources, falling back +// to the bound default when no source yields a usable result. +func resolveBoolFlag(set bool, cliVal bool, sources []AltSource) bool { + if set { + return cliVal + } + for _, src := range sources { + if v, ok := toBool(altSourceValue(src)); ok { + return v + } + } + return cliVal +} + +// resolveFloat64Flag resolves a float64 flag from the CLI or its alternative sources, falling +// back to the bound default when no source yields a usable result. +func resolveFloat64Flag(set bool, cliVal float64, sources []AltSource) float64 { + if set { + return cliVal + } + for _, src := range sources { + if v, ok := toFloat64(altSourceValue(src)); ok { + return v + } + } + return cliVal +} + +// resolveSliceFlag resolves a variadic flag from the CLI or its alternative +// sources. When the flag was not set, it returns the first source that yields +// at least one value coercible to T; otherwise the bound default (an empty slice +// if none was declared). +func resolveSliceFlag[T any](set bool, cliVal []T, sources []AltSource, coerce func(any) (T, bool)) []T { + if set { + return cliVal + } + for _, src := range sources { + items := altSourceItems(src) + result := make([]T, 0, len(items)) + for _, item := range items { + if v, ok := coerce(item); ok { + result = append(result, v) + } + } + if len(result) > 0 { + return result + } + } + return cliVal +} + +// resolveStringSliceFlag resolves a string slice flag from the CLI or its alternative sources. +func resolveStringSliceFlag(set bool, cliVal []string, sources []AltSource) []string { + return resolveSliceFlag(set, cliVal, sources, toString) +} + +// resolveInt64SliceFlag resolves an int64 slice flag from the CLI or its alternative sources. +func resolveInt64SliceFlag(set bool, cliVal []int64, sources []AltSource) []int64 { + return resolveSliceFlag(set, cliVal, sources, toInt64) +} + +// resolveBoolSliceFlag resolves a bool slice flag from the CLI or its alternative sources. +func resolveBoolSliceFlag(set bool, cliVal []bool, sources []AltSource) []bool { + return resolveSliceFlag(set, cliVal, sources, toBool) +} + +// resolveFloat64SliceFlag resolves a float64 slice flag from the CLI or its alternative sources. +func resolveFloat64SliceFlag(set bool, cliVal []float64, sources []AltSource) []float64 { + return resolveSliceFlag(set, cliVal, sources, toFloat64) +} diff --git a/gen/templates/code/urfavecli/gencli/params.tmpl b/gen/templates/code/urfavecli/gencli/params.tmpl index b3bcdb9..5465fc4 100644 --- a/gen/templates/code/urfavecli/gencli/params.tmpl +++ b/gen/templates/code/urfavecli/gencli/params.tmpl @@ -1,5 +1,9 @@ // Code generated by github.com/bcdxn/opencli@{{.ModuleVersion}} DO NOT EDIT. package gencli +{{if $.GlobalFlags}} + +import "context" +{{end -}} {{range .LeafCommands}} {{- range .Args}}{{if .TypeName}}{{$t := .TypeName}} // {{$t}} represents the allowed values for the {{.FieldName}} argument. @@ -52,3 +56,28 @@ type {{.FlagsTypeName}} struct { {{- end}} } {{end}}{{end}} +{{if $.GlobalFlags}} + +// GlobalFlags holds the global (root-level) flag values shared by every action. +type GlobalFlags struct { +{{- range $.GlobalFlags}} + {{.FieldName}} {{.GoType}} +{{- end}} +} + +// globalFlagsKey is an unexported context key so only this package can set or read the value. +type globalFlagsKey struct{} + +// WithGlobalFlags returns a copy of ctx carrying the given GlobalFlags for actions to retrieve via GlobalFlagsFromContext. +func WithGlobalFlags(ctx context.Context, g GlobalFlags) context.Context { + return context.WithValue(ctx, globalFlagsKey{}, g) +} + +// GlobalFlagsFromContext retrieves the GlobalFlags injected by the generated command handler; it returns zero values if none were set. +func GlobalFlagsFromContext(ctx context.Context) GlobalFlags { + if v, ok := ctx.Value(globalFlagsKey{}).(GlobalFlags); ok { + return v + } + return GlobalFlags{} +} +{{end}} diff --git a/gen/templates/code/urfavecli/gencli/run.tmpl b/gen/templates/code/urfavecli/gencli/run.tmpl index 6e8a2f2..bc1acd6 100644 --- a/gen/templates/code/urfavecli/gencli/run.tmpl +++ b/gen/templates/code/urfavecli/gencli/run.tmpl @@ -6,10 +6,19 @@ import ( "errors" "fmt" "os" +{{- if .GlobalFlags}} + + "github.com/urfave/cli/v3" +{{- end}} ) // Run executes the root urfave command and returns an exit code. func Run(ctx context.Context, actions ActionsInterface) int { +{{- if .HasAltSources}} + // Load config file from disk (JSON/YAML/TOML) for $FILE alternative sources + loadConfig() + +{{end}} // Setup custom help printer with glamour/lipgloss rendering SetupUrfaveHelpPrinter(actions) diff --git a/gen/templates/code/yargs/gencli/command.tmpl b/gen/templates/code/yargs/gencli/command.tmpl index 4ecae41..619d086 100644 --- a/gen/templates/code/yargs/gencli/command.tmpl +++ b/gen/templates/code/yargs/gencli/command.tmpl @@ -15,8 +15,18 @@ import { {{- range .YargsFlags}}{{if .TypeName}} {{.TypeName}}, {{- end}}{{end}} -} from "./params"; +{{- if .GlobalFlags}} + GlobalFlags, + setGlobalFlags, {{- end}} +} from "./params"; +{{- end}}{{if .ConfigImports}} +import { + {{joinStrings .ConfigImports ",\n "}} +} from "./config"; + +{{else}} +{{end -}} import { CommandPrintData } from "./types"; import { CliError, ExitCode, createBadUserInputError } from "./errors"; @@ -27,7 +37,12 @@ interface {{.CommandArgName}} { "{{.RawName}}"{{if not .IsRequired}}?{{end}}: {{.TSType}}; {{- end}} {{- range .YargsFlags}} - "{{.FieldName}}"{{if not .IsRequired}}?{{end}}: {{if .IsVariadic}}string[] | undefined{{else}}{{.TSType}}{{end}}; + "{{.FieldName}}"{{if not .IsRequired}}?{{end}}: {{.TSType}}{{if .IsVariadic}} | undefined{{end}}; +{{- end}} +{{- if .GlobalFlags}} +{{- range .GlobalFlags}} + "{{.FieldName}}"{{if not (or .IsRequired .AltSources)}}?{{end}}: {{.TSType}}{{if or .IsVariadic .AltSources}} | undefined{{end}}; +{{- end}} {{- end}} help: boolean; } @@ -68,14 +83,14 @@ export function {{.FuncName}}( {{- end}} {{- if .IsVariadic}} type: "array", - string: true, + {{if .VariadicCoerce}}coerce: {{.VariadicCoerce}},{{else}}string: true,{{end}} {{- else}} type: {{if eq .TSType "number"}}"number"{{else if eq .TSType "boolean"}}"boolean"{{else}}"string"{{end}}, {{- end}} {{- if .Choices}} choices: [{{range $i, $c := .Choices}}{{if $i}}, {{end}}{{tsString $c.Value}}{{end}}], {{- end}} -{{- if .IsRequired}} +{{- if and .IsRequired (not .AltSources)}} demandOption: true, {{- end}} {{- if .Default}} @@ -134,14 +149,17 @@ export function {{.FuncName}}( {{- if .YargsFlags}} const cmdFlags: {{.FlagsTypeName}} = { {{- range .YargsFlags}} -{{- if .TypeName}} - {{.FieldName}}: argv.{{.FieldName}} as {{.TypeName}}, -{{- else}} - {{.FieldName}}: argv.{{.FieldName}} as {{.TSType}}, -{{- end}} + {{.FieldName}}: {{resolveFlagValue .}}, {{- end}} }; {{- end}} +{{- if .GlobalFlags}} + setGlobalFlags({ +{{- range .GlobalFlags}} + {{.FieldName}}: {{resolveFlagValue .}}, +{{- end}} + }); +{{- end}} {{- if and .YargsArgs .YargsFlags}} return actions.{{.MethodName}}(cmdArgs, cmdFlags); {{- else if .YargsArgs}} diff --git a/gen/templates/code/yargs/gencli/config.tmpl b/gen/templates/code/yargs/gencli/config.tmpl new file mode 100644 index 0000000..69446d2 --- /dev/null +++ b/gen/templates/code/yargs/gencli/config.tmpl @@ -0,0 +1,281 @@ +// Code generated by github.com/bcdxn/opencli@{{.ModuleVersion}} DO NOT EDIT. +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +{{- if .ConfigYAML}} +import { parse as parseYaml } from "yaml"; +{{- end}} +{{- if .ConfigTOML}} +import toml from "@iarna/toml"; +{{- end}} +{{- if .HasFileAltSource}} +import { JSONPath } from "jsonpath-plus"; +{{- end}} + +// AltSource is a single alternative source for a flag value: either an +// environment variable ($ENV) or a property in the config file ($FILE). +export interface AltSource { + type: string; // "$ENV" or "$FILE" + property: string; // env var name, or JSONPath into the config file +} + +let globalConfig: Record | null = null; + +// expandTilde expands a leading tilde (~) in the path to the user's home directory. +function expandTilde(p: string): string { + if (!p.startsWith("~")) return p; + const home = os.homedir(); + if (p === "~") return home; + return path.join(home, p.slice(1)); +} + +// loadConfig loads the config file from the first available path (JSON > YAML > TOML). +export function loadConfig(): void { + if (globalConfig !== null) return; + globalConfig = {}; +{{- if .ConfigJSON}} + // Try JSON config first. + try { + const data = fs.readFileSync(expandTilde({{tsString .ConfigJSON}}), "utf8"); + globalConfig = JSON.parse(data) as Record; + return; + } catch {} +{{- end}} +{{- if .ConfigYAML}} + // Try YAML config. + try { + const data = fs.readFileSync(expandTilde({{tsString .ConfigYAML}}), "utf8"); + globalConfig = parseYaml(data) as Record; + return; + } catch {} +{{- end}} +{{- if .ConfigTOML}} + // Try TOML config. + try { + const data = fs.readFileSync(expandTilde({{tsString .ConfigTOML}}), "utf8"); + globalConfig = toml.parse(data) as Record; + return; + } catch {} +{{- end}} +} +{{if .HasFileAltSource}} +// resolveJSONPath resolves a JSONPath expression against the loaded config. It returns +// null when no config was loaded or the path does not match. +function resolveJSONPath(expr: string): unknown { + if (globalConfig === null) return null; + try { + const results = JSONPath({ path: expr, json: globalConfig }); + if (!Array.isArray(results)) return results; + return results.length > 0 ? results[0] : null; + } catch { + return null; + } +} +{{end}} +// altSourceValue returns the raw value from a single alternative source, or null when +// the source yields nothing. $ENV sources yield the environment variable's string +// value; $FILE sources yield the value at the given JSONPath. +function altSourceValue(src: AltSource): unknown { + switch (src.type) { + case "$ENV": { + const val = process.env[src.property]; + if (val !== undefined && val !== "") return val; + break; + } +{{- if .HasFileAltSource}} + case "$FILE": { + const val = resolveJSONPath(src.property); + if (val !== null) return val; + break; + } +{{- end}} + } + return null; +} + +// altSourceItems returns the raw element values from a single alternative source for a +// variadic flag. $ENV sources are comma-separated; $FILE sources are arrays (a single +// value is treated as a one-element list). +function altSourceItems(src: AltSource): unknown[] { + switch (src.type) { + case "$ENV": { + const variable = process.env[src.property]; + if (!variable || variable === "") return []; + return variable + .split(",") + .map((p) => p.trim()) + .filter((p) => p !== ""); + } +{{- if .HasFileAltSource}} + case "$FILE": { + const val = resolveJSONPath(src.property); + if (val === null || val === undefined) return []; + if (Array.isArray(val)) return val; + return [val]; + } +{{- end}} + } + return []; +} + +// Coercion helpers convert a raw source value to a concrete TypeScript type. Each returns +// ok=false when the value cannot be interpreted as the target type, so resolvers can skip +// it and try the next alternative source in order. + +function toString(v: unknown): [string, boolean] { + if (typeof v === "string" && v !== "") return [v, true]; + return ["", false]; +} + +function toNumber(v: unknown): [number, boolean] { + if (typeof v === "number") { + return Number.isFinite(v) ? [v, true] : ([0, false] as [number, boolean]); + } + if (typeof v === "string" && v.trim() !== "") { + const n = Number(v); + if (!Number.isNaN(n)) return [n, true]; + } + return [0, false]; +} + +function toBool(v: unknown): [boolean, boolean] { + if (typeof v === "boolean") return [v, true]; + if (typeof v === "string") { + switch (v.toLowerCase()) { + case "1": + case "t": + case "true": + return [true, true]; + case "0": + case "f": + case "false": + return [false, true]; + } + } + return [false, false]; +} + +function toStringSlice(v: unknown): [string[], boolean] { + const items = Array.isArray(v) ? v : typeof v === "string" && v !== "" ? [v] : []; + if (items.length === 0) return [[], false]; + const result: string[] = []; + for (const item of items) { + const s = toString(item); + if (!s[1]) continue; + result.push(s[0]); + } + return [result, result.length > 0]; +} + +function toNumberSlice(v: unknown): [number[], boolean] { + const items = Array.isArray(v) ? v : typeof v === "string" && v !== "" ? [v] : []; + if (items.length === 0) return [[], false]; + const result: number[] = []; + for (const item of items) { + const n = toNumber(item); + if (!n[1]) continue; + result.push(n[0]); + } + return [result, result.length > 0]; +} + +function toBoolSlice(v: unknown): [boolean[], boolean] { + const items = Array.isArray(v) ? v : typeof v === "string" && v !== "" ? [v] : []; + if (items.length === 0) return [[], false]; + const result: boolean[] = []; + for (const item of items) { + const b = toBool(item); + if (!b[1]) continue; + result.push(b[0]); + } + return [result, result.length > 0]; +} + +/* +Flag Resolver Functions + +These functions are called from generated command code to resolve flag values. yargs has +no "was this option set on the CLI" API (unlike pflag.Changed or urfave's IsSet), so an +explicit CLI value is detected by scanning process.argv for tokens that reference the +option (--name, --name=..., -x shorthand, multi-char aliases as long options, and the +--no-name negation form). When found, the parsed argv value is used as-is; otherwise the +value is resolved from the flag's alternative sources (environment variables, config file) +in declared order. Returning undefined lets yargs apply its bound default or leave the +field unset. +*/ + +// wasSetOnCli reports whether any token in process.argv references one of the given +// option spellings. names[0] is the camelCase argv field; the rest are raw spec names and +// aliases (yargs accepts all of them). The --no-name negation form also counts as set. +function wasSetOnCli(names: string[]): boolean { + const tokens = process.argv.slice(2); + for (const t of tokens) { + if (!t.startsWith("-")) continue; + let body = t.replace(/^--?/, ""); + // --no-name negation form references the same option. + if (body.startsWith("no-") && names.includes(body.slice(3))) return true; + const eqIdx = body.indexOf("="); + if (eqIdx >= 0) body = body.slice(0, eqIdx); + if (body !== "" && names.includes(body)) return true; + } + return false; +} + +// resolveStringFlag resolves a string flag from the CLI or its alternative sources. +export function resolveStringFlag(argv: any, names: string[], sources: AltSource[]): string | undefined { + if (wasSetOnCli(names)) return argv[names[0]] as string; + for (const src of sources) { + const v = toString(altSourceValue(src)); + if (v[1]) return v[0]; + } + return undefined; +} + +// resolveNumberFlag resolves a number flag from the CLI or its alternative sources. +export function resolveNumberFlag(argv: any, names: string[], sources: AltSource[]): number | undefined { + if (wasSetOnCli(names)) return argv[names[0]] as number; + for (const src of sources) { + const v = toNumber(altSourceValue(src)); + if (v[1]) return v[0]; + } + return undefined; +} + +// resolveBoolFlag resolves a boolean flag from the CLI or its alternative sources. An +// explicit --flag=false on the command line is honored via wasSetOnCli; omitting the flag +// consults the alternative sources, matching cobra/urfave behavior for cross-framework parity. +export function resolveBoolFlag(argv: any, names: string[], sources: AltSource[]): boolean | undefined { + if (wasSetOnCli(names)) return argv[names[0]] as boolean; + for (const src of sources) { + const v = toBool(altSourceValue(src)); + if (v[1]) return v[0]; + } + return undefined; +} + +// resolveSliceFlag resolves a variadic flag from the CLI or its alternative sources. When +// not set on the command line it returns the first source that yields at least one value +// coercible to T, otherwise undefined (yargs then applies its bound default). +function resolveSliceFlag(argv: any, names: string[], sources: AltSource[], coerce: (v: unknown) => [T[], boolean]): T[] | undefined { + if (wasSetOnCli(names)) return argv[names[0]] as T[]; + for (const src of sources) { + const v = coerce(altSourceItems(src)); + if (v[1]) return v[0]; + } + return undefined; +} + +// resolveStringSliceFlag resolves a string slice flag from the CLI or its alternative sources. +export function resolveStringSliceFlag(argv: any, names: string[], sources: AltSource[]): string[] | undefined { + return resolveSliceFlag(argv, names, sources, toStringSlice); +} + +// resolveNumberSliceFlag resolves a number slice flag from the CLI or its alternative sources. +export function resolveNumberSliceFlag(argv: any, names: string[], sources: AltSource[]): number[] | undefined { + return resolveSliceFlag(argv, names, sources, toNumberSlice); +} + +// resolveBoolSliceFlag resolves a boolean slice flag from the CLI or its alternative sources. +export function resolveBoolSliceFlag(argv: any, names: string[], sources: AltSource[]): boolean[] | undefined { + return resolveSliceFlag(argv, names, sources, toBoolSlice); +} diff --git a/gen/templates/code/yargs/gencli/help.tmpl b/gen/templates/code/yargs/gencli/help.tmpl index 48cfad9..7d72f6c 100644 --- a/gen/templates/code/yargs/gencli/help.tmpl +++ b/gen/templates/code/yargs/gencli/help.tmpl @@ -79,11 +79,11 @@ function appendArgSections( if (cmd.args?.length) { sections.push({ header: "ARGUMENTS", - optionList: cmd.args.map((a) => ({ + content: cmd.args.map((a) => ({ name: a.name, description: escapeChalk(a.summary), })), - } as commandLineUsage.OptionList); + } as commandLineUsage.Content); } } diff --git a/gen/templates/code/yargs/gencli/params.tmpl b/gen/templates/code/yargs/gencli/params.tmpl index 6da3a5a..2e0b6c4 100644 --- a/gen/templates/code/yargs/gencli/params.tmpl +++ b/gen/templates/code/yargs/gencli/params.tmpl @@ -1,4 +1,22 @@ // Code generated by github.com/bcdxn/opencli@{{.ModuleVersion}} DO NOT EDIT. +{{- if .GlobalFlags}} +// GlobalFlags holds the global (root-level) flag values shared by every action. +export interface GlobalFlags { +{{- range .GlobalFlags}} + {{.FieldName}}{{if not (or .IsRequired .AltSources)}}?{{end}}: {{.TSType}}{{if or .IsVariadic .AltSources}} | undefined{{end}}; +{{- end}} +} + +// Module-level holder for the current invocation's global flag values, set by each generated command handler before calling an action. +let _globalFlags: GlobalFlags = {} as GlobalFlags; +export function setGlobalFlags(flags: GlobalFlags): void { + _globalFlags = flags; +} +export function getGlobalFlags(): GlobalFlags { + return _globalFlags; +} + +{{end -}} {{- range .LeafCommands}} {{- range .Args}}{{if .TypeName}}{{$t := .TypeName}} // {{$t}} represents the allowed values for the {{.FieldName}} argument. diff --git a/gen/templates/code/yargs/gencli/run.tmpl b/gen/templates/code/yargs/gencli/run.tmpl index e058eb2..7fbee46 100644 --- a/gen/templates/code/yargs/gencli/run.tmpl +++ b/gen/templates/code/yargs/gencli/run.tmpl @@ -1,7 +1,12 @@ // Code generated by github.com/bcdxn/opencli@{{.ModuleVersion}} DO NOT EDIT. import yargs from "yargs"; +import { hideBin } from "yargs/helpers"; {{- range .ChildImports}} import { {{.FuncName}} } from "./{{.FileName}}"; +{{- end}} +{{- if .HasAltSources}} +import { loadConfig } from "./config"; + {{- end}} import { ActionsInterface } from "./actions"; import { CommandPrintData } from "./types"; @@ -9,15 +14,39 @@ import { CliError, ExitCode } from "./errors"; import { defaultHelpFn, defaultUsageFn } from "./help"; export async function run( - yargsInstance: yargs.Argv<{}>, + argv: string[], actions: ActionsInterface, ): Promise { - await yargsInstance +{{- if .HasAltSources}} + // Load config file from disk (JSON/YAML/TOML) for $FILE alternative sources. + loadConfig(); + +{{- end}} + await yargs(hideBin(argv)) .scriptName({{tsString .Binary}}) .help(false) {{- range .ChildImports}} .command({{.FuncName}}(actions)) +{{- end}}{{- if .GlobalFlags}}{{range .GlobalFlags}} + .option({{tsString .RawName}}, { + describe: {{tsString .Summary}}, +{{- if or .Shorthand .ExtraAliases}}{{$sh := .Shorthand}} + aliases: [{{if $sh}}{{tsString $sh}}{{end}}{{range $i, $a := .ExtraAliases}}{{if or $i $sh}}, {{end}}{{tsString $a}}{{end}}], {{- end}} +{{- if .IsVariadic}} + type: "array", + string: true, +{{- else}} + type: {{if eq .TSType "number"}}"number"{{else if eq .TSType "boolean"}}"boolean"{{else}}"string"{{end}}, +{{- end}} +{{- if and .IsRequired (not .AltSources)}} + demandOption: true, +{{- end}} +{{- if .Default}} + default: {{.Default}}, +{{- end}} + }) +{{- end}}{{end}} .demandCommand(1) .option("help", { alias: "h", diff --git a/gen/testdata/cobra/gencli/cmd_petstore_user_login.gen.go b/gen/testdata/cobra/gencli/cmd_petstore_user_login.gen.go index 5e4a328..6882e4c 100644 --- a/gen/testdata/cobra/gencli/cmd_petstore_user_login.gen.go +++ b/gen/testdata/cobra/gencli/cmd_petstore_user_login.gen.go @@ -15,8 +15,8 @@ func NewCmdPetstoreUserLogin(a ActionsInterface) *cobra.Command { Long: "", RunE: func(c *cobra.Command, args []string) error { cmdFlags := PetstoreUserLoginFlags{ - Username: flagUsername, - Password: flagPassword, + Username: resolveStringFlag(c.Flags(), "username", []AltSource{{Type: "$ENV", Property: "PETSTORE_USER"}, {Type: "$FILE", Property: "$.auth.user"}}), + Password: resolveStringFlag(c.Flags(), "password", []AltSource{{Type: "$ENV", Property: "PETSTORE_PASS"}, {Type: "$FILE", Property: "$.auth.pass"}}), } return a.PetstoreUserLogin(c.Context(), cmdFlags) }, diff --git a/gen/testdata/cobra/gencli/config.gen.go b/gen/testdata/cobra/gencli/config.gen.go new file mode 100644 index 0000000..53208c6 --- /dev/null +++ b/gen/testdata/cobra/gencli/config.gen.go @@ -0,0 +1,313 @@ +// Code generated by github.com/bcdxn/opencli@(devel) DO NOT EDIT. +package gencli + +import ( + "encoding/json" + "github.com/BurntSushi/toml" + "github.com/ohler55/ojg/jp" + "gopkg.in/yaml.v3" + "math" + "os" + "path/filepath" + "strconv" + "strings" + + "github.com/spf13/pflag" +) + +// AltSource is a single alternative source for a flag value: either an +// environment variable ($ENV) or a property in the config file ($FILE). +type AltSource struct { + Type string // "$ENV" or "$FILE" + Property string // env var name, or JSONPath into the config file +} + +// globalConfig holds the parsed config file data as a nested map. It is loaded +// lazily once via loadConfig at startup and shared by all $FILE lookups. +var globalConfig map[string]any + +// expandTilde expands a leading tilde (~) in the path to the user's home directory. +func expandTilde(path string) string { + if !strings.HasPrefix(path, "~") { + return path + } + home, err := os.UserHomeDir() + if err != nil { + return path + } + if path == "~" { + return home + } + return filepath.Join(home, path[1:]) +} + +// loadConfig loads the config file from the first available path (JSON > YAML > TOML). +func loadConfig() { + if globalConfig != nil { + return + } + globalConfig = make(map[string]any) + // Try JSON config first + if data, err := os.ReadFile(expandTilde("~/.petstore/config.json")); err == nil { + var cfg map[string]any + if err := json.Unmarshal(data, &cfg); err == nil { + globalConfig = cfg + return + } + } + // Try YAML config + if data, err := os.ReadFile(expandTilde("~/.petstore/config.yaml")); err == nil { + var cfg map[string]any + if err := yaml.Unmarshal(data, &cfg); err == nil { + globalConfig = cfg + return + } + } + // Try TOML config + if data, err := os.ReadFile(expandTilde("~/.petstore/config.toml")); err == nil { + var cfg map[string]any + if _, err := toml.Decode(string(data), &cfg); err == nil { + globalConfig = cfg + return + } + } +} + +// resolveJSONPath resolves a JSONPath expression against the global config using +// the ohler55/ojg JSONPath implementation. It returns the value at the path, or nil +// if the path does not match (or no config was loaded). +func resolveJSONPath(expr string) any { + if globalConfig == nil { + return nil + } + p, err := jp.ParseString(expr) + if err != nil { + return nil + } + return p.First(globalConfig) +} + +// altSourceValue returns the raw value from a single alternative source, or nil if +// the source yields nothing. $ENV sources yield the environment variable's string +// value; $FILE sources yield the value at the given JSONPath. +func altSourceValue(src AltSource) any { + switch src.Type { + case "$ENV": + if val := os.Getenv(src.Property); val != "" { + return val + } + case "$FILE": + if val := resolveJSONPath(src.Property); val != nil { + return val + } + } + return nil +} + +// altSourceItems returns the raw element values from a single alternative source for +// a variadic flag. $ENV sources are comma-separated; $FILE sources are arrays (a +// single value is treated as a one-element list). +func altSourceItems(src AltSource) []any { + switch src.Type { + case "$ENV": + variable := os.Getenv(src.Property) + if variable == "" { + return nil + } + parts := strings.Split(variable, ",") + items := make([]any, 0, len(parts)) + for _, p := range parts { + if trimmed := strings.TrimSpace(p); trimmed != "" { + items = append(items, trimmed) + } + } + return items + case "$FILE": + switch v := resolveJSONPath(src.Property).(type) { + case []any: + return v + case nil: + return nil + default: + return []any{v} + } + } + return nil +} + +// Coercion helpers convert a raw source value to a concrete Go type. Each returns +// ok=false when the value cannot be interpreted as the target type, so resolvers can +// skip it and try the next alternative source in order. + +func toString(v any) (string, bool) { + if s, ok := v.(string); ok && s != "" { + return s, true + } + return "", false +} + +func toInt64(v any) (int64, bool) { + switch n := v.(type) { + case int64: + return n, true + case float64: + if n != math.Trunc(n) || n < math.MinInt64 || n > math.MaxInt64 { + return 0, false + } + return int64(n), true + case string: + if i, err := strconv.ParseInt(n, 10, 64); err == nil { + return i, true + } + } + return 0, false +} + +func toBool(v any) (bool, bool) { + switch b := v.(type) { + case bool: + return b, true + case string: + if parsed, err := strconv.ParseBool(b); err == nil { + return parsed, true + } + } + return false, false +} + +func toFloat64(v any) (float64, bool) { + switch n := v.(type) { + case float64: + return n, true + case int64: + return float64(n), true + case string: + if parsed, err := strconv.ParseFloat(n, 64); err == nil { + return parsed, true + } + } + return 0, false +} + +/* +Flag Resolver Functions + +These functions are called from generated command code to resolve flag values. When the +flag was provided on the command line (fs.Changed) its CLI value is used as-is; otherwise +the value is resolved from the flag's alternative sources (environment variables, config +file) in the order they are declared, falling back to the flag's bound default when no +source yields a usable result. The pflag getters return that bound default for unchanged +flags (or the zero value if none was declared). The merged pflag.FlagSet passed in already +contains both local and inherited persistent flags at RunE time. +*/ + +// resolveStringFlag resolves a string flag from the CLI or its alternative sources, falling +// back to the bound default when no source yields a usable result. +func resolveStringFlag(fs *pflag.FlagSet, name string, sources []AltSource) string { + if fs.Changed(name) { + val, _ := fs.GetString(name) + return val + } + for _, src := range sources { + if v, ok := toString(altSourceValue(src)); ok { + return v + } + } + defaultVal, _ := fs.GetString(name) + return defaultVal +} + +// resolveInt64Flag resolves an int64 flag from the CLI or its alternative sources, falling +// back to the bound default when no source yields a usable result. +func resolveInt64Flag(fs *pflag.FlagSet, name string, sources []AltSource) int64 { + if fs.Changed(name) { + val, _ := fs.GetInt64(name) + return val + } + for _, src := range sources { + if v, ok := toInt64(altSourceValue(src)); ok { + return v + } + } + defaultVal, _ := fs.GetInt64(name) + return defaultVal +} + +// resolveBoolFlag resolves a bool flag from the CLI or its alternative sources. An explicit +// --flag=false on the command line is honored (fs.Changed); omitting the flag consults the +// alternative sources, matching urfave/cli behavior for cross-framework parity. Falls back +// to the bound default when no source yields a usable result. +func resolveBoolFlag(fs *pflag.FlagSet, name string, sources []AltSource) bool { + if fs.Changed(name) { + val, _ := fs.GetBool(name) + return val + } + for _, src := range sources { + if v, ok := toBool(altSourceValue(src)); ok { + return v + } + } + defaultVal, _ := fs.GetBool(name) + return defaultVal +} + +// resolveFloat64Flag resolves a float64 flag from the CLI or its alternative sources, falling +// back to the bound default when no source yields a usable result. +func resolveFloat64Flag(fs *pflag.FlagSet, name string, sources []AltSource) float64 { + if fs.Changed(name) { + val, _ := fs.GetFloat64(name) + return val + } + for _, src := range sources { + if v, ok := toFloat64(altSourceValue(src)); ok { + return v + } + } + defaultVal, _ := fs.GetFloat64(name) + return defaultVal +} + +// resolveSliceFlag resolves a variadic flag from the CLI or its alternative sources. When +// the flag was not set on the command line it returns the first source that yields at least +// one value coercible to T; otherwise the bound default (an empty slice if none was declared). +// get is the bound pflag getter for the concrete element type (e.g. fs.GetStringArray). +func resolveSliceFlag[T any](fs *pflag.FlagSet, name string, sources []AltSource, get func(string) ([]T, error), coerce func(any) (T, bool)) []T { + if fs.Changed(name) { + val, _ := get(name) + return val + } + for _, src := range sources { + items := altSourceItems(src) + result := make([]T, 0, len(items)) + for _, item := range items { + if v, ok := coerce(item); ok { + result = append(result, v) + } + } + if len(result) > 0 { + return result + } + } + defaultVal, _ := get(name) + return defaultVal +} + +// resolveStringSliceFlag resolves a string slice flag from the CLI or its alternative sources. +func resolveStringSliceFlag(fs *pflag.FlagSet, name string, sources []AltSource) []string { + return resolveSliceFlag(fs, name, sources, fs.GetStringArray, toString) +} + +// resolveInt64SliceFlag resolves an int64 slice flag from the CLI or its alternative sources. +func resolveInt64SliceFlag(fs *pflag.FlagSet, name string, sources []AltSource) []int64 { + return resolveSliceFlag(fs, name, sources, fs.GetInt64Slice, toInt64) +} + +// resolveBoolSliceFlag resolves a bool slice flag from the CLI or its alternative sources. +func resolveBoolSliceFlag(fs *pflag.FlagSet, name string, sources []AltSource) []bool { + return resolveSliceFlag(fs, name, sources, fs.GetBoolSlice, toBool) +} + +// resolveFloat64SliceFlag resolves a float64 slice flag from the CLI or its alternative sources. +func resolveFloat64SliceFlag(fs *pflag.FlagSet, name string, sources []AltSource) []float64 { + return resolveSliceFlag(fs, name, sources, fs.GetFloat64Slice, toFloat64) +} diff --git a/gen/testdata/cobra/gencli/run.go b/gen/testdata/cobra/gencli/run.gen.go similarity index 88% rename from gen/testdata/cobra/gencli/run.go rename to gen/testdata/cobra/gencli/run.gen.go index ba59c4d..cfb42a9 100644 --- a/gen/testdata/cobra/gencli/run.go +++ b/gen/testdata/cobra/gencli/run.gen.go @@ -9,6 +9,8 @@ import ( // Run executes the root cobra command and returns an exit code. func Run(ctx context.Context, actions ActionsInterface) int { + // Load config file from disk (JSON/YAML/TOML) for $FILE alternative sources + loadConfig() // Instantiate root command rootCmd := NewCmdPetstore(actions) // Add version for `--version` flag diff --git a/gen/testdata/cobra/globalflags/gencli/actions.gen.go b/gen/testdata/cobra/globalflags/gencli/actions.gen.go new file mode 100644 index 0000000..95c8402 --- /dev/null +++ b/gen/testdata/cobra/globalflags/gencli/actions.gen.go @@ -0,0 +1,20 @@ +// Code generated by github.com/bcdxn/opencli@(devel) DO NOT EDIT. +package gencli + +import ( + "context" + + "github.com/bcdxn/opencli/spec" +) + +// ActionsInterface defines all actions the gflag CLI supports. +type ActionsInterface interface { + GflagPing(ctx context.Context) error + GflagEcho(ctx context.Context, args GflagEchoArgs) error + GflagGreet(ctx context.Context, flags GflagGreetFlags) error + GflagSend(ctx context.Context, args GflagSendArgs, flags GflagSendFlags) error + HelpFunc(cmd *spec.CommandItem) + UsageFunc(cmd *spec.CommandItem) error + IOStreams() IOStreams + Version() string +} diff --git a/gen/testdata/cobra/globalflags/gencli/cmd_gflag.gen.go b/gen/testdata/cobra/globalflags/gencli/cmd_gflag.gen.go new file mode 100644 index 0000000..cc2a295 --- /dev/null +++ b/gen/testdata/cobra/globalflags/gencli/cmd_gflag.gen.go @@ -0,0 +1,62 @@ +// Code generated by github.com/bcdxn/opencli@(devel) DO NOT EDIT. +package gencli + +import ( + "github.com/bcdxn/opencli/spec" + "github.com/spf13/cobra" +) + +func NewCmdGflag(a ActionsInterface) *cobra.Command { + command := &cobra.Command{ + Use: "gflag", + Short: "A minimal spec that declares real (non-help/version) global flags to exercise the context/accessor codegen paths for every framework.", + Long: "`gflag` is a tiny fixture whose only purpose is to declare genuine global flags so\ngenerated output exercises the `GlobalFlagsFromContext` / `setGlobalFlags` /\n`getGlobalFlags` helpers and their call sites in each command handler.\n\nIt also declares one leaf per return-branch shape (args+flags, args-only,\nflags-only, neither) so all four action-call forms appear in the goldens.\n", + Args: cobra.NoArgs, + RunE: func(c *cobra.Command, args []string) error { + return BadUserInput("subcommand is required", func() error { + return a.UsageFunc(getSpecGflagCmd()) + }) + }, + } + command.SilenceErrors = true + command.SilenceUsage = true + command.AddCommand(NewCmdGflagPing(a)) + command.AddCommand(NewCmdGflagEcho(a)) + command.AddCommand(NewCmdGflagGreet(a)) + command.AddCommand(NewCmdGflagSend(a)) + command.SetHelpFunc(func(_ *cobra.Command, _ []string) { + a.HelpFunc(getSpecGflagCmd()) + }) + command.SetUsageFunc(func(_ *cobra.Command) error { + return a.UsageFunc(getSpecGflagCmd()) + }) + + return command +} + +func getSpecGflagCmd() *spec.CommandItem { + return &spec.CommandItem{ + Segment: "gflag", + CommandLine: "gflag", + Summary: "A minimal spec that declares real (non-help/version) global flags to exercise the context/accessor codegen paths for every framework.", + Description: "`gflag` is a tiny fixture whose only purpose is to declare genuine global flags so\ngenerated output exercises the `GlobalFlagsFromContext` / `setGlobalFlags` /\n`getGlobalFlags` helpers and their call sites in each command handler.\n\nIt also declares one leaf per return-branch shape (args+flags, args-only,\nflags-only, neither) so all four action-call forms appear in the goldens.\n", + VisibleChildren: true, + VisibleArgs: false, + VisibleFlags: false, + CommandModifiers: []string{ + "{command}", + }, + ArgsModifiers: []string{ + "", + }, + FlagsModifiers: []string{ + "[flags]", + }, + Commands: []*spec.CommandItem{ + {Segment: "ping", Summary: "Report that the CLI is alive"}, + {Segment: "echo", Summary: "Echo a single argument back"}, + {Segment: "greet", Summary: "Greet someone by name"}, + {Segment: "send", Summary: "Send a message the given number of times"}, + }, + } +} diff --git a/gen/testdata/cobra/globalflags/gencli/cmd_gflag_echo.gen.go b/gen/testdata/cobra/globalflags/gencli/cmd_gflag_echo.gen.go new file mode 100644 index 0000000..17b8527 --- /dev/null +++ b/gen/testdata/cobra/globalflags/gencli/cmd_gflag_echo.gen.go @@ -0,0 +1,56 @@ +// Code generated by github.com/bcdxn/opencli@(devel) DO NOT EDIT. +package gencli + +import ( + "github.com/bcdxn/opencli/spec" + "github.com/spf13/cobra" +) + +func NewCmdGflagEcho(a ActionsInterface) *cobra.Command { + command := &cobra.Command{ + Use: "echo", + Short: "Echo a single argument back", + Long: "", + RunE: func(c *cobra.Command, args []string) error { + cmdArgs := GflagEchoArgs{} + if len(args) > 0 { + cmdArgs.Text = args[0] + } + ctx := c.Context() + ctx = WithGlobalFlags(ctx, GlobalFlags{ + Debug: flagDebug, + Timeout: flagTimeout, + OutputFormat: flagOutputFormat, + }) + return a.GflagEcho(ctx, cmdArgs) + }, + } + command.SilenceErrors = true + command.SilenceUsage = true + command.SetHelpFunc(func(_ *cobra.Command, _ []string) { + a.HelpFunc(getSpecGflagEchoCmd()) + }) + command.SetUsageFunc(func(_ *cobra.Command) error { + return a.UsageFunc(getSpecGflagEchoCmd()) + }) + + return command +} + +func getSpecGflagEchoCmd() *spec.CommandItem { + return &spec.CommandItem{ + Segment: "echo", + CommandLine: "gflag echo", + Summary: "Echo a single argument back", + Description: "", + VisibleChildren: false, + VisibleArgs: true, + VisibleFlags: false, + ArgsModifiers: []string{ + "", + }, + Args: []spec.ArgumentItem{ + {Name: "text", Summary: "the text to echo"}, + }, + } +} diff --git a/gen/testdata/cobra/globalflags/gencli/cmd_gflag_greet.gen.go b/gen/testdata/cobra/globalflags/gencli/cmd_gflag_greet.gen.go new file mode 100644 index 0000000..c1b26bc --- /dev/null +++ b/gen/testdata/cobra/globalflags/gencli/cmd_gflag_greet.gen.go @@ -0,0 +1,57 @@ +// Code generated by github.com/bcdxn/opencli@(devel) DO NOT EDIT. +package gencli + +import ( + "github.com/bcdxn/opencli/spec" + "github.com/spf13/cobra" +) + +func NewCmdGflagGreet(a ActionsInterface) *cobra.Command { + var flagName string + command := &cobra.Command{ + Use: "greet", + Short: "Greet someone by name", + Long: "", + RunE: func(c *cobra.Command, args []string) error { + cmdFlags := GflagGreetFlags{ + Name: flagName, + } + ctx := c.Context() + ctx = WithGlobalFlags(ctx, GlobalFlags{ + Debug: flagDebug, + Timeout: flagTimeout, + OutputFormat: flagOutputFormat, + }) + return a.GflagGreet(ctx, cmdFlags) + }, + } + command.SilenceErrors = true + command.SilenceUsage = true + command.Flags().StringVarP(&flagName, "name", "", "", "who to greet") + command.SetHelpFunc(func(_ *cobra.Command, _ []string) { + a.HelpFunc(getSpecGflagGreetCmd()) + }) + command.SetUsageFunc(func(_ *cobra.Command) error { + return a.UsageFunc(getSpecGflagGreetCmd()) + }) + + return command +} + +func getSpecGflagGreetCmd() *spec.CommandItem { + return &spec.CommandItem{ + Segment: "greet", + CommandLine: "gflag greet", + Summary: "Greet someone by name", + Description: "", + VisibleChildren: false, + VisibleArgs: false, + VisibleFlags: true, + FlagsModifiers: []string{ + "[flags]", + }, + Flags: []spec.FlagItem{ + {Name: "name", Summary: "who to greet"}, + }, + } +} diff --git a/gen/testdata/cobra/globalflags/gencli/cmd_gflag_ping.gen.go b/gen/testdata/cobra/globalflags/gencli/cmd_gflag_ping.gen.go new file mode 100644 index 0000000..c141ddc --- /dev/null +++ b/gen/testdata/cobra/globalflags/gencli/cmd_gflag_ping.gen.go @@ -0,0 +1,46 @@ +// Code generated by github.com/bcdxn/opencli@(devel) DO NOT EDIT. +package gencli + +import ( + "github.com/bcdxn/opencli/spec" + "github.com/spf13/cobra" +) + +func NewCmdGflagPing(a ActionsInterface) *cobra.Command { + command := &cobra.Command{ + Use: "ping", + Short: "Report that the CLI is alive", + Long: "", + RunE: func(c *cobra.Command, args []string) error { + ctx := c.Context() + ctx = WithGlobalFlags(ctx, GlobalFlags{ + Debug: flagDebug, + Timeout: flagTimeout, + OutputFormat: flagOutputFormat, + }) + return a.GflagPing(ctx) + }, + } + command.SilenceErrors = true + command.SilenceUsage = true + command.SetHelpFunc(func(_ *cobra.Command, _ []string) { + a.HelpFunc(getSpecGflagPingCmd()) + }) + command.SetUsageFunc(func(_ *cobra.Command) error { + return a.UsageFunc(getSpecGflagPingCmd()) + }) + + return command +} + +func getSpecGflagPingCmd() *spec.CommandItem { + return &spec.CommandItem{ + Segment: "ping", + CommandLine: "gflag ping", + Summary: "Report that the CLI is alive", + Description: "", + VisibleChildren: false, + VisibleArgs: false, + VisibleFlags: false, + } +} diff --git a/gen/testdata/cobra/globalflags/gencli/cmd_gflag_send.gen.go b/gen/testdata/cobra/globalflags/gencli/cmd_gflag_send.gen.go new file mode 100644 index 0000000..f6eed3e --- /dev/null +++ b/gen/testdata/cobra/globalflags/gencli/cmd_gflag_send.gen.go @@ -0,0 +1,67 @@ +// Code generated by github.com/bcdxn/opencli@(devel) DO NOT EDIT. +package gencli + +import ( + "github.com/bcdxn/opencli/spec" + "github.com/spf13/cobra" +) + +func NewCmdGflagSend(a ActionsInterface) *cobra.Command { + var flagCount int64 + command := &cobra.Command{ + Use: "send", + Short: "Send a message the given number of times", + Long: "", + RunE: func(c *cobra.Command, args []string) error { + cmdArgs := GflagSendArgs{} + if len(args) > 0 { + cmdArgs.Recipient = args[0] + } + cmdFlags := GflagSendFlags{ + Count: flagCount, + } + ctx := c.Context() + ctx = WithGlobalFlags(ctx, GlobalFlags{ + Debug: flagDebug, + Timeout: flagTimeout, + OutputFormat: flagOutputFormat, + }) + return a.GflagSend(ctx, cmdArgs, cmdFlags) + }, + } + command.SilenceErrors = true + command.SilenceUsage = true + command.Flags().Int64VarP(&flagCount, "count", "", 1, "how many times to send") + command.SetHelpFunc(func(_ *cobra.Command, _ []string) { + a.HelpFunc(getSpecGflagSendCmd()) + }) + command.SetUsageFunc(func(_ *cobra.Command) error { + return a.UsageFunc(getSpecGflagSendCmd()) + }) + + return command +} + +func getSpecGflagSendCmd() *spec.CommandItem { + return &spec.CommandItem{ + Segment: "send", + CommandLine: "gflag send", + Summary: "Send a message the given number of times", + Description: "", + VisibleChildren: false, + VisibleArgs: true, + VisibleFlags: true, + ArgsModifiers: []string{ + "", + }, + FlagsModifiers: []string{ + "[flags]", + }, + Args: []spec.ArgumentItem{ + {Name: "recipient", Summary: "who to send to"}, + }, + Flags: []spec.FlagItem{ + {Name: "count", Summary: "how many times to send"}, + }, + } +} diff --git a/gen/testdata/cobra/globalflags/gencli/errors.gen.go b/gen/testdata/cobra/globalflags/gencli/errors.gen.go new file mode 100644 index 0000000..de48bf2 --- /dev/null +++ b/gen/testdata/cobra/globalflags/gencli/errors.gen.go @@ -0,0 +1,54 @@ +// Code generated by github.com/bcdxn/opencli@(devel) DO NOT EDIT. +package gencli + +const ( + ExitCodeOK int = 0 + ExitCodeInternalErr int = 1 + ExitCodeBadUserInputErr int = 2 +) + +// UsageFunc is a function that renders the usage for a command. +type UsageFunc func() error + +// CLIError represents a CLI error with an exit code and an optional usage function. +type CLIError struct { + Code int + Message string + UsageFunc UsageFunc +} + +func (err CLIError) Error() string { + return err.Message +} + +// ValidationError represents an error that occurred during action validation. +type ValidationError struct { + Message string +} + +func (err ValidationError) Error() string { + return err.Message +} + +// NewValidationError creates a new ValidationError. +func NewValidationError(message string) *ValidationError { + return &ValidationError{Message: message} +} + +// InternalError creates a new CLIError with the internal error exit code. +func InternalError(message string, usageFn UsageFunc) *CLIError { + return &CLIError{ + Code: ExitCodeInternalErr, + Message: message, + UsageFunc: usageFn, + } +} + +// BadUserInput creates a new CLIError with the bad user input exit code. +func BadUserInput(message string, usageFn UsageFunc) *CLIError { + return &CLIError{ + Code: ExitCodeBadUserInputErr, + Message: message, + UsageFunc: usageFn, + } +} diff --git a/examples/code/gencli/help.gen.go b/gen/testdata/cobra/globalflags/gencli/help.gen.go similarity index 99% rename from examples/code/gencli/help.gen.go rename to gen/testdata/cobra/globalflags/gencli/help.gen.go index bad3fc3..c1de5f7 100644 --- a/examples/code/gencli/help.gen.go +++ b/gen/testdata/cobra/globalflags/gencli/help.gen.go @@ -1,4 +1,4 @@ -// Code generated by github.com/bcdxn/opencli@unknown version DO NOT EDIT. +// Code generated by github.com/bcdxn/opencli@(devel) DO NOT EDIT. package gencli import ( diff --git a/gen/testdata/cobra/globalflags/gencli/iostreams.gen.go b/gen/testdata/cobra/globalflags/gencli/iostreams.gen.go new file mode 100644 index 0000000..3d8c122 --- /dev/null +++ b/gen/testdata/cobra/globalflags/gencli/iostreams.gen.go @@ -0,0 +1,197 @@ +// Code generated by github.com/bcdxn/opencli@(devel) DO NOT EDIT. +package gencli + +import ( + "bytes" + "io" + "os" + "strings" + + "charm.land/lipgloss/v2" + "golang.org/x/term" +) + +// IOStreams provides access to the standard I/O streams. +type IOStreams interface { + In() FileReader + Out() FileWriter + ErrOut() FileWriter + TerminalTheme() string + TerminalSize() (int, int, error) +} + +// FileWriter is an io.Writer that exposes the underlying file descriptor. +type FileWriter interface { + io.Writer + Fd() uintptr +} + +// FileReader is an io.ReadCloser that exposes the underlying file descriptor. +type FileReader interface { + io.ReadCloser + Fd() uintptr +} + +// DefaultIOStreams is the default implementation of IOStreams backed by os.Stdin/Stdout/Stderr. +type DefaultIOStreams struct { + in FileReader + out FileWriter + errOut FileWriter + term Terminal +} + +func (ios DefaultIOStreams) In() FileReader { + return ios.in +} + +func (ios DefaultIOStreams) Out() FileWriter { + return ios.out +} + +func (ios DefaultIOStreams) ErrOut() FileWriter { + return ios.errOut +} + +func (ios DefaultIOStreams) TerminalTheme() string { + return ios.term.Theme() +} + +func (ios DefaultIOStreams) TerminalSize() (int, int, error) { + return ios.term.Size() +} + +// DefaultIOS returns an IOStreams backed by the real os.Stdin/Stdout/Stderr. +func DefaultIOS() IOStreams { + return DefaultIOStreams{ + in: os.Stdin, + out: os.Stdout, + errOut: os.Stderr, + term: &DefaultTerminal{ + in: os.Stdin, + out: os.Stdout, + errOut: os.Stderr, + isTTY: isTerminal(os.Stdout), + is256enabled: is256ColorSupported(), + hasTrueColor: isTrueColorSupported(), + }, + } +} + +// Terminal provides terminal capability detection. +type Terminal interface { + IsTerminalOutput() bool + Is256ColorSupported() bool + IsTrueColorSupported() bool + Theme() string + Size() (int, int, error) +} + +// DefaultTerminal is the default Terminal implementation backed by the real OS terminal. +type DefaultTerminal struct { + in *os.File + out *os.File + errOut *os.File + isTTY bool + colorEnabled bool + is256enabled bool + hasTrueColor bool + width int +} + +func (t *DefaultTerminal) IsTerminalOutput() bool { + return t.isTTY +} + +func (t *DefaultTerminal) Is256ColorSupported() bool { + return t.is256enabled +} + +func (t *DefaultTerminal) IsTrueColorSupported() bool { + return t.hasTrueColor +} + +func (t *DefaultTerminal) Theme() string { + if lipgloss.HasDarkBackground(t.in, t.out) { + return "dark" + } + return "light" +} + +func (t *DefaultTerminal) Size() (int, int, error) { + ttyOut := t.out + if ttyOut == nil || !isTerminal(ttyOut) { + if f, err := openTTY(); err == nil { + defer f.Close() + ttyOut = f + } else { + return 80, 100, nil + } + } + return terminalSize(ttyOut) +} + +// TestIOS returns an IOStreams backed by bytes.Buffer for use in tests. +func TestIOS() (IOStreams, *bytes.Buffer, *bytes.Buffer, *bytes.Buffer) { + in := &bytes.Buffer{} + out := &bytes.Buffer{} + errOut := &bytes.Buffer{} + + ios := DefaultIOStreams{ + in: &fdReader{ + fd: 0, + ReadCloser: io.NopCloser(in), + }, + out: &fdWriter{fd: 1, Writer: out}, + errOut: &fdWriter{fd: 2, Writer: errOut}, + } + + return ios, in, out, errOut +} + +// fdWriter wraps an io.Writer and preserves the original file descriptor. +type fdWriter struct { + io.Writer + fd uintptr +} + +func (w *fdWriter) Fd() uintptr { + return w.fd +} + +// fdReader wraps an io.ReadCloser and preserves the original file descriptor. +type fdReader struct { + io.ReadCloser + fd uintptr +} + +func (r *fdReader) Fd() uintptr { + return r.fd +} + +func isTerminal(f *os.File) bool { + return term.IsTerminal(int(f.Fd())) +} + +func is256ColorSupported() bool { + return isTrueColorSupported() || + strings.Contains(os.Getenv("TERM"), "256") || + strings.Contains(os.Getenv("COLORTERM"), "256") +} + +func isTrueColorSupported() bool { + t := os.Getenv("TERM") + colorterm := os.Getenv("COLORTERM") + + return strings.Contains(t, "24bit") || + strings.Contains(t, "truecolor") || + strings.Contains(colorterm, "24bit") || + strings.Contains(colorterm, "truecolor") +} + +func openTTY() (*os.File, error) { + return os.Open("/dev/tty") +} + +func terminalSize(f *os.File) (int, int, error) { + return term.GetSize(int(f.Fd())) +} diff --git a/gen/testdata/cobra/globalflags/gencli/params.gen.go b/gen/testdata/cobra/globalflags/gencli/params.gen.go new file mode 100644 index 0000000..adada0d --- /dev/null +++ b/gen/testdata/cobra/globalflags/gencli/params.gen.go @@ -0,0 +1,47 @@ +// Code generated by github.com/bcdxn/opencli@(devel) DO NOT EDIT. +package gencli + +import "context" + +// GflagEchoArgs holds the positional arguments for the GflagEcho action. +type GflagEchoArgs struct { + Text string +} + +// GflagGreetFlags holds the flag values for the GflagGreet action. +type GflagGreetFlags struct { + Name string +} + +// GflagSendArgs holds the positional arguments for the GflagSend action. +type GflagSendArgs struct { + Recipient string +} + +// GflagSendFlags holds the flag values for the GflagSend action. +type GflagSendFlags struct { + Count int64 +} + +// GlobalFlags holds the global (root-level) flag values shared by every action. +type GlobalFlags struct { + Debug bool + Timeout int64 + OutputFormat string +} + +// globalFlagsKey is an unexported context key so only this package can set or read the value. +type globalFlagsKey struct{} + +// WithGlobalFlags returns a copy of ctx carrying the given GlobalFlags for actions to retrieve via GlobalFlagsFromContext. +func WithGlobalFlags(ctx context.Context, g GlobalFlags) context.Context { + return context.WithValue(ctx, globalFlagsKey{}, g) +} + +// GlobalFlagsFromContext retrieves the GlobalFlags injected by the generated command handler; it returns zero values if none were set. +func GlobalFlagsFromContext(ctx context.Context) GlobalFlags { + if v, ok := ctx.Value(globalFlagsKey{}).(GlobalFlags); ok { + return v + } + return GlobalFlags{} +} diff --git a/gen/testdata/cobra/globalflags/gencli/run.gen.go b/gen/testdata/cobra/globalflags/gencli/run.gen.go new file mode 100644 index 0000000..378bb7c --- /dev/null +++ b/gen/testdata/cobra/globalflags/gencli/run.gen.go @@ -0,0 +1,39 @@ +// Code generated by github.com/bcdxn/opencli@(devel) DO NOT EDIT. +package gencli + +import ( + "context" + "errors" + "fmt" +) + +var flagDebug bool // global flag, bound to the root command's persistent flags in Run() +var flagTimeout int64 // global flag, bound to the root command's persistent flags in Run() +var flagOutputFormat string // global flag, bound to the root command's persistent flags in Run() +// Run executes the root cobra command and returns an exit code. +func Run(ctx context.Context, actions ActionsInterface) int { + // Instantiate root command + rootCmd := NewCmdGflag(actions) + // Add version for `--version` flag + rootCmd.Version = actions.Version() + // Global Flags + rootCmd.PersistentFlags().BoolVarP(&flagDebug, "debug", "v", false, "enable verbose debug output") + // Global Flags + rootCmd.PersistentFlags().Int64VarP(&flagTimeout, "timeout", "", 30, "request timeout in seconds") + // Global Flags + rootCmd.PersistentFlags().StringVarP(&flagOutputFormat, "output-format", "", "text", "preferred output format") + + // Run the CLI + if _, err := rootCmd.ExecuteContextC(ctx); err != nil { + fmt.Fprintf(actions.IOStreams().Out(), "error: %v\n\n", err.Error()) + + if cliErr, ok := errors.AsType[*CLIError](err); ok { + cliErr.UsageFunc() + return cliErr.Code + } + + return ExitCodeInternalErr + } + + return ExitCodeOK +} diff --git a/gen/testdata/globalflags-cli.ocs.yaml b/gen/testdata/globalflags-cli.ocs.yaml new file mode 100644 index 0000000..6806b2b --- /dev/null +++ b/gen/testdata/globalflags-cli.ocs.yaml @@ -0,0 +1,86 @@ +opencliVersion: 1.0.0-alpha.13 + +info: + title: Global Flags CLI + summary: A minimal spec that declares real (non-help/version) global flags to exercise the context/accessor codegen paths for every framework. + description: | + `gflag` is a tiny fixture whose only purpose is to declare genuine global flags so + generated output exercises the `GlobalFlagsFromContext` / `setGlobalFlags` / + `getGlobalFlags` helpers and their call sites in each command handler. + + It also declares one leaf per return-branch shape (args+flags, args-only, + flags-only, neither) so all four action-call forms appear in the goldens. + version: 0.1.0 + binary: gflag + +global: + exitCodes: + - code: 0 + status: OK + summary: The command was successful + - code: 2 + status: BAD_USER_INPUT_ERROR + summary: Missing or invalid input + config: {} + flags: + # help/version are filtered out by codegen and must NOT appear in GlobalFlags. + - name: "help" + aliases: + - "h" + type: boolean + summary: contextual help + - name: "version" + type: boolean + summary: print the version of the CLI + # The real global flags below DO appear in GlobalFlags and are injected into + # context (Go) / set via setGlobalFlags (Yargs). + - name: "debug" + aliases: + - "v" + type: boolean + summary: enable verbose debug output + - name: "timeout" + type: integer + default: 30 + summary: request timeout in seconds + - name: "output-format" + type: string + default: text + summary: preferred output format + +commands: + gflag {command} [flags]: + kind: group + + # Neither args nor flags -> action called with no parameters. + gflag ping [flags]: + summary: Report that the CLI is alive + + # Args only -> action called with just cmdArgs. + gflag echo [flags]: + summary: Echo a single argument back + args: + - name: text + type: string + summary: the text to echo + + # Flags only -> action called with just cmdFlags. + gflag greet --name [flags]: + summary: Greet someone by name + flags: + - name: "name" + type: string + summary: who to greet + + # Args + flags -> action called with both cmdArgs and cmdFlags. + gflag send --count [flags]: + summary: Send a message the given number of times + args: + - name: recipient + type: string + summary: who to send to + flags: + - name: "count" + type: integer + default: 1 + summary: how many times to send diff --git a/gen/testdata/urfavecli/gencli/cmd_petstore_user_login.gen.go b/gen/testdata/urfavecli/gencli/cmd_petstore_user_login.gen.go index c331ebc..3aa6943 100644 --- a/gen/testdata/urfavecli/gencli/cmd_petstore_user_login.gen.go +++ b/gen/testdata/urfavecli/gencli/cmd_petstore_user_login.gen.go @@ -15,8 +15,8 @@ func NewCmdPetstoreUserLogin(a ActionsInterface) *cli.Command { Metadata: map[string]any{"spec_cmd": getSpecPetstoreUserLoginCmd()}, Action: func(ctx context.Context, c *cli.Command) error { cmdFlags := PetstoreUserLoginFlags{ - Username: c.String("username"), - Password: c.String("password"), + Username: resolveStringFlag(c.IsSet("username"), c.String("username"), []AltSource{{Type: "$ENV", Property: "PETSTORE_USER"}, {Type: "$FILE", Property: "$.auth.user"}}), + Password: resolveStringFlag(c.IsSet("password"), c.String("password"), []AltSource{{Type: "$ENV", Property: "PETSTORE_PASS"}, {Type: "$FILE", Property: "$.auth.pass"}}), } return a.PetstoreUserLogin(ctx, cmdFlags) }, diff --git a/gen/testdata/urfavecli/gencli/config.gen.go b/gen/testdata/urfavecli/gencli/config.gen.go new file mode 100644 index 0000000..7e74d41 --- /dev/null +++ b/gen/testdata/urfavecli/gencli/config.gen.go @@ -0,0 +1,297 @@ +// Code generated by github.com/bcdxn/opencli@(devel) DO NOT EDIT. +package gencli + +import ( + "encoding/json" + "github.com/BurntSushi/toml" + "github.com/ohler55/ojg/jp" + "gopkg.in/yaml.v3" + "math" + "os" + "path/filepath" + "strconv" + "strings" +) + +// AltSource is a single alternative source for a flag value: either an +// environment variable ($ENV) or a property in the config file ($FILE). +type AltSource struct { + Type string // "$ENV" or "$FILE" + Property string // env var name, or JSONPath into the config file +} + +// globalConfig holds the parsed config file data as a nested map. +var globalConfig map[string]any + +// expandTilde expands a leading tilde (~) in the path to the user's home directory. +func expandTilde(path string) string { + if !strings.HasPrefix(path, "~") { + return path + } + home, err := os.UserHomeDir() + if err != nil { + return path + } + if path == "~" { + return home + } + return filepath.Join(home, path[1:]) +} + +// loadConfig loads the config file from the first available path (JSON > YAML > TOML). +func loadConfig() { + if globalConfig != nil { + return + } + globalConfig = make(map[string]any) + // Try JSON config first + if data, err := os.ReadFile(expandTilde("~/.petstore/config.json")); err == nil { + var cfg map[string]any + if err := json.Unmarshal(data, &cfg); err == nil { + globalConfig = cfg + return + } + } + // Try YAML config + if data, err := os.ReadFile(expandTilde("~/.petstore/config.yaml")); err == nil { + var cfg map[string]any + if err := yaml.Unmarshal(data, &cfg); err == nil { + globalConfig = cfg + return + } + } + // Try TOML config + if data, err := os.ReadFile(expandTilde("~/.petstore/config.toml")); err == nil { + var cfg map[string]any + if _, err := toml.Decode(string(data), &cfg); err == nil { + globalConfig = cfg + return + } + } +} + +// resolveJSONPath resolves a JSONPath expression against the global config +// using the ohler55/ojg JSONPath implementation. It returns the value at the +// path, or nil if the path does not match. +func resolveJSONPath(expr string) any { + if globalConfig == nil { + return nil + } + p, err := jp.ParseString(expr) + if err != nil { + return nil + } + return p.First(globalConfig) +} + +// altSourceValue returns the raw value from a single alternative source, or nil +// if the source yields nothing. $ENV sources yield the environment variable's +// string value; $FILE sources yield the value at the given JSONPath. +func altSourceValue(src AltSource) any { + switch src.Type { + case "$ENV": + if val := os.Getenv(src.Property); val != "" { + return val + } + case "$FILE": + if val := resolveJSONPath(src.Property); val != nil { + return val + } + } + return nil +} + +// altSourceItems returns the raw element values from a single alternative +// source for a variadic flag. $ENV sources are comma-separated; $FILE sources +// are arrays (a single value is treated as a one-element list). +func altSourceItems(src AltSource) []any { + switch src.Type { + case "$ENV": + variable := os.Getenv(src.Property) + if variable == "" { + return nil + } + parts := strings.Split(variable, ",") + items := make([]any, 0, len(parts)) + for _, p := range parts { + if trimmed := strings.TrimSpace(p); trimmed != "" { + items = append(items, trimmed) + } + } + return items + case "$FILE": + switch v := resolveJSONPath(src.Property).(type) { + case []any: + return v + case nil: + return nil + default: + return []any{v} + } + } + return nil +} + +// Coercion helpers convert a raw source value to a concrete Go type. Each +// returns ok=false when the value cannot be interpreted as the target type. + +func toString(v any) (string, bool) { + if s, ok := v.(string); ok && s != "" { + return s, true + } + return "", false +} + +func toInt64(v any) (int64, bool) { + switch n := v.(type) { + case int64: + return n, true + case float64: + if n != math.Trunc(n) || n < math.MinInt64 || n > math.MaxInt64 { + return 0, false + } + return int64(n), true + case string: + if i, err := strconv.ParseInt(n, 10, 64); err == nil { + return i, true + } + } + return 0, false +} + +func toBool(v any) (bool, bool) { + switch b := v.(type) { + case bool: + return b, true + case string: + if parsed, err := strconv.ParseBool(b); err == nil { + return parsed, true + } + } + return false, false +} + +func toFloat64(v any) (float64, bool) { + switch n := v.(type) { + case float64: + return n, true + case int64: + return float64(n), true + case string: + if parsed, err := strconv.ParseFloat(n, 64); err == nil { + return parsed, true + } + } + return 0, false +} + +/* +Flag Resolver Functions + +These functions are called from generated command code to resolve flag values. +When the flag was provided on the command line (set is true) the CLI value is +used as-is; otherwise the value is resolved from the flag's alternative sources +(environment variables, config file) in the order they are declared, falling back +to cliVal when no source yields a usable result. For an unset flag the caller passes +the bound default via the command accessor (or the zero value if none was declared), +so this fallback honors any default declared in the spec. +*/ + +// resolveStringFlag resolves a string flag from the CLI or its alternative sources, falling +// back to the bound default when no source yields a usable result. +func resolveStringFlag(set bool, cliVal string, sources []AltSource) string { + if set { + return cliVal + } + for _, src := range sources { + if v, ok := toString(altSourceValue(src)); ok { + return v + } + } + return cliVal +} + +// resolveInt64Flag resolves an int64 flag from the CLI or its alternative sources, falling +// back to the bound default when no source yields a usable result. +func resolveInt64Flag(set bool, cliVal int64, sources []AltSource) int64 { + if set { + return cliVal + } + for _, src := range sources { + if v, ok := toInt64(altSourceValue(src)); ok { + return v + } + } + return cliVal +} + +// resolveBoolFlag resolves a bool flag from the CLI or its alternative sources, falling back +// to the bound default when no source yields a usable result. +func resolveBoolFlag(set bool, cliVal bool, sources []AltSource) bool { + if set { + return cliVal + } + for _, src := range sources { + if v, ok := toBool(altSourceValue(src)); ok { + return v + } + } + return cliVal +} + +// resolveFloat64Flag resolves a float64 flag from the CLI or its alternative sources, falling +// back to the bound default when no source yields a usable result. +func resolveFloat64Flag(set bool, cliVal float64, sources []AltSource) float64 { + if set { + return cliVal + } + for _, src := range sources { + if v, ok := toFloat64(altSourceValue(src)); ok { + return v + } + } + return cliVal +} + +// resolveSliceFlag resolves a variadic flag from the CLI or its alternative +// sources. When the flag was not set, it returns the first source that yields +// at least one value coercible to T; otherwise the bound default (an empty slice +// if none was declared). +func resolveSliceFlag[T any](set bool, cliVal []T, sources []AltSource, coerce func(any) (T, bool)) []T { + if set { + return cliVal + } + for _, src := range sources { + items := altSourceItems(src) + result := make([]T, 0, len(items)) + for _, item := range items { + if v, ok := coerce(item); ok { + result = append(result, v) + } + } + if len(result) > 0 { + return result + } + } + return cliVal +} + +// resolveStringSliceFlag resolves a string slice flag from the CLI or its alternative sources. +func resolveStringSliceFlag(set bool, cliVal []string, sources []AltSource) []string { + return resolveSliceFlag(set, cliVal, sources, toString) +} + +// resolveInt64SliceFlag resolves an int64 slice flag from the CLI or its alternative sources. +func resolveInt64SliceFlag(set bool, cliVal []int64, sources []AltSource) []int64 { + return resolveSliceFlag(set, cliVal, sources, toInt64) +} + +// resolveBoolSliceFlag resolves a bool slice flag from the CLI or its alternative sources. +func resolveBoolSliceFlag(set bool, cliVal []bool, sources []AltSource) []bool { + return resolveSliceFlag(set, cliVal, sources, toBool) +} + +// resolveFloat64SliceFlag resolves a float64 slice flag from the CLI or its alternative sources. +func resolveFloat64SliceFlag(set bool, cliVal []float64, sources []AltSource) []float64 { + return resolveSliceFlag(set, cliVal, sources, toFloat64) +} diff --git a/gen/testdata/urfavecli/gencli/run.gen.go b/gen/testdata/urfavecli/gencli/run.gen.go index da22829..b970d4a 100644 --- a/gen/testdata/urfavecli/gencli/run.gen.go +++ b/gen/testdata/urfavecli/gencli/run.gen.go @@ -10,6 +10,9 @@ import ( // Run executes the root urfave command and returns an exit code. func Run(ctx context.Context, actions ActionsInterface) int { + // Load config file from disk (JSON/YAML/TOML) for $FILE alternative sources + loadConfig() + // Setup custom help printer with glamour/lipgloss rendering SetupUrfaveHelpPrinter(actions) diff --git a/gen/testdata/urfavecli/globalflags/gencli/actions.gen.go b/gen/testdata/urfavecli/globalflags/gencli/actions.gen.go new file mode 100644 index 0000000..95c8402 --- /dev/null +++ b/gen/testdata/urfavecli/globalflags/gencli/actions.gen.go @@ -0,0 +1,20 @@ +// Code generated by github.com/bcdxn/opencli@(devel) DO NOT EDIT. +package gencli + +import ( + "context" + + "github.com/bcdxn/opencli/spec" +) + +// ActionsInterface defines all actions the gflag CLI supports. +type ActionsInterface interface { + GflagPing(ctx context.Context) error + GflagEcho(ctx context.Context, args GflagEchoArgs) error + GflagGreet(ctx context.Context, flags GflagGreetFlags) error + GflagSend(ctx context.Context, args GflagSendArgs, flags GflagSendFlags) error + HelpFunc(cmd *spec.CommandItem) + UsageFunc(cmd *spec.CommandItem) error + IOStreams() IOStreams + Version() string +} diff --git a/gen/testdata/urfavecli/globalflags/gencli/cmd_gflag.gen.go b/gen/testdata/urfavecli/globalflags/gencli/cmd_gflag.gen.go new file mode 100644 index 0000000..f61af7c --- /dev/null +++ b/gen/testdata/urfavecli/globalflags/gencli/cmd_gflag.gen.go @@ -0,0 +1,55 @@ +// Code generated by github.com/bcdxn/opencli@(devel) DO NOT EDIT. +package gencli + +import ( + "context" + "github.com/bcdxn/opencli/spec" + "github.com/urfave/cli/v3" +) + +func NewCmdGflag(a ActionsInterface) *cli.Command { + cmd := &cli.Command{ + Name: "gflag", + Usage: "A minimal spec that declares real (non-help/version) global flags to exercise the context/accessor codegen paths for every framework.", + Description: "`gflag` is a tiny fixture whose only purpose is to declare genuine global flags so\ngenerated output exercises the `GlobalFlagsFromContext` / `setGlobalFlags` /\n`getGlobalFlags` helpers and their call sites in each command handler.\n\nIt also declares one leaf per return-branch shape (args+flags, args-only,\nflags-only, neither) so all four action-call forms appear in the goldens.\n", + Metadata: map[string]any{"spec_cmd": getSpecGflagCmd()}, + Action: func(ctx context.Context, c *cli.Command) error { + return BadUserInput("subcommand is required", func() error { + return a.UsageFunc(getSpecGflagCmd()) + }) + }, + } + cmd.Commands = append(cmd.Commands, NewCmdGflagPing(a)) + cmd.Commands = append(cmd.Commands, NewCmdGflagEcho(a)) + cmd.Commands = append(cmd.Commands, NewCmdGflagGreet(a)) + cmd.Commands = append(cmd.Commands, NewCmdGflagSend(a)) + + return cmd +} + +func getSpecGflagCmd() *spec.CommandItem { + return &spec.CommandItem{ + Segment: "gflag", + CommandLine: "gflag", + Summary: "A minimal spec that declares real (non-help/version) global flags to exercise the context/accessor codegen paths for every framework.", + Description: "`gflag` is a tiny fixture whose only purpose is to declare genuine global flags so\ngenerated output exercises the `GlobalFlagsFromContext` / `setGlobalFlags` /\n`getGlobalFlags` helpers and their call sites in each command handler.\n\nIt also declares one leaf per return-branch shape (args+flags, args-only,\nflags-only, neither) so all four action-call forms appear in the goldens.\n", + VisibleChildren: true, + VisibleArgs: false, + VisibleFlags: false, + CommandModifiers: []string{ + "{command}", + }, + ArgsModifiers: []string{ + "", + }, + FlagsModifiers: []string{ + "[flags]", + }, + Commands: []*spec.CommandItem{ + {Segment: "ping", Summary: "Report that the CLI is alive"}, + {Segment: "echo", Summary: "Echo a single argument back"}, + {Segment: "greet", Summary: "Greet someone by name"}, + {Segment: "send", Summary: "Send a message the given number of times"}, + }, + } +} diff --git a/gen/testdata/urfavecli/globalflags/gencli/cmd_gflag_echo.gen.go b/gen/testdata/urfavecli/globalflags/gencli/cmd_gflag_echo.gen.go new file mode 100644 index 0000000..9e5402e --- /dev/null +++ b/gen/testdata/urfavecli/globalflags/gencli/cmd_gflag_echo.gen.go @@ -0,0 +1,49 @@ +// Code generated by github.com/bcdxn/opencli@(devel) DO NOT EDIT. +package gencli + +import ( + "context" + "github.com/bcdxn/opencli/spec" + "github.com/urfave/cli/v3" +) + +func NewCmdGflagEcho(a ActionsInterface) *cli.Command { + cmd := &cli.Command{ + Name: "echo", + Usage: "Echo a single argument back", + Description: "", + Metadata: map[string]any{"spec_cmd": getSpecGflagEchoCmd()}, + Action: func(ctx context.Context, c *cli.Command) error { + cmdArgs := GflagEchoArgs{} + if len(c.Args().Slice()) > 0 { + cmdArgs.Text = c.Args().Slice()[0] + } + ctx = WithGlobalFlags(ctx, GlobalFlags{ + Debug: c.Bool("debug"), + Timeout: c.Int64("timeout"), + OutputFormat: c.String("output-format"), + }) + return a.GflagEcho(ctx, cmdArgs) + }, + } + + return cmd +} + +func getSpecGflagEchoCmd() *spec.CommandItem { + return &spec.CommandItem{ + Segment: "echo", + CommandLine: "gflag echo", + Summary: "Echo a single argument back", + Description: "", + VisibleChildren: false, + VisibleArgs: true, + VisibleFlags: false, + ArgsModifiers: []string{ + "", + }, + Args: []spec.ArgumentItem{ + {Name: "text", Summary: "the text to echo"}, + }, + } +} diff --git a/gen/testdata/urfavecli/globalflags/gencli/cmd_gflag_greet.gen.go b/gen/testdata/urfavecli/globalflags/gencli/cmd_gflag_greet.gen.go new file mode 100644 index 0000000..0003715 --- /dev/null +++ b/gen/testdata/urfavecli/globalflags/gencli/cmd_gflag_greet.gen.go @@ -0,0 +1,53 @@ +// Code generated by github.com/bcdxn/opencli@(devel) DO NOT EDIT. +package gencli + +import ( + "context" + "github.com/bcdxn/opencli/spec" + "github.com/urfave/cli/v3" +) + +func NewCmdGflagGreet(a ActionsInterface) *cli.Command { + cmd := &cli.Command{ + Name: "greet", + Usage: "Greet someone by name", + Description: "", + Metadata: map[string]any{"spec_cmd": getSpecGflagGreetCmd()}, + Action: func(ctx context.Context, c *cli.Command) error { + cmdFlags := GflagGreetFlags{ + Name: c.String("name"), + } + ctx = WithGlobalFlags(ctx, GlobalFlags{ + Debug: c.Bool("debug"), + Timeout: c.Int64("timeout"), + OutputFormat: c.String("output-format"), + }) + return a.GflagGreet(ctx, cmdFlags) + }, + } + cmd.Flags = append(cmd.Flags, &cli.StringFlag{ + Name: "name", + Value: "", + Usage: "who to greet", + }) + + return cmd +} + +func getSpecGflagGreetCmd() *spec.CommandItem { + return &spec.CommandItem{ + Segment: "greet", + CommandLine: "gflag greet", + Summary: "Greet someone by name", + Description: "", + VisibleChildren: false, + VisibleArgs: false, + VisibleFlags: true, + FlagsModifiers: []string{ + "[flags]", + }, + Flags: []spec.FlagItem{ + {Name: "name", Summary: "who to greet"}, + }, + } +} diff --git a/gen/testdata/urfavecli/globalflags/gencli/cmd_gflag_ping.gen.go b/gen/testdata/urfavecli/globalflags/gencli/cmd_gflag_ping.gen.go new file mode 100644 index 0000000..a76e392 --- /dev/null +++ b/gen/testdata/urfavecli/globalflags/gencli/cmd_gflag_ping.gen.go @@ -0,0 +1,39 @@ +// Code generated by github.com/bcdxn/opencli@(devel) DO NOT EDIT. +package gencli + +import ( + "context" + "github.com/bcdxn/opencli/spec" + "github.com/urfave/cli/v3" +) + +func NewCmdGflagPing(a ActionsInterface) *cli.Command { + cmd := &cli.Command{ + Name: "ping", + Usage: "Report that the CLI is alive", + Description: "", + Metadata: map[string]any{"spec_cmd": getSpecGflagPingCmd()}, + Action: func(ctx context.Context, c *cli.Command) error { + ctx = WithGlobalFlags(ctx, GlobalFlags{ + Debug: c.Bool("debug"), + Timeout: c.Int64("timeout"), + OutputFormat: c.String("output-format"), + }) + return a.GflagPing(ctx) + }, + } + + return cmd +} + +func getSpecGflagPingCmd() *spec.CommandItem { + return &spec.CommandItem{ + Segment: "ping", + CommandLine: "gflag ping", + Summary: "Report that the CLI is alive", + Description: "", + VisibleChildren: false, + VisibleArgs: false, + VisibleFlags: false, + } +} diff --git a/gen/testdata/urfavecli/globalflags/gencli/cmd_gflag_send.gen.go b/gen/testdata/urfavecli/globalflags/gencli/cmd_gflag_send.gen.go new file mode 100644 index 0000000..cea5106 --- /dev/null +++ b/gen/testdata/urfavecli/globalflags/gencli/cmd_gflag_send.gen.go @@ -0,0 +1,63 @@ +// Code generated by github.com/bcdxn/opencli@(devel) DO NOT EDIT. +package gencli + +import ( + "context" + "github.com/bcdxn/opencli/spec" + "github.com/urfave/cli/v3" +) + +func NewCmdGflagSend(a ActionsInterface) *cli.Command { + cmd := &cli.Command{ + Name: "send", + Usage: "Send a message the given number of times", + Description: "", + Metadata: map[string]any{"spec_cmd": getSpecGflagSendCmd()}, + Action: func(ctx context.Context, c *cli.Command) error { + cmdArgs := GflagSendArgs{} + if len(c.Args().Slice()) > 0 { + cmdArgs.Recipient = c.Args().Slice()[0] + } + cmdFlags := GflagSendFlags{ + Count: c.Int64("count"), + } + ctx = WithGlobalFlags(ctx, GlobalFlags{ + Debug: c.Bool("debug"), + Timeout: c.Int64("timeout"), + OutputFormat: c.String("output-format"), + }) + return a.GflagSend(ctx, cmdArgs, cmdFlags) + }, + } + cmd.Flags = append(cmd.Flags, &cli.Int64Flag{ + Name: "count", + Value: 1, + Usage: "how many times to send", + }) + + return cmd +} + +func getSpecGflagSendCmd() *spec.CommandItem { + return &spec.CommandItem{ + Segment: "send", + CommandLine: "gflag send", + Summary: "Send a message the given number of times", + Description: "", + VisibleChildren: false, + VisibleArgs: true, + VisibleFlags: true, + ArgsModifiers: []string{ + "", + }, + FlagsModifiers: []string{ + "[flags]", + }, + Args: []spec.ArgumentItem{ + {Name: "recipient", Summary: "who to send to"}, + }, + Flags: []spec.FlagItem{ + {Name: "count", Summary: "how many times to send"}, + }, + } +} diff --git a/gen/testdata/urfavecli/globalflags/gencli/errors.gen.go b/gen/testdata/urfavecli/globalflags/gencli/errors.gen.go new file mode 100644 index 0000000..de48bf2 --- /dev/null +++ b/gen/testdata/urfavecli/globalflags/gencli/errors.gen.go @@ -0,0 +1,54 @@ +// Code generated by github.com/bcdxn/opencli@(devel) DO NOT EDIT. +package gencli + +const ( + ExitCodeOK int = 0 + ExitCodeInternalErr int = 1 + ExitCodeBadUserInputErr int = 2 +) + +// UsageFunc is a function that renders the usage for a command. +type UsageFunc func() error + +// CLIError represents a CLI error with an exit code and an optional usage function. +type CLIError struct { + Code int + Message string + UsageFunc UsageFunc +} + +func (err CLIError) Error() string { + return err.Message +} + +// ValidationError represents an error that occurred during action validation. +type ValidationError struct { + Message string +} + +func (err ValidationError) Error() string { + return err.Message +} + +// NewValidationError creates a new ValidationError. +func NewValidationError(message string) *ValidationError { + return &ValidationError{Message: message} +} + +// InternalError creates a new CLIError with the internal error exit code. +func InternalError(message string, usageFn UsageFunc) *CLIError { + return &CLIError{ + Code: ExitCodeInternalErr, + Message: message, + UsageFunc: usageFn, + } +} + +// BadUserInput creates a new CLIError with the bad user input exit code. +func BadUserInput(message string, usageFn UsageFunc) *CLIError { + return &CLIError{ + Code: ExitCodeBadUserInputErr, + Message: message, + UsageFunc: usageFn, + } +} diff --git a/gen/testdata/urfavecli/globalflags/gencli/help.gen.go b/gen/testdata/urfavecli/globalflags/gencli/help.gen.go new file mode 100644 index 0000000..fe642c7 --- /dev/null +++ b/gen/testdata/urfavecli/globalflags/gencli/help.gen.go @@ -0,0 +1,428 @@ +// Code generated by github.com/bcdxn/opencli@(devel) DO NOT EDIT. +package gencli + +import ( + "fmt" + "io" + "strings" + + "charm.land/glamour/v2" + "charm.land/lipgloss/v2" + "github.com/bcdxn/opencli/spec" + "github.com/urfave/cli/v3" +) + +var maxWidth = 100 + +var mdFormatting = []byte(`{ + "document": { + "block_prefix": "\n", + "block_suffix": "\n", + "margin": 0 + }, + "heading": { + "block_suffix": "\n", + "bold": true + }, + "h1": { + "prefix": " ", + "suffix": " ", + "bold": true + }, + "h2": { + "prefix": "## ", + "bold": true + }, + "emph": { + "underline": true + }, + "strong": { + "bold": true + }, + "link": { + "underline": true + }, + "code_block": { + "margin": 2 + }, + "list": { + "indent": 2 + }, + "item": { + "prefix": "• " + } +}`) + +var lightTheme = []byte(`{ + "document": { + "color": "236" + }, + "heading": { + "color": "239" + }, + "h1": { + "color": "236", + "background_color": "252" + }, + "h2": { + "color": "238" + }, + "link": { + "color": "31" + }, + "code": { + "color": "167", + "background_color": "254" + }, + "code_block": { + "color": "244" + } +}`) + +var darkTheme = []byte(`{ + "document": { + "color": "251" + }, + "heading": { + "color": "250" + }, + "h1": { + "color": "252", + "background_color": "238" + }, + "h2": { + "color": "250" + }, + "link": { + "color": "110" + }, + "code": { + "color": "180", + "background_color": "237" + }, + "code_block": { + "color": "246" + } +}`) + +var noPadding = []byte(`{ + "document": { + "block_prefix": "", + "block_suffix": "", + "margin": 0 + } +}`) + +var bold = lipgloss.NewStyle().Bold(true) + +func markdownTheme(a ActionsInterface) []byte { + if a.IOStreams().TerminalTheme() == "dark" { + return darkTheme + } + + return lightTheme +} + +// DefaultHelpFunc renders contextual help for cmd to the actions IOStreams output. +func DefaultHelpFunc(a ActionsInterface, cmd *spec.CommandItem) { + stdout := a.IOStreams().Out() + w, _, _ := a.IOStreams().TerminalSize() + r, _ := glamour.NewTermRenderer( + glamour.WithStylesFromJSONBytes(mdFormatting), + glamour.WithStylesFromJSONBytes(markdownTheme(a)), + glamour.WithWordWrap(w), + ) + + desc := []string{cmd.Summary} + if cmd.Description != "" { + desc = append(desc, cmd.Description) + } + + formattedDesc, err := r.Render(strings.Join(desc, "\n\n")) + if err != nil { + panic(err) + } + + lipgloss.Fprint(stdout, formattedDesc) + lipgloss.Fprint(stdout, bold.Render("USAGE:")) + lipgloss.Fprint(stdout, useLine(cmd)) + + if cmd.VisibleChildren { + lipgloss.Fprintf(stdout, "\n%s\n", bold.Render("AVAILABLE COMMANDS")) + lipgloss.Fprint(stdout, availableCommands(a, cmd)) + } + + if cmd.VisibleArgs { + lipgloss.Fprintf(stdout, "\n\n%s\n", bold.Render("ARGUMENTS")) + lipgloss.Fprint(a.IOStreams().Out(), availableArgs(a, cmd)) + } + + if cmd.VisibleFlags { + lipgloss.Fprintf(stdout, "\n\n%s\n", bold.Render("FLAGS")) + lipgloss.Fprint(stdout, availableFlags(a, cmd)) + } +} + +// DefaultUsageFunc renders contextual usage for cmd to the actions IOStreams output. +func DefaultUsageFunc(a ActionsInterface, cmd *spec.CommandItem) error { + stdout := a.IOStreams().Out() + + lipgloss.Fprint(stdout, bold.Render("USAGE:")) + lipgloss.Fprint(stdout, useLine(cmd)) + + if cmd.VisibleChildren { + lipgloss.Fprintf(stdout, "\n%s\n", bold.Render("AVAILABLE COMMANDS")) + lipgloss.Fprint(stdout, availableCommands(a, cmd)) + } + + if cmd.VisibleArgs { + lipgloss.Fprintf(stdout, "\n\n%s\n", bold.Render("ARGUMENTS")) + lipgloss.Fprint(a.IOStreams().Out(), availableArgs(a, cmd)) + } + + if cmd.VisibleFlags { + lipgloss.Fprintf(stdout, "\n\n%s\n", bold.Render("FLAGS")) + lipgloss.Fprint(stdout, availableFlags(a, cmd)) + lipgloss.Fprint(stdout, "\n") + } + + return nil +} + +func useLine(cmd *spec.CommandItem) string { + line := []string{cmd.CommandLine} + + if len(cmd.CommandModifiers) > 0 { + line = append(line, strings.Join(cmd.CommandModifiers, " ")) + } + if len(cmd.ArgsModifiers) > 0 { + line = append(line, strings.Join(cmd.ArgsModifiers, " ")) + } + if len(cmd.FlagsModifiers) > 0 { + line = append(line, strings.Join(cmd.FlagsModifiers, " ")) + } + + return fmt.Sprintf("\n %s\n", strings.Join(line, " ")) +} + +func availableCommands(a ActionsInterface, cmd *spec.CommandItem) string { + w, _, _ := a.IOStreams().TerminalSize() + if w > maxWidth { + w = maxWidth + } + + names := []string{} + for _, subcmd := range cmd.Commands { + names = append(names, subcmd.Segment) + } + + leftColWidth := columnWidth(names) + 3 + + r, err := glamour.NewTermRenderer( + glamour.WithStylesFromJSONBytes(mdFormatting), + glamour.WithStylesFromJSONBytes(markdownTheme(a)), + glamour.WithStylesFromJSONBytes(noPadding), + glamour.WithWordWrap(w-leftColWidth), + ) + if err != nil { + panic(err) + } + + leftStyle := lipgloss.NewStyle(). + Width(leftColWidth). + PaddingRight(1). + PaddingLeft(2) + + rightStyle := lipgloss.NewStyle(). + Width(w - leftColWidth) + + rows := []string{} + for _, subcmd := range cmd.Commands { + formattedName := leftStyle.Render(subcmd.Segment) + formattedSummary, err := r.Render(subcmd.Summary) + if err != nil { + panic(err) + } + + formattedSummary = strings.TrimSuffix(formattedSummary, "\n") + formattedSummary = rightStyle.Render(formattedSummary) + rows = append(rows, lipgloss.JoinHorizontal( + lipgloss.Top, + formattedName, + formattedSummary, + )) + } + + return lipgloss.JoinVertical(lipgloss.Top, rows...) +} + +func availableArgs(a ActionsInterface, cmd *spec.CommandItem) string { + w, _, _ := a.IOStreams().TerminalSize() + if w > maxWidth { + w = maxWidth + } + + names := []string{} + for _, arg := range cmd.Args { + names = append(names, fmt.Sprintf("<%s>", arg.Name)) + } + + leftColWidth := columnWidth(names) + 3 + + r, err := glamour.NewTermRenderer( + glamour.WithStylesFromJSONBytes(mdFormatting), + glamour.WithStylesFromJSONBytes(markdownTheme(a)), + glamour.WithStylesFromJSONBytes(noPadding), + glamour.WithWordWrap(w-leftColWidth), + ) + if err != nil { + panic(err) + } + + leftStyle := lipgloss.NewStyle(). + Width(leftColWidth). + PaddingRight(1). + PaddingLeft(2) + + rightStyle := lipgloss.NewStyle(). + Width(w - leftColWidth) + + rows := []string{} + for _, arg := range cmd.Args { + formattedName := leftStyle.Render(fmt.Sprintf("<%s>", arg.Name)) + formattedSummary, err := r.Render(arg.Summary) + if err != nil { + panic(err) + } + + formattedSummary = strings.TrimSuffix(formattedSummary, "\n") + formattedSummary = rightStyle.Render(formattedSummary) + rows = append(rows, lipgloss.JoinHorizontal( + lipgloss.Top, + formattedName, + formattedSummary, + )) + } + + return lipgloss.JoinVertical(lipgloss.Top, rows...) +} + +func availableFlags(a ActionsInterface, cmd *spec.CommandItem) string { + w, _, _ := a.IOStreams().TerminalSize() + if w > maxWidth { + w = maxWidth + } + + names := []string{} + for _, flag := range cmd.Flags { + names = append(names, flagNameWithAliases(flag)) + } + + leftColWidth := columnWidth(names) + 3 + + r, err := glamour.NewTermRenderer( + glamour.WithStylesFromJSONBytes(mdFormatting), + glamour.WithStylesFromJSONBytes(markdownTheme(a)), + glamour.WithStylesFromJSONBytes(noPadding), + glamour.WithWordWrap(w-leftColWidth), + ) + if err != nil { + panic(err) + } + + leftStyle := lipgloss.NewStyle(). + Width(leftColWidth). + PaddingRight(1). + PaddingLeft(2) + + rightStyle := lipgloss.NewStyle(). + Width(w - leftColWidth) + + rows := []string{} + for _, flag := range cmd.Flags { + formattedName := leftStyle.Render(flagNameWithAliases(flag)) + formattedSummary, err := r.Render(flag.Summary) + if err != nil { + panic(err) + } + + formattedSummary = strings.TrimSuffix(formattedSummary, "\n") + formattedSummary = rightStyle.Render(formattedSummary) + rows = append(rows, lipgloss.JoinHorizontal( + lipgloss.Top, + formattedName, + formattedSummary, + )) + } + + return lipgloss.JoinVertical(lipgloss.Top, rows...) +} + +func flagNameWithAliases(flag spec.FlagItem) string { + flagWithAliases := []string{fmt.Sprintf("--%s", flag.Name)} + for _, alias := range flag.Aliases { + flagWithAliases = append(flagWithAliases, fmt.Sprintf("-%s", alias)) + } + return strings.Join(flagWithAliases, " ") +} + +func columnWidth(rows []string) int { + max := 0 + for _, row := range rows { + if len(row) > max { + max = len(row) + } + } + return max +} + +// urfaveHelpTemplate is the custom help template for urfave/cli commands. +// It mirrors the section structure of our DefaultHelpFunc output (DESCRIPTION, USAGE, +// AVAILABLE COMMANDS, FLAGS). The actual rendering is handled by our custom HelpPrinter +// override which uses glamour/lipgloss for styled terminal output. +const urfaveHelpTemplate = `{{- if .Description}} + +DESCRIPTION: + {{wrap .Description 3}} +{{end}}{{- if .UsageText}}{{else}} + +USAGE: + {{.FullName}}{{if .VisibleFlags}}} [flags]{{end}}}{{if .VisibleCommands}}} [command]{{end}}}{{if .ArgsUsage}}} {{.ArgsUsage}}{{else}}}{{if .Arguments}}} [arguments]{{end}}{{end}}{{end}} +{{- if .VisibleCommands}} + +AVAILABLE COMMANDS:{{range .VisibleCommands}} + {{.Name}} - {{.Usage}} +{{end}} +{{end}}}{{- if .VisibleFlags}} + +FLAGS:{{range .VisibleFlags}} + {{.String}} +{{end}} +{{end}}` + +// SetupUrfaveHelpPrinter overrides the urfave/cli HelpPrinter to use our custom +// glamour-based help rendering pipeline. The override captures the ActionsInterface +// and delegates to DefaultHelpFunc for styled output when a spec.CommandItem is +// available in the command's metadata. +func SetupUrfaveHelpPrinter(a ActionsInterface) { + cli.RootCommandHelpTemplate = urfaveHelpTemplate + cli.CommandHelpTemplate = urfaveHelpTemplate + cli.SubcommandHelpTemplate = urfaveHelpTemplate + + cli.HelpPrinter = func(w io.Writer, templ string, data any) { + cmd, ok := data.(*cli.Command) + if !ok { + cli.DefaultPrintHelp(w, templ, data) + return + } + + // Extract the spec.CommandItem from metadata and render with our custom help function + if specCmd, ok := cmd.Metadata["spec_cmd"].(*spec.CommandItem); ok { + DefaultHelpFunc(a, specCmd) + return + } + + // Fallback to default urfave/cli rendering when spec metadata is not available + cli.DefaultPrintHelp(w, templ, data) + } +} diff --git a/gen/testdata/urfavecli/globalflags/gencli/iostreams.gen.go b/gen/testdata/urfavecli/globalflags/gencli/iostreams.gen.go new file mode 100644 index 0000000..3d8c122 --- /dev/null +++ b/gen/testdata/urfavecli/globalflags/gencli/iostreams.gen.go @@ -0,0 +1,197 @@ +// Code generated by github.com/bcdxn/opencli@(devel) DO NOT EDIT. +package gencli + +import ( + "bytes" + "io" + "os" + "strings" + + "charm.land/lipgloss/v2" + "golang.org/x/term" +) + +// IOStreams provides access to the standard I/O streams. +type IOStreams interface { + In() FileReader + Out() FileWriter + ErrOut() FileWriter + TerminalTheme() string + TerminalSize() (int, int, error) +} + +// FileWriter is an io.Writer that exposes the underlying file descriptor. +type FileWriter interface { + io.Writer + Fd() uintptr +} + +// FileReader is an io.ReadCloser that exposes the underlying file descriptor. +type FileReader interface { + io.ReadCloser + Fd() uintptr +} + +// DefaultIOStreams is the default implementation of IOStreams backed by os.Stdin/Stdout/Stderr. +type DefaultIOStreams struct { + in FileReader + out FileWriter + errOut FileWriter + term Terminal +} + +func (ios DefaultIOStreams) In() FileReader { + return ios.in +} + +func (ios DefaultIOStreams) Out() FileWriter { + return ios.out +} + +func (ios DefaultIOStreams) ErrOut() FileWriter { + return ios.errOut +} + +func (ios DefaultIOStreams) TerminalTheme() string { + return ios.term.Theme() +} + +func (ios DefaultIOStreams) TerminalSize() (int, int, error) { + return ios.term.Size() +} + +// DefaultIOS returns an IOStreams backed by the real os.Stdin/Stdout/Stderr. +func DefaultIOS() IOStreams { + return DefaultIOStreams{ + in: os.Stdin, + out: os.Stdout, + errOut: os.Stderr, + term: &DefaultTerminal{ + in: os.Stdin, + out: os.Stdout, + errOut: os.Stderr, + isTTY: isTerminal(os.Stdout), + is256enabled: is256ColorSupported(), + hasTrueColor: isTrueColorSupported(), + }, + } +} + +// Terminal provides terminal capability detection. +type Terminal interface { + IsTerminalOutput() bool + Is256ColorSupported() bool + IsTrueColorSupported() bool + Theme() string + Size() (int, int, error) +} + +// DefaultTerminal is the default Terminal implementation backed by the real OS terminal. +type DefaultTerminal struct { + in *os.File + out *os.File + errOut *os.File + isTTY bool + colorEnabled bool + is256enabled bool + hasTrueColor bool + width int +} + +func (t *DefaultTerminal) IsTerminalOutput() bool { + return t.isTTY +} + +func (t *DefaultTerminal) Is256ColorSupported() bool { + return t.is256enabled +} + +func (t *DefaultTerminal) IsTrueColorSupported() bool { + return t.hasTrueColor +} + +func (t *DefaultTerminal) Theme() string { + if lipgloss.HasDarkBackground(t.in, t.out) { + return "dark" + } + return "light" +} + +func (t *DefaultTerminal) Size() (int, int, error) { + ttyOut := t.out + if ttyOut == nil || !isTerminal(ttyOut) { + if f, err := openTTY(); err == nil { + defer f.Close() + ttyOut = f + } else { + return 80, 100, nil + } + } + return terminalSize(ttyOut) +} + +// TestIOS returns an IOStreams backed by bytes.Buffer for use in tests. +func TestIOS() (IOStreams, *bytes.Buffer, *bytes.Buffer, *bytes.Buffer) { + in := &bytes.Buffer{} + out := &bytes.Buffer{} + errOut := &bytes.Buffer{} + + ios := DefaultIOStreams{ + in: &fdReader{ + fd: 0, + ReadCloser: io.NopCloser(in), + }, + out: &fdWriter{fd: 1, Writer: out}, + errOut: &fdWriter{fd: 2, Writer: errOut}, + } + + return ios, in, out, errOut +} + +// fdWriter wraps an io.Writer and preserves the original file descriptor. +type fdWriter struct { + io.Writer + fd uintptr +} + +func (w *fdWriter) Fd() uintptr { + return w.fd +} + +// fdReader wraps an io.ReadCloser and preserves the original file descriptor. +type fdReader struct { + io.ReadCloser + fd uintptr +} + +func (r *fdReader) Fd() uintptr { + return r.fd +} + +func isTerminal(f *os.File) bool { + return term.IsTerminal(int(f.Fd())) +} + +func is256ColorSupported() bool { + return isTrueColorSupported() || + strings.Contains(os.Getenv("TERM"), "256") || + strings.Contains(os.Getenv("COLORTERM"), "256") +} + +func isTrueColorSupported() bool { + t := os.Getenv("TERM") + colorterm := os.Getenv("COLORTERM") + + return strings.Contains(t, "24bit") || + strings.Contains(t, "truecolor") || + strings.Contains(colorterm, "24bit") || + strings.Contains(colorterm, "truecolor") +} + +func openTTY() (*os.File, error) { + return os.Open("/dev/tty") +} + +func terminalSize(f *os.File) (int, int, error) { + return term.GetSize(int(f.Fd())) +} diff --git a/gen/testdata/urfavecli/globalflags/gencli/params.gen.go b/gen/testdata/urfavecli/globalflags/gencli/params.gen.go new file mode 100644 index 0000000..adada0d --- /dev/null +++ b/gen/testdata/urfavecli/globalflags/gencli/params.gen.go @@ -0,0 +1,47 @@ +// Code generated by github.com/bcdxn/opencli@(devel) DO NOT EDIT. +package gencli + +import "context" + +// GflagEchoArgs holds the positional arguments for the GflagEcho action. +type GflagEchoArgs struct { + Text string +} + +// GflagGreetFlags holds the flag values for the GflagGreet action. +type GflagGreetFlags struct { + Name string +} + +// GflagSendArgs holds the positional arguments for the GflagSend action. +type GflagSendArgs struct { + Recipient string +} + +// GflagSendFlags holds the flag values for the GflagSend action. +type GflagSendFlags struct { + Count int64 +} + +// GlobalFlags holds the global (root-level) flag values shared by every action. +type GlobalFlags struct { + Debug bool + Timeout int64 + OutputFormat string +} + +// globalFlagsKey is an unexported context key so only this package can set or read the value. +type globalFlagsKey struct{} + +// WithGlobalFlags returns a copy of ctx carrying the given GlobalFlags for actions to retrieve via GlobalFlagsFromContext. +func WithGlobalFlags(ctx context.Context, g GlobalFlags) context.Context { + return context.WithValue(ctx, globalFlagsKey{}, g) +} + +// GlobalFlagsFromContext retrieves the GlobalFlags injected by the generated command handler; it returns zero values if none were set. +func GlobalFlagsFromContext(ctx context.Context) GlobalFlags { + if v, ok := ctx.Value(globalFlagsKey{}).(GlobalFlags); ok { + return v + } + return GlobalFlags{} +} diff --git a/gen/testdata/urfavecli/globalflags/gencli/run.gen.go b/gen/testdata/urfavecli/globalflags/gencli/run.gen.go new file mode 100644 index 0000000..19c80a4 --- /dev/null +++ b/gen/testdata/urfavecli/globalflags/gencli/run.gen.go @@ -0,0 +1,55 @@ +// Code generated by github.com/bcdxn/opencli@(devel) DO NOT EDIT. +package gencli + +import ( + "context" + "errors" + "fmt" + "os" + + "github.com/urfave/cli/v3" +) + +// Run executes the root urfave command and returns an exit code. +func Run(ctx context.Context, actions ActionsInterface) int { + // Setup custom help printer with glamour/lipgloss rendering + SetupUrfaveHelpPrinter(actions) + + // Instantiate root command + rootCmd := NewCmdGflag(actions) + // Add version for `--version` flag + rootCmd.Version = actions.Version() + // Global Flags + rootCmd.Flags = append(rootCmd.Flags, &cli.BoolFlag{ + Name: "debug", + Aliases: []string{"v"}, + Value: false, + Usage: "enable verbose debug output", + }) + // Global Flags + rootCmd.Flags = append(rootCmd.Flags, &cli.Int64Flag{ + Name: "timeout", + Value: 30, + Usage: "request timeout in seconds", + }) + // Global Flags + rootCmd.Flags = append(rootCmd.Flags, &cli.StringFlag{ + Name: "output-format", + Value: "text", + Usage: "preferred output format", + }) + + // Run the CLI + if err := rootCmd.Run(ctx, os.Args); err != nil { + fmt.Fprintf(actions.IOStreams().Out(), "error: %v\n\n", err.Error()) + + if cliErr, ok := errors.AsType[*CLIError](err); ok { + cliErr.UsageFunc() + return cliErr.Code + } + + return ExitCodeInternalErr + } + + return ExitCodeOK +} diff --git a/gen/testdata/yargs/gencli/cmd-petstore-user-login.ts b/gen/testdata/yargs/gencli/cmd-petstore-user-login.ts index a3e9d02..40858ca 100644 --- a/gen/testdata/yargs/gencli/cmd-petstore-user-login.ts +++ b/gen/testdata/yargs/gencli/cmd-petstore-user-login.ts @@ -4,6 +4,10 @@ import { ActionsInterface } from "./actions"; import { PetstoreUserLoginFlags, } from "./params"; +import { + resolveStringFlag +} from "./config"; + import { CommandPrintData } from "./types"; import { CliError, ExitCode, createBadUserInputError } from "./errors"; // Local argv shape for this command's builder. @@ -62,8 +66,8 @@ export function newPetstoreUserLoginCmd( process.exit(0); } const cmdFlags: PetstoreUserLoginFlags = { - username: argv.username as string, - password: argv.password as string, + username: resolveStringFlag(argv, ["username"], [{ type: "$ENV", property: "PETSTORE_USER" }, { type: "$FILE", property: "$.auth.user" }]), + password: resolveStringFlag(argv, ["password"], [{ type: "$ENV", property: "PETSTORE_PASS" }, { type: "$FILE", property: "$.auth.pass" }]), }; return actions.PetstoreUserLogin(cmdFlags); }, diff --git a/gen/testdata/yargs/gencli/config.ts b/gen/testdata/yargs/gencli/config.ts new file mode 100644 index 0000000..ad87d51 --- /dev/null +++ b/gen/testdata/yargs/gencli/config.ts @@ -0,0 +1,265 @@ +// Code generated by github.com/bcdxn/opencli@(devel) DO NOT EDIT. +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { parse as parseYaml } from "yaml"; +import toml from "@iarna/toml"; +import { JSONPath } from "jsonpath-plus"; + +// AltSource is a single alternative source for a flag value: either an +// environment variable ($ENV) or a property in the config file ($FILE). +export interface AltSource { + type: string; // "$ENV" or "$FILE" + property: string; // env var name, or JSONPath into the config file +} + +let globalConfig: Record | null = null; + +// expandTilde expands a leading tilde (~) in the path to the user's home directory. +function expandTilde(p: string): string { + if (!p.startsWith("~")) return p; + const home = os.homedir(); + if (p === "~") return home; + return path.join(home, p.slice(1)); +} + +// loadConfig loads the config file from the first available path (JSON > YAML > TOML). +export function loadConfig(): void { + if (globalConfig !== null) return; + globalConfig = {}; + // Try JSON config first. + try { + const data = fs.readFileSync(expandTilde("~/.petstore/config.json"), "utf8"); + globalConfig = JSON.parse(data) as Record; + return; + } catch {} + // Try YAML config. + try { + const data = fs.readFileSync(expandTilde("~/.petstore/config.yaml"), "utf8"); + globalConfig = parseYaml(data) as Record; + return; + } catch {} + // Try TOML config. + try { + const data = fs.readFileSync(expandTilde("~/.petstore/config.toml"), "utf8"); + globalConfig = toml.parse(data) as Record; + return; + } catch {} +} + +// resolveJSONPath resolves a JSONPath expression against the loaded config. It returns +// null when no config was loaded or the path does not match. +function resolveJSONPath(expr: string): unknown { + if (globalConfig === null) return null; + try { + const results = JSONPath({ path: expr, json: globalConfig }); + if (!Array.isArray(results)) return results; + return results.length > 0 ? results[0] : null; + } catch { + return null; + } +} + +// altSourceValue returns the raw value from a single alternative source, or null when +// the source yields nothing. $ENV sources yield the environment variable's string +// value; $FILE sources yield the value at the given JSONPath. +function altSourceValue(src: AltSource): unknown { + switch (src.type) { + case "$ENV": { + const val = process.env[src.property]; + if (val !== undefined && val !== "") return val; + break; + } + case "$FILE": { + const val = resolveJSONPath(src.property); + if (val !== null) return val; + break; + } + } + return null; +} + +// altSourceItems returns the raw element values from a single alternative source for a +// variadic flag. $ENV sources are comma-separated; $FILE sources are arrays (a single +// value is treated as a one-element list). +function altSourceItems(src: AltSource): unknown[] { + switch (src.type) { + case "$ENV": { + const variable = process.env[src.property]; + if (!variable || variable === "") return []; + return variable + .split(",") + .map((p) => p.trim()) + .filter((p) => p !== ""); + } + case "$FILE": { + const val = resolveJSONPath(src.property); + if (val === null || val === undefined) return []; + if (Array.isArray(val)) return val; + return [val]; + } + } + return []; +} + +// Coercion helpers convert a raw source value to a concrete TypeScript type. Each returns +// ok=false when the value cannot be interpreted as the target type, so resolvers can skip +// it and try the next alternative source in order. + +function toString(v: unknown): [string, boolean] { + if (typeof v === "string" && v !== "") return [v, true]; + return ["", false]; +} + +function toNumber(v: unknown): [number, boolean] { + if (typeof v === "number") { + return Number.isFinite(v) ? [v, true] : ([0, false] as [number, boolean]); + } + if (typeof v === "string" && v.trim() !== "") { + const n = Number(v); + if (!Number.isNaN(n)) return [n, true]; + } + return [0, false]; +} + +function toBool(v: unknown): [boolean, boolean] { + if (typeof v === "boolean") return [v, true]; + if (typeof v === "string") { + switch (v.toLowerCase()) { + case "1": + case "t": + case "true": + return [true, true]; + case "0": + case "f": + case "false": + return [false, true]; + } + } + return [false, false]; +} + +function toStringSlice(v: unknown): [string[], boolean] { + const items = Array.isArray(v) ? v : typeof v === "string" && v !== "" ? [v] : []; + if (items.length === 0) return [[], false]; + const result: string[] = []; + for (const item of items) { + const s = toString(item); + if (!s[1]) continue; + result.push(s[0]); + } + return [result, result.length > 0]; +} + +function toNumberSlice(v: unknown): [number[], boolean] { + const items = Array.isArray(v) ? v : typeof v === "string" && v !== "" ? [v] : []; + if (items.length === 0) return [[], false]; + const result: number[] = []; + for (const item of items) { + const n = toNumber(item); + if (!n[1]) continue; + result.push(n[0]); + } + return [result, result.length > 0]; +} + +function toBoolSlice(v: unknown): [boolean[], boolean] { + const items = Array.isArray(v) ? v : typeof v === "string" && v !== "" ? [v] : []; + if (items.length === 0) return [[], false]; + const result: boolean[] = []; + for (const item of items) { + const b = toBool(item); + if (!b[1]) continue; + result.push(b[0]); + } + return [result, result.length > 0]; +} + +/* +Flag Resolver Functions + +These functions are called from generated command code to resolve flag values. yargs has +no "was this option set on the CLI" API (unlike pflag.Changed or urfave's IsSet), so an +explicit CLI value is detected by scanning process.argv for tokens that reference the +option (--name, --name=..., -x shorthand, multi-char aliases as long options, and the +--no-name negation form). When found, the parsed argv value is used as-is; otherwise the +value is resolved from the flag's alternative sources (environment variables, config file) +in declared order. Returning undefined lets yargs apply its bound default or leave the +field unset. +*/ + +// wasSetOnCli reports whether any token in process.argv references one of the given +// option spellings. names[0] is the camelCase argv field; the rest are raw spec names and +// aliases (yargs accepts all of them). The --no-name negation form also counts as set. +function wasSetOnCli(names: string[]): boolean { + const tokens = process.argv.slice(2); + for (const t of tokens) { + if (!t.startsWith("-")) continue; + let body = t.replace(/^--?/, ""); + // --no-name negation form references the same option. + if (body.startsWith("no-") && names.includes(body.slice(3))) return true; + const eqIdx = body.indexOf("="); + if (eqIdx >= 0) body = body.slice(0, eqIdx); + if (body !== "" && names.includes(body)) return true; + } + return false; +} + +// resolveStringFlag resolves a string flag from the CLI or its alternative sources. +export function resolveStringFlag(argv: any, names: string[], sources: AltSource[]): string | undefined { + if (wasSetOnCli(names)) return argv[names[0]] as string; + for (const src of sources) { + const v = toString(altSourceValue(src)); + if (v[1]) return v[0]; + } + return undefined; +} + +// resolveNumberFlag resolves a number flag from the CLI or its alternative sources. +export function resolveNumberFlag(argv: any, names: string[], sources: AltSource[]): number | undefined { + if (wasSetOnCli(names)) return argv[names[0]] as number; + for (const src of sources) { + const v = toNumber(altSourceValue(src)); + if (v[1]) return v[0]; + } + return undefined; +} + +// resolveBoolFlag resolves a boolean flag from the CLI or its alternative sources. An +// explicit --flag=false on the command line is honored via wasSetOnCli; omitting the flag +// consults the alternative sources, matching cobra/urfave behavior for cross-framework parity. +export function resolveBoolFlag(argv: any, names: string[], sources: AltSource[]): boolean | undefined { + if (wasSetOnCli(names)) return argv[names[0]] as boolean; + for (const src of sources) { + const v = toBool(altSourceValue(src)); + if (v[1]) return v[0]; + } + return undefined; +} + +// resolveSliceFlag resolves a variadic flag from the CLI or its alternative sources. When +// not set on the command line it returns the first source that yields at least one value +// coercible to T, otherwise undefined (yargs then applies its bound default). +function resolveSliceFlag(argv: any, names: string[], sources: AltSource[], coerce: (v: unknown) => [T[], boolean]): T[] | undefined { + if (wasSetOnCli(names)) return argv[names[0]] as T[]; + for (const src of sources) { + const v = coerce(altSourceItems(src)); + if (v[1]) return v[0]; + } + return undefined; +} + +// resolveStringSliceFlag resolves a string slice flag from the CLI or its alternative sources. +export function resolveStringSliceFlag(argv: any, names: string[], sources: AltSource[]): string[] | undefined { + return resolveSliceFlag(argv, names, sources, toStringSlice); +} + +// resolveNumberSliceFlag resolves a number slice flag from the CLI or its alternative sources. +export function resolveNumberSliceFlag(argv: any, names: string[], sources: AltSource[]): number[] | undefined { + return resolveSliceFlag(argv, names, sources, toNumberSlice); +} + +// resolveBoolSliceFlag resolves a boolean slice flag from the CLI or its alternative sources. +export function resolveBoolSliceFlag(argv: any, names: string[], sources: AltSource[]): boolean[] | undefined { + return resolveSliceFlag(argv, names, sources, toBoolSlice); +} diff --git a/gen/testdata/yargs/gencli/help.ts b/gen/testdata/yargs/gencli/help.ts index 2d6a8ba..719330e 100644 --- a/gen/testdata/yargs/gencli/help.ts +++ b/gen/testdata/yargs/gencli/help.ts @@ -79,11 +79,11 @@ function appendArgSections( if (cmd.args?.length) { sections.push({ header: "ARGUMENTS", - optionList: cmd.args.map((a) => ({ + content: cmd.args.map((a) => ({ name: a.name, description: escapeChalk(a.summary), })), - } as commandLineUsage.OptionList); + } as commandLineUsage.Content); } } diff --git a/gen/testdata/yargs/gencli/run.ts b/gen/testdata/yargs/gencli/run.ts index 4ad6279..062e828 100644 --- a/gen/testdata/yargs/gencli/run.ts +++ b/gen/testdata/yargs/gencli/run.ts @@ -1,19 +1,23 @@ // Code generated by github.com/bcdxn/opencli@(devel) DO NOT EDIT. import yargs from "yargs"; +import { hideBin } from "yargs/helpers"; import { newPetstoreListCmd } from "./cmd-petstore-list"; import { newPetstorePetCmd } from "./cmd-petstore-pet"; import { newPetstoreStoreCmd } from "./cmd-petstore-store"; import { newPetstoreUserCmd } from "./cmd-petstore-user"; +import { loadConfig } from "./config"; import { ActionsInterface } from "./actions"; import { CommandPrintData } from "./types"; import { CliError, ExitCode } from "./errors"; import { defaultHelpFn, defaultUsageFn } from "./help"; export async function run( - yargsInstance: yargs.Argv<{}>, + argv: string[], actions: ActionsInterface, ): Promise { - await yargsInstance + // Load config file from disk (JSON/YAML/TOML) for $FILE alternative sources. + loadConfig(); + await yargs(hideBin(argv)) .scriptName("petstore") .help(false) .command(newPetstoreListCmd(actions)) diff --git a/gen/testdata/yargs/globalflags/gencli/actions.ts b/gen/testdata/yargs/globalflags/gencli/actions.ts new file mode 100644 index 0000000..4fdfb45 --- /dev/null +++ b/gen/testdata/yargs/globalflags/gencli/actions.ts @@ -0,0 +1,17 @@ +// Code generated by github.com/bcdxn/opencli@(devel) DO NOT EDIT. +import { CommandPrintData } from "./types"; +import { + GflagEchoArgs, + GflagGreetFlags, + GflagSendArgs, + GflagSendFlags +} from "./params"; + +export interface ActionsInterface { + GflagPing(): Promise; + GflagEcho(args: GflagEchoArgs): Promise; + GflagGreet(flags: GflagGreetFlags): Promise; + GflagSend(args: GflagSendArgs, flags: GflagSendFlags): Promise; + help(cmd: CommandPrintData): void; + usage(cmd: CommandPrintData): void; +} \ No newline at end of file diff --git a/gen/testdata/yargs/globalflags/gencli/cmd-gflag-echo.ts b/gen/testdata/yargs/globalflags/gencli/cmd-gflag-echo.ts new file mode 100644 index 0000000..51cd816 --- /dev/null +++ b/gen/testdata/yargs/globalflags/gencli/cmd-gflag-echo.ts @@ -0,0 +1,94 @@ +// Code generated by github.com/bcdxn/opencli@(devel) DO NOT EDIT. +import yargs from "yargs"; +import { ActionsInterface } from "./actions"; +import { + GflagEchoArgs, + GlobalFlags, + setGlobalFlags, +} from "./params"; +import { CommandPrintData } from "./types"; +import { CliError, ExitCode, createBadUserInputError } from "./errors"; +// Local argv shape for this command's builder. +interface gflagEchoArgs { + "text"?: string; + "debug"?: boolean; + "timeout"?: number; + "outputFormat"?: string; + help: boolean; +} + +export function newGflagEchoCmd( + actions: ActionsInterface, +): yargs.CommandModule<{}, gflagEchoArgs> { + return { + command: "echo ", + describe: "Echo a single argument back", + builder: (argv: yargs.Argv<{}>): yargs.Argv => { + return argv + .positional("text", { + describe: "", + type: "string", + }) + .help(false) + .option("help", { + alias: "h", + type: "boolean", + default: false, + }) + .fail(async (msg: string, err: Error) => { + if (process.argv.includes("--help") || process.argv.includes("-h")) { + actions.help(getGflagEchoCmdHelpData()); + process.exit(0); + } + + if (err instanceof CliError) { + console.log(err.message); + actions.usage(getGflagEchoCmdHelpData()); + process.exit(err.code); + } + + console.log(msg); + actions.usage(getGflagEchoCmdHelpData()); + if ( + msg.includes("Missing required arguments") || + msg.includes("Not enough non-option arguments") + ) { + process.exit(ExitCode.BadUserInputErr); + } + process.exit(ExitCode.InternalErr); + }); + }, + handler: async (argv: yargs.ArgumentsCamelCase) => { + if (argv.help) { + actions.help(getGflagEchoCmdHelpData()); + process.exit(0); + } + const cmdArgs: GflagEchoArgs = { + text: argv.text, + }; + setGlobalFlags({ + debug: argv.debug as boolean, + timeout: argv.timeout as number, + outputFormat: argv.outputFormat as string, + }); + return actions.GflagEcho(cmdArgs); + }, + }; +} + +function getGflagEchoCmdHelpData(): CommandPrintData { + return { + segment: "echo", + commandLine: "gflag echo", + summary: "Echo a single argument back", + visibleChildren: false, + visibleArgs: true, + visibleFlags: false, + argsModifiers: [ + "", + ], + args: [ + { name: "text", summary: "the text to echo", isRequired: false }, + ], + }; +} diff --git a/gen/testdata/yargs/globalflags/gencli/cmd-gflag-greet.ts b/gen/testdata/yargs/globalflags/gencli/cmd-gflag-greet.ts new file mode 100644 index 0000000..b09629b --- /dev/null +++ b/gen/testdata/yargs/globalflags/gencli/cmd-gflag-greet.ts @@ -0,0 +1,93 @@ +// Code generated by github.com/bcdxn/opencli@(devel) DO NOT EDIT. +import yargs from "yargs"; +import { ActionsInterface } from "./actions"; +import { + GflagGreetFlags, + GlobalFlags, + setGlobalFlags, +} from "./params"; +import { CommandPrintData } from "./types"; +import { CliError, ExitCode, createBadUserInputError } from "./errors"; +// Local argv shape for this command's builder. +interface gflagGreetArgs { + "name"?: string; + "debug"?: boolean; + "timeout"?: number; + "outputFormat"?: string; + help: boolean; +} + +export function newGflagGreetCmd( + actions: ActionsInterface, +): yargs.CommandModule<{}, gflagGreetArgs> { + return { + command: "greet", + describe: "Greet someone by name", + builder: (argv: yargs.Argv<{}>): yargs.Argv => { + return argv + .option("name", { + type: "string", + }) + .help(false) + .option("help", { + alias: "h", + type: "boolean", + default: false, + }) + .fail(async (msg: string, err: Error) => { + if (process.argv.includes("--help") || process.argv.includes("-h")) { + actions.help(getGflagGreetCmdHelpData()); + process.exit(0); + } + + if (err instanceof CliError) { + console.log(err.message); + actions.usage(getGflagGreetCmdHelpData()); + process.exit(err.code); + } + + console.log(msg); + actions.usage(getGflagGreetCmdHelpData()); + if ( + msg.includes("Missing required arguments") || + msg.includes("Not enough non-option arguments") + ) { + process.exit(ExitCode.BadUserInputErr); + } + process.exit(ExitCode.InternalErr); + }); + }, + handler: async (argv: yargs.ArgumentsCamelCase) => { + if (argv.help) { + actions.help(getGflagGreetCmdHelpData()); + process.exit(0); + } + const cmdFlags: GflagGreetFlags = { + name: argv.name as string, + }; + setGlobalFlags({ + debug: argv.debug as boolean, + timeout: argv.timeout as number, + outputFormat: argv.outputFormat as string, + }); + return actions.GflagGreet(cmdFlags); + }, + }; +} + +function getGflagGreetCmdHelpData(): CommandPrintData { + return { + segment: "greet", + commandLine: "gflag greet", + summary: "Greet someone by name", + visibleChildren: false, + visibleArgs: false, + visibleFlags: true, + flagsModifiers: [ + "[flags]", + ], + flags: [ + { name: "name", summary: "who to greet", aliases: [] }, + ], + }; +} diff --git a/gen/testdata/yargs/globalflags/gencli/cmd-gflag-ping.ts b/gen/testdata/yargs/globalflags/gencli/cmd-gflag-ping.ts new file mode 100644 index 0000000..b2d76c0 --- /dev/null +++ b/gen/testdata/yargs/globalflags/gencli/cmd-gflag-ping.ts @@ -0,0 +1,79 @@ +// Code generated by github.com/bcdxn/opencli@(devel) DO NOT EDIT. +import yargs from "yargs"; +import { ActionsInterface } from "./actions"; +import { + GlobalFlags, + setGlobalFlags, +} from "./params"; +import { CommandPrintData } from "./types"; +import { CliError, ExitCode, createBadUserInputError } from "./errors"; +// Local argv shape for this command's builder. +interface gflagPingArgs { + "debug"?: boolean; + "timeout"?: number; + "outputFormat"?: string; + help: boolean; +} + +export function newGflagPingCmd( + actions: ActionsInterface, +): yargs.CommandModule<{}, gflagPingArgs> { + return { + command: "ping", + describe: "Report that the CLI is alive", + builder: (argv: yargs.Argv<{}>): yargs.Argv => { + return argv + .help(false) + .option("help", { + alias: "h", + type: "boolean", + default: false, + }) + .fail(async (msg: string, err: Error) => { + if (process.argv.includes("--help") || process.argv.includes("-h")) { + actions.help(getGflagPingCmdHelpData()); + process.exit(0); + } + + if (err instanceof CliError) { + console.log(err.message); + actions.usage(getGflagPingCmdHelpData()); + process.exit(err.code); + } + + console.log(msg); + actions.usage(getGflagPingCmdHelpData()); + if ( + msg.includes("Missing required arguments") || + msg.includes("Not enough non-option arguments") + ) { + process.exit(ExitCode.BadUserInputErr); + } + process.exit(ExitCode.InternalErr); + }); + }, + handler: async (argv: yargs.ArgumentsCamelCase) => { + if (argv.help) { + actions.help(getGflagPingCmdHelpData()); + process.exit(0); + } + setGlobalFlags({ + debug: argv.debug as boolean, + timeout: argv.timeout as number, + outputFormat: argv.outputFormat as string, + }); + return actions.GflagPing(); + }, + }; +} + +function getGflagPingCmdHelpData(): CommandPrintData { + return { + segment: "ping", + commandLine: "gflag ping", + summary: "Report that the CLI is alive", + visibleChildren: false, + visibleArgs: false, + visibleFlags: false, + }; +} diff --git a/gen/testdata/yargs/globalflags/gencli/cmd-gflag-send.ts b/gen/testdata/yargs/globalflags/gencli/cmd-gflag-send.ts new file mode 100644 index 0000000..5932c48 --- /dev/null +++ b/gen/testdata/yargs/globalflags/gencli/cmd-gflag-send.ts @@ -0,0 +1,109 @@ +// Code generated by github.com/bcdxn/opencli@(devel) DO NOT EDIT. +import yargs from "yargs"; +import { ActionsInterface } from "./actions"; +import { + GflagSendArgs, + GflagSendFlags, + GlobalFlags, + setGlobalFlags, +} from "./params"; +import { CommandPrintData } from "./types"; +import { CliError, ExitCode, createBadUserInputError } from "./errors"; +// Local argv shape for this command's builder. +interface gflagSendArgs { + "recipient"?: string; + "count"?: number; + "debug"?: boolean; + "timeout"?: number; + "outputFormat"?: string; + help: boolean; +} + +export function newGflagSendCmd( + actions: ActionsInterface, +): yargs.CommandModule<{}, gflagSendArgs> { + return { + command: "send ", + describe: "Send a message the given number of times", + builder: (argv: yargs.Argv<{}>): yargs.Argv => { + return argv + .positional("recipient", { + describe: "", + type: "string", + }) + .option("count", { + type: "number", + default: 1, + }) + .help(false) + .option("help", { + alias: "h", + type: "boolean", + default: false, + }) + .fail(async (msg: string, err: Error) => { + if (process.argv.includes("--help") || process.argv.includes("-h")) { + actions.help(getGflagSendCmdHelpData()); + process.exit(0); + } + + if (err instanceof CliError) { + console.log(err.message); + actions.usage(getGflagSendCmdHelpData()); + process.exit(err.code); + } + + console.log(msg); + actions.usage(getGflagSendCmdHelpData()); + if ( + msg.includes("Missing required arguments") || + msg.includes("Not enough non-option arguments") + ) { + process.exit(ExitCode.BadUserInputErr); + } + process.exit(ExitCode.InternalErr); + }); + }, + handler: async (argv: yargs.ArgumentsCamelCase) => { + if (argv.help) { + actions.help(getGflagSendCmdHelpData()); + process.exit(0); + } + const cmdArgs: GflagSendArgs = { + recipient: argv.recipient, + }; + const cmdFlags: GflagSendFlags = { + count: argv.count as number, + }; + setGlobalFlags({ + debug: argv.debug as boolean, + timeout: argv.timeout as number, + outputFormat: argv.outputFormat as string, + }); + return actions.GflagSend(cmdArgs, cmdFlags); + }, + }; +} + +function getGflagSendCmdHelpData(): CommandPrintData { + return { + segment: "send", + commandLine: "gflag send", + summary: "Send a message the given number of times", + visibleChildren: false, + visibleArgs: true, + visibleFlags: true, + argsModifiers: [ + "", + ], + flagsModifiers: [ + "[flags]", + ], + args: [ + { name: "recipient", summary: "who to send to", isRequired: false }, + ], + flags: [ + { name: "count", summary: "how many times to send", aliases: [] }, + ], + }; +} diff --git a/gen/testdata/yargs/globalflags/gencli/cmd-gflag.ts b/gen/testdata/yargs/globalflags/gencli/cmd-gflag.ts new file mode 100644 index 0000000..31ce36a --- /dev/null +++ b/gen/testdata/yargs/globalflags/gencli/cmd-gflag.ts @@ -0,0 +1,91 @@ +// Code generated by github.com/bcdxn/opencli@(devel) DO NOT EDIT. +import yargs from "yargs"; +import { newGflagPingCmd } from "./cmd-gflag-ping"; +import { newGflagEchoCmd } from "./cmd-gflag-echo"; +import { newGflagGreetCmd } from "./cmd-gflag-greet"; +import { newGflagSendCmd } from "./cmd-gflag-send"; +import { ActionsInterface } from "./actions"; +import { CommandPrintData } from "./types"; +import { CliError, ExitCode, createBadUserInputError } from "./errors"; +export function newGflagCmd( + actions: ActionsInterface, +): yargs.CommandModule<{}> { + return { + command: "gflag", + describe: "A minimal spec that declares real (non-help/version) global flags to exercise the context/accessor codegen paths for every framework.", + builder: (argv: yargs.Argv<{}>): yargs.Argv<{}> => { + return argv + .command(newGflagPingCmd(actions)) + .command(newGflagEchoCmd(actions)) + .command(newGflagGreetCmd(actions)) + .command(newGflagSendCmd(actions)) + .demandCommand(1) + .help(false) + .option("help", { + alias: "h", + type: "boolean", + default: false, + }) + .fail(async (msg: string, err: Error) => { + if (process.argv.includes("--help") || process.argv.includes("-h")) { + actions.help(getGflagCmdHelpData()); + process.exit(0); + } + + if (err instanceof CliError) { + console.log(err.message); + actions.usage(getGflagCmdHelpData()); + process.exit(err.code); + } + + console.log(msg); + actions.usage(getGflagCmdHelpData()); + if ( + msg.includes("Missing required arguments") || + msg.includes("Not enough non-option arguments") + ) { + process.exit(ExitCode.BadUserInputErr); + } + process.exit(ExitCode.InternalErr); + }); + }, + handler: async (argv: yargs.ArgumentsCamelCase<{}>) => { + if (argv.help) { + actions.help(getGflagCmdHelpData()); + process.exit(0); + } + return Promise.reject( + createBadUserInputError("missing required sub command", () => { + actions.usage(getGflagCmdHelpData()); + }), + ); + }, + }; +} + +function getGflagCmdHelpData(): CommandPrintData { + return { + segment: "gflag", + commandLine: "gflag", + summary: "A minimal spec that declares real (non-help/version) global flags to exercise the context/accessor codegen paths for every framework.", + description: "`gflag` is a tiny fixture whose only purpose is to declare genuine global flags so\ngenerated output exercises the `GlobalFlagsFromContext` / `setGlobalFlags` /\n`getGlobalFlags` helpers and their call sites in each command handler.\n\nIt also declares one leaf per return-branch shape (args+flags, args-only,\nflags-only, neither) so all four action-call forms appear in the goldens.\n", + visibleChildren: true, + visibleArgs: false, + visibleFlags: false, + commandModifiers: [ + "{command}", + ], + argsModifiers: [ + "", + ], + flagsModifiers: [ + "[flags]", + ], + commands: [ + { segment: "ping", commandLine: "ping", summary: "Report that the CLI is alive" }, + { segment: "echo", commandLine: "echo", summary: "Echo a single argument back" }, + { segment: "greet", commandLine: "greet", summary: "Greet someone by name" }, + { segment: "send", commandLine: "send", summary: "Send a message the given number of times" }, + ], + }; +} diff --git a/gen/testdata/yargs/globalflags/gencli/errors.ts b/gen/testdata/yargs/globalflags/gencli/errors.ts new file mode 100644 index 0000000..87e60b7 --- /dev/null +++ b/gen/testdata/yargs/globalflags/gencli/errors.ts @@ -0,0 +1,49 @@ +// Code generated by github.com/bcdxn/opencli@(devel) DO NOT EDIT. +import { HelpFn } from "./help"; + +export const ExitCode = { + OK: 0, + InternalErr: 1, + BadUserInputErr: 2, +} as const; + +export type ExitCode = (typeof ExitCode)[keyof typeof ExitCode]; + +/** + * CliError represents a CLI error with an exit code and an optional usage function. + */ +export class CliError extends Error { + public readonly code: ExitCode; + public readonly usageFunc: HelpFn; + + constructor(message: string, code: ExitCode, usageFunc: HelpFn) { + super(message); + this.code = code; + this.usageFunc = usageFunc; + Object.setPrototypeOf(this, CliError.prototype); + if (Error.captureStackTrace) { + Error.captureStackTrace(this, this.constructor); + } + } +} + +/** + * ValidationError represents an error that occurred during action validation. + */ +export class ValidationError extends Error { + constructor(message: string) { + super(message); + Object.setPrototypeOf(this, ValidationError.prototype); + if (Error.captureStackTrace) { + Error.captureStackTrace(this, this.constructor); + } + } +} + +export function createInternalError(message: string, usageFn: HelpFn): CliError { + return new CliError(message, ExitCode.InternalErr, usageFn); +} + +export function createBadUserInputError(message: string, usageFn: HelpFn): CliError { + return new CliError(message, ExitCode.BadUserInputErr, usageFn); +} diff --git a/gen/testdata/yargs/globalflags/gencli/help.ts b/gen/testdata/yargs/globalflags/gencli/help.ts new file mode 100644 index 0000000..719330e --- /dev/null +++ b/gen/testdata/yargs/globalflags/gencli/help.ts @@ -0,0 +1,116 @@ +// Code generated by github.com/bcdxn/opencli@(devel) DO NOT EDIT. +import os from "node:os"; +import commandLineUsage from "command-line-usage"; +import { CommandPrintData } from "./types"; + +// HelpFn is a function that renders the help for a command. +export type HelpFn = () => void; +// UsageFn is a function that renders the usage for a command on the event of a handled error. +export type UsageFn = () => void; + +export function defaultHelpFn(cmd: CommandPrintData): void { + let header = cmd.summary; + if (cmd.description) { + header += os.EOL + os.EOL + cmd.description; + } + + const sections: commandLineUsage.Section[] = [ + { + header: escapeChalk( + [ + cmd.commandLine, + ...(cmd.commandModifiers ?? []), + ...(cmd.argsModifiers ?? []), + ...(cmd.flagsModifiers ?? []), + ].join(" "), + ), + content: escapeChalk(header), + }, + ]; + + appendChildCommandSections(cmd, sections); + appendArgSections(cmd, sections); + appendFlagSections(cmd, sections); + + console.log(commandLineUsage(sections)); +} + +export function defaultUsageFn(cmd: CommandPrintData): void { + const sections: commandLineUsage.Section[] = [ + { + header: escapeChalk( + [ + cmd.commandLine, + ...(cmd.commandModifiers ?? []), + ...(cmd.argsModifiers ?? []), + ...(cmd.flagsModifiers ?? []), + ].join(" "), + ), + content: escapeChalk(cmd.summary), + }, + ]; + + appendChildCommandSections(cmd, sections); + appendArgSections(cmd, sections); + appendFlagSections(cmd, sections); + + console.log(commandLineUsage(sections)); +} + +function appendChildCommandSections( + cmd: CommandPrintData, + sections: commandLineUsage.Section[], +): void { + if (cmd.commands?.length) { + sections.push({ + header: "AVAILABLE COMMANDS", + content: cmd.commands.map((c) => ({ + name: c.segment, + summary: escapeChalk(c.summary), + })), + }); + } +} + +function appendArgSections( + cmd: CommandPrintData, + sections: commandLineUsage.Section[], +): void { + if (cmd.args?.length) { + sections.push({ + header: "ARGUMENTS", + content: cmd.args.map((a) => ({ + name: a.name, + description: escapeChalk(a.summary), + })), + } as commandLineUsage.Content); + } +} + +function appendFlagSections( + cmd: CommandPrintData, + sections: commandLineUsage.Section[], +): void { + if (cmd.flags?.length) { + sections.push({ + header: "FLAGS", + optionList: cmd.flags.map((f) => { + let alias = ""; + if (f.aliases?.length) { + alias = f.aliases[0]; + } + return { + name: f.name, + alias: alias, + description: escapeChalk(f.summary), + multiple: f.isVariadic, + type: f.type, + }; + }), + } as commandLineUsage.OptionList); + } +} + +function escapeChalk(val: string): string { + return val.replaceAll("{", "\\{").replaceAll("}", "\\}"); +} diff --git a/gen/testdata/yargs/globalflags/gencli/params.ts b/gen/testdata/yargs/globalflags/gencli/params.ts new file mode 100644 index 0000000..f9b0d22 --- /dev/null +++ b/gen/testdata/yargs/globalflags/gencli/params.ts @@ -0,0 +1,38 @@ +// Code generated by github.com/bcdxn/opencli@(devel) DO NOT EDIT. +// GlobalFlags holds the global (root-level) flag values shared by every action. +export interface GlobalFlags { + debug?: boolean; + timeout?: number; + outputFormat?: string; +} + +// Module-level holder for the current invocation's global flag values, set by each generated command handler before calling an action. +let _globalFlags: GlobalFlags = {} as GlobalFlags; +export function setGlobalFlags(flags: GlobalFlags): void { + _globalFlags = flags; +} +export function getGlobalFlags(): GlobalFlags { + return _globalFlags; +} + + +// GflagEchoArgs holds the positional arguments for the GflagEcho action. +export interface GflagEchoArgs { + text: string | undefined; +} + +// GflagGreetFlags holds the flag values for the GflagGreet action. +export interface GflagGreetFlags { + name?: string | undefined; +} + +// GflagSendArgs holds the positional arguments for the GflagSend action. +export interface GflagSendArgs { + recipient: string | undefined; +} + +// GflagSendFlags holds the flag values for the GflagSend action. +export interface GflagSendFlags { + count?: number | undefined; +} + diff --git a/gen/testdata/yargs/globalflags/gencli/run.ts b/gen/testdata/yargs/globalflags/gencli/run.ts new file mode 100644 index 0000000..20edff2 --- /dev/null +++ b/gen/testdata/yargs/globalflags/gencli/run.ts @@ -0,0 +1,100 @@ +// Code generated by github.com/bcdxn/opencli@(devel) DO NOT EDIT. +import yargs from "yargs"; +import { hideBin } from "yargs/helpers"; +import { newGflagPingCmd } from "./cmd-gflag-ping"; +import { newGflagEchoCmd } from "./cmd-gflag-echo"; +import { newGflagGreetCmd } from "./cmd-gflag-greet"; +import { newGflagSendCmd } from "./cmd-gflag-send"; +import { ActionsInterface } from "./actions"; +import { CommandPrintData } from "./types"; +import { CliError, ExitCode } from "./errors"; +import { defaultHelpFn, defaultUsageFn } from "./help"; + +export async function run( + argv: string[], + actions: ActionsInterface, +): Promise { + await yargs(hideBin(argv)) + .scriptName("gflag") + .help(false) + .command(newGflagPingCmd(actions)) + .command(newGflagEchoCmd(actions)) + .command(newGflagGreetCmd(actions)) + .command(newGflagSendCmd(actions)) + .option("debug", { + describe: "enable verbose debug output", + aliases: ["v"], + type: "boolean", + }) + .option("timeout", { + describe: "request timeout in seconds", + type: "number", + default: 30, + }) + .option("output-format", { + describe: "preferred output format", + type: "string", + default: "text", + }) + .demandCommand(1) + .option("help", { + alias: "h", + type: "boolean", + }) + .fail(async (msg: string, err: Error) => { + if (process.argv.includes("--help") || process.argv.includes("-h")) { + defaultHelpFn(getRootCmdHelpData()); + process.exit(0); + } + + if (err instanceof CliError) { + console.log(err.message); + defaultUsageFn(getRootCmdHelpData()); + process.exit(err.code); + } + + console.log(msg); + defaultUsageFn(getRootCmdHelpData()); + if ( + msg.includes("Missing required arguments") || + msg.includes("Not enough non-option arguments") + ) { + process.exit(ExitCode.BadUserInputErr); + } + process.exit(ExitCode.InternalErr); + }) + .parseAsync(); +} + +function getRootCmdHelpData(): CommandPrintData { + return { + segment: "gflag", + commandLine: "gflag", + summary: "A minimal spec that declares real (non-help/version) global flags to exercise the context/accessor codegen paths for every framework.", + visibleChildren: true, + visibleArgs: false, + visibleFlags: false, + commands: [ + { + segment: "ping", + commandLine: "ping", + summary: "Report that the CLI is alive", + }, + { + segment: "echo", + commandLine: "echo", + summary: "Echo a single argument back", + }, + { + segment: "greet", + commandLine: "greet", + summary: "Greet someone by name", + }, + { + segment: "send", + commandLine: "send", + summary: "Send a message the given number of times", + }, + ], + }; +} diff --git a/gen/testdata/yargs/globalflags/gencli/types.ts b/gen/testdata/yargs/globalflags/gencli/types.ts new file mode 100644 index 0000000..7d3ce13 --- /dev/null +++ b/gen/testdata/yargs/globalflags/gencli/types.ts @@ -0,0 +1,33 @@ +// Code generated by github.com/bcdxn/opencli@(devel) DO NOT EDIT. + +// CommandPrintData carries the data required to render help and usage output. +export interface CommandPrintData { + segment: string; + commandLine: string; + summary: string; + description?: string; + visibleChildren?: boolean; + visibleArgs?: boolean; + visibleFlags?: boolean; + args?: CommandHelpDataArgument[]; + flags?: CommandHelpDataFlag[]; + commandModifiers?: string[]; + argsModifiers?: string[]; + flagsModifiers?: string[]; + commands?: CommandPrintData[]; +} + +export interface CommandHelpDataArgument { + name: string; + summary: string; + isRequired: boolean; +} + +export interface CommandHelpDataFlag { + name: string; + summary: string; + isRequired?: boolean; + isVariadic?: boolean; + type?: StringConstructor | BooleanConstructor | NumberConstructor; + aliases?: string[]; +} diff --git a/go.mod b/go.mod index dc122c1..f3f281c 100644 --- a/go.mod +++ b/go.mod @@ -4,12 +4,14 @@ go 1.26.4 require ( github.com/goccy/go-yaml v1.19.2 + github.com/ohler55/ojg v1.28.4 github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 github.com/spf13/cobra v1.10.2 github.com/spf13/pflag v1.0.10 github.com/urfave/cli/v3 v3.10.1 github.com/yuin/goldmark v1.8.4 golang.org/x/text v0.40.0 + gopkg.in/yaml.v3 v3.0.1 ) require github.com/dlclark/regexp2/v2 v2.2.1 // indirect diff --git a/go.sum b/go.sum index c9ed76e..afee53d 100644 --- a/go.sum +++ b/go.sum @@ -55,6 +55,8 @@ github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwX github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/ohler55/ojg v1.28.4 h1:KVmO+KWnm4IU3bgKsp3pGUkyepoHnon5TwrvnlGx5Ek= +github.com/ohler55/ojg v1.28.4/go.mod h1:/Y5dGWkekv9ocnUixuETqiL58f+5pAsUfg5P8e7Pa2o= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= @@ -90,6 +92,7 @@ golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/cli/gencli/cmd_ocli_gen_cli.gen.go b/internal/cli/gencli/cmd_ocli_gen_cli.gen.go index ec8566a..55b7f17 100644 --- a/internal/cli/gencli/cmd_ocli_gen_cli.gen.go +++ b/internal/cli/gencli/cmd_ocli_gen_cli.gen.go @@ -20,7 +20,7 @@ func NewCmdOcliGenCli(a ActionsInterface) *cobra.Command { } cmdFlags := OcliGenCliFlags{ Out: flagOut, - Framework: OcliGenCliFramework(flagFramework), + Framework: OcliGenCliFramework(resolveStringFlag(c.Flags(), "framework", []AltSource{{Type: "$ENV", Property: "OCLI_CLI_FRAMEWORK"}, {Type: "$FILE", Property: "$.cli.framework"}})), } if cmdFlags.Framework != "" && !cmdFlags.Framework.IsValid() { return BadUserInput("invalid value for --framework flag: "+string(cmdFlags.Framework), func() error { diff --git a/internal/cli/gencli/cmd_ocli_gen_docs.gen.go b/internal/cli/gencli/cmd_ocli_gen_docs.gen.go index 05eea22..6b8ede5 100644 --- a/internal/cli/gencli/cmd_ocli_gen_docs.gen.go +++ b/internal/cli/gencli/cmd_ocli_gen_docs.gen.go @@ -21,7 +21,7 @@ func NewCmdOcliGenDocs(a ActionsInterface) *cobra.Command { cmdArgs.PathToSpec = args[0] } cmdFlags := OcliGenDocsFlags{ - Format: OcliGenDocsFormat(flagFormat), + Format: OcliGenDocsFormat(resolveStringFlag(c.Flags(), "format", []AltSource{{Type: "$ENV", Property: "OCLI_DOCS_FORMAT"}, {Type: "$FILE", Property: "$.docs.format"}})), Out: flagOut, NoFooter: flagNoFooter, NoBadge: flagNoBadge, diff --git a/internal/cli/gencli/config.gen.go b/internal/cli/gencli/config.gen.go new file mode 100644 index 0000000..0f20e62 --- /dev/null +++ b/internal/cli/gencli/config.gen.go @@ -0,0 +1,295 @@ +// Code generated by github.com/bcdxn/opencli@unknown version DO NOT EDIT. +package gencli + +import ( + "github.com/ohler55/ojg/jp" + "gopkg.in/yaml.v3" + "math" + "os" + "path/filepath" + "strconv" + "strings" + + "github.com/spf13/pflag" +) + +// AltSource is a single alternative source for a flag value: either an +// environment variable ($ENV) or a property in the config file ($FILE). +type AltSource struct { + Type string // "$ENV" or "$FILE" + Property string // env var name, or JSONPath into the config file +} + +// globalConfig holds the parsed config file data as a nested map. It is loaded +// lazily once via loadConfig at startup and shared by all $FILE lookups. +var globalConfig map[string]any + +// expandTilde expands a leading tilde (~) in the path to the user's home directory. +func expandTilde(path string) string { + if !strings.HasPrefix(path, "~") { + return path + } + home, err := os.UserHomeDir() + if err != nil { + return path + } + if path == "~" { + return home + } + return filepath.Join(home, path[1:]) +} + +// loadConfig loads the config file from the first available path (JSON > YAML > TOML). +func loadConfig() { + if globalConfig != nil { + return + } + globalConfig = make(map[string]any) + // Try YAML config + if data, err := os.ReadFile(expandTilde("~/.ocli/config.yaml")); err == nil { + var cfg map[string]any + if err := yaml.Unmarshal(data, &cfg); err == nil { + globalConfig = cfg + return + } + } +} + +// resolveJSONPath resolves a JSONPath expression against the global config using +// the ohler55/ojg JSONPath implementation. It returns the value at the path, or nil +// if the path does not match (or no config was loaded). +func resolveJSONPath(expr string) any { + if globalConfig == nil { + return nil + } + p, err := jp.ParseString(expr) + if err != nil { + return nil + } + return p.First(globalConfig) +} + +// altSourceValue returns the raw value from a single alternative source, or nil if +// the source yields nothing. $ENV sources yield the environment variable's string +// value; $FILE sources yield the value at the given JSONPath. +func altSourceValue(src AltSource) any { + switch src.Type { + case "$ENV": + if val := os.Getenv(src.Property); val != "" { + return val + } + case "$FILE": + if val := resolveJSONPath(src.Property); val != nil { + return val + } + } + return nil +} + +// altSourceItems returns the raw element values from a single alternative source for +// a variadic flag. $ENV sources are comma-separated; $FILE sources are arrays (a +// single value is treated as a one-element list). +func altSourceItems(src AltSource) []any { + switch src.Type { + case "$ENV": + variable := os.Getenv(src.Property) + if variable == "" { + return nil + } + parts := strings.Split(variable, ",") + items := make([]any, 0, len(parts)) + for _, p := range parts { + if trimmed := strings.TrimSpace(p); trimmed != "" { + items = append(items, trimmed) + } + } + return items + case "$FILE": + switch v := resolveJSONPath(src.Property).(type) { + case []any: + return v + case nil: + return nil + default: + return []any{v} + } + } + return nil +} + +// Coercion helpers convert a raw source value to a concrete Go type. Each returns +// ok=false when the value cannot be interpreted as the target type, so resolvers can +// skip it and try the next alternative source in order. + +func toString(v any) (string, bool) { + if s, ok := v.(string); ok && s != "" { + return s, true + } + return "", false +} + +func toInt64(v any) (int64, bool) { + switch n := v.(type) { + case int64: + return n, true + case float64: + if n != math.Trunc(n) || n < math.MinInt64 || n > math.MaxInt64 { + return 0, false + } + return int64(n), true + case string: + if i, err := strconv.ParseInt(n, 10, 64); err == nil { + return i, true + } + } + return 0, false +} + +func toBool(v any) (bool, bool) { + switch b := v.(type) { + case bool: + return b, true + case string: + if parsed, err := strconv.ParseBool(b); err == nil { + return parsed, true + } + } + return false, false +} + +func toFloat64(v any) (float64, bool) { + switch n := v.(type) { + case float64: + return n, true + case int64: + return float64(n), true + case string: + if parsed, err := strconv.ParseFloat(n, 64); err == nil { + return parsed, true + } + } + return 0, false +} + +/* +Flag Resolver Functions + +These functions are called from generated command code to resolve flag values. When the +flag was provided on the command line (fs.Changed) its CLI value is used as-is; otherwise +the value is resolved from the flag's alternative sources (environment variables, config +file) in the order they are declared, falling back to the flag's bound default when no +source yields a usable result. The pflag getters return that bound default for unchanged +flags (or the zero value if none was declared). The merged pflag.FlagSet passed in already +contains both local and inherited persistent flags at RunE time. +*/ + +// resolveStringFlag resolves a string flag from the CLI or its alternative sources, falling +// back to the bound default when no source yields a usable result. +func resolveStringFlag(fs *pflag.FlagSet, name string, sources []AltSource) string { + if fs.Changed(name) { + val, _ := fs.GetString(name) + return val + } + for _, src := range sources { + if v, ok := toString(altSourceValue(src)); ok { + return v + } + } + defaultVal, _ := fs.GetString(name) + return defaultVal +} + +// resolveInt64Flag resolves an int64 flag from the CLI or its alternative sources, falling +// back to the bound default when no source yields a usable result. +func resolveInt64Flag(fs *pflag.FlagSet, name string, sources []AltSource) int64 { + if fs.Changed(name) { + val, _ := fs.GetInt64(name) + return val + } + for _, src := range sources { + if v, ok := toInt64(altSourceValue(src)); ok { + return v + } + } + defaultVal, _ := fs.GetInt64(name) + return defaultVal +} + +// resolveBoolFlag resolves a bool flag from the CLI or its alternative sources. An explicit +// --flag=false on the command line is honored (fs.Changed); omitting the flag consults the +// alternative sources, matching urfave/cli behavior for cross-framework parity. Falls back +// to the bound default when no source yields a usable result. +func resolveBoolFlag(fs *pflag.FlagSet, name string, sources []AltSource) bool { + if fs.Changed(name) { + val, _ := fs.GetBool(name) + return val + } + for _, src := range sources { + if v, ok := toBool(altSourceValue(src)); ok { + return v + } + } + defaultVal, _ := fs.GetBool(name) + return defaultVal +} + +// resolveFloat64Flag resolves a float64 flag from the CLI or its alternative sources, falling +// back to the bound default when no source yields a usable result. +func resolveFloat64Flag(fs *pflag.FlagSet, name string, sources []AltSource) float64 { + if fs.Changed(name) { + val, _ := fs.GetFloat64(name) + return val + } + for _, src := range sources { + if v, ok := toFloat64(altSourceValue(src)); ok { + return v + } + } + defaultVal, _ := fs.GetFloat64(name) + return defaultVal +} + +// resolveSliceFlag resolves a variadic flag from the CLI or its alternative sources. When +// the flag was not set on the command line it returns the first source that yields at least +// one value coercible to T; otherwise the bound default (an empty slice if none was declared). +// get is the bound pflag getter for the concrete element type (e.g. fs.GetStringArray). +func resolveSliceFlag[T any](fs *pflag.FlagSet, name string, sources []AltSource, get func(string) ([]T, error), coerce func(any) (T, bool)) []T { + if fs.Changed(name) { + val, _ := get(name) + return val + } + for _, src := range sources { + items := altSourceItems(src) + result := make([]T, 0, len(items)) + for _, item := range items { + if v, ok := coerce(item); ok { + result = append(result, v) + } + } + if len(result) > 0 { + return result + } + } + defaultVal, _ := get(name) + return defaultVal +} + +// resolveStringSliceFlag resolves a string slice flag from the CLI or its alternative sources. +func resolveStringSliceFlag(fs *pflag.FlagSet, name string, sources []AltSource) []string { + return resolveSliceFlag(fs, name, sources, fs.GetStringArray, toString) +} + +// resolveInt64SliceFlag resolves an int64 slice flag from the CLI or its alternative sources. +func resolveInt64SliceFlag(fs *pflag.FlagSet, name string, sources []AltSource) []int64 { + return resolveSliceFlag(fs, name, sources, fs.GetInt64Slice, toInt64) +} + +// resolveBoolSliceFlag resolves a bool slice flag from the CLI or its alternative sources. +func resolveBoolSliceFlag(fs *pflag.FlagSet, name string, sources []AltSource) []bool { + return resolveSliceFlag(fs, name, sources, fs.GetBoolSlice, toBool) +} + +// resolveFloat64SliceFlag resolves a float64 slice flag from the CLI or its alternative sources. +func resolveFloat64SliceFlag(fs *pflag.FlagSet, name string, sources []AltSource) []float64 { + return resolveSliceFlag(fs, name, sources, fs.GetFloat64Slice, toFloat64) +} diff --git a/internal/cli/gencli/run.go b/internal/cli/gencli/run.gen.go similarity index 88% rename from internal/cli/gencli/run.go rename to internal/cli/gencli/run.gen.go index 9c683a0..fa0fd29 100644 --- a/internal/cli/gencli/run.go +++ b/internal/cli/gencli/run.gen.go @@ -9,6 +9,8 @@ import ( // Run executes the root cobra command and returns an exit code. func Run(ctx context.Context, actions ActionsInterface) int { + // Load config file from disk (JSON/YAML/TOML) for $FILE alternative sources + loadConfig() // Instantiate root command rootCmd := NewCmdOcli(actions) // Add version for `--version` flag diff --git a/opencli.ocs.yaml b/ocli.ocs.yaml similarity index 100% rename from opencli.ocs.yaml rename to ocli.ocs.yaml diff --git a/web/public/assets/code/actions.txt b/web/public/assets/code/actions.txt new file mode 100644 index 0000000..5cb8994 --- /dev/null +++ b/web/public/assets/code/actions.txt @@ -0,0 +1,40 @@ +import { ActionsInterface } from "./gencli/actions"; +import { CommandPrintData } from "./gencli/types"; +import { + PleasantriesFarewellArgs, + PleasantriesFarewellFlags, + PleasantriesGreetArgs, + PleasantriesGreetFlags, +} from "./gencli/params"; +import { defaultHelpFn, defaultUsageFn } from "./gencli/help"; + +export class Actions implements ActionsInterface { + async PleasantriesGreet( + args: PleasantriesGreetArgs, + flags: PleasantriesGreetFlags, + ): Promise { + if (flags.language == "english") { + console.log("hello", args.name); + } else { + console.log("hola", args.name); + } + } + + async PleasantriesFarewell( + args: PleasantriesFarewellArgs, + flags: PleasantriesFarewellFlags, + ): Promise { + if (flags.language == "english") { + console.log("good bye", args.name); + } else { + console.log("adios", args.name); + } + } + + help(cmd: CommandPrintData): void { + defaultHelpFn(cmd); + } + usage(cmd: CommandPrintData): void { + defaultUsageFn(cmd); + } +} diff --git a/web/src/app/docs/code-generation-yargs/page.tsx b/web/src/app/docs/code-generation-yargs/page.tsx new file mode 100644 index 0000000..d9c1606 --- /dev/null +++ b/web/src/app/docs/code-generation-yargs/page.tsx @@ -0,0 +1,22 @@ +import GeneratingYargsCode from "../../../views/GeneratingYargsCode"; + +export const metadata = { + title: "OpenCLI Specification | Docs - TS Code Gen", + description: + "Learn how to generate a TypeScript CLI using Yargs from an OpenCLI Specification document.", + alternates: { + canonical: "/docs/code-generation-yargs", + }, + keywords: [ + "opencli", + "open cli", + "opencli specification", + "generate cli typescript code", + "generate yargs cli", + "yargs node.js cli", + ], +}; + +export default function Page() { + return ; +} diff --git a/web/src/views/GenHtmlDocs.tsx b/web/src/views/GenHtmlDocs.tsx index bf50544..f994e2a 100644 --- a/web/src/views/GenHtmlDocs.tsx +++ b/web/src/views/GenHtmlDocs.tsx @@ -252,6 +252,9 @@ export default function GuidePage() { Code Generation (Go) + + Code Generation (TS) + diff --git a/web/src/views/GenManDocs.tsx b/web/src/views/GenManDocs.tsx index ddff134..08393d5 100644 --- a/web/src/views/GenManDocs.tsx +++ b/web/src/views/GenManDocs.tsx @@ -302,6 +302,9 @@ export default function GuidePage() { Code Generation (Go) + + Code Generation (TS) + diff --git a/web/src/views/GenMarkdownDocs.tsx b/web/src/views/GenMarkdownDocs.tsx index c2c3ac7..c4d4356 100644 --- a/web/src/views/GenMarkdownDocs.tsx +++ b/web/src/views/GenMarkdownDocs.tsx @@ -239,6 +239,9 @@ export default function GuidePage() { Code Generation (Go) + + Code Generation (TS) + diff --git a/web/src/views/GeneratingGoCode.tsx b/web/src/views/GeneratingGoCode.tsx index ee8837c..6164155 100644 --- a/web/src/views/GeneratingGoCode.tsx +++ b/web/src/views/GeneratingGoCode.tsx @@ -76,8 +76,8 @@ function GeneratingGoCodePage() { Generating A Go CLI From OpenCLI Specs

- Turn a declarative OpenCLI Specification into framework-specific, - production-ready CLI code — then implement only the business logic. + Turn a declarative OpenCLI Specification into production-ready CLI Go + code — then implement only the business logic.

Tip: Support is currently available for{" "} @@ -276,7 +276,7 @@ function GeneratingGoCodePage() { command produces all the scaffolding. We'll generate a urfave/cli-based CLI here, but the same process works for Cobra. If you want to see a JS/TS example checkout the{" "} - Generating TS Code docs. + Code Generation (TS) docs.

@@ -397,7 +397,7 @@ function GeneratingGoCodePage() { Action handler delegates to the corresponding function on our struct implementing the{" "} ActionsInterface shown - below. But in general you can treat these generated command file as + below. But in general you can treat these generated command files as black boxes.

@@ -523,6 +523,56 @@ function GeneratingGoCodePage() {

+

+ If your OpenCLI document declares root-level{" "} + global flags, they're + available to every action — but not as a method parameter. The + generated handler builds a{" "} + GlobalFlags value and + injects it into the context before calling your action (identical + for Cobra and urfave/cli), so you retrieve it with an exported + helper from the gencli package: +

+ + + +
+

+ The GlobalFlags type + and context helpers are emitted into{" "} + params.gen.go whenever + your document declares root-level flags. Note that{" "} + GlobalFlagsFromContext{" "} + returns zero values if no globals were set (e.g., when calling an + action directly from a test without wrapping the context) — if you + need to distinguish “absent” from “zero”, + wrap it yourself with{" "} + + gencli.WithGlobalFlags(ctx, g) + {" "} + in tests; that's what the generated handler does at runtime. +

+
+

Finally, wire up the helper methods using sensible defaults provided by the generated code (or replace them with custom implementations @@ -704,6 +754,9 @@ export default function GuidePage() { > Code Generation (Go) + + Code Generation (TS) + diff --git a/web/src/views/GeneratingYargsCode.tsx b/web/src/views/GeneratingYargsCode.tsx new file mode 100644 index 0000000..dd51ac6 --- /dev/null +++ b/web/src/views/GeneratingYargsCode.tsx @@ -0,0 +1,701 @@ +"use client"; + +import React, { useState, useCallback } from "react"; +import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; +import { nord } from "react-syntax-highlighter/dist/esm/styles/prism"; +import SiteHeader from "../components/SiteHeader"; +import "./Docs.css"; + +// ── Highlighted Code Block (with syntax highlighting) ───────────────────────── + +function HighlightedCodeBlock({ + lines, + language, +}: { + lines: React.ReactNode[]; + language: string; +}) { + const [copied, setCopied] = useState(false); + + // Extract plain text for copy + const getPlainText = useCallback(() => { + const tempDiv = document.createElement("div"); + lines.forEach((line) => { + if (typeof line === "string") tempDiv.textContent += line; + else if (React.isValidElement(line)) { + const children = line.props; //?.children; + if (Array.isArray(children)) { + children.forEach((c) => { + if (typeof c === "string") tempDiv.textContent += c; + }); + } else if (typeof children === "string") { + tempDiv.textContent += children; + } + } + }); + return tempDiv.textContent || ""; + }, [lines]); + + const handleCopy = useCallback(() => { + navigator.clipboard.writeText(getPlainText()).then(() => { + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }); + }, [getPlainText]); + + return ( +

+
+ {language} + +
+
+ + {lines.join("\n")} + +
+
+ ); +} + +// ── Code Generation (TS) Page ─────────────────────────────────────────────── + +function GeneratingYargsCodePage() { + return ( + <> +

+ Generating A TypeScript CLI From OpenCLI Specs +

+

+ Turn a declarative OpenCLI Specification into production-ready CLI code + using the Yargs framework. +

+
+ Tip: Support is currently available for{" "} + +

+ Want to add support for your favorite CLI framework? Open an{" "} + + issue + {" "} + or submit a{" "} + + pull request + + . +

+
+ + {/* Step 1 */} +
+
1
+
+

Install the CLI

+

+ If you haven't already, install the{" "} + ocli tool: +

+ + +
+
+ + {/* The OpenCLI Document */} +
+
2
+
+

Define your OpenCLI Document

+

+ Every OpenCLI-powered project starts with a spec-compliant YAML (or + JSON) file. For this walkthrough we'll use the{" "} + + pleasantries-cli.ocs.yaml + {" "} + example from the{" "} + + OpenCLI GitHub repository + + , a small CLI for greeting and bidding farewell to people by name. +

+ + [flags]:`, + ` kind: group`, + ``, + ` pleasantries greet [flags]:`, + ` summary: "Say hello"`, + ` args:`, + ` - name: "name"`, + ` summary: "A name to include in the greeting"`, + ` required: true`, + ` type: "string"`, + ` flags:`, + ` - name: "language"`, + ` summary: "The language of the greeting"`, + ` type: "string"`, + ` choices:`, + ` - value: "english"`, + ` - value: "spanish"`, + ` default: "english"`, + ``, + ` pleasantries farewell [flags]:`, + ` summary: "Say goodbye"`, + ` # ... same shape as greet, but for farewells`, + ]} + /> + +

+ You can find the full example document{" "} + + here + {" "} + and explore the complete specification schema at{" "} + opencli.dev/specification. +

+
+
+ + {/* Step 3: Initialize the Project */} +
+
3
+
+

Initialize the Project

+

+ Set up a fresh Node.js project and pull in the pleasantries spec: +

+ + + +

Then install the runtime and dev dependencies:

+ + + +
+

+ The generated code uses{" "} + command-line-usage to + render help and usage output, so it's a required dependency. +

+
+ +

That's it for setup — one spec file, one package.

+
+
+ + {/* Step 4: Generate Boilerplate Code */} +
+
4
+
+

Generate Boilerplate Code

+

+ A single ocli gen cli{" "} + command produces all the scaffolding: +

+ + + +

+ All generated code is encapsulated in the{" "} + gencli directory. Each + command gets its own file, plus supporting files for bootstrapping, + error handling, and help rendering: +

+ + + +
+

+ Key insight: the generated code defines an{" "} + ActionsInterface. The + interface creates a contract that maps methods one-to-one with + every command in your spec along with some convenience methods. + Your job is simply to implement that contract and those methods. +

+
+ +

+ Let's take a look at the all-important{" "} + src/gencli/actions.ts. It + defines one method per command, plus helpers for help and usage: +

+ + ;`, + ` PleasantriesFarewell(args: PleasantriesFarewellArgs, flags: PleasantriesFarewellFlags): Promise;`, + ` help(cmd: CommandPrintData): void;`, + ` usage(cmd: CommandPrintData): void;`, + `}`, + ]} + /> + +
+

+ Look at your generated{" "} + src/gencli/actions.ts{" "} + to see the full interface we'll need to implement. +

+
+ +

+ Notice that the methods we need to implement have no + framework-dependencies injected. We could reuse our same{" "} + ActionsInterface{" "} + implementation for multiple frameworks within the same language (or + port it across languages entirely). +

+ +

+ The generated types for{" "} + args and{" "} + flags are strongly typed, + so you get compile-time safety — no more typos in flag names or + mismatched types. Flags with choices even become enums: +

+ + + +

+ Next we can take a look at the generated command files, like{" "} + + src/gencli/cmd-pleasantries-greet.ts + + . Each generated command file adapts our ActionsInterface methods, + handling the framework specifics of parsing args and flags and + passing them to our framework-agnostic implementations. If + you're interested, you can look at a generated file to see how the{" "} + handler delegates to the + corresponding method on your class implementing the{" "} + ActionsInterface. But in + general you can treat these generated command files as black boxes. +

+ + {`, + ` const cmdArgs: PleasantriesGreetArgs = { name: argv.name };`, + ` const cmdFlags: PleasantriesGreetFlags = { language: argv.language as PleasantriesGreetLanguage };`, + ` return actions.PleasantriesGreet(cmdArgs, cmdFlags);`, + `},`, + ]} + /> +
+
+ + {/* Step 5: Implement the Actions Interface */} +
+
5
+
+

Implement the Actions Interface

+

+ This is where you write your actual business logic. Create a class + that satisfies{" "} + ActionsInterface. The + pattern feels familiar if you've used{" "} + + oapi-codegen + {" "} + with OpenAPI specs. +

+ +

+ Start by creating a new file for your implementation to keep it + separate from the generated code in the{" "} + gencli package: +

+ + + +

+ Define your Actions{" "} + class: +

+ + + +

+ Now implement each method to fulfill the interface. For + demonstration we'll keep the bodies simple — in a real project this + is where you'd call your API, hit a database, or orchestrate + whatever your CLI is designed to do: +

+ + {`, + ` if (flags.language == "english") {`, + ` console.log("hello", args.name);`, + ` } else {`, + ` console.log("hola", args.name);`, + ` }`, + `}`, + ``, + `async PleasantriesFarewell(args: PleasantriesFarewellArgs, flags: PleasantriesFarewellFlags): Promise {`, + ` if (flags.language == "english") {`, + ` console.log("good bye", args.name);`, + ` } else {`, + ` console.log("adios", args.name);`, + ` }`, + `}`, + ]} + /> + +
+

+ You can download a full example implementation{" "} + here. +

+
+ +

+ If your OpenCLI document declares root-level{" "} + global flags, they're not + passed to action methods either. Yargs has no context object, so + codegen instead exports a pair of accessors from{" "} + params.ts: the generated + handler calls{" "} + setGlobalFlags(...){" "} + immediately before invoking your action, and you read them with{" "} + getGlobalFlags() inside: +

+ + {`, + ` const name = args.name; // positional arg — arrives as a parameter`, + ``, + ` // Global (root-level) flags come from the module accessor, not the method signature.`, + ` const global = getGlobalFlags();`, + ` if (global.debug) { // e.g., for a root-level --debug flag declared in your spec`, + ` console.error(\`greeting \${name} in debug mode\`);`, + ` }`, + `}`, + ]} + /> + +
+

+ These types and accessors are only emitted when your document + declares root-level flags. Yargs parameter fields are typed{" "} + T | undefined even for + required args, so guard values with{" "} + ?? or an explicit check + rather than assuming presence (e.g.,{" "} + global.timeout ?? 30 + ). The accessor is a module-level singleton set per invocation by + the generated handler — safe under normal sequential CLI use. +

+
+ +

+ Finally, wire up the helper methods using sensible defaults provided + by the generated code (or replace them with custom implementations + if you need tailored behavior): +

+ + + +
+

+ Benefits of this approach: your spec is the + contract, your business logic has zero dependencies on any CLI + framework, and documentation stays in sync with the OpenCLI Spec + document as the source of truth. +

+
+
+
+ + {/* Step 6: Wire Up the Entry Point */} +
+
6
+
+

Wire Up the Entry Point

+

+ The final piece is a minimal{" "} + src/index.ts: +

+ + {`, + ` console.error(err);`, + ` process.exit(1);`, + `});`, + ]} + /> + +

+ Just a handful of lines of substance, and critically — no framework + dependencies in your user-land code. +

+
+
+ + {/* Step 7: Try it Out */} +
+
7
+
+

Try It Out

+

That's the entire application. Let's build and run it:

+ + + + + +

+ A fully functional CLI with zero framework coupling in your business + logic. The spec defined the interface,{" "} + ocli generated the + scaffolding, and you implemented the business logic. +

+
+
+ + {/* Next steps */} +
+

What's next?

+ +
+ + ); +} + +// ── Main Component ──────────────────────────────────────────────────────────── + +export default function GuidePage() { + return ( +
+ + +
+ ); +} diff --git a/web/src/views/GettingStarted.tsx b/web/src/views/GettingStarted.tsx index b155997..3963d77 100644 --- a/web/src/views/GettingStarted.tsx +++ b/web/src/views/GettingStarted.tsx @@ -322,6 +322,9 @@ export default function GuidePage() { Code Generation (Go) + + Code Generation (TS) + diff --git a/web/tsconfig.json b/web/tsconfig.json index b575f7d..e7524f2 100644 --- a/web/tsconfig.json +++ b/web/tsconfig.json @@ -36,6 +36,7 @@ ".next/dev/types/**/*.ts" ], "exclude": [ - "node_modules" + "node_modules", + "public" ] }