Add configuration scope suite registration

This commit is contained in:
2026-10-07 13:44:46 +00:00
parent ecd2b19783
commit cc9b0eddab
9 changed files with 781 additions and 5 deletions

5
app.go
View File

@@ -4,8 +4,9 @@ import "fmt"
// App owns one command registry and its application identity. // App owns one command registry and its application identity.
type App struct { type App struct {
identity Identity identity Identity
commands map[string]*commandNode commands map[string]*commandNode
configRegistered bool
} }
type commandNode struct { type commandNode struct {

View File

@@ -18,6 +18,7 @@ func ValueFlag[I, V any](meta FlagMeta, defaultValue *V, parse func(string) (V,
return Flag[I]{binding: binding} return Flag[I]{binding: binding}
} }
binding.valid = true binding.valid = true
binding.clear = func(input *I) { set(input, Supplied[V]{}) }
binding.prepare = func(values []string) (func(*I), error) { binding.prepare = func(values []string) (func(*I), error) {
value := fallback value := fallback
supplied := len(values) > 0 supplied := len(values) > 0
@@ -42,6 +43,7 @@ func BoolFlag[I any](meta FlagMeta, defaultValue bool, set func(*I, Supplied[boo
return Flag[I]{binding: binding} return Flag[I]{binding: binding}
} }
binding.valid = true binding.valid = true
binding.clear = func(input *I) { set(input, Supplied[bool]{}) }
binding.prepare = func(values []string) (func(*I), error) { binding.prepare = func(values []string) (func(*I), error) {
value := defaultValue value := defaultValue
supplied := len(values) > 0 supplied := len(values) > 0
@@ -67,6 +69,7 @@ func RepeatFlag[I, V any](meta FlagMeta, defaults []V, parse func(string) (V, er
return Flag[I]{binding: binding} return Flag[I]{binding: binding}
} }
binding.valid = true binding.valid = true
binding.clear = func(input *I) { set(input, Supplied[[]V]{}) }
binding.prepare = func(values []string) (func(*I), error) { binding.prepare = func(values []string) (func(*I), error) {
parsedValues := append([]V(nil), fallback...) parsedValues := append([]V(nil), fallback...)
supplied := len(values) > 0 supplied := len(values) > 0

View File

@@ -57,6 +57,7 @@ type flagBinding[I any] struct {
repeated bool repeated bool
boolean bool boolean bool
prepare func([]string) (func(*I), error) prepare func([]string) (func(*I), error)
clear func(*I)
valid bool valid bool
} }

324
config.go Normal file
View File

@@ -0,0 +1,324 @@
package commandkit
import (
"context"
"fmt"
"io"
"sort"
"strings"
configkit "gitea.maximumdirect.net/eric/ConfigKit"
"go.yaml.in/yaml/v4"
)
// Selection identifies the application configuration inputs supplied to a
// configuration scope.
type Selection struct {
Config Supplied[string]
Overlay Supplied[string]
}
// ConfigOperation identifies a supported configuration inspection operation.
type ConfigOperation string
const (
ConfigValidate ConfigOperation = "validate"
ConfigShow ConfigOperation = "show"
ConfigSources ConfigOperation = "sources"
ConfigExplain ConfigOperation = "explain"
)
// ConfigRequest describes one configuration operation and its selected inputs.
type ConfigRequest struct {
Operation ConfigOperation
Selection Selection
}
// EffectiveWriter writes an owning library's native effective configuration.
type EffectiveWriter interface {
WriteEffective(io.Writer) error
}
// SourceReader reports the sources used to construct a configuration.
type SourceReader interface {
Sources() []configkit.SourceInfo
}
// ExplanationReader writes an owning library's path explanation.
type ExplanationReader interface {
Explain(configkit.Path) (configkit.Explanation, bool)
WriteExplanation(io.Writer, configkit.Path) error
}
// ConfigView contains optional views relevant to supported operations.
type ConfigView struct {
Effective EffectiveWriter
Sources SourceReader
Explanation ExplanationReader
}
// Acquired contains a configuration view and diagnostics from its acquisition.
type Acquired[T any] struct {
Value T
Diagnostics []Diagnostic
}
// ConfigScope declares one named configuration and its supported operations.
type ConfigScope[C any] struct {
Name string
Summary string
Operations []ConfigOperation
Acquire func(context.Context, ConfigRequest, C) (Acquired[ConfigView], error)
}
// Selector binds one typed command flag to selected configuration scopes.
type Selector[C any] struct {
Binding Flag[C]
Scopes []string
}
// ConfigSuite declares selectors and configuration scopes for an App.
type ConfigSuite[C any] struct {
NewContext func() C
Selectors []Selector[C]
CheckInput func(Selection, C) error
Scopes []ConfigScope[C]
}
type configScopeInfo struct {
Name string `yaml:"name"`
Operations []ConfigOperation `yaml:"operations"`
}
type scopesInventory struct {
Scopes []configScopeInfo `yaml:"scopes"`
}
type configScopesInput struct {
Format string
}
type selectorState[C any] struct {
scopes []string
supplied bool
apply func(*C)
clear func(*C)
}
type configInput[C any] struct {
Selection Selection
Scope string
Format string
SelectorContext C
selectors []selectorState[C]
}
// RegisterConfig installs the config family and its scope discovery command.
func RegisterConfig[C any](app *App, suite ConfigSuite[C]) error {
if app == nil {
return fmt.Errorf("cannot register configuration on a nil app")
}
if app.configRegistered {
return fmt.Errorf("configuration suite is already registered")
}
normalized, err := normalizeConfigSuite(suite)
if err != nil {
return err
}
if _, exists := app.commands["config"]; exists {
return fmt.Errorf("command path %q is already registered", "config")
}
command, err := newConfigScopesCommandWithScopes(normalized.Scopes, nil)
if err != nil {
return err
}
root, err := Group("config", "Inspect configuration.", command)
if err != nil {
return err
}
if err := app.Register(root); err != nil {
return err
}
app.configRegistered = true
return nil
}
func normalizeConfigSuite[C any](suite ConfigSuite[C]) (ConfigSuite[C], error) {
normalized := suite
normalized.Selectors = append([]Selector[C](nil), suite.Selectors...)
normalized.Scopes = make([]ConfigScope[C], 0, len(suite.Scopes))
scopeNames := make(map[string]struct{}, len(suite.Scopes))
for _, scope := range suite.Scopes {
if scope.Name == "" {
return ConfigSuite[C]{}, fmt.Errorf("configuration scope name must not be empty")
}
if scope.Name == "all" {
return ConfigSuite[C]{}, fmt.Errorf("configuration scope name %q is reserved", scope.Name)
}
if _, exists := scopeNames[scope.Name]; exists {
return ConfigSuite[C]{}, fmt.Errorf("duplicate configuration scope %q", scope.Name)
}
scopeNames[scope.Name] = struct{}{}
operations, err := normalizeConfigOperations(scope.Operations)
if err != nil {
return ConfigSuite[C]{}, fmt.Errorf("configuration scope %q: %w", scope.Name, err)
}
if len(operations) > 0 && scope.Acquire == nil {
return ConfigSuite[C]{}, fmt.Errorf("configuration scope %q declares operations without an acquisition callback", scope.Name)
}
scope.Operations = operations
normalized.Scopes = append(normalized.Scopes, scope)
}
for index := range normalized.Selectors {
selector := &normalized.Selectors[index]
binding := selector.Binding.binding
if !binding.valid || binding.clear == nil || binding.meta.Name == "" {
return ConfigSuite[C]{}, fmt.Errorf("configuration selector %d has an invalid flag binding", index)
}
if !validFlagName(binding.meta.Name) {
return ConfigSuite[C]{}, fmt.Errorf("configuration selector flag name %q is invalid", binding.meta.Name)
}
if binding.meta.Name == "config" || binding.meta.Name == "overlay" || binding.meta.Name == "scope" || binding.meta.Name == "format" || binding.meta.Name == "help" {
return ConfigSuite[C]{}, fmt.Errorf("configuration selector flag --%s conflicts with a standard flag", binding.meta.Name)
}
if binding.meta.Short == "h" {
return ConfigSuite[C]{}, fmt.Errorf("configuration selector short flag -%s conflicts with help", binding.meta.Short)
}
if binding.meta.Short != "" && (len(binding.meta.Short) != 1 || binding.meta.Short == "-") {
return ConfigSuite[C]{}, fmt.Errorf("configuration selector short spelling %q must be one byte", binding.meta.Short)
}
seen := make(map[string]struct{}, len(selector.Scopes))
unique := make([]string, 0, len(selector.Scopes))
for _, scopeName := range selector.Scopes {
if _, exists := scopeNames[scopeName]; !exists {
return ConfigSuite[C]{}, fmt.Errorf("selector --%s references unknown configuration scope %q", binding.meta.Name, scopeName)
}
if _, exists := seen[scopeName]; exists {
continue
}
seen[scopeName] = struct{}{}
unique = append(unique, scopeName)
}
selector.Scopes = append([]string(nil), unique...)
for previous := 0; previous < index; previous++ {
other := normalized.Selectors[previous].Binding.binding.meta
if other.Name == binding.meta.Name || binding.meta.Short != "" && other.Short == binding.meta.Short {
return ConfigSuite[C]{}, fmt.Errorf("duplicate configuration selector flag --%s", binding.meta.Name)
}
}
}
return normalized, nil
}
func normalizeConfigOperations(operations []ConfigOperation) ([]ConfigOperation, error) {
seen := make(map[ConfigOperation]struct{}, len(operations))
for _, operation := range operations {
switch operation {
case ConfigValidate, ConfigShow, ConfigSources, ConfigExplain:
default:
return nil, fmt.Errorf("unknown operation %q", operation)
}
seen[operation] = struct{}{}
}
ordered := make([]ConfigOperation, 0, len(seen))
for _, operation := range []ConfigOperation{ConfigValidate, ConfigShow, ConfigSources, ConfigExplain} {
if _, exists := seen[operation]; exists {
ordered = append(ordered, operation)
}
}
return ordered, nil
}
func newConfigScopesCommandWithScopes[C any](scopes []ConfigScope[C], installed []ConfigOperation) (Command, error) {
formatDefault := "text"
spec := CommandSpec[configScopesInput]{
Name: "scopes",
Summary: "List configuration scopes and supported operations.",
Flags: []Flag[configScopesInput]{ValueFlag[configScopesInput](FlagMeta{
Name: "format", Usage: "Output format: text or yaml.",
}, &formatDefault, parseConfigFormat, func(input *configScopesInput, value Supplied[string]) {
input.Format = value.Value
})},
CheckInput: func(input configScopesInput) error { return checkConfigFormat(input.Format) },
Run: func(invocation Invocation, input configScopesInput) Outcome {
inventory := scopesInventory{Scopes: make([]configScopeInfo, 0, len(scopes))}
for _, scope := range orderedConfigScopes(scopes) {
operations := make([]ConfigOperation, 0, len(scope.Operations))
for _, operation := range scope.Operations {
if containsOperation(installed, operation) {
operations = append(operations, operation)
}
}
inventory.Scopes = append(inventory.Scopes, configScopeInfo{Name: scope.Name, Operations: operations})
}
var output []byte
var err error
if input.Format == "yaml" {
output, err = yaml.Marshal(inventory)
} else {
output = []byte(formatScopeInventory(inventory))
}
if err != nil {
return Failure(err)
}
if err := writeReport(invocation.Streams.Out, output); err != nil {
return incompleteReport(err)
}
return Success()
},
}
return NewCommand(spec)
}
func orderedConfigScopes[C any](scopes []ConfigScope[C]) []ConfigScope[C] {
ordered := append([]ConfigScope[C](nil), scopes...)
sort.SliceStable(ordered, func(i, j int) bool {
return configScopeOrder(ordered[i].Name) < configScopeOrder(ordered[j].Name)
})
return ordered
}
func configScopeOrder(name string) string {
switch name {
case "app":
return "\x00"
case "promptkit":
return "\x01"
default:
return "\x02" + name
}
}
func formatScopeInventory(inventory scopesInventory) string {
var output strings.Builder
for _, scope := range inventory.Scopes {
output.WriteString(scope.Name)
output.WriteString(":")
if len(scope.Operations) > 0 {
output.WriteString(" ")
for index, operation := range scope.Operations {
if index > 0 {
output.WriteString(", ")
}
output.WriteString(string(operation))
}
}
output.WriteByte('\n')
}
return output.String()
}
func parseConfigFormat(value string) (string, error) {
if err := checkConfigFormat(value); err != nil {
return "", err
}
return value, nil
}
func checkConfigFormat(value string) error {
if value != "text" && value != "yaml" {
return fmt.Errorf("format must be text or yaml")
}
return nil
}

219
config_test.go Normal file
View File

@@ -0,0 +1,219 @@
package commandkit
import (
"bytes"
"context"
"reflect"
"strconv"
"strings"
"testing"
)
func TestConfigScopeDiscoveryOrdersAndDeduplicatesCapabilities(t *testing.T) {
app, err := New(Identity{Name: "example"})
if err != nil {
t.Fatal(err)
}
newContextCalls := 0
acquireCalls := 0
suite := ConfigSuite[struct{}]{
NewContext: func() struct{} { newContextCalls++; return struct{}{} },
Scopes: []ConfigScope[struct{}]{
{Name: "zeta", Operations: []ConfigOperation{ConfigExplain, ConfigShow, ConfigExplain}, Acquire: func(context.Context, ConfigRequest, struct{}) (Acquired[ConfigView], error) {
acquireCalls++
return Acquired[ConfigView]{}, nil
}},
{Name: "promptkit", Operations: []ConfigOperation{ConfigSources}, Acquire: noopConfigAcquire[struct{}]},
{Name: "app", Operations: []ConfigOperation{ConfigShow, ConfigValidate, ConfigShow}, Acquire: func(context.Context, ConfigRequest, struct{}) (Acquired[ConfigView], error) {
acquireCalls++
return Acquired[ConfigView]{}, nil
}},
{Name: "metadata", Summary: "No provider is required."},
},
}
normalized, err := normalizeConfigSuite(suite)
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(normalized.Scopes[0].Operations, []ConfigOperation{ConfigShow, ConfigExplain}) || !reflect.DeepEqual(normalized.Scopes[2].Operations, []ConfigOperation{ConfigValidate, ConfigShow}) {
t.Fatalf("normalized capabilities = %#v", normalized.Scopes)
}
if err := RegisterConfig(app, suite); err != nil {
t.Fatal(err)
}
var textOutput bytes.Buffer
outcome := app.Execute(context.Background(), []string{"config", "scopes"}, Streams{Out: &textOutput})
if outcome.Kind != OutcomeSuccess {
t.Fatalf("text inventory outcome = %#v", outcome)
}
wantText := "app:\npromptkit:\nmetadata:\nzeta:\n"
if textOutput.String() != wantText {
t.Fatalf("text inventory = %q, want %q", textOutput.String(), wantText)
}
var yamlOutput bytes.Buffer
outcome = app.Execute(context.Background(), []string{"config", "scopes", "--format", "yaml"}, Streams{Out: &yamlOutput})
if outcome.Kind != OutcomeSuccess {
t.Fatalf("YAML inventory outcome = %#v", outcome)
}
if got := yamlOutput.String(); !strings.Contains(got, "scopes:") || strings.Index(got, "name: app") > strings.Index(got, "name: promptkit") || !strings.Contains(got, "operations: []") {
t.Fatalf("YAML inventory = %q", got)
}
outcome = app.Execute(context.Background(), []string{"config", "scopes", "--help"}, Streams{})
if outcome.Kind != OutcomeSuccess {
t.Fatalf("scope help outcome = %#v", outcome)
}
outcome = app.Execute(context.Background(), []string{"config", "scopes", "--format", "json"}, Streams{})
if outcome.Kind != OutcomeUsage {
t.Fatalf("invalid format outcome = %#v", outcome)
}
if newContextCalls != 0 || acquireCalls != 0 {
t.Fatalf("discovery called runtime callbacks: NewContext=%d Acquire=%d", newContextCalls, acquireCalls)
}
}
func TestConfigInputDefaultsToAppAndRetainsAbsentSelections(t *testing.T) {
suite := ConfigSuite[struct{}]{Scopes: []ConfigScope[struct{}]{{Name: "app"}}}
prepared, err := prepareInput(CommandSpec[configInput[struct{}]]{Flags: configInputFlags(suite, "yaml")}, make([][]string, 4), nil, true)
if err != nil {
t.Fatal(err)
}
input := prepared.build()
if input.Scope != "app" || input.Format != "yaml" || input.Selection.Config.Set || input.Selection.Overlay.Set {
t.Fatalf("configuration defaults = %#v", input)
}
}
func TestRegisterConfigRejectsInvalidSuitesAndCollisions(t *testing.T) {
tests := []struct {
name string
scope ConfigScope[struct{}]
want string
}{
{name: "empty name", scope: ConfigScope[struct{}]{}, want: "name must not be empty"},
{name: "all name", scope: ConfigScope[struct{}]{Name: "all"}, want: "reserved"},
{name: "unknown operation", scope: ConfigScope[struct{}]{Name: "app", Operations: []ConfigOperation{"diff"}}, want: "unknown operation"},
{name: "missing acquisition", scope: ConfigScope[struct{}]{Name: "app", Operations: []ConfigOperation{ConfigValidate}}, want: "without an acquisition"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
app, _ := New(Identity{Name: "example"})
err := RegisterConfig(app, ConfigSuite[struct{}]{Scopes: []ConfigScope[struct{}]{test.scope}})
if err == nil || !strings.Contains(err.Error(), test.want) {
t.Fatalf("RegisterConfig error = %v, want %q", err, test.want)
}
})
}
app, _ := New(Identity{Name: "example"})
custom, err := NewCommand(CommandSpec[struct{}]{Name: "config", Run: func(Invocation, struct{}) Outcome { return Success() }})
if err != nil {
t.Fatal(err)
}
if err := app.Register(custom); err != nil {
t.Fatal(err)
}
if err := RegisterConfig(app, ConfigSuite[struct{}]{}); err == nil {
t.Fatal("replaced a pre-existing config command")
}
}
func TestConfigSelectorPreflightAndScopedContextProjection(t *testing.T) {
type selectorInput struct {
Count Supplied[int]
Enabled Supplied[bool]
Other Supplied[int]
Runtime string
}
baseContextCalls := 0
checkCalls := 0
var checked selectorInput
countDefault := 9
suite := ConfigSuite[selectorInput]{
NewContext: func() selectorInput { baseContextCalls++; return selectorInput{Runtime: "invocation cache"} },
Selectors: []Selector[selectorInput]{
{Binding: ValueFlag[selectorInput](FlagMeta{Name: "count"}, &countDefault, strconv.Atoi, func(input *selectorInput, value Supplied[int]) { input.Count = value }), Scopes: []string{"app"}},
{Binding: BoolFlag[selectorInput](FlagMeta{Name: "enabled"}, true, func(input *selectorInput, value Supplied[bool]) { input.Enabled = value }), Scopes: []string{"promptkit"}},
{Binding: ValueFlag[selectorInput](FlagMeta{Name: "other"}, nil, strconv.Atoi, func(input *selectorInput, value Supplied[int]) { input.Other = value })},
},
CheckInput: func(_ Selection, input selectorInput) error { checkCalls++; checked = input; return nil },
Scopes: []ConfigScope[selectorInput]{
{Name: "app", Operations: []ConfigOperation{ConfigValidate}, Acquire: noopConfigAcquire[selectorInput]},
{Name: "promptkit", Operations: []ConfigOperation{ConfigValidate}, Acquire: noopConfigAcquire[selectorInput]},
},
}
normalized, err := normalizeConfigSuite(suite)
if err != nil {
t.Fatal(err)
}
spec := CommandSpec[configInput[selectorInput]]{Flags: configInputFlags(normalized, "text")}
prepared, err := prepareInput(spec, [][]string{{"all"}, {"text"}, {""}, nil, {"0"}, {"false"}, nil}, nil, true)
if err != nil {
t.Fatal(err)
}
input := prepared.build()
scopeNames, err := preflightConfig(normalized, input, ConfigValidate)
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(scopeNames, []string{"app", "promptkit"}) || checkCalls != 1 || checked.Count != (Supplied[int]{Value: 0, Set: true}) || checked.Enabled != (Supplied[bool]{Value: false, Set: true}) {
t.Fatalf("preflight result=%v checked=%#v calls=%d", scopeNames, checked, checkCalls)
}
if !input.Selection.Config.Set || input.Selection.Config.Value != "" || input.Selection.Overlay.Set {
t.Fatalf("configuration selection presence = %#v", input.Selection)
}
if baseContextCalls != 0 {
t.Fatalf("NewContext called during preflight %d times", baseContextCalls)
}
contexts := contextsForScopes(normalized, input, scopeNames)
if baseContextCalls != 1 {
t.Fatalf("NewContext calls = %d, want one", baseContextCalls)
}
if contexts["app"].Count != (Supplied[int]{Value: 0, Set: true}) || contexts["app"].Enabled != (Supplied[bool]{}) || contexts["app"].Other != (Supplied[int]{}) {
t.Fatalf("app selectors = %#v", contexts["app"])
}
if contexts["promptkit"].Count != (Supplied[int]{}) || contexts["promptkit"].Enabled != (Supplied[bool]{Value: false, Set: true}) || contexts["promptkit"].Runtime != "invocation cache" {
t.Fatalf("promptkit selectors = %#v", contexts["promptkit"])
}
}
func TestConfigPreflightRejectsUnsupportedAggregateAndInapplicableSelector(t *testing.T) {
type selectorInput struct{ Region Supplied[string] }
newContextCalls := 0
suite := ConfigSuite[selectorInput]{
NewContext: func() selectorInput { newContextCalls++; return selectorInput{} },
Selectors: []Selector[selectorInput]{{Binding: ValueFlag[selectorInput](FlagMeta{Name: "region"}, nil, parseString, func(input *selectorInput, value Supplied[string]) { input.Region = value }), Scopes: []string{"promptkit"}}},
Scopes: []ConfigScope[selectorInput]{
{Name: "app", Operations: []ConfigOperation{ConfigValidate}, Acquire: noopConfigAcquire[selectorInput]},
{Name: "promptkit", Operations: []ConfigOperation{ConfigShow}, Acquire: noopConfigAcquire[selectorInput]},
},
}
normalized, err := normalizeConfigSuite(suite)
if err != nil {
t.Fatal(err)
}
prepared, err := prepareInput(CommandSpec[configInput[selectorInput]]{Flags: configInputFlags(normalized, "text")}, [][]string{{"all"}, {"text"}, nil, nil, nil}, nil, true)
if err != nil {
t.Fatal(err)
}
if _, err := preflightConfig(normalized, prepared.build(), ConfigValidate); err == nil || !strings.Contains(err.Error(), "does not support validate") {
t.Fatalf("unsupported aggregate error = %v", err)
}
if newContextCalls != 0 {
t.Fatalf("unsupported aggregate created context %d times", newContextCalls)
}
prepared, err = prepareInput(CommandSpec[configInput[selectorInput]]{Flags: configInputFlags(normalized, "text")}, [][]string{{"app"}, {"text"}, nil, nil, {"west"}}, nil, true)
if err != nil {
t.Fatal(err)
}
if _, err := preflightConfig(normalized, prepared.build(), ConfigValidate); err == nil || !strings.Contains(err.Error(), "not applicable") {
t.Fatalf("inapplicable selector error = %v", err)
}
}
func noopConfigAcquire[C any](context.Context, ConfigRequest, C) (Acquired[ConfigView], error) {
return Acquired[ConfigView]{}, nil
}

View File

@@ -83,6 +83,25 @@ Typed inputs keep supplied state separate from defaults. An explicit empty,
false, or zero value is supplied. Help and version are available without false, or zero value is supplied. Help and version are available without
constructing application command inputs. constructing application command inputs.
## Configuration scopes
An application installs one configuration suite with `RegisterConfig`. Its
`config scopes` command lists scope names and the configuration operations
currently installed for those scopes:
```text
example config scopes
example config scopes --format yaml
```
Text output uses one `scope: operation, operation` line per scope. YAML uses a
`scopes` sequence with explicit `name` and `operations` fields. Scope order is
`app`, then `promptkit`, then other names alphabetically. A metadata-only scope
can be listed without an acquisition callback. Discovery does not construct a
runtime context or acquire configuration. It reports the intersection between
declared scope capabilities and installed handlers, so an operation is listed
only when the corresponding command is available.
## Outcome status ## Outcome status
CommandKit usage failures use status `2`; operational failures use status `1`. CommandKit usage failures use status `2`; operational failures use status `1`.

View File

@@ -4,7 +4,8 @@ CommandKit currently contains adopted repository policies, accepted architectura
decisions, implementation plans, identity and outcome primitives, typed command decisions, implementation plans, identity and outcome primitives, typed command
declarations, an instance-owned registry, a Cobra-backed routing and parsing declarations, an instance-owned registry, a Cobra-backed routing and parsing
adapter, public `App.Execute` coordination, and generated help/version commands. adapter, public `App.Execute` coordination, and generated help/version commands.
Standard command families remain pending. The Configuration suite registration and scope discovery are implemented; the
configuration operations remain pending. The
[README](../README.md) provides project orientation; the [README](../README.md) provides project orientation; the
[documentation policy](policy/documentation.md#canonical-ownership) assigns [documentation policy](policy/documentation.md#canonical-ownership) assigns
canonical documentation owners. canonical documentation owners.

View File

@@ -1,6 +1,6 @@
# Initial CommandKit implementation plan # Initial CommandKit implementation plan
Status: implementation underway; Stages 1-6 complete and Stages 7-14 pending. Status: implementation underway; Stages 1-7 complete and Stages 8-14 pending.
Date: 2026-10-07. Date: 2026-10-07.
@@ -448,7 +448,7 @@ implemented boundary.
## Stage 7: implement configuration suites, scopes, and selector preflight ## Stage 7: implement configuration suites, scopes, and selector preflight
**Status:** pending. **Prerequisite:** Stage 6. **Status:** complete. **Prerequisite:** Stage 6.
Add the following root contracts in `config.go`/`selectors.go`: Add the following root contracts in `config.go`/`selectors.go`:
@@ -539,6 +539,16 @@ unsupported all before callbacks, explicit/omitted selector applicability,
typed empty/false/zero context selectors, and factories uninvoked on help/usage. typed empty/false/zero context selectors, and factories uninvoked on help/usage.
Use a small custom scope fixture; owning library helpers arrive next. Use a small custom scope fixture; owning library helpers arrive next.
**Completion evidence:** added typed configuration suite, scope, selector, view,
and request contracts; validates scope IDs, capability metadata, selector
bindings, and family collisions. `config scopes` reports installed capability
intersections in stable text/YAML inventories without creating a runtime context
or acquiring configuration. Added selector preflight and per-scope context
projection for later operation handlers, including explicit empty/false/zero
values and clearing inapplicable bound fields. `go test ./...` passes; the CLI
reference describes scope discovery. Configuration operation handlers remain
sequenced in Stages 8-11.
## Stage 8: integrate ConfigKit and implement validation ## Stage 8: integrate ConfigKit and implement validation
**Status:** pending. **Prerequisite:** Stage 7. **Status:** pending. **Prerequisite:** Stage 7.

198
selectors.go Normal file
View File

@@ -0,0 +1,198 @@
package commandkit
import (
"fmt"
"sort"
"strings"
)
func configInputFlags[C any](suite ConfigSuite[C], formatDefault string) []Flag[configInput[C]] {
scopeDefault := "app"
flags := []Flag[configInput[C]]{
ValueFlag[configInput[C]](FlagMeta{Name: "scope", Usage: "Configuration scope name or all where supported."}, &scopeDefault, parseString, func(input *configInput[C], value Supplied[string]) {
input.Scope = value.Value
}),
ValueFlag[configInput[C]](FlagMeta{Name: "format", Usage: "Output format."}, &formatDefault, parseString, func(input *configInput[C], value Supplied[string]) {
input.Format = value.Value
}),
ValueFlag[configInput[C]](FlagMeta{Name: "config", Usage: "Configuration source."}, nil, parseString, func(input *configInput[C], value Supplied[string]) {
input.Selection.Config = value
}),
ValueFlag[configInput[C]](FlagMeta{Name: "overlay", Usage: "Overlay source."}, nil, parseString, func(input *configInput[C], value Supplied[string]) {
input.Selection.Overlay = value
}),
}
for _, selector := range suite.Selectors {
flags = append(flags, adaptSelector(selector))
}
return flags
}
func adaptSelector[C any](selector Selector[C]) Flag[configInput[C]] {
binding := selector.Binding.binding
meta := binding.meta
if len(selector.Scopes) == 0 {
meta.Usage += " (applies to all scopes)"
} else {
meta.Usage += " (applies to: " + joinNames(selector.Scopes) + ")"
}
return Flag[configInput[C]]{binding: flagBinding[configInput[C]]{
meta: meta,
defaultText: binding.defaultText,
repeated: binding.repeated,
boolean: binding.boolean,
valid: binding.valid,
prepare: func(values []string) (func(*configInput[C]), error) {
apply, err := binding.prepare(values)
if err != nil {
return nil, err
}
state := selectorState[C]{
scopes: append([]string(nil), selector.Scopes...),
supplied: len(values) > 0,
apply: apply,
clear: binding.clear,
}
return func(input *configInput[C]) {
apply(&input.SelectorContext)
input.selectors = append(input.selectors, state)
}, nil
},
}}
}
func parseString(value string) (string, error) { return value, nil }
func preflightConfig[C any](suite ConfigSuite[C], input configInput[C], operation ConfigOperation) ([]string, error) {
if err := checkConfigFormat(input.Format); err != nil {
return nil, err
}
if input.Scope == "all" {
if operation != ConfigValidate && operation != ConfigShow {
return nil, fmt.Errorf("scope all is supported only for validate and show")
}
} else if input.Scope == "" {
return nil, fmt.Errorf("configuration scope must not be empty")
}
scopes := make([]string, 0, len(suite.Scopes))
if input.Scope == "all" {
for _, scope := range orderedConfigScopes(suite.Scopes) {
scopes = append(scopes, scope.Name)
}
} else {
scope, ok := findConfigScope(suite.Scopes, input.Scope)
if !ok {
return nil, fmt.Errorf("unknown configuration scope %q", input.Scope)
}
if !containsOperation(scope.Operations, operation) {
return nil, unsupportedConfigOperation(scope, operation)
}
scopes = append(scopes, scope.Name)
}
if input.Scope == "all" {
for _, scopeName := range scopes {
scope, _ := findConfigScope(suite.Scopes, scopeName)
if !containsOperation(scope.Operations, operation) {
return nil, unsupportedConfigOperation(scope, operation)
}
}
}
for _, selector := range input.selectors {
if !selector.supplied {
continue
}
applies := false
for _, scopeName := range scopes {
if selectorApplies(selector.scopes, scopeName) {
applies = true
break
}
}
if !applies {
return nil, fmt.Errorf("selector is not applicable to configuration scope %q", input.Scope)
}
}
if suite.CheckInput != nil {
if err := suite.CheckInput(input.Selection, input.SelectorContext); err != nil {
return nil, err
}
}
return scopes, nil
}
func contextsForScopes[C any](suite ConfigSuite[C], input configInput[C], scopeNames []string) map[string]C {
base := *new(C)
if suite.NewContext != nil {
base = suite.NewContext()
}
contexts := make(map[string]C, len(scopeNames))
for _, name := range scopeNames {
context := base
for _, selector := range input.selectors {
if selectorApplies(selector.scopes, name) {
selector.apply(&context)
} else {
selector.clear(&context)
}
}
contexts[name] = context
}
return contexts
}
func findConfigScope[C any](scopes []ConfigScope[C], name string) (ConfigScope[C], bool) {
for _, scope := range scopes {
if scope.Name == name {
return scope, true
}
}
return ConfigScope[C]{}, false
}
func containsOperation(operations []ConfigOperation, requested ConfigOperation) bool {
for _, operation := range operations {
if operation == requested {
return true
}
}
return false
}
func unsupportedConfigOperation[C any](scope ConfigScope[C], operation ConfigOperation) error {
return fmt.Errorf("configuration scope %q does not support %s (available: %s)", scope.Name, operation, joinOperations(scope.Operations))
}
func selectorApplies(scopes []string, name string) bool {
if len(scopes) == 0 {
return true
}
for _, scope := range scopes {
if scope == name {
return true
}
}
return false
}
func joinNames(names []string) string {
ordered := append([]string(nil), names...)
sort.Strings(ordered)
return joinStrings(ordered)
}
func joinOperations(operations []ConfigOperation) string {
values := make([]string, len(operations))
for index, operation := range operations {
values[index] = string(operation)
}
return joinStrings(values)
}
func joinStrings(values []string) string {
if len(values) == 0 {
return "none"
}
return strings.Join(values, ", ")
}