From b6ac9094dd9613bb528396b9d72dfdc5ef3f1c6c Mon Sep 17 00:00:00 2001 From: bcdxn <5067649+bcdxn@users.noreply.github.com> Date: Fri, 14 Aug 2026 13:16:05 -0400 Subject: [PATCH 01/22] alt sources --- gen/cli_urfave_cli.go | 156 +++++- .../code/urfavecli/gencli/command.tmpl | 2 +- .../code/urfavecli/gencli/config.tmpl | 469 ++++++++++++++++++ gen/templates/code/urfavecli/gencli/run.tmpl | 3 + .../gencli/cmd_petstore_user_login.gen.go | 4 +- gen/testdata/urfavecli/gencli/config.gen.go | 458 +++++++++++++++++ gen/testdata/urfavecli/gencli/run.gen.go | 3 + 7 files changed, 1066 insertions(+), 29 deletions(-) create mode 100644 gen/templates/code/urfavecli/gencli/config.tmpl create mode 100644 gen/testdata/urfavecli/gencli/config.gen.go diff --git a/gen/cli_urfave_cli.go b/gen/cli_urfave_cli.go index a590075..4b01025 100644 --- a/gen/cli_urfave_cli.go +++ b/gen/cli_urfave_cli.go @@ -20,6 +20,12 @@ 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 + // HasConfig is true if any config file path is declared + HasConfig bool } // urfaveCliCommandFileTmplData is the template data passed to command.tmpl. @@ -40,17 +46,25 @@ type urfaveCliArgEntry struct { TypeName string // non-empty when the struct field uses a generated type (needs cast) } +// urfaveCliAltSource represents an alternative source for a flag value ($ENV or $FILE). +type urfaveCliAltSource struct { + Type string // "$ENV" or "$FILE" + Property string // env var name or JSONPath in config file +} + // urfaveCliFlagEntry describes how to bind a flag in an urfave command. type urfaveCliFlagEntry struct { - FieldName string - FlagName string - GoType string - UrfaveFlag string // e.g. "cli.StringFlag", "cli.Int64Flag" - Default string // Go literal for the default value - Summary string - 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. + FieldName string + FlagName string + GoType string + UrfaveFlag string // e.g. "cli.StringFlag", "cli.Int64Flag" + Default string // Go literal for the default value + Summary string + 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 []urfaveCliAltSource // alternative sources from flag's alternativeSources + HasAltSource bool // true if AltSources is non-empty } //go:embed templates/code/urfavecli @@ -77,21 +91,30 @@ func genCLIUrfaveCli(doc *spec.Document, opts *genCLIOptions) (map[string][]byte var exitCodes []spec.ExitCode var globalFlags []urfaveCliFlagEntry + var configJSON, configTOML, configYAML string + hasConfig := false if doc.Global != nil { exitCodes = doc.Global.ExitCodes + configJSON = doc.Global.Config.JSON + configTOML = doc.Global.Config.TOML + configYAML = doc.Global.Config.YAML + hasConfig = configJSON != "" || configTOML != "" || configYAML != "" for _, flag := range doc.Global.Flags { if flag.Name == "help" || flag.Name == "version" { continue } + altSources := urfaveCliAltSources(flag.AltSources) globalFlags = append(globalFlags, urfaveCliFlagEntry{ - FieldName: toPascalCase(flag.Name), - FlagName: flag.Name, - GoType: toGoType(flag.Type, flag.Variadic), - UrfaveFlag: urfaveCliFlagStruct(flag.Type, flag.Variadic), - Default: urfaveCliDefaultVal(flag.Default, flag.Type, flag.Variadic), - Summary: flag.Summary, - Aliases: flag.Aliases, - Accessor: urfaveCliAccessor(flag.Type, flag.Variadic), + FieldName: toPascalCase(flag.Name), + FlagName: flag.Name, + GoType: toGoType(flag.Type, flag.Variadic), + UrfaveFlag: urfaveCliFlagStruct(flag.Type, flag.Variadic), + Default: urfaveCliDefaultVal(flag.Default, flag.Type, flag.Variadic), + Summary: flag.Summary, + Aliases: flag.Aliases, + Accessor: urfaveCliAccessor(flag.Type, flag.Variadic), + AltSources: altSources, + HasAltSource: len(altSources) > 0, }) } } @@ -103,6 +126,10 @@ func genCLIUrfaveCli(doc *spec.Document, opts *genCLIOptions) (map[string][]byte LeafCommands: leafCommands, ExitCodes: exitCodes, GlobalFlags: globalFlags, + ConfigJSON: configJSON, + ConfigTOML: configTOML, + ConfigYAML: configYAML, + HasConfig: hasConfig, } funcMap := urfaveCliTemplateFuncMap() @@ -113,6 +140,7 @@ func genCLIUrfaveCli(doc *spec.Document, opts *genCLIOptions) (map[string][]byte } gencliFiles := []gencliFile{ {"gencli/actions.gen.go", "templates/code/urfavecli/gencli/actions.tmpl"}, + {"gencli/config.gen.go", "templates/code/urfavecli/gencli/config.tmpl"}, {"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"}, @@ -253,21 +281,24 @@ func walkUrfaveCliCmdTree( if len(flag.Choices) > 0 && (flag.Type == "string" || flag.Type == "") && !flag.Variadic { flagTypeName = methodName + toPascalCase(flag.Name) } + altSources := urfaveCliAltSources(flag.AltSources) specFlags = append(specFlags, specFlagEntry{ Name: flag.Name, Summary: flag.Summary, Aliases: flag.Aliases, }) urfaveFlags = append(urfaveFlags, urfaveCliFlagEntry{ - FieldName: toPascalCase(flag.Name), - FlagName: flag.Name, - GoType: toGoType(flag.Type, flag.Variadic), - UrfaveFlag: urfaveCliFlagStruct(flag.Type, flag.Variadic), - Default: urfaveCliDefaultVal(flag.Default, flag.Type, flag.Variadic), - Summary: flag.Summary, - TypeName: flagTypeName, - Aliases: flag.Aliases, - Accessor: urfaveCliAccessor(flag.Type, flag.Variadic), + FieldName: toPascalCase(flag.Name), + FlagName: flag.Name, + GoType: toGoType(flag.Type, flag.Variadic), + UrfaveFlag: urfaveCliFlagStruct(flag.Type, flag.Variadic), + Default: urfaveCliDefaultVal(flag.Default, flag.Type, flag.Variadic), + Summary: flag.Summary, + TypeName: flagTypeName, + Aliases: flag.Aliases, + Accessor: urfaveCliAccessor(flag.Type, flag.Variadic), + AltSources: altSources, + HasAltSource: len(altSources) > 0, }) } @@ -333,6 +364,51 @@ func urfaveCliTemplateFuncMap() template.FuncMap { "goString": func(s string) string { return fmt.Sprintf("%q", s) }, + "resolveFlagValue": func(f urfaveCliFlagEntry) string { + if !f.HasAltSource { + // No alternative sources — read directly from CLI + 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) + } + // Has alternative sources — read from CLI first, fall back to env/config + // We use a helper that checks if the CLI value is the zero value + switch f.GoType { + case "string": + srcs := formatAltSources(f.AltSources) + if f.TypeName != "" { + return fmt.Sprintf("%s(resolveStringFlag(c.String(%q), %s))", f.TypeName, f.FlagName, srcs) + } + return fmt.Sprintf("resolveStringFlag(c.String(%q), %s)", f.FlagName, srcs) + case "int64": + srcs := formatAltSources(f.AltSources) + return fmt.Sprintf("resolveInt64Flag(c.Int64(%q), %s)", f.FlagName, srcs) + case "bool": + srcs := formatAltSources(f.AltSources) + return fmt.Sprintf("resolveBoolFlag(c.Bool(%q), %s)", f.FlagName, srcs) + case "float64": + srcs := formatAltSources(f.AltSources) + return fmt.Sprintf("resolveFloat64Flag(c.Float64(%q), %s)", f.FlagName, srcs) + case "[]string": + srcs := formatAltSources(f.AltSources) + return fmt.Sprintf("resolveStringSliceFlag(c.StringSlice(%q), %s)", f.FlagName, srcs) + case "[]int64": + srcs := formatAltSources(f.AltSources) + return fmt.Sprintf("resolveInt64SliceFlag(c.Int64Slice(%q), %s)", f.FlagName, srcs) + case "[]bool": + srcs := formatAltSources(f.AltSources) + return fmt.Sprintf("resolveBoolSliceFlag(c.BoolSlice(%q), %s)", f.FlagName, srcs) + case "[]float64": + srcs := formatAltSources(f.AltSources) + return fmt.Sprintf("resolveFloat64SliceFlag(c.Float64Slice(%q), %s)", f.FlagName, srcs) + default: + 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) + } + }, } } @@ -463,3 +539,31 @@ func urfaveCliDefaultVal(val any, t string, variadic bool) string { panic(fmt.Sprintf("unsupported type: must be a slice of string, int, float64, or bool - %T", val)) } } + +// urfaveCliAltSources converts spec.AlternativeSource slices to urfaveCliAltSource slices. +func urfaveCliAltSources(sources []spec.AlternativeSource) []urfaveCliAltSource { + if len(sources) == 0 { + return nil + } + result := make([]urfaveCliAltSource, 0, len(sources)) + for _, s := range sources { + result = append(result, urfaveCliAltSource{ + Type: s.Type, + Property: s.Property, + }) + } + return result +} + +// formatAltSources formats a slice of urfaveCliAltSource as a Go composite literal +// for use in generated template code. +func formatAltSources(sources []urfaveCliAltSource) 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("[]struct{Type,Property string}{%s}", strings.Join(parts, ",")) +} diff --git a/gen/templates/code/urfavecli/gencli/command.tmpl b/gen/templates/code/urfavecli/gencli/command.tmpl index ce5d043..92e7e02 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}} diff --git a/gen/templates/code/urfavecli/gencli/config.tmpl b/gen/templates/code/urfavecli/gencli/config.tmpl new file mode 100644 index 0000000..cea8ccb --- /dev/null +++ b/gen/templates/code/urfavecli/gencli/config.tmpl @@ -0,0 +1,469 @@ +// Code generated by github.com/bcdxn/opencli@{{.ModuleVersion}} DO NOT EDIT. +package gencli + +import ( + "encoding/json" + "os" + "os/user" + "path/filepath" + "strconv" + "strings" +{{- if .ConfigYAML}} + "gopkg.in/yaml.v3" +{{- end}} +{{- if .ConfigTOML}} + "github.com/BurntSushi/toml" +{{- end}} +) + +// globalConfig holds the parsed config file data as a nested map. +var globalConfig map[string]any + +// expandTilde expands a tilde (~) in the path to the user's home directory. +func expandTilde(path string) string { + if !strings.HasPrefix(path, "~") { + return path + } + if path == "~" { + home, err := os.UserHomeDir() + if err != nil { + return path + } + return home + } + home, err := os.UserHomeDir() + if err != nil { + return path + } + 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 { + if err := json.Unmarshal(data, &globalConfig); err == nil { + 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}} +} + +// resolveJSONPath resolves a JSONPath expression against the global config. +// Supports simple paths like $.key, $.key.subkey, and $.key[0]. +func resolveJSONPath(path string) any { + if globalConfig == nil { + return nil + } + + // Strip leading $ + path = strings.TrimPrefix(path, "$") + + // Split by dots, but handle array indices + parts := strings.Split(path, ".") + current := any(globalConfig) + + for _, part := range parts { + if part == "" { + continue + } + + // Handle array index like key[0] + bracketIdx := strings.Index(part, "[") + var key string + var index int + var isArray bool + if bracketIdx >= 0 { + key = part[:bracketIdx] + idxStr := strings.TrimSuffix(part[bracketIdx+1:], "]") + if idx, err := strconv.Atoi(idxStr); err == nil { + index = idx + isArray = true + } else { + continue + } + } else { + key = part + } + + switch m := current.(type) { + case map[string]any: + if isArray { + current = m[key] + arr, ok := current.([]any) + if !ok || index < 0 || index >= len(arr) { + return nil + } + current = arr[index] + } else { + var ok bool + current, ok = m[key] + if !ok { + return nil + } + } + case []any: + if idx, err := strconv.Atoi(key); err == nil && idx >= 0 && idx < len(m) { + current = m[idx] + } else { + return nil + } + default: + return nil + } + } + + return current +} + +// resolveStringValue resolves a string flag value from alternative sources. +// Returns the first non-empty value found, checking env vars then config file. +func resolveStringValue(sources []struct{ Type, Property string }) string { + for _, src := range sources { + switch src.Type { + case "$ENV": + if val := os.Getenv(src.Property); val != "" { + return val + } + case "$FILE": + if val := resolveJSONPath(src.Property); val != nil { + if s, ok := val.(string); ok && s != "" { + return s + } + } + } + } + return "" +} + +// resolveInt64Value resolves an integer flag value from alternative sources. +func resolveInt64Value(sources []struct{ Type, Property string }) int64 { + for _, src := range sources { + switch src.Type { + case "$ENV": + if val := os.Getenv(src.Property); val != "" { + if n, err := strconv.ParseInt(val, 10, 64); err == nil { + return n + } + } + case "$FILE": + if val := resolveJSONPath(src.Property); val != nil { + switch v := val.(type) { + case float64: + return int64(v) + case int64: + return v + case string: + if n, err := strconv.ParseInt(v, 10, 64); err == nil { + return n + } + } + } + } + } + return 0 +} + +// resolveBoolValue resolves a boolean flag value from alternative sources. +func resolveBoolValue(sources []struct{ Type, Property string }) bool { + for _, src := range sources { + switch src.Type { + case "$ENV": + if val := os.Getenv(src.Property); val != "" { + if b, err := strconv.ParseBool(val); err == nil { + return b + } + } + case "$FILE": + if val := resolveJSONPath(src.Property); val != nil { + switch v := val.(type) { + case bool: + return v + case string: + if b, err := strconv.ParseBool(v); err == nil { + return b + } + } + } + } + } + return false +} + +// resolveFloat64Value resolves a number flag value from alternative sources. +func resolveFloat64Value(sources []struct{ Type, Property string }) float64 { + for _, src := range sources { + switch src.Type { + case "$ENV": + if val := os.Getenv(src.Property); val != "" { + if n, err := strconv.ParseFloat(val, 64); err == nil { + return n + } + } + case "$FILE": + if val := resolveJSONPath(src.Property); val != nil { + switch v := val.(type) { + case float64: + return v + case int64: + return float64(v) + case string: + if n, err := strconv.ParseFloat(v, 64); err == nil { + return n + } + } + } + } + } + return 0.0 +} + +// resolveStringSliceValue resolves a variadic string flag value from alternative sources. +// Values in env var or config are expected to be comma-separated. +func resolveStringSliceValue(sources []struct{ Type, Property string }) []string { + for _, src := range sources { + switch src.Type { + case "$ENV": + if val := os.Getenv(src.Property); val != "" { + parts := strings.Split(val, ",") + result := make([]string, 0, len(parts)) + for _, p := range parts { + if trimmed := strings.TrimSpace(p); trimmed != "" { + result = append(result, trimmed) + } + } + if len(result) > 0 { + return result + } + } + case "$FILE": + if val := resolveJSONPath(src.Property); val != nil { + switch v := val.(type) { + case []any: + result := make([]string, 0, len(v)) + for _, item := range v { + if s, ok := item.(string); ok { + result = append(result, s) + } + } + if len(result) > 0 { + return result + } + case string: + if v != "" { + return []string{v} + } + } + } + } + } + return nil +} + +/* +Flag Resolver Functions + +These functions are called from generated command code to resolve flag values. +They first check if the CLI-provided value is non-zero. If it is the zero value, +they fall back to alternative sources (environment variables, config files). +*/ + +// resolveStringFlag resolves a string flag, falling back to alternative sources if empty. +func resolveStringFlag(cliVal string, sources []struct{ Type, Property string }) string { + if cliVal != "" { + return cliVal + } + return resolveStringValue(sources) +} + +// resolveTypedStringFlag resolves a string flag with a custom type, falling back to alternative sources if empty. +func resolveTypedStringFlag[T ~string](cliVal string, sources []struct{ Type, Property string }) T { + if cliVal != "" { + return T(cliVal) + } + return T(resolveStringValue(sources)) +} + +// resolveInt64Flag resolves an int64 flag, falling back to alternative sources if zero. +func resolveInt64Flag(cliVal int64, sources []struct{ Type, Property string }) int64 { + if cliVal != 0 { + return cliVal + } + return resolveInt64Value(sources) +} + +// resolveBoolFlag resolves a bool flag, falling back to alternative sources if false. +func resolveBoolFlag(cliVal bool, sources []struct{ Type, Property string }) bool { + if cliVal { + return cliVal + } + return resolveBoolValue(sources) +} + +// resolveFloat64Flag resolves a float64 flag, falling back to alternative sources if zero. +func resolveFloat64Flag(cliVal float64, sources []struct{ Type, Property string }) float64 { + if cliVal != 0 { + return cliVal + } + return resolveFloat64Value(sources) +} + +// resolveStringSliceFlag resolves a string slice flag, falling back to alternative sources if empty. +func resolveStringSliceFlag(cliVal []string, sources []struct{ Type, Property string }) []string { + if len(cliVal) > 0 { + return cliVal + } + if val := resolveStringSliceValue(sources); val != nil { + return val + } + return []string{} +} + +// resolveInt64SliceFlag resolves an int64 slice flag, falling back to alternative sources if empty. +func resolveInt64SliceFlag(cliVal []int64, sources []struct{ Type, Property string }) []int64 { + if len(cliVal) > 0 { + return cliVal + } + for _, src := range sources { + switch src.Type { + case "$ENV": + if val := os.Getenv(src.Property); val != "" { + parts := strings.Split(val, ",") + result := make([]int64, 0, len(parts)) + for _, p := range parts { + if n, err := strconv.ParseInt(strings.TrimSpace(p), 10, 64); err == nil { + result = append(result, n) + } + } + if len(result) > 0 { + return result + } + } + case "$FILE": + if val := resolveJSONPath(src.Property); val != nil { + if arr, ok := val.([]any); ok { + result := make([]int64, 0, len(arr)) + for _, item := range arr { + switch v := item.(type) { + case float64: + result = append(result, int64(v)) + case int64: + result = append(result, v) + } + } + if len(result) > 0 { + return result + } + } + } + } + } + return []int64{} +} + +// resolveBoolSliceFlag resolves a bool slice flag, falling back to alternative sources if empty. +func resolveBoolSliceFlag(cliVal []bool, sources []struct{ Type, Property string }) []bool { + if len(cliVal) > 0 { + return cliVal + } + for _, src := range sources { + switch src.Type { + case "$ENV": + if val := os.Getenv(src.Property); val != "" { + parts := strings.Split(val, ",") + result := make([]bool, 0, len(parts)) + for _, p := range parts { + if b, err := strconv.ParseBool(strings.TrimSpace(p)); err == nil { + result = append(result, b) + } + } + if len(result) > 0 { + return result + } + } + case "$FILE": + if val := resolveJSONPath(src.Property); val != nil { + if arr, ok := val.([]any); ok { + result := make([]bool, 0, len(arr)) + for _, item := range arr { + if b, ok := item.(bool); ok { + result = append(result, b) + } + } + if len(result) > 0 { + return result + } + } + } + } + } + return []bool{} +} + +// resolveFloat64SliceFlag resolves a float64 slice flag, falling back to alternative sources if empty. +func resolveFloat64SliceFlag(cliVal []float64, sources []struct{ Type, Property string }) []float64 { + if len(cliVal) > 0 { + return cliVal + } + for _, src := range sources { + switch src.Type { + case "$ENV": + if val := os.Getenv(src.Property); val != "" { + parts := strings.Split(val, ",") + result := make([]float64, 0, len(parts)) + for _, p := range parts { + if n, err := strconv.ParseFloat(strings.TrimSpace(p), 64); err == nil { + result = append(result, n) + } + } + if len(result) > 0 { + return result + } + } + case "$FILE": + if val := resolveJSONPath(src.Property); val != nil { + if arr, ok := val.([]any); ok { + result := make([]float64, 0, len(arr)) + for _, item := range arr { + switch v := item.(type) { + case float64: + result = append(result, v) + case int64: + result = append(result, float64(v)) + } + } + if len(result) > 0 { + return result + } + } + } + } + } + return []float64{} +} diff --git a/gen/templates/code/urfavecli/gencli/run.tmpl b/gen/templates/code/urfavecli/gencli/run.tmpl index 6e8a2f2..756c6a8 100644 --- a/gen/templates/code/urfavecli/gencli/run.tmpl +++ b/gen/templates/code/urfavecli/gencli/run.tmpl @@ -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) + loadConfig() + // Setup custom help printer with glamour/lipgloss rendering SetupUrfaveHelpPrinter(actions) 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..8d4c88d 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.String("username"), []struct{ Type, Property string }{{Type: "$ENV", Property: "PETSTORE_USER"}, {Type: "$FILE", Property: "$.auth.user"}}), + Password: resolveStringFlag(c.String("password"), []struct{ Type, Property string }{{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..751fe79 --- /dev/null +++ b/gen/testdata/urfavecli/gencli/config.gen.go @@ -0,0 +1,458 @@ +// Code generated by github.com/bcdxn/opencli@(devel) DO NOT EDIT. +package gencli + +import ( + "encoding/json" + "github.com/BurntSushi/toml" + "gopkg.in/yaml.v3" + "os" + "os/user" + "path/filepath" + "strconv" + "strings" +) + +// globalConfig holds the parsed config file data as a nested map. +var globalConfig map[string]any + +// expandTilde expands a tilde (~) in the path to the user's home directory. +func expandTilde(path string) string { + if !strings.HasPrefix(path, "~") { + return path + } + if path == "~" { + home, err := os.UserHomeDir() + if err != nil { + return path + } + return home + } + home, err := os.UserHomeDir() + if err != nil { + return path + } + 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 { + if err := json.Unmarshal(data, &globalConfig); err == nil { + 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. +// Supports simple paths like $.key, $.key.subkey, and $.key[0]. +func resolveJSONPath(path string) any { + if globalConfig == nil { + return nil + } + + // Strip leading $ + path = strings.TrimPrefix(path, "$") + + // Split by dots, but handle array indices + parts := strings.Split(path, ".") + current := any(globalConfig) + + for _, part := range parts { + if part == "" { + continue + } + + // Handle array index like key[0] + bracketIdx := strings.Index(part, "[") + var key string + var index int + var isArray bool + if bracketIdx >= 0 { + key = part[:bracketIdx] + idxStr := strings.TrimSuffix(part[bracketIdx+1:], "]") + if idx, err := strconv.Atoi(idxStr); err == nil { + index = idx + isArray = true + } else { + continue + } + } else { + key = part + } + + switch m := current.(type) { + case map[string]any: + if isArray { + current = m[key] + arr, ok := current.([]any) + if !ok || index < 0 || index >= len(arr) { + return nil + } + current = arr[index] + } else { + var ok bool + current, ok = m[key] + if !ok { + return nil + } + } + case []any: + if idx, err := strconv.Atoi(key); err == nil && idx >= 0 && idx < len(m) { + current = m[idx] + } else { + return nil + } + default: + return nil + } + } + + return current +} + +// resolveStringValue resolves a string flag value from alternative sources. +// Returns the first non-empty value found, checking env vars then config file. +func resolveStringValue(sources []struct{ Type, Property string }) string { + for _, src := range sources { + switch src.Type { + case "$ENV": + if val := os.Getenv(src.Property); val != "" { + return val + } + case "$FILE": + if val := resolveJSONPath(src.Property); val != nil { + if s, ok := val.(string); ok && s != "" { + return s + } + } + } + } + return "" +} + +// resolveInt64Value resolves an integer flag value from alternative sources. +func resolveInt64Value(sources []struct{ Type, Property string }) int64 { + for _, src := range sources { + switch src.Type { + case "$ENV": + if val := os.Getenv(src.Property); val != "" { + if n, err := strconv.ParseInt(val, 10, 64); err == nil { + return n + } + } + case "$FILE": + if val := resolveJSONPath(src.Property); val != nil { + switch v := val.(type) { + case float64: + return int64(v) + case int64: + return v + case string: + if n, err := strconv.ParseInt(v, 10, 64); err == nil { + return n + } + } + } + } + } + return 0 +} + +// resolveBoolValue resolves a boolean flag value from alternative sources. +func resolveBoolValue(sources []struct{ Type, Property string }) bool { + for _, src := range sources { + switch src.Type { + case "$ENV": + if val := os.Getenv(src.Property); val != "" { + if b, err := strconv.ParseBool(val); err == nil { + return b + } + } + case "$FILE": + if val := resolveJSONPath(src.Property); val != nil { + switch v := val.(type) { + case bool: + return v + case string: + if b, err := strconv.ParseBool(v); err == nil { + return b + } + } + } + } + } + return false +} + +// resolveFloat64Value resolves a number flag value from alternative sources. +func resolveFloat64Value(sources []struct{ Type, Property string }) float64 { + for _, src := range sources { + switch src.Type { + case "$ENV": + if val := os.Getenv(src.Property); val != "" { + if n, err := strconv.ParseFloat(val, 64); err == nil { + return n + } + } + case "$FILE": + if val := resolveJSONPath(src.Property); val != nil { + switch v := val.(type) { + case float64: + return v + case int64: + return float64(v) + case string: + if n, err := strconv.ParseFloat(v, 64); err == nil { + return n + } + } + } + } + } + return 0.0 +} + +// resolveStringSliceValue resolves a variadic string flag value from alternative sources. +// Values in env var or config are expected to be comma-separated. +func resolveStringSliceValue(sources []struct{ Type, Property string }) []string { + for _, src := range sources { + switch src.Type { + case "$ENV": + if val := os.Getenv(src.Property); val != "" { + parts := strings.Split(val, ",") + result := make([]string, 0, len(parts)) + for _, p := range parts { + if trimmed := strings.TrimSpace(p); trimmed != "" { + result = append(result, trimmed) + } + } + if len(result) > 0 { + return result + } + } + case "$FILE": + if val := resolveJSONPath(src.Property); val != nil { + switch v := val.(type) { + case []any: + result := make([]string, 0, len(v)) + for _, item := range v { + if s, ok := item.(string); ok { + result = append(result, s) + } + } + if len(result) > 0 { + return result + } + case string: + if v != "" { + return []string{v} + } + } + } + } + } + return nil +} + +/* +Flag Resolver Functions + +These functions are called from generated command code to resolve flag values. +They first check if the CLI-provided value is non-zero. If it is the zero value, +they fall back to alternative sources (environment variables, config files). +*/ + +// resolveStringFlag resolves a string flag, falling back to alternative sources if empty. +func resolveStringFlag(cliVal string, sources []struct{ Type, Property string }) string { + if cliVal != "" { + return cliVal + } + return resolveStringValue(sources) +} + +// resolveTypedStringFlag resolves a string flag with a custom type, falling back to alternative sources if empty. +func resolveTypedStringFlag[T ~string](cliVal string, sources []struct{ Type, Property string }) T { + if cliVal != "" { + return T(cliVal) + } + return T(resolveStringValue(sources)) +} + +// resolveInt64Flag resolves an int64 flag, falling back to alternative sources if zero. +func resolveInt64Flag(cliVal int64, sources []struct{ Type, Property string }) int64 { + if cliVal != 0 { + return cliVal + } + return resolveInt64Value(sources) +} + +// resolveBoolFlag resolves a bool flag, falling back to alternative sources if false. +func resolveBoolFlag(cliVal bool, sources []struct{ Type, Property string }) bool { + if cliVal { + return cliVal + } + return resolveBoolValue(sources) +} + +// resolveFloat64Flag resolves a float64 flag, falling back to alternative sources if zero. +func resolveFloat64Flag(cliVal float64, sources []struct{ Type, Property string }) float64 { + if cliVal != 0 { + return cliVal + } + return resolveFloat64Value(sources) +} + +// resolveStringSliceFlag resolves a string slice flag, falling back to alternative sources if empty. +func resolveStringSliceFlag(cliVal []string, sources []struct{ Type, Property string }) []string { + if len(cliVal) > 0 { + return cliVal + } + if val := resolveStringSliceValue(sources); val != nil { + return val + } + return []string{} +} + +// resolveInt64SliceFlag resolves an int64 slice flag, falling back to alternative sources if empty. +func resolveInt64SliceFlag(cliVal []int64, sources []struct{ Type, Property string }) []int64 { + if len(cliVal) > 0 { + return cliVal + } + for _, src := range sources { + switch src.Type { + case "$ENV": + if val := os.Getenv(src.Property); val != "" { + parts := strings.Split(val, ",") + result := make([]int64, 0, len(parts)) + for _, p := range parts { + if n, err := strconv.ParseInt(strings.TrimSpace(p), 10, 64); err == nil { + result = append(result, n) + } + } + if len(result) > 0 { + return result + } + } + case "$FILE": + if val := resolveJSONPath(src.Property); val != nil { + if arr, ok := val.([]any); ok { + result := make([]int64, 0, len(arr)) + for _, item := range arr { + switch v := item.(type) { + case float64: + result = append(result, int64(v)) + case int64: + result = append(result, v) + } + } + if len(result) > 0 { + return result + } + } + } + } + } + return []int64{} +} + +// resolveBoolSliceFlag resolves a bool slice flag, falling back to alternative sources if empty. +func resolveBoolSliceFlag(cliVal []bool, sources []struct{ Type, Property string }) []bool { + if len(cliVal) > 0 { + return cliVal + } + for _, src := range sources { + switch src.Type { + case "$ENV": + if val := os.Getenv(src.Property); val != "" { + parts := strings.Split(val, ",") + result := make([]bool, 0, len(parts)) + for _, p := range parts { + if b, err := strconv.ParseBool(strings.TrimSpace(p)); err == nil { + result = append(result, b) + } + } + if len(result) > 0 { + return result + } + } + case "$FILE": + if val := resolveJSONPath(src.Property); val != nil { + if arr, ok := val.([]any); ok { + result := make([]bool, 0, len(arr)) + for _, item := range arr { + if b, ok := item.(bool); ok { + result = append(result, b) + } + } + if len(result) > 0 { + return result + } + } + } + } + } + return []bool{} +} + +// resolveFloat64SliceFlag resolves a float64 slice flag, falling back to alternative sources if empty. +func resolveFloat64SliceFlag(cliVal []float64, sources []struct{ Type, Property string }) []float64 { + if len(cliVal) > 0 { + return cliVal + } + for _, src := range sources { + switch src.Type { + case "$ENV": + if val := os.Getenv(src.Property); val != "" { + parts := strings.Split(val, ",") + result := make([]float64, 0, len(parts)) + for _, p := range parts { + if n, err := strconv.ParseFloat(strings.TrimSpace(p), 64); err == nil { + result = append(result, n) + } + } + if len(result) > 0 { + return result + } + } + case "$FILE": + if val := resolveJSONPath(src.Property); val != nil { + if arr, ok := val.([]any); ok { + result := make([]float64, 0, len(arr)) + for _, item := range arr { + switch v := item.(type) { + case float64: + result = append(result, v) + case int64: + result = append(result, float64(v)) + } + } + if len(result) > 0 { + return result + } + } + } + } + } + return []float64{} +} diff --git a/gen/testdata/urfavecli/gencli/run.gen.go b/gen/testdata/urfavecli/gencli/run.gen.go index da22829..308fff0 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) + loadConfig() + // Setup custom help printer with glamour/lipgloss rendering SetupUrfaveHelpPrinter(actions) From 9caf03bca2ef481b4c7a2d08382c7607432182ce Mon Sep 17 00:00:00 2001 From: bcdxn <5067649+bcdxn@users.noreply.github.com> Date: Fri, 14 Aug 2026 21:03:51 -0400 Subject: [PATCH 02/22] feat: urfave cli alternative sources --- gen/cli_urfave_cli.go | 151 +++--- .../code/urfavecli/gencli/config.tmpl | 497 +++++++----------- .../gencli/cmd_petstore_user_login.gen.go | 4 +- gen/testdata/urfavecli/gencli/config.gen.go | 497 +++++++----------- 4 files changed, 417 insertions(+), 732 deletions(-) diff --git a/gen/cli_urfave_cli.go b/gen/cli_urfave_cli.go index 4b01025..232a6ad 100644 --- a/gen/cli_urfave_cli.go +++ b/gen/cli_urfave_cli.go @@ -46,25 +46,19 @@ type urfaveCliArgEntry struct { TypeName string // non-empty when the struct field uses a generated type (needs cast) } -// urfaveCliAltSource represents an alternative source for a flag value ($ENV or $FILE). -type urfaveCliAltSource struct { - Type string // "$ENV" or "$FILE" - Property string // env var name or JSONPath in config file -} - // 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 - GoType string - UrfaveFlag string // e.g. "cli.StringFlag", "cli.Int64Flag" - Default string // Go literal for the default value - Summary string - 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 []urfaveCliAltSource // alternative sources from flag's alternativeSources - HasAltSource bool // true if AltSources is non-empty + FieldName string + FlagName string + GoType string + UrfaveFlag string // e.g. "cli.StringFlag", "cli.Int64Flag" + Default string // Go literal for the default value + Summary string + 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 @@ -103,18 +97,16 @@ func genCLIUrfaveCli(doc *spec.Document, opts *genCLIOptions) (map[string][]byte if flag.Name == "help" || flag.Name == "version" { continue } - altSources := urfaveCliAltSources(flag.AltSources) globalFlags = append(globalFlags, urfaveCliFlagEntry{ - FieldName: toPascalCase(flag.Name), - FlagName: flag.Name, - GoType: toGoType(flag.Type, flag.Variadic), - UrfaveFlag: urfaveCliFlagStruct(flag.Type, flag.Variadic), - Default: urfaveCliDefaultVal(flag.Default, flag.Type, flag.Variadic), - Summary: flag.Summary, - Aliases: flag.Aliases, - Accessor: urfaveCliAccessor(flag.Type, flag.Variadic), - AltSources: altSources, - HasAltSource: len(altSources) > 0, + FieldName: toPascalCase(flag.Name), + FlagName: flag.Name, + GoType: toGoType(flag.Type, flag.Variadic), + UrfaveFlag: urfaveCliFlagStruct(flag.Type, flag.Variadic), + Default: urfaveCliDefaultVal(flag.Default, flag.Type, flag.Variadic), + Summary: flag.Summary, + Aliases: flag.Aliases, + Accessor: urfaveCliAccessor(flag.Type, flag.Variadic), + AltSources: flag.AltSources, }) } } @@ -281,24 +273,22 @@ func walkUrfaveCliCmdTree( if len(flag.Choices) > 0 && (flag.Type == "string" || flag.Type == "") && !flag.Variadic { flagTypeName = methodName + toPascalCase(flag.Name) } - altSources := urfaveCliAltSources(flag.AltSources) specFlags = append(specFlags, specFlagEntry{ Name: flag.Name, Summary: flag.Summary, Aliases: flag.Aliases, }) urfaveFlags = append(urfaveFlags, urfaveCliFlagEntry{ - FieldName: toPascalCase(flag.Name), - FlagName: flag.Name, - GoType: toGoType(flag.Type, flag.Variadic), - UrfaveFlag: urfaveCliFlagStruct(flag.Type, flag.Variadic), - Default: urfaveCliDefaultVal(flag.Default, flag.Type, flag.Variadic), - Summary: flag.Summary, - TypeName: flagTypeName, - Aliases: flag.Aliases, - Accessor: urfaveCliAccessor(flag.Type, flag.Variadic), - AltSources: altSources, - HasAltSource: len(altSources) > 0, + FieldName: toPascalCase(flag.Name), + FlagName: flag.Name, + GoType: toGoType(flag.Type, flag.Variadic), + UrfaveFlag: urfaveCliFlagStruct(flag.Type, flag.Variadic), + Default: urfaveCliDefaultVal(flag.Default, flag.Type, flag.Variadic), + Summary: flag.Summary, + TypeName: flagTypeName, + Aliases: flag.Aliases, + Accessor: urfaveCliAccessor(flag.Type, flag.Variadic), + AltSources: flag.AltSources, }) } @@ -365,53 +355,45 @@ func urfaveCliTemplateFuncMap() template.FuncMap { return fmt.Sprintf("%q", s) }, "resolveFlagValue": func(f urfaveCliFlagEntry) string { - if !f.HasAltSource { - // No alternative sources — read directly from CLI + // 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) } - // Has alternative sources — read from CLI first, fall back to env/config - // We use a helper that checks if the CLI value is the zero value - switch f.GoType { - case "string": - srcs := formatAltSources(f.AltSources) - if f.TypeName != "" { - return fmt.Sprintf("%s(resolveStringFlag(c.String(%q), %s))", f.TypeName, f.FlagName, srcs) - } - return fmt.Sprintf("resolveStringFlag(c.String(%q), %s)", f.FlagName, srcs) - case "int64": - srcs := formatAltSources(f.AltSources) - return fmt.Sprintf("resolveInt64Flag(c.Int64(%q), %s)", f.FlagName, srcs) - case "bool": - srcs := formatAltSources(f.AltSources) - return fmt.Sprintf("resolveBoolFlag(c.Bool(%q), %s)", f.FlagName, srcs) - case "float64": - srcs := formatAltSources(f.AltSources) - return fmt.Sprintf("resolveFloat64Flag(c.Float64(%q), %s)", f.FlagName, srcs) - case "[]string": - srcs := formatAltSources(f.AltSources) - return fmt.Sprintf("resolveStringSliceFlag(c.StringSlice(%q), %s)", f.FlagName, srcs) - case "[]int64": - srcs := formatAltSources(f.AltSources) - return fmt.Sprintf("resolveInt64SliceFlag(c.Int64Slice(%q), %s)", f.FlagName, srcs) - case "[]bool": - srcs := formatAltSources(f.AltSources) - return fmt.Sprintf("resolveBoolSliceFlag(c.BoolSlice(%q), %s)", f.FlagName, srcs) - case "[]float64": - srcs := formatAltSources(f.AltSources) - return fmt.Sprintf("resolveFloat64SliceFlag(c.Float64Slice(%q), %s)", f.FlagName, srcs) - default: + // 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 { @@ -540,24 +522,9 @@ func urfaveCliDefaultVal(val any, t string, variadic bool) string { } } -// urfaveCliAltSources converts spec.AlternativeSource slices to urfaveCliAltSource slices. -func urfaveCliAltSources(sources []spec.AlternativeSource) []urfaveCliAltSource { - if len(sources) == 0 { - return nil - } - result := make([]urfaveCliAltSource, 0, len(sources)) - for _, s := range sources { - result = append(result, urfaveCliAltSource{ - Type: s.Type, - Property: s.Property, - }) - } - return result -} - -// formatAltSources formats a slice of urfaveCliAltSource as a Go composite literal -// for use in generated template code. -func formatAltSources(sources []urfaveCliAltSource) string { +// 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" } @@ -565,5 +532,5 @@ func formatAltSources(sources []urfaveCliAltSource) string { for _, s := range sources { parts = append(parts, fmt.Sprintf("{Type: %q, Property: %q}", s.Type, s.Property)) } - return fmt.Sprintf("[]struct{Type,Property string}{%s}", strings.Join(parts, ",")) + return fmt.Sprintf("[]AltSource{%s}", strings.Join(parts, ", ")) } diff --git a/gen/templates/code/urfavecli/gencli/config.tmpl b/gen/templates/code/urfavecli/gencli/config.tmpl index cea8ccb..ec16091 100644 --- a/gen/templates/code/urfavecli/gencli/config.tmpl +++ b/gen/templates/code/urfavecli/gencli/config.tmpl @@ -4,7 +4,6 @@ package gencli import ( "encoding/json" "os" - "os/user" "path/filepath" "strconv" "strings" @@ -16,25 +15,28 @@ import ( {{- 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 tilde (~) in the path to the user's home directory. +// 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 } - if path == "~" { - home, err := os.UserHomeDir() - if err != nil { - return path - } - return home - } home, err := os.UserHomeDir() if err != nil { return path } + if path == "~" { + return home + } return filepath.Join(home, path[1:]) } @@ -48,7 +50,9 @@ func loadConfig() { {{- if .ConfigJSON}} // Try JSON config first if data, err := os.ReadFile(expandTilde({{goString .ConfigJSON}})); err == nil { - if err := json.Unmarshal(data, &globalConfig); err == nil { + var cfg map[string]any + if err := json.Unmarshal(data, &cfg); err == nil { + globalConfig = cfg return } } @@ -75,395 +79,250 @@ func loadConfig() { {{- end}} } -// resolveJSONPath resolves a JSONPath expression against the global config. -// Supports simple paths like $.key, $.key.subkey, and $.key[0]. +// resolveJSONPath resolves a simple JSONPath expression against the global +// config. Supports paths like $.key, $.key.subkey, and $.key[0]. func resolveJSONPath(path string) any { if globalConfig == nil { return nil } - - // Strip leading $ - path = strings.TrimPrefix(path, "$") - - // Split by dots, but handle array indices - parts := strings.Split(path, ".") current := any(globalConfig) - - for _, part := range parts { + for _, part := range strings.Split(strings.TrimPrefix(path, "$"), ".") { if part == "" { continue } - - // Handle array index like key[0] - bracketIdx := strings.Index(part, "[") - var key string - var index int - var isArray bool - if bracketIdx >= 0 { - key = part[:bracketIdx] - idxStr := strings.TrimSuffix(part[bracketIdx+1:], "]") - if idx, err := strconv.Atoi(idxStr); err == nil { - index = idx - isArray = true - } else { - continue + // A part may carry an array index, e.g. "key[0]". + key, index, hasIndex := part, 0, false + if i := strings.Index(part, "["); i >= 0 { + key = part[:i] + idx, err := strconv.Atoi(part[i+1 : len(part)-1]) + if err != nil { + return nil // malformed index } - } else { - key = part + index, hasIndex = idx, true } - - switch m := current.(type) { + switch cur := current.(type) { case map[string]any: - if isArray { - current = m[key] - arr, ok := current.([]any) + next, ok := cur[key] + if !ok { + return nil + } + if hasIndex { + arr, ok := next.([]any) if !ok || index < 0 || index >= len(arr) { return nil } - current = arr[index] - } else { - var ok bool - current, ok = m[key] - if !ok { - return nil - } + next = arr[index] } + current = next case []any: - if idx, err := strconv.Atoi(key); err == nil && idx >= 0 && idx < len(m) { - current = m[idx] - } else { + idx, err := strconv.Atoi(key) + if err != nil || idx < 0 || idx >= len(cur) { return nil } + current = cur[idx] default: return nil } } - return current } -// resolveStringValue resolves a string flag value from alternative sources. -// Returns the first non-empty value found, checking env vars then config file. -func resolveStringValue(sources []struct{ Type, Property string }) string { - for _, src := range sources { - switch src.Type { - case "$ENV": - if val := os.Getenv(src.Property); val != "" { - return val - } - case "$FILE": - if val := resolveJSONPath(src.Property); val != nil { - if s, ok := val.(string); ok && s != "" { - return s - } - } +// 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 "" + return nil } -// resolveInt64Value resolves an integer flag value from alternative sources. -func resolveInt64Value(sources []struct{ Type, Property string }) int64 { - for _, src := range sources { - switch src.Type { - case "$ENV": - if val := os.Getenv(src.Property); val != "" { - if n, err := strconv.ParseInt(val, 10, 64); err == nil { - return n - } - } - case "$FILE": - if val := resolveJSONPath(src.Property); val != nil { - switch v := val.(type) { - case float64: - return int64(v) - case int64: - return v - case string: - if n, err := strconv.ParseInt(v, 10, 64); err == nil { - return n - } - } +// 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 0 + return nil } -// resolveBoolValue resolves a boolean flag value from alternative sources. -func resolveBoolValue(sources []struct{ Type, Property string }) bool { - for _, src := range sources { - switch src.Type { - case "$ENV": - if val := os.Getenv(src.Property); val != "" { - if b, err := strconv.ParseBool(val); err == nil { - return b - } - } - case "$FILE": - if val := resolveJSONPath(src.Property); val != nil { - switch v := val.(type) { - case bool: - return v - case string: - if b, err := strconv.ParseBool(v); err == nil { - return b - } - } - } +// 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: + return int64(n), true + case string: + if i, err := strconv.ParseInt(n, 10, 64); err == nil { + return i, true } } - return false + return 0, false } -// resolveFloat64Value resolves a number flag value from alternative sources. -func resolveFloat64Value(sources []struct{ Type, Property string }) float64 { - for _, src := range sources { - switch src.Type { - case "$ENV": - if val := os.Getenv(src.Property); val != "" { - if n, err := strconv.ParseFloat(val, 64); err == nil { - return n - } - } - case "$FILE": - if val := resolveJSONPath(src.Property); val != nil { - switch v := val.(type) { - case float64: - return v - case int64: - return float64(v) - case string: - if n, err := strconv.ParseFloat(v, 64); err == nil { - return n - } - } - } +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 0.0 + return false, false } -// resolveStringSliceValue resolves a variadic string flag value from alternative sources. -// Values in env var or config are expected to be comma-separated. -func resolveStringSliceValue(sources []struct{ Type, Property string }) []string { - for _, src := range sources { - switch src.Type { - case "$ENV": - if val := os.Getenv(src.Property); val != "" { - parts := strings.Split(val, ",") - result := make([]string, 0, len(parts)) - for _, p := range parts { - if trimmed := strings.TrimSpace(p); trimmed != "" { - result = append(result, trimmed) - } - } - if len(result) > 0 { - return result - } - } - case "$FILE": - if val := resolveJSONPath(src.Property); val != nil { - switch v := val.(type) { - case []any: - result := make([]string, 0, len(v)) - for _, item := range v { - if s, ok := item.(string); ok { - result = append(result, s) - } - } - if len(result) > 0 { - return result - } - case string: - if v != "" { - return []string{v} - } - } - } +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 nil + return 0, false } /* Flag Resolver Functions These functions are called from generated command code to resolve flag values. -They first check if the CLI-provided value is non-zero. If it is the zero value, -they fall back to alternative sources (environment variables, config files). +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. */ -// resolveStringFlag resolves a string flag, falling back to alternative sources if empty. -func resolveStringFlag(cliVal string, sources []struct{ Type, Property string }) string { - if cliVal != "" { +// resolveStringFlag resolves a string flag from the CLI or its alternative sources. +func resolveStringFlag(set bool, cliVal string, sources []AltSource) string { + if set { return cliVal } - return resolveStringValue(sources) -} - -// resolveTypedStringFlag resolves a string flag with a custom type, falling back to alternative sources if empty. -func resolveTypedStringFlag[T ~string](cliVal string, sources []struct{ Type, Property string }) T { - if cliVal != "" { - return T(cliVal) - } - return T(resolveStringValue(sources)) -} - -// resolveInt64Flag resolves an int64 flag, falling back to alternative sources if zero. -func resolveInt64Flag(cliVal int64, sources []struct{ Type, Property string }) int64 { - if cliVal != 0 { - return cliVal - } - return resolveInt64Value(sources) -} - -// resolveBoolFlag resolves a bool flag, falling back to alternative sources if false. -func resolveBoolFlag(cliVal bool, sources []struct{ Type, Property string }) bool { - if cliVal { - return cliVal - } - return resolveBoolValue(sources) -} - -// resolveFloat64Flag resolves a float64 flag, falling back to alternative sources if zero. -func resolveFloat64Flag(cliVal float64, sources []struct{ Type, Property string }) float64 { - if cliVal != 0 { - return cliVal + for _, src := range sources { + if v, ok := toString(altSourceValue(src)); ok { + return v + } } - return resolveFloat64Value(sources) + return "" } -// resolveStringSliceFlag resolves a string slice flag, falling back to alternative sources if empty. -func resolveStringSliceFlag(cliVal []string, sources []struct{ Type, Property string }) []string { - if len(cliVal) > 0 { +// resolveInt64Flag resolves an int64 flag from the CLI or its alternative sources. +func resolveInt64Flag(set bool, cliVal int64, sources []AltSource) int64 { + if set { return cliVal } - if val := resolveStringSliceValue(sources); val != nil { - return val + for _, src := range sources { + if v, ok := toInt64(altSourceValue(src)); ok { + return v + } } - return []string{} + return 0 } -// resolveInt64SliceFlag resolves an int64 slice flag, falling back to alternative sources if empty. -func resolveInt64SliceFlag(cliVal []int64, sources []struct{ Type, Property string }) []int64 { - if len(cliVal) > 0 { +// resolveBoolFlag resolves a bool flag from the CLI or its alternative sources. +func resolveBoolFlag(set bool, cliVal bool, sources []AltSource) bool { + if set { return cliVal } for _, src := range sources { - switch src.Type { - case "$ENV": - if val := os.Getenv(src.Property); val != "" { - parts := strings.Split(val, ",") - result := make([]int64, 0, len(parts)) - for _, p := range parts { - if n, err := strconv.ParseInt(strings.TrimSpace(p), 10, 64); err == nil { - result = append(result, n) - } - } - if len(result) > 0 { - return result - } - } - case "$FILE": - if val := resolveJSONPath(src.Property); val != nil { - if arr, ok := val.([]any); ok { - result := make([]int64, 0, len(arr)) - for _, item := range arr { - switch v := item.(type) { - case float64: - result = append(result, int64(v)) - case int64: - result = append(result, v) - } - } - if len(result) > 0 { - return result - } - } - } + if v, ok := toBool(altSourceValue(src)); ok { + return v } } - return []int64{} + return false } -// resolveBoolSliceFlag resolves a bool slice flag, falling back to alternative sources if empty. -func resolveBoolSliceFlag(cliVal []bool, sources []struct{ Type, Property string }) []bool { - if len(cliVal) > 0 { +// resolveFloat64Flag resolves a float64 flag from the CLI or its alternative sources. +func resolveFloat64Flag(set bool, cliVal float64, sources []AltSource) float64 { + if set { return cliVal } for _, src := range sources { - switch src.Type { - case "$ENV": - if val := os.Getenv(src.Property); val != "" { - parts := strings.Split(val, ",") - result := make([]bool, 0, len(parts)) - for _, p := range parts { - if b, err := strconv.ParseBool(strings.TrimSpace(p)); err == nil { - result = append(result, b) - } - } - if len(result) > 0 { - return result - } - } - case "$FILE": - if val := resolveJSONPath(src.Property); val != nil { - if arr, ok := val.([]any); ok { - result := make([]bool, 0, len(arr)) - for _, item := range arr { - if b, ok := item.(bool); ok { - result = append(result, b) - } - } - if len(result) > 0 { - return result - } - } - } + if v, ok := toFloat64(altSourceValue(src)); ok { + return v } } - return []bool{} + return 0 } -// resolveFloat64SliceFlag resolves a float64 slice flag, falling back to alternative sources if empty. -func resolveFloat64SliceFlag(cliVal []float64, sources []struct{ Type, Property string }) []float64 { - if len(cliVal) > 0 { +// 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. +func resolveSliceFlag[T any](set bool, cliVal []T, sources []AltSource, coerce func(any) (T, bool)) []T { + if set { return cliVal } for _, src := range sources { - switch src.Type { - case "$ENV": - if val := os.Getenv(src.Property); val != "" { - parts := strings.Split(val, ",") - result := make([]float64, 0, len(parts)) - for _, p := range parts { - if n, err := strconv.ParseFloat(strings.TrimSpace(p), 64); err == nil { - result = append(result, n) - } - } - if len(result) > 0 { - return result - } - } - case "$FILE": - if val := resolveJSONPath(src.Property); val != nil { - if arr, ok := val.([]any); ok { - result := make([]float64, 0, len(arr)) - for _, item := range arr { - switch v := item.(type) { - case float64: - result = append(result, v) - case int64: - result = append(result, float64(v)) - } - } - if len(result) > 0 { - return result - } - } + result := make([]T, 0, len(altSourceItems(src))) + for _, item := range altSourceItems(src) { + if v, ok := coerce(item); ok { + result = append(result, v) } } + if len(result) > 0 { + return result + } } - return []float64{} + return []T{} +} + +// 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/cmd_petstore_user_login.gen.go b/gen/testdata/urfavecli/gencli/cmd_petstore_user_login.gen.go index 8d4c88d..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: resolveStringFlag(c.String("username"), []struct{ Type, Property string }{{Type: "$ENV", Property: "PETSTORE_USER"}, {Type: "$FILE", Property: "$.auth.user"}}), - Password: resolveStringFlag(c.String("password"), []struct{ Type, Property string }{{Type: "$ENV", Property: "PETSTORE_PASS"}, {Type: "$FILE", Property: "$.auth.pass"}}), + 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 index 751fe79..db8d977 100644 --- a/gen/testdata/urfavecli/gencli/config.gen.go +++ b/gen/testdata/urfavecli/gencli/config.gen.go @@ -6,31 +6,33 @@ import ( "github.com/BurntSushi/toml" "gopkg.in/yaml.v3" "os" - "os/user" "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 tilde (~) in the path to the user's home directory. +// 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 } - if path == "~" { - home, err := os.UserHomeDir() - if err != nil { - return path - } - return home - } home, err := os.UserHomeDir() if err != nil { return path } + if path == "~" { + return home + } return filepath.Join(home, path[1:]) } @@ -42,7 +44,9 @@ func loadConfig() { globalConfig = make(map[string]any) // Try JSON config first if data, err := os.ReadFile(expandTilde("~/.petstore/config.json")); err == nil { - if err := json.Unmarshal(data, &globalConfig); err == nil { + var cfg map[string]any + if err := json.Unmarshal(data, &cfg); err == nil { + globalConfig = cfg return } } @@ -64,395 +68,250 @@ func loadConfig() { } } -// resolveJSONPath resolves a JSONPath expression against the global config. -// Supports simple paths like $.key, $.key.subkey, and $.key[0]. +// resolveJSONPath resolves a simple JSONPath expression against the global +// config. Supports paths like $.key, $.key.subkey, and $.key[0]. func resolveJSONPath(path string) any { if globalConfig == nil { return nil } - - // Strip leading $ - path = strings.TrimPrefix(path, "$") - - // Split by dots, but handle array indices - parts := strings.Split(path, ".") current := any(globalConfig) - - for _, part := range parts { + for _, part := range strings.Split(strings.TrimPrefix(path, "$"), ".") { if part == "" { continue } - - // Handle array index like key[0] - bracketIdx := strings.Index(part, "[") - var key string - var index int - var isArray bool - if bracketIdx >= 0 { - key = part[:bracketIdx] - idxStr := strings.TrimSuffix(part[bracketIdx+1:], "]") - if idx, err := strconv.Atoi(idxStr); err == nil { - index = idx - isArray = true - } else { - continue + // A part may carry an array index, e.g. "key[0]". + key, index, hasIndex := part, 0, false + if i := strings.Index(part, "["); i >= 0 { + key = part[:i] + idx, err := strconv.Atoi(part[i+1 : len(part)-1]) + if err != nil { + return nil // malformed index } - } else { - key = part + index, hasIndex = idx, true } - - switch m := current.(type) { + switch cur := current.(type) { case map[string]any: - if isArray { - current = m[key] - arr, ok := current.([]any) + next, ok := cur[key] + if !ok { + return nil + } + if hasIndex { + arr, ok := next.([]any) if !ok || index < 0 || index >= len(arr) { return nil } - current = arr[index] - } else { - var ok bool - current, ok = m[key] - if !ok { - return nil - } + next = arr[index] } + current = next case []any: - if idx, err := strconv.Atoi(key); err == nil && idx >= 0 && idx < len(m) { - current = m[idx] - } else { + idx, err := strconv.Atoi(key) + if err != nil || idx < 0 || idx >= len(cur) { return nil } + current = cur[idx] default: return nil } } - return current } -// resolveStringValue resolves a string flag value from alternative sources. -// Returns the first non-empty value found, checking env vars then config file. -func resolveStringValue(sources []struct{ Type, Property string }) string { - for _, src := range sources { - switch src.Type { - case "$ENV": - if val := os.Getenv(src.Property); val != "" { - return val - } - case "$FILE": - if val := resolveJSONPath(src.Property); val != nil { - if s, ok := val.(string); ok && s != "" { - return s - } - } +// 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 "" + return nil } -// resolveInt64Value resolves an integer flag value from alternative sources. -func resolveInt64Value(sources []struct{ Type, Property string }) int64 { - for _, src := range sources { - switch src.Type { - case "$ENV": - if val := os.Getenv(src.Property); val != "" { - if n, err := strconv.ParseInt(val, 10, 64); err == nil { - return n - } - } - case "$FILE": - if val := resolveJSONPath(src.Property); val != nil { - switch v := val.(type) { - case float64: - return int64(v) - case int64: - return v - case string: - if n, err := strconv.ParseInt(v, 10, 64); err == nil { - return n - } - } +// 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 0 + return nil } -// resolveBoolValue resolves a boolean flag value from alternative sources. -func resolveBoolValue(sources []struct{ Type, Property string }) bool { - for _, src := range sources { - switch src.Type { - case "$ENV": - if val := os.Getenv(src.Property); val != "" { - if b, err := strconv.ParseBool(val); err == nil { - return b - } - } - case "$FILE": - if val := resolveJSONPath(src.Property); val != nil { - switch v := val.(type) { - case bool: - return v - case string: - if b, err := strconv.ParseBool(v); err == nil { - return b - } - } - } +// 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: + return int64(n), true + case string: + if i, err := strconv.ParseInt(n, 10, 64); err == nil { + return i, true } } - return false + return 0, false } -// resolveFloat64Value resolves a number flag value from alternative sources. -func resolveFloat64Value(sources []struct{ Type, Property string }) float64 { - for _, src := range sources { - switch src.Type { - case "$ENV": - if val := os.Getenv(src.Property); val != "" { - if n, err := strconv.ParseFloat(val, 64); err == nil { - return n - } - } - case "$FILE": - if val := resolveJSONPath(src.Property); val != nil { - switch v := val.(type) { - case float64: - return v - case int64: - return float64(v) - case string: - if n, err := strconv.ParseFloat(v, 64); err == nil { - return n - } - } - } +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 0.0 + return false, false } -// resolveStringSliceValue resolves a variadic string flag value from alternative sources. -// Values in env var or config are expected to be comma-separated. -func resolveStringSliceValue(sources []struct{ Type, Property string }) []string { - for _, src := range sources { - switch src.Type { - case "$ENV": - if val := os.Getenv(src.Property); val != "" { - parts := strings.Split(val, ",") - result := make([]string, 0, len(parts)) - for _, p := range parts { - if trimmed := strings.TrimSpace(p); trimmed != "" { - result = append(result, trimmed) - } - } - if len(result) > 0 { - return result - } - } - case "$FILE": - if val := resolveJSONPath(src.Property); val != nil { - switch v := val.(type) { - case []any: - result := make([]string, 0, len(v)) - for _, item := range v { - if s, ok := item.(string); ok { - result = append(result, s) - } - } - if len(result) > 0 { - return result - } - case string: - if v != "" { - return []string{v} - } - } - } +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 nil + return 0, false } /* Flag Resolver Functions These functions are called from generated command code to resolve flag values. -They first check if the CLI-provided value is non-zero. If it is the zero value, -they fall back to alternative sources (environment variables, config files). +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. */ -// resolveStringFlag resolves a string flag, falling back to alternative sources if empty. -func resolveStringFlag(cliVal string, sources []struct{ Type, Property string }) string { - if cliVal != "" { +// resolveStringFlag resolves a string flag from the CLI or its alternative sources. +func resolveStringFlag(set bool, cliVal string, sources []AltSource) string { + if set { return cliVal } - return resolveStringValue(sources) -} - -// resolveTypedStringFlag resolves a string flag with a custom type, falling back to alternative sources if empty. -func resolveTypedStringFlag[T ~string](cliVal string, sources []struct{ Type, Property string }) T { - if cliVal != "" { - return T(cliVal) - } - return T(resolveStringValue(sources)) -} - -// resolveInt64Flag resolves an int64 flag, falling back to alternative sources if zero. -func resolveInt64Flag(cliVal int64, sources []struct{ Type, Property string }) int64 { - if cliVal != 0 { - return cliVal - } - return resolveInt64Value(sources) -} - -// resolveBoolFlag resolves a bool flag, falling back to alternative sources if false. -func resolveBoolFlag(cliVal bool, sources []struct{ Type, Property string }) bool { - if cliVal { - return cliVal - } - return resolveBoolValue(sources) -} - -// resolveFloat64Flag resolves a float64 flag, falling back to alternative sources if zero. -func resolveFloat64Flag(cliVal float64, sources []struct{ Type, Property string }) float64 { - if cliVal != 0 { - return cliVal + for _, src := range sources { + if v, ok := toString(altSourceValue(src)); ok { + return v + } } - return resolveFloat64Value(sources) + return "" } -// resolveStringSliceFlag resolves a string slice flag, falling back to alternative sources if empty. -func resolveStringSliceFlag(cliVal []string, sources []struct{ Type, Property string }) []string { - if len(cliVal) > 0 { +// resolveInt64Flag resolves an int64 flag from the CLI or its alternative sources. +func resolveInt64Flag(set bool, cliVal int64, sources []AltSource) int64 { + if set { return cliVal } - if val := resolveStringSliceValue(sources); val != nil { - return val + for _, src := range sources { + if v, ok := toInt64(altSourceValue(src)); ok { + return v + } } - return []string{} + return 0 } -// resolveInt64SliceFlag resolves an int64 slice flag, falling back to alternative sources if empty. -func resolveInt64SliceFlag(cliVal []int64, sources []struct{ Type, Property string }) []int64 { - if len(cliVal) > 0 { +// resolveBoolFlag resolves a bool flag from the CLI or its alternative sources. +func resolveBoolFlag(set bool, cliVal bool, sources []AltSource) bool { + if set { return cliVal } for _, src := range sources { - switch src.Type { - case "$ENV": - if val := os.Getenv(src.Property); val != "" { - parts := strings.Split(val, ",") - result := make([]int64, 0, len(parts)) - for _, p := range parts { - if n, err := strconv.ParseInt(strings.TrimSpace(p), 10, 64); err == nil { - result = append(result, n) - } - } - if len(result) > 0 { - return result - } - } - case "$FILE": - if val := resolveJSONPath(src.Property); val != nil { - if arr, ok := val.([]any); ok { - result := make([]int64, 0, len(arr)) - for _, item := range arr { - switch v := item.(type) { - case float64: - result = append(result, int64(v)) - case int64: - result = append(result, v) - } - } - if len(result) > 0 { - return result - } - } - } + if v, ok := toBool(altSourceValue(src)); ok { + return v } } - return []int64{} + return false } -// resolveBoolSliceFlag resolves a bool slice flag, falling back to alternative sources if empty. -func resolveBoolSliceFlag(cliVal []bool, sources []struct{ Type, Property string }) []bool { - if len(cliVal) > 0 { +// resolveFloat64Flag resolves a float64 flag from the CLI or its alternative sources. +func resolveFloat64Flag(set bool, cliVal float64, sources []AltSource) float64 { + if set { return cliVal } for _, src := range sources { - switch src.Type { - case "$ENV": - if val := os.Getenv(src.Property); val != "" { - parts := strings.Split(val, ",") - result := make([]bool, 0, len(parts)) - for _, p := range parts { - if b, err := strconv.ParseBool(strings.TrimSpace(p)); err == nil { - result = append(result, b) - } - } - if len(result) > 0 { - return result - } - } - case "$FILE": - if val := resolveJSONPath(src.Property); val != nil { - if arr, ok := val.([]any); ok { - result := make([]bool, 0, len(arr)) - for _, item := range arr { - if b, ok := item.(bool); ok { - result = append(result, b) - } - } - if len(result) > 0 { - return result - } - } - } + if v, ok := toFloat64(altSourceValue(src)); ok { + return v } } - return []bool{} + return 0 } -// resolveFloat64SliceFlag resolves a float64 slice flag, falling back to alternative sources if empty. -func resolveFloat64SliceFlag(cliVal []float64, sources []struct{ Type, Property string }) []float64 { - if len(cliVal) > 0 { +// 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. +func resolveSliceFlag[T any](set bool, cliVal []T, sources []AltSource, coerce func(any) (T, bool)) []T { + if set { return cliVal } for _, src := range sources { - switch src.Type { - case "$ENV": - if val := os.Getenv(src.Property); val != "" { - parts := strings.Split(val, ",") - result := make([]float64, 0, len(parts)) - for _, p := range parts { - if n, err := strconv.ParseFloat(strings.TrimSpace(p), 64); err == nil { - result = append(result, n) - } - } - if len(result) > 0 { - return result - } - } - case "$FILE": - if val := resolveJSONPath(src.Property); val != nil { - if arr, ok := val.([]any); ok { - result := make([]float64, 0, len(arr)) - for _, item := range arr { - switch v := item.(type) { - case float64: - result = append(result, v) - case int64: - result = append(result, float64(v)) - } - } - if len(result) > 0 { - return result - } - } + result := make([]T, 0, len(altSourceItems(src))) + for _, item := range altSourceItems(src) { + if v, ok := coerce(item); ok { + result = append(result, v) } } + if len(result) > 0 { + return result + } } - return []float64{} + return []T{} +} + +// 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) } From 003b7c666dd5b39850beb92479166b2718fda994 Mon Sep 17 00:00:00 2001 From: bcdxn <5067649+bcdxn@users.noreply.github.com> Date: Sun, 16 Aug 2026 13:27:28 -0400 Subject: [PATCH 03/22] reviewed urfave impl --- gen/cli_urfave_cli.go | 50 ++++++++++++--- .../code/urfavecli/gencli/config.tmpl | 61 ++++++------------- gen/testdata/urfavecli/gencli/config.gen.go | 51 +++------------- 3 files changed, 68 insertions(+), 94 deletions(-) diff --git a/gen/cli_urfave_cli.go b/gen/cli_urfave_cli.go index 232a6ad..8de598b 100644 --- a/gen/cli_urfave_cli.go +++ b/gen/cli_urfave_cli.go @@ -26,6 +26,9 @@ type urfaveCliAllCommandsTmplData struct { ConfigYAML string // HasConfig is true if any config file path is declared HasConfig 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. @@ -111,17 +114,31 @@ func genCLIUrfaveCli(doc *spec.Document, opts *genCLIOptions) (map[string][]byte } } + // hasFileAltSource is true if any flag (global or command) declares a $FILE + // alternative source, which requires the generated config code to import a + // JSONPath library. + hasFileAltSource := hasFileAltSourceInFlags(globalFlags) + if !hasFileAltSource { + for _, cmdFile := range cmdFiles { + if hasFileAltSourceInFlags(cmdFile.UrfaveFlags) { + hasFileAltSource = true + break + } + } + } + allCmdsData := urfaveCliAllCommandsTmplData{ - ModuleVersion: opts.ModuleVersion, - Binary: binary, - BinaryPascal: binaryPascal, - LeafCommands: leafCommands, - ExitCodes: exitCodes, - GlobalFlags: globalFlags, - ConfigJSON: configJSON, - ConfigTOML: configTOML, - ConfigYAML: configYAML, - HasConfig: hasConfig, + ModuleVersion: opts.ModuleVersion, + Binary: binary, + BinaryPascal: binaryPascal, + LeafCommands: leafCommands, + ExitCodes: exitCodes, + GlobalFlags: globalFlags, + ConfigJSON: configJSON, + ConfigTOML: configTOML, + ConfigYAML: configYAML, + HasConfig: hasConfig, + HasFileAltSource: hasFileAltSource, } funcMap := urfaveCliTemplateFuncMap() @@ -167,6 +184,19 @@ func genCLIUrfaveCli(doc *spec.Document, opts *genCLIOptions) (map[string][]byte return out, nil } +// hasFileAltSourceInFlags reports whether any flag in the slice declares a $FILE +// alternative source. +func hasFileAltSourceInFlags(flags []urfaveCliFlagEntry) bool { + for _, flag := range flags { + for _, src := range flag.AltSources { + if src.Type == "$FILE" { + return true + } + } + } + return false +} + // walkUrfaveCliCmdTree recursively collects template data for all commands in the tree. func walkUrfaveCliCmdTree( doc *spec.Document, diff --git a/gen/templates/code/urfavecli/gencli/config.tmpl b/gen/templates/code/urfavecli/gencli/config.tmpl index ec16091..abbbebd 100644 --- a/gen/templates/code/urfavecli/gencli/config.tmpl +++ b/gen/templates/code/urfavecli/gencli/config.tmpl @@ -2,7 +2,9 @@ package gencli import ( +{{- if .ConfigJSON}} "encoding/json" +{{- end}} "os" "path/filepath" "strconv" @@ -13,6 +15,9 @@ import ( {{- 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 @@ -79,53 +84,21 @@ func loadConfig() { {{- end}} } -// resolveJSONPath resolves a simple JSONPath expression against the global -// config. Supports paths like $.key, $.key.subkey, and $.key[0]. -func resolveJSONPath(path string) any { +{{- 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 } - current := any(globalConfig) - for _, part := range strings.Split(strings.TrimPrefix(path, "$"), ".") { - if part == "" { - continue - } - // A part may carry an array index, e.g. "key[0]". - key, index, hasIndex := part, 0, false - if i := strings.Index(part, "["); i >= 0 { - key = part[:i] - idx, err := strconv.Atoi(part[i+1 : len(part)-1]) - if err != nil { - return nil // malformed index - } - index, hasIndex = idx, true - } - switch cur := current.(type) { - case map[string]any: - next, ok := cur[key] - if !ok { - return nil - } - if hasIndex { - arr, ok := next.([]any) - if !ok || index < 0 || index >= len(arr) { - return nil - } - next = arr[index] - } - current = next - case []any: - idx, err := strconv.Atoi(key) - if err != nil || idx < 0 || idx >= len(cur) { - return nil - } - current = cur[idx] - default: - return nil - } + p, err := jp.ParseString(expr) + if err != nil { + return nil } - return current + 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 @@ -136,10 +109,12 @@ func altSourceValue(src AltSource) any { 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 } @@ -162,6 +137,7 @@ func altSourceItems(src AltSource) []any { } } return items +{{- if .HasFileAltSource}} case "$FILE": switch v := resolveJSONPath(src.Property).(type) { case []any: @@ -171,6 +147,7 @@ func altSourceItems(src AltSource) []any { default: return []any{v} } +{{- end}} } return nil } diff --git a/gen/testdata/urfavecli/gencli/config.gen.go b/gen/testdata/urfavecli/gencli/config.gen.go index db8d977..82352d0 100644 --- a/gen/testdata/urfavecli/gencli/config.gen.go +++ b/gen/testdata/urfavecli/gencli/config.gen.go @@ -4,6 +4,7 @@ package gencli import ( "encoding/json" "github.com/BurntSushi/toml" + "github.com/ohler55/ojg/jp" "gopkg.in/yaml.v3" "os" "path/filepath" @@ -68,52 +69,18 @@ func loadConfig() { } } -// resolveJSONPath resolves a simple JSONPath expression against the global -// config. Supports paths like $.key, $.key.subkey, and $.key[0]. -func resolveJSONPath(path string) any { +// 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 } - current := any(globalConfig) - for _, part := range strings.Split(strings.TrimPrefix(path, "$"), ".") { - if part == "" { - continue - } - // A part may carry an array index, e.g. "key[0]". - key, index, hasIndex := part, 0, false - if i := strings.Index(part, "["); i >= 0 { - key = part[:i] - idx, err := strconv.Atoi(part[i+1 : len(part)-1]) - if err != nil { - return nil // malformed index - } - index, hasIndex = idx, true - } - switch cur := current.(type) { - case map[string]any: - next, ok := cur[key] - if !ok { - return nil - } - if hasIndex { - arr, ok := next.([]any) - if !ok || index < 0 || index >= len(arr) { - return nil - } - next = arr[index] - } - current = next - case []any: - idx, err := strconv.Atoi(key) - if err != nil || idx < 0 || idx >= len(cur) { - return nil - } - current = cur[idx] - default: - return nil - } + p, err := jp.ParseString(expr) + if err != nil { + return nil } - return current + return p.First(globalConfig) } // altSourceValue returns the raw value from a single alternative source, or nil From bfce2d71618f8fb8b8e065dbb74d0344fe658f91 Mon Sep 17 00:00:00 2001 From: bcdxn <5067649+bcdxn@users.noreply.github.com> Date: Sun, 16 Aug 2026 15:54:55 -0400 Subject: [PATCH 04/22] feat: enable alternative sources for cobra --- gen/cli_cobra.go | 111 +++++- gen/cli_test.go | 142 ++++++++ gen/templates/code/cobra/gencli/command.tmpl | 2 +- gen/templates/code/cobra/gencli/config.tmpl | 319 ++++++++++++++++++ gen/templates/code/cobra/gencli/run.tmpl | 6 +- .../gencli/cmd_petstore_user_login.gen.go | 4 +- gen/testdata/cobra/gencli/config.gen.go | 298 ++++++++++++++++ gen/testdata/cobra/gencli/run.go | 2 + go.mod | 2 + go.sum | 3 + internal/cli/gencli/cmd_ocli_gen_cli.gen.go | 2 +- internal/cli/gencli/cmd_ocli_gen_docs.gen.go | 2 +- internal/cli/gencli/config.gen.go | 280 +++++++++++++++ internal/cli/gencli/run.go | 2 + 14 files changed, 1162 insertions(+), 13 deletions(-) create mode 100644 gen/templates/code/cobra/gencli/config.tmpl create mode 100644 gen/testdata/cobra/gencli/config.gen.go create mode 100644 internal/cli/gencli/config.gen.go diff --git a/gen/cli_cobra.go b/gen/cli_cobra.go index 69ea63f..53ca189 100644 --- a/gen/cli_cobra.go +++ b/gen/cli_cobra.go @@ -20,6 +20,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. @@ -82,6 +92,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 +119,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 +140,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 +177,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) @@ -308,6 +346,7 @@ func walkCmdTree( TypeName: flagTypeName, Shorthand: shorthand, ExtraAliases: extraAliases, + AltSources: flag.AltSources, }) } @@ -381,7 +420,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. diff --git a/gen/cli_test.go b/gen/cli_test.go index e032ff3..c904efa 100644 --- a/gen/cli_test.go +++ b/gen/cli_test.go @@ -380,3 +380,145 @@ 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) + } + }) + } +} diff --git a/gen/templates/code/cobra/gencli/command.tmpl b/gen/templates/code/cobra/gencli/command.tmpl index 095ec15..c868336 100644 --- a/gen/templates/code/cobra/gencli/command.tmpl +++ b/gen/templates/code/cobra/gencli/command.tmpl @@ -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}} diff --git a/gen/templates/code/cobra/gencli/config.tmpl b/gen/templates/code/cobra/gencli/config.tmpl new file mode 100644 index 0000000..4012984 --- /dev/null +++ b/gen/templates/code/cobra/gencli/config.tmpl @@ -0,0 +1,319 @@ +// Code generated by github.com/bcdxn/opencli@{{.ModuleVersion}} DO NOT EDIT. +package gencli + +import ( +{{- if .ConfigJSON}} + "encoding/json" +{{- end}} + "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: + 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 zero value when no source yields +a usable result. 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. +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 + } + } + return "" +} + +// resolveInt64Flag resolves an int64 flag from the CLI or its alternative sources. +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 + } + } + return 0 +} + +// 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. +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 + } + } + return false +} + +// resolveFloat64Flag resolves a float64 flag from the CLI or its alternative sources. +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 + } + } + return 0 +} + +// 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 an empty slice. 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 { + result := make([]T, 0, len(altSourceItems(src))) + for _, item := range altSourceItems(src) { + if v, ok := coerce(item); ok { + result = append(result, v) + } + } + if len(result) > 0 { + return result + } + } + return []T{} +} + +// 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/run.tmpl b/gen/templates/code/cobra/gencli/run.tmpl index c697d16..01cc8e9 100644 --- a/gen/templates/code/cobra/gencli/run.tmpl +++ b/gen/templates/code/cobra/gencli/run.tmpl @@ -12,7 +12,11 @@ import ( // Run executes the root cobra command and returns an exit code. func Run(ctx context.Context, actions ActionsInterface) int { -{{- range .GlobalFlags}} +{{- if .HasAltSources}} + // Load config file from disk (JSON/YAML/TOML) for $FILE alternative sources + loadConfig() + +{{- end}}{{- range .GlobalFlags}} var {{.VarName}} {{.GoType}} {{- end}} // Instantiate root command 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..6882ab3 --- /dev/null +++ b/gen/testdata/cobra/gencli/config.gen.go @@ -0,0 +1,298 @@ +// 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" + "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: + 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 zero value when no source yields +a usable result. 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. +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 + } + } + return "" +} + +// resolveInt64Flag resolves an int64 flag from the CLI or its alternative sources. +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 + } + } + return 0 +} + +// 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. +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 + } + } + return false +} + +// resolveFloat64Flag resolves a float64 flag from the CLI or its alternative sources. +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 + } + } + return 0 +} + +// 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 an empty slice. 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 { + result := make([]T, 0, len(altSourceItems(src))) + for _, item := range altSourceItems(src) { + if v, ok := coerce(item); ok { + result = append(result, v) + } + } + if len(result) > 0 { + return result + } + } + return []T{} +} + +// 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.go index ba59c4d..cfb42a9 100644 --- a/gen/testdata/cobra/gencli/run.go +++ b/gen/testdata/cobra/gencli/run.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/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..cd65b4f --- /dev/null +++ b/internal/cli/gencli/config.gen.go @@ -0,0 +1,280 @@ +// Code generated by github.com/bcdxn/opencli@unknown version DO NOT EDIT. +package gencli + +import ( + "github.com/ohler55/ojg/jp" + "gopkg.in/yaml.v3" + "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: + 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 zero value when no source yields +a usable result. 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. +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 + } + } + return "" +} + +// resolveInt64Flag resolves an int64 flag from the CLI or its alternative sources. +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 + } + } + return 0 +} + +// 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. +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 + } + } + return false +} + +// resolveFloat64Flag resolves a float64 flag from the CLI or its alternative sources. +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 + } + } + return 0 +} + +// 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 an empty slice. 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 { + result := make([]T, 0, len(altSourceItems(src))) + for _, item := range altSourceItems(src) { + if v, ok := coerce(item); ok { + result = append(result, v) + } + } + if len(result) > 0 { + return result + } + } + return []T{} +} + +// 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.go index 9c683a0..fa0fd29 100644 --- a/internal/cli/gencli/run.go +++ b/internal/cli/gencli/run.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 From 5e6d6fba62111229c09b8e45f67b8f6c46e65b79 Mon Sep 17 00:00:00 2001 From: bcdxn <5067649+bcdxn@users.noreply.github.com> Date: Sun, 16 Aug 2026 17:14:05 -0400 Subject: [PATCH 05/22] fix: make urfavecli load config conditional --- gen/cli_test.go | 122 +++++++++++++++++++ gen/cli_urfave_cli.go | 58 +++++---- gen/templates/code/urfavecli/gencli/run.tmpl | 4 +- gen/testdata/urfavecli/gencli/run.gen.go | 2 +- 4 files changed, 158 insertions(+), 28 deletions(-) diff --git a/gen/cli_test.go b/gen/cli_test.go index c904efa..60e4f5f 100644 --- a/gen/cli_test.go +++ b/gen/cli_test.go @@ -522,3 +522,125 @@ func TestCobraScanAltSources(t *testing.T) { }) } } + +// 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") + } + }) +} diff --git a/gen/cli_urfave_cli.go b/gen/cli_urfave_cli.go index 8de598b..ee4f543 100644 --- a/gen/cli_urfave_cli.go +++ b/gen/cli_urfave_cli.go @@ -24,8 +24,9 @@ type urfaveCliAllCommandsTmplData struct { ConfigJSON string ConfigTOML string ConfigYAML string - // HasConfig is true if any config file path is declared - HasConfig bool + // 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 @@ -89,13 +90,11 @@ func genCLIUrfaveCli(doc *spec.Document, opts *genCLIOptions) (map[string][]byte var exitCodes []spec.ExitCode var globalFlags []urfaveCliFlagEntry var configJSON, configTOML, configYAML string - hasConfig := false if doc.Global != nil { exitCodes = doc.Global.ExitCodes configJSON = doc.Global.Config.JSON configTOML = doc.Global.Config.TOML configYAML = doc.Global.Config.YAML - hasConfig = configJSON != "" || configTOML != "" || configYAML != "" for _, flag := range doc.Global.Flags { if flag.Name == "help" || flag.Name == "version" { continue @@ -114,17 +113,14 @@ func genCLIUrfaveCli(doc *spec.Document, opts *genCLIOptions) (map[string][]byte } } - // hasFileAltSource is true if any flag (global or command) declares a $FILE - // alternative source, which requires the generated config code to import a - // JSONPath library. - hasFileAltSource := hasFileAltSourceInFlags(globalFlags) - if !hasFileAltSource { - for _, cmdFile := range cmdFiles { - if hasFileAltSourceInFlags(cmdFile.UrfaveFlags) { - hasFileAltSource = true - break - } - } + // 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{ @@ -137,7 +133,7 @@ func genCLIUrfaveCli(doc *spec.Document, opts *genCLIOptions) (map[string][]byte ConfigJSON: configJSON, ConfigTOML: configTOML, ConfigYAML: configYAML, - HasConfig: hasConfig, + HasAltSources: hasAltSources, HasFileAltSource: hasFileAltSource, } @@ -149,14 +145,19 @@ func genCLIUrfaveCli(doc *spec.Document, opts *genCLIOptions) (map[string][]byte } gencliFiles := []gencliFile{ {"gencli/actions.gen.go", "templates/code/urfavecli/gencli/actions.tmpl"}, - {"gencli/config.gen.go", "templates/code/urfavecli/gencli/config.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 { @@ -184,17 +185,22 @@ func genCLIUrfaveCli(doc *spec.Document, opts *genCLIOptions) (map[string][]byte return out, nil } -// hasFileAltSourceInFlags reports whether any flag in the slice declares a $FILE -// alternative source. -func hasFileAltSourceInFlags(flags []urfaveCliFlagEntry) bool { - for _, flag := range flags { - for _, src := range flag.AltSources { +// 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" { - return true + hasFile = true } } } - return false + return hasAlt, hasFile } // walkUrfaveCliCmdTree recursively collects template data for all commands in the tree. diff --git a/gen/templates/code/urfavecli/gencli/run.tmpl b/gen/templates/code/urfavecli/gencli/run.tmpl index 756c6a8..856d585 100644 --- a/gen/templates/code/urfavecli/gencli/run.tmpl +++ b/gen/templates/code/urfavecli/gencli/run.tmpl @@ -10,9 +10,11 @@ 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) +{{- 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/testdata/urfavecli/gencli/run.gen.go b/gen/testdata/urfavecli/gencli/run.gen.go index 308fff0..b970d4a 100644 --- a/gen/testdata/urfavecli/gencli/run.gen.go +++ b/gen/testdata/urfavecli/gencli/run.gen.go @@ -10,7 +10,7 @@ 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) + // Load config file from disk (JSON/YAML/TOML) for $FILE alternative sources loadConfig() // Setup custom help printer with glamour/lipgloss rendering From f559563489fc441ac56254922f3436aafd1f17c8 Mon Sep 17 00:00:00 2001 From: bcdxn <5067649+bcdxn@users.noreply.github.com> Date: Sun, 16 Aug 2026 20:13:00 -0400 Subject: [PATCH 06/22] fix: default bug --- gen/templates/code/cobra/gencli/config.tmpl | 38 ++++++++++++------- .../code/urfavecli/gencli/config.tmpl | 30 +++++++++------ gen/testdata/cobra/gencli/config.gen.go | 38 ++++++++++++------- gen/testdata/urfavecli/gencli/config.gen.go | 30 +++++++++------ 4 files changed, 86 insertions(+), 50 deletions(-) diff --git a/gen/templates/code/cobra/gencli/config.tmpl b/gen/templates/code/cobra/gencli/config.tmpl index 4012984..8783f32 100644 --- a/gen/templates/code/cobra/gencli/config.tmpl +++ b/gen/templates/code/cobra/gencli/config.tmpl @@ -212,12 +212,14 @@ 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 zero value when no source yields -a usable result. The merged pflag.FlagSet passed in already contains both local and -inherited persistent flags at RunE time. +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. +// 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) @@ -228,10 +230,12 @@ func resolveStringFlag(fs *pflag.FlagSet, name string, sources []AltSource) stri return v } } - return "" + defaultVal, _ := fs.GetString(name) + return defaultVal } -// resolveInt64Flag resolves an int64 flag from the CLI or its alternative sources. +// 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) @@ -242,12 +246,14 @@ func resolveInt64Flag(fs *pflag.FlagSet, name string, sources []AltSource) int64 return v } } - return 0 + 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. +// 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) @@ -258,10 +264,12 @@ func resolveBoolFlag(fs *pflag.FlagSet, name string, sources []AltSource) bool { return v } } - return false + defaultVal, _ := fs.GetBool(name) + return defaultVal } -// resolveFloat64Flag resolves a float64 flag from the CLI or its alternative sources. +// 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) @@ -272,13 +280,14 @@ func resolveFloat64Flag(fs *pflag.FlagSet, name string, sources []AltSource) flo return v } } - return 0 + 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 an empty slice. get is the bound pflag getter for the -// concrete element type (e.g. fs.GetStringArray). +// 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) @@ -295,7 +304,8 @@ func resolveSliceFlag[T any](fs *pflag.FlagSet, name string, sources []AltSource return result } } - return []T{} + defaultVal, _ := get(name) + return defaultVal } // resolveStringSliceFlag resolves a string slice flag from the CLI or its alternative sources. diff --git a/gen/templates/code/urfavecli/gencli/config.tmpl b/gen/templates/code/urfavecli/gencli/config.tmpl index abbbebd..737a0a4 100644 --- a/gen/templates/code/urfavecli/gencli/config.tmpl +++ b/gen/templates/code/urfavecli/gencli/config.tmpl @@ -208,10 +208,14 @@ 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. +(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. +// 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 @@ -221,10 +225,11 @@ func resolveStringFlag(set bool, cliVal string, sources []AltSource) string { return v } } - return "" + return cliVal } -// resolveInt64Flag resolves an int64 flag from the CLI or its alternative sources. +// 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 @@ -234,10 +239,11 @@ func resolveInt64Flag(set bool, cliVal int64, sources []AltSource) int64 { return v } } - return 0 + return cliVal } -// resolveBoolFlag resolves a bool flag from the CLI or its alternative sources. +// 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 @@ -247,10 +253,11 @@ func resolveBoolFlag(set bool, cliVal bool, sources []AltSource) bool { return v } } - return false + return cliVal } -// resolveFloat64Flag resolves a float64 flag from the CLI or its alternative sources. +// 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 @@ -260,12 +267,13 @@ func resolveFloat64Flag(set bool, cliVal float64, sources []AltSource) float64 { return v } } - return 0 + 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. +// 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 @@ -281,7 +289,7 @@ func resolveSliceFlag[T any](set bool, cliVal []T, sources []AltSource, coerce f return result } } - return []T{} + return cliVal } // resolveStringSliceFlag resolves a string slice flag from the CLI or its alternative sources. diff --git a/gen/testdata/cobra/gencli/config.gen.go b/gen/testdata/cobra/gencli/config.gen.go index 6882ab3..349b69e 100644 --- a/gen/testdata/cobra/gencli/config.gen.go +++ b/gen/testdata/cobra/gencli/config.gen.go @@ -191,12 +191,14 @@ 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 zero value when no source yields -a usable result. The merged pflag.FlagSet passed in already contains both local and -inherited persistent flags at RunE time. +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. +// 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) @@ -207,10 +209,12 @@ func resolveStringFlag(fs *pflag.FlagSet, name string, sources []AltSource) stri return v } } - return "" + defaultVal, _ := fs.GetString(name) + return defaultVal } -// resolveInt64Flag resolves an int64 flag from the CLI or its alternative sources. +// 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) @@ -221,12 +225,14 @@ func resolveInt64Flag(fs *pflag.FlagSet, name string, sources []AltSource) int64 return v } } - return 0 + 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. +// 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) @@ -237,10 +243,12 @@ func resolveBoolFlag(fs *pflag.FlagSet, name string, sources []AltSource) bool { return v } } - return false + defaultVal, _ := fs.GetBool(name) + return defaultVal } -// resolveFloat64Flag resolves a float64 flag from the CLI or its alternative sources. +// 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) @@ -251,13 +259,14 @@ func resolveFloat64Flag(fs *pflag.FlagSet, name string, sources []AltSource) flo return v } } - return 0 + 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 an empty slice. get is the bound pflag getter for the -// concrete element type (e.g. fs.GetStringArray). +// 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) @@ -274,7 +283,8 @@ func resolveSliceFlag[T any](fs *pflag.FlagSet, name string, sources []AltSource return result } } - return []T{} + defaultVal, _ := get(name) + return defaultVal } // resolveStringSliceFlag resolves a string slice flag from the CLI or its alternative sources. diff --git a/gen/testdata/urfavecli/gencli/config.gen.go b/gen/testdata/urfavecli/gencli/config.gen.go index 82352d0..79760b2 100644 --- a/gen/testdata/urfavecli/gencli/config.gen.go +++ b/gen/testdata/urfavecli/gencli/config.gen.go @@ -187,10 +187,14 @@ 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. +(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. +// 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 @@ -200,10 +204,11 @@ func resolveStringFlag(set bool, cliVal string, sources []AltSource) string { return v } } - return "" + return cliVal } -// resolveInt64Flag resolves an int64 flag from the CLI or its alternative sources. +// 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 @@ -213,10 +218,11 @@ func resolveInt64Flag(set bool, cliVal int64, sources []AltSource) int64 { return v } } - return 0 + return cliVal } -// resolveBoolFlag resolves a bool flag from the CLI or its alternative sources. +// 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 @@ -226,10 +232,11 @@ func resolveBoolFlag(set bool, cliVal bool, sources []AltSource) bool { return v } } - return false + return cliVal } -// resolveFloat64Flag resolves a float64 flag from the CLI or its alternative sources. +// 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 @@ -239,12 +246,13 @@ func resolveFloat64Flag(set bool, cliVal float64, sources []AltSource) float64 { return v } } - return 0 + 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. +// 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 @@ -260,7 +268,7 @@ func resolveSliceFlag[T any](set bool, cliVal []T, sources []AltSource, coerce f return result } } - return []T{} + return cliVal } // resolveStringSliceFlag resolves a string slice flag from the CLI or its alternative sources. From b74a3fc87f0a2a3855ed284b93b021ca12df80d4 Mon Sep 17 00:00:00 2001 From: bcdxn <5067649+bcdxn@users.noreply.github.com> Date: Sun, 16 Aug 2026 21:06:51 -0400 Subject: [PATCH 07/22] fix: examples --- Makefile | 4 + examples/code/gencli/actions.gen.go | 36 --- examples/code/gencli/cmd_petstore.gen.go | 62 ----- examples/code/gencli/cmd_petstore_list.gen.go | 51 ---- examples/code/gencli/cmd_petstore_pet.gen.go | 70 ------ .../code/gencli/cmd_petstore_pet_add.gen.go | 78 ------ .../gencli/cmd_petstore_pet_delete.gen.go | 55 ----- .../cmd_petstore_pet_findbystatus.gen.go | 56 ----- .../gencli/cmd_petstore_pet_findbytags.gen.go | 51 ---- .../code/gencli/cmd_petstore_pet_get.gen.go | 51 ---- .../gencli/cmd_petstore_pet_update.gen.go | 78 ------ .../gencli/cmd_petstore_pet_updateform.gen.go | 64 ----- .../cmd_petstore_pet_uploadimage.gen.go | 65 ----- .../code/gencli/cmd_petstore_store.gen.go | 58 ----- .../cmd_petstore_store_inventory.gen.go | 40 --- .../gencli/cmd_petstore_store_order.gen.go | 60 ----- .../cmd_petstore_store_order_delete.gen.go | 50 ---- .../cmd_petstore_store_order_get.gen.go | 51 ---- .../cmd_petstore_store_order_place.gen.go | 78 ------ examples/code/gencli/cmd_petstore_user.gen.go | 68 ------ .../gencli/cmd_petstore_user_create.gen.go | 85 ------- .../cmd_petstore_user_createwithlist.gen.go | 50 ---- .../gencli/cmd_petstore_user_delete.gen.go | 52 ---- .../code/gencli/cmd_petstore_user_get.gen.go | 51 ---- .../gencli/cmd_petstore_user_login.gen.go | 55 ----- .../gencli/cmd_petstore_user_logout.gen.go | 40 --- .../gencli/cmd_petstore_user_update.gen.go | 75 ------ examples/code/gencli/params.gen.go | 228 ------------------ examples/code/urfavecli/pleasantries/go.mod | 39 +++ examples/code/urfavecli/pleasantries/go.sum | 80 ++++++ .../pleasantries/internal/cli/actions.go | 55 +++++ .../internal/gencli/actions.gen.go | 18 ++ .../internal/gencli/cmd_pleasantries.gen.go | 51 ++++ .../gencli/cmd_pleasantries_farewell.gen.go | 63 +++++ .../gencli/cmd_pleasantries_greet.gen.go | 63 +++++ .../internal}/gencli/errors.gen.go | 0 .../pleasantries/internal}/gencli/help.gen.go | 53 ++++ .../internal}/gencli/iostreams.gen.go | 0 .../internal/gencli/params.gen.go | 54 +++++ .../pleasantries/internal/gencli/run.gen.go} | 10 +- examples/code/urfavecli/pleasantries/main.go | 18 ++ gen/cli_cobra.go | 2 + gen/cli_urfave_cli.go | 2 + gen/templates/code/cobra/gencli/actions.tmpl | 2 +- gen/templates/code/cobra/gencli/command.tmpl | 9 +- gen/templates/code/cobra/gencli/params.tmpl | 9 + gen/templates/code/cobra/gencli/run.tmpl | 5 +- .../code/urfavecli/gencli/actions.tmpl | 2 +- .../code/urfavecli/gencli/command.tmpl | 9 +- .../code/urfavecli/gencli/params.tmpl | 9 + gen/templates/code/urfavecli/gencli/run.tmpl | 4 + 51 files changed, 552 insertions(+), 1767 deletions(-) delete mode 100644 examples/code/gencli/actions.gen.go delete mode 100644 examples/code/gencli/cmd_petstore.gen.go delete mode 100644 examples/code/gencli/cmd_petstore_list.gen.go delete mode 100644 examples/code/gencli/cmd_petstore_pet.gen.go delete mode 100644 examples/code/gencli/cmd_petstore_pet_add.gen.go delete mode 100644 examples/code/gencli/cmd_petstore_pet_delete.gen.go delete mode 100644 examples/code/gencli/cmd_petstore_pet_findbystatus.gen.go delete mode 100644 examples/code/gencli/cmd_petstore_pet_findbytags.gen.go delete mode 100644 examples/code/gencli/cmd_petstore_pet_get.gen.go delete mode 100644 examples/code/gencli/cmd_petstore_pet_update.gen.go delete mode 100644 examples/code/gencli/cmd_petstore_pet_updateform.gen.go delete mode 100644 examples/code/gencli/cmd_petstore_pet_uploadimage.gen.go delete mode 100644 examples/code/gencli/cmd_petstore_store.gen.go delete mode 100644 examples/code/gencli/cmd_petstore_store_inventory.gen.go delete mode 100644 examples/code/gencli/cmd_petstore_store_order.gen.go delete mode 100644 examples/code/gencli/cmd_petstore_store_order_delete.gen.go delete mode 100644 examples/code/gencli/cmd_petstore_store_order_get.gen.go delete mode 100644 examples/code/gencli/cmd_petstore_store_order_place.gen.go delete mode 100644 examples/code/gencli/cmd_petstore_user.gen.go delete mode 100644 examples/code/gencli/cmd_petstore_user_create.gen.go delete mode 100644 examples/code/gencli/cmd_petstore_user_createwithlist.gen.go delete mode 100644 examples/code/gencli/cmd_petstore_user_delete.gen.go delete mode 100644 examples/code/gencli/cmd_petstore_user_get.gen.go delete mode 100644 examples/code/gencli/cmd_petstore_user_login.gen.go delete mode 100644 examples/code/gencli/cmd_petstore_user_logout.gen.go delete mode 100644 examples/code/gencli/cmd_petstore_user_update.gen.go delete mode 100644 examples/code/gencli/params.gen.go create mode 100644 examples/code/urfavecli/pleasantries/go.mod create mode 100644 examples/code/urfavecli/pleasantries/go.sum create mode 100644 examples/code/urfavecli/pleasantries/internal/cli/actions.go create mode 100644 examples/code/urfavecli/pleasantries/internal/gencli/actions.gen.go create mode 100644 examples/code/urfavecli/pleasantries/internal/gencli/cmd_pleasantries.gen.go create mode 100644 examples/code/urfavecli/pleasantries/internal/gencli/cmd_pleasantries_farewell.gen.go create mode 100644 examples/code/urfavecli/pleasantries/internal/gencli/cmd_pleasantries_greet.gen.go rename examples/code/{ => urfavecli/pleasantries/internal}/gencli/errors.gen.go (100%) rename examples/code/{ => urfavecli/pleasantries/internal}/gencli/help.gen.go (81%) rename examples/code/{ => urfavecli/pleasantries/internal}/gencli/iostreams.gen.go (100%) create mode 100644 examples/code/urfavecli/pleasantries/internal/gencli/params.gen.go rename examples/code/{gencli/run.go => urfavecli/pleasantries/internal/gencli/run.gen.go} (67%) create mode 100644 examples/code/urfavecli/pleasantries/main.go diff --git a/Makefile b/Makefile index b625bce..b035993 100644 --- a/Makefile +++ b/Makefile @@ -44,6 +44,10 @@ 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: release release: gen-docs gen-examples 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/gencli/help.gen.go b/examples/code/urfavecli/pleasantries/internal/gencli/help.gen.go similarity index 81% rename from examples/code/gencli/help.gen.go rename to examples/code/urfavecli/pleasantries/internal/gencli/help.gen.go index bad3fc3..00eb52f 100644 --- a/examples/code/gencli/help.gen.go +++ b/examples/code/urfavecli/pleasantries/internal/gencli/help.gen.go @@ -3,11 +3,13 @@ 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 @@ -373,3 +375,54 @@ func columnWidth(rows []string) int { } 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/gen/cli_cobra.go b/gen/cli_cobra.go index 53ca189..d70f25a 100644 --- a/gen/cli_cobra.go +++ b/gen/cli_cobra.go @@ -63,6 +63,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). @@ -213,6 +214,7 @@ func genCLICobra(doc *spec.Document, opts *genCLIOptions) (map[string][]byte, er out["gencli/run.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) diff --git a/gen/cli_urfave_cli.go b/gen/cli_urfave_cli.go index ee4f543..41fca0b 100644 --- a/gen/cli_urfave_cli.go +++ b/gen/cli_urfave_cli.go @@ -40,6 +40,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. @@ -171,6 +172,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) diff --git a/gen/templates/code/cobra/gencli/actions.tmpl b/gen/templates/code/cobra/gencli/actions.tmpl index 0a75df0..4270476 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}}{{if $.GlobalFlags}}globalFlags GlobalFlags{{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 c868336..d987e8d 100644 --- a/gen/templates/code/cobra/gencli/command.tmpl +++ b/gen/templates/code/cobra/gencli/command.tmpl @@ -62,7 +62,14 @@ func {{.FuncName}}(a ActionsInterface) *cobra.Command { {{- end}} {{- end}} {{- end}} - return a.{{.MethodName}}(c.Context(), {{if and .CobraArgs .CobraFlags}}cmdArgs, cmdFlags{{else if .CobraArgs}}cmdArgs{{else if .CobraFlags}}cmdFlags{{end}}) +{{- if .GlobalFlags}} + globalFlags := GlobalFlags{ +{{- range .GlobalFlags}} + {{.FieldName}}: {{resolveFlagValue .}}, +{{- end}} + } +{{- end}} + return a.{{.MethodName}}(c.Context(), {{if and .CobraArgs .CobraFlags}}cmdArgs, cmdFlags{{else if .CobraArgs}}cmdArgs{{else if .CobraFlags}}cmdFlags{{end}}{{if .GlobalFlags}}, globalFlags{{end}}) }, {{- end}} } diff --git a/gen/templates/code/cobra/gencli/params.tmpl b/gen/templates/code/cobra/gencli/params.tmpl index b3bcdb9..4fefbb0 100644 --- a/gen/templates/code/cobra/gencli/params.tmpl +++ b/gen/templates/code/cobra/gencli/params.tmpl @@ -52,3 +52,12 @@ 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}} +} +{{end}} diff --git a/gen/templates/code/cobra/gencli/run.tmpl b/gen/templates/code/cobra/gencli/run.tmpl index 01cc8e9..56d9526 100644 --- a/gen/templates/code/cobra/gencli/run.tmpl +++ b/gen/templates/code/cobra/gencli/run.tmpl @@ -10,14 +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 { {{- if .HasAltSources}} // Load config file from disk (JSON/YAML/TOML) for $FILE alternative sources loadConfig() -{{- end}}{{- range .GlobalFlags}} - var {{.VarName}} {{.GoType}} {{- 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..5571e98 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}}{{if $.GlobalFlags}}globalFlags GlobalFlags{{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 92e7e02..28c72c8 100644 --- a/gen/templates/code/urfavecli/gencli/command.tmpl +++ b/gen/templates/code/urfavecli/gencli/command.tmpl @@ -55,7 +55,14 @@ func {{.FuncName}}(a ActionsInterface) *cli.Command { {{- end}} {{- end}} {{- end}} - return a.{{.MethodName}}(ctx, {{if and .UrfaveArgs .UrfaveFlags}}cmdArgs, cmdFlags{{else if .UrfaveArgs}}cmdArgs{{else if .UrfaveFlags}}cmdFlags{{end}}) +{{- if .GlobalFlags}} + globalFlags := 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}}{{if .GlobalFlags}}, globalFlags{{end}}) }, {{- end}} } diff --git a/gen/templates/code/urfavecli/gencli/params.tmpl b/gen/templates/code/urfavecli/gencli/params.tmpl index b3bcdb9..4fefbb0 100644 --- a/gen/templates/code/urfavecli/gencli/params.tmpl +++ b/gen/templates/code/urfavecli/gencli/params.tmpl @@ -52,3 +52,12 @@ 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}} +} +{{end}} diff --git a/gen/templates/code/urfavecli/gencli/run.tmpl b/gen/templates/code/urfavecli/gencli/run.tmpl index 856d585..bc1acd6 100644 --- a/gen/templates/code/urfavecli/gencli/run.tmpl +++ b/gen/templates/code/urfavecli/gencli/run.tmpl @@ -6,6 +6,10 @@ import ( "errors" "fmt" "os" +{{- if .GlobalFlags}} + + "github.com/urfave/cli/v3" +{{- end}} ) // Run executes the root urfave command and returns an exit code. From f5ac20317182d73ef2d91d8da94420dda394a36b Mon Sep 17 00:00:00 2001 From: bcdxn <5067649+bcdxn@users.noreply.github.com> Date: Sun, 16 Aug 2026 21:37:44 -0400 Subject: [PATCH 08/22] fix: global flags and alt sources --- .github/copilot-instructions.md | 1 + Makefile | 9 + examples/code/README.md | 34 +- .../internal/gencli/{run.go => run.gen.go} | 0 gen/cli_cobra.go | 6 +- .../cobra/gencli/{run.go => run.gen.go} | 0 internal/cli/gencli/config.gen.go | 38 +- internal/cli/gencli/{run.go => run.gen.go} | 0 internal/gencli/actions.gen.go | 19 + internal/gencli/cmd_ocli.gen.go | 58 +++ internal/gencli/cmd_ocli_check.gen.go | 61 +++ internal/gencli/cmd_ocli_gen.gen.go | 59 +++ internal/gencli/cmd_ocli_gen_cli.gen.go | 70 ++++ internal/gencli/cmd_ocli_gen_docs.gen.go | 78 ++++ internal/gencli/config.gen.go | 290 ++++++++++++++ internal/gencli/errors.gen.go | 54 +++ internal/gencli/help.gen.go | 375 ++++++++++++++++++ internal/gencli/iostreams.gen.go | 197 +++++++++ internal/gencli/params.gen.go | 71 ++++ internal/gencli/run.gen.go | 32 ++ web/src/views/GeneratingGoCode.tsx | 2 +- 21 files changed, 1422 insertions(+), 32 deletions(-) rename examples/code/cobra/pleasantries/internal/gencli/{run.go => run.gen.go} (100%) rename gen/testdata/cobra/gencli/{run.go => run.gen.go} (100%) rename internal/cli/gencli/{run.go => run.gen.go} (100%) create mode 100644 internal/gencli/actions.gen.go create mode 100644 internal/gencli/cmd_ocli.gen.go create mode 100644 internal/gencli/cmd_ocli_check.gen.go create mode 100644 internal/gencli/cmd_ocli_gen.gen.go create mode 100644 internal/gencli/cmd_ocli_gen_cli.gen.go create mode 100644 internal/gencli/cmd_ocli_gen_docs.gen.go create mode 100644 internal/gencli/config.gen.go create mode 100644 internal/gencli/errors.gen.go create mode 100644 internal/gencli/help.gen.go create mode 100644 internal/gencli/iostreams.gen.go create mode 100644 internal/gencli/params.gen.go create mode 100644 internal/gencli/run.gen.go diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 3b5fa46..1b2e7a8 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -123,3 +123,4 @@ OpenCLI Specification is a declarative, language-agnostic document specification - The project is self-documenting: `opencli.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 diff --git a/Makefile b/Makefile index b035993..2d38c74 100644 --- a/Makefile +++ b/Makefile @@ -10,6 +10,7 @@ test: generate version = $(shell git describe --tags HEAD) + .PHONY: gen-docs gen-docs: generate @go run cmd/ocli/main.go gen docs \ @@ -26,6 +27,7 @@ gen-docs: generate opencli.ocs.yaml mkdir -p build && mv docs/opencli.ocs.1 build/ocli.1 + .PHONY: gen-examples gen-examples: generate @go run cmd/ocli/main.go gen docs \ @@ -49,6 +51,13 @@ gen-examples: generate --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 \ + ./opencli.ocs.yaml + .PHONY: release release: gen-docs gen-examples @echo "building OpenCLI version $(version)" 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/gen/cli_cobra.go b/gen/cli_cobra.go index d70f25a..e36b983 100644 --- a/gen/cli_cobra.go +++ b/gen/cli_cobra.go @@ -205,13 +205,13 @@ 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 diff --git a/gen/testdata/cobra/gencli/run.go b/gen/testdata/cobra/gencli/run.gen.go similarity index 100% rename from gen/testdata/cobra/gencli/run.go rename to gen/testdata/cobra/gencli/run.gen.go diff --git a/internal/cli/gencli/config.gen.go b/internal/cli/gencli/config.gen.go index cd65b4f..886d796 100644 --- a/internal/cli/gencli/config.gen.go +++ b/internal/cli/gencli/config.gen.go @@ -173,12 +173,14 @@ 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 zero value when no source yields -a usable result. The merged pflag.FlagSet passed in already contains both local and -inherited persistent flags at RunE time. +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. +// 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) @@ -189,10 +191,12 @@ func resolveStringFlag(fs *pflag.FlagSet, name string, sources []AltSource) stri return v } } - return "" + defaultVal, _ := fs.GetString(name) + return defaultVal } -// resolveInt64Flag resolves an int64 flag from the CLI or its alternative sources. +// 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) @@ -203,12 +207,14 @@ func resolveInt64Flag(fs *pflag.FlagSet, name string, sources []AltSource) int64 return v } } - return 0 + 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. +// 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) @@ -219,10 +225,12 @@ func resolveBoolFlag(fs *pflag.FlagSet, name string, sources []AltSource) bool { return v } } - return false + defaultVal, _ := fs.GetBool(name) + return defaultVal } -// resolveFloat64Flag resolves a float64 flag from the CLI or its alternative sources. +// 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) @@ -233,13 +241,14 @@ func resolveFloat64Flag(fs *pflag.FlagSet, name string, sources []AltSource) flo return v } } - return 0 + 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 an empty slice. get is the bound pflag getter for the -// concrete element type (e.g. fs.GetStringArray). +// 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) @@ -256,7 +265,8 @@ func resolveSliceFlag[T any](fs *pflag.FlagSet, name string, sources []AltSource return result } } - return []T{} + defaultVal, _ := get(name) + return defaultVal } // resolveStringSliceFlag resolves a string slice flag from the CLI or its alternative sources. diff --git a/internal/cli/gencli/run.go b/internal/cli/gencli/run.gen.go similarity index 100% rename from internal/cli/gencli/run.go rename to internal/cli/gencli/run.gen.go diff --git a/internal/gencli/actions.gen.go b/internal/gencli/actions.gen.go new file mode 100644 index 0000000..0b3d7b2 --- /dev/null +++ b/internal/gencli/actions.gen.go @@ -0,0 +1,19 @@ +// 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 ocli CLI supports. +type ActionsInterface interface { + OcliCheck(ctx context.Context, args OcliCheckArgs, flags OcliCheckFlags) error + OcliGenDocs(ctx context.Context, args OcliGenDocsArgs, flags OcliGenDocsFlags) error + OcliGenCli(ctx context.Context, args OcliGenCliArgs, flags OcliGenCliFlags) error + HelpFunc(cmd *spec.CommandItem) + UsageFunc(cmd *spec.CommandItem) error + IOStreams() IOStreams + Version() string +} diff --git a/internal/gencli/cmd_ocli.gen.go b/internal/gencli/cmd_ocli.gen.go new file mode 100644 index 0000000..3f7b643 --- /dev/null +++ b/internal/gencli/cmd_ocli.gen.go @@ -0,0 +1,58 @@ +// 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 NewCmdOcli(a ActionsInterface) *cobra.Command { + command := &cobra.Command{ + Use: "ocli", + Short: "A CLI for working with OpenCLI Specs", + Long: "`ocli` is a command line interface designed to make working with [OpenCLI\nSpec documents](https://github.com/bcdxn/opencli/tree/main) easier. It provides a number of capabilities, including:\n\n- validating OpenCLI Spec documents\n- generating Documentation from OpenCLI Spec documents\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\nocli gen --help\n```\n", + Args: cobra.NoArgs, + RunE: func(c *cobra.Command, args []string) error { + return BadUserInput("subcommand is required", func() error { + return a.UsageFunc(getSpecOcliCmd()) + }) + }, + } + command.SilenceErrors = true + command.SilenceUsage = true + command.AddCommand(NewCmdOcliCheck(a)) + command.AddCommand(NewCmdOcliGen(a)) + command.SetHelpFunc(func(_ *cobra.Command, _ []string) { + a.HelpFunc(getSpecOcliCmd()) + }) + command.SetUsageFunc(func(_ *cobra.Command) error { + return a.UsageFunc(getSpecOcliCmd()) + }) + + return command +} + +func getSpecOcliCmd() *spec.CommandItem { + return &spec.CommandItem{ + Segment: "ocli", + CommandLine: "ocli", + Summary: "A CLI for working with OpenCLI Specs", + Description: "`ocli` is a command line interface designed to make working with [OpenCLI\nSpec documents](https://github.com/bcdxn/opencli/tree/main) easier. It provides a number of capabilities, including:\n\n- validating OpenCLI Spec documents\n- generating Documentation from OpenCLI Spec documents\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\nocli gen --help\n```\n", + VisibleChildren: true, + VisibleArgs: false, + VisibleFlags: false, + CommandModifiers: []string{ + "{command}", + }, + ArgsModifiers: []string{ + "", + }, + FlagsModifiers: []string{ + "[flags]", + }, + Commands: []*spec.CommandItem{ + {Segment: "check", Summary: "Check an OpenCLI Spec document for errors"}, + {Segment: "gen", Summary: "Collection of commands used to generate code/docs from an OpenCLI Spec document"}, + }, + } +} diff --git a/internal/gencli/cmd_ocli_check.gen.go b/internal/gencli/cmd_ocli_check.gen.go new file mode 100644 index 0000000..0613e7d --- /dev/null +++ b/internal/gencli/cmd_ocli_check.gen.go @@ -0,0 +1,61 @@ +// 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 NewCmdOcliCheck(a ActionsInterface) *cobra.Command { + var flagFailOnErr bool + command := &cobra.Command{ + Use: "check", + Short: "Check an OpenCLI Spec document for errors", + Long: "The `check` command will validate an OpenCLI Spec document and return any\nerrors that are found.", + RunE: func(c *cobra.Command, args []string) error { + cmdArgs := OcliCheckArgs{} + if len(args) > 0 { + cmdArgs.PathToSpec = args[0] + } + cmdFlags := OcliCheckFlags{ + FailOnErr: flagFailOnErr, + } + return a.OcliCheck(c.Context(), cmdArgs, cmdFlags) + }, + } + command.SilenceErrors = true + command.SilenceUsage = true + command.Flags().BoolVarP(&flagFailOnErr, "fail-on-err", "", true, "Return a non-zero exit code if validation fails") + command.SetHelpFunc(func(_ *cobra.Command, _ []string) { + a.HelpFunc(getSpecOcliCheckCmd()) + }) + command.SetUsageFunc(func(_ *cobra.Command) error { + return a.UsageFunc(getSpecOcliCheckCmd()) + }) + + return command +} + +func getSpecOcliCheckCmd() *spec.CommandItem { + return &spec.CommandItem{ + Segment: "check", + CommandLine: "ocli check", + Summary: "Check an OpenCLI Spec document for errors", + Description: "The `check` command will validate an OpenCLI Spec document and return any\nerrors that are found.", + VisibleChildren: false, + VisibleArgs: true, + VisibleFlags: true, + ArgsModifiers: []string{ + "", + }, + FlagsModifiers: []string{ + "[flags]", + }, + Args: []spec.ArgumentItem{ + {Name: "path-to-spec", Summary: "The path to the OpenCLI Spec document to check"}, + }, + Flags: []spec.FlagItem{ + {Name: "fail-on-err", Summary: "Return a non-zero exit code if validation fails"}, + }, + } +} diff --git a/internal/gencli/cmd_ocli_gen.gen.go b/internal/gencli/cmd_ocli_gen.gen.go new file mode 100644 index 0000000..846f48f --- /dev/null +++ b/internal/gencli/cmd_ocli_gen.gen.go @@ -0,0 +1,59 @@ +// 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 NewCmdOcliGen(a ActionsInterface) *cobra.Command { + command := &cobra.Command{ + Use: "gen", + Short: "Collection of commands used to generate code/docs from an OpenCLI Spec document", + Long: "", + Aliases: []string{"gen"}, + Args: cobra.NoArgs, + RunE: func(c *cobra.Command, args []string) error { + return BadUserInput("subcommand is required", func() error { + return a.UsageFunc(getSpecOcliGenCmd()) + }) + }, + } + command.SilenceErrors = true + command.SilenceUsage = true + command.AddCommand(NewCmdOcliGenDocs(a)) + command.AddCommand(NewCmdOcliGenCli(a)) + command.SetHelpFunc(func(_ *cobra.Command, _ []string) { + a.HelpFunc(getSpecOcliGenCmd()) + }) + command.SetUsageFunc(func(_ *cobra.Command) error { + return a.UsageFunc(getSpecOcliGenCmd()) + }) + + return command +} + +func getSpecOcliGenCmd() *spec.CommandItem { + return &spec.CommandItem{ + Segment: "gen", + CommandLine: "ocli gen", + Summary: "Collection of commands used to generate code/docs from an OpenCLI Spec document", + Description: "", + VisibleChildren: true, + VisibleArgs: false, + VisibleFlags: false, + CommandModifiers: []string{ + "{command}", + }, + ArgsModifiers: []string{ + "", + }, + FlagsModifiers: []string{ + "[flags]", + }, + Commands: []*spec.CommandItem{ + {Segment: "docs", Summary: "Generate documentation"}, + {Segment: "cli", Summary: "Generate CLI Boilerplate code"}, + }, + } +} diff --git a/internal/gencli/cmd_ocli_gen_cli.gen.go b/internal/gencli/cmd_ocli_gen_cli.gen.go new file mode 100644 index 0000000..55b7f17 --- /dev/null +++ b/internal/gencli/cmd_ocli_gen_cli.gen.go @@ -0,0 +1,70 @@ +// 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 NewCmdOcliGenCli(a ActionsInterface) *cobra.Command { + var flagOut string + var flagFramework string + command := &cobra.Command{ + Use: "cli", + Short: "Generate CLI Boilerplate code", + Long: "The `ocli gen cli` command will generate the boilerplate code for\nvarious CLI frameworks from an OpenCLI Spec document.", + RunE: func(c *cobra.Command, args []string) error { + cmdArgs := OcliGenCliArgs{} + if len(args) > 0 { + cmdArgs.PathToSpec = args[0] + } + cmdFlags := OcliGenCliFlags{ + Out: flagOut, + 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 { + return a.UsageFunc(getSpecOcliGenCliCmd()) + }) + } + return a.OcliGenCli(c.Context(), cmdArgs, cmdFlags) + }, + } + command.SilenceErrors = true + command.SilenceUsage = true + command.Flags().StringVarP(&flagOut, "out", "o", "", "The path to the directory where the generated code will be output") + command.Flags().StringVarP(&flagFramework, "framework", "f", "", "The framework of the CLI boilerplate to generate") + command.SetHelpFunc(func(_ *cobra.Command, _ []string) { + a.HelpFunc(getSpecOcliGenCliCmd()) + }) + command.SetUsageFunc(func(_ *cobra.Command) error { + return a.UsageFunc(getSpecOcliGenCliCmd()) + }) + + return command +} + +func getSpecOcliGenCliCmd() *spec.CommandItem { + return &spec.CommandItem{ + Segment: "cli", + CommandLine: "ocli gen cli", + Summary: "Generate CLI Boilerplate code", + Description: "The `ocli gen cli` command will generate the boilerplate code for\nvarious CLI frameworks from an OpenCLI Spec document.", + VisibleChildren: false, + VisibleArgs: true, + VisibleFlags: true, + ArgsModifiers: []string{ + "", + }, + FlagsModifiers: []string{ + "[flags]", + }, + Args: []spec.ArgumentItem{ + {Name: "path-to-spec", Summary: "The spec used to generate the code"}, + }, + Flags: []spec.FlagItem{ + {Name: "out", Summary: "The path to the directory where the generated code will be output"}, + {Name: "framework", Summary: "The framework of the CLI boilerplate to generate"}, + }, + } +} diff --git a/internal/gencli/cmd_ocli_gen_docs.gen.go b/internal/gencli/cmd_ocli_gen_docs.gen.go new file mode 100644 index 0000000..6b8ede5 --- /dev/null +++ b/internal/gencli/cmd_ocli_gen_docs.gen.go @@ -0,0 +1,78 @@ +// 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 NewCmdOcliGenDocs(a ActionsInterface) *cobra.Command { + var flagFormat string + var flagOut string + var flagNoFooter bool + var flagNoBadge bool + command := &cobra.Command{ + Use: "docs", + Short: "Generate documentation", + Long: "The `ocli gen docs` command will generate documentation from an OpenCLI\nSpec document. You can specify the format of the documentation to be\ngenerated using the `--format` flag.", + RunE: func(c *cobra.Command, args []string) error { + cmdArgs := OcliGenDocsArgs{} + if len(args) > 0 { + cmdArgs.PathToSpec = args[0] + } + cmdFlags := OcliGenDocsFlags{ + Format: OcliGenDocsFormat(resolveStringFlag(c.Flags(), "format", []AltSource{{Type: "$ENV", Property: "OCLI_DOCS_FORMAT"}, {Type: "$FILE", Property: "$.docs.format"}})), + Out: flagOut, + NoFooter: flagNoFooter, + NoBadge: flagNoBadge, + } + if cmdFlags.Format != "" && !cmdFlags.Format.IsValid() { + return BadUserInput("invalid value for --format flag: "+string(cmdFlags.Format), func() error { + return a.UsageFunc(getSpecOcliGenDocsCmd()) + }) + } + return a.OcliGenDocs(c.Context(), cmdArgs, cmdFlags) + }, + } + command.SilenceErrors = true + command.SilenceUsage = true + command.Flags().StringVarP(&flagFormat, "format", "f", "", "The format of the documentation to generate") + command.Flags().StringVarP(&flagOut, "out", "o", "./docs", "The path to the directory where the generated docs will be written") + command.Flags().BoolVarP(&flagNoFooter, "no-footer", "", false, "Do not include the footer in the docs") + command.Flags().BoolVarP(&flagNoBadge, "no-badge", "", false, "Do not include the OpenCLI badge") + command.SetHelpFunc(func(_ *cobra.Command, _ []string) { + a.HelpFunc(getSpecOcliGenDocsCmd()) + }) + command.SetUsageFunc(func(_ *cobra.Command) error { + return a.UsageFunc(getSpecOcliGenDocsCmd()) + }) + + return command +} + +func getSpecOcliGenDocsCmd() *spec.CommandItem { + return &spec.CommandItem{ + Segment: "docs", + CommandLine: "ocli gen docs", + Summary: "Generate documentation", + Description: "The `ocli gen docs` command will generate documentation from an OpenCLI\nSpec document. You can specify the format of the documentation to be\ngenerated using the `--format` flag.", + VisibleChildren: false, + VisibleArgs: true, + VisibleFlags: true, + ArgsModifiers: []string{ + "", + }, + FlagsModifiers: []string{ + "[flags]", + }, + Args: []spec.ArgumentItem{ + {Name: "path-to-spec", Summary: "Path to the OpenCLI Spec document"}, + }, + Flags: []spec.FlagItem{ + {Name: "format", Summary: "The format of the documentation to generate"}, + {Name: "out", Summary: "The path to the directory where the generated docs will be written"}, + {Name: "no-footer", Summary: "Do not include the footer in the docs"}, + {Name: "no-badge", Summary: "Do not include the OpenCLI badge"}, + }, + } +} diff --git a/internal/gencli/config.gen.go b/internal/gencli/config.gen.go new file mode 100644 index 0000000..886d796 --- /dev/null +++ b/internal/gencli/config.gen.go @@ -0,0 +1,290 @@ +// Code generated by github.com/bcdxn/opencli@unknown version DO NOT EDIT. +package gencli + +import ( + "github.com/ohler55/ojg/jp" + "gopkg.in/yaml.v3" + "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: + 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 { + result := make([]T, 0, len(altSourceItems(src))) + for _, item := range altSourceItems(src) { + 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/gencli/errors.gen.go b/internal/gencli/errors.gen.go new file mode 100644 index 0000000..1a4ad69 --- /dev/null +++ b/internal/gencli/errors.gen.go @@ -0,0 +1,54 @@ +// Code generated by github.com/bcdxn/opencli@unknown version 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/internal/gencli/help.gen.go b/internal/gencli/help.gen.go new file mode 100644 index 0000000..bad3fc3 --- /dev/null +++ b/internal/gencli/help.gen.go @@ -0,0 +1,375 @@ +// Code generated by github.com/bcdxn/opencli@unknown version DO NOT EDIT. +package gencli + +import ( + "fmt" + "strings" + + "charm.land/glamour/v2" + "charm.land/lipgloss/v2" + "github.com/bcdxn/opencli/spec" +) + +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 +} diff --git a/internal/gencli/iostreams.gen.go b/internal/gencli/iostreams.gen.go new file mode 100644 index 0000000..f5cd595 --- /dev/null +++ b/internal/gencli/iostreams.gen.go @@ -0,0 +1,197 @@ +// Code generated by github.com/bcdxn/opencli@unknown version 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/internal/gencli/params.gen.go b/internal/gencli/params.gen.go new file mode 100644 index 0000000..89e93f3 --- /dev/null +++ b/internal/gencli/params.gen.go @@ -0,0 +1,71 @@ +// Code generated by github.com/bcdxn/opencli@unknown version DO NOT EDIT. +package gencli + +// OcliCheckArgs holds the positional arguments for the OcliCheck action. +type OcliCheckArgs struct { + PathToSpec string +} + +// OcliCheckFlags holds the flag values for the OcliCheck action. +type OcliCheckFlags struct { + FailOnErr bool +} + +// OcliGenDocsFormat represents the allowed values for the Format flag. +type OcliGenDocsFormat string + +const ( + OcliGenDocsFormatMarkdown OcliGenDocsFormat = "markdown" + OcliGenDocsFormatHtmlPage OcliGenDocsFormat = "html-page" + OcliGenDocsFormatHtmlEmbed OcliGenDocsFormat = "html-embed" + OcliGenDocsFormatMan OcliGenDocsFormat = "man" +) + +func (v OcliGenDocsFormat) IsValid() bool { + switch v { + case OcliGenDocsFormatMarkdown, OcliGenDocsFormatHtmlPage, OcliGenDocsFormatHtmlEmbed, OcliGenDocsFormatMan: + return true + } + return false +} + +// OcliGenDocsArgs holds the positional arguments for the OcliGenDocs action. +type OcliGenDocsArgs struct { + PathToSpec string +} + +// OcliGenDocsFlags holds the flag values for the OcliGenDocs action. +type OcliGenDocsFlags struct { + Format OcliGenDocsFormat + Out string + NoFooter bool + NoBadge bool +} + +// OcliGenCliFramework represents the allowed values for the Framework flag. +type OcliGenCliFramework string + +const ( + OcliGenCliFrameworkCobra OcliGenCliFramework = "cobra" + OcliGenCliFrameworkUrfavecli OcliGenCliFramework = "urfavecli" + OcliGenCliFrameworkYargs OcliGenCliFramework = "yargs" +) + +func (v OcliGenCliFramework) IsValid() bool { + switch v { + case OcliGenCliFrameworkCobra, OcliGenCliFrameworkUrfavecli, OcliGenCliFrameworkYargs: + return true + } + return false +} + +// OcliGenCliArgs holds the positional arguments for the OcliGenCli action. +type OcliGenCliArgs struct { + PathToSpec string +} + +// OcliGenCliFlags holds the flag values for the OcliGenCli action. +type OcliGenCliFlags struct { + Out string + Framework OcliGenCliFramework +} diff --git a/internal/gencli/run.gen.go b/internal/gencli/run.gen.go new file mode 100644 index 0000000..fa0fd29 --- /dev/null +++ b/internal/gencli/run.gen.go @@ -0,0 +1,32 @@ +// Code generated by github.com/bcdxn/opencli@unknown version DO NOT EDIT. +package gencli + +import ( + "context" + "errors" + "fmt" +) + +// 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 + rootCmd.Version = actions.Version() + + // 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/web/src/views/GeneratingGoCode.tsx b/web/src/views/GeneratingGoCode.tsx index ee8837c..2a8774c 100644 --- a/web/src/views/GeneratingGoCode.tsx +++ b/web/src/views/GeneratingGoCode.tsx @@ -315,7 +315,7 @@ function GeneratingGoCodePage() { ` \u251c\u2500\u2500 help.gen.go Default help/usage messaging`, ` \u251c\u2500\u2500 iostreams.gen.go Standard I/O streams abstraction`, ` \u251c\u2500\u2500 params.gen.go Command flags & parameter types`, - ` \u251c\u2500\u2500 run.go CLI entry point (Run function)`, + ` \u251c\u2500\u2500 run.gen.go CLI entry point (Run function)`, ` \u2514\u2500\u2500 cmd_... Generated Cobra command definitions`, ]} /> From 24e105dea541346970436392bfa203b58e731e44 Mon Sep 17 00:00:00 2001 From: bcdxn <5067649+bcdxn@users.noreply.github.com> Date: Mon, 17 Aug 2026 08:26:42 -0400 Subject: [PATCH 09/22] fix: alt sources resolution bug in supported frameworks --- .github/copilot-instructions.md | 5 +- CONTRIBUTING.md | 2 +- Makefile | 10 +- README.md | 2 +- codec/codec.go | 258 +++++++++++++++- codec/default_test.go | 234 ++++++++++++++ gen/cli_cobra.go | 27 +- gen/cli_test.go | 51 +++ gen/cli_urfave_cli.go | 22 +- gen/cli_yargs.go | 33 +- internal/gencli/actions.gen.go | 19 -- internal/gencli/cmd_ocli.gen.go | 58 ---- internal/gencli/cmd_ocli_check.gen.go | 61 ---- internal/gencli/cmd_ocli_gen.gen.go | 59 ---- internal/gencli/cmd_ocli_gen_cli.gen.go | 70 ----- internal/gencli/cmd_ocli_gen_docs.gen.go | 78 ----- internal/gencli/config.gen.go | 290 ------------------ internal/gencli/errors.gen.go | 54 ---- internal/gencli/help.gen.go | 375 ----------------------- internal/gencli/iostreams.gen.go | 197 ------------ internal/gencli/params.gen.go | 71 ----- internal/gencli/run.gen.go | 32 -- opencli.ocs.yaml => ocli.ocs.yaml | 0 23 files changed, 609 insertions(+), 1399 deletions(-) create mode 100644 codec/default_test.go delete mode 100644 internal/gencli/actions.gen.go delete mode 100644 internal/gencli/cmd_ocli.gen.go delete mode 100644 internal/gencli/cmd_ocli_check.gen.go delete mode 100644 internal/gencli/cmd_ocli_gen.gen.go delete mode 100644 internal/gencli/cmd_ocli_gen_cli.gen.go delete mode 100644 internal/gencli/cmd_ocli_gen_docs.gen.go delete mode 100644 internal/gencli/config.gen.go delete mode 100644 internal/gencli/errors.gen.go delete mode 100644 internal/gencli/help.gen.go delete mode 100644 internal/gencli/iostreams.gen.go delete mode 100644 internal/gencli/params.gen.go delete mode 100644 internal/gencli/run.gen.go rename opencli.ocs.yaml => ocli.ocs.yaml (100%) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 1b2e7a8..c1622a1 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,7 +120,8 @@ 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. 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 2d38c74..122160c 100644 --- a/Makefile +++ b/Makefile @@ -16,15 +16,15 @@ 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 + ocli.ocs.yaml mkdir -p build && mv docs/opencli.ocs.1 build/ocli.1 @@ -55,8 +55,8 @@ gen-examples: generate gen-ocli: @go run cmd/ocli/main.go gen cli \ --framework cobra \ - --out ./internal \ - ./opencli.ocs.yaml + --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..930ce04 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,12 +74,15 @@ 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 @@ -87,9 +95,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 +230,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 +279,235 @@ 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 { + flag := &node.Flags[i] + 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. + continue + } + 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: + if v > math.MaxInt64 { + return 0, false + } + return float64(int64(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..e891c5f --- /dev/null +++ b/codec/default_test.go @@ -0,0 +1,234 @@ +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) +} + +// 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 +} + +// 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) + } + }) + } +} diff --git a/gen/cli_cobra.go b/gen/cli_cobra.go index e36b983..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" @@ -512,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: @@ -521,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_test.go b/gen/cli_test.go index 60e4f5f..6644010 100644 --- a/gen/cli_test.go +++ b/gen/cli_test.go @@ -163,6 +163,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 +273,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 +324,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, ``}, } diff --git a/gen/cli_urfave_cli.go b/gen/cli_urfave_cli.go index 41fca0b..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" @@ -513,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 { @@ -521,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) @@ -555,8 +555,8 @@ 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)) } } diff --git a/gen/cli_yargs.go b/gen/cli_yargs.go index 82e93b6..130d6ed 100644 --- a/gen/cli_yargs.go +++ b/gen/cli_yargs.go @@ -6,6 +6,7 @@ import ( "fmt" "path/filepath" "sort" + "strconv" "strings" "text/template" @@ -453,12 +454,38 @@ func yargsCommandDSL(cmd *spec.CommandItem) string { return strings.Join(cmdDSL, " ") } -// yargsDefaultVal returns a TypeScript literal default value for a flag. +// 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/internal/gencli/actions.gen.go b/internal/gencli/actions.gen.go deleted file mode 100644 index 0b3d7b2..0000000 --- a/internal/gencli/actions.gen.go +++ /dev/null @@ -1,19 +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 ocli CLI supports. -type ActionsInterface interface { - OcliCheck(ctx context.Context, args OcliCheckArgs, flags OcliCheckFlags) error - OcliGenDocs(ctx context.Context, args OcliGenDocsArgs, flags OcliGenDocsFlags) error - OcliGenCli(ctx context.Context, args OcliGenCliArgs, flags OcliGenCliFlags) error - HelpFunc(cmd *spec.CommandItem) - UsageFunc(cmd *spec.CommandItem) error - IOStreams() IOStreams - Version() string -} diff --git a/internal/gencli/cmd_ocli.gen.go b/internal/gencli/cmd_ocli.gen.go deleted file mode 100644 index 3f7b643..0000000 --- a/internal/gencli/cmd_ocli.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 NewCmdOcli(a ActionsInterface) *cobra.Command { - command := &cobra.Command{ - Use: "ocli", - Short: "A CLI for working with OpenCLI Specs", - Long: "`ocli` is a command line interface designed to make working with [OpenCLI\nSpec documents](https://github.com/bcdxn/opencli/tree/main) easier. It provides a number of capabilities, including:\n\n- validating OpenCLI Spec documents\n- generating Documentation from OpenCLI Spec documents\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\nocli gen --help\n```\n", - Args: cobra.NoArgs, - RunE: func(c *cobra.Command, args []string) error { - return BadUserInput("subcommand is required", func() error { - return a.UsageFunc(getSpecOcliCmd()) - }) - }, - } - command.SilenceErrors = true - command.SilenceUsage = true - command.AddCommand(NewCmdOcliCheck(a)) - command.AddCommand(NewCmdOcliGen(a)) - command.SetHelpFunc(func(_ *cobra.Command, _ []string) { - a.HelpFunc(getSpecOcliCmd()) - }) - command.SetUsageFunc(func(_ *cobra.Command) error { - return a.UsageFunc(getSpecOcliCmd()) - }) - - return command -} - -func getSpecOcliCmd() *spec.CommandItem { - return &spec.CommandItem{ - Segment: "ocli", - CommandLine: "ocli", - Summary: "A CLI for working with OpenCLI Specs", - Description: "`ocli` is a command line interface designed to make working with [OpenCLI\nSpec documents](https://github.com/bcdxn/opencli/tree/main) easier. It provides a number of capabilities, including:\n\n- validating OpenCLI Spec documents\n- generating Documentation from OpenCLI Spec documents\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\nocli gen --help\n```\n", - VisibleChildren: true, - VisibleArgs: false, - VisibleFlags: false, - CommandModifiers: []string{ - "{command}", - }, - ArgsModifiers: []string{ - "", - }, - FlagsModifiers: []string{ - "[flags]", - }, - Commands: []*spec.CommandItem{ - {Segment: "check", Summary: "Check an OpenCLI Spec document for errors"}, - {Segment: "gen", Summary: "Collection of commands used to generate code/docs from an OpenCLI Spec document"}, - }, - } -} diff --git a/internal/gencli/cmd_ocli_check.gen.go b/internal/gencli/cmd_ocli_check.gen.go deleted file mode 100644 index 0613e7d..0000000 --- a/internal/gencli/cmd_ocli_check.gen.go +++ /dev/null @@ -1,61 +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 NewCmdOcliCheck(a ActionsInterface) *cobra.Command { - var flagFailOnErr bool - command := &cobra.Command{ - Use: "check", - Short: "Check an OpenCLI Spec document for errors", - Long: "The `check` command will validate an OpenCLI Spec document and return any\nerrors that are found.", - RunE: func(c *cobra.Command, args []string) error { - cmdArgs := OcliCheckArgs{} - if len(args) > 0 { - cmdArgs.PathToSpec = args[0] - } - cmdFlags := OcliCheckFlags{ - FailOnErr: flagFailOnErr, - } - return a.OcliCheck(c.Context(), cmdArgs, cmdFlags) - }, - } - command.SilenceErrors = true - command.SilenceUsage = true - command.Flags().BoolVarP(&flagFailOnErr, "fail-on-err", "", true, "Return a non-zero exit code if validation fails") - command.SetHelpFunc(func(_ *cobra.Command, _ []string) { - a.HelpFunc(getSpecOcliCheckCmd()) - }) - command.SetUsageFunc(func(_ *cobra.Command) error { - return a.UsageFunc(getSpecOcliCheckCmd()) - }) - - return command -} - -func getSpecOcliCheckCmd() *spec.CommandItem { - return &spec.CommandItem{ - Segment: "check", - CommandLine: "ocli check", - Summary: "Check an OpenCLI Spec document for errors", - Description: "The `check` command will validate an OpenCLI Spec document and return any\nerrors that are found.", - VisibleChildren: false, - VisibleArgs: true, - VisibleFlags: true, - ArgsModifiers: []string{ - "", - }, - FlagsModifiers: []string{ - "[flags]", - }, - Args: []spec.ArgumentItem{ - {Name: "path-to-spec", Summary: "The path to the OpenCLI Spec document to check"}, - }, - Flags: []spec.FlagItem{ - {Name: "fail-on-err", Summary: "Return a non-zero exit code if validation fails"}, - }, - } -} diff --git a/internal/gencli/cmd_ocli_gen.gen.go b/internal/gencli/cmd_ocli_gen.gen.go deleted file mode 100644 index 846f48f..0000000 --- a/internal/gencli/cmd_ocli_gen.gen.go +++ /dev/null @@ -1,59 +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 NewCmdOcliGen(a ActionsInterface) *cobra.Command { - command := &cobra.Command{ - Use: "gen", - Short: "Collection of commands used to generate code/docs from an OpenCLI Spec document", - Long: "", - Aliases: []string{"gen"}, - Args: cobra.NoArgs, - RunE: func(c *cobra.Command, args []string) error { - return BadUserInput("subcommand is required", func() error { - return a.UsageFunc(getSpecOcliGenCmd()) - }) - }, - } - command.SilenceErrors = true - command.SilenceUsage = true - command.AddCommand(NewCmdOcliGenDocs(a)) - command.AddCommand(NewCmdOcliGenCli(a)) - command.SetHelpFunc(func(_ *cobra.Command, _ []string) { - a.HelpFunc(getSpecOcliGenCmd()) - }) - command.SetUsageFunc(func(_ *cobra.Command) error { - return a.UsageFunc(getSpecOcliGenCmd()) - }) - - return command -} - -func getSpecOcliGenCmd() *spec.CommandItem { - return &spec.CommandItem{ - Segment: "gen", - CommandLine: "ocli gen", - Summary: "Collection of commands used to generate code/docs from an OpenCLI Spec document", - Description: "", - VisibleChildren: true, - VisibleArgs: false, - VisibleFlags: false, - CommandModifiers: []string{ - "{command}", - }, - ArgsModifiers: []string{ - "", - }, - FlagsModifiers: []string{ - "[flags]", - }, - Commands: []*spec.CommandItem{ - {Segment: "docs", Summary: "Generate documentation"}, - {Segment: "cli", Summary: "Generate CLI Boilerplate code"}, - }, - } -} diff --git a/internal/gencli/cmd_ocli_gen_cli.gen.go b/internal/gencli/cmd_ocli_gen_cli.gen.go deleted file mode 100644 index 55b7f17..0000000 --- a/internal/gencli/cmd_ocli_gen_cli.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 NewCmdOcliGenCli(a ActionsInterface) *cobra.Command { - var flagOut string - var flagFramework string - command := &cobra.Command{ - Use: "cli", - Short: "Generate CLI Boilerplate code", - Long: "The `ocli gen cli` command will generate the boilerplate code for\nvarious CLI frameworks from an OpenCLI Spec document.", - RunE: func(c *cobra.Command, args []string) error { - cmdArgs := OcliGenCliArgs{} - if len(args) > 0 { - cmdArgs.PathToSpec = args[0] - } - cmdFlags := OcliGenCliFlags{ - Out: flagOut, - 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 { - return a.UsageFunc(getSpecOcliGenCliCmd()) - }) - } - return a.OcliGenCli(c.Context(), cmdArgs, cmdFlags) - }, - } - command.SilenceErrors = true - command.SilenceUsage = true - command.Flags().StringVarP(&flagOut, "out", "o", "", "The path to the directory where the generated code will be output") - command.Flags().StringVarP(&flagFramework, "framework", "f", "", "The framework of the CLI boilerplate to generate") - command.SetHelpFunc(func(_ *cobra.Command, _ []string) { - a.HelpFunc(getSpecOcliGenCliCmd()) - }) - command.SetUsageFunc(func(_ *cobra.Command) error { - return a.UsageFunc(getSpecOcliGenCliCmd()) - }) - - return command -} - -func getSpecOcliGenCliCmd() *spec.CommandItem { - return &spec.CommandItem{ - Segment: "cli", - CommandLine: "ocli gen cli", - Summary: "Generate CLI Boilerplate code", - Description: "The `ocli gen cli` command will generate the boilerplate code for\nvarious CLI frameworks from an OpenCLI Spec document.", - VisibleChildren: false, - VisibleArgs: true, - VisibleFlags: true, - ArgsModifiers: []string{ - "", - }, - FlagsModifiers: []string{ - "[flags]", - }, - Args: []spec.ArgumentItem{ - {Name: "path-to-spec", Summary: "The spec used to generate the code"}, - }, - Flags: []spec.FlagItem{ - {Name: "out", Summary: "The path to the directory where the generated code will be output"}, - {Name: "framework", Summary: "The framework of the CLI boilerplate to generate"}, - }, - } -} diff --git a/internal/gencli/cmd_ocli_gen_docs.gen.go b/internal/gencli/cmd_ocli_gen_docs.gen.go deleted file mode 100644 index 6b8ede5..0000000 --- a/internal/gencli/cmd_ocli_gen_docs.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 NewCmdOcliGenDocs(a ActionsInterface) *cobra.Command { - var flagFormat string - var flagOut string - var flagNoFooter bool - var flagNoBadge bool - command := &cobra.Command{ - Use: "docs", - Short: "Generate documentation", - Long: "The `ocli gen docs` command will generate documentation from an OpenCLI\nSpec document. You can specify the format of the documentation to be\ngenerated using the `--format` flag.", - RunE: func(c *cobra.Command, args []string) error { - cmdArgs := OcliGenDocsArgs{} - if len(args) > 0 { - cmdArgs.PathToSpec = args[0] - } - cmdFlags := OcliGenDocsFlags{ - Format: OcliGenDocsFormat(resolveStringFlag(c.Flags(), "format", []AltSource{{Type: "$ENV", Property: "OCLI_DOCS_FORMAT"}, {Type: "$FILE", Property: "$.docs.format"}})), - Out: flagOut, - NoFooter: flagNoFooter, - NoBadge: flagNoBadge, - } - if cmdFlags.Format != "" && !cmdFlags.Format.IsValid() { - return BadUserInput("invalid value for --format flag: "+string(cmdFlags.Format), func() error { - return a.UsageFunc(getSpecOcliGenDocsCmd()) - }) - } - return a.OcliGenDocs(c.Context(), cmdArgs, cmdFlags) - }, - } - command.SilenceErrors = true - command.SilenceUsage = true - command.Flags().StringVarP(&flagFormat, "format", "f", "", "The format of the documentation to generate") - command.Flags().StringVarP(&flagOut, "out", "o", "./docs", "The path to the directory where the generated docs will be written") - command.Flags().BoolVarP(&flagNoFooter, "no-footer", "", false, "Do not include the footer in the docs") - command.Flags().BoolVarP(&flagNoBadge, "no-badge", "", false, "Do not include the OpenCLI badge") - command.SetHelpFunc(func(_ *cobra.Command, _ []string) { - a.HelpFunc(getSpecOcliGenDocsCmd()) - }) - command.SetUsageFunc(func(_ *cobra.Command) error { - return a.UsageFunc(getSpecOcliGenDocsCmd()) - }) - - return command -} - -func getSpecOcliGenDocsCmd() *spec.CommandItem { - return &spec.CommandItem{ - Segment: "docs", - CommandLine: "ocli gen docs", - Summary: "Generate documentation", - Description: "The `ocli gen docs` command will generate documentation from an OpenCLI\nSpec document. You can specify the format of the documentation to be\ngenerated using the `--format` flag.", - VisibleChildren: false, - VisibleArgs: true, - VisibleFlags: true, - ArgsModifiers: []string{ - "", - }, - FlagsModifiers: []string{ - "[flags]", - }, - Args: []spec.ArgumentItem{ - {Name: "path-to-spec", Summary: "Path to the OpenCLI Spec document"}, - }, - Flags: []spec.FlagItem{ - {Name: "format", Summary: "The format of the documentation to generate"}, - {Name: "out", Summary: "The path to the directory where the generated docs will be written"}, - {Name: "no-footer", Summary: "Do not include the footer in the docs"}, - {Name: "no-badge", Summary: "Do not include the OpenCLI badge"}, - }, - } -} diff --git a/internal/gencli/config.gen.go b/internal/gencli/config.gen.go deleted file mode 100644 index 886d796..0000000 --- a/internal/gencli/config.gen.go +++ /dev/null @@ -1,290 +0,0 @@ -// Code generated by github.com/bcdxn/opencli@unknown version DO NOT EDIT. -package gencli - -import ( - "github.com/ohler55/ojg/jp" - "gopkg.in/yaml.v3" - "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: - 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 { - result := make([]T, 0, len(altSourceItems(src))) - for _, item := range altSourceItems(src) { - 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/gencli/errors.gen.go b/internal/gencli/errors.gen.go deleted file mode 100644 index 1a4ad69..0000000 --- a/internal/gencli/errors.gen.go +++ /dev/null @@ -1,54 +0,0 @@ -// Code generated by github.com/bcdxn/opencli@unknown version 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/internal/gencli/help.gen.go b/internal/gencli/help.gen.go deleted file mode 100644 index bad3fc3..0000000 --- a/internal/gencli/help.gen.go +++ /dev/null @@ -1,375 +0,0 @@ -// Code generated by github.com/bcdxn/opencli@unknown version DO NOT EDIT. -package gencli - -import ( - "fmt" - "strings" - - "charm.land/glamour/v2" - "charm.land/lipgloss/v2" - "github.com/bcdxn/opencli/spec" -) - -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 -} diff --git a/internal/gencli/iostreams.gen.go b/internal/gencli/iostreams.gen.go deleted file mode 100644 index f5cd595..0000000 --- a/internal/gencli/iostreams.gen.go +++ /dev/null @@ -1,197 +0,0 @@ -// Code generated by github.com/bcdxn/opencli@unknown version 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/internal/gencli/params.gen.go b/internal/gencli/params.gen.go deleted file mode 100644 index 89e93f3..0000000 --- a/internal/gencli/params.gen.go +++ /dev/null @@ -1,71 +0,0 @@ -// Code generated by github.com/bcdxn/opencli@unknown version DO NOT EDIT. -package gencli - -// OcliCheckArgs holds the positional arguments for the OcliCheck action. -type OcliCheckArgs struct { - PathToSpec string -} - -// OcliCheckFlags holds the flag values for the OcliCheck action. -type OcliCheckFlags struct { - FailOnErr bool -} - -// OcliGenDocsFormat represents the allowed values for the Format flag. -type OcliGenDocsFormat string - -const ( - OcliGenDocsFormatMarkdown OcliGenDocsFormat = "markdown" - OcliGenDocsFormatHtmlPage OcliGenDocsFormat = "html-page" - OcliGenDocsFormatHtmlEmbed OcliGenDocsFormat = "html-embed" - OcliGenDocsFormatMan OcliGenDocsFormat = "man" -) - -func (v OcliGenDocsFormat) IsValid() bool { - switch v { - case OcliGenDocsFormatMarkdown, OcliGenDocsFormatHtmlPage, OcliGenDocsFormatHtmlEmbed, OcliGenDocsFormatMan: - return true - } - return false -} - -// OcliGenDocsArgs holds the positional arguments for the OcliGenDocs action. -type OcliGenDocsArgs struct { - PathToSpec string -} - -// OcliGenDocsFlags holds the flag values for the OcliGenDocs action. -type OcliGenDocsFlags struct { - Format OcliGenDocsFormat - Out string - NoFooter bool - NoBadge bool -} - -// OcliGenCliFramework represents the allowed values for the Framework flag. -type OcliGenCliFramework string - -const ( - OcliGenCliFrameworkCobra OcliGenCliFramework = "cobra" - OcliGenCliFrameworkUrfavecli OcliGenCliFramework = "urfavecli" - OcliGenCliFrameworkYargs OcliGenCliFramework = "yargs" -) - -func (v OcliGenCliFramework) IsValid() bool { - switch v { - case OcliGenCliFrameworkCobra, OcliGenCliFrameworkUrfavecli, OcliGenCliFrameworkYargs: - return true - } - return false -} - -// OcliGenCliArgs holds the positional arguments for the OcliGenCli action. -type OcliGenCliArgs struct { - PathToSpec string -} - -// OcliGenCliFlags holds the flag values for the OcliGenCli action. -type OcliGenCliFlags struct { - Out string - Framework OcliGenCliFramework -} diff --git a/internal/gencli/run.gen.go b/internal/gencli/run.gen.go deleted file mode 100644 index fa0fd29..0000000 --- a/internal/gencli/run.gen.go +++ /dev/null @@ -1,32 +0,0 @@ -// Code generated by github.com/bcdxn/opencli@unknown version DO NOT EDIT. -package gencli - -import ( - "context" - "errors" - "fmt" -) - -// 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 - rootCmd.Version = actions.Version() - - // 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/opencli.ocs.yaml b/ocli.ocs.yaml similarity index 100% rename from opencli.ocs.yaml rename to ocli.ocs.yaml From 14e50010c5b429c80612750606c1283db6f8b95b Mon Sep 17 00:00:00 2001 From: bcdxn <5067649+bcdxn@users.noreply.github.com> Date: Mon, 17 Aug 2026 09:08:02 -0400 Subject: [PATCH 10/22] fix: flags on groups gap --- gen/cli.go | 36 +++++ gen/cli_group_test.go | 136 +++++++++++++++++++ gen/templates/code/cobra/gencli/command.tmpl | 2 + 3 files changed, 174 insertions(+) create mode 100644 gen/cli_group_test.go 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_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/templates/code/cobra/gencli/command.tmpl b/gen/templates/code/cobra/gencli/command.tmpl index d987e8d..e66c6bf 100644 --- a/gen/templates/code/cobra/gencli/command.tmpl +++ b/gen/templates/code/cobra/gencli/command.tmpl @@ -81,9 +81,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 { From bf0e1eef6d16aa5d2cc5bf9f66547009a1d06650 Mon Sep 17 00:00:00 2001 From: bcdxn <5067649+bcdxn@users.noreply.github.com> Date: Mon, 17 Aug 2026 16:23:16 -0400 Subject: [PATCH 11/22] fix: variadic errors in yargs --- gen/cli_yargs.go | 60 ++++++++++------ gen/cli_yargs_variadic_test.go | 73 ++++++++++++++++++++ gen/templates/code/yargs/gencli/command.tmpl | 4 +- 3 files changed, 115 insertions(+), 22 deletions(-) create mode 100644 gen/cli_yargs_variadic_test.go diff --git a/gen/cli_yargs.go b/gen/cli_yargs.go index 130d6ed..10c62ac 100644 --- a/gen/cli_yargs.go +++ b/gen/cli_yargs.go @@ -81,16 +81,17 @@ 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 + VariadicCoerce string // pre-rendered JS arrow coercing array elements for non-string variadics ("" otherwise) } //go:embed templates/code/yargs @@ -305,16 +306,17 @@ 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), }) } @@ -454,6 +456,24 @@ func yargsCommandDSL(cmd *spec.CommandItem) string { return strings.Join(cmdDSL, " ") } +// 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. 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/yargs/gencli/command.tmpl b/gen/templates/code/yargs/gencli/command.tmpl index 4ecae41..8ac1692 100644 --- a/gen/templates/code/yargs/gencli/command.tmpl +++ b/gen/templates/code/yargs/gencli/command.tmpl @@ -27,7 +27,7 @@ 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}} help: boolean; } @@ -68,7 +68,7 @@ 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}} From caa50cc60353d7ba0996836901ca575cea31d84d Mon Sep 17 00:00:00 2001 From: bcdxn <5067649+bcdxn@users.noreply.github.com> Date: Tue, 18 Aug 2026 09:51:45 -0400 Subject: [PATCH 12/22] fix: add yargs alt source capability + minor fixes --- .github/copilot-instructions.md | 1 + gen/cli_test.go | 121 ++++++++ gen/cli_yargs.go | 234 ++++++++++++++- gen/templates/code/yargs/gencli/actions.tmpl | 10 +- gen/templates/code/yargs/gencli/command.tmpl | 37 ++- gen/templates/code/yargs/gencli/config.tmpl | 281 ++++++++++++++++++ gen/templates/code/yargs/gencli/params.tmpl | 9 + gen/templates/code/yargs/gencli/run.tmpl | 28 ++ .../yargs/gencli/cmd-petstore-user-login.ts | 8 +- gen/testdata/yargs/gencli/config.ts | 265 +++++++++++++++++ gen/testdata/yargs/gencli/run.ts | 3 + 11 files changed, 970 insertions(+), 27 deletions(-) create mode 100644 gen/templates/code/yargs/gencli/config.tmpl create mode 100644 gen/testdata/yargs/gencli/config.ts diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index c1622a1..4636090 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -125,3 +125,4 @@ OpenCLI Specification is a declarative, language-agnostic document specification - 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 files always use a subdirectory within `/tmp`. And be sure to clean up after yourself when done. diff --git a/gen/cli_test.go b/gen/cli_test.go index 6644010..44442c2 100644 --- a/gen/cli_test.go +++ b/gen/cli_test.go @@ -695,3 +695,124 @@ commands: } }) } + +// 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) + } + }) + } +} + +// 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_yargs.go b/gen/cli_yargs.go index 10c62ac..2308202 100644 --- a/gen/cli_yargs.go +++ b/gen/cli_yargs.go @@ -22,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). @@ -58,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. @@ -91,7 +104,9 @@ type yargsFlagEntry struct { 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 @@ -118,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}), @@ -143,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 { @@ -158,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 { @@ -317,6 +410,7 @@ func walkYargsCmdTree( Shorthand: shorthand, ExtraAliases: extraAliases, Default: yargsDefaultVal(flag.Default), + AltSources: flag.AltSources, }) } @@ -397,8 +491,128 @@ func yargsTemplateFuncMap() template.FuncMap { } return result }, + // appendParamImports returns the param type import list for actions.ts: every + // Args/Flags type used by a leaf command plus GlobalFlags when global flags exist. + "appendParamImports": func(imports []string, hasGlobal bool) []string { + if !hasGlobal || len(imports) == 0 { + return imports + } + for _, n := range imports { + if n == "GlobalFlags" { + return imports + } + } + result := make([]string, len(imports)+1) + copy(result, imports) + result[len(imports)] = "GlobalFlags" + 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) + 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. diff --git a/gen/templates/code/yargs/gencli/actions.tmpl b/gen/templates/code/yargs/gencli/actions.tmpl index 170f34f..dfb90f1 100644 --- a/gen/templates/code/yargs/gencli/actions.tmpl +++ b/gen/templates/code/yargs/gencli/actions.tmpl @@ -1,6 +1,6 @@ // Code generated by github.com/bcdxn/opencli@{{.ModuleVersion}} DO NOT EDIT. import { CommandPrintData } from "./types"; -{{- $imports := collectParamImports .LeafCommands}}{{if $imports}} +{{- $imports := appendParamImports (collectParamImports .LeafCommands) (gt (len $.GlobalFlags) 0)}}{{if $imports}} import { {{joinStrings $imports ",\n "}} } from "./params"; @@ -9,13 +9,13 @@ import { export interface ActionsInterface { {{- range .LeafCommands}} {{- if and .Args .Flags}} - {{.MethodName}}(args: {{.ArgsTypeName}}, flags: {{.FlagsTypeName}}): Promise; + {{.MethodName}}(args: {{.ArgsTypeName}}, flags: {{.FlagsTypeName}}{{if $.GlobalFlags}}, globalFlags: GlobalFlags{{end}}): Promise; {{- else if .Args}} - {{.MethodName}}(args: {{.ArgsTypeName}}): Promise; + {{.MethodName}}(args: {{.ArgsTypeName}}{{if $.GlobalFlags}}, globalFlags: GlobalFlags{{end}}): Promise; {{- else if .Flags}} - {{.MethodName}}(flags: {{.FlagsTypeName}}): Promise; + {{.MethodName}}(flags: {{.FlagsTypeName}}{{if $.GlobalFlags}}, globalFlags: GlobalFlags{{end}}): Promise; {{- else}} - {{.MethodName}}(): Promise; + {{.MethodName}}{{if $.GlobalFlags}}(globalFlags: GlobalFlags){{else}}(){{end}}: Promise; {{- end}} {{- end}} help(cmd: CommandPrintData): void; diff --git a/gen/templates/code/yargs/gencli/command.tmpl b/gen/templates/code/yargs/gencli/command.tmpl index 8ac1692..77f2529 100644 --- a/gen/templates/code/yargs/gencli/command.tmpl +++ b/gen/templates/code/yargs/gencli/command.tmpl @@ -15,8 +15,17 @@ import { {{- range .YargsFlags}}{{if .TypeName}} {{.TypeName}}, {{- end}}{{end}} -} from "./params"; +{{- if .GlobalFlags}} + GlobalFlags, {{- end}} +} from "./params"; +{{- end}}{{if .ConfigImports}} +import { + {{joinStrings .ConfigImports ",\n "}} +} from "./config"; + +{{else}} +{{end -}} import { CommandPrintData } from "./types"; import { CliError, ExitCode, createBadUserInputError } from "./errors"; @@ -28,6 +37,11 @@ interface {{.CommandArgName}} { {{- end}} {{- range .YargsFlags}} "{{.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; } @@ -75,7 +89,7 @@ export function {{.FuncName}}( {{- 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,22 +148,25 @@ export function {{.FuncName}}( {{- if .YargsFlags}} const cmdFlags: {{.FlagsTypeName}} = { {{- range .YargsFlags}} -{{- if .TypeName}} - {{.FieldName}}: argv.{{.FieldName}} as {{.TypeName}}, -{{- else}} - {{.FieldName}}: argv.{{.FieldName}} as {{.TSType}}, + {{.FieldName}}: {{resolveFlagValue .}}, +{{- end}} + }; {{- end}} +{{- if .GlobalFlags}} + const globalFlags: GlobalFlags = { +{{- range .GlobalFlags}} + {{.FieldName}}: {{resolveFlagValue .}}, {{- end}} }; {{- end}} {{- if and .YargsArgs .YargsFlags}} - return actions.{{.MethodName}}(cmdArgs, cmdFlags); + return actions.{{.MethodName}}(cmdArgs, cmdFlags{{if .GlobalFlags}}, globalFlags{{end}}); {{- else if .YargsArgs}} - return actions.{{.MethodName}}(cmdArgs); + return actions.{{.MethodName}}(cmdArgs{{if .GlobalFlags}}, globalFlags{{end}}); {{- else if .YargsFlags}} - return actions.{{.MethodName}}(cmdFlags); + return actions.{{.MethodName}}(cmdFlags{{if .GlobalFlags}}, globalFlags{{end}}); {{- else}} - return actions.{{.MethodName}}(); + return actions.{{.MethodName}}{{if .GlobalFlags}}(globalFlags){{else}}(){{end}}; {{- end}} {{- end}} }, 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/params.tmpl b/gen/templates/code/yargs/gencli/params.tmpl index 6da3a5a..b4bb685 100644 --- a/gen/templates/code/yargs/gencli/params.tmpl +++ b/gen/templates/code/yargs/gencli/params.tmpl @@ -1,4 +1,13 @@ // 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}} +} + +{{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..df8e77a 100644 --- a/gen/templates/code/yargs/gencli/run.tmpl +++ b/gen/templates/code/yargs/gencli/run.tmpl @@ -2,6 +2,10 @@ import yargs from "yargs"; {{- range .ChildImports}} import { {{.FuncName}} } from "./{{.FileName}}"; +{{- end}} +{{- if .HasAltSources}} +import { loadConfig } from "./config"; + {{- end}} import { ActionsInterface } from "./actions"; import { CommandPrintData } from "./types"; @@ -12,12 +16,36 @@ export async function run( yargsInstance: yargs.Argv<{}>, actions: ActionsInterface, ): Promise { +{{- if .HasAltSources}} + // Load config file from disk (JSON/YAML/TOML) for $FILE alternative sources. + loadConfig(); + +{{- end}} await yargsInstance .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}} + aliases: [{{range $i, $a := .ExtraAliases}}{{if $i}}, {{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/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/run.ts b/gen/testdata/yargs/gencli/run.ts index 4ad6279..3bea8c4 100644 --- a/gen/testdata/yargs/gencli/run.ts +++ b/gen/testdata/yargs/gencli/run.ts @@ -4,6 +4,7 @@ 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"; @@ -13,6 +14,8 @@ export async function run( yargsInstance: yargs.Argv<{}>, actions: ActionsInterface, ): Promise { + // Load config file from disk (JSON/YAML/TOML) for $FILE alternative sources. + loadConfig(); await yargsInstance .scriptName("petstore") .help(false) From 8356c2da673bbd9ee3198d6eb9f4af31ff26348d Mon Sep 17 00:00:00 2001 From: bcdxn <5067649+bcdxn@users.noreply.github.com> Date: Tue, 18 Aug 2026 16:19:26 -0400 Subject: [PATCH 13/22] fix: missing yargs shorthand name when processing alt sources --- gen/cli_test.go | 27 +++++++++++++++++++++++++++ gen/cli_yargs.go | 1 + 2 files changed, 28 insertions(+) diff --git a/gen/cli_test.go b/gen/cli_test.go index 44442c2..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" @@ -736,6 +737,32 @@ func TestYargsScanAltSources(t *testing.T) { } } +// 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. diff --git a/gen/cli_yargs.go b/gen/cli_yargs.go index 2308202..8bbf30a 100644 --- a/gen/cli_yargs.go +++ b/gen/cli_yargs.go @@ -600,6 +600,7 @@ func yargsAltSourceNames(f yargsFlagEntry) []string { } add(f.FieldName) add(f.RawName) + add(f.Shorthand) for _, a := range f.ExtraAliases { add(a) } From a11e3b65d2977140dbdf1f6faa48e156c35109c8 Mon Sep 17 00:00:00 2001 From: bcdxn <5067649+bcdxn@users.noreply.github.com> Date: Tue, 18 Aug 2026 16:26:45 -0400 Subject: [PATCH 14/22] fix: global flags missing normalization --- codec/codec.go | 60 ++++++++++------ codec/default_test.go | 154 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 194 insertions(+), 20 deletions(-) diff --git a/codec/codec.go b/codec/codec.go index 930ce04..39ea79f 100644 --- a/codec/codec.go +++ b/codec/codec.go @@ -87,6 +87,16 @@ func buildSpecDoc(rawDoc *rawDocument) (*spec.Document, error) { 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 @@ -311,33 +321,43 @@ func normalizeFlagDefaults(node *spec.CommandItem) error { } for i := range node.Flags { - flag := &node.Flags[i] - 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. - continue - } - if err != nil { + if err := normalizeOneFlagDefault(&node.Flags[i]); err != nil { return err } - flag.Default = norm } 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: diff --git a/codec/default_test.go b/codec/default_test.go index e891c5f..05d08f5 100644 --- a/codec/default_test.go +++ b/codec/default_test.go @@ -39,6 +39,36 @@ func defaultTestSpecJSON(t *testing.T, flagObj string) []byte { 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() @@ -58,6 +88,25 @@ func rootFlagDefault(t *testing.T, d *spec.Document, name string) any { 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 @@ -232,3 +281,108 @@ func TestUnmarshalFlagDefaultTypeMismatch(t *testing.T) { }) } } + +// 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) + } + }) + } +} From 98a6839fc6db032a1214f2896bd9aab0fc1964b3 Mon Sep 17 00:00:00 2001 From: bcdxn <5067649+bcdxn@users.noreply.github.com> Date: Tue, 18 Aug 2026 18:05:03 -0400 Subject: [PATCH 15/22] fix: bug wher toInt64 generated runtime config silently trucated float64 --- gen/templates/code/cobra/gencli/config.tmpl | 4 ++++ gen/templates/code/urfavecli/gencli/config.tmpl | 4 ++++ gen/testdata/cobra/gencli/config.gen.go | 4 ++++ gen/testdata/urfavecli/gencli/config.gen.go | 4 ++++ internal/cli/gencli/config.gen.go | 4 ++++ 5 files changed, 20 insertions(+) diff --git a/gen/templates/code/cobra/gencli/config.tmpl b/gen/templates/code/cobra/gencli/config.tmpl index 8783f32..37dd1d8 100644 --- a/gen/templates/code/cobra/gencli/config.tmpl +++ b/gen/templates/code/cobra/gencli/config.tmpl @@ -5,6 +5,7 @@ import ( {{- if .ConfigJSON}} "encoding/json" {{- end}} + "math" "os" "path/filepath" "strconv" @@ -171,6 +172,9 @@ func toInt64(v any) (int64, bool) { 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 { diff --git a/gen/templates/code/urfavecli/gencli/config.tmpl b/gen/templates/code/urfavecli/gencli/config.tmpl index 737a0a4..7d1d1ef 100644 --- a/gen/templates/code/urfavecli/gencli/config.tmpl +++ b/gen/templates/code/urfavecli/gencli/config.tmpl @@ -5,6 +5,7 @@ import ( {{- if .ConfigJSON}} "encoding/json" {{- end}} + "math" "os" "path/filepath" "strconv" @@ -167,6 +168,9 @@ func toInt64(v any) (int64, bool) { 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 { diff --git a/gen/testdata/cobra/gencli/config.gen.go b/gen/testdata/cobra/gencli/config.gen.go index 349b69e..2225c3f 100644 --- a/gen/testdata/cobra/gencli/config.gen.go +++ b/gen/testdata/cobra/gencli/config.gen.go @@ -6,6 +6,7 @@ import ( "github.com/BurntSushi/toml" "github.com/ohler55/ojg/jp" "gopkg.in/yaml.v3" + "math" "os" "path/filepath" "strconv" @@ -150,6 +151,9 @@ func toInt64(v any) (int64, bool) { 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 { diff --git a/gen/testdata/urfavecli/gencli/config.gen.go b/gen/testdata/urfavecli/gencli/config.gen.go index 79760b2..8cf3adf 100644 --- a/gen/testdata/urfavecli/gencli/config.gen.go +++ b/gen/testdata/urfavecli/gencli/config.gen.go @@ -6,6 +6,7 @@ import ( "github.com/BurntSushi/toml" "github.com/ohler55/ojg/jp" "gopkg.in/yaml.v3" + "math" "os" "path/filepath" "strconv" @@ -146,6 +147,9 @@ func toInt64(v any) (int64, bool) { 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 { diff --git a/internal/cli/gencli/config.gen.go b/internal/cli/gencli/config.gen.go index 886d796..811449d 100644 --- a/internal/cli/gencli/config.gen.go +++ b/internal/cli/gencli/config.gen.go @@ -4,6 +4,7 @@ package gencli import ( "github.com/ohler55/ojg/jp" "gopkg.in/yaml.v3" + "math" "os" "path/filepath" "strconv" @@ -132,6 +133,9 @@ func toInt64(v any) (int64, bool) { 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 { From 679e7bc337799bb2c533bbb8a2f46aed678027c8 Mon Sep 17 00:00:00 2001 From: bcdxn <5067649+bcdxn@users.noreply.github.com> Date: Tue, 18 Aug 2026 20:00:02 -0400 Subject: [PATCH 16/22] fix: altSourceItems called twice in resolveSliceFlag logic --- gen/templates/code/cobra/gencli/config.tmpl | 5 +++-- gen/templates/code/urfavecli/gencli/config.tmpl | 5 +++-- gen/testdata/cobra/gencli/config.gen.go | 5 +++-- gen/testdata/urfavecli/gencli/config.gen.go | 5 +++-- internal/cli/gencli/config.gen.go | 5 +++-- 5 files changed, 15 insertions(+), 10 deletions(-) diff --git a/gen/templates/code/cobra/gencli/config.tmpl b/gen/templates/code/cobra/gencli/config.tmpl index 37dd1d8..9780866 100644 --- a/gen/templates/code/cobra/gencli/config.tmpl +++ b/gen/templates/code/cobra/gencli/config.tmpl @@ -298,8 +298,9 @@ func resolveSliceFlag[T any](fs *pflag.FlagSet, name string, sources []AltSource return val } for _, src := range sources { - result := make([]T, 0, len(altSourceItems(src))) - for _, item := range altSourceItems(src) { + items := altSourceItems(src) + result := make([]T, 0, len(items)) + for _, item := range items { if v, ok := coerce(item); ok { result = append(result, v) } diff --git a/gen/templates/code/urfavecli/gencli/config.tmpl b/gen/templates/code/urfavecli/gencli/config.tmpl index 7d1d1ef..175677a 100644 --- a/gen/templates/code/urfavecli/gencli/config.tmpl +++ b/gen/templates/code/urfavecli/gencli/config.tmpl @@ -283,8 +283,9 @@ func resolveSliceFlag[T any](set bool, cliVal []T, sources []AltSource, coerce f return cliVal } for _, src := range sources { - result := make([]T, 0, len(altSourceItems(src))) - for _, item := range altSourceItems(src) { + items := altSourceItems(src) + result := make([]T, 0, len(items)) + for _, item := range items { if v, ok := coerce(item); ok { result = append(result, v) } diff --git a/gen/testdata/cobra/gencli/config.gen.go b/gen/testdata/cobra/gencli/config.gen.go index 2225c3f..53208c6 100644 --- a/gen/testdata/cobra/gencli/config.gen.go +++ b/gen/testdata/cobra/gencli/config.gen.go @@ -277,8 +277,9 @@ func resolveSliceFlag[T any](fs *pflag.FlagSet, name string, sources []AltSource return val } for _, src := range sources { - result := make([]T, 0, len(altSourceItems(src))) - for _, item := range altSourceItems(src) { + items := altSourceItems(src) + result := make([]T, 0, len(items)) + for _, item := range items { if v, ok := coerce(item); ok { result = append(result, v) } diff --git a/gen/testdata/urfavecli/gencli/config.gen.go b/gen/testdata/urfavecli/gencli/config.gen.go index 8cf3adf..7e74d41 100644 --- a/gen/testdata/urfavecli/gencli/config.gen.go +++ b/gen/testdata/urfavecli/gencli/config.gen.go @@ -262,8 +262,9 @@ func resolveSliceFlag[T any](set bool, cliVal []T, sources []AltSource, coerce f return cliVal } for _, src := range sources { - result := make([]T, 0, len(altSourceItems(src))) - for _, item := range altSourceItems(src) { + items := altSourceItems(src) + result := make([]T, 0, len(items)) + for _, item := range items { if v, ok := coerce(item); ok { result = append(result, v) } diff --git a/internal/cli/gencli/config.gen.go b/internal/cli/gencli/config.gen.go index 811449d..0f20e62 100644 --- a/internal/cli/gencli/config.gen.go +++ b/internal/cli/gencli/config.gen.go @@ -259,8 +259,9 @@ func resolveSliceFlag[T any](fs *pflag.FlagSet, name string, sources []AltSource return val } for _, src := range sources { - result := make([]T, 0, len(altSourceItems(src))) - for _, item := range altSourceItems(src) { + items := altSourceItems(src) + result := make([]T, 0, len(items)) + for _, item := range items { if v, ok := coerce(item); ok { result = append(result, v) } From 61c7bbd9bef8c35e83a3ba51d0d772d2b38d22d9 Mon Sep 17 00:00:00 2001 From: bcdxn <5067649+bcdxn@users.noreply.github.com> Date: Tue, 18 Aug 2026 20:19:11 -0400 Subject: [PATCH 17/22] fix: silently dropping shorthand in yargs templates --- gen/templates/code/yargs/gencli/run.tmpl | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/gen/templates/code/yargs/gencli/run.tmpl b/gen/templates/code/yargs/gencli/run.tmpl index df8e77a..75ac078 100644 --- a/gen/templates/code/yargs/gencli/run.tmpl +++ b/gen/templates/code/yargs/gencli/run.tmpl @@ -29,8 +29,8 @@ export async function run( {{- end}}{{- if .GlobalFlags}}{{range .GlobalFlags}} .option({{tsString .RawName}}, { describe: {{tsString .Summary}}, -{{- if or .Shorthand .ExtraAliases}} - aliases: [{{range $i, $a := .ExtraAliases}}{{if $i}}, {{end}}{{tsString $a}}{{end}}], +{{- 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", From 0c8ff3879c8c84112811638599bc7ac1e4378fce Mon Sep 17 00:00:00 2001 From: bcdxn <5067649+bcdxn@users.noreply.github.com> Date: Tue, 18 Aug 2026 20:35:20 -0400 Subject: [PATCH 18/22] fix: minor fixes --- .github/copilot-instructions.md | 2 +- codec/codec.go | 5 +---- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 4636090..1171720 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -125,4 +125,4 @@ OpenCLI Specification is a declarative, language-agnostic document specification - 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 files always use a subdirectory within `/tmp`. And be sure to clean up after yourself when done. +- For temporary, test, scratch files always use a subdirectory within `/tmp`. And be sure to clean up after yourself when done. diff --git a/codec/codec.go b/codec/codec.go index 39ea79f..3112356 100644 --- a/codec/codec.go +++ b/codec/codec.go @@ -507,10 +507,7 @@ func coerceFloat64(val any) (float64, bool) { case int64: return float64(v), true case uint64: - if v > math.MaxInt64 { - return 0, false - } - return float64(int64(v)), true + return float64(v), true case float64: return v, true default: From 98d2624e5453e1d0de8502ff6d1344a58c8b87a5 Mon Sep 17 00:00:00 2001 From: bcdxn <5067649+bcdxn@users.noreply.github.com> Date: Tue, 18 Aug 2026 21:20:33 -0400 Subject: [PATCH 19/22] fix: handle global flags in a more elegant way --- .github/copilot-instructions.md | 2 +- gen/cli_globalflags_test.go | 78 ++++ gen/cli_yargs.go | 16 - gen/templates/code/cobra/gencli/actions.tmpl | 2 +- gen/templates/code/cobra/gencli/command.tmpl | 14 +- gen/templates/code/cobra/gencli/params.tmpl | 20 + .../code/urfavecli/gencli/actions.tmpl | 2 +- .../code/urfavecli/gencli/command.tmpl | 6 +- .../code/urfavecli/gencli/params.tmpl | 20 + gen/templates/code/yargs/gencli/actions.tmpl | 10 +- gen/templates/code/yargs/gencli/command.tmpl | 13 +- gen/templates/code/yargs/gencli/params.tmpl | 9 + .../cobra/globalflags/gencli/actions.gen.go | 20 + .../cobra/globalflags/gencli/cmd_gflag.gen.go | 64 +++ .../globalflags/gencli/cmd_gflag_echo.gen.go | 58 +++ .../globalflags/gencli/cmd_gflag_greet.gen.go | 59 +++ .../globalflags/gencli/cmd_gflag_ping.gen.go | 48 ++ .../globalflags/gencli/cmd_gflag_send.gen.go | 69 +++ .../cobra/globalflags/gencli/errors.gen.go | 54 +++ .../cobra/globalflags/gencli/help.gen.go | 375 +++++++++++++++ .../cobra/globalflags/gencli/iostreams.gen.go | 197 ++++++++ .../cobra/globalflags/gencli/params.gen.go | 47 ++ .../cobra/globalflags/gencli/run.gen.go | 39 ++ gen/testdata/globalflags-cli.ocs.yaml | 86 ++++ .../globalflags/gencli/actions.gen.go | 20 + .../globalflags/gencli/cmd_gflag.gen.go | 55 +++ .../globalflags/gencli/cmd_gflag_echo.gen.go | 49 ++ .../globalflags/gencli/cmd_gflag_greet.gen.go | 53 +++ .../globalflags/gencli/cmd_gflag_ping.gen.go | 39 ++ .../globalflags/gencli/cmd_gflag_send.gen.go | 63 +++ .../globalflags/gencli/errors.gen.go | 54 +++ .../urfavecli/globalflags/gencli/help.gen.go | 428 ++++++++++++++++++ .../globalflags/gencli/iostreams.gen.go | 197 ++++++++ .../globalflags/gencli/params.gen.go | 47 ++ .../urfavecli/globalflags/gencli/run.gen.go | 55 +++ .../yargs/globalflags/gencli/actions.ts | 17 + .../globalflags/gencli/cmd-gflag-echo.ts | 94 ++++ .../globalflags/gencli/cmd-gflag-greet.ts | 93 ++++ .../globalflags/gencli/cmd-gflag-ping.ts | 79 ++++ .../globalflags/gencli/cmd-gflag-send.ts | 109 +++++ .../yargs/globalflags/gencli/cmd-gflag.ts | 91 ++++ .../yargs/globalflags/gencli/errors.ts | 49 ++ gen/testdata/yargs/globalflags/gencli/help.ts | 116 +++++ .../yargs/globalflags/gencli/params.ts | 38 ++ gen/testdata/yargs/globalflags/gencli/run.ts | 99 ++++ .../yargs/globalflags/gencli/types.ts | 33 ++ 46 files changed, 3149 insertions(+), 37 deletions(-) create mode 100644 gen/cli_globalflags_test.go create mode 100644 gen/testdata/cobra/globalflags/gencli/actions.gen.go create mode 100644 gen/testdata/cobra/globalflags/gencli/cmd_gflag.gen.go create mode 100644 gen/testdata/cobra/globalflags/gencli/cmd_gflag_echo.gen.go create mode 100644 gen/testdata/cobra/globalflags/gencli/cmd_gflag_greet.gen.go create mode 100644 gen/testdata/cobra/globalflags/gencli/cmd_gflag_ping.gen.go create mode 100644 gen/testdata/cobra/globalflags/gencli/cmd_gflag_send.gen.go create mode 100644 gen/testdata/cobra/globalflags/gencli/errors.gen.go create mode 100644 gen/testdata/cobra/globalflags/gencli/help.gen.go create mode 100644 gen/testdata/cobra/globalflags/gencli/iostreams.gen.go create mode 100644 gen/testdata/cobra/globalflags/gencli/params.gen.go create mode 100644 gen/testdata/cobra/globalflags/gencli/run.gen.go create mode 100644 gen/testdata/globalflags-cli.ocs.yaml create mode 100644 gen/testdata/urfavecli/globalflags/gencli/actions.gen.go create mode 100644 gen/testdata/urfavecli/globalflags/gencli/cmd_gflag.gen.go create mode 100644 gen/testdata/urfavecli/globalflags/gencli/cmd_gflag_echo.gen.go create mode 100644 gen/testdata/urfavecli/globalflags/gencli/cmd_gflag_greet.gen.go create mode 100644 gen/testdata/urfavecli/globalflags/gencli/cmd_gflag_ping.gen.go create mode 100644 gen/testdata/urfavecli/globalflags/gencli/cmd_gflag_send.gen.go create mode 100644 gen/testdata/urfavecli/globalflags/gencli/errors.gen.go create mode 100644 gen/testdata/urfavecli/globalflags/gencli/help.gen.go create mode 100644 gen/testdata/urfavecli/globalflags/gencli/iostreams.gen.go create mode 100644 gen/testdata/urfavecli/globalflags/gencli/params.gen.go create mode 100644 gen/testdata/urfavecli/globalflags/gencli/run.gen.go create mode 100644 gen/testdata/yargs/globalflags/gencli/actions.ts create mode 100644 gen/testdata/yargs/globalflags/gencli/cmd-gflag-echo.ts create mode 100644 gen/testdata/yargs/globalflags/gencli/cmd-gflag-greet.ts create mode 100644 gen/testdata/yargs/globalflags/gencli/cmd-gflag-ping.ts create mode 100644 gen/testdata/yargs/globalflags/gencli/cmd-gflag-send.ts create mode 100644 gen/testdata/yargs/globalflags/gencli/cmd-gflag.ts create mode 100644 gen/testdata/yargs/globalflags/gencli/errors.ts create mode 100644 gen/testdata/yargs/globalflags/gencli/help.ts create mode 100644 gen/testdata/yargs/globalflags/gencli/params.ts create mode 100644 gen/testdata/yargs/globalflags/gencli/run.ts create mode 100644 gen/testdata/yargs/globalflags/gencli/types.ts diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 1171720..5d3947e 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -125,4 +125,4 @@ OpenCLI Specification is a declarative, language-agnostic document specification - 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 a subdirectory within `/tmp`. And be sure to clean up after yourself when done. +- 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/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_yargs.go b/gen/cli_yargs.go index 8bbf30a..cfd5b40 100644 --- a/gen/cli_yargs.go +++ b/gen/cli_yargs.go @@ -491,22 +491,6 @@ func yargsTemplateFuncMap() template.FuncMap { } return result }, - // appendParamImports returns the param type import list for actions.ts: every - // Args/Flags type used by a leaf command plus GlobalFlags when global flags exist. - "appendParamImports": func(imports []string, hasGlobal bool) []string { - if !hasGlobal || len(imports) == 0 { - return imports - } - for _, n := range imports { - if n == "GlobalFlags" { - return imports - } - } - result := make([]string, len(imports)+1) - copy(result, imports) - result[len(imports)] = "GlobalFlags" - 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 diff --git a/gen/templates/code/cobra/gencli/actions.tmpl b/gen/templates/code/cobra/gencli/actions.tmpl index 4270476..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}}{{if $.GlobalFlags}}globalFlags GlobalFlags{{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 e66c6bf..80a9fc9 100644 --- a/gen/templates/code/cobra/gencli/command.tmpl +++ b/gen/templates/code/cobra/gencli/command.tmpl @@ -6,7 +6,10 @@ import ( "github.com/spf13/cobra" {{- if hasExtraAliases .CobraFlags}} "github.com/spf13/pflag" -{{- end}} +{{- end}}{{if .GlobalFlags}} + + "context" +{{- end -}} ) func {{.FuncName}}(a ActionsInterface) *cobra.Command { @@ -63,13 +66,16 @@ func {{.FuncName}}(a ActionsInterface) *cobra.Command { {{- end}} {{- end}} {{- if .GlobalFlags}} - globalFlags := 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}} - return a.{{.MethodName}}(c.Context(), {{if and .CobraArgs .CobraFlags}}cmdArgs, cmdFlags{{else if .CobraArgs}}cmdArgs{{else if .CobraFlags}}cmdFlags{{end}}{{if .GlobalFlags}}, globalFlags{{end}}) }, {{- end}} } diff --git a/gen/templates/code/cobra/gencli/params.tmpl b/gen/templates/code/cobra/gencli/params.tmpl index 4fefbb0..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. @@ -60,4 +64,20 @@ type GlobalFlags struct { {{.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/actions.tmpl b/gen/templates/code/urfavecli/gencli/actions.tmpl index 5571e98..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}}{{if $.GlobalFlags}}globalFlags GlobalFlags{{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 28c72c8..5589e64 100644 --- a/gen/templates/code/urfavecli/gencli/command.tmpl +++ b/gen/templates/code/urfavecli/gencli/command.tmpl @@ -56,13 +56,13 @@ func {{.FuncName}}(a ActionsInterface) *cli.Command { {{- end}} {{- end}} {{- if .GlobalFlags}} - globalFlags := 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}}{{if .GlobalFlags}}, globalFlags{{end}}) + return a.{{.MethodName}}(ctx, {{if and .UrfaveArgs .UrfaveFlags}}cmdArgs, cmdFlags{{else if .UrfaveArgs}}cmdArgs{{else if .UrfaveFlags}}cmdFlags{{end}}) }, {{- end}} } diff --git a/gen/templates/code/urfavecli/gencli/params.tmpl b/gen/templates/code/urfavecli/gencli/params.tmpl index 4fefbb0..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. @@ -60,4 +64,20 @@ type GlobalFlags struct { {{.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/yargs/gencli/actions.tmpl b/gen/templates/code/yargs/gencli/actions.tmpl index dfb90f1..170f34f 100644 --- a/gen/templates/code/yargs/gencli/actions.tmpl +++ b/gen/templates/code/yargs/gencli/actions.tmpl @@ -1,6 +1,6 @@ // Code generated by github.com/bcdxn/opencli@{{.ModuleVersion}} DO NOT EDIT. import { CommandPrintData } from "./types"; -{{- $imports := appendParamImports (collectParamImports .LeafCommands) (gt (len $.GlobalFlags) 0)}}{{if $imports}} +{{- $imports := collectParamImports .LeafCommands}}{{if $imports}} import { {{joinStrings $imports ",\n "}} } from "./params"; @@ -9,13 +9,13 @@ import { export interface ActionsInterface { {{- range .LeafCommands}} {{- if and .Args .Flags}} - {{.MethodName}}(args: {{.ArgsTypeName}}, flags: {{.FlagsTypeName}}{{if $.GlobalFlags}}, globalFlags: GlobalFlags{{end}}): Promise; + {{.MethodName}}(args: {{.ArgsTypeName}}, flags: {{.FlagsTypeName}}): Promise; {{- else if .Args}} - {{.MethodName}}(args: {{.ArgsTypeName}}{{if $.GlobalFlags}}, globalFlags: GlobalFlags{{end}}): Promise; + {{.MethodName}}(args: {{.ArgsTypeName}}): Promise; {{- else if .Flags}} - {{.MethodName}}(flags: {{.FlagsTypeName}}{{if $.GlobalFlags}}, globalFlags: GlobalFlags{{end}}): Promise; + {{.MethodName}}(flags: {{.FlagsTypeName}}): Promise; {{- else}} - {{.MethodName}}{{if $.GlobalFlags}}(globalFlags: GlobalFlags){{else}}(){{end}}: Promise; + {{.MethodName}}(): Promise; {{- end}} {{- end}} help(cmd: CommandPrintData): void; diff --git a/gen/templates/code/yargs/gencli/command.tmpl b/gen/templates/code/yargs/gencli/command.tmpl index 77f2529..619d086 100644 --- a/gen/templates/code/yargs/gencli/command.tmpl +++ b/gen/templates/code/yargs/gencli/command.tmpl @@ -17,6 +17,7 @@ import { {{- end}}{{end}} {{- if .GlobalFlags}} GlobalFlags, + setGlobalFlags, {{- end}} } from "./params"; {{- end}}{{if .ConfigImports}} @@ -153,20 +154,20 @@ export function {{.FuncName}}( }; {{- end}} {{- if .GlobalFlags}} - const globalFlags: GlobalFlags = { + setGlobalFlags({ {{- range .GlobalFlags}} {{.FieldName}}: {{resolveFlagValue .}}, {{- end}} - }; + }); {{- end}} {{- if and .YargsArgs .YargsFlags}} - return actions.{{.MethodName}}(cmdArgs, cmdFlags{{if .GlobalFlags}}, globalFlags{{end}}); + return actions.{{.MethodName}}(cmdArgs, cmdFlags); {{- else if .YargsArgs}} - return actions.{{.MethodName}}(cmdArgs{{if .GlobalFlags}}, globalFlags{{end}}); + return actions.{{.MethodName}}(cmdArgs); {{- else if .YargsFlags}} - return actions.{{.MethodName}}(cmdFlags{{if .GlobalFlags}}, globalFlags{{end}}); + return actions.{{.MethodName}}(cmdFlags); {{- else}} - return actions.{{.MethodName}}{{if .GlobalFlags}}(globalFlags){{else}}(){{end}}; + return actions.{{.MethodName}}(); {{- end}} {{- end}} }, diff --git a/gen/templates/code/yargs/gencli/params.tmpl b/gen/templates/code/yargs/gencli/params.tmpl index b4bb685..2e0b6c4 100644 --- a/gen/templates/code/yargs/gencli/params.tmpl +++ b/gen/templates/code/yargs/gencli/params.tmpl @@ -7,6 +7,15 @@ export interface GlobalFlags { {{- 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}} 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..9fd182a --- /dev/null +++ b/gen/testdata/cobra/globalflags/gencli/cmd_gflag.gen.go @@ -0,0 +1,64 @@ +// Code generated by github.com/bcdxn/opencli@(devel) DO NOT EDIT. +package gencli + +import ( + "github.com/bcdxn/opencli/spec" + "github.com/spf13/cobra" + + "context" +) + +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..d7993c6 --- /dev/null +++ b/gen/testdata/cobra/globalflags/gencli/cmd_gflag_echo.gen.go @@ -0,0 +1,58 @@ +// Code generated by github.com/bcdxn/opencli@(devel) DO NOT EDIT. +package gencli + +import ( + "github.com/bcdxn/opencli/spec" + "github.com/spf13/cobra" + + "context" +) + +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..2bd49fd --- /dev/null +++ b/gen/testdata/cobra/globalflags/gencli/cmd_gflag_greet.gen.go @@ -0,0 +1,59 @@ +// Code generated by github.com/bcdxn/opencli@(devel) DO NOT EDIT. +package gencli + +import ( + "github.com/bcdxn/opencli/spec" + "github.com/spf13/cobra" + + "context" +) + +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..ba20163 --- /dev/null +++ b/gen/testdata/cobra/globalflags/gencli/cmd_gflag_ping.gen.go @@ -0,0 +1,48 @@ +// Code generated by github.com/bcdxn/opencli@(devel) DO NOT EDIT. +package gencli + +import ( + "github.com/bcdxn/opencli/spec" + "github.com/spf13/cobra" + + "context" +) + +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..1272930 --- /dev/null +++ b/gen/testdata/cobra/globalflags/gencli/cmd_gflag_send.gen.go @@ -0,0 +1,69 @@ +// Code generated by github.com/bcdxn/opencli@(devel) DO NOT EDIT. +package gencli + +import ( + "github.com/bcdxn/opencli/spec" + "github.com/spf13/cobra" + + "context" +) + +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/gen/testdata/cobra/globalflags/gencli/help.gen.go b/gen/testdata/cobra/globalflags/gencli/help.gen.go new file mode 100644 index 0000000..c1de5f7 --- /dev/null +++ b/gen/testdata/cobra/globalflags/gencli/help.gen.go @@ -0,0 +1,375 @@ +// Code generated by github.com/bcdxn/opencli@(devel) DO NOT EDIT. +package gencli + +import ( + "fmt" + "strings" + + "charm.land/glamour/v2" + "charm.land/lipgloss/v2" + "github.com/bcdxn/opencli/spec" +) + +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 +} 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/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/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..2d6a8ba --- /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", + optionList: cmd.args.map((a) => ({ + name: a.name, + description: escapeChalk(a.summary), + })), + } as commandLineUsage.OptionList); + } +} + +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..77e1010 --- /dev/null +++ b/gen/testdata/yargs/globalflags/gencli/run.ts @@ -0,0 +1,99 @@ +// 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 } from "./errors"; +import { defaultHelpFn, defaultUsageFn } from "./help"; + +export async function run( + yargsInstance: yargs.Argv<{}>, + actions: ActionsInterface, +): Promise { + await yargsInstance + .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[]; +} From 75b172dc8e37c86514c2a3a6c112af4a19568b43 Mon Sep 17 00:00:00 2001 From: bcdxn <5067649+bcdxn@users.noreply.github.com> Date: Thu, 20 Aug 2026 10:38:58 -0400 Subject: [PATCH 20/22] fix: import bug + add docs --- docs/actions-implementation.md | 143 +++++++++++++++++++ gen/templates/code/cobra/gencli/command.tmpl | 3 - 2 files changed, 143 insertions(+), 3 deletions(-) create mode 100644 docs/actions-implementation.md 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/gen/templates/code/cobra/gencli/command.tmpl b/gen/templates/code/cobra/gencli/command.tmpl index 80a9fc9..49d663f 100644 --- a/gen/templates/code/cobra/gencli/command.tmpl +++ b/gen/templates/code/cobra/gencli/command.tmpl @@ -6,9 +6,6 @@ import ( "github.com/spf13/cobra" {{- if hasExtraAliases .CobraFlags}} "github.com/spf13/pflag" -{{- end}}{{if .GlobalFlags}} - - "context" {{- end -}} ) From 75be0df2169df549e207ee65d3135c2893cfd686 Mon Sep 17 00:00:00 2001 From: bcdxn <5067649+bcdxn@users.noreply.github.com> Date: Thu, 20 Aug 2026 14:56:24 -0400 Subject: [PATCH 21/22] doc: update documentation for yargs --- Makefile | 2 +- docs/{opencli.ocs.md => ocli.ocs.md} | 0 web/public/assets/code/actions.txt | 40 ++ .../app/docs/code-generation-yargs/page.tsx | 22 + web/src/views/GenHtmlDocs.tsx | 3 + web/src/views/GenManDocs.tsx | 3 + web/src/views/GenMarkdownDocs.tsx | 3 + web/src/views/GeneratingGoCode.tsx | 8 +- web/src/views/GeneratingYargsCode.tsx | 657 ++++++++++++++++++ web/src/views/GettingStarted.tsx | 3 + web/tsconfig.json | 3 +- 11 files changed, 740 insertions(+), 4 deletions(-) rename docs/{opencli.ocs.md => ocli.ocs.md} (100%) create mode 100644 web/public/assets/code/actions.txt create mode 100644 web/src/app/docs/code-generation-yargs/page.tsx create mode 100644 web/src/views/GeneratingYargsCode.tsx diff --git a/Makefile b/Makefile index 122160c..21f40d0 100644 --- a/Makefile +++ b/Makefile @@ -25,7 +25,7 @@ gen-docs: generate --format man \ --out ./docs \ ocli.ocs.yaml - mkdir -p build && mv docs/opencli.ocs.1 build/ocli.1 + mkdir -p build && mv docs/ocli.ocs.1 build/ocli.1 .PHONY: gen-examples 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/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..8593574 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 (Yargs) + diff --git a/web/src/views/GenManDocs.tsx b/web/src/views/GenManDocs.tsx index ddff134..70c778c 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 (Yargs) + diff --git a/web/src/views/GenMarkdownDocs.tsx b/web/src/views/GenMarkdownDocs.tsx index c2c3ac7..f625773 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 (Yargs) + diff --git a/web/src/views/GeneratingGoCode.tsx b/web/src/views/GeneratingGoCode.tsx index 2a8774c..97b8c9b 100644 --- a/web/src/views/GeneratingGoCode.tsx +++ b/web/src/views/GeneratingGoCode.tsx @@ -276,7 +276,8 @@ 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 (Yargs){" "} + docs.

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.

