Add generated help and version commands

This commit is contained in:
2026-10-07 13:37:57 +00:00
parent c3448a2ebe
commit ecd2b19783
13 changed files with 736 additions and 27 deletions

View File

@@ -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)

3
app.go
View File

@@ -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
}

208
builtins.go Normal file
View File

@@ -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
}

58
builtins_test.go Normal file
View File

@@ -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)
}
})
}
}

View File

@@ -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 {

91
docs/cli.md Normal file
View File

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

View File

@@ -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) |

View File

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

View File

@@ -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 {

View File

@@ -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 }

View File

@@ -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 {

View File

@@ -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{

View File

@@ -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