Files
pad/internal/cmdhelp/dynamic.go
T
xarmian e6fd25322e feat(cmdhelp): dynamic enum resolution + workspace context (TASK-936) (#329)
* feat(cmdhelp): dynamic enum resolution + workspace context (TASK-936)

The killer differentiator from the cmdhelp v0.1 spec — splice live
workspace facts into help output so an LLM asking "what collections
exist?" gets the real answer rather than a generic "any string".

Files:
- internal/cmdhelp/dynamic.go (new) — Resolver type with Apply method.
  ArgEnumSources / FlagEnumSources map names to enum_source identifiers;
  Sources maps enum_source to a fetcher func. Apply walks the Document,
  stamps enum_source on matching args/flags, populates Enum from the
  fetcher, and sets doc.Context.Workspace. Per-Apply caching keeps each
  source func to ≤1 invocation regardless of how many commands need it.
- internal/cmdhelp/json.go — added Options.Resolver; Build calls
  Resolver.Apply after the static walk, so callers that inspect Build
  output as either pre- or post-resolution still work.
- cmd/pad/help_cmdhelp.go — newDynamicResolver constructs a Resolver
  bound to the runtime: workspace from DetectWorkspace, server URL from
  config, three sources (collections, roles, members). Returns nil when
  no workspace is detected so help still works outside any workspace.
  cmdhelpOptions takes target so Binary derives from root.Name() instead
  of hardcoded "pad" (preserves test-tree bindings for synthetic roots).

Pad-side bindings (matches `pad item create --help`'s existing context):
  arg  collection → dynamic:pad collection list
  flag role       → dynamic:pad role list
  flag assign     → dynamic:pad workspace members

End-to-end on the real binary (inside docapp workspace):
  pad help item create --format json
  → args[0].collection: type=enum, enum=[ideas,conventions,...,roadmap],
                        enum_source="dynamic:pad collection list"
  → flags.role:    enum=[planner,implementer,reviewer]
  → flags.assign:  enum=[dave]
  → context.workspace="docapp"
  pad help --format md → "## Workspace context\n- workspace: `docapp`"

Outside any workspace (cd /tmp; pad help item create --format json):
  → collection arg: type=string, no enum, no enum_source (graceful fallback)
  → context: null
  → output still validates against schema/cmdhelp.schema.json

Fail-safe behavior:
- newDynamicResolver returns nil on any config/detection error → static doc.
- Per-source fetcher errors are caught inside Apply → enum_source still
  announced on the binding arg/flag, but Enum is left empty. The help
  command MUST NOT fail because dynamic facts can't be fetched.
- Existing Enum values from alternation/ValidArgs are preserved
  (resolver only fills the gap, never overwrites authoritative spec).

Tests:
- 10 dynamic-resolver tests in internal/cmdhelp/dynamic_test.go
  covering: arg + flag enum population, context population, per-source
  caching across multiple commands, graceful error handling, nil
  resolver as no-op, existing-Enum preservation, global flag resolution,
  unaffected commands left unchanged, end-to-end via Build().
- All 24 prior cmdhelp tests + 12 routing tests still green.
- make check clean (lint + go test + web build).

Out of scope (deferred):
- --capabilities discovery flag — TASK-937.
- Schema-validate live output in CI — TASK-938.
- Audit pad's existing commands' Examples — TASK-939.

Parent: PLAN-930.

* fix(cmdhelp): scope --role / --assign bindings per-command per Codex review (round 1)

Codex round 1 on PR #329 caught a real semantic bug: globally binding
--role to dynamic:pad role list was wrong because pad has two
unrelated --role flags:

  pad workspace invite --role           workspace role: owner|editor|viewer
  pad item create     --role <slug>    agent role slug
  pad item update     --role <slug>    agent role slug

Globally announcing agent-role slugs as the values for `pad workspace
invite --role` would mislead consumers (LLMs would suggest "planner"
when "owner" is expected; tab-completion would offer the wrong set).

Fix:

- Resolver gains CommandArgBindings and CommandFlagBindings
  (map[path]map[name]source) — scoped to a specific command path.
  Per-command bindings win over wildcard ArgEnumSources/FlagEnumSources
  when both match.
- Helper methods argSource(path,name) / flagSource(path,name) own the
  precedence rule so both args and flags use it consistently.
- newDynamicResolver in cmd/pad keeps `<collection>` as a wildcard
  ArgEnumSources (universal — every <collection> in pad means a pad
  collection), but moves --role and --assign into CommandFlagBindings
  scoped to "item create", "item update", and "item list". `pad
  workspace invite --role` is intentionally left without a binding.
- A header comment in newDynamicResolver enumerates every --role /
  --assign site in the CLI and which one each binding targets, so a
  future reviewer adding a new flag can see the rule at a glance.

End-to-end on the real binary:
  pad help item create --format json
  → flags.role: type=enum, enum=[planner,implementer,reviewer], enum_source=...
  pad help workspace invite --format json
  → flags.role: type=string (untouched). ✓

New tests:

- TestResolver_Apply_PerCommandBindingScoped — explicitly mirrors the
  Codex finding: two commands both have a `role` flag, only the bound
  command resolves. workspace-invite-style isolation regression test.
- TestResolver_Apply_PerCommandWinsOverWildcard — precedence: when
  both wildcard and per-command match, per-command wins.
- TestResolver_Apply_PerCommandArgBindings — same precedence rule
  for positional args.

All 33 cmdhelp tests + 12 routing tests still green; make check clean.

* fix(cmdhelp): bind item list --role to agent roles per Codex review (round 2)

Codex round 2 caught that item list --role was still unbound — I missed
it in round 1's grep because the variable name is `&roleFilter` rather
than `&roleFlag`. Pad has 4 --role flags total:

  pad workspace invite --role     workspace role (NOT bound)
  pad item create      --role     agent role slug (bound)
  pad item update      --role     agent role slug (bound)
  pad item list        --role     agent role filter (now bound)

Fix: extend CommandFlagBindings["item list"] to include the same
itemRoleAssign map as item create/update, so all three item subcommands
that reference an agent role get the dynamic binding.

Added a `grep` recipe in the comment so a future maintainer adding a
new --role / --assign site can find every existing one in one shot
(both `&roleFlag` and `&roleFilter` style declarations).

End-to-end on real binary (inside docapp workspace):
  pad help item list --format json
  → flags.role: enum=[planner,implementer,reviewer], enum_source set ✓
  → flags.assign: enum=[dave], enum_source set ✓

make check clean.
2026-05-01 01:27:06 -04:00

207 lines
6.9 KiB
Go

package cmdhelp
import "strings"
// Canonical enum_source identifiers for the dynamic facts pad's CLI
// can splice into help output. Format: "dynamic:<command>" per spec §7.
//
// New tools adding cmdhelp may declare their own dynamic sources;
// these constants are pad-flavored and live here so the cli layer and
// tests reference them via stable names.
const (
EnumSourceCollections = "dynamic:pad collection list"
EnumSourceRoles = "dynamic:pad role list"
EnumSourceMembers = "dynamic:pad workspace members"
)
// DynamicEnum resolves a single enum_source to its current values.
// Implementations close over a CLI client; see cmd/pad/help_cmdhelp.go
// for pad's wiring.
//
// Returning (nil, error) is treated as "no values available" — the
// document still announces enum_source so consumers know the binding,
// but Enum is left empty. This is the right behavior when the server
// is unreachable or auth is missing: the help command MUST NOT fail
// just because dynamic facts can't be fetched.
type DynamicEnum func() ([]interface{}, error)
// Resolver maps known arg/flag names to dynamic enum sources, plus the
// resolver functions that fetch live values for each source. It's
// passed via Options.Resolver and applied by Build after the static
// document is constructed.
//
// All maps use lowercase keys; lookups are case-insensitive.
//
// Two binding scopes are supported:
//
// 1. **Wildcard** (ArgEnumSources / FlagEnumSources) — applied to
// every command. Use only when the arg/flag name has a unique
// semantic across the entire CLI (e.g. an `<collection>` arg
// always refers to a pad collection regardless of which command
// declares it).
//
// 2. **Per-command** (CommandArgBindings / CommandFlagBindings) —
// scoped to a specific command path. Use whenever the same name
// can mean different things in different places — for example
// `--role` accepts agent-role slugs on item commands but accepts
// workspace roles (owner / editor / viewer) on `workspace invite`.
//
// Per-command bindings win over wildcards when both match.
type Resolver struct {
// Workspace, when non-empty, populates doc.Context.Workspace so
// markdown's `## Workspace context` section has something to render.
Workspace string
// ArgEnumSources binds positional arg names to enum_source globally.
// Example: {"collection": EnumSourceCollections}.
ArgEnumSources map[string]string
// FlagEnumSources binds flag names to enum_source globally.
// Use cautiously: a single name often means different things on
// different commands. Prefer CommandFlagBindings unless you've
// verified the name is unambiguous across the entire CLI.
FlagEnumSources map[string]string
// CommandArgBindings: per-command-path overrides for positional args.
// Outer key is the command path (e.g. "item create"); inner key is
// arg name. Wins over ArgEnumSources when both match.
CommandArgBindings map[string]map[string]string
// CommandFlagBindings: per-command-path overrides for flags.
// Outer key is the command path (e.g. "item create"); inner key is
// flag name. Wins over FlagEnumSources when both match.
CommandFlagBindings map[string]map[string]string
// Sources maps enum_source string → resolver function. Functions
// are called at most once per Apply call (results cached internally).
Sources map[string]DynamicEnum
}
// argSource looks up the enum_source for a positional arg on the
// given command path. Returns ("", false) when no binding applies.
func (r *Resolver) argSource(path, name string) (string, bool) {
name = strings.ToLower(name)
if specific, ok := r.CommandArgBindings[path][name]; ok {
return specific, true
}
if global, ok := r.ArgEnumSources[name]; ok {
return global, true
}
return "", false
}
// flagSource looks up the enum_source for a flag on the given command
// path. Returns ("", false) when no binding applies.
func (r *Resolver) flagSource(path, name string) (string, bool) {
name = strings.ToLower(name)
if specific, ok := r.CommandFlagBindings[path][name]; ok {
return specific, true
}
if global, ok := r.FlagEnumSources[name]; ok {
return global, true
}
return "", false
}
// Apply walks doc and stamps EnumSource + resolved Enum values on
// matching args and flags. Resolution failures are silent: the document
// continues to advertise enum_source even when no values are available,
// so consumers can fall back to invoking the dynamic command themselves.
//
// Apply is a no-op when r is nil — callers may pass nil from
// Options.Resolver to disable dynamic resolution entirely.
func (r *Resolver) Apply(doc *Document) {
if r == nil || doc == nil {
return
}
// Per-Apply cache: each enum_source resolved at most once even when
// many commands reference it (e.g. every `item` subcommand has a
// `collection` arg).
cache := make(map[string][]interface{}, len(r.Sources))
resolve := func(src string) []interface{} {
if cached, ok := cache[src]; ok {
return cached
}
fn, ok := r.Sources[src]
if !ok {
cache[src] = nil
return nil
}
values, err := fn()
if err != nil {
values = nil
}
cache[src] = values
return values
}
// Global flags: only the wildcard FlagEnumSources applies — there's
// no command path to scope a per-command binding against.
for name, f := range doc.GlobalFlags {
src, ok := r.FlagEnumSources[strings.ToLower(name)]
if !ok {
continue
}
f.EnumSource = src
values := resolve(src)
if len(values) > 0 && len(f.Enum) == 0 {
f.Enum = values
if f.Type == "string" {
f.Type = "enum"
}
}
doc.GlobalFlags[name] = f
}
for path, cmd := range doc.Commands {
// Args: in-place via index since cmd.Args is a slice value
// inside the map's struct value.
for i := range cmd.Args {
src, ok := r.argSource(path, cmd.Args[i].Name)
if !ok {
continue
}
cmd.Args[i].EnumSource = src
values := resolve(src)
// Don't replace existing Enum values from alternation /
// ValidArgs — those are the authoritative spec for that arg
// and dynamic resolution is only meant to fill the gap.
if len(values) > 0 && len(cmd.Args[i].Enum) == 0 {
cmd.Args[i].Enum = values
// Upgrade type from generic string → enum, but preserve
// any non-string type that was set deliberately upstream.
if cmd.Args[i].Type == "string" {
cmd.Args[i].Type = "enum"
}
}
}
// Flags: map values must be re-stored after mutation (Go map
// values are not addressable).
for name, f := range cmd.Flags {
src, ok := r.flagSource(path, name)
if !ok {
continue
}
f.EnumSource = src
values := resolve(src)
if len(values) > 0 && len(f.Enum) == 0 {
f.Enum = values
if f.Type == "string" {
f.Type = "enum"
}
}
cmd.Flags[name] = f
}
doc.Commands[path] = cmd
}
// Workspace context for markdown's ## Workspace context section.
if r.Workspace != "" {
if doc.Context == nil {
doc.Context = &Context{}
}
doc.Context.Workspace = r.Workspace
}
}