|
| 1 | +# `ctx system` Unknown-Subcommand Verbatim Relay |
| 2 | + |
| 3 | +## Problem |
| 4 | + |
| 5 | +`ctx system <unknown>` prints the `system` group's ~51-line Long |
| 6 | +help and exits **0**. Cobra's default `legacyArgs` raises an |
| 7 | +"unknown command" error only for the *root* command |
| 8 | +(`!cmd.HasParent()`); for a non-root grouping command like |
| 9 | +`ctx system`, leftover args fall through and — because the group |
| 10 | +has no `Run`/`RunE` — cobra calls `Help()` and returns `nil`. |
| 11 | + |
| 12 | +In a Claude Code `UserPromptSubmit` hook, exit 0 is read by the |
| 13 | +harness as "hook success", so the entire help blob is injected |
| 14 | +into the agent's context **on every prompt**. This is exactly the |
| 15 | +failure mode that the version-skew bug rode in on: a stale |
| 16 | +`hooks.json` wired `ctx system check-anchor-drift` after the |
| 17 | +binary deleted that command, and instead of a visible error the |
| 18 | +agent silently ate 51 lines of help each turn (see |
| 19 | +`specs/experiments/acdl-session-start.md` §Root Cause, Bug #2). |
| 20 | + |
| 21 | +A non-zero exit code **alone** does not fix this: in a |
| 22 | +`UserPromptSubmit` hook a non-zero exit with help suppressed is |
| 23 | +swallowed by the harness — the user/agent sees nothing. The signal |
| 24 | +has to travel on the channel the harness actually surfaces: hook |
| 25 | +**stdout**. So the fix must *emit a message* (not just fail) when |
| 26 | +`ctx system` is handed a verb it does not recognise. |
| 27 | + |
| 28 | +The companion build-time guard already shipped |
| 29 | +(`specs/hooks-wiring-guard.md`, |
| 30 | +`TestShippedHooksResolveToRegisteredCommands`): it stops *our own* |
| 31 | +package from shipping a hooks.json that names a missing verb. This |
| 32 | +spec covers the **runtime** half — making a skew that reaches a |
| 33 | +user's machine (old plugin, new binary, or vice versa) fail |
| 34 | +*loud and legible* instead of silent. |
| 35 | + |
| 36 | +## Approach |
| 37 | + |
| 38 | +Give the `ctx system` command — and only that command — a `RunE` |
| 39 | +that intercepts the unknown-subcommand case: |
| 40 | + |
| 41 | +- **Bare `ctx system`** (no args): print help, exit 0. Unchanged |
| 42 | + behavior. |
| 43 | +- **`ctx system <verb>` where `<verb>` is a real subcommand**: |
| 44 | + cobra descends into it and runs *its* `RunE`; `system`'s own |
| 45 | + `RunE` is never reached. Unchanged behavior. |
| 46 | +- **`ctx system <unknown>`**: cobra finds no matching subcommand, |
| 47 | + so the leftover args reach `system`'s `RunE`. The handler emits a |
| 48 | + verbatim-relay box to stdout naming the unknown verb and hinting |
| 49 | + at the likely cause (plugin/binary version skew), sets |
| 50 | + `SilenceUsage`, and returns a non-nil error → exit non-zero. When |
| 51 | + a real session ID is present on stdin (i.e. the command was run |
| 52 | + by a hook), it *also* records a relay event — event-log append |
| 53 | + then webhook — via the same `nudge.Relay` path the `check-*` |
| 54 | + hooks use, so the skew is captured for out-of-band/autonomous |
| 55 | + alerting, not just the live session. |
| 56 | + |
| 57 | +The behavior attaches to `system.Cmd()` after it is built by the |
| 58 | +shared `parent.Cmd(...)`. The shared `parent.Cmd` is **not** |
| 59 | +changed: every other group (`ctx hook`, …) keeps cobra's current |
| 60 | +default. Scope is `ctx system` only, because that is the only group |
| 61 | +wired into `hooks.json` and therefore the only one whose skew |
| 62 | +pollutes a session. |
| 63 | + |
| 64 | +Why `RunE` and not `Args: cobra.NoArgs`: `NoArgs` would surface |
| 65 | +cobra's *generic* "unknown command" error on stderr and still emit |
| 66 | +nothing to stdout — the harness would swallow it just like a bare |
| 67 | +non-zero exit. We need a controlled message on stdout, which a |
| 68 | +`RunE` gives us. |
| 69 | + |
| 70 | +`ctx system` is registered `Hidden` (group.go `hiddenCmds`), so |
| 71 | +`RootCmd`'s `PersistentPreRunE` early-returns for it |
| 72 | +(`if cmd.Hidden { return nil }`) — adding a `RunE` does **not** |
| 73 | +subject `ctx system` to the context-dir / init / git |
| 74 | +preconditions. Verify this holds during implementation. |
| 75 | + |
| 76 | +## Behavior |
| 77 | + |
| 78 | +### Happy Path |
| 79 | + |
| 80 | +1. A stale `hooks.json` (or a typo) runs |
| 81 | + `ctx system check-anchor-drift`. |
| 82 | +2. Cobra finds no `check-anchor-drift` subcommand; the arg falls |
| 83 | + through to `system`'s `RunE` as `args = ["check-anchor-drift"]`. |
| 84 | +3. The handler renders a verbatim-relay box to **stdout**: |
| 85 | + - names the unknown subcommand, |
| 86 | + - states the likely cause (a hook referencing a command this |
| 87 | + binary no longer ships — version skew between the installed |
| 88 | + plugin's `hooks.json` and the on-PATH `ctx` binary), |
| 89 | + - gives the recovery (align plugin and binary to the same |
| 90 | + release). |
| 91 | +4. Best-effort: the handler reads the session ID from stdin |
| 92 | + (`session.ReadID`, which is TTY-safe and timeout-guarded). If a |
| 93 | + real session ID is present, it calls `nudge.Relay` to append a |
| 94 | + relay event to the local event log and fire the relay webhook |
| 95 | + (log-first: webhook only on a successful append). |
| 96 | +5. The handler sets `cmd.SilenceUsage = true` and returns a |
| 97 | + non-nil error. |
| 98 | +6. The harness surfaces the stdout box to the user/agent; the |
| 99 | + non-zero exit makes the failure debuggable instead of disguised |
| 100 | + as success; the event-log/webhook record gives out-of-band |
| 101 | + visibility. No 51-line help dump. |
| 102 | + |
| 103 | +### Edge Cases |
| 104 | + |
| 105 | +| Case | Expected behavior | |
| 106 | +|------|-------------------| |
| 107 | +| Bare `ctx system` (no args) | Print help, exit 0. Unchanged. | |
| 108 | +| Valid hidden subcommand (`ctx system bootstrap`) | Routes to the subcommand; `system`'s `RunE` not invoked. Unchanged. | |
| 109 | +| Valid subcommand + bad flag (`ctx system heartbeat --nope`) | Cobra's flag parsing on the *subcommand* errors as today; `system`'s `RunE` not involved. | |
| 110 | +| Multiple leftover args (`ctx system foo bar`) | Treated as unknown; relay names the first token (`foo`). Exit non-zero. | |
| 111 | +| Unknown verb that is a prefix of a real one (`ctx system check`) | No exact match → unknown; relay names `check`. (Cobra prefix-matching is not enabled.) | |
| 112 | +| `ctx system --help` | Cobra's help flag short-circuits before `RunE`; prints help, exit 0. Unchanged. | |
| 113 | +| Manual `ctx system typo` at a terminal (stdin is a TTY) | `session.ReadID` detects the char device and returns `IDUnknown` immediately — **no hang**. Stdout box + exit non-zero; relay leg (event log + webhook) is skipped (no session context to attribute it to). | |
| 114 | +| Hook stdin present but blank/garbage session JSON | `ReadID` returns `IDUnknown` within the 2s timeout; relay leg skipped; stdout box + exit non-zero still happen. | |
| 115 | +| Hook runs the dead command under a non-`UserPromptSubmit` event | Stdout may not be injected for every event, but the non-zero exit + stderr error + event-log/webhook relay still beat a silent exit-0 help dump; no regression. | |
| 116 | + |
| 117 | +### Validation Rules |
| 118 | + |
| 119 | +The "unknown" determination is cobra's, not ours: if `system`'s |
| 120 | +`RunE` runs with `len(args) > 0`, cobra has already failed to match |
| 121 | +a subcommand. The handler treats any non-empty `args` as an unknown |
| 122 | +subcommand and `args[0]` as its name. No additional parsing. |
| 123 | + |
| 124 | +### Error Handling |
| 125 | + |
| 126 | +| Error condition | User-facing message (stdout verbatim box) | Recovery | |
| 127 | +|-----------------|-------------------------------------------|----------| |
| 128 | +| `ctx system <unknown>` | Verbatim-relay box: `ctx system: unknown subcommand "<verb>". A Claude Code hook is likely calling a ctx command this binary no longer ships (version skew between the installed plugin and the on-PATH ctx binary). Align the plugin and binary to the same release.` | Update plugin/binary to matching versions; or fix the hook command. | |
| 129 | + |
| 130 | +The returned error (stderr, via `main.go`'s `writeErr`; cobra's |
| 131 | +own printing stays silenced by `RootCmd`'s `SilenceErrors`) is a |
| 132 | +terse companion to the stdout box — it carries the unknown verb so |
| 133 | +a human reading logs sees the cause without the help dump. |
| 134 | + |
| 135 | +## Interface |
| 136 | + |
| 137 | +### CLI |
| 138 | + |
| 139 | +No new command or flag. Changes the behavior of the existing |
| 140 | +(hidden) `ctx system` grouping command when invoked with an |
| 141 | +unrecognised subcommand. |
| 142 | + |
| 143 | +``` |
| 144 | +ctx system <unknown> # was: help + exit 0 ; now: verbatim relay + exit 1 |
| 145 | +ctx system # unchanged: help, exit 0 |
| 146 | +ctx system bootstrap # unchanged: runs bootstrap |
| 147 | +``` |
| 148 | + |
| 149 | +## Implementation |
| 150 | + |
| 151 | +### Files to Create/Modify |
| 152 | + |
| 153 | +| File | Change | |
| 154 | +|------|--------| |
| 155 | +| `internal/cli/system/system.go` | After `parent.Cmd(...)`, set `c.RunE` to the unknown-subcommand handler. Keep grouping construction via `parent.Cmd`. | |
| 156 | +| `internal/cli/system/core/.../unknown.go` (new, e.g. `core/unknown/`) | Handler: render the verbatim box, set `SilenceUsage`, return the error. Keeps `system.go` thin. | |
| 157 | +| `internal/err/<system>` | New `UnknownSubcommand(verb string) error` constructor. Locate the existing system/CLI error file rather than creating a package. | |
| 158 | +| `internal/assets/commands/text/*.yaml` + `internal/config/embed/text` | New `DescKey` for the relay box title and body text (no hardcoded user-facing strings, per convention). | |
| 159 | +| `internal/cli/system/*_test.go` | Tests per Testing section. | |
| 160 | + |
| 161 | +### Key Functions |
| 162 | + |
| 163 | +```go |
| 164 | +// in internal/cli/system/system.go (sketch) |
| 165 | +c := parent.Cmd(cmd.DescKeySystem, cmd.UseSystem, /* subs... */) |
| 166 | +c.RunE = unknown.Handler // name TBD |
| 167 | +return c |
| 168 | + |
| 169 | +// in the new handler (sketch) |
| 170 | +func Handler(cmd *cobra.Command, args []string) error { |
| 171 | + if len(args) == 0 { |
| 172 | + return cmd.Help() // bare `ctx system`: unchanged |
| 173 | + } |
| 174 | + verb := args[0] |
| 175 | + box := message.NudgeBox(relayPrefix, title, bodyFor(verb)) |
| 176 | + fmt.Fprintln(cmd.OutOrStdout(), box) |
| 177 | + |
| 178 | + // Best-effort relay leg: only when a hook supplied a session. |
| 179 | + // ReadID is TTY-safe and timeout-guarded, so a manual typo at a |
| 180 | + // terminal returns IDUnknown without blocking. |
| 181 | + if sid := session.ReadID(os.Stdin); sid != cfgSession.IDUnknown { |
| 182 | + ref := entity.NewTemplateRef(hook.System, variantUnknownSub, nil) |
| 183 | + if relayErr := nudge.Relay(relayMsgFor(verb), sid, ref); relayErr != nil { |
| 184 | + logWarn.Warn(warn.RelayUnknownSubcommand, relayErr) // log, don't mask |
| 185 | + } |
| 186 | + } |
| 187 | + |
| 188 | + cmd.SilenceUsage = true // do NOT re-dump help on error |
| 189 | + return errSystem.UnknownSubcommand(verb) |
| 190 | +} |
| 191 | +``` |
| 192 | + |
| 193 | +Note: a relay-leg failure is logged via the existing warn path and |
| 194 | +does **not** change the returned error — the user's actual problem |
| 195 | +is the unknown subcommand, and that is what the exit reflects. The |
| 196 | +hook name/variant for the `TemplateRef` (`hook.System` / |
| 197 | +`variantUnknownSub`) may need new constants; reuse existing ones if |
| 198 | +a suitable pair already exists. |
| 199 | + |
| 200 | +### Helpers to Reuse |
| 201 | + |
| 202 | +- `message.NudgeBox(relayPrefix, title, content)` — the same |
| 203 | + verbatim-box framing the `check-*` hooks use |
| 204 | + (`internal/cli/system/core/message`). |
| 205 | +- `session.ReadID(os.Stdin)` — TTY-safe, timeout-guarded session-ID |
| 206 | + read from hook stdin; returns `cfgSession.IDUnknown` when absent |
| 207 | + (`internal/cli/system/core/session`). |
| 208 | +- `nudge.Relay(msg, sessionID, ref)` — log-first event-log append + |
| 209 | + relay webhook (`internal/cli/system/core/nudge`). |
| 210 | +- `entity.NewTemplateRef(hook, variant, vars)` — relay event ref for |
| 211 | + filtering/aggregation. |
| 212 | +- `desc.Text(...)` — load box title/body from assets, not literals. |
| 213 | +- `parent.Cmd` — still builds the grouping command; only the `RunE` |
| 214 | + is added on top. |
| 215 | + |
| 216 | +## Configuration |
| 217 | + |
| 218 | +None. No `.ctxrc` keys, env vars, or settings. |
| 219 | + |
| 220 | +## Testing |
| 221 | + |
| 222 | +- **Unit (handler):** call the handler with `args=["bogus"]`; |
| 223 | + assert (a) stdout contains the verbatim box, the token `bogus`, |
| 224 | + and the skew hint; (b) it returns a non-nil error; |
| 225 | + (c) `cmd.SilenceUsage` is true after the call. |
| 226 | +- **Unit (bare):** handler with `args=[]` prints help and returns |
| 227 | + nil. |
| 228 | +- **Cobra-level:** build `system.Cmd()`, execute with |
| 229 | + `["definitely-not-a-cmd"]`, capture output; assert non-nil error |
| 230 | + and the box on stdout, and assert **no** Long-help body is |
| 231 | + printed. Execute with `[]` (bare) and assert help + nil error. |
| 232 | +- **Routing:** assert a known subcommand name resolves to its own |
| 233 | + command (e.g. `system.Cmd().Find([]string{"bootstrap"})` returns |
| 234 | + `bootstrap`), proving the handler is bypassed for valid verbs. |
| 235 | +- **Relay leg — fired:** with a stdin carrying a real session ID, |
| 236 | + assert `nudge.Relay` records an event (event-log append observed |
| 237 | + in a temp state dir) for the unknown verb. Assert a relay failure |
| 238 | + is logged but the handler still returns the `UnknownSubcommand` |
| 239 | + error (relay failure does not mask it). |
| 240 | +- **Relay leg — skipped:** with a TTY/blank stdin (`ReadID` → |
| 241 | + `IDUnknown`), assert no relay event is recorded and the call does |
| 242 | + not block; stdout box + error still happen. |
| 243 | +- **Scope:** assert `parent.Cmd` is unchanged — another group built |
| 244 | + from `parent.Cmd` (e.g. `ctx hook`) still exits 0 on an unknown |
| 245 | + subcommand. This documents the intentional scoping and will flag |
| 246 | + if someone later moves the fix into the shared helper. |
| 247 | + |
| 248 | +## Non-Goals |
| 249 | + |
| 250 | +- **Not** changing the shared `parent.Cmd`. Other groups keep |
| 251 | + cobra's default unknown-subcommand behavior. |
| 252 | +- **Not** fixing `ctx hook` or any other group. They are not wired |
| 253 | + into `hooks.json`, so their skew does not pollute a session. If |
| 254 | + that changes, revisit. |
| 255 | +- **Not** the build-time wiring guard — already shipped |
| 256 | + (`specs/hooks-wiring-guard.md`). This is its runtime complement. |
| 257 | +- **Not** removing the leftover `check-anchor-drift` prose mention |
| 258 | + in `commands.yaml` (acdl follow-up #3 — a separate explicit |
| 259 | + decision). |
| 260 | +- **Not** generalizing the unknown-subcommand handler into the |
| 261 | + shared `parent.Cmd`. `ctx hook` (and any future group) has the |
| 262 | + same latent exit-0-on-unknown behavior, but it is not wired into |
| 263 | + `hooks.json`, so its skew does not pollute a session. Captured as |
| 264 | + a follow-up task, not built here. |
| 265 | + |
| 266 | +## Resolved Decisions |
| 267 | + |
| 268 | +Both decisions below were settled with the user at spec time |
| 269 | +(session 0066d49b): |
| 270 | + |
| 271 | +1. **Relay leg is in scope.** The handler fires the event-log + |
| 272 | + webhook relay (`nudge.Relay`), gated on a real session ID read |
| 273 | + best-effort from stdin. When no session is present (manual TTY |
| 274 | + typo, blank JSON), it emits the stdout box and exits non-zero |
| 275 | + without a relay record. |
| 276 | +2. **Scoped to `ctx system` only.** `parent.Cmd` is untouched; |
| 277 | + other groups keep cobra's default. Generalization is recorded as |
| 278 | + a follow-up task (see Non-Goals). |
0 commit comments