diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 44cc575..20b4d86 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -1,6 +1,6 @@ # Implement PromptKit comparison and catalogs -Status: Stages 1–6 complete; remaining stages are planned. +Status: Stages 1–7 complete; remaining stages are planned. ## Scope and authority @@ -425,6 +425,24 @@ Compile and exercise custom prompts-only/profiles-only suites. Check a successfu catalog, invalid format stopping before factories, and catalog-only help omitting `show`. Stage 8 completes evidence. No new core public API should be necessary. +**Completion evidence (2026-10-12)** + +- `Command` now builds `show` and `list` independently, omits absent families, + retains construction issues when all four callbacks are cleared, and captures + the caller's selectors when the command is created. Catalog commands expose + config, overlay, selectors, and YAML format without named-show arguments. +- Catalog invocation checks format before suite input preparation, applies + captured selectors once, checks the prepared input, then creates one state and + calls only the selected provider. It retains diagnostics through provider and + cancellation failures, encodes before output, and reuses existing report + delivery behavior. +- Added focused command coverage for prompts-only and profiles-only help, + catalog-only omission of `show`, successful preparation and provider input, + and invalid-format preflight before suite factories/providers. +- `GOTOOLCHAIN=local go test -count=1 .` passed in `integrations/ckprompt`. + Complete lifecycle, YAML schema, cancellation, and delivery-failure coverage + remains assigned to Stage 8. + ## Stage 8: Complete catalog invocation and presentation coverage **Outcome:** observable catalog contracts have focused coverage without freezing diff --git a/integrations/ckprompt/command.go b/integrations/ckprompt/command.go index 0f8b1d1..9fcbe69 100644 --- a/integrations/ckprompt/command.go +++ b/integrations/ckprompt/command.go @@ -2,6 +2,7 @@ package ckprompt import ( "bytes" + "context" "errors" "fmt" "io" @@ -23,13 +24,20 @@ type definitionInput[I any] struct { selected I } -// Command captures the optional named PromptKit inspection tree and adds only -// the inspection capabilities present in suite. It adds no configuration +type catalogInput[I any] struct { + selection ckconfig.Selection + format string + captured commandkit.FlagValues[I] + selected I +} + +// Command captures the optional PromptKit catalog and named inspection trees +// and adds only the operations present in suite. It adds no configuration // family; callers compose ConfigScope independently. Construction and // installation do not run suite callbacks. Suite documents invocation input // and state initialization, zero values, and caller ownership of returned objects. func Command[I, S any](suite Suite[I, S]) commandkit.Command { - if suite.InspectPrompt == nil && suite.InspectProfile == nil { + if suite.InspectPrompt == nil && suite.InspectProfile == nil && suite.ListPrompts == nil && suite.ListProfiles == nil { command := commandkit.NoCommand() for _, issue := range suite.issues { command = commandkit.WithError(command, issue) @@ -38,6 +46,7 @@ func Command[I, S any](suite Suite[I, S]) commandkit.Command { } suite.Selectors = append([]commandkit.Flag[I](nil), suite.Selectors...) + rootCommands := make([]commandkit.Command, 0, 2) showCommands := make([]commandkit.Command, 0, 2) if suite.InspectPrompt != nil { showCommands = append(showCommands, newDefinitionCommand(suite, "prompt", true)) @@ -45,15 +54,82 @@ func Command[I, S any](suite Suite[I, S]) commandkit.Command { if suite.InspectProfile != nil { showCommands = append(showCommands, newDefinitionCommand(suite, "profile", false)) } - command := commandkit.RootGroup("promptkit", "Inspect named PromptKit definitions.", - commandkit.Group("show", "Show a named PromptKit definition.", showCommands...), - ) + if len(showCommands) != 0 { + rootCommands = append(rootCommands, commandkit.Group("show", "Show a named PromptKit definition.", showCommands...)) + } + listCommands := make([]commandkit.Command, 0, 2) + if suite.ListPrompts != nil { + listCommands = append(listCommands, newCatalogListCommand(suite, "prompts", true)) + } + if suite.ListProfiles != nil { + listCommands = append(listCommands, newCatalogListCommand(suite, "profiles", false)) + } + if len(listCommands) != 0 { + rootCommands = append(rootCommands, commandkit.Group("list", "List PromptKit catalogs.", listCommands...)) + } + command := commandkit.RootGroup("promptkit", "Inspect and list PromptKit definitions.", rootCommands...) for _, issue := range suite.issues { command = commandkit.WithError(command, issue) } return command } +func newCatalogListCommand[I, S any](suite Suite[I, S], name string, prompts bool) commandkit.Command { + flags := commandkit.CaptureFlags(suite.Selectors, func(input **catalogInput[I]) *commandkit.FlagValues[I] { + if input == nil || *input == nil { + return nil + } + return &(*input).captured + }) + flags = append(flags, + commandkit.ValueFlag(commandkit.FlagMeta{Name: "config", Usage: "Configuration input path."}, (*string)(nil), parseDefinitionString, + func(input **catalogInput[I], value commandkit.Supplied[string]) { (*input).selection.Config = value }), + commandkit.ValueFlag(commandkit.FlagMeta{Name: "overlay", Usage: "Configuration overlay path."}, (*string)(nil), parseDefinitionString, + func(input **catalogInput[I], value commandkit.Supplied[string]) { (*input).selection.Overlay = value }), + commandkit.ValueFlag(commandkit.FlagMeta{Name: "format", Usage: "Catalog format: yaml."}, stringDefault("yaml"), parseDefinitionString, + func(input **catalogInput[I], value commandkit.Supplied[string]) { (*input).format = value.Value }), + ) + return commandkit.NewCommand(commandkit.CommandSpec[*catalogInput[I]]{ + Name: name, + Summary: "List PromptKit " + name + ".", + NewInput: func() *catalogInput[I] { + return &catalogInput[I]{} + }, + Flags: flags, + CheckInput: func(input *catalogInput[I]) error { + if input == nil { + return errors.New("PromptKit catalog input must not be nil") + } + if err := checkCatalogFormat(input.format); err != nil { + return err + } + selected, err := prepareCatalogInput(suite, input) + if err != nil { + return err + } + input.selected = selected + return nil + }, + Run: func(invocation commandkit.Invocation, input *catalogInput[I]) commandkit.Outcome { + if prompts { + return runCatalogList(invocation, suite, input, suite.ListPrompts, encodePromptCatalog) + } + return runCatalogList(invocation, suite, input, suite.ListProfiles, encodeProfileCatalog) + }, + }) +} + +func checkCatalogFormat(format string) error { + if format != "yaml" { + return fmt.Errorf("unsupported catalog format %q", format) + } + return nil +} + +func prepareCatalogInput[I, S any](suite Suite[I, S], input *catalogInput[I]) (I, error) { + return prepareSuiteInput(suite, input.selection, input.captured) +} + func newDefinitionCommand[I, S any](suite Suite[I, S], name string, prompt bool) commandkit.Command { flags := commandkit.CaptureFlags(suite.Selectors, func(input **definitionInput[I]) *commandkit.FlagValues[I] { if input == nil || *input == nil { @@ -106,21 +182,63 @@ func newDefinitionCommand[I, S any](suite Suite[I, S], name string, prompt bool) } func prepareDefinitionInput[I, S any](suite Suite[I, S], input *definitionInput[I]) (I, error) { + return prepareSuiteInput(suite, input.selection, input.captured) +} + +func prepareSuiteInput[I, S any](suite Suite[I, S], selection ckconfig.Selection, captured commandkit.FlagValues[I]) (I, error) { var selected I if suite.NewInput != nil { selected = suite.NewInput() } - if err := input.captured.ApplyTo(&selected, suite.Selectors...); err != nil { + if err := captured.ApplyTo(&selected, suite.Selectors...); err != nil { return selected, err } if suite.CheckInput != nil { - if err := suite.CheckInput(input.selection, selected); err != nil { + if err := suite.CheckInput(selection, selected); err != nil { return selected, err } } return selected, nil } +func runCatalogList[T any, I, S any]( + invocation commandkit.Invocation, + suite Suite[I, S], + input *catalogInput[I], + list func(context.Context, ckconfig.Selection, I, S) (commandkit.Acquired[T], error), + encode func(T) ([]byte, error), +) commandkit.Outcome { + if invocation.Context != nil { + if err := invocation.Context.Err(); err != nil { + return definitionFailure(err, nil) + } + } + var state S + if suite.NewState != nil { + state = suite.NewState() + } + if invocation.Context != nil { + if err := invocation.Context.Err(); err != nil { + return definitionFailure(err, nil) + } + } + + acquired, err := list(invocation.Context, input.selection, input.selected, state) + if err != nil { + return definitionFailure(err, acquired.Diagnostics) + } + if invocation.Context != nil { + if err := invocation.Context.Err(); err != nil { + return definitionFailure(err, acquired.Diagnostics) + } + } + output, err := encode(acquired.Value) + if err != nil { + return definitionFailure(err, acquired.Diagnostics) + } + return writePromptReport(invocation, output, acquired.Diagnostics) +} + func runDefinitionInspection[I, S any](invocation commandkit.Invocation, suite Suite[I, S], input *definitionInput[I], prompt bool) commandkit.Outcome { if invocation.Context != nil { if err := invocation.Context.Err(); err != nil { diff --git a/integrations/ckprompt/command_test.go b/integrations/ckprompt/command_test.go index f202f92..385be96 100644 --- a/integrations/ckprompt/command_test.go +++ b/integrations/ckprompt/command_test.go @@ -62,12 +62,89 @@ func TestCommandBuildsIndependentOptionalTrees(t *testing.T) { } invalid := FromEngine[int, int](nil) + invalid.ListPrompts, invalid.ListProfiles = nil, nil invalid.InspectPrompt, invalid.InspectProfile = nil, nil if err := newPromptTestApp(t).Register(Command(invalid)); err == nil || !strings.Contains(err.Error(), "PromptKit engine loader is nil") { t.Fatalf("empty invalid adapter lost its construction issue: %v", err) } } +func TestCatalogCommandTreesAreIndependentAndOmitShow(t *testing.T) { + prompts := Command(Suite[struct{}, struct{}]{ + ListPrompts: func(context.Context, ckconfig.Selection, struct{}, struct{}) (commandkit.Acquired[[]promptkit.PromptIdentity], error) { + return commandkit.Acquired[[]promptkit.PromptIdentity]{}, nil + }, + }) + profiles := Command(Suite[struct{}, struct{}]{ + ListProfiles: func(context.Context, ckconfig.Selection, struct{}, struct{}) (commandkit.Acquired[[]string], error) { + return commandkit.Acquired[[]string]{}, nil + }, + }) + for _, test := range []struct { + name string + cmd commandkit.Command + want string + omit string + }{ + {name: "prompts", cmd: prompts, want: "prompts", omit: "profiles"}, + {name: "profiles", cmd: profiles, want: "profiles", omit: "prompts"}, + } { + t.Run(test.name, func(t *testing.T) { + app := newPromptTestApp(t, test.cmd) + var output bytes.Buffer + outcome := app.Execute(context.Background(), []string{"help", "promptkit", "list"}, commandkit.Streams{Out: &output, Err: io.Discard}) + if outcome.Kind != commandkit.OutcomeSuccess || !strings.Contains(output.String(), "list") || !strings.Contains(output.String(), test.want) || strings.Contains(output.String(), test.omit) || strings.Contains(output.String(), "show") { + t.Fatalf("catalog-only help = (%#v, %q)", outcome, output.String()) + } + }) + } +} + +func TestCatalogListUsesPreparedInputAndPreflightsFormat(t *testing.T) { + type input struct{ campaign commandkit.Supplied[string] } + type state struct{ token int } + flag := commandkit.ValueFlag(commandkit.FlagMeta{Name: "campaign"}, (*string)(nil), + func(value string) (string, error) { return value, nil }, + func(target *input, value commandkit.Supplied[string]) { target.campaign = value }) + newInputCalls, newStateCalls, providerCalls := 0, 0, 0 + var received input + var receivedSelection ckconfig.Selection + var receivedState state + suite := Suite[input, state]{ + NewInput: func() input { newInputCalls++; return input{} }, + NewState: func() state { newStateCalls++; return state{token: newStateCalls} }, + Selectors: []commandkit.Flag[input]{flag}, + CheckInput: func(selection ckconfig.Selection, value input) error { + if !value.campaign.Set || value.campaign.Value != "spring" { + t.Errorf("checked campaign = %#v", value.campaign) + } + if !selection.Config.Set || selection.Config.Value != "" || !selection.Overlay.Set || selection.Overlay.Value != "base.yml" { + t.Errorf("checked selection = %#v", selection) + } + return nil + }, + ListPrompts: func(_ context.Context, selection ckconfig.Selection, value input, gotState state) (commandkit.Acquired[[]promptkit.PromptIdentity], error) { + providerCalls++ + received, receivedSelection, receivedState = value, selection, gotState + return commandkit.Acquired[[]promptkit.PromptIdentity]{Value: []promptkit.PromptIdentity{{ID: "briefing", Version: "1"}}}, nil + }, + } + app := newPromptTestApp(t, Command(suite)) + var output bytes.Buffer + invalid := app.Execute(context.Background(), []string{"promptkit", "list", "prompts", "--format", "text"}, commandkit.Streams{Out: &output, Err: io.Discard}) + if invalid.Kind != commandkit.OutcomeUsage || newInputCalls != 0 || newStateCalls != 0 || providerCalls != 0 || output.Len() != 0 { + t.Fatalf("invalid format preflight = %#v, factories/providers=%d/%d/%d output=%q", invalid, newInputCalls, newStateCalls, providerCalls, output.String()) + } + + outcome := app.Execute(context.Background(), []string{"promptkit", "list", "prompts", "--config", "", "--overlay", "base.yml", "--campaign", "spring"}, commandkit.Streams{Out: &output, Err: io.Discard}) + if outcome.Kind != commandkit.OutcomeSuccess || newInputCalls != 1 || newStateCalls != 1 || providerCalls != 1 { + t.Fatalf("catalog invocation = %#v, factories/providers=%d/%d/%d output=%q", outcome, newInputCalls, newStateCalls, providerCalls, output.String()) + } + if !received.campaign.Set || received.campaign.Value != "spring" || !receivedSelection.Config.Set || receivedSelection.Config.Value != "" || receivedState.token != 1 { + t.Fatalf("provider values = input %#v selection %#v state %#v", received, receivedSelection, receivedState) + } +} + func TestCommandUsesInstalledNamespaceCollisionChecks(t *testing.T) { command := Command(Suite[struct{}, struct{}]{InspectPrompt: func(context.Context, PromptRequest, struct{}, struct{}) (commandkit.Acquired[*promptkit.PromptInspection], error) { return commandkit.Acquired[*promptkit.PromptInspection]{}, nil