diff --git a/package.json b/package.json index d6328db56c..3481f0f9c0 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "agent-device", "version": "0.20.5", "description": "Agent-native CLI for AI app automation across iOS, Android, tvOS, Android TV, macOS, Linux, and web.", - "mcpName": "io.github.callstackincubator/agent-device", + "mcpName": "io.github.callstack/agent-device", "license": "MIT", "author": "Callstack", "homepage": "https://agent-device.dev/", diff --git a/server.json b/server.json index 6f9373be4b..cc47587e17 100644 --- a/server.json +++ b/server.json @@ -1,6 +1,6 @@ { "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json", - "name": "io.github.callstackincubator/agent-device", + "name": "io.github.callstack/agent-device", "title": "agent-device", "description": "Let AI agents inspect, control, and debug real iOS, Android, desktop, and TV apps", "repository": { diff --git a/src/cli-schema/command-overrides.ts b/src/cli-schema/command-overrides.ts index 797e355439..dea8b5fde6 100644 --- a/src/cli-schema/command-overrides.ts +++ b/src/cli-schema/command-overrides.ts @@ -1,7 +1,7 @@ import type { CommandName } from '../commands/command-metadata.ts'; import { listCommandFamilyCliSchemas } from '../commands/family/registry.ts'; import type { LocalCliCommandName } from '../command-catalog.ts'; -import type { CommandSchema, CommandSchemaOverride } from './types.ts'; +import type { CommandSchema } from './types.ts'; import { COMMON_COMMAND_SUPPORTED_FLAG_KEYS, METRO_PREPARE_FLAGS, @@ -11,53 +11,61 @@ type SchemaOnlyCliCommandName = Exclude; const SCHEMA_ONLY_CLI_COMMAND_SCHEMAS = { cdp: { + text: { + summary: 'Inspect CDP targets, JS heap, and leaks', + description: + 'Run CDP commands for React Native diagnostics, JS heap usage, heap snapshots, and leak analysis', + }, usageOverride: 'cdp [...args]', listUsageOverride: 'cdp', - helpDescription: - 'Run CDP commands for React Native diagnostics, JS heap usage, heap snapshots, and leak analysis', - summary: - 'Inspect React Native CDP targets, JS heap growth, heap snapshots, retainers, and leak signals', positionalArgs: ['args?'], allowsExtraPositionals: true, supportedFlags: COMMON_COMMAND_SUPPORTED_FLAG_KEYS, }, auth: { + text: { + summary: 'Manage cloud login state', + description: 'Manage cloud login state used by remote daemon and cloud device workflows.', + }, usageOverride: 'auth status|login|logout', listUsageOverride: 'auth', - helpDescription: 'Manage cloud login state used by remote daemon and cloud device workflows.', - summary: 'Manage cloud login state used by remote daemon and cloud device workflows', positionalArgs: ['status|login|logout'], supportedFlags: ['remoteConfig', 'stateDir'], }, daemon: { + text: { + summary: 'Stop a local daemon and clean up resources', + 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 ] [--clean]', listUsageOverride: 'daemon stop', - helpDescription: - '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.', - summary: 'Safely stop a local daemon and optionally clean retained runner resources', positionalArgs: ['stop'], allowedFlags: ['clean'], supportedFlags: ['stateDir'], }, device: { + text: { + summary: 'Inspect local advisory device ownership without daemon side effects', + description: + 'Inspect advisory host-local device ownership claims without starting or contacting a daemon. --stale only inspects proven-stale claims; it does not reclaim claims or clean platform resources.', + }, usageOverride: 'device status [--platform ] [--udid ] [--serial ] [--stale]', listUsageOverride: 'device status', - helpDescription: - 'Inspect advisory host-local device ownership claims without starting or contacting a daemon. --stale only inspects proven-stale claims; it does not reclaim claims or clean platform resources.', - summary: 'Inspect local advisory device ownership without daemon side effects', positionalArgs: ['status'], allowedFlags: ['stale'], supportedFlags: ['platform', 'device', 'udid', 'serial'], }, connect: { + text: { + summary: 'Configure remote or provider state', + description: + 'Configure remote access without allocating a device. Direct providers validate credentials/resources before saving state and print the exact device/app preparation needed before open. AGENT_DEVICE_CLOUD_BASE_URL is the bridge/control-plane API origin; use AGENT_DEVICE_DAEMON_AUTH_TOKEN=adc_live_... for CI/service-token automation.', + }, usageOverride: 'connect [cloud|proxy|limrun|browserstack|aws-device-farm] [--remote-config ] [--daemon-base-url ] [--tenant ] [--run-id ] [--lease-id ] [--lease-backend ] [--force] [--no-login]', - helpDescription: - 'Configure remote access without allocating a device. Direct providers validate credentials/resources before saving state and print the exact device/app preparation needed before open. AGENT_DEVICE_CLOUD_BASE_URL is the bridge/control-plane API origin; use AGENT_DEVICE_DAEMON_AUTH_TOKEN=adc_live_... for CI/service-token automation.', listUsageOverride: 'connect', - summary: - 'Configure remote/provider state without allocating a live device; direct providers verify access', positionalArgs: ['provider?'], allowedFlags: [ 'remoteConfig', @@ -98,79 +106,70 @@ const SCHEMA_ONLY_CLI_COMMAND_SCHEMAS = { ], }, connection: { + text: { + summary: 'Inspect the saved remote connection', + description: 'Inspect active remote connection state', + }, usageOverride: 'connection status', listUsageOverride: 'connection', - helpDescription: 'Inspect active remote connection state', - summary: 'Inspect the active saved remote connection before assuming commands are local', positionalArgs: ['status'], supportedFlags: ['remoteConfig', 'stateDir', 'session'], }, disconnect: { - helpDescription: - 'Disconnect remote daemon state, stop owned Metro companion, and release lease', + text: { + summary: 'Clear remote connection state and release leases', + description: 'Disconnect remote daemon state, stop owned Metro companion, and release lease', + }, listUsageOverride: 'disconnect', - summary: - 'Clear remote connection state, stop owned Metro companions, and release remote leases', allowedFlags: ['shutdown'], supportedFlags: ['remoteConfig', 'stateDir', 'session'], }, mcp: { - helpDescription: - 'Start the official stdio MCP server. It exposes structured command tools backed by the agent-device client.', - summary: 'Start MCP server', + text: { + summary: 'Start MCP server', + description: + 'Start the official stdio MCP server. It exposes structured command tools backed by the agent-device client.', + }, }, proxy: { + text: { + summary: 'Expose a local daemon through an HTTP tunnel', + 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 ', + }, usageOverride: 'proxy [--host ] [--port ] [--daemon-auth-token ] [--state-dir ]', listUsageOverride: 'proxy', - helpDescription: `Expose the local daemon HTTP contract through a tunnel-friendly reverse proxy. - -Run 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. - -The 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. - -Use 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. - -Examples: - agent-device proxy --port 4310 - cloudflared tunnel --url http://127.0.0.1:4310 - agent-device connect proxy --daemon-base-url https://example.trycloudflare.com/agent-device --daemon-auth-token `, - summary: 'Expose a local daemon through cloudflared, ngrok, or another HTTP tunnel', allowedFlags: ['proxyHost', 'proxyPort', 'daemonAuthToken', 'stateDir'], }, 'react-devtools': { + text: { + summary: 'Inspect components, hooks, and render profiles', + description: + 'Run pinned agent-react-devtools commands for React Native performance profiling, component trees, props/state/hooks, and render analysis', + }, usageOverride: 'react-devtools [...args]', listUsageOverride: 'react-devtools', - helpDescription: - 'Run pinned agent-react-devtools commands for React Native performance profiling, component trees, props/state/hooks, and render analysis', - summary: - 'Inspect React Native components, props, hooks, errors, slow renders, and rerender profiles', positionalArgs: ['args?'], allowsExtraPositionals: true, supportedFlags: COMMON_COMMAND_SUPPORTED_FLAG_KEYS, }, web: { + text: { + summary: 'Manage web automation backend', + description: + 'Install and inspect the managed web automation backend used by --platform web.\n\nFirst-run flow:\n agent-device web setup\n agent-device open "https://example.com" --platform web\n agent-device snapshot -i --platform web\n agent-device close --platform web\n\nRuntime web commands do not install the backend implicitly. If the managed backend is missing, run agent-device web setup. The backend is resolved only from the managed install in the effective agent-device state dir.\n\nUse web setup to install or reuse the pinned backend. Use web doctor after setup to verify browser backend health.', + }, usageOverride: 'web setup | web doctor', listUsageOverride: 'web setup|doctor', - helpDescription: `Install and inspect the managed web automation backend used by --platform web. - -First-run flow: - agent-device web setup - agent-device open "https://example.com" --platform web - agent-device snapshot -i --platform web - agent-device close --platform web - -Runtime web commands do not install the backend implicitly. If the managed backend is missing, run agent-device web setup. The backend is resolved only from the managed install in the effective agent-device state dir. - -Use web setup to install or reuse the pinned backend. Use web doctor after setup to verify browser backend health.`, - summary: 'Manage web automation backend', positionalArgs: ['setup|doctor'], supportedFlags: ['stateDir'], }, } as const satisfies Record; +// Facet schemas are complete: `defineCommandFacet` attaches the resolved text to every one. const CLI_COMMAND_OVERRIDES = listCommandFamilyCliSchemas() as Partial< - Record + Record >; export function getSchemaOnlyCliCommandSchema(command: string): CommandSchema | undefined { @@ -179,7 +178,7 @@ export function getSchemaOnlyCliCommandSchema(command: string): CommandSchema | : undefined; } -export function getCliCommandOverride(command: string): CommandSchemaOverride | undefined { +export function getCliCommandOverride(command: string): CommandSchema | undefined { return Object.hasOwn(CLI_COMMAND_OVERRIDES, command) ? CLI_COMMAND_OVERRIDES[command as keyof typeof CLI_COMMAND_OVERRIDES] : undefined; diff --git a/src/cli-schema/command-schema.ts b/src/cli-schema/command-schema.ts index ec5ea08524..485460782f 100644 --- a/src/cli-schema/command-schema.ts +++ b/src/cli-schema/command-schema.ts @@ -18,10 +18,12 @@ export type { CliFlags, FlagDefinition, FlagKey }; export type { CommandSchema }; export { getFlagDefinition, getFlagDefinitions, GLOBAL_FLAG_KEYS }; -const COMMAND_SCHEMA_BASES = new Map( +// Bases hold only the flags every command supports; prose arrives with the facet's schema, +// which always carries a complete `text`. +const COMMAND_SCHEMA_BASES = new Map>( listCommandMetadata().map((metadata) => [ metadata.name, - { helpDescription: metadata.description, supportedFlags: COMMON_COMMAND_SUPPORTED_FLAG_KEYS }, + { supportedFlags: COMMON_COMMAND_SUPPORTED_FLAG_KEYS }, ]), ); @@ -59,8 +61,8 @@ function readCommandSchema(command: string): CommandSchema | undefined { if (schemaOnly) return schemaOnly; const base = COMMAND_SCHEMA_BASES.get(command); const override = getCliCommandOverride(command); - if (!base) return undefined; - return override ? { ...base, ...override } : base; + if (!base || !override) return undefined; + return { ...base, ...override }; } export function applyCommandDefaults( diff --git a/src/cli-schema/types.ts b/src/cli-schema/types.ts index da303787d6..d1de9a93c5 100644 --- a/src/cli-schema/types.ts +++ b/src/cli-schema/types.ts @@ -1,15 +1,21 @@ import type { FlagKey } from '../commands/cli-grammar/flag-types.ts'; import type { CliFlags } from '@agent-device/contracts/command'; +import type { CommandText } from '../commands/command-text.ts'; +/** + * Command grammar plus its resolved text. Prose lives entirely in `text`; everything else here + * describes what the parser accepts, so no field is both authored and generated. + */ export type CommandSchema = { - helpDescription: string; - summary?: string; + text: CommandText; positionalArgs?: readonly string[]; allowsExtraPositionals?: boolean; allowedFlags?: readonly FlagKey[]; supportedFlags?: readonly FlagKey[]; defaults?: Partial; + /** Replaces the generated synopsis in `--help`, for shapes the generator cannot express. */ usageOverride?: string; + /** 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 // documentation (flag-definitions-*.ts) does not fit every command it is allowed on — for @@ -17,4 +23,5 @@ export type CommandSchema = { flagDescriptionOverrides?: Partial>; }; -export type CommandSchemaOverride = Partial; +/** Grammar a facet may override. Its prose is authored as the facet's `text`, never here. */ +export type CommandSchemaOverride = Partial>; diff --git a/src/cli/parser/__tests__/cli-help-command-usage.test.ts b/src/cli/parser/__tests__/cli-help-command-usage.test.ts index 72c8f9fc88..29eb38e3cc 100644 --- a/src/cli/parser/__tests__/cli-help-command-usage.test.ts +++ b/src/cli/parser/__tests__/cli-help-command-usage.test.ts @@ -41,7 +41,7 @@ test('usageForCommand documents tv-remote longpress preset', async () => { assert.equal(help === null, false); assert.match(help ?? '', /agent-device tv-remote \[press\|longpress\]/); assert.match(help ?? '', /--duration-ms /); - assert.match(help ?? '', /Use longpress for a 500ms held remote button/); + assert.match(help ?? '', /longpress holds for 500ms by default/); }); test('usageForCommand supports legacy long-press alias', async () => { @@ -244,7 +244,10 @@ test('snapshot command usage documents diff alias', async () => { if (help === null) throw new Error('Expected command help text'); assert.match(help, /agent-device snapshot \[--diff\]/); assert.match(help, /--timeout /); - assert.match(help, /Capture accessibility tree or diff against the previous session baseline/); + assert.match( + help, + /Capture the accessibility tree or compare it with the previous session baseline/, + ); assert.match(help, /inspect rects with snapshot -i --json/); assert.match(help, /verify with diff snapshot -i or snapshot --diff/); }); @@ -314,7 +317,7 @@ test('command usage shows record touch-overlay opt-out flag', async () => { test('command usage keeps detailed descriptions', async () => { const help = await usageForCommand('metro'); if (help === null) throw new Error('Expected command help text'); - assert.match(help, /Prepare a local React Native dev-server runtime/); + assert.match(help, /Prepare a React Native development server or ask connected apps to reload/); assert.match(help, /metro reload/); assert.match(help, /--metro-host /); assert.match(help, /AGENT_DEVICE_METRO_BEARER_TOKEN/); @@ -356,7 +359,7 @@ test('clipboard command usage is documented', async () => { const help = await usageForCommand('clipboard'); if (help === null) throw new Error('Expected command help text'); assert.match(help, /clipboard read \| clipboard write /); - assert.match(help, /Read or write device clipboard text/); + assert.match(help, /Read the current device clipboard text, or replace its contents/); }); test('keyboard command usage is documented', async () => { diff --git a/src/cli/parser/__tests__/cli-help-topics.test.ts b/src/cli/parser/__tests__/cli-help-topics.test.ts index 463c3034eb..74ffaebb8d 100644 --- a/src/cli/parser/__tests__/cli-help-topics.test.ts +++ b/src/cli/parser/__tests__/cli-help-topics.test.ts @@ -5,20 +5,14 @@ import { usage, usageForCommand } from '../args.ts'; test('usage includes concise top-level commands', async () => { const usageText = await usage(); - assert.match( - usageText, - /install-from-source\s{2,}Install app builds from URLs, remote source specs, or CI artifacts/, - ); - assert.match(usageText, /prepare\s{2,}Pre-warm platform helpers/); - assert.match( - usageText, - /metro\s{2,}Prepare Metro\/Re\.Pack reachability for React Native\/Expo apps or trigger app reloads/, - ); + assert.match(usageText, /install-from-source\s{2,}Install app builds from URLs or CI artifacts/); + assert.match(usageText, /prepare\s{2,}Pre-warm platform helpers before automation/); + assert.match(usageText, /metro\s{2,}Prepare the dev server or reload apps/); assert.match(usageText, /batch --steps \| --steps-file /); - assert.match(usageText, /network\s{2,}Inspect HTTP\(S\) traffic parsed from session app logs/); + assert.match(usageText, /network\s{2,}Inspect HTTP\(S\) traffic from session logs/); assert.match(usageText, /clipboard read \| clipboard write /); assert.match(usageText, /keyboard \[action\]/); - assert.match(usageText, /trigger-app-event\s{2,}Invoke app-defined automation\/test events/); + assert.match(usageText, /trigger-app-event\s{2,}Invoke an app-defined automation event/); assert.match(usageText, /gesture \.\.\./); assert.doesNotMatch( usageText, @@ -136,7 +130,7 @@ test('usage includes agent workflows, config, environment, and examples footers' assert.match(usageText, /After mutation: refs are stale/); assert.match(usageText, /use its selector directly; otherwise refresh with snapshot -i/); assert.match(usageText, /fill \[text\]\s+Replace text in/); - assert.match(usageText, /type \s+Append text to the focused field/); + assert.match(usageText, /type \s+Append text to the focused input/); assert.match(usageText, /macOS context menus use click --button secondary/); assert.match( usageText, @@ -710,24 +704,15 @@ test('usage renders concise commands inline with descriptions', async () => { assert.match(help, /Commands:[\s\S]*\n boot\s{2,}Boot target device\/simulator/); assert.match(help, /Commands:[\s\S]*\n shutdown\s{2,}Shutdown target simulator\/emulator/); assert.match(help, / prepare\s{2,}Pre-warm platform helpers/); - assert.match( - help, - / metro\s{2,}Prepare Metro\/Re\.Pack reachability for React Native\/Expo apps/, - ); - assert.match(help, / perf\s{2,}Check runtime metrics, frames, memory, CPU profiles/); - assert.match(help, / cdp\s{2,}Inspect React Native CDP targets, JS heap growth/); - assert.match(help, / react-devtools\s{2,}Inspect React Native components, props, hooks/); - assert.match(help, / proxy\s{2,}Expose a local daemon through cloudflared, ngrok/); + assert.match(help, / metro\s{2,}Prepare the dev server or reload apps/); + assert.match(help, / perf\s{2,}Check metrics, frames, memory, or profiles/); + assert.match(help, / cdp\s{2,}Inspect CDP targets, JS heap, and leaks/); + assert.match(help, / react-devtools\s{2,}Inspect components, hooks, and render profiles/); + assert.match(help, / proxy\s{2,}Expose a local daemon through an HTTP tunnel/); assert.match(help, / batch --steps \| --steps-file \s{2,}Run multiple commands/); assert.match(help, / test \.\.\.\s{2,}Run replay test suites/); - assert.match( - help, - / screenshot \[path\]\s{2,}Capture screenshot with optional density, full-page, desktop/, - ); - assert.match( - help, - / session\s{2,}List active sessions, print the effective daemon state directory, or publish/, - ); + assert.match(help, / screenshot \[path\]\s{2,}Capture a screenshot/); + assert.match(help, / session\s{2,}List sessions, show the state dir, or publish a script/); assert.doesNotMatch(help, / metro prepare[^\n]*--project-root/); assert.doesNotMatch(help, /\n batch\s{2,}Run multiple commands/); assert.doesNotMatch(help, /agent-device-proxy/); diff --git a/src/cli/parser/cli-help.ts b/src/cli/parser/cli-help.ts index c40a0a273e..6e05ee0ba4 100644 --- a/src/cli/parser/cli-help.ts +++ b/src/cli/parser/cli-help.ts @@ -4,6 +4,7 @@ import { MAESTRO_COMPATIBILITY_ADR_URL, MAESTRO_COMPATIBILITY_ISSUE_URL, } from '@agent-device/maestro'; +import { helpBody } from '../../commands/command-text.ts'; import { getCliCommandSchema, getCommandSchema, @@ -1328,7 +1329,7 @@ function renderCommandSection( 'Commands:', commands.map((command) => ({ label: command.usage, - description: command.schema.summary ?? command.schema.helpDescription, + description: command.schema.text.summary, })), ); } @@ -1349,12 +1350,11 @@ export function buildCommandUsageText(commandName: string): string | null { } const flagsSections = sections.length > 0 ? `\n\n${sections.join('\n\n')}` : ''; - return `agent-device ${usage} + // One synopsis, not two: the header used to repeat the Usage block verbatim. + return `Usage: + agent-device ${usage} -${schema.helpDescription} - -Usage: - agent-device ${usage}${flagsSections} +${helpBody(schema.text)}${flagsSections} `; } diff --git a/src/commands/__tests__/command-surface-metadata.test.ts b/src/commands/__tests__/command-surface-metadata.test.ts index d1a6e8ac73..0541de0841 100644 --- a/src/commands/__tests__/command-surface-metadata.test.ts +++ b/src/commands/__tests__/command-surface-metadata.test.ts @@ -20,6 +20,9 @@ import { listCommandFamilyMetadata, } from '../family/registry.ts'; import { listExecutableCommandNames } from '../command-surface.ts'; +import { helpBody, mcpBody } from '../command-text.ts'; +import { explainCommand } from '../command-explain.ts'; +import { getDaemonRouteOwnerFiles } from '../../daemon/route-owner-files.ts'; test('MCP exposed command names have metadata and executable command definitions', () => { const mcpExposedNames = listMcpExposedCommandNames().sort(); @@ -41,12 +44,96 @@ test('CI-only prepare command stays out of MCP tool surface', () => { assert.equal(listMcpExposedCommandNames().includes('prepare'), false); }); +test('every surface reports the same canonical description', () => { + const cliSchemas = listCommandFamilyCliSchemas(); + const definitionsByName = new Map( + listCommandFamilyDefinitions().map((definition) => [definition.name, definition] as const), + ); + + for (const metadata of listCommandMetadata()) { + const definition = definitionsByName.get(metadata.name); + assert.equal( + definition?.description, + metadata.description, + `${metadata.name}: executable definition description drifted from metadata`, + ); + assert.equal( + describeCommand(metadata.name), + metadata.description, + `${metadata.name}: explain reports a description other than the canonical body`, + ); + const text = cliSchemas[metadata.name]?.text; + assert.ok(text, `${metadata.name}: missing command text`); + assert.equal( + text.description, + metadata.description, + `${metadata.name}: CLI text description drifted from metadata`, + ); + // Surfaces may only extend the body, never replace it. + assert.ok( + helpBody(text).startsWith(metadata.description), + `${metadata.name}: help replaces body`, + ); + assert.ok( + mcpBody(metadata).startsWith(metadata.description), + `${metadata.name}: MCP replaces body`, + ); + } +}); + +test('every command states a summary that is shorter than its description', () => { + for (const [name, schema] of Object.entries(listCommandFamilyCliSchemas())) { + const { summary, description } = schema.text; + assert.ok(summary.length > 0, `${name}: summary must not be empty`); + assert.ok( + summary.length <= 72, + `${name}: summary is ${summary.length} chars; the command list wants one short line`, + ); + assert.ok( + !summary.endsWith('.'), + `${name}: summary should read as a label, without a trailing period`, + ); + // Compare loosely: `push` differed from its description only by a trailing period. + assert.notEqual( + summary + .toLowerCase() + .replace(/[^a-z0-9 ]/g, '') + .trim(), + description + .toLowerCase() + .replace(/[^a-z0-9 ]/g, '') + .trim(), + `${name}: summary duplicates the description`, + ); + } +}); + +test('open keeps flag guidance on the CLI surface only', () => { + const cliSchemas = listCommandFamilyCliSchemas(); + const open = listMcpCommandMetadata().find((metadata) => metadata.name === 'open'); + assert.match(mcpBody(open!), /foreground automation target/); + assert.match(helpBody(cliSchemas.open!.text), /foreground automation target/); + assert.match(helpBody(cliSchemas.open!.text), /--launch-console/); +}); + +test('MCP tool descriptions avoid CLI syntax', () => { + const cliSyntax = [/--[a-z]/, /<[^>]+>|\[[^\]]+\]/, /agent-device/, /\bpositional\b/]; + for (const metadata of listMcpCommandMetadata()) { + const description = mcpBody(metadata); + for (const pattern of cliSyntax) { + assert.doesNotMatch(description, pattern, `${metadata.name} contains CLI syntax`); + } + } +}); + test('common command input accepts web platform selector', () => { const snapshotMetadata = listCommandMetadata().find((metadata) => metadata.name === 'snapshot'); if (!snapshotMetadata) throw new Error('Expected snapshot command metadata'); const platformSchema = snapshotMetadata.inputSchema.properties?.platform; - const input = snapshotMetadata.readInput({ platform: 'web' }) as { platform?: unknown }; + const input = snapshotMetadata.readInput({ platform: 'web' }) as { + platform?: unknown; + }; assert.deepEqual(platformSchema?.enum, [ 'apple', 'android', @@ -64,7 +151,11 @@ test('trigger-app-event rejects non-object payloads at command input read time', if (!metadata) throw new Error('Expected trigger-app-event command metadata'); assert.throws( - () => metadata.readInput({ event: 'screenshot_taken', payload: 'not-json-object' }), + () => + metadata.readInput({ + event: 'screenshot_taken', + payload: 'not-json-object', + }), /Expected payload to be an object\./, ); }); @@ -147,3 +238,10 @@ test('command family facets keep daemon writers as an explicit projection subset assert.ok(metadataNames.has(name), `${name} daemon writer must belong to command metadata`); } }); + +const daemonRouteOwnerFiles = getDaemonRouteOwnerFiles(); + +function describeCommand(name: string): string | undefined { + const result = explainCommand(name, { daemonRouteOwnerFiles }); + return result.found ? result.explanation.description : undefined; +} diff --git a/src/commands/batch/index.ts b/src/commands/batch/index.ts index 5c68950fd4..675890e53f 100644 --- a/src/commands/batch/index.ts +++ b/src/commands/batch/index.ts @@ -18,8 +18,6 @@ const batchCommandDefinition = defineExecutableCommand(batchCommandMetadata, (cl const batchCliSchema = { usageOverride: 'batch [--steps | --steps-file ]', listUsageOverride: 'batch --steps | --steps-file ', - helpDescription: 'Execute multiple commands in one daemon request', - summary: 'Run multiple commands', allowedFlags: ['steps', 'stepsFile', 'batchOnError', 'batchMaxSteps', 'out'], } as const satisfies CommandSchemaOverride; @@ -33,6 +31,9 @@ const batchCliReader: CliReader = (_positionals, flags) => ({ const batchCommandFacet = defineCommandFacet({ name: 'batch', + text: { + summary: 'Run multiple commands', + }, metadata: batchCommandMetadata, definition: batchCommandDefinition, cliSchema: batchCliSchema, diff --git a/src/commands/batch/metadata.ts b/src/commands/batch/metadata.ts index ebb45bfbca..d997a9039b 100644 --- a/src/commands/batch/metadata.ts +++ b/src/commands/batch/metadata.ts @@ -49,7 +49,7 @@ export function createBatchCommandMetadata( const fields = batchFields(nestedCommands); return defineCommandMetadata({ name: 'batch', - description: 'Run multiple structured command steps in one daemon request.', + description: 'Execute multiple commands in one daemon request', inputSchema: fieldsInputSchema(fields), readInput: (input) => readBatchInput(input, fields), }); diff --git a/src/commands/capture/alert.ts b/src/commands/capture/alert.ts index 0b5d2b3b0b..679ee7794f 100644 --- a/src/commands/capture/alert.ts +++ b/src/commands/capture/alert.ts @@ -17,7 +17,8 @@ import { AppError } from '@agent-device/kernel/errors'; const ALERT_COMMAND_NAME = 'alert'; -const alertCommandDescription = 'Inspect or handle platform alerts.'; +const alertCommandDescription = + 'Inspect, wait for, accept, or dismiss a platform alert. Use get before acting when the alert content matters; accept and dismiss change the active alert state.'; const alertCommandMetadata = defineFieldCommandMetadata( ALERT_COMMAND_NAME, @@ -48,6 +49,9 @@ export const alertDaemonWriter: DaemonWriter = direct(PUBLIC_COMMANDS.alert, (in export const alertCommandFacet = defineCommandFacet({ name: ALERT_COMMAND_NAME, + text: { + summary: 'Inspect, accept, or dismiss a platform alert', + }, metadata: alertCommandMetadata, definition: alertCommandDefinition, cliSchema: alertCliSchema, diff --git a/src/commands/capture/diff.ts b/src/commands/capture/diff.ts index 0bf1edaaa9..6ad23cec4e 100644 --- a/src/commands/capture/diff.ts +++ b/src/commands/capture/diff.ts @@ -16,7 +16,8 @@ import { defineFieldCommandMetadata } from '../field-command-contract.ts'; const DIFF_COMMAND_NAME = 'diff'; -const diffCommandDescription = 'Diff accessibility snapshots.'; +const diffCommandDescription = + 'Compare accessibility snapshots or screenshots to identify UI changes. Use snapshot comparisons for semantic tree changes and screenshot comparisons for pixel differences.'; const diffCommandMetadata = defineFieldCommandMetadata(DIFF_COMMAND_NAME, diffCommandDescription, { kind: requiredField(jsonSchemaField<'snapshot'>({ type: 'string', const: 'snapshot' })), @@ -34,9 +35,6 @@ const diffCommandDefinition = defineExecutableCommand(diffCommandMetadata, (clie const diffCliSchema = { usageOverride: 'diff snapshot | diff screenshot --baseline [current.png] [--out ] [--threshold <0-1>] [--overlay-refs]', - helpDescription: - 'Diff accessibility snapshot or compare screenshots pixel-by-pixel. Live iOS simulator screenshot diffs normalize status-bar chrome by default; use screenshot --normalize-status-bar when capturing reusable baselines.', - summary: 'Diff snapshot or screenshot', positionalArgs: ['kind', 'current?'], allowedFlags: [...SNAPSHOT_FLAGS, 'baseline', 'threshold', 'out', 'overlayRefs'], } as const; @@ -62,6 +60,11 @@ const diffDaemonWriter: DaemonWriter = direct(PUBLIC_COMMANDS.diff, (input) => [ export const diffCommandFacet = defineCommandFacet({ name: DIFF_COMMAND_NAME, + text: { + summary: 'Diff snapshot or screenshot', + cliDetail: + 'Live iOS simulator screenshot diffs normalize status-bar chrome by default; use screenshot --normalize-status-bar when capturing reusable baselines.', + }, metadata: diffCommandMetadata, definition: diffCommandDefinition, cliSchema: diffCliSchema, diff --git a/src/commands/capture/screenshot.ts b/src/commands/capture/screenshot.ts index d1b2014b61..367bf64b27 100644 --- a/src/commands/capture/screenshot.ts +++ b/src/commands/capture/screenshot.ts @@ -15,7 +15,8 @@ import { defineFieldCommandMetadata } from '../field-command-contract.ts'; const SCREENSHOT_COMMAND_NAME = 'screenshot'; -const screenshotCommandDescription = 'Capture a screenshot.'; +const screenshotCommandDescription = + 'Capture a screenshot of the active app or web session. Choose the capture scope, density, size, or annotations through the corresponding input fields when needed.'; const screenshotCommandMetadata = defineFieldCommandMetadata( SCREENSHOT_COMMAND_NAME, @@ -40,10 +41,6 @@ const screenshotCommandDefinition = defineExecutableCommand( ); const screenshotCliSchema = { - helpDescription: - 'Capture screenshot (web defaults to the viewport; use --fullscreen, --full, or -f for the entire page. iOS simulators default to 1x logical-point output; use --pixel-density to request a different screenshot density. macOS app sessions default to the app window; use --fullscreen for full desktop, --max-size to downscale, --overlay-refs to annotate current refs, --normalize-status-bar for deterministic iOS simulator chrome, or --no-stabilize for low-latency Android capture loops)', - summary: - 'Capture screenshot with optional density, full-page, desktop, downscale, or ref overlay modes', positionalArgs: ['path?'], allowedFlags: SCREENSHOT_COMMAND_FLAG_KEYS, } as const; @@ -62,6 +59,11 @@ export const screenshotDaemonWriter: DaemonWriter = (input) => export const screenshotCommandFacet = defineCommandFacet({ name: SCREENSHOT_COMMAND_NAME, + text: { + summary: 'Capture a screenshot', + cliDetail: + 'Web defaults to the viewport; use --fullscreen, --full, or -f for the entire page. iOS simulators default to 1x logical-point output; use --pixel-density to request a different screenshot density. macOS app sessions default to the app window; use --fullscreen for full desktop, --max-size to downscale, --overlay-refs to annotate current refs, --normalize-status-bar for deterministic iOS simulator chrome, or --no-stabilize for low-latency Android capture loops.', + }, metadata: screenshotCommandMetadata, definition: screenshotCommandDefinition, cliSchema: screenshotCliSchema, diff --git a/src/commands/capture/settings.ts b/src/commands/capture/settings.ts index c64b149629..0c47ddfeb1 100644 --- a/src/commands/capture/settings.ts +++ b/src/commands/capture/settings.ts @@ -19,7 +19,8 @@ import { defineCommandFacet } from '../family/types.ts'; import { defineFieldCommandMetadata } from '../field-command-contract.ts'; const SETTINGS_COMMAND_NAME = 'settings'; -const settingsCommandDescription = 'Change OS settings and app permissions.'; +const settingsCommandDescription = + 'Change supported operating-system settings, animation scales, appearance, or app permissions on the selected target. Platform support varies by setting and action.'; const settingsCommandMetadata = defineFieldCommandMetadata( SETTINGS_COMMAND_NAME, @@ -43,9 +44,6 @@ const settingsCommandDefinition = defineExecutableCommand( const settingsCliSchema = { usageOverride: SETTINGS_USAGE_OVERRIDE, listUsageOverride: 'settings [area] [options]', - helpDescription: - 'Toggle OS settings, animation scales, appearance, and app permissions (macOS supports only settings appearance and settings permission ; wifi|airplane|location|animations remain unsupported on macOS; mobile permission actions use the active session app)', - summary: 'Change OS settings and app permissions', positionalArgs: ['setting', 'state', 'target?', 'mode?'], } as const satisfies CommandSchemaOverride; @@ -58,6 +56,11 @@ export const settingsDaemonWriter: DaemonWriter = direct(PUBLIC_COMMANDS.setting export const settingsCommandFacet = defineCommandFacet({ name: SETTINGS_COMMAND_NAME, + text: { + summary: 'Change OS settings and app permissions', + cliDetail: + 'macOS supports only settings appearance and settings permission ; wifi|airplane|location|animations remain unsupported on macOS. Mobile permission actions use the active session app.', + }, metadata: settingsCommandMetadata, definition: settingsCommandDefinition, cliSchema: settingsCliSchema, diff --git a/src/commands/capture/snapshot.ts b/src/commands/capture/snapshot.ts index 1fd0284222..df8b592816 100644 --- a/src/commands/capture/snapshot.ts +++ b/src/commands/capture/snapshot.ts @@ -14,7 +14,8 @@ import { captureCliOutputFormatters } from './output.ts'; const SNAPSHOT_COMMAND_NAME = 'snapshot'; -const snapshotCommandDescription = 'Capture an accessibility snapshot.'; +const snapshotCommandDescription = + 'Capture the accessibility tree or compare it with the previous session baseline. Use the returned refs for subsequent semantic interactions and the diff option to verify UI changes.'; const snapshotCommandMetadata = defineFieldCommandMetadata( SNAPSHOT_COMMAND_NAME, @@ -45,9 +46,6 @@ const snapshotCommandDefinition = defineExecutableCommand( const snapshotCliSchema = { usageOverride: 'snapshot [--diff] [-i] [-d ] [-s ] [--raw] [--force-full] [--timeout ]', - helpDescription: - 'Capture accessibility tree or diff against the previous session baseline. For iOS raw-coordinate fallback after a no-op ref press, inspect rects with snapshot -i --json, press the rect center, then verify with diff snapshot -i or snapshot --diff.', - summary: 'Capture accessibility tree or diff against the previous session baseline', allowedFlags: ['snapshotDiff', ...SNAPSHOT_FLAGS, 'snapshotForceFull', 'timeoutMs', 'record'], } as const; @@ -66,6 +64,11 @@ const snapshotDaemonWriter: DaemonWriter = direct(PUBLIC_COMMANDS.snapshot); export const snapshotCommandFacet = defineCommandFacet({ name: SNAPSHOT_COMMAND_NAME, + text: { + summary: 'Capture or diff the accessibility tree', + cliDetail: + 'For iOS raw-coordinate fallback after a no-op ref press, inspect rects with snapshot -i --json, press the rect center, then verify with diff snapshot -i or snapshot --diff.', + }, metadata: snapshotCommandMetadata, definition: snapshotCommandDefinition, cliSchema: snapshotCliSchema, diff --git a/src/commands/capture/wait.ts b/src/commands/capture/wait.ts index 4bea760d71..6dbd138c64 100644 --- a/src/commands/capture/wait.ts +++ b/src/commands/capture/wait.ts @@ -27,7 +27,8 @@ import { WAIT_KIND_VALUES } from './wait-command-contract.ts'; const WAIT_COMMAND_NAME = 'wait'; -const waitCommandDescription = 'Wait for duration, text, ref, selector, or stable UI.'; +const waitCommandDescription = + 'Wait for a duration, text, snapshot ref, selector, or stable UI. Use text, ref, or selector for a specific readiness condition; stable waits until the UI stays quiet for the requested window.'; const waitCommandMetadata = defineFieldCommandMetadata(WAIT_COMMAND_NAME, waitCommandDescription, { kind: enumField(WAIT_KIND_VALUES), @@ -63,6 +64,9 @@ export const waitDaemonWriter: DaemonWriter = direct(PUBLIC_COMMANDS.wait, (inpu export const waitCommandFacet = defineCommandFacet({ name: WAIT_COMMAND_NAME, + text: { + summary: 'Wait for a duration, text, selector, or stable UI', + }, metadata: waitCommandMetadata, definition: waitCommandDefinition, cliSchema: waitCliSchema, diff --git a/src/commands/command-contract.ts b/src/commands/command-contract.ts index e20ced9dd4..a290d26095 100644 --- a/src/commands/command-contract.ts +++ b/src/commands/command-contract.ts @@ -18,6 +18,12 @@ export type JsonSchema = { export type CommandMetadata = { name: Name; description: string; + /** + * MCP-only tail appended to `description` for the MCP tool surface. Stored as the tail alone + * so `description` remains the single home of the canonical body for CLI help, `explain`, + * the executable definition, and docs. Compose with `composeMcpDescription`. + */ + mcpDetail?: string; inputSchema: JsonSchema; readInput: (input: unknown) => Input; }; diff --git a/src/commands/command-explain.ts b/src/commands/command-explain.ts index fb190abefa..9fab6fe4a0 100644 --- a/src/commands/command-explain.ts +++ b/src/commands/command-explain.ts @@ -204,7 +204,7 @@ function describeCommandText( (candidate): candidate is CommandFamilyMetadata => candidate.name === descriptor.name, ); return ( - metadata?.description ?? cliSchema?.helpDescription ?? `Internal command ${descriptor.name}` + metadata?.description ?? cliSchema?.text.description ?? `Internal command ${descriptor.name}` ); } diff --git a/src/commands/command-text.ts b/src/commands/command-text.ts new file mode 100644 index 0000000000..a9fb7b6218 --- /dev/null +++ b/src/commands/command-text.ts @@ -0,0 +1,56 @@ +/** + * Every word a command shows a human or a model, in one place. + * + * Each field answers a different question, so none can substitute for another and none needs a + * fallback chain: + * + * summary what is this command, in a list of ninety? + * description what does it do, and when should I reach for it? + * cliDetail what else does a terminal user need — flags, argument shapes, examples? + * mcpDetail what else does a model need — sequencing, cross-tool hints? + * + * `--help`, the command list, the MCP tool description and `explain` are all projections of + * these four, computed where they are rendered rather than written back into the schema. That + * is deliberate: the field this replaced was authored on some commands and generated on others, + * which is how surfaces silently drifted apart. + * + * Because `cliDetail` is the only field carrying terminal vocabulary, and the MCP surface never + * reads it, MCP descriptions stay free of flags and positional syntax structurally rather than + * by a review guard. + */ +export type CommandText = { + /** One line for the command list. Imperative, no trailing period, fits a terminal column. */ + summary: string; + /** Canonical description: the MCP tool text, `explain`, the docs site, and the `--help` body. */ + description: string; + /** Appended to the `--help` body only: flags, positional syntax, terminal examples. */ + cliDetail?: string; + /** Appended to the MCP tool description only: when-to-use and sequencing hints. */ + mcpDetail?: string; +}; + +/** + * What a command backed by a family facet authors. `description` is absent because the facet's + * metadata already carries it — repeating it here would leave a shadowed copy behind, which is + * exactly the dead-literal problem this file's predecessor had. + */ +export type FacetCommandText = Omit; + +/** The `--help` body: canonical description plus any terminal-only detail. */ +export function helpBody(text: CommandText): string { + return join(text.description, text.cliDetail); +} + +/** The MCP tool description: canonical description plus any model-only detail. */ +export function mcpBody(text: Pick): string { + return join(text.description, text.mcpDetail); +} + +/** Resolves a facet's authored text against the description its metadata already holds. */ +export function resolveFacetText(text: FacetCommandText, description: string): CommandText { + return { ...text, description }; +} + +function join(description: string, detail: string | undefined): string { + return detail ? `${description} ${detail}` : description; +} diff --git a/src/commands/debugging/index.ts b/src/commands/debugging/index.ts index e3565e25fd..1de2c1b62f 100644 --- a/src/commands/debugging/index.ts +++ b/src/commands/debugging/index.ts @@ -11,7 +11,8 @@ import { debuggingCliOutputFormatters } from './output.ts'; const DEBUG_COMMAND_NAME = 'debug'; const DEBUG_ACTION_VALUES = ['symbols'] as const; -const debugCommandDescription = 'Symbolicate crash artifacts with matching debug symbols.'; +const debugCommandDescription = + 'Symbolicate Apple crash artifacts with matching dSYM UUIDs. This debug namespace is intentionally narrow: use logs for app logs, network for HTTP evidence, perf for performance samples, record/trace for media and traces, and react-devtools for React Native profiles.'; export const debugCommandMetadata = defineFieldCommandMetadata( DEBUG_COMMAND_NAME, @@ -34,10 +35,6 @@ const debugCliSchema = { usageOverride: 'debug symbols --artifact (--dsym | --search-path ) [--out ]', listUsageOverride: 'debug', - helpDescription: - 'Symbolicate Apple crash artifacts with matching dSYM UUIDs. This debug namespace is intentionally narrow: use logs for app logs, network for HTTP evidence, perf for performance samples, record/trace for media and traces, and react-devtools for React Native profiles.', - summary: - 'Symbolicate Apple crash artifacts with dSYMs; use logs/network/perf for other diagnostics', positionalArgs: ['symbols'], allowedFlags: ['artifact', 'dsym', 'searchPath', 'out'], } as const satisfies CommandSchemaOverride; @@ -53,6 +50,9 @@ export const debugCliReader: CliReader = (positionals, flags) => ({ const debugCommandFacet = defineCommandFacet({ name: DEBUG_COMMAND_NAME, + text: { + summary: 'Symbolicate Apple crash artifacts', + }, metadata: debugCommandMetadata, definition: debugCommandDefinition, cliSchema: debugCliSchema, diff --git a/src/commands/family/registry.ts b/src/commands/family/registry.ts index 30b55d69b6..484a84370a 100644 --- a/src/commands/family/registry.ts +++ b/src/commands/family/registry.ts @@ -12,11 +12,11 @@ import { reactNativeCommandFamily } from '../react-native/index.ts'; import { recordingCommandFamily } from '../recording/index.ts'; import { replayCommandFamily } from '../replay/index.ts'; import { systemCommandFamily } from '../system/index.ts'; -import type { CommandSchemaOverride } from '../../cli-schema/types.ts'; +import type { CommandSchema } from '../../cli-schema/types.ts'; import { type CommandFamilyFacet } from './types.ts'; type CommandFamilyRecordMap = { - cliSchemas: CommandSchemaOverride; + cliSchemas: CommandSchema; cliReaders: CliReader; daemonWriters: DaemonWriter; cliOutputFormatters: CliOutputFormatter; @@ -49,7 +49,7 @@ export function listCommandFamilyDefinitions(): CommandFamilyDefinition[] { return commandFamilies.flatMap((family) => [...family.definitions]); } -export function listCommandFamilyCliSchemas(): Record { +export function listCommandFamilyCliSchemas(): Record { return mergeFamilyRecords('cliSchemas'); } diff --git a/src/commands/family/types.ts b/src/commands/family/types.ts index 21793027b6..bf71c9c91b 100644 --- a/src/commands/family/types.ts +++ b/src/commands/family/types.ts @@ -1,5 +1,5 @@ import type { AgentDeviceClient } from '../../client/client-types.ts'; -import type { CommandSchemaOverride } from '../../cli-schema/types.ts'; +import type { CommandSchema, CommandSchemaOverride } from '../../cli-schema/types.ts'; import type { CliReader, DaemonWriter } from '../cli-grammar/types.ts'; import type { CommandMetadata, @@ -7,12 +7,14 @@ import type { JsonSchema, } from '../command-contract.ts'; import type { CliOutputFormatter } from '../output-common.ts'; +import { resolveFacetText, type FacetCommandText } from '../command-text.ts'; export type AnyCommandMetadata = CommandMetadata; export type AnyCommandDefinition = { name: Name; description: string; + mcpDetail?: string; inputSchema: JsonSchema; invoke: (client: AgentDeviceClient, input: unknown) => Promise; projection?: ExecutableCommandProjection; @@ -24,13 +26,17 @@ export type CommandFamilyFacet = { metadata: readonly AnyCommandMetadata[]; definitions: readonly AnyCommandDefinition[]; clientCommandMethods?: Readonly>; - cliSchemas?: Readonly>>; + cliSchemas?: Readonly>>; cliReaders: Readonly>; daemonWriters?: Readonly>; cliOutputFormatters?: Readonly>>; }; -export type CommandFacet = { +/** + * What a command file authors. `cliSchema` carries grammar only and may be omitted entirely; + * `text` is required, because a command with no list line has nowhere to appear in `--help`. + */ +export type CommandFacetInput = { name: TCommandName; metadata: AnyCommandMetadata; definition: AnyCommandDefinition; @@ -39,6 +45,15 @@ export type CommandFacet = { cliReader: CliReader; daemonWriter?: DaemonWriter; cliOutputFormatter?: CliOutputFormatter; + text: FacetCommandText; +}; + +/** + * What `defineCommandFacet` returns: the same facet with its schema completed. Stating this as a + * distinct type is what lets the registry read `cliSchema` without asserting it is populated. + */ +export type CommandFacet = CommandFacetInput & { + cliSchema: CommandSchema; }; type CommandFacetMetadata = { @@ -60,25 +75,32 @@ export type ProjectedCommandOutputSchemas, ->(command: TCommand): TCommand { - return command; + const TCommand extends CommandFacetInput, +>(command: TCommand): TCommand & { cliSchema: CommandSchema } { + // The metadata already holds the canonical description, so the facet never repeats it; the + // resolved text is what every surface renders from. + const text = resolveFacetText(command.text, command.metadata.description); + const mcpTail = text.mcpDetail ? { mcpDetail: text.mcpDetail } : {}; + return { + ...command, + metadata: { ...command.metadata, ...mcpTail }, + definition: { ...command.definition, ...mcpTail }, + cliSchema: { ...command.cliSchema, text }, + }; } export function defineCommandFamilyFromFacets< const TFamilyName extends string, const TCommands extends readonly CommandFacet[], >(family: { name: TFamilyName; clientSurface?: boolean; commands: TCommands }) { - const cliSchemas: Record = {}; + const cliSchemas: Record = {}; const clientCommandMethods: Record = {}; const cliReaders: Record = {}; const daemonWriters: Record = {}; const cliOutputFormatters: Record = {}; for (const command of family.commands) { - if (command.cliSchema) { - addRecordEntry(cliSchemas, 'CLI schema', command.name, command.cliSchema); - } + addRecordEntry(cliSchemas, 'CLI schema', command.name, command.cliSchema); const clientMethod = command.definition.projection?.clientMethod ?? command.clientMethod; if (clientMethod) { addRecordEntry(clientCommandMethods, 'client command method', clientMethod, command.name); @@ -105,7 +127,7 @@ export function defineCommandFamilyFromFacets< (command) => command.definition, ) as CommandFacetDefinitions, clientCommandMethods: clientCommandMethods as Record>, - cliSchemas: cliSchemas as Partial, CommandSchemaOverride>>, + cliSchemas: cliSchemas as Partial, CommandSchema>>, cliReaders: cliReaders as Record, CliReader>, daemonWriters, cliOutputFormatters: cliOutputFormatters as Partial< diff --git a/src/commands/interaction/index.ts b/src/commands/interaction/index.ts index 21e1369ce8..80b91799b2 100644 --- a/src/commands/interaction/index.ts +++ b/src/commands/interaction/index.ts @@ -67,8 +67,6 @@ const interactionCliSchemas = { }, find: { usageOverride: 'find [value] [--first|--last]', - helpDescription: 'Find by text/label/value/role/id and run action', - summary: 'Find an element and act', positionalArgs: ['query', 'action', 'value?'], allowsExtraPositionals: true, allowedFlags: ['snapshotDepth', 'snapshotRaw', 'findFirst', 'findLast', 'record'], @@ -91,8 +89,6 @@ const interactionCliSchemas = { }, press: { usageOverride: 'press ', - helpDescription: - 'Short press a semantic UI target by ref, selector, or point. For native context menus or hold gestures, use longpress instead of press --hold-ms.', positionalArgs: ['targetOrX', 'y?'], allowsExtraPositionals: true, allowedFlags: [ @@ -103,14 +99,11 @@ const interactionCliSchemas = { }, longpress: { usageOverride: 'longpress [durationMs]', - helpDescription: - 'Open native context menus or long-press targets by ref, selector, or point. Duration is positional, for example longpress @e12 800 or longpress 300 500 800.', positionalArgs: ['targetOrX', 'yOrDurationMs?', 'durationMs?'], allowsExtraPositionals: true, allowedFlags: [...postActionObservationCliFlags('longpress'), ...SELECTOR_SNAPSHOT_FLAGS], }, swipe: { - helpDescription: 'Quick coordinate fling with optional repeat pattern.', positionalArgs: ['x1', 'y1', 'x2', 'y2'], // Arity is enforced by swipePayloadFromPositionals (assertGestureArity), so // an extra positional reaches that migration-hint error, not this schema's. @@ -120,9 +113,6 @@ const interactionCliSchemas = { gesture: { usageOverride: 'gesture ...', listUsageOverride: 'gesture ...', - helpDescription: - 'Run touch gestures: pan [durationMs], fling [distance], swipe , pinch [x] [y], rotate [x] [y], transform [durationMs], or drag [sourceHoldMs] [moveMs] [destinationHoldMs]. For command plans, output only command lines. Android transform verification should use all app-observable effects, for example wait text "pan changed yes", wait text "pinch changed yes", and wait text "rotate changed yes", not exact transform values.', - summary: 'Run pan, fling, swipe, pinch, rotate, transform, or drag gestures', positionalArgs: ['pan|fling|swipe|pinch|rotate|transform|drag', 'args?'], allowsExtraPositionals: true, allowedFlags: ['pointerCount'], @@ -148,8 +138,6 @@ const interactionCliSchemas = { }, scroll: { usageOverride: 'scroll [amount] [--pixels ] [--duration-ms ]', - helpDescription: 'Scroll in a direction, or toward the top/bottom edge of scrollable content.', - summary: 'Scroll in a direction or to an edge', positionalArgs: ['directionOrEdge', 'amount?'], allowedFlags: ['pixels', 'durationMs'], }, @@ -232,6 +220,9 @@ const gestureCommandDefinition = defineExecutableCommand( const clickCommandFacet = defineCommandFacet({ name: 'click', + text: { + summary: 'Click or tap a UI target', + }, metadata: metadata('click'), definition: clickCommandDefinition, cliSchema: interactionCliSchemas.click, @@ -242,6 +233,10 @@ const clickCommandFacet = defineCommandFacet({ const pressCommandFacet = defineCommandFacet({ name: 'press', + text: { + summary: 'Short-press a UI target', + cliDetail: 'The hold duration is positional on longpress, not press --hold-ms.', + }, metadata: metadata('press'), definition: pressCommandDefinition, cliSchema: interactionCliSchemas.press, @@ -252,6 +247,9 @@ const pressCommandFacet = defineCommandFacet({ const fillCommandFacet = defineCommandFacet({ name: 'fill', + text: { + summary: 'Replace text in a UI input', + }, metadata: metadata('fill'), definition: fillCommandDefinition, cliSchema: interactionCliSchemas.fill, @@ -262,6 +260,10 @@ const fillCommandFacet = defineCommandFacet({ const longPressCommandFacet = defineCommandFacet({ name: 'longpress', + text: { + summary: 'Hold a UI target to open a context menu', + cliDetail: 'Duration is positional, for example longpress @e12 800 or longpress 300 500 800.', + }, metadata: metadata('longpress'), definition: longPressCommandDefinition, cliSchema: interactionCliSchemas.longpress, @@ -272,6 +274,9 @@ const longPressCommandFacet = defineCommandFacet({ const swipeCommandFacet = defineCommandFacet({ name: 'swipe', + text: { + summary: 'Fling between coordinates', + }, metadata: metadata('swipe'), definition: swipeCommandDefinition, cliSchema: interactionCliSchemas.swipe, @@ -281,6 +286,9 @@ const swipeCommandFacet = defineCommandFacet({ const focusCommandFacet = defineCommandFacet({ name: 'focus', + text: { + summary: 'Focus input at screen coordinates', + }, metadata: metadata('focus'), definition: focusCommandDefinition, cliSchema: interactionCliSchemas.focus, @@ -290,6 +298,9 @@ const focusCommandFacet = defineCommandFacet({ const typeCommandFacet = defineCommandFacet({ name: 'type', + text: { + summary: 'Append text to the focused input', + }, metadata: metadata('type'), definition: typeCommandDefinition, cliSchema: interactionCliSchemas.type, @@ -299,6 +310,9 @@ const typeCommandFacet = defineCommandFacet({ const scrollCommandFacet = defineCommandFacet({ name: 'scroll', + text: { + summary: 'Scroll in a direction or to an edge', + }, metadata: metadata('scroll'), definition: scrollCommandDefinition, cliSchema: interactionCliSchemas.scroll, @@ -308,6 +322,9 @@ const scrollCommandFacet = defineCommandFacet({ const getCommandFacet = defineCommandFacet({ name: 'get', + text: { + summary: 'Read element text or attributes', + }, metadata: metadata('get'), definition: getCommandDefinition, cliSchema: interactionCliSchemas.get, @@ -318,6 +335,9 @@ const getCommandFacet = defineCommandFacet({ const isCommandFacet = defineCommandFacet({ name: 'is', + text: { + summary: 'Check a UI predicate on a selector', + }, metadata: metadata('is'), definition: isCommandDefinition, cliSchema: interactionCliSchemas.is, @@ -328,6 +348,9 @@ const isCommandFacet = defineCommandFacet({ const findCommandFacet = defineCommandFacet({ name: 'find', + text: { + summary: 'Find an element and act', + }, metadata: metadata('find'), definition: findCommandDefinition, cliSchema: interactionCliSchemas.find, @@ -338,6 +361,11 @@ const findCommandFacet = defineCommandFacet({ const gestureCommandFacet = defineCommandFacet({ name: 'gesture', + text: { + summary: 'Run pan, fling, swipe, pinch, rotate, transform, or drag gestures', + cliDetail: + 'Argument shapes: pan [durationMs], fling [distance], swipe , pinch [x] [y], rotate [x] [y], transform [durationMs], or drag [sourceHoldMs] [moveMs] [destinationHoldMs]. For command plans, output only command lines. Android transform verification should use all app-observable effects, for example wait text "pan changed yes", wait text "pinch changed yes", and wait text "rotate changed yes", not exact transform values.', + }, metadata: metadata('gesture'), definition: gestureCommandDefinition, cliSchema: interactionCliSchemas.gesture, diff --git a/src/commands/interaction/metadata.ts b/src/commands/interaction/metadata.ts index 590b1bd054..34d2bc9acf 100644 --- a/src/commands/interaction/metadata.ts +++ b/src/commands/interaction/metadata.ts @@ -58,18 +58,23 @@ const FIND_ACTION_VALUES = [ ] as const; const interactionCommandDescriptions = { - click: 'Click or tap a semantic UI target by ref, selector, or point.', - press: 'Press a semantic UI target by ref, selector, or point.', - fill: 'Replace text in a semantic UI target by ref, selector, or point.', - longpress: 'Long press by ref, selector, or point.', - swipe: 'Swipe between two points.', - focus: 'Focus input at coordinates.', - type: 'Append text to the focused field.', - scroll: 'Scroll in a direction or to an edge.', - get: 'Get element text or attributes.', - is: 'Assert UI state.', - find: 'Find an element and optionally act on it.', - gesture: 'Run a structured gesture.', + click: + 'Activate a UI target by snapshot ref, selector, or coordinates. Prefer a ref or selector after a snapshot; use coordinates only when semantic targeting is unavailable. This can change app state; use settle or verify to confirm the result without a follow-up snapshot.', + press: + 'Short-press a UI target by snapshot ref, selector, or coordinates. Use longpress instead when the target requires a context-menu or hold gesture.', + fill: 'Replace text in a UI input selected by snapshot ref, selector, or coordinates. Prefer refs or selectors after snapshot; use recordAs to keep sensitive text out of a recorded replay while sending it to the live app.', + longpress: + 'Hold a UI target by snapshot ref, selector, or coordinates to open a context menu or perform another hold gesture. Set durationMs when the default hold duration is unsuitable.', + swipe: 'Quick coordinate fling with optional repeat pattern.', + focus: + 'Move input focus to explicit screen coordinates without entering text. Prefer semantic interactions when a snapshot ref or selector is available; use type or fill after focus.', + type: 'Append text to the currently focused input. Use fill when the existing field value should be replaced, and focus first when no input is active.', + scroll: 'Scroll in a direction, or toward the top/bottom edge of scrollable content.', + get: 'Read text or accessibility attributes from a snapshot ref or selector without changing the app. Use format text for visible content or attrs for the element attribute map.', + is: 'Check whether a selector satisfies a UI predicate such as visible, hidden, editable, selected, focused, or text. Use wait when the condition may appear asynchronously.', + find: 'Find by text/label/value/role/id and run action', + gesture: + 'Perform a structured pan, fling, swipe, pinch, rotate, transform, or drag gesture. Select the gesture kind, then provide only the inputs that apply to that kind.', } as const; type InteractionCommandName = keyof typeof interactionCommandDescriptions; diff --git a/src/commands/management/app.ts b/src/commands/management/app.ts index d25dbdde0e..dbc5603746 100644 --- a/src/commands/management/app.ts +++ b/src/commands/management/app.ts @@ -23,24 +23,36 @@ import { defineFieldCommandMetadata } from '../field-command-contract.ts'; import { withCommandRuntimeHints } from '../runtime-hints.ts'; import { managementCliOutputFormatters } from './output.ts'; -const appsCommandMetadata = defineFieldCommandMetadata('apps', 'List installed apps.', { - appsFilter: enumField(['user-installed', 'all']), -}); +const appsCommandMetadata = defineFieldCommandMetadata( + 'apps', + 'List the apps installed on the selected device. Include system or OEM apps only when they are needed as automation targets.', + { + appsFilter: enumField( + ['user-installed', 'all'], + 'Restrict the listing to user-installed apps, or include system and OEM apps.', + ), + }, +); const openCommandMetadata = defineFieldCommandMetadata( 'open', - 'Open an app, deep link, URL, or platform surface.', + 'Boot the selected device when needed, then open an app, deep link, or URL in a session. Use the app or URL inputs to choose what becomes the foreground automation target.', { app: stringField('App name, bundle id, package, or URL.'), url: stringField('Optional URL passed with an app shell.'), - surface: enumField(SESSION_SURFACES), + surface: enumField( + SESSION_SURFACES, + 'macOS presentation surface to open: the app itself, the frontmost app, the desktop, or the menu bar.', + ), activity: stringField('Android activity name.'), launchConsole: stringField('Launch console mode.'), launchArgs: stringArrayField( 'Launch arguments forwarded verbatim to the platform launch command.', ), relaunch: booleanField('Force relaunch.'), - saveScript: jsonSchemaField({ oneOf: [booleanSchema(), stringSchema()] }), + saveScript: jsonSchemaField({ + oneOf: [booleanSchema(), stringSchema()], + }), force: booleanField( 'Overwrite an existing --save-script target instead of refusing (alias: --overwrite).', ), @@ -64,11 +76,13 @@ const openCommandMetadata = defineFieldCommandMetadata( const closeCommandMetadata = defineFieldCommandMetadata( 'close', - 'Close an app or end the active session.', + 'Close the named app, or close the active session app when app is omitted. Use shutdown only when the selected simulator or emulator should also stop.', { app: stringField('Optional app to close.'), shutdown: booleanField('Shutdown the session/device where supported.'), - saveScript: jsonSchemaField({ oneOf: [booleanSchema(), stringSchema()] }), + saveScript: jsonSchemaField({ + oneOf: [booleanSchema(), stringSchema()], + }), force: booleanField( 'Overwrite an existing --save-script target instead of refusing (alias: --overwrite).', ), @@ -100,16 +114,11 @@ const closeCommandDefinition = defineExecutableCommand(closeCommandMetadata, (cl ); const appsCliSchema = { - helpDescription: 'List user-installed apps; use --all to include system/OEM apps', - summary: 'List installed apps', allowedFlags: ['appsFilter'], defaults: { appsFilter: DEFAULT_APPS_FILTER }, } as const satisfies CommandSchemaOverride; const openCliSchema = { - helpDescription: - 'Boot device/simulator; optionally launch app or deep link URL. Use --platform to bind URL/deep-link opens to the target platform. For iOS simulator initial stdout/stderr, put --launch-console on this open command, for example agent-device open "Agent Device Tester" --platform ios --launch-console artifacts/launch-console.log. Expo Go/dev-client shells accept host + URL, for example agent-device open "Expo Go" exp://127.0.0.1:8081 --platform ios. macOS also supports --surface app|frontmost-app|desktop|menubar. --metro-host/--metro-port/--bundle-url/--launch-url set this session\'s Metro/debug runtime hints as part of open itself (applied to the app\'s dev-server prefs and recorded as the session\'s dev-server binding), so a fresh session has them before its first reload instead of needing a throwaway reload-first call just to seed hints; a later plain metro reload in the same session reuses whichever of these were set. A fresh open without these flags clears any leftover binding from a previous same-name session; close also clears it.', - summary: 'Open an app, deep link or URL, save replays', positionalArgs: ['appOrUrl?', 'url?'], allowedFlags: [ 'activity', @@ -173,6 +182,10 @@ const closeDaemonWriter: DaemonWriter = direct(PUBLIC_COMMANDS.close, (input) => export const appsCommandFacet = defineCommandFacet({ name: 'apps', + text: { + summary: 'List installed apps', + cliDetail: 'Defaults to user-installed apps; use --all to include system/OEM apps.', + }, metadata: appsCommandMetadata, definition: appsCommandDefinition, cliSchema: appsCliSchema, @@ -183,6 +196,13 @@ export const appsCommandFacet = defineCommandFacet({ export const openCommandFacet = defineCommandFacet({ name: 'open', + text: { + summary: 'Open an app, deep link or URL, save replays', + cliDetail: + 'Use --platform to bind URL/deep-link opens to the target platform. For iOS simulator initial stdout/stderr, put --launch-console on this open command, for example agent-device open "Agent Device Tester" --platform ios --launch-console artifacts/launch-console.log. Expo Go/dev-client shells accept host + URL, for example agent-device open "Expo Go" exp://127.0.0.1:8081 --platform ios. macOS also supports --surface app|frontmost-app|desktop|menubar. --metro-host/--metro-port/--bundle-url/--launch-url set this session\'s Metro/debug runtime hints as part of open itself (applied to the app\'s dev-server prefs and recorded as the session\'s dev-server binding), so a fresh session has them before its first reload instead of needing a throwaway reload-first call just to seed hints; a later plain metro reload in the same session reuses whichever of these were set. A fresh open without these flags clears any leftover binding from a previous same-name session; close also clears it.', + mcpDetail: + "Metro and debug runtime hints given here are recorded as the session's dev-server binding, so a later reload reuses them; a fresh open without them clears any binding left by a previous same-name session.", + }, metadata: openCommandMetadata, definition: openCommandDefinition, cliSchema: openCliSchema, @@ -193,6 +213,9 @@ export const openCommandFacet = defineCommandFacet({ export const closeCommandFacet = defineCommandFacet({ name: 'close', + text: { + summary: 'Close an app or end the session', + }, metadata: closeCommandMetadata, definition: closeCommandDefinition, cliSchema: closeCliSchema, @@ -201,7 +224,9 @@ export const closeCommandFacet = defineCommandFacet({ cliOutputFormatter: managementCliOutputFormatters.close, }); -function withoutApp(input: AppCloseOptions & { shutdown?: boolean }): { shutdown?: boolean } { +function withoutApp(input: AppCloseOptions & { shutdown?: boolean }): { + shutdown?: boolean; +} { const { app: _app, ...rest } = input; return rest; } diff --git a/src/commands/management/artifacts.ts b/src/commands/management/artifacts.ts index 81dbda25d2..6a7796aa37 100644 --- a/src/commands/management/artifacts.ts +++ b/src/commands/management/artifacts.ts @@ -23,7 +23,6 @@ const artifactsCommandDefinition = defineExecutableCommand( ); const artifactsCliSchema = { - summary: 'List daemon or cloud provider session artifacts', usageOverride: 'artifacts [provider-session-id] [--provider ]', positionalArgs: ['provider-session-id?'], allowedFlags: ['provider', 'providerSessionId'], @@ -39,6 +38,9 @@ const artifactsDaemonWriter: DaemonWriter = direct(PUBLIC_COMMANDS.artifacts); export const artifactsCommandFacet = defineCommandFacet({ name: 'artifacts', + text: { + summary: 'List daemon or cloud provider session artifacts', + }, metadata: artifactsCommandMetadata, definition: artifactsCommandDefinition, cliSchema: artifactsCliSchema, diff --git a/src/commands/management/device.ts b/src/commands/management/device.ts index d41b2a8be0..10d374f51b 100644 --- a/src/commands/management/device.ts +++ b/src/commands/management/device.ts @@ -8,17 +8,21 @@ import { defineCommandFacet } from '../family/types.ts'; import { defineFieldCommandMetadata } from '../field-command-contract.ts'; import { managementCliOutputFormatters } from './output.ts'; -const devicesCommandMetadata = defineFieldCommandMetadata('devices', 'List available devices.', {}); +const devicesCommandMetadata = defineFieldCommandMetadata( + 'devices', + 'List available devices and simulators that can be selected for automation. Use platform, device, udid, or serial inputs on later commands to target one result.', + {}, +); const capabilitiesCommandMetadata = defineFieldCommandMetadata( 'capabilities', - 'List commands supported by the selected device.', + 'List the commands supported by the selected device or active session. Use device-selection inputs when checking support before a session is open.', {}, ); const bootCommandMetadata = defineFieldCommandMetadata( 'boot', - 'Boot or prepare a selected device without using CLI positional arguments.', + 'Boot or prepare the selected device or simulator so later commands can target it. The device is chosen through the device-selection inputs, not by naming it here.', { headless: booleanField('Boot without showing simulator UI when supported.'), }, @@ -49,19 +53,14 @@ const shutdownCommandDefinition = defineExecutableCommand( ); const bootCliSchema = { - summary: 'Boot target device/simulator', allowedFlags: ['headless'], } as const satisfies CommandSchemaOverride; -const capabilitiesCliSchema = { - summary: 'List supported commands for the selected device', - helpDescription: - 'List command names supported by the selected session device or explicit --platform/--device/--udid/--serial target.', -} as const satisfies CommandSchemaOverride; +const devicesCliSchema = {} as const satisfies CommandSchemaOverride; -const shutdownCliSchema = { - summary: 'Shutdown target simulator/emulator', -} as const satisfies CommandSchemaOverride; +const capabilitiesCliSchema = {} as const satisfies CommandSchemaOverride; + +const shutdownCliSchema = {} as const satisfies CommandSchemaOverride; const commonCliReader: CliReader = (_positionals, flags) => commonInputFromFlags(flags); @@ -77,8 +76,12 @@ const shutdownDaemonWriter: DaemonWriter = direct(PUBLIC_COMMANDS.shutdown); const devicesCommandFacet = defineCommandFacet({ name: 'devices', + text: { + summary: 'List available devices and simulators', + }, metadata: devicesCommandMetadata, definition: devicesCommandDefinition, + cliSchema: devicesCliSchema, cliReader: commonCliReader, daemonWriter: devicesDaemonWriter, cliOutputFormatter: managementCliOutputFormatters.devices, @@ -86,6 +89,10 @@ const devicesCommandFacet = defineCommandFacet({ const capabilitiesCommandFacet = defineCommandFacet({ name: 'capabilities', + text: { + summary: 'List supported commands for the selected device', + cliDetail: 'Select an explicit target with --platform/--device/--udid/--serial.', + }, metadata: capabilitiesCommandMetadata, definition: capabilitiesCommandDefinition, cliSchema: capabilitiesCliSchema, @@ -96,6 +103,9 @@ const capabilitiesCommandFacet = defineCommandFacet({ const bootCommandFacet = defineCommandFacet({ name: 'boot', + text: { + summary: 'Boot target device/simulator', + }, metadata: bootCommandMetadata, definition: bootCommandDefinition, cliSchema: bootCliSchema, @@ -106,6 +116,9 @@ const bootCommandFacet = defineCommandFacet({ const shutdownCommandFacet = defineCommandFacet({ name: 'shutdown', + text: { + summary: 'Shutdown target simulator/emulator', + }, metadata: shutdownCommandMetadata, definition: shutdownCommandDefinition, cliSchema: shutdownCliSchema, diff --git a/src/commands/management/doctor.ts b/src/commands/management/doctor.ts index 5c45766ad5..c02a2cdb19 100644 --- a/src/commands/management/doctor.ts +++ b/src/commands/management/doctor.ts @@ -10,7 +10,7 @@ import { managementCliOutputFormatters } from './output.ts'; const doctorCommandMetadata = defineFieldCommandMetadata( 'doctor', - 'Diagnose device, app, Metro, and React Native setup/readiness issues.', + 'Diagnose device, app, development-server, and React Native or Expo readiness issues. Returns compact evidence for local inventory, sessions, optional app discovery, toolchains, and server reachability.', { targetApp: commandInput.stringField( 'Installed app package/bundle id or app name to verify without opening a session.', @@ -28,9 +28,6 @@ const doctorCommandDefinition = defineExecutableCommand(doctorCommandMetadata, ( const doctorCliSchema = { usageOverride: 'doctor [--platform ios|android|vega|macos|linux|web|apple] [--app ] [--remote]', - helpDescription: - 'Setup and recovery diagnostic for device, app, dev-server, and RN/Expo readiness issues. Reports local device inventory, active sessions, optional app discovery, scoped toolchain info, and Metro reachability inferred from cwd/runtime. On iOS simulators it also warms the XCTest runner build cache in the background when missing. Pass --app to verify a target app on the one matching booted device without opening a session. Use --remote to check remote connection setup without probing local devices. Default output is compact; use --json for full checks and evidence.', - summary: 'Diagnose device, app, dev-server, and RN/Expo readiness', allowedFlags: ['targetApp', 'remote'], } as const satisfies CommandSchemaOverride; @@ -44,6 +41,13 @@ const doctorDaemonWriter: DaemonWriter = direct(PUBLIC_COMMANDS.doctor); export const doctorCommandFacet = defineCommandFacet({ name: 'doctor', + text: { + summary: 'Diagnose device, app, dev-server, and RN/Expo readiness', + cliDetail: + 'Metro reachability is inferred from cwd/runtime. On iOS simulators it also warms the XCTest runner build cache in the background when missing. Pass --app to verify a target app on the one matching booted device without opening a session. Use --remote to check remote connection setup without probing local devices. Default output is compact; use --json for full checks and evidence.', + mcpDetail: + 'On iOS simulators it also warms the XCTest runner build cache in the background when missing, so run it before the first Apple snapshot or interaction of a session.', + }, metadata: doctorCommandMetadata, definition: doctorCommandDefinition, cliSchema: doctorCliSchema, diff --git a/src/commands/management/install.ts b/src/commands/management/install.ts index 5f918d84c0..4f732bcffc 100644 --- a/src/commands/management/install.ts +++ b/src/commands/management/install.ts @@ -25,14 +25,18 @@ import { defineCommandFacet } from '../family/types.ts'; import { defineFieldCommandMetadata } from '../field-command-contract.ts'; import { managementCliOutputFormatters } from './output.ts'; -const installCommandMetadata = defineFieldCommandMetadata('install', 'Install an app binary.', { - app: stringField('Optional app identifier hint.'), - appPath: requiredField(stringField('Path to app binary.')), -}); +const installCommandMetadata = defineFieldCommandMetadata( + 'install', + 'Install an app binary from a local path. Provide an app identifier with the path when the target needs explicit app selection; use reinstall to replace an already installed app.', + { + app: stringField('Optional app identifier hint.'), + appPath: requiredField(stringField('Path to app binary.')), + }, +); const reinstallCommandMetadata = defineFieldCommandMetadata( 'reinstall', - 'Reinstall an app binary.', + 'Replace an installed app with a binary from a local path. Use this when preserving the same app identity while installing a new build on the selected device.', { app: requiredField(stringField()), appPath: requiredField(stringField('Path to app binary.')), @@ -41,7 +45,7 @@ const reinstallCommandMetadata = defineFieldCommandMetadata( const installFromSourceCommandMetadata = defineFieldCommandMetadata( 'install-from-source', - 'Install an app from a structured source.', + 'Install app builds from URLs, remote source specs, or CI artifacts resolved by a remote daemon.', { source: requiredField( jsonSchemaField(looseObjectSchema('Install source object.')), @@ -79,9 +83,6 @@ const installFromSourceCliSchema = { usageOverride: 'install-from-source | install-from-source --github-actions-artifact ', listUsageOverride: 'install-from-source', - helpDescription: - 'Install app builds from URLs, remote source specs, or CI artifacts resolved by a remote daemon.', - summary: 'Install app builds from URLs, remote source specs, or CI artifacts', positionalArgs: ['url?'], allowedFlags: ['header', 'githubActionsArtifact', 'installSource', 'retainPaths', 'retentionMs'], } as const satisfies CommandSchemaOverride; @@ -116,6 +117,9 @@ const installFromSourceDaemonWriter: DaemonWriter = (input) => const installCommandFacet = defineCommandFacet({ name: 'install', + text: { + summary: 'Install an app binary from a path', + }, metadata: installCommandMetadata, definition: installCommandDefinition, cliSchema: installCliSchema, @@ -126,6 +130,9 @@ const installCommandFacet = defineCommandFacet({ const reinstallCommandFacet = defineCommandFacet({ name: 'reinstall', + text: { + summary: 'Replace an installed app with a new build', + }, metadata: reinstallCommandMetadata, definition: reinstallCommandDefinition, cliSchema: reinstallCliSchema, @@ -136,6 +143,9 @@ const reinstallCommandFacet = defineCommandFacet({ const installFromSourceCommandFacet = defineCommandFacet({ name: 'install-from-source', + text: { + summary: 'Install app builds from URLs or CI artifacts', + }, metadata: installFromSourceCommandMetadata, definition: installFromSourceCommandDefinition, cliSchema: installFromSourceCliSchema, diff --git a/src/commands/management/prepare.ts b/src/commands/management/prepare.ts index 750aadcc5c..8146005796 100644 --- a/src/commands/management/prepare.ts +++ b/src/commands/management/prepare.ts @@ -17,7 +17,7 @@ const PREPARE_ACTION_VALUES = ['ios-runner'] as const; const prepareCommandMetadata = defineFieldCommandMetadata( 'prepare', - 'Prepare platform helper infrastructure.', + 'Prepare platform helper infrastructure. ios-runner builds/reuses, starts, and health-checks the XCTest runner so later Apple snapshots and interactions do not pay first-use startup cost. In JSON output, top-level buildMs/connectMs/healthCheckMs are diagnostic fields and may overlap; use timing.additiveParts for additive wall-clock phase totals. In CI, run it after boot/install and before replay/test; if replay/test starts a separate daemon, stop the prepare daemon before replay/test so it does not keep the prepared runner lease. It is not a recovery step for "runner already owned by another agent-device daemon"; stop the owning daemon on the Mac with simulator access instead. Runner build/start output is written to the session runner.log; daemon.log is for daemon lifecycle/startup issues.', { action: requiredField(enumField(PREPARE_ACTION_VALUES)), timeoutMs: integerField('Maximum wall-clock time for the prepare command.'), @@ -31,10 +31,6 @@ const prepareCommandDefinition = defineExecutableCommand(prepareCommandMetadata, const prepareCliSchema = { usageOverride: 'prepare ios-runner --platform ios|macos [--timeout ]', listUsageOverride: 'prepare', - helpDescription: - 'Prepare platform helper infrastructure. ios-runner builds/reuses, starts, and health-checks the XCTest runner so later Apple snapshots and interactions do not pay first-use startup cost. In JSON output, top-level buildMs/connectMs/healthCheckMs are diagnostic fields and may overlap; use timing.additiveParts for additive wall-clock phase totals. In CI, run it after boot/install and before replay/test; if replay/test starts a separate daemon, stop the prepare daemon before replay/test so it does not keep the prepared runner lease. It is not a recovery step for "runner already owned by another agent-device daemon"; stop the owning daemon on the Mac with simulator access instead. Runner build/start output is written to the session runner.log; daemon.log is for daemon lifecycle/startup issues.', - summary: - 'Pre-warm platform helpers, especially the iOS/macOS XCTest runner before Apple automation', positionalArgs: ['ios-runner'], allowedFlags: ['timeoutMs'], } as const satisfies CommandSchemaOverride; @@ -51,6 +47,9 @@ const prepareDaemonWriter: DaemonWriter = direct(PUBLIC_COMMANDS.prepare, (input export const prepareCommandFacet = defineCommandFacet({ name: 'prepare', + text: { + summary: 'Pre-warm platform helpers before automation', + }, metadata: prepareCommandMetadata, definition: prepareCommandDefinition, cliSchema: prepareCliSchema, diff --git a/src/commands/management/push.ts b/src/commands/management/push.ts index e684efd7f9..dfaa41d273 100644 --- a/src/commands/management/push.ts +++ b/src/commands/management/push.ts @@ -25,21 +25,29 @@ import { import { defineCommandFacet } from '../family/types.ts'; import { defineFieldCommandMetadata } from '../field-command-contract.ts'; -const pushCommandMetadata = defineFieldCommandMetadata('push', 'Deliver a push payload.', { - app: requiredField(stringField()), - payload: requiredField( - jsonSchemaField({ - oneOf: [stringSchema(), looseObjectSchema()], - }), - ), -}); +const pushCommandMetadata = defineFieldCommandMetadata( + 'push', + 'Deliver push notification payloads to an installed app.', + { + app: requiredField(stringField()), + payload: requiredField( + jsonSchemaField({ + oneOf: [stringSchema(), looseObjectSchema()], + }), + ), + }, +); const triggerAppEventCommandMetadata = defineFieldCommandMetadata( 'trigger-app-event', - 'Trigger an app-defined event.', + 'Ask the app to handle an app-defined automation or test event, with an optional structured payload. Call this only for event names and payload shapes the app documents.', { - event: requiredField(stringField()), - payload: jsonObjectField(), + event: requiredField( + stringField('Name of an app-defined automation or test event the app documents.'), + ), + payload: jsonObjectField( + 'Structured payload passed to the event, in the shape the app documents for it.', + ), }, ); @@ -54,17 +62,12 @@ const triggerAppEventCommandDefinition = defineExecutableCommand( const pushCliSchema = { listUsageOverride: 'push', - helpDescription: 'Deliver push notification payloads to an installed app.', - summary: 'Deliver push notification payloads to an installed app', positionalArgs: ['bundleOrPackage', 'payloadOrJson'], } as const satisfies CommandSchemaOverride; const triggerAppEventCliSchema = { usageOverride: 'trigger-app-event [payloadJson]', listUsageOverride: 'trigger-app-event', - helpDescription: - 'Invoke app-defined automation or test events with an optional structured payload.', - summary: 'Invoke app-defined automation/test events with optional structured payloads', positionalArgs: ['event', 'payloadJson?'], } as const satisfies CommandSchemaOverride; @@ -90,6 +93,9 @@ const triggerAppEventDaemonWriter: DaemonWriter = direct(PUBLIC_COMMANDS.trigger const pushCommandFacet = defineCommandFacet({ name: 'push', + text: { + summary: 'Deliver a push notification payload', + }, metadata: pushCommandMetadata, definition: pushCommandDefinition, cliSchema: pushCliSchema, @@ -99,6 +105,9 @@ const pushCommandFacet = defineCommandFacet({ const triggerAppEventCommandFacet = defineCommandFacet({ name: 'trigger-app-event', + text: { + summary: 'Invoke an app-defined automation event', + }, metadata: triggerAppEventCommandMetadata, definition: triggerAppEventCommandDefinition, cliSchema: triggerAppEventCliSchema, @@ -119,6 +128,6 @@ function triggerEventPositionals(input: AppTriggerEventOptions): string[] { return [input.event, ...(input.payload ? [JSON.stringify(input.payload)] : [])]; } -function jsonObjectField(): CommandField { - return looseObjectField() as CommandField; +function jsonObjectField(description?: string): CommandField { + return looseObjectField(description) as CommandField; } diff --git a/src/commands/management/session.ts b/src/commands/management/session.ts index 9f1926e9db..49d885cc9d 100644 --- a/src/commands/management/session.ts +++ b/src/commands/management/session.ts @@ -10,7 +10,7 @@ import { managementCliOutputFormatters } from './output.ts'; const sessionCommandMetadata = defineFieldCommandMetadata( 'session', - 'List active sessions or print daemon state directory.', + 'List active sessions, print the effective daemon state directory, or publish an armed open-to-destination script without closing its session', { action: enumField( ['list', 'state-dir', 'save-script'], @@ -39,8 +39,6 @@ const sessionCommandDefinition = defineExecutableCommand( const sessionCliSchema = { usageOverride: 'session list | session state-dir | session save-script [path] [--force]', listUsageOverride: 'session', - helpDescription: - 'List active sessions, print the effective daemon state directory, or publish an armed open-to-destination script without closing its session', positionalArgs: ['list|state-dir|save-script?', 'path?'], allowedFlags: ['force'], } as const satisfies CommandSchemaOverride; @@ -54,6 +52,9 @@ const sessionCliReader: CliReader = (positionals, flags) => ({ export const sessionCommandFacet = defineCommandFacet({ name: 'session', + text: { + summary: 'List sessions, show the state dir, or publish a script', + }, metadata: sessionCommandMetadata, definition: sessionCommandDefinition, cliSchema: sessionCliSchema, diff --git a/src/commands/management/viewport.ts b/src/commands/management/viewport.ts index 1ee2f8fb85..08e3efd5d5 100644 --- a/src/commands/management/viewport.ts +++ b/src/commands/management/viewport.ts @@ -12,7 +12,7 @@ import { managementCliOutputFormatters } from './output.ts'; const viewportCommandMetadata = defineFieldCommandMetadata( 'viewport', - 'Resize the active web viewport.', + 'Resize the active web viewport before taking snapshots or screenshots. Useful for fixed-layout or 100vh apps where changing the viewport reveals different content.', { width: requiredField(integerField('Viewport width in CSS pixels.', { min: 1 })), height: requiredField(integerField('Viewport height in CSS pixels.', { min: 1 })), @@ -25,9 +25,6 @@ const viewportCommandDefinition = defineExecutableCommand( ); const viewportCliSchema = { - helpDescription: - 'Resize the active web viewport before taking snapshots or screenshots. Useful for fixed-layout or 100vh apps where changing the viewport reveals different content.', - summary: 'Resize the active web viewport for the current session', positionalArgs: ['width', 'height'], } as const satisfies CommandSchemaOverride; @@ -44,6 +41,9 @@ const viewportDaemonWriter: DaemonWriter = direct(PUBLIC_COMMANDS.viewport, (inp export const viewportCommandFacet = defineCommandFacet({ name: 'viewport', + text: { + summary: 'Resize the active web viewport for the current session', + }, metadata: viewportCommandMetadata, definition: viewportCommandDefinition, cliSchema: viewportCliSchema, diff --git a/src/commands/metro/index.ts b/src/commands/metro/index.ts index f292437969..b151520324 100644 --- a/src/commands/metro/index.ts +++ b/src/commands/metro/index.ts @@ -26,7 +26,8 @@ import { readMetroPrepareKind } from './prepare-kind.ts'; const METRO_COMMAND_NAME = 'metro'; const METRO_ACTION_VALUES = ['prepare', 'reload'] as const; -const metroCommandDescription = 'Prepare React Native dev-server runtime or reload apps.'; +const metroCommandDescription = + 'Prepare a React Native development server or ask connected apps to reload, using the development server this session is bound to. Provide explicit runtime inputs only to override that binding for one call.'; export const metroCommandMetadata = defineFieldCommandMetadata( METRO_COMMAND_NAME, @@ -73,26 +74,6 @@ const metroCliSchema = { usageOverride: 'metro prepare (--public-base-url | --proxy-base-url ) [--project-root ] [--port ] [--kind auto|react-native|expo|repack]\n agent-device metro reload [--metro-host ] [--metro-port ] [--bundle-url ]', listUsageOverride: 'metro', - helpDescription: - 'Prepare a local React Native dev-server runtime or ask connected apps to reload. ' + - 'reload with no --metro-host/--metro-port/--bundle-url resolves against the dev server ' + - "this session last bound via metro prepare or open's metro hint flags (falling back to " + - 'localhost:8081 only when the session never bound one), so it never silently reloads a ' + - "different project's server on the default port; pass an explicit flag to override the " + - 'session hint for one call. The reload URL keeps the bound bundle URL mount prefix instead ' + - 'of collapsing to the host root, and when the server has no HTTP /reload route (Expo) the ' + - 'reload is broadcast over its /message websocket instead of trusting the app-page fallback. ' + - 'The binding is cleared when the session closes, and a fresh ' + - 'open without hint flags also clears any leftover binding from a previous same-name session. ' + - '--kind expo (detected or forced) requests the virtual-entry bundle URL ' + - '(.expo/.virtual-metro-entry.bundle) instead of index.bundle, since index.bundle 404s/500s ' + - 'against Expo dev servers in monorepos. Dependency install auto-detects the package manager ' + - 'from the nearest yarn.lock/pnpm-lock.yaml/bun.lock/bun.lockb/package-lock.json walking up ' + - 'from --project-root (bounded at the repo root), so Yarn/pnpm workspace monorepos with the ' + - 'lockfile at the repo root do not wrongly fall back to npm install (which fails on ' + - 'workspace: dependency specifiers); if install still fails, pass --no-install-deps when ' + - 'dependencies are already installed (for example via a monorepo root install).', - summary: 'Prepare Metro/Re.Pack reachability for React Native/Expo apps or trigger app reloads', positionalArgs: ['prepare|reload'], allowedFlags: [...METRO_RELOAD_FLAGS, ...METRO_PREPARE_FLAGS], } as const satisfies CommandSchemaOverride; @@ -145,6 +126,13 @@ export const metroCliReader: CliReader = (positionals, flags) => { const metroCommandFacet = defineCommandFacet({ name: METRO_COMMAND_NAME, + text: { + summary: 'Prepare the dev server or reload apps', + cliDetail: + "reload with no --metro-host/--metro-port/--bundle-url resolves against the dev server this session last bound via metro prepare or open's metro hint flags (falling back to localhost:8081 only when the session never bound one), so it never silently reloads a different project's server on the default port; pass an explicit flag to override the session hint for one call. The reload URL keeps the bound bundle URL mount prefix instead of collapsing to the host root, and when the server has no HTTP /reload route (Expo) the reload is broadcast over its /message websocket instead of trusting the app-page fallback. The binding is cleared when the session closes, and a fresh open without hint flags also clears any leftover binding from a previous same-name session. --kind expo (detected or forced) requests the virtual-entry bundle URL (.expo/.virtual-metro-entry.bundle) instead of index.bundle, since index.bundle 404s/500s against Expo dev servers in monorepos. Dependency install auto-detects the package manager from the nearest yarn.lock/pnpm-lock.yaml/bun.lock/bun.lockb/package-lock.json walking up from --project-root (bounded at the repo root), so Yarn/pnpm workspace monorepos with the lockfile at the repo root do not wrongly fall back to npm install (which fails on workspace: dependency specifiers); if install still fails, pass --no-install-deps when dependencies are already installed (for example via a monorepo root install).", + mcpDetail: + 'The binding is cleared when the session closes, and a fresh open without runtime hints also clears any leftover binding from a previous same-name session.', + }, metadata: metroCommandMetadata, definition: metroCommandDefinition, cliSchema: metroCliSchema, diff --git a/src/commands/observability/index.ts b/src/commands/observability/index.ts index 28610b9846..7fea894c38 100644 --- a/src/commands/observability/index.ts +++ b/src/commands/observability/index.ts @@ -32,10 +32,13 @@ const NETWORK_ACTION_VALUES = ['dump', 'log'] as const; const AUDIO_ACTION_VALUES = ['probe'] as const; const AUDIO_PROBE_ACTION_VALUES = ['start', 'status', 'stop'] as const; -const logsCommandDescription = 'Manage session app logs.'; -const eventsCommandDescription = 'Read the session event timeline.'; -const networkCommandDescription = 'Show recent HTTP traffic.'; -const audioCommandDescription = 'Probe audio levels.'; +const logsCommandDescription = + 'Session app log info, start/stop streaming, diagnostics, and markers'; +const eventsCommandDescription = + 'Read the daemon-owned session event timeline as paged JSON-friendly entries'; +const networkCommandDescription = 'Dump recent HTTP(s) traffic parsed from the session app log'; +const audioCommandDescription = + 'Measure browser or host-rendered simulator/emulator audio as compact dBFS buckets. Start a probe before requesting its status or stopping it.'; export const logsCommandMetadata = defineFieldCommandMetadata( LOGS_COMMAND_NAME, @@ -99,8 +102,6 @@ export const audioCommandDefinition = defineExecutableCommand( const logsCliSchema = { usageOverride: 'logs path | logs start | logs stop | logs clear [--restart] | logs doctor | logs mark [message...]', - helpDescription: 'Session app log info, start/stop streaming, diagnostics, and markers', - summary: 'Manage session app logs', positionalArgs: ['path|start|stop|clear|doctor|mark', 'message?'], allowsExtraPositionals: true, allowedFlags: ['restart'], @@ -109,8 +110,6 @@ const logsCliSchema = { const eventsCliSchema = { usageOverride: 'events [limit] [cursor]', listUsageOverride: 'events', - helpDescription: 'Read the daemon-owned session event timeline as paged JSON-friendly entries', - summary: 'Read session event timeline', positionalArgs: ['limit?', 'cursor?'], } as const satisfies CommandSchemaOverride; @@ -118,9 +117,6 @@ const networkCliSchema = { usageOverride: 'network dump [limit] [summary|headers|body|all] [--include summary|headers|body|all] | network log [limit] [summary|headers|body|all] [--include summary|headers|body|all]', listUsageOverride: 'network', - helpDescription: 'Dump recent HTTP(s) traffic parsed from the session app log', - summary: - 'Inspect HTTP(S) traffic parsed from session app logs, including summaries, headers, and bodies', positionalArgs: ['dump|log', 'limit?', 'include?'], allowedFlags: ['networkInclude'], } as const satisfies CommandSchemaOverride; @@ -129,9 +125,6 @@ const audioCliSchema = { usageOverride: 'audio probe start [durationSeconds] [bucketMs] | audio probe status | audio probe stop', listUsageOverride: 'audio', - helpDescription: - 'Probe browser or host-rendered simulator/emulator audio as compact dBFS buckets', - summary: 'Probe audio levels', positionalArgs: ['probe', 'start|status|stop', 'durationSeconds?', 'bucketMs?'], } as const satisfies CommandSchemaOverride; @@ -181,6 +174,9 @@ export const audioDaemonWriter: DaemonWriter = (input) => const logsCommandFacet = defineCommandFacet({ name: LOGS_COMMAND_NAME, + text: { + summary: 'Manage session app logs', + }, metadata: logsCommandMetadata, definition: logsCommandDefinition, cliSchema: logsCliSchema, @@ -191,6 +187,9 @@ const logsCommandFacet = defineCommandFacet({ const eventsCommandFacet = defineCommandFacet({ name: EVENTS_COMMAND_NAME, + text: { + summary: 'Read session event timeline', + }, metadata: eventsCommandMetadata, definition: eventsCommandDefinition, cliSchema: eventsCliSchema, @@ -201,6 +200,9 @@ const eventsCommandFacet = defineCommandFacet({ const networkCommandFacet = defineCommandFacet({ name: NETWORK_COMMAND_NAME, + text: { + summary: 'Inspect HTTP(S) traffic from session logs', + }, metadata: networkCommandMetadata, definition: networkCommandDefinition, cliSchema: networkCliSchema, @@ -211,6 +213,9 @@ const networkCommandFacet = defineCommandFacet({ const audioCommandFacet = defineCommandFacet({ name: AUDIO_COMMAND_NAME, + text: { + summary: 'Probe audio levels', + }, metadata: audioCommandMetadata, definition: audioCommandDefinition, cliSchema: audioCliSchema, diff --git a/src/commands/perf/index.ts b/src/commands/perf/index.ts index 761fff50d8..e26db3acc8 100644 --- a/src/commands/perf/index.ts +++ b/src/commands/perf/index.ts @@ -29,7 +29,8 @@ import { perfCliOutputFormatters } from './output.ts'; const PERF_COMMAND_NAME = 'perf'; -const perfCommandDescription = 'Show session performance, frame health, and memory diagnostics.'; +const perfCommandDescription = + 'Collect session performance metrics, frame health, memory diagnostics, and platform profiling artifacts. Prefer structured metrics for a first-pass diagnosis; raw profiles and traces remain session artifacts.'; export const perfCommandMetadata = defineFieldCommandMetadata( PERF_COMMAND_NAME, @@ -53,9 +54,6 @@ const perfCliSchema = { usageOverride: 'perf metrics --json\n agent-device perf frames --json\n agent-device perf memory sample --json\n agent-device perf memory snapshot [--kind android-hprof|memgraph] [--out ]\n agent-device perf cpu profile start --kind xctrace [--template ] --out \n agent-device perf cpu profile stop --kind xctrace --out \n agent-device perf cpu profile report --kind xctrace --out \n agent-device perf trace start|stop --kind xctrace [--template ] --out \n agent-device perf cpu profile start --kind simpleperf --out \n agent-device perf cpu profile stop --kind simpleperf\n agent-device perf cpu profile report --kind simpleperf --out \n agent-device perf trace start|stop --kind perfetto [--out ]', listUsageOverride: 'perf', - helpDescription: - 'Show session performance metrics, focused frame/jank health, memory diagnostics artifacts, Apple xctrace artifacts, or Android native Simpleperf/Perfetto artifacts. Prefer explicit perf metrics --json for first-pass startup/CPU/memory data. For CPU profiles, start/stop write the raw artifact and report writes a compact .json summary; include report after simpleperf stop when the task needs agent-readable native CPU evidence. Bare perf and metrics remain aliases. Native perf output is agent evidence: compact state, artifact path, and size only; raw profiles/traces stay on disk.', - summary: 'Check runtime metrics, frames, memory, CPU profiles, or native trace artifacts', positionalArgs: ['area?', 'subjectOrAction?', 'action?'], allowedFlags: ['kind', 'perfTemplate', 'out'], } as const satisfies CommandSchemaOverride; @@ -75,6 +73,13 @@ export const perfDaemonWriter: DaemonWriter = direct(PERF_COMMAND_NAME, (input) const perfCommandFacet = defineCommandFacet({ name: PERF_COMMAND_NAME, + text: { + summary: 'Check metrics, frames, memory, or profiles', + cliDetail: + 'Covers Apple xctrace and Android native Simpleperf/Perfetto artifacts. Prefer explicit perf metrics --json for first-pass startup/CPU/memory data. For CPU profiles, start/stop write the raw artifact and report writes a compact .json summary; include report after simpleperf stop when the task needs agent-readable native CPU evidence. Bare perf and metrics remain aliases. Native perf output is agent evidence: compact state, artifact path, and size only; raw profiles/traces stay on disk.', + mcpDetail: + 'For CPU profiles, start and stop write the raw artifact while report writes a compact summary; request the report when the task needs readable native CPU evidence. Profiling output is evidence only: compact state, artifact path, and size.', + }, metadata: perfCommandMetadata, definition: perfCommandDefinition, cliSchema: perfCliSchema, diff --git a/src/commands/react-native/index.ts b/src/commands/react-native/index.ts index a0c38efac2..a2f8dacf0c 100644 --- a/src/commands/react-native/index.ts +++ b/src/commands/react-native/index.ts @@ -10,7 +10,8 @@ import type { CliReader, DaemonWriter } from '../cli-grammar/types.ts'; const REACT_NATIVE_COMMAND_NAME = 'react-native'; const REACT_NATIVE_ACTION_VALUES = ['dismiss-overlay'] as const; -const reactNativeCommandDescription = 'Run supported React Native app automation helpers.'; +const reactNativeCommandDescription = + 'Run supported React Native automation helpers. Use dismiss-overlay to close a visible development error overlay before continuing normal UI automation.'; export const reactNativeCommandMetadata = defineFieldCommandMetadata( REACT_NATIVE_COMMAND_NAME, @@ -42,6 +43,9 @@ export const reactNativeDaemonWriter: DaemonWriter = direct(REACT_NATIVE_COMMAND const reactNativeCommandFacet = defineCommandFacet({ name: REACT_NATIVE_COMMAND_NAME, + text: { + summary: 'Run React Native automation helpers', + }, metadata: reactNativeCommandMetadata, definition: reactNativeCommandDefinition, cliSchema: reactNativeCliSchema, diff --git a/src/commands/recording/index.ts b/src/commands/recording/index.ts index 7c63adb878..ee536ede5a 100644 --- a/src/commands/recording/index.ts +++ b/src/commands/recording/index.ts @@ -23,8 +23,10 @@ const RECORD_COMMAND_NAME = 'record'; const TRACE_COMMAND_NAME = 'trace'; const RECORDING_ACTION_VALUES = ['start', 'stop'] as const; -const recordCommandDescription = 'Start or stop screen recording.'; -const traceCommandDescription = 'Start or stop trace capture.'; +const recordCommandDescription = + 'Start or stop a screen recording for the active app session or, where supported, the selected device. Long Android recordings can return multiple video artifacts.'; +const traceCommandDescription = + 'Start or stop trace-log capture and return the resulting artifact when capture ends. Use the same artifact path for the matching start and stop requests when an explicit path is required.'; export const recordCommandMetadata = defineFieldCommandMetadata( RECORD_COMMAND_NAME, @@ -63,9 +65,6 @@ const recordCliSchema = { usageOverride: 'record start [path] [--scope ] [--fps ] [--max-size ] [--quality ] [--hide-touches] | record stop', listUsageOverride: 'record start [path] | record stop', - helpDescription: - 'Start/stop screen recording. The default --scope app requires an active app session from open ; use --scope device/system to explicitly request whole-screen recording where the selected backend supports it. Android record start publishes a durable device manifest, recordings longer than the 180s adb screenrecord limit are returned as multiple MP4 chunks while the daemon stays alive, and daemon-restart recovery uses only manifest-owned chunks. Use --max-size to limit dimensions and --quality to choose medium or high export quality', - summary: 'Start or stop screen recording', positionalArgs: ['start|stop', 'path?'], allowedFlags: ['recordingScope', 'fps', 'screenshotMaxSize', 'quality', 'hideTouches'], } as const satisfies CommandSchemaOverride; @@ -73,9 +72,6 @@ const recordCliSchema = { const traceCliSchema = { usageOverride: 'trace start | trace stop ', listUsageOverride: 'trace start | trace stop ', - helpDescription: - 'Start/stop trace log capture; when an artifact path is requested, pass the same positional path to start and stop', - summary: 'Start or stop trace capture', positionalArgs: ['start|stop', 'path?'], } as const satisfies CommandSchemaOverride; @@ -106,6 +102,11 @@ export const traceDaemonWriter: DaemonWriter = direct(TRACE_COMMAND_NAME, (input const recordCommandFacet = defineCommandFacet({ name: RECORD_COMMAND_NAME, + text: { + summary: 'Start or stop screen recording', + cliDetail: + 'The default --scope app requires an active app session from open ; use --scope device/system to explicitly request whole-screen recording where the selected backend supports it. Android record start publishes a durable device manifest, recordings longer than the 180s adb screenrecord limit are returned as multiple MP4 chunks while the daemon stays alive, and daemon-restart recovery uses only manifest-owned chunks. Use --max-size to limit dimensions and --quality to choose medium or high export quality.', + }, metadata: recordCommandMetadata, definition: recordCommandDefinition, cliSchema: recordCliSchema, @@ -116,6 +117,10 @@ const recordCommandFacet = defineCommandFacet({ const traceCommandFacet = defineCommandFacet({ name: TRACE_COMMAND_NAME, + text: { + summary: 'Start or stop trace capture', + cliDetail: 'Pass that path as the same positional argument to start and stop.', + }, metadata: traceCommandMetadata, definition: traceCommandDefinition, cliSchema: traceCliSchema, diff --git a/src/commands/replay/index.ts b/src/commands/replay/index.ts index eb0c1743b6..67b3701f0a 100644 --- a/src/commands/replay/index.ts +++ b/src/commands/replay/index.ts @@ -27,8 +27,9 @@ const TEST_COMMAND_NAME = 'test'; const REPLAY_SHELL_ENV_PREFIX = 'AD_VAR_'; -const replayCommandDescription = 'Replay a recorded session.'; -const testCommandDescription = 'Run one or more replay scripts.'; +const replayCommandDescription = + 'Run a recorded automation script, including compatible Maestro YAML flows. A script without a terminal close leaves its session active for subsequent automation.'; +const testCommandDescription = 'Run one or more replay scripts as a serial test suite'; export const replayCommandMetadata = defineFieldCommandMetadata( REPLAY_COMMAND_NAME, @@ -55,7 +56,9 @@ export const replayCommandMetadata = defineFieldCommandMetadata( // ADR 0012 decision 6, R1/R6: arms agent-supervised re-record repair // from the first replay attempt; optional string value is the healed // script's output path. - saveScript: jsonSchemaField({ oneOf: [booleanSchema(), stringSchema()] }), + saveScript: jsonSchemaField({ + oneOf: [booleanSchema(), stringSchema()], + }), // #1258: overwrite an existing --save-script target (arm-time preflight + // publish) instead of refusing. Alias: --overwrite. force: booleanField(), @@ -95,9 +98,6 @@ export const testCommandDefinition = defineExecutableCommand(testCommandMetadata const replayCliSchema = { usageOverride: 'replay | replay export [--out ]', - helpDescription: - 'Replay a recorded session. For Maestro YAML compatibility flows, use replay --maestro and keep the target binding such as --platform ios on the replay command. A script with no terminal close leaves its session (and daemon) running until you close it or it idle-reaps — no different from a session opened interactively. For native .ad scripts, --keep-session suppresses exactly an authored terminal close so you can continue interactively.', - summary: replayCommandDescription, positionalArgs: ['path'], allowsExtraPositionals: true, allowedFlags: [ @@ -124,8 +124,6 @@ const replayCliSchema = { const testCliSchema = { usageOverride: 'test ...', listUsageOverride: 'test ...', - helpDescription: 'Run one or more replay scripts as a serial test suite', - summary: 'Run replay test suites', positionalArgs: ['pathOrGlob'], allowsExtraPositionals: true, allowedFlags: [ @@ -202,6 +200,11 @@ export const testDaemonWriter: DaemonWriter = (input) => const replayCommandFacet = defineCommandFacet({ name: REPLAY_COMMAND_NAME, + text: { + summary: 'Replay a recorded session or Maestro flow', + cliDetail: + 'For Maestro YAML compatibility flows, use replay --maestro and keep the target binding such as --platform ios on the replay command. A script with no terminal close leaves its session (and daemon) running until you close it or it idle-reaps — no different from a session opened interactively. For native .ad scripts, --keep-session suppresses exactly an authored terminal close so you can continue interactively.', + }, metadata: replayCommandMetadata, definition: replayCommandDefinition, cliSchema: replayCliSchema, @@ -211,6 +214,9 @@ const replayCommandFacet = defineCommandFacet({ const testCommandFacet = defineCommandFacet({ name: TEST_COMMAND_NAME, + text: { + summary: 'Run replay test suites', + }, metadata: testCommandMetadata, definition: testCommandDefinition, cliSchema: testCliSchema, diff --git a/src/commands/system/index.ts b/src/commands/system/index.ts index bdef4ad86b..29bfa38f62 100644 --- a/src/commands/system/index.ts +++ b/src/commands/system/index.ts @@ -48,14 +48,20 @@ const TV_REMOTE_LONGPRESS_PRESET_MS = 500; const CLIPBOARD_ACTION_VALUES = ['read', 'write'] as const; const KEYBOARD_METADATA_ACTION_VALUES = ['status', 'dismiss'] as const; -const appStateCommandDescription = 'Show foreground app or activity.'; -const backCommandDescription = 'Navigate back.'; -const homeCommandDescription = 'Go to the home screen.'; -const orientationCommandDescription = 'Set device orientation.'; -const appSwitcherCommandDescription = 'Open the app switcher.'; -const keyboardCommandDescription = 'Inspect or dismiss the keyboard.'; -const clipboardCommandDescription = 'Read or write clipboard text.'; -const tvRemoteCommandDescription = 'Press a TV remote/D-pad button.'; +const appStateCommandDescription = 'Show foreground app/activity'; +const backCommandDescription = + 'Navigate back in the app or through system navigation. Use in-app for the app navigation stack and system when the platform back behavior is required.'; +const homeCommandDescription = + 'Send the selected device to its home screen. This leaves the app session open but moves the foreground away from the app.'; +const orientationCommandDescription = 'Set device orientation on iOS and Android'; +const appSwitcherCommandDescription = + 'Open the device app switcher to inspect or change foreground apps. This changes the visible system UI and may move focus away from the current app.'; +const keyboardCommandDescription = + 'Inspect Android keyboard visibility/type or press/dismiss the device keyboard. To hide the keyboard, use keyboard dismiss. It taps the keyboard dismiss/hide key when one is exposed, verifies the keyboard closed, and reports UNSUPPORTED_OPERATION when no dismiss key exists \u2014 background taps are never attempted.'; +const clipboardCommandDescription = + 'Read the current device clipboard text, or replace its contents with the given text.'; +const tvRemoteCommandDescription = + 'Press or long-press a TV remote or D-pad button on Android TV, tvOS, or Vega OS. Choose the button and optional hold duration through the input fields. The aliases ok, center, and enter all map to select.'; const appStateCommandMetadata = defineFieldCommandMetadata( APPSTATE_COMMAND_NAME, @@ -163,33 +169,30 @@ const tvRemoteCommandDefinition = defineExecutableCommand( NAVIGATION_COMMAND_PROJECTIONS['tv-remote'], ); -const appStateCliSchema = { - helpDescription: 'Show foreground app/activity', -} as const satisfies CommandSchemaOverride; +const appStateCliSchema = {} as const satisfies CommandSchemaOverride; const backCliSchema = { usageOverride: 'back [--in-app|--system]', allowedFlags: ['backMode'], } as const satisfies CommandSchemaOverride; +const homeCliSchema = {} as const satisfies CommandSchemaOverride; + +const appSwitcherCliSchema = {} as const satisfies CommandSchemaOverride; + const orientationCliSchema = { usageOverride: 'orientation ', - helpDescription: 'Set device orientation on iOS and Android', positionalArgs: ['orientation'], } as const satisfies CommandSchemaOverride; const keyboardCliSchema = { usageOverride: 'keyboard [status|get|dismiss|enter|return]', - helpDescription: - 'Inspect Android keyboard visibility/type or press/dismiss the device keyboard. To hide the keyboard, use keyboard dismiss. It taps the keyboard dismiss/hide key when one is exposed, verifies the keyboard closed, and reports UNSUPPORTED_OPERATION when no dismiss key exists — background taps are never attempted.', - summary: 'Inspect, press, or dismiss the device keyboard', positionalArgs: ['action?'], } as const satisfies CommandSchemaOverride; const clipboardCliSchema = { usageOverride: 'clipboard read | clipboard write ', listUsageOverride: 'clipboard read | clipboard write ', - helpDescription: 'Read or write device clipboard text', positionalArgs: ['read|write', 'text?'], allowsExtraPositionals: true, } as const satisfies CommandSchemaOverride; @@ -197,9 +200,6 @@ const clipboardCliSchema = { const tvRemoteCliSchema = { usageOverride: `tv-remote [press|longpress] ${TV_REMOTE_BUTTON_USAGE} [--duration-ms ]`, listUsageOverride: 'tv-remote press|longpress