From 7dce7b816b9666be6b5532c75f65c6499d47e237 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Mon, 12 Oct 2026 17:05:33 +0000 Subject: [PATCH] Add PromptKit requirements command --- docs/roadmap/implementation.md | 14 ++- integrations/ckprompt/command.go | 158 +++++++++++++++++++++++-- integrations/ckprompt/command_test.go | 164 +++++++++++++++++++++++++- 3 files changed, 322 insertions(+), 14 deletions(-) diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 5b98355..812b6b7 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -1,6 +1,6 @@ # Implement PromptKit requirements inspection -Status: in progress; Stages 1 and 2 are complete. +Status: in progress; Stages 1 through 3 are complete. ## Authority and working rules @@ -239,6 +239,18 @@ suite factories or loaders. Normal flag conversion still applies on help; do not add a special bypass for malformed variable occurrences. Namespace collision/root-placement enforcement stays with the existing core installer. +**Completed:** Added the optional `promptkit requirements show` command, typed +bindings, per-invocation variable assignment parsing, format preflight, and +buffered YAML delivery in `integrations/ckprompt/command.go`. Extended +`command_test.go` for requirements-only and all-capability trees, help, request +forwarding, selector/configuration presence, variable grammar, singleton +overrides, repeated executions, and usage preflight. Focused optional-family and +requirements command tests passed with `GOTOOLCHAIN=local go test +./integrations/ckprompt -run +'Test(CommandBuildsIndependentOptionalTrees|CatalogCommandTreesAreIndependentAndOmitShow|RequirementsCommandCapturesContextAndVariableAssignments|RequirementsCommandUsageFailuresPrecedeApplicationPreparation)$' +-count=1`; `GOTOOLCHAIN=local go test ./...` from `integrations/ckprompt` also +passed. No material limitations were encountered. + ## Stage 4: Verify native semantics, lifecycle, and failure delivery **Outcome:** the installed command demonstrably delegates discovery to PromptKit diff --git a/integrations/ckprompt/command.go b/integrations/ckprompt/command.go index dc79814..34e899d 100644 --- a/integrations/ckprompt/command.go +++ b/integrations/ckprompt/command.go @@ -31,18 +31,31 @@ type catalogInput[I any] struct { selected I } -// Command captures the optional PromptKit catalog and named inspection -// callbacks and adds only supplied operations, generating list and show groups -// independently. An absent suite claims no namespace; attached construction -// issues remain reportable. It adds no configuration family; callers compose -// ConfigScope independently. Construction, installation, help, and version do -// not run suite factories or providers. Suite documents invocation input and -// state initialization, zero values, and caller ownership of returned objects. +type requirementsInput[I any] struct { + selection ckconfig.Selection + inspection promptkit.RequirementInspectionRequest + format string + captured commandkit.FlagValues[I] + selected I +} + +type variableAssignment struct { + key string + value string +} + +// Command captures the optional PromptKit catalog, named inspection, and +// requirements inspection callbacks and adds only supplied operations. An +// absent suite claims no namespace; attached construction issues remain +// reportable. It adds no configuration family; callers compose ConfigScope +// independently. Construction, installation, help, and version do not run +// suite factories or providers. Suite documents invocation input and state +// initialization, zero values, and caller ownership of returned objects. // Catalog commands accept config/overlay selection and suite selectors, and // 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.ListPrompts == nil && suite.ListProfiles == nil { + if suite.InspectPrompt == nil && suite.InspectProfile == nil && suite.InspectRequirements == nil && suite.ListPrompts == nil && suite.ListProfiles == nil { command := commandkit.NoCommand() for _, issue := range suite.issues { command = commandkit.WithError(command, issue) @@ -51,7 +64,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) + rootCommands := make([]commandkit.Command, 0, 3) showCommands := make([]commandkit.Command, 0, 2) if suite.InspectPrompt != nil { showCommands = append(showCommands, newDefinitionCommand(suite, "prompt", true)) @@ -72,7 +85,10 @@ func Command[I, S any](suite Suite[I, S]) commandkit.Command { if len(listCommands) != 0 { rootCommands = append(rootCommands, commandkit.Group("list", "List PromptKit catalogs.", listCommands...)) } - command := commandkit.RootGroup("promptkit", "Inspect and list PromptKit definitions.", rootCommands...) + if suite.InspectRequirements != nil { + rootCommands = append(rootCommands, commandkit.Group("requirements", "Inspect prompt requirements.", newRequirementsCommand(suite))) + } + command := commandkit.RootGroup("promptkit", "Inspect and list PromptKit definitions and requirements.", rootCommands...) for _, issue := range suite.issues { command = commandkit.WithError(command, issue) } @@ -131,6 +147,13 @@ func checkCatalogFormat(format string) error { return nil } +func checkRequirementsFormat(format string) error { + if format != "yaml" { + return fmt.Errorf("unsupported requirements format %q; expected yaml", format) + } + return nil +} + func prepareCatalogInput[I, S any](suite Suite[I, S], input *catalogInput[I]) (I, error) { return prepareSuiteInput(suite, input.selection, input.captured) } @@ -186,6 +209,77 @@ func newDefinitionCommand[I, S any](suite Suite[I, S], name string, prompt bool) return commandkit.NewCommand(spec) } +func newRequirementsCommand[I, S any](suite Suite[I, S]) commandkit.Command { + flags := commandkit.CaptureFlags(suite.Selectors, func(input **requirementsInput[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 **requirementsInput[I], value commandkit.Supplied[string]) { + (*input).selection.Config = value + }), + commandkit.ValueFlag(commandkit.FlagMeta{Name: "overlay", Usage: "Configuration overlay path."}, (*string)(nil), parseDefinitionString, + func(input **requirementsInput[I], value commandkit.Supplied[string]) { + (*input).selection.Overlay = value + }), + commandkit.ValueFlag(commandkit.FlagMeta{Name: "version", Usage: "Prompt version to inspect."}, stringDefault(""), parseDefinitionString, + func(input **requirementsInput[I], value commandkit.Supplied[string]) { + (*input).inspection.PromptVersion = value.Value + }), + commandkit.ValueFlag(commandkit.FlagMeta{Name: "profile", Usage: "Profile identity for discovery."}, stringDefault(""), parseDefinitionString, + func(input **requirementsInput[I], value commandkit.Supplied[string]) { + (*input).inspection.ProfileID = value.Value + }), + commandkit.ValueFlag(commandkit.FlagMeta{Name: "session-id", Usage: "Session context for discovery."}, stringDefault(""), parseDefinitionString, + func(input **requirementsInput[I], value commandkit.Supplied[string]) { + (*input).inspection.SessionID = value.Value + }), + commandkit.ValueFlag(commandkit.FlagMeta{Name: "format", Usage: "Report format: yaml."}, stringDefault("yaml"), parseDefinitionString, + func(input **requirementsInput[I], value commandkit.Supplied[string]) { (*input).format = value.Value }), + commandkit.RepeatFlag(commandkit.FlagMeta{Name: "var", Usage: "Caller variable assignment KEY=VALUE."}, []variableAssignment(nil), parseVariableAssignment, + func(input **requirementsInput[I], value commandkit.Supplied[[]variableAssignment]) { + variables := make(map[string]string, len(value.Value)) + for _, assignment := range value.Value { + variables[assignment.key] = assignment.value + } + (*input).inspection.Vars = variables + }), + ) + + spec := commandkit.CommandSpec[*requirementsInput[I]]{ + Name: "show", + Summary: "Inspect requirements for a named PromptKit prompt.", + NewInput: func() *requirementsInput[I] { + return &requirementsInput[I]{} + }, + Flags: flags, + Arguments: []commandkit.Argument[*requirementsInput[I]]{ + commandkit.ValueArgument(commandkit.ArgumentMeta{Name: "id", Usage: "Prompt ID."}, true, parseDefinitionString, + func(input **requirementsInput[I], value commandkit.Supplied[string]) { + (*input).inspection.PromptID = value.Value + }), + }, + CheckInput: func(input *requirementsInput[I]) error { + if err := checkRequirementsFormat(input.format); err != nil { + return err + } + selected, err := prepareSuiteInput(suite, input.selection, input.captured) + if err != nil { + return err + } + input.selected = selected + return nil + }, + Run: func(invocation commandkit.Invocation, input *requirementsInput[I]) commandkit.Outcome { + return runRequirementsInspection(invocation, suite, input) + }, + } + return commandkit.NewCommand(spec) +} + func prepareDefinitionInput[I, S any](suite Suite[I, S], input *definitionInput[I]) (I, error) { return prepareSuiteInput(suite, input.selection, input.captured) } @@ -446,6 +540,42 @@ func profileReportFromInspection(inspection *promptkit.ProfileInspection) profil } } +func runRequirementsInspection[I, S any](invocation commandkit.Invocation, suite Suite[I, S], input *requirementsInput[I]) 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) + } + } + + request := RequirementsRequest{Selection: input.selection, Inspection: input.inspection} + acquired, err := suite.InspectRequirements(invocation.Context, request, input.selected, state) + if err != nil { + return definitionFailure(err, acquired.Diagnostics) + } + if acquired.Value == nil { + return definitionFailure(errors.New("PromptKit requirements inspection returned nil"), acquired.Diagnostics) + } + if invocation.Context != nil { + if err := invocation.Context.Err(); err != nil { + return definitionFailure(err, acquired.Diagnostics) + } + } + output, err := encodePromptYAMLBytes(requirementsReportFromInspection(acquired.Value)) + if err != nil { + return definitionFailure(err, acquired.Diagnostics) + } + return writePromptReport(invocation, output, acquired.Diagnostics) +} + func renderPromptReport(invocation commandkit.Invocation, report promptInspectionReport, format string, diagnostics []commandkit.Diagnostic) commandkit.Outcome { var output bytes.Buffer if format == "yaml" { @@ -581,4 +711,12 @@ func checkDefinitionFormat(format string) error { func parseDefinitionString(value string) (string, error) { return value, nil } +func parseVariableAssignment(raw string) (variableAssignment, error) { + key, value, found := strings.Cut(raw, "=") + if !found { + return variableAssignment{}, fmt.Errorf("variable assignment %q must contain =", raw) + } + return variableAssignment{key: key, value: value}, nil +} + func stringDefault(value string) *string { return &value } diff --git a/integrations/ckprompt/command_test.go b/integrations/ckprompt/command_test.go index 824dca5..309dde9 100644 --- a/integrations/ckprompt/command_test.go +++ b/integrations/ckprompt/command_test.go @@ -5,6 +5,7 @@ import ( "context" "errors" "io" + "reflect" "strings" "testing" @@ -29,7 +30,7 @@ func TestCommandBuildsIndependentOptionalTrees(t *testing.T) { }})) var output bytes.Buffer outcome := promptOnly.Execute(context.Background(), []string{"help", "promptkit", "show"}, commandkit.Streams{Out: &output, Err: io.Discard}) - if outcome.Kind != commandkit.OutcomeSuccess || !strings.Contains(output.String(), "\n prompt ") || strings.Contains(output.String(), "\n profile ") || strings.Contains(output.String(), "config") { + if outcome.Kind != commandkit.OutcomeSuccess || !strings.Contains(output.String(), "\n prompt ") || strings.Contains(output.String(), "\n profile ") || strings.Contains(output.String(), "requirements") || strings.Contains(output.String(), "config") { t.Fatalf("prompt-only help = (%#v, %q)", outcome, output.String()) } @@ -49,9 +50,21 @@ func TestCommandBuildsIndependentOptionalTrees(t *testing.T) { InspectProfile: func(context.Context, ProfileRequest, struct{}, struct{}) (commandkit.Acquired[*promptkit.ProfileInspection], error) { return commandkit.Acquired[*promptkit.ProfileInspection]{}, nil }, + InspectRequirements: func(context.Context, RequirementsRequest, struct{}, struct{}) (commandkit.Acquired[*promptkit.RequirementInspection], error) { + return commandkit.Acquired[*promptkit.RequirementInspection]{}, nil + }, + ListPrompts: func(context.Context, ckconfig.Selection, struct{}, struct{}) (commandkit.Acquired[[]promptkit.PromptIdentity], error) { + return commandkit.Acquired[[]promptkit.PromptIdentity]{}, nil + }, + ListProfiles: func(context.Context, ckconfig.Selection, struct{}, struct{}) (commandkit.Acquired[[]string], error) { + return commandkit.Acquired[[]string]{}, nil + }, }) - if err := newPromptTestApp(t).Register(both); err != nil { - t.Fatalf("both-capability tree failed to register: %v", err) + all := newPromptTestApp(t, both) + output.Reset() + outcome = all.Execute(context.Background(), []string{"help", "promptkit"}, commandkit.Streams{Out: &output, Err: io.Discard}) + if outcome.Kind != commandkit.OutcomeSuccess || !strings.Contains(output.String(), "show") || !strings.Contains(output.String(), "list") || !strings.Contains(output.String(), "requirements") { + t.Fatalf("all-capability tree = (%#v, %q)", outcome, output.String()) } app := newPromptTestApp(t, Command(Suite[struct{}, struct{}]{Selectors: []commandkit.Flag[struct{}]{{}}})) @@ -64,11 +77,156 @@ func TestCommandBuildsIndependentOptionalTrees(t *testing.T) { invalid := FromEngine[int, int](nil) invalid.ListPrompts, invalid.ListProfiles = nil, nil invalid.InspectPrompt, invalid.InspectProfile = nil, nil + invalid.InspectRequirements = 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 TestRequirementsCommandCapturesContextAndVariableAssignments(t *testing.T) { + type input struct{ campaign commandkit.Supplied[string] } + type state struct{ call int } + + campaignFlag := commandkit.ValueFlag(commandkit.FlagMeta{Name: "campaign", Usage: "Application campaign."}, (*string)(nil), parseDefinitionString, + func(target *input, value commandkit.Supplied[string]) { target.campaign = value }) + newInputCalls, checkInputCalls, newStateCalls, providerCalls := 0, 0, 0, 0 + var received RequirementsRequest + var receivedInput input + var receivedState state + suite := Suite[input, state]{ + NewInput: func() input { + newInputCalls++ + return input{} + }, + NewState: func() state { + newStateCalls++ + return state{call: newStateCalls} + }, + Selectors: []commandkit.Flag[input]{campaignFlag}, + CheckInput: func(selection ckconfig.Selection, value input) error { + checkInputCalls++ + if checkInputCalls == 1 { + if !selection.Config.Set || selection.Config.Value != "" || !selection.Overlay.Set || selection.Overlay.Value != "" { + t.Errorf("checked selection = %#v", selection) + } + if !value.campaign.Set || value.campaign.Value != "spring" { + t.Errorf("checked campaign = %#v", value.campaign) + } + } else if selection.Config.Set || selection.Overlay.Set || value.campaign.Set { + t.Errorf("second checked input retained prior invocation values: selection=%#v input=%#v", selection, value) + } + return nil + }, + InspectRequirements: func(_ context.Context, request RequirementsRequest, value input, gotState state) (commandkit.Acquired[*promptkit.RequirementInspection], error) { + providerCalls++ + received, receivedInput, receivedState = request, value, gotState + return commandkit.Acquired[*promptkit.RequirementInspection]{Value: &promptkit.RequirementInspection{ + PromptID: request.Inspection.PromptID, PromptVersion: request.Inspection.PromptVersion, + ProfileID: request.Inspection.ProfileID, Complete: true, + }}, nil + }, + } + app := newPromptTestApp(t, Command(suite)) + streams := commandkit.Streams{Out: io.Discard, Err: io.Discard} + var help bytes.Buffer + streams.Out = &help + helpOutcome := app.Execute(context.Background(), []string{"help", "promptkit", "requirements", "show"}, streams) + if helpOutcome.Kind != commandkit.OutcomeSuccess || !strings.Contains(help.String(), "id") { + t.Fatalf("requirements-only help = (%#v, %q)", helpOutcome, help.String()) + } + var familyHelp bytes.Buffer + familyOutcome := app.Execute(context.Background(), []string{"help", "promptkit"}, commandkit.Streams{Out: &familyHelp, Err: io.Discard}) + if familyOutcome.Kind != commandkit.OutcomeSuccess || !strings.Contains(familyHelp.String(), "requirements") || strings.Contains(familyHelp.String(), "\n show ") || strings.Contains(familyHelp.String(), "\n list ") { + t.Fatalf("requirements-only family help = (%#v, %q)", familyOutcome, familyHelp.String()) + } + for _, flag := range []string{"--config", "--overlay", "--version", "--profile", "--session-id", "--var", "--format", "--campaign"} { + if !strings.Contains(help.String(), flag) { + t.Errorf("requirements help omits %s: %q", flag, help.String()) + } + } + if strings.Contains(help.String(), "--scope") || newInputCalls != 0 || checkInputCalls != 0 || newStateCalls != 0 || providerCalls != 0 { + t.Fatalf("help selected a scope or invoked application code: output=%q factories/checks/providers=%d/%d/%d/%d", help.String(), newInputCalls, checkInputCalls, newStateCalls, providerCalls) + } + + var output bytes.Buffer + args := []string{ + "promptkit", "requirements", "show", "briefing", "--campaign", "spring", + "--config", "before.yml", "--config=", "--overlay", "base.yml", "--overlay=", + "--version", "1", "--version", "2", "--profile", "first", "--profile", "editorial", + "--session-id", "old-session", "--session-id", "session-42", "--format", "text", "--format", "yaml", + "--var", "note=hello, world", "--var", "empty=", "--var", "token=a=b", "--var", "=value", + "--var", "mode=brief", "--var", "mode=full", + } + outcome := app.Execute(context.Background(), args, commandkit.Streams{Out: &output, Err: io.Discard}) + if outcome.Kind != commandkit.OutcomeSuccess || outcome.Status != 0 { + t.Fatalf("requirements invocation = %#v, output=%q", outcome, output.String()) + } + if newInputCalls != 1 || checkInputCalls != 1 || newStateCalls != 1 || providerCalls != 1 { + t.Fatalf("first invocation calls = input/check/state/provider %d/%d/%d/%d", newInputCalls, checkInputCalls, newStateCalls, providerCalls) + } + if !received.Selection.Config.Set || received.Selection.Config.Value != "" || !received.Selection.Overlay.Set || received.Selection.Overlay.Value != "" { + t.Fatalf("configuration selection = %#v", received.Selection) + } + wantVariables := map[string]string{"note": "hello, world", "empty": "", "token": "a=b", "": "value", "mode": "full"} + if received.Inspection.PromptID != "briefing" || received.Inspection.PromptVersion != "2" || received.Inspection.ProfileID != "editorial" || received.Inspection.SessionID != "session-42" || !reflect.DeepEqual(received.Inspection.Vars, wantVariables) { + t.Fatalf("native request = %#v, want vars %#v", received.Inspection, wantVariables) + } + if !receivedInput.campaign.Set || receivedInput.campaign.Value != "spring" || receivedState.call != 1 { + t.Fatalf("provider input/state = %#v/%#v", receivedInput, receivedState) + } + var report map[string]any + if err := yaml.Unmarshal(output.Bytes(), &report); err != nil || report["prompt_id"] != "briefing" || report["complete"] != true { + t.Fatalf("default requirements YAML = %#v (decode error %v), output=%q", report, err, output.String()) + } + + output.Reset() + outcome = app.Execute(context.Background(), []string{"promptkit", "requirements", "show", "briefing"}, commandkit.Streams{Out: &output, Err: io.Discard}) + if outcome.Kind != commandkit.OutcomeSuccess || newInputCalls != 2 || checkInputCalls != 2 || newStateCalls != 2 || providerCalls != 2 { + t.Fatalf("second invocation = %#v, calls=%d/%d/%d/%d", outcome, newInputCalls, checkInputCalls, newStateCalls, providerCalls) + } + if len(received.Inspection.Vars) != 0 || received.Inspection.PromptVersion != "" || received.Inspection.ProfileID != "" || received.Inspection.SessionID != "" { + t.Fatalf("second invocation retained native values: %#v", received.Inspection) + } + if !strings.HasPrefix(output.String(), "prompt_id:") { + t.Fatalf("omitted format did not default to YAML: %q", output.String()) + } +} + +func TestRequirementsCommandUsageFailuresPrecedeApplicationPreparation(t *testing.T) { + for _, test := range []struct { + name string + args []string + }{ + {name: "missing operand", args: []string{"promptkit", "requirements", "show"}}, + {name: "extra operand", args: []string{"promptkit", "requirements", "show", "briefing", "extra"}}, + {name: "unsupported format", args: []string{"promptkit", "requirements", "show", "briefing", "--format", "text"}}, + {name: "malformed variable followed by valid assignment", args: []string{"promptkit", "requirements", "show", "briefing", "--var", "broken", "--var", "mode=full"}}, + {name: "no built-in scope", args: []string{"promptkit", "requirements", "show", "briefing", "--scope", "all"}}, + {name: "malformed variable on help", args: []string{"help", "promptkit", "requirements", "show", "--var", "broken"}}, + } { + t.Run(test.name, func(t *testing.T) { + newInputCalls, checkInputCalls, newStateCalls, providerCalls := 0, 0, 0, 0 + suite := Suite[struct{}, struct{}]{ + NewInput: func() struct{} { newInputCalls++; return struct{}{} }, + CheckInput: func(ckconfig.Selection, struct{}) error { + checkInputCalls++ + return nil + }, + NewState: func() struct{} { newStateCalls++; return struct{}{} }, + InspectRequirements: func(context.Context, RequirementsRequest, struct{}, struct{}) (commandkit.Acquired[*promptkit.RequirementInspection], error) { + providerCalls++ + return commandkit.Acquired[*promptkit.RequirementInspection]{Value: &promptkit.RequirementInspection{}}, nil + }, + } + var output bytes.Buffer + outcome := newPromptTestApp(t, Command(suite)).Execute(context.Background(), test.args, commandkit.Streams{Out: &output, Err: io.Discard}) + if outcome.Kind != commandkit.OutcomeUsage || outcome.Status != 2 || newInputCalls != 0 || checkInputCalls != 0 || newStateCalls != 0 || providerCalls != 0 { + t.Fatalf("usage failure = %#v, factories/checks/providers=%d/%d/%d/%d output=%q", outcome, newInputCalls, checkInputCalls, newStateCalls, providerCalls, output.String()) + } + }) + } +} + func TestCatalogCommandTreesAreIndependentAndOmitShow(t *testing.T) { prompts := Command(Suite[struct{}, struct{}]{ ListPrompts: func(context.Context, ckconfig.Selection, struct{}, struct{}) (commandkit.Acquired[[]promptkit.PromptIdentity], error) {