diff --git a/README.md b/README.md index bbf2474..f3ba881 100644 --- a/README.md +++ b/README.md @@ -6,8 +6,8 @@ and shared command infrastructure across related applications. The current Go library provides an instance-owned command registry, typed flag and operand bindings, Cobra-backed routing, invocation outcomes, generated help, application or CommandKit version reporting, configuration scope discovery, and -ConfigKit-backed validation. Effective display, source and explanation reports, -and PromptKit commands remain planned. +ConfigKit-backed validation and native effective YAML display. Source and +explanation reports and PromptKit commands remain planned. ## Documentation diff --git a/config.go b/config.go index 2111e42..3b2b8c1 100644 --- a/config.go +++ b/config.go @@ -131,8 +131,8 @@ func RegisterConfig[C any](app *App, suite ConfigSuite[C]) error { return fmt.Errorf("command path %q is already registered", "config") } - commands := make([]Command, 0, 2) - installed := make([]ConfigOperation, 0, 1) + commands := make([]Command, 0, 3) + installed := make([]ConfigOperation, 0, 2) if configOperationDeclared(normalized.Scopes, ConfigValidate) { validate, err := newConfigValidateCommand(normalized) if err != nil { @@ -141,6 +141,14 @@ func RegisterConfig[C any](app *App, suite ConfigSuite[C]) error { commands = append(commands, validate) installed = append(installed, ConfigValidate) } + if configOperationDeclared(normalized.Scopes, ConfigShow) { + show, err := newConfigShowCommand(normalized) + if err != nil { + return err + } + commands = append(commands, show) + installed = append(installed, ConfigShow) + } command, err := newConfigScopesCommandWithScopes(normalized.Scopes, installed) if err != nil { return err diff --git a/config_test.go b/config_test.go index 14dc877..61d1e44 100644 --- a/config_test.go +++ b/config_test.go @@ -47,7 +47,7 @@ func TestConfigScopeDiscoveryOrdersAndDeduplicatesCapabilities(t *testing.T) { if outcome.Kind != OutcomeSuccess { t.Fatalf("text inventory outcome = %#v", outcome) } - wantText := "app: validate\npromptkit:\nmetadata:\nzeta:\n" + wantText := "app: validate, show\npromptkit:\nmetadata:\nzeta: show\n" if textOutput.String() != wantText { t.Fatalf("text inventory = %q, want %q", textOutput.String(), wantText) } diff --git a/configkit_test.go b/configkit_test.go index 3eb9070..a023ba0 100644 --- a/configkit_test.go +++ b/configkit_test.go @@ -12,8 +12,129 @@ import ( commandkit "gitea.maximumdirect.net/eric/CommandKit" configkit "gitea.maximumdirect.net/eric/ConfigKit" + "go.yaml.in/yaml/v4" ) +type effectiveYAML string + +func (document effectiveYAML) WriteEffective(writer io.Writer) error { + _, err := io.WriteString(writer, string(document)) + return err +} + +func TestConfigShowWritesNativeSingleScopeAndFramesAggregateYAML(t *testing.T) { + const native = "%YAML 1.1\n# authored comment\n---\nvalue: null\nitems: []\nmissing: {}\n" + app, err := commandkit.New(commandkit.Identity{Name: "example"}) + if err != nil { + t.Fatal(err) + } + var calls []string + suite := commandkit.ConfigSuite[struct{}]{Scopes: []commandkit.ConfigScope[struct{}]{ + {Name: "zeta", Operations: []commandkit.ConfigOperation{commandkit.ConfigShow}, Acquire: func(context.Context, commandkit.ConfigRequest, struct{}) (commandkit.Acquired[commandkit.ConfigView], error) { + calls = append(calls, "zeta") + return commandkit.Acquired[commandkit.ConfigView]{Value: commandkit.ConfigView{Effective: effectiveYAML("%YAML 1.1\n# zeta comment\nvalue: zeta\n")}}, nil + }}, + {Name: "app", Operations: []commandkit.ConfigOperation{commandkit.ConfigShow}, Acquire: func(context.Context, commandkit.ConfigRequest, struct{}) (commandkit.Acquired[commandkit.ConfigView], error) { + calls = append(calls, "app") + return commandkit.Acquired[commandkit.ConfigView]{Value: commandkit.ConfigView{Effective: effectiveYAML(native)}}, nil + }}, + }} + if err := commandkit.RegisterConfig(app, suite); err != nil { + t.Fatal(err) + } + var single bytes.Buffer + outcome := app.Execute(context.Background(), []string{"config", "show"}, commandkit.Streams{Out: &single, Err: io.Discard}) + if outcome.Kind != commandkit.OutcomeSuccess || single.String() != native { + t.Fatalf("single show outcome=%#v output=%q", outcome, single.String()) + } + calls = nil + outcome = app.Execute(context.Background(), []string{"config", "show", "--format", "text"}, commandkit.Streams{Out: &single, Err: io.Discard}) + if outcome.Kind != commandkit.OutcomeUsage || len(calls) != 0 { + t.Fatalf("text format outcome=%#v acquisitions=%v", outcome, calls) + } + + calls = nil + var aggregate bytes.Buffer + outcome = app.Execute(context.Background(), []string{"config", "show", "--scope", "all"}, commandkit.Streams{Out: &aggregate, Err: io.Discard}) + if outcome.Kind != commandkit.OutcomeSuccess || !reflect.DeepEqual(calls, []string{"app", "zeta"}) { + t.Fatalf("aggregate outcome=%#v acquisitions=%v", outcome, calls) + } + if !strings.Contains(aggregate.String(), "%YAML 1.1\n# authored comment\n---\n# scope: app\n") || !strings.Contains(aggregate.String(), "...\n%YAML 1.1\n# zeta comment\n---\n# scope: zeta\nvalue: zeta\n") { + t.Fatalf("aggregate framing = %q", aggregate.String()) + } + decoder := yaml.NewDecoder(bytes.NewReader(aggregate.Bytes())) + var first, second map[string]any + if err := decoder.Decode(&first); err != nil { + t.Fatal(err) + } + if err := decoder.Decode(&second); err != nil { + t.Fatal(err) + } + var extra any + if err := decoder.Decode(&extra); err != io.EOF { + t.Fatalf("third YAML document decode error = %v, value=%#v", err, extra) + } + if first["value"] != nil || first["items"] == nil || first["missing"] == nil || second["value"] != "zeta" { + t.Fatalf("decoded documents = %#v and %#v", first, second) + } + if _, exists := first["absent"]; exists { + t.Fatalf("absent field was added to native document: %#v", first) + } +} + +func TestConfigShowAcquiresAllBeforeOutputAndTracksWriterFailures(t *testing.T) { + failure := errors.New("second scope failed") + app, err := commandkit.New(commandkit.Identity{Name: "example"}) + if err != nil { + t.Fatal(err) + } + calls := 0 + suite := commandkit.ConfigSuite[struct{}]{Scopes: []commandkit.ConfigScope[struct{}]{ + {Name: "app", Operations: []commandkit.ConfigOperation{commandkit.ConfigShow}, Acquire: func(context.Context, commandkit.ConfigRequest, struct{}) (commandkit.Acquired[commandkit.ConfigView], error) { + calls++ + return commandkit.Acquired[commandkit.ConfigView]{Value: commandkit.ConfigView{Effective: effectiveYAML("value: app\n")}}, nil + }}, + {Name: "promptkit", Operations: []commandkit.ConfigOperation{commandkit.ConfigShow}, Acquire: func(context.Context, commandkit.ConfigRequest, struct{}) (commandkit.Acquired[commandkit.ConfigView], error) { + calls++ + return commandkit.Acquired[commandkit.ConfigView]{Diagnostics: []commandkit.Diagnostic{commandkit.MessageDiagnostic(configkit.SeverityWarning, "provider warning")}}, failure + }}, + }} + if err := commandkit.RegisterConfig(app, suite); err != nil { + t.Fatal(err) + } + var output bytes.Buffer + outcome := app.Execute(context.Background(), []string{"config", "show", "--scope", "all"}, commandkit.Streams{Out: &output, Err: io.Discard}) + if !errors.Is(outcome.Cause, failure) || outcome.ReportIncomplete || output.Len() != 0 || calls != 2 || len(outcome.Diagnostics) < 1 || outcome.Diagnostics[0].Severity != configkit.SeverityWarning { + t.Fatalf("acquisition failure outcome=%#v output=%q calls=%d", outcome, output.String(), calls) + } + + app, _ = commandkit.New(commandkit.Identity{Name: "example"}) + if err := commandkit.RegisterConfig(app, commandkit.ConfigSuite[struct{}]{Scopes: []commandkit.ConfigScope[struct{}]{{Name: "app", Operations: []commandkit.ConfigOperation{commandkit.ConfigShow}, Acquire: func(context.Context, commandkit.ConfigRequest, struct{}) (commandkit.Acquired[commandkit.ConfigView], error) { + return commandkit.Acquired[commandkit.ConfigView]{Value: commandkit.ConfigView{Effective: effectiveYAML("value: app\n")}}, nil + }}}}); err != nil { + t.Fatal(err) + } + partial := &configKitShowPartialWriter{failure: io.ErrClosedPipe} + outcome = app.Execute(context.Background(), []string{"config", "show", "--scope", "all"}, commandkit.Streams{Out: partial, Err: io.Discard}) + if outcome.Kind != commandkit.OutcomeOperational || !outcome.ReportIncomplete || !errors.Is(outcome.Cause, io.ErrClosedPipe) || !errors.Is(outcome.DeliveryError, io.ErrClosedPipe) || len(partial.written) == 0 { + t.Fatalf("writer failure outcome = %#v", outcome) + } +} + +type configKitShowPartialWriter struct { + written []byte + failure error +} + +func (writer *configKitShowPartialWriter) Write(data []byte) (int, error) { + n := len(data) + if n > 4 { + n = 4 + } + writer.written = append(writer.written, data[:n]...) + return n, writer.failure +} + func TestConfigKitScopeUsesDefaultsOverlayAndRecordedResolution(t *testing.T) { type configuration struct { Mode string `yaml:"mode"` diff --git a/docs/cli.md b/docs/cli.md index cd98d20..00efdb1 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -125,6 +125,28 @@ empty value was explicitly supplied, so a scope callback can map those inputs to its own ConfigKit load policy. Help and invalid syntax do not call scope providers. +### Show effective configuration + +`config show` writes the provider's native effective YAML for one scope. Its +default scope is `app`; `--format yaml` is accepted and YAML is the only initial +format. Single-scope output is passed through unchanged, without a CommandKit +header or document marker. + +```text +example config show +example config show --scope promptkit +example config show --scope all +``` + +`--scope all` acquires every selected scope and checks each effective writer +before writing output. It then emits one YAML document per scope in discovery +order, with a `# scope: NAME` comment after each document marker. Existing native +markers and leading comments are retained; documents without a marker receive +one. Scope labels containing line control characters are quoted to keep them on +one comment line. A provider failure leaves the output stream empty. A rendering +or output failure may leave an aggregate prefix, and the outcome reports +incomplete report delivery. + ## Outcome status CommandKit usage failures use status `2`; operational failures use status `1`. diff --git a/docs/development.md b/docs/development.md index ec45bb2..9e286cf 100644 --- a/docs/development.md +++ b/docs/development.md @@ -4,9 +4,9 @@ CommandKit currently contains adopted repository policies, accepted architectura decisions, implementation plans, identity and outcome primitives, typed command declarations, an instance-owned registry, a Cobra-backed routing and parsing adapter, public `App.Execute` coordination, and generated help/version commands. -Configuration suite registration, scope discovery, ConfigKit integration, and -validation are implemented; other configuration and PromptKit operations remain -pending. The +Configuration suite registration, scope discovery, ConfigKit integration, +validation, and native effective YAML display are implemented; other +configuration and PromptKit operations remain pending. The [README](../README.md) provides project orientation; the [documentation policy](policy/documentation.md#canonical-ownership) assigns canonical documentation owners. diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 2ef6743..032890b 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -1,6 +1,6 @@ # Initial CommandKit implementation plan -Status: implementation underway; Stages 1-8 complete and Stages 9-14 pending. +Status: implementation underway; Stages 1-9 complete and Stages 10-14 pending. Date: 2026-10-07. @@ -606,7 +606,7 @@ development orientation, and CLI reference describe the implemented boundary. ## Stage 9: implement native effective YAML display -**Status:** pending. **Prerequisite:** Stage 8. +**Status:** complete. **Prerequisite:** Stage 8. 1. Implement `config show`; its only initial format is yaml (default and explicit). Single scope writes Effective.WriteEffective directly to Out with no envelope, @@ -633,6 +633,16 @@ labels and native values. Cover an already marked native document, missing/null/ empty structures, a second acquisition failure with no prefix, and a writer failure after output begins. These protect selected behavior, not encoder whitespace. +**Completion evidence:** installed `config show` when a scope declares it, with +YAML as its only format. Single-scope output passes through the native effective +writer; aggregate output acquires and checks every view before emitting framed +native documents in scope order. Existing markers, leading comments and YAML +directives are retained, including explicit document termination where needed +between directive-bearing documents. Checks cover native single-scope bytes, +aggregate document decoding and values, invalid formats, acquisition failure with +empty output, and incomplete delivery after a writer failure. `go test ./...` +passes; README, development orientation, and CLI reference describe the feature. + ## Stage 10: implement source inventory reports **Status:** pending. **Prerequisite:** Stage 9. diff --git a/selectors.go b/selectors.go index f88543f..03313f6 100644 --- a/selectors.go +++ b/selectors.go @@ -80,6 +80,9 @@ func selectConfigScopes[C any](suite ConfigSuite[C], input configInput[C], opera if err := checkConfigFormat(input.Format); err != nil { return nil, err } + if operation == ConfigShow && input.Format != "yaml" { + return nil, fmt.Errorf("unsupported configuration show format %q", input.Format) + } if input.Scope == "all" { if operation != ConfigValidate && operation != ConfigShow { return nil, fmt.Errorf("scope all is supported only for validate and show") diff --git a/show.go b/show.go new file mode 100644 index 0000000..b272c69 --- /dev/null +++ b/show.go @@ -0,0 +1,177 @@ +package commandkit + +import ( + "bytes" + "errors" + "fmt" + "strconv" + "strings" + "unicode" +) + +func newConfigShowCommand[C any](suite ConfigSuite[C]) (Command, error) { + spec := CommandSpec[configInput[C]]{ + Name: string(ConfigShow), + Summary: "Show effective configuration as YAML.", + Flags: configInputFlags(suite, "yaml"), + CheckInput: func(input configInput[C]) error { + _, err := preflightConfig(suite, input, ConfigShow) + return err + }, + Run: func(invocation Invocation, input configInput[C]) Outcome { + return runConfigShow(invocation, suite, input) + }, + } + return NewCommand(spec) +} + +type effectiveDocument struct { + name string + view ConfigView +} + +func runConfigShow[C any](invocation Invocation, suite ConfigSuite[C], input configInput[C]) Outcome { + scopeNames, err := selectConfigScopes(suite, input, ConfigShow) + if err != nil { + return Failure(err) + } + contexts := contextsForScopes(suite, input, scopeNames) + documents := make([]effectiveDocument, 0, len(scopeNames)) + diagnostics := make([]Diagnostic, 0) + for _, name := range scopeNames { + scope, ok := findConfigScope(suite.Scopes, name) + if !ok || scope.Acquire == nil { + return showFailure(fmt.Errorf("configuration scope %q has no acquisition callback", name), diagnostics, false) + } + acquired, acquireErr := scope.Acquire(invocation.Context, ConfigRequest{Operation: ConfigShow, Selection: input.Selection}, contexts[name]) + diagnostics = append(diagnostics, acquired.Diagnostics...) + if acquireErr != nil { + return showFailure(acquireErr, diagnostics, false) + } + if acquired.Value.Effective == nil { + return showFailure(fmt.Errorf("configuration scope %q did not provide an effective configuration writer", name), diagnostics, false) + } + documents = append(documents, effectiveDocument{name: name, view: acquired.Value}) + } + + if len(documents) == 1 && input.Scope != "all" { + checked := &checkedWriter{writer: invocation.Streams.Out} + renderErr := documents[0].view.Effective.WriteEffective(checked) + if checked.failure != nil { + if renderErr == nil { + renderErr = checked.failure + } else { + renderErr = errors.Join(renderErr, checked.failure) + } + } + if renderErr != nil { + outcome := showFailure(renderErr, diagnostics, true) + outcome.DeliveryError = checked.failure + return outcome + } + outcome := Success() + outcome.Diagnostics = diagnostics + return outcome + } + + written := false + for index, document := range documents { + var native bytes.Buffer + if err := document.view.Effective.WriteEffective(&native); err != nil { + return showFailure(err, diagnostics, written) + } + if index > 0 && hasYAMLDirective(native.Bytes()) { + if err := writeReport(invocation.Streams.Out, []byte("...\n")); err != nil { + outcome := showFailure(err, diagnostics, true) + outcome.DeliveryError = err + return outcome + } + } + framed := frameEffectiveYAML(document.name, native.Bytes()) + if index < len(documents)-1 && len(framed) > 0 && framed[len(framed)-1] != '\n' { + framed = append(framed, '\n') + } + if err := writeReport(invocation.Streams.Out, framed); err != nil { + outcome := showFailure(err, diagnostics, true) + outcome.DeliveryError = err + return outcome + } + written = true + } + outcome := Success() + outcome.Diagnostics = diagnostics + return outcome +} + +func hasYAMLDirective(native []byte) bool { + lines := bytes.Split(native, []byte("\n")) + for _, line := range lines { + trimmed := bytes.TrimSpace(line) + if bytes.Equal(trimmed, []byte("---")) || bytes.HasPrefix(trimmed, []byte("--- #")) { + return false + } + if bytes.HasPrefix(trimmed, []byte("%YAML ")) || bytes.HasPrefix(trimmed, []byte("%TAG ")) { + return true + } + if len(trimmed) > 0 && trimmed[0] != '#' { + return false + } + } + return false +} + +func frameEffectiveYAML(scope string, native []byte) []byte { + label := scope + for _, r := range scope { + if unicode.IsControl(r) { + label = strconv.Quote(scope) + break + } + } + comment := []byte("# scope: " + label + "\n") + lines := bytes.SplitAfter(native, []byte("\n")) + position := 0 + preambleEnd := 0 + hasDirective := false + for _, line := range lines { + trimmed := strings.TrimSpace(string(line)) + if trimmed == "---" || strings.HasPrefix(trimmed, "--- #") { + out := make([]byte, 0, len(native)+len(comment)) + out = append(out, native[:position+len(line)]...) + if len(line) > 0 && line[len(line)-1] != '\n' { + out = append(out, '\n') + } + out = append(out, comment...) + out = append(out, native[position+len(line):]...) + return out + } + if strings.HasPrefix(trimmed, "%YAML ") || strings.HasPrefix(trimmed, "%TAG ") { + hasDirective = true + } + if trimmed == "" || strings.HasPrefix(trimmed, "#") || strings.HasPrefix(trimmed, "%YAML ") || strings.HasPrefix(trimmed, "%TAG ") { + preambleEnd = position + len(line) + } else { + break + } + position += len(line) + } + result := make([]byte, 0, len(native)+len(comment)+5) + if hasDirective { + result = append(result, native[:preambleEnd]...) + } + result = append(result, []byte("---\n")...) + result = append(result, comment...) + if hasDirective { + result = append(result, native[preambleEnd:]...) + } else { + result = append(result, native...) + } + return result +} + +func showFailure(cause error, diagnostics []Diagnostic, reportStarted bool) Outcome { + outcome := Failure(cause) + outcome.Diagnostics = diagnostics + outcome.ReportIncomplete = reportStarted + return outcome +}