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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@

## Unreleased

- Changed: a command whose synopsis is generated names each option with the label its declaration
carries, so `snapshot` now shows `--depth, -d <depth>` and `--scope, -s <scope>` where it used to
show the short aliases, and `--record` is documented under `Command flags:` instead of inside the
`snapshot` and `is` synopsis lines. `snapshot`, `proxy`, `daemon`, `device`, `doctor`, `prepare`
and `tv-remote` no longer restate their option list in a hand-written usage string, so adding an
option to those commands updates `--help` on its own (#2444).
- Fixed: iOS `--depth` on `snapshot`, `is`, `wait`, `get`, and `find` no longer fails with
`regular iOS snapshot presentation requires a valid viewport` when the runner plan is pinned or
deferred to the private AX backend (custom actions, a private AX verdict on the session, or the
Expand Down
7 changes: 7 additions & 0 deletions docs/agents/cli-flags.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,13 @@ steps 1-3, plus step 9.
## Where CLI help and schema live

- Long help prose: `src/cli-schema/cli-help.ts`. Flag definitions: `src/commands/cli-grammar/`.
- Synopsis: `src/cli-schema/usage.ts` generates the `[label]` flag tail from `allowedFlags`, so a
new option reaches `--help` without any synopsis edit. Declare `usageFlags` on the command only
when its synopsis names fewer options: `[]` for a synopsis that is pure grammar (or writes its own
mutually-exclusive brackets), otherwise the subset it names. `Command flags:` always lists
everything in `allowedFlags`. Keep a cross-cutting opt-in out of every synopsis with
`usageHidden: true` on its flag definition. `src/cli-schema/usage.test.ts` fails a tail that names
an option the command does not accept, or one the hand-written grammar already wrote.
- Command-specific usage/flag metadata lives with the command family metadata that owns the command.
- Parser/help *rendering* stays in `src/cli/parser/`; command schema metadata is derived from command
metadata, family declarations, and the schema-only merge path in
Expand Down
7 changes: 7 additions & 0 deletions src/cli-schema/cli-help-command-usage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,13 @@ test('snapshot command usage documents diff alias', async () => {
assert.match(help, /verify with diff snapshot -i or snapshot --diff/);
});

test('snapshot documents the synopsis-hidden record flag', async () => {
const help = await usageForCommand('snapshot');
if (help === null) throw new Error('Expected command help text');
assert.doesNotMatch(help, /agent-device snapshot \[[^\n]*--record/);
assert.match(help, /--record\s+Force-record this action/);
});

test('network command usage documents include flag', async () => {
const help = await usageForCommand('network');
if (help === null) throw new Error('Expected command help text');
Expand Down
7 changes: 3 additions & 4 deletions src/cli-schema/command-overrides.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ const SCHEMA_ONLY_CLI_COMMAND_SCHEMAS = {
description:
'Stop a local daemon after verifying its PID/start-time identity. Use --clean to remove retained Apple runner processes and leases owned by that daemon.',
},
usageOverride: 'daemon stop [--state-dir <path>] [--clean]',
usageOverride: 'daemon stop [--state-dir <path>]',
listUsageOverride: 'daemon stop',
positionalArgs: ['stop'],
allowedFlags: ['clean'],
Expand All @@ -51,7 +51,7 @@ const SCHEMA_ONLY_CLI_COMMAND_SCHEMAS = {
'Inspect enforced host-local device ownership claims without starting or contacting a daemon; status --stale only inspects proven-stale claims. release --stale settles a provably dead owner through exact-owner resource reconciliation and clears its claim last — live and uncertain owners always fail closed. Automatic reclamation still occurs during open and daemon startup.',
},
usageOverride:
'device status|release [--platform <platform>] [--udid <udid>] [--serial <serial>] [--stale]',
'device status|release [--platform <platform>] [--udid <udid>] [--serial <serial>]',
listUsageOverride: 'device status',
positionalArgs: ['status|release'],
allowedFlags: ['stale'],
Expand All @@ -65,6 +65,7 @@ const SCHEMA_ONLY_CLI_COMMAND_SCHEMAS = {
},
usageOverride:
'connect [cloud|proxy|limrun|browserstack|aws-device-farm] [--remote-config <path>] [--daemon-base-url <url>] [--tenant <id>] [--run-id <id>] [--lease-id <id>] [--lease-backend <backend>] [--force] [--no-login]',
usageFlags: [],
listUsageOverride: 'connect',
positionalArgs: ['provider?'],
allowedFlags: [
Expand Down Expand Up @@ -137,8 +138,6 @@ const SCHEMA_ONLY_CLI_COMMAND_SCHEMAS = {
description:
'Expose the local daemon HTTP contract through a tunnel-friendly reverse proxy.\n\nRun this on the host that has access to simulators/devices, expose the printed local proxy URL through a tunnel, then point another machine at the tunnel URL with connect proxy.\n\nThe proxy starts or reuses a local HTTP daemon, accepts /health, /rpc, /upload and resumable /upload/* routes, and /artifacts plus /artifacts/*, and also accepts the same routes under /agent-device/*. Health is unauthenticated for reachability probes. Other routes require the generated bearer token printed at startup, or the explicit --daemon-auth-token value when provided. The proxy rewrites authorized client requests to the upstream daemon token instead of exposing the local daemon token.\n\nUse the /agent-device base path when connecting through cloudflared, ngrok, or another shared origin. Treat the bearer token as a secret; anyone with it can control the proxied daemon. This direct proxy flow does not use agent-device auth.\n\nExamples:\n agent-device proxy --port 4310\n cloudflared tunnel --url http://127.0.0.1:4310\n agent-device connect proxy --daemon-base-url https://example.trycloudflare.com/agent-device --daemon-auth-token <token>',
},
usageOverride:
'proxy [--host <host>] [--port <port>] [--daemon-auth-token <token>] [--state-dir <path>]',
listUsageOverride: 'proxy',
allowedFlags: ['proxyHost', 'proxyPort', 'daemonAuthToken', 'stateDir'],
},
Expand Down
13 changes: 12 additions & 1 deletion src/cli-schema/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,19 @@ export type CommandSchema = {
allowedFlags?: readonly FlagKey[];
supportedFlags?: readonly FlagKey[];
defaults?: Partial<CliFlags>;
/** Replaces the generated synopsis in `--help`, for shapes the generator cannot express. */
/**
* Replaces the generated synopsis grammar in `--help`, for shapes the generator cannot express.
* The flag tail after it stays generated from `usageFlags`, so this string never restates the
* command's option list; a bracket it writes itself must be declared out of that tail.
*/
usageOverride?: string;
/**
* The options the synopsis names in its `[label]` flag tail; defaults to `allowedFlags`. Declare
* `[]` when the synopsis is pure grammar (or writes its own mutually-exclusive brackets) and the
* `Command flags:` section is the option list. Affects the synopsis only: every option in
* `allowedFlags` is documented and parsed regardless.
*/
usageFlags?: readonly FlagKey[];
/** Replaces the generated synopsis in the command list, which stays terser than `--help`. */
listUsageOverride?: string;
// Swaps a shared flag's usageDescription for this command only, when the flag's generic
Expand Down
128 changes: 128 additions & 0 deletions src/cli-schema/usage.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import assert from 'node:assert/strict';
import { test } from 'vitest';
import { listCliCommandNames } from '@agent-device/command-registry/catalog';
import type { CommandText } from '../commands/command-text.ts';
import {
getCliCommandSchema,
getFlagDefinitions,
type CommandSchema,
type FlagDefinition,
type FlagKey,
} from './command-schema.ts';
import { buildCommandUsage } from './usage.ts';

const TEXT: CommandText = {
summary: 'Synopsis fixture',
description: 'Synthetic grammar used to pin synopsis rendering rules.',
};

function synopsisFor(grammar: Omit<CommandSchema, 'text'>): string {
return buildCommandUsage('sample', { text: TEXT, ...grammar });
}

function flagDefinitionsFor(key: FlagKey): FlagDefinition[] {
return getFlagDefinitions().filter((definition) => definition.key === key);
}

/** The options a synopsis names in its generated flag tail. */
function tailFlags(schema: CommandSchema): readonly FlagKey[] {
return schema.usageFlags ?? schema.allowedFlags ?? [];
}

/** A synopsis names an option by one of its CLI tokens, delimited so `--settle` is not `--settle-quiet`. */
function namesOption(synopsis: string, definition: FlagDefinition): boolean {
// Flag tokens are letters, digits and dashes, so the name needs no escaping here.
return definition.names.some((name) =>
new RegExp(String.raw`(?<![\w-])${name}(?![\w-])`).test(synopsis),
);
}

test('synopsis names each tailed option with its declared label, aliases included', () => {
assert.equal(
synopsisFor({ allowedFlags: ['snapshotDepth', 'snapshotInteractiveOnly', 'timeoutMs'] }),
'sample [--depth, -d <depth>] [-i] [--timeout <ms>]',
);
});

test('synopsis omits a hidden option and an option with no CLI token', () => {
assert.equal(synopsisFor({ allowedFlags: ['snapshotDiff', 'record'] }), 'sample [--diff]');
assert.equal(synopsisFor({ allowedFlags: ['snapshotDiff', 'installSource'] }), 'sample [--diff]');
});

test('synopsis renders positionals before the flag tail', () => {
assert.equal(
synopsisFor({ positionalArgs: ['kind', 'current?'], allowedFlags: ['threshold'] }),
'sample <kind> [current] [--threshold <0-1>]',
);
});

test('usageFlags chooses the tail and a hand-written grammar keeps it generated', () => {
assert.equal(
synopsisFor({
usageOverride: 'sample first|second [--exclusive-a | --exclusive-b]',
usageFlags: ['threshold'],
allowedFlags: ['threshold', 'out'],
}),
'sample first|second [--exclusive-a | --exclusive-b] [--threshold <0-1>]',
);
assert.equal(
synopsisFor({ usageOverride: 'sample only <arg>', usageFlags: [], allowedFlags: ['out'] }),
'sample only <arg>',
);
});

test('snapshot synopsis is generated from its allowed flags', () => {
const schema = getCliCommandSchema('snapshot');
assert.equal(schema.usageOverride, undefined);
assert.equal(
buildCommandUsage('snapshot', schema),
'snapshot [--diff] [-i] [--depth, -d <depth>] [--scope, -s <scope>] [--raw] [--actions] [--force-full] [--timeout <ms>]',
);
});

test('a synopsis names no option its command refuses', () => {
const offenders = listCliCommandNames().flatMap((command) => {
const schema = getCliCommandSchema(command);
const accepted = new Set<FlagKey>(schema.allowedFlags ?? []);
const unaccepted = tailFlags(schema).filter((key) => !accepted.has(key));
if (unaccepted.length === 0) return [];
return [`${command} tails ${unaccepted.join(', ')} outside its allowedFlags`];
});
assert.deepEqual(
offenders,
[],
'usageFlags is the tail of allowedFlags: an option the synopsis names must be one the ' +
'command parses. Add it to allowedFlags or drop it from usageFlags.',
);
});

test('a generated flag tail repeats no bracket the grammar already wrote', () => {
const offenders: string[] = [];
for (const command of listCliCommandNames()) {
const authored = getCliCommandSchema(command).usageOverride;
if (authored === undefined) continue;
const schema = getCliCommandSchema(command);
const repeated = tailFlags(schema).filter((key) =>
flagDefinitionsFor(key).some((definition) => namesOption(authored, definition)),
);
if (repeated.length > 0) offenders.push(`${command}: ${repeated.join(', ')}`);
}
assert.deepEqual(
offenders,
[],
'A hand-written grammar that names an option leaves it out of usageFlags, so the tail ' +
'generated after it renders that option exactly once.',
);
});

test('an authored synopsis is not empty', () => {
const offenders = listCliCommandNames().filter((command) => {
const authored = getCliCommandSchema(command).usageOverride;
return authored !== undefined && authored.trim().length === 0;
});
assert.deepEqual(
offenders,
[],
'An empty usageOverride suppresses the whole synopsis; delete the field instead.',
);
});
29 changes: 22 additions & 7 deletions src/cli-schema/usage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,27 @@ function flagDefinitionsForKey(key: FlagKey): FlagDefinition[] {
return getFlagDefinitions().filter((definition) => definition.key === key);
}

export function buildCommandUsage(commandName: string, schema: CommandSchema): string {
if (schema.usageOverride) return schema.usageOverride;
const positionals = (schema.positionalArgs ?? []).map(formatPositionalArg);
const flagLabels = (schema.allowedFlags ?? []).flatMap((key) =>
flagDefinitionsForKey(key).map((definition) => definition.usageLabel ?? definition.names[0]),
/**
* An option's synopsis token is its `usageLabel`, else its first CLI name: nothing for a
* `usageHidden` option, or one with no CLI name at all (a config-only virtual option).
*/
function usageToken(definition: FlagDefinition): string | undefined {
if (definition.usageHidden) return undefined;
return definition.usageLabel ?? definition.names[0];
}

function buildFlagTail(allowedFlags: readonly FlagKey[] | undefined): string[] {
return (allowedFlags ?? []).flatMap((key) =>
flagDefinitionsForKey(key)
.map(usageToken)
.filter((token): token is string => token !== undefined)
.map((token) => `[${token}]`),
);
const optionalFlags = flagLabels.map((label) => `[${label}]`);
return [commandName, ...positionals, ...optionalFlags].join(' ');
}

export function buildCommandUsage(commandName: string, schema: CommandSchema): string {
const grammar =
schema.usageOverride ??
[commandName, ...(schema.positionalArgs ?? []).map(formatPositionalArg)].join(' ');
return [grammar, ...buildFlagTail(schema.usageFlags ?? schema.allowedFlags)].join(' ');
}
1 change: 1 addition & 0 deletions src/commands/batch/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ const batchCommandMetadata = createBatchCommandMetadata();

const batchCliSchema = {
usageOverride: 'batch [--steps <json> | --steps-file <path>]',
usageFlags: [],
listUsageOverride: 'batch --steps <json> | --steps-file <path>',
allowedFlags: ['steps', 'stepsFile', 'batchOnError', 'batchMaxSteps', 'out'],
} as const satisfies CommandSchemaOverride;
Expand Down
1 change: 1 addition & 0 deletions src/commands/capture/diff.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ const diffCommandMetadata = defineFieldCommandMetadata(DIFF_COMMAND_NAME, diffCo
const diffCliSchema = {
usageOverride:
'diff snapshot | diff screenshot --baseline <path> [current.png] [--out <diff.png>] [--threshold <0-1>] [--overlay-refs]',
usageFlags: [],
positionalArgs: ['kind', 'current?'],
allowedFlags: [...SNAPSHOT_FLAGS, 'baseline', 'threshold', 'out', 'overlayRefs'],
} as const;
Expand Down
2 changes: 0 additions & 2 deletions src/commands/capture/snapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,6 @@ const snapshotCommandMetadata = defineFieldCommandMetadata(
);

const snapshotCliSchema = {
usageOverride:
'snapshot [--diff] [-i] [-d <depth>] [-s <scope>] [--raw] [--actions] [--force-full] [--timeout <ms>]',
allowedFlags: [
'snapshotDiff',
...SNAPSHOT_FLAGS,
Expand Down
1 change: 1 addition & 0 deletions src/commands/capture/wait.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ const waitCommandMetadata = defineFieldCommandMetadata(WAIT_COMMAND_NAME, waitCo
const waitCliSchema = {
usageOverride:
'wait <ms>|text <text>|@ref|<selector>|absent <selector> [timeoutMs]|stable [quietMs] [timeoutMs]',
usageFlags: [],
positionalArgs: ['durationOrSelector', 'timeoutMs?'],
allowsExtraPositionals: true,
allowedFlags: [...SELECTOR_SNAPSHOT_FLAGS],
Expand Down
3 changes: 3 additions & 0 deletions src/commands/cli-grammar/flag-definitions-action.ts
Original file line number Diff line number Diff line change
Expand Up @@ -317,6 +317,9 @@ export const ACTION_FLAG_DEFINITIONS: readonly FlagDefinition[] = [
names: ['--record'],
type: 'boolean',
usageLabel: '--record',
// Accepted by the observation-only commands, not asked for by an operator writing a flow:
// the synopsis stays the invocation they are choosing between (#1271 stage 2).
usageHidden: true,
usageDescription:
'Force-record this action even though its command is observation-only and would otherwise be excluded from a repair-armed heal by default (mutually exclusive with --no-record)',
},
Expand Down
6 changes: 6 additions & 0 deletions src/commands/cli-grammar/flag-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,12 @@ export type FlagDefinition = {
max?: number;
setValue?: CliFlags[FlagKey];
usageLabel?: string;
/**
* Keeps this option out of generated command synopses while `usageLabel` still
* renders it under `Command flags:`. Reserve it for cross-cutting opt-ins whose
* synopsis bracket would read as noise on every command that accepts them.
*/
usageHidden?: boolean;
/** The `--help` audience: one line, command-prefixed. */
usageDescription?: string;
/**
Expand Down
1 change: 1 addition & 0 deletions src/commands/debugging/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export const debugCommandMetadata = defineFieldCommandMetadata(
const debugCliSchema = {
usageOverride:
'debug symbols --artifact <crash.ips|crash.log> (--dsym <App.dSYM> | --search-path <dir>) [--out <symbolicated>]',
usageFlags: [],
listUsageOverride: 'debug',
positionalArgs: ['symbols'],
allowedFlags: ['artifact', 'dsym', 'searchPath', 'out'],
Expand Down
12 changes: 10 additions & 2 deletions src/commands/interaction/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,12 +54,14 @@ import { selectorCliReaders, selectorDaemonWriters } from './selectors.ts';
const interactionCliSchemas = {
get: {
usageOverride: 'get text|attrs <@ref|selector>',
usageFlags: [],
positionalArgs: ['subcommand', 'target'],
allowsExtraPositionals: true,
allowedFlags: [...SELECTOR_SNAPSHOT_FLAGS, 'record'],
},
find: {
usageOverride: 'find <locator|text> <action> [value] [--first|--last]',
usageFlags: [],
positionalArgs: ['query', 'action', 'value?'],
allowsExtraPositionals: true,
allowedFlags: ['snapshotDepth', 'snapshotRaw', 'findFirst', 'findLast', 'record'],
Expand All @@ -71,6 +73,7 @@ const interactionCliSchemas = {
},
click: {
usageOverride: 'click <x y|@ref|selector>',
usageFlags: [],
positionalArgs: ['target'],
allowsExtraPositionals: true,
allowedFlags: [
Expand All @@ -82,6 +85,7 @@ const interactionCliSchemas = {
},
press: {
usageOverride: 'press <x y|@ref|selector>',
usageFlags: [],
positionalArgs: ['targetOrX', 'y?'],
allowsExtraPositionals: true,
allowedFlags: [
Expand All @@ -92,12 +96,14 @@ const interactionCliSchemas = {
},
longpress: {
usageOverride: 'longpress <x y|@ref|selector> [durationMs]',
usageFlags: [],
positionalArgs: ['targetOrX', 'yOrDurationMs?', 'durationMs?'],
allowsExtraPositionals: true,
allowedFlags: [...postActionObservationCliFlags('longpress'), ...SELECTOR_SNAPSHOT_FLAGS],
},
hover: {
usageOverride: 'hover <x y|@ref|selector>',
usageFlags: [],
positionalArgs: ['targetOrX', 'y?'],
allowsExtraPositionals: true,
allowedFlags: [...postActionObservationCliFlags('hover'), ...SELECTOR_SNAPSHOT_FLAGS],
Expand All @@ -111,6 +117,7 @@ const interactionCliSchemas = {
},
gesture: {
usageOverride: 'gesture <pan|fling|swipe|pinch|rotate|transform|drag> ...',
usageFlags: [],
listUsageOverride: 'gesture <pan|fling|swipe|pinch|rotate|transform|drag> ...',
positionalArgs: ['pan|fling|swipe|pinch|rotate|transform|drag', 'args?'],
allowsExtraPositionals: true,
Expand All @@ -126,6 +133,7 @@ const interactionCliSchemas = {
},
fill: {
usageOverride: 'fill <x> <y> <text> | fill <@ref|selector> <text>',
usageFlags: [],
positionalArgs: ['targetOrX', 'yOrText', 'text?'],
allowsExtraPositionals: true,
allowedFlags: [
Expand All @@ -136,8 +144,8 @@ const interactionCliSchemas = {
],
},
scroll: {
usageOverride:
'scroll <direction|top|bottom> [amount] [--pixels <n>] [--duration-ms <ms>] [--settle]',
usageOverride: 'scroll <direction|top|bottom> [amount]',
usageFlags: ['pixels', 'durationMs', 'settle'],
positionalArgs: ['directionOrEdge', 'amount?'],
allowedFlags: ['pixels', 'durationMs', ...postActionObservationCliFlags('scroll')],
},
Expand Down
Loading
Loading