diff --git a/src/__tests__/cli-help.test.ts b/src/__tests__/cli-help.test.ts index ff1756a45e..5739f3a0b2 100644 --- a/src/__tests__/cli-help.test.ts +++ b/src/__tests__/cli-help.test.ts @@ -137,3 +137,19 @@ test('help rejects multiple positional commands and skips daemon dispatch', asyn assert.equal(result.calls.length, 0); assert.match(result.stderr, /Error \(INVALID_ARGS\): help accepts at most one command/); }); + +test('unknown command with flags reports unknown command before flag validation', async () => { + const result = await runCliCapture(['tap', 'e3', '--session', 'foo']); + assert.equal(result.code, 1); + assert.equal(result.calls.length, 0); + assert.match(result.stderr, /Error \(INVALID_ARGS\): Unknown command: tap/); + assert.match(result.stderr, /Did you mean press or click/); +}); + +test('unknown command without flags reports unknown command with alias suggestion', async () => { + const result = await runCliCapture(['tap', 'e3']); + assert.equal(result.code, 1); + assert.equal(result.calls.length, 0); + assert.match(result.stderr, /Error \(INVALID_ARGS\): Unknown command: tap/); + assert.match(result.stderr, /Did you mean press or click/); +}); diff --git a/src/cli/parser/args.ts b/src/cli/parser/args.ts index e212fb3fda..161677e3c9 100644 --- a/src/cli/parser/args.ts +++ b/src/cli/parser/args.ts @@ -11,6 +11,7 @@ import { } from '../../utils/command-schema.ts'; import { buildCommandUsageText, buildUsageText } from './cli-help.ts'; import { isFlagSupportedForCommand } from '../../utils/cli-option-schema.ts'; +import { listCliCommandNames, INTERNAL_COMMANDS } from '../../command-catalog.ts'; type ParsedArgs = { command: string | null; @@ -148,6 +149,18 @@ export function finalizeParsedArgs( options?.defaultFlags ?? {}, ); mergeDefinedFlags(flags, parsed.flags); + + // Check if the command is known before validating flags + // 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 && !isCommandKnown(parsed.command) && !flags.help) { + const hint = getCommandAliasSuggestion(parsed.command); + const message = hint + ? `Unknown command: ${parsed.command}. Did you mean ${hint}?` + : `Unknown command: ${parsed.command}`; + throw new AppError('INVALID_ARGS', message); + } + const disallowed = parsed.providedFlags.filter( (entry) => !isFlagSupportedForCommand(entry.key, parsed.command), ); @@ -333,6 +346,25 @@ function normalizeParsedCommandAliases(parsed: ParsedArgs): ParsedArgs { return parsed; } +function isCommandKnown(command: string): boolean { + // Must stay in sync with the authoritative dispatch fall-through in + // cli.ts ("Unknown command"): any command runnable there must be listed in + // the catalog (or below), or this early check rejects it at parse time. + // 'help' is handled specially in cli.ts and is not in the command catalog + if (command === 'help') return true; + // Internal commands are handled specially in cli.ts + if ((Object.values(INTERNAL_COMMANDS) as readonly string[]).includes(command)) return true; + return (listCliCommandNames() as readonly string[]).includes(command); +} + +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