From fd2daae0cbfec059bad602370a1133928880ee5b Mon Sep 17 00:00:00 2001 From: hbrooks Date: Fri, 31 Jul 2026 10:47:05 -0400 Subject: [PATCH] fix: keep every word of an unquoted prompt, and stop a mistyped command from starting a session `agent fix the tests` sent just "fix". The positional was a single argument, so commander bound the first word and dropped the rest with no error. Making it variadic and joining on spaces fixes that, and gives `--` a useful meaning for free: `agent -- why does -m fail` now sends the whole line. `session handoff` had the same truncation. A bare word is also treated as a prompt, so `agent sesion` used to start a session instead of failing. The guard keys on shape, not edit distance: one word with no spaces and no leading dash is a mistake, and anything longer is a prompt. Edit distance decides only the wording of the hint, never whether to block. Gating on it would have been much worse. Of 68 common prompt-opening words, 23 land within commander's match threshold of a command name (revert/review, audit/budget, test/host), so `agent revert the migration` would have been refused. `agent sesion list` still slips through. A near-miss plus a known subcommand looked like the fix, but it also caught `revert list` and `audit list`. Blocking a real prompt is worse than missing a typo. Forcing a one-word prompt through: `agent -p refactor` or `agent -- refactor`. Quoting cannot work, since the shell strips it, which is why the error names `-p`. --- src/cli.tsx | 15 ++++++++ src/commands/session.tsx | 12 ++++--- src/lib/args.ts | 75 ++++++++++++++++++++++++++++++++++++++++ test/args.test.ts | 67 +++++++++++++++++++++++++++++++++++ test/session.test.ts | 44 +++++++++++++++++++++++ 5 files changed, 209 insertions(+), 4 deletions(-) diff --git a/src/cli.tsx b/src/cli.tsx index 5669567..c020d60 100644 --- a/src/cli.tsx +++ b/src/cli.tsx @@ -19,6 +19,7 @@ import { registerSentry } from './commands/sentry' import { registerUsage } from './commands/usage' import { registerAnalytics } from './commands/analytics' import { registerPing } from './commands/ping' +import { commandTypoMessage, looksLikeCommandTypo } from './lib/args' import { VERSION } from './lib/constants' import { configureCliHelp } from './lib/help' import { canHostSessionsUi, defaultStartRequest, runSessionsUi } from './ui/launch' @@ -66,10 +67,17 @@ registerPing(program) // flag through to a fresh connected session, which opens in the same UI. // `agent --help`, `agent --version`, `agent help`, and every subcommand // dispatch unchanged. +// +// The exception is a single bare word (`agent sesion`): see +// looksLikeCommandTypo. Quoting does not help, since the shell strips the +// quotes. Use `agent -p word` or `agent -- word` to force it through. const topLevelCommands = new Set([ 'help', ...program.commands.flatMap((c) => [c.name(), ...c.aliases()]), ]) +// Hidden plural aliases dispatch, but a "did you mean" hint should only ever +// name the spelling we document. +const suggestableCommands = ['help', ...program.commands.map((c) => c.name())] const first = process.argv[2] const isTopLevel = first === '-h' || @@ -84,6 +92,13 @@ if (first === undefined && canHostSessionsUi()) { ) } else { if (!isTopLevel) { + // One bare word is far more likely a mistyped command than a prompt, so + // stop instead of starting a session nobody asked for. + const rest = process.argv.slice(2) + if (looksLikeCommandTypo(rest)) { + console.error(commandTypoMessage(rest[0]!, suggestableCommands)) + process.exit(1) + } process.argv.splice(2, 0, 'session', 'start', '--connect') } await program.parseAsync(process.argv) diff --git a/src/commands/session.tsx b/src/commands/session.tsx index d1d603d..9eae191 100644 --- a/src/commands/session.tsx +++ b/src/commands/session.tsx @@ -96,7 +96,7 @@ export function registerSession(program: Command): void { 'WS /v1/sessions/{id}/stream with --watch or --connect', ) .argument( - '[prompt]', + '[prompt...]', 'what the agent should do this session (positional shorthand for --prompt)', ) .option( @@ -164,7 +164,7 @@ export function registerSession(program: Command): void { .option('--json', 'output raw JSON') .action( async ( - promptArg: string | undefined, + promptWords: string[], opts: { config?: string configFile?: string @@ -196,6 +196,9 @@ export function registerSession(program: Command): void { if (sources.length > 1) { throw new Error('provide only one of --config / --config-file / --template') } + // An unquoted prompt arrives as one word per argv entry, so join it + // back into a sentence: `agent fix the tests` means one instruction. + const promptArg = promptWords.length > 0 ? promptWords.join(' ') : undefined // The prompt is either positional or --prompt, not both. if (promptArg !== undefined && opts.prompt !== undefined) { throw new Error('provide the prompt positionally or with --prompt, not both') @@ -699,7 +702,7 @@ export function registerSession(program: Command): void { // literal `claude --resume` of the local session. apiRoutes( session - .command('handoff ') + .command('handoff ') .description('Hand this repo and a synced local session off to a cloud agent'), 'POST /v1/sessions', ) @@ -711,10 +714,11 @@ export function registerSession(program: Command): void { .option('--json', 'output raw JSON') .action( async ( - prompt: string, + promptWords: string[], opts: { parent: string; cwd?: string; json?: boolean }, ) => { await runAction(async () => { + const prompt = promptWords.join(' ') const cwd = opts.cwd ?? process.cwd() const repo = repoFromCwd(cwd) if (!repo) { diff --git a/src/lib/args.ts b/src/lib/args.ts index 25bb7af..75cad51 100644 --- a/src/lib/args.ts +++ b/src/lib/args.ts @@ -100,6 +100,81 @@ export function parseWhen(value: string, now: Date = new Date()): string { return value } +// A bare `agent ` is shorthand for starting a session with that text as +// the prompt, so a mistyped subcommand like `agent sesion` would silently start +// a session instead of failing. Guard on shape rather than edit distance: a +// real prompt is a sentence, a typo is one word. So one bare word that is not a +// known command is treated as a mistake, even when nothing looks close to it. +// Anything with a space is a prompt, and `-p sesion` still forces it through. +export function looksLikeCommandTypo(args: string[]): boolean { + if (args.length !== 1) return false + const arg = args[0] + if (arg === undefined || arg === '') return false + // Options are commander's job, and `--` already means "the rest is a prompt". + if (arg.startsWith('-')) return false + return !/\s/.test(arg) +} + +// The closest command names to a typo, for a "did you mean" hint. Returns the +// ties at the best distance, or nothing when the word resembles no command. +export function similarCommands(word: string, commands: string[]): string[] { + const maxDistance = 3 + let best = maxDistance + 1 + let matches: string[] = [] + for (const command of commands) { + // One-character names would match almost anything. + if (command.length <= 1) continue + const distance = editDistance(word, command) + const length = Math.max(word.length, command.length) + // Same bar commander uses: reject matches that share too little. + if ((length - distance) / length <= 0.4) continue + if (distance < best) { + best = distance + matches = [command] + } else if (distance === best) { + matches.push(command) + } + } + return matches.sort((a, b) => a.localeCompare(b)) +} + +// Damerau-Levenshtein optimal string alignment distance. +function editDistance(a: string, b: string): number { + if (Math.abs(a.length - b.length) > 3) return Math.max(a.length, b.length) + const d: number[][] = [] + for (let i = 0; i <= a.length; i++) d[i] = [i] + for (let j = 0; j <= b.length; j++) d[0]![j] = j + for (let j = 1; j <= b.length; j++) { + for (let i = 1; i <= a.length; i++) { + const cost = a[i - 1] === b[j - 1] ? 0 : 1 + d[i]![j] = Math.min( + d[i - 1]![j]! + 1, + d[i]![j - 1]! + 1, + d[i - 1]![j - 1]! + cost, + ) + if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1]) { + d[i]![j] = Math.min(d[i]![j]!, d[i - 2]![j - 2]! + 1) + } + } + } + return d[a.length]![b.length]! +} + +// The message for a suspected typo. Names the fix both ways: the command they +// probably meant, and how to send the word as a prompt on purpose. +export function commandTypoMessage(word: string, commands: string[]): string { + const similar = similarCommands(word, commands) + const lines = [`error: unknown command "${word}"`] + if (similar.length === 1) { + lines.push(` did you mean "agent ${similar[0]}"?`) + } else if (similar.length > 1) { + lines.push(` did you mean one of: ${similar.map((c) => `agent ${c}`).join(', ')}?`) + } + lines.push(` to start a session with that prompt: agent -p ${word}`) + lines.push(' to see every command: agent --help') + return lines.join('\n') +} + // Accumulate repeated `key=value` options into an object. export function collectKeyValue( value: string, diff --git a/test/args.test.ts b/test/args.test.ts index b7e07b9..549fb13 100644 --- a/test/args.test.ts +++ b/test/args.test.ts @@ -4,7 +4,10 @@ import { collectKeyValue, collectSource, collectStatus, + commandTypoMessage, + looksLikeCommandTypo, parseScope, + similarCommands, parseWhen, toInt, } from '../src/lib/args' @@ -94,3 +97,67 @@ describe('parseWhen', () => { expect(() => parseWhen('', now)).toThrow(/ISO 8601/) }) }) + +// A bare `agent ` starts a session with that text as the prompt, so a +// mistyped command must not silently spawn one. +describe('looksLikeCommandTypo', () => { + it('flags a single bare word', () => { + expect(looksLikeCommandTypo(['sesion'])).toBe(true) + expect(looksLikeCommandTypo(['xyzzy'])).toBe(true) + }) + + it('lets a real multi-word prompt through', () => { + expect(looksLikeCommandTypo(['fix', 'the', 'tests'])).toBe(false) + expect(looksLikeCommandTypo(['fix the tests'])).toBe(false) + }) + + it('leaves options to commander', () => { + expect(looksLikeCommandTypo(['--model'])).toBe(false) + expect(looksLikeCommandTypo(['-p'])).toBe(false) + }) + + // `--` means the caller already said "this is a prompt", and a one-word + // prompt plus any flag is deliberate enough to trust. + it('does not flag anything but a lone word', () => { + expect(looksLikeCommandTypo([])).toBe(false) + expect(looksLikeCommandTypo(['--', 'sesion'])).toBe(false) + expect(looksLikeCommandTypo(['refactor', '--model', 'claude-fable-5'])).toBe(false) + }) +}) + +describe('similarCommands', () => { + const commands = ['session', 'review', 'config', 'install', 'model', 'host'] + + it('finds the intended command behind a typo', () => { + expect(similarCommands('sesion', commands)).toEqual(['session']) + expect(similarCommands('reveiw', commands)).toEqual(['review']) + expect(similarCommands('instal', commands)).toEqual(['install']) + }) + + it('returns nothing when the word resembles no command', () => { + expect(similarCommands('xyzzy', commands)).toEqual([]) + }) + + it('returns every tie at the best distance', () => { + expect(similarCommands('hos', ['hook', 'host'])).toEqual(['host']) + expect(similarCommands('mode', ['model', 'mode1'])).toEqual(['mode1', 'model']) + }) +}) + +describe('commandTypoMessage', () => { + const commands = ['session', 'review', 'install'] + + it('names the likely command and how to force a prompt', () => { + const msg = commandTypoMessage('sesion', commands) + expect(msg).toContain('unknown command "sesion"') + expect(msg).toContain('did you mean "agent session"?') + expect(msg).toContain('agent -p sesion') + }) + + it('still explains the escape hatch with no suggestion', () => { + const msg = commandTypoMessage('xyzzy', commands) + expect(msg).not.toContain('did you mean') + expect(msg).toContain('agent -p xyzzy') + expect(msg).toContain('agent --help') + }) +}) diff --git a/test/session.test.ts b/test/session.test.ts index 7d4d261..a00f0b8 100644 --- a/test/session.test.ts +++ b/test/session.test.ts @@ -288,3 +288,47 @@ describe('fetchLogSegment', () => { await expect(fetchLogSegment(segment())).rejects.toThrow(/presigned URL likely expired/) }) }) + +// Regression: `[prompt]` was a single positional, so an unquoted +// `agent fix the tests` sent just "fix" and dropped the rest silently. +describe('session start prompt positional', () => { + async function startedPrompt(argv: string[]): Promise { + const { Command } = await import('commander') + const { registerSession } = await import('../src/commands/session') + const { ApiClient } = await import('../src/lib/api') + let seen: string | undefined + const spy = vi + .spyOn(ApiClient.prototype, 'startAgentSession') + .mockImplementation(async (req) => { + seen = req.prompt + return session('queued') + }) + const program = new Command() + program.exitOverride() + registerSession(program) + try { + await program.parseAsync(['node', 'agent', 'session', 'start', ...argv, '--json']) + } finally { + spy.mockRestore() + } + return seen + } + + it('joins an unquoted multi-word prompt into one instruction', async () => { + expect(await startedPrompt(['fix', 'the', 'tests'])).toBe('fix the tests') + }) + + it('keeps a quoted prompt intact', async () => { + expect(await startedPrompt(['fix the tests'])).toBe('fix the tests') + }) + + it('still separates trailing flags from the prompt', async () => { + expect(await startedPrompt(['fix', 'the', 'tests', '--model', 'claude-fable-5'])).toBe( + 'fix the tests', + ) + }) + + it('leaves a promptless start without a prompt', async () => { + expect(await startedPrompt([])).toBeUndefined() + }) +})