From 2707a310741b1bf70e6f64b90c19787151051ae5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 5 Aug 2026 14:30:31 +0200 Subject: [PATCH 1/9] fix: update MCP registry namespace --- package.json | 2 +- server.json | 2 +- website/docs/docs/agent-setup.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) 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/website/docs/docs/agent-setup.md b/website/docs/docs/agent-setup.md index a4c76b06fb..aafb52e4e6 100644 --- a/website/docs/docs/agent-setup.md +++ b/website/docs/docs/agent-setup.md @@ -88,7 +88,7 @@ No global install variant. Pin a user- or project-selected package version for u } ``` -Registry metadata uses MCP name `io.github.callstackincubator/agent-device`, npm package `agent-device`, stdio transport, `mcpName` package verification, `server.json`, `glama.json`, and `smithery.yaml`. Glama lists the server at [callstack/agent-device](https://glama.ai/mcp/servers/callstack/agent-device). +Registry metadata uses MCP name `io.github.callstack/agent-device`, npm package `agent-device`, stdio transport, `mcpName` package verification, `server.json`, `glama.json`, and `smithery.yaml`. Glama lists the server at [callstack/agent-device](https://glama.ai/mcp/servers/callstack/agent-device). ## Cursor From e90292e76770423c5db548dd97def747c75fcfde Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 5 Aug 2026 14:39:00 +0200 Subject: [PATCH 2/9] feat: inherit MCP descriptions from CLI help --- .../__tests__/command-surface-metadata.test.ts | 15 +++++++++++++++ src/commands/capture/alert.ts | 2 ++ src/commands/capture/wait.ts | 2 ++ src/commands/family/types.ts | 11 ++++++++++- src/commands/interaction/index.ts | 12 ++++++++++++ src/commands/management/app.ts | 2 ++ src/commands/management/device.ts | 6 ++++++ src/commands/management/install.ts | 4 ++++ src/commands/react-native/index.ts | 2 ++ src/commands/system/index.ts | 14 ++++++++++++++ 10 files changed, 69 insertions(+), 1 deletion(-) diff --git a/src/commands/__tests__/command-surface-metadata.test.ts b/src/commands/__tests__/command-surface-metadata.test.ts index d1a6e8ac73..332282dbb8 100644 --- a/src/commands/__tests__/command-surface-metadata.test.ts +++ b/src/commands/__tests__/command-surface-metadata.test.ts @@ -41,6 +41,21 @@ test('CI-only prepare command stays out of MCP tool surface', () => { assert.equal(listMcpExposedCommandNames().includes('prepare'), false); }); +test('MCP tool descriptions inherit complete CLI guidance', () => { + const cliSchemas = listCommandFamilyCliSchemas(); + const definitionsByName = new Map( + listCommandFamilyDefinitions().map((definition) => [definition.name, definition] as const), + ); + + for (const metadata of listMcpCommandMetadata()) { + const cliSchema = cliSchemas[metadata.name]; + const cliDescription = cliSchema?.helpDescription ?? cliSchema?.summary; + assert.ok(cliDescription, `${metadata.name} must define CLI guidance for its MCP description`); + assert.equal(metadata.description, cliDescription); + assert.equal(definitionsByName.get(metadata.name)?.description, cliDescription); + } +}); + 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'); diff --git a/src/commands/capture/alert.ts b/src/commands/capture/alert.ts index 0b5d2b3b0b..5b8d31f1f5 100644 --- a/src/commands/capture/alert.ts +++ b/src/commands/capture/alert.ts @@ -34,6 +34,8 @@ const alertCommandDefinition = defineExecutableCommand(alertCommandMetadata, (cl const alertCliSchema = { usageOverride: 'alert [get|accept|dismiss|wait] [timeout]', + helpDescription: + '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.', positionalArgs: ['action?', 'timeout?'], } as const; diff --git a/src/commands/capture/wait.ts b/src/commands/capture/wait.ts index 4bea760d71..91e4c4f8c1 100644 --- a/src/commands/capture/wait.ts +++ b/src/commands/capture/wait.ts @@ -49,6 +49,8 @@ const waitCommandDefinition = defineExecutableCommand(waitCommandMetadata, (clie const waitCliSchema = { usageOverride: 'wait |text |@ref||stable [quietMs] [timeoutMs]', + helpDescription: + '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.', positionalArgs: ['durationOrSelector', 'timeoutMs?'], allowsExtraPositionals: true, allowedFlags: [...SELECTOR_SNAPSHOT_FLAGS], diff --git a/src/commands/family/types.ts b/src/commands/family/types.ts index 21793027b6..f7a61428e0 100644 --- a/src/commands/family/types.ts +++ b/src/commands/family/types.ts @@ -62,7 +62,16 @@ export function defineCommandFacet< const TCommandName extends string, const TCommand extends CommandFacet, >(command: TCommand): TCommand { - return command; + const description = command.cliSchema?.helpDescription ?? command.cliSchema?.summary; + if (!description) return command; + + // CLI help is the command-surface owner for agent-facing guidance. Keep MCP + // tool descriptions aligned by default so the two projections cannot drift. + return { + ...command, + metadata: { ...command.metadata, description }, + definition: { ...command.definition, description }, + } as TCommand; } export function defineCommandFamilyFromFacets< diff --git a/src/commands/interaction/index.ts b/src/commands/interaction/index.ts index 21e1369ce8..37c38baf4f 100644 --- a/src/commands/interaction/index.ts +++ b/src/commands/interaction/index.ts @@ -61,6 +61,8 @@ import { selectorCliReaders, selectorDaemonWriters } from './selectors.ts'; const interactionCliSchemas = { get: { usageOverride: 'get text|attrs <@ref|selector>', + helpDescription: + '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.', positionalArgs: ['subcommand', 'target'], allowsExtraPositionals: true, allowedFlags: [...SELECTOR_SNAPSHOT_FLAGS, 'record'], @@ -74,12 +76,16 @@ const interactionCliSchemas = { allowedFlags: ['snapshotDepth', 'snapshotRaw', 'findFirst', 'findLast', 'record'], }, is: { + helpDescription: + '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.', positionalArgs: ['predicate', 'selector', 'value?'], allowsExtraPositionals: true, allowedFlags: [...SELECTOR_SNAPSHOT_FLAGS, 'record'], }, click: { usageOverride: 'click ', + helpDescription: + 'Activate a UI target by snapshot ref, selector, or coordinates. Prefer a ref or selector after snapshot; use coordinates only when semantic targeting is unavailable. This can change app state; use settle or snapshot to verify the result.', positionalArgs: ['target'], allowsExtraPositionals: true, allowedFlags: [ @@ -128,15 +134,21 @@ const interactionCliSchemas = { allowedFlags: ['pointerCount'], }, focus: { + helpDescription: + '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.', positionalArgs: ['x', 'y'], }, type: { + helpDescription: + '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.', positionalArgs: ['text'], allowsExtraPositionals: true, allowedFlags: ['delayMs'], }, fill: { usageOverride: 'fill | fill <@ref|selector> ', + helpDescription: + '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.', positionalArgs: ['targetOrX', 'yOrText', 'text?'], allowsExtraPositionals: true, allowedFlags: [ diff --git a/src/commands/management/app.ts b/src/commands/management/app.ts index d25dbdde0e..bea7fe5d8d 100644 --- a/src/commands/management/app.ts +++ b/src/commands/management/app.ts @@ -128,6 +128,8 @@ const openCliSchema = { } as const satisfies CommandSchemaOverride; const closeCliSchema = { + helpDescription: + '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.', positionalArgs: ['app?'], allowedFlags: ['saveScript', 'force', 'shutdown'], } as const satisfies CommandSchemaOverride; diff --git a/src/commands/management/device.ts b/src/commands/management/device.ts index d41b2a8be0..80d068dc1d 100644 --- a/src/commands/management/device.ts +++ b/src/commands/management/device.ts @@ -53,6 +53,11 @@ const bootCliSchema = { allowedFlags: ['headless'], } as const satisfies CommandSchemaOverride; +const devicesCliSchema = { + helpDescription: + '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.', +} as const satisfies CommandSchemaOverride; + const capabilitiesCliSchema = { summary: 'List supported commands for the selected device', helpDescription: @@ -79,6 +84,7 @@ const devicesCommandFacet = defineCommandFacet({ name: 'devices', metadata: devicesCommandMetadata, definition: devicesCommandDefinition, + cliSchema: devicesCliSchema, cliReader: commonCliReader, daemonWriter: devicesDaemonWriter, cliOutputFormatter: managementCliOutputFormatters.devices, diff --git a/src/commands/management/install.ts b/src/commands/management/install.ts index 5f918d84c0..843502e2dc 100644 --- a/src/commands/management/install.ts +++ b/src/commands/management/install.ts @@ -68,10 +68,14 @@ const installFromSourceCommandDefinition = defineExecutableCommand( const installCliSchema = { usageOverride: 'install | install ', listUsageOverride: 'install ', + helpDescription: + '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.', positionalArgs: ['appOrPath', 'path?'], } as const satisfies CommandSchemaOverride; const reinstallCliSchema = { + helpDescription: + '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.', positionalArgs: ['app', 'path'], } as const satisfies CommandSchemaOverride; diff --git a/src/commands/react-native/index.ts b/src/commands/react-native/index.ts index a0c38efac2..7a5e906333 100644 --- a/src/commands/react-native/index.ts +++ b/src/commands/react-native/index.ts @@ -28,6 +28,8 @@ export const reactNativeCommandDefinition = defineExecutableCommand( const reactNativeCliSchema = { usageOverride: 'react-native dismiss-overlay', listUsageOverride: 'react-native dismiss-overlay', + helpDescription: + 'Run supported React Native automation helpers. Use dismiss-overlay to close a visible development error overlay before continuing normal UI automation.', positionalArgs: ['dismiss-overlay'], } as const satisfies CommandSchemaOverride; diff --git a/src/commands/system/index.ts b/src/commands/system/index.ts index bdef4ad86b..307bcb592c 100644 --- a/src/commands/system/index.ts +++ b/src/commands/system/index.ts @@ -169,9 +169,21 @@ const appStateCliSchema = { const backCliSchema = { usageOverride: 'back [--in-app|--system]', + helpDescription: + '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.', allowedFlags: ['backMode'], } as const satisfies CommandSchemaOverride; +const homeCliSchema = { + helpDescription: + 'Send the selected device to its home screen. This leaves the app session open but moves the foreground away from the app.', +} as const satisfies CommandSchemaOverride; + +const appSwitcherCliSchema = { + helpDescription: + '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.', +} as const satisfies CommandSchemaOverride; + const orientationCliSchema = { usageOverride: 'orientation ', helpDescription: 'Set device orientation on iOS and Android', @@ -283,6 +295,7 @@ const homeCommandFacet = defineCommandFacet({ name: HOME_COMMAND_NAME, metadata: homeCommandMetadata, definition: homeCommandDefinition, + cliSchema: homeCliSchema, cliReader: homeCliReader, daemonWriter: homeDaemonWriter, cliOutputFormatter: systemCliOutputFormatters.home, @@ -302,6 +315,7 @@ const appSwitcherCommandFacet = defineCommandFacet({ name: APP_SWITCHER_COMMAND_NAME, metadata: appSwitcherCommandMetadata, definition: appSwitcherCommandDefinition, + cliSchema: appSwitcherCliSchema, cliReader: appSwitcherCliReader, daemonWriter: appSwitcherDaemonWriter, cliOutputFormatter: systemCliOutputFormatters['app-switcher'], From 5a318b9eb1d399ca85a475265053f6195e951a1f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 5 Aug 2026 14:51:28 +0200 Subject: [PATCH 3/9] refactor: project command guidance per surface --- .../command-surface-metadata.test.ts | 31 +++++-- src/commands/capture/diff.ts | 6 ++ src/commands/capture/screenshot.ts | 6 ++ src/commands/capture/settings.ts | 6 ++ src/commands/capture/snapshot.ts | 6 ++ src/commands/command-guidance.test.ts | 31 +++++++ src/commands/command-guidance.ts | 82 +++++++++++++++++++ src/commands/family/types.ts | 18 ++-- src/commands/interaction/index.ts | 28 +++++++ src/commands/management/app.ts | 14 ++++ src/commands/management/device.ts | 6 ++ src/commands/management/doctor.ts | 6 ++ src/commands/management/push.ts | 7 ++ src/commands/metro/index.ts | 6 ++ src/commands/observability/index.ts | 7 ++ src/commands/perf/index.ts | 6 ++ src/commands/recording/index.ts | 12 +++ src/commands/replay/index.ts | 6 ++ src/commands/system/index.ts | 6 ++ 19 files changed, 277 insertions(+), 13 deletions(-) create mode 100644 src/commands/command-guidance.test.ts create mode 100644 src/commands/command-guidance.ts diff --git a/src/commands/__tests__/command-surface-metadata.test.ts b/src/commands/__tests__/command-surface-metadata.test.ts index 332282dbb8..dc8d7cb4ab 100644 --- a/src/commands/__tests__/command-surface-metadata.test.ts +++ b/src/commands/__tests__/command-surface-metadata.test.ts @@ -41,18 +41,37 @@ test('CI-only prepare command stays out of MCP tool surface', () => { assert.equal(listMcpExposedCommandNames().includes('prepare'), false); }); -test('MCP tool descriptions inherit complete CLI guidance', () => { +test('command guidance projects consistent metadata and executable descriptions', () => { const cliSchemas = listCommandFamilyCliSchemas(); const definitionsByName = new Map( listCommandFamilyDefinitions().map((definition) => [definition.name, definition] as const), ); for (const metadata of listMcpCommandMetadata()) { - const cliSchema = cliSchemas[metadata.name]; - const cliDescription = cliSchema?.helpDescription ?? cliSchema?.summary; - assert.ok(cliDescription, `${metadata.name} must define CLI guidance for its MCP description`); - assert.equal(metadata.description, cliDescription); - assert.equal(definitionsByName.get(metadata.name)?.description, cliDescription); + assert.ok(metadata.description, `${metadata.name} must define MCP guidance`); + assert.equal(definitionsByName.get(metadata.name)?.description, metadata.description); + } + + assert.match( + listMcpCommandMetadata().find((metadata) => metadata.name === 'open')?.description ?? '', + /foreground automation target/, + ); + assert.doesNotMatch( + listMcpCommandMetadata().find((metadata) => metadata.name === 'open')?.description ?? '', + /--platform/, + ); + assert.match( + cliSchemas.open?.helpDescription ?? '', + /Relevant flags: --surface, --launch-console\./, + ); +}); + +test('MCP tool descriptions avoid CLI syntax', () => { + const cliSyntax = [/--[a-z]/, /<[^>]+>|\[[^\]]+\]/, /agent-device/, /\bpositional\b/]; + for (const metadata of listMcpCommandMetadata()) { + for (const pattern of cliSyntax) { + assert.doesNotMatch(metadata.description, pattern, `${metadata.name} contains CLI syntax`); + } } }); diff --git a/src/commands/capture/diff.ts b/src/commands/capture/diff.ts index 0bf1edaaa9..2b9e1b1f9b 100644 --- a/src/commands/capture/diff.ts +++ b/src/commands/capture/diff.ts @@ -65,6 +65,12 @@ export const diffCommandFacet = defineCommandFacet({ metadata: diffCommandMetadata, definition: diffCommandDefinition, cliSchema: diffCliSchema, + guidance: { + mcp: { + description: + 'Compare accessibility snapshots or screenshots to identify UI changes. Use snapshot comparisons for semantic tree changes and screenshot comparisons for pixel differences.', + }, + }, cliReader: diffCliReader, daemonWriter: diffDaemonWriter, }); diff --git a/src/commands/capture/screenshot.ts b/src/commands/capture/screenshot.ts index d1b2014b61..dcf69b7af8 100644 --- a/src/commands/capture/screenshot.ts +++ b/src/commands/capture/screenshot.ts @@ -65,6 +65,12 @@ export const screenshotCommandFacet = defineCommandFacet({ metadata: screenshotCommandMetadata, definition: screenshotCommandDefinition, cliSchema: screenshotCliSchema, + guidance: { + mcp: { + description: + '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.', + }, + }, cliReader: screenshotCliReader, daemonWriter: screenshotDaemonWriter, }); diff --git a/src/commands/capture/settings.ts b/src/commands/capture/settings.ts index c64b149629..693b270dff 100644 --- a/src/commands/capture/settings.ts +++ b/src/commands/capture/settings.ts @@ -61,6 +61,12 @@ export const settingsCommandFacet = defineCommandFacet({ metadata: settingsCommandMetadata, definition: settingsCommandDefinition, cliSchema: settingsCliSchema, + guidance: { + mcp: { + description: + 'Change supported operating-system settings, animation scales, appearance, or app permissions on the selected target. Platform support varies by setting and action.', + }, + }, cliReader: settingsCliReader, daemonWriter: settingsDaemonWriter, }); diff --git a/src/commands/capture/snapshot.ts b/src/commands/capture/snapshot.ts index 1fd0284222..f7a57987c8 100644 --- a/src/commands/capture/snapshot.ts +++ b/src/commands/capture/snapshot.ts @@ -69,6 +69,12 @@ export const snapshotCommandFacet = defineCommandFacet({ metadata: snapshotCommandMetadata, definition: snapshotCommandDefinition, cliSchema: snapshotCliSchema, + guidance: { + mcp: { + description: + '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.', + }, + }, cliReader: snapshotCliReader, daemonWriter: snapshotDaemonWriter, cliOutputFormatter: captureCliOutputFormatters.snapshot, diff --git a/src/commands/command-guidance.test.ts b/src/commands/command-guidance.test.ts new file mode 100644 index 0000000000..dec43ffd7e --- /dev/null +++ b/src/commands/command-guidance.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, test } from 'vitest'; +import { projectCommandGuidance } from './command-guidance.ts'; + +describe('projectCommandGuidance', () => { + test('keeps CLI-only flag guidance out of the MCP description and injects selected MCP inputs', () => { + const guidance = projectCommandGuidance( + 'Open an app.', + { helpDescription: 'Open an app with --surface.' }, + { + properties: { + app: { description: 'App name or bundle identifier.' }, + surface: { description: 'macOS presentation surface.' }, + }, + }, + { + mcp: { + description: 'Open an app or URL in the selected session.', + parameters: ['app', 'surface'], + }, + cli: { flags: ['surface'] }, + }, + ); + + expect(guidance.cliSchema?.helpDescription).toBe( + 'Open an app with --surface. Relevant flags: --surface.', + ); + expect(guidance.mcpDescription).toBe( + 'Open an app or URL in the selected session. Key inputs: app: App name or bundle identifier. surface: macOS presentation surface.', + ); + }); +}); diff --git a/src/commands/command-guidance.ts b/src/commands/command-guidance.ts new file mode 100644 index 0000000000..3e3612a2fc --- /dev/null +++ b/src/commands/command-guidance.ts @@ -0,0 +1,82 @@ +import type { CommandSchemaOverride } from '../cli-schema/types.ts'; +import type { FlagKey } from './cli-grammar/flag-types.ts'; +import type { JsonSchema } from './command-contract.ts'; + +export type CommandGuidance = { + /** Surface-neutral intent shared by the CLI, MCP, and command explanation. */ + description?: string; + cli?: { + /** Replaces the shared description when terminal phrasing needs to differ. */ + description?: string; + /** CLI-only operational detail that does not belong in an MCP tool description. */ + detail?: string; + /** Flags worth naming in the CLI synopsis; full flag docs remain in the flag section. */ + flags?: readonly FlagKey[]; + }; + mcp?: { + /** Replaces the shared description when MCP phrasing needs to differ. */ + description?: string; + /** MCP-only operational detail that does not belong in terminal help. */ + detail?: string; + /** Input fields whose schema descriptions should be appended to the MCP tool description. */ + parameters?: readonly string[]; + }; +}; + +export function projectCommandGuidance( + fallbackDescription: string, + cliSchema: CommandSchemaOverride | undefined, + inputSchema: JsonSchema, + guidance: CommandGuidance | undefined, +): { cliSchema: CommandSchemaOverride | undefined; mcpDescription: string } { + const shared = + guidance?.description ?? + cliSchema?.helpDescription ?? + cliSchema?.summary ?? + fallbackDescription; + return { + cliSchema: injectCliGuidance(guidance?.cli?.description ?? shared, cliSchema, guidance?.cli), + mcpDescription: injectMcpGuidance( + guidance?.mcp?.description ?? shared, + inputSchema, + guidance?.mcp, + ), + }; +} + +function injectCliGuidance( + shared: string, + cliSchema: CommandSchemaOverride | undefined, + guidance: CommandGuidance['cli'] | undefined, +): CommandSchemaOverride | undefined { + if (!cliSchema && !guidance) return undefined; + const flagNames = guidance?.flags?.map((flag) => `--${toKebabCase(flag)}`) ?? []; + const additions = [ + guidance?.detail, + flagNames.length > 0 ? `Relevant flags: ${flagNames.join(', ')}.` : undefined, + ] + .filter((value): value is string => Boolean(value)) + .join(' '); + return { ...cliSchema, helpDescription: [shared, additions].filter(Boolean).join(' ') }; +} + +function injectMcpGuidance( + shared: string, + inputSchema: JsonSchema, + guidance: CommandGuidance['mcp'] | undefined, +): string { + const properties = inputSchema.properties ?? {}; + const parameterHints = (guidance?.parameters ?? []).flatMap((name) => { + const description = properties[name]?.description; + return description ? [`${name}: ${description}`] : []; + }); + const additions = [ + guidance?.detail, + parameterHints.length > 0 ? `Key inputs: ${parameterHints.join(' ')}` : undefined, + ].filter((value): value is string => Boolean(value)); + return [shared, ...additions].join(' '); +} + +function toKebabCase(value: string): string { + return value.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`); +} diff --git a/src/commands/family/types.ts b/src/commands/family/types.ts index f7a61428e0..4c25a50666 100644 --- a/src/commands/family/types.ts +++ b/src/commands/family/types.ts @@ -7,6 +7,7 @@ import type { JsonSchema, } from '../command-contract.ts'; import type { CliOutputFormatter } from '../output-common.ts'; +import { projectCommandGuidance, type CommandGuidance } from '../command-guidance.ts'; export type AnyCommandMetadata = CommandMetadata; @@ -39,6 +40,7 @@ export type CommandFacet = { cliReader: CliReader; daemonWriter?: DaemonWriter; cliOutputFormatter?: CliOutputFormatter; + guidance?: CommandGuidance; }; type CommandFacetMetadata = { @@ -62,15 +64,17 @@ export function defineCommandFacet< const TCommandName extends string, const TCommand extends CommandFacet, >(command: TCommand): TCommand { - const description = command.cliSchema?.helpDescription ?? command.cliSchema?.summary; - if (!description) return command; - - // CLI help is the command-surface owner for agent-facing guidance. Keep MCP - // tool descriptions aligned by default so the two projections cannot drift. + const { cliSchema, mcpDescription } = projectCommandGuidance( + command.metadata.description, + command.cliSchema, + command.metadata.inputSchema, + command.guidance, + ); return { ...command, - metadata: { ...command.metadata, description }, - definition: { ...command.definition, description }, + metadata: { ...command.metadata, description: mcpDescription }, + definition: { ...command.definition, description: mcpDescription }, + ...(cliSchema ? { cliSchema } : {}), } as TCommand; } diff --git a/src/commands/interaction/index.ts b/src/commands/interaction/index.ts index 37c38baf4f..9e365461fb 100644 --- a/src/commands/interaction/index.ts +++ b/src/commands/interaction/index.ts @@ -247,6 +247,13 @@ const clickCommandFacet = defineCommandFacet({ metadata: metadata('click'), definition: clickCommandDefinition, cliSchema: interactionCliSchemas.click, + guidance: { + mcp: { + description: + '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.', + parameters: ['target', 'settle', 'verify'], + }, + }, cliReader: interactionCliReaders.click, daemonWriter: interactionDaemonWriters.click, cliOutputFormatter: interactionCliOutputFormatters.click, @@ -257,6 +264,13 @@ const pressCommandFacet = defineCommandFacet({ metadata: metadata('press'), definition: pressCommandDefinition, cliSchema: interactionCliSchemas.press, + guidance: { + mcp: { + description: + 'Short-press a UI target by snapshot ref, selector, or coordinates. Use longpress instead when the target requires a context-menu or hold gesture.', + parameters: ['target', 'settle', 'verify'], + }, + }, cliReader: interactionCliReaders.press, daemonWriter: interactionDaemonWriters.press, cliOutputFormatter: interactionCliOutputFormatters.press, @@ -277,6 +291,13 @@ const longPressCommandFacet = defineCommandFacet({ metadata: metadata('longpress'), definition: longPressCommandDefinition, cliSchema: interactionCliSchemas.longpress, + guidance: { + mcp: { + description: + '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.', + parameters: ['target', 'durationMs'], + }, + }, cliReader: interactionCliReaders.longpress, daemonWriter: interactionDaemonWriters.longpress, cliOutputFormatter: interactionCliOutputFormatters.longpress, @@ -353,6 +374,13 @@ const gestureCommandFacet = defineCommandFacet({ metadata: metadata('gesture'), definition: gestureCommandDefinition, cliSchema: interactionCliSchemas.gesture, + guidance: { + mcp: { + description: + '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.', + parameters: ['kind', 'direction', 'preset', 'durationMs'], + }, + }, cliReader: gestureCliReaders.gesture, daemonWriter: gestureDaemonWriters.gesture, }); diff --git a/src/commands/management/app.ts b/src/commands/management/app.ts index bea7fe5d8d..c2aefe9ed3 100644 --- a/src/commands/management/app.ts +++ b/src/commands/management/app.ts @@ -178,6 +178,12 @@ export const appsCommandFacet = defineCommandFacet({ metadata: appsCommandMetadata, definition: appsCommandDefinition, cliSchema: appsCliSchema, + guidance: { + mcp: { + description: + 'List the apps installed on the selected device. Include system or OEM apps only when they are needed as automation targets.', + }, + }, cliReader: appsCliReader, daemonWriter: appsDaemonWriter, cliOutputFormatter: managementCliOutputFormatters.apps, @@ -188,6 +194,14 @@ export const openCommandFacet = defineCommandFacet({ metadata: openCommandMetadata, definition: openCommandDefinition, cliSchema: openCliSchema, + guidance: { + mcp: { + description: + '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.', + parameters: ['app', 'url', 'surface'], + }, + cli: { flags: ['surface', 'launchConsole'] }, + }, cliReader: openCliReader, daemonWriter: openDaemonWriter, cliOutputFormatter: managementCliOutputFormatters.open, diff --git a/src/commands/management/device.ts b/src/commands/management/device.ts index 80d068dc1d..e38134d023 100644 --- a/src/commands/management/device.ts +++ b/src/commands/management/device.ts @@ -95,6 +95,12 @@ const capabilitiesCommandFacet = defineCommandFacet({ metadata: capabilitiesCommandMetadata, definition: capabilitiesCommandDefinition, cliSchema: capabilitiesCliSchema, + guidance: { + mcp: { + description: + 'List the commands supported by the selected device or active session. Use device-selection inputs when checking support before a session is open.', + }, + }, cliReader: commonCliReader, daemonWriter: capabilitiesDaemonWriter, cliOutputFormatter: managementCliOutputFormatters.capabilities, diff --git a/src/commands/management/doctor.ts b/src/commands/management/doctor.ts index 5c45766ad5..74570d1a4d 100644 --- a/src/commands/management/doctor.ts +++ b/src/commands/management/doctor.ts @@ -47,6 +47,12 @@ export const doctorCommandFacet = defineCommandFacet({ metadata: doctorCommandMetadata, definition: doctorCommandDefinition, cliSchema: doctorCliSchema, + guidance: { + mcp: { + description: + '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.', + }, + }, cliReader: doctorCliReader, daemonWriter: doctorDaemonWriter, cliOutputFormatter: managementCliOutputFormatters.doctor, diff --git a/src/commands/management/push.ts b/src/commands/management/push.ts index e684efd7f9..3688507fad 100644 --- a/src/commands/management/push.ts +++ b/src/commands/management/push.ts @@ -102,6 +102,13 @@ const triggerAppEventCommandFacet = defineCommandFacet({ metadata: triggerAppEventCommandMetadata, definition: triggerAppEventCommandDefinition, cliSchema: triggerAppEventCliSchema, + guidance: { + mcp: { + description: + 'Ask the app to handle an app-defined automation or test event. Call this only for event names and payload shapes the app documents.', + parameters: ['event', 'payload'], + }, + }, cliReader: triggerAppEventCliReader, daemonWriter: triggerAppEventDaemonWriter, }); diff --git a/src/commands/metro/index.ts b/src/commands/metro/index.ts index f292437969..ad8379e04a 100644 --- a/src/commands/metro/index.ts +++ b/src/commands/metro/index.ts @@ -148,6 +148,12 @@ const metroCommandFacet = defineCommandFacet({ metadata: metroCommandMetadata, definition: metroCommandDefinition, cliSchema: metroCliSchema, + guidance: { + mcp: { + description: + "Prepare a React Native development server or reload connected apps using the session's bound development-server settings. Use explicit runtime inputs only when overriding that session binding.", + }, + }, cliReader: metroCliReader, cliOutputFormatter: metroCliOutputFormatters.metro, }); diff --git a/src/commands/observability/index.ts b/src/commands/observability/index.ts index 28610b9846..aab8a75fea 100644 --- a/src/commands/observability/index.ts +++ b/src/commands/observability/index.ts @@ -214,6 +214,13 @@ const audioCommandFacet = defineCommandFacet({ metadata: audioCommandMetadata, definition: audioCommandDefinition, cliSchema: audioCliSchema, + guidance: { + mcp: { + description: + 'Measure browser or host-rendered simulator/emulator audio as compact dBFS buckets. Start a probe before requesting its status or stopping it.', + parameters: ['durationMs', 'bucketMs'], + }, + }, cliReader: audioCliReader, daemonWriter: audioDaemonWriter, cliOutputFormatter: observabilityCliOutputFormatters.audio, diff --git a/src/commands/perf/index.ts b/src/commands/perf/index.ts index 761fff50d8..3b755345ae 100644 --- a/src/commands/perf/index.ts +++ b/src/commands/perf/index.ts @@ -78,6 +78,12 @@ const perfCommandFacet = defineCommandFacet({ metadata: perfCommandMetadata, definition: perfCommandDefinition, cliSchema: perfCliSchema, + guidance: { + mcp: { + description: + '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.', + }, + }, cliReader: perfCliReader, daemonWriter: perfDaemonWriter, cliOutputFormatter: perfCliOutputFormatters.perf, diff --git a/src/commands/recording/index.ts b/src/commands/recording/index.ts index 7c63adb878..4168847bd1 100644 --- a/src/commands/recording/index.ts +++ b/src/commands/recording/index.ts @@ -109,6 +109,12 @@ const recordCommandFacet = defineCommandFacet({ metadata: recordCommandMetadata, definition: recordCommandDefinition, cliSchema: recordCliSchema, + guidance: { + mcp: { + description: + '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.', + }, + }, cliReader: recordCliReader, daemonWriter: recordDaemonWriter, cliOutputFormatter: recordingCliOutputFormatters.record, @@ -119,6 +125,12 @@ const traceCommandFacet = defineCommandFacet({ metadata: traceCommandMetadata, definition: traceCommandDefinition, cliSchema: traceCliSchema, + guidance: { + mcp: { + description: + '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.', + }, + }, cliReader: traceCliReader, daemonWriter: traceDaemonWriter, }); diff --git a/src/commands/replay/index.ts b/src/commands/replay/index.ts index eb0c1743b6..2f5fff5015 100644 --- a/src/commands/replay/index.ts +++ b/src/commands/replay/index.ts @@ -205,6 +205,12 @@ const replayCommandFacet = defineCommandFacet({ metadata: replayCommandMetadata, definition: replayCommandDefinition, cliSchema: replayCliSchema, + guidance: { + mcp: { + description: + 'Run a recorded automation script, including compatible Maestro YAML flows. A script without a terminal close leaves its session active for subsequent automation.', + }, + }, cliReader: replayCliReader, daemonWriter: replayDaemonWriter, }); diff --git a/src/commands/system/index.ts b/src/commands/system/index.ts index 307bcb592c..bec84b4b67 100644 --- a/src/commands/system/index.ts +++ b/src/commands/system/index.ts @@ -348,6 +348,12 @@ const tvRemoteCommandFacet = defineCommandFacet({ metadata: tvRemoteCommandMetadata, definition: tvRemoteCommandDefinition, cliSchema: tvRemoteCliSchema, + guidance: { + mcp: { + description: + '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.', + }, + }, cliReader: tvRemoteCliReader, daemonWriter: tvRemoteDaemonWriter, cliOutputFormatter: systemCliOutputFormatters['tv-remote'], From 5225628fd95ab49b43b6b9ed4f4223404208dd5e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 13:48:25 +0000 Subject: [PATCH 4/9] refactor: make command guidance a single canonical description MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The guidance type carried seven fields, but only three were ever set, and all twenty call sites used it the same way: to hold a second, hand-written MCP string next to a near-identical CLI one. That is the drift the abstraction was meant to remove, so the type no longer offers a per-surface description at all. A command now has one canonical description plus an optional tail per surface: guidance: { description: 'Shared body.', cliDetail: 'Flags, positional syntax, terminal examples.', mcpDetail: 'When-to-use and sequencing hints.', } Because a surface can only append, CLI help and MCP tool text cannot diverge — the guard against CLI syntax in MCP descriptions becomes structural rather than a review tripwire, since flag vocabulary only lives in cliDetail. All twenty commands that previously carried two descriptions now share one body. Also: - Drop `summary` from the description fallback chain. It is the short list-view line, so falling back to it replaced the full description with a fragment on both surfaces: artifacts, boot, and shutdown each lost their real description. - Stop writing the MCP variant back over `metadata.description`. That field feeds CLI help, `explain`, and docs; `explain` was printing MCP-only text. MCP now reads a separate `mcpDescription`. - Drop `mcp.parameters`. It restated inputSchema property descriptions inside the tool description — 1232 characters duplicated verbatim across six tools, and three of sixteen declared hints silently rendered nothing because the property had no description. Those properties are documented in the schema instead, which serves MCP, --help, and docs at once. - Drop `cli.flags`. Its one use appended "Relevant flags: --surface, --launch-console." to help text that already named both flags inline. Tests assert the structural property (both surfaces share a canonical prefix) and the summary-fallback regression, alongside the existing CLI-syntax guard. CLI help wording assertions follow the new copy. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Dp3J8UUgYxtw5vzJzvjkSf --- .../__tests__/cli-help-command-usage.test.ts | 9 +- .../parser/__tests__/cli-help-topics.test.ts | 2 +- .../command-surface-metadata.test.ts | 68 +++++++++---- src/commands/capture/diff.ts | 10 +- src/commands/capture/screenshot.ts | 10 +- src/commands/capture/settings.ts | 10 +- src/commands/capture/snapshot.ts | 10 +- src/commands/command-contract.ts | 6 ++ src/commands/command-guidance.test.ts | 54 +++++++--- src/commands/command-guidance.ts | 99 ++++++------------- src/commands/family/types.ts | 6 +- src/commands/interaction/index.ts | 40 +++----- src/commands/management/app.ts | 44 +++++---- src/commands/management/device.ts | 11 +-- src/commands/management/doctor.ts | 12 +-- src/commands/management/push.ts | 9 +- src/commands/metro/index.ts | 47 ++++----- src/commands/observability/index.ts | 9 +- src/commands/perf/index.ts | 12 +-- src/commands/recording/index.ts | 19 ++-- src/commands/replay/index.ts | 14 +-- src/commands/system/index.ts | 14 +-- src/mcp/command-tools.ts | 2 +- 23 files changed, 254 insertions(+), 263 deletions(-) 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..4ebdbb65fc 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/); diff --git a/src/cli/parser/__tests__/cli-help-topics.test.ts b/src/cli/parser/__tests__/cli-help-topics.test.ts index 463c3034eb..2f245f86ed 100644 --- a/src/cli/parser/__tests__/cli-help-topics.test.ts +++ b/src/cli/parser/__tests__/cli-help-topics.test.ts @@ -136,7 +136,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 currently focused input/); assert.match(usageText, /macOS context menus use click --button secondary/); assert.match( usageText, diff --git a/src/commands/__tests__/command-surface-metadata.test.ts b/src/commands/__tests__/command-surface-metadata.test.ts index dc8d7cb4ab..b2446e8e9d 100644 --- a/src/commands/__tests__/command-surface-metadata.test.ts +++ b/src/commands/__tests__/command-surface-metadata.test.ts @@ -41,36 +41,56 @@ test('CI-only prepare command stays out of MCP tool surface', () => { assert.equal(listMcpExposedCommandNames().includes('prepare'), false); }); -test('command guidance projects consistent metadata and executable descriptions', () => { +test('every surface description derives from the same canonical body', () => { const cliSchemas = listCommandFamilyCliSchemas(); - const definitionsByName = new Map( - listCommandFamilyDefinitions().map((definition) => [definition.name, definition] as const), - ); for (const metadata of listMcpCommandMetadata()) { - assert.ok(metadata.description, `${metadata.name} must define MCP guidance`); - assert.equal(definitionsByName.get(metadata.name)?.description, metadata.description); + const mcpDescription = metadata.mcpDescription ?? metadata.description; + const helpDescription = cliSchemas[metadata.name]?.helpDescription ?? metadata.description; + assert.ok(mcpDescription, `${metadata.name} must have an MCP description`); + // Guidance can only append a per-surface tail, so both surfaces share a prefix. + // A per-surface description override would break this and reintroduce drift. + const shared = sharedPrefix(mcpDescription, helpDescription); + assert.ok( + shared.length >= Math.min(40, mcpDescription.length, helpDescription.length), + `${metadata.name} CLI and MCP descriptions diverge instead of sharing a canonical body:\n MCP: ${mcpDescription}\n CLI: ${helpDescription}`, + ); } +}); - assert.match( - listMcpCommandMetadata().find((metadata) => metadata.name === 'open')?.description ?? '', - /foreground automation target/, - ); - assert.doesNotMatch( - listMcpCommandMetadata().find((metadata) => metadata.name === 'open')?.description ?? '', - /--platform/, - ); - assert.match( - cliSchemas.open?.helpDescription ?? '', - /Relevant flags: --surface, --launch-console\./, - ); +test('a short CLI summary never replaces a command description', () => { + const cliSchemas = listCommandFamilyCliSchemas(); + for (const metadata of listCommandMetadata()) { + const schema = cliSchemas[metadata.name]; + if (!schema?.summary || !schema.helpDescription) continue; + assert.notEqual( + schema.helpDescription, + schema.summary, + `${metadata.name} help fell back to its short summary`, + ); + } }); +test('open keeps flag guidance on the CLI surface only', () => { + const cliSchemas = listCommandFamilyCliSchemas(); + const open = listMcpCommandMetadata().find((metadata) => metadata.name === 'open'); + assert.match(open?.mcpDescription ?? '', /foreground automation target/); + assert.match(cliSchemas.open?.helpDescription ?? '', /foreground automation target/); + assert.match(cliSchemas.open?.helpDescription ?? '', /--launch-console/); +}); + +function sharedPrefix(left: string, right: string): string { + let index = 0; + while (index < left.length && index < right.length && left[index] === right[index]) index += 1; + return left.slice(0, index); +} + test('MCP tool descriptions avoid CLI syntax', () => { const cliSyntax = [/--[a-z]/, /<[^>]+>|\[[^\]]+\]/, /agent-device/, /\bpositional\b/]; for (const metadata of listMcpCommandMetadata()) { + const description = metadata.mcpDescription ?? metadata.description; for (const pattern of cliSyntax) { - assert.doesNotMatch(metadata.description, pattern, `${metadata.name} contains CLI syntax`); + assert.doesNotMatch(description, pattern, `${metadata.name} contains CLI syntax`); } } }); @@ -80,7 +100,9 @@ test('common command input accepts web platform selector', () => { 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', @@ -98,7 +120,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\./, ); }); diff --git a/src/commands/capture/diff.ts b/src/commands/capture/diff.ts index 2b9e1b1f9b..79a1864163 100644 --- a/src/commands/capture/diff.ts +++ b/src/commands/capture/diff.ts @@ -34,8 +34,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'], @@ -66,10 +64,10 @@ export const diffCommandFacet = defineCommandFacet({ definition: diffCommandDefinition, cliSchema: diffCliSchema, guidance: { - mcp: { - description: - 'Compare accessibility snapshots or screenshots to identify UI changes. Use snapshot comparisons for semantic tree changes and screenshot comparisons for pixel differences.', - }, + description: + 'Compare accessibility snapshots or screenshots to identify UI changes. Use snapshot comparisons for semantic tree changes and screenshot comparisons for pixel differences.', + cliDetail: + 'Live iOS simulator screenshot diffs normalize status-bar chrome by default; use screenshot --normalize-status-bar when capturing reusable baselines.', }, cliReader: diffCliReader, daemonWriter: diffDaemonWriter, diff --git a/src/commands/capture/screenshot.ts b/src/commands/capture/screenshot.ts index dcf69b7af8..38c96d66d2 100644 --- a/src/commands/capture/screenshot.ts +++ b/src/commands/capture/screenshot.ts @@ -40,8 +40,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?'], @@ -66,10 +64,10 @@ export const screenshotCommandFacet = defineCommandFacet({ definition: screenshotCommandDefinition, cliSchema: screenshotCliSchema, guidance: { - mcp: { - description: - '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.', - }, + description: + '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.', + 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.', }, cliReader: screenshotCliReader, daemonWriter: screenshotDaemonWriter, diff --git a/src/commands/capture/settings.ts b/src/commands/capture/settings.ts index 693b270dff..2efd94a472 100644 --- a/src/commands/capture/settings.ts +++ b/src/commands/capture/settings.ts @@ -43,8 +43,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; @@ -62,10 +60,10 @@ export const settingsCommandFacet = defineCommandFacet({ definition: settingsCommandDefinition, cliSchema: settingsCliSchema, guidance: { - mcp: { - description: - 'Change supported operating-system settings, animation scales, appearance, or app permissions on the selected target. Platform support varies by setting and action.', - }, + description: + 'Change supported operating-system settings, animation scales, appearance, or app permissions on the selected target. Platform support varies by setting and action.', + 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.', }, cliReader: settingsCliReader, daemonWriter: settingsDaemonWriter, diff --git a/src/commands/capture/snapshot.ts b/src/commands/capture/snapshot.ts index f7a57987c8..9bf69901b9 100644 --- a/src/commands/capture/snapshot.ts +++ b/src/commands/capture/snapshot.ts @@ -45,8 +45,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; @@ -70,10 +68,10 @@ export const snapshotCommandFacet = defineCommandFacet({ definition: snapshotCommandDefinition, cliSchema: snapshotCliSchema, guidance: { - mcp: { - description: - '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.', - }, + description: + '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.', + 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.', }, cliReader: snapshotCliReader, daemonWriter: snapshotDaemonWriter, diff --git a/src/commands/command-contract.ts b/src/commands/command-contract.ts index e20ced9dd4..6ca0d57ff5 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; + /** + * Canonical description plus any MCP-only tail, projected from the command's guidance. + * Only the MCP tool surface reads this; `description` stays surface-neutral for CLI help, + * `explain`, and docs. + */ + mcpDescription?: string; inputSchema: JsonSchema; readInput: (input: unknown) => Input; }; diff --git a/src/commands/command-guidance.test.ts b/src/commands/command-guidance.test.ts index dec43ffd7e..889624116f 100644 --- a/src/commands/command-guidance.test.ts +++ b/src/commands/command-guidance.test.ts @@ -2,30 +2,54 @@ import { describe, expect, test } from 'vitest'; import { projectCommandGuidance } from './command-guidance.ts'; describe('projectCommandGuidance', () => { - test('keeps CLI-only flag guidance out of the MCP description and injects selected MCP inputs', () => { + test('projects one canonical body with a per-surface tail', () => { const guidance = projectCommandGuidance( 'Open an app.', - { helpDescription: 'Open an app with --surface.' }, + { summary: 'Open an app' }, { - properties: { - app: { description: 'App name or bundle identifier.' }, - surface: { description: 'macOS presentation surface.' }, - }, - }, - { - mcp: { - description: 'Open an app or URL in the selected session.', - parameters: ['app', 'surface'], - }, - cli: { flags: ['surface'] }, + description: 'Open an app or URL in the selected session.', + cliDetail: 'macOS also supports --surface app|desktop.', + mcpDetail: 'Prefer this over booting the device separately.', }, ); expect(guidance.cliSchema?.helpDescription).toBe( - 'Open an app with --surface. Relevant flags: --surface.', + 'Open an app or URL in the selected session. macOS also supports --surface app|desktop.', ); expect(guidance.mcpDescription).toBe( - 'Open an app or URL in the selected session. Key inputs: app: App name or bundle identifier. surface: macOS presentation surface.', + 'Open an app or URL in the selected session. Prefer this over booting the device separately.', + ); + }); + + test('keeps CLI-only flag guidance out of the MCP description', () => { + const guidance = projectCommandGuidance('Open an app.', undefined, { + description: 'Open an app.', + cliDetail: 'Use --surface to pick a macOS surface.', + }); + + expect(guidance.mcpDescription).toBe('Open an app.'); + expect(guidance.cliSchema?.helpDescription).toContain('--surface'); + }); + + test('never falls back to the short list-view summary', () => { + const guidance = projectCommandGuidance( + 'Boot or prepare a selected device without using CLI positional arguments.', + { summary: 'Boot target device/simulator' }, + undefined, ); + + expect(guidance.mcpDescription).toBe( + 'Boot or prepare a selected device without using CLI positional arguments.', + ); + expect(guidance.cliSchema?.helpDescription).toBe( + 'Boot or prepare a selected device without using CLI positional arguments.', + ); + }); + + test('leaves commands without a CLI schema or guidance untouched', () => { + const guidance = projectCommandGuidance('Show foreground app.', undefined, undefined); + + expect(guidance.cliSchema).toBeUndefined(); + expect(guidance.mcpDescription).toBe('Show foreground app.'); }); }); diff --git a/src/commands/command-guidance.ts b/src/commands/command-guidance.ts index 3e3612a2fc..680e7d7305 100644 --- a/src/commands/command-guidance.ts +++ b/src/commands/command-guidance.ts @@ -1,82 +1,47 @@ import type { CommandSchemaOverride } from '../cli-schema/types.ts'; -import type { FlagKey } from './cli-grammar/flag-types.ts'; -import type { JsonSchema } from './command-contract.ts'; +/** + * One canonical description per command, plus an optional tail per surface. + * + * There is deliberately no per-surface description *override*: a surface can only append + * to the shared body, never replace it, so CLI help and MCP tool text cannot drift apart. + * Terminal-only vocabulary — flags, positional syntax, `agent-device` examples — belongs in + * `cliDetail`, which the MCP surface never reads; that is what keeps MCP descriptions free of + * CLI syntax structurally rather than by review. + * + * Input fields are documented once, in the command's `inputSchema`. Both surfaces already + * render those descriptions (MCP sends the schema with the tool, `--help` prints the flag + * section), so guidance never restates them. + */ export type CommandGuidance = { - /** Surface-neutral intent shared by the CLI, MCP, and command explanation. */ + /** Canonical intent. Defaults to the CLI help description, then the command metadata description. */ description?: string; - cli?: { - /** Replaces the shared description when terminal phrasing needs to differ. */ - description?: string; - /** CLI-only operational detail that does not belong in an MCP tool description. */ - detail?: string; - /** Flags worth naming in the CLI synopsis; full flag docs remain in the flag section. */ - flags?: readonly FlagKey[]; - }; - mcp?: { - /** Replaces the shared description when MCP phrasing needs to differ. */ - description?: string; - /** MCP-only operational detail that does not belong in terminal help. */ - detail?: string; - /** Input fields whose schema descriptions should be appended to the MCP tool description. */ - parameters?: readonly string[]; - }; + /** Appended to CLI help only: flags, positional syntax, terminal examples. */ + cliDetail?: string; + /** Appended to the MCP tool description only: when-to-use and sequencing hints. */ + mcpDetail?: string; }; export function projectCommandGuidance( - fallbackDescription: string, + metadataDescription: string, cliSchema: CommandSchemaOverride | undefined, - inputSchema: JsonSchema, guidance: CommandGuidance | undefined, ): { cliSchema: CommandSchemaOverride | undefined; mcpDescription: string } { - const shared = - guidance?.description ?? - cliSchema?.helpDescription ?? - cliSchema?.summary ?? - fallbackDescription; + // `summary` is the short list-view line, so it is deliberately not in this chain: + // falling back to it would replace a full description with a fragment on both surfaces. + const shared = guidance?.description ?? cliSchema?.helpDescription ?? metadataDescription; return { - cliSchema: injectCliGuidance(guidance?.cli?.description ?? shared, cliSchema, guidance?.cli), - mcpDescription: injectMcpGuidance( - guidance?.mcp?.description ?? shared, - inputSchema, - guidance?.mcp, - ), + cliSchema: + (cliSchema ?? guidance) + ? { + ...cliSchema, + helpDescription: appendDetail(shared, guidance?.cliDetail), + } + : undefined, + mcpDescription: appendDetail(shared, guidance?.mcpDetail), }; } -function injectCliGuidance( - shared: string, - cliSchema: CommandSchemaOverride | undefined, - guidance: CommandGuidance['cli'] | undefined, -): CommandSchemaOverride | undefined { - if (!cliSchema && !guidance) return undefined; - const flagNames = guidance?.flags?.map((flag) => `--${toKebabCase(flag)}`) ?? []; - const additions = [ - guidance?.detail, - flagNames.length > 0 ? `Relevant flags: ${flagNames.join(', ')}.` : undefined, - ] - .filter((value): value is string => Boolean(value)) - .join(' '); - return { ...cliSchema, helpDescription: [shared, additions].filter(Boolean).join(' ') }; -} - -function injectMcpGuidance( - shared: string, - inputSchema: JsonSchema, - guidance: CommandGuidance['mcp'] | undefined, -): string { - const properties = inputSchema.properties ?? {}; - const parameterHints = (guidance?.parameters ?? []).flatMap((name) => { - const description = properties[name]?.description; - return description ? [`${name}: ${description}`] : []; - }); - const additions = [ - guidance?.detail, - parameterHints.length > 0 ? `Key inputs: ${parameterHints.join(' ')}` : undefined, - ].filter((value): value is string => Boolean(value)); - return [shared, ...additions].join(' '); -} - -function toKebabCase(value: string): string { - return value.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`); +function appendDetail(description: string, detail: string | undefined): string { + return detail ? `${description} ${detail}` : description; } diff --git a/src/commands/family/types.ts b/src/commands/family/types.ts index 4c25a50666..ac5c05998b 100644 --- a/src/commands/family/types.ts +++ b/src/commands/family/types.ts @@ -67,13 +67,13 @@ export function defineCommandFacet< const { cliSchema, mcpDescription } = projectCommandGuidance( command.metadata.description, command.cliSchema, - command.metadata.inputSchema, command.guidance, ); + // `description` stays canonical for CLI help, `explain`, and docs; the MCP tool surface + // reads `mcpDescription` instead of having its variant written back over the shared field. return { ...command, - metadata: { ...command.metadata, description: mcpDescription }, - definition: { ...command.definition, description: mcpDescription }, + metadata: { ...command.metadata, mcpDescription }, ...(cliSchema ? { cliSchema } : {}), } as TCommand; } diff --git a/src/commands/interaction/index.ts b/src/commands/interaction/index.ts index 9e365461fb..06dec2744d 100644 --- a/src/commands/interaction/index.ts +++ b/src/commands/interaction/index.ts @@ -84,8 +84,6 @@ const interactionCliSchemas = { }, click: { usageOverride: 'click ', - helpDescription: - 'Activate a UI target by snapshot ref, selector, or coordinates. Prefer a ref or selector after snapshot; use coordinates only when semantic targeting is unavailable. This can change app state; use settle or snapshot to verify the result.', positionalArgs: ['target'], allowsExtraPositionals: true, allowedFlags: [ @@ -97,8 +95,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: [ @@ -109,8 +105,6 @@ 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], @@ -126,8 +120,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, @@ -248,11 +240,8 @@ const clickCommandFacet = defineCommandFacet({ definition: clickCommandDefinition, cliSchema: interactionCliSchemas.click, guidance: { - mcp: { - description: - '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.', - parameters: ['target', 'settle', 'verify'], - }, + description: + '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.', }, cliReader: interactionCliReaders.click, daemonWriter: interactionDaemonWriters.click, @@ -265,11 +254,9 @@ const pressCommandFacet = defineCommandFacet({ definition: pressCommandDefinition, cliSchema: interactionCliSchemas.press, guidance: { - mcp: { - description: - 'Short-press a UI target by snapshot ref, selector, or coordinates. Use longpress instead when the target requires a context-menu or hold gesture.', - parameters: ['target', 'settle', 'verify'], - }, + description: + 'Short-press a UI target by snapshot ref, selector, or coordinates. Use longpress instead when the target requires a context-menu or hold gesture.', + cliDetail: 'Use longpress rather than press --hold-ms.', }, cliReader: interactionCliReaders.press, daemonWriter: interactionDaemonWriters.press, @@ -292,11 +279,9 @@ const longPressCommandFacet = defineCommandFacet({ definition: longPressCommandDefinition, cliSchema: interactionCliSchemas.longpress, guidance: { - mcp: { - description: - '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.', - parameters: ['target', 'durationMs'], - }, + description: + '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.', + cliDetail: 'Duration is positional, for example longpress @e12 800 or longpress 300 500 800.', }, cliReader: interactionCliReaders.longpress, daemonWriter: interactionDaemonWriters.longpress, @@ -375,11 +360,10 @@ const gestureCommandFacet = defineCommandFacet({ definition: gestureCommandDefinition, cliSchema: interactionCliSchemas.gesture, guidance: { - mcp: { - description: - '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.', - parameters: ['kind', 'direction', 'preset', 'durationMs'], - }, + description: + '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.', + 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.', }, cliReader: gestureCliReaders.gesture, daemonWriter: gestureDaemonWriters.gesture, diff --git a/src/commands/management/app.ts b/src/commands/management/app.ts index c2aefe9ed3..236c63fae4 100644 --- a/src/commands/management/app.ts +++ b/src/commands/management/app.ts @@ -24,7 +24,10 @@ import { withCommandRuntimeHints } from '../runtime-hints.ts'; import { managementCliOutputFormatters } from './output.ts'; const appsCommandMetadata = defineFieldCommandMetadata('apps', 'List installed apps.', { - appsFilter: enumField(['user-installed', 'all']), + appsFilter: enumField( + ['user-installed', 'all'], + 'Restrict the listing to user-installed apps, or include system and OEM apps.', + ), }); const openCommandMetadata = defineFieldCommandMetadata( @@ -33,14 +36,19 @@ const openCommandMetadata = defineFieldCommandMetadata( { 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).', ), @@ -68,7 +76,9 @@ const closeCommandMetadata = defineFieldCommandMetadata( { 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,15 +110,12 @@ 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: [ @@ -179,10 +186,9 @@ export const appsCommandFacet = defineCommandFacet({ definition: appsCommandDefinition, cliSchema: appsCliSchema, guidance: { - mcp: { - description: - 'List the apps installed on the selected device. Include system or OEM apps only when they are needed as automation targets.', - }, + description: + 'List the apps installed on the selected device. Include system or OEM apps only when they are needed as automation targets.', + cliDetail: 'Defaults to user-installed apps; use --all to include system/OEM apps.', }, cliReader: appsCliReader, daemonWriter: appsDaemonWriter, @@ -195,12 +201,12 @@ export const openCommandFacet = defineCommandFacet({ definition: openCommandDefinition, cliSchema: openCliSchema, guidance: { - mcp: { - description: - '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.', - parameters: ['app', 'url', 'surface'], - }, - cli: { flags: ['surface', 'launchConsole'] }, + description: + '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.', + 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.", + 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.', }, cliReader: openCliReader, daemonWriter: openDaemonWriter, @@ -217,7 +223,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/device.ts b/src/commands/management/device.ts index e38134d023..e1edbc29f7 100644 --- a/src/commands/management/device.ts +++ b/src/commands/management/device.ts @@ -18,7 +18,7 @@ const capabilitiesCommandMetadata = defineFieldCommandMetadata( 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.'), }, @@ -60,8 +60,6 @@ const devicesCliSchema = { 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 shutdownCliSchema = { @@ -96,10 +94,9 @@ const capabilitiesCommandFacet = defineCommandFacet({ definition: capabilitiesCommandDefinition, cliSchema: capabilitiesCliSchema, guidance: { - mcp: { - description: - 'List the commands supported by the selected device or active session. Use device-selection inputs when checking support before a session is open.', - }, + description: + 'List the commands supported by the selected device or active session. Use device-selection inputs when checking support before a session is open.', + cliDetail: 'Select an explicit target with --platform/--device/--udid/--serial.', }, cliReader: commonCliReader, daemonWriter: capabilitiesDaemonWriter, diff --git a/src/commands/management/doctor.ts b/src/commands/management/doctor.ts index 74570d1a4d..131512eb78 100644 --- a/src/commands/management/doctor.ts +++ b/src/commands/management/doctor.ts @@ -28,8 +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; @@ -48,10 +46,12 @@ export const doctorCommandFacet = defineCommandFacet({ definition: doctorCommandDefinition, cliSchema: doctorCliSchema, guidance: { - mcp: { - description: - '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.', - }, + description: + '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.', + 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.', + 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.', }, cliReader: doctorCliReader, daemonWriter: doctorDaemonWriter, diff --git a/src/commands/management/push.ts b/src/commands/management/push.ts index 3688507fad..0d7048f0d9 100644 --- a/src/commands/management/push.ts +++ b/src/commands/management/push.ts @@ -62,8 +62,6 @@ const pushCliSchema = { 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; @@ -103,11 +101,8 @@ const triggerAppEventCommandFacet = defineCommandFacet({ definition: triggerAppEventCommandDefinition, cliSchema: triggerAppEventCliSchema, guidance: { - mcp: { - description: - 'Ask the app to handle an app-defined automation or test event. Call this only for event names and payload shapes the app documents.', - parameters: ['event', 'payload'], - }, + description: + '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.', }, cliReader: triggerAppEventCliReader, daemonWriter: triggerAppEventDaemonWriter, diff --git a/src/commands/metro/index.ts b/src/commands/metro/index.ts index ad8379e04a..7802646b1c 100644 --- a/src/commands/metro/index.ts +++ b/src/commands/metro/index.ts @@ -73,30 +73,30 @@ 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; +const metroCliDetail = + '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).'; + export const metroCliReader: CliReader = (positionals, flags) => { const action = (positionals[0] ?? '').toLowerCase(); if (action !== 'prepare' && action !== 'reload') { @@ -149,10 +149,11 @@ const metroCommandFacet = defineCommandFacet({ definition: metroCommandDefinition, cliSchema: metroCliSchema, guidance: { - mcp: { - description: - "Prepare a React Native development server or reload connected apps using the session's bound development-server settings. Use explicit runtime inputs only when overriding that session binding.", - }, + description: + '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.', + 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.', + cliDetail: metroCliDetail, }, cliReader: metroCliReader, cliOutputFormatter: metroCliOutputFormatters.metro, diff --git a/src/commands/observability/index.ts b/src/commands/observability/index.ts index aab8a75fea..5df42896c2 100644 --- a/src/commands/observability/index.ts +++ b/src/commands/observability/index.ts @@ -129,8 +129,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; @@ -215,11 +213,8 @@ const audioCommandFacet = defineCommandFacet({ definition: audioCommandDefinition, cliSchema: audioCliSchema, guidance: { - mcp: { - description: - 'Measure browser or host-rendered simulator/emulator audio as compact dBFS buckets. Start a probe before requesting its status or stopping it.', - parameters: ['durationMs', 'bucketMs'], - }, + description: + 'Measure browser or host-rendered simulator/emulator audio as compact dBFS buckets. Start a probe before requesting its status or stopping it.', }, cliReader: audioCliReader, daemonWriter: audioDaemonWriter, diff --git a/src/commands/perf/index.ts b/src/commands/perf/index.ts index 3b755345ae..98baafbb64 100644 --- a/src/commands/perf/index.ts +++ b/src/commands/perf/index.ts @@ -53,8 +53,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'], @@ -79,10 +77,12 @@ const perfCommandFacet = defineCommandFacet({ definition: perfCommandDefinition, cliSchema: perfCliSchema, guidance: { - mcp: { - description: - '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.', - }, + description: + '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.', + 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.', + 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.', }, cliReader: perfCliReader, daemonWriter: perfDaemonWriter, diff --git a/src/commands/recording/index.ts b/src/commands/recording/index.ts index 4168847bd1..a87b9e5f36 100644 --- a/src/commands/recording/index.ts +++ b/src/commands/recording/index.ts @@ -63,8 +63,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'], @@ -73,8 +71,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; @@ -110,10 +106,10 @@ const recordCommandFacet = defineCommandFacet({ definition: recordCommandDefinition, cliSchema: recordCliSchema, guidance: { - mcp: { - description: - '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.', - }, + description: + '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.', + 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.', }, cliReader: recordCliReader, daemonWriter: recordDaemonWriter, @@ -126,10 +122,9 @@ const traceCommandFacet = defineCommandFacet({ definition: traceCommandDefinition, cliSchema: traceCliSchema, guidance: { - mcp: { - description: - '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.', - }, + description: + '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.', + cliDetail: 'Pass that path as the same positional argument to start and stop.', }, cliReader: traceCliReader, daemonWriter: traceDaemonWriter, diff --git a/src/commands/replay/index.ts b/src/commands/replay/index.ts index 2f5fff5015..5641abcbb8 100644 --- a/src/commands/replay/index.ts +++ b/src/commands/replay/index.ts @@ -55,7 +55,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,8 +97,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, @@ -206,10 +206,10 @@ const replayCommandFacet = defineCommandFacet({ definition: replayCommandDefinition, cliSchema: replayCliSchema, guidance: { - mcp: { - description: - 'Run a recorded automation script, including compatible Maestro YAML flows. A script without a terminal close leaves its session active for subsequent automation.', - }, + description: + 'Run a recorded automation script, including compatible Maestro YAML flows. A script without a terminal close leaves its session active for subsequent automation.', + 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.', }, cliReader: replayCliReader, daemonWriter: replayDaemonWriter, diff --git a/src/commands/system/index.ts b/src/commands/system/index.ts index bec84b4b67..c11a9d7675 100644 --- a/src/commands/system/index.ts +++ b/src/commands/system/index.ts @@ -209,8 +209,6 @@ const clipboardCliSchema = { const tvRemoteCliSchema = { usageOverride: `tv-remote [press|longpress] ${TV_REMOTE_BUTTON_USAGE} [--duration-ms ]`, listUsageOverride: 'tv-remote press|longpress