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,
|
CommandKit is a Go library project for consistent CLI inspection, command syntax,
|
||||||
and shared command infrastructure across related applications.
|
and shared command infrastructure across related applications.
|
||||||
|
|
||||||
The repository currently contains planning documents, accepted architectural
|
The current Go library provides an instance-owned command registry, typed flag
|
||||||
decisions, and adopted development policies. No Go module, public API, or command
|
and operand bindings, Cobra-backed routing, invocation outcomes, generated help,
|
||||||
implementation is available yet.
|
and application or CommandKit version reporting. Standard configuration and
|
||||||
|
PromptKit command families remain planned.
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
- [Contributor guide](docs/development.md)
|
- [Contributor guide](docs/development.md)
|
||||||
|
- [Command line reference](docs/cli.md)
|
||||||
- [Architecture policy](docs/policy/architecture.md)
|
- [Architecture policy](docs/policy/architecture.md)
|
||||||
- [CLI standard proposal](docs/roadmap/01-cli-plan.md)
|
- [CLI standard proposal](docs/roadmap/01-cli-plan.md)
|
||||||
- [Dispatcher implementation roadmap](docs/roadmap/02-dispatch.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 {
|
if err := app.Register(commands...); err != nil {
|
||||||
return nil, err
|
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
|
validate() error
|
||||||
hasRun() bool
|
hasRun() bool
|
||||||
bridgeFlags() []internalcobra.Flag
|
bridgeFlags() []internalcobra.Flag
|
||||||
|
bridgeArguments() []internalcobra.Argument
|
||||||
prepare([][]string, []string, bool) (func() any, error)
|
prepare([][]string, []string, bool) (func() any, error)
|
||||||
checkInput(any) error
|
checkInput(any) error
|
||||||
run(Invocation, any) Outcome
|
run(Invocation, any) Outcome
|
||||||
@@ -132,6 +133,20 @@ func (c typedCommand[I]) bridgeFlags() []internalcobra.Flag {
|
|||||||
return flags
|
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) {
|
func (c typedCommand[I]) prepare(flagValues [][]string, operands []string, enforceRequired bool) (func() any, error) {
|
||||||
prepared, err := prepareInput(c.spec, flagValues, operands, enforceRequired)
|
prepared, err := prepareInput(c.spec, flagValues, operands, enforceRequired)
|
||||||
if err != nil {
|
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
|
CommandKit currently contains adopted repository policies, accepted architectural
|
||||||
decisions, implementation plans, identity and outcome primitives, typed command
|
decisions, implementation plans, identity and outcome primitives, typed command
|
||||||
declarations, an instance-owned registry, and a Cobra-backed routing and parsing
|
declarations, an instance-owned registry, a Cobra-backed routing and parsing
|
||||||
adapter, plus public `App.Execute` coordination for custom commands. Generated
|
adapter, public `App.Execute` coordination, and generated help/version commands.
|
||||||
help/version rendering and standard command families remain pending. The
|
Standard command families remain pending. The
|
||||||
[README](../README.md) provides project orientation; the
|
[README](../README.md) provides project orientation; the
|
||||||
[documentation policy](policy/documentation.md#canonical-ownership) assigns
|
[documentation policy](policy/documentation.md#canonical-ownership) assigns
|
||||||
canonical documentation owners.
|
canonical documentation owners.
|
||||||
@@ -44,6 +44,7 @@ canonical documentation owners.
|
|||||||
|
|
||||||
| Task | Read |
|
| 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) |
|
| 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) |
|
| 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) |
|
| Backend integration | [Backend roadmap](roadmap/03-backend.md) and [ADR 0002](adr/0002-cobra-backend.md) |
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Initial CommandKit implementation plan
|
# 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.
|
Date: 2026-10-07.
|
||||||
|
|
||||||
@@ -401,7 +401,7 @@ will set `ReportIncomplete` when introduced with the standard reports.
|
|||||||
|
|
||||||
## Stage 6: complete help and version
|
## 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,
|
1. Complete automatically installed `help` and `version`. Root bare invocation,
|
||||||
`help [COMMAND…]`, root/selected `--help`/`-h`, and non-runnable groups render
|
`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.
|
controlled build info covers tagged/development/replacement component selection.
|
||||||
Create/update the substantive help/version portion of `docs/cli.md` now.
|
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
|
## Stage 7: implement configuration suites, scopes, and selector preflight
|
||||||
|
|
||||||
**Status:** pending. **Prerequisite:** Stage 6.
|
**Status:** pending. **Prerequisite:** Stage 6.
|
||||||
|
|||||||
18
execute.go
18
execute.go
@@ -5,7 +5,6 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"strings"
|
|
||||||
|
|
||||||
internalcobra "gitea.maximumdirect.net/eric/CommandKit/internal/cobra"
|
internalcobra "gitea.maximumdirect.net/eric/CommandKit/internal/cobra"
|
||||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
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)
|
command, ok := a.commandAt(parsed.Path)
|
||||||
if !ok || command.payload == nil {
|
if !ok || command.payload == nil {
|
||||||
// Help rendering is completed in Stage 6. The dispatch boundary already
|
return a.renderHelpOutcome(parsed.Path, streams)
|
||||||
// resolves metadata requests without constructing handler input.
|
|
||||||
return Success()
|
|
||||||
}
|
}
|
||||||
if parsed.HelpRequested || !command.payload.hasRun() {
|
if parsed.HelpRequested || !command.payload.hasRun() {
|
||||||
if _, err := command.payload.prepare(parsed.FlagValues, parsed.Operands, false); err != nil {
|
if _, err := command.payload.prepare(parsed.FlagValues, parsed.Operands, false); err != nil {
|
||||||
return a.present(Usage(err), parsed.Path, streams)
|
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)
|
buildInput, err := command.payload.prepare(parsed.FlagValues, parsed.Operands, true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -70,7 +67,7 @@ func (a *App) commandAt(path []string) (Command, bool) {
|
|||||||
return Command{}, true
|
return Command{}, true
|
||||||
}
|
}
|
||||||
command, ok := a.commands[path[0]]
|
command, ok := a.commands[path[0]]
|
||||||
if !ok || command.reserved {
|
if !ok || (command.reserved && command.command.payload == nil) {
|
||||||
return Command{}, false
|
return Command{}, false
|
||||||
}
|
}
|
||||||
current := command.command
|
current := command.command
|
||||||
@@ -133,11 +130,12 @@ func (a *App) usageLine(path []string) string {
|
|||||||
if a != nil && a.identity.Name != "" {
|
if a != nil && a.identity.Name != "" {
|
||||||
name = a.identity.Name
|
name = a.identity.Name
|
||||||
}
|
}
|
||||||
parts := append([]string{name}, path...)
|
if a != nil {
|
||||||
if len(path) == 0 {
|
if usage, failure := internalcobra.UsageLine(a.bridgeTree(), path); failure == nil {
|
||||||
return fmt.Sprintf("Usage: %s [command]", name)
|
return "Usage: " + usage
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return fmt.Sprintf("Usage: %s [flags]", strings.Join(parts, " "))
|
return fmt.Sprintf("Usage: %s [command]", name)
|
||||||
}
|
}
|
||||||
|
|
||||||
func hasErrorDiagnostic(diagnostics []Diagnostic) bool {
|
func hasErrorDiagnostic(diagnostics []Diagnostic) bool {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"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 }
|
type failingWriter struct{ err error }
|
||||||
|
|
||||||
func (w failingWriter) Write([]byte) (int, error) { return 0, w.err }
|
func (w failingWriter) Write([]byte) (int, error) { return 0, w.err }
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
package cobra
|
package cobra
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
@@ -20,9 +21,18 @@ type Command struct {
|
|||||||
Description string
|
Description string
|
||||||
Runnable bool
|
Runnable bool
|
||||||
Flags []Flag
|
Flags []Flag
|
||||||
|
Arguments []Argument
|
||||||
Children []*Command
|
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.
|
// Flag describes one local option without exposing a pflag value.
|
||||||
type Flag struct {
|
type Flag struct {
|
||||||
Name string
|
Name string
|
||||||
@@ -48,6 +58,50 @@ type Result struct {
|
|||||||
Failure *Failure
|
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
|
// ParseOptions makes the parser's context and streams explicit. Nil writers
|
||||||
// are replaced with io.Discard.
|
// are replaced with io.Discard.
|
||||||
type ParseOptions struct {
|
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")
|
return failure(result, "selected command is not part of the declared tree")
|
||||||
}
|
}
|
||||||
result.Path = pathFor(root, selectedNode)
|
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...)
|
remaining := append(append([]string(nil), unconsumed...), tail...)
|
||||||
if err := selected.ParseFlags(remaining); err != nil {
|
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
|
var build func(*Command) *spfcobra.Command
|
||||||
build = func(declaration *Command) *spfcobra.Command {
|
build = func(declaration *Command) *spfcobra.Command {
|
||||||
backend := &spfcobra.Command{
|
backend := &spfcobra.Command{
|
||||||
Use: declaration.Name,
|
Use: commandUse(declaration),
|
||||||
Short: declaration.Summary,
|
Short: declaration.Summary,
|
||||||
Long: declaration.Description,
|
Long: helpDescription(declaration),
|
||||||
Args: func(*spfcobra.Command, []string) error { return nil },
|
Args: func(*spfcobra.Command, []string) error { return nil },
|
||||||
SilenceErrors: true,
|
SilenceErrors: true,
|
||||||
SilenceUsage: true,
|
SilenceUsage: true,
|
||||||
DisableSuggestions: true,
|
DisableSuggestions: true,
|
||||||
}
|
}
|
||||||
|
if declaration.Runnable {
|
||||||
|
backend.Run = func(*spfcobra.Command, []string) {}
|
||||||
|
}
|
||||||
backend.SetContext(options.Context)
|
backend.SetContext(options.Context)
|
||||||
backend.SetIn(options.In)
|
backend.SetIn(options.In)
|
||||||
backend.SetOut(options.Out)
|
backend.SetOut(options.Out)
|
||||||
backend.SetErr(options.Err)
|
backend.SetErr(options.Err)
|
||||||
backend.SetHelpFunc(func(*spfcobra.Command, []string) {})
|
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.SetFlagErrorFunc(func(_ *spfcobra.Command, err error) error { return err })
|
||||||
|
backend.SetUsageTemplate(usageTemplate)
|
||||||
backend.Flags().SetInterspersed(true)
|
backend.Flags().SetInterspersed(true)
|
||||||
backend.Flags().SortFlags = false
|
backend.Flags().SortFlags = false
|
||||||
backend.DisableAutoGenTag = true
|
backend.DisableAutoGenTag = true
|
||||||
helpValue := &rawValue{boolean: true}
|
helpValue := &rawValue{boolean: true}
|
||||||
backend.Flags().VarP(helpValue, "help", "h", "help for this command")
|
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 {
|
for _, declarationFlag := range declaration.Flags {
|
||||||
value := &rawValue{boolean: declarationFlag.Boolean}
|
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 := backend.Flags().Lookup(declarationFlag.Name)
|
||||||
backendFlag.DefValue = declarationFlag.Default
|
backendFlag.DefValue = ""
|
||||||
if declarationFlag.Boolean {
|
if declarationFlag.Boolean {
|
||||||
backendFlag.NoOptDefVal = "true"
|
backendFlag.NoOptDefVal = "true"
|
||||||
}
|
}
|
||||||
@@ -169,6 +232,94 @@ func buildTree(root *Command, options ParseOptions) (*spfcobra.Command, map[*Com
|
|||||||
return build(root), nodes
|
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 {
|
func declarationFor(nodes map[*Command]*spfcobra.Command, selected *spfcobra.Command) *Command {
|
||||||
for declaration, backend := range nodes {
|
for declaration, backend := range nodes {
|
||||||
if backend == selected {
|
if backend == selected {
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import (
|
|||||||
"reflect"
|
"reflect"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
spfcobra "github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestParseRoutesBeforeOptionsAndKeepsFlagsLocal(t *testing.T) {
|
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) {
|
func TestParseInterspersedOperandsBooleanFlagsAndTerminator(t *testing.T) {
|
||||||
tree := &Command{Name: "app", Children: []*Command{
|
tree := &Command{Name: "app", Children: []*Command{
|
||||||
{Name: "restore", Runnable: true, Flags: []Flag{
|
{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 {
|
if a == nil {
|
||||||
return internalcobra.Result{Failure: &internalcobra.Failure{Message: "cannot parse commands on a nil app"}}
|
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}
|
root := &internalcobra.Command{Name: a.identity.Name}
|
||||||
names := make([]string, 0, len(a.commands))
|
names := make([]string, 0, len(a.commands))
|
||||||
for name, node := range a.commands {
|
for name, node := range a.commands {
|
||||||
if !node.reserved {
|
if !node.reserved || node.command.payload != nil {
|
||||||
names = append(names, name)
|
names = append(names, name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -21,7 +28,7 @@ func (a *App) parse(args []string, options internalcobra.ParseOptions) internalc
|
|||||||
for _, name := range names {
|
for _, name := range names {
|
||||||
root.Children = append(root.Children, bridgeCommand(a.commands[name].command))
|
root.Children = append(root.Children, bridgeCommand(a.commands[name].command))
|
||||||
}
|
}
|
||||||
return internalcobra.Parse(root, args, options)
|
return root
|
||||||
}
|
}
|
||||||
|
|
||||||
func bridgeCommand(command Command) *internalcobra.Command {
|
func bridgeCommand(command Command) *internalcobra.Command {
|
||||||
@@ -33,8 +40,11 @@ func bridgeCommand(command Command) *internalcobra.Command {
|
|||||||
if command.payload != nil {
|
if command.payload != nil {
|
||||||
declaration.Runnable = command.payload.hasRun()
|
declaration.Runnable = command.payload.hasRun()
|
||||||
declaration.Flags = command.payload.bridgeFlags()
|
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))
|
declaration.Children = append(declaration.Children, bridgeCommand(child))
|
||||||
}
|
}
|
||||||
return declaration
|
return declaration
|
||||||
|
|||||||
Reference in New Issue
Block a user