diff --git a/src/__tests__/cli-help.test.ts b/src/__tests__/cli-help.test.ts index 746a8bbbd..e306b8b51 100644 --- a/src/__tests__/cli-help.test.ts +++ b/src/__tests__/cli-help.test.ts @@ -211,6 +211,25 @@ test('tap dispatches as press with positionals and flags preserved', async () => assert.deepEqual(result.calls[0]?.positionals, ['@e3']); }); +test('relaunch dispatches as open with the relaunch flag injected', async () => { + const result = await runCliCapture(['relaunch', 'com.example.app', '--json']); + assert.doesNotMatch(result.stderr, /Unknown command/); + // Canonicalization: the daemon call must record open, never relaunch. + assert.equal(result.calls.length, 1); + assert.equal(result.calls[0]?.command, 'open'); + assert.deepEqual(result.calls[0]?.positionals, ['com.example.app']); + assert.equal(result.calls[0]?.flags?.relaunch, true); +}); + +test('launch dispatches as a plain open without forcing a relaunch', async () => { + const result = await runCliCapture(['launch', 'com.example.app', '--json']); + assert.doesNotMatch(result.stderr, /Unknown command/); + assert.equal(result.calls.length, 1); + assert.equal(result.calls[0]?.command, 'open'); + assert.deepEqual(result.calls[0]?.positionals, ['com.example.app']); + assert.notEqual(result.calls[0]?.flags?.relaunch, true); +}); + // From #1052 (credit: @vku2018): the alias must compose with the bare-ref // hint — `tap e3` normalizes to press, then gets the @e3 suggestion. test('tap with a bare ref gets the @ref hint, not an unknown-command error', async () => { diff --git a/src/cli.ts b/src/cli.ts index f6a11ec1b..b095f29db 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,4 +1,5 @@ import { parseRawArgs, usage, usageForCommand } from './cli/parser/args.ts'; +import { suggestCommandFor } from './cli/parser/command-suggestions.ts'; import { asAppError, AppError, normalizeError } from './kernel/errors.ts'; import { printHumanError, printJson } from './utils/output.ts'; import { readVersion } from './utils/version.ts'; @@ -146,7 +147,7 @@ export async function runCli(argv: string[], deps: CliDeps = DEFAULT_CLI_DEPS): process.stdout.write(commandHelp); process.exit(0); } - printHumanError(new AppError('INVALID_ARGS', `Unknown command: ${helpTarget}`)); + printHumanError(new AppError('INVALID_ARGS', formatUnknownHelpTargetMessage(helpTarget))); process.stdout.write(`${await usage()}\n`); process.exit(1); } @@ -462,6 +463,13 @@ function isDebugRequested(argv: string[]): boolean { } } +function formatUnknownHelpTargetMessage(helpTarget: string): string { + const hint = suggestCommandFor(helpTarget); + return hint + ? `Unknown command: ${helpTarget}. Did you mean ${hint}?` + : `Unknown command: ${helpTarget}`; +} + function formatUnhandledCommandMessage(command: string): string { if (isKnownCliCommandName(command)) { // Registered-but-unhandled means catalog/dispatch drift — make it visible diff --git a/src/cli/parser/__tests__/args-parse-session.test.ts b/src/cli/parser/__tests__/args-parse-session.test.ts index 50fda9ea1..a202c93c6 100644 --- a/src/cli/parser/__tests__/args-parse-session.test.ts +++ b/src/cli/parser/__tests__/args-parse-session.test.ts @@ -891,3 +891,51 @@ test('apps defaults to user-installed filter and allows overrides', () => { /Unknown flag: --user-installed/, ); }); + +// `relaunch` and `launch` are true command aliases for open (like tap -> +// press): normalization is pre-dispatch, so command identity stays `open` +// everywhere downstream (daemon requests, telemetry). +test('relaunch is a true alias for open with --relaunch injected', () => { + const parsed = parseArgs(['relaunch', 'com.example.app'], { strictFlags: true }); + assert.equal(parsed.command, 'open'); + assert.deepEqual(parsed.positionals, ['com.example.app']); + assert.equal(parsed.flags.relaunch, true); +}); + +test('launch is a true alias for a plain open without --relaunch', () => { + // Aliasing launch to a forced restart would silently destroy app state. + const parsed = parseArgs(['launch', 'com.example.app'], { strictFlags: true }); + assert.equal(parsed.command, 'open'); + assert.deepEqual(parsed.positionals, ['com.example.app']); + assert.equal(parsed.flags.relaunch, undefined); +}); + +test('relaunch with an explicit --relaunch stays idempotent', () => { + const parsed = parseArgs(['relaunch', 'com.example.app', '--relaunch'], { strictFlags: true }); + assert.equal(parsed.command, 'open'); + assert.deepEqual(parsed.positionals, ['com.example.app']); + assert.equal(parsed.flags.relaunch, true); +}); + +test('command aliases normalize case-insensitively', () => { + const relaunch = parseArgs(['RELAUNCH', 'com.example.app'], { strictFlags: true }); + assert.equal(relaunch.command, 'open'); + assert.equal(relaunch.flags.relaunch, true); + + const launch = parseArgs(['Launch', 'com.example.app'], { strictFlags: true }); + assert.equal(launch.command, 'open'); + assert.equal(launch.flags.relaunch, undefined); + + const tap = parseArgs(['TAP', '@e3'], { strictFlags: true }); + assert.equal(tap.command, 'press'); + assert.deepEqual(tap.positionals, ['@e3']); +}); + +test('relaunch passes non-command args through to open validation untouched', () => { + // URL targets parse fine here; the daemon's existing "open --relaunch does + // not support URL targets" guidance owns the semantic rejection. + const parsed = parseArgs(['relaunch', 'rne://url'], { strictFlags: true }); + assert.equal(parsed.command, 'open'); + assert.deepEqual(parsed.positionals, ['rne://url']); + assert.equal(parsed.flags.relaunch, true); +}); diff --git a/src/cli/parser/__tests__/command-suggestions.test.ts b/src/cli/parser/__tests__/command-suggestions.test.ts new file mode 100644 index 000000000..7c2ffbf96 --- /dev/null +++ b/src/cli/parser/__tests__/command-suggestions.test.ts @@ -0,0 +1,192 @@ +import assert from 'node:assert/strict'; +import { test } from 'vitest'; +import { isKnownCliCommandName } from '../../../command-catalog.ts'; +import { keyboardCliReader } from '../../../commands/system/index.ts'; +import { AppError } from '../../../kernel/errors.ts'; +import { parseArgs } from '../args.ts'; +import type { CliFlags } from '../cli-flags.ts'; +import { listCommandAliasSuggestionEntries, suggestCommandFor } from '../command-suggestions.ts'; + +// Guards against the curated alias map drifting to a command that no longer +// exists (renamed, removed, gated) in the live command registry. +test('every curated alias suggestion target resolves to a registered command', () => { + for (const [guess, suggestion] of listCommandAliasSuggestionEntries()) { + assert.ok( + isKnownCliCommandName(suggestion.command), + `alias suggestion for "${guess}" points at unregistered command "${suggestion.command}"`, + ); + assert.ok( + suggestion.example === suggestion.command || + suggestion.example.startsWith(`${suggestion.command} `), + `alias suggestion example for "${guess}" ("${suggestion.example}") must start with its command ("${suggestion.command}")`, + ); + } +}); + +// Guards the full example shapes, not just the leading command token: every +// example must parse as a valid invocation (placeholders substituted), so a +// renamed flag (e.g. open --relaunch) or a dropped subcommand fails here. +test('every curated alias suggestion example parses as a valid invocation', () => { + for (const [guess, suggestion] of listCommandAliasSuggestionEntries()) { + const tokens = suggestion.example + .split(' ') + .map((token) => (token.startsWith('<') ? 'com.example.app' : token)); + assert.doesNotThrow( + () => parseArgs(tokens, { strictFlags: true }), + `alias suggestion example for "${guess}" ("${suggestion.example}") no longer parses`, + ); + } +}); + +test('the keyboard dismiss example uses a real keyboard action', () => { + const baseFlags: CliFlags = { json: false, help: false, version: false }; + assert.doesNotThrow(() => keyboardCliReader(['dismiss'], baseFlags)); +}); + +// `launch`/`relaunch` (and `tap`) are true aliases normalized before the +// unknown-command check, so they must never appear in the suggestion map — +// a stale entry there would be dead code masking the alias. +test('true aliases are not listed in the curated suggestion map', () => { + const guesses = new Set(listCommandAliasSuggestionEntries().map(([guess]) => guess)); + for (const alias of ['launch', 'relaunch', 'tap']) { + assert.ok(!guesses.has(alias), `"${alias}" is a true alias and must not be a suggestion`); + } +}); + +for (const guess of ['start', 'restart']) { + test(`${guess} suggests the canonical open --relaunch shape`, () => { + assert.throws( + () => parseArgs([guess, 'com.example.app']), + (error: unknown) => + error instanceof AppError && + error.code === 'INVALID_ARGS' && + error.message.includes('Did you mean open --relaunch?'), + ); + }); +} + +test('touch suggests press', () => { + assert.throws( + () => parseArgs(['touch', '100', '200']), + (error: unknown) => + error instanceof AppError && + error.code === 'INVALID_ARGS' && + error.message === 'Unknown command: touch. Did you mean press?', + ); +}); + +test('dismiss suggests keyboard dismiss', () => { + assert.throws( + () => parseArgs(['dismiss']), + (error: unknown) => + error instanceof AppError && + error.code === 'INVALID_ARGS' && + error.message === 'Unknown command: dismiss. Did you mean keyboard dismiss?', + ); +}); + +test('input and settext suggest fill', () => { + for (const guess of ['input', 'settext', 'entertext']) { + assert.throws( + () => parseArgs([guess, '@e1', 'hello']), + (error: unknown) => + error instanceof AppError && + error.code === 'INVALID_ARGS' && + error.message === `Unknown command: ${guess}. Did you mean fill?`, + ); + } +}); + +test('screencap and capture suggest screenshot', () => { + for (const guess of ['screencap', 'capture']) { + assert.throws( + () => parseArgs([guess]), + (error: unknown) => + error instanceof AppError && + error.code === 'INVALID_ARGS' && + error.message === `Unknown command: ${guess}. Did you mean screenshot?`, + ); + } +}); + +test('curated suggestions are case-insensitive', () => { + assert.equal(suggestCommandFor('RESTART'), 'open --relaunch'); + assert.equal(suggestCommandFor('Restart'), 'open --relaunch'); + assert.equal(suggestCommandFor('Touch'), 'press'); + assert.equal(suggestCommandFor('DISMISS'), 'keyboard dismiss'); +}); + +test('known command names in the wrong case suggest their lowercase form', () => { + assert.equal(suggestCommandFor('OPEN'), 'open'); + assert.equal(suggestCommandFor('Press'), 'press'); +}); + +test('nonsense command names fall back to nearest-name suggestion or a plain error, never a crash', () => { + assert.throws( + () => parseArgs(['frobnicate']), + (error: unknown) => + error instanceof AppError && + error.code === 'INVALID_ARGS' && + error.message === 'Unknown command: frobnicate', + ); +}); + +test('near-miss typos of real commands are suggested via edit distance', () => { + assert.throws( + () => parseArgs(['presss', '100', '200']), + (error: unknown) => + error instanceof AppError && + error.code === 'INVALID_ARGS' && + error.message === 'Unknown command: presss. Did you mean press?', + ); +}); + +test('a prefix match wins outright instead of bundling weaker edit-distance ties', () => { + // Without the prefix rule, `clos` would suggest "one of: close, logs". + assert.throws( + () => parseArgs(['clos']), + (error: unknown) => + error instanceof AppError && + error.code === 'INVALID_ARGS' && + error.message === 'Unknown command: clos. Did you mean close?', + ); +}); + +test('1-2 character tokens never get a nearest-name suggestion', () => { + // `ls` is one edit from `is`, but suggesting `is` would be a false positive. + assert.throws( + () => parseArgs(['ls']), + (error: unknown) => + error instanceof AppError && + error.code === 'INVALID_ARGS' && + error.message === 'Unknown command: ls', + ); +}); + +test('suggestCommandFor never throws for arbitrary input', () => { + const inputs = ['', ' ', '@#$%', 'a'.repeat(200), 'RELAUNCH', 'Relaunch']; + for (const input of inputs) { + assert.doesNotThrow(() => suggestCommandFor(input)); + } +}); + +test('unknown flag that looks like an app/bundle id hints at the open positional', () => { + assert.throws( + () => parseArgs(['launch', '--bundle-id', 'com.example.app']), + (error: unknown) => + error instanceof AppError && + error.code === 'INVALID_ARGS' && + error.message === + 'Unknown flag: --bundle-id. The app or bundle id is a positional argument, e.g. open --relaunch.', + ); +}); + +test('unrelated unknown flags are unaffected', () => { + assert.throws( + () => parseArgs(['press', '100', '200', '--not-a-real-flag']), + (error: unknown) => + error instanceof AppError && + error.code === 'INVALID_ARGS' && + error.message === 'Unknown flag: --not-a-real-flag', + ); +}); diff --git a/src/cli/parser/args.ts b/src/cli/parser/args.ts index cf37275ec..4fd332d4d 100644 --- a/src/cli/parser/args.ts +++ b/src/cli/parser/args.ts @@ -11,6 +11,7 @@ import { } from '../../utils/command-schema.ts'; import { isFlagSupportedForCommand } from '../../utils/cli-option-schema.ts'; import { isKnownCliCommandName } from '../../command-catalog.ts'; +import { formatUnknownFlagMessage, suggestCommandFor } from './command-suggestions.ts'; type ParsedArgs = { command: string | null; @@ -43,6 +44,7 @@ export function parseArgs(argv: string[], options?: FinalizeArgsOptions): Parsed export function parseRawArgs(argv: string[]): RawParsedArgs { const flags: CliFlags = { json: false, help: false, version: false }; let command: string | null = null; + let rawCommand: string | null = null; const positionals: string[] = []; const warnings: string[] = []; const providedFlags: ParsedFlagRecord[] = []; @@ -55,8 +57,10 @@ export function parseRawArgs(argv: string[]): RawParsedArgs { continue; } if (!parseFlags) { - if (!command) command = normalizeCommandAlias(arg); - else positionals.push(arg); + if (!command) { + rawCommand = arg; + command = normalizeCommandAlias(arg); + } else positionals.push(arg); continue; } if (shouldPreservePostCommandArgs(command)) { @@ -66,8 +70,10 @@ export function parseRawArgs(argv: string[]): RawParsedArgs { const isLongFlag = arg.startsWith('--'); const isShortFlag = arg.startsWith('-') && arg.length > 1; if (!isLongFlag && !isShortFlag) { - if (!command) command = normalizeCommandAlias(arg); - else positionals.push(arg); + if (!command) { + rawCommand = arg; + command = normalizeCommandAlias(arg); + } else positionals.push(arg); continue; } @@ -86,7 +92,7 @@ export function parseRawArgs(argv: string[]): RawParsedArgs { else positionals.push(arg); continue; } - throw new AppError('INVALID_ARGS', `Unknown flag: ${token}`); + throw new AppError('INVALID_ARGS', formatUnknownFlagMessage(token)); } const parsed = parseFlagValue(definition, token, inlineValue, argv[i + 1]); @@ -105,9 +111,20 @@ export function parseRawArgs(argv: string[]): RawParsedArgs { providedFlags.push({ key: definition.key, token }); } + applyAliasImpliedFlags(rawCommand, flags); return { command, positionals, flags, warnings, providedFlags }; } +// `relaunch ` is a true alias for `open --relaunch`: the command +// token normalizes to open and the flag is injected here. Setting the flag is +// idempotent with an explicit --relaunch; everything else passes through to +// open's normal validation. +function applyAliasImpliedFlags(rawCommand: string | null, flags: CliFlags): void { + if (rawCommand?.toLowerCase() === 'relaunch') { + flags.relaunch = true; + } +} + function isLegacyIgnoredSnapshotShortFlag(command: string | null, token: string): boolean { return token === '-c' && (command === 'snapshot' || command === 'diff'); } @@ -153,7 +170,7 @@ export function finalizeParsedArgs( // This ensures "Unknown command" errors take precedence over flag validation errors // However, skip this check if --help is provided, since cli.ts will handle it gracefully if (parsed.command && !isKnownCliCommandName(parsed.command) && !flags.help) { - const hint = getCommandAliasSuggestion(parsed.command); + const hint = suggestCommandFor(parsed.command); const message = hint ? `Unknown command: ${parsed.command}. Did you mean ${hint}?` : `Unknown command: ${parsed.command}`; @@ -345,14 +362,6 @@ function normalizeParsedCommandAliases(parsed: ParsedArgs): ParsedArgs { return parsed; } -const COMMAND_ALIAS_SUGGESTIONS: Record = { - tap: 'press or click', -}; - -function getCommandAliasSuggestion(command: string): string | undefined { - return COMMAND_ALIAS_SUGGESTIONS[command]; -} - function formatUnsupportedFlagMessage(command: string | null, unsupported: string[]): string { if (!command) { return unsupported.length === 1 @@ -376,9 +385,17 @@ export async function usageForCommand(command: string): Promise { return buildCommandUsageText(normalizeCommandAlias(command)); } +// Alias matching is case-insensitive (`TAP`, `Relaunch`) so agent-typed case +// variants normalize instead of erroring. Non-alias tokens pass through +// unchanged, keeping the user's original casing in unknown-command errors. function normalizeCommandAlias(command: string): string { - if (command === 'long-press') return 'longpress'; - if (command === 'metrics') return 'perf'; - if (command === 'tap') return 'press'; + const normalized = command.toLowerCase(); + if (normalized === 'long-press') return 'longpress'; + if (normalized === 'metrics') return 'perf'; + if (normalized === 'tap') return 'press'; + // `launch` maps to a plain open: forcing --relaunch here would silently + // destroy app state. `relaunch` additionally injects --relaunch via + // applyAliasImpliedFlags in parseRawArgs. + if (normalized === 'launch' || normalized === 'relaunch') return 'open'; return command; } diff --git a/src/cli/parser/command-suggestions.ts b/src/cli/parser/command-suggestions.ts new file mode 100644 index 000000000..67c059f5b --- /dev/null +++ b/src/cli/parser/command-suggestions.ts @@ -0,0 +1,149 @@ +import { listCliCommandNames } from '../../command-catalog.ts'; + +/** + * Curated guess -> canonical command mapping for unknown CLI command names. + * + * Agents (and humans) commonly guess command names that don't exist under that + * spelling, such as `restart` instead of `open --relaunch`. Keys must be + * lowercase (lookups lowercase the input token first). Each entry's `command` + * must resolve to a real, registered CLI command name, and each `example` must + * parse as a valid invocation of it; the registry-drift tests in + * `src/cli/parser/__tests__/command-suggestions.test.ts` fail the build on drift. + * + * True aliases (`tap` -> press, `launch`/`relaunch` -> open) are normalized + * case-insensitively in `normalizeCommandAlias` (args.ts) before the + * unknown-command check runs, so they never reach this map and must not be + * listed here. `start`/`restart` stay suggestion-only: `start` is genuinely + * ambiguous, so a hint beats silently guessing. + */ +type CommandAliasSuggestion = { + /** Canonical command name this guess should have used. */ + command: string; + /** Full example invocation shown to the user. */ + example: string; +}; + +const OPEN_RELAUNCH_EXAMPLE = 'open --relaunch'; + +const COMMAND_ALIAS_SUGGESTIONS: Record = { + start: { command: 'open', example: OPEN_RELAUNCH_EXAMPLE }, + restart: { command: 'open', example: OPEN_RELAUNCH_EXAMPLE }, + touch: { command: 'press', example: 'press' }, + input: { command: 'fill', example: 'fill' }, + settext: { command: 'fill', example: 'fill' }, + entertext: { command: 'fill', example: 'fill' }, + screencap: { command: 'screenshot', example: 'screenshot' }, + capture: { command: 'screenshot', example: 'screenshot' }, + dismiss: { command: 'keyboard', example: 'keyboard dismiss' }, +}; + +export function listCommandAliasSuggestionEntries(): Array<[string, CommandAliasSuggestion]> { + return Object.entries(COMMAND_ALIAS_SUGGESTIONS); +} + +const NEAREST_COMMAND_SUGGESTION_LIMIT = 3; + +/** + * Nearest registered command names for an unrecognized (lowercased) command + * token. Names are derived from the live command descriptor registry (via + * `listCliCommandNames`), never hardcoded, so the suggestion list can't drift + * from what the CLI actually supports. + * + * Precision rules: 1-2 character tokens never get a suggestion, exact prefix + * matches win outright, and otherwise only ties at the minimum edit distance + * are kept so a strong match is not bundled with a coincidental weak one. + */ +function getNearestCommandNames(command: string): string[] { + if (command.length <= 2) return []; + const names = listCliCommandNames(); + const prefixMatches = names.filter((name) => name.startsWith(command)); + if (prefixMatches.length > 0) { + return prefixMatches + .sort((a, b) => a.length - b.length || a.localeCompare(b)) + .slice(0, NEAREST_COMMAND_SUGGESTION_LIMIT); + } + const threshold = nearestMatchThreshold(command); + const scored = names + .map((name) => ({ name, distance: commandNameDistance(command, name) })) + .filter((entry) => entry.distance <= threshold); + if (scored.length === 0) return []; + const best = Math.min(...scored.map((entry) => entry.distance)); + return scored + .filter((entry) => entry.distance === best) + .sort((a, b) => a.name.localeCompare(b.name)) + .slice(0, NEAREST_COMMAND_SUGGESTION_LIMIT) + .map((entry) => entry.name); +} + +function nearestMatchThreshold(command: string): number { + if (command.length < 4) return 1; + if (command.length <= 6) return 2; + return 3; +} + +function commandNameDistance(a: string, b: string): number { + if (a === b) return 0; + if (a.startsWith(b) || b.startsWith(a)) { + return Math.abs(a.length - b.length); + } + return levenshteinDistance(a, b); +} + +function levenshteinDistance(a: string, b: string): number { + const rows = a.length + 1; + const cols = b.length + 1; + const distances: number[][] = Array.from({ length: rows }, () => new Array(cols).fill(0)); + for (let i = 0; i < rows; i += 1) distances[i]![0] = i; + for (let j = 0; j < cols; j += 1) distances[0]![j] = j; + for (let i = 1; i < rows; i += 1) { + for (let j = 1; j < cols; j += 1) { + const cost = a[i - 1] === b[j - 1] ? 0 : 1; + distances[i]![j] = Math.min( + distances[i - 1]![j]! + 1, + distances[i]![j - 1]! + 1, + distances[i - 1]![j - 1]! + cost, + ); + } + } + return distances[rows - 1]![cols - 1]!; +} + +/** + * Builds the "Did you mean ...?" fragment for an unknown command, or + * `undefined` when neither the curated alias map nor the nearest-name + * fallback has a confident suggestion. Matching is case-insensitive so + * `RELAUNCH` and `Touch` get the same hint as their lowercase forms. + */ +export function suggestCommandFor(command: string): string | undefined { + const normalized = command.toLowerCase(); + const curated = COMMAND_ALIAS_SUGGESTIONS[normalized]?.example; + if (curated) return curated; + const nearest = getNearestCommandNames(normalized); + if (nearest.length === 0) return undefined; + if (nearest.length === 1) return nearest[0]; + return `one of: ${nearest.join(', ')}`; +} + +// Unknown flag names that read like an app/bundle identity concept. `open` (and +// the commands the curated map above points agents toward) take the app or +// bundle id as a positional argument, not a flag, so a bare "Unknown flag" +// error leaves agents guessing. Kept intentionally narrow to avoid false +// positives on unrelated unknown flags. +const POSITIONAL_APP_FLAG_GUESSES = new Set([ + '--bundle-id', + '--bundleid', + '--bundle', + '--package', + '--package-name', + '--packagename', + '--app-id', + '--appid', + '--pkg', +]); + +export function formatUnknownFlagMessage(token: string): string { + if (POSITIONAL_APP_FLAG_GUESSES.has(token.toLowerCase())) { + return `Unknown flag: ${token}. The app or bundle id is a positional argument, e.g. ${OPEN_RELAUNCH_EXAMPLE}.`; + } + return `Unknown flag: ${token}`; +}