Complete CommandKit consumer documentation

This commit is contained in:
2026-10-07 14:56:13 +00:00
parent 0af44a1172
commit 85f44cd358
17 changed files with 587 additions and 76 deletions

View File

@@ -11,10 +11,16 @@ and path explanation reports. Applications can register a PromptKit engine
configuration scope independently of PromptKit definition commands, and can
install optional prompt and profile inspection commands.
The [consumer guide](docs/consumers/guide.md) links to two maintained examples:
[simple](examples/simple/README.md) and
[composed](examples/composed/README.md).
## Documentation
- [Contributor guide](docs/development.md)
- [Command line reference](docs/cli.md)
- [Consumer guide](docs/consumers/guide.md)
- [Internal package overview](docs/internal/overview.md)
- [Architecture policy](docs/policy/architecture.md)
- [CLI standard proposal](docs/roadmap/01-cli-plan.md)
- [Dispatcher implementation roadmap](docs/roadmap/02-dispatch.md)

View File

@@ -116,7 +116,8 @@ type configInput[C any] struct {
selectors []selectorState[C]
}
// RegisterConfig installs the config family and its scope discovery command.
// RegisterConfig installs the available config handlers and the scope discovery
// command. Its registration and discovery paths do not invoke scope loaders.
func RegisterConfig[C any](app *App, suite ConfigSuite[C]) error {
if app == nil {
return fmt.Errorf("cannot register configuration on a nil app")
@@ -182,7 +183,11 @@ func RegisterConfig[C any](app *App, suite ConfigSuite[C]) error {
return nil
}
// NewConfigKitScope adapts a ConfigKit loader into a configuration scope.
// NewConfigKitScope adapts a ConfigKit loader into a scope with validation,
// effective display, source listing, and path explanation capabilities. The
// loader runs once per selected operation. Successful loads expose the result's
// public facets directly; ConfigKit LoadError records become CommandKit
// diagnostics while the original error is returned.
func NewConfigKitScope[C, T any](name, summary string, load func(context.Context, Selection, C) (*configkit.Result[T], error)) (ConfigScope[C], error) {
if load == nil {
return ConfigScope[C]{}, fmt.Errorf("ConfigKit scope loader must not be nil")

4
doc.go
View File

@@ -1,3 +1,3 @@
// Package commandkit defines public identity, stream, invocation, and outcome
// types for the CommandKit Go library.
// Package commandkit provides an instance-owned CLI dispatcher with typed
// command declarations, configuration scopes, and PromptKit inspection.
package commandkit

View File

@@ -233,7 +233,7 @@ and asks PromptKit to inspect the named definition. Inspection reads the
engine's captured configuration; it does not generate a prompt, contact a
provider, or look up credentials.
Prompt definitions and profile resolution remain owned by
[PromptKit](https://pkg.go.dev/gitea.maximumdirect.net/eric/promptkit).
[PromptKit's inspection API](https://gitea.maximumdirect.net/eric/promptkit/src/commit/4b8169da286917fde7875425bb32b799b12c6af3/engine.go).
```text
example promptkit show prompt customer/summary --version 2 --format yaml

74
docs/consumers/guide.md Normal file
View File

@@ -0,0 +1,74 @@
# Consumer guide
CommandKit's supported Go API is the root `commandkit` package. Start with an
`App`, register CommandKit's standard inspection families, register application
commands, and call `App.Execute` with the caller's context and streams. The
caller retains process startup, signal handling, and exit mapping.
## A configuration scope
Use [`NewConfigKitScope`](../../config.go) to adapt one ConfigKit loader. The
loader receives the invocation's explicit `Selection`; the application decides
how those supplied config and overlay values map to its inputs.
```go
scope, err := commandkit.NewConfigKitScope[appContext, appConfig](
"app",
"Application settings.",
func(ctx context.Context, selection commandkit.Selection, app appContext) (*configkit.Result[appConfig], error) {
return loadAppConfig(ctx, selection, app)
},
)
if err != nil {
return err
}
err = commandkit.RegisterConfig(app, commandkit.ConfigSuite[appContext]{
Scopes: []commandkit.ConfigScope[appContext]{scope},
})
```
`config show`, `config sources`, and `config explain` use the configuration
captured by that load. A scope can support only the operations it provides.
When several scopes share invocation-local application state, create it in
`ConfigSuite.NewContext`; use typed `Selector` bindings to copy only the values
that apply to each scope. See the [composed example](../../examples/composed/README.md)
for a cache shared by the `app` and `promptkit` scopes.
## PromptKit configuration and definitions
Register `NewPromptKitScope` with the config suite when you want the effective
PromptKit configuration and its ConfigKit provenance available under `config`.
Register `NewPromptKitSuite` and `RegisterPromptKit` separately to enable
`promptkit show prompt` and/or `promptkit show profile`. Each adapter calls the
owning public inspection method and preserves acquisition diagnostics with a
failed named lookup. Definition inspection does not invoke execution APIs or
resolve provider credentials.
The [composed example](../../examples/composed/README.md) shares its ordinary app and
engine-loading functions across the configuration and definition suites. It
also keeps `session restore` as an application-owned typed command, including
its session data and domain outcome mapping.
## Typed application commands
Declare flags, operands, and a typed input struct with `NewCommand`. Bindings
preserve whether a value was explicitly supplied, so an explicit zero can differ
from an omitted default. `RepeatFlag` retains occurrence order. The
[simple example](../../examples/simple/README.md) combines both features with a typed
ConfigKit scope.
## Outcomes and process exit
Handlers return `Success`, `Failure`, or an application-owned `Outcome`. The
caller chooses how a nonzero status maps to process exit; `Execute` never exits
the process. It writes CommandKit diagnostics to the supplied error stream and
returns the resulting classification and delivery information. Applications
keep their domain meanings and output in their own handlers.
Exact CommandKit contracts are documented on the exported declarations and
their GoDoc comments. ConfigKit owns configuration decoding and provenance;
consult its [inspection API](https://gitea.maximumdirect.net/eric/ConfigKit/src/commit/dc09959cca4e105f49ec316c6d4ba585bd2340bd/inspection.go).
PromptKit owns prompt formats and profile resolution; consult its [public
engine methods](https://gitea.maximumdirect.net/eric/promptkit/src/commit/4b8169da286917fde7875425bb32b799b12c6af3/engine.go),
[configuration inspection](https://gitea.maximumdirect.net/eric/promptkit/src/commit/4b8169da286917fde7875425bb32b799b12c6af3/configuration_inspection.go),
and [inspection types](https://gitea.maximumdirect.net/eric/promptkit/src/commit/4b8169da286917fde7875425bb32b799b12c6af3/types.go).

View File

@@ -4,10 +4,10 @@ CommandKit currently contains adopted repository policies, accepted architectura
decisions, implementation plans, identity and outcome primitives, typed command
declarations, an instance-owned registry, a Cobra-backed routing and parsing
adapter, public `App.Execute` coordination, and generated help/version commands.
Configuration suite registration, scope discovery, ConfigKit integration,
validation, native effective YAML display, source inventory, and path
explanation reports, PromptKit engine configuration inspection, and optional
PromptKit prompt/profile inspection commands are implemented. The
Configuration suite registration, scope discovery, ConfigKit validation and
reports, PromptKit engine configuration inspection, and optional PromptKit
prompt/profile inspection commands are implemented. The maintained
[consumer guide](consumers/guide.md) links to maintained examples. The
[README](../README.md) provides project orientation; the
[documentation policy](policy/documentation.md#canonical-ownership) assigns
canonical documentation owners.
@@ -87,3 +87,19 @@ Follow the [local commit policy](policy/agents.md#git-commit-policy). Completed
units receive local commits by default, subject to explicit user instructions.
Preserve unrelated work; default commit authorization does not authorize pushing
or publishing.
## Current public examples
The maintained consumers under `examples/` compile with the module suite and
run without external services:
```sh
go run ./examples/simple config show
go run ./examples/simple greet --count=0 --tag=first --tag second
go run ./examples/composed config validate --scope all --campaign spring
go run ./examples/composed promptkit show prompt briefing --campaign spring --format yaml
go run ./examples/composed session restore session-42 --campaign spring
```
The [consumer guide](consumers/guide.md) explains how their public API usage
fits together.

36
docs/internal/overview.md Normal file
View File

@@ -0,0 +1,36 @@
# Internal package overview
CommandKit ships one public package, `commandkit`, and one internal package,
`internal/cobra`. This inventory describes the current implementation; the
[architecture policy](../policy/architecture.md) owns normative boundaries and
[ADR 0003](../adr/0003-public-facade.md) records the public package boundary.
## `commandkit`
The root package declares the public API and owns the instance registry, typed
command input preparation, invocation coordination, standard handlers, and
provider adapters. Configuration reports adapt ConfigKit's public records and
writers. PromptKit scope and definition handlers use PromptKit's public
inspection methods and map their results to CommandKit-owned reports.
The root package imports `internal/cobra` to bridge its command declarations and
invocations to Cobra parsing and routing. Application callbacks, output streams,
context, and supported error identity cross that bridge through the root's typed
contracts.
## `internal/cobra`
This package owns the Cobra-specific command tree and argument/flag parsing
adapter. It translates neutral command metadata and parsed values into a bridge
result consumed by the root package. It does not import `commandkit` and does not
own registry policy, public command declarations, provider loading, or report
rendering.
## External implementation boundaries
ConfigKit owns typed configuration loading, source history, validation,
resolution, and path explanations. PromptKit owns captured engine
configuration, prompt definitions, profile inheritance, and named inspections.
CommandKit depends on their public module APIs and does not maintain parallel
schemas. Cobra and pflag are confined behind the internal adapter; YAML report
encoding is performed by the root package.

View File

@@ -1,6 +1,6 @@
# Initial CommandKit implementation plan
Status: implementation underway; Stages 1-13 complete and Stage 14 pending.
Status: implementation complete; Stages 1-14 complete.
Date: 2026-10-07.
@@ -818,7 +818,7 @@ selection errors, optional capabilities, and help without engine construction.
## Stage 14: substantiate consumer fit and complete documentation
**Status:** pending. **Prerequisite:** Stage 13.
**Status:** complete. **Prerequisite:** Stage 13.
1. Add complete maintained examples using only the public root API and published
upstream packages. Use two ordinary example directories (separate Go main
@@ -860,8 +860,12 @@ selection errors, optional capabilities, and help without engine construction.
into the implementation. Record completed stage status and checks, then commit
the completed unit under the local policy. Publishing remains separate work.
**Completion evidence:** each included roadmap feature has observable behavior
and its appropriate checks; examples compile and use the real public dispatcher;
default tests do not require real services/credentials; documents describe only
implemented contracts. The final report identifies the commits, checks, and
deferred capabilities without claiming downstream migrations or release completion.
**Completion evidence:** added maintained `examples/simple` and
`examples/composed` consumers, public consumer guidance, internal architecture
overview, and updated CLI/development/README/feature-roadmap documentation. The
examples compile and use the public dispatcher with synthetic data. Shared
selector helpers serve both configuration suites. Reviewed exported declarations,
root and adapter imports, dependency replacements, attribution, and retained
deferred feature IDs. Verified with `gofmt`, `go test ./...`, `go mod tidy -diff`,
document link checks, example smoke commands, and `git diff --check`. Publishing,
downstream migrations, and deferred capabilities remain outside this stage.

View File

@@ -1,16 +1,25 @@
# Initial CommandKit feature roadmap
Status: selected initial implementation target; unimplemented.
Status: selected initial implementation target; implemented.
Date: 2026-10-07.
## Purpose and authority
This document defines the selected end state of the initial library: packages,
features, integration contracts, dependencies, and completion evidence. It is
prepared using [the agreed checklist](initial-plan.md). Implementation steps belong
in [the implementation plan](implementation.md). This document describes the target
state; CommandKit currently has no Go module or dispatcher implementation.
This document defines the selected initial target for the library: packages,
features, integration contracts, dependencies, and completion evidence. It was
prepared using [the agreed checklist](initial-plan.md). Implementation steps and
stage evidence belong in [the implementation plan](implementation.md). The
included target is implemented; the table below records feature status.
| Included feature IDs | Status | Evidence |
| --- | --- | --- |
| API-01, REG-01, INPUT-01, BACK-01, INV-01, OUT-01 | complete | Root API and dispatcher tests; `go test ./...`. |
| HELP-01, VER-01 | complete | Help/version behavior tests; `go test ./...`. |
| INT-01, CFG-01–05 | complete | Configuration scope, validation, show, sources, and explain tests; `go test ./...`. |
| PK-01 | complete | PromptKit scope adapter tests; `go test ./...`. |
| PK-02, PK-03 | complete | Prompt/profile inspection tests; `go test ./...`. |
| DIFF-01, CAT-01 | deferred | Require upstream safe snapshot and catalog APIs; see [deferred capability targets](#deferred-capability-targets). |
The [architecture policy](../policy/architecture.md) and nine accepted ADRs control
this roadmap. The delivery scope, typed API model, selector/report behavior, and
@@ -63,11 +72,10 @@ module still declares the dependencies used by its bundled helpers.
| PromptKit definitions | `promptkit show prompt` and `promptkit show profile`, including effective `ExtraParams` |
| Application commands | Typed custom leaf commands, nested groups, and groups with their own runnable handler |
Defer catalog listing and semantic comparison until the owning libraries expose
the required public contracts. These accepted standard operations remain future
implementation targets. Their absence must appear accurately in help and
capabilities. Initial delivery uses the current released inspection APIs; upstream
snapshot/catalog enhancements are separate follow-up work.
Catalog listing and semantic comparison remain deferred until the owning
libraries expose the required public contracts. Their absence appears accurately
in help and capabilities. The implemented target uses the released inspection
APIs; upstream snapshot/catalog enhancements are separate follow-up work.
This scope provides meaningful configuration inspection immediately and avoids
requiring an initial consumer migration to wait for every future capability.

View File

@@ -0,0 +1,18 @@
# Composed consumer
This example composes application configuration, PromptKit configuration,
definition inspection, and one application-owned command:
```sh
go run . config scopes
go run . config validate --scope all --campaign spring
go run . promptkit show prompt briefing --campaign spring --format yaml
go run . promptkit show profile spring --campaign spring
go run . session restore session-42 --campaign spring
```
The sources and session store are small in-memory fixtures. `--campaign` is a
typed selector shared by the app and PromptKit configuration scopes. Their
scope contexts share a per-invocation cache created by `ConfigSuite.NewContext`.
Prompt/profile inspection has its own invocation context and composes the same
application and engine loader functions.

223
examples/composed/main.go Normal file
View File

@@ -0,0 +1,223 @@
package main
import (
"context"
"errors"
"fmt"
"os"
commandkit "gitea.maximumdirect.net/eric/CommandKit"
configkit "gitea.maximumdirect.net/eric/ConfigKit"
"gitea.maximumdirect.net/eric/promptkit"
)
type campaign struct {
PromptID string `yaml:"prompt_id"`
PromptVersion string `yaml:"prompt_version"`
ProfileID string `yaml:"profile_id"`
Model string `yaml:"model"`
}
type appConfiguration struct {
DefaultCampaign string `yaml:"default_campaign"`
Campaigns map[string]campaign `yaml:"campaigns"`
}
type invocationCache struct {
application *configkit.Result[appConfiguration]
}
type configContext struct {
Cache *invocationCache
Campaign commandkit.Supplied[string]
}
type definitionContext struct {
Cache *invocationCache
Campaign commandkit.Supplied[string]
}
type restoreInput struct {
Campaign commandkit.Supplied[string]
Session string
}
type session struct {
ID string
Campaign string
Summary string
}
type sessionStore struct {
active string
records map[string]session
}
func loadApplicationConfig(_ context.Context, _ commandkit.Selection, cache *invocationCache) (*configkit.Result[appConfiguration], error) {
if cache != nil && cache.application != nil {
return cache.application, nil
}
result, err := configkit.Load[appConfiguration](configkit.Mapping("composed app config", map[string]any{
"default_campaign": "spring",
"campaigns": map[string]any{
"spring": map[string]any{"prompt_id": "briefing", "prompt_version": "1", "profile_id": "spring", "model": "spring-model"},
"autumn": map[string]any{"prompt_id": "briefing", "prompt_version": "1", "profile_id": "autumn", "model": "autumn-model"},
},
}))
if err == nil && cache != nil {
cache.application = result
}
return result, err
}
func selectedCampaign(configuration appConfiguration, supplied commandkit.Supplied[string]) (string, campaign, error) {
name := configuration.DefaultCampaign
if supplied.Set {
name = supplied.Value
}
value, ok := configuration.Campaigns[name]
if !ok {
return "", campaign{}, fmt.Errorf("unknown campaign %q", name)
}
return name, value, nil
}
func loadPromptEngine(ctx context.Context, selection commandkit.Selection, cache *invocationCache, selected commandkit.Supplied[string]) (*promptkit.Engine, error) {
application, err := loadApplicationConfig(ctx, selection, cache)
if err != nil {
return nil, err
}
name, chosen, err := selectedCampaign(application.Config, selected)
if err != nil {
return nil, err
}
engineConfig := configkit.Mapping("campaign "+name, map[string]any{
"profiles": map[string]any{chosen.ProfileID: map[string]any{
"endpoint": "https://example.invalid/v1",
"model": chosen.Model,
"extra_params": map[string]any{
"campaign": name,
},
}},
"prompts": map[string]any{chosen.PromptID: map[string]any{
chosen.PromptVersion: map[string]any{
"default_profile": chosen.ProfileID,
"inputs": []any{map[string]any{"name": "topic", "required": true, "content_type": "text/plain", "description": "Topic to brief"}},
"messages": []any{map[string]any{"role": "user", "content": "Write a concise briefing about {{.topic}}."}},
"output": map[string]any{"format": "text", "validation_mode": "none"},
},
}},
})
return promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{Main: &configkit.Input{Source: engineConfig}}})
}
func campaignSelector[C any](set func(*C, commandkit.Supplied[string])) commandkit.Selector[C] {
return commandkit.Selector[C]{
Binding: commandkit.ValueFlag[C](commandkit.FlagMeta{Name: "campaign", Usage: "Select a configured campaign."}, nil, func(value string) (string, error) { return value, nil }, set),
Scopes: []string{"app", "promptkit"},
}
}
func newApp() (*commandkit.App, error) {
app, err := commandkit.New(commandkit.Identity{Name: "composed-example", Version: "1.0"})
if err != nil {
return nil, err
}
appScope, err := commandkit.NewConfigKitScope[configContext, appConfiguration]("app", "Application campaigns.", func(ctx context.Context, selection commandkit.Selection, input configContext) (*configkit.Result[appConfiguration], error) {
return loadApplicationConfig(ctx, selection, input.Cache)
})
if err != nil {
return nil, err
}
promptScope, err := commandkit.NewPromptKitScope(func(ctx context.Context, selection commandkit.Selection, input configContext) (*promptkit.Engine, error) {
return loadPromptEngine(ctx, selection, input.Cache, input.Campaign)
})
if err != nil {
return nil, err
}
configSuite := commandkit.ConfigSuite[configContext]{
NewContext: func() configContext { return configContext{Cache: &invocationCache{}} },
Selectors: []commandkit.Selector[configContext]{campaignSelector(func(input *configContext, value commandkit.Supplied[string]) {
input.Campaign = value
})},
Scopes: []commandkit.ConfigScope[configContext]{appScope, promptScope},
}
if err := commandkit.RegisterConfig(app, configSuite); err != nil {
return nil, err
}
definitionSuite, err := commandkit.NewPromptKitSuite(func(ctx context.Context, selection commandkit.Selection, input definitionContext) (*promptkit.Engine, error) {
return loadPromptEngine(ctx, selection, input.Cache, input.Campaign)
})
if err != nil {
return nil, err
}
definitionSuite.NewContext = func() definitionContext { return definitionContext{Cache: &invocationCache{}} }
definitionSuite.Selectors = []commandkit.Selector[definitionContext]{campaignSelector(func(input *definitionContext, value commandkit.Supplied[string]) {
input.Campaign = value
})}
if err := commandkit.RegisterPromptKit(app, definitionSuite); err != nil {
return nil, err
}
store := &sessionStore{records: map[string]session{"session-42": {ID: "session-42", Campaign: "spring", Summary: "A synthetic planning session."}}}
restore, err := commandkit.NewCommand(commandkit.CommandSpec[restoreInput]{
Name: "restore",
Summary: "Restore one saved session.",
Flags: []commandkit.Flag[restoreInput]{commandkit.ValueFlag[restoreInput](commandkit.FlagMeta{Name: "campaign", Usage: "Campaign owning the session."}, nil, func(value string) (string, error) { return value, nil }, func(input *restoreInput, value commandkit.Supplied[string]) {
input.Campaign = value
})},
Arguments: []commandkit.Argument[restoreInput]{commandkit.ValueArgument[restoreInput](commandkit.ArgumentMeta{Name: "ID"}, true, func(value string) (string, error) { return value, nil }, func(input *restoreInput, value commandkit.Supplied[string]) {
input.Session = value.Value
})},
Run: func(invocation commandkit.Invocation, input restoreInput) commandkit.Outcome {
return restoreSession(invocation, store, input)
},
})
if err != nil {
return nil, err
}
sessionGroup, err := commandkit.Group("session", "Manage saved sessions.", restore)
if err != nil {
return nil, err
}
if err := app.Register(sessionGroup); err != nil {
return nil, err
}
return app, nil
}
func restoreSession(invocation commandkit.Invocation, store *sessionStore, input restoreInput) commandkit.Outcome {
application, err := loadApplicationConfig(invocation.Context, commandkit.Selection{}, nil)
if err != nil {
return commandkit.Failure(err)
}
name, _, err := selectedCampaign(application.Config, input.Campaign)
if err != nil {
return commandkit.Failure(err)
}
record, ok := store.records[input.Session]
if !ok {
return commandkit.Outcome{Kind: commandkit.OutcomeDomain, Status: 4, Domain: "session_not_found", Cause: errors.New("saved session was not found")}
}
if record.Campaign != name {
return commandkit.Outcome{Kind: commandkit.OutcomeDomain, Status: 3, Domain: "campaign_mismatch", Cause: fmt.Errorf("session belongs to campaign %q", record.Campaign)}
}
store.active = record.ID
if _, err := fmt.Fprintf(invocation.Streams.Out, "restored %s: %s\n", record.ID, record.Summary); err != nil {
return commandkit.Failure(err)
}
return commandkit.Success()
}
func main() {
app, err := newApp()
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
outcome := app.Execute(context.Background(), os.Args[1:], commandkit.Streams{Out: os.Stdout, Err: os.Stderr})
if outcome.Status != 0 {
os.Exit(outcome.Status)
}
}

14
examples/simple/README.md Normal file
View File

@@ -0,0 +1,14 @@
# Simple consumer
Run the maintained example with:
```sh
go run . config show
go run . greet
go run . greet --count=0 --tag=first --tag second
```
The `config` family comes from one typed ConfigKit scope. The custom `greet`
command uses CommandKit's typed bindings and shows the difference between an
omitted `--count` and an explicitly supplied zero, plus ordered repeated tags.
The configuration is synthetic and stays in memory.

90
examples/simple/main.go Normal file
View File

@@ -0,0 +1,90 @@
package main
import (
"context"
"fmt"
"os"
"strconv"
commandkit "gitea.maximumdirect.net/eric/CommandKit"
configkit "gitea.maximumdirect.net/eric/ConfigKit"
)
type appConfiguration struct {
Greeting string `yaml:"greeting"`
}
type greetInput struct {
Count commandkit.Supplied[int]
Tags commandkit.Supplied[[]string]
}
func loadAppConfiguration(context.Context, commandkit.Selection, struct{}) (*configkit.Result[appConfiguration], error) {
return configkit.Load[appConfiguration](configkit.Bytes("simple example", []byte("greeting: hello\n")))
}
func newApp() (*commandkit.App, error) {
app, err := commandkit.New(commandkit.Identity{Name: "simple-example", Version: "1.0"})
if err != nil {
return nil, err
}
scope, err := commandkit.NewConfigKitScope[struct{}, appConfiguration]("app", "Application settings.", loadAppConfiguration)
if err != nil {
return nil, err
}
if err := commandkit.RegisterConfig(app, commandkit.ConfigSuite[struct{}]{Scopes: []commandkit.ConfigScope[struct{}]{scope}}); err != nil {
return nil, err
}
countDefault := 1
tagDefaults := []string{"example"}
greet, err := commandkit.NewCommand(commandkit.CommandSpec[greetInput]{
Name: "greet",
Summary: "Show flag presence and repeated values.",
Flags: []commandkit.Flag[greetInput]{
commandkit.ValueFlag[greetInput](commandkit.FlagMeta{Name: "count", Usage: "Greeting count."}, &countDefault, strconv.Atoi, func(input *greetInput, value commandkit.Supplied[int]) {
input.Count = value
}),
commandkit.RepeatFlag[greetInput](commandkit.FlagMeta{Name: "tag", Usage: "A repeatable label."}, tagDefaults, func(value string) (string, error) { return value, nil }, func(input *greetInput, value commandkit.Supplied[[]string]) {
input.Tags = value
}),
},
Run: func(invocation commandkit.Invocation, input greetInput) commandkit.Outcome {
configuration, err := loadAppConfiguration(invocation.Context, commandkit.Selection{}, struct{}{})
if err != nil {
return commandkit.Failure(err)
}
count := fmt.Sprintf("default %d (not supplied)", input.Count.Value)
if input.Count.Set {
count = fmt.Sprintf("explicit %d", input.Count.Value)
}
tags := fmt.Sprintf("default %v (not supplied)", input.Tags.Value)
if input.Tags.Set {
tags = fmt.Sprintf("explicit %v", input.Tags.Value)
}
if _, err := fmt.Fprintf(invocation.Streams.Out, "%s; count=%s; tags=%s\n", configuration.Config.Greeting, count, tags); err != nil {
return commandkit.Failure(err)
}
return commandkit.Success()
},
})
if err != nil {
return nil, err
}
if err := app.Register(greet); err != nil {
return nil, err
}
return app, nil
}
func main() {
app, err := newApp()
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
outcome := app.Execute(context.Background(), os.Args[1:], commandkit.Streams{Out: os.Stdout, Err: os.Stderr})
if outcome.Status != 0 {
os.Exit(outcome.Status)
}
}

View File

@@ -169,19 +169,13 @@ func normalizePromptKitSuite[C any](suite PromptKitSuite[C]) (PromptKitSuite[C],
return normalized, nil
}
type promptKitSelectorState[C any] struct {
scopes []string
apply func(*C)
clear func(*C)
}
type promptKitInput[C any] struct {
Selection Selection
Format string
ID string
Version string
SelectorContext C
selectors []promptKitSelectorState[C]
selectors []selectorState[C]
}
func promptKitInputFlags[C any](suite PromptKitSuite[C], prompt bool) []Flag[promptKitInput[C]] {
@@ -190,13 +184,11 @@ func promptKitInputFlags[C any](suite PromptKitSuite[C], prompt bool) []Flag[pro
ValueFlag[promptKitInput[C]](FlagMeta{Name: "format", Usage: "Output format."}, &formatDefault, parseString, func(input *promptKitInput[C], value Supplied[string]) {
input.Format = value.Value
}),
ValueFlag[promptKitInput[C]](FlagMeta{Name: "config", Usage: "Configuration source."}, nil, parseString, func(input *promptKitInput[C], value Supplied[string]) {
input.Selection.Config = value
}),
ValueFlag[promptKitInput[C]](FlagMeta{Name: "overlay", Usage: "Overlay source."}, nil, parseString, func(input *promptKitInput[C], value Supplied[string]) {
input.Selection.Overlay = value
}),
}
flags = append(flags, configurationSelectionFlags(
func(input *promptKitInput[C], value Supplied[string]) { input.Selection.Config = value },
func(input *promptKitInput[C], value Supplied[string]) { input.Selection.Overlay = value },
)...)
if prompt {
versionDefault := ""
flags = append(flags, ValueFlag[promptKitInput[C]](FlagMeta{Name: "version", Usage: "Exact prompt version; empty selects the owning default."}, &versionDefault, parseString, func(input *promptKitInput[C], value Supplied[string]) {
@@ -204,34 +196,16 @@ func promptKitInputFlags[C any](suite PromptKitSuite[C], prompt bool) []Flag[pro
}))
}
for _, selector := range suite.Selectors {
flags = append(flags, adaptPromptKitSelector(selector))
flags = append(flags, adaptContextSelector(selector,
func(input *promptKitInput[C]) *C { return &input.SelectorContext },
func(input *promptKitInput[C], state selectorState[C]) {
input.selectors = append(input.selectors, state)
},
))
}
return flags
}
func adaptPromptKitSelector[C any](selector Selector[C]) Flag[promptKitInput[C]] {
binding := selector.Binding.binding
meta := binding.meta
return Flag[promptKitInput[C]]{binding: flagBinding[promptKitInput[C]]{
meta: meta,
defaultText: binding.defaultText,
repeated: binding.repeated,
boolean: binding.boolean,
valid: binding.valid,
prepare: func(values []string) (func(*promptKitInput[C]), error) {
apply, err := binding.prepare(values)
if err != nil {
return nil, err
}
state := promptKitSelectorState[C]{scopes: append([]string(nil), selector.Scopes...), apply: apply, clear: binding.clear}
return func(input *promptKitInput[C]) {
apply(&input.SelectorContext)
input.selectors = append(input.selectors, state)
}, nil
},
}}
}
func checkPromptKitInput[C any](suite PromptKitSuite[C], input promptKitInput[C]) error {
if _, err := parseConfigFormat(input.Format); err != nil {
return err

View File

@@ -8,7 +8,10 @@ import (
)
// NewPromptKitScope adapts PromptKit's captured configuration inspection to
// CommandKit's configuration commands.
// CommandKit's configuration commands. The loader runs only when a selected
// configuration operation acquires this scope. Successful construction and
// inspection supply validation; the adapter does not execute prompts, resolve
// credentials, or probe model availability.
func NewPromptKitScope[C any](
load func(context.Context, Selection, C) (*promptkit.Engine, error),
) (ConfigScope[C], error) {

View File

@@ -250,6 +250,11 @@ func TestPromptKitInspectionSelectionErrorsHelpAndOptionalCapabilities(t *testin
if outcome.Kind != commandkit.OutcomeUsage || loaderCalls != beforeMissing {
t.Fatalf("missing ID = (%#v, calls=%d), want usage before acquisition", outcome, loaderCalls)
}
beforeScopeFlag := loaderCalls
outcome = app.Execute(context.Background(), []string{"promptkit", "show", "profile", "child", "--scope", "promptkit"}, commandkit.Streams{Out: io.Discard, Err: io.Discard})
if outcome.Kind != commandkit.OutcomeUsage || loaderCalls != beforeScopeFlag {
t.Fatalf("definition command accepted --scope: outcome=%#v calls=%d", outcome, loaderCalls)
}
profileOnlyApp, _ := commandkit.New(commandkit.Identity{Name: "profile-only"})
if err := commandkit.RegisterPromptKit(profileOnlyApp, commandkit.PromptKitSuite[struct{}]{InspectProfile: func(ctx context.Context, _ commandkit.Selection, _ struct{}, id string) (commandkit.Acquired[*promptkit.ProfileInspection], error) {
@@ -362,6 +367,29 @@ func TestPromptKitDirectSuiteRetainsDiagnosticsOnNamedLookupFailure(t *testing.T
}
}
func TestPromptKitInspectionReportsOutputDeliveryFailure(t *testing.T) {
engine := promptKitInspectionEngine(t)
suite, err := commandkit.NewPromptKitSuite(func(context.Context, commandkit.Selection, struct{}) (*promptkit.Engine, error) {
return engine, nil
})
if err != nil {
t.Fatal(err)
}
app, _ := commandkit.New(commandkit.Identity{Name: "example"})
if err := commandkit.RegisterPromptKit(app, suite); err != nil {
t.Fatal(err)
}
writeErr := errors.New("report output failed")
outcome := app.Execute(context.Background(), []string{"promptkit", "show", "profile", "child", "--format", "yaml"}, commandkit.Streams{Out: promptKitFailingWriter{err: writeErr}, Err: io.Discard})
if outcome.Kind != commandkit.OutcomeOperational || !outcome.ReportIncomplete || !errors.Is(outcome.DeliveryError, writeErr) {
t.Fatalf("promptkit output failure = %#v", outcome)
}
}
type promptKitFailingWriter struct{ err error }
func (writer promptKitFailingWriter) Write([]byte) (int, error) { return 0, writer.err }
func sortedKeys(values map[string]any) []string {
keys := make([]string, 0, len(values))
for key := range values {

View File

@@ -15,13 +15,11 @@ func configInputFlags[C any](suite ConfigSuite[C], formatDefault string) []Flag[
ValueFlag[configInput[C]](FlagMeta{Name: "format", Usage: "Output format."}, &formatDefault, parseString, func(input *configInput[C], value Supplied[string]) {
input.Format = value.Value
}),
ValueFlag[configInput[C]](FlagMeta{Name: "config", Usage: "Configuration source."}, nil, parseString, func(input *configInput[C], value Supplied[string]) {
input.Selection.Config = value
}),
ValueFlag[configInput[C]](FlagMeta{Name: "overlay", Usage: "Overlay source."}, nil, parseString, func(input *configInput[C], value Supplied[string]) {
input.Selection.Overlay = value
}),
}
flags = append(flags, configurationSelectionFlags(
func(input *configInput[C], value Supplied[string]) { input.Selection.Config = value },
func(input *configInput[C], value Supplied[string]) { input.Selection.Overlay = value },
)...)
for _, selector := range suite.Selectors {
flags = append(flags, adaptSelector(selector))
}
@@ -29,6 +27,13 @@ func configInputFlags[C any](suite ConfigSuite[C], formatDefault string) []Flag[
}
func adaptSelector[C any](selector Selector[C]) Flag[configInput[C]] {
return adaptContextSelector(selector,
func(input *configInput[C]) *C { return &input.SelectorContext },
func(input *configInput[C], state selectorState[C]) { input.selectors = append(input.selectors, state) },
)
}
func adaptContextSelector[C any, I any](selector Selector[C], selectContext func(*I) *C, record func(*I, selectorState[C])) Flag[I] {
binding := selector.Binding.binding
meta := binding.meta
if len(selector.Scopes) == 0 {
@@ -36,13 +41,13 @@ func adaptSelector[C any](selector Selector[C]) Flag[configInput[C]] {
} else {
meta.Usage += " (applies to: " + joinNames(selector.Scopes) + ")"
}
return Flag[configInput[C]]{binding: flagBinding[configInput[C]]{
return Flag[I]{binding: flagBinding[I]{
meta: meta,
defaultText: binding.defaultText,
repeated: binding.repeated,
boolean: binding.boolean,
valid: binding.valid,
prepare: func(values []string) (func(*configInput[C]), error) {
prepare: func(values []string) (func(*I), error) {
apply, err := binding.prepare(values)
if err != nil {
return nil, err
@@ -53,14 +58,21 @@ func adaptSelector[C any](selector Selector[C]) Flag[configInput[C]] {
apply: apply,
clear: binding.clear,
}
return func(input *configInput[C]) {
apply(&input.SelectorContext)
input.selectors = append(input.selectors, state)
return func(input *I) {
apply(selectContext(input))
record(input, state)
}, nil
},
}}
}
func configurationSelectionFlags[I any](setConfig, setOverlay func(*I, Supplied[string])) []Flag[I] {
return []Flag[I]{
ValueFlag[I](FlagMeta{Name: "config", Usage: "Configuration source."}, nil, parseString, setConfig),
ValueFlag[I](FlagMeta{Name: "overlay", Usage: "Overlay source."}, nil, parseString, setOverlay),
}
}
func parseString(value string) (string, error) { return value, nil }
func preflightConfig[C any](suite ConfigSuite[C], input configInput[C], operation ConfigOperation) ([]string, error) {