From 43410bc141af3b945a200a9fd8a02e15fe4e34b9 Mon Sep 17 00:00:00 2001 From: Mike Fridman Date: Mon, 16 Feb 2026 15:26:20 -0500 Subject: [PATCH 1/3] feat: add short flag aliases via FlagMetadata.Short --- command.go | 7 +++- parse.go | 49 ++++++++++++++++++++++------ parse_test.go | 89 +++++++++++++++++++++++++++++++++++++++++++++++++++ usage.go | 85 +++++++++++++++++++++++++++++++----------------- usage_test.go | 51 +++++++++++++++++++++++++++++ 5 files changed, 241 insertions(+), 40 deletions(-) diff --git a/command.go b/command.go index 31d1613..c91b793 100644 --- a/command.go +++ b/command.go @@ -71,11 +71,16 @@ func (c *Command) terminal() *Command { return c.state.path[len(c.state.path)-1] } -// FlagMetadata holds additional metadata for a flag, such as whether it is required. +// FlagMetadata holds additional metadata for a flag, such as whether it is required or has a short +// alias. type FlagMetadata struct { // Name is the flag's name. Must match the flag name in the flag set. Name string + // Short is an optional single-character alias for the flag. When set, users can use either + // -v or -verbose (if Short is "v" and Name is "verbose"). Must be a single ASCII letter. + Short string + // Required indicates whether the flag is required. Required bool } diff --git a/parse.go b/parse.go index 3b35307..dd5cc3c 100644 --- a/parse.go +++ b/parse.go @@ -106,11 +106,22 @@ func resolveCommandPath(root *Command, argsToParse []string) (*Command, error) { // Check if this flag expects a value across all commands in the chain (not just the // current command), since flags from ancestor commands are inherited and can appear - // anywhere. + // anywhere. Also check short flag aliases from FlagsMetadata. name := strings.TrimLeft(arg, "-") skipValue := false for _, cmd := range root.state.path { - if f := cmd.Flags.Lookup(name); f != nil { + // First try direct lookup. + f := cmd.Flags.Lookup(name) + // If not found, check if it's a short alias. + if f == nil { + for _, fm := range cmd.FlagsMetadata { + if fm.Short == name { + f = cmd.Flags.Lookup(fm.Name) + break + } + } + } + if f != nil { if _, isBool := f.Value.(interface{ IsBoolFlag() bool }); !isBool { skipValue = true } @@ -145,23 +156,43 @@ func resolveCommandPath(root *Command, argsToParse []string) (*Command, error) { } // combineFlags merges flags from the command path into a single FlagSet. Flags are added in reverse -// order (deepest command first) so that child flags take precedence over parent flags. +// order (deepest command first) so that child flags take precedence over parent flags. Short flag +// aliases from FlagsMetadata are also registered, sharing the same Value as their long counterpart. func combineFlags(path []*Command) *flag.FlagSet { combined := flag.NewFlagSet(path[0].Name, flag.ContinueOnError) combined.SetOutput(io.Discard) for i := len(path) - 1; i >= 0; i-- { cmd := path[i] - if cmd.Flags != nil { - cmd.Flags.VisitAll(func(f *flag.Flag) { - if combined.Lookup(f.Name) == nil { - combined.Var(f.Value, f.Name, f.Usage) - } - }) + if cmd.Flags == nil { + continue } + shortMap := shortFlagMap(cmd.FlagsMetadata) + cmd.Flags.VisitAll(func(f *flag.Flag) { + if combined.Lookup(f.Name) == nil { + combined.Var(f.Value, f.Name, f.Usage) + } + // Register the short alias pointing to the same Value. + if short, ok := shortMap[f.Name]; ok { + if combined.Lookup(short) == nil { + combined.Var(f.Value, short, f.Usage) + } + } + }) } return combined } +// shortFlagMap builds a map from long flag name to short alias from FlagsMetadata. +func shortFlagMap(metadata []FlagMetadata) map[string]string { + m := make(map[string]string, len(metadata)) + for _, fm := range metadata { + if fm.Short != "" { + m[fm.Name] = fm.Short + } + } + return m +} + // checkRequiredFlags verifies that all flags marked as required in FlagsMetadata were explicitly // set during parsing. func checkRequiredFlags(path []*Command, combined *flag.FlagSet) error { diff --git a/parse_test.go b/parse_test.go index 6e2bb29..5e512ad 100644 --- a/parse_test.go +++ b/parse_test.go @@ -696,6 +696,95 @@ func TestParse(t *testing.T) { }) } +func TestShortFlags(t *testing.T) { + t.Parallel() + + t.Run("short flag sets value", func(t *testing.T) { + t.Parallel() + cmd := &Command{ + Name: "root", + Flags: FlagsFunc(func(f *flag.FlagSet) { + f.Bool("verbose", false, "enable verbose output") + f.String("output", "", "output file") + }), + FlagsMetadata: []FlagMetadata{ + {Name: "verbose", Short: "v"}, + {Name: "output", Short: "o"}, + }, + Exec: func(ctx context.Context, s *State) error { return nil }, + } + err := Parse(cmd, []string{"-v", "-o", "file.txt"}) + require.NoError(t, err) + require.True(t, GetFlag[bool](cmd.state, "verbose")) + require.Equal(t, "file.txt", GetFlag[string](cmd.state, "output")) + }) + + t.Run("long flag still works with short alias defined", func(t *testing.T) { + t.Parallel() + cmd := &Command{ + Name: "root", + Flags: FlagsFunc(func(f *flag.FlagSet) { + f.Bool("verbose", false, "enable verbose output") + }), + FlagsMetadata: []FlagMetadata{ + {Name: "verbose", Short: "v"}, + }, + Exec: func(ctx context.Context, s *State) error { return nil }, + } + err := Parse(cmd, []string{"-verbose"}) + require.NoError(t, err) + require.True(t, GetFlag[bool](cmd.state, "verbose")) + }) + + t.Run("short flag with subcommand", func(t *testing.T) { + t.Parallel() + child := &Command{ + Name: "child", + Flags: FlagsFunc(func(f *flag.FlagSet) { + f.String("name", "", "the name") + }), + FlagsMetadata: []FlagMetadata{ + {Name: "name", Short: "n"}, + }, + Exec: func(ctx context.Context, s *State) error { return nil }, + } + root := &Command{ + Name: "root", + Flags: FlagsFunc(func(f *flag.FlagSet) { + f.Bool("verbose", false, "verbose") + }), + FlagsMetadata: []FlagMetadata{ + {Name: "verbose", Short: "v"}, + }, + SubCommands: []*Command{child}, + Exec: func(ctx context.Context, s *State) error { return nil }, + } + err := Parse(root, []string{"-v", "child", "-n", "hello"}) + require.NoError(t, err) + require.True(t, GetFlag[bool](root.state, "verbose")) + require.Equal(t, "hello", GetFlag[string](root.state, "name")) + }) + + t.Run("short and long flags are aliases sharing same value", func(t *testing.T) { + t.Parallel() + cmd := &Command{ + Name: "root", + Flags: FlagsFunc(func(f *flag.FlagSet) { + f.Int("count", 0, "number of items") + }), + FlagsMetadata: []FlagMetadata{ + {Name: "count", Short: "c"}, + }, + Exec: func(ctx context.Context, s *State) error { return nil }, + } + // Use short flag + err := Parse(cmd, []string{"-c", "42"}) + require.NoError(t, err) + // Both short and long name should return the same value + require.Equal(t, 42, GetFlag[int](cmd.state, "count")) + }) +} + func getCommand(t *testing.T, c *Command) *Command { require.NotNil(t, c) require.NotNil(t, c.state) diff --git a/usage.go b/usage.go index 98e0a87..0ba6de5 100644 --- a/usage.go +++ b/usage.go @@ -95,39 +95,37 @@ func DefaultUsage(root *Command) string { continue } isGlobal := i < len(root.state.path)-1 - requiredFlags := make(map[string]bool) - for _, m := range cmd.FlagsMetadata { - if m.Required { - requiredFlags[m.Name] = true - } - } + metaMap := flagMetadataMap(cmd.FlagsMetadata) cmd.Flags.VisitAll(func(f *flag.Flag) { - flags = append(flags, flagInfo{ - name: "-" + f.Name, + fi := flagInfo{ + name: "--" + f.Name, usage: f.Usage, defval: f.DefValue, typeName: flagTypeName(f), global: isGlobal, - required: requiredFlags[f.Name], - }) + } + if m, ok := metaMap[f.Name]; ok { + fi.required = m.Required + fi.short = m.Short + } + flags = append(flags, fi) }) } } else if terminalCmd.Flags != nil { // Pre-parse fallback: show the command's own flags even without state. - requiredFlags := make(map[string]bool) - for _, m := range terminalCmd.FlagsMetadata { - if m.Required { - requiredFlags[m.Name] = true - } - } + metaMap := flagMetadataMap(terminalCmd.FlagsMetadata) terminalCmd.Flags.VisitAll(func(f *flag.Flag) { - flags = append(flags, flagInfo{ - name: "-" + f.Name, + fi := flagInfo{ + name: "--" + f.Name, usage: f.Usage, defval: f.DefValue, typeName: flagTypeName(f), - required: requiredFlags[f.Name], - }) + } + if m, ok := metaMap[f.Name]; ok { + fi.required = m.Required + fi.short = m.Short + } + flags = append(flags, fi) }) } @@ -136,9 +134,17 @@ func DefaultUsage(root *Command) string { return cmp.Compare(a.name, b.name) }) + hasAnyShort := false + for _, f := range flags { + if f.short != "" { + hasAnyShort = true + break + } + } + maxFlagLen := 0 for _, f := range flags { - if n := len(f.displayName()); n > maxFlagLen { + if n := len(f.displayName(hasAnyShort)); n > maxFlagLen { maxFlagLen = n } } @@ -155,13 +161,13 @@ func DefaultUsage(root *Command) string { if hasLocal { b.WriteString("Flags:\n") - writeFlagSection(&b, flags, maxFlagLen, false) + writeFlagSection(&b, flags, maxFlagLen, false, hasAnyShort) b.WriteString("\n") } if hasGlobal { b.WriteString("Global Flags:\n") - writeFlagSection(&b, flags, maxFlagLen, true) + writeFlagSection(&b, flags, maxFlagLen, true, hasAnyShort) b.WriteString("\n") } } @@ -178,7 +184,7 @@ func DefaultUsage(root *Command) string { } // writeFlagSection handles the formatting of flag descriptions -func writeFlagSection(b *strings.Builder, flags []flagInfo, maxLen int, global bool) { +func writeFlagSection(b *strings.Builder, flags []flagInfo, maxLen int, global, hasAnyShort bool) { nameWidth := maxLen + 4 wrapWidth := defaultTerminalWidth - nameWidth @@ -194,7 +200,7 @@ func writeFlagSection(b *strings.Builder, flags []flagInfo, maxLen int, global b description += fmt.Sprintf(" (default: %s)", f.defval) } - display := f.displayName() + display := f.displayName(hasAnyShort) lines := textutil.Wrap(description, wrapWidth) padding := strings.Repeat(" ", maxLen-len(display)+4) fmt.Fprintf(b, " %s%s%s\n", display, padding, lines[0]) @@ -206,8 +212,18 @@ func writeFlagSection(b *strings.Builder, flags []flagInfo, maxLen int, global b } } +// flagMetadataMap builds a lookup map from flag name to its FlagMetadata. +func flagMetadataMap(metadata []FlagMetadata) map[string]FlagMetadata { + m := make(map[string]FlagMetadata, len(metadata)) + for _, fm := range metadata { + m[fm.Name] = fm + } + return m +} + type flagInfo struct { name string + short string usage string defval string typeName string @@ -215,13 +231,22 @@ type flagInfo struct { required bool } -// displayName returns the flag name with its type hint, e.g., "-config string" or "-verbose" (no -// type for bools). -func (f flagInfo) displayName() string { +// displayName returns the flag name with optional short alias and type hint. When hasAnyShort is +// true, flags without a short alias are padded to align with those that have one. +// Examples: "-v, --verbose", "-o, --output string", " --config string", "--debug". +func (f flagInfo) displayName(hasAnyShort bool) string { + var name string + if f.short != "" { + name = "-" + f.short + ", " + f.name + } else if hasAnyShort { + name = " " + f.name + } else { + name = f.name + } if f.typeName == "" { - return f.name + return name } - return f.name + " " + f.typeName + return name + " " + f.typeName } // flagTypeName returns a short type name for a flag's value. Bool flags return "" since their type diff --git a/usage_test.go b/usage_test.go index d2a75bd..021e3ca 100644 --- a/usage_test.go +++ b/usage_test.go @@ -423,6 +423,57 @@ func TestWriteFlagSection(t *testing.T) { require.Contains(t, output, "(default: stdout)") }) + t.Run("short flags displayed", func(t *testing.T) { + t.Parallel() + + cmd := &Command{ + Name: "test", + Flags: FlagsFunc(func(fset *flag.FlagSet) { + fset.Bool("verbose", false, "enable verbose output") + fset.String("output", "", "output file") + fset.String("config", "", "config file path") + }), + FlagsMetadata: []FlagMetadata{ + {Name: "verbose", Short: "v"}, + {Name: "output", Short: "o"}, + }, + Exec: func(ctx context.Context, s *State) error { return nil }, + } + + err := Parse(cmd, []string{}) + require.NoError(t, err) + + output := DefaultUsage(cmd) + // Flags with short aliases show both forms + require.Contains(t, output, "-v, --verbose") + require.Contains(t, output, "-o, --output string") + // Flags without short aliases are padded to align with double-dash + require.Contains(t, output, " --config string") + }) + + t.Run("no short flags means no padding", func(t *testing.T) { + t.Parallel() + + cmd := &Command{ + Name: "test", + Flags: FlagsFunc(func(fset *flag.FlagSet) { + fset.Bool("verbose", false, "enable verbose output") + fset.String("config", "", "config file path") + }), + Exec: func(ctx context.Context, s *State) error { return nil }, + } + + err := Parse(cmd, []string{}) + require.NoError(t, err) + + output := DefaultUsage(cmd) + // Without any short flags, no extra padding should be added + require.Contains(t, output, " --verbose") + require.Contains(t, output, " --config string") + require.NotContains(t, output, " --verbose") + require.NotContains(t, output, " --config") + }) + t.Run("no flags section when no flags", func(t *testing.T) { t.Parallel() From 24974c439fd77b713cf7e58a00654e2e368adeee Mon Sep 17 00:00:00 2001 From: Mike Fridman Date: Mon, 16 Feb 2026 15:30:22 -0500 Subject: [PATCH 2/3] validate FlagsMetadata references, short alias format, and duplicates --- parse.go | 36 +++++++++++++++++++++++++++++++ parse_test.go | 59 +++++++++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 91 insertions(+), 4 deletions(-) diff --git a/parse.go b/parse.go index dd5cc3c..c6ec933 100644 --- a/parse.go +++ b/parse.go @@ -280,6 +280,14 @@ func validateCommands(root *Command, path []string) error { return fmt.Errorf("command [%s]: %w", strings.Join(quoted, ", "), err) } + if err := validateFlagsMetadata(root); err != nil { + quoted := make([]string, len(currentPath)) + for i, p := range currentPath { + quoted[i] = strconv.Quote(p) + } + return fmt.Errorf("command [%s]: %w", strings.Join(quoted, ", "), err) + } + for _, sub := range root.SubCommands { if err := validateCommands(sub, currentPath); err != nil { return err @@ -287,3 +295,31 @@ func validateCommands(root *Command, path []string) error { } return nil } + +// validateFlagsMetadata checks that each FlagMetadata entry refers to a flag that exists in the +// command's FlagSet, that Short aliases are single ASCII letters, and that no two entries share the +// same Short alias. +func validateFlagsMetadata(cmd *Command) error { + if len(cmd.FlagsMetadata) == 0 { + return nil + } + seenShorts := make(map[string]string) // short -> flag name + for _, fm := range cmd.FlagsMetadata { + if cmd.Flags == nil || cmd.Flags.Lookup(fm.Name) == nil { + return fmt.Errorf("flag metadata references unknown flag %q", fm.Name) + } + if fm.Short == "" { + continue + } + if len(fm.Short) != 1 || fm.Short[0] < 'a' || fm.Short[0] > 'z' { + if fm.Short[0] < 'A' || fm.Short[0] > 'Z' { + return fmt.Errorf("flag %q: short alias must be a single ASCII letter, got %q", fm.Name, fm.Short) + } + } + if other, ok := seenShorts[fm.Short]; ok { + return fmt.Errorf("duplicate short flag %q: used by both %q and %q", fm.Short, other, fm.Name) + } + seenShorts[fm.Short] = fm.Name + } + return nil +} diff --git a/parse_test.go b/parse_test.go index 5e512ad..ca2c3e0 100644 --- a/parse_test.go +++ b/parse_test.go @@ -368,9 +368,7 @@ func TestParse(t *testing.T) { } err := Parse(cmd, nil) require.Error(t, err) - // TODO(mf): consider improving this error message so it's obvious that a "required" flag - // was set by the cli author but not registered in the flag set - require.ErrorContains(t, err, `command "root": internal error: required flag -some-other-flag not found in flag set`) + require.ErrorContains(t, err, `flag metadata references unknown flag "some-other-flag"`) }) t.Run("space in command name", func(t *testing.T) { t.Parallel() @@ -569,7 +567,7 @@ func TestParse(t *testing.T) { } err := Parse(cmd, []string{"--existing=value"}) require.Error(t, err) - require.ErrorContains(t, err, "required flag -nonexistent not found in flag set") + require.ErrorContains(t, err, `flag metadata references unknown flag "nonexistent"`) }) t.Run("args with special characters", func(t *testing.T) { t.Parallel() @@ -783,6 +781,59 @@ func TestShortFlags(t *testing.T) { // Both short and long name should return the same value require.Equal(t, 42, GetFlag[int](cmd.state, "count")) }) + + t.Run("metadata references unknown flag", func(t *testing.T) { + t.Parallel() + cmd := &Command{ + Name: "root", + Flags: FlagsFunc(func(f *flag.FlagSet) { + f.Bool("verbose", false, "enable verbose output") + }), + FlagsMetadata: []FlagMetadata{ + {Name: "vrbose", Short: "v"}, // typo in Name + }, + Exec: func(ctx context.Context, s *State) error { return nil }, + } + err := Parse(cmd, []string{}) + require.Error(t, err) + require.Contains(t, err.Error(), `flag metadata references unknown flag "vrbose"`) + }) + + t.Run("short alias must be single ASCII letter", func(t *testing.T) { + t.Parallel() + cmd := &Command{ + Name: "root", + Flags: FlagsFunc(func(f *flag.FlagSet) { + f.Bool("verbose", false, "enable verbose output") + }), + FlagsMetadata: []FlagMetadata{ + {Name: "verbose", Short: "vv"}, + }, + Exec: func(ctx context.Context, s *State) error { return nil }, + } + err := Parse(cmd, []string{}) + require.Error(t, err) + require.Contains(t, err.Error(), "short alias must be a single ASCII letter") + }) + + t.Run("duplicate short alias", func(t *testing.T) { + t.Parallel() + cmd := &Command{ + Name: "root", + Flags: FlagsFunc(func(f *flag.FlagSet) { + f.Bool("verbose", false, "enable verbose output") + f.Bool("version", false, "show version") + }), + FlagsMetadata: []FlagMetadata{ + {Name: "verbose", Short: "v"}, + {Name: "version", Short: "v"}, + }, + Exec: func(ctx context.Context, s *State) error { return nil }, + } + err := Parse(cmd, []string{}) + require.Error(t, err) + require.Contains(t, err.Error(), `duplicate short flag "v"`) + }) } func getCommand(t *testing.T, c *Command) *Command { From c56fbb36a20077be4fd45b0cf722e2cf59b50a8c Mon Sep 17 00:00:00 2001 From: Mike Fridman Date: Mon, 16 Feb 2026 15:32:47 -0500 Subject: [PATCH 3/3] update README.md --- README.md | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 9e884ab..db3970e 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ resolved command. For applications that need work between parsing and execution, ## Flags `FlagsFunc` is a convenience for defining flags inline. Use `FlagsMetadata` to extend the standard -`flag` package with features like required flag enforcement: +`flag` package with features like required flag enforcement and short aliases: ```go Flags: cli.FlagsFunc(func(f *flag.FlagSet) { @@ -48,10 +48,14 @@ Flags: cli.FlagsFunc(func(f *flag.FlagSet) { f.String("output", "", "output file") }), FlagsMetadata: []cli.FlagMetadata{ - {Name: "output", Required: true}, + {Name: "verbose", Short: "v"}, + {Name: "output", Short: "o", Required: true}, }, ``` +Short aliases register `-v` as an alias for `--verbose`, `-o` as an alias for `--output`, and so on. +Both forms are shown in help output automatically. + Access flags inside `Exec` with the type-safe `GetFlag` function: ```go @@ -107,8 +111,8 @@ There are many great CLI libraries out there, but I always felt [they were too h needs](https://mfridman.com/blog/2021/a-simpler-building-block-for-go-clis/). Inspired by Peter Bourgon's [ff](https://github.com/peterbourgon/ff) library, specifically the `v3` -branch, which was so close to what I wanted. The `v4` branch took a different direction, and I wanted -to keep the simplicity of `v3`. This library carries that idea forward. +branch, which was so close to what I wanted. The `v4` branch took a different direction, and I +wanted to keep the simplicity of `v3`. This library carries that idea forward. ## License