Skip to content

Commit d50d2c2

Browse files
committed
Tighten parser positional handling and ref flag validation
1 parent a4f61ae commit d50d2c2

5 files changed

Lines changed: 123 additions & 5 deletions

File tree

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import test from 'node:test';
2+
import assert from 'node:assert/strict';
3+
import { unsupportedRefSnapshotFlags } from '../interaction.ts';
4+
5+
test('unsupportedRefSnapshotFlags returns unsupported snapshot flags for @ref flows', () => {
6+
const unsupported = unsupportedRefSnapshotFlags({
7+
snapshotDepth: 2,
8+
snapshotScope: 'Login',
9+
snapshotRaw: true,
10+
snapshotBackend: 'ax',
11+
});
12+
assert.deepEqual(unsupported, ['--depth', '--scope', '--raw', '--backend']);
13+
});
14+
15+
test('unsupportedRefSnapshotFlags returns empty when no ref-unsupported flags are present', () => {
16+
const unsupported = unsupportedRefSnapshotFlags({
17+
platform: 'ios',
18+
session: 'default',
19+
verbose: true,
20+
});
21+
assert.deepEqual(unsupported, []);
22+
});

src/daemon/handlers/interaction.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,16 @@ export async function handleInteractionCommands(params: {
4141
}
4242
const refInput = req.positionals?.[0] ?? '';
4343
if (refInput.startsWith('@')) {
44+
const unsupported = unsupportedRefSnapshotFlags(req.flags);
45+
if (unsupported.length > 0) {
46+
return {
47+
ok: false,
48+
error: {
49+
code: 'INVALID_ARGS',
50+
message: `click @ref does not support ${unsupported.join(', ')}.`,
51+
},
52+
};
53+
}
4454
if (!session.snapshot) {
4555
return { ok: false, error: { code: 'INVALID_ARGS', message: 'No snapshot in session. Run snapshot first.' } };
4656
}
@@ -126,6 +136,16 @@ export async function handleInteractionCommands(params: {
126136
if (command === 'fill') {
127137
const session = sessionStore.get(sessionName);
128138
if (req.positionals?.[0]?.startsWith('@')) {
139+
const unsupported = unsupportedRefSnapshotFlags(req.flags);
140+
if (unsupported.length > 0) {
141+
return {
142+
ok: false,
143+
error: {
144+
code: 'INVALID_ARGS',
145+
message: `fill @ref does not support ${unsupported.join(', ')}.`,
146+
},
147+
};
148+
}
129149
if (!session?.snapshot) {
130150
return { ok: false, error: { code: 'INVALID_ARGS', message: 'No snapshot in session. Run snapshot first.' } };
131151
}
@@ -258,6 +278,16 @@ export async function handleInteractionCommands(params: {
258278
}
259279
const refInput = req.positionals?.[1] ?? '';
260280
if (refInput.startsWith('@')) {
281+
const unsupported = unsupportedRefSnapshotFlags(req.flags);
282+
if (unsupported.length > 0) {
283+
return {
284+
ok: false,
285+
error: {
286+
code: 'INVALID_ARGS',
287+
message: `get @ref does not support ${unsupported.join(', ')}.`,
288+
},
289+
};
290+
}
261291
if (!session.snapshot) {
262292
return { ok: false, error: { code: 'INVALID_ARGS', message: 'No snapshot in session. Run snapshot first.' } };
263293
}
@@ -511,3 +541,19 @@ async function captureSnapshotForSession(
511541
sessionStore.set(session.name, session);
512542
return session.snapshot;
513543
}
544+
545+
const REF_UNSUPPORTED_FLAG_MAP: ReadonlyArray<[keyof CommandFlags, string]> = [
546+
['snapshotDepth', '--depth'],
547+
['snapshotScope', '--scope'],
548+
['snapshotRaw', '--raw'],
549+
['snapshotBackend', '--backend'],
550+
];
551+
552+
export function unsupportedRefSnapshotFlags(flags: CommandFlags | undefined): string[] {
553+
if (!flags) return [];
554+
const unsupported: string[] = [];
555+
for (const [key, label] of REF_UNSUPPORTED_FLAG_MAP) {
556+
if (flags[key] !== undefined) unsupported.push(label);
557+
}
558+
return unsupported;
559+
}

src/utils/__tests__/args.test.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import test from 'node:test';
22
import assert from 'node:assert/strict';
33
import { parseArgs, usage } from '../args.ts';
44
import { AppError } from '../errors.ts';
5-
import { getCliCommandNames } from '../command-schema.ts';
5+
import { getCliCommandNames, getSchemaCapabilityKeys } from '../command-schema.ts';
66
import { listCapabilityCommands } from '../../core/capabilities.ts';
77

88
test('parseArgs recognizes --relaunch', () => {
@@ -25,6 +25,10 @@ test('every capability command has a parser schema entry', () => {
2525
}
2626
});
2727

28+
test('schema capability mappings match capability source-of-truth', () => {
29+
assert.deepEqual(getSchemaCapabilityKeys(), listCapabilityCommands());
30+
});
31+
2832
test('compat mode warns and strips unsupported pilot-command flags', () => {
2933
const parsed = parseArgs(['press', '10', '20', '--depth', '2'], { strictFlags: false });
3034
assert.equal(parsed.command, 'press');
@@ -59,6 +63,16 @@ test('unknown short flags are rejected', () => {
5963
);
6064
});
6165

66+
test('negative numeric positionals are accepted without -- separator', () => {
67+
const typed = parseArgs(['type', '-123'], { strictFlags: true });
68+
assert.equal(typed.command, 'type');
69+
assert.deepEqual(typed.positionals, ['-123']);
70+
71+
const pressed = parseArgs(['press', '-10', '20'], { strictFlags: true });
72+
assert.equal(pressed.command, 'press');
73+
assert.deepEqual(pressed.positionals, ['-10', '20']);
74+
});
75+
6276
test('all commands participate in strict command-flag validation', () => {
6377
assert.throws(
6478
() => parseArgs(['open', 'Settings', '--depth', '1'], { strictFlags: true }),

src/utils/args.ts

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ type ParsedFlagRecord = {
2929
export function parseArgs(argv: string[], options?: ParseArgsOptions): ParsedArgs {
3030
const strictFlags = options?.strictFlags ?? isStrictFlagModeEnabled(process.env.AGENT_DEVICE_STRICT_FLAGS);
3131
const flags: CliFlags = { json: false, help: false, version: false };
32+
let command: string | null = null;
3233
const positionals: string[] = [];
3334
const warnings: string[] = [];
3435
const providedFlags: ParsedFlagRecord[] = [];
@@ -41,19 +42,26 @@ export function parseArgs(argv: string[], options?: ParseArgsOptions): ParsedArg
4142
continue;
4243
}
4344
if (!parseFlags) {
44-
positionals.push(arg);
45+
if (!command) command = arg;
46+
else positionals.push(arg);
4547
continue;
4648
}
4749
const isLongFlag = arg.startsWith('--');
4850
const isShortFlag = arg.startsWith('-') && arg.length > 1;
4951
if (!isLongFlag && !isShortFlag) {
50-
positionals.push(arg);
52+
if (!command) command = arg;
53+
else positionals.push(arg);
5154
continue;
5255
}
5356

5457
const [token, inlineValue] = isLongFlag ? splitLongFlag(arg) : [arg, undefined];
5558
const definition = getFlagDefinition(token);
5659
if (!definition) {
60+
if (shouldTreatUnknownDashTokenAsPositional(command, positionals, arg)) {
61+
if (!command) command = arg;
62+
else positionals.push(arg);
63+
continue;
64+
}
5765
throw new AppError('INVALID_ARGS', `Unknown flag: ${token}`);
5866
}
5967

@@ -63,7 +71,6 @@ export function parseArgs(argv: string[], options?: ParseArgsOptions): ParsedArg
6371
providedFlags.push({ key: definition.key, token });
6472
}
6573

66-
const command = positionals.shift() ?? null;
6774
const commandSchema = getCommandSchema(command);
6875
const allowedFlagKeys = new Set<FlagKey>([
6976
...GLOBAL_FLAG_KEYS,
@@ -165,6 +172,24 @@ function looksLikeFlagToken(value: string): boolean {
165172
return getFlagDefinition(token) !== undefined;
166173
}
167174

175+
function shouldTreatUnknownDashTokenAsPositional(
176+
command: string | null,
177+
positionals: string[],
178+
arg: string,
179+
): boolean {
180+
if (!isNegativeNumericToken(arg)) return false;
181+
if (!command) return false;
182+
const schema = getCommandSchema(command);
183+
if (!schema) return true;
184+
if (schema.positionalArgs.length === 0) return false;
185+
if (positionals.length < schema.positionalArgs.length) return true;
186+
return schema.positionalArgs.some((entry) => entry.includes('?'));
187+
}
188+
189+
function isNegativeNumericToken(value: string): boolean {
190+
return /^-\d+(\.\d+)?$/.test(value);
191+
}
192+
168193
export function toDaemonFlags(flags: CliFlags): Omit<CliFlags, 'json' | 'help' | 'version'> {
169194
const { json: _json, help: _help, version: _version, ...daemonFlags } = flags;
170195
return daemonFlags;

src/utils/command-schema.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -607,13 +607,23 @@ export function getCliCommandNames(): string[] {
607607
return [...CLI_COMMAND_ORDER];
608608
}
609609

610+
export function getSchemaCapabilityKeys(): string[] {
611+
return Object.values(COMMAND_SCHEMAS)
612+
.map((schema) => schema.capabilityKey)
613+
.filter((key): key is string => typeof key === 'string')
614+
.sort();
615+
}
616+
610617
export function isStrictFlagModeEnabled(value: string | undefined): boolean {
611618
if (!value) return false;
612619
const normalized = value.trim().toLowerCase();
613620
return normalized === '1' || normalized === 'true' || normalized === 'yes' || normalized === 'on';
614621
}
615622

623+
let cachedUsageText: string | null = null;
624+
616625
export function buildUsageText(): string {
626+
if (cachedUsageText) return cachedUsageText;
617627
const header = `agent-device <command> [args] [--json]
618628
619629
CLI to control iOS and Android devices for AI agents.
@@ -646,9 +656,10 @@ CLI to control iOS and Android devices for AI agents.
646656
);
647657
}
648658

649-
return `${header}
659+
cachedUsageText = `${header}
650660
${commandLines.join('\n')}
651661
652662
${flagLines.join('\n')}
653663
`;
664+
return cachedUsageText;
654665
}

0 commit comments

Comments
 (0)