diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 71e20b4..07215e8 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -1,6 +1,6 @@ # Implement PromptKit comparison and catalogs -Status: Stages 1–4 complete; remaining stages are planned. +Status: Stages 1–5 complete; remaining stages are planned. ## Scope and authority @@ -324,6 +324,22 @@ selection/input/state forwarding, load diagnostics/errors, and cancellation. Retain named-inspection tests. Custom empty catalogs and presentation belong to Stage 8; do not retest PromptKit's complete membership semantics. +**Completion evidence (2026-10-12)** + +- Added the selected optional `ListPrompts` and `ListProfiles` callbacks to + `ckprompt.Suite` with the accepted signatures, and updated its declaration and + `FromEngine` documentation to cover catalogs alongside named inspection. +- `FromEngine` now loads an engine only when a catalog callback is reached, + delegates to PromptKit's native `ListPrompts` and `ListProfiles`, and returns + the native ordered results with acquisition diagnostics. +- Adapter tests compare both results with the same engine's public native + methods and cover context/selection/input/state forwarding, ConfigKit load + diagnostic and error identity retention, cancellation after loading, and a + nil-loader issue after all four callbacks are cleared. Existing named + inspection coverage remains in place. +- `GOTOOLCHAIN=local go test -count=1 .` passed in `integrations/ckprompt`. + Family assembly and catalog presentation remain assigned to Stages 7 and 8. + ## Stage 6: Implement the catalog YAML representation **Outcome:** small private helpers encode the explicitly selected CLI schema. diff --git a/integrations/ckprompt/adapters.go b/integrations/ckprompt/adapters.go index 51e2344..5c498f6 100644 --- a/integrations/ckprompt/adapters.go +++ b/integrations/ckprompt/adapters.go @@ -31,8 +31,9 @@ type ProfileRequest struct { ID string } -// Suite describes application-owned input, shared state, and named inspection -// capabilities. Its private issues retain adapter construction failures. +// Suite describes application-owned input, shared state, and optional catalog +// and named inspection capabilities. Its private issues retain adapter +// construction failures. // Factory results and callback effects remain caller-owned: Command does not // deep-copy arbitrary objects or repair aliases returned by factories. // Factories are not invoked during construction, installation, help, or version, @@ -45,14 +46,22 @@ type Suite[I, S any] struct { // NewState is called once when inspection is reached, after input checks // pass. If nil, state is the zero value of S, including nil for pointer types. NewState func() S - // Selectors declares the original typed flags accepted by definition commands. + // Selectors declares the original typed flags accepted by definition and + // catalog commands. Selectors []commandkit.Flag[I] - // CheckInput validates the selected definition inputs before state creation. + // CheckInput validates selected definition or catalog input before state + // creation. CheckInput func(ckconfig.Selection, I) error // InspectPrompt inspects the selected prompt after input and state preparation. InspectPrompt func(context.Context, PromptRequest, I, S) (commandkit.Acquired[*promptkit.PromptInspection], error) // InspectProfile inspects the selected profile after input and state preparation. InspectProfile func(context.Context, ProfileRequest, I, S) (commandkit.Acquired[*promptkit.ProfileInspection], error) + // ListPrompts lists prompt identities after input and state preparation. + // It is optional; a nil callback does not install a prompt catalog command. + ListPrompts func(context.Context, ckconfig.Selection, I, S) (commandkit.Acquired[[]promptkit.PromptIdentity], error) + // ListProfiles lists profile IDs after input and state preparation. + // It is optional; a nil callback does not install a profile catalog command. + ListProfiles func(context.Context, ckconfig.Selection, I, S) (commandkit.Acquired[[]string], error) issues []error } @@ -130,17 +139,34 @@ func promptKitContextError(ctx context.Context) error { return ctx.Err() } -// FromEngine creates named prompt and profile inspection callbacks backed by a -// PromptKit engine loader. It does not call the loader during construction. Each -// selected inspection passes its context, supplied selection, initialized input, -// and shared invocation state to the loader before calling PromptKit's public -// inspection method. A nil loader is retained as an adapter issue. +// FromEngine creates prompt and profile catalog and inspection callbacks backed +// by a PromptKit engine loader. It does not call the loader during construction. +// Each selected operation passes its context, supplied selection, initialized +// input, and shared invocation state to the loader before calling PromptKit's +// public listing or inspection method. A nil loader is retained as an adapter +// issue. func FromEngine[I, S any](load func(context.Context, ckconfig.Selection, I, S) (*promptkit.Engine, error)) Suite[I, S] { if load == nil { return Suite[I, S]{issues: []error{errors.New("PromptKit engine loader is nil")}} } return Suite[I, S]{ + ListPrompts: func(ctx context.Context, selection ckconfig.Selection, input I, state S) (commandkit.Acquired[[]promptkit.PromptIdentity], error) { + engine, _, diagnostics, err := loadPromptKitEngine(ctx, selection, input, state, "", load) + if err != nil { + return commandkit.Acquired[[]promptkit.PromptIdentity]{Diagnostics: diagnostics}, err + } + prompts, err := engine.ListPrompts(ctx) + return commandkit.Acquired[[]promptkit.PromptIdentity]{Value: prompts, Diagnostics: diagnostics}, err + }, + ListProfiles: func(ctx context.Context, selection ckconfig.Selection, input I, state S) (commandkit.Acquired[[]string], error) { + engine, _, diagnostics, err := loadPromptKitEngine(ctx, selection, input, state, "", load) + if err != nil { + return commandkit.Acquired[[]string]{Diagnostics: diagnostics}, err + } + profiles, err := engine.ListProfiles(ctx) + return commandkit.Acquired[[]string]{Value: profiles, Diagnostics: diagnostics}, err + }, InspectPrompt: func(ctx context.Context, request PromptRequest, input I, state S) (commandkit.Acquired[*promptkit.PromptInspection], error) { engine, _, diagnostics, err := loadPromptKitEngine(ctx, request.Selection, input, state, "", load) if err != nil { diff --git a/integrations/ckprompt/adapters_test.go b/integrations/ckprompt/adapters_test.go index f4a4396..222bf35 100644 --- a/integrations/ckprompt/adapters_test.go +++ b/integrations/ckprompt/adapters_test.go @@ -311,6 +311,8 @@ func TestFromEnginePassesRequestsAndRetainsNilLoaderIssue(t *testing.T) { } empty := FromEngine[int, int](nil) + empty.ListPrompts = nil + empty.ListProfiles = nil empty.InspectPrompt = nil empty.InspectProfile = nil if len(empty.issues) != 1 || !strings.Contains(empty.issues[0].Error(), "PromptKit engine loader is nil") { @@ -318,6 +320,116 @@ func TestFromEnginePassesRequestsAndRetainsNilLoaderIssue(t *testing.T) { } } +func TestFromEngineListsNativeCatalogsWithInvocationValues(t *testing.T) { + type input struct{ label string } + type state struct{ label string } + type contextKey struct{} + + engine := adapterEngine(t) + wantContext := context.WithValue(context.Background(), contextKey{}, "catalog invocation") + wantSelection := ckconfig.Selection{Config: commandkit.Supplied[string]{Value: "selected.yml", Set: true}} + wantInput := input{label: "typed input"} + wantState := state{label: "shared state"} + loaderCalls := 0 + suite := FromEngine(func(ctx context.Context, selection ckconfig.Selection, gotInput input, gotState state) (*promptkit.Engine, error) { + loaderCalls++ + if ctx.Value(contextKey{}) != "catalog invocation" || selection != wantSelection || gotInput != wantInput || gotState != wantState { + t.Errorf("loader args = ctx=%v selection=%#v input=%#v state=%#v", ctx.Value(contextKey{}), selection, gotInput, gotState) + } + return engine, nil + }) + if suite.ListPrompts == nil || suite.ListProfiles == nil { + t.Fatalf("FromEngine did not supply both catalog callbacks: %#v", suite) + } + wantPrompts, err := engine.ListPrompts(wantContext) + if err != nil { + t.Fatal(err) + } + wantProfiles, err := engine.ListProfiles(wantContext) + if err != nil { + t.Fatal(err) + } + prompts, err := suite.ListPrompts(wantContext, wantSelection, wantInput, wantState) + if err != nil || !reflect.DeepEqual(prompts.Value, wantPrompts) || len(prompts.Diagnostics) != 0 || loaderCalls != 1 { + t.Fatalf("prompt catalog = (%#v, %v), calls=%d, want %#v", prompts, err, loaderCalls, wantPrompts) + } + profiles, err := suite.ListProfiles(wantContext, wantSelection, wantInput, wantState) + if err != nil || !reflect.DeepEqual(profiles.Value, wantProfiles) || len(profiles.Diagnostics) != 0 || loaderCalls != 2 { + t.Fatalf("profile catalog = (%#v, %v), calls=%d, want %#v", profiles, err, loaderCalls, wantProfiles) + } +} + +func TestFromEngineCatalogsRetainLoadDiagnosticsAndErrorIdentity(t *testing.T) { + _, loadErr := configkit.Load[map[string]any](configkit.Bytes("invalid catalog configuration", []byte("broken: ["))) + if loadErr == nil { + t.Fatal("invalid configuration unexpectedly loaded") + } + suite := FromEngine[int, int](func(context.Context, ckconfig.Selection, int, int) (*promptkit.Engine, error) { + return nil, loadErr + }) + for _, test := range []struct { + name string + call func() (int, error) + }{ + {name: "prompts", call: func() (int, error) { + acquired, err := suite.ListPrompts(context.Background(), ckconfig.Selection{}, 0, 0) + var retained *configkit.LoadError + if !errors.As(err, &retained) || retained != loadErr || len(acquired.Diagnostics) == 0 { + t.Errorf("prompt load failure = (%#v, %v)", acquired, err) + } + return len(acquired.Diagnostics), err + }}, + {name: "profiles", call: func() (int, error) { + acquired, err := suite.ListProfiles(context.Background(), ckconfig.Selection{}, 0, 0) + var retained *configkit.LoadError + if !errors.As(err, &retained) || retained != loadErr || len(acquired.Diagnostics) == 0 { + t.Errorf("profile load failure = (%#v, %v)", acquired, err) + } + return len(acquired.Diagnostics), err + }}, + } { + t.Run(test.name, func(t *testing.T) { + diagnosticCount, err := test.call() + if !errors.Is(err, loadErr) || diagnosticCount == 0 { + t.Fatalf("catalog failure = (%d diagnostics, %v)", diagnosticCount, err) + } + }) + } +} + +func TestFromEngineCatalogsHonorCancellationAfterLoad(t *testing.T) { + engine := adapterEngine(t) + loaderCalls := 0 + for _, test := range []struct { + name string + call func(Suite[int, int], context.Context) error + }{ + {name: "prompts", call: func(suite Suite[int, int], ctx context.Context) error { + _, err := suite.ListPrompts(ctx, ckconfig.Selection{}, 0, 0) + return err + }}, + {name: "profiles", call: func(suite Suite[int, int], ctx context.Context) error { + _, err := suite.ListProfiles(ctx, ckconfig.Selection{}, 0, 0) + return err + }}, + } { + t.Run(test.name, func(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancelSuite := FromEngine(func(_ context.Context, _ ckconfig.Selection, _, _ int) (*promptkit.Engine, error) { + loaderCalls++ + cancel() + return engine, nil + }) + if err := test.call(cancelSuite, ctx); !errors.Is(err, context.Canceled) { + t.Fatalf("catalog cancellation = %v", err) + } + }) + } + if loaderCalls != 2 { + t.Fatalf("catalog loaders called = %d, want one per provider", loaderCalls) + } +} + func TestAdapterAcquisitionPreservesLoadAndContextErrors(t *testing.T) { loadErr := errors.New("loader failure") suite := FromEngine[int, int](func(context.Context, ckconfig.Selection, int, int) (*promptkit.Engine, error) {