Skip to content
Closed
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
8 changes: 8 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down
15 changes: 2 additions & 13 deletions src/cli/parser/args.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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}?`
Expand Down Expand Up @@ -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<string, string> = {
tap: 'press or click',
};
Expand Down
19 changes: 19 additions & 0 deletions src/command-catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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
Expand Down Expand Up @@ -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<string> {
return new Set(commands);
}
Expand All @@ -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 {
Expand Down
44 changes: 43 additions & 1 deletion src/utils/__tests__/args.test.ts
Original file line number Diff line number Diff line change
@@ -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 () => {
Expand Down Expand Up @@ -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(/(?<!\.)\bcommand\s*===\s*'([^']+)'/g)].map(
(match) => 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(),
Expand Down
Loading