Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions src/cli.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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' ||
Expand All @@ -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)
Expand Down
12 changes: 8 additions & 4 deletions src/commands/session.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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')
Expand Down Expand Up @@ -699,7 +702,7 @@ export function registerSession(program: Command): void {
// literal `claude --resume` of the local session.
apiRoutes(
session
.command('handoff <prompt>')
.command('handoff <prompt...>')
.description('Hand this repo and a synced local session off to a cloud agent'),
'POST /v1/sessions',
)
Expand All @@ -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) {
Expand Down
75 changes: 75 additions & 0 deletions src/lib/args.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,81 @@ export function parseWhen(value: string, now: Date = new Date()): string {
return value
}

// A bare `agent <text>` 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,
Expand Down
67 changes: 67 additions & 0 deletions test/args.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@ import {
collectKeyValue,
collectSource,
collectStatus,
commandTypoMessage,
looksLikeCommandTypo,
parseScope,
similarCommands,
parseWhen,
toInt,
} from '../src/lib/args'
Expand Down Expand Up @@ -94,3 +97,67 @@ describe('parseWhen', () => {
expect(() => parseWhen('', now)).toThrow(/ISO 8601/)
})
})

// A bare `agent <text>` 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')
})
})
44 changes: 44 additions & 0 deletions test/session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | undefined> {
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()
})
})