Add generated help and version commands
This commit is contained in:
@@ -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
3
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
|
||||
}
|
||||
|
||||
208
builtins.go
Normal file
208
builtins.go
Normal 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
58
builtins_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
15
command.go
15
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 {
|
||||
|
||||
91
docs/cli.md
Normal file
91
docs/cli.md
Normal 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.
|
||||
@@ -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) |
|
||||
|
||||
@@ -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.
|
||||
|
||||
18
execute.go
18
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 {
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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{
|
||||
|
||||
16
parser.go
16
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
|
||||
|
||||
Reference in New Issue
Block a user