From ecd2b19783ed22821af3bc23d452ebfdb8849a0f Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Wed, 7 Oct 2026 13:37:57 +0000 Subject: [PATCH] Add generated help and version commands --- README.md | 8 +- app.go | 3 + builtins.go | 208 +++++++++++++++++++++++++++++++++ builtins_test.go | 58 +++++++++ command.go | 15 +++ docs/cli.md | 91 +++++++++++++++ docs/development.md | 7 +- docs/roadmap/implementation.md | 11 +- execute.go | 18 ++- execute_external_test.go | 151 ++++++++++++++++++++++++ internal/cobra/parse.go | 163 +++++++++++++++++++++++++- internal/cobra/parse_test.go | 14 +++ parser.go | 16 ++- 13 files changed, 736 insertions(+), 27 deletions(-) create mode 100644 builtins.go create mode 100644 builtins_test.go create mode 100644 docs/cli.md diff --git a/README.md b/README.md index 7704cc7..dbde344 100644 --- a/README.md +++ b/README.md @@ -3,13 +3,15 @@ CommandKit is a Go library project for consistent CLI inspection, command syntax, and shared command infrastructure across related applications. -The repository currently contains planning documents, accepted architectural -decisions, and adopted development policies. No Go module, public API, or command -implementation is available yet. +The current Go library provides an instance-owned command registry, typed flag +and operand bindings, Cobra-backed routing, invocation outcomes, generated help, +and application or CommandKit version reporting. Standard configuration and +PromptKit command families remain planned. ## Documentation - [Contributor guide](docs/development.md) +- [Command line reference](docs/cli.md) - [Architecture policy](docs/policy/architecture.md) - [CLI standard proposal](docs/roadmap/01-cli-plan.md) - [Dispatcher implementation roadmap](docs/roadmap/02-dispatch.md) diff --git a/app.go b/app.go index cd3937e..f3ac4ec 100644 --- a/app.go +++ b/app.go @@ -34,6 +34,9 @@ func New(identity Identity, commands ...Command) (*App, error) { }, }, } + if err := app.installBuiltins(); err != nil { + return nil, err + } if err := app.Register(commands...); err != nil { return nil, err } diff --git a/builtins.go b/builtins.go new file mode 100644 index 0000000..c68e679 --- /dev/null +++ b/builtins.go @@ -0,0 +1,208 @@ +package commandkit + +import ( + "bytes" + "errors" + "fmt" + "io" + "runtime/debug" + "strings" + + internalcobra "gitea.maximumdirect.net/eric/CommandKit/internal/cobra" + yaml "go.yaml.in/yaml/v4" +) + +const commandKitModulePath = "gitea.maximumdirect.net/eric/CommandKit" + +type helpInput struct { + Targets Supplied[[]string] +} + +type versionInput struct { + Scope string + Format string +} + +type versionDocument struct { + Name string `yaml:"name"` + Version string `yaml:"version"` + Revision string `yaml:"revision"` +} + +func (a *App) installBuiltins() error { + help, err := NewCommand(CommandSpec[helpInput]{ + Name: "help", + Summary: "Show help for commands.", + Description: "Describe registered commands and their inputs.", + Arguments: []Argument[helpInput]{RepeatArgument(ArgumentMeta{Name: "COMMAND", Usage: "registered command path"}, 0, -1, + func(value string) (string, error) { return value, nil }, + func(input *helpInput, value Supplied[[]string]) { input.Targets = value })}, + Run: func(invocation Invocation, input helpInput) Outcome { + target, err := a.helpCommandTarget(input.Targets.Value) + if err != nil { + return Usage(err) + } + return a.renderHelpOutcome(target, invocation.Streams) + }, + }) + if err != nil { + return fmt.Errorf("create built-in help command: %w", err) + } + scopeDefault := "app" + formatDefault := "text" + version, err := NewCommand(CommandSpec[versionInput]{ + Name: "version", + Summary: "Show application or CommandKit version.", + Description: "Report identity and build metadata without loading application configuration.", + Flags: []Flag[versionInput]{ + ValueFlag(FlagMeta{Name: "scope", Usage: "version subject"}, &scopeDefault, parseVersionScope, func(input *versionInput, value Supplied[string]) { input.Scope = value.Value }), + ValueFlag(FlagMeta{Name: "format", Usage: "output format"}, &formatDefault, parseVersionFormat, func(input *versionInput, value Supplied[string]) { input.Format = value.Value }), + }, + Run: func(invocation Invocation, input versionInput) Outcome { + identity := a.identity + if input.Scope == "commandkit" { + identity = commandKitBuildIdentity(readBuildInfo()) + } + if identity.Version == "" { + identity.Version = "dev" + } + return renderVersionOutcome(identity, input.Format, invocation.Streams) + }, + }) + if err != nil { + return fmt.Errorf("create built-in version command: %w", err) + } + a.commands["help"] = &commandNode{command: help, reserved: true} + a.commands["version"] = &commandNode{command: version, reserved: true} + return nil +} + +func parseVersionScope(value string) (string, error) { + if value != "app" && value != "commandkit" { + return "", fmt.Errorf("scope must be app or commandkit, got %q", value) + } + return value, nil +} + +func parseVersionFormat(value string) (string, error) { + if value != "text" && value != "yaml" { + return "", fmt.Errorf("format must be text or yaml, got %q", value) + } + return value, nil +} + +func (a *App) renderHelpOutcome(path []string, streams Streams) Outcome { + if len(path) > 0 { + if _, ok := a.commandAt(path); !ok { + return Usage(fmt.Errorf("unknown help path %q", strings.Join(path, " "))) + } + } + content, failure := internalcobra.RenderHelp(a.bridgeTree(), path) + if failure != nil { + return Failure(errors.New(failure.Message)) + } + if err := writeReport(streams.Out, []byte(content)); err != nil { + return incompleteReport(err) + } + return Success() +} + +func renderVersionOutcome(identity Identity, format string, streams Streams) Outcome { + var content []byte + switch format { + case "text": + name := identity.Name + if identity.Version == "" { + identity.Version = "dev" + } + line := name + " " + identity.Version + if identity.Revision != "" { + line += " (" + identity.Revision + ")" + } + content = []byte(line + "\n") + case "yaml": + if identity.Version == "" { + identity.Version = "dev" + } + var buffer bytes.Buffer + if err := encodeYAML(&buffer, versionDocument{Name: identity.Name, Version: identity.Version, Revision: identity.Revision}); err != nil { + return Failure(err) + } + content = buffer.Bytes() + default: + return Usage(fmt.Errorf("unsupported version format %q", format)) + } + if err := writeReport(streams.Out, content); err != nil { + return incompleteReport(err) + } + return Success() +} + +func encodeYAML(writer io.Writer, document any) error { + encoder := yaml.NewEncoder(writer) + encoder.SetIndent(2) + return encoder.Encode(document) +} + +func writeReport(writer io.Writer, content []byte) error { + checked := &checkedWriter{writer: writer} + _, err := checked.Write(content) + if err == nil { + err = checked.failure + } + return err +} + +func incompleteReport(err error) Outcome { + outcome := Failure(err) + outcome.ReportIncomplete = true + outcome.DeliveryError = err + return outcome +} + +func readBuildInfo() *debug.BuildInfo { + info, ok := debug.ReadBuildInfo() + if !ok { + return nil + } + return info +} + +func commandKitBuildIdentity(info *debug.BuildInfo) Identity { + identity := Identity{Name: "CommandKit", Version: "dev"} + if info == nil { + return identity + } + var module *debug.Module + if info.Main.Path == commandKitModulePath { + module = &info.Main + } else { + for _, dependency := range info.Deps { + if dependency != nil && dependency.Path == commandKitModulePath { + module = dependency + break + } + } + } + if module == nil { + return identity + } + version := module.Version + if module.Replace != nil { + version = module.Replace.Version + } + if version != "" && version != "(devel)" { + identity.Version = version + } + return identity +} + +func (a *App) helpCommandTarget(targets []string) ([]string, error) { + if len(targets) == 0 { + return nil, nil + } + if _, ok := a.commandAt(targets); !ok { + return nil, fmt.Errorf("unknown help path %q", strings.Join(targets, " ")) + } + return append([]string(nil), targets...), nil +} diff --git a/builtins_test.go b/builtins_test.go new file mode 100644 index 0000000..9e859f8 --- /dev/null +++ b/builtins_test.go @@ -0,0 +1,58 @@ +package commandkit + +import ( + "runtime/debug" + "testing" +) + +func TestCommandKitBuildIdentityProjectsEffectiveModuleVersion(t *testing.T) { + tests := []struct { + name string + info *debug.BuildInfo + want string + }{ + { + name: "main module tag", + info: &debug.BuildInfo{Main: debug.Module{Path: commandKitModulePath, Version: "v1.2.3"}}, + want: "v1.2.3", + }, + { + name: "dependency tag", + info: &debug.BuildInfo{Main: debug.Module{Path: "example.com/application", Version: "v4.0.0"}, Deps: []*debug.Module{{Path: commandKitModulePath, Version: "v1.2.3"}}}, + want: "v1.2.3", + }, + { + name: "versioned replacement", + info: &debug.BuildInfo{Main: debug.Module{Path: "example.com/application"}, Deps: []*debug.Module{{Path: commandKitModulePath, Version: "v1.2.3", Replace: &debug.Module{Path: "example.com/fork", Version: "v2.0.0"}}}}, + want: "v2.0.0", + }, + { + name: "local replacement", + info: &debug.BuildInfo{Main: debug.Module{Path: commandKitModulePath, Version: "v1.2.3", Replace: &debug.Module{Path: "../CommandKit"}}}, + want: "dev", + }, + { + name: "development module", + info: &debug.BuildInfo{Main: debug.Module{Path: commandKitModulePath, Version: "(devel)"}}, + want: "dev", + }, + { + name: "missing module", + info: &debug.BuildInfo{Main: debug.Module{Path: "example.com/application"}}, + want: "dev", + }, + { + name: "missing build info", + info: nil, + want: "dev", + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + identity := commandKitBuildIdentity(test.info) + if identity.Name != "CommandKit" || identity.Version != test.want || identity.Revision != "" { + t.Fatalf("identity = %#v, want CommandKit %q with no revision", identity, test.want) + } + }) + } +} diff --git a/command.go b/command.go index e544303..e71a82c 100644 --- a/command.go +++ b/command.go @@ -98,6 +98,7 @@ type commandPayload interface { validate() error hasRun() bool bridgeFlags() []internalcobra.Flag + bridgeArguments() []internalcobra.Argument prepare([][]string, []string, bool) (func() any, error) checkInput(any) error run(Invocation, any) Outcome @@ -132,6 +133,20 @@ func (c typedCommand[I]) bridgeFlags() []internalcobra.Flag { return flags } +func (c typedCommand[I]) bridgeArguments() []internalcobra.Argument { + arguments := make([]internalcobra.Argument, len(c.spec.Arguments)) + for index, argument := range c.spec.Arguments { + binding := argument.binding + arguments[index] = internalcobra.Argument{ + Name: binding.meta.Name, + Usage: binding.meta.Usage, + Min: binding.min, + Max: binding.max, + } + } + return arguments +} + func (c typedCommand[I]) prepare(flagValues [][]string, operands []string, enforceRequired bool) (func() any, error) { prepared, err := prepareInput(c.spec, flagValues, operands, enforceRequired) if err != nil { diff --git a/docs/cli.md b/docs/cli.md new file mode 100644 index 0000000..edf580e --- /dev/null +++ b/docs/cli.md @@ -0,0 +1,91 @@ +# Command line reference + +CommandKit owns command routing and syntax for commands registered with its +`App`. The caller supplies argv without the executable name, a context, and the +output and diagnostic writers. `App.Execute` returns an `Outcome`; it does not +exit the process. + +## Help + +The root help command lists registered commands and their summaries. Help is +available through a bare invocation, `--help`, `-h`, and `help`: + +```text +example +example --help +example -h +example help +example help config show +example config --help +``` + +`help [COMMAND...]` resolves an exact registered path. A command group without +an execution handler displays its help when invoked by itself. A runnable +command with children runs its handler when invoked without help; add `--help` +to inspect that command and its children. + +Help uses registered descriptions, command summaries, local flags, defaults, +required and repeatable flag metadata, and operand names and counts. It does not +construct the selected command's input or invoke its handler. Supplied flag and +operand values are still parsed so malformed values and unknown flags remain +usage errors. Missing execution-required values and input-combination checks +are skipped for help. + +## Version + +`version` reports the application identity by default: + +```text +example version +example version --scope commandkit +example version --format yaml +``` + +The supported flags are: + +| Flag | Values | Default | Meaning | +| --- | --- | --- | --- | +| `--scope` | `app`, `commandkit` | `app` | Select the caller's application identity or the embedded CommandKit module identity. | +| `--format` | `text`, `yaml` | `text` | Select the output format. | + +Text output is `NAME VERSION`, followed by ` (REVISION)` when the selected +identity has a revision. An empty application version is displayed as `dev`; +the revision remains empty when omitted. + +CommandKit identity uses the version of its module in build information. A +versioned module replacement supplies the effective version. A local or +versionless replacement, an untagged module, or missing build information +displays `dev`. The CommandKit identity name is `CommandKit`; dependency build +metadata does not supply a revision. + +YAML output uses these explicit fields, including an empty revision: + +```yaml +name: ExampleApp +version: dev +revision: "" +``` + +## Registered command syntax + +Options are parsed after the complete command path and may be interspersed with +declared operands. Flags are local to the selected command. Use `--` to end +option parsing; all later tokens are operands. + +Value flags accept `--name value` and `--name=value`. Boolean flags accept +presence as true and explicit values such as `--enabled=false`. A singleton +flag accepts repeated occurrences; the last successfully parsed occurrence +supplies its value. Every supplied occurrence is converted, so an earlier +malformed value remains an error. Repeatable flags preserve occurrence order, +with each occurrence treated as one value, including strings containing commas. + +Typed inputs keep supplied state separate from defaults. An explicit empty, +false, or zero value is supplied. Help and version are available without +constructing application command inputs. + +## Outcome status + +CommandKit usage failures use status `2`; operational failures use status `1`. +Successful operations use status `0`. Application handlers may return a domain +classification and status of their choice, which `Execute` preserves. The caller +chooses how to map an outcome to process exit. diff --git a/docs/development.md b/docs/development.md index ae3de48..5a1763d 100644 --- a/docs/development.md +++ b/docs/development.md @@ -2,9 +2,9 @@ CommandKit currently contains adopted repository policies, accepted architectural decisions, implementation plans, identity and outcome primitives, typed command -declarations, an instance-owned registry, and a Cobra-backed routing and parsing -adapter, plus public `App.Execute` coordination for custom commands. Generated -help/version rendering and standard command families remain pending. The +declarations, an instance-owned registry, a Cobra-backed routing and parsing +adapter, public `App.Execute` coordination, and generated help/version commands. +Standard command families remain pending. The [README](../README.md) provides project orientation; the [documentation policy](policy/documentation.md#canonical-ownership) assigns canonical documentation owners. @@ -44,6 +44,7 @@ canonical documentation owners. | Task | Read | | --- | --- | +| Implemented CLI behavior | [CLI reference](cli.md) | | Standard command vocabulary, reports, or remaining operator-contract proposals | [CLI plan](roadmap/01-cli-plan.md) | | Dispatcher, registration, or typed input design | [Dispatcher roadmap](roadmap/02-dispatch.md) and [ADR 0001](adr/0001-command-dispatch-and-inputs.md) | | Backend integration | [Backend roadmap](roadmap/03-backend.md) and [ADR 0002](adr/0002-cobra-backend.md) | diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 62a4ec3..4f5379a 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -1,6 +1,6 @@ # Initial CommandKit implementation plan -Status: implementation underway; Stages 1-5 complete and Stages 6-14 pending. +Status: implementation underway; Stages 1-6 complete and Stages 7-14 pending. Date: 2026-10-07. @@ -401,7 +401,7 @@ will set `ReportIncomplete` when introduced with the standard reports. ## Stage 6: complete help and version -**Status:** pending. **Prerequisite:** Stage 5. +**Status:** complete. **Prerequisite:** Stage 5. 1. Complete automatically installed `help` and `version`. Root bare invocation, `help [COMMAND…]`, root/selected `--help`/`-h`, and non-runnable groups render @@ -439,6 +439,13 @@ help stream failure is operational; default version contains only app identity; controlled build info covers tagged/development/replacement component selection. Create/update the substantive help/version portion of `docs/cli.md` now. +**Completion evidence:** external tests cover root, group, runnable-parent, and +exact-path help; help without required inputs; version text/YAML and invalid +selectors; and checked report-write failures. Pure build-info tests cover main, +dependency, tagged replacement, local replacement, development, and missing +module cases. `go test ./...` passes; README and CLI reference describe the +implemented boundary. + ## Stage 7: implement configuration suites, scopes, and selector preflight **Status:** pending. **Prerequisite:** Stage 6. diff --git a/execute.go b/execute.go index 4fee0fa..325ce15 100644 --- a/execute.go +++ b/execute.go @@ -5,7 +5,6 @@ import ( "errors" "fmt" "io" - "strings" internalcobra "gitea.maximumdirect.net/eric/CommandKit/internal/cobra" configkit "gitea.maximumdirect.net/eric/ConfigKit" @@ -30,15 +29,13 @@ func (a *App) Execute(ctx context.Context, args []string, streams Streams) Outco } command, ok := a.commandAt(parsed.Path) if !ok || command.payload == nil { - // Help rendering is completed in Stage 6. The dispatch boundary already - // resolves metadata requests without constructing handler input. - return Success() + return a.renderHelpOutcome(parsed.Path, streams) } if parsed.HelpRequested || !command.payload.hasRun() { if _, err := command.payload.prepare(parsed.FlagValues, parsed.Operands, false); err != nil { return a.present(Usage(err), parsed.Path, streams) } - return Success() + return a.renderHelpOutcome(parsed.Path, streams) } buildInput, err := command.payload.prepare(parsed.FlagValues, parsed.Operands, true) if err != nil { @@ -70,7 +67,7 @@ func (a *App) commandAt(path []string) (Command, bool) { return Command{}, true } command, ok := a.commands[path[0]] - if !ok || command.reserved { + if !ok || (command.reserved && command.command.payload == nil) { return Command{}, false } current := command.command @@ -133,11 +130,12 @@ func (a *App) usageLine(path []string) string { if a != nil && a.identity.Name != "" { name = a.identity.Name } - parts := append([]string{name}, path...) - if len(path) == 0 { - return fmt.Sprintf("Usage: %s [command]", name) + if a != nil { + if usage, failure := internalcobra.UsageLine(a.bridgeTree(), path); failure == nil { + return "Usage: " + usage + } } - return fmt.Sprintf("Usage: %s [flags]", strings.Join(parts, " ")) + return fmt.Sprintf("Usage: %s [command]", name) } func hasErrorDiagnostic(diagnostics []Diagnostic) bool { diff --git a/execute_external_test.go b/execute_external_test.go index ff5eb0d..21caa71 100644 --- a/execute_external_test.go +++ b/execute_external_test.go @@ -5,6 +5,7 @@ import ( "context" "errors" "io" + "strconv" "strings" "testing" @@ -250,6 +251,156 @@ func TestExecuteHelpSkipsRequiredInputsAndRejectsNilContext(t *testing.T) { } } +func TestHelpRendersRegisteredMetadataAndResolvesExactPaths(t *testing.T) { + type input struct { + count commandkit.Supplied[int] + tags commandkit.Supplied[[]string] + } + factoryCalls, runCalls := 0, 0 + show, err := commandkit.NewCommand(commandkit.CommandSpec[input]{ + Name: "show", + Summary: "Show one item.", + Description: "Display the selected item in detail.", + NewInput: func() input { + factoryCalls++ + return input{} + }, + Flags: []commandkit.Flag[input]{ + commandkit.ValueFlag(commandkit.FlagMeta{Name: "count", Usage: "number of items", Required: true}, nil, func(value string) (int, error) { + return strconv.Atoi(value) + }, func(target *input, value commandkit.Supplied[int]) { target.count = value }), + commandkit.RepeatFlag(commandkit.FlagMeta{Name: "tag", Usage: "item tag"}, []string{"base"}, func(value string) (string, error) { return value, nil }, func(target *input, value commandkit.Supplied[[]string]) { target.tags = value }), + }, + Arguments: []commandkit.Argument[input]{commandkit.RepeatArgument(commandkit.ArgumentMeta{Name: "ID", Usage: "item identifier"}, 1, 3, func(value string) (string, error) { return value, nil }, func(*input, commandkit.Supplied[[]string]) {})}, + Run: func(commandkit.Invocation, input) commandkit.Outcome { runCalls++; return commandkit.Success() }, + }) + if err != nil { + t.Fatal(err) + } + config, err := commandkit.Group("config", "Inspect configuration.", show) + if err != nil { + t.Fatal(err) + } + now, err := commandkit.NewCommand(commandkit.CommandSpec[struct{}]{Name: "now", Summary: "Deploy immediately.", Run: func(commandkit.Invocation, struct{}) commandkit.Outcome { return commandkit.Success() }}) + if err != nil { + t.Fatal(err) + } + deployParent, err := commandkit.NewCommand(commandkit.CommandSpec[struct{}]{Name: "deploy", Summary: "Deploy artifacts.", Run: func(commandkit.Invocation, struct{}) commandkit.Outcome { runCalls++; return commandkit.Success() }}) + if err != nil { + t.Fatal(err) + } + deploy, err := commandkit.WithChildren(deployParent, now) + if err != nil { + t.Fatal(err) + } + app, err := commandkit.New(commandkit.Identity{Name: "example"}, config, deploy) + if err != nil { + t.Fatal(err) + } + + var out, diagnostic bytes.Buffer + root := app.Execute(context.Background(), []string{"--help"}, commandkit.Streams{Out: &out, Err: &diagnostic}) + if root.Kind != commandkit.OutcomeSuccess || !strings.Contains(out.String(), "Available Commands:") { + t.Fatalf("root help = %#v, output %q", root, out.String()) + } + for _, name := range []string{"config", "help", "version"} { + if !strings.Contains(out.String(), name) { + t.Fatalf("root help omitted %q: %q", name, out.String()) + } + } + for _, earlier := range []string{"config", "deploy", "help"} { + if strings.Index(out.String(), earlier) > strings.Index(out.String(), "version") { + t.Fatalf("root help commands are not sorted: %q", out.String()) + } + } + out.Reset() + bare := app.Execute(context.Background(), nil, commandkit.Streams{Out: &out, Err: &diagnostic}) + if bare.Kind != commandkit.OutcomeSuccess || !strings.Contains(out.String(), "Available Commands:") { + t.Fatalf("bare root help = %#v, output %q", bare, out.String()) + } + out.Reset() + helpCommand := app.Execute(context.Background(), []string{"help"}, commandkit.Streams{Out: &out, Err: &diagnostic}) + if helpCommand.Kind != commandkit.OutcomeSuccess || !strings.Contains(out.String(), "Available Commands:") { + t.Fatalf("root help command = %#v, output %q", helpCommand, out.String()) + } + out.Reset() + group := app.Execute(context.Background(), []string{"config"}, commandkit.Streams{Out: &out, Err: &diagnostic}) + if group.Kind != commandkit.OutcomeSuccess || !strings.Contains(out.String(), "show") { + t.Fatalf("group help = %#v, output %q", group, out.String()) + } + out.Reset() + parentHelp := app.Execute(context.Background(), []string{"deploy", "--help"}, commandkit.Streams{Out: &out, Err: &diagnostic}) + if parentHelp.Kind != commandkit.OutcomeSuccess || !strings.Contains(out.String(), "now") || strings.Contains(out.String(), "Usage: example deploy now") { + t.Fatalf("runnable parent help = %#v, output %q", parentHelp, out.String()) + } + out.Reset() + selected := app.Execute(context.Background(), []string{"help", "config", "show"}, commandkit.Streams{Out: &out, Err: &diagnostic}) + if selected.Kind != commandkit.OutcomeSuccess { + t.Fatalf("selected help = %#v, error %q", selected, diagnostic.String()) + } + for _, expected := range []string{"Display the selected item in detail.", "Usage:", "ID{1..3}", "Arguments:", "min 1, max 3", "--count", "required", "default: 0", "--tag", "repeatable", "default: [base]", "--help"} { + if !strings.Contains(out.String(), expected) { + t.Fatalf("selected help omitted %q: %q", expected, out.String()) + } + } + if factoryCalls != 0 || runCalls != 0 { + t.Fatalf("help invoked execution callbacks: factories=%d runs=%d", factoryCalls, runCalls) + } + badValue := app.Execute(context.Background(), []string{"config", "show", "--count=bad", "--help"}, commandkit.Streams{Out: &out, Err: &diagnostic}) + if badValue.Kind != commandkit.OutcomeUsage || factoryCalls != 0 { + t.Fatalf("help skipped conversion of supplied values: %#v", badValue) + } + out.Reset() + versionOutcome := app.Execute(context.Background(), []string{"version"}, commandkit.Streams{Out: &out, Err: &diagnostic}) + if versionOutcome.Kind != commandkit.OutcomeSuccess || factoryCalls != 0 || runCalls != 0 { + t.Fatalf("version invoked an application command: %#v calls=%d/%d", versionOutcome, factoryCalls, runCalls) + } + failed := app.Execute(context.Background(), []string{"config", "show", "--help"}, commandkit.Streams{Out: shortWriter{}, Err: &diagnostic}) + if failed.Kind != commandkit.OutcomeOperational || !failed.ReportIncomplete || !errors.Is(failed.DeliveryError, io.ErrShortWrite) { + t.Fatalf("failed help delivery = %#v", failed) + } + out.Reset() + unknown := app.Execute(context.Background(), []string{"help", "config", "missing"}, commandkit.Streams{Out: &out, Err: &diagnostic}) + if unknown.Kind != commandkit.OutcomeUsage || !strings.Contains(diagnostic.String(), "unknown help path") { + t.Fatalf("unknown help path = %#v, diagnostics %q", unknown, diagnostic.String()) + } + badFlag := app.Execute(context.Background(), []string{"config", "show", "--unknown", "--help"}, commandkit.Streams{Out: &out, Err: &diagnostic}) + if badFlag.Kind != commandkit.OutcomeUsage { + t.Fatalf("help accepted an unknown flag: %#v", badFlag) + } +} + +func TestVersionCommandFormatsApplicationAndCommandKitIdentity(t *testing.T) { + app, err := commandkit.New(commandkit.Identity{Name: "ExampleApp", Revision: "abc123"}) + if err != nil { + t.Fatal(err) + } + var out, diagnostic bytes.Buffer + textOutcome := app.Execute(context.Background(), []string{"version"}, commandkit.Streams{Out: &out, Err: &diagnostic}) + if textOutcome.Kind != commandkit.OutcomeSuccess || out.String() != "ExampleApp dev (abc123)\n" { + t.Fatalf("default version = %#v, output %q", textOutcome, out.String()) + } + out.Reset() + yamlOutcome := app.Execute(context.Background(), []string{"version", "--format=yaml"}, commandkit.Streams{Out: &out, Err: &diagnostic}) + if yamlOutcome.Kind != commandkit.OutcomeSuccess || out.String() != "name: ExampleApp\nversion: dev\nrevision: abc123\n" { + t.Fatalf("YAML version = %#v, output %q", yamlOutcome, out.String()) + } + out.Reset() + component := app.Execute(context.Background(), []string{"version", "--scope=commandkit"}, commandkit.Streams{Out: &out, Err: &diagnostic}) + if component.Kind != commandkit.OutcomeSuccess || !strings.HasPrefix(out.String(), "CommandKit ") || !strings.HasSuffix(out.String(), "\n") { + t.Fatalf("CommandKit version = %#v, output %q", component, out.String()) + } + out.Reset() + invalid := app.Execute(context.Background(), []string{"version", "--scope=unknown"}, commandkit.Streams{Out: &out, Err: &diagnostic}) + if invalid.Kind != commandkit.OutcomeUsage || out.Len() != 0 { + t.Fatalf("invalid version scope = %#v, output %q", invalid, out.String()) + } + failed := app.Execute(context.Background(), []string{"version"}, commandkit.Streams{Out: shortWriter{}, Err: &diagnostic}) + if failed.Kind != commandkit.OutcomeOperational || !failed.ReportIncomplete || !errors.Is(failed.DeliveryError, io.ErrShortWrite) { + t.Fatalf("failed version delivery = %#v", failed) + } +} + type failingWriter struct{ err error } func (w failingWriter) Write([]byte) (int, error) { return 0, w.err } diff --git a/internal/cobra/parse.go b/internal/cobra/parse.go index fcbb7e5..9439b8f 100644 --- a/internal/cobra/parse.go +++ b/internal/cobra/parse.go @@ -3,6 +3,7 @@ package cobra import ( + "bytes" "context" "fmt" "io" @@ -20,9 +21,18 @@ type Command struct { Description string Runnable bool Flags []Flag + Arguments []Argument Children []*Command } +// Argument describes one positional input for usage rendering. +type Argument struct { + Name string + Usage string + Min int + Max int +} + // Flag describes one local option without exposing a pflag value. type Flag struct { Name string @@ -48,6 +58,50 @@ type Result struct { Failure *Failure } +// RenderHelp renders registered metadata for the exact command path. It uses +// Cobra's usage metadata and returns backend failures as neutral message data. +func RenderHelp(root *Command, path []string) (string, *Failure) { + if root == nil { + return "", &Failure{Message: "command tree is nil"} + } + buffer := new(bytes.Buffer) + options := normalizeOptions(ParseOptions{Context: context.Background(), In: strings.NewReader(""), Out: buffer, Err: buffer}) + backendRoot, nodes := buildTree(root, options) + selected, remaining, err := backendRoot.Find(path) + if err != nil { + return "", &Failure{Message: err.Error()} + } + declaration := declarationFor(nodes, selected) + if declaration == nil || len(remaining) != 0 || !equalPath(path, pathFor(root, declaration)) { + return "", &Failure{Message: "help path is not registered"} + } + selected.SetOut(buffer) + selected.SetErr(buffer) + if err := selected.Usage(); err != nil { + return "", &Failure{Message: err.Error()} + } + return buffer.String(), nil +} + +// UsageLine returns Cobra's usage line for an exact registered command path. +func UsageLine(root *Command, path []string) (string, *Failure) { + if root == nil { + return "", &Failure{Message: "command tree is nil"} + } + options := normalizeOptions(ParseOptions{Context: context.Background(), In: strings.NewReader(""), Out: io.Discard, Err: io.Discard}) + backendRoot, nodes := buildTree(root, options) + selected, remaining, err := backendRoot.Find(path) + declaration := declarationFor(nodes, selected) + if err != nil || declaration == nil || len(remaining) != 0 || !equalPath(path, pathFor(root, declaration)) { + return "", &Failure{Message: "usage path is not registered"} + } + line := selected.UseLine() + if selected.HasAvailableSubCommands() { + line += " [command]" + } + return line, nil +} + // ParseOptions makes the parser's context and streams explicit. Nil writers // are replaced with io.Discard. type ParseOptions struct { @@ -79,6 +133,10 @@ func Parse(root *Command, argv []string, options ParseOptions) Result { return failure(result, "selected command is not part of the declared tree") } result.Path = pathFor(root, selectedNode) + consumed := len(leading) - len(unconsumed) + if consumed != len(result.Path) || !equalPath(leading[:consumed], result.Path) { + return failure(result, "command path must match a registered command exactly") + } remaining := append(append([]string(nil), unconsumed...), tail...) if err := selected.ParseFlags(remaining); err != nil { @@ -127,32 +185,37 @@ func buildTree(root *Command, options ParseOptions) (*spfcobra.Command, map[*Com var build func(*Command) *spfcobra.Command build = func(declaration *Command) *spfcobra.Command { backend := &spfcobra.Command{ - Use: declaration.Name, + Use: commandUse(declaration), Short: declaration.Summary, - Long: declaration.Description, + Long: helpDescription(declaration), Args: func(*spfcobra.Command, []string) error { return nil }, SilenceErrors: true, SilenceUsage: true, DisableSuggestions: true, } + if declaration.Runnable { + backend.Run = func(*spfcobra.Command, []string) {} + } backend.SetContext(options.Context) backend.SetIn(options.In) backend.SetOut(options.Out) backend.SetErr(options.Err) backend.SetHelpFunc(func(*spfcobra.Command, []string) {}) - backend.SetUsageFunc(func(*spfcobra.Command) error { return nil }) backend.SetFlagErrorFunc(func(_ *spfcobra.Command, err error) error { return err }) + backend.SetUsageTemplate(usageTemplate) backend.Flags().SetInterspersed(true) backend.Flags().SortFlags = false backend.DisableAutoGenTag = true helpValue := &rawValue{boolean: true} backend.Flags().VarP(helpValue, "help", "h", "help for this command") - backend.Flags().Lookup("help").NoOptDefVal = "true" + helpFlag := backend.Flags().Lookup("help") + helpFlag.NoOptDefVal = "true" + helpFlag.Usage = "help for this command (default: false)" for _, declarationFlag := range declaration.Flags { value := &rawValue{boolean: declarationFlag.Boolean} - backend.Flags().VarP(value, declarationFlag.Name, declarationFlag.Short, declarationFlag.Usage) + backend.Flags().VarP(value, declarationFlag.Name, declarationFlag.Short, flagUsage(declarationFlag)) backendFlag := backend.Flags().Lookup(declarationFlag.Name) - backendFlag.DefValue = declarationFlag.Default + backendFlag.DefValue = "" if declarationFlag.Boolean { backendFlag.NoOptDefVal = "true" } @@ -169,6 +232,94 @@ func buildTree(root *Command, options ParseOptions) (*spfcobra.Command, map[*Com return build(root), nodes } +const usageTemplate = `{{with (or .Long .Short)}}{{. | trimTrailingWhitespaces}} + +{{end}}Usage: + {{.UseLine}}{{if .HasAvailableSubCommands}} + {{.CommandPath}} [command]{{end}}{{if .HasAvailableSubCommands}} + +Available Commands:{{range .Commands}}{{if .IsAvailableCommand}} + {{rpad .Name .NamePadding}} {{.Short}}{{end}}{{end}}{{end}}{{if .HasAvailableLocalFlags}} + +Flags: +{{.LocalFlags.FlagUsages | trimTrailingWhitespaces}}{{end}} +` + +func commandUse(command *Command) string { + var parts []string + for _, argument := range command.Arguments { + name := argument.Name + if argument.Min == 1 && argument.Max == 1 { + parts = append(parts, name) + } else if argument.Max == -1 { + if argument.Min > 0 { + parts = append(parts, name+"...") + } else { + parts = append(parts, "["+name+"...]") + } + } else { + parts = append(parts, fmt.Sprintf("%s{%d..%d}", name, argument.Min, argument.Max)) + } + } + if len(parts) == 0 { + return command.Name + } + return command.Name + " " + strings.Join(parts, " ") +} + +func helpDescription(command *Command) string { + var sections []string + if description := strings.TrimSpace(command.Description); description != "" { + sections = append(sections, description) + } + if len(command.Arguments) > 0 { + lines := []string{"Arguments:"} + for _, argument := range command.Arguments { + maximum := fmt.Sprint(argument.Max) + if argument.Max == -1 { + maximum = "unbounded" + } + line := fmt.Sprintf(" %s (min %d, max %s)", argument.Name, argument.Min, maximum) + if argument.Usage != "" { + line += " — " + argument.Usage + } + lines = append(lines, line) + } + sections = append(sections, strings.Join(lines, "\n")) + } + return strings.Join(sections, "\n\n") +} + +func flagUsage(flag Flag) string { + var details []string + if flag.Required { + details = append(details, "required") + } + if flag.Repeated { + details = append(details, "repeatable") + } + details = append(details, "default: "+flag.Default) + usage := flag.Usage + if usage != "" { + usage += " (" + strings.Join(details, "; ") + ")" + } else { + usage = strings.Join(details, "; ") + } + return usage +} + +func equalPath(left, right []string) bool { + if len(left) != len(right) { + return false + } + for index := range left { + if left[index] != right[index] { + return false + } + } + return true +} + func declarationFor(nodes map[*Command]*spfcobra.Command, selected *spfcobra.Command) *Command { for declaration, backend := range nodes { if backend == selected { diff --git a/internal/cobra/parse_test.go b/internal/cobra/parse_test.go index d4432b6..0ff9b6d 100644 --- a/internal/cobra/parse_test.go +++ b/internal/cobra/parse_test.go @@ -6,6 +6,8 @@ import ( "reflect" "strings" "testing" + + spfcobra "github.com/spf13/cobra" ) func TestParseRoutesBeforeOptionsAndKeepsFlagsLocal(t *testing.T) { @@ -29,6 +31,18 @@ func TestParseRoutesBeforeOptionsAndKeepsFlagsLocal(t *testing.T) { } } +func TestParseRequiresExactCommandPathWhenPrefixMatchingIsEnabled(t *testing.T) { + previous := spfcobra.EnablePrefixMatching + spfcobra.EnablePrefixMatching = true + t.Cleanup(func() { spfcobra.EnablePrefixMatching = previous }) + + tree := &Command{Name: "app", Children: []*Command{{Name: "config", Runnable: true}}} + result := Parse(tree, []string{"con"}, ParseOptions{}) + if result.Failure == nil { + t.Fatal("accepted a prefixed command path") + } +} + func TestParseInterspersedOperandsBooleanFlagsAndTerminator(t *testing.T) { tree := &Command{Name: "app", Children: []*Command{ {Name: "restore", Runnable: true, Flags: []Flag{ diff --git a/parser.go b/parser.go index 7d0d7da..8a2d24a 100644 --- a/parser.go +++ b/parser.go @@ -10,10 +10,17 @@ func (a *App) parse(args []string, options internalcobra.ParseOptions) internalc if a == nil { return internalcobra.Result{Failure: &internalcobra.Failure{Message: "cannot parse commands on a nil app"}} } + return internalcobra.Parse(a.bridgeTree(), args, options) +} + +func (a *App) bridgeTree() *internalcobra.Command { + if a == nil { + return nil + } root := &internalcobra.Command{Name: a.identity.Name} names := make([]string, 0, len(a.commands)) for name, node := range a.commands { - if !node.reserved { + if !node.reserved || node.command.payload != nil { names = append(names, name) } } @@ -21,7 +28,7 @@ func (a *App) parse(args []string, options internalcobra.ParseOptions) internalc for _, name := range names { root.Children = append(root.Children, bridgeCommand(a.commands[name].command)) } - return internalcobra.Parse(root, args, options) + return root } func bridgeCommand(command Command) *internalcobra.Command { @@ -33,8 +40,11 @@ func bridgeCommand(command Command) *internalcobra.Command { if command.payload != nil { declaration.Runnable = command.payload.hasRun() declaration.Flags = command.payload.bridgeFlags() + declaration.Arguments = command.payload.bridgeArguments() } - for _, child := range command.children { + children := append([]Command(nil), command.children...) + sort.Slice(children, func(i, j int) bool { return children[i].name < children[j].name }) + for _, child := range children { declaration.Children = append(declaration.Children, bridgeCommand(child)) } return declaration