diff --git a/builtins.go b/builtins.go index d9ae607..6aed4d0 100644 --- a/builtins.go +++ b/builtins.go @@ -45,8 +45,8 @@ func (a *App) builtinCommands() []Declaration { 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 }), + ValueFlag(FlagMeta{Name: "scope", Usage: "version subject", Completion: CompleteChoices(CompletionChoice{Value: "app"}, CompletionChoice{Value: "commandkit"})}, &scopeDefault, parseVersionScope, func(input *versionInput, value Supplied[string]) { input.Scope = value.Value }), + ValueFlag(FlagMeta{Name: "format", Usage: "output format", Completion: CompleteChoices(CompletionChoice{Value: "text"}, CompletionChoice{Value: "yaml"})}, &formatDefault, parseVersionFormat, func(input *versionInput, value Supplied[string]) { input.Format = value.Value }), }, Run: func(invocation Invocation, input versionInput) Outcome { identity := a.identity diff --git a/completion.go b/completion.go index 24fd775..c7580d1 100644 --- a/completion.go +++ b/completion.go @@ -51,7 +51,9 @@ func validCompletionExecutable(name string) bool { // CompletionChoice is a literal suggested value and its optional description. type CompletionChoice struct { - Value string + // Value is inserted as data; it is not interpreted as shell source. + Value string + // Description is optional context displayed by shells that support it. Description string } @@ -71,7 +73,10 @@ const ( completionDirectories ) -// CompleteChoices suggests literal values. The supplied slice is copied. +// CompleteChoices suggests literal values matching the current value prefix. +// The supplied slice is copied. An empty list produces no suggestions. Empty +// values and values containing a newline, tab, or NUL cannot be presented as +// suggestions, but remain valid input if the command's converter accepts them. func CompleteChoices(choices ...CompletionChoice) CompletionHint { return CompletionHint{kind: completionChoices, choices: append([]CompletionChoice(nil), choices...)} } diff --git a/docs/cli.md b/docs/cli.md index b9ee2c0..5212d9d 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -66,6 +66,31 @@ version: dev revision: "" ``` +## Shell completion + +Applications may install shell completion explicitly. When installed, generate +and load the script for the executable name chosen by the application: + +```sh +# Bash +source <(app completion bash) +# Zsh, after initializing its completion system with compinit +source <(app completion zsh) +# Fish +app completion fish | source +``` + +The application selects the executable basename at installation; use that same +name to invoke the generated script. Completion offers canonical command and +flag names, static values, and file or directory paths where declarations +provide hints. A fully entered alias is resolved to its canonical command; +alias spellings and partial alias paths are not suggested. Completion queries +do not invoke input factories, providers, or the selected command; application +startup remains application-owned. See the +[consumer guide](consumers/guide.md#shell-completion) for installation and +hint declarations. Script generation follows the [reporting policy](#reporting), +including quiet mode. + ## Reporting CommandKit applies one shared reporting policy to application handlers, diff --git a/docs/consumers/guide.md b/docs/consumers/guide.md index 35119c6..966a35b 100644 --- a/docs/consumers/guide.md +++ b/docs/consumers/guide.md @@ -87,6 +87,34 @@ values convert before input construction. A command's `NewInput` creates input only when execution reaches it; help does not construct caller input or run provider callbacks. +## Shell completion + +Install `commandkit.Completion("app")` alongside the application's other +declarations, using the basename operators actually type. The basename accepts +letters, digits, underscores, dots, hyphens, and colons, except for a leading +hyphen. Add a hint to a flag or operand's metadata when a value has useful +static choices or is a filesystem path: + +```go +commandkit.FlagMeta{ + Name: "format", + Completion: commandkit.CompleteChoices( + commandkit.CompletionChoice{Value: "text", Description: "Readable text"}, + commandkit.CompletionChoice{Value: "yaml", Description: "YAML output"}, + ), +} +commandkit.FlagMeta{Name: "config", Completion: commandkit.CompleteFiles()} +commandkit.ArgumentMeta{Name: "directory", Completion: commandkit.CompleteDirectories()} +``` + +Hints are suggestions: converters and input validation still decide which +values are accepted. An omitted hint or `NoCompletion()` gives no value +suggestions. `CompleteChoices()` with no choices also gives no suggestions. +Declarations retain copies of supplied choices. The [CLI reference](../cli.md#shell-completion) +describes supported shells and loading scripts; the [core example](../../examples/core) +and [composed example](../../integrations/ckprompt/examples/composed) show +installation alongside aliases and integration commands. + ## Invocation reporting `Invocation.Reporter()` supplies the reporter for the current handler. Use diff --git a/docs/roadmap/completion.md b/docs/roadmap/completion.md index e3d1890..4b076bd 100644 --- a/docs/roadmap/completion.md +++ b/docs/roadmap/completion.md @@ -1,8 +1,8 @@ # Shell completion -Status: initial scope and all four product decisions selected; implementation -has not started. Provide canonical suggestions with alias-aware interpretation -for Bash, Zsh, and Fish through explicitly installed completion support. +Status: the selected initial scope is implemented; final sprint validation +remains. Canonical suggestions and alias-aware interpretation are available for +Bash, Zsh, and Fish through explicitly installed completion support. ## Purpose @@ -14,7 +14,7 @@ Completion helps an operator construct an invocation. It does not execute the selected application command. Preserve application ownership of runtime behavior and upstream ownership of configuration and prompt semantics. -## Current baseline +## Planning baseline Reviewed baseline: `2301872` (`Stop flag interpretation at unknown flags`). diff --git a/docs/roadmap/deferred.md b/docs/roadmap/deferred.md index 32da8e2..6d44482 100644 --- a/docs/roadmap/deferred.md +++ b/docs/roadmap/deferred.md @@ -33,7 +33,7 @@ includes suggesting command and flag alias spellings and valid continuations of alias-only prefixes. For example, completion could suggest `inspect` after `app legacy ` when `legacy inspect` is an installed alias. -Alias discovery may be omitted from the MVP. This deferral does not establish a +Alias discovery is outside the initial scope. This deferral does not establish a permanent canonical-only completion contract or prohibit future alias discovery. A later design should determine how alias suggestions are presented alongside canonical suggestions while ensuring inserted continuations form valid next @@ -46,7 +46,8 @@ operator or consumer need and an appropriate ownership/behavior decision: - JSON reports and output-file management or reloadable export. - Shell-completion extensions beyond the initial scope in the - [completion roadmap](completion.md), including alias discovery described above. + [completion roadmap](completion.md), including alias discovery described above + and dynamic discovery of configured or remote values. - Configuration mutation, generic value assignment, and exhaustive field-origin inventories. - Credential diagnostics beyond PromptKit's native smoke checks and additional diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 5b545a9..f11e295 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -1,6 +1,6 @@ # Shell completion implementation plan -Status: Stages 1–6 complete. Stage 7 is next. +Status: Stages 1–7 complete. Stage 8 is next. Implement the [shell completion roadmap](completion.md) from baseline `2301872`. Follow the stages in order in one sprint. Intermediate stages may @@ -274,6 +274,8 @@ interactive Bash, Zsh, and Fish transport matrix pass. ## Stage 7: Annotate maintained commands and examples +Status: complete. + 1. Annotate static format/scope flags where their choices already come from fixed command metadata or literals. Use the same values as the existing version, config, and PromptKit command implementations; omit scope hints @@ -291,6 +293,16 @@ interactive Bash, Zsh, and Fish transport matrix pass. update the feature status and keep alias discovery and dynamic discovery in deferred work. +Completion: annotated built-in version, configuration, and PromptKit formats, +supported configuration scopes, and filename selections. The core and composed +examples install completion and demonstrate choices, paths, and entered +aliases. Integration tests verify scope availability and completion without +input, state, acquisition, comparison, engine, or client callbacks. GoDoc, CLI, +consumer, example, and roadmap documentation now describe the delivered scope; +alias and dynamic discovery remain deferred. Root and both integration package +tests pass in the local workspace, and changed Markdown links and whitespace +checks pass. + ## Stage 8: Validate the assembled sprint 1. Exercise the generated scripts in Bash, Zsh, and Fish using a built fixture diff --git a/examples/core/README.md b/examples/core/README.md index 0846831..313cfd8 100644 --- a/examples/core/README.md +++ b/examples/core/README.md @@ -7,6 +7,8 @@ go run ./examples/core hello --times=0 --tag=first go run ./examples/core ws greet --count=2 go run ./examples/core ws --help go run ./examples/core hello --hlep +go build -o core-example ./examples/core +./core-example completion bash ``` The example installs a canonical `workspace greet` command together with a @@ -16,6 +18,11 @@ alias. Its declaration slice mixes the canonical group and routing rules as `Invocation.CanonicalPath()` so the alternate route and its canonical target are visible in the output. +The example also installs `Completion("core-example")` and gives the `report` +scenario argument static choices. Build it with the basename shown above, load +the script for your shell using the [CLI reference](../../docs/cli.md#shell-completion), +and try completing `core-example report d` or `core-example ws gr`. + Integration factories also return `commandkit.Command` and can be passed directly to `New` beside `commandkit.Alias` or flag-alias declarations. For a command-only `[]commandkit.Command`, convert its elements into a diff --git a/examples/core/main.go b/examples/core/main.go index ce56974..fc517c0 100644 --- a/examples/core/main.go +++ b/examples/core/main.go @@ -64,7 +64,9 @@ func newApp() (*commandkit.App, error) { return reportInput{Scenario: "demo"} }, Arguments: []commandkit.Argument[reportInput]{ - commandkit.ValueArgument(commandkit.ArgumentMeta{Name: "scenario", Usage: "demo, recover, warn, warn-error, fail, or partial."}, false, func(value string) (string, error) { return value, nil }, + commandkit.ValueArgument(commandkit.ArgumentMeta{Name: "scenario", Usage: "demo, recover, warn, warn-error, fail, or partial.", Completion: commandkit.CompleteChoices( + commandkit.CompletionChoice{Value: "demo"}, commandkit.CompletionChoice{Value: "recover"}, commandkit.CompletionChoice{Value: "warn"}, + commandkit.CompletionChoice{Value: "warn-error"}, commandkit.CompletionChoice{Value: "fail"}, commandkit.CompletionChoice{Value: "partial"})}, false, func(value string) (string, error) { return value, nil }, func(input *reportInput, value commandkit.Supplied[string]) { input.Scenario = value.Value }), }, CheckInput: func(input reportInput) error { @@ -83,6 +85,7 @@ func newApp() (*commandkit.App, error) { declarations := []commandkit.Declaration{ workspace, report, + commandkit.Completion("core-example"), commandkit.Alias("hello", "workspace greet"), commandkit.Alias("ws", "workspace"), commandkit.FlagAliasFor("workspace greet", "--times", "--count"), diff --git a/integrations/ckconfig/completion.go b/integrations/ckconfig/completion.go new file mode 100644 index 0000000..e10b463 --- /dev/null +++ b/integrations/ckconfig/completion.go @@ -0,0 +1,11 @@ +package ckconfig + +import commandkit "gitea.maximumdirect.net/eric/CommandKit" + +func completionChoices(values ...string) commandkit.CompletionHint { + choices := make([]commandkit.CompletionChoice, len(values)) + for i, value := range values { + choices[i].Value = value + } + return commandkit.CompleteChoices(choices...) +} diff --git a/integrations/ckconfig/completion_test.go b/integrations/ckconfig/completion_test.go new file mode 100644 index 0000000..dc8cbf3 --- /dev/null +++ b/integrations/ckconfig/completion_test.go @@ -0,0 +1,66 @@ +package ckconfig + +import ( + "bytes" + "context" + "io" + "strings" + "testing" + + commandkit "gitea.maximumdirect.net/eric/CommandKit" + configkit "gitea.maximumdirect.net/eric/ConfigKit" +) + +func TestCompletionUsesInstalledScopeCapabilitiesWithoutAcquisition(t *testing.T) { + var inputs, states, acquisitions, comparisons int + suite := Suite[struct{}, struct{}]{ + NewInput: func() struct{} { inputs++; return struct{}{} }, + NewState: func() struct{} { states++; return struct{}{} }, + Scopes: []Scope[struct{}, struct{}]{ + {Name: "app", Operations: []Operation{Diff}, Compare: func(context.Context, ComparisonRequest[struct{}], struct{}) (commandkit.Acquired[[]configkit.Difference], error) { + comparisons++ + return commandkit.Acquired[[]configkit.Difference]{}, nil + }}, + {Name: "other", Operations: []Operation{Validate}, Acquire: func(context.Context, Request, struct{}, struct{}) (commandkit.Acquired[View], error) { + acquisitions++ + return commandkit.Acquired[View]{}, nil + }}, + }, + } + app, err := commandkit.New(commandkit.Identity{Name: "example"}, Command(suite), commandkit.Completion("example")) + if err != nil { + t.Fatal(err) + } + for _, tc := range []struct { + args []string + contains, omit string + }{ + {[]string{"__complete", "config", "diff", "--scope="}, "app\n", "other\n"}, + {[]string{"__complete", "config", "validate", "--scope="}, "other\n", "all\n"}, + {[]string{"__complete", "config", "diff", "--format="}, "yaml\n", "text\n"}, + {[]string{"__complete", "config", "validate", "--format="}, "text\n", "invalid\n"}, + {[]string{"__complete", "config", "diff", "--against-config=fixtures/"}, ":0\n", ":4\n"}, + } { + var output bytes.Buffer + outcome := app.Execute(context.Background(), tc.args, commandkit.Streams{Out: &output, Err: io.Discard}) + if outcome.Status != 0 || !strings.Contains(output.String(), tc.contains) || strings.Contains(output.String(), tc.omit) { + t.Fatalf("query %q outcome=%#v output=%q", tc.args, outcome, output.String()) + } + } + allSuite := Suite[struct{}, struct{}]{Scopes: []Scope[struct{}, struct{}]{ + {Name: "app", Operations: []Operation{Validate}, Acquire: suite.Scopes[1].Acquire}, + {Name: "other", Operations: []Operation{Validate}, Acquire: suite.Scopes[1].Acquire}, + }} + allApp, err := commandkit.New(commandkit.Identity{Name: "example"}, Command(allSuite), commandkit.Completion("example")) + if err != nil { + t.Fatal(err) + } + var allOutput bytes.Buffer + allOutcome := allApp.Execute(context.Background(), []string{"__complete", "config", "validate", "--scope="}, commandkit.Streams{Out: &allOutput}) + if allOutcome.Status != 0 || !strings.Contains(allOutput.String(), "all\n") { + t.Fatalf("all-scope query outcome=%#v output=%q", allOutcome, allOutput.String()) + } + if inputs != 0 || states != 0 || acquisitions != 0 || comparisons != 0 { + t.Fatalf("completion ran suite work: %d %d %d %d", inputs, states, acquisitions, comparisons) + } +} diff --git a/integrations/ckconfig/family.go b/integrations/ckconfig/family.go index fa23679..9b2f0d4 100644 --- a/integrations/ckconfig/family.go +++ b/integrations/ckconfig/family.go @@ -260,7 +260,7 @@ func newConfigScopesCommand[I, S any](prepared *preparedSuite[I, S]) commandkit. Name: "scopes", Summary: "List configuration scopes and supported operations.", Flags: []commandkit.Flag[scopeInventoryInput]{commandkit.ValueFlag( - commandkit.FlagMeta{Name: "format", Usage: "Output format: text or yaml."}, &formatDefault, parseConfigString, + commandkit.FlagMeta{Name: "format", Usage: "Output format: text or yaml.", Completion: completionChoices("text", "yaml")}, &formatDefault, parseConfigString, func(input *scopeInventoryInput, value commandkit.Supplied[string]) { input.Format = value.Value }, )}, CheckInput: func(input scopeInventoryInput) error { return checkConfigFormat(input.Format) }, diff --git a/integrations/ckconfig/input.go b/integrations/ckconfig/input.go index bebf93a..6cbd3ad 100644 --- a/integrations/ckconfig/input.go +++ b/integrations/ckconfig/input.go @@ -45,27 +45,43 @@ func newConfigOperationCommand[I, S any](prepared *preparedSuite[I, S], operatio if operation == Show || operation == Diff { defaultFormat = "yaml" } + formats := completionChoices("text", "yaml") + if operation == Show || operation == Diff { + formats = completionChoices("yaml") + } + var scopeChoices []commandkit.CompletionChoice + allSupported := len(prepared.scopes) > 0 + for _, scope := range prepared.scopes { + if containsOperation(scope.scope.Operations, operation) { + scopeChoices = append(scopeChoices, commandkit.CompletionChoice{Value: scope.scope.Name}) + } else { + allSupported = false + } + } + if allSupported && (operation == Validate || operation == Show) { + scopeChoices = append(scopeChoices, commandkit.CompletionChoice{Value: "all"}) + } flags := []commandkit.Flag[*configCommandInput[I, S]]{ - commandkit.ValueFlag(commandkit.FlagMeta{Name: "scope", Usage: "Configuration scope name or all where supported."}, &defaultScope, parseConfigString, + commandkit.ValueFlag(commandkit.FlagMeta{Name: "scope", Usage: "Configuration scope name or all where supported.", Completion: commandkit.CompleteChoices(scopeChoices...)}, &defaultScope, parseConfigString, func(input **configCommandInput[I, S], value commandkit.Supplied[string]) { (*input).scope = value }), - commandkit.ValueFlag(commandkit.FlagMeta{Name: "format", Usage: "Output format."}, &defaultFormat, parseConfigString, + commandkit.ValueFlag(commandkit.FlagMeta{Name: "format", Usage: "Output format.", Completion: formats}, &defaultFormat, parseConfigString, func(input **configCommandInput[I, S], value commandkit.Supplied[string]) { (*input).format = value }), - commandkit.ValueFlag(commandkit.FlagMeta{Name: "config", Usage: "Configuration source."}, (*string)(nil), parseConfigString, + commandkit.ValueFlag(commandkit.FlagMeta{Name: "config", Usage: "Configuration source.", Completion: commandkit.CompleteFiles()}, (*string)(nil), parseConfigString, func(input **configCommandInput[I, S], value commandkit.Supplied[string]) { (*input).selection.Config = value }), - commandkit.ValueFlag(commandkit.FlagMeta{Name: "overlay", Usage: "Overlay source."}, (*string)(nil), parseConfigString, + commandkit.ValueFlag(commandkit.FlagMeta{Name: "overlay", Usage: "Overlay source.", Completion: commandkit.CompleteFiles()}, (*string)(nil), parseConfigString, func(input **configCommandInput[I, S], value commandkit.Supplied[string]) { (*input).selection.Overlay = value }), } if operation == Diff { flags = append(flags, - commandkit.ValueFlag(commandkit.FlagMeta{Name: "against-config", Usage: "Configuration source for the right side."}, (*string)(nil), parseConfigString, + commandkit.ValueFlag(commandkit.FlagMeta{Name: "against-config", Usage: "Configuration source for the right side.", Completion: commandkit.CompleteFiles()}, (*string)(nil), parseConfigString, func(input **configCommandInput[I, S], value commandkit.Supplied[string]) { (*input).against.Config = value }), - commandkit.ValueFlag(commandkit.FlagMeta{Name: "against-overlay", Usage: "Overlay source for the right side."}, (*string)(nil), parseConfigString, + commandkit.ValueFlag(commandkit.FlagMeta{Name: "against-overlay", Usage: "Overlay source for the right side.", Completion: commandkit.CompleteFiles()}, (*string)(nil), parseConfigString, func(input **configCommandInput[I, S], value commandkit.Supplied[string]) { (*input).against.Overlay = value }), diff --git a/integrations/ckprompt/command.go b/integrations/ckprompt/command.go index e7b9143..f035418 100644 --- a/integrations/ckprompt/command.go +++ b/integrations/ckprompt/command.go @@ -134,11 +134,11 @@ func newCatalogListCommand[I, S any](suite Suite[I, S], name string) commandkit. return &(*input).captured }) flags = append(flags, - commandkit.ValueFlag(commandkit.FlagMeta{Name: "config", Usage: "Configuration input path."}, (*string)(nil), parseDefinitionString, + commandkit.ValueFlag(commandkit.FlagMeta{Name: "config", Usage: "Configuration input path.", Completion: commandkit.CompleteFiles()}, (*string)(nil), parseDefinitionString, func(input **catalogInput[I], value commandkit.Supplied[string]) { (*input).selection.Config = value }), - commandkit.ValueFlag(commandkit.FlagMeta{Name: "overlay", Usage: "Configuration overlay path."}, (*string)(nil), parseDefinitionString, + commandkit.ValueFlag(commandkit.FlagMeta{Name: "overlay", Usage: "Configuration overlay path.", Completion: commandkit.CompleteFiles()}, (*string)(nil), parseDefinitionString, func(input **catalogInput[I], value commandkit.Supplied[string]) { (*input).selection.Overlay = value }), - commandkit.ValueFlag(commandkit.FlagMeta{Name: "format", Usage: "Catalog format: yaml."}, stringDefault("yaml"), parseDefinitionString, + commandkit.ValueFlag(commandkit.FlagMeta{Name: "format", Usage: "Catalog format: yaml.", Completion: completionChoices("yaml")}, stringDefault("yaml"), parseDefinitionString, func(input **catalogInput[I], value commandkit.Supplied[string]) { (*input).format = value.Value }), ) if name == "prompts" || name == "profiles" || name == "backends" { @@ -256,13 +256,13 @@ func newDefinitionCommand[I, S any](suite Suite[I, S], name string, prompt bool) return &(*input).captured }) flags = append(flags, - commandkit.ValueFlag(commandkit.FlagMeta{Name: "config", Usage: "Configuration input path."}, (*string)(nil), parseDefinitionString, + commandkit.ValueFlag(commandkit.FlagMeta{Name: "config", Usage: "Configuration input path.", Completion: commandkit.CompleteFiles()}, (*string)(nil), parseDefinitionString, func(input **definitionInput[I], value commandkit.Supplied[string]) { (*input).selection.Config = value }), - commandkit.ValueFlag(commandkit.FlagMeta{Name: "overlay", Usage: "Configuration overlay path."}, (*string)(nil), parseDefinitionString, + commandkit.ValueFlag(commandkit.FlagMeta{Name: "overlay", Usage: "Configuration overlay path.", Completion: commandkit.CompleteFiles()}, (*string)(nil), parseDefinitionString, func(input **definitionInput[I], value commandkit.Supplied[string]) { (*input).selection.Overlay = value }), - commandkit.ValueFlag(commandkit.FlagMeta{Name: "format", Usage: "Report format: text or yaml."}, stringDefault("text"), parseDefinitionString, + commandkit.ValueFlag(commandkit.FlagMeta{Name: "format", Usage: "Report format: text or yaml.", Completion: completionChoices("text", "yaml")}, stringDefault("text"), parseDefinitionString, func(input **definitionInput[I], value commandkit.Supplied[string]) { (*input).format = value.Value }), ) spec := commandkit.CommandSpec[*definitionInput[I]]{ @@ -302,11 +302,11 @@ func newRequirementsCommand[I, S any](suite Suite[I, S]) commandkit.Command { return &(*input).captured }) flags = append(flags, - commandkit.ValueFlag(commandkit.FlagMeta{Name: "config", Usage: "Configuration input path."}, (*string)(nil), parseDefinitionString, + commandkit.ValueFlag(commandkit.FlagMeta{Name: "config", Usage: "Configuration input path.", Completion: commandkit.CompleteFiles()}, (*string)(nil), parseDefinitionString, func(input **requirementsInput[I], value commandkit.Supplied[string]) { (*input).selection.Config = value }), - commandkit.ValueFlag(commandkit.FlagMeta{Name: "overlay", Usage: "Configuration overlay path."}, (*string)(nil), parseDefinitionString, + commandkit.ValueFlag(commandkit.FlagMeta{Name: "overlay", Usage: "Configuration overlay path.", Completion: commandkit.CompleteFiles()}, (*string)(nil), parseDefinitionString, func(input **requirementsInput[I], value commandkit.Supplied[string]) { (*input).selection.Overlay = value }), @@ -318,7 +318,7 @@ func newRequirementsCommand[I, S any](suite Suite[I, S]) commandkit.Command { func(input **requirementsInput[I], value commandkit.Supplied[string]) { (*input).inspection.SessionID = value.Value }), - commandkit.ValueFlag(commandkit.FlagMeta{Name: "format", Usage: "Report format: yaml."}, stringDefault("yaml"), parseDefinitionString, + commandkit.ValueFlag(commandkit.FlagMeta{Name: "format", Usage: "Report format: yaml.", Completion: completionChoices("yaml")}, stringDefault("yaml"), parseDefinitionString, func(input **requirementsInput[I], value commandkit.Supplied[string]) { (*input).format = value.Value }), commandkit.RepeatFlag(commandkit.FlagMeta{Name: "var", Usage: "Caller variable assignment KEY=VALUE."}, []variableAssignment(nil), parseVariableAssignment, func(input **requirementsInput[I], value commandkit.Supplied[[]variableAssignment]) { diff --git a/integrations/ckprompt/completion.go b/integrations/ckprompt/completion.go new file mode 100644 index 0000000..920d6a6 --- /dev/null +++ b/integrations/ckprompt/completion.go @@ -0,0 +1,11 @@ +package ckprompt + +import commandkit "gitea.maximumdirect.net/eric/CommandKit" + +func completionChoices(values ...string) commandkit.CompletionHint { + choices := make([]commandkit.CompletionChoice, len(values)) + for i, value := range values { + choices[i].Value = value + } + return commandkit.CompleteChoices(choices...) +} diff --git a/integrations/ckprompt/examples/composed/README.md b/integrations/ckprompt/examples/composed/README.md index a42eab7..5a136d4 100644 --- a/integrations/ckprompt/examples/composed/README.md +++ b/integrations/ckprompt/examples/composed/README.md @@ -40,6 +40,13 @@ shortens the PromptKit group. `--fmt` is scoped to named prompt inspection and `--hlep` is available globally. The `smoke` shortcut retains the canonical `promptkit test KIND` operand, including the special `all` aggregate. +The example installs `Completion("composed-example")`. Build it as +`go build -o composed-example .`, then load a shell script as described in the +[CLI reference](../../../../docs/cli.md#shell-completion). Try completion after +`composed-example pk show prompt briefing --fmt=` or after the filename prefix +in `composed-example config show --config=fixtures/`. The already-entered `pk` +alias selects the PromptKit command context without loading an engine. + The composition calls `ckconfig.Command(suite)` and `ckprompt.Command(definitions)` directly in a `[]commandkit.Declaration` with root-installed routing rules. Group children remain canonical `Command` values. For command-only slices, diff --git a/integrations/ckprompt/examples/composed/main.go b/integrations/ckprompt/examples/composed/main.go index 9c2c63d..02f1599 100644 --- a/integrations/ckprompt/examples/composed/main.go +++ b/integrations/ckprompt/examples/composed/main.go @@ -290,6 +290,7 @@ func newAppWithEngineLoader( declarations := []commandkit.Declaration{ configCommand, definitionCommand, + commandkit.Completion("composed-example"), commandkit.Group("session", "Manage saved sessions.", restore), commandkit.Alias("list collections", "promptkit list collections"), commandkit.Alias("promptkit list bundles", "promptkit list collections"), diff --git a/integrations/ckprompt/examples/composed/main_test.go b/integrations/ckprompt/examples/composed/main_test.go index 08c1bc9..83433cd 100644 --- a/integrations/ckprompt/examples/composed/main_test.go +++ b/integrations/ckprompt/examples/composed/main_test.go @@ -352,6 +352,34 @@ func TestComposedCatalogsAndSmokeUseInjectedClientOnlyForInference(t *testing.T) } } +func TestComposedCompletionUsesHintsWithoutLoadingEngine(t *testing.T) { + var loads, calls int + loader := func(ctx context.Context, selection ckconfig.Selection, input *inspectionInput, cache *runCache) (*promptkit.Engine, error) { + loads++ + return loadPromptEngine(ctx, selection, input, cache) + } + app, err := newAppWithEngineLoader(&exampleLLMClient{calls: &calls}, loader, nil) + if err != nil { + t.Fatal(err) + } + for _, tc := range []struct { + args []string + want string + }{ + {[]string{"__complete", "pk", "show", "prompt", "briefing", "--fmt=y"}, "yaml\n"}, + {[]string{"__complete", "pk", "show", "prompt", "briefing", "--config=fixtures/"}, ":0\n"}, + } { + var output bytes.Buffer + outcome := app.Execute(context.Background(), tc.args, commandkit.Streams{Out: &output, Err: io.Discard}) + if outcome.Status != 0 || !strings.Contains(output.String(), tc.want) { + t.Fatalf("query %q outcome=%#v output=%q", tc.args, outcome, output.String()) + } + } + if loads != 0 || calls != 0 { + t.Fatalf("completion loaded engine %d times and invoked client %d times", loads, calls) + } +} + func TestComposedSmokeReportSurvivesInferenceFailure(t *testing.T) { wantCause := errors.New("local example inference failed") smokeTest := func(_ context.Context, request ckprompt.SmokeRequest, _ *inspectionInput, _ *runCache) (commandkit.Acquired[*promptkit.SmokeTestReport], error) { diff --git a/integrations/ckprompt/smoke_command.go b/integrations/ckprompt/smoke_command.go index 4b88ab0..d346501 100644 --- a/integrations/ckprompt/smoke_command.go +++ b/integrations/ckprompt/smoke_command.go @@ -28,15 +28,15 @@ func newSmokeTestCommand[I, S any](suite Suite[I, S]) commandkit.Command { return &(*input).captured }) flags = append(flags, - commandkit.ValueFlag(commandkit.FlagMeta{Name: "config", Usage: "Configuration input path."}, (*string)(nil), parseDefinitionString, + commandkit.ValueFlag(commandkit.FlagMeta{Name: "config", Usage: "Configuration input path.", Completion: commandkit.CompleteFiles()}, (*string)(nil), parseDefinitionString, func(input **smokeCommandInput[I], value commandkit.Supplied[string]) { (*input).selection.Config = value }), - commandkit.ValueFlag(commandkit.FlagMeta{Name: "overlay", Usage: "Configuration overlay path."}, (*string)(nil), parseDefinitionString, + commandkit.ValueFlag(commandkit.FlagMeta{Name: "overlay", Usage: "Configuration overlay path.", Completion: commandkit.CompleteFiles()}, (*string)(nil), parseDefinitionString, func(input **smokeCommandInput[I], value commandkit.Supplied[string]) { (*input).selection.Overlay = value }), - commandkit.ValueFlag(commandkit.FlagMeta{Name: "format", Usage: "Smoke report format: yaml."}, stringDefault("yaml"), parseDefinitionString, + commandkit.ValueFlag(commandkit.FlagMeta{Name: "format", Usage: "Smoke report format: yaml.", Completion: completionChoices("yaml")}, stringDefault("yaml"), parseDefinitionString, func(input **smokeCommandInput[I], value commandkit.Supplied[string]) { (*input).format = value.Value }), commandkit.ValueFlag(commandkit.FlagMeta{Name: "backend", Usage: "PromptKit backend ID."}, (*string)(nil), parseDefinitionString, func(input **smokeCommandInput[I], value commandkit.Supplied[string]) { diff --git a/integrations/ckprompt/validation_command.go b/integrations/ckprompt/validation_command.go index a605b58..3c5cf4e 100644 --- a/integrations/ckprompt/validation_command.go +++ b/integrations/ckprompt/validation_command.go @@ -29,11 +29,11 @@ func newValidationCommand[I, S any](suite Suite[I, S]) commandkit.Command { return &(*input).captured }) flags = append(flags, - commandkit.ValueFlag(commandkit.FlagMeta{Name: "config", Usage: "Configuration input path."}, (*string)(nil), parseDefinitionString, + commandkit.ValueFlag(commandkit.FlagMeta{Name: "config", Usage: "Configuration input path.", Completion: commandkit.CompleteFiles()}, (*string)(nil), parseDefinitionString, func(input **validationCommandInput[I], value commandkit.Supplied[string]) { (*input).request.Selection.Config = value }), - commandkit.ValueFlag(commandkit.FlagMeta{Name: "overlay", Usage: "Configuration overlay path."}, (*string)(nil), parseDefinitionString, + commandkit.ValueFlag(commandkit.FlagMeta{Name: "overlay", Usage: "Configuration overlay path.", Completion: commandkit.CompleteFiles()}, (*string)(nil), parseDefinitionString, func(input **validationCommandInput[I], value commandkit.Supplied[string]) { (*input).request.Selection.Overlay = value }), @@ -45,7 +45,7 @@ func newValidationCommand[I, S any](suite Suite[I, S]) commandkit.Command { func(input **validationCommandInput[I], value commandkit.Supplied[string]) { (*input).request.Request.SessionID = value.Value }), - commandkit.ValueFlag(commandkit.FlagMeta{Name: "format", Usage: "Confirmation format: yaml."}, stringDefault("yaml"), parseDefinitionString, + commandkit.ValueFlag(commandkit.FlagMeta{Name: "format", Usage: "Confirmation format: yaml.", Completion: completionChoices("yaml")}, stringDefault("yaml"), parseDefinitionString, func(input **validationCommandInput[I], value commandkit.Supplied[string]) { (*input).format = value.Value }),