@@ -704,6 +705,9 @@ export default function GuidePage() { > Code Generation (Go) + + Code Generation (Yargs) + diff --git a/web/src/views/GeneratingYargsCode.tsx b/web/src/views/GeneratingYargsCode.tsx new file mode 100644 index 0000000..d58ca93 --- /dev/null +++ b/web/src/views/GeneratingYargsCode.tsx @@ -0,0 +1,657 @@ +"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 (Yargs) Page ─────────────────────────────────────────────── + +function GeneratingYargsCodePage() { + return ( + <> +

+ Generating A TypeScript CLI From OpenCLI Specs +

+

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

+
+ 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. +

+
+ +

+ 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..ed6fdf4 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 (Yargs) + 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" ] } From 8045f7608a277c11efa6d30dacdbca3acd784505 Mon Sep 17 00:00:00 2001 From: bcdxn <5067649+bcdxn@users.noreply.github.com> Date: Sun, 23 Aug 2026 21:54:52 -0400 Subject: [PATCH 22/22] add docs + minor fixes --- .../yargs/pleasantries/src/gencli/help.ts | 4 +- .../code/yargs/pleasantries/src/gencli/run.ts | 5 +- examples/code/yargs/pleasantries/src/index.ts | 4 +- gen/templates/code/yargs/gencli/help.tmpl | 4 +- gen/templates/code/yargs/gencli/run.tmpl | 5 +- .../cobra/globalflags/gencli/cmd_gflag.gen.go | 2 - .../globalflags/gencli/cmd_gflag_echo.gen.go | 2 - .../globalflags/gencli/cmd_gflag_greet.gen.go | 2 - .../globalflags/gencli/cmd_gflag_ping.gen.go | 2 - .../globalflags/gencli/cmd_gflag_send.gen.go | 2 - gen/testdata/yargs/gencli/help.ts | 4 +- gen/testdata/yargs/gencli/run.ts | 5 +- gen/testdata/yargs/globalflags/gencli/help.ts | 4 +- gen/testdata/yargs/globalflags/gencli/run.ts | 5 +- web/src/views/GenHtmlDocs.tsx | 2 +- web/src/views/GenManDocs.tsx | 2 +- web/src/views/GenMarkdownDocs.tsx | 2 +- web/src/views/GeneratingGoCode.tsx | 59 +++++++++++++++++-- web/src/views/GeneratingYargsCode.tsx | 52 ++++++++++++++-- web/src/views/GettingStarted.tsx | 2 +- 20 files changed, 127 insertions(+), 42 deletions(-) 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/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/run.tmpl b/gen/templates/code/yargs/gencli/run.tmpl index 75ac078..7fbee46 100644 --- a/gen/templates/code/yargs/gencli/run.tmpl +++ b/gen/templates/code/yargs/gencli/run.tmpl @@ -1,5 +1,6 @@ // 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}} @@ -13,7 +14,7 @@ import { CliError, ExitCode } from "./errors"; import { defaultHelpFn, defaultUsageFn } from "./help"; export async function run( - yargsInstance: yargs.Argv<{}>, + argv: string[], actions: ActionsInterface, ): Promise { {{- if .HasAltSources}} @@ -21,7 +22,7 @@ export async function run( loadConfig(); {{- end}} - await yargsInstance + await yargs(hideBin(argv)) .scriptName({{tsString .Binary}}) .help(false) {{- range .ChildImports}} diff --git a/gen/testdata/cobra/globalflags/gencli/cmd_gflag.gen.go b/gen/testdata/cobra/globalflags/gencli/cmd_gflag.gen.go index 9fd182a..cc2a295 100644 --- a/gen/testdata/cobra/globalflags/gencli/cmd_gflag.gen.go +++ b/gen/testdata/cobra/globalflags/gencli/cmd_gflag.gen.go @@ -4,8 +4,6 @@ package gencli import ( "github.com/bcdxn/opencli/spec" "github.com/spf13/cobra" - - "context" ) func NewCmdGflag(a ActionsInterface) *cobra.Command { diff --git a/gen/testdata/cobra/globalflags/gencli/cmd_gflag_echo.gen.go b/gen/testdata/cobra/globalflags/gencli/cmd_gflag_echo.gen.go index d7993c6..17b8527 100644 --- a/gen/testdata/cobra/globalflags/gencli/cmd_gflag_echo.gen.go +++ b/gen/testdata/cobra/globalflags/gencli/cmd_gflag_echo.gen.go @@ -4,8 +4,6 @@ package gencli import ( "github.com/bcdxn/opencli/spec" "github.com/spf13/cobra" - - "context" ) func NewCmdGflagEcho(a ActionsInterface) *cobra.Command { diff --git a/gen/testdata/cobra/globalflags/gencli/cmd_gflag_greet.gen.go b/gen/testdata/cobra/globalflags/gencli/cmd_gflag_greet.gen.go index 2bd49fd..c1b26bc 100644 --- a/gen/testdata/cobra/globalflags/gencli/cmd_gflag_greet.gen.go +++ b/gen/testdata/cobra/globalflags/gencli/cmd_gflag_greet.gen.go @@ -4,8 +4,6 @@ package gencli import ( "github.com/bcdxn/opencli/spec" "github.com/spf13/cobra" - - "context" ) func NewCmdGflagGreet(a ActionsInterface) *cobra.Command { diff --git a/gen/testdata/cobra/globalflags/gencli/cmd_gflag_ping.gen.go b/gen/testdata/cobra/globalflags/gencli/cmd_gflag_ping.gen.go index ba20163..c141ddc 100644 --- a/gen/testdata/cobra/globalflags/gencli/cmd_gflag_ping.gen.go +++ b/gen/testdata/cobra/globalflags/gencli/cmd_gflag_ping.gen.go @@ -4,8 +4,6 @@ package gencli import ( "github.com/bcdxn/opencli/spec" "github.com/spf13/cobra" - - "context" ) func NewCmdGflagPing(a ActionsInterface) *cobra.Command { diff --git a/gen/testdata/cobra/globalflags/gencli/cmd_gflag_send.gen.go b/gen/testdata/cobra/globalflags/gencli/cmd_gflag_send.gen.go index 1272930..f6eed3e 100644 --- a/gen/testdata/cobra/globalflags/gencli/cmd_gflag_send.gen.go +++ b/gen/testdata/cobra/globalflags/gencli/cmd_gflag_send.gen.go @@ -4,8 +4,6 @@ package gencli import ( "github.com/bcdxn/opencli/spec" "github.com/spf13/cobra" - - "context" ) func NewCmdGflagSend(a ActionsInterface) *cobra.Command { 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 3bea8c4..062e828 100644 --- a/gen/testdata/yargs/gencli/run.ts +++ b/gen/testdata/yargs/gencli/run.ts @@ -1,5 +1,6 @@ // 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"; @@ -11,12 +12,12 @@ import { CliError, ExitCode } from "./errors"; import { defaultHelpFn, defaultUsageFn } from "./help"; export async function run( - yargsInstance: yargs.Argv<{}>, + argv: string[], actions: ActionsInterface, ): Promise { // Load config file from disk (JSON/YAML/TOML) for $FILE alternative sources. loadConfig(); - await yargsInstance + await yargs(hideBin(argv)) .scriptName("petstore") .help(false) .command(newPetstoreListCmd(actions)) diff --git a/gen/testdata/yargs/globalflags/gencli/help.ts b/gen/testdata/yargs/globalflags/gencli/help.ts index 2d6a8ba..719330e 100644 --- a/gen/testdata/yargs/globalflags/gencli/help.ts +++ b/gen/testdata/yargs/globalflags/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/globalflags/gencli/run.ts b/gen/testdata/yargs/globalflags/gencli/run.ts index 77e1010..20edff2 100644 --- a/gen/testdata/yargs/globalflags/gencli/run.ts +++ b/gen/testdata/yargs/globalflags/gencli/run.ts @@ -1,5 +1,6 @@ // 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"; @@ -10,10 +11,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("gflag") .help(false) .command(newGflagPingCmd(actions)) diff --git a/web/src/views/GenHtmlDocs.tsx b/web/src/views/GenHtmlDocs.tsx index 8593574..f994e2a 100644 --- a/web/src/views/GenHtmlDocs.tsx +++ b/web/src/views/GenHtmlDocs.tsx @@ -253,7 +253,7 @@ export default function GuidePage() { Code Generation (Go) - Code Generation (Yargs) + Code Generation (TS) diff --git a/web/src/views/GenManDocs.tsx b/web/src/views/GenManDocs.tsx index 70c778c..08393d5 100644 --- a/web/src/views/GenManDocs.tsx +++ b/web/src/views/GenManDocs.tsx @@ -303,7 +303,7 @@ export default function GuidePage() { Code Generation (Go) - Code Generation (Yargs) + Code Generation (TS) diff --git a/web/src/views/GenMarkdownDocs.tsx b/web/src/views/GenMarkdownDocs.tsx index f625773..c4d4356 100644 --- a/web/src/views/GenMarkdownDocs.tsx +++ b/web/src/views/GenMarkdownDocs.tsx @@ -240,7 +240,7 @@ export default function GuidePage() { Code Generation (Go) - Code Generation (Yargs) + Code Generation (TS) diff --git a/web/src/views/GeneratingGoCode.tsx b/web/src/views/GeneratingGoCode.tsx index 97b8c9b..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,8 +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{" "} - Code Generation (Yargs){" "} - docs. + Code Generation (TS) docs.

+

+ 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 @@ -706,7 +755,7 @@ export default function GuidePage() { Code Generation (Go) - Code Generation (Yargs) + Code Generation (TS) diff --git a/web/src/views/GeneratingYargsCode.tsx b/web/src/views/GeneratingYargsCode.tsx index d58ca93..dd51ac6 100644 --- a/web/src/views/GeneratingYargsCode.tsx +++ b/web/src/views/GeneratingYargsCode.tsx @@ -67,7 +67,7 @@ function HighlightedCodeBlock({ ); } -// ── Code Generation (Yargs) Page ─────────────────────────────────────────────── +// ── Code Generation (TS) Page ─────────────────────────────────────────────── function GeneratingYargsCodePage() { return ( @@ -76,8 +76,8 @@ function GeneratingYargsCodePage() { Generating A TypeScript 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 code + using the Yargs framework.

Tip: Support is currently available for{" "} @@ -485,6 +485,50 @@ function GeneratingYargsCodePage() {

+

+ 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 @@ -641,7 +685,7 @@ export default function GuidePage() { href="/docs/code-generation-yargs" className="guide-nav__link is-active" > - Code Generation (Yargs) + Code Generation (TS) diff --git a/web/src/views/GettingStarted.tsx b/web/src/views/GettingStarted.tsx index ed6fdf4..3963d77 100644 --- a/web/src/views/GettingStarted.tsx +++ b/web/src/views/GettingStarted.tsx @@ -323,7 +323,7 @@ export default function GuidePage() { Code Generation (Go) - Code Generation (Yargs) + Code Generation (TS)