From 7e27e9193de01e47b2f8afcd439c67b6447ef552 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 9 Jul 2026 11:55:28 +0200 Subject: [PATCH 1/3] fix: suggest canonical commands for unknown command names Agents commonly guess command names that don't exist, e.g. relaunch/launch instead of `open --relaunch`, burning turns on Unknown command errors that only say "run --help". Add a curated alias-to-canonical-shape map for the most common guesses (launch/relaunch/start/restart, touch, input/ settext/entertext, screencap/capture, dismiss), backed by a nearest-name edit-distance fallback derived from the live command registry so suggestions can't drift. Also hint that `open` takes the app/bundle id as a positional when an unknown flag looks like a bundle-id guess (e.g. --bundle-id), and apply the same suggestion to `help `. Suggestions are display-only; nothing auto-executes and the error code stays INVALID_ARGS. --- src/cli.ts | 10 +- .../__tests__/command-suggestions.test.ts | 136 ++++++++++++++++++ src/cli/parser/args.ts | 13 +- src/cli/parser/command-suggestions.ts | 127 ++++++++++++++++ 4 files changed, 275 insertions(+), 11 deletions(-) create mode 100644 src/cli/parser/__tests__/command-suggestions.test.ts create mode 100644 src/cli/parser/command-suggestions.ts 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__/command-suggestions.test.ts b/src/cli/parser/__tests__/command-suggestions.test.ts new file mode 100644 index 000000000..a3959c921 --- /dev/null +++ b/src/cli/parser/__tests__/command-suggestions.test.ts @@ -0,0 +1,136 @@ +import assert from 'node:assert/strict'; +import { test } from 'vitest'; +import { isKnownCliCommandName } from '../../../command-catalog.ts'; +import { AppError } from '../../../kernel/errors.ts'; +import { parseArgs } from '../args.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}")`, + ); + } +}); + +test('relaunch suggests the canonical open --relaunch shape', () => { + assert.throws( + () => parseArgs(['relaunch', 'com.example.app']), + (error: unknown) => + error instanceof AppError && + error.code === 'INVALID_ARGS' && + error.message === 'Unknown command: relaunch. Did you mean open --relaunch?', + ); +}); + +for (const guess of ['launch', '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('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('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..bdb07014c 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; @@ -86,7 +87,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]); @@ -153,7 +154,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 +346,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 diff --git a/src/cli/parser/command-suggestions.ts b/src/cli/parser/command-suggestions.ts new file mode 100644 index 000000000..445b77a62 --- /dev/null +++ b/src/cli/parser/command-suggestions.ts @@ -0,0 +1,127 @@ +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 `relaunch` instead of `open --relaunch`. Each entry's + * `command` must resolve to a real, registered CLI command name; the + * "alias suggestion targets exist" test in + * `src/cli/parser/__tests__/command-suggestions.test.ts` fails the build on drift. + */ +type CommandAliasSuggestion = { + /** Canonical command name this guess should have used. */ + command: string; + /** Full example invocation shown to the user. */ + example: string; +}; + +const COMMAND_ALIAS_SUGGESTIONS: Record = { + launch: { command: 'open', example: 'open --relaunch' }, + relaunch: { command: 'open', example: 'open --relaunch' }, + start: { command: 'open', example: 'open --relaunch' }, + restart: { command: 'open', example: 'open --relaunch' }, + 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); +} + +function getCuratedCommandSuggestion(command: string): string | undefined { + return COMMAND_ALIAS_SUGGESTIONS[command]?.example; +} + +const NEAREST_COMMAND_SUGGESTION_LIMIT = 3; + +/** + * Nearest registered command names for an unrecognized command, ranked by a + * prefix-aware edit distance. 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. + */ +export function getNearestCommandNames(command: string): string[] { + const names = listCliCommandNames(); + const scored = names + .map((name) => ({ name, distance: commandNameDistance(command, name) })) + .filter((entry) => entry.distance <= nearestMatchThreshold(command)) + .sort((a, b) => a.distance - b.distance || a.name.localeCompare(b.name)); + return scored.slice(0, NEAREST_COMMAND_SUGGESTION_LIMIT).map((entry) => entry.name); +} + +function nearestMatchThreshold(command: string): number { + if (command.length <= 3) 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. + */ +export function suggestCommandFor(command: string): string | undefined { + const curated = getCuratedCommandSuggestion(command); + if (curated) return curated; + const nearest = getNearestCommandNames(command); + 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.`; + } + return `Unknown flag: ${token}`; +} From 96778f371732e9006a9f6a403db19b3ea4354eb5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 9 Jul 2026 12:13:43 +0200 Subject: [PATCH 2/3] =?UTF-8?q?fix:=20address=20review=20=E2=80=94=20dead?= =?UTF-8?q?=20export,=20case-insensitive=20suggestions,=20tighter=20neares?= =?UTF-8?q?t-name=20matching?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Drop the export on getNearestCommandNames (module-private; only suggestCommandFor uses it) to satisfy the Fallow unused-export gate. - Lowercase the input token before both the curated-map lookup and the nearest-name pass, so RELAUNCH/Relaunch/TAP/Touch get the same hint as their lowercase forms. Added a curated `tap` entry: lowercase `tap` is normalized to press before the unknown-command check, so the entry only catches case variants like TAP. - Tighten the nearest-name fallback: exact prefix matches win outright (`clos` now suggests only `close`, not "one of: close, logs"), otherwise only ties at the minimum edit distance are kept, and 1-2 character tokens never get a suggestion (`ls` no longer suggests `is`). - Share the "open --relaunch" example string between the curated map and the unknown-flag hint, and extend the registry-drift tests to parse each curated example end-to-end (validates open --relaunch as a registered flag) plus assert keyboard dismiss is a real keyboard action. --- .../__tests__/command-suggestions.test.ts | 56 +++++++++++++++ src/cli/parser/command-suggestions.ts | 72 ++++++++++++------- 2 files changed, 103 insertions(+), 25 deletions(-) diff --git a/src/cli/parser/__tests__/command-suggestions.test.ts b/src/cli/parser/__tests__/command-suggestions.test.ts index a3959c921..ca6567322 100644 --- a/src/cli/parser/__tests__/command-suggestions.test.ts +++ b/src/cli/parser/__tests__/command-suggestions.test.ts @@ -1,8 +1,10 @@ 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 @@ -21,6 +23,26 @@ test('every curated alias suggestion target resolves to a registered 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)); +}); + test('relaunch suggests the canonical open --relaunch shape', () => { assert.throws( () => parseArgs(['relaunch', 'com.example.app']), @@ -87,6 +109,18 @@ test('screencap and capture suggest screenshot', () => { } }); +test('curated suggestions are case-insensitive', () => { + assert.equal(suggestCommandFor('RELAUNCH'), 'open --relaunch'); + assert.equal(suggestCommandFor('Relaunch'), 'open --relaunch'); + assert.equal(suggestCommandFor('TAP'), 'press'); + assert.equal(suggestCommandFor('Touch'), 'press'); +}); + +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']), @@ -107,6 +141,28 @@ test('near-miss typos of real commands are suggested via edit distance', () => { ); }); +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) { diff --git a/src/cli/parser/command-suggestions.ts b/src/cli/parser/command-suggestions.ts index 445b77a62..dc2a3f80f 100644 --- a/src/cli/parser/command-suggestions.ts +++ b/src/cli/parser/command-suggestions.ts @@ -4,10 +4,14 @@ 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 `relaunch` instead of `open --relaunch`. Each entry's - * `command` must resolve to a real, registered CLI command name; the - * "alias suggestion targets exist" test in - * `src/cli/parser/__tests__/command-suggestions.test.ts` fails the build on drift. + * spelling, such as `relaunch` 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. + * + * `tap` is normalized to `press` as a true alias before the unknown-command + * check runs, so its entry here only catches case variants such as `TAP`. */ type CommandAliasSuggestion = { /** Canonical command name this guess should have used. */ @@ -16,11 +20,14 @@ type CommandAliasSuggestion = { example: string; }; +const OPEN_RELAUNCH_EXAMPLE = 'open --relaunch'; + const COMMAND_ALIAS_SUGGESTIONS: Record = { - launch: { command: 'open', example: 'open --relaunch' }, - relaunch: { command: 'open', example: 'open --relaunch' }, - start: { command: 'open', example: 'open --relaunch' }, - restart: { command: 'open', example: 'open --relaunch' }, + launch: { command: 'open', example: OPEN_RELAUNCH_EXAMPLE }, + relaunch: { command: 'open', example: OPEN_RELAUNCH_EXAMPLE }, + start: { command: 'open', example: OPEN_RELAUNCH_EXAMPLE }, + restart: { command: 'open', example: OPEN_RELAUNCH_EXAMPLE }, + tap: { command: 'press', example: 'press' }, touch: { command: 'press', example: 'press' }, input: { command: 'fill', example: 'fill' }, settext: { command: 'fill', example: 'fill' }, @@ -34,29 +41,42 @@ export function listCommandAliasSuggestionEntries(): Array<[string, CommandAlias return Object.entries(COMMAND_ALIAS_SUGGESTIONS); } -function getCuratedCommandSuggestion(command: string): string | undefined { - return COMMAND_ALIAS_SUGGESTIONS[command]?.example; -} - const NEAREST_COMMAND_SUGGESTION_LIMIT = 3; /** - * Nearest registered command names for an unrecognized command, ranked by a - * prefix-aware edit distance. 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. + * 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. */ -export function getNearestCommandNames(command: string): string[] { +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 <= nearestMatchThreshold(command)) - .sort((a, b) => a.distance - b.distance || a.name.localeCompare(b.name)); - return scored.slice(0, NEAREST_COMMAND_SUGGESTION_LIMIT).map((entry) => entry.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 <= 3) return 1; + if (command.length < 4) return 1; if (command.length <= 6) return 2; return 3; } @@ -91,12 +111,14 @@ function levenshteinDistance(a: string, b: string): number { /** * 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. + * 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 curated = getCuratedCommandSuggestion(command); + const normalized = command.toLowerCase(); + const curated = COMMAND_ALIAS_SUGGESTIONS[normalized]?.example; if (curated) return curated; - const nearest = getNearestCommandNames(command); + const nearest = getNearestCommandNames(normalized); if (nearest.length === 0) return undefined; if (nearest.length === 1) return nearest[0]; return `one of: ${nearest.join(', ')}`; @@ -121,7 +143,7 @@ const POSITIONAL_APP_FLAG_GUESSES = new Set([ 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.`; + return `Unknown flag: ${token}. The app or bundle id is a positional argument, e.g. ${OPEN_RELAUNCH_EXAMPLE}.`; } return `Unknown flag: ${token}`; } From 0d0b823a42e7036d8dc3a9d9706305508d3d8436 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 9 Jul 2026 18:41:21 +0200 Subject: [PATCH 3/3] feat: promote launch and relaunch to true open aliases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow the tap -> press precedent: `relaunch ` now runs `open ` with --relaunch injected, and `launch ` runs a plain `open ` (no forced restart — that would silently destroy app state). Both are normalized in normalizeCommandAlias before parsing, so command identity stays `open` for daemon requests and telemetry, all other args/flags pass through to open's normal validation (URL targets still get the daemon's existing --relaunch guidance), and an explicit --relaunch stays idempotent. Alias matching is now case-insensitive (TAP, RELAUNCH, Launch), so the curated tap suggestion entry is dead and removed along with launch/relaunch; start/restart and the rest of the map stay suggestion-only since start is genuinely ambiguous. --- src/__tests__/cli-help.test.ts | 19 ++++++++ .../__tests__/args-parse-session.test.ts | 48 +++++++++++++++++++ .../__tests__/command-suggestions.test.ts | 24 +++++----- src/cli/parser/args.ts | 38 ++++++++++++--- src/cli/parser/command-suggestions.ts | 12 ++--- 5 files changed, 116 insertions(+), 25 deletions(-) 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/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 index ca6567322..7c2ffbf96 100644 --- a/src/cli/parser/__tests__/command-suggestions.test.ts +++ b/src/cli/parser/__tests__/command-suggestions.test.ts @@ -43,17 +43,17 @@ test('the keyboard dismiss example uses a real keyboard action', () => { assert.doesNotThrow(() => keyboardCliReader(['dismiss'], baseFlags)); }); -test('relaunch suggests the canonical open --relaunch shape', () => { - assert.throws( - () => parseArgs(['relaunch', 'com.example.app']), - (error: unknown) => - error instanceof AppError && - error.code === 'INVALID_ARGS' && - error.message === 'Unknown command: relaunch. Did you mean open --relaunch?', - ); +// `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 ['launch', 'start', 'restart']) { +for (const guess of ['start', 'restart']) { test(`${guess} suggests the canonical open --relaunch shape`, () => { assert.throws( () => parseArgs([guess, 'com.example.app']), @@ -110,10 +110,10 @@ test('screencap and capture suggest screenshot', () => { }); test('curated suggestions are case-insensitive', () => { - assert.equal(suggestCommandFor('RELAUNCH'), 'open --relaunch'); - assert.equal(suggestCommandFor('Relaunch'), 'open --relaunch'); - assert.equal(suggestCommandFor('TAP'), 'press'); + 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', () => { diff --git a/src/cli/parser/args.ts b/src/cli/parser/args.ts index bdb07014c..4fd332d4d 100644 --- a/src/cli/parser/args.ts +++ b/src/cli/parser/args.ts @@ -44,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[] = []; @@ -56,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)) { @@ -67,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; } @@ -106,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'); } @@ -369,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 index dc2a3f80f..67c059f5b 100644 --- a/src/cli/parser/command-suggestions.ts +++ b/src/cli/parser/command-suggestions.ts @@ -4,14 +4,17 @@ 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 `relaunch` instead of `open --relaunch`. Keys must be + * 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. * - * `tap` is normalized to `press` as a true alias before the unknown-command - * check runs, so its entry here only catches case variants such as `TAP`. + * 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. */ @@ -23,11 +26,8 @@ type CommandAliasSuggestion = { const OPEN_RELAUNCH_EXAMPLE = 'open --relaunch'; const COMMAND_ALIAS_SUGGESTIONS: Record = { - launch: { command: 'open', example: OPEN_RELAUNCH_EXAMPLE }, - relaunch: { command: 'open', example: OPEN_RELAUNCH_EXAMPLE }, start: { command: 'open', example: OPEN_RELAUNCH_EXAMPLE }, restart: { command: 'open', example: OPEN_RELAUNCH_EXAMPLE }, - tap: { command: 'press', example: 'press' }, touch: { command: 'press', example: 'press' }, input: { command: 'fill', example: 'fill' }, settext: { command: 'fill', example: 'fill' },