Skip to content

Commit dbfebd4

Browse files
authored
refactor(cli-schema): derive the flag tail of usage synopses (#2456)
A command that hand-wrote its synopsis had to restate every option it accepts inside that string, which is the last restatement left on the help surface after #2421 made the flag declaration own the option itself. A synopsis is now grammar plus a generated `[label]` tail, and the two rendering rules live on the declaration rather than per command: - the tail names an option with its declared `usageLabel`, alias included, the token the `Command flags:` section already shows; - `usageHidden: true` keeps a cross-cutting opt-in out of every synopsis; `--record` is the one today, and it stays under `Command flags:`. `usageFlags` is where a command states that its synopsis names fewer options than it accepts: `[]` for a synopsis that is pure grammar or writes its own mutually-exclusive brackets, otherwise the subset it names. `Command flags:` still documents everything in `allowedFlags`. Adding an option to a command therefore updates `--help` on its own, except where the command said its synopsis stays short. `snapshot` and `proxy` drop their override; `daemon`, `device`, `doctor`, `prepare`, `tv-remote`, `scroll` and `artifacts` drop the flag brackets from theirs. Guards fail a tail that names an option the command does not accept, or one the hand-written grammar already wrote. Every synopsis except `snapshot` and `is` is byte-identical; those two move exactly per the rules above, and the canonical `snapshot` docs line follows the generator. Closes #2444
1 parent 805ffb4 commit dbfebd4

27 files changed

Lines changed: 224 additions & 22 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,12 @@
22

33
## Unreleased
44

5+
- Changed: a command whose synopsis is generated names each option with the label its declaration
6+
carries, so `snapshot` now shows `--depth, -d <depth>` and `--scope, -s <scope>` where it used to
7+
show the short aliases, and `--record` is documented under `Command flags:` instead of inside the
8+
`snapshot` and `is` synopsis lines. `snapshot`, `proxy`, `daemon`, `device`, `doctor`, `prepare`
9+
and `tv-remote` no longer restate their option list in a hand-written usage string, so adding an
10+
option to those commands updates `--help` on its own (#2444).
511
- Fixed: iOS `--depth` on `snapshot`, `is`, `wait`, `get`, and `find` no longer fails with
612
`regular iOS snapshot presentation requires a valid viewport` when the runner plan is pinned or
713
deferred to the private AX backend (custom actions, a private AX verdict on the session, or the

docs/agents/cli-flags.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,13 @@ steps 1-3, plus step 9.
4040
## Where CLI help and schema live
4141

4242
- Long help prose: `src/cli-schema/cli-help.ts`. Flag definitions: `src/commands/cli-grammar/`.
43+
- Synopsis: `src/cli-schema/usage.ts` generates the `[label]` flag tail from `allowedFlags`, so a
44+
new option reaches `--help` without any synopsis edit. Declare `usageFlags` on the command only
45+
when its synopsis names fewer options: `[]` for a synopsis that is pure grammar (or writes its own
46+
mutually-exclusive brackets), otherwise the subset it names. `Command flags:` always lists
47+
everything in `allowedFlags`. Keep a cross-cutting opt-in out of every synopsis with
48+
`usageHidden: true` on its flag definition. `src/cli-schema/usage.test.ts` fails a tail that names
49+
an option the command does not accept, or one the hand-written grammar already wrote.
4350
- Command-specific usage/flag metadata lives with the command family metadata that owns the command.
4451
- Parser/help *rendering* stays in `src/cli/parser/`; command schema metadata is derived from command
4552
metadata, family declarations, and the schema-only merge path in

src/cli-schema/cli-help-command-usage.test.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -277,6 +277,13 @@ test('snapshot command usage documents diff alias', async () => {
277277
assert.match(help, /verify with diff snapshot -i or snapshot --diff/);
278278
});
279279

280+
test('snapshot documents the synopsis-hidden record flag', async () => {
281+
const help = await usageForCommand('snapshot');
282+
if (help === null) throw new Error('Expected command help text');
283+
assert.doesNotMatch(help, /agent-device snapshot \[[^\n]*--record/);
284+
assert.match(help, /--record\s+Force-record this action/);
285+
});
286+
280287
test('network command usage documents include flag', async () => {
281288
const help = await usageForCommand('network');
282289
if (help === null) throw new Error('Expected command help text');

src/cli-schema/command-overrides.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ const SCHEMA_ONLY_CLI_COMMAND_SCHEMAS = {
3838
description:
3939
'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.',
4040
},
41-
usageOverride: 'daemon stop [--state-dir <path>] [--clean]',
41+
usageOverride: 'daemon stop [--state-dir <path>]',
4242
listUsageOverride: 'daemon stop',
4343
positionalArgs: ['stop'],
4444
allowedFlags: ['clean'],
@@ -51,7 +51,7 @@ const SCHEMA_ONLY_CLI_COMMAND_SCHEMAS = {
5151
'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.',
5252
},
5353
usageOverride:
54-
'device status|release [--platform <platform>] [--udid <udid>] [--serial <serial>] [--stale]',
54+
'device status|release [--platform <platform>] [--udid <udid>] [--serial <serial>]',
5555
listUsageOverride: 'device status',
5656
positionalArgs: ['status|release'],
5757
allowedFlags: ['stale'],
@@ -65,6 +65,7 @@ const SCHEMA_ONLY_CLI_COMMAND_SCHEMAS = {
6565
},
6666
usageOverride:
6767
'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]',
68+
usageFlags: [],
6869
listUsageOverride: 'connect',
6970
positionalArgs: ['provider?'],
7071
allowedFlags: [
@@ -137,8 +138,6 @@ const SCHEMA_ONLY_CLI_COMMAND_SCHEMAS = {
137138
description:
138139
'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>',
139140
},
140-
usageOverride:
141-
'proxy [--host <host>] [--port <port>] [--daemon-auth-token <token>] [--state-dir <path>]',
142141
listUsageOverride: 'proxy',
143142
allowedFlags: ['proxyHost', 'proxyPort', 'daemonAuthToken', 'stateDir'],
144143
},

src/cli-schema/types.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,19 @@ export type CommandSchema = {
1313
allowedFlags?: readonly FlagKey[];
1414
supportedFlags?: readonly FlagKey[];
1515
defaults?: Partial<CliFlags>;
16-
/** Replaces the generated synopsis in `--help`, for shapes the generator cannot express. */
16+
/**
17+
* Replaces the generated synopsis grammar in `--help`, for shapes the generator cannot express.
18+
* The flag tail after it stays generated from `usageFlags`, so this string never restates the
19+
* command's option list; a bracket it writes itself must be declared out of that tail.
20+
*/
1721
usageOverride?: string;
22+
/**
23+
* The options the synopsis names in its `[label]` flag tail; defaults to `allowedFlags`. Declare
24+
* `[]` when the synopsis is pure grammar (or writes its own mutually-exclusive brackets) and the
25+
* `Command flags:` section is the option list. Affects the synopsis only: every option in
26+
* `allowedFlags` is documented and parsed regardless.
27+
*/
28+
usageFlags?: readonly FlagKey[];
1829
/** Replaces the generated synopsis in the command list, which stays terser than `--help`. */
1930
listUsageOverride?: string;
2031
// Swaps a shared flag's usageDescription for this command only, when the flag's generic

src/cli-schema/usage.test.ts

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
import assert from 'node:assert/strict';
2+
import { test } from 'vitest';
3+
import { listCliCommandNames } from '@agent-device/command-registry/catalog';
4+
import type { CommandText } from '../commands/command-text.ts';
5+
import {
6+
getCliCommandSchema,
7+
getFlagDefinitions,
8+
type CommandSchema,
9+
type FlagDefinition,
10+
type FlagKey,
11+
} from './command-schema.ts';
12+
import { buildCommandUsage } from './usage.ts';
13+
14+
const TEXT: CommandText = {
15+
summary: 'Synopsis fixture',
16+
description: 'Synthetic grammar used to pin synopsis rendering rules.',
17+
};
18+
19+
function synopsisFor(grammar: Omit<CommandSchema, 'text'>): string {
20+
return buildCommandUsage('sample', { text: TEXT, ...grammar });
21+
}
22+
23+
function flagDefinitionsFor(key: FlagKey): FlagDefinition[] {
24+
return getFlagDefinitions().filter((definition) => definition.key === key);
25+
}
26+
27+
/** The options a synopsis names in its generated flag tail. */
28+
function tailFlags(schema: CommandSchema): readonly FlagKey[] {
29+
return schema.usageFlags ?? schema.allowedFlags ?? [];
30+
}
31+
32+
/** A synopsis names an option by one of its CLI tokens, delimited so `--settle` is not `--settle-quiet`. */
33+
function namesOption(synopsis: string, definition: FlagDefinition): boolean {
34+
// Flag tokens are letters, digits and dashes, so the name needs no escaping here.
35+
return definition.names.some((name) =>
36+
new RegExp(String.raw`(?<![\w-])${name}(?![\w-])`).test(synopsis),
37+
);
38+
}
39+
40+
test('synopsis names each tailed option with its declared label, aliases included', () => {
41+
assert.equal(
42+
synopsisFor({ allowedFlags: ['snapshotDepth', 'snapshotInteractiveOnly', 'timeoutMs'] }),
43+
'sample [--depth, -d <depth>] [-i] [--timeout <ms>]',
44+
);
45+
});
46+
47+
test('synopsis omits a hidden option and an option with no CLI token', () => {
48+
assert.equal(synopsisFor({ allowedFlags: ['snapshotDiff', 'record'] }), 'sample [--diff]');
49+
assert.equal(synopsisFor({ allowedFlags: ['snapshotDiff', 'installSource'] }), 'sample [--diff]');
50+
});
51+
52+
test('synopsis renders positionals before the flag tail', () => {
53+
assert.equal(
54+
synopsisFor({ positionalArgs: ['kind', 'current?'], allowedFlags: ['threshold'] }),
55+
'sample <kind> [current] [--threshold <0-1>]',
56+
);
57+
});
58+
59+
test('usageFlags chooses the tail and a hand-written grammar keeps it generated', () => {
60+
assert.equal(
61+
synopsisFor({
62+
usageOverride: 'sample first|second [--exclusive-a | --exclusive-b]',
63+
usageFlags: ['threshold'],
64+
allowedFlags: ['threshold', 'out'],
65+
}),
66+
'sample first|second [--exclusive-a | --exclusive-b] [--threshold <0-1>]',
67+
);
68+
assert.equal(
69+
synopsisFor({ usageOverride: 'sample only <arg>', usageFlags: [], allowedFlags: ['out'] }),
70+
'sample only <arg>',
71+
);
72+
});
73+
74+
test('snapshot synopsis is generated from its allowed flags', () => {
75+
const schema = getCliCommandSchema('snapshot');
76+
assert.equal(schema.usageOverride, undefined);
77+
assert.equal(
78+
buildCommandUsage('snapshot', schema),
79+
'snapshot [--diff] [-i] [--depth, -d <depth>] [--scope, -s <scope>] [--raw] [--actions] [--force-full] [--timeout <ms>]',
80+
);
81+
});
82+
83+
test('a synopsis names no option its command refuses', () => {
84+
const offenders = listCliCommandNames().flatMap((command) => {
85+
const schema = getCliCommandSchema(command);
86+
const accepted = new Set<FlagKey>(schema.allowedFlags ?? []);
87+
const unaccepted = tailFlags(schema).filter((key) => !accepted.has(key));
88+
if (unaccepted.length === 0) return [];
89+
return [`${command} tails ${unaccepted.join(', ')} outside its allowedFlags`];
90+
});
91+
assert.deepEqual(
92+
offenders,
93+
[],
94+
'usageFlags is the tail of allowedFlags: an option the synopsis names must be one the ' +
95+
'command parses. Add it to allowedFlags or drop it from usageFlags.',
96+
);
97+
});
98+
99+
test('a generated flag tail repeats no bracket the grammar already wrote', () => {
100+
const offenders: string[] = [];
101+
for (const command of listCliCommandNames()) {
102+
const authored = getCliCommandSchema(command).usageOverride;
103+
if (authored === undefined) continue;
104+
const schema = getCliCommandSchema(command);
105+
const repeated = tailFlags(schema).filter((key) =>
106+
flagDefinitionsFor(key).some((definition) => namesOption(authored, definition)),
107+
);
108+
if (repeated.length > 0) offenders.push(`${command}: ${repeated.join(', ')}`);
109+
}
110+
assert.deepEqual(
111+
offenders,
112+
[],
113+
'A hand-written grammar that names an option leaves it out of usageFlags, so the tail ' +
114+
'generated after it renders that option exactly once.',
115+
);
116+
});
117+
118+
test('an authored synopsis is not empty', () => {
119+
const offenders = listCliCommandNames().filter((command) => {
120+
const authored = getCliCommandSchema(command).usageOverride;
121+
return authored !== undefined && authored.trim().length === 0;
122+
});
123+
assert.deepEqual(
124+
offenders,
125+
[],
126+
'An empty usageOverride suppresses the whole synopsis; delete the field instead.',
127+
);
128+
});

src/cli-schema/usage.ts

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,27 @@ function flagDefinitionsForKey(key: FlagKey): FlagDefinition[] {
1515
return getFlagDefinitions().filter((definition) => definition.key === key);
1616
}
1717

18-
export function buildCommandUsage(commandName: string, schema: CommandSchema): string {
19-
if (schema.usageOverride) return schema.usageOverride;
20-
const positionals = (schema.positionalArgs ?? []).map(formatPositionalArg);
21-
const flagLabels = (schema.allowedFlags ?? []).flatMap((key) =>
22-
flagDefinitionsForKey(key).map((definition) => definition.usageLabel ?? definition.names[0]),
18+
/**
19+
* An option's synopsis token is its `usageLabel`, else its first CLI name: nothing for a
20+
* `usageHidden` option, or one with no CLI name at all (a config-only virtual option).
21+
*/
22+
function usageToken(definition: FlagDefinition): string | undefined {
23+
if (definition.usageHidden) return undefined;
24+
return definition.usageLabel ?? definition.names[0];
25+
}
26+
27+
function buildFlagTail(allowedFlags: readonly FlagKey[] | undefined): string[] {
28+
return (allowedFlags ?? []).flatMap((key) =>
29+
flagDefinitionsForKey(key)
30+
.map(usageToken)
31+
.filter((token): token is string => token !== undefined)
32+
.map((token) => `[${token}]`),
2333
);
24-
const optionalFlags = flagLabels.map((label) => `[${label}]`);
25-
return [commandName, ...positionals, ...optionalFlags].join(' ');
34+
}
35+
36+
export function buildCommandUsage(commandName: string, schema: CommandSchema): string {
37+
const grammar =
38+
schema.usageOverride ??
39+
[commandName, ...(schema.positionalArgs ?? []).map(formatPositionalArg)].join(' ');
40+
return [grammar, ...buildFlagTail(schema.usageFlags ?? schema.allowedFlags)].join(' ');
2641
}

src/commands/batch/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ const batchCommandMetadata = createBatchCommandMetadata();
1313

1414
const batchCliSchema = {
1515
usageOverride: 'batch [--steps <json> | --steps-file <path>]',
16+
usageFlags: [],
1617
listUsageOverride: 'batch --steps <json> | --steps-file <path>',
1718
allowedFlags: ['steps', 'stepsFile', 'batchOnError', 'batchMaxSteps', 'out'],
1819
} as const satisfies CommandSchemaOverride;

src/commands/capture/diff.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ const diffCommandMetadata = defineFieldCommandMetadata(DIFF_COMMAND_NAME, diffCo
3030
const diffCliSchema = {
3131
usageOverride:
3232
'diff snapshot | diff screenshot --baseline <path> [current.png] [--out <diff.png>] [--threshold <0-1>] [--overlay-refs]',
33+
usageFlags: [],
3334
positionalArgs: ['kind', 'current?'],
3435
allowedFlags: [...SNAPSHOT_FLAGS, 'baseline', 'threshold', 'out', 'overlayRefs'],
3536
} as const;

src/commands/capture/snapshot.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,8 +51,6 @@ const snapshotCommandMetadata = defineFieldCommandMetadata(
5151
);
5252

5353
const snapshotCliSchema = {
54-
usageOverride:
55-
'snapshot [--diff] [-i] [-d <depth>] [-s <scope>] [--raw] [--actions] [--force-full] [--timeout <ms>]',
5654
allowedFlags: [
5755
'snapshotDiff',
5856
...SNAPSHOT_FLAGS,

0 commit comments

Comments
 (0)