Skip to content

feat(extensions): dispatch cu <word> to cu-<word> on PATH - #53

Open
timimsms wants to merge 2 commits into
mainfrom
feat/extension-dispatch
Open

timimsms wants to merge 2 commits into
mainfrom
feat/extension-dispatch

Conversation

@timimsms

Copy link
Copy Markdown
Owner

Summary

Implements the kubectl-style PATH passthrough promised in PROJECT_SPEC §4.3 and specified in the context-layer design §3.6 — the subset that does not depend on refs, aliases or packs, so it can land on its own.

Any executable named cu-<word> on PATH becomes cu <word>:

$ cu demo plan --flag x        # runs cu-demo
args: plan --flag x
CU_CONFIG_DIR=/Users/tim/.config/cu
CU_PROJECT_CONFIG=/path/to/.cu.yml
$ echo $?
42                              # the extension's own exit code

Guarantees

  • Builtins always win. Dispatch is attempted only for a word cobra itself could not resolve, so an extension can never shadow a cu command. Verified with a cu-version on PATH: cu version still runs the builtin, while cu-version remains runnable directly — the escape hatch §3.6 calls for.
  • No startup cost. No PATH scan; one exec.LookPath only once a word is known to be unresolvable.
  • Unknown words still get cobra's error. If no matching extension exists, dispatch falls through so you keep the did-you-mean suggestions.
  • Exit status is propagated unchanged.

The part most worth reviewing

Finding the command word has a sharp edge: cu --config x worklog must dispatch to worklog, not to x. Guessing "first argument not starting with -" gets that wrong. Flag arity is read from the flag set instead, so value-taking flags consume their value and boolean flags do not. An unknown flag is assumed not to take a value, so it cannot swallow the command word.

Everything after the word is forwarded verbatim — including flags that look like cu's own, since they belong to the extension — while cu's own preceding flags are not.

Environment handed to the child

CU_CONFIG_DIR, CU_WORKSPACE, CU_PROJECT_CONFIG, per §3.6, so an extension does not have to rediscover cu's context. Config is loaded explicitly here because dispatch happens before PersistentPreRunE; a load failure is non-fatal and simply means less context.

Not included

cu which (shadowing/non-executable warnings) belongs with the wider context-layer work, as do aliases, refs and packs. This is only §5's step 4.

Tests

TestFirstCommandWord covers bare words, long and shorthand flags with values in both --flag value and --flag=value forms, boolean flags, unknown flags, and --. TestArgsAfter covers forwarding and the non-forwarding of cu's own flags. TestConfigFileFrom includes a dangling --config so it cannot panic. TestTryExtensionLeavesBuiltinsAlone asserts resolvable builtins return normally rather than being routed out.

Manually verified: dispatch with args, a global flag before the word, builtin precedence, the direct-invocation escape hatch, CU_PROJECT_CONFIG inside a project, and exit-code propagation.

Checklist

  • ./scripts/ci.sh passes locally — except errcheck, which reports the same pre-existing findings on main, none in files this PR touches
  • Commit messages use conventional prefixes
  • CLI docs regenerated — no builtin help text changed, so no doc diff
  • Docs updated if user-facing behavior changed — README gains an Extensions section

timimsms and others added 2 commits September 22, 2026 23:31
Implements the kubectl-style passthrough promised in PROJECT_SPEC §4.3 and
specified in the context-layer design §3.6 — the subset that does not depend on
refs, aliases or packs, so it can land on its own.

Builtins always win: dispatch is attempted only for a word cobra itself could
not resolve, so an extension can never shadow a cu command, and a shadowed
extension is still runnable directly as cu-<word>. There is no PATH scan at
startup — a single exec.LookPath once a word is known to be unresolvable — so
the normal path costs nothing.

The command word is found by reading flag arity from the flag set rather than
guessing, so `cu --config x worklog` dispatches to worklog and not to x.
Everything after the word is forwarded verbatim, including flags that look like
cu's own; cu's own preceding flags are not.

The child inherits CU_CONFIG_DIR, CU_WORKSPACE and CU_PROJECT_CONFIG, and its
exit status is propagated unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014aqbmccWm1tqttmBUCR5rv
ClickUp: 86dxbeqyt
CI's gosec caught a real one. exec.LookPath treats any word containing a
separator as a *path* rather than a PATH search, so `cu ../../tmp/evil` would
have resolved and run an executable that was never on PATH at all.

Extension words are now constrained to ^[a-zA-Z0-9][a-zA-Z0-9_-]*$, so an
extension is found on PATH, by name, or not at all; anything else falls through
to cobra's unknown-command error. Verified live that ./evil and ../outside/evil
are both refused while normal dispatch still works.

The suppression comment was also wrong: it named G204 while the rule that fires
is G702 (taint analysis), so it never applied to begin with. It now names both
and explains why the call is safe rather than asserting it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014aqbmccWm1tqttmBUCR5rv
ClickUp: 86dxbeqyt

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant