Compare commits
235 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7416bf6fdc | |||
| d14fa366f5 | |||
| 1289868ed0 | |||
| 42af1da142 | |||
| 0b70f37267 | |||
| 1986c44f49 | |||
| 65b454b2ab | |||
| b38130548e | |||
| aeb70e5873 | |||
| 85ae55bf42 | |||
| da120c2dd4 | |||
| 0183022a28 | |||
| 644e6c365b | |||
| 8e5e64c6e9 | |||
| c3d5f551c3 | |||
| 6378ceb3c5 | |||
| 5ff0dd4ea2 | |||
| 6c35da3ab6 | |||
| 7159f52f4d | |||
| d748056652 | |||
| 924692b541 | |||
| 3a6c66f550 | |||
| 190bfbd77b | |||
| 7693482a8b | |||
| 105bb741cc | |||
| cb7e05284c | |||
| 022055dc26 | |||
| e908213318 | |||
| 5d90d59996 | |||
| 76c00e32d0 | |||
| 9b85c7cf60 | |||
| ee3cb21ebc | |||
| 93c16bd57e | |||
| ca454624cc | |||
| 90d9706f6d | |||
| f494088124 | |||
| 5010f9a102 | |||
| 3b6614605a | |||
| e7b00efc07 | |||
| 647a9d5372 | |||
| c52034f3fe | |||
| 408383db60 | |||
| e82101434f | |||
| ffe568b6f0 | |||
| 756954f056 | |||
| b6a8f1f2cc | |||
| d15926a878 | |||
| 7f2fdce8e7 | |||
| 491f0b49f4 | |||
| 61bcd80171 | |||
| 9076cb2c9c | |||
| f63af12097 | |||
| 111f963c67 | |||
| 66a33756c3 | |||
| ff9f840edd | |||
| f6f41ee6aa | |||
| df33e8aefc | |||
| 6830b96821 | |||
| 42af12cb0e | |||
| 0b4e47c420 | |||
| ad16bfc521 | |||
| 540e2359d6 | |||
| e28ba63b59 | |||
| 7f327758d8 | |||
| 1e3879e880 | |||
| 33cc36ff3b | |||
| 983c22be77 | |||
| f933f056d7 | |||
| cf50b135f2 | |||
| ee0debe4fb | |||
| 4f851371da | |||
| 69cc6429ff | |||
| eeae10fa05 | |||
| bc1d1d5844 | |||
| 260256d722 | |||
| 5a79b56e3c | |||
| 5ccdcb91c9 | |||
| 42176a7ac4 | |||
| 72099958d1 | |||
| 2904eadf4f | |||
| 1c778d7ad3 | |||
| 16584084b1 | |||
| ce8ab3be38 | |||
| 7a9e3a1f7c | |||
| 60841227b7 | |||
| 3699c6c5e6 | |||
| 3f1ed2f107 | |||
| 4a8f73f289 | |||
| 92a327db97 | |||
| f0121ad466 | |||
| e256eb1cc0 | |||
| 881dae27c4 | |||
| c8d659dfe5 | |||
| d7cbaed3ed | |||
| 289a171e97 | |||
| 2cde616ce5 | |||
| 42d1774019 | |||
| bdc3fd8fa3 | |||
| eb974b6d29 | |||
| 0dc4ac41f0 | |||
| 129f95be0f | |||
| 87eb84ccb4 | |||
| 0fc00039bd | |||
| 4d92ee4ee6 | |||
| 7eac3fea65 | |||
| 12a6fa66fa | |||
| 98e7e99333 | |||
| 124a9fa57f | |||
| 75c0d5bce2 | |||
| 007eae4ed0 | |||
| 48fd2dc7bc | |||
| b94ed6665b | |||
| 7344994198 | |||
| cb40f1d9f8 | |||
| 3d396b1a66 | |||
| 09bb51d1c3 | |||
| a74fcb2004 | |||
| 3934038b98 | |||
| 0acbbf45ad | |||
| f4ed4c4839 | |||
| 5227049de6 | |||
| 8964ad02ad | |||
| f79f446726 | |||
| b83c0a0dfc | |||
| f39f27b5cd | |||
| 8b48115328 | |||
| c205903900 | |||
| c87bba3d2e | |||
| d3d40acea0 | |||
| 0450ecfc16 | |||
| 96250d1887 | |||
| d1f31d2931 | |||
| 4c47a0ca0b | |||
| 8bb5383169 | |||
| fc2f6d5346 | |||
| 0bed76a6f7 | |||
| 1d193c87fe | |||
| 10a799a2b7 | |||
| 230187296f | |||
| 473fb027a1 | |||
| 135d8da2b9 | |||
| de2266af75 | |||
| 13a4d4eacd | |||
| 215fc34d51 | |||
| 3338b902a2 | |||
| 41c6ca8070 | |||
| ee76b0f5f1 | |||
| 1e532cdfa5 | |||
| 7c035ccc54 | |||
| 96aa6b5ca0 | |||
| a6c0fb5b0d | |||
| 6264a87183 | |||
| b6f22462d3 | |||
| 401232a1b0 | |||
| 4ace616bc2 | |||
| 7c010e945a | |||
| 7fc89768ef | |||
| 37dfea5b25 | |||
| c0e0010683 | |||
| 5f37aac70f | |||
| 9a4d1db955 | |||
| f3e9326544 | |||
| 7a1067d387 | |||
| 49a10690c6 | |||
| df21b75f9c | |||
| f3bd6dc2ae | |||
| 6ba9338c0a | |||
| 5df88a6d94 | |||
| f5a5bbac58 | |||
| e56537690b | |||
| 2a1096bd1f | |||
| 1d29cdd029 | |||
| e48187d822 | |||
| e951a48902 | |||
| 976ed89403 | |||
| a5a92c7f08 | |||
| 514c4d47bb | |||
| ec29d658b7 | |||
| 085c296d89 | |||
| 0869b17b43 | |||
| 810333e376 | |||
| 2e337155ce | |||
| fa0aeace36 | |||
| 09d4d7992a | |||
| c70e29d3fd | |||
| e47ae799d7 | |||
| 5c26d80228 | |||
| bafd365115 | |||
| 4ddf960836 | |||
| 08ad7427e2 | |||
| f42aefe02d | |||
| 520d7e125b | |||
| eb92f7be7f | |||
| 744b8135f2 | |||
| c231a41665 | |||
| fa09edf8cc | |||
| 7dad20680e | |||
| ccf8acd861 | |||
| c225a66c97 | |||
| a54a9354b1 | |||
| 42d9989533 | |||
| 333a630cf1 | |||
| 666c006a89 | |||
| c3bb3941d3 | |||
| f34ab12ca5 | |||
| 1fa026e5bd | |||
| a504efbad7 | |||
| 451a0c483f | |||
| 75fc45f072 | |||
| 58b71c390f | |||
| 6ea8c4dd3f | |||
| 2ab87e9333 | |||
| 7a4efddab8 | |||
| 854426a0c9 | |||
| 80d4d6e1bd | |||
| b3fb795224 | |||
| d7cc12a347 | |||
| f3b8a129d9 | |||
| fae8c4dafa | |||
| ca85d5b012 | |||
| 93ee328779 | |||
| c6c4b01902 | |||
| 55330d2817 | |||
| fbef0f2ecc | |||
| 573a22a38e | |||
| f526198059 | |||
| 4b53c96cf3 | |||
| 50c54c229e | |||
| 0dfeac3022 | |||
| 8d5966a868 | |||
| a4cf55f7a5 | |||
| 1f3d1594c9 | |||
| ae6af1dc65 | |||
| df6ed89a97 | |||
| a320c76574 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -31,7 +31,7 @@ go.work.sum
|
|||||||
# temporary files which can be created if a process still has a handle open of a deleted file
|
# temporary files which can be created if a process still has a handle open of a deleted file
|
||||||
.fuse_hidden*
|
.fuse_hidden*
|
||||||
|
|
||||||
# Local ReleaseKit bootstrap executable
|
# Local ReleaseKit credentials
|
||||||
/.releasekit/
|
/.releasekit/
|
||||||
|
|
||||||
# KDE directory preferences
|
# KDE directory preferences
|
||||||
|
|||||||
27
README.md
27
README.md
@@ -8,24 +8,31 @@ modules, so applications choose which upstream libraries they add.
|
|||||||
The core captures reusable command declarations, validates installation batches
|
The core captures reusable command declarations, validates installation batches
|
||||||
before changing an application's registry, converts typed inputs before
|
before changing an application's registry, converts typed inputs before
|
||||||
constructing them, and returns outcomes without exiting the process. Its
|
constructing them, and returns outcomes without exiting the process. Its
|
||||||
[consumer guide](docs/consumers/guide.md) covers declaration and invocation.
|
[consumer guide](docs/consumers/guide.md) covers declaration, invocation
|
||||||
|
and reporting. Each handler receives one invocation reporter for
|
||||||
|
substantive output, categorized messages, and terminal explanations; `Execute`
|
||||||
|
returns delivery observations with the outcome. The [CLI reference](docs/cli.md)
|
||||||
|
defines the shared visibility policy.
|
||||||
|
|
||||||
## Modules
|
## Modules
|
||||||
|
|
||||||
| Module | Package | Provides |
|
| Module | Package | Provides |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `gitea.maximumdirect.net/eric/CommandKit` | `commandkit` | Typed commands, bindings, execution, outcomes, help, and version. |
|
| `go.maximumdirect.net/commandkit` | `commandkit` | Typed commands, bindings, execution reporting and outcomes, help, and version. |
|
||||||
| `gitea.maximumdirect.net/eric/CommandKit/integrations/ckconfig` | `ckconfig` | ConfigKit-backed `config` scopes, including configuration source evidence. |
|
| `go.maximumdirect.net/ckconfig` | `ckconfig` | ConfigKit-backed `config` scopes, including configuration source evidence. |
|
||||||
| `gitea.maximumdirect.net/eric/CommandKit/integrations/ckprompt` | `ckprompt` | PromptKit engine configuration/comparison, five catalogs, named inspection, requirements discovery, concrete request validation, and native smoke tests. |
|
| `go.maximumdirect.net/ckprompt` | `ckprompt` | PromptKit engine configuration/comparison, five catalogs, named inspection, requirements discovery, concrete request validation, and native smoke tests. |
|
||||||
|
|
||||||
Importing an integration does not install commands. Pass its declaration to
|
Importing an integration does not install commands. The published ckconfig and
|
||||||
`commandkit.New` or `App.Register` to install it. The integrations depend on the
|
ckprompt v0.2.0 modules use core v0.3.0. The published
|
||||||
core and their selected upstream libraries; the core does not depend on either
|
ckconfig v0.3.0 uses ConfigKit v1.1.0 and does not compose with
|
||||||
integration.
|
ckprompt v0.2.0; the published v0.2.0 pair remains matched. Current integration
|
||||||
|
sources use new core and ckconfig APIs and are developed in an aligned workspace
|
||||||
|
until their release dependencies are available. See the
|
||||||
|
[consumer guide](docs/consumers/guide.md#module-selection) for dependency details.
|
||||||
|
|
||||||
## Examples
|
## Examples
|
||||||
|
|
||||||
- [Core-only example](examples/core/main.go)
|
- [Core-only example](examples/core/README.md)
|
||||||
- [ConfigKit example](integrations/ckconfig/examples/simple/README.md)
|
- [ConfigKit example](integrations/ckconfig/examples/simple/README.md)
|
||||||
- [Composed ConfigKit and PromptKit example](integrations/ckprompt/examples/composed/README.md)
|
- [Composed ConfigKit and PromptKit example](integrations/ckprompt/examples/composed/README.md)
|
||||||
|
|
||||||
@@ -38,7 +45,7 @@ integration.
|
|||||||
- [Maintainer release procedure](docs/release.md)
|
- [Maintainer release procedure](docs/release.md)
|
||||||
- [Architecture policy](docs/policy/architecture.md)
|
- [Architecture policy](docs/policy/architecture.md)
|
||||||
- [Policy provenance](docs/policy/meta.md)
|
- [Policy provenance](docs/policy/meta.md)
|
||||||
- [Deferred capabilities and proposals](docs/roadmap/deferred.md)
|
- [Future work and proposals](docs/roadmap/future.md)
|
||||||
|
|
||||||
CommandKit is licensed under the [LGPL v3.0 or later](COPYRIGHT).
|
CommandKit is licensed under the [LGPL v3.0 or later](COPYRIGHT).
|
||||||
|
|
||||||
|
|||||||
45
app.go
45
app.go
@@ -3,38 +3,59 @@ package commandkit
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
commandmeta "gitea.maximumdirect.net/eric/CommandKit/internal/commandmeta"
|
"go.maximumdirect.net/commandkit/internal/alias"
|
||||||
|
commandmeta "go.maximumdirect.net/commandkit/internal/commandmeta"
|
||||||
)
|
)
|
||||||
|
|
||||||
// App owns one command registry and its application identity. Each App has an
|
// App owns one command registry and its application identity. Each App has an
|
||||||
// independent registry; command declarations may be reused across Apps.
|
// independent registry; command declarations may be reused across Apps.
|
||||||
type App struct {
|
type App struct {
|
||||||
identity Identity
|
identity Identity
|
||||||
commands map[string]*commandNode
|
commands map[string]*commandNode
|
||||||
snapshot *routingSnapshot
|
routingRules []RoutingRule
|
||||||
|
aliases *alias.Resolver
|
||||||
|
flagAliases map[string]resolvedFlagAliases
|
||||||
|
discoveryFlags map[string]resolvedFlagAliases
|
||||||
|
snapshot *routingSnapshot
|
||||||
}
|
}
|
||||||
|
|
||||||
type routingSnapshot struct {
|
type routingSnapshot struct {
|
||||||
metadata *commandmeta.Command
|
metadata *commandmeta.Command
|
||||||
runners map[commandmeta.Key]commandPayload
|
runners map[commandmeta.Key]commandPayload
|
||||||
|
aliases *alias.Resolver
|
||||||
|
canonical map[string]*canonicalNode
|
||||||
|
commandAliases []commandmeta.CommandAlias
|
||||||
|
flagAliases map[string]resolvedFlagAliases
|
||||||
|
discoveryFlags map[string]resolvedFlagAliases
|
||||||
|
completion bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type commandNode struct {
|
type commandNode struct {
|
||||||
command Command
|
command Command
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates an application with the supplied identity and initial commands.
|
// New creates an application with the supplied identity and initial canonical
|
||||||
|
// commands and routing rules. Use []Declaration to expand a mixed declaration
|
||||||
|
// collection; []Command cannot be expanded as ...Declaration without
|
||||||
|
// converting its elements.
|
||||||
|
//
|
||||||
// The identity name must not be empty. New installs its generated help and
|
// The identity name must not be empty. New installs its generated help and
|
||||||
// version commands and caller declarations as one batch; an invalid declaration
|
// version commands and caller declarations as one batch; an invalid, nil, or
|
||||||
// returns an error without exposing an App. The root help and version names are
|
// zero declaration returns an error without exposing an App. Factory issues
|
||||||
// reserved for generated commands.
|
// retained in Command and RoutingRule values are reported during installation.
|
||||||
func New(identity Identity, commands ...Command) (*App, error) {
|
// Identical duplicate routing mappings are accepted; conflicting mappings
|
||||||
|
// fail the batch. The root help and version names are reserved for generated
|
||||||
|
// commands.
|
||||||
|
func New(identity Identity, declarations ...Declaration) (*App, error) {
|
||||||
if identity.Name == "" {
|
if identity.Name == "" {
|
||||||
return nil, fmt.Errorf("application identity name must not be empty")
|
return nil, fmt.Errorf("application identity name must not be empty")
|
||||||
}
|
}
|
||||||
|
|
||||||
app := &App{identity: identity, commands: make(map[string]*commandNode)}
|
app := &App{identity: identity, commands: make(map[string]*commandNode)}
|
||||||
all := append(app.builtinCommands(), commands...)
|
builtins := app.builtinCommands()
|
||||||
|
all := make([]Declaration, 0, len(builtins)+len(declarations))
|
||||||
|
all = append(all, builtins...)
|
||||||
|
all = append(all, declarations...)
|
||||||
if err := app.installBatch(all...); err != nil {
|
if err := app.installBatch(all...); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
194
binding.go
194
binding.go
@@ -1,8 +1,12 @@
|
|||||||
package commandkit
|
package commandkit
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
|
commandmeta "go.maximumdirect.net/commandkit/internal/commandmeta"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ValueFlag declares a singleton flag with a typed parser, optional default,
|
// ValueFlag declares a singleton flag with a typed parser, optional default,
|
||||||
@@ -22,10 +26,10 @@ func ValueFlag[I, V any](meta FlagMeta, defaultValue *V, parse func(string) (V,
|
|||||||
binding.prepare = func(values []string) (preparedValue[I], error) {
|
binding.prepare = func(values []string) (preparedValue[I], error) {
|
||||||
value := fallback
|
value := fallback
|
||||||
supplied := len(values) > 0
|
supplied := len(values) > 0
|
||||||
for _, raw := range values {
|
for index, raw := range values {
|
||||||
parsed, err := parse(raw)
|
parsed, err := parse(raw)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return preparedValue[I]{}, err
|
return preparedValue[I]{}, &flagOccurrenceError{index: index, cause: err}
|
||||||
}
|
}
|
||||||
value = parsed
|
value = parsed
|
||||||
}
|
}
|
||||||
@@ -35,6 +39,48 @@ func ValueFlag[I, V any](meta FlagMeta, defaultValue *V, parse func(string) (V,
|
|||||||
return Flag[I]{binding: binding}
|
return Flag[I]{binding: binding}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// OccurrenceFlag declares a repeatable flag whose setter runs once for each
|
||||||
|
// supplied occurrence. Occurrence setters run in command-line order across
|
||||||
|
// participating bindings; an omitted flag does not call its setter.
|
||||||
|
func OccurrenceFlag[I, V any](meta FlagMeta, parse func(string) (V, error), set func(*I, Supplied[V])) Flag[I] {
|
||||||
|
binding := flagBinding[I]{identity: newBindingIdentity(), meta: meta, repeated: true}
|
||||||
|
if parse == nil || set == nil {
|
||||||
|
return Flag[I]{binding: binding}
|
||||||
|
}
|
||||||
|
binding.valid = true
|
||||||
|
binding.prepare = func(values []string) (preparedValue[I], error) {
|
||||||
|
result := preparedValue[I]{identity: binding.identity, supplied: len(values) > 0}
|
||||||
|
for index, raw := range values {
|
||||||
|
value, err := parse(raw)
|
||||||
|
if err != nil {
|
||||||
|
return preparedValue[I]{}, &flagOccurrenceError{index: index, cause: err}
|
||||||
|
}
|
||||||
|
ordinal := index
|
||||||
|
result.ordered = append(result.ordered, preparedOccurrence[I]{ordinal: &ordinal, apply: func(input *I) { set(input, Supplied[V]{Value: value, Set: true}) }})
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
return Flag[I]{binding: binding}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RenameFlag returns an independent binding with a new long name and no short
|
||||||
|
// spelling. Parsing, defaults, completion, requiredness, and setters are retained.
|
||||||
|
func RenameFlag[I any](flag Flag[I], name string) Flag[I] {
|
||||||
|
binding := flag.binding
|
||||||
|
binding.meta.Name = name
|
||||||
|
binding.meta.Short = ""
|
||||||
|
binding.identity = newBindingIdentity()
|
||||||
|
if prepare := binding.prepare; prepare != nil {
|
||||||
|
identity := binding.identity
|
||||||
|
binding.prepare = func(values []string) (preparedValue[I], error) {
|
||||||
|
value, err := prepare(values)
|
||||||
|
value.identity = identity
|
||||||
|
return value, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Flag[I]{binding: binding}
|
||||||
|
}
|
||||||
|
|
||||||
// BoolFlag declares a boolean flag. A bare flag is supplied by the parser as
|
// BoolFlag declares a boolean flag. A bare flag is supplied by the parser as
|
||||||
// "true"; explicit values are converted with strconv.ParseBool. Set distinguishes
|
// "true"; explicit values are converted with strconv.ParseBool. Set distinguishes
|
||||||
// an omitted flag from an explicit false value.
|
// an omitted flag from an explicit false value.
|
||||||
@@ -47,10 +93,10 @@ func BoolFlag[I any](meta FlagMeta, defaultValue bool, set func(*I, Supplied[boo
|
|||||||
binding.prepare = func(values []string) (preparedValue[I], error) {
|
binding.prepare = func(values []string) (preparedValue[I], error) {
|
||||||
value := defaultValue
|
value := defaultValue
|
||||||
supplied := len(values) > 0
|
supplied := len(values) > 0
|
||||||
for _, raw := range values {
|
for index, raw := range values {
|
||||||
parsed, err := strconv.ParseBool(raw)
|
parsed, err := strconv.ParseBool(raw)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return preparedValue[I]{}, err
|
return preparedValue[I]{}, &flagOccurrenceError{index: index, cause: err}
|
||||||
}
|
}
|
||||||
value = parsed
|
value = parsed
|
||||||
}
|
}
|
||||||
@@ -76,10 +122,10 @@ func RepeatFlag[I, V any](meta FlagMeta, defaults []V, parse func(string) (V, er
|
|||||||
supplied := len(values) > 0
|
supplied := len(values) > 0
|
||||||
if supplied {
|
if supplied {
|
||||||
parsedValues = make([]V, 0, len(values))
|
parsedValues = make([]V, 0, len(values))
|
||||||
for _, raw := range values {
|
for index, raw := range values {
|
||||||
parsed, err := parse(raw)
|
parsed, err := parse(raw)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return preparedValue[I]{}, err
|
return preparedValue[I]{}, &flagOccurrenceError{index: index, cause: err}
|
||||||
}
|
}
|
||||||
parsedValues = append(parsedValues, parsed)
|
parsedValues = append(parsedValues, parsed)
|
||||||
}
|
}
|
||||||
@@ -158,13 +204,37 @@ type preparedInput[I any] struct {
|
|||||||
values []preparedValue[I]
|
values []preparedValue[I]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type flagOccurrenceError struct {
|
||||||
|
index int
|
||||||
|
cause error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (err *flagOccurrenceError) Error() string { return err.cause.Error() }
|
||||||
|
|
||||||
|
func (err *flagOccurrenceError) Unwrap() error { return err.cause }
|
||||||
|
|
||||||
|
type flagConversionError struct {
|
||||||
|
binding string
|
||||||
|
spelling string
|
||||||
|
occurrence int
|
||||||
|
cause error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (err *flagConversionError) Error() string {
|
||||||
|
return fmt.Sprintf("flag %s occurrence %d: %v", err.spelling, err.occurrence, err.cause)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (err *flagConversionError) Unwrap() error { return err.cause }
|
||||||
|
|
||||||
func (p preparedInput[I]) build() I {
|
func (p preparedInput[I]) build() I {
|
||||||
var input I
|
var input I
|
||||||
if p.newInput != nil {
|
if p.newInput != nil {
|
||||||
input = p.newInput()
|
input = p.newInput()
|
||||||
}
|
}
|
||||||
for _, value := range p.values {
|
for _, value := range p.values {
|
||||||
value.apply(&input)
|
if value.apply != nil {
|
||||||
|
value.apply(&input)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return input
|
return input
|
||||||
}
|
}
|
||||||
@@ -173,18 +243,64 @@ func (p preparedInput[I]) build() I {
|
|||||||
// creates an input value. When enforceRequired is false, missing required flags
|
// creates an input value. When enforceRequired is false, missing required flags
|
||||||
// and operands are allowed so metadata help can validate supplied syntax.
|
// and operands are allowed so metadata help can validate supplied syntax.
|
||||||
func prepareInput[I any](spec CommandSpec[I], flagValues [][]string, operands []string, enforceRequired bool) (preparedInput[I], error) {
|
func prepareInput[I any](spec CommandSpec[I], flagValues [][]string, operands []string, enforceRequired bool) (preparedInput[I], error) {
|
||||||
|
return prepareInputWithSpellings(spec, flagValues, nil, operands, enforceRequired)
|
||||||
|
}
|
||||||
|
|
||||||
|
func prepareInputWithSpellings[I any](spec CommandSpec[I], flagValues, flagSpellings [][]string, operands []string, enforceRequired bool) (preparedInput[I], error) {
|
||||||
|
return prepareInputWithOccurrences(spec, flagValues, flagSpellings, nil, operands, enforceRequired)
|
||||||
|
}
|
||||||
|
|
||||||
|
func prepareInputWithOccurrences[I any](spec CommandSpec[I], flagValues, flagSpellings [][]string, occurrences []commandmeta.FlagOccurrence, operands []string, enforceRequired bool) (preparedInput[I], error) {
|
||||||
if len(flagValues) != len(spec.Flags) {
|
if len(flagValues) != len(spec.Flags) {
|
||||||
return preparedInput[I]{}, fmt.Errorf("received %d flag value sets for %d bindings", len(flagValues), len(spec.Flags))
|
return preparedInput[I]{}, fmt.Errorf("received %d flag value sets for %d bindings", len(flagValues), len(spec.Flags))
|
||||||
}
|
}
|
||||||
|
if flagSpellings == nil {
|
||||||
|
flagSpellings = make([][]string, len(spec.Flags))
|
||||||
|
}
|
||||||
|
if len(flagSpellings) != len(spec.Flags) {
|
||||||
|
return preparedInput[I]{}, fmt.Errorf("received %d flag spelling sets for %d bindings", len(flagSpellings), len(spec.Flags))
|
||||||
|
}
|
||||||
preparedFlags := make([]preparedValue[I], len(spec.Flags))
|
preparedFlags := make([]preparedValue[I], len(spec.Flags))
|
||||||
|
ordinals := make(map[string][]int)
|
||||||
|
for index, occurrence := range occurrences {
|
||||||
|
ordinals[occurrence.Name] = append(ordinals[occurrence.Name], index)
|
||||||
|
}
|
||||||
|
var ordered []preparedOccurrence[I]
|
||||||
|
fallbackOrdinal := 0
|
||||||
missingRequired := ""
|
missingRequired := ""
|
||||||
for i, flag := range spec.Flags {
|
for i, flag := range spec.Flags {
|
||||||
binding := flag.binding
|
binding := flag.binding
|
||||||
value, err := binding.prepare(flagValues[i])
|
value, err := binding.prepare(flagValues[i])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
var occurrenceErr *flagOccurrenceError
|
||||||
|
if errors.As(err, &occurrenceErr) {
|
||||||
|
spelling := "--" + binding.meta.Name
|
||||||
|
if occurrenceErr.index >= 0 && occurrenceErr.index < len(flagSpellings[i]) && flagSpellings[i][occurrenceErr.index] != "" {
|
||||||
|
spelling = flagSpellings[i][occurrenceErr.index]
|
||||||
|
}
|
||||||
|
return preparedInput[I]{}, &flagConversionError{
|
||||||
|
binding: binding.meta.Name, spelling: spelling,
|
||||||
|
occurrence: occurrenceErr.index + 1, cause: occurrenceErr.cause,
|
||||||
|
}
|
||||||
|
}
|
||||||
return preparedInput[I]{}, fmt.Errorf("flag --%s: %w", binding.meta.Name, err)
|
return preparedInput[I]{}, fmt.Errorf("flag --%s: %w", binding.meta.Name, err)
|
||||||
}
|
}
|
||||||
preparedFlags[i] = value
|
preparedFlags[i] = value
|
||||||
|
for index, occurrence := range value.ordered {
|
||||||
|
if occurrences != nil {
|
||||||
|
positions := ordinals[binding.meta.Name]
|
||||||
|
if index >= len(positions) {
|
||||||
|
return preparedInput[I]{}, fmt.Errorf("missing occurrence position for flag --%s", binding.meta.Name)
|
||||||
|
}
|
||||||
|
*occurrence.ordinal = positions[index]
|
||||||
|
} else {
|
||||||
|
*occurrence.ordinal = fallbackOrdinal
|
||||||
|
fallbackOrdinal++
|
||||||
|
}
|
||||||
|
if occurrence.apply != nil {
|
||||||
|
ordered = append(ordered, occurrence)
|
||||||
|
}
|
||||||
|
}
|
||||||
if enforceRequired && binding.meta.Required && !binding.deferredRequired && !value.supplied && missingRequired == "" {
|
if enforceRequired && binding.meta.Required && !binding.deferredRequired && !value.supplied && missingRequired == "" {
|
||||||
missingRequired = binding.meta.Name
|
missingRequired = binding.meta.Name
|
||||||
}
|
}
|
||||||
@@ -197,6 +313,10 @@ func prepareInput[I any](spec CommandSpec[I], flagValues [][]string, operands []
|
|||||||
return preparedInput[I]{}, err
|
return preparedInput[I]{}, err
|
||||||
}
|
}
|
||||||
preparedValues := append([]preparedValue[I](nil), preparedFlags...)
|
preparedValues := append([]preparedValue[I](nil), preparedFlags...)
|
||||||
|
sort.SliceStable(ordered, func(i, j int) bool { return *ordered[i].ordinal < *ordered[j].ordinal })
|
||||||
|
for _, occurrence := range ordered {
|
||||||
|
preparedValues = append(preparedValues, preparedValue[I]{apply: occurrence.apply})
|
||||||
|
}
|
||||||
for i, argument := range spec.Arguments {
|
for i, argument := range spec.Arguments {
|
||||||
binding := argument.binding
|
binding := argument.binding
|
||||||
value, err := binding.prepare(argumentValues[i])
|
value, err := binding.prepare(argumentValues[i])
|
||||||
@@ -211,60 +331,76 @@ func prepareInput[I any](spec CommandSpec[I], flagValues [][]string, operands []
|
|||||||
func newBindingIdentity() *bindingIdentity { return &bindingIdentity{marker: 1} }
|
func newBindingIdentity() *bindingIdentity { return &bindingIdentity{marker: 1} }
|
||||||
|
|
||||||
func allocateOperands[I any](operands []string, definitions []Argument[I], enforceRequired bool) ([][]string, error) {
|
func allocateOperands[I any](operands []string, definitions []Argument[I], enforceRequired bool) ([][]string, error) {
|
||||||
|
arguments := make([]commandmeta.Argument, len(definitions))
|
||||||
|
for i, definition := range definitions {
|
||||||
|
arguments[i] = commandmeta.Argument{Name: definition.binding.meta.Name, Min: definition.binding.min, Max: definition.binding.max}
|
||||||
|
}
|
||||||
|
counts, err := allocateOperandCounts(len(operands), arguments, enforceRequired)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
allocated := make([][]string, len(definitions))
|
||||||
|
position := 0
|
||||||
|
for i, count := range counts {
|
||||||
|
allocated[i] = operands[position : position+count]
|
||||||
|
position += count
|
||||||
|
}
|
||||||
|
return allocated, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func allocateOperandCounts(total int, definitions []commandmeta.Argument, enforceRequired bool) ([]int, error) {
|
||||||
for _, argument := range definitions {
|
for _, argument := range definitions {
|
||||||
binding := argument.binding
|
if argument.Min < 0 || argument.Max < -1 || (argument.Max >= 0 && argument.Min > argument.Max) {
|
||||||
if binding.min < 0 || binding.max < -1 || (binding.max >= 0 && binding.min > binding.max) {
|
return nil, fmt.Errorf("argument %q has invalid bounds %d..%d", argument.Name, argument.Min, argument.Max)
|
||||||
return nil, fmt.Errorf("argument %q has invalid bounds %d..%d", binding.meta.Name, binding.min, binding.max)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if enforceRequired {
|
if enforceRequired {
|
||||||
minimum := 0
|
minimum := 0
|
||||||
for _, argument := range definitions {
|
for _, argument := range definitions {
|
||||||
if argument.binding.min > len(operands)-minimum {
|
if argument.Min > total-minimum {
|
||||||
return nil, fmt.Errorf("not enough positional arguments")
|
return nil, fmt.Errorf("not enough positional arguments")
|
||||||
}
|
}
|
||||||
minimum += argument.binding.min
|
minimum += argument.Min
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suffixMinimum := make([]int, len(definitions)+1)
|
suffixMinimum := make([]int, len(definitions)+1)
|
||||||
for i := len(definitions) - 1; i >= 0; i-- {
|
for i := len(definitions) - 1; i >= 0; i-- {
|
||||||
minimum := definitions[i].binding.min
|
minimum := definitions[i].Min
|
||||||
remaining := len(operands) - suffixMinimum[i+1]
|
remaining := total - suffixMinimum[i+1]
|
||||||
if minimum > remaining {
|
if minimum > remaining {
|
||||||
suffixMinimum[i] = len(operands)
|
suffixMinimum[i] = total
|
||||||
} else {
|
} else {
|
||||||
suffixMinimum[i] = suffixMinimum[i+1] + minimum
|
suffixMinimum[i] = suffixMinimum[i+1] + minimum
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
allocated := make([][]string, len(definitions))
|
counts := make([]int, len(definitions))
|
||||||
position := 0
|
position := 0
|
||||||
for i, argument := range definitions {
|
for i, argument := range definitions {
|
||||||
binding := argument.binding
|
remaining := total - position
|
||||||
remaining := len(operands) - position
|
|
||||||
available := remaining - suffixMinimum[i+1]
|
available := remaining - suffixMinimum[i+1]
|
||||||
if available < 0 {
|
if available < 0 {
|
||||||
available = 0
|
available = 0
|
||||||
}
|
}
|
||||||
count := available
|
count := available
|
||||||
if binding.max >= 0 && count > binding.max {
|
if argument.Max >= 0 && count > argument.Max {
|
||||||
count = binding.max
|
count = argument.Max
|
||||||
}
|
}
|
||||||
if !enforceRequired && count < binding.min && remaining > 0 {
|
if !enforceRequired && count < argument.Min && remaining > 0 {
|
||||||
count = remaining
|
count = remaining
|
||||||
if binding.max >= 0 && count > binding.max {
|
if argument.Max >= 0 && count > argument.Max {
|
||||||
count = binding.max
|
count = argument.Max
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if enforceRequired && count < binding.min {
|
if enforceRequired && count < argument.Min {
|
||||||
return nil, fmt.Errorf("not enough values for argument %q", binding.meta.Name)
|
return nil, fmt.Errorf("not enough values for argument %q", argument.Name)
|
||||||
}
|
}
|
||||||
allocated[i] = operands[position : position+count]
|
counts[i] = count
|
||||||
position += count
|
position += count
|
||||||
}
|
}
|
||||||
if position < len(operands) {
|
if position < total {
|
||||||
return nil, fmt.Errorf("received %d extra positional arguments", len(operands)-position)
|
return nil, fmt.Errorf("received %d extra positional arguments", total-position)
|
||||||
}
|
}
|
||||||
return allocated, nil
|
return counts, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
package commandkit
|
package commandkit
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"io"
|
||||||
"reflect"
|
"reflect"
|
||||||
"strconv"
|
"strconv"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -245,3 +247,90 @@ func TestIncompletePreparationSkipsMissingRequirementsAndInputFactory(t *testing
|
|||||||
t.Fatalf("preparation invoked NewInput %d times", factoryCalls)
|
t.Fatalf("preparation invoked NewInput %d times", factoryCalls)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestExecuteBindsMixedFlagSpellingsWithFreshPresence(t *testing.T) {
|
||||||
|
type input struct {
|
||||||
|
count Supplied[int]
|
||||||
|
enabled Supplied[bool]
|
||||||
|
message Supplied[string]
|
||||||
|
tags Supplied[[]string]
|
||||||
|
required Supplied[string]
|
||||||
|
instance int
|
||||||
|
}
|
||||||
|
type observed struct {
|
||||||
|
count Supplied[int]
|
||||||
|
enabled Supplied[bool]
|
||||||
|
message Supplied[string]
|
||||||
|
tags Supplied[[]string]
|
||||||
|
required Supplied[string]
|
||||||
|
instance int
|
||||||
|
}
|
||||||
|
defaultCount := 9
|
||||||
|
defaultMessage := "fallback"
|
||||||
|
defaultTags := []string{"base"}
|
||||||
|
countParses, inputFactories := 0, 0
|
||||||
|
var calls []observed
|
||||||
|
command := NewCommand(CommandSpec[input]{
|
||||||
|
Name: "run",
|
||||||
|
NewInput: func() input {
|
||||||
|
inputFactories++
|
||||||
|
return input{instance: inputFactories}
|
||||||
|
},
|
||||||
|
Flags: []Flag[input]{
|
||||||
|
ValueFlag(FlagMeta{Name: "count"}, &defaultCount, func(value string) (int, error) {
|
||||||
|
countParses++
|
||||||
|
return strconv.Atoi(value)
|
||||||
|
}, func(target *input, value Supplied[int]) { target.count = value }),
|
||||||
|
BoolFlag(FlagMeta{Name: "enabled"}, true, func(target *input, value Supplied[bool]) { target.enabled = value }),
|
||||||
|
ValueFlag(FlagMeta{Name: "message"}, &defaultMessage, func(value string) (string, error) { return value, nil }, func(target *input, value Supplied[string]) { target.message = value }),
|
||||||
|
RepeatFlag(FlagMeta{Name: "tag"}, defaultTags, func(value string) (string, error) { return value, nil }, func(target *input, value Supplied[[]string]) { target.tags = value }),
|
||||||
|
ValueFlag(FlagMeta{Name: "required", Required: true}, (*string)(nil), func(value string) (string, error) { return value, nil }, func(target *input, value Supplied[string]) { target.required = value }),
|
||||||
|
},
|
||||||
|
Run: func(_ Invocation, value input) Outcome {
|
||||||
|
value.tags.Value = append([]string(nil), value.tags.Value...)
|
||||||
|
calls = append(calls, observed(value))
|
||||||
|
return Success()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
app, err := New(Identity{Name: "Example"}, command,
|
||||||
|
Alias("shortcut", "run"),
|
||||||
|
FlagAliasFor("run", "--number", "--count"),
|
||||||
|
FlagAliasFor("run", "--active", "--enabled"),
|
||||||
|
FlagAliasFor("run", "--text", "--message"),
|
||||||
|
FlagAliasFor("run", "--label", "--tag"),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, args := range [][]string{
|
||||||
|
{"shortcut", "--number=0", "--count=5", "--active=false", "--enabled=true", "--text=", "--message=last", "--label=a", "--tag=b", "--required=first"},
|
||||||
|
{"run", "--number=0", "--active=false", "--text=", "--label=c", "--required=second"},
|
||||||
|
} {
|
||||||
|
if outcome := app.Execute(context.Background(), args, Streams{Out: io.Discard, Err: io.Discard}); outcome.Kind != OutcomeSuccess {
|
||||||
|
t.Fatalf("Execute(%q) = %#v, want success", args, outcome)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if countParses != 3 {
|
||||||
|
t.Fatalf("count conversions = %d, want every one of three occurrences", countParses)
|
||||||
|
}
|
||||||
|
if len(calls) != 2 || calls[0].count != (Supplied[int]{Value: 5, Set: true}) || calls[0].enabled != (Supplied[bool]{Value: true, Set: true}) || calls[0].message != (Supplied[string]{Value: "last", Set: true}) || !reflect.DeepEqual(calls[0].tags, Supplied[[]string]{Value: []string{"a", "b"}, Set: true}) || calls[0].required != (Supplied[string]{Value: "first", Set: true}) {
|
||||||
|
t.Fatalf("first mixed-spelling invocation = %#v", calls)
|
||||||
|
}
|
||||||
|
if calls[1].count != (Supplied[int]{Value: 0, Set: true}) || calls[1].enabled != (Supplied[bool]{Value: false, Set: true}) || calls[1].message != (Supplied[string]{Value: "", Set: true}) || !reflect.DeepEqual(calls[1].tags, Supplied[[]string]{Value: []string{"c"}, Set: true}) || calls[1].required != (Supplied[string]{Value: "second", Set: true}) || calls[0].instance == calls[1].instance {
|
||||||
|
t.Fatalf("second mixed-spelling invocation = %#v", calls[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
if outcome := app.Execute(context.Background(), []string{"shortcut", "--count=7"}, Streams{Out: io.Discard, Err: io.Discard}); outcome.Kind != OutcomeUsage {
|
||||||
|
t.Fatalf("missing required flag outcome = %#v, want usage", outcome)
|
||||||
|
}
|
||||||
|
if inputFactories != 2 || len(calls) != 2 {
|
||||||
|
t.Fatalf("missing required flag created or ran input: factories=%d calls=%d", inputFactories, len(calls))
|
||||||
|
}
|
||||||
|
if outcome := app.Execute(context.Background(), []string{"shortcut", "--required=defaults"}, Streams{Out: io.Discard, Err: io.Discard}); outcome.Kind != OutcomeSuccess {
|
||||||
|
t.Fatalf("defaulted invocation = %#v, want success", outcome)
|
||||||
|
}
|
||||||
|
if len(calls) != 3 || calls[2].count != (Supplied[int]{Value: 9}) || calls[2].enabled != (Supplied[bool]{Value: true}) || calls[2].message != (Supplied[string]{Value: "fallback"}) || !reflect.DeepEqual(calls[2].tags, Supplied[[]string]{Value: []string{"base"}}) || calls[2].required != (Supplied[string]{Value: "defaults", Set: true}) || calls[2].instance == calls[1].instance {
|
||||||
|
t.Fatalf("omitted values reused prior invocation state: %#v", calls[2])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
120
builtins.go
120
builtins.go
@@ -1,35 +1,34 @@
|
|||||||
package commandkit
|
package commandkit
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"runtime/debug"
|
"runtime/debug"
|
||||||
|
"text/template"
|
||||||
|
|
||||||
internalcobra "gitea.maximumdirect.net/eric/CommandKit/internal/cobra"
|
internalcobra "go.maximumdirect.net/commandkit/internal/cobra"
|
||||||
commandmeta "gitea.maximumdirect.net/eric/CommandKit/internal/commandmeta"
|
commandmeta "go.maximumdirect.net/commandkit/internal/commandmeta"
|
||||||
yaml "go.yaml.in/yaml/v4"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const commandKitModulePath = "gitea.maximumdirect.net/eric/CommandKit"
|
const commandKitModulePath = "go.maximumdirect.net/commandkit"
|
||||||
|
|
||||||
type helpInput struct {
|
type helpInput struct {
|
||||||
Targets Supplied[[]string]
|
Targets Supplied[[]string]
|
||||||
}
|
}
|
||||||
|
|
||||||
type versionInput struct {
|
type versionInput struct {
|
||||||
Scope string
|
Scope string
|
||||||
Format string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type versionDocument struct {
|
type versionDocument struct {
|
||||||
Name string `yaml:"name"`
|
Name string `json:"name"`
|
||||||
Version string `yaml:"version"`
|
Version string `json:"version"`
|
||||||
Revision string `yaml:"revision"`
|
Revision string `json:"revision"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) builtinCommands() []Command {
|
var versionTextTemplate = template.Must(ParseTextTemplate("version", "{{.Name}} {{.Version}}{{if .Revision}} ({{.Revision}}){{end}}\n", nil))
|
||||||
|
|
||||||
|
func (a *App) builtinCommands() []Declaration {
|
||||||
help := captureCommand(CommandSpec[helpInput]{
|
help := captureCommand(CommandSpec[helpInput]{
|
||||||
Name: "help",
|
Name: "help",
|
||||||
Summary: "Show help for commands.",
|
Summary: "Show help for commands.",
|
||||||
@@ -41,14 +40,12 @@ func (a *App) builtinCommands() []Command {
|
|||||||
return current.renderHelpTargets(input.Targets.Value)
|
return current.renderHelpTargets(input.Targets.Value)
|
||||||
})
|
})
|
||||||
scopeDefault := "app"
|
scopeDefault := "app"
|
||||||
formatDefault := "text"
|
|
||||||
version := NewCommand(CommandSpec[versionInput]{
|
version := NewCommand(CommandSpec[versionInput]{
|
||||||
Name: "version",
|
Name: "version",
|
||||||
Summary: "Show application or CommandKit version.",
|
Summary: "Show application or CommandKit version.",
|
||||||
Description: "Report identity and build metadata without loading application configuration.",
|
Description: "Report identity and build metadata without loading application configuration.",
|
||||||
Flags: []Flag[versionInput]{
|
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: "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"}, &formatDefault, parseVersionFormat, func(input *versionInput, value Supplied[string]) { input.Format = value.Value }),
|
|
||||||
},
|
},
|
||||||
Run: func(invocation Invocation, input versionInput) Outcome {
|
Run: func(invocation Invocation, input versionInput) Outcome {
|
||||||
identity := a.identity
|
identity := a.identity
|
||||||
@@ -58,10 +55,10 @@ func (a *App) builtinCommands() []Command {
|
|||||||
if identity.Version == "" {
|
if identity.Version == "" {
|
||||||
identity.Version = "dev"
|
identity.Version = "dev"
|
||||||
}
|
}
|
||||||
return renderVersionOutcome(identity, input.Format, invocation.Streams)
|
return renderVersionOutcome(identity, invocation.Reporter())
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
return []Command{help, version}
|
return []Declaration{help, version}
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseVersionScope(value string) (string, error) {
|
func parseVersionScope(value string) (string, error) {
|
||||||
@@ -71,94 +68,29 @@ func parseVersionScope(value string) (string, error) {
|
|||||||
return value, nil
|
return value, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseVersionFormat(value string) (string, error) {
|
func (a *App) renderHelpViewOutcomeWithSession(view commandmeta.HelpView, reporter *Reporter, session *internalcobra.Session) Outcome {
|
||||||
if value != "text" && value != "yaml" {
|
content, failure := session.RenderHelpView(view)
|
||||||
return "", fmt.Errorf("format must be text or yaml, got %q", value)
|
|
||||||
}
|
|
||||||
return value, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *App) renderHelpOutcome(path []string, streams Streams) Outcome {
|
|
||||||
session := internalcobra.Prepare(a.routeSnapshot().metadata, commandmeta.ParseOptions{})
|
|
||||||
return a.renderHelpOutcomeWithSession(path, streams, session)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *App) renderHelpTargets(targets []string, streams Streams) Outcome {
|
|
||||||
session := internalcobra.Prepare(a.routeSnapshot().metadata, commandmeta.ParseOptions{})
|
|
||||||
return a.renderHelpTargetsWithSession(targets, streams, session)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *App) renderHelpTargetsWithSession(targets []string, streams Streams, session *internalcobra.Session) Outcome {
|
|
||||||
return a.renderHelpOutcomeWithSession(targets, streams, session)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *App) renderHelpOutcomeWithSession(path []string, streams Streams, session *internalcobra.Session) Outcome {
|
|
||||||
content, failure := session.RenderHelp(path)
|
|
||||||
if failure != nil {
|
if failure != nil {
|
||||||
if failure.Usage {
|
if failure.Usage {
|
||||||
return Usage(errors.New(failure.Message))
|
return Usage(failure.Message)
|
||||||
}
|
}
|
||||||
return Failure(errors.New(failure.Message))
|
return Failure(failure.Message)
|
||||||
}
|
|
||||||
if err := writeReport(streams.Out, []byte(content)); err != nil {
|
|
||||||
return incompleteReport(err)
|
|
||||||
}
|
}
|
||||||
|
_ = reporter.RenderOutput(func(w io.Writer) error {
|
||||||
|
_, err := io.WriteString(w, content)
|
||||||
|
return err
|
||||||
|
})
|
||||||
return Success()
|
return Success()
|
||||||
}
|
}
|
||||||
|
|
||||||
func renderVersionOutcome(identity Identity, format string, streams Streams) Outcome {
|
func renderVersionOutcome(identity Identity, reporter *Reporter) Outcome {
|
||||||
var content []byte
|
if identity.Version == "" {
|
||||||
switch format {
|
identity.Version = "dev"
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
_ = reporter.Report(Report{Value: versionDocument{Name: identity.Name, Version: identity.Version, Revision: identity.Revision}, TextTemplate: versionTextTemplate})
|
||||||
return Success()
|
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 {
|
func readBuildInfo() *debug.BuildInfo {
|
||||||
info, ok := debug.ReadBuildInfo()
|
info, ok := debug.ReadBuildInfo()
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|||||||
110
builtins_reporting_test.go
Normal file
110
builtins_reporting_test.go
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
package commandkit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func builtinReportingTestApp(t *testing.T) *App {
|
||||||
|
t.Helper()
|
||||||
|
command := NewCommand(CommandSpec[struct{}]{
|
||||||
|
Name: "run",
|
||||||
|
Run: func(Invocation, struct{}) Outcome { return Success() },
|
||||||
|
})
|
||||||
|
app, err := New(Identity{Name: "Example", Version: "1.2.3"}, command,
|
||||||
|
Group("promptkit", "PromptKit", Group("list", "List", simpleRunnable("collections"))),
|
||||||
|
Alias("r", "run"), Alias("list collections", "promptkit list collections"),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return app
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHelpAndVersionUseManagedOutput(t *testing.T) {
|
||||||
|
app := builtinReportingTestApp(t)
|
||||||
|
for _, args := range [][]string{{"help", "--quiet"}, {"version", "--quiet"}, {"list", "--help", "--quiet"}} {
|
||||||
|
var output, diagnostics bytes.Buffer
|
||||||
|
outcome := app.Execute(context.Background(), args, Streams{Out: &output, Err: &diagnostics})
|
||||||
|
if outcome.Kind != OutcomeSuccess || outcome.Status != 0 {
|
||||||
|
t.Errorf("Execute(%q) = %#v", args, outcome)
|
||||||
|
}
|
||||||
|
if output.Len() != 0 || diagnostics.Len() != 0 {
|
||||||
|
t.Errorf("Execute(%q) wrote output %q or diagnostics %q", args, output.String(), diagnostics.String())
|
||||||
|
}
|
||||||
|
if outcome.Presentation.Output.State != DeliverySuppressed {
|
||||||
|
t.Errorf("Execute(%q) output presentation = %#v", args, outcome.Presentation.Output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range []struct {
|
||||||
|
args []string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{args: []string{"help", "run"}, want: "run"},
|
||||||
|
{args: []string{"help", "r"}, want: "Alias for: Example run"},
|
||||||
|
{args: []string{"list", "--help"}, want: "Reporting Flags"},
|
||||||
|
{args: []string{"version"}, want: "Example 1.2.3"},
|
||||||
|
{args: []string{"version", "--format=yaml"}, want: "version: 1.2.3"},
|
||||||
|
} {
|
||||||
|
var output, diagnostics bytes.Buffer
|
||||||
|
outcome := app.Execute(context.Background(), test.args, Streams{Out: &output, Err: &diagnostics})
|
||||||
|
if outcome.Kind != OutcomeSuccess || !strings.Contains(output.String(), test.want) {
|
||||||
|
t.Errorf("Execute(%q) = %#v, output %q", test.args, outcome, output.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHelpAndVersionDestinationFailuresUseReporterObservations(t *testing.T) {
|
||||||
|
app := builtinReportingTestApp(t)
|
||||||
|
writeErr := errors.New("help/version destination failed")
|
||||||
|
for _, args := range [][]string{{"help", "run"}, {"version"}} {
|
||||||
|
var diagnostics bytes.Buffer
|
||||||
|
outcome := app.Execute(context.Background(), args, Streams{
|
||||||
|
Out: executeReportingFailureWriter{err: writeErr}, Err: &diagnostics,
|
||||||
|
})
|
||||||
|
if outcome.Kind != OutcomeOperational || outcome.Status != 1 {
|
||||||
|
t.Errorf("Execute(%q) = %#v", args, outcome)
|
||||||
|
}
|
||||||
|
if !errors.Is(outcome.Presentation.Output.WriteError, writeErr) || outcome.Presentation.Output.State != DeliveryIncomplete {
|
||||||
|
t.Errorf("Execute(%q) presentation = %#v", args, outcome.Presentation)
|
||||||
|
}
|
||||||
|
if !strings.Contains(diagnostics.String(), "deliver") || !strings.Contains(diagnostics.String(), "output") {
|
||||||
|
t.Errorf("Execute(%q) diagnostics = %q", args, diagnostics.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInvalidHelpAndVersionRequestsRemainUsageOutcomes(t *testing.T) {
|
||||||
|
app := builtinReportingTestApp(t)
|
||||||
|
for _, args := range [][]string{{"help", "unknown"}, {"version", "--scope=unknown"}, {"version", "--format=unknown"}} {
|
||||||
|
var output, diagnostics bytes.Buffer
|
||||||
|
outcome := app.Execute(context.Background(), args, Streams{Out: &output, Err: &diagnostics})
|
||||||
|
if outcome.Kind != OutcomeUsage || outcome.Status != 2 {
|
||||||
|
t.Errorf("Execute(%q) = %#v", args, outcome)
|
||||||
|
}
|
||||||
|
if !strings.Contains(diagnostics.String(), "Usage: Example") {
|
||||||
|
t.Errorf("Execute(%q) diagnostics = %q", args, diagnostics.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQuietBuiltinRenderingSuppressesDestinationWrites(t *testing.T) {
|
||||||
|
app := builtinReportingTestApp(t)
|
||||||
|
|
||||||
|
// A quiet successful run must not touch its supplied result destination.
|
||||||
|
var diagnostics bytes.Buffer
|
||||||
|
outcome := app.Execute(context.Background(), []string{"version", "--quiet"}, Streams{
|
||||||
|
Out: executeReportingFailureWriter{err: io.ErrClosedPipe}, Err: &diagnostics,
|
||||||
|
})
|
||||||
|
if outcome.Kind != OutcomeSuccess || outcome.Presentation.Output.State != DeliverySuppressed {
|
||||||
|
t.Fatalf("quiet version outcome = %#v", outcome)
|
||||||
|
}
|
||||||
|
if diagnostics.Len() != 0 {
|
||||||
|
t.Fatalf("quiet version diagnostics = %q", diagnostics.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,7 +13,7 @@ func TestCommandKitBuildIdentityProjectsEffectiveModuleVersion(t *testing.T) {
|
|||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "main module tag",
|
name: "main module tag",
|
||||||
info: &debug.BuildInfo{Main: debug.Module{Path: commandKitModulePath, Version: "v1.2.3"}},
|
info: &debug.BuildInfo{Main: debug.Module{Path: "go.maximumdirect.net/commandkit", Version: "v1.2.3"}},
|
||||||
want: "v1.2.3",
|
want: "v1.2.3",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
76
cli_json.go
Normal file
76
cli_json.go
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
package commandkit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ParseCLIJSON parses exactly one JSON value for use in CLI inputs. Integer
|
||||||
|
// tokens become int64, or uint64 for larger nonnegative integers; decimal and
|
||||||
|
// exponent tokens become float64. Nested values follow the same rules. Syntax
|
||||||
|
// errors and numbers outside the selected Go types' ranges are returned.
|
||||||
|
func ParseCLIJSON(input string) (any, error) {
|
||||||
|
decoder := json.NewDecoder(strings.NewReader(input))
|
||||||
|
decoder.UseNumber()
|
||||||
|
var value any
|
||||||
|
if err := decoder.Decode(&value); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var extra any
|
||||||
|
if err := decoder.Decode(&extra); err != io.EOF {
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("unexpected additional JSON value")
|
||||||
|
}
|
||||||
|
return convertCLIJSON(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CLI inputs use ordinary Go numbers for upstream consumers. Classification
|
||||||
|
// follows the token's syntax, so 1.0 and 1e0 remain float64. Unlike report
|
||||||
|
// serialization, this boundary deliberately accepts float64 approximation.
|
||||||
|
func convertCLIJSON(value any) (any, error) {
|
||||||
|
switch v := value.(type) {
|
||||||
|
case json.Number:
|
||||||
|
token := string(v)
|
||||||
|
if strings.ContainsAny(token, ".eE") {
|
||||||
|
result, err := strconv.ParseFloat(token, 64)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("JSON number %q: %w", token, err)
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
result, err := strconv.ParseInt(token, 10, 64)
|
||||||
|
if err == nil {
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(token, "-") {
|
||||||
|
unsigned, unsignedErr := strconv.ParseUint(token, 10, 64)
|
||||||
|
if unsignedErr == nil {
|
||||||
|
return unsigned, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("JSON integer %q: %w", token, unsignedErr)
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("JSON integer %q: %w", token, err)
|
||||||
|
case []any:
|
||||||
|
for index, item := range v {
|
||||||
|
converted, err := convertCLIJSON(item)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
v[index] = converted
|
||||||
|
}
|
||||||
|
case map[string]any:
|
||||||
|
for key, item := range v {
|
||||||
|
converted, err := convertCLIJSON(item)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
v[key] = converted
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return value, nil
|
||||||
|
}
|
||||||
44
cli_json_test.go
Normal file
44
cli_json_test.go
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
package commandkit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"math"
|
||||||
|
"reflect"
|
||||||
|
"strconv"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseCLIJSONValues(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
input string
|
||||||
|
want any
|
||||||
|
}{
|
||||||
|
{`{"a":[null,true,"x",-9223372036854775808,9223372036854775807,9223372036854775808,18446744073709551615],"b":{"n":1.0,"exp":1e3}}`, map[string]any{
|
||||||
|
"a": []any{nil, true, "x", int64(math.MinInt64), int64(math.MaxInt64), uint64(1 << 63), uint64(math.MaxUint64)},
|
||||||
|
"b": map[string]any{"n": float64(1), "exp": float64(1000)},
|
||||||
|
}},
|
||||||
|
{`0`, int64(0)}, {`-0`, int64(0)}, {`42.0`, float64(42)}, {`1e0`, float64(1)},
|
||||||
|
{`0.1`, float64(0.1)}, {`"1"`, "1"}, {`false`, false}, {`null`, nil},
|
||||||
|
} {
|
||||||
|
t.Run(tc.input, func(t *testing.T) {
|
||||||
|
got, err := ParseCLIJSON(tc.input)
|
||||||
|
if err != nil || !reflect.DeepEqual(got, tc.want) {
|
||||||
|
t.Fatalf("got %#v, %v; want %#v", got, err, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseCLIJSONRejectsInvalidInput(t *testing.T) {
|
||||||
|
for _, input := range []string{"", "[", "01", `1 2`, `true false`, `{} garbage`, `-9223372036854775809`, `18446744073709551616`, `1e400`, `[-1e400]`} {
|
||||||
|
t.Run(input, func(t *testing.T) {
|
||||||
|
if _, err := ParseCLIJSON(input); err == nil {
|
||||||
|
t.Fatalf("accepted %q", input)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
_, err := ParseCLIJSON(`18446744073709551616`)
|
||||||
|
if !errors.Is(err, strconv.ErrRange) {
|
||||||
|
t.Fatalf("range error = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
228
command.go
228
command.go
@@ -6,15 +6,66 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"unicode"
|
"unicode"
|
||||||
|
|
||||||
commandmeta "gitea.maximumdirect.net/eric/CommandKit/internal/commandmeta"
|
commandmeta "go.maximumdirect.net/commandkit/internal/commandmeta"
|
||||||
|
"go.maximumdirect.net/commandkit/internal/commandpath"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Command is an opaque, reusable declaration captured by NewCommand, Group,
|
// Command is an opaque, reusable declaration of canonical commands and groups.
|
||||||
// RootGroup, or an integration. Capture copies declaration slices but retains
|
// NewCommand, Group, RootGroup, and integration command factories return
|
||||||
// callback and caller-owned object references. Installation validates the
|
// Command values. Capture copies declaration slices but retains callback and
|
||||||
// declaration before changing an App registry.
|
// caller-owned object references. Installation validates the declaration
|
||||||
|
// before changing an App registry.
|
||||||
type Command struct{ record *commandRecord }
|
type Command struct{ record *commandRecord }
|
||||||
|
|
||||||
|
// Declaration is a captured canonical command or application routing rule
|
||||||
|
// accepted by New and App.Register. Use []Declaration when collecting both
|
||||||
|
// kinds. Group and RootGroup children remain Commands; install routing rules
|
||||||
|
// alongside the completed command tree through New or Register. Only
|
||||||
|
// CommandKit declaration types implement Declaration.
|
||||||
|
//
|
||||||
|
// A zero Command, zero RoutingRule, nil declaration, or typed nil declaration
|
||||||
|
// pointer is invalid. Use NoCommand to represent an intentionally absent
|
||||||
|
// optional command.
|
||||||
|
type Declaration interface {
|
||||||
|
commandkitDeclaration()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Command) commandkitDeclaration() {}
|
||||||
|
|
||||||
|
// RoutingRule is an opaque, reusable command or flag alias declaration. Alias,
|
||||||
|
// FlagAlias, and FlagAliasFor capture routing rules eagerly and retain any
|
||||||
|
// construction issues for New or App.Register to report. Identical duplicate
|
||||||
|
// mappings are accepted; competing targets for the same source are errors.
|
||||||
|
type RoutingRule struct{ record *routingRuleRecord }
|
||||||
|
|
||||||
|
func (RoutingRule) commandkitDeclaration() {}
|
||||||
|
|
||||||
|
type routingRuleKind uint8
|
||||||
|
|
||||||
|
const (
|
||||||
|
commandAliasRule routingRuleKind = iota + 1
|
||||||
|
globalFlagAliasRule
|
||||||
|
scopedFlagAliasRule
|
||||||
|
)
|
||||||
|
|
||||||
|
type capturedFlagSpelling struct {
|
||||||
|
name string
|
||||||
|
short bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type routingRuleRecord struct {
|
||||||
|
kind routingRuleKind
|
||||||
|
sourcePath commandpath.Path
|
||||||
|
targetPath commandpath.Path
|
||||||
|
scopePath commandpath.Path
|
||||||
|
sourceFlag capturedFlagSpelling
|
||||||
|
targetFlag capturedFlagSpelling
|
||||||
|
sourceText string
|
||||||
|
targetText string
|
||||||
|
scopeText string
|
||||||
|
issues []error
|
||||||
|
}
|
||||||
|
|
||||||
type commandRecord struct {
|
type commandRecord struct {
|
||||||
name string
|
name string
|
||||||
summary string
|
summary string
|
||||||
@@ -23,6 +74,7 @@ type commandRecord struct {
|
|||||||
payload commandPayload
|
payload commandPayload
|
||||||
issues []error
|
issues []error
|
||||||
rootOnly bool
|
rootOnly bool
|
||||||
|
completion bool
|
||||||
absent bool
|
absent bool
|
||||||
invalid bool
|
invalid bool
|
||||||
}
|
}
|
||||||
@@ -47,6 +99,8 @@ type FlagMeta struct {
|
|||||||
Usage string
|
Usage string
|
||||||
// Required reports whether execution requires the flag to be supplied.
|
// Required reports whether execution requires the flag to be supplied.
|
||||||
Required bool
|
Required bool
|
||||||
|
// Completion describes optional value suggestions.
|
||||||
|
Completion CompletionHint
|
||||||
}
|
}
|
||||||
|
|
||||||
// ArgumentMeta describes a positional command argument. Its name and usage are
|
// ArgumentMeta describes a positional command argument. Its name and usage are
|
||||||
@@ -56,6 +110,8 @@ type ArgumentMeta struct {
|
|||||||
Name string
|
Name string
|
||||||
// Usage briefly describes the argument.
|
// Usage briefly describes the argument.
|
||||||
Usage string
|
Usage string
|
||||||
|
// Completion describes optional value suggestions.
|
||||||
|
Completion CompletionHint
|
||||||
}
|
}
|
||||||
|
|
||||||
// Flag represents a typed binding for a command flag. Copies retain binding
|
// Flag represents a typed binding for a command flag. Copies retain binding
|
||||||
@@ -71,6 +127,13 @@ type preparedValue[I any] struct {
|
|||||||
identity *bindingIdentity
|
identity *bindingIdentity
|
||||||
supplied bool
|
supplied bool
|
||||||
apply func(*I)
|
apply func(*I)
|
||||||
|
ordered []preparedOccurrence[I]
|
||||||
|
}
|
||||||
|
|
||||||
|
type preparedOccurrence[I any] struct {
|
||||||
|
// Captured bindings share this position with the outer prepared occurrence.
|
||||||
|
ordinal *int
|
||||||
|
apply func(*I)
|
||||||
}
|
}
|
||||||
|
|
||||||
type flagBinding[I any] struct {
|
type flagBinding[I any] struct {
|
||||||
@@ -130,7 +193,7 @@ type commandPayload interface {
|
|||||||
hasRun() bool
|
hasRun() bool
|
||||||
bridgeFlags() []commandmeta.Flag
|
bridgeFlags() []commandmeta.Flag
|
||||||
bridgeArguments() []commandmeta.Argument
|
bridgeArguments() []commandmeta.Argument
|
||||||
prepare([][]string, []string, bool) (func(*execution) Outcome, error)
|
prepare([][]string, [][]string, []commandmeta.FlagOccurrence, []string, bool) (func(*execution) Outcome, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type typedCommand[I any] struct {
|
type typedCommand[I any] struct {
|
||||||
@@ -151,13 +214,14 @@ func (c typedCommand[I]) bridgeFlags() []commandmeta.Flag {
|
|||||||
for index, flag := range c.spec.Flags {
|
for index, flag := range c.spec.Flags {
|
||||||
binding := flag.binding
|
binding := flag.binding
|
||||||
flags[index] = commandmeta.Flag{
|
flags[index] = commandmeta.Flag{
|
||||||
Name: binding.meta.Name,
|
Name: binding.meta.Name,
|
||||||
Short: binding.meta.Short,
|
Short: binding.meta.Short,
|
||||||
Usage: binding.meta.Usage,
|
Usage: binding.meta.Usage,
|
||||||
Default: binding.defaultText,
|
Default: binding.defaultText,
|
||||||
Boolean: binding.boolean,
|
Boolean: binding.boolean,
|
||||||
Repeated: binding.repeated,
|
Repeated: binding.repeated,
|
||||||
Required: binding.meta.Required,
|
Required: binding.meta.Required,
|
||||||
|
Completion: bridgeCompletion(binding.meta.Completion),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return flags
|
return flags
|
||||||
@@ -168,17 +232,18 @@ func (c typedCommand[I]) bridgeArguments() []commandmeta.Argument {
|
|||||||
for index, argument := range c.spec.Arguments {
|
for index, argument := range c.spec.Arguments {
|
||||||
binding := argument.binding
|
binding := argument.binding
|
||||||
arguments[index] = commandmeta.Argument{
|
arguments[index] = commandmeta.Argument{
|
||||||
Name: binding.meta.Name,
|
Name: binding.meta.Name,
|
||||||
Usage: binding.meta.Usage,
|
Usage: binding.meta.Usage,
|
||||||
Min: binding.min,
|
Min: binding.min,
|
||||||
Max: binding.max,
|
Max: binding.max,
|
||||||
|
Completion: bridgeCompletion(binding.meta.Completion),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return arguments
|
return arguments
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c typedCommand[I]) prepare(flagValues [][]string, operands []string, enforceRequired bool) (func(*execution) Outcome, error) {
|
func (c typedCommand[I]) prepare(flagValues, flagSpellings [][]string, occurrences []commandmeta.FlagOccurrence, operands []string, enforceRequired bool) (func(*execution) Outcome, error) {
|
||||||
prepared, err := prepareInput(c.spec, flagValues, operands, enforceRequired)
|
prepared, err := prepareInputWithOccurrences(c.spec, flagValues, flagSpellings, occurrences, operands, enforceRequired)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -192,13 +257,15 @@ func (c typedCommand[I]) prepare(flagValues [][]string, operands []string, enfor
|
|||||||
if c.privateRun != nil {
|
if c.privateRun != nil {
|
||||||
return c.privateRun(current.commandContext(), input)
|
return c.privateRun(current.commandContext(), input)
|
||||||
}
|
}
|
||||||
return c.spec.Run(Invocation{Context: current.ctx, Streams: current.streams}, input)
|
return c.spec.Run(current.invocation(), input)
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewCommand captures a typed command declaration without constructing its
|
// NewCommand captures a typed canonical command declaration without
|
||||||
// input or running callbacks. Construction issues are retained and reported
|
// constructing its input or running callbacks. It copies the spec's flags,
|
||||||
// when the declaration is installed. The returned declaration can be reused.
|
// arguments, and children slices. Construction issues are retained and
|
||||||
|
// reported when the declaration is installed; NewCommand itself does not
|
||||||
|
// return an error. The returned declaration can be reused across Apps.
|
||||||
func NewCommand[I any](spec CommandSpec[I]) Command {
|
func NewCommand[I any](spec CommandSpec[I]) Command {
|
||||||
return captureCommand(spec, nil)
|
return captureCommand(spec, nil)
|
||||||
}
|
}
|
||||||
@@ -220,25 +287,29 @@ func captureCommand[I any](spec CommandSpec[I], privateRun func(commandExecution
|
|||||||
return Command{record: record}
|
return Command{record: record}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Group captures a non-runnable command group and a copy of its relative child
|
// Group captures a non-runnable canonical command group and a copy of its
|
||||||
// slice. Child declarations remain reusable and are not recursively copied.
|
// relative Command child slice. Child declarations remain reusable and are not
|
||||||
|
// recursively copied. Routing rules cannot be children; install them alongside
|
||||||
|
// the root command tree through New or App.Register.
|
||||||
func Group(name, summary string, children ...Command) Command {
|
func Group(name, summary string, children ...Command) Command {
|
||||||
record := &commandRecord{name: name, summary: summary, children: append([]Command(nil), children...)}
|
record := &commandRecord{name: name, summary: summary, children: append([]Command(nil), children...)}
|
||||||
captureComposition(record)
|
captureComposition(record)
|
||||||
return Command{record: record}
|
return Command{record: record}
|
||||||
}
|
}
|
||||||
|
|
||||||
// RootGroup captures a group that may only be installed at the application root.
|
// RootGroup captures a group that may only be installed at the application
|
||||||
// Installation rejects it below any other command, including through wrappers.
|
// root. Installation rejects it below any other command, including through
|
||||||
|
// wrappers. Its children are canonical Commands; install routing rules at the
|
||||||
|
// application root separately.
|
||||||
func RootGroup(name, summary string, children ...Command) Command {
|
func RootGroup(name, summary string, children ...Command) Command {
|
||||||
command := Group(name, summary, children...)
|
command := Group(name, summary, children...)
|
||||||
command.record.rootOnly = true
|
command.record.rootOnly = true
|
||||||
return command
|
return command
|
||||||
}
|
}
|
||||||
|
|
||||||
// WithError returns a new declaration that also retains a non-nil err for
|
// WithError returns a new Command declaration that also retains a non-nil err
|
||||||
// installation. Earlier declaration and child errors remain available, and
|
// for installation. Earlier declaration and child errors remain available,
|
||||||
// wrapped error identity is preserved. A nil err returns command unchanged.
|
// and wrapped error identity is preserved. A nil err returns command unchanged.
|
||||||
func WithError(command Command, err error) Command {
|
func WithError(command Command, err error) Command {
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return command
|
return command
|
||||||
@@ -254,11 +325,91 @@ func WithError(command Command, err error) Command {
|
|||||||
return Command{record: &record}
|
return Command{record: &record}
|
||||||
}
|
}
|
||||||
|
|
||||||
// NoCommand explicitly represents an absent optional declaration. Installation
|
// NoCommand explicitly represents an absent optional command declaration.
|
||||||
// skips it, but reports errors attached to it; a zero Command is invalid and is
|
// Installation skips it, but reports errors attached to it; a zero Command is
|
||||||
// not an absence marker.
|
// invalid and is not an absence marker. Routing rules have no absence marker.
|
||||||
func NoCommand() Command { return Command{record: &commandRecord{absent: true}} }
|
func NoCommand() Command { return Command{record: &commandRecord{absent: true}} }
|
||||||
|
|
||||||
|
// Alias captures a command-path alias from path to target. Paths are
|
||||||
|
// whitespace-separated sequences of command names relative to the application
|
||||||
|
// root. The target must resolve to a canonical command or group; aliases do not
|
||||||
|
// add operands or transform flag values. Construction issues are retained for
|
||||||
|
// New or App.Register to report. Identical mappings are accepted, while
|
||||||
|
// different targets for the same source path are installation errors.
|
||||||
|
func Alias(path, target string) RoutingRule {
|
||||||
|
record := &routingRuleRecord{
|
||||||
|
kind: commandAliasRule,
|
||||||
|
sourceText: path,
|
||||||
|
targetText: target,
|
||||||
|
}
|
||||||
|
record.sourcePath = captureRoutingPath("source", path, &record.issues)
|
||||||
|
record.targetPath = captureRoutingPath("target", target, &record.issues)
|
||||||
|
return RoutingRule{record: record}
|
||||||
|
}
|
||||||
|
|
||||||
|
// FlagAlias captures an application-wide flag spelling alias. Spellings must
|
||||||
|
// be one long flag name or one individual short spelling. The mapping applies
|
||||||
|
// wherever its canonical target flag is available and contributes nothing on
|
||||||
|
// commands without that flag. Identical mappings are accepted; different
|
||||||
|
// targets for the same source spelling in this scope are installation errors.
|
||||||
|
func FlagAlias(spelling, target string) RoutingRule {
|
||||||
|
record := &routingRuleRecord{
|
||||||
|
kind: globalFlagAliasRule,
|
||||||
|
sourceText: spelling,
|
||||||
|
targetText: target,
|
||||||
|
}
|
||||||
|
record.sourceFlag = captureFlagSpelling("source", spelling, &record.issues)
|
||||||
|
record.targetFlag = captureFlagSpelling("target", target, &record.issues)
|
||||||
|
return RoutingRule{record: record}
|
||||||
|
}
|
||||||
|
|
||||||
|
// FlagAliasFor captures a flag spelling alias scoped to a canonical command
|
||||||
|
// path, not an alias path. The command and canonical target flag must be
|
||||||
|
// available in the complete installation. Scoped mappings take precedence over
|
||||||
|
// application-wide mappings with the same source spelling. Identical mappings
|
||||||
|
// are accepted; competing targets in the same scope are installation errors.
|
||||||
|
func FlagAliasFor(commandPath, spelling, target string) RoutingRule {
|
||||||
|
record := &routingRuleRecord{
|
||||||
|
kind: scopedFlagAliasRule,
|
||||||
|
sourceText: spelling,
|
||||||
|
targetText: target,
|
||||||
|
scopeText: commandPath,
|
||||||
|
}
|
||||||
|
record.scopePath = captureRoutingPath("command scope", commandPath, &record.issues)
|
||||||
|
record.sourceFlag = captureFlagSpelling("source", spelling, &record.issues)
|
||||||
|
record.targetFlag = captureFlagSpelling("target", target, &record.issues)
|
||||||
|
return RoutingRule{record: record}
|
||||||
|
}
|
||||||
|
|
||||||
|
func captureRoutingPath(label, value string, issues *[]error) commandpath.Path {
|
||||||
|
path, err := commandpath.ParseDeclaration(value)
|
||||||
|
if err != nil {
|
||||||
|
*issues = append(*issues, fmt.Errorf("invalid %s path: %w", label, err))
|
||||||
|
}
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
func captureFlagSpelling(label, value string, issues *[]error) capturedFlagSpelling {
|
||||||
|
spelling, err := parseFlagSpelling(value)
|
||||||
|
if err != nil {
|
||||||
|
*issues = append(*issues, fmt.Errorf("invalid %s flag spelling %q: %w", label, value, err))
|
||||||
|
}
|
||||||
|
return spelling
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseFlagSpelling(value string) (capturedFlagSpelling, error) {
|
||||||
|
if strings.HasPrefix(value, "--") {
|
||||||
|
name := value[2:]
|
||||||
|
if validFlagName(name) {
|
||||||
|
return capturedFlagSpelling{name: name}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(value) == 2 && value[0] == '-' && value[1] != '-' {
|
||||||
|
return capturedFlagSpelling{name: value[1:], short: true}, nil
|
||||||
|
}
|
||||||
|
return capturedFlagSpelling{}, fmt.Errorf("must be a long flag or one-byte short flag spelling")
|
||||||
|
}
|
||||||
|
|
||||||
func captureComposition(record *commandRecord) {
|
func captureComposition(record *commandRecord) {
|
||||||
if err := validateCommandName(record.name); err != nil {
|
if err := validateCommandName(record.name); err != nil {
|
||||||
record.issues = append(record.issues, err)
|
record.issues = append(record.issues, err)
|
||||||
@@ -297,7 +448,7 @@ func collectDeclarationIssues(command Command, parents []string, root bool, issu
|
|||||||
}
|
}
|
||||||
record := command.record
|
record := command.record
|
||||||
path := append(append([]string(nil), parents...), record.name)
|
path := append(append([]string(nil), parents...), record.name)
|
||||||
label := strings.Join(path, " ")
|
label := commandpath.FromTokens(path).String()
|
||||||
for _, issue := range record.issues {
|
for _, issue := range record.issues {
|
||||||
if label != "" {
|
if label != "" {
|
||||||
*issues = append(*issues, fmt.Errorf("command %q: %w", label, issue))
|
*issues = append(*issues, fmt.Errorf("command %q: %w", label, issue))
|
||||||
@@ -320,11 +471,7 @@ func collectDeclarationIssues(command Command, parents []string, root bool, issu
|
|||||||
}
|
}
|
||||||
|
|
||||||
func validateCommandName(name string) error {
|
func validateCommandName(name string) error {
|
||||||
fields := strings.Fields(name)
|
return commandpath.ValidateComponent(name)
|
||||||
if len(fields) != 1 || fields[0] != name || strings.HasPrefix(name, "-") || name == "--" {
|
|
||||||
return fmt.Errorf("command name %q must be one non-option token", name)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateBindings[I any](flags []Flag[I], arguments []Argument[I]) error {
|
func validateBindings[I any](flags []Flag[I], arguments []Argument[I]) error {
|
||||||
@@ -338,6 +485,9 @@ func validateBindings[I any](flags []Flag[I], arguments []Argument[I]) error {
|
|||||||
if binding.meta.Name == "help" {
|
if binding.meta.Name == "help" {
|
||||||
return fmt.Errorf("flag name %q is reserved for help", binding.meta.Name)
|
return fmt.Errorf("flag name %q is reserved for help", binding.meta.Name)
|
||||||
}
|
}
|
||||||
|
if commandmeta.IsReportingFlag(binding.meta.Name) {
|
||||||
|
return fmt.Errorf("flag name %q is reserved for reporting", binding.meta.Name)
|
||||||
|
}
|
||||||
if _, exists := flagNames[binding.meta.Name]; exists {
|
if _, exists := flagNames[binding.meta.Name]; exists {
|
||||||
return fmt.Errorf("duplicate flag name %q", binding.meta.Name)
|
return fmt.Errorf("duplicate flag name %q", binding.meta.Name)
|
||||||
}
|
}
|
||||||
|
|||||||
119
completion.go
Normal file
119
completion.go
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
package commandkit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
"unicode"
|
||||||
|
|
||||||
|
internalcobra "go.maximumdirect.net/commandkit/internal/cobra"
|
||||||
|
commandmeta "go.maximumdirect.net/commandkit/internal/commandmeta"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Completion declares opt-in shell script generation for the executable
|
||||||
|
// basename used to invoke the application. Install it at the application root.
|
||||||
|
// Generated shell identifiers support letters, digits, underscores, dots,
|
||||||
|
// hyphens, and colons; a leading hyphen is not supported.
|
||||||
|
func Completion(executable string) Command {
|
||||||
|
children := make([]Command, 0, 3)
|
||||||
|
for _, shell := range []string{"bash", "zsh", "fish"} {
|
||||||
|
shell := shell
|
||||||
|
children = append(children, NewCommand(CommandSpec[struct{}]{
|
||||||
|
Name: shell, Summary: "Generate " + shell + " completion script.",
|
||||||
|
Run: func(invocation Invocation, _ struct{}) Outcome {
|
||||||
|
_ = invocation.Reporter().RenderOutput(func(writer io.Writer) error {
|
||||||
|
return internalcobra.CompletionScript(writer, executable, shell)
|
||||||
|
})
|
||||||
|
return Success()
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
command := RootGroup("completion", "Generate shell completion scripts.", children...)
|
||||||
|
command.record.completion = true
|
||||||
|
if !validCompletionExecutable(executable) {
|
||||||
|
return WithError(command, fmt.Errorf("unsupported completion executable basename %q", executable))
|
||||||
|
}
|
||||||
|
return command
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cobra emits the basename into shell identifiers and unquoted command words.
|
||||||
|
func validCompletionExecutable(name string) bool {
|
||||||
|
if name == "" || name == "." || name == ".." || strings.HasPrefix(name, "-") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, ch := range name {
|
||||||
|
if !(unicode.IsLetter(ch) || unicode.IsDigit(ch) || ch == '_' || ch == '-' || ch == ':' || ch == '.') {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// CompletionChoice is a literal suggested value and its optional description.
|
||||||
|
type CompletionChoice struct {
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// CompletionHint selects suggestions for a flag or positional value. Its zero
|
||||||
|
// value requests no value suggestions; hints do not constrain accepted input.
|
||||||
|
type CompletionHint struct {
|
||||||
|
kind completionHintKind
|
||||||
|
choices *completionChoicesData
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep the public metadata structs comparable without exposing mutable choices.
|
||||||
|
type completionChoicesData struct {
|
||||||
|
values []CompletionChoice
|
||||||
|
}
|
||||||
|
|
||||||
|
type completionHintKind uint8
|
||||||
|
|
||||||
|
const (
|
||||||
|
completionNone completionHintKind = iota
|
||||||
|
completionChoices
|
||||||
|
completionFiles
|
||||||
|
completionDirectories
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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 {
|
||||||
|
hint := CompletionHint{kind: completionChoices}
|
||||||
|
if len(choices) != 0 {
|
||||||
|
hint.choices = &completionChoicesData{values: append([]CompletionChoice(nil), choices...)}
|
||||||
|
}
|
||||||
|
return hint
|
||||||
|
}
|
||||||
|
|
||||||
|
// CompleteFiles requests shell filename completion.
|
||||||
|
func CompleteFiles() CompletionHint { return CompletionHint{kind: completionFiles} }
|
||||||
|
|
||||||
|
// CompleteDirectories requests shell directory completion.
|
||||||
|
func CompleteDirectories() CompletionHint { return CompletionHint{kind: completionDirectories} }
|
||||||
|
|
||||||
|
// NoCompletion requests no value suggestions.
|
||||||
|
func NoCompletion() CompletionHint { return CompletionHint{} }
|
||||||
|
|
||||||
|
func bridgeCompletion(hint CompletionHint) commandmeta.CompletionHint {
|
||||||
|
result := commandmeta.CompletionHint{}
|
||||||
|
switch hint.kind {
|
||||||
|
case completionChoices:
|
||||||
|
result.Kind = commandmeta.CompletionChoices
|
||||||
|
if hint.choices == nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
for _, choice := range hint.choices.values {
|
||||||
|
result.Choices = append(result.Choices, commandmeta.CompletionChoice{Value: choice.Value, Description: choice.Description})
|
||||||
|
}
|
||||||
|
case completionFiles:
|
||||||
|
result.Kind = commandmeta.CompletionFiles
|
||||||
|
case completionDirectories:
|
||||||
|
result.Kind = commandmeta.CompletionDirectories
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
78
completion_context.go
Normal file
78
completion_context.go
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
package commandkit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
internalcobra "go.maximumdirect.net/commandkit/internal/cobra"
|
||||||
|
commandmeta "go.maximumdirect.net/commandkit/internal/commandmeta"
|
||||||
|
"go.maximumdirect.net/commandkit/internal/commandpath"
|
||||||
|
)
|
||||||
|
|
||||||
|
type completionContext struct {
|
||||||
|
route routeResult
|
||||||
|
command *commandmeta.Command
|
||||||
|
aliases []commandmeta.FlagAlias
|
||||||
|
completed []string
|
||||||
|
tail []string
|
||||||
|
current string
|
||||||
|
commandPosition bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveCompletionContext(snapshot *routingSnapshot, completed []string, current string) completionContext {
|
||||||
|
context := completionContext{completed: append([]string(nil), completed...), current: current}
|
||||||
|
if snapshot == nil {
|
||||||
|
return context
|
||||||
|
}
|
||||||
|
context.route = resolveEnteredRoute(completed, snapshot)
|
||||||
|
switch context.route.kind {
|
||||||
|
case routeCanonical:
|
||||||
|
context.command = completionCommand(snapshot, context.route.canonicalPath)
|
||||||
|
if context.command == nil || len(context.route.args) < len(context.route.canonicalPath) {
|
||||||
|
context.command = nil
|
||||||
|
return context
|
||||||
|
}
|
||||||
|
context.tail = append([]string(nil), context.route.args[len(context.route.canonicalPath):]...)
|
||||||
|
context.aliases = snapshot.flagAliases[context.route.canonicalPath.Key()].lookupRecords()
|
||||||
|
context.commandPosition = len(context.tail) == 0
|
||||||
|
case routeDiscovery:
|
||||||
|
context.command = &commandmeta.Command{CoreFlags: commandmeta.ReportingFlags()}
|
||||||
|
context.tail = append([]string(nil), context.route.args...)
|
||||||
|
context.aliases = snapshot.discoveryFlags[context.route.discoveryPath.Key()].lookupRecords()
|
||||||
|
}
|
||||||
|
return context
|
||||||
|
}
|
||||||
|
|
||||||
|
func completionCommand(snapshot *routingSnapshot, path commandpath.Path) *commandmeta.Command {
|
||||||
|
if len(path) == 0 {
|
||||||
|
return snapshot.metadata
|
||||||
|
}
|
||||||
|
children := snapshot.canonical
|
||||||
|
var node *canonicalNode
|
||||||
|
for _, name := range path {
|
||||||
|
node = children[name]
|
||||||
|
if node == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
children = node.children
|
||||||
|
}
|
||||||
|
return node.metadata
|
||||||
|
}
|
||||||
|
|
||||||
|
func completionChildren(snapshot *routingSnapshot, context completionContext) []commandmeta.CompletionChoice {
|
||||||
|
if snapshot == nil || context.command == nil || !context.commandPosition {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var choices []commandmeta.CompletionChoice
|
||||||
|
for _, child := range context.command.Children {
|
||||||
|
if !strings.HasPrefix(child.Name, context.current) || (len(context.route.canonicalPath) == 0 && child.Name == internalcobra.CompletionQueryToken) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
entered := append(append([]string(nil), context.completed...), child.Name)
|
||||||
|
route := resolveEnteredRoute(entered, snapshot)
|
||||||
|
intended := append(context.route.canonicalPath.Clone(), child.Name)
|
||||||
|
if route.kind == routeCanonical && route.canonicalPath.Equal(intended) {
|
||||||
|
choices = append(choices, commandmeta.CompletionChoice{Value: child.Name, Description: child.Summary})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return choices
|
||||||
|
}
|
||||||
89
completion_context_test.go
Normal file
89
completion_context_test.go
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
package commandkit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"cmp"
|
||||||
|
"reflect"
|
||||||
|
"slices"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
commandmeta "go.maximumdirect.net/commandkit/internal/commandmeta"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCompletionCommandContext(t *testing.T) {
|
||||||
|
parse := func(value string) (string, error) { return value, nil }
|
||||||
|
mixed := NewCommand(CommandSpec[struct{}]{
|
||||||
|
Name: "mixed", Children: []Command{simpleRunnable("child")},
|
||||||
|
Arguments: []Argument[struct{}]{ValueArgument(ArgumentMeta{Name: "item"}, false, parse, func(*struct{}, Supplied[string]) {})},
|
||||||
|
Run: func(Invocation, struct{}) Outcome { return Success() },
|
||||||
|
})
|
||||||
|
app, err := New(Identity{Name: "example"},
|
||||||
|
Group("workspace", "", Group("list", "", simpleRunnable("shadow"), simpleRunnable("show"))),
|
||||||
|
simpleRunnable("other"), mixed, Completion("example"),
|
||||||
|
Alias("ws", "workspace"), Alias("w", "ws"), Alias("ws list shadow", "other"),
|
||||||
|
Alias("legacy inspect", "workspace list"), FlagAlias("--silent", "--quiet"),
|
||||||
|
FlagAliasFor("workspace list", "--detail", "--verbose"),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
snapshot := app.routeSnapshot()
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
completed []string
|
||||||
|
current string
|
||||||
|
path string
|
||||||
|
position bool
|
||||||
|
choices []string
|
||||||
|
kind routeKind
|
||||||
|
}{
|
||||||
|
{"root", nil, "work", "", true, []string{"workspace"}, routeCanonical},
|
||||||
|
{"group", []string{"workspace"}, "li", "workspace", true, []string{"list"}, routeCanonical},
|
||||||
|
{"entered alias", []string{"ws", "list"}, "sh", "workspace list", true, []string{"show"}, routeCanonical},
|
||||||
|
{"alias chain", []string{"w", "list"}, "sh", "workspace list", true, []string{"show"}, routeCanonical},
|
||||||
|
{"discovery", []string{"legacy"}, "in", "", false, nil, routeDiscovery},
|
||||||
|
{"complete alias", []string{"legacy", "inspect"}, "sh", "workspace list", true, []string{"shadow", "show"}, routeCanonical},
|
||||||
|
{"runnable child", []string{"mixed"}, "ch", "mixed", true, []string{"child"}, routeCanonical},
|
||||||
|
{"unmatched child", []string{"mixed"}, "other", "mixed", true, nil, routeCanonical},
|
||||||
|
{"runnable operand", []string{"mixed", "value"}, "ch", "mixed", false, nil, routeCanonical},
|
||||||
|
{"flag ends path", []string{"mixed", "--quiet"}, "ch", "mixed", false, nil, routeCanonical},
|
||||||
|
{"flag before child", []string{"workspace", "--quiet"}, "li", "workspace", false, nil, routeCanonical},
|
||||||
|
{"unknown", []string{"missing"}, "x", "", false, nil, routeUnknown},
|
||||||
|
{"hidden endpoint", nil, "__comp", "", true, nil, routeCanonical},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
original := append([]string(nil), tc.completed...)
|
||||||
|
context := resolveCompletionContext(snapshot, tc.completed, tc.current)
|
||||||
|
if context.route.kind != tc.kind || context.route.canonicalPath.String() != tc.path || context.commandPosition != tc.position || !reflect.DeepEqual(tc.completed, original) {
|
||||||
|
t.Fatalf("context=%#v completed=%q", context, tc.completed)
|
||||||
|
}
|
||||||
|
var names []string
|
||||||
|
for _, choice := range completionChildren(snapshot, context) {
|
||||||
|
names = append(names, choice.Value)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(names, tc.choices) {
|
||||||
|
t.Fatalf("children=%q, want %q", names, tc.choices)
|
||||||
|
}
|
||||||
|
if tc.kind == routeDiscovery && !reflect.DeepEqual(context.aliases, []commandmeta.FlagAlias{{Source: "--silent", Target: "--quiet"}}) {
|
||||||
|
t.Fatalf("discovery aliases=%#v", context.aliases)
|
||||||
|
}
|
||||||
|
if (tc.name == "runnable child" || tc.name == "unmatched child") && (context.command == nil || len(context.command.Arguments) != 1) {
|
||||||
|
t.Fatalf("runnable operand metadata missing: %#v", context.command)
|
||||||
|
}
|
||||||
|
if tc.name == "entered alias" {
|
||||||
|
actual := slices.Clone(context.aliases)
|
||||||
|
expected := []commandmeta.FlagAlias{{Source: "--detail", Target: "--verbose"}, {Source: "--silent", Target: "--quiet"}}
|
||||||
|
compare := func(a, b commandmeta.FlagAlias) int {
|
||||||
|
if result := cmp.Compare(a.Source, b.Source); result != 0 {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
return cmp.Compare(a.Target, b.Target)
|
||||||
|
}
|
||||||
|
slices.SortFunc(actual, compare)
|
||||||
|
slices.SortFunc(expected, compare)
|
||||||
|
if !slices.Equal(actual, expected) {
|
||||||
|
t.Fatalf("canonical aliases=%#v, want %#v", actual, expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
217
completion_flags.go
Normal file
217
completion_flags.go
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
package commandkit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
commandmeta "go.maximumdirect.net/commandkit/internal/commandmeta"
|
||||||
|
)
|
||||||
|
|
||||||
|
type completionTargetKind uint8
|
||||||
|
|
||||||
|
const (
|
||||||
|
completionTargetNone completionTargetKind = iota
|
||||||
|
completionTargetCommands
|
||||||
|
completionTargetFlags
|
||||||
|
completionTargetValue
|
||||||
|
completionTargetOperand
|
||||||
|
)
|
||||||
|
|
||||||
|
type completionTarget struct {
|
||||||
|
kind completionTargetKind
|
||||||
|
choices []commandmeta.CompletionChoice
|
||||||
|
hint commandmeta.CompletionHint
|
||||||
|
prefix string
|
||||||
|
valuePrefix string
|
||||||
|
}
|
||||||
|
|
||||||
|
type completionFlagState struct {
|
||||||
|
supplied map[string]bool
|
||||||
|
operands []string
|
||||||
|
pending *commandmeta.Flag
|
||||||
|
ended bool
|
||||||
|
invalid bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func completionFlags(context completionContext) []commandmeta.Flag {
|
||||||
|
flags := make([]commandmeta.Flag, 0, len(context.command.Flags)+len(context.command.CoreFlags)+1)
|
||||||
|
flags = append(flags, commandmeta.HelpFlag())
|
||||||
|
flags = append(flags, context.command.CoreFlags...)
|
||||||
|
flags = append(flags, context.command.Flags...)
|
||||||
|
return flags
|
||||||
|
}
|
||||||
|
|
||||||
|
func walkCompletionFlags(tokens []string, lookup commandmeta.FlagLookup) completionFlagState {
|
||||||
|
state := completionFlagState{supplied: make(map[string]bool)}
|
||||||
|
for index := 0; index < len(tokens); index++ {
|
||||||
|
token := tokens[index]
|
||||||
|
if state.ended {
|
||||||
|
state.operands = append(state.operands, token)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch commandmeta.ClassifyFlagToken(token) {
|
||||||
|
case commandmeta.TerminatorToken:
|
||||||
|
state.ended = true
|
||||||
|
case commandmeta.OperandToken:
|
||||||
|
state.operands = append(state.operands, token)
|
||||||
|
case commandmeta.LongFlagToken:
|
||||||
|
spelling, _, attached := commandmeta.SplitLongFlag(token)
|
||||||
|
flag, ok := lookup.Find(spelling)
|
||||||
|
if !ok {
|
||||||
|
state.invalid = true
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
state.supplied[flag.Name] = true
|
||||||
|
if !flag.Boolean && !attached {
|
||||||
|
if index+1 == len(tokens) {
|
||||||
|
state.pending = &flag
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
index++
|
||||||
|
}
|
||||||
|
case commandmeta.ShortFlagToken:
|
||||||
|
for offset := 1; offset < len(token); offset++ {
|
||||||
|
flag, ok := lookup.Find("-" + token[offset:offset+1])
|
||||||
|
if !ok {
|
||||||
|
state.invalid = true
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
state.supplied[flag.Name] = true
|
||||||
|
suffix := token[offset+1:]
|
||||||
|
if flag.Boolean {
|
||||||
|
if strings.HasPrefix(suffix, "=") {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if suffix == "" {
|
||||||
|
if index+1 == len(tokens) {
|
||||||
|
state.pending = &flag
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
index++
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
|
||||||
|
func completionTargetFor(snapshot *routingSnapshot, context completionContext) completionTarget {
|
||||||
|
if context.command == nil {
|
||||||
|
return completionTarget{}
|
||||||
|
}
|
||||||
|
flags := completionFlags(context)
|
||||||
|
lookup := commandmeta.NewFlagLookup(flags, context.aliases)
|
||||||
|
state := walkCompletionFlags(context.tail, lookup)
|
||||||
|
if state.invalid {
|
||||||
|
return completionTarget{}
|
||||||
|
}
|
||||||
|
if state.pending != nil {
|
||||||
|
return completionTarget{kind: completionTargetValue, hint: state.pending.Completion, valuePrefix: context.current}
|
||||||
|
}
|
||||||
|
if !state.ended && context.commandPosition {
|
||||||
|
if children := completionChildren(snapshot, context); len(children) > 0 {
|
||||||
|
return completionTarget{kind: completionTargetCommands, choices: children}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !state.ended && strings.HasPrefix(context.current, "-") {
|
||||||
|
return completionOptionTarget(context.current, flags, lookup, state.supplied)
|
||||||
|
}
|
||||||
|
if hint, ok := completionOperandHint(context.command.Arguments, len(state.operands)); ok {
|
||||||
|
return completionTarget{kind: completionTargetOperand, hint: hint, valuePrefix: context.current}
|
||||||
|
}
|
||||||
|
if !state.ended && context.current == "" {
|
||||||
|
return completionTarget{kind: completionTargetFlags, choices: completionFlagNames(flags, state.supplied, "")}
|
||||||
|
}
|
||||||
|
return completionTarget{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func completionOptionTarget(current string, flags []commandmeta.Flag, lookup commandmeta.FlagLookup, supplied map[string]bool) completionTarget {
|
||||||
|
if current == "-" {
|
||||||
|
return completionTarget{kind: completionTargetFlags, choices: completionFlagNames(flags, supplied, current)}
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(current, "--") {
|
||||||
|
spelling, value, attached := commandmeta.SplitLongFlag(current)
|
||||||
|
if attached {
|
||||||
|
if flag, ok := lookup.Find(spelling); ok {
|
||||||
|
return completionTarget{kind: completionTargetValue, hint: flag.Completion, prefix: spelling + "=", valuePrefix: value}
|
||||||
|
}
|
||||||
|
return completionTarget{}
|
||||||
|
}
|
||||||
|
return completionTarget{kind: completionTargetFlags, choices: completionFlagNames(flags, supplied, current)}
|
||||||
|
}
|
||||||
|
used := make(map[string]bool, len(supplied))
|
||||||
|
for name, value := range supplied {
|
||||||
|
used[name] = value
|
||||||
|
}
|
||||||
|
for offset := 1; offset < len(current); offset++ {
|
||||||
|
flag, ok := lookup.Find("-" + current[offset:offset+1])
|
||||||
|
if !ok {
|
||||||
|
return completionTarget{}
|
||||||
|
}
|
||||||
|
used[flag.Name] = true
|
||||||
|
suffix := current[offset+1:]
|
||||||
|
if flag.Boolean {
|
||||||
|
if strings.HasPrefix(suffix, "=") {
|
||||||
|
return completionTarget{kind: completionTargetValue, hint: flag.Completion, prefix: current[:offset+2], valuePrefix: suffix[1:]}
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
prefix := current[:offset+1]
|
||||||
|
if strings.HasPrefix(suffix, "=") {
|
||||||
|
prefix += "="
|
||||||
|
suffix = suffix[1:]
|
||||||
|
}
|
||||||
|
return completionTarget{kind: completionTargetValue, hint: flag.Completion, prefix: prefix, valuePrefix: suffix}
|
||||||
|
}
|
||||||
|
var choices []commandmeta.CompletionChoice
|
||||||
|
for _, flag := range flags {
|
||||||
|
if flag.Short != "" && completionFlagEligible(flag, used) {
|
||||||
|
choices = append(choices, commandmeta.CompletionChoice{Value: current + flag.Short, Description: flag.Usage})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return completionTarget{kind: completionTargetFlags, choices: choices}
|
||||||
|
}
|
||||||
|
|
||||||
|
func completionFlagNames(flags []commandmeta.Flag, supplied map[string]bool, prefix string) []commandmeta.CompletionChoice {
|
||||||
|
var choices []commandmeta.CompletionChoice
|
||||||
|
for _, flag := range flags {
|
||||||
|
if !completionFlagEligible(flag, supplied) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, spelling := range []string{"--" + flag.Name, shortCompletionSpelling(flag.Short)} {
|
||||||
|
if spelling != "" && strings.HasPrefix(spelling, prefix) {
|
||||||
|
choices = append(choices, commandmeta.CompletionChoice{Value: spelling, Description: flag.Usage})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return choices
|
||||||
|
}
|
||||||
|
|
||||||
|
func completionFlagEligible(flag commandmeta.Flag, supplied map[string]bool) bool {
|
||||||
|
return !supplied[flag.Name] || flag.Repeated || commandmeta.IsReportingFlag(flag.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func shortCompletionSpelling(short string) string {
|
||||||
|
if short == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return "-" + short
|
||||||
|
}
|
||||||
|
|
||||||
|
func completionOperandHint(arguments []commandmeta.Argument, completed int) (commandmeta.CompletionHint, bool) {
|
||||||
|
total := completed + 1
|
||||||
|
counts, err := allocateOperandCounts(total, arguments, false)
|
||||||
|
if err != nil {
|
||||||
|
return commandmeta.CompletionHint{}, false
|
||||||
|
}
|
||||||
|
position := 0
|
||||||
|
for index, count := range counts {
|
||||||
|
position += count
|
||||||
|
if position >= total {
|
||||||
|
return arguments[index].Completion, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return commandmeta.CompletionHint{}, false
|
||||||
|
}
|
||||||
146
completion_flags_test.go
Normal file
146
completion_flags_test.go
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
package commandkit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
commandmeta "go.maximumdirect.net/commandkit/internal/commandmeta"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCompletionFlagAndValuePositions(t *testing.T) {
|
||||||
|
parse := func(value string) (string, error) { return value, nil }
|
||||||
|
choice := CompleteChoices(CompletionChoice{Value: "blue"}, CompletionChoice{Value: "green"})
|
||||||
|
command := NewCommand(CommandSpec[struct{}]{
|
||||||
|
Name: "run",
|
||||||
|
Flags: []Flag[struct{}]{
|
||||||
|
ValueFlag(FlagMeta{Name: "color", Short: "c", Completion: choice}, (*string)(nil), parse, func(*struct{}, Supplied[string]) {}),
|
||||||
|
RepeatFlag(FlagMeta{Name: "tag", Short: "t", Completion: choice}, nil, parse, func(*struct{}, Supplied[[]string]) {}),
|
||||||
|
BoolFlag(FlagMeta{Name: "switch", Short: "s"}, false, func(*struct{}, Supplied[bool]) {}),
|
||||||
|
},
|
||||||
|
Run: func(Invocation, struct{}) Outcome { return Success() },
|
||||||
|
})
|
||||||
|
app, err := New(Identity{Name: "example"}, command,
|
||||||
|
FlagAliasFor("run", "--colour", "--color"), FlagAliasFor("run", "-C", "--color"), FlagAlias("-v", "--verbose"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
snapshot := app.routeSnapshot()
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name, current, prefix, value string
|
||||||
|
completed []string
|
||||||
|
kind completionTargetKind
|
||||||
|
hint commandmeta.CompletionKind
|
||||||
|
}{
|
||||||
|
{"pending takes option", "--quiet", "", "--quiet", []string{"run", "--color"}, completionTargetValue, commandmeta.CompletionChoices},
|
||||||
|
{"terminator consumed as value", "-x", "", "-x", []string{"run", "--color", "--", "--tag"}, completionTargetValue, commandmeta.CompletionChoices},
|
||||||
|
{"repeated singleton value", "--color=gr", "--color=", "gr", []string{"run", "--color=blue"}, completionTargetValue, commandmeta.CompletionChoices},
|
||||||
|
{"long alias value", "--colour=bl", "--colour=", "bl", []string{"run"}, completionTargetValue, commandmeta.CompletionChoices},
|
||||||
|
{"long empty value", "--color=", "--color=", "", []string{"run"}, completionTargetValue, commandmeta.CompletionChoices},
|
||||||
|
{"short alias value", "-sCbl", "-sC", "bl", []string{"run"}, completionTargetValue, commandmeta.CompletionChoices},
|
||||||
|
{"short equals", "-sC=bl", "-sC=", "bl", []string{"run"}, completionTargetValue, commandmeta.CompletionChoices},
|
||||||
|
{"reporting boolean", "--verbose=f", "--verbose=", "f", []string{"run"}, completionTargetValue, commandmeta.CompletionChoices},
|
||||||
|
{"short boolean alias", "-v=f", "-v=", "f", []string{"run"}, completionTargetValue, commandmeta.CompletionChoices},
|
||||||
|
{"application boolean", "--switch=f", "--switch=", "f", []string{"run"}, completionTargetValue, commandmeta.CompletionNone},
|
||||||
|
{"scoped alias unavailable at root", "--colour=bl", "", "", nil, completionTargetNone, commandmeta.CompletionNone},
|
||||||
|
{"unknown completed", "--c", "", "", []string{"run", "--unknown"}, completionTargetNone, commandmeta.CompletionNone},
|
||||||
|
{"unknown attached", "--unknown=x", "", "", []string{"run"}, completionTargetNone, commandmeta.CompletionNone},
|
||||||
|
{"terminator", "--color", "", "", []string{"run", "--"}, completionTargetNone, commandmeta.CompletionNone},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
target := completionTargetFor(snapshot, resolveCompletionContext(snapshot, tc.completed, tc.current))
|
||||||
|
if target.kind != tc.kind || target.hint.Kind != tc.hint || target.prefix != tc.prefix || target.valuePrefix != tc.value {
|
||||||
|
t.Fatalf("target=%#v", target)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
context := resolveCompletionContext(snapshot, []string{"run", "--colour=blue", "--tag=one", "--verbose"}, "--")
|
||||||
|
target := completionTargetFor(snapshot, context)
|
||||||
|
names := completionChoiceNames(target.choices)
|
||||||
|
if containsCompletionName(names, "--color") || !containsCompletionName(names, "--tag") || !containsCompletionName(names, "--verbose") {
|
||||||
|
t.Fatalf("flag suggestions=%q", names)
|
||||||
|
}
|
||||||
|
cluster := completionTargetFor(snapshot, resolveCompletionContext(snapshot, []string{"run"}, "-sv"))
|
||||||
|
if cluster.kind != completionTargetFlags || !containsCompletionName(completionChoiceNames(cluster.choices), "-svc") || containsCompletionName(completionChoiceNames(cluster.choices), "-svs") {
|
||||||
|
t.Fatalf("short cluster=%#v", cluster)
|
||||||
|
}
|
||||||
|
dash := completionTargetFor(snapshot, resolveCompletionContext(snapshot, []string{"run"}, "-"))
|
||||||
|
if !containsCompletionName(completionChoiceNames(dash.choices), "--color") || !containsCompletionName(completionChoiceNames(dash.choices), "-c") {
|
||||||
|
t.Fatalf("dash suggestions=%#v", dash)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCompletionOperandAllocation(t *testing.T) {
|
||||||
|
arguments := []commandmeta.Argument{
|
||||||
|
{Name: "optional", Min: 0, Max: 1, Completion: commandmeta.CompletionHint{Kind: commandmeta.CompletionFiles}},
|
||||||
|
{Name: "repeated", Min: 0, Max: -1, Completion: commandmeta.CompletionHint{Kind: commandmeta.CompletionDirectories}},
|
||||||
|
{Name: "required", Min: 1, Max: 1, Completion: commandmeta.BooleanCompletion()},
|
||||||
|
}
|
||||||
|
for _, tc := range []struct {
|
||||||
|
completed int
|
||||||
|
want commandmeta.CompletionKind
|
||||||
|
}{
|
||||||
|
{0, commandmeta.CompletionChoices},
|
||||||
|
{1, commandmeta.CompletionChoices},
|
||||||
|
{2, commandmeta.CompletionChoices},
|
||||||
|
} {
|
||||||
|
hint, ok := completionOperandHint(arguments, tc.completed)
|
||||||
|
if !ok || hint.Kind != tc.want {
|
||||||
|
t.Fatalf("%d operands: %#v, %v", tc.completed, hint, ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
bounded := []commandmeta.Argument{{Name: "only", Max: 1, Completion: commandmeta.BooleanCompletion()}}
|
||||||
|
if _, ok := completionOperandHint(bounded, 1); ok {
|
||||||
|
t.Fatal("excess operand received a hint")
|
||||||
|
}
|
||||||
|
trailing := []commandmeta.Argument{{Name: "first", Min: 1, Max: 1, Completion: commandmeta.BooleanCompletion()}, {Name: "rest", Min: 0, Max: -1, Completion: commandmeta.CompletionHint{Kind: commandmeta.CompletionDirectories}}}
|
||||||
|
if hint, ok := completionOperandHint(trailing, 1); !ok || hint.Kind != commandmeta.CompletionDirectories {
|
||||||
|
t.Fatalf("repeated trailing operand: %#v, %v", hint, ok)
|
||||||
|
}
|
||||||
|
if got, want := completionChoiceNames(commandmeta.BooleanCompletion().Choices), []string{"true", "false"}; !reflect.DeepEqual(got, want) {
|
||||||
|
t.Fatalf("boolean choices=%q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCompletionOperandAndFlagContexts(t *testing.T) {
|
||||||
|
command := &commandmeta.Command{
|
||||||
|
Arguments: []commandmeta.Argument{{Name: "item", Min: 0, Max: 1, Completion: commandmeta.CompletionHint{Kind: commandmeta.CompletionFiles}}},
|
||||||
|
Flags: []commandmeta.Flag{{Name: "name", Completion: commandmeta.BooleanCompletion()}},
|
||||||
|
}
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name, current string
|
||||||
|
tail []string
|
||||||
|
kind completionTargetKind
|
||||||
|
hint commandmeta.CompletionKind
|
||||||
|
}{
|
||||||
|
{"empty chooses operand", "", nil, completionTargetOperand, commandmeta.CompletionFiles},
|
||||||
|
{"dash chooses flag", "--n", nil, completionTargetFlags, commandmeta.CompletionNone},
|
||||||
|
{"after terminator", "--n", []string{"--"}, completionTargetOperand, commandmeta.CompletionFiles},
|
||||||
|
{"interspersed flag value", "value", []string{"--name", "--quiet"}, completionTargetOperand, commandmeta.CompletionFiles},
|
||||||
|
{"excess operand", "value", []string{"already"}, completionTargetNone, commandmeta.CompletionNone},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
target := completionTargetFor(nil, completionContext{command: command, tail: tc.tail, current: tc.current})
|
||||||
|
if target.kind != tc.kind || target.hint.Kind != tc.hint {
|
||||||
|
t.Fatalf("target=%#v", target)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func completionChoiceNames(choices []commandmeta.CompletionChoice) []string {
|
||||||
|
var names []string
|
||||||
|
for _, choice := range choices {
|
||||||
|
names = append(names, choice.Value)
|
||||||
|
}
|
||||||
|
return names
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsCompletionName(names []string, want string) bool {
|
||||||
|
for _, name := range names {
|
||||||
|
if name == want {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
108
completion_install_test.go
Normal file
108
completion_install_test.go
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
package commandkit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCompletionDeclarationGeneratesScripts(t *testing.T) {
|
||||||
|
for _, name := range []string{"app", "my-app", "tool.exe", "café"} {
|
||||||
|
if !validCompletionExecutable(name) {
|
||||||
|
t.Fatalf("supported executable rejected: %q", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
app, err := New(Identity{Name: "Human Readable Name"}, Completion("my-app"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !app.routeSnapshot().completion {
|
||||||
|
t.Fatal("completion not present in snapshot")
|
||||||
|
}
|
||||||
|
for _, shell := range []string{"bash", "zsh", "fish"} {
|
||||||
|
var output, diagnostics bytes.Buffer
|
||||||
|
outcome := app.Execute(context.Background(), []string{"completion", shell}, Streams{Out: &output, Err: &diagnostics})
|
||||||
|
if outcome.Status != 0 || !strings.Contains(output.String(), "my-app") || strings.Contains(output.String(), "Human Readable Name") {
|
||||||
|
t.Fatalf("%s outcome=%#v output=%q diagnostics=%q", shell, outcome, output.String(), diagnostics.String())
|
||||||
|
}
|
||||||
|
output.Reset()
|
||||||
|
outcome = app.Execute(context.Background(), []string{"completion", shell, "--quiet"}, Streams{Out: &output, Err: &diagnostics})
|
||||||
|
if outcome.Status != 0 || output.Len() != 0 {
|
||||||
|
t.Fatalf("quiet %s outcome=%#v output=%q", shell, outcome, output.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var help bytes.Buffer
|
||||||
|
outcome := app.Execute(context.Background(), []string{"--help"}, Streams{Out: &help})
|
||||||
|
if outcome.Status != 0 || !strings.Contains(help.String(), "completion") || strings.Contains(help.String(), "__complete") {
|
||||||
|
t.Fatalf("help outcome=%#v output=%q", outcome, help.String())
|
||||||
|
}
|
||||||
|
writeErr := errors.New("destination failed")
|
||||||
|
outcome = app.Execute(context.Background(), []string{"completion", "bash"}, Streams{Out: completionErrorWriter{writeErr}, Err: io.Discard})
|
||||||
|
if outcome.Status != 1 || !errors.Is(outcome.Presentation.Output.WriteError, writeErr) {
|
||||||
|
t.Fatalf("script delivery failure: %#v", outcome)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type completionErrorWriter struct{ err error }
|
||||||
|
|
||||||
|
func (w completionErrorWriter) Write([]byte) (int, error) { return 0, w.err }
|
||||||
|
|
||||||
|
func TestCompletionInstallationCollisionsAndRollback(t *testing.T) {
|
||||||
|
run := NewCommand(CommandSpec[struct{}]{Name: "run", Run: func(Invocation, struct{}) Outcome { return Success() }})
|
||||||
|
query := NewCommand(CommandSpec[struct{}]{Name: "__complete", Run: func(Invocation, struct{}) Outcome { return Success() }})
|
||||||
|
for _, declarations := range [][]Declaration{
|
||||||
|
{Completion("app"), query},
|
||||||
|
{query, Completion("app")},
|
||||||
|
{Completion("app"), run, Alias("__complete nested", "run")},
|
||||||
|
{run, Alias("__complete nested", "run"), Completion("app")},
|
||||||
|
{Completion("app"), Group("completion", "collision")},
|
||||||
|
{Group("outer", "", Completion("app"))},
|
||||||
|
{Completion("dir/app")},
|
||||||
|
{Completion("two words")},
|
||||||
|
} {
|
||||||
|
if _, err := New(Identity{Name: "example"}, declarations...); err == nil {
|
||||||
|
t.Fatalf("accepted conflicting declarations: %#v", declarations)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
app, err := New(Identity{Name: "example"}, run)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
before := app.routeSnapshot()
|
||||||
|
if err := app.Register(Completion("app"), query); err == nil || app.routeSnapshot() != before {
|
||||||
|
t.Fatalf("failed batch changed snapshot: %v", err)
|
||||||
|
}
|
||||||
|
if err := app.Register(Completion("app")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if app.routeSnapshot() == before || !app.routeSnapshot().completion {
|
||||||
|
t.Fatal("late completion installation did not refresh snapshot")
|
||||||
|
}
|
||||||
|
if err := app.Register(query); err == nil {
|
||||||
|
t.Fatal("query command accepted after completion installation")
|
||||||
|
}
|
||||||
|
if err := app.Register(Alias("__complete nested", "run")); err == nil {
|
||||||
|
t.Fatal("query alias accepted after completion installation")
|
||||||
|
}
|
||||||
|
|
||||||
|
withoutCompletion, err := New(Identity{Name: "example"}, query)
|
||||||
|
if err != nil || withoutCompletion.routeSnapshot().completion {
|
||||||
|
t.Fatalf("ordinary query-named command: %v", err)
|
||||||
|
}
|
||||||
|
if outcome := withoutCompletion.Execute(context.Background(), []string{"__complete"}, Streams{}); outcome.Status != 0 {
|
||||||
|
t.Fatalf("ordinary command outcome: %#v", outcome)
|
||||||
|
}
|
||||||
|
if err := withoutCompletion.Register(Completion("app")); err == nil || withoutCompletion.routeSnapshot().completion {
|
||||||
|
t.Fatal("completion accepted an existing query command")
|
||||||
|
}
|
||||||
|
aliased, err := New(Identity{Name: "example"}, run, Alias("__complete nested", "run"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := aliased.Register(Completion("app")); err == nil || aliased.routeSnapshot().completion {
|
||||||
|
t.Fatal("completion accepted an existing query alias")
|
||||||
|
}
|
||||||
|
}
|
||||||
53
completion_query.go
Normal file
53
completion_query.go
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
package commandkit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
internalcobra "go.maximumdirect.net/commandkit/internal/cobra"
|
||||||
|
commandmeta "go.maximumdirect.net/commandkit/internal/commandmeta"
|
||||||
|
)
|
||||||
|
|
||||||
|
func completeRequest(snapshot *routingSnapshot, words []string) (commandmeta.CompletionResult, error) {
|
||||||
|
if len(words) == 0 {
|
||||||
|
return commandmeta.CompletionResult{}, errors.New("completion request has no current word")
|
||||||
|
}
|
||||||
|
context := resolveCompletionContext(snapshot, words[:len(words)-1], words[len(words)-1])
|
||||||
|
target := completionTargetFor(snapshot, context)
|
||||||
|
result := commandmeta.CompletionResult{Prefix: target.prefix}
|
||||||
|
switch target.kind {
|
||||||
|
case completionTargetCommands, completionTargetFlags:
|
||||||
|
result.Kind = commandmeta.CompletionChoices
|
||||||
|
result.Choices = target.choices
|
||||||
|
case completionTargetValue, completionTargetOperand:
|
||||||
|
result.Kind = target.hint.Kind
|
||||||
|
if target.hint.Kind == commandmeta.CompletionChoices {
|
||||||
|
for _, choice := range target.hint.Choices {
|
||||||
|
if strings.HasPrefix(choice.Value, target.valuePrefix) {
|
||||||
|
result.Choices = append(result.Choices, choice)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (current *execution) complete(words []string) Outcome {
|
||||||
|
result, err := completeRequest(current.snapshot, words)
|
||||||
|
var reply string
|
||||||
|
if err == nil {
|
||||||
|
reply, err = internalcobra.CompletionReply(result)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
reply = internalcobra.CompletionErrorReply()
|
||||||
|
}
|
||||||
|
_ = current.reporter.RenderOutput(func(writer io.Writer) error {
|
||||||
|
_, writeErr := io.WriteString(writer, reply)
|
||||||
|
return writeErr
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return current.reporter.finalize(Failure("Could not complete the shell request.").WithCause(err), nil)
|
||||||
|
}
|
||||||
|
return current.reporter.finalize(Success(), nil)
|
||||||
|
}
|
||||||
122
completion_query_test.go
Normal file
122
completion_query_test.go
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
package commandkit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
internalcobra "go.maximumdirect.net/commandkit/internal/cobra"
|
||||||
|
commandmeta "go.maximumdirect.net/commandkit/internal/commandmeta"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCompletionQueryDispatch(t *testing.T) {
|
||||||
|
var factories, converters, handlers int
|
||||||
|
command := NewCommand(CommandSpec[struct{}]{
|
||||||
|
Name: "run", Summary: "Run a command.",
|
||||||
|
NewInput: func() struct{} { factories++; return struct{}{} },
|
||||||
|
Flags: []Flag[struct{}]{
|
||||||
|
ValueFlag(FlagMeta{Name: "color", Short: "c", Completion: CompleteChoices(CompletionChoice{Value: "blue", Description: "cool\ncolor"}, CompletionChoice{Value: "green"}, CompletionChoice{Value: "foo=bar"})}, (*string)(nil),
|
||||||
|
func(value string) (string, error) { converters++; return value, nil }, func(*struct{}, Supplied[string]) {}),
|
||||||
|
ValueFlag(FlagMeta{Name: "file", Short: "f", Completion: CompleteFiles()}, (*string)(nil),
|
||||||
|
func(value string) (string, error) { converters++; return value, nil }, func(*struct{}, Supplied[string]) {}),
|
||||||
|
},
|
||||||
|
Run: func(Invocation, struct{}) Outcome { handlers++; return Success() },
|
||||||
|
})
|
||||||
|
app, err := New(Identity{Name: "example"}, command, Completion("example"), Alias("shortcut", "run"), FlagAliasFor("run", "--colour", "--color"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
emptyReply, err := internalcobra.CompletionReply(commandmeta.CompletionResult{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
args []string
|
||||||
|
want string
|
||||||
|
empty bool
|
||||||
|
}{
|
||||||
|
{"empty word", []string{"__complete", ""}, "run\tRun a command.", false},
|
||||||
|
{"alias value", []string{"__complete", "shortcut", "--colour=bl"}, "blue\tcool color\n", false},
|
||||||
|
{"quiet is data", []string{"__complete", "run", "--quiet", "--color=bl"}, "blue\tcool color\n", false},
|
||||||
|
{"debug is data", []string{"__complete", "run", "--debug", "--color=bl"}, "blue\tcool color\n", false},
|
||||||
|
{"format value", []string{"__complete", "run", "--format", "j"}, "json\n", false},
|
||||||
|
{"short attached choice", []string{"__complete", "run", "-cfoo=ba"}, "foo=bar\n", false},
|
||||||
|
{"unknown route", []string{"__complete", "missing", ""}, emptyReply, true},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
var output, diagnostic bytes.Buffer
|
||||||
|
outcome := app.Execute(context.Background(), tc.args, Streams{Out: &output, Err: &diagnostic})
|
||||||
|
if outcome.Status != 0 || diagnostic.Len() != 0 || (tc.empty && output.String() != tc.want) || (!tc.empty && !strings.Contains(output.String(), tc.want)) {
|
||||||
|
t.Fatalf("outcome=%#v output=%q diagnostics=%q", outcome, output.String(), diagnostic.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
fileReply, err := internalcobra.CompletionReply(commandmeta.CompletionResult{Kind: commandmeta.CompletionFiles, Prefix: "-f"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var fileOutput bytes.Buffer
|
||||||
|
fileOutcome := app.Execute(context.Background(), []string{"__complete", "run", "-f./part"}, Streams{Out: &fileOutput})
|
||||||
|
if fileOutcome.Status != 0 || fileOutput.String() != fileReply {
|
||||||
|
t.Fatalf("file query outcome=%#v reply=%q", fileOutcome, fileOutput.String())
|
||||||
|
}
|
||||||
|
if factories != 0 || converters != 0 || handlers != 0 {
|
||||||
|
t.Fatalf("query invoked application callbacks: %d %d %d", factories, converters, handlers)
|
||||||
|
}
|
||||||
|
if err := app.Register(simpleRunnable("later")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var output bytes.Buffer
|
||||||
|
if outcome := app.Execute(context.Background(), []string{"__complete", "lat"}, Streams{Out: &output}); outcome.Status != 0 || !strings.Contains(output.String(), "later") {
|
||||||
|
t.Fatalf("later command outcome=%#v reply=%q", outcome, output.String())
|
||||||
|
}
|
||||||
|
output.Reset()
|
||||||
|
if outcome := app.Execute(context.Background(), []string{"__complete", "later", "--qui"}, Streams{Out: &output}); outcome.Status != 0 || !strings.Contains(output.String(), "--quiet") {
|
||||||
|
t.Fatalf("later command flag outcome=%#v reply=%q", outcome, output.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCompletionQueryErrorsAndDelivery(t *testing.T) {
|
||||||
|
app, err := New(Identity{Name: "example"}, Completion("example"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var output, diagnostics bytes.Buffer
|
||||||
|
outcome := app.Execute(context.Background(), []string{"__complete"}, Streams{Out: &output, Err: &diagnostics})
|
||||||
|
if outcome.Status != 1 || outcome.Cause == nil || output.String() != internalcobra.CompletionErrorReply() || diagnostics.Len() == 0 {
|
||||||
|
t.Fatalf("malformed query outcome=%#v reply=%q diagnostics=%q", outcome, output.String(), diagnostics.String())
|
||||||
|
}
|
||||||
|
writeErr := errors.New("reply destination failed")
|
||||||
|
writer := &completionCountingWriter{err: writeErr}
|
||||||
|
outcome = app.Execute(context.Background(), []string{"__complete", ""}, Streams{Out: writer, Err: io.Discard})
|
||||||
|
if outcome.Status != 1 || !errors.Is(outcome.Presentation.Output.WriteError, writeErr) || writer.calls != 1 {
|
||||||
|
t.Fatalf("reply write failure: %#v", outcome)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type completionCountingWriter struct {
|
||||||
|
err error
|
||||||
|
calls int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (writer *completionCountingWriter) Write([]byte) (int, error) {
|
||||||
|
writer.calls++
|
||||||
|
return 0, writer.err
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCompletionQueryDiscovery(t *testing.T) {
|
||||||
|
app, err := New(Identity{Name: "example"}, Completion("example"),
|
||||||
|
Group("workspace", "", simpleRunnable("inspect")), Alias("legacy inspect", "workspace inspect"), FlagAlias("--silent", "--quiet"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var output bytes.Buffer
|
||||||
|
outcome := app.Execute(context.Background(), []string{"__complete", "legacy", "--"}, Streams{Out: &output})
|
||||||
|
if outcome.Status != 0 || !strings.Contains(output.String(), "--quiet") || strings.Contains(output.String(), "inspect") || strings.Contains(output.String(), "--silent") {
|
||||||
|
t.Fatalf("discovery outcome=%#v reply=%q", outcome, output.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
93
completion_shell_test.go
Normal file
93
completion_shell_test.go
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
package commandkit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCompletionConsumerInShells(t *testing.T) {
|
||||||
|
if os.Getenv("COMMANDKIT_COMPLETION_SHELLS") != "1" {
|
||||||
|
t.Skip("set COMMANDKIT_COMPLETION_SHELLS=1 to run shell completion checks")
|
||||||
|
}
|
||||||
|
dir := t.TempDir()
|
||||||
|
app := filepath.Join(dir, "probe")
|
||||||
|
build := exec.Command("go", "build", "-o", app, "./testdata/completion")
|
||||||
|
if output, err := build.CombinedOutput(); err != nil {
|
||||||
|
t.Fatalf("build completion fixture: %v\n%s", err, output)
|
||||||
|
}
|
||||||
|
if err := os.Mkdir(filepath.Join(dir, "partial dir"), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "partial file"), nil, 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
tests := []struct {
|
||||||
|
name, line string
|
||||||
|
want []string
|
||||||
|
directory bool
|
||||||
|
}{
|
||||||
|
{"canonical command", "probe ru", []string{"run"}, false},
|
||||||
|
{"entered alias", "probe shortcut --format=ya", []string{"shortcut", "--format=yaml"}, false},
|
||||||
|
{"choice", "probe run --format=te", []string{"run", "--format=text"}, false},
|
||||||
|
{"no file fallback", `probe run --format=./partial\ f`, []string{"run", "--format=./partial f"}, false},
|
||||||
|
{"separated file", `probe run --config ./partial\ f`, []string{"run", "--config", "./partial file"}, false},
|
||||||
|
{"attached file", `probe run --config=./partial\ f`, []string{"run", "--config=./partial file"}, false},
|
||||||
|
{"short attached file", `probe run -c./partial\ f`, []string{"run", "-c./partial file"}, false},
|
||||||
|
{"short cluster equals file", `probe run -sc=./partial\ f`, []string{"run", "-sc=./partial file"}, false},
|
||||||
|
{"separated directory", "probe run --directory ./partial", []string{"run", "--directory", "./partial dir"}, true},
|
||||||
|
{"attached directory", "probe run --directory=./partial", []string{"run", "--directory=./partial dir"}, true},
|
||||||
|
{"empty operand", "probe run ", []string{"run", "alpha"}, false},
|
||||||
|
{"leading dash", "probe run --for", []string{"run", "--format"}, false},
|
||||||
|
}
|
||||||
|
for _, shell := range []string{"bash", "zsh", "fish"} {
|
||||||
|
generator := exec.Command(app, "completion", shell)
|
||||||
|
script, err := generator.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("generate %s script: %v\n%s", shell, err, script)
|
||||||
|
}
|
||||||
|
scriptPath := filepath.Join(dir, shell+".completion")
|
||||||
|
if err := os.WriteFile(scriptPath, script, 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(shell+"/"+tc.name, func(t *testing.T) {
|
||||||
|
log := filepath.Join(dir, "log")
|
||||||
|
if err := os.WriteFile(log, nil, 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
terminal := exec.Command("python3", "internal/cobra/testdata/completion_terminal.py", shell, scriptPath, tc.line)
|
||||||
|
terminal.Env = append(os.Environ(), "PATH="+dir+string(os.PathListSeparator)+os.Getenv("PATH"), "HOME="+dir, "TERM=xterm", "PROBE_LOG="+log, "PROBE_DIR="+dir)
|
||||||
|
if output, err := terminal.CombinedOutput(); err != nil {
|
||||||
|
t.Fatalf("shell completion: %v\n%s", err, output)
|
||||||
|
}
|
||||||
|
content, err := os.ReadFile(log)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var got []string
|
||||||
|
if err := json.Unmarshal(content, &got); err != nil {
|
||||||
|
t.Fatalf("fixture output %q: %v", content, err)
|
||||||
|
}
|
||||||
|
if tc.directory && len(got) > 0 {
|
||||||
|
got[len(got)-1] = strings.TrimSuffix(got[len(got)-1], "/")
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(got, tc.want) {
|
||||||
|
t.Fatalf("edited arguments=%q, want %q", got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
t.Run(shell+"/descriptions", func(t *testing.T) {
|
||||||
|
terminal := exec.Command("python3", "internal/cobra/testdata/completion_terminal.py", shell, scriptPath, "probe run --format=")
|
||||||
|
terminal.Env = append(os.Environ(), "PATH="+dir+string(os.PathListSeparator)+os.Getenv("PATH"), "HOME="+dir, "TERM=xterm", "PROBE_LOG="+filepath.Join(dir, "log"), "PROBE_DIR="+dir, "PROBE_LIST=1")
|
||||||
|
output, err := terminal.CombinedOutput()
|
||||||
|
if err != nil || !strings.Contains(string(output), "Readable text") || !strings.Contains(string(output), "YAML output") {
|
||||||
|
t.Fatalf("completion descriptions: %v\n%s", err, output)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
81
completion_test.go
Normal file
81
completion_test.go
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
package commandkit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
commandmeta "go.maximumdirect.net/commandkit/internal/commandmeta"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Existing consumer code can compare these metadata values or use them as keys.
|
||||||
|
var (
|
||||||
|
_ map[FlagMeta]struct{}
|
||||||
|
_ map[ArgumentMeta]struct{}
|
||||||
|
_ = (FlagMeta{}) == (FlagMeta{})
|
||||||
|
_ = (ArgumentMeta{}) == (ArgumentMeta{})
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCompletionHintsFollowCapturedBindings(t *testing.T) {
|
||||||
|
type inner struct{ values Supplied[[]string] }
|
||||||
|
type outer struct{ flags FlagValues[inner] }
|
||||||
|
choices := []CompletionChoice{{Value: "alpha", Description: "first"}}
|
||||||
|
hint := CompleteChoices(choices...)
|
||||||
|
choices[0] = CompletionChoice{Value: "changed"}
|
||||||
|
parse := func(value string) (string, error) { return value, nil }
|
||||||
|
repeated := RepeatFlag(FlagMeta{Name: "tag", Completion: hint}, nil, parse,
|
||||||
|
func(input *inner, value Supplied[[]string]) { input.values = value })
|
||||||
|
captured := CaptureFlags([]Flag[inner]{repeated}, func(input *outer) *FlagValues[inner] { return &input.flags })
|
||||||
|
command := NewCommand(CommandSpec[outer]{
|
||||||
|
Name: "run",
|
||||||
|
Flags: append(captured,
|
||||||
|
ValueFlag(FlagMeta{Name: "file", Completion: CompleteFiles()}, (*string)(nil), parse, func(*outer, Supplied[string]) {}),
|
||||||
|
BoolFlag(FlagMeta{Name: "enabled"}, false, func(*outer, Supplied[bool]) {}),
|
||||||
|
ValueFlag(FlagMeta{Name: "empty", Completion: CompleteChoices()}, (*string)(nil), parse, func(*outer, Supplied[string]) {})),
|
||||||
|
Arguments: []Argument[outer]{
|
||||||
|
ValueArgument(ArgumentMeta{Name: "directory", Completion: CompleteDirectories()}, false, parse, func(*outer, Supplied[string]) {}),
|
||||||
|
RepeatArgument(ArgumentMeta{Name: "values", Completion: hint}, 0, -1, parse, func(*outer, Supplied[[]string]) {}),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
flags := command.record.payload.bridgeFlags()
|
||||||
|
arguments := command.record.payload.bridgeArguments()
|
||||||
|
want := commandmeta.CompletionHint{Kind: commandmeta.CompletionChoices, Choices: []commandmeta.CompletionChoice{{Value: "alpha", Description: "first"}}}
|
||||||
|
if !flags[0].Repeated || !reflect.DeepEqual(flags[0].Completion, want) || !reflect.DeepEqual(arguments[1].Completion, want) {
|
||||||
|
t.Fatalf("captured hints: flags=%#v arguments=%#v", flags, arguments)
|
||||||
|
}
|
||||||
|
if flags[1].Completion.Kind != commandmeta.CompletionFiles || flags[2].Completion.Kind != commandmeta.CompletionNone || flags[3].Completion.Kind != commandmeta.CompletionChoices || len(flags[3].Completion.Choices) != 0 || arguments[0].Completion.Kind != commandmeta.CompletionDirectories {
|
||||||
|
t.Fatalf("hint strategies: flags=%#v arguments=%#v", flags, arguments)
|
||||||
|
}
|
||||||
|
flags[0].Completion.Choices[0].Value = "mutated"
|
||||||
|
if got := command.record.payload.bridgeFlags()[0].Completion; !reflect.DeepEqual(got, want) {
|
||||||
|
t.Fatalf("bridged choices changed captured hint: %#v", got)
|
||||||
|
}
|
||||||
|
if got := bridgeCompletion(NoCompletion()); got.Kind != commandmeta.CompletionNone || !reflect.DeepEqual(got, bridgeCompletion(CompletionHint{})) {
|
||||||
|
t.Fatalf("no completion: %#v", got)
|
||||||
|
}
|
||||||
|
for _, flag := range append(commandmeta.ReportingFlags(), commandmeta.HelpFlag()) {
|
||||||
|
if flag.Boolean && !reflect.DeepEqual(flag.Completion.Choices, []commandmeta.CompletionChoice{{Value: "true"}, {Value: "false"}}) {
|
||||||
|
t.Fatalf("reporting flag choices: %#v", flag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCompletionChoicesDoNotRestrictExecution(t *testing.T) {
|
||||||
|
type input struct{ mode Supplied[string] }
|
||||||
|
var received input
|
||||||
|
command := NewCommand(CommandSpec[input]{
|
||||||
|
Name: "run",
|
||||||
|
Flags: []Flag[input]{ValueFlag(FlagMeta{Name: "mode", Completion: CompleteChoices(CompletionChoice{Value: "suggested"})}, (*string)(nil),
|
||||||
|
func(value string) (string, error) { return value, nil },
|
||||||
|
func(i *input, value Supplied[string]) { i.mode = value })},
|
||||||
|
Run: func(_ Invocation, value input) Outcome { received = value; return Success() },
|
||||||
|
})
|
||||||
|
app, err := New(Identity{Name: "example"}, command)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
outcome := app.Execute(context.Background(), []string{"run", "--mode=other"}, Streams{})
|
||||||
|
if outcome.Status != 0 || !received.mode.Set || received.mode.Value != "other" {
|
||||||
|
t.Fatalf("outcome=%#v input=%#v", outcome, received)
|
||||||
|
}
|
||||||
|
}
|
||||||
118
debug_events_test.go
Normal file
118
debug_events_test.go
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
package commandkit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDebugEventsFollowModeAndFinalizationOrder(t *testing.T) {
|
||||||
|
cause := errors.New("private operation cause")
|
||||||
|
command := NewCommand(CommandSpec[struct{}]{
|
||||||
|
Name: "run",
|
||||||
|
Run: func(inv Invocation, _ struct{}) Outcome {
|
||||||
|
if err := inv.Reporter().AtEnd().Info("deferred summary"); err != nil {
|
||||||
|
t.Errorf("queue summary: %v", err)
|
||||||
|
}
|
||||||
|
return Failure("operation failed").WithCause(cause)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
app, err := New(Identity{Name: "Example"}, command, Alias("shortcut", "run"), FlagAlias("--trace", "--debug"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var diagnostics bytes.Buffer
|
||||||
|
outcome := app.Execute(context.Background(), []string{"shortcut", "--verbose", "--trace"}, Streams{Err: &diagnostics})
|
||||||
|
if outcome.Kind != OutcomeOperational || !errors.Is(outcome.Cause, cause) {
|
||||||
|
t.Fatalf("outcome = %#v", outcome)
|
||||||
|
}
|
||||||
|
got := diagnostics.String()
|
||||||
|
for _, fragment := range []string{
|
||||||
|
"resolved command alias \"shortcut\" to \"run\"",
|
||||||
|
"selected debug reporting mode using --trace (alias for --debug)",
|
||||||
|
"deferred summary",
|
||||||
|
"handler completed for \"run\" with operational outcome and status 1",
|
||||||
|
"ERROR: operation failed",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(got, fragment) {
|
||||||
|
t.Errorf("diagnostics %q omit %q", got, fragment)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Contains(got, cause.Error()) {
|
||||||
|
t.Fatalf("diagnostics exposed the operation cause: %q", got)
|
||||||
|
}
|
||||||
|
ordered := []string{
|
||||||
|
"resolved command alias",
|
||||||
|
"selected debug reporting mode",
|
||||||
|
"deferred summary",
|
||||||
|
"handler completed",
|
||||||
|
"ERROR: operation failed",
|
||||||
|
}
|
||||||
|
previous := -1
|
||||||
|
for _, fragment := range ordered {
|
||||||
|
index := strings.Index(got, fragment)
|
||||||
|
if index <= previous {
|
||||||
|
t.Fatalf("diagnostic event %q out of order in %q", fragment, got)
|
||||||
|
}
|
||||||
|
previous = index
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDebugEventsRespectFinalQuietSelection(t *testing.T) {
|
||||||
|
command := NewCommand(CommandSpec[struct{}]{
|
||||||
|
Name: "run",
|
||||||
|
Run: func(Invocation, struct{}) Outcome { return Success() },
|
||||||
|
})
|
||||||
|
app, err := New(Identity{Name: "Example"}, command)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var diagnostics bytes.Buffer
|
||||||
|
outcome := app.Execute(context.Background(), []string{"run", "--debug", "--quiet"}, Streams{Err: &diagnostics})
|
||||||
|
if outcome.Kind != OutcomeSuccess || diagnostics.Len() != 0 {
|
||||||
|
t.Fatalf("outcome = %#v, diagnostics = %q", outcome, diagnostics.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDebugCompletionRequiresAHandler(t *testing.T) {
|
||||||
|
command := NewCommand(CommandSpec[struct{}]{
|
||||||
|
Name: "run",
|
||||||
|
Run: func(Invocation, struct{}) Outcome { return Success() },
|
||||||
|
})
|
||||||
|
app, err := New(Identity{Name: "Example"}, command)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var diagnostics bytes.Buffer
|
||||||
|
app.Execute(context.Background(), []string{"unknown", "--debug"}, Streams{Err: &diagnostics})
|
||||||
|
if strings.Contains(diagnostics.String(), "handler completed") {
|
||||||
|
t.Fatalf("no-handler diagnostics include completion: %q", diagnostics.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDebugDeliveryFailureAffectsInvocationOutcome(t *testing.T) {
|
||||||
|
ran := false
|
||||||
|
command := NewCommand(CommandSpec[struct{}]{
|
||||||
|
Name: "run",
|
||||||
|
Run: func(Invocation, struct{}) Outcome {
|
||||||
|
ran = true
|
||||||
|
return Success()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
app, err := New(Identity{Name: "Example"}, command)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
writeErr := errors.New("debug destination failed")
|
||||||
|
outcome := app.Execute(context.Background(), []string{"run", "--debug"}, Streams{
|
||||||
|
Err: executeReportingFailureWriter{err: writeErr},
|
||||||
|
})
|
||||||
|
if !ran {
|
||||||
|
t.Fatal("debug delivery failure prevented handler execution")
|
||||||
|
}
|
||||||
|
if outcome.Kind != OutcomeOperational || outcome.Status != 1 || !errors.Is(outcome.Presentation.Diagnostics.WriteError, writeErr) {
|
||||||
|
t.Fatalf("outcome = %#v", outcome)
|
||||||
|
}
|
||||||
|
}
|
||||||
241
declaration_external_test.go
Normal file
241
declaration_external_test.go
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
package commandkit_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
commandkit "go.maximumdirect.net/commandkit"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPublicInstallationAcceptsMixedDeclarations(t *testing.T) {
|
||||||
|
command := commandkit.NewCommand(commandkit.CommandSpec[struct{}]{
|
||||||
|
Name: "show",
|
||||||
|
Summary: "Show a value.",
|
||||||
|
Flags: []commandkit.Flag[struct{}]{
|
||||||
|
commandkit.ValueFlag(commandkit.FlagMeta{Name: "style"}, (*string)(nil), func(value string) (string, error) { return value, nil }, func(*struct{}, commandkit.Supplied[string]) {}),
|
||||||
|
},
|
||||||
|
Run: func(commandkit.Invocation, struct{}) commandkit.Outcome { return commandkit.Success() },
|
||||||
|
})
|
||||||
|
declarations := []commandkit.Declaration{
|
||||||
|
command,
|
||||||
|
commandkit.Alias("inspect", "show"),
|
||||||
|
commandkit.FlagAlias("--fmt", "--style"),
|
||||||
|
commandkit.FlagAliasFor("show", "-f", "--style"),
|
||||||
|
}
|
||||||
|
app, err := commandkit.New(commandkit.Identity{Name: "Example"}, declarations...)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if app == nil {
|
||||||
|
t.Fatal("New() returned a nil app")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPublicAliasesPreserveCommandsAcrossDeclarationOrders(t *testing.T) {
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
rulesFirst bool
|
||||||
|
}{
|
||||||
|
{name: "commands first"},
|
||||||
|
{name: "routing rules first", rulesFirst: true},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
type runInput struct{ words commandkit.Supplied[[]string] }
|
||||||
|
type inspectInput struct{ id commandkit.Supplied[string] }
|
||||||
|
type showInput struct {
|
||||||
|
profile commandkit.Supplied[string]
|
||||||
|
format commandkit.Supplied[string]
|
||||||
|
}
|
||||||
|
var runParses, inspectParses, optionalParses int
|
||||||
|
var runValues [][]string
|
||||||
|
var inspectValues []string
|
||||||
|
var showValues []string
|
||||||
|
run := commandkit.NewCommand(commandkit.CommandSpec[runInput]{
|
||||||
|
Name: "run",
|
||||||
|
Arguments: []commandkit.Argument[runInput]{commandkit.RepeatArgument(commandkit.ArgumentMeta{Name: "words"}, 0, -1,
|
||||||
|
func(value string) (string, error) { runParses++; return value, nil },
|
||||||
|
func(target *runInput, value commandkit.Supplied[[]string]) { target.words = value },
|
||||||
|
)},
|
||||||
|
Run: func(_ commandkit.Invocation, value runInput) commandkit.Outcome {
|
||||||
|
runValues = append(runValues, append([]string(nil), value.words.Value...))
|
||||||
|
return commandkit.Success()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
inspect := commandkit.NewCommand(commandkit.CommandSpec[inspectInput]{
|
||||||
|
Name: "inspect",
|
||||||
|
Arguments: []commandkit.Argument[inspectInput]{commandkit.ValueArgument(commandkit.ArgumentMeta{Name: "id"}, true,
|
||||||
|
func(value string) (string, error) { inspectParses++; return value, nil },
|
||||||
|
func(target *inspectInput, value commandkit.Supplied[string]) { target.id = value },
|
||||||
|
)},
|
||||||
|
Run: func(_ commandkit.Invocation, value inspectInput) commandkit.Outcome {
|
||||||
|
inspectValues = append(inspectValues, value.id.Value)
|
||||||
|
return commandkit.Success()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
show := commandkit.NewCommand(commandkit.CommandSpec[showInput]{
|
||||||
|
Name: "show",
|
||||||
|
Arguments: []commandkit.Argument[showInput]{commandkit.ValueArgument(commandkit.ArgumentMeta{Name: "profile"}, false,
|
||||||
|
func(value string) (string, error) { optionalParses++; return value, nil },
|
||||||
|
func(target *showInput, value commandkit.Supplied[string]) { target.profile = value },
|
||||||
|
)},
|
||||||
|
Flags: []commandkit.Flag[showInput]{commandkit.ValueFlag(commandkit.FlagMeta{Name: "style"}, (*string)(nil),
|
||||||
|
func(value string) (string, error) { return value, nil },
|
||||||
|
func(target *showInput, value commandkit.Supplied[string]) { target.format = value },
|
||||||
|
)},
|
||||||
|
Run: func(_ commandkit.Invocation, value showInput) commandkit.Outcome {
|
||||||
|
showValues = append(showValues, value.profile.Value+":"+value.format.Value)
|
||||||
|
return commandkit.Success()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
commands := []commandkit.Declaration{run, inspect, show}
|
||||||
|
rules := []commandkit.Declaration{
|
||||||
|
commandkit.Alias("r", "run"),
|
||||||
|
commandkit.Alias("r full", "inspect"),
|
||||||
|
commandkit.Alias("inspect-shortcut", "inspect"),
|
||||||
|
commandkit.Alias("i", "inspect-shortcut"),
|
||||||
|
commandkit.FlagAlias("--fmt", "--style"),
|
||||||
|
}
|
||||||
|
declarations := append([]commandkit.Declaration(nil), commands...)
|
||||||
|
if test.rulesFirst {
|
||||||
|
declarations = append(append([]commandkit.Declaration(nil), rules...), commands...)
|
||||||
|
} else {
|
||||||
|
declarations = append(declarations, rules...)
|
||||||
|
}
|
||||||
|
|
||||||
|
app, err := commandkit.New(commandkit.Identity{Name: "Example"}, declarations...)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if runParses != 0 || inspectParses != 0 || optionalParses != 0 {
|
||||||
|
t.Fatalf("installation invoked operand converters: %d/%d/%d", runParses, inspectParses, optionalParses)
|
||||||
|
}
|
||||||
|
for _, args := range [][]string{{"run", "full"}, {"r", "full", "item-1"}, {"i", "item-2"}, {"show", "profile", "--fmt=json"}} {
|
||||||
|
if outcome := app.Execute(context.Background(), args, commandkit.Streams{Out: io.Discard, Err: io.Discard}); outcome.Kind != commandkit.OutcomeSuccess {
|
||||||
|
t.Fatalf("Execute(%q) = %#v, want success", args, outcome)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(runValues, [][]string{{"full"}}) {
|
||||||
|
t.Fatalf("canonical run operands = %#v, want [[full]]", runValues)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(inspectValues, []string{"item-1", "item-2"}) {
|
||||||
|
t.Fatalf("routed required operands = %#v", inspectValues)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(showValues, []string{"profile:json"}) || optionalParses != 1 {
|
||||||
|
t.Fatalf("optional operand/flag values = %#v, converter calls %d", showValues, optionalParses)
|
||||||
|
}
|
||||||
|
|
||||||
|
parsesBeforeReuse := [3]int{runParses, inspectParses, optionalParses}
|
||||||
|
secondApp, err := commandkit.New(commandkit.Identity{Name: "Second"}, declarations...)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("reusing declarations: %v", err)
|
||||||
|
}
|
||||||
|
if got := [3]int{runParses, inspectParses, optionalParses}; got != parsesBeforeReuse {
|
||||||
|
t.Fatalf("reinstalling declarations invoked operand converters: %v -> %v", parsesBeforeReuse, got)
|
||||||
|
}
|
||||||
|
if outcome := secondApp.Execute(context.Background(), []string{"run", "again"}, commandkit.Streams{Out: io.Discard, Err: io.Discard}); outcome.Kind != commandkit.OutcomeSuccess {
|
||||||
|
t.Fatalf("reused declaration execution = %#v", outcome)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(runValues, [][]string{{"full"}, {"again"}}) {
|
||||||
|
t.Fatalf("reused command declaration values = %#v", runValues)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPublicRootGroupRegistrationAndFailedBatchRecovery(t *testing.T) {
|
||||||
|
type showInput struct{ format commandkit.Supplied[string] }
|
||||||
|
var formatParses, lateRuns int
|
||||||
|
var formats []string
|
||||||
|
show := commandkit.NewCommand(commandkit.CommandSpec[showInput]{
|
||||||
|
Name: "show",
|
||||||
|
Flags: []commandkit.Flag[showInput]{commandkit.ValueFlag(commandkit.FlagMeta{Name: "style"}, (*string)(nil),
|
||||||
|
func(value string) (string, error) { formatParses++; return value, nil },
|
||||||
|
func(target *showInput, value commandkit.Supplied[string]) { target.format = value },
|
||||||
|
)},
|
||||||
|
Run: func(_ commandkit.Invocation, value showInput) commandkit.Outcome {
|
||||||
|
formats = append(formats, value.format.Value)
|
||||||
|
return commandkit.Success()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
family := commandkit.RootGroup("config", "Configuration", show)
|
||||||
|
constructionFailure := errors.New("family construction failed")
|
||||||
|
if _, err := commandkit.New(commandkit.Identity{Name: "Broken"}, commandkit.WithError(family, fmt.Errorf("adapter: %w", constructionFailure))); !errors.Is(err, constructionFailure) {
|
||||||
|
t.Fatalf("New() error = %v, want retained family construction failure", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
app, err := commandkit.New(commandkit.Identity{Name: "Example"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var before bytes.Buffer
|
||||||
|
app.Execute(context.Background(), []string{"--help"}, commandkit.Streams{Out: &before, Err: io.Discard})
|
||||||
|
if strings.Contains(before.String(), "config") {
|
||||||
|
t.Fatalf("root help showed an unregistered family: %q", before.String())
|
||||||
|
}
|
||||||
|
if err := app.Register(family,
|
||||||
|
commandkit.Alias("cfg-show", "config show"),
|
||||||
|
commandkit.Alias("cs", "cfg-show"),
|
||||||
|
commandkit.FlagAlias("--fmt", "--style"),
|
||||||
|
); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if formatParses != 0 {
|
||||||
|
t.Fatalf("registration invoked flag converter %d times", formatParses)
|
||||||
|
}
|
||||||
|
var after bytes.Buffer
|
||||||
|
app.Execute(context.Background(), []string{"--help"}, commandkit.Streams{Out: &after, Err: io.Discard})
|
||||||
|
for _, fragment := range []string{"config", "Aliases:", "cfg-show", "cs"} {
|
||||||
|
if !strings.Contains(after.String(), fragment) {
|
||||||
|
t.Errorf("updated root help %q does not contain %q", after.String(), fragment)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if outcome := app.Execute(context.Background(), []string{"cs", "--fmt=yaml"}, commandkit.Streams{Out: io.Discard, Err: io.Discard}); outcome.Kind != commandkit.OutcomeSuccess {
|
||||||
|
t.Fatalf("registered alias execution = %#v", outcome)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(formats, []string{"yaml"}) || formatParses != 1 {
|
||||||
|
t.Fatalf("registered family values = %#v, converter calls %d", formats, formatParses)
|
||||||
|
}
|
||||||
|
if err := app.Register(commandkit.NewCommand(commandkit.CommandSpec[struct{}]{Name: "cfg-show", Run: func(commandkit.Invocation, struct{}) commandkit.Outcome { return commandkit.Success() }})); err == nil {
|
||||||
|
t.Fatal("registration accepted a canonical command that conflicts with an existing alias")
|
||||||
|
}
|
||||||
|
var afterConflict bytes.Buffer
|
||||||
|
app.Execute(context.Background(), []string{"--help"}, commandkit.Streams{Out: &afterConflict, Err: io.Discard})
|
||||||
|
if !strings.Contains(afterConflict.String(), "cfg-show") {
|
||||||
|
t.Fatalf("failed relationship validation changed registered aliases: %q", afterConflict.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
late := commandkit.NewCommand(commandkit.CommandSpec[struct{}]{Name: "late", Run: func(commandkit.Invocation, struct{}) commandkit.Outcome {
|
||||||
|
lateRuns++
|
||||||
|
return commandkit.Success()
|
||||||
|
}})
|
||||||
|
registrationFailure := errors.New("retained registration failure")
|
||||||
|
err = app.Register(commandkit.WithError(late, fmt.Errorf("family: %w", registrationFailure)))
|
||||||
|
if !errors.Is(err, registrationFailure) {
|
||||||
|
t.Fatalf("Register() error = %v, want retained failure", err)
|
||||||
|
}
|
||||||
|
if err := app.Register(late, commandkit.Alias("late-shortcut", "missing")); err == nil {
|
||||||
|
t.Fatal("registration accepted a batch whose alias target is absent")
|
||||||
|
}
|
||||||
|
var afterFailure bytes.Buffer
|
||||||
|
app.Execute(context.Background(), []string{"--help"}, commandkit.Streams{Out: &afterFailure, Err: io.Discard})
|
||||||
|
if strings.Contains(afterFailure.String(), "late-shortcut") || strings.Contains(afterFailure.String(), " late ") {
|
||||||
|
t.Fatalf("failed batch changed root help: %q", afterFailure.String())
|
||||||
|
}
|
||||||
|
if err := app.Register(late, commandkit.Alias("late-shortcut", "late")); err != nil {
|
||||||
|
t.Fatalf("registration after failed batch: %v", err)
|
||||||
|
}
|
||||||
|
var recovered bytes.Buffer
|
||||||
|
app.Execute(context.Background(), []string{"--help"}, commandkit.Streams{Out: &recovered, Err: io.Discard})
|
||||||
|
if !strings.Contains(recovered.String(), "late-shortcut") {
|
||||||
|
t.Fatalf("recovered root help omitted alias: %q", recovered.String())
|
||||||
|
}
|
||||||
|
if outcome := app.Execute(context.Background(), []string{"late-shortcut"}, commandkit.Streams{Out: io.Discard, Err: io.Discard}); outcome.Kind != commandkit.OutcomeSuccess || lateRuns != 1 {
|
||||||
|
t.Fatalf("recovered alias execution = %#v, runs %d", outcome, lateRuns)
|
||||||
|
}
|
||||||
|
}
|
||||||
7
doc.go
7
doc.go
@@ -1,5 +1,8 @@
|
|||||||
// Package commandkit provides the core instance-owned CLI dispatcher, typed
|
// Package commandkit provides the core instance-owned CLI dispatcher, typed
|
||||||
// command declarations, input bindings, and invocation outcomes. Optional
|
// command declarations, application-owned command and flag aliases, input
|
||||||
// integration modules provide ConfigKit and PromptKit-specific declarations;
|
// bindings, invocation-scoped reporting, and outcomes with separate operation
|
||||||
|
// causes and presentation observations. New and App.Register accept canonical
|
||||||
|
// Command declarations and RoutingRule declarations together. Optional
|
||||||
|
// integration modules provide ConfigKit and PromptKit-specific commands;
|
||||||
// importing those modules does not install their commands.
|
// importing those modules does not install their commands.
|
||||||
package commandkit
|
package commandkit
|
||||||
|
|||||||
@@ -1,209 +1,78 @@
|
|||||||
# ADR 0001: Own dispatch through an explicit registry and typed inputs
|
# ADR 0001: Own dispatch through captured declarations and typed inputs
|
||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
Accepted.
|
Accepted.
|
||||||
|
|
||||||
[ADR 0011](0011-captured-declarations-and-installation.md) refines
|
|
||||||
declaration construction, installation, optional-family namespace reservation,
|
|
||||||
and integration input/state lifetimes.
|
|
||||||
|
|
||||||
## Date
|
## Date
|
||||||
|
|
||||||
2026-10-06
|
2026-11-07; consolidates decisions made on 2026-10-06 and 2026-10-10.
|
||||||
|
|
||||||
## Context
|
## Context
|
||||||
|
|
||||||
Several downstream consumers of the ConfigKit and PromptKit libraries include
|
Applications repeat routing, flag parsing, help, and inspection commands while
|
||||||
duplicated or near-duplicated code relating to configuration inspection,
|
also needing their own domain commands. Sharing only isolated command handlers
|
||||||
routing, parsing, help, and error handling. However, their command vocabulary
|
would leave much of that infrastructure and its inconsistent behavior with each
|
||||||
and syntax have diverged. CommandKit aims to provide a single, unified CLI
|
application. Optional integrations also need to assemble commands through the
|
||||||
implementation that will reduce duplication, provide consistent vocabulary and
|
same public mechanisms as applications.
|
||||||
grammar, and reduce the amount of code each individual application must own.
|
|
||||||
|
|
||||||
Applications have genuine domain commands, such as Narratio's `session restore`,
|
|
||||||
that remain outside the standard suite. Merely forwarding standard families from
|
|
||||||
separate application dispatchers would leave custom-command mechanics and root
|
|
||||||
help dependent on each application's implementation.
|
|
||||||
|
|
||||||
Input handling also needs a common boundary. Raw argv handlers or string-based
|
|
||||||
flag retrieval leave repeated parsing and input assembly with applications.
|
|
||||||
Presence matters for configuration overrides: omitted values differ from
|
|
||||||
explicitly supplied empty strings, false, and zero.
|
|
||||||
|
|
||||||
## Decision
|
## Decision
|
||||||
|
|
||||||
### Ownership and namespace structure
|
CommandKit owns dispatch, shared parsing, presence tracking, help, syntax
|
||||||
|
failures, and standard command mechanics. Applications own their domain commands,
|
||||||
|
validation, effects, runtime construction, and executable lifecycle. CommandKit
|
||||||
|
accepts caller context and streams and returns outcomes rather than terminating
|
||||||
|
the process.
|
||||||
|
|
||||||
CommandKit owns the dispatcher, shared parsing and presence tracking, help,
|
Use an explicitly constructed, per-instance registry. Applications and
|
||||||
syntax diagnostics, and standard-family handlers and outcomes. Applications own
|
integrations supply captured declarations containing routing, parsing, help
|
||||||
custom command names, domain meaning, validation, effects, artifacts, and
|
metadata, and callback references. One declaration supplies those views of the
|
||||||
operational outcomes.
|
command. Captured metadata is insulated from later edits to its source;
|
||||||
|
application objects referenced by callbacks remain caller-owned.
|
||||||
|
|
||||||
Use namespace-first syntax for the standard suite, which provides a clear
|
Declarations share one installation boundary. Construction issues are retained
|
||||||
family ownership boundary. The initial standard command families are `config`
|
for installation, and each installation batch is validated before registry
|
||||||
for ConfigKit inspection and validation, and `promptkit` for PromptKit inspection
|
mutation. Collisions are errors rather than order-dependent replacement.
|
||||||
and utilities. CommandKit integrates these libraries through their supported public
|
Optional standard families claim their namespaces when installed, not when
|
||||||
semantics.
|
imported. Their canonical operations remain integration-owned; application
|
||||||
|
commands live outside installed standard families.
|
||||||
|
|
||||||
CommandKit accepts custom commands outside its reserved standard families and
|
Handlers receive typed inputs through explicit flag and operand bindings.
|
||||||
reports registration errors for attempts to replace standard handlers or add
|
Presence is separate from value so omission differs from supplied empty, false,
|
||||||
private operations beneath them. Its input extension mechanism accepts context
|
or zero values. Domain parsers and validation remain application-owned. Core
|
||||||
selectors and typed overrides for standard operations.
|
provides typed composition for integrations without exposing parser state or
|
||||||
|
requiring a second argument parser.
|
||||||
|
|
||||||
CommandKit accepts caller-supplied context, streams, handlers, and inspection
|
Parsed state is independent between invocations. Integration inputs for selected
|
||||||
providers and returns invocation outcomes. It leaves process termination to the
|
scopes or comparison sides are distinct from invocation-wide acquisition state.
|
||||||
caller. Executable startup, signals, runtime construction, schemas, acquisition,
|
Applications own the objects and caches returned by their factories. CommandKit
|
||||||
and domain resolution remain application choices.
|
coordinates its own preparation and calls without promising arbitrary deep copies
|
||||||
|
or concurrent dispatch.
|
||||||
|
|
||||||
### Explicit registry and capability installation
|
Construction, installation, help, version, and capability discovery use metadata
|
||||||
|
without acquiring application state. The selected operation acquires what it
|
||||||
Each explicitly constructed CommandKit instance owns a registry. Its API accepts
|
needs after applicable input checks. Caller startup and callback effects retain
|
||||||
individual commands and composable groups. Registration uses explicit instance
|
their own lifecycle.
|
||||||
state with caller-controlled construction and composition.
|
|
||||||
|
|
||||||
Definitions provide paths, descriptions, flags and defaults, repetition and
|
|
||||||
presence metadata, operand requirements, and execution handlers. One definition
|
|
||||||
supplies routing, parsing, and help. Duplicate paths and attempts to replace
|
|
||||||
reserved commands return registration errors; order does not select a winner.
|
|
||||||
|
|
||||||
Help and version are installed automatically using supplied application identity
|
|
||||||
and build metadata. CommandKit assembles standard families from declared provider
|
|
||||||
capabilities, installing their supported handlers and metadata together. Each
|
|
||||||
family defines its own resource model, supported verbs, and per-operation data
|
|
||||||
contracts. Configuration scopes declare their available inspection operations.
|
|
||||||
|
|
||||||
The API allows supported capabilities to be integrated independently. Adding
|
|
||||||
comparison, catalogs, or other inspection features extends the installed surface
|
|
||||||
according to the supplied providers. Shared verbs have consistent purposes across
|
|
||||||
families, while the available subsets reflect their resources and capabilities.
|
|
||||||
Help and discovery expose those subsets. Registration validates the wiring of
|
|
||||||
declared capabilities and reports unsupported selections before acquisition.
|
|
||||||
|
|
||||||
CommandKit registration validates declarations with acquisition callbacks left
|
|
||||||
uninvoked. Its construction, help, and version paths use metadata. The selected
|
|
||||||
handler requests dependencies when its operation needs them. Invalid configuration
|
|
||||||
returned by an acquisition callback is an invocation-time failure. Caller code
|
|
||||||
executed before registration and effects inside callbacks remain caller-owned.
|
|
||||||
|
|
||||||
CommandKit installs handlers for supplied capabilities and describes actual
|
|
||||||
availability. Each enabled operation retains its standard meaning and output
|
|
||||||
contract. The exact operation inventory and remaining command contracts are
|
|
||||||
separate design decisions.
|
|
||||||
|
|
||||||
### Typed inputs with explicit bindings
|
|
||||||
|
|
||||||
The public binding contract accepts application-defined input structs for custom
|
|
||||||
commands and uses CommandKit-defined inputs for standard handlers. Explicit
|
|
||||||
registration binds flags and operands to fields and supplies parsing and help
|
|
||||||
metadata. CommandKit delivers parsed typed inputs to registered handlers.
|
|
||||||
|
|
||||||
Bindings support ordinary typed fields and presence-aware fields where omission
|
|
||||||
has a separate meaning. CommandKit records supplied state independently of values
|
|
||||||
and defaults, preserving explicitly supplied empty, false, and zero values.
|
|
||||||
|
|
||||||
CommandKit keeps parsed values, collections, and presence from one invocation out
|
|
||||||
of subsequent invocations. Storage allocation and reuse are implementation
|
|
||||||
choices. Handler closures and application state retain their own lifecycle;
|
|
||||||
concurrent dispatch is a separate contract.
|
|
||||||
|
|
||||||
The invocation API supplies context and streams separately from CLI inputs.
|
|
||||||
CommandKit creates its declarations and bindings from metadata and defers its
|
|
||||||
acquisition calls to the selected handler. The binding system accepts domain value
|
|
||||||
parsers; domain interpretation and validation belong to registered callbacks.
|
|
||||||
|
|
||||||
The public factory, binding, registration, and handler signatures and presence
|
|
||||||
type names remain API design choices. No illustrative declaration establishes
|
|
||||||
an exact public API.
|
|
||||||
|
|
||||||
### Outcomes and domain behavior
|
|
||||||
|
|
||||||
Resolve the command and handle help/version before loading application state.
|
|
||||||
For execution, prepare independent invocation inputs, apply shared parsing and
|
|
||||||
operand checks, then invoke the typed handler. Return its invocation outcome to
|
|
||||||
the application.
|
|
||||||
|
|
||||||
CommandKit classifies shared usage errors and standard-family outcomes. Its
|
|
||||||
handler result contract accepts application-specific outcomes and preserves their
|
|
||||||
supplied classification. Scriptorium output validation and Notarius artifact
|
|
||||||
outcomes, for example, remain domain results. CommandKit translates results from
|
|
||||||
explicit classification and emits usage guidance for usage failures.
|
|
||||||
|
|
||||||
Common mechanics do not require a common success-report format for custom
|
|
||||||
commands. The application owns its artifact and operational output contracts.
|
|
||||||
Exact result/error interfaces and unresolved grammar choices are separate design
|
|
||||||
work, not approvals implied by this decision.
|
|
||||||
|
|
||||||
### Contract scope and consumer guidance
|
|
||||||
|
|
||||||
This decision governs CommandKit's public contracts and implemented behavior.
|
|
||||||
Each operation specifies the inputs, result semantics, callback lifetimes, and
|
|
||||||
metadata its provider supplies. Consumers choose how to implement those callbacks
|
|
||||||
and how to structure loading, execution, startup, and process status mapping.
|
|
||||||
|
|
||||||
Sharing acquisition and resolution between inspection and execution, and keeping
|
|
||||||
acquisition focused on producing inspection views, are recommended practices.
|
|
||||||
CommandKit supports those patterns through reusable inputs and lazy provider
|
|
||||||
hooks. Its API preserves declared results and coordinates its own calls; arbitrary
|
|
||||||
application internals remain application-owned.
|
|
||||||
|
|
||||||
Cobra isolation applies to CommandKit's exports and internal adapter. Independent
|
|
||||||
consumer use of another CLI library remains an application choice. Additional
|
|
||||||
families and source models can use capability contracts appropriate to their
|
|
||||||
resources, with standard meanings preserved for operations that are installed.
|
|
||||||
|
|
||||||
## Alternatives considered
|
## Alternatives considered
|
||||||
|
|
||||||
### Dispatcher and ownership
|
- Application dispatchers forwarding standard families would share handlers but
|
||||||
|
leave custom-command parsing, root help, and diagnostics duplicated.
|
||||||
| Alternative | Analysis |
|
- Raw-argument handlers or string-keyed input access would leave typed input
|
||||||
| --- | --- |
|
assembly and presence handling with every consumer.
|
||||||
| Application-owned dispatcher forwarding standard families | Small integration surface and convenient migration, but custom syntax, root help, and errors still need cross-dispatcher coordination; removes less infrastructure. |
|
- Reflection, struct tags, or generated bindings could shorten declarations but
|
||||||
| Standard definitions mounted into an application's framework | Reuses routing infrastructure, but application configuration can preserve inconsistent behavior outside mounted families. Internal framework reuse remains compatible with this decision. |
|
add another declaration language or toolchain.
|
||||||
| Shared metadata/help with separate routing | Unifies discovery but adds synchronization among metadata, routing, and parsing. One dispatcher avoids it. |
|
- Import-time registries or a separate integration registration framework would
|
||||||
| Both full dispatch and embeddable family handlers | Serves established external CLI architectures but adds integration surfaces and maintenance. Add embedding only for a concrete need. |
|
introduce hidden state or parallel assembly conventions.
|
||||||
| Raw-argument handlers for custom commands | Eases migration but leaves parsing and help responsibilities with applications. A future transition adapter would need to describe its separate parsing and help contract. |
|
|
||||||
| Universal application runtime framework | Could absorb startup and operational services, but expands beyond the demonstrated CLI boundary and domain ownership. |
|
|
||||||
| Verb-first standard paths | Groups actions, but distributes namespace ownership among individual routes. Namespace/resource-first paths keep family ownership clear. |
|
|
||||||
| Reserve root `prompt` and `profile` for PromptKit | Provides short resource paths but consumes application namespaces. A single `promptkit` family gives the library one ownership boundary. |
|
|
||||||
|
|
||||||
### Registration
|
|
||||||
|
|
||||||
| Alternative | Analysis |
|
|
||||||
| --- | --- |
|
|
||||||
| Declarative tree passed to construction | Makes assembly visible together; larger applications still need group composition. May become a convenience over the same registry. |
|
|
||||||
| Constructor options for every command/group | Compact setup, but excessive options can obscure commands versus application settings. Option conventions remain API design work. |
|
|
||||||
| Global registry populated by `init` | Less entry-point code, but hidden import effects, shared mutable state, and unclear ownership. Explicit instances avoid those costs. |
|
|
||||||
|
|
||||||
### Handler inputs
|
|
||||||
|
|
||||||
| Alternative | Analysis |
|
|
||||||
| --- | --- |
|
|
||||||
| Parsed-value accessor object | Uniform handlers, but string-based lookup, runtime type errors, and repeated assembly remain. |
|
|
||||||
| Individual typed parameters | Clear for small commands; grows into long signatures and adapter machinery. Typed structs scale more naturally. |
|
|
||||||
| Reflection and struct tags | Compact declarations, but adds a tag language, field-discovery rules, and runtime declaration validation. Explicit bindings keep declarations visible. |
|
|
||||||
| Typed command objects | Can organize complex handlers. CommandKit's invocation-state contract remains separate from caller-owned object lifetimes; objects are an application option. |
|
|
||||||
| Generated bindings/adapters | Can combine compact authoring and typing, but adds tooling and generated-file maintenance without demonstrated need. |
|
|
||||||
| Raw arguments | Simple routing integration, but returns common parsing and help to applications. |
|
|
||||||
|
|
||||||
## Consequences
|
## Consequences
|
||||||
|
|
||||||
One command system removes repeated routing, parser setup, help assembly, and
|
Applications use one command system while retaining domain ownership. Explicit
|
||||||
shared error plumbing as well as standard inspection handlers. Domain handlers
|
bindings and captured declarations require deliberate assembly, but integrations
|
||||||
and their declarations remain recognizable application code.
|
can supply complete families through the same public contracts.
|
||||||
|
|
||||||
The public registration surface is larger than family delegation, and explicit
|
[GoDoc](../../command.go) owns declaration, installation, and callback contracts;
|
||||||
bindings require connecting declarations to the correct fields. Those costs
|
the [consumer guide](../consumers/guide.md) explains their use. Exact grammar,
|
||||||
support consistent mechanics without requiring reflection or generated code.
|
commands, and outcomes belong in the [CLI reference](../cli.md). Binding identity,
|
||||||
|
absence handling, factory defaults, and operation-specific preparation timing
|
||||||
Shared parsing defects can affect every adopter. Consumer and backend integration
|
can evolve within this architecture through deliberate API changes.
|
||||||
evidence must protect selected semantics, including ownership conflicts, help
|
|
||||||
without configuration, presence, independent invocation inputs, cancellation,
|
|
||||||
and domain outcomes.
|
|
||||||
CommandKit releases document intentional compatibility changes. Coordinated
|
|
||||||
application documentation and subprocess-caller updates are recommended migration
|
|
||||||
practices.
|
|
||||||
|
|
||||||
The [architecture policy](../policy/architecture.md) owns normative boundaries.
|
|
||||||
[ADR 0002](0002-cobra-backend.md) selects the backend, and
|
|
||||||
[ADR 0003](0003-public-facade.md) defines its public/internal boundary.
|
|
||||||
|
|||||||
@@ -1,78 +0,0 @@
|
|||||||
# ADR 0002: Use Cobra and pflag as the initial internal CLI backend
|
|
||||||
|
|
||||||
## Status
|
|
||||||
|
|
||||||
Accepted.
|
|
||||||
|
|
||||||
## Date
|
|
||||||
|
|
||||||
2026-10-06
|
|
||||||
|
|
||||||
## Context
|
|
||||||
|
|
||||||
CommandKit's selected dispatcher model needs nested routing, flags and operands,
|
|
||||||
generated help, usage diagnostics, and invocation execution. Reimplementing this
|
|
||||||
generic infrastructure would add work beyond the suite-specific inspection and
|
|
||||||
consistency behavior that motivates the library.
|
|
||||||
|
|
||||||
## Decision
|
|
||||||
|
|
||||||
Use Cobra with pflag as the sole initial CLI backend. CommandKit translates its
|
|
||||||
registrations into backend command trees, flag bindings, and execution callbacks.
|
|
||||||
Reuse their routing, option parsing, help, context, and stream mechanisms and add
|
|
||||||
adaptation where selected CommandKit semantics require it.
|
|
||||||
|
|
||||||
CommandKit retains its own public registration API, typed inputs, standard
|
|
||||||
families, and observable contracts. Its public registration and handler APIs
|
|
||||||
use CommandKit-owned definitions and typed inputs. Cobra commands and pflag
|
|
||||||
getters stay within CommandKit's internal adapter. Independent consumer framework
|
|
||||||
use remains application-owned. [ADR 0003](0003-public-facade.md) records the
|
|
||||||
adapter and root-owned public contracts.
|
|
||||||
|
|
||||||
Invocations do not inherit parsed values or presence from previous invocations.
|
|
||||||
Rebuilding the backend command tree and input storage from registered definitions
|
|
||||||
is the initial implementation strategy; safe reuse remains an implementation
|
|
||||||
choice. CommandKit's tree construction uses registered metadata and leaves
|
|
||||||
acquisition callbacks uninvoked. Application startup and callback effects retain
|
|
||||||
their own lifecycle.
|
|
||||||
|
|
||||||
Implement one concrete backend. There is no initial public backend selector or
|
|
||||||
pluggable framework. A future replacement can change internal implementation
|
|
||||||
while preserving the public API and selected behavioral contracts.
|
|
||||||
|
|
||||||
## Alternatives considered
|
|
||||||
|
|
||||||
| Alternative | Advantages | Reason not selected |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| urfave/cli v3 | Destination bindings for flags and named operands, presence queries, and duplicate controls fit the chosen input model closely. | Credible alternative; Cobra's broader adoption is the preferred maintenance tradeoff. |
|
|
||||||
| pflag alone | Established parsing with a smaller integration surface. | Leaves more routing, help integration, and command infrastructure to implement. |
|
|
||||||
| Kong | Compact typed-struct and tag-driven declarations. | Its main declaration model differs from the explicit-binding API; adaptation reduces its benefit. |
|
|
||||||
| Standard `flag` alone | Familiar variable binding and custom value parsing. | First-operand stopping requires adaptation for the intended syntax, with more custom command infrastructure. |
|
|
||||||
| Expose Cobra as the public API | Less adapter code and the full framework surface. | Couples consumers to backend structures and framework-specific configuration. |
|
|
||||||
| Interchangeable backends immediately | Future integration flexibility. | Adds abstraction and behavior surfaces without a current requirement. |
|
|
||||||
|
|
||||||
## Consequences
|
|
||||||
|
|
||||||
The framework supplies established generic CLI machinery. CommandKit still owns
|
|
||||||
registration conflicts, reserved families, typed bindings and presence, standard
|
|
||||||
help and diagnostics, provider integration, and selected reports and outcomes.
|
|
||||||
|
|
||||||
The adapter must accommodate explicit empty/false/zero values, independent parsed
|
|
||||||
state, selected repetition and operand rules, and help without configuration.
|
|
||||||
Backend features do not expand the standard automatically: for example,
|
|
||||||
completion commands remain deferred unless deliberately selected.
|
|
||||||
|
|
||||||
Backend error handling must preserve application outcomes, avoid duplicate
|
|
||||||
diagnostics, and return control to the application's entry point.
|
|
||||||
|
|
||||||
The [architecture policy](../policy/architecture.md) owns normative boundaries.
|
|
||||||
[ADR 0001](0001-command-dispatch-and-inputs.md) records the dispatcher and input
|
|
||||||
model.
|
|
||||||
|
|
||||||
Reviewed primary references from the selection:
|
|
||||||
|
|
||||||
- [Cobra API](https://pkg.go.dev/github.com/spf13/cobra).
|
|
||||||
- [Cobra command and help guide](https://github.com/spf13/cobra/blob/main/site/content/user_guide.md).
|
|
||||||
- [Cobra flag bindings](https://cobra.dev/docs/how-to-guides/working-with-flags/).
|
|
||||||
- [pflag syntax and extension points](https://github.com/spf13/pflag).
|
|
||||||
- [urfave/cli v3 API](https://pkg.go.dev/github.com/urfave/cli/v3).
|
|
||||||
68
docs/adr/0002-public-facade-and-cli-backend.md
Normal file
68
docs/adr/0002-public-facade-and-cli-backend.md
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
# ADR 0002: Own public contracts behind an internal CLI backend
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Accepted.
|
||||||
|
|
||||||
|
## Date
|
||||||
|
|
||||||
|
2026-11-07; consolidates decisions made on 2026-10-06 and 2026-10-09.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
CommandKit needs established routing, parsing, and help machinery while retaining
|
||||||
|
control of its consumer contracts. Exposing a backend framework directly would
|
||||||
|
couple applications and integration handlers to that framework's objects and
|
||||||
|
configuration model.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Use the root `commandkit` package as the core module's public facade. Optional
|
||||||
|
integrations own their public contracts in separate modules under
|
||||||
|
[ADR 0004](0004-optional-integration-modules.md).
|
||||||
|
|
||||||
|
Use Cobra with pflag as the internal CLI backend. Confine their types and
|
||||||
|
operations to an internal adapter that translates CommandKit declarations and
|
||||||
|
selected semantics into backend machinery. Registry logic, shared definitions,
|
||||||
|
and standard handlers remain independent of backend structures. Do not mirror
|
||||||
|
the complete backend API through renamed wrappers.
|
||||||
|
|
||||||
|
Public types and handles expose deliberate contracts and keep implementation
|
||||||
|
state private. Exports must not reveal internal or backend types through aliases,
|
||||||
|
embedding, signatures, method sets, loosely typed values, or backend-owned
|
||||||
|
syntax errors. Translate those syntax failures into CommandKit diagnostics and
|
||||||
|
outcomes. Caller-supplied objects and errors retain their documented handling;
|
||||||
|
this boundary does not require transforming arbitrary application content.
|
||||||
|
|
||||||
|
Standard Go contracts remain appropriate. Deliberate upstream public types may
|
||||||
|
be exposed by integrations that own those dependencies. Implementation may live
|
||||||
|
in the owning public package; introduce internal layers and conversions where
|
||||||
|
they simplify the code or protect the backend boundary.
|
||||||
|
|
||||||
|
There is one concrete backend, without a public backend-selection framework.
|
||||||
|
A replacement must preserve public contracts or accompany deliberate
|
||||||
|
compatibility changes. Backend defaults do not independently define CommandKit
|
||||||
|
behavior.
|
||||||
|
|
||||||
|
## Alternatives considered
|
||||||
|
|
||||||
|
- Exposing Cobra directly would reduce adapter work but make the framework part
|
||||||
|
of the consumer contract.
|
||||||
|
- A forwarding-only facade with duplicate internal representations would enforce
|
||||||
|
layering at the cost of conversions without a demonstrated need.
|
||||||
|
- pflag alone or the standard flag package would leave more routing and help
|
||||||
|
machinery to implement. Other full CLI frameworks are credible alternatives;
|
||||||
|
Cobra's adoption was the preferred maintenance tradeoff.
|
||||||
|
- Interchangeable backends would add public assembly and behavior contracts
|
||||||
|
before a concrete need exists.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
Consumers depend on CommandKit's selected contracts while internal implementation
|
||||||
|
can evolve. The adapter still needs to preserve typed inputs, presence, public
|
||||||
|
error identity, and invocation lifetimes; package placement alone is insufficient.
|
||||||
|
|
||||||
|
The [architecture policy](../policy/architecture.md) owns dependency constraints,
|
||||||
|
GoDoc owns exact exports, and the [internal overview](../internal/overview.md)
|
||||||
|
owns package mechanics. Backend construction and reuse strategies are
|
||||||
|
implementation choices within the invocation-isolation contract.
|
||||||
61
docs/adr/0003-configuration-scopes.md
Normal file
61
docs/adr/0003-configuration-scopes.md
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
# ADR 0003: Expose configuration scopes through declared capabilities
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Accepted.
|
||||||
|
|
||||||
|
## Date
|
||||||
|
|
||||||
|
2026-11-07; consolidates decisions made on 2026-10-06.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
An application may expose its own configuration and configurations belonging to
|
||||||
|
integrated libraries. Repeating a configuration command tree for each one would
|
||||||
|
duplicate operator mechanics. Requiring identical inspection capabilities would
|
||||||
|
exclude useful integrations whose upstream APIs provide only a subset.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Expose independently meaningful configurations as named scopes through a shared
|
||||||
|
configuration command family. A scope identifies the configuration being
|
||||||
|
inspected; a command family defines operations on its resources. An integration
|
||||||
|
may expose either or both without requiring the other.
|
||||||
|
|
||||||
|
Each scope declares its supported operations and providers. Assemble standard
|
||||||
|
commands from the available capabilities and describe actual availability through
|
||||||
|
metadata-driven help and discovery. An unavailable capability does not prevent
|
||||||
|
using the scope's other operations.
|
||||||
|
|
||||||
|
Validate required provider wiring against declared capabilities. Check selected
|
||||||
|
operation availability before acquisition. Aggregate inspection must account for
|
||||||
|
its requested coverage rather than silently dropping unsupported scopes.
|
||||||
|
|
||||||
|
Use narrow integration contracts, reusing upstream public types where useful.
|
||||||
|
Applications supply acquisition and domain context; integrations adapt supported
|
||||||
|
inspection APIs under [ADR 0004](0004-optional-integration-modules.md). Scope and
|
||||||
|
comparison inputs follow the input/state separation in
|
||||||
|
[ADR 0001](0001-command-dispatch-and-inputs.md).
|
||||||
|
|
||||||
|
## Alternatives considered
|
||||||
|
|
||||||
|
- A separate command tree per configuration would make the target part of the
|
||||||
|
path but repeat shared operations and help.
|
||||||
|
- Requiring every operation for every scope would make one missing capability
|
||||||
|
block otherwise useful inspection.
|
||||||
|
- One universal operation set across library families would assign operations
|
||||||
|
to resources for which they have little meaning.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
Operators use a common configuration family while providers can expose supported
|
||||||
|
features incrementally. Capability declarations and help must describe actual
|
||||||
|
coverage, and provider abstractions must remain proportionate to integration
|
||||||
|
needs.
|
||||||
|
|
||||||
|
The [CLI reference](../cli.md#configuration-scopes) owns command inventory, scope
|
||||||
|
names and defaults, selector syntax, aggregate applicability, and report ordering.
|
||||||
|
The [consumer guide](../consumers/guide.md#configkit-scopes) and
|
||||||
|
[GoDoc](../../integrations/ckconfig/contracts.go) own provider wiring and precise
|
||||||
|
input/acquisition lifetimes. Those details do not require separate ADRs merely
|
||||||
|
because they are public contracts.
|
||||||
@@ -1,104 +0,0 @@
|
|||||||
# ADR 0003: Own public contracts through a root facade and internal CLI adapter
|
|
||||||
|
|
||||||
## Status
|
|
||||||
|
|
||||||
Partially superseded by [ADR 0010](0010-optional-integration-modules.md).
|
|
||||||
|
|
||||||
ADR 0010 narrows the root facade and public-type ownership decision to the core
|
|
||||||
module and places optional library dependencies in integration modules. The
|
|
||||||
remaining principles continue to apply; this record's decision text is retained.
|
|
||||||
|
|
||||||
## Date
|
|
||||||
|
|
||||||
2026-10-06
|
|
||||||
|
|
||||||
## Context
|
|
||||||
|
|
||||||
CommandKit needs room to change its internal implementation details while
|
|
||||||
consumers retain access to a stable external API. Exposing backend assembly or
|
|
||||||
extension structures would let applications ossify around implementation
|
|
||||||
details that maintainers may later decide to replace.
|
|
||||||
|
|
||||||
ConfigKit, PromptKit, and ReleaseKit demonstrate facades that own public types
|
|
||||||
and delegate through private state and explicit conversion. ConfigKit also
|
|
||||||
translates upstream YAML parser structures into an independent internal document
|
|
||||||
model.
|
|
||||||
|
|
||||||
The selection of Cobra as an internal dependency is compatible with this
|
|
||||||
decision. Public API independence needs a genuine contract boundary, and
|
|
||||||
keeping backend structures out of shared internal representations also makes
|
|
||||||
future replacement easier.
|
|
||||||
|
|
||||||
## Decision
|
|
||||||
|
|
||||||
Use the module root `commandkit` as the sole initial supported public facade.
|
|
||||||
Define CommandKit's public named types there and expose only selected operations,
|
|
||||||
extension contracts, and documented methods. Handles keep implementation state in
|
|
||||||
private fields.
|
|
||||||
|
|
||||||
Straightforward implementation may live in the root package without expanding
|
|
||||||
its exports. Introduce internal packages, interfaces, and conversions where they
|
|
||||||
simplify implementation or protect the backend boundary. Package organization and
|
|
||||||
the amount of internal layering follow implementation needs. Additional public
|
|
||||||
packages require a deliberate decision based on a durable consumer responsibility.
|
|
||||||
|
|
||||||
Do not alias public types to internal or backend types, anonymously embed those
|
|
||||||
types to promote methods, or expose implementation bridge methods. Exported
|
|
||||||
fields, signatures, variables, callbacks, interface methods, constraints, and
|
|
||||||
method sets must not reveal internal or Cobra/pflag structures. CommandKit does
|
|
||||||
not expose its internal backend objects or backend-specific syntax errors through
|
|
||||||
its public API, including through loosely typed values or returned errors.
|
|
||||||
Caller-supplied values and errors retain their documented handling; this boundary
|
|
||||||
does not require inspecting or transforming arbitrary application objects.
|
|
||||||
|
|
||||||
Standard contracts such as `context.Context` and `io.Writer` remain appropriate.
|
|
||||||
Public types from integration libraries, such as ConfigKit and PromptKit, may
|
|
||||||
be selected as deliberate dependencies. Application input types remain
|
|
||||||
application-owned. Go declarations and GoDoc own exact exported contracts.
|
|
||||||
|
|
||||||
Confine Cobra/pflag types and operations to an internal adapter. Registry logic,
|
|
||||||
standard handlers, and shared definitions remain independent of backend structures.
|
|
||||||
The adapter translates paths, descriptions, bindings, presence, callbacks, and
|
|
||||||
selected help/outcome semantics into backend machinery. Do not reproduce Cobra's
|
|
||||||
complete API through renamed wrappers.
|
|
||||||
|
|
||||||
Preserve independent parsed state per invocation under
|
|
||||||
[ADR 0001](0001-command-dispatch-and-inputs.md). Factories, callbacks, and shared
|
|
||||||
representations remain API and implementation design choices.
|
|
||||||
|
|
||||||
Translate backend-owned syntax failures into CommandKit diagnostics and outcomes.
|
|
||||||
Preserve useful public identity for application handler errors and cancellation
|
|
||||||
according to the invocation contract. Any conversions must preserve value
|
|
||||||
ownership, callback lifetime, presence, and supported error semantics.
|
|
||||||
|
|
||||||
## Alternatives considered
|
|
||||||
|
|
||||||
| Alternative | Analysis |
|
|
||||||
| --- | --- |
|
|
||||||
| Keep all machinery in the public root | Less forwarding, but harder consumer navigation and easier accidental public expansion. |
|
|
||||||
| Require a forwarding-only root and separate internal representations | Enforces a uniform layering scheme. It can add duplicate types and callback plumbing before those mechanisms simplify implementation. |
|
|
||||||
| Alias or embed implementation types | Less conversion code, but couples public type identity, fields, or method sets to internal choices. |
|
|
||||||
| Hide Cobra only behind the public facade | Hides types from applications but allows backend structures to spread through internal logic, complicating replacement. |
|
|
||||||
| Mirror Cobra's API with wrappers | Removes dependency names while preserving its conceptual coupling and compatibility surface. |
|
|
||||||
| Public packages for registry, bindings, backend, and other mechanisms | Adds supported imports and assembly contracts without a distinct consumer responsibility. |
|
|
||||||
| Selectable/pluggable backends immediately | Adds selection and extension machinery before a concrete need exists. |
|
|
||||||
|
|
||||||
## Consequences
|
|
||||||
|
|
||||||
Consumers depend on deliberate root contracts while internal algorithms,
|
|
||||||
collaborators, and package boundaries can evolve. Useful application registration
|
|
||||||
capabilities still need a proportionate API; minimizing accidental exports must
|
|
||||||
not recreate repetitive consumer infrastructure.
|
|
||||||
|
|
||||||
Forwarding and conversion add maintenance where used. Proportionate checks should
|
|
||||||
protect the public surface, backend isolation, and consumer behavior through the
|
|
||||||
real adapter. The implementation determines which checks provide useful evidence;
|
|
||||||
package relocation alone does not establish those properties.
|
|
||||||
|
|
||||||
Backend replacement can preserve the public Go API but must also preserve selected
|
|
||||||
CLI behavior or follow the release policy for intentional incompatibilities.
|
|
||||||
Backend defaults do not independently establish new CommandKit contracts.
|
|
||||||
|
|
||||||
The [architecture policy](../policy/architecture.md) owns normative boundaries.
|
|
||||||
[ADR 0001](0001-command-dispatch-and-inputs.md) records the dispatcher/input model,
|
|
||||||
and [ADR 0002](0002-cobra-backend.md) selects the initial backend.
|
|
||||||
71
docs/adr/0004-optional-integration-modules.md
Normal file
71
docs/adr/0004-optional-integration-modules.md
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
# ADR 0004: Separate optional CLI integrations from the core
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Accepted.
|
||||||
|
|
||||||
|
## Date
|
||||||
|
|
||||||
|
2026-11-07; consolidates decisions made on 2026-10-09 and 2026-10-11.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Applications need CommandKit with different combinations of library integrations,
|
||||||
|
including none. A core-only consumer should not acquire optional libraries in its
|
||||||
|
module graph. Integrations also need a clear boundary between exposing operator
|
||||||
|
tasks and taking over the upstream libraries' domain functionality.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Keep the core and optional integrations in separate Go modules with their own
|
||||||
|
public package contracts. Core owns shared CLI infrastructure. Integrations own
|
||||||
|
standard operator-facing commands, provider contracts, content projections, and
|
||||||
|
adapters for their libraries. They are not convenience libraries for
|
||||||
|
application-internal use of upstream APIs.
|
||||||
|
|
||||||
|
Dependencies point from integrations toward core. Core's API, implementation,
|
||||||
|
tests, examples, tools, and module requirements remain independent of optional
|
||||||
|
integration libraries. An integration may depend on another integration when
|
||||||
|
its responsibility requires it; document that dependency for consumers.
|
||||||
|
|
||||||
|
Applications explicitly install integration declarations through public core
|
||||||
|
contracts. Importing a module installs nothing. Integrations supply convenient
|
||||||
|
complete-family assembly and supported composition without accessing private
|
||||||
|
core state or backend structures. Add narrow public core capabilities for actual
|
||||||
|
integration needs rather than a universal provider or plugin framework.
|
||||||
|
|
||||||
|
The integrated libraries own configuration resolution, prompt preparation,
|
||||||
|
comparison, validation, and other domain algorithms. CommandKit invokes their
|
||||||
|
supported public APIs and preserves returned meaning, supported error identity,
|
||||||
|
and safe-disclosure rules. The adopted upstream version governs those semantics;
|
||||||
|
CommandKit does not maintain a parallel domain specification or reconstruct
|
||||||
|
comparison from rendered text or redacted snapshots.
|
||||||
|
|
||||||
|
Integrations choose report content from suitable inspection evidence, preserving
|
||||||
|
upstream confidentiality and presence distinctions. CommandKit may summarize or
|
||||||
|
project that safe evidence for operator needs. Applications retain ownership of
|
||||||
|
schemas, loading, engine construction, credentials, domain execution, and startup.
|
||||||
|
Arbitrary application content is not implicitly safe for disclosure.
|
||||||
|
|
||||||
|
## Alternatives considered
|
||||||
|
|
||||||
|
- Keeping integrations in core, or in packages within its module, would retain
|
||||||
|
their dependencies in the core module graph.
|
||||||
|
- Requiring applications to implement standard integration handlers would repeat
|
||||||
|
command and reporting infrastructure.
|
||||||
|
- Universal core provider types would introduce abstraction and conversion work
|
||||||
|
before concrete integrations justify it.
|
||||||
|
- Reimplementing upstream algorithms would duplicate domain ownership and could
|
||||||
|
lose evidence already withheld from safe inspection values.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
Consumers can adopt core independently and add integrations as needed. Separate
|
||||||
|
modules require coordinated dependency, validation, and release work. Integrations
|
||||||
|
remain maintained parts of CommandKit with shared CLI conventions.
|
||||||
|
|
||||||
|
Upstream changes require adapter and documentation updates against the adopted
|
||||||
|
public contract. The [CLI reference](../cli.md) owns operator behavior, GoDoc owns
|
||||||
|
provider contracts, and [ADR 0006](0006-invocation-reporting.md) owns the shared
|
||||||
|
presentation boundary. Report formats, comparison statuses, and individual
|
||||||
|
operation inventories can evolve without changing domain ownership.
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
# ADR 0004: Define standard inspection operations by family and capability
|
|
||||||
|
|
||||||
## Status
|
|
||||||
|
|
||||||
Accepted.
|
|
||||||
|
|
||||||
## Date
|
|
||||||
|
|
||||||
2026-10-06
|
|
||||||
|
|
||||||
## Context
|
|
||||||
|
|
||||||
CommandKit serves applications with shared ConfigKit and PromptKit inspection
|
|
||||||
needs. A defined operator surface establishes the infrastructure CommandKit
|
|
||||||
provides. Different integrations expose different resources and inspection
|
|
||||||
capabilities, while shared verbs benefit from consistent meanings.
|
|
||||||
|
|
||||||
## Decision
|
|
||||||
|
|
||||||
CommandKit supplies help and version reporting and defines ConfigKit and
|
|
||||||
PromptKit inspection operations. Each family defines its own supported set of
|
|
||||||
operations. Registration installs handlers for the capabilities supplied by the
|
|
||||||
integration and exposes their availability through help and discovery metadata.
|
|
||||||
|
|
||||||
| Standard operation | Responsibility |
|
|
||||||
| --- | --- |
|
|
||||||
| `help` | Describe registered standard and application commands. |
|
|
||||||
| `version` | Report application version by default; report embedded CommandKit version when explicitly selected. |
|
|
||||||
| `config scopes` | List registered configuration scopes and supported operations. |
|
|
||||||
| `config validate` | Validate the selected configuration and its configured definitions. |
|
|
||||||
| `config show` | Display final effective configuration. |
|
|
||||||
| `config sources` | Describe the acquired and synthetic input source inventory. |
|
|
||||||
| `config explain` | Explain effective or historical values and provenance at a selected path. |
|
|
||||||
| `config diff` | Compare effective configuration selections semantically. |
|
|
||||||
|
|
||||||
The optional PromptKit suite supplies `promptkit list prompts`,
|
|
||||||
`promptkit list profiles`, `promptkit show prompt`, and `promptkit show profile`.
|
|
||||||
These operations expose captured definition catalogs, prompt metadata, and
|
|
||||||
backend-completed named execution targets. PromptKit definition capabilities and
|
|
||||||
its configuration scope are registered according to their available provider
|
|
||||||
operations. Either can be integrated independently.
|
|
||||||
|
|
||||||
Adoption can use the supported subset under
|
|
||||||
[ADR 0001](0001-command-dispatch-and-inputs.md). Missing comparison or catalog
|
|
||||||
capabilities leave other inspection operations available.
|
|
||||||
|
|
||||||
Shared verbs keep consistent purposes: `list` enumerates resources, `show`
|
|
||||||
displays a selected resource, and `diff` compares supported representations. Each
|
|
||||||
family specializes those purposes for its own resources.
|
|
||||||
|
|
||||||
Version subjects are independent of configuration scopes and loading providers.
|
|
||||||
|
|
||||||
CommandKit owns vocabulary and observable contracts within its standard families.
|
|
||||||
Custom execution, rendering, serving, sessions, and artifact workflows retain
|
|
||||||
their application-owned meanings.
|
|
||||||
|
|
||||||
The [CLI reference](../cli.md) owns exact flags, operands, defaults, and report
|
|
||||||
schemas for implemented operations.
|
|
||||||
|
|
||||||
## Alternatives considered
|
|
||||||
|
|
||||||
| Alternative | Analysis |
|
|
||||||
| --- | --- |
|
|
||||||
| Initial operations limited to validation and display | Accelerates first integrations. Source explanation, comparison, and discovery remain repeated application infrastructure. |
|
|
||||||
| Require every operation for every integration | Gives a uniform inventory. One unavailable capability blocks other useful operations and future resource families. |
|
|
||||||
| Definition inspection through configuration operations alone | Source values and provenance share one path. Captured catalogs and completed execution targets require additional interpretation. |
|
|
||||||
| Standardize execution and mutation alongside inspection | Expands shared vocabulary to more workflows. Additional domain and state-management contracts require their own evidence and design. |
|
|
||||||
| Display dependency identities in ordinary application version output | Makes dependency information immediately visible. Application identity becomes coupled to dependency reporting. |
|
|
||||||
|
|
||||||
## Consequences
|
|
||||||
|
|
||||||
CommandKit supplies shared handlers with predictable meanings and discovers their
|
|
||||||
availability from registration. Each enabled operation needs its own data
|
|
||||||
contract: comparison needs supported native comparison results, catalogs need
|
|
||||||
enumeration, and profile display needs safe execution-target inspection.
|
|
||||||
|
|
||||||
The [scope model](0009-configuration-scopes.md) and
|
|
||||||
[comparison policy](0008-semantic-configuration-diff.md) describe related
|
|
||||||
capability and behavioral decisions.
|
|
||||||
65
docs/adr/0005-application-alias-routing.md
Normal file
65
docs/adr/0005-application-alias-routing.md
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
# ADR 0005: Separate application alias routing from canonical command ownership
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Accepted.
|
||||||
|
|
||||||
|
## Date
|
||||||
|
|
||||||
|
2026-11-07; original decision made on 2026-10-19.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Applications need alternate command paths and flag spellings that suit their
|
||||||
|
operator vocabulary. These conveniences should reuse the behavior supplied by
|
||||||
|
application commands and optional integrations. Canonical command families
|
||||||
|
already establish ownership of handlers, namespaces, inputs, and invocation
|
||||||
|
behavior.
|
||||||
|
|
||||||
|
A routing layer allows applications to choose alternate spellings while
|
||||||
|
preserving those ownership boundaries. Commands and routing rules have distinct
|
||||||
|
roles, and their relationships require validation during application assembly.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
CommandKit core owns alias declaration, resolution, and coordination with parsing
|
||||||
|
and help. Applications choose their alias mappings. Alias infrastructure is
|
||||||
|
available independently of optional integrations under
|
||||||
|
[ADR 0004](0004-optional-integration-modules.md).
|
||||||
|
|
||||||
|
Aliases belong to an application routing layer that directs invocations to
|
||||||
|
canonical commands and flags. Canonical definitions retain ownership of their
|
||||||
|
handlers, inputs, and namespaces. An alternate path within an integration's
|
||||||
|
namespace remains an application routing rule; the integration continues to own
|
||||||
|
the canonical command family and its operations.
|
||||||
|
|
||||||
|
Canonical commands and routing rules are distinct declaration kinds installed
|
||||||
|
through the common assembly boundary in [ADR 0001](0001-command-dispatch-and-inputs.md).
|
||||||
|
Installation validates their relationships together and preserves canonical
|
||||||
|
ownership. Declaration capture, retained errors, and batch validation follow that
|
||||||
|
shared model.
|
||||||
|
|
||||||
|
An invocation through an alias uses the canonical target's parsing, typed
|
||||||
|
bindings, validation, and ordinary invocation lifecycle. Execution behavior
|
||||||
|
remains with the canonical command and its owning application or integration.
|
||||||
|
Core coordinates alternate routing and presentation through the same command
|
||||||
|
infrastructure.
|
||||||
|
|
||||||
|
## Alternatives considered
|
||||||
|
|
||||||
|
- Wrapper commands would introduce additional nodes and handler delegation into
|
||||||
|
namespace ownership and invocation lifetimes.
|
||||||
|
- Application argument rewriting would repeat routing infrastructure and separate
|
||||||
|
alias handling from target validation, parsing, and help.
|
||||||
|
- A separate alias assembly API would split validation of commands and their
|
||||||
|
alternate routes across installation boundaries.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
Applications can tailor vocabulary while reusing canonical behavior. Core must
|
||||||
|
coordinate routing, parsing, and help across those layers, while optional
|
||||||
|
integrations retain ownership of their command families.
|
||||||
|
|
||||||
|
GoDoc and the [CLI reference](../cli.md#application-owned-aliases) own exact API
|
||||||
|
shapes, resolution rules, and presentation choices. Those details can evolve
|
||||||
|
within this architectural separation.
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
# ADR 0005: Return explicit outcomes and deliver reports through defined channels
|
|
||||||
|
|
||||||
## Status
|
|
||||||
|
|
||||||
Accepted.
|
|
||||||
|
|
||||||
## Date
|
|
||||||
|
|
||||||
2026-10-06
|
|
||||||
|
|
||||||
## Context
|
|
||||||
|
|
||||||
An invocation crosses routing, parsing, provider acquisition, a standard or
|
|
||||||
application handler, report writing, and the application entry point. Operators
|
|
||||||
and subprocess consumers need consistent classifications for those stages.
|
|
||||||
Applications also have meaningful domain outcomes of their own.
|
|
||||||
|
|
||||||
## Decision
|
|
||||||
|
|
||||||
CommandKit returns an invocation outcome with an explicit classification and
|
|
||||||
recommended process status. The public model distinguishes successful operation,
|
|
||||||
usage error, operational failure, and application domain outcome. Failures retain
|
|
||||||
their consumer-facing cause and supported `errors.Is`/`errors.As` identity.
|
|
||||||
Exact Go declarations remain part of facade API design under
|
|
||||||
[ADR 0003](0003-public-facade.md).
|
|
||||||
|
|
||||||
Use these mappings for CommandKit-owned commands:
|
|
||||||
|
|
||||||
| Status | Meaning |
|
|
||||||
| --- | --- |
|
|
||||||
| `0` | The requested operation completed successfully. |
|
|
||||||
| `1` | A syntactically valid operation failed during loading, validation, lookup, inspection, cancellation, diagnostics delivery, or report writing. |
|
|
||||||
| `2` | The invocation failed command, flag, scope, format, operand, or input-combination validation. |
|
|
||||||
|
|
||||||
CommandKit's handler result contract accepts domain classifications and suggested
|
|
||||||
process statuses supplied by the caller. CommandKit preserves that classification
|
|
||||||
when returning the handler's result. A custom domain status and a shared usage
|
|
||||||
status may have the same numeric value; their explicit classifications retain
|
|
||||||
their distinct meanings. The application entry point controls final process
|
|
||||||
termination and status mapping.
|
|
||||||
|
|
||||||
CommandKit writes standard reports to the supplied report stream, conventionally
|
|
||||||
stdout, and its warnings and errors to the supplied diagnostic stream,
|
|
||||||
conventionally stderr. Callers choose the streams. CommandKit assigns responsibility
|
|
||||||
for presenting its diagnostics, avoids duplicate presentation through its own
|
|
||||||
paths, and reports write failures. Caller callbacks retain control of their own
|
|
||||||
output. CommandKit adapts backend syntax failures into its public classification
|
|
||||||
and diagnostic presentation. Usage guidance accompanies usage errors; operational
|
|
||||||
failures receive diagnostics appropriate to the failed operation.
|
|
||||||
|
|
||||||
Warning severity alone permits a successful operation. A diagnostic writer
|
|
||||||
failure is an operational failure.
|
|
||||||
|
|
||||||
A writer failure can leave a partial report and returns operational failure. The
|
|
||||||
outcome identifies incomplete delivery. Custom handler output remains
|
|
||||||
application-owned. [ADR 0007](0007-native-yaml-reports.md) covers `config show` report
|
|
||||||
preparation; [ADR 0008](0008-semantic-configuration-diff.md) defines successful
|
|
||||||
comparison outcomes.
|
|
||||||
|
|
||||||
## Alternatives considered
|
|
||||||
|
|
||||||
| Alternative | Analysis |
|
|
||||||
| --- | --- |
|
|
||||||
| A single failure classification | Simplifies outcome handling. Callers derive syntax, operational, and domain distinctions through additional interpretation. |
|
|
||||||
| Backend errors and output as the public outcome model | Reuses framework machinery directly. Backend conventions become dependencies for applications and subprocess consumers. |
|
|
||||||
| One common mapping for application domain failures | Produces uniform numeric statuses. Applications adapt existing artifact and execution outcomes to shared categories. |
|
|
||||||
| Handler-owned diagnostics for every failure | Gives each handler full presentation control. Shared diagnostic consistency requires repeated ownership and formatting coordination. |
|
|
||||||
|
|
||||||
## Consequences
|
|
||||||
|
|
||||||
The outcome model supports both Go callers and executable entry points. Explicit
|
|
||||||
classification keeps domain identity intact through adapter and facade conversion.
|
|
||||||
Single diagnostic ownership supports predictable presentation.
|
|
||||||
|
|
||||||
The public result/error design must preserve cause and delivery failures across
|
|
||||||
handlers, writers, and the backend adapter. Diagnostic delivery bookkeeping
|
|
||||||
remains API design work. Acceptance establishes the compatibility meaning of the
|
|
||||||
status values and delivery rules across standard commands.
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
# ADR 0006: Apply common command grammar and recommend shared vocabulary
|
|
||||||
|
|
||||||
## Status
|
|
||||||
|
|
||||||
Accepted.
|
|
||||||
|
|
||||||
## Date
|
|
||||||
|
|
||||||
2026-10-06
|
|
||||||
|
|
||||||
## Context
|
|
||||||
|
|
||||||
CommandKit owns routing, parsing, and help for standard and registered application
|
|
||||||
commands. A concrete grammar makes option placement, operands, repetition, and
|
|
||||||
help predictable across the application suite. Shared vocabulary also reduces
|
|
||||||
the need to remember different verbs for equivalent operations.
|
|
||||||
|
|
||||||
## Decision
|
|
||||||
|
|
||||||
CommandKit's standard grammar and vocabulary are part of the contract for
|
|
||||||
CommandKit-owned commands. Its shared dispatcher and binding APIs implement that
|
|
||||||
grammar, so application-owned commands registered through those APIs receive the
|
|
||||||
same parsing mechanics. Applications retain ownership of their command names,
|
|
||||||
operand meanings, and domain behavior.
|
|
||||||
|
|
||||||
CommandKit's documentation recommends the same grammar and vocabulary for
|
|
||||||
application-owned commands, including commands implemented outside its dispatcher.
|
|
||||||
Applications are encouraged to use standard verbs such as `help`, `list`, and
|
|
||||||
`show` where their operations have the corresponding purposes described in
|
|
||||||
[ADR 0004](0004-standard-inspection-surface.md).
|
|
||||||
|
|
||||||
CommandKit parses options after the complete registered command path. Within the
|
|
||||||
selected command, options and declared operands may be interspersed. Operand
|
|
||||||
counts and types come from registration.
|
|
||||||
|
|
||||||
Standard options use lowercase names with hyphen-separated words and the `--`
|
|
||||||
prefix. Value options accept `--name value` and `--name=value`. Boolean options
|
|
||||||
accept their documented presence form and explicit values such as `--name=false`.
|
|
||||||
The `--` token ends option parsing; subsequent tokens are operands.
|
|
||||||
|
|
||||||
Registered metadata identifies each flag as singleton or repeatable. For a
|
|
||||||
singleton, the last occurrence supplies its value, including when the invocation
|
|
||||||
mixes supported spellings. A repeatable flag accumulates values in invocation order.
|
|
||||||
Presence follows the typed-input contract in
|
|
||||||
[ADR 0001](0001-command-dispatch-and-inputs.md).
|
|
||||||
|
|
||||||
CommandKit validates paths, flag names, conversions, operand counts, and declared
|
|
||||||
input combinations through the shared parser. Those failures use the usage
|
|
||||||
classification in [ADR 0005](0005-invocation-outcomes.md).
|
|
||||||
|
|
||||||
Root help is available through `app`, `app help`, `app --help`, and `app -h`.
|
|
||||||
Invoking a command group with no registered execution handler displays group help.
|
|
||||||
A runnable command follows its registered handler and operand requirements,
|
|
||||||
including when it also has subcommands. CommandKit-owned groups such as `config`
|
|
||||||
display help on bare invocation. `app help COMMAND ...` and a resolved command's
|
|
||||||
`--help` display the same registered help. Required execution operands are checked
|
|
||||||
for execution requests; help uses registered metadata. Invalid paths and flag
|
|
||||||
names receive usage diagnostics.
|
|
||||||
|
|
||||||
## Alternatives considered
|
|
||||||
|
|
||||||
| Alternative | Analysis |
|
|
||||||
| --- | --- |
|
|
||||||
| Flags before all operands | Gives argument order a simple positional rule. Operators organize values around that rule when assembling commands. |
|
|
||||||
| Common flags before or between command-path components | Supports familiar global-option styles. Routing and help need rules for options across several command levels. |
|
|
||||||
| Reject repeated singleton flags | Detects repeated assignments, but prevents callers from assembling defaults followed by explicit overrides. |
|
|
||||||
| Application-selected parser conventions | Supports existing CLI implementations. Operators and shared help need per-application syntax knowledge. |
|
|
||||||
|
|
||||||
## Consequences
|
|
||||||
|
|
||||||
Operators learn one option placement and repetition policy for adopting
|
|
||||||
commands using CommandKit. Shared vocabulary guidance encourages consistent
|
|
||||||
names for equivalent application-owned operations.
|
|
||||||
|
|
||||||
The internal adapter must preserve the selected grammar across backend changes.
|
|
||||||
The [CLI reference](../cli.md) owns operation-specific command signatures and
|
|
||||||
examples for implemented commands.
|
|
||||||
78
docs/adr/0006-invocation-reporting.md
Normal file
78
docs/adr/0006-invocation-reporting.md
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
# ADR 0006: Share invocation reporting while preserving application outcomes
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Accepted.
|
||||||
|
The structured-report formatting extension is accepted but not yet implemented;
|
||||||
|
the [adoption roadmap](../roadmap/promptkit-v0.20.0.md#shared-core-report-output-prerequisite)
|
||||||
|
tracks that work.
|
||||||
|
|
||||||
|
## Date
|
||||||
|
|
||||||
|
2026-11-07; consolidates decisions made on 2026-10-06 and 2026-10-25
|
||||||
|
and the accepted shared-format ownership extension.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
An invocation crosses routing, parsing, acquisition, command execution, and output
|
||||||
|
delivery. Applications need consistent presentation and useful returned outcomes
|
||||||
|
without giving a CLI library control of domain recovery or process termination.
|
||||||
|
Separate reporting facilities would repeat visibility and failure-handling rules.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Core provides one reporting facility per invocation for command results,
|
||||||
|
immediate messages, deferred messages, and final presentation. Callers choose
|
||||||
|
result and diagnostic destinations. Applications and integrations choose content,
|
||||||
|
messages, and domain outcomes; core coordinates visibility, delivery, and its
|
||||||
|
own parsing and presentation failures.
|
||||||
|
|
||||||
|
Applications decide whether a domain error warrants recovery, a warning, or a
|
||||||
|
failed invocation. They supply explicit outcomes and explanations, optionally
|
||||||
|
with an error cause. A cause alone does not select an outcome or create an
|
||||||
|
operator-facing message. Preserve supplied causes and their supported public
|
||||||
|
identity. Integrations adapt upstream evidence without taking ownership of its
|
||||||
|
domain meaning or disclosure rules.
|
||||||
|
|
||||||
|
Return explicit outcome classifications that distinguish shared usage and
|
||||||
|
operational failures from application-defined domain outcomes. Preserve domain
|
||||||
|
classification and keep presentation failures separately observable from the
|
||||||
|
operation's explanation and cause. Delivery failure must be visible without
|
||||||
|
silently discarding the application's result. The application entry point owns
|
||||||
|
final process status mapping and termination.
|
||||||
|
|
||||||
|
Present terminal failure explanations after ordinary reporting finishes. Core
|
||||||
|
coordinates its own diagnostic paths to avoid duplicate presentation. Reporting
|
||||||
|
state serves delivery and the returned outcome; logging and message history
|
||||||
|
remain application responsibilities.
|
||||||
|
|
||||||
|
The accepted structured-report extension centralizes format selection,
|
||||||
|
serialization, and rendering in core. Applications and integrations hand off
|
||||||
|
selected content, including their chosen detail level and optional text template;
|
||||||
|
core determines how to output it. New core formats should work through that
|
||||||
|
shared handoff without each command maintaining encoders. This extends the same
|
||||||
|
invocation lifecycle, with a writer API sharing rendering outside invocations.
|
||||||
|
Implementation status and initial format choices remain in the roadmap above.
|
||||||
|
|
||||||
|
## Alternatives considered
|
||||||
|
|
||||||
|
- Handler-owned presentation would maximize local control but repeat output,
|
||||||
|
visibility, and delivery-error infrastructure.
|
||||||
|
- Letting core interpret domain errors would give it recovery decisions that
|
||||||
|
depend on application behavior.
|
||||||
|
- One failure category or numeric status alone would lose meaningful distinctions
|
||||||
|
between usage, operational, and domain results.
|
||||||
|
- Separate facilities for results and messages, or encoders in each integration,
|
||||||
|
would spread shared behavior across independent implementations.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
Commands share presentation while retaining ownership of their content and
|
||||||
|
outcomes. Core must keep operation and presentation evidence distinguishable,
|
||||||
|
including when output is partial. Caller writers retain their own lifecycle.
|
||||||
|
|
||||||
|
GoDoc and the [CLI reference](../cli.md#reporting) own implemented APIs, numeric
|
||||||
|
statuses, visibility controls, and exact delivery behavior. Report layouts,
|
||||||
|
format defaults, YAML framing, and scope ordering are presentation contracts,
|
||||||
|
not separate architectural decisions. The shared-format extension remains
|
||||||
|
planned until its implementation and canonical contracts land.
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
# ADR 0007: Use native YAML and ordered documents for inspection reports
|
|
||||||
|
|
||||||
## Status
|
|
||||||
|
|
||||||
Accepted.
|
|
||||||
|
|
||||||
## Date
|
|
||||||
|
|
||||||
2026-10-06
|
|
||||||
|
|
||||||
## Context
|
|
||||||
|
|
||||||
ConfigKit and PromptKit provide native YAML configuration inspection. Reusing
|
|
||||||
their writers preserves captured value semantics, ordering, and redaction. YAML
|
|
||||||
also supports automated consumption of structured reports.
|
|
||||||
|
|
||||||
## Decision
|
|
||||||
|
|
||||||
The initial standard uses text for concise operator reports and YAML for
|
|
||||||
structured consumption.
|
|
||||||
|
|
||||||
A single-scope `config show` writes the owning library's complete native redacted
|
|
||||||
YAML. Its output consists of the inspection document itself. The native writer
|
|
||||||
preserves effective presence, null and container distinctions, stable mapping
|
|
||||||
order, and sequence order according to its public contract.
|
|
||||||
|
|
||||||
`config show --scope all` emits one native document per registered scope in the
|
|
||||||
order defined by [ADR 0009](0009-configuration-scopes.md). CommandKit supplies
|
|
||||||
document separators and scope-label comments around native writer output. Each
|
|
||||||
scope retains its own value namespace and native rendering; the documents can
|
|
||||||
be decoded individually.
|
|
||||||
|
|
||||||
For `config show`, CommandKit checks operation availability for selected scopes
|
|
||||||
before acquisition. It acquires all selected views and completes validation
|
|
||||||
required by those provider contracts before report output begins. A failure
|
|
||||||
returns its diagnostic outcome before report writing. Other inspection operations
|
|
||||||
define their own output timing. After writing begins, a writer failure may leave
|
|
||||||
partial bytes and returns operational failure under
|
|
||||||
[ADR 0005](0005-invocation-outcomes.md).
|
|
||||||
|
|
||||||
Report confidentiality follows the owning libraries' sensitivity classifications.
|
|
||||||
Their safe writers and records supply redacted content. CommandKit preserves
|
|
||||||
those confidentiality semantics; arbitrary caller content retains the caller's
|
|
||||||
disclosure policy.
|
|
||||||
|
|
||||||
Additional families select formats appropriate to their resources. Custom handler
|
|
||||||
formats remain application-owned. The [CLI reference](../cli.md) owns exact
|
|
||||||
per-operation formats, framing syntax, and report shapes for implemented commands.
|
|
||||||
|
|
||||||
## Alternatives considered
|
|
||||||
|
|
||||||
| Alternative | Analysis |
|
|
||||||
| --- | --- |
|
|
||||||
| A scope-keyed YAML wrapper | Carries scope identity as parsed mapping keys. Rendering needs an additional wrapper representation and indentation of native documents. |
|
|
||||||
| JSON alongside YAML initially | Serves JSON-specific tools. The library maintains another serializer and representation contract. |
|
|
||||||
| YAML as every report's default | Provides uniform structured output. Interactive help, tables, and explanation workflows use structured documents for routine discovery. |
|
|
||||||
| Write `config show` documents as each scope is acquired | Starts reporting earlier. A later acquisition failure can leave a successful prefix representing only part of the selection. |
|
|
||||||
| Marshal application runtime structs | Reuses application data types. Presence, captured resolution, ordering, and safe historical inspection need a separate rendering lifecycle. |
|
|
||||||
|
|
||||||
## Consequences
|
|
||||||
|
|
||||||
Native display preserves owning-library behavior and keeps rendering focused.
|
|
||||||
Combined inspection requires multi-document YAML consumption and an ordered scope
|
|
||||||
contract.
|
|
||||||
|
|
||||||
Loading all selected scopes before `config show` output adds preparation time and
|
|
||||||
allows a failure in one scope to prevent combined display. Explicit single-scope
|
|
||||||
display remains available for a narrower view.
|
|
||||||
|
|
||||||
Additional structured schemas are compatibility contracts owned by CommandKit.
|
|
||||||
Native concatenation can use existing writers; semantic comparison uses the owning
|
|
||||||
library's supported comparison API under
|
|
||||||
[ADR 0008](0008-semantic-configuration-diff.md).
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
# ADR 0008: Delegate effective configuration comparison to owning libraries
|
|
||||||
|
|
||||||
## Status
|
|
||||||
|
|
||||||
Accepted.
|
|
||||||
|
|
||||||
## Date
|
|
||||||
|
|
||||||
2026-10-06; revised 2026-10-11.
|
|
||||||
|
|
||||||
## Context
|
|
||||||
|
|
||||||
ConfigKit provides a native configuration comparison capability. Exposing it
|
|
||||||
through CommandKit gives applications a consistent operator interface and reduces
|
|
||||||
repeated command, selection, and reporting infrastructure. ConfigKit remains the
|
|
||||||
authority for the meaning and disclosure of its comparison results.
|
|
||||||
|
|
||||||
## Decision
|
|
||||||
|
|
||||||
### Comparison ownership
|
|
||||||
|
|
||||||
For ConfigKit-backed scopes declaring comparison support, `config diff` exposes
|
|
||||||
ConfigKit's native comparison API. CommandKit checks capability availability before
|
|
||||||
acquisition and coordinates selection, acquisition, adaptation, presentation,
|
|
||||||
and invocation outcomes.
|
|
||||||
|
|
||||||
Integrations delegate comparison to their owning libraries' supported public
|
|
||||||
APIs. ConfigKit owns its comparison semantics and disclosure rules. CommandKit
|
|
||||||
follows the public contract of the ConfigKit version it adopts. When an adopted
|
|
||||||
upstream revision changes those semantics, the changes flow through to
|
|
||||||
CommandKit's integration. Upstream remains authoritative as the capability evolves.
|
|
||||||
|
|
||||||
Application-supplied providers retain ownership of their implementation and
|
|
||||||
loading effects. [ADR 0012](0012-typed-configuration-comparison-pairs.md) governs
|
|
||||||
paired selections, provider contracts, and typed input/shared-state lifetimes.
|
|
||||||
|
|
||||||
### Safe disclosure and presentation
|
|
||||||
|
|
||||||
Adapt and present native comparison results in accordance with the adopted
|
|
||||||
upstream API's disclosure contract. The integration carries the upstream results
|
|
||||||
and relevant metadata through its public provider boundary.
|
|
||||||
|
|
||||||
CommandKit owns report presentation and may summarize or transform the returned
|
|
||||||
safe comparison data for different operator needs. Reports may vary in format,
|
|
||||||
organization, and detail while respecting safe disclosure and the evidence
|
|
||||||
provided by the comparison. Each report's documented meaning defines the
|
|
||||||
conclusions an operator can draw from it.
|
|
||||||
|
|
||||||
Report formats, schemas, and rendering choices belong in the
|
|
||||||
[CLI reference](../cli.md) when implemented. They can evolve independently of
|
|
||||||
this ownership decision under the [release policy](../policy/release.md).
|
|
||||||
|
|
||||||
### Invocation outcomes
|
|
||||||
|
|
||||||
A completed comparison returns success regardless of whether differences are
|
|
||||||
reported. Load, comparison, and writer
|
|
||||||
failures use the operational failure classification in
|
|
||||||
[ADR 0005](0005-invocation-outcomes.md). Applications may use comparison results
|
|
||||||
to make their own domain decisions.
|
|
||||||
|
|
||||||
## Alternatives considered
|
|
||||||
|
|
||||||
| Alternative | Analysis |
|
|
||||||
| --- | --- |
|
|
||||||
| Compare safe snapshots in CommandKit | Gives CommandKit direct control over comparison, but requires it to maintain equality and sensitivity logic and account for values already withheld from snapshots. Native comparison keeps those responsibilities with the owning library. |
|
|
||||||
| Compare rendered configuration text | Reuses existing output, but formatting and masking affect the apparent differences. Native comparison supplies evidence grounded in the library's effective-value model. |
|
|
||||||
| Leave comparison commands entirely to applications | Preserves application flexibility, but repeats selection, acquisition coordination, and presentation infrastructure across consumers. Integration providers retain application control within the common command system. |
|
|
||||||
| Return a distinct status when differences are found | Supports assertion-oriented scripts, but couples successful inspection with a policy judgment about the result. The selected outcome model leaves that judgment to consumers. |
|
|
||||||
|
|
||||||
## Consequences
|
|
||||||
|
|
||||||
CommandKit supplies consistent comparison commands while relying on owning
|
|
||||||
libraries for comparison semantics and safe disclosure. Dependency adoption
|
|
||||||
includes adapting the integration and its documentation to upstream changes.
|
|
||||||
New library integrations
|
|
||||||
are assessed through their supported comparison contracts; each scope can expose
|
|
||||||
its available capabilities independently under
|
|
||||||
[ADR 0009](0009-configuration-scopes.md).
|
|
||||||
|
|
||||||
Reports can serve different operator needs using the same native comparison
|
|
||||||
data. Their level of detail may differ, and sensitivity rules may limit what
|
|
||||||
can be shown. Maintaining the boundary between comparison evidence and report
|
|
||||||
presentation allows those reports to evolve while preserving library ownership
|
|
||||||
and consumer control.
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
# ADR 0009: Select named configuration scopes with declared inspection capabilities
|
|
||||||
|
|
||||||
## Status
|
|
||||||
|
|
||||||
Accepted.
|
|
||||||
|
|
||||||
## Date
|
|
||||||
|
|
||||||
2026-10-06
|
|
||||||
|
|
||||||
## Context
|
|
||||||
|
|
||||||
An application can expose its own configuration, PromptKit engine configuration,
|
|
||||||
and other independently meaningful configurations through the `config` family.
|
|
||||||
A scope identifies the configuration targeted by an operation. Different
|
|
||||||
providers may support different inspection operations.
|
|
||||||
|
|
||||||
Configuration scopes and command families are distinct. A scope targets
|
|
||||||
configuration; a family defines operations on its own resources. A library
|
|
||||||
integration can expose either or both.
|
|
||||||
|
|
||||||
## Decision
|
|
||||||
|
|
||||||
Use stable semantic scope identifiers. The `app` scope describes application
|
|
||||||
configuration; an optional `promptkit` scope describes captured engine
|
|
||||||
configuration. Registration also accepts additional independently meaningful
|
|
||||||
configuration scopes with unique identifiers.
|
|
||||||
|
|
||||||
Each scope declares its available inspection operations and their providers.
|
|
||||||
Capabilities can include validation, native effective YAML, source inventory,
|
|
||||||
path explanation, and safe semantic comparison data. CommandKit validates declared
|
|
||||||
wiring and registers operations supplied by at least one scope under
|
|
||||||
[ADR 0004](0004-standard-inspection-surface.md). Help and scope discovery report
|
|
||||||
actual capabilities from metadata, independently of file loading.
|
|
||||||
|
|
||||||
Define narrow integration contracts, reusing upstream public types where useful
|
|
||||||
and introducing CommandKit-owned abstractions where they simplify integration.
|
|
||||||
Preserve supported upstream values, provenance, and confidentiality. Select
|
|
||||||
provider representations against concrete integration needs; exact Go declarations
|
|
||||||
remain API design work under
|
|
||||||
[ADR 0003](0003-public-facade.md).
|
|
||||||
|
|
||||||
`--scope NAME` selects one configuration. `all` denotes all registered scopes for
|
|
||||||
`config validate` and `config show`; `sources`, `explain`, and `diff` select one.
|
|
||||||
An aggregate selection retains its all-scope meaning. CommandKit checks operation
|
|
||||||
availability across the requested selection before acquiring views. An unavailable
|
|
||||||
operation yields a usage diagnostic identifying the scope and available choices,
|
|
||||||
so an aggregate result accurately describes the requested coverage.
|
|
||||||
|
|
||||||
`show`, `sources`, `explain`, `diff`, and `validate` default to `app`. Explicit
|
|
||||||
`--scope all` requests aggregate validation or display.
|
|
||||||
|
|
||||||
Scope order is `app` first when registered, `promptkit` next when registered,
|
|
||||||
then additional scopes sorted by identifier. `config scopes` and combined YAML
|
|
||||||
show use that order. [ADR 0007](0007-native-yaml-reports.md) defines combined
|
|
||||||
presentation. The [CLI reference](../cli.md) owns implemented selector syntax;
|
|
||||||
the [consumer guide](../consumers/guide.md) explains acquisition integration.
|
|
||||||
|
|
||||||
## Alternatives considered
|
|
||||||
|
|
||||||
| Alternative | Analysis |
|
|
||||||
| --- | --- |
|
|
||||||
| Require every configuration inspection operation per scope | Gives one capability set. Missing comparison or provenance blocks other useful inspection operations. |
|
|
||||||
| A separate command tree for each configuration | Makes the target part of the path. Repeats common operations and help. |
|
|
||||||
| One verb set for every library family | Gives a uniform inventory. Resource domains acquire operations with little useful meaning. |
|
|
||||||
| Executable names as scope identifiers | Relates scopes to application branding. Shared scripts need application-specific identifiers. |
|
|
||||||
| Validation defaults to `all` | Checks every scope implicitly, but adding a scope without validation support can make bare validation unavailable. |
|
|
||||||
| Skip unsupported scopes during aggregate inspection | Produces available reports. The meaning and coverage of `all` become less apparent. |
|
|
||||||
| Require CommandKit-owned provider types throughout | Gives a uniform abstraction boundary, but can duplicate upstream APIs and require unnecessary adapters. |
|
|
||||||
|
|
||||||
## Consequences
|
|
||||||
|
|
||||||
A shared configuration family serves multiple configurations while help and
|
|
||||||
inventory make operation availability explicit. Individual capabilities can be
|
|
||||||
integrated before other upstream inspection APIs exist. Default and ordering
|
|
||||||
rules remain observable CommandKit behavior.
|
|
||||||
|
|
||||||
An `all` request can report unsupported coverage; default application inspection
|
|
||||||
remains independent of other scopes' capabilities. Provider design must balance
|
|
||||||
simple wiring against dependency coupling as concrete integrations are developed.
|
|
||||||
@@ -1,100 +0,0 @@
|
|||||||
# ADR 0010: Separate the core facade from optional integration modules
|
|
||||||
|
|
||||||
## Status
|
|
||||||
|
|
||||||
Accepted. Partially supersedes
|
|
||||||
[ADR 0003](0003-public-facade.md) as described below.
|
|
||||||
Partially superseded by [ADR 0011](0011-captured-declarations-and-installation.md)
|
|
||||||
for the consumer-migration consequence during the unreleased prototype
|
|
||||||
transition. It also supplies the registration and invocation-lifetime
|
|
||||||
decisions left open here. The module
|
|
||||||
boundary and remaining principles continue to apply; this record's decision
|
|
||||||
text is retained.
|
|
||||||
|
|
||||||
## Date
|
|
||||||
|
|
||||||
2026-10-09
|
|
||||||
|
|
||||||
## Context
|
|
||||||
|
|
||||||
CommandKit serves consumers that need its command infrastructure with different
|
|
||||||
combinations of library integrations, including none. A consumer using only the
|
|
||||||
core should not acquire dependencies belonging to optional integrations.
|
|
||||||
|
|
||||||
ADR 0003 establishes deliberate public contracts and an internal CLI backend
|
|
||||||
boundary. Those principles remain appropriate. Its initial single root facade,
|
|
||||||
however, combines core and integration responsibilities. Separate packages within
|
|
||||||
one module would separate imports while retaining shared module requirements.
|
|
||||||
|
|
||||||
## Decision
|
|
||||||
|
|
||||||
Retain the root `commandkit` package as the core module's public facade. It owns
|
|
||||||
core command declarations, registration, typed inputs, dispatch, help/version,
|
|
||||||
shared syntax, diagnostics, and invocation outcomes.
|
|
||||||
|
|
||||||
Provide optional library integrations as separate Go modules with their own
|
|
||||||
public package contracts. Each integration owns the standard command handlers,
|
|
||||||
provider contracts, reports, and adapters appropriate to its responsibility.
|
|
||||||
Applications select integrations and explicitly register their commands; importing
|
|
||||||
an integration does not install a command family.
|
|
||||||
|
|
||||||
Integrations provide convenient registration of their standard command families,
|
|
||||||
with standard declarations and handlers supplied by the integration. Applications
|
|
||||||
provide acquisition and domain-specific context without assembling individual
|
|
||||||
standard commands or repeating adapter plumbing. More specialized composition
|
|
||||||
remains available where concrete consumer needs justify it.
|
|
||||||
|
|
||||||
Dependencies point from integrations toward the core. The core's public API,
|
|
||||||
implementation, and module requirements remain independent of optional
|
|
||||||
integration libraries. An integration may depend on another integration when
|
|
||||||
its responsibility requires that relationship. Document such dependencies so
|
|
||||||
consumers can understand the modules they select.
|
|
||||||
|
|
||||||
Integrations compose through supported public core contracts and preserve
|
|
||||||
CommandKit's standard grammar, command ownership, help, and outcome conventions.
|
|
||||||
They do not access private core state or backend structures. Add public contracts
|
|
||||||
where concrete integration needs justify them, without requiring a general plugin
|
|
||||||
or backend-selection framework.
|
|
||||||
|
|
||||||
### Relationship to ADR 0003
|
|
||||||
|
|
||||||
The root-facade and public-type ownership provisions of ADR 0003 now apply to
|
|
||||||
the core module. Integration-owned public types and operations belong in their
|
|
||||||
respective public packages. The permission to expose deliberately selected
|
|
||||||
upstream library types applies within the integrations that depend on them;
|
|
||||||
it does not justify optional integration dependencies in the core.
|
|
||||||
|
|
||||||
ADR 0003's remaining principles continue to apply across the core and
|
|
||||||
integrations: deliberate exports, private implementation state, proportionate
|
|
||||||
internal layering, no exposure of internal or backend types, and preservation of
|
|
||||||
documented ownership, presence, callback lifetime, and error semantics. Application
|
|
||||||
input types and caller-supplied values and errors retain their existing handling.
|
|
||||||
Cobra/pflag remain behind the core's internal adapter.
|
|
||||||
|
|
||||||
This decision changes the scope of the single-facade decision rather than
|
|
||||||
replacing its contract discipline. ADR 0003 is partially superseded and its
|
|
||||||
original decision text remains intact.
|
|
||||||
|
|
||||||
## Alternatives considered
|
|
||||||
|
|
||||||
| Alternative | Analysis |
|
|
||||||
| --- | --- |
|
|
||||||
| Keep integrations in the root package | Preserves existing imports and simplifies releases, but core consumers acquire optional library dependencies. |
|
|
||||||
| Use separate packages within one module | Separates compiled imports and retains one release unit, but keeps integration requirements in the shared module graph. |
|
|
||||||
| Have applications implement integration handlers | Keeps the core independent, but recreates duplicated inspection commands and weakens consistent operator behavior. |
|
|
||||||
| Introduce universal provider contracts in the core | Could support several libraries through a common model, but adds abstractions and conversion obligations before concrete needs justify them. |
|
|
||||||
|
|
||||||
## Consequences
|
|
||||||
|
|
||||||
Consumers can adopt the core independently and add supported integrations as
|
|
||||||
needed. Integrations remain maintained parts of CommandKit, with shared CLI
|
|
||||||
conventions and owning-library semantics. Dependency independence does not mean
|
|
||||||
the core has no external implementation dependencies.
|
|
||||||
|
|
||||||
Moving existing integration APIs requires a documented consumer migration.
|
|
||||||
Integration modules add module-specific dependency, validation, and release work.
|
|
||||||
Core tests, examples, and tools must not reintroduce the optional dependencies
|
|
||||||
that this boundary removes.
|
|
||||||
|
|
||||||
Exact registration mechanics and release cadence are not selected here.
|
|
||||||
Current GoDoc and CLI contracts continue to own implemented behavior.
|
|
||||||
@@ -1,172 +0,0 @@
|
|||||||
# ADR 0011: Capture command declarations and install optional families explicitly
|
|
||||||
|
|
||||||
## Status
|
|
||||||
|
|
||||||
Accepted. Refines [ADR 0001](0001-command-dispatch-and-inputs.md) for declaration
|
|
||||||
construction, installation, optional-family namespace reservation, and integration
|
|
||||||
input/state lifetimes. Partially supersedes
|
|
||||||
[ADR 0010](0010-optional-integration-modules.md) only for its consumer-migration
|
|
||||||
consequence during the unreleased prototype transition, and refines its
|
|
||||||
convenient composition principle.
|
|
||||||
|
|
||||||
Refined by [ADR 0012](0012-typed-configuration-comparison-pairs.md) for
|
|
||||||
two-selection configuration comparison and its input/state lifetime. The
|
|
||||||
single-selection lifecycle and other decisions remain in force.
|
|
||||||
|
|
||||||
## Date
|
|
||||||
|
|
||||||
2026-10-10
|
|
||||||
|
|
||||||
## Context
|
|
||||||
|
|
||||||
Applications declare domain commands, while optional integrations assemble
|
|
||||||
standard command families. A common construction and installation model reduces
|
|
||||||
repeated error checks and registration plumbing. Integrations need public typed
|
|
||||||
composition contracts to assemble those families while preserving core and
|
|
||||||
backend boundaries.
|
|
||||||
|
|
||||||
Declaration and installation have different responsibilities. An optional family
|
|
||||||
claims a namespace when installed; selecting a dependency alone should leave the
|
|
||||||
application's command surface unchanged. During execution, scope-specific CLI
|
|
||||||
inputs and application-owned shared state also have distinct lifetimes. Making
|
|
||||||
those lifetimes explicit avoids accidental aliasing between selected scopes.
|
|
||||||
|
|
||||||
At the time of this decision, the implementation was an unreleased prototype
|
|
||||||
with no downstream consumers. Its API shapes carried no compatibility obligations
|
|
||||||
that would constrain the selected design.
|
|
||||||
|
|
||||||
## Decision
|
|
||||||
|
|
||||||
### Declaration capture and reuse
|
|
||||||
|
|
||||||
Applications and integrations construct the same opaque core command declarations
|
|
||||||
and install them through core application construction or registration. Initial
|
|
||||||
integrations provide complete-family factories and focused conveniences through
|
|
||||||
that common installation API. Suite and provider types remain integration-owned.
|
|
||||||
|
|
||||||
Factories eagerly capture metadata, bindings, capability selections, selector
|
|
||||||
applicability, and callback references. They copy the declaration collections
|
|
||||||
needed to isolate returned commands from later edits to source metadata.
|
|
||||||
Construction errors remain attached for installation to report. This model uses
|
|
||||||
eager declarations with public typed composition; a deferred preparation lifecycle
|
|
||||||
and untyped integration payload are outside the selected API.
|
|
||||||
|
|
||||||
Declarations may be reused across applications with independent registry and
|
|
||||||
invocation state. Callback references and captured application objects retain
|
|
||||||
caller ownership. Reuse provides no deep-copy or concurrent-dispatch guarantee.
|
|
||||||
Application helpers performing other fallible work may still return errors.
|
|
||||||
|
|
||||||
### Installation and retained errors
|
|
||||||
|
|
||||||
Core validates each installation batch before registry mutation, checking
|
|
||||||
retained errors, bindings, placement, and collisions. Failed application
|
|
||||||
construction returns no usable application; failed later registration preserves
|
|
||||||
the existing registry. Atomicity covers one installation call. Separate calls
|
|
||||||
and arbitrary callback effects retain their own lifetimes.
|
|
||||||
|
|
||||||
Report retained errors with useful command context and preserve supported wrapped
|
|
||||||
error identity. The aggregate-error representation remains an implementation
|
|
||||||
choice.
|
|
||||||
|
|
||||||
An explicit absence declaration contributes no command, help entry, or namespace.
|
|
||||||
Installation checks attached errors before skipping it. A zero command declaration
|
|
||||||
remains invalid. Integrations define their own empty-suite semantics: the initial
|
|
||||||
configuration suite needs at least one named scope, and metadata-only scopes
|
|
||||||
remain discoverable. A definition suite with no capabilities contributes nothing.
|
|
||||||
|
|
||||||
Binding validation applies to generated command declarations. Unused selector
|
|
||||||
bindings in an absent family are ignored; explicitly retained adapter-construction
|
|
||||||
errors still reach installation. A nil loader supplied to an adapter remains
|
|
||||||
invalid wiring even if its capabilities are subsequently cleared.
|
|
||||||
|
|
||||||
### Namespace ownership and placement
|
|
||||||
|
|
||||||
Core construction reserves help and version. An optional standard family owns
|
|
||||||
its complete root namespace upon installation. Imports and absent declarations
|
|
||||||
claim nothing. An application may occupy an uninstalled family name; installing
|
|
||||||
a conflicting declaration then fails in either registration order. Installation
|
|
||||||
preserves existing handlers and group ownership. Private application operations
|
|
||||||
remain outside installed standard families.
|
|
||||||
|
|
||||||
Standard families carry root-only placement metadata, which core enforces when
|
|
||||||
they are nested beneath another group. Placement is a public declaration property;
|
|
||||||
its enforcement is independent of package identity or a list of integration names.
|
|
||||||
A real empty root-only group claims its namespace and obeys placement checks.
|
|
||||||
Explicit absence contributes no node to place.
|
|
||||||
|
|
||||||
### Input and shared-state lifetimes
|
|
||||||
|
|
||||||
Integration suites distinguish typed CLI input from typed shared acquisition
|
|
||||||
state. Ordinary configuration inspection initializes one input per selected scope,
|
|
||||||
applies its applicable selector bindings, and initializes shared state once for
|
|
||||||
the inspection invocation. Input validation receives the scope identity and its
|
|
||||||
typed input. Named definition inspection uses one input and one shared state.
|
|
||||||
Providers receive both explicitly. ADR 0012 refines this lifecycle for comparison
|
|
||||||
pairs with two selections of one scope.
|
|
||||||
|
|
||||||
Availability preflight and applicable input checks precede shared-state
|
|
||||||
initialization and acquisition. Failures may stop later input factories.
|
|
||||||
Construction, installation, help, version, and scope discovery leave suite
|
|
||||||
input/state factories and acquisition providers uninvoked.
|
|
||||||
|
|
||||||
An omitted factory supplies its type's zero value, including nil pointers.
|
|
||||||
Applications provide initialization needed by their accessors and providers;
|
|
||||||
providers may intentionally accept nil. Allocation of application objects,
|
|
||||||
cloning, and aliases returned by factories remain caller responsibilities.
|
|
||||||
Core-owned parsed collections stay independent when applied to separate inputs.
|
|
||||||
Caller-owned objects and callback effects retain their own lifetimes.
|
|
||||||
|
|
||||||
### Typed binding composition
|
|
||||||
|
|
||||||
Core provides a narrow typed contract to capture converted values and apply a
|
|
||||||
selected binding subset to initialized input. It preserves binding identity,
|
|
||||||
defaults, and presence while keeping private setters, parser state, and backend
|
|
||||||
objects behind their existing boundaries. A copied binding retains its identity;
|
|
||||||
a newly constructed lookalike has its own identity.
|
|
||||||
|
|
||||||
Execution-required checks apply to the selected subset. Missing required input
|
|
||||||
is a usage failure; invalid binding selection or destination is a wiring failure
|
|
||||||
with operational classification at invocation. These categories remain identifiable
|
|
||||||
through supported wrapping and stop execution before shared-state initialization
|
|
||||||
and acquisition. Metadata defects detectable during assembly of generated
|
|
||||||
commands remain installation errors under the validation boundary above.
|
|
||||||
|
|
||||||
### Relationship to earlier decisions
|
|
||||||
|
|
||||||
For the unreleased prototype transition covered by this decision, compatibility
|
|
||||||
wrappers and a downstream migration guide were unnecessary. This exception to
|
|
||||||
ADR 0010's migration consequence is limited to that transition. Changes to
|
|
||||||
published contracts follow the [release policy](../policy/release.md).
|
|
||||||
|
|
||||||
ADR 0001 retains dispatcher, registry, typed-input, and application-ownership
|
|
||||||
principles. ADR 0010 governs module boundaries and dependency direction, with
|
|
||||||
[ADR 0003](0003-public-facade.md) governing deliberate exports and backend isolation
|
|
||||||
within that scope. ADRs 0004 through 0009 retain their operation, grammar, report,
|
|
||||||
and outcome decisions. Go declarations and GoDoc own exact implemented signatures.
|
|
||||||
|
|
||||||
## Alternatives considered
|
|
||||||
|
|
||||||
| Alternative | Analysis |
|
|
||||||
| --- | --- |
|
|
||||||
| Fallible constructors plus integration-specific registration helpers | Makes each construction failure immediate, but creates several assembly conventions and repeated consumer checks. Captured errors keep one installation boundary. |
|
|
||||||
| Deferred preparation callbacks | Allows installation to construct a family, but adds lifecycle and error timing beyond eager capture. No demonstrated assembly need requires it. |
|
|
||||||
| Reserve all known optional family names at core construction | Prevents later name conflicts, but consumes namespaces for integrations the application has not selected. Installed-family ownership keeps conflicts explicit without that restriction. |
|
|
||||||
| Separate family/plugin registration framework | Could encode ownership and composition, but a captured command with root-only placement and batch validation expresses the required behavior without another installation model. |
|
|
||||||
| One shared input/state object | Uses fewer type parameters and can share a cache through references in separate input values, but leaves the distinct lifetimes implicit and scope-input isolation to application conventions. Explicit input and state types make those lifetimes visible. |
|
|
||||||
| Clone hooks or automatic pointer allocation | Can hide input construction, but adds object-graph and initialization responsibilities that belong to the application. Explicit factories keep those choices visible. |
|
|
||||||
| Public binding getters/setters or an untyped value bag | Enables integration assembly, but exposes private mechanics or returns typed-input assembly to string lookup. Narrow typed composition addresses the actual need. |
|
|
||||||
|
|
||||||
## Consequences
|
|
||||||
|
|
||||||
Consumers assemble domain commands and optional families through one installation
|
|
||||||
boundary. Eager assembly errors reach that boundary before provider acquisition.
|
|
||||||
Namespace collisions remain explicit choices for the application to resolve.
|
|
||||||
|
|
||||||
Separate input and state types make initialization and sharing visible. Focused
|
|
||||||
conveniences support simple applications, while typed composition gives
|
|
||||||
integrations the public contracts needed to assemble their families.
|
|
||||||
|
|
||||||
The [architecture policy](../policy/architecture.md) owns normative boundaries;
|
|
||||||
roadmaps own implementation status and sequencing. Application loading, cache
|
|
||||||
design, startup, domain validation, callback effects, and process exit remain
|
|
||||||
consumer responsibilities.
|
|
||||||
@@ -1,108 +0,0 @@
|
|||||||
# ADR 0012: Prepare typed selection pairs for configuration comparison
|
|
||||||
|
|
||||||
## Status
|
|
||||||
|
|
||||||
Accepted. Refines [ADR 0011](0011-captured-declarations-and-installation.md) for
|
|
||||||
two-selection configuration comparison; its other decisions remain in force.
|
|
||||||
[ADR 0008](0008-semantic-configuration-diff.md) governs native comparison and
|
|
||||||
report presentation.
|
|
||||||
|
|
||||||
## Date
|
|
||||||
|
|
||||||
2026-10-11
|
|
||||||
|
|
||||||
## Context
|
|
||||||
|
|
||||||
Native effective-configuration comparison needs two completed loads. ConfigKit
|
|
||||||
requires results of the same configuration type, which an ordinary inspection
|
|
||||||
view does not expose for pairwise comparison. A generic adapter can retain that
|
|
||||||
type without introducing result assertions or a universal core provider model.
|
|
||||||
|
|
||||||
ADR 0011 separates a selected scope's typed input from shared acquisition state.
|
|
||||||
A comparison selects the same scope twice, potentially with different roots or
|
|
||||||
overlays. Reusing one input obscures which selection a provider receives; creating
|
|
||||||
separate shared state loses the invocation-wide lifetime chosen for inspection.
|
|
||||||
|
|
||||||
## Decision
|
|
||||||
|
|
||||||
### Selections and provider ownership
|
|
||||||
|
|
||||||
Comparison selects one scope and prepares an earlier/left selection and a
|
|
||||||
later/right selection. Omitted right selection fields inherit the corresponding
|
|
||||||
left field and supplied state. An explicit right override, including an empty
|
|
||||||
value, replaces that field. Providers retain interpretation of omitted defaults
|
|
||||||
and explicit empty values. Applicable domain selector values are shared between
|
|
||||||
the two sides initially; independent per-side domain selectors may be considered
|
|
||||||
for a concrete consumer need.
|
|
||||||
|
|
||||||
Equal selections are allowed. CommandKit does not skip acquisition merely because
|
|
||||||
their selection values are equal: caller-owned loading may produce different
|
|
||||||
effective results. Exact flag spellings belong in the CLI reference when
|
|
||||||
implemented.
|
|
||||||
|
|
||||||
Use a narrow optional comparison provider owned by the integration. Its request
|
|
||||||
groups each side's selection with its typed input and supplies one shared state
|
|
||||||
value separately. The provider returns acquired native comparison results
|
|
||||||
with diagnostics and an error through the existing acquisition convention.
|
|
||||||
Reuse useful upstream public record types rather than duplicating them solely
|
|
||||||
to rename fields. Exact exported signatures belong in Go declarations and GoDoc.
|
|
||||||
|
|
||||||
The typed ConfigKit adapter invokes the supplied loader for each reached side
|
|
||||||
and passes the successful typed results to `configkit.Diff`. It retains the
|
|
||||||
configuration type inside that adapter, without reflection, result type
|
|
||||||
assertions, private core access, or a comparison-facet framework. Applications
|
|
||||||
may supply their own pair provider through the same integration contract.
|
|
||||||
|
|
||||||
Declared capabilities determine required wiring. A comparison-only scope needs
|
|
||||||
its comparison provider, without an unrelated ordinary acquisition callback.
|
|
||||||
Ordinary operations continue to use their ordinary acquisition provider.
|
|
||||||
Retained adapter construction errors still apply when capabilities are narrowed.
|
|
||||||
Other scopes and families need not support comparison under
|
|
||||||
[ADR 0009](0009-configuration-scopes.md).
|
|
||||||
|
|
||||||
### Typed input and shared state lifetime
|
|
||||||
|
|
||||||
Capture comparison declarations and applicable selector bindings under ADR 0011.
|
|
||||||
Convert supplied flag values through core once, including on help paths; applying
|
|
||||||
the values to both inputs does not parse them again.
|
|
||||||
|
|
||||||
After availability preflight, initialize each reached side's typed input
|
|
||||||
separately, apply the same applicable captured selector values, and check that
|
|
||||||
input with the selected scope identity and that side's selection. Finish both
|
|
||||||
input checks before initializing shared state once and invoking the pair
|
|
||||||
provider. Failures may stop later factories or loads. Construction, installation,
|
|
||||||
help, version, and scope discovery invoke neither input/state factories nor
|
|
||||||
acquisition/comparison providers.
|
|
||||||
|
|
||||||
Existing factory ownership rules remain unchanged: an omitted factory supplies
|
|
||||||
its type's zero value, including nil pointers. Separate factory calls do not
|
|
||||||
guarantee distinct caller-owned objects. CommandKit does not allocate arbitrary
|
|
||||||
pointer types, clone application graphs, repair caller aliases, or supply or
|
|
||||||
repair application caches. Core-owned parsed collections remain independent
|
|
||||||
when applied to the two inputs; callback effects remain caller-owned.
|
|
||||||
|
|
||||||
## Alternatives considered
|
|
||||||
|
|
||||||
| Alternative | Analysis |
|
|
||||||
| --- | --- |
|
|
||||||
| Symmetric independently resolved left/right selectors | Makes each side explicit and avoids inheritance. It adds vocabulary and repetition for common base-versus-overlay comparisons. |
|
|
||||||
| One typed input for both sides | Requires fewer factory calls, but obscures side-specific selection checks and leaves mutation isolation implicit. |
|
|
||||||
| Acquire a comparison facet with each ordinary view | Shares single-side acquisition across operations, but requires a public compatibility contract between heterogeneous facets and additional handle or assertion machinery. |
|
|
||||||
| Separate shared state for each side | Isolates acquisition state, but loses the invocation-wide sharing already selected for inspection. Applications can choose isolation within their own pair provider if needed. |
|
|
||||||
|
|
||||||
## Consequences
|
|
||||||
|
|
||||||
Pair preparation has explicit input and state lifetimes while ordinary inspection
|
|
||||||
keeps its existing lifecycle. Generic result handling stays inside the integration
|
|
||||||
adapter; core gains no owning-library dependency or universal comparison model.
|
|
||||||
|
|
||||||
Shared state permits application-owned caches. A cache used by a pair provider
|
|
||||||
should distinguish selections and any input values affecting loading to avoid
|
|
||||||
reusing the left result for a different right selection. This is integration
|
|
||||||
guidance, not a CommandKit cache policy or guarantee about caller callbacks.
|
|
||||||
|
|
||||||
The [architecture policy](../policy/architecture.md) owns normative boundaries.
|
|
||||||
ADR 0008 governs delegation to upstream comparison semantics, safe disclosure,
|
|
||||||
and CommandKit's presentation responsibility. This decision does not add a new
|
|
||||||
equality engine or strengthen application
|
|
||||||
loading, object ownership, or concurrency contracts.
|
|
||||||
14
docs/adr/README.md
Normal file
14
docs/adr/README.md
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
# Architectural decision records
|
||||||
|
|
||||||
|
These records explain major structural and ownership decisions. The
|
||||||
|
[architecture policy](../policy/architecture.md) owns normative boundaries;
|
||||||
|
GoDoc and the [CLI reference](../cli.md) own implemented contracts.
|
||||||
|
|
||||||
|
| ADR | Decision |
|
||||||
|
| --- | --- |
|
||||||
|
| [0001](0001-command-dispatch-and-inputs.md) | Dispatch, captured declarations, and typed inputs |
|
||||||
|
| [0002](0002-public-facade-and-cli-backend.md) | Public facade and internal CLI backend |
|
||||||
|
| [0003](0003-configuration-scopes.md) | Configuration scopes and declared capabilities |
|
||||||
|
| [0004](0004-optional-integration-modules.md) | Optional integration modules and upstream ownership |
|
||||||
|
| [0005](0005-application-alias-routing.md) | Alias routing and canonical command ownership |
|
||||||
|
| [0006](0006-invocation-reporting.md) | Invocation reporting, outcomes, and presentation ownership |
|
||||||
656
docs/cli.md
656
docs/cli.md
@@ -46,7 +46,7 @@ The supported flags are:
|
|||||||
| Flag | Values | Default | Meaning |
|
| Flag | Values | Default | Meaning |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| `--scope` | `app`, `commandkit` | `app` | Select the caller's application identity or the embedded CommandKit module identity. |
|
| `--scope` | `app`, `commandkit` | `app` | Select the caller's application identity or the embedded CommandKit module identity. |
|
||||||
| `--format` | `text`, `yaml` | `text` | Select the output format. |
|
| `--format` | `text`, `yaml`, `json` | automatic | Select the output format; automatic uses text for version. |
|
||||||
|
|
||||||
Text output is `NAME VERSION`, followed by ` (REVISION)` when the selected
|
Text output is `NAME VERSION`, followed by ` (REVISION)` when the selected
|
||||||
identity has a revision. An empty application version is displayed as `dev`;
|
identity has a revision. An empty application version is displayed as `dev`;
|
||||||
@@ -58,7 +58,7 @@ versionless replacement, an untagged module, or missing build information
|
|||||||
displays `dev`. The CommandKit identity name is `CommandKit`; dependency build
|
displays `dev`. The CommandKit identity name is `CommandKit`; dependency build
|
||||||
metadata does not supply a revision.
|
metadata does not supply a revision.
|
||||||
|
|
||||||
YAML output uses these explicit fields, including an empty revision:
|
YAML and JSON output use these fields, including an empty revision:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
name: ExampleApp
|
name: ExampleApp
|
||||||
@@ -66,6 +66,138 @@ version: dev
|
|||||||
revision: ""
|
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, after initializing bash-completion
|
||||||
|
source <(app completion bash)
|
||||||
|
# Zsh, after initializing its completion system with compinit
|
||||||
|
source <(app completion zsh)
|
||||||
|
# Fish
|
||||||
|
app completion fish | source
|
||||||
|
```
|
||||||
|
|
||||||
|
Bash requires the `bash-completion` package and its helpers in the current
|
||||||
|
shell. If your startup files do not initialize it, source the package's
|
||||||
|
initialization file before loading the generated script. For example, on
|
||||||
|
systems that install it at the following path:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
source /usr/share/bash-completion/bash_completion
|
||||||
|
source <(app completion bash)
|
||||||
|
```
|
||||||
|
|
||||||
|
Use the path provided by your Bash completion installation.
|
||||||
|
|
||||||
|
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,
|
||||||
|
integrations, help, version, and CommandKit's own invocation diagnostics. The
|
||||||
|
caller passes result and diagnostic writers to `App.Execute`; substantive
|
||||||
|
results go to `Streams.Out`, and categorized messages, terminal explanations,
|
||||||
|
and usage guidance go to `Streams.Err`.
|
||||||
|
|
||||||
|
The shared `--format` flag selects `text`, `yaml`, or `json` for reports
|
||||||
|
submitted through the core reporter. When omitted, a report's text template
|
||||||
|
selects text; otherwise scalar values and nonempty string lists use text, while
|
||||||
|
structured values, empty collections, and null use YAML. Explicit text without a
|
||||||
|
template uses YAML for structured values. Invalid or empty format values are
|
||||||
|
usage errors. Raw output, help, completion, and diagnostics are not encoded as
|
||||||
|
reports.
|
||||||
|
|
||||||
|
### Modes and visibility
|
||||||
|
|
||||||
|
Each invocation starts in ordinary mode. `--quiet`, `--verbose`, and `--debug`
|
||||||
|
are mutually exclusive; the last enabled occurrence selects the mode. They
|
||||||
|
accept presence or an explicit boolean value. A `false` occurrence clears the
|
||||||
|
selected mode only when it names the current mode, returning to ordinary mode;
|
||||||
|
it does not restore an earlier selection. Invalid boolean values are usage
|
||||||
|
errors, even if a later flag would select another mode.
|
||||||
|
|
||||||
|
| Category | `--quiet` | No mode flag | `--verbose` | `--debug` |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| Substantive result | Suppressed | Shown | Shown | Shown |
|
||||||
|
| Terminal failure | Shown | Shown | Shown | Shown |
|
||||||
|
| Warning | Suppressed | Suppressed | Shown | Shown |
|
||||||
|
| Info | Suppressed | Suppressed | Shown | Shown |
|
||||||
|
| Debug | Suppressed | Suppressed | Suppressed | Shown |
|
||||||
|
| Progress, unless enabled explicitly | Suppressed | Suppressed | Suppressed | Shown |
|
||||||
|
|
||||||
|
`--progress` independently enables Progress; `--progress=false` disables it.
|
||||||
|
Both accept the existing boolean syntax, and the last occurrence wins. An
|
||||||
|
explicit setting overrides the mode default, except quiet always suppresses
|
||||||
|
Progress. Without an explicit setting, debug enables Progress; ordinary and
|
||||||
|
verbose modes leave it disabled. Progress does not enable Info, Warning, or
|
||||||
|
Debug.
|
||||||
|
|
||||||
|
### Placement and aliases
|
||||||
|
|
||||||
|
Reporting flags are available on application commands, the root, canonical
|
||||||
|
groups, help, version, and alias-only discovery help. Place them after the
|
||||||
|
complete command path; they may be interspersed with operands after that path.
|
||||||
|
For example:
|
||||||
|
|
||||||
|
```text
|
||||||
|
example export --quiet
|
||||||
|
example config show --verbose
|
||||||
|
example promptkit test all --debug --progress=false
|
||||||
|
example help config show --quiet
|
||||||
|
example version --quiet
|
||||||
|
```
|
||||||
|
|
||||||
|
Reporting flags do not go before or between command-path components. Tokens
|
||||||
|
consumed as values and tokens after `--` retain their normal meaning.
|
||||||
|
|
||||||
|
The reserved long spellings are `--quiet`, `--verbose`, `--debug`,
|
||||||
|
`--progress`, and `--format`; application declarations cannot reuse them. There
|
||||||
|
are no built-in short spellings. Applications may define aliases such as `-q → --quiet`,
|
||||||
|
`-v → --verbose`, and `-d → --debug`; canonical and aliased occurrences share
|
||||||
|
the same ordering. Thus `example export --verbose -q` selects quiet mode.
|
||||||
|
|
||||||
|
### Channels and messages
|
||||||
|
|
||||||
|
Substantive reports use the result destination. Warnings, Info, Progress,
|
||||||
|
Debug, terminal explanations, and usage guidance use the diagnostic destination.
|
||||||
|
Simple Warning, Debug, and terminal failure messages begin with `WARN:`,
|
||||||
|
`DEBUG:`, and `ERROR:` respectively. Info, Progress, and substantive results
|
||||||
|
have no prefix. For multiline messages, the prefix appears on the opening line;
|
||||||
|
native structured details keep their own formatting without duplicate labels.
|
||||||
|
Progress is streamed as unprefixed lines; terminal redraw and progress bars are
|
||||||
|
not provided. A failed outcome with no message or detail uses the fallback
|
||||||
|
`ERROR: Command failed.`.
|
||||||
|
|
||||||
|
Quiet mode suppresses successful help and version output, as it does other
|
||||||
|
substantive results. Usage guidance and routing, parsing, and operational
|
||||||
|
failures remain visible in every mode. A recovered problem is reported only if
|
||||||
|
the application chooses to submit a Warning; an error cause by itself does not
|
||||||
|
create a message.
|
||||||
|
|
||||||
|
### Presentation failures
|
||||||
|
|
||||||
|
CommandKit continues rendering substantive output under quiet mode through a
|
||||||
|
discard destination. This suppresses writes to the caller's result writer while
|
||||||
|
still detecting report preparation, encoding, or rendering failures. An
|
||||||
|
intentional suppression is not a delivery failure. A failure while rendering or
|
||||||
|
writing requested content is retained in the returned `Outcome` and makes an
|
||||||
|
otherwise successful or usage invocation operational with status `1`. A domain
|
||||||
|
classification and its nonzero status are preserved; a zero-status domain
|
||||||
|
outcome becomes status `1` when presentation fails. Earlier bytes already
|
||||||
|
written remain visible.
|
||||||
|
|
||||||
## Registered command syntax
|
## Registered command syntax
|
||||||
|
|
||||||
Options are parsed after the complete command path and may be interspersed with
|
Options are parsed after the complete command path and may be interspersed with
|
||||||
@@ -83,6 +215,74 @@ Typed inputs keep supplied state separate from defaults. An explicit empty,
|
|||||||
false, or zero value is supplied. Help and version are available without
|
false, or zero value is supplied. Help and version are available without
|
||||||
constructing application command inputs.
|
constructing application command inputs.
|
||||||
|
|
||||||
|
## Application-owned aliases
|
||||||
|
|
||||||
|
Applications can install command and flag aliases alongside their canonical
|
||||||
|
commands. Aliases route an invocation to existing behavior; they do not add
|
||||||
|
handlers, commands, or flags to a canonical command family.
|
||||||
|
|
||||||
|
Command alias paths are sequences of command tokens relative to the application
|
||||||
|
root. The executable name, flags, flag values, and operands are not part of an
|
||||||
|
alias path. Matching uses complete tokens and chooses the longest matching
|
||||||
|
path. A group alias preserves its remaining command tokens, and aliases can
|
||||||
|
compose through chains. For example, `list collections` can route to
|
||||||
|
`promptkit list collections`, while `pk` can route to the `promptkit` group.
|
||||||
|
The target must resolve to a canonical command or group.
|
||||||
|
|
||||||
|
Aliases do not rewrite operands or supply arguments. In particular,
|
||||||
|
`promptkit test KIND` remains one canonical command whose `KIND` is an operand;
|
||||||
|
`all` is an operand value, not a child command. `smoke → promptkit test` is a
|
||||||
|
valid shortcut because it preserves the caller's `KIND` value.
|
||||||
|
|
||||||
|
Flag aliases can be application-wide or scoped to a canonical command path.
|
||||||
|
An application-wide alias is effective wherever its canonical target flag is
|
||||||
|
available and is ignored elsewhere. A scoped alias requires its canonical
|
||||||
|
command and target flag to be installed. The selected canonical command
|
||||||
|
determines applicable scoped aliases, so the alias also works when that
|
||||||
|
command is reached through another command path. A scoped mapping takes
|
||||||
|
precedence over an application-wide mapping with the same spelling.
|
||||||
|
|
||||||
|
Only tokens parsed as flag names participate in flag aliasing. Values, operands,
|
||||||
|
and tokens following `--` remain unchanged. This includes values that look like
|
||||||
|
flags, such as `--message=--hlep`. Aliased flags use the target's canonical
|
||||||
|
binding, conversion, default, required-value check, presence, and repetition
|
||||||
|
behavior. Mixed spellings preserve occurrence order: singleton flags use the
|
||||||
|
last successfully parsed occurrence, every supplied occurrence is converted,
|
||||||
|
and repeatable flags accumulate values in order.
|
||||||
|
|
||||||
|
Flag aliases accept one long spelling or one individual short spelling. Short
|
||||||
|
aliases use the canonical short-option cluster and attached-value grammar:
|
||||||
|
|
||||||
|
| Input | Interpretation |
|
||||||
|
| --- | --- |
|
||||||
|
| `-vq` | Two boolean flags, `--verbose` and `--quiet`. |
|
||||||
|
| `-cblue` or `-c blue` | Value `blue` for a value-taking `-c` flag. |
|
||||||
|
| `-vqcblue` | `-v`, `-q`, then `--color=blue`. |
|
||||||
|
| `-cvq` | Value `vq` for a value-taking `-c` flag. |
|
||||||
|
| `-v=false` | Explicit false for the boolean flag `-v`. |
|
||||||
|
| `-c=` | An explicitly supplied empty string. |
|
||||||
|
|
||||||
|
Boolean flags use presence as true; an explicit boolean value requires `=`.
|
||||||
|
The first value-taking short flag consumes the remaining cluster suffix or the
|
||||||
|
next argument. A consumed value is never interpreted as an alias. The canonical
|
||||||
|
short-option empty-value form `-c=` supplies an empty value, consistently with
|
||||||
|
`--color=` and `-c ""`.
|
||||||
|
|
||||||
|
Help reached through an alias describes the canonical command's flags and
|
||||||
|
operands while using the entered route in usage. It identifies the canonical
|
||||||
|
target, lists applicable aliases separately, and supports global help aliases
|
||||||
|
such as `--hlep → --help`. Help for an alias-only prefix lists its routes
|
||||||
|
without creating a command group; the bare incomplete prefix points to that
|
||||||
|
help. Help and alias registration do not construct command inputs or invoke
|
||||||
|
acquisition callbacks.
|
||||||
|
|
||||||
|
Usage diagnostics preserve the command path and flag spelling the caller used.
|
||||||
|
When a command alias resolved successfully, usage identifies its canonical
|
||||||
|
target while retaining the entered path in the diagnostic. An unresolved route
|
||||||
|
receives no invented canonical label. A flag conversion error identifies the
|
||||||
|
entered flag spelling and the failing occurrence while preserving the original
|
||||||
|
cause.
|
||||||
|
|
||||||
## Configuration scopes
|
## Configuration scopes
|
||||||
|
|
||||||
An application installs the declaration returned by
|
An application installs the declaration returned by
|
||||||
@@ -97,14 +297,26 @@ example config scopes
|
|||||||
example config scopes --format yaml
|
example config scopes --format yaml
|
||||||
```
|
```
|
||||||
|
|
||||||
Text output uses one `scope: operation, operation` line per scope. YAML uses a
|
Text output uses one `scope: operation, operation` line per scope and lists any
|
||||||
`scopes` sequence with explicit `name` and `operations` fields. Scope order is
|
declared optional features. Structured output uses a `scopes` sequence with
|
||||||
|
`name`, `operations`, and any supported `features`. Scope order is
|
||||||
`app`, then `promptkit`, then other names alphabetically. A metadata-only scope
|
`app`, then `promptkit`, then other names alphabetically. A metadata-only scope
|
||||||
can be listed without an acquisition callback. Discovery does not construct a
|
can be listed without an acquisition callback. Discovery does not construct a
|
||||||
runtime context or acquire configuration. It reports the intersection between
|
runtime context or acquire configuration. It reports the intersection between
|
||||||
declared scope capabilities and installed handlers, so an operation is listed
|
declared scope capabilities and installed handlers, so an operation is listed
|
||||||
only when the corresponding command is available.
|
only when the corresponding command is available.
|
||||||
|
|
||||||
|
Scopes can opt in to transient overrides. For an opted-in scope, acquisition
|
||||||
|
commands accept repeatable `--set POINTER=JSON`; comparison also accepts
|
||||||
|
`--against-set POINTER=JSON` for the right side. Each assignment is passed to
|
||||||
|
the loader in order, with `--set` or `--against-set` as its source label.
|
||||||
|
The pointer and value are split at the first `=`, so pointer keys containing
|
||||||
|
`=` cannot be addressed by these flags; JSON strings can contain `=`. Values
|
||||||
|
use JSON types, including null, false, zero, and empty containers. ConfigKit
|
||||||
|
owns override path validation and conflict handling. A selected scope must opt
|
||||||
|
in before receiving overrides; for `--scope all`, every selected scope must
|
||||||
|
support them.
|
||||||
|
|
||||||
Applications can add `ckprompt.ConfigScope(loadEngine)` to the ConfigKit suite
|
Applications can add `ckprompt.ConfigScope(loadEngine)` to the ConfigKit suite
|
||||||
to expose PromptKit engine configuration through these handlers. The scope
|
to expose PromptKit engine configuration through these handlers. The scope
|
||||||
declares validate, show, sources, explain, and native diff. It does not install
|
declares validate, show, sources, explain, and native diff. It does not install
|
||||||
@@ -116,54 +328,67 @@ PromptKit prompt or profile definition commands.
|
|||||||
`app`; `--scope all` validates every registered scope and is available when each
|
`app`; `--scope all` validates every registered scope and is available when each
|
||||||
scope declares validation. The command checks all selected capabilities before
|
scope declares validation. The command checks all selected capabilities before
|
||||||
calling scope providers.
|
calling scope providers.
|
||||||
Its default format is text; `--format yaml` emits a `scopes` sequence with
|
Automatic output is concise text; structured formats emit a `scopes` sequence
|
||||||
`name` and `valid: true` for each scope that completed successfully.
|
with each reached scope and its outcome (`valid`, `failed`, or `canceled`).
|
||||||
|
|
||||||
```text
|
```text
|
||||||
example config validate
|
example config validate
|
||||||
example config validate --scope promptkit --format yaml
|
example config validate --scope promptkit --format yaml
|
||||||
example config validate --scope all
|
example config validate --scope all
|
||||||
|
example config validate --scope all --details --severity warning --code schema.version
|
||||||
```
|
```
|
||||||
|
|
||||||
Each successful scope is reported as `SCOPE: valid`. Aggregate validation stops
|
Each successful scope is reported as `SCOPE: valid`. Aggregate validation stops
|
||||||
at the first load or output failure; records from earlier successful scopes may
|
at the first load failure or cancellation, then submits one report containing
|
||||||
already have been written. Provider warnings go to the diagnostic stream and do
|
the reached scopes. Output errors occur after acquisition has stopped. Provider
|
||||||
not alone make validation fail. `--config` and `--overlay` preserve whether an
|
warnings go to the diagnostic stream and do not alone make validation fail.
|
||||||
|
`--details` includes native findings and the source references needed to
|
||||||
|
interpret them. Each scope's detail state is `unavailable`, `empty`, `filtered`,
|
||||||
|
or `present`. Repeat `--severity error|warning` or `--code CODE` to filter
|
||||||
|
findings: values within a filter match any, and the two filters narrow together.
|
||||||
|
Filters require `--details` and never change the validation outcome. Detailed
|
||||||
|
output suppresses ordinary warnings associated with its native findings; other
|
||||||
|
messages and terminal failure explanations remain visible.
|
||||||
|
`--config` and `--overlay` preserve whether an
|
||||||
empty value was explicitly supplied, so a scope callback can map those inputs to
|
empty value was explicitly supplied, so a scope callback can map those inputs to
|
||||||
its own ConfigKit load policy. Help and invalid syntax do not call scope
|
its own ConfigKit load policy. Help and invalid syntax do not call scope
|
||||||
providers.
|
providers.
|
||||||
|
|
||||||
### Show effective configuration
|
### Show effective configuration
|
||||||
|
|
||||||
`config show` writes the provider's native effective YAML for one scope. Its
|
`config show` displays the provider's safe effective configuration. The default
|
||||||
default scope is `app`; `--format yaml` is accepted and YAML is the only initial
|
scope is `app`. The native inspection YAML is converted to a logical report
|
||||||
format. Single-scope output is passed through unchanged, without a CommandKit
|
value, preserving meaningful scalar types, empty collections, and redaction.
|
||||||
header or document marker.
|
Automatic formatting uses text for a single scalar or nonempty string list and
|
||||||
|
YAML for structured values; `--format` selects text, YAML, or JSON.
|
||||||
|
|
||||||
```text
|
```text
|
||||||
example config show
|
example config show
|
||||||
example config show --scope promptkit
|
example config show --scope promptkit
|
||||||
example config show --scope all
|
example config show --scope all
|
||||||
|
example config show --path /server/addr --format json
|
||||||
```
|
```
|
||||||
|
|
||||||
`--scope all` acquires every selected scope and checks each effective writer
|
`--scope all` acquires every selected scope before producing one mapping keyed
|
||||||
before writing output. It then emits one YAML document per scope in discovery
|
by scope name, including when only one scope is registered. A single selected
|
||||||
order, with a `# scope: NAME` comment at each document boundary. For ordinary
|
scope is unwrapped. A provider failure prevents report output. Conversion or
|
||||||
block documents the comment follows the marker. When a native opening marker
|
output failures are recorded in `Outcome.Presentation.Output`.
|
||||||
contains inline content, the comment precedes that marker so it does not
|
|
||||||
interrupt the native value. Existing native markers and leading comments are
|
For a scope that supports path-selected show, `--path POINTER` displays only
|
||||||
retained; documents without a marker receive one. Scope labels containing line
|
that safe effective value. An explicitly empty pointer selects the root, and a
|
||||||
control characters are quoted to keep them on one comment line. A provider
|
present null remains a value. Missing and historical-only paths fail; path
|
||||||
failure leaves the output stream empty. A rendering or output failure may leave
|
selection cannot be combined with `--scope all`.
|
||||||
an aggregate prefix, and the outcome reports
|
|
||||||
incomplete report delivery.
|
|
||||||
|
|
||||||
### List configuration sources
|
### List configuration sources
|
||||||
|
|
||||||
`config sources` reports the source inventory captured during the selected
|
`config sources` reports the source inventory captured during the selected
|
||||||
scope's acquisition. It reads the source facet once and does not reload
|
scope's acquisition. It reads the source facet once and does not reload
|
||||||
configuration. The default scope is `app`; `--scope all` is unsupported. Text is
|
configuration. The default scope is `app`; `--scope all` is unsupported. Text is
|
||||||
the default format, and `--format yaml` returns a structured report:
|
the automatic format, and `--format yaml` or `--format json` returns a structured report.
|
||||||
|
`--source-id ID` selects one entry by the ID from that acquisition; an unknown
|
||||||
|
ID fails. If acquisition fails but returns a captured source inventory, the
|
||||||
|
command reports that evidence and preserves the acquisition error. Unavailable
|
||||||
|
inventory is reported as such.
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
scope: app
|
scope: app
|
||||||
@@ -171,14 +396,14 @@ sources:
|
|||||||
- id: source-1
|
- id: source-1
|
||||||
label: "application config"
|
label: "application config"
|
||||||
path: /etc/example/config.yml
|
path: /etc/example/config.yml
|
||||||
layer: base
|
layer: main
|
||||||
kind: file
|
kind: file
|
||||||
underlay_index: 0
|
underlay_index: 0
|
||||||
imported_from: null
|
imported_from: null
|
||||||
destination: /server
|
destination: /server
|
||||||
placement: mapping
|
placement: mapping
|
||||||
destination_unresolved: false
|
destination_is_fallback: false
|
||||||
version_check:
|
version_evidence:
|
||||||
path: /version
|
path: /version
|
||||||
version:
|
version:
|
||||||
present: true
|
present: true
|
||||||
@@ -189,43 +414,59 @@ sources:
|
|||||||
line: 2
|
line: 2
|
||||||
column: 1
|
column: 1
|
||||||
redacted: false
|
redacted: false
|
||||||
|
empty_import_selections: []
|
||||||
```
|
```
|
||||||
|
|
||||||
Each source includes `id`, `label`, `path`, `layer`, `kind`, `underlay_index`,
|
Each source includes `id`, `label`, `path`, `layer`, `kind`, `underlay_index`,
|
||||||
and `imported_from`, plus the native destination pointer, placement, unresolved
|
and `imported_from`, plus the native destination pointer, placement,
|
||||||
destination flag, and nullable `version_check`. Placement is `mapping` or
|
`destination_is_fallback`, nullable `version_evidence`, and
|
||||||
`sequence_item`. The import location is null when absent; otherwise it has
|
`empty_import_selections`. Placement is `mapping` or `sequence_item`. The
|
||||||
|
import location is null when absent; otherwise it has
|
||||||
`source_id`, `line`, and `column`. Version evidence preserves `path`, `present`,
|
`source_id`, `line`, and `column`. Version evidence preserves `path`, `present`,
|
||||||
`null`, `text`, optional source `location`, and `redacted` as returned by
|
`null`, `text`, optional source `location`, and `redacted` as returned by
|
||||||
ConfigKit. Owning values and source order are preserved, including empty fields,
|
ConfigKit. Owning values and source order are preserved, including empty fields,
|
||||||
zero coordinates, false and null observations, and synthetic sources without
|
zero coordinates, false and null observations, and synthetic sources without
|
||||||
paths. An absent version check is null. An empty inventory is `sources: []`.
|
paths. Absent version evidence is null. Each empty-import record has a source-local
|
||||||
|
`path`, a `location` mapping (`source_id`, `line`, `column`), and a `selector`;
|
||||||
|
records preserve their returned order. No records are `empty_import_selections: []`.
|
||||||
|
An empty inventory is `sources: []`.
|
||||||
Text output begins with the scope heading and prints the same source fields,
|
Text output begins with the scope heading and prints the same source fields,
|
||||||
quoting labels and paths. These fields project the selected ConfigKit source
|
quoting labels and paths. These fields project the selected ConfigKit source
|
||||||
records; CommandKit does not reload files or rerun version checks. See the
|
records; CommandKit does not reload files or rerun version checks. See the
|
||||||
[ConfigKit v0.8.0 inspection API](https://gitea.maximumdirect.net/eric/ConfigKit/src/commit/137769f3f18a370c68444242fb5d88e620e307e5/inspection.go)
|
[ConfigKit v1.1.0 source records](https://forge.maximumdirect.net/base-layer/configkit/src/commit/191015e289d7f2b91abb9fdd9c4f2f5cf75b4de9/records.go)
|
||||||
for native evidence definitions.
|
for native evidence definitions.
|
||||||
|
|
||||||
### Explain a configuration path
|
### Explain a configuration path
|
||||||
|
|
||||||
`config explain --path POINTER` explains the effective and historical value at a
|
`config explain --path POINTER` explains the effective and historical value at a
|
||||||
JSON Pointer path. `--path` is required; an explicitly empty value selects the
|
JSON Pointer path. An explicitly empty path selects the root. `config explain
|
||||||
root. The default scope is `app`; `--format yaml` selects structured output, and
|
--list` discovers paths when the selected scope supports path discovery. An
|
||||||
text is the default. `--scope all` is unsupported.
|
invocation with neither mode displays help; the two modes cannot be combined.
|
||||||
|
The default scope is `app`; `--format yaml` or `--format json` selects
|
||||||
|
structured output, and automatic output uses text. `--scope all` is unsupported.
|
||||||
|
|
||||||
```text
|
```text
|
||||||
example config explain --path /server/addr
|
example config explain --path /server/addr
|
||||||
example config explain --path /profiles/team~1summary --format yaml
|
example config explain --path /profiles/team~1summary --format yaml
|
||||||
example config explain --path= --format yaml
|
example config explain --path= --format yaml
|
||||||
|
example config explain --list --under /profiles --historical
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Listing normally includes current paths. `--historical` adds paths that only
|
||||||
|
occurred in history; `--under POINTER` selects a subtree, and `--explicit=true`
|
||||||
|
or `--explicit=false` filters current paths by native explicitness. Historical-only
|
||||||
|
paths do not match an explicitness filter. These options require `--list`.
|
||||||
|
The report contains the scope and each selected path's current presence and,
|
||||||
|
when present, its explicitness. Path discovery does not include values or history.
|
||||||
|
|
||||||
Pointer tokens use JSON Pointer escaping: `~1` represents `/` and `~0`
|
Pointer tokens use JSON Pointer escaping: `~1` represents `/` and `~0`
|
||||||
represents `~`. A malformed pointer is a usage error with status `2` and is
|
represents `~`. A malformed pointer is a usage error with status `2` and is
|
||||||
rejected before acquisition. A well-formed path that is unknown in the selected
|
rejected before acquisition. A well-formed path that is unknown in the selected
|
||||||
configuration is an operational error with status `1`. Text output comes from
|
configuration is an operational error with status `1`. Text output includes
|
||||||
the scope's native explanation writer.
|
the scope's native explanation and available field metadata. Structured output
|
||||||
|
includes metadata when supplied by the scope.
|
||||||
|
|
||||||
YAML output uses these root fields:
|
Structured output uses these root fields:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
scope: app
|
scope: app
|
||||||
@@ -235,7 +476,7 @@ effective:
|
|||||||
redacted: false
|
redacted: false
|
||||||
yaml: "localhost:8080\n"
|
yaml: "localhost:8080\n"
|
||||||
origin:
|
origin:
|
||||||
layer: base
|
layer: main
|
||||||
locations:
|
locations:
|
||||||
- source_id: source-1
|
- source_id: source-1
|
||||||
line: 4
|
line: 4
|
||||||
@@ -264,35 +505,49 @@ configuration inspection API. PromptKit owns the meaning of that native
|
|||||||
comparison; CommandKit uses the existing `config diff` preparation and report
|
comparison; CommandKit uses the existing `config diff` preparation and report
|
||||||
path.
|
path.
|
||||||
`--config` and `--overlay` select the left side. `--against-config` and
|
`--config` and `--overlay` select the left side. `--against-config` and
|
||||||
`--against-overlay` independently override the right side. When a right-side
|
`--against-overlay` select the right side. An omitted field keeps that side's
|
||||||
field is omitted, it inherits the corresponding left value and supplied state;
|
ordinary application loading behavior and unsupplied state; an explicit empty
|
||||||
an explicit empty string overrides it and remains supplied. Equal selections are
|
string remains supplied. Application selectors affect the left side, while
|
||||||
allowed; their equality alone does not cause CommandKit to skip the provider.
|
declared `--against-*` counterparts affect the right. Equal selections are
|
||||||
|
allowed and do not cause CommandKit to skip the provider.
|
||||||
|
|
||||||
YAML is the only format and the default. Explicit `--format yaml` is accepted;
|
Automatic output is YAML. The shared `--format` also accepts YAML, JSON, and
|
||||||
`text` and other formats are usage errors. For example:
|
text; text uses the same structured representation. For example:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
example config diff --config before.yml --against-config after.yml
|
example config diff --config before.yml --against-config after.yml
|
||||||
example config diff --config base.yml --against-overlay candidate
|
example config diff --config base.yml --against-overlay candidate
|
||||||
example config diff --config base.yml --overlay current --against-overlay ""
|
example config diff --config base.yml --overlay current --against-overlay ""
|
||||||
example config diff --scope promptkit --config before.yml --against-config after.yml
|
example config diff --scope promptkit --config before.yml --against-config after.yml
|
||||||
|
example config diff --config before.yml --against-config after.yml --details --check
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`--details` replaces the concise report with `{scope, differences}`, containing
|
||||||
|
one entry per native difference. Each entry has `path`, `before`, `after`, and
|
||||||
|
`hidden_changed`; each snapshot has `present`, `redacted`, and `value`. An
|
||||||
|
absent snapshot has `present: false` and a null value, distinct from a present
|
||||||
|
null. Empty comparisons contain `differences: []`.
|
||||||
|
|
||||||
|
`--check` returns status `3` when native differences exist and `0` when they do
|
||||||
|
not. Without it, a completed comparison succeeds even when differences exist.
|
||||||
|
Usage errors retain status `2`, and operational failures retain status `1`.
|
||||||
|
The report is still delivered in check mode; a presentation failure remains
|
||||||
|
separate evidence and does not replace a nonzero difference outcome.
|
||||||
|
|
||||||
For ConfigKit-backed scopes, ConfigKit compares frozen effective typed values
|
For ConfigKit-backed scopes, ConfigKit compares frozen effective typed values
|
||||||
after defaults and resolution; source or provenance differences alone do not
|
after defaults and resolution; source or provenance differences alone do not
|
||||||
create a change. PromptKit delegates its native comparison to ConfigKit. The
|
create a change. PromptKit delegates its native comparison to ConfigKit. The
|
||||||
report is one YAML document with `additions`, `changes`, and `deletions` in that
|
concise report contains `additions`, `changes`, and `deletions`. Additions and changes
|
||||||
order. Additions and changes preserve semantic paths and contain right-side
|
preserve semantic paths and contain right-side values provided by the native
|
||||||
values provided by the native comparison. Deletions contain canonical JSON
|
comparison. Deletions contain canonical JSON
|
||||||
Pointer paths. Explicit null is a value; it is not a deletion. Changed sequences
|
Pointer paths. Explicit null is a value; it is not a deletion. Changed sequences
|
||||||
and native structural replacement regions stay atomic.
|
and native structural replacement regions stay atomic.
|
||||||
|
|
||||||
ConfigKit pairs sensitivity across both loads. A concealed change can therefore
|
ConfigKit pairs sensitivity across both loads. A concealed change can therefore
|
||||||
appear even when its two safe snapshots are equal. For those ConfigKit-backed
|
appear even when its two safe snapshots are equal. For those ConfigKit-backed
|
||||||
comparisons, the report omits separate redaction and hidden-change metadata: a
|
comparisons, the concise report omits separate redaction and hidden-change metadata: a
|
||||||
reported masked region changed, but that does not mean every member inside it
|
reported masked region changed, but that does not mean every member inside it
|
||||||
changed. Marker-looking public strings stay ordinary YAML values. An empty
|
changed. Marker-looking public strings stay ordinary strings. An empty
|
||||||
comparison succeeds with this shape:
|
comparison succeeds with this shape:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
@@ -306,7 +561,7 @@ automatically applicable patch. Exceptional whole-root empty/null transitions
|
|||||||
can be ambiguous because the report has no root envelope; ordinary field-level
|
can be ambiguous because the report has no root envelope; ordinary field-level
|
||||||
null values and deletions retain their distinction.
|
null values and deletions retain their distinction.
|
||||||
|
|
||||||
Successful comparisons return status `0`, including empty results.
|
Without `--check`, successful comparisons return status `0`, including empty results.
|
||||||
Unsupported scopes and format errors are usage errors; input checks retain the
|
Unsupported scopes and format errors are usage errors; input checks retain the
|
||||||
existing usage/operational classification. Load and native comparison failures
|
existing usage/operational classification. Load and native comparison failures
|
||||||
are operational and preserve supported error identity.
|
are operational and preserve supported error identity.
|
||||||
@@ -322,81 +577,90 @@ again.
|
|||||||
The complete report is prepared before writing. Report preparation failures and
|
The complete report is prepared before writing. Report preparation failures and
|
||||||
cancellation observed before report delivery starts leave CommandKit's report
|
cancellation observed before report delivery starts leave CommandKit's report
|
||||||
output empty. Once writing starts, CommandKit does not interrupt the supplied
|
output empty. Once writing starts, CommandKit does not interrupt the supplied
|
||||||
writer or roll back delivered bytes. A write error or short write uses the
|
writer or roll back delivered bytes. A write error or short write is recorded
|
||||||
existing incomplete-report delivery outcome and can leave partial output.
|
as incomplete delivery in `Outcome.Presentation.Output` and can leave partial
|
||||||
|
output.
|
||||||
|
|
||||||
## PromptKit definition inspection
|
## PromptKit definition inspection
|
||||||
|
|
||||||
Applications can install prompt and profile inspection independently with
|
Applications can install prompt, profile, and backend inspection independently
|
||||||
`ckprompt.Command(suite)`. The `ckprompt.FromEngine(loadEngine)` adapter
|
with `ckprompt.Command(suite)`. `ckprompt.FromEngine(loadEngine)` supplies these
|
||||||
supplies these callbacks alongside native requirements discovery and the five
|
callbacks alongside requirements discovery and the five catalogs. Its loader
|
||||||
catalogs. Its loader receives invocation context, configuration selection,
|
receives invocation context, configuration selection, typed input, and shared
|
||||||
typed input, and shared state. Direct suite callbacks retain their own
|
state. Direct callbacks retain their own lifecycle.
|
||||||
implementation and lifecycle.
|
|
||||||
|
|
||||||
Prompt inspection uses an ID only. PromptKit returns any descriptive version
|
|
||||||
metadata as an optional report field; there is no `--version` flag for selecting
|
|
||||||
a prompt version. The prompt format and native selection behavior remain owned by
|
|
||||||
[PromptKit v0.16.0](https://gitea.maximumdirect.net/eric/promptkit/src/commit/9178f3eb91c2b2138caa78e22be93c048b4bc992/docs/consumers/pkg-promptkit.md).
|
|
||||||
|
|
||||||
```text
|
```text
|
||||||
example promptkit show prompt customer/summary --format yaml
|
|
||||||
example promptkit show prompt customer/summary
|
example promptkit show prompt customer/summary
|
||||||
example promptkit show profile customer-support --format yaml
|
example promptkit show profile customer-support --format json
|
||||||
|
example promptkit show backend local --format yaml
|
||||||
```
|
```
|
||||||
|
|
||||||
Both commands require one ID operand and accept `--config`, `--overlay`, and
|
Each command takes one resource ID and accepts `--config` and `--overlay`.
|
||||||
`--format text|yaml` (default `text`). These commands have no built-in
|
Reports use the native PromptKit inspection value through the shared reporting
|
||||||
`--scope` selection; their namespace is `promptkit`.
|
formats; automatic and explicit text use YAML for these structured values. Native
|
||||||
|
JSON field names and omission rules apply. Absent optional fields are omitted;
|
||||||
|
explicit zero values and empty collections remain present. Prompt messages are
|
||||||
|
captured content, not rendered requests, and the prompt hash is opaque.
|
||||||
|
|
||||||
Prompt YAML preserves native input order and uses an optional `version` field.
|
Profile reports describe the selected profile before backend completion or
|
||||||
The key is omitted when PromptKit returns no version and is retained when the
|
request overrides. Backend reports describe configured backend fields. An
|
||||||
version is present, including an empty string. The prompt hash is an opaque
|
`api_key_env` value names an environment variable; it is not a credential
|
||||||
owning-library equality value.
|
value or a claim that one is available. Neither report resolves references or
|
||||||
|
performs a network probe. See the [PromptKit resource inspection contracts](https://forge.maximumdirect.net/base-layer/promptkit/src/commit/8ff9aa3efdc9f16b9d2729e0a29ea083eb3c3eaf/resource_inspection.go).
|
||||||
|
A missing ID is a usage error; native lookup errors and cancellation retain
|
||||||
|
supported cause identity.
|
||||||
|
|
||||||
```yaml
|
### Resource namespace selection
|
||||||
prompt_id: customer/summary
|
|
||||||
version: "2"
|
An application can enable resource namespace flags with `ckprompt.WithNamespaces`
|
||||||
prompt_hash: opaque-value
|
and provide prompt, profile, and backend defaults. Omitted flags retain those
|
||||||
default_profile_id: customer-support
|
defaults; an explicitly empty value clears the corresponding default. Qualified
|
||||||
inputs:
|
resource IDs pass through unchanged. Without the option, the application loader
|
||||||
- name: topic
|
controls the engine view and these flags are unavailable.
|
||||||
required: true
|
|
||||||
content_type: text/plain
|
| Operation | Available namespace flags |
|
||||||
description: Topic to summarize
|
| --- | --- |
|
||||||
output_contract:
|
| `show prompt` | `--llm-prompt-namespace` |
|
||||||
format: json
|
| `show profile` | `--llm-profile-namespace` |
|
||||||
validation_mode: basic
|
| `show backend` | `--llm-backend-namespace` |
|
||||||
schema_path: ""
|
| `requirements show` | `--llm-prompt-namespace`, `--llm-profile-namespace` |
|
||||||
repair_attempts: 2
|
| `requirements validate`, `show prepared`, `test` | All three, including `--llm-backend-namespace` |
|
||||||
|
|
||||||
|
Catalog namespace filters remain separate; configuration inspection has no
|
||||||
|
resource namespace selectors.
|
||||||
|
|
||||||
|
### PromptKit convenience aliases
|
||||||
|
|
||||||
|
Applications that install `ckprompt.Declarations(suite)` provide scoped
|
||||||
|
convenience spellings for the enabled PromptKit commands. Each applicable
|
||||||
|
`--llm-NAME` flag also accepts `--NAME`; `--llm-input-text` additionally accepts
|
||||||
|
`--llm-input` and `--input` for literal text. Use `--llm-input-file` or
|
||||||
|
`--input-file` for a file reference. Canonical and alias spellings share the
|
||||||
|
same binding, including their occurrence order and explicit-value semantics.
|
||||||
|
|
||||||
|
Applications that install `ckprompt.Command(suite)` receive the canonical
|
||||||
|
spellings only. The aliases do not apply to application-owned commands unless
|
||||||
|
the application installs them separately.
|
||||||
|
|
||||||
|
## PromptKit prepared-request preview
|
||||||
|
|
||||||
|
When `Suite.PrepareRequest` is present, `ckprompt.Command(suite)` installs
|
||||||
|
`promptkit show prepared PROMPT_ID` independently of validation and other
|
||||||
|
inspection commands. It accepts the same configuration, selector, profile,
|
||||||
|
session, variable, input, and execution flags as
|
||||||
|
[request validation](#concrete-request-validation):
|
||||||
|
|
||||||
|
```text
|
||||||
|
example promptkit show prepared briefing --llm-input-text topic=roadmap --format json
|
||||||
```
|
```
|
||||||
|
|
||||||
Profile YAML preserves the effective values reported by PromptKit, including
|
The command returns PromptKit's native prepared-run report using the shared
|
||||||
zero values and nested provider parameters. `api_key_env` is only an
|
formats; automatic and explicit text use YAML. Preparation can read resources
|
||||||
environment-variable name. `api_key_required` reports a direct credential
|
and report rendered application content. It makes no model call and does not
|
||||||
requirement; neither field reports credential availability. A nil provider
|
guarantee a subsequent execution will succeed. Application callbacks and
|
||||||
parameter map is displayed as `{}`.
|
loaders retain their own effects. Native preparation errors retain supported
|
||||||
|
cause identity. The [PromptKit Prepare API](https://forge.maximumdirect.net/base-layer/promptkit/src/commit/8ff9aa3efdc9f16b9d2729e0a29ea083eb3c3eaf/engine.go)
|
||||||
```yaml
|
owns the prepared value and upstream preparation behavior.
|
||||||
profile_id: customer-support
|
|
||||||
effective_model_params:
|
|
||||||
backend_id: local
|
|
||||||
endpoint: https://example.invalid/v1
|
|
||||||
model: support-model
|
|
||||||
temperature: 0
|
|
||||||
max_tokens: 0
|
|
||||||
top_p: 0
|
|
||||||
timeout_seconds: 600
|
|
||||||
service_tier: ""
|
|
||||||
reasoning_effort: ""
|
|
||||||
api_key_env: ""
|
|
||||||
extra_params: {}
|
|
||||||
api_key_required: true
|
|
||||||
```
|
|
||||||
|
|
||||||
Text uses labeled fields and includes nested `extra_params` values. A missing ID
|
|
||||||
is a usage error. Definition selection reaches the owning inspection API and
|
|
||||||
preserves supported error identity and cancellation.
|
|
||||||
|
|
||||||
## PromptKit requirements
|
## PromptKit requirements
|
||||||
|
|
||||||
@@ -406,8 +670,8 @@ native selection and discovery rules:
|
|||||||
|
|
||||||
```text
|
```text
|
||||||
example promptkit requirements show briefing --campaign spring
|
example promptkit requirements show briefing --campaign spring
|
||||||
example promptkit requirements show briefing --profile editorial --var mode=full
|
example promptkit requirements show briefing --llm-profile editorial --llm-var mode=full
|
||||||
example promptkit requirements show briefing --session-id session-42 --var note= --format yaml
|
example promptkit requirements show briefing --llm-session-id session-42 --llm-var note= --format yaml
|
||||||
```
|
```
|
||||||
|
|
||||||
The command accepts:
|
The command accepts:
|
||||||
@@ -417,41 +681,42 @@ The command accepts:
|
|||||||
| `--config` | path string | omitted | Select the configuration input; explicit presence is retained. |
|
| `--config` | path string | omitted | Select the configuration input; explicit presence is retained. |
|
||||||
| `--overlay` | path string | omitted | Select one overlay; explicit presence is retained. Repeated occurrences use the last value. |
|
| `--overlay` | path string | omitted | Select one overlay; explicit presence is retained. Repeated occurrences use the last value. |
|
||||||
| Suite selectors | application-defined | per selector | The flags captured from `Suite.Selectors`. |
|
| Suite selectors | application-defined | per selector | The flags captured from `Suite.Selectors`. |
|
||||||
| `--profile` | string | empty | Pass the profile ID to PromptKit; empty uses its native selection rule. |
|
| `--llm-profile` | string | empty | Pass the profile ID to PromptKit; empty uses its native selection rule. |
|
||||||
| `--session-id` | string | empty | Pass session context to PromptKit. |
|
| `--llm-session-id` | string | empty | Pass session context to PromptKit. |
|
||||||
| `--var` | repeatable `KEY=VALUE` | none | Supply caller-variable context for native discovery. |
|
| `--llm-var` | repeatable `KEY=VALUE` | none | Supply caller-variable context for native discovery. |
|
||||||
| `--format` | `yaml` | `yaml` | Select the requirements report format. Other formats are usage errors for this operation. |
|
| `--format` | `text`, `yaml`, `json` | automatic | Select the requirements report format; automatic and text use YAML for this structured report. |
|
||||||
|
|
||||||
The `promptkit` namespace identifies the operation, so there is no built-in
|
The `promptkit` namespace identifies the operation, so there is no built-in
|
||||||
`--scope` flag. An explicitly empty profile or session value retains PromptKit's
|
`--scope` flag. An explicitly empty profile or session value retains PromptKit's
|
||||||
native empty-string meaning. Requirements inspection does not require a profile
|
native empty-string meaning. Profile selection establishes the reported profile
|
||||||
to be execution-ready. See the
|
identity rather than changing the discovered requirements. An enabled profile
|
||||||
[PromptKit v0.16.0 consumer guide](https://gitea.maximumdirect.net/eric/promptkit/src/commit/9178f3eb91c2b2138caa78e22be93c048b4bc992/docs/consumers/pkg-promptkit.md)
|
namespace qualifies an explicitly supplied short profile ID; it does not rewrite
|
||||||
|
the prompt's stored default reference. Requirements inspection does not require
|
||||||
|
the selected profile to be execution-ready. See the
|
||||||
|
[PromptKit v0.20.0 consumer guide](https://forge.maximumdirect.net/base-layer/promptkit/src/commit/8ff9aa3efdc9f16b9d2729e0a29ea083eb3c3eaf/docs/consumers/pkg-promptkit.md)
|
||||||
for native configuration, selection, and requirements behavior. The built-in
|
for native configuration, selection, and requirements behavior. The built-in
|
||||||
flags other than `--var` are singleton bindings; when repeated, the last value
|
flags other than `--llm-var` are singleton bindings; when repeated, the last value
|
||||||
is used. Suite selectors retain their declared binding behavior.
|
is used. Suite selectors retain their declared binding behavior.
|
||||||
|
|
||||||
Each `--var` occurrence must contain `=`. CommandKit splits at the first
|
Each `--llm-var` occurrence must contain `=`. CommandKit splits at the first
|
||||||
separator and preserves the exact key and remaining value. Empty keys and
|
separator and preserves the exact key and remaining value. Empty keys and
|
||||||
values, whitespace, commas, and additional `=` characters are retained. All
|
values, whitespace, commas, and additional `=` characters are retained. All
|
||||||
occurrences are parsed; duplicate keys use the last value, while any malformed
|
occurrences are parsed; duplicate keys use the last value, while any malformed
|
||||||
occurrence is a usage error even if a later assignment is valid.
|
occurrence is a usage error even if a later assignment is valid.
|
||||||
|
|
||||||
The YAML report contains selected identities, native completeness, and input,
|
The report contains selected identities, native completeness, and input,
|
||||||
variable, and issue inventories. False values and empty inventories are
|
variable, and issue inventories. False values and empty inventories are
|
||||||
retained. Input metadata preserves null versus an empty string. Variable entries
|
retained. Input metadata preserves null versus an empty string. Variable entries
|
||||||
contain `name`, `kind`, `supplied`, and `explanation`. `supplied` reports native
|
contain `name`, `kind`, `supplied`, and `explanation`. `supplied` reports native
|
||||||
exact-key presence, including an explicitly empty value, independently of
|
exact-key presence, including an explicitly empty value, independently of
|
||||||
`kind` and `complete`. Explanations and issue descriptions are upstream-owned
|
`kind` and `complete`. Explanations and issue descriptions are upstream-owned
|
||||||
human-readable summaries, not machine-readable details. The optional `version`
|
human-readable summaries, not machine-readable details.
|
||||||
key is omitted when absent and retained when supplied by PromptKit.
|
|
||||||
|
|
||||||
For a prompt with the template `{{if .mode}}{{input "notes"}}{{end}}` and no
|
For a prompt with the template `{{if .mode}}{{input "notes"}}{{end}}` and no
|
||||||
input declarations, omitting `mode` can produce a partial report:
|
input declarations, omitting `mode` can produce a partial report:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
prompt_id: briefing
|
prompt_id: briefing
|
||||||
version: "2"
|
|
||||||
profile_id: editorial
|
profile_id: editorial
|
||||||
complete: false
|
complete: false
|
||||||
inputs:
|
inputs:
|
||||||
@@ -489,8 +754,8 @@ prompt ID and passes a concrete request to PromptKit's native
|
|||||||
`Engine.ValidateRequest` preparation path:
|
`Engine.ValidateRequest` preparation path:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
example promptkit requirements validate briefing --campaign spring --input topic=topic.txt
|
example promptkit requirements validate briefing --campaign spring --llm-input-file topic=topic.txt
|
||||||
example promptkit requirements validate briefing --profile editorial --session-id session-42 --var mode=full --input notes=notes.txt
|
example promptkit requirements validate briefing --llm-profile editorial --llm-session-id session-42 --llm-var mode=full --llm-input-file notes=notes.txt
|
||||||
```
|
```
|
||||||
|
|
||||||
The command accepts:
|
The command accepts:
|
||||||
@@ -500,26 +765,31 @@ The command accepts:
|
|||||||
| `--config` | path string | omitted | Select the configuration input; explicit presence is retained. |
|
| `--config` | path string | omitted | Select the configuration input; explicit presence is retained. |
|
||||||
| `--overlay` | path string | omitted | Select one overlay; explicit presence is retained. Repeated occurrences use the last value. |
|
| `--overlay` | path string | omitted | Select one overlay; explicit presence is retained. Repeated occurrences use the last value. |
|
||||||
| Suite selectors | application-defined | per selector | The flags captured from `Suite.Selectors`. |
|
| Suite selectors | application-defined | per selector | The flags captured from `Suite.Selectors`. |
|
||||||
| `--profile` | string | empty | Pass the profile ID to PromptKit. |
|
| `--llm-profile` | string | empty | Pass the profile ID to PromptKit. |
|
||||||
| `--session-id` | string | empty | Pass session context to PromptKit. |
|
| `--llm-session-id` | string | empty | Pass session context to PromptKit. |
|
||||||
| `--var` | repeatable `KEY=VALUE` | none | Supply caller variables for the concrete request. |
|
| `--llm-var` | repeatable `KEY=VALUE` | none | Supply caller variables for the concrete request. |
|
||||||
| `--input` | repeatable `NAME=PATH` | none | Bind a file-backed PromptKit artifact. |
|
| `--llm-input-text` | repeatable `NAME=TEXT` | none | Bind a literal PromptKit artifact. |
|
||||||
| `--format` | `yaml` | `yaml` | Select the success confirmation format. Other formats are usage errors. |
|
| `--llm-input-file` | repeatable `NAME=PATH` | none | Bind a file-backed PromptKit artifact. |
|
||||||
|
| `--format` | `text`, `yaml`, `json` | automatic | Select the success confirmation format; automatic and text use YAML for this structured report. |
|
||||||
|
|
||||||
The built-in flags other than repeatable `--var` and `--input` are singleton
|
Validation also accepts the execution controls from `--llm-endpoint` through
|
||||||
bindings; when repeated, the last value is used. Explicit empty `--config` or
|
`--llm-extra-params` listed under [smoke testing](#promptkit-smoke-testing).
|
||||||
|
The built-in flags other than
|
||||||
|
`--llm-var`, `--llm-input-text`, and `--llm-input-file` are singleton bindings;
|
||||||
|
when repeated, the last value is used. Explicit empty `--config` or
|
||||||
`--overlay` values keep their supplied-presence information. The profile and
|
`--overlay` values keep their supplied-presence information. The profile and
|
||||||
session fields use native empty-string semantics. PromptKit owns their
|
session fields use native empty-string semantics. PromptKit owns their
|
||||||
interpretation and selection errors.
|
interpretation and selection errors.
|
||||||
|
|
||||||
Each `--var` and `--input` occurrence must contain `=`. CommandKit splits at the
|
Each `--llm-var`, `--llm-input-text`, and `--llm-input-file` occurrence must
|
||||||
first separator and preserves the exact key/name and remaining value/path.
|
contain `=`. CommandKit splits at the first separator and preserves the exact
|
||||||
Empty variable keys and values and empty input names and paths are passed to
|
key/name and remaining value. Empty names and values are passed to native
|
||||||
native validation. File paths become `promptkit.File(PATH)` references without
|
validation. Text becomes a `promptkit.Inline` reference; file paths become
|
||||||
being read, normalized, expanded, or rebased during argument binding. Duplicate
|
`promptkit.File` references without being read, normalized, expanded, or
|
||||||
keys or names use the last value, but every occurrence is parsed.
|
rebased during argument binding. Every occurrence is parsed; for each input
|
||||||
|
name the last text or file occurrence wins, regardless of form.
|
||||||
|
|
||||||
On success, the YAML confirmation contains only the requested prompt ID and
|
On success, the confirmation contains only the requested prompt ID and
|
||||||
`valid: true`:
|
`valid: true`:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
@@ -534,13 +804,14 @@ configuration and credential checks. It does not make a model call or retain a
|
|||||||
prepared execution snapshot. A successful report describes this preparation
|
prepared execution snapshot. A successful report describes this preparation
|
||||||
invocation; it does not certify provider acceptance or guarantee later
|
invocation; it does not certify provider acceptance or guarantee later
|
||||||
execution. See PromptKit's
|
execution. See PromptKit's
|
||||||
[`Engine.ValidateRequest` API](https://gitea.maximumdirect.net/eric/promptkit/src/commit/9178f3eb91c2b2138caa78e22be93c048b4bc992/engine.go).
|
[`Engine.ValidateRequest` API](https://forge.maximumdirect.net/base-layer/promptkit/src/commit/8ff9aa3efdc9f16b9d2729e0a29ea083eb3c3eaf/engine.go).
|
||||||
|
|
||||||
Builder, engine-loading, and native validation errors use the operational path
|
Builder, engine-loading, and native validation errors use the operational path
|
||||||
(status `1`) with supported error identity and acquired diagnostics retained.
|
(status `1`) with supported error identity and acquired diagnostics retained.
|
||||||
Malformed syntax, missing operands, and unsupported formats use usage status
|
Malformed syntax, missing operands, and unsupported formats use usage status
|
||||||
`2`. Successful diagnostics and report-delivery failures follow the common
|
`2`. Successful diagnostics use the common reporting policy. Report delivery
|
||||||
delivery contract.
|
failures are recorded in `Outcome.Presentation.Output`, separately from the
|
||||||
|
operation cause.
|
||||||
|
|
||||||
### PromptKit catalogs
|
### PromptKit catalogs
|
||||||
|
|
||||||
@@ -556,8 +827,10 @@ example promptkit list collections
|
|||||||
```
|
```
|
||||||
|
|
||||||
All catalogs accept `--config`, `--overlay`, applicable flags from
|
All catalogs accept `--config`, `--overlay`, applicable flags from
|
||||||
`Suite.Selectors`, and `--format yaml` (the default). Other formats are usage
|
`Suite.Selectors`, and the shared `--format` flag. Automatic output uses text
|
||||||
errors. Prompt, profile, and backend listings accept namespace selection:
|
for nonempty string lists and YAML for other inventories; explicit `text` uses
|
||||||
|
the same rendering for structured inventories. Prompt, profile, and backend
|
||||||
|
listings accept namespace selection:
|
||||||
|
|
||||||
| Binding | Effect |
|
| Binding | Effect |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
@@ -573,11 +846,10 @@ before provider work. Namespace inventory accepts repeatable `--kind KIND`;
|
|||||||
omitting `--kind` forwards PromptKit's native default kind selection. Repeated
|
omitting `--kind` forwards PromptKit's native default kind selection. Repeated
|
||||||
values are forwarded in order. Collections have no namespace or kind filter.
|
values are forwarded in order. Collections have no namespace or kind filter.
|
||||||
|
|
||||||
Each operation writes one bare YAML sequence, retaining provider order and
|
Each operation reports one bare sequence, retaining provider order and native
|
||||||
native identities:
|
identities. YAML examples follow:
|
||||||
|
|
||||||
- Prompt entries are `{id, version?}`. The optional `version` key is omitted
|
- Prompt entries are `{id}`.
|
||||||
when absent and retained for a present empty or nonempty value.
|
|
||||||
- Profiles, backends, and namespaces are strings.
|
- Profiles, backends, and namespaces are strings.
|
||||||
- Collection entries are `{namespace, version?}`, where `version` projects
|
- Collection entries are `{namespace, version?}`, where `version` projects
|
||||||
PromptKit's optional release version.
|
PromptKit's optional release version.
|
||||||
@@ -585,7 +857,6 @@ native identities:
|
|||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
- id: team/briefing
|
- id: team/briefing
|
||||||
version: "2"
|
|
||||||
```
|
```
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
@@ -596,7 +867,7 @@ native identities:
|
|||||||
A catalog result is an inventory, not a readiness check or execution result.
|
A catalog result is an inventory, not a readiness check or execution result.
|
||||||
The owning provider controls membership and order. Namespace filters are passed
|
The owning provider controls membership and order. Namespace filters are passed
|
||||||
to PromptKit without local membership interpretation. See the
|
to PromptKit without local membership interpretation. See the
|
||||||
[PromptKit v0.16.0 consumer guide](https://gitea.maximumdirect.net/eric/promptkit/src/commit/9178f3eb91c2b2138caa78e22be93c048b4bc992/docs/consumers/pkg-promptkit.md)
|
[PromptKit v0.20.0 consumer guide](https://forge.maximumdirect.net/base-layer/promptkit/src/commit/8ff9aa3efdc9f16b9d2729e0a29ea083eb3c3eaf/docs/consumers/pkg-promptkit.md)
|
||||||
for native resource and selection definitions.
|
for native resource and selection definitions.
|
||||||
|
|
||||||
### PromptKit smoke testing
|
### PromptKit smoke testing
|
||||||
@@ -609,43 +880,49 @@ each aggregate call changes the seed kind before invoking it. The command
|
|||||||
accepts:
|
accepts:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
example promptkit test text_inference --campaign spring --prompt briefing --profile spring
|
example promptkit test text_inference --campaign spring --llm-prompt briefing --llm-profile spring
|
||||||
example promptkit test all --campaign spring --prompt briefing --profile spring
|
example promptkit test all --campaign spring --llm-prompt briefing --llm-profile spring
|
||||||
example promptkit test authentication --backend local
|
example promptkit test authentication --llm-backend local
|
||||||
```
|
```
|
||||||
|
|
||||||
| Flag | Values | Default | Meaning |
|
| Flag | Values | Default | Meaning |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| `--config`, `--overlay` | path strings | omitted | Select PromptKit configuration; supplied presence is retained. |
|
| `--config`, `--overlay` | path strings | omitted | Select PromptKit configuration; supplied presence is retained. |
|
||||||
| Suite selectors | application-defined | per selector | Captured flags from `Suite.Selectors`. |
|
| Suite selectors | application-defined | per selector | Captured flags from `Suite.Selectors`. |
|
||||||
| `--backend` | string | empty | Native backend ID. |
|
| `--llm-backend` | string | empty | Native backend ID. |
|
||||||
| `--prompt` | string | empty | Native prompt ID. |
|
| `--llm-prompt` | string | empty | Native prompt ID. |
|
||||||
| `--profile` | string | empty | Native profile ID. |
|
| `--llm-profile` | string | empty | Native profile ID. |
|
||||||
| `--endpoint` | string | omitted | Override the execution endpoint. |
|
| `--llm-endpoint` | string | omitted | Override the execution endpoint. |
|
||||||
| `--model` | string | omitted | Override the execution model. |
|
| `--llm-model` | string | omitted | Override the execution model. |
|
||||||
| `--temperature` | float | omitted | Override execution temperature. |
|
| `--llm-temperature` | float | omitted | Override execution temperature. |
|
||||||
| `--max-tokens` | integer | omitted | Override execution maximum tokens. |
|
| `--llm-max-tokens` | integer | omitted | Override execution maximum tokens. |
|
||||||
| `--top-p` | float | omitted | Override execution top-p. |
|
| `--llm-top-p` | float | omitted | Override execution top-p. |
|
||||||
| `--timeout-seconds` | integer | omitted | Override the execution timeout. |
|
| `--llm-timeout-seconds` | integer | omitted | Override the execution timeout. |
|
||||||
| `--service-tier` | string | omitted | Override the execution service tier. |
|
| `--llm-service-tier` | string | omitted | Override the execution service tier. |
|
||||||
| `--reasoning-effort` | string | omitted | Override execution reasoning effort. |
|
| `--llm-reasoning-effort` | string | omitted | Override execution reasoning effort. |
|
||||||
| `--api-key-env` | string | omitted | Supply an API-key environment-variable name to native configuration. |
|
| `--llm-api-key-env` | string | omitted | Supply an API-key environment-variable name to native configuration. |
|
||||||
| `--transient-retries` | integer | omitted | Set the native inference transient retry count. |
|
| `--llm-transient-retries` | integer | omitted | Set the native inference transient retry count. |
|
||||||
| `--format` | `yaml` | `yaml` | Select the report format. Other formats are usage errors. |
|
| `--llm-extra-params` | JSON object | omitted | Supply provider parameters as a whole map. |
|
||||||
|
| `--format` | `text`, `yaml`, `json` | automatic | Select the report format; automatic and text use YAML for these structured reports. |
|
||||||
|
|
||||||
The execution flags populate PromptKit's native execution override. The
|
The execution flags populate PromptKit's native execution override. The
|
||||||
`--api-key-env` value is a variable name, not a credential value. Application
|
`--llm-api-key-env` value is a variable name, not a credential value. The
|
||||||
selectors, loader policy, credentials, and client wiring remain application
|
extra-parameters flag accepts one JSON object, including an empty object;
|
||||||
|
PromptKit owns its native replacement semantics and validation. Integer JSON
|
||||||
|
numbers use signed or unsigned 64-bit integers where representable; decimal
|
||||||
|
and exponent numbers use floating point.
|
||||||
|
|
||||||
|
Application selectors, loader policy, credentials, and client wiring remain application
|
||||||
owned. The `WithSmokeTesting` helper accepts an optional typed builder. A nil
|
owned. The `WithSmokeTesting` helper accepts an optional typed builder. A nil
|
||||||
builder forwards the seed; a supplied builder's complete native request is
|
builder forwards the seed; a supplied builder's complete native request is
|
||||||
authoritative. The helper retains a native report when PromptKit returns both a
|
authoritative. The helper retains a native report when PromptKit returns both a
|
||||||
report and an error.
|
report and an error.
|
||||||
|
|
||||||
A single report projects the native smoke report into YAML. It preserves native
|
A single report projects the native smoke report. It preserves native
|
||||||
false, unknown/null, and incomplete values and does not add a readiness score.
|
false, unknown/null, and incomplete values and does not add a readiness score.
|
||||||
The report includes `kind`, `supported`, `applicable`, `completed`, `reason`,
|
The report includes `kind`, `supported`, `applicable`, `completed`, `reason`,
|
||||||
`target`, `facts`, `timing`, `attempts`, `usage`, and `failure`. Its target
|
`target`, `facts`, `timing`, `attempts`, `usage`, and `failure`. Its target
|
||||||
contains selected IDs, optional `version`, selection source, endpoint, effective
|
contains selected IDs, selection source, endpoint, effective
|
||||||
settings, and settings-presence flags. Attempt entries retain their own facts,
|
settings, and settings-presence flags. Attempt entries retain their own facts,
|
||||||
timing, failure, and token-usage evidence. Durations use Go duration strings;
|
timing, failure, and token-usage evidence. Durations use Go duration strings;
|
||||||
token counts preserve `value`, `known`, and `complete`.
|
token counts preserve `value`, `known`, and `complete`.
|
||||||
@@ -659,14 +936,14 @@ applicable: null
|
|||||||
completed: true
|
completed: true
|
||||||
reason: http_diagnostics_unavailable
|
reason: http_diagnostics_unavailable
|
||||||
target:
|
target:
|
||||||
backend_id: ""
|
backend_id: local
|
||||||
profile_id: spring
|
profile_id: spring
|
||||||
prompt_id: briefing
|
prompt_id: briefing
|
||||||
version: "1"
|
|
||||||
default_source_backend_id: ""
|
default_source_backend_id: ""
|
||||||
selection_source: profile
|
selection_source: profile
|
||||||
endpoint: https://example.invalid/v1
|
endpoint: https://example.invalid/v1
|
||||||
effective_settings:
|
effective_settings:
|
||||||
|
backend_id: local
|
||||||
endpoint: https://example.invalid/v1
|
endpoint: https://example.invalid/v1
|
||||||
model: spring-model
|
model: spring-model
|
||||||
temperature: 0
|
temperature: 0
|
||||||
@@ -711,7 +988,7 @@ usage:
|
|||||||
failure: ""
|
failure: ""
|
||||||
```
|
```
|
||||||
|
|
||||||
Aggregate output is one YAML mapping with ordered `tests` entries. Each entry
|
Aggregate output is one mapping with ordered `tests` entries. Each entry
|
||||||
has selected `kind`, nullable `report`, and optional `error` text. The inner
|
has selected `kind`, nullable `report`, and optional `error` text. The inner
|
||||||
report keeps its native `kind`. This cancellation example shows two attempted
|
report keeps its native `kind`. This cancellation example shows two attempted
|
||||||
operations with no reports; later kinds are absent because cancellation stops
|
operations with no reports; later kinds are absent because cancellation stops
|
||||||
@@ -734,18 +1011,23 @@ outcome. Operation errors use operational status `1`; aggregate runs continue
|
|||||||
after individual errors, retain their reports and ordered error text, and join
|
after individual errors, retain their reports and ordered error text, and join
|
||||||
causes for supported `errors.Is`/`errors.As` access. Cancellation stops later
|
causes for supported `errors.Is`/`errors.As` access. Cancellation stops later
|
||||||
operations and preserves results already acquired. Report encoding or delivery
|
operations and preserves results already acquired. Report encoding or delivery
|
||||||
failures use the common incomplete-report delivery outcome and retain operation
|
failures are recorded in `Outcome.Presentation.Output`, separately from
|
||||||
causes where applicable. Syntax, operand, unsupported format, and conflicting
|
operation causes. Syntax, operand, unsupported format, and conflicting
|
||||||
namespace selection are usage status `2`.
|
namespace selection are usage status `2`.
|
||||||
|
|
||||||
Native configuration, selection, and diagnostic meanings remain owned by
|
Native configuration, selection, and diagnostic meanings remain owned by
|
||||||
PromptKit. See the [v0.16.0 consumer guide](https://gitea.maximumdirect.net/eric/promptkit/src/commit/9178f3eb91c2b2138caa78e22be93c048b4bc992/docs/consumers/pkg-promptkit.md),
|
PromptKit. See the [v0.20.0 consumer guide](https://forge.maximumdirect.net/base-layer/promptkit/src/commit/8ff9aa3efdc9f16b9d2729e0a29ea083eb3c3eaf/docs/consumers/pkg-promptkit.md),
|
||||||
[smoke API](https://gitea.maximumdirect.net/eric/promptkit/src/commit/9178f3eb91c2b2138caa78e22be93c048b4bc992/smoke_diagnostics.go),
|
[smoke API](https://forge.maximumdirect.net/base-layer/promptkit/src/commit/8ff9aa3efdc9f16b9d2729e0a29ea083eb3c3eaf/smoke_diagnostics.go),
|
||||||
and [engine API](https://gitea.maximumdirect.net/eric/promptkit/src/commit/9178f3eb91c2b2138caa78e22be93c048b4bc992/engine.go).
|
and [engine API](https://forge.maximumdirect.net/base-layer/promptkit/src/commit/8ff9aa3efdc9f16b9d2729e0a29ea083eb3c3eaf/engine.go).
|
||||||
|
|
||||||
## Outcome status
|
## Outcome status
|
||||||
|
|
||||||
CommandKit usage failures use status `2`; operational failures use status `1`.
|
CommandKit usage failures use status `2`; operational failures use status `1`.
|
||||||
Successful operations use status `0`. Application handlers may return a domain
|
Successful operations use status `0`. Application handlers may return a domain
|
||||||
classification and status of their choice, which `Execute` preserves. The caller
|
classification and status of their choice, which `Execute` preserves. The caller
|
||||||
chooses how to map an outcome to process exit.
|
chooses how to map an outcome to process exit. A rendering or delivery failure
|
||||||
|
changes a successful or usage outcome to operational status `1`. A domain
|
||||||
|
outcome keeps its classification and nonzero status; a zero-status domain
|
||||||
|
outcome becomes `1` if presentation fails. The returned `Outcome.Presentation`
|
||||||
|
records output and diagnostic delivery observations separately from its
|
||||||
|
operation `Cause`.
|
||||||
|
|||||||
@@ -1,17 +1,34 @@
|
|||||||
# Consumer guide
|
# Consumer guide
|
||||||
|
|
||||||
CommandKit is distributed as three Go modules. Start with the core and add an
|
CommandKit is distributed as three Go modules. Start with the core and add an
|
||||||
integration module only when the application uses that library. Each module
|
integration module only when the application uses that library. Core and
|
||||||
returns core `commandkit.Command` declarations, which the application installs
|
compatible integration command factories return canonical `commandkit.Command` values.
|
||||||
through `commandkit.New` or `App.Register`.
|
Applications may install those alongside application-owned `RoutingRule`
|
||||||
|
declarations through `commandkit.New` or `App.Register`; routing rules are
|
||||||
|
installed at the application root, not as group children.
|
||||||
|
|
||||||
## Module selection
|
## Module selection
|
||||||
|
|
||||||
| Import | Use when |
|
| Import | Use when |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `gitea.maximumdirect.net/eric/CommandKit` | The application needs typed command declarations and common CLI behavior. |
|
| `go.maximumdirect.net/commandkit` | The application needs typed command declarations and common CLI behavior. |
|
||||||
| `gitea.maximumdirect.net/eric/CommandKit/integrations/ckconfig` | The application wants ConfigKit-backed configuration scopes and reports. |
|
| `go.maximumdirect.net/ckconfig` | The application wants ConfigKit-backed configuration scopes and reports. |
|
||||||
| `gitea.maximumdirect.net/eric/CommandKit/integrations/ckprompt` | The application wants PromptKit engine configuration/comparison, five catalogs, named inspection, requirements discovery, request validation, or native smoke testing. |
|
| `go.maximumdirect.net/ckprompt` | The application wants PromptKit engine configuration/comparison, five catalogs, named inspection, requirements discovery, request validation, or native smoke testing. |
|
||||||
|
|
||||||
|
The published matched releases are core v0.3.0, ckconfig v0.2.0, and ckprompt v0.2.0.
|
||||||
|
The published ckconfig v0.3.0 uses ConfigKit v1.1.0 and does not compose
|
||||||
|
with ckprompt v0.2.0, which retains ckconfig v0.2.0 and ConfigKit v0.8.0.
|
||||||
|
Install core with
|
||||||
|
`go get go.maximumdirect.net/commandkit@v0.3.0` and either integration with
|
||||||
|
`go get go.maximumdirect.net/ckconfig@v0.2.0` or
|
||||||
|
`go get go.maximumdirect.net/ckprompt@v0.2.0`.
|
||||||
|
|
||||||
|
The current ckprompt source targets ckconfig v0.3.0, ConfigKit v1.1.0, and
|
||||||
|
PromptKit v0.20.0 using their `go.maximumdirect.net` identities. Its v0.3.0
|
||||||
|
release is a candidate, not a published release. A Go 1.27.0 workspace may
|
||||||
|
compose all three local modules. The current integration source uses APIs that
|
||||||
|
require the upcoming core and ckconfig releases; independent validation follows
|
||||||
|
their publication and dependency alignment.
|
||||||
|
|
||||||
The two integrations are independent optional imports. `ckprompt` uses the
|
The two integrations are independent optional imports. `ckprompt` uses the
|
||||||
public scope and error contracts from `ckconfig`; importing it does not add the
|
public scope and error contracts from `ckconfig`; importing it does not add the
|
||||||
@@ -21,11 +38,13 @@ core-only, ConfigKit-only, and composed applications.
|
|||||||
|
|
||||||
## Go compatibility
|
## Go compatibility
|
||||||
|
|
||||||
All three modules declare a consumer minimum of Go 1.26.0. The root module also
|
Core declares a consumer minimum of Go 1.26.0; both current integration
|
||||||
selects ReleaseKit v0.4.2 as a Go tool, which makes that tool's module graph part
|
modules require Go 1.27.0. The root module also
|
||||||
of the root consumer module requirements even though production packages do not
|
selects ReleaseKit v0.9.0 (`go.maximumdirect.net/releasekit`) as a Go tool, which
|
||||||
import ReleaseKit. The [release policy](../policy/release.md#consumer-minimum-and-release-toolchain)
|
makes that tool's module graph part of the root consumer module requirements
|
||||||
defines the support assumption. Maintainers use Go 1.26.8 for validation and
|
even though production packages do not import ReleaseKit. The
|
||||||
|
[release policy](../policy/release.md#consumer-minimum-and-release-toolchain)
|
||||||
|
defines the support assumption. Maintainers use Go 1.27.0 for validation and
|
||||||
publication; see the [release procedure](../release.md#release-toolchain).
|
publication; see the [release procedure](../release.md#release-toolchain).
|
||||||
|
|
||||||
## Core commands
|
## Core commands
|
||||||
@@ -41,7 +60,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
commandkit "gitea.maximumdirect.net/eric/CommandKit"
|
commandkit "go.maximumdirect.net/commandkit"
|
||||||
)
|
)
|
||||||
|
|
||||||
type GreetInput struct {
|
type GreetInput struct {
|
||||||
@@ -62,7 +81,9 @@ func Greeting() commandkit.Command {
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
Run: func(invocation commandkit.Invocation, input GreetInput) commandkit.Outcome {
|
Run: func(invocation commandkit.Invocation, input GreetInput) commandkit.Outcome {
|
||||||
_, _ = fmt.Fprintln(invocation.Streams.Out, input.Count.Value)
|
if _, err := fmt.Fprintln(invocation.Reporter().Output(), input.Count.Value); err != nil {
|
||||||
|
return commandkit.Failure("Could not write the greeting.").WithCause(err)
|
||||||
|
}
|
||||||
return commandkit.Success()
|
return commandkit.Success()
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -83,6 +104,115 @@ values convert before input construction. A command's `NewInput` creates input
|
|||||||
only when execution reaches it; help does not construct caller input or run
|
only when execution reaches it; help does not construct caller input or run
|
||||||
provider callbacks.
|
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: "view",
|
||||||
|
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
|
||||||
|
`Report()` for formatted substantive values and optional text templates;
|
||||||
|
use `Output()` or `RenderOutput()` for deliberate raw output. Use `Warn`, `Info`,
|
||||||
|
`Progress`, and `Debug` for categorized messages. Queue a summary with
|
||||||
|
`AtEnd().Info` when it belongs after the handler returns. The
|
||||||
|
[CLI reference](../cli.md#reporting) owns flag grammar, visibility, prefixes,
|
||||||
|
and status behavior.
|
||||||
|
|
||||||
|
Reports use JSON serialization to establish the common value representation
|
||||||
|
for YAML and JSON. A text template receives the original value; without one,
|
||||||
|
text uses readable scalar or string-list output and YAML for structured values.
|
||||||
|
The CLI's shared format selection and the writer-level `RenderReport` use the
|
||||||
|
same rules. Use `DecodeYAMLReport` only for safe upstream inspection YAML that
|
||||||
|
must become a report value; ordinary strings are not parsed as YAML.
|
||||||
|
|
||||||
|
Return terminal text with `Failure(message, details...)`, `Usage(message,
|
||||||
|
details...)`, or `WithExplanation` on a domain outcome. Attach an optional Go
|
||||||
|
error separately with `WithCause`. A cause is preserved for inspection, but it
|
||||||
|
does not choose the outcome or become user-facing text. Rendering callbacks
|
||||||
|
write to their supplied writer only while the callback runs; they must not
|
||||||
|
submit another message or start another rendering operation through the same
|
||||||
|
reporter.
|
||||||
|
|
||||||
|
```go
|
||||||
|
Run: func(invocation commandkit.Invocation, input GreetInput) commandkit.Outcome {
|
||||||
|
if _, err := fmt.Fprintln(invocation.Reporter().Output(), input.Count.Value); err != nil {
|
||||||
|
return commandkit.Failure("Could not write the greeting.").WithCause(err)
|
||||||
|
}
|
||||||
|
return commandkit.Success()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
After `App.Execute`, inspect the returned `Outcome`. Its `Cause` retains the
|
||||||
|
operation error; `Presentation.Output` and `Presentation.Diagnostics` report
|
||||||
|
delivery state and the first rendering or write errors for their destinations.
|
||||||
|
CommandKit does not retain a message transcript. Reporter operations for one
|
||||||
|
invocation are safe concurrently, but concurrent submission order is not
|
||||||
|
specified. The caller owns the writers passed to `Execute`; CommandKit does not
|
||||||
|
close them.
|
||||||
|
|
||||||
|
## Application-owned aliases
|
||||||
|
|
||||||
|
Pass commands and aliases together as declarations. Integration factories can
|
||||||
|
be called directly in the installation list, and command-scoped flag aliases
|
||||||
|
name the canonical command path:
|
||||||
|
|
||||||
|
```go
|
||||||
|
app, err := commandkit.New(
|
||||||
|
identity,
|
||||||
|
ckprompt.Command(suite),
|
||||||
|
commandkit.Alias("pk", "promptkit"),
|
||||||
|
commandkit.FlagAliasFor("promptkit show prompt", "--fmt", "--format"),
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
For a reusable collection, use `[]commandkit.Declaration`. Go does not expand a
|
||||||
|
`[]commandkit.Command` into `...commandkit.Declaration`, so convert command-only
|
||||||
|
slices when composing them with routing rules:
|
||||||
|
|
||||||
|
```go
|
||||||
|
commands := []commandkit.Command{ckprompt.Command(suite)}
|
||||||
|
declarations := make([]commandkit.Declaration, 0, len(commands)+1)
|
||||||
|
for _, command := range commands {
|
||||||
|
declarations = append(declarations, command)
|
||||||
|
}
|
||||||
|
declarations = append(declarations, commandkit.Alias("pk", "promptkit"))
|
||||||
|
app, err := commandkit.New(identity, declarations...)
|
||||||
|
```
|
||||||
|
|
||||||
|
New and Register validate each supplied batch before publishing its commands
|
||||||
|
and routing rules. Factories capture their inputs eagerly; malformed or
|
||||||
|
conflicting rules are reported at installation. Identical rules are safe to
|
||||||
|
contribute from reusable helpers. The [core example](../../examples/core) and
|
||||||
|
[composed consumer](../../integrations/ckprompt/examples/composed) show complete
|
||||||
|
maintained wiring with ordinary command behavior alongside aliases. The
|
||||||
|
[Go declarations and GoDoc](../../command.go) own the public API contracts;
|
||||||
|
the [CLI reference](../cli.md#application-owned-aliases) owns routing and
|
||||||
|
operator-visible behavior.
|
||||||
|
|
||||||
## ConfigKit scopes
|
## ConfigKit scopes
|
||||||
|
|
||||||
For an application with one ordinary app configuration loader,
|
For an application with one ordinary app configuration loader,
|
||||||
@@ -105,8 +235,10 @@ Use `ckconfig.Command(ckconfig.Suite[I, S]{...})` when the application needs
|
|||||||
multiple named scopes, selector flags, scope-aware input checks, or shared
|
multiple named scopes, selector flags, scope-aware input checks, or shared
|
||||||
acquisition state or a custom comparison provider. A declared `Diff` capability
|
acquisition state or a custom comparison provider. A declared `Diff` capability
|
||||||
uses `Scope.Compare`; `ckconfig.FromConfigKit` and `AppCommand` provide the
|
uses `Scope.Compare`; `ckconfig.FromConfigKit` and `AppCommand` provide the
|
||||||
ConfigKit-backed implementation. Each comparison side gets its own initialized
|
ConfigKit-backed implementation. `Selector.AgainstName` opts a selector into an
|
||||||
and checked input, while state is shared by the invocation. Factory results
|
independent right-side flag. Each comparison side gets its own initialized and
|
||||||
|
checked input, with only its supplied selectors applied; state is shared by the
|
||||||
|
invocation. Factory results
|
||||||
remain caller-owned, so separate initialization calls do not guarantee distinct
|
remain caller-owned, so separate initialization calls do not guarantee distinct
|
||||||
objects. See
|
objects. See
|
||||||
[ckconfig.Suite's GoDoc](../../integrations/ckconfig/contracts.go) for factory
|
[ckconfig.Suite's GoDoc](../../integrations/ckconfig/contracts.go) for factory
|
||||||
@@ -114,10 +246,17 @@ timing, omitted-factory zero values (including nil pointers), and ownership.
|
|||||||
The application owns the state type and what it caches. `ckconfig.FromConfigKit`
|
The application owns the state type and what it caches. `ckconfig.FromConfigKit`
|
||||||
adapts a typed ConfigKit loader without calling it during construction.
|
adapts a typed ConfigKit loader without calling it during construction.
|
||||||
|
|
||||||
The `config` family provides scope discovery, validation, native effective YAML,
|
The `config` family provides scope discovery, validation, safe effective configuration display,
|
||||||
source inventory, path explanation, and ConfigKit-backed comparison for the
|
source inventory, path explanation, and ConfigKit-backed comparison for the
|
||||||
capabilities declared by its scopes. The [CLI reference](../cli.md#configuration-scopes)
|
capabilities declared by its scopes. The [CLI reference](../cli.md#configuration-scopes)
|
||||||
owns operator syntax and report details.
|
owns operator syntax and report details. `Scope.Features` declares optional modes; acquired
|
||||||
|
`View.Metadata` and `View.Findings` carry native path metadata and diagnostic
|
||||||
|
records when available. `Findings.Messages` contains presentations associated
|
||||||
|
with those records, while `Acquired.Diagnostics` carries independent messages.
|
||||||
|
Set `Scope.Features.Overrides` only when the loader passes
|
||||||
|
`Selection.Overrides` to ConfigKit as part of its normal load. The integration
|
||||||
|
parses CLI records but cannot add them to an already-loaded result. The
|
||||||
|
[CLI reference](../cli.md#configuration-scopes) describes the operator syntax.
|
||||||
|
|
||||||
## PromptKit configuration, definitions, and requirements
|
## PromptKit configuration, definitions, and requirements
|
||||||
|
|
||||||
@@ -125,20 +264,25 @@ owns operator syntax and report details.
|
|||||||
engine's captured configuration. Add that scope to a `ckconfig.Suite` to expose
|
engine's captured configuration. Add that scope to a `ckconfig.Suite` to expose
|
||||||
it under `config` with validate/show/sources/explain and native diff operations;
|
it under `config` with validate/show/sources/explain and native diff operations;
|
||||||
it does not install PromptKit catalog, definition, or requirements commands.
|
it does not install PromptKit catalog, definition, or requirements commands.
|
||||||
|
Its override feature is disabled because the released PromptKit configuration
|
||||||
|
loader does not accept ConfigKit override records.
|
||||||
|
|
||||||
`ckprompt.FromEngine(loadEngine)` supplies eight callbacks: prompt, profile,
|
`ckprompt.FromEngine(loadEngine)` supplies nine callbacks: prompt, profile,
|
||||||
backend, namespace, and collection catalogs; named prompt and profile
|
backend, namespace, and collection catalogs; named prompt, profile, and backend
|
||||||
inspection; and native requirements discovery. Concrete request validation and
|
inspection; and native requirements discovery. Concrete request validation,
|
||||||
smoke testing are separate capabilities, added explicitly with
|
prepared-request preview, and smoke testing are separate capabilities, added
|
||||||
`ckprompt.WithRequestValidation` and `ckprompt.WithSmokeTesting`. The suite has
|
with `ckprompt.WithRequestValidation`, `ckprompt.WithRequestPreparation`, and
|
||||||
ten independently optional callbacks in total. `ckprompt.Command(suite)` creates
|
`ckprompt.WithSmokeTesting`. The suite has twelve independently optional
|
||||||
only operations whose callbacks are present; discovery, validation, and smoke
|
callbacks in total. `ckprompt.Command(suite)` creates only operations whose
|
||||||
testing can be installed independently. A composed suite can enable both
|
callbacks are present, with canonical flags. `ckprompt.Declarations(suite)`
|
||||||
additional operations:
|
returns the command and its scoped convenience aliases for installation with
|
||||||
|
other declarations. These capabilities can be installed independently. A
|
||||||
|
composed suite can enable the additional operations:
|
||||||
|
|
||||||
```go
|
```go
|
||||||
suite := ckprompt.FromEngine(loadEngine)
|
suite := ckprompt.FromEngine(loadEngine)
|
||||||
suite = ckprompt.WithRequestValidation(suite, loadEngine, buildRequest)
|
suite = ckprompt.WithRequestValidation(suite, loadEngine, buildRequest)
|
||||||
|
suite = ckprompt.WithRequestPreparation(suite, loadEngine, nil)
|
||||||
suite = ckprompt.WithSmokeTesting(suite, loadEngine, nil)
|
suite = ckprompt.WithSmokeTesting(suite, loadEngine, nil)
|
||||||
suite.NewInput = newInput
|
suite.NewInput = newInput
|
||||||
suite.NewState = newState
|
suite.NewState = newState
|
||||||
@@ -168,7 +312,7 @@ absent, so explicitly supplied empty values stay meaningful. Applications that
|
|||||||
validate and then execute a request are encouraged to share their request
|
validate and then execute a request are encouraged to share their request
|
||||||
construction logic between those paths.
|
construction logic between those paths.
|
||||||
|
|
||||||
The eight catalog and inspection callbacks and the two explicit additions are
|
The catalog and inspection callbacks and the explicit additions are
|
||||||
independently optional. For catalogs without named or requirements inspection,
|
independently optional. For catalogs without named or requirements inspection,
|
||||||
narrow the suite before command capture:
|
narrow the suite before command capture:
|
||||||
|
|
||||||
@@ -176,11 +320,12 @@ narrow the suite before command capture:
|
|||||||
definitions := ckprompt.FromEngine(loadEngine)
|
definitions := ckprompt.FromEngine(loadEngine)
|
||||||
definitions.InspectPrompt = nil
|
definitions.InspectPrompt = nil
|
||||||
definitions.InspectProfile = nil
|
definitions.InspectProfile = nil
|
||||||
|
definitions.InspectBackend = nil
|
||||||
definitions.InspectRequirements = nil
|
definitions.InspectRequirements = nil
|
||||||
definitionCommand := ckprompt.Command(definitions)
|
definitionCommand := ckprompt.Command(definitions)
|
||||||
```
|
```
|
||||||
|
|
||||||
Each selected inspection, requirements, validation, smoke, or catalog operation
|
Each selected inspection, requirements, validation, preparation, smoke, or catalog operation
|
||||||
prepares one input, applies its captured selectors, checks that input, and
|
prepares one input, applies its captured selectors, checks that input, and
|
||||||
initializes one state value when those steps are reached. The bundled validation
|
initializes one state value when those steps are reached. The bundled validation
|
||||||
adapter delegates concrete request preparation to PromptKit and carries its
|
adapter delegates concrete request preparation to PromptKit and carries its
|
||||||
@@ -193,6 +338,24 @@ and state. `FromEngine` uses PromptKit's public listing and inspection methods,
|
|||||||
including `Engine.InspectRequirements`; validation must be opted into separately
|
including `Engine.InspectRequirements`; validation must be opted into separately
|
||||||
and uses `Engine.ValidateRequest`.
|
and uses `Engine.ValidateRequest`.
|
||||||
|
|
||||||
|
`ckprompt.WithRequestPreparation` adds a separate callback backed by
|
||||||
|
`Engine.Prepare`. Its optional builder receives the seeded request and returns
|
||||||
|
the complete request to prepare. The resulting native report can contain
|
||||||
|
rendered application content. See the [CLI reference](../cli.md#promptkit-prepared-request-preview)
|
||||||
|
for operator behavior.
|
||||||
|
|
||||||
|
Application-owned commands can use `ckprompt.NewExecutionFlags`,
|
||||||
|
`ckprompt.NewResourceFlags`, and `ckprompt.NewPromptInputFlags` to bind the
|
||||||
|
same canonical request flags into their own input types. Each collection exposes
|
||||||
|
individual handles and an `All` method, so a command can choose its applicable
|
||||||
|
subset. The application decides how to apply the resulting values to its request.
|
||||||
|
|
||||||
|
`ckprompt.WithNamespaces` enables standard resource namespace selection from
|
||||||
|
application-supplied defaults. Loaders used with this option should leave view
|
||||||
|
selection to the adapter. Without it, a loader can supply its own engine view.
|
||||||
|
Direct callbacks receive the composed selection in their request. The
|
||||||
|
[CLI reference](../cli.md#resource-namespace-selection) lists applicable flags.
|
||||||
|
|
||||||
Catalog, named definition inspection, and requirements discovery do not render
|
Catalog, named definition inspection, and requirements discovery do not render
|
||||||
messages or load request artifacts. Concrete request validation follows native
|
messages or load request artifacts. Concrete request validation follows native
|
||||||
PromptKit preparation, which can resolve prompt/profile resources, load
|
PromptKit preparation, which can resolve prompt/profile resources, load
|
||||||
@@ -213,19 +376,42 @@ combines app and PromptKit config scopes, comparison and catalogs, named and
|
|||||||
requirements inspection, request validation, smoke testing, and an
|
requirements inspection, request validation, smoke testing, and an
|
||||||
application-owned `session restore` command.
|
application-owned `session restore` command.
|
||||||
|
|
||||||
|
### Namespace views
|
||||||
|
|
||||||
|
An application can return a scoped PromptKit view from its own engine loader:
|
||||||
|
|
||||||
|
```go
|
||||||
|
func loadScopedEngine(ctx context.Context, selection ckconfig.Selection, input Input, state State) (*promptkit.Engine, error) {
|
||||||
|
engine, err := loadEngine(ctx, selection, input, state)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return engine.WithNamespaces(promptkit.Namespaces{
|
||||||
|
Prompt: "editorial", Profile: "team", Backend: "providers",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
These are independent defaults for unqualified caller-supplied prompt, profile,
|
||||||
|
and backend names; qualified IDs pass through. Stored references and configuration
|
||||||
|
inspection are unchanged, while namespace and collection inventories remain global.
|
||||||
|
Catalog `--namespace` filters are separate from these defaults. See the released
|
||||||
|
[namespace API](https://forge.maximumdirect.net/base-layer/promptkit/src/commit/8ff9aa3efdc9f16b9d2729e0a29ea083eb3c3eaf/namespaces.go).
|
||||||
|
|
||||||
## Outcomes and ownership
|
## Outcomes and ownership
|
||||||
|
|
||||||
Core classifies usage and operational failures while preserving application
|
Core classifies usage and operational failures while preserving application
|
||||||
domain outcomes and supported error identity. Report and diagnostic delivery
|
domain outcomes and supported error identity. Applications choose domain
|
||||||
errors remain inspectable separately from the operation cause. The caller owns
|
classification and status. A terminal explanation is presented separately from
|
||||||
process startup, signal handling, and exit mapping; `Execute` never exits the
|
the optional operation cause, while report and diagnostic delivery observations
|
||||||
process.
|
are available in `Outcome.Presentation`. The caller owns process startup,
|
||||||
|
signal handling, and exit mapping; `Execute` never exits the process.
|
||||||
|
|
||||||
Go declarations and GoDoc own exact exported contracts. ConfigKit owns
|
Go declarations and GoDoc own exact exported contracts. ConfigKit owns
|
||||||
configuration decoding, validation, and provenance. PromptKit owns prompt
|
configuration decoding, validation, and provenance. PromptKit owns prompt
|
||||||
formats, profile resolution, and inspection details. See the
|
formats, profile resolution, and inspection details. See the
|
||||||
[ConfigKit v0.8.0 inspection API](https://gitea.maximumdirect.net/eric/ConfigKit/src/commit/137769f3f18a370c68444242fb5d88e620e307e5/inspection.go),
|
[ConfigKit v1.1.0 source records](https://forge.maximumdirect.net/base-layer/configkit/src/commit/191015e289d7f2b91abb9fdd9c4f2f5cf75b4de9/records.go),
|
||||||
[PromptKit engine methods](https://gitea.maximumdirect.net/eric/promptkit/src/commit/9178f3eb91c2b2138caa78e22be93c048b4bc992/engine.go),
|
[PromptKit engine methods](https://forge.maximumdirect.net/base-layer/promptkit/src/commit/8ff9aa3efdc9f16b9d2729e0a29ea083eb3c3eaf/engine.go),
|
||||||
[configuration inspection](https://gitea.maximumdirect.net/eric/promptkit/src/commit/9178f3eb91c2b2138caa78e22be93c048b4bc992/configuration_inspection.go),
|
[configuration inspection](https://forge.maximumdirect.net/base-layer/promptkit/src/commit/8ff9aa3efdc9f16b9d2729e0a29ea083eb3c3eaf/configuration_inspection.go),
|
||||||
[smoke diagnostics](https://gitea.maximumdirect.net/eric/promptkit/src/commit/9178f3eb91c2b2138caa78e22be93c048b4bc992/smoke_diagnostics.go),
|
[smoke diagnostics](https://forge.maximumdirect.net/base-layer/promptkit/src/commit/8ff9aa3efdc9f16b9d2729e0a29ea083eb3c3eaf/smoke_diagnostics.go),
|
||||||
and [PromptKit v0.16.0 consumer guidance](https://gitea.maximumdirect.net/eric/promptkit/src/commit/9178f3eb91c2b2138caa78e22be93c048b4bc992/docs/consumers/pkg-promptkit.md).
|
and [PromptKit v0.20.0 consumer guidance](https://forge.maximumdirect.net/base-layer/promptkit/src/commit/8ff9aa3efdc9f16b9d2729e0a29ea083eb3c3eaf/docs/consumers/pkg-promptkit.md).
|
||||||
|
|||||||
@@ -2,8 +2,9 @@
|
|||||||
|
|
||||||
CommandKit is distributed as a core Go module and two optional integration
|
CommandKit is distributed as a core Go module and two optional integration
|
||||||
modules. Core owns captured declarations, typed input preparation, installation,
|
modules. Core owns captured declarations, typed input preparation, installation,
|
||||||
dispatch, outcomes, help, and version. `integrations/ckconfig` owns ConfigKit
|
dispatch, invocation reporting and outcomes, help, and version.
|
||||||
scope adaptation and reports; `integrations/ckprompt` owns PromptKit engine,
|
`integrations/ckconfig` owns ConfigKit scope adaptation and reports;
|
||||||
|
`integrations/ckprompt` owns PromptKit engine,
|
||||||
definition inspection, requirements discovery, and concrete request validation.
|
definition inspection, requirements discovery, and concrete request validation.
|
||||||
The maintained [consumer guide](consumers/guide.md) links to the three maintained
|
The maintained [consumer guide](consumers/guide.md) links to the three maintained
|
||||||
consumers.
|
consumers.
|
||||||
@@ -11,13 +12,19 @@ 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.
|
||||||
|
|
||||||
|
For application-owned aliases, use the
|
||||||
|
[CLI reference](cli.md#application-owned-aliases),
|
||||||
|
[Go declarations and GoDoc](../command.go), and
|
||||||
|
[ADR 0005](adr/0005-application-alias-routing.md). Keep unfinished work and
|
||||||
|
capability proposals in [future work](roadmap/future.md).
|
||||||
|
|
||||||
## Initial orientation
|
## Initial orientation
|
||||||
|
|
||||||
1. Inspect the worktree and preserve unrelated staged and unstaged changes.
|
1. Inspect the worktree and preserve unrelated staged and unstaged changes.
|
||||||
2. Read the [agent policy](policy/agents.md) and select other applicable policies
|
2. Read the [agent policy](policy/agents.md) and select other applicable policies
|
||||||
using the guide below.
|
using the guide below.
|
||||||
3. Read the owning roadmap for proposals and implementation status, and the
|
3. Read the owning roadmap for proposals and implementation status, and the
|
||||||
applicable ADR for accepted decisions and rationale.
|
applicable [ADR](adr/README.md) for accepted decisions and rationale.
|
||||||
4. Inspect implementation and relevant consumer contracts when they exist.
|
4. Inspect implementation and relevant consumer contracts when they exist.
|
||||||
|
|
||||||
## Policy Reading Guide
|
## Policy Reading Guide
|
||||||
@@ -47,15 +54,17 @@ canonical documentation owners.
|
|||||||
| Task | Read |
|
| Task | Read |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| Implemented CLI behavior | [CLI reference](cli.md) |
|
| Implemented CLI behavior | [CLI reference](cli.md) |
|
||||||
|
| Invocation reporting | [CLI reporting contract](cli.md#reporting), [consumer guide](consumers/guide.md#invocation-reporting), core GoDoc, and [ADR 0006](adr/0006-invocation-reporting.md) |
|
||||||
|
| Application-owned aliases | [CLI reference](cli.md#application-owned-aliases), [Go declarations and GoDoc](../command.go), [consumer guide](consumers/guide.md#application-owned-aliases), and [ADR 0005](adr/0005-application-alias-routing.md) |
|
||||||
| Public API usage and standard-suite wiring | [Consumer guide](consumers/guide.md), [ckconfig package](../integrations/ckconfig/doc.go), [ckprompt package](../integrations/ckprompt/doc.go), and applicable ADRs |
|
| Public API usage and standard-suite wiring | [Consumer guide](consumers/guide.md), [ckconfig package](../integrations/ckconfig/doc.go), [ckprompt package](../integrations/ckprompt/doc.go), and applicable ADRs |
|
||||||
| Dispatcher, registration, or typed inputs | [Command declarations](../command.go), [bindings](../binding.go), [invocation](../execute.go), and [ADR 0001](adr/0001-command-dispatch-and-inputs.md), refined by [ADR 0011](adr/0011-captured-declarations-and-installation.md) |
|
| Dispatcher, registration, or typed inputs | [Command declarations](../command.go), [bindings](../binding.go), [invocation](../execute.go), and [ADR 0001](adr/0001-command-dispatch-and-inputs.md) |
|
||||||
| Backend integration | [Internal overview](internal/overview.md), [adapter implementation](../internal/cobra/parse.go), and [ADR 0002](adr/0002-cobra-backend.md) |
|
| Backend integration | [Internal overview](internal/overview.md), [adapter implementation](../internal/cobra/parse.go), and [ADR 0002](adr/0002-public-facade-and-cli-backend.md) |
|
||||||
| Public facade or adapter boundaries | [Internal overview](internal/overview.md), [ADR 0003](adr/0003-public-facade.md) as scoped by [ADR 0010](adr/0010-optional-integration-modules.md), and the architecture policy |
|
| Public facade or adapter boundaries | [Internal overview](internal/overview.md), [ADR 0002](adr/0002-public-facade-and-cli-backend.md), [ADR 0004](adr/0004-optional-integration-modules.md), and the architecture policy |
|
||||||
| Module ownership and public contracts | [Consumer guide](consumers/guide.md), [internal overview](internal/overview.md), [core GoDoc](../doc.go), [ckconfig GoDoc](../integrations/ckconfig/doc.go), [ckprompt GoDoc](../integrations/ckprompt/doc.go), and applicable ADRs |
|
| Module ownership and public contracts | [Consumer guide](consumers/guide.md), [internal overview](internal/overview.md), [core GoDoc](../doc.go), [ckconfig GoDoc](../integrations/ckconfig/doc.go), [ckprompt GoDoc](../integrations/ckprompt/doc.go), and applicable ADRs |
|
||||||
| Deferred capabilities and proposals | [Deferred work](roadmap/deferred.md) |
|
| Unfinished work and capability proposals | [Future work](roadmap/future.md) |
|
||||||
| Completed planning and implementation history | [Completed dependency upgrades, PromptKit validation, discovery, smoke testing, and remediation](https://gitea.maximumdirect.net/eric/CommandKit/src/commit/a6278a6d9f940948ec7745955d745523aaff0734/docs/roadmap), [earlier ConfigKit and PromptKit adoption and remediation](https://gitea.maximumdirect.net/eric/CommandKit/src/commit/8a82620dfcd34c0ae56bbecb0d014eb49786bc92/docs/roadmap), [earlier retired records](https://gitea.maximumdirect.net/eric/CommandKit/src/commit/6bc37e7ba64ca0920b4d0cc77039a18d4006b24e/docs/roadmap), and [completed module implementation and remediation](https://gitea.maximumdirect.net/eric/CommandKit/src/commit/bb2528532b78d30e52f5082e491a5e1fd9b0fb99/docs/roadmap) in Git history |
|
| Completed planning and implementation history | [Completed dependency upgrades, PromptKit validation, discovery, smoke testing, and remediation](https://gitea.maximumdirect.net/eric/CommandKit/src/commit/a6278a6d9f940948ec7745955d745523aaff0734/docs/roadmap), [earlier ConfigKit and PromptKit adoption and remediation](https://gitea.maximumdirect.net/eric/CommandKit/src/commit/8a82620dfcd34c0ae56bbecb0d014eb49786bc92/docs/roadmap), [earlier retired records](https://gitea.maximumdirect.net/eric/CommandKit/src/commit/6bc37e7ba64ca0920b4d0cc77039a18d4006b24e/docs/roadmap), and [completed module implementation and remediation](https://gitea.maximumdirect.net/eric/CommandKit/src/commit/bb2528532b78d30e52f5082e491a5e1fd9b0fb99/docs/roadmap) in Git history |
|
||||||
| Release tooling | [Maintainer release procedure](release.md) |
|
| Release tooling | [Maintainer release procedure](release.md) |
|
||||||
| Performance maintenance | [Internal overview](internal/overview.md), existing benchmarks, and [deferred efficiency work](roadmap/deferred.md#possible-further-efficiency-work) |
|
| Performance maintenance | [Internal overview](internal/overview.md), existing benchmarks, and [possible further efficiency work](roadmap/future.md#possible-further-efficiency-work) |
|
||||||
| Licensing or incorporated material | [Project grant](../COPYRIGHT), [license texts](../LICENSE), and [attribution](oss/attribution.md) |
|
| Licensing or incorporated material | [Project grant](../COPYRIGHT), [license texts](../LICENSE), and [attribution](oss/attribution.md) |
|
||||||
| Policy provenance | [Policy provenance](policy/meta.md) |
|
| Policy provenance | [Policy provenance](policy/meta.md) |
|
||||||
|
|
||||||
@@ -63,10 +72,13 @@ Read all applicable rows for cross-cutting work. Go declarations and GoDoc own
|
|||||||
current exported contracts; the CLI reference owns operator behavior. The
|
current exported contracts; the CLI reference owns operator behavior. The
|
||||||
[maintainer release procedure](release.md) owns current release operations.
|
[maintainer release procedure](release.md) owns current release operations.
|
||||||
Completed roadmaps and implementation evidence remain available in Git history,
|
Completed roadmaps and implementation evidence remain available in Git history,
|
||||||
including [PromptKit requirements implementation and remediation](https://gitea.maximumdirect.net/eric/CommandKit/src/commit/40499a43608bf60d1104cd8ee24caf18ac0e9e66/docs/roadmap).
|
including [PromptKit v0.18.0 adoption and implementation](https://forge.maximumdirect.net/base-layer/commandkit/src/commit/540e2359d66d604a563df0be1c9fc961588f5b9c/docs/roadmap),
|
||||||
|
[ConfigKit adoption, reporting, release-tooling, and remediation records](https://forge.maximumdirect.net/base-layer/commandkit/src/commit/260256d722f52bc947103d078cb67a129f4cc9f1/docs/roadmap),
|
||||||
|
[application-owned alias planning, implementation, and remediation](https://gitea.maximumdirect.net/eric/CommandKit/src/commit/f42aefe02d84e154855ac922173e6f367180929f/docs/roadmap)
|
||||||
|
and [PromptKit requirements implementation and remediation](https://gitea.maximumdirect.net/eric/CommandKit/src/commit/40499a43608bf60d1104cd8ee24caf18ac0e9e66/docs/roadmap).
|
||||||
These records are not assignments to restart completed work. The
|
These records are not assignments to restart completed work. The
|
||||||
[deferred-work record](roadmap/deferred.md) preserves future capability targets
|
[future work record](roadmap/future.md) preserves unfinished work and unselected
|
||||||
and unselected proposals separately from current contracts.
|
proposals separately from current contracts.
|
||||||
|
|
||||||
## Documentation validation
|
## Documentation validation
|
||||||
|
|
||||||
@@ -89,39 +101,30 @@ Sibling-checkout references in retired planning records are historical review
|
|||||||
evidence, not build dependencies. Implemented contracts belong in CommandKit
|
evidence, not build dependencies. Implemented contracts belong in CommandKit
|
||||||
under their canonical owners.
|
under their canonical owners.
|
||||||
|
|
||||||
The root module does not traverse nested modules. During development, use a
|
The root module does not traverse nested modules. Preserve the ignored `go.work`
|
||||||
local workspace for all three modules; it is ignored and must not be committed.
|
and `go.work.sum`; use `GOWORK=off GOTOOLCHAIN=local` for dependency preparation
|
||||||
If a local `go.work` already exists, preserve it and add these module directories:
|
and independent module suites once released requirements are aligned. Until then,
|
||||||
|
the integrations require a Go 1.27.0 workspace containing all three modules.
|
||||||
|
Run `GOWORK=off GOTOOLCHAIN=local go test ./...` separately from each module
|
||||||
|
directory. From the repository root, a workspace containing all three modules can
|
||||||
|
also run:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
if [ -f go.work ]; then
|
GOTOOLCHAIN=local go test ./...
|
||||||
go work use . ./integrations/ckconfig ./integrations/ckprompt
|
GOTOOLCHAIN=local go test ./integrations/ckconfig/...
|
||||||
else
|
GOTOOLCHAIN=local go test ./integrations/ckprompt/...
|
||||||
go work init . ./integrations/ckconfig ./integrations/ckprompt
|
|
||||||
fi
|
|
||||||
go test ./...
|
|
||||||
(cd integrations/ckconfig && go test ./...)
|
|
||||||
(cd integrations/ckprompt && go test ./...)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
The workspace checks package behavior. Workspace-off checks with each module's
|
|
||||||
own dependency graph are separate evidence. For unpublished CommandKit dependency
|
|
||||||
versions, the [completed integration validation record](https://gitea.maximumdirect.net/eric/CommandKit/src/commit/8a82620dfcd34c0ae56bbecb0d014eb49786bc92/docs/roadmap/implementation.md#stage-12-validate-the-complete-end-state-and-finish)
|
|
||||||
describes the disposable module-file procedure used during implementation.
|
|
||||||
Format changed Go files with `gofmt`. The
|
Format changed Go files with `gofmt`. The
|
||||||
[maintainer release procedure](release.md) owns candidate preparation and
|
[maintainer release procedure](release.md) owns candidate preparation and
|
||||||
publication commands.
|
publication commands.
|
||||||
|
|
||||||
## Release tooling development
|
## Release tooling development
|
||||||
|
|
||||||
The local publication exercise is opt-in and uses disposable Git repositories.
|
Run the focused launcher wiring tests with `go test ./scripts`. The release
|
||||||
Prepare the module cache before running it; ordinary tests do not bootstrap or
|
profiles select upstream checks of committed inputs independently for each
|
||||||
publish a release:
|
module; these checks do not replace domain review. See the
|
||||||
|
[release procedure](release.md) for publication, delivery, and recovery.
|
||||||
```sh
|
|
||||||
GOWORK=off GOTOOLCHAIN=local go mod download
|
|
||||||
COMMANDKIT_RELEASE_INTEGRATION=1 GOWORK=off GOTOOLCHAIN=local GOPROXY=off GONOPROXY=none GOSUMDB=off go test -mod=readonly -count=1 ./scripts -run '^TestReleaseIntegration$'
|
|
||||||
```
|
|
||||||
|
|
||||||
Follow the [local commit policy](policy/agents.md#git-commit-policy). Completed
|
Follow the [local commit policy](policy/agents.md#git-commit-policy). Completed
|
||||||
units receive local commits by default, subject to explicit user instructions.
|
units receive local commits by default, subject to explicit user instructions.
|
||||||
@@ -130,31 +133,46 @@ or publishing.
|
|||||||
|
|
||||||
## Current public examples
|
## Current public examples
|
||||||
|
|
||||||
The maintained consumers compile with their owning module suites and run without
|
The maintained consumers run without external services using the aligned local
|
||||||
external services. The PromptKit smoke examples use local fixtures and the
|
workspace. The PromptKit smoke examples use local fixtures and the example's
|
||||||
example's configured client:
|
configured client:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
go run ./examples/core workspace greet --count=0 --tag=first --tag second
|
GOTOOLCHAIN=local go run ./examples/core workspace greet --count=0 --tag=first --tag second
|
||||||
(cd integrations/ckconfig/examples/simple && go run . config show)
|
(cd integrations/ckconfig/examples/simple && GOTOOLCHAIN=local go run . config show)
|
||||||
(cd integrations/ckconfig/examples/simple && go run . config diff --config fixtures/before.yml --against-config fixtures/after.yml)
|
(cd integrations/ckconfig/examples/simple && GOTOOLCHAIN=local go run . config diff --config fixtures/before.yml --against-config fixtures/after.yml)
|
||||||
(cd integrations/ckprompt/examples/composed && go run . config validate --scope all --campaign spring --limit=0)
|
(cd integrations/ckprompt/examples/composed && GOTOOLCHAIN=local go run . config validate --scope all --campaign spring --limit=0)
|
||||||
(cd integrations/ckprompt/examples/composed && go run . config diff --scope app --config fixtures/before.yml --against-config fixtures/after.yml)
|
(cd integrations/ckprompt/examples/composed && GOTOOLCHAIN=local go run . config diff --scope app --config fixtures/before.yml --against-config fixtures/after.yml)
|
||||||
(cd integrations/ckprompt/examples/composed && go run . config diff --scope promptkit --config fixtures/before.yml --against-config fixtures/after.yml --campaign spring)
|
(cd integrations/ckprompt/examples/composed && GOTOOLCHAIN=local go run . config diff --scope promptkit --config fixtures/before.yml --against-config fixtures/after.yml --campaign spring)
|
||||||
(cd integrations/ckprompt/examples/composed && go run . promptkit list prompts --config fixtures/before.yml --campaign spring)
|
(cd integrations/ckprompt/examples/composed && GOTOOLCHAIN=local go run . promptkit list prompts --config fixtures/before.yml --campaign spring)
|
||||||
(cd integrations/ckprompt/examples/composed && go run . promptkit list profiles --config fixtures/after.yml --campaign autumn --format yaml)
|
(cd integrations/ckprompt/examples/composed && GOTOOLCHAIN=local go run . promptkit list profiles --config fixtures/after.yml --campaign autumn --format yaml)
|
||||||
(cd integrations/ckprompt/examples/composed && go run . promptkit list backends --namespace team)
|
(cd integrations/ckprompt/examples/composed && GOTOOLCHAIN=local go run . promptkit list backends --namespace team)
|
||||||
(cd integrations/ckprompt/examples/composed && go run . promptkit list namespaces --kind prompt --kind profile)
|
(cd integrations/ckprompt/examples/composed && GOTOOLCHAIN=local go run . promptkit list namespaces --kind prompt --kind profile)
|
||||||
(cd integrations/ckprompt/examples/composed && go run . promptkit list collections)
|
(cd integrations/ckprompt/examples/composed && GOTOOLCHAIN=local go run . promptkit list collections)
|
||||||
(cd integrations/ckprompt/examples/composed && go run . promptkit show prompt briefing --campaign spring --format yaml)
|
(cd integrations/ckprompt/examples/composed && GOTOOLCHAIN=local go run . promptkit show prompt briefing --campaign spring --format yaml)
|
||||||
(cd integrations/ckprompt/examples/composed && go run . promptkit requirements show briefing --campaign spring)
|
(cd integrations/ckprompt/examples/composed && GOTOOLCHAIN=local go run . promptkit show prepared briefing --campaign spring --input topic=sprint --profile spring --extra-params '{"campaign":"preview"}')
|
||||||
(cd integrations/ckprompt/examples/composed && go run . promptkit requirements show briefing --campaign spring --var mode=full --format yaml)
|
(cd integrations/ckprompt/examples/composed && GOTOOLCHAIN=local go run . promptkit requirements show briefing --campaign spring)
|
||||||
(cd integrations/ckprompt/examples/composed && go run . promptkit requirements validate briefing --campaign spring --input topic=fixtures/topic.txt)
|
(cd integrations/ckprompt/examples/composed && GOTOOLCHAIN=local go run . promptkit requirements show briefing --campaign spring --var mode=full --format yaml)
|
||||||
(cd integrations/ckprompt/examples/composed && go run . promptkit requirements validate briefing --campaign spring --input topic=fixtures/topic.txt --var mode=full --input notes=fixtures/notes.txt --format yaml)
|
(cd integrations/ckprompt/examples/composed && GOTOOLCHAIN=local go run . promptkit requirements validate briefing --campaign spring --input-file topic=fixtures/topic.txt)
|
||||||
(cd integrations/ckprompt/examples/composed && go run . promptkit test reachability --campaign spring --prompt briefing --profile spring)
|
(cd integrations/ckprompt/examples/composed && GOTOOLCHAIN=local go run . promptkit requirements validate briefing --campaign spring --input-file topic=fixtures/topic.txt --var mode=full --input-file notes=fixtures/notes.txt --format yaml)
|
||||||
(cd integrations/ckprompt/examples/composed && go run . promptkit test all --campaign spring --prompt briefing --profile spring)
|
(cd integrations/ckprompt/examples/composed && GOTOOLCHAIN=local go run . promptkit test reachability --campaign spring --prompt briefing --profile spring)
|
||||||
(cd integrations/ckprompt/examples/composed && go run . session restore session-42 --campaign spring)
|
(cd integrations/ckprompt/examples/composed && GOTOOLCHAIN=local go run . promptkit test all --campaign spring --prompt briefing --profile spring)
|
||||||
|
(cd integrations/ckprompt/examples/composed && GOTOOLCHAIN=local go run . session restore session-42 --campaign spring)
|
||||||
```
|
```
|
||||||
|
|
||||||
The [consumer guide](consumers/guide.md) explains how their public API usage
|
The [consumer guide](consumers/guide.md) explains how their public API usage
|
||||||
fits together.
|
fits together.
|
||||||
|
|
||||||
|
|
||||||
|
## Shell completion validation
|
||||||
|
|
||||||
|
The opt-in interactive completion checks require Bash, Zsh, Fish, Python with
|
||||||
|
`pexpect`, and the Bash completion package. The harness starts a clean Bash
|
||||||
|
and sources `/usr/share/bash-completion/bash_completion` by default. Set
|
||||||
|
`COMPLETION_BASH_INIT` to your installation's initialization file if it is
|
||||||
|
elsewhere. See the [operator setup](cli.md#shell-completion) for ordinary use.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
COMMANDKIT_COMPLETION_SHELLS=1 go test -count=1 -run '^TestCompletionShells$' ./internal/cobra
|
||||||
|
COMMANDKIT_COMPLETION_SHELLS=1 go test -count=1 -run '^TestCompletionConsumerInShells$' .
|
||||||
|
```
|
||||||
|
|||||||
@@ -6,20 +6,61 @@ own the optional ConfigKit and PromptKit integrations. The
|
|||||||
[architecture policy](../policy/architecture.md) owns normative boundaries;
|
[architecture policy](../policy/architecture.md) owns normative boundaries;
|
||||||
this page inventories the implemented owners.
|
this page inventories the implemented owners.
|
||||||
|
|
||||||
|
The core, ckconfig, and ckprompt modules declare
|
||||||
|
`go.maximumdirect.net/commandkit`, `go.maximumdirect.net/ckconfig`, and
|
||||||
|
`go.maximumdirect.net/ckprompt`, respectively.
|
||||||
|
|
||||||
## Root `commandkit`
|
## Root `commandkit`
|
||||||
|
|
||||||
The root facade owns captured command declarations, binding conversion,
|
The root facade owns captured canonical command declarations, application-owned
|
||||||
invocation coordination, the per-App registry, standard help/version commands,
|
routing rules, binding conversion, invocation coordination, the per-App
|
||||||
and outcome/diagnostic delivery. Its dependency graph has no ConfigKit or
|
registries, standard help/version commands, shared report rendering, invocation reporting, and outcome
|
||||||
PromptKit integration dependency. Applications provide typed callbacks and
|
finalization.
|
||||||
choose when to install declarations.
|
Its dependency graph has no ConfigKit or PromptKit integration dependency.
|
||||||
|
Applications provide typed callbacks and install routing rules alongside the
|
||||||
|
complete canonical command tree through the same batch API.
|
||||||
|
|
||||||
The shared command compiler prepares supplied values before constructing input.
|
Canonical commands own handlers and parsing/help metadata; routing rules resolve
|
||||||
The batch installer validates retained issues, placement, and collisions before
|
entered command paths and flag spellings to those canonical records without
|
||||||
mutating the registry. Each invocation uses one routing snapshot and backend
|
adding command nodes or handlers. The shared command compiler prepares supplied
|
||||||
session; a successful registration invalidates the snapshot for a later
|
values before constructing input. The batch installer validates retained
|
||||||
invocation. The root package owns app input and callback execution, not provider
|
issues, placement, and collisions across both registries before publishing
|
||||||
configuration semantics.
|
either. Each invocation uses one routing snapshot and backend session; a
|
||||||
|
successful registration invalidates the snapshot for a later invocation. The
|
||||||
|
root package owns app input and callback execution, not provider configuration
|
||||||
|
semantics.
|
||||||
|
|
||||||
|
Each execution creates one reporter backed by the caller's result and diagnostic
|
||||||
|
writers. The Cobra adapter returns parsed values, flag spellings, and reporting
|
||||||
|
flag occurrences. Core reduces those occurrences in entered order after route
|
||||||
|
resolution, applies the final mode and progress selection, and presents any
|
||||||
|
collected parser events through that selection. The selected mode therefore
|
||||||
|
applies to command execution, help, and version; routing and parsing failures
|
||||||
|
remain visible in every mode.
|
||||||
|
|
||||||
|
The reporter owns invocation-scoped visibility, message formatting, managed
|
||||||
|
report rendering, delivery observations, and finalization. It presents queued
|
||||||
|
messages after the handler returns, then a failed outcome's explanation, and
|
||||||
|
closes its handles before `Execute` returns. It records rendering and write
|
||||||
|
errors separately for result and diagnostic destinations; the returned
|
||||||
|
`Outcome.Presentation` exposes those observations independently from the
|
||||||
|
operation cause. The reporter does not retain a transcript and does not close
|
||||||
|
or flush caller-owned writers.
|
||||||
|
|
||||||
|
Internally, one reporting-state mutex guards visibility, queued messages,
|
||||||
|
destination state, and delivery errors while serializing reporter operations
|
||||||
|
and writes. Rendering callbacks execute within that coordination boundary, so
|
||||||
|
they must not recursively submit or render through the same reporter. This is
|
||||||
|
an implementation detail supporting the callback contract; the mutex does not
|
||||||
|
coordinate writes made outside the reporter.
|
||||||
|
|
||||||
|
## `internal/alias` and `internal/commandpath`
|
||||||
|
|
||||||
|
`internal/commandpath` provides shared path parsing and token operations used by
|
||||||
|
declaration capture, routing, and backend selection. `internal/alias` compiles
|
||||||
|
application routing rules, resolves command prefixes while retaining token
|
||||||
|
provenance, and validates command-route termination. Neither package owns
|
||||||
|
handlers, application factories, or backend types.
|
||||||
|
|
||||||
## `internal/commandmeta`
|
## `internal/commandmeta`
|
||||||
|
|
||||||
@@ -49,8 +90,9 @@ acquisitions or comparison callback.
|
|||||||
`integrations/ckprompt` owns PromptKit engine loading, configuration-scope
|
`integrations/ckprompt` owns PromptKit engine loading, configuration-scope
|
||||||
adaptation and native comparison, plus optional prompt/profile/backend/namespace/
|
adaptation and native comparison, plus optional prompt/profile/backend/namespace/
|
||||||
collection catalogs, named inspection, native requirements discovery,
|
collection catalogs, named inspection, native requirements discovery,
|
||||||
concrete request validation, and native smoke testing. Validation and smoke
|
concrete request validation, prepared-request preview, and native smoke testing.
|
||||||
testing delegate to PromptKit and are independently opted into a suite. Smoke
|
Validation, preview, and smoke testing delegate to PromptKit and are independently
|
||||||
|
opted into a suite. Smoke
|
||||||
operations use the application's native engine and its configured client or
|
operations use the application's native engine and its configured client or
|
||||||
probes. The integration uses public core and ckconfig contracts plus PromptKit's
|
probes. The integration uses public core and ckconfig contracts plus PromptKit's
|
||||||
public APIs. It does not add a configuration family when its declaration is
|
public APIs. It does not add a configuration family when its declaration is
|
||||||
|
|||||||
@@ -119,8 +119,9 @@ Material: `docs/policy/meta.md`, `docs/policy/agents.md`,
|
|||||||
`docs/policy/documentation.md`, `docs/policy/release.md`, and
|
`docs/policy/documentation.md`, `docs/policy/release.md`, and
|
||||||
`docs/policy/oss.md`; the root `AGENTS.md` orientation after the knowledge-graph
|
`docs/policy/oss.md`; the root `AGENTS.md` orientation after the knowledge-graph
|
||||||
instructions; and the Policy Reading Guide in `docs/development.md`, adapted for
|
instructions; and the Policy Reading Guide in `docs/development.md`, adapted for
|
||||||
this repository. The release policy uses baseline
|
this repository. The documentation policy uses baseline
|
||||||
`e833faddf120c789a9b45348edaf75720de16348`; the remaining material uses baseline
|
`beb5f191470a9daef2383981a6bfd02232490ab4`; the release policy uses
|
||||||
|
`e833faddf120c789a9b45348edaf75720de16348`; the remaining material uses
|
||||||
`d84a38ebab26f048cafbe59a2246700c35d29843`.
|
`d84a38ebab26f048cafbe59a2246700c35d29843`.
|
||||||
|
|
||||||
Copyright (C) 2026 Eric Rakestraw
|
Copyright (C) 2026 Eric Rakestraw
|
||||||
@@ -131,24 +132,28 @@ License and notices: [CC BY-SA 4.0](licenses/CC-BY-SA-4.0.txt) |
|
|||||||
[COPYRIGHT](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843/COPYRIGHT) |
|
[COPYRIGHT](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843/COPYRIGHT) |
|
||||||
[LICENSE](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843/LICENSE) |
|
[LICENSE](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843/LICENSE) |
|
||||||
[COPYRIGHT](https://gitea.maximumdirect.net/eric/policy/src/commit/e833faddf120c789a9b45348edaf75720de16348/COPYRIGHT) |
|
[COPYRIGHT](https://gitea.maximumdirect.net/eric/policy/src/commit/e833faddf120c789a9b45348edaf75720de16348/COPYRIGHT) |
|
||||||
[LICENSE](https://gitea.maximumdirect.net/eric/policy/src/commit/e833faddf120c789a9b45348edaf75720de16348/LICENSE).
|
[LICENSE](https://gitea.maximumdirect.net/eric/policy/src/commit/e833faddf120c789a9b45348edaf75720de16348/LICENSE) |
|
||||||
|
[COPYRIGHT](https://gitea.maximumdirect.net/eric/policy/src/commit/beb5f191470a9daef2383981a6bfd02232490ab4/COPYRIGHT) |
|
||||||
|
[LICENSE](https://gitea.maximumdirect.net/eric/policy/src/commit/beb5f191470a9daef2383981a6bfd02232490ab4/LICENSE).
|
||||||
|
|
||||||
[Source](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843).
|
[Source](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843).
|
||||||
|
|
||||||
[Source](https://gitea.maximumdirect.net/eric/policy/src/commit/e833faddf120c789a9b45348edaf75720de16348).
|
[Source](https://gitea.maximumdirect.net/eric/policy/src/commit/e833faddf120c789a9b45348edaf75720de16348).
|
||||||
|
|
||||||
|
[Source](https://gitea.maximumdirect.net/eric/policy/src/commit/beb5f191470a9daef2383981a6bfd02232490ab4).
|
||||||
|
|
||||||
## ReleaseKit
|
## ReleaseKit
|
||||||
|
|
||||||
Selected module: `gitea.maximumdirect.net/eric/ReleaseKit` at `v0.4.2` (selected Go tool; not a runtime import).
|
Selected module: `go.maximumdirect.net/releasekit` at `v0.9.0` (selected Go tool; not a runtime import).
|
||||||
|
|
||||||
Copyright (C) 2026 Eric Rakestraw
|
Copyright (C) 2026 Eric Rakestraw
|
||||||
|
|
||||||
SPDX-License-Identifier: LGPL-3.0-or-later
|
SPDX-License-Identifier: LGPL-3.0-or-later
|
||||||
|
|
||||||
License and notices: [LGPL v3.0](../../LICENSE) |
|
License and notices: [LGPL v3.0](../../LICENSE) |
|
||||||
[LICENSE](https://gitea.maximumdirect.net/eric/ReleaseKit/src/commit/7d033c5ca1c95aec7db9690293638a0a1ac887b8/LICENSE).
|
[LICENSE](https://forge.maximumdirect.net/base-layer/releasekit/src/commit/2180aa868ec2caa95fc8279fd59d5f0ebac41bd4/LICENSE).
|
||||||
|
|
||||||
[Source](https://gitea.maximumdirect.net/eric/ReleaseKit/src/commit/7d033c5ca1c95aec7db9690293638a0a1ac887b8).
|
[Source](https://forge.maximumdirect.net/base-layer/releasekit/src/commit/2180aa868ec2caa95fc8279fd59d5f0ebac41bd4).
|
||||||
|
|
||||||
## Goldmark
|
## Goldmark
|
||||||
|
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ invariant, not from the convenience of the selected implementation. Explain the
|
|||||||
specific failure it prevents and why a less restrictive implementation cannot
|
specific failure it prevents and why a less restrictive implementation cannot
|
||||||
satisfy the contract.
|
satisfy the contract.
|
||||||
|
|
||||||
The [deferred-work record](../roadmap/deferred.md) distinguishes future proposals
|
The [future work record](../roadmap/future.md) distinguishes future proposals
|
||||||
from accepted contracts. Do not approve proposed commands, grammar, statuses, or
|
from accepted contracts. Do not approve proposed commands, grammar, statuses, or
|
||||||
illustrative API declarations
|
illustrative API declarations
|
||||||
merely by repeating them in implementation or tests. Cobra/pflag defaults do not
|
merely by repeating them in implementation or tests. Cobra/pflag defaults do not
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
**Base Policy Version:** 1.3
|
**Base Policy Version:** 1.3
|
||||||
|
|
||||||
**Last Updated**: 2026-10-11
|
**Last Updated**: 2026-11-06
|
||||||
|
|
||||||
**Upstream Source:** [architecture.md](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843/docs/policy/architecture.md)
|
**Upstream Source:** [architecture.md](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843/docs/policy/architecture.md)
|
||||||
|
|
||||||
@@ -23,6 +23,22 @@ Exact exported contracts belong in Go declarations and GoDoc; implemented
|
|||||||
operator behavior belongs in the CLI reference. Do not describe planned packages
|
operator behavior belongs in the CLI reference. Do not describe planned packages
|
||||||
or APIs as existing implementation.
|
or APIs as existing implementation.
|
||||||
|
|
||||||
|
## Ownership Boundaries
|
||||||
|
|
||||||
|
Applications and integration handlers choose the operation and report content;
|
||||||
|
CommandKit core supplies the shared CLI mechanics. Integrated libraries retain
|
||||||
|
ownership of their domain behavior. These boundaries govern current and planned
|
||||||
|
capabilities; they do not imply that a planned API is implemented.
|
||||||
|
|
||||||
|
| Area | CommandKit Owns | CommandKit Does Not Own |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| **CLI output — core** | Format selection, serialization, template execution, and output delivery | Choosing report content or detail level; authoring application-specific text templates |
|
||||||
|
| **PromptKit/ConfigKit integrations** | Standard commands, flags, and report content that expose useful operator tasks through upstream public APIs | Helpers or convenience interfaces for application-internal use of upstream APIs |
|
||||||
|
| **CLI inputs** | CLI syntax, parsing, typed bindings, presence tracking, and usage diagnostics | Application business rules or upstream domain-validation semantics |
|
||||||
|
| **Upstream behavior** | Selecting and invoking supported capabilities, adapting their results, and preserving their meaning | Reimplementing upstream configuration resolution, prompt preparation, validation, or other domain algorithms |
|
||||||
|
| **Outcomes and errors — core** | Carrying and presenting selected outcomes; handling CommandKit's own parsing, rendering, and delivery failures | Deciding application recovery policy or terminating the process |
|
||||||
|
| **Application lifecycle** | Command dispatch and invocation reporting lifecycle | Application startup, engine construction, credential provisioning, signal handling, or long-lived application state |
|
||||||
|
|
||||||
## Success Criteria
|
## Success Criteria
|
||||||
|
|
||||||
Evaluate designs and adoption against both criteria:
|
Evaluate designs and adoption against both criteria:
|
||||||
@@ -42,12 +58,29 @@ Replacing parsing and dispatch plumbing with equally repetitive registration or
|
|||||||
callback bookkeeping does not satisfy infrastructure reduction. Keep enough
|
callback bookkeeping does not satisfy infrastructure reduction. Keep enough
|
||||||
flexibility to express genuine application requirements within one command model.
|
flexibility to express genuine application requirements within one command model.
|
||||||
|
|
||||||
|
## Go Types And Facilities
|
||||||
|
|
||||||
|
CommandKit is a Go library for Go applications. Its APIs and implementation use
|
||||||
|
ordinary Go types, language features, and standard-library facilities. Their
|
||||||
|
documented semantics and representational limits are accepted design constraints.
|
||||||
|
CommandKit does not introduce substitute type systems or compatibility machinery
|
||||||
|
merely to circumvent those limits.
|
||||||
|
|
||||||
|
Ordinary parsing, application-defined domain types, and necessary serialization
|
||||||
|
adapters remain appropriate. Preserve values that the selected Go types can
|
||||||
|
represent; accepting their limits does not justify accidental rounding or
|
||||||
|
truncation during conversion. Keep CLI boundary conversions small and focused,
|
||||||
|
without turning them into general-purpose type-conversion frameworks.
|
||||||
|
|
||||||
|
Specific numeric parsing and report serialization contracts remain separate.
|
||||||
|
A CLI parser's selected numeric types do not establish a blanket restriction on
|
||||||
|
application-supplied values or silently redefine their serialization contracts.
|
||||||
|
GoDoc and CLI documentation own implemented behavior; roadmaps own pending work.
|
||||||
|
|
||||||
## Command And Application Ownership
|
## Command And Application Ownership
|
||||||
|
|
||||||
[ADR 0001](../adr/0001-command-dispatch-and-inputs.md) records the selected
|
[ADR 0001](../adr/0001-command-dispatch-and-inputs.md) records the selected
|
||||||
CommandKit-owned dispatcher, per-instance registry, and typed-input model.
|
dispatcher, captured declarations, installation boundary, and typed-input model.
|
||||||
[ADR 0011](../adr/0011-captured-declarations-and-installation.md) refines declaration
|
|
||||||
capture, installation, optional-family ownership, and invocation input lifetimes.
|
|
||||||
|
|
||||||
CommandKit owns dispatch, common parsing, presence tracking, help, shared syntax
|
CommandKit owns dispatch, common parsing, presence tracking, help, shared syntax
|
||||||
diagnostics, and the handlers and CLI contracts of its standard families.
|
diagnostics, and the handlers and CLI contracts of its standard families.
|
||||||
@@ -68,33 +101,33 @@ Applications own their executable entry points, context creation, signals,
|
|||||||
stream selection, runtime construction, and final process exit. CommandKit
|
stream selection, runtime construction, and final process exit. CommandKit
|
||||||
returns invocation outcomes and must not terminate the process. Its common
|
returns invocation outcomes and must not terminate the process. Its common
|
||||||
mechanics apply to custom commands without taking ownership of their domain
|
mechanics apply to custom commands without taking ownership of their domain
|
||||||
semantics or success-report formats.
|
semantics or report content.
|
||||||
|
|
||||||
CommandKit's grammar applies to commands using its dispatcher and binding APIs.
|
CommandKit's grammar applies to commands using its dispatcher and binding APIs.
|
||||||
Its documentation recommends the same grammar and vocabulary for application-owned
|
Its documentation recommends the same grammar and vocabulary for application-owned
|
||||||
commands, including those outside its dispatcher, under
|
commands, including those outside its dispatcher. The
|
||||||
[ADR 0006](../adr/0006-command-grammar.md).
|
[CLI reference](../cli.md#registered-command-syntax) owns implemented grammar.
|
||||||
|
|
||||||
CommandKit preserves application-supplied outcome classifications and supported
|
|
||||||
error identity. It assigns responsibility for its diagnostics, avoids duplicate
|
|
||||||
presentation through its own paths, and reports write failures. Caller callbacks
|
|
||||||
retain control of their own output. [ADR 0005](../adr/0005-invocation-outcomes.md)
|
|
||||||
describes the outcome and delivery model.
|
|
||||||
|
|
||||||
Use explicit instance construction and composable command registration, not
|
Use explicit instance construction and composable command registration, not
|
||||||
package-global or import-time registries. Applications and integrations supply
|
package-global or import-time registries. Applications and integrations supply
|
||||||
the same captured command declarations through the core installation API.
|
captured canonical command declarations through the core installation API.
|
||||||
Declaration factories capture metadata, bindings, and callback references eagerly;
|
Declaration factories capture metadata, bindings, and callback references eagerly;
|
||||||
construction errors remain attached for installation to report. Capture the
|
construction errors remain attached for installation to report. Capture the
|
||||||
metadata collections needed to keep later edits to a source declaration from
|
metadata collections needed to keep later edits to a source declaration from
|
||||||
changing a returned command. Application callback objects retain their ownership.
|
changing a returned command. Application callback objects retain their ownership.
|
||||||
One definition supplies routing, parsing, and help metadata.
|
Canonical command declarations own handlers, parsing metadata, and help
|
||||||
|
metadata. Applications may provide separate routing rules in the same
|
||||||
|
installation batch; these rules resolve invocations to canonical commands and
|
||||||
|
do not add command nodes, handlers, or flags.
|
||||||
|
|
||||||
Validate each installation batch before mutating the instance registry. A failed
|
Validate each installation batch before mutating the instance registry. A failed
|
||||||
construction yields no usable application; a failed later registration leaves the
|
construction yields no usable application; a failed later registration leaves the
|
||||||
existing registry intact. Separate successful registration calls are not one
|
existing registry intact. Separate successful registration calls are not one
|
||||||
transaction. Duplicate paths and attempts to replace reserved commands report
|
transaction. Duplicate canonical paths and attempts to replace reserved commands
|
||||||
errors rather than letting registration order select a winner.
|
report errors rather than letting registration order select a winner. Identical
|
||||||
|
routing rules are idempotent; competing targets for the same source and scope
|
||||||
|
report an error. Command-scoped flag routing refers to an exact canonical
|
||||||
|
command path.
|
||||||
|
|
||||||
Explicit absent-command declarations contribute no commands, help entries, or
|
Explicit absent-command declarations contribute no commands, help entries, or
|
||||||
namespace claims; attached errors are reported before skipping absence. An invalid
|
namespace claims; attached errors are reported before skipping absence. An invalid
|
||||||
@@ -121,6 +154,48 @@ inspection/execution loading and focused acquisition callbacks are recommended
|
|||||||
integration practices. Independent consumer use of other CLI frameworks remains
|
integration practices. Independent consumer use of other CLI frameworks remains
|
||||||
application-owned.
|
application-owned.
|
||||||
|
|
||||||
|
## Invocation Reporting And Outcomes
|
||||||
|
|
||||||
|
Use one reporting facility per invocation for command results, immediate messages,
|
||||||
|
and deferred messages. Core owns shared visibility, formatting, delivery, and
|
||||||
|
finalization. Applications and integrations choose their report content and
|
||||||
|
messages; callers choose the destinations.
|
||||||
|
[ADR 0006](../adr/0006-invocation-reporting.md) records this reporting and outcome
|
||||||
|
ownership model. Its accepted structured-report extension remains planned in the
|
||||||
|
[adoption roadmap](../roadmap/promptkit-v0.20.0.md#shared-core-report-output-prerequisite).
|
||||||
|
|
||||||
|
For that shared report handoff, core owns format selection, serialization,
|
||||||
|
template execution, and delivery. Applications and integrations select logical
|
||||||
|
report content and detail level, and author any report-specific text templates.
|
||||||
|
Changing format must not implicitly change that content or detail level.
|
||||||
|
Representations and encoders are core implementation choices; commands should
|
||||||
|
not maintain parallel rendering pipelines or a separate report tree per format.
|
||||||
|
|
||||||
|
Use ordinary Go report values and thin projections where upstream data needs
|
||||||
|
adaptation. Prefer standard serialization and template facilities to a new public
|
||||||
|
document model, tag convention, or template language. Shared rendering serves
|
||||||
|
both invocation reporting and direct writer use, while invocation delivery keeps
|
||||||
|
the existing visibility, outcome, and presentation-failure lifecycle. Exact
|
||||||
|
serialization conventions, format options, and API signatures belong in the
|
||||||
|
roadmap until implemented, then in their API and CLI contracts.
|
||||||
|
|
||||||
|
Applications decide how to respond to domain-specific errors. CommandKit may
|
||||||
|
provide shared helpers for carrying, inspecting, and presenting errors while
|
||||||
|
leaving those decisions with the application. Supplying an error cause alone
|
||||||
|
does not select an outcome or generate an operator-facing message. CommandKit
|
||||||
|
handles its own errors, including routing, parsing, and presentation failures.
|
||||||
|
|
||||||
|
Preserve application-defined domain classifications and supplied error identity.
|
||||||
|
Report rendering and delivery failures separately from the command's explanation
|
||||||
|
and cause, applying the [CLI reporting contract](../cli.md#presentation-failures).
|
||||||
|
Intentionally suppressed output is not a delivery failure. Present terminal
|
||||||
|
failure explanations after normal reporting finishes, avoid duplicate presentation
|
||||||
|
through core's own paths, and retain the explanation and any supplied cause in
|
||||||
|
the returned outcome.
|
||||||
|
|
||||||
|
Keep reporting state scoped to the invocation's presentation and returned outcome.
|
||||||
|
Logging and message history remain application concerns.
|
||||||
|
|
||||||
## Handler Inputs And Invocation State
|
## Handler Inputs And Invocation State
|
||||||
|
|
||||||
Handlers receive typed input structs with explicit flag and operand bindings.
|
Handlers receive typed input structs with explicit flag and operand bindings.
|
||||||
@@ -138,12 +213,14 @@ Factories need not run after a failure and remain uninvoked during construction,
|
|||||||
installation, help, version, and scope discovery.
|
installation, help, version, and scope discovery.
|
||||||
|
|
||||||
For two-selection configuration comparison, prepare and check each side's input
|
For two-selection configuration comparison, prepare and check each side's input
|
||||||
separately with its scope identity and selection, applying the same applicable
|
separately with its scope identity and selection, applying only the selectors
|
||||||
converted selector values to both. Finish both checks before initializing shared
|
supplied for that side. Finish both checks before initializing shared
|
||||||
state once and invoking the integration-owned pair provider under
|
state once and invoking the integration-owned pair provider. The
|
||||||
[ADR 0012](../adr/0012-typed-configuration-comparison-pairs.md). Required provider
|
[consumer guide](../consumers/guide.md#configkit-scopes) and linked GoDoc own the
|
||||||
wiring follows declared capabilities; a comparison-only scope does not require
|
provider lifecycle; the [CLI reference](../cli.md#compare-configuration-selections)
|
||||||
an unrelated ordinary acquisition callback.
|
owns current selector behavior. Required provider wiring follows declared
|
||||||
|
capabilities; a comparison-only scope does not require an unrelated ordinary
|
||||||
|
acquisition callback.
|
||||||
|
|
||||||
Omitted input and state factories supply the respective type's zero value,
|
Omitted input and state factories supply the respective type's zero value,
|
||||||
including nil for pointers. Caller factories and callbacks retain responsibility
|
including nil for pointers. Caller factories and callbacks retain responsibility
|
||||||
@@ -163,8 +240,8 @@ composition does not create a second parser or a raw-argument handler model.
|
|||||||
|
|
||||||
CommandKit keeps parsed values, collections, and presence from one invocation out
|
CommandKit keeps parsed values, collections, and presence from one invocation out
|
||||||
of subsequent runs. Storage allocation and safe reuse are implementation choices;
|
of subsequent runs. Storage allocation and safe reuse are implementation choices;
|
||||||
rebuilding backend state is the initial strategy under
|
the [internal overview](../internal/overview.md#internalcobra) describes the
|
||||||
[ADR 0002](../adr/0002-cobra-backend.md). Caller-owned closures and runtime state
|
current backend lifecycle. Caller-owned closures and runtime state
|
||||||
retain their own lifecycle. Concurrent dispatch is a separate contract.
|
retain their own lifecycle. Concurrent dispatch is a separate contract.
|
||||||
|
|
||||||
Applications may interpret parsed domain values and provide domain value parsers
|
Applications may interpret parsed domain values and provide domain value parsers
|
||||||
@@ -173,16 +250,18 @@ Keep input declarations available without application initialization.
|
|||||||
|
|
||||||
## Public Facade And Dependency Direction
|
## Public Facade And Dependency Direction
|
||||||
|
|
||||||
[ADR 0010](../adr/0010-optional-integration-modules.md) scopes the root
|
[ADR 0004](../adr/0004-optional-integration-modules.md) selects separate optional
|
||||||
`commandkit` facade to the core module and selects separate optional integration
|
integration modules with their own public package contracts. The facade and
|
||||||
modules with their own public package contracts. It partially supersedes
|
backend isolation principles in
|
||||||
[ADR 0003](../adr/0003-public-facade.md), whose remaining facade and internal
|
[ADR 0002](../adr/0002-public-facade-and-cli-backend.md) apply within those module
|
||||||
adapter principles apply to the core and integrations alike.
|
boundaries.
|
||||||
|
|
||||||
The core owns command declarations, registration, bindings, dispatch, help/version,
|
The core owns command declarations, registration, bindings, dispatch, help/version,
|
||||||
shared syntax, diagnostics, and outcomes. Each integration owns its standard
|
shared syntax, reporting mechanics, diagnostics, and outcomes. Each integration
|
||||||
handlers, provider contracts, reports, adapters, and integration-specific public
|
owns its standard handlers, provider contracts, report content, adapters, and
|
||||||
types. Dependencies point from integrations toward the core; an integration may
|
integration-specific public types. Integrations expose operator-facing CLI tasks;
|
||||||
|
they do not provide convenience interfaces for application-internal upstream APIs.
|
||||||
|
Dependencies point from integrations toward the core; an integration may
|
||||||
depend on another integration when its responsibility requires that relationship.
|
depend on another integration when its responsibility requires that relationship.
|
||||||
Document those dependencies. Core public signatures, implementation imports, and
|
Document those dependencies. Core public signatures, implementation imports, and
|
||||||
module requirements remain independent of optional integration libraries,
|
module requirements remain independent of optional integration libraries,
|
||||||
@@ -209,19 +288,19 @@ transforming arbitrary application objects.
|
|||||||
Standard contracts such as `context.Context` and `io.Writer` remain appropriate.
|
Standard contracts such as `context.Context` and `io.Writer` remain appropriate.
|
||||||
Upstream public types may be selected as deliberate dependencies in the
|
Upstream public types may be selected as deliberate dependencies in the
|
||||||
integrations that own them; that permission does not justify optional library
|
integrations that own them; that permission does not justify optional library
|
||||||
dependencies in the core. Core diagnostics use a CommandKit-owned severity type,
|
dependencies in the core. Core owns shared presentation categories,
|
||||||
and integrations convert upstream diagnostics while preserving selected outcome
|
and integrations adapt upstream diagnostics while preserving selected outcome
|
||||||
and error semantics. Application-owned input types remain application-owned.
|
and error semantics. Application-owned input types remain application-owned.
|
||||||
Expose narrow core composition contracts for demonstrated integration needs,
|
Expose narrow core composition contracts for demonstrated integration needs,
|
||||||
without exporting private assembly or backend structures.
|
without exporting private assembly or backend structures.
|
||||||
|
|
||||||
## Backend And Library Integration
|
## Backend And Library Integration
|
||||||
|
|
||||||
[ADR 0002](../adr/0002-cobra-backend.md) selects Cobra with pflag as the sole
|
[ADR 0002](../adr/0002-public-facade-and-cli-backend.md) selects Cobra with pflag
|
||||||
initial backend. Their types and operations stay inside an internal adapter that
|
as the sole initial backend. Their types and operations stay inside an internal
|
||||||
translates CommandKit-owned definitions into backend machinery. Registry logic,
|
adapter that translates CommandKit-owned definitions into backend machinery.
|
||||||
standard handlers, and shared internal representations remain independent of
|
Registry logic, standard handlers, and shared internal representations remain
|
||||||
backend-specific structures.
|
independent of backend-specific structures.
|
||||||
|
|
||||||
Reuse backend routing and parsing mechanisms, adapting them to selected
|
Reuse backend routing and parsing mechanisms, adapting them to selected
|
||||||
CommandKit semantics. Do not replicate Cobra's entire API through renamed
|
CommandKit semantics. Do not replicate Cobra's entire API through renamed
|
||||||
@@ -229,9 +308,9 @@ wrappers. Introduce narrow internal interfaces where actual interactions justify
|
|||||||
them; a public backend selection or plugin framework is not part of the design.
|
them; a public backend selection or plugin framework is not part of the design.
|
||||||
|
|
||||||
ConfigKit and PromptKit own configuration composition, provenance, inspection
|
ConfigKit and PromptKit own configuration composition, provenance, inspection
|
||||||
semantics, secret classification, and native rendering. Applications provide
|
semantics, secret classification, and their native inspection representations.
|
||||||
schemas, acquisition, domain resolution and validation, and construction of
|
Applications provide schemas, acquisition, domain resolution and validation,
|
||||||
inspection providers. CommandKit exposes those capabilities through supported
|
and construction of inspection providers. CommandKit exposes those capabilities through supported
|
||||||
upstream APIs. The public contract of the adopted library version governs their
|
upstream APIs. The public contract of the adopted library version governs their
|
||||||
meaning and disclosure rules. When CommandKit adopts an upstream revision,
|
meaning and disclosure rules. When CommandKit adopts an upstream revision,
|
||||||
changes to those semantics flow through its integration. Adaptation and
|
changes to those semantics flow through its integration. Adaptation and
|
||||||
@@ -251,15 +330,26 @@ packages or depend on adjacent checkouts.
|
|||||||
Use narrow integration contracts, reusing upstream public types where useful and
|
Use narrow integration contracts, reusing upstream public types where useful and
|
||||||
introducing CommandKit-owned abstractions where they simplify integration.
|
introducing CommandKit-owned abstractions where they simplify integration.
|
||||||
Concrete integration needs guide provider representations under
|
Concrete integration needs guide provider representations under
|
||||||
[ADR 0009](../adr/0009-configuration-scopes.md).
|
[ADR 0003](../adr/0003-configuration-scopes.md).
|
||||||
|
|
||||||
Native report writers and safe inspection data preserve owning-library semantics.
|
Use upstream safe inspection data to preserve owning-library meaning and
|
||||||
Loading all selected views before report output is a `config show` decision under
|
disclosure rules under [ADR 0004](../adr/0004-optional-integration-modules.md).
|
||||||
[ADR 0007](../adr/0007-native-yaml-reports.md); other inspection operations define
|
Do not reconstruct inspection reports from mutable runtime configuration or source
|
||||||
their own output timing. Semantic comparison exposes the adopted upstream
|
files when the upstream inspection API supplies captured, safe evidence. Explicit
|
||||||
comparison API, carrying its results and relevant metadata through the provider
|
adaptation into ordinary report values must preserve the selected evidence and
|
||||||
boundary. CommandKit owns report presentation under
|
report conversion failures rather than silently changing its meaning.
|
||||||
[ADR 0008](../adr/0008-semantic-configuration-diff.md).
|
|
||||||
|
CommandKit parses and forwards supplied CLI selections and overrides through the
|
||||||
|
declared acquisition boundary. Upstream libraries retain path validity,
|
||||||
|
resolution, precedence, conflict handling, and domain validation. Preserve their
|
||||||
|
errors instead of inventing a parallel override policy or retrying with altered
|
||||||
|
values. Application loading and application-owned overrides retain their own
|
||||||
|
responsibilities.
|
||||||
|
|
||||||
|
The [CLI reference](../cli.md#show-effective-configuration) owns current report
|
||||||
|
formats and operation-specific preparation and delivery timing. Semantic
|
||||||
|
comparison exposes the adopted upstream comparison API and carries its evidence
|
||||||
|
through the provider boundary; it does not introduce a CommandKit equality engine.
|
||||||
|
|
||||||
## Scope And Non-goals
|
## Scope And Non-goals
|
||||||
|
|
||||||
@@ -270,7 +360,7 @@ recommended for application-owned commands; their names and domain behavior
|
|||||||
remain application choices.
|
remain application choices.
|
||||||
|
|
||||||
Accepted ADRs and current CLI/API owners define selected contracts; the
|
Accepted ADRs and current CLI/API owners define selected contracts; the
|
||||||
[deferred-work record](../roadmap/deferred.md) preserves remaining proposals.
|
[future work record](../roadmap/future.md) preserves remaining proposals.
|
||||||
Backend defaults and incidental consumer behavior do not establish new contracts.
|
Backend defaults and incidental consumer behavior do not establish new contracts.
|
||||||
A replacement backend must preserve published CLI behavior or follow deliberate compatibility
|
A replacement backend must preserve published CLI behavior or follow deliberate compatibility
|
||||||
changes under the release policy.
|
changes under the release policy.
|
||||||
|
|||||||
@@ -2,9 +2,9 @@
|
|||||||
|
|
||||||
**Base Policy Version:** 1.11
|
**Base Policy Version:** 1.11
|
||||||
|
|
||||||
**Last Updated**: 2026-10-10
|
**Last Updated**: 2026-11-07
|
||||||
|
|
||||||
**Upstream Source:** [documentation.md](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843/docs/policy/documentation.md)
|
**Upstream Source:** [documentation.md](https://gitea.maximumdirect.net/eric/policy/src/commit/beb5f191470a9daef2383981a6bfd02232490ab4/docs/policy/documentation.md)
|
||||||
|
|
||||||
## Purpose
|
## Purpose
|
||||||
|
|
||||||
@@ -86,7 +86,7 @@ secret values.
|
|||||||
| Inventory of implemented internal packages | `docs/internal/overview.md` | Implemented packages and components, responsibilities, and links to focused internal docs. | Normative architecture, contributor reading policy, external contracts. |
|
| Inventory of implemented internal packages | `docs/internal/overview.md` | Implemented packages and components, responsibilities, and links to focused internal docs. | Normative architecture, contributor reading policy, external contracts. |
|
||||||
| Behavior and architecture of internal packages | Other files under `docs/internal/` | Implementation flow, collaborators, state transitions, package-local guarantees and failures, and relevant tests. | Global architecture invariants, configuration definitions and defaults, external schemas, operator procedures. |
|
| Behavior and architecture of internal packages | Other files under `docs/internal/` | Implementation flow, collaborators, state transitions, package-local guarantees and failures, and relevant tests. | Global architecture invariants, configuration definitions and defaults, external schemas, operator procedures. |
|
||||||
| Architectural decision records | `docs/adr/` | Significant decisions, context, alternatives, rationale, consequences, and supersession history. | Current behavior reference, implementation status, task sequencing. |
|
| Architectural decision records | `docs/adr/` | Significant decisions, context, alternatives, rationale, consequences, and supersession history. | Current behavior reference, implementation status, task sequencing. |
|
||||||
| Future work and implementation status | `docs/roadmap/` | Proposed, accepted, deferred, or rejected work; implementation status, sequencing, and task breakdowns. | Implemented behavior reference and architectural decision rationale. |
|
| Future work and implementation status | `docs/roadmap/` | Proposed, accepted, or deferred work; implementation status, sequencing, and task breakdowns. | Implemented behavior reference and architectural decision rationale. |
|
||||||
| Release-note history | `docs/releases/` | One checked-in summary per source release under the procedure; the note at its immutable tag is the release record. | Current commands, behavior, contracts, and compatibility definitions. |
|
| Release-note history | `docs/releases/` | One checked-in summary per source release under the procedure; the note at its immutable tag is the release record. | Current commands, behavior, contracts, and compatibility definitions. |
|
||||||
| Complete copyable artifacts | `examples/` | Maintained configuration, inputs, and other copyable or runnable files. | Field-by-field reference, command reference, prose explanation. |
|
| Complete copyable artifacts | `examples/` | Maintained configuration, inputs, and other copyable or runnable files. | Field-by-field reference, command reference, prose explanation. |
|
||||||
|
|
||||||
@@ -147,14 +147,16 @@ mechanisms only.
|
|||||||
|
|
||||||
## Architecture Decision Records
|
## Architecture Decision Records
|
||||||
|
|
||||||
ADRs record durable architectural decisions: responsibility boundaries,
|
ADRs record major architectural decisions, such as responsibility and ownership
|
||||||
ownership, significant constraints, and the reasons for choosing them. A settled
|
boundaries, large-scale architectural features, or significant architectural
|
||||||
implementation or presentation choice does not automatically belong in an ADR.
|
invariants, and the reasons for choosing them. Routine choices, implementation
|
||||||
Ask whether changing the detail would alter the architecture or represent
|
or presentation decisions, and rejected suggestions do not warrant an ADR
|
||||||
reasonable evolution within it. Details that can evolve within the chosen
|
unless they otherwise represent a significant architectural decision. Ask
|
||||||
architecture belong in API or CLI contracts, implementation documentation, or
|
whether changing the detail would alter the architecture or represent reasonable
|
||||||
roadmaps. Describe upstream behavior as context without turning it into an
|
evolution within it. Details that can evolve within the chosen architecture
|
||||||
independent project guarantee.
|
belong in API or CLI contracts, implementation documentation, or roadmaps.
|
||||||
|
Describe upstream behavior as context without turning it into an independent
|
||||||
|
project guarantee.
|
||||||
|
|
||||||
Use sequentially numbered ADR filenames such as
|
Use sequentially numbered ADR filenames such as
|
||||||
`0001-record-architecture-decisions.md`. Follow the lightweight Nygard format:
|
`0001-record-architecture-decisions.md`. Follow the lightweight Nygard format:
|
||||||
@@ -169,8 +171,8 @@ Use sequentially numbered ADR filenames such as
|
|||||||
|
|
||||||
Treat the decision content of an accepted ADR as immutable. When a decision
|
Treat the decision content of an accepted ADR as immutable. When a decision
|
||||||
changes, create a new ADR and update the earlier ADR's status to superseded.
|
changes, create a new ADR and update the earlier ADR's status to superseded.
|
||||||
Rejected architectural alternatives belong in the ADR; rejected product ideas
|
|
||||||
belong in the roadmap.
|
Roadmaps should preserve plausible future work, not rejected product ideas.
|
||||||
|
|
||||||
## Maintenance
|
## Maintenance
|
||||||
|
|
||||||
|
|||||||
@@ -25,9 +25,11 @@ additional release requirements.
|
|||||||
CommandKit independently versions three source-only Go modules: the root core,
|
CommandKit independently versions three source-only Go modules: the root core,
|
||||||
`integrations/ckconfig`, and `integrations/ckprompt`. Standard CLI behavior is
|
`integrations/ckconfig`, and `integrations/ckprompt`. Standard CLI behavior is
|
||||||
exposed by consuming applications; CommandKit does not declare binary
|
exposed by consuming applications; CommandKit does not declare binary
|
||||||
deliverables or a cross-platform build matrix. ReleaseKit v0.4.2 is selected as
|
deliverables or a cross-platform build matrix. ReleaseKit v0.9.0 is selected as
|
||||||
the maintainer publication tool. The [release procedure](../release.md) owns
|
the maintainer publication tool. Maintainers may separately deliver page-only
|
||||||
the selected-module workflow, release order, and repository-specific commands;
|
Gitea releases from tagged source; delivery has its own outcome and URL. The
|
||||||
|
[release procedure](../release.md) owns the selected-module workflow, release
|
||||||
|
order, and repository-specific commands;
|
||||||
the integration adds no application command family or runtime ReleaseKit
|
the integration adds no application command family or runtime ReleaseKit
|
||||||
import.
|
import.
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
**Base Policy Version:** 1.3
|
**Base Policy Version:** 1.3
|
||||||
|
|
||||||
**Last Updated**: 2026-10-17
|
**Last Updated**: 2026-11-07
|
||||||
|
|
||||||
**Upstream Source:** [testing.md](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843/docs/policy/testing.md)
|
**Upstream Source:** [testing.md](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843/docs/policy/testing.md)
|
||||||
|
|
||||||
@@ -63,16 +63,19 @@ implemented:
|
|||||||
- Help and command discovery without provider loading or runtime construction.
|
- Help and command discovery without provider loading or runtime construction.
|
||||||
- Provider failures, cancellation, output errors, and preservation of application
|
- Provider failures, cancellation, output errors, and preservation of application
|
||||||
outcomes and public error identity.
|
outcomes and public error identity.
|
||||||
- Selected inspection/report contracts, including native YAML and document order
|
- Selected inspection/report contracts, including safe scalar preservation and
|
||||||
for `config show --scope all`.
|
scope-keyed aggregation for `config show --scope all`.
|
||||||
- Public type ownership and the internal Cobra/pflag import boundary selected by
|
- Public type ownership and the internal Cobra/pflag import boundary selected by
|
||||||
[ADR 0003](../adr/0003-public-facade.md).
|
[ADR 0002](../adr/0002-public-facade-and-cli-backend.md).
|
||||||
|
|
||||||
Use external-package consumer tests for facade semantics and real backend
|
Use external-package consumer tests for facade semantics and real backend
|
||||||
integration where correctness depends on that interaction. Internal package
|
integration where correctness depends on that interaction. Internal package
|
||||||
tests own focused mechanisms.
|
tests own focused mechanisms.
|
||||||
Boundary checks protect selected architectural constraints; behavioral evidence
|
Boundary checks protect selected architectural constraints; behavioral evidence
|
||||||
protects invocation semantics and ownership through conversions.
|
protects invocation semantics and ownership through conversions.
|
||||||
|
For shared reports, compare logical values and selected evidence rather than
|
||||||
|
YAML formatting or mapping order. Preserve sequence ordering only where the
|
||||||
|
relevant contract gives it meaning.
|
||||||
|
|
||||||
Do not infer concurrent-dispatch support from fresh invocation state. Test
|
Do not infer concurrent-dispatch support from fresh invocation state. Test
|
||||||
concurrency guarantees only if deliberately selected. Coverage targets, race
|
concurrency guarantees only if deliberately selected. Coverage targets, race
|
||||||
|
|||||||
145
docs/release.md
145
docs/release.md
@@ -3,10 +3,11 @@
|
|||||||
## Scope and release order
|
## Scope and release order
|
||||||
|
|
||||||
CommandKit publishes source-only Go module releases. Each module has an
|
CommandKit publishes source-only Go module releases. Each module has an
|
||||||
independent version and immutable annotated tag. This procedure publishes no
|
independent version and immutable annotated tag. A successful source-tag push
|
||||||
hosted release page, binaries, checksums, or other delivery assets. A successful
|
completes the source release. A separate local delivery command can publish a
|
||||||
source-tag push is release completion; CI, module proxy availability, and
|
Gitea release page; there are no binaries,
|
||||||
consumer installation are not completion gates.
|
checksums, or other delivery assets. CI, module proxy availability, and consumer
|
||||||
|
installation are not completion gates.
|
||||||
|
|
||||||
Publish in dependency order: core, then `ckconfig`, then `ckprompt`. The
|
Publish in dependency order: core, then `ckconfig`, then `ckprompt`. The
|
||||||
integration modules require the preceding CommandKit releases, so prepare and
|
integration modules require the preceding CommandKit releases, so prepare and
|
||||||
@@ -15,37 +16,40 @@ release runs only the root module's default suite; it need not run suites for
|
|||||||
independently released integrations.
|
independently released integrations.
|
||||||
|
|
||||||
Use branch `main`, remote `origin`, and expected effective push endpoint
|
Use branch `main`, remote `origin`, and expected effective push endpoint
|
||||||
`https://gitea.maximumdirect.net/eric/CommandKit.git`. The selected module profile
|
`ssh://git@forge.maximumdirect.net:22222/base-layer/commandkit.git`. The selected
|
||||||
enforces the endpoint. The maintainer needs the selected
|
module profile enforces the endpoint. The maintainer needs the selected
|
||||||
[release toolchain](#release-toolchain), Git push access, and a POSIX shell.
|
[release toolchain](#release-toolchain), Git push access, and a POSIX shell.
|
||||||
This procedure follows the [release policy](policy/release.md); release-note
|
This procedure follows the [release policy](policy/release.md); release-note
|
||||||
footers follow the [OSS discoverability policy](policy/oss.md#discoverability).
|
footers follow the [OSS discoverability policy](policy/oss.md#discoverability).
|
||||||
|
|
||||||
ReleaseKit v0.4.2 owns candidate validation, publication guards, tag creation,
|
ReleaseKit v0.9.0 (`go.maximumdirect.net/releasekit`) owns candidate validation,
|
||||||
tag publication, and outcome reporting. Its pinned public contracts are the
|
publication guards, tag creation, tag publication, and outcome reporting.
|
||||||
[CLI reference](https://gitea.maximumdirect.net/eric/ReleaseKit/src/commit/7d033c5ca1c95aec7db9690293638a0a1ac887b8/docs/cli.md),
|
Its pinned public contracts are the
|
||||||
[profile reference](https://gitea.maximumdirect.net/eric/ReleaseKit/src/commit/7d033c5ca1c95aec7db9690293638a0a1ac887b8/docs/config.md),
|
[CLI reference](https://forge.maximumdirect.net/base-layer/releasekit/src/commit/2180aa868ec2caa95fc8279fd59d5f0ebac41bd4/docs/cli.md),
|
||||||
and [publication guide](https://gitea.maximumdirect.net/eric/ReleaseKit/src/commit/7d033c5ca1c95aec7db9690293638a0a1ac887b8/docs/consumers/publish.md).
|
[profile reference](https://forge.maximumdirect.net/base-layer/releasekit/src/commit/2180aa868ec2caa95fc8279fd59d5f0ebac41bd4/docs/config.md),
|
||||||
|
the [publication guide](https://forge.maximumdirect.net/base-layer/releasekit/src/commit/2180aa868ec2caa95fc8279fd59d5f0ebac41bd4/docs/consumers/publish.md),
|
||||||
|
and the [delivery guide](https://forge.maximumdirect.net/base-layer/releasekit/src/commit/2180aa868ec2caa95fc8279fd59d5f0ebac41bd4/docs/consumers/delivery.md).
|
||||||
The repository selects one default module suite per invocation. The launcher
|
The repository selects one default module suite per invocation. The launcher
|
||||||
bootstraps the native tool into ignored `.releasekit/` storage and delegates to
|
uses the root module’s registered tool with native host settings and read-only
|
||||||
its `publish` command.
|
module metadata.
|
||||||
|
|
||||||
## Release toolchain
|
## Release toolchain
|
||||||
|
|
||||||
The selected release toolchain is **Go 1.26.8**. Each module declares a consumer
|
The selected release toolchain is **Go 1.27.0**. Core declares a
|
||||||
minimum of **Go 1.26.0**. Maintainers use Go 1.26.8 for dependency preparation,
|
consumer minimum of **Go 1.26.0**; both integrations require **Go 1.27.0**. Maintainers
|
||||||
candidate validation, and the ReleaseKit bootstrap. The
|
use Go 1.27.0 for dependency preparation, candidate validation, and ReleaseKit
|
||||||
|
execution. The
|
||||||
[consumer guide](consumers/guide.md#go-compatibility) and
|
[consumer guide](consumers/guide.md#go-compatibility) and
|
||||||
[release policy](policy/release.md#consumer-minimum-and-release-toolchain)
|
[release policy](policy/release.md#consumer-minimum-and-release-toolchain)
|
||||||
describe these selections.
|
describe these selections.
|
||||||
|
|
||||||
Select Go 1.26.8 on `PATH`; the launcher uses `GOTOOLCHAIN=local`. If needed,
|
Select Go 1.27.0 on `PATH`; the launcher uses `GOTOOLCHAIN=local`. If needed,
|
||||||
install it with the standard Go tools:
|
install it with the standard Go tools:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
GOWORK=off GOTOOLCHAIN=local go install golang.org/dl/go1.26.8@latest
|
GOWORK=off GOTOOLCHAIN=local go install golang.org/dl/go1.27.0@latest
|
||||||
go1.26.8 download
|
go1.27.0 download
|
||||||
export PATH="$(go1.26.8 env GOROOT)/bin:$PATH"
|
export PATH="$(go1.27.0 env GOROOT)/bin:$PATH"
|
||||||
hash -r
|
hash -r
|
||||||
GOWORK=off GOTOOLCHAIN=local go version
|
GOWORK=off GOTOOLCHAIN=local go version
|
||||||
```
|
```
|
||||||
@@ -66,14 +70,16 @@ its note explains the impact and consumer action. Never reuse a published
|
|||||||
version. Each release note starts with a product title and exact version, gives
|
version. Each release note starts with a product title and exact version, gives
|
||||||
a proportionate summary and compatibility guidance, and ends with immutable
|
a proportionate summary and compatibility guidance, and ends with immutable
|
||||||
links to that module's local grant, license, and attribution paths. The full tag
|
links to that module's local grant, license, and attribution paths. The full tag
|
||||||
in each URL includes the integration directory where applicable. Future tag
|
in each URL includes the integration directory where applicable. For future
|
||||||
URLs need not resolve before publication.
|
notes, use `https://forge.maximumdirect.net/base-layer/commandkit/src/tag/FULL_TAG/`
|
||||||
|
followed by the repository-relative path: `COPYRIGHT`, `LICENSE`, and
|
||||||
|
`docs/oss/attribution.md` for core, or those paths prefixed with the selected
|
||||||
|
integration directory. Future tag URLs need not resolve before publication.
|
||||||
|
|
||||||
All three release profiles explicitly select the repository-root `COPYRIGHT`,
|
All three release profiles explicitly select the repository-root `COPYRIGHT`,
|
||||||
`LICENSE`, and `docs/oss/attribution.md` through `validation.shared_files`.
|
`LICENSE`, and `docs/oss/attribution.md` through `validation.shared_files`.
|
||||||
ReleaseKit requires these committed files to exist in the candidate even when
|
ReleaseKit requires these committed files to exist in the candidate. The list
|
||||||
no optional file checks are enabled. The list does not enable content checks
|
does not expand Go validation scope; see the pinned [profile reference](https://forge.maximumdirect.net/base-layer/releasekit/src/commit/2180aa868ec2caa95fc8279fd59d5f0ebac41bd4/docs/config.md).
|
||||||
or expand Go validation scope; see the pinned [profile reference](https://gitea.maximumdirect.net/eric/ReleaseKit/src/commit/b96262eb0ce9c8f2eb3e948a5841db2b799bc91b/docs/config.md).
|
|
||||||
|
|
||||||
Each integration also distributes its own `COPYRIGHT`, `LICENSE`, and
|
Each integration also distributes its own `COPYRIGHT`, `LICENSE`, and
|
||||||
`docs/oss/attribution.md`. These module-local materials are separate from the
|
`docs/oss/attribution.md`. These module-local materials are separate from the
|
||||||
@@ -82,10 +88,12 @@ Automatic module file inputs include applicable module-local files and stop at
|
|||||||
independent nested-module boundaries. Their inclusion does not itself enable
|
independent nested-module boundaries. Their inclusion does not itself enable
|
||||||
license or content validation.
|
license or content validation.
|
||||||
|
|
||||||
The current profiles enable no optional file checks or additional product
|
Each profile selects Go formatting, Markdown links, workspace-file and local
|
||||||
checks. Publication runs the selected module's standard offline suite and
|
replacement checks, and forbids the committed `.releasekit/gitea-token` path.
|
||||||
ReleaseKit's candidate/note and publication guards. The profiles select their
|
These upstream checks inspect committed inputs; module file selection stops at
|
||||||
physical module directory while using the same repository and endpoint.
|
independent module boundaries, while the forbidden path covers the repository.
|
||||||
|
They do not replace domain review. Publication also runs the selected module’s
|
||||||
|
standard offline suite and ReleaseKit’s candidate/note and publication guards.
|
||||||
|
|
||||||
## Prepare and publish one candidate
|
## Prepare and publish one candidate
|
||||||
|
|
||||||
@@ -99,39 +107,92 @@ future sums.
|
|||||||
|
|
||||||
Then commit the selected module's release note and all intended candidate
|
Then commit the selected module's release note and all intended candidate
|
||||||
changes on `main`, and publish the candidate commit to `origin`. Set one
|
changes on `main`, and publish the candidate commit to `origin`. Set one
|
||||||
selector, module directory, and version from the table above, then run:
|
module directory and version from the table above, then run the corresponding
|
||||||
|
publication command:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
module_dir=.
|
module_dir=.
|
||||||
module_selector=core
|
|
||||||
module_version=v0.2.0
|
|
||||||
(cd "$module_dir" && GOWORK=off GOTOOLCHAIN=local go mod download)
|
(cd "$module_dir" && GOWORK=off GOTOOLCHAIN=local go mod download)
|
||||||
# Commit reviewed dependency changes and the selected module's note on main.
|
# Commit reviewed dependency changes and the selected module's note on main.
|
||||||
git push origin HEAD:refs/heads/main
|
git push origin HEAD:refs/heads/main
|
||||||
./scripts/release "$module_selector" "$module_version"
|
./scripts/release core vX.Y.Z
|
||||||
|
# For integrations use: ./scripts/release ckconfig vX.Y.Z
|
||||||
|
# or: ./scripts/release ckprompt vX.Y.Z
|
||||||
```
|
```
|
||||||
|
|
||||||
Run only the command for the selected module. Its profile runs the complete
|
Run only the command for the selected module. Its profile runs the complete
|
||||||
offline deterministic default suite once for that module, checks candidate and
|
offline deterministic default suite once for that module, checks candidate and
|
||||||
remote guards, creates the annotated tag from the matching note, and pushes
|
remote guards, creates the annotated tag from the matching note, and pushes
|
||||||
only that full tag. `check` is available for diagnosis and is not a required
|
only that full tag. `./scripts/releasekit core check vX.Y.Z` is an optional
|
||||||
pre-publication command. The launcher does not push `main`; publish the
|
diagnostic, not a required pre-publication step: publication repeats validation.
|
||||||
|
Use the relevant integration selector and its version when diagnosing that
|
||||||
|
module. Inspect the registered tool with `./scripts/releasekit version`. The
|
||||||
|
launcher does not push `main`; publish the
|
||||||
candidate commit first. Successful source publication does not wait for CI,
|
candidate commit first. Successful source publication does not wait for CI,
|
||||||
proxy availability, or downstream installation.
|
proxy availability, or downstream installation.
|
||||||
|
|
||||||
## Completion and ambiguous publication
|
## Completion and ambiguous publication
|
||||||
|
|
||||||
A successful push to the expected endpoint completes the source release. On an
|
A successful push to the expected endpoint completes the source release.
|
||||||
ambiguous result, use the exact endpoint, full tag, and annotated tag object
|
ReleaseKit also attempts to save private publication evidence under the
|
||||||
reported by that invocation. Confirm endpoint resolution has not changed, then
|
repository's Git common directory, shared by linked worktrees. It reports the
|
||||||
query only that exact tag ref. Compare the remote annotated tag-object ID with
|
saved record path or a recording warning separately from publication status;
|
||||||
the reported object: an exact match confirms publication, a different object is
|
a storage failure does not change publication status. Records can contain
|
||||||
a conflict, successful output without the ref proves absence at that endpoint,
|
credential-bearing endpoints. Retain them privately while recovery is needed
|
||||||
|
and remove them when their recovery value expires; retention is maintainer-owned.
|
||||||
|
|
||||||
|
On an ambiguous result, use the recovery command printed for that invocation's
|
||||||
|
saved record. To discover records and explicitly select the matching attempt,
|
||||||
|
the shared helper can also be used:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
./scripts/releasekit publication-attempts
|
||||||
|
./scripts/releasekit reconcile --record /absolute/path/to/attempt.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace the example record path with the saved path for the intended attempt.
|
||||||
|
Relative record paths resolve from the repository root.
|
||||||
|
Discovery lists local evidence without querying remotes or selecting an attempt.
|
||||||
|
Reconciliation observes the recorded endpoint, full tag, and tag object without
|
||||||
|
rerunning candidate validation or changing tags. It distinguishes confirmed
|
||||||
|
publication, current absence, conflict, and an unresolved observation. Current
|
||||||
|
absence does not prove that a version was never published. The pinned
|
||||||
|
[CLI reference](https://forge.maximumdirect.net/base-layer/releasekit/src/commit/2180aa868ec2caa95fc8279fd59d5f0ebac41bd4/docs/cli.md#recover-a-saved-publication-attempt-or-discover-local-attempts)
|
||||||
|
owns recovery statuses and structured output; the
|
||||||
|
[publication guide](https://forge.maximumdirect.net/base-layer/releasekit/src/commit/2180aa868ec2caa95fc8279fd59d5f0ebac41bd4/docs/consumers/publish.md#retain-and-reconcile-publication-evidence)
|
||||||
|
describes record storage and recovery entry points.
|
||||||
|
|
||||||
|
If no usable record was saved, use the exact endpoint, full tag, and annotated
|
||||||
|
tag object reported by that invocation. Query only that exact tag ref at the
|
||||||
|
captured endpoint. An exact tag-object match confirms publication, a different
|
||||||
|
object is a conflict, successful output without the ref shows current absence,
|
||||||
and a failed query leaves the result unresolved. Do not infer the captured
|
and a failed query leaves the result unresolved. Do not infer the captured
|
||||||
endpoint's state from another remote or a rewritten URL.
|
endpoint's state from another remote or a rewritten URL. Resolve unknown
|
||||||
|
publication state before retrying.
|
||||||
|
|
||||||
ReleaseKit may retain a local tag after a failed push. Remove an unpublished
|
ReleaseKit may retain a local tag after a failed push. Remove an unpublished
|
||||||
local tag only after proving absence at the captured endpoint and confirming
|
local tag only after proving absence at the captured endpoint and confirming
|
||||||
that the tag was never published or used by another release channel. Never
|
that the tag was never published or used by another release channel. Never
|
||||||
move, overwrite, recreate, or delete a published tag. Resolve conflicts with a
|
move, overwrite, recreate, or delete a published tag. Resolve conflicts with a
|
||||||
fresh version; corrections to published source require a new version.
|
fresh version; corrections to published source require a new version.
|
||||||
|
|
||||||
|
## Hosted delivery
|
||||||
|
|
||||||
|
After source publication, check out the original tagged source with a clean
|
||||||
|
worktree and index, then deliver the selected module's page:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
./scripts/releasekit core deliver vX.Y.Z
|
||||||
|
# Or use ckconfig or ckprompt with that module's semantic version.
|
||||||
|
```
|
||||||
|
|
||||||
|
The profile derives the full module-specific tag from the version. Delivery
|
||||||
|
uses the checked-in release note as a page-only body and reports its own outcome
|
||||||
|
and URL separately from source publication. Retry later from the same original
|
||||||
|
tagged source. The pinned [delivery guide](https://forge.maximumdirect.net/base-layer/releasekit/src/commit/2180aa868ec2caa95fc8279fd59d5f0ebac41bd4/docs/consumers/delivery.md)
|
||||||
|
owns delivery and retry behavior.
|
||||||
|
|
||||||
|
A nonblank `RELEASEKIT_GITEA_TOKEN` takes precedence over the repository-relative
|
||||||
|
`.releasekit/gitea-token` file. Keep the credential directory ignored and retain
|
||||||
|
its local contents. Credentials and Gitea availability do not gate source
|
||||||
|
publication.
|
||||||
|
|||||||
88
docs/releases/v0.3.0.md
Normal file
88
docs/releases/v0.3.0.md
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
# CommandKit v0.3.0
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
CommandKit v0.3.0 moves core to `go.maximumdirect.net/commandkit`, adds
|
||||||
|
application-owned command and flag aliases, shared invocation reporting, and
|
||||||
|
shell completion. This pre-1.0 minor release also updates the installation
|
||||||
|
declaration type and Invocation struct shape, replaces the error-first outcome
|
||||||
|
and diagnostic-delivery APIs, and reserves reporting flag names; review the
|
||||||
|
migration guidance before upgrading.
|
||||||
|
|
||||||
|
## Changes
|
||||||
|
|
||||||
|
- Declare command shortcuts and global or canonical-command-scoped flag
|
||||||
|
spellings with `Alias`, `FlagAlias`, and `FlagAliasFor`. Install routing rules
|
||||||
|
beside canonical commands through `New` or `App.Register`. Aliases route to
|
||||||
|
existing handlers and preserve the target command's operand and binding
|
||||||
|
behavior.
|
||||||
|
- Read the entered and canonical command paths from `Invocation` with
|
||||||
|
`EnteredPath` and `CanonicalPath`.
|
||||||
|
- Interpret a canonical short value flag such as `-c=` as an explicitly
|
||||||
|
supplied empty value, consistent with `--color=` and `-c ""`.
|
||||||
|
- Obtain an invocation-scoped `Reporter` from each handler's `Invocation` for
|
||||||
|
substantive output, Warning/Info/Progress/Debug messages, deferred messages,
|
||||||
|
and managed report rendering. The shared `--quiet`, `--verbose`, `--debug`,
|
||||||
|
and `--progress` flags apply across core and integration commands.
|
||||||
|
- Return terminal explanation text with `Failure`, `Usage`, or
|
||||||
|
`Outcome.WithExplanation`; attach an optional operation error separately with
|
||||||
|
`Outcome.WithCause`. Returned `Outcome.Presentation` records output and
|
||||||
|
diagnostic delivery observations without changing the operation cause.
|
||||||
|
- Install the optional `Completion` command and declare static choices or
|
||||||
|
path hints for interactive shell completion. Bash, Zsh, and Fish scripts
|
||||||
|
use the application's captured commands and routing metadata.
|
||||||
|
- Maintainer release tooling now uses the root-selected ReleaseKit v0.9.0 tool,
|
||||||
|
with separate source publication and optional page-only hosted delivery.
|
||||||
|
|
||||||
|
## Compatibility and migration
|
||||||
|
|
||||||
|
Change core imports from `gitea.maximumdirect.net/eric/CommandKit` to
|
||||||
|
`go.maximumdirect.net/commandkit` and select v0.3.0. These paths identify
|
||||||
|
different Go packages, so their types cannot be mixed. Applications using the
|
||||||
|
integrations should migrate together to the forthcoming matched
|
||||||
|
`go.maximumdirect.net/ckconfig v0.2.0` and
|
||||||
|
`go.maximumdirect.net/ckprompt v0.2.0` releases, which depend on the new core;
|
||||||
|
the existing integration releases retain their old identities.
|
||||||
|
|
||||||
|
`New` and `App.Register` now accept `...Declaration` instead of `...Command`.
|
||||||
|
Command, group, and integration command factories still return `Command`, so
|
||||||
|
individual factory calls can be passed directly. A `[]Command` cannot be
|
||||||
|
expanded into `...Declaration`; use a `[]Declaration` for mixed reusable
|
||||||
|
collections or convert each command element before expanding it. Function
|
||||||
|
values whose parameter type is `...Command`, including wrappers assigned from
|
||||||
|
`New` or `Register`, must update their signature to `...Declaration`.
|
||||||
|
|
||||||
|
Adding private route fields to `Invocation` means positional struct literals no
|
||||||
|
longer compile. `Invocation` no longer contains `Streams`; `App.Execute` supplies
|
||||||
|
the reporter and route paths to handlers. `Streams` remains the argument to
|
||||||
|
`App.Execute`, where the caller owns the result and diagnostic writers.
|
||||||
|
|
||||||
|
Handlers that used `invocation.Streams.Out` or `.Err` should use
|
||||||
|
`invocation.Reporter().Output()` or the categorized reporting methods. Replace
|
||||||
|
`Failure(err)` and `Usage(err)` with an operator-facing explanation and an
|
||||||
|
optional `.WithCause(err)`; causes alone are not rendered. Replace
|
||||||
|
`Outcome.Diagnostics` submissions and terminal `SeverityError` records with
|
||||||
|
reporter messages and an `Outcome` explanation. Provider-returned
|
||||||
|
`Acquired.Diagnostics` remain passive data for the owning integration to
|
||||||
|
submit. Replace `ReportIncomplete`, `DiagnosticDelivery`, and `DeliveryError`
|
||||||
|
with `Outcome.Presentation.Output` and `.Diagnostics`, whose `DeliveryResult`
|
||||||
|
contains a state plus first render and write errors.
|
||||||
|
|
||||||
|
The long flags `--quiet`, `--verbose`, `--debug`, and `--progress` are reserved
|
||||||
|
for CommandKit reporting and cannot be declared by applications. Application
|
||||||
|
aliases may target them. See the current [CLI reporting contract](../cli.md#reporting)
|
||||||
|
and [consumer migration guide](../consumers/guide.md#invocation-reporting) for
|
||||||
|
their behavior and API examples.
|
||||||
|
|
||||||
|
The consumer minimum remains Go 1.26.0 and the maintainer release toolchain
|
||||||
|
remains Go 1.26.8. Core v0.3.0 must be published before preparing dependent
|
||||||
|
integration releases that adopt it and record downloaded dependency sums; this
|
||||||
|
release does not change their v0.1.0 release records.
|
||||||
|
|
||||||
|
See the [consumer guide](https://forge.maximumdirect.net/base-layer/commandkit/src/tag/v0.3.0/docs/consumers/guide.md),
|
||||||
|
[CLI reference](https://forge.maximumdirect.net/base-layer/commandkit/src/tag/v0.3.0/docs/cli.md),
|
||||||
|
and [maintainer release procedure](https://forge.maximumdirect.net/base-layer/commandkit/src/tag/v0.3.0/docs/release.md).
|
||||||
|
|
||||||
|
[COPYRIGHT](https://forge.maximumdirect.net/base-layer/commandkit/src/tag/v0.3.0/COPYRIGHT) |
|
||||||
|
[LICENSE](https://forge.maximumdirect.net/base-layer/commandkit/src/tag/v0.3.0/LICENSE) |
|
||||||
|
[ATTRIBUTION](https://forge.maximumdirect.net/base-layer/commandkit/src/tag/v0.3.0/docs/oss/attribution.md)
|
||||||
32
docs/releases/v0.4.0.md
Normal file
32
docs/releases/v0.4.0.md
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
# CommandKit v0.4.0
|
||||||
|
|
||||||
|
CommandKit v0.4.0 adds shared structured reporting and reusable CLI input
|
||||||
|
facilities. Applications and integrations can submit one report value for
|
||||||
|
automatic or explicit text, YAML, and JSON output. Core selects the format,
|
||||||
|
renders optional standard text templates, and records presentation failures
|
||||||
|
through the existing invocation lifecycle. The writer API exposes the same
|
||||||
|
rendering behavior outside an invocation.
|
||||||
|
|
||||||
|
Report values use JSON serialization conventions for structured output. The
|
||||||
|
safe YAML conversion helper accepts upstream inspection documents without
|
||||||
|
reconstructing configuration or treating ordinary strings as YAML. Public CLI
|
||||||
|
JSON parsing and ordered flag occurrences support structured inputs and
|
||||||
|
interleaved bindings. Applications can reuse and rename flag bindings while
|
||||||
|
preserving their typed parsing and presence behavior.
|
||||||
|
|
||||||
|
This pre-1.0 minor release reserves the long flag `--format` for core and
|
||||||
|
changes standard version output to use shared reporting. Applications that
|
||||||
|
declare a local `--format` must remove or rename it; an application alias may
|
||||||
|
target the core flag. Integration report framing and format availability have
|
||||||
|
changed with the shared API; select compatible integration releases as they
|
||||||
|
become available. See the [CLI reference](../cli.md#reporting) and
|
||||||
|
[consumer guide](../consumers/guide.md#invocation-reporting) for current
|
||||||
|
contracts and examples.
|
||||||
|
|
||||||
|
Core continues to declare a Go 1.26.0 consumer minimum. The maintainer release
|
||||||
|
toolchain is Go 1.27.0, and core has no ConfigKit or PromptKit integration
|
||||||
|
dependency.
|
||||||
|
|
||||||
|
[COPYRIGHT](https://forge.maximumdirect.net/base-layer/commandkit/src/tag/v0.4.0/COPYRIGHT) |
|
||||||
|
[LICENSE](https://forge.maximumdirect.net/base-layer/commandkit/src/tag/v0.4.0/LICENSE) |
|
||||||
|
[ATTRIBUTION](https://forge.maximumdirect.net/base-layer/commandkit/src/tag/v0.4.0/docs/oss/attribution.md)
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
# Deferred work
|
|
||||||
|
|
||||||
Status: retained future work and unselected proposals; no implementation schedule.
|
|
||||||
|
|
||||||
## Purpose and authority
|
|
||||||
|
|
||||||
Completed initial delivery, performance, ReleaseKit upgrade, and remediation
|
|
||||||
records have been retired. This document preserves remaining deferred
|
|
||||||
capabilities and unselected ideas under the
|
|
||||||
[release policy](../policy/release.md#release-documentation-and-preparation).
|
|
||||||
The original records remain available in
|
|
||||||
[Git history](https://gitea.maximumdirect.net/eric/CommandKit/src/commit/6bc37e7ba64ca0920b4d0cc77039a18d4006b24e/docs/roadmap).
|
|
||||||
|
|
||||||
Accepted [ADRs](../adr/) and the [architecture policy](../policy/architecture.md)
|
|
||||||
govern any future implementation. The [CLI reference](../cli.md), public GoDoc,
|
|
||||||
and [consumer guide](../consumers/guide.md) own implemented behavior. Retaining
|
|
||||||
an idea here does not select its syntax, API, priority, or delivery date.
|
|
||||||
|
|
||||||
## Deferred capability targets
|
|
||||||
|
|
||||||
### Comparison extensions
|
|
||||||
|
|
||||||
Independent per-side domain selectors and an optional assertion mode remain
|
|
||||||
unselected extensions to the delivered ConfigKit comparison. Consider them only
|
|
||||||
for a concrete consumer need and a deliberate API/behavior decision under the
|
|
||||||
relevant ADRs.
|
|
||||||
|
|
||||||
## Other deferred proposals
|
|
||||||
|
|
||||||
These capabilities remain outside the delivered scope and require a concrete
|
|
||||||
operator or consumer need and an appropriate ownership/behavior decision:
|
|
||||||
|
|
||||||
- JSON reports, shell completion, and output-file management or reloadable export.
|
|
||||||
- Configuration mutation, generic value assignment, and exhaustive field-origin
|
|
||||||
inventories.
|
|
||||||
- Credential diagnostics beyond PromptKit's native smoke checks and additional
|
|
||||||
live provider probes.
|
|
||||||
- Additional component identity registration and domain artifact catalogs.
|
|
||||||
- Additional library families, including possible FeedKit integration; families
|
|
||||||
can define suitable verbs without requiring comparison or configuration scopes.
|
|
||||||
- Backend selection or replacement, a plugin framework, general dependency
|
|
||||||
injection, and concurrent-dispatch guarantees.
|
|
||||||
|
|
||||||
Execution, rendering, serving, and session operations remain application-owned.
|
|
||||||
Shared vocabulary is guidance under [ADR 0006](../adr/0006-command-grammar.md).
|
|
||||||
Backend replacement remains possible behind the accepted facade, subject to the
|
|
||||||
published API/CLI contracts and deliberate compatibility decisions.
|
|
||||||
|
|
||||||
## Possible further efficiency work
|
|
||||||
|
|
||||||
Selected-scope memoization and selector-membership indexes were deferred pending
|
|
||||||
measurements that justify their bookkeeping. Mutable backend reuse across
|
|
||||||
invocations, segmented aggregate YAML delivery, provider caching, consumer-loader
|
|
||||||
redesign, and a replacement backend were outside the completed performance
|
|
||||||
target. Reconsider them only with concrete motivation and review of ownership,
|
|
||||||
invocation state, native reports, and the affected contracts. Retained measurement
|
|
||||||
records are historical evidence, not performance guarantees or release gates.
|
|
||||||
|
|
||||||
## Consumer adoption
|
|
||||||
|
|
||||||
Consumer migrations remain separate from library delivery. Applications can
|
|
||||||
replace duplicated standard handlers and routing, then update their CLI docs
|
|
||||||
and subprocess consumers for intentional changes while retaining domain commands.
|
|
||||||
No migration order or claim about individual applications' adoption is recorded
|
|
||||||
here; the [consumer guide](../consumers/guide.md) and maintained examples provide
|
|
||||||
integration guidance.
|
|
||||||
221
docs/roadmap/future.md
Normal file
221
docs/roadmap/future.md
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
# Future work
|
||||||
|
|
||||||
|
Status: pending release and integration work, plus future capability proposals.
|
||||||
|
No delivery dates are selected here.
|
||||||
|
|
||||||
|
## Purpose and authority
|
||||||
|
|
||||||
|
Completed roadmaps, implementation plans, and review findings have been retired.
|
||||||
|
This document preserves unfinished work and unselected ideas under the
|
||||||
|
[release policy](../policy/release.md#release-documentation-and-preparation).
|
||||||
|
The completed PromptKit v0.18.0 adoption roadmap and implementation plan remain
|
||||||
|
available in the
|
||||||
|
[PromptKit retirement snapshot](https://forge.maximumdirect.net/base-layer/commandkit/src/commit/540e2359d66d604a563df0be1c9fc961588f5b9c/docs/roadmap).
|
||||||
|
The completed ConfigKit adoption, reporting, dependency alignment, release-tooling,
|
||||||
|
and remediation records remain available at the
|
||||||
|
[retirement snapshot](https://forge.maximumdirect.net/base-layer/commandkit/src/commit/260256d722f52bc947103d078cb67a129f4cc9f1/docs/roadmap).
|
||||||
|
[Earlier retired records](https://gitea.maximumdirect.net/eric/CommandKit/src/commit/6bc37e7ba64ca0920b4d0cc77039a18d4006b24e/docs/roadmap)
|
||||||
|
also remain in Git history. Completed records are not assignments to restart work.
|
||||||
|
|
||||||
|
Accepted [ADRs](../adr/) and the [architecture policy](../policy/architecture.md)
|
||||||
|
govern any future implementation. The [CLI reference](../cli.md), public GoDoc,
|
||||||
|
and [consumer guide](../consumers/guide.md) own implemented behavior. Retaining
|
||||||
|
an idea here does not select its syntax, API, priority, or delivery date.
|
||||||
|
|
||||||
|
## Pending release and integration work
|
||||||
|
|
||||||
|
### ckprompt release
|
||||||
|
|
||||||
|
The PromptKit v0.18.0 adoption is complete. The
|
||||||
|
[ckprompt v0.3.0 candidate](../../integrations/ckprompt/docs/releases/v0.3.0.md)
|
||||||
|
remains unpublished. Publication and hosted delivery remain separate operations
|
||||||
|
under the [release procedure](../release.md).
|
||||||
|
The published ckprompt v0.2.0 retains its earlier dependencies; see the
|
||||||
|
[consumer guide](../consumers/guide.md#module-selection) for module selection.
|
||||||
|
|
||||||
|
### PromptKit v0.20.0 adoption
|
||||||
|
|
||||||
|
The [PromptKit v0.20.0 roadmap](promptkit-v0.20.0.md) owns the agreed adoption,
|
||||||
|
the shared core reporting/input prerequisites, operator CLI additions for
|
||||||
|
PromptKit and ConfigKit, recorded decisions, and completion checklist. It targets
|
||||||
|
a clean alpha adoption without backward-compatibility provisions or consumer
|
||||||
|
migration guidance. Implementation has not started. Use released upstream
|
||||||
|
contracts only; do not inspect or monitor unreleased work in anticipation of
|
||||||
|
adoption.
|
||||||
|
|
||||||
|
## Future capability targets
|
||||||
|
|
||||||
|
### Comparison extensions
|
||||||
|
|
||||||
|
The [working roadmap](promptkit-v0.20.0.md#additional-configkit-operator-workflows)
|
||||||
|
owns independent comparison selections (OD-9), assertion outcomes (OD-10), and
|
||||||
|
optional detailed reports (OD-5). These accepted plans do not change currently
|
||||||
|
implemented behavior.
|
||||||
|
|
||||||
|
General collection-clearing support is deferred pending a concrete consumer need.
|
||||||
|
Revisit standardized syntax and binding support for explicitly replacing a
|
||||||
|
nonempty application-default selector collection with an empty collection.
|
||||||
|
This is separate from clearing configuration lists through OD-11's transient
|
||||||
|
overrides and does not reinstate comparison inheritance. No clearing grammar
|
||||||
|
or public helper API is selected.
|
||||||
|
|
||||||
|
### Configuration inspection extensions
|
||||||
|
|
||||||
|
The [working roadmap](promptkit-v0.20.0.md#shared-configuration-capabilities)
|
||||||
|
owns metadata/path discovery (OD-4), detailed comparison (OD-5), failed-load
|
||||||
|
evidence and aggregate validation (OD-6), focused inspection/filters (OD-8), and
|
||||||
|
transient overrides (OD-11). The six-command suite, selected output behavior,
|
||||||
|
and pass-through override policy are defined there. Implementation belongs in
|
||||||
|
`ckconfig`, with PromptKit exposing native evidence through its configuration
|
||||||
|
scope. Use the shared core reporting prerequisite and thin projections;
|
||||||
|
presentation refinements should follow implementation and beta feedback.
|
||||||
|
All numbered decisions are settled; exact APIs and wiring remain implementation
|
||||||
|
work. Persistent configuration editing remains deferred.
|
||||||
|
|
||||||
|
Discovery, import defaults, collection identities, and field-level resolution
|
||||||
|
recording remain available through application loaders. Discovery tracing,
|
||||||
|
resource-context inspection, and secrets-directory bootstrap commands remain
|
||||||
|
deferred. Acquired source records do not provide a complete trace of rejected
|
||||||
|
discovery candidates; bootstrap and resource contexts primarily serve
|
||||||
|
application loading and startup.
|
||||||
|
|
||||||
|
### PromptKit inspection and selection extensions
|
||||||
|
|
||||||
|
The [PromptKit v0.20.0 roadmap](promptkit-v0.20.0.md#targeted-end-state)
|
||||||
|
owns native prompt/profile/backend resource inspection, concrete request preview,
|
||||||
|
applicable request flags, namespace selection, and the shared configuration
|
||||||
|
inspection extensions. The [resource inspection plan](promptkit-v0.20.0.md#resource-inspection)
|
||||||
|
includes backend inspection through the released upstream API. Execution flags
|
||||||
|
will be public reusable `ckprompt` collections for standard and application-owned
|
||||||
|
CLI commands, composed through existing core bindings.
|
||||||
|
[Roadmap OD-2](promptkit-v0.20.0.md#od-2-request-controls-and-provider-parameter-grammar)
|
||||||
|
records the agreed canonical naming, scoped convenience aliases, integration
|
||||||
|
installation collection, selected text/file input forms, and CLI JSON number
|
||||||
|
policy. Numeric conversion uses ordinary Go types and a small internal helper
|
||||||
|
with policy comments, behind a narrow public core CLI JSON parser. Collections
|
||||||
|
and aliases use existing declarations; cross-flag text/file precedence uses the
|
||||||
|
roadmap's ordered typed input prerequisite.
|
||||||
|
Output-contract overrides and appended-message flags are deferred below.
|
||||||
|
These are CLI features; general-purpose helpers for
|
||||||
|
application-internal PromptKit use are outside CommandKit's ownership boundary.
|
||||||
|
|
||||||
|
[Roadmap OD-3](promptkit-v0.20.0.md#od-3-namespace-flags-and-loader-defaults)
|
||||||
|
records the agreed namespace-selector applicability, including the limited role
|
||||||
|
of profile selection in requirements inspection. That selection follows the
|
||||||
|
released upstream identity requirement; it does not change prompt dependencies.
|
||||||
|
|
||||||
|
- **Output-contract override flags.** Deferred from the current adoption. A
|
||||||
|
possible future operator task is validating or previewing one prompt with a
|
||||||
|
replacement output contract without editing its definition. PromptKit uses
|
||||||
|
whole-contract replacement; a complete-contract file option was discussed,
|
||||||
|
but no flag spelling or file format is selected. Existing configuration and
|
||||||
|
application builders remain available. Revisit when the standard `promptkit`
|
||||||
|
commands have a demonstrated need for this workflow.
|
||||||
|
|
||||||
|
- **Appended-message flags.** Deferred from the current adoption. The potential
|
||||||
|
operator task is reproducing a single request with application-added messages
|
||||||
|
through `promptkit show prepared` or `promptkit requirements validate`, to
|
||||||
|
inspect roles, ordering, and content or check preparation. These commands do
|
||||||
|
not test model responses or application workflows. Appended messages follow
|
||||||
|
definition messages in supplied order; they are already rendered and do not
|
||||||
|
satisfy missing named prompt inputs. Revisit when request reproduction is a
|
||||||
|
demonstrated operator requirement. An ordered message-list file is a candidate,
|
||||||
|
but no flag syntax, file format, or supported-role selection is decided.
|
||||||
|
Deferring flags does not remove native appended messages from preview reports
|
||||||
|
when an application builder supplies them.
|
||||||
|
|
||||||
|
[Roadmap OD-2](promptkit-v0.20.0.md#od-2-request-controls-and-provider-parameter-grammar)
|
||||||
|
selects inline text and explicit file input for single-prompt validation and
|
||||||
|
preview. Native API availability alone does not select further CLI syntax or
|
||||||
|
require a convenience API for application execution.
|
||||||
|
|
||||||
|
### Alias completion and discovery
|
||||||
|
|
||||||
|
The delivered [shell completion feature](../cli.md#shell-completion) provides
|
||||||
|
canonical suggestions and alias-aware interpretation. Potential future work
|
||||||
|
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 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
|
||||||
|
steps. No delivery date or final API is selected here.
|
||||||
|
|
||||||
|
### Reporting extensions
|
||||||
|
|
||||||
|
The [shared core report output prerequisite](promptkit-v0.20.0.md#shared-core-report-output-prerequisite)
|
||||||
|
owns application-selected content, core rendering, universal/automatic format
|
||||||
|
selection, JSON representation conventions, text templates, and safe YAML input
|
||||||
|
conversion. Text, YAML, and JSON land together; new core formats work through the
|
||||||
|
same report API. The [output inventory](promptkit-v0.20.0.md#output-implementation-inventory)
|
||||||
|
identifies report paths and preserves explicit raw/help/diagnostic boundaries.
|
||||||
|
OD-6 defines one aggregate validation report and its changed delivery timing.
|
||||||
|
XML is outside current consideration; Markdown and other renderers are not
|
||||||
|
selected for this adoption.
|
||||||
|
|
||||||
|
Error-conversion helpers such as `WarnError` remain deferred pending demonstrated
|
||||||
|
value; applications can already use `report.Warn(err.Error())`. Terminal redraw
|
||||||
|
and progress-bar presentation were outside the completed reporting scope and
|
||||||
|
remain unselected. Whole-run buffering to make failed runs show only errors
|
||||||
|
was also outside that scope. Any later extension needs a deliberate API and
|
||||||
|
presentation decision rather than assuming those behaviors are already provided.
|
||||||
|
|
||||||
|
### Release workflow extensions
|
||||||
|
|
||||||
|
Historical hosted-page backfilling and automated CI delivery remain separate
|
||||||
|
future choices. The current local page-only delivery workflow is implemented;
|
||||||
|
these extensions are not prerequisites for using it. The release procedure owns
|
||||||
|
delivery, immutable content, and recovery behavior.
|
||||||
|
|
||||||
|
Additional validation options, including race testing, full vet, compilation
|
||||||
|
matrices, size limits, and more forbidden-file paths, can be considered for
|
||||||
|
demonstrated needs. None is selected by retaining it here, and the existing
|
||||||
|
candidate checks do not become general prohibitions on development workspaces.
|
||||||
|
|
||||||
|
## Other future proposals
|
||||||
|
|
||||||
|
These capabilities remain outside the delivered scope and require a concrete
|
||||||
|
operator or consumer need and an appropriate ownership/behavior decision:
|
||||||
|
|
||||||
|
- Additional report renderers as described under [reporting extensions](#reporting-extensions),
|
||||||
|
and output-file management or reloadable export.
|
||||||
|
- Shell completion extensions, including alias discovery described above and
|
||||||
|
dynamic discovery of configured or remote values.
|
||||||
|
- Persistent configuration mutation. Transient CLI overrides are selected under
|
||||||
|
roadmap OD-11; field metadata and provenance inspection are scoped under
|
||||||
|
[configuration inspection extensions](#configuration-inspection-extensions).
|
||||||
|
- Credential diagnostics beyond PromptKit's native smoke checks and additional
|
||||||
|
live provider probes.
|
||||||
|
- Additional component identity registration and domain artifact catalogs.
|
||||||
|
- Additional library families, including possible FeedKit integration; families
|
||||||
|
can define suitable verbs without requiring comparison or configuration scopes.
|
||||||
|
- Backend selection or replacement, a plugin framework, general dependency
|
||||||
|
injection, and concurrent-dispatch guarantees.
|
||||||
|
|
||||||
|
Execution, serving, and session operations remain application-owned. The
|
||||||
|
[request preview proposal](promptkit-v0.20.0.md#preview-and-request-flags)
|
||||||
|
exposes native preparation and rendering as an agreed operator inspection target;
|
||||||
|
implementation has not started.
|
||||||
|
The [architecture policy](../policy/architecture.md#command-and-application-ownership)
|
||||||
|
recommends shared vocabulary; the [CLI reference](../cli.md#registered-command-syntax)
|
||||||
|
owns implemented grammar.
|
||||||
|
Backend replacement remains possible behind the accepted facade, subject to the
|
||||||
|
published API/CLI contracts and deliberate compatibility decisions.
|
||||||
|
|
||||||
|
## Possible further efficiency work
|
||||||
|
|
||||||
|
Selected-scope memoization and selector-membership indexes were deferred pending
|
||||||
|
measurements that justify their bookkeeping. Mutable backend reuse across
|
||||||
|
invocations, segmented aggregate YAML delivery, provider caching, consumer-loader
|
||||||
|
redesign, and a replacement backend were outside the completed performance
|
||||||
|
target. Reconsider them only with concrete motivation and review of ownership,
|
||||||
|
invocation state, native reports, and the affected contracts. Retained measurement
|
||||||
|
records are historical evidence, not performance guarantees or release gates.
|
||||||
|
|
||||||
|
## Consumer adoption
|
||||||
|
|
||||||
|
CommandKit is in alpha with no consumers. No application adoption work or rollout
|
||||||
|
order is selected. The [consumer guide](../consumers/guide.md) and maintained
|
||||||
|
examples provide integration guidance for future applications.
|
||||||
1016
docs/roadmap/implementation.md
Normal file
1016
docs/roadmap/implementation.md
Normal file
File diff suppressed because it is too large
Load Diff
1417
docs/roadmap/promptkit-v0.20.0.md
Normal file
1417
docs/roadmap/promptkit-v0.20.0.md
Normal file
File diff suppressed because it is too large
Load Diff
38
examples/core/README.md
Normal file
38
examples/core/README.md
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
# Core example
|
||||||
|
|
||||||
|
Run the example from the repository root:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
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
|
||||||
|
command shortcut, a group shortcut, a scoped flag alias, and a global help
|
||||||
|
alias. Its declaration slice mixes the canonical group and routing rules as
|
||||||
|
`[]commandkit.Declaration`. The handler prints `Invocation.EnteredPath()` and
|
||||||
|
`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. Initialize `bash-completion` first if using
|
||||||
|
Bash. 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`.
|
||||||
|
|
||||||
|
Compatible integration factories also return `commandkit.Command` and can be
|
||||||
|
passed directly to `New` beside `commandkit.Alias` or flag-alias declarations.
|
||||||
|
The matched ckconfig and ckprompt releases use the new core identity. For a
|
||||||
|
command-only `[]commandkit.Command`, convert its elements into a
|
||||||
|
`[]commandkit.Declaration` before appending routing rules; Go cannot expand the
|
||||||
|
command slice as `...commandkit.Declaration`. The
|
||||||
|
[consumer guide](../../docs/consumers/guide.md#application-owned-aliases)
|
||||||
|
shows both forms, and the [composed consumer](../../integrations/ckprompt/examples/composed)
|
||||||
|
is the maintained integration example.
|
||||||
|
|
||||||
|
These aliases require core v0.3.0. Repository contributors can run its test with
|
||||||
|
`go test ./examples/core` from the root.
|
||||||
@@ -2,11 +2,14 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"text/template"
|
||||||
|
|
||||||
commandkit "gitea.maximumdirect.net/eric/CommandKit"
|
commandkit "go.maximumdirect.net/commandkit"
|
||||||
)
|
)
|
||||||
|
|
||||||
type greetInput struct {
|
type greetInput struct {
|
||||||
@@ -14,6 +17,25 @@ type greetInput struct {
|
|||||||
Tags commandkit.Supplied[[]string]
|
Tags commandkit.Supplied[[]string]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type reportInput struct {
|
||||||
|
Scenario string
|
||||||
|
}
|
||||||
|
|
||||||
|
type greetingReport struct {
|
||||||
|
Count string `json:"count"`
|
||||||
|
Tags string `json:"tags"`
|
||||||
|
Entered string `json:"entered"`
|
||||||
|
Canonical string `json:"canonical"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var greetingTemplate = template.Must(commandkit.ParseTextTemplate("greeting", "Hello; count={{.Count}}; tags={{.Tags}}; entered={{printf \"%q\" .Entered}}; canonical={{printf \"%q\" .Canonical}}\n", nil))
|
||||||
|
|
||||||
|
var (
|
||||||
|
errRecoveredFixture = errors.New("a local read failed and was recovered")
|
||||||
|
errWarningFixture = errors.New("a local warning has a cause")
|
||||||
|
errPartialFixture = errors.New("the local report could not be completed")
|
||||||
|
)
|
||||||
|
|
||||||
func newApp() (*commandkit.App, error) {
|
func newApp() (*commandkit.App, error) {
|
||||||
countDefault := 1
|
countDefault := 1
|
||||||
tagDefaults := []string{"demo"}
|
tagDefaults := []string{"demo"}
|
||||||
@@ -29,6 +51,7 @@ func newApp() (*commandkit.App, error) {
|
|||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
Run: func(invocation commandkit.Invocation, input greetInput) commandkit.Outcome {
|
Run: func(invocation commandkit.Invocation, input greetInput) commandkit.Outcome {
|
||||||
|
reporter := invocation.Reporter()
|
||||||
count := fmt.Sprintf("default %d (not supplied)", input.Count.Value)
|
count := fmt.Sprintf("default %d (not supplied)", input.Count.Value)
|
||||||
if input.Count.Set {
|
if input.Count.Set {
|
||||||
count = fmt.Sprintf("explicit %d", input.Count.Value)
|
count = fmt.Sprintf("explicit %d", input.Count.Value)
|
||||||
@@ -37,14 +60,93 @@ func newApp() (*commandkit.App, error) {
|
|||||||
if input.Tags.Set {
|
if input.Tags.Set {
|
||||||
tags = fmt.Sprintf("explicit %v", input.Tags.Value)
|
tags = fmt.Sprintf("explicit %v", input.Tags.Value)
|
||||||
}
|
}
|
||||||
if _, err := fmt.Fprintf(invocation.Streams.Out, "Hello; count=%s; tags=%s\n", count, tags); err != nil {
|
_ = reporter.Report(commandkit.Report{Value: greetingReport{Count: count, Tags: tags, Entered: invocation.EnteredPath(), Canonical: invocation.CanonicalPath()}, TextTemplate: greetingTemplate})
|
||||||
return commandkit.Failure(err)
|
|
||||||
}
|
|
||||||
return commandkit.Success()
|
return commandkit.Success()
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
report := commandkit.NewCommand(commandkit.CommandSpec[reportInput]{
|
||||||
|
Name: "report",
|
||||||
|
Summary: "Demonstrate invocation reporting with local scenarios.",
|
||||||
|
NewInput: func() reportInput {
|
||||||
|
return reportInput{Scenario: "demo"}
|
||||||
|
},
|
||||||
|
Arguments: []commandkit.Argument[reportInput]{
|
||||||
|
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 {
|
||||||
|
switch input.Scenario {
|
||||||
|
case "demo", "recover", "warn", "warn-error", "fail", "partial":
|
||||||
|
return nil
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("unknown reporting scenario %q", input.Scenario)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Run: func(invocation commandkit.Invocation, input reportInput) commandkit.Outcome {
|
||||||
|
return runReportingExample(invocation, input.Scenario)
|
||||||
|
},
|
||||||
|
})
|
||||||
workspace := commandkit.Group("workspace", "Workspace commands.", greet)
|
workspace := commandkit.Group("workspace", "Workspace commands.", greet)
|
||||||
return commandkit.New(commandkit.Identity{Name: "core-example", Version: "1.0"}, workspace)
|
declarations := []commandkit.Declaration{
|
||||||
|
workspace,
|
||||||
|
report,
|
||||||
|
commandkit.Completion("core-example"),
|
||||||
|
commandkit.Alias("hello", "workspace greet"),
|
||||||
|
commandkit.Alias("ws", "workspace"),
|
||||||
|
commandkit.FlagAliasFor("workspace greet", "--times", "--count"),
|
||||||
|
commandkit.FlagAlias("--hlep", "--help"),
|
||||||
|
}
|
||||||
|
return commandkit.New(commandkit.Identity{Name: "core-example", Version: "1.0"}, declarations...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func runReportingExample(invocation commandkit.Invocation, scenario string) commandkit.Outcome {
|
||||||
|
reporter := invocation.Reporter()
|
||||||
|
deferred := reporter.AtEnd()
|
||||||
|
switch scenario {
|
||||||
|
case "demo":
|
||||||
|
_ = reporter.Progress("Loaded one local fixture.")
|
||||||
|
_ = deferred.Warn("Optional fixture warning", func(writer io.Writer) error {
|
||||||
|
_, err := io.WriteString(writer, "The sample contains an optional note.\n")
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
_ = deferred.Info("Using deterministic local data.")
|
||||||
|
_ = deferred.Debug("Selected the built-in reporting example.")
|
||||||
|
_ = deferred.Info("Summary", writeExampleSummary)
|
||||||
|
_ = reporter.RenderOutput(writeExampleReport)
|
||||||
|
return commandkit.Success()
|
||||||
|
case "recover":
|
||||||
|
return commandkit.Success().WithCause(errRecoveredFixture)
|
||||||
|
case "warn":
|
||||||
|
_ = deferred.Warn("The local operation completed with a warning.")
|
||||||
|
return commandkit.Success()
|
||||||
|
case "warn-error":
|
||||||
|
_ = deferred.Warn("The local operation completed with a warning.")
|
||||||
|
return commandkit.Success().WithCause(errWarningFixture)
|
||||||
|
case "fail":
|
||||||
|
return commandkit.Failure("The selected local operation could not be completed.")
|
||||||
|
case "partial":
|
||||||
|
_ = reporter.RenderOutput(writePartialExampleReport)
|
||||||
|
return commandkit.Failure("The local report stopped after partial output.").WithCause(errPartialFixture)
|
||||||
|
default:
|
||||||
|
return commandkit.Usage(fmt.Sprintf("unknown reporting scenario %q", scenario))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeExampleReport(writer io.Writer) error {
|
||||||
|
_, err := fmt.Fprintln(writer, "Prepared a local report from one fixture.")
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func writePartialExampleReport(writer io.Writer) error {
|
||||||
|
_, err := io.WriteString(writer, "Partial local report: first section complete.\n")
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeExampleSummary(writer io.Writer) error {
|
||||||
|
_, err := io.WriteString(writer, "Processed one local fixture.\n")
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
|||||||
199
examples/core/main_test.go
Normal file
199
examples/core/main_test.go
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
commandkit "go.maximumdirect.net/commandkit"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAliasesReachCoreExampleCommand(t *testing.T) {
|
||||||
|
app, err := newApp()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
args []string
|
||||||
|
entered string
|
||||||
|
canonical string
|
||||||
|
greeting string
|
||||||
|
}{
|
||||||
|
{name: "command shortcut and scoped flag", args: []string{"hello", "--times=0", "--tag", "one"}, entered: "hello", canonical: "workspace greet", greeting: "count=explicit 0; tags=explicit [one]"},
|
||||||
|
{name: "group shortcut", args: []string{"ws", "greet", "--count=2"}, entered: "ws greet", canonical: "workspace greet", greeting: "count=explicit 2"},
|
||||||
|
{name: "canonical command", args: []string{"workspace", "greet"}, entered: "workspace greet", canonical: "workspace greet", greeting: "count=default 1 (not supplied)"},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
var output bytes.Buffer
|
||||||
|
outcome := app.Execute(context.Background(), test.args, commandkit.Streams{Out: &output, Err: io.Discard})
|
||||||
|
if outcome.Kind != commandkit.OutcomeSuccess {
|
||||||
|
t.Fatalf("Execute(%q) = %#v; output %q", test.args, outcome, output.String())
|
||||||
|
}
|
||||||
|
for _, fragment := range []string{
|
||||||
|
test.greeting,
|
||||||
|
"entered=\"" + test.entered + "\"",
|
||||||
|
"canonical=\"" + test.canonical + "\"",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(output.String(), fragment) {
|
||||||
|
t.Errorf("output %q does not contain %q", output.String(), fragment)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGlobalHelpAliasDoesNotRunCoreExampleCommand(t *testing.T) {
|
||||||
|
app, err := newApp()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var output bytes.Buffer
|
||||||
|
outcome := app.Execute(context.Background(), []string{"hello", "--hlep"}, commandkit.Streams{Out: &output, Err: io.Discard})
|
||||||
|
if outcome.Kind != commandkit.OutcomeSuccess || !strings.Contains(output.String(), "--hlep → --help") || strings.Contains(output.String(), "Hello;") {
|
||||||
|
t.Fatalf("help alias outcome/output = (%#v, %q)", outcome, output.String())
|
||||||
|
}
|
||||||
|
output.Reset()
|
||||||
|
outcome = app.Execute(context.Background(), []string{"ws", "--help"}, commandkit.Streams{Out: &output, Err: io.Discard})
|
||||||
|
if outcome.Kind != commandkit.OutcomeSuccess || !strings.Contains(output.String(), "greet") || strings.Contains(output.String(), "Hello;") {
|
||||||
|
t.Fatalf("group shortcut help outcome/output = (%#v, %q)", outcome, output.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReportingExampleModes(t *testing.T) {
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
args []string
|
||||||
|
wantOutput []string
|
||||||
|
wantDiag []string
|
||||||
|
omitDiag []string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "ordinary result",
|
||||||
|
args: []string{"report", "demo"},
|
||||||
|
wantOutput: []string{"Prepared a local report from one fixture."},
|
||||||
|
omitDiag: []string{"Optional fixture warning", "Using deterministic local data.", "Loaded one local fixture.", "DEBUG:"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "progress selected",
|
||||||
|
args: []string{"report", "demo", "--progress"},
|
||||||
|
wantOutput: []string{"Prepared a local report from one fixture."},
|
||||||
|
wantDiag: []string{"Loaded one local fixture."},
|
||||||
|
omitDiag: []string{"WARN:", "Using deterministic local data.", "DEBUG:"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "verbose messages",
|
||||||
|
args: []string{"report", "demo", "--verbose"},
|
||||||
|
wantOutput: []string{"Prepared a local report from one fixture."},
|
||||||
|
wantDiag: []string{"WARN: Optional fixture warning", "The sample contains an optional note.", "Using deterministic local data.", "Summary", "Processed one local fixture."},
|
||||||
|
omitDiag: []string{"Loaded one local fixture.", "DEBUG:"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "debug messages",
|
||||||
|
args: []string{"report", "demo", "--debug"},
|
||||||
|
wantOutput: []string{"Prepared a local report from one fixture."},
|
||||||
|
wantDiag: []string{"WARN: Optional fixture warning", "Using deterministic local data.", "Loaded one local fixture.", "DEBUG: Selected the built-in reporting example.", "Processed one local fixture."},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "quiet suppresses result and messages",
|
||||||
|
args: []string{"report", "demo", "--quiet"},
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
outcome, output, diagnostics := executeExample(t, test.args...)
|
||||||
|
if outcome.Kind != commandkit.OutcomeSuccess {
|
||||||
|
t.Fatalf("Execute(%q) = %#v", test.args, outcome)
|
||||||
|
}
|
||||||
|
for _, fragment := range test.wantOutput {
|
||||||
|
if !strings.Contains(output, fragment) {
|
||||||
|
t.Errorf("output %q does not contain %q", output, fragment)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, fragment := range test.wantDiag {
|
||||||
|
if !strings.Contains(diagnostics, fragment) {
|
||||||
|
t.Errorf("diagnostics %q does not contain %q", diagnostics, fragment)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, fragment := range test.omitDiag {
|
||||||
|
if strings.Contains(diagnostics, fragment) {
|
||||||
|
t.Errorf("diagnostics %q unexpectedly contain %q", diagnostics, fragment)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if test.name == "quiet suppresses result and messages" && (output != "" || diagnostics != "") {
|
||||||
|
t.Errorf("quiet output/diagnostics = (%q, %q), want both empty", output, diagnostics)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReportingExampleScenarios(t *testing.T) {
|
||||||
|
t.Run("silent recovery retains cause", func(t *testing.T) {
|
||||||
|
outcome, output, diagnostics := executeExample(t, "report", "recover")
|
||||||
|
if outcome.Kind != commandkit.OutcomeSuccess || !errors.Is(outcome.Cause, errRecoveredFixture) {
|
||||||
|
t.Fatalf("outcome = %#v, want successful recovery with original cause", outcome)
|
||||||
|
}
|
||||||
|
if output != "" || diagnostics != "" {
|
||||||
|
t.Fatalf("recovery output/diagnostics = (%q, %q), want silent recovery", output, diagnostics)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("warning without cause", func(t *testing.T) {
|
||||||
|
outcome, _, diagnostics := executeExample(t, "report", "warn", "--verbose")
|
||||||
|
if outcome.Kind != commandkit.OutcomeSuccess || outcome.Cause != nil {
|
||||||
|
t.Fatalf("outcome = %#v, want success without cause", outcome)
|
||||||
|
}
|
||||||
|
if !strings.Contains(diagnostics, "WARN: The local operation completed with a warning.") {
|
||||||
|
t.Fatalf("diagnostics = %q, want explicit warning", diagnostics)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("warning with independent cause", func(t *testing.T) {
|
||||||
|
outcome, _, diagnostics := executeExample(t, "report", "warn-error", "--verbose")
|
||||||
|
if outcome.Kind != commandkit.OutcomeSuccess || !errors.Is(outcome.Cause, errWarningFixture) {
|
||||||
|
t.Fatalf("outcome = %#v, want successful warning with original cause", outcome)
|
||||||
|
}
|
||||||
|
if !strings.Contains(diagnostics, "WARN: The local operation completed with a warning.") || strings.Contains(diagnostics, errWarningFixture.Error()) {
|
||||||
|
t.Fatalf("diagnostics = %q, want warning text without cause text", diagnostics)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("terminal explanation without cause", func(t *testing.T) {
|
||||||
|
outcome, _, diagnostics := executeExample(t, "report", "fail", "--quiet")
|
||||||
|
if outcome.Kind != commandkit.OutcomeOperational || outcome.Status == 0 || outcome.Cause != nil {
|
||||||
|
t.Fatalf("outcome = %#v, want terminal failure without cause", outcome)
|
||||||
|
}
|
||||||
|
if outcome.Explanation.Message != "The selected local operation could not be completed." || !strings.Contains(diagnostics, outcome.Explanation.Message) {
|
||||||
|
t.Fatalf("explanation/diagnostics = (%q, %q)", outcome.Explanation.Message, diagnostics)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("partial output keeps cause independent", func(t *testing.T) {
|
||||||
|
outcome, output, diagnostics := executeExample(t, "report", "partial")
|
||||||
|
if outcome.Kind != commandkit.OutcomeOperational || !errors.Is(outcome.Cause, errPartialFixture) {
|
||||||
|
t.Fatalf("outcome = %#v, want terminal failure with original cause", outcome)
|
||||||
|
}
|
||||||
|
if !strings.Contains(output, "Partial local report: first section complete.") {
|
||||||
|
t.Fatalf("output = %q, want completed first section", output)
|
||||||
|
}
|
||||||
|
if outcome.Explanation.Message != "The local report stopped after partial output." || !strings.Contains(diagnostics, outcome.Explanation.Message) {
|
||||||
|
t.Fatalf("explanation/diagnostics = (%q, %q)", outcome.Explanation.Message, diagnostics)
|
||||||
|
}
|
||||||
|
if strings.Contains(diagnostics, errPartialFixture.Error()) {
|
||||||
|
t.Errorf("diagnostics %q unexpectedly contain cause text", diagnostics)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func executeExample(t *testing.T, args ...string) (commandkit.Outcome, string, string) {
|
||||||
|
t.Helper()
|
||||||
|
app, err := newApp()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var output, diagnostics bytes.Buffer
|
||||||
|
outcome := app.Execute(context.Background(), args, commandkit.Streams{Out: &output, Err: &diagnostics})
|
||||||
|
return outcome, output.String(), diagnostics.String()
|
||||||
|
}
|
||||||
404
execute.go
404
execute.go
@@ -5,52 +5,88 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
internalcobra "gitea.maximumdirect.net/eric/CommandKit/internal/cobra"
|
internalcobra "go.maximumdirect.net/commandkit/internal/cobra"
|
||||||
commandmeta "gitea.maximumdirect.net/eric/CommandKit/internal/commandmeta"
|
commandmeta "go.maximumdirect.net/commandkit/internal/commandmeta"
|
||||||
|
"go.maximumdirect.net/commandkit/internal/commandpath"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Execute parses and dispatches one invocation. args excludes the executable
|
// Execute parses and dispatches one invocation. args excludes the executable
|
||||||
// name. Supplied values are converted before command input construction; help
|
// name. Supplied values are converted before command input construction; help
|
||||||
// does not construct caller inputs or run acquisition callbacks. CommandKit
|
// does not construct caller inputs or run acquisition callbacks. It applies the
|
||||||
// writes diagnostics to streams.Err and leaves process exit to the caller. A
|
// parsed reporting selection, finalizes invocation presentation, and returns
|
||||||
// nil context returns an operational failure, and nil streams are replaced with
|
// the resulting Outcome without exiting the process. Supplied streams remain
|
||||||
// io.Discard.
|
// caller-owned; nil destinations are replaced with io.Discard. A nil context
|
||||||
|
// returns an operational failure.
|
||||||
func (a *App) Execute(ctx context.Context, args []string, streams Streams) Outcome {
|
func (a *App) Execute(ctx context.Context, args []string, streams Streams) Outcome {
|
||||||
if ctx == nil {
|
|
||||||
return a.present(Failure(errors.New("invocation context must not be nil")), nil, streams)
|
|
||||||
}
|
|
||||||
streams = normalizeStreams(streams)
|
|
||||||
current := newExecution(a, ctx, streams)
|
current := newExecution(a, ctx, streams)
|
||||||
|
if a == nil {
|
||||||
|
err := errors.New("cannot execute commands on a nil app")
|
||||||
|
return current.finalize(Failure(err.Error()).WithCause(err), nil, false)
|
||||||
|
}
|
||||||
|
if ctx == nil {
|
||||||
|
err := errors.New("invocation context must not be nil")
|
||||||
|
return current.finalize(Failure(err.Error()).WithCause(err), nil, false)
|
||||||
|
}
|
||||||
|
if current.snapshot != nil && current.snapshot.completion && len(args) > 0 && args[0] == internalcobra.CompletionQueryToken {
|
||||||
|
return current.complete(args[1:])
|
||||||
|
}
|
||||||
routedSnapshot := current.snapshot
|
routedSnapshot := current.snapshot
|
||||||
parsed := current.parse(args)
|
current.route = resolveEnteredRoute(args, routedSnapshot)
|
||||||
|
current.recordRouteEvents()
|
||||||
|
parsed := current.parse(current.route.args)
|
||||||
|
current.reporter.applySelection(parsed.Reporting)
|
||||||
|
current.recordSelectionEvents(parsed)
|
||||||
|
current.submitDebugEvents()
|
||||||
|
var outcome Outcome
|
||||||
|
path := parsed.Path
|
||||||
|
handlerRan := false
|
||||||
if parsed.Failure != nil {
|
if parsed.Failure != nil {
|
||||||
return current.present(Usage(errors.New(parsed.Failure.Message)), parsed.Path)
|
outcome = Usage(parsed.Failure.Message)
|
||||||
}
|
} else if current.route.kind == routeDiscovery {
|
||||||
payload, ok := routedSnapshot.runners[parsed.Key]
|
if parsed.HelpRequested {
|
||||||
if !ok || payload == nil {
|
outcome = current.renderHelp(parsed.Path)
|
||||||
return current.present(current.renderHelp(parsed.Path), parsed.Path)
|
path = clonePath(current.route.enteredPath)
|
||||||
}
|
} else {
|
||||||
if parsed.HelpRequested || !payload.hasRun() {
|
entered := current.route.enteredPath.String()
|
||||||
if _, err := payload.prepare(parsed.FlagValues, parsed.Operands, false); err != nil {
|
outcome = Usage(fmt.Sprintf("incomplete command path %q; use %q --help", entered, entered))
|
||||||
return current.present(Usage(err), parsed.Path)
|
path = clonePath(current.route.enteredPath)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
payload, ok := routedSnapshot.runners[parsed.Key]
|
||||||
|
if !ok || payload == nil {
|
||||||
|
outcome = current.renderHelp(parsed.Path)
|
||||||
|
} else if parsed.HelpRequested || !payload.hasRun() {
|
||||||
|
if _, err := payload.prepare(parsed.FlagValues, parsed.FlagSpellings, parsed.FlagOccurrences, parsed.Operands, false); err != nil {
|
||||||
|
outcome = classifyInputError(err)
|
||||||
|
} else {
|
||||||
|
outcome = current.renderHelp(parsed.Path)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
run, err := payload.prepare(parsed.FlagValues, parsed.FlagSpellings, parsed.FlagOccurrences, parsed.Operands, true)
|
||||||
|
if err != nil {
|
||||||
|
outcome = classifyInputError(err)
|
||||||
|
} else {
|
||||||
|
handlerRan = true
|
||||||
|
outcome = run(current)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return current.present(current.renderHelp(parsed.Path), parsed.Path)
|
|
||||||
}
|
}
|
||||||
run, err := payload.prepare(parsed.FlagValues, parsed.Operands, true)
|
return current.finalize(outcome, path, handlerRan)
|
||||||
if err != nil {
|
|
||||||
return current.present(Usage(err), parsed.Path)
|
|
||||||
}
|
|
||||||
return current.present(run(current), parsed.Path)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type execution struct {
|
type execution struct {
|
||||||
app *App
|
app *App
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
streams Streams
|
streams Streams
|
||||||
options commandmeta.ParseOptions
|
reporter *Reporter
|
||||||
snapshot *routingSnapshot
|
debugEvents []string
|
||||||
session *internalcobra.Session
|
options commandmeta.ParseOptions
|
||||||
|
snapshot *routingSnapshot
|
||||||
|
session *internalcobra.Session
|
||||||
|
route routeResult
|
||||||
}
|
}
|
||||||
|
|
||||||
type commandExecution struct {
|
type commandExecution struct {
|
||||||
@@ -60,25 +96,36 @@ type commandExecution struct {
|
|||||||
|
|
||||||
func (current *execution) commandContext() commandExecution {
|
func (current *execution) commandContext() commandExecution {
|
||||||
return commandExecution{
|
return commandExecution{
|
||||||
invocation: Invocation{Context: current.ctx, Streams: current.streams},
|
invocation: current.invocation(),
|
||||||
renderHelpTargetsFunc: current.renderHelpTargets,
|
renderHelpTargetsFunc: current.renderHelpTargets,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (current *execution) invocation() Invocation {
|
||||||
|
return Invocation{
|
||||||
|
Context: current.ctx,
|
||||||
|
reporter: current.reporter,
|
||||||
|
enteredPath: current.route.enteredPath.String(),
|
||||||
|
canonicalPath: current.route.canonicalPath.String(),
|
||||||
|
renderHelp: func() Outcome { return current.renderHelp(nil) },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (current commandExecution) renderHelpTargets(targets []string) Outcome {
|
func (current commandExecution) renderHelpTargets(targets []string) Outcome {
|
||||||
return current.renderHelpTargetsFunc(targets)
|
return current.renderHelpTargetsFunc(targets)
|
||||||
}
|
}
|
||||||
|
|
||||||
func newExecution(app *App, ctx context.Context, streams Streams) *execution {
|
func newExecution(app *App, ctx context.Context, streams Streams) *execution {
|
||||||
|
streams = normalizeStreams(streams)
|
||||||
current := &execution{
|
current := &execution{
|
||||||
app: app,
|
app: app,
|
||||||
ctx: ctx,
|
ctx: ctx,
|
||||||
streams: streams,
|
streams: streams,
|
||||||
options: commandmeta.ParseOptions{Context: ctx, Out: streams.Out, Err: streams.Err},
|
reporter: newReporter(streams),
|
||||||
|
options: commandmeta.ParseOptions{Context: ctx, Out: streams.Out, Err: streams.Err},
|
||||||
}
|
}
|
||||||
if app != nil {
|
if app != nil {
|
||||||
current.snapshot = app.routeSnapshot()
|
current.snapshot = app.routeSnapshot()
|
||||||
current.session = internalcobra.Prepare(current.snapshot.metadata, current.options)
|
|
||||||
}
|
}
|
||||||
return current
|
return current
|
||||||
}
|
}
|
||||||
@@ -87,7 +134,34 @@ func (current *execution) parse(args []string) commandmeta.Result {
|
|||||||
if current.app == nil {
|
if current.app == nil {
|
||||||
return commandmeta.Result{Failure: &commandmeta.Failure{Message: "cannot parse commands on a nil app"}}
|
return commandmeta.Result{Failure: &commandmeta.Failure{Message: "cannot parse commands on a nil app"}}
|
||||||
}
|
}
|
||||||
return current.session.Parse(args)
|
if current.session == nil {
|
||||||
|
current.prepareSession()
|
||||||
|
}
|
||||||
|
return reduceReportingOccurrences(current.session.Parse(args))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (current *execution) prepareSession() {
|
||||||
|
if current.app == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if current.snapshot == nil {
|
||||||
|
current.snapshot = current.app.routeSnapshot()
|
||||||
|
}
|
||||||
|
options := current.options
|
||||||
|
if current.snapshot != nil {
|
||||||
|
switch current.route.kind {
|
||||||
|
case routeCanonical:
|
||||||
|
options.FlagAliases = current.snapshot.flagAliases[current.route.canonicalPath.Key()].neutralRecords()
|
||||||
|
case routeDiscovery:
|
||||||
|
options.FlagAliases = current.snapshot.discoveryFlags[current.route.discoveryPath.Key()].neutralRecords()
|
||||||
|
default:
|
||||||
|
snapshot := current.snapshot
|
||||||
|
options.FlagAliasesForPath = func(path []string) []commandmeta.FlagAlias {
|
||||||
|
return snapshot.flagAliases[commandpath.FromTokens(path).Key()].neutralRecords()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
current.session = internalcobra.Prepare(current.snapshot.metadata, options)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (current *execution) presentationSession() *internalcobra.Session {
|
func (current *execution) presentationSession() *internalcobra.Session {
|
||||||
@@ -97,23 +171,50 @@ func (current *execution) presentationSession() *internalcobra.Session {
|
|||||||
snapshot := current.app.routeSnapshot()
|
snapshot := current.app.routeSnapshot()
|
||||||
if current.session == nil || current.snapshot != snapshot {
|
if current.session == nil || current.snapshot != snapshot {
|
||||||
current.snapshot = snapshot
|
current.snapshot = snapshot
|
||||||
current.session = internalcobra.Prepare(snapshot.metadata, current.options)
|
current.session = nil
|
||||||
|
current.prepareSession()
|
||||||
}
|
}
|
||||||
return current.session
|
return current.session
|
||||||
}
|
}
|
||||||
|
|
||||||
func (current *execution) renderHelp(path []string) Outcome {
|
func (current *execution) renderHelp(path []string) Outcome {
|
||||||
if current.app == nil {
|
if current.app == nil {
|
||||||
return Failure(errors.New("cannot render help on a nil app"))
|
err := errors.New("cannot render help on a nil app")
|
||||||
|
return Failure(err.Error()).WithCause(err)
|
||||||
}
|
}
|
||||||
return current.app.renderHelpOutcomeWithSession(path, current.streams, current.presentationSession())
|
session := current.presentationSession()
|
||||||
|
view := current.app.helpView(current.route, current.snapshot)
|
||||||
|
return current.app.renderHelpViewOutcomeWithSession(view, current.reporter, session)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (current *execution) renderHelpTargets(targets []string) Outcome {
|
func (current *execution) renderHelpTargets(targets []string) Outcome {
|
||||||
if current.app == nil {
|
if current.app == nil {
|
||||||
return Failure(errors.New("cannot render help on a nil app"))
|
err := errors.New("cannot render help on a nil app")
|
||||||
|
return Failure(err.Error()).WithCause(err)
|
||||||
}
|
}
|
||||||
return current.app.renderHelpTargetsWithSession(targets, current.streams, current.presentationSession())
|
snapshot := current.app.routeSnapshot()
|
||||||
|
if snapshot == nil {
|
||||||
|
err := errors.New("cannot render help without command metadata")
|
||||||
|
return Failure(err.Error()).WithCause(err)
|
||||||
|
}
|
||||||
|
route := resolveEnteredRoute(targets, snapshot)
|
||||||
|
// Explicit help targets are paths, not command invocations with operands.
|
||||||
|
// Discovery routes have no canonical path and retain only trailing options.
|
||||||
|
if route.kind == routeUnknown || len(route.args) != len(route.canonicalPath) {
|
||||||
|
session := internalcobra.Prepare(snapshot.metadata, current.options)
|
||||||
|
view := commandmeta.HelpView{EnteredPath: targets, CanonicalPath: targets}
|
||||||
|
return current.app.renderHelpViewOutcomeWithSession(view, current.reporter, session)
|
||||||
|
}
|
||||||
|
options := current.options
|
||||||
|
switch route.kind {
|
||||||
|
case routeCanonical:
|
||||||
|
options.FlagAliases = snapshot.flagAliases[route.canonicalPath.Key()].neutralRecords()
|
||||||
|
case routeDiscovery:
|
||||||
|
options.FlagAliases = snapshot.discoveryFlags[route.discoveryPath.Key()].neutralRecords()
|
||||||
|
}
|
||||||
|
session := internalcobra.Prepare(snapshot.metadata, options)
|
||||||
|
view := current.app.helpView(route, snapshot)
|
||||||
|
return current.app.renderHelpViewOutcomeWithSession(view, current.reporter, session)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (current *execution) usageLine(path []string) string {
|
func (current *execution) usageLine(path []string) string {
|
||||||
@@ -129,71 +230,133 @@ func (current *execution) usageLine(path []string) string {
|
|||||||
return current.app.usageFallback()
|
return current.app.usageFallback()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (current *execution) present(outcome Outcome, path []string) Outcome {
|
func (current *execution) routeUsageLine() string {
|
||||||
return current.app.presentWithUsageLine(outcome, path, current.streams, current.usageLine)
|
line := current.usageLine(clonePath(current.route.canonicalPath))
|
||||||
|
canonical := strings.TrimSpace(current.app.identity.Name + " " + current.route.canonicalPath.String())
|
||||||
|
entered := strings.TrimSpace(current.app.identity.Name + " " + current.route.enteredPath.String())
|
||||||
|
line = strings.Replace(line, "Usage: "+canonical, "Usage: "+entered, 1)
|
||||||
|
return line + "\nAlias for: " + canonical
|
||||||
}
|
}
|
||||||
|
|
||||||
func normalizeStreams(streams Streams) Streams {
|
func (current *execution) recordRouteEvents() {
|
||||||
if streams.Out == nil {
|
entered := current.route.enteredPath.String()
|
||||||
streams.Out = io.Discard
|
canonical := current.route.canonicalPath.String()
|
||||||
}
|
switch current.route.kind {
|
||||||
if streams.Err == nil {
|
case routeCanonical:
|
||||||
streams.Err = io.Discard
|
if entered == canonical {
|
||||||
}
|
if canonical == "" {
|
||||||
return streams
|
current.debugEvents = append(current.debugEvents, "selected the application root command")
|
||||||
}
|
} else {
|
||||||
|
current.debugEvents = append(current.debugEvents, fmt.Sprintf("selected command path %q", canonical))
|
||||||
func (a *App) present(outcome Outcome, path []string, streams Streams) Outcome {
|
}
|
||||||
return a.presentWithUsageLine(outcome, path, streams, a.usageLine)
|
} else {
|
||||||
}
|
current.debugEvents = append(current.debugEvents, fmt.Sprintf("resolved command alias %q to %q", entered, canonical))
|
||||||
|
|
||||||
func (a *App) presentWithUsageLine(outcome Outcome, path []string, streams Streams, usageLine func([]string) string) Outcome {
|
|
||||||
if outcome.Kind == "" && outcome.Status == 0 && outcome.Domain == "" &&
|
|
||||||
outcome.Cause == nil && outcome.DeliveryError == nil &&
|
|
||||||
!outcome.ReportIncomplete && len(outcome.Diagnostics) == 0 {
|
|
||||||
outcome.Kind = OutcomeSuccess
|
|
||||||
}
|
|
||||||
if outcome.DiagnosticDelivery == "" {
|
|
||||||
outcome.DiagnosticDelivery = DeliveryNone
|
|
||||||
}
|
|
||||||
streams = normalizeStreams(streams)
|
|
||||||
if outcome.DiagnosticDelivery == DeliveryComplete {
|
|
||||||
return outcome
|
|
||||||
}
|
|
||||||
diagnostics := append([]Diagnostic(nil), outcome.Diagnostics...)
|
|
||||||
if outcome.Cause != nil && !hasErrorDiagnostic(diagnostics) {
|
|
||||||
diagnostics = append(diagnostics, MessageDiagnostic(SeverityError, outcome.Cause.Error()))
|
|
||||||
}
|
|
||||||
if outcome.Kind == OutcomeUsage {
|
|
||||||
diagnostics = append(diagnostics, MessageDiagnostic(SeverityError, usageLine(path)))
|
|
||||||
}
|
|
||||||
outcome.Diagnostics = diagnostics
|
|
||||||
if len(diagnostics) == 0 {
|
|
||||||
return outcome
|
|
||||||
}
|
|
||||||
delivered := false
|
|
||||||
for _, diagnostic := range diagnostics {
|
|
||||||
if diagnostic.Write == nil {
|
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
delivered = true
|
case routeDiscovery:
|
||||||
writer := &checkedWriter{writer: streams.Err}
|
current.debugEvents = append(current.debugEvents, fmt.Sprintf("selected discovery route for prefix %q", entered))
|
||||||
writeErr := diagnostic.Write(writer)
|
default:
|
||||||
if writer.failure != nil {
|
if entered != "" {
|
||||||
if writeErr == nil {
|
current.debugEvents = append(current.debugEvents, fmt.Sprintf("no canonical route matched command path %q", entered))
|
||||||
writeErr = writer.failure
|
}
|
||||||
} else if !errors.Is(writeErr, writer.failure) {
|
}
|
||||||
writeErr = errors.Join(writeErr, writer.failure)
|
}
|
||||||
|
|
||||||
|
func (current *execution) recordSelectionEvents(result commandmeta.Result) {
|
||||||
|
mode := "ordinary"
|
||||||
|
flagName := ""
|
||||||
|
switch result.Reporting.Mode {
|
||||||
|
case commandmeta.ReportingModeQuiet:
|
||||||
|
mode, flagName = "quiet", "quiet"
|
||||||
|
case commandmeta.ReportingModeVerbose:
|
||||||
|
mode, flagName = "verbose", "verbose"
|
||||||
|
case commandmeta.ReportingModeDebug:
|
||||||
|
mode, flagName = "debug", "debug"
|
||||||
|
}
|
||||||
|
event := "selected ordinary reporting mode"
|
||||||
|
if flagName != "" {
|
||||||
|
event = fmt.Sprintf("selected %s reporting mode", mode)
|
||||||
|
for index := len(result.FlagOccurrences) - 1; index >= 0; index-- {
|
||||||
|
occurrence := result.FlagOccurrences[index]
|
||||||
|
if occurrence.Name != flagName {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
enabled, err := strconv.ParseBool(occurrence.RawValue)
|
||||||
|
if err != nil || !enabled {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
canonical := "--" + flagName
|
||||||
|
if occurrence.Spelling != canonical {
|
||||||
|
event += fmt.Sprintf(" using %s (alias for %s)", occurrence.Spelling, canonical)
|
||||||
|
} else {
|
||||||
|
event += fmt.Sprintf(" using %s", canonical)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
current.debugEvents = append(current.debugEvents, event)
|
||||||
|
if result.Reporting.ProgressSet {
|
||||||
|
state := "disabled"
|
||||||
|
if result.Reporting.Progress {
|
||||||
|
state = "enabled"
|
||||||
|
}
|
||||||
|
event := "explicitly " + state + " progress output"
|
||||||
|
for index := len(result.FlagOccurrences) - 1; index >= 0; index-- {
|
||||||
|
occurrence := result.FlagOccurrences[index]
|
||||||
|
if occurrence.Name == "progress" {
|
||||||
|
if occurrence.Spelling != "--progress" {
|
||||||
|
event += fmt.Sprintf(" using %s (alias for --progress)", occurrence.Spelling)
|
||||||
|
}
|
||||||
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if writeErr != nil {
|
current.debugEvents = append(current.debugEvents, event)
|
||||||
return deliveryFailure(outcome, writeErr)
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (current *execution) submitDebugEvents() {
|
||||||
|
events := current.debugEvents
|
||||||
|
current.debugEvents = nil
|
||||||
|
for _, event := range events {
|
||||||
|
_ = current.reporter.Debug(event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (current *execution) finalize(outcome Outcome, path []string, handlerRan bool) Outcome {
|
||||||
|
if outcome.Kind == OutcomeUsage {
|
||||||
|
details := append([]Detail(nil), outcome.Explanation.Details...)
|
||||||
|
details = append(details, func(w io.Writer) error {
|
||||||
|
line := current.usageText(path)
|
||||||
|
if !strings.HasSuffix(line, "\n") {
|
||||||
|
line += "\n"
|
||||||
|
}
|
||||||
|
_, err := io.WriteString(w, line)
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
outcome.Explanation.Details = details
|
||||||
|
}
|
||||||
|
var completion func(*Reporter)
|
||||||
|
if handlerRan {
|
||||||
|
completion = func(reporter *Reporter) {
|
||||||
|
kind := outcome.Kind
|
||||||
|
if kind == "" && outcome.Status == 0 && outcome.Domain == "" {
|
||||||
|
kind = OutcomeSuccess
|
||||||
|
}
|
||||||
|
path := current.route.canonicalPath.String()
|
||||||
|
_ = reporter.Debug(fmt.Sprintf("handler completed for %q with %s outcome and status %d", path, kind, outcome.Status))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if delivered {
|
return current.reporter.finalize(outcome, completion)
|
||||||
outcome.DiagnosticDelivery = DeliveryComplete
|
}
|
||||||
|
|
||||||
|
func (current *execution) usageText(path []string) string {
|
||||||
|
switch {
|
||||||
|
case current.route.kind == routeDiscovery && current.app != nil:
|
||||||
|
return fmt.Sprintf("Usage: %s %s [command]", current.app.identity.Name, current.route.enteredPath.String())
|
||||||
|
case current.route.kind == routeCanonical && !current.route.enteredPath.Equal(current.route.canonicalPath):
|
||||||
|
return current.routeUsageLine()
|
||||||
|
default:
|
||||||
|
return current.usageLine(path)
|
||||||
}
|
}
|
||||||
return outcome
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) usageLine(path []string) string {
|
func (a *App) usageLine(path []string) string {
|
||||||
@@ -212,42 +375,3 @@ func (a *App) usageFallback() string {
|
|||||||
}
|
}
|
||||||
return fmt.Sprintf("Usage: %s [command]", name)
|
return fmt.Sprintf("Usage: %s [command]", name)
|
||||||
}
|
}
|
||||||
|
|
||||||
func hasErrorDiagnostic(diagnostics []Diagnostic) bool {
|
|
||||||
for _, diagnostic := range diagnostics {
|
|
||||||
if diagnostic.Write != nil && diagnostic.Severity == SeverityError {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func deliveryFailure(outcome Outcome, err error) Outcome {
|
|
||||||
if outcome.DeliveryError == nil {
|
|
||||||
outcome.DeliveryError = err
|
|
||||||
} else {
|
|
||||||
outcome.DeliveryError = errors.Join(outcome.DeliveryError, err)
|
|
||||||
}
|
|
||||||
outcome.DiagnosticDelivery = DeliveryIncomplete
|
|
||||||
if outcome.Kind != OutcomeDomain {
|
|
||||||
outcome.Kind = OutcomeOperational
|
|
||||||
outcome.Status = 1
|
|
||||||
}
|
|
||||||
return outcome
|
|
||||||
}
|
|
||||||
|
|
||||||
type checkedWriter struct {
|
|
||||||
writer io.Writer
|
|
||||||
failure error
|
|
||||||
}
|
|
||||||
|
|
||||||
func (w *checkedWriter) Write(data []byte) (int, error) {
|
|
||||||
n, err := w.writer.Write(data)
|
|
||||||
if err == nil && n < len(data) {
|
|
||||||
err = io.ErrShortWrite
|
|
||||||
}
|
|
||||||
if err != nil && w.failure == nil {
|
|
||||||
w.failure = err
|
|
||||||
}
|
|
||||||
return n, err
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
commandkit "gitea.maximumdirect.net/eric/CommandKit"
|
commandkit "go.maximumdirect.net/commandkit"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestExecuteRunsTypedCommandsWithFreshInvocationState(t *testing.T) {
|
func TestExecuteRunsTypedCommandsWithFreshInvocationState(t *testing.T) {
|
||||||
@@ -19,7 +19,7 @@ func TestExecuteRunsTypedCommandsWithFreshInvocationState(t *testing.T) {
|
|||||||
}
|
}
|
||||||
var inputs []input
|
var inputs []input
|
||||||
var contexts []context.Context
|
var contexts []context.Context
|
||||||
var streams []commandkit.Streams
|
var reporters []*commandkit.Reporter
|
||||||
firstContext := context.WithValue(context.Background(), struct{}{}, "first")
|
firstContext := context.WithValue(context.Background(), struct{}{}, "first")
|
||||||
secondContext := context.WithValue(context.Background(), struct{}{}, "second")
|
secondContext := context.WithValue(context.Background(), struct{}{}, "second")
|
||||||
var firstOut, secondOut, firstDiagnostic, secondDiagnostic bytes.Buffer
|
var firstOut, secondOut, firstDiagnostic, secondDiagnostic bytes.Buffer
|
||||||
@@ -32,7 +32,12 @@ func TestExecuteRunsTypedCommandsWithFreshInvocationState(t *testing.T) {
|
|||||||
Run: func(invocation commandkit.Invocation, value input) commandkit.Outcome {
|
Run: func(invocation commandkit.Invocation, value input) commandkit.Outcome {
|
||||||
inputs = append(inputs, value)
|
inputs = append(inputs, value)
|
||||||
contexts = append(contexts, invocation.Context)
|
contexts = append(contexts, invocation.Context)
|
||||||
streams = append(streams, invocation.Streams)
|
reporter := invocation.Reporter()
|
||||||
|
reporters = append(reporters, reporter)
|
||||||
|
_ = reporter.RenderOutput(func(writer io.Writer) error {
|
||||||
|
_, err := io.WriteString(writer, value.name.Value+"\n")
|
||||||
|
return err
|
||||||
|
})
|
||||||
return commandkit.Success()
|
return commandkit.Success()
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -52,11 +57,11 @@ func TestExecuteRunsTypedCommandsWithFreshInvocationState(t *testing.T) {
|
|||||||
if inputs[1].name.Set || inputs[1].name.Value != "" || inputs[1].ids.Set {
|
if inputs[1].name.Set || inputs[1].name.Value != "" || inputs[1].ids.Set {
|
||||||
t.Fatalf("second invocation inherited state: %#v", inputs[1])
|
t.Fatalf("second invocation inherited state: %#v", inputs[1])
|
||||||
}
|
}
|
||||||
if streams[2].Out != io.Discard || streams[2].Err != io.Discard {
|
if reporters[0] == nil || reporters[1] == nil || reporters[2] == nil || reporters[0] == reporters[1] || reporters[0] == reporters[2] || reporters[1] == reporters[2] {
|
||||||
t.Fatalf("nil streams were not replaced with io.Discard: %#v", streams[2])
|
t.Fatalf("invocations did not receive fresh reporters: %#v", reporters)
|
||||||
}
|
}
|
||||||
if contexts[0] != firstContext || contexts[1] != secondContext || streams[0].Out != &firstOut || streams[0].Err != &firstDiagnostic || streams[1].Out != &secondOut || streams[1].Err != &secondDiagnostic {
|
if firstOut.String() != "first\n" || secondOut.String() != "\n" || contexts[0] != firstContext || contexts[1] != secondContext {
|
||||||
t.Fatalf("handler invocation contexts or streams were not forwarded independently")
|
t.Fatalf("handler invocation contexts or reporter destinations were not independent: out=%q/%q contexts=%v/%v", firstOut.String(), secondOut.String(), contexts[0], contexts[1])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,7 +70,7 @@ func TestOmittedInputFactoryUsesExactPointerZeroValue(t *testing.T) {
|
|||||||
Name: "run",
|
Name: "run",
|
||||||
Run: func(_ commandkit.Invocation, value *int) commandkit.Outcome {
|
Run: func(_ commandkit.Invocation, value *int) commandkit.Outcome {
|
||||||
if value != nil {
|
if value != nil {
|
||||||
return commandkit.Failure(errors.New("omitted input factory allocated a pointer"))
|
return commandkit.Failure("omitted input factory allocated a pointer")
|
||||||
}
|
}
|
||||||
return commandkit.Success()
|
return commandkit.Success()
|
||||||
},
|
},
|
||||||
@@ -136,9 +141,9 @@ func TestExecutePresentsHandlerUsageAfterRegisteringCommand(t *testing.T) {
|
|||||||
Run: func(commandkit.Invocation, struct{}) commandkit.Outcome {
|
Run: func(commandkit.Invocation, struct{}) commandkit.Outcome {
|
||||||
calls++
|
calls++
|
||||||
if err := app.Register(late); err != nil {
|
if err := app.Register(late); err != nil {
|
||||||
return commandkit.Failure(err)
|
return commandkit.Failure(err.Error()).WithCause(err)
|
||||||
}
|
}
|
||||||
return commandkit.Usage(usageCause)
|
return commandkit.Usage("The handler requests usage.").WithCause(usageCause)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
app, err := commandkit.New(commandkit.Identity{Name: "example"}, command)
|
app, err := commandkit.New(commandkit.Identity{Name: "example"}, command)
|
||||||
@@ -195,12 +200,11 @@ func TestExecuteReturnsUsageForParsingRequirementsAndInputChecks(t *testing.T) {
|
|||||||
|
|
||||||
func TestExecutePreservesDomainOutcomesAndWarningDiagnostics(t *testing.T) {
|
func TestExecutePreservesDomainOutcomesAndWarningDiagnostics(t *testing.T) {
|
||||||
domainCause := errors.New("domain rejected the request")
|
domainCause := errors.New("domain rejected the request")
|
||||||
warning := commandkit.MessageDiagnostic(commandkit.SeverityWarning, "review needed")
|
|
||||||
command := commandkit.NewCommand(commandkit.CommandSpec[struct{}]{
|
command := commandkit.NewCommand(commandkit.CommandSpec[struct{}]{
|
||||||
Name: "run",
|
Name: "run",
|
||||||
Run: func(commandkit.Invocation, struct{}) commandkit.Outcome {
|
Run: func(invocation commandkit.Invocation, _ struct{}) commandkit.Outcome {
|
||||||
outcome := commandkit.Domain("sample", 2, domainCause)
|
_ = invocation.Reporter().Warn("review needed")
|
||||||
outcome.Diagnostics = []commandkit.Diagnostic{warning}
|
outcome := commandkit.Domain("sample", 2).WithExplanation("The request was rejected.").WithCause(domainCause)
|
||||||
return outcome
|
return outcome
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -209,19 +213,18 @@ func TestExecutePreservesDomainOutcomesAndWarningDiagnostics(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
var diagnostic bytes.Buffer
|
var diagnostic bytes.Buffer
|
||||||
outcome := app.Execute(context.Background(), []string{"run"}, commandkit.Streams{Err: &diagnostic})
|
outcome := app.Execute(context.Background(), []string{"run", "--verbose"}, commandkit.Streams{Err: &diagnostic})
|
||||||
if outcome.Kind != commandkit.OutcomeDomain || outcome.Domain != "sample" || outcome.Status != 2 || !errors.Is(outcome.Cause, domainCause) {
|
if outcome.Kind != commandkit.OutcomeDomain || outcome.Domain != "sample" || outcome.Status != 2 || !errors.Is(outcome.Cause, domainCause) {
|
||||||
t.Fatalf("domain outcome changed: %#v", outcome)
|
t.Fatalf("domain outcome changed: %#v", outcome)
|
||||||
}
|
}
|
||||||
if diagnostic.String() != "review needed\ndomain rejected the request\n" || outcome.DiagnosticDelivery != commandkit.DeliveryComplete {
|
if diagnostic.String() != "WARN: review needed\nERROR: The request was rejected.\n" || outcome.Presentation.Diagnostics.State != commandkit.DeliveryComplete {
|
||||||
t.Fatalf("warning delivery = %q, %#v", diagnostic.String(), outcome)
|
t.Fatalf("warning delivery = %q, %#v", diagnostic.String(), outcome)
|
||||||
}
|
}
|
||||||
warningOnly := commandkit.NewCommand(commandkit.CommandSpec[struct{}]{
|
warningOnly := commandkit.NewCommand(commandkit.CommandSpec[struct{}]{
|
||||||
Name: "warn",
|
Name: "warn",
|
||||||
Run: func(commandkit.Invocation, struct{}) commandkit.Outcome {
|
Run: func(invocation commandkit.Invocation, _ struct{}) commandkit.Outcome {
|
||||||
result := commandkit.Success()
|
_ = invocation.Reporter().Warn("review needed")
|
||||||
result.Diagnostics = []commandkit.Diagnostic{warning}
|
return commandkit.Success()
|
||||||
return result
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
app, err = commandkit.New(commandkit.Identity{Name: "example"}, warningOnly)
|
app, err = commandkit.New(commandkit.Identity{Name: "example"}, warningOnly)
|
||||||
@@ -229,23 +232,18 @@ func TestExecutePreservesDomainOutcomesAndWarningDiagnostics(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
diagnostic.Reset()
|
diagnostic.Reset()
|
||||||
outcome = app.Execute(context.Background(), []string{"warn"}, commandkit.Streams{Err: &diagnostic})
|
outcome = app.Execute(context.Background(), []string{"warn", "--verbose"}, commandkit.Streams{Err: &diagnostic})
|
||||||
if outcome.Kind != commandkit.OutcomeSuccess || diagnostic.String() != "review needed\n" {
|
if outcome.Kind != commandkit.OutcomeSuccess || diagnostic.String() != "WARN: review needed\n" {
|
||||||
t.Fatalf("warning-only outcome = %#v, diagnostics %q", outcome, diagnostic.String())
|
t.Fatalf("warning-only outcome = %#v, diagnostics %q", outcome, diagnostic.String())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestExecuteDoesNotRepeatCompletedDiagnosticsOrPresentedCauses(t *testing.T) {
|
func TestExecuteDoesNotPresentOperationCauseWithoutExplanation(t *testing.T) {
|
||||||
cause := errors.New("already shown")
|
cause := errors.New("private operation detail")
|
||||||
command := commandkit.NewCommand(commandkit.CommandSpec[struct{}]{
|
command := commandkit.NewCommand(commandkit.CommandSpec[struct{}]{
|
||||||
Name: "run",
|
Name: "run",
|
||||||
Run: func(commandkit.Invocation, struct{}) commandkit.Outcome {
|
Run: func(commandkit.Invocation, struct{}) commandkit.Outcome {
|
||||||
outcome := commandkit.Domain("sample", 2, cause)
|
return commandkit.Domain("sample", 2).WithCause(cause)
|
||||||
outcome.Diagnostics = []commandkit.Diagnostic{{Severity: commandkit.SeverityError, Write: func(writer io.Writer) error {
|
|
||||||
_, err := io.WriteString(writer, "presented\n")
|
|
||||||
return err
|
|
||||||
}}}
|
|
||||||
return outcome
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
app, err := commandkit.New(commandkit.Identity{Name: "example"}, command)
|
app, err := commandkit.New(commandkit.Identity{Name: "example"}, command)
|
||||||
@@ -254,21 +252,11 @@ func TestExecuteDoesNotRepeatCompletedDiagnosticsOrPresentedCauses(t *testing.T)
|
|||||||
}
|
}
|
||||||
var diagnostic bytes.Buffer
|
var diagnostic bytes.Buffer
|
||||||
outcome := app.Execute(context.Background(), []string{"run"}, commandkit.Streams{Err: &diagnostic})
|
outcome := app.Execute(context.Background(), []string{"run"}, commandkit.Streams{Err: &diagnostic})
|
||||||
if outcome.Kind != commandkit.OutcomeDomain || diagnostic.String() != "presented\n" {
|
if outcome.Kind != commandkit.OutcomeDomain || !errors.Is(outcome.Cause, cause) {
|
||||||
t.Fatalf("presented cause was duplicated: %#v, %q", outcome, diagnostic.String())
|
t.Fatalf("domain outcome changed: %#v", outcome)
|
||||||
}
|
}
|
||||||
|
if strings.Contains(diagnostic.String(), cause.Error()) || !strings.Contains(diagnostic.String(), "ERROR: Command failed.") {
|
||||||
diagnostic.Reset()
|
t.Fatalf("cause was presented instead of the fallback explanation: %#v, %q", outcome, diagnostic.String())
|
||||||
completed := commandkit.Domain("sample", 2, cause)
|
|
||||||
completed.DiagnosticDelivery = commandkit.DeliveryComplete
|
|
||||||
command = commandkit.NewCommand(commandkit.CommandSpec[struct{}]{Name: "done", Run: func(commandkit.Invocation, struct{}) commandkit.Outcome { return completed }})
|
|
||||||
app, err = commandkit.New(commandkit.Identity{Name: "example"}, command)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
outcome = app.Execute(context.Background(), []string{"done"}, commandkit.Streams{Err: &diagnostic})
|
|
||||||
if diagnostic.Len() != 0 || outcome.DiagnosticDelivery != commandkit.DeliveryComplete {
|
|
||||||
t.Fatalf("completed diagnostics were repeated: %#v, %q", outcome, diagnostic.String())
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -278,9 +266,7 @@ func TestExecuteRetainsOperationAndDiagnosticDeliveryFailures(t *testing.T) {
|
|||||||
command := commandkit.NewCommand(commandkit.CommandSpec[struct{}]{
|
command := commandkit.NewCommand(commandkit.CommandSpec[struct{}]{
|
||||||
Name: "run",
|
Name: "run",
|
||||||
Run: func(commandkit.Invocation, struct{}) commandkit.Outcome {
|
Run: func(commandkit.Invocation, struct{}) commandkit.Outcome {
|
||||||
outcome := commandkit.Failure(operationFailure)
|
return commandkit.Failure("The operation was canceled.").WithCause(operationFailure)
|
||||||
outcome.ReportIncomplete = true
|
|
||||||
return outcome
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
app, err := commandkit.New(commandkit.Identity{Name: "example"}, command)
|
app, err := commandkit.New(commandkit.Identity{Name: "example"}, command)
|
||||||
@@ -288,12 +274,12 @@ func TestExecuteRetainsOperationAndDiagnosticDeliveryFailures(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
outcome := app.Execute(context.Background(), []string{"run"}, commandkit.Streams{Err: failingWriter{err: writeFailure}})
|
outcome := app.Execute(context.Background(), []string{"run"}, commandkit.Streams{Err: failingWriter{err: writeFailure}})
|
||||||
if outcome.Kind != commandkit.OutcomeOperational || outcome.Status != 1 || outcome.DiagnosticDelivery != commandkit.DeliveryIncomplete || !outcome.ReportIncomplete || !errors.Is(outcome.Cause, operationFailure) || !errors.Is(outcome.DeliveryError, writeFailure) {
|
if outcome.Kind != commandkit.OutcomeOperational || outcome.Status != 1 || outcome.Presentation.Diagnostics.State != commandkit.DeliveryIncomplete || !errors.Is(outcome.Cause, operationFailure) || !errors.Is(outcome.Presentation.Diagnostics.WriteError, writeFailure) {
|
||||||
t.Fatalf("combined operation and delivery failure = %#v", outcome)
|
t.Fatalf("combined operation and delivery failure = %#v", outcome)
|
||||||
}
|
}
|
||||||
|
|
||||||
short := app.Execute(context.Background(), []string{"run"}, commandkit.Streams{Err: shortWriter{}})
|
short := app.Execute(context.Background(), []string{"run"}, commandkit.Streams{Err: shortWriter{}})
|
||||||
if !errors.Is(short.DeliveryError, io.ErrShortWrite) || short.DiagnosticDelivery != commandkit.DeliveryIncomplete {
|
if !errors.Is(short.Presentation.Diagnostics.WriteError, io.ErrShortWrite) || short.Presentation.Diagnostics.State != commandkit.DeliveryIncomplete {
|
||||||
t.Fatalf("short write was not detected: %#v", short)
|
t.Fatalf("short write was not detected: %#v", short)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -339,7 +325,7 @@ func TestExecuteRunsApplicationOwnedNestedHelp(t *testing.T) {
|
|||||||
)},
|
)},
|
||||||
Run: func(_ commandkit.Invocation, value input) commandkit.Outcome {
|
Run: func(_ commandkit.Invocation, value input) commandkit.Outcome {
|
||||||
received = append(received, value)
|
received = append(received, value)
|
||||||
return commandkit.Domain("session-help", 7, domainCause)
|
return commandkit.Domain("session-help", 7).WithExplanation("Session help is unavailable.").WithCause(domainCause)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
session := commandkit.Group("session", "Session commands.", help)
|
session := commandkit.Group("session", "Session commands.", help)
|
||||||
@@ -463,7 +449,7 @@ func TestHelpRendersRegisteredMetadataAndResolvesExactPaths(t *testing.T) {
|
|||||||
t.Fatalf("version invoked an application command: %#v calls=%d/%d", versionOutcome, factoryCalls, runCalls)
|
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})
|
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) {
|
if failed.Kind != commandkit.OutcomeOperational || failed.Presentation.Output.State != commandkit.DeliveryIncomplete || !errors.Is(failed.Presentation.Output.WriteError, io.ErrShortWrite) {
|
||||||
t.Fatalf("failed help delivery = %#v", failed)
|
t.Fatalf("failed help delivery = %#v", failed)
|
||||||
}
|
}
|
||||||
out.Reset()
|
out.Reset()
|
||||||
@@ -489,10 +475,15 @@ func TestVersionCommandFormatsApplicationAndCommandKitIdentity(t *testing.T) {
|
|||||||
}
|
}
|
||||||
out.Reset()
|
out.Reset()
|
||||||
yamlOutcome := app.Execute(context.Background(), []string{"version", "--format=yaml"}, commandkit.Streams{Out: &out, Err: &diagnostic})
|
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" {
|
if yamlOutcome.Kind != commandkit.OutcomeSuccess || !strings.Contains(out.String(), "name: ExampleApp\n") || !strings.Contains(out.String(), "version: dev\n") || !strings.Contains(out.String(), "revision: abc123\n") {
|
||||||
t.Fatalf("YAML version = %#v, output %q", yamlOutcome, out.String())
|
t.Fatalf("YAML version = %#v, output %q", yamlOutcome, out.String())
|
||||||
}
|
}
|
||||||
out.Reset()
|
out.Reset()
|
||||||
|
jsonOutcome := app.Execute(context.Background(), []string{"version", "--format=json"}, commandkit.Streams{Out: &out, Err: &diagnostic})
|
||||||
|
if jsonOutcome.Kind != commandkit.OutcomeSuccess || !strings.Contains(out.String(), `"revision": "abc123"`) {
|
||||||
|
t.Fatalf("JSON version = %#v, output %q", jsonOutcome, out.String())
|
||||||
|
}
|
||||||
|
out.Reset()
|
||||||
component := app.Execute(context.Background(), []string{"version", "--scope=commandkit"}, commandkit.Streams{Out: &out, Err: &diagnostic})
|
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") {
|
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())
|
t.Fatalf("CommandKit version = %#v, output %q", component, out.String())
|
||||||
@@ -503,7 +494,7 @@ func TestVersionCommandFormatsApplicationAndCommandKitIdentity(t *testing.T) {
|
|||||||
t.Fatalf("invalid version scope = %#v, output %q", invalid, out.String())
|
t.Fatalf("invalid version scope = %#v, output %q", invalid, out.String())
|
||||||
}
|
}
|
||||||
failed := app.Execute(context.Background(), []string{"version"}, commandkit.Streams{Out: shortWriter{}, Err: &diagnostic})
|
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) {
|
if failed.Kind != commandkit.OutcomeOperational || failed.Presentation.Output.State != commandkit.DeliveryIncomplete || !errors.Is(failed.Presentation.Output.WriteError, io.ErrShortWrite) {
|
||||||
t.Fatalf("failed version delivery = %#v", failed)
|
t.Fatalf("failed version delivery = %#v", failed)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -532,11 +523,11 @@ func TestHelpEntryFormsPresentReportAndDiagnosticFailures(t *testing.T) {
|
|||||||
for _, args := range [][]string{nil, {"notes"}, {"--help"}, {"-h"}, {"notes", "--help"}, {"help"}, {"help", "notes"}} {
|
for _, args := range [][]string{nil, {"notes"}, {"--help"}, {"-h"}, {"notes", "--help"}, {"help"}, {"help", "notes"}} {
|
||||||
var diagnostics bytes.Buffer
|
var diagnostics bytes.Buffer
|
||||||
result := app.Execute(context.Background(), args, commandkit.Streams{Out: failingWriter{reportErr}, Err: &diagnostics})
|
result := app.Execute(context.Background(), args, commandkit.Streams{Out: failingWriter{reportErr}, Err: &diagnostics})
|
||||||
if result.Kind != commandkit.OutcomeOperational || !result.ReportIncomplete || !errors.Is(result.Cause, reportErr) || !errors.Is(result.DeliveryError, reportErr) || result.DiagnosticDelivery != commandkit.DeliveryComplete || strings.Count(diagnostics.String(), reportErr.Error()) != 1 {
|
if result.Kind != commandkit.OutcomeOperational || result.Presentation.Output.State != commandkit.DeliveryIncomplete || !errors.Is(result.Presentation.Output.WriteError, reportErr) || result.Presentation.Diagnostics.State != commandkit.DeliveryComplete || (!strings.Contains(diagnostics.String(), "deliver") || !strings.Contains(diagnostics.String(), "output")) {
|
||||||
t.Fatalf("%v: result=%#v diagnostics=%q", args, result, diagnostics.String())
|
t.Fatalf("%v: result=%#v diagnostics=%q", args, result, diagnostics.String())
|
||||||
}
|
}
|
||||||
result = app.Execute(context.Background(), args, commandkit.Streams{Out: failingWriter{reportErr}, Err: failingWriter{diagnosticErr}})
|
result = app.Execute(context.Background(), args, commandkit.Streams{Out: failingWriter{reportErr}, Err: failingWriter{diagnosticErr}})
|
||||||
if result.DiagnosticDelivery != commandkit.DeliveryIncomplete || !errors.Is(result.Cause, reportErr) || !errors.Is(result.DeliveryError, reportErr) || !errors.Is(result.DeliveryError, diagnosticErr) {
|
if result.Presentation.Output.State != commandkit.DeliveryIncomplete || !errors.Is(result.Presentation.Output.WriteError, reportErr) || result.Presentation.Diagnostics.State != commandkit.DeliveryIncomplete || !errors.Is(result.Presentation.Diagnostics.WriteError, diagnosticErr) {
|
||||||
t.Fatalf("%v: result=%#v", args, result)
|
t.Fatalf("%v: result=%#v", args, result)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -560,46 +551,34 @@ func TestExecuteNormalizesOutcomeDefaultsAndPreservesDomainValues(t *testing.T)
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
result := app.Execute(context.Background(), []string{"run"}, commandkit.Streams{})
|
var diagnostic bytes.Buffer
|
||||||
wantDelivery := commandkit.DeliveryNone
|
result := app.Execute(context.Background(), []string{"run"}, commandkit.Streams{Err: &diagnostic})
|
||||||
if tc.cause != nil {
|
if result.Kind != tc.kind || result.Status != tc.status || result.Domain != tc.domain || result.Cause != tc.cause {
|
||||||
wantDelivery = commandkit.DeliveryComplete
|
|
||||||
}
|
|
||||||
if result.Kind != tc.kind || result.Status != tc.status || result.Domain != tc.domain || result.Cause != tc.cause || result.DiagnosticDelivery != wantDelivery {
|
|
||||||
t.Fatalf("result=%#v expected=%#v", result, tc)
|
t.Fatalf("result=%#v expected=%#v", result, tc)
|
||||||
}
|
}
|
||||||
|
if tc.status != 0 && !strings.Contains(diagnostic.String(), "ERROR: Command failed.") {
|
||||||
|
t.Fatalf("failed outcome omitted its fallback explanation: %q", diagnostic.String())
|
||||||
|
}
|
||||||
|
if tc.cause != nil && strings.Contains(diagnostic.String(), tc.cause.Error()) {
|
||||||
|
t.Fatalf("operation cause was presented automatically: %q", diagnostic.String())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestNilDiagnosticWritersHaveNoOutputAndDoNotHideCauses(t *testing.T) {
|
func TestNilDiagnosticWriterDoesNotHideOperationCause(t *testing.T) {
|
||||||
cause := errors.New("operation failed")
|
cause := errors.New("operation failed")
|
||||||
for _, base := range []commandkit.Outcome{commandkit.Success(), commandkit.Domain("needs action", 2, nil), commandkit.Failure(cause)} {
|
command := commandkit.NewCommand(commandkit.CommandSpec[struct{}]{
|
||||||
base.Diagnostics = []commandkit.Diagnostic{{}, {Severity: commandkit.SeverityWarning}, {Severity: commandkit.SeverityError}}
|
Name: "run",
|
||||||
command := commandkit.NewCommand(commandkit.CommandSpec[struct{}]{Name: "run", Run: func(commandkit.Invocation, struct{}) commandkit.Outcome { return base }})
|
Run: func(commandkit.Invocation, struct{}) commandkit.Outcome {
|
||||||
app, err := commandkit.New(commandkit.Identity{Name: "example"}, command)
|
return commandkit.Failure("The operation failed.").WithCause(cause)
|
||||||
if err != nil {
|
},
|
||||||
t.Fatal(err)
|
})
|
||||||
}
|
app, err := commandkit.New(commandkit.Identity{Name: "example"}, command)
|
||||||
var diagnostics bytes.Buffer
|
if err != nil {
|
||||||
result := app.Execute(context.Background(), []string{"run"}, commandkit.Streams{Err: &diagnostics})
|
t.Fatal(err)
|
||||||
want := commandkit.DeliveryNone
|
}
|
||||||
if base.Cause != nil {
|
result := app.Execute(context.Background(), []string{"run"}, commandkit.Streams{})
|
||||||
want = commandkit.DeliveryComplete
|
if result.Kind != commandkit.OutcomeOperational || !errors.Is(result.Cause, cause) || result.Presentation.Diagnostics.State != commandkit.DeliveryComplete {
|
||||||
}
|
t.Fatalf("nil diagnostic destination changed the outcome: %#v", result)
|
||||||
if result.Kind != base.Kind || result.Status != base.Status || result.Domain != base.Domain || result.Cause != base.Cause || result.DeliveryError != nil || result.DiagnosticDelivery != want {
|
|
||||||
t.Fatalf("result=%#v base=%#v", result, base)
|
|
||||||
}
|
|
||||||
if base.Cause == nil && diagnostics.Len() != 0 {
|
|
||||||
t.Fatalf("unexpected output=%q", diagnostics.String())
|
|
||||||
}
|
|
||||||
if base.Cause != nil && strings.Count(diagnostics.String(), cause.Error()) != 1 {
|
|
||||||
t.Fatalf("cause output=%q", diagnostics.String())
|
|
||||||
}
|
|
||||||
if base.Cause != nil {
|
|
||||||
result = app.Execute(context.Background(), []string{"run"}, commandkit.Streams{Err: failingWriter{io.ErrClosedPipe}})
|
|
||||||
if !errors.Is(result.Cause, cause) || !errors.Is(result.DeliveryError, io.ErrClosedPipe) || result.DiagnosticDelivery != commandkit.DeliveryIncomplete {
|
|
||||||
t.Fatalf("failed delivery=%#v", result)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
189
execute_reporting_test.go
Normal file
189
execute_reporting_test.go
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
package commandkit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestExecuteGivesEachInvocationItsReporterAndAppliesSelection(t *testing.T) {
|
||||||
|
var reporters []*Reporter
|
||||||
|
command := NewCommand(CommandSpec[struct{}]{
|
||||||
|
Name: "run",
|
||||||
|
Run: func(inv Invocation, _ struct{}) Outcome {
|
||||||
|
reporter := inv.Reporter()
|
||||||
|
reporters = append(reporters, reporter)
|
||||||
|
if _, err := fmt.Fprint(reporter.Output(), "result\n"); err != nil {
|
||||||
|
t.Errorf("write result: %v", err)
|
||||||
|
}
|
||||||
|
if err := reporter.Warn("attention"); err != nil {
|
||||||
|
t.Errorf("submit warning: %v", err)
|
||||||
|
}
|
||||||
|
return Outcome{
|
||||||
|
Kind: OutcomeDomain, Domain: "accepted", Status: 0,
|
||||||
|
Presentation: PresentationResult{
|
||||||
|
Output: DeliveryResult{State: DeliveryIncomplete, WriteError: io.ErrClosedPipe},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
app, err := New(Identity{Name: "Example"}, command)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var quietOut, quietErr bytes.Buffer
|
||||||
|
quiet := app.Execute(context.Background(), []string{"run", "--quiet"}, Streams{Out: &quietOut, Err: &quietErr})
|
||||||
|
if quiet.Kind != OutcomeDomain || quiet.Status != 0 || quiet.Domain != "accepted" {
|
||||||
|
t.Fatalf("quiet outcome = %#v", quiet)
|
||||||
|
}
|
||||||
|
if quietOut.Len() != 0 || quietErr.Len() != 0 {
|
||||||
|
t.Fatalf("quiet output = %q, diagnostics = %q", quietOut.String(), quietErr.String())
|
||||||
|
}
|
||||||
|
if quiet.Presentation.Output.State != DeliverySuppressed || quiet.Presentation.Diagnostics.State != DeliverySuppressed {
|
||||||
|
t.Fatalf("quiet presentation = %#v", quiet.Presentation)
|
||||||
|
}
|
||||||
|
|
||||||
|
var verboseOut, verboseErr bytes.Buffer
|
||||||
|
verbose := app.Execute(context.Background(), []string{"run", "--verbose"}, Streams{Out: &verboseOut, Err: &verboseErr})
|
||||||
|
if verbose.Kind != OutcomeDomain || verbose.Status != 0 {
|
||||||
|
t.Fatalf("verbose outcome = %#v", verbose)
|
||||||
|
}
|
||||||
|
if verboseOut.String() != "result\n" || verboseErr.String() != "WARN: attention\n" {
|
||||||
|
t.Fatalf("verbose output = %q, diagnostics = %q", verboseOut.String(), verboseErr.String())
|
||||||
|
}
|
||||||
|
if verbose.Presentation.Output.State != DeliveryComplete || verbose.Presentation.Diagnostics.State != DeliveryComplete {
|
||||||
|
t.Fatalf("verbose presentation = %#v", verbose.Presentation)
|
||||||
|
}
|
||||||
|
if len(reporters) != 2 || reporters[0] == nil || reporters[1] == nil || reporters[0] == reporters[1] {
|
||||||
|
t.Fatalf("invocation reporters = %#v", reporters)
|
||||||
|
}
|
||||||
|
if _, err := reporters[0].Output().Write([]byte("late")); !errors.Is(err, ErrReporterClosed) {
|
||||||
|
t.Fatalf("write through finalized reporter error = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecuteQuietUsageKeepsAliasGuidanceVisible(t *testing.T) {
|
||||||
|
command := NewCommand(CommandSpec[struct{}]{
|
||||||
|
Name: "run",
|
||||||
|
Run: func(Invocation, struct{}) Outcome { return Success() },
|
||||||
|
})
|
||||||
|
app, err := New(Identity{Name: "Example"}, command, Alias("r", "run"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var output, diagnostics bytes.Buffer
|
||||||
|
outcome := app.Execute(context.Background(), []string{"r", "--quiet", "--unknown"}, Streams{Out: &output, Err: &diagnostics})
|
||||||
|
if outcome.Kind != OutcomeUsage || outcome.Status != 2 {
|
||||||
|
t.Fatalf("outcome = %#v", outcome)
|
||||||
|
}
|
||||||
|
if output.Len() != 0 {
|
||||||
|
t.Fatalf("usage output = %q", output.String())
|
||||||
|
}
|
||||||
|
if got := diagnostics.String(); !strings.Contains(got, "ERROR: unknown flag") || !strings.Contains(got, "Usage: Example r") || !strings.Contains(got, "Alias for: Example run") {
|
||||||
|
t.Fatalf("usage diagnostics = %q", got)
|
||||||
|
}
|
||||||
|
if outcome.Presentation.Diagnostics.State != DeliveryComplete {
|
||||||
|
t.Fatalf("diagnostic presentation = %#v", outcome.Presentation.Diagnostics)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnresolvedRoutesUseReportingAliasesForTheirParseContext(t *testing.T) {
|
||||||
|
calls := 0
|
||||||
|
command := NewCommand(CommandSpec[struct{}]{
|
||||||
|
Name: "run",
|
||||||
|
NewInput: func() struct{} { calls++; return struct{}{} },
|
||||||
|
Run: func(Invocation, struct{}) Outcome { calls++; return Success() },
|
||||||
|
})
|
||||||
|
app, err := New(Identity{Name: "Example"}, Group("group", "", Group("nested", "", command)),
|
||||||
|
FlagAlias("-d", "--debug"), FlagAlias("-q", "--quiet"),
|
||||||
|
FlagAlias("-x", "--quiet"), FlagAliasFor("group nested", "-x", "--debug"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, path := range [][]string{{"missing"}, {"group", "missing"}, {"group", "nested", "missing"}} {
|
||||||
|
t.Run(strings.Join(path, "/"), func(t *testing.T) {
|
||||||
|
var canonicalDiagnostics, aliasDiagnostics bytes.Buffer
|
||||||
|
canonical := app.Execute(context.Background(), append(append([]string(nil), path...), "--debug"), Streams{Err: &canonicalDiagnostics})
|
||||||
|
alias := app.Execute(context.Background(), append(append([]string(nil), path...), "-d"), Streams{Err: &aliasDiagnostics})
|
||||||
|
if canonical.Kind != OutcomeUsage || alias.Kind != OutcomeUsage || canonical.Status != alias.Status || canonical.Explanation.Message != alias.Explanation.Message {
|
||||||
|
t.Fatalf("canonical=%#v alias=%#v", canonical, alias)
|
||||||
|
}
|
||||||
|
for _, text := range []string{canonicalDiagnostics.String(), aliasDiagnostics.String()} {
|
||||||
|
if !strings.Contains(text, "DEBUG:") || !strings.Contains(text, "ERROR:") || strings.Contains(text, "unknown shorthand") || strings.Contains(text, "Alias for:") {
|
||||||
|
t.Fatalf("diagnostics=%q", text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
route := resolveEnteredRoute(path, app.routeSnapshot())
|
||||||
|
if route.kind != routeUnknown || len(route.canonicalPath) != 0 {
|
||||||
|
t.Fatalf("invalid route acquired canonical identity: %#v", route)
|
||||||
|
}
|
||||||
|
var quiet bytes.Buffer
|
||||||
|
got := app.Execute(context.Background(), append(append([]string(nil), path...), "-q", "--unknown"), Streams{Err: &quiet})
|
||||||
|
if got.Kind != OutcomeUsage || !strings.Contains(quiet.String(), "ERROR: unknown flag") || strings.Contains(quiet.String(), "DEBUG:") {
|
||||||
|
t.Fatalf("quiet alias outcome=%#v diagnostics=%q", got, quiet.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
var scoped bytes.Buffer
|
||||||
|
got := app.Execute(context.Background(), []string{"group", "nested", "missing", "-x"}, Streams{Err: &scoped})
|
||||||
|
if got.Kind != OutcomeUsage || !strings.Contains(scoped.String(), "DEBUG:") {
|
||||||
|
t.Fatalf("scoped precedence outcome=%#v diagnostics=%q", got, scoped.String())
|
||||||
|
}
|
||||||
|
if calls != 0 {
|
||||||
|
t.Fatalf("invalid routes ran factories or handlers %d times", calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type executeReportingFailureWriter struct{ err error }
|
||||||
|
|
||||||
|
func (w executeReportingFailureWriter) Write([]byte) (int, error) { return 0, w.err }
|
||||||
|
|
||||||
|
func TestExecuteFinalizesEarlyAndIgnoredWriterFailures(t *testing.T) {
|
||||||
|
command := NewCommand(CommandSpec[struct{}]{
|
||||||
|
Name: "run",
|
||||||
|
Run: func(inv Invocation, _ struct{}) Outcome {
|
||||||
|
_, _ = inv.Reporter().Output().Write([]byte("report"))
|
||||||
|
return Success()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
app, err := New(Identity{Name: "Example"}, command)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
writeErr := errors.New("result destination failed")
|
||||||
|
var diagnostics bytes.Buffer
|
||||||
|
outcome := app.Execute(context.Background(), []string{"run"}, Streams{
|
||||||
|
Out: executeReportingFailureWriter{err: writeErr}, Err: &diagnostics,
|
||||||
|
})
|
||||||
|
if outcome.Kind != OutcomeOperational || outcome.Status != 1 || !errors.Is(outcome.Presentation.Output.WriteError, writeErr) {
|
||||||
|
t.Fatalf("ignored output failure outcome = %#v", outcome)
|
||||||
|
}
|
||||||
|
if !strings.Contains(diagnostics.String(), "deliver") || !strings.Contains(diagnostics.String(), "output") {
|
||||||
|
t.Fatalf("fallback diagnostic = %q", diagnostics.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
diagnostics.Reset()
|
||||||
|
outcome = app.Execute(nil, []string{"run"}, Streams{Err: &diagnostics})
|
||||||
|
if outcome.Kind != OutcomeOperational || outcome.Status != 1 || outcome.Cause == nil {
|
||||||
|
t.Fatalf("nil context outcome = %#v", outcome)
|
||||||
|
}
|
||||||
|
if !strings.Contains(diagnostics.String(), "ERROR: invocation context must not be nil\n") {
|
||||||
|
t.Fatalf("nil context diagnostic = %q", diagnostics.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
diagnostics.Reset()
|
||||||
|
var nilApp *App
|
||||||
|
outcome = nilApp.Execute(context.Background(), []string{"run"}, Streams{Err: &diagnostics})
|
||||||
|
if outcome.Kind != OutcomeOperational || outcome.Status != 1 || outcome.Cause == nil {
|
||||||
|
t.Fatalf("nil app outcome = %#v", outcome)
|
||||||
|
}
|
||||||
|
if !strings.Contains(diagnostics.String(), "ERROR: cannot execute commands on a nil app\n") {
|
||||||
|
t.Fatalf("nil app diagnostic = %q", diagnostics.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
296
flag_aliases.go
Normal file
296
flag_aliases.go
Normal file
@@ -0,0 +1,296 @@
|
|||||||
|
package commandkit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
commandmeta "go.maximumdirect.net/commandkit/internal/commandmeta"
|
||||||
|
"go.maximumdirect.net/commandkit/internal/commandpath"
|
||||||
|
)
|
||||||
|
|
||||||
|
type resolvedFlagAlias struct {
|
||||||
|
source capturedFlagSpelling
|
||||||
|
target capturedFlagSpelling
|
||||||
|
}
|
||||||
|
|
||||||
|
type resolvedFlagAliases struct {
|
||||||
|
bySpelling map[string]resolvedFlagAlias
|
||||||
|
}
|
||||||
|
|
||||||
|
type flagAliasMapping struct {
|
||||||
|
target capturedFlagSpelling
|
||||||
|
scoped bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type flagAliasContext struct {
|
||||||
|
path commandpath.Path
|
||||||
|
canonical map[string]capturedFlagSpelling
|
||||||
|
discovery bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (aliases resolvedFlagAliases) neutralRecords() []commandmeta.FlagAlias {
|
||||||
|
keys := make([]string, 0, len(aliases.bySpelling))
|
||||||
|
for key := range aliases.bySpelling {
|
||||||
|
keys = append(keys, key)
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
records := make([]commandmeta.FlagAlias, 0, len(keys))
|
||||||
|
for _, key := range keys {
|
||||||
|
alias := aliases.bySpelling[key]
|
||||||
|
records = append(records, alias.neutralRecord())
|
||||||
|
}
|
||||||
|
return records
|
||||||
|
}
|
||||||
|
|
||||||
|
// lookupRecords skips sorting: resolved source spellings are unique and the
|
||||||
|
// parser's lookup does not depend on record order.
|
||||||
|
func (aliases resolvedFlagAliases) lookupRecords() []commandmeta.FlagAlias {
|
||||||
|
records := make([]commandmeta.FlagAlias, 0, len(aliases.bySpelling))
|
||||||
|
for _, alias := range aliases.bySpelling {
|
||||||
|
records = append(records, alias.neutralRecord())
|
||||||
|
}
|
||||||
|
return records
|
||||||
|
}
|
||||||
|
|
||||||
|
func (alias resolvedFlagAlias) neutralRecord() commandmeta.FlagAlias {
|
||||||
|
return commandmeta.FlagAlias{Source: alias.source.token(), Target: alias.target.token()}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (spelling capturedFlagSpelling) token() string {
|
||||||
|
if spelling.short {
|
||||||
|
return "-" + spelling.name
|
||||||
|
}
|
||||||
|
return "--" + spelling.name
|
||||||
|
}
|
||||||
|
|
||||||
|
func compileFlagAliases(rules []RoutingRule, roots map[string]*canonicalNode, aliases interface {
|
||||||
|
Resolve(commandpath.Path) commandpath.Path
|
||||||
|
}) (map[string]resolvedFlagAliases, map[string]resolvedFlagAliases, error) {
|
||||||
|
canonicalNodes := indexCanonicalNodes(roots)
|
||||||
|
global := make(map[string]flagAliasMapping)
|
||||||
|
scoped := make(map[string]map[string]flagAliasMapping)
|
||||||
|
for _, rule := range rules {
|
||||||
|
record := rule.record
|
||||||
|
switch record.kind {
|
||||||
|
case globalFlagAliasRule:
|
||||||
|
source := flagSpellingKey(record.sourceFlag)
|
||||||
|
global[source] = flagAliasMapping{target: record.targetFlag}
|
||||||
|
case scopedFlagAliasRule:
|
||||||
|
scope := record.scopePath.Key()
|
||||||
|
if canonicalNodes[scope] == nil {
|
||||||
|
return nil, nil, fmt.Errorf("flag alias scope %q is not a canonical command path", record.scopePath.String())
|
||||||
|
}
|
||||||
|
if scoped[scope] == nil {
|
||||||
|
scoped[scope] = make(map[string]flagAliasMapping)
|
||||||
|
}
|
||||||
|
source := flagSpellingKey(record.sourceFlag)
|
||||||
|
scoped[scope][source] = flagAliasMapping{target: record.targetFlag, scoped: true}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
canonicalContexts := make(map[string]flagAliasContext, len(canonicalNodes)+1)
|
||||||
|
rootPath := commandpath.Path(nil)
|
||||||
|
canonicalContexts[rootPath.Key()] = flagAliasContext{path: rootPath, canonical: helpFlagSpellings()}
|
||||||
|
for key, node := range canonicalNodes {
|
||||||
|
canonicalContexts[key] = flagAliasContext{path: node.path.Clone(), canonical: canonicalFlagSpellings(node.command)}
|
||||||
|
}
|
||||||
|
discoveryContexts := discoverFlagContexts(rules, roots, aliases, canonicalNodes)
|
||||||
|
|
||||||
|
canonicalKeys := sortedContextKeys(canonicalContexts)
|
||||||
|
resolvedCanonical := make(map[string]resolvedFlagAliases, len(canonicalContexts))
|
||||||
|
for _, key := range canonicalKeys {
|
||||||
|
context := canonicalContexts[key]
|
||||||
|
mappings := cloneFlagMappings(global)
|
||||||
|
for source, mapping := range scoped[key] {
|
||||||
|
mappings[source] = mapping
|
||||||
|
}
|
||||||
|
resolved, err := resolveFlagAliases(context, mappings)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
resolvedCanonical[key] = resolved
|
||||||
|
}
|
||||||
|
|
||||||
|
discoveryKeys := sortedContextKeys(discoveryContexts)
|
||||||
|
resolvedDiscovery := make(map[string]resolvedFlagAliases, len(discoveryContexts))
|
||||||
|
for _, key := range discoveryKeys {
|
||||||
|
context := discoveryContexts[key]
|
||||||
|
resolved, err := resolveFlagAliases(context, cloneFlagMappings(global))
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
resolvedDiscovery[key] = resolved
|
||||||
|
}
|
||||||
|
return resolvedCanonical, resolvedDiscovery, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func indexCanonicalNodes(roots map[string]*canonicalNode) map[string]*canonicalNode {
|
||||||
|
indexed := make(map[string]*canonicalNode)
|
||||||
|
var add func(*canonicalNode)
|
||||||
|
add = func(node *canonicalNode) {
|
||||||
|
indexed[node.path.Key()] = node
|
||||||
|
for _, child := range node.children {
|
||||||
|
add(child)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, root := range roots {
|
||||||
|
add(root)
|
||||||
|
}
|
||||||
|
return indexed
|
||||||
|
}
|
||||||
|
|
||||||
|
func helpFlagSpellings() map[string]capturedFlagSpelling {
|
||||||
|
flags := make(map[string]capturedFlagSpelling, 2+len(commandmeta.ReportingFlags()))
|
||||||
|
for _, spelling := range []capturedFlagSpelling{{name: "help"}, {name: "h", short: true}} {
|
||||||
|
flags[flagSpellingKey(spelling)] = spelling
|
||||||
|
}
|
||||||
|
for _, metadata := range commandmeta.ReportingFlags() {
|
||||||
|
spelling := capturedFlagSpelling{name: metadata.Name}
|
||||||
|
flags[flagSpellingKey(spelling)] = spelling
|
||||||
|
}
|
||||||
|
return flags
|
||||||
|
}
|
||||||
|
|
||||||
|
func canonicalFlagSpellings(command Command) map[string]capturedFlagSpelling {
|
||||||
|
flags := helpFlagSpellings()
|
||||||
|
if command.record.payload == nil {
|
||||||
|
return flags
|
||||||
|
}
|
||||||
|
for _, metadata := range command.record.payload.bridgeFlags() {
|
||||||
|
long := capturedFlagSpelling{name: metadata.Name}
|
||||||
|
flags[flagSpellingKey(long)] = long
|
||||||
|
if metadata.Short != "" {
|
||||||
|
short := capturedFlagSpelling{name: metadata.Short, short: true}
|
||||||
|
flags[flagSpellingKey(short)] = short
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return flags
|
||||||
|
}
|
||||||
|
|
||||||
|
func discoverFlagContexts(rules []RoutingRule, roots map[string]*canonicalNode, aliases interface {
|
||||||
|
Resolve(commandpath.Path) commandpath.Path
|
||||||
|
}, canonicalNodes map[string]*canonicalNode) map[string]flagAliasContext {
|
||||||
|
commandSources := make(map[string]struct{})
|
||||||
|
prefixes := make(map[string]commandpath.Path)
|
||||||
|
for _, rule := range rules {
|
||||||
|
if rule.record.kind == commandAliasRule {
|
||||||
|
commandSources[rule.record.sourcePath.Key()] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, rule := range rules {
|
||||||
|
if rule.record.kind != commandAliasRule {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for length := 1; length < len(rule.record.sourcePath); length++ {
|
||||||
|
prefix := rule.record.sourcePath[:length].Clone()
|
||||||
|
key := prefix.Key()
|
||||||
|
if canonicalNodes[key] != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, exactAlias := commandSources[key]; exactAlias {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
resolved := aliases.Resolve(prefix)
|
||||||
|
trace := traceCanonicalPath(roots, resolved)
|
||||||
|
if trace.exact != nil || (trace.operandOwner != nil && acceptsOperandPrefix(trace.operandOwner.command, len(resolved)-len(trace.operandOwner.path))) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
prefixes[key] = prefix
|
||||||
|
}
|
||||||
|
}
|
||||||
|
contexts := make(map[string]flagAliasContext, len(prefixes))
|
||||||
|
for key, path := range prefixes {
|
||||||
|
contexts[key] = flagAliasContext{path: path, canonical: helpFlagSpellings(), discovery: true}
|
||||||
|
}
|
||||||
|
return contexts
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneFlagMappings(mappings map[string]flagAliasMapping) map[string]flagAliasMapping {
|
||||||
|
cloned := make(map[string]flagAliasMapping, len(mappings))
|
||||||
|
for source, mapping := range mappings {
|
||||||
|
cloned[source] = mapping
|
||||||
|
}
|
||||||
|
return cloned
|
||||||
|
}
|
||||||
|
|
||||||
|
func sortedContextKeys(contexts map[string]flagAliasContext) []string {
|
||||||
|
keys := make([]string, 0, len(contexts))
|
||||||
|
for key := range contexts {
|
||||||
|
keys = append(keys, key)
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
return keys
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveFlagAliases(context flagAliasContext, mappings map[string]flagAliasMapping) (resolvedFlagAliases, error) {
|
||||||
|
result := resolvedFlagAliases{bySpelling: make(map[string]resolvedFlagAlias)}
|
||||||
|
sources := make([]string, 0, len(mappings))
|
||||||
|
for source := range mappings {
|
||||||
|
sources = append(sources, source)
|
||||||
|
}
|
||||||
|
sort.Strings(sources)
|
||||||
|
for _, sourceKey := range sources {
|
||||||
|
mapping := mappings[sourceKey]
|
||||||
|
source := spellingFromKey(sourceKey)
|
||||||
|
target, active, err := resolveFlagTarget(source, mapping.target, mappings, context.canonical)
|
||||||
|
if err != nil {
|
||||||
|
return resolvedFlagAliases{}, fmt.Errorf("%s: %w", flagContextDescription(context), err)
|
||||||
|
}
|
||||||
|
if !active {
|
||||||
|
if mapping.scoped {
|
||||||
|
return resolvedFlagAliases{}, fmt.Errorf("scoped flag alias %s has no canonical destination in %s", source.token(), flagContextDescription(context))
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, collision := context.canonical[sourceKey]; collision {
|
||||||
|
return resolvedFlagAliases{}, fmt.Errorf("flag alias %s conflicts with a canonical spelling in %s", source.token(), flagContextDescription(context))
|
||||||
|
}
|
||||||
|
result.bySpelling[sourceKey] = resolvedFlagAlias{source: source, target: target}
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveFlagTarget(source, target capturedFlagSpelling, mappings map[string]flagAliasMapping, canonical map[string]capturedFlagSpelling) (capturedFlagSpelling, bool, error) {
|
||||||
|
visited := map[string]int{flagSpellingKey(source): 0}
|
||||||
|
chain := []capturedFlagSpelling{source}
|
||||||
|
current := target
|
||||||
|
for {
|
||||||
|
key := flagSpellingKey(current)
|
||||||
|
if endpoint, exists := canonical[key]; exists {
|
||||||
|
return endpoint, true, nil
|
||||||
|
}
|
||||||
|
if start, exists := visited[key]; exists {
|
||||||
|
cycle := append(append([]capturedFlagSpelling(nil), chain[start:]...), current)
|
||||||
|
spellings := make([]string, 0, len(cycle))
|
||||||
|
for _, spelling := range cycle {
|
||||||
|
spellings = append(spellings, spelling.token())
|
||||||
|
}
|
||||||
|
return capturedFlagSpelling{}, false, fmt.Errorf("flag alias cycle: %s", strings.Join(spellings, " → "))
|
||||||
|
}
|
||||||
|
visited[key] = len(chain)
|
||||||
|
chain = append(chain, current)
|
||||||
|
next, exists := mappings[key]
|
||||||
|
if !exists {
|
||||||
|
return capturedFlagSpelling{}, false, nil
|
||||||
|
}
|
||||||
|
current = next.target
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func spellingFromKey(key string) capturedFlagSpelling {
|
||||||
|
if strings.HasPrefix(key, "short:") {
|
||||||
|
return capturedFlagSpelling{name: strings.TrimPrefix(key, "short:"), short: true}
|
||||||
|
}
|
||||||
|
return capturedFlagSpelling{name: strings.TrimPrefix(key, "long:")}
|
||||||
|
}
|
||||||
|
|
||||||
|
func flagContextDescription(context flagAliasContext) string {
|
||||||
|
if context.discovery {
|
||||||
|
return fmt.Sprintf("alias prefix %q", context.path.String())
|
||||||
|
}
|
||||||
|
if len(context.path) == 0 {
|
||||||
|
return "application root"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("command %q", context.path.String())
|
||||||
|
}
|
||||||
297
flag_aliases_test.go
Normal file
297
flag_aliases_test.go
Normal file
@@ -0,0 +1,297 @@
|
|||||||
|
package commandkit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
commandmeta "go.maximumdirect.net/commandkit/internal/commandmeta"
|
||||||
|
"go.maximumdirect.net/commandkit/internal/commandpath"
|
||||||
|
)
|
||||||
|
|
||||||
|
func flagAliasCommand(name string, flags ...Flag[struct{}]) Command {
|
||||||
|
return NewCommand(CommandSpec[struct{}]{
|
||||||
|
Name: name, Summary: name, Flags: flags,
|
||||||
|
Run: func(Invocation, struct{}) Outcome { return Success() },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func stringFlag(name, short string) Flag[struct{}] {
|
||||||
|
return ValueFlag(FlagMeta{Name: name, Short: short}, (*string)(nil),
|
||||||
|
func(value string) (string, error) { return value, nil },
|
||||||
|
func(*struct{}, Supplied[string]) {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFlagAliasChainsUseScopedOverridesAndCanonicalEndpoints(t *testing.T) {
|
||||||
|
inspect := flagAliasCommand("inspect", stringFlag("color", "c"), stringFlag("style", "f"))
|
||||||
|
show := flagAliasCommand("show", stringFlag("color", "c"), stringFlag("style", "f"))
|
||||||
|
group := Group("Tools", "tools", show)
|
||||||
|
app, err := New(Identity{Name: "Example"},
|
||||||
|
inspect,
|
||||||
|
group,
|
||||||
|
Alias("look", "inspect"),
|
||||||
|
FlagAlias("--colour", "--hue"),
|
||||||
|
FlagAlias("--hue", "--color"),
|
||||||
|
FlagAlias("-x", "--color"),
|
||||||
|
FlagAlias("--tint", "-c"),
|
||||||
|
FlagAliasFor("Tools", "--group-help", "--help"),
|
||||||
|
FlagAliasFor("inspect", "--hue", "--style"),
|
||||||
|
FlagAliasFor("inspect", "--local", "--style"),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
inspectAliases := app.flagAliases[commandpath.Path{"inspect"}.Key()].bySpelling
|
||||||
|
for source, target := range map[string]string{
|
||||||
|
"--colour": "--style",
|
||||||
|
"--hue": "--style",
|
||||||
|
"-x": "--color",
|
||||||
|
"--tint": "-c",
|
||||||
|
"--local": "--style",
|
||||||
|
} {
|
||||||
|
alias, ok := inspectAliases[flagSpellingKey(capturedSpelling(source))]
|
||||||
|
if !ok || alias.target.token() != target {
|
||||||
|
t.Errorf("inspect alias %s = (%s, %v), want %s", source, alias.target.token(), ok, target)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
groupAliases := app.flagAliases[commandpath.Path{"Tools"}.Key()].bySpelling
|
||||||
|
if _, active := groupAliases[flagSpellingKey(capturedFlagSpelling{name: "colour"})]; active {
|
||||||
|
t.Error("global alias was active on a group without its destination flag")
|
||||||
|
}
|
||||||
|
if _, inherited := groupAliases[flagSpellingKey(capturedFlagSpelling{name: "local"})]; inherited {
|
||||||
|
t.Fatal("command-scoped alias was inherited by its parent group")
|
||||||
|
}
|
||||||
|
if _, inherited := app.flagAliases[commandpath.Path{"Tools", "show"}.Key()].bySpelling[flagSpellingKey(capturedFlagSpelling{name: "local"})]; inherited {
|
||||||
|
t.Fatal("command-scoped alias was inherited by a different command")
|
||||||
|
}
|
||||||
|
groupHelp := flagSpellingKey(capturedFlagSpelling{name: "group-help"})
|
||||||
|
if got := groupAliases[groupHelp].target.token(); got != "--help" {
|
||||||
|
t.Fatalf("group-scoped help alias target = %s, want --help", got)
|
||||||
|
}
|
||||||
|
if _, inherited := app.flagAliases[commandpath.Path{"Tools", "show"}.Key()].bySpelling[groupHelp]; inherited {
|
||||||
|
t.Fatal("group-scoped alias was inherited by its child command")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFlagAliasDiscoveryContextsUseGlobalReportingAliases(t *testing.T) {
|
||||||
|
show := flagAliasCommand("show")
|
||||||
|
app, err := New(Identity{Name: "Example"},
|
||||||
|
Group("Tools", "tools", show),
|
||||||
|
Alias("shortcut show", "Tools show"),
|
||||||
|
FlagAlias("--hlep", "--help"),
|
||||||
|
FlagAlias("--silent", "--quiet"),
|
||||||
|
FlagAlias("--chatty", "--verbose"),
|
||||||
|
FlagAlias("--trace", "--debug"),
|
||||||
|
FlagAlias("--show-progress", "--progress"),
|
||||||
|
FlagAliasFor("Tools show", "--local-help", "--help"),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
globalHelp := flagSpellingKey(capturedFlagSpelling{name: "hlep"})
|
||||||
|
if got := app.flagAliases[commandpath.Path(nil).Key()].bySpelling[globalHelp].target.token(); got != "--help" {
|
||||||
|
t.Fatalf("root --hlep target = %s, want --help", got)
|
||||||
|
}
|
||||||
|
if got := app.flagAliases[commandpath.Path{"Tools"}.Key()].bySpelling[globalHelp].target.token(); got != "--help" {
|
||||||
|
t.Fatalf("group --hlep target = %s, want --help", got)
|
||||||
|
}
|
||||||
|
discovery := app.discoveryFlags[commandpath.Path{"shortcut"}.Key()].bySpelling
|
||||||
|
if got := discovery[globalHelp].target.token(); got != "--help" {
|
||||||
|
t.Fatalf("discovery --hlep target = %s, want --help", got)
|
||||||
|
}
|
||||||
|
for label, aliases := range map[string]resolvedFlagAliases{
|
||||||
|
"root": app.flagAliases[commandpath.Path(nil).Key()],
|
||||||
|
"group": app.flagAliases[commandpath.Path{"Tools"}.Key()],
|
||||||
|
"command": app.flagAliases[commandpath.Path{"Tools", "show"}.Key()],
|
||||||
|
"discovery": app.discoveryFlags[commandpath.Path{"shortcut"}.Key()],
|
||||||
|
} {
|
||||||
|
for source, target := range map[string]string{
|
||||||
|
"silent": "quiet",
|
||||||
|
"chatty": "verbose",
|
||||||
|
"trace": "debug",
|
||||||
|
"show-progress": "progress",
|
||||||
|
} {
|
||||||
|
key := flagSpellingKey(capturedFlagSpelling{name: source})
|
||||||
|
if got := aliases.bySpelling[key].target.token(); got != "--"+target {
|
||||||
|
t.Errorf("%s --%s target = %s, want --%s", label, source, got, target)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, inherited := discovery[flagSpellingKey(capturedFlagSpelling{name: "local-help"})]; inherited {
|
||||||
|
t.Fatal("discovery context inherited a command-scoped alias")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReportingFlagsAreReservedAndAcceptApplicationShortAliases(t *testing.T) {
|
||||||
|
for _, name := range []string{"quiet", "verbose", "debug", "progress", "format"} {
|
||||||
|
command := flagAliasCommand("reserved", stringFlag(name, ""))
|
||||||
|
if len(command.record.issues) == 0 {
|
||||||
|
t.Errorf("capturing --%s did not retain a declaration error", name)
|
||||||
|
}
|
||||||
|
app, err := New(Identity{Name: "Example"}, flagAliasCommand("safe"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
snapshot := app.routeSnapshot()
|
||||||
|
if err := app.Register(command, Alias("reserved-shortcut", "reserved")); err == nil || !strings.Contains(err.Error(), "reserved for reporting") {
|
||||||
|
t.Errorf("registering --%s returned %v, want a retained reporting reservation error", name, err)
|
||||||
|
}
|
||||||
|
if app.routeSnapshot() != snapshot || app.commands["reserved"] != nil || app.commands["safe"] == nil || len(app.routingRules) != 0 {
|
||||||
|
t.Fatalf("failed registration of --%s changed the installed commands", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
declarations := []Declaration{flagAliasCommand("run")}
|
||||||
|
for short, target := range map[string]string{"q": "quiet", "v": "verbose", "d": "debug"} {
|
||||||
|
declarations = append(declarations, FlagAlias("-"+short, "--"+target))
|
||||||
|
}
|
||||||
|
if _, err := New(Identity{Name: "Example"}, declarations...); err != nil {
|
||||||
|
t.Fatalf("application short aliases for reporting flags were rejected: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReportingFlagSpellingsConflictWithAliasSources(t *testing.T) {
|
||||||
|
for _, name := range []string{"quiet", "verbose", "debug", "progress", "format"} {
|
||||||
|
if _, err := New(Identity{Name: "Example"}, FlagAlias("--"+name, "--help")); err == nil || !strings.Contains(err.Error(), "conflicts with a canonical spelling") {
|
||||||
|
t.Errorf("alias source --%s returned %v, want a canonical spelling collision", name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := New(Identity{Name: "Example"}, Group("tools", "", flagAliasCommand("show")), FlagAliasFor("tools", "--quiet", "--help")); err == nil || !strings.Contains(err.Error(), "conflicts with a canonical spelling") {
|
||||||
|
t.Errorf("group-scoped canonical reporting spelling returned %v, want a collision", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRoutingSnapshotCarriesReportingMetadataOutsideConsumerBindings(t *testing.T) {
|
||||||
|
app, err := New(Identity{Name: "Example"},
|
||||||
|
flagAliasCommand("run", stringFlag("style", "f")),
|
||||||
|
Group("tools", "", flagAliasCommand("show")),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var check func(*commandmeta.Command)
|
||||||
|
check = func(node *commandmeta.Command) {
|
||||||
|
if len(node.CoreFlags) != 5 {
|
||||||
|
t.Errorf("%s core reporting flags = %#v", node.Name, node.CoreFlags)
|
||||||
|
}
|
||||||
|
for _, flag := range node.Flags {
|
||||||
|
if commandmeta.IsReportingFlag(flag.Name) {
|
||||||
|
t.Errorf("%s reporting flag %q was added to consumer bindings", node.Name, flag.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, child := range node.Children {
|
||||||
|
check(child)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
check(app.routeSnapshot().metadata)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFlagAliasDuplicatesAndScopesAreDeterministic(t *testing.T) {
|
||||||
|
command := flagAliasCommand("inspect", stringFlag("color", "c"), stringFlag("style", "f"))
|
||||||
|
duplicate := FlagAlias("--colour", "--color")
|
||||||
|
app, err := New(Identity{Name: "Example"}, command, flagAliasCommand("plain", stringFlag("color", "c")), duplicate, FlagAlias("--colour", "--color"),
|
||||||
|
FlagAliasFor("inspect", "--colour", "--style"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("identical duplicates and scoped override were rejected: %v", err)
|
||||||
|
}
|
||||||
|
if got := app.flagAliases[commandpath.Path{"inspect"}.Key()].bySpelling[flagSpellingKey(capturedFlagSpelling{name: "colour"})].target.token(); got != "--style" {
|
||||||
|
t.Fatalf("scoped alias target = %s, want --style", got)
|
||||||
|
}
|
||||||
|
if got := app.flagAliases[commandpath.Path{"plain"}.Key()].bySpelling[flagSpellingKey(capturedFlagSpelling{name: "colour"})].target.token(); got != "--color" {
|
||||||
|
t.Fatalf("effective global alias target = %s, want --color", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
for name, rules := range map[string][]RoutingRule{
|
||||||
|
"competing global targets": {
|
||||||
|
FlagAlias("--colour", "--color"),
|
||||||
|
FlagAlias("--colour", "--style"),
|
||||||
|
},
|
||||||
|
"competing scoped targets": {
|
||||||
|
FlagAliasFor("inspect", "--colour", "--color"),
|
||||||
|
FlagAliasFor("inspect", "--colour", "--style"),
|
||||||
|
},
|
||||||
|
"unknown scope": {FlagAliasFor("missing", "--colour", "--color")},
|
||||||
|
"missing scoped destination": {FlagAliasFor("inspect", "--colour", "--missing")},
|
||||||
|
} {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
declarations := []Declaration{command}
|
||||||
|
for _, rule := range rules {
|
||||||
|
declarations = append(declarations, rule)
|
||||||
|
}
|
||||||
|
if _, err := New(Identity{Name: "Example"}, declarations...); err == nil {
|
||||||
|
t.Fatal("New() accepted invalid flag alias rules")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFlagAliasCyclesIdentifyParticipatingSpellings(t *testing.T) {
|
||||||
|
for name, declarations := range map[string][]Declaration{
|
||||||
|
"global": {
|
||||||
|
FlagAlias("--first", "--second"),
|
||||||
|
FlagAlias("--second", "--first"),
|
||||||
|
},
|
||||||
|
"scoped": {
|
||||||
|
flagAliasCommand("inspect"),
|
||||||
|
FlagAliasFor("inspect", "-a", "--second"),
|
||||||
|
FlagAliasFor("inspect", "--second", "-a"),
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
_, err := New(Identity{Name: "Example"}, declarations...)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("New() accepted a flag alias cycle")
|
||||||
|
}
|
||||||
|
message := err.Error()
|
||||||
|
for _, spelling := range []string{"--first", "--second"} {
|
||||||
|
if name == "scoped" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !strings.Contains(message, spelling) {
|
||||||
|
t.Errorf("cycle error %q does not identify %s", message, spelling)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if name == "scoped" && (!strings.Contains(message, "-a") || !strings.Contains(message, "--second")) {
|
||||||
|
t.Errorf("scoped cycle error %q does not identify both spellings", message)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInactiveGlobalAliasesActivateAtomicallyWhenDestinationAppears(t *testing.T) {
|
||||||
|
command := flagAliasCommand("inspect", stringFlag("color", "c"))
|
||||||
|
app, err := New(Identity{Name: "Example"}, command,
|
||||||
|
FlagAlias("--color", "--missing"),
|
||||||
|
FlagAlias("--colour", "--color"),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
aliases := app.flagAliases[commandpath.Path{"inspect"}.Key()].bySpelling
|
||||||
|
if _, active := aliases[flagSpellingKey(capturedFlagSpelling{name: "color"})]; active {
|
||||||
|
t.Fatal("global alias with an unavailable destination became active")
|
||||||
|
}
|
||||||
|
if got := aliases[flagSpellingKey(capturedFlagSpelling{name: "colour"})].target.token(); got != "--color" {
|
||||||
|
t.Fatalf("--colour endpoint = %s, want canonical --color", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
snapshot := app.routeSnapshot()
|
||||||
|
commands := len(app.commands)
|
||||||
|
rules := len(app.routingRules)
|
||||||
|
originalAlias := aliases[flagSpellingKey(capturedFlagSpelling{name: "colour"})]
|
||||||
|
if err := app.Register(flagAliasCommand("later", stringFlag("color", "c"), stringFlag("missing", ""))); err == nil {
|
||||||
|
t.Fatal("Register() accepted a newly active global source collision")
|
||||||
|
}
|
||||||
|
currentAlias := app.flagAliases[commandpath.Path{"inspect"}.Key()].bySpelling[flagSpellingKey(capturedFlagSpelling{name: "colour"})]
|
||||||
|
if app.routeSnapshot() != snapshot || len(app.commands) != commands || len(app.routingRules) != rules || currentAlias != originalAlias {
|
||||||
|
t.Fatal("failed registration changed the existing command or routing state")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func capturedSpelling(token string) capturedFlagSpelling {
|
||||||
|
issues := []error(nil)
|
||||||
|
return captureFlagSpelling("test", token, &issues)
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ package commandkit
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"sort"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ErrFlagInput identifies a missing required captured flag selected for an input.
|
// ErrFlagInput identifies a missing required captured flag selected for an input.
|
||||||
@@ -13,7 +14,8 @@ var ErrFlagWiring = errors.New("flag wiring is invalid")
|
|||||||
|
|
||||||
// FlagValues stores invocation-local converted values and presence for captured
|
// FlagValues stores invocation-local converted values and presence for captured
|
||||||
// flags. It does not retain an input instance; values can be applied to
|
// flags. It does not retain an input instance; values can be applied to
|
||||||
// independently initialized destinations.
|
// independently initialized destinations. Captured occurrence flags retain
|
||||||
|
// their relative command-line order across the selected bindings.
|
||||||
type FlagValues[I any] struct {
|
type FlagValues[I any] struct {
|
||||||
values map[*bindingIdentity]preparedValue[I]
|
values map[*bindingIdentity]preparedValue[I]
|
||||||
}
|
}
|
||||||
@@ -42,7 +44,7 @@ func CaptureFlags[Outer, I any](flags []Flag[I], target func(*Outer) *FlagValues
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return preparedValue[Outer]{}, err
|
return preparedValue[Outer]{}, err
|
||||||
}
|
}
|
||||||
return preparedValue[Outer]{
|
result := preparedValue[Outer]{
|
||||||
identity: identity,
|
identity: identity,
|
||||||
supplied: prepared.supplied,
|
supplied: prepared.supplied,
|
||||||
apply: func(outer *Outer) {
|
apply: func(outer *Outer) {
|
||||||
@@ -55,7 +57,11 @@ func CaptureFlags[Outer, I any](flags []Flag[I], target func(*Outer) *FlagValues
|
|||||||
}
|
}
|
||||||
store.values[inner.binding.identity] = prepared
|
store.values[inner.binding.identity] = prepared
|
||||||
},
|
},
|
||||||
}, nil
|
}
|
||||||
|
for _, occurrence := range prepared.ordered {
|
||||||
|
result.ordered = append(result.ordered, preparedOccurrence[Outer]{ordinal: occurrence.ordinal})
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
captured[index] = Flag[Outer]{binding: binding}
|
captured[index] = Flag[Outer]{binding: binding}
|
||||||
@@ -66,11 +72,25 @@ func CaptureFlags[Outer, I any](flags []Flag[I], target func(*Outer) *FlagValues
|
|||||||
// ApplyTo applies selected original captured flags to an initialized
|
// ApplyTo applies selected original captured flags to an initialized
|
||||||
// destination. A missing captured binding or nil destination matches
|
// destination. A missing captured binding or nil destination matches
|
||||||
// ErrFlagWiring. A required selected value that was not supplied matches
|
// ErrFlagWiring. A required selected value that was not supplied matches
|
||||||
// ErrFlagInput. Repeated core-owned values are copied for each application.
|
// ErrFlagInput. Ordinary selected values are applied first, followed by selected
|
||||||
|
// occurrence values in command-line order. Repeated core-owned values are copied
|
||||||
|
// for each application.
|
||||||
func (values FlagValues[I]) ApplyTo(target *I, selected ...Flag[I]) error {
|
func (values FlagValues[I]) ApplyTo(target *I, selected ...Flag[I]) error {
|
||||||
|
return values.applyTo(target, false, selected...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ApplySuppliedTo applies only explicitly supplied selected flags, preserving
|
||||||
|
// the destination's existing values for omitted flags. It checks selected
|
||||||
|
// bindings and required values like ApplyTo and preserves occurrence order.
|
||||||
|
func (values FlagValues[I]) ApplySuppliedTo(target *I, selected ...Flag[I]) error {
|
||||||
|
return values.applyTo(target, true, selected...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (values FlagValues[I]) applyTo(target *I, suppliedOnly bool, selected ...Flag[I]) error {
|
||||||
if target == nil {
|
if target == nil {
|
||||||
return fmt.Errorf("%w: destination is nil", ErrFlagWiring)
|
return fmt.Errorf("%w: destination is nil", ErrFlagWiring)
|
||||||
}
|
}
|
||||||
|
preparedValues := make([]preparedValue[I], 0, len(selected))
|
||||||
for _, flag := range selected {
|
for _, flag := range selected {
|
||||||
binding := flag.binding
|
binding := flag.binding
|
||||||
if binding.identity == nil {
|
if binding.identity == nil {
|
||||||
@@ -83,14 +103,32 @@ func (values FlagValues[I]) ApplyTo(target *I, selected ...Flag[I]) error {
|
|||||||
if binding.meta.Required && !prepared.supplied {
|
if binding.meta.Required && !prepared.supplied {
|
||||||
return fmt.Errorf("flag --%s: %w", binding.meta.Name, ErrFlagInput)
|
return fmt.Errorf("flag --%s: %w", binding.meta.Name, ErrFlagInput)
|
||||||
}
|
}
|
||||||
prepared.apply(target)
|
if suppliedOnly && !prepared.supplied {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
preparedValues = append(preparedValues, prepared)
|
||||||
|
}
|
||||||
|
var ordered []preparedOccurrence[I]
|
||||||
|
for _, prepared := range preparedValues {
|
||||||
|
if prepared.apply != nil {
|
||||||
|
prepared.apply(target)
|
||||||
|
}
|
||||||
|
for _, occurrence := range prepared.ordered {
|
||||||
|
if occurrence.apply != nil {
|
||||||
|
ordered = append(ordered, occurrence)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.SliceStable(ordered, func(i, j int) bool { return *ordered[i].ordinal < *ordered[j].ordinal })
|
||||||
|
for _, occurrence := range ordered {
|
||||||
|
occurrence.apply(target)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func classifyInputError(err error) Outcome {
|
func classifyInputError(err error) Outcome {
|
||||||
if errors.Is(err, ErrFlagWiring) {
|
if errors.Is(err, ErrFlagWiring) {
|
||||||
return Failure(err)
|
return Failure(err.Error()).WithCause(err)
|
||||||
}
|
}
|
||||||
return Usage(err)
|
return Usage(err.Error()).WithCause(err)
|
||||||
}
|
}
|
||||||
|
|||||||
82
format_flag_test.go
Normal file
82
format_flag_test.go
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
package commandkit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
commandmeta "go.maximumdirect.net/commandkit/internal/commandmeta"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSharedFormatFlag(t *testing.T) {
|
||||||
|
factories, runs := 0, 0
|
||||||
|
command := NewCommand(CommandSpec[struct{}]{Name: "run", NewInput: func() struct{} { factories++; return struct{}{} }, Run: func(inv Invocation, _ struct{}) Outcome {
|
||||||
|
runs++
|
||||||
|
_ = inv.Reporter().Report(Report{Value: map[string]any{"answer": 42}})
|
||||||
|
return Success()
|
||||||
|
}})
|
||||||
|
app, err := New(Identity{Name: "Example", Version: "1"}, command, FlagAlias("--fmt", "--format"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, tc := range []struct {
|
||||||
|
args []string
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{[]string{"run", "--format", "text"}, "yaml"},
|
||||||
|
{[]string{"run", "--format=yaml"}, "yaml"},
|
||||||
|
{[]string{"run", "--fmt=json"}, "json"},
|
||||||
|
{[]string{"run", "--format=yaml", "--fmt", "json"}, "json"},
|
||||||
|
{[]string{"run"}, "yaml"},
|
||||||
|
} {
|
||||||
|
var out bytes.Buffer
|
||||||
|
got := app.Execute(context.Background(), tc.args, Streams{Out: &out})
|
||||||
|
if got.Status != 0 {
|
||||||
|
t.Fatalf("%v: %#v", tc.args, got)
|
||||||
|
}
|
||||||
|
var value any
|
||||||
|
if tc.expected == "json" {
|
||||||
|
dec := json.NewDecoder(&out)
|
||||||
|
dec.UseNumber()
|
||||||
|
err = dec.Decode(&value)
|
||||||
|
} else {
|
||||||
|
value, err = DecodeYAMLReport(&out)
|
||||||
|
}
|
||||||
|
if err != nil || !reflect.DeepEqual(value, map[string]any{"answer": json.Number("42")}) {
|
||||||
|
t.Fatalf("%v: %#v %v", tc.args, value, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
beforeFactories, beforeRuns := factories, runs
|
||||||
|
for _, args := range [][]string{{"run", "--format="}, {"run", "--fmt=xml"}, {"run", "--format=bad", "--format=json"}, {"run", "--format", "--quiet"}} {
|
||||||
|
var diagnostics bytes.Buffer
|
||||||
|
got := app.Execute(context.Background(), args, Streams{Err: &diagnostics})
|
||||||
|
if got.Kind != OutcomeUsage || !strings.Contains(diagnostics.String(), "format") {
|
||||||
|
t.Fatalf("%v: %#v %q", args, got, &diagnostics)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if factories != beforeFactories || runs != beforeRuns {
|
||||||
|
t.Fatalf("invalid formats ran domain code: factories %d, runs %d", factories, runs)
|
||||||
|
}
|
||||||
|
var help bytes.Buffer
|
||||||
|
got := app.Execute(context.Background(), []string{"run", "--help"}, Streams{Out: &help})
|
||||||
|
if got.Status != 0 || !strings.Contains(help.String(), "--format") || factories != beforeFactories || runs != beforeRuns {
|
||||||
|
t.Fatalf("help: %#v, %q", got, &help)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSharedFormatParseOrderAndTerminator(t *testing.T) {
|
||||||
|
app, err := New(Identity{Name: "Example"}, flagAliasCommand("run", stringFlag("message", "")), FlagAlias("--fmt", "--format"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
result := parseReportingArgs(app, []string{"run", "--verbose", "--fmt", "json", "--quiet", "--message=--format", "--", "--format=yaml"})
|
||||||
|
if result.Failure != nil || result.Reporting.Format != "json" || result.Reporting.Mode != commandmeta.ReportingModeQuiet || !reflect.DeepEqual(result.Operands, []string{"--format=yaml"}) {
|
||||||
|
t.Fatalf("%#v", result)
|
||||||
|
}
|
||||||
|
if result.FlagOccurrences[1].Spelling != "--fmt" || result.FlagOccurrences[1].RawValue != "json" {
|
||||||
|
t.Fatalf("%#v", result.FlagOccurrences)
|
||||||
|
}
|
||||||
|
}
|
||||||
6
go.mod
6
go.mod
@@ -1,4 +1,4 @@
|
|||||||
module gitea.maximumdirect.net/eric/CommandKit
|
module go.maximumdirect.net/commandkit
|
||||||
|
|
||||||
go 1.26.0
|
go 1.26.0
|
||||||
|
|
||||||
@@ -9,10 +9,10 @@ require (
|
|||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
gitea.maximumdirect.net/eric/ReleaseKit v0.4.2 // indirect
|
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
github.com/yuin/goldmark/v2 v2.1.6 // indirect
|
github.com/yuin/goldmark/v2 v2.1.6 // indirect
|
||||||
|
go.maximumdirect.net/releasekit v0.9.0 // indirect
|
||||||
golang.org/x/mod v0.40.0 // indirect
|
golang.org/x/mod v0.40.0 // indirect
|
||||||
)
|
)
|
||||||
|
|
||||||
tool gitea.maximumdirect.net/eric/ReleaseKit/cmd/releasekit
|
tool go.maximumdirect.net/releasekit/cmd/releasekit
|
||||||
|
|||||||
4
go.sum
4
go.sum
@@ -1,5 +1,3 @@
|
|||||||
gitea.maximumdirect.net/eric/ReleaseKit v0.4.2 h1:P2eDQ29BgyYqHnaz5MV+vzOk7r3s8py2y9k8JISFtJ0=
|
|
||||||
gitea.maximumdirect.net/eric/ReleaseKit v0.4.2/go.mod h1:LVodqNQ3OIKv9NBq+fFeyMByd1lHY25YOmmpah/NaNI=
|
|
||||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||||
@@ -10,6 +8,8 @@ github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
|
|||||||
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
github.com/yuin/goldmark/v2 v2.1.6 h1:igy0q9JcqovoFXFx/XjtU8sHVhcUHavPj2sA3EoKJUE=
|
github.com/yuin/goldmark/v2 v2.1.6 h1:igy0q9JcqovoFXFx/XjtU8sHVhcUHavPj2sA3EoKJUE=
|
||||||
github.com/yuin/goldmark/v2 v2.1.6/go.mod h1:G6M4/qOFtfn01/o14BU1UR2Lo5N3S9Qo7xCuz/sHjGQ=
|
github.com/yuin/goldmark/v2 v2.1.6/go.mod h1:G6M4/qOFtfn01/o14BU1UR2Lo5N3S9Qo7xCuz/sHjGQ=
|
||||||
|
go.maximumdirect.net/releasekit v0.9.0 h1:sK+Aoj6gS6ATU/1reocjwBekp4YETYENwkcX5ti6mrE=
|
||||||
|
go.maximumdirect.net/releasekit v0.9.0/go.mod h1:QE4BLrAhVJTzi+/YTcVl21K/qZp9ojRLg5kuoy3Zb44=
|
||||||
go.yaml.in/yaml/v4 v4.0.0-rc.6 h1:1h7H1ohdUh93/FyE4YaDa1Zh64K6VVbjF4K6WUxMtH4=
|
go.yaml.in/yaml/v4 v4.0.0-rc.6 h1:1h7H1ohdUh93/FyE4YaDa1Zh64K6VVbjF4K6WUxMtH4=
|
||||||
go.yaml.in/yaml/v4 v4.0.0-rc.6/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0=
|
go.yaml.in/yaml/v4 v4.0.0-rc.6/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0=
|
||||||
golang.org/x/mod v0.40.0 h1:hUv+3cXcdRHz08UmSiOob7sadHig73uo5bkXxQ/tvUs=
|
golang.org/x/mod v0.40.0 h1:hUv+3cXcdRHz08UmSiOob7sadHig73uo5bkXxQ/tvUs=
|
||||||
|
|||||||
63
help_view.go
Normal file
63
help_view.go
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
package commandkit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sort"
|
||||||
|
|
||||||
|
commandmeta "go.maximumdirect.net/commandkit/internal/commandmeta"
|
||||||
|
"go.maximumdirect.net/commandkit/internal/commandpath"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (a *App) helpView(route routeResult, snapshot *routingSnapshot) commandmeta.HelpView {
|
||||||
|
view := commandmeta.HelpView{
|
||||||
|
EnteredPath: clonePath(route.enteredPath),
|
||||||
|
CanonicalPath: clonePath(route.canonicalPath),
|
||||||
|
Discovery: route.kind == routeDiscovery,
|
||||||
|
CoreFlags: commandmeta.ReportingFlags(),
|
||||||
|
}
|
||||||
|
if snapshot == nil {
|
||||||
|
return view
|
||||||
|
}
|
||||||
|
view.CommandAliases = routeHelpAliases(snapshot, route)
|
||||||
|
if view.Discovery {
|
||||||
|
view.FlagAliases = snapshot.discoveryFlags[route.discoveryPath.Key()].neutralRecords()
|
||||||
|
return view
|
||||||
|
}
|
||||||
|
view.FlagAliases = snapshot.flagAliases[route.canonicalPath.Key()].neutralRecords()
|
||||||
|
return view
|
||||||
|
}
|
||||||
|
|
||||||
|
func clonePath(path commandpath.Path) []string {
|
||||||
|
return append([]string(nil), path...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func routeHelpAliases(snapshot *routingSnapshot, route routeResult) []commandmeta.CommandAlias {
|
||||||
|
if route.kind != routeDiscovery && len(route.canonicalPath) == 0 {
|
||||||
|
return append([]commandmeta.CommandAlias(nil), snapshot.commandAliases...)
|
||||||
|
}
|
||||||
|
bySource := make(map[string]commandmeta.CommandAlias)
|
||||||
|
for _, prefix := range snapshot.aliases.ResolutionPaths(route.enteredPath) {
|
||||||
|
for _, alias := range snapshot.commandAliases {
|
||||||
|
source := commandpath.FromTokens(alias.Source)
|
||||||
|
if len(source) <= len(prefix) || !source.HasPrefix(prefix) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
suffix := source[len(prefix):]
|
||||||
|
entered := append(route.enteredPath.Clone(), suffix...)
|
||||||
|
target := snapshot.aliases.Resolve(entered)
|
||||||
|
if traceCanonicalPath(snapshot.canonical, target).exact == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
bySource[suffix.Key()] = commandmeta.CommandAlias{Source: clonePath(suffix), Target: clonePath(target)}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
keys := make([]string, 0, len(bySource))
|
||||||
|
for key := range bySource {
|
||||||
|
keys = append(keys, key)
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
result := make([]commandmeta.CommandAlias, 0, len(keys))
|
||||||
|
for _, key := range keys {
|
||||||
|
result = append(result, bySource[key])
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
55
identity.go
55
identity.go
@@ -18,8 +18,11 @@ type Identity struct {
|
|||||||
Revision string
|
Revision string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Streams contains the output destinations associated with a command
|
// Streams contains the caller-owned output destinations associated with an
|
||||||
// invocation.
|
// invocation. Out receives substantive results; Err receives messages,
|
||||||
|
// terminal explanations, usage guidance, and CommandKit diagnostics. Nil
|
||||||
|
// destinations are replaced with io.Discard. CommandKit does not close or flush
|
||||||
|
// supplied writers.
|
||||||
type Streams struct {
|
type Streams struct {
|
||||||
// Out receives help and command reports.
|
// Out receives help and command reports.
|
||||||
Out io.Writer
|
Out io.Writer
|
||||||
@@ -27,11 +30,51 @@ type Streams struct {
|
|||||||
Err io.Writer
|
Err io.Writer
|
||||||
}
|
}
|
||||||
|
|
||||||
// Invocation provides execution context and output streams to a command
|
func normalizeStreams(streams Streams) Streams {
|
||||||
// handler.
|
if streams.Out == nil {
|
||||||
|
streams.Out = io.Discard
|
||||||
|
}
|
||||||
|
if streams.Err == nil {
|
||||||
|
streams.Err = io.Discard
|
||||||
|
}
|
||||||
|
return streams
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invocation provides execution context and invocation-scoped reporting to a
|
||||||
|
// command handler. Execute finalizes its reporter before returning. For command
|
||||||
|
// aliases, EnteredPath and CanonicalPath expose the route the caller used and
|
||||||
|
// the selected canonical command path. Both omit the executable name, flags,
|
||||||
|
// and operands.
|
||||||
type Invocation struct {
|
type Invocation struct {
|
||||||
// Context carries caller cancellation and request-scoped values.
|
// Context carries caller cancellation and request-scoped values.
|
||||||
Context context.Context
|
Context context.Context
|
||||||
// Streams are the caller-selected output destinations.
|
|
||||||
Streams Streams
|
reporter *Reporter
|
||||||
|
enteredPath string
|
||||||
|
canonicalPath string
|
||||||
|
renderHelp func() Outcome
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reporter returns the reporting facility supplied for this invocation. A
|
||||||
|
// manually constructed Invocation has no reporter.
|
||||||
|
func (inv Invocation) Reporter() *Reporter { return inv.reporter }
|
||||||
|
|
||||||
|
// Help renders the selected command's help through this invocation's output.
|
||||||
|
// It returns an operational failure when the invocation has no help renderer.
|
||||||
|
func (inv Invocation) Help() Outcome {
|
||||||
|
if inv.renderHelp == nil {
|
||||||
|
return Failure("Command help is unavailable.")
|
||||||
|
}
|
||||||
|
return inv.renderHelp()
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnteredPath returns the command path used to invoke the selected command,
|
||||||
|
// excluding the executable name, flags, and operands. It is empty for an
|
||||||
|
// Invocation that was not created by command execution.
|
||||||
|
func (inv Invocation) EnteredPath() string { return inv.enteredPath }
|
||||||
|
|
||||||
|
// CanonicalPath returns the selected command's canonical path after resolving
|
||||||
|
// aliases, excluding the executable name, flags, and operands. It equals
|
||||||
|
// EnteredPath for a direct canonical invocation and is empty for an Invocation
|
||||||
|
// that was not created by command execution.
|
||||||
|
func (inv Invocation) CanonicalPath() string { return inv.canonicalPath }
|
||||||
|
|||||||
@@ -7,10 +7,9 @@ itself.
|
|||||||
|
|
||||||
## Module
|
## Module
|
||||||
|
|
||||||
The module path is
|
The module path is `go.maximumdirect.net/ckconfig`. Version v0.3.0 requires Go
|
||||||
`gitea.maximumdirect.net/eric/CommandKit/integrations/ckconfig`. Its first
|
1.27.0 and uses `go.maximumdirect.net/commandkit v0.3.0` and
|
||||||
release is prepared as `v0.1.0`. It uses the core `commandkit` package and
|
`go.maximumdirect.net/configkit v1.1.0`; it does not import PromptKit. See the
|
||||||
ConfigKit; it does not import PromptKit. See the
|
|
||||||
[consumer guide](https://gitea.maximumdirect.net/eric/CommandKit/src/branch/main/docs/consumers/guide.md#configkit-scopes)
|
[consumer guide](https://gitea.maximumdirect.net/eric/CommandKit/src/branch/main/docs/consumers/guide.md#configkit-scopes)
|
||||||
for exact callback and lifetime contracts.
|
for exact callback and lifetime contracts.
|
||||||
|
|
||||||
@@ -27,9 +26,9 @@ scopes, selectors, input checks, and state shared by the selected acquisitions
|
|||||||
or comparison in a config invocation. `ckconfig.FromConfigKit` adapts a typed
|
or comparison in a config invocation. `ckconfig.FromConfigKit` adapts a typed
|
||||||
ConfigKit loader. The `config` command family exposes scope discovery and the
|
ConfigKit loader. The `config` command family exposes scope discovery and the
|
||||||
validate, show, sources, explain, and diff operations supported by the declared
|
validate, show, sources, explain, and diff operations supported by the declared
|
||||||
scopes. Source reports include native destination, placement, unresolved
|
scopes. Source reports include native destination, placement, destination
|
||||||
destination, and source version-check evidence. Comparison returns ConfigKit's
|
fallback, version evidence, and empty-import selections. Comparison returns
|
||||||
native records to the YAML report path; the
|
ConfigKit's native records to shared report rendering; the
|
||||||
[CLI reference](https://gitea.maximumdirect.net/eric/CommandKit/src/branch/main/docs/cli.md#compare-configuration-selections)
|
[CLI reference](https://gitea.maximumdirect.net/eric/CommandKit/src/branch/main/docs/cli.md#compare-configuration-selections)
|
||||||
owns its operator details.
|
owns its operator details.
|
||||||
|
|
||||||
|
|||||||
@@ -7,11 +7,12 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
commandkit "gitea.maximumdirect.net/eric/CommandKit"
|
commandkit "go.maximumdirect.net/commandkit"
|
||||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
configkit "go.maximumdirect.net/configkit"
|
||||||
)
|
)
|
||||||
|
|
||||||
type adapterSettings struct {
|
type adapterSettings struct {
|
||||||
@@ -27,7 +28,7 @@ func adapterDefinition() configkit.Definition[adapterSettings] {
|
|||||||
validation.Warning(configkit.Keys("greeting"), "using the default greeting")
|
validation.Warning(configkit.Keys("greeting"), "using the default greeting")
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
Secret: func(path configkit.Path) bool { return path.String() == "/secret" },
|
Sensitive: func(path configkit.Path) bool { return path.String() == "/secret" },
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,7 +67,7 @@ func TestFromConfigKitCompareReturnsNativeChangesAndLoadsEqualSelections(t *test
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if len(calls) != 2 || calls[0].selection != selection || calls[1].selection != selection {
|
if len(calls) != 2 || !reflect.DeepEqual(calls[0].selection, selection) || !reflect.DeepEqual(calls[1].selection, selection) {
|
||||||
t.Fatalf("loader calls = %#v", calls)
|
t.Fatalf("loader calls = %#v", calls)
|
||||||
}
|
}
|
||||||
if calls[0].input.Label != "before" || calls[1].input.Label != "after" || calls[0].state != state || calls[1].state != state || *state != 2 {
|
if calls[0].input.Label != "before" || calls[1].input.Label != "after" || calls[0].state != state || calls[1].state != state || *state != 2 {
|
||||||
@@ -97,7 +98,7 @@ func TestFromConfigKitCompareIgnoresDefaultAndResolutionHistoryForEqualValues(t
|
|||||||
Resolve: func(value *resolvedSettings, resolution *configkit.Resolution) error {
|
Resolve: func(value *resolvedSettings, resolution *configkit.Resolution) error {
|
||||||
if value.Greeting != "resolved" {
|
if value.Greeting != "resolved" {
|
||||||
value.Greeting = "resolved"
|
value.Greeting = "resolved"
|
||||||
resolution.Record(configkit.Keys("greeting"), configkit.ChangeNormalization, "canonical greeting")
|
resolution.RecordNormalization(configkit.Keys("greeting"), "canonical greeting")
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
@@ -192,14 +193,14 @@ func TestFromConfigKitCompareRetainsSideDiagnosticsAndLoadError(t *testing.T) {
|
|||||||
if !errors.As(gotErr, &typed) || typed != loadErr {
|
if !errors.As(gotErr, &typed) || typed != loadErr {
|
||||||
t.Fatalf("load error type not preserved: %#v", typed)
|
t.Fatalf("load error type not preserved: %#v", typed)
|
||||||
}
|
}
|
||||||
if len(acquired.Diagnostics) != 2 || acquired.Diagnostics[0].Severity != commandkit.SeverityWarning || acquired.Diagnostics[1].Severity != commandkit.SeverityError {
|
if len(acquired.Diagnostics) != 1 || acquired.Diagnostics[0].Severity != commandkit.SeverityWarning || len(acquired.Explanation.Details) != 1 {
|
||||||
t.Fatalf("retained diagnostics = %#v", acquired.Diagnostics)
|
t.Fatalf("retained diagnostics/explanation = %#v / %#v", acquired.Diagnostics, acquired.Explanation)
|
||||||
}
|
}
|
||||||
var leftText, rightText bytes.Buffer
|
var leftText, rightText bytes.Buffer
|
||||||
if err := acquired.Diagnostics[0].Write(&leftText); err != nil {
|
if err := acquired.Diagnostics[0].Write(&leftText); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if err := acquired.Diagnostics[1].Write(&rightText); err != nil {
|
if err := acquired.Explanation.Details[0](&rightText); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if !strings.Contains(leftText.String(), `Configuration scope "app" left:`) || !strings.Contains(leftText.String(), `"left-source"`) {
|
if !strings.Contains(leftText.String(), `Configuration scope "app" left:`) || !strings.Contains(leftText.String(), `"left-source"`) {
|
||||||
@@ -231,8 +232,15 @@ func TestFromConfigKitCompareStopsAfterLeftLoadFailure(t *testing.T) {
|
|||||||
t.Fatalf("left failure/right load = %v / %v", err, rightCalled)
|
t.Fatalf("left failure/right load = %v / %v", err, rightCalled)
|
||||||
}
|
}
|
||||||
var typed *configkit.LoadError
|
var typed *configkit.LoadError
|
||||||
if !errors.As(err, &typed) || typed != loadErr || len(acquired.Diagnostics) != 1 || acquired.Diagnostics[0].Severity != commandkit.SeverityError {
|
if !errors.As(err, &typed) || typed != loadErr || len(acquired.Explanation.Details) != 1 {
|
||||||
t.Fatalf("left failure identity/diagnostics = %v / %#v", err, acquired.Diagnostics)
|
t.Fatalf("left failure identity/explanation = %v / %#v", err, acquired.Explanation)
|
||||||
|
}
|
||||||
|
var terminal bytes.Buffer
|
||||||
|
if err := acquired.Explanation.Details[0](&terminal); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(terminal.String(), `Configuration scope "app" left:`) || !strings.Contains(terminal.String(), `"left-source"`) {
|
||||||
|
t.Fatalf("left terminal context lost side/source identity: %q", terminal.String())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
commandkit "gitea.maximumdirect.net/eric/CommandKit"
|
commandkit "go.maximumdirect.net/commandkit"
|
||||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
configkit "go.maximumdirect.net/configkit"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestComparisonCapabilitiesAreIndependentAndCaptured(t *testing.T) {
|
func TestComparisonCapabilitiesAreIndependentAndCaptured(t *testing.T) {
|
||||||
|
|||||||
@@ -3,64 +3,14 @@ package ckconfig
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"io"
|
"io"
|
||||||
|
"reflect"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
commandkit "gitea.maximumdirect.net/eric/CommandKit"
|
commandkit "go.maximumdirect.net/commandkit"
|
||||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
configkit "go.maximumdirect.net/configkit"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestComparisonSelectionInheritsFieldsIndependently(t *testing.T) {
|
|
||||||
leftConfig := commandkit.Supplied[string]{Value: "before.yml", Set: true}
|
|
||||||
leftOverlay := commandkit.Supplied[string]{Value: "base", Set: true}
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
left Selection
|
|
||||||
right Selection
|
|
||||||
want Selection
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "omitted fields stay omitted",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "both fields inherit supplied state",
|
|
||||||
left: Selection{Config: leftConfig, Overlay: leftOverlay},
|
|
||||||
want: Selection{Config: leftConfig, Overlay: leftOverlay},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "explicit empty config overrides while overlay inherits",
|
|
||||||
left: Selection{Config: leftConfig, Overlay: leftOverlay},
|
|
||||||
right: Selection{Config: commandkit.Supplied[string]{Set: true}},
|
|
||||||
want: Selection{Config: commandkit.Supplied[string]{Set: true}, Overlay: leftOverlay},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "explicit overlay overrides while config inherits",
|
|
||||||
left: Selection{Config: leftConfig, Overlay: leftOverlay},
|
|
||||||
right: Selection{Overlay: commandkit.Supplied[string]{Value: "candidate", Set: true}},
|
|
||||||
want: Selection{Config: leftConfig, Overlay: commandkit.Supplied[string]{Value: "candidate", Set: true}},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "explicit empty overlay remains supplied",
|
|
||||||
left: Selection{Config: leftConfig, Overlay: leftOverlay},
|
|
||||||
right: Selection{Overlay: commandkit.Supplied[string]{Set: true}},
|
|
||||||
want: Selection{Config: leftConfig, Overlay: commandkit.Supplied[string]{Set: true}},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "both explicit right fields replace left",
|
|
||||||
left: Selection{Config: leftConfig, Overlay: leftOverlay},
|
|
||||||
right: Selection{Config: commandkit.Supplied[string]{Value: "after.yml", Set: true}, Overlay: commandkit.Supplied[string]{Value: "new", Set: true}},
|
|
||||||
want: Selection{Config: commandkit.Supplied[string]{Value: "after.yml", Set: true}, Overlay: commandkit.Supplied[string]{Value: "new", Set: true}},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
for _, test := range tests {
|
|
||||||
t.Run(test.name, func(t *testing.T) {
|
|
||||||
if got := inheritComparisonSelection(test.left, test.right); got != test.want {
|
|
||||||
t.Fatalf("inherited selection = %#v, want %#v", got, test.want)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestComparisonHelpLimitsFlagsAndDefaultsFormatToYAML(t *testing.T) {
|
func TestComparisonHelpLimitsFlagsAndDefaultsFormatToYAML(t *testing.T) {
|
||||||
scope := Scope[struct{}, struct{}]{
|
scope := Scope[struct{}, struct{}]{
|
||||||
Name: "app", Operations: []Operation{Diff, Show, Explain, Validate, Sources},
|
Name: "app", Operations: []Operation{Diff, Show, Explain, Validate, Sources},
|
||||||
@@ -83,21 +33,12 @@ func TestComparisonHelpLimitsFlagsAndDefaultsFormatToYAML(t *testing.T) {
|
|||||||
if !strings.Contains(diffHelp, "--against-config") || !strings.Contains(diffHelp, "--against-overlay") {
|
if !strings.Contains(diffHelp, "--against-config") || !strings.Contains(diffHelp, "--against-overlay") {
|
||||||
t.Fatalf("diff help omits right-side selection flags:\n%s", diffHelp)
|
t.Fatalf("diff help omits right-side selection flags:\n%s", diffHelp)
|
||||||
}
|
}
|
||||||
if !strings.Contains(diffHelp, "default: yaml") {
|
|
||||||
t.Fatalf("diff help does not show the YAML default:\n%s", diffHelp)
|
|
||||||
}
|
|
||||||
for _, operation := range []string{"validate", "show", "sources", "explain"} {
|
for _, operation := range []string{"validate", "show", "sources", "explain"} {
|
||||||
operationHelp := help("config", operation, "--help")
|
operationHelp := help("config", operation, "--help")
|
||||||
if strings.Contains(operationHelp, "--against-config") || strings.Contains(operationHelp, "--against-overlay") {
|
if strings.Contains(operationHelp, "--against-config") || strings.Contains(operationHelp, "--against-overlay") {
|
||||||
t.Errorf("%s exposes comparison-only flags:\n%s", operation, operationHelp)
|
t.Errorf("%s exposes comparison-only flags:\n%s", operation, operationHelp)
|
||||||
}
|
}
|
||||||
if operation == "show" && !strings.Contains(operationHelp, "default: yaml") {
|
if operation != "explain" && operation != "show" && strings.Contains(operationHelp, "--path") {
|
||||||
t.Errorf("show format default changed:\n%s", operationHelp)
|
|
||||||
}
|
|
||||||
if operation != "show" && !strings.Contains(operationHelp, "default: text") {
|
|
||||||
t.Errorf("%s format default changed:\n%s", operation, operationHelp)
|
|
||||||
}
|
|
||||||
if operation != "explain" && strings.Contains(operationHelp, "--path") {
|
|
||||||
t.Errorf("%s exposes the explain-only path flag:\n%s", operation, operationHelp)
|
t.Errorf("%s exposes the explain-only path flag:\n%s", operation, operationHelp)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -107,7 +48,7 @@ func TestComparisonHelpLimitsFlagsAndDefaultsFormatToYAML(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestComparisonPreparationAcceptsOnlyYAML(t *testing.T) {
|
func TestComparisonPreparation(t *testing.T) {
|
||||||
inputCalls, stateCalls := 0, 0
|
inputCalls, stateCalls := 0, 0
|
||||||
prepared := captureSuite(Suite[struct{}, struct{}]{
|
prepared := captureSuite(Suite[struct{}, struct{}]{
|
||||||
NewInput: func() struct{} { inputCalls++; return struct{}{} },
|
NewInput: func() struct{} { inputCalls++; return struct{}{} },
|
||||||
@@ -123,30 +64,22 @@ func TestComparisonPreparationAcceptsOnlyYAML(t *testing.T) {
|
|||||||
input := &configCommandInput[struct{}, struct{}]{
|
input := &configCommandInput[struct{}, struct{}]{
|
||||||
selection: Selection{Config: commandkit.Supplied[string]{Value: "before.yml", Set: true}},
|
selection: Selection{Config: commandkit.Supplied[string]{Value: "before.yml", Set: true}},
|
||||||
against: Selection{Overlay: commandkit.Supplied[string]{Value: "candidate", Set: true}},
|
against: Selection{Overlay: commandkit.Supplied[string]{Value: "candidate", Set: true}},
|
||||||
format: commandkit.Supplied[string]{Value: "yaml", Set: true},
|
|
||||||
}
|
}
|
||||||
if err := prepareConfigOperation(prepared, Diff, input); err != nil {
|
if err := prepareConfigOperation(prepared, Diff, input); err != nil {
|
||||||
t.Fatalf("explicit YAML format rejected: %v", err)
|
t.Fatalf("explicit YAML format rejected: %v", err)
|
||||||
}
|
}
|
||||||
if input.plan == nil || input.plan.format != "yaml" {
|
if input.plan == nil {
|
||||||
t.Fatalf("comparison plan = %#v", input.plan)
|
t.Fatalf("comparison plan = %#v", input.plan)
|
||||||
}
|
}
|
||||||
if input.plan.scopeName != "app" {
|
if input.plan.scopeName != "app" {
|
||||||
t.Fatalf("comparison default scope = %q, want app", input.plan.scopeName)
|
t.Fatalf("comparison default scope = %q, want app", input.plan.scopeName)
|
||||||
}
|
}
|
||||||
if want := (Selection{
|
if want := (Selection{
|
||||||
Config: commandkit.Supplied[string]{Value: "before.yml", Set: true},
|
|
||||||
Overlay: commandkit.Supplied[string]{Value: "candidate", Set: true},
|
Overlay: commandkit.Supplied[string]{Value: "candidate", Set: true},
|
||||||
}); input.against != want {
|
}); !reflect.DeepEqual(input.against, want) {
|
||||||
t.Fatalf("resolved right selection = %#v, want %#v", input.against, want)
|
t.Fatalf("resolved right selection = %#v, want %#v", input.against, want)
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, format := range []string{"text", "json"} {
|
|
||||||
input := &configCommandInput[struct{}, struct{}]{format: commandkit.Supplied[string]{Value: format}}
|
|
||||||
if err := prepareConfigOperation(prepared, Diff, input); err == nil {
|
|
||||||
t.Errorf("comparison format %q was accepted", format)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if inputCalls != 2 || stateCalls != 0 {
|
if inputCalls != 2 || stateCalls != 0 {
|
||||||
t.Fatalf("comparison preparation calls input/state: %d/%d", inputCalls, stateCalls)
|
t.Fatalf("comparison preparation calls input/state: %d/%d", inputCalls, stateCalls)
|
||||||
}
|
}
|
||||||
@@ -170,8 +103,7 @@ func TestComparisonAvailabilityPreflightPrecedesFactories(t *testing.T) {
|
|||||||
})
|
})
|
||||||
for _, scope := range []string{"missing", "all", "plain"} {
|
for _, scope := range []string{"missing", "all", "plain"} {
|
||||||
input := &configCommandInput[struct{}, struct{}]{
|
input := &configCommandInput[struct{}, struct{}]{
|
||||||
scope: commandkit.Supplied[string]{Value: scope, Set: true},
|
scope: commandkit.Supplied[string]{Value: scope, Set: true},
|
||||||
format: commandkit.Supplied[string]{Value: "yaml"},
|
|
||||||
}
|
}
|
||||||
if err := prepareConfigOperation(prepared, Diff, input); err == nil {
|
if err := prepareConfigOperation(prepared, Diff, input); err == nil {
|
||||||
t.Errorf("comparison scope %q was accepted", scope)
|
t.Errorf("comparison scope %q was accepted", scope)
|
||||||
|
|||||||
@@ -4,12 +4,13 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
|
"reflect"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
commandkit "gitea.maximumdirect.net/eric/CommandKit"
|
commandkit "go.maximumdirect.net/commandkit"
|
||||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
configkit "go.maximumdirect.net/configkit"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestComparisonPreparesEqualSelectionsWithSeparateTypedInputs(t *testing.T) {
|
func TestComparisonPreparesEqualSelectionsWithSeparateTypedInputs(t *testing.T) {
|
||||||
@@ -62,7 +63,7 @@ func TestComparisonPreparesEqualSelectionsWithSeparateTypedInputs(t *testing.T)
|
|||||||
}
|
}
|
||||||
return struct{}{}
|
return struct{}{}
|
||||||
},
|
},
|
||||||
Selectors: []Selector[*input]{{Binding: enabled}, {Binding: limit}, {Binding: tags}},
|
Selectors: []Selector[*input]{{Binding: enabled, AgainstName: "against-enabled"}, {Binding: limit, AgainstName: "against-limit"}, {Binding: tags, AgainstName: "against-tag"}},
|
||||||
CheckInput: func(scope string, selection Selection, value *input) error {
|
CheckInput: func(scope string, selection Selection, value *input) error {
|
||||||
checkCalls++
|
checkCalls++
|
||||||
preparedInputs = append(preparedInputs, value)
|
preparedInputs = append(preparedInputs, value)
|
||||||
@@ -91,7 +92,7 @@ func TestComparisonPreparesEqualSelectionsWithSeparateTypedInputs(t *testing.T)
|
|||||||
app := newConfigTestApp(t, Command(suite))
|
app := newConfigTestApp(t, Command(suite))
|
||||||
outcome := app.Execute(context.Background(), []string{
|
outcome := app.Execute(context.Background(), []string{
|
||||||
"config", "diff", "--config", "same.yml", "--against-config", "same.yml", "--overlay=",
|
"config", "diff", "--config", "same.yml", "--against-config", "same.yml", "--overlay=",
|
||||||
"--enabled=false", "--limit=0", "--tag", "alpha", "--tag", "beta",
|
"--enabled=false", "--limit=0", "--tag", "alpha", "--tag", "beta", "--against-enabled=false", "--against-limit=0", "--against-tag", "alpha", "--against-tag", "beta", "--against-overlay=",
|
||||||
}, commandkit.Streams{Out: io.Discard, Err: io.Discard})
|
}, commandkit.Streams{Out: io.Discard, Err: io.Discard})
|
||||||
if outcome.Kind != commandkit.OutcomeSuccess {
|
if outcome.Kind != commandkit.OutcomeSuccess {
|
||||||
t.Fatalf("valid comparison outcome = %#v", outcome)
|
t.Fatalf("valid comparison outcome = %#v", outcome)
|
||||||
@@ -99,14 +100,14 @@ func TestComparisonPreparesEqualSelectionsWithSeparateTypedInputs(t *testing.T)
|
|||||||
if inputCalls != 2 || checkCalls != 2 || stateCalls != 1 {
|
if inputCalls != 2 || checkCalls != 2 || stateCalls != 1 {
|
||||||
t.Fatalf("preparation calls input=%d check=%d state=%d", inputCalls, checkCalls, stateCalls)
|
t.Fatalf("preparation calls input=%d check=%d state=%d", inputCalls, checkCalls, stateCalls)
|
||||||
}
|
}
|
||||||
if parseCalls != 3 {
|
if parseCalls != 6 {
|
||||||
t.Fatalf("typed values were converted %d times, want once per supplied occurrence", parseCalls)
|
t.Fatalf("typed values were converted %d times, want once per supplied occurrence", parseCalls)
|
||||||
}
|
}
|
||||||
wantSelection := Selection{
|
wantSelection := Selection{
|
||||||
Config: commandkit.Supplied[string]{Value: "same.yml", Set: true},
|
Config: commandkit.Supplied[string]{Value: "same.yml", Set: true},
|
||||||
Overlay: commandkit.Supplied[string]{Set: true},
|
Overlay: commandkit.Supplied[string]{Set: true},
|
||||||
}
|
}
|
||||||
if len(selections) != 2 || selections[0] != wantSelection || selections[1] != wantSelection {
|
if len(selections) != 2 || !reflect.DeepEqual(selections[0], wantSelection) || !reflect.DeepEqual(selections[1], wantSelection) {
|
||||||
t.Fatalf("side selections = %#v, want equal %#v values", selections, wantSelection)
|
t.Fatalf("side selections = %#v, want equal %#v values", selections, wantSelection)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -115,7 +116,7 @@ func TestComparisonProviderReceivesCheckedPairAndSharedState(t *testing.T) {
|
|||||||
type input struct{ ID int }
|
type input struct{ ID int }
|
||||||
type state struct{ Label string }
|
type state struct{ Label string }
|
||||||
inputCalls, checkCalls, stateCalls, compareCalls, acquireCalls := 0, 0, 0, 0, 0
|
inputCalls, checkCalls, stateCalls, compareCalls, acquireCalls := 0, 0, 0, 0, 0
|
||||||
checked := make(map[Selection]input)
|
var checked []input
|
||||||
shared := &state{Label: "shared"}
|
shared := &state{Label: "shared"}
|
||||||
var request ComparisonRequest[input]
|
var request ComparisonRequest[input]
|
||||||
app := newConfigTestApp(t, Command(Suite[input, *state]{
|
app := newConfigTestApp(t, Command(Suite[input, *state]{
|
||||||
@@ -135,7 +136,7 @@ func TestComparisonProviderReceivesCheckedPairAndSharedState(t *testing.T) {
|
|||||||
if scope != "app" {
|
if scope != "app" {
|
||||||
return errors.New("unexpected scope")
|
return errors.New("unexpected scope")
|
||||||
}
|
}
|
||||||
checked[selection] = value
|
checked = append(checked, value)
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
Scopes: []Scope[input, *state]{{
|
Scopes: []Scope[input, *state]{{
|
||||||
@@ -161,11 +162,11 @@ func TestComparisonProviderReceivesCheckedPairAndSharedState(t *testing.T) {
|
|||||||
t.Fatalf("comparison outcome = %#v", outcome)
|
t.Fatalf("comparison outcome = %#v", outcome)
|
||||||
}
|
}
|
||||||
left := Selection{Config: commandkit.Supplied[string]{Value: "before.yml", Set: true}}
|
left := Selection{Config: commandkit.Supplied[string]{Value: "before.yml", Set: true}}
|
||||||
right := Selection{Config: left.Config, Overlay: commandkit.Supplied[string]{Set: true}}
|
right := Selection{Overlay: commandkit.Supplied[string]{Set: true}}
|
||||||
if request.Left.Selection != left || request.Right.Selection != right {
|
if !reflect.DeepEqual(request.Left.Selection, left) || !reflect.DeepEqual(request.Right.Selection, right) {
|
||||||
t.Fatalf("provider selections = %#v", request)
|
t.Fatalf("provider selections = %#v", request)
|
||||||
}
|
}
|
||||||
if len(checked) != 2 || request.Left.Input != checked[left] || request.Right.Input != checked[right] {
|
if len(checked) != 2 || request.Left.Input != checked[0] || request.Right.Input != checked[1] {
|
||||||
t.Fatalf("provider inputs = %#v, checked = %#v", request, checked)
|
t.Fatalf("provider inputs = %#v, checked = %#v", request, checked)
|
||||||
}
|
}
|
||||||
if inputCalls != 2 || checkCalls != 2 || stateCalls != 1 || compareCalls != 1 || acquireCalls != 0 {
|
if inputCalls != 2 || checkCalls != 2 || stateCalls != 1 || compareCalls != 1 || acquireCalls != 0 {
|
||||||
@@ -191,7 +192,7 @@ func TestComparisonDoesNotRetainPartialPlanAfterSideFailure(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}},
|
}},
|
||||||
})
|
})
|
||||||
commandInput := &configCommandInput[struct{}, struct{}]{format: commandkit.Supplied[string]{Value: "yaml"}}
|
commandInput := &configCommandInput[struct{}, struct{}]{}
|
||||||
if err := prepareConfigOperation(prepared, Diff, commandInput); !errors.Is(err, failure) {
|
if err := prepareConfigOperation(prepared, Diff, commandInput); !errors.Is(err, failure) {
|
||||||
t.Fatalf("right-side input failure = %v", err)
|
t.Fatalf("right-side input failure = %v", err)
|
||||||
}
|
}
|
||||||
@@ -217,7 +218,7 @@ func TestComparisonNilInputFactoryUsesZeroValue(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}},
|
}},
|
||||||
})
|
})
|
||||||
commandInput := &configCommandInput[*int, struct{}]{format: commandkit.Supplied[string]{Value: "yaml"}}
|
commandInput := &configCommandInput[*int, struct{}]{}
|
||||||
if err := prepareConfigOperation(prepared, Diff, commandInput); err != nil {
|
if err := prepareConfigOperation(prepared, Diff, commandInput); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -246,7 +247,7 @@ func TestComparisonPreservesCallerOwnedInputAliases(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}},
|
}},
|
||||||
})
|
})
|
||||||
commandInput := &configCommandInput[*int, struct{}]{format: commandkit.Supplied[string]{Value: "yaml"}}
|
commandInput := &configCommandInput[*int, struct{}]{}
|
||||||
if err := prepareConfigOperation(prepared, Diff, commandInput); err != nil {
|
if err := prepareConfigOperation(prepared, Diff, commandInput); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
11
integrations/ckconfig/completion.go
Normal file
11
integrations/ckconfig/completion.go
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
package ckconfig
|
||||||
|
|
||||||
|
import commandkit "go.maximumdirect.net/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...)
|
||||||
|
}
|
||||||
66
integrations/ckconfig/completion_test.go
Normal file
66
integrations/ckconfig/completion_test.go
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
package ckconfig
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
commandkit "go.maximumdirect.net/commandkit"
|
||||||
|
configkit "go.maximumdirect.net/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", "invalid\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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,8 +7,8 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
commandkit "gitea.maximumdirect.net/eric/CommandKit"
|
commandkit "go.maximumdirect.net/commandkit"
|
||||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
configkit "go.maximumdirect.net/configkit"
|
||||||
)
|
)
|
||||||
|
|
||||||
// FromConfigKit adapts a typed ConfigKit loader to all five native
|
// FromConfigKit adapts a typed ConfigKit loader to all five native
|
||||||
@@ -26,6 +26,7 @@ func FromConfigKit[I, S, T any](
|
|||||||
Name: name,
|
Name: name,
|
||||||
Summary: summary,
|
Summary: summary,
|
||||||
Operations: []Operation{Validate, Show, Sources, Explain, Diff},
|
Operations: []Operation{Validate, Show, Sources, Explain, Diff},
|
||||||
|
Features: ScopeFeatures{PathDiscovery: true, PathShow: true},
|
||||||
}
|
}
|
||||||
if load == nil {
|
if load == nil {
|
||||||
return WithScopeError(scope, fmt.Errorf("ConfigKit scope %q loader must not be nil", name))
|
return WithScopeError(scope, fmt.Errorf("ConfigKit scope %q loader must not be nil", name))
|
||||||
@@ -33,53 +34,61 @@ func FromConfigKit[I, S, T any](
|
|||||||
|
|
||||||
scope.Acquire = func(ctx context.Context, request Request, input I, state S) (commandkit.Acquired[View], error) {
|
scope.Acquire = func(ctx context.Context, request Request, input I, state S) (commandkit.Acquired[View], error) {
|
||||||
result, err := load(ctx, request.Selection, input, state)
|
result, err := load(ctx, request.Selection, input, state)
|
||||||
|
acquired := commandkit.Acquired[View]{}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
var loadError *configkit.LoadError
|
var loadError *configkit.LoadError
|
||||||
if errors.As(err, &loadError) && loadError != nil {
|
if errors.As(err, &loadError) && loadError != nil {
|
||||||
return commandkit.Acquired[View]{Diagnostics: configKitDiagnostics(name, loadError.Sources(), loadError.Diagnostics())}, err
|
records := loadError.Diagnostics()
|
||||||
|
messages, explanation := configKitDiagnostics(name, loadError.Sources(), records)
|
||||||
|
acquired.Value.Sources = loadError
|
||||||
|
acquired.Value.Findings = &Findings{Records: records, Messages: messages}
|
||||||
|
acquired.Explanation = explanation
|
||||||
}
|
}
|
||||||
return commandkit.Acquired[View]{}, err
|
return acquired, err
|
||||||
}
|
}
|
||||||
if result == nil {
|
if result == nil {
|
||||||
return commandkit.Acquired[View]{}, fmt.Errorf("ConfigKit scope %q returned a nil result", name)
|
return commandkit.Acquired[View]{}, fmt.Errorf("ConfigKit scope %q returned a nil result", name)
|
||||||
}
|
}
|
||||||
|
metadata := result.Metadata()
|
||||||
sources := result.Sources()
|
records := result.Diagnostics()
|
||||||
return commandkit.Acquired[View]{
|
messages, explanation := configKitDiagnostics(name, result.Sources(), records)
|
||||||
Value: View{Effective: result, Sources: result, Explanation: result},
|
acquired.Value = View{Effective: result, Sources: result, Explanation: result, Metadata: &metadata, Findings: &Findings{Records: records, Messages: messages}}
|
||||||
Diagnostics: configKitDiagnostics(name, sources, result.Diagnostics()),
|
acquired.Explanation = explanation
|
||||||
}, nil
|
return acquired, nil
|
||||||
}
|
}
|
||||||
scope.Compare = func(ctx context.Context, request ComparisonRequest[I], state S) (commandkit.Acquired[[]configkit.Difference], error) {
|
scope.Compare = func(ctx context.Context, request ComparisonRequest[I], state S) (commandkit.Acquired[[]configkit.Difference], error) {
|
||||||
if err := ctx.Err(); err != nil {
|
if err := ctx.Err(); err != nil {
|
||||||
return commandkit.Acquired[[]configkit.Difference]{}, fmt.Errorf("ConfigKit scope %q comparison before left load: %w", name, err)
|
return commandkit.Acquired[[]configkit.Difference]{}, fmt.Errorf("ConfigKit scope %q comparison before left load: %w", name, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
left, leftDiagnostics, err := loadComparisonSide(ctx, name, "left", load, request.Left, state)
|
acquired := commandkit.Acquired[[]configkit.Difference]{}
|
||||||
diagnostics := append([]commandkit.Diagnostic(nil), leftDiagnostics...)
|
left, leftDiagnostics, leftExplanation, err := loadComparisonSide(ctx, name, "left", load, request.Left, state)
|
||||||
|
acquired.Diagnostics = append(acquired.Diagnostics, leftDiagnostics...)
|
||||||
|
acquired.Explanation = appendConfigKitExplanation(acquired.Explanation, leftExplanation)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return commandkit.Acquired[[]configkit.Difference]{Diagnostics: diagnostics}, err
|
return acquired, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := ctx.Err(); err != nil {
|
if err := ctx.Err(); err != nil {
|
||||||
return commandkit.Acquired[[]configkit.Difference]{Diagnostics: diagnostics}, fmt.Errorf("ConfigKit scope %q comparison after left load: %w", name, err)
|
return acquired, fmt.Errorf("ConfigKit scope %q comparison after left load: %w", name, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
right, rightDiagnostics, err := loadComparisonSide(ctx, name, "right", load, request.Right, state)
|
right, rightDiagnostics, rightExplanation, err := loadComparisonSide(ctx, name, "right", load, request.Right, state)
|
||||||
diagnostics = append(diagnostics, rightDiagnostics...)
|
acquired.Diagnostics = append(acquired.Diagnostics, rightDiagnostics...)
|
||||||
|
acquired.Explanation = appendConfigKitExplanation(acquired.Explanation, rightExplanation)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return commandkit.Acquired[[]configkit.Difference]{Diagnostics: diagnostics}, err
|
return acquired, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := ctx.Err(); err != nil {
|
if err := ctx.Err(); err != nil {
|
||||||
return commandkit.Acquired[[]configkit.Difference]{Diagnostics: diagnostics}, fmt.Errorf("ConfigKit scope %q comparison before native diff: %w", name, err)
|
return acquired, fmt.Errorf("ConfigKit scope %q comparison before native diff: %w", name, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
differences, err := configkit.Diff(left, right)
|
acquired.Value, err = configkit.Diff(left, right)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return commandkit.Acquired[[]configkit.Difference]{Diagnostics: diagnostics}, fmt.Errorf("ConfigKit scope %q native diff: %w", name, err)
|
return acquired, fmt.Errorf("ConfigKit scope %q native diff: %w", name, err)
|
||||||
}
|
}
|
||||||
return commandkit.Acquired[[]configkit.Difference]{Value: differences, Diagnostics: diagnostics}, nil
|
return acquired, nil
|
||||||
}
|
}
|
||||||
return scope
|
return scope
|
||||||
}
|
}
|
||||||
@@ -90,67 +99,106 @@ func loadComparisonSide[I, S, T any](
|
|||||||
load func(context.Context, Selection, I, S) (*configkit.Result[T], error),
|
load func(context.Context, Selection, I, S) (*configkit.Result[T], error),
|
||||||
request ComparisonSide[I],
|
request ComparisonSide[I],
|
||||||
state S,
|
state S,
|
||||||
) (*configkit.Result[T], []commandkit.Diagnostic, error) {
|
) (*configkit.Result[T], []commandkit.Diagnostic, commandkit.Explanation, error) {
|
||||||
result, err := load(ctx, request.Selection, request.Input, state)
|
result, err := load(ctx, request.Selection, request.Input, state)
|
||||||
|
var diagnostics []commandkit.Diagnostic
|
||||||
|
var explanation commandkit.Explanation
|
||||||
if err != nil {
|
if err != nil {
|
||||||
var loadError *configkit.LoadError
|
var loadError *configkit.LoadError
|
||||||
var diagnostics []commandkit.Diagnostic
|
|
||||||
if errors.As(err, &loadError) && loadError != nil {
|
if errors.As(err, &loadError) && loadError != nil {
|
||||||
diagnostics = configKitComparisonDiagnostics(scope, side, loadError.Sources(), loadError.Diagnostics())
|
warnings, terminal := configKitComparisonDiagnostics(scope, side, loadError.Sources(), loadError.Diagnostics())
|
||||||
|
diagnostics = append(diagnostics, warnings...)
|
||||||
|
explanation = appendConfigKitExplanation(explanation, terminal)
|
||||||
}
|
}
|
||||||
return nil, diagnostics, fmt.Errorf("ConfigKit scope %q %s load: %w", scope, side, err)
|
return result, diagnostics, explanation, fmt.Errorf("ConfigKit scope %q %s load: %w", scope, side, err)
|
||||||
}
|
}
|
||||||
if result == nil {
|
if result == nil {
|
||||||
return nil, nil, fmt.Errorf("ConfigKit scope %q %s loader returned a nil result", scope, side)
|
return nil, diagnostics, explanation, fmt.Errorf("ConfigKit scope %q %s loader returned a nil result", scope, side)
|
||||||
}
|
}
|
||||||
return result, configKitComparisonDiagnostics(scope, side, result.Sources(), result.Diagnostics()), nil
|
diagnostics, explanation = configKitComparisonDiagnostics(scope, side, result.Sources(), result.Diagnostics())
|
||||||
|
return result, diagnostics, explanation, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func configKitDiagnostics(scope string, sources []configkit.SourceInfo, records []configkit.Diagnostic) []commandkit.Diagnostic {
|
func configKitDiagnostics(scope string, sources []configkit.SourceInfo, records []configkit.Diagnostic) ([]commandkit.Diagnostic, commandkit.Explanation) {
|
||||||
return configKitDiagnosticsWithHeading("Configuration scope "+strconv.Quote(scope)+":\n", sources, records)
|
return configKitDiagnosticsWithHeading("Configuration scope "+strconv.Quote(scope)+":\n", sources, records)
|
||||||
}
|
}
|
||||||
|
|
||||||
func configKitComparisonDiagnostics(scope, side string, sources []configkit.SourceInfo, records []configkit.Diagnostic) []commandkit.Diagnostic {
|
func configKitComparisonDiagnostics(scope, side string, sources []configkit.SourceInfo, records []configkit.Diagnostic) ([]commandkit.Diagnostic, commandkit.Explanation) {
|
||||||
return configKitDiagnosticsWithHeading("Configuration scope "+strconv.Quote(scope)+" "+side+":\n", sources, records)
|
return configKitDiagnosticsWithHeading("Configuration scope "+strconv.Quote(scope)+" "+side+":\n", sources, records)
|
||||||
}
|
}
|
||||||
|
|
||||||
func configKitDiagnosticsWithHeading(heading string, sources []configkit.SourceInfo, records []configkit.Diagnostic) []commandkit.Diagnostic {
|
func configKitDiagnosticsWithHeading(heading string, sources []configkit.SourceInfo, records []configkit.Diagnostic) ([]commandkit.Diagnostic, commandkit.Explanation) {
|
||||||
if len(records) == 0 {
|
if len(records) == 0 {
|
||||||
return nil
|
return nil, commandkit.Explanation{}
|
||||||
}
|
}
|
||||||
|
|
||||||
severity := commandkit.SeverityWarning
|
sources = append([]configkit.SourceInfo(nil), sources...)
|
||||||
|
records = cloneConfigKitDiagnostics(records)
|
||||||
|
warnings := make([]configkit.Diagnostic, 0, len(records))
|
||||||
|
terminal := make([]configkit.Diagnostic, 0, len(records))
|
||||||
for _, record := range records {
|
for _, record := range records {
|
||||||
if record.Severity == configkit.SeverityError {
|
if record.Severity == configkit.SeverityError {
|
||||||
severity = commandkit.SeverityError
|
terminal = append(terminal, record)
|
||||||
break
|
} else {
|
||||||
|
warnings = append(warnings, record)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Capture the upstream snapshots so later calls to Write do not depend on
|
if len(warnings) == 0 {
|
||||||
// slices returned by the provider.
|
return nil, explanationWithConfigKitRecords(heading, sources, terminal)
|
||||||
sources = append([]configkit.SourceInfo(nil), sources...)
|
}
|
||||||
records = append([]configkit.Diagnostic(nil), records...)
|
warning := commandkit.Diagnostic{
|
||||||
return []commandkit.Diagnostic{{
|
Severity: commandkit.SeverityWarning,
|
||||||
Severity: severity,
|
Write: configKitDiagnosticWriter(heading, sources, warnings),
|
||||||
Write: func(writer io.Writer) error {
|
}
|
||||||
if err := writeDiagnosticText(writer, heading); err != nil {
|
return []commandkit.Diagnostic{warning}, explanationWithConfigKitRecords(heading, sources, terminal)
|
||||||
|
}
|
||||||
|
|
||||||
|
func explanationWithConfigKitRecords(heading string, sources []configkit.SourceInfo, records []configkit.Diagnostic) commandkit.Explanation {
|
||||||
|
if len(records) == 0 {
|
||||||
|
return commandkit.Explanation{}
|
||||||
|
}
|
||||||
|
return commandkit.Explanation{Details: []commandkit.Detail{configKitDiagnosticWriter(heading, sources, records)}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func configKitDiagnosticWriter(heading string, sources []configkit.SourceInfo, records []configkit.Diagnostic) commandkit.Detail {
|
||||||
|
return func(writer io.Writer) error {
|
||||||
|
if err := writeDiagnosticText(writer, heading); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(sources) > 0 {
|
||||||
|
if err := writeDiagnosticText(writer, "Source IDs:\n"); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if len(sources) > 0 {
|
for _, source := range sources {
|
||||||
if err := writeDiagnosticText(writer, "Source IDs:\n"); err != nil {
|
line := " " + strconv.Quote(source.ID) + ": label=" + strconv.Quote(source.Label) + ", path=" + strconv.Quote(source.Path) + "\n"
|
||||||
|
if err := writeDiagnosticText(writer, line); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
for _, source := range sources {
|
|
||||||
line := " " + strconv.Quote(source.ID) + ": label=" + strconv.Quote(source.Label) + ", path=" + strconv.Quote(source.Path) + "\n"
|
|
||||||
if err := writeDiagnosticText(writer, line); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return configkit.WriteDiagnostics(writer, records)
|
}
|
||||||
},
|
return configkit.WriteDiagnostics(writer, records)
|
||||||
}}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneConfigKitDiagnostics(records []configkit.Diagnostic) []configkit.Diagnostic {
|
||||||
|
cloned := append([]configkit.Diagnostic(nil), records...)
|
||||||
|
for index := range cloned {
|
||||||
|
if records[index].Primary != nil {
|
||||||
|
primary := *records[index].Primary
|
||||||
|
cloned[index].Primary = &primary
|
||||||
|
}
|
||||||
|
cloned[index].Related = append([]configkit.Location(nil), records[index].Related...)
|
||||||
|
}
|
||||||
|
return cloned
|
||||||
|
}
|
||||||
|
|
||||||
|
func appendConfigKitExplanation(left, right commandkit.Explanation) commandkit.Explanation {
|
||||||
|
if left.Message == "" {
|
||||||
|
left.Message = right.Message
|
||||||
|
}
|
||||||
|
left.Details = append(left.Details, right.Details...)
|
||||||
|
return left
|
||||||
}
|
}
|
||||||
|
|
||||||
func writeDiagnosticText(writer io.Writer, value string) error {
|
func writeDiagnosticText(writer io.Writer, value string) error {
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"io"
|
"io"
|
||||||
|
|
||||||
commandkit "gitea.maximumdirect.net/eric/CommandKit"
|
commandkit "go.maximumdirect.net/commandkit"
|
||||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
configkit "go.maximumdirect.net/configkit"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Selection carries the configuration and overlay choices supplied for a
|
// Selection carries the configuration and overlay choices supplied for a
|
||||||
@@ -15,6 +15,8 @@ type Selection struct {
|
|||||||
Config commandkit.Supplied[string]
|
Config commandkit.Supplied[string]
|
||||||
// Overlay is the caller-supplied overlay path, if present.
|
// Overlay is the caller-supplied overlay path, if present.
|
||||||
Overlay commandkit.Supplied[string]
|
Overlay commandkit.Supplied[string]
|
||||||
|
// Overrides contains ordered CLI override records, or nil when none were supplied.
|
||||||
|
Overrides []configkit.Override
|
||||||
}
|
}
|
||||||
|
|
||||||
// Operation identifies a supported standard configuration operation.
|
// Operation identifies a supported standard configuration operation.
|
||||||
@@ -45,7 +47,7 @@ type Request struct {
|
|||||||
// initialized and checked typed input. The supplied-state fields of Selection
|
// initialized and checked typed input. The supplied-state fields of Selection
|
||||||
// are preserved for the scope's loader.
|
// are preserved for the scope's loader.
|
||||||
type ComparisonSide[I any] struct {
|
type ComparisonSide[I any] struct {
|
||||||
// Selection carries the explicit config and overlay inputs for this side.
|
// Selection carries the explicit config, overlay, and override inputs for this side.
|
||||||
Selection Selection
|
Selection Selection
|
||||||
// Input is initialized and checked for this side before comparison.
|
// Input is initialized and checked for this side before comparison.
|
||||||
Input I
|
Input I
|
||||||
@@ -83,6 +85,29 @@ type View struct {
|
|||||||
Effective EffectiveWriter
|
Effective EffectiveWriter
|
||||||
Sources SourceReader
|
Sources SourceReader
|
||||||
Explanation ExplanationReader
|
Explanation ExplanationReader
|
||||||
|
// Metadata supplies native configuration path metadata when available.
|
||||||
|
Metadata *configkit.Metadata
|
||||||
|
// Findings supplies native diagnostics and their ordinary presentations.
|
||||||
|
// Nil means unavailable; a nonnil value may contain no records.
|
||||||
|
Findings *Findings
|
||||||
|
}
|
||||||
|
|
||||||
|
// Findings associates native diagnostic records with their ordinary messages.
|
||||||
|
type Findings struct {
|
||||||
|
// Records contains the native diagnostic records, which may be empty.
|
||||||
|
Records []configkit.Diagnostic
|
||||||
|
// Messages contains ordinary presentations associated with Records.
|
||||||
|
Messages []commandkit.Diagnostic
|
||||||
|
}
|
||||||
|
|
||||||
|
// ScopeFeatures describes optional modes within configuration operations.
|
||||||
|
type ScopeFeatures struct {
|
||||||
|
// PathDiscovery indicates support for metadata-backed path discovery.
|
||||||
|
PathDiscovery bool
|
||||||
|
// PathShow indicates support for displaying a selected effective path.
|
||||||
|
PathShow bool
|
||||||
|
// Overrides indicates that the loader accepts explicit configuration overrides.
|
||||||
|
Overrides bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// Scope declares the operations and providers for one named configuration.
|
// Scope declares the operations and providers for one named configuration.
|
||||||
@@ -93,17 +118,20 @@ type View struct {
|
|||||||
type Scope[I, S any] struct {
|
type Scope[I, S any] struct {
|
||||||
// Name identifies the scope in CLI selection and reports.
|
// Name identifies the scope in CLI selection and reports.
|
||||||
Name string
|
Name string
|
||||||
// Summary describes the scope. Scope discovery reports name and operations only.
|
// Summary describes the scope.
|
||||||
Summary string
|
Summary string
|
||||||
// Operations selects the standard handlers exposed for this scope.
|
// Operations selects the standard handlers exposed for this scope.
|
||||||
Operations []Operation
|
Operations []Operation
|
||||||
|
// Features describes optional modes supported by this scope.
|
||||||
|
Features ScopeFeatures
|
||||||
// Acquire loads the selected configuration for one ordinary inspection
|
// Acquire loads the selected configuration for one ordinary inspection
|
||||||
// invocation.
|
// invocation. It returns independent diagnostics and terminal context
|
||||||
|
// separately from the native value and optional operation error.
|
||||||
Acquire func(context.Context, Request, I, S) (commandkit.Acquired[View], error)
|
Acquire func(context.Context, Request, I, S) (commandkit.Acquired[View], error)
|
||||||
// Compare compares two selections for this scope using one shared state.
|
// Compare compares two selections for this scope using one shared state.
|
||||||
// Its Acquired result carries native difference records and diagnostics;
|
// Its Acquired result carries native difference records, independent
|
||||||
// returned errors retain their supported cause identity. It is required when
|
// diagnostics, and terminal context; returned errors retain their supported
|
||||||
// Diff is declared in Operations.
|
// cause identity. It is required when Diff is declared in Operations.
|
||||||
Compare func(context.Context, ComparisonRequest[I], S) (commandkit.Acquired[[]configkit.Difference], error)
|
Compare func(context.Context, ComparisonRequest[I], S) (commandkit.Acquired[[]configkit.Difference], error)
|
||||||
|
|
||||||
issues []error
|
issues []error
|
||||||
@@ -115,6 +143,9 @@ type Selector[I any] struct {
|
|||||||
Binding commandkit.Flag[I]
|
Binding commandkit.Flag[I]
|
||||||
// Scopes lists the scope names that receive this flag's value.
|
// Scopes lists the scope names that receive this flag's value.
|
||||||
Scopes []string
|
Scopes []string
|
||||||
|
// AgainstName optionally declares the long name of an independent
|
||||||
|
// right-side comparison flag using the same parser and setter.
|
||||||
|
AgainstName string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Suite declares the typed input, shared state, selectors, and configuration
|
// Suite declares the typed input, shared state, selectors, and configuration
|
||||||
|
|||||||
@@ -6,11 +6,12 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"reflect"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
commandkit "gitea.maximumdirect.net/eric/CommandKit"
|
commandkit "go.maximumdirect.net/commandkit"
|
||||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
configkit "go.maximumdirect.net/configkit"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestWithScopeErrorCopiesAndRetainsCauses(t *testing.T) {
|
func TestWithScopeErrorCopiesAndRetainsCauses(t *testing.T) {
|
||||||
@@ -91,10 +92,10 @@ func TestFromConfigKitPassesInvocationValuesAndNativeFacets(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if gotContext != ctx || gotSelection != selection || gotInput.Label != "typed" || gotState != stateValue || stateValue.Calls != 1 {
|
if gotContext != ctx || !reflect.DeepEqual(gotSelection, selection) || gotInput.Label != "typed" || gotState != stateValue || stateValue.Calls != 1 {
|
||||||
t.Fatalf("loader arguments/context/state = %v, %#v, %#v, %p, %d", gotContext, gotSelection, gotInput, gotState, stateValue.Calls)
|
t.Fatalf("loader arguments/context/state = %v, %#v, %#v, %p, %d", gotContext, gotSelection, gotInput, gotState, stateValue.Calls)
|
||||||
}
|
}
|
||||||
if acquired.Value.Effective != result || acquired.Value.Sources != result || acquired.Value.Explanation != result {
|
if acquired.Value.Effective != result || acquired.Value.Sources != result || acquired.Value.Explanation != result || acquired.Value.Metadata == nil || acquired.Value.Findings == nil {
|
||||||
t.Fatal("adapter did not expose the native result facets directly")
|
t.Fatal("adapter did not expose the native result facets directly")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,14 +127,20 @@ func TestFromConfigKitPassesInvocationValuesAndNativeFacets(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestFromConfigKitRetainsLoadErrorAndDiagnostics(t *testing.T) {
|
func TestFromConfigKitRetainsLoadErrorAndDiagnostics(t *testing.T) {
|
||||||
type settings struct {
|
path := configkit.Keys("schema_version")
|
||||||
Name string `yaml:"name"`
|
_, loadErr := configkit.Load(configkit.Bytes("bad source", []byte("{}\n")), configkit.Options[struct{}]{
|
||||||
}
|
Definition: configkit.Definition[struct{}]{Version: &configkit.VersionRule{
|
||||||
_, loadErr := configkit.Load(configkit.Mapping("bad source", map[string]any{"unknown": true}), configkit.Options[settings]{Definition: configkit.Definition[settings]{}})
|
Path: path,
|
||||||
|
Check: func(_ configkit.Version, validation *configkit.Validation) error {
|
||||||
|
validation.Warning(path, "review schema compatibility")
|
||||||
|
return errors.New("private checker error")
|
||||||
|
},
|
||||||
|
}},
|
||||||
|
})
|
||||||
if loadErr == nil {
|
if loadErr == nil {
|
||||||
t.Fatal("fixture did not produce a ConfigKit load error")
|
t.Fatal("fixture did not produce a ConfigKit load error")
|
||||||
}
|
}
|
||||||
scope := FromConfigKit[struct{}, struct{}, settings]("app", "", func(context.Context, Selection, struct{}, struct{}) (*configkit.Result[settings], error) {
|
scope := FromConfigKit[struct{}, struct{}, struct{}]("app", "", func(context.Context, Selection, struct{}, struct{}) (*configkit.Result[struct{}], error) {
|
||||||
return nil, loadErr
|
return nil, loadErr
|
||||||
})
|
})
|
||||||
acquired, gotErr := scope.Acquire(context.Background(), Request{}, struct{}{}, struct{}{})
|
acquired, gotErr := scope.Acquire(context.Background(), Request{}, struct{}{}, struct{}{})
|
||||||
@@ -144,38 +151,118 @@ func TestFromConfigKitRetainsLoadErrorAndDiagnostics(t *testing.T) {
|
|||||||
if !errors.As(gotErr, &typed) {
|
if !errors.As(gotErr, &typed) {
|
||||||
t.Fatalf("load error type was not retained: %T", gotErr)
|
t.Fatalf("load error type was not retained: %T", gotErr)
|
||||||
}
|
}
|
||||||
if len(acquired.Diagnostics) != 1 || acquired.Diagnostics[0].Severity != commandkit.SeverityError {
|
if acquired.Value.Effective != nil || acquired.Value.Metadata != nil || acquired.Value.Sources == nil || acquired.Value.Findings == nil || len(acquired.Value.Findings.Messages) != 1 || acquired.Value.Findings.Messages[0].Severity != commandkit.SeverityWarning {
|
||||||
t.Fatalf("failure diagnostics = %#v", acquired.Diagnostics)
|
t.Fatalf("failure evidence = %#v", acquired.Value)
|
||||||
}
|
}
|
||||||
var rendered bytes.Buffer
|
if acquired.Explanation.Message != "" || len(acquired.Explanation.Details) != 1 {
|
||||||
if err := acquired.Diagnostics[0].Write(&rendered); err != nil {
|
t.Fatalf("failure explanation = %#v", acquired.Explanation)
|
||||||
|
}
|
||||||
|
var warning, terminal bytes.Buffer
|
||||||
|
if err := acquired.Value.Findings.Messages[0].Write(&warning); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if !strings.Contains(rendered.String(), `Configuration scope "app":`) || !strings.Contains(rendered.String(), `"bad source"`) {
|
if err := acquired.Explanation.Details[0](&terminal); err != nil {
|
||||||
t.Fatalf("diagnostic output lacks scope/source legend: %q", rendered.String())
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(warning.String(), `Configuration scope "app":`) || !strings.Contains(warning.String(), `review schema compatibility`) {
|
||||||
|
t.Fatalf("warning output lost its scope or record: %q", warning.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(terminal.String(), `Configuration scope "app":`) || !strings.Contains(terminal.String(), `"bad source"`) || strings.Contains(terminal.String(), "review schema compatibility") {
|
||||||
|
t.Fatalf("terminal explanation lost native context or included an independent warning: %q", terminal.String())
|
||||||
|
}
|
||||||
|
if strings.Contains(terminal.String(), "private checker error") {
|
||||||
|
t.Fatalf("terminal explanation exposed the upstream cause: %q", terminal.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFromConfigKitDoesNotExposeResultFromFailedLoad(t *testing.T) {
|
||||||
|
result, err := adapterSettingsLoad(configkit.Mapping("partial source", map[string]any{"greeting": "Hello"}))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
loadErr := errors.New("loader returned a value with an error")
|
||||||
|
scope := FromConfigKit[struct{}, struct{}, adapterSettings]("app", "", func(context.Context, Selection, struct{}, struct{}) (*configkit.Result[adapterSettings], error) {
|
||||||
|
return result, loadErr
|
||||||
|
})
|
||||||
|
|
||||||
|
acquired, gotErr := scope.Acquire(context.Background(), Request{}, struct{}{}, struct{}{})
|
||||||
|
if gotErr != loadErr {
|
||||||
|
t.Fatalf("load error identity = %v, want %v", gotErr, loadErr)
|
||||||
|
}
|
||||||
|
if acquired.Value.Effective != nil || acquired.Value.Metadata != nil || acquired.Value.Sources != nil || acquired.Value.Explanation != nil || acquired.Value.Findings != nil {
|
||||||
|
t.Fatalf("failed load exposed result facets: %#v", acquired.Value)
|
||||||
|
}
|
||||||
|
if len(acquired.Explanation.Details) != 0 {
|
||||||
|
t.Fatalf("warning-only result acquired terminal context: %#v", acquired.Explanation)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestConfigKitDiagnosticsMapSeverityAndPropagateWriterFailures(t *testing.T) {
|
func TestConfigKitDiagnosticsMapSeverityAndPropagateWriterFailures(t *testing.T) {
|
||||||
warning := configKitDiagnostics("app", nil, []configkit.Diagnostic{{Severity: configkit.SeverityWarning, Message: "safe warning"}})
|
warning, noExplanation := configKitDiagnostics("app", nil, []configkit.Diagnostic{{Severity: configkit.SeverityWarning, Message: "safe warning"}})
|
||||||
if len(warning) != 1 || warning[0].Severity != commandkit.SeverityWarning {
|
if len(warning) != 1 || warning[0].Severity != commandkit.SeverityWarning {
|
||||||
t.Fatalf("warning diagnostics = %#v", warning)
|
t.Fatalf("warning diagnostics = %#v", warning)
|
||||||
}
|
}
|
||||||
withError := configKitDiagnostics("app", nil, []configkit.Diagnostic{
|
if noExplanation.Message != "" || len(noExplanation.Details) != 0 {
|
||||||
|
t.Fatalf("warning-only explanation = %#v", noExplanation)
|
||||||
|
}
|
||||||
|
withWarning, withExplanation := configKitDiagnostics("app", nil, []configkit.Diagnostic{
|
||||||
{Severity: configkit.SeverityWarning, Message: "safe warning"},
|
{Severity: configkit.SeverityWarning, Message: "safe warning"},
|
||||||
{Severity: configkit.SeverityError, Message: "safe error"},
|
{Severity: configkit.SeverityError, Message: "safe error"},
|
||||||
})
|
})
|
||||||
if withError[0].Severity != commandkit.SeverityError {
|
if len(withWarning) != 1 || withWarning[0].Severity != commandkit.SeverityWarning || len(withExplanation.Details) != 1 {
|
||||||
t.Fatalf("mixed diagnostics severity = %q", withError[0].Severity)
|
t.Fatalf("mixed diagnostics = %#v, explanation = %#v", withWarning, withExplanation)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := withError[0].Write(shortWriter{}); !errors.Is(err, io.ErrShortWrite) {
|
if err := withWarning[0].Write(shortWriter{}); !errors.Is(err, io.ErrShortWrite) {
|
||||||
t.Fatalf("short writer error = %v, want %v", err, io.ErrShortWrite)
|
t.Fatalf("short writer error = %v, want %v", err, io.ErrShortWrite)
|
||||||
}
|
}
|
||||||
|
if err := withExplanation.Details[0](shortWriter{}); !errors.Is(err, io.ErrShortWrite) {
|
||||||
|
t.Fatalf("terminal short writer error = %v, want %v", err, io.ErrShortWrite)
|
||||||
|
}
|
||||||
wantErr := errors.New("writer failed")
|
wantErr := errors.New("writer failed")
|
||||||
if err := withError[0].Write(failingWriter{err: wantErr}); !errors.Is(err, wantErr) {
|
if err := withWarning[0].Write(failingWriter{err: wantErr}); !errors.Is(err, wantErr) {
|
||||||
t.Fatalf("writer error = %v, want %v", err, wantErr)
|
t.Fatalf("writer error = %v, want %v", err, wantErr)
|
||||||
}
|
}
|
||||||
|
if err := withExplanation.Details[0](failingWriter{err: wantErr}); !errors.Is(err, wantErr) {
|
||||||
|
t.Fatalf("terminal writer error = %v, want %v", err, wantErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConfigKitTerminalContextRetainsRelatedLocationsAndSnapshots(t *testing.T) {
|
||||||
|
primary := &configkit.Location{SourceID: "primary-source", Line: 4, Column: 2}
|
||||||
|
related := []configkit.Location{{SourceID: "related-source", Line: 2, Column: 1}}
|
||||||
|
sources := []configkit.SourceInfo{
|
||||||
|
{ID: "primary-source", Label: "primary", Path: "primary.yml"},
|
||||||
|
{ID: "related-source", Label: "related", Path: "related.yml"},
|
||||||
|
}
|
||||||
|
records := []configkit.Diagnostic{{
|
||||||
|
Severity: configkit.SeverityError,
|
||||||
|
Path: configkit.Keys("name"),
|
||||||
|
Message: "native terminal context",
|
||||||
|
Primary: primary,
|
||||||
|
Related: related,
|
||||||
|
}}
|
||||||
|
_, explanation := configKitDiagnostics("app", sources, records)
|
||||||
|
primary.SourceID = "mutated-primary"
|
||||||
|
related[0].SourceID = "mutated-related"
|
||||||
|
sources[0].Path = "mutated.yml"
|
||||||
|
|
||||||
|
if len(explanation.Details) != 1 {
|
||||||
|
t.Fatalf("explanation = %#v", explanation)
|
||||||
|
}
|
||||||
|
var output bytes.Buffer
|
||||||
|
if err := explanation.Details[0](&output); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, fragment := range []string{"primary.yml", "primary-source", "related-source", "native terminal context"} {
|
||||||
|
if !strings.Contains(output.String(), fragment) {
|
||||||
|
t.Errorf("terminal context %q omits %q", output.String(), fragment)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Contains(output.String(), "mutated-") || strings.Contains(output.String(), "mutated.yml") {
|
||||||
|
t.Fatalf("terminal renderer retained caller mutations: %q", output.String())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type shortWriter struct{}
|
type shortWriter struct{}
|
||||||
@@ -185,3 +272,23 @@ func (shortWriter) Write(value []byte) (int, error) { return len(value) - 1, nil
|
|||||||
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 }
|
||||||
|
|
||||||
|
func TestFromConfigKitUsesFailureSnapshotInsteadOfReturnedResult(t *testing.T) {
|
||||||
|
result, err := adapterSettingsLoad(configkit.Mapping("successful", map[string]any{"greeting": "Hello"}))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_, failure := configkit.Load[map[string]any](configkit.Bytes("failed", []byte("broken: [")))
|
||||||
|
var snapshot *configkit.LoadError
|
||||||
|
if !errors.As(failure, &snapshot) {
|
||||||
|
t.Fatalf("load failure: %v", failure)
|
||||||
|
}
|
||||||
|
wrapped := fmt.Errorf("loading: %w", failure)
|
||||||
|
scope := FromConfigKit[struct{}, struct{}, adapterSettings]("app", "", func(context.Context, Selection, struct{}, struct{}) (*configkit.Result[adapterSettings], error) {
|
||||||
|
return result, wrapped
|
||||||
|
})
|
||||||
|
acquired, gotErr := scope.Acquire(context.Background(), Request{}, struct{}{}, struct{}{})
|
||||||
|
if !errors.Is(gotErr, failure) || acquired.Value.Effective != nil || acquired.Value.Metadata != nil || acquired.Value.Explanation != nil || acquired.Value.Sources == nil || acquired.Value.Findings == nil || !reflect.DeepEqual(acquired.Value.Sources.Sources(), snapshot.Sources()) || !reflect.DeepEqual(acquired.Value.Findings.Records, snapshot.Diagnostics()) || len(acquired.Diagnostics) != 0 {
|
||||||
|
t.Fatalf("failed acquisition = %#v, %v", acquired, gotErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
104
integrations/ckconfig/detailed_diff_test.go
Normal file
104
integrations/ckconfig/detailed_diff_test.go
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
package ckconfig
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
commandkit "go.maximumdirect.net/commandkit"
|
||||||
|
configkit "go.maximumdirect.net/configkit"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDetailedDiffPreservesNativeEvidenceAndAssertions(t *testing.T) {
|
||||||
|
values := []configkit.Difference{
|
||||||
|
{Path: configkit.Keys("added"), Right: configkit.ValueSnapshot{Present: true, YAML: "null\n"}},
|
||||||
|
{Path: configkit.Keys("removed"), Left: configkit.ValueSnapshot{Present: true, YAML: "false\n"}},
|
||||||
|
{Path: configkit.Keys("secret"), Left: configkit.ValueSnapshot{Present: true, Redacted: true, YAML: "'***'\n"}, Right: configkit.ValueSnapshot{Present: true, Redacted: true, YAML: "'***'\n"}, HiddenChanged: true},
|
||||||
|
{Path: configkit.Keys("list"), Left: configkit.ValueSnapshot{Present: true, YAML: "[one, two]\n"}, Right: configkit.ValueSnapshot{Present: true, YAML: "[three]\n"}},
|
||||||
|
{Path: configkit.Keys("marker"), Left: configkit.ValueSnapshot{Present: true, YAML: "'redacted'\n"}, Right: configkit.ValueSnapshot{Present: true, YAML: "'***'\n"}},
|
||||||
|
}
|
||||||
|
calls := 0
|
||||||
|
scope := Scope[struct{}, struct{}]{Name: "app", Operations: []Operation{Diff}, Compare: func(context.Context, ComparisonRequest[struct{}], struct{}) (commandkit.Acquired[[]configkit.Difference], error) {
|
||||||
|
calls++
|
||||||
|
return commandkit.Acquired[[]configkit.Difference]{Value: values}, nil
|
||||||
|
}}
|
||||||
|
app := newConfigTestApp(t, Command(Suite[struct{}, struct{}]{Scopes: []Scope[struct{}, struct{}]{scope}}))
|
||||||
|
for _, format := range []string{"json", "yaml", "text"} {
|
||||||
|
var out bytes.Buffer
|
||||||
|
result := app.Execute(context.Background(), []string{"config", "diff", "--details", "--check", "--format=" + format}, commandkit.Streams{Out: &out, Err: io.Discard})
|
||||||
|
var logical any
|
||||||
|
var err error
|
||||||
|
if format == "json" {
|
||||||
|
err = json.Unmarshal(out.Bytes(), &logical)
|
||||||
|
} else {
|
||||||
|
logical, err = commandkit.DecodeYAMLReport(&out)
|
||||||
|
}
|
||||||
|
if result.Kind != commandkit.OutcomeDomain || result.Status != 3 || err != nil {
|
||||||
|
t.Fatalf("format %s = %#v %v %q", format, result, err, out.String())
|
||||||
|
}
|
||||||
|
report := logical.(map[string]any)
|
||||||
|
entries := report["differences"].([]any)
|
||||||
|
if report["scope"] != "app" || len(entries) != len(values) {
|
||||||
|
t.Fatalf("format %s report %#v", format, report)
|
||||||
|
}
|
||||||
|
added := entries[0].(map[string]any)
|
||||||
|
before, after := added["before"].(map[string]any), added["after"].(map[string]any)
|
||||||
|
if before["present"] != false || before["value"] != nil || after["present"] != true || after["value"] != nil {
|
||||||
|
t.Fatalf("null/absence: %#v", added)
|
||||||
|
}
|
||||||
|
secret := entries[2].(map[string]any)
|
||||||
|
if secret["hidden_changed"] != true || !reflect.DeepEqual(secret["before"].(map[string]any)["value"], secret["after"].(map[string]any)["value"]) {
|
||||||
|
t.Fatalf("hidden change: %#v", secret)
|
||||||
|
}
|
||||||
|
list := entries[3].(map[string]any)["after"].(map[string]any)["value"]
|
||||||
|
if !reflect.DeepEqual(list, []any{"three"}) {
|
||||||
|
t.Fatalf("atomic sequence: %#v", list)
|
||||||
|
}
|
||||||
|
marker := entries[4].(map[string]any)["after"].(map[string]any)
|
||||||
|
if marker["value"] != "***" || marker["redacted"] != false {
|
||||||
|
t.Fatalf("marker string: %#v", marker)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if calls != 3 {
|
||||||
|
t.Fatalf("provider calls=%d", calls)
|
||||||
|
}
|
||||||
|
result := app.Execute(context.Background(), []string{"config", "diff"}, commandkit.Streams{Out: io.Discard, Err: io.Discard})
|
||||||
|
if result.Kind != commandkit.OutcomeSuccess || calls != 4 {
|
||||||
|
t.Fatalf("ordinary diff: %#v calls=%d", result, calls)
|
||||||
|
}
|
||||||
|
values = nil
|
||||||
|
var out bytes.Buffer
|
||||||
|
result = app.Execute(context.Background(), []string{"config", "diff", "--check", "--details", "--format=json"}, commandkit.Streams{Out: &out, Err: io.Discard})
|
||||||
|
if result.Kind != commandkit.OutcomeSuccess || !strings.Contains(out.String(), `"differences": []`) {
|
||||||
|
t.Fatalf("equality: %#v %q", result, out.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDiffAssertionRetainsDomainOutcomeOnDeliveryFailures(t *testing.T) {
|
||||||
|
values := []configkit.Difference{{Path: configkit.Keys("x"), Right: configkit.ValueSnapshot{Present: true, YAML: "true\n"}}}
|
||||||
|
cause := errors.New("provider failed")
|
||||||
|
var providerErr error
|
||||||
|
scope := Scope[struct{}, struct{}]{Name: "app", Operations: []Operation{Diff}, Compare: func(context.Context, ComparisonRequest[struct{}], struct{}) (commandkit.Acquired[[]configkit.Difference], error) {
|
||||||
|
return commandkit.Acquired[[]configkit.Difference]{Value: values}, providerErr
|
||||||
|
}}
|
||||||
|
app := newConfigTestApp(t, Command(Suite[struct{}, struct{}]{Scopes: []Scope[struct{}, struct{}]{scope}}))
|
||||||
|
result := app.Execute(context.Background(), []string{"config", "diff", "--check", "--details"}, commandkit.Streams{Out: shortWriter{}, Err: io.Discard})
|
||||||
|
if result.Kind != commandkit.OutcomeDomain || result.Status != 3 || result.Presentation.Output.State != commandkit.DeliveryIncomplete {
|
||||||
|
t.Fatalf("writer failure: %#v", result)
|
||||||
|
}
|
||||||
|
values[0].Right.YAML = "[invalid"
|
||||||
|
result = app.Execute(context.Background(), []string{"config", "diff", "--check", "--details"}, commandkit.Streams{Out: io.Discard, Err: io.Discard})
|
||||||
|
if result.Kind != commandkit.OutcomeDomain || result.Status != 3 || result.Presentation.Output.RenderError == nil {
|
||||||
|
t.Fatalf("render failure: %#v", result)
|
||||||
|
}
|
||||||
|
providerErr = cause
|
||||||
|
result = app.Execute(context.Background(), []string{"config", "diff", "--check"}, commandkit.Streams{Out: io.Discard, Err: io.Discard})
|
||||||
|
if result.Kind != commandkit.OutcomeOperational || !errors.Is(result.Cause, cause) {
|
||||||
|
t.Fatalf("provider failure: %#v", result)
|
||||||
|
}
|
||||||
|
}
|
||||||
136
integrations/ckconfig/detailed_validation_test.go
Normal file
136
integrations/ckconfig/detailed_validation_test.go
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
package ckconfig
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
commandkit "go.maximumdirect.net/commandkit"
|
||||||
|
configkit "go.maximumdirect.net/configkit"
|
||||||
|
)
|
||||||
|
|
||||||
|
func validationDiagnostic(message string) commandkit.Diagnostic {
|
||||||
|
return commandkit.Diagnostic{Severity: commandkit.SeverityWarning, Write: func(w io.Writer) error { _, err := io.WriteString(w, message+"\n"); return err }}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetailedValidationFiltersAndRetainsFailureEvidence(t *testing.T) {
|
||||||
|
cause := errors.New("second scope failed")
|
||||||
|
calls := []string{}
|
||||||
|
reader := &sourceInventory{values: []configkit.SourceInfo{{ID: "primary", Label: "main", Path: "main.yml"}, {ID: "related", Label: "included", Path: "include.yml"}, {ID: "unused", Label: "unused"}}}
|
||||||
|
records := []configkit.Diagnostic{
|
||||||
|
{Code: "chosen", Severity: configkit.SeverityWarning, Path: configkit.Keys("a/b"), Message: "selected warning", Primary: &configkit.Location{SourceID: "primary", Line: 2, Column: 3}, Related: []configkit.Location{{SourceID: "related", Line: 7, Column: 8}}},
|
||||||
|
{Code: "other", Severity: configkit.SeverityWarning, Path: configkit.Keys("other"), Message: "other warning"},
|
||||||
|
}
|
||||||
|
scopes := []Scope[struct{}, struct{}]{
|
||||||
|
{Name: "app", Operations: []Operation{Validate}, Acquire: func(context.Context, Request, struct{}, struct{}) (commandkit.Acquired[View], error) {
|
||||||
|
calls = append(calls, "app")
|
||||||
|
return commandkit.Acquired[View]{Value: View{Sources: reader, Findings: &Findings{Records: records, Messages: []commandkit.Diagnostic{validationDiagnostic("native warnings")}}}, Diagnostics: []commandkit.Diagnostic{validationDiagnostic("application warning")}}, nil
|
||||||
|
}},
|
||||||
|
{Name: "second", Operations: []Operation{Validate}, Acquire: func(context.Context, Request, struct{}, struct{}) (commandkit.Acquired[View], error) {
|
||||||
|
calls = append(calls, "second")
|
||||||
|
return commandkit.Acquired[View]{Value: View{Findings: &Findings{Records: []configkit.Diagnostic{{Code: "failed", Severity: configkit.SeverityError, Message: "failed finding"}}}}, Explanation: commandkit.Explanation{Message: "terminal failure"}}, cause
|
||||||
|
}},
|
||||||
|
{Name: "third", Operations: []Operation{Validate}, Acquire: func(context.Context, Request, struct{}, struct{}) (commandkit.Acquired[View], error) {
|
||||||
|
calls = append(calls, "third")
|
||||||
|
return commandkit.Acquired[View]{}, nil
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
app := newConfigTestApp(t, Command(Suite[struct{}, struct{}]{Scopes: scopes}))
|
||||||
|
var out, diag bytes.Buffer
|
||||||
|
result := app.Execute(context.Background(), []string{"config", "validate", "--scope=all", "--details", "--severity=warning", "--code=chosen", "--format=json", "--verbose"}, commandkit.Streams{Out: &out, Err: &diag})
|
||||||
|
decoder := json.NewDecoder(&out)
|
||||||
|
var report validationReport
|
||||||
|
if err := decoder.Decode(&report); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := decoder.Decode(new(any)); err != io.EOF {
|
||||||
|
t.Fatalf("extra JSON: %v", err)
|
||||||
|
}
|
||||||
|
if !errors.Is(result.Cause, cause) || result.Kind != commandkit.OutcomeOperational || len(calls) != 2 || calls[0] != "app" || calls[1] != "second" || len(report.Scopes) != 2 || report.Scopes[0].Outcome != "valid" || report.Scopes[1].Outcome != "failed" {
|
||||||
|
t.Fatalf("result=%#v calls=%v report=%#v", result, calls, report)
|
||||||
|
}
|
||||||
|
first, second := report.Scopes[0].Details, report.Scopes[1].Details
|
||||||
|
if first == nil || first.State != "present" || len(first.Findings) != 1 || first.Findings[0].Path != "/a~1b" || len(first.Sources) != 2 || first.Sources[0].ID != "primary" || first.Sources[1].ID != "related" || second == nil || second.State != "filtered" || len(second.Findings) != 0 {
|
||||||
|
t.Fatalf("details=%#v %#v", first, second)
|
||||||
|
}
|
||||||
|
if strings.Contains(diag.String(), "native warnings") || !strings.Contains(diag.String(), "application warning") || !strings.Contains(diag.String(), "terminal failure") {
|
||||||
|
t.Fatalf("diagnostics=%q", diag.String())
|
||||||
|
}
|
||||||
|
before := len(calls)
|
||||||
|
for _, args := range [][]string{{"--severity=warning"}, {"--code=x"}, {"--details", "--severity=unknown"}} {
|
||||||
|
result = app.Execute(context.Background(), append([]string{"config", "validate"}, args...), commandkit.Streams{Out: io.Discard, Err: io.Discard})
|
||||||
|
if result.Kind != commandkit.OutcomeUsage || len(calls) != before {
|
||||||
|
t.Fatalf("invalid %v: %#v", args, result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidationCancellationAndDeliveryRetainReachedScopes(t *testing.T) {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
calls := 0
|
||||||
|
scopes := []Scope[struct{}, struct{}]{
|
||||||
|
{Name: "app", Operations: []Operation{Validate}, Acquire: func(context.Context, Request, struct{}, struct{}) (commandkit.Acquired[View], error) {
|
||||||
|
calls++
|
||||||
|
return commandkit.Acquired[View]{Value: View{Findings: &Findings{}}}, nil
|
||||||
|
}},
|
||||||
|
{Name: "second", Operations: []Operation{Validate}, Acquire: func(context.Context, Request, struct{}, struct{}) (commandkit.Acquired[View], error) {
|
||||||
|
calls++
|
||||||
|
cancel()
|
||||||
|
return commandkit.Acquired[View]{}, nil
|
||||||
|
}},
|
||||||
|
{Name: "third", Operations: []Operation{Validate}, Acquire: func(context.Context, Request, struct{}, struct{}) (commandkit.Acquired[View], error) {
|
||||||
|
calls++
|
||||||
|
return commandkit.Acquired[View]{}, nil
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
app := newConfigTestApp(t, Command(Suite[struct{}, struct{}]{Scopes: scopes}))
|
||||||
|
var out bytes.Buffer
|
||||||
|
result := app.Execute(ctx, []string{"config", "validate", "--scope=all", "--details", "--format=json"}, commandkit.Streams{Out: &out, Err: io.Discard})
|
||||||
|
var report validationReport
|
||||||
|
if err := json.Unmarshal(out.Bytes(), &report); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !errors.Is(result.Cause, context.Canceled) || calls != 2 || len(report.Scopes) != 2 || report.Scopes[0].Details.State != "empty" || report.Scopes[1].Details.State != "unavailable" || report.Scopes[1].Outcome != "canceled" {
|
||||||
|
t.Fatalf("result=%#v calls=%d report=%#v", result, calls, report)
|
||||||
|
}
|
||||||
|
failure := errors.New("load failure")
|
||||||
|
scope := Scope[struct{}, struct{}]{Name: "app", Operations: []Operation{Validate}, Acquire: func(context.Context, Request, struct{}, struct{}) (commandkit.Acquired[View], error) {
|
||||||
|
return commandkit.Acquired[View]{}, failure
|
||||||
|
}}
|
||||||
|
app = newConfigTestApp(t, Command(Suite[struct{}, struct{}]{Scopes: []Scope[struct{}, struct{}]{scope}}))
|
||||||
|
result = app.Execute(context.Background(), []string{"config", "validate", "--details"}, commandkit.Streams{Out: shortWriter{}, Err: io.Discard})
|
||||||
|
if !errors.Is(result.Cause, failure) || result.Presentation.Output.State != commandkit.DeliveryIncomplete {
|
||||||
|
t.Fatalf("failure and delivery: %#v", result)
|
||||||
|
}
|
||||||
|
out.Reset()
|
||||||
|
result = app.Execute(context.Background(), []string{"config", "validate", "--details", "--quiet"}, commandkit.Streams{Out: &out, Err: io.Discard})
|
||||||
|
if !errors.Is(result.Cause, failure) || out.Len() != 0 {
|
||||||
|
t.Fatalf("quiet failure: %#v %q", result, out.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetailedValidationReportsNativeLoadError(t *testing.T) {
|
||||||
|
_, failure := configkit.Load[map[string]any](configkit.Bytes("invalid source", []byte("invalid: [")))
|
||||||
|
var native *configkit.LoadError
|
||||||
|
if !errors.As(failure, &native) {
|
||||||
|
t.Fatalf("fixture: %v", failure)
|
||||||
|
}
|
||||||
|
scope := FromConfigKit("app", "", func(context.Context, Selection, struct{}, struct{}) (*configkit.Result[map[string]any], error) {
|
||||||
|
return nil, failure
|
||||||
|
})
|
||||||
|
app := newConfigTestApp(t, Command(Suite[struct{}, struct{}]{Scopes: []Scope[struct{}, struct{}]{scope}}))
|
||||||
|
var out bytes.Buffer
|
||||||
|
result := app.Execute(context.Background(), []string{"config", "validate", "--details", "--format=json"}, commandkit.Streams{Out: &out, Err: io.Discard})
|
||||||
|
var report validationReport
|
||||||
|
if err := json.Unmarshal(out.Bytes(), &report); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !errors.Is(result.Cause, failure) || len(report.Scopes) != 1 || report.Scopes[0].Outcome != "failed" || report.Scopes[0].Details.State != "present" || len(report.Scopes[0].Details.Findings) != len(native.Diagnostics()) {
|
||||||
|
t.Fatalf("native failure: %#v %q", result, out.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,40 +4,40 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
commandkit "gitea.maximumdirect.net/eric/CommandKit"
|
commandkit "go.maximumdirect.net/commandkit"
|
||||||
)
|
)
|
||||||
|
|
||||||
func runConfigDiff[I, S any](invocation commandkit.Invocation, plan *configOperationPlan[I, S], state S) commandkit.Outcome {
|
func runConfigDiff[I, S any](invocation commandkit.Invocation, plan *configOperationPlan[I, S], state S) commandkit.Outcome {
|
||||||
if plan.comparison == nil || plan.comparison.scope == nil {
|
if plan.comparison == nil || plan.comparison.scope == nil {
|
||||||
return configOperationFailure(errors.New("configuration comparison was not prepared"), nil, false)
|
cause := errors.New("configuration comparison was not prepared")
|
||||||
|
return configFailure("Configuration comparison was not prepared.", commandkit.Explanation{}, cause)
|
||||||
}
|
}
|
||||||
selected := plan.comparison
|
selected := plan.comparison
|
||||||
if err := invocation.Context.Err(); err != nil {
|
if err := invocation.Context.Err(); err != nil {
|
||||||
return configOperationFailure(err, nil, false)
|
return configFailure("Configuration comparison was canceled.", commandkit.Explanation{}, err)
|
||||||
}
|
}
|
||||||
compare := selected.scope.scope.Compare
|
compare := selected.scope.scope.Compare
|
||||||
if compare == nil {
|
if compare == nil {
|
||||||
return configOperationFailure(fmt.Errorf("configuration scope %q has no comparison callback", selected.scope.scope.Name), nil, false)
|
cause := fmt.Errorf("configuration scope %q has no comparison callback", selected.scope.scope.Name)
|
||||||
|
return configFailure("Configuration scope does not support comparison.", commandkit.Explanation{}, cause)
|
||||||
}
|
}
|
||||||
acquired, err := compare(invocation.Context, selected.request, state)
|
acquired, err := compare(invocation.Context, selected.request, state)
|
||||||
diagnostics := append([]commandkit.Diagnostic(nil), acquired.Diagnostics...)
|
reporter := invocation.Reporter()
|
||||||
|
queueConfigDiagnostics(reporter, acquired.Diagnostics)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return configOperationFailure(err, diagnostics, false)
|
return configFailure(fmt.Sprintf("Unable to compare configuration scope %q.", selected.scope.scope.Name), acquired.Explanation, err)
|
||||||
}
|
}
|
||||||
if err := invocation.Context.Err(); err != nil {
|
if err := invocation.Context.Err(); err != nil {
|
||||||
return configOperationFailure(err, diagnostics, false)
|
return configFailure("Configuration comparison was canceled.", acquired.Explanation, err)
|
||||||
}
|
}
|
||||||
report, err := renderDiffReport(acquired.Value)
|
outcome := renderPreparedConfigReport(invocation, acquired.Explanation, func() (any, error) {
|
||||||
if err != nil {
|
if plan.details {
|
||||||
return configOperationFailure(err, diagnostics, false)
|
return renderDetailedDiff(selected.scope.scope.Name, acquired.Value)
|
||||||
|
}
|
||||||
|
return renderDiffReport(acquired.Value)
|
||||||
|
})
|
||||||
|
if outcome.Kind == commandkit.OutcomeSuccess && plan.check && len(acquired.Value) > 0 {
|
||||||
|
return commandkit.Domain("configuration differences", 3)
|
||||||
}
|
}
|
||||||
if err := invocation.Context.Err(); err != nil {
|
|
||||||
return configOperationFailure(err, diagnostics, false)
|
|
||||||
}
|
|
||||||
if err := writeConfigReport(invocation.Streams.Out, report); err != nil {
|
|
||||||
return configDeliveryFailure(err, diagnostics, true)
|
|
||||||
}
|
|
||||||
outcome := commandkit.Success()
|
|
||||||
outcome.Diagnostics = diagnostics
|
|
||||||
return outcome
|
return outcome
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,12 +9,12 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
commandkit "gitea.maximumdirect.net/eric/CommandKit"
|
commandkit "go.maximumdirect.net/commandkit"
|
||||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
configkit "go.maximumdirect.net/configkit"
|
||||||
"go.yaml.in/yaml/v4"
|
"go.yaml.in/yaml/v4"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestConfigDiffExecutesWithDefaultAndExplicitYAMLAndInheritedSelections(t *testing.T) {
|
func TestConfigDiffExecutesWithDefaultAndExplicitYAMLAndIndependentSelections(t *testing.T) {
|
||||||
type settings struct {
|
type settings struct {
|
||||||
Greeting string `yaml:"greeting"`
|
Greeting string `yaml:"greeting"`
|
||||||
}
|
}
|
||||||
@@ -32,7 +32,7 @@ func TestConfigDiffExecutesWithDefaultAndExplicitYAMLAndInheritedSelections(t *t
|
|||||||
if outcome.Kind != commandkit.OutcomeSuccess {
|
if outcome.Kind != commandkit.OutcomeSuccess {
|
||||||
t.Fatalf("default YAML comparison outcome = %#v", outcome)
|
t.Fatalf("default YAML comparison outcome = %#v", outcome)
|
||||||
}
|
}
|
||||||
if len(selections) != 2 || !selections[0].Config.Set || selections[0].Config.Value != "before.yml" || !selections[1].Config.Set || selections[1].Config.Value != "after.yml" || selections[1].Overlay != selections[0].Overlay {
|
if len(selections) != 2 || !selections[0].Config.Set || selections[0].Config.Value != "before.yml" || !selections[1].Config.Set || selections[1].Config.Value != "after.yml" || selections[1].Overlay.Set {
|
||||||
t.Fatalf("comparison selections = %#v", selections)
|
t.Fatalf("comparison selections = %#v", selections)
|
||||||
}
|
}
|
||||||
var document yaml.Node
|
var document yaml.Node
|
||||||
@@ -60,7 +60,7 @@ func TestConfigDiffExecutesWithDefaultAndExplicitYAMLAndInheritedSelections(t *t
|
|||||||
loadsBeforeText := len(selections)
|
loadsBeforeText := len(selections)
|
||||||
output.Reset()
|
output.Reset()
|
||||||
outcome = app.Execute(context.Background(), []string{"config", "diff", "--format", "text"}, commandkit.Streams{Out: &output, Err: io.Discard})
|
outcome = app.Execute(context.Background(), []string{"config", "diff", "--format", "text"}, commandkit.Streams{Out: &output, Err: io.Discard})
|
||||||
if outcome.Kind != commandkit.OutcomeUsage || len(selections) != loadsBeforeText || output.Len() != 0 {
|
if outcome.Kind != commandkit.OutcomeSuccess || len(selections) != loadsBeforeText+2 || !strings.Contains(output.String(), "deletions:") {
|
||||||
t.Fatalf("text format outcome/work/output = %#v / %d / %q", outcome, len(selections)-loadsBeforeText, output.String())
|
t.Fatalf("text format outcome/work/output = %#v / %d / %q", outcome, len(selections)-loadsBeforeText, output.String())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -101,21 +101,30 @@ func TestConfigDiffRunsComparisonOnlyScopeAndRejectsUnsupportedScopeBeforeWork(t
|
|||||||
|
|
||||||
func TestConfigDiffProviderFailureRetainsDiagnosticsAndCause(t *testing.T) {
|
func TestConfigDiffProviderFailureRetainsDiagnosticsAndCause(t *testing.T) {
|
||||||
wantErr := errors.New("provider unavailable")
|
wantErr := errors.New("provider unavailable")
|
||||||
diagnostic := commandkit.Diagnostic{Severity: commandkit.SeverityError, Write: func(writer io.Writer) error {
|
diagnostic := commandkit.Diagnostic{Severity: commandkit.SeverityWarning, Write: func(writer io.Writer) error {
|
||||||
_, err := io.WriteString(writer, "provider warning\n")
|
_, err := io.WriteString(writer, "provider warning\n")
|
||||||
return err
|
return err
|
||||||
}}
|
}}
|
||||||
app := newConfigTestApp(t, Command(Suite[struct{}, struct{}]{Scopes: []Scope[struct{}, struct{}]{{
|
app := newConfigTestApp(t, Command(Suite[struct{}, struct{}]{Scopes: []Scope[struct{}, struct{}]{{
|
||||||
Name: "app", Operations: []Operation{Diff},
|
Name: "app", Operations: []Operation{Diff},
|
||||||
Compare: func(context.Context, ComparisonRequest[struct{}], struct{}) (commandkit.Acquired[[]configkit.Difference], error) {
|
Compare: func(context.Context, ComparisonRequest[struct{}], struct{}) (commandkit.Acquired[[]configkit.Difference], error) {
|
||||||
return commandkit.Acquired[[]configkit.Difference]{Diagnostics: []commandkit.Diagnostic{diagnostic}}, fmt.Errorf("comparison setup: %w", wantErr)
|
return commandkit.Acquired[[]configkit.Difference]{
|
||||||
|
Diagnostics: []commandkit.Diagnostic{diagnostic},
|
||||||
|
Explanation: commandkit.Explanation{Details: []commandkit.Detail{func(writer io.Writer) error {
|
||||||
|
_, err := io.WriteString(writer, "native comparison context\n")
|
||||||
|
return err
|
||||||
|
}}},
|
||||||
|
}, fmt.Errorf("comparison setup: %w", wantErr)
|
||||||
},
|
},
|
||||||
}}}))
|
}}}))
|
||||||
var output bytes.Buffer
|
var output, diagnosticOutput bytes.Buffer
|
||||||
outcome := app.Execute(context.Background(), []string{"config", "diff"}, commandkit.Streams{Out: &output, Err: io.Discard})
|
outcome := app.Execute(context.Background(), []string{"config", "diff", "--verbose"}, commandkit.Streams{Out: &output, Err: &diagnosticOutput})
|
||||||
if outcome.Kind != commandkit.OutcomeOperational || !errors.Is(outcome.Cause, wantErr) || outcome.ReportIncomplete || outcome.DeliveryError != nil || output.Len() != 0 || len(outcome.Diagnostics) < 1 || outcome.Diagnostics[0].Severity != commandkit.SeverityError {
|
if outcome.Kind != commandkit.OutcomeOperational || !errors.Is(outcome.Cause, wantErr) || output.Len() != 0 {
|
||||||
t.Fatalf("provider failure outcome/output = %#v / %q", outcome, output.String())
|
t.Fatalf("provider failure outcome/output = %#v / %q", outcome, output.String())
|
||||||
}
|
}
|
||||||
|
if got := diagnosticOutput.String(); !strings.Contains(got, "provider warning") || !strings.Contains(got, "native comparison context") || strings.Index(got, "provider warning") > strings.Index(got, "native comparison context") || strings.Contains(got, wantErr.Error()) {
|
||||||
|
t.Fatalf("provider messages and terminal context = %q", got)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestConfigDiffReportPreparationFailureWritesNoReport(t *testing.T) {
|
func TestConfigDiffReportPreparationFailureWritesNoReport(t *testing.T) {
|
||||||
@@ -128,8 +137,8 @@ func TestConfigDiffReportPreparationFailureWritesNoReport(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}}}))
|
}}}))
|
||||||
var output bytes.Buffer
|
var output bytes.Buffer
|
||||||
outcome := app.Execute(context.Background(), []string{"config", "diff"}, commandkit.Streams{Out: &output, Err: io.Discard})
|
outcome := app.Execute(context.Background(), []string{"config", "diff", "--quiet"}, commandkit.Streams{Out: failingOutput{err: errors.New("quiet destination should not be called")}, Err: io.Discard})
|
||||||
if outcome.Kind != commandkit.OutcomeOperational || outcome.ReportIncomplete || outcome.DeliveryError != nil || output.Len() != 0 {
|
if outcome.Kind != commandkit.OutcomeOperational || outcome.Cause != nil || outcome.Presentation.Output.State != commandkit.DeliveryIncomplete || outcome.Presentation.Output.RenderError == nil || output.Len() != 0 {
|
||||||
t.Fatalf("report preparation failure outcome/output = %#v / %q", outcome, output.String())
|
t.Fatalf("report preparation failure outcome/output = %#v / %q", outcome, output.String())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -146,8 +155,8 @@ func TestConfigDiffCancellationAfterAcquisitionRetainsDiagnosticsWithoutWriting(
|
|||||||
}}}))
|
}}}))
|
||||||
defer cancel()
|
defer cancel()
|
||||||
var output bytes.Buffer
|
var output bytes.Buffer
|
||||||
outcome := app.Execute(ctx, []string{"config", "diff"}, commandkit.Streams{Out: &output, Err: io.Discard})
|
outcome := app.Execute(ctx, []string{"config", "diff", "--verbose"}, commandkit.Streams{Out: &output, Err: io.Discard})
|
||||||
if outcome.Kind != commandkit.OutcomeOperational || !errors.Is(outcome.Cause, context.Canceled) || outcome.ReportIncomplete || output.Len() != 0 || len(outcome.Diagnostics) < 1 || outcome.Diagnostics[0].Severity != commandkit.SeverityWarning {
|
if outcome.Kind != commandkit.OutcomeOperational || !errors.Is(outcome.Cause, context.Canceled) || output.Len() != 0 {
|
||||||
t.Fatalf("cancelled comparison outcome/output = %#v / %q", outcome, output.String())
|
t.Fatalf("cancelled comparison outcome/output = %#v / %q", outcome, output.String())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -170,13 +179,36 @@ func TestConfigDiffOutputErrorsCarryIncompleteDeliveryOutcome(t *testing.T) {
|
|||||||
} {
|
} {
|
||||||
t.Run(test.name, func(t *testing.T) {
|
t.Run(test.name, func(t *testing.T) {
|
||||||
outcome := app.Execute(context.Background(), []string{"config", "diff"}, commandkit.Streams{Out: test.writer, Err: io.Discard})
|
outcome := app.Execute(context.Background(), []string{"config", "diff"}, commandkit.Streams{Out: test.writer, Err: io.Discard})
|
||||||
if outcome.Kind != commandkit.OutcomeOperational || !errors.Is(outcome.DeliveryError, test.want) || !errors.Is(outcome.Cause, test.want) || !outcome.ReportIncomplete {
|
if outcome.Kind != commandkit.OutcomeOperational || outcome.Cause != nil || !errors.Is(outcome.Presentation.Output.WriteError, test.want) || outcome.Presentation.Output.State != commandkit.DeliveryIncomplete {
|
||||||
t.Fatalf("delivery failure outcome = %#v", outcome)
|
t.Fatalf("delivery failure outcome = %#v", outcome)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestConfigDiffDiagnosticDestinationFailureIsPresentationEvidence(t *testing.T) {
|
||||||
|
app := newConfigTestApp(t, Command(Suite[struct{}, struct{}]{Scopes: []Scope[struct{}, struct{}]{{
|
||||||
|
Name: "app", Operations: []Operation{Diff},
|
||||||
|
Compare: func(context.Context, ComparisonRequest[struct{}], struct{}) (commandkit.Acquired[[]configkit.Difference], error) {
|
||||||
|
return commandkit.Acquired[[]configkit.Difference]{
|
||||||
|
Diagnostics: []commandkit.Diagnostic{{Severity: commandkit.SeverityWarning, Write: func(writer io.Writer) error {
|
||||||
|
_, err := io.WriteString(writer, "provider warning\n")
|
||||||
|
return err
|
||||||
|
}}},
|
||||||
|
}, nil
|
||||||
|
},
|
||||||
|
}}}))
|
||||||
|
writeErr := errors.New("diagnostic destination failed")
|
||||||
|
var output bytes.Buffer
|
||||||
|
outcome := app.Execute(context.Background(), []string{"config", "diff", "--verbose"}, commandkit.Streams{Out: &output, Err: failingOutput{err: writeErr}})
|
||||||
|
if outcome.Kind != commandkit.OutcomeOperational || outcome.Cause != nil || !errors.Is(outcome.Presentation.Diagnostics.WriteError, writeErr) || outcome.Presentation.Diagnostics.State != commandkit.DeliveryIncomplete {
|
||||||
|
t.Fatalf("diagnostic delivery failure outcome = %#v", outcome)
|
||||||
|
}
|
||||||
|
if output.Len() == 0 {
|
||||||
|
t.Fatal("healthy result destination did not receive the diff report")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestConfigDiffRepeatedInvocationsPrepareFreshInputsAndState(t *testing.T) {
|
func TestConfigDiffRepeatedInvocationsPrepareFreshInputsAndState(t *testing.T) {
|
||||||
type input struct {
|
type input struct {
|
||||||
ID int
|
ID int
|
||||||
@@ -224,7 +256,7 @@ func TestConfigDiffRepeatedInvocationsPrepareFreshInputsAndState(t *testing.T) {
|
|||||||
if len(observed) != 2 || observed[0].left.ID == observed[0].right.ID || observed[1].left.ID == observed[1].right.ID || observed[0].left.ID == observed[1].left.ID || observed[0].right.ID == observed[1].right.ID {
|
if len(observed) != 2 || observed[0].left.ID == observed[0].right.ID || observed[1].left.ID == observed[1].right.ID || observed[0].left.ID == observed[1].left.ID || observed[0].right.ID == observed[1].right.ID {
|
||||||
t.Fatalf("input instances were reused: %#v", observed)
|
t.Fatalf("input instances were reused: %#v", observed)
|
||||||
}
|
}
|
||||||
if observed[0].left.Label != "current" || observed[0].right.Label != "current" || observed[1].left.Label != "current" || observed[1].right.Label != "current" {
|
if observed[0].left.Label != "current" || observed[0].right.Label != "" || observed[1].left.Label != "current" || observed[1].right.Label != "" {
|
||||||
t.Fatalf("comparison inputs missed captured flag values: %#v", observed)
|
t.Fatalf("comparison inputs missed captured flag values: %#v", observed)
|
||||||
}
|
}
|
||||||
if observed[0].state == nil || observed[1].state == nil || observed[0].state == observed[1].state || observed[0].state.ID != 1 || observed[1].state.ID != 2 {
|
if observed[0].state == nil || observed[1].state == nil || observed[0].state == observed[1].state || observed[0].state.ID != 1 || observed[1].state.ID != 2 {
|
||||||
@@ -241,7 +273,7 @@ func TestConfigDiffExecutesConcealedChangesAndEmptyResults(t *testing.T) {
|
|||||||
if selection.Config.Set && selection.Config.Value == "after" {
|
if selection.Config.Set && selection.Config.Value == "after" {
|
||||||
value = "changed-secret"
|
value = "changed-secret"
|
||||||
}
|
}
|
||||||
return configkit.Load(configkit.Mapping("selected", map[string]any{"secret": value}), configkit.Options[settings]{Definition: configkit.Definition[settings]{Secret: func(path configkit.Path) bool { return path.String() == "/secret" }}})
|
return configkit.Load(configkit.Mapping("selected", map[string]any{"secret": value}), configkit.Options[settings]{Definition: configkit.Definition[settings]{Sensitive: func(path configkit.Path) bool { return path.String() == "/secret" }}})
|
||||||
}))
|
}))
|
||||||
var output bytes.Buffer
|
var output bytes.Buffer
|
||||||
outcome := app.Execute(context.Background(), []string{"config", "diff", "--config", "before", "--against-config", "after"}, commandkit.Streams{Out: &output, Err: io.Discard})
|
outcome := app.Execute(context.Background(), []string{"config", "diff", "--config", "before", "--against-config", "after"}, commandkit.Streams{Out: &output, Err: io.Discard})
|
||||||
|
|||||||
@@ -1,26 +1,13 @@
|
|||||||
package ckconfig
|
package ckconfig
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
commandkit "go.maximumdirect.net/commandkit"
|
||||||
"go.yaml.in/yaml/v4"
|
configkit "go.maximumdirect.net/configkit"
|
||||||
)
|
)
|
||||||
|
|
||||||
func decodeSafeSnapshot(snapshot string) (*yaml.Node, error) {
|
|
||||||
var document yaml.Node
|
|
||||||
decoder := yaml.NewDecoder(strings.NewReader(snapshot))
|
|
||||||
if err := decoder.Decode(&document); err != nil {
|
|
||||||
return nil, fmt.Errorf("decode safe YAML snapshot: %w", err)
|
|
||||||
}
|
|
||||||
if document.Kind != yaml.DocumentNode || len(document.Content) == 0 {
|
|
||||||
return nil, fmt.Errorf("decode safe YAML snapshot: document has no value")
|
|
||||||
}
|
|
||||||
return document.Content[0], nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func decodeJSONPointer(pointer string) ([]string, error) {
|
func decodeJSONPointer(pointer string) ([]string, error) {
|
||||||
if pointer == "" {
|
if pointer == "" {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
@@ -56,139 +43,114 @@ func decodeJSONPointer(pointer string) ([]string, error) {
|
|||||||
return parts, nil
|
return parts, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// yamlValuePlacer indexes each mapping once while assembling one report.
|
func placeReportValue(root any, path []string, value any) (any, error) {
|
||||||
// Content remains the ordered representation used by the YAML encoder.
|
|
||||||
type yamlValuePlacer struct {
|
|
||||||
keys map[*yaml.Node]map[string]int
|
|
||||||
}
|
|
||||||
|
|
||||||
func (placer *yamlValuePlacer) placeValue(category *yaml.Node, path []string, value *yaml.Node) (*yaml.Node, error) {
|
|
||||||
if value == nil {
|
|
||||||
return nil, fmt.Errorf("place YAML value: value node is nil")
|
|
||||||
}
|
|
||||||
if len(path) == 0 {
|
if len(path) == 0 {
|
||||||
return value, nil
|
return value, nil
|
||||||
}
|
}
|
||||||
if category == nil || category.Kind != yaml.MappingNode {
|
current, ok := root.(map[string]any)
|
||||||
return nil, fmt.Errorf("place YAML value at %q: category ancestor is not a mapping", pointerFromTokens(path[:1]))
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("report path ancestor is not a mapping")
|
||||||
}
|
}
|
||||||
|
|
||||||
current := category
|
|
||||||
for index, token := range path {
|
for index, token := range path {
|
||||||
keys := placer.mappingKeys(current)
|
|
||||||
keyIndex, exists := keys[token]
|
|
||||||
if index == len(path)-1 {
|
if index == len(path)-1 {
|
||||||
if exists {
|
current[token] = value
|
||||||
current.Content[keyIndex+1] = value
|
return root, nil
|
||||||
} else {
|
|
||||||
keys[token] = len(current.Content)
|
|
||||||
current.Content = append(current.Content, stringKey(token), value)
|
|
||||||
}
|
|
||||||
return category, nil
|
|
||||||
}
|
}
|
||||||
|
child, exists := current[token]
|
||||||
if !exists {
|
if !exists {
|
||||||
child := &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"}
|
next := make(map[string]any)
|
||||||
keys[token] = len(current.Content)
|
current[token] = next
|
||||||
current.Content = append(current.Content, stringKey(token), child)
|
current = next
|
||||||
current = child
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
next, ok := child.(map[string]any)
|
||||||
child := current.Content[keyIndex+1]
|
if !ok {
|
||||||
if child.Kind != yaml.MappingNode {
|
return nil, fmt.Errorf("report path ancestor %q is not a mapping", token)
|
||||||
return nil, fmt.Errorf("place YAML value at %q: ancestor %q is not a mapping", pointerFromTokens(path), pointerFromTokens(path[:index+1]))
|
|
||||||
}
|
}
|
||||||
current = child
|
current = next
|
||||||
}
|
}
|
||||||
return category, nil
|
return root, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (placer *yamlValuePlacer) mappingKeys(mapping *yaml.Node) map[string]int {
|
type diffReport struct {
|
||||||
if keys, exists := placer.keys[mapping]; exists {
|
Additions any `json:"additions"`
|
||||||
return keys
|
Changes any `json:"changes"`
|
||||||
}
|
Deletions []string `json:"deletions"`
|
||||||
if placer.keys == nil {
|
}
|
||||||
placer.keys = make(map[*yaml.Node]map[string]int)
|
|
||||||
}
|
type detailedDiffReport struct {
|
||||||
keys := make(map[string]int, len(mapping.Content)/2)
|
Scope string `json:"scope"`
|
||||||
for index := 0; index+1 < len(mapping.Content); index += 2 {
|
Differences []detailedDifference `json:"differences"`
|
||||||
candidate := mapping.Content[index]
|
}
|
||||||
if candidate.Kind == yaml.ScalarNode && candidate.Tag == "!!str" {
|
|
||||||
if _, exists := keys[candidate.Value]; !exists {
|
type detailedDifference struct {
|
||||||
keys[candidate.Value] = index
|
Path string `json:"path"`
|
||||||
}
|
Before detailedDiffSnapshot `json:"before"`
|
||||||
|
After detailedDiffSnapshot `json:"after"`
|
||||||
|
HiddenChanged bool `json:"hidden_changed"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type detailedDiffSnapshot struct {
|
||||||
|
Present bool `json:"present"`
|
||||||
|
Redacted bool `json:"redacted"`
|
||||||
|
Value any `json:"value"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderDetailedDiff(scope string, differences []configkit.Difference) (any, error) {
|
||||||
|
report := detailedDiffReport{Scope: scope, Differences: make([]detailedDifference, 0, len(differences))}
|
||||||
|
for _, difference := range differences {
|
||||||
|
before, err := detailedSnapshot(difference.Left)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("difference at %q before: %w", difference.Path.String(), err)
|
||||||
}
|
}
|
||||||
|
after, err := detailedSnapshot(difference.Right)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("difference at %q after: %w", difference.Path.String(), err)
|
||||||
|
}
|
||||||
|
report.Differences = append(report.Differences, detailedDifference{Path: difference.Path.String(), Before: before, After: after, HiddenChanged: difference.HiddenChanged})
|
||||||
}
|
}
|
||||||
placer.keys[mapping] = keys
|
return report, nil
|
||||||
return keys
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func stringKey(value string) *yaml.Node {
|
func detailedSnapshot(snapshot configkit.ValueSnapshot) (detailedDiffSnapshot, error) {
|
||||||
return &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: value}
|
report := detailedDiffSnapshot{Present: snapshot.Present, Redacted: snapshot.Redacted}
|
||||||
|
if snapshot.Present {
|
||||||
|
value, err := commandkit.DecodeYAMLReport(strings.NewReader(snapshot.YAML))
|
||||||
|
if err != nil {
|
||||||
|
return report, err
|
||||||
|
}
|
||||||
|
report.Value = value
|
||||||
|
}
|
||||||
|
return report, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func pointerFromTokens(tokens []string) string {
|
func renderDiffReport(differences []configkit.Difference) (any, error) {
|
||||||
if len(tokens) == 0 {
|
report := diffReport{Additions: map[string]any{}, Changes: map[string]any{}, Deletions: []string{}}
|
||||||
return ""
|
|
||||||
}
|
|
||||||
var pointer strings.Builder
|
|
||||||
for _, token := range tokens {
|
|
||||||
pointer.WriteByte('/')
|
|
||||||
pointer.WriteString(strings.ReplaceAll(strings.ReplaceAll(token, "~", "~0"), "/", "~1"))
|
|
||||||
}
|
|
||||||
return pointer.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
func renderDiffReport(differences []configkit.Difference) ([]byte, error) {
|
|
||||||
var placer yamlValuePlacer
|
|
||||||
additions := &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"}
|
|
||||||
changes := &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"}
|
|
||||||
deletions := &yaml.Node{Kind: yaml.SequenceNode, Tag: "!!seq"}
|
|
||||||
|
|
||||||
for _, difference := range differences {
|
for _, difference := range differences {
|
||||||
path := difference.Path.String()
|
path := difference.Path.String()
|
||||||
switch {
|
if difference.Left.Present && !difference.Right.Present {
|
||||||
case !difference.Left.Present && difference.Right.Present:
|
report.Deletions = append(report.Deletions, path)
|
||||||
tokens, err := decodeJSONPointer(path)
|
continue
|
||||||
if err != nil {
|
}
|
||||||
return nil, fmt.Errorf("render addition at %q: %w", path, err)
|
if !difference.Right.Present {
|
||||||
}
|
continue
|
||||||
value, err := decodeSafeSnapshot(difference.Right.YAML)
|
}
|
||||||
if err != nil {
|
tokens, err := decodeJSONPointer(path)
|
||||||
return nil, fmt.Errorf("render addition at %q: %w", path, err)
|
if err != nil {
|
||||||
}
|
return nil, fmt.Errorf("difference at %q: %w", path, err)
|
||||||
additions, err = placer.placeValue(additions, tokens, value)
|
}
|
||||||
if err != nil {
|
value, err := commandkit.DecodeYAMLReport(strings.NewReader(difference.Right.YAML))
|
||||||
return nil, fmt.Errorf("render addition at %q: %w", path, err)
|
if err != nil {
|
||||||
}
|
return nil, fmt.Errorf("difference at %q: %w", path, err)
|
||||||
case difference.Left.Present && difference.Right.Present:
|
}
|
||||||
tokens, err := decodeJSONPointer(path)
|
if difference.Left.Present {
|
||||||
if err != nil {
|
report.Changes, err = placeReportValue(report.Changes, tokens, value)
|
||||||
return nil, fmt.Errorf("render change at %q: %w", path, err)
|
} else {
|
||||||
}
|
report.Additions, err = placeReportValue(report.Additions, tokens, value)
|
||||||
value, err := decodeSafeSnapshot(difference.Right.YAML)
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("render change at %q: %w", path, err)
|
return nil, fmt.Errorf("difference at %q: %w", path, err)
|
||||||
}
|
|
||||||
changes, err = placer.placeValue(changes, tokens, value)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("render change at %q: %w", path, err)
|
|
||||||
}
|
|
||||||
case difference.Left.Present && !difference.Right.Present:
|
|
||||||
deletions.Content = append(deletions.Content, stringKey(path))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return report, nil
|
||||||
root := &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map", Content: []*yaml.Node{
|
|
||||||
stringKey("additions"), additions,
|
|
||||||
stringKey("changes"), changes,
|
|
||||||
stringKey("deletions"), deletions,
|
|
||||||
}}
|
|
||||||
document := &yaml.Node{Kind: yaml.DocumentNode, Content: []*yaml.Node{root}}
|
|
||||||
var output bytes.Buffer
|
|
||||||
if err := encodeYAML(&output, document); err != nil {
|
|
||||||
return nil, fmt.Errorf("encode configuration diff report: %w", err)
|
|
||||||
}
|
|
||||||
return output.Bytes(), nil
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,11 +2,11 @@ package ckconfig
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"reflect"
|
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
commandkit "go.maximumdirect.net/commandkit"
|
||||||
|
configkit "go.maximumdirect.net/configkit"
|
||||||
"go.yaml.in/yaml/v4"
|
"go.yaml.in/yaml/v4"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -16,11 +16,16 @@ func renderAndDecode(t *testing.T, differences []configkit.Difference) (*yaml.No
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
var rendered bytes.Buffer
|
||||||
|
if err := commandkit.RenderReport(&rendered, commandkit.Report{Value: report}, commandkit.FormatYAML); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
data := rendered.Bytes()
|
||||||
var document yaml.Node
|
var document yaml.Node
|
||||||
if err := yaml.Unmarshal(report, &document); err != nil {
|
if err := yaml.Unmarshal(data, &document); err != nil {
|
||||||
t.Fatalf("decode rendered report %q: %v", report, err)
|
t.Fatalf("decode rendered report %q: %v", report, err)
|
||||||
}
|
}
|
||||||
return document.Content[0], report
|
return document.Content[0], data
|
||||||
}
|
}
|
||||||
|
|
||||||
func reportField(mapping *yaml.Node, key string) *yaml.Node {
|
func reportField(mapping *yaml.Node, key string) *yaml.Node {
|
||||||
@@ -43,18 +48,12 @@ func TestRenderDiffReportClassifiesAndOrdersNativeRecords(t *testing.T) {
|
|||||||
if root.Kind != yaml.MappingNode || len(root.Content) != 6 {
|
if root.Kind != yaml.MappingNode || len(root.Content) != 6 {
|
||||||
t.Fatalf("report root = %#v", root)
|
t.Fatalf("report root = %#v", root)
|
||||||
}
|
}
|
||||||
if got := []string{root.Content[0].Value, root.Content[2].Value, root.Content[4].Value}; !reflect.DeepEqual(got, []string{"additions", "changes", "deletions"}) {
|
|
||||||
t.Fatalf("report category order = %#v", got)
|
|
||||||
}
|
|
||||||
additions := reportField(root, "additions")
|
additions := reportField(root, "additions")
|
||||||
if additions.Kind != yaml.MappingNode || additions.Content[0].Value != "z-added" || additions.Content[2].Value != "a-added" {
|
if value := reportField(additions, "z-added"); value == nil || value.Tag != "!!bool" || value.Value != "false" {
|
||||||
t.Fatalf("addition order/value = %#v", additions)
|
t.Fatalf("boolean addition = %#v", value)
|
||||||
}
|
}
|
||||||
if additions.Content[1].Kind != yaml.ScalarNode || additions.Content[1].Tag != "!!bool" || additions.Content[1].Value != "false" {
|
if value := reportField(additions, "a-added"); value == nil || value.Tag != "!!str" || value.Value != "123" {
|
||||||
t.Fatalf("boolean addition lost its type: %#v", additions.Content[1])
|
t.Fatalf("string addition = %#v", value)
|
||||||
}
|
|
||||||
if additions.Content[3].Tag != "!!str" || additions.Content[3].Value != "123" {
|
|
||||||
t.Fatalf("numeric-looking string lost its type: %#v", additions.Content[3])
|
|
||||||
}
|
}
|
||||||
changes := reportField(root, "changes")
|
changes := reportField(root, "changes")
|
||||||
if changes.Kind != yaml.MappingNode || changes.Content[1].Tag != "!!int" || changes.Content[1].Value != "9007199254740993" {
|
if changes.Kind != yaml.MappingNode || changes.Content[1].Tag != "!!int" || changes.Content[1].Value != "9007199254740993" {
|
||||||
@@ -68,9 +67,6 @@ func TestRenderDiffReportClassifiesAndOrdersNativeRecords(t *testing.T) {
|
|||||||
|
|
||||||
func TestRenderDiffReportEmitsEmptyCategoriesAndRootValue(t *testing.T) {
|
func TestRenderDiffReportEmitsEmptyCategoriesAndRootValue(t *testing.T) {
|
||||||
root, _ := renderAndDecode(t, nil)
|
root, _ := renderAndDecode(t, nil)
|
||||||
if got := []string{root.Content[0].Value, root.Content[2].Value, root.Content[4].Value}; !reflect.DeepEqual(got, []string{"additions", "changes", "deletions"}) {
|
|
||||||
t.Fatalf("empty report keys = %#v", got)
|
|
||||||
}
|
|
||||||
if reportField(root, "additions").Kind != yaml.MappingNode || len(reportField(root, "additions").Content) != 0 || reportField(root, "changes").Kind != yaml.MappingNode || len(reportField(root, "changes").Content) != 0 || reportField(root, "deletions").Kind != yaml.SequenceNode || len(reportField(root, "deletions").Content) != 0 {
|
if reportField(root, "additions").Kind != yaml.MappingNode || len(reportField(root, "additions").Content) != 0 || reportField(root, "changes").Kind != yaml.MappingNode || len(reportField(root, "changes").Content) != 0 || reportField(root, "deletions").Kind != yaml.SequenceNode || len(reportField(root, "deletions").Content) != 0 {
|
||||||
t.Fatalf("empty report categories = %#v", root)
|
t.Fatalf("empty report categories = %#v", root)
|
||||||
}
|
}
|
||||||
@@ -119,7 +115,7 @@ func TestRenderDiffReportPreservesNativeMasksAndMarkerLookingStrings(t *testing.
|
|||||||
Public string `yaml:"public"`
|
Public string `yaml:"public"`
|
||||||
Secret string `yaml:"secret"`
|
Secret string `yaml:"secret"`
|
||||||
}
|
}
|
||||||
definition := configkit.Definition[settings]{Secret: func(path configkit.Path) bool { return path.String() == "/secret" }}
|
definition := configkit.Definition[settings]{Sensitive: func(path configkit.Path) bool { return path.String() == "/secret" }}
|
||||||
load := func(public, secret string) *configkit.Result[settings] {
|
load := func(public, secret string) *configkit.Result[settings] {
|
||||||
result, err := configkit.Load(configkit.Mapping("configuration", map[string]any{"public": public, "secret": secret}), configkit.Options[settings]{Definition: definition})
|
result, err := configkit.Load(configkit.Mapping("configuration", map[string]any{"public": public, "secret": secret}), configkit.Options[settings]{Definition: definition})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -2,49 +2,9 @@ package ckconfig
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"reflect"
|
"reflect"
|
||||||
"strings"
|
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"go.yaml.in/yaml/v4"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestDecodeSafeSnapshotPreservesYAMLTypes(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
text string
|
|
||||||
kind yaml.Kind
|
|
||||||
tag string
|
|
||||||
value string
|
|
||||||
}{
|
|
||||||
{name: "null", text: "null\n", kind: yaml.ScalarNode, tag: "!!null", value: "null"},
|
|
||||||
{name: "boolean", text: "true\n", kind: yaml.ScalarNode, tag: "!!bool", value: "true"},
|
|
||||||
{name: "large integer", text: "9007199254740993\n", kind: yaml.ScalarNode, tag: "!!int", value: "9007199254740993"},
|
|
||||||
{name: "numeric string", text: "'123'\n", kind: yaml.ScalarNode, tag: "!!str", value: "123"},
|
|
||||||
{name: "boolean-like string", text: "'false'\n", kind: yaml.ScalarNode, tag: "!!str", value: "false"},
|
|
||||||
{name: "empty mapping", text: "{}\n", kind: yaml.MappingNode, tag: "!!map"},
|
|
||||||
{name: "empty sequence", text: "[]\n", kind: yaml.SequenceNode, tag: "!!seq"},
|
|
||||||
}
|
|
||||||
for _, test := range tests {
|
|
||||||
t.Run(test.name, func(t *testing.T) {
|
|
||||||
node, err := decodeSafeSnapshot(test.text)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if node.Kind != test.kind || node.Tag != test.tag || node.Value != test.value {
|
|
||||||
t.Fatalf("node kind/tag/value = %v/%q/%q, want %v/%q/%q", node.Kind, node.Tag, node.Value, test.kind, test.tag, test.value)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
nested, err := decodeSafeSnapshot("items:\n - name: example\n enabled: false\n")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if nested.Kind != yaml.MappingNode || nested.Content[1].Kind != yaml.SequenceNode || nested.Content[1].Content[0].Content[3].Tag != "!!bool" {
|
|
||||||
t.Fatalf("nested collection/scalar tags were not preserved: %#v", nested)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDecodeJSONPointerPreservesSemanticTokens(t *testing.T) {
|
func TestDecodeJSONPointerPreservesSemanticTokens(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
pointer string
|
pointer string
|
||||||
@@ -73,74 +33,25 @@ func TestDecodeJSONPointerPreservesSemanticTokens(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestPlaceYAMLValueBuildsOrderedSemanticMappings(t *testing.T) {
|
func TestPlaceReportValue(t *testing.T) {
|
||||||
var placer yamlValuePlacer
|
var root any = map[string]any{}
|
||||||
category := &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"}
|
var err error
|
||||||
paths := [][]string{
|
root, err = placeReportValue(root, []string{"a/b", "~1"}, false)
|
||||||
{"parent", "0", "dot.name", "50%"},
|
|
||||||
{"parent", "second"},
|
|
||||||
{"a/b", "~1"},
|
|
||||||
{""},
|
|
||||||
}
|
|
||||||
for index, path := range paths {
|
|
||||||
value := &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: string(rune('a' + index))}
|
|
||||||
updated, err := placer.placeValue(category, path, value)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("place %q: %v", pointerFromTokens(path), err)
|
|
||||||
}
|
|
||||||
category = updated
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(category.Content) != 6 || category.Content[0].Value != "parent" || category.Content[2].Value != "a/b" || category.Content[4].Value != "" {
|
|
||||||
t.Fatalf("mapping insertion order or empty key = %#v", category.Content)
|
|
||||||
}
|
|
||||||
parent := category.Content[1]
|
|
||||||
if parent.Kind != yaml.MappingNode || len(parent.Content) != 4 || parent.Content[0].Value != "0" || parent.Content[2].Value != "second" {
|
|
||||||
t.Fatalf("nested shared ancestor/order = %#v", parent)
|
|
||||||
}
|
|
||||||
numeric := parent.Content[1]
|
|
||||||
dotted := numeric.Content[1]
|
|
||||||
if numeric.Kind != yaml.MappingNode || numeric.Content[0].Value != "dot.name" || dotted.Kind != yaml.MappingNode || dotted.Content[0].Value != "50%" {
|
|
||||||
t.Fatalf("semantic tokens were changed: numeric=%#v dotted=%#v", numeric, dotted)
|
|
||||||
}
|
|
||||||
if category.Content[1].Content[0].Tag != "!!str" || category.Content[2].Tag != "!!str" || category.Content[4].Tag != "!!str" {
|
|
||||||
t.Fatal("mapping keys are not explicitly tagged as YAML strings")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPlaceYAMLValueHandlesRootAndEmptyKey(t *testing.T) {
|
|
||||||
var placer yamlValuePlacer
|
|
||||||
category := &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"}
|
|
||||||
root := &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!null", Value: "null"}
|
|
||||||
updated, err := placer.placeValue(category, nil, root)
|
|
||||||
if err != nil || updated != root {
|
|
||||||
t.Fatalf("root placement = %#v, %v", updated, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
category = &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"}
|
|
||||||
value := &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: "empty-key"}
|
|
||||||
updated, err = placer.placeValue(category, []string{""}, value)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if updated != category || len(category.Content) != 2 || category.Content[0].Value != "" || category.Content[0].Tag != "!!str" || category.Content[1] != value {
|
root, err = placeReportValue(root, []string{""}, nil)
|
||||||
t.Fatalf("slash path placement = %#v", category)
|
if err != nil {
|
||||||
}
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
want := map[string]any{"a/b": map[string]any{"~1": false}, "": nil}
|
||||||
func TestPlaceYAMLValueRejectsIncompatibleAncestors(t *testing.T) {
|
if !reflect.DeepEqual(root, want) {
|
||||||
var placer yamlValuePlacer
|
t.Fatalf("got %#v", root)
|
||||||
category := &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map", Content: []*yaml.Node{
|
}
|
||||||
stringKey("parent"), {Kind: yaml.ScalarNode, Tag: "!!str", Value: "leaf"},
|
if _, err := placeReportValue(root, []string{"a/b", "~1", "child"}, 1); err == nil {
|
||||||
}}
|
t.Fatal("scalar ancestor accepted")
|
||||||
_, err := placer.placeValue(category, []string{"parent", "child"}, &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!null", Value: "null"})
|
}
|
||||||
if err == nil || !strings.Contains(err.Error(), "/parent/child") || !strings.Contains(err.Error(), "/parent") {
|
if value, err := placeReportValue(root, nil, "replacement"); err != nil || value != "replacement" {
|
||||||
t.Fatalf("incompatible ancestor error = %v", err)
|
t.Fatalf("root: %#v, %v", value, err)
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDecodeSafeSnapshotRejectsMalformedYAML(t *testing.T) {
|
|
||||||
if _, err := decodeSafeSnapshot("value: [unterminated\n"); err == nil {
|
|
||||||
t.Fatal("malformed snapshot unexpectedly decoded")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
116
integrations/ckconfig/discovery_test.go
Normal file
116
integrations/ckconfig/discovery_test.go
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
package ckconfig
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
commandkit "go.maximumdirect.net/commandkit"
|
||||||
|
configkit "go.maximumdirect.net/configkit"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestExplainDiscoveryModesAndMetadata(t *testing.T) {
|
||||||
|
loads, inputs, states := 0, 0, 0
|
||||||
|
scope := FromConfigKit("app", "", func(_ context.Context, selection Selection, _, _ struct{}) (*configkit.Result[map[string]any], error) {
|
||||||
|
loads++
|
||||||
|
return configkit.Load(configkit.Mapping("fixture", map[string]any{"a/b": map[string]any{"x": 1}, "ab": 2, "abacus": 3, "gone": map[string]any{"x": 1}}), configkit.Options[map[string]any]{Overrides: selection.Overrides})
|
||||||
|
})
|
||||||
|
scope.Features.Overrides = true
|
||||||
|
app := newConfigTestApp(t, Command(Suite[struct{}, struct{}]{NewInput: func() struct{} { inputs++; return struct{}{} }, NewState: func() struct{} { states++; return struct{}{} }, Scopes: []Scope[struct{}, struct{}]{scope}}))
|
||||||
|
run := func(args ...string) (commandkit.Outcome, string) {
|
||||||
|
var out bytes.Buffer
|
||||||
|
result := app.Execute(context.Background(), append([]string{"config", "explain"}, args...), commandkit.Streams{Out: &out, Err: io.Discard})
|
||||||
|
return result, out.String()
|
||||||
|
}
|
||||||
|
result, out := run()
|
||||||
|
if result.Kind != commandkit.OutcomeSuccess || !strings.Contains(out, "--list") || !strings.Contains(out, "--path") || loads != 0 || inputs != 0 || states != 0 {
|
||||||
|
t.Fatalf("bare help: %#v %q, %d/%d/%d", result, out, loads, inputs, states)
|
||||||
|
}
|
||||||
|
for _, args := range [][]string{{"--list", "--path=/ab"}, {"--historical"}, {"--under=/ab"}, {"--explicit=false"}, {"--path=/ab", "--under=/ab"}, {"--list", "--under=bad"}} {
|
||||||
|
result, _ := run(args...)
|
||||||
|
if result.Kind != commandkit.OutcomeUsage || loads != 0 || inputs != 0 || states != 0 {
|
||||||
|
t.Fatalf("invalid %v: %#v, %d/%d/%d", args, result, loads, inputs, states)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result, out = run("--list", "--under=/a~1b", "--format=json")
|
||||||
|
var report pathDiscoveryReport
|
||||||
|
if result.Kind != commandkit.OutcomeSuccess || json.Unmarshal([]byte(out), &report) != nil || len(report.Paths) != 2 {
|
||||||
|
t.Fatalf("escaped subtree: %#v %q", result, out)
|
||||||
|
}
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, entry := range report.Paths {
|
||||||
|
seen[entry.Path] = true
|
||||||
|
}
|
||||||
|
if !seen["/a~1b"] || !seen["/a~1b/x"] {
|
||||||
|
t.Fatalf("escaped paths: %#v", report.Paths)
|
||||||
|
}
|
||||||
|
result, out = run("--list", "--under=/ab", "--format=json")
|
||||||
|
if result.Kind != commandkit.OutcomeSuccess || json.Unmarshal([]byte(out), &report) != nil || len(report.Paths) != 1 || report.Paths[0].Path != "/ab" {
|
||||||
|
t.Fatalf("token boundary: %#v %q", result, out)
|
||||||
|
}
|
||||||
|
result, out = run("--list", "--historical", "--set=/gone=null", "--format=json")
|
||||||
|
if result.Kind != commandkit.OutcomeSuccess || json.Unmarshal([]byte(out), &report) != nil {
|
||||||
|
t.Fatalf("historical: %#v %q", result, out)
|
||||||
|
}
|
||||||
|
removed := false
|
||||||
|
for _, entry := range report.Paths {
|
||||||
|
if entry.Path == "/gone/x" && !entry.Present && entry.Metadata == nil {
|
||||||
|
removed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !removed {
|
||||||
|
t.Fatalf("missing historical entry: %q", out)
|
||||||
|
}
|
||||||
|
result, out = run("--list", "--historical", "--explicit=false", "--set=/gone=null", "--format=json")
|
||||||
|
if result.Kind != commandkit.OutcomeSuccess || json.Unmarshal([]byte(out), &report) != nil {
|
||||||
|
t.Fatalf("explicit filter: %#v %q", result, out)
|
||||||
|
}
|
||||||
|
for _, entry := range report.Paths {
|
||||||
|
if !entry.Present || entry.Metadata == nil || entry.Metadata.Explicit {
|
||||||
|
t.Fatalf("invalid filtered entry: %#v", entry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result, out = run("--path=", "--format=json")
|
||||||
|
if result.Kind != commandkit.OutcomeSuccess || !strings.Contains(out, `"path": ""`) || !strings.Contains(out, `"metadata"`) {
|
||||||
|
t.Fatalf("root explanation: %#v %q", result, out)
|
||||||
|
}
|
||||||
|
result, out = run("--path=/gone/x", "--set=/gone=null", "--format=json")
|
||||||
|
if result.Kind != commandkit.OutcomeSuccess || !strings.Contains(out, `"metadata"`) || !strings.Contains(out, `"history"`) {
|
||||||
|
t.Fatalf("historical explanation: %#v %q", result, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExplainDiscoveryCapabilityBeforeAcquisition(t *testing.T) {
|
||||||
|
calls := 0
|
||||||
|
scope := Scope[struct{}, struct{}]{Name: "app", Operations: []Operation{Explain}, Acquire: func(context.Context, Request, struct{}, struct{}) (commandkit.Acquired[View], error) {
|
||||||
|
calls++
|
||||||
|
return commandkit.Acquired[View]{}, nil
|
||||||
|
}}
|
||||||
|
app := newConfigTestApp(t, Command(Suite[struct{}, struct{}]{Scopes: []Scope[struct{}, struct{}]{scope}}))
|
||||||
|
result := app.Execute(context.Background(), []string{"config", "explain", "--list"}, commandkit.Streams{Out: io.Discard, Err: io.Discard})
|
||||||
|
if result.Kind != commandkit.OutcomeUsage || calls != 0 {
|
||||||
|
t.Fatalf("unsupported discovery: %#v calls=%d", result, calls)
|
||||||
|
}
|
||||||
|
scope.Features.PathDiscovery = true
|
||||||
|
app = newConfigTestApp(t, Command(Suite[struct{}, struct{}]{Scopes: []Scope[struct{}, struct{}]{scope}}))
|
||||||
|
result = app.Execute(context.Background(), []string{"config", "explain", "--list"}, commandkit.Streams{Out: io.Discard, Err: io.Discard})
|
||||||
|
if result.Kind != commandkit.OutcomeOperational || calls != 1 {
|
||||||
|
t.Fatalf("missing metadata: %#v calls=%d", result, calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExplainBareHelpRecordsDeliveryFailure(t *testing.T) {
|
||||||
|
inputs, states, calls := 0, 0, 0
|
||||||
|
scope := Scope[struct{}, struct{}]{Name: "app", Operations: []Operation{Explain}, Acquire: func(context.Context, Request, struct{}, struct{}) (commandkit.Acquired[View], error) {
|
||||||
|
calls++
|
||||||
|
return commandkit.Acquired[View]{}, nil
|
||||||
|
}}
|
||||||
|
app := newConfigTestApp(t, Command(Suite[struct{}, struct{}]{NewInput: func() struct{} { inputs++; return struct{}{} }, NewState: func() struct{} { states++; return struct{}{} }, Scopes: []Scope[struct{}, struct{}]{scope}}))
|
||||||
|
result := app.Execute(context.Background(), []string{"config", "explain"}, commandkit.Streams{Out: shortWriter{}, Err: io.Discard})
|
||||||
|
if result.Presentation.Output.State != commandkit.DeliveryIncomplete || inputs != 0 || states != 0 || calls != 0 {
|
||||||
|
t.Fatalf("help delivery = %#v, input/state/acquire=%d/%d/%d", result, inputs, states, calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,10 @@
|
|||||||
// Package ckconfig provides CommandKit's optional ConfigKit integration. Its
|
// Package ckconfig, provided by go.maximumdirect.net/ckconfig, provides
|
||||||
// declarations install the standard config command family through the core
|
// CommandKit's optional ConfigKit v1.1.0 integration, requiring Go 1.27.0. Its
|
||||||
// commandkit package. Source reports preserve ConfigKit's destination,
|
// canonical Command declaration installs the standard config command family
|
||||||
// placement, and version-check evidence; importing ckconfig alone installs no
|
// through the core commandkit package. Applications can install CommandKit
|
||||||
// commands.
|
// RoutingRule declarations alongside it through commandkit.New or
|
||||||
|
// App.Register. Source reports preserve ConfigKit's destination, placement,
|
||||||
|
// version, and empty-import evidence. Invocation reporting applies CommandKit's shared
|
||||||
|
// visibility and delivery policy; provider warnings remain independent of
|
||||||
|
// failure explanations. Importing ckconfig alone installs no commands.
|
||||||
package ckconfig
|
package ckconfig
|
||||||
|
|||||||
@@ -2,16 +2,16 @@
|
|||||||
|
|
||||||
## ConfigKit
|
## ConfigKit
|
||||||
|
|
||||||
Selected module: `gitea.maximumdirect.net/eric/ConfigKit` at `v0.8.0`.
|
Selected module: `go.maximumdirect.net/configkit` at `v1.1.0`.
|
||||||
|
|
||||||
Copyright (C) 2026 Eric Rakestraw
|
Copyright (C) 2026 Eric Rakestraw
|
||||||
|
|
||||||
SPDX-License-Identifier: LGPL-3.0-or-later
|
SPDX-License-Identifier: LGPL-3.0-or-later
|
||||||
|
|
||||||
License and notices: [LGPL v3.0](../../LICENSE) |
|
License and notices: [LGPL v3.0](../../LICENSE) |
|
||||||
[LICENSE](https://gitea.maximumdirect.net/eric/ConfigKit/src/commit/137769f3f18a370c68444242fb5d88e620e307e5/LICENSE).
|
[LICENSE](https://forge.maximumdirect.net/base-layer/configkit/src/commit/191015e289d7f2b91abb9fdd9c4f2f5cf75b4de9/LICENSE).
|
||||||
|
|
||||||
[Source](https://gitea.maximumdirect.net/eric/ConfigKit/src/commit/137769f3f18a370c68444242fb5d88e620e307e5).
|
[Source](https://forge.maximumdirect.net/base-layer/configkit/src/commit/191015e289d7f2b91abb9fdd9c4f2f5cf75b4de9).
|
||||||
|
|
||||||
## go-yaml v4
|
## go-yaml v4
|
||||||
|
|
||||||
@@ -153,16 +153,16 @@ License and notices: [MIT](licenses/MIT.txt) |
|
|||||||
|
|
||||||
## ReleaseKit
|
## ReleaseKit
|
||||||
|
|
||||||
Selected module: `gitea.maximumdirect.net/eric/ReleaseKit` at `v0.4.2` (transitive Go tool requirement; not a runtime import).
|
Selected module: `go.maximumdirect.net/releasekit` at `v0.9.0` (transitive Go tool requirement; not a runtime import).
|
||||||
|
|
||||||
Copyright (C) 2026 Eric Rakestraw
|
Copyright (C) 2026 Eric Rakestraw
|
||||||
|
|
||||||
SPDX-License-Identifier: LGPL-3.0-or-later
|
SPDX-License-Identifier: LGPL-3.0-or-later
|
||||||
|
|
||||||
License and notices: [LGPL v3.0](../../LICENSE) |
|
License and notices: [LGPL v3.0](../../LICENSE) |
|
||||||
[LICENSE](https://gitea.maximumdirect.net/eric/ReleaseKit/src/commit/7d033c5ca1c95aec7db9690293638a0a1ac887b8/LICENSE).
|
[LICENSE](https://forge.maximumdirect.net/base-layer/releasekit/src/commit/2180aa868ec2caa95fc8279fd59d5f0ebac41bd4/LICENSE).
|
||||||
|
|
||||||
[Source](https://gitea.maximumdirect.net/eric/ReleaseKit/src/commit/7d033c5ca1c95aec7db9690293638a0a1ac887b8).
|
[Source](https://forge.maximumdirect.net/base-layer/releasekit/src/commit/2180aa868ec2caa95fc8279fd59d5f0ebac41bd4).
|
||||||
|
|
||||||
## Goldmark
|
## Goldmark
|
||||||
|
|
||||||
|
|||||||
35
integrations/ckconfig/docs/releases/v0.2.0.md
Normal file
35
integrations/ckconfig/docs/releases/v0.2.0.md
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
# CommandKit ckconfig v0.2.0
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
CommandKit ckconfig v0.2.0 moves the integration to
|
||||||
|
`go.maximumdirect.net/ckconfig` and uses `go.maximumdirect.net/commandkit v0.3.0`
|
||||||
|
with ConfigKit v0.8.0. It adopts core's invocation reporting and completion
|
||||||
|
contracts while retaining ConfigKit-owned inspection and comparison behavior.
|
||||||
|
|
||||||
|
## Migration
|
||||||
|
|
||||||
|
Replace imports of `gitea.maximumdirect.net/eric/CommandKit/integrations/ckconfig`
|
||||||
|
with `go.maximumdirect.net/ckconfig`, and update core imports to
|
||||||
|
`go.maximumdirect.net/commandkit`. Select ckconfig v0.2.0 and core v0.3.0
|
||||||
|
as a matched set. Old and new core paths have distinct Go type identities.
|
||||||
|
Applications also using PromptKit should move to the forthcoming matched
|
||||||
|
`go.maximumdirect.net/ckprompt v0.2.0` release.
|
||||||
|
|
||||||
|
Handlers use `Invocation.Reporter()` for output and categorized messages;
|
||||||
|
terminal failures carry an explanation and optional cause. The integration
|
||||||
|
submits provider diagnostics through the invocation reporter, respecting shared
|
||||||
|
reporting flags. Update callers that used `Invocation.Streams`,
|
||||||
|
`Outcome.Diagnostics`, or the old error-first failure API as described in the
|
||||||
|
[core migration guide](https://forge.maximumdirect.net/base-layer/commandkit/src/tag/v0.3.0/docs/consumers/guide.md#invocation-reporting).
|
||||||
|
The standard config commands also provide completion hints for applicable
|
||||||
|
selectors and file paths when the application installs core's completion command.
|
||||||
|
|
||||||
|
Go 1.26.0 remains the consumer minimum. Importing ckconfig does not install
|
||||||
|
commands; pass its declarations to `commandkit.New` or `App.Register`.
|
||||||
|
See the [module README](https://forge.maximumdirect.net/base-layer/commandkit/src/tag/integrations/ckconfig/v0.2.0/integrations/ckconfig/README.md)
|
||||||
|
and [consumer guide](https://forge.maximumdirect.net/base-layer/commandkit/src/tag/integrations/ckconfig/v0.2.0/docs/consumers/guide.md#configkit-scopes).
|
||||||
|
|
||||||
|
[COPYRIGHT](https://forge.maximumdirect.net/base-layer/commandkit/src/tag/integrations/ckconfig/v0.2.0/integrations/ckconfig/COPYRIGHT) |
|
||||||
|
[LICENSE](https://forge.maximumdirect.net/base-layer/commandkit/src/tag/integrations/ckconfig/v0.2.0/integrations/ckconfig/LICENSE) |
|
||||||
|
[ATTRIBUTION](https://forge.maximumdirect.net/base-layer/commandkit/src/tag/integrations/ckconfig/v0.2.0/integrations/ckconfig/docs/oss/attribution.md)
|
||||||
25
integrations/ckconfig/docs/releases/v0.3.0.md
Normal file
25
integrations/ckconfig/docs/releases/v0.3.0.md
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
# CommandKit ckconfig v0.3.0
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
This candidate adopts `go.maximumdirect.net/configkit v1.1.0` and requires Go
|
||||||
|
1.27.0, while retaining `go.maximumdirect.net/commandkit v0.3.0`. It updates
|
||||||
|
configuration source reports to expose ConfigKit's current evidence.
|
||||||
|
|
||||||
|
## Migration
|
||||||
|
|
||||||
|
Change ConfigKit imports from `gitea.maximumdirect.net/eric/ConfigKit` to
|
||||||
|
`go.maximumdirect.net/configkit`. Values from the two paths have distinct Go
|
||||||
|
type identities. Update application loaders and provider values together with
|
||||||
|
ckconfig. Published ckprompt v0.2.0 continues to use ckconfig v0.2.0 and the old
|
||||||
|
ConfigKit identity; it does not compose with this candidate.
|
||||||
|
|
||||||
|
Source reports rename `destination_unresolved` to `destination_is_fallback` and
|
||||||
|
`version_check` to `version_evidence`, use the native `main` layer name, and add
|
||||||
|
`empty_import_selections` records (or `[]` when empty). Update report consumers.
|
||||||
|
See the [consumer migration guide](https://forge.maximumdirect.net/base-layer/commandkit/src/tag/integrations/ckconfig/v0.3.0/docs/consumers/guide.md#configkit-v11-migration)
|
||||||
|
for links to upstream configuration and API changes.
|
||||||
|
|
||||||
|
[COPYRIGHT](https://forge.maximumdirect.net/base-layer/commandkit/src/tag/integrations/ckconfig/v0.3.0/integrations/ckconfig/COPYRIGHT) |
|
||||||
|
[LICENSE](https://forge.maximumdirect.net/base-layer/commandkit/src/tag/integrations/ckconfig/v0.3.0/integrations/ckconfig/LICENSE) |
|
||||||
|
[ATTRIBUTION](https://forge.maximumdirect.net/base-layer/commandkit/src/tag/integrations/ckconfig/v0.3.0/integrations/ckconfig/docs/oss/attribution.md)
|
||||||
@@ -1,10 +1,22 @@
|
|||||||
# Simple consumer
|
# Simple consumer
|
||||||
|
|
||||||
|
This example imports `go.maximumdirect.net/ckconfig` and
|
||||||
|
`go.maximumdirect.net/commandkit`; use ckconfig v0.3.0 with core
|
||||||
|
v0.3.0, ConfigKit v1.1.0, and Go 1.27.0. Set `GOWORK=off GOTOOLCHAIN=local`
|
||||||
|
for the commands below.
|
||||||
|
|
||||||
Run the maintained example with:
|
Run the maintained example with:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
go run . config show
|
go run . config show
|
||||||
|
go run . config show --config fixtures/before.yml --verbose
|
||||||
|
go run . config show --config fixtures/before.yml --quiet
|
||||||
|
go run . config explain --list --under /schema
|
||||||
|
go run . config show --path /greeting --set '/greeting="Hi"'
|
||||||
go run . config diff --config fixtures/before.yml --against-config fixtures/after.yml
|
go run . config diff --config fixtures/before.yml --against-config fixtures/after.yml
|
||||||
|
go run . config diff --set '/greeting="Hi"' --details --check
|
||||||
|
go run . config validate --config fixtures/invalid.yml --details --severity error
|
||||||
|
go run . config sources --config fixtures/invalid.yml --source-id source-1
|
||||||
go run . greet
|
go run . greet
|
||||||
go run . greet --count=0 --tag=first --tag second
|
go run . greet --count=0 --tag=first --tag second
|
||||||
```
|
```
|
||||||
@@ -15,3 +27,11 @@ omitted `--count` and an explicitly supplied zero, plus ordered repeated tags.
|
|||||||
With no `--config`, application settings use the in-memory default. A supplied
|
With no `--config`, application settings use the in-memory default. A supplied
|
||||||
configuration and overlay are passed to ConfigKit unchanged. The comparison
|
configuration and overlay are passed to ConfigKit unchanged. The comparison
|
||||||
command loads the two local fixtures and reports `changes.greeting: Goodbye`.
|
command loads the two local fixtures and reports `changes.greeting: Goodbye`.
|
||||||
|
The before fixture uses schema version 1, which ConfigKit reports as a provider
|
||||||
|
warning in verbose mode. Ordinary mode hides the warning while preserving the
|
||||||
|
effective YAML report; quiet mode suppresses that report.
|
||||||
|
The invalid fixture demonstrates a failed-load report and captured source
|
||||||
|
inventory; `source-1` is the ID from that fixture's acquisition. The scope opts
|
||||||
|
in to transient overrides and passes them into its normal ConfigKit load. The
|
||||||
|
diff example compares an overridden left side with ordinary configuration on
|
||||||
|
the right and returns status 3 because the values differ.
|
||||||
|
|||||||
@@ -1 +1,3 @@
|
|||||||
|
schema:
|
||||||
|
version: 2
|
||||||
greeting: Goodbye
|
greeting: Goodbye
|
||||||
|
|||||||
@@ -1 +1,3 @@
|
|||||||
|
schema:
|
||||||
|
version: 1
|
||||||
greeting: Hello
|
greeting: Hello
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
schema:
|
||||||
|
version: 2
|
||||||
|
greeting: [invalid]
|
||||||
@@ -3,15 +3,19 @@ package main
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
commandkit "gitea.maximumdirect.net/eric/CommandKit"
|
"go.maximumdirect.net/ckconfig"
|
||||||
"gitea.maximumdirect.net/eric/CommandKit/integrations/ckconfig"
|
commandkit "go.maximumdirect.net/commandkit"
|
||||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
configkit "go.maximumdirect.net/configkit"
|
||||||
)
|
)
|
||||||
|
|
||||||
type appConfiguration struct {
|
type appConfiguration struct {
|
||||||
|
Schema struct {
|
||||||
|
Version int `yaml:"version"`
|
||||||
|
} `yaml:"schema"`
|
||||||
Greeting string `yaml:"greeting"`
|
Greeting string `yaml:"greeting"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -22,20 +26,38 @@ type greetInput struct {
|
|||||||
|
|
||||||
func loadAppConfiguration(_ context.Context, selection ckconfig.Selection) (*configkit.Result[appConfiguration], error) {
|
func loadAppConfiguration(_ context.Context, selection ckconfig.Selection) (*configkit.Result[appConfiguration], error) {
|
||||||
source := configkit.Mapping("simple application settings", map[string]any{
|
source := configkit.Mapping("simple application settings", map[string]any{
|
||||||
|
"schema": map[string]any{"version": 2},
|
||||||
"greeting": "Hello",
|
"greeting": "Hello",
|
||||||
})
|
})
|
||||||
if selection.Config.Set {
|
if selection.Config.Set {
|
||||||
source = configkit.File(selection.Config.Value)
|
source = configkit.File(selection.Config.Value)
|
||||||
}
|
}
|
||||||
options := configkit.Options[appConfiguration]{}
|
options := configkit.Options[appConfiguration]{
|
||||||
|
Definition: configkit.Definition[appConfiguration]{
|
||||||
|
Version: &configkit.VersionRule{
|
||||||
|
Path: configkit.Keys("schema", "version"),
|
||||||
|
Check: func(version configkit.Version, validation *configkit.Validation) error {
|
||||||
|
if version.Text == "1" {
|
||||||
|
validation.Warning(configkit.Keys("schema", "version"), "schema version 1 is supported; upgrade to version 2 before the next example release")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
if selection.Overlay.Set {
|
if selection.Overlay.Set {
|
||||||
options.Overlay = selection.Overlay.Value
|
options.Overlay = selection.Overlay.Value
|
||||||
options.Filesystem = &configkit.FilesystemContext{Root: ".", BaseDir: "."}
|
options.Filesystem = &configkit.FilesystemContext{Root: ".", BaseDir: "."}
|
||||||
}
|
}
|
||||||
|
options.Overrides = append(options.Overrides, selection.Overrides...)
|
||||||
return configkit.Load(source, options)
|
return configkit.Load(source, options)
|
||||||
}
|
}
|
||||||
|
|
||||||
func newApp() (*commandkit.App, error) {
|
func newApp() (*commandkit.App, error) {
|
||||||
|
return newAppWithLoader(loadAppConfiguration)
|
||||||
|
}
|
||||||
|
|
||||||
|
func newAppWithLoader(load func(context.Context, ckconfig.Selection) (*configkit.Result[appConfiguration], error)) (*commandkit.App, error) {
|
||||||
countDefault := 1
|
countDefault := 1
|
||||||
tagDefaults := []string{"example"}
|
tagDefaults := []string{"example"}
|
||||||
greet := commandkit.NewCommand(commandkit.CommandSpec[greetInput]{
|
greet := commandkit.NewCommand(commandkit.CommandSpec[greetInput]{
|
||||||
@@ -50,9 +72,10 @@ func newApp() (*commandkit.App, error) {
|
|||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
Run: func(invocation commandkit.Invocation, input greetInput) commandkit.Outcome {
|
Run: func(invocation commandkit.Invocation, input greetInput) commandkit.Outcome {
|
||||||
configuration, err := loadAppConfiguration(invocation.Context, ckconfig.Selection{})
|
reporter := invocation.Reporter()
|
||||||
|
configuration, err := load(invocation.Context, ckconfig.Selection{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return commandkit.Failure(err)
|
return commandkit.Failure("Could not load application configuration.").WithCause(err)
|
||||||
}
|
}
|
||||||
count := fmt.Sprintf("default %d (not supplied)", input.Count.Value)
|
count := fmt.Sprintf("default %d (not supplied)", input.Count.Value)
|
||||||
if input.Count.Set {
|
if input.Count.Set {
|
||||||
@@ -62,15 +85,20 @@ func newApp() (*commandkit.App, error) {
|
|||||||
if input.Tags.Set {
|
if input.Tags.Set {
|
||||||
tags = fmt.Sprintf("explicit %v", input.Tags.Value)
|
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 {
|
_ = reporter.RenderOutput(func(writer io.Writer) error {
|
||||||
return commandkit.Failure(err)
|
_, err := fmt.Fprintf(writer, "%s; count=%s; tags=%s\n", configuration.Config.Greeting, count, tags)
|
||||||
}
|
return err
|
||||||
|
})
|
||||||
return commandkit.Success()
|
return commandkit.Success()
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
scope := ckconfig.FromConfigKit("app", "Application configuration.", func(ctx context.Context, selection ckconfig.Selection, _, _ struct{}) (*configkit.Result[appConfiguration], error) {
|
||||||
|
return load(ctx, selection)
|
||||||
|
})
|
||||||
|
scope.Features.Overrides = true
|
||||||
return commandkit.New(
|
return commandkit.New(
|
||||||
commandkit.Identity{Name: "simple-example", Version: "1.0"},
|
commandkit.Identity{Name: "simple-example", Version: "1.0"},
|
||||||
ckconfig.AppCommand(loadAppConfiguration),
|
ckconfig.Command(ckconfig.Suite[struct{}, struct{}]{Scopes: []ckconfig.Scope[struct{}, struct{}]{scope}}),
|
||||||
greet,
|
greet,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user