diff --git a/src/cli.ts b/src/cli.ts index eba232eec5..49fa9347aa 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -37,6 +37,7 @@ import { import { resolveRemoteAuthForCli } from './cli/auth-session.ts'; import type { CliFlags, FlagKey } from './cli/parser/cli-flags.ts'; import type { SessionRuntimeHints } from './kernel/contracts.ts'; +import { isKnownCliCommandName } from './command-catalog.ts'; type CliDeps = { sendToDaemon: typeof sendToDaemon; @@ -405,6 +406,13 @@ export async function runCli(argv: string[], deps: CliDeps = DEFAULT_CLI_DEPS): return; } + if (isKnownCliCommandName(command)) { + emitDiagnostic({ + level: 'error', + phase: 'cli_known_command_unhandled', + data: { command }, + }); + } throw new AppError('INVALID_ARGS', `Unknown command: ${command}`); } catch (err) { const appErr = asAppError(err); diff --git a/src/cli/parser/args.ts b/src/cli/parser/args.ts index 161677e3c9..b9e5954176 100644 --- a/src/cli/parser/args.ts +++ b/src/cli/parser/args.ts @@ -11,7 +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'; +import { isKnownCliCommandName } from '../../command-catalog.ts'; type ParsedArgs = { command: string | null; @@ -153,7 +153,7 @@ export function finalizeParsedArgs( // 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) { + if (parsed.command && !isKnownCliCommandName(parsed.command) && !flags.help) { const hint = getCommandAliasSuggestion(parsed.command); const message = hint ? `Unknown command: ${parsed.command}. Did you mean ${hint}?` @@ -346,17 +346,6 @@ 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', }; diff --git a/src/command-catalog.ts b/src/command-catalog.ts index 502f02c5a6..639460a84b 100644 --- a/src/command-catalog.ts +++ b/src/command-catalog.ts @@ -61,6 +61,8 @@ export const INTERNAL_COMMANDS = { sessionList: 'session_list', } as const; +export const CLI_HELP_COMMAND = 'help'; + const LOCAL_CLI_COMMANDS = { cdp: 'cdp', auth: 'auth', @@ -81,8 +83,10 @@ export type GestureKind = (typeof GESTURE_KINDS)[number]; export const GESTURE_SUBCOMMAND_ERROR = `gesture requires one of: ${GESTURE_KINDS.join(', ')}`; export type PublicCommandName = (typeof PUBLIC_COMMANDS)[keyof typeof PUBLIC_COMMANDS]; +export type InternalCommandName = (typeof INTERNAL_COMMANDS)[keyof typeof INTERNAL_COMMANDS]; export type LocalCliCommandName = (typeof LOCAL_CLI_COMMANDS)[keyof typeof LOCAL_CLI_COMMANDS]; export type CliCommandName = PublicCommandName | LocalCliCommandName; +export type KnownCliCommandName = CliCommandName | InternalCommandName | typeof CLI_HELP_COMMAND; export type ClientBackedCliCommandName = | PublicCommandName | typeof LOCAL_CLI_COMMANDS.debug @@ -127,6 +131,13 @@ const CAPABILITY_EXEMPT_CLI_COMMANDS = commandSet( PUBLIC_COMMANDS.trace, ); +const KNOWN_CLI_COMMANDS = commandSet( + ...Object.values(PUBLIC_COMMANDS), + ...Object.values(LOCAL_CLI_COMMANDS), + ...Object.values(INTERNAL_COMMANDS), + CLI_HELP_COMMAND, +); + function commandSet(...commands: readonly string[]): ReadonlySet { return new Set(commands); } @@ -135,6 +146,14 @@ export function listCliCommandNames(): CliCommandName[] { return [...Object.values(PUBLIC_COMMANDS), ...Object.values(LOCAL_CLI_COMMANDS)].sort(); } +export function listKnownCliCommandNames(): KnownCliCommandName[] { + return [...KNOWN_CLI_COMMANDS].sort() as KnownCliCommandName[]; +} + +export function isKnownCliCommandName(command: string): command is KnownCliCommandName { + return KNOWN_CLI_COMMANDS.has(command); +} + export function isClientBackedCliCommandName( command: string, ): command is ClientBackedCliCommandName { diff --git a/src/utils/__tests__/args.test.ts b/src/utils/__tests__/args.test.ts index 11ac47fe20..8e874a010f 100644 --- a/src/utils/__tests__/args.test.ts +++ b/src/utils/__tests__/args.test.ts @@ -1,9 +1,17 @@ import { test } from 'vitest'; import assert from 'node:assert/strict'; +import fs from 'node:fs'; import { parseArgs, usage, usageForCommand } from '../../cli/parser/args.ts'; import { AppError } from '../../kernel/errors.ts'; import { listCapabilityCommands } from '../../core/capabilities.ts'; -import { listCapabilityCheckedCommandNames, listCliCommandNames } from '../../command-catalog.ts'; +import { + CLI_HELP_COMMAND, + INTERNAL_COMMANDS, + isKnownCliCommandName, + listCapabilityCheckedCommandNames, + listCliCommandNames, + listKnownCliCommandNames, +} from '../../command-catalog.ts'; import { getCliCommandSchema } from '../command-schema.ts'; test('parseArgs recognizes command-specific flag combinations', async () => { @@ -1935,6 +1943,40 @@ test('every CLI command has a derived or local parser schema entry', () => { } }); +test('known CLI command predicate covers public, local, internal, and help commands', () => { + for (const command of listCliCommandNames()) { + assert.equal(isKnownCliCommandName(command), true, `${command} must be known`); + } + for (const command of Object.values(INTERNAL_COMMANDS)) { + assert.equal(isKnownCliCommandName(command), true, `${command} must be known`); + } + assert.equal(isKnownCliCommandName(CLI_HELP_COMMAND), true); + assert.equal(isKnownCliCommandName('tap'), false); +}); + +test('parser known-command check stays in sync with the catalog predicate', () => { + for (const command of listKnownCliCommandNames()) { + try { + parseArgs([command, '--session', 'parser-drift-check'], { strictFlags: true }); + } catch (error) { + assert.ok(error instanceof Error); + assert.doesNotMatch(error.message, /Unknown command/, `${command} must not parse as unknown`); + } + } +}); + +test('catalog known-command predicate covers cli.ts literal command branches', () => { + const cliSource = fs.readFileSync(new URL('../../cli.ts', import.meta.url), 'utf8'); + const branchCommands = [...cliSource.matchAll(/(? match[1]!, + ); + assert.ok(branchCommands.length > 0, 'expected cli.ts command branches'); + + for (const command of [...new Set(branchCommands)].sort()) { + assert.equal(isKnownCliCommandName(command), true, `${command} must be known`); + } +}); + test('schema capability mappings match capability source-of-truth', () => { assert.deepEqual( listCapabilityCheckedCommandNames(),