Skip to content

Commit cdf8274

Browse files
authored
fix: keep every word of an unquoted prompt, and stop a mistyped command from starting a session (#96)
`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`.
1 parent a9b85cc commit cdf8274

5 files changed

Lines changed: 209 additions & 4 deletions

File tree

src/cli.tsx

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { registerSentry } from './commands/sentry'
1919
import { registerUsage } from './commands/usage'
2020
import { registerAnalytics } from './commands/analytics'
2121
import { registerPing } from './commands/ping'
22+
import { commandTypoMessage, looksLikeCommandTypo } from './lib/args'
2223
import { VERSION } from './lib/constants'
2324
import { configureCliHelp } from './lib/help'
2425
import { canHostSessionsUi, defaultStartRequest, runSessionsUi } from './ui/launch'
@@ -66,10 +67,17 @@ registerPing(program)
6667
// flag through to a fresh connected session, which opens in the same UI.
6768
// `agent --help`, `agent --version`, `agent help`, and every subcommand
6869
// dispatch unchanged.
70+
//
71+
// The exception is a single bare word (`agent sesion`): see
72+
// looksLikeCommandTypo. Quoting does not help, since the shell strips the
73+
// quotes. Use `agent -p word` or `agent -- word` to force it through.
6974
const topLevelCommands = new Set([
7075
'help',
7176
...program.commands.flatMap((c) => [c.name(), ...c.aliases()]),
7277
])
78+
// Hidden plural aliases dispatch, but a "did you mean" hint should only ever
79+
// name the spelling we document.
80+
const suggestableCommands = ['help', ...program.commands.map((c) => c.name())]
7381
const first = process.argv[2]
7482
const isTopLevel =
7583
first === '-h' ||
@@ -84,6 +92,13 @@ if (first === undefined && canHostSessionsUi()) {
8492
)
8593
} else {
8694
if (!isTopLevel) {
95+
// One bare word is far more likely a mistyped command than a prompt, so
96+
// stop instead of starting a session nobody asked for.
97+
const rest = process.argv.slice(2)
98+
if (looksLikeCommandTypo(rest)) {
99+
console.error(commandTypoMessage(rest[0]!, suggestableCommands))
100+
process.exit(1)
101+
}
87102
process.argv.splice(2, 0, 'session', 'start', '--connect')
88103
}
89104
await program.parseAsync(process.argv)

src/commands/session.tsx

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ export function registerSession(program: Command): void {
9696
'WS /v1/sessions/{id}/stream with --watch or --connect',
9797
)
9898
.argument(
99-
'[prompt]',
99+
'[prompt...]',
100100
'what the agent should do this session (positional shorthand for --prompt)',
101101
)
102102
.option(
@@ -164,7 +164,7 @@ export function registerSession(program: Command): void {
164164
.option('--json', 'output raw JSON')
165165
.action(
166166
async (
167-
promptArg: string | undefined,
167+
promptWords: string[],
168168
opts: {
169169
config?: string
170170
configFile?: string
@@ -196,6 +196,9 @@ export function registerSession(program: Command): void {
196196
if (sources.length > 1) {
197197
throw new Error('provide only one of --config / --config-file / --template')
198198
}
199+
// An unquoted prompt arrives as one word per argv entry, so join it
200+
// back into a sentence: `agent fix the tests` means one instruction.
201+
const promptArg = promptWords.length > 0 ? promptWords.join(' ') : undefined
199202
// The prompt is either positional or --prompt, not both.
200203
if (promptArg !== undefined && opts.prompt !== undefined) {
201204
throw new Error('provide the prompt positionally or with --prompt, not both')
@@ -699,7 +702,7 @@ export function registerSession(program: Command): void {
699702
// literal `claude --resume` of the local session.
700703
apiRoutes(
701704
session
702-
.command('handoff <prompt>')
705+
.command('handoff <prompt...>')
703706
.description('Hand this repo and a synced local session off to a cloud agent'),
704707
'POST /v1/sessions',
705708
)
@@ -711,10 +714,11 @@ export function registerSession(program: Command): void {
711714
.option('--json', 'output raw JSON')
712715
.action(
713716
async (
714-
prompt: string,
717+
promptWords: string[],
715718
opts: { parent: string; cwd?: string; json?: boolean },
716719
) => {
717720
await runAction(async () => {
721+
const prompt = promptWords.join(' ')
718722
const cwd = opts.cwd ?? process.cwd()
719723
const repo = repoFromCwd(cwd)
720724
if (!repo) {

src/lib/args.ts

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,81 @@ export function parseWhen(value: string, now: Date = new Date()): string {
100100
return value
101101
}
102102

103+
// A bare `agent <text>` is shorthand for starting a session with that text as
104+
// the prompt, so a mistyped subcommand like `agent sesion` would silently start
105+
// a session instead of failing. Guard on shape rather than edit distance: a
106+
// real prompt is a sentence, a typo is one word. So one bare word that is not a
107+
// known command is treated as a mistake, even when nothing looks close to it.
108+
// Anything with a space is a prompt, and `-p sesion` still forces it through.
109+
export function looksLikeCommandTypo(args: string[]): boolean {
110+
if (args.length !== 1) return false
111+
const arg = args[0]
112+
if (arg === undefined || arg === '') return false
113+
// Options are commander's job, and `--` already means "the rest is a prompt".
114+
if (arg.startsWith('-')) return false
115+
return !/\s/.test(arg)
116+
}
117+
118+
// The closest command names to a typo, for a "did you mean" hint. Returns the
119+
// ties at the best distance, or nothing when the word resembles no command.
120+
export function similarCommands(word: string, commands: string[]): string[] {
121+
const maxDistance = 3
122+
let best = maxDistance + 1
123+
let matches: string[] = []
124+
for (const command of commands) {
125+
// One-character names would match almost anything.
126+
if (command.length <= 1) continue
127+
const distance = editDistance(word, command)
128+
const length = Math.max(word.length, command.length)
129+
// Same bar commander uses: reject matches that share too little.
130+
if ((length - distance) / length <= 0.4) continue
131+
if (distance < best) {
132+
best = distance
133+
matches = [command]
134+
} else if (distance === best) {
135+
matches.push(command)
136+
}
137+
}
138+
return matches.sort((a, b) => a.localeCompare(b))
139+
}
140+
141+
// Damerau-Levenshtein optimal string alignment distance.
142+
function editDistance(a: string, b: string): number {
143+
if (Math.abs(a.length - b.length) > 3) return Math.max(a.length, b.length)
144+
const d: number[][] = []
145+
for (let i = 0; i <= a.length; i++) d[i] = [i]
146+
for (let j = 0; j <= b.length; j++) d[0]![j] = j
147+
for (let j = 1; j <= b.length; j++) {
148+
for (let i = 1; i <= a.length; i++) {
149+
const cost = a[i - 1] === b[j - 1] ? 0 : 1
150+
d[i]![j] = Math.min(
151+
d[i - 1]![j]! + 1,
152+
d[i]![j - 1]! + 1,
153+
d[i - 1]![j - 1]! + cost,
154+
)
155+
if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1]) {
156+
d[i]![j] = Math.min(d[i]![j]!, d[i - 2]![j - 2]! + 1)
157+
}
158+
}
159+
}
160+
return d[a.length]![b.length]!
161+
}
162+
163+
// The message for a suspected typo. Names the fix both ways: the command they
164+
// probably meant, and how to send the word as a prompt on purpose.
165+
export function commandTypoMessage(word: string, commands: string[]): string {
166+
const similar = similarCommands(word, commands)
167+
const lines = [`error: unknown command "${word}"`]
168+
if (similar.length === 1) {
169+
lines.push(` did you mean "agent ${similar[0]}"?`)
170+
} else if (similar.length > 1) {
171+
lines.push(` did you mean one of: ${similar.map((c) => `agent ${c}`).join(', ')}?`)
172+
}
173+
lines.push(` to start a session with that prompt: agent -p ${word}`)
174+
lines.push(' to see every command: agent --help')
175+
return lines.join('\n')
176+
}
177+
103178
// Accumulate repeated `key=value` options into an object.
104179
export function collectKeyValue(
105180
value: string,

test/args.test.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,10 @@ import {
44
collectKeyValue,
55
collectSource,
66
collectStatus,
7+
commandTypoMessage,
8+
looksLikeCommandTypo,
79
parseScope,
10+
similarCommands,
811
parseWhen,
912
toInt,
1013
} from '../src/lib/args'
@@ -94,3 +97,67 @@ describe('parseWhen', () => {
9497
expect(() => parseWhen('', now)).toThrow(/ISO 8601/)
9598
})
9699
})
100+
101+
// A bare `agent <text>` starts a session with that text as the prompt, so a
102+
// mistyped command must not silently spawn one.
103+
describe('looksLikeCommandTypo', () => {
104+
it('flags a single bare word', () => {
105+
expect(looksLikeCommandTypo(['sesion'])).toBe(true)
106+
expect(looksLikeCommandTypo(['xyzzy'])).toBe(true)
107+
})
108+
109+
it('lets a real multi-word prompt through', () => {
110+
expect(looksLikeCommandTypo(['fix', 'the', 'tests'])).toBe(false)
111+
expect(looksLikeCommandTypo(['fix the tests'])).toBe(false)
112+
})
113+
114+
it('leaves options to commander', () => {
115+
expect(looksLikeCommandTypo(['--model'])).toBe(false)
116+
expect(looksLikeCommandTypo(['-p'])).toBe(false)
117+
})
118+
119+
// `--` means the caller already said "this is a prompt", and a one-word
120+
// prompt plus any flag is deliberate enough to trust.
121+
it('does not flag anything but a lone word', () => {
122+
expect(looksLikeCommandTypo([])).toBe(false)
123+
expect(looksLikeCommandTypo(['--', 'sesion'])).toBe(false)
124+
expect(looksLikeCommandTypo(['refactor', '--model', 'claude-fable-5'])).toBe(false)
125+
})
126+
})
127+
128+
describe('similarCommands', () => {
129+
const commands = ['session', 'review', 'config', 'install', 'model', 'host']
130+
131+
it('finds the intended command behind a typo', () => {
132+
expect(similarCommands('sesion', commands)).toEqual(['session'])
133+
expect(similarCommands('reveiw', commands)).toEqual(['review'])
134+
expect(similarCommands('instal', commands)).toEqual(['install'])
135+
})
136+
137+
it('returns nothing when the word resembles no command', () => {
138+
expect(similarCommands('xyzzy', commands)).toEqual([])
139+
})
140+
141+
it('returns every tie at the best distance', () => {
142+
expect(similarCommands('hos', ['hook', 'host'])).toEqual(['host'])
143+
expect(similarCommands('mode', ['model', 'mode1'])).toEqual(['mode1', 'model'])
144+
})
145+
})
146+
147+
describe('commandTypoMessage', () => {
148+
const commands = ['session', 'review', 'install']
149+
150+
it('names the likely command and how to force a prompt', () => {
151+
const msg = commandTypoMessage('sesion', commands)
152+
expect(msg).toContain('unknown command "sesion"')
153+
expect(msg).toContain('did you mean "agent session"?')
154+
expect(msg).toContain('agent -p sesion')
155+
})
156+
157+
it('still explains the escape hatch with no suggestion', () => {
158+
const msg = commandTypoMessage('xyzzy', commands)
159+
expect(msg).not.toContain('did you mean')
160+
expect(msg).toContain('agent -p xyzzy')
161+
expect(msg).toContain('agent --help')
162+
})
163+
})

test/session.test.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -288,3 +288,47 @@ describe('fetchLogSegment', () => {
288288
await expect(fetchLogSegment(segment())).rejects.toThrow(/presigned URL likely expired/)
289289
})
290290
})
291+
292+
// Regression: `[prompt]` was a single positional, so an unquoted
293+
// `agent fix the tests` sent just "fix" and dropped the rest silently.
294+
describe('session start prompt positional', () => {
295+
async function startedPrompt(argv: string[]): Promise<string | undefined> {
296+
const { Command } = await import('commander')
297+
const { registerSession } = await import('../src/commands/session')
298+
const { ApiClient } = await import('../src/lib/api')
299+
let seen: string | undefined
300+
const spy = vi
301+
.spyOn(ApiClient.prototype, 'startAgentSession')
302+
.mockImplementation(async (req) => {
303+
seen = req.prompt
304+
return session('queued')
305+
})
306+
const program = new Command()
307+
program.exitOverride()
308+
registerSession(program)
309+
try {
310+
await program.parseAsync(['node', 'agent', 'session', 'start', ...argv, '--json'])
311+
} finally {
312+
spy.mockRestore()
313+
}
314+
return seen
315+
}
316+
317+
it('joins an unquoted multi-word prompt into one instruction', async () => {
318+
expect(await startedPrompt(['fix', 'the', 'tests'])).toBe('fix the tests')
319+
})
320+
321+
it('keeps a quoted prompt intact', async () => {
322+
expect(await startedPrompt(['fix the tests'])).toBe('fix the tests')
323+
})
324+
325+
it('still separates trailing flags from the prompt', async () => {
326+
expect(await startedPrompt(['fix', 'the', 'tests', '--model', 'claude-fable-5'])).toBe(
327+
'fix the tests',
328+
)
329+
})
330+
331+
it('leaves a promptless start without a prompt', async () => {
332+
expect(await startedPrompt([])).toBeUndefined()
333+
})
334+
})

0 commit comments

Comments
 (0)