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
16 changes: 16 additions & 0 deletions src/__tests__/cli-help.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/);
});
32 changes: 32 additions & 0 deletions src/cli/parser/args.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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),
);
Expand Down Expand Up @@ -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<string, string> = {
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
Expand Down
Loading