Add PromptKit catalog commands
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user