Compare commits
83 Commits
v0.4.0
...
integratio
| Author | SHA1 | Date | |
|---|---|---|---|
| 1ca660e35f | |||
| 5ed947bd26 | |||
| 1613ff95ca | |||
| 68288ed684 | |||
| 56e3b013f9 | |||
| 96c96bb793 | |||
| d914295ced | |||
| a54ff03aef | |||
| 7aff296c86 | |||
| 3271c18b77 | |||
| 7dd30f7df3 | |||
| 5e5f61a306 | |||
| 6727d22ce6 | |||
| f23af30234 | |||
| ec0e58efff | |||
| 69257066e2 | |||
| 88ac6e76e4 | |||
| 5c96053255 | |||
| 49f2d09ccd | |||
| af7cc1da3e | |||
| ee4e226914 | |||
| c648d1de33 | |||
| 69fe35c480 | |||
| 68825de200 | |||
| 0435a632b3 | |||
| aac0e11ae6 | |||
| e46d28a924 | |||
| 1cba65fffe | |||
| d1102e12c5 | |||
| e950f61c7f | |||
| c4b5bcdbc2 | |||
| b66a00da26 | |||
| a62f1f056b | |||
| 0a81be84f6 | |||
| 83386217db | |||
| 0d3f7299cc | |||
| 02cd49d6b6 | |||
| 51a147a6e5 | |||
| f2822f055c | |||
| 27500dbc6c | |||
| 3b75ae7d2e | |||
| 52f1d99994 | |||
| ac113291fc | |||
| 8dbb25aba9 | |||
| 7c7d1df13f | |||
| 4346b840db | |||
| 552b91c0a2 | |||
| 1fc6409f95 | |||
| c82235100e | |||
| b66d265032 | |||
| a806a9a62e | |||
| 118b49ee55 | |||
| d492c77471 | |||
| 264b82bd57 | |||
| d131999c7c | |||
| c7f898e56e | |||
| 485a27a662 | |||
| 9e0f2d28cd | |||
| 2a93b45419 | |||
| 9d4c963c53 | |||
| 1798dcde2e | |||
| f00fe1909d | |||
| db732e3a1d | |||
| 4a91f29bcd | |||
| efb5f77417 | |||
| 8fe8b67b29 | |||
| 2ea26afcc5 | |||
| 1e4fb73d15 | |||
| d8da761a21 | |||
| 7b2dc2f2c9 | |||
| 7bb7326ed1 | |||
| d72c9a788f | |||
| 3223b5cb82 | |||
| d7a85f5f35 | |||
| 07e1634e79 | |||
| c61e125649 | |||
| 3a7e2d4ce7 | |||
| f5b2d1f80c | |||
| f83866e44a | |||
| 2f43f78f76 | |||
| 668c609062 | |||
| bd115c8bee | |||
| f66502cb23 |
36
README.md
36
README.md
@@ -1,34 +1,26 @@
|
|||||||
# CommandKit
|
# CommandKit
|
||||||
|
|
||||||
CommandKit is a Go library for typed command declarations, shared CLI behavior,
|
CommandKit helps Go applications build consistent command-line interfaces. It
|
||||||
and application-owned execution. The core `commandkit` module is usable by
|
provides typed flags and arguments, help, output formatting, and error reporting.
|
||||||
itself. ConfigKit and PromptKit support lives in separately versioned optional
|
Optional integrations add commands for inspecting configuration and working with
|
||||||
modules, so applications choose which upstream libraries they add.
|
PromptKit.
|
||||||
|
|
||||||
The core captures reusable command declarations, validates installation batches
|
CommandKit is in alpha. Start with the [consumer guide](docs/consumers/guide.md#core-commands)
|
||||||
before changing an application's registry, converts typed inputs before
|
to define a command, or run the [core example](examples/core/README.md).
|
||||||
constructing them, and returns outcomes without exiting the process. Its
|
Applications supply their own command handlers and decide when to exit;
|
||||||
[consumer guide](docs/consumers/guide.md) covers declaration, invocation
|
CommandKit returns the result of each 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 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `go.maximumdirect.net/commandkit` | `commandkit` | Typed commands, bindings, execution reporting and outcomes, help, and version. |
|
| `go.maximumdirect.net/commandkit` | `commandkit` | Commands, typed inputs, shared output and error reporting, help, and version. |
|
||||||
| `go.maximumdirect.net/ckconfig` | `ckconfig` | ConfigKit-backed `config` scopes, including configuration source evidence. |
|
| `go.maximumdirect.net/ckconfig` | `ckconfig` | Commands to view, validate, explain, and compare ConfigKit configuration. |
|
||||||
| `go.maximumdirect.net/ckprompt` | `ckprompt` | PromptKit engine configuration/comparison, five catalogs, named inspection, requirements discovery, concrete request validation, and native smoke tests. |
|
| `go.maximumdirect.net/ckprompt` | `ckprompt` | Commands to list and inspect PromptKit resources, discover required inputs, validate and preview requests, and run smoke tests. |
|
||||||
|
|
||||||
Importing an integration does not install commands. The published ckconfig and
|
Importing an integration does not install commands. Core and the integrations
|
||||||
ckprompt v0.2.0 modules use core v0.3.0. The published
|
are versioned separately; see the [consumer guide](docs/consumers/guide.md#module-selection)
|
||||||
ckconfig v0.3.0 uses ConfigKit v1.1.0 and does not compose with
|
for published versions, compatibility, and installation choices.
|
||||||
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
|
||||||
|
|
||||||
|
|||||||
81
binding.go
81
binding.go
@@ -3,7 +3,6 @@ package commandkit
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"sort"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
commandmeta "go.maximumdirect.net/commandkit/internal/commandmeta"
|
commandmeta "go.maximumdirect.net/commandkit/internal/commandmeta"
|
||||||
@@ -65,6 +64,7 @@ func OccurrenceFlag[I, V any](meta FlagMeta, parse func(string) (V, error), set
|
|||||||
|
|
||||||
// RenameFlag returns an independent binding with a new long name and no short
|
// RenameFlag returns an independent binding with a new long name and no short
|
||||||
// spelling. Parsing, defaults, completion, requiredness, and setters are retained.
|
// spelling. Parsing, defaults, completion, requiredness, and setters are retained.
|
||||||
|
// The name follows the declaration rules in [FlagMeta].
|
||||||
func RenameFlag[I any](flag Flag[I], name string) Flag[I] {
|
func RenameFlag[I any](flag Flag[I], name string) Flag[I] {
|
||||||
binding := flag.binding
|
binding := flag.binding
|
||||||
binding.meta.Name = name
|
binding.meta.Name = name
|
||||||
@@ -84,6 +84,12 @@ func RenameFlag[I any](flag Flag[I], name string) Flag[I] {
|
|||||||
// 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.
|
||||||
|
//
|
||||||
|
// A BoolFlag named debug receives the shared reporting flag's input. Its
|
||||||
|
// default and requiredness apply to application input, not reporting mode;
|
||||||
|
// explicit false satisfies requiredness. Its optional short spelling and
|
||||||
|
// completion metadata follow ordinary flag rules. The final input value can
|
||||||
|
// differ from [Invocation.ReportingMode] when other mode flags are supplied.
|
||||||
func BoolFlag[I any](meta FlagMeta, defaultValue bool, set func(*I, Supplied[bool])) Flag[I] {
|
func BoolFlag[I any](meta FlagMeta, defaultValue bool, set func(*I, Supplied[bool])) Flag[I] {
|
||||||
binding := flagBinding[I]{identity: newBindingIdentity(), meta: meta, defaultText: fmt.Sprint(defaultValue), boolean: true}
|
binding := flagBinding[I]{identity: newBindingIdentity(), meta: meta, defaultText: fmt.Sprint(defaultValue), boolean: true}
|
||||||
if set == nil {
|
if set == nil {
|
||||||
@@ -239,17 +245,6 @@ func (p preparedInput[I]) build() I {
|
|||||||
return input
|
return input
|
||||||
}
|
}
|
||||||
|
|
||||||
// prepareInput parses all supplied bindings into typed closures before it
|
|
||||||
// creates an input value. When enforceRequired is false, missing required flags
|
|
||||||
// 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) {
|
|
||||||
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) {
|
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))
|
||||||
@@ -272,18 +267,7 @@ func prepareInputWithOccurrences[I any](spec CommandSpec[I], flagValues, flagSpe
|
|||||||
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
|
return preparedInput[I]{}, attributeFlagConversionError(binding.meta.Name, flagSpellings[i], err)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
preparedFlags[i] = value
|
preparedFlags[i] = value
|
||||||
for index, occurrence := range value.ordered {
|
for index, occurrence := range value.ordered {
|
||||||
@@ -298,7 +282,14 @@ func prepareInputWithOccurrences[I any](spec CommandSpec[I], flagValues, flagSpe
|
|||||||
fallbackOrdinal++
|
fallbackOrdinal++
|
||||||
}
|
}
|
||||||
if occurrence.apply != nil {
|
if occurrence.apply != nil {
|
||||||
|
if occurrences == nil {
|
||||||
ordered = append(ordered, occurrence)
|
ordered = append(ordered, occurrence)
|
||||||
|
} else {
|
||||||
|
if ordered == nil {
|
||||||
|
ordered = make([]preparedOccurrence[I], len(occurrences))
|
||||||
|
}
|
||||||
|
ordered[*occurrence.ordinal] = occurrence
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if enforceRequired && binding.meta.Required && !binding.deferredRequired && !value.supplied && missingRequired == "" {
|
if enforceRequired && binding.meta.Required && !binding.deferredRequired && !value.supplied && missingRequired == "" {
|
||||||
@@ -308,26 +299,50 @@ func prepareInputWithOccurrences[I any](spec CommandSpec[I], flagValues, flagSpe
|
|||||||
if missingRequired != "" {
|
if missingRequired != "" {
|
||||||
return preparedInput[I]{}, fmt.Errorf("required flag --%s was not supplied", missingRequired)
|
return preparedInput[I]{}, fmt.Errorf("required flag --%s was not supplied", missingRequired)
|
||||||
}
|
}
|
||||||
argumentValues, err := allocateOperands(operands, spec.Arguments, enforceRequired)
|
arguments, err := prepareOperands(operands, spec.Arguments, enforceRequired)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
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 })
|
// Parsed occurrences use dense parser positions; without a parser log the
|
||||||
|
// fallback ordinals were assigned in the same order as the append above.
|
||||||
for _, occurrence := range ordered {
|
for _, occurrence := range ordered {
|
||||||
|
if occurrence.apply != nil {
|
||||||
preparedValues = append(preparedValues, preparedValue[I]{apply: occurrence.apply})
|
preparedValues = append(preparedValues, preparedValue[I]{apply: occurrence.apply})
|
||||||
}
|
}
|
||||||
for i, argument := range spec.Arguments {
|
|
||||||
binding := argument.binding
|
|
||||||
value, err := binding.prepare(argumentValues[i])
|
|
||||||
if err != nil {
|
|
||||||
return preparedInput[I]{}, fmt.Errorf("argument %q: %w", binding.meta.Name, err)
|
|
||||||
}
|
|
||||||
preparedValues = append(preparedValues, value)
|
|
||||||
}
|
}
|
||||||
|
preparedValues = append(preparedValues, arguments...)
|
||||||
return preparedInput[I]{newInput: spec.NewInput, values: preparedValues}, nil
|
return preparedInput[I]{newInput: spec.NewInput, values: preparedValues}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func attributeFlagConversionError(name string, spellings []string, err error) error {
|
||||||
|
var occurrenceErr *flagOccurrenceError
|
||||||
|
if errors.As(err, &occurrenceErr) {
|
||||||
|
spelling := "--" + name
|
||||||
|
if occurrenceErr.index >= 0 && occurrenceErr.index < len(spellings) && spellings[occurrenceErr.index] != "" {
|
||||||
|
spelling = spellings[occurrenceErr.index]
|
||||||
|
}
|
||||||
|
return &flagConversionError{binding: name, spelling: spelling, occurrence: occurrenceErr.index + 1, cause: occurrenceErr.cause}
|
||||||
|
}
|
||||||
|
return fmt.Errorf("flag --%s: %w", name, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func prepareOperands[I any](operands []string, definitions []Argument[I], enforceRequired bool) ([]preparedValue[I], error) {
|
||||||
|
values, err := allocateOperands(operands, definitions, enforceRequired)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
prepared := make([]preparedValue[I], 0, len(definitions))
|
||||||
|
for i, argument := range definitions {
|
||||||
|
value, err := argument.binding.prepare(values[i])
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("argument %q: %w", argument.binding.meta.Name, err)
|
||||||
|
}
|
||||||
|
prepared = append(prepared, value)
|
||||||
|
}
|
||||||
|
return prepared, nil
|
||||||
|
}
|
||||||
|
|
||||||
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) {
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ func TestScalarBindingsKeepDefaultsSeparateFromPresence(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
defaultMessage = "changed after declaration"
|
defaultMessage = "changed after declaration"
|
||||||
prepared, err := prepareInput(spec, [][]string{nil, nil, nil}, nil, true)
|
prepared, err := prepareInputWithOccurrences(spec, [][]string{nil, nil, nil}, nil, nil, nil, true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -60,7 +60,7 @@ func TestScalarBindingsKeepDefaultsSeparateFromPresence(t *testing.T) {
|
|||||||
t.Fatalf("NewInput runtime value = %q, want caller value", input.runtime)
|
t.Fatalf("NewInput runtime value = %q, want caller value", input.runtime)
|
||||||
}
|
}
|
||||||
|
|
||||||
prepared, err = prepareInput(spec, [][]string{{"", "last"}, {"0"}, {"true", "false"}}, nil, true)
|
prepared, err = prepareInputWithOccurrences(spec, [][]string{{"", "last"}, {"0"}, {"true", "false"}}, nil, nil, nil, true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -75,7 +75,7 @@ func TestScalarBindingsKeepDefaultsSeparateFromPresence(t *testing.T) {
|
|||||||
t.Fatalf("enabled = %#v, want explicit false", input.enabled)
|
t.Fatalf("enabled = %#v, want explicit false", input.enabled)
|
||||||
}
|
}
|
||||||
|
|
||||||
prepared, err = prepareInput(spec, [][]string{{""}, nil, nil}, nil, true)
|
prepared, err = prepareInputWithOccurrences(spec, [][]string{{""}, nil, nil}, nil, nil, nil, true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -83,7 +83,7 @@ func TestScalarBindingsKeepDefaultsSeparateFromPresence(t *testing.T) {
|
|||||||
if input.message.Value != "" || !input.message.Set {
|
if input.message.Value != "" || !input.message.Set {
|
||||||
t.Fatalf("message = %#v, want explicitly supplied empty string", input.message)
|
t.Fatalf("message = %#v, want explicitly supplied empty string", input.message)
|
||||||
}
|
}
|
||||||
prepared, err = prepareInput(spec, [][]string{nil, nil, nil}, nil, true)
|
prepared, err = prepareInputWithOccurrences(spec, [][]string{nil, nil, nil}, nil, nil, nil, true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -207,7 +207,7 @@ func TestRepeatArgumentAllocationReservesLaterMinimums(t *testing.T) {
|
|||||||
if err := validateCommandDeclaration(NewCommand(spec)); err != nil {
|
if err := validateCommandDeclaration(NewCommand(spec)); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
prepared, err := prepareInput(spec, nil, []string{"a", "b", "id-1", "x", "y"}, true)
|
prepared, err := prepareInputWithOccurrences(spec, nil, nil, nil, []string{"a", "b", "id-1", "x", "y"}, true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -237,10 +237,10 @@ func TestIncompletePreparationSkipsMissingRequirementsAndInputFactory(t *testing
|
|||||||
if err := validateCommandDeclaration(NewCommand(spec)); err != nil {
|
if err := validateCommandDeclaration(NewCommand(spec)); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if _, err := prepareInput(spec, [][]string{nil}, nil, true); err == nil {
|
if _, err := prepareInputWithOccurrences(spec, [][]string{nil}, nil, nil, nil, true); err == nil {
|
||||||
t.Fatal("strict preparation accepted missing requirements")
|
t.Fatal("strict preparation accepted missing requirements")
|
||||||
}
|
}
|
||||||
if _, err := prepareInput(spec, [][]string{nil}, nil, false); err != nil {
|
if _, err := prepareInputWithOccurrences(spec, [][]string{nil}, nil, nil, nil, false); err != nil {
|
||||||
t.Fatalf("help preparation rejected omitted execution inputs: %v", err)
|
t.Fatalf("help preparation rejected omitted execution inputs: %v", err)
|
||||||
}
|
}
|
||||||
if factoryCalls != 0 {
|
if factoryCalls != 0 {
|
||||||
|
|||||||
@@ -32,8 +32,8 @@ 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.",
|
||||||
Description: "Describe registered commands and their inputs.",
|
Description: "Show available commands, flags, and arguments.",
|
||||||
Arguments: []Argument[helpInput]{RepeatArgument(ArgumentMeta{Name: "COMMAND", Usage: "registered command path"}, 0, -1,
|
Arguments: []Argument[helpInput]{RepeatArgument(ArgumentMeta{Name: "COMMAND", Usage: "Command path to describe."}, 0, -1,
|
||||||
func(value string) (string, error) { return value, nil },
|
func(value string) (string, error) { return value, nil },
|
||||||
func(input *helpInput, value Supplied[[]string]) { input.Targets = value })},
|
func(input *helpInput, value Supplied[[]string]) { input.Targets = value })},
|
||||||
}, func(current commandExecution, input helpInput) Outcome {
|
}, func(current commandExecution, input helpInput) Outcome {
|
||||||
@@ -43,9 +43,9 @@ func (a *App) builtinCommands() []Declaration {
|
|||||||
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: "Show the name, version, and revision without loading application configuration.",
|
||||||
Flags: []Flag[versionInput]{
|
Flags: []Flag[versionInput]{
|
||||||
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: "scope", Usage: "Show the app or CommandKit version.", Completion: CompleteChoices(CompletionChoice{Value: "app"}, CompletionChoice{Value: "commandkit"})}, &scopeDefault, parseVersionScope, func(input *versionInput, value Supplied[string]) { input.Scope = value.Value }),
|
||||||
},
|
},
|
||||||
Run: func(invocation Invocation, input versionInput) Outcome {
|
Run: func(invocation Invocation, input versionInput) Outcome {
|
||||||
identity := a.identity
|
identity := a.identity
|
||||||
|
|||||||
44
command.go
44
command.go
@@ -90,10 +90,15 @@ type Supplied[T any] struct {
|
|||||||
|
|
||||||
// FlagMeta describes a command flag. Required applies when execution prepares
|
// FlagMeta describes a command flag. Required applies when execution prepares
|
||||||
// the selected command; help reports the metadata without requiring the value.
|
// the selected command; help reports the metadata without requiring the value.
|
||||||
|
// Invalid declarations are reported when installed.
|
||||||
type FlagMeta struct {
|
type FlagMeta struct {
|
||||||
// Name is the flag name without leading dashes.
|
// Name is a nonempty long name without leading dashes. It consists of
|
||||||
|
// Unicode lowercase letters or digits in segments separated by single
|
||||||
|
// hyphens; digit-leading and numeric-only names are valid. The names help,
|
||||||
|
// quiet, verbose, debug, progress and format are reserved, except that a
|
||||||
|
// BoolFlag named debug attaches to the shared reporting flag.
|
||||||
Name string
|
Name string
|
||||||
// Short is an optional one-byte spelling.
|
// Short is an optional one-byte spelling other than '-' or the reserved 'h'.
|
||||||
Short string
|
Short string
|
||||||
// Usage briefly describes the flag.
|
// Usage briefly describes the flag.
|
||||||
Usage string
|
Usage string
|
||||||
@@ -352,6 +357,7 @@ func Alias(path, target string) RoutingRule {
|
|||||||
// wherever its canonical target flag is available and contributes nothing on
|
// wherever its canonical target flag is available and contributes nothing on
|
||||||
// commands without that flag. Identical mappings are accepted; different
|
// commands without that flag. Identical mappings are accepted; different
|
||||||
// targets for the same source spelling in this scope are installation errors.
|
// targets for the same source spelling in this scope are installation errors.
|
||||||
|
// Long and short spelling forms follow the grammar described by [FlagMeta].
|
||||||
func FlagAlias(spelling, target string) RoutingRule {
|
func FlagAlias(spelling, target string) RoutingRule {
|
||||||
record := &routingRuleRecord{
|
record := &routingRuleRecord{
|
||||||
kind: globalFlagAliasRule,
|
kind: globalFlagAliasRule,
|
||||||
@@ -368,6 +374,7 @@ func FlagAlias(spelling, target string) RoutingRule {
|
|||||||
// available in the complete installation. Scoped mappings take precedence over
|
// available in the complete installation. Scoped mappings take precedence over
|
||||||
// application-wide mappings with the same source spelling. Identical mappings
|
// application-wide mappings with the same source spelling. Identical mappings
|
||||||
// are accepted; competing targets in the same scope are installation errors.
|
// are accepted; competing targets in the same scope are installation errors.
|
||||||
|
// Long and short spelling forms follow the grammar described by [FlagMeta].
|
||||||
func FlagAliasFor(commandPath, spelling, target string) RoutingRule {
|
func FlagAliasFor(commandPath, spelling, target string) RoutingRule {
|
||||||
record := &routingRuleRecord{
|
record := &routingRuleRecord{
|
||||||
kind: scopedFlagAliasRule,
|
kind: scopedFlagAliasRule,
|
||||||
@@ -437,27 +444,31 @@ func validateCommandDeclaration(command Command) error {
|
|||||||
|
|
||||||
func validateCommandPlacement(command Command) error {
|
func validateCommandPlacement(command Command) error {
|
||||||
var issues []error
|
var issues []error
|
||||||
collectDeclarationIssues(command, nil, true, &issues)
|
// Keep the grown slice header across recursive calls so siblings reuse capacity.
|
||||||
return errors.Join(issues...)
|
var path []string
|
||||||
}
|
var collectDeclarationIssues func(Command, bool)
|
||||||
|
collectDeclarationIssues = func(command Command, root bool) {
|
||||||
func collectDeclarationIssues(command Command, parents []string, root bool, issues *[]error) {
|
|
||||||
if command.record == nil {
|
if command.record == nil {
|
||||||
*issues = append(*issues, errors.New("invalid zero command declaration"))
|
issues = append(issues, errors.New("invalid zero command declaration"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
record := command.record
|
record := command.record
|
||||||
path := append(append([]string(nil), parents...), record.name)
|
length := len(path)
|
||||||
label := commandpath.FromTokens(path).String()
|
path = append(path, record.name)
|
||||||
|
defer func() { path = path[:length] }()
|
||||||
|
var label string
|
||||||
|
if len(record.issues) > 0 || (record.rootOnly && !root) {
|
||||||
|
label = commandpath.Path(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))
|
||||||
} else {
|
} else {
|
||||||
*issues = append(*issues, issue)
|
issues = append(issues, issue)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if record.rootOnly && !root {
|
if record.rootOnly && !root {
|
||||||
*issues = append(*issues, fmt.Errorf("root-only command %q is nested beneath another command", label))
|
issues = append(issues, fmt.Errorf("root-only command %q is nested beneath another command", label))
|
||||||
}
|
}
|
||||||
if record.invalid || record.absent {
|
if record.invalid || record.absent {
|
||||||
return
|
return
|
||||||
@@ -466,8 +477,11 @@ func collectDeclarationIssues(command Command, parents []string, root bool, issu
|
|||||||
if child.record == nil {
|
if child.record == nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
collectDeclarationIssues(child, path, false, issues)
|
collectDeclarationIssues(child, false)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
collectDeclarationIssues(command, true)
|
||||||
|
return errors.Join(issues...)
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateCommandName(name string) error {
|
func validateCommandName(name string) error {
|
||||||
@@ -485,7 +499,7 @@ 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) {
|
if commandmeta.IsReportingFlag(binding.meta.Name) && !(binding.meta.Name == "debug" && binding.boolean) {
|
||||||
return fmt.Errorf("flag name %q is reserved for reporting", 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 {
|
||||||
|
|||||||
@@ -35,8 +35,7 @@ type completionFlagState struct {
|
|||||||
func completionFlags(context completionContext) []commandmeta.Flag {
|
func completionFlags(context completionContext) []commandmeta.Flag {
|
||||||
flags := make([]commandmeta.Flag, 0, len(context.command.Flags)+len(context.command.CoreFlags)+1)
|
flags := make([]commandmeta.Flag, 0, len(context.command.Flags)+len(context.command.CoreFlags)+1)
|
||||||
flags = append(flags, commandmeta.HelpFlag())
|
flags = append(flags, commandmeta.HelpFlag())
|
||||||
flags = append(flags, context.command.CoreFlags...)
|
flags = append(flags, commandmeta.EffectiveFlags(context.command.Flags, context.command.CoreFlags)...)
|
||||||
flags = append(flags, context.command.Flags...)
|
|
||||||
return flags
|
return flags
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,44 +52,26 @@ func walkCompletionFlags(tokens []string, lookup commandmeta.FlagLookup) complet
|
|||||||
state.ended = true
|
state.ended = true
|
||||||
case commandmeta.OperandToken:
|
case commandmeta.OperandToken:
|
||||||
state.operands = append(state.operands, token)
|
state.operands = append(state.operands, token)
|
||||||
case commandmeta.LongFlagToken:
|
case commandmeta.LongFlagToken, commandmeta.ShortFlagToken:
|
||||||
spelling, _, attached := commandmeta.SplitLongFlag(token)
|
for offset := 1; ; {
|
||||||
flag, ok := lookup.Find(spelling)
|
fact := commandmeta.ConsumeFlag(lookup, token, offset)
|
||||||
if !ok {
|
if !fact.Found {
|
||||||
state.invalid = true
|
state.invalid = true
|
||||||
return state
|
return state
|
||||||
}
|
}
|
||||||
state.supplied[flag.Name] = true
|
state.supplied[fact.Flag.Name] = true
|
||||||
if !flag.Boolean && !attached {
|
if fact.NeedsNext {
|
||||||
if index+1 == len(tokens) {
|
if index+1 == len(tokens) {
|
||||||
|
flag := fact.Flag
|
||||||
state.pending = &flag
|
state.pending = &flag
|
||||||
return state
|
return state
|
||||||
}
|
}
|
||||||
index++
|
index++
|
||||||
}
|
}
|
||||||
case commandmeta.ShortFlagToken:
|
if fact.NextOffset == 0 {
|
||||||
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
|
break
|
||||||
}
|
}
|
||||||
continue
|
offset = fact.NextOffset
|
||||||
}
|
|
||||||
if suffix == "" {
|
|
||||||
if index+1 == len(tokens) {
|
|
||||||
state.pending = &flag
|
|
||||||
return state
|
|
||||||
}
|
|
||||||
index++
|
|
||||||
}
|
|
||||||
break
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -132,10 +113,10 @@ func completionOptionTarget(current string, flags []commandmeta.Flag, lookup com
|
|||||||
return completionTarget{kind: completionTargetFlags, choices: completionFlagNames(flags, supplied, current)}
|
return completionTarget{kind: completionTargetFlags, choices: completionFlagNames(flags, supplied, current)}
|
||||||
}
|
}
|
||||||
if strings.HasPrefix(current, "--") {
|
if strings.HasPrefix(current, "--") {
|
||||||
spelling, value, attached := commandmeta.SplitLongFlag(current)
|
fact := commandmeta.ConsumeFlag(lookup, current, 1)
|
||||||
if attached {
|
if fact.Attached {
|
||||||
if flag, ok := lookup.Find(spelling); ok {
|
if fact.Found {
|
||||||
return completionTarget{kind: completionTargetValue, hint: flag.Completion, prefix: spelling + "=", valuePrefix: value}
|
return completionTarget{kind: completionTargetValue, hint: fact.Flag.Completion, prefix: fact.Spelling + "=", valuePrefix: fact.Value}
|
||||||
}
|
}
|
||||||
return completionTarget{}
|
return completionTarget{}
|
||||||
}
|
}
|
||||||
@@ -145,25 +126,26 @@ func completionOptionTarget(current string, flags []commandmeta.Flag, lookup com
|
|||||||
for name, value := range supplied {
|
for name, value := range supplied {
|
||||||
used[name] = value
|
used[name] = value
|
||||||
}
|
}
|
||||||
for offset := 1; offset < len(current); offset++ {
|
for offset := 1; offset < len(current); {
|
||||||
flag, ok := lookup.Find("-" + current[offset:offset+1])
|
fact := commandmeta.ConsumeFlag(lookup, current, offset)
|
||||||
if !ok {
|
if !fact.Found {
|
||||||
return completionTarget{}
|
return completionTarget{}
|
||||||
}
|
}
|
||||||
used[flag.Name] = true
|
used[fact.Flag.Name] = true
|
||||||
suffix := current[offset+1:]
|
if fact.Flag.Boolean && fact.Attached {
|
||||||
if flag.Boolean {
|
return completionTarget{kind: completionTargetValue, hint: fact.Flag.Completion, prefix: current[:offset+2], valuePrefix: fact.Value}
|
||||||
if strings.HasPrefix(suffix, "=") {
|
|
||||||
return completionTarget{kind: completionTargetValue, hint: flag.Completion, prefix: current[:offset+2], valuePrefix: suffix[1:]}
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
|
if !fact.Flag.Boolean {
|
||||||
prefix := current[:offset+1]
|
prefix := current[:offset+1]
|
||||||
if strings.HasPrefix(suffix, "=") {
|
if strings.HasPrefix(current[offset+1:], "=") {
|
||||||
prefix += "="
|
prefix += "="
|
||||||
suffix = suffix[1:]
|
|
||||||
}
|
}
|
||||||
return completionTarget{kind: completionTargetValue, hint: flag.Completion, prefix: prefix, valuePrefix: suffix}
|
return completionTarget{kind: completionTargetValue, hint: fact.Flag.Completion, prefix: prefix, valuePrefix: fact.Value}
|
||||||
|
}
|
||||||
|
if fact.NextOffset == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
offset = fact.NextOffset
|
||||||
}
|
}
|
||||||
var choices []commandmeta.CompletionChoice
|
var choices []commandmeta.CompletionChoice
|
||||||
for _, flag := range flags {
|
for _, flag := range flags {
|
||||||
|
|||||||
@@ -70,6 +70,34 @@ func TestCompletionFlagAndValuePositions(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSharedDebugCompletionHints(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
hint CompletionHint
|
||||||
|
want commandmeta.CompletionKind
|
||||||
|
}{
|
||||||
|
{"none", NoCompletion(), commandmeta.CompletionNone},
|
||||||
|
{"choices", CompleteChoices(CompletionChoice{Value: "true"}), commandmeta.CompletionChoices},
|
||||||
|
{"files", CompleteFiles(), commandmeta.CompletionFiles},
|
||||||
|
{"directories", CompleteDirectories(), commandmeta.CompletionDirectories},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
command := NewCommand(CommandSpec[struct{}]{Name: "run", Flags: []Flag[struct{}]{BoolFlag(FlagMeta{Name: "debug", Short: "d", Completion: tc.hint}, false, func(*struct{}, Supplied[bool]) {})}, Run: func(Invocation, struct{}) Outcome { return Success() }})
|
||||||
|
app, err := New(Identity{Name: "example"}, command)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
snapshot := app.routeSnapshot()
|
||||||
|
for _, current := range []string{"--debug=", "-d="} {
|
||||||
|
target := completionTargetFor(snapshot, resolveCompletionContext(snapshot, []string{"run"}, current))
|
||||||
|
if target.kind != completionTargetValue || target.hint.Kind != tc.want {
|
||||||
|
t.Fatalf("%s: %#v", current, target)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestCompletionOperandAllocation(t *testing.T) {
|
func TestCompletionOperandAllocation(t *testing.T) {
|
||||||
arguments := []commandmeta.Argument{
|
arguments := []commandmeta.Argument{
|
||||||
{Name: "optional", Min: 0, Max: 1, Completion: commandmeta.CompletionHint{Kind: commandmeta.CompletionFiles}},
|
{Name: "optional", Min: 0, Max: 1, Completion: commandmeta.CompletionHint{Kind: commandmeta.CompletionFiles}},
|
||||||
|
|||||||
@@ -120,3 +120,51 @@ func TestCompletionQueryDiscovery(t *testing.T) {
|
|||||||
t.Fatalf("discovery outcome=%#v reply=%q", outcome, output.String())
|
t.Fatalf("discovery outcome=%#v reply=%q", outcome, output.String())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSharedDebugHelpAndCompletion(t *testing.T) {
|
||||||
|
var factories, setters, checks, handlers int
|
||||||
|
command := NewCommand(CommandSpec[struct{}]{
|
||||||
|
Name: "run",
|
||||||
|
NewInput: func() struct{} { factories++; return struct{}{} },
|
||||||
|
Flags: []Flag[struct{}]{BoolFlag(FlagMeta{Name: "debug", Short: "d", Usage: "application tracing", Required: true, Completion: CompleteChoices(CompletionChoice{Value: "true"}, CompletionChoice{Value: "trace"})}, true, func(*struct{}, Supplied[bool]) { setters++ })},
|
||||||
|
CheckInput: func(struct{}) error { checks++; return nil },
|
||||||
|
Run: func(Invocation, struct{}) Outcome { handlers++; return Success() },
|
||||||
|
})
|
||||||
|
app, err := New(Identity{Name: "example"}, command, Completion("example"), Alias("shortcut", "run"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, args := range [][]string{{"run", "--help"}, {"help", "run"}, {"shortcut", "--help"}, {"help", "shortcut"}, {"run", "--unknown"}, {"shortcut", "--unknown"}} {
|
||||||
|
var output, diagnostic bytes.Buffer
|
||||||
|
result := app.Execute(context.Background(), args, Streams{Out: &output, Err: &diagnostic})
|
||||||
|
text := output.String() + diagnostic.String()
|
||||||
|
if strings.Count(text, "--debug") != 1 || !strings.Contains(text, "-d") || !strings.Contains(text, "application tracing") || !strings.Contains(text, "application input defaults to true") || !strings.Contains(text, "required") || !strings.Contains(text, "default: false") {
|
||||||
|
t.Fatalf("%v: outcome=%#v text=%q", args, result, text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, tc := range []struct {
|
||||||
|
args []string
|
||||||
|
want string
|
||||||
|
count int
|
||||||
|
}{
|
||||||
|
{[]string{"__complete", "run", "--deb"}, "--debug", 1},
|
||||||
|
{[]string{"__complete", "run", "-"}, "\n-d\t", 1},
|
||||||
|
{[]string{"__complete", "run", "--debug=tr"}, "trace", 1},
|
||||||
|
{[]string{"__complete", "run", "-d=tr"}, "trace", 1},
|
||||||
|
} {
|
||||||
|
var output bytes.Buffer
|
||||||
|
result := app.Execute(context.Background(), tc.args, Streams{Out: &output, Err: io.Discard})
|
||||||
|
if result.Kind != OutcomeSuccess || strings.Count(output.String(), tc.want) != tc.count {
|
||||||
|
t.Fatalf("%v: %#v %q", tc.args, result, output.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if factories != 0 || setters != 0 || checks != 0 || handlers != 0 {
|
||||||
|
t.Fatalf("callbacks: %d %d %d %d", factories, setters, checks, handlers)
|
||||||
|
}
|
||||||
|
if result := app.Execute(context.Background(), []string{"run", "--debug=invalid", "--help"}, Streams{Out: io.Discard, Err: io.Discard}); result.Kind != OutcomeUsage {
|
||||||
|
t.Fatalf("help skipped boolean validation: %#v", result)
|
||||||
|
}
|
||||||
|
if result := app.Execute(context.Background(), []string{"run", "--debug=trace"}, Streams{Out: io.Discard, Err: io.Discard}); result.Kind != OutcomeUsage {
|
||||||
|
t.Fatalf("suggestion bypassed boolean parsing: %#v", result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -32,9 +32,9 @@ func TestCompletionConsumerInShells(t *testing.T) {
|
|||||||
directory bool
|
directory bool
|
||||||
}{
|
}{
|
||||||
{"canonical command", "probe ru", []string{"run"}, false},
|
{"canonical command", "probe ru", []string{"run"}, false},
|
||||||
{"entered alias", "probe shortcut --format=ya", []string{"shortcut", "--format=yaml"}, false},
|
{"entered alias", "probe shortcut --choice=ya", []string{"shortcut", "--choice=yaml"}, false},
|
||||||
{"choice", "probe run --format=te", []string{"run", "--format=text"}, false},
|
{"choice", "probe run --choice=te", []string{"run", "--choice=text"}, false},
|
||||||
{"no file fallback", `probe run --format=./partial\ f`, []string{"run", "--format=./partial f"}, false},
|
{"no file fallback", `probe run --choice=./partial\ f`, []string{"run", "--choice=./partial f"}, false},
|
||||||
{"separated file", `probe run --config ./partial\ f`, []string{"run", "--config", "./partial file"}, 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},
|
{"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 attached file", `probe run -c./partial\ f`, []string{"run", "-c./partial file"}, false},
|
||||||
@@ -42,7 +42,7 @@ func TestCompletionConsumerInShells(t *testing.T) {
|
|||||||
{"separated directory", "probe run --directory ./partial", []string{"run", "--directory", "./partial dir"}, true},
|
{"separated directory", "probe run --directory ./partial", []string{"run", "--directory", "./partial dir"}, true},
|
||||||
{"attached 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},
|
{"empty operand", "probe run ", []string{"run", "alpha"}, false},
|
||||||
{"leading dash", "probe run --for", []string{"run", "--format"}, false},
|
{"leading dash", "probe run --cho", []string{"run", "--choice"}, false},
|
||||||
}
|
}
|
||||||
for _, shell := range []string{"bash", "zsh", "fish"} {
|
for _, shell := range []string{"bash", "zsh", "fish"} {
|
||||||
generator := exec.Command(app, "completion", shell)
|
generator := exec.Command(app, "completion", shell)
|
||||||
@@ -82,7 +82,7 @@ func TestCompletionConsumerInShells(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
t.Run(shell+"/descriptions", func(t *testing.T) {
|
t.Run(shell+"/descriptions", func(t *testing.T) {
|
||||||
terminal := exec.Command("python3", "internal/cobra/testdata/completion_terminal.py", shell, scriptPath, "probe run --format=")
|
terminal := exec.Command("python3", "internal/cobra/testdata/completion_terminal.py", shell, scriptPath, "probe run --choice=")
|
||||||
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")
|
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()
|
output, err := terminal.CombinedOutput()
|
||||||
if err != nil || !strings.Contains(string(output), "Readable text") || !strings.Contains(string(output), "YAML output") {
|
if err != nil || !strings.Contains(string(output), "Readable text") || !strings.Contains(string(output), "YAML output") {
|
||||||
|
|||||||
@@ -79,3 +79,40 @@ func TestCompletionChoicesDoNotRestrictExecution(t *testing.T) {
|
|||||||
t.Fatalf("outcome=%#v input=%#v", outcome, received)
|
t.Fatalf("outcome=%#v input=%#v", outcome, received)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCompletionConsumesFlagGrammarAndKeepsPendingValues(t *testing.T) {
|
||||||
|
flags := []commandmeta.Flag{{Name: "verbose", Short: "v", Boolean: true}, {Name: "choice", Short: "c", Completion: commandmeta.CompletionHint{Kind: commandmeta.CompletionChoices}}}
|
||||||
|
lookup := commandmeta.NewFlagLookup(flags, []commandmeta.FlagAlias{{Source: "-x", Target: "--choice"}})
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
tokens []string
|
||||||
|
pending, operand string
|
||||||
|
invalid bool
|
||||||
|
}{
|
||||||
|
{name: "cluster attached empty", tokens: []string{"-vc="}, operand: ""},
|
||||||
|
{name: "option-looking value", tokens: []string{"-vx", "--other"}, operand: ""},
|
||||||
|
{name: "pending", tokens: []string{"-vx"}, pending: "choice"},
|
||||||
|
{name: "unknown cluster", tokens: []string{"-vz"}, invalid: true},
|
||||||
|
{name: "terminator", tokens: []string{"--", "--choice"}, operand: "--choice"},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
state := walkCompletionFlags(tc.tokens, lookup)
|
||||||
|
if state.invalid != tc.invalid {
|
||||||
|
t.Fatalf("state=%#v", state)
|
||||||
|
}
|
||||||
|
if tc.pending != "" && (state.pending == nil || state.pending.Name != tc.pending) {
|
||||||
|
t.Fatalf("pending=%#v", state.pending)
|
||||||
|
}
|
||||||
|
if tc.pending == "" && state.pending != nil {
|
||||||
|
t.Fatalf("unexpected pending=%#v", state.pending)
|
||||||
|
}
|
||||||
|
if tc.operand != "" && (len(state.operands) != 1 || state.operands[0] != tc.operand) {
|
||||||
|
t.Fatalf("operands=%v", state.operands)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
target := completionOptionTarget("-vc=", flags, lookup, nil)
|
||||||
|
if target.kind != completionTargetValue || target.prefix != "-vc=" || target.valuePrefix != "" {
|
||||||
|
t.Fatalf("partial target = %#v", target)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
16
doc.go
16
doc.go
@@ -1,8 +1,10 @@
|
|||||||
// Package commandkit provides the core instance-owned CLI dispatcher, typed
|
// Package commandkit helps Go applications build consistent command-line
|
||||||
// command declarations, application-owned command and flag aliases, input
|
// interfaces with typed flags and arguments, help, and shared output formatting.
|
||||||
// bindings, invocation-scoped reporting, and outcomes with separate operation
|
// Define commands with NewCommand and install them with New or App.Register.
|
||||||
// causes and presentation observations. New and App.Register accept canonical
|
// Commands and command or flag aliases can be installed together.
|
||||||
// Command declarations and RoutingRule declarations together. Optional
|
//
|
||||||
// integration modules provide ConfigKit and PromptKit-specific commands;
|
// App.Execute runs a command and returns an Outcome; the application decides
|
||||||
// importing those modules does not install their commands.
|
// when to exit. Handlers use Invocation.Reporter for output and messages.
|
||||||
|
// Optional ckconfig and ckprompt modules add inspection and testing commands;
|
||||||
|
// applications install those commands explicitly.
|
||||||
package commandkit
|
package commandkit
|
||||||
|
|||||||
@@ -3,9 +3,11 @@
|
|||||||
## Status
|
## Status
|
||||||
|
|
||||||
Accepted.
|
Accepted.
|
||||||
The structured-report formatting extension is accepted but not yet implemented;
|
The structured-report formatting extension is implemented; its adoption record
|
||||||
the [adoption roadmap](../roadmap/promptkit-v0.20.0.md#shared-core-report-output-prerequisite)
|
is preserved in [Git history](https://forge.maximumdirect.net/base-layer/commandkit/src/commit/2f43f78f76439f5d11ef313cd379bec036543504/docs/roadmap/promptkit-v0.20.0.md#shared-core-report-output-prerequisite).
|
||||||
tracks that work.
|
The future-tense implementation and roadmap references in the decision and
|
||||||
|
consequences below are historical. Current contracts are owned by the
|
||||||
|
[reporting GoDoc](../../report.go) and [CLI reference](../cli.md#reporting).
|
||||||
|
|
||||||
## Date
|
## Date
|
||||||
|
|
||||||
|
|||||||
877
docs/cli.md
877
docs/cli.md
File diff suppressed because it is too large
Load Diff
@@ -1,40 +1,45 @@
|
|||||||
# Consumer guide
|
# Consumer guide
|
||||||
|
|
||||||
CommandKit is distributed as three Go modules. Start with the core and add an
|
Start with core to build commands with typed inputs and shared output
|
||||||
integration module only when the application uses that library. Core and
|
formatting. Add an integration when your application uses ConfigKit or
|
||||||
compatible integration command factories return canonical `commandkit.Command` values.
|
PromptKit. The [core command example](#core-commands) below shows the basic
|
||||||
Applications may install those alongside application-owned `RoutingRule`
|
API; the [maintained examples](../../README.md#examples) show complete
|
||||||
declarations through `commandkit.New` or `App.Register`; routing rules are
|
applications.
|
||||||
installed at the application root, not as group children.
|
|
||||||
|
|
||||||
## Module selection
|
## Module selection
|
||||||
|
|
||||||
| Import | Use when |
|
| Import | Use when |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `go.maximumdirect.net/commandkit` | The application needs typed command declarations and common CLI behavior. |
|
| `go.maximumdirect.net/commandkit` | You need commands, typed inputs, help, and common output handling. |
|
||||||
| `go.maximumdirect.net/ckconfig` | The application wants ConfigKit-backed configuration scopes and reports. |
|
| `go.maximumdirect.net/ckconfig` | You want operators to view, validate, explain, and compare ConfigKit configuration. |
|
||||||
| `go.maximumdirect.net/ckprompt` | The application wants PromptKit engine configuration/comparison, five catalogs, named inspection, requirements discovery, request validation, or native smoke testing. |
|
| `go.maximumdirect.net/ckprompt` | You want operators to inspect PromptKit resources, check and preview requests, or run smoke tests. |
|
||||||
|
|
||||||
The published matched releases are core v0.3.0, ckconfig v0.2.0, and ckprompt v0.2.0.
|
The following lists the current release set and earlier module combinations:
|
||||||
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
|
| Selection | Core | ckconfig | ckprompt | Upstream libraries and status |
|
||||||
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
|
| Published core | v0.6.0 | — | — | Usable independently. |
|
||||||
compose all three local modules. The current integration source uses APIs that
|
| ConfigKit integration | v0.6.0 | v0.6.0 | — | ConfigKit v1.1.0. |
|
||||||
require the upcoming core and ckconfig releases; independent validation follows
|
| PromptKit integration | v0.6.0 | v0.6.0 | v0.6.0 | ConfigKit v1.1.0 and PromptKit v0.20.0. |
|
||||||
their publication and dependency alignment.
|
| Earlier matched integration set | v0.3.0 | v0.2.0 | v0.2.0 | ConfigKit v0.8.0 and PromptKit v0.16.0, using their earlier import paths. |
|
||||||
|
|
||||||
The two integrations are independent optional imports. `ckprompt` uses the
|
Install the published core with:
|
||||||
public scope and error contracts from `ckconfig`; importing it does not add the
|
|
||||||
`config` family. An application that wants both families passes both returned
|
```sh
|
||||||
declarations to core. The [maintained examples](../../README.md#examples) show
|
go get go.maximumdirect.net/commandkit@v0.6.0
|
||||||
core-only, ConfigKit-only, and composed applications.
|
```
|
||||||
|
|
||||||
|
Select either integration with `go get go.maximumdirect.net/ckconfig@v0.6.0`
|
||||||
|
or `go get go.maximumdirect.net/ckprompt@v0.6.0`. The integrations select core
|
||||||
|
v0.6.0, and ckprompt selects ckconfig v0.6.0, which includes `AdaptDiagnostics`.
|
||||||
|
They build independently against these released dependencies; a local workspace
|
||||||
|
is optional for development. Published ckconfig v0.3.0 also uses ConfigKit v1.1.0
|
||||||
|
and does not compose with ckprompt v0.2.0's earlier ConfigKit dependency.
|
||||||
|
|
||||||
|
Importing a module does not install its commands. Pass commands to
|
||||||
|
`commandkit.New` or `App.Register` explicitly. ckprompt depends on ckconfig's
|
||||||
|
public APIs, but you can install its `promptkit` commands without installing
|
||||||
|
the `config` family.
|
||||||
|
|
||||||
## Go compatibility
|
## Go compatibility
|
||||||
|
|
||||||
@@ -57,7 +62,6 @@ default from an explicitly supplied empty, false, or zero value.
|
|||||||
package example
|
package example
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
commandkit "go.maximumdirect.net/commandkit"
|
commandkit "go.maximumdirect.net/commandkit"
|
||||||
@@ -81,8 +85,12 @@ func Greeting() commandkit.Command {
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
Run: func(invocation commandkit.Invocation, input GreetInput) commandkit.Outcome {
|
Run: func(invocation commandkit.Invocation, input GreetInput) commandkit.Outcome {
|
||||||
if _, err := fmt.Fprintln(invocation.Reporter().Output(), input.Count.Value); err != nil {
|
greetings := []string{}
|
||||||
return commandkit.Failure("Could not write the greeting.").WithCause(err)
|
for i := 0; i < input.Count.Value; i++ {
|
||||||
|
greetings = append(greetings, "Hello!")
|
||||||
|
}
|
||||||
|
if err := invocation.Reporter().Report(commandkit.Report{Value: greetings}); err != nil {
|
||||||
|
return commandkit.Failure("Could not write the greetings.").WithCause(err)
|
||||||
}
|
}
|
||||||
return commandkit.Success()
|
return commandkit.Success()
|
||||||
},
|
},
|
||||||
@@ -90,6 +98,10 @@ func Greeting() commandkit.Command {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Install `Greeting()` with `commandkit.New(identity, Greeting())`. By default,
|
||||||
|
`greet` prints `Hello!`; `greet --count=2` prints it twice. Add `--format
|
||||||
|
json` to receive a JSON list. A count of zero produces an empty list.
|
||||||
|
|
||||||
The application supplies `context.Context`, argv without the executable name,
|
The application supplies `context.Context`, argv without the executable name,
|
||||||
and output streams to `App.Execute`; it decides how the returned status maps to
|
and output streams to `App.Execute`; it decides how the returned status maps to
|
||||||
process exit. `New` installs generated help/version and caller declarations as
|
process exit. `New` installs generated help/version and caller declarations as
|
||||||
@@ -134,13 +146,42 @@ installation alongside aliases and integration commands.
|
|||||||
|
|
||||||
## Invocation reporting
|
## Invocation reporting
|
||||||
|
|
||||||
`Invocation.Reporter()` supplies the reporter for the current handler. Use
|
Use `Invocation.Reporter().Report` to print a value in the selected output
|
||||||
`Report()` for formatted substantive values and optional text templates;
|
format. Pass ordinary Go values and, optionally, a `text/template` for text
|
||||||
use `Output()` or `RenderOutput()` for deliberate raw output. Use `Warn`, `Info`,
|
output. Use `Warn`, `Info`, `Progress`, and `Debug` for categorized messages.
|
||||||
`Progress`, and `Debug` for categorized messages. Queue a summary with
|
Queue a summary with `AtEnd().Info` when it belongs after the handler returns.
|
||||||
`AtEnd().Info` when it belongs after the handler returns. The
|
Deferred submissions are accepted until finalization begins; later submissions
|
||||||
[CLI reference](../cli.md#reporting) owns flag grammar, visibility, prefixes,
|
return `ErrReporterClosed`. The [CLI reference](../cli.md#reporting) owns flag
|
||||||
and status behavior.
|
grammar, visibility, prefixes, and status behavior.
|
||||||
|
|
||||||
|
An application can attach a boolean input to the shared `--debug` option and
|
||||||
|
inspect the reporting selection separately. For example:
|
||||||
|
|
||||||
|
```go
|
||||||
|
type input struct { debug commandkit.Supplied[bool] }
|
||||||
|
|
||||||
|
command := commandkit.NewCommand(commandkit.CommandSpec[input]{
|
||||||
|
Name: "run",
|
||||||
|
Flags: []commandkit.Flag[input]{
|
||||||
|
commandkit.BoolFlag(commandkit.FlagMeta{Name: "debug", Short: "d"}, false,
|
||||||
|
func(in *input, value commandkit.Supplied[bool]) { in.debug = value }),
|
||||||
|
},
|
||||||
|
Run: func(inv commandkit.Invocation, in input) commandkit.Outcome {
|
||||||
|
captureArtifacts := in.debug.Value
|
||||||
|
quiet := inv.ReportingMode() == commandkit.ReportingModeQuiet
|
||||||
|
_, _ = captureArtifacts, quiet // Apply application policy here.
|
||||||
|
return commandkit.Success()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
_ = command
|
||||||
|
```
|
||||||
|
|
||||||
|
With `run --debug --quiet`, the input is true and explicitly supplied, while
|
||||||
|
the selected reporting mode is quiet. Application defaults and requiredness
|
||||||
|
affect input, not mode selection; explicit false counts as supplied. Captured
|
||||||
|
bindings retain the usual selection and application behavior. Applications own
|
||||||
|
any artifact creation and other effects. The mode reports selection rather than
|
||||||
|
successful delivery; see [Invocation.ReportingMode](../../identity.go).
|
||||||
|
|
||||||
Reports use JSON serialization to establish the common value representation
|
Reports use JSON serialization to establish the common value representation
|
||||||
for YAML and JSON. A text template receives the original value; without one,
|
for YAML and JSON. A text template receives the original value; without one,
|
||||||
@@ -157,22 +198,29 @@ write to their supplied writer only while the callback runs; they must not
|
|||||||
submit another message or start another rendering operation through the same
|
submit another message or start another rendering operation through the same
|
||||||
reporter.
|
reporter.
|
||||||
|
|
||||||
|
For example, a handler can report a count as a small mapping:
|
||||||
|
|
||||||
```go
|
```go
|
||||||
Run: func(invocation commandkit.Invocation, input GreetInput) commandkit.Outcome {
|
if err := invocation.Reporter().Report(commandkit.Report{
|
||||||
if _, err := fmt.Fprintln(invocation.Reporter().Output(), input.Count.Value); err != nil {
|
Value: map[string]int{"count": input.Count.Value},
|
||||||
return commandkit.Failure("Could not write the greeting.").WithCause(err)
|
}); err != nil {
|
||||||
}
|
return commandkit.Failure("Could not write the count.").WithCause(err)
|
||||||
return commandkit.Success()
|
|
||||||
}
|
}
|
||||||
|
return commandkit.Success()
|
||||||
```
|
```
|
||||||
|
|
||||||
|
This uses YAML automatically and supports `--format json` without a separate
|
||||||
|
encoder. Use `Output()` or `RenderOutput()` instead when you deliberately need
|
||||||
|
raw output; `--format` does not reformat those bytes.
|
||||||
|
|
||||||
After `App.Execute`, inspect the returned `Outcome`. Its `Cause` retains the
|
After `App.Execute`, inspect the returned `Outcome`. Its `Cause` retains the
|
||||||
operation error; `Presentation.Output` and `Presentation.Diagnostics` report
|
operation error; `Presentation.Output` and `Presentation.Diagnostics` report
|
||||||
delivery state and the first rendering or write errors for their destinations.
|
delivery state and the first rendering or write errors for their destinations.
|
||||||
CommandKit does not retain a message transcript. Reporter operations for one
|
Joined operation errors support `errors.Is` and `errors.As` where the
|
||||||
invocation are safe concurrently, but concurrent submission order is not
|
underlying errors do. CommandKit does not retain a message transcript.
|
||||||
specified. The caller owns the writers passed to `Execute`; CommandKit does not
|
Reporter operations for one invocation are safe concurrently, but concurrent
|
||||||
close them.
|
submission order is not specified. The caller owns the writers passed to
|
||||||
|
`Execute`; CommandKit does not close them.
|
||||||
|
|
||||||
## Application-owned aliases
|
## Application-owned aliases
|
||||||
|
|
||||||
@@ -233,18 +281,22 @@ values.
|
|||||||
|
|
||||||
Use `ckconfig.Command(ckconfig.Suite[I, S]{...})` when the application needs
|
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`
|
||||||
uses `Scope.Compare`; `ckconfig.FromConfigKit` and `AppCommand` provide the
|
capability uses `Scope.Compare`; `ckconfig.FromConfigKit` and `AppCommand`
|
||||||
ConfigKit-backed implementation. `Selector.AgainstName` opts a selector into an
|
provide the ConfigKit-backed implementation. `Selector.AgainstName` opts a
|
||||||
independent right-side flag. Each comparison side gets its own initialized and
|
selector into an independent right-side flag. Each comparison side gets its
|
||||||
checked input, with only its supplied selectors applied; state is shared by the
|
own initialized and checked input, with only its supplied selectors applied;
|
||||||
invocation. Factory results
|
state is shared by the invocation. Factory results remain caller-owned, so
|
||||||
remain caller-owned, so separate initialization calls do not guarantee distinct
|
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
|
||||||
timing, omitted-factory zero values (including nil pointers), and ownership.
|
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.
|
||||||
adapts a typed ConfigKit loader without calling it during construction.
|
`ckconfig.FromConfigKit` adapts a typed ConfigKit loader without calling it
|
||||||
|
during construction. The bundled comparison adapters call the loader for the
|
||||||
|
left side before the right, retain left diagnostics if right loading fails,
|
||||||
|
and collect diagnostics from both loads before native comparison. Custom
|
||||||
|
comparison callbacks own their loading and diagnostics; loader calls need not
|
||||||
|
reread sources if the application caches them.
|
||||||
|
|
||||||
The `config` family provides scope discovery, validation, safe effective configuration display,
|
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
|
||||||
@@ -253,6 +305,11 @@ owns operator syntax and report details. `Scope.Features` declares optional mode
|
|||||||
`View.Metadata` and `View.Findings` carry native path metadata and diagnostic
|
`View.Metadata` and `View.Findings` carry native path metadata and diagnostic
|
||||||
records when available. `Findings.Messages` contains presentations associated
|
records when available. `Findings.Messages` contains presentations associated
|
||||||
with those records, while `Acquired.Diagnostics` carries independent messages.
|
with those records, while `Acquired.Diagnostics` carries independent messages.
|
||||||
|
Custom explanation providers implement `Explain`; CommandKit renders the
|
||||||
|
acquired evidence. See `EffectiveWriter` and `ExplanationReader` in the
|
||||||
|
[ckconfig GoDoc](../../integrations/ckconfig/contracts.go) for the provider boundary.
|
||||||
|
`ckconfig.AdaptDiagnostics` captures native diagnostic presentation evidence
|
||||||
|
for applications providing their own ConfigKit-backed CLI integration.
|
||||||
Set `Scope.Features.Overrides` only when the loader passes
|
Set `Scope.Features.Overrides` only when the loader passes
|
||||||
`Selection.Overrides` to ConfigKit as part of its normal load. The integration
|
`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
|
parses CLI records but cannot add them to an already-loaded result. The
|
||||||
@@ -267,6 +324,8 @@ it does not install PromptKit catalog, definition, or requirements commands.
|
|||||||
Its override feature is disabled because the released PromptKit configuration
|
Its override feature is disabled because the released PromptKit configuration
|
||||||
loader does not accept ConfigKit override records.
|
loader does not accept ConfigKit override records.
|
||||||
|
|
||||||
|
### Install resource commands and optional checks
|
||||||
|
|
||||||
`ckprompt.FromEngine(loadEngine)` supplies nine callbacks: prompt, profile,
|
`ckprompt.FromEngine(loadEngine)` supplies nine callbacks: prompt, profile,
|
||||||
backend, namespace, and collection catalogs; named prompt, profile, and backend
|
backend, namespace, and collection catalogs; named prompt, profile, and backend
|
||||||
inspection; and native requirements discovery. Concrete request validation,
|
inspection; and native requirements discovery. Concrete request validation,
|
||||||
@@ -301,6 +360,8 @@ suite := ckprompt.WithRequestValidation(
|
|||||||
validationCommand := ckprompt.Command(suite)
|
validationCommand := ckprompt.Command(suite)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Request builders and loading
|
||||||
|
|
||||||
If the loader is nil, the returned suite retains a wiring issue for installation
|
If the loader is nil, the returned suite retains a wiring issue for installation
|
||||||
to report, even if capabilities are narrowed later. A builder runs before the
|
to report, even if capabilities are narrowed later. A builder runs before the
|
||||||
engine loader and receives the seed request, selection, typed input, shared
|
engine loader and receives the seed request, selection, typed input, shared
|
||||||
@@ -350,6 +411,8 @@ 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
|
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.
|
subset. The application decides how to apply the resulting values to its request.
|
||||||
|
|
||||||
|
### Enable namespace flags
|
||||||
|
|
||||||
`ckprompt.WithNamespaces` enables standard resource namespace selection from
|
`ckprompt.WithNamespaces` enables standard resource namespace selection from
|
||||||
application-supplied defaults. Loaders used with this option should leave view
|
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.
|
selection to the adapter. Without it, a loader can supply its own engine view.
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ capability proposals in [future work](roadmap/future.md).
|
|||||||
| 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 |
|
| 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 |
|
||||||
| Unfinished work and capability proposals | [Future work](roadmap/future.md) |
|
| Unfinished work and capability proposals | [Future work](roadmap/future.md) |
|
||||||
|
| Completed documentation refresh | [Retired refresh record](https://forge.maximumdirect.net/base-layer/commandkit/src/commit/5c960532552037d38119941389e558f78bf73aad/docs/roadmap/documentation-refresh.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 [possible further efficiency work](roadmap/future.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) |
|
||||||
@@ -72,7 +73,10 @@ 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 v0.18.0 adoption and implementation](https://forge.maximumdirect.net/base-layer/commandkit/src/commit/540e2359d66d604a563df0be1c9fc961588f5b9c/docs/roadmap),
|
including [shared debug binding and reporting-mode implementation](https://forge.maximumdirect.net/base-layer/commandkit/src/commit/56e3b013f9f83c9c9494f11602e802ce5ed8814e/docs/roadmap),
|
||||||
|
[audit remediation and its declaration-path follow-up](https://forge.maximumdirect.net/base-layer/commandkit/src/commit/aac0e11ae67c78a35745ed9a25688aa51c15e793/docs/roadmap),
|
||||||
|
[shared reporting and PromptKit v0.20.0 implementation](https://forge.maximumdirect.net/base-layer/commandkit/src/commit/2f43f78f76439f5d11ef313cd379bec036543504/docs/roadmap),
|
||||||
|
[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),
|
[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)
|
[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).
|
and [PromptKit requirements implementation and remediation](https://gitea.maximumdirect.net/eric/CommandKit/src/commit/40499a43608bf60d1104cd8ee24caf18ac0e9e66/docs/roadmap).
|
||||||
@@ -80,6 +84,9 @@ These records are not assignments to restart completed work. The
|
|||||||
[future work record](roadmap/future.md) preserves unfinished work and unselected
|
[future work record](roadmap/future.md) preserves unfinished work and unselected
|
||||||
proposals separately from current contracts.
|
proposals separately from current contracts.
|
||||||
|
|
||||||
|
The [ckprompt release errata](../integrations/ckprompt/docs/releases/errata.md)
|
||||||
|
corrects navigation in a historical release note.
|
||||||
|
|
||||||
## Documentation validation
|
## Documentation validation
|
||||||
|
|
||||||
For current prose work, inspect relevant diffs, check local links and fragment
|
For current prose work, inspect relevant diffs, check local links and fragment
|
||||||
@@ -101,10 +108,20 @@ 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. Preserve the ignored `go.work`
|
The root module does not traverse nested modules. With Go 1.27.0 on `PATH`,
|
||||||
and `go.work.sum`; use `GOWORK=off GOTOOLCHAIN=local` for dependency preparation
|
create the local workspace from the repository root if `go.work` is absent:
|
||||||
and independent module suites once released requirements are aligned. Until then,
|
|
||||||
the integrations require a Go 1.27.0 workspace containing all three modules.
|
```sh
|
||||||
|
go work init . ./integrations/ckconfig ./integrations/ckprompt
|
||||||
|
```
|
||||||
|
|
||||||
|
If a workspace already exists, ensure it includes these three modules. Preserve
|
||||||
|
the ignored `go.work` and `go.work.sum`; do not commit them. Use
|
||||||
|
`GOWORK=off GOTOOLCHAIN=local` for dependency preparation
|
||||||
|
and independent module suites. Both integrations select released core v0.6.0;
|
||||||
|
ckprompt also selects released ckconfig v0.6.0. Their declared dependencies
|
||||||
|
support independent builds. The aligned workspace exercises the local sources
|
||||||
|
together.
|
||||||
Run `GOWORK=off GOTOOLCHAIN=local go test ./...` separately from each module
|
Run `GOWORK=off GOTOOLCHAIN=local go test ./...` separately from each module
|
||||||
directory. From the repository root, a workspace containing all three modules can
|
directory. From the repository root, a workspace containing all three modules can
|
||||||
also run:
|
also run:
|
||||||
|
|||||||
@@ -38,6 +38,12 @@ collected parser events through that selection. The selected mode therefore
|
|||||||
applies to command execution, help, and version; routing and parsing failures
|
applies to command execution, help, and version; routing and parsing failures
|
||||||
remain visible in every mode.
|
remain visible in every mode.
|
||||||
|
|
||||||
|
An effective flag view combines a shared application debug binding with the
|
||||||
|
core declaration for parsing, help, and completion, while preserving the
|
||||||
|
application binding's position. The same occurrence stream selects reporting
|
||||||
|
mode independently of the prepared application value. The invocation's mode
|
||||||
|
accessor reads the reporter's selection under its existing state mutex.
|
||||||
|
|
||||||
The reporter owns invocation-scoped visibility, message formatting, managed
|
The reporter owns invocation-scoped visibility, message formatting, managed
|
||||||
report rendering, delivery observations, and finalization. It presents queued
|
report rendering, delivery observations, and finalization. It presents queued
|
||||||
messages after the handler returns, then a failed outcome's explanation, and
|
messages after the handler returns, then a failed outcome's explanation, and
|
||||||
@@ -76,6 +82,8 @@ rendering. A session owns the mutable backend tree for one invocation; parsing
|
|||||||
and presentation reuse that session. The adapter returns raw values and neutral
|
and presentation reuse that session. The adapter returns raw values and neutral
|
||||||
syntax failures to core. It does not own the registry, typed input preparation,
|
syntax failures to core. It does not own the registry, typed input preparation,
|
||||||
integration declarations, provider acquisition, or native report rendering.
|
integration declarations, provider acquisition, or native report rendering.
|
||||||
|
Parser options carry flag-alias selection; invocation context and streams stay
|
||||||
|
with core, while backend output destinations discard and help uses a local buffer.
|
||||||
Cobra and pflag imports remain inside this package.
|
Cobra and pflag imports remain inside this package.
|
||||||
|
|
||||||
## Optional modules
|
## Optional modules
|
||||||
|
|||||||
@@ -161,8 +161,8 @@ and deferred messages. Core owns shared visibility, formatting, delivery, and
|
|||||||
finalization. Applications and integrations choose their report content and
|
finalization. Applications and integrations choose their report content and
|
||||||
messages; callers choose the destinations.
|
messages; callers choose the destinations.
|
||||||
[ADR 0006](../adr/0006-invocation-reporting.md) records this reporting and outcome
|
[ADR 0006](../adr/0006-invocation-reporting.md) records this reporting and outcome
|
||||||
ownership model. Its accepted structured-report extension remains planned in the
|
ownership model. The [CLI reference](../cli.md#reporting) describes the
|
||||||
[adoption roadmap](../roadmap/promptkit-v0.20.0.md#shared-core-report-output-prerequisite).
|
implemented structured-report behavior.
|
||||||
|
|
||||||
For that shared report handoff, core owns format selection, serialization,
|
For that shared report handoff, core owns format selection, serialization,
|
||||||
template execution, and delivery. Applications and integrations select logical
|
template execution, and delivery. Applications and integrations select logical
|
||||||
@@ -187,7 +187,7 @@ handles its own errors, including routing, parsing, and presentation failures.
|
|||||||
|
|
||||||
Preserve application-defined domain classifications and supplied error identity.
|
Preserve application-defined domain classifications and supplied error identity.
|
||||||
Report rendering and delivery failures separately from the command's explanation
|
Report rendering and delivery failures separately from the command's explanation
|
||||||
and cause, applying the [CLI reporting contract](../cli.md#presentation-failures).
|
and cause, applying the [CLI reporting contract](../cli.md#output-failures).
|
||||||
Intentionally suppressed output is not a delivery failure. Present terminal
|
Intentionally suppressed output is not a delivery failure. Present terminal
|
||||||
failure explanations after normal reporting finishes, avoid duplicate presentation
|
failure explanations after normal reporting finishes, avoid duplicate presentation
|
||||||
through core's own paths, and retain the explanation and any supplied cause in
|
through core's own paths, and retain the explanation and any supplied cause in
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
**Base Policy Version:** 1.6
|
**Base Policy Version:** 1.6
|
||||||
|
|
||||||
**Last Updated**: 2026-10-11
|
**Last Updated**: 2026-11-08
|
||||||
|
|
||||||
**Upstream Source:** [release.md](https://gitea.maximumdirect.net/eric/policy/src/commit/e833faddf120c789a9b45348edaf75720de16348/docs/policy/release.md)
|
**Upstream Source:** [release.md](https://gitea.maximumdirect.net/eric/policy/src/commit/e833faddf120c789a9b45348edaf75720de16348/docs/policy/release.md)
|
||||||
|
|
||||||
@@ -96,6 +96,22 @@ Additional compatibility promises and deliberate exceptions belong in the
|
|||||||
adopted policy or canonical contract documentation. Release notes explain
|
adopted policy or canonical contract documentation. Release notes explain
|
||||||
changes; they do not redefine current compatibility contracts.
|
changes; they do not redefine current compatibility contracts.
|
||||||
|
|
||||||
|
### CommandKit downstream dependency alignment
|
||||||
|
|
||||||
|
Each new core or integration release requires dependency updates and new releases
|
||||||
|
of all downstream CommandKit-owned integration modules, following direct and
|
||||||
|
transitive dependencies. Each affected integration advances at least its minor
|
||||||
|
version to incorporate the dependency updates, even when the initiating release
|
||||||
|
is a patch. Use a major release where the compatibility rules above require it.
|
||||||
|
Versions remain independent and need not match across modules.
|
||||||
|
|
||||||
|
A module with no downstream CommandKit integrations may release independently;
|
||||||
|
its release does not require new versions of its dependencies. This policy does
|
||||||
|
not require releases of external consuming applications. The
|
||||||
|
[runbook](../release.md#scope-and-release-order) owns dependency-order sequencing.
|
||||||
|
Downstream releases are required follow-up work, not additional completion gates
|
||||||
|
for an already published source release.
|
||||||
|
|
||||||
## Consumer Minimum And Release Toolchain
|
## Consumer Minimum And Release Toolchain
|
||||||
|
|
||||||
Distinguish the minimum toolchain supported for consumers from the toolchain
|
Distinguish the minimum toolchain supported for consumers from the toolchain
|
||||||
|
|||||||
@@ -9,11 +9,28 @@ Gitea release page; there are no binaries,
|
|||||||
checksums, or other delivery assets. CI, module proxy availability, and consumer
|
checksums, or other delivery assets. CI, module proxy availability, and consumer
|
||||||
installation are not completion gates.
|
installation are not completion gates.
|
||||||
|
|
||||||
Publish in dependency order: core, then `ckconfig`, then `ckprompt`. The
|
Plan the initiating release and all downstream integration releases required by
|
||||||
integration modules require the preceding CommandKit releases, so prepare and
|
the [dependency-alignment policy](policy/release.md#commandkit-downstream-dependency-alignment).
|
||||||
publish each module only after its CommandKit dependencies are available. A root
|
That policy sets the minimum version bump for affected integrations; select
|
||||||
release runs only the root module's default suite; it need not run suites for
|
each module's version independently according to its compatibility impact.
|
||||||
independently released integrations.
|
|
||||||
|
| Initiating release | Required downstream releases, in order |
|
||||||
|
| --- | --- |
|
||||||
|
| Core | `ckconfig`, then `ckprompt` |
|
||||||
|
| `ckconfig` | `ckprompt` |
|
||||||
|
| `ckprompt` | None with the current dependency graph |
|
||||||
|
|
||||||
|
As integrations are added, follow their actual dependencies to include every
|
||||||
|
affected downstream integration. Publish dependencies before their consumers.
|
||||||
|
Prepare each integration after its updated CommandKit dependencies are published,
|
||||||
|
incorporating all of those versions in one candidate. For a core update, release
|
||||||
|
`ckprompt` once after both core and `ckconfig`; it does not need separate releases
|
||||||
|
for each dependency bump. Unaffected dependencies do not need new releases.
|
||||||
|
|
||||||
|
Track any remaining downstream releases as pending coordination work. Each
|
||||||
|
successful source-tag push still completes that module's release; a downstream
|
||||||
|
failure does not undo it or authorize replacing its tag. A root release runs
|
||||||
|
only the root module's default suite; each downstream release runs its own suite.
|
||||||
|
|
||||||
Use branch `main`, remote `origin`, and expected effective push endpoint
|
Use branch `main`, remote `origin`, and expected effective push endpoint
|
||||||
`ssh://git@forge.maximumdirect.net:22222/base-layer/commandkit.git`. The selected
|
`ssh://git@forge.maximumdirect.net:22222/base-layer/commandkit.git`. The selected
|
||||||
@@ -98,12 +115,38 @@ standard offline suite and ReleaseKit’s candidate/note and publication guards.
|
|||||||
## Prepare and publish one candidate
|
## Prepare and publish one candidate
|
||||||
|
|
||||||
Prepare dependencies in the selected module before the candidate is finalized.
|
Prepare dependencies in the selected module before the candidate is finalized.
|
||||||
Do this after the preceding CommandKit module has been published and before the
|
Do this after any CommandKit dependency releases required by the coordinated
|
||||||
selected candidate suite is run. For `core`, run the command at the repository
|
update have been published and before the selected candidate suite is run.
|
||||||
root; for integrations, run it from that module directory. Commit any resulting
|
For `core`, run the command at the repository root; for integrations, run it
|
||||||
`go.mod` or `go.sum` changes with the release candidate. The sum files must
|
from that module directory.
|
||||||
reflect dependencies actually downloaded for that module; do not fabricate
|
|
||||||
future sums.
|
Before downloading dependencies, update each integration to the newly published
|
||||||
|
CommandKit versions. `go mod download` downloads the versions already selected;
|
||||||
|
it does not upgrade requirements. Replace the version placeholders below with
|
||||||
|
the actual published versions, which may differ between modules. When a
|
||||||
|
dependency is not being released in this update, use its current selected
|
||||||
|
release; a `ckconfig`-initiated update does not require a new core release:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# After publishing core, prepare ckconfig from its module directory.
|
||||||
|
GOWORK=off GOTOOLCHAIN=local go get go.maximumdirect.net/commandkit@vCORE_VERSION
|
||||||
|
|
||||||
|
# After publishing ckconfig, prepare ckprompt from its module directory.
|
||||||
|
GOWORK=off GOTOOLCHAIN=local go get \
|
||||||
|
go.maximumdirect.net/commandkit@vCORE_VERSION \
|
||||||
|
go.maximumdirect.net/ckconfig@vCKCONFIG_VERSION
|
||||||
|
```
|
||||||
|
|
||||||
|
Run only the command for the integration being prepared. ckprompt needs a
|
||||||
|
ckconfig release containing `AdaptDiagnostics`; v0.5.0 contains it, while v0.4.0
|
||||||
|
does not. Validate the integration with the workspace disabled against its
|
||||||
|
updated requirements, using the selected module's release validation below.
|
||||||
|
An aligned workspace can hide an outdated dependency.
|
||||||
|
|
||||||
|
Review and commit the resulting `go.mod` and `go.sum` changes with the release
|
||||||
|
candidate, and make its release note agree with the selected versions. The sum
|
||||||
|
files must reflect dependencies actually downloaded for that module; do not
|
||||||
|
fabricate 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
|
||||||
|
|||||||
43
docs/releases/v0.5.0.md
Normal file
43
docs/releases/v0.5.0.md
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
# CommandKit v0.5.0
|
||||||
|
|
||||||
|
CommandKit v0.5.0 fixes lost deferred messages and preserves YAML value types
|
||||||
|
more accurately. It also reduces repeated work when installing commands and
|
||||||
|
showing alias help, and refreshes documentation and help text.
|
||||||
|
|
||||||
|
## Changes
|
||||||
|
|
||||||
|
- Deferred messages accepted before finalization are retained for final reporting;
|
||||||
|
they can no longer be silently lost when a handler finishes. Later submissions return
|
||||||
|
`ErrReporterClosed`.
|
||||||
|
- Finished invocations release their direct references to output and diagnostic
|
||||||
|
writers while keeping delivery results available. Applications still own the
|
||||||
|
writers.
|
||||||
|
- `DecodeYAMLReport` preserves numeric-looking strings as strings. YAML report
|
||||||
|
output also preserves explicitly tagged numbers beyond the YAML resolver's
|
||||||
|
ordinary numeric range.
|
||||||
|
- Command installation, alias help, and typed input handling avoid repeated
|
||||||
|
work. Parsing and completion share flag handling while retaining their
|
||||||
|
different treatment of errors and unfinished input.
|
||||||
|
- The reporting example now works when its optional scenario is omitted, and
|
||||||
|
the interactive completion fixture no longer conflicts with `--format`.
|
||||||
|
`FlagMeta` documents existing naming and reserved-spelling rules.
|
||||||
|
- READMEs, CLI instructions, examples, and generated help use clearer language
|
||||||
|
and explain common tasks before implementation details.
|
||||||
|
|
||||||
|
## Compatibility and upgrade
|
||||||
|
|
||||||
|
The public core Go API, CLI grammar, and Go 1.26.0 consumer minimum are unchanged.
|
||||||
|
The maintainer release toolchain remains Go 1.27.0.
|
||||||
|
|
||||||
|
Applications using background producers should coordinate deferred submissions
|
||||||
|
before the handler returns and handle `ErrReporterClosed` once finalization
|
||||||
|
begins. See the [invocation reporting guide](../consumers/guide.md#invocation-reporting).
|
||||||
|
|
||||||
|
Callers supplying YAML with numbers beyond the resolver's ordinary range should
|
||||||
|
use explicit `!!int` or `!!float` tags when numeric meaning is intended. Untagged
|
||||||
|
values resolved as strings now retain that type. See
|
||||||
|
[`DecodeYAMLReport`](../../report_value.go) for the conversion contract.
|
||||||
|
|
||||||
|
[COPYRIGHT](https://forge.maximumdirect.net/base-layer/commandkit/src/tag/v0.5.0/COPYRIGHT) |
|
||||||
|
[LICENSE](https://forge.maximumdirect.net/base-layer/commandkit/src/tag/v0.5.0/LICENSE) |
|
||||||
|
[ATTRIBUTION](https://forge.maximumdirect.net/base-layer/commandkit/src/tag/v0.5.0/docs/oss/attribution.md)
|
||||||
25
docs/releases/v0.6.0.md
Normal file
25
docs/releases/v0.6.0.md
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
# CommandKit v0.6.0
|
||||||
|
|
||||||
|
CommandKit v0.6.0 lets applications bind the shared debug option to typed
|
||||||
|
input and inspect the selected reporting mode during and after an invocation.
|
||||||
|
|
||||||
|
## Changes
|
||||||
|
|
||||||
|
- A `BoolFlag` named `debug` attaches to the existing reporting option. Its
|
||||||
|
application default, requiredness, short spelling, and completion hints are
|
||||||
|
retained; help describes the combined option.
|
||||||
|
- `Invocation.ReportingMode` returns the resolved visibility selection, which
|
||||||
|
can differ from the bound debug value when other mode flags are supplied.
|
||||||
|
|
||||||
|
## Compatibility and upgrade
|
||||||
|
|
||||||
|
Other reporting flag names remain reserved. Applications choosing a required
|
||||||
|
debug binding require an explicit value; an explicit false satisfies it. A
|
||||||
|
true application default does not enable debug reporting. See the
|
||||||
|
[consumer guide](../consumers/guide.md#invocation-reporting) and
|
||||||
|
[CLI reference](../cli.md#reporting). The core consumer minimum remains Go
|
||||||
|
1.26.0 and the maintainer release toolchain remains Go 1.27.0.
|
||||||
|
|
||||||
|
[COPYRIGHT](https://forge.maximumdirect.net/base-layer/commandkit/src/tag/v0.6.0/COPYRIGHT) |
|
||||||
|
[LICENSE](https://forge.maximumdirect.net/base-layer/commandkit/src/tag/v0.6.0/LICENSE) |
|
||||||
|
[ATTRIBUTION](https://forge.maximumdirect.net/base-layer/commandkit/src/tag/v0.6.0/docs/oss/attribution.md)
|
||||||
@@ -1,16 +1,24 @@
|
|||||||
# Future work
|
# Future work
|
||||||
|
|
||||||
Status: pending release and integration work, plus future capability proposals.
|
Status: optional shell validation and future capability proposals.
|
||||||
No delivery dates are selected here.
|
No delivery dates are selected here.
|
||||||
|
|
||||||
## Purpose and authority
|
## Purpose and authority
|
||||||
|
|
||||||
Completed roadmaps, implementation plans, and review findings have been retired.
|
Earlier completed roadmaps, implementation plans, and review findings have been retired.
|
||||||
This document preserves unfinished work and unselected ideas under the
|
This document preserves unfinished work and unselected ideas under the
|
||||||
[release policy](../policy/release.md#release-documentation-and-preparation).
|
[release policy](../policy/release.md#release-documentation-and-preparation).
|
||||||
|
The completed shared debug binding and reporting-mode implementation is recorded
|
||||||
|
in its [retirement snapshot](https://forge.maximumdirect.net/base-layer/commandkit/src/commit/56e3b013f9f83c9c9494f11602e802ce5ed8814e/docs/roadmap).
|
||||||
|
The completed [documentation refresh](https://forge.maximumdirect.net/base-layer/commandkit/src/commit/5c960532552037d38119941389e558f78bf73aad/docs/roadmap/documentation-refresh.md) remains available in Git history.
|
||||||
|
The completed audits, consolidated remediation, implementation plan, and
|
||||||
|
declaration-path follow-up remain available in the
|
||||||
|
[audit remediation retirement snapshot](https://forge.maximumdirect.net/base-layer/commandkit/src/commit/aac0e11ae67c78a35745ed9a25688aa51c15e793/docs/roadmap).
|
||||||
The completed PromptKit v0.18.0 adoption roadmap and implementation plan remain
|
The completed PromptKit v0.18.0 adoption roadmap and implementation plan remain
|
||||||
available in the
|
available in the
|
||||||
[PromptKit retirement snapshot](https://forge.maximumdirect.net/base-layer/commandkit/src/commit/540e2359d66d604a563df0be1c9fc961588f5b9c/docs/roadmap).
|
[PromptKit retirement snapshot](https://forge.maximumdirect.net/base-layer/commandkit/src/commit/540e2359d66d604a563df0be1c9fc961588f5b9c/docs/roadmap).
|
||||||
|
The completed PromptKit v0.20.0 and shared reporting implementation is recorded
|
||||||
|
in its [retirement snapshot](https://forge.maximumdirect.net/base-layer/commandkit/src/commit/2f43f78f76439f5d11ef313cd379bec036543504/docs/roadmap).
|
||||||
The completed ConfigKit adoption, reporting, dependency alignment, release-tooling,
|
The completed ConfigKit adoption, reporting, dependency alignment, release-tooling,
|
||||||
and remediation records remain available at the
|
and remediation records remain available at the
|
||||||
[retirement snapshot](https://forge.maximumdirect.net/base-layer/commandkit/src/commit/260256d722f52bc947103d078cb67a129f4cc9f1/docs/roadmap).
|
[retirement snapshot](https://forge.maximumdirect.net/base-layer/commandkit/src/commit/260256d722f52bc947103d078cb67a129f4cc9f1/docs/roadmap).
|
||||||
@@ -22,55 +30,30 @@ govern any future implementation. The [CLI reference](../cli.md), public GoDoc,
|
|||||||
and [consumer guide](../consumers/guide.md) own implemented behavior. Retaining
|
and [consumer guide](../consumers/guide.md) own implemented behavior. Retaining
|
||||||
an idea here does not select its syntax, API, priority, or delivery date.
|
an idea here does not select its syntax, API, priority, or delivery date.
|
||||||
|
|
||||||
## Pending release and integration work
|
## Optional verification
|
||||||
|
|
||||||
### ckprompt release
|
### Optional shell completion validation
|
||||||
|
|
||||||
The PromptKit v0.18.0 adoption is complete. The
|
Zsh and Fish interactive completion checks remain unverified because those
|
||||||
[ckprompt v0.3.0 candidate](../../integrations/ckprompt/docs/releases/v0.3.0.md)
|
executables were unavailable during remediation. Script generation for Bash,
|
||||||
remains unpublished. Publication and hosted delivery remain separate operations
|
Zsh and Fish and the Bash interactive checks passed. Run the existing opt-in
|
||||||
under the [release procedure](../release.md).
|
backend and consumer checks when those prerequisites are available, following
|
||||||
The published ckprompt v0.2.0 retains its earlier dependencies; see the
|
the [developer guide](../development.md#shell-completion-validation).
|
||||||
[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
|
## Future capability targets
|
||||||
|
|
||||||
### Comparison extensions
|
### 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.
|
General collection-clearing support is deferred pending a concrete consumer need.
|
||||||
Revisit standardized syntax and binding support for explicitly replacing a
|
Revisit standardized syntax and binding support for explicitly replacing a
|
||||||
nonempty application-default selector collection with an empty collection.
|
nonempty application-default selector collection with an empty collection.
|
||||||
This is separate from clearing configuration lists through OD-11's transient
|
This is separate from clearing configuration lists through transient
|
||||||
overrides and does not reinstate comparison inheritance. No clearing grammar
|
overrides and does not reinstate comparison inheritance. No clearing grammar
|
||||||
or public helper API is selected.
|
or public helper API is selected.
|
||||||
|
|
||||||
### Configuration inspection extensions
|
### Configuration inspection extensions
|
||||||
|
|
||||||
The [working roadmap](promptkit-v0.20.0.md#shared-configuration-capabilities)
|
Persistent configuration editing remains deferred.
|
||||||
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
|
Discovery, import defaults, collection identities, and field-level resolution
|
||||||
recording remain available through application loaders. Discovery tracing,
|
recording remain available through application loaders. Discovery tracing,
|
||||||
@@ -81,28 +64,8 @@ application loading and startup.
|
|||||||
|
|
||||||
### PromptKit inspection and selection extensions
|
### PromptKit inspection and selection extensions
|
||||||
|
|
||||||
The [PromptKit v0.20.0 roadmap](promptkit-v0.20.0.md#targeted-end-state)
|
General-purpose helpers for application-internal PromptKit use remain outside
|
||||||
owns native prompt/profile/backend resource inspection, concrete request preview,
|
CommandKit's ownership boundary. The following CLI extensions are deferred.
|
||||||
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
|
- **Output-contract override flags.** Deferred from the current adoption. A
|
||||||
possible future operator task is validating or previewing one prompt with a
|
possible future operator task is validating or previewing one prompt with a
|
||||||
@@ -124,11 +87,6 @@ released upstream identity requirement; it does not change prompt dependencies.
|
|||||||
Deferring flags does not remove native appended messages from preview reports
|
Deferring flags does not remove native appended messages from preview reports
|
||||||
when an application builder supplies them.
|
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
|
### Alias completion and discovery
|
||||||
|
|
||||||
The delivered [shell completion feature](../cli.md#shell-completion) provides
|
The delivered [shell completion feature](../cli.md#shell-completion) provides
|
||||||
@@ -145,15 +103,8 @@ steps. No delivery date or final API is selected here.
|
|||||||
|
|
||||||
### Reporting extensions
|
### Reporting extensions
|
||||||
|
|
||||||
The [shared core report output prerequisite](promptkit-v0.20.0.md#shared-core-report-output-prerequisite)
|
XML is outside current consideration; Markdown and other renderers remain
|
||||||
owns application-selected content, core rendering, universal/automatic format
|
unselected.
|
||||||
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
|
Error-conversion helpers such as `WarnError` remain deferred pending demonstrated
|
||||||
value; applications can already use `report.Warn(err.Error())`. Terminal redraw
|
value; applications can already use `report.Warn(err.Error())`. Terminal redraw
|
||||||
@@ -183,9 +134,8 @@ operator or consumer need and an appropriate ownership/behavior decision:
|
|||||||
and output-file management or reloadable export.
|
and output-file management or reloadable export.
|
||||||
- Shell completion extensions, including alias discovery described above and
|
- Shell completion extensions, including alias discovery described above and
|
||||||
dynamic discovery of configured or remote values.
|
dynamic discovery of configured or remote values.
|
||||||
- Persistent configuration mutation. Transient CLI overrides are selected under
|
- Persistent configuration mutation. Transient CLI overrides and field metadata
|
||||||
roadmap OD-11; field metadata and provenance inspection are scoped under
|
inspection are available through the standard configuration suite.
|
||||||
[configuration inspection extensions](#configuration-inspection-extensions).
|
|
||||||
- Credential diagnostics beyond PromptKit's native smoke checks and additional
|
- Credential diagnostics beyond PromptKit's native smoke checks and additional
|
||||||
live provider probes.
|
live provider probes.
|
||||||
- Additional component identity registration and domain artifact catalogs.
|
- Additional component identity registration and domain artifact catalogs.
|
||||||
@@ -194,10 +144,7 @@ operator or consumer need and an appropriate ownership/behavior decision:
|
|||||||
- Backend selection or replacement, a plugin framework, general dependency
|
- Backend selection or replacement, a plugin framework, general dependency
|
||||||
injection, and concurrent-dispatch guarantees.
|
injection, and concurrent-dispatch guarantees.
|
||||||
|
|
||||||
Execution, serving, and session operations remain application-owned. The
|
Execution, serving, and session operations remain application-owned.
|
||||||
[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)
|
The [architecture policy](../policy/architecture.md#command-and-application-ownership)
|
||||||
recommends shared vocabulary; the [CLI reference](../cli.md#registered-command-syntax)
|
recommends shared vocabulary; the [CLI reference](../cli.md#registered-command-syntax)
|
||||||
owns implemented grammar.
|
owns implemented grammar.
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,38 +1,70 @@
|
|||||||
# Core example
|
# Core example
|
||||||
|
|
||||||
Run the example from the repository root:
|
This example shows typed flags, command shortcuts, shared reporting, and shell
|
||||||
|
completion. It needs no external service. See the [consumer guide](../../docs/consumers/guide.md#go-compatibility)
|
||||||
|
for Go requirements; maintainers use the toolchain in the
|
||||||
|
[developer guide](../../docs/development.md#documentation-validation).
|
||||||
|
|
||||||
|
Run from the repository root, with that Go toolchain on `PATH`:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
export GOWORK=off GOTOOLCHAIN=local
|
||||||
|
go run ./examples/core workspace greet --count=2
|
||||||
|
```
|
||||||
|
|
||||||
|
The result shows the selected count and whether it was supplied. It also shows
|
||||||
|
the entered command path and the original command path, which differ when you
|
||||||
|
use a shortcut.
|
||||||
|
|
||||||
|
## Try shortcuts and explicit values
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# The hello shortcut runs workspace greet; --times means --count.
|
||||||
go run ./examples/core hello --times=0 --tag=first
|
go run ./examples/core hello --times=0 --tag=first
|
||||||
|
# ws is a shortcut for the workspace group.
|
||||||
go run ./examples/core ws greet --count=2
|
go run ./examples/core ws greet --count=2
|
||||||
|
# Inspect the group or use the example's global help alias.
|
||||||
go run ./examples/core ws --help
|
go run ./examples/core ws --help
|
||||||
go run ./examples/core hello --hlep
|
go run ./examples/core hello --hlep
|
||||||
|
```
|
||||||
|
|
||||||
|
An explicit zero remains a supplied value. The handler prints both
|
||||||
|
`Invocation.EnteredPath()` and `Invocation.CanonicalPath()` so you can see how
|
||||||
|
shortcuts resolve.
|
||||||
|
|
||||||
|
## Try reporting
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go run ./examples/core report
|
||||||
|
go run ./examples/core report --verbose
|
||||||
|
go run ./examples/core report --quiet
|
||||||
|
```
|
||||||
|
|
||||||
|
The default scenario succeeds and writes its result. Verbose mode also shows
|
||||||
|
warnings and information; quiet mode hides the result. The
|
||||||
|
[CLI reference](../../docs/cli.md#reporting) defines the shared visibility rules.
|
||||||
|
|
||||||
|
## Try shell completion
|
||||||
|
|
||||||
|
```sh
|
||||||
go build -o core-example ./examples/core
|
go build -o core-example ./examples/core
|
||||||
./core-example completion bash
|
./core-example completion bash
|
||||||
```
|
```
|
||||||
|
|
||||||
The example installs a canonical `workspace greet` command together with a
|
This prints the Bash completion script. Initialize `bash-completion`, then load
|
||||||
command shortcut, a group shortcut, a scoped flag alias, and a global help
|
the script as described in the [CLI reference](../../docs/cli.md#shell-completion).
|
||||||
alias. Its declaration slice mixes the canonical group and routing rules as
|
Use the executable basename `core-example` and try completing
|
||||||
`[]commandkit.Declaration`. The handler prints `Invocation.EnteredPath()` and
|
`core-example report d` or `core-example ws gr`. Other supported shells are
|
||||||
`Invocation.CanonicalPath()` so the alternate route and its canonical target
|
listed in the reference.
|
||||||
are visible in the output.
|
|
||||||
|
|
||||||
The example also installs `Completion("core-example")` and gives the `report`
|
## How it is wired
|
||||||
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
|
The example installs `workspace greet` alongside command and flag aliases in a
|
||||||
passed directly to `New` beside `commandkit.Alias` or flag-alias declarations.
|
`[]commandkit.Declaration`. It also installs `Completion("core-example")` and
|
||||||
The matched ckconfig and ckprompt releases use the new core identity. For a
|
provides static choices for the reporting scenario argument. The
|
||||||
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)
|
[consumer guide](../../docs/consumers/guide.md#application-owned-aliases)
|
||||||
shows both forms, and the [composed consumer](../../integrations/ckprompt/examples/composed)
|
explains declaration composition; the [composed example](../../integrations/ckprompt/examples/composed)
|
||||||
is the maintained integration example.
|
adds integration commands.
|
||||||
|
|
||||||
These aliases require core v0.3.0. Repository contributors can run its test with
|
Run `GOWORK=off GOTOOLCHAIN=local go test ./examples/core` from the repository
|
||||||
`go test ./examples/core` from the root.
|
root to check this example.
|
||||||
|
|||||||
@@ -74,7 +74,11 @@ func newApp() (*commandkit.App, error) {
|
|||||||
commandkit.ValueArgument(commandkit.ArgumentMeta{Name: "scenario", Usage: "demo, recover, warn, warn-error, fail, or partial.", Completion: commandkit.CompleteChoices(
|
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: "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 },
|
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 }),
|
func(input *reportInput, value commandkit.Supplied[string]) {
|
||||||
|
if value.Set {
|
||||||
|
input.Scenario = value.Value
|
||||||
|
}
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
CheckInput: func(input reportInput) error {
|
CheckInput: func(input reportInput) error {
|
||||||
switch input.Scenario {
|
switch input.Scenario {
|
||||||
|
|||||||
11
execute.go
11
execute.go
@@ -80,10 +80,8 @@ func (a *App) Execute(ctx context.Context, args []string, streams Streams) Outco
|
|||||||
type execution struct {
|
type execution struct {
|
||||||
app *App
|
app *App
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
streams Streams
|
|
||||||
reporter *Reporter
|
reporter *Reporter
|
||||||
debugEvents []string
|
debugEvents []string
|
||||||
options commandmeta.ParseOptions
|
|
||||||
snapshot *routingSnapshot
|
snapshot *routingSnapshot
|
||||||
session *internalcobra.Session
|
session *internalcobra.Session
|
||||||
route routeResult
|
route routeResult
|
||||||
@@ -116,13 +114,10 @@ func (current commandExecution) renderHelpTargets(targets []string) Outcome {
|
|||||||
}
|
}
|
||||||
|
|
||||||
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,
|
|
||||||
reporter: newReporter(streams),
|
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()
|
||||||
@@ -147,7 +142,7 @@ func (current *execution) prepareSession() {
|
|||||||
if current.snapshot == nil {
|
if current.snapshot == nil {
|
||||||
current.snapshot = current.app.routeSnapshot()
|
current.snapshot = current.app.routeSnapshot()
|
||||||
}
|
}
|
||||||
options := current.options
|
options := commandmeta.ParseOptions{}
|
||||||
if current.snapshot != nil {
|
if current.snapshot != nil {
|
||||||
switch current.route.kind {
|
switch current.route.kind {
|
||||||
case routeCanonical:
|
case routeCanonical:
|
||||||
@@ -201,11 +196,11 @@ func (current *execution) renderHelpTargets(targets []string) Outcome {
|
|||||||
// Explicit help targets are paths, not command invocations with operands.
|
// Explicit help targets are paths, not command invocations with operands.
|
||||||
// Discovery routes have no canonical path and retain only trailing options.
|
// Discovery routes have no canonical path and retain only trailing options.
|
||||||
if route.kind == routeUnknown || len(route.args) != len(route.canonicalPath) {
|
if route.kind == routeUnknown || len(route.args) != len(route.canonicalPath) {
|
||||||
session := internalcobra.Prepare(snapshot.metadata, current.options)
|
session := internalcobra.Prepare(snapshot.metadata, commandmeta.ParseOptions{})
|
||||||
view := commandmeta.HelpView{EnteredPath: targets, CanonicalPath: targets}
|
view := commandmeta.HelpView{EnteredPath: targets, CanonicalPath: targets}
|
||||||
return current.app.renderHelpViewOutcomeWithSession(view, current.reporter, session)
|
return current.app.renderHelpViewOutcomeWithSession(view, current.reporter, session)
|
||||||
}
|
}
|
||||||
options := current.options
|
options := commandmeta.ParseOptions{}
|
||||||
switch route.kind {
|
switch route.kind {
|
||||||
case routeCanonical:
|
case routeCanonical:
|
||||||
options.FlagAliases = snapshot.flagAliases[route.canonicalPath.Key()].neutralRecords()
|
options.FlagAliases = snapshot.flagAliases[route.canonicalPath.Key()].neutralRecords()
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ func (spelling capturedFlagSpelling) token() string {
|
|||||||
|
|
||||||
func compileFlagAliases(rules []RoutingRule, roots map[string]*canonicalNode, aliases interface {
|
func compileFlagAliases(rules []RoutingRule, roots map[string]*canonicalNode, aliases interface {
|
||||||
Resolve(commandpath.Path) commandpath.Path
|
Resolve(commandpath.Path) commandpath.Path
|
||||||
|
ProperSourcePrefixes() []commandpath.Path
|
||||||
}) (map[string]resolvedFlagAliases, map[string]resolvedFlagAliases, error) {
|
}) (map[string]resolvedFlagAliases, map[string]resolvedFlagAliases, error) {
|
||||||
canonicalNodes := indexCanonicalNodes(roots)
|
canonicalNodes := indexCanonicalNodes(roots)
|
||||||
global := make(map[string]flagAliasMapping)
|
global := make(map[string]flagAliasMapping)
|
||||||
@@ -170,6 +171,7 @@ func canonicalFlagSpellings(command Command) map[string]capturedFlagSpelling {
|
|||||||
|
|
||||||
func discoverFlagContexts(rules []RoutingRule, roots map[string]*canonicalNode, aliases interface {
|
func discoverFlagContexts(rules []RoutingRule, roots map[string]*canonicalNode, aliases interface {
|
||||||
Resolve(commandpath.Path) commandpath.Path
|
Resolve(commandpath.Path) commandpath.Path
|
||||||
|
ProperSourcePrefixes() []commandpath.Path
|
||||||
}, canonicalNodes map[string]*canonicalNode) map[string]flagAliasContext {
|
}, canonicalNodes map[string]*canonicalNode) map[string]flagAliasContext {
|
||||||
commandSources := make(map[string]struct{})
|
commandSources := make(map[string]struct{})
|
||||||
prefixes := make(map[string]commandpath.Path)
|
prefixes := make(map[string]commandpath.Path)
|
||||||
@@ -178,12 +180,7 @@ func discoverFlagContexts(rules []RoutingRule, roots map[string]*canonicalNode,
|
|||||||
commandSources[rule.record.sourcePath.Key()] = struct{}{}
|
commandSources[rule.record.sourcePath.Key()] = struct{}{}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for _, rule := range rules {
|
for _, prefix := range aliases.ProperSourcePrefixes() {
|
||||||
if rule.record.kind != commandAliasRule {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
for length := 1; length < len(rule.record.sourcePath); length++ {
|
|
||||||
prefix := rule.record.sourcePath[:length].Clone()
|
|
||||||
key := prefix.Key()
|
key := prefix.Key()
|
||||||
if canonicalNodes[key] != nil {
|
if canonicalNodes[key] != nil {
|
||||||
continue
|
continue
|
||||||
@@ -198,7 +195,6 @@ func discoverFlagContexts(rules []RoutingRule, roots map[string]*canonicalNode,
|
|||||||
}
|
}
|
||||||
prefixes[key] = prefix
|
prefixes[key] = prefix
|
||||||
}
|
}
|
||||||
}
|
|
||||||
contexts := make(map[string]flagAliasContext, len(prefixes))
|
contexts := make(map[string]flagAliasContext, len(prefixes))
|
||||||
for key, path := range prefixes {
|
for key, path := range prefixes {
|
||||||
contexts[key] = flagAliasContext{path: path, canonical: helpFlagSpellings(), discovery: true}
|
contexts[key] = flagAliasContext{path: path, canonical: helpFlagSpellings(), discovery: true}
|
||||||
@@ -230,14 +226,54 @@ func resolveFlagAliases(context flagAliasContext, mappings map[string]flagAliasM
|
|||||||
sources = append(sources, source)
|
sources = append(sources, source)
|
||||||
}
|
}
|
||||||
sort.Strings(sources)
|
sort.Strings(sources)
|
||||||
|
type terminal struct {
|
||||||
|
spelling capturedFlagSpelling
|
||||||
|
active bool
|
||||||
|
}
|
||||||
|
resolved := make(map[string]terminal)
|
||||||
|
visiting := make(map[string]int)
|
||||||
|
var stack []capturedFlagSpelling
|
||||||
|
var resolve func(capturedFlagSpelling) (terminal, error)
|
||||||
|
resolve = func(current capturedFlagSpelling) (terminal, error) {
|
||||||
|
key := flagSpellingKey(current)
|
||||||
|
if endpoint, exists := context.canonical[key]; exists {
|
||||||
|
return terminal{spelling: endpoint, active: true}, nil
|
||||||
|
}
|
||||||
|
if result, exists := resolved[key]; exists {
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
if start, exists := visiting[key]; exists {
|
||||||
|
cycle := append(append([]capturedFlagSpelling(nil), stack[start:]...), current)
|
||||||
|
spellings := make([]string, 0, len(cycle))
|
||||||
|
for _, spelling := range cycle {
|
||||||
|
spellings = append(spellings, spelling.token())
|
||||||
|
}
|
||||||
|
return terminal{}, fmt.Errorf("flag alias cycle: %s", strings.Join(spellings, " → "))
|
||||||
|
}
|
||||||
|
mapping, exists := mappings[key]
|
||||||
|
if !exists {
|
||||||
|
resolved[key] = terminal{}
|
||||||
|
return terminal{}, nil
|
||||||
|
}
|
||||||
|
visiting[key] = len(stack)
|
||||||
|
stack = append(stack, current)
|
||||||
|
result, err := resolve(mapping.target)
|
||||||
|
stack = stack[:len(stack)-1]
|
||||||
|
delete(visiting, key)
|
||||||
|
if err != nil {
|
||||||
|
return terminal{}, err
|
||||||
|
}
|
||||||
|
resolved[key] = result
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
for _, sourceKey := range sources {
|
for _, sourceKey := range sources {
|
||||||
mapping := mappings[sourceKey]
|
mapping := mappings[sourceKey]
|
||||||
source := spellingFromKey(sourceKey)
|
source := spellingFromKey(sourceKey)
|
||||||
target, active, err := resolveFlagTarget(source, mapping.target, mappings, context.canonical)
|
terminal, err := resolve(mapping.target)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return resolvedFlagAliases{}, fmt.Errorf("%s: %w", flagContextDescription(context), err)
|
return resolvedFlagAliases{}, fmt.Errorf("%s: %w", flagContextDescription(context), err)
|
||||||
}
|
}
|
||||||
if !active {
|
if !terminal.active {
|
||||||
if mapping.scoped {
|
if mapping.scoped {
|
||||||
return resolvedFlagAliases{}, fmt.Errorf("scoped flag alias %s has no canonical destination in %s", source.token(), flagContextDescription(context))
|
return resolvedFlagAliases{}, fmt.Errorf("scoped flag alias %s has no canonical destination in %s", source.token(), flagContextDescription(context))
|
||||||
}
|
}
|
||||||
@@ -246,38 +282,11 @@ func resolveFlagAliases(context flagAliasContext, mappings map[string]flagAliasM
|
|||||||
if _, collision := context.canonical[sourceKey]; collision {
|
if _, collision := context.canonical[sourceKey]; collision {
|
||||||
return resolvedFlagAliases{}, fmt.Errorf("flag alias %s conflicts with a canonical spelling in %s", source.token(), flagContextDescription(context))
|
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}
|
result.bySpelling[sourceKey] = resolvedFlagAlias{source: source, target: terminal.spelling}
|
||||||
}
|
}
|
||||||
return result, nil
|
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 {
|
func spellingFromKey(key string) capturedFlagSpelling {
|
||||||
if strings.HasPrefix(key, "short:") {
|
if strings.HasPrefix(key, "short:") {
|
||||||
return capturedFlagSpelling{name: strings.TrimPrefix(key, "short:"), short: true}
|
return capturedFlagSpelling{name: strings.TrimPrefix(key, "short:"), short: true}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package commandkit
|
package commandkit
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -295,3 +296,45 @@ func capturedSpelling(token string) capturedFlagSpelling {
|
|||||||
issues := []error(nil)
|
issues := []error(nil)
|
||||||
return captureFlagSpelling("test", token, &issues)
|
return captureFlagSpelling("test", token, &issues)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestFlagAliasSharedSuffixAndUnavailableDestination(t *testing.T) {
|
||||||
|
app, err := New(Identity{Name: "Example"}, flagAliasCommand("active", stringFlag("color", "")), flagAliasCommand("inactive"),
|
||||||
|
FlagAlias("--first", "--common"), FlagAlias("--second", "--common"), FlagAlias("--common", "--color"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, name := range []string{"first", "second", "common"} {
|
||||||
|
key := flagSpellingKey(capturedFlagSpelling{name: name})
|
||||||
|
active := app.flagAliases[commandpath.Path{"active"}.Key()].bySpelling
|
||||||
|
if alias, ok := active[key]; !ok || alias.target.token() != "--color" {
|
||||||
|
t.Fatalf("active %s = %#v", name, alias)
|
||||||
|
}
|
||||||
|
if _, ok := app.flagAliases[commandpath.Path{"inactive"}.Key()].bySpelling[key]; ok {
|
||||||
|
t.Fatalf("inactive %s was installed", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSharedDebugDeclarationValidation(t *testing.T) {
|
||||||
|
valid := BoolFlag(FlagMeta{Name: "debug", Short: "d"}, true, func(*struct{}, Supplied[bool]) {})
|
||||||
|
if _, err := New(Identity{Name: "Example"}, flagAliasCommand("run", valid)); err != nil {
|
||||||
|
t.Fatalf("valid debug: %v", err)
|
||||||
|
}
|
||||||
|
for _, flags := range [][]Flag[struct{}]{
|
||||||
|
{valid, valid},
|
||||||
|
{BoolFlag[struct{}](FlagMeta{Name: "debug"}, false, nil)},
|
||||||
|
{BoolFlag(FlagMeta{Name: "debug", Short: "h"}, false, func(*struct{}, Supplied[bool]) {})},
|
||||||
|
{ValueFlag(FlagMeta{Name: "debug"}, (*bool)(nil), strconv.ParseBool, func(*struct{}, Supplied[bool]) {})},
|
||||||
|
{OccurrenceFlag(FlagMeta{Name: "debug"}, strconv.ParseBool, func(*struct{}, Supplied[bool]) {})},
|
||||||
|
} {
|
||||||
|
if _, err := New(Identity{Name: "Example"}, flagAliasCommand("bad", flags...)); err == nil {
|
||||||
|
t.Fatalf("invalid binding accepted: %#v", flags)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, name := range []string{"quiet", "verbose", "progress", "format"} {
|
||||||
|
flag := BoolFlag(FlagMeta{Name: name}, false, func(*struct{}, Supplied[bool]) {})
|
||||||
|
if _, err := New(Identity{Name: "Example"}, flagAliasCommand("bad", flag)); err == nil {
|
||||||
|
t.Errorf("reserved boolean --%s was accepted", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
package commandkit
|
package commandkit
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"container/heap"
|
||||||
"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.
|
||||||
@@ -24,6 +24,8 @@ type FlagValues[I any] struct {
|
|||||||
// each adapted flag stores its prepared value in target, associated with the
|
// each adapted flag stores its prepared value in target, associated with the
|
||||||
// original inner binding identity. Copies of an original handle remain
|
// original inner binding identity. Copies of an original handle remain
|
||||||
// selectable; an independently constructed lookalike does not.
|
// selectable; an independently constructed lookalike does not.
|
||||||
|
// A captured debug BoolFlag shares reporting input as described by [BoolFlag];
|
||||||
|
// its requiredness is checked when selected for application.
|
||||||
func CaptureFlags[Outer, I any](flags []Flag[I], target func(*Outer) *FlagValues[I]) []Flag[Outer] {
|
func CaptureFlags[Outer, I any](flags []Flag[I], target func(*Outer) *FlagValues[I]) []Flag[Outer] {
|
||||||
captured := make([]Flag[Outer], len(flags))
|
captured := make([]Flag[Outer], len(flags))
|
||||||
for index, original := range flags {
|
for index, original := range flags {
|
||||||
@@ -108,22 +110,72 @@ func (values FlagValues[I]) applyTo(target *I, suppliedOnly bool, selected ...Fl
|
|||||||
}
|
}
|
||||||
preparedValues = append(preparedValues, prepared)
|
preparedValues = append(preparedValues, prepared)
|
||||||
}
|
}
|
||||||
var ordered []preparedOccurrence[I]
|
var ordered occurrenceHeap[I]
|
||||||
for _, prepared := range preparedValues {
|
for index, prepared := range preparedValues {
|
||||||
if prepared.apply != nil {
|
if prepared.apply != nil {
|
||||||
prepared.apply(target)
|
prepared.apply(target)
|
||||||
}
|
}
|
||||||
for _, occurrence := range prepared.ordered {
|
stream := occurrenceStream[I]{events: prepared.ordered, order: index}
|
||||||
if occurrence.apply != nil {
|
stream.skipUnapplied()
|
||||||
ordered = append(ordered, occurrence)
|
if len(stream.events) > 0 {
|
||||||
|
ordered = append(ordered, stream)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// One selected stream already has the required order.
|
||||||
|
if len(ordered) == 1 {
|
||||||
|
for _, event := range ordered[0].events {
|
||||||
|
if event.apply != nil {
|
||||||
|
event.apply(target)
|
||||||
}
|
}
|
||||||
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
|
||||||
|
}
|
||||||
|
heap.Init(&ordered)
|
||||||
|
for len(ordered) > 0 {
|
||||||
|
stream := &ordered[0]
|
||||||
|
stream.events[0].apply(target)
|
||||||
|
stream.events = stream.events[1:]
|
||||||
|
stream.skipUnapplied()
|
||||||
|
if len(stream.events) == 0 {
|
||||||
|
heap.Pop(&ordered)
|
||||||
|
} else {
|
||||||
|
heap.Fix(&ordered, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Each binding supplies a monotone stream. The selection index breaks ordinal
|
||||||
|
// ties in the same order as concatenation followed by a stable sort.
|
||||||
|
type occurrenceStream[I any] struct {
|
||||||
|
events []preparedOccurrence[I]
|
||||||
|
order int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (stream *occurrenceStream[I]) skipUnapplied() {
|
||||||
|
for len(stream.events) > 0 && stream.events[0].apply == nil {
|
||||||
|
stream.events = stream.events[1:]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type occurrenceHeap[I any] []occurrenceStream[I]
|
||||||
|
|
||||||
|
func (h occurrenceHeap[I]) Len() int { return len(h) }
|
||||||
|
func (h occurrenceHeap[I]) Less(i, j int) bool {
|
||||||
|
left, right := *h[i].events[0].ordinal, *h[j].events[0].ordinal
|
||||||
|
if left == right {
|
||||||
|
return h[i].order < h[j].order
|
||||||
|
}
|
||||||
|
return left < right
|
||||||
|
}
|
||||||
|
func (h occurrenceHeap[I]) Swap(i, j int) { h[i], h[j] = h[j], h[i] }
|
||||||
|
func (h *occurrenceHeap[I]) Push(value any) { *h = append(*h, value.(occurrenceStream[I])) }
|
||||||
|
func (h *occurrenceHeap[I]) Pop() any {
|
||||||
|
last := len(*h) - 1
|
||||||
|
value := (*h)[last]
|
||||||
|
(*h)[last] = occurrenceStream[I]{}
|
||||||
|
*h = (*h)[:last]
|
||||||
|
return value
|
||||||
}
|
}
|
||||||
|
|
||||||
func classifyInputError(err error) Outcome {
|
func classifyInputError(err error) Outcome {
|
||||||
|
|||||||
@@ -36,11 +36,8 @@ func routeHelpAliases(snapshot *routingSnapshot, route routeResult) []commandmet
|
|||||||
}
|
}
|
||||||
bySource := make(map[string]commandmeta.CommandAlias)
|
bySource := make(map[string]commandmeta.CommandAlias)
|
||||||
for _, prefix := range snapshot.aliases.ResolutionPaths(route.enteredPath) {
|
for _, prefix := range snapshot.aliases.ResolutionPaths(route.enteredPath) {
|
||||||
for _, alias := range snapshot.commandAliases {
|
for _, alias := range snapshot.aliases.Descendants(prefix) {
|
||||||
source := commandpath.FromTokens(alias.Source)
|
source := alias.Source
|
||||||
if len(source) <= len(prefix) || !source.HasPrefix(prefix) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
suffix := source[len(prefix):]
|
suffix := source[len(prefix):]
|
||||||
entered := append(route.enteredPath.Clone(), suffix...)
|
entered := append(route.enteredPath.Clone(), suffix...)
|
||||||
target := snapshot.aliases.Resolve(entered)
|
target := snapshot.aliases.Resolve(entered)
|
||||||
|
|||||||
15
identity.go
15
identity.go
@@ -59,6 +59,21 @@ type Invocation struct {
|
|||||||
// manually constructed Invocation has no reporter.
|
// manually constructed Invocation has no reporter.
|
||||||
func (inv Invocation) Reporter() *Reporter { return inv.reporter }
|
func (inv Invocation) Reporter() *Reporter { return inv.reporter }
|
||||||
|
|
||||||
|
// ReportingMode returns the selected reporting mode, independently of delivery
|
||||||
|
// success. The selection is stable during the handler and remains available
|
||||||
|
// after finalization. A zero Invocation returns ReportingModeOrdinary. Reads
|
||||||
|
// are safe alongside concurrent reporting; reporting callbacks must not reenter
|
||||||
|
// the same reporter.
|
||||||
|
func (inv Invocation) ReportingMode() ReportingMode {
|
||||||
|
if inv.reporter == nil || inv.reporter.state == nil {
|
||||||
|
return ReportingModeOrdinary
|
||||||
|
}
|
||||||
|
state := inv.reporter.state
|
||||||
|
state.mu.Lock()
|
||||||
|
defer state.mu.Unlock()
|
||||||
|
return state.mode
|
||||||
|
}
|
||||||
|
|
||||||
// Help renders the selected command's help through this invocation's output.
|
// Help renders the selected command's help through this invocation's output.
|
||||||
// It returns an operational failure when the invocation has no help renderer.
|
// It returns an operational failure when the invocation has no help renderer.
|
||||||
func (inv Invocation) Help() Outcome {
|
func (inv Invocation) Help() Outcome {
|
||||||
|
|||||||
@@ -1,36 +1,30 @@
|
|||||||
# CommandKit ckconfig
|
# CommandKit ckconfig
|
||||||
|
|
||||||
`ckconfig` is the optional ConfigKit integration for CommandKit. It adapts
|
`ckconfig` adds configuration inspection commands to Go applications that use
|
||||||
ConfigKit loaders and scopes into reusable `commandkit.Command` declarations;
|
CommandKit and ConfigKit. Operators can view settings, validate configuration,
|
||||||
installing the integration is explicit and does not modify an application by
|
find where values came from, and compare two configurations.
|
||||||
itself.
|
|
||||||
|
The application chooses which commands to install. Importing the package alone
|
||||||
|
does not install commands.
|
||||||
|
|
||||||
## Module
|
## Module
|
||||||
|
|
||||||
The module path is `go.maximumdirect.net/ckconfig`. Version v0.3.0 requires Go
|
The module path is `go.maximumdirect.net/ckconfig`. It does not import PromptKit.
|
||||||
1.27.0 and uses `go.maximumdirect.net/commandkit v0.3.0` and
|
See the [consumer guide](https://forge.maximumdirect.net/base-layer/commandkit/src/branch/main/docs/consumers/guide.md#module-selection)
|
||||||
`go.maximumdirect.net/configkit v1.1.0`; it does not import PromptKit. See the
|
for compatible releases and Go requirements.
|
||||||
[consumer guide](https://gitea.maximumdirect.net/eric/CommandKit/src/branch/main/docs/consumers/guide.md#configkit-scopes)
|
|
||||||
for exact callback and lifetime contracts.
|
|
||||||
|
|
||||||
## Example
|
## Example
|
||||||
|
|
||||||
For one application configuration scope, install the convenience declaration:
|
To inspect one application configuration, pass your loader to `AppCommand`:
|
||||||
|
|
||||||
```go
|
```go
|
||||||
app, err := commandkit.New(identity, ckconfig.AppCommand(loadAppConfig))
|
app, err := commandkit.New(identity, ckconfig.AppCommand(loadAppConfig))
|
||||||
```
|
```
|
||||||
|
|
||||||
Use `ckconfig.Command(ckconfig.Suite[Input, State]{...})` to define named
|
For multiple named configurations or application-specific flags, use
|
||||||
scopes, selectors, input checks, and state shared by the selected acquisitions
|
`ckconfig.Command` with a `ckconfig.Suite`. The
|
||||||
or comparison in a config invocation. `ckconfig.FromConfigKit` adapts a typed
|
[integration guide](https://forge.maximumdirect.net/base-layer/commandkit/src/branch/main/docs/consumers/guide.md#configkit-scopes)
|
||||||
ConfigKit loader. The `config` command family exposes scope discovery and the
|
explains loader setup, inputs, and shared state.
|
||||||
validate, show, sources, explain, and diff operations supported by the declared
|
|
||||||
scopes. Source reports include native destination, placement, destination
|
|
||||||
fallback, version evidence, and empty-import selections. Comparison returns
|
|
||||||
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)
|
|
||||||
owns its operator details.
|
|
||||||
|
|
||||||
The maintained [simple example](examples/simple) shows a complete consumer.
|
The maintained [simple example](examples/simple) shows a complete consumer.
|
||||||
The [CommandKit CLI reference](https://gitea.maximumdirect.net/eric/CommandKit/src/branch/main/docs/cli.md#configuration-scopes)
|
The [CommandKit CLI reference](https://gitea.maximumdirect.net/eric/CommandKit/src/branch/main/docs/cli.md#configuration-scopes)
|
||||||
|
|||||||
@@ -43,17 +43,24 @@ func FromConfigKit[I, S, T any](
|
|||||||
acquired.Value.Sources = loadError
|
acquired.Value.Sources = loadError
|
||||||
acquired.Value.Findings = &Findings{Records: records, Messages: messages}
|
acquired.Value.Findings = &Findings{Records: records, Messages: messages}
|
||||||
acquired.Explanation = explanation
|
acquired.Explanation = explanation
|
||||||
}
|
|
||||||
return acquired, err
|
return acquired, err
|
||||||
}
|
}
|
||||||
|
}
|
||||||
if result == nil {
|
if result == nil {
|
||||||
|
if err != nil {
|
||||||
|
return acquired, err
|
||||||
|
}
|
||||||
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()
|
|
||||||
records := result.Diagnostics()
|
records := result.Diagnostics()
|
||||||
messages, explanation := configKitDiagnostics(name, result.Sources(), records)
|
messages, explanation := configKitDiagnostics(name, result.Sources(), records)
|
||||||
acquired.Value = View{Effective: result, Sources: result, Explanation: result, Metadata: &metadata, Findings: &Findings{Records: records, Messages: messages}}
|
acquired.Value = View{Sources: result, Findings: &Findings{Records: records, Messages: messages}}
|
||||||
acquired.Explanation = explanation
|
acquired.Explanation = explanation
|
||||||
|
if err != nil {
|
||||||
|
return acquired, err
|
||||||
|
}
|
||||||
|
metadata := result.Metadata()
|
||||||
|
acquired.Value.Effective, acquired.Value.Explanation, acquired.Value.Metadata = result, result, &metadata
|
||||||
return acquired, 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) {
|
||||||
@@ -120,14 +127,17 @@ func loadComparisonSide[I, S, T any](
|
|||||||
}
|
}
|
||||||
|
|
||||||
func configKitDiagnostics(scope string, sources []configkit.SourceInfo, records []configkit.Diagnostic) ([]commandkit.Diagnostic, commandkit.Explanation) {
|
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 AdaptDiagnostics("Configuration scope "+strconv.Quote(scope)+":\n", sources, records)
|
||||||
}
|
}
|
||||||
|
|
||||||
func configKitComparisonDiagnostics(scope, side string, sources []configkit.SourceInfo, records []configkit.Diagnostic) ([]commandkit.Diagnostic, commandkit.Explanation) {
|
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 AdaptDiagnostics("Configuration scope "+strconv.Quote(scope)+" "+side+":\n", sources, records)
|
||||||
}
|
}
|
||||||
|
|
||||||
func configKitDiagnosticsWithHeading(heading string, sources []configkit.SourceInfo, records []configkit.Diagnostic) ([]commandkit.Diagnostic, commandkit.Explanation) {
|
// AdaptDiagnostics captures ConfigKit diagnostics as CLI presentation evidence.
|
||||||
|
// It does not print, acquire configuration, determine outcomes, or schedule messages.
|
||||||
|
// The heading is used as supplied when the captured evidence is rendered.
|
||||||
|
func AdaptDiagnostics(heading string, sources []configkit.SourceInfo, records []configkit.Diagnostic) ([]commandkit.Diagnostic, commandkit.Explanation) {
|
||||||
if len(records) == 0 {
|
if len(records) == 0 {
|
||||||
return nil, commandkit.Explanation{}
|
return nil, commandkit.Explanation{}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,7 +63,9 @@ type ComparisonRequest[I any] struct {
|
|||||||
Right ComparisonSide[I]
|
Right ComparisonSide[I]
|
||||||
}
|
}
|
||||||
|
|
||||||
// EffectiveWriter writes a configuration's native effective representation.
|
// EffectiveWriter writes one safe YAML document for conversion by
|
||||||
|
// commandkit.DecodeYAMLReport. Its formatting, comments, and framing are not
|
||||||
|
// passed through to the resulting report.
|
||||||
type EffectiveWriter interface {
|
type EffectiveWriter interface {
|
||||||
WriteEffective(io.Writer) error
|
WriteEffective(io.Writer) error
|
||||||
}
|
}
|
||||||
@@ -73,14 +75,12 @@ type SourceReader interface {
|
|||||||
Sources() []configkit.SourceInfo
|
Sources() []configkit.SourceInfo
|
||||||
}
|
}
|
||||||
|
|
||||||
// ExplanationReader reads and writes a configuration's native path explanation.
|
// ExplanationReader acquires a configuration's native path explanation.
|
||||||
type ExplanationReader interface {
|
type ExplanationReader interface {
|
||||||
Explain(configkit.Path) (configkit.Explanation, bool)
|
Explain(configkit.Path) (configkit.Explanation, bool)
|
||||||
WriteExplanation(io.Writer, configkit.Path) error
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// View exposes native configuration facets without reconstructing their
|
// View exposes native configuration evidence used to build reports.
|
||||||
// reports.
|
|
||||||
type View struct {
|
type View struct {
|
||||||
Effective EffectiveWriter
|
Effective EffectiveWriter
|
||||||
Sources SourceReader
|
Sources SourceReader
|
||||||
|
|||||||
@@ -117,13 +117,6 @@ func TestFromConfigKitPassesInvocationValuesAndNativeFacets(t *testing.T) {
|
|||||||
if _, ok := acquired.Value.Explanation.Explain(path); !ok {
|
if _, ok := acquired.Value.Explanation.Explain(path); !ok {
|
||||||
t.Fatal("native explanation facet did not resolve /name")
|
t.Fatal("native explanation facet did not resolve /name")
|
||||||
}
|
}
|
||||||
var explanation bytes.Buffer
|
|
||||||
if err := acquired.Value.Explanation.WriteExplanation(&explanation, path); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if explanation.Len() == 0 {
|
|
||||||
t.Fatal("native explanation writer produced no output")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFromConfigKitRetainsLoadErrorAndDiagnostics(t *testing.T) {
|
func TestFromConfigKitRetainsLoadErrorAndDiagnostics(t *testing.T) {
|
||||||
@@ -140,8 +133,12 @@ func TestFromConfigKitRetainsLoadErrorAndDiagnostics(t *testing.T) {
|
|||||||
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")
|
||||||
}
|
}
|
||||||
|
partial, err := configkit.Load[struct{}](configkit.Mapping("unrelated result", map[string]any{}))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
scope := FromConfigKit[struct{}, struct{}, struct{}]("app", "", func(context.Context, Selection, struct{}, struct{}) (*configkit.Result[struct{}], error) {
|
scope := FromConfigKit[struct{}, struct{}, struct{}]("app", "", func(context.Context, Selection, struct{}, struct{}) (*configkit.Result[struct{}], error) {
|
||||||
return nil, loadErr
|
return partial, loadErr
|
||||||
})
|
})
|
||||||
acquired, gotErr := scope.Acquire(context.Background(), Request{}, struct{}{}, struct{}{})
|
acquired, gotErr := scope.Acquire(context.Background(), Request{}, struct{}{}, struct{}{})
|
||||||
if gotErr != loadErr {
|
if gotErr != loadErr {
|
||||||
@@ -157,6 +154,9 @@ func TestFromConfigKitRetainsLoadErrorAndDiagnostics(t *testing.T) {
|
|||||||
if acquired.Explanation.Message != "" || len(acquired.Explanation.Details) != 1 {
|
if acquired.Explanation.Message != "" || len(acquired.Explanation.Details) != 1 {
|
||||||
t.Fatalf("failure explanation = %#v", acquired.Explanation)
|
t.Fatalf("failure explanation = %#v", acquired.Explanation)
|
||||||
}
|
}
|
||||||
|
if !reflect.DeepEqual(acquired.Value.Sources.Sources(), typed.Sources()) || !reflect.DeepEqual(acquired.Value.Findings.Records, typed.Diagnostics()) {
|
||||||
|
t.Fatal("LoadError snapshot was replaced or duplicated")
|
||||||
|
}
|
||||||
var warning, terminal bytes.Buffer
|
var warning, terminal bytes.Buffer
|
||||||
if err := acquired.Value.Findings.Messages[0].Write(&warning); err != nil {
|
if err := acquired.Value.Findings.Messages[0].Write(&warning); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -175,43 +175,83 @@ func TestFromConfigKitRetainsLoadErrorAndDiagnostics(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFromConfigKitDoesNotExposeResultFromFailedLoad(t *testing.T) {
|
func TestFromConfigKitRetainsResultEvidenceWithApplicationFailure(t *testing.T) {
|
||||||
result, err := adapterSettingsLoad(configkit.Mapping("partial source", map[string]any{"greeting": "Hello"}))
|
result, err := adapterSettingsLoad(configkit.Mapping("partial source", map[string]any{"greeting": "Hello"}))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
loadErr := errors.New("loader returned a value with an error")
|
for _, cause := range []error{errors.New("post-load check failed"), context.Canceled} {
|
||||||
scope := FromConfigKit[struct{}, struct{}, adapterSettings]("app", "", func(context.Context, Selection, struct{}, struct{}) (*configkit.Result[adapterSettings], error) {
|
t.Run(cause.Error(), func(t *testing.T) {
|
||||||
return result, loadErr
|
scope := FromConfigKit("app", "", func(context.Context, Selection, struct{}, struct{}) (*configkit.Result[adapterSettings], error) {
|
||||||
|
return result, cause
|
||||||
})
|
})
|
||||||
|
|
||||||
acquired, gotErr := scope.Acquire(context.Background(), Request{}, struct{}{}, struct{}{})
|
acquired, gotErr := scope.Acquire(context.Background(), Request{}, struct{}{}, struct{}{})
|
||||||
if gotErr != loadErr {
|
if gotErr != cause || acquired.Value.Effective != nil || acquired.Value.Metadata != nil || acquired.Value.Explanation != nil || acquired.Value.Sources == nil || acquired.Value.Findings == nil {
|
||||||
t.Fatalf("load error identity = %v, want %v", gotErr, loadErr)
|
t.Fatalf("failed acquisition = %#v, %v", acquired, gotErr)
|
||||||
}
|
}
|
||||||
if acquired.Value.Effective != nil || acquired.Value.Metadata != nil || acquired.Value.Sources != nil || acquired.Value.Explanation != nil || acquired.Value.Findings != nil {
|
if !reflect.DeepEqual(acquired.Value.Sources.Sources(), result.Sources()) || !reflect.DeepEqual(acquired.Value.Findings.Records, result.Diagnostics()) || len(acquired.Value.Findings.Messages) != 1 || len(acquired.Explanation.Details) != 0 {
|
||||||
t.Fatalf("failed load exposed result facets: %#v", acquired.Value)
|
t.Fatalf("lost or duplicated result evidence: %#v", acquired)
|
||||||
|
}
|
||||||
|
app := newConfigTestApp(t, Command(Suite[struct{}, struct{}]{Scopes: []Scope[struct{}, struct{}]{scope}}))
|
||||||
|
for _, args := range [][]string{{"config", "sources", "--format=json"}, {"config", "validate", "--details", "--format=json"}, {"config", "validate", "--verbose"}} {
|
||||||
|
var out, diag bytes.Buffer
|
||||||
|
got := app.Execute(context.Background(), args, commandkit.Streams{Out: &out, Err: &diag})
|
||||||
|
if !errors.Is(got.Cause, cause) {
|
||||||
|
t.Fatalf("lost cause: %#v", got)
|
||||||
|
}
|
||||||
|
if args[1] == "sources" {
|
||||||
|
if !strings.Contains(out.String(), "partial source") {
|
||||||
|
t.Fatalf("lost sources: %s", out.String())
|
||||||
|
}
|
||||||
|
} else if strings.Contains(strings.Join(args, " "), "--details") {
|
||||||
|
if !strings.Contains(out.String(), result.Diagnostics()[0].Message) || strings.Contains(diag.String(), result.Diagnostics()[0].Message) {
|
||||||
|
t.Fatalf("detailed evidence: output=%s diag=%s", out.String(), diag.String())
|
||||||
|
}
|
||||||
|
} else if !strings.Contains(diag.String(), result.Diagnostics()[0].Message) {
|
||||||
|
t.Fatalf("lost ordinary warning: %s", diag.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
writeErr := errors.New("output failed")
|
||||||
|
got := app.Execute(context.Background(), []string{"config", "validate", "--details"}, commandkit.Streams{Out: failingOutput{err: writeErr}, Err: io.Discard})
|
||||||
|
if !errors.Is(got.Cause, cause) || !errors.Is(got.Presentation.Output.WriteError, writeErr) {
|
||||||
|
t.Fatalf("lost operation/presentation cause: %#v", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
empty, err := configkit.Load[map[string]any](configkit.Mapping("empty", map[string]any{}))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, result := range []*configkit.Result[map[string]any]{nil, empty} {
|
||||||
|
cause := errors.New("application failure")
|
||||||
|
scope := FromConfigKit("app", "", func(context.Context, Selection, struct{}, struct{}) (*configkit.Result[map[string]any], error) {
|
||||||
|
return result, cause
|
||||||
|
})
|
||||||
|
got, err := scope.Acquire(context.Background(), Request{}, struct{}{}, struct{}{})
|
||||||
|
if err != cause || (got.Value.Findings == nil) != (result == nil) {
|
||||||
|
t.Fatalf("unavailable/empty evidence: %#v %v", got, err)
|
||||||
}
|
}
|
||||||
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, noExplanation := configKitDiagnostics("app", nil, []configkit.Diagnostic{{Severity: configkit.SeverityWarning, Message: "safe warning"}})
|
warning, noExplanation := AdaptDiagnostics("scope:\n", 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)
|
||||||
}
|
}
|
||||||
if noExplanation.Message != "" || len(noExplanation.Details) != 0 {
|
if noExplanation.Message != "" || len(noExplanation.Details) != 0 {
|
||||||
t.Fatalf("warning-only explanation = %#v", noExplanation)
|
t.Fatalf("warning-only explanation = %#v", noExplanation)
|
||||||
}
|
}
|
||||||
withWarning, withExplanation := configKitDiagnostics("app", nil, []configkit.Diagnostic{
|
withWarning, withExplanation := AdaptDiagnostics("scope:\n", 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 len(withWarning) != 1 || withWarning[0].Severity != commandkit.SeverityWarning || len(withExplanation.Details) != 1 {
|
if len(withWarning) != 1 || withWarning[0].Severity != commandkit.SeverityWarning || len(withExplanation.Details) != 1 {
|
||||||
t.Fatalf("mixed diagnostics = %#v, explanation = %#v", withWarning, withExplanation)
|
t.Fatalf("mixed diagnostics = %#v, explanation = %#v", withWarning, withExplanation)
|
||||||
}
|
}
|
||||||
|
if empty, explanation := AdaptDiagnostics("unused", nil, nil); len(empty) != 0 || len(explanation.Details) != 0 {
|
||||||
|
t.Fatalf("empty evidence = %#v, %#v", empty, explanation)
|
||||||
|
}
|
||||||
|
|
||||||
if err := withWarning[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)
|
||||||
@@ -243,10 +283,11 @@ func TestConfigKitTerminalContextRetainsRelatedLocationsAndSnapshots(t *testing.
|
|||||||
Primary: primary,
|
Primary: primary,
|
||||||
Related: related,
|
Related: related,
|
||||||
}}
|
}}
|
||||||
_, explanation := configKitDiagnostics("app", sources, records)
|
_, explanation := AdaptDiagnostics("Configuration scope \"app\":\n", sources, records)
|
||||||
primary.SourceID = "mutated-primary"
|
primary.SourceID = "mutated-primary"
|
||||||
related[0].SourceID = "mutated-related"
|
related[0].SourceID = "mutated-related"
|
||||||
sources[0].Path = "mutated.yml"
|
sources[0].Path = "mutated.yml"
|
||||||
|
records[0].Message = "mutated message"
|
||||||
|
|
||||||
if len(explanation.Details) != 1 {
|
if len(explanation.Details) != 1 {
|
||||||
t.Fatalf("explanation = %#v", explanation)
|
t.Fatalf("explanation = %#v", explanation)
|
||||||
@@ -260,7 +301,7 @@ func TestConfigKitTerminalContextRetainsRelatedLocationsAndSnapshots(t *testing.
|
|||||||
t.Errorf("terminal context %q omits %q", 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") {
|
if strings.Contains(output.String(), "mutated-") || strings.Contains(output.String(), "mutated.yml") || strings.Contains(output.String(), "mutated message") {
|
||||||
t.Fatalf("terminal renderer retained caller mutations: %q", output.String())
|
t.Fatalf("terminal renderer retained caller mutations: %q", output.String())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,6 +60,44 @@ func TestDetailedValidationFiltersAndRetainsFailureEvidence(t *testing.T) {
|
|||||||
if strings.Contains(diag.String(), "native warnings") || !strings.Contains(diag.String(), "application warning") || !strings.Contains(diag.String(), "terminal failure") {
|
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())
|
t.Fatalf("diagnostics=%q", diag.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
flags []string
|
||||||
|
first, second []string
|
||||||
|
}{
|
||||||
|
{"any within filters", []string{"--severity=warning", "--severity=error", "--code=chosen", "--code=other", "--code=failed"}, []string{"selected warning", "other warning"}, []string{"failed finding"}},
|
||||||
|
{"duplicates and unknown", []string{"--severity=warning", "--severity=warning", "--code=chosen", "--code=chosen", "--code=future-code"}, []string{"selected warning"}, nil},
|
||||||
|
{"both filters", []string{"--severity=error", "--code=chosen", "--code=failed"}, nil, []string{"failed finding"}},
|
||||||
|
{"no matching code", []string{"--code=future-code"}, nil, nil},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
var out bytes.Buffer
|
||||||
|
args := append([]string{"config", "validate", "--scope=all", "--details", "--format=json"}, tc.flags...)
|
||||||
|
got := app.Execute(context.Background(), args, 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(got.Cause, cause) || len(report.Scopes) != 2 {
|
||||||
|
t.Fatalf("outcome=%#v report=%#v", got, report)
|
||||||
|
}
|
||||||
|
for index, want := range [][]string{tc.first, tc.second} {
|
||||||
|
details := report.Scopes[index].Details
|
||||||
|
if len(details.Findings) != len(want) {
|
||||||
|
t.Fatalf("findings=%#v want=%v", details.Findings, want)
|
||||||
|
}
|
||||||
|
for i, message := range want {
|
||||||
|
if details.Findings[i].Message != message {
|
||||||
|
t.Fatalf("finding order=%#v want=%v", details.Findings, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(want) == 0 && details.State != "filtered" {
|
||||||
|
t.Fatalf("state=%q", details.State)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
before := len(calls)
|
before := len(calls)
|
||||||
for _, args := range [][]string{{"--severity=warning"}, {"--code=x"}, {"--details", "--severity=unknown"}} {
|
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})
|
result = app.Execute(context.Background(), append([]string{"config", "validate"}, args...), commandkit.Streams{Out: io.Discard, Err: io.Discard})
|
||||||
|
|||||||
@@ -8,39 +8,17 @@ import (
|
|||||||
configkit "go.maximumdirect.net/configkit"
|
configkit "go.maximumdirect.net/configkit"
|
||||||
)
|
)
|
||||||
|
|
||||||
func decodeJSONPointer(pointer string) ([]string, error) {
|
func decodeJSONPointer(path configkit.Path) []string {
|
||||||
|
pointer := path.String()
|
||||||
if pointer == "" {
|
if pointer == "" {
|
||||||
return nil, nil
|
return nil
|
||||||
}
|
}
|
||||||
if pointer[0] != '/' {
|
|
||||||
return nil, fmt.Errorf("decode JSON pointer %q: path must start with /", pointer)
|
|
||||||
}
|
|
||||||
|
|
||||||
parts := strings.Split(pointer[1:], "/")
|
parts := strings.Split(pointer[1:], "/")
|
||||||
|
decoder := strings.NewReplacer("~1", "/", "~0", "~")
|
||||||
for index, part := range parts {
|
for index, part := range parts {
|
||||||
var decoded strings.Builder
|
parts[index] = decoder.Replace(part)
|
||||||
decoded.Grow(len(part))
|
|
||||||
for offset := 0; offset < len(part); offset++ {
|
|
||||||
if part[offset] != '~' {
|
|
||||||
decoded.WriteByte(part[offset])
|
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
if offset+1 >= len(part) {
|
return parts
|
||||||
return nil, fmt.Errorf("decode JSON pointer %q: incomplete escape in token %d", pointer, index)
|
|
||||||
}
|
|
||||||
offset++
|
|
||||||
switch part[offset] {
|
|
||||||
case '0':
|
|
||||||
decoded.WriteByte('~')
|
|
||||||
case '1':
|
|
||||||
decoded.WriteByte('/')
|
|
||||||
default:
|
|
||||||
return nil, fmt.Errorf("decode JSON pointer %q: invalid escape in token %d", pointer, index)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
parts[index] = decoded.String()
|
|
||||||
}
|
|
||||||
return parts, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func placeReportValue(root any, path []string, value any) (any, error) {
|
func placeReportValue(root any, path []string, value any) (any, error) {
|
||||||
@@ -135,10 +113,7 @@ func renderDiffReport(differences []configkit.Difference) (any, error) {
|
|||||||
if !difference.Right.Present {
|
if !difference.Right.Present {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
tokens, err := decodeJSONPointer(path)
|
tokens := decodeJSONPointer(difference.Path)
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("difference at %q: %w", path, err)
|
|
||||||
}
|
|
||||||
value, err := commandkit.DecodeYAMLReport(strings.NewReader(difference.Right.YAML))
|
value, err := commandkit.DecodeYAMLReport(strings.NewReader(difference.Right.YAML))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("difference at %q: %w", path, err)
|
return nil, fmt.Errorf("difference at %q: %w", path, err)
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ package ckconfig
|
|||||||
import (
|
import (
|
||||||
"reflect"
|
"reflect"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
configkit "go.maximumdirect.net/configkit"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestDecodeJSONPointerPreservesSemanticTokens(t *testing.T) {
|
func TestDecodeJSONPointerPreservesSemanticTokens(t *testing.T) {
|
||||||
@@ -16,21 +18,18 @@ func TestDecodeJSONPointerPreservesSemanticTokens(t *testing.T) {
|
|||||||
{pointer: "/0/../a.b/50%", want: []string{"0", "..", "a.b", "50%"}},
|
{pointer: "/0/../a.b/50%", want: []string{"0", "..", "a.b", "50%"}},
|
||||||
{pointer: "/a~1b/c~0d", want: []string{"a/b", "c~d"}},
|
{pointer: "/a~1b/c~0d", want: []string{"a/b", "c~d"}},
|
||||||
{pointer: "/~01", want: []string{"~1"}},
|
{pointer: "/~01", want: []string{"~1"}},
|
||||||
|
{pointer: "/café", want: []string{"café"}},
|
||||||
}
|
}
|
||||||
for _, test := range tests {
|
for _, test := range tests {
|
||||||
got, err := decodeJSONPointer(test.pointer)
|
path, err := configkit.ParsePath(test.pointer)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("decode %q: %v", test.pointer, err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
got := decodeJSONPointer(path)
|
||||||
if !reflect.DeepEqual(got, test.want) {
|
if !reflect.DeepEqual(got, test.want) {
|
||||||
t.Errorf("decode %q = %#v, want %#v", test.pointer, got, test.want)
|
t.Errorf("decode %q = %#v, want %#v", test.pointer, got, test.want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for _, pointer := range []string{"missing-leading-slash", "/bad~", "/bad~2escape"} {
|
|
||||||
if _, err := decodeJSONPointer(pointer); err == nil {
|
|
||||||
t.Errorf("decode %q unexpectedly succeeded", pointer)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestPlaceReportValue(t *testing.T) {
|
func TestPlaceReportValue(t *testing.T) {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"io"
|
"io"
|
||||||
|
"reflect"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -16,7 +17,7 @@ func TestExplainDiscoveryModesAndMetadata(t *testing.T) {
|
|||||||
loads, inputs, states := 0, 0, 0
|
loads, inputs, states := 0, 0, 0
|
||||||
scope := FromConfigKit("app", "", func(_ context.Context, selection Selection, _, _ struct{}) (*configkit.Result[map[string]any], error) {
|
scope := FromConfigKit("app", "", func(_ context.Context, selection Selection, _, _ struct{}) (*configkit.Result[map[string]any], error) {
|
||||||
loads++
|
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})
|
return configkit.Load(configkit.Mapping("fixture", map[string]any{"a/b": map[string]any{"x": 1}, "a~b": 4, "ab": 2, "abacus": 3, "gone": map[string]any{"x": 1}}), configkit.Options[map[string]any]{Overrides: selection.Overrides})
|
||||||
})
|
})
|
||||||
scope.Features.Overrides = true
|
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}}))
|
app := newConfigTestApp(t, Command(Suite[struct{}, struct{}]{NewInput: func() struct{} { inputs++; return struct{}{} }, NewState: func() struct{} { states++; return struct{}{} }, Scopes: []Scope[struct{}, struct{}]{scope}}))
|
||||||
@@ -37,7 +38,11 @@ func TestExplainDiscoveryModesAndMetadata(t *testing.T) {
|
|||||||
}
|
}
|
||||||
result, out = run("--list", "--under=/a~1b", "--format=json")
|
result, out = run("--list", "--under=/a~1b", "--format=json")
|
||||||
var report pathDiscoveryReport
|
var report pathDiscoveryReport
|
||||||
if result.Kind != commandkit.OutcomeSuccess || json.Unmarshal([]byte(out), &report) != nil || len(report.Paths) != 2 {
|
decode := func(output string) error {
|
||||||
|
report = pathDiscoveryReport{}
|
||||||
|
return json.Unmarshal([]byte(output), &report)
|
||||||
|
}
|
||||||
|
if result.Kind != commandkit.OutcomeSuccess || decode(out) != nil || len(report.Paths) != 2 {
|
||||||
t.Fatalf("escaped subtree: %#v %q", result, out)
|
t.Fatalf("escaped subtree: %#v %q", result, out)
|
||||||
}
|
}
|
||||||
seen := map[string]bool{}
|
seen := map[string]bool{}
|
||||||
@@ -48,11 +53,46 @@ func TestExplainDiscoveryModesAndMetadata(t *testing.T) {
|
|||||||
t.Fatalf("escaped paths: %#v", report.Paths)
|
t.Fatalf("escaped paths: %#v", report.Paths)
|
||||||
}
|
}
|
||||||
result, out = run("--list", "--under=/ab", "--format=json")
|
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" {
|
if result.Kind != commandkit.OutcomeSuccess || decode(out) != nil || len(report.Paths) != 1 || report.Paths[0].Path != "/ab" {
|
||||||
t.Fatalf("token boundary: %#v %q", result, out)
|
t.Fatalf("token boundary: %#v %q", result, out)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
prefix string
|
||||||
|
paths []string
|
||||||
|
}{
|
||||||
|
{"/a~1b/x", []string{"/a~1b/x"}},
|
||||||
|
{"/a~0b", []string{"/a~0b"}},
|
||||||
|
{"/a", nil},
|
||||||
|
{"/a~1b/x/unknown", nil},
|
||||||
|
{"/ab/" + strings.Repeat("too-deep/", 20) + "unknown", nil},
|
||||||
|
} {
|
||||||
|
result, out = run("--list", "--under="+tc.prefix, "--format=json")
|
||||||
|
if result.Status != 0 || decode(out) != nil {
|
||||||
|
t.Fatalf("prefix %q: %#v %s", tc.prefix, result, out)
|
||||||
|
}
|
||||||
|
var paths []string
|
||||||
|
for _, entry := range report.Paths {
|
||||||
|
paths = append(paths, entry.Path)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(paths, tc.paths) {
|
||||||
|
t.Fatalf("prefix %q: paths=%v want=%v", tc.prefix, paths, tc.paths)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result, baseline := run("--list", "--format=json")
|
||||||
|
if result.Status != 0 {
|
||||||
|
t.Fatalf("unfiltered discovery=%#v", result)
|
||||||
|
}
|
||||||
|
var all pathDiscoveryReport
|
||||||
|
if err := json.Unmarshal([]byte(baseline), &all); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
result, out = run("--list", "--under=", "--format=json")
|
||||||
|
if result.Status != 0 || decode(out) != nil || !reflect.DeepEqual(report, all) {
|
||||||
|
t.Fatalf("root subtree changed discovery: %#v %s", result, out)
|
||||||
|
}
|
||||||
result, out = run("--list", "--historical", "--set=/gone=null", "--format=json")
|
result, out = run("--list", "--historical", "--set=/gone=null", "--format=json")
|
||||||
if result.Kind != commandkit.OutcomeSuccess || json.Unmarshal([]byte(out), &report) != nil {
|
if result.Kind != commandkit.OutcomeSuccess || decode(out) != nil {
|
||||||
t.Fatalf("historical: %#v %q", result, out)
|
t.Fatalf("historical: %#v %q", result, out)
|
||||||
}
|
}
|
||||||
removed := false
|
removed := false
|
||||||
@@ -64,8 +104,8 @@ func TestExplainDiscoveryModesAndMetadata(t *testing.T) {
|
|||||||
if !removed {
|
if !removed {
|
||||||
t.Fatalf("missing historical entry: %q", out)
|
t.Fatalf("missing historical entry: %q", out)
|
||||||
}
|
}
|
||||||
result, out = run("--list", "--historical", "--explicit=false", "--set=/gone=null", "--format=json")
|
result, out = run("--list", "--under=/gone", "--historical", "--explicit=false", "--set=/gone=null", "--format=json")
|
||||||
if result.Kind != commandkit.OutcomeSuccess || json.Unmarshal([]byte(out), &report) != nil {
|
if result.Kind != commandkit.OutcomeSuccess || decode(out) != nil {
|
||||||
t.Fatalf("explicit filter: %#v %q", result, out)
|
t.Fatalf("explicit filter: %#v %q", result, out)
|
||||||
}
|
}
|
||||||
for _, entry := range report.Paths {
|
for _, entry := range report.Paths {
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
// Package ckconfig, provided by go.maximumdirect.net/ckconfig, provides
|
// Package ckconfig adds commands to view, validate, explain, and compare
|
||||||
// CommandKit's optional ConfigKit v1.1.0 integration, requiring Go 1.27.0. Its
|
// ConfigKit configuration in CommandKit applications.
|
||||||
// canonical Command declaration installs the standard config command family
|
//
|
||||||
// through the core commandkit package. Applications can install CommandKit
|
// Use AppCommand for one application configuration loader, or Command with a
|
||||||
// RoutingRule declarations alongside it through commandkit.New or
|
// Suite for multiple named configurations and application-specific flags.
|
||||||
// App.Register. Source reports preserve ConfigKit's destination, placement,
|
// Install the returned command with commandkit.New or App.Register.
|
||||||
// version, and empty-import evidence. Invocation reporting applies CommandKit's shared
|
// Importing this package alone installs no commands.
|
||||||
// visibility and delivery policy; provider warnings remain independent of
|
//
|
||||||
// failure explanations. Importing ckconfig alone installs no commands.
|
// ConfigKit owns configuration loading and interpretation. CommandKit formats
|
||||||
|
// reports and messages using its shared reporting policy.
|
||||||
package ckconfig
|
package ckconfig
|
||||||
|
|||||||
@@ -116,8 +116,9 @@ root): `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
|
||||||
the core module. The release policy uses baseline
|
the core module. 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
|
||||||
@@ -128,12 +129,16 @@ 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).
|
||||||
|
|
||||||
## go-yaml v3
|
## go-yaml v3
|
||||||
|
|
||||||
Selected module: `gopkg.in/yaml.v3` at `v3.0.1` (transitive dependency).
|
Selected module: `gopkg.in/yaml.v3` at `v3.0.1` (transitive dependency).
|
||||||
|
|||||||
24
integrations/ckconfig/docs/releases/v0.4.0.md
Normal file
24
integrations/ckconfig/docs/releases/v0.4.0.md
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# CommandKit ckconfig v0.4.0
|
||||||
|
|
||||||
|
CommandKit ckconfig v0.4.0 requires `go.maximumdirect.net/commandkit v0.4.0`,
|
||||||
|
ConfigKit v1.1.0, and Go 1.27.0. Configuration commands use core's shared
|
||||||
|
text, YAML, and JSON reporting and universal `--format` flag.
|
||||||
|
|
||||||
|
Scopes can expose metadata-backed path discovery and focused values, filtered
|
||||||
|
source evidence, and detailed validation and comparison reports. Validation
|
||||||
|
retains available evidence after a failed load and reports reached scopes in
|
||||||
|
one result. Aggregate configuration display is one scope-keyed mapping.
|
||||||
|
Applications opt into transient CLI overrides only when their loader forwards
|
||||||
|
them to ConfigKit; `--set` and `--against-set` select the comparison sides.
|
||||||
|
Application-declared right-side selectors let each side use independent
|
||||||
|
inputs. `config diff --check` returns status 3 when native differences exist.
|
||||||
|
|
||||||
|
This pre-1.0 minor release changes configuration report formatting and
|
||||||
|
aggregate framing, and comparison selectors now apply independently to each
|
||||||
|
side. Update callers and report consumers to the current
|
||||||
|
[CLI reference](../../../../docs/cli.md#configuration-scopes) and
|
||||||
|
[consumer guide](../../../../docs/consumers/guide.md#configkit-scopes).
|
||||||
|
|
||||||
|
[COPYRIGHT](https://forge.maximumdirect.net/base-layer/commandkit/src/tag/integrations/ckconfig/v0.4.0/integrations/ckconfig/COPYRIGHT) |
|
||||||
|
[LICENSE](https://forge.maximumdirect.net/base-layer/commandkit/src/tag/integrations/ckconfig/v0.4.0/integrations/ckconfig/LICENSE) |
|
||||||
|
[ATTRIBUTION](https://forge.maximumdirect.net/base-layer/commandkit/src/tag/integrations/ckconfig/v0.4.0/integrations/ckconfig/docs/oss/attribution.md)
|
||||||
39
integrations/ckconfig/docs/releases/v0.5.0.md
Normal file
39
integrations/ckconfig/docs/releases/v0.5.0.md
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
# CommandKit ckconfig v0.5.0
|
||||||
|
|
||||||
|
CommandKit ckconfig v0.5.0 improves configuration explanations, preserves
|
||||||
|
available inspection evidence after loader failures, and uses CommandKit core
|
||||||
|
v0.5.0 for reporting and input handling. ConfigKit remains at v1.1.0, and the
|
||||||
|
consumer minimum remains Go 1.27.0.
|
||||||
|
|
||||||
|
## Changes
|
||||||
|
|
||||||
|
- Configuration explanations render the same acquired evidence in text, YAML,
|
||||||
|
and JSON, including available field metadata, history, and source labels.
|
||||||
|
- Failed loads retain available ConfigKit result evidence for inspection.
|
||||||
|
Cancellation during aggregate configuration display remains an operation
|
||||||
|
failure rather than being reported as a rendering failure.
|
||||||
|
- The new `AdaptDiagnostics` helper captures native ConfigKit diagnostics for
|
||||||
|
CLI presentation. Both standard integrations use the same adapter.
|
||||||
|
- Validation and path discovery avoid rebuilding filter sets and subtree
|
||||||
|
prefixes repeatedly. Scope setup and operation preparation are simpler,
|
||||||
|
preserving independent comparison inputs and existing CLI behavior.
|
||||||
|
|
||||||
|
## Compatibility and upgrade
|
||||||
|
|
||||||
|
Select `go.maximumdirect.net/ckconfig v0.5.0` with CommandKit core v0.5.0.
|
||||||
|
Custom `ExplanationReader` implementations need only `Explain`; the interface
|
||||||
|
no longer requires `WriteExplanation`. CommandKit renders the acquired evidence
|
||||||
|
instead of invoking a provider's text writer. Applications that customized that
|
||||||
|
writer should review their text output.
|
||||||
|
|
||||||
|
Effective configuration writers supply one safe YAML document. Conversion
|
||||||
|
preserves resolved scalar types rather than formatting or comments. Core
|
||||||
|
v0.5.0 preserves numeric-looking strings and explicit numeric tags; review
|
||||||
|
callers that relied on automatic conversion of strings into numbers.
|
||||||
|
|
||||||
|
See the [configuration CLI reference](../../../../docs/cli.md#configuration-scopes)
|
||||||
|
and [consumer guide](../../../../docs/consumers/guide.md#configkit-scopes).
|
||||||
|
|
||||||
|
[COPYRIGHT](https://forge.maximumdirect.net/base-layer/commandkit/src/tag/integrations/ckconfig/v0.5.0/integrations/ckconfig/COPYRIGHT) |
|
||||||
|
[LICENSE](https://forge.maximumdirect.net/base-layer/commandkit/src/tag/integrations/ckconfig/v0.5.0/integrations/ckconfig/LICENSE) |
|
||||||
|
[ATTRIBUTION](https://forge.maximumdirect.net/base-layer/commandkit/src/tag/integrations/ckconfig/v0.5.0/integrations/ckconfig/docs/oss/attribution.md)
|
||||||
16
integrations/ckconfig/docs/releases/v0.6.0.md
Normal file
16
integrations/ckconfig/docs/releases/v0.6.0.md
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
# CommandKit ckconfig v0.6.0
|
||||||
|
|
||||||
|
CommandKit ckconfig v0.6.0 selects CommandKit core v0.6.0, which supports
|
||||||
|
shared debug input bindings and access to the selected reporting mode.
|
||||||
|
ConfigKit remains at v1.1.0.
|
||||||
|
|
||||||
|
## Compatibility and upgrade
|
||||||
|
|
||||||
|
Select `go.maximumdirect.net/ckconfig v0.6.0` with CommandKit core v0.6.0.
|
||||||
|
The consumer minimum remains Go 1.27.0. See the
|
||||||
|
[consumer guide](../../../../docs/consumers/guide.md#invocation-reporting)
|
||||||
|
for the new core reporting and debug input APIs.
|
||||||
|
|
||||||
|
[COPYRIGHT](https://forge.maximumdirect.net/base-layer/commandkit/src/tag/integrations/ckconfig/v0.6.0/integrations/ckconfig/COPYRIGHT) |
|
||||||
|
[LICENSE](https://forge.maximumdirect.net/base-layer/commandkit/src/tag/integrations/ckconfig/v0.6.0/integrations/ckconfig/LICENSE) |
|
||||||
|
[ATTRIBUTION](https://forge.maximumdirect.net/base-layer/commandkit/src/tag/integrations/ckconfig/v0.6.0/integrations/ckconfig/docs/oss/attribution.md)
|
||||||
@@ -1,37 +1,90 @@
|
|||||||
# Simple consumer
|
# ConfigKit example
|
||||||
|
|
||||||
This example imports `go.maximumdirect.net/ckconfig` and
|
This example adds configuration inspection and a custom `greet` command to a
|
||||||
`go.maximumdirect.net/commandkit`; use ckconfig v0.3.0 with core
|
small application. It uses local fixtures and in-memory defaults, with no
|
||||||
v0.3.0, ConfigKit v1.1.0, and Go 1.27.0. Set `GOWORK=off GOTOOLCHAIN=local`
|
external services. See [module selection](../../../../docs/consumers/guide.md#module-selection)
|
||||||
for the commands below.
|
for dependencies and Go requirements. Use Go 1.27.0 on `PATH` for this checkout.
|
||||||
|
|
||||||
Run the maintained example with:
|
From the repository root:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
cd integrations/ckconfig/examples/simple
|
||||||
|
export GOWORK=off GOTOOLCHAIN=local
|
||||||
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
|
|
||||||
|
The output includes the default greeting, `Hello`, and schema version 2.
|
||||||
|
This example can run against its declared released dependencies with the
|
||||||
|
workspace disabled.
|
||||||
|
|
||||||
|
## Inspect and override a value
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# Discover configuration paths under schema.
|
||||||
go run . config explain --list --under /schema
|
go run . config explain --list --under /schema
|
||||||
|
# Change the greeting for this invocation and show just that value.
|
||||||
go run . config show --path /greeting --set '/greeting="Hi"'
|
go run . config show --path /greeting --set '/greeting="Hi"'
|
||||||
|
```
|
||||||
|
|
||||||
|
The second command prints `Hi`. It does not edit a file.
|
||||||
|
|
||||||
|
## Compare configurations
|
||||||
|
|
||||||
|
```sh
|
||||||
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
|
||||||
|
```
|
||||||
|
|
||||||
|
The report includes `changes.greeting: Goodbye`.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# Expected status 3: the overridden greeting differs from the ordinary default.
|
||||||
go run . config diff --set '/greeting="Hi"' --details --check
|
go run . config diff --set '/greeting="Hi"' --details --check
|
||||||
|
```
|
||||||
|
|
||||||
|
This compares the overridden left side with ordinary configuration on the
|
||||||
|
right. `go run` prints `exit status 3` and itself exits with status 1; a built
|
||||||
|
executable returns the application's status 3 directly.
|
||||||
|
|
||||||
|
## Inspect warnings and failed loads
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# Schema version 1 produces a warning, visible in verbose mode.
|
||||||
|
go run . config show --config fixtures/before.yml --verbose
|
||||||
|
# Quiet mode hides the configuration report and warning.
|
||||||
|
go run . config show --config fixtures/before.yml --quiet
|
||||||
|
```
|
||||||
|
|
||||||
|
Ordinary mode hides the warning but still displays configuration.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# Intentional failure: show detailed validation findings for the invalid fixture.
|
||||||
go run . config validate --config fixtures/invalid.yml --details --severity error
|
go run . config validate --config fixtures/invalid.yml --details --severity error
|
||||||
|
# Intentional failure: still show the source recorded before loading failed.
|
||||||
go run . config sources --config fixtures/invalid.yml --source-id source-1
|
go run . config sources --config fixtures/invalid.yml --source-id source-1
|
||||||
|
```
|
||||||
|
|
||||||
|
Both commands retain the load failure. `source-1` belongs to this fixture's
|
||||||
|
load; other configurations can have different source IDs. See the
|
||||||
|
[CLI reference](../../../../docs/cli.md#configuration-scopes) for report and status rules.
|
||||||
|
|
||||||
|
## Try a custom command
|
||||||
|
|
||||||
|
```sh
|
||||||
go run . greet
|
go run . greet
|
||||||
go run . greet --count=0 --tag=first --tag second
|
go run . greet --count=0 --tag=first --tag second
|
||||||
```
|
```
|
||||||
|
|
||||||
The `config` family comes from one typed ConfigKit scope. The custom `greet`
|
The handler reports its greeting, count, and tags. It distinguishes an omitted
|
||||||
command uses CommandKit's typed bindings and shows the difference between an
|
count from an explicitly supplied zero and preserves repeated tag order.
|
||||||
omitted `--count` and an explicitly supplied zero, plus ordered repeated tags.
|
|
||||||
With no `--config`, application settings use the in-memory default. A supplied
|
## How it is wired
|
||||||
configuration and overlay are passed to ConfigKit unchanged. The comparison
|
|
||||||
command loads the two local fixtures and reports `changes.greeting: Goodbye`.
|
One ConfigKit scope supplies the standard `config` commands. Without `--config`,
|
||||||
The before fixture uses schema version 1, which ConfigKit reports as a provider
|
its loader uses an in-memory default. Supplied configuration and overlay values
|
||||||
warning in verbose mode. Ordinary mode hides the warning while preserving the
|
are passed to ConfigKit unchanged. The scope enables temporary overrides and
|
||||||
effective YAML report; quiet mode suppresses that report.
|
passes them into its ordinary ConfigKit load. The
|
||||||
The invalid fixture demonstrates a failed-load report and captured source
|
[consumer guide](../../../../docs/consumers/guide.md#configkit-scopes) explains
|
||||||
inventory; `source-1` is the ID from that fixture's acquisition. The scope opts
|
loader and scope setup.
|
||||||
in to transient overrides and passes them into its normal ConfigKit load. The
|
|
||||||
diff example compares an overridden left side with ordinary configuration on
|
Run `GOWORK=off GOTOOLCHAIN=local go test ./...` from this example directory to
|
||||||
the right and returns status 3 because the values differ.
|
check it against its declared dependencies.
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package ckconfig
|
package ckconfig
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
"text/template"
|
"text/template"
|
||||||
@@ -18,6 +17,7 @@ type explanationReport struct {
|
|||||||
History []explanationChangeDTO `json:"history"`
|
History []explanationChangeDTO `json:"history"`
|
||||||
SecretReference *secretReferenceDTO `json:"secret_reference"`
|
SecretReference *secretReferenceDTO `json:"secret_reference"`
|
||||||
Metadata *explanationMetadata `json:"metadata,omitempty"`
|
Metadata *explanationMetadata `json:"metadata,omitempty"`
|
||||||
|
Sources []validationSource `json:"sources,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type explanationMetadata struct {
|
type explanationMetadata struct {
|
||||||
@@ -48,6 +48,29 @@ type pathMetadata struct {
|
|||||||
|
|
||||||
var pathDiscoveryTemplate = template.Must(commandkit.ParseTextTemplate("paths", "{{range .Paths}}{{printf \"%q\" .Path}} {{if .Present}}{{if .Metadata.Explicit}}explicit{{else}}non-explicit{{end}}{{else}}historical{{end}}\n{{end}}", nil))
|
var pathDiscoveryTemplate = template.Must(commandkit.ParseTextTemplate("paths", "{{range .Paths}}{{printf \"%q\" .Path}} {{if .Present}}{{if .Metadata.Explicit}}explicit{{else}}non-explicit{{end}}{{else}}historical{{end}}\n{{end}}", nil))
|
||||||
|
|
||||||
|
var explanationTemplate = template.Must(commandkit.ParseTextTemplate("explanation", `Scope: {{.Scope}}
|
||||||
|
Path: {{printf "%q" .Path}}
|
||||||
|
Effective: {{template "snapshot" .Effective}}
|
||||||
|
Origin: {{template "origin" .Origin}}
|
||||||
|
History:
|
||||||
|
{{range .History}} Before: {{template "snapshot" .Before}}
|
||||||
|
After: {{template "snapshot" .After}}
|
||||||
|
Origin: {{template "origin" .Origin}}
|
||||||
|
Reasons: {{range .Reasons}}{{printf "%q " .}}{{end}}
|
||||||
|
Dependencies: {{range .Dependencies}}{{printf "%q " .}}{{end}}
|
||||||
|
{{end}}{{with .SecretReference}}Secret reference: {{.Name}}
|
||||||
|
{{end}}{{with .Metadata}}{{if .Explicit}}Explicit: {{.Explicit}}
|
||||||
|
Null: {{.Null}}
|
||||||
|
{{end}}{{range .History}}Metadata history: {{printf "%q" .Path}}
|
||||||
|
{{range .Previous}} Previous origin: {{template "origin" .}}
|
||||||
|
{{end}}{{end}}{{end}}{{with .Sources}}Sources:
|
||||||
|
{{range .}} {{printf "%q" .ID}}: label={{printf "%q" .Label}} path={{printf "%q" .Path}}
|
||||||
|
{{end}}{{end}}{{define "snapshot"}}present={{.Present}} redacted={{.Redacted}} yaml={{printf "%q" .YAML}}{{end}}
|
||||||
|
{{- define "locations"}}{{range .}} {{printf "%q" .SourceID}}:{{.Line}}:{{.Column}}{{end}}{{end}}
|
||||||
|
{{- define "origin"}}layer={{printf "%q" .Layer}} operation={{printf "%q" .Operation}} locations:{{template "locations" .Locations}}
|
||||||
|
{{range .Inheritance}} Inheritance: from={{printf "%q" .From}} to={{printf "%q" .To}} reference={{printf "%q" .Reference}} locations:{{template "locations" .Locations}}
|
||||||
|
{{end}}{{end}}`, nil))
|
||||||
|
|
||||||
type snapshotDTO struct {
|
type snapshotDTO struct {
|
||||||
Present bool `json:"present"`
|
Present bool `json:"present"`
|
||||||
Redacted bool `json:"redacted"`
|
Redacted bool `json:"redacted"`
|
||||||
@@ -107,8 +130,13 @@ func runConfigExplanation[I, S any](invocation commandkit.Invocation, plan *conf
|
|||||||
return configFailure("Configuration scope did not provide path metadata.", acquired.Explanation, fmt.Errorf("configuration scope %q declared path discovery without metadata", name))
|
return configFailure("Configuration scope did not provide path metadata.", acquired.Explanation, fmt.Errorf("configuration scope %q declared path discovery without metadata", name))
|
||||||
}
|
}
|
||||||
report := pathDiscoveryReport{Scope: name, Paths: []pathDiscoveryEntry{}}
|
report := pathDiscoveryReport{Scope: name, Paths: []pathDiscoveryEntry{}}
|
||||||
|
var prefix []string
|
||||||
|
if plan.under != nil {
|
||||||
|
prefix = pointerTokens(plan.under.String())
|
||||||
|
}
|
||||||
for _, path := range acquired.Value.Metadata.HistoricalPaths() {
|
for _, path := range acquired.Value.Metadata.HistoricalPaths() {
|
||||||
if plan.under != nil && !pathWithin(path, *plan.under) {
|
pointer := path.String()
|
||||||
|
if plan.under != nil && !pathWithin(pointer, prefix) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
info, present := acquired.Value.Metadata.Lookup(path)
|
info, present := acquired.Value.Metadata.Lookup(path)
|
||||||
@@ -118,7 +146,7 @@ func runConfigExplanation[I, S any](invocation commandkit.Invocation, plan *conf
|
|||||||
if present && plan.explicit.Set && info.Explicit != plan.explicit.Value {
|
if present && plan.explicit.Set && info.Explicit != plan.explicit.Value {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
entry := pathDiscoveryEntry{Path: path.String(), Present: present}
|
entry := pathDiscoveryEntry{Path: pointer, Present: present}
|
||||||
if present {
|
if present {
|
||||||
entry.Metadata = &pathMetadata{Explicit: info.Explicit}
|
entry.Metadata = &pathMetadata{Explicit: info.Explicit}
|
||||||
}
|
}
|
||||||
@@ -157,31 +185,27 @@ func runConfigExplanation[I, S any](invocation commandkit.Invocation, plan *conf
|
|||||||
report.Metadata = metadata
|
report.Metadata = metadata
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
text := template.Must(commandkit.ParseTextTemplate("explanation", "{{render .}}", template.FuncMap{
|
if acquired.Value.Sources != nil {
|
||||||
"render": func(report explanationReport) (string, error) {
|
for _, source := range acquired.Value.Sources.Sources() {
|
||||||
var output bytes.Buffer
|
report.Sources = append(report.Sources, validationSource{ID: source.ID, Label: source.Label, Path: source.Path})
|
||||||
err := acquired.Value.Explanation.WriteExplanation(&output, plan.path)
|
|
||||||
if err == nil && report.Metadata != nil && report.Metadata.Explicit != nil {
|
|
||||||
_, err = fmt.Fprintf(&output, "Explicit: %t\nNull: %t\n", *report.Metadata.Explicit, *report.Metadata.Null)
|
|
||||||
}
|
}
|
||||||
return output.String(), err
|
}
|
||||||
},
|
renderErr := reporter.Report(commandkit.Report{Value: report, TextTemplate: explanationTemplate})
|
||||||
}))
|
|
||||||
renderErr := reporter.Report(commandkit.Report{Value: report, TextTemplate: text})
|
|
||||||
if renderErr != nil && invocation.Context.Err() != nil {
|
if renderErr != nil && invocation.Context.Err() != nil {
|
||||||
return configFailure("Configuration explanation was canceled.", acquired.Explanation, invocation.Context.Err())
|
return configFailure("Configuration explanation was canceled.", acquired.Explanation, invocation.Context.Err())
|
||||||
}
|
}
|
||||||
return commandkit.Success()
|
return commandkit.Success()
|
||||||
}
|
}
|
||||||
|
|
||||||
func pathWithin(path, parent configkit.Path) bool {
|
func pointerTokens(value string) []string {
|
||||||
tokens := func(value string) []string {
|
|
||||||
if value == "" {
|
if value == "" {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return strings.Split(strings.TrimPrefix(value, "/"), "/")
|
return strings.Split(strings.TrimPrefix(value, "/"), "/")
|
||||||
}
|
}
|
||||||
child, prefix := tokens(path.String()), tokens(parent.String())
|
|
||||||
|
func pathWithin(path string, prefix []string) bool {
|
||||||
|
child := pointerTokens(path)
|
||||||
if len(prefix) > len(child) {
|
if len(prefix) > len(child) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -91,18 +91,16 @@ func captureSuite[I, S any](suite Suite[I, S]) *preparedSuite[I, S] {
|
|||||||
byName: make(map[string]*preparedScope[I, S], len(suite.Scopes)),
|
byName: make(map[string]*preparedScope[I, S], len(suite.Scopes)),
|
||||||
bindingsByOperation: make(map[Operation][]commandkit.Flag[I]),
|
bindingsByOperation: make(map[Operation][]commandkit.Flag[I]),
|
||||||
}
|
}
|
||||||
selectors := make([]Selector[I], len(suite.Selectors))
|
seenNames := captureConfigScopes(prepared, suite.Scopes)
|
||||||
against := make([]commandkit.Flag[I], len(suite.Selectors))
|
selectors, against := captureConfigSelectors(prepared, suite.Selectors, seenNames)
|
||||||
for index, selector := range suite.Selectors {
|
captureOperationBindings(prepared, selectors, against)
|
||||||
selector.Scopes = append([]string(nil), selector.Scopes...)
|
captureScopeInventory(prepared)
|
||||||
selectors[index] = selector
|
return prepared
|
||||||
if selector.AgainstName != "" {
|
}
|
||||||
against[index] = commandkit.RenameFlag(selector.Binding, selector.AgainstName)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
seenNames := make(map[string]struct{}, len(suite.Scopes))
|
func captureConfigScopes[I, S any](prepared *preparedSuite[I, S], scopes []Scope[I, S]) map[string]struct{} {
|
||||||
for _, original := range suite.Scopes {
|
seenNames := make(map[string]struct{}, len(scopes))
|
||||||
|
for _, original := range scopes {
|
||||||
scope := original
|
scope := original
|
||||||
scope.Operations = normalizeOperations(scope.Operations, &prepared.issues, scope.Name)
|
scope.Operations = normalizeOperations(scope.Operations, &prepared.issues, scope.Name)
|
||||||
scope.issues = append([]error(nil), original.issues...)
|
scope.issues = append([]error(nil), original.issues...)
|
||||||
@@ -146,6 +144,20 @@ func captureSuite[I, S any](suite Suite[I, S]) *preparedSuite[I, S] {
|
|||||||
}
|
}
|
||||||
prepared.scopeChoices = joinNames(names)
|
prepared.scopeChoices = joinNames(names)
|
||||||
|
|
||||||
|
return seenNames
|
||||||
|
}
|
||||||
|
|
||||||
|
func captureConfigSelectors[I, S any](prepared *preparedSuite[I, S], originals []Selector[I], seenNames map[string]struct{}) ([]Selector[I], []commandkit.Flag[I]) {
|
||||||
|
selectors := make([]Selector[I], len(originals))
|
||||||
|
against := make([]commandkit.Flag[I], len(originals))
|
||||||
|
for index, selector := range originals {
|
||||||
|
selector.Scopes = append([]string(nil), selector.Scopes...)
|
||||||
|
selectors[index] = selector
|
||||||
|
if selector.AgainstName != "" {
|
||||||
|
against[index] = commandkit.RenameFlag(selector.Binding, selector.AgainstName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for index, selector := range selectors {
|
for index, selector := range selectors {
|
||||||
seen := make(map[string]struct{}, len(selector.Scopes))
|
seen := make(map[string]struct{}, len(selector.Scopes))
|
||||||
uniqueScopes := make([]string, 0, len(selector.Scopes))
|
uniqueScopes := make([]string, 0, len(selector.Scopes))
|
||||||
@@ -181,6 +193,10 @@ func captureSuite[I, S any](suite Suite[I, S]) *preparedSuite[I, S] {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return selectors, against
|
||||||
|
}
|
||||||
|
|
||||||
|
func captureOperationBindings[I, S any](prepared *preparedSuite[I, S], selectors []Selector[I], against []commandkit.Flag[I]) {
|
||||||
for _, operation := range []Operation{Validate, Show, Sources, Explain, Diff} {
|
for _, operation := range []Operation{Validate, Show, Sources, Explain, Diff} {
|
||||||
for _, scope := range prepared.scopes {
|
for _, scope := range prepared.scopes {
|
||||||
if containsOperation(scope.scope.Operations, operation) {
|
if containsOperation(scope.scope.Operations, operation) {
|
||||||
@@ -200,6 +216,9 @@ func captureSuite[I, S any](suite Suite[I, S]) *preparedSuite[I, S] {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func captureScopeInventory[I, S any](prepared *preparedSuite[I, S]) {
|
||||||
for _, scope := range prepared.scopes {
|
for _, scope := range prepared.scopes {
|
||||||
prepared.inventory.Scopes = append(prepared.inventory.Scopes, scopeMetadata{
|
prepared.inventory.Scopes = append(prepared.inventory.Scopes, scopeMetadata{
|
||||||
Name: scope.scope.Name,
|
Name: scope.scope.Name,
|
||||||
@@ -207,7 +226,6 @@ func captureSuite[I, S any](suite Suite[I, S]) *preparedSuite[I, S] {
|
|||||||
Features: featureNames(scope.scope.Features),
|
Features: featureNames(scope.scope.Features),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return prepared
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func normalizeOperations(operations []Operation, issues *[]error, scope string) []Operation {
|
func normalizeOperations(operations []Operation, issues *[]error, scope string) []Operation {
|
||||||
|
|||||||
@@ -3,10 +3,12 @@ package ckconfig
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"reflect"
|
"reflect"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -478,18 +480,55 @@ func TestExplainParsesBeforeAcquisitionAndPreservesNativeSafeHistory(t *testing.
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestExplainTracksNativeRenderFailureWithoutReplacingOperationCause(t *testing.T) {
|
func TestExplainUsesAcquiredEvidenceAcrossFormats(t *testing.T) {
|
||||||
renderErr := errors.New("native explanation renderer failed")
|
result, err := configkit.Load(configkit.Mapping("original source", map[string]any{"token": "before"}), configkit.Options[map[string]any]{Overrides: []configkit.Override{{Path: configkit.Keys("token"), Value: "after", Flag: "--fixture-set"}}})
|
||||||
reader := &failingNativeExplanation{err: renderErr}
|
if err != nil {
|
||||||
app := newConfigTestApp(t, Command(Suite[struct{}, struct{}]{Scopes: []Scope[struct{}, struct{}]{{
|
t.Fatal(err)
|
||||||
Name: "app", Operations: []Operation{Explain}, Acquire: func(context.Context, Request, struct{}, struct{}) (commandkit.Acquired[View], error) {
|
}
|
||||||
return commandkit.Acquired[View]{Value: View{Explanation: reader}}, nil
|
path := configkit.Keys("token")
|
||||||
},
|
explanation, found := result.Explain(path)
|
||||||
}}}))
|
if !found {
|
||||||
|
t.Fatal("missing fixture explanation")
|
||||||
|
}
|
||||||
|
metadata := result.Metadata()
|
||||||
|
reader := &nativeExplanation{value: explanation, found: true}
|
||||||
|
app := newConfigTestApp(t, Command(Suite[struct{}, struct{}]{Scopes: []Scope[struct{}, struct{}]{{Name: "tenant", Operations: []Operation{Explain, Show}, Features: ScopeFeatures{PathShow: true}, Acquire: func(context.Context, Request, struct{}, struct{}) (commandkit.Acquired[View], error) {
|
||||||
|
return commandkit.Acquired[View]{Value: View{Explanation: reader, Metadata: &metadata, Sources: result}}, nil
|
||||||
|
}}}}))
|
||||||
|
var focused bytes.Buffer
|
||||||
|
show := app.Execute(context.Background(), []string{"config", "show", "--scope=tenant", "--path=/token", "--format=json"}, commandkit.Streams{Out: &focused, Err: io.Discard})
|
||||||
|
if show.Kind != commandkit.OutcomeSuccess || strings.TrimSpace(focused.String()) != `"after"` {
|
||||||
|
t.Fatalf("focused show = %#v %q", show, focused.String())
|
||||||
|
}
|
||||||
|
for _, format := range []string{"text", "yaml", "json"} {
|
||||||
|
t.Run(format, func(t *testing.T) {
|
||||||
var output bytes.Buffer
|
var output bytes.Buffer
|
||||||
outcome := app.Execute(context.Background(), []string{"config", "explain", "--path", "/token"}, commandkit.Streams{Out: &output, Err: io.Discard})
|
outcome := app.Execute(context.Background(), []string{"config", "explain", "--scope=tenant", "--path=/token", "--format=" + format}, commandkit.Streams{Out: &output, Err: io.Discard})
|
||||||
if outcome.Kind != commandkit.OutcomeOperational || outcome.Cause != nil || !errors.Is(outcome.Presentation.Output.RenderError, renderErr) || output.Len() != 0 {
|
if outcome.Status != 0 {
|
||||||
t.Fatalf("native explanation failure outcome/output = %#v / %q", outcome, output.String())
|
t.Fatalf("outcome=%#v output=%s", outcome, output.String())
|
||||||
|
}
|
||||||
|
for _, evidence := range []string{"tenant", "/token", "before", "after", "original source", "--fixture-set"} {
|
||||||
|
if !strings.Contains(output.String(), evidence) {
|
||||||
|
t.Errorf("missing %q: %s", evidence, output.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if format == "json" {
|
||||||
|
var report explanationReport
|
||||||
|
if err := json.Unmarshal(output.Bytes(), &report); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if report.Metadata == nil || report.Metadata.Explicit == nil || !*report.Metadata.Explicit || !slices.ContainsFunc(report.Metadata.History, func(record metadataHistoryRecord) bool { return len(record.Previous) > 0 }) {
|
||||||
|
t.Fatalf("missing metadata: %#v", report.Metadata)
|
||||||
|
}
|
||||||
|
} else if format == "text" && (!strings.Contains(output.String(), "Previous origin:") || !strings.Contains(output.String(), "Explicit: true")) {
|
||||||
|
t.Fatalf("missing text metadata: %s", output.String())
|
||||||
|
}
|
||||||
|
writeErr := errors.New("output failed")
|
||||||
|
outcome = app.Execute(context.Background(), []string{"config", "explain", "--scope=tenant", "--path=/token", "--format=" + format}, commandkit.Streams{Out: failingOutput{err: writeErr}, Err: io.Discard})
|
||||||
|
if outcome.Status != 1 || outcome.Cause != nil || !errors.Is(outcome.Presentation.Output.WriteError, writeErr) {
|
||||||
|
t.Fatalf("delivery failure = %#v", outcome)
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -552,30 +591,11 @@ type nativeExplanation struct {
|
|||||||
found bool
|
found bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type failingNativeExplanation struct{ err error }
|
|
||||||
|
|
||||||
func (reader *failingNativeExplanation) Explain(configkit.Path) (configkit.Explanation, bool) {
|
|
||||||
return configkit.Explanation{}, true
|
|
||||||
}
|
|
||||||
|
|
||||||
func (reader *failingNativeExplanation) WriteExplanation(writer io.Writer, _ configkit.Path) error {
|
|
||||||
if _, err := io.WriteString(writer, "partial native explanation\n"); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return reader.err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (reader *nativeExplanation) Explain(path configkit.Path) (configkit.Explanation, bool) {
|
func (reader *nativeExplanation) Explain(path configkit.Path) (configkit.Explanation, bool) {
|
||||||
reader.path = path
|
reader.path = path
|
||||||
return reader.value, reader.found
|
return reader.value, reader.found
|
||||||
}
|
}
|
||||||
|
|
||||||
func (reader *nativeExplanation) WriteExplanation(writer io.Writer, path configkit.Path) error {
|
|
||||||
reader.path = path
|
|
||||||
_, err := fmt.Fprintf(writer, "Explanation for %q\n", path.String())
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
type failingOutput struct{ err error }
|
type failingOutput struct{ err error }
|
||||||
|
|
||||||
func (writer failingOutput) Write(value []byte) (int, error) {
|
func (writer failingOutput) Write(value []byte) (int, error) {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ module go.maximumdirect.net/ckconfig
|
|||||||
go 1.27.0
|
go 1.27.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
go.maximumdirect.net/commandkit v0.3.0
|
go.maximumdirect.net/commandkit v0.6.0
|
||||||
go.maximumdirect.net/configkit v1.1.0
|
go.maximumdirect.net/configkit v1.1.0
|
||||||
go.yaml.in/yaml/v4 v4.0.0-rc.6
|
go.yaml.in/yaml/v4 v4.0.0-rc.6
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -6,8 +6,12 @@ github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s=
|
|||||||
github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0=
|
github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0=
|
||||||
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
|
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=
|
||||||
go.maximumdirect.net/commandkit v0.3.0 h1:RCmg2GS8uzlLSJCIqarpn1uFDec0W3ylNNRRPT6kUFE=
|
go.maximumdirect.net/commandkit v0.4.0 h1:bWqflDWDn98FUCgh+0e+w6LGmvWIkaeJK519H7PjD2Y=
|
||||||
go.maximumdirect.net/commandkit v0.3.0/go.mod h1:ZS98DewKNVBygKVDoh5MFD09CnPlP37dS9pVsID5Eoc=
|
go.maximumdirect.net/commandkit v0.4.0/go.mod h1:ZS98DewKNVBygKVDoh5MFD09CnPlP37dS9pVsID5Eoc=
|
||||||
|
go.maximumdirect.net/commandkit v0.5.0 h1:Ln+bCp+FwCg4tZD3NaMKQXUDB3mPusgwsnRZmb0+3cM=
|
||||||
|
go.maximumdirect.net/commandkit v0.5.0/go.mod h1:ZS98DewKNVBygKVDoh5MFD09CnPlP37dS9pVsID5Eoc=
|
||||||
|
go.maximumdirect.net/commandkit v0.6.0 h1:4PHhMIlZuuvreIxZhUazeweg6zoYixEvhM9LJnkbGNA=
|
||||||
|
go.maximumdirect.net/commandkit v0.6.0/go.mod h1:ZS98DewKNVBygKVDoh5MFD09CnPlP37dS9pVsID5Eoc=
|
||||||
go.maximumdirect.net/configkit v1.1.0 h1:UzXN2ZMnEkcL0O4YSLqD9qd9M1gb/NWCHQR/H8AaQzA=
|
go.maximumdirect.net/configkit v1.1.0 h1:UzXN2ZMnEkcL0O4YSLqD9qd9M1gb/NWCHQR/H8AaQzA=
|
||||||
go.maximumdirect.net/configkit v1.1.0/go.mod h1:s1zLJ9GGB/1Q4a+aOHMLISonJxWFgNWpA6SpPlOafeA=
|
go.maximumdirect.net/configkit v1.1.0/go.mod h1:s1zLJ9GGB/1Q4a+aOHMLISonJxWFgNWpA6SpPlOafeA=
|
||||||
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=
|
||||||
|
|||||||
@@ -73,38 +73,27 @@ func newConfigOperationCommand[I, S any](prepared *preparedSuite[I, S], operatio
|
|||||||
scopeChoices = append(scopeChoices, commandkit.CompletionChoice{Value: "all"})
|
scopeChoices = append(scopeChoices, commandkit.CompletionChoice{Value: "all"})
|
||||||
}
|
}
|
||||||
flags := []commandkit.Flag[*configCommandInput[I, S]]{
|
flags := []commandkit.Flag[*configCommandInput[I, S]]{
|
||||||
commandkit.ValueFlag(commandkit.FlagMeta{Name: "scope", Usage: "Configuration scope name or all where supported.", Completion: commandkit.CompleteChoices(scopeChoices...)}, &defaultScope, parseConfigString,
|
commandkit.ValueFlag(commandkit.FlagMeta{Name: "scope", Usage: "Select a named configuration, or all where supported.", Completion: commandkit.CompleteChoices(scopeChoices...)}, &defaultScope, parseConfigString,
|
||||||
func(input **configCommandInput[I, S], value commandkit.Supplied[string]) { (*input).scope = value }),
|
func(input **configCommandInput[I, S], value commandkit.Supplied[string]) { (*input).scope = value }),
|
||||||
commandkit.ValueFlag(commandkit.FlagMeta{Name: "config", Usage: "Configuration source.", Completion: commandkit.CompleteFiles()}, (*string)(nil), parseConfigString,
|
commandkit.ValueFlag(commandkit.FlagMeta{Name: "config", Usage: "Select the configuration source.", Completion: commandkit.CompleteFiles()}, (*string)(nil), parseConfigString,
|
||||||
func(input **configCommandInput[I, S], value commandkit.Supplied[string]) {
|
func(input **configCommandInput[I, S], value commandkit.Supplied[string]) {
|
||||||
(*input).selection.Config = value
|
(*input).selection.Config = value
|
||||||
}),
|
}),
|
||||||
commandkit.ValueFlag(commandkit.FlagMeta{Name: "overlay", Usage: "Overlay source.", Completion: commandkit.CompleteFiles()}, (*string)(nil), parseConfigString,
|
commandkit.ValueFlag(commandkit.FlagMeta{Name: "overlay", Usage: "Select the configuration overlay.", Completion: commandkit.CompleteFiles()}, (*string)(nil), parseConfigString,
|
||||||
func(input **configCommandInput[I, S], value commandkit.Supplied[string]) {
|
func(input **configCommandInput[I, S], value commandkit.Supplied[string]) {
|
||||||
(*input).selection.Overlay = value
|
(*input).selection.Overlay = value
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
if operation == Diff {
|
if operation == Diff {
|
||||||
flags = append(flags,
|
flags = append(flags, comparisonOperationFlags[I, S]()...)
|
||||||
commandkit.BoolFlag(commandkit.FlagMeta{Name: "details", Usage: "Show native before and after difference evidence."}, false, func(input **configCommandInput[I, S], value commandkit.Supplied[bool]) { (*input).details = value }),
|
|
||||||
commandkit.BoolFlag(commandkit.FlagMeta{Name: "check", Usage: "Return status 3 when differences are found."}, false, func(input **configCommandInput[I, S], value commandkit.Supplied[bool]) { (*input).check = value }),
|
|
||||||
commandkit.ValueFlag(commandkit.FlagMeta{Name: "against-config", Usage: "Configuration source for the right side.", Completion: commandkit.CompleteFiles()}, (*string)(nil), parseConfigString,
|
|
||||||
func(input **configCommandInput[I, S], value commandkit.Supplied[string]) {
|
|
||||||
(*input).against.Config = value
|
|
||||||
}),
|
|
||||||
commandkit.ValueFlag(commandkit.FlagMeta{Name: "against-overlay", Usage: "Overlay source for the right side.", Completion: commandkit.CompleteFiles()}, (*string)(nil), parseConfigString,
|
|
||||||
func(input **configCommandInput[I, S], value commandkit.Supplied[string]) {
|
|
||||||
(*input).against.Overlay = value
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
if operation == Validate {
|
if operation == Validate {
|
||||||
flags = append(flags,
|
flags = append(flags,
|
||||||
commandkit.BoolFlag(commandkit.FlagMeta{Name: "details", Usage: "Show native validation findings."}, false, func(input **configCommandInput[I, S], value commandkit.Supplied[bool]) { (*input).details = value }),
|
commandkit.BoolFlag(commandkit.FlagMeta{Name: "details", Usage: "Show detailed validation findings."}, false, func(input **configCommandInput[I, S], value commandkit.Supplied[bool]) { (*input).details = value }),
|
||||||
commandkit.RepeatFlag(commandkit.FlagMeta{Name: "severity", Usage: "Filter detailed findings by error or warning."}, []configkit.Severity(nil), parseValidationSeverity, func(input **configCommandInput[I, S], value commandkit.Supplied[[]configkit.Severity]) {
|
commandkit.RepeatFlag(commandkit.FlagMeta{Name: "severity", Usage: "Filter detailed findings by error or warning."}, []configkit.Severity(nil), parseValidationSeverity, func(input **configCommandInput[I, S], value commandkit.Supplied[[]configkit.Severity]) {
|
||||||
(*input).severities = value.Value
|
(*input).severities = value.Value
|
||||||
}),
|
}),
|
||||||
commandkit.RepeatFlag(commandkit.FlagMeta{Name: "code", Usage: "Filter detailed findings by exact native code."}, []string(nil), parseConfigString, func(input **configCommandInput[I, S], value commandkit.Supplied[[]string]) {
|
commandkit.RepeatFlag(commandkit.FlagMeta{Name: "code", Usage: "Filter detailed findings by exact diagnostic code."}, []string(nil), parseConfigString, func(input **configCommandInput[I, S], value commandkit.Supplied[[]string]) {
|
||||||
(*input).codes = value.Value
|
(*input).codes = value.Value
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -125,16 +114,16 @@ func newConfigOperationCommand[I, S any](prepared *preparedSuite[I, S], operatio
|
|||||||
flags = append(flags,
|
flags = append(flags,
|
||||||
commandkit.ValueFlag(commandkit.FlagMeta{Name: "path", Usage: "JSON Pointer path to explain."}, (*string)(nil), parseConfigString, func(input **configCommandInput[I, S], value commandkit.Supplied[string]) { (*input).path = value }),
|
commandkit.ValueFlag(commandkit.FlagMeta{Name: "path", Usage: "JSON Pointer path to explain."}, (*string)(nil), parseConfigString, func(input **configCommandInput[I, S], value commandkit.Supplied[string]) { (*input).path = value }),
|
||||||
commandkit.BoolFlag(commandkit.FlagMeta{Name: "list", Usage: "List configuration paths."}, false, func(input **configCommandInput[I, S], value commandkit.Supplied[bool]) { (*input).list = value }),
|
commandkit.BoolFlag(commandkit.FlagMeta{Name: "list", Usage: "List configuration paths."}, false, func(input **configCommandInput[I, S], value commandkit.Supplied[bool]) { (*input).list = value }),
|
||||||
commandkit.BoolFlag(commandkit.FlagMeta{Name: "historical", Usage: "Include historical-only paths when listing."}, false, func(input **configCommandInput[I, S], value commandkit.Supplied[bool]) { (*input).historical = value }),
|
commandkit.BoolFlag(commandkit.FlagMeta{Name: "historical", Usage: "Include paths that exist only in configuration history."}, false, func(input **configCommandInput[I, S], value commandkit.Supplied[bool]) { (*input).historical = value }),
|
||||||
commandkit.ValueFlag(commandkit.FlagMeta{Name: "under", Usage: "List paths under a JSON Pointer."}, (*string)(nil), parseConfigString, func(input **configCommandInput[I, S], value commandkit.Supplied[string]) { (*input).under = value }),
|
commandkit.ValueFlag(commandkit.FlagMeta{Name: "under", Usage: "List paths under a JSON Pointer."}, (*string)(nil), parseConfigString, func(input **configCommandInput[I, S], value commandkit.Supplied[string]) { (*input).under = value }),
|
||||||
commandkit.BoolFlag(commandkit.FlagMeta{Name: "explicit", Usage: "Filter listed current paths by explicitness."}, false, func(input **configCommandInput[I, S], value commandkit.Supplied[bool]) { (*input).explicit = value }),
|
commandkit.BoolFlag(commandkit.FlagMeta{Name: "explicit", Usage: "Filter current paths by whether ConfigKit marks them explicit."}, false, func(input **configCommandInput[I, S], value commandkit.Supplied[bool]) { (*input).explicit = value }),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if operation == Show {
|
if operation == Show {
|
||||||
flags = append(flags, commandkit.ValueFlag(commandkit.FlagMeta{Name: "path", Usage: "JSON Pointer to one effective value."}, (*string)(nil), parseConfigString, func(input **configCommandInput[I, S], value commandkit.Supplied[string]) { (*input).path = value }))
|
flags = append(flags, commandkit.ValueFlag(commandkit.FlagMeta{Name: "path", Usage: "JSON Pointer to one effective value."}, (*string)(nil), parseConfigString, func(input **configCommandInput[I, S], value commandkit.Supplied[string]) { (*input).path = value }))
|
||||||
}
|
}
|
||||||
if operation == Sources {
|
if operation == Sources {
|
||||||
flags = append(flags, commandkit.ValueFlag(commandkit.FlagMeta{Name: "source-id", Usage: "Select one source ID from this acquisition."}, (*string)(nil), parseConfigString, func(input **configCommandInput[I, S], value commandkit.Supplied[string]) { (*input).sourceID = value }))
|
flags = append(flags, commandkit.ValueFlag(commandkit.FlagMeta{Name: "source-id", Usage: "Show one source ID from this configuration load."}, (*string)(nil), parseConfigString, func(input **configCommandInput[I, S], value commandkit.Supplied[string]) { (*input).sourceID = value }))
|
||||||
}
|
}
|
||||||
flags = append(flags, commandkit.CaptureFlags(prepared.bindingsByOperation[operation], func(input **configCommandInput[I, S]) *commandkit.FlagValues[I] {
|
flags = append(flags, commandkit.CaptureFlags(prepared.bindingsByOperation[operation], func(input **configCommandInput[I, S]) *commandkit.FlagValues[I] {
|
||||||
if input == nil || *input == nil {
|
if input == nil || *input == nil {
|
||||||
@@ -169,6 +158,21 @@ func newConfigOperationCommand[I, S any](prepared *preparedSuite[I, S], operatio
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func comparisonOperationFlags[I, S any]() []commandkit.Flag[*configCommandInput[I, S]] {
|
||||||
|
return []commandkit.Flag[*configCommandInput[I, S]]{
|
||||||
|
commandkit.BoolFlag(commandkit.FlagMeta{Name: "details", Usage: "Show before and after values with presence and redaction details."}, false, func(input **configCommandInput[I, S], value commandkit.Supplied[bool]) { (*input).details = value }),
|
||||||
|
commandkit.BoolFlag(commandkit.FlagMeta{Name: "check", Usage: "Return status 3 when differences are found."}, false, func(input **configCommandInput[I, S], value commandkit.Supplied[bool]) { (*input).check = value }),
|
||||||
|
commandkit.ValueFlag(commandkit.FlagMeta{Name: "against-config", Usage: "Configuration source for the right side.", Completion: commandkit.CompleteFiles()}, (*string)(nil), parseConfigString,
|
||||||
|
func(input **configCommandInput[I, S], value commandkit.Supplied[string]) {
|
||||||
|
(*input).against.Config = value
|
||||||
|
}),
|
||||||
|
commandkit.ValueFlag(commandkit.FlagMeta{Name: "against-overlay", Usage: "Overlay source for the right side.", Completion: commandkit.CompleteFiles()}, (*string)(nil), parseConfigString,
|
||||||
|
func(input **configCommandInput[I, S], value commandkit.Supplied[string]) {
|
||||||
|
(*input).against.Overlay = value
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func operationDescription(operation Operation) string {
|
func operationDescription(operation Operation) string {
|
||||||
if operation == Explain {
|
if operation == Explain {
|
||||||
return "Use --list to discover paths or --path POINTER to explain one path."
|
return "Use --list to discover paths or --path POINTER to explain one path."
|
||||||
@@ -187,41 +191,44 @@ func operationDeclaration(operation Operation) (string, string) {
|
|||||||
case Explain:
|
case Explain:
|
||||||
return "explain", "Explain a configuration path."
|
return "explain", "Explain a configuration path."
|
||||||
case Diff:
|
case Diff:
|
||||||
return "diff", "Compare two configuration selections."
|
return "diff", "Compare two configurations."
|
||||||
default:
|
default:
|
||||||
return string(operation), "Inspect configuration."
|
return string(operation), "Inspect configuration."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func prepareConfigOperation[I, S any](prepared *preparedSuite[I, S], operation Operation, input *configCommandInput[I, S]) error {
|
func checkConfigOperationOptions[I, S any](operation Operation, input *configCommandInput[I, S]) (bool, error) {
|
||||||
if operation == Validate && !input.details.Value && (len(input.severities) > 0 || len(input.codes) > 0) {
|
if operation == Validate && !input.details.Value && (len(input.severities) > 0 || len(input.codes) > 0) {
|
||||||
return fmt.Errorf("--severity and --code require --details")
|
return false, fmt.Errorf("--severity and --code require --details")
|
||||||
}
|
}
|
||||||
if operation == Explain {
|
if operation == Explain {
|
||||||
if input.list.Value && input.path.Set {
|
if input.list.Value && input.path.Set {
|
||||||
return fmt.Errorf("--list and --path cannot be combined")
|
return false, fmt.Errorf("--list and --path cannot be combined")
|
||||||
}
|
}
|
||||||
if !input.list.Value && (input.historical.Set || input.under.Set || input.explicit.Set) {
|
if !input.list.Value && (input.historical.Set || input.under.Set || input.explicit.Set) {
|
||||||
return fmt.Errorf("--historical, --under, and --explicit require --list")
|
return false, fmt.Errorf("--historical, --under, and --explicit require --list")
|
||||||
}
|
}
|
||||||
if !input.list.Value && !input.path.Set {
|
if !input.list.Value && !input.path.Set {
|
||||||
input.plan = &configOperationPlan[I, S]{prepared: prepared, operation: operation, help: true}
|
return true, nil
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func selectConfigScopes[I, S any](prepared *preparedSuite[I, S], operation Operation, input *configCommandInput[I, S]) (string, []*preparedScope[I, S], error) {
|
||||||
scopeName := "app"
|
scopeName := "app"
|
||||||
if input.scope.Set {
|
if input.scope.Set {
|
||||||
scopeName = input.scope.Value
|
scopeName = input.scope.Value
|
||||||
}
|
}
|
||||||
if scopeName == "" {
|
if scopeName == "" {
|
||||||
return fmt.Errorf("configuration scope must not be empty")
|
return "", nil, fmt.Errorf("configuration scope must not be empty")
|
||||||
}
|
}
|
||||||
if scopeName == "all" && operation != Validate && operation != Show {
|
if scopeName == "all" && operation != Validate && operation != Show {
|
||||||
return fmt.Errorf("scope all is supported only for validate and show")
|
return "", nil, fmt.Errorf("scope all is supported only for validate and show")
|
||||||
}
|
}
|
||||||
if scopeName == "all" && operation == Show && input.path.Set {
|
if scopeName == "all" && operation == Show && input.path.Set {
|
||||||
return fmt.Errorf("path-selected show requires one scope")
|
return "", nil, fmt.Errorf("path-selected show requires one scope")
|
||||||
}
|
}
|
||||||
|
|
||||||
var selected []*preparedScope[I, S]
|
var selected []*preparedScope[I, S]
|
||||||
@@ -230,24 +237,62 @@ func prepareConfigOperation[I, S any](prepared *preparedSuite[I, S], operation O
|
|||||||
} else if scope, exists := prepared.byName[scopeName]; exists {
|
} else if scope, exists := prepared.byName[scopeName]; exists {
|
||||||
selected = []*preparedScope[I, S]{scope}
|
selected = []*preparedScope[I, S]{scope}
|
||||||
} else {
|
} else {
|
||||||
return fmt.Errorf("unknown configuration scope %q (available: %s)", scopeName, prepared.scopeChoices)
|
return "", nil, fmt.Errorf("unknown configuration scope %q (available: %s)", scopeName, prepared.scopeChoices)
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, scope := range selected {
|
for _, scope := range selected {
|
||||||
if !containsOperation(scope.scope.Operations, operation) {
|
if !containsOperation(scope.scope.Operations, operation) {
|
||||||
return fmt.Errorf("configuration scope %q does not support %s (available: %s)", scope.scope.Name, operation, joinOperations(scope.scope.Operations))
|
return "", nil, fmt.Errorf("configuration scope %q does not support %s (available: %s)", scope.scope.Name, operation, joinOperations(scope.scope.Operations))
|
||||||
}
|
}
|
||||||
if (len(input.selection.Overrides) > 0 || len(input.against.Overrides) > 0) && !scope.scope.Features.Overrides {
|
if (len(input.selection.Overrides) > 0 || len(input.against.Overrides) > 0) && !scope.scope.Features.Overrides {
|
||||||
return fmt.Errorf("configuration scope %q does not support overrides", scope.scope.Name)
|
return "", nil, fmt.Errorf("configuration scope %q does not support overrides", scope.scope.Name)
|
||||||
}
|
}
|
||||||
if operation == Explain && input.list.Value && !scope.scope.Features.PathDiscovery {
|
if operation == Explain && input.list.Value && !scope.scope.Features.PathDiscovery {
|
||||||
return fmt.Errorf("configuration scope %q does not support path discovery", scope.scope.Name)
|
return "", nil, fmt.Errorf("configuration scope %q does not support path discovery", scope.scope.Name)
|
||||||
}
|
}
|
||||||
if operation == Show && input.path.Set && !scope.scope.Features.PathShow {
|
if operation == Show && input.path.Set && !scope.scope.Features.PathShow {
|
||||||
return fmt.Errorf("configuration scope %q does not support path-selected show", scope.scope.Name)
|
return "", nil, fmt.Errorf("configuration scope %q does not support path-selected show", scope.scope.Name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return scopeName, selected, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func prepareComparisonInputs[I, S any](prepared *preparedSuite[I, S], input *configCommandInput[I, S], scope *preparedScope[I, S], plan *configOperationPlan[I, S]) error {
|
||||||
|
input.selection = cloneSelection(input.selection)
|
||||||
|
input.against = cloneSelection(input.against)
|
||||||
|
left, err := prepareScopeInput(prepared, input.values, scope, scope.bindings, input.selection, true)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("configuration scope %q left input: %w", scope.scope.Name, err)
|
||||||
|
}
|
||||||
|
right, err := prepareScopeInput(prepared, input.againstValues, scope, scope.againstBindings, input.against, true)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("configuration scope %q right input: %w", scope.scope.Name, err)
|
||||||
|
}
|
||||||
|
plan.comparison = &preparedComparison[I, S]{
|
||||||
|
scope: scope,
|
||||||
|
request: ComparisonRequest[I]{
|
||||||
|
Left: ComparisonSide[I]{Selection: input.selection, Input: left},
|
||||||
|
Right: ComparisonSide[I]{Selection: input.against, Input: right},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func prepareConfigOperation[I, S any](prepared *preparedSuite[I, S], operation Operation, input *configCommandInput[I, S]) error {
|
||||||
|
help, err := checkConfigOperationOptions(operation, input)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if help {
|
||||||
|
input.plan = &configOperationPlan[I, S]{prepared: prepared, operation: operation, help: true}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
scopeName, selected, err := selectConfigScopes(prepared, operation, input)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
var path configkit.Path
|
var path configkit.Path
|
||||||
if (operation == Explain || operation == Show) && input.path.Set {
|
if (operation == Explain || operation == Show) && input.path.Set {
|
||||||
parsed, err := configkit.ParsePath(input.path.Value)
|
parsed, err := configkit.ParsePath(input.path.Value)
|
||||||
@@ -283,23 +328,8 @@ func prepareConfigOperation[I, S any](prepared *preparedSuite[I, S], operation O
|
|||||||
scopes: make([]selectedScopeInput[I, S], 0, len(selected)),
|
scopes: make([]selectedScopeInput[I, S], 0, len(selected)),
|
||||||
}
|
}
|
||||||
if operation == Diff {
|
if operation == Diff {
|
||||||
scope := selected[0]
|
if err := prepareComparisonInputs(prepared, input, selected[0], plan); err != nil {
|
||||||
input.selection = cloneSelection(input.selection)
|
return err
|
||||||
input.against = cloneSelection(input.against)
|
|
||||||
left, err := prepareScopeInput(prepared, input.values, scope, scope.bindings, input.selection, true)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("configuration scope %q left input: %w", scope.scope.Name, err)
|
|
||||||
}
|
|
||||||
right, err := prepareScopeInput(prepared, input.againstValues, scope, scope.againstBindings, input.against, true)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("configuration scope %q right input: %w", scope.scope.Name, err)
|
|
||||||
}
|
|
||||||
plan.comparison = &preparedComparison[I, S]{
|
|
||||||
scope: scope,
|
|
||||||
request: ComparisonRequest[I]{
|
|
||||||
Left: ComparisonSide[I]{Selection: input.selection, Input: left},
|
|
||||||
Right: ComparisonSide[I]{Selection: input.against, Input: right},
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
input.plan = plan
|
input.plan = plan
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package ckconfig
|
package ckconfig
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
@@ -67,3 +68,39 @@ func TestBufferedReportDoesNotRejectCancellationAfterDeliveryStarts(t *testing.T
|
|||||||
t.Fatalf("post-delivery cancellation changed the operation outcome: %#v", got)
|
t.Fatalf("post-delivery cancellation changed the operation outcome: %#v", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type effectiveWriterFunc func(io.Writer) error
|
||||||
|
|
||||||
|
func (write effectiveWriterFunc) WriteEffective(w io.Writer) error { return write(w) }
|
||||||
|
|
||||||
|
func TestShowCancellationDuringBufferedPreparation(t *testing.T) {
|
||||||
|
renderErr := errors.New("native rendering failed")
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
renderErr error
|
||||||
|
}{{name: "cancellation"}, {name: "cancellation and rendering failure", renderErr: renderErr}} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
acquired, secondWrites := 0, 0
|
||||||
|
scope := func(name string, writer EffectiveWriter) Scope[struct{}, struct{}] {
|
||||||
|
return Scope[struct{}, struct{}]{Name: name, Operations: []Operation{Show}, Acquire: func(context.Context, Request, struct{}, struct{}) (commandkit.Acquired[View], error) {
|
||||||
|
acquired++
|
||||||
|
return commandkit.Acquired[View]{Value: View{Effective: writer}}, nil
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
app := newConfigTestApp(t, Command(Suite[struct{}, struct{}]{Scopes: []Scope[struct{}, struct{}]{
|
||||||
|
scope("app", effectiveWriterFunc(func(w io.Writer) error { _, _ = io.WriteString(w, "name: app\n"); cancel(); return tc.renderErr })),
|
||||||
|
scope("promptkit", effectiveWriterFunc(func(w io.Writer) error { secondWrites++; _, err := io.WriteString(w, "name: promptkit\n"); return err })),
|
||||||
|
}}))
|
||||||
|
var output bytes.Buffer
|
||||||
|
got := app.Execute(ctx, []string{"config", "show", "--scope=all"}, commandkit.Streams{Out: &output, Err: io.Discard})
|
||||||
|
if acquired != 2 || secondWrites != 0 || output.Len() != 0 || !errors.Is(got.Cause, context.Canceled) || got.Presentation.Output.WriteError != nil {
|
||||||
|
t.Fatalf("acquired=%d second=%d output=%q outcome=%#v", acquired, secondWrites, output.String(), got)
|
||||||
|
}
|
||||||
|
if tc.renderErr == nil && got.Presentation.Output.RenderError != nil || tc.renderErr != nil && !errors.Is(got.Presentation.Output.RenderError, tc.renderErr) {
|
||||||
|
t.Fatalf("render evidence = %#v", got.Presentation.Output)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -66,11 +66,13 @@ func runConfigShow[I, S any](invocation commandkit.Invocation, plan *configOpera
|
|||||||
return configFailure("Configuration show was canceled.", commandkit.Explanation{}, err)
|
return configFailure("Configuration show was canceled.", commandkit.Explanation{}, err)
|
||||||
}
|
}
|
||||||
var value any
|
var value any
|
||||||
|
var canceled error
|
||||||
err := reporter.RenderOutput(func(io.Writer) error {
|
err := reporter.RenderOutput(func(io.Writer) error {
|
||||||
values := make(map[string]any, len(documents))
|
values := make(map[string]any, len(documents))
|
||||||
for _, document := range documents {
|
for _, document := range documents {
|
||||||
if err := invocation.Context.Err(); err != nil {
|
if err := invocation.Context.Err(); err != nil {
|
||||||
return err
|
canceled = err
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
var native bytes.Buffer
|
var native bytes.Buffer
|
||||||
if err := document.view.Effective.WriteEffective(&native); err != nil {
|
if err := document.view.Effective.WriteEffective(&native); err != nil {
|
||||||
@@ -89,6 +91,9 @@ func runConfigShow[I, S any](invocation commandkit.Invocation, plan *configOpera
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
|
if canceled != nil {
|
||||||
|
return configFailure("Configuration show was canceled.", commandkit.Explanation{}, canceled)
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if canceled := invocation.Context.Err(); canceled != nil {
|
if canceled := invocation.Context.Err(); canceled != nil {
|
||||||
return configFailure("Configuration show was canceled.", commandkit.Explanation{}, canceled)
|
return configFailure("Configuration show was canceled.", commandkit.Explanation{}, canceled)
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package ckconfig
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"slices"
|
|
||||||
"text/template"
|
"text/template"
|
||||||
|
|
||||||
commandkit "go.maximumdirect.net/commandkit"
|
commandkit "go.maximumdirect.net/commandkit"
|
||||||
@@ -62,6 +61,14 @@ func runConfigValidation[I, S any](invocation commandkit.Invocation, plan *confi
|
|||||||
reporter := invocation.Reporter()
|
reporter := invocation.Reporter()
|
||||||
report := validationReport{Scopes: make([]validationScope, 0, len(plan.scopes))}
|
report := validationReport{Scopes: make([]validationScope, 0, len(plan.scopes))}
|
||||||
outcome := commandkit.Success()
|
outcome := commandkit.Success()
|
||||||
|
severities := make(map[configkit.Severity]bool, len(plan.severities))
|
||||||
|
for _, severity := range plan.severities {
|
||||||
|
severities[severity] = true
|
||||||
|
}
|
||||||
|
codes := make(map[string]bool, len(plan.codes))
|
||||||
|
for _, code := range plan.codes {
|
||||||
|
codes[code] = true
|
||||||
|
}
|
||||||
for _, selected := range plan.scopes {
|
for _, selected := range plan.scopes {
|
||||||
if err := invocation.Context.Err(); err != nil {
|
if err := invocation.Context.Err(); err != nil {
|
||||||
outcome = configFailure("Configuration validation was canceled.", commandkit.Explanation{}, err)
|
outcome = configFailure("Configuration validation was canceled.", commandkit.Explanation{}, err)
|
||||||
@@ -71,7 +78,7 @@ func runConfigValidation[I, S any](invocation commandkit.Invocation, plan *confi
|
|||||||
if selected.scope.scope.Acquire == nil {
|
if selected.scope.scope.Acquire == nil {
|
||||||
entry := validationScope{Scope: name, Outcome: "failed"}
|
entry := validationScope{Scope: name, Outcome: "failed"}
|
||||||
if plan.details {
|
if plan.details {
|
||||||
entry.Details = projectValidationDetails(View{}, plan.severities, plan.codes)
|
entry.Details = projectValidationDetails(View{}, severities, codes)
|
||||||
}
|
}
|
||||||
report.Scopes = append(report.Scopes, entry)
|
report.Scopes = append(report.Scopes, entry)
|
||||||
outcome = commandkit.Failure(fmt.Sprintf("Configuration scope %q has no acquisition callback.", name))
|
outcome = commandkit.Failure(fmt.Sprintf("Configuration scope %q has no acquisition callback.", name))
|
||||||
@@ -84,7 +91,7 @@ func runConfigValidation[I, S any](invocation commandkit.Invocation, plan *confi
|
|||||||
}
|
}
|
||||||
entry := validationScope{Scope: name, Outcome: "valid"}
|
entry := validationScope{Scope: name, Outcome: "valid"}
|
||||||
if plan.details {
|
if plan.details {
|
||||||
entry.Details = projectValidationDetails(acquired.Value, plan.severities, plan.codes)
|
entry.Details = projectValidationDetails(acquired.Value, severities, codes)
|
||||||
}
|
}
|
||||||
if canceled := invocation.Context.Err(); canceled != nil {
|
if canceled := invocation.Context.Err(); canceled != nil {
|
||||||
entry.Outcome = "canceled"
|
entry.Outcome = "canceled"
|
||||||
@@ -114,7 +121,7 @@ func runConfigValidation[I, S any](invocation commandkit.Invocation, plan *confi
|
|||||||
return outcome
|
return outcome
|
||||||
}
|
}
|
||||||
|
|
||||||
func projectValidationDetails(view View, severities []configkit.Severity, codes []string) *validationDetails {
|
func projectValidationDetails(view View, severities map[configkit.Severity]bool, codes map[string]bool) *validationDetails {
|
||||||
details := &validationDetails{State: "unavailable", Findings: []validationFinding{}, Sources: []validationSource{}}
|
details := &validationDetails{State: "unavailable", Findings: []validationFinding{}, Sources: []validationSource{}}
|
||||||
if view.Findings == nil {
|
if view.Findings == nil {
|
||||||
return details
|
return details
|
||||||
@@ -126,10 +133,10 @@ func projectValidationDetails(view View, severities []configkit.Severity, codes
|
|||||||
details.State = "filtered"
|
details.State = "filtered"
|
||||||
references := make(map[string]bool)
|
references := make(map[string]bool)
|
||||||
for _, record := range view.Findings.Records {
|
for _, record := range view.Findings.Records {
|
||||||
if len(severities) > 0 && !slices.Contains(severities, record.Severity) {
|
if len(severities) > 0 && !severities[record.Severity] {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if len(codes) > 0 && !slices.Contains(codes, string(record.Code)) {
|
if len(codes) > 0 && !codes[string(record.Code)] {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
finding := validationFinding{Code: string(record.Code), Severity: string(record.Severity), Path: record.Path.String(), Message: record.Message, Related: locationReports(record.Related)}
|
finding := validationFinding{Code: string(record.Code), Severity: string(record.Severity), Path: record.Path.String(), Message: record.Message, Related: locationReports(record.Related)}
|
||||||
|
|||||||
@@ -1,39 +1,39 @@
|
|||||||
# CommandKit ckprompt
|
# CommandKit ckprompt
|
||||||
|
|
||||||
`ckprompt` is the optional PromptKit integration for CommandKit. It adapts
|
`ckprompt` adds PromptKit inspection and testing commands to Go applications
|
||||||
PromptKit engine configuration, native comparison, five catalogs, named
|
that use CommandKit. Operators can list prompts, profiles, and backends, inspect
|
||||||
inspection, requirements discovery and validation, and native smoke tests.
|
their settings, discover required inputs, validate and preview requests, and
|
||||||
Importing it does not install either command family.
|
run smoke tests.
|
||||||
|
|
||||||
|
The application chooses which commands to install. Importing the package alone
|
||||||
|
does not install commands.
|
||||||
|
|
||||||
## Module
|
## Module
|
||||||
|
|
||||||
The module path is `go.maximumdirect.net/ckprompt`. This source uses Go 1.27.0,
|
The module path is `go.maximumdirect.net/ckprompt`. See the
|
||||||
`go.maximumdirect.net/commandkit v0.3.0`, `go.maximumdirect.net/ckconfig v0.3.0`,
|
[consumer guide](https://forge.maximumdirect.net/base-layer/commandkit/src/branch/main/docs/consumers/guide.md#module-selection)
|
||||||
`go.maximumdirect.net/configkit v1.1.0`, and `go.maximumdirect.net/promptkit v0.20.0`. See the
|
for compatible releases and Go requirements.
|
||||||
[consumer guide](https://gitea.maximumdirect.net/eric/CommandKit/src/branch/main/docs/consumers/guide.md#promptkit-configuration-definitions-and-requirements)
|
|
||||||
for callback, input, state, and acquisition contracts.
|
|
||||||
The current source requires the aligned local workspace until new core and
|
|
||||||
ckconfig releases supply its APIs.
|
|
||||||
|
|
||||||
## Example
|
## Example
|
||||||
|
|
||||||
Add `ckprompt.ConfigScope(loadEngine)` to the scopes passed to
|
Pass your engine loader to `FromEngine` to add resource listings, inspection,
|
||||||
`ckconfig.Command` when the application wants PromptKit engine configuration
|
and requirements discovery:
|
||||||
and native comparison in the `config` family. Install
|
|
||||||
`ckprompt.Command(ckprompt.FromEngine(loadEngine))` separately for catalogs,
|
```go
|
||||||
named inspections, and requirements discovery. `FromEngine` supplies nine
|
app, err := commandkit.New(identity, ckprompt.Command(ckprompt.FromEngine(loadEngine)))
|
||||||
PromptKit-backed callbacks: four inspections and five catalogs. Applications
|
```
|
||||||
may narrow, wrap, or replace them before capture or provide custom callbacks
|
|
||||||
with `ckprompt.Suite`. Add concrete request validation, prepared-request preview,
|
Add request validation, preview, and smoke tests with `WithRequestValidation`,
|
||||||
and smoke testing explicitly with `WithRequestValidation`,
|
`WithRequestPreparation`, and `WithSmokeTesting`. To expose engine configuration
|
||||||
`WithRequestPreparation`, and `WithSmokeTesting`. Inspection,
|
under `config`, add `ckprompt.ConfigScope(loadEngine)` to a `ckconfig.Suite`.
|
||||||
requirements, and catalog operations do not execute prompts or contact model
|
The [integration guide](https://forge.maximumdirect.net/base-layer/commandkit/src/branch/main/docs/consumers/guide.md#promptkit-configuration-definitions-and-requirements)
|
||||||
providers; application loaders and callbacks retain their own effects. Smoke
|
explains these choices; the [composed example](examples/composed) shows them together.
|
||||||
testing invokes the selected native PromptKit operation; the
|
|
||||||
application retains ownership of client and configuration choices. See the
|
Inspection commands do not call the model. Smoke tests can use the application's
|
||||||
[CLI reference](https://gitea.maximumdirect.net/eric/CommandKit/src/branch/main/docs/cli.md#promptkit-smoke-testing)
|
configured client or network probes. Applications own engine setup, credentials,
|
||||||
for operator commands and report semantics. The maintained
|
and any effects of their loaders and callbacks. See the
|
||||||
[composed example](examples/composed) shows complete application wiring.
|
[CLI reference](https://forge.maximumdirect.net/base-layer/commandkit/src/branch/main/docs/cli.md#promptkit-smoke-testing)
|
||||||
|
for commands and results.
|
||||||
|
|
||||||
## License and attribution
|
## License and attribution
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"go.maximumdirect.net/ckconfig"
|
"go.maximumdirect.net/ckconfig"
|
||||||
@@ -281,6 +280,9 @@ func ConfigScope[I, S any](load func(context.Context, ckconfig.Selection, I, S)
|
|||||||
if errors.As(err, &loadError) && loadError != nil {
|
if errors.As(err, &loadError) && loadError != nil {
|
||||||
acquired.Value.Sources = loadError
|
acquired.Value.Sources = loadError
|
||||||
acquired.Value.Findings = &ckconfig.Findings{Records: loadError.Diagnostics(), Messages: messages.diagnostics}
|
acquired.Value.Findings = &ckconfig.Findings{Records: loadError.Diagnostics(), Messages: messages.diagnostics}
|
||||||
|
} else if inspection != nil {
|
||||||
|
acquired.Value.Sources = *inspection
|
||||||
|
acquired.Value.Findings = &ckconfig.Findings{Records: inspection.Diagnostics(), Messages: messages.diagnostics}
|
||||||
} else {
|
} else {
|
||||||
acquired.Diagnostics = messages.diagnostics
|
acquired.Diagnostics = messages.diagnostics
|
||||||
}
|
}
|
||||||
@@ -289,9 +291,9 @@ func ConfigScope[I, S any](load func(context.Context, ckconfig.Selection, I, S)
|
|||||||
metadata := inspection.Metadata()
|
metadata := inspection.Metadata()
|
||||||
return commandkit.Acquired[ckconfig.View]{
|
return commandkit.Acquired[ckconfig.View]{
|
||||||
Value: ckconfig.View{
|
Value: ckconfig.View{
|
||||||
Effective: inspection,
|
Effective: *inspection,
|
||||||
Sources: inspection,
|
Sources: *inspection,
|
||||||
Explanation: inspection,
|
Explanation: *inspection,
|
||||||
Metadata: &metadata,
|
Metadata: &metadata,
|
||||||
Findings: &ckconfig.Findings{Records: inspection.Diagnostics(), Messages: messages.diagnostics},
|
Findings: &ckconfig.Findings{Records: inspection.Diagnostics(), Messages: messages.diagnostics},
|
||||||
},
|
},
|
||||||
@@ -322,7 +324,7 @@ func ConfigScope[I, S any](load func(context.Context, ckconfig.Selection, I, S)
|
|||||||
return commandkit.Acquired[[]configkit.Difference]{Diagnostics: messages.diagnostics, Explanation: messages.explanation}, fmt.Errorf("PromptKit scope %q comparison before native diff: %w", scope.Name, err)
|
return commandkit.Acquired[[]configkit.Difference]{Diagnostics: messages.diagnostics, Explanation: messages.explanation}, fmt.Errorf("PromptKit scope %q comparison before native diff: %w", scope.Name, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
differences, err := leftInspection.Diff(rightInspection)
|
differences, err := leftInspection.Diff(*rightInspection)
|
||||||
messages = messages.withOperationError(err)
|
messages = messages.withOperationError(err)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return commandkit.Acquired[[]configkit.Difference]{Diagnostics: messages.diagnostics, Explanation: messages.explanation}, fmt.Errorf("PromptKit scope %q native configuration diff: %w", scope.Name, err)
|
return commandkit.Acquired[[]configkit.Difference]{Diagnostics: messages.diagnostics, Explanation: messages.explanation}, fmt.Errorf("PromptKit scope %q native configuration diff: %w", scope.Name, err)
|
||||||
@@ -471,31 +473,31 @@ func loadPromptKitEngine[I, S any](
|
|||||||
state S,
|
state S,
|
||||||
side string,
|
side string,
|
||||||
load engineLoader[I, S],
|
load engineLoader[I, S],
|
||||||
) (*promptkit.Engine, promptkit.ConfigurationInspection, promptKitAcquisition, error) {
|
) (*promptkit.Engine, *promptkit.ConfigurationInspection, promptKitAcquisition, error) {
|
||||||
engine, err := load(ctx, selection, input, state)
|
engine, err := load(ctx, selection, input, state)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
var loadErr *configkit.LoadError
|
var loadErr *configkit.LoadError
|
||||||
if errors.As(err, &loadErr) {
|
if errors.As(err, &loadErr) {
|
||||||
messages := promptKitDiagnostics("promptkit", side, loadErr.Sources(), loadErr.Diagnostics())
|
messages := promptKitDiagnostics("promptkit", side, loadErr.Sources(), loadErr.Diagnostics())
|
||||||
return nil, promptkit.ConfigurationInspection{}, messages, err
|
return nil, nil, messages, err
|
||||||
}
|
}
|
||||||
return nil, promptkit.ConfigurationInspection{}, promptKitAcquisition{}, err
|
return nil, nil, promptKitAcquisition{}, err
|
||||||
}
|
}
|
||||||
if engine == nil {
|
if engine == nil {
|
||||||
return nil, promptkit.ConfigurationInspection{}, promptKitAcquisition{}, errors.New("PromptKit configuration loader returned a nil engine")
|
return nil, nil, promptKitAcquisition{}, errors.New("PromptKit configuration loader returned a nil engine")
|
||||||
}
|
}
|
||||||
inspection, err := engine.InspectConfiguration()
|
inspection, err := engine.InspectConfiguration()
|
||||||
messages := promptKitDiagnostics("promptkit", side, inspection.Sources(), inspection.Diagnostics())
|
messages := promptKitDiagnostics("promptkit", side, inspection.Sources(), inspection.Diagnostics())
|
||||||
messages = messages.withOperationError(err)
|
messages = messages.withOperationError(err)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, inspection, messages, err
|
return nil, nil, messages, err
|
||||||
}
|
}
|
||||||
if ctx != nil {
|
if ctx != nil {
|
||||||
if err := ctx.Err(); err != nil {
|
if err := ctx.Err(); err != nil {
|
||||||
return nil, promptkit.ConfigurationInspection{}, messages, err
|
return nil, &inspection, messages, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return engine, inspection, messages, nil
|
return engine, &inspection, messages, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func appendPromptKitAcquisition(left, right promptKitAcquisition) promptKitAcquisition {
|
func appendPromptKitAcquisition(left, right promptKitAcquisition) promptKitAcquisition {
|
||||||
@@ -508,61 +510,10 @@ func appendPromptKitAcquisition(left, right promptKitAcquisition) promptKitAcqui
|
|||||||
}
|
}
|
||||||
|
|
||||||
func promptKitDiagnostics(scope, side string, sources []configkit.SourceInfo, records []configkit.Diagnostic) promptKitAcquisition {
|
func promptKitDiagnostics(scope, side string, sources []configkit.SourceInfo, records []configkit.Diagnostic) promptKitAcquisition {
|
||||||
if len(records) == 0 {
|
|
||||||
return promptKitAcquisition{}
|
|
||||||
}
|
|
||||||
// Snapshot provider-owned slices before deferring their report to invocation.
|
|
||||||
sources = append([]configkit.SourceInfo(nil), sources...)
|
|
||||||
records = append([]configkit.Diagnostic(nil), records...)
|
|
||||||
for index := range records {
|
|
||||||
if records[index].Primary != nil {
|
|
||||||
primary := *records[index].Primary
|
|
||||||
records[index].Primary = &primary
|
|
||||||
}
|
|
||||||
records[index].Related = append([]configkit.Location(nil), records[index].Related...)
|
|
||||||
}
|
|
||||||
var warnings, failures []configkit.Diagnostic
|
|
||||||
for _, record := range records {
|
|
||||||
if record.Severity == configkit.SeverityError {
|
|
||||||
failures = append(failures, record)
|
|
||||||
} else {
|
|
||||||
warnings = append(warnings, record)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
messages := promptKitAcquisition{}
|
|
||||||
if len(warnings) > 0 {
|
|
||||||
messages.diagnostics = []commandkit.Diagnostic{{
|
|
||||||
Severity: commandkit.SeverityWarning,
|
|
||||||
Write: func(writer io.Writer) error {
|
|
||||||
return writePromptKitDiagnostics(writer, scope, side, sources, warnings)
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
}
|
|
||||||
if len(failures) > 0 {
|
|
||||||
messages.explanation = commandkit.Explanation{Details: []commandkit.Detail{func(writer io.Writer) error {
|
|
||||||
return writePromptKitDiagnostics(writer, scope, side, sources, failures)
|
|
||||||
}}}
|
|
||||||
}
|
|
||||||
return messages
|
|
||||||
}
|
|
||||||
|
|
||||||
func writePromptKitDiagnostics(writer io.Writer, scope, side string, sources []configkit.SourceInfo, records []configkit.Diagnostic) error {
|
|
||||||
heading := "Configuration scope " + strconv.Quote(scope)
|
heading := "Configuration scope " + strconv.Quote(scope)
|
||||||
if side != "" {
|
if side != "" {
|
||||||
heading += " (" + side + ")"
|
heading += " (" + side + ")"
|
||||||
}
|
}
|
||||||
if _, err := fmt.Fprintf(writer, "%s:\n", heading); err != nil {
|
diagnostics, explanation := ckconfig.AdaptDiagnostics(heading+":\n", sources, records)
|
||||||
return err
|
return promptKitAcquisition{diagnostics: diagnostics, explanation: explanation}
|
||||||
}
|
|
||||||
if len(sources) > 0 {
|
|
||||||
if _, err := io.WriteString(writer, "Source IDs:\n"); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
for _, source := range sources {
|
|
||||||
if _, err := fmt.Fprintf(writer, " %s: label=%s, path=%s\n", strconv.Quote(source.ID), strconv.Quote(source.Label), strconv.Quote(source.Path)); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return configkit.WriteDiagnostics(writer, records)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"reflect"
|
"reflect"
|
||||||
"slices"
|
"slices"
|
||||||
@@ -858,18 +859,79 @@ func TestFromEngineRetainsConfigKitLoadExplanation(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestConfigScopeHonorsCancellationAfterLoad(t *testing.T) {
|
func TestConfigScopeRetainsCapturedEvidenceAfterCancellation(t *testing.T) {
|
||||||
|
engine := adapterEngine(t)
|
||||||
|
native, err := engine.InspectConfiguration()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
defer cancel()
|
defer cancel()
|
||||||
engine := adapterEngine(t)
|
scope := ConfigScope(func(context.Context, ckconfig.Selection, struct{}, struct{}) (*promptkit.Engine, error) {
|
||||||
scope := ConfigScope(func(context.Context, ckconfig.Selection, int, int) (*promptkit.Engine, error) {
|
|
||||||
cancel()
|
cancel()
|
||||||
return engine, nil
|
return engine, nil
|
||||||
})
|
})
|
||||||
|
acquired, err := scope.Acquire(ctx, ckconfig.Request{}, struct{}{}, struct{}{})
|
||||||
acquired, err := scope.Acquire(ctx, ckconfig.Request{Operation: ckconfig.Show}, 0, 0)
|
if !errors.Is(err, context.Canceled) || acquired.Value.Effective != nil || acquired.Value.Metadata != nil || acquired.Value.Explanation != nil || acquired.Value.Sources == nil || acquired.Value.Findings == nil {
|
||||||
if !errors.Is(err, context.Canceled) || acquired.Value.Effective != nil {
|
t.Fatalf("canceled acquisition = %#v, %v", acquired, err)
|
||||||
t.Fatalf("cancelled scope acquisition = (%#v, %v), want cancellation and no value", acquired, err)
|
}
|
||||||
|
if !reflect.DeepEqual(acquired.Value.Sources.Sources(), native.Sources()) || !reflect.DeepEqual(acquired.Value.Findings.Records, native.Diagnostics()) {
|
||||||
|
t.Fatalf("lost captured evidence: %#v", acquired.Value)
|
||||||
|
}
|
||||||
|
for _, operation := range []string{"sources", "validate"} {
|
||||||
|
for _, failOutput := range []bool{false, true} {
|
||||||
|
t.Run(fmt.Sprintf("%s/failOutput=%t", operation, failOutput), func(t *testing.T) {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
scope := ConfigScope(func(context.Context, ckconfig.Selection, struct{}, struct{}) (*promptkit.Engine, error) {
|
||||||
|
cancel()
|
||||||
|
return engine, nil
|
||||||
|
})
|
||||||
|
unvisited := 0
|
||||||
|
app, err := commandkit.New(commandkit.Identity{Name: "fixture"}, ckconfig.Command(ckconfig.Suite[struct{}, struct{}]{Scopes: []ckconfig.Scope[struct{}, struct{}]{
|
||||||
|
{Name: "app", Operations: []ckconfig.Operation{ckconfig.Validate}, Acquire: func(context.Context, ckconfig.Request, struct{}, struct{}) (commandkit.Acquired[ckconfig.View], error) {
|
||||||
|
return commandkit.Acquired[ckconfig.View]{}, nil
|
||||||
|
}},
|
||||||
|
scope,
|
||||||
|
{Name: "zeta", Operations: []ckconfig.Operation{ckconfig.Validate}, Acquire: func(context.Context, ckconfig.Request, struct{}, struct{}) (commandkit.Acquired[ckconfig.View], error) {
|
||||||
|
unvisited++
|
||||||
|
return commandkit.Acquired[ckconfig.View]{}, nil
|
||||||
|
}},
|
||||||
|
}}))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
args := []string{"config", operation, "--scope=promptkit", "--format=json"}
|
||||||
|
if operation == "validate" {
|
||||||
|
args = []string{"config", "validate", "--scope=all", "--details", "--format=json"}
|
||||||
|
}
|
||||||
|
var out bytes.Buffer
|
||||||
|
var writer io.Writer = &out
|
||||||
|
writeErr := errors.New("output failed")
|
||||||
|
if failOutput {
|
||||||
|
writer = failingDiagnosticWriter{err: writeErr}
|
||||||
|
}
|
||||||
|
got := app.Execute(ctx, args, commandkit.Streams{Out: writer, Err: io.Discard})
|
||||||
|
if !errors.Is(got.Cause, context.Canceled) || unvisited != 0 {
|
||||||
|
t.Fatalf("outcome=%#v unvisited=%d", got, unvisited)
|
||||||
|
}
|
||||||
|
if failOutput {
|
||||||
|
if !errors.Is(got.Presentation.Output.WriteError, writeErr) {
|
||||||
|
t.Fatalf("lost presentation failure: %#v", got)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if operation == "sources" {
|
||||||
|
for _, source := range native.Sources() {
|
||||||
|
if !strings.Contains(out.String(), source.ID) {
|
||||||
|
t.Fatalf("missing acquired source: %s", out.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if !strings.Contains(out.String(), `"state": "empty"`) || !strings.Contains(out.String(), `"scope": "app"`) || strings.Contains(out.String(), `"scope": "zeta"`) {
|
||||||
|
t.Fatalf("reached-scope evidence: %s", out.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ func buildCommand[I, S any](suite Suite[I, S], aliases *[]commandkit.Declaration
|
|||||||
showCommands = append(showCommands, newPreparationCommand(suite, aliases))
|
showCommands = append(showCommands, newPreparationCommand(suite, aliases))
|
||||||
}
|
}
|
||||||
if len(showCommands) != 0 {
|
if len(showCommands) != 0 {
|
||||||
rootCommands = append(rootCommands, commandkit.Group("show", "Show a named PromptKit definition.", showCommands...))
|
rootCommands = append(rootCommands, commandkit.Group("show", "Show PromptKit definitions or preview a prepared request.", showCommands...))
|
||||||
}
|
}
|
||||||
listCommands := make([]commandkit.Command, 0, 5)
|
listCommands := make([]commandkit.Command, 0, 5)
|
||||||
if suite.ListPrompts != nil {
|
if suite.ListPrompts != nil {
|
||||||
@@ -110,7 +110,7 @@ func buildCommand[I, S any](suite Suite[I, S], aliases *[]commandkit.Declaration
|
|||||||
listCommands = append(listCommands, newCatalogListCommand(suite, "collections"))
|
listCommands = append(listCommands, newCatalogListCommand(suite, "collections"))
|
||||||
}
|
}
|
||||||
if len(listCommands) != 0 {
|
if len(listCommands) != 0 {
|
||||||
rootCommands = append(rootCommands, commandkit.Group("list", "List PromptKit catalogs.", listCommands...))
|
rootCommands = append(rootCommands, commandkit.Group("list", "List available PromptKit resources.", listCommands...))
|
||||||
}
|
}
|
||||||
requirementsCommands := make([]commandkit.Command, 0, 2)
|
requirementsCommands := make([]commandkit.Command, 0, 2)
|
||||||
if suite.InspectRequirements != nil {
|
if suite.InspectRequirements != nil {
|
||||||
@@ -125,7 +125,7 @@ func buildCommand[I, S any](suite Suite[I, S], aliases *[]commandkit.Declaration
|
|||||||
if suite.SmokeTest != nil {
|
if suite.SmokeTest != nil {
|
||||||
rootCommands = append(rootCommands, newSmokeTestCommand(suite, aliases))
|
rootCommands = append(rootCommands, newSmokeTestCommand(suite, aliases))
|
||||||
}
|
}
|
||||||
command := commandkit.RootGroup("promptkit", "Inspect, list, validate requests, and run PromptKit smoke tests.", rootCommands...)
|
command := commandkit.RootGroup("promptkit", "Inspect PromptKit resources, check requests, and run smoke tests.", rootCommands...)
|
||||||
for _, issue := range suite.issues {
|
for _, issue := range suite.issues {
|
||||||
command = commandkit.WithError(command, issue)
|
command = commandkit.WithError(command, issue)
|
||||||
}
|
}
|
||||||
@@ -140,16 +140,16 @@ func newCatalogListCommand[I, S any](suite Suite[I, S], name string) commandkit.
|
|||||||
return &(*input).captured
|
return &(*input).captured
|
||||||
})
|
})
|
||||||
flags = append(flags,
|
flags = append(flags,
|
||||||
commandkit.ValueFlag(commandkit.FlagMeta{Name: "config", Usage: "Configuration input path.", Completion: commandkit.CompleteFiles()}, (*string)(nil), parseDefinitionString,
|
commandkit.ValueFlag(commandkit.FlagMeta{Name: "config", Usage: "Select the configuration input path.", Completion: commandkit.CompleteFiles()}, (*string)(nil), parseDefinitionString,
|
||||||
func(input **catalogInput[I], value commandkit.Supplied[string]) { (*input).selection.Config = value }),
|
func(input **catalogInput[I], value commandkit.Supplied[string]) { (*input).selection.Config = value }),
|
||||||
commandkit.ValueFlag(commandkit.FlagMeta{Name: "overlay", Usage: "Configuration overlay path.", Completion: commandkit.CompleteFiles()}, (*string)(nil), parseDefinitionString,
|
commandkit.ValueFlag(commandkit.FlagMeta{Name: "overlay", Usage: "Select the configuration overlay path.", Completion: commandkit.CompleteFiles()}, (*string)(nil), parseDefinitionString,
|
||||||
func(input **catalogInput[I], value commandkit.Supplied[string]) { (*input).selection.Overlay = value }),
|
func(input **catalogInput[I], value commandkit.Supplied[string]) { (*input).selection.Overlay = value }),
|
||||||
)
|
)
|
||||||
if name == "prompts" || name == "profiles" || name == "backends" {
|
if name == "prompts" || name == "profiles" || name == "backends" {
|
||||||
flags = append(flags,
|
flags = append(flags,
|
||||||
commandkit.ValueFlag(commandkit.FlagMeta{Name: "namespace", Usage: "Select one native namespace."}, (*string)(nil), parseDefinitionString,
|
commandkit.ValueFlag(commandkit.FlagMeta{Name: "namespace", Usage: "List resources in one namespace."}, (*string)(nil), parseDefinitionString,
|
||||||
func(input **catalogInput[I], value commandkit.Supplied[string]) { (*input).namespace = value }),
|
func(input **catalogInput[I], value commandkit.Supplied[string]) { (*input).namespace = value }),
|
||||||
commandkit.BoolFlag(commandkit.FlagMeta{Name: "all-namespaces", Usage: "Select identities from every namespace."}, false,
|
commandkit.BoolFlag(commandkit.FlagMeta{Name: "all-namespaces", Usage: "List resources from every namespace."}, false,
|
||||||
func(input **catalogInput[I], value commandkit.Supplied[bool]) { (*input).allNamespaces = value }),
|
func(input **catalogInput[I], value commandkit.Supplied[bool]) { (*input).allNamespaces = value }),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -243,9 +243,9 @@ func newDefinitionCommand[I, S any](suite Suite[I, S], name string, aliases *[]c
|
|||||||
return &(*input).captured
|
return &(*input).captured
|
||||||
})
|
})
|
||||||
flags = append(flags,
|
flags = append(flags,
|
||||||
commandkit.ValueFlag(commandkit.FlagMeta{Name: "config", Usage: "Configuration input path.", Completion: commandkit.CompleteFiles()}, (*string)(nil), parseDefinitionString,
|
commandkit.ValueFlag(commandkit.FlagMeta{Name: "config", Usage: "Select the configuration input path.", Completion: commandkit.CompleteFiles()}, (*string)(nil), parseDefinitionString,
|
||||||
func(input **definitionInput[I], value commandkit.Supplied[string]) { (*input).selection.Config = value }),
|
func(input **definitionInput[I], value commandkit.Supplied[string]) { (*input).selection.Config = value }),
|
||||||
commandkit.ValueFlag(commandkit.FlagMeta{Name: "overlay", Usage: "Configuration overlay path.", Completion: commandkit.CompleteFiles()}, (*string)(nil), parseDefinitionString,
|
commandkit.ValueFlag(commandkit.FlagMeta{Name: "overlay", Usage: "Select the configuration overlay path.", Completion: commandkit.CompleteFiles()}, (*string)(nil), parseDefinitionString,
|
||||||
func(input **definitionInput[I], value commandkit.Supplied[string]) {
|
func(input **definitionInput[I], value commandkit.Supplied[string]) {
|
||||||
(*input).selection.Overlay = value
|
(*input).selection.Overlay = value
|
||||||
}),
|
}),
|
||||||
@@ -288,11 +288,11 @@ func newRequirementsCommand[I, S any](suite Suite[I, S], aliases *[]commandkit.D
|
|||||||
return &(*input).captured
|
return &(*input).captured
|
||||||
})
|
})
|
||||||
flags = append(flags,
|
flags = append(flags,
|
||||||
commandkit.ValueFlag(commandkit.FlagMeta{Name: "config", Usage: "Configuration input path.", Completion: commandkit.CompleteFiles()}, (*string)(nil), parseDefinitionString,
|
commandkit.ValueFlag(commandkit.FlagMeta{Name: "config", Usage: "Select the configuration input path.", Completion: commandkit.CompleteFiles()}, (*string)(nil), parseDefinitionString,
|
||||||
func(input **requirementsInput[I], value commandkit.Supplied[string]) {
|
func(input **requirementsInput[I], value commandkit.Supplied[string]) {
|
||||||
(*input).selection.Config = value
|
(*input).selection.Config = value
|
||||||
}),
|
}),
|
||||||
commandkit.ValueFlag(commandkit.FlagMeta{Name: "overlay", Usage: "Configuration overlay path.", Completion: commandkit.CompleteFiles()}, (*string)(nil), parseDefinitionString,
|
commandkit.ValueFlag(commandkit.FlagMeta{Name: "overlay", Usage: "Select the configuration overlay path.", Completion: commandkit.CompleteFiles()}, (*string)(nil), parseDefinitionString,
|
||||||
func(input **requirementsInput[I], value commandkit.Supplied[string]) {
|
func(input **requirementsInput[I], value commandkit.Supplied[string]) {
|
||||||
(*input).selection.Overlay = value
|
(*input).selection.Overlay = value
|
||||||
}),
|
}),
|
||||||
@@ -308,8 +308,8 @@ func newRequirementsCommand[I, S any](suite Suite[I, S], aliases *[]commandkit.D
|
|||||||
|
|
||||||
spec := commandkit.CommandSpec[*requirementsInput[I]]{
|
spec := commandkit.CommandSpec[*requirementsInput[I]]{
|
||||||
Name: "show",
|
Name: "show",
|
||||||
Summary: "Inspect requirements for a named PromptKit prompt.",
|
Summary: "Discover the inputs and variables a prompt needs.",
|
||||||
Description: "Profile selection establishes the reported profile identity, not a different set of requirements. A profile namespace qualifies an explicitly supplied short profile ID, not the prompt’s stored default reference.",
|
Description: "List required inputs without running the prompt. Choosing a profile changes the reported profile ID, not the requirements. A profile namespace applies to a short profile ID supplied here; it does not change the prompt’s stored default reference.",
|
||||||
NewInput: func() *requirementsInput[I] {
|
NewInput: func() *requirementsInput[I] {
|
||||||
return &requirementsInput[I]{}
|
return &requirementsInput[I]{}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
// Package ckprompt, provided by go.maximumdirect.net/ckprompt, provides optional
|
// Package ckprompt adds PromptKit inspection and testing commands to CommandKit
|
||||||
// PromptKit configuration comparison,
|
// applications. Operators can list resources, inspect definitions, discover
|
||||||
// prompt catalogs, named inspections, requirements discovery and validation,
|
// required inputs, validate and preview requests, and run smoke tests.
|
||||||
// prepared-request preview, and native smoke testing for CommandKit. Command
|
//
|
||||||
// returns a canonical declaration; Declarations adds scoped flag aliases.
|
// Start with FromEngine and add optional operations with WithRequestValidation,
|
||||||
// Applications can install those alongside other declarations through
|
// WithRequestPreparation, and WithSmokeTesting. Command returns the command
|
||||||
// commandkit.New or App.Register. Importing ckprompt installs neither family; callers compose its
|
// family; Declarations also includes its convenience flag aliases. Install
|
||||||
// declarations with core and ckconfig explicitly. Provider diagnostics remain
|
// these with commandkit.New or App.Register.
|
||||||
// passive until the owning command submits them through the invocation
|
//
|
||||||
// reporter, separately from any terminal explanation. This module uses
|
// ConfigScope separately exposes engine configuration through ckconfig.
|
||||||
// PromptKit v0.20.0 and its native engine inventory, including configured
|
// Importing this package installs neither command family. Applications own
|
||||||
// bundled resources.
|
// engine construction, credentials, and client configuration.
|
||||||
package ckprompt
|
package ckprompt
|
||||||
|
|||||||
@@ -85,8 +85,9 @@ root): `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
|
||||||
the core module. The release policy uses baseline
|
the core module. 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
|
||||||
@@ -97,12 +98,16 @@ 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).
|
||||||
|
|
||||||
## go-yaml v4
|
## go-yaml v4
|
||||||
|
|
||||||
Selected module: `go.yaml.in/yaml/v4` at `v4.0.0-rc.6`.
|
Selected module: `go.yaml.in/yaml/v4` at `v4.0.0-rc.6`.
|
||||||
|
|||||||
4
integrations/ckprompt/docs/releases/errata.md
Normal file
4
integrations/ckprompt/docs/releases/errata.md
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
# ckprompt release errata
|
||||||
|
|
||||||
|
The v0.2.0 release note links to the wrong consumer-guide section. The correct
|
||||||
|
pinned section is [PromptKit configuration, definitions, and requirements](https://forge.maximumdirect.net/base-layer/commandkit/src/tag/integrations/ckprompt/v0.2.0/docs/consumers/guide.md#promptkit-configuration-definitions-and-requirements).
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
# CommandKit ckprompt v0.3.0
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
This candidate adopts `go.maximumdirect.net/promptkit v0.18.0`,
|
|
||||||
`go.maximumdirect.net/configkit v1.1.0`, and
|
|
||||||
`go.maximumdirect.net/ckconfig v0.3.0`, while retaining `go.maximumdirect.net/commandkit v0.3.0`. It requires
|
|
||||||
Go 1.27.0. Existing configuration, catalog, inspection, validation, and smoke
|
|
||||||
operations continue to forward the selected engine's native results.
|
|
||||||
|
|
||||||
## Migration
|
|
||||||
|
|
||||||
Change PromptKit and ConfigKit imports to `go.maximumdirect.net/promptkit` and
|
|
||||||
`go.maximumdirect.net/configkit`. Update loaders and consumers together because
|
|
||||||
values from the old and new paths have distinct Go type identities. Migrate
|
|
||||||
PromptKit prompt, profile, and backend maps to lists with explicit IDs, and
|
|
||||||
update source builders, inheritance, import requiredness, and source-root
|
|
||||||
controls as needed. See the pinned [upstream migration guide](https://forge.maximumdirect.net/base-layer/promptkit/src/commit/d9f6aad30509e538fc73ea16710df3a3ec084502/docs/consumers/configuration-migration.md).
|
|
||||||
|
|
||||||
Prompt catalogs, prompt and requirements inspections, and smoke targets no
|
|
||||||
longer include descriptive prompt versions. Update report consumers accordingly;
|
|
||||||
collection release labels and source version evidence remain. Configuration
|
|
||||||
inspection now exposes native normalized values and source evidence, and
|
|
||||||
native comparisons treat lists atomically.
|
|
||||||
|
|
||||||
PromptKit's default OpenRouter bundle supplies a backend and root profiles.
|
|
||||||
They appear in catalog and configuration reports when enabled. Applications
|
|
||||||
that need an application-only inventory may choose
|
|
||||||
`promptkit.WithBundledOpenRouter(false)` in their engine loader. Review smoke
|
|
||||||
selections and configure appropriate clients and HTTP probes for the application.
|
|
||||||
|
|
||||||
[COPYRIGHT](https://forge.maximumdirect.net/base-layer/commandkit/src/tag/integrations/ckprompt/v0.3.0/integrations/ckprompt/COPYRIGHT) |
|
|
||||||
[LICENSE](https://forge.maximumdirect.net/base-layer/commandkit/src/tag/integrations/ckprompt/v0.3.0/integrations/ckprompt/LICENSE) |
|
|
||||||
[ATTRIBUTION](https://forge.maximumdirect.net/base-layer/commandkit/src/tag/integrations/ckprompt/v0.3.0/integrations/ckprompt/docs/oss/attribution.md)
|
|
||||||
52
integrations/ckprompt/docs/releases/v0.5.0.md
Normal file
52
integrations/ckprompt/docs/releases/v0.5.0.md
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
# CommandKit ckprompt v0.5.0
|
||||||
|
|
||||||
|
CommandKit ckprompt v0.5.0 adds PromptKit v0.20.0 support, including request
|
||||||
|
preview, namespace selection, and shared text, YAML, and JSON output. It requires
|
||||||
|
CommandKit core v0.5.0, ckconfig v0.5.0, ConfigKit v1.1.0, and Go 1.27.0.
|
||||||
|
|
||||||
|
## Operator features
|
||||||
|
|
||||||
|
- Inspect prompts, profiles, and backends, and list available resources.
|
||||||
|
- Preview rendered requests without calling the model, or validate that a
|
||||||
|
request can be prepared with the supplied inputs.
|
||||||
|
- Choose namespaces and override request settings for supported commands.
|
||||||
|
- Run smoke tests and inspect their results, including incomplete or unsupported
|
||||||
|
checks and reports returned alongside errors.
|
||||||
|
- Inspect engine configuration through `config`, including detailed validation
|
||||||
|
findings, source information after failed loads, and configuration comparisons.
|
||||||
|
|
||||||
|
Applications choose which commands to enable. Reusable flag collections also
|
||||||
|
let application commands offer the same request flags. `ckprompt.Declarations`
|
||||||
|
adds the standard commands with their shorter flag aliases. Literal and file
|
||||||
|
inputs keep their command-line order.
|
||||||
|
|
||||||
|
## Correctness improvements
|
||||||
|
|
||||||
|
Aggregate smoke testing retains cancellation when it stops remaining tests,
|
||||||
|
including previously returned errors and reports. A prepared-request preview
|
||||||
|
fails if the provider returns no prepared request without an error. Parsed
|
||||||
|
provider-parameter objects are copied between independent request destinations,
|
||||||
|
so mutations in one callback do not change later requests. Configuration
|
||||||
|
inspection shares ckconfig's native diagnostic adapter.
|
||||||
|
|
||||||
|
## Compatibility and upgrade
|
||||||
|
|
||||||
|
This pre-1.0 minor release changes resource report shapes, input flags,
|
||||||
|
formatting, and configuration comparison behavior. Review the current
|
||||||
|
[CLI reference](../../../../docs/cli.md#promptkit-definition-inspection) and
|
||||||
|
[consumer guide](../../../../docs/consumers/guide.md#promptkit-configuration-definitions-and-requirements)
|
||||||
|
when updating declarations or code that reads reports. Applications continue to
|
||||||
|
own engine construction, credentials, and execution.
|
||||||
|
|
||||||
|
Upgrade all three CommandKit modules to v0.5.0 together. This release follows
|
||||||
|
published ckprompt v0.2.0 and adopts the vanity imports
|
||||||
|
`go.maximumdirect.net/configkit` and `go.maximumdirect.net/promptkit`.
|
||||||
|
Replace earlier upstream import paths consistently; types from different
|
||||||
|
module identities cannot be mixed. Review callers of the former prompt
|
||||||
|
version-report API and use native prompt, profile, and backend inspections.
|
||||||
|
The [module selection table](../../../../docs/consumers/guide.md#module-selection)
|
||||||
|
records the release dependencies.
|
||||||
|
|
||||||
|
[COPYRIGHT](https://forge.maximumdirect.net/base-layer/commandkit/src/tag/integrations/ckprompt/v0.5.0/integrations/ckprompt/COPYRIGHT) |
|
||||||
|
[LICENSE](https://forge.maximumdirect.net/base-layer/commandkit/src/tag/integrations/ckprompt/v0.5.0/integrations/ckprompt/LICENSE) |
|
||||||
|
[ATTRIBUTION](https://forge.maximumdirect.net/base-layer/commandkit/src/tag/integrations/ckprompt/v0.5.0/integrations/ckprompt/docs/oss/attribution.md)
|
||||||
16
integrations/ckprompt/docs/releases/v0.6.0.md
Normal file
16
integrations/ckprompt/docs/releases/v0.6.0.md
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
# CommandKit ckprompt v0.6.0
|
||||||
|
|
||||||
|
CommandKit ckprompt v0.6.0 selects CommandKit core and ckconfig v0.6.0.
|
||||||
|
Core adds shared debug input bindings and access to the selected reporting mode.
|
||||||
|
ConfigKit remains at v1.1.0 and PromptKit at v0.20.0.
|
||||||
|
|
||||||
|
## Compatibility and upgrade
|
||||||
|
|
||||||
|
Select `go.maximumdirect.net/ckprompt v0.6.0` with CommandKit core and ckconfig
|
||||||
|
v0.6.0. The consumer minimum remains Go 1.27.0. See the
|
||||||
|
[consumer guide](../../../../docs/consumers/guide.md#invocation-reporting)
|
||||||
|
for the new core reporting and debug input APIs.
|
||||||
|
|
||||||
|
[COPYRIGHT](https://forge.maximumdirect.net/base-layer/commandkit/src/tag/integrations/ckprompt/v0.6.0/integrations/ckprompt/COPYRIGHT) |
|
||||||
|
[LICENSE](https://forge.maximumdirect.net/base-layer/commandkit/src/tag/integrations/ckprompt/v0.6.0/integrations/ckprompt/LICENSE) |
|
||||||
|
[ATTRIBUTION](https://forge.maximumdirect.net/base-layer/commandkit/src/tag/integrations/ckprompt/v0.6.0/integrations/ckprompt/docs/oss/attribution.md)
|
||||||
@@ -1,144 +1,171 @@
|
|||||||
# Composed consumer
|
# ConfigKit and PromptKit example
|
||||||
|
|
||||||
This example is part of the ckprompt module and uses core v0.3.0, ckconfig
|
This example combines application and PromptKit configuration, resource
|
||||||
v0.3.0, ConfigKit v1.1.0, and PromptKit v0.20.0. It requires Go 1.27.0.
|
inspection, request checks, and smoke tests. It uses local fixtures and an
|
||||||
The current work uses the aligned local workspace until the required core and
|
in-memory model client. **No model service or credentials are required.**
|
||||||
ckconfig releases are published.
|
|
||||||
|
|
||||||
This example composes application configuration, PromptKit configuration,
|
Use Go 1.27.0 on `PATH`. An aligned workspace containing all three local modules
|
||||||
PromptKit comparison and catalogs, definition and requirements inspection, and
|
exercises their current sources; follow the [workspace setup](../../../../docs/development.md#documentation-validation)
|
||||||
one application-owned command:
|
from the repository root. The example also builds independently with
|
||||||
|
`GOWORK=off` against its module's released dependencies.
|
||||||
|
The [consumer guide](../../../../docs/consumers/guide.md#module-selection) owns
|
||||||
|
version and compatibility details.
|
||||||
|
|
||||||
|
From the repository root:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
cd integrations/ckprompt/examples/composed
|
||||||
|
export GOWORK=auto GOTOOLCHAIN=local
|
||||||
go run . config scopes
|
go run . config scopes
|
||||||
|
go run . promptkit list prompts --campaign spring
|
||||||
|
go run . promptkit show prompt briefing --campaign spring --format yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
The first command lists `app` and `promptkit` configurations and their available
|
||||||
|
operations. The next commands find and inspect the `briefing` prompt.
|
||||||
|
`--campaign` is a selector supplied by this example, not a standard PromptKit flag.
|
||||||
|
|
||||||
|
## Validate and compare configuration
|
||||||
|
|
||||||
|
```sh
|
||||||
go run . config validate --scope all --campaign spring --limit=0
|
go run . config validate --scope all --campaign spring --limit=0
|
||||||
go run . config diff --scope app --config fixtures/before.yml --against-config fixtures/after.yml
|
go run . config diff --scope app --config fixtures/before.yml --against-config fixtures/after.yml
|
||||||
go run . config diff --scope app --config fixtures/before.yml --against-config fixtures/after.yml --campaign spring --limit=0
|
|
||||||
go run . config diff --scope app --campaign spring --limit=0 --against-campaign autumn --against-limit=0 --set '/campaigns/spring/model="left"' --against-set '/campaigns/spring/model="right"' --details
|
|
||||||
go run . config diff --scope promptkit --config fixtures/before.yml --against-config fixtures/after.yml --campaign spring --against-campaign spring
|
go run . config diff --scope promptkit --config fixtures/before.yml --against-config fixtures/after.yml --campaign spring --against-campaign spring
|
||||||
go run . promptkit list prompts --config fixtures/before.yml --campaign spring
|
```
|
||||||
|
|
||||||
|
The application scope requires `--limit=0` when `--campaign` is supplied.
|
||||||
|
The app comparison reports a changed default campaign and `campaigns.spring.model`.
|
||||||
|
The PromptKit comparison selects spring on both sides and reports the changed
|
||||||
|
profiles list as one value.
|
||||||
|
|
||||||
|
The two sides have independent selectors and overrides:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go run . config diff --scope app --campaign spring --limit=0 --against-campaign autumn --against-limit=0 --set '/campaigns/spring/model="left"' --against-set '/campaigns/spring/model="right"' --details
|
||||||
|
```
|
||||||
|
|
||||||
|
Only the application scope supports `--set` here. The released PromptKit loader
|
||||||
|
has no override handoff, so its configuration scope does not enable the feature.
|
||||||
|
An aggregate operation that includes that scope rejects overrides.
|
||||||
|
|
||||||
|
## List resources and select namespaces
|
||||||
|
|
||||||
|
```sh
|
||||||
go run . promptkit list profiles --config fixtures/after.yml --campaign autumn --format yaml
|
go run . promptkit list profiles --config fixtures/after.yml --campaign autumn --format yaml
|
||||||
go run . promptkit list backends --campaign spring
|
go run . promptkit list backends --campaign spring
|
||||||
go run . promptkit list namespaces --campaign spring
|
go run . promptkit list namespaces --campaign spring
|
||||||
go run . promptkit list collections --campaign spring
|
go run . promptkit list collections --campaign spring
|
||||||
go run . list collections --campaign spring
|
|
||||||
go run . pk list bundles --campaign spring
|
|
||||||
go run . promptkit list prompts --namespace field-notes --campaign spring
|
go run . promptkit list prompts --namespace field-notes --campaign spring
|
||||||
go run . promptkit show prompt briefing --campaign spring --format yaml
|
|
||||||
go run . promptkit show prompt briefing --campaign spring --prompt-namespace field-notes
|
go run . promptkit show prompt briefing --campaign spring --prompt-namespace field-notes
|
||||||
go run . promptkit show prepared briefing --campaign spring --input topic=sprint --profile spring --extra-params '{"campaign":"preview"}'
|
|
||||||
go run . pk show prompt briefing --campaign spring --fmt yaml
|
|
||||||
go run . promptkit show profile spring --campaign spring
|
go run . promptkit show profile spring --campaign spring
|
||||||
|
```
|
||||||
|
|
||||||
|
The profile list includes the selected `autumn` profile. PromptKit's default
|
||||||
|
bundle contributes additional resources. The example also supplies a
|
||||||
|
`field-notes` collection containing a backend, profile, and prompt. Listing
|
||||||
|
filters and resource namespace flags let you inspect that collection.
|
||||||
|
|
||||||
|
## Discover inputs, then check a request
|
||||||
|
|
||||||
|
```sh
|
||||||
go run . promptkit requirements show briefing --campaign spring
|
go run . promptkit requirements show briefing --campaign spring
|
||||||
go run . promptkit requirements show briefing --campaign spring --var mode=full --format yaml
|
go run . promptkit requirements show briefing --campaign spring --var mode=full --format yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
The first report is incomplete: the `notes` input is conditional and `mode` is
|
||||||
|
missing. Supplying `mode=full` selects that branch and makes `notes` required.
|
||||||
|
Both commands succeed; discovery alone does not establish that a request can run.
|
||||||
|
|
||||||
|
```sh
|
||||||
go run . promptkit requirements validate briefing --campaign spring --llm-input-file topic=fixtures/topic.txt
|
go run . promptkit requirements validate briefing --campaign spring --llm-input-file topic=fixtures/topic.txt
|
||||||
go run . promptkit requirements validate briefing --campaign spring --llm-input-file topic=fixtures/topic.txt --llm-var mode=full --llm-input-file notes=fixtures/notes.txt --format yaml
|
go run . promptkit requirements validate briefing --campaign spring --llm-input-file topic=fixtures/topic.txt --llm-var mode=full --llm-input-file notes=fixtures/notes.txt --format yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
Both commands check request preparation and report `prompt_id` and `valid: true`.
|
||||||
|
The builder provides campaign-specific variable defaults; the operator supplies
|
||||||
|
the input files. For comparison, this command intentionally fails because the
|
||||||
|
required `topic` input is missing:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# Intentional failure: missing topic input.
|
||||||
go run . promptkit requirements validate briefing --campaign spring
|
go run . promptkit requirements validate briefing --campaign spring
|
||||||
|
```
|
||||||
|
|
||||||
|
Preview a request with literal input instead of a file:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go run . promptkit show prepared briefing --campaign spring --input topic=sprint --profile spring --extra-params '{"campaign":"preview"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
The preview includes rendered messages and effective provider settings.
|
||||||
|
Validation and preview do not call the model or guarantee later execution.
|
||||||
|
|
||||||
|
## Run smoke tests
|
||||||
|
|
||||||
|
```sh
|
||||||
go run . promptkit test text_inference --campaign spring --prompt briefing --profile spring
|
go run . promptkit test text_inference --campaign spring --prompt briefing --profile spring
|
||||||
go run . smoke text_inference --campaign spring --llm-prompt briefing --llm-profile spring
|
# Some diagnostics can fail or be unsupported; inspect the retained report.
|
||||||
go run . promptkit test all --campaign spring --llm-prompt briefing --llm-profile spring
|
go run . promptkit test all --campaign spring --llm-prompt briefing --llm-profile spring
|
||||||
|
```
|
||||||
|
|
||||||
|
Inference uses the example's in-memory client. HTTP checks may be unsupported
|
||||||
|
or not applicable because the local backend has no network probe. The aggregate
|
||||||
|
can return a failure while still showing results. Read the facts in the report;
|
||||||
|
a successful command status does not mean every observation is positive.
|
||||||
|
|
||||||
|
For any command that returns nonzero, `go run` prints `exit status N` and itself
|
||||||
|
exits with status 1. A built executable returns the application's status directly.
|
||||||
|
The [CLI reference](../../../../docs/cli.md#promptkit-smoke-testing) explains the
|
||||||
|
standard test kinds, results, and statuses.
|
||||||
|
|
||||||
|
## Try aliases and application commands
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go run . list collections --campaign spring
|
||||||
|
go run . pk list bundles --campaign spring
|
||||||
|
go run . pk show prompt briefing --campaign spring --fmt yaml
|
||||||
|
go run . smoke text_inference --campaign spring --llm-prompt briefing --llm-profile spring
|
||||||
go run . pk --hlep
|
go run . pk --hlep
|
||||||
go run . session restore session-42 --campaign spring
|
go run . session restore session-42 --campaign spring
|
||||||
go run . request inspect --llm-prompt briefing --llm-profile= --llm-model example --llm-input-text topic=sprint --format json
|
go run . request inspect --llm-prompt briefing --llm-profile= --llm-model example --llm-input-text topic=sprint --format json
|
||||||
```
|
```
|
||||||
|
|
||||||
`newApp` installs application-owned command and flag aliases alongside the
|
The first two commands reach `promptkit list collections`. `pk` shortens the
|
||||||
complete `ckconfig` and `ckprompt` declarations. `list collections` and
|
PromptKit group; `--fmt` is an alias on prompt inspection and `--hlep` is a global
|
||||||
`promptkit list bundles` both reach the canonical collection command; `pk`
|
help alias. `smoke` shortens `promptkit test` while leaving the test kind as an
|
||||||
shortens the PromptKit group. `--fmt` is scoped to named prompt inspection and
|
argument. The session command uses an in-memory store. `request inspect` shows
|
||||||
`--hlep` is available globally. The `smoke` shortcut retains the canonical
|
how an application command can reuse canonical `--llm-*` flag collections without
|
||||||
`promptkit test KIND` operand, including the special `all` aggregate.
|
installing PromptKit's short aliases.
|
||||||
|
|
||||||
The example installs `Completion("composed-example")`. Build it as
|
## Try shell completion
|
||||||
`go build -o composed-example .`. Initialize `bash-completion` first if using
|
|
||||||
Bash, then load a shell script as described in the
|
|
||||||
[CLI reference](../../../../docs/cli.md#shell-completion). Try completion after
|
|
||||||
`composed-example pk show prompt briefing --fmt=` or after the filename prefix
|
|
||||||
in `composed-example config show --config=fixtures/`. The already-entered `pk`
|
|
||||||
alias selects the PromptKit command context without loading an engine.
|
|
||||||
|
|
||||||
The composition installs `ckconfig.Command(suite)` and the complete
|
```sh
|
||||||
`ckprompt.Declarations(definitions)` in a `[]commandkit.Declaration` with
|
go build -o composed-example .
|
||||||
root-installed routing rules. The PromptKit declarations include scoped aliases;
|
./composed-example completion bash
|
||||||
the application-owned `request inspect` command reuses the flag collections
|
|
||||||
with their canonical names and does not install those aliases.
|
|
||||||
Group children remain canonical `Command` values. For command-only slices,
|
|
||||||
convert each `Command` to `Declaration` before appending aliases. The
|
|
||||||
[consumer guide](../../../../docs/consumers/guide.md#application-owned-aliases)
|
|
||||||
includes minimal direct-call and slice-conversion examples; this README documents
|
|
||||||
the maintained complete consumer.
|
|
||||||
|
|
||||||
The sources and session store are small in-memory fixtures. `--campaign` is a
|
|
||||||
typed selector shared by the app and PromptKit configuration scopes. Their
|
|
||||||
`ckconfig.Suite[*inspectionInput, *runCache]` uses `NewInput` to initialize each
|
|
||||||
selected scope's input separately and `NewState` to create one cache shared by
|
|
||||||
that invocation's acquisitions. Prompt/profile inspection prepares its input and
|
|
||||||
cache independently for its invocation, using the same application and engine
|
|
||||||
loader functions. See the
|
|
||||||
[consumer guide](https://gitea.maximumdirect.net/eric/CommandKit/src/branch/main/docs/consumers/guide.md)
|
|
||||||
and the suites' GoDoc for initialization timing and ownership.
|
|
||||||
|
|
||||||
The application-scope comparison reports the changed default campaign and the
|
|
||||||
updated `campaigns.spring.model`. The PromptKit-scope comparison selects spring
|
|
||||||
on both sides and reports the native engine change in the atomic profiles list.
|
|
||||||
The application input check requires `--limit=0` when `--campaign` is supplied.
|
|
||||||
Catalogs use the same selection-aware loader: the prompt catalog includes
|
|
||||||
`briefing`, and the profile catalog includes the selected `autumn` profile.
|
|
||||||
Prompt inspection uses an ID only. The default PromptKit bundle also contributes
|
|
||||||
profiles and a backend to the engine inventory and source reports. The synthetic `field-notes` collection
|
|
||||||
provides one backend, profile, and prompt for collection discovery and native
|
|
||||||
namespace filtering. The example's shared run cache keys successful results by
|
|
||||||
the complete supplied configuration/overlay selection, CLI overrides, and the
|
|
||||||
supplied campaign, including presence. The campaign override and application
|
|
||||||
scope's `--set` records are applied through ConfigKit before loading. The
|
|
||||||
PromptKit configuration scope does not enable `--set` because its released
|
|
||||||
configuration loader has no override handoff. An aggregate operation therefore
|
|
||||||
rejects overrides when that scope is selected. These are choices for this
|
|
||||||
example's loaders and do not define a CommandKit cache policy.
|
|
||||||
|
|
||||||
Smoke testing is explicitly wired with `ckprompt.WithSmokeTesting` and its
|
|
||||||
optional request builder is nil, so CommandKit forwards the selected native
|
|
||||||
request. Applications that need to adjust the request can provide a typed
|
|
||||||
builder that starts with the seed and returns the chosen native request:
|
|
||||||
|
|
||||||
```go
|
|
||||||
definitions = ckprompt.WithSmokeTesting(definitions, loadEngine,
|
|
||||||
func(ctx context.Context, seed ckprompt.SmokeRequest, input *inspectionInput, state *runCache) (promptkit.SmokeTestRequest, error) {
|
|
||||||
request := seed.Request
|
|
||||||
request.ProfileID = "spring"
|
|
||||||
return request, nil
|
|
||||||
})
|
|
||||||
```
|
```
|
||||||
|
|
||||||
The example injects a small in-memory `promptkit.LLMClient` through its shared
|
Initialize `bash-completion` if using Bash, then load the generated script using
|
||||||
run state. Catalog, inspection, and requirements commands do not call it;
|
the [CLI reference](../../../../docs/cli.md#shell-completion). Try completing
|
||||||
inference smoke commands do. No model service or credentials are required.
|
`composed-example pk show prompt briefing --fmt=` or
|
||||||
Native HTTP checks may retain unsupported or not-applicable observations because
|
`composed-example config show --config=fixtures/`. The already-entered `pk` alias
|
||||||
the selected local backend does not configure a network probe. An aggregate smoke command can
|
selects the command context without loading an engine.
|
||||||
return an operational outcome for a native diagnostic while still delivering
|
|
||||||
the complete aggregate report.
|
|
||||||
|
|
||||||
The first requirements command reports incomplete discovery: PromptKit records
|
## How it is wired
|
||||||
the conditional `notes` input and a missing `mode` issue. Supplying `mode=full`
|
|
||||||
selects that branch, classifies the undeclared `notes` input as required, and
|
|
||||||
completes discovery. Both commands return successful reports; the result does
|
|
||||||
not execute the prompt or establish input, credential, or execution readiness.
|
|
||||||
See the [CLI reference](../../../../docs/cli.md#promptkit-requirements) for the
|
|
||||||
command and report contract.
|
|
||||||
|
|
||||||
The validation and preview commands exercise concrete PromptKit request preparation.
|
The example installs `ckconfig.Command(suite)` and
|
||||||
The first uses the builder's campaign-specific default topic variable and the
|
`ckprompt.Declarations(definitions)` with application-owned aliases in a
|
||||||
operator's topic file. The second also selects the notes branch and supplies its
|
`[]commandkit.Declaration`. `NewInput` initializes scope inputs; `NewState`
|
||||||
file. Validation reports only `prompt_id` and `valid: true`; preview returns the
|
creates a cache shared within an invocation. The loaders cache successful
|
||||||
prepared request, including rendered messages and effective provider settings.
|
configuration results by the complete configuration/overlay selection,
|
||||||
Neither executes the prompt. The validation command without inputs intentionally
|
overrides, and supplied campaign, including whether each value was supplied.
|
||||||
fails because the prompt's
|
These are example choices, not a CommandKit cache policy.
|
||||||
required `topic` artifact is not supplied. The builder defaults variables from
|
|
||||||
the selected campaign but leaves artifact bindings to the operator. The
|
|
||||||
`requirements show` commands remain discovery reports: partial discovery can
|
|
||||||
succeed even when a concrete request would fail validation.
|
|
||||||
|
|
||||||
Use the aligned local workspace to test the three current sources, as described in
|
Smoke testing uses `WithSmokeTesting` with a nil builder, forwarding the request
|
||||||
the [developer guide](../../../../docs/development.md#documentation-validation).
|
selected by flags. Applications can supply a builder to customize that request.
|
||||||
Once released dependency requirements are aligned, `GOWORK=off go test ./...`
|
See the [consumer guide](../../../../docs/consumers/guide.md#promptkit-configuration-definitions-and-requirements)
|
||||||
from this example directory checks it against its declared dependencies.
|
for suite composition, builders, and ownership, and the [source](main.go) for
|
||||||
|
the complete application.
|
||||||
|
|
||||||
|
Run `GOWORK=auto GOTOOLCHAIN=local go test ./...` from this example directory
|
||||||
|
for an aligned workspace check. Use
|
||||||
|
`GOWORK=off GOTOOLCHAIN=local go test ./...` to check the declared releases.
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ module go.maximumdirect.net/ckprompt
|
|||||||
go 1.27.0
|
go 1.27.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
go.maximumdirect.net/ckconfig v0.3.0
|
go.maximumdirect.net/ckconfig v0.6.0
|
||||||
go.maximumdirect.net/commandkit v0.3.0
|
go.maximumdirect.net/commandkit v0.6.0
|
||||||
go.maximumdirect.net/configkit v1.1.0
|
go.maximumdirect.net/configkit v1.1.0
|
||||||
go.maximumdirect.net/promptkit v0.20.0
|
go.maximumdirect.net/promptkit v0.20.0
|
||||||
go.yaml.in/yaml/v4 v4.0.0-rc.6
|
go.yaml.in/yaml/v4 v4.0.0-rc.6
|
||||||
|
|||||||
@@ -10,10 +10,18 @@ github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s=
|
|||||||
github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0=
|
github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0=
|
||||||
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
|
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=
|
||||||
go.maximumdirect.net/ckconfig v0.3.0 h1:1FceT13Qe9J7V5knWPiJhwSNwxrZnYPH8HoloYpWLgs=
|
go.maximumdirect.net/ckconfig v0.4.0 h1:7MpI/jIy+3BZrVME5rhKQibh5U9IA3s97BUJ2EOG41s=
|
||||||
go.maximumdirect.net/ckconfig v0.3.0/go.mod h1:NKFBVr2TyDNGGIWgwijGqg5WFoSUL2F8FiKRlNo9TLw=
|
go.maximumdirect.net/ckconfig v0.4.0/go.mod h1:Mk6QOfHrFy00uE0/gw9h6/Y9LcdTShVEzM/oI/HGDns=
|
||||||
go.maximumdirect.net/commandkit v0.3.0 h1:RCmg2GS8uzlLSJCIqarpn1uFDec0W3ylNNRRPT6kUFE=
|
go.maximumdirect.net/ckconfig v0.5.0 h1:5YCPFySTFIFR+gzd0FjqkTPEVB7zkGa8kOEYh4aBvWc=
|
||||||
go.maximumdirect.net/commandkit v0.3.0/go.mod h1:ZS98DewKNVBygKVDoh5MFD09CnPlP37dS9pVsID5Eoc=
|
go.maximumdirect.net/ckconfig v0.5.0/go.mod h1:BONXRSQlHdJvYVw9mN6lMV4G4ldlV/5fRO9EEApA99s=
|
||||||
|
go.maximumdirect.net/ckconfig v0.6.0 h1:N7MJKJiXhv+tT4HA7dnINUxC0Uq8hzYmBTFjrpvkbvQ=
|
||||||
|
go.maximumdirect.net/ckconfig v0.6.0/go.mod h1:69HOkR2Jbq7rJz3jlFAL6Oo5qorEiVrxQVLXoqav98s=
|
||||||
|
go.maximumdirect.net/commandkit v0.4.0 h1:bWqflDWDn98FUCgh+0e+w6LGmvWIkaeJK519H7PjD2Y=
|
||||||
|
go.maximumdirect.net/commandkit v0.4.0/go.mod h1:ZS98DewKNVBygKVDoh5MFD09CnPlP37dS9pVsID5Eoc=
|
||||||
|
go.maximumdirect.net/commandkit v0.5.0 h1:Ln+bCp+FwCg4tZD3NaMKQXUDB3mPusgwsnRZmb0+3cM=
|
||||||
|
go.maximumdirect.net/commandkit v0.5.0/go.mod h1:ZS98DewKNVBygKVDoh5MFD09CnPlP37dS9pVsID5Eoc=
|
||||||
|
go.maximumdirect.net/commandkit v0.6.0 h1:4PHhMIlZuuvreIxZhUazeweg6zoYixEvhM9LJnkbGNA=
|
||||||
|
go.maximumdirect.net/commandkit v0.6.0/go.mod h1:ZS98DewKNVBygKVDoh5MFD09CnPlP37dS9pVsID5Eoc=
|
||||||
go.maximumdirect.net/configkit v1.1.0 h1:UzXN2ZMnEkcL0O4YSLqD9qd9M1gb/NWCHQR/H8AaQzA=
|
go.maximumdirect.net/configkit v1.1.0 h1:UzXN2ZMnEkcL0O4YSLqD9qd9M1gb/NWCHQR/H8AaQzA=
|
||||||
go.maximumdirect.net/configkit v1.1.0/go.mod h1:s1zLJ9GGB/1Q4a+aOHMLISonJxWFgNWpA6SpPlOafeA=
|
go.maximumdirect.net/configkit v1.1.0/go.mod h1:s1zLJ9GGB/1Q4a+aOHMLISonJxWFgNWpA6SpPlOafeA=
|
||||||
go.maximumdirect.net/promptkit v0.20.0 h1:ZpwbjiCZWXdnnZ26RuWKoOco0jFnaAX8m2tQAX7ZlnM=
|
go.maximumdirect.net/promptkit v0.20.0 h1:ZpwbjiCZWXdnnZ26RuWKoOco0jFnaAX8m2tQAX7ZlnM=
|
||||||
|
|||||||
@@ -39,9 +39,9 @@ func namespaceFlags[I any](destination func(*I) *namespaceInput, kinds ...string
|
|||||||
}
|
}
|
||||||
|
|
||||||
var namespaceFlagDefinitions = struct{ prompt, profile, backend requestFlagDefinition }{
|
var namespaceFlagDefinitions = struct{ prompt, profile, backend requestFlagDefinition }{
|
||||||
prompt: requestFlagDefinition{commandkit.FlagMeta{Name: "llm-prompt-namespace", Usage: "Prompt resource namespace."}, []string{"prompt-namespace"}},
|
prompt: requestFlagDefinition{commandkit.FlagMeta{Name: "llm-prompt-namespace", Usage: "Use this namespace for unqualified prompt IDs."}, []string{"prompt-namespace"}},
|
||||||
profile: requestFlagDefinition{commandkit.FlagMeta{Name: "llm-profile-namespace", Usage: "Profile resource namespace."}, []string{"profile-namespace"}},
|
profile: requestFlagDefinition{commandkit.FlagMeta{Name: "llm-profile-namespace", Usage: "Use this namespace for unqualified profile IDs."}, []string{"profile-namespace"}},
|
||||||
backend: requestFlagDefinition{commandkit.FlagMeta{Name: "llm-backend-namespace", Usage: "Backend resource namespace."}, []string{"backend-namespace"}},
|
backend: requestFlagDefinition{commandkit.FlagMeta{Name: "llm-backend-namespace", Usage: "Use this namespace for unqualified backend IDs."}, []string{"backend-namespace"}},
|
||||||
}
|
}
|
||||||
|
|
||||||
func selectedNamespaces(defaults *promptkit.Namespaces, selected namespaceInput) *promptkit.Namespaces {
|
func selectedNamespaces(defaults *promptkit.Namespaces, selected namespaceInput) *promptkit.Namespaces {
|
||||||
|
|||||||
@@ -100,6 +100,9 @@ func runRequestPreparation[I, S any](invocation commandkit.Invocation, suite Sui
|
|||||||
if err := promptKitContextError(invocation.Context); err != nil {
|
if err := promptKitContextError(invocation.Context); err != nil {
|
||||||
return definitionFailure(fmt.Sprintf("Preparation of the PromptKit request for prompt %q was canceled.", input.promptID), acquired.Explanation, err)
|
return definitionFailure(fmt.Sprintf("Preparation of the PromptKit request for prompt %q was canceled.", input.promptID), acquired.Explanation, err)
|
||||||
}
|
}
|
||||||
|
if acquired.Value == nil {
|
||||||
|
return definitionFailure("PromptKit request preparation returned no result.", acquired.Explanation, nil)
|
||||||
|
}
|
||||||
_ = invocation.Reporter().Report(commandkit.Report{Value: acquired.Value})
|
_ = invocation.Reporter().Report(commandkit.Report{Value: acquired.Value})
|
||||||
return commandkit.Success()
|
return commandkit.Success()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -105,3 +105,43 @@ func TestNativePreparationBuilderAndErrorIdentity(t *testing.T) {
|
|||||||
t.Fatalf("native error = %v", err)
|
t.Fatalf("native error = %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestPreparationCommandRejectsMissingResultAndRetainsEvidence(t *testing.T) {
|
||||||
|
cause := errors.New("provider failure")
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
providerErr error
|
||||||
|
cancel bool
|
||||||
|
}{
|
||||||
|
{name: "missing result"}, {name: "provider error", providerErr: cause}, {name: "cancellation", cancel: true},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
app := newPromptTestApp(t, Command(Suite[struct{}, struct{}]{PrepareRequest: func(context.Context, PreparationRequest, struct{}, struct{}) (commandkit.Acquired[*promptkit.PreparedRun], error) {
|
||||||
|
if tc.cancel {
|
||||||
|
cancel()
|
||||||
|
}
|
||||||
|
acquired := commandkit.Acquired[*promptkit.PreparedRun]{
|
||||||
|
Diagnostics: []commandkit.Diagnostic{{Severity: commandkit.SeverityWarning, Write: func(w io.Writer) error { _, err := io.WriteString(w, "provider diagnostic\n"); return err }}},
|
||||||
|
Explanation: commandkit.Explanation{Message: "provider explanation"},
|
||||||
|
}
|
||||||
|
if tc.providerErr != nil {
|
||||||
|
acquired.Value = &promptkit.PreparedRun{PromptID: "briefing"}
|
||||||
|
}
|
||||||
|
return acquired, tc.providerErr
|
||||||
|
}}))
|
||||||
|
var output, diagnostics bytes.Buffer
|
||||||
|
result := app.Execute(ctx, []string{"promptkit", "show", "prepared", "briefing", "--format=json", "--verbose"}, commandkit.Streams{Out: &output, Err: &diagnostics})
|
||||||
|
if result.Kind != commandkit.OutcomeOperational || output.Len() != 0 || !bytes.Contains(diagnostics.Bytes(), []byte("provider diagnostic")) || !bytes.Contains(diagnostics.Bytes(), []byte("provider explanation")) {
|
||||||
|
t.Fatalf("result=%#v output=%q diagnostics=%q", result, output.String(), diagnostics.String())
|
||||||
|
}
|
||||||
|
if tc.providerErr != nil && !errors.Is(result.Cause, tc.providerErr) {
|
||||||
|
t.Fatalf("provider cause = %v", result.Cause)
|
||||||
|
}
|
||||||
|
if tc.cancel && !errors.Is(result.Cause, context.Canceled) {
|
||||||
|
t.Fatalf("cancellation cause = %v", result.Cause)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -35,24 +35,24 @@ var requestFlags = struct {
|
|||||||
InputText requestFlagDefinition
|
InputText requestFlagDefinition
|
||||||
InputFile requestFlagDefinition
|
InputFile requestFlagDefinition
|
||||||
}{
|
}{
|
||||||
Endpoint: requestFlagDefinition{commandkit.FlagMeta{Name: "llm-endpoint", Usage: "Execution endpoint override."}, []string{"endpoint"}},
|
Endpoint: requestFlagDefinition{commandkit.FlagMeta{Name: "llm-endpoint", Usage: "Use this endpoint."}, []string{"endpoint"}},
|
||||||
Model: requestFlagDefinition{commandkit.FlagMeta{Name: "llm-model", Usage: "Execution model override."}, []string{"model"}},
|
Model: requestFlagDefinition{commandkit.FlagMeta{Name: "llm-model", Usage: "Use this model."}, []string{"model"}},
|
||||||
Temperature: requestFlagDefinition{commandkit.FlagMeta{Name: "llm-temperature", Usage: "Execution temperature override."}, []string{"temperature"}},
|
Temperature: requestFlagDefinition{commandkit.FlagMeta{Name: "llm-temperature", Usage: "Use this temperature."}, []string{"temperature"}},
|
||||||
MaxTokens: requestFlagDefinition{commandkit.FlagMeta{Name: "llm-max-tokens", Usage: "Execution maximum tokens override."}, []string{"max-tokens"}},
|
MaxTokens: requestFlagDefinition{commandkit.FlagMeta{Name: "llm-max-tokens", Usage: "Use this maximum token count."}, []string{"max-tokens"}},
|
||||||
TopP: requestFlagDefinition{commandkit.FlagMeta{Name: "llm-top-p", Usage: "Execution top-p override."}, []string{"top-p"}},
|
TopP: requestFlagDefinition{commandkit.FlagMeta{Name: "llm-top-p", Usage: "Use this top-p value."}, []string{"top-p"}},
|
||||||
TimeoutSeconds: requestFlagDefinition{commandkit.FlagMeta{Name: "llm-timeout-seconds", Usage: "Execution timeout override in seconds."}, []string{"timeout-seconds"}},
|
TimeoutSeconds: requestFlagDefinition{commandkit.FlagMeta{Name: "llm-timeout-seconds", Usage: "Set the request timeout in seconds."}, []string{"timeout-seconds"}},
|
||||||
ServiceTier: requestFlagDefinition{commandkit.FlagMeta{Name: "llm-service-tier", Usage: "Execution service tier."}, []string{"service-tier"}},
|
ServiceTier: requestFlagDefinition{commandkit.FlagMeta{Name: "llm-service-tier", Usage: "Use this service tier."}, []string{"service-tier"}},
|
||||||
ReasoningEffort: requestFlagDefinition{commandkit.FlagMeta{Name: "llm-reasoning-effort", Usage: "Execution reasoning effort override."}, []string{"reasoning-effort"}},
|
ReasoningEffort: requestFlagDefinition{commandkit.FlagMeta{Name: "llm-reasoning-effort", Usage: "Use this reasoning effort."}, []string{"reasoning-effort"}},
|
||||||
APIKeyEnv: requestFlagDefinition{commandkit.FlagMeta{Name: "llm-api-key-env", Usage: "Execution API key environment variable."}, []string{"api-key-env"}},
|
APIKeyEnv: requestFlagDefinition{commandkit.FlagMeta{Name: "llm-api-key-env", Usage: "Name the environment variable containing the API key."}, []string{"api-key-env"}},
|
||||||
TransientRetries: requestFlagDefinition{commandkit.FlagMeta{Name: "llm-transient-retries", Usage: "Inference transient retry count."}, []string{"transient-retries"}},
|
TransientRetries: requestFlagDefinition{commandkit.FlagMeta{Name: "llm-transient-retries", Usage: "Set the retry count for transient inference failures."}, []string{"transient-retries"}},
|
||||||
ExtraParams: requestFlagDefinition{commandkit.FlagMeta{Name: "llm-extra-params", Usage: "Execution provider parameters as a JSON object."}, []string{"extra-params"}},
|
ExtraParams: requestFlagDefinition{commandkit.FlagMeta{Name: "llm-extra-params", Usage: "Supply provider parameters as a JSON object."}, []string{"extra-params"}},
|
||||||
Prompt: requestFlagDefinition{commandkit.FlagMeta{Name: "llm-prompt", Usage: "PromptKit prompt ID."}, []string{"prompt"}},
|
Prompt: requestFlagDefinition{commandkit.FlagMeta{Name: "llm-prompt", Usage: "Select a prompt by ID."}, []string{"prompt"}},
|
||||||
Profile: requestFlagDefinition{commandkit.FlagMeta{Name: "llm-profile", Usage: "PromptKit profile ID."}, []string{"profile"}},
|
Profile: requestFlagDefinition{commandkit.FlagMeta{Name: "llm-profile", Usage: "Select a profile by ID."}, []string{"profile"}},
|
||||||
Backend: requestFlagDefinition{commandkit.FlagMeta{Name: "llm-backend", Usage: "PromptKit backend ID."}, []string{"backend"}},
|
Backend: requestFlagDefinition{commandkit.FlagMeta{Name: "llm-backend", Usage: "Select a backend by ID."}, []string{"backend"}},
|
||||||
SessionID: requestFlagDefinition{commandkit.FlagMeta{Name: "llm-session-id", Usage: "PromptKit session ID."}, []string{"session-id"}},
|
SessionID: requestFlagDefinition{commandkit.FlagMeta{Name: "llm-session-id", Usage: "Supply the session ID."}, []string{"session-id"}},
|
||||||
Var: requestFlagDefinition{commandkit.FlagMeta{Name: "llm-var", Usage: "Caller variable assignment KEY=VALUE."}, []string{"var"}},
|
Var: requestFlagDefinition{commandkit.FlagMeta{Name: "llm-var", Usage: "Supply a prompt variable as KEY=VALUE."}, []string{"var"}},
|
||||||
InputText: requestFlagDefinition{commandkit.FlagMeta{Name: "llm-input-text", Usage: "Literal prompt input assignment NAME=TEXT."}, []string{"input-text", "llm-input", "input"}},
|
InputText: requestFlagDefinition{commandkit.FlagMeta{Name: "llm-input-text", Usage: "Supply text for a prompt input as NAME=TEXT."}, []string{"input-text", "llm-input", "input"}},
|
||||||
InputFile: requestFlagDefinition{commandkit.FlagMeta{Name: "llm-input-file", Usage: "Prompt input file assignment NAME=PATH."}, []string{"input-file"}},
|
InputFile: requestFlagDefinition{commandkit.FlagMeta{Name: "llm-input-file", Usage: "Supply a file for a prompt input as NAME=PATH."}, []string{"input-file"}},
|
||||||
}
|
}
|
||||||
|
|
||||||
// ExecutionInput contains request-scoped execution controls supplied on the CLI.
|
// ExecutionInput contains request-scoped execution controls supplied on the CLI.
|
||||||
@@ -85,6 +85,7 @@ func (f ExecutionFlags[I]) bindings() []requestBinding[I] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// NewExecutionFlags binds execution controls to a field of the caller's input.
|
// NewExecutionFlags binds execution controls to a field of the caller's input.
|
||||||
|
// Parsed provider parameters are copied when the binding is applied.
|
||||||
func NewExecutionFlags[I any](destination func(*I) *ExecutionInput) ExecutionFlags[I] {
|
func NewExecutionFlags[I any](destination func(*I) *ExecutionInput) ExecutionFlags[I] {
|
||||||
target := func(input *I) *promptkit.ExecutionTargetOverride {
|
target := func(input *I) *promptkit.ExecutionTargetOverride {
|
||||||
value := destination(input)
|
value := destination(input)
|
||||||
@@ -104,7 +105,36 @@ func NewExecutionFlags[I any](destination func(*I) *ExecutionInput) ExecutionFla
|
|||||||
ReasoningEffort: executionFlag(requestFlags.ReasoningEffort.meta, parseDefinitionString, func(i *I, v string) { target(i).ReasoningEffort = &v }),
|
ReasoningEffort: executionFlag(requestFlags.ReasoningEffort.meta, parseDefinitionString, func(i *I, v string) { target(i).ReasoningEffort = &v }),
|
||||||
APIKeyEnv: executionFlag(requestFlags.APIKeyEnv.meta, parseDefinitionString, func(i *I, v string) { target(i).APIKeyEnv = v }),
|
APIKeyEnv: executionFlag(requestFlags.APIKeyEnv.meta, parseDefinitionString, func(i *I, v string) { target(i).APIKeyEnv = v }),
|
||||||
TransientRetries: executionFlag(requestFlags.TransientRetries.meta, strconv.Atoi, func(i *I, v int) { destination(i).TransientRetries = &v }),
|
TransientRetries: executionFlag(requestFlags.TransientRetries.meta, strconv.Atoi, func(i *I, v int) { destination(i).TransientRetries = &v }),
|
||||||
ExtraParams: executionFlag(requestFlags.ExtraParams.meta, parseExtraParams, func(i *I, v map[string]any) { target(i).ExtraParams = v }),
|
ExtraParams: executionFlag(requestFlags.ExtraParams.meta, parseExtraParams, func(i *I, v map[string]any) { target(i).ExtraParams = copyProviderParams(v) }),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyProviderParams(params map[string]any) map[string]any {
|
||||||
|
if params == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
result := make(map[string]any, len(params))
|
||||||
|
for key, value := range params {
|
||||||
|
result[key] = copyProviderValue(value)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyProviderValue(value any) any {
|
||||||
|
switch value := value.(type) {
|
||||||
|
case map[string]any:
|
||||||
|
return copyProviderParams(value)
|
||||||
|
case []any:
|
||||||
|
if value == nil {
|
||||||
|
return []any(nil)
|
||||||
|
}
|
||||||
|
result := make([]any, len(value))
|
||||||
|
for i, element := range value {
|
||||||
|
result[i] = copyProviderValue(element)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
default:
|
||||||
|
return value
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -76,3 +76,33 @@ func TestValidationForwardsExecutionControls(t *testing.T) {
|
|||||||
t.Fatalf("result/request = %#v %#v", result, received)
|
t.Fatalf("result/request = %#v %#v", result, received)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestExecutionExtraParamsCapturedApplicationsAreIndependent(t *testing.T) {
|
||||||
|
type target struct{ execution ckprompt.ExecutionInput }
|
||||||
|
type outer struct{ captured commandkit.FlagValues[target] }
|
||||||
|
flag := ckprompt.NewExecutionFlags(func(i *target) *ckprompt.ExecutionInput { return &i.execution }).ExtraParams
|
||||||
|
captured := commandkit.CaptureFlags([]commandkit.Flag[target]{flag}, func(i *outer) *commandkit.FlagValues[target] { return &i.captured })
|
||||||
|
command := commandkit.NewCommand(commandkit.CommandSpec[outer]{Name: "run", Flags: captured, Run: func(_ commandkit.Invocation, input outer) commandkit.Outcome {
|
||||||
|
var first, second target
|
||||||
|
if err := input.captured.ApplyTo(&first, flag); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
first.execution.Execution.ExtraParams["nested"].(map[string]any)["array"].([]any)[0].(map[string]any)["number"] = int64(9)
|
||||||
|
if err := input.captured.ApplySuppliedTo(&second, flag); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
value := second.execution.Execution.ExtraParams["nested"].(map[string]any)["array"].([]any)[0].(map[string]any)["number"]
|
||||||
|
if value != int64(1) {
|
||||||
|
t.Errorf("second value = %#v", value)
|
||||||
|
}
|
||||||
|
return commandkit.Success()
|
||||||
|
}})
|
||||||
|
app, err := commandkit.New(commandkit.Identity{Name: "app"}, command)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
result := app.Execute(context.Background(), []string{"run", `--llm-extra-params={"nested":{"array":[{"number":1}]}}`}, commandkit.Streams{Out: io.Discard, Err: io.Discard})
|
||||||
|
if result.Kind != commandkit.OutcomeSuccess {
|
||||||
|
t.Fatal(result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,9 +3,11 @@ package ckprompt
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
"reflect"
|
"reflect"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"go.maximumdirect.net/ckconfig"
|
"go.maximumdirect.net/ckconfig"
|
||||||
@@ -109,6 +111,79 @@ func TestSmokeAggregateCoordinatesConcreteRequestsAndRetainsErrors(t *testing.T)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type cancelProgressWriter struct {
|
||||||
|
bytes.Buffer
|
||||||
|
cancel context.CancelFunc
|
||||||
|
}
|
||||||
|
|
||||||
|
func (writer *cancelProgressWriter) Write(p []byte) (int, error) {
|
||||||
|
writer.cancel()
|
||||||
|
return writer.Buffer.Write(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSmokeAggregateCancellationDuringProgress(t *testing.T) {
|
||||||
|
providerErr := errors.New("provider failed")
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
err error
|
||||||
|
cancelAt int
|
||||||
|
}{
|
||||||
|
{name: "partial success", cancelAt: 1},
|
||||||
|
{name: "partial provider failure", err: providerErr, cancelAt: 1},
|
||||||
|
{name: "last completed test", cancelAt: 4},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
var kinds []promptkit.SmokeTestKind
|
||||||
|
app := newPromptTestApp(t, Command(Suite[struct{}, struct{}]{
|
||||||
|
SmokeTest: func(_ context.Context, request SmokeRequest, _, _ struct{}) (commandkit.Acquired[*promptkit.SmokeTestReport], error) {
|
||||||
|
kinds = append(kinds, request.Request.Kind)
|
||||||
|
acquired := commandkit.Acquired[*promptkit.SmokeTestReport]{Value: &promptkit.SmokeTestReport{Kind: request.Request.Kind, Completed: true}}
|
||||||
|
if len(kinds) == test.cancelAt {
|
||||||
|
acquired.Diagnostics = []commandkit.Diagnostic{{Severity: commandkit.SeverityProgress, Write: func(w io.Writer) error { _, err := io.WriteString(w, "progress evidence\n"); return err }}}
|
||||||
|
acquired.Explanation = commandkit.Explanation{Message: "provider explanation"}
|
||||||
|
return acquired, test.err
|
||||||
|
}
|
||||||
|
return acquired, nil
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
var output bytes.Buffer
|
||||||
|
diagnostics := &cancelProgressWriter{cancel: cancel}
|
||||||
|
outcome := app.Execute(ctx, []string{"promptkit", "test", "all", "--progress", "--format=json"}, commandkit.Streams{Out: &output, Err: diagnostics})
|
||||||
|
if len(kinds) != test.cancelAt || !strings.Contains(diagnostics.String(), "progress evidence") {
|
||||||
|
t.Fatalf("kinds=%v diagnostics=%q", kinds, diagnostics.String())
|
||||||
|
}
|
||||||
|
if test.cancelAt < 4 {
|
||||||
|
if outcome.Kind != commandkit.OutcomeOperational || !errors.Is(outcome.Cause, context.Canceled) || (test.err != nil && (!errors.Is(outcome.Cause, test.err) || !strings.Contains(diagnostics.String(), "provider explanation"))) {
|
||||||
|
t.Fatalf("partial outcome=%#v diagnostics=%q", outcome, diagnostics.String())
|
||||||
|
}
|
||||||
|
} else if outcome.Kind != commandkit.OutcomeSuccess || outcome.Cause != nil {
|
||||||
|
t.Fatalf("completed outcome=%#v", outcome)
|
||||||
|
}
|
||||||
|
var report struct {
|
||||||
|
Tests []struct {
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
Report *struct {
|
||||||
|
Completed bool `json:"completed"`
|
||||||
|
} `json:"report"`
|
||||||
|
} `json:"tests"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(output.Bytes(), &report); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(report.Tests) != len(kinds) {
|
||||||
|
t.Fatalf("report=%s", output.String())
|
||||||
|
}
|
||||||
|
for i, entry := range report.Tests {
|
||||||
|
if entry.Kind != string(kinds[i]) || entry.Report == nil || !entry.Report.Completed {
|
||||||
|
t.Fatalf("report entry=%#v", entry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestSmokeAggregateCancellationRetainsPartialResults(t *testing.T) {
|
func TestSmokeAggregateCancellationRetainsPartialResults(t *testing.T) {
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
t.Cleanup(cancel)
|
t.Cleanup(cancel)
|
||||||
@@ -144,3 +219,33 @@ func TestSmokeAggregateCancellationRetainsPartialResults(t *testing.T) {
|
|||||||
t.Fatalf("partial aggregate entries = %#v", document.Tests)
|
t.Fatalf("partial aggregate entries = %#v", document.Tests)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSmokeAggregateIsolatesProviderParameters(t *testing.T) {
|
||||||
|
calls := 0
|
||||||
|
app := newPromptTestApp(t, Command(Suite[struct{}, struct{}]{
|
||||||
|
SmokeTest: func(_ context.Context, request SmokeRequest, _, _ struct{}) (commandkit.Acquired[*promptkit.SmokeTestReport], error) {
|
||||||
|
calls++
|
||||||
|
params := request.Request.Execution.ExtraParams
|
||||||
|
value := params["nested"].(map[string]any)["array"].([]any)[0].(map[string]any)
|
||||||
|
if value["number"] != int64(1) {
|
||||||
|
t.Errorf("call %d: %#v", calls, value)
|
||||||
|
}
|
||||||
|
value["number"] = int64(9)
|
||||||
|
return commandkit.Acquired[*promptkit.SmokeTestReport]{Value: &promptkit.SmokeTestReport{Kind: request.Request.Kind}}, nil
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
outcome := app.Execute(context.Background(), []string{"promptkit", "test", "all", `--llm-extra-params={"nested":{"array":[{"number":1}]}}`}, commandkit.Streams{Out: io.Discard, Err: io.Discard})
|
||||||
|
if outcome.Kind != commandkit.OutcomeSuccess || calls != 4 {
|
||||||
|
t.Fatalf("outcome=%#v calls=%d", outcome, calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCopyProviderParamsPreservesEmptyAndNilValues(t *testing.T) {
|
||||||
|
if copyProviderParams(nil) != nil {
|
||||||
|
t.Fatal("nil object became non-nil")
|
||||||
|
}
|
||||||
|
copied := copyProviderParams(map[string]any{"emptyObject": map[string]any{}, "nilObject": map[string]any(nil), "emptyArray": []any{}, "nilArray": []any(nil), "null": nil, "decimal": float64(1.5)})
|
||||||
|
if copied["emptyObject"].(map[string]any) == nil || copied["nilObject"].(map[string]any) != nil || copied["emptyArray"].([]any) == nil || copied["nilArray"].([]any) != nil || copied["null"] != nil || copied["decimal"] != float64(1.5) {
|
||||||
|
t.Fatalf("copied = %#v", copied)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -29,11 +29,11 @@ func newSmokeTestCommand[I, S any](suite Suite[I, S], aliases *[]commandkit.Decl
|
|||||||
return &(*input).captured
|
return &(*input).captured
|
||||||
})
|
})
|
||||||
flags = append(flags,
|
flags = append(flags,
|
||||||
commandkit.ValueFlag(commandkit.FlagMeta{Name: "config", Usage: "Configuration input path.", Completion: commandkit.CompleteFiles()}, (*string)(nil), parseDefinitionString,
|
commandkit.ValueFlag(commandkit.FlagMeta{Name: "config", Usage: "Select the configuration input path.", Completion: commandkit.CompleteFiles()}, (*string)(nil), parseDefinitionString,
|
||||||
func(input **smokeCommandInput[I], value commandkit.Supplied[string]) {
|
func(input **smokeCommandInput[I], value commandkit.Supplied[string]) {
|
||||||
(*input).selection.Config = value
|
(*input).selection.Config = value
|
||||||
}),
|
}),
|
||||||
commandkit.ValueFlag(commandkit.FlagMeta{Name: "overlay", Usage: "Configuration overlay path.", Completion: commandkit.CompleteFiles()}, (*string)(nil), parseDefinitionString,
|
commandkit.ValueFlag(commandkit.FlagMeta{Name: "overlay", Usage: "Select the configuration overlay path.", Completion: commandkit.CompleteFiles()}, (*string)(nil), parseDefinitionString,
|
||||||
func(input **smokeCommandInput[I], value commandkit.Supplied[string]) {
|
func(input **smokeCommandInput[I], value commandkit.Supplied[string]) {
|
||||||
(*input).selection.Overlay = value
|
(*input).selection.Overlay = value
|
||||||
}),
|
}),
|
||||||
@@ -54,13 +54,14 @@ func newSmokeTestCommand[I, S any](suite Suite[I, S], aliases *[]commandkit.Decl
|
|||||||
|
|
||||||
return commandkit.NewCommand(commandkit.CommandSpec[*smokeCommandInput[I]]{
|
return commandkit.NewCommand(commandkit.CommandSpec[*smokeCommandInput[I]]{
|
||||||
Name: "test",
|
Name: "test",
|
||||||
Summary: "Run one PromptKit smoke test.",
|
Summary: "Run one or all PromptKit smoke tests.",
|
||||||
|
Description: "Tests use the configured client or probes and may contact a provider. The all option runs the four tests in order. Read the report: a successful command status does not mean every observation is positive.",
|
||||||
NewInput: func() *smokeCommandInput[I] {
|
NewInput: func() *smokeCommandInput[I] {
|
||||||
return &smokeCommandInput[I]{}
|
return &smokeCommandInput[I]{}
|
||||||
},
|
},
|
||||||
Flags: flags,
|
Flags: flags,
|
||||||
Arguments: []commandkit.Argument[*smokeCommandInput[I]]{
|
Arguments: []commandkit.Argument[*smokeCommandInput[I]]{
|
||||||
commandkit.ValueArgument(commandkit.ArgumentMeta{Name: "kind", Usage: "Native smoke-test kind.", Completion: smokeKindCompletion()}, true, parseDefinitionString,
|
commandkit.ValueArgument(commandkit.ArgumentMeta{Name: "kind", Usage: "Test kind: reachability, authentication, text_inference, structured_output, or all.", Completion: smokeKindCompletion()}, true, parseDefinitionString,
|
||||||
func(input **smokeCommandInput[I], value commandkit.Supplied[string]) { (*input).kind = value.Value }),
|
func(input **smokeCommandInput[I], value commandkit.Supplied[string]) { (*input).kind = value.Value }),
|
||||||
},
|
},
|
||||||
CheckInput: func(input *smokeCommandInput[I]) error {
|
CheckInput: func(input *smokeCommandInput[I]) error {
|
||||||
@@ -173,6 +174,9 @@ func runSmokeAggregate[I, S any](invocation commandkit.Invocation, suite Suite[I
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if err := promptKitContextError(invocation.Context); err != nil {
|
if err := promptKitContextError(invocation.Context); err != nil {
|
||||||
|
if len(results) < len(kinds) && !errors.Is(errors.Join(operationErrors...), err) {
|
||||||
|
operationErrors = append(operationErrors, err)
|
||||||
|
}
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -229,6 +233,7 @@ func smokeRequestForKind(seed promptkit.SmokeTestRequest, kind promptkit.SmokeTe
|
|||||||
request.Kind = kind
|
request.Kind = kind
|
||||||
if seed.Execution != nil {
|
if seed.Execution != nil {
|
||||||
execution := *seed.Execution
|
execution := *seed.Execution
|
||||||
|
execution.ExtraParams = copyProviderParams(seed.Execution.ExtraParams)
|
||||||
execution.Temperature = copyFloat64(seed.Execution.Temperature)
|
execution.Temperature = copyFloat64(seed.Execution.Temperature)
|
||||||
execution.MaxTokens = copyInt(seed.Execution.MaxTokens)
|
execution.MaxTokens = copyInt(seed.Execution.MaxTokens)
|
||||||
execution.TopP = copyFloat64(seed.Execution.TopP)
|
execution.TopP = copyFloat64(seed.Execution.TopP)
|
||||||
|
|||||||
@@ -31,11 +31,11 @@ func newConcreteRequestSpec[I, S any](suite Suite[I, S], path string, aliases *[
|
|||||||
return &(*input).captured
|
return &(*input).captured
|
||||||
})
|
})
|
||||||
flags = append(flags,
|
flags = append(flags,
|
||||||
commandkit.ValueFlag(commandkit.FlagMeta{Name: "config", Usage: "Configuration input path.", Completion: commandkit.CompleteFiles()}, (*string)(nil), parseDefinitionString,
|
commandkit.ValueFlag(commandkit.FlagMeta{Name: "config", Usage: "Select the configuration input path.", Completion: commandkit.CompleteFiles()}, (*string)(nil), parseDefinitionString,
|
||||||
func(input **concreteRequestInput[I], value commandkit.Supplied[string]) {
|
func(input **concreteRequestInput[I], value commandkit.Supplied[string]) {
|
||||||
(*input).request.Selection.Config = value
|
(*input).request.Selection.Config = value
|
||||||
}),
|
}),
|
||||||
commandkit.ValueFlag(commandkit.FlagMeta{Name: "overlay", Usage: "Configuration overlay path.", Completion: commandkit.CompleteFiles()}, (*string)(nil), parseDefinitionString,
|
commandkit.ValueFlag(commandkit.FlagMeta{Name: "overlay", Usage: "Select the configuration overlay path.", Completion: commandkit.CompleteFiles()}, (*string)(nil), parseDefinitionString,
|
||||||
func(input **concreteRequestInput[I], value commandkit.Supplied[string]) {
|
func(input **concreteRequestInput[I], value commandkit.Supplied[string]) {
|
||||||
(*input).request.Selection.Overlay = value
|
(*input).request.Selection.Overlay = value
|
||||||
}),
|
}),
|
||||||
@@ -93,7 +93,7 @@ func newConcreteRequestSpec[I, S any](suite Suite[I, S], path string, aliases *[
|
|||||||
func newValidationCommand[I, S any](suite Suite[I, S], aliases *[]commandkit.Declaration) commandkit.Command {
|
func newValidationCommand[I, S any](suite Suite[I, S], aliases *[]commandkit.Declaration) commandkit.Command {
|
||||||
spec := newConcreteRequestSpec(suite, "promptkit requirements validate", aliases)
|
spec := newConcreteRequestSpec(suite, "promptkit requirements validate", aliases)
|
||||||
spec.Name = "validate"
|
spec.Name = "validate"
|
||||||
spec.Summary = "Validate a concrete PromptKit request without executing it."
|
spec.Summary = "Check whether a request can be prepared without calling the model."
|
||||||
spec.Run = func(invocation commandkit.Invocation, input *concreteRequestInput[I]) commandkit.Outcome {
|
spec.Run = func(invocation commandkit.Invocation, input *concreteRequestInput[I]) commandkit.Outcome {
|
||||||
return runRequestValidation(invocation, suite, input)
|
return runRequestValidation(invocation, suite, input)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -182,3 +182,50 @@ func (resolver *Resolver) ResolveWithProvenance(path commandpath.Path) (commandp
|
|||||||
origins = nextOrigins
|
origins = nextOrigins
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ProperSourcePrefixes returns distinct nonempty proper prefixes of source paths.
|
||||||
|
// Returned paths are owned by the caller.
|
||||||
|
func (resolver *Resolver) ProperSourcePrefixes() []commandpath.Path {
|
||||||
|
if resolver == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var paths []commandpath.Path
|
||||||
|
for index := 1; index < len(resolver.trie); index++ {
|
||||||
|
node := &resolver.trie[index]
|
||||||
|
if len(node.children) != 0 {
|
||||||
|
paths = append(paths, node.path.Clone())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return paths
|
||||||
|
}
|
||||||
|
|
||||||
|
// Descendants returns owned mappings whose sources are strictly below prefix.
|
||||||
|
func (resolver *Resolver) Descendants(prefix commandpath.Path) []Mapping {
|
||||||
|
if resolver == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
index := 0
|
||||||
|
for _, component := range prefix {
|
||||||
|
next, ok := resolver.trie[index].children[component]
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
index = next
|
||||||
|
}
|
||||||
|
var mappings []Mapping
|
||||||
|
var visit func(int)
|
||||||
|
visit = func(index int) {
|
||||||
|
node := &resolver.trie[index]
|
||||||
|
if node.mapping >= 0 {
|
||||||
|
mapping := resolver.mappings[node.mapping]
|
||||||
|
mappings = append(mappings, Mapping{Source: mapping.Source.Clone(), Target: mapping.Target.Clone()})
|
||||||
|
}
|
||||||
|
for _, child := range node.children {
|
||||||
|
visit(child)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, child := range resolver.trie[index].children {
|
||||||
|
visit(child)
|
||||||
|
}
|
||||||
|
return mappings
|
||||||
|
}
|
||||||
|
|||||||
@@ -314,3 +314,34 @@ func (machine *Machine) classify(token string) symbol {
|
|||||||
}
|
}
|
||||||
return symbol{kind: otherSymbol}
|
return symbol{kind: otherSymbol}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestResolverSourcePrefixesAndDescendantsAreOwned(t *testing.T) {
|
||||||
|
resolver, err := NewResolver([]Mapping{
|
||||||
|
{Source: commandpath.Path{"a"}, Target: commandpath.Path{"root"}},
|
||||||
|
{Source: commandpath.Path{"a", "b", "one"}, Target: commandpath.Path{"root", "one"}},
|
||||||
|
{Source: commandpath.Path{"a", "b", "two"}, Target: commandpath.Path{"root", "two"}},
|
||||||
|
{Source: commandpath.Path{"other"}, Target: commandpath.Path{"root"}},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
prefixes := resolver.ProperSourcePrefixes()
|
||||||
|
if len(prefixes) != 2 || !prefixes[0].Equal(commandpath.Path{"a"}) || !prefixes[1].Equal(commandpath.Path{"a", "b"}) {
|
||||||
|
t.Fatalf("prefixes = %#v", prefixes)
|
||||||
|
}
|
||||||
|
prefixes[0][0] = "mutated"
|
||||||
|
descendants := resolver.Descendants(commandpath.Path{"a"})
|
||||||
|
if len(descendants) != 2 {
|
||||||
|
t.Fatalf("descendants = %#v", descendants)
|
||||||
|
}
|
||||||
|
for _, mapping := range descendants {
|
||||||
|
if len(mapping.Source) != 3 || mapping.Source[0] != "a" {
|
||||||
|
t.Fatalf("mapping = %#v", mapping)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
descendants[0].Source[0] = "mutated"
|
||||||
|
descendants[0].Target[0] = "mutated"
|
||||||
|
if !resolver.Resolve(commandpath.Path{"a", "b", "one"}).Equal(commandpath.Path{"root", "one"}) || len(resolver.Descendants(commandpath.Path{"missing"})) != 0 {
|
||||||
|
t.Fatal("resolver was mutated or missing branch returned mappings")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package cobra
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
|
||||||
|
|
||||||
commandmeta "go.maximumdirect.net/commandkit/internal/commandmeta"
|
commandmeta "go.maximumdirect.net/commandkit/internal/commandmeta"
|
||||||
)
|
)
|
||||||
@@ -20,78 +19,43 @@ func interpretFlagTokens(argv []string, flags []commandmeta.Flag, aliases []comm
|
|||||||
interpreted = append(interpreted, argv[index:]...)
|
interpreted = append(interpreted, argv[index:]...)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
if kind == commandmeta.LongFlagToken {
|
if kind == commandmeta.LongFlagToken || kind == commandmeta.ShortFlagToken {
|
||||||
original, value, attached := commandmeta.SplitLongFlag(token)
|
offset := 1
|
||||||
flag, ok := lookup.Find(original)
|
|
||||||
if !ok {
|
|
||||||
interpreted = append(interpreted, token)
|
|
||||||
return interpreted, spellings, occurrences, nil
|
|
||||||
}
|
|
||||||
spellings[flag.Name] = append(spellings[flag.Name], original)
|
|
||||||
if attached {
|
|
||||||
interpreted = append(interpreted, "--"+flag.Name+"="+value)
|
|
||||||
occurrences = append(occurrences, commandmeta.FlagOccurrence{Name: flag.Name, Spelling: original, RawValue: value})
|
|
||||||
index++
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if !flag.Boolean && index+1 < len(argv) {
|
|
||||||
value := argv[index+1]
|
|
||||||
interpreted = append(interpreted, "--"+flag.Name+"="+value)
|
|
||||||
occurrences = append(occurrences, commandmeta.FlagOccurrence{Name: flag.Name, Spelling: original, RawValue: value})
|
|
||||||
index += 2
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if !flag.Boolean {
|
|
||||||
return interpreted, spellings, occurrences, &commandmeta.Failure{Message: fmt.Sprintf("flag needs an argument: %s", original)}
|
|
||||||
}
|
|
||||||
occurrences = append(occurrences, commandmeta.FlagOccurrence{Name: flag.Name, Spelling: original, RawValue: "true"})
|
|
||||||
interpreted = append(interpreted, "--"+flag.Name)
|
|
||||||
index++
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if kind == commandmeta.ShortFlagToken {
|
|
||||||
consumedNext := false
|
consumedNext := false
|
||||||
for offset := 1; offset < len(token); {
|
for {
|
||||||
original := "-" + token[offset:offset+1]
|
fact := commandmeta.ConsumeFlag(lookup, token, offset)
|
||||||
flag, ok := lookup.Find(original)
|
if !fact.Found {
|
||||||
if !ok {
|
if kind == commandmeta.ShortFlagToken {
|
||||||
interpreted = append(interpreted, "-"+token[offset:])
|
interpreted = append(interpreted, "-"+token[offset:])
|
||||||
|
} else {
|
||||||
|
interpreted = append(interpreted, token)
|
||||||
|
}
|
||||||
return interpreted, spellings, occurrences, nil
|
return interpreted, spellings, occurrences, nil
|
||||||
}
|
}
|
||||||
|
flag, original := fact.Flag, fact.Spelling
|
||||||
spellings[flag.Name] = append(spellings[flag.Name], original)
|
spellings[flag.Name] = append(spellings[flag.Name], original)
|
||||||
suffix := token[offset+1:]
|
value := fact.Value
|
||||||
if flag.Boolean {
|
if fact.NeedsNext {
|
||||||
if strings.HasPrefix(suffix, "=") {
|
if index+1 >= len(argv) {
|
||||||
value := suffix[1:]
|
|
||||||
interpreted = append(interpreted, "--"+flag.Name+"="+value)
|
|
||||||
occurrences = append(occurrences, commandmeta.FlagOccurrence{Name: flag.Name, Spelling: original, RawValue: value})
|
|
||||||
break
|
|
||||||
}
|
|
||||||
occurrences = append(occurrences, commandmeta.FlagOccurrence{Name: flag.Name, Spelling: original, RawValue: "true"})
|
|
||||||
interpreted = append(interpreted, "--"+flag.Name)
|
|
||||||
offset++
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if suffix != "" {
|
|
||||||
value := suffix
|
|
||||||
if strings.HasPrefix(value, "=") {
|
|
||||||
value = value[1:]
|
|
||||||
}
|
|
||||||
interpreted = append(interpreted, "--"+flag.Name+"="+value)
|
|
||||||
occurrences = append(occurrences, commandmeta.FlagOccurrence{Name: flag.Name, Spelling: original, RawValue: value})
|
|
||||||
break
|
|
||||||
}
|
|
||||||
if index+1 < len(argv) {
|
|
||||||
value := argv[index+1]
|
|
||||||
interpreted = append(interpreted, "--"+flag.Name+"="+value)
|
|
||||||
occurrences = append(occurrences, commandmeta.FlagOccurrence{Name: flag.Name, Spelling: original, RawValue: value})
|
|
||||||
index += 2
|
|
||||||
consumedNext = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
return interpreted, spellings, occurrences, &commandmeta.Failure{Message: fmt.Sprintf("flag needs an argument: %s", original)}
|
return interpreted, spellings, occurrences, &commandmeta.Failure{Message: fmt.Sprintf("flag needs an argument: %s", original)}
|
||||||
}
|
}
|
||||||
if !consumedNext {
|
value = argv[index+1]
|
||||||
|
consumedNext = true
|
||||||
|
}
|
||||||
|
if !fact.Attached && !fact.NeedsNext {
|
||||||
|
value = "true"
|
||||||
|
interpreted = append(interpreted, "--"+flag.Name)
|
||||||
|
} else {
|
||||||
|
interpreted = append(interpreted, "--"+flag.Name+"="+value)
|
||||||
|
}
|
||||||
|
occurrences = append(occurrences, commandmeta.FlagOccurrence{Name: flag.Name, Spelling: original, RawValue: value})
|
||||||
|
if fact.NextOffset == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
offset = fact.NextOffset
|
||||||
|
}
|
||||||
|
index++
|
||||||
|
if consumedNext {
|
||||||
index++
|
index++
|
||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ package cobra
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -35,8 +34,7 @@ func Prepare(root *commandmeta.Command, options commandmeta.ParseOptions) *Sessi
|
|||||||
session.metadata = root
|
session.metadata = root
|
||||||
session.flagAliases = append([]commandmeta.FlagAlias(nil), options.FlagAliases...)
|
session.flagAliases = append([]commandmeta.FlagAlias(nil), options.FlagAliases...)
|
||||||
session.flagAliasesForPath = options.FlagAliasesForPath
|
session.flagAliasesForPath = options.FlagAliasesForPath
|
||||||
options = normalizeOptions(options)
|
session.root, session.declarations = buildTree(root)
|
||||||
session.root, session.declarations = buildTree(root, options)
|
|
||||||
return session
|
return session
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,9 +77,7 @@ func (session *Session) Parse(argv []string) commandmeta.Result {
|
|||||||
}
|
}
|
||||||
|
|
||||||
remaining := append(commandpath.CloneTokens(unconsumed), tail...)
|
remaining := append(commandpath.CloneTokens(unconsumed), tail...)
|
||||||
localFlags := make([]commandmeta.Flag, 0, len(selectedNode.Flags)+len(selectedNode.CoreFlags))
|
localFlags := commandmeta.EffectiveFlags(selectedNode.Flags, selectedNode.CoreFlags)
|
||||||
localFlags = append(localFlags, selectedNode.Flags...)
|
|
||||||
localFlags = append(localFlags, selectedNode.CoreFlags...)
|
|
||||||
aliases := session.flagAliases
|
aliases := session.flagAliases
|
||||||
if session.flagAliasesForPath != nil {
|
if session.flagAliasesForPath != nil {
|
||||||
aliases = session.flagAliasesForPath(result.Path)
|
aliases = session.flagAliasesForPath(result.Path)
|
||||||
@@ -256,7 +252,7 @@ func displayCommandPath(rootName string, path []string) string {
|
|||||||
return strings.Join(parts, " ")
|
return strings.Join(parts, " ")
|
||||||
}
|
}
|
||||||
|
|
||||||
// UsageLine returns Cobra's usage line for an exact path on this session.
|
// UsageLine returns usage for an exact path on this session.
|
||||||
func (session *Session) UsageLine(path []string) (string, *commandmeta.Failure) {
|
func (session *Session) UsageLine(path []string) (string, *commandmeta.Failure) {
|
||||||
if session == nil || session.root == nil {
|
if session == nil || session.root == nil {
|
||||||
return "", &commandmeta.Failure{Message: "command tree is nil"}
|
return "", &commandmeta.Failure{Message: "command tree is nil"}
|
||||||
@@ -271,6 +267,22 @@ func (session *Session) UsageLine(path []string) (string, *commandmeta.Failure)
|
|||||||
if selected.HasSubCommands() {
|
if selected.HasSubCommands() {
|
||||||
line += " [command]"
|
line += " [command]"
|
||||||
}
|
}
|
||||||
|
for _, flag := range declaration.Flags {
|
||||||
|
if flag.Name != "debug" || !flag.Boolean {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, effective := range commandmeta.EffectiveFlags(declaration.Flags, declaration.CoreFlags) {
|
||||||
|
if effective.Name == "debug" {
|
||||||
|
spelling := "--debug"
|
||||||
|
if effective.Short != "" {
|
||||||
|
spelling = "-" + effective.Short + ", " + spelling
|
||||||
|
}
|
||||||
|
line += "\n " + spelling + " " + flagUsage(effective)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
return line, nil
|
return line, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -289,23 +301,7 @@ func Parse(root *commandmeta.Command, argv []string, options commandmeta.ParseOp
|
|||||||
return Prepare(root, options).Parse(argv)
|
return Prepare(root, options).Parse(argv)
|
||||||
}
|
}
|
||||||
|
|
||||||
func normalizeOptions(options commandmeta.ParseOptions) commandmeta.ParseOptions {
|
func buildTree(root *commandmeta.Command) (*spfcobra.Command, map[*spfcobra.Command]*commandmeta.Command) {
|
||||||
if options.Context == nil {
|
|
||||||
options.Context = context.Background()
|
|
||||||
}
|
|
||||||
if options.In == nil {
|
|
||||||
options.In = strings.NewReader("")
|
|
||||||
}
|
|
||||||
if options.Out == nil {
|
|
||||||
options.Out = io.Discard
|
|
||||||
}
|
|
||||||
if options.Err == nil {
|
|
||||||
options.Err = io.Discard
|
|
||||||
}
|
|
||||||
return options
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildTree(root *commandmeta.Command, options commandmeta.ParseOptions) (*spfcobra.Command, map[*spfcobra.Command]*commandmeta.Command) {
|
|
||||||
nodes := make(map[*spfcobra.Command]*commandmeta.Command)
|
nodes := make(map[*spfcobra.Command]*commandmeta.Command)
|
||||||
var build func(*commandmeta.Command) *spfcobra.Command
|
var build func(*commandmeta.Command) *spfcobra.Command
|
||||||
build = func(declaration *commandmeta.Command) *spfcobra.Command {
|
build = func(declaration *commandmeta.Command) *spfcobra.Command {
|
||||||
@@ -321,10 +317,8 @@ func buildTree(root *commandmeta.Command, options commandmeta.ParseOptions) (*sp
|
|||||||
if declaration.Runnable {
|
if declaration.Runnable {
|
||||||
backend.Run = func(*spfcobra.Command, []string) {}
|
backend.Run = func(*spfcobra.Command, []string) {}
|
||||||
}
|
}
|
||||||
backend.SetContext(options.Context)
|
backend.SetOut(io.Discard)
|
||||||
backend.SetIn(options.In)
|
backend.SetErr(io.Discard)
|
||||||
backend.SetOut(options.Out)
|
|
||||||
backend.SetErr(options.Err)
|
|
||||||
backend.SetHelpFunc(func(*spfcobra.Command, []string) {})
|
backend.SetHelpFunc(func(*spfcobra.Command, []string) {})
|
||||||
backend.SetFlagErrorFunc(func(_ *spfcobra.Command, err error) error { return err })
|
backend.SetFlagErrorFunc(func(_ *spfcobra.Command, err error) error { return err })
|
||||||
backend.SetUsageTemplate(usageTemplate)
|
backend.SetUsageTemplate(usageTemplate)
|
||||||
@@ -336,7 +330,7 @@ func buildTree(root *commandmeta.Command, options commandmeta.ParseOptions) (*sp
|
|||||||
helpFlag := backend.Flags().Lookup("help")
|
helpFlag := backend.Flags().Lookup("help")
|
||||||
helpFlag.NoOptDefVal = "true"
|
helpFlag.NoOptDefVal = "true"
|
||||||
helpFlag.Usage = "help for this command (default: false)"
|
helpFlag.Usage = "help for this command (default: false)"
|
||||||
for _, declarationFlag := range append(append([]commandmeta.Flag(nil), declaration.Flags...), declaration.CoreFlags...) {
|
for _, declarationFlag := range commandmeta.EffectiveFlags(declaration.Flags, declaration.CoreFlags) {
|
||||||
value := &rawValue{boolean: declarationFlag.Boolean}
|
value := &rawValue{boolean: declarationFlag.Boolean}
|
||||||
backend.Flags().VarP(value, declarationFlag.Name, declarationFlag.Short, flagUsage(declarationFlag))
|
backend.Flags().VarP(value, declarationFlag.Name, declarationFlag.Short, flagUsage(declarationFlag))
|
||||||
backendFlag := backend.Flags().Lookup(declarationFlag.Name)
|
backendFlag := backend.Flags().Lookup(declarationFlag.Name)
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
package cobra
|
package cobra
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"reflect"
|
"reflect"
|
||||||
@@ -182,7 +180,7 @@ func TestParseReturnsNeutralFailuresAndFreshState(t *testing.T) {
|
|||||||
tree := &Command{Name: "app", Children: []*Command{
|
tree := &Command{Name: "app", Children: []*Command{
|
||||||
{Name: "run", Runnable: true, Flags: []Flag{{Name: "value"}}},
|
{Name: "run", Runnable: true, Flags: []Flag{{Name: "value"}}},
|
||||||
}}
|
}}
|
||||||
options := ParseOptions{Context: context.Background(), In: strings.NewReader("unused"), Out: io.Discard, Err: io.Discard}
|
options := ParseOptions{}
|
||||||
first := Parse(tree, []string{"run", "--value=old"}, options)
|
first := Parse(tree, []string{"run", "--value=old"}, options)
|
||||||
second := Parse(tree, []string{"run"}, options)
|
second := Parse(tree, []string{"run"}, options)
|
||||||
if first.Failure != nil || second.Failure != nil {
|
if first.Failure != nil || second.Failure != nil {
|
||||||
@@ -247,8 +245,7 @@ func TestSelectedPathsResolveThroughBackendParents(t *testing.T) {
|
|||||||
|
|
||||||
func TestSessionReusesTreeForParseHelpAndUsageWithFreshState(t *testing.T) {
|
func TestSessionReusesTreeForParseHelpAndUsageWithFreshState(t *testing.T) {
|
||||||
tree := &Command{Name: "app", Children: []*Command{{Name: "run", Runnable: true, Flags: []Flag{{Name: "value"}}}}}
|
tree := &Command{Name: "app", Children: []*Command{{Name: "run", Runnable: true, Flags: []Flag{{Name: "value"}}}}}
|
||||||
var output, errors bytes.Buffer
|
session := Prepare(tree, ParseOptions{})
|
||||||
session := Prepare(tree, ParseOptions{Context: context.Background(), Out: &output, Err: &errors})
|
|
||||||
first := session.Parse([]string{"run", "--value=old"})
|
first := session.Parse([]string{"run", "--value=old"})
|
||||||
if first.Failure != nil || !reflect.DeepEqual(first.FlagValues, [][]string{{"old"}}) {
|
if first.Failure != nil || !reflect.DeepEqual(first.FlagValues, [][]string{{"old"}}) {
|
||||||
t.Fatalf("first session parse = %#v", first)
|
t.Fatalf("first session parse = %#v", first)
|
||||||
@@ -261,19 +258,15 @@ func TestSessionReusesTreeForParseHelpAndUsageWithFreshState(t *testing.T) {
|
|||||||
if failure != nil || !strings.Contains(line, "run") {
|
if failure != nil || !strings.Contains(line, "run") {
|
||||||
t.Fatalf("session usage = %q, %v", line, failure)
|
t.Fatalf("session usage = %q, %v", line, failure)
|
||||||
}
|
}
|
||||||
if output.Len() != 0 || errors.Len() != 0 {
|
if session.root.OutOrStdout() != io.Discard || session.root.ErrOrStderr() != io.Discard {
|
||||||
t.Fatalf("help escaped session delivery control: out=%q err=%q", output.String(), errors.String())
|
t.Fatal("backend destinations are not discarded")
|
||||||
}
|
}
|
||||||
|
|
||||||
var secondOutput, secondErrors bytes.Buffer
|
second := Prepare(tree, ParseOptions{})
|
||||||
second := Prepare(tree, ParseOptions{Context: context.Background(), Out: &secondOutput, Err: &secondErrors})
|
|
||||||
secondResult := second.Parse([]string{"run"})
|
secondResult := second.Parse([]string{"run"})
|
||||||
if secondResult.Failure != nil || !reflect.DeepEqual(secondResult.FlagValues, [][]string{nil}) {
|
if secondResult.Failure != nil || !reflect.DeepEqual(secondResult.FlagValues, [][]string{nil}) {
|
||||||
t.Fatalf("fresh session state = %#v", secondResult)
|
t.Fatalf("fresh session state = %#v", secondResult)
|
||||||
}
|
}
|
||||||
if output.Len() != 0 || errors.Len() != 0 || secondOutput.Len() != 0 || secondErrors.Len() != 0 {
|
|
||||||
t.Fatal("prepared session streams were unexpectedly used")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestMissingSessionTreeReturnsNeutralFailures(t *testing.T) {
|
func TestMissingSessionTreeReturnsNeutralFailures(t *testing.T) {
|
||||||
@@ -288,3 +281,14 @@ func TestMissingSessionTreeReturnsNeutralFailures(t *testing.T) {
|
|||||||
t.Fatalf("missing-tree usage failure = %#v", failure)
|
t.Fatalf("missing-tree usage failure = %#v", failure)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSharedDebugExtractionPreservesConsumerIndexes(t *testing.T) {
|
||||||
|
tree := &Command{Name: "app", CoreFlags: commandmeta.ReportingFlags(), Children: []*Command{{Name: "run", Runnable: true, CoreFlags: commandmeta.ReportingFlags(), Flags: []Flag{{Name: "before"}, {Name: "debug", Short: "d", Boolean: true}, {Name: "after"}}}}}
|
||||||
|
result := Parse(tree, []string{"run", "--before=one", "-d", "--after=two", "-x=false"}, ParseOptions{FlagAliases: []commandmeta.FlagAlias{{Source: "-x", Target: "--debug"}}})
|
||||||
|
if result.Failure != nil {
|
||||||
|
t.Fatal(result.Failure)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(result.FlagValues, [][]string{{"one"}, {"true", "false"}, {"two"}}) || !reflect.DeepEqual(result.FlagSpellings[1], []string{"-d", "-x"}) || len(result.FlagOccurrences) != 4 {
|
||||||
|
t.Fatalf("parse = %#v", result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
package cobra
|
package cobra
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -11,7 +9,7 @@ func BenchmarkPerformanceParse(b *testing.B) {
|
|||||||
wide := performanceFlatTree(128)
|
wide := performanceFlatTree(128)
|
||||||
wideArgs := []string{"wide127"}
|
wideArgs := []string{"wide127"}
|
||||||
deep, deepArgs := performanceNestedTree(24)
|
deep, deepArgs := performanceNestedTree(24)
|
||||||
options := ParseOptions{Context: context.Background(), Out: io.Discard, Err: io.Discard}
|
options := ParseOptions{}
|
||||||
|
|
||||||
for _, benchmark := range []struct {
|
for _, benchmark := range []struct {
|
||||||
name string
|
name string
|
||||||
@@ -41,7 +39,7 @@ func BenchmarkPerformanceParse(b *testing.B) {
|
|||||||
func BenchmarkPerformanceSessionParseAndHelp(b *testing.B) {
|
func BenchmarkPerformanceSessionParseAndHelp(b *testing.B) {
|
||||||
root := performanceFlatTree(8)
|
root := performanceFlatTree(8)
|
||||||
args := []string{"wide007"}
|
args := []string{"wide007"}
|
||||||
session := Prepare(root, ParseOptions{Context: context.Background(), Out: io.Discard, Err: io.Discard})
|
session := Prepare(root, ParseOptions{})
|
||||||
result := session.Parse(args)
|
result := session.Parse(args)
|
||||||
if result.Failure != nil {
|
if result.Failure != nil {
|
||||||
b.Fatalf("fixture parse failed: %s", result.Failure.Message)
|
b.Fatalf("fixture parse failed: %s", result.Failure.Message)
|
||||||
|
|||||||
@@ -65,3 +65,47 @@ func (lookup FlagLookup) Find(spelling string) (Flag, bool) {
|
|||||||
}
|
}
|
||||||
return flag, ok
|
return flag, ok
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FlagConsumption describes one flag within an option token. NextOffset is zero
|
||||||
|
// when the token is exhausted; otherwise it identifies the next short spelling.
|
||||||
|
type FlagConsumption struct {
|
||||||
|
Flag Flag
|
||||||
|
Spelling string
|
||||||
|
Found bool
|
||||||
|
Value string
|
||||||
|
Attached bool
|
||||||
|
NeedsNext bool
|
||||||
|
NextOffset int
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConsumeFlag reads a long option or a short option at offset in a cluster.
|
||||||
|
func ConsumeFlag(lookup FlagLookup, token string, offset int) FlagConsumption {
|
||||||
|
var result FlagConsumption
|
||||||
|
if ClassifyFlagToken(token) == LongFlagToken {
|
||||||
|
result.Spelling, result.Value, result.Attached = SplitLongFlag(token)
|
||||||
|
result.Flag, result.Found = lookup.Find(result.Spelling)
|
||||||
|
result.NeedsNext = result.Found && !result.Flag.Boolean && !result.Attached
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
result.Spelling = "-" + token[offset:offset+1]
|
||||||
|
result.Flag, result.Found = lookup.Find(result.Spelling)
|
||||||
|
if !result.Found {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
suffix := token[offset+1:]
|
||||||
|
if result.Flag.Boolean {
|
||||||
|
if strings.HasPrefix(suffix, "=") {
|
||||||
|
result.Attached, result.Value = true, suffix[1:]
|
||||||
|
} else if suffix != "" {
|
||||||
|
result.NextOffset = offset + 1
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
if suffix == "" {
|
||||||
|
result.NeedsNext = true
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
result.Attached = true
|
||||||
|
result.Value = strings.TrimPrefix(suffix, "=")
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,10 +2,7 @@
|
|||||||
// its command-line backend.
|
// its command-line backend.
|
||||||
package commandmeta
|
package commandmeta
|
||||||
|
|
||||||
import (
|
import "strings"
|
||||||
"context"
|
|
||||||
"io"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Key identifies one command inside one routing snapshot.
|
// Key identifies one command inside one routing snapshot.
|
||||||
type Key uint64
|
type Key uint64
|
||||||
@@ -75,7 +72,7 @@ type CompletionResult struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var reportingFlags = [...]Flag{
|
var reportingFlags = [...]Flag{
|
||||||
{Name: "quiet", Usage: "suppress substantive output and optional messages; keep failures visible", Default: "false", Boolean: true},
|
{Name: "quiet", Usage: "hide command output and optional messages; keep failures visible", Default: "false", Boolean: true},
|
||||||
{Name: "verbose", Usage: "show warning and informational messages", Default: "false", Boolean: true},
|
{Name: "verbose", Usage: "show warning and informational messages", Default: "false", Boolean: true},
|
||||||
{Name: "debug", Usage: "show debugging messages", Default: "false", Boolean: true},
|
{Name: "debug", Usage: "show debugging messages", Default: "false", Boolean: true},
|
||||||
{Name: "progress", Usage: "show progress messages", Default: "false", Boolean: true},
|
{Name: "progress", Usage: "show progress messages", Default: "false", Boolean: true},
|
||||||
@@ -105,6 +102,35 @@ func ReportingFlags() []Flag {
|
|||||||
return flags
|
return flags
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// EffectiveFlags combines the shared debug option while preserving consumer order.
|
||||||
|
func EffectiveFlags(flags, core []Flag) []Flag {
|
||||||
|
result := append([]Flag(nil), flags...)
|
||||||
|
shared := -1
|
||||||
|
for i, flag := range result {
|
||||||
|
if flag.Name == "debug" && flag.Boolean {
|
||||||
|
shared = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, flag := range core {
|
||||||
|
if flag.Name == "debug" && shared >= 0 {
|
||||||
|
app := result[shared]
|
||||||
|
usage := flag.Usage
|
||||||
|
if app.Usage != "" {
|
||||||
|
usage += "; " + app.Usage
|
||||||
|
}
|
||||||
|
if strings.EqualFold(app.Default, "true") {
|
||||||
|
usage += "; application input defaults to true (reporting defaults to ordinary)"
|
||||||
|
}
|
||||||
|
flag.Usage, flag.Short, flag.Required, flag.Completion = usage, app.Short, app.Required, app.Completion
|
||||||
|
result[shared] = flag
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
result = append(result, flag)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
// IsReportingFlag reports whether name is reserved for a shared reporting flag.
|
// IsReportingFlag reports whether name is reserved for a shared reporting flag.
|
||||||
func IsReportingFlag(name string) bool {
|
func IsReportingFlag(name string) bool {
|
||||||
for _, flag := range reportingFlags {
|
for _, flag := range reportingFlags {
|
||||||
@@ -181,14 +207,10 @@ type Result struct {
|
|||||||
Failure *Failure
|
Failure *Failure
|
||||||
}
|
}
|
||||||
|
|
||||||
// ParseOptions makes parser context and streams explicit.
|
// ParseOptions selects flag aliases for parser contexts.
|
||||||
type ParseOptions struct {
|
type ParseOptions struct {
|
||||||
// FlagAliasesForPath selects aliases for the actual parser context when
|
// FlagAliasesForPath selects aliases for the actual parser context when
|
||||||
// routing has not already selected one. It overrides FlagAliases when set.
|
// routing has not already selected one. It overrides FlagAliases when set.
|
||||||
FlagAliasesForPath func([]string) []FlagAlias
|
FlagAliasesForPath func([]string) []FlagAlias
|
||||||
Context context.Context
|
|
||||||
In io.Reader
|
|
||||||
Out io.Writer
|
|
||||||
Err io.Writer
|
|
||||||
FlagAliases []FlagAlias
|
FlagAliases []FlagAlias
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
package commandmeta
|
package commandmeta
|
||||||
|
|
||||||
import "testing"
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
func TestReportingFlagsAreBooleanAndReturnedIndependently(t *testing.T) {
|
func TestReportingFlagsAreBooleanAndReturnedIndependently(t *testing.T) {
|
||||||
flags := ReportingFlags()
|
flags := ReportingFlags()
|
||||||
@@ -33,3 +36,15 @@ func TestReportingFlagsAreBooleanAndReturnedIndependently(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestEffectiveFlagsCombinesDebugMetadata(t *testing.T) {
|
||||||
|
flags := []Flag{{Name: "before"}, {Name: "debug", Short: "d", Boolean: true, Default: "true", Required: true, Usage: "application diagnostics", Completion: CompletionHint{Kind: CompletionFiles}}, {Name: "after"}}
|
||||||
|
core := ReportingFlags()
|
||||||
|
got := EffectiveFlags(flags, core)
|
||||||
|
if len(got) != len(flags)+len(core)-1 || got[1].Name != "debug" || got[1].Default != "false" || got[1].Short != "d" || !got[1].Required || got[1].Completion.Kind != CompletionFiles || !strings.Contains(got[1].Usage, "application diagnostics") || !strings.Contains(got[1].Usage, "application input defaults to true") {
|
||||||
|
t.Fatalf("effective debug = %#v", got)
|
||||||
|
}
|
||||||
|
if flags[1].Default != "true" || core[2].Short != "" {
|
||||||
|
t.Fatal("input metadata changed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -150,3 +150,65 @@ func TestOccurrenceRequiredAndAliasedConversion(t *testing.T) {
|
|||||||
t.Fatalf("help: %#v", help)
|
t.Fatalf("help: %#v", help)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestNestedCapturedOccurrencesMergeSelections(t *testing.T) {
|
||||||
|
calls := 0
|
||||||
|
a := occurrenceFor("alpha", false, &calls)
|
||||||
|
b := RenameFlag(occurrenceFor("beta", false, &calls), "beta")
|
||||||
|
c := RenameFlag(occurrenceFor("gamma", false, &calls), "gamma")
|
||||||
|
type middle struct{ values FlagValues[occurrenceInput] }
|
||||||
|
type outer struct{ values FlagValues[middle] }
|
||||||
|
inner := CaptureFlags([]Flag[occurrenceInput]{a, b, c}, func(m *middle) *FlagValues[occurrenceInput] { return &m.values })
|
||||||
|
flags := CaptureFlags(inner, func(o *outer) *FlagValues[middle] { return &o.values })
|
||||||
|
var captured FlagValues[occurrenceInput]
|
||||||
|
app, err := New(Identity{Name: "Example"}, NewCommand(CommandSpec[outer]{Name: "run", Flags: flags, Run: func(_ Invocation, input outer) Outcome {
|
||||||
|
var m middle
|
||||||
|
if err := input.values.ApplyTo(&m, inner...); err != nil {
|
||||||
|
return Failure("middle replay failed").WithCause(err)
|
||||||
|
}
|
||||||
|
captured = m.values
|
||||||
|
return Success()
|
||||||
|
}}))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, args := range [][]string{
|
||||||
|
{"run", "--gamma=c1", "--alpha=a1", "--beta=b1", "--alpha=a2", "--gamma=c2", "--beta=b2"},
|
||||||
|
{"run"},
|
||||||
|
} {
|
||||||
|
result := app.Execute(t.Context(), args, Streams{})
|
||||||
|
if result.Status != 0 {
|
||||||
|
t.Fatalf("capture=%#v", result)
|
||||||
|
}
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
flags []Flag[occurrenceInput]
|
||||||
|
want []string
|
||||||
|
}{
|
||||||
|
{"all reversed", []Flag[occurrenceInput]{c, b, a}, []string{"gamma=c1", "alpha=a1", "beta=b1", "alpha=a2", "gamma=c2", "beta=b2"}},
|
||||||
|
{"subset", []Flag[occurrenceInput]{a, c}, []string{"gamma=c1", "alpha=a1", "alpha=a2", "gamma=c2"}},
|
||||||
|
{"single", []Flag[occurrenceInput]{b}, []string{"beta=b1", "beta=b2"}},
|
||||||
|
{"duplicate handle", []Flag[occurrenceInput]{b, a, b}, []string{"alpha=a1", "beta=b1", "beta=b1", "alpha=a2", "beta=b2", "beta=b2"}},
|
||||||
|
{"empty", nil, nil},
|
||||||
|
} {
|
||||||
|
for _, suppliedOnly := range []bool{false, true} {
|
||||||
|
var got occurrenceInput
|
||||||
|
if suppliedOnly {
|
||||||
|
err = captured.ApplySuppliedTo(&got, tc.flags...)
|
||||||
|
} else {
|
||||||
|
err = captured.ApplyTo(&got, tc.flags...)
|
||||||
|
}
|
||||||
|
want := tc.want
|
||||||
|
if len(args) == 1 {
|
||||||
|
want = nil
|
||||||
|
}
|
||||||
|
if err != nil || !reflect.DeepEqual(got.events, want) {
|
||||||
|
t.Fatalf("%s suppliedOnly=%t args=%v events=%v want=%v err=%v", tc.name, suppliedOnly, args, got.events, want, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if calls != 6 {
|
||||||
|
t.Fatalf("replay reconverted captured input: %d parses", calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,19 +5,10 @@ import (
|
|||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
internalcobra "go.maximumdirect.net/commandkit/internal/cobra"
|
|
||||||
commandmeta "go.maximumdirect.net/commandkit/internal/commandmeta"
|
commandmeta "go.maximumdirect.net/commandkit/internal/commandmeta"
|
||||||
"go.maximumdirect.net/commandkit/internal/commandpath"
|
"go.maximumdirect.net/commandkit/internal/commandpath"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (a *App) parse(args []string, options commandmeta.ParseOptions) commandmeta.Result {
|
|
||||||
if a == nil {
|
|
||||||
return commandmeta.Result{Failure: &commandmeta.Failure{Message: "cannot parse commands on a nil app"}}
|
|
||||||
}
|
|
||||||
result := internalcobra.Parse(a.routeSnapshot().metadata, args, options)
|
|
||||||
return reduceReportingOccurrences(result)
|
|
||||||
}
|
|
||||||
|
|
||||||
func reduceReportingOccurrences(result commandmeta.Result) commandmeta.Result {
|
func reduceReportingOccurrences(result commandmeta.Result) commandmeta.Result {
|
||||||
selection := commandmeta.ReportingSelection{}
|
selection := commandmeta.ReportingSelection{}
|
||||||
for index, occurrence := range result.FlagOccurrences {
|
for index, occurrence := range result.FlagOccurrences {
|
||||||
|
|||||||
@@ -34,15 +34,14 @@ func TestAppParseUsesRegisteredBindingsWithTheAdapter(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
options := commandmeta.ParseOptions{Context: context.Background()}
|
result := parseReportingArgs(app, []string{"config", "show", "one", "--scope", "app"})
|
||||||
result := app.parse([]string{"config", "show", "one", "--scope", "app"}, options)
|
|
||||||
if result.Failure != nil {
|
if result.Failure != nil {
|
||||||
t.Fatalf("parse failed: %s", result.Failure.Message)
|
t.Fatalf("parse failed: %s", result.Failure.Message)
|
||||||
}
|
}
|
||||||
if !reflect.DeepEqual(result.Path, []string{"config", "show"}) || !reflect.DeepEqual(result.FlagValues, [][]string{{"app"}}) || !reflect.DeepEqual(result.Operands, []string{"one"}) {
|
if !reflect.DeepEqual(result.Path, []string{"config", "show"}) || !reflect.DeepEqual(result.FlagValues, [][]string{{"app"}}) || !reflect.DeepEqual(result.Operands, []string{"one"}) {
|
||||||
t.Fatalf("parse result = %#v", result)
|
t.Fatalf("parse result = %#v", result)
|
||||||
}
|
}
|
||||||
result = app.parse([]string{"config", "--scope", "app", "show"}, options)
|
result = parseReportingArgs(app, []string{"config", "--scope", "app", "show"})
|
||||||
if result.Failure == nil {
|
if result.Failure == nil {
|
||||||
t.Fatal("accepted a child flag before the complete command path")
|
t.Fatal("accepted a child flag before the complete command path")
|
||||||
}
|
}
|
||||||
@@ -55,7 +54,7 @@ func TestRoutingSnapshotPairsParsedKeyWithItsRunner(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
snapshot := app.routeSnapshot()
|
snapshot := app.routeSnapshot()
|
||||||
session := internalcobra.Prepare(snapshot.metadata, commandmeta.ParseOptions{Context: context.Background()})
|
session := internalcobra.Prepare(snapshot.metadata, commandmeta.ParseOptions{})
|
||||||
parsed := session.Parse([]string{"run"})
|
parsed := session.Parse([]string{"run"})
|
||||||
if parsed.Failure != nil {
|
if parsed.Failure != nil {
|
||||||
t.Fatalf("parse failed: %s", parsed.Failure.Message)
|
t.Fatalf("parse failed: %s", parsed.Failure.Message)
|
||||||
|
|||||||
@@ -32,8 +32,10 @@ func normalizeReport(value any) (any, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// DecodeYAMLReport decodes one YAML document from safe inspection output into
|
// DecodeYAMLReport decodes one YAML document from safe inspection output into
|
||||||
// JSON-compatible values. Unsupported YAML meanings return an error; the result
|
// JSON-compatible values. Multiple documents, unsupported tags and non-string
|
||||||
// is not a lossless representation of the input document.
|
// mapping keys return an error. The result does not preserve formatting, comments
|
||||||
|
// or framing. Scalar types follow resolved YAML tags; numeric-looking strings
|
||||||
|
// remain strings.
|
||||||
func DecodeYAMLReport(reader io.Reader) (any, error) {
|
func DecodeYAMLReport(reader io.Reader) (any, error) {
|
||||||
decoder := yaml.NewDecoder(reader)
|
decoder := yaml.NewDecoder(reader)
|
||||||
var doc yaml.Node
|
var doc yaml.Node
|
||||||
@@ -104,18 +106,6 @@ func yamlReportValue(node *yaml.Node, visiting map[*yaml.Node]bool) (any, error)
|
|||||||
case yaml.ScalarNode:
|
case yaml.ScalarNode:
|
||||||
switch node.ShortTag() {
|
switch node.ShortTag() {
|
||||||
case "!!str":
|
case "!!str":
|
||||||
// The YAML resolver falls back to a string when a numeric scalar is
|
|
||||||
// outside its machine-number range. Preserve explicit and quoted
|
|
||||||
// strings, but do not silently turn a plain numeric scalar into text.
|
|
||||||
if node.Style == 0 {
|
|
||||||
plain := strings.ReplaceAll(node.Value, "_", "")
|
|
||||||
if yamlInteger.MatchString(plain) {
|
|
||||||
return yamlReportNumber(node.Value, "!!int")
|
|
||||||
}
|
|
||||||
if yamlNumericPlain.MatchString(plain) {
|
|
||||||
return yamlReportNumber(node.Value, "!!float")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return node.Value, nil
|
return node.Value, nil
|
||||||
case "!!null":
|
case "!!null":
|
||||||
return nil, nil
|
return nil, nil
|
||||||
@@ -188,7 +178,14 @@ func reportYAMLNode(value any) (*yaml.Node, error) {
|
|||||||
if strings.ContainsAny(string(v), ".eE") {
|
if strings.ContainsAny(string(v), ".eE") {
|
||||||
tag = "!!float"
|
tag = "!!float"
|
||||||
}
|
}
|
||||||
return &yaml.Node{Kind: yaml.ScalarNode, Tag: tag, Value: string(v)}, nil
|
node := &yaml.Node{Kind: yaml.ScalarNode, Tag: tag, Value: string(v)}
|
||||||
|
// The YAML emitter can omit numeric tags even when its resolver would
|
||||||
|
// classify an overflowing token as a string. Retain the numeric evidence
|
||||||
|
// explicitly; ParseFloat selects only the style, never the stored value.
|
||||||
|
if _, err := strconv.ParseFloat(string(v), 64); err != nil {
|
||||||
|
node.Style = yaml.TaggedStyle
|
||||||
|
}
|
||||||
|
return node, nil
|
||||||
case []any:
|
case []any:
|
||||||
node := &yaml.Node{Kind: yaml.SequenceNode, Tag: "!!seq"}
|
node := &yaml.Node{Kind: yaml.SequenceNode, Tag: "!!seq"}
|
||||||
for _, item := range v {
|
for _, item := range v {
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ func TestNormalizeReport(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestYAMLReportValues(t *testing.T) {
|
func TestYAMLReportValues(t *testing.T) {
|
||||||
input := `large: 123456789012345678901234567890
|
input := `large: !!int 123456789012345678901234567890
|
||||||
hex: 0xff
|
hex: 0xff
|
||||||
fraction: .125
|
fraction: .125
|
||||||
text: "false"
|
text: "false"
|
||||||
@@ -94,8 +94,8 @@ func TestDecodeYAMLReportRejectsUnsupportedValues(t *testing.T) {
|
|||||||
|
|
||||||
func TestYAMLReportLargeNumericScalars(t *testing.T) {
|
func TestYAMLReportLargeNumericScalars(t *testing.T) {
|
||||||
for _, tc := range []struct{ input, want string }{
|
for _, tc := range []struct{ input, want string }{
|
||||||
{"1e9999", "1e9999"}, {"0x100000000000000000000", "1208925819614629174706176"},
|
{"!!float 1e9999", "1e9999"}, {"!!int 0x100000000000000000000", "1208925819614629174706176"},
|
||||||
{"+01.2500e+9999", "1.2500e+9999"},
|
{"!!float +01.2500e+9999", "1.2500e+9999"},
|
||||||
} {
|
} {
|
||||||
t.Run(tc.input, func(t *testing.T) {
|
t.Run(tc.input, func(t *testing.T) {
|
||||||
got, err := DecodeYAMLReport(strings.NewReader(tc.input))
|
got, err := DecodeYAMLReport(strings.NewReader(tc.input))
|
||||||
@@ -104,7 +104,7 @@ func TestYAMLReportLargeNumericScalars(t *testing.T) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
for _, input := range []string{`"1e9999"`, `!!str 1e9999`, `.`, `hello`} {
|
for _, input := range []string{`1e9999`, `"1e9999"`, `!!str 1e9999`, `.`, `hello`} {
|
||||||
got, err := DecodeYAMLReport(strings.NewReader(input))
|
got, err := DecodeYAMLReport(strings.NewReader(input))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -133,3 +133,37 @@ func TestReportYAMLNodePreservesNumericLookingStrings(t *testing.T) {
|
|||||||
t.Fatalf("%s: got %#v, want %#v", data, got, value)
|
t.Fatalf("%s: got %#v, want %#v", data, got, value)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDecodeYAMLReportPreservesPlainNativeStrings(t *testing.T) {
|
||||||
|
// Native ConfigKit safe output can contain an unquoted numeric-looking string
|
||||||
|
// when the YAML resolver considers the token outside the numeric range.
|
||||||
|
value, err := DecodeYAMLReport(strings.NewReader(`1e400: 1e400
|
||||||
|
quoted: "1e400"
|
||||||
|
explicit: !!str 1e400
|
||||||
|
number: !!float 1e400
|
||||||
|
`))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
want := map[string]any{"1e400": "1e400", "quoted": "1e400", "explicit": "1e400", "number": json.Number("1e400")}
|
||||||
|
if !reflect.DeepEqual(value, want) {
|
||||||
|
t.Fatalf("decoded=%#v want=%#v", value, want)
|
||||||
|
}
|
||||||
|
for _, format := range []Format{FormatJSON, FormatYAML} {
|
||||||
|
var output bytes.Buffer
|
||||||
|
if err := RenderReport(&output, Report{Value: value}, format); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var got any
|
||||||
|
if format == FormatJSON {
|
||||||
|
decoder := json.NewDecoder(&output)
|
||||||
|
decoder.UseNumber()
|
||||||
|
err = decoder.Decode(&got)
|
||||||
|
} else {
|
||||||
|
got, err = DecodeYAMLReport(&output)
|
||||||
|
}
|
||||||
|
if err != nil || !reflect.DeepEqual(got, want) {
|
||||||
|
t.Fatalf("format %v: got=%#v err=%v", format, got, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
108
reporter.go
108
reporter.go
@@ -10,13 +10,19 @@ import (
|
|||||||
commandmeta "go.maximumdirect.net/commandkit/internal/commandmeta"
|
commandmeta "go.maximumdirect.net/commandkit/internal/commandmeta"
|
||||||
)
|
)
|
||||||
|
|
||||||
type reportingMode uint8
|
// ReportingMode identifies the invocation's selected reporting visibility.
|
||||||
|
// Its zero value is ReportingModeOrdinary.
|
||||||
|
type ReportingMode uint8
|
||||||
|
|
||||||
const (
|
const (
|
||||||
modeOrdinary reportingMode = iota
|
// ReportingModeOrdinary uses ordinary reporting visibility.
|
||||||
modeQuiet
|
ReportingModeOrdinary ReportingMode = iota
|
||||||
modeVerbose
|
// ReportingModeQuiet suppresses ordinary output and messages.
|
||||||
modeDebug
|
ReportingModeQuiet
|
||||||
|
// ReportingModeVerbose enables verbose reporting.
|
||||||
|
ReportingModeVerbose
|
||||||
|
// ReportingModeDebug enables debug reporting.
|
||||||
|
ReportingModeDebug
|
||||||
)
|
)
|
||||||
|
|
||||||
type reportingDestination uint8
|
type reportingDestination uint8
|
||||||
@@ -40,7 +46,8 @@ type reportingState struct {
|
|||||||
output destinationState
|
output destinationState
|
||||||
diagnostics destinationState
|
diagnostics destinationState
|
||||||
closed bool
|
closed bool
|
||||||
mode reportingMode
|
deferredClosed bool
|
||||||
|
mode ReportingMode
|
||||||
format Format
|
format Format
|
||||||
progress bool
|
progress bool
|
||||||
progressSet bool
|
progressSet bool
|
||||||
@@ -78,8 +85,8 @@ type Reporter struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// DeferredReporter queues messages for delivery during invocation finalization.
|
// DeferredReporter queues messages for delivery during invocation finalization.
|
||||||
// Copies share the same invocation reporting state. Submissions made after
|
// Copies share the same invocation reporting state. Submissions return
|
||||||
// finalization return ErrReporterClosed.
|
// ErrReporterClosed once finalization begins, before the accepted batch is delivered.
|
||||||
type DeferredReporter struct {
|
type DeferredReporter struct {
|
||||||
state *reportingState
|
state *reportingState
|
||||||
}
|
}
|
||||||
@@ -108,7 +115,7 @@ func newReporter(streams Streams) *Reporter {
|
|||||||
return &Reporter{state: &reportingState{
|
return &Reporter{state: &reportingState{
|
||||||
output: destinationState{writer: streams.Out},
|
output: destinationState{writer: streams.Out},
|
||||||
diagnostics: destinationState{writer: streams.Err},
|
diagnostics: destinationState{writer: streams.Err},
|
||||||
mode: modeOrdinary,
|
mode: ReportingModeOrdinary,
|
||||||
}}
|
}}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,13 +131,13 @@ func (r *Reporter) applySelection(selection commandmeta.ReportingSelection) {
|
|||||||
}
|
}
|
||||||
switch selection.Mode {
|
switch selection.Mode {
|
||||||
case commandmeta.ReportingModeQuiet:
|
case commandmeta.ReportingModeQuiet:
|
||||||
state.mode = modeQuiet
|
state.mode = ReportingModeQuiet
|
||||||
case commandmeta.ReportingModeVerbose:
|
case commandmeta.ReportingModeVerbose:
|
||||||
state.mode = modeVerbose
|
state.mode = ReportingModeVerbose
|
||||||
case commandmeta.ReportingModeDebug:
|
case commandmeta.ReportingModeDebug:
|
||||||
state.mode = modeDebug
|
state.mode = ReportingModeDebug
|
||||||
default:
|
default:
|
||||||
state.mode = modeOrdinary
|
state.mode = ReportingModeOrdinary
|
||||||
}
|
}
|
||||||
state.progress = selection.Progress
|
state.progress = selection.Progress
|
||||||
state.progressSet = selection.ProgressSet
|
state.progressSet = selection.ProgressSet
|
||||||
@@ -188,7 +195,7 @@ func (r *Reporter) RenderOutput(write func(io.Writer) error) error {
|
|||||||
destination.submitted = true
|
destination.submitted = true
|
||||||
writer := &coordinatedWriter{state: state, destination: destinationOutput}
|
writer := &coordinatedWriter{state: state, destination: destinationOutput}
|
||||||
renderErr := write(writer)
|
renderErr := write(writer)
|
||||||
if state.mode == modeQuiet {
|
if state.mode == ReportingModeQuiet {
|
||||||
destination.suppressed = true
|
destination.suppressed = true
|
||||||
} else if renderErr == nil && writer.writeError == nil {
|
} else if renderErr == nil && writer.writeError == nil {
|
||||||
destination.delivered = true
|
destination.delivered = true
|
||||||
@@ -222,7 +229,8 @@ func (r *Reporter) Progress(message string, details ...Detail) error {
|
|||||||
|
|
||||||
// AtEnd returns a view for messages delivered after the handler returns and
|
// AtEnd returns a view for messages delivered after the handler returns and
|
||||||
// before terminal explanation delivery. The deferred view shares this
|
// before terminal explanation delivery. The deferred view shares this
|
||||||
// invocation's state.
|
// invocation's state. Acceptance ends when finalization begins, before queued
|
||||||
|
// messages are delivered.
|
||||||
func (r *Reporter) AtEnd() DeferredReporter {
|
func (r *Reporter) AtEnd() DeferredReporter {
|
||||||
if r == nil {
|
if r == nil {
|
||||||
return DeferredReporter{}
|
return DeferredReporter{}
|
||||||
@@ -258,7 +266,7 @@ func (r DeferredReporter) enqueue(category messageCategory, message string, deta
|
|||||||
state := r.state
|
state := r.state
|
||||||
state.mu.Lock()
|
state.mu.Lock()
|
||||||
defer state.mu.Unlock()
|
defer state.mu.Unlock()
|
||||||
if state.closed {
|
if state.closed || state.deferredClosed {
|
||||||
return ErrReporterClosed
|
return ErrReporterClosed
|
||||||
}
|
}
|
||||||
if !hasDetails(details) {
|
if !hasDetails(details) {
|
||||||
@@ -290,6 +298,7 @@ func (r *Reporter) finalize(outcome Outcome, completion func(*Reporter)) Outcome
|
|||||||
state.mu.Unlock()
|
state.mu.Unlock()
|
||||||
return normalizeOutcome(outcome)
|
return normalizeOutcome(outcome)
|
||||||
}
|
}
|
||||||
|
state.deferredClosed = true
|
||||||
deferred := state.deferred
|
deferred := state.deferred
|
||||||
state.deferred = nil
|
state.deferred = nil
|
||||||
state.mu.Unlock()
|
state.mu.Unlock()
|
||||||
@@ -324,6 +333,8 @@ func (r *Reporter) finalize(outcome Outcome, completion func(*Reporter)) Outcome
|
|||||||
state.closed = true
|
state.closed = true
|
||||||
state.deferred = nil
|
state.deferred = nil
|
||||||
outcome.Presentation = state.presentationLocked()
|
outcome.Presentation = state.presentationLocked()
|
||||||
|
state.output.writer = nil
|
||||||
|
state.diagnostics.writer = nil
|
||||||
state.mu.Unlock()
|
state.mu.Unlock()
|
||||||
return normalizeOutcome(outcome)
|
return normalizeOutcome(outcome)
|
||||||
}
|
}
|
||||||
@@ -400,21 +411,7 @@ func renderTerminalLocked(state *reportingState, explanation Explanation) error
|
|||||||
writeErr = writer.writeError
|
writeErr = writer.writeError
|
||||||
}
|
}
|
||||||
if writeErr == nil {
|
if writeErr == nil {
|
||||||
for _, detail := range explanation.Details {
|
renderErr, writeErr = renderDetailsLocked(state, explanation.Details, true)
|
||||||
if detail == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
writer := &coordinatedWriter{state: state, destination: destinationDiagnostics}
|
|
||||||
detailErr := detail(writer)
|
|
||||||
detailWriteErr := writer.writeError
|
|
||||||
detailRenderErr := recordRendererErrorLocked(state, destinationDiagnostics, detailErr, detailWriteErr)
|
|
||||||
if renderErr == nil {
|
|
||||||
renderErr = detailRenderErr
|
|
||||||
}
|
|
||||||
if writeErr == nil {
|
|
||||||
writeErr = detailWriteErr
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if renderErr == nil && writeErr == nil {
|
if renderErr == nil && writeErr == nil {
|
||||||
destination.delivered = true
|
destination.delivered = true
|
||||||
@@ -464,6 +461,14 @@ func (state *reportingState) submitMessageLocked(category messageCategory, messa
|
|||||||
if visible && writeErr != nil {
|
if visible && writeErr != nil {
|
||||||
return writeErr
|
return writeErr
|
||||||
}
|
}
|
||||||
|
renderErr, writeErr = renderDetailsLocked(state, details, visible)
|
||||||
|
if visible && renderErr == nil && writeErr == nil {
|
||||||
|
destination.delivered = true
|
||||||
|
}
|
||||||
|
return combineOperationErrors(renderErr, writeErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderDetailsLocked(state *reportingState, details []Detail, visible bool) (renderErr, writeErr error) {
|
||||||
for _, detail := range details {
|
for _, detail := range details {
|
||||||
if detail == nil {
|
if detail == nil {
|
||||||
continue
|
continue
|
||||||
@@ -484,26 +489,23 @@ func (state *reportingState) submitMessageLocked(category messageCategory, messa
|
|||||||
writeErr = detailWriteErr
|
writeErr = detailWriteErr
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if visible && renderErr == nil && writeErr == nil {
|
return renderErr, writeErr
|
||||||
destination.delivered = true
|
|
||||||
}
|
|
||||||
return combineOperationErrors(renderErr, writeErr)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (state *reportingState) visible(category messageCategory) bool {
|
func (state *reportingState) visible(category messageCategory) bool {
|
||||||
switch category {
|
switch category {
|
||||||
case categoryInfo, categoryWarning:
|
case categoryInfo, categoryWarning:
|
||||||
return state.mode == modeVerbose || state.mode == modeDebug
|
return state.mode == ReportingModeVerbose || state.mode == ReportingModeDebug
|
||||||
case categoryDebug:
|
case categoryDebug:
|
||||||
return state.mode == modeDebug
|
return state.mode == ReportingModeDebug
|
||||||
case categoryProgress:
|
case categoryProgress:
|
||||||
if state.mode == modeQuiet {
|
if state.mode == ReportingModeQuiet {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if state.progressSet {
|
if state.progressSet {
|
||||||
return state.progress
|
return state.progress
|
||||||
}
|
}
|
||||||
return state.mode == modeDebug
|
return state.mode == ReportingModeDebug
|
||||||
default:
|
default:
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -621,7 +623,7 @@ func (state *reportingState) destination(destination reportingDestination) *dest
|
|||||||
|
|
||||||
func (state *reportingState) suppressed(destination reportingDestination) bool {
|
func (state *reportingState) suppressed(destination reportingDestination) bool {
|
||||||
if destination == destinationOutput {
|
if destination == destinationOutput {
|
||||||
return state.mode == modeQuiet
|
return state.mode == ReportingModeQuiet
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -643,12 +645,6 @@ func (state *reportingState) presentationLocked() PresentationResult {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (state *reportingState) presentation() PresentationResult {
|
|
||||||
state.mu.Lock()
|
|
||||||
defer state.mu.Unlock()
|
|
||||||
return state.presentationLocked()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (state *reportingState) deliveryResultLocked(destination reportingDestination) DeliveryResult {
|
func (state *reportingState) deliveryResultLocked(destination reportingDestination) DeliveryResult {
|
||||||
current := state.destination(destination)
|
current := state.destination(destination)
|
||||||
result := DeliveryResult{RenderError: current.renderError, WriteError: current.writeError}
|
result := DeliveryResult{RenderError: current.renderError, WriteError: current.writeError}
|
||||||
@@ -664,23 +660,3 @@ func (state *reportingState) deliveryResultLocked(destination reportingDestinati
|
|||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Reporter) setVisibility(mode reportingMode, progress bool, progressSet bool) {
|
|
||||||
if r == nil || r.state == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
r.state.mu.Lock()
|
|
||||||
r.state.mode = mode
|
|
||||||
r.state.progress = progress
|
|
||||||
r.state.progressSet = progressSet
|
|
||||||
r.state.mu.Unlock()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *Reporter) close() {
|
|
||||||
if r == nil || r.state == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
r.state.mu.Lock()
|
|
||||||
r.state.closed = true
|
|
||||||
r.state.mu.Unlock()
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -10,10 +10,10 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func TestHiddenSimpleDeferredMessagesDoNotAccumulate(t *testing.T) {
|
func TestHiddenSimpleDeferredMessagesDoNotAccumulate(t *testing.T) {
|
||||||
for _, mode := range []reportingMode{modeOrdinary, modeQuiet} {
|
for _, mode := range []ReportingMode{ReportingModeOrdinary, ReportingModeQuiet} {
|
||||||
t.Run(fmt.Sprint(mode), func(t *testing.T) {
|
t.Run(fmt.Sprint(mode), func(t *testing.T) {
|
||||||
reporter := newReporter(Streams{})
|
reporter := newReporter(Streams{})
|
||||||
reporter.setVisibility(mode, false, false)
|
setReporterVisibility(reporter, mode, false, false)
|
||||||
for index := 0; index < 256; index++ {
|
for index := 0; index < 256; index++ {
|
||||||
if err := reporter.AtEnd().Debug(fmt.Sprintf("hidden %d", index), nil); err != nil {
|
if err := reporter.AtEnd().Debug(fmt.Sprintf("hidden %d", index), nil); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -45,7 +45,7 @@ func TestHiddenSimpleDeferredMessagesDoNotAccumulate(t *testing.T) {
|
|||||||
func TestHiddenDeferredDetailsRemainDeferredAndCaptured(t *testing.T) {
|
func TestHiddenDeferredDetailsRemainDeferredAndCaptured(t *testing.T) {
|
||||||
var diagnostics bytes.Buffer
|
var diagnostics bytes.Buffer
|
||||||
reporter := newReporter(Streams{Err: &diagnostics})
|
reporter := newReporter(Streams{Err: &diagnostics})
|
||||||
reporter.setVisibility(modeQuiet, false, false)
|
setReporterVisibility(reporter, ReportingModeQuiet, false, false)
|
||||||
failure := errors.New("hidden rendering failed")
|
failure := errors.New("hidden rendering failed")
|
||||||
var order []string
|
var order []string
|
||||||
details := []Detail{func(w io.Writer) error {
|
details := []Detail{func(w io.Writer) error {
|
||||||
@@ -67,3 +67,57 @@ func TestHiddenDeferredDetailsRemainDeferredAndCaptured(t *testing.T) {
|
|||||||
t.Fatalf("hidden content leaked or queue retained: %q", diagnostics.String())
|
t.Fatalf("hidden content leaked or queue retained: %q", diagnostics.String())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type gatedDiagnosticWriter struct {
|
||||||
|
bytes.Buffer
|
||||||
|
entered chan struct{}
|
||||||
|
release chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *gatedDiagnosticWriter) Write(p []byte) (int, error) {
|
||||||
|
if w.entered != nil {
|
||||||
|
close(w.entered)
|
||||||
|
w.entered = nil
|
||||||
|
<-w.release
|
||||||
|
}
|
||||||
|
return w.Buffer.Write(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReporterDeferredAcceptanceEndsBeforeDelivery(t *testing.T) {
|
||||||
|
writer := &gatedDiagnosticWriter{entered: make(chan struct{}), release: make(chan struct{})}
|
||||||
|
entered := writer.entered
|
||||||
|
reporter := newReporter(Streams{Err: writer})
|
||||||
|
setReporterVisibility(reporter, ReportingModeVerbose, false, false)
|
||||||
|
deferred := reporter.AtEnd()
|
||||||
|
if err := deferred.Warn("accepted"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
completionEntered, finishCompletion := make(chan struct{}), make(chan struct{})
|
||||||
|
done := make(chan Outcome, 1)
|
||||||
|
go func() {
|
||||||
|
done <- reporter.finalize(Failure("terminal"), func(r *Reporter) {
|
||||||
|
close(completionEntered)
|
||||||
|
<-finishCompletion
|
||||||
|
_ = r.Info("completion")
|
||||||
|
})
|
||||||
|
}()
|
||||||
|
<-entered
|
||||||
|
late := make(chan error, 1)
|
||||||
|
go func() { late <- deferred.Warn("late") }()
|
||||||
|
close(writer.release)
|
||||||
|
<-completionEntered
|
||||||
|
if err := <-late; !errors.Is(err, ErrReporterClosed) {
|
||||||
|
t.Errorf("late submission = %v", err)
|
||||||
|
}
|
||||||
|
if err := deferred.Debug(""); !errors.Is(err, ErrReporterClosed) {
|
||||||
|
t.Errorf("empty hidden submission = %v", err)
|
||||||
|
}
|
||||||
|
close(finishCompletion)
|
||||||
|
outcome := <-done
|
||||||
|
if got := writer.String(); got != "WARN: accepted\ncompletion\nERROR: terminal\n" {
|
||||||
|
t.Errorf("diagnostics = %q", got)
|
||||||
|
}
|
||||||
|
if outcome.Presentation.Diagnostics.State != DeliveryComplete {
|
||||||
|
t.Fatalf("outcome = %#v", outcome)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ func TestReporterExplainsItsOwnFailuresWithoutExposingCauses(t *testing.T) {
|
|||||||
var diagnostics bytes.Buffer
|
var diagnostics bytes.Buffer
|
||||||
reporter := newReporter(Streams{Err: &diagnostics})
|
reporter := newReporter(Streams{Err: &diagnostics})
|
||||||
if quiet {
|
if quiet {
|
||||||
reporter.setVisibility(modeQuiet, false, false)
|
setReporterVisibility(reporter, ReportingModeQuiet, false, false)
|
||||||
}
|
}
|
||||||
failure := errors.New("private rendering detail")
|
failure := errors.New("private rendering detail")
|
||||||
render := func(io.Writer) error { return failure }
|
render := func(io.Writer) error { return failure }
|
||||||
@@ -84,7 +84,7 @@ func TestReporterPreservesJoinedRendererEvidence(t *testing.T) {
|
|||||||
writeErr := errors.Join(errors.New("sink failure"), errors.New("sink detail"))
|
writeErr := errors.Join(errors.New("sink failure"), errors.New("sink detail"))
|
||||||
destination := &reporterTestWriter{err: writeErr}
|
destination := &reporterTestWriter{err: writeErr}
|
||||||
reporter := newReporter(Streams{Out: destination, Err: destination})
|
reporter := newReporter(Streams{Out: destination, Err: destination})
|
||||||
reporter.setVisibility(modeVerbose, false, false)
|
setReporterVisibility(reporter, ReportingModeVerbose, false, false)
|
||||||
render := func(w io.Writer) error {
|
render := func(w io.Writer) error {
|
||||||
_, err := io.WriteString(w, "report")
|
_, err := io.WriteString(w, "report")
|
||||||
return test.combine(renderErr, err)
|
return test.combine(renderErr, err)
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import (
|
|||||||
func TestReporterReportQuietAndClosed(t *testing.T) {
|
func TestReporterReportQuietAndClosed(t *testing.T) {
|
||||||
var out bytes.Buffer
|
var out bytes.Buffer
|
||||||
reporter := newReporter(Streams{Out: &out})
|
reporter := newReporter(Streams{Out: &out})
|
||||||
reporter.state.mode = modeQuiet
|
reporter.state.mode = ReportingModeQuiet
|
||||||
err := reporter.Report(Report{Value: make(chan int)})
|
err := reporter.Report(Report{Value: make(chan int)})
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("expected serialization error")
|
t.Fatal("expected serialization error")
|
||||||
|
|||||||
108
reporter_test.go
108
reporter_test.go
@@ -2,10 +2,13 @@ package commandkit
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
commandmeta "go.maximumdirect.net/commandkit/internal/commandmeta"
|
||||||
)
|
)
|
||||||
|
|
||||||
type reporterTestWriter struct {
|
type reporterTestWriter struct {
|
||||||
@@ -77,7 +80,7 @@ func TestReporterLeavesCallerWriterOpen(t *testing.T) {
|
|||||||
func TestReporterQuietOutputDiscardsSuccessfully(t *testing.T) {
|
func TestReporterQuietOutputDiscardsSuccessfully(t *testing.T) {
|
||||||
var destination bytes.Buffer
|
var destination bytes.Buffer
|
||||||
reporter := newReporter(Streams{Out: &destination})
|
reporter := newReporter(Streams{Out: &destination})
|
||||||
reporter.setVisibility(modeQuiet, false, false)
|
setReporterVisibility(reporter, ReportingModeQuiet, false, false)
|
||||||
writer := reporter.Output()
|
writer := reporter.Output()
|
||||||
|
|
||||||
if n, err := writer.Write([]byte("hidden")); err != nil || n != len("hidden") {
|
if n, err := writer.Write([]byte("hidden")); err != nil || n != len("hidden") {
|
||||||
@@ -174,8 +177,8 @@ func TestReporterClosedHandlesReturnClosedErrorIncludingQuiet(t *testing.T) {
|
|||||||
var destination bytes.Buffer
|
var destination bytes.Buffer
|
||||||
reporter := newReporter(Streams{Out: &destination})
|
reporter := newReporter(Streams{Out: &destination})
|
||||||
writer := reporter.Output()
|
writer := reporter.Output()
|
||||||
reporter.setVisibility(modeQuiet, false, false)
|
setReporterVisibility(reporter, ReportingModeQuiet, false, false)
|
||||||
reporter.close()
|
_ = reporter.finalize(Success(), nil)
|
||||||
|
|
||||||
if n, err := writer.Write([]byte("late")); n != 0 || !errors.Is(err, ErrReporterClosed) {
|
if n, err := writer.Write([]byte("late")); n != 0 || !errors.Is(err, ErrReporterClosed) {
|
||||||
t.Fatalf("late Write() = %d, %v", n, err)
|
t.Fatalf("late Write() = %d, %v", n, err)
|
||||||
@@ -190,11 +193,11 @@ func TestNewReporterNormalizesNilDestinationsAndStartsOrdinary(t *testing.T) {
|
|||||||
if reporter.state.output.writer != io.Discard || reporter.state.diagnostics.writer != io.Discard {
|
if reporter.state.output.writer != io.Discard || reporter.state.diagnostics.writer != io.Discard {
|
||||||
t.Fatalf("nil destinations were not normalized: %#v", reporter.state)
|
t.Fatalf("nil destinations were not normalized: %#v", reporter.state)
|
||||||
}
|
}
|
||||||
if reporter.state.mode != modeOrdinary || reporter.state.progress || reporter.state.progressSet {
|
if reporter.state.mode != ReportingModeOrdinary || reporter.state.progress || reporter.state.progressSet {
|
||||||
t.Fatalf("initial visibility = mode %v, progress %v (set %v)", reporter.state.mode, reporter.state.progress, reporter.state.progressSet)
|
t.Fatalf("initial visibility = mode %v, progress %v (set %v)", reporter.state.mode, reporter.state.progress, reporter.state.progressSet)
|
||||||
}
|
}
|
||||||
reporter.setVisibility(modeDebug, true, true)
|
setReporterVisibility(reporter, ReportingModeDebug, true, true)
|
||||||
if reporter.state.mode != modeDebug || !reporter.state.progress || !reporter.state.progressSet {
|
if reporter.state.mode != ReportingModeDebug || !reporter.state.progress || !reporter.state.progressSet {
|
||||||
t.Fatalf("visibility was not retained: mode %v, progress %v (set %v)", reporter.state.mode, reporter.state.progress, reporter.state.progressSet)
|
t.Fatalf("visibility was not retained: mode %v, progress %v (set %v)", reporter.state.mode, reporter.state.progress, reporter.state.progressSet)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -202,24 +205,24 @@ func TestNewReporterNormalizesNilDestinationsAndStartsOrdinary(t *testing.T) {
|
|||||||
func TestReporterMessageVisibilityAndProgressPrecedence(t *testing.T) {
|
func TestReporterMessageVisibilityAndProgressPrecedence(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
mode reportingMode
|
mode ReportingMode
|
||||||
progress bool
|
progress bool
|
||||||
progressSet bool
|
progressSet bool
|
||||||
want string
|
want string
|
||||||
}{
|
}{
|
||||||
{name: "ordinary", mode: modeOrdinary},
|
{name: "ordinary", mode: ReportingModeOrdinary},
|
||||||
{name: "verbose", mode: modeVerbose, want: "not used\nWARN: not used\n"},
|
{name: "verbose", mode: ReportingModeVerbose, want: "not used\nWARN: not used\n"},
|
||||||
{name: "debug", mode: modeDebug, want: "not used\nWARN: not used\nDEBUG: DEBUG\nPROGRESS\n"},
|
{name: "debug", mode: ReportingModeDebug, want: "not used\nWARN: not used\nDEBUG: DEBUG\nPROGRESS\n"},
|
||||||
{name: "quiet overrides progress", mode: modeQuiet, progress: true, progressSet: true},
|
{name: "quiet overrides progress", mode: ReportingModeQuiet, progress: true, progressSet: true},
|
||||||
{name: "explicit ordinary progress", mode: modeOrdinary, progress: true, progressSet: true, want: "PROGRESS\n"},
|
{name: "explicit ordinary progress", mode: ReportingModeOrdinary, progress: true, progressSet: true, want: "PROGRESS\n"},
|
||||||
{name: "explicit debug disable", mode: modeDebug, progressSet: true, want: "not used\nWARN: not used\nDEBUG: DEBUG\n"},
|
{name: "explicit debug disable", mode: ReportingModeDebug, progressSet: true, want: "not used\nWARN: not used\nDEBUG: DEBUG\n"},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, test := range tests {
|
for _, test := range tests {
|
||||||
t.Run(test.name, func(t *testing.T) {
|
t.Run(test.name, func(t *testing.T) {
|
||||||
var output bytes.Buffer
|
var output bytes.Buffer
|
||||||
reporter := newReporter(Streams{Err: &output})
|
reporter := newReporter(Streams{Err: &output})
|
||||||
reporter.setVisibility(test.mode, test.progress, test.progressSet)
|
setReporterVisibility(reporter, test.mode, test.progress, test.progressSet)
|
||||||
_ = reporter.Info("not used")
|
_ = reporter.Info("not used")
|
||||||
_ = reporter.Warn("not used")
|
_ = reporter.Warn("not used")
|
||||||
_ = reporter.Debug("DEBUG")
|
_ = reporter.Debug("DEBUG")
|
||||||
@@ -234,7 +237,7 @@ func TestReporterMessageVisibilityAndProgressPrecedence(t *testing.T) {
|
|||||||
func TestReporterFormatsMessagesAndPreservesDetailOrder(t *testing.T) {
|
func TestReporterFormatsMessagesAndPreservesDetailOrder(t *testing.T) {
|
||||||
var diagnostics bytes.Buffer
|
var diagnostics bytes.Buffer
|
||||||
reporter := newReporter(Streams{Err: &diagnostics})
|
reporter := newReporter(Streams{Err: &diagnostics})
|
||||||
reporter.setVisibility(modeVerbose, false, false)
|
setReporterVisibility(reporter, ReportingModeVerbose, false, false)
|
||||||
if err := reporter.Warn("Native warning\nwith second line\n", nil,
|
if err := reporter.Warn("Native warning\nwith second line\n", nil,
|
||||||
func(w io.Writer) error { _, err := io.WriteString(w, "detail one\n"); return err },
|
func(w io.Writer) error { _, err := io.WriteString(w, "detail one\n"); return err },
|
||||||
func(w io.Writer) error { _, err := io.WriteString(w, "detail two\n"); return err }); err != nil {
|
func(w io.Writer) error { _, err := io.WriteString(w, "detail two\n"); return err }); err != nil {
|
||||||
@@ -255,7 +258,7 @@ func TestReporterFormatsMessagesAndPreservesDetailOrder(t *testing.T) {
|
|||||||
func TestReporterHiddenRenderersRunAgainstDiscardAndRetainErrors(t *testing.T) {
|
func TestReporterHiddenRenderersRunAgainstDiscardAndRetainErrors(t *testing.T) {
|
||||||
var diagnostics bytes.Buffer
|
var diagnostics bytes.Buffer
|
||||||
reporter := newReporter(Streams{Err: &diagnostics})
|
reporter := newReporter(Streams{Err: &diagnostics})
|
||||||
reporter.setVisibility(modeQuiet, false, false)
|
setReporterVisibility(reporter, ReportingModeQuiet, false, false)
|
||||||
renderErr := errors.New("native renderer failed")
|
renderErr := errors.New("native renderer failed")
|
||||||
calls := 0
|
calls := 0
|
||||||
err := reporter.Warn("hidden warning", func(w io.Writer) error {
|
err := reporter.Warn("hidden warning", func(w io.Writer) error {
|
||||||
@@ -281,7 +284,7 @@ func TestReporterRecordsIgnoredRenderErrors(t *testing.T) {
|
|||||||
renderErr := errors.New("output renderer failed")
|
renderErr := errors.New("output renderer failed")
|
||||||
diagnosticErr := errors.New("diagnostic renderer failed")
|
diagnosticErr := errors.New("diagnostic renderer failed")
|
||||||
reporter := newReporter(Streams{Out: io.Discard, Err: io.Discard})
|
reporter := newReporter(Streams{Out: io.Discard, Err: io.Discard})
|
||||||
reporter.setVisibility(modeVerbose, false, false)
|
setReporterVisibility(reporter, ReportingModeVerbose, false, false)
|
||||||
_ = reporter.RenderOutput(func(io.Writer) error { return renderErr })
|
_ = reporter.RenderOutput(func(io.Writer) error { return renderErr })
|
||||||
_ = reporter.Warn("warning", func(io.Writer) error { return diagnosticErr })
|
_ = reporter.Warn("warning", func(io.Writer) error { return diagnosticErr })
|
||||||
|
|
||||||
@@ -294,7 +297,7 @@ func TestReporterRecordsIgnoredRenderErrors(t *testing.T) {
|
|||||||
func TestReporterRenderOutputRunsInQuietAndRetainsFailure(t *testing.T) {
|
func TestReporterRenderOutputRunsInQuietAndRetainsFailure(t *testing.T) {
|
||||||
destination := &reporterTestWriter{}
|
destination := &reporterTestWriter{}
|
||||||
reporter := newReporter(Streams{Out: destination})
|
reporter := newReporter(Streams{Out: destination})
|
||||||
reporter.setVisibility(modeQuiet, false, false)
|
setReporterVisibility(reporter, ReportingModeQuiet, false, false)
|
||||||
renderErr := errors.New("quiet encoder failed")
|
renderErr := errors.New("quiet encoder failed")
|
||||||
calls := 0
|
calls := 0
|
||||||
_ = reporter.RenderOutput(func(w io.Writer) error {
|
_ = reporter.RenderOutput(func(w io.Writer) error {
|
||||||
@@ -318,7 +321,7 @@ func TestReporterCountsSuccessfulZeroByteRenderAsSubmitted(t *testing.T) {
|
|||||||
if err := reporter.RenderOutput(func(io.Writer) error { return nil }); err != nil {
|
if err := reporter.RenderOutput(func(io.Writer) error { return nil }); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
reporter.setVisibility(modeVerbose, false, false)
|
setReporterVisibility(reporter, ReportingModeVerbose, false, false)
|
||||||
if err := reporter.Warn("", func(io.Writer) error { return nil }); err != nil {
|
if err := reporter.Warn("", func(io.Writer) error { return nil }); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -406,7 +409,7 @@ func TestReporterConcurrentOperationsRemainWhole(t *testing.T) {
|
|||||||
t.Run("render callback and message", func(t *testing.T) {
|
t.Run("render callback and message", func(t *testing.T) {
|
||||||
var output bytes.Buffer
|
var output bytes.Buffer
|
||||||
reporter := newReporter(Streams{Out: &output, Err: &output})
|
reporter := newReporter(Streams{Out: &output, Err: &output})
|
||||||
reporter.setVisibility(modeVerbose, false, false)
|
setReporterVisibility(reporter, ReportingModeVerbose, false, false)
|
||||||
start := make(chan struct{})
|
start := make(chan struct{})
|
||||||
ready := make(chan struct{}, 2)
|
ready := make(chan struct{}, 2)
|
||||||
done := make(chan struct{}, 2)
|
done := make(chan struct{}, 2)
|
||||||
@@ -440,7 +443,7 @@ func TestReporterConcurrentOperationsRemainWhole(t *testing.T) {
|
|||||||
t.Run("message details and render callback", func(t *testing.T) {
|
t.Run("message details and render callback", func(t *testing.T) {
|
||||||
var output bytes.Buffer
|
var output bytes.Buffer
|
||||||
reporter := newReporter(Streams{Out: &output, Err: &output})
|
reporter := newReporter(Streams{Out: &output, Err: &output})
|
||||||
reporter.setVisibility(modeVerbose, false, false)
|
setReporterVisibility(reporter, ReportingModeVerbose, false, false)
|
||||||
start := make(chan struct{})
|
start := make(chan struct{})
|
||||||
ready := make(chan struct{}, 2)
|
ready := make(chan struct{}, 2)
|
||||||
done := make(chan struct{}, 2)
|
done := make(chan struct{}, 2)
|
||||||
@@ -478,7 +481,7 @@ func TestReporterConcurrentOperationsRemainWhole(t *testing.T) {
|
|||||||
func TestReporterEmptyHeadingDoesNotAddPrefixOrBlankLine(t *testing.T) {
|
func TestReporterEmptyHeadingDoesNotAddPrefixOrBlankLine(t *testing.T) {
|
||||||
var diagnostics bytes.Buffer
|
var diagnostics bytes.Buffer
|
||||||
reporter := newReporter(Streams{Err: &diagnostics})
|
reporter := newReporter(Streams{Err: &diagnostics})
|
||||||
reporter.setVisibility(modeVerbose, false, false)
|
setReporterVisibility(reporter, ReportingModeVerbose, false, false)
|
||||||
if err := reporter.Warn("", func(w io.Writer) error {
|
if err := reporter.Warn("", func(w io.Writer) error {
|
||||||
_, err := io.WriteString(w, "native multiline\nsecond line\n")
|
_, err := io.WriteString(w, "native multiline\nsecond line\n")
|
||||||
return err
|
return err
|
||||||
@@ -496,7 +499,7 @@ func TestReporterEmptyHeadingDoesNotAddPrefixOrBlankLine(t *testing.T) {
|
|||||||
func TestReporterFinalizesDeferredMessagesBeforeCompletionAndExplanation(t *testing.T) {
|
func TestReporterFinalizesDeferredMessagesBeforeCompletionAndExplanation(t *testing.T) {
|
||||||
var output, diagnostics bytes.Buffer
|
var output, diagnostics bytes.Buffer
|
||||||
reporter := newReporter(Streams{Out: &output, Err: &diagnostics})
|
reporter := newReporter(Streams{Out: &output, Err: &diagnostics})
|
||||||
reporter.setVisibility(modeDebug, false, false)
|
setReporterVisibility(reporter, ReportingModeDebug, false, false)
|
||||||
if err := reporter.Info("started"); err != nil {
|
if err := reporter.Info("started"); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -522,7 +525,7 @@ func TestReporterFinalizesDeferredMessagesBeforeCompletionAndExplanation(t *test
|
|||||||
func TestReporterDeferredSubmissionCopiesDetailsAndRetainsDeferredFailures(t *testing.T) {
|
func TestReporterDeferredSubmissionCopiesDetailsAndRetainsDeferredFailures(t *testing.T) {
|
||||||
var diagnostics bytes.Buffer
|
var diagnostics bytes.Buffer
|
||||||
reporter := newReporter(Streams{Err: &diagnostics})
|
reporter := newReporter(Streams{Err: &diagnostics})
|
||||||
reporter.setVisibility(modeVerbose, false, false)
|
setReporterVisibility(reporter, ReportingModeVerbose, false, false)
|
||||||
writeFirst := Detail(func(w io.Writer) error { _, err := io.WriteString(w, "first detail\n"); return err })
|
writeFirst := Detail(func(w io.Writer) error { _, err := io.WriteString(w, "first detail\n"); return err })
|
||||||
writeSecond := Detail(func(w io.Writer) error { _, err := io.WriteString(w, "second detail\n"); return err })
|
writeSecond := Detail(func(w io.Writer) error { _, err := io.WriteString(w, "second detail\n"); return err })
|
||||||
details := []Detail{writeFirst}
|
details := []Detail{writeFirst}
|
||||||
@@ -542,7 +545,7 @@ func TestReporterDeferredSubmissionCopiesDetailsAndRetainsDeferredFailures(t *te
|
|||||||
|
|
||||||
renderErr := errors.New("deferred renderer failed")
|
renderErr := errors.New("deferred renderer failed")
|
||||||
failedReporter := newReporter(Streams{Err: io.Discard})
|
failedReporter := newReporter(Streams{Err: io.Discard})
|
||||||
failedReporter.setVisibility(modeQuiet, false, false)
|
setReporterVisibility(failedReporter, ReportingModeQuiet, false, false)
|
||||||
if err := failedReporter.AtEnd().Warn("hidden", func(io.Writer) error { return renderErr }); err != nil {
|
if err := failedReporter.AtEnd().Warn("hidden", func(io.Writer) error { return renderErr }); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -695,3 +698,58 @@ func TestReporterFinalizationClosesOutputAndDeferredHandles(t *testing.T) {
|
|||||||
t.Fatalf("late output emitted: result=%q diagnostics=%q", output.String(), diagnostics.String())
|
t.Fatalf("late output emitted: result=%q diagnostics=%q", output.String(), diagnostics.String())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func setReporterVisibility(reporter *Reporter, mode ReportingMode, progress, progressSet bool) {
|
||||||
|
selection := commandmeta.ReportingSelection{Progress: progress, ProgressSet: progressSet}
|
||||||
|
switch mode {
|
||||||
|
case ReportingModeQuiet:
|
||||||
|
selection.Mode = commandmeta.ReportingModeQuiet
|
||||||
|
case ReportingModeVerbose:
|
||||||
|
selection.Mode = commandmeta.ReportingModeVerbose
|
||||||
|
case ReportingModeDebug:
|
||||||
|
selection.Mode = commandmeta.ReportingModeDebug
|
||||||
|
}
|
||||||
|
reporter.applySelection(selection)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReporterFinalizationReleasesBorrowedDestinations(t *testing.T) {
|
||||||
|
cause := errors.New("operation failed")
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
outcome Outcome
|
||||||
|
cause error
|
||||||
|
}{
|
||||||
|
{name: "success", outcome: Success()},
|
||||||
|
{name: "failure", outcome: Failure("failed").WithCause(cause), cause: cause},
|
||||||
|
{name: "cancellation", outcome: Failure("canceled").WithCause(context.Canceled), cause: context.Canceled},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
out, diagnostics := &callerOwnedWriter{}, &callerOwnedWriter{}
|
||||||
|
reporter := newReporter(Streams{Out: out, Err: diagnostics})
|
||||||
|
output, deferred := reporter.Output(), reporter.AtEnd()
|
||||||
|
result := reporter.finalize(tc.outcome, nil)
|
||||||
|
if reporter.state.output.writer != nil || reporter.state.diagnostics.writer != nil || out.closeCalls != 0 || diagnostics.closeCalls != 0 || tc.cause != nil && !errors.Is(result.Cause, tc.cause) {
|
||||||
|
t.Fatalf("outcome=%#v state=%#v", result, reporter.state)
|
||||||
|
}
|
||||||
|
outLen, diagnosticLen := out.Len(), diagnostics.Len()
|
||||||
|
if _, err := output.Write([]byte("late")); !errors.Is(err, ErrReporterClosed) {
|
||||||
|
t.Errorf("output error=%v", err)
|
||||||
|
}
|
||||||
|
if err := reporter.Warn("late"); !errors.Is(err, ErrReporterClosed) {
|
||||||
|
t.Errorf("reporter error=%v", err)
|
||||||
|
}
|
||||||
|
if err := deferred.Warn("late"); !errors.Is(err, ErrReporterClosed) {
|
||||||
|
t.Errorf("deferred error=%v", err)
|
||||||
|
}
|
||||||
|
if out.Len() != outLen || diagnostics.Len() != diagnosticLen {
|
||||||
|
t.Fatal("late operation touched destinations")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (state *reportingState) presentation() PresentationResult {
|
||||||
|
state.mu.Lock()
|
||||||
|
defer state.mu.Unlock()
|
||||||
|
return state.presentationLocked()
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,10 +13,10 @@ import (
|
|||||||
|
|
||||||
const tool = "go.maximumdirect.net/releasekit/cmd/releasekit"
|
const tool = "go.maximumdirect.net/releasekit/cmd/releasekit"
|
||||||
|
|
||||||
var modules = []struct{ selector, dir, profile, product string }{
|
var modules = []struct{ selector, dir, profile string }{
|
||||||
{"core", ".", "releasekit.json", "CommandKit"},
|
{"core", ".", "releasekit.json"},
|
||||||
{"ckconfig", "integrations/ckconfig", "integrations/ckconfig/releasekit.json", "CommandKit ckconfig"},
|
{"ckconfig", "integrations/ckconfig", "integrations/ckconfig/releasekit.json"},
|
||||||
{"ckprompt", "integrations/ckprompt", "integrations/ckprompt/releasekit.json", "CommandKit ckprompt"},
|
{"ckprompt", "integrations/ckprompt", "integrations/ckprompt/releasekit.json"},
|
||||||
}
|
}
|
||||||
|
|
||||||
type fixture struct{ root, outside, log string }
|
type fixture struct{ root, outside, log string }
|
||||||
@@ -90,38 +90,28 @@ func lines(t *testing.T, path string) []string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestProfiles(t *testing.T) {
|
func TestProfiles(t *testing.T) {
|
||||||
f := newFixture(t)
|
|
||||||
for _, m := range modules {
|
for _, m := range modules {
|
||||||
t.Run(m.selector, func(t *testing.T) {
|
t.Run(m.selector, func(t *testing.T) {
|
||||||
var p struct {
|
var p struct {
|
||||||
Product string `json:"product"`
|
|
||||||
Module string `json:"module_dir"`
|
Module string `json:"module_dir"`
|
||||||
Remote string `json:"remote"`
|
|
||||||
URL string `json:"expected_push_url"`
|
URL string `json:"expected_push_url"`
|
||||||
Validation struct {
|
|
||||||
Shared []string `json:"shared_files"`
|
|
||||||
Format bool `json:"go_format"`
|
|
||||||
Markdown map[string]any `json:"markdown"`
|
|
||||||
Distribution map[string]bool `json:"module_distribution"`
|
|
||||||
Forbidden []string `json:"forbidden_files"`
|
|
||||||
} `json:"validation"`
|
|
||||||
Delivery map[string]string `json:"delivery"`
|
|
||||||
}
|
}
|
||||||
b, err := os.ReadFile(filepath.Join(f.root, m.profile))
|
b, err := os.ReadFile(filepath.Join("..", m.profile))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal(b, &p); err != nil {
|
if err := json.Unmarshal(b, &p); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if p.Product != m.product || p.Module != m.dir || p.Remote != "origin" || p.URL != "ssh://git@forge.maximumdirect.net:22222/base-layer/commandkit.git" {
|
// The profile must agree with the launcher's module selection.
|
||||||
t.Fatalf("profile identity: %+v", p)
|
if p.Module != m.dir {
|
||||||
|
t.Errorf("module_dir = %q, want %q", p.Module, m.dir)
|
||||||
}
|
}
|
||||||
if !reflect.DeepEqual(p.Validation.Shared, []string{"COPYRIGHT", "LICENSE", "docs/oss/attribution.md"}) || !p.Validation.Format || p.Validation.Markdown == nil || len(p.Validation.Markdown) != 0 || !reflect.DeepEqual(p.Validation.Distribution, map[string]bool{"workspace_files": true, "local_replacements": true}) || !reflect.DeepEqual(p.Validation.Forbidden, []string{".releasekit/gitea-token"}) {
|
// The release procedure selects this exact publication endpoint.
|
||||||
t.Fatalf("validation: %+v", p.Validation)
|
// An empty or different value would weaken or misdirect that guard.
|
||||||
}
|
const pushURL = "ssh://git@forge.maximumdirect.net:22222/base-layer/commandkit.git"
|
||||||
if !reflect.DeepEqual(p.Delivery, map[string]string{"forge": "gitea", "base_url": "https://forge.maximumdirect.net", "owner": "base-layer", "repository": "commandkit", "token_file": ".releasekit/gitea-token"}) {
|
if p.URL != pushURL {
|
||||||
t.Fatalf("delivery: %v", p.Delivery)
|
t.Errorf("expected_push_url = %q, want %q", p.URL, pushURL)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
205
shared_debug_external_test.go
Normal file
205
shared_debug_external_test.go
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
package commandkit_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
commandkit "go.maximumdirect.net/commandkit"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestInvocationReportingMode(t *testing.T) {
|
||||||
|
if got := (commandkit.Invocation{}).ReportingMode(); got != commandkit.ReportingModeOrdinary {
|
||||||
|
t.Fatalf("zero invocation mode = %v", got)
|
||||||
|
}
|
||||||
|
var retained commandkit.Invocation
|
||||||
|
var observed commandkit.ReportingMode
|
||||||
|
command := commandkit.NewCommand(commandkit.CommandSpec[struct{}]{Name: "run", Run: func(inv commandkit.Invocation, _ struct{}) commandkit.Outcome {
|
||||||
|
retained = inv
|
||||||
|
observed = inv.ReportingMode()
|
||||||
|
return commandkit.Success()
|
||||||
|
}})
|
||||||
|
app, err := commandkit.New(commandkit.Identity{Name: "example"}, command)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, tc := range []struct {
|
||||||
|
flag string
|
||||||
|
want commandkit.ReportingMode
|
||||||
|
}{{"", commandkit.ReportingModeOrdinary}, {"--quiet", commandkit.ReportingModeQuiet}, {"--verbose", commandkit.ReportingModeVerbose}, {"--debug", commandkit.ReportingModeDebug}, {"", commandkit.ReportingModeOrdinary}} {
|
||||||
|
args := []string{"run"}
|
||||||
|
if tc.flag != "" {
|
||||||
|
args = append(args, tc.flag)
|
||||||
|
}
|
||||||
|
result := app.Execute(context.Background(), args, commandkit.Streams{Out: io.Discard, Err: io.Discard})
|
||||||
|
if result.Kind != commandkit.OutcomeSuccess || observed != tc.want || retained.ReportingMode() != tc.want {
|
||||||
|
t.Fatalf("%q: outcome=%#v mode=%v", tc.flag, result, retained.ReportingMode())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInvocationReportingModeConcurrentReads(t *testing.T) {
|
||||||
|
command := commandkit.NewCommand(commandkit.CommandSpec[struct{}]{Name: "run", Run: func(inv commandkit.Invocation, _ struct{}) commandkit.Outcome {
|
||||||
|
start := make(chan struct{})
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for i := 0; i < 4; i++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
<-start
|
||||||
|
for j := 0; j < 100; j++ {
|
||||||
|
if inv.ReportingMode() != commandkit.ReportingModeDebug {
|
||||||
|
t.Error("mode changed")
|
||||||
|
}
|
||||||
|
_ = inv.Reporter().Debug("diagnostic")
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
close(start)
|
||||||
|
wg.Wait()
|
||||||
|
return commandkit.Success()
|
||||||
|
}})
|
||||||
|
app, err := commandkit.New(commandkit.Identity{Name: "example"}, command)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if outcome := app.Execute(context.Background(), []string{"run", "--debug"}, commandkit.Streams{Out: io.Discard, Err: io.Discard}); outcome.Kind != commandkit.OutcomeSuccess {
|
||||||
|
t.Fatal(outcome)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSharedDebugBindingAndReportingMode(t *testing.T) {
|
||||||
|
type input struct {
|
||||||
|
debug commandkit.Supplied[bool]
|
||||||
|
before, after commandkit.Supplied[string]
|
||||||
|
operand commandkit.Supplied[string]
|
||||||
|
}
|
||||||
|
var received input
|
||||||
|
var mode commandkit.ReportingMode
|
||||||
|
factories, calls := 0, 0
|
||||||
|
parse := func(s string) (string, error) { return s, nil }
|
||||||
|
cmd := commandkit.NewCommand(commandkit.CommandSpec[input]{Name: "run", NewInput: func() input { factories++; return input{} }, Flags: []commandkit.Flag[input]{
|
||||||
|
commandkit.ValueFlag(commandkit.FlagMeta{Name: "before"}, (*string)(nil), parse, func(i *input, v commandkit.Supplied[string]) { i.before = v }),
|
||||||
|
commandkit.BoolFlag(commandkit.FlagMeta{Name: "debug", Short: "d"}, false, func(i *input, v commandkit.Supplied[bool]) { i.debug = v }),
|
||||||
|
commandkit.ValueFlag(commandkit.FlagMeta{Name: "after"}, (*string)(nil), parse, func(i *input, v commandkit.Supplied[string]) { i.after = v }),
|
||||||
|
}, Arguments: []commandkit.Argument[input]{commandkit.ValueArgument(commandkit.ArgumentMeta{Name: "operand"}, false, parse, func(i *input, v commandkit.Supplied[string]) { i.operand = v })}, Run: func(inv commandkit.Invocation, i input) commandkit.Outcome {
|
||||||
|
calls++
|
||||||
|
received = i
|
||||||
|
mode = inv.ReportingMode()
|
||||||
|
return commandkit.Success()
|
||||||
|
}})
|
||||||
|
app, err := commandkit.New(commandkit.Identity{Name: "example"}, cmd, commandkit.FlagAlias("-x", "--debug"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, tc := range []struct {
|
||||||
|
args []string
|
||||||
|
value, set bool
|
||||||
|
mode commandkit.ReportingMode
|
||||||
|
}{
|
||||||
|
{nil, false, false, commandkit.ReportingModeOrdinary}, {[]string{"--debug"}, true, true, commandkit.ReportingModeDebug}, {[]string{"--debug", "--quiet"}, true, true, commandkit.ReportingModeQuiet}, {[]string{"--quiet", "--debug"}, true, true, commandkit.ReportingModeDebug}, {[]string{"--debug", "--verbose"}, true, true, commandkit.ReportingModeVerbose}, {[]string{"--debug", "--debug=false"}, false, true, commandkit.ReportingModeOrdinary}, {[]string{"--verbose", "--debug", "--debug=false"}, false, true, commandkit.ReportingModeOrdinary}, {[]string{"--debug", "--quiet=false"}, true, true, commandkit.ReportingModeDebug}, {[]string{"--debug", "--progress=false"}, true, true, commandkit.ReportingModeDebug}, {[]string{"-x"}, true, true, commandkit.ReportingModeDebug}, {[]string{"-d=false"}, false, true, commandkit.ReportingModeOrdinary},
|
||||||
|
} {
|
||||||
|
args := append([]string{"run", "--before=one", "--after=two"}, tc.args...)
|
||||||
|
got := app.Execute(context.Background(), args, commandkit.Streams{Out: io.Discard, Err: io.Discard})
|
||||||
|
if got.Kind != commandkit.OutcomeSuccess || received.debug.Value != tc.value || received.debug.Set != tc.set || mode != tc.mode || received.before.Value != "one" || received.after.Value != "two" {
|
||||||
|
t.Fatalf("%v: result=%#v input=%#v mode=%v", tc.args, got, received, mode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, value := range []string{"--debug=", "--debug=invalid"} {
|
||||||
|
beforeFactories, beforeCalls := factories, calls
|
||||||
|
got := app.Execute(context.Background(), []string{"run", value}, commandkit.Streams{Out: io.Discard, Err: io.Discard})
|
||||||
|
if got.Kind != commandkit.OutcomeUsage || factories != beforeFactories || calls != beforeCalls {
|
||||||
|
t.Fatalf("%s result=%#v factories=%d calls=%d", value, got, factories, calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
got := app.Execute(context.Background(), []string{"run", "--before", "--debug"}, commandkit.Streams{Out: io.Discard, Err: io.Discard})
|
||||||
|
if got.Kind != commandkit.OutcomeSuccess || received.before.Value != "--debug" || received.debug.Set || mode != commandkit.ReportingModeOrdinary {
|
||||||
|
t.Fatalf("option-looking value: %#v %#v mode=%v", got, received, mode)
|
||||||
|
}
|
||||||
|
got = app.Execute(context.Background(), []string{"run", "--", "--debug"}, commandkit.Streams{Out: io.Discard, Err: io.Discard})
|
||||||
|
if got.Kind != commandkit.OutcomeSuccess || received.operand.Value != "--debug" || received.debug.Set || mode != commandkit.ReportingModeOrdinary {
|
||||||
|
t.Fatalf("operand after terminator: %#v %#v mode=%v", got, received, mode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSharedDebugCapturedDefaultsAndRequiredness(t *testing.T) {
|
||||||
|
type inner struct{ debug commandkit.Supplied[bool] }
|
||||||
|
type outer struct{ flags commandkit.FlagValues[inner] }
|
||||||
|
for _, required := range []bool{false, true} {
|
||||||
|
flag := commandkit.BoolFlag(commandkit.FlagMeta{Name: "debug", Short: "d", Required: required}, true, func(i *inner, v commandkit.Supplied[bool]) { i.debug = v })
|
||||||
|
captured := commandkit.CaptureFlags([]commandkit.Flag[inner]{flag}, func(i *outer) *commandkit.FlagValues[inner] { return &i.flags })
|
||||||
|
var applied, supplied inner
|
||||||
|
var applyErr error
|
||||||
|
var mode commandkit.ReportingMode
|
||||||
|
cmd := commandkit.NewCommand(commandkit.CommandSpec[outer]{Name: "run", Flags: captured, Run: func(inv commandkit.Invocation, i outer) commandkit.Outcome {
|
||||||
|
mode = inv.ReportingMode()
|
||||||
|
applied = inner{}
|
||||||
|
supplied = inner{debug: commandkit.Supplied[bool]{Value: true, Set: true}}
|
||||||
|
applyErr = i.flags.ApplyTo(&applied, flag)
|
||||||
|
if err := i.flags.ApplySuppliedTo(&supplied, flag); err != nil && applyErr == nil {
|
||||||
|
applyErr = err
|
||||||
|
}
|
||||||
|
return commandkit.Success()
|
||||||
|
}})
|
||||||
|
app, err := commandkit.New(commandkit.Identity{Name: "example"}, cmd, commandkit.FlagAliasFor("run", "-x", "-d"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
result := app.Execute(context.Background(), []string{"run"}, commandkit.Streams{})
|
||||||
|
if result.Kind != commandkit.OutcomeSuccess || mode != commandkit.ReportingModeOrdinary {
|
||||||
|
t.Fatalf("omission=%#v mode=%v", result, mode)
|
||||||
|
}
|
||||||
|
if required {
|
||||||
|
if !errors.Is(applyErr, commandkit.ErrFlagInput) {
|
||||||
|
t.Fatalf("missing required: %v", applyErr)
|
||||||
|
}
|
||||||
|
} else if applyErr != nil || !applied.debug.Value || applied.debug.Set || !supplied.debug.Value || !supplied.debug.Set {
|
||||||
|
t.Fatalf("defaults=%#v %#v %v", applied, supplied, applyErr)
|
||||||
|
}
|
||||||
|
result = app.Execute(context.Background(), []string{"run", "-x=false"}, commandkit.Streams{})
|
||||||
|
if result.Kind != commandkit.OutcomeSuccess || applyErr != nil || applied.debug.Value || !applied.debug.Set || supplied.debug.Value || !supplied.debug.Set || mode != commandkit.ReportingModeOrdinary {
|
||||||
|
t.Fatalf("false=%#v %#v %#v mode=%v err=%v", result, applied, supplied, mode, applyErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSharedDebugDirectRequiredAndRenamedBinding(t *testing.T) {
|
||||||
|
type input struct{ debug commandkit.Supplied[bool] }
|
||||||
|
var got input
|
||||||
|
var mode commandkit.ReportingMode
|
||||||
|
original := commandkit.BoolFlag(commandkit.FlagMeta{Name: "debug", Required: true}, true, func(i *input, v commandkit.Supplied[bool]) { i.debug = v })
|
||||||
|
required := commandkit.NewCommand(commandkit.CommandSpec[input]{Name: "required", Flags: []commandkit.Flag[input]{original}, Run: func(inv commandkit.Invocation, i input) commandkit.Outcome {
|
||||||
|
got = i
|
||||||
|
mode = inv.ReportingMode()
|
||||||
|
return commandkit.Success()
|
||||||
|
}})
|
||||||
|
renamed := commandkit.NewCommand(commandkit.CommandSpec[input]{Name: "renamed", Flags: []commandkit.Flag[input]{commandkit.RenameFlag(original, "trace")}, Run: func(inv commandkit.Invocation, i input) commandkit.Outcome {
|
||||||
|
got = i
|
||||||
|
mode = inv.ReportingMode()
|
||||||
|
return commandkit.Success()
|
||||||
|
}})
|
||||||
|
ordinary := commandkit.BoolFlag(commandkit.FlagMeta{Name: "local"}, false, func(i *input, v commandkit.Supplied[bool]) { i.debug = v })
|
||||||
|
attached := commandkit.NewCommand(commandkit.CommandSpec[input]{Name: "attached", Flags: []commandkit.Flag[input]{commandkit.RenameFlag(ordinary, "debug")}, Run: func(inv commandkit.Invocation, i input) commandkit.Outcome {
|
||||||
|
got = i
|
||||||
|
mode = inv.ReportingMode()
|
||||||
|
return commandkit.Success()
|
||||||
|
}})
|
||||||
|
app, err := commandkit.New(commandkit.Identity{Name: "example"}, required, renamed, attached)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if r := app.Execute(context.Background(), []string{"required"}, commandkit.Streams{}); r.Kind != commandkit.OutcomeUsage {
|
||||||
|
t.Fatalf("required omission=%#v", r)
|
||||||
|
}
|
||||||
|
if r := app.Execute(context.Background(), []string{"required", "--debug=false"}, commandkit.Streams{}); r.Kind != commandkit.OutcomeSuccess || got.debug.Value || !got.debug.Set || mode != commandkit.ReportingModeOrdinary {
|
||||||
|
t.Fatalf("explicit false=%#v %#v", r, got)
|
||||||
|
}
|
||||||
|
if r := app.Execute(context.Background(), []string{"renamed", "--trace=false", "--debug"}, commandkit.Streams{}); r.Kind != commandkit.OutcomeSuccess || got.debug.Value || !got.debug.Set || mode != commandkit.ReportingModeDebug {
|
||||||
|
t.Fatalf("renamed=%#v %#v mode=%v", r, got, mode)
|
||||||
|
}
|
||||||
|
if r := app.Execute(context.Background(), []string{"attached", "--debug"}, commandkit.Streams{}); r.Kind != commandkit.OutcomeSuccess || !got.debug.Value || !got.debug.Set || mode != commandkit.ReportingModeDebug {
|
||||||
|
t.Fatalf("renamed to debug=%#v %#v mode=%v", r, got, mode)
|
||||||
|
}
|
||||||
|
}
|
||||||
2
testdata/completion/main.go
vendored
2
testdata/completion/main.go
vendored
@@ -29,7 +29,7 @@ func main() {
|
|||||||
Name: "run", Summary: "Run a fixture command.",
|
Name: "run", Summary: "Run a fixture command.",
|
||||||
Flags: []commandkit.Flag[struct{}]{
|
Flags: []commandkit.Flag[struct{}]{
|
||||||
commandkit.BoolFlag(commandkit.FlagMeta{Name: "switch", Short: "s"}, false, func(*struct{}, commandkit.Supplied[bool]) {}),
|
commandkit.BoolFlag(commandkit.FlagMeta{Name: "switch", Short: "s"}, false, func(*struct{}, commandkit.Supplied[bool]) {}),
|
||||||
flag(commandkit.FlagMeta{Name: "format", Usage: "Report format.", Completion: commandkit.CompleteChoices(commandkit.CompletionChoice{Value: "text", Description: "Readable text"}, commandkit.CompletionChoice{Value: "yaml", Description: "YAML output"})}),
|
flag(commandkit.FlagMeta{Name: "choice", Usage: "Fixture choice.", Completion: commandkit.CompleteChoices(commandkit.CompletionChoice{Value: "text", Description: "Readable text"}, commandkit.CompletionChoice{Value: "yaml", Description: "YAML output"})}),
|
||||||
flag(commandkit.FlagMeta{Name: "config", Short: "c", Usage: "Configuration path.", Completion: commandkit.CompleteFiles()}),
|
flag(commandkit.FlagMeta{Name: "config", Short: "c", Usage: "Configuration path.", Completion: commandkit.CompleteFiles()}),
|
||||||
flag(commandkit.FlagMeta{Name: "directory", Short: "d", Usage: "Directory path.", Completion: commandkit.CompleteDirectories()}),
|
flag(commandkit.FlagMeta{Name: "directory", Short: "d", Usage: "Directory path.", Completion: commandkit.CompleteDirectories()}),
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user