[STG-2278] feat(cli): did-you-mean + telemetry for unknown commands#2249
[STG-2278] feat(cli): did-you-mean + telemetry for unknown commands#2249shrey150 wants to merge 5 commits into
Conversation
Unknown commands previously exited 2 with no suggestion and emitted no telemetry (prerun never fires, so command_completed early-returns). This adds a command_not_found oclif hook that prints a did-you-mean line (explicit alias table for old Commander-era syntax, Levenshtein fallback for typos) and emits a new cli.command_not_found event with only the sanitized attempted command id and the suggestion - never raw argv. oclif's standard error and exit code 2 are preserved, and no new runtime dependency is added (deliberately not @oclif/plugin-not-found, which prompts interactively in TTYs). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
🦋 Changeset detectedLatest commit: bcee6ed The changes in this PR will be included in the next version bump. This PR includes changesets to release 0 packagesWhen changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
There was a problem hiding this comment.
1 issue found across 6 files
Confidence score: 3/5
- In
packages/cli/src/lib/command-suggestions.ts, fuzzy matching can leave trailing user tokens inattempted, which then gets emitted asattempted_commandtelemetry; merging as-is risks leaking argv-derived input and undermines the file’s sanitization contract. Ensureattemptedis fully sanitized (or omitted from telemetry) before merge, and add a regression test covering trailing-token cases.
Architecture diagram
sequenceDiagram
participant CLI as CLI Process
participant Hook as command_not_found Hook
participant Suggest as suggestCommand()
participant Telemetry as captureCommandNotFound()
participant PostHog as PostHog Transport
participant Oclif as oclif Core
Note over CLI,Oclif: Unknown command path: "browse sessions" / "browse opne https://..."
CLI->>Oclif: dispatch unknown command id
Oclif->>Oclif: identify command_not_found hook
Oclif->>Hook: invoke hook({ config, id })
Note over Hook,Suggest: id = "sessions" or "opne:https://example.com"
Hook->>Suggest: suggestCommand(id, config.commandIDs)
Note over Suggest: extractCommandTokens() strips<br/>argument-like tokens (URLs, flags)<br/>Returns only command-shaped prefix
alt Explicit alias match
Suggest->>Suggest: check aliasSuggestions map
Note over Suggest: "sessions" -> "cloud:sessions:list"<br/>"auth:status" -> "doctor"
else Levenshtein fallback
Suggest->>Suggest: compute edit distance vs all command IDs
Note over Suggest: threshold = max(2, floor(len/3)), cap 5<br/>"opne" -> "open" (distance 2)
else No decent match
Suggest->>Suggest: return suggestion = null
end
Suggest-->>Hook: { attempted, suggestion }
alt Suggestion exists
Hook->>Hook: validate suggestion exists in config.findCommand()
Note over Hook: Guard against alias target drift
Hook->>CLI: stderr: "Did you mean ...?"
else No suggestion
Hook->>CLI: stderr: "... Run --help for all commands."
end
Note over Hook,PostHog: Privacy: only attempted + suggested command ids, never argv
Hook->>Telemetry: captureCommandNotFound(version, attempted, suggestion)
Telemetry->>PostHog: POST /capture - event: cli.command_not_found
Note over Telemetry,PostHog: Payload: { attempted_command, suggested_command }<br/>Timeout: 400ms, best-effort catch
PostHog-->>Telemetry: 200 OK (or timeout/error, silently swallowed)
Note over Hook: Await telemetry flush<br/>before throwing error
Hook->>Oclif: throw CLIError("command {id} not found")
Oclif->>CLI: stderr: "Error: command {id} not found"
Oclif->>CLI: exit code 2
Reply with feedback, questions, or to request a fix.
Fix all with cubic | Re-trigger cubic
…r reach telemetry Cubic flagged that whole-string fuzzy scoring could retain a trailing user-provided token in the attempted command (e.g. 'browse stat s' -> attempted_command 'stat.s'). Fuzzy matching now aligns token prefixes per command-id segment: only ids with the same segment count are considered and each token must be within its own edit-distance threshold of the aligned segment, so a token can only be retained when it itself looks like a typo of a real command word. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
auth and login express authentication intent, not diagnostics; an unknown auth command now falls through to the plain not-found message (telemetry still records the attempt). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…stance Zero-dep micro-lib (same one @oclif/plugin-not-found uses), already in the monorepo pnpm store. The segment-aligned threshold logic stays ours. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Re: the auth/login alias comment — agreed and addressed in e8520c4a0: dropped all three ( Also pre-addressed the two pending draft comments on the hand-rolled edit distance ("there has to be a util function for this") in 37ae41c74: swapped to (Replying here instead of in-thread: GitHub blocks threaded replies while your review draft is pending.) |
…tegration tests Addresses review: extractCommandTokens + suggestCommand cases become it.each tables; the built-CLI/dummy-server tests (real command_not_found hook + telemetry transport) move to cli-command-not-found.integration.test.ts, leaving the pure-function unit tests in cli-command-not-found.test.ts. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Summary
Linear: https://linear.app/browserbase/issue/STG-2278/add-did-you-mean-suggestions-and-telemetry-for-unknown-browse-commands
Adds a
command_not_foundoclif hook to the browse CLI that prints a did-you-mean suggestion for unknown commands and emits a newcli.command_not_foundtelemetry event, while preserving oclif's standard "command not found" error and exit code 2.Impact if merged
Unknown commands (
browse sessions/search/contexts/auth status— the old Commander-era syntax that agents were trained on, plus plain typos) currently exit 2 with no suggestion and emit NO telemetry event, so this failure class is invisible by construction. Old-binary telemetry shows the pattern is real (1,310 commander-error events fromsessions.listalone in 30d; 115 fromsearch). It's a failed-first-command class, and a failed first command cuts 7-day retention 12.4x (0.42% vs 5.21%). Did-you-mean turns an agent guess-loop into a one-turn recovery, and the newcli.command_not_foundevent finally lets us size and rank the dead ends. No new dependency — deliberately avoids@oclif/plugin-not-found, which prompts interactively (agent-hostile).Implementation notes
src/hooks/command-not-found.ts, registered in theoclif.hooksconfig. Suggestion order: explicit alias table first (old-CLI syntax → current tree, e.g.sessions→cloud sessions list,auth status→doctor,search→cloud search), then nearest match by Levenshtein overconfig.commandIDswith a distance threshold (the did-you-mean clause is omitted when nothing decent matches). Alias targets are validated against the live command tree at runtime and againstoclif.manifest.jsonin tests, so they can't silently drift.browse opne https://example.comarrives asopne:https://example.com), so the hook sanitizes down to leading command-shaped tokens and reports only the matched prefix (or the first token when nothing matches). The telemetry payload carries exactlyattempted_commandandsuggested_command— URLs, selectors, queries, and secrets never leave the machine. Covered by a dedicated test asserting argv values are absent from captured payloads.command_not_foundhook that returns normally makes oclif treat the invocation as handled (exit 0), silently swallowing the failure. The hook therefore re-throws oclif's standardCLIError("command <id> not found")after printing the suggestion, keeping stderr output and exit code 2 byte-identical to current behavior.finally-hook completion path early-returns for unknown commands (prerun never fires), so there is no double counting.E2E Test Matrix
node bin/run.js sessions(local build)"browse sessions" is not a browse command. Did you mean "browse cloud sessions list"? Run browse --help for all commands.thenError: command sessions not found;echo $?→2node bin/run.js auth status"browse auth status" is not a browse command. Did you mean "browse doctor"? ...; exit2auth:status→doctor)node bin/run.js search "test""browse search" is not a browse command. Did you mean "browse cloud search"? ...; exit2; the query token is not shown as part of the attempted commandnode bin/run.js opne https://example.com"browse opne" is not a browse command. Did you mean "browse open"? ...; exit2node bin/run.js open https://example.com --local{"mode": "managed-local", ..., "title": "Example Domain", "url": "https://example.com/"}; exit0; no suggestion outputBROWSERBASE_TELEMETRY_HOST=<local capture server>+node bin/run.js auth statusPOST /i/v0/e/with"event": "cli.command_not_found","attempted_command": "auth.status","suggested_command": "doctor"plus standard env/version props; payload received before CLI exit2; no argv content in payloadpnpm test(builds then vitest)Test Files 16 passed (16), Tests 229 passed (229)— includes 13 new unit/integration tests (alias table validity vs manifest, Levenshtein, thresholds, token sanitization, built-CLI suggestion/exit-code/telemetry/privacy)pnpm linttsc --noEmitall pass🤖 Generated with Claude Code
Summary by cubic
Adds did-you-mean suggestions and privacy-safe telemetry for unknown
browseCLI commands, while keeping the standard error output and exit code 2. Addresses Linear STG-2278 by helping users recover from old syntax and typos; typo matching now usesfastest-levenshtein.command_not_foundhook that prints a suggestion using an alias table for old syntax, with segment-aligned Levenshtein fallback for typos; omitted when no good match.cli.command_not_foundtelemetry with strict privacy: only the sanitized attempted command id and the suggested command, never raw argv.@oclif/plugin-not-found.auth/login→doctorsuggestions.Written for commit bcee6ed. Summary will update on new commits.