From 870f548d425236ca9b4784976d53f0ebd8203d6a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 3 Jul 2026 08:05:25 +0200 Subject: [PATCH 1/2] fix: report unknown command before flag-support validation When an unknown command is provided with flags, the parser was validating flag support before checking if the command exists, resulting in misleading "Flags are not supported for command X" errors instead of "Unknown command: X". This fix reorders the validation to check for unknown commands first, reporting more helpful error messages. Additionally, when the unknown command is 'tap', the error now includes a hint suggesting 'press or click' as alternatives. Closes #1036 --- src/__tests__/cli-help.test.ts | 16 ++++++++++++++++ src/cli/parser/args.ts | 29 +++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+) 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..9485598b36 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,22 @@ function normalizeParsedCommandAliases(parsed: ParsedArgs): ParsedArgs { return parsed; } +function isCommandKnown(command: string): boolean { + // '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).includes(command as any)) return true; + return listCliCommandNames().includes(command as any); +} + +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 From 6df7d4e4bb10493fee67a93ae40db56c8cb1e1f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 3 Jul 2026 10:02:29 +0200 Subject: [PATCH 2/2] refactor: drop as-any casts and note the cli.ts sync requirement Review follow-ups: widen the command lists to readonly string[] instead of narrowing the value, and document that isCommandKnown must stay in sync with the cli.ts dispatch fall-through. --- src/cli/parser/args.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/cli/parser/args.ts b/src/cli/parser/args.ts index 9485598b36..161677e3c9 100644 --- a/src/cli/parser/args.ts +++ b/src/cli/parser/args.ts @@ -347,11 +347,14 @@ function normalizeParsedCommandAliases(parsed: ParsedArgs): ParsedArgs { } 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).includes(command as any)) return true; - return listCliCommandNames().includes(command as any); + if ((Object.values(INTERNAL_COMMANDS) as readonly string[]).includes(command)) return true; + return (listCliCommandNames() as readonly string[]).includes(command); } const COMMAND_ALIAS_SUGGESTIONS: Record = {