From 504ea035746ff7498388482bfbbec89f00c6a390 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Sat, 17 Oct 2026 18:45:39 +0000 Subject: [PATCH] Add filtered PromptKit catalog commands --- docs/roadmap/implementation.md | 10 ++- integrations/ckprompt/command.go | 111 +++++++++++++++++++++++--- integrations/ckprompt/command_test.go | 88 ++++++++++++++++++++ 3 files changed, 195 insertions(+), 14 deletions(-) diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 36d69c1..a419739 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -1,6 +1,6 @@ # Dependency upgrades, discovery, and smoke testing implementation plan -Status: Stages 1–6 complete; Stage 7 is next. +Status: Stages 1–7 complete; Stage 8 is next. ## Authority and baseline @@ -423,6 +423,14 @@ filter conversion, and collection optional versions; run focused catalog tests. **Complete when:** the accepted catalog CLI and reports invoke the intended callbacks through the standard binding system. +**Completed:** 2026-10-17. `promptkit list` now installs each supplied catalog +independently. Prompts, profiles, and backends bind namespace and all-namespace +filters; namespaces bind repeatable native resource kinds; collections use +their unfiltered operation and project optional release versions. Added +capturing-callback coverage for filter mapping, false and repeated values, +collection YAML presence, and conflicting filter preflight. The full ckprompt +suite and `GOTOOLCHAIN=local go build ./...` passed. + ## Stage 8: Verify discovery availability, lifetimes, and outcomes **Primary files:** focused ckprompt catalog command/adapter tests. diff --git a/integrations/ckprompt/command.go b/integrations/ckprompt/command.go index 9f7379e..efa5a4e 100644 --- a/integrations/ckprompt/command.go +++ b/integrations/ckprompt/command.go @@ -24,10 +24,14 @@ type definitionInput[I any] struct { } type catalogInput[I any] struct { - selection ckconfig.Selection - format string - captured commandkit.FlagValues[I] - selected I + selection ckconfig.Selection + namespace commandkit.Supplied[string] + allNamespaces commandkit.Supplied[bool] + kinds []promptkit.ResourceKind + filter promptkit.NamespaceFilter + format string + captured commandkit.FlagValues[I] + selected I } type requirementsInput[I any] struct { @@ -48,7 +52,7 @@ type inputAssignment struct { path string } -// Command captures the optional PromptKit catalog, named inspection, +// Command captures the optional PromptKit catalogs, named inspection, // requirements discovery, and concrete request validation callbacks and adds // only supplied operations. Discovery and validation share the // `requirements` group and remain independently optional. An absent suite @@ -63,7 +67,7 @@ type inputAssignment struct { // render YAML with `yaml` as the only format and default. Named-show formats are // unchanged. func Command[I, S any](suite Suite[I, S]) commandkit.Command { - if suite.InspectPrompt == nil && suite.InspectProfile == nil && suite.InspectRequirements == nil && suite.ValidateRequest == nil && suite.ListPrompts == nil && suite.ListProfiles == nil { + if suite.InspectPrompt == nil && suite.InspectProfile == nil && suite.InspectRequirements == nil && suite.ValidateRequest == nil && suite.ListPrompts == nil && suite.ListProfiles == nil && suite.ListBackends == nil && suite.ListNamespaces == nil && suite.ListCollections == nil { command := commandkit.NoCommand() for _, issue := range suite.issues { command = commandkit.WithError(command, issue) @@ -83,12 +87,21 @@ func Command[I, S any](suite Suite[I, S]) commandkit.Command { if len(showCommands) != 0 { rootCommands = append(rootCommands, commandkit.Group("show", "Show a named PromptKit definition.", showCommands...)) } - listCommands := make([]commandkit.Command, 0, 2) + listCommands := make([]commandkit.Command, 0, 5) if suite.ListPrompts != nil { - listCommands = append(listCommands, newCatalogListCommand(suite, "prompts", true)) + listCommands = append(listCommands, newCatalogListCommand(suite, "prompts")) } if suite.ListProfiles != nil { - listCommands = append(listCommands, newCatalogListCommand(suite, "profiles", false)) + listCommands = append(listCommands, newCatalogListCommand(suite, "profiles")) + } + if suite.ListBackends != nil { + listCommands = append(listCommands, newCatalogListCommand(suite, "backends")) + } + if suite.ListNamespaces != nil { + listCommands = append(listCommands, newCatalogListCommand(suite, "namespaces")) + } + if suite.ListCollections != nil { + listCommands = append(listCommands, newCatalogListCommand(suite, "collections")) } if len(listCommands) != 0 { rootCommands = append(rootCommands, commandkit.Group("list", "List PromptKit catalogs.", listCommands...)) @@ -110,7 +123,7 @@ func Command[I, S any](suite Suite[I, S]) commandkit.Command { return command } -func newCatalogListCommand[I, S any](suite Suite[I, S], name string, prompts bool) commandkit.Command { +func newCatalogListCommand[I, S any](suite Suite[I, S], name string) commandkit.Command { flags := commandkit.CaptureFlags(suite.Selectors, func(input **catalogInput[I]) *commandkit.FlagValues[I] { if input == nil || *input == nil { return nil @@ -125,6 +138,23 @@ func newCatalogListCommand[I, S any](suite Suite[I, S], name string, prompts boo 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 }), ) + if name == "prompts" || name == "profiles" || name == "backends" { + flags = append(flags, + commandkit.ValueFlag(commandkit.FlagMeta{Name: "namespace", Usage: "Select one native namespace."}, (*string)(nil), parseDefinitionString, + func(input **catalogInput[I], value commandkit.Supplied[string]) { (*input).namespace = value }), + commandkit.BoolFlag(commandkit.FlagMeta{Name: "all-namespaces", Usage: "Select identities from every namespace."}, false, + func(input **catalogInput[I], value commandkit.Supplied[bool]) { (*input).allNamespaces = value }), + ) + } + if name == "namespaces" { + flags = append(flags, + commandkit.RepeatFlag(commandkit.FlagMeta{Name: "kind", Usage: "Resource kind to include."}, []promptkit.ResourceKind(nil), + func(value string) (promptkit.ResourceKind, error) { return promptkit.ResourceKind(value), nil }, + func(input **catalogInput[I], value commandkit.Supplied[[]promptkit.ResourceKind]) { + (*input).kinds = value.Value + }), + ) + } return commandkit.NewCommand(commandkit.CommandSpec[*catalogInput[I]]{ Name: name, Summary: "List PromptKit " + name + ".", @@ -139,6 +169,9 @@ func newCatalogListCommand[I, S any](suite Suite[I, S], name string, prompts boo if err := checkCatalogFormat(input.format); err != nil { return err } + if err := prepareCatalogFilter(input, name); err != nil { + return err + } selected, err := prepareCatalogInput(suite, input) if err != nil { return err @@ -147,14 +180,53 @@ func newCatalogListCommand[I, S any](suite Suite[I, S], name string, prompts boo return nil }, Run: func(invocation commandkit.Invocation, input *catalogInput[I]) commandkit.Outcome { - if prompts { + switch name { + case "prompts": return runCatalogList(invocation, suite, input, suite.ListPrompts, encodePromptCatalog) + case "profiles": + return runCatalogList(invocation, suite, input, suite.ListProfiles, encodeProfileCatalog) + case "backends": + return runCatalogList(invocation, suite, input, suite.ListBackends, encodeProfileCatalog) + case "namespaces": + list := func(ctx context.Context, request CatalogRequest, value I, state S) (commandkit.Acquired[[]string], error) { + return suite.ListNamespaces(ctx, NamespaceRequest{Selection: request.Selection, Kinds: input.kinds}, value, state) + } + return runCatalogList(invocation, suite, input, list, encodeProfileCatalog) + case "collections": + list := func(ctx context.Context, request CatalogRequest, value I, state S) (commandkit.Acquired[[]promptkit.CollectionInfo], error) { + return suite.ListCollections(ctx, request.Selection, value, state) + } + return runCatalogList(invocation, suite, input, list, encodeCollectionCatalog) + default: + return definitionFailure(fmt.Errorf("unknown PromptKit catalog %q", name), nil) } - return runCatalogList(invocation, suite, input, suite.ListProfiles, encodeProfileCatalog) }, }) } +func prepareCatalogFilter[I any](input *catalogInput[I], name string) error { + input.filter = promptkit.NamespaceFilter{Mode: promptkit.NamespaceDefault} + if name != "prompts" && name != "profiles" && name != "backends" { + return nil + } + if input.allNamespaces.Set && input.allNamespaces.Value && input.namespace.Set { + return errors.New("--namespace cannot be combined with --all-namespaces") + } + if input.allNamespaces.Set && input.allNamespaces.Value { + input.filter.Mode = promptkit.NamespaceAll + return nil + } + if input.namespace.Set { + if input.namespace.Value == "" { + input.filter.Mode = promptkit.NamespaceUnqualified + } else { + input.filter.Mode = promptkit.NamespaceNamed + input.filter.Name = input.namespace.Value + } + } + return nil +} + func checkCatalogFormat(format string) error { if format != "yaml" { return fmt.Errorf("unsupported catalog format %q", format) @@ -328,7 +400,7 @@ func runCatalogList[T any, I, S any]( } } - acquired, err := list(invocation.Context, CatalogRequest{Selection: input.selection}, input.selected, state) + acquired, err := list(invocation.Context, CatalogRequest{Selection: input.selection, Filter: input.filter}, input.selected, state) if err != nil { return definitionFailure(err, acquired.Diagnostics) } @@ -675,6 +747,19 @@ func encodeProfileCatalog(profiles []string) ([]byte, error) { return encodePromptYAMLBytes(profiles) } +type collectionCatalogEntry struct { + Namespace string `yaml:"namespace"` + Version *string `yaml:"version,omitempty"` +} + +func encodeCollectionCatalog(collections []promptkit.CollectionInfo) ([]byte, error) { + entries := make([]collectionCatalogEntry, len(collections)) + for index, collection := range collections { + entries[index] = collectionCatalogEntry{Namespace: collection.Namespace, Version: collection.ReleaseVersion} + } + return encodePromptYAMLBytes(entries) +} + func encodePromptYAMLBytes(value any) ([]byte, error) { var output bytes.Buffer if err := encodePromptYAML(&output, value); err != nil { diff --git a/integrations/ckprompt/command_test.go b/integrations/ckprompt/command_test.go index e78c6d1..49370cf 100644 --- a/integrations/ckprompt/command_test.go +++ b/integrations/ckprompt/command_test.go @@ -275,6 +275,94 @@ func TestCatalogCommandTreesAreIndependentAndOmitShow(t *testing.T) { } } +func TestDiscoveryCommandsForwardFiltersAndRenderCollections(t *testing.T) { + var promptRequest, profileRequest, backendRequest CatalogRequest + var namespaceRequest NamespaceRequest + var collectionSelection ckconfig.Selection + providers := 0 + suite := Suite[struct{}, struct{}]{ + ListPrompts: func(_ context.Context, request CatalogRequest, _, _ struct{}) (commandkit.Acquired[[]promptkit.PromptIdentity], error) { + providers++ + promptRequest = request + return commandkit.Acquired[[]promptkit.PromptIdentity]{Value: []promptkit.PromptIdentity{{ID: "team/briefing", Version: testStringPointer("")}}}, nil + }, + ListProfiles: func(_ context.Context, request CatalogRequest, _, _ struct{}) (commandkit.Acquired[[]string], error) { + providers++ + profileRequest = request + return commandkit.Acquired[[]string]{Value: []string{"default"}}, nil + }, + ListBackends: func(_ context.Context, request CatalogRequest, _, _ struct{}) (commandkit.Acquired[[]string], error) { + providers++ + backendRequest = request + return commandkit.Acquired[[]string]{Value: []string{"local"}}, nil + }, + ListNamespaces: func(_ context.Context, request NamespaceRequest, _, _ struct{}) (commandkit.Acquired[[]string], error) { + providers++ + namespaceRequest = request + return commandkit.Acquired[[]string]{Value: []string{"team", "root"}}, nil + }, + ListCollections: func(_ context.Context, selection ckconfig.Selection, _, _ struct{}) (commandkit.Acquired[[]promptkit.CollectionInfo], error) { + providers++ + collectionSelection = selection + return commandkit.Acquired[[]promptkit.CollectionInfo]{Value: []promptkit.CollectionInfo{ + {Namespace: "team", ReleaseVersion: testStringPointer("v1")}, + {Namespace: "empty-version", ReleaseVersion: testStringPointer("")}, + {Namespace: "unversioned"}, + }}, nil + }, + } + app := newPromptTestApp(t, Command(suite)) + run := func(args ...string) (commandkit.Outcome, string) { + t.Helper() + var output bytes.Buffer + outcome := app.Execute(context.Background(), args, commandkit.Streams{Out: &output, Err: io.Discard}) + return outcome, output.String() + } + + outcome, output := run("promptkit", "list", "prompts", "--namespace", "first", "--namespace", "team", "--all-namespaces=false", "--config", "selected.yml") + if outcome.Kind != commandkit.OutcomeSuccess || promptRequest.Filter != (promptkit.NamespaceFilter{Mode: promptkit.NamespaceNamed, Name: "team"}) || !promptRequest.Selection.Config.Set || promptRequest.Selection.Config.Value != "selected.yml" { + t.Fatalf("prompt list outcome/request = (%#v, %#v), output=%q", outcome, promptRequest, output) + } + var promptRows []map[string]any + if err := yaml.Unmarshal([]byte(output), &promptRows); err != nil || len(promptRows) != 1 || promptRows[0]["id"] != "team/briefing" || promptRows[0]["version"] != "" { + t.Fatalf("prompt catalog YAML = %#v, error=%v, output=%q", promptRows, err, output) + } + + outcome, _ = run("promptkit", "list", "profiles", "--namespace", "") + if outcome.Kind != commandkit.OutcomeSuccess || profileRequest.Filter.Mode != promptkit.NamespaceUnqualified || profileRequest.Filter.Name != "" { + t.Fatalf("profile list outcome/filter = (%#v, %#v)", outcome, profileRequest.Filter) + } + outcome, _ = run("promptkit", "list", "backends", "--all-namespaces") + if outcome.Kind != commandkit.OutcomeSuccess || backendRequest.Filter.Mode != promptkit.NamespaceAll { + t.Fatalf("backend list outcome/filter = (%#v, %#v)", outcome, backendRequest.Filter) + } + outcome, output = run("promptkit", "list", "namespaces", "--kind", "prompt", "--kind", "backend") + if outcome.Kind != commandkit.OutcomeSuccess || !reflect.DeepEqual(namespaceRequest.Kinds, []promptkit.ResourceKind{promptkit.ResourcePrompt, promptkit.ResourceBackend}) { + t.Fatalf("namespace list outcome/kinds = (%#v, %#v)", outcome, namespaceRequest.Kinds) + } + var namespaces []string + if err := yaml.Unmarshal([]byte(output), &namespaces); err != nil || !reflect.DeepEqual(namespaces, []string{"team", "root"}) { + t.Fatalf("namespace YAML = %#v, error=%v, output=%q", namespaces, err, output) + } + outcome, output = run("promptkit", "list", "collections", "--config", "collections.yml") + if outcome.Kind != commandkit.OutcomeSuccess || !collectionSelection.Config.Set || collectionSelection.Config.Value != "collections.yml" { + t.Fatalf("collection list outcome/selection = (%#v, %#v)", outcome, collectionSelection) + } + var collections []map[string]any + if err := yaml.Unmarshal([]byte(output), &collections); err != nil || len(collections) != 3 || collections[0]["namespace"] != "team" || collections[0]["version"] != "v1" || collections[1]["version"] != "" || collections[2]["version"] != nil { + t.Fatalf("collection YAML = %#v, error=%v, output=%q", collections, err, output) + } + + beforeConflict := providers + outcome, _ = run("promptkit", "list", "backends", "--namespace", "team", "--all-namespaces") + if outcome.Kind != commandkit.OutcomeUsage || providers != beforeConflict { + t.Fatalf("conflicting namespace filters = %#v, provider calls %d -> %d", outcome, beforeConflict, providers) + } + if providers != 5 { + t.Fatalf("catalog providers called = %d, want five", providers) + } +} + func TestCatalogListUsesPreparedInputAndPreflightsFormat(t *testing.T) { type input struct{ campaign commandkit.Supplied[string] } type state struct{ token int }