cli: alias table, registrar and printer move to internal/cliflag; vet joins
carlos vet lives in internal/vet, outside package main — leaving it long-only while every sibling verb takes -a is the papercut the spec closes, and open-coding an alias pair there is the default-value trap. The table, the registrar and the usage printer become a package; the four helpers and flagWasSet stay in flags.go as the root package's face, so the 79 call sites and the tests read the same.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
4 files changed,
+132
−92
flags.go+21 −89flags_test.go+5 −0internal/cliflag/cliflag.go+99 −0internal/vet/vet.go+7 −3
diff --git a/flags.go b/flags.go| index b1ba4079b..146f6bd26 100644 |
| --- a/flags.go |
| +++ b/flags.go |
| @@ -2,43 +2,34 @@ package main |
| import ( |
| "flag" |
| - "fmt" |
| - "strings" |
| + |
| + "github.com/carlosframework/platform/internal/cliflag" |
| ) |
| -// 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 |
| -// helpers, one usage printer. --app is the documented face; -app parses |
| -// forever (Go's flag treats one and two dashes alike). Letters already |
| -// claimed elsewhere and untouched: -f (logs, follow), -n (logs, line |
| -// count), -y (update, skip prompt). -vv is reserved for a future verbose |
| -// mode so it can never quietly claim -v and collide with --version; |
| -// -console stays long-only by ruling. |
| +// flags.go is the carlos CLI's face of internal/cliflag (the spec's |
| +// claimed short-flag namespace: -a app, -c channel, -e environment, |
| +// -v version). The four helpers below are the only way the aliased |
| +// flags are registered in this package; sites with something bespoke to |
| +// say pass it through, and an empty usage takes the flag's common |
| +// wording. The table itself, the registrar, and the usage printer live |
| +// in internal/cliflag so out-of-package verbs (internal/vet) register |
| +// through the same table instead of open-coding an alias pair. |
| -// cliAliases maps a primary flag name to its short alias. flagWasSet |
| -// resolves through this same table, which is what keeps "did the user |
| -// type it" true no matter which spelling was typed. |
| -var cliAliases = map[string]string{ |
| - "app": "a", |
| - "channel": "c", |
| - "environment": "e", |
| - "version": "v", |
| -} |
| +// cliAliases is the claimed table, shared with every registrar caller. |
| +var cliAliases = cliflag.Aliases |
| -// aliasedString registers name and its short form against one variable. |
| -// The alias's StringVar assigns through the shared pointer, so both |
| -// registrations MUST pass the same default — repeating def here is the |
| -// reason this helper exists rather than two open-coded calls (a |
| -// mismatched alias default silently clobbers the primary's). |
| +// aliasedString registers name and its short form against one variable |
| +// with one default — see cliflag.String for the default-value trap this |
| +// signature exists to prevent. |
| func aliasedString(fs *flag.FlagSet, name, def, usage string) *string { |
| - p := fs.String(name, def, usage) |
| - fs.StringVar(p, cliAliases[name], def, "alias for --"+name) |
| - return p |
| + return cliflag.String(fs, name, def, usage) |
| } |
| -// The four helpers below are the only way the aliased flags are |
| -// registered. An empty usage takes the flag's common wording; sites with |
| -// something bespoke to say pass it through. |
| +// flagUsage renders a flag set with aliases folded into their primary |
| +// and -- canonical; see cliflag.Usage. |
| +func flagUsage(fs *flag.FlagSet, header string) func() { |
| + return cliflag.Usage(fs, header) |
| +} |
| func appFlag(fs *flag.FlagSet, usage string) *string { |
| if usage == "" { |
| @@ -85,62 +76,3 @@ 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 287d8a22f..57e3766f9 100644 |
| --- a/flags_test.go |
| +++ b/flags_test.go |
| @@ -149,6 +149,8 @@ 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") |
| + var fields multiFlag |
| + fs.Var(&fields, "field", "only structured lines with this k=v field (repeatable)") |
| out := printedUsage(fs, "") |
| if !strings.Contains(out, "(default: stable)") { |
| t.Errorf("stable default not shown:\n%s", out) |
| @@ -156,4 +158,7 @@ func TestFlagUsageShowsRealDefaultsOnly(t *testing.T) { |
| if strings.Contains(out, "default: 0") { |
| t.Errorf("zero default shown:\n%s", out) |
| } |
| + if strings.Contains(out, "default: []") { |
| + t.Errorf("empty repeatable default shown:\n%s", out) |
| + } |
| } |
diff --git a/internal/cliflag/cliflag.go b/internal/cliflag/cliflag.go| new file mode 100644 |
| index 000000000..92b2440f1 |
| --- /dev/null |
| +++ b/internal/cliflag/cliflag.go |
| @@ -0,0 +1,99 @@ |
| +// Package cliflag is the single home of the carlos CLI's claimed |
| +// short-flag namespace (spec 2026-08-19-cli-flag-conventions-design.md): |
| +// the alias table, the one registrar that cannot get the default-value |
| +// trap wrong, and the usage printer that folds an alias into its |
| +// primary. It is a package rather than a corner of package main so that |
| +// verbs living outside main (internal/vet) register through the same |
| +// table instead of open-coding an alias pair. |
| +package cliflag |
| + |
| +import ( |
| + "flag" |
| + "fmt" |
| + "strings" |
| +) |
| + |
| +// Aliases maps a primary flag name to its short alias. Four letters, |
| +// and nothing else: -vv is reserved for a future verbose mode so it can |
| +// never quietly claim -v and collide with --version; -console stays |
| +// long-only by ruling; -f, -n, -y are claimed by logs/update as |
| +// primaries and are not aliases. |
| +var Aliases = map[string]string{ |
| + "app": "a", |
| + "channel": "c", |
| + "environment": "e", |
| + "version": "v", |
| +} |
| + |
| +// String registers name — and its short form, when the table claims one |
| +// — against one variable. The alias's StringVar assigns through the |
| +// shared pointer, so both registrations MUST pass the same default; |
| +// repeating def here is the reason this registrar exists rather than |
| +// two open-coded calls (a mismatched alias default silently clobbers |
| +// the primary's). |
| +func String(fs *flag.FlagSet, name, def, usage string) *string { |
| + p := fs.String(name, def, usage) |
| + if short := Aliases[name]; short != "" { |
| + fs.StringVar(p, short, def, "alias for --"+name) |
| + } |
| + return p |
| +} |
| + |
| +// Usage 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 Usage(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(Aliases)) |
| + for primary, short := range Aliases { |
| + 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 |
| + // registrar 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 := Aliases[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/internal/vet/vet.go b/internal/vet/vet.go| index 5a929f2e8..a52736f29 100644 |
| --- a/internal/vet/vet.go |
| +++ b/internal/vet/vet.go |
| @@ -29,6 +29,7 @@ import ( |
| "strings" |
| "time" |
| + "github.com/carlosframework/platform/internal/cliflag" |
| "github.com/carlosframework/platform/internal/release" |
| ) |
| @@ -177,19 +178,22 @@ func bootHealthz(ctx context.Context, bin []byte) error { |
| return fmt.Errorf("no 200 from /healthz within %s: %v", bootTimeout, last) |
| } |
| +const vetUsage = "usage: carlos vet --app <a> --version <v> [--account <sqid>] [--boot]" |
| + |
| // Run is the `carlos vet` verb: vet a shipped release against the store the |
| // environment names. |
| func Run(args []string) { |
| fs := flag.NewFlagSet("vet", flag.ExitOnError) |
| - app := fs.String("app", "", "app name") |
| + app := cliflag.String(fs, "app", "", "app name") |
| // Sqid only, deliberately: vet has no -registry flag to resolve a name |
| // against, and guessing an account would vet the wrong app's bytes. |
| acct := fs.String("account", release.OpsAccount, "account sqid the app lives under") |
| - ver := fs.String("version", "", "shipped version to vet") |
| + ver := cliflag.String(fs, "version", "", "shipped version to vet") |
| boot := fs.Bool("boot", false, "also boot the binary with -socket and poll /healthz (binary kind)") |
| + fs.Usage = cliflag.Usage(fs, vetUsage) |
| _ = fs.Parse(args) |
| if *app == "" || *ver == "" { |
| - log.Fatal("usage: carlos vet -app <a> -version <v> [-account <sqid>] [-boot]") |
| + log.Fatal(vetUsage) |
| } |
| if !release.ValidAccount(*acct) { |
| log.Fatalf("vet: invalid account sqid %q", *acct) |