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
17 changes: 0 additions & 17 deletions scripts/help-conformance-command-validator.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { pathToFileURL } from 'node:url';
import { parseArgs } from '../src/cli/parser/args.ts';
import { getCommandSchema } from '../src/cli-schema/command-schema.ts';
import { readInputFromCli } from '../src/commands/cli-grammar.ts';
import { isCommandName } from '../src/commands/command-metadata.ts';

Expand Down Expand Up @@ -34,8 +33,6 @@ export function validateAgentDeviceCommand(value: unknown): ValidationResult {
function validateParsedCommand(parsed: ReturnType<typeof parseArgs>): ValidationResult {
if (!parsed.command) return validateGlobalFlags(parsed.flags);

const arityError = validatePositionalArity(parsed.command, parsed.positionals);
if (arityError) return arityError;
const pseudoRef = targetRefCandidate(parsed.command, parsed.positionals);
if (pseudoRef && !CONCRETE_REF.test(pseudoRef)) {
return invalid('pseudo-ref', `Pseudo ref "${pseudoRef}" is not an observed @eN or @cN ref.`);
Expand All @@ -53,20 +50,6 @@ function validateGlobalFlags(flags: ReturnType<typeof parseArgs>['flags']): Vali
: invalid('agent-device-grammar', 'Missing command.');
}

function validatePositionalArity(
command: string,
positionals: string[],
): ValidationResult | undefined {
const schema = getCommandSchema(command);
if (!schema || schema.allowsExtraPositionals) return undefined;
const maximum = schema.positionalArgs?.length ?? 0;
if (positionals.length <= maximum) return undefined;
return invalid(
'agent-device-grammar',
`${command} accepts at most ${maximum} positional argument(s), received ${positionals.length}: ${positionals.join(' ')}`,
);
}

function targetRefCandidate(command: string, positionals: string[]): string | undefined {
const targetPosition = TARGET_POSITION_BY_COMMAND.get(command);
if (targetPosition !== undefined) {
Expand Down
1 change: 1 addition & 0 deletions src/cli-schema/command-overrides.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ const SCHEMA_ONLY_CLI_COMMAND_SCHEMAS = {
listUsageOverride: 'connect',
summary:
'Attach CLI commands to a saved remote daemon/cloud lease; inspect for remote runs, tenants, or service-token CI',
positionalArgs: ['provider?'],
allowedFlags: [
'remoteConfig',
'daemonBaseUrl',
Expand Down
17 changes: 17 additions & 0 deletions src/cli-schema/command-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
type FlagDefinition,
type FlagKey,
} from '../commands/cli-grammar/flag-types.ts';
import { AppError } from '../kernel/errors.ts';

export type { CliFlags, FlagDefinition, FlagKey };
export type { CommandSchema };
Expand All @@ -37,6 +38,22 @@ export function getCliCommandSchema(command: CliCommandName): CommandSchema {
return schema;
}

export function assertCommandPositionalArity(
command: string | null,
positionals: readonly string[],
context?: string,
): void {
const schema = getCommandSchema(command);
if (!command || !schema || schema.allowsExtraPositionals) return;
const maximum = schema.positionalArgs?.length ?? 0;
if (positionals.length <= maximum) return;
const subject = context ? `${context} ${command}` : command;
throw new AppError(
'INVALID_ARGS',
`${subject} accepts at most ${maximum} positional argument(s), received ${positionals.length}: ${positionals.join(' ')}`,
);
}

function readCommandSchema(command: string): CommandSchema | undefined {
const schemaOnly = getSchemaOnlyCliCommandSchema(command);
if (schemaOnly) return schemaOnly;
Expand Down
2 changes: 2 additions & 0 deletions src/cli/batch-steps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { isCommandName, type CommandName } from '../commands/command-metadata.ts
import type { CliFlags } from '../contracts/cli-flags.ts';
import { AppError } from '../kernel/errors.ts';
import { isRecord } from '../utils/parsing.ts';
import { assertCommandPositionalArity } from '../cli-schema/command-schema.ts';

type LegacyCliBatchStep = {
command: CommandName;
Expand Down Expand Up @@ -79,6 +80,7 @@ function readLegacyCliBatchStep(step: unknown, stepNumber: number): LegacyCliBat
assertLegacyBatchStepKeys(step, stepNumber);
const command = readLegacyCommand(step.command, stepNumber);
const positionals = readLegacyPositionals(step.positionals, stepNumber);
assertCommandPositionalArity(command, positionals ?? [], `Batch step ${stepNumber}`);
const flags = readLegacyFlags(step.flags, stepNumber);
const runtime = parseBatchStepRuntime(step.runtime, stepNumber);
return {
Expand Down
28 changes: 28 additions & 0 deletions src/cli/parser/__tests__/args-validation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { test } from 'vitest';
import assert from 'node:assert/strict';
import { parseArgs } from '../args.ts';
import { AppError } from '../../../kernel/errors.ts';
import { listCliCommandNames } from '../../../command-catalog.ts';
import { getCliCommandSchema } from '../../../cli-schema/command-schema.ts';

test('parseArgs rejects test retries above the supported ceiling', () => {
assert.throws(
Expand Down Expand Up @@ -130,6 +132,32 @@ test('negative numeric positionals are accepted without -- separator', () => {
assert.deepEqual(pressed.positionals, ['-10', '20']);
});

test('get accepts a snapshot ref with a multiword label', () => {
const parsed = parseArgs(['get', 'text', '@e5~s3', 'World', 'Clock'], { strictFlags: true });

assert.equal(parsed.command, 'get');
assert.deepEqual(parsed.positionals, ['text', '@e5~s3', 'World', 'Clock']);
});

test('bounded commands reject excess positionals from their CLI schema', () => {
for (const command of listCliCommandNames()) {
const schema = getCliCommandSchema(command);
if (schema.allowsExtraPositionals) continue;
const maximum = schema.positionalArgs?.length ?? 0;
const positionals = Array.from({ length: maximum + 1 }, (_, index) => `arg-${index + 1}`);

assert.throws(
() => parseArgs([command, ...positionals], { strictFlags: true }),
(error: unknown) =>
error instanceof AppError &&
error.code === 'INVALID_ARGS' &&
error.message ===
`${command} accepts at most ${maximum} positional argument(s), received ${positionals.length}: ${positionals.join(' ')}`,
`Expected ${command} to reject excess positionals`,
);
}
});

test('command-specific flags without command fail in strict mode', () => {
assert.throws(
() => parseArgs(['--depth', '3'], { strictFlags: true }),
Expand Down
17 changes: 10 additions & 7 deletions src/cli/parser/args.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { AppError } from '../../kernel/errors.ts';
import { mergeDefinedFlags } from '../../utils/merge-flags.ts';
import {
applyCommandDefaults,
assertCommandPositionalArity,
getCommandSchema,
getFlagDefinition,
getFlagDefinitions,
Expand Down Expand Up @@ -200,7 +201,14 @@ export function finalizeParsedArgs(
}
assertNoConflictingBackModeFlags(parsed);
applyCommandDefaults(parsed.command, flags);
if (parsed.command === 'batch') {
const normalized = normalizeParsedCommandAliases({
command: parsed.command,
positionals: parsed.positionals,
flags,
warnings,
});
assertCommandPositionalArity(normalized.command, normalized.positionals);
if (normalized.command === 'batch') {
const stepSourceCount = (flags.steps ? 1 : 0) + (flags.stepsFile ? 1 : 0);
if (stepSourceCount !== 1) {
throw new AppError(
Expand All @@ -209,12 +217,7 @@ export function finalizeParsedArgs(
);
}
}
return normalizeParsedCommandAliases({
command: parsed.command,
positionals: parsed.positionals,
flags,
warnings,
});
return normalized;
}

function assertNoConflictingBackModeFlags(parsed: RawParsedArgs): void {
Expand Down
32 changes: 32 additions & 0 deletions src/commands/batch/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,38 @@ test('batch accepts legacy positionals/flags steps with deprecation warning', as
});
});

test('batch rejects excess legacy positionals before daemon projection', async () => {
const result = await runCliCapture([
'batch',
'--steps',
'[{"command":"open","positionals":["settings","https://example.com","close"]}]',
]);

assert.equal(result.code, 1);
assert.equal(result.calls.length, 0);
assert.match(
result.stderr,
/Batch step 1 open accepts at most 2 positional argument\(s\), received 3/,
);
});

test('batch accepts a multiword ref label in a legacy get step', async () => {
const result = await runCliCapture([
'batch',
'--steps',
'[{"command":"get","positionals":["text","@e5~s3","World","Clock"]}]',
'--json',
]);

assert.equal(result.code, null);
assert.equal(result.calls.length, 1);
assert.deepEqual((result.calls[0]?.flags?.batchSteps ?? [])[0]?.positionals, [
'text',
'@e5~s3',
'World Clock',
]);
});

test('batch rejects hybrid structured and legacy step shapes', async () => {
const result = await runCliCapture([
'batch',
Expand Down
3 changes: 2 additions & 1 deletion src/commands/interaction/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ const interactionCliSchemas = {
get: {
usageOverride: 'get text|attrs <@ref|selector>',
positionalArgs: ['subcommand', 'target'],
allowsExtraPositionals: true,
allowedFlags: [...SELECTOR_SNAPSHOT_FLAGS, 'record'],
},
find: {
Expand Down Expand Up @@ -108,7 +109,7 @@ const interactionCliSchemas = {
},
swipe: {
helpDescription: 'Quick coordinate fling with optional repeat pattern.',
positionalArgs: ['x1', 'y1', 'x2', 'y2'],
positionalArgs: ['x1', 'y1', 'x2', 'y2', 'durationMs?'],
allowedFlags: ['count', 'pauseMs', 'pattern'],
},
gesture: {
Expand Down
Loading