diff --git a/docs/cli.md b/docs/cli.md index fc43a1d..9b95ab6 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -245,7 +245,7 @@ Both commands require one ID operand and accept `--config`, `--overlay`, and `--format text|yaml` (default `text`). Prompt inspection also accepts singleton `--version`, defaulting to the empty string. PromptKit owns version selection: an omitted version works for a single-version ID, while an ambiguous ID is an -operational error. These commands do not accept `--scope`; their namespace is +operational error. These commands have no built-in `--scope` selection; their namespace is `promptkit`. Prompt YAML has these fields. Input order is preserved and an empty input list is diff --git a/docs/roadmap/remediation.md b/docs/roadmap/remediation.md index a25a2a3..8e019c8 100644 --- a/docs/roadmap/remediation.md +++ b/docs/roadmap/remediation.md @@ -69,6 +69,9 @@ architecture. The issues below affect behavior within the implemented structure. - R-08 complete: unknown scopes and absent default app scopes report available choices in standard scope order without runtime acquisition. +- R-09 complete: PromptKit selector collisions are checked against installed + standard flags; profile-only `version` and custom `scope` selectors are usable. + ## Findings and priority P1 means a supported invocation is blocked or its report content is corrupted. diff --git a/promptkit.go b/promptkit.go index 48fd5b9..2efadca 100644 --- a/promptkit.go +++ b/promptkit.go @@ -128,6 +128,10 @@ func RegisterPromptKit[C any](app *App, suite PromptKitSuite[C]) error { func normalizePromptKitSuite[C any](suite PromptKitSuite[C]) (PromptKitSuite[C], error) { normalized := suite normalized.Selectors = append([]Selector[C](nil), suite.Selectors...) + standardNames := map[string]bool{"help": true} + for _, flag := range promptKitInputFlags(PromptKitSuite[C]{}, suite.InspectPrompt != nil) { + standardNames[flag.binding.meta.Name] = true + } seenNames := make(map[string]struct{}, len(normalized.Selectors)) seenShorts := make(map[string]struct{}, len(normalized.Selectors)) for i, selector := range normalized.Selectors { @@ -139,8 +143,7 @@ func normalizePromptKitSuite[C any](suite PromptKitSuite[C]) (PromptKitSuite[C], if !validFlagName(meta.Name) { return PromptKitSuite[C]{}, fmt.Errorf("PromptKit selector flag name %q is invalid", meta.Name) } - switch meta.Name { - case "config", "overlay", "scope", "format", "version", "help": + if standardNames[meta.Name] { return PromptKitSuite[C]{}, fmt.Errorf("PromptKit selector flag --%s conflicts with a standard flag", meta.Name) } if meta.Short == "h" { diff --git a/promptkit_test.go b/promptkit_test.go index 5c8e3b0..29dc497 100644 --- a/promptkit_test.go +++ b/promptkit_test.go @@ -404,3 +404,47 @@ func sortedKeys(values map[string]any) []string { } return keys } + +func TestPromptKitSelectorNamesCollideOnlyWithInstalledFlags(t *testing.T) { + for _, name := range []string{"version", "scope"} { + var captured commandkit.Supplied[string] + suite := commandkit.PromptKitSuite[commandkit.Supplied[string]]{ + Selectors: []commandkit.Selector[commandkit.Supplied[string]]{{Binding: commandkit.ValueFlag[commandkit.Supplied[string]](commandkit.FlagMeta{Name: name}, nil, func(s string) (string, error) { return s, nil }, func(c *commandkit.Supplied[string], v commandkit.Supplied[string]) { *c = v })}}, + InspectProfile: func(_ context.Context, _ commandkit.Selection, c commandkit.Supplied[string], _ string) (commandkit.Acquired[*promptkit.ProfileInspection], error) { + captured = c + return commandkit.Acquired[*promptkit.ProfileInspection]{Value: &promptkit.ProfileInspection{}}, nil + }, + } + app, _ := commandkit.New(commandkit.Identity{Name: "example"}) + if err := commandkit.RegisterPromptKit(app, suite); err != nil { + t.Fatalf("%s: %v", name, err) + } + result := app.Execute(context.Background(), []string{"promptkit", "show", "profile", "example", "--" + name, "selected"}, commandkit.Streams{}) + if result.Kind != commandkit.OutcomeSuccess || captured != (commandkit.Supplied[string]{Value: "selected", Set: true}) { + t.Fatalf("%s: result=%#v captured=%#v", name, result, captured) + } + suite.InspectPrompt = func(context.Context, commandkit.Selection, commandkit.Supplied[string], string, string) (commandkit.Acquired[*promptkit.PromptInspection], error) { + return commandkit.Acquired[*promptkit.PromptInspection]{Value: &promptkit.PromptInspection{}}, nil + } + other, _ := commandkit.New(commandkit.Identity{Name: "example"}) + err := commandkit.RegisterPromptKit(other, suite) + if name == "version" && err == nil { + t.Fatal("accepted collision with installed prompt version flag") + } + if name == "scope" && err != nil { + t.Fatalf("scope selector conflicts with no installed flag: %v", err) + } + } + for _, name := range []string{"config", "overlay", "format", "help"} { + app, _ := commandkit.New(commandkit.Identity{Name: "example"}) + suite := commandkit.PromptKitSuite[struct{}]{ + InspectProfile: func(context.Context, commandkit.Selection, struct{}, string) (commandkit.Acquired[*promptkit.ProfileInspection], error) { + return commandkit.Acquired[*promptkit.ProfileInspection]{}, nil + }, + Selectors: []commandkit.Selector[struct{}]{{Binding: commandkit.ValueFlag[struct{}](commandkit.FlagMeta{Name: name}, nil, func(s string) (string, error) { return s, nil }, func(*struct{}, commandkit.Supplied[string]) {})}}, + } + if err := commandkit.RegisterPromptKit(app, suite); err == nil { + t.Fatalf("accepted standard flag collision --%s", name) + } + } +}