cli: one usage printer — aliases folded, double dash canonical
Go's PrintDefaults can neither hide a flag nor emit --, so adding aliases to it prints two single-dash entries per flag, alphabetically separated, contradicting the documentation this change writes. The replacement derives every row from the live flag set — help cannot drift from what the parser accepts.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2 files changed,
+115
−1
flags.go+64 −1flags_test.go+51 −0
diff --git a/flags.go b/flags.go| index 48e088599..b1ba4079b 100644 |
| --- a/flags.go |
| +++ b/flags.go |
| @@ -1,6 +1,10 @@ |
| package main |
| -import "flag" |
| +import ( |
| + "flag" |
| + "fmt" |
| + "strings" |
| +) |
| // flags.go is the single home of the CLI's claimed short-flag namespace |
| // (spec 2026-08-19-cli-flag-conventions-design.md): four letters, four |
| @@ -81,3 +85,62 @@ func flagWasSet(fs *flag.FlagSet, name string) bool { |
| }) |
| return set |
| } |
| + |
| +// flagUsage replaces the stock PrintDefaults face for every carlos flag |
| +// set: the alias folds into its primary ("--app, -a"), long flags render |
| +// with the documented double dash, single-letter primaries (-f, -n, -y) |
| +// keep their one dash, and alias registrations never print a row of |
| +// their own. Output derives from the live flag set, so help cannot |
| +// drift from what the parser accepts — the reason to generate rather |
| +// than hand-write 90 flag lists. header is the command's synopsis |
| +// ("usage: carlos ..."); empty derives one from the set's name. |
| +func flagUsage(fs *flag.FlagSet, header string) func() { |
| + return func() { |
| + w := fs.Output() |
| + if header == "" { |
| + header = "usage: carlos " + fs.Name() + " [flags]" |
| + } |
| + fmt.Fprintln(w, strings.TrimRight(header, "\n")) |
| + aliasOf := make(map[string]string, len(cliAliases)) |
| + for primary, short := range cliAliases { |
| + aliasOf[short] = primary |
| + } |
| + type row struct{ head, usage string } |
| + var rows []row |
| + width := 0 |
| + fs.VisitAll(func(f *flag.Flag) { |
| + // Fold only pairs the table AND the set both know: a |
| + // command registering a plain -a of its own without the |
| + // helper would neither be hidden nor claim the fold. |
| + if p, ok := aliasOf[f.Name]; ok && fs.Lookup(p) != nil { |
| + return |
| + } |
| + head := "--" + f.Name |
| + if len(f.Name) == 1 { |
| + head = "-" + f.Name |
| + } |
| + if short := cliAliases[f.Name]; short != "" && fs.Lookup(short) != nil { |
| + head += ", -" + short |
| + } |
| + arg, usage := flag.UnquoteUsage(f) |
| + if arg != "" { |
| + head += " <" + arg + ">" |
| + } |
| + switch f.DefValue { |
| + case "", "false", "0": |
| + default: |
| + usage += " (default: " + f.DefValue + ")" |
| + } |
| + if len(head) > width { |
| + width = len(head) |
| + } |
| + rows = append(rows, row{head, usage}) |
| + }) |
| + if len(rows) > 0 { |
| + fmt.Fprintln(w, "flags:") |
| + } |
| + for _, r := range rows { |
| + fmt.Fprintf(w, " %-*s %s\n", width, r.head, r.usage) |
| + } |
| + } |
| +} |
diff --git a/flags_test.go b/flags_test.go| index 29279fd6c..287d8a22f 100644 |
| --- a/flags_test.go |
| +++ b/flags_test.go |
| @@ -3,6 +3,7 @@ package main |
| import ( |
| "flag" |
| "io" |
| + "strings" |
| "testing" |
| ) |
| @@ -106,3 +107,53 @@ func TestFlagWasSetSeesEverySpelling(t *testing.T) { |
| t.Error("kind reported set when only -a was typed") |
| } |
| } |
| + |
| +func printedUsage(fs *flag.FlagSet, header string) string { |
| + var b strings.Builder |
| + fs.SetOutput(&b) |
| + flagUsage(fs, header)() |
| + return b.String() |
| +} |
| + |
| +// One row per flag: the alias folds into its primary, long names render |
| +// with --, claimed single-letter primaries (-f, -n, -y) keep one dash, |
| +// and the alias never gets a row of its own. |
| +func TestFlagUsageFoldsAliases(t *testing.T) { |
| + fs := quietFlagSet("t") |
| + appFlag(fs, "") |
| + fs.Bool("f", false, "follow: poll for new lines until interrupted") |
| + fs.String("console", "", "console to work through") |
| + out := printedUsage(fs, "usage: carlos logs [flags] <app>") |
| + for _, want := range []string{ |
| + "usage: carlos logs [flags] <app>", |
| + "--app, -a <string>", |
| + "app name", |
| + "--console <string>", |
| + "\n -f ", |
| + } { |
| + if !strings.Contains(out, want) { |
| + t.Errorf("missing %q in:\n%s", want, out) |
| + } |
| + } |
| + if strings.Contains(out, "alias for") { |
| + t.Errorf("alias got its own row:\n%s", out) |
| + } |
| + if strings.Contains(out, "--f") { |
| + t.Errorf("single-letter primary rendered double-dash:\n%s", out) |
| + } |
| +} |
| + |
| +// Non-zero defaults are shown; zero-value defaults ("" / false / 0) are |
| +// noise and are not. |
| +func TestFlagUsageShowsRealDefaultsOnly(t *testing.T) { |
| + fs := quietFlagSet("t") |
| + aliasedString(fs, "channel", "stable", "release channel to follow") |
| + fs.Int("n", 0, "at most this many lines") |
| + out := printedUsage(fs, "") |
| + if !strings.Contains(out, "(default: stable)") { |
| + t.Errorf("stable default not shown:\n%s", out) |
| + } |
| + if strings.Contains(out, "default: 0") { |
| + t.Errorf("zero default shown:\n%s", out) |
| + } |
| +} |