Add PromptKit catalog commands

This commit is contained in:
2026-10-12 02:17:54 +00:00
parent c75d371ecd
commit e4cdedd6db
3 changed files with 222 additions and 9 deletions

View File

@@ -1,6 +1,6 @@
# Implement PromptKit comparison and catalogs # 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 ## 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 catalog, invalid format stopping before factories, and catalog-only help omitting
`show`. Stage 8 completes evidence. No new core public API should be necessary. `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 ## Stage 8: Complete catalog invocation and presentation coverage
**Outcome:** observable catalog contracts have focused coverage without freezing **Outcome:** observable catalog contracts have focused coverage without freezing

View File

@@ -2,6 +2,7 @@ package ckprompt
import ( import (
"bytes" "bytes"
"context"
"errors" "errors"
"fmt" "fmt"
"io" "io"
@@ -23,13 +24,20 @@ type definitionInput[I any] struct {
selected I selected I
} }
// Command captures the optional named PromptKit inspection tree and adds only type catalogInput[I any] struct {
// the inspection capabilities present in suite. It adds no configuration 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 // family; callers compose ConfigScope independently. Construction and
// installation do not run suite callbacks. Suite documents invocation input // installation do not run suite callbacks. Suite documents invocation input
// and state initialization, zero values, and caller ownership of returned objects. // and state initialization, zero values, and caller ownership of returned objects.
func Command[I, S any](suite Suite[I, S]) commandkit.Command { 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() command := commandkit.NoCommand()
for _, issue := range suite.issues { for _, issue := range suite.issues {
command = commandkit.WithError(command, issue) 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...) suite.Selectors = append([]commandkit.Flag[I](nil), suite.Selectors...)
rootCommands := make([]commandkit.Command, 0, 2)
showCommands := make([]commandkit.Command, 0, 2) showCommands := make([]commandkit.Command, 0, 2)
if suite.InspectPrompt != nil { if suite.InspectPrompt != nil {
showCommands = append(showCommands, newDefinitionCommand(suite, "prompt", true)) 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 { if suite.InspectProfile != nil {
showCommands = append(showCommands, newDefinitionCommand(suite, "profile", false)) showCommands = append(showCommands, newDefinitionCommand(suite, "profile", false))
} }
command := commandkit.RootGroup("promptkit", "Inspect named PromptKit definitions.", if len(showCommands) != 0 {
commandkit.Group("show", "Show a named PromptKit definition.", showCommands...), 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 { for _, issue := range suite.issues {
command = commandkit.WithError(command, issue) command = commandkit.WithError(command, issue)
} }
return command 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 { 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] { flags := commandkit.CaptureFlags(suite.Selectors, func(input **definitionInput[I]) *commandkit.FlagValues[I] {
if input == nil || *input == nil { 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) { 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 var selected I
if suite.NewInput != nil { if suite.NewInput != nil {
selected = suite.NewInput() 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 return selected, err
} }
if suite.CheckInput != nil { 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, err
} }
} }
return selected, nil 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 { func runDefinitionInspection[I, S any](invocation commandkit.Invocation, suite Suite[I, S], input *definitionInput[I], prompt bool) commandkit.Outcome {
if invocation.Context != nil { if invocation.Context != nil {
if err := invocation.Context.Err(); err != nil { if err := invocation.Context.Err(); err != nil {

View File

@@ -62,12 +62,89 @@ func TestCommandBuildsIndependentOptionalTrees(t *testing.T) {
} }
invalid := FromEngine[int, int](nil) invalid := FromEngine[int, int](nil)
invalid.ListPrompts, invalid.ListProfiles = nil, nil
invalid.InspectPrompt, invalid.InspectProfile = 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") { 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) 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) { func TestCommandUsesInstalledNamespaceCollisionChecks(t *testing.T) {
command := Command(Suite[struct{}, struct{}]{InspectPrompt: func(context.Context, PromptRequest, struct{}, struct{}) (commandkit.Acquired[*promptkit.PromptInspection], error) { command := Command(Suite[struct{}, struct{}]{InspectPrompt: func(context.Context, PromptRequest, struct{}, struct{}) (commandkit.Acquired[*promptkit.PromptInspection], error) {
return commandkit.Acquired[*promptkit.PromptInspection]{}, nil return commandkit.Acquired[*promptkit.PromptInspection]{}, nil