diff --git a/AGENTS.md b/AGENTS.md index 5419d7523..fd5a33cd7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -52,12 +52,29 @@ Single-context repo. Read `CONTEXT.md` for domain language and testing/architect - Keep modules small for agent context safety: - target <= 300 LOC per implementation file when practical. - if a file grows past 500 LOC, plan/extract focused submodules before adding new behavior. - - exception: generated files, schema/fixture snapshots, and integration test aggregations. + - if a file grows past 1,000 LOC, treat it as architecture debt unless it is generated data, a fixture snapshot, or an integration test aggregation. + - long guidance/data tables should live behind focused modules instead of sharing a file with parser/runtime logic. + - prefer deep modules over mechanical splits: extract when it improves locality for a concept callers already need, not just to reduce line count. + +## Context Management +- Optimize for one-pass agent reads. A module that requires reading many siblings to understand one change is usually too shallow; a module that hides one concept behind a small interface is usually worth keeping. +- Start with the owning module, then one shared helper, then one downstream caller or adapter. Broaden only when the contract crosses that edge. +- Use targeted symbol searches before opening large files. For files over 500 LOC, search for the relevant type/function/section first, then read a bounded range. +- Do not add unrelated exports just to make tests easier. Test through the public interface when possible; if that is awkward, consider whether the module's interface is too shallow. +- When adding new guidance, examples, schemas, or command metadata, decide whether it belongs in the command surface, CLI grammar, CLI help, MCP projection, or daemon runtime before editing. +- Prefer updating existing domain vocabulary in `CONTEXT.md` when naming a new durable module concept. Do not coin parallel names in docs, tests, and code. ## Routing - Keep `src/daemon.ts` as a thin router. - Keep command names and daemon routing groups centralized in `src/command-catalog.ts`; do not re-create command string sets in handlers or request policy modules. -- Keep CLI/client positional grammar in `src/command-codecs.ts` and its `src/command-codecs/*` command-family modules. CLI commands, typed client methods, and daemon interaction adapters should reuse these codecs instead of duplicating selector/ref/positionals parsing. +- Keep command input/output contracts in the command modules: + - command surface and shared schemas: `src/commands/command-surface.ts`, `src/commands/command-contract.ts`, `src/commands/command-input.ts` + - typed client command execution: `src/commands/client-command-contracts.ts` + - command families: `src/commands/interaction-command-contracts.ts`, `src/commands/batch-command.ts`, with other typed client contracts in `src/commands/client-command-contracts.ts` + - CLI positional/flag grammar: `src/commands/cli-grammar.ts` and `src/commands/cli-grammar/*` + - typed input to daemon request projection: `src/commands/command-projection.ts` + - CLI/client/runtime output projection: `src/commands/cli-output.ts`, `src/commands/client-output.ts`, `src/commands/runtime-output.ts` +- Do not reintroduce CLI-shaped command adapters or schemas as a second source of truth. CLI, Node.js, and MCP should project from command contracts. - Keep `src/daemon/request-router.ts` as request orchestration: auth, diagnostics scope, request admission, locking, handler chain, and fallback dispatch. - Put request policies in focused request modules: - tenant/lease/selector/lock admission: `src/daemon/request-admission.ts` @@ -111,17 +128,18 @@ Single-context repo. Read `CONTEXT.md` for domain language and testing/architect ## Adding a New CLI Flag -A new snapshot/command flag touches up to 7 files in a fixed order. Follow this checklist: +A new snapshot/command flag touches only the layers that need to understand it. Follow this checklist in order: -1. `src/utils/command-schema.ts`: add to `CliFlags` type, `FLAG_DEFINITIONS` array, and the relevant `*_FLAGS` constant (e.g. `SNAPSHOT_FLAGS`). Update the command's `usageOverride` string. -2. `src/utils/snapshot.ts` (or the relevant options type): add to `SnapshotOptions` or equivalent. -3. `src/client-types.ts`: add to `CaptureSnapshotOptions` (or equivalent public options type) **and** `InternalRequestOptions`. -4. `src/client-normalizers.ts`: map the public option name to the internal flag name in `buildFlags`. -5. `src/daemon/context.ts`: add to `DaemonCommandContext` type and `contextFromFlags` function. -6. `src/core/dispatch-context.ts`: add to `DispatchContext` when the flag flows into platform dispatch, then thread it through the relevant dispatcher module. -7. `src/cli/commands/.ts`: pass the flag from `flags.*` to the client call. +1. `src/utils/cli-flags.ts`: add to `CliFlags`, `FLAG_DEFINITIONS`, and the relevant exported flag group (e.g. `SNAPSHOT_FLAGS`). Add the flag to `CLI_COMMAND_OVERRIDES` in `src/utils/cli-command-overrides.ts` for each command that supports it; command names/descriptions come from command contracts unless CLI help needs a specific override. +2. `src/commands/cli-grammar/*`: read the CLI flag into command input when the CLI accepts it. +3. `src/commands/command-projection.ts` and command-family projection helpers: write the input into the daemon request only if the flag affects daemon execution. +4. `src/commands/*-command-contracts.ts`: add or update the command input schema only if the option should be available through Node.js or MCP as structured input. +5. `src/client-types.ts`: update the public typed client option only when the Node.js interface exposes the option. +6. `src/client-normalizers.ts`: update daemon flag normalization only when the request still needs a public-to-internal option translation. +7. `src/daemon/context.ts` and `src/core/dispatch-context.ts`: add the field only when it flows into platform dispatch. +8. Handler/platform modules: thread the option only after the command surface, grammar, and projection prove it belongs there. -Command-only flags (like `find --first`) that don't flow to the platform layer only need steps 1 and the handler file. +Command-only flags (like `find --first`) that do not flow to the platform layer usually stop at steps 1-3. ## Hard Rules - Use process helpers from `src/utils/exec.ts` for TypeScript process execution: `runCmd`, `runCmdStreaming`, `runCmdSync`, `runCmdBackground`, and `runCmdDetached`. Do not import raw `spawn`/`spawnSync` outside `src/utils/exec.ts`; add or extend an exec helper instead. Plain `.mjs` packaging fixtures that cannot import TypeScript helpers should keep child-process usage local and prefer `execFile`/`execFileSync` over spawn. @@ -190,7 +208,7 @@ Command-only flags (like `find --first`) that don't flow to the platform layer o ## Testing Matrix - Docs/skills only: no tests required unless a more specific rule below applies. -- CLI help/guidance changes in `src/utils/command-schema.ts`: run `pnpm exec vitest run src/utils/__tests__/args.test.ts`. +- CLI help/guidance changes in `src/utils/cli-help.ts`, `src/utils/cli-command-overrides.ts`, or `src/utils/command-schema.ts`: run `pnpm exec vitest run src/utils/__tests__/args.test.ts`. - SkillGym prompt/assertion changes: run `pnpm test:skillgym:case `; the script builds local CLI help first. For broad validation, use `pnpm test:skillgym`; append `-- --tag fixture-smoke` or `-- --tag skill-guidance` when validating one suite group. - Non-TS, no behavior impact: no tests unless requested. - Keep tests behavioral; do not assert shapes or cases TypeScript already proves. @@ -208,6 +226,7 @@ Command-only flags (like `find --first`) that don't flow to the platform layer o - Do not run integration tests by default. - Do not inspect both iOS and Android codepaths unless task requires both. - Prefer targeted `git diff -- ` over broad file reads during review. +- Keep long help prose in `src/utils/cli-help.ts`; keep flag definitions in `src/utils/cli-flags.ts`; keep CLI-specific command usage/flag metadata in `src/utils/cli-command-overrides.ts`. - Prefer `snapshot -i`, `find`, and scoped selectors over repeated full snapshot dumps when exploring Apple desktop UIs. - Keep PR summaries short and scoped. @@ -222,9 +241,10 @@ Command-only flags (like `find --first`) that don't flow to the platform layer o - Changing `tsconfig.lib.json`/build tooling without running `pnpm check:tooling`; declaration generation is stricter than `tsc --noEmit`. ## Docs & Skills -- Versioned CLI help is the agent-facing source of truth. Put workflow guidance in `src/utils/command-schema.ts` help topics and assert important copy in `src/utils/__tests__/args.test.ts`. +- Versioned CLI help is the agent-facing source of truth. Put workflow guidance and help-topic prose in `src/utils/cli-help.ts`, keep flag definitions in `src/utils/cli-flags.ts`, keep CLI command overrides in `src/utils/cli-command-overrides.ts`, and assert important copy in `src/utils/__tests__/args.test.ts`. +- Keep parser schema and help rendering separate: `src/utils/command-schema.ts` composes contract-derived command schemas with CLI overrides; `src/utils/cli-help.ts` owns help topics and usage rendering. - Skills are thin routers. Keep `skills/**/SKILL.md` focused on when to use the skill, version gating, which `agent-device help ` page to read, and a short default loop. Do not duplicate full CLI manuals in skills. -- For behavior/CLI surface changes, update the versioned help instructions in `src/utils/command-schema.ts` and assert important help copy in `src/utils/__tests__/args.test.ts`. Also update `README.md` and relevant `website/docs/**` when user-facing docs need it. +- For behavior/CLI surface changes, update the versioned help instructions in `src/utils/cli-help.ts` or the CLI command metadata in `src/utils/cli-command-overrides.ts`, then assert important help copy in `src/utils/__tests__/args.test.ts`. Also update `README.md` and relevant `website/docs/**` when user-facing docs need it. - For behavior/CLI surface changes and command-planning guidance changes, write or update a SkillGym case in `test/skillgym/suites/agent-device-smoke-suite.ts` that captures the expected agent command plan. - Do not update `skills/**/SKILL.md` for command behavior or workflow guidance unless the user explicitly asks; skills must route to versioned CLI help instead of carrying behavior details. - Keep SkillGym cases behavioral and command-planning oriented. Prefer prompts that assert the user-visible contract and expected command family over brittle exact output, but forbid known bad patterns. @@ -245,6 +265,7 @@ Command-only flags (like `find --first`) that don't flow to the platform layer o ## Key Files - CLI parse + formatting: `src/bin.ts`, `src/cli.ts`, `src/utils/args.ts` +- CLI help + option metadata: `src/utils/cli-help.ts`, `src/utils/cli-flags.ts`, `src/utils/cli-command-overrides.ts`, `src/utils/command-schema.ts`, `src/utils/cli-option-schema.ts` - Daemon client transport: `src/daemon-client.ts` - Daemon state/store: `src/daemon/session-store.ts` - Selector DSL and matching: `src/daemon/selectors.ts` @@ -254,7 +275,9 @@ Command-only flags (like `find --first`) that don't flow to the platform layer o - Handler context helpers: `src/daemon/context.ts`, `src/daemon/device-ready.ts` - Request routing/policy: `src/daemon/request-router.ts`, `src/daemon/request-admission.ts`, `src/daemon/request-generic-dispatch.ts` - Dispatcher + capability map: `src/core/dispatch.ts`, `src/core/dispatch-context.ts`, `src/core/dispatch-interactions.ts`, `src/core/capabilities.ts` -- Command catalog + positional codecs: `src/command-catalog.ts`, `src/command-codecs.ts`, `src/command-codecs/*` +- Command catalog + command surface: `src/command-catalog.ts`, `src/commands/command-surface.ts`, `src/commands/command-contract.ts`, `src/commands/client-command-contracts.ts` +- CLI grammar: `src/commands/cli-grammar.ts`, `src/commands/cli-grammar/*` +- Daemon request projection: `src/commands/command-projection.ts` - Platform backends: `src/platforms/ios/*`, `ios-runner/*`, `src/platforms/android/*` ## Pull Requests diff --git a/CONTEXT.md b/CONTEXT.md index 20e0c5a35..fb96db71b 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -13,6 +13,7 @@ - Target: selected automation destination, such as mobile, tv, or desktop. - Modality: broad supported device family, such as mobile, tv, or desktop. - Session: daemon-owned state for a selected target and opened app or surface. +- Command surface: catalog of public command identity, interface exposure, adapter policy, and shared command metadata across CLI, Node.js, MCP, and batch entrypoints. ## Testing Principles diff --git a/README.md b/README.md index 935ab0d28..914707ebc 100644 --- a/README.md +++ b/README.md @@ -83,7 +83,7 @@ Snapshots assign refs like `@e1`, `@e2`, and `@e3` to elements on the current sc ## Next Steps -- **Set up your agent**: run the CLI from Cursor, Codex, Claude Code, Windsurf, or another agent terminal. For skills, rules, MCP discovery, and client-specific setup, see [AI Agent Setup](https://incubator.callstack.com/agent-device/docs/agent-setup). +- **Set up your agent**: run the CLI from Cursor, Codex, Claude Code, Windsurf, or another agent terminal. For skills, rules, direct MCP tools, and client-specific setup, see [AI Agent Setup](https://incubator.callstack.com/agent-device/docs/agent-setup). - **Try the sample app**: clone the repo and run the bundled Expo fixture when you want a guided first dogfood run with screenshots, replay, and performance evidence. See [Quick Start](https://incubator.callstack.com/agent-device/docs/quick-start). - **Go deeper**: use [Commands](https://incubator.callstack.com/agent-device/docs/commands), [Replay & E2E](https://incubator.callstack.com/agent-device/docs/replay-e2e), and [Debugging & Profiling](https://incubator.callstack.com/agent-device/docs/debugging-profiling) for production workflows. diff --git a/scripts/integration-progress.mjs b/scripts/integration-progress.mjs index 6930416e4..e295436ae 100644 --- a/scripts/integration-progress.mjs +++ b/scripts/integration-progress.mjs @@ -9,6 +9,12 @@ const HANDLER_TEST_DIR = path.join(ROOT, 'src/daemon/handlers/__tests__'); const PROVIDER_SCENARIO_DIR = path.join(ROOT, 'test/integration/provider-scenarios'); const COVERAGE_SUMMARY = path.join(ROOT, 'coverage/coverage-summary.json'); const COMMAND_CATALOG = path.join(ROOT, 'src/command-catalog.ts'); +const COMMAND_CONTRACT_FILES = [ + path.join(ROOT, 'src/commands/client-command-contracts.ts'), + path.join(ROOT, 'src/commands/interaction-command-contracts.ts'), +]; +const COMMAND_CATALOG_SOURCE = fs.readFileSync(COMMAND_CATALOG, 'utf8'); +const clientCommandMethods = readClientCommandMethods(); const handlerTests = listFiles(HANDLER_TEST_DIR, (file) => file.endsWith('.test.ts')); const providerScenarioTests = listFiles(PROVIDER_SCENARIO_DIR, (file) => file.endsWith('.test.ts')); @@ -24,8 +30,7 @@ const mockHeavyHandlerRows = summarizeMockHeavyHandlerFiles(mockHeavyHandlerFile const providerPressureRows = summarizeProviderPressure(providerScenarioSources); const publicCommandRows = summarizePublicCommandCoverage(providerScenarioTests); const missingPublicCommands = publicCommandRows.filter((command) => command.references === 0); -const commandFamilyRows = summarizeCommandFamilyOwnership(providerScenarioTests); -const flagCoverageRows = summarizeProviderScenarioFlagCoverage(providerScenarioSources); +const flagCoverageRows = summarizeProviderScenarioFlagCoverage(providerScenarioTests); const missingFlagRows = flagCoverageRows.filter((flag) => flag.references === 0); const excludedFlagRows = summarizeProviderScenarioFlagExclusions(); const publicCliFlagKeys = readPublicCliFlagKeys(); @@ -95,17 +100,6 @@ if (mockHeavyHandlerRows.length > 0) { } } -if (commandFamilyRows.length > 0) { - console.log(''); - console.log('Command family ownership in provider-backed integration'); - console.log(''); - console.log('| Command family | Command references | Files |'); - console.log('| --- | ---: | ---: |'); - for (const family of commandFamilyRows) { - console.log(`| ${family.name} | ${family.references} | ${family.files} |`); - } -} - if (missingPublicCommands.length > 0) { console.log(''); console.log('Public command coverage gaps'); @@ -470,80 +464,6 @@ function summarizeProviderPressure(files) { .filter((surface) => surface.references > 0); } -function summarizeCommandFamilyOwnership(files) { - const commandFamilies = [ - { - name: 'devices/boot/open/close/session/appstate', - commands: ['devices', 'boot', 'open', 'close', 'session_list', 'appstate'], - }, - { - name: 'apps', - commands: ['apps'], - }, - { - name: 'install/reinstall/install-source/push/trigger-event', - commands: ['install', 'reinstall', 'install-from-source', 'push', 'trigger-app-event'], - }, - { - name: 'snapshot/diff/screenshot', - commands: ['snapshot', 'diff', 'screenshot'], - }, - { - name: 'press/click/fill/type/scroll/swipe/gesture/rotate/app-switcher', - commands: [ - 'press', - 'click', - 'focus', - 'longpress', - 'swipe', - 'scroll', - 'gesture', - 'type', - 'fill', - 'rotate', - 'app-switcher', - 'back', - 'home', - ], - }, - { - name: 'get/is/find/wait', - commands: ['get', 'is', 'find', 'wait'], - }, - { - name: 'clipboard/keyboard/settings/alert', - commands: ['clipboard', 'keyboard', 'settings', 'alert'], - }, - { - name: 'record/trace/logs/network/perf/replay/test/batch', - commands: ['record', 'trace', 'logs', 'network', 'perf', 'replay', 'test', 'batch'], - }, - ]; - - const commandRefsByFile = files.map((file) => ({ - file, - commands: extractProviderScenarioCommandReferences(fs.readFileSync(file, 'utf8')), - })); - - return commandFamilies - .map((family) => { - const commands = new Set(family.commands); - let references = 0; - let filesWithReferences = 0; - for (const file of commandRefsByFile) { - const count = file.commands.filter((command) => commands.has(command)).length; - references += count; - if (count > 0) filesWithReferences += 1; - } - return { - name: family.name, - references, - files: filesWithReferences, - }; - }) - .filter((family) => family.references > 0); -} - function summarizePublicCommandCoverage(files) { const publicCommands = readPublicCommands(); const commandRefsByFile = files.map((file) => ({ @@ -564,16 +484,52 @@ function summarizePublicCommandCoverage(files) { } function readPublicCommands() { - const text = fs.readFileSync(COMMAND_CATALOG, 'utf8'); - const match = text.match(/export const PUBLIC_COMMANDS = \{([\s\S]*?)\} as const;/); + return [...readPublicCommandEntries().values()].sort(); +} + +function readPublicCommandEntries() { + const match = COMMAND_CATALOG_SOURCE.match(/export const PUBLIC_COMMANDS = \{([\s\S]*?)\} as const;/); if (!match) { throw new Error('Unable to find PUBLIC_COMMANDS in src/command-catalog.ts'); } - const commands = []; - for (const command of match[1].matchAll(/:\s*'([^']+)'/g)) { - commands.push(command[1]); + const commands = new Map(); + for (const command of match[1].matchAll(/\b([A-Za-z0-9_]+):\s*'([^']+)'/g)) { + commands.set(command[1], command[2]); } - return commands.sort(); + return commands; +} + +function readClientCommandMethods() { + const commands = new Map(); + for (const file of COMMAND_CONTRACT_FILES) { + const text = fs.readFileSync(file, 'utf8'); + for (const block of readCommandContractBlocks(text)) { + for (const method of block.source.matchAll(/\bclient\.([A-Za-z0-9_]+)\.([A-Za-z0-9_]+)\s*\(/g)) { + commands.set(`${method[1]}.${method[2]}`, block.name); + } + } + } + return commands; +} + +function readCommandContractBlocks(text) { + const starts = [ + ...text.matchAll(/defineFieldCommand\(\s*['"]([^'"]+)['"]/g), + ...text.matchAll(/defineCommand\(\s*\{[\s\S]*?\bname:\s*['"]([^'"]+)['"]/g), + ] + .map((match) => ({ + index: match.index ?? 0, + name: match[1], + })) + .sort((a, b) => a.index - b.index); + + return starts.map((start, index) => { + const end = starts[index + 1]?.index ?? text.length; + return { + name: start.name, + source: text.slice(start.index, end), + }; + }); } function extractProviderScenarioCommandReferences(text) { @@ -581,54 +537,7 @@ function extractProviderScenarioCommandReferences(text) { for (const match of text.matchAll(/\bcommand:\s*['"]([^'"]+)['"]|\.callCommand\(\s*['"]([^'"]+)['"]/g)) { commands.push(match[1] ?? match[2]); } - const typedClientCommands = new Map([ - ['devices.list', 'devices'], - ['devices.boot', 'boot'], - ['apps.open', 'open'], - ['apps.close', 'close'], - ['apps.list', 'apps'], - ['apps.install', 'install'], - ['apps.reinstall', 'reinstall'], - ['apps.installFromSource', 'install-from-source'], - ['apps.push', 'push'], - ['apps.triggerEvent', 'trigger-app-event'], - ['command.appState', 'appstate'], - ['command.appSwitcher', 'app-switcher'], - ['command.back', 'back'], - ['command.clipboard', 'clipboard'], - ['command.home', 'home'], - ['command.keyboard', 'keyboard'], - ['command.rotate', 'rotate'], - ['command.wait', 'wait'], - ['capture.diff', 'diff'], - ['capture.screenshot', 'screenshot'], - ['capture.snapshot', 'snapshot'], - ['interactions.click', 'click'], - ['interactions.fill', 'fill'], - ['interactions.find', 'find'], - ['interactions.focus', 'focus'], - ['interactions.get', 'get'], - ['interactions.is', 'is'], - ['interactions.longPress', 'longpress'], - ['interactions.pan', 'gesture'], - ['interactions.fling', 'gesture'], - ['interactions.pinch', 'gesture'], - ['interactions.rotateGesture', 'gesture'], - ['interactions.press', 'press'], - ['interactions.scroll', 'scroll'], - ['interactions.swipe', 'swipe'], - ['interactions.type', 'type'], - ['observability.logs', 'logs'], - ['observability.network', 'network'], - ['observability.perf', 'perf'], - ['recording.record', 'record'], - ['recording.trace', 'trace'], - ['replay.run', 'replay'], - ['replay.test', 'test'], - ['batch.run', 'batch'], - ['settings.update', 'settings'], - ]); - for (const [method, command] of typedClientCommands) { + for (const [method, command] of clientCommandMethods) { const escapedMethod = method.replace('.', '\\.'); const matches = text.match(new RegExp(`\\.${escapedMethod}\\s*\\(`, 'g'))?.length ?? 0; for (let index = 0; index < matches; index += 1) commands.push(command); diff --git a/src/__tests__/cli-batch.test.ts b/src/__tests__/cli-batch.test.ts index c7026afbe..4243da1e1 100644 --- a/src/__tests__/cli-batch.test.ts +++ b/src/__tests__/cli-batch.test.ts @@ -32,7 +32,7 @@ test('batch --steps parses JSON and forwards batchSteps only', async () => { '--platform', 'ios', '--steps', - '[{"command":"open","positionals":["settings"]}]', + '[{"command":"open","input":{"app":"settings"}}]', '--json', ]); assert.equal(result.code, null); @@ -49,7 +49,11 @@ test('batch --steps parses JSON and forwards batchSteps only', async () => { test('batch --steps-file parses file payload', async () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-batch-')); const stepsPath = path.join(tmpDir, 'steps.json'); - fs.writeFileSync(stepsPath, JSON.stringify([{ command: 'wait', positionals: ['100'] }]), 'utf8'); + fs.writeFileSync( + stepsPath, + JSON.stringify([{ command: 'wait', input: { kind: 'duration', durationMs: 100 } }]), + 'utf8', + ); const result = await runCliCapture(['batch', '--steps-file', stepsPath, '--json']); assert.equal(result.code, null); assert.equal(result.calls.length, 1); @@ -58,6 +62,52 @@ test('batch --steps-file parses file payload', async () => { assert.equal((req.flags?.batchSteps ?? [])[0]?.command, 'wait'); }); +test('batch structured interaction target is projected to positionals, not device flags', async () => { + const result = await runCliCapture([ + 'batch', + '--steps', + '[{"command":"press","input":{"target":{"kind":"point","x":10,"y":20},"count":2}}]', + '--json', + ]); + + assert.equal(result.code, null); + assert.equal(result.calls.length, 1); + const step = (result.calls[0]?.flags?.batchSteps ?? [])[0]; + assert.deepEqual(step?.positionals, ['10', '20']); + assert.equal(step?.flags?.target, undefined); + assert.equal(step?.flags?.count, 2); +}); + +test('batch accepts legacy positionals/flags steps with deprecation warning', async () => { + const result = await runCliCapture([ + 'batch', + '--steps', + '[{"command":"open","positionals":["settings"],"flags":{"platform":"ios"}}]', + '--json', + ]); + assert.equal(result.code, null); + assert.match(result.stderr, /positionals\/flags are deprecated.*next major version/); + assert.equal(result.calls.length, 1); + const req = result.calls[0]; + assert.equal(req.command, 'batch'); + assert.deepEqual((req.flags?.batchSteps ?? [])[0], { + command: 'open', + positionals: ['settings'], + flags: { platform: 'ios' }, + runtime: undefined, + }); +}); + +test('batch rejects hybrid structured and legacy step shapes', async () => { + const result = await runCliCapture([ + 'batch', + '--steps', + '[{"command":"open","input":{},"positionals":["settings"]}]', + ]); + assert.equal(result.code, 1); + assert.match(result.stderr, /unknown legacy field\(s\): input/); +}); + test('batch --steps-file returns clear error for missing file', async () => { const result = await runCliCapture([ 'batch', @@ -84,7 +134,7 @@ test('batch forwards strip lock policy for nested steps when bound session uses [ 'batch', '--steps', - '[{"command":"snapshot","flags":{"platform":"android","serial":"emulator-5554"}}]', + '[{"command":"snapshot","input":{"platform":"android","serial":"emulator-5554"}}]', '--json', ], undefined, @@ -107,7 +157,7 @@ test('batch forwards strip lock policy for nested steps when bound session uses test('batch forwards reject lock policy for target retargeting', async () => { const result = await runCliCapture( - ['batch', '--steps', '[{"command":"open","flags":{"target":"tv"}}]', '--json'], + ['batch', '--steps', '[{"command":"open","input":{"target":"tv"}}]', '--json'], undefined, { env: { @@ -130,7 +180,7 @@ test('batch session lock flags apply to nested steps without env configuration', '--session-lock', 'strip', '--steps', - '[{"command":"snapshot","flags":{"target":"tv","serial":"emulator-5554"}}]', + '[{"command":"snapshot","input":{"target":"tv","serial":"emulator-5554"}}]', '--json', ], undefined, @@ -161,7 +211,7 @@ test('batch step without explicit platform inherits parent platform over env def '--platform', 'android', '--steps', - '[{"command":"snapshot"}]', + '[{"command":"snapshot","input":{}}]', '--json', ]); assert.equal(result.code, null); @@ -175,30 +225,33 @@ test('batch step without explicit platform inherits parent platform over env def }); test('batch human output renders per-step results', async () => { - const result = await runCliCapture(['batch', '--steps', '[{"command":"open"}]'], async () => ({ - ok: true, - data: { - total: 2, - executed: 2, - totalDurationMs: 15, - results: [ - { - step: 1, - command: 'open', - ok: true, - data: { appName: 'Settings', message: 'Opened: Settings' }, - durationMs: 7, - }, - { - step: 2, - command: 'type', - ok: true, - data: { text: 'hello', message: 'Typed 5 chars' }, - durationMs: 8, - }, - ], - }, - })); + const result = await runCliCapture( + ['batch', '--steps', '[{"command":"open","input":{}}]'], + async () => ({ + ok: true, + data: { + total: 2, + executed: 2, + totalDurationMs: 15, + results: [ + { + step: 1, + command: 'open', + ok: true, + data: { appName: 'Settings', message: 'Opened: Settings' }, + durationMs: 7, + }, + { + step: 2, + command: 'type', + ok: true, + data: { text: 'hello', message: 'Typed 5 chars' }, + durationMs: 8, + }, + ], + }, + }), + ); assert.equal(result.code, null); assert.match(result.stdout, /Batch completed: 2\/2 steps in 15ms/); @@ -207,30 +260,33 @@ test('batch human output renders per-step results', async () => { }); test('batch human output renders failed steps distinctly', async () => { - const result = await runCliCapture(['batch', '--steps', '[{"command":"open"}]'], async () => ({ - ok: true, - data: { - total: 2, - executed: 1, - totalDurationMs: 15, - results: [ - { - step: 1, - command: 'open', - ok: true, - data: { appName: 'Settings', message: 'Opened: Settings' }, - durationMs: 7, - }, - { - step: 2, - command: 'type', - ok: false, - error: { message: 'type requires text' }, - durationMs: 8, - }, - ], - }, - })); + const result = await runCliCapture( + ['batch', '--steps', '[{"command":"open","input":{}}]'], + async () => ({ + ok: true, + data: { + total: 2, + executed: 1, + totalDurationMs: 15, + results: [ + { + step: 1, + command: 'open', + ok: true, + data: { appName: 'Settings', message: 'Opened: Settings' }, + durationMs: 7, + }, + { + step: 2, + command: 'type', + ok: false, + error: { message: 'type requires text' }, + durationMs: 8, + }, + ], + }, + }), + ); assert.equal(result.code, null); assert.match(result.stdout, /1\. OK Opened: Settings \(7ms\)/); diff --git a/src/__tests__/cli-grammar.test.ts b/src/__tests__/cli-grammar.test.ts new file mode 100644 index 000000000..7b1a13e49 --- /dev/null +++ b/src/__tests__/cli-grammar.test.ts @@ -0,0 +1,82 @@ +import { test } from 'vitest'; +import assert from 'node:assert/strict'; +import { DAEMON_COMMAND_GROUPS, PUBLIC_COMMANDS } from '../command-catalog.ts'; +import { readInputFromCli } from '../commands/cli-grammar.ts'; +import type { CliFlags } from '../utils/command-schema.ts'; + +const BASE_FLAGS: CliFlags = { + json: false, + help: false, + version: false, +}; + +test('command catalog owns daemon routing groups', () => { + assert.equal(DAEMON_COMMAND_GROUPS.snapshot.has(PUBLIC_COMMANDS.wait), true); + assert.equal(DAEMON_COMMAND_GROUPS.observability.has(PUBLIC_COMMANDS.logs), true); + assert.equal(DAEMON_COMMAND_GROUPS.replay.has(PUBLIC_COMMANDS.test), true); +}); + +test('wait grammar preserves CLI bare text forms', () => { + const options = readInputFromCli('wait', ['Continue', '1500'], BASE_FLAGS); + assert.equal(options.text, 'Continue'); + assert.equal(options.timeoutMs, 1500); +}); + +test('interaction and fill grammar share ref, selector, and point parsing', () => { + assert.deepEqual(readInputFromCli('press', ['@e3', 'Email'], BASE_FLAGS).target, { + kind: 'ref', + ref: '@e3', + label: 'Email', + }); + const selectorFill = readInputFromCli('fill', ['id=email', 'qa@example.com'], BASE_FLAGS); + assert.deepEqual(selectorFill.target, { kind: 'selector', selector: 'id=email' }); + assert.equal(selectorFill.text, 'qa@example.com'); + + const refFill = readInputFromCli('fill', ['@e4', 'Email', 'qa@example.com'], BASE_FLAGS); + assert.deepEqual(refFill.target, { kind: 'ref', ref: '@e4', label: 'Email' }); + assert.equal(refFill.text, 'qa@example.com'); + + const pointFill = readInputFromCli('fill', ['10', '20', 'hello'], BASE_FLAGS); + assert.deepEqual(pointFill.target, { kind: 'point', x: 10, y: 20 }); + assert.equal(pointFill.text, 'hello'); + + assert.deepEqual(readInputFromCli('longpress', ['@e4', '800'], BASE_FLAGS), { + target: { kind: 'ref', ref: '@e4' }, + durationMs: 800, + }); + assert.deepEqual(readInputFromCli('longpress', ['10', '20', '800'], BASE_FLAGS), { + target: { kind: 'point', x: 10, y: 20 }, + durationMs: 800, + }); +}); + +test('find and is grammar decodes command action positionals', () => { + const findOptions = readInputFromCli('find', ['label', 'Continue', 'wait', '3000'], { + ...BASE_FLAGS, + platform: 'ios', + findFirst: true, + }); + assert.equal(findOptions.platform, 'ios'); + assert.equal(findOptions.locator, 'label'); + assert.equal(findOptions.query, 'Continue'); + assert.equal(findOptions.action, 'wait'); + assert.equal(findOptions.timeoutMs, 3000); + assert.equal(findOptions.first, true); + + const isOptions = readInputFromCli('is', ['text', 'id=title', 'Welcome'], BASE_FLAGS); + assert.equal(isOptions.predicate, 'text'); + assert.equal(isOptions.selector, 'id=title'); + assert.equal(isOptions.value, 'Welcome'); +}); + +test('settings grammar owns positional parsing for CLI commands', () => { + const location = readInputFromCli('settings', ['location', 'set', '37.3349', '-122.009'], { + ...BASE_FLAGS, + platform: 'ios', + }); + assert.equal(location.platform, 'ios'); + assert.equal(location.setting, 'location'); + assert.equal(location.state, 'set'); + assert.equal(location.latitude, 37.3349); + assert.equal(location.longitude, -122.009); +}); diff --git a/src/__tests__/client.test.ts b/src/__tests__/client.test.ts index 89791f066..489e61948 100644 --- a/src/__tests__/client.test.ts +++ b/src/__tests__/client.test.ts @@ -1,6 +1,7 @@ import { test } from 'vitest'; import assert from 'node:assert/strict'; import { createAgentDeviceClient, type AgentDeviceClientConfig } from '../client.ts'; +import { runCommand } from '../commands/command-surface.ts'; import type { DaemonRequest, DaemonResponse } from '../contracts.ts'; import { AppError } from '../utils/errors.ts'; @@ -118,6 +119,121 @@ test('apps.open forwards explicit runtime hints through the daemon request', asy }); }); +test('structured command input accepts target as deviceTarget alias when no UI target exists', async () => { + const setup = createTransport(async (req) => { + if (req.command === 'open') { + return { + ok: true, + data: { + session: 'qa', + appName: 'Settings', + appBundleId: 'com.apple.Preferences', + platform: 'ios', + target: 'tv', + device: 'Apple TV', + id: 'TV-001', + kind: 'simulator', + }, + }; + } + throw new Error(`Unexpected command: ${req.command}`); + }); + const client = createAgentDeviceClient(setup.config, { transport: setup.transport }); + + await runCommand(client, 'open', { app: 'Settings', target: 'tv' }); + + assert.equal(setup.calls.length, 1); + assert.equal(setup.calls[0]?.command, 'open'); + assert.deepEqual(setup.calls[0]?.positionals, ['Settings']); + assert.equal(setup.calls[0]?.flags?.target, 'tv'); +}); + +test('structured interaction input keeps UI target separate from deviceTarget', async () => { + const setup = createTransport(async (req) => { + if (req.command === 'get' || req.command === 'longpress') { + return { + ok: true, + data: { ok: true }, + }; + } + throw new Error(`Unexpected command: ${req.command}`); + }); + const client = createAgentDeviceClient(setup.config, { transport: setup.transport }); + + await runCommand(client, 'get', { + deviceTarget: 'mobile', + format: 'text', + target: { kind: 'ref', ref: '@e1' }, + }); + await runCommand(client, 'longpress', { + deviceTarget: 'mobile', + durationMs: 800, + target: { kind: 'ref', ref: '@e2' }, + }); + + assert.equal(setup.calls.length, 2); + assert.equal(setup.calls[0]?.command, 'get'); + assert.deepEqual(setup.calls[0]?.positionals, ['text', '@e1']); + assert.equal(setup.calls[0]?.flags?.target, 'mobile'); + assert.equal(setup.calls[1]?.command, 'longpress'); + assert.deepEqual(setup.calls[1]?.positionals, ['@e2', '800']); + assert.equal(setup.calls[1]?.flags?.target, 'mobile'); +}); + +test('apps.installFromSource forwards source payload and normalizes launch identity', async () => { + const setup = createTransport(async () => ({ + ok: true, + data: { + packageName: 'com.example.demo', + appName: 'Demo', + launchTarget: 'com.example.demo', + installablePath: '/tmp/materialized/installable/demo.apk', + archivePath: '/tmp/materialized/archive/demo.zip', + materializationId: 'materialized-123', + materializationExpiresAt: '2026-03-13T12:00:00.000Z', + }, + })); + const client = createAgentDeviceClient(setup.config, { transport: setup.transport }); + + const result = await client.apps.installFromSource({ + platform: 'android', + retainPaths: true, + retentionMs: 60_000, + source: { + kind: 'url', + url: 'https://example.com/demo.apk', + headers: { authorization: 'Bearer token' }, + }, + }); + + assert.equal(setup.calls.length, 1); + assert.equal(setup.calls[0]?.command, 'install_source'); + assert.deepEqual(setup.calls[0]?.meta?.installSource, { + kind: 'url', + url: 'https://example.com/demo.apk', + headers: { authorization: 'Bearer token' }, + }); + assert.equal(setup.calls[0]?.meta?.retainMaterializedPaths, true); + assert.equal(setup.calls[0]?.meta?.materializedPathRetentionMs, 60_000); + assert.deepEqual(result, { + appName: 'Demo', + appId: 'com.example.demo', + bundleId: undefined, + packageName: 'com.example.demo', + launchTarget: 'com.example.demo', + installablePath: '/tmp/materialized/installable/demo.apk', + archivePath: '/tmp/materialized/archive/demo.zip', + materializationId: 'materialized-123', + materializationExpiresAt: '2026-03-13T12:00:00.000Z', + identifiers: { + session: 'qa', + appId: 'com.example.demo', + appBundleId: undefined, + package: 'com.example.demo', + }, + }); +}); + test('apps.installFromSource derives Android launchTarget from packageName when daemon omits it', async () => { const setup = createTransport(async () => ({ ok: true, diff --git a/src/__tests__/command-codecs.test.ts b/src/__tests__/command-codecs.test.ts deleted file mode 100644 index 33ec8be1b..000000000 --- a/src/__tests__/command-codecs.test.ts +++ /dev/null @@ -1,124 +0,0 @@ -import { test } from 'vitest'; -import assert from 'node:assert/strict'; -import { - fillCommandCodec, - findCommandCodec, - interactionTargetCodec, - isCommandCodec, - longPressCommandCodec, - settingsCommandCodec, - waitCommandCodec, -} from '../command-codecs.ts'; -import type { CliFlags } from '../utils/command-schema.ts'; - -const BASE_FLAGS: CliFlags = { - json: false, - help: false, - version: false, -}; - -test('wait codec preserves CLI bare text and client selector forms', () => { - const options = waitCommandCodec.decode(['Continue', '1500'], BASE_FLAGS); - assert.equal(options.text, 'Continue'); - assert.equal(options.timeoutMs, 1500); - assert.deepEqual( - waitCommandCodec.encode({ - selector: 'id=submit', - timeoutMs: 2000, - }), - ['id=submit', '2000'], - ); -}); - -test('interaction and fill codecs share ref, selector, and point grammar', () => { - assert.deepEqual(interactionTargetCodec.decode(['@e3', 'Email']), { - ref: '@e3', - label: 'Email', - }); - assert.deepEqual(interactionTargetCodec.encode({ selector: 'id=submit' }), ['id=submit']); - assert.deepEqual(fillCommandCodec.decode(['id=email', 'qa@example.com']), { - kind: 'selector', - target: { selector: 'id=email' }, - text: 'qa@example.com', - }); - assert.deepEqual(fillCommandCodec.decode(['@e4', 'Email', 'qa@example.com']), { - kind: 'ref', - target: { ref: '@e4', label: 'Email' }, - text: 'qa@example.com', - }); - assert.deepEqual(fillCommandCodec.decode(['10', '20', 'hello']), { - kind: 'point', - target: { x: 10, y: 20 }, - text: 'hello', - }); - assert.deepEqual( - fillCommandCodec.encode({ - ref: '@e4', - label: 'Email', - text: 'qa@example.com', - }), - ['@e4', 'Email', 'qa@example.com'], - ); - assert.deepEqual(longPressCommandCodec.decode(['@e4', '800']), { - ref: '@e4', - durationMs: 800, - }); - assert.deepEqual(longPressCommandCodec.decode(['10', '20', '800']), { - x: 10, - y: 20, - durationMs: 800, - }); - assert.deepEqual( - longPressCommandCodec.encode({ selector: 'label="Last message"', durationMs: 800 }), - ['label="Last message"', '800'], - ); -}); - -test('find and is codecs round-trip command action positionals', () => { - const findOptions = findCommandCodec.decode(['label', 'Continue', 'wait', '3000'], { - ...BASE_FLAGS, - platform: 'ios', - findFirst: true, - }); - assert.equal(findOptions.platform, 'ios'); - assert.equal(findOptions.locator, 'label'); - assert.equal(findOptions.query, 'Continue'); - assert.equal(findOptions.action, 'wait'); - assert.equal(findOptions.timeoutMs, 3000); - assert.equal(findOptions.first, true); - assert.deepEqual(findCommandCodec.encode(findOptions), ['label', 'Continue', 'wait', '3000']); - - const isOptions = isCommandCodec.decode(['text', 'id=title', 'Welcome'], BASE_FLAGS); - assert.equal(isOptions.predicate, 'text'); - assert.equal(isOptions.selector, 'id=title'); - assert.equal(isOptions.value, 'Welcome'); - assert.deepEqual(isCommandCodec.encode(isOptions), ['text', 'id=title', 'Welcome']); -}); - -test('settings codec owns positional grammar for command and client paths', () => { - const location = settingsCommandCodec.decode(['location', 'set', '37.3349', '-122.009'], { - ...BASE_FLAGS, - platform: 'ios', - }); - assert.equal(location.platform, 'ios'); - assert.equal(location.setting, 'location'); - assert.equal(location.state, 'set'); - assert.equal(location.latitude, 37.3349); - assert.equal(location.longitude, -122.009); - assert.deepEqual(settingsCommandCodec.encode(location), [ - 'location', - 'set', - '37.3349', - '-122.009', - ]); - - assert.deepEqual( - settingsCommandCodec.encode({ - setting: 'permission', - state: 'grant', - permission: 'camera', - mode: 'limited', - }), - ['permission', 'grant', 'camera', 'limited'], - ); -}); diff --git a/src/__tests__/remote-connection.test.ts b/src/__tests__/remote-connection.test.ts index 4bed1346f..225b24afd 100644 --- a/src/__tests__/remote-connection.test.ts +++ b/src/__tests__/remote-connection.test.ts @@ -489,7 +489,7 @@ test('deferred materialization prepares Metro for batch when a step opens an app metroPublicBaseUrl: 'https://sandbox.example.test', metroProxyBaseUrl: 'https://proxy.example.test', }, - batchSteps: [{ command: 'open', positionals: ['com.example.demo'] }], + batchSteps: [{ command: 'open', input: { app: 'com.example.demo' } }], client: createTestClient(), }); diff --git a/src/batch.ts b/src/batch.ts index 5de128059..a9b2ec4b7 100644 --- a/src/batch.ts +++ b/src/batch.ts @@ -11,7 +11,7 @@ export type { BatchFlags, BatchInvoke, BatchRequest, - BatchStep, + DaemonBatchStep, BatchStepResult, NormalizedBatchStep, } from './core/batch.ts'; diff --git a/src/cli.ts b/src/cli.ts index 6eb772418..065de4167 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -5,7 +5,7 @@ import { readVersion } from './utils/version.ts'; import { pathToFileURL } from 'node:url'; import { sendToDaemon } from './daemon-client.ts'; import fs from 'node:fs'; -import { parseBatchStepsJson, type BatchStep } from './core/batch.ts'; +import type { BatchStep } from './client-types.ts'; import { createAgentDeviceClient, type AgentDeviceClientConfig, @@ -14,6 +14,7 @@ import { import { materializeRemoteConnectionForCommand } from './cli/commands/connection-runtime.ts'; import { tryRunClientBackedCommand } from './cli/commands/router.ts'; import { runReactDevtoolsCommand } from './cli/commands/react-devtools.ts'; +import { readCliBatchStepsJson } from './cli/batch-steps.ts'; import { createRequestId, emitDiagnostic, @@ -295,10 +296,10 @@ export async function runCli(argv: string[], deps: CliDeps = DEFAULT_CLI_DEPS): } const batchSteps = parsedBatchSteps.map((step, _index) => ({ ...step, - flags: + input: binding.lockPolicy && flags.platform === undefined - ? { ...((step.flags ?? {}) as Partial) } - : applyDefaultPlatformBinding((step.flags ?? {}) as Partial, { + ? { ...step.input } + : applyDefaultPlatformBinding(step.input, { policyOverrides: effectiveFlags, configuredPlatform: effectiveFlags.platform, configuredSession: effectiveFlags.session, @@ -384,7 +385,7 @@ function readBatchSteps(flags: ReturnType['flags']): B ); } } - return parseBatchStepsJson(raw); + return readCliBatchStepsJson(raw); } function isDaemonStartupFailure(error: AppError): boolean { diff --git a/src/cli/batch-steps.ts b/src/cli/batch-steps.ts new file mode 100644 index 000000000..70b56a197 --- /dev/null +++ b/src/cli/batch-steps.ts @@ -0,0 +1,132 @@ +import type { BatchStep } from '../client-types.ts'; +import { readInputFromCli } from '../commands/cli-grammar.ts'; +import { isCommandName, type CommandName } from '../commands/command-surface.ts'; +import type { CliFlags } from '../utils/command-schema.ts'; +import { AppError } from '../utils/errors.ts'; + +type LegacyCliBatchStep = { + command: CommandName; + positionals?: string[]; + flags?: Record; + runtime?: unknown; +}; + +export function readCliBatchStepsJson(raw: string): BatchStep[] { + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + throw new AppError('INVALID_ARGS', 'Batch steps must be valid JSON.'); + } + if (!Array.isArray(parsed) || parsed.length === 0) { + throw new AppError('INVALID_ARGS', 'Batch steps must be a non-empty JSON array.'); + } + return normalizeCliBatchSteps(parsed); +} + +function normalizeCliBatchSteps(steps: unknown[]): BatchStep[] { + let sawLegacyStep = false; + const normalized = steps.map((step, index) => { + if (isStructuredBatchStep(step)) return step; + const legacyStep = readLegacyCliBatchStep(step, index + 1); + sawLegacyStep = true; + return legacyStepToStructuredStep(legacyStep); + }); + if (sawLegacyStep) { + process.stderr.write( + 'Warning: batch steps using positionals/flags are deprecated and will be removed in the next major version. Use {"command":"...","input":{...}} steps instead.\n', + ); + } + return normalized; +} + +function legacyStepToStructuredStep(legacyStep: LegacyCliBatchStep): BatchStep { + const input = readInputFromCli( + legacyStep.command, + legacyStep.positionals ?? [], + cliFlagsFromBatchStep(legacyStep.flags), + ); + return { + command: legacyStep.command, + input, + ...(legacyStep.runtime === undefined ? {} : { runtime: legacyStep.runtime }), + }; +} + +function isStructuredBatchStep(step: unknown): step is BatchStep { + return ( + step !== null && + typeof step === 'object' && + !Array.isArray(step) && + 'input' in step && + !('positionals' in step) && + !('flags' in step) + ); +} + +function readLegacyCliBatchStep(step: unknown, stepNumber: number): LegacyCliBatchStep { + if (!step || typeof step !== 'object' || Array.isArray(step)) { + throw new AppError('INVALID_ARGS', `Invalid batch step ${stepNumber}.`); + } + const record = step as Record; + assertLegacyBatchStepKeys(record, stepNumber); + const command = readLegacyCommand(record.command, stepNumber); + const positionals = readLegacyPositionals(record.positionals, stepNumber); + const flags = readLegacyFlags(record.flags, stepNumber); + return { + command, + ...(positionals === undefined ? {} : { positionals }), + ...(flags === undefined ? {} : { flags }), + ...(record.runtime === undefined ? {} : { runtime: record.runtime }), + }; +} + +function readLegacyCommand(value: unknown, stepNumber: number): CommandName { + const command = typeof value === 'string' ? value.trim().toLowerCase() : ''; + if (!command) throw new AppError('INVALID_ARGS', `Batch step ${stepNumber} requires command.`); + if (isCommandName(command)) return command; + throw new AppError( + 'INVALID_ARGS', + `Batch step ${stepNumber} command is not available through command batch: ${String(value)}`, + ); +} + +function assertLegacyBatchStepKeys(record: Record, stepNumber: number): void { + const unknownKeys = Object.keys(record).filter( + (key) => !['command', 'positionals', 'flags', 'runtime'].includes(key), + ); + if (unknownKeys.length > 0) { + throw new AppError( + 'INVALID_ARGS', + `Batch step ${stepNumber} has unknown legacy field(s): ${unknownKeys.join(', ')}.`, + ); + } +} + +function readLegacyPositionals(value: unknown, stepNumber: number): string[] | undefined { + if (value === undefined) return undefined; + if (!Array.isArray(value) || value.some((item) => typeof item !== 'string')) { + throw new AppError( + 'INVALID_ARGS', + `Batch step ${stepNumber} positionals must contain only strings.`, + ); + } + return value; +} + +function readLegacyFlags(value: unknown, stepNumber: number): Record | undefined { + if (value === undefined) return undefined; + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new AppError('INVALID_ARGS', `Batch step ${stepNumber} flags must be an object.`); + } + return value as Record; +} + +function cliFlagsFromBatchStep(flags: Record | undefined): CliFlags { + return { + json: false, + help: false, + version: false, + ...(flags as Partial | undefined), + }; +} diff --git a/src/cli/commands/apps.ts b/src/cli/commands/apps.ts deleted file mode 100644 index 2a8285f25..000000000 --- a/src/cli/commands/apps.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { buildSelectionOptions, writeCommandOutput } from './shared.ts'; -import { assertResolvedAppsFilter } from '../../commands/app-inventory-contract.ts'; -import type { ClientCommandHandler } from './router-types.ts'; - -export const appsCommand: ClientCommandHandler = async ({ flags, client }) => { - const appsFilter = assertResolvedAppsFilter(flags.appsFilter); - const apps = await client.apps.list({ - ...buildSelectionOptions(flags), - appsFilter, - }); - const data = { apps }; - writeCommandOutput(flags, data, () => { - if (!flags.json) { - process.stderr.write( - appsFilter === 'all' - ? 'Showing all apps, including system apps.\n' - : 'Showing user-installed apps. Use --all to include system apps.\n', - ); - } - if (apps.length > 0) return apps.join('\n'); - return appsFilter === 'all' ? 'No apps found.' : 'No user-installed apps found.'; - }); - return true; -}; diff --git a/src/cli/commands/client-command.ts b/src/cli/commands/client-command.ts deleted file mode 100644 index 871c37da0..000000000 --- a/src/cli/commands/client-command.ts +++ /dev/null @@ -1,218 +0,0 @@ -import type { - AlertCommandOptions, - AppStateCommandResult, - ClipboardCommandOptions, - ClipboardCommandResult, - KeyboardCommandOptions, - KeyboardCommandResult, - RotateCommandOptions, -} from '../../client.ts'; -import type { CliFlags } from '../../utils/command-schema.ts'; -import { AppError } from '../../utils/errors.ts'; -import { readCommandMessage } from '../../utils/success-text.ts'; -import { isKeyboardAction } from '../../utils/keyboard-actions.ts'; -import { waitCommandCodec } from '../../command-codecs.ts'; -import { parseDeviceRotation } from '../../core/device-rotation.ts'; -import { buildSelectionOptions, writeCommandMessage, writeCommandOutput } from './shared.ts'; -import type { ClientCommandHandlerMap } from './router-types.ts'; - -export const clientCommandMethodHandlers = { - wait: async ({ positionals, flags, client }) => { - writeCommandMessage( - flags, - await client.command.wait(waitCommandCodec.decode(positionals, flags)), - ); - return true; - }, - alert: async ({ positionals, flags, client }) => { - writeCommandMessage(flags, await client.command.alert(readAlertOptions(positionals, flags))); - return true; - }, - appstate: async ({ flags, client }) => { - const result = await client.command.appState(buildSelectionOptions(flags)); - writeCommandOutput(flags, result, () => formatAppState(result)); - return true; - }, - back: async ({ flags, client }) => { - writeCommandMessage( - flags, - await client.command.back({ ...buildSelectionOptions(flags), mode: flags.backMode }), - ); - return true; - }, - home: async ({ flags, client }) => { - writeCommandMessage(flags, await client.command.home(buildSelectionOptions(flags))); - return true; - }, - rotate: async ({ positionals, flags, client }) => { - writeCommandMessage(flags, await client.command.rotate(readRotateOptions(positionals, flags))); - return true; - }, - 'app-switcher': async ({ flags, client }) => { - writeCommandMessage(flags, await client.command.appSwitcher(buildSelectionOptions(flags))); - return true; - }, - keyboard: async ({ positionals, flags, client }) => { - writeKeyboardOutput( - flags, - await client.command.keyboard(readKeyboardOptions(positionals, flags)), - ); - return true; - }, - clipboard: async ({ positionals, flags, client }) => { - writeClipboardOutput( - flags, - await client.command.clipboard(readClipboardOptions(positionals, flags)), - ); - return true; - }, -} satisfies ClientCommandHandlerMap; - -function readAlertOptions(positionals: string[], flags: CliFlags): AlertCommandOptions { - if (positionals.length > 2) { - throw new AppError('INVALID_ARGS', 'alert accepts at most action and timeout arguments.'); - } - const action = readAlertAction(positionals[0]); - const timeoutMs = readFiniteNumber(positionals[1], 'alert timeout'); - return { - ...buildSelectionOptions(flags), - ...(action ? { action } : {}), - ...(timeoutMs !== undefined ? { timeoutMs } : {}), - }; -} - -function readRotateOptions(positionals: string[], flags: CliFlags): RotateCommandOptions { - if (positionals.length > 1) { - throw new AppError('INVALID_ARGS', 'rotate accepts exactly one orientation argument.'); - } - return { - ...buildSelectionOptions(flags), - orientation: parseDeviceRotation(positionals[0]), - }; -} - -function readKeyboardOptions(positionals: string[], flags: CliFlags): KeyboardCommandOptions { - if (positionals.length > 1) { - throw new AppError('INVALID_ARGS', 'keyboard accepts at most one action argument.'); - } - const action = readKeyboardAction(positionals[0]); - return { - ...buildSelectionOptions(flags), - ...(action ? { action } : {}), - }; -} - -function writeKeyboardOutput(flags: CliFlags, result: KeyboardCommandResult): void { - writeCommandOutput(flags, result, () => { - if (result.platform === 'android' && result.action === 'status') { - const lines = [ - `Keyboard visible: ${result.visible === true ? 'yes' : 'no'}`, - `Input type: ${result.type ?? result.inputType ?? 'unknown'}`, - `Input owner: ${result.inputOwner ?? 'unknown'}`, - ]; - if (result.inputMethodPackage) lines.push(`Input method: ${result.inputMethodPackage}`); - if (result.focusedPackage) lines.push(`Focused package: ${result.focusedPackage}`); - if (result.focusedResourceId) lines.push(`Focused resource: ${result.focusedResourceId}`); - lines.push(`Next action: ${androidKeyboardNextAction(result.visible, result.inputOwner)}`); - return lines.join('\n'); - } - return readCommandMessage(result); - }); -} - -function androidKeyboardNextAction( - visible: boolean | undefined, - inputOwner: KeyboardCommandResult['inputOwner'], -): string { - if (inputOwner === 'ime') { - return 'Focused input appears to be owned by the keyboard/IME; dismiss or change the IME before retrying text entry.'; - } - if (visible === true) { - return 'Keyboard is visible and focused input appears app-owned; fill/type can proceed.'; - } - return 'Keyboard is hidden; focus an app field before type, or use fill with a concrete target.'; -} - -function readClipboardOptions(positionals: string[], flags: CliFlags): ClipboardCommandOptions { - const action = positionals[0]?.toLowerCase(); - if (action !== 'read' && action !== 'write') { - throw new AppError('INVALID_ARGS', 'clipboard requires a subcommand: read or write.'); - } - const base = buildSelectionOptions(flags); - if (action === 'read') { - if (positionals.length !== 1) { - throw new AppError('INVALID_ARGS', 'clipboard read does not accept additional arguments.'); - } - return { ...base, action }; - } - if (positionals.length < 2) { - throw new AppError('INVALID_ARGS', 'clipboard write requires text.'); - } - return { - ...base, - action, - text: positionals.slice(1).join(' '), - }; -} - -function readAlertAction(value: string | undefined): AlertCommandOptions['action'] | undefined { - const action = value?.toLowerCase(); - if ( - action === undefined || - action === 'get' || - action === 'accept' || - action === 'dismiss' || - action === 'wait' - ) { - return action; - } - throw new AppError('INVALID_ARGS', 'alert action must be get, accept, dismiss, or wait.'); -} - -function readKeyboardAction( - value: string | undefined, -): KeyboardCommandOptions['action'] | undefined { - const action = value?.toLowerCase(); - if (action === 'get') return 'status'; - if (action === undefined || (isKeyboardAction(action) && action !== 'get')) { - return action; - } - throw new AppError( - 'INVALID_ARGS', - 'keyboard action must be status, get, dismiss, enter, or return.', - ); -} - -function readFiniteNumber(value: string | undefined, label: string): number | undefined { - if (value === undefined) return undefined; - const parsed = Number(value); - if (Number.isFinite(parsed)) return parsed; - throw new AppError('INVALID_ARGS', `${label} must be a finite number.`); -} - -function formatAppState(data: AppStateCommandResult): string | null { - if (data.platform === 'ios') { - const lines = [`Foreground app: ${data.appName ?? data.appBundleId ?? 'unknown'}`]; - if (data.appBundleId) lines.push(`Bundle: ${data.appBundleId}`); - if (data.source) lines.push(`Source: ${data.source}`); - return lines.join('\n'); - } - if (data.platform === 'android') { - const lines = [`Foreground app: ${data.package ?? 'unknown'}`]; - if (data.activity) lines.push(`Activity: ${data.activity}`); - return lines.join('\n'); - } - return null; -} - -function writeClipboardOutput(flags: CliFlags, result: ClipboardCommandResult): void { - if (flags.json) { - writeCommandOutput(flags, result); - return; - } - if (result.action === 'read') { - process.stdout.write(`${result.text}\n`); - return; - } - writeCommandMessage(flags, result); -} diff --git a/src/cli/commands/connection-runtime.ts b/src/cli/commands/connection-runtime.ts index 791350dd1..11930be38 100644 --- a/src/cli/commands/connection-runtime.ts +++ b/src/cli/commands/connection-runtime.ts @@ -11,7 +11,7 @@ import { type RemoteConnectionState, } from '../../remote-connection-state.ts'; import { profileToCliFlags } from '../../utils/remote-config.ts'; -import type { BatchStep } from '../../core/batch.ts'; +import type { BatchStep } from '../../client-types.ts'; import { AppError } from '../../utils/errors.ts'; import type { LeaseBackend, SessionRuntimeHints } from '../../contracts.ts'; import type { CliFlags } from '../../utils/command-schema.ts'; diff --git a/src/cli/commands/devices.ts b/src/cli/commands/devices.ts deleted file mode 100644 index 57d14e361..000000000 --- a/src/cli/commands/devices.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { serializeDevice } from '../../client-shared.ts'; -import type { AgentDeviceDevice } from '../../client.ts'; -import { buildSelectionOptions, writeCommandOutput } from './shared.ts'; -import type { ClientCommandHandler } from './router-types.ts'; - -export const devicesCommand: ClientCommandHandler = async ({ flags, client }) => { - const devices = await client.devices.list(buildSelectionOptions(flags)); - const data = { devices: devices.map(serializeDevice) }; - writeCommandOutput(flags, data, () => devices.map(formatDeviceLine).join('\n')); - return true; -}; - -function formatDeviceLine(device: AgentDeviceDevice): string { - const kind = device.kind ? ` ${device.kind}` : ''; - const target = device.target ? ` target=${device.target}` : ''; - const booted = typeof device.booted === 'boolean' ? ` booted=${device.booted}` : ''; - return `${device.name} (${device.platform}${kind}${target})${booted}`; -} diff --git a/src/cli/commands/generic.ts b/src/cli/commands/generic.ts index e8c2be820..19e0cda49 100644 --- a/src/cli/commands/generic.ts +++ b/src/cli/commands/generic.ts @@ -1,22 +1,14 @@ import type { AgentDeviceClient, CommandRequestResult } from '../../client.ts'; -import type { RecordOptions } from '../../client-types.ts'; -import { announceReplayTestRun } from '../../cli-test.ts'; -import { runTypeCliCommand } from '../../commands/interactions/cli.ts'; -import { AppError } from '../../utils/errors.ts'; +import { announceReplayTestRun, renderReplayTestResponse } from '../../cli-test.ts'; +import { listCliOutputCommandNames } from '../../commands/cli-output.ts'; +import { runCliCommand, runCliCommandWithOutput } from '../../commands/cli-runner.ts'; +import { listCommandNames, type CommandName } from '../../commands/command-surface.ts'; +import type { CliOutput } from '../../commands/command-contract.ts'; +import type { ReplaySuiteResult } from '../../daemon/types.ts'; import type { CliFlags } from '../../utils/command-schema.ts'; -import { - elementTargetCodec, - fillCommandCodec, - findCommandCodec, - interactionTargetCodec, - isCommandCodec, - longPressCommandCodec, - settingsCommandCodec, -} from '../../command-codecs.ts'; -import { selectorSnapshotOptionsFromFlags } from '../../command-codecs/flags.ts'; -import { buildSelectionOptions } from './shared.ts'; -import { writeCommandCliOutput } from './output.ts'; -import { GESTURE_SUBCOMMAND_ERROR, type PublicCommandName } from '../../command-catalog.ts'; +import { readCommandMessage } from '../../utils/success-text.ts'; +import { writeCommandOutput } from './shared.ts'; +import type { PublicCommandName } from '../../command-catalog.ts'; import type { ClientCommandHandler } from './router-types.ts'; type GenericClientCommandRunner = (params: { @@ -25,231 +17,25 @@ type GenericClientCommandRunner = (params: { flags: CliFlags; }) => Promise; -const genericClientCommandRunners = { - boot: ({ client, flags }) => - client.devices.boot({ ...buildSelectionOptions(flags), headless: flags.headless }), - push: ({ client, positionals, flags }) => - client.apps.push({ - ...buildSelectionOptions(flags), - app: required(positionals[0], 'push requires bundleOrPackage'), - payload: required(positionals[1], 'push requires payloadOrJson'), - }), - perf: ({ client, flags }) => client.observability.perf(buildSelectionOptions(flags)), - click: ({ client, positionals, flags }) => - client.interactions.click({ - ...interactionTargetCodec.decode(positionals), - ...selectorSnapshotOptionsFromFlags(flags), - ...buildSelectionOptions(flags), - count: flags.count, - intervalMs: flags.intervalMs, - holdMs: flags.holdMs, - jitterPx: flags.jitterPx, - doubleTap: flags.doubleTap, - button: flags.clickButton, - }), - get: ({ client, positionals, flags }) => - client.interactions.get({ - ...elementTargetCodec.decode(positionals.slice(1)), - ...selectorSnapshotOptionsFromFlags(flags), - ...buildSelectionOptions(flags), - format: readGetFormat(positionals[0]), - }), - replay: ({ client, positionals, flags }) => - client.replay.run({ - ...buildSelectionOptions(flags), - path: required(positionals[0], 'replay requires path'), - update: flags.replayUpdate, - backend: flags.replayMaestro ? 'maestro' : undefined, - env: flags.replayEnv, - timeoutMs: flags.timeoutMs, - }), - test: ({ client, positionals, flags }) => { - announceReplayTestRun({ json: flags.json }); - return client.replay.test({ - ...buildSelectionOptions(flags), - paths: positionals, - update: flags.replayUpdate, - backend: flags.replayMaestro ? 'maestro' : undefined, - env: flags.replayEnv, - failFast: flags.failFast, - timeoutMs: flags.timeoutMs, - retries: flags.retries, - artifactsDir: flags.artifactsDir, - reportJunit: flags.reportJunit, - }); - }, - batch: ({ client, flags }) => - client.batch.run({ - ...buildSelectionOptions(flags), - steps: flags.batchSteps ?? [], - onError: flags.batchOnError, - maxSteps: flags.batchMaxSteps, - out: flags.out, - }), - press: ({ client, positionals, flags }) => - client.interactions.press({ - ...interactionTargetCodec.decode(positionals), - ...selectorSnapshotOptionsFromFlags(flags), - ...buildSelectionOptions(flags), - count: flags.count, - intervalMs: flags.intervalMs, - holdMs: flags.holdMs, - jitterPx: flags.jitterPx, - doubleTap: flags.doubleTap, - }), - longpress: ({ client, positionals, flags }) => - client.interactions.longPress({ - ...longPressCommandCodec.decode(positionals), - ...selectorSnapshotOptionsFromFlags(flags), - ...buildSelectionOptions(flags), - }), - swipe: ({ client, positionals, flags }) => - client.interactions.swipe({ - ...buildSelectionOptions(flags), - from: { x: Number(positionals[0]), y: Number(positionals[1]) }, - to: { x: Number(positionals[2]), y: Number(positionals[3]) }, - durationMs: optionalNumber(positionals[4]), - count: flags.count, - pauseMs: flags.pauseMs, - pattern: flags.pattern, - }), - gesture: ({ client, positionals, flags }) => - runGestureCommand({ - client, - positionals, - flags, - }), - focus: ({ client, positionals, flags }) => - client.interactions.focus({ - ...buildSelectionOptions(flags), - x: Number(positionals[0]), - y: Number(positionals[1]), - }), - type: runTypeCliCommand, - fill: ({ client, positionals, flags }) => { - const decoded = fillCommandCodec.decode(positionals); - return client.interactions.fill({ - ...decoded.target, - text: decoded.text, - ...selectorSnapshotOptionsFromFlags(flags), - ...buildSelectionOptions(flags), - delayMs: flags.delayMs, - }); - }, - scroll: ({ client, positionals, flags }) => - client.interactions.scroll({ - ...buildSelectionOptions(flags), - direction: readScrollDirection(positionals[0]), - amount: optionalNumber(positionals[1]), - pixels: flags.pixels, - }), - 'trigger-app-event': ({ client, positionals, flags }) => - client.apps.triggerEvent({ - ...buildSelectionOptions(flags), - event: required(positionals[0], 'trigger-app-event requires event'), - payload: positionals[1] - ? readJsonObject(positionals[1], 'trigger-app-event payload') - : undefined, - }), - record: ({ client, positionals, flags }) => - client.recording.record({ - ...buildSelectionOptions(flags), - action: readStartStop(positionals[0], 'record'), - path: positionals[1], - fps: flags.fps, - quality: flags.quality as RecordOptions['quality'], - hideTouches: flags.hideTouches, - }), - trace: ({ client, positionals, flags }) => - client.recording.trace({ - ...buildSelectionOptions(flags), - action: readStartStop(positionals[0], 'trace'), - path: positionals[1], - }), - logs: ({ client, positionals, flags }) => - client.observability.logs({ - ...buildSelectionOptions(flags), - action: readLogsAction(positionals[0]), - message: positionals.slice(1).join(' ') || undefined, - restart: flags.restart, - }), - network: ({ client, positionals, flags }) => - client.observability.network({ - ...buildSelectionOptions(flags), - action: readNetworkAction(positionals[0]), - limit: optionalNumber(positionals[1]), - include: flags.networkInclude ?? readNetworkInclude(positionals[2]), - }), - 'react-native': ({ client, positionals, flags }) => - client.command.reactNative({ - ...buildSelectionOptions(flags), - action: readReactNativeAction(positionals[0]), - }), - find: ({ client, positionals, flags }) => - client.interactions.find(findCommandCodec.decode(positionals, flags)), - is: ({ client, positionals, flags }) => - client.interactions.is(isCommandCodec.decode(positionals, flags)), - settings: ({ client, positionals, flags }) => - client.settings.update(settingsCommandCodec.decode(positionals, flags)), -} satisfies Partial>; +const formattedCommandHandlers = Object.fromEntries( + listCliOutputCommandNames().map((command) => [command, createFormattedHandler(command)]), +) as Partial>; -function runGestureCommand(params: { - client: AgentDeviceClient; - positionals: string[]; - flags: CliFlags; -}): Promise { - const { client, positionals, flags } = params; - const subcommand = required(positionals[0], 'gesture requires subcommand'); - const args = positionals.slice(1); - switch (subcommand) { - case 'pan': - return client.interactions.pan({ - ...buildSelectionOptions(flags), - x: Number(args[0]), - y: Number(args[1]), - dx: Number(args[2]), - dy: Number(args[3]), - durationMs: optionalNumber(args[4]), - }); - case 'fling': - return client.interactions.fling({ - ...buildSelectionOptions(flags), - direction: readGestureDirection(args[0], 'gesture fling'), - x: Number(args[1]), - y: Number(args[2]), - distance: optionalNumber(args[3]), - durationMs: optionalNumber(args[4]), - }); - case 'pinch': - return client.interactions.pinch({ - ...buildSelectionOptions(flags), - scale: Number(args[0]), - x: optionalNumber(args[1]), - y: optionalNumber(args[2]), - }); - case 'rotate': - return client.interactions.rotateGesture({ - ...buildSelectionOptions(flags), - degrees: Number(args[0]), - x: optionalNumber(args[1]), - y: optionalNumber(args[2]), - velocity: optionalNumber(args[3]), - }); - case 'transform': - return client.interactions.transformGesture({ - ...buildSelectionOptions(flags), - x: Number(args[0]), - y: Number(args[1]), - dx: Number(args[2]), - dy: Number(args[3]), - scale: Number(args[4]), - degrees: Number(args[5]), - durationMs: optionalNumber(args[6]), - }); - default: - throw new AppError('INVALID_ARGS', GESTURE_SUBCOMMAND_ERROR); - } -} +export const dedicatedCommandHandlers = formattedCommandHandlers; + +const genericCommands = listCommandNames().filter(isGenericCliCommand); + +const genericClientCommandRunners = Object.fromEntries( + genericCommands.map((command) => [ + command, + async ({ client, positionals, flags }) => { + if (command === 'test') { + announceReplayTestRun({ json: flags.json }); + } + return await runCliCommand({ client, command, positionals, flags }); + }, + ]), +) as Record<(typeof genericCommands)[number], GenericClientCommandRunner>; export const genericClientCommandHandlers = Object.fromEntries( Object.entries(genericClientCommandRunners).map(([command, run]) => [ @@ -267,7 +53,7 @@ function createGenericClientCommandHandler( ): ClientCommandHandler { return async ({ positionals, flags, client }) => { const data = await run({ client, positionals, flags }); - const exitCode = writeCommandCliOutput(command, positionals, flags, data); + const exitCode = writeGenericCliOutput(command, flags, data); if (exitCode !== 0) { process.exit(exitCode); } @@ -275,92 +61,52 @@ function createGenericClientCommandHandler( }; } -function readGetFormat(value: string | undefined): 'text' | 'attrs' { - if (value === 'text' || value === 'attrs') return value; - throw new AppError('INVALID_ARGS', 'get only supports text or attrs'); -} - -function readScrollDirection( - value: string | undefined, -): 'up' | 'down' | 'left' | 'right' | 'top' | 'bottom' { - if ( - value === 'up' || - value === 'down' || - value === 'left' || - value === 'right' || - value === 'top' || - value === 'bottom' - ) { - return value; - } - throw new AppError('INVALID_ARGS', `Unknown direction: ${String(value)}`); -} - -function readGestureDirection( - value: string | undefined, - command: string, -): 'up' | 'down' | 'left' | 'right' { - if (value === 'up' || value === 'down' || value === 'left' || value === 'right') return value; - throw new AppError('INVALID_ARGS', `${command} direction must be up, down, left, or right`); -} - -function readStartStop(value: string | undefined, command: string): 'start' | 'stop' { - if (value === 'start' || value === 'stop') return value; - throw new AppError('INVALID_ARGS', `${command} requires start|stop`); -} - -function readLogsAction( - value: string | undefined, -): 'path' | 'start' | 'stop' | 'doctor' | 'mark' | 'clear' | undefined { - if (value === undefined) return undefined; - if ( - value === 'path' || - value === 'start' || - value === 'stop' || - value === 'doctor' || - value === 'mark' || - value === 'clear' - ) { - return value; +function writeGenericCliOutput( + command: PublicCommandName, + flags: CliFlags, + data: CommandRequestResult, +): number { + if (command === 'test') { + return renderReplayTestResponse({ + suite: data as ReplaySuiteResult, + verbose: flags.verbose, + json: flags.json, + reportJunit: flags.reportJunit, + }); } - throw new AppError('INVALID_ARGS', 'logs requires path, start, stop, doctor, mark, or clear'); -} - -function readNetworkAction(value: string | undefined): 'dump' | 'log' | undefined { - if (value === undefined) return undefined; - if (value === 'dump' || value === 'log') return value; - throw new AppError('INVALID_ARGS', 'network requires dump or log'); -} - -function readNetworkInclude( - value: string | undefined, -): 'summary' | 'headers' | 'body' | 'all' | undefined { - if (value === undefined) return undefined; - if (value === 'summary' || value === 'headers' || value === 'body' || value === 'all') - return value; - throw new AppError('INVALID_ARGS', 'network include mode must be summary, headers, body, or all'); + writeCommandOutput(flags, data, () => + readCommandMessage(data as Record | undefined), + ); + return 0; } -function readJsonObject(value: string, label: string): Record { - try { - const parsed = JSON.parse(value) as unknown; - if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { - return parsed as Record; +function createFormattedHandler(command: CommandName): ClientCommandHandler { + return async ({ positionals, flags, client }) => { + const { cliOutput } = await runCliCommandWithOutput({ + client, + command, + positionals, + flags, + }); + if (!cliOutput) { + throw new Error(`Missing CLI output formatter for command: ${command}`); } - } catch {} - throw new AppError('INVALID_ARGS', `${label} must be a JSON object`); -} - -function required(value: string | undefined, message: string): string { - if (value === undefined || value === '') throw new AppError('INVALID_ARGS', message); - return value; + writeCliOutput(flags, cliOutput); + return true; + }; } -function readReactNativeAction(value: string | undefined): 'dismiss-overlay' { - if (value === 'dismiss-overlay') return value; - throw new AppError('INVALID_ARGS', 'react-native supports only: dismiss-overlay'); +function writeCliOutput(flags: CliFlags, output: CliOutput): void { + if (!flags.json && output.stderr) { + process.stderr.write(output.stderr); + } + writeCommandOutput( + flags, + flags.json ? (output.jsonData ?? output.data) : output.data, + () => output.text, + ); } -function optionalNumber(value: string | undefined): number | undefined { - return value === undefined ? undefined : Number(value); +function isGenericCliCommand(command: CommandName): boolean { + return !(command in formattedCommandHandlers) && command !== 'screenshot' && command !== 'diff'; } diff --git a/src/cli/commands/install.ts b/src/cli/commands/install.ts deleted file mode 100644 index 549b55eab..000000000 --- a/src/cli/commands/install.ts +++ /dev/null @@ -1,130 +0,0 @@ -import { AppError } from '../../utils/errors.ts'; -import { serializeDeployResult, serializeInstallFromSourceResult } from '../../client-shared.ts'; -import type { CliFlags } from '../../utils/command-schema.ts'; -import type { AgentDeviceClient, AppDeployResult } from '../../client.ts'; -import { buildSelectionOptions, writeCommandMessage } from './shared.ts'; -import { parseGitHubActionsArtifactInstallSourceSpec } from '../../utils/install-source-config.ts'; -import type { ClientCommandHandler } from './router-types.ts'; - -export const installCommand: ClientCommandHandler = async ({ positionals, flags, client }) => { - const result = await runDeployCommand('install', positionals, flags, client); - const data = serializeDeployResult(result); - writeCommandMessage(flags, data); - return true; -}; - -export const reinstallCommand: ClientCommandHandler = async ({ positionals, flags, client }) => { - const result = await runDeployCommand('reinstall', positionals, flags, client); - const data = serializeDeployResult(result); - writeCommandMessage(flags, data); - return true; -}; - -export const installFromSourceCommand: ClientCommandHandler = async ({ - positionals, - flags, - client, -}) => { - const result = await runInstallFromSourceCommand(positionals, flags, client); - const data = serializeInstallFromSourceResult(result); - writeCommandMessage(flags, data); - return true; -}; - -async function runDeployCommand( - command: 'install' | 'reinstall', - positionals: string[], - flags: CliFlags, - client: AgentDeviceClient, -): Promise { - const app = positionals[0]; - const appPath = positionals[1]; - if (!app || !appPath) { - throw new AppError( - 'INVALID_ARGS', - `${command} requires: ${command} `, - ); - } - const options = { - app, - appPath, - ...buildSelectionOptions(flags), - }; - return command === 'install' - ? await client.apps.install(options) - : await client.apps.reinstall(options); -} - -async function runInstallFromSourceCommand( - positionals: string[], - flags: CliFlags, - client: AgentDeviceClient, -) { - const source = resolveInstallSource(positionals, flags); - if (source.kind !== 'url' && flags.header && flags.header.length > 0) { - throw new AppError( - 'INVALID_ARGS', - 'install-from-source --header is only supported for URL sources', - ); - } - return await client.apps.installFromSource({ - ...buildSelectionOptions(flags), - retainPaths: flags.retainPaths, - retentionMs: flags.retentionMs, - source, - }); -} - -function resolveInstallSource(positionals: string[], flags: CliFlags) { - const url = positionals[0]?.trim(); - if (positionals.length > 1) { - throw new AppError( - 'INVALID_ARGS', - 'install-from-source accepts either one positional or --github-actions-artifact', - ); - } - const githubArtifactSource = flags.githubActionsArtifact - ? parseGitHubActionsArtifactInstallSourceSpec(flags.githubActionsArtifact) - : undefined; - const configuredSource = flags.installSource; - const sourceCount = (url ? 1 : 0) + (githubArtifactSource ? 1 : 0) + (configuredSource ? 1 : 0); - if (sourceCount !== 1) { - throw new AppError( - 'INVALID_ARGS', - 'install-from-source requires exactly one source: , --github-actions-artifact, or config installSource', - ); - } - if (githubArtifactSource) return githubArtifactSource; - if (configuredSource) return configuredSource; - return { - kind: 'url' as const, - url: url!, - headers: parseInstallSourceHeaders(flags.header), - }; -} - -function parseInstallSourceHeaders( - headerFlags: CliFlags['header'], -): Record | undefined { - if (!headerFlags || headerFlags.length === 0) return undefined; - const headers: Record = {}; - for (const rawHeader of headerFlags) { - const separator = rawHeader.indexOf(':'); - if (separator <= 0) { - throw new AppError( - 'INVALID_ARGS', - `Invalid --header value "${rawHeader}". Expected "name:value".`, - ); - } - const name = rawHeader.slice(0, separator).trim(); - const value = rawHeader.slice(separator + 1).trim(); - if (!name) { - throw new AppError( - 'INVALID_ARGS', - `Invalid --header value "${rawHeader}". Header name cannot be empty.`, - ); - } - headers[name] = value; - } - return headers; -} diff --git a/src/cli/commands/open.ts b/src/cli/commands/open.ts deleted file mode 100644 index 0eb9f01ca..000000000 --- a/src/cli/commands/open.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { serializeCloseResult, serializeOpenResult } from '../../client-shared.ts'; -import { buildSelectionOptions, writeCommandMessage } from './shared.ts'; -import type { ClientCommandHandler } from './router-types.ts'; - -export const openCommand: ClientCommandHandler = async ({ positionals, flags, client }) => { - const result = await client.apps.open({ - app: positionals[0], - url: positionals[1], - surface: flags.surface, - activity: flags.activity, - launchConsole: flags.launchConsole, - relaunch: flags.relaunch, - saveScript: flags.saveScript, - noRecord: flags.noRecord, - ...buildSelectionOptions(flags), - }); - const data = serializeOpenResult(result); - writeCommandMessage(flags, data); - return true; -}; - -export const closeCommand: ClientCommandHandler = async ({ positionals, flags, client }) => { - const result = positionals[0] - ? await client.apps.close({ app: positionals[0], shutdown: flags.shutdown }) - : await client.sessions.close({ shutdown: flags.shutdown }); - - const data = serializeCloseResult(result); - writeCommandMessage(flags, data); - return true; -}; diff --git a/src/cli/commands/output.ts b/src/cli/commands/output.ts deleted file mode 100644 index c4c71f4c0..000000000 --- a/src/cli/commands/output.ts +++ /dev/null @@ -1,380 +0,0 @@ -import type { CliFlags } from '../../utils/command-schema.ts'; -import type { PublicCommandName } from '../../command-catalog.ts'; -import { readCommandMessage } from '../../utils/success-text.ts'; -import { printJson } from '../../utils/output.ts'; -import { renderReplayTestResponse } from '../../cli-test.ts'; -import type { ReplaySuiteResult } from '../../daemon/types.ts'; - -type CliOutputFlags = Pick; -type TextOutputHandler = (options: { - positionals: string[]; - flags: CliOutputFlags; - data: Record; -}) => boolean; - -function renderBatchSummary(data: Record): void { - const total = typeof data.total === 'number' ? data.total : 0; - const executed = typeof data.executed === 'number' ? data.executed : 0; - const durationMs = typeof data.totalDurationMs === 'number' ? data.totalDurationMs : undefined; - process.stdout.write( - `Batch completed: ${executed}/${total} steps${durationMs !== undefined ? ` in ${durationMs}ms` : ''}\n`, - ); - const results = Array.isArray(data.results) ? data.results : []; - for (const entry of results) { - const line = renderBatchStepLine(entry); - if (line) process.stdout.write(line); - } -} - -function renderBatchStepLine(entry: unknown): string | undefined { - const result = readRecord(entry); - if (!result) return undefined; - const step = typeof result.step === 'number' ? result.step : undefined; - const command = typeof result.command === 'string' ? result.command : 'step'; - const stepOk = result.ok !== false; - const stepData = readRecord(result.data); - const stepError = readRecord(result.error); - const description = stepOk - ? (readCommandMessage(stepData) ?? command) - : (readBatchStepFailure(stepError) ?? command); - const prefix = step !== undefined ? `${step}. ` : '- '; - const durationMs = typeof result.durationMs === 'number' ? result.durationMs : undefined; - const durationSuffix = durationMs !== undefined ? ` (${durationMs}ms)` : ''; - return `${prefix}${stepOk ? 'OK' : 'FAILED'} ${description}${durationSuffix}\n`; -} - -export function writeCommandCliOutput( - command: PublicCommandName, - positionals: string[], - flags: CliOutputFlags, - data: Record, -): number { - if (flags.json) { - return writeJsonCliOutput(command, flags, data); - } - - if (command === 'test') { - return renderReplayTestResponse({ - suite: data as ReplaySuiteResult, - verbose: flags.verbose, - reportJunit: flags.reportJunit, - }); - } - - const handler = TEXT_OUTPUT_HANDLERS[command]; - if (handler?.({ positionals, flags, data })) { - return 0; - } - - const successText = readCommandMessage(data); - if (successText) { - process.stdout.write(`${successText}\n`); - } - return 0; -} - -function writeJsonCliOutput( - command: PublicCommandName, - flags: CliOutputFlags, - data: Record, -): number { - if (command === 'test') { - return renderReplayTestResponse({ - suite: data as ReplaySuiteResult, - json: true, - reportJunit: flags.reportJunit, - }); - } - printJson({ success: true, data }); - return 0; -} - -const TEXT_OUTPUT_HANDLERS: Partial> = { - batch: ({ data }) => { - renderBatchSummary(data); - return true; - }, - get: ({ positionals, data }) => writeGetCliOutput(positionals, data), - find: ({ data }) => writeFindCliOutput(data), - is: ({ data }) => { - process.stdout.write(`Passed: is ${data.predicate ?? 'assertion'}\n`); - return true; - }, - boot: ({ data }) => { - const platform = data.platform ?? 'unknown'; - const device = data.device ?? data.id ?? 'unknown'; - process.stdout.write(`Boot ready: ${device} (${platform})\n`); - return true; - }, - record: ({ data }) => { - const outPath = typeof data.outPath === 'string' ? data.outPath : ''; - if (outPath) process.stdout.write(`${outPath}\n`); - return true; - }, - logs: ({ data, flags }) => { - writeLogsCliOutput(data, flags); - return true; - }, - network: ({ data }) => { - writeNetworkCliOutput(data); - return true; - }, - click: ({ data }) => writeTapCliOutput(data), - press: ({ data }) => writeTapCliOutput(data), - perf: ({ data }) => { - writePerfCliOutput(data); - return true; - }, -}; - -function writeGetCliOutput(positionals: string[], data: Record): boolean { - const sub = positionals[0]; - if (sub === 'text') { - process.stdout.write(`${typeof data.text === 'string' ? data.text : ''}\n`); - return true; - } - if (sub === 'attrs') { - process.stdout.write(`${JSON.stringify(data.node ?? {}, null, 2)}\n`); - return true; - } - return false; -} - -function writeFindCliOutput(data: Record): boolean { - if (typeof data.text === 'string') { - process.stdout.write(`${data.text}\n`); - return true; - } - if (typeof data.found === 'boolean') { - process.stdout.write(`Found: ${data.found}\n`); - return true; - } - if (!data.node) return false; - process.stdout.write(`${JSON.stringify(data.node, null, 2)}\n`); - return true; -} - -function writeTapCliOutput(data: Record): boolean { - const ref = data.ref ?? ''; - const x = data.x; - const y = data.y; - if (!ref || typeof x !== 'number' || typeof y !== 'number') return false; - process.stdout.write(`Tapped @${ref} (${x}, ${y})\n`); - return true; -} - -function writePerfCliOutput(data: Record): void { - const metrics = readRecord(data.metrics); - const fps = readRecord(metrics?.fps); - const resourceSummary = buildResourcePerfSummary(metrics); - if (!fps) { - process.stdout.write( - resourceSummary - ? `Performance: ${resourceSummary}\n` - : 'Frame health: unavailable - missing frame metric\n', - ); - return; - } - - if (fps.available === false) { - if (resourceSummary) { - process.stdout.write(`Performance: ${resourceSummary}\n`); - return; - } - const reason = - typeof fps.reason === 'string' && fps.reason.length > 0 ? fps.reason : 'not available'; - process.stdout.write(`Frame health: unavailable - ${reason}\n`); - return; - } - - const droppedFramePercent = readFiniteNumber(fps.droppedFramePercent); - const droppedFrameCount = readFiniteNumber(fps.droppedFrameCount); - const totalFrameCount = readFiniteNumber(fps.totalFrameCount); - if (droppedFramePercent === undefined || droppedFrameCount === undefined) { - process.stdout.write( - resourceSummary - ? `Performance: ${resourceSummary}\n` - : 'Frame health: unavailable - missing dropped-frame summary\n', - ); - return; - } - - const parts = [`dropped ${formatPercent(droppedFramePercent)}`]; - if (totalFrameCount !== undefined) { - parts.push(`(${Math.round(droppedFrameCount)}/${Math.round(totalFrameCount)} frames)`); - } else { - parts.push(`(${Math.round(droppedFrameCount)} dropped frames)`); - } - - const sampleWindowMs = readFiniteNumber(fps.sampleWindowMs); - if (sampleWindowMs !== undefined) { - parts.push(`window ${formatDurationMs(sampleWindowMs)}`); - } - - process.stdout.write(`Frame health: ${parts.join(' ')}\n`); - writeWorstFrameWindows(fps); -} - -function writeWorstFrameWindows(fps: Record): void { - const worstWindows = readRecordArray(fps.worstWindows); - if (worstWindows.length === 0) return; - process.stdout.write('Worst windows:\n'); - for (const window of worstWindows) { - const line = formatWorstFrameWindow(window); - if (line) process.stdout.write(line); - } -} - -function formatWorstFrameWindow(window: Record): string | undefined { - const startOffsetMs = readFiniteNumber(window.startOffsetMs); - const endOffsetMs = readFiniteNumber(window.endOffsetMs); - const count = readFiniteNumber(window.missedDeadlineFrameCount); - if (startOffsetMs === undefined || endOffsetMs === undefined || count === undefined) { - return undefined; - } - const worstFrameMs = readFiniteNumber(window.worstFrameMs); - const worstFrameText = - worstFrameMs === undefined ? '' : `, worst ${formatDurationMs(worstFrameMs)}`; - return `- +${formatDurationMs(startOffsetMs)}-+${formatDurationMs(endOffsetMs)}: ${Math.round(count)} missed-deadline frames${worstFrameText}\n`; -} - -function buildResourcePerfSummary( - metrics: Record | undefined, -): string | undefined { - const parts: string[] = []; - const cpu = readRecord(metrics?.cpu); - if (cpu?.available === true) { - const usagePercent = readFiniteNumber(cpu.usagePercent); - if (usagePercent !== undefined) parts.push(`CPU ${formatPercent(usagePercent)}`); - } - - const memory = readRecord(metrics?.memory); - if (memory?.available === true) { - const memoryKb = - readFiniteNumber(memory.residentMemoryKb) ?? - readFiniteNumber(memory.totalPssKb) ?? - readFiniteNumber(memory.totalRssKb); - if (memoryKb !== undefined) parts.push(`memory ${formatMemoryKb(memoryKb)}`); - } - - return parts.length > 0 ? parts.join(', ') : undefined; -} - -function readRecord(value: unknown): Record | undefined { - return value && typeof value === 'object' && !Array.isArray(value) - ? (value as Record) - : undefined; -} - -function readRecordArray(value: unknown): Array> { - return Array.isArray(value) - ? value.filter( - (entry): entry is Record => - Boolean(entry) && typeof entry === 'object' && !Array.isArray(entry), - ) - : []; -} - -function readFiniteNumber(value: unknown): number | undefined { - return typeof value === 'number' && Number.isFinite(value) ? value : undefined; -} - -function formatPercent(value: number): string { - return `${Number.isInteger(value) ? value : value.toFixed(1)}%`; -} - -function formatDurationMs(value: number): string { - const roundedMs = Math.max(0, Math.round(value)); - if (roundedMs < 1000) return `${roundedMs}ms`; - const seconds = Math.round(roundedMs / 1000); - if (seconds < 60) return `${seconds}s`; - const minutes = Math.floor(seconds / 60); - const remainingSeconds = seconds % 60; - return remainingSeconds > 0 ? `${minutes}m ${remainingSeconds}s` : `${minutes}m`; -} - -function formatMemoryKb(value: number): string { - const megabytes = value / 1024; - return `${megabytes >= 10 ? Math.round(megabytes) : megabytes.toFixed(1)}MB`; -} - -function readBatchStepFailure(error: Record | undefined): string | null { - return typeof error?.message === 'string' && error.message.length > 0 ? error.message : null; -} - -function writeLogsCliOutput(data: Record, flags: { json?: boolean }): void { - const pathOut = typeof data.path === 'string' ? data.path : ''; - if (!pathOut) return; - process.stdout.write(`${pathOut}\n`); - const meta = formatKeyValueFields(data, ['active', 'state', 'backend', 'sizeBytes']); - if (meta && !flags.json) process.stderr.write(`${meta}\n`); - const actionMeta = formatActionFields(data); - if (actionMeta && !flags.json) process.stderr.write(`${actionMeta}\n`); - if (data.hint && !flags.json) process.stderr.write(`${data.hint}\n`); - if (!flags.json) writeNotes(data.notes); -} - -function formatActionFields(data: Record): string { - return ['started', 'stopped', 'marked', 'cleared', 'restarted', 'removedRotatedFiles'] - .map((key) => formatActionField(key, data[key])) - .filter(Boolean) - .join(' '); -} - -function formatActionField(key: string, value: unknown): string { - if (value === true) return `${key}=true`; - return typeof value === 'number' ? `${key}=${value}` : ''; -} - -function writeNetworkCliOutput(data: Record): void { - const pathOut = typeof data.path === 'string' ? data.path : ''; - if (pathOut) process.stdout.write(`${pathOut}\n`); - const entries = Array.isArray(data.entries) ? data.entries : []; - if (entries.length === 0) { - process.stdout.write('No recent HTTP(s) entries found.\n'); - } else { - for (const entry of entries as Array>) { - writeNetworkEntry(entry); - } - } - const meta = formatKeyValueFields(data, [ - 'active', - 'state', - 'backend', - 'include', - 'scannedLines', - 'matchedLines', - ]); - if (meta) process.stderr.write(`${meta}\n`); - writeNotes(data.notes); -} - -function writeNetworkEntry(entry: Record): void { - const method = typeof entry.method === 'string' ? entry.method : 'HTTP'; - const url = typeof entry.url === 'string' ? entry.url : ''; - const status = typeof entry.status === 'number' ? ` status=${entry.status}` : ''; - const timestamp = typeof entry.timestamp === 'string' ? `${entry.timestamp} ` : ''; - const durationMs = typeof entry.durationMs === 'number' ? ` durationMs=${entry.durationMs}` : ''; - process.stdout.write(`${timestamp}${method} ${url}${status}${durationMs}\n`); - writeNetworkEntryBody('headers', entry.headers); - writeNetworkEntryBody('request', entry.requestBody); - writeNetworkEntryBody('response', entry.responseBody); -} - -function writeNetworkEntryBody(label: string, value: unknown): void { - if (typeof value === 'string') process.stdout.write(` ${label}: ${value}\n`); -} - -function formatKeyValueFields(data: Record, fields: string[]): string { - return fields - .map((key) => (data[key] !== undefined && data[key] !== null ? `${key}=${data[key]}` : '')) - .filter(Boolean) - .join(' '); -} - -function writeNotes(notes: unknown): void { - if (!Array.isArray(notes)) return; - for (const note of notes) { - if (typeof note === 'string' && note.length > 0) process.stderr.write(`${note}\n`); - } -} diff --git a/src/cli/commands/router.ts b/src/cli/commands/router.ts index 6783e349e..ec6c12654 100644 --- a/src/cli/commands/router.ts +++ b/src/cli/commands/router.ts @@ -1,17 +1,9 @@ import { applyCommandDefaults, type CliFlags } from '../../utils/command-schema.ts'; import type { AgentDeviceClient } from '../../client.ts'; -import { sessionCommand } from './session.ts'; -import { devicesCommand } from './devices.ts'; -import { metroCommand } from './metro.ts'; -import { appsCommand } from './apps.ts'; -import { installCommand, reinstallCommand, installFromSourceCommand } from './install.ts'; -import { openCommand, closeCommand } from './open.ts'; import { connectCommand, connectionCommand, disconnectCommand } from './connection.ts'; import { authCommand } from './auth.ts'; -import { snapshotCommand } from './snapshot.ts'; import { screenshotCommand, diffCommand } from './screenshot.ts'; -import { clientCommandMethodHandlers } from './client-command.ts'; -import { genericClientCommandHandlers } from './generic.ts'; +import { dedicatedCommandHandlers, genericClientCommandHandlers } from './generic.ts'; import type { ClientCommandHandlerMap } from './router-types.ts'; export type { @@ -21,27 +13,17 @@ export type { } from './router-types.ts'; const dedicatedCliCommandHandlers = { - session: sessionCommand, - devices: devicesCommand, - apps: appsCommand, - metro: metroCommand, - install: installCommand, - reinstall: reinstallCommand, - 'install-from-source': installFromSourceCommand, connect: connectCommand, disconnect: disconnectCommand, connection: connectionCommand, auth: authCommand, - open: openCommand, - close: closeCommand, - snapshot: snapshotCommand, screenshot: screenshotCommand, diff: diffCommand, } satisfies ClientCommandHandlerMap; const clientCommandHandlers: ClientCommandHandlerMap = { ...dedicatedCliCommandHandlers, - ...clientCommandMethodHandlers, + ...dedicatedCommandHandlers, ...genericClientCommandHandlers, }; diff --git a/src/cli/commands/screenshot.ts b/src/cli/commands/screenshot.ts index 9742c0caf..271aa2713 100644 --- a/src/cli/commands/screenshot.ts +++ b/src/cli/commands/screenshot.ts @@ -2,19 +2,21 @@ import { formatScreenshotDiffText, formatSnapshotDiffText } from '../../utils/ou import { AppError } from '../../utils/errors.ts'; import { resolveUserPath } from '../../utils/path-resolution.ts'; import type { AgentDeviceBackend } from '../../backend.ts'; -import type { AgentDeviceClient } from '../../client.ts'; +import type { AgentDeviceClient, CaptureScreenshotResult } from '../../client.ts'; import { createLocalArtifactAdapter } from '../../io.ts'; import { createAgentDevice, localCommandPolicy } from '../../runtime.ts'; -import { screenshotOptionsFromFlags } from '../../commands/capture-screenshot-options.ts'; +import { runCliCommand } from '../../commands/cli-runner.ts'; import type { CliFlags } from '../../utils/command-schema.ts'; -import { buildSelectionOptions, writeCommandOutput } from './shared.ts'; +import { writeCommandOutput } from './shared.ts'; import type { ClientCommandHandler } from './router-types.ts'; export const screenshotCommand: ClientCommandHandler = async ({ positionals, flags, client }) => { - const result = await client.capture.screenshot({ - path: positionals[0] ?? flags.out, - ...screenshotOptionsFromFlags(flags), - }); + const result = (await runCliCommand({ + client, + command: 'screenshot', + positionals, + flags, + })) as CaptureScreenshotResult; const data = { path: result.path, ...(result.overlayRefs ? { overlayRefs: result.overlayRefs } : {}), @@ -29,16 +31,7 @@ export const screenshotCommand: ClientCommandHandler = async ({ positionals, fla export const diffCommand: ClientCommandHandler = async ({ positionals, flags, client }) => { if (positionals[0] === 'snapshot') { - const result = await client.capture.diff({ - ...buildSelectionOptions(flags), - kind: 'snapshot', - out: flags.out, - interactiveOnly: flags.snapshotInteractiveOnly, - compact: flags.snapshotCompact, - depth: flags.snapshotDepth, - scope: flags.snapshotScope, - raw: flags.snapshotRaw, - }); + const result = await runCliCommand({ client, command: 'diff', positionals, flags }); writeCommandOutput(flags, result, () => formatSnapshotDiffText(result)); return true; } diff --git a/src/cli/commands/session.ts b/src/cli/commands/session.ts deleted file mode 100644 index 884881419..000000000 --- a/src/cli/commands/session.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { AppError } from '../../utils/errors.ts'; -import { serializeSessionListEntry } from '../../client-shared.ts'; -import { writeCommandOutput } from './shared.ts'; -import type { ClientCommandHandler } from './router-types.ts'; - -export const sessionCommand: ClientCommandHandler = async ({ positionals, flags, client }) => { - const sub = positionals[0] ?? 'list'; - if (sub !== 'list') { - throw new AppError('INVALID_ARGS', 'session only supports list'); - } - const sessions = await client.sessions.list(); - const data = { sessions: sessions.map(serializeSessionListEntry) }; - writeCommandOutput(flags, data, () => JSON.stringify(data, null, 2)); - return true; -}; diff --git a/src/cli/commands/shared.ts b/src/cli/commands/shared.ts index f1805a936..7728a34ba 100644 --- a/src/cli/commands/shared.ts +++ b/src/cli/commands/shared.ts @@ -1,11 +1,5 @@ import type { CliFlags } from '../../utils/command-schema.ts'; import { printJson } from '../../utils/output.ts'; -import { readCommandMessage } from '../../utils/success-text.ts'; -import { selectionOptionsFromFlags, type SelectionOptions } from '../../command-codecs/flags.ts'; - -export function buildSelectionOptions(flags: CliFlags): SelectionOptions { - return selectionOptionsFromFlags(flags); -} export function writeCommandOutput( flags: CliFlags, @@ -20,10 +14,6 @@ export function writeCommandOutput( if (text) writeLine(text); } -export function writeCommandMessage(flags: CliFlags, data: Record): void { - writeCommandOutput(flags, data, () => readCommandMessage(data)); -} - function writeLine(text: string): void { process.stdout.write(text.endsWith('\n') ? text : `${text}\n`); } diff --git a/src/cli/commands/snapshot.ts b/src/cli/commands/snapshot.ts deleted file mode 100644 index c3a11af79..000000000 --- a/src/cli/commands/snapshot.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { formatSnapshotText } from '../../utils/output.ts'; -import { serializeSnapshotResult } from '../../client-shared.ts'; -import { buildSelectionOptions, writeCommandOutput } from './shared.ts'; -import type { ClientCommandHandler } from './router-types.ts'; - -export const snapshotCommand: ClientCommandHandler = async ({ flags, client }) => { - const result = await client.capture.snapshot({ - ...buildSelectionOptions(flags), - interactiveOnly: flags.snapshotInteractiveOnly, - compact: flags.snapshotCompact, - depth: flags.snapshotDepth, - scope: flags.snapshotScope, - raw: flags.snapshotRaw, - forceFull: flags.snapshotForceFull, - }); - const data = serializeSnapshotResult(result); - // Programmatic SDK callers can see `unchanged`; CLI --json hides it for schema compatibility. - const outputData = flags.json ? withoutUnchanged(data) : data; - writeCommandOutput(flags, outputData, () => - formatSnapshotText(outputData, { - raw: flags.snapshotRaw, - flatten: flags.snapshotInteractiveOnly, - }), - ); - return true; -}; - -function withoutUnchanged(data: Record): Record { - const { unchanged: _unchanged, ...outputData } = data; - return outputData; -} diff --git a/src/client-commands.ts b/src/client-commands.ts deleted file mode 100644 index c3f6ee5f7..000000000 --- a/src/client-commands.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { PUBLIC_COMMANDS, type PublicCommandName } from './command-catalog.ts'; -import { waitCommandCodec } from './command-codecs.ts'; -import type { AgentDeviceCommandClient, InternalRequestOptions } from './client-types.ts'; - -export type PreparedClientCommand = { - command: PublicCommandName; - positionals: string[]; - options: InternalRequestOptions; -}; - -type ExecutePreparedCommand = (prepared: PreparedClientCommand) => Promise; -type CommandOptions = NonNullable< - Parameters[0] ->; -type CommandResult = Awaited< - ReturnType ->; - -export function createAgentDeviceCommandClient( - executePreparedCommand: ExecutePreparedCommand, -): AgentDeviceCommandClient { - const run = async ( - prepared: PreparedClientCommand, - ): Promise> => await executePreparedCommand>(prepared); - - return { - wait: async (options) => await run<'wait'>(prepareWaitCommand(options)), - alert: async (options = {}) => await run<'alert'>(prepareAlertCommand(options)), - appState: async (options = {}) => - await run<'appState'>({ - command: PUBLIC_COMMANDS.appState, - positionals: [], - options, - }), - back: async (options = {}) => - await run<'back'>({ - command: PUBLIC_COMMANDS.back, - positionals: [], - options: { - ...options, - backMode: options.mode, - }, - }), - home: async (options = {}) => - await run<'home'>({ - command: PUBLIC_COMMANDS.home, - positionals: [], - options, - }), - rotate: async (options) => - await run<'rotate'>({ - command: PUBLIC_COMMANDS.rotate, - positionals: [options.orientation], - options, - }), - appSwitcher: async (options = {}) => - await run<'appSwitcher'>({ - command: PUBLIC_COMMANDS.appSwitcher, - positionals: [], - options, - }), - keyboard: async (options = {}) => - await run<'keyboard'>({ - command: PUBLIC_COMMANDS.keyboard, - positionals: options.action ? [options.action] : [], - options, - }), - clipboard: async (options) => await run<'clipboard'>(prepareClipboardCommand(options)), - reactNative: async (options) => - await run<'reactNative'>({ - command: PUBLIC_COMMANDS.reactNative, - positionals: [options.action], - options, - }), - }; -} - -function prepareWaitCommand(options: CommandOptions<'wait'>): PreparedClientCommand { - return { - command: PUBLIC_COMMANDS.wait, - positionals: waitCommandCodec.encode(options), - options, - }; -} - -function prepareAlertCommand(options: CommandOptions<'alert'>): PreparedClientCommand { - const action = options.action ?? 'get'; - return { - command: PUBLIC_COMMANDS.alert, - positionals: [action, ...(options.timeoutMs !== undefined ? [String(options.timeoutMs)] : [])], - options, - }; -} - -function prepareClipboardCommand(options: CommandOptions<'clipboard'>): PreparedClientCommand { - if (options.action === 'read') { - return { command: PUBLIC_COMMANDS.clipboard, positionals: ['read'], options }; - } - return { - command: PUBLIC_COMMANDS.clipboard, - positionals: ['write', options.text], - options, - }; -} diff --git a/src/client-types.ts b/src/client-types.ts index dddbc47d9..4bc7ec6cc 100644 --- a/src/client-types.ts +++ b/src/client-types.ts @@ -24,6 +24,7 @@ import type { import type { MetroBridgeScope } from './client-companion-tunnel-contract.ts'; import type { AppsFilter } from './commands/app-inventory-contract.ts'; import type { ScreenshotRequestFlags } from './commands/capture-screenshot-options.ts'; +import type { DaemonBatchStep } from './core/batch.ts'; import type { AlertInfo } from './alert-contract.ts'; export type { FindLocator } from './utils/finders.ts'; @@ -702,8 +703,8 @@ export type ReplayTestOptions = AgentDeviceRequestOverrides & export type BatchStep = { command: string; - positionals?: string[]; - flags?: Record; + input: Record; + runtime?: unknown; }; export type BatchRunOptions = AgentDeviceRequestOverrides & { @@ -832,11 +833,7 @@ type CommandExecutionOptions = Partial & { networkInclude?: 'summary' | 'headers' | 'body' | 'all'; batchOnError?: 'stop'; batchMaxSteps?: number; - batchSteps?: Array<{ - command: string; - positionals?: string[]; - flags?: Record; - }>; + batchSteps?: DaemonBatchStep[]; }; export type InternalRequestOptions = AgentDeviceClientConfig & diff --git a/src/client.ts b/src/client.ts index a9272b526..48d1068b0 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1,18 +1,10 @@ import { sendToDaemon } from './daemon-client.ts'; import { prepareMetroRuntime, reloadMetro } from './client-metro.ts'; -import { INTERNAL_COMMANDS, PUBLIC_COMMANDS } from './command-catalog.ts'; -import { createAgentDeviceCommandClient, type PreparedClientCommand } from './client-commands.ts'; -import { screenshotFlagsFromOptions } from './commands/capture-screenshot-options.ts'; +import { INTERNAL_COMMANDS } from './command-catalog.ts'; import { - elementTargetCodec, - fillCommandCodec, - findCommandCodec, - interactionTargetCodec, - isCommandCodec, - longPressCommandCodec, - settingsCommandCodec, -} from './command-codecs.ts'; -import { typeCommandCodec } from './commands/interactions/definition.ts'; + prepareDaemonCommandRequest, + type DaemonCommandName, +} from './commands/command-projection.ts'; import { throwDaemonError } from './daemon-error.ts'; import { buildFlags, @@ -35,8 +27,6 @@ import type { AgentDeviceClient, AgentDeviceClientConfig, AgentDeviceDaemonTransport, - AppPushOptions, - AppTriggerEventOptions, AppCloseOptions, AppDeployOptions, AppInstallFromSourceOptions, @@ -45,14 +35,11 @@ import type { CaptureScreenshotOptions, CaptureSnapshotOptions, CaptureSnapshotResult, - CommandRequestResult, InternalRequestOptions, Lease, MaterializationReleaseOptions, MetroPrepareOptions, - NetworkOptions, } from './client-types.ts'; -import { AppError } from './utils/errors.ts'; export function createAgentDeviceClient( config: AgentDeviceClientConfig = {}, @@ -86,34 +73,43 @@ export function createAgentDeviceClient( return sessions.map(normalizeSession); }; - const executePreparedCommand = async (prepared: PreparedClientCommand): Promise => - (await execute(prepared.command, prepared.positionals, prepared.options)) as T; - - const executeCommandRequest = async ( - command: string, - positionals: string[] = [], + const executeCommand = async ( + command: DaemonCommandName, options: InternalRequestOptions = {}, - ): Promise => - (await execute(command, positionals, options)) as CommandRequestResult; + ): Promise => { + const request = prepareDaemonCommandRequest(command, options); + return (await execute(request.command, request.positionals, request.options)) as T; + }; const resolveRequestSession = (options: InternalRequestOptions = {}) => resolveSessionName(mergeClientOptions(config, options).session); return { - command: createAgentDeviceCommandClient(executePreparedCommand), + command: { + wait: async (options) => await executeCommand('wait', options), + alert: async (options = {}) => await executeCommand('alert', options), + appState: async (options = {}) => await executeCommand('appstate', options), + back: async (options = {}) => await executeCommand('back', options), + home: async (options = {}) => await executeCommand('home', options), + rotate: async (options) => await executeCommand('rotate', options), + appSwitcher: async (options = {}) => await executeCommand('app-switcher', options), + keyboard: async (options = {}) => await executeCommand('keyboard', options), + clipboard: async (options) => await executeCommand('clipboard', options), + reactNative: async (options) => await executeCommand('react-native', options), + }, devices: { list: async (options = {}) => { - const data = await execute(PUBLIC_COMMANDS.devices, [], options); + const data = await executeCommand>('devices', options); const devices = Array.isArray(data.devices) ? data.devices : []; return devices.map(normalizeDevice); }, - boot: async (options = {}) => await executeCommandRequest(PUBLIC_COMMANDS.boot, [], options), + boot: async (options = {}) => await executeCommand('boot', options), }, sessions: { list: async (options = {}) => await listSessions(options), close: async (options = {}) => { const session = resolveRequestSession(options); - const data = await execute(PUBLIC_COMMANDS.close, [], options); + const data = await executeCommand>('close', options); const shutdown = data.shutdown; return { session, @@ -128,38 +124,28 @@ export function createAgentDeviceClient( apps: { install: async (options: AppDeployOptions) => normalizeDeployResult( - await execute(PUBLIC_COMMANDS.install, [options.app, options.appPath], options), + await executeCommand('install', options), resolveRequestSession(options), ), reinstall: async (options: AppDeployOptions) => normalizeDeployResult( - await execute(PUBLIC_COMMANDS.reinstall, [options.app, options.appPath], options), + await executeCommand('reinstall', options), resolveRequestSession(options), ), installFromSource: async (options: AppInstallFromSourceOptions) => normalizeInstallFromSourceResult( - await execute(INTERNAL_COMMANDS.installSource, [], { - ...options, - installSource: options.source, - retainMaterializedPaths: options.retainPaths, - materializedPathRetentionMs: options.retentionMs, - }), + await executeCommand('install-from-source', options), resolveRequestSession(options), ), list: async (options: AppListOptions = {}) => { - const data = await execute(PUBLIC_COMMANDS.apps, [], options); + const data = await executeCommand>('apps', options); return Array.isArray(data.apps) ? data.apps.filter((app): app is string => typeof app === 'string') : []; }, open: async (options: AppOpenOptions) => { const session = resolveRequestSession(options); - const positionals = options.app - ? options.url - ? [options.app, options.url] - : [options.app] - : []; - const data = await execute(PUBLIC_COMMANDS.open, positionals, options); + const data = await executeCommand>('open', options); const device = normalizeOpenDevice(data); const appBundleId = readOptionalString(data, 'appBundleId'); const appId = appBundleId; @@ -184,11 +170,7 @@ export function createAgentDeviceClient( }, close: async (options: AppCloseOptions = {}) => { const session = resolveRequestSession(options); - const data = await execute( - PUBLIC_COMMANDS.close, - options.app ? [options.app] : [], - options, - ); + const data = await executeCommand>('close', options); const shutdown = data.shutdown; return { session, @@ -200,18 +182,8 @@ export function createAgentDeviceClient( identifiers: { session }, }; }, - push: async (options) => - await executeCommandRequest( - PUBLIC_COMMANDS.push, - [options.app, stringifyPayload(options.payload)], - options, - ), - triggerEvent: async (options) => - await executeCommandRequest( - PUBLIC_COMMANDS.triggerAppEvent, - triggerEventPositionals(options), - options, - ), + push: async (options) => await executeCommand('push', options), + triggerEvent: async (options) => await executeCommand('trigger-app-event', options), }, materializations: { release: async (options: MaterializationReleaseOptions) => @@ -277,230 +249,56 @@ export function createAgentDeviceClient( capture: { snapshot: async (options: CaptureSnapshotOptions = {}) => { const session = resolveRequestSession(options); - const data = await execute(PUBLIC_COMMANDS.snapshot, [], options); + const data = await executeCommand>('snapshot', options); return normalizeSnapshotResult(data, session); }, screenshot: async (options: CaptureScreenshotOptions = {}) => { const session = resolveRequestSession(options); - const data = await execute(PUBLIC_COMMANDS.screenshot, options.path ? [options.path] : [], { - ...options, - ...screenshotFlagsFromOptions(options), - }); + const data = await executeCommand>('screenshot', options); return { path: readRequiredString(data, 'path'), overlayRefs: readScreenshotOverlayRefs(data), identifiers: { session }, }; }, - diff: async (options) => - await executeCommandRequest(PUBLIC_COMMANDS.diff, [options.kind], { - ...options, - interactiveOnly: options.interactiveOnly, - compact: options.compact, - depth: options.depth, - scope: options.scope, - raw: options.raw, - }), + diff: async (options) => await executeCommand('diff', options), }, interactions: { - click: async (options) => - await executeCommandRequest(PUBLIC_COMMANDS.click, interactionTargetCodec.encode(options), { - ...options, - clickButton: options.button, - }), - press: async (options) => - await executeCommandRequest( - PUBLIC_COMMANDS.press, - interactionTargetCodec.encode(options), - options, - ), - longPress: async (options) => - await executeCommandRequest( - PUBLIC_COMMANDS.longPress, - longPressCommandCodec.encode(options), - options, - ), - swipe: async (options) => - await executeCommandRequest( - PUBLIC_COMMANDS.swipe, - [ - String(options.from.x), - String(options.from.y), - String(options.to.x), - String(options.to.y), - ...optionalNumber(options.durationMs), - ], - options, - ), - pan: async (options) => - await executeCommandRequest( - PUBLIC_COMMANDS.gesture, - [ - 'pan', - String(options.x), - String(options.y), - String(options.dx), - String(options.dy), - ...optionalNumber(options.durationMs), - ], - options, - ), - fling: async (options) => { - const distance = - options.durationMs !== undefined ? (options.distance ?? 180) : options.distance; - return await executeCommandRequest( - PUBLIC_COMMANDS.gesture, - [ - 'fling', - options.direction, - String(options.x), - String(options.y), - ...optionalNumber(distance), - ...optionalNumber(options.durationMs), - ], - options, - ); - }, - focus: async (options) => - await executeCommandRequest( - PUBLIC_COMMANDS.focus, - [String(options.x), String(options.y)], - options, - ), - type: async (options) => - await executeCommandRequest( - PUBLIC_COMMANDS.type, - typeCommandCodec.encode(options), - options, - ), - fill: async (options) => - await executeCommandRequest( - PUBLIC_COMMANDS.fill, - fillCommandCodec.encode(options), - options, - ), - scroll: async (options) => - await executeCommandRequest( - PUBLIC_COMMANDS.scroll, - [options.direction, ...optionalNumber(options.amount)], - options, - ), - pinch: async (options) => - await executeCommandRequest( - PUBLIC_COMMANDS.gesture, - [ - 'pinch', - String(options.scale), - ...optionalNumber(options.x), - ...optionalNumber(options.y), - ], - options, - ), - rotateGesture: async (options) => { - if ( - (options.x === undefined && options.y !== undefined) || - (options.x !== undefined && options.y === undefined) - ) { - throw new AppError('INVALID_ARGS', 'gesture rotate center requires both x and y'); - } - const center = - options.x === undefined || options.y === undefined - ? [] - : [String(options.x), String(options.y)]; - return await executeCommandRequest( - PUBLIC_COMMANDS.gesture, - ['rotate', String(options.degrees), ...center, ...optionalNumber(options.velocity)], - options, - ); - }, - transformGesture: async (options) => - await executeCommandRequest( - PUBLIC_COMMANDS.gesture, - [ - 'transform', - String(options.x), - String(options.y), - String(options.dx), - String(options.dy), - String(options.scale), - String(options.degrees), - ...optionalNumber(options.durationMs), - ], - options, - ), - get: async (options) => - await executeCommandRequest( - PUBLIC_COMMANDS.get, - [options.format, ...elementTargetCodec.encode(options)], - options, - ), - is: async (options) => - await executeCommandRequest(PUBLIC_COMMANDS.is, isCommandCodec.encode(options), options), - find: async (options) => - await executeCommandRequest(PUBLIC_COMMANDS.find, findCommandCodec.encode(options), { - ...options, - findFirst: options.first, - findLast: options.last, - }), + click: async (options) => await executeCommand('click', options), + press: async (options) => await executeCommand('press', options), + longPress: async (options) => await executeCommand('longpress', options), + swipe: async (options) => await executeCommand('swipe', options), + pan: async (options) => await executeCommand('gesture-pan', options), + fling: async (options) => await executeCommand('gesture-fling', options), + focus: async (options) => await executeCommand('focus', options), + type: async (options) => await executeCommand('type', options), + fill: async (options) => await executeCommand('fill', options), + scroll: async (options) => await executeCommand('scroll', options), + pinch: async (options) => await executeCommand('gesture-pinch', options), + rotateGesture: async (options) => await executeCommand('gesture-rotate', options), + transformGesture: async (options) => await executeCommand('gesture-transform', options), + get: async (options) => await executeCommand('get', options), + is: async (options) => await executeCommand('is', options), + find: async (options) => await executeCommand('find', options), }, replay: { - run: async (options) => - await executeCommandRequest(PUBLIC_COMMANDS.replay, [options.path], { - ...options, - replayUpdate: options.update, - replayBackend: options.backend ?? (options.maestro === true ? 'maestro' : undefined), - replayEnv: options.env, - replayShellEnv: collectReplayClientShellEnv(process.env), - }), - test: async (options) => - await executeCommandRequest(PUBLIC_COMMANDS.test, options.paths, { - ...options, - replayUpdate: options.update, - replayBackend: options.backend ?? (options.maestro === true ? 'maestro' : undefined), - replayEnv: options.env, - replayShellEnv: collectReplayClientShellEnv(process.env), - }), + run: async (options) => await executeCommand('replay', options), + test: async (options) => await executeCommand('test', options), }, batch: { - run: async (options) => - await executeCommandRequest(PUBLIC_COMMANDS.batch, [], { - ...options, - batchSteps: options.steps, - batchOnError: options.onError, - batchMaxSteps: options.maxSteps, - }), + run: async (options) => await executeCommand('batch', options), }, observability: { - perf: async (options = {}) => await executeCommandRequest(PUBLIC_COMMANDS.perf, [], options), - logs: async (options = {}) => - await executeCommandRequest(PUBLIC_COMMANDS.logs, logsPositionals(options), options), - network: async (options = {}) => - await executeCommandRequest(PUBLIC_COMMANDS.network, networkPositionals(options), { - ...options, - networkInclude: options.include, - }), + perf: async (options = {}) => await executeCommand('perf', options), + logs: async (options = {}) => await executeCommand('logs', options), + network: async (options = {}) => await executeCommand('network', options), }, recording: { - record: async (options) => - await executeCommandRequest( - PUBLIC_COMMANDS.record, - [options.action, ...optionalString(options.path)], - options, - ), - trace: async (options) => - await executeCommandRequest( - PUBLIC_COMMANDS.trace, - [options.action, ...optionalString(options.path)], - options, - ), + record: async (options) => await executeCommand('record', options), + trace: async (options) => await executeCommand('trace', options), }, settings: { - update: async (options) => - await executeCommandRequest( - PUBLIC_COMMANDS.settings, - settingsCommandCodec.encode(options), - options, - ), + update: async (options) => await executeCommand('settings', options), }, }; } @@ -551,42 +349,6 @@ function readObject(value: unknown): Record | undefined { : undefined; } -function stringifyPayload(payload: AppPushOptions['payload']): string { - return typeof payload === 'string' ? payload : JSON.stringify(payload); -} - -function triggerEventPositionals(options: AppTriggerEventOptions): string[] { - return [options.event, ...(options.payload ? [JSON.stringify(options.payload)] : [])]; -} - -function logsPositionals(options: { action?: string; message?: string }): string[] { - return [options.action ?? 'path', ...optionalString(options.message)]; -} - -function networkPositionals(options: NetworkOptions): string[] { - return [...(options.action ? [options.action] : []), ...optionalNumber(options.limit)]; -} - -function optionalString(value: string | undefined): string[] { - return value === undefined ? [] : [value]; -} - -function optionalNumber(value: number | undefined): string[] { - return value === undefined ? [] : [String(value)]; -} - -const REPLAY_SHELL_ENV_PREFIX = 'AD_VAR_'; - -function collectReplayClientShellEnv(env: NodeJS.ProcessEnv): Record { - const result: Record = {}; - for (const [key, value] of Object.entries(env)) { - if (typeof value === 'string' && key.startsWith(REPLAY_SHELL_ENV_PREFIX)) { - result[key] = value; - } - } - return result; -} - function mergeClientOptions( config: AgentDeviceClientConfig, options: InternalRequestOptions, diff --git a/src/command-catalog.ts b/src/command-catalog.ts index 9dc44851f..aace45853 100644 --- a/src/command-catalog.ts +++ b/src/command-catalog.ts @@ -54,18 +54,50 @@ export const INTERNAL_COMMANDS = { sessionList: 'session_list', } as const; +const LOCAL_CLI_COMMANDS = { + auth: 'auth', + connect: 'connect', + connection: 'connection', + disconnect: 'disconnect', + mcp: 'mcp', + metro: 'metro', + reactDevtools: 'react-devtools', + session: 'session', +} as const; + const GESTURE_SUBCOMMANDS = ['pan', 'fling', 'pinch', 'rotate', 'transform'] as const; export const GESTURE_SUBCOMMAND_ERROR = `gesture requires one of: ${GESTURE_SUBCOMMANDS.join(', ')}`; export type PublicCommandName = (typeof PUBLIC_COMMANDS)[keyof typeof PUBLIC_COMMANDS]; -export type CliCommandName = - | PublicCommandName - | 'auth' - | 'connect' - | 'connection' - | 'disconnect' - | 'metro' - | 'session'; +export type LocalCliCommandName = (typeof LOCAL_CLI_COMMANDS)[keyof typeof LOCAL_CLI_COMMANDS]; +export type CliCommandName = PublicCommandName | LocalCliCommandName; + +const MCP_UNEXPOSED_CLI_COMMANDS = commandSet( + LOCAL_CLI_COMMANDS.auth, + LOCAL_CLI_COMMANDS.connect, + LOCAL_CLI_COMMANDS.connection, + LOCAL_CLI_COMMANDS.disconnect, + LOCAL_CLI_COMMANDS.mcp, + LOCAL_CLI_COMMANDS.reactDevtools, +); + +const CAPABILITY_EXEMPT_CLI_COMMANDS = commandSet( + LOCAL_CLI_COMMANDS.auth, + LOCAL_CLI_COMMANDS.connect, + LOCAL_CLI_COMMANDS.connection, + LOCAL_CLI_COMMANDS.disconnect, + LOCAL_CLI_COMMANDS.mcp, + LOCAL_CLI_COMMANDS.metro, + LOCAL_CLI_COMMANDS.reactDevtools, + LOCAL_CLI_COMMANDS.session, + PUBLIC_COMMANDS.appState, + PUBLIC_COMMANDS.batch, + PUBLIC_COMMANDS.devices, + PUBLIC_COMMANDS.gesture, + PUBLIC_COMMANDS.replay, + PUBLIC_COMMANDS.test, + PUBLIC_COMMANDS.trace, +); export const DAEMON_COMMAND_GROUPS = { inventory: commandSet( @@ -148,3 +180,15 @@ export const DAEMON_COMMAND_GROUPS = { function commandSet(...commands: readonly string[]): ReadonlySet { return new Set(commands); } + +export function listCliCommandNames(): CliCommandName[] { + return [...Object.values(PUBLIC_COMMANDS), ...Object.values(LOCAL_CLI_COMMANDS)].sort(); +} + +export function listMcpExposedCommandNames(): CliCommandName[] { + return listCliCommandNames().filter((command) => !MCP_UNEXPOSED_CLI_COMMANDS.has(command)); +} + +export function listCapabilityCheckedCommandNames(): CliCommandName[] { + return listCliCommandNames().filter((command) => !CAPABILITY_EXEMPT_CLI_COMMANDS.has(command)); +} diff --git a/src/command-codecs.ts b/src/command-codecs.ts deleted file mode 100644 index e8cd15744..000000000 --- a/src/command-codecs.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { - elementTargetToPositionals, - fillOptionsToPositionals, - interactionTargetToPositionals, - readElementTargetFromPositionals, - readFillTargetFromPositionals, - readInteractionTargetFromPositionals, - longPressOptionsToPositionals, - readLongPressTargetFromPositionals, -} from './command-codecs/targets.ts'; -import { readWaitOptionsFromPositionals, waitOptionsToPositionals } from './command-codecs/wait.ts'; -import { findOptionsToPositionals, readFindOptionsFromPositionals } from './command-codecs/find.ts'; -import { isOptionsToPositionals, readIsOptionsFromPositionals } from './command-codecs/is.ts'; -import { - readSettingsOptionsFromPositionals, - settingsOptionsToPositionals, -} from './command-codecs/settings.ts'; - -export const interactionTargetCodec = { - decode: readInteractionTargetFromPositionals, - encode: interactionTargetToPositionals, -} as const; - -export const elementTargetCodec = { - decode: readElementTargetFromPositionals, - encode: elementTargetToPositionals, -} as const; - -export const fillCommandCodec = { - decode: readFillTargetFromPositionals, - encode: fillOptionsToPositionals, -} as const; - -export const longPressCommandCodec = { - decode: readLongPressTargetFromPositionals, - encode: longPressOptionsToPositionals, -} as const; - -export const waitCommandCodec = { - decode: readWaitOptionsFromPositionals, - encode: waitOptionsToPositionals, -} as const; - -export const findCommandCodec = { - decode: readFindOptionsFromPositionals, - encode: findOptionsToPositionals, -} as const; - -export const isCommandCodec = { - decode: readIsOptionsFromPositionals, - encode: isOptionsToPositionals, -} as const; - -export const settingsCommandCodec = { - decode: readSettingsOptionsFromPositionals, - encode: settingsOptionsToPositionals, -} as const; diff --git a/src/command-codecs/find.ts b/src/command-codecs/find.ts deleted file mode 100644 index 686e4abfc..000000000 --- a/src/command-codecs/find.ts +++ /dev/null @@ -1,123 +0,0 @@ -import type { FindOptions } from '../client-types.ts'; -import type { CliFlags } from '../utils/command-schema.ts'; -import { AppError } from '../utils/errors.ts'; -import type { FindLocator } from '../utils/finders.ts'; -import { selectionOptionsFromFlags } from './flags.ts'; - -export function readFindOptionsFromPositionals( - positionals: string[], - flags: CliFlags, -): FindOptions { - const base = { - ...findSnapshotOptionsFromFlags(flags), - ...selectionOptionsFromFlags(flags), - first: flags.findFirst, - last: flags.findLast, - }; - const locator = readFindLocator(positionals[0]); - const hasExplicitLocator = locator !== undefined; - const query = hasExplicitLocator ? positionals[1] : positionals[0]; - const actionOffset = hasExplicitLocator ? 2 : 1; - const action = positionals[actionOffset]; - if (action === undefined) { - return { ...base, locator, query: readRequiredQuery(query) }; - } - if (action === 'get') { - const subcommand = positionals[actionOffset + 1]; - if (subcommand === 'text') { - return { ...base, locator, query: readRequiredQuery(query), action: 'getText' }; - } - if (subcommand === 'attrs') { - return { - ...base, - locator, - query: readRequiredQuery(query), - action: 'getAttrs', - }; - } - throw new AppError('INVALID_ARGS', 'find get only supports text or attrs'); - } - if (action === 'wait') { - return { - ...base, - locator, - query: readRequiredQuery(query), - action: 'wait', - timeoutMs: readOptionalTimeoutMs(positionals[actionOffset + 1]), - }; - } - if (action === 'fill' || action === 'type') { - return { - ...base, - locator, - query: readRequiredQuery(query), - action, - value: positionals.slice(actionOffset + 1).join(' '), - }; - } - if (action === 'click' || action === 'focus' || action === 'exists') { - return { ...base, locator, query: readRequiredQuery(query), action }; - } - throw new AppError('INVALID_ARGS', `Unsupported find action: ${action}`); -} - -export function findOptionsToPositionals(options: FindOptions): string[] { - const args = - options.locator && options.locator !== 'any' - ? [options.locator, options.query] - : [options.query]; - switch (options.action) { - case undefined: - case 'click': - case 'focus': - case 'exists': - return options.action ? [...args, options.action] : args; - case 'getText': - return [...args, 'get', 'text']; - case 'getAttrs': - return [...args, 'get', 'attrs']; - case 'wait': - return [...args, 'wait', ...optionalNumberValue(options.timeoutMs)]; - case 'fill': - case 'type': - return [...args, options.action, options.value]; - } -} - -function readFindLocator(value: string | undefined): FindLocator | undefined { - if ( - value === 'text' || - value === 'label' || - value === 'value' || - value === 'role' || - value === 'id' - ) { - return value; - } - return undefined; -} - -function findSnapshotOptionsFromFlags(flags: CliFlags): { - depth?: number; - raw?: boolean; -} { - return { - depth: flags.snapshotDepth, - raw: flags.snapshotRaw, - }; -} - -function readRequiredQuery(value: string | undefined): string { - if (value === undefined || value === '') { - throw new AppError('INVALID_ARGS', 'find requires query'); - } - return value; -} - -function readOptionalTimeoutMs(value: string | undefined): number | undefined { - return value === undefined ? undefined : Number(value); -} - -function optionalNumberValue(value: number | undefined): string[] { - return value === undefined ? [] : [String(value)]; -} diff --git a/src/command-codecs/flags.ts b/src/command-codecs/flags.ts deleted file mode 100644 index 675ac3927..000000000 --- a/src/command-codecs/flags.ts +++ /dev/null @@ -1,35 +0,0 @@ -import type { CliFlags } from '../utils/command-schema.ts'; - -export type SelectionOptions = { - platform?: CliFlags['platform']; - target?: CliFlags['target']; - device?: string; - udid?: string; - serial?: string; - iosSimulatorDeviceSet?: string; - androidDeviceAllowlist?: string; -}; - -export function selectionOptionsFromFlags(flags: CliFlags): SelectionOptions { - return { - platform: flags.platform, - target: flags.target, - device: flags.device, - udid: flags.udid, - serial: flags.serial, - iosSimulatorDeviceSet: flags.iosSimulatorDeviceSet, - androidDeviceAllowlist: flags.androidDeviceAllowlist, - }; -} - -export function selectorSnapshotOptionsFromFlags(flags: CliFlags): { - depth?: number; - scope?: string; - raw?: boolean; -} { - return { - depth: flags.snapshotDepth, - scope: flags.snapshotScope, - raw: flags.snapshotRaw, - }; -} diff --git a/src/command-codecs/is.ts b/src/command-codecs/is.ts deleted file mode 100644 index 9fc9cbff7..000000000 --- a/src/command-codecs/is.ts +++ /dev/null @@ -1,41 +0,0 @@ -import type { IsOptions } from '../client-types.ts'; -import { splitSelectorFromArgs } from '../daemon/selectors.ts'; -import type { CliFlags } from '../utils/command-schema.ts'; -import { AppError } from '../utils/errors.ts'; -import { selectionOptionsFromFlags, selectorSnapshotOptionsFromFlags } from './flags.ts'; - -export function readIsOptionsFromPositionals(positionals: string[], flags: CliFlags): IsOptions { - const base = { - ...selectorSnapshotOptionsFromFlags(flags), - ...selectionOptionsFromFlags(flags), - }; - const predicate = positionals[0]; - const split = splitSelectorFromArgs(positionals.slice(1), { - preferTrailingValue: predicate === 'text', - }); - if (!split) throw new AppError('INVALID_ARGS', 'is requires a selector expression'); - if (predicate === 'text') { - return { ...base, predicate, selector: split.selectorExpression, value: split.rest.join(' ') }; - } - if ( - predicate === 'visible' || - predicate === 'hidden' || - predicate === 'exists' || - predicate === 'editable' || - predicate === 'selected' - ) { - return { ...base, predicate, selector: split.selectorExpression }; - } - throw new AppError( - 'INVALID_ARGS', - 'is requires predicate: visible|hidden|exists|editable|selected|text', - ); -} - -export function isOptionsToPositionals(options: IsOptions): string[] { - return [ - options.predicate, - options.selector, - ...(options.predicate === 'text' ? [options.value] : []), - ]; -} diff --git a/src/command-codecs/settings.ts b/src/command-codecs/settings.ts deleted file mode 100644 index 9a71cb8fd..000000000 --- a/src/command-codecs/settings.ts +++ /dev/null @@ -1,115 +0,0 @@ -import type { SettingsUpdateOptions } from '../client-types.ts'; -import type { CliFlags } from '../utils/command-schema.ts'; -import { AppError } from '../utils/errors.ts'; -import { readLocationCoordinate } from '../utils/location-coordinates.ts'; -import { selectionOptionsFromFlags } from './flags.ts'; - -type PermissionTarget = Extract['permission']; -type OnOffSetting = Extract['setting']; -type OnOffState = Extract['state']; -type BiometricSetting = Extract< - SettingsUpdateOptions, - { setting: 'faceid' | 'touchid' } ->['setting']; -type BiometricState = Extract['state']; -type FingerprintState = Extract['state']; -type AppearanceState = Extract['state']; -type PermissionState = Extract['state']; - -const ON_OFF_SETTINGS = setOf('wifi', 'airplane', 'location', 'animations'); -const ON_OFF_STATES = setOf('on', 'off'); -const APPEARANCE_STATES = setOf('light', 'dark', 'toggle'); -const BIOMETRIC_SETTINGS = setOf('faceid', 'touchid'); -const BIOMETRIC_STATES = setOf('match', 'nonmatch', 'enroll', 'unenroll'); -const FINGERPRINT_STATES = setOf('match', 'nonmatch'); -const PERMISSION_STATES = setOf('grant', 'deny', 'reset'); -const PERMISSION_TARGETS = setOf( - 'camera', - 'microphone', - 'photos', - 'contacts', - 'contacts-limited', - 'notifications', - 'calendar', - 'location', - 'location-always', - 'media-library', - 'motion', - 'reminders', - 'siri', - 'accessibility', - 'screen-recording', - 'input-monitoring', -); - -export function readSettingsOptionsFromPositionals( - positionals: string[], - flags: CliFlags, -): SettingsUpdateOptions { - const base = selectionOptionsFromFlags(flags); - const setting = positionals[0]; - const state = positionals[1]; - if (isOneOf(setting, ON_OFF_SETTINGS) && isOneOf(state, ON_OFF_STATES)) { - return { ...base, setting, state }; - } - if (setting === 'location' && state === 'set') { - return { - ...base, - setting, - state, - latitude: readLocationCoordinate(positionals[2], 'latitude'), - longitude: readLocationCoordinate(positionals[3], 'longitude'), - }; - } - if (setting === 'appearance' && isOneOf(state, APPEARANCE_STATES)) { - return { ...base, setting, state }; - } - if (isOneOf(setting, BIOMETRIC_SETTINGS) && isOneOf(state, BIOMETRIC_STATES)) { - return { ...base, setting, state }; - } - if (setting === 'fingerprint' && isOneOf(state, FINGERPRINT_STATES)) { - return { ...base, setting, state }; - } - if (setting === 'permission' && isOneOf(state, PERMISSION_STATES)) { - return { - ...base, - setting, - state, - permission: readPermission(positionals[2]), - mode: readPermissionMode(positionals[3]), - }; - } - throw new AppError('INVALID_ARGS', 'Invalid settings arguments.'); -} - -export function settingsOptionsToPositionals(options: SettingsUpdateOptions): string[] { - if (options.setting === 'location' && options.state === 'set') { - return [options.setting, options.state, String(options.latitude), String(options.longitude)]; - } - if (options.setting === 'permission') { - return [options.setting, options.state, options.permission, ...optionalString(options.mode)]; - } - return [options.setting, options.state]; -} - -function readPermission(value: string | undefined): PermissionTarget { - if (isOneOf(value, PERMISSION_TARGETS)) return value; - throw new AppError('INVALID_ARGS', 'settings permission requires a permission target.'); -} - -function readPermissionMode(value: string | undefined): 'full' | 'limited' | undefined { - if (value === undefined || value === 'full' || value === 'limited') return value; - throw new AppError('INVALID_ARGS', 'settings permission mode must be full or limited.'); -} - -function optionalString(value: string | undefined): string[] { - return value === undefined ? [] : [value]; -} - -function setOf(...values: T[]): ReadonlySet { - return new Set(values); -} - -function isOneOf(value: string | undefined, values: ReadonlySet): value is T { - return value !== undefined && values.has(value as T); -} diff --git a/src/command-codecs/targets.ts b/src/command-codecs/targets.ts deleted file mode 100644 index 1857d1c19..000000000 --- a/src/command-codecs/targets.ts +++ /dev/null @@ -1,126 +0,0 @@ -import type { - ElementTarget, - FillOptions, - InteractionTarget, - LongPressOptions, -} from '../client-types.ts'; -import { splitSelectorFromArgs } from '../daemon/selectors.ts'; -import { AppError } from '../utils/errors.ts'; - -export type DecodedFillTarget = - | { kind: 'ref'; target: { ref: string; label?: string }; text: string } - | { kind: 'selector'; target: { selector: string }; text: string } - | { kind: 'point'; target: { x: number; y: number }; text: string }; - -export function readInteractionTargetFromPositionals(positionals: string[]): InteractionTarget { - if (positionals[0]?.startsWith('@')) { - const label = optionalTrimmedText(positionals.slice(1)); - return { ref: positionals[0], ...(label === undefined ? {} : { label }) }; - } - const selectorArgs = splitSelectorFromArgs(positionals); - if (selectorArgs) return { selector: selectorArgs.selectorExpression }; - return { x: Number(positionals[0]), y: Number(positionals[1]) }; -} - -export function interactionTargetToPositionals(options: InteractionTarget): string[] { - if (options.ref !== undefined) return [options.ref, ...optionalString(options.label)]; - if (options.selector !== undefined) return [options.selector]; - return [String(options.x), String(options.y)]; -} - -export function readLongPressTargetFromPositionals(positionals: string[]): LongPressOptions { - const targetPositionals = readLongPressTargetPositionals(positionals); - return { - ...readInteractionTargetFromPositionals(targetPositionals.target), - ...(targetPositionals.durationMs !== undefined - ? { durationMs: targetPositionals.durationMs } - : {}), - }; -} - -export function longPressOptionsToPositionals(options: LongPressOptions): string[] { - return [ - ...interactionTargetToPositionals(options), - ...(options.durationMs === undefined ? [] : [String(options.durationMs)]), - ]; -} - -export function readElementTargetFromPositionals(positionals: string[]): ElementTarget { - if (positionals[0]?.startsWith('@')) { - return { ref: positionals[0], label: optionalTrimmedText(positionals.slice(1)) }; - } - const selector = positionals.join(' ').trim(); - if (!selector) throw new AppError('INVALID_ARGS', 'get requires @ref or selector expression'); - return { selector }; -} - -export function elementTargetToPositionals(options: ElementTarget): string[] { - if (options.ref !== undefined) return [options.ref, ...optionalString(options.label)]; - return [options.selector]; -} - -export function readFillTargetFromPositionals(positionals: string[]): DecodedFillTarget { - if (positionals[0]?.startsWith('@')) { - const text = - positionals.length >= 3 ? positionals.slice(2).join(' ') : positionals.slice(1).join(' '); - return { - kind: 'ref', - target: { - ref: positionals[0], - label: positionals.length >= 3 ? optionalTrimmedText([positionals[1]]) : undefined, - }, - text, - }; - } - const selectorArgs = splitSelectorFromArgs(positionals, { preferTrailingValue: true }); - if (selectorArgs) { - return { - kind: 'selector', - target: { selector: selectorArgs.selectorExpression }, - text: selectorArgs.rest.join(' '), - }; - } - return { - kind: 'point', - target: { x: Number(positionals[0]), y: Number(positionals[1]) }, - text: positionals.slice(2).join(' '), - }; -} - -export function fillOptionsToPositionals(options: FillOptions): string[] { - return [...interactionTargetToPositionals(options), options.text]; -} - -function optionalString(value: string | undefined): string[] { - return value === undefined ? [] : [value]; -} - -function optionalTrimmedText(values: string[]): string | undefined { - const text = values.join(' ').trim(); - return text || undefined; -} - -function readLongPressTargetPositionals(positionals: string[]): { - target: string[]; - durationMs?: number; -} { - if (isFiniteNumberString(positionals[0]) && isFiniteNumberString(positionals[1])) { - return { - target: positionals.slice(0, 2), - ...(positionals[2] !== undefined ? { durationMs: Number(positionals[2]) } : {}), - }; - } - const last = positionals.at(-1); - if (positionals.length > 1 && isFiniteNumberString(last)) { - return { - target: positionals.slice(0, -1), - durationMs: Number(last), - }; - } - return { target: positionals }; -} - -function isFiniteNumberString(value: string | undefined): boolean { - if (value === undefined || value.trim() === '') return false; - return Number.isFinite(Number(value)); -} diff --git a/src/command-codecs/wait.ts b/src/command-codecs/wait.ts deleted file mode 100644 index bb5c32f41..000000000 --- a/src/command-codecs/wait.ts +++ /dev/null @@ -1,107 +0,0 @@ -import type { WaitCommandOptions } from '../client-types.ts'; -import { parseTimeout } from '../daemon/handlers/parse-utils.ts'; -import { splitSelectorFromArgs, tryParseSelectorChain } from '../daemon/selectors.ts'; -import type { CliFlags } from '../utils/command-schema.ts'; -import { AppError } from '../utils/errors.ts'; -import { selectionOptionsFromFlags, selectorSnapshotOptionsFromFlags } from './flags.ts'; - -export type WaitParsed = - | { kind: 'sleep'; durationMs: number } - | { kind: 'ref'; rawRef: string; timeoutMs: number | null } - | { kind: 'selector'; selectorExpression: string; timeoutMs: number | null } - | { kind: 'text'; text: string; timeoutMs: number | null }; - -export function readWaitOptionsFromPositionals( - positionals: string[], - flags: CliFlags, -): WaitCommandOptions { - const parsed = parseWaitPositionals(positionals); - if (!parsed) { - throw new AppError( - 'INVALID_ARGS', - 'wait requires , text , @ref, or [timeoutMs].', - ); - } - - const base = { - ...selectionOptionsFromFlags(flags), - ...selectorSnapshotOptionsFromFlags(flags), - }; - - if (parsed.kind === 'sleep') return { ...base, durationMs: parsed.durationMs }; - if (parsed.kind === 'text') { - if (!parsed.text) throw new AppError('INVALID_ARGS', 'wait requires text.'); - return { ...base, text: parsed.text, ...readTimeoutOption(parsed.timeoutMs) }; - } - if (parsed.kind === 'ref') { - return { ...base, ref: parsed.rawRef, ...readTimeoutOption(parsed.timeoutMs) }; - } - return { - ...base, - selector: parsed.selectorExpression, - ...readTimeoutOption(parsed.timeoutMs), - }; -} - -export function waitOptionsToPositionals(options: WaitCommandOptions): string[] { - const targets = [ - options.durationMs !== undefined ? 'durationMs' : undefined, - options.text !== undefined ? 'text' : undefined, - options.ref !== undefined ? 'ref' : undefined, - options.selector !== undefined ? 'selector' : undefined, - ].filter(Boolean); - if (targets.length !== 1) { - throw new AppError( - 'INVALID_ARGS', - 'wait command requires exactly one of durationMs, text, ref, or selector.', - ); - } - if (options.durationMs !== undefined) return [String(options.durationMs)]; - const timeout = options.timeoutMs !== undefined ? [String(options.timeoutMs)] : []; - if (options.text !== undefined) return ['text', options.text, ...timeout]; - if (options.ref !== undefined) return [options.ref, ...timeout]; - const selector = options.selector!; - assertValidSelector(selector); - return [selector, ...timeout]; -} - -export function parseWaitPositionals(args: string[]): WaitParsed | null { - if (args.length === 0) return null; - - const sleepMs = parseTimeout(args[0]); - if (sleepMs !== null) return { kind: 'sleep', durationMs: sleepMs }; - - if (args[0] === 'text') { - const timeoutMs = parseTimeout(args[args.length - 1]); - const text = timeoutMs !== null ? args.slice(1, -1).join(' ') : args.slice(1).join(' '); - return { kind: 'text', text: text.trim(), timeoutMs }; - } - - if (args[0].startsWith('@')) { - const timeoutMs = parseTimeout(args[args.length - 1]); - return { kind: 'ref', rawRef: args[0], timeoutMs }; - } - - const timeoutMs = parseTimeout(args[args.length - 1]); - const argsWithoutTimeout = timeoutMs !== null ? args.slice(0, -1) : args.slice(); - const split = splitSelectorFromArgs(argsWithoutTimeout); - if (split && split.rest.length === 0 && tryParseSelectorChain(split.selectorExpression)) { - return { - kind: 'selector', - selectorExpression: split.selectorExpression, - timeoutMs, - }; - } - - const text = timeoutMs !== null ? args.slice(0, -1).join(' ') : args.join(' '); - return { kind: 'text', text: text.trim(), timeoutMs }; -} - -function assertValidSelector(selector: string): void { - if (tryParseSelectorChain(selector)) return; - throw new AppError('INVALID_ARGS', `Invalid wait selector: ${selector}`); -} - -function readTimeoutOption(timeoutMs: number | null): { timeoutMs?: number } { - return timeoutMs === null ? {} : { timeoutMs }; -} diff --git a/src/commands/__tests__/capture-screenshot-options.test.ts b/src/commands/__tests__/capture-screenshot-options.test.ts index 6dd2c2ed2..d998a81b4 100644 --- a/src/commands/__tests__/capture-screenshot-options.test.ts +++ b/src/commands/__tests__/capture-screenshot-options.test.ts @@ -6,8 +6,44 @@ import { SCREENSHOT_COMMAND_FLAG_KEYS, SCREENSHOT_SPECIFIC_FLAG_DEFINITIONS, readScreenshotScriptFlag, + screenshotFlagsFromOptions, + screenshotOptionsFromFlags, } from '../capture-screenshot-options.ts'; +test('screenshot flag projection maps CLI flags to runtime options', () => { + assert.deepEqual( + screenshotOptionsFromFlags({ + overlayRefs: true, + screenshotFullscreen: true, + screenshotMaxSize: 1024, + screenshotNoStabilize: true, + }), + { + overlayRefs: true, + fullscreen: true, + maxSize: 1024, + stabilize: false, + }, + ); +}); + +test('screenshot flag projection maps public options to request flags', () => { + assert.deepEqual( + screenshotFlagsFromOptions({ + overlayRefs: true, + fullscreen: false, + maxSize: 512, + stabilize: false, + }), + { + overlayRefs: true, + screenshotFullscreen: false, + screenshotMaxSize: 512, + screenshotNoStabilize: true, + }, + ); +}); + test('screenshot script flags use the shared recorded flag contract', () => { const parts: string[] = []; const flags = {}; diff --git a/src/commands/batch-command.ts b/src/commands/batch-command.ts new file mode 100644 index 000000000..f05dc6b1a --- /dev/null +++ b/src/commands/batch-command.ts @@ -0,0 +1,160 @@ +import type { BatchRunOptions, BatchStep } from '../client-types.ts'; +import { DEFAULT_BATCH_MAX_STEPS } from '../core/batch.ts'; +import { defineCommand, type JsonSchema } from './command-contract.ts'; +import { type DaemonCommandName } from './command-projection.ts'; +import { + assertAllowedKeys, + commonToClientOptions, + customField, + enumField, + fieldsInputSchema, + integerField, + readFieldInput, + requiredEnum, + requiredField, + stringField, + type InferCommandInput, + type CommandFieldMap, +} from './command-input.ts'; + +type BatchInput = InferCommandInput & { + steps: BatchStep[]; + onError?: 'stop'; + maxSteps?: number; + out?: string; +}; + +export function createBatchCommand( + nestedCommands: readonly TCommand[], +) { + const fields = batchFields(nestedCommands); + return defineCommand({ + name: 'batch', + description: 'Run multiple structured command steps in one daemon request.', + inputSchema: fieldsInputSchema(fields), + readInput: (input) => readBatchInput(input, fields), + run: (client, input) => client.batch.run(toBatchOptions(input)), + }); +} + +function batchFields(nestedCommands: readonly DaemonCommandName[]) { + return { + steps: requiredField( + customField( + { + type: 'array', + description: + 'Structured batch steps. Each step uses a command name and the same input object as that command tool.', + items: batchStepSchema(nestedCommands), + }, + (record, key) => readBatchSteps(record[key], nestedCommands), + ), + ), + onError: enumField(['stop'] as const, 'Batch failure policy.'), + maxSteps: integerField('Maximum number of steps accepted for this batch.', { + min: 1, + max: 1000, + }), + out: stringField('Optional output path for command artifacts.'), + }; +} + +function batchStepSchema(nestedCommands: readonly DaemonCommandName[]): JsonSchema { + return { + type: 'object', + properties: { + command: { + type: 'string', + enum: nestedCommands, + description: 'Command name to run with structured input.', + }, + input: { + type: 'object', + additionalProperties: true, + description: + 'Structured command input for the nested command. Use the matching MCP tool schema for this object.', + }, + runtime: { + type: 'object', + additionalProperties: true, + description: 'Optional per-step runtime payload.', + }, + }, + required: ['command', 'input'], + additionalProperties: false, + }; +} + +function readBatchInput(input: unknown, fields: ReturnType): BatchInput { + const parsed = readFieldInput(input, fields); + const maxSteps = parsed.maxSteps ?? DEFAULT_BATCH_MAX_STEPS; + if (!Number.isInteger(maxSteps) || maxSteps < 1 || maxSteps > 1000) { + throw new Error(`Invalid batch maxSteps: ${String(parsed.maxSteps)}`); + } + if (parsed.steps.length > maxSteps) { + throw new Error(`batch has ${parsed.steps.length} steps; max allowed is ${maxSteps}.`); + } + return { + ...parsed, + }; +} + +function readBatchSteps(steps: unknown, nestedCommands: readonly DaemonCommandName[]): BatchStep[] { + if (!Array.isArray(steps)) { + throw new Error('Expected steps to be an array.'); + } + return steps.map((step, index) => readBatchStep(step, index + 1, nestedCommands)); +} + +function readBatchStep( + step: unknown, + stepNumber: number, + nestedCommands: readonly DaemonCommandName[], +): BatchStep { + const record = readBatchStepRecord(step, stepNumber); + assertAllowedKeys(record, ['command', 'input', 'runtime'], `Batch step ${stepNumber}`); + return { + command: requiredEnum(record, 'command', nestedCommands), + input: readBatchStepInput(record, stepNumber), + ...readBatchStepRuntimeProperty(record, stepNumber), + }; +} + +function readBatchStepRecord(step: unknown, stepNumber: number): Record { + if (!step || typeof step !== 'object' || Array.isArray(step)) { + throw new Error(`Invalid batch step ${stepNumber}.`); + } + return step as Record; +} + +function readBatchStepInput(record: Record, stepNumber: number) { + const input = record.input; + if (!input || typeof input !== 'object' || Array.isArray(input)) { + throw new Error(`Batch step ${stepNumber} input must be an object.`); + } + return input as Record; +} + +function readBatchStepRuntimeProperty( + record: Record, + stepNumber: number, +): Pick { + const runtime = record.runtime; + if ( + runtime !== undefined && + (!runtime || typeof runtime !== 'object' || Array.isArray(runtime)) + ) { + throw new Error(`Batch step ${stepNumber} runtime must be an object.`); + } + return runtime === undefined ? {} : { runtime }; +} + +function toBatchOptions(input: BatchInput): BatchRunOptions { + return { + ...commonToClientOptions(input), + steps: input.steps, + onError: input.onError, + maxSteps: input.maxSteps, + out: input.out, + }; +} diff --git a/src/commands/capture-definition.ts b/src/commands/capture-definition.ts deleted file mode 100644 index b437b9f17..000000000 --- a/src/commands/capture-definition.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { PUBLIC_COMMANDS } from '../command-catalog.ts'; -import { - ALL_DEVICE_COMMAND_CAPABILITY, - commandCapabilityMap, - commandSchemaMap, - defineCommand, -} from './command-definition.ts'; -import { SCREENSHOT_COMMAND_FLAG_KEYS } from './capture-screenshot-options.ts'; - -const SNAPSHOT_FLAGS = [ - 'snapshotInteractiveOnly', - 'snapshotCompact', - 'snapshotDepth', - 'snapshotScope', - 'snapshotRaw', -] as const; - -const snapshotCommandDefinition = defineCommand({ - name: PUBLIC_COMMANDS.snapshot, - schema: { - usageOverride: 'snapshot [--diff] [-i] [-c] [-d ] [-s ] [--raw] [--force-full]', - helpDescription: 'Capture accessibility tree or diff against the previous session baseline', - positionalArgs: [], - allowedFlags: ['snapshotDiff', ...SNAPSHOT_FLAGS, 'snapshotForceFull'], - }, - capability: ALL_DEVICE_COMMAND_CAPABILITY, -}); - -const diffCommandDefinition = defineCommand({ - name: PUBLIC_COMMANDS.diff, - schema: { - usageOverride: - 'diff snapshot | diff screenshot --baseline [current.png] [--out ] [--threshold <0-1>] [--overlay-refs]', - helpDescription: 'Diff accessibility snapshot or compare screenshots pixel-by-pixel', - summary: 'Diff snapshot or screenshot', - positionalArgs: ['kind', 'current?'], - allowedFlags: [...SNAPSHOT_FLAGS, 'baseline', 'threshold', 'out', 'overlayRefs'], - }, - capability: ALL_DEVICE_COMMAND_CAPABILITY, -}); - -const screenshotCommandDefinition = defineCommand({ - name: PUBLIC_COMMANDS.screenshot, - schema: { - helpDescription: - 'Capture screenshot (macOS app sessions default to the app window; use --fullscreen for full desktop, --max-size to downscale, --overlay-refs to annotate current refs, or --no-stabilize for low-latency Android capture loops)', - positionalArgs: ['path?'], - allowedFlags: SCREENSHOT_COMMAND_FLAG_KEYS, - }, - capability: ALL_DEVICE_COMMAND_CAPABILITY, -}); - -export const CAPTURE_COMMAND_DEFINITIONS = [ - snapshotCommandDefinition, - diffCommandDefinition, - screenshotCommandDefinition, -] as const; - -export const CAPTURE_COMMAND_SCHEMAS = commandSchemaMap(CAPTURE_COMMAND_DEFINITIONS); -export const CAPTURE_COMMAND_CAPABILITIES = commandCapabilityMap(CAPTURE_COMMAND_DEFINITIONS); diff --git a/src/commands/cli-grammar.ts b/src/commands/cli-grammar.ts new file mode 100644 index 000000000..9ab744810 --- /dev/null +++ b/src/commands/cli-grammar.ts @@ -0,0 +1 @@ +export { readInputFromCli } from './cli-grammar/registry.ts'; diff --git a/src/commands/cli-grammar/apps.ts b/src/commands/cli-grammar/apps.ts new file mode 100644 index 000000000..415047b09 --- /dev/null +++ b/src/commands/cli-grammar/apps.ts @@ -0,0 +1,192 @@ +import { INTERNAL_COMMANDS, PUBLIC_COMMANDS } from '../../command-catalog.ts'; +import type { AppPushOptions, AppTriggerEventOptions } from '../../client-types.ts'; +import type { CliFlags } from '../../utils/command-schema.ts'; +import { AppError } from '../../utils/errors.ts'; +import { parseGitHubActionsArtifactInstallSourceSpec } from '../../utils/install-source-config.ts'; +import { assertResolvedAppsFilter } from '../app-inventory-contract.ts'; +import { + commonInputFromFlags, + direct, + optionalString, + readJsonObject, + request, + requiredDaemonString, + requiredString, +} from './common.ts'; +import type { CliReader, DaemonWriter, CommandInput } from './types.ts'; + +export const appCliReaders = { + devices: (_positionals, flags) => commonInputFromFlags(flags), + apps: (_positionals, flags) => ({ + ...commonInputFromFlags(flags), + appsFilter: assertResolvedAppsFilter(flags.appsFilter), + }), + session: (positionals, flags) => ({ + ...commonInputFromFlags(flags), + action: readSessionAction(positionals[0]), + }), + boot: (_positionals, flags) => ({ + ...commonInputFromFlags(flags), + headless: flags.headless, + }), + open: (positionals, flags) => ({ + ...commonInputFromFlags(flags), + app: positionals[0], + url: positionals[1], + surface: flags.surface, + activity: flags.activity, + launchConsole: flags.launchConsole, + relaunch: flags.relaunch, + saveScript: flags.saveScript, + noRecord: flags.noRecord, + }), + close: (positionals, flags) => ({ + ...commonInputFromFlags(flags), + app: positionals[0], + shutdown: flags.shutdown, + saveScript: flags.saveScript, + }), + install: installInputFromCli, + reinstall: installInputFromCli, + 'install-from-source': (positionals, flags) => ({ + ...commonInputFromFlags(flags), + source: resolveInstallSource(positionals, flags), + retainPaths: flags.retainPaths, + retentionMs: flags.retentionMs, + }), + push: (positionals, flags) => ({ + ...commonInputFromFlags(flags), + app: requiredString(positionals[0], 'push requires bundleOrPackage'), + payload: requiredString(positionals[1], 'push requires payloadOrJson'), + }), + 'trigger-app-event': (positionals, flags) => ({ + ...commonInputFromFlags(flags), + event: requiredString(positionals[0], 'trigger-app-event requires event'), + payload: positionals[1] + ? readJsonObject(positionals[1], 'trigger-app-event payload') + : undefined, + }), +} satisfies Record; + +export const appDaemonWriters = { + devices: direct(PUBLIC_COMMANDS.devices), + boot: direct(PUBLIC_COMMANDS.boot), + apps: direct(PUBLIC_COMMANDS.apps), + open: direct(PUBLIC_COMMANDS.open, openPositionals), + close: direct(PUBLIC_COMMANDS.close, (input) => optionalString(input.app)), + install: direct(PUBLIC_COMMANDS.install, (input) => requiredPair(input.app, input.appPath)), + reinstall: direct(PUBLIC_COMMANDS.reinstall, (input) => requiredPair(input.app, input.appPath)), + 'install-from-source': (input) => + request(INTERNAL_COMMANDS.installSource, [], { + ...input, + installSource: input.source, + retainMaterializedPaths: input.retainPaths, + materializedPathRetentionMs: input.retentionMs, + }), + push: direct(PUBLIC_COMMANDS.push, (input) => pushPositionals(input as AppPushOptions)), + 'trigger-app-event': direct(PUBLIC_COMMANDS.triggerAppEvent, (input) => + triggerEventPositionals(input as AppTriggerEventOptions), + ), +} satisfies Record; + +function installInputFromCli( + positionals: string[], + flags: CliFlags, + command = 'install', +): Record { + return { + ...commonInputFromFlags(flags), + app: requiredString(positionals[0], `${command} requires app`), + appPath: requiredString(positionals[1], `${command} requires path`), + }; +} + +function readSessionAction(value: string | undefined): 'list' { + const action = value ?? 'list'; + if (action === 'list') return action; + throw new AppError('INVALID_ARGS', 'session only supports list'); +} + +function openPositionals(input: CommandInput): string[] { + if (!input.app) return []; + return input.url ? [input.app, input.url] : [input.app]; +} + +function requiredPair(first: unknown, second: unknown): string[] { + return [ + requiredDaemonString(first, 'missing first positional'), + requiredDaemonString(second, 'missing second positional'), + ]; +} + +function pushPositionals(input: AppPushOptions): string[] { + return [ + input.app, + typeof input.payload === 'string' ? input.payload : JSON.stringify(input.payload), + ]; +} + +function triggerEventPositionals(input: AppTriggerEventOptions): string[] { + return [input.event, ...(input.payload ? [JSON.stringify(input.payload)] : [])]; +} + +// fallow-ignore-next-line complexity +function resolveInstallSource(positionals: string[], flags: CliFlags) { + const url = positionals[0]?.trim(); + if (positionals.length > 1) { + throw new AppError( + 'INVALID_ARGS', + 'install-from-source accepts either one positional or --github-actions-artifact', + ); + } + const githubArtifactSource = flags.githubActionsArtifact + ? parseGitHubActionsArtifactInstallSourceSpec(flags.githubActionsArtifact) + : undefined; + const configuredSource = flags.installSource; + const sourceCount = (url ? 1 : 0) + (githubArtifactSource ? 1 : 0) + (configuredSource ? 1 : 0); + if (sourceCount !== 1) { + throw new AppError( + 'INVALID_ARGS', + 'install-from-source requires exactly one source: , --github-actions-artifact, or config installSource', + ); + } + if (!url && flags.header && flags.header.length > 0) { + throw new AppError( + 'INVALID_ARGS', + 'install-from-source --header is only supported for URL sources', + ); + } + if (githubArtifactSource) return githubArtifactSource; + if (configuredSource) return configuredSource; + return { + kind: 'url' as const, + url: url!, + headers: parseInstallSourceHeaders(flags.header), + }; +} + +function parseInstallSourceHeaders( + headerFlags: CliFlags['header'], +): Record | undefined { + if (!headerFlags || headerFlags.length === 0) return undefined; + const headers: Record = {}; + for (const rawHeader of headerFlags) { + const separator = rawHeader.indexOf(':'); + if (separator <= 0) { + throw new AppError( + 'INVALID_ARGS', + `Invalid --header value "${rawHeader}". Expected "name:value".`, + ); + } + const name = rawHeader.slice(0, separator).trim(); + const value = rawHeader.slice(separator + 1).trim(); + if (!name) { + throw new AppError( + 'INVALID_ARGS', + `Invalid --header value "${rawHeader}". Header name cannot be empty.`, + ); + } + headers[name] = value; + } + return headers; +} diff --git a/src/commands/cli-grammar/capture.ts b/src/commands/cli-grammar/capture.ts new file mode 100644 index 000000000..2f6561d41 --- /dev/null +++ b/src/commands/cli-grammar/capture.ts @@ -0,0 +1,292 @@ +import { PUBLIC_COMMANDS } from '../../command-catalog.ts'; +import type { + AlertCommandOptions, + CaptureScreenshotOptions, + SettingsUpdateOptions, + WaitCommandOptions, +} from '../../client-types.ts'; +import { parseTimeout } from '../../daemon/handlers/parse-utils.ts'; +import { splitSelectorFromArgs, tryParseSelectorChain } from '../../daemon/selectors.ts'; +import type { CliFlags } from '../../utils/command-schema.ts'; +import { AppError } from '../../utils/errors.ts'; +import { readLocationCoordinate } from '../../utils/location-coordinates.ts'; +import { + screenshotFlagsFromOptions, + screenshotOptionsFromFlags, +} from '../capture-screenshot-options.ts'; +import { compactRecord } from '../command-input.ts'; +import { + commonInputFromFlags, + direct, + isOneOf, + optionalNumber, + optionalString, + readFiniteNumber, + request, + requiredDaemonString, + selectionOptionsFromFlags, + selectorSnapshotOptionsFromFlags, + setOf, +} from './common.ts'; +import type { CliReader, DaemonWriter, WaitParsed } from './types.ts'; + +export const captureCliReaders = { + snapshot: (_positionals, flags) => ({ + ...commonInputFromFlags(flags), + interactiveOnly: flags.snapshotInteractiveOnly, + compact: flags.snapshotCompact, + depth: flags.snapshotDepth, + scope: flags.snapshotScope, + raw: flags.snapshotRaw, + forceFull: flags.snapshotForceFull, + }), + screenshot: (positionals, flags) => ({ + ...commonInputFromFlags(flags), + path: positionals[0] ?? flags.out, + ...screenshotOptionsFromFlags(flags), + }), + diff: (positionals, flags) => { + if (positionals[0] !== 'snapshot') { + throw new AppError('INVALID_ARGS', 'Only diff snapshot is available through this parser.'); + } + return { + ...commonInputFromFlags(flags), + kind: 'snapshot', + out: flags.out, + interactiveOnly: flags.snapshotInteractiveOnly, + compact: flags.snapshotCompact, + depth: flags.snapshotDepth, + scope: flags.snapshotScope, + raw: flags.snapshotRaw, + }; + }, + wait: (positionals, flags) => readWaitOptionsFromPositionals(positionals, flags), + alert: (positionals, flags) => ({ + ...commonInputFromFlags(flags), + ...readAlertInput(positionals), + }), + settings: (positionals, flags) => readSettingsOptionsFromPositionals(positionals, flags), +} satisfies Record; + +export const captureDaemonWriters = { + snapshot: direct(PUBLIC_COMMANDS.snapshot), + screenshot: (input) => + request(PUBLIC_COMMANDS.screenshot, optionalString(input.path), { + ...input, + ...screenshotFlagsFromOptions(input as CaptureScreenshotOptions), + }), + diff: direct(PUBLIC_COMMANDS.diff, (input) => [ + requiredDaemonString(input.kind, 'diff requires kind'), + ]), + wait: direct(PUBLIC_COMMANDS.wait, (input) => waitPositionals(input as WaitCommandOptions)), + alert: direct(PUBLIC_COMMANDS.alert, (input) => alertPositionals(input as AlertCommandOptions)), + settings: direct(PUBLIC_COMMANDS.settings, (input) => + settingsPositionals(input as SettingsUpdateOptions), + ), +} satisfies Record; + +function readWaitOptionsFromPositionals( + positionals: string[], + flags: CliFlags, +): WaitCommandOptions { + const parsed = parseWaitPositionals(positionals); + if (!parsed) { + throw new AppError( + 'INVALID_ARGS', + 'wait requires , text , @ref, or [timeoutMs].', + ); + } + const base = { + ...selectionOptionsFromFlags(flags), + ...selectorSnapshotOptionsFromFlags(flags), + }; + if (parsed.kind === 'sleep') return { ...base, durationMs: parsed.durationMs }; + if (parsed.kind === 'text') { + if (!parsed.text) throw new AppError('INVALID_ARGS', 'wait requires text.'); + return { ...base, text: parsed.text, ...readTimeoutOption(parsed.timeoutMs) }; + } + if (parsed.kind === 'ref') { + return { ...base, ref: parsed.rawRef, ...readTimeoutOption(parsed.timeoutMs) }; + } + return { + ...base, + selector: parsed.selectorExpression, + ...readTimeoutOption(parsed.timeoutMs), + }; +} + +export function parseWaitPositionals(args: string[]): WaitParsed | null { + if (args.length === 0) return null; + const sleepMs = parseTimeout(args[0]); + if (sleepMs !== null) return { kind: 'sleep', durationMs: sleepMs }; + const timeoutMs = parseTimeout(args[args.length - 1]); + if (args[0] === 'text') { + const text = timeoutMs !== null ? args.slice(1, -1).join(' ') : args.slice(1).join(' '); + return { kind: 'text', text: text.trim(), timeoutMs }; + } + if (args[0].startsWith('@')) return { kind: 'ref', rawRef: args[0], timeoutMs }; + const argsWithoutTimeout = timeoutMs !== null ? args.slice(0, -1) : args.slice(); + const split = splitSelectorFromArgs(argsWithoutTimeout); + if (split && split.rest.length === 0 && tryParseSelectorChain(split.selectorExpression)) { + return { kind: 'selector', selectorExpression: split.selectorExpression, timeoutMs }; + } + const text = timeoutMs !== null ? args.slice(0, -1).join(' ') : args.join(' '); + return { kind: 'text', text: text.trim(), timeoutMs }; +} + +// fallow-ignore-next-line complexity +function waitPositionals(options: WaitCommandOptions): string[] { + const targets = [ + options.durationMs !== undefined ? 'durationMs' : undefined, + options.text !== undefined ? 'text' : undefined, + options.ref !== undefined ? 'ref' : undefined, + options.selector !== undefined ? 'selector' : undefined, + ].filter(Boolean); + if (targets.length !== 1) { + throw new AppError( + 'INVALID_ARGS', + 'wait command requires exactly one of durationMs, text, ref, or selector.', + ); + } + if (options.durationMs !== undefined) return [String(options.durationMs)]; + const timeout = optionalNumber(options.timeoutMs); + if (options.text !== undefined) return ['text', options.text, ...timeout]; + if (options.ref !== undefined) return [options.ref, ...timeout]; + const selector = options.selector!; + if (!tryParseSelectorChain(selector)) { + throw new AppError('INVALID_ARGS', `Invalid wait selector: ${selector}`); + } + return [selector, ...timeout]; +} + +function alertPositionals(input: AlertCommandOptions): string[] { + return [input.action ?? 'get', ...optionalNumber(input.timeoutMs)]; +} + +function readAlertInput(positionals: string[]): Record { + if (positionals.length > 2) { + throw new AppError('INVALID_ARGS', 'alert accepts at most action and timeout arguments.'); + } + const action = readAlertAction(positionals[0]); + const timeoutMs = readFiniteNumber(positionals[1], 'alert timeout'); + return compactRecord({ action, timeoutMs }); +} + +function readAlertAction( + value: string | undefined, +): 'get' | 'accept' | 'dismiss' | 'wait' | undefined { + const action = value?.toLowerCase(); + if ( + action === undefined || + action === 'get' || + action === 'accept' || + action === 'dismiss' || + action === 'wait' + ) { + return action; + } + throw new AppError('INVALID_ARGS', 'alert action must be get, accept, dismiss, or wait.'); +} + +function readTimeoutOption(timeoutMs: number | null): { timeoutMs?: number } { + return timeoutMs === null ? {} : { timeoutMs }; +} + +// fallow-ignore-next-line complexity +function readSettingsOptionsFromPositionals( + positionals: string[], + flags: CliFlags, +): SettingsUpdateOptions { + const base = selectionOptionsFromFlags(flags); + const setting = positionals[0]; + const state = positionals[1]; + if (isOneOf(setting, ON_OFF_SETTINGS) && isOneOf(state, ON_OFF_STATES)) { + return { ...base, setting, state }; + } + if (setting === 'location' && state === 'set') { + return { + ...base, + setting, + state, + latitude: readLocationCoordinate(positionals[2], 'latitude'), + longitude: readLocationCoordinate(positionals[3], 'longitude'), + }; + } + if (setting === 'appearance' && isOneOf(state, APPEARANCE_STATES)) { + return { ...base, setting, state }; + } + if (isOneOf(setting, BIOMETRIC_SETTINGS) && isOneOf(state, BIOMETRIC_STATES)) { + return { ...base, setting, state }; + } + if (setting === 'fingerprint' && isOneOf(state, FINGERPRINT_STATES)) { + return { ...base, setting, state }; + } + if (setting === 'permission' && isOneOf(state, PERMISSION_STATES)) { + return { + ...base, + setting, + state, + permission: readPermission(positionals[2]), + mode: readPermissionMode(positionals[3]), + }; + } + throw new AppError('INVALID_ARGS', 'Invalid settings arguments.'); +} + +function settingsPositionals(input: SettingsUpdateOptions): string[] { + if (input.setting === 'location' && input.state === 'set') { + return [input.setting, input.state, String(input.latitude), String(input.longitude)]; + } + if (input.setting === 'permission') { + return [input.setting, input.state, input.permission, ...optionalString(input.mode)]; + } + return [input.setting, input.state]; +} + +function readPermission(value: string | undefined): PermissionTarget { + if (isOneOf(value, PERMISSION_TARGETS)) return value; + throw new AppError('INVALID_ARGS', 'settings permission requires a permission target.'); +} + +function readPermissionMode(value: string | undefined): 'full' | 'limited' | undefined { + if (value === undefined || value === 'full' || value === 'limited') return value; + throw new AppError('INVALID_ARGS', 'settings permission mode must be full or limited.'); +} + +type PermissionTarget = Extract['permission']; +type OnOffSetting = Extract['setting']; +type OnOffState = Extract['state']; +type BiometricSetting = Extract< + SettingsUpdateOptions, + { setting: 'faceid' | 'touchid' } +>['setting']; +type BiometricState = Extract['state']; +type FingerprintState = Extract['state']; +type AppearanceState = Extract['state']; +type PermissionState = Extract['state']; + +const ON_OFF_SETTINGS = setOf('wifi', 'airplane', 'location', 'animations'); +const ON_OFF_STATES = setOf('on', 'off'); +const APPEARANCE_STATES = setOf('light', 'dark', 'toggle'); +const BIOMETRIC_SETTINGS = setOf('faceid', 'touchid'); +const BIOMETRIC_STATES = setOf('match', 'nonmatch', 'enroll', 'unenroll'); +const FINGERPRINT_STATES = setOf('match', 'nonmatch'); +const PERMISSION_STATES = setOf('grant', 'deny', 'reset'); +const PERMISSION_TARGETS = setOf( + 'camera', + 'microphone', + 'photos', + 'contacts', + 'contacts-limited', + 'notifications', + 'calendar', + 'location', + 'location-always', + 'media-library', + 'motion', + 'reminders', + 'siri', + 'accessibility', + 'screen-recording', + 'input-monitoring', +); diff --git a/src/commands/cli-grammar/common.ts b/src/commands/cli-grammar/common.ts new file mode 100644 index 000000000..93ead593a --- /dev/null +++ b/src/commands/cli-grammar/common.ts @@ -0,0 +1,247 @@ +import type { + ElementTarget, + InteractionTarget, + InternalRequestOptions, +} from '../../client-types.ts'; +import { splitSelectorFromArgs } from '../../daemon/selectors.ts'; +import type { CliFlags } from '../../utils/command-schema.ts'; +import { AppError } from '../../utils/errors.ts'; +import { compactRecord } from '../command-input.ts'; +import type { + DaemonWriter, + SelectionOptions, + DaemonCommandRequest, + CommandInput, +} from './types.ts'; + +export function direct( + command: string, + positionals?: (input: CommandInput) => string[], +): DaemonWriter { + return (input) => request(command, positionals ? positionals(input) : [], input); +} + +export function request( + command: string, + positionals: string[], + options: CommandInput, +): DaemonCommandRequest { + return { command, positionals, options: normalizeCommonRequestOptions(options) }; +} + +function normalizeCommonRequestOptions(options: CommandInput): InternalRequestOptions { + const normalizedTarget = readDeviceTarget(options.deviceTarget ?? options.target); + if (normalizedTarget === undefined && options.target === undefined) { + return options as InternalRequestOptions; + } + const { target: _target, ...rest } = options; + return ( + normalizedTarget === undefined ? rest : { ...rest, target: normalizedTarget } + ) as InternalRequestOptions; +} + +function readDeviceTarget(value: unknown): InternalRequestOptions['target'] | undefined { + return value === 'mobile' || value === 'tv' || value === 'desktop' ? value : undefined; +} + +export function commonInputFromFlags(flags: CliFlags): Record { + return compactRecord({ + session: flags.session, + platform: flags.platform, + deviceTarget: flags.target, + device: flags.device, + udid: flags.udid, + serial: flags.serial, + iosSimulatorDeviceSet: flags.iosSimulatorDeviceSet, + androidDeviceAllowlist: flags.androidDeviceAllowlist, + }); +} + +export function selectionOptionsFromFlags(flags: CliFlags): SelectionOptions { + return { + platform: flags.platform, + target: flags.target, + device: flags.device, + udid: flags.udid, + serial: flags.serial, + iosSimulatorDeviceSet: flags.iosSimulatorDeviceSet, + androidDeviceAllowlist: flags.androidDeviceAllowlist, + }; +} + +export function selectorSnapshotInputFromFlags(flags: CliFlags): Record { + return compactRecord({ + depth: flags.snapshotDepth, + scope: flags.snapshotScope, + raw: flags.snapshotRaw, + }); +} + +export function selectorSnapshotOptionsFromFlags(flags: CliFlags): { + depth?: number; + scope?: string; + raw?: boolean; +} { + return { + depth: flags.snapshotDepth, + scope: flags.snapshotScope, + raw: flags.snapshotRaw, + }; +} + +export function repeatedInputFromFlags(flags: CliFlags): Record { + return compactRecord({ + count: flags.count, + intervalMs: flags.intervalMs, + holdMs: flags.holdMs, + jitterPx: flags.jitterPx, + doubleTap: flags.doubleTap, + }); +} + +export function targetInputFromClientTarget( + target: InteractionTarget | ElementTarget, +): Record { + if ('ref' in target && target.ref !== undefined) { + return compactRecord({ kind: 'ref', ref: target.ref, label: target.label }); + } + if ('selector' in target && target.selector !== undefined) { + return { kind: 'selector', selector: target.selector }; + } + const point = target as { x: number; y: number }; + return { kind: 'point', x: point.x, y: point.y }; +} + +export function interactionTargetPositionals(input: InteractionTarget | CommandInput): string[] { + const target = readTargetRecord(input); + if (typeof target.ref === 'string') return [target.ref, ...optionalTargetLabel(target.label)]; + if (typeof target.selector === 'string') return [target.selector]; + if (target.kind === 'point' || target.x !== undefined || target.y !== undefined) { + return [ + String(requiredTargetNumber(target.x, 'x')), + String(requiredTargetNumber(target.y, 'y')), + ]; + } + throw new AppError('INVALID_ARGS', 'interaction requires @ref, selector, or point target'); +} + +export function elementTargetPositionals(input: ElementTarget | CommandInput): string[] { + const target = readTargetRecord(input); + if (typeof target.ref === 'string') return [target.ref, ...optionalTargetLabel(target.label)]; + if (typeof target.selector === 'string') return [target.selector]; + throw new AppError('INVALID_ARGS', 'element command requires @ref or selector target'); +} + +function readTargetRecord(input: unknown): Record { + if (!input || typeof input !== 'object' || Array.isArray(input)) { + throw new AppError('INVALID_ARGS', 'Expected target object.'); + } + const record = input as Record; + const nestedTarget = record.target; + if (nestedTarget && typeof nestedTarget === 'object' && !Array.isArray(nestedTarget)) { + return nestedTarget as Record; + } + return record; +} + +function requiredTargetNumber(value: unknown, field: string): number { + if (typeof value !== 'number' || !Number.isFinite(value)) { + throw new AppError('INVALID_ARGS', `point target requires numeric ${field}.`); + } + return value; +} + +function optionalTargetLabel(value: unknown): string[] { + return typeof value === 'string' && value.length > 0 ? [value] : []; +} + +export function readElementTargetFromPositionals(positionals: string[]): ElementTarget { + if (positionals[0]?.startsWith('@')) { + return { ref: positionals[0], label: optionalTrimmedText(positionals.slice(1)) }; + } + const selector = positionals.join(' ').trim(); + if (!selector) throw new AppError('INVALID_ARGS', 'get requires @ref or selector expression'); + return { selector }; +} + +export function readGetFormat(value: string | undefined): 'text' | 'attrs' { + if (value === 'text' || value === 'attrs') return value; + throw new AppError('INVALID_ARGS', 'get only supports text or attrs'); +} + +export function splitRequiredSelector( + positionals: string[], + options: { preferTrailingValue?: boolean } = {}, +) { + const split = splitSelectorFromArgs(positionals, options); + if (!split) throw new AppError('INVALID_ARGS', 'is requires a selector expression'); + return split; +} + +export function readJsonObject(value: string, label: string): Record { + try { + const parsed = JSON.parse(value) as unknown; + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + return parsed as Record; + } + } catch {} + throw new AppError('INVALID_ARGS', `${label} must be a JSON object`); +} + +export function optionalTrimmedText(values: string[]): string | undefined { + const text = values.join(' ').trim(); + return text || undefined; +} + +export function setOf(...values: T[]): ReadonlySet { + return new Set(values); +} + +export function commandNameSet( + names: readonly TName[], +): ReadonlySet { + return new Set(names); +} + +export function isOneOf( + value: string | undefined, + values: ReadonlySet, +): value is T { + return value !== undefined && values.has(value as T); +} + +export function isFiniteNumberString(value: string | undefined): boolean { + if (value === undefined || value.trim() === '') return false; + return Number.isFinite(Number(value)); +} + +export function readFiniteNumber(value: string | undefined, label: string): number | undefined { + if (value === undefined) return undefined; + const parsed = Number(value); + if (Number.isFinite(parsed)) return parsed; + throw new AppError('INVALID_ARGS', `${label} must be a finite number.`); +} + +export function optionalCliNumber(value: string | undefined): number | undefined { + return value === undefined ? undefined : Number(value); +} + +export function optionalString(value: string | undefined): string[] { + return value === undefined ? [] : [value]; +} + +export function optionalNumber(value: number | undefined): string[] { + return value === undefined ? [] : [String(value)]; +} + +export function requiredString(value: string | undefined, message: string): string { + if (value === undefined || value === '') throw new AppError('INVALID_ARGS', message); + return value; +} + +export function requiredDaemonString(value: unknown, message: string): string { + if (typeof value !== 'string' || value.length === 0) { + throw new AppError('INVALID_ARGS', message); + } + return value; +} diff --git a/src/commands/cli-grammar/gesture.ts b/src/commands/cli-grammar/gesture.ts new file mode 100644 index 000000000..6ded85308 --- /dev/null +++ b/src/commands/cli-grammar/gesture.ts @@ -0,0 +1,202 @@ +import { PUBLIC_COMMANDS } from '../../command-catalog.ts'; +import type { FlingOptions, RotateGestureOptions } from '../../client-types.ts'; +import type { CliFlags } from '../../utils/command-schema.ts'; +import { AppError } from '../../utils/errors.ts'; +import { + commonInputFromFlags, + direct, + optionalCliNumber, + optionalNumber, + requiredDaemonString, +} from './common.ts'; +import type { CliReader, DaemonWriter, CommandInput } from './types.ts'; + +export const gestureCliReaders = { + gesture: gestureInputFromCli, +} satisfies Record; + +export const gestureDaemonWriters = { + gesture: direct(PUBLIC_COMMANDS.gesture, gesturePositionals), + 'gesture-pan': direct(PUBLIC_COMMANDS.gesture, panPositionals), + 'gesture-fling': direct(PUBLIC_COMMANDS.gesture, (input) => + flingPositionals(input as FlingOptions), + ), + 'gesture-pinch': direct(PUBLIC_COMMANDS.gesture, pinchPositionals), + 'gesture-rotate': direct(PUBLIC_COMMANDS.gesture, (input) => + rotateGesturePositionals(input as RotateGestureOptions), + ), + 'gesture-transform': direct(PUBLIC_COMMANDS.gesture, transformPositionals), +} satisfies Record; + +// fallow-ignore-next-line complexity +function gesturePositionals(input: CommandInput): string[] { + switch (input.kind) { + case 'pan': + return [ + 'pan', + String(input.origin?.x), + String(input.origin?.y), + String(input.delta?.x), + String(input.delta?.y), + ...optionalNumber(input.durationMs), + ]; + case 'fling': + return [ + 'fling', + requiredDaemonString(input.direction, 'gesture fling requires direction'), + String(input.origin?.x), + String(input.origin?.y), + ...optionalNumber(input.distance), + ...optionalNumber(input.durationMs), + ]; + case 'pinch': + return [ + 'pinch', + String(input.scale), + ...optionalNumber(input.origin?.x), + ...optionalNumber(input.origin?.y), + ]; + case 'rotate': + return [ + 'rotate', + String(input.degrees), + ...optionalNumber(input.origin?.x), + ...optionalNumber(input.origin?.y), + ...optionalNumber(input.velocity), + ]; + case 'transform': + return [ + 'transform', + String(input.origin?.x), + String(input.origin?.y), + String(input.delta?.x), + String(input.delta?.y), + String(input.scale), + String(input.degrees), + ...optionalNumber(input.durationMs), + ]; + default: + throw new AppError( + 'INVALID_ARGS', + 'gesture requires pan, fling, pinch, rotate, or transform', + ); + } +} + +function panPositionals(input: CommandInput): string[] { + return [ + 'pan', + String(input.x), + String(input.y), + String(input.dx), + String(input.dy), + ...optionalNumber(input.durationMs), + ]; +} + +function flingPositionals(input: FlingOptions): string[] { + const distance = input.durationMs !== undefined ? (input.distance ?? 180) : input.distance; + return [ + 'fling', + input.direction, + String(input.x), + String(input.y), + ...optionalNumber(distance), + ...optionalNumber(input.durationMs), + ]; +} + +function pinchPositionals(input: CommandInput): string[] { + return ['pinch', String(input.scale), ...optionalNumber(input.x), ...optionalNumber(input.y)]; +} + +function rotateGesturePositionals(input: RotateGestureOptions): string[] { + assertCompleteCenter(input); + const center = + input.x === undefined || input.y === undefined ? [] : [String(input.x), String(input.y)]; + return ['rotate', String(input.degrees), ...center, ...optionalNumber(input.velocity)]; +} + +function transformPositionals(input: CommandInput): string[] { + return [ + 'transform', + String(input.x), + String(input.y), + String(input.dx), + String(input.dy), + String(input.scale), + String(input.degrees), + ...optionalNumber(input.durationMs), + ]; +} + +// fallow-ignore-next-line complexity +function gestureInputFromCli(positionals: string[], flags: CliFlags): Record { + const subcommand = positionals[0]; + const args = positionals.slice(1); + const common = commonInputFromFlags(flags); + switch (subcommand) { + case 'pan': + return { + ...common, + kind: subcommand, + origin: { x: Number(args[0]), y: Number(args[1]) }, + delta: { x: Number(args[2]), y: Number(args[3]) }, + durationMs: optionalCliNumber(args[4]), + }; + case 'fling': + return { + ...common, + kind: subcommand, + direction: args[0], + origin: { x: Number(args[1]), y: Number(args[2]) }, + distance: optionalCliNumber(args[3]), + durationMs: optionalCliNumber(args[4]), + }; + case 'pinch': + return { + ...common, + kind: subcommand, + scale: Number(args[0]), + origin: + args[1] === undefined || args[2] === undefined + ? undefined + : { x: Number(args[1]), y: Number(args[2]) }, + }; + case 'rotate': + return { + ...common, + kind: subcommand, + degrees: Number(args[0]), + origin: + args[1] === undefined || args[2] === undefined + ? undefined + : { x: Number(args[1]), y: Number(args[2]) }, + velocity: optionalCliNumber(args[3]), + }; + case 'transform': + return { + ...common, + kind: subcommand, + origin: { x: Number(args[0]), y: Number(args[1]) }, + delta: { x: Number(args[2]), y: Number(args[3]) }, + scale: Number(args[4]), + degrees: Number(args[5]), + durationMs: optionalCliNumber(args[6]), + }; + default: + throw new AppError( + 'INVALID_ARGS', + 'gesture requires pan, fling, pinch, rotate, or transform', + ); + } +} + +function assertCompleteCenter(input: RotateGestureOptions): void { + if ( + (input.x === undefined && input.y !== undefined) || + (input.x !== undefined && input.y === undefined) + ) { + throw new AppError('INVALID_ARGS', 'gesture rotate center requires both x and y'); + } +} diff --git a/src/commands/cli-grammar/interactions.ts b/src/commands/cli-grammar/interactions.ts new file mode 100644 index 000000000..41b7b9ea9 --- /dev/null +++ b/src/commands/cli-grammar/interactions.ts @@ -0,0 +1,223 @@ +import { PUBLIC_COMMANDS } from '../../command-catalog.ts'; +import type { + ElementTarget, + FillOptions, + InteractionTarget, + LongPressOptions, + TypeTextOptions, +} from '../../client-types.ts'; +import { splitSelectorFromArgs } from '../../daemon/selectors.ts'; +import { AppError } from '../../utils/errors.ts'; +import { + commonInputFromFlags, + direct, + elementTargetPositionals, + interactionTargetPositionals, + isFiniteNumberString, + optionalCliNumber, + optionalNumber, + optionalTrimmedText, + readElementTargetFromPositionals, + readGetFormat, + request, + requiredDaemonString, + repeatedInputFromFlags, + selectorSnapshotInputFromFlags, + targetInputFromClientTarget, +} from './common.ts'; +import type { CliReader, DaemonWriter, DecodedFillTarget, CommandInput } from './types.ts'; + +export const interactionCliReaders = { + click: (positionals, flags) => ({ + ...commonInputFromFlags(flags), + ...selectorSnapshotInputFromFlags(flags), + ...repeatedInputFromFlags(flags), + target: targetInputFromClientTarget(readInteractionTargetFromPositionals(positionals)), + button: flags.clickButton, + }), + press: (positionals, flags) => ({ + ...commonInputFromFlags(flags), + ...selectorSnapshotInputFromFlags(flags), + ...repeatedInputFromFlags(flags), + target: targetInputFromClientTarget(readInteractionTargetFromPositionals(positionals)), + }), + longpress: (positionals, flags) => { + const decoded = readLongPressTargetFromPositionals(positionals); + return { + ...commonInputFromFlags(flags), + ...selectorSnapshotInputFromFlags(flags), + target: targetInputFromClientTarget(decoded), + durationMs: decoded.durationMs, + }; + }, + swipe: (positionals, flags) => ({ + ...commonInputFromFlags(flags), + from: { x: Number(positionals[0]), y: Number(positionals[1]) }, + to: { x: Number(positionals[2]), y: Number(positionals[3]) }, + durationMs: optionalCliNumber(positionals[4]), + count: flags.count, + pauseMs: flags.pauseMs, + pattern: flags.pattern, + }), + focus: (positionals, flags) => ({ + ...commonInputFromFlags(flags), + x: Number(positionals[0]), + y: Number(positionals[1]), + }), + type: (positionals, flags) => ({ + ...commonInputFromFlags(flags), + text: positionals.join(' '), + delayMs: flags.delayMs, + }), + fill: (positionals, flags) => { + const decoded = readFillTargetFromPositionals(positionals); + return { + ...commonInputFromFlags(flags), + ...selectorSnapshotInputFromFlags(flags), + target: targetInputFromClientTarget(decoded.target), + text: decoded.text, + delayMs: flags.delayMs, + }; + }, + scroll: (positionals, flags) => ({ + ...commonInputFromFlags(flags), + direction: readScrollDirection(positionals[0]), + amount: optionalCliNumber(positionals[1]), + pixels: flags.pixels, + }), + get: (positionals, flags) => ({ + ...commonInputFromFlags(flags), + ...selectorSnapshotInputFromFlags(flags), + format: readGetFormat(positionals[0]), + target: targetInputFromClientTarget(readElementTargetFromPositionals(positionals.slice(1))), + }), +} satisfies Record; + +export const interactionDaemonWriters = { + click: (input) => + request(PUBLIC_COMMANDS.click, interactionTargetPositionals(input as InteractionTarget), { + ...input, + clickButton: input.button, + }), + press: direct(PUBLIC_COMMANDS.press, (input) => + interactionTargetPositionals(input as InteractionTarget), + ), + longpress: direct(PUBLIC_COMMANDS.longPress, (input) => + longPressPositionals(input as LongPressOptions), + ), + swipe: direct(PUBLIC_COMMANDS.swipe, swipePositionals), + focus: direct(PUBLIC_COMMANDS.focus, (input) => [String(input.x), String(input.y)]), + type: direct(PUBLIC_COMMANDS.type, (input) => typePositionals(input as TypeTextOptions)), + fill: direct(PUBLIC_COMMANDS.fill, (input) => fillPositionals(input as FillOptions)), + scroll: direct(PUBLIC_COMMANDS.scroll, (input) => [ + requiredDaemonString(input.direction, 'scroll requires direction'), + ...optionalNumber(input.amount), + ]), + get: direct(PUBLIC_COMMANDS.get, (input) => [ + requiredDaemonString(input.format, 'get requires format'), + ...elementTargetPositionals(input as ElementTarget), + ]), +} satisfies Record; + +export function readInteractionTargetFromPositionals(positionals: string[]): InteractionTarget { + if (positionals[0]?.startsWith('@')) { + const label = optionalTrimmedText(positionals.slice(1)); + return { ref: positionals[0], ...(label === undefined ? {} : { label }) }; + } + const selectorArgs = splitSelectorFromArgs(positionals); + if (selectorArgs) return { selector: selectorArgs.selectorExpression }; + return { x: Number(positionals[0]), y: Number(positionals[1]) }; +} + +function readLongPressTargetFromPositionals(positionals: string[]): LongPressOptions { + const targetPositionals = readLongPressTargetPositionals(positionals); + return { + ...readInteractionTargetFromPositionals(targetPositionals.target), + ...(targetPositionals.durationMs !== undefined + ? { durationMs: targetPositionals.durationMs } + : {}), + }; +} + +export function readFillTargetFromPositionals(positionals: string[]): DecodedFillTarget { + if (positionals[0]?.startsWith('@')) { + const text = + positionals.length >= 3 ? positionals.slice(2).join(' ') : positionals.slice(1).join(' '); + return { + kind: 'ref', + target: { + ref: positionals[0], + label: positionals.length >= 3 ? optionalTrimmedText([positionals[1]]) : undefined, + }, + text, + }; + } + const selectorArgs = splitSelectorFromArgs(positionals, { preferTrailingValue: true }); + if (selectorArgs) { + return { + kind: 'selector', + target: { selector: selectorArgs.selectorExpression }, + text: selectorArgs.rest.join(' '), + }; + } + return { + kind: 'point', + target: { x: Number(positionals[0]), y: Number(positionals[1]) }, + text: positionals.slice(2).join(' '), + }; +} + +function longPressPositionals(input: LongPressOptions): string[] { + return [...interactionTargetPositionals(input), ...optionalNumber(input.durationMs)]; +} + +function typePositionals(input: TypeTextOptions): string[] { + return [input.text]; +} + +function fillPositionals(input: FillOptions): string[] { + return [...interactionTargetPositionals(input), input.text]; +} + +function swipePositionals(input: CommandInput): string[] { + return [ + String(input.from?.x), + String(input.from?.y), + String(input.to?.x), + String(input.to?.y), + ...optionalNumber(input.durationMs), + ]; +} + +function readScrollDirection( + value: string | undefined, +): 'up' | 'down' | 'left' | 'right' | 'top' | 'bottom' { + if ( + value === 'up' || + value === 'down' || + value === 'left' || + value === 'right' || + value === 'top' || + value === 'bottom' + ) { + return value; + } + throw new AppError('INVALID_ARGS', `Unknown direction: ${String(value)}`); +} + +function readLongPressTargetPositionals(positionals: string[]): { + target: string[]; + durationMs?: number; +} { + if (isFiniteNumberString(positionals[0]) && isFiniteNumberString(positionals[1])) { + return { + target: positionals.slice(0, 2), + ...(positionals[2] !== undefined ? { durationMs: Number(positionals[2]) } : {}), + }; + } + const last = positionals.at(-1); + if (positionals.length > 1 && isFiniteNumberString(last)) { + return { target: positionals.slice(0, -1), durationMs: Number(last) }; + } + return { target: positionals }; +} diff --git a/src/cli/commands/metro.ts b/src/commands/cli-grammar/metro.ts similarity index 71% rename from src/cli/commands/metro.ts rename to src/commands/cli-grammar/metro.ts index a999d5bbf..e4982c462 100644 --- a/src/cli/commands/metro.ts +++ b/src/commands/cli-grammar/metro.ts @@ -1,21 +1,24 @@ import { AppError } from '../../utils/errors.ts'; -import { writeCommandOutput } from './shared.ts'; -import type { ClientCommandHandler } from './router-types.ts'; +import type { CliReader } from './types.ts'; -export const metroCommand: ClientCommandHandler = async ({ positionals, flags, client }) => { +export const metroCliReaders = { + metro: metroInputFromCli, +} satisfies Record; + +// fallow-ignore-next-line complexity +function metroInputFromCli(positionals: string[], flags: Parameters[1]) { const action = (positionals[0] ?? '').toLowerCase(); + if (action !== 'prepare' && action !== 'reload') { + throw new AppError('INVALID_ARGS', 'metro requires a subcommand: prepare or reload'); + } if (action === 'reload') { - const result = await client.metro.reload({ + return { + action, metroHost: flags.metroHost, metroPort: flags.metroPort, bundleUrl: flags.bundleUrl, timeoutMs: flags.metroProbeTimeoutMs, - }); - writeCommandOutput(flags, result, () => `Reloaded React Native apps via ${result.reloadUrl}`); - return true; - } - if (action !== 'prepare') { - throw new AppError('INVALID_ARGS', 'metro requires a subcommand: prepare or reload'); + }; } if (!flags.metroPublicBaseUrl && !flags.metroProxyBaseUrl) { throw new AppError( @@ -23,8 +26,8 @@ export const metroCommand: ClientCommandHandler = async ({ positionals, flags, c 'metro prepare requires --public-base-url or --proxy-base-url .', ); } - - const result = await client.metro.prepare({ + return { + action, projectRoot: flags.metroProjectRoot, kind: flags.metroKind, port: flags.metroPreparePort, @@ -46,8 +49,5 @@ export const metroCommand: ClientCommandHandler = async ({ positionals, flags, c reuseExisting: flags.metroNoReuseExisting ? false : undefined, installDependenciesIfNeeded: flags.metroNoInstallDeps ? false : undefined, runtimeFilePath: flags.metroRuntimeFile, - }); - - writeCommandOutput(flags, result, () => JSON.stringify(result, null, 2)); - return true; -}; + }; +} diff --git a/src/commands/cli-grammar/observability.ts b/src/commands/cli-grammar/observability.ts new file mode 100644 index 000000000..4773ef819 --- /dev/null +++ b/src/commands/cli-grammar/observability.ts @@ -0,0 +1,102 @@ +import { PUBLIC_COMMANDS } from '../../command-catalog.ts'; +import type { LogsOptions, NetworkOptions, RecordOptions } from '../../client-types.ts'; +import { AppError } from '../../utils/errors.ts'; +import { + commonInputFromFlags, + direct, + optionalCliNumber, + optionalNumber, + optionalString, + request, +} from './common.ts'; +import type { CliReader, DaemonWriter } from './types.ts'; + +export const observabilityCliReaders = { + perf: (_positionals, flags) => commonInputFromFlags(flags), + logs: (positionals, flags) => ({ + ...commonInputFromFlags(flags), + action: readLogsAction(positionals[0]), + message: positionals.slice(1).join(' ') || undefined, + restart: flags.restart, + }), + network: (positionals, flags) => ({ + ...commonInputFromFlags(flags), + action: readNetworkAction(positionals[0]), + limit: optionalCliNumber(positionals[1]), + include: flags.networkInclude ?? readNetworkInclude(positionals[2]), + }), + record: (positionals, flags) => ({ + ...commonInputFromFlags(flags), + action: readStartStop(positionals[0], 'record'), + path: positionals[1], + fps: flags.fps, + quality: flags.quality as RecordOptions['quality'], + hideTouches: flags.hideTouches, + }), + trace: (positionals, flags) => ({ + ...commonInputFromFlags(flags), + action: readStartStop(positionals[0], 'trace'), + path: positionals[1], + }), +} satisfies Record; + +export const observabilityDaemonWriters = { + perf: direct(PUBLIC_COMMANDS.perf), + logs: direct(PUBLIC_COMMANDS.logs, (input) => logsPositionals(input as LogsOptions)), + network: (input) => + request(PUBLIC_COMMANDS.network, networkPositionals(input as NetworkOptions), { + ...input, + networkInclude: input.include, + }), + record: direct(PUBLIC_COMMANDS.record, (input) => recordingPositionals(input as RecordOptions)), + trace: direct(PUBLIC_COMMANDS.trace, (input) => recordingPositionals(input as RecordOptions)), +} satisfies Record; + +function logsPositionals(input: { action?: string; message?: string }): string[] { + return [input.action ?? 'path', ...optionalString(input.message)]; +} + +function networkPositionals(input: NetworkOptions): string[] { + return [...(input.action ? [input.action] : []), ...optionalNumber(input.limit)]; +} + +function recordingPositionals(input: RecordOptions): string[] { + return [input.action, ...optionalString(input.path)]; +} + +function readStartStop(value: string | undefined, command: string): 'start' | 'stop' { + if (value === 'start' || value === 'stop') return value; + throw new AppError('INVALID_ARGS', `${command} requires start|stop`); +} + +function readLogsAction( + value: string | undefined, +): 'path' | 'start' | 'stop' | 'doctor' | 'mark' | 'clear' | undefined { + if (value === undefined) return undefined; + if ( + value === 'path' || + value === 'start' || + value === 'stop' || + value === 'doctor' || + value === 'mark' || + value === 'clear' + ) { + return value; + } + throw new AppError('INVALID_ARGS', 'logs requires path, start, stop, doctor, mark, or clear'); +} + +function readNetworkAction(value: string | undefined): 'dump' | 'log' | undefined { + if (value === undefined) return undefined; + if (value === 'dump' || value === 'log') return value; + throw new AppError('INVALID_ARGS', 'network requires dump or log'); +} + +function readNetworkInclude( + value: string | undefined, +): 'summary' | 'headers' | 'body' | 'all' | undefined { + if (value === undefined) return undefined; + if (value === 'summary' || value === 'headers' || value === 'body' || value === 'all') + return value; + throw new AppError('INVALID_ARGS', 'network include mode must be summary, headers, body, or all'); +} diff --git a/src/commands/cli-grammar/registry.ts b/src/commands/cli-grammar/registry.ts new file mode 100644 index 000000000..f04eaacbf --- /dev/null +++ b/src/commands/cli-grammar/registry.ts @@ -0,0 +1,40 @@ +import type { CliFlags } from '../../utils/command-schema.ts'; +import { appCliReaders } from './apps.ts'; +import { captureCliReaders } from './capture.ts'; +import { commonInputFromFlags } from './common.ts'; +import { gestureCliReaders } from './gesture.ts'; +import { interactionCliReaders } from './interactions.ts'; +import { metroCliReaders } from './metro.ts'; +import { observabilityCliReaders } from './observability.ts'; +import { replayCliReaders } from './replay.ts'; +import { selectorCliReaders } from './selectors.ts'; +import { systemCliReaders } from './system.ts'; +import type { CliReader } from './types.ts'; +import type { CommandName } from '../command-surface.ts'; + +const cliReaders = { + ...appCliReaders, + ...captureCliReaders, + ...interactionCliReaders, + ...gestureCliReaders, + ...selectorCliReaders, + ...observabilityCliReaders, + ...replayCliReaders, + ...systemCliReaders, + ...metroCliReaders, + batch: (_positionals, flags) => ({ + ...commonInputFromFlags(flags), + steps: flags.batchSteps ?? [], + onError: flags.batchOnError, + maxSteps: flags.batchMaxSteps, + out: flags.out, + }), +} satisfies Record; + +export function readInputFromCli( + command: CommandName, + positionals: string[], + flags: CliFlags, +): Record { + return cliReaders[command](positionals, flags); +} diff --git a/src/commands/cli-grammar/replay.ts b/src/commands/cli-grammar/replay.ts new file mode 100644 index 000000000..c0606789d --- /dev/null +++ b/src/commands/cli-grammar/replay.ts @@ -0,0 +1,54 @@ +import { PUBLIC_COMMANDS } from '../../command-catalog.ts'; +import type { ReplayRunOptions } from '../../client-types.ts'; +import { commonInputFromFlags, request, requiredDaemonString, requiredString } from './common.ts'; +import type { CliReader, DaemonWriter } from './types.ts'; + +export const replayCliReaders = { + replay: (positionals, flags) => ({ + ...commonInputFromFlags(flags), + path: requiredString(positionals[0], 'replay requires path'), + update: flags.replayUpdate, + backend: flags.replayMaestro ? 'maestro' : undefined, + env: flags.replayEnv, + }), + test: (positionals, flags) => ({ + ...commonInputFromFlags(flags), + paths: positionals, + update: flags.replayUpdate, + env: flags.replayEnv, + failFast: flags.failFast, + timeoutMs: flags.timeoutMs, + retries: flags.retries, + artifactsDir: flags.artifactsDir, + reportJunit: flags.reportJunit, + }), +} satisfies Record; + +export const replayDaemonWriters = { + replay: (input) => + request(PUBLIC_COMMANDS.replay, [requiredDaemonString(input.path, 'replay requires path')], { + ...input, + replayUpdate: input.update, + replayBackend: + input.backend ?? ((input as ReplayRunOptions).maestro === true ? 'maestro' : undefined), + replayEnv: input.env, + replayShellEnv: collectReplayClientShellEnv(process.env), + }), + test: (input) => + request(PUBLIC_COMMANDS.test, input.paths ?? [], { + ...input, + replayUpdate: input.update, + replayEnv: input.env, + replayShellEnv: collectReplayClientShellEnv(process.env), + }), +} satisfies Record; + +const REPLAY_SHELL_ENV_PREFIX = 'AD_VAR_'; + +function collectReplayClientShellEnv(env: NodeJS.ProcessEnv): Record { + const result: Record = {}; + for (const [key, value] of Object.entries(env)) { + if (typeof value === 'string' && key.startsWith(REPLAY_SHELL_ENV_PREFIX)) result[key] = value; + } + return result; +} diff --git a/src/commands/cli-grammar/selectors.ts b/src/commands/cli-grammar/selectors.ts new file mode 100644 index 000000000..34e4fdbc7 --- /dev/null +++ b/src/commands/cli-grammar/selectors.ts @@ -0,0 +1,159 @@ +import { PUBLIC_COMMANDS } from '../../command-catalog.ts'; +import type { FindOptions, IsOptions } from '../../client-types.ts'; +import type { CliFlags } from '../../utils/command-schema.ts'; +import { AppError } from '../../utils/errors.ts'; +import { + direct, + optionalCliNumber, + optionalNumber, + request, + selectionOptionsFromFlags, + selectorSnapshotOptionsFromFlags, + splitRequiredSelector, +} from './common.ts'; +import type { CliReader, DaemonWriter } from './types.ts'; + +export const selectorCliReaders = { + find: (positionals, flags) => readFindOptionsFromPositionals(positionals, flags), + is: (positionals, flags) => readIsOptionsFromPositionals(positionals, flags), +} satisfies Record; + +export const selectorDaemonWriters = { + is: direct(PUBLIC_COMMANDS.is, (input) => isPositionals(input as IsOptions)), + find: (input) => + request(PUBLIC_COMMANDS.find, findPositionals(input as FindOptions), { + ...input, + findFirst: input.first, + findLast: input.last, + }), +} satisfies Record; + +function isPositionals(input: IsOptions): string[] { + return [input.predicate, input.selector, ...(input.predicate === 'text' ? [input.value] : [])]; +} + +// fallow-ignore-next-line complexity +function findPositionals(input: FindOptions): string[] { + const args = + input.locator && input.locator !== 'any' ? [input.locator, input.query] : [input.query]; + switch (input.action) { + case undefined: + case 'click': + case 'focus': + case 'exists': + return input.action ? [...args, input.action] : args; + case 'getText': + return [...args, 'get', 'text']; + case 'getAttrs': + return [...args, 'get', 'attrs']; + case 'wait': + return [...args, 'wait', ...optionalNumber(input.timeoutMs)]; + case 'fill': + case 'type': + return [...args, input.action, input.value]; + } +} + +// fallow-ignore-next-line complexity +function readFindOptionsFromPositionals(positionals: string[], flags: CliFlags): FindOptions { + const base = { + ...findSnapshotOptionsFromFlags(flags), + ...selectionOptionsFromFlags(flags), + first: flags.findFirst, + last: flags.findLast, + }; + const locator = readFindLocator(positionals[0]); + const hasExplicitLocator = locator !== undefined; + const query = hasExplicitLocator ? positionals[1] : positionals[0]; + const actionOffset = hasExplicitLocator ? 2 : 1; + const action = positionals[actionOffset]; + if (action === undefined) return { ...base, locator, query: readRequiredQuery(query) }; + if (action === 'get') { + const subcommand = positionals[actionOffset + 1]; + if (subcommand === 'text') { + return { ...base, locator, query: readRequiredQuery(query), action: 'getText' }; + } + if (subcommand === 'attrs') { + return { ...base, locator, query: readRequiredQuery(query), action: 'getAttrs' }; + } + throw new AppError('INVALID_ARGS', 'find get only supports text or attrs'); + } + if (action === 'wait') { + return { + ...base, + locator, + query: readRequiredQuery(query), + action: 'wait', + timeoutMs: optionalCliNumber(positionals[actionOffset + 1]), + }; + } + if (action === 'fill' || action === 'type') { + return { + ...base, + locator, + query: readRequiredQuery(query), + action, + value: positionals.slice(actionOffset + 1).join(' '), + }; + } + if (action === 'click' || action === 'focus' || action === 'exists') { + return { ...base, locator, query: readRequiredQuery(query), action }; + } + throw new AppError('INVALID_ARGS', `Unsupported find action: ${action}`); +} + +function readIsOptionsFromPositionals(positionals: string[], flags: CliFlags): IsOptions { + const base = { + ...selectorSnapshotOptionsFromFlags(flags), + ...selectionOptionsFromFlags(flags), + }; + const predicate = positionals[0]; + const split = splitRequiredSelector(positionals.slice(1), { + preferTrailingValue: predicate === 'text', + }); + if (predicate === 'text') { + return { ...base, predicate, selector: split.selectorExpression, value: split.rest.join(' ') }; + } + if ( + predicate === 'visible' || + predicate === 'hidden' || + predicate === 'exists' || + predicate === 'editable' || + predicate === 'selected' + ) { + return { ...base, predicate, selector: split.selectorExpression }; + } + throw new AppError( + 'INVALID_ARGS', + 'is requires predicate: visible|hidden|exists|editable|selected|text', + ); +} + +function readFindLocator(value: string | undefined): FindOptions['locator'] | undefined { + if ( + value === 'text' || + value === 'label' || + value === 'value' || + value === 'role' || + value === 'id' + ) { + return value; + } + return undefined; +} + +function findSnapshotOptionsFromFlags(flags: CliFlags): { + depth?: number; + raw?: boolean; +} { + return { + depth: flags.snapshotDepth, + raw: flags.snapshotRaw, + }; +} + +function readRequiredQuery(value: string | undefined): string { + if (value === undefined || value === '') + throw new AppError('INVALID_ARGS', 'find requires query'); + return value; +} diff --git a/src/commands/cli-grammar/system.ts b/src/commands/cli-grammar/system.ts new file mode 100644 index 000000000..4226cb225 --- /dev/null +++ b/src/commands/cli-grammar/system.ts @@ -0,0 +1,114 @@ +import { PUBLIC_COMMANDS } from '../../command-catalog.ts'; +import type { ClipboardCommandOptions } from '../../client-types.ts'; +import { parseDeviceRotation } from '../../core/device-rotation.ts'; +import { AppError } from '../../utils/errors.ts'; +import { compactRecord } from '../command-input.ts'; +import { + commonInputFromFlags, + direct, + optionalString, + request, + requiredDaemonString, +} from './common.ts'; +import type { CliReader, DaemonWriter } from './types.ts'; + +export const systemCliReaders = { + appstate: (_positionals, flags) => commonInputFromFlags(flags), + home: (_positionals, flags) => commonInputFromFlags(flags), + 'app-switcher': (_positionals, flags) => commonInputFromFlags(flags), + back: (_positionals, flags) => ({ + ...commonInputFromFlags(flags), + mode: flags.backMode, + }), + rotate: (positionals, flags) => ({ + ...commonInputFromFlags(flags), + orientation: parseDeviceRotation(positionals[0]), + }), + keyboard: (positionals, flags) => ({ + ...commonInputFromFlags(flags), + ...readKeyboardInput(positionals), + }), + clipboard: (positionals, flags) => ({ + ...commonInputFromFlags(flags), + ...readClipboardInput(positionals), + }), + 'react-native': (positionals, flags) => ({ + ...commonInputFromFlags(flags), + action: readReactNativeAction(positionals[0]), + }), +} satisfies Record; + +export const systemDaemonWriters = { + appstate: direct(PUBLIC_COMMANDS.appState), + back: (input) => + request(PUBLIC_COMMANDS.back, [], { ...input, backMode: readBackMode(input.mode) }), + home: direct(PUBLIC_COMMANDS.home), + rotate: direct(PUBLIC_COMMANDS.rotate, (input) => [ + requiredDaemonString(input.orientation, 'rotate requires orientation'), + ]), + 'app-switcher': direct(PUBLIC_COMMANDS.appSwitcher), + keyboard: direct(PUBLIC_COMMANDS.keyboard, (input) => optionalString(input.action)), + clipboard: direct(PUBLIC_COMMANDS.clipboard, (input) => + clipboardPositionals(input as ClipboardCommandOptions), + ), + 'react-native': direct(PUBLIC_COMMANDS.reactNative, (input) => [ + requiredDaemonString(input.action, 'react-native requires action'), + ]), +} satisfies Record; + +function readBackMode(value: unknown): 'in-app' | 'system' | undefined { + return value === 'in-app' || value === 'system' ? value : undefined; +} + +function clipboardPositionals(input: ClipboardCommandOptions): string[] { + return input.action === 'read' ? ['read'] : ['write', input.text]; +} + +function readKeyboardInput(positionals: string[]): Record { + if (positionals.length > 1) { + throw new AppError('INVALID_ARGS', 'keyboard accepts at most one action argument.'); + } + return compactRecord({ action: readKeyboardAction(positionals[0]) }); +} + +function readClipboardInput(positionals: string[]): Record { + const action = positionals[0]?.toLowerCase(); + if (action !== 'read' && action !== 'write') { + throw new AppError('INVALID_ARGS', 'clipboard requires a subcommand: read or write.'); + } + if (action === 'read') { + if (positionals.length !== 1) { + throw new AppError('INVALID_ARGS', 'clipboard read does not accept additional arguments.'); + } + return { action }; + } + if (positionals.length < 2) { + throw new AppError('INVALID_ARGS', 'clipboard write requires text.'); + } + return { action, text: positionals.slice(1).join(' ') }; +} + +function readKeyboardAction( + value: string | undefined, +): 'status' | 'dismiss' | 'enter' | 'return' | undefined { + const action = value?.toLowerCase(); + if (action === 'get') return 'status'; + if ( + action === undefined || + action === 'status' || + action === 'dismiss' || + action === 'enter' || + action === 'return' + ) { + return action; + } + throw new AppError( + 'INVALID_ARGS', + 'keyboard action must be status, get, dismiss, enter, or return.', + ); +} + +function readReactNativeAction(value: string | undefined): 'dismiss-overlay' { + if (value === 'dismiss-overlay') return value; + throw new AppError('INVALID_ARGS', 'react-native supports only: dismiss-overlay'); +} diff --git a/src/commands/cli-grammar/types.ts b/src/commands/cli-grammar/types.ts new file mode 100644 index 000000000..03877618a --- /dev/null +++ b/src/commands/cli-grammar/types.ts @@ -0,0 +1,91 @@ +import type { InteractionTarget, InternalRequestOptions } from '../../client-types.ts'; +import type { CliFlags } from '../../utils/command-schema.ts'; + +export type DaemonCommandRequest = { + command: string; + positionals: string[]; + options: InternalRequestOptions; +}; + +type PointInput = { + x?: number; + y?: number; +}; + +export type CommandInput = Omit & + Omit, 'batchSteps' | 'target'> & { + target?: InternalRequestOptions['target'] | Record; + action?: string; + amount?: number; + app?: string; + appPath?: string; + backend?: string; + degrees?: number; + direction?: string; + distance?: number; + durationMs?: number; + dx?: number; + dy?: number; + delta?: PointInput; + env?: string[]; + event?: string; + format?: string; + from?: PointInput; + include?: CliFlags['networkInclude']; + kind?: string; + locator?: string; + mode?: 'in-app' | 'system' | 'full' | 'limited'; + button?: 'primary' | 'secondary' | 'middle'; + first?: boolean; + last?: boolean; + maxSteps?: number; + onError?: 'stop'; + origin?: PointInput; + path?: string; + paths?: string[]; + payload?: unknown; + permission?: string; + predicate?: string; + query?: string; + retainPaths?: boolean; + retentionMs?: number; + scale?: number; + selector?: string; + source?: InternalRequestOptions['installSource']; + state?: string; + text?: string; + to?: PointInput; + update?: boolean; + url?: string; + value?: string; + velocity?: number; + x?: number; + y?: number; + } & Record; + +export type SelectionOptions = { + platform?: CliFlags['platform']; + target?: CliFlags['target']; + device?: string; + udid?: string; + serial?: string; + iosSimulatorDeviceSet?: string; + androidDeviceAllowlist?: string; +}; + +export type CliInput = Record; +export type CliReader = (positionals: string[], flags: CliFlags) => CliInput; +export type DaemonWriter = (input: CommandInput) => DaemonCommandRequest; + +export type DecodedFillTarget = + | { kind: 'ref'; target: { ref: string; label?: string }; text: string } + | { kind: 'selector'; target: { selector: string }; text: string } + | { kind: 'point'; target: { x: number; y: number }; text: string }; + +export type WaitParsed = + | { kind: 'sleep'; durationMs: number } + | { kind: 'ref'; rawRef: string; timeoutMs: number | null } + | { kind: 'selector'; selectorExpression: string; timeoutMs: number | null } + | { kind: 'text'; text: string; timeoutMs: number | null }; + +export type { InteractionTarget }; diff --git a/src/commands/cli-output.ts b/src/commands/cli-output.ts new file mode 100644 index 000000000..b69b03dc5 --- /dev/null +++ b/src/commands/cli-output.ts @@ -0,0 +1,103 @@ +import type { CommandRequestResult } from '../client.ts'; +import type { CommandName } from './command-surface.ts'; +import type { CliOutput } from './command-contract.ts'; +import { + appStateCliOutput, + appsCliOutput, + bootCliOutput, + clipboardCliOutput, + closeCliOutput, + deployCliOutput, + devicesCliOutput, + findCliOutput, + getCliOutput, + installFromSourceCliOutput, + isCliOutput, + keyboardCliOutput, + messageCliOutput, + metroCliOutput, + openCliOutput, + recordCliOutput, + sessionCliOutput, + snapshotCliOutput, + tapCliOutput, +} from './client-output.ts'; +import { + batchCliOutput, + logsCliOutput, + networkCliOutput, + perfCliOutput, +} from './runtime-output.ts'; + +type CliOutputFormatter = (params: { + input: Record; + result: unknown; +}) => CliOutput; + +function resultOutput(formatter: (result: TResult) => CliOutput): CliOutputFormatter { + return ({ result }) => formatter(result as TResult); +} + +const messageOutput = resultOutput(messageCliOutput); + +const cliOutputFormatters: Partial> = { + boot: resultOutput(bootCliOutput), + click: resultOutput(tapCliOutput), + press: resultOutput(tapCliOutput), + batch: resultOutput(batchCliOutput), + devices: resultOutput(devicesCliOutput), + apps: ({ input, result }) => + appsCliOutput({ + result: result as Parameters[0]['result'], + appsFilter: input.appsFilter as Parameters[0]['appsFilter'], + }), + session: resultOutput(sessionCliOutput), + open: resultOutput(openCliOutput), + close: resultOutput(closeCliOutput), + install: resultOutput(deployCliOutput), + reinstall: resultOutput(deployCliOutput), + 'install-from-source': resultOutput(installFromSourceCliOutput), + snapshot: ({ input, result }) => + snapshotCliOutput({ + result: result as Parameters[0]['result'], + raw: input.raw as boolean | undefined, + interactiveOnly: input.interactiveOnly as boolean | undefined, + }), + wait: messageOutput, + alert: messageOutput, + appstate: resultOutput(appStateCliOutput), + back: messageOutput, + home: messageOutput, + rotate: messageOutput, + 'app-switcher': messageOutput, + keyboard: resultOutput(keyboardCliOutput), + clipboard: resultOutput(clipboardCliOutput), + get: ({ input, result }) => + getCliOutput({ + result: result as CommandRequestResult, + format: input.format as Parameters[0]['format'], + }), + is: resultOutput(isCliOutput), + find: resultOutput(findCliOutput), + perf: resultOutput(perfCliOutput), + logs: resultOutput(logsCliOutput), + network: resultOutput(networkCliOutput), + record: resultOutput(recordCliOutput), + metro: ({ input, result }) => + metroCliOutput({ result, action: input.action as string | undefined }), +}; + +export function listCliOutputCommandNames(): CommandName[] { + return Object.keys(cliOutputFormatters) as CommandName[]; +} + +export function formatCliOutput(params: { + name: CommandName; + input: unknown; + result: unknown; +}): CliOutput | undefined { + return cliOutputFormatters[params.name]?.({ + input: (params.input ?? {}) as Record, + result: params.result, + }); +} diff --git a/src/commands/cli-runner.ts b/src/commands/cli-runner.ts new file mode 100644 index 000000000..e117a4ec3 --- /dev/null +++ b/src/commands/cli-runner.ts @@ -0,0 +1,33 @@ +import type { AgentDeviceClient, CommandRequestResult } from '../client.ts'; +import { formatCliOutput } from './cli-output.ts'; +import { readInputFromCli } from './cli-grammar.ts'; +import { runCommand, type CommandName } from './command-surface.ts'; +import type { CliOutput } from './command-contract.ts'; +import type { CliFlags } from '../utils/command-schema.ts'; + +type CliRunOptions = { + client: AgentDeviceClient; + command: CommandName; + positionals: string[]; + flags: CliFlags; +}; + +export async function runCliCommand(options: CliRunOptions): Promise { + return (await runCliCommandWithOutput(options)).result; +} + +export async function runCliCommandWithOutput(options: CliRunOptions): Promise<{ + result: CommandRequestResult; + cliOutput?: CliOutput; +}> { + const input = readInputFromCli(options.command, options.positionals, options.flags); + const result = (await runCommand(options.client, options.command, input)) as CommandRequestResult; + return { + result, + cliOutput: formatCliOutput({ + name: options.command, + input, + result, + }), + }; +} diff --git a/src/commands/client-command-contracts.ts b/src/commands/client-command-contracts.ts new file mode 100644 index 000000000..a298f5de3 --- /dev/null +++ b/src/commands/client-command-contracts.ts @@ -0,0 +1,411 @@ +import type { + AppCloseOptions, + ClipboardCommandOptions, + MetroPrepareOptions, + MetroPrepareResult, + MetroReloadOptions, + MetroReloadResult, + RecordOptions, + SettingsUpdateOptions, + WaitCommandOptions, +} from '../client-types.ts'; +import type { DaemonInstallSource } from '../contracts.ts'; +import { + booleanSchema, + booleanField, + enumField, + integerField, + integerSchema, + jsonSchemaField, + looseObjectField, + looseObjectSchema, + numberField, + optionalEnum, + requiredField, + stringArrayField, + stringField, + stringSchema, +} from './command-input.ts'; +import { defineFieldCommand } from './field-command-contract.ts'; + +const SURFACE_VALUES = ['app', 'frontmost-app', 'desktop', 'menubar'] as const; +const WAIT_KIND_VALUES = ['duration', 'text', 'ref', 'selector'] as const; +const ALERT_ACTION_VALUES = ['get', 'accept', 'dismiss', 'wait'] as const; +const BACK_MODE_VALUES = ['in-app', 'system'] as const; +const ORIENTATION_VALUES = [ + 'portrait', + 'portrait-upside-down', + 'landscape-left', + 'landscape-right', +] as const; +const CLIPBOARD_ACTION_VALUES = ['read', 'write'] as const; +const LOG_ACTION_VALUES = ['path', 'start', 'stop', 'doctor', 'mark', 'clear'] as const; +const NETWORK_ACTION_VALUES = ['dump', 'log'] as const; +const NETWORK_INCLUDE_VALUES = ['summary', 'headers', 'body', 'all'] as const; +const START_STOP_VALUES = ['start', 'stop'] as const; +const REACT_NATIVE_ACTION_VALUES = ['dismiss-overlay'] as const; +const METRO_ACTION_VALUES = ['prepare', 'reload'] as const; + +type MetroInput = { action: 'prepare' | 'reload' } & MetroPrepareOptions & MetroReloadOptions; + +export const clientCommandDefinitions = [ + defineFieldCommand('devices', 'List available devices.', {}, (client, input) => + client.devices.list(input), + ), + defineFieldCommand( + 'boot', + 'Boot or prepare a selected device without using CLI positional arguments.', + { headless: booleanField('Boot without showing simulator UI when supported.') }, + (client, input) => client.devices.boot(input), + ), + defineFieldCommand( + 'apps', + 'List installed apps.', + { appsFilter: enumField(['user-installed', 'all']) }, + (client, input) => client.apps.list(input), + ), + defineFieldCommand( + 'session', + 'List active sessions.', + { action: enumField(['list']) }, + async (client) => ({ sessions: await client.sessions.list() }), + ), + defineFieldCommand( + 'open', + 'Open an app, deep link, URL, or platform surface.', + { + app: stringField('App name, bundle id, package, or URL.'), + url: stringField('Optional URL passed with an app shell.'), + surface: enumField(SURFACE_VALUES), + activity: stringField('Android activity name.'), + launchConsole: stringField('Launch console mode.'), + relaunch: booleanField('Force relaunch.'), + saveScript: jsonSchemaField({ oneOf: [booleanSchema(), stringSchema()] }), + noRecord: booleanField('Do not record this action.'), + }, + (client, input) => client.apps.open(input), + ), + defineFieldCommand( + 'close', + 'Close an app or end the active session.', + { + app: stringField('Optional app to close.'), + shutdown: booleanField('Shutdown the session/device where supported.'), + saveScript: jsonSchemaField({ oneOf: [booleanSchema(), stringSchema()] }), + }, + (client, input) => + input.app ? client.apps.close(input) : client.sessions.close(withoutApp(input)), + ), + defineFieldCommand( + 'install', + 'Install an app binary.', + { + app: requiredField(stringField()), + appPath: requiredField(stringField('Path to app binary.')), + }, + (client, input) => client.apps.install(input), + ), + defineFieldCommand( + 'reinstall', + 'Reinstall an app binary.', + { + app: requiredField(stringField()), + appPath: requiredField(stringField('Path to app binary.')), + }, + (client, input) => client.apps.reinstall(input), + ), + defineFieldCommand( + 'install-from-source', + 'Install an app from a structured source.', + { + source: requiredField( + jsonSchemaField(looseObjectSchema('Install source object.')), + ), + retainPaths: booleanField(), + retentionMs: integerField(), + }, + (client, input) => client.apps.installFromSource(input), + ), + defineFieldCommand( + 'push', + 'Deliver a push payload.', + { + app: requiredField(stringField()), + payload: requiredField( + jsonSchemaField>({ + oneOf: [stringSchema(), looseObjectSchema()], + }), + ), + }, + (client, input) => client.apps.push(input), + ), + defineFieldCommand( + 'trigger-app-event', + 'Trigger an app-defined event.', + { event: requiredField(stringField()), payload: looseObjectField() }, + (client, input) => client.apps.triggerEvent(input), + ), + defineFieldCommand( + 'snapshot', + 'Capture an accessibility snapshot.', + { + interactiveOnly: booleanField(), + compact: booleanField(), + depth: integerField(), + scope: stringField(), + raw: booleanField(), + forceFull: booleanField(), + }, + (client, input) => client.capture.snapshot(input), + ), + defineFieldCommand( + 'screenshot', + 'Capture a screenshot.', + { + path: stringField('Output path.'), + overlayRefs: booleanField(), + fullscreen: booleanField(), + maxSize: integerField(), + stabilize: booleanField(), + surface: enumField(SURFACE_VALUES), + }, + (client, input) => client.capture.screenshot(input), + ), + defineFieldCommand( + 'diff', + 'Diff accessibility snapshots.', + { + kind: requiredField(jsonSchemaField<'snapshot'>({ type: 'string', const: 'snapshot' })), + out: stringField(), + interactiveOnly: booleanField(), + compact: booleanField(), + depth: integerField(), + scope: stringField(), + raw: booleanField(), + }, + (client, input) => client.capture.diff(input), + ), + defineFieldCommand( + 'wait', + 'Wait for duration, text, ref, or selector.', + { + kind: enumField(WAIT_KIND_VALUES), + durationMs: integerField(), + text: stringField(), + ref: stringField(), + selector: stringField(), + timeoutMs: integerField(), + depth: integerField(), + scope: stringField(), + raw: booleanField(), + }, + (client, input) => client.command.wait(waitInputToOptions(input)), + ), + defineFieldCommand( + 'alert', + 'Inspect or handle platform alerts.', + { action: enumField(ALERT_ACTION_VALUES), timeoutMs: integerField() }, + (client, input) => client.command.alert(input), + ), + defineFieldCommand('appstate', 'Show foreground app or activity.', {}, (client, input) => + client.command.appState(input), + ), + defineFieldCommand( + 'back', + 'Navigate back.', + { mode: enumField(BACK_MODE_VALUES) }, + (client, input) => client.command.back(input), + ), + defineFieldCommand('home', 'Go to the home screen.', {}, (client, input) => + client.command.home(input), + ), + defineFieldCommand( + 'rotate', + 'Rotate device orientation.', + { orientation: requiredField(enumField(ORIENTATION_VALUES)) }, + (client, input) => client.command.rotate(input), + ), + defineFieldCommand('app-switcher', 'Open the app switcher.', {}, (client, input) => + client.command.appSwitcher(input), + ), + defineFieldCommand( + 'keyboard', + 'Inspect or dismiss the keyboard.', + { action: enumField(['status', 'dismiss']) }, + (client, input) => client.command.keyboard(input), + ), + defineFieldCommand( + 'clipboard', + 'Read or write clipboard text.', + { action: requiredField(enumField(CLIPBOARD_ACTION_VALUES)), text: stringField() }, + (client, input) => client.command.clipboard(input as ClipboardCommandOptions), + ), + defineFieldCommand( + 'react-native', + 'Run supported React Native app automation helpers.', + { action: requiredField(enumField(REACT_NATIVE_ACTION_VALUES)) }, + (client, input) => client.command.reactNative(input), + ), + defineFieldCommand( + 'replay', + 'Replay a recorded session.', + { + path: requiredField(stringField()), + update: booleanField(), + backend: stringField(), + env: stringArrayField(), + }, + (client, input) => client.replay.run(input), + ), + defineFieldCommand( + 'test', + 'Run one or more .ad scripts.', + { + paths: requiredField(stringArrayField()), + update: booleanField(), + env: stringArrayField(), + failFast: booleanField(), + timeoutMs: integerField(), + retries: integerField(), + artifactsDir: stringField(), + reportJunit: stringField(), + }, + (client, input) => client.replay.test(input), + ), + defineFieldCommand('perf', 'Show session performance metrics.', {}, (client, input) => + client.observability.perf(input), + ), + defineFieldCommand( + 'logs', + 'Manage session app logs.', + { action: enumField(LOG_ACTION_VALUES), message: stringField(), restart: booleanField() }, + (client, input) => client.observability.logs(input), + ), + defineFieldCommand( + 'network', + 'Show recent HTTP traffic.', + { + action: enumField(NETWORK_ACTION_VALUES), + limit: integerField(), + include: enumField(NETWORK_INCLUDE_VALUES), + }, + (client, input) => client.observability.network(input), + ), + defineFieldCommand( + 'record', + 'Start or stop screen recording.', + { + action: requiredField(enumField(START_STOP_VALUES)), + path: stringField(), + fps: integerField(), + quality: jsonSchemaField(integerSchema()), + hideTouches: booleanField(), + }, + (client, input) => client.recording.record(input as RecordOptions), + ), + defineFieldCommand( + 'trace', + 'Start or stop trace capture.', + { action: requiredField(enumField(START_STOP_VALUES)), path: stringField() }, + (client, input) => client.recording.trace(input), + ), + defineFieldCommand( + 'settings', + 'Change OS settings and app permissions.', + { + setting: requiredField(stringField()), + state: requiredField(stringField()), + latitude: numberField(), + longitude: numberField(), + permission: stringField(), + mode: enumField(['full', 'limited']), + }, + (client, input) => client.settings.update(input as SettingsUpdateOptions), + ), + defineFieldCommand( + 'metro', + 'Prepare Metro runtime or reload React Native apps.', + { + action: requiredField(enumField(METRO_ACTION_VALUES)), + projectRoot: stringField(), + kind: jsonSchemaField(stringSchema()), + publicBaseUrl: stringField(), + proxyBaseUrl: stringField(), + bearerToken: stringField(), + bridgeScope: jsonSchemaField({ + type: 'object', + additionalProperties: true, + }), + launchUrl: stringField(), + port: integerField(), + listenHost: stringField(), + statusHost: stringField(), + startupTimeoutMs: integerField(), + probeTimeoutMs: integerField(), + reuseExisting: booleanField(), + installDependenciesIfNeeded: booleanField(), + runtimeFilePath: stringField(), + logPath: stringField(), + metroHost: stringField(), + metroPort: integerField(), + bundleUrl: stringField(), + timeoutMs: integerField(), + }, + async (client, input): Promise => + input.action === 'prepare' + ? await client.metro.prepare(toMetroPrepareOptions(input)) + : await client.metro.reload(toMetroReloadOptions(input)), + ), +] as const; + +function withoutApp(input: AppCloseOptions & { shutdown?: boolean }): { shutdown?: boolean } { + const { app: _app, ...rest } = input; + return rest; +} + +function toMetroPrepareOptions(input: MetroInput): MetroPrepareOptions { + return { + projectRoot: input.projectRoot, + kind: input.kind, + publicBaseUrl: input.publicBaseUrl, + proxyBaseUrl: input.proxyBaseUrl, + bearerToken: input.bearerToken, + bridgeScope: input.bridgeScope ?? metroBridgeScopeFromInput(input), + port: input.port, + listenHost: input.listenHost, + statusHost: input.statusHost, + startupTimeoutMs: input.startupTimeoutMs, + probeTimeoutMs: input.probeTimeoutMs, + reuseExisting: input.reuseExisting, + installDependenciesIfNeeded: input.installDependenciesIfNeeded, + runtimeFilePath: input.runtimeFilePath, + }; +} + +function metroBridgeScopeFromInput( + input: MetroInput & { + tenant?: string; + runId?: string; + leaseId?: string; + }, +): MetroPrepareOptions['bridgeScope'] { + return input.tenant && input.runId && input.leaseId + ? { tenantId: input.tenant, runId: input.runId, leaseId: input.leaseId } + : undefined; +} + +function toMetroReloadOptions(input: MetroInput): MetroReloadOptions { + return { + metroHost: input.metroHost, + metroPort: input.metroPort, + bundleUrl: input.bundleUrl, + timeoutMs: input.timeoutMs, + }; +} + +function waitInputToOptions(input: Record): WaitCommandOptions { + optionalEnum(input, 'kind', WAIT_KIND_VALUES); + const options = { ...input }; + delete options.kind; + return options as WaitCommandOptions & { kind?: never }; +} diff --git a/src/commands/client-output.ts b/src/commands/client-output.ts new file mode 100644 index 000000000..e544bbbe7 --- /dev/null +++ b/src/commands/client-output.ts @@ -0,0 +1,227 @@ +import { + serializeCloseResult, + serializeDeployResult, + serializeDevice, + serializeInstallFromSourceResult, + serializeOpenResult, + serializeSessionListEntry, + serializeSnapshotResult, +} from '../client-shared.ts'; +import type { + AgentDeviceDevice, + AgentDeviceSession, + AppStateCommandResult, + AppCloseResult, + AppDeployResult, + AppInstallFromSourceResult, + AppOpenResult, + CaptureSnapshotResult, + ClipboardCommandResult, + CommandRequestResult, + KeyboardCommandResult, + SessionCloseResult, +} from '../client-types.ts'; +import { formatSnapshotText } from '../utils/output.ts'; +import { readCommandMessage } from '../utils/success-text.ts'; +import type { CliOutput } from './command-contract.ts'; + +export function devicesCliOutput(result: AgentDeviceDevice[]): CliOutput { + const data = { devices: result.map(serializeDevice) }; + return { data, text: result.map(formatDeviceLine).join('\n') }; +} + +export function appsCliOutput(params: { + result: string[]; + appsFilter?: 'user-installed' | 'all'; +}): CliOutput { + const data = { apps: params.result }; + return { + data, + stderr: + params.appsFilter === 'all' + ? 'Showing all apps, including system apps.\n' + : 'Showing user-installed apps. Use --all to include system apps.\n', + text: + params.result.length > 0 + ? params.result.join('\n') + : params.appsFilter === 'all' + ? 'No apps found.' + : 'No user-installed apps found.', + }; +} + +export function sessionCliOutput(result: { sessions: AgentDeviceSession[] }): CliOutput { + const data = { sessions: result.sessions.map(serializeSessionListEntry) }; + return { data, text: JSON.stringify(data, null, 2) }; +} + +export function openCliOutput(result: AppOpenResult): CliOutput { + return messageOutput(serializeOpenResult(result)); +} + +export function closeCliOutput(result: AppCloseResult | SessionCloseResult): CliOutput { + return messageOutput(serializeCloseResult(result)); +} + +export function messageCliOutput(result: Record): CliOutput { + return messageOutput(result); +} + +export function appStateCliOutput(result: AppStateCommandResult): CliOutput { + return { + data: result, + text: formatAppState(result), + }; +} + +export function keyboardCliOutput(result: KeyboardCommandResult): CliOutput { + if (result.platform === 'android' && result.action === 'status') { + const lines = [ + `Keyboard visible: ${result.visible === true ? 'yes' : 'no'}`, + `Input type: ${result.type ?? result.inputType ?? 'unknown'}`, + `Input owner: ${result.inputOwner ?? 'unknown'}`, + ]; + if (result.inputMethodPackage) lines.push(`Input method: ${result.inputMethodPackage}`); + if (result.focusedPackage) lines.push(`Focused package: ${result.focusedPackage}`); + if (result.focusedResourceId) lines.push(`Focused resource: ${result.focusedResourceId}`); + lines.push(`Next action: ${androidKeyboardNextAction(result.visible, result.inputOwner)}`); + return { data: result, text: lines.join('\n') }; + } + return messageOutput(result); +} + +export function clipboardCliOutput(result: ClipboardCommandResult): CliOutput { + if (result.action === 'read') return { data: result, text: result.text }; + return messageOutput(result); +} + +export function deployCliOutput(result: AppDeployResult): CliOutput { + return messageOutput(serializeDeployResult(result)); +} + +export function installFromSourceCliOutput(result: AppInstallFromSourceResult): CliOutput { + return messageOutput(serializeInstallFromSourceResult(result)); +} + +export function snapshotCliOutput(params: { + result: CaptureSnapshotResult; + raw?: boolean; + interactiveOnly?: boolean; +}): CliOutput { + const data = serializeSnapshotResult(params.result); + return { + data, + // Programmatic SDK callers can see `unchanged`; CLI --json hides it for schema compatibility. + jsonData: withoutUnchanged(data), + text: formatSnapshotText(data, { + raw: params.raw, + flatten: params.interactiveOnly, + }), + }; +} + +export function metroCliOutput(params: { result: unknown; action?: string }): CliOutput { + return { + data: params.result, + text: + params.action === 'reload' + ? `Reloaded React Native apps via ${(params.result as { reloadUrl?: unknown }).reloadUrl}` + : JSON.stringify(params.result, null, 2), + }; +} + +export function bootCliOutput(result: CommandRequestResult): CliOutput { + const data = result as Record; + const platform = data.platform ?? 'unknown'; + const device = data.device ?? data.id ?? 'unknown'; + return { data, text: `Boot ready: ${device} (${platform})` }; +} + +export function getCliOutput(params: { result: CommandRequestResult; format?: string }): CliOutput { + const data = params.result as Record; + if (params.format === 'text') { + return { data, text: typeof data.text === 'string' ? data.text : '' }; + } + if (params.format === 'attrs') { + return { data, text: JSON.stringify(data.node ?? {}, null, 2) }; + } + return defaultCommandCliOutput(data); +} + +export function findCliOutput(result: CommandRequestResult): CliOutput { + const data = result as Record; + if (typeof data.text === 'string') return { data, text: data.text }; + if (typeof data.found === 'boolean') return { data, text: `Found: ${data.found}` }; + if (data.node) return { data, text: JSON.stringify(data.node, null, 2) }; + return defaultCommandCliOutput(data); +} + +export function isCliOutput(result: CommandRequestResult): CliOutput { + const data = result as Record; + return { data, text: `Passed: is ${data.predicate ?? 'assertion'}` }; +} + +export function tapCliOutput(result: CommandRequestResult): CliOutput { + const data = result as Record; + const ref = data.ref ?? ''; + const x = data.x; + const y = data.y; + if (!ref || typeof x !== 'number' || typeof y !== 'number') { + return defaultCommandCliOutput(data); + } + return { data, text: `Tapped @${ref} (${x}, ${y})` }; +} + +export function recordCliOutput(result: CommandRequestResult): CliOutput { + const data = result as Record; + const outPath = typeof data.outPath === 'string' ? data.outPath : ''; + return { data, text: outPath }; +} + +function defaultCommandCliOutput(result: CommandRequestResult): CliOutput { + return messageOutput(result as Record); +} + +function messageOutput(data: Record): CliOutput { + return { data, text: readCommandMessage(data) }; +} + +function formatAppState(data: AppStateCommandResult): string | null { + if (data.platform === 'ios') { + const lines = [`Foreground app: ${data.appName ?? data.appBundleId ?? 'unknown'}`]; + if (data.appBundleId) lines.push(`Bundle: ${data.appBundleId}`); + if (data.source) lines.push(`Source: ${data.source}`); + return lines.join('\n'); + } + if (data.platform === 'android') { + const lines = [`Foreground app: ${data.package ?? 'unknown'}`]; + if (data.activity) lines.push(`Activity: ${data.activity}`); + return lines.join('\n'); + } + return null; +} + +function androidKeyboardNextAction( + visible: boolean | undefined, + inputOwner: KeyboardCommandResult['inputOwner'], +): string { + if (inputOwner === 'ime') { + return 'Focused input appears to be owned by the keyboard/IME; dismiss or change the IME before retrying text entry.'; + } + if (visible === true) { + return 'Keyboard is visible and focused input appears app-owned; fill/type can proceed.'; + } + return 'Keyboard is hidden; focus an app field before type, or use fill with a concrete target.'; +} + +function formatDeviceLine(device: AgentDeviceDevice): string { + const kind = device.kind ? ` ${device.kind}` : ''; + const target = device.target ? ` target=${device.target}` : ''; + const booted = typeof device.booted === 'boolean' ? ` booted=${device.booted}` : ''; + return `${device.name} (${device.platform}${kind}${target})${booted}`; +} + +function withoutUnchanged(data: Record): Record { + const { unchanged: _unchanged, ...outputData } = data; + return outputData; +} diff --git a/src/commands/command-contract.ts b/src/commands/command-contract.ts new file mode 100644 index 000000000..8a4feea56 --- /dev/null +++ b/src/commands/command-contract.ts @@ -0,0 +1,48 @@ +import type { AgentDeviceClient } from '../client-types.ts'; + +export type JsonSchema = { + type?: string | readonly string[]; + description?: string; + properties?: Record; + required?: readonly string[]; + additionalProperties?: boolean | JsonSchema; + items?: JsonSchema; + prefixItems?: readonly JsonSchema[]; + oneOf?: readonly JsonSchema[]; + enum?: readonly unknown[]; + const?: unknown; + minimum?: number; + maximum?: number; +}; + +type CommandContract = { + name: Name; + description: string; + inputSchema: JsonSchema; + readInput: (input: unknown) => Input; + run: (client: AgentDeviceClient, input: Input) => Promise; +}; + +export type ExecutableCommandContract = CommandContract< + Name, + Input, + Result +> & { + invoke: (client: AgentDeviceClient, input: unknown) => Promise; +}; + +export type CliOutput = { + data: unknown; + jsonData?: unknown; + text?: string | null; + stderr?: string | null; +}; + +export function defineCommand( + definition: CommandContract, +): ExecutableCommandContract { + return { + ...definition, + invoke: async (client, input) => await definition.run(client, definition.readInput(input)), + }; +} diff --git a/src/commands/command-definition.ts b/src/commands/command-definition.ts deleted file mode 100644 index d571e03e6..000000000 --- a/src/commands/command-definition.ts +++ /dev/null @@ -1,42 +0,0 @@ -import type { CommandCapability } from '../core/capabilities.ts'; -import type { CliFlags, CommandSchema } from '../utils/command-schema.ts'; - -export const ALL_DEVICE_COMMAND_CAPABILITY = { - apple: { simulator: true, device: true }, - android: { emulator: true, device: true, unknown: true }, - linux: { device: true }, -} as const satisfies CommandCapability; - -export type CommandCodec = { - decode(positionals: string[], flags?: Partial): TOptions; - encode(options: TOptions): string[]; -}; - -export type CommandDefinition = { - name: TName; - schema: CommandSchema; - capability: CommandCapability; - codec?: CommandCodec; -}; - -export function defineCommand>( - definition: TDefinition, -): TDefinition { - return definition; -} - -export function commandSchemaMap( - definitions: readonly CommandDefinition[], -): Record { - return Object.fromEntries( - definitions.map((definition) => [definition.name, definition.schema]), - ) as Record; -} - -export function commandCapabilityMap( - definitions: readonly CommandDefinition[], -): Record { - return Object.fromEntries( - definitions.map((definition) => [definition.name, definition.capability]), - ) as Record; -} diff --git a/src/commands/command-input.ts b/src/commands/command-input.ts new file mode 100644 index 000000000..4c4070890 --- /dev/null +++ b/src/commands/command-input.ts @@ -0,0 +1,653 @@ +import type { + AgentDeviceRequestOverrides, + AgentDeviceSelectionOptions, + ElementTarget, + InteractionTarget, +} from '../client-types.ts'; +import type { DeviceTarget, PlatformSelector } from '../utils/device.ts'; +import type { JsonSchema } from './command-contract.ts'; + +const PLATFORM_VALUES = ['ios', 'android', 'macos', 'linux', 'apple'] as const; +const DEVICE_TARGET_VALUES = ['mobile', 'tv', 'desktop'] as const; +const INTERACTION_TARGET_KINDS = ['ref', 'selector', 'point'] as const; + +export type CommonCommandInput = Pick< + AgentDeviceRequestOverrides, + 'session' | 'daemonBaseUrl' | 'daemonAuthToken' | 'tenant' | 'runId' | 'leaseId' | 'cwd' | 'debug' +> & { + platform?: PlatformSelector; + deviceTarget?: DeviceTarget; + device?: string; + udid?: string; + serial?: string; + iosSimulatorDeviceSet?: string; + androidDeviceAllowlist?: string; +}; + +export type InteractionTargetInput = + | { kind: 'ref'; ref: string; label?: string } + | { kind: 'selector'; selector: string } + | { kind: 'point'; x: number; y: number }; + +export type ElementTargetInput = + | { kind: 'ref'; ref: string; label?: string } + | { kind: 'selector'; selector: string }; + +export type RepeatedInput = { + count?: number; + intervalMs?: number; + holdMs?: number; + jitterPx?: number; + doubleTap?: boolean; +}; + +export type SelectorSnapshotInput = { + depth?: number; + scope?: string; + raw?: boolean; +}; + +export type PointInput = { x: number; y: number }; +type CommonInputOptions = { readTargetAlias?: boolean }; + +function commandInputSchema( + properties: Record, + required: readonly string[] = [], +): JsonSchema { + return { + type: 'object', + properties: { + ...commonProperties(), + ...properties, + }, + ...(required.length > 0 ? { required } : {}), + additionalProperties: false, + }; +} + +function pointSchema(description: string): JsonSchema { + return { + type: 'object', + description, + properties: { + x: { type: 'number' }, + y: { type: 'number' }, + }, + required: ['x', 'y'], + additionalProperties: false, + }; +} + +function enumSchema(values: readonly string[], description?: string): JsonSchema { + return { type: 'string', enum: values, ...(description ? { description } : {}) }; +} + +export function stringSchema(description?: string): JsonSchema { + return { type: 'string', ...(description ? { description } : {}) }; +} + +function numberSchema(description?: string): JsonSchema { + return { type: 'number', ...(description ? { description } : {}) }; +} + +export function integerSchema(description?: string): JsonSchema { + return { type: 'integer', ...(description ? { description } : {}) }; +} + +export function booleanSchema(description?: string): JsonSchema { + return { type: 'boolean', ...(description ? { description } : {}) }; +} + +function stringArraySchema(description?: string): JsonSchema { + return { + type: 'array', + items: { type: 'string' }, + ...(description ? { description } : {}), + }; +} + +export function looseObjectSchema(description?: string): JsonSchema { + return { + type: 'object', + additionalProperties: true, + ...(description ? { description } : {}), + }; +} + +type FieldReader = (record: Record, key: string) => T | undefined; + +export type CommandField = { + schema: JsonSchema; + required: boolean; + read: FieldReader; +}; + +export type CommandFieldMap = Record>; + +export type InferCommandFields = { + [TKey in keyof TFields as TFields[TKey]['required'] extends true + ? TKey + : never]: TFields[TKey] extends CommandField ? TValue : never; +} & { + [TKey in keyof TFields as TFields[TKey]['required'] extends true + ? never + : TKey]?: TFields[TKey] extends CommandField ? TValue : never; +}; + +export type InferCommandInput = InferCommandFields & + CommonCommandInput & + AgentDeviceRequestOverrides & + AgentDeviceSelectionOptions; + +export function requiredField( + field: CommandField, +): CommandField> & { required: true } { + return { ...field, required: true } as CommandField> & { + required: true; + }; +} + +export function stringField(description?: string): CommandField { + return optionalField(stringSchema(description), optionalString); +} + +export function numberField(description?: string): CommandField { + return optionalField(numberSchema(description), optionalNumberValue); +} + +export function integerField( + description?: string, + options: { min?: number; max?: number } = {}, +): CommandField { + return optionalField(integerSchemaWithBounds(description, options), (record, key) => + optionalInteger(record, key, options), + ); +} + +export function booleanField(description?: string): CommandField { + return optionalField(booleanSchema(description), optionalBoolean); +} + +export function enumField( + values: TValues, + description?: string, +): CommandField { + return optionalField(enumSchema(values, description), (record, key) => + optionalEnum(record, key, values), + ); +} + +export function looseObjectField(description?: string): CommandField> { + return optionalField(looseObjectSchema(description), optionalRecord); +} + +export function stringArrayField(description?: string): CommandField { + return optionalField(stringArraySchema(description), optionalStringArray); +} + +export function jsonSchemaField(schema: JsonSchema): CommandField { + return optionalField(schema, (record, key) => record[key] as T | undefined); +} + +export function customField( + schema: JsonSchema, + read: (record: Record, key: string) => T | undefined, +): CommandField { + return optionalField(schema, read); +} + +export function interactionTargetField(): CommandField { + return optionalField(interactionTargetSchema(), (record, key) => + record[key] === undefined ? undefined : readInteractionTarget(record, key), + ); +} + +export function elementTargetField(): CommandField { + return optionalField(elementTargetSchema(), (record, key) => + record[key] === undefined ? undefined : readElementTarget(record, key), + ); +} + +export function pointField(description: string): CommandField { + return optionalField(pointSchema(description), (record, key) => + record[key] === undefined ? undefined : readPoint(record, key), + ); +} + +export function selectorSnapshotFields() { + return { + depth: integerField('Snapshot traversal depth.', { min: 0 }), + scope: stringField('Snapshot scope selector used before resolution.'), + raw: booleanField('Use raw snapshot data during selector resolution.'), + }; +} + +export function repeatedFields() { + return { + count: integerField('Number of press/click repetitions.', { min: 1 }), + intervalMs: integerField('Delay between repeated press/click actions.', { min: 0 }), + holdMs: integerField('Hold duration for each action.', { min: 0 }), + jitterPx: integerField('Randomization radius in pixels.', { min: 0 }), + doubleTap: booleanField('Request a double-tap action.'), + }; +} + +export function fieldsInputSchema(fields: CommandFieldMap): JsonSchema { + return commandInputSchema(fieldProperties(fields), requiredFieldNames(fields)); +} + +export function readFieldInput( + input: unknown, + fields: TFields, +): InferCommandInput { + const record = readInputRecord(input); + const commandOptions = Object.fromEntries( + Object.entries(fields).flatMap(([key, field]) => { + const value = field.read(record, key); + if (field.required && value === undefined) { + throw new Error(`Expected ${key} to be set.`); + } + return value === undefined ? [] : [[key, value]]; + }), + ); + const commonInput = readCommonInput(record, { + readTargetAlias: !Object.hasOwn(fields, 'target'), + }); + return compactRecord({ + ...commonInput, + ...commonToClientOptions(commonInput), + ...commandOptions, + }) as InferCommandInput; +} + +export function readInputRecord(input: unknown): Record { + if (input === undefined || input === null) return {}; + if (!input || typeof input !== 'object' || Array.isArray(input)) { + throw new Error('Expected object arguments.'); + } + return input as Record; +} + +export function readCommonInput( + record: Record, + options: CommonInputOptions = {}, +): CommonCommandInput { + return { + session: optionalString(record, 'session'), + platform: optionalEnum(record, 'platform', PLATFORM_VALUES), + deviceTarget: readDeviceTarget(record, options), + device: optionalString(record, 'device'), + udid: optionalString(record, 'udid'), + serial: optionalString(record, 'serial'), + iosSimulatorDeviceSet: optionalString(record, 'iosSimulatorDeviceSet'), + androidDeviceAllowlist: optionalString(record, 'androidDeviceAllowlist'), + daemonBaseUrl: optionalString(record, 'daemonBaseUrl'), + daemonAuthToken: optionalString(record, 'daemonAuthToken'), + tenant: optionalString(record, 'tenant'), + runId: optionalString(record, 'runId'), + leaseId: optionalString(record, 'leaseId'), + cwd: optionalString(record, 'cwd'), + debug: optionalBoolean(record, 'debug'), + }; +} + +function readDeviceTarget( + record: Record, + options: CommonInputOptions, +): DeviceTarget | undefined { + const deviceTarget = optionalEnum(record, 'deviceTarget', DEVICE_TARGET_VALUES); + if (options.readTargetAlias === false || record.target === undefined) return deviceTarget; + const targetAlias = optionalEnum(record, 'target', DEVICE_TARGET_VALUES); + if (deviceTarget !== undefined && targetAlias !== deviceTarget) { + throw new Error('Expected target alias to match deviceTarget when both are set.'); + } + return deviceTarget ?? targetAlias; +} + +function readInteractionTarget( + record: Record, + key: string, +): InteractionTargetInput { + const target = readRecordField(record, key); + const kind = requiredEnum(target, 'kind', INTERACTION_TARGET_KINDS); + switch (kind) { + case 'ref': + return { + kind, + ref: requiredString(target, 'ref'), + label: optionalString(target, 'label'), + }; + case 'selector': + return { kind, selector: requiredString(target, 'selector') }; + case 'point': + return { + kind, + x: requiredNumber(target, 'x'), + y: requiredNumber(target, 'y'), + }; + } +} + +function readElementTarget(record: Record, key: string): ElementTargetInput { + const target = readRecordField(record, key); + const kind = requiredEnum(target, 'kind', ['ref', 'selector'] as const); + if (kind === 'ref') { + return { + kind, + ref: requiredString(target, 'ref'), + label: optionalString(target, 'label'), + }; + } + return { kind, selector: requiredString(target, 'selector') }; +} + +export function readPoint(record: Record, key: string): PointInput { + const point = readRecordField(record, key); + return { x: requiredNumber(point, 'x'), y: requiredNumber(point, 'y') }; +} + +function requiredString(record: Record, key: string): string { + const value = record[key]; + if (typeof value !== 'string' || value.length === 0) { + throw new Error(`Expected ${key} to be a non-empty string.`); + } + return value; +} + +function optionalString(record: Record, key: string): string | undefined { + const value = record[key]; + if (value === undefined) return undefined; + if (typeof value !== 'string' || value.length === 0) { + throw new Error(`Expected ${key} to be a non-empty string.`); + } + return value; +} + +export function requiredNumber(record: Record, key: string): number { + const value = record[key]; + if (typeof value !== 'number' || !Number.isFinite(value)) { + throw new Error(`Expected ${key} to be a finite number.`); + } + return value; +} + +function optionalNumberValue(record: Record, key: string): number | undefined { + const value = record[key]; + if (value === undefined) return undefined; + if (typeof value !== 'number' || !Number.isFinite(value)) { + throw new Error(`Expected ${key} to be a finite number.`); + } + return value; +} + +export function optionalInteger( + record: Record, + key: string, + options: { min?: number; max?: number } = {}, +): number | undefined { + const value = record[key]; + if (value === undefined) return undefined; + if (!Number.isInteger(value)) { + throw new Error(`Expected ${key} to be an integer.`); + } + const numberValue = value as number; + if (options.min !== undefined && numberValue < options.min) { + throw new Error(`Expected ${key} to be at least ${options.min}.`); + } + if (options.max !== undefined && numberValue > options.max) { + throw new Error(`Expected ${key} to be at most ${options.max}.`); + } + return numberValue; +} + +function optionalBoolean(record: Record, key: string): boolean | undefined { + const value = record[key]; + if (value === undefined) return undefined; + if (typeof value !== 'boolean') { + throw new Error(`Expected ${key} to be a boolean.`); + } + return value; +} + +export function requiredEnum( + record: Record, + key: string, + values: T, +): T[number] { + const value = record[key]; + if (typeof value !== 'string' || !values.includes(value)) { + throw new Error(`Expected ${key} to be one of: ${values.join(', ')}.`); + } + return value; +} + +export function optionalEnum( + record: Record, + key: string, + values: T, +): T[number] | undefined { + const value = record[key]; + if (value === undefined) return undefined; + if (typeof value !== 'string' || !values.includes(value)) { + throw new Error(`Expected ${key} to be one of: ${values.join(', ')}.`); + } + return value; +} + +export function commonToClientOptions( + input: CommonCommandInput, +): AgentDeviceRequestOverrides & AgentDeviceSelectionOptions { + return { + session: input.session, + platform: input.platform, + target: input.deviceTarget, + device: input.device, + udid: input.udid, + serial: input.serial, + iosSimulatorDeviceSet: input.iosSimulatorDeviceSet, + androidDeviceAllowlist: input.androidDeviceAllowlist, + daemonBaseUrl: input.daemonBaseUrl, + daemonAuthToken: input.daemonAuthToken, + tenant: input.tenant, + runId: input.runId, + leaseId: input.leaseId, + cwd: input.cwd, + debug: input.debug, + }; +} + +export function toClientInteractionTarget(target: InteractionTargetInput): InteractionTarget { + switch (target.kind) { + case 'ref': + return { ref: target.ref, label: target.label }; + case 'selector': + return { selector: target.selector }; + case 'point': + return { x: target.x, y: target.y }; + } +} + +export function toClientElementTarget(target: ElementTargetInput): ElementTarget { + switch (target.kind) { + case 'ref': + return { ref: target.ref, label: target.label }; + case 'selector': + return { selector: target.selector }; + } +} + +export function toRepeatedOptions(input: RepeatedInput): RepeatedInput { + return { + count: input.count, + intervalMs: input.intervalMs, + holdMs: input.holdMs, + jitterPx: input.jitterPx, + doubleTap: input.doubleTap, + }; +} + +export function toSelectorSnapshotOptions(input: SelectorSnapshotInput): SelectorSnapshotInput { + return { + depth: input.depth, + scope: input.scope, + raw: input.raw, + }; +} + +export function assertAllowedKeys( + record: Record, + allowedKeys: readonly string[], + label: string, +): void { + const allowed = new Set(allowedKeys); + const unknownKeys = Object.keys(record).filter((key) => !allowed.has(key)); + if (unknownKeys.length > 0) { + throw new Error(`${label} has unknown field(s): ${unknownKeys.join(', ')}.`); + } +} + +export function compactRecord(record: Record): Record { + return Object.fromEntries(Object.entries(record).filter(([, value]) => value !== undefined)); +} + +function optionalField(schema: JsonSchema, read: FieldReader): CommandField { + return { schema, required: false, read }; +} + +function integerSchemaWithBounds( + description: string | undefined, + options: { min?: number; max?: number }, +): JsonSchema { + return { + ...integerSchema(description), + ...(options.min === undefined ? {} : { minimum: options.min }), + ...(options.max === undefined ? {} : { maximum: options.max }), + }; +} + +function fieldProperties(fields: CommandFieldMap): Record { + return Object.fromEntries(Object.entries(fields).map(([key, field]) => [key, field.schema])); +} + +function requiredFieldNames(fields: CommandFieldMap): string[] { + return Object.entries(fields).flatMap(([key, field]) => (field.required ? [key] : [])); +} + +function optionalRecord( + record: Record, + key: string, +): Record | undefined { + const value = record[key]; + if (value === undefined) return undefined; + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error(`Expected ${key} to be an object.`); + } + return value as Record; +} + +function optionalStringArray(record: Record, key: string): string[] | undefined { + const value = record[key]; + if (value === undefined) return undefined; + if (!Array.isArray(value) || value.some((entry) => typeof entry !== 'string')) { + throw new Error(`Expected ${key} to be an array of strings.`); + } + return value as string[]; +} + +function commonProperties(): Record { + return { + session: { type: 'string', description: 'Agent-device session name.' }, + platform: { + type: 'string', + enum: PLATFORM_VALUES, + description: 'Platform selector used to resolve a device.', + }, + deviceTarget: { + type: 'string', + enum: DEVICE_TARGET_VALUES, + description: 'Device target form. Maps to the CLI --target flag.', + }, + target: { + type: 'string', + enum: DEVICE_TARGET_VALUES, + description: + 'Alias for deviceTarget on commands without a UI target field. Interaction commands reserve target for the UI element.', + }, + device: { type: 'string', description: 'Device name selector.' }, + udid: { type: 'string', description: 'iOS device UDID selector.' }, + serial: { type: 'string', description: 'Android serial selector.' }, + iosSimulatorDeviceSet: { + type: 'string', + description: 'iOS simulator device-set path used for device resolution.', + }, + androidDeviceAllowlist: { + type: 'string', + description: 'Android serial allowlist used for device resolution.', + }, + daemonBaseUrl: { type: 'string', description: 'Remote daemon base URL.' }, + daemonAuthToken: { type: 'string', description: 'Remote daemon auth token.' }, + tenant: { type: 'string', description: 'Remote tenant identifier.' }, + runId: { type: 'string', description: 'Lease run identifier.' }, + leaseId: { type: 'string', description: 'Existing lease identifier.' }, + cwd: { type: 'string', description: 'Working directory for command execution.' }, + debug: { type: 'boolean', description: 'Enable debug diagnostics.' }, + }; +} + +function interactionTargetSchema(): JsonSchema { + return { + oneOf: [ + ...elementTargetSchemaVariants(), + { + type: 'object', + properties: { + kind: { type: 'string', const: 'point' }, + x: { type: 'number' }, + y: { type: 'number' }, + }, + required: ['kind', 'x', 'y'], + additionalProperties: false, + }, + ], + description: 'UI target. This is separate from deviceTarget, which selects the device form.', + }; +} + +function elementTargetSchema(): JsonSchema { + return { + oneOf: elementTargetSchemaVariants(), + description: 'UI element target by snapshot ref or selector expression.', + }; +} + +function elementTargetSchemaVariants(): JsonSchema[] { + return [ + { + type: 'object', + properties: { + kind: { type: 'string', const: 'ref' }, + ref: { type: 'string', description: 'Snapshot element ref such as @e12.' }, + label: { type: 'string', description: 'Optional human label for the ref.' }, + }, + required: ['kind', 'ref'], + additionalProperties: false, + }, + { + type: 'object', + properties: { + kind: { type: 'string', const: 'selector' }, + selector: { type: 'string', description: 'Agent-device selector expression.' }, + }, + required: ['kind', 'selector'], + additionalProperties: false, + }, + ]; +} + +function readRecordField(record: Record, key: string): Record { + const value = record[key]; + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error(`Expected ${key} to be an object.`); + } + return value as Record; +} diff --git a/src/commands/command-projection.ts b/src/commands/command-projection.ts new file mode 100644 index 000000000..4c95ee0d4 --- /dev/null +++ b/src/commands/command-projection.ts @@ -0,0 +1,135 @@ +import { PUBLIC_COMMANDS } from '../command-catalog.ts'; +import { buildFlags } from '../client-normalizers.ts'; +import type { DaemonBatchStep } from '../core/batch.ts'; +import { AppError } from '../utils/errors.ts'; +import { appDaemonWriters } from './cli-grammar/apps.ts'; +import { captureDaemonWriters } from './cli-grammar/capture.ts'; +import { commandNameSet, request } from './cli-grammar/common.ts'; +import { gestureDaemonWriters } from './cli-grammar/gesture.ts'; +import { interactionDaemonWriters } from './cli-grammar/interactions.ts'; +import { observabilityDaemonWriters } from './cli-grammar/observability.ts'; +import { replayDaemonWriters } from './cli-grammar/replay.ts'; +import { selectorDaemonWriters } from './cli-grammar/selectors.ts'; +import { systemDaemonWriters } from './cli-grammar/system.ts'; +import type { CommandInput, DaemonCommandRequest, DaemonWriter } from './cli-grammar/types.ts'; + +const daemonWriters = { + ...appDaemonWriters, + ...captureDaemonWriters, + ...interactionDaemonWriters, + ...gestureDaemonWriters, + ...selectorDaemonWriters, + ...observabilityDaemonWriters, + ...replayDaemonWriters, + ...systemDaemonWriters, + batch: (input) => + request(PUBLIC_COMMANDS.batch, [], { + ...input, + batchSteps: readBatchDaemonSteps(input.steps), + batchOnError: input.onError, + batchMaxSteps: input.maxSteps, + }), +} satisfies Record; + +export type DaemonCommandName = keyof typeof daemonWriters; + +const NON_BATCH_COMMAND_NAMES = [ + 'replay', + 'batch', + 'gesture-pan', + 'gesture-fling', + 'gesture-pinch', + 'gesture-rotate', + 'gesture-transform', +] as const; +type NonBatchCommandName = (typeof NON_BATCH_COMMAND_NAMES)[number]; +export type BatchCommandName = Exclude; + +const nonBatchCommandNames = commandNameSet(NON_BATCH_COMMAND_NAMES); + +export const batchCommandNames = (Object.keys(daemonWriters) as DaemonCommandName[]).filter( + (name): name is BatchCommandName => !nonBatchCommandNames.has(name), +); + +const batchNames = commandNameSet(batchCommandNames); + +function isBatchCommandName(name: string): name is BatchCommandName { + return batchNames.has(name); +} + +function prepareBatchStep(command: DaemonCommandName, input: CommandInput): DaemonBatchStep { + const prepared = prepareDaemonCommandRequest(command, input); + return { + command: prepared.command, + positionals: prepared.positionals, + flags: buildFlags(prepared.options), + runtime: prepared.options.runtime, + }; +} + +function readBatchDaemonSteps(steps: unknown): DaemonBatchStep[] { + if (!Array.isArray(steps) || steps.length === 0) { + throw new AppError('INVALID_ARGS', 'batch requires a non-empty steps array.'); + } + return steps.map((step, index) => readBatchDaemonStep(step, index + 1)); +} + +function readBatchDaemonStep(step: unknown, stepNumber: number): DaemonBatchStep { + const record = readBatchStepRecord(step, stepNumber); + const command = readBatchStepCommand(record, stepNumber); + const input = readBatchStepInput(record, stepNumber); + const runtime = readBatchStepRuntime(record, stepNumber); + const prepared = prepareBatchStep(command, input); + return { + ...prepared, + runtime: runtime ?? prepared.runtime, + }; +} + +function readBatchStepRecord(step: unknown, stepNumber: number): Record { + if (!step || typeof step !== 'object' || Array.isArray(step)) { + throw new AppError('INVALID_ARGS', `Invalid batch step ${stepNumber}.`); + } + return step as Record; +} + +function readBatchStepCommand( + record: Record, + stepNumber: number, +): BatchCommandName { + const command = typeof record.command === 'string' ? record.command.trim().toLowerCase() : ''; + if (isBatchCommandName(command)) return command; + throw new AppError( + 'INVALID_ARGS', + `Batch step ${stepNumber} command is not available through command batch: ${String(record.command)}`, + ); +} + +function readBatchStepInput(record: Record, stepNumber: number): CommandInput { + const input = record.input; + if (!input || typeof input !== 'object' || Array.isArray(input)) { + throw new AppError('INVALID_ARGS', `Batch step ${stepNumber} input must be an object.`); + } + return input as CommandInput; +} + +function readBatchStepRuntime( + record: Record, + stepNumber: number, +): Record | undefined { + const runtime = record.runtime; + if ( + runtime !== undefined && + (!runtime || typeof runtime !== 'object' || Array.isArray(runtime)) + ) { + throw new AppError('INVALID_ARGS', `Batch step ${stepNumber} runtime must be an object.`); + } + return runtime as Record | undefined; +} + +export function prepareDaemonCommandRequest( + command: DaemonCommandName, + input: CommandInput, +): DaemonCommandRequest { + return daemonWriters[command](input); +} diff --git a/src/commands/command-surface.ts b/src/commands/command-surface.ts new file mode 100644 index 000000000..8cc001380 --- /dev/null +++ b/src/commands/command-surface.ts @@ -0,0 +1,62 @@ +import type { AgentDeviceClient } from '../client-types.ts'; +import { listMcpExposedCommandNames } from '../command-catalog.ts'; +import { createBatchCommand } from './batch-command.ts'; +import { clientCommandDefinitions } from './client-command-contracts.ts'; +import type { JsonSchema } from './command-contract.ts'; +import { interactionCommandDefinitions } from './interaction-command-contracts.ts'; +import { batchCommandNames, type BatchCommandName } from './command-projection.ts'; + +type AnyExecutableCommand = { + name: string; + description: string; + inputSchema: JsonSchema; + invoke: (client: AgentDeviceClient, input: unknown) => Promise; +}; + +const batchCommandDefinition = createBatchCommand(batchCommandNames); + +const commandSurface = [ + ...interactionCommandDefinitions, + ...clientCommandDefinitions, + batchCommandDefinition, +] as const; + +export type CommandName = (typeof commandSurface)[number]['name']; +export type { BatchCommandName }; + +const commandMap: ReadonlyMap = new Map( + commandSurface.map((definition) => [definition.name, definition]), +); + +export function listMcpToolDefinitions(): AnyExecutableCommand[] { + return listMcpExposedCommandNames().map((name) => { + if (!isCommandName(name)) { + throw new Error(`Missing command for MCP-exposed command: ${name}`); + } + return getCommandDefinition(name); + }); +} + +export function listCommandNames(): CommandName[] { + return commandSurface.map((definition) => definition.name); +} + +export function listCommandDefinitions(): AnyExecutableCommand[] { + return [...commandSurface]; +} + +export function isCommandName(name: string): name is CommandName { + return commandMap.has(name as CommandName); +} + +export async function runCommand( + client: AgentDeviceClient, + name: CommandName, + input: unknown, +): Promise { + return await getCommandDefinition(name).invoke(client, input); +} + +function getCommandDefinition(name: CommandName): AnyExecutableCommand { + return commandMap.get(name)!; +} diff --git a/src/commands/field-command-contract.ts b/src/commands/field-command-contract.ts new file mode 100644 index 000000000..61fff530b --- /dev/null +++ b/src/commands/field-command-contract.ts @@ -0,0 +1,27 @@ +import type { AgentDeviceClient } from '../client-types.ts'; +import { defineCommand } from './command-contract.ts'; +import { + fieldsInputSchema, + readFieldInput, + type CommandFieldMap, + type InferCommandInput, +} from './command-input.ts'; + +export function defineFieldCommand< + const TName extends string, + const TFields extends CommandFieldMap, + TResult, +>( + name: TName, + description: string, + fields: TFields, + run: (client: AgentDeviceClient, input: InferCommandInput) => Promise, +) { + return defineCommand({ + name, + description, + inputSchema: fieldsInputSchema(fields), + readInput: (input) => readFieldInput(input, fields), + run, + }); +} diff --git a/src/commands/interaction-command-contracts.ts b/src/commands/interaction-command-contracts.ts new file mode 100644 index 000000000..405cb36d4 --- /dev/null +++ b/src/commands/interaction-command-contracts.ts @@ -0,0 +1,437 @@ +import type { + ClickOptions, + FindOptions, + FlingOptions, + FillOptions, + FocusOptions, + GetOptions, + PanOptions, + PinchOptions, + PressOptions, + IsOptions, + LongPressOptions, + RotateGestureOptions, + ScrollOptions, + SwipeOptions, + TransformGestureOptions, + TypeTextOptions, +} from '../client-types.ts'; +import { defineCommand } from './command-contract.ts'; +import { + booleanField, + commonToClientOptions, + elementTargetField, + enumField, + fieldsInputSchema, + integerField, + interactionTargetField, + numberField, + optionalInteger, + pointField, + readCommonInput, + readFieldInput, + readInputRecord, + readPoint, + repeatedFields, + requiredEnum, + requiredField, + requiredNumber, + selectorSnapshotFields, + stringField, + toClientElementTarget, + toClientInteractionTarget, + toRepeatedOptions, + toSelectorSnapshotOptions, + type CommonCommandInput, + type InferCommandInput, + type PointInput, +} from './command-input.ts'; +import { defineFieldCommand } from './field-command-contract.ts'; + +const CLICK_BUTTON_VALUES = ['primary', 'secondary', 'middle'] as const; +const GESTURE_KIND_VALUES = ['pan', 'fling', 'pinch', 'rotate', 'transform'] as const; +const GESTURE_DIRECTION_VALUES = ['up', 'down', 'left', 'right'] as const; +const FIND_ACTION_VALUES = [ + 'click', + 'focus', + 'exists', + 'getText', + 'getAttrs', + 'wait', + 'fill', + 'type', +] as const; +const FIND_LOCATOR_VALUES = ['any', 'text', 'label', 'value', 'role', 'id'] as const; +const SCROLL_DIRECTION_VALUES = ['up', 'down', 'left', 'right', 'top', 'bottom'] as const; +const SWIPE_PATTERN_VALUES = ['one-way', 'ping-pong'] as const; + +const clickFields = { + target: requiredField(interactionTargetField()), + button: enumField( + CLICK_BUTTON_VALUES, + 'Pointer button for platforms that support mouse buttons.', + ), + ...selectorSnapshotFields(), + ...repeatedFields(), +}; + +const pressFields = { + target: requiredField(interactionTargetField()), + ...selectorSnapshotFields(), + ...repeatedFields(), +}; + +const fillFields = { + target: requiredField(interactionTargetField()), + text: requiredField(stringField('Text to enter into the target.')), + delayMs: integerField('Delay between typed characters.', { min: 0 }), + ...selectorSnapshotFields(), +}; + +const longPressFields = { + target: requiredField(interactionTargetField()), + durationMs: integerField('Long press duration in milliseconds.', { min: 0 }), + ...selectorSnapshotFields(), +}; + +const swipeFields = { + from: requiredField(pointField('Swipe start point.')), + to: requiredField(pointField('Swipe end point.')), + durationMs: integerField('Swipe duration in milliseconds.', { min: 0 }), + count: integerField('Number of swipe repetitions.', { min: 1 }), + pauseMs: integerField('Pause between repeated swipes.', { min: 0 }), + pattern: enumField(SWIPE_PATTERN_VALUES), +}; + +const focusFields = { + x: requiredField(numberField('X coordinate.')), + y: requiredField(numberField('Y coordinate.')), +}; + +const typeFields = { + text: requiredField(stringField('Text to type.')), + delayMs: integerField('Delay between typed characters.', { min: 0 }), +}; + +const scrollFields = { + direction: requiredField(enumField(SCROLL_DIRECTION_VALUES)), + amount: numberField('Platform scroll amount.'), + pixels: integerField('Pixel scroll amount.', { min: 0 }), +}; + +const getFields = { + format: requiredField(enumField(['text', 'attrs'] as const)), + target: requiredField(elementTargetField()), + ...selectorSnapshotFields(), +}; + +const isFields = { + predicate: requiredField( + enumField(['visible', 'hidden', 'exists', 'editable', 'selected', 'text'] as const), + ), + selector: requiredField(stringField()), + value: stringField(), + ...selectorSnapshotFields(), +}; + +const findFields = { + locator: enumField(FIND_LOCATOR_VALUES), + query: requiredField(stringField()), + action: enumField(FIND_ACTION_VALUES), + value: stringField(), + timeoutMs: integerField(), + first: booleanField(), + last: booleanField(), + depth: integerField(), + raw: booleanField(), +}; + +const gestureFields = { + kind: requiredField(enumField(GESTURE_KIND_VALUES, 'Gesture variant.')), + direction: enumField(GESTURE_DIRECTION_VALUES, 'Fling direction.'), + origin: pointField('Gesture origin point.'), + delta: pointField('Movement delta for pan or transform gestures.'), + distance: integerField('Fling distance.', { min: 0 }), + scale: numberField('Pinch or transform scale.'), + degrees: numberField('Rotation in degrees.'), + velocity: integerField('Rotate gesture velocity.', { min: 0 }), + durationMs: integerField('Gesture duration in milliseconds.', { min: 0 }), +}; + +type ClickInput = InferCommandInput; +type PressInput = InferCommandInput; +type FillInput = InferCommandInput; +type LongPressInput = InferCommandInput; +type GetInput = InferCommandInput; + +type PanInput = CommonCommandInput & { + kind: 'pan'; + origin: PointInput; + delta: PointInput; + durationMs?: number; +}; + +type FlingInput = CommonCommandInput & { + kind: 'fling'; + direction: 'up' | 'down' | 'left' | 'right'; + origin: PointInput; + distance?: number; + durationMs?: number; +}; + +type PinchInput = CommonCommandInput & { + kind: 'pinch'; + scale: number; + origin?: PointInput; +}; + +type RotateInput = CommonCommandInput & { + kind: 'rotate'; + degrees: number; + origin?: PointInput; + velocity?: number; +}; + +type TransformInput = CommonCommandInput & { + kind: 'transform'; + origin: PointInput; + delta: PointInput; + scale: number; + degrees: number; + durationMs?: number; +}; + +type GestureInput = PanInput | FlingInput | PinchInput | RotateInput | TransformInput; + +export const interactionCommandDefinitions = [ + defineCommand({ + name: 'click', + description: 'Click or tap a semantic UI target by ref, selector, or point.', + inputSchema: fieldsInputSchema(clickFields), + readInput: (input) => readFieldInput(input, clickFields), + run: (client, input) => client.interactions.click(toClickOptions(input)), + }), + defineCommand({ + name: 'press', + description: 'Press a semantic UI target by ref, selector, or point.', + inputSchema: fieldsInputSchema(pressFields), + readInput: (input) => readFieldInput(input, pressFields), + run: (client, input) => client.interactions.press(toPressOptions(input)), + }), + defineCommand({ + name: 'fill', + description: 'Fill text into a semantic UI target by ref, selector, or point.', + inputSchema: fieldsInputSchema(fillFields), + readInput: (input) => readFieldInput(input, fillFields), + run: (client, input) => client.interactions.fill(toFillOptions(input)), + }), + defineFieldCommand( + 'longpress', + 'Long press by ref, selector, or point.', + longPressFields, + (client, input) => client.interactions.longPress(toLongPressOptions(input)), + ), + defineFieldCommand('swipe', 'Swipe between two points.', swipeFields, (client, input) => + client.interactions.swipe(input as SwipeOptions), + ), + defineFieldCommand('focus', 'Focus input at coordinates.', focusFields, (client, input) => + client.interactions.focus(input as FocusOptions), + ), + defineFieldCommand('type', 'Type text in the focused field.', typeFields, (client, input) => + client.interactions.type(input as TypeTextOptions), + ), + defineFieldCommand( + 'scroll', + 'Scroll in a direction or to an edge.', + scrollFields, + (client, input) => client.interactions.scroll(input as ScrollOptions), + ), + defineFieldCommand('get', 'Get element text or attributes.', getFields, (client, input) => + client.interactions.get(toGetOptions(input)), + ), + defineFieldCommand('is', 'Assert UI state.', isFields, (client, input) => + client.interactions.is(input as IsOptions), + ), + defineFieldCommand( + 'find', + 'Find an element and optionally act on it.', + findFields, + (client, input) => client.interactions.find(input as FindOptions), + ), + defineCommand({ + name: 'gesture', + description: 'Run a structured gesture.', + inputSchema: fieldsInputSchema(gestureFields), + readInput: readGestureInput, + run: async (client, input) => { + switch (input.kind) { + case 'pan': + return await client.interactions.pan(toPanOptions(input)); + case 'fling': + return await client.interactions.fling(toFlingOptions(input)); + case 'pinch': + return await client.interactions.pinch(toPinchOptions(input)); + case 'rotate': + return await client.interactions.rotateGesture(toRotateOptions(input)); + case 'transform': + return await client.interactions.transformGesture(toTransformOptions(input)); + } + }, + }), +] as const; + +function readGestureInput(input: unknown): GestureInput { + const record = readInputRecord(input); + const common = readCommonInput(record); + const kind = requiredEnum(record, 'kind', GESTURE_KIND_VALUES); + if (kind === 'pan') { + return { + ...common, + kind, + origin: readPoint(record, 'origin'), + delta: readPoint(record, 'delta'), + durationMs: optionalInteger(record, 'durationMs', { min: 0 }), + }; + } + if (kind === 'fling') { + return { + ...common, + kind, + direction: requiredEnum(record, 'direction', GESTURE_DIRECTION_VALUES), + origin: readPoint(record, 'origin'), + distance: optionalInteger(record, 'distance', { min: 0 }), + durationMs: optionalInteger(record, 'durationMs', { min: 0 }), + }; + } + if (kind === 'pinch') { + return { + ...common, + kind, + scale: requiredNumber(record, 'scale'), + origin: optionalPoint(record, 'origin'), + }; + } + if (kind === 'rotate') { + return { + ...common, + kind, + degrees: requiredNumber(record, 'degrees'), + origin: optionalPoint(record, 'origin'), + velocity: optionalInteger(record, 'velocity', { min: 0 }), + }; + } + return { + ...common, + kind, + origin: readPoint(record, 'origin'), + delta: readPoint(record, 'delta'), + scale: requiredNumber(record, 'scale'), + degrees: requiredNumber(record, 'degrees'), + durationMs: optionalInteger(record, 'durationMs', { min: 0 }), + }; +} + +function optionalPoint(record: Record, key: string): PointInput | undefined { + return record[key] === undefined ? undefined : readPoint(record, key); +} + +function toClickOptions(input: ClickInput): ClickOptions { + return { + ...commonToClientOptions(input), + ...toClientInteractionTarget(input.target), + ...toSelectorSnapshotOptions(input), + ...toRepeatedOptions(input), + button: input.button, + }; +} + +function toPressOptions(input: PressInput): PressOptions { + return { + ...commonToClientOptions(input), + ...toClientInteractionTarget(input.target), + ...toSelectorSnapshotOptions(input), + ...toRepeatedOptions(input), + }; +} + +function toFillOptions(input: FillInput): FillOptions { + return { + ...commonToClientOptions(input), + ...toClientInteractionTarget(input.target), + ...toSelectorSnapshotOptions(input), + text: input.text, + delayMs: input.delayMs, + }; +} + +function toLongPressOptions(input: LongPressInput): LongPressOptions { + return { + ...commonToClientOptions(input), + ...toClientInteractionTarget(input.target), + ...toSelectorSnapshotOptions(input), + durationMs: input.durationMs, + }; +} + +function toGetOptions(input: GetInput): GetOptions { + return { + ...commonToClientOptions(input), + ...toClientElementTarget(input.target), + ...toSelectorSnapshotOptions(input), + format: input.format, + }; +} + +function toPanOptions(input: PanInput): PanOptions { + return { + ...commonToClientOptions(input), + x: input.origin.x, + y: input.origin.y, + dx: input.delta.x, + dy: input.delta.y, + durationMs: input.durationMs, + }; +} + +function toFlingOptions(input: FlingInput): FlingOptions { + return { + ...commonToClientOptions(input), + direction: input.direction, + x: input.origin.x, + y: input.origin.y, + distance: input.distance, + durationMs: input.durationMs, + }; +} + +function toPinchOptions(input: PinchInput): PinchOptions { + return { + ...commonToClientOptions(input), + scale: input.scale, + x: input.origin?.x, + y: input.origin?.y, + }; +} + +function toRotateOptions(input: RotateInput): RotateGestureOptions { + return { + ...commonToClientOptions(input), + degrees: input.degrees, + x: input.origin?.x, + y: input.origin?.y, + velocity: input.velocity, + }; +} + +function toTransformOptions(input: TransformInput): TransformGestureOptions { + return { + ...commonToClientOptions(input), + x: input.origin.x, + y: input.origin.y, + dx: input.delta.x, + dy: input.delta.y, + scale: input.scale, + degrees: input.degrees, + durationMs: input.durationMs, + }; +} diff --git a/src/commands/interactions/__tests__/definition.test.ts b/src/commands/interactions/__tests__/definition.test.ts deleted file mode 100644 index 4a2ed8a2b..000000000 --- a/src/commands/interactions/__tests__/definition.test.ts +++ /dev/null @@ -1,53 +0,0 @@ -import assert from 'node:assert/strict'; -import { test } from 'vitest'; -import type { AgentDeviceClient } from '../../../client.ts'; -import { getCommandCapability } from '../../../core/capabilities.ts'; -import { getCommandSchema, type CliFlags } from '../../../utils/command-schema.ts'; -import { CAPTURE_COMMAND_DEFINITIONS } from '../../capture-definition.ts'; -import { SELECTOR_COMMAND_DEFINITIONS } from '../../selectors-definition.ts'; -import { SESSION_LIFECYCLE_COMMAND_DEFINITIONS } from '../../session-lifecycle/definition.ts'; -import { runTypeCliCommand } from '../cli.ts'; -import { INTERACTION_COMMAND_DEFINITIONS, typeCommandDefinition } from '../definition.ts'; - -test('command definitions feed schema and capability registries', () => { - for (const definition of [ - ...INTERACTION_COMMAND_DEFINITIONS, - ...CAPTURE_COMMAND_DEFINITIONS, - ...SELECTOR_COMMAND_DEFINITIONS, - ...SESSION_LIFECYCLE_COMMAND_DEFINITIONS, - ]) { - assert.deepEqual(getCommandSchema(definition.name), definition.schema); - assert.deepEqual(getCommandCapability(definition.name), definition.capability); - } -}); - -test('type command definition exposes its positional codec', () => { - assert.deepEqual(typeCommandDefinition.codec.decode(['hello', 'world'], { delayMs: 25 }), { - text: 'hello world', - delayMs: 25, - }); - assert.deepEqual(typeCommandDefinition.codec.encode({ text: 'hello world' }), ['hello world']); -}); - -test('type CLI command routes through the definition codec', async () => { - let received: unknown; - const client = { - interactions: { - type: async (options: unknown) => { - received = options; - return {}; - }, - }, - } as AgentDeviceClient; - - await runTypeCliCommand({ - client, - positionals: ['hello', 'world'], - flags: { platform: 'ios', delayMs: 25 } as CliFlags, - }); - - const options = received as Record; - assert.equal(options.platform, 'ios'); - assert.equal(options.text, 'hello world'); - assert.equal(options.delayMs, 25); -}); diff --git a/src/commands/interactions/cli.ts b/src/commands/interactions/cli.ts deleted file mode 100644 index 33758a312..000000000 --- a/src/commands/interactions/cli.ts +++ /dev/null @@ -1,22 +0,0 @@ -import type { AgentDeviceClient, CommandRequestResult } from '../../client.ts'; -import type { CliFlags } from '../../utils/command-schema.ts'; -import { buildSelectionOptions } from '../../cli/commands/shared.ts'; -import { typeCommandCodec } from './definition.ts'; - -export type InteractionCliCommandParams = { - client: AgentDeviceClient; - positionals: string[]; - flags: CliFlags; -}; - -export async function runTypeCliCommand({ - client, - positionals, - flags, -}: InteractionCliCommandParams): Promise { - const decoded = typeCommandCodec.decode(positionals, flags); - return await client.interactions.type({ - ...buildSelectionOptions(flags), - ...decoded, - }); -} diff --git a/src/commands/interactions/definition.ts b/src/commands/interactions/definition.ts deleted file mode 100644 index c9c7398fe..000000000 --- a/src/commands/interactions/definition.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { PUBLIC_COMMANDS } from '../../command-catalog.ts'; -import { - ALL_DEVICE_COMMAND_CAPABILITY, - type CommandCodec, - commandCapabilityMap, - commandSchemaMap, - defineCommand, -} from '../command-definition.ts'; - -type TypeCommandCodecOptions = { - text: string; - delayMs?: number; -}; - -export const typeCommandCodec = { - decode: (positionals, flags) => ({ - text: positionals.join(' '), - delayMs: flags?.delayMs, - }), - // `delayMs` is encoded through flags, so positionals only carry text. - encode: (options) => [options.text], -} satisfies CommandCodec; - -export const typeCommandDefinition = defineCommand({ - name: PUBLIC_COMMANDS.type, - schema: { - helpDescription: 'Type text in focused field', - positionalArgs: ['text'], - allowsExtraPositionals: true, - allowedFlags: ['delayMs'], - }, - capability: ALL_DEVICE_COMMAND_CAPABILITY, - codec: typeCommandCodec, -}); - -export const INTERACTION_COMMAND_DEFINITIONS = [typeCommandDefinition] as const; - -export const INTERACTION_COMMAND_SCHEMAS = commandSchemaMap(INTERACTION_COMMAND_DEFINITIONS); -export const INTERACTION_COMMAND_CAPABILITIES = commandCapabilityMap( - INTERACTION_COMMAND_DEFINITIONS, -); diff --git a/src/commands/react-native/definition.ts b/src/commands/react-native/definition.ts deleted file mode 100644 index f97496ebe..000000000 --- a/src/commands/react-native/definition.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { PUBLIC_COMMANDS } from '../../command-catalog.ts'; -import { commandCapabilityMap, commandSchemaMap, defineCommand } from '../command-definition.ts'; - -export type ReactNativeCommandOptions = { - action: 'dismiss-overlay'; -}; - -const reactNativeCommandDefinition = defineCommand({ - name: PUBLIC_COMMANDS.reactNative, - schema: { - usageOverride: 'react-native dismiss-overlay', - listUsageOverride: 'react-native dismiss-overlay', - helpDescription: 'Dismiss React Native LogBox/RedBox overlays safely', - summary: 'Dismiss React Native overlays', - positionalArgs: ['dismiss-overlay'], - allowedFlags: [], - }, - capability: { - apple: { simulator: true, device: true }, - android: { emulator: true, device: true, unknown: true }, - linux: {}, - }, -}); - -const REACT_NATIVE_COMMAND_DEFINITIONS = [reactNativeCommandDefinition] as const; - -export const REACT_NATIVE_COMMAND_SCHEMAS = commandSchemaMap(REACT_NATIVE_COMMAND_DEFINITIONS); -export const REACT_NATIVE_COMMAND_CAPABILITIES = commandCapabilityMap( - REACT_NATIVE_COMMAND_DEFINITIONS, -); diff --git a/src/commands/runtime-output.ts b/src/commands/runtime-output.ts new file mode 100644 index 000000000..e33800f75 --- /dev/null +++ b/src/commands/runtime-output.ts @@ -0,0 +1,286 @@ +import type { CommandRequestResult } from '../client-types.ts'; +import { readCommandMessage } from '../utils/success-text.ts'; +import type { CliOutput } from './command-contract.ts'; + +export function batchCliOutput(result: CommandRequestResult): CliOutput { + const data = result as Record; + const total = typeof data.total === 'number' ? data.total : 0; + const executed = typeof data.executed === 'number' ? data.executed : 0; + const durationMs = typeof data.totalDurationMs === 'number' ? data.totalDurationMs : undefined; + const lines = [ + `Batch completed: ${executed}/${total} steps${durationMs !== undefined ? ` in ${durationMs}ms` : ''}`, + ]; + const results = Array.isArray(data.results) ? data.results : []; + for (const entry of results) { + const line = renderBatchStepLine(entry); + if (line) lines.push(line); + } + return { data, text: lines.join('\n') }; +} + +export function logsCliOutput(result: CommandRequestResult): CliOutput { + const data = result as Record; + const pathOut = typeof data.path === 'string' ? data.path : ''; + return { + data, + text: pathOut, + stderr: joinDefinedLines([ + formatKeyValueFields(data, ['active', 'state', 'backend', 'sizeBytes']), + formatActionFields(data), + typeof data.hint === 'string' ? data.hint : undefined, + formatNotes(data.notes), + ]), + }; +} + +export function networkCliOutput(result: CommandRequestResult): CliOutput { + const data = result as Record; + const lines: string[] = []; + const pathOut = typeof data.path === 'string' ? data.path : ''; + if (pathOut) lines.push(pathOut); + const entries = Array.isArray(data.entries) ? data.entries : []; + if (entries.length === 0) { + lines.push('No recent HTTP(s) entries found.'); + } else { + for (const entry of entries) { + lines.push(...formatNetworkEntry(entry)); + } + } + return { + data, + text: lines.join('\n'), + stderr: joinDefinedLines([ + formatKeyValueFields(data, [ + 'active', + 'state', + 'backend', + 'include', + 'scannedLines', + 'matchedLines', + ]), + formatNotes(data.notes), + ]), + }; +} + +export function perfCliOutput(result: CommandRequestResult): CliOutput { + const data = result as Record; + return { data, text: formatPerfCliOutput(data) }; +} + +function renderBatchStepLine(entry: unknown): string | undefined { + const result = readRecord(entry); + if (!result) return undefined; + const step = typeof result.step === 'number' ? result.step : undefined; + const command = typeof result.command === 'string' ? result.command : 'step'; + const stepOk = result.ok !== false; + const description = readBatchStepDescription(result, stepOk, command); + const prefix = step !== undefined ? `${step}. ` : '- '; + const durationMs = typeof result.durationMs === 'number' ? result.durationMs : undefined; + const durationSuffix = durationMs !== undefined ? ` (${durationMs}ms)` : ''; + return `${prefix}${stepOk ? 'OK' : 'FAILED'} ${description}${durationSuffix}`; +} + +function readBatchStepDescription( + result: Record, + stepOk: boolean, + command: string, +): string { + if (stepOk) return readCommandMessage(readRecord(result.data)) ?? command; + return readBatchStepFailure(readRecord(result.error)) ?? command; +} + +function readBatchStepFailure(error: Record | undefined): string | null { + return typeof error?.message === 'string' && error.message.length > 0 ? error.message : null; +} + +function formatActionFields(data: Record): string | undefined { + return ( + ['started', 'stopped', 'marked', 'cleared', 'restarted', 'removedRotatedFiles'] + .map((key) => formatActionField(key, data[key])) + .filter(Boolean) + .join(' ') || undefined + ); +} + +function formatActionField(key: string, value: unknown): string { + if (value === true) return `${key}=true`; + return typeof value === 'number' ? `${key}=${value}` : ''; +} + +function formatNetworkEntry(entry: unknown): string[] { + const record = readRecord(entry) ?? {}; + const method = typeof record.method === 'string' ? record.method : 'HTTP'; + const url = typeof record.url === 'string' ? record.url : ''; + const status = typeof record.status === 'number' ? ` status=${record.status}` : ''; + const timestamp = typeof record.timestamp === 'string' ? `${record.timestamp} ` : ''; + const durationMs = + typeof record.durationMs === 'number' ? ` durationMs=${record.durationMs}` : ''; + const lines = [`${timestamp}${method} ${url}${status}${durationMs}`]; + appendNetworkEntryBody(lines, 'headers', record.headers); + appendNetworkEntryBody(lines, 'request', record.requestBody); + appendNetworkEntryBody(lines, 'response', record.responseBody); + return lines; +} + +function appendNetworkEntryBody(lines: string[], label: string, value: unknown): void { + if (typeof value === 'string') lines.push(` ${label}: ${value}`); +} + +function formatKeyValueFields(data: Record, fields: string[]): string | undefined { + const text = fields + .map((key) => (data[key] !== undefined && data[key] !== null ? `${key}=${data[key]}` : '')) + .filter(Boolean) + .join(' '); + return text || undefined; +} + +function formatNotes(notes: unknown): string | undefined { + if (!Array.isArray(notes)) return undefined; + const lines = notes.filter((note): note is string => typeof note === 'string' && note.length > 0); + return lines.length > 0 ? lines.join('\n') : undefined; +} + +function joinDefinedLines(lines: Array): string | undefined { + const joined = lines.filter((line): line is string => Boolean(line)).join('\n'); + return joined || undefined; +} + +function formatPerfCliOutput(data: Record): string { + const metrics = readRecord(data.metrics); + const fps = readRecord(metrics?.fps); + const resourceSummary = buildResourcePerfSummary(metrics); + if (!fps) { + return formatPerfUnavailable(resourceSummary, 'missing frame metric'); + } + + if (fps.available === false) { + return formatPerfUnavailable(resourceSummary, readUnavailableReason(fps)); + } + + const frameSummary = formatFrameHealthSummary(fps); + if (!frameSummary) return formatPerfUnavailable(resourceSummary, 'missing dropped-frame summary'); + + const lines = [`Frame health: ${frameSummary}`]; + const worstWindows = formatWorstFrameWindows(fps); + if (worstWindows.length > 0) { + lines.push('Worst windows:', ...worstWindows); + } + return lines.join('\n'); +} + +function formatPerfUnavailable(resourceSummary: string | undefined, reason: string): string { + return resourceSummary + ? `Performance: ${resourceSummary}` + : `Frame health: unavailable - ${reason}`; +} + +function readUnavailableReason(fps: Record): string { + return typeof fps.reason === 'string' && fps.reason.length > 0 ? fps.reason : 'not available'; +} + +function formatFrameHealthSummary(fps: Record): string | undefined { + const droppedFramePercent = readFiniteNumber(fps.droppedFramePercent); + const droppedFrameCount = readFiniteNumber(fps.droppedFrameCount); + if (droppedFramePercent === undefined || droppedFrameCount === undefined) return undefined; + return [ + `dropped ${formatPercent(droppedFramePercent)}`, + formatDroppedFrameCount(droppedFrameCount, readFiniteNumber(fps.totalFrameCount)), + formatSampleWindow(readFiniteNumber(fps.sampleWindowMs)), + ] + .filter(Boolean) + .join(' '); +} + +function formatDroppedFrameCount(droppedFrameCount: number, totalFrameCount?: number): string { + return totalFrameCount !== undefined + ? `(${Math.round(droppedFrameCount)}/${Math.round(totalFrameCount)} frames)` + : `(${Math.round(droppedFrameCount)} dropped frames)`; +} + +function formatSampleWindow(sampleWindowMs: number | undefined): string { + return sampleWindowMs !== undefined ? `window ${formatDurationMs(sampleWindowMs)}` : ''; +} + +function formatWorstFrameWindows(fps: Record): string[] { + return readRecordArray(fps.worstWindows).flatMap((window) => { + const line = formatWorstFrameWindow(window); + return line ? [line] : []; + }); +} + +function formatWorstFrameWindow(window: Record): string | undefined { + const startOffsetMs = readFiniteNumber(window.startOffsetMs); + const endOffsetMs = readFiniteNumber(window.endOffsetMs); + const count = readFiniteNumber(window.missedDeadlineFrameCount); + if (startOffsetMs === undefined || endOffsetMs === undefined || count === undefined) { + return undefined; + } + const worstFrameMs = readFiniteNumber(window.worstFrameMs); + const worstFrameText = + worstFrameMs === undefined ? '' : `, worst ${formatDurationMs(worstFrameMs)}`; + return `- +${formatDurationMs(startOffsetMs)}-+${formatDurationMs(endOffsetMs)}: ${Math.round(count)} missed-deadline frames${worstFrameText}`; +} + +function buildResourcePerfSummary( + metrics: Record | undefined, +): string | undefined { + const parts = [ + formatCpuPerfSummary(readRecord(metrics?.cpu)), + formatMemoryPerfSummary(readRecord(metrics?.memory)), + ].filter((part): part is string => Boolean(part)); + return parts.length > 0 ? parts.join(', ') : undefined; +} + +function formatCpuPerfSummary(cpu: Record | undefined): string | undefined { + if (cpu?.available !== true) return undefined; + const usagePercent = readFiniteNumber(cpu.usagePercent); + return usagePercent !== undefined ? `CPU ${formatPercent(usagePercent)}` : undefined; +} + +function formatMemoryPerfSummary(memory: Record | undefined): string | undefined { + if (memory?.available !== true) return undefined; + const memoryKb = + readFiniteNumber(memory.residentMemoryKb) ?? + readFiniteNumber(memory.totalPssKb) ?? + readFiniteNumber(memory.totalRssKb); + return memoryKb !== undefined ? `memory ${formatMemoryKb(memoryKb)}` : undefined; +} + +function readRecord(value: unknown): Record | undefined { + return value && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : undefined; +} + +function readRecordArray(value: unknown): Array> { + return Array.isArray(value) + ? value.filter( + (entry): entry is Record => + Boolean(entry) && typeof entry === 'object' && !Array.isArray(entry), + ) + : []; +} + +function readFiniteNumber(value: unknown): number | undefined { + return typeof value === 'number' && Number.isFinite(value) ? value : undefined; +} + +function formatPercent(value: number): string { + return `${Number.isInteger(value) ? value : value.toFixed(1)}%`; +} + +function formatDurationMs(value: number): string { + const roundedMs = Math.max(0, Math.round(value)); + if (roundedMs < 1000) return `${roundedMs}ms`; + const seconds = Math.round(roundedMs / 1000); + if (seconds < 60) return `${seconds}s`; + const minutes = Math.floor(seconds / 60); + const remainingSeconds = seconds % 60; + return remainingSeconds > 0 ? `${minutes}m ${remainingSeconds}s` : `${minutes}m`; +} + +function formatMemoryKb(value: number): string { + const megabytes = value / 1024; + return `${megabytes >= 10 ? Math.round(megabytes) : megabytes.toFixed(1)}MB`; +} diff --git a/src/commands/selectors-definition.ts b/src/commands/selectors-definition.ts deleted file mode 100644 index 3cc081cbf..000000000 --- a/src/commands/selectors-definition.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { PUBLIC_COMMANDS } from '../command-catalog.ts'; -import type { FlagKey } from '../utils/command-schema.ts'; -import { - ALL_DEVICE_COMMAND_CAPABILITY, - commandCapabilityMap, - commandSchemaMap, - defineCommand, -} from './command-definition.ts'; - -export const SELECTOR_SNAPSHOT_FLAGS = [ - 'snapshotDepth', - 'snapshotScope', - 'snapshotRaw', -] as const satisfies readonly FlagKey[]; - -const waitCommandDefinition = defineCommand({ - name: PUBLIC_COMMANDS.wait, - schema: { - usageOverride: 'wait |text |@ref| [timeoutMs]', - helpDescription: 'Wait for duration, text, ref, or selector to appear', - summary: 'Wait for time, text, ref, or selector', - positionalArgs: ['durationOrSelector', 'timeoutMs?'], - allowsExtraPositionals: true, - allowedFlags: [...SELECTOR_SNAPSHOT_FLAGS], - }, - capability: ALL_DEVICE_COMMAND_CAPABILITY, -}); - -const getCommandDefinition = defineCommand({ - name: PUBLIC_COMMANDS.get, - schema: { - usageOverride: 'get text|attrs <@ref|selector>', - helpDescription: - 'Return exposed element text/attributes by ref or selector; use snapshot -s @ref for truncated previews', - summary: 'Get exposed text or attrs by ref or selector', - positionalArgs: ['subcommand', 'target'], - allowedFlags: [...SELECTOR_SNAPSHOT_FLAGS], - }, - capability: ALL_DEVICE_COMMAND_CAPABILITY, -}); - -const findCommandDefinition = defineCommand({ - name: PUBLIC_COMMANDS.find, - schema: { - usageOverride: 'find [value] [--first|--last]', - helpDescription: 'Find by text/label/value/role/id and run action', - summary: 'Find an element and act', - positionalArgs: ['query', 'action', 'value?'], - allowsExtraPositionals: true, - allowedFlags: ['snapshotDepth', 'snapshotRaw', 'findFirst', 'findLast'], - }, - capability: ALL_DEVICE_COMMAND_CAPABILITY, -}); - -const isCommandDefinition = defineCommand({ - name: PUBLIC_COMMANDS.is, - schema: { - helpDescription: 'Assert UI state (visible|hidden|exists|editable|selected|text)', - summary: 'Assert UI state', - positionalArgs: ['predicate', 'selector', 'value?'], - allowsExtraPositionals: true, - allowedFlags: [...SELECTOR_SNAPSHOT_FLAGS], - }, - capability: ALL_DEVICE_COMMAND_CAPABILITY, -}); - -export const SELECTOR_COMMAND_DEFINITIONS = [ - waitCommandDefinition, - getCommandDefinition, - findCommandDefinition, - isCommandDefinition, -] as const; - -export const SELECTOR_COMMAND_SCHEMAS = commandSchemaMap(SELECTOR_COMMAND_DEFINITIONS); -export const SELECTOR_COMMAND_CAPABILITIES = commandCapabilityMap(SELECTOR_COMMAND_DEFINITIONS); diff --git a/src/commands/session-lifecycle/definition.ts b/src/commands/session-lifecycle/definition.ts deleted file mode 100644 index 0bfc0548d..000000000 --- a/src/commands/session-lifecycle/definition.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { PUBLIC_COMMANDS } from '../../command-catalog.ts'; -import type { CommandCapability } from '../../core/capabilities.ts'; -import { DEFAULT_APPS_FILTER } from '../app-inventory-contract.ts'; -import { commandCapabilityMap, commandSchemaMap, defineCommand } from '../command-definition.ts'; - -const APP_RUNTIME_CAPABILITY = { - apple: { simulator: true, device: true }, - android: { emulator: true, device: true, unknown: true }, - linux: { device: true }, -} as const satisfies CommandCapability; - -const APP_INVENTORY_CAPABILITY = { - apple: { simulator: true, device: true }, - android: { emulator: true, device: true, unknown: true }, - linux: {}, -} as const satisfies CommandCapability; - -const APP_INSTALL_CAPABILITY = { - apple: { simulator: true, device: true }, - android: { emulator: true, device: true, unknown: true }, - linux: {}, - supports: (device) => device.platform !== 'macos', -} as const satisfies CommandCapability; - -const openCommandDefinition = defineCommand({ - name: PUBLIC_COMMANDS.open, - schema: { - helpDescription: - 'Boot device/simulator; optionally launch app or deep link URL (macOS also supports --surface app|frontmost-app|desktop|menubar)', - summary: 'Open an app, deep link or URL, save replays', - positionalArgs: ['appOrUrl?', 'url?'], - allowedFlags: ['activity', 'launchConsole', 'saveScript', 'relaunch', 'surface'], - }, - capability: APP_RUNTIME_CAPABILITY, -}); - -const closeCommandDefinition = defineCommand({ - name: PUBLIC_COMMANDS.close, - schema: { - helpDescription: 'Close app or just end session', - summary: 'Close app or end session', - positionalArgs: ['app?'], - allowedFlags: ['saveScript', 'shutdown'], - }, - capability: APP_RUNTIME_CAPABILITY, -}); - -const reinstallCommandDefinition = defineCommand({ - name: PUBLIC_COMMANDS.reinstall, - schema: { - helpDescription: 'Uninstall + install app from binary path', - summary: 'Reinstall app from binary path', - positionalArgs: ['app', 'path'], - allowedFlags: [], - }, - capability: APP_INSTALL_CAPABILITY, -}); - -const installCommandDefinition = defineCommand({ - name: PUBLIC_COMMANDS.install, - schema: { - helpDescription: 'Install app from binary path without uninstalling first', - summary: 'Install app from binary path', - positionalArgs: ['app', 'path'], - allowedFlags: [], - }, - capability: APP_INSTALL_CAPABILITY, -}); - -const installFromSourceCommandDefinition = defineCommand({ - name: PUBLIC_COMMANDS.installFromSource, - schema: { - usageOverride: - 'install-from-source | install-from-source --github-actions-artifact ', - listUsageOverride: 'install-from-source | install-from-source --github-actions-artifact', - helpDescription: 'Install app from a URL or remote-resolved source', - summary: 'Install app from a source', - positionalArgs: ['url?'], - allowedFlags: [ - 'header', - 'githubActionsArtifact', - 'installSource', - 'retainPaths', - 'retentionMs', - ], - }, - capability: APP_INSTALL_CAPABILITY, -}); - -const appsCommandDefinition = defineCommand({ - name: PUBLIC_COMMANDS.apps, - schema: { - helpDescription: 'List user-installed apps; use --all to include system/OEM apps', - summary: 'List installed apps', - positionalArgs: [], - allowedFlags: ['appsFilter'], - defaults: { appsFilter: DEFAULT_APPS_FILTER }, - }, - capability: APP_INVENTORY_CAPABILITY, -}); - -export const SESSION_LIFECYCLE_COMMAND_DEFINITIONS = [ - openCommandDefinition, - closeCommandDefinition, - reinstallCommandDefinition, - installCommandDefinition, - installFromSourceCommandDefinition, - appsCommandDefinition, -] as const; - -export const SESSION_LIFECYCLE_COMMAND_SCHEMAS = commandSchemaMap( - SESSION_LIFECYCLE_COMMAND_DEFINITIONS, -); - -export const SESSION_LIFECYCLE_COMMAND_CAPABILITIES = commandCapabilityMap( - SESSION_LIFECYCLE_COMMAND_DEFINITIONS, -); diff --git a/src/core/__tests__/batch.test.ts b/src/core/__tests__/batch.test.ts index 0b33d7c3d..afa818540 100644 --- a/src/core/__tests__/batch.test.ts +++ b/src/core/__tests__/batch.test.ts @@ -1,6 +1,6 @@ import { test } from 'vitest'; import assert from 'node:assert/strict'; -import { validateAndNormalizeBatchSteps, type BatchStep } from '../batch.ts'; +import { validateAndNormalizeBatchSteps, type DaemonBatchStep } from '../batch.ts'; test('validateAndNormalizeBatchSteps rejects unknown top-level step fields', () => { assert.throws( @@ -11,7 +11,7 @@ test('validateAndNormalizeBatchSteps rejects unknown top-level step fields', () command: 'open', positionals: ['Settings'], args: ['unexpected'], - } as unknown as BatchStep, + } as unknown as DaemonBatchStep, ], 10, ), diff --git a/src/core/batch.ts b/src/core/batch.ts index 23c742e1b..07bbe88ea 100644 --- a/src/core/batch.ts +++ b/src/core/batch.ts @@ -14,7 +14,7 @@ export const INHERITED_PARENT_FLAG_KEYS = [ 'out', ] as const; -export type BatchStep = { +export type DaemonBatchStep = { command: string; positionals?: string[]; flags?: Record; @@ -24,7 +24,7 @@ export type BatchStep = { export type BatchFlags = Record & { batchOnError?: 'stop'; batchMaxSteps?: number; - batchSteps?: BatchStep[]; + batchSteps?: DaemonBatchStep[]; }; export type BatchRequest = Omit & { @@ -110,19 +110,6 @@ export async function runBatch( } } -export function parseBatchStepsJson(raw: string): BatchStep[] { - let parsed: unknown; - try { - parsed = JSON.parse(raw); - } catch { - throw new AppError('INVALID_ARGS', 'Batch steps must be valid JSON.'); - } - if (!Array.isArray(parsed) || parsed.length === 0) { - throw new AppError('INVALID_ARGS', 'Batch steps must be a non-empty JSON array.'); - } - return parsed as BatchStep[]; -} - export function validateAndNormalizeBatchSteps( steps: unknown, maxSteps: number, @@ -139,7 +126,7 @@ export function validateAndNormalizeBatchSteps( const normalized: NormalizedBatchStep[] = []; for (let index = 0; index < steps.length; index += 1) { - const step = steps[index] as Partial; + const step = steps[index] as Partial; if (!step || typeof step !== 'object') { throw new AppError('INVALID_ARGS', `Invalid batch step at index ${index}.`); } @@ -192,7 +179,7 @@ export function validateAndNormalizeBatchSteps( export function buildBatchStepFlags( parentFlags: BatchFlags | Record | undefined, - stepFlags: BatchStep['flags'] | Record | undefined, + stepFlags: DaemonBatchStep['flags'] | Record | undefined, ): BatchFlags { const { batchSteps: _batchSteps, diff --git a/src/core/capabilities.ts b/src/core/capabilities.ts index 8ff7bfeb7..29c57d4b1 100644 --- a/src/core/capabilities.ts +++ b/src/core/capabilities.ts @@ -1,9 +1,4 @@ import { isApplePlatform, type DeviceInfo } from '../utils/device.ts'; -import { CAPTURE_COMMAND_CAPABILITIES } from '../commands/capture-definition.ts'; -import { INTERACTION_COMMAND_CAPABILITIES } from '../commands/interactions/definition.ts'; -import { REACT_NATIVE_COMMAND_CAPABILITIES } from '../commands/react-native/definition.ts'; -import { SELECTOR_COMMAND_CAPABILITIES } from '../commands/selectors-definition.ts'; -import { SESSION_LIFECYCLE_COMMAND_CAPABILITIES } from '../commands/session-lifecycle/definition.ts'; type KindMatrix = { simulator?: boolean; @@ -31,6 +26,23 @@ const isIosMobileSimulator = (device: DeviceInfo): boolean => // Linux device kind is always 'device' (local desktop). const LINUX_DEVICE: KindMatrix = { device: true }; const LINUX_NONE: KindMatrix = {}; +const ALL_DEVICE_COMMAND_CAPABILITY = { + apple: { simulator: true, device: true }, + android: { emulator: true, device: true, unknown: true }, + linux: LINUX_DEVICE, +} as const satisfies CommandCapability; +const APP_RUNTIME_CAPABILITY = ALL_DEVICE_COMMAND_CAPABILITY; +const APP_INVENTORY_CAPABILITY = { + apple: { simulator: true, device: true }, + android: { emulator: true, device: true, unknown: true }, + linux: LINUX_NONE, +} as const satisfies CommandCapability; +const APP_INSTALL_CAPABILITY = { + apple: { simulator: true, device: true }, + android: { emulator: true, device: true, unknown: true }, + linux: LINUX_NONE, + supports: isNotMacOs, +} as const satisfies CommandCapability; const COMMAND_CAPABILITY_MATRIX: Record = { // Apple simulator-only. @@ -68,7 +80,12 @@ const COMMAND_CAPABILITY_MATRIX: Record = { linux: LINUX_NONE, supports: isNotMacOs, }, - ...SESSION_LIFECYCLE_COMMAND_CAPABILITIES, + open: APP_RUNTIME_CAPABILITY, + close: APP_RUNTIME_CAPABILITY, + reinstall: APP_INSTALL_CAPABILITY, + install: APP_INSTALL_CAPABILITY, + 'install-from-source': APP_INSTALL_CAPABILITY, + apps: APP_INVENTORY_CAPABILITY, back: { apple: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true }, @@ -113,8 +130,13 @@ const COMMAND_CAPABILITY_MATRIX: Record = { android: { emulator: true, device: true, unknown: true }, linux: LINUX_NONE, }, - ...CAPTURE_COMMAND_CAPABILITIES, - ...SELECTOR_COMMAND_CAPABILITIES, + snapshot: ALL_DEVICE_COMMAND_CAPABILITY, + diff: ALL_DEVICE_COMMAND_CAPABILITY, + screenshot: ALL_DEVICE_COMMAND_CAPABILITY, + wait: ALL_DEVICE_COMMAND_CAPABILITY, + get: ALL_DEVICE_COMMAND_CAPABILITY, + find: ALL_DEVICE_COMMAND_CAPABILITY, + is: ALL_DEVICE_COMMAND_CAPABILITY, focus: { apple: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true }, @@ -167,7 +189,11 @@ const COMMAND_CAPABILITY_MATRIX: Record = { android: { emulator: true, device: true, unknown: true }, linux: LINUX_NONE, }, - ...REACT_NATIVE_COMMAND_CAPABILITIES, + 'react-native': { + apple: { simulator: true, device: true }, + android: { emulator: true, device: true, unknown: true }, + linux: LINUX_NONE, + }, rotate: { apple: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true }, @@ -197,7 +223,7 @@ const COMMAND_CAPABILITY_MATRIX: Record = { android: { emulator: true, device: true, unknown: true }, linux: LINUX_NONE, }, - ...INTERACTION_COMMAND_CAPABILITIES, + type: ALL_DEVICE_COMMAND_CAPABILITY, }; export function isCommandSupportedOnDevice(command: string, device: DeviceInfo): boolean { @@ -214,10 +240,6 @@ export function isCommandSupportedOnDevice(command: string, device: DeviceInfo): return byPlatform[kind] === true; } -export function getCommandCapability(command: string): CommandCapability | undefined { - return COMMAND_CAPABILITY_MATRIX[command]; -} - export function listCapabilityCommands(): string[] { return Object.keys(COMMAND_CAPABILITY_MATRIX).sort(); } diff --git a/src/core/dispatch-context.ts b/src/core/dispatch-context.ts index 27b5b2f59..1278e0368 100644 --- a/src/core/dispatch-context.ts +++ b/src/core/dispatch-context.ts @@ -1,15 +1,9 @@ import type { CliFlags, DaemonExcludedCliFlag } from '../utils/command-schema.ts'; import type { ScreenshotDispatchFlags } from '../commands/capture-screenshot-options.ts'; +import type { DaemonBatchStep } from './batch.ts'; import type { ClickButton } from './click-button.ts'; import type { SessionSurface } from './session-surface.ts'; -export type BatchStep = { - command: string; - positionals?: string[]; - flags?: Partial; - runtime?: unknown; -}; - export type MaestroRuntimeFlags = { allowNonHittableCoordinateFallback?: boolean; optional?: boolean; @@ -17,7 +11,7 @@ export type MaestroRuntimeFlags = { }; export type CommandFlags = Omit & { - batchSteps?: BatchStep[]; + batchSteps?: DaemonBatchStep[]; clearAppState?: boolean; launchArgs?: string[]; maestro?: MaestroRuntimeFlags; diff --git a/src/core/dispatch.ts b/src/core/dispatch.ts index 4367157ab..d7340e3f3 100644 --- a/src/core/dispatch.ts +++ b/src/core/dispatch.ts @@ -43,7 +43,7 @@ import { readNotificationPayload } from './dispatch-payload.ts'; import { parseDeviceRotation } from './device-rotation.ts'; export { resolveTargetDevice } from './dispatch-resolve.ts'; -export type { BatchStep, CommandFlags, DispatchContext } from './dispatch-context.ts'; +export type { CommandFlags, DispatchContext } from './dispatch-context.ts'; export async function dispatchCommand( device: DeviceInfo, diff --git a/src/daemon/handlers/__tests__/interaction-touch-targets.test.ts b/src/daemon/handlers/__tests__/interaction-touch-targets.test.ts index fc29098f4..804d1ed33 100644 --- a/src/daemon/handlers/__tests__/interaction-touch-targets.test.ts +++ b/src/daemon/handlers/__tests__/interaction-touch-targets.test.ts @@ -1,7 +1,7 @@ import { test, expect } from 'vitest'; import { parseFillTarget, parseTouchTarget } from '../interaction-touch-targets.ts'; -test('parseTouchTarget preserves ref fallback label through shared target codec', () => { +test('parseTouchTarget preserves ref fallback label through shared grammar', () => { const parsed = parseTouchTarget(['@e4', 'Email field'], 'press'); expect(parsed).toEqual({ @@ -39,7 +39,7 @@ test('parseTouchTarget keeps invalid coordinates as selector text', () => { }); }); -test('parseFillTarget reads selector text through shared fill codec', () => { +test('parseFillTarget reads selector text through shared grammar', () => { const parsed = parseFillTarget(['label="Email"', 'qa@example.com']); expect(parsed).toEqual({ diff --git a/src/daemon/handlers/__tests__/snapshot.test.ts b/src/daemon/handlers/__tests__/snapshot.test.ts index 2ea91836a..c741e1651 100644 --- a/src/daemon/handlers/__tests__/snapshot.test.ts +++ b/src/daemon/handlers/__tests__/snapshot.test.ts @@ -1,6 +1,27 @@ import { test } from 'vitest'; import assert from 'node:assert/strict'; -import { parseWaitPositionals as parseWaitArgs } from '../../../command-codecs/wait.ts'; +import { parseWaitPositionals as parseWaitArgs } from '../../../commands/cli-grammar/capture.ts'; +import { parseTimeout } from '../parse-utils.ts'; + +// --- parseTimeout --- + +test('parseTimeout parses integer string', () => { + assert.equal(parseTimeout('500'), 500); +}); + +test('parseTimeout parses zero', () => { + assert.equal(parseTimeout('0'), 0); +}); + +test('parseTimeout returns null for non-numeric string', () => { + assert.equal(parseTimeout('abc'), null); +}); + +test('parseTimeout returns null for Infinity', () => { + assert.equal(parseTimeout('Infinity'), null); +}); + +// --- parseWaitArgs --- test('parseWaitArgs returns null for empty args', () => { assert.equal(parseWaitArgs([]), null); @@ -21,6 +42,16 @@ test('parseWaitArgs parses text keyword with label', () => { assert.deepEqual(result, { kind: 'text', text: 'Loading', timeoutMs: null }); }); +test('parseWaitArgs parses text keyword with timeout', () => { + const result = parseWaitArgs(['text', 'Loading', '5000']); + assert.deepEqual(result, { kind: 'text', text: 'Loading', timeoutMs: 5000 }); +}); + +test('parseWaitArgs parses text keyword with multi-word and timeout', () => { + const result = parseWaitArgs(['text', 'Sign', 'In', '3000']); + assert.deepEqual(result, { kind: 'text', text: 'Sign In', timeoutMs: 3000 }); +}); + test('parseWaitArgs parses text keyword with multi-word and no timeout', () => { const result = parseWaitArgs(['text', 'Sign', 'In']); assert.deepEqual(result, { kind: 'text', text: 'Sign In', timeoutMs: null }); diff --git a/src/daemon/handlers/interaction-touch-targets.ts b/src/daemon/handlers/interaction-touch-targets.ts index b22beb409..49888fc0a 100644 --- a/src/daemon/handlers/interaction-touch-targets.ts +++ b/src/daemon/handlers/interaction-touch-targets.ts @@ -4,7 +4,10 @@ import type { LongPressCommandResult, PressCommandResult, } from '../../commands/index.ts'; -import { fillCommandCodec, interactionTargetCodec } from '../../command-codecs.ts'; +import { + readFillTargetFromPositionals, + readInteractionTargetFromPositionals, +} from '../../commands/cli-grammar/interactions.ts'; import type { DaemonResponse } from '../types.ts'; import { parseCoordinateTarget } from './interaction-targeting.ts'; import { errorResponse } from './response.ts'; @@ -20,7 +23,7 @@ export function parseTouchTarget(positionals: string[], commandLabel: string): P } const first = positionals[0] ?? ''; if (first.startsWith('@')) { - const parsed = interactionTargetCodec.decode(positionals); + const parsed = readInteractionTargetFromPositionals(positionals); return { ok: true, target: { @@ -74,7 +77,7 @@ export type ParsedFillTarget = export function parseFillTarget(positionals: string[]): ParsedFillTarget { const first = positionals[0] ?? ''; if (first.startsWith('@')) { - const parsed = fillCommandCodec.decode(positionals); + const parsed = readFillTargetFromPositionals(positionals); const text = parsed.text; if (!text) return { ok: false, response: errorResponse('INVALID_ARGS', 'fill requires text after ref') }; @@ -100,7 +103,7 @@ export function parseFillTarget(positionals: string[]): ParsedFillTarget { return { ok: true, target: { kind: 'point', x: coordinates.x, y: coordinates.y }, text }; } - const parsed = fillCommandCodec.decode(positionals); + const parsed = readFillTargetFromPositionals(positionals); if (parsed.kind !== 'selector') { return { ok: false, diff --git a/src/daemon/handlers/interaction.ts b/src/daemon/handlers/interaction.ts index dba9101d1..2d5590a20 100644 --- a/src/daemon/handlers/interaction.ts +++ b/src/daemon/handlers/interaction.ts @@ -7,8 +7,8 @@ import { dispatchGetViaRuntime, dispatchIsViaRuntime } from '../selector-runtime import { createInteractionRuntime } from './interaction-runtime.ts'; import { finalizeTouchInteraction } from './interaction-common.ts'; import { errorResponse } from './response.ts'; +import { PUBLIC_COMMANDS } from '../../command-catalog.ts'; import { isCommandSupportedOnDevice } from '../../core/capabilities.ts'; -import { typeCommandDefinition } from '../../commands/interactions/definition.ts'; import { normalizeError } from '../../utils/errors.ts'; import { successText } from '../../utils/success-text.ts'; import { @@ -29,7 +29,7 @@ export async function handleInteractionCommands( } switch (params.req.command) { - case typeCommandDefinition.name: + case PUBLIC_COMMANDS.type: return await dispatchTypeViaRuntime({ ...params, captureSnapshotForSession, @@ -51,7 +51,7 @@ async function dispatchTypeViaRuntime( const { sessionName, sessionStore } = params; const session = sessionStore.get(sessionName); if (!session) return errorResponse('SESSION_NOT_FOUND', 'No active session. Run open first.'); - if (!isCommandSupportedOnDevice(typeCommandDefinition.name, session.device)) { + if (!isCommandSupportedOnDevice(PUBLIC_COMMANDS.type, session.device)) { return errorResponse('UNSUPPORTED_OPERATION', 'type is not supported on this device'); } const recordingRecoveryResponse = await recoverAndroidRecordingDialogForType(session); diff --git a/src/daemon/selector-runtime.ts b/src/daemon/selector-runtime.ts index d46337df2..39b9d133e 100644 --- a/src/daemon/selector-runtime.ts +++ b/src/daemon/selector-runtime.ts @@ -4,7 +4,8 @@ import type { BackendSnapshotResult, } from '../backend.ts'; import { createAgentDevice } from '../runtime.ts'; -import { parseWaitPositionals, type WaitParsed } from '../command-codecs/wait.ts'; +import { parseWaitPositionals } from '../commands/cli-grammar/capture.ts'; +import type { WaitParsed } from '../commands/cli-grammar/types.ts'; import { isCommandSupportedOnDevice } from '../core/capabilities.ts'; import { resolveTargetDevice, type CommandFlags } from '../core/dispatch.ts'; import { isApplePlatform } from '../utils/device.ts'; diff --git a/src/mcp/__tests__/command-tools.test.ts b/src/mcp/__tests__/command-tools.test.ts new file mode 100644 index 000000000..2156ea5c5 --- /dev/null +++ b/src/mcp/__tests__/command-tools.test.ts @@ -0,0 +1,40 @@ +import assert from 'node:assert/strict'; +import { test } from 'vitest'; +import type { AgentDeviceClient } from '../../client-types.ts'; +import { createCommandToolExecutor, listCommandTools } from '../command-tools.ts'; + +test('MCP command tool executor hides client creation behind an execution adapter', async () => { + const client = {} as AgentDeviceClient; + const createdConfigs: unknown[] = []; + const calls: unknown[] = []; + const executor = createCommandToolExecutor({ + createClient: (config) => { + createdConfigs.push(config); + return client; + }, + runCommand: async (actualClient, name, input) => { + calls.push({ client: actualClient, name, input }); + return { name, ok: true }; + }, + }); + + const result = await executor.execute('devices', { stateDir: '/tmp/agent-device-mcp' }); + + assert.deepEqual(createdConfigs, [{ stateDir: '/tmp/agent-device-mcp' }]); + assert.deepEqual(calls, [ + { + client, + name: 'devices', + input: {}, + }, + ]); + assert.deepEqual(result.structuredContent, { name: 'devices', ok: true }); + assert.match(result.content[0]?.text ?? '', /"name": "devices"/); +}); + +test('MCP tool schemas add MCP client config fields at the MCP boundary', () => { + const devicesTool = listCommandTools().find((tool) => tool.name === 'devices'); + + assert.ok(devicesTool); + assert.ok('stateDir' in (devicesTool.inputSchema.properties ?? {})); +}); diff --git a/src/mcp/__tests__/router.test.ts b/src/mcp/__tests__/router.test.ts index 2fa15bce0..777817291 100644 --- a/src/mcp/__tests__/router.test.ts +++ b/src/mcp/__tests__/router.test.ts @@ -1,103 +1,96 @@ import assert from 'node:assert/strict'; +import { setImmediate } from 'node:timers/promises'; import { test } from 'vitest'; +import { listMcpExposedCommandNames } from '../../command-catalog.ts'; import { handleMcpMessage } from '../router.ts'; +import { createMcpPayloadQueue, handleMcpPayload } from '../server.ts'; -test('MCP initialize advertises discovery-only tool capability', () => { - const response = handleMcpMessage({ +test('MCP exposes every automatable CLI command as a structured direct tool', async () => { + const response = await handleMcpMessage({ jsonrpc: '2.0', id: 1, - method: 'initialize', - params: { - protocolVersion: '2099-01-01', - }, + method: 'tools/list', }); assert.ok(response && 'result' in response); - const result = response.result as { - protocolVersion: string; - capabilities: Record; - }; - assert.equal(result.protocolVersion, '2025-11-25'); - assert.deepEqual(result.capabilities, { tools: {} }); -}); + const tools = (response.result as { tools: Array<{ name: string }> }).tools.map( + (tool) => tool.name, + ); + const expectedToolNames = listMcpExposedCommandNames().sort(); + + assert.deepEqual(tools.sort(), expectedToolNames); + + const fillTool = (response.result as { tools: Array> }).tools.find( + (tool) => tool.name === 'fill', + ); + assert.ok(fillTool); + const fillProperties = (fillTool.inputSchema as { properties: Record }) + .properties; + assert.ok(!('positionals' in fillProperties)); + assert.ok('target' in fillProperties); + + const batchTool = (response.result as { tools: Array> }).tools.find( + (tool) => tool.name === 'batch', + ); + assert.ok(batchTool); + assert.ok(!JSON.stringify(batchTool.inputSchema).includes('"positionals"')); + assert.ok(!JSON.stringify(batchTool.inputSchema).includes('"flags"')); -test('MCP tools/list exposes only status', () => { - const response = handleMcpMessage({ + const invalidFillResponse = await handleMcpMessage({ jsonrpc: '2.0', id: 2, - method: 'tools/list', + method: 'tools/call', + params: { name: 'fill', arguments: {} }, }); + assert.ok(invalidFillResponse && 'result' in invalidFillResponse); + assert.equal((invalidFillResponse.result as { isError: boolean }).isError, true); + assert.match(JSON.stringify(invalidFillResponse.result), /Expected target to be set/); +}); + +test('MCP JSON-RPC batches return responses in request order and skip notifications', async () => { + const response = await handleMcpPayload([ + { jsonrpc: '2.0', id: 'first', method: 'ping' }, + { jsonrpc: '2.0', method: 'notifications/initialized' }, + { jsonrpc: '2.0', id: 'second', method: 'ping' }, + ]); - assert.ok(response && 'result' in response); - const tools = ( - response.result as { tools: Array<{ name: string; outputSchema?: { type: string } }> } - ).tools; assert.deepEqual( - tools.map((tool) => tool.name), - ['status'], + (response as Array<{ id: string }>).map((entry) => entry.id), + ['first', 'second'], ); - assert.equal(tools[0]?.outputSchema?.type, 'object'); }); -test('MCP status tool returns structured CLI handoff guidance', () => { - const response = handleMcpMessage({ - jsonrpc: '2.0', - id: 3, - method: 'tools/call', - params: { - name: 'status', +test('MCP stdio payload queue serializes separate messages', async () => { + const started: JsonRpcId[] = []; + const writes: unknown[] = []; + const completions = new Map void>(); + const queue = createMcpPayloadQueue({ + handlePayload: async (message) => { + const id = Array.isArray(message) ? null : (message.id ?? null); + started.push(id); + return await new Promise((resolve) => completions.set(id, resolve)); + }, + write: (message) => { + writes.push(message); }, }); - assert.ok(response && 'result' in response); - const result = response.result as { - content: Array<{ text: string }>; - isError: boolean; - structuredContent: { - packageName: string; - cliCommandName: string; - installCommand: string; - verifyCommand: string; - startingHelpCommand: string; - supportedTargets: string[]; - capabilities: string[]; - prerequisites: string[]; - docsUrl: string; - agentDocsUrl: string; - firstCommands: string[]; - automationInterface: string; - automationNote: string; - installRequiresHumanApproval: boolean; - installSafetyNote: string; - }; - }; - assert.equal(result.isError, false); + queue.push({ jsonrpc: '2.0', id: 'first', method: 'tools/call' }); + queue.push({ jsonrpc: '2.0', id: 'second', method: 'tools/call' }); + await Promise.resolve(); - const handoff = result.structuredContent; - assert.deepEqual(JSON.parse(result.content[0]?.text ?? ''), handoff); - assert.equal(handoff.packageName, 'agent-device'); - assert.equal(handoff.cliCommandName, 'agent-device'); - assert.equal(handoff.installCommand, 'npm install -g agent-device@latest'); - assert.equal(handoff.verifyCommand, 'agent-device --version'); - assert.equal(handoff.startingHelpCommand, 'agent-device help workflow'); - assert.ok(handoff.supportedTargets.includes('ios-simulator')); - assert.ok(handoff.supportedTargets.includes('android-emulator')); - assert.ok(handoff.capabilities.includes('inspect-ui')); - assert.ok(handoff.capabilities.includes('interact-with-elements')); - assert.ok(handoff.capabilities.includes('accessibility-snapshot')); - assert.ok(handoff.capabilities.includes('react-native')); - assert.ok(handoff.capabilities.includes('expo')); - assert.ok(handoff.capabilities.includes('android-adb')); - assert.ok(handoff.capabilities.includes('ios-xcuitest')); - assert.ok(handoff.prerequisites.includes('node>=22')); - assert.ok(handoff.prerequisites.includes('xcode-for-ios')); - assert.ok(handoff.prerequisites.includes('android-sdk-adb-for-android')); - assert.equal(handoff.docsUrl, 'https://agent-device.dev/'); - assert.equal(handoff.agentDocsUrl, 'https://incubator.callstack.com/agent-device/llms-full.txt'); - assert.ok(handoff.firstCommands.includes('agent-device apps --platform ios')); - assert.ok(handoff.firstCommands.includes('agent-device apps --platform android')); - assert.equal(handoff.automationInterface, 'cli'); - assert.match(handoff.automationNote, /discovery-only/); - assert.equal(handoff.installRequiresHumanApproval, true); - assert.match(handoff.installSafetyNote, /human has approved/); + assert.deepEqual(started, ['first']); + completions.get('first')?.({ jsonrpc: '2.0', id: 'first', result: {} }); + await setImmediate(); + + assert.deepEqual(started, ['first', 'second']); + completions.get('second')?.({ jsonrpc: '2.0', id: 'second', result: {} }); + await queue.idle(); + + assert.deepEqual( + writes.map((message) => (message as { id: JsonRpcId }).id), + ['first', 'second'], + ); }); + +type JsonRpcId = string | number | null; diff --git a/src/mcp/catalog.ts b/src/mcp/catalog.ts deleted file mode 100644 index 36175b74d..000000000 --- a/src/mcp/catalog.ts +++ /dev/null @@ -1,144 +0,0 @@ -import { readVersion } from '../utils/version.ts'; - -export const MCP_SERVER_NAME = 'agent-device'; - -type StatusHandoff = { - packageName: string; - installedPackageVersion: string; - cliCommandName: string; - installCommand: string; - verifyCommand: string; - startingHelpCommand: string; - supportedTargets: string[]; - capabilities: string[]; - prerequisites: string[]; - docsUrl: string; - agentDocsUrl: string; - firstCommands: string[]; - automationInterface: 'cli'; - automationNote: string; - installRequiresHumanApproval: true; - installSafetyNote: string; -}; - -export function createStatusHandoff(): StatusHandoff { - return { - packageName: 'agent-device', - installedPackageVersion: readVersion(), - cliCommandName: 'agent-device', - installCommand: 'npm install -g agent-device@latest', - verifyCommand: 'agent-device --version', - startingHelpCommand: 'agent-device help workflow', - supportedTargets: [ - 'ios-simulator', - 'android-emulator', - 'ios-device', - 'android-device', - 'tvos-simulator', - 'macos', - 'linux', - ], - capabilities: [ - 'inspect-ui', - 'interact-with-elements', - 'open-apps', - 'install-app', - 'capture-screenshot', - 'accessibility-snapshot', - 'collect-logs', - 'collect-network', - 'collect-performance', - 'record-replay', - 'react-native', - 'expo', - 'android-adb', - 'ios-xcuitest', - ], - prerequisites: [ - 'node>=22', - 'xcode-for-ios', - 'android-sdk-adb-for-android', - 'macos-accessibility-permission-for-desktop', - ], - docsUrl: 'https://agent-device.dev/', - agentDocsUrl: 'https://incubator.callstack.com/agent-device/llms-full.txt', - firstCommands: [ - 'agent-device help workflow', - 'agent-device apps --platform ios', - 'agent-device apps --platform android', - ], - automationInterface: 'cli', - automationNote: - 'Device automation happens through the agent-device CLI. This MCP server is discovery-only and does not expose device automation tools.', - installRequiresHumanApproval: true, - installSafetyNote: - 'Agents should not install or update the package unless the human has approved the environment change. If the CLI is missing, ask the human to run the install command, then run the verify command.', - }; -} - -export function listTools(): unknown[] { - return [ - { - name: 'status', - description: - 'Return discovery-only handoff metadata for installing, verifying, and using the agent-device CLI.', - inputSchema: { - type: 'object', - properties: {}, - additionalProperties: false, - }, - outputSchema: { - type: 'object', - properties: { - packageName: { type: 'string' }, - installedPackageVersion: { type: 'string' }, - cliCommandName: { type: 'string' }, - installCommand: { type: 'string' }, - verifyCommand: { type: 'string' }, - startingHelpCommand: { type: 'string' }, - supportedTargets: { - type: 'array', - items: { type: 'string' }, - }, - capabilities: { - type: 'array', - items: { type: 'string' }, - }, - prerequisites: { - type: 'array', - items: { type: 'string' }, - }, - docsUrl: { type: 'string' }, - agentDocsUrl: { type: 'string' }, - firstCommands: { - type: 'array', - items: { type: 'string' }, - }, - automationInterface: { type: 'string', const: 'cli' }, - automationNote: { type: 'string' }, - installRequiresHumanApproval: { type: 'boolean', const: true }, - installSafetyNote: { type: 'string' }, - }, - required: [ - 'packageName', - 'installedPackageVersion', - 'cliCommandName', - 'installCommand', - 'verifyCommand', - 'startingHelpCommand', - 'supportedTargets', - 'capabilities', - 'prerequisites', - 'docsUrl', - 'agentDocsUrl', - 'firstCommands', - 'automationInterface', - 'automationNote', - 'installRequiresHumanApproval', - 'installSafetyNote', - ], - additionalProperties: false, - }, - }, - ]; -} diff --git a/src/mcp/command-tools.ts b/src/mcp/command-tools.ts new file mode 100644 index 000000000..bccda730b --- /dev/null +++ b/src/mcp/command-tools.ts @@ -0,0 +1,90 @@ +import { createAgentDeviceClient } from '../client.ts'; +import type { AgentDeviceClient, AgentDeviceClientConfig } from '../client-types.ts'; +import { + isCommandName, + listMcpToolDefinitions, + runCommand, + type CommandName, +} from '../commands/command-surface.ts'; +import type { JsonSchema } from '../commands/command-contract.ts'; + +type ToolResult = { + isError: boolean; + structuredContent?: unknown; + content: Array<{ type: 'text'; text: string }>; +}; + +type CommandToolExecutorDeps = { + createClient: (config: AgentDeviceClientConfig) => AgentDeviceClient; + runCommand: (client: AgentDeviceClient, name: CommandName, input: unknown) => Promise; +}; + +type CommandToolExecutor = { + execute: (name: string, input: unknown) => Promise; +}; + +export function listCommandTools(): Array<{ + name: string; + description: string; + inputSchema: JsonSchema; +}> { + return listMcpToolDefinitions().map((definition) => ({ + name: definition.name, + description: definition.description, + inputSchema: withMcpConfigSchema(definition.inputSchema), + })); +} + +export function createCommandToolExecutor( + deps: CommandToolExecutorDeps = { + createClient: createAgentDeviceClient, + runCommand: runCommand, + }, +): CommandToolExecutor { + return { + execute: async (name, input) => { + if (!isCommandName(name)) { + throw new Error(`Unknown command tool: ${name}`); + } + const client = deps.createClient(readClientConfig(input)); + const result = await deps.runCommand(client, name, stripClientConfigFields(input)); + return { + isError: false, + structuredContent: result, + content: [{ type: 'text', text: renderToolText(result) }], + }; + }, + }; +} + +export const commandToolExecutor = createCommandToolExecutor(); + +function readClientConfig(input: unknown): AgentDeviceClientConfig { + if (!input || typeof input !== 'object' || Array.isArray(input)) return {}; + const stateDir = (input as Record).stateDir; + if (stateDir === undefined) return {}; + if (typeof stateDir !== 'string' || stateDir.length === 0) { + throw new Error('Expected stateDir to be a non-empty string.'); + } + return { stateDir }; +} + +function stripClientConfigFields(input: unknown): unknown { + if (!input || typeof input !== 'object' || Array.isArray(input)) return input; + const { stateDir: _stateDir, ...commandInput } = input as Record; + return commandInput; +} + +function withMcpConfigSchema(schema: JsonSchema): JsonSchema { + return { + ...schema, + properties: { + ...schema.properties, + stateDir: { type: 'string', description: 'Agent-device state directory.' }, + }, + }; +} + +function renderToolText(value: unknown): string { + return typeof value === 'string' ? value : JSON.stringify(value, null, 2); +} diff --git a/src/mcp/router.ts b/src/mcp/router.ts index 7ff2e6b3b..659b342dd 100644 --- a/src/mcp/router.ts +++ b/src/mcp/router.ts @@ -1,7 +1,8 @@ -import { createStatusHandoff, listTools, MCP_SERVER_NAME } from './catalog.ts'; +import { listCommandTools, commandToolExecutor } from './command-tools.ts'; import { readVersion } from '../utils/version.ts'; type JsonRpcId = string | number | null; +const MCP_SERVER_NAME = 'agent-device'; const SUPPORTED_PROTOCOL_VERSION = '2025-11-25'; export type JsonRpcMessage = { @@ -15,7 +16,7 @@ type JsonRpcResponse = | { jsonrpc: '2.0'; id: JsonRpcId; result: unknown } | { jsonrpc: '2.0'; id: JsonRpcId; error: { code: number; message: string } }; -export function handleMcpMessage(message: JsonRpcMessage): JsonRpcResponse | null { +export async function handleMcpMessage(message: JsonRpcMessage): Promise { if (message.jsonrpc !== '2.0' || typeof message.method !== 'string') { return errorResponse(message.id ?? null, -32600, 'Invalid JSON-RPC request.'); } @@ -23,7 +24,7 @@ export function handleMcpMessage(message: JsonRpcMessage): JsonRpcResponse | nul if (message.id === undefined) return null; try { - return successResponse(message.id, handleRequest(message.method, message.params)); + return successResponse(message.id, await handleRequest(message.method, message.params)); } catch (error) { if (error instanceof JsonRpcMethodNotFoundError) { return errorResponse(message.id, -32601, error.message); @@ -36,7 +37,7 @@ export function handleMcpMessage(message: JsonRpcMessage): JsonRpcResponse | nul } } -function handleRequest(method: string, params: unknown): unknown { +async function handleRequest(method: string, params: unknown): Promise { switch (method) { case 'initialize': return { @@ -52,20 +53,19 @@ function handleRequest(method: string, params: unknown): unknown { case 'ping': return {}; case 'tools/list': - return { tools: listTools() }; + return { tools: listCommandTools() }; case 'tools/call': - return callTool(params); + return await callTool(params); default: throw new JsonRpcMethodNotFoundError(`Unsupported MCP method: ${method}`); } } -function callTool(params: unknown): unknown { +async function callTool(params: unknown): Promise { const record = asRecord(params); const name = stringField(record, 'name'); try { - if (name === 'status') return statusToolResult(); - throw new Error(`Unknown tool: ${name}`); + return await commandToolExecutor.execute(name, record.arguments); } catch (error) { return textToolResult(error instanceof Error ? error.message : String(error), true); } @@ -82,15 +82,6 @@ function textToolResult(text: string, isError = false): unknown { }; } -function statusToolResult(): unknown { - const handoff = createStatusHandoff(); - return { - isError: false, - structuredContent: handoff, - content: [{ type: 'text', text: JSON.stringify(handoff, null, 2) }], - }; -} - function successResponse(id: JsonRpcId, result: unknown): JsonRpcResponse { return { jsonrpc: '2.0', id, result }; } diff --git a/src/mcp/server.ts b/src/mcp/server.ts index e18fc59d2..2f9c9d3ed 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -1,12 +1,17 @@ import { handleMcpMessage, type JsonRpcMessage } from './router.ts'; -type JsonRpcResponse = NonNullable>; +type JsonRpcResponse = Awaited>>; +type JsonRpcId = string | number | null; type MessageSink = (message: JsonRpcMessage | JsonRpcMessage[]) => void; +type PayloadHandler = ( + messageOrBatch: JsonRpcMessage | JsonRpcMessage[], +) => Promise; +type MessageWriter = (message: unknown) => void; export async function runAgentDeviceMcpServer(): Promise { + const payloadQueue = createMcpPayloadQueue(); const decoder = new McpMessageDecoder((messageOrBatch) => { - const response = handleMcpPayload(messageOrBatch); - if (response) writeMessage(response); + payloadQueue.push(messageOrBatch); }); process.stdin.setEncoding('utf8'); @@ -30,16 +35,70 @@ export async function runAgentDeviceMcpServer(): Promise { process.stdin.on('close', resolve); process.stdin.resume(); }); + await payloadQueue.idle(); } -function handleMcpPayload(messageOrBatch: JsonRpcMessage | JsonRpcMessage[]): unknown | null { +export function createMcpPayloadQueue( + options: { + handlePayload?: PayloadHandler; + write?: MessageWriter; + } = {}, +): { + push: (messageOrBatch: JsonRpcMessage | JsonRpcMessage[]) => void; + idle: () => Promise; +} { + const handlePayload = options.handlePayload ?? handleMcpPayload; + const write = options.write ?? writeMessage; + let pending = Promise.resolve(); + return { + push: (messageOrBatch) => { + const fallbackId = fallbackErrorId(messageOrBatch); + pending = pending + .then(async () => { + const response = await handlePayload(messageOrBatch); + if (response) write(response); + }) + .catch((error: unknown) => { + write({ + jsonrpc: '2.0', + id: fallbackId, + error: { + code: -32603, + message: error instanceof Error ? error.message : String(error), + }, + }); + }); + }, + idle: async () => { + await pending; + }, + }; +} + +export function handleMcpPayload( + messageOrBatch: JsonRpcMessage | JsonRpcMessage[], +): Promise { if (Array.isArray(messageOrBatch)) { - const responses = messageOrBatch.flatMap((message) => responseArray(handleMcpMessage(message))); - return responses.length > 0 ? responses : null; + return handleMcpBatch(messageOrBatch); } return handleMcpMessage(messageOrBatch); } +async function handleMcpBatch(messages: JsonRpcMessage[]): Promise { + const responses: JsonRpcResponse[] = []; + for (const message of messages) { + responses.push(...responseArray(await handleMcpMessage(message))); + } + return responses.length > 0 ? responses : null; +} + +function fallbackErrorId(messageOrBatch: JsonRpcMessage | JsonRpcMessage[]): JsonRpcId { + if (Array.isArray(messageOrBatch)) { + return messageOrBatch.length === 1 ? (messageOrBatch[0]?.id ?? null) : null; + } + return messageOrBatch.id ?? null; +} + class McpMessageDecoder { private buffer = ''; private readonly sink: MessageSink; diff --git a/src/utils/__tests__/args.test.ts b/src/utils/__tests__/args.test.ts index 4792ae804..b37d5cb86 100644 --- a/src/utils/__tests__/args.test.ts +++ b/src/utils/__tests__/args.test.ts @@ -2,8 +2,9 @@ import { test } from 'vitest'; import assert from 'node:assert/strict'; import { parseArgs, usage, usageForCommand } from '../args.ts'; import { AppError } from '../errors.ts'; -import { getCliCommandNames, getSchemaCapabilityKeys } from '../command-schema.ts'; import { listCapabilityCommands } from '../../core/capabilities.ts'; +import { listCapabilityCheckedCommandNames, listCliCommandNames } from '../../command-catalog.ts'; +import { getCliCommandSchema } from '../command-schema.ts'; test('parseArgs recognizes command-specific flag combinations', async () => { const scenarios: Array<{ @@ -1168,16 +1169,25 @@ const INTERNAL_GESTURE_CAPABILITY_COMMANDS = new Set([ ]); test('every public capability command has a parser schema entry', () => { - const schemaCommands = new Set(getCliCommandNames()); + const schemaCommands = new Set(listCliCommandNames()); for (const command of listCapabilityCommands()) { if (INTERNAL_GESTURE_CAPABILITY_COMMANDS.has(command)) continue; assert.equal(schemaCommands.has(command), true, `Missing schema for command: ${command}`); } }); +test('every CLI command has a derived or local parser schema entry', () => { + for (const command of listCliCommandNames()) { + assert.doesNotThrow( + () => getCliCommandSchema(command), + `Missing schema for command: ${command}`, + ); + } +}); + test('schema capability mappings match capability source-of-truth', () => { assert.deepEqual( - getSchemaCapabilityKeys(), + listCapabilityCheckedCommandNames(), listCapabilityCommands().filter( (command) => !INTERNAL_GESTURE_CAPABILITY_COMMANDS.has(command), ), diff --git a/src/utils/args.ts b/src/utils/args.ts index e84e5cdea..177e5dede 100644 --- a/src/utils/args.ts +++ b/src/utils/args.ts @@ -2,14 +2,13 @@ import { AppError } from './errors.ts'; import { mergeDefinedFlags } from './merge-flags.ts'; import { applyCommandDefaults, - buildCommandUsageText, - buildUsageText, getCommandSchema, getFlagDefinition, type CliFlags, type FlagDefinition, type FlagKey, } from './command-schema.ts'; +import { buildCommandUsageText, buildUsageText } from './cli-help.ts'; import { isFlagSupportedForCommand } from './cli-option-schema.ts'; type ParsedArgs = { @@ -280,9 +279,10 @@ function shouldTreatUnknownDashTokenAsPositional( const schema = getCommandSchema(command); if (!schema) return true; if (schema.allowsExtraPositionals) return true; - if (schema.positionalArgs.length === 0) return false; - if (positionals.length < schema.positionalArgs.length) return true; - return schema.positionalArgs.some((entry) => entry.includes('?')); + const positionalArgs = schema.positionalArgs ?? []; + if (positionalArgs.length === 0) return false; + if (positionals.length < positionalArgs.length) return true; + return positionalArgs.some((entry) => entry.includes('?')); } function isNegativeNumericToken(value: string): boolean { diff --git a/src/utils/cli-command-overrides.ts b/src/utils/cli-command-overrides.ts new file mode 100644 index 000000000..b9a2478b9 --- /dev/null +++ b/src/utils/cli-command-overrides.ts @@ -0,0 +1,341 @@ +import { SETTINGS_USAGE_OVERRIDE } from '../core/settings-contract.ts'; +import type { CommandName } from '../commands/command-surface.ts'; +import { DEFAULT_APPS_FILTER } from '../commands/app-inventory-contract.ts'; +import { SCREENSHOT_COMMAND_FLAG_KEYS } from '../commands/capture-screenshot-options.ts'; +import type { LocalCliCommandName } from '../command-catalog.ts'; +import type { CommandSchema, CommandSchemaOverride } from './cli-command-schema-types.ts'; +import { + METRO_PREPARE_FLAGS, + METRO_RELOAD_FLAGS, + REPEATED_TOUCH_FLAGS, + REPLAY_FLAGS, + SELECTOR_SNAPSHOT_FLAGS, + SNAPSHOT_FLAGS, +} from './cli-flags.ts'; + +type SchemaOnlyCliCommandName = Exclude; + +const SCHEMA_ONLY_CLI_COMMAND_SCHEMAS = { + auth: { + usageOverride: 'auth status|login|logout', + listUsageOverride: 'auth status|login|logout', + helpDescription: 'Manage cloud CLI authentication', + summary: 'Manage cloud authentication', + positionalArgs: ['status|login|logout'], + }, + connect: { + usageOverride: + 'connect [--remote-config ] [--tenant ] [--run-id ] [--lease-backend ] [--force] [--no-login]', + helpDescription: + 'Connect to a remote daemon, authenticate when needed, and save remote session state. AGENT_DEVICE_CLOUD_BASE_URL is the bridge/control-plane API origin; use AGENT_DEVICE_DAEMON_AUTH_TOKEN=adc_live_... for CI/service-token automation.', + summary: 'Connect to remote daemon', + allowedFlags: ['force', 'noLogin', ...METRO_PREPARE_FLAGS, 'launchUrl'], + }, + connection: { + usageOverride: 'connection status', + listUsageOverride: 'connection status', + helpDescription: 'Inspect active remote connection state', + summary: 'Inspect remote connection', + positionalArgs: ['status'], + }, + disconnect: { + helpDescription: + 'Disconnect remote daemon state, stop owned Metro companion, and release lease', + summary: 'Disconnect remote daemon', + allowedFlags: ['shutdown'], + }, + mcp: { + helpDescription: + 'Start the official stdio MCP server. It exposes structured command tools backed by the agent-device client.', + summary: 'Start MCP server', + }, + 'react-devtools': { + usageOverride: 'react-devtools [...args]', + listUsageOverride: 'react-devtools [...args]', + helpDescription: + 'Run pinned agent-react-devtools commands for React Native performance profiling, component trees, props/state/hooks, and render analysis', + summary: 'Profile React Native performance and component renders', + positionalArgs: ['args?'], + allowsExtraPositionals: true, + }, +} as const satisfies Record; + +const CLI_COMMAND_OVERRIDES = { + boot: { + summary: 'Boot target device/simulator', + allowedFlags: ['headless'], + }, + open: { + helpDescription: + 'Boot device/simulator; optionally launch app or deep link URL (macOS also supports --surface app|frontmost-app|desktop|menubar)', + summary: 'Open an app, deep link or URL, save replays', + positionalArgs: ['appOrUrl?', 'url?'], + allowedFlags: ['activity', 'launchConsole', 'saveScript', 'relaunch', 'surface'], + }, + close: { + positionalArgs: ['app?'], + allowedFlags: ['saveScript', 'shutdown'], + }, + reinstall: { + positionalArgs: ['app', 'path'], + }, + install: { + positionalArgs: ['app', 'path'], + }, + 'install-from-source': { + usageOverride: + 'install-from-source | install-from-source --github-actions-artifact ', + listUsageOverride: 'install-from-source | install-from-source --github-actions-artifact', + helpDescription: 'Install app from a URL or remote-resolved source', + summary: 'Install app from a source', + positionalArgs: ['url?'], + allowedFlags: [ + 'header', + 'githubActionsArtifact', + 'installSource', + 'retainPaths', + 'retentionMs', + ], + }, + apps: { + helpDescription: 'List user-installed apps; use --all to include system/OEM apps', + summary: 'List installed apps', + allowedFlags: ['appsFilter'], + defaults: { appsFilter: DEFAULT_APPS_FILTER }, + }, + push: { + positionalArgs: ['bundleOrPackage', 'payloadOrJson'], + }, + snapshot: { + usageOverride: 'snapshot [--diff] [-i] [-c] [-d ] [-s ] [--raw] [--force-full]', + helpDescription: 'Capture accessibility tree or diff against the previous session baseline', + allowedFlags: ['snapshotDiff', ...SNAPSHOT_FLAGS, 'snapshotForceFull'], + }, + diff: { + usageOverride: + 'diff snapshot | diff screenshot --baseline [current.png] [--out ] [--threshold <0-1>] [--overlay-refs]', + helpDescription: 'Diff accessibility snapshot or compare screenshots pixel-by-pixel', + summary: 'Diff snapshot or screenshot', + positionalArgs: ['kind', 'current?'], + allowedFlags: [...SNAPSHOT_FLAGS, 'baseline', 'threshold', 'out', 'overlayRefs'], + }, + screenshot: { + helpDescription: + 'Capture screenshot (macOS app sessions default to the app window; use --fullscreen for full desktop, --max-size to downscale, --overlay-refs to annotate current refs, or --no-stabilize for low-latency Android capture loops)', + positionalArgs: ['path?'], + allowedFlags: SCREENSHOT_COMMAND_FLAG_KEYS, + }, + appstate: { + helpDescription: 'Show foreground app/activity', + }, + metro: { + usageOverride: + 'metro prepare (--public-base-url | --proxy-base-url ) [--project-root ] [--port ] [--kind auto|react-native|expo]\n agent-device metro reload [--metro-host ] [--metro-port ] [--bundle-url ]', + listUsageOverride: + 'metro prepare --public-base-url | --proxy-base-url ; metro reload', + helpDescription: + 'Prepare a local Metro runtime or ask Metro to reload connected React Native apps', + summary: 'Prepare Metro or reload apps', + positionalArgs: ['prepare|reload'], + allowedFlags: [...METRO_RELOAD_FLAGS, ...METRO_PREPARE_FLAGS], + }, + clipboard: { + usageOverride: 'clipboard read | clipboard write ', + listUsageOverride: 'clipboard read | clipboard write ', + helpDescription: 'Read or write device clipboard text', + positionalArgs: ['read|write', 'text?'], + allowsExtraPositionals: true, + }, + keyboard: { + usageOverride: 'keyboard [status|get|dismiss|enter|return]', + helpDescription: + 'Inspect Android keyboard visibility/type or press/dismiss the device keyboard', + summary: 'Inspect, press, or dismiss the device keyboard', + positionalArgs: ['action?'], + }, + back: { + usageOverride: 'back [--in-app|--system]', + allowedFlags: ['backMode'], + }, + rotate: { + usageOverride: 'rotate ', + helpDescription: 'Rotate device orientation on iOS and Android', + positionalArgs: ['orientation'], + }, + wait: { + usageOverride: 'wait |text |@ref| [timeoutMs]', + positionalArgs: ['durationOrSelector', 'timeoutMs?'], + allowsExtraPositionals: true, + allowedFlags: [...SELECTOR_SNAPSHOT_FLAGS], + }, + get: { + usageOverride: 'get text|attrs <@ref|selector>', + positionalArgs: ['subcommand', 'target'], + allowedFlags: [...SELECTOR_SNAPSHOT_FLAGS], + }, + find: { + usageOverride: 'find [value] [--first|--last]', + helpDescription: 'Find by text/label/value/role/id and run action', + summary: 'Find an element and act', + positionalArgs: ['query', 'action', 'value?'], + allowsExtraPositionals: true, + allowedFlags: ['snapshotDepth', 'snapshotRaw', 'findFirst', 'findLast'], + }, + is: { + positionalArgs: ['predicate', 'selector', 'value?'], + allowsExtraPositionals: true, + allowedFlags: [...SELECTOR_SNAPSHOT_FLAGS], + }, + alert: { + usageOverride: 'alert [get|accept|dismiss|wait] [timeout]', + positionalArgs: ['action?', 'timeout?'], + }, + click: { + usageOverride: 'click ', + positionalArgs: ['target'], + allowsExtraPositionals: true, + allowedFlags: [...REPEATED_TOUCH_FLAGS, 'clickButton', ...SELECTOR_SNAPSHOT_FLAGS], + }, + replay: { + positionalArgs: ['path'], + allowedFlags: ['replayMaestro', ...REPLAY_FLAGS, 'timeoutMs'], + }, + test: { + usageOverride: 'test ...', + listUsageOverride: 'test ...', + helpDescription: 'Run one or more replay scripts as a serial test suite', + summary: 'Run replay test suites', + positionalArgs: ['pathOrGlob'], + allowsExtraPositionals: true, + allowedFlags: [ + 'replayMaestro', + ...REPLAY_FLAGS, + 'failFast', + 'timeoutMs', + 'retries', + 'artifactsDir', + 'reportJunit', + ], + }, + batch: { + usageOverride: 'batch [--steps | --steps-file ]', + listUsageOverride: 'batch --steps | --steps-file ', + helpDescription: 'Execute multiple commands in one daemon request', + summary: 'Run multiple commands', + allowedFlags: ['steps', 'stepsFile', 'batchOnError', 'batchMaxSteps', 'out'], + }, + press: { + usageOverride: 'press ', + positionalArgs: ['targetOrX', 'y?'], + allowsExtraPositionals: true, + allowedFlags: [...REPEATED_TOUCH_FLAGS, ...SELECTOR_SNAPSHOT_FLAGS], + }, + longpress: { + usageOverride: 'longpress [durationMs]', + positionalArgs: ['targetOrX', 'yOrDurationMs?', 'durationMs?'], + allowsExtraPositionals: true, + allowedFlags: [...SELECTOR_SNAPSHOT_FLAGS], + }, + swipe: { + helpDescription: 'Swipe coordinates with optional repeat pattern', + positionalArgs: ['x1', 'y1', 'x2', 'y2', 'durationMs?'], + allowedFlags: ['count', 'pauseMs', 'pattern'], + }, + gesture: { + usageOverride: 'gesture ...', + listUsageOverride: 'gesture ...', + helpDescription: + 'Run touch gestures: pan [durationMs], fling [distance] [durationMs], pinch [x] [y], rotate [x] [y] [velocity], or transform [durationMs]', + summary: 'Run pan, fling, pinch, rotate, or transform gestures', + positionalArgs: ['pan|fling|pinch|rotate|transform', 'args?'], + allowsExtraPositionals: true, + }, + focus: { + positionalArgs: ['x', 'y'], + }, + type: { + positionalArgs: ['text'], + allowsExtraPositionals: true, + allowedFlags: ['delayMs'], + }, + fill: { + usageOverride: 'fill | fill <@ref|selector> ', + positionalArgs: ['targetOrX', 'yOrText', 'text?'], + allowsExtraPositionals: true, + allowedFlags: [...SELECTOR_SNAPSHOT_FLAGS, 'delayMs'], + }, + scroll: { + usageOverride: 'scroll [amount] [--pixels ]', + helpDescription: 'Scroll in direction, or verify hidden content and scroll toward top/bottom', + summary: 'Scroll in a direction or to an edge', + positionalArgs: ['directionOrEdge', 'amount?'], + allowedFlags: ['pixels'], + }, + 'trigger-app-event': { + usageOverride: 'trigger-app-event [payloadJson]', + positionalArgs: ['event', 'payloadJson?'], + }, + record: { + usageOverride: + 'record start [path] [--fps ] [--quality <5-10>] [--hide-touches] | record stop', + listUsageOverride: 'record start [path] | record stop', + helpDescription: 'Start/stop screen recording', + summary: 'Start or stop screen recording', + positionalArgs: ['start|stop', 'path?'], + allowedFlags: ['fps', 'quality', 'hideTouches'], + }, + 'react-native': { + usageOverride: 'react-native dismiss-overlay', + listUsageOverride: 'react-native dismiss-overlay', + positionalArgs: ['dismiss-overlay'], + }, + trace: { + 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?'], + }, + logs: { + usageOverride: + 'logs path | logs start | logs stop | logs clear [--restart] | logs doctor | logs mark [message...]', + helpDescription: 'Session app log info, start/stop streaming, diagnostics, and markers', + summary: 'Manage session app logs', + positionalArgs: ['path|start|stop|clear|doctor|mark', 'message?'], + allowsExtraPositionals: true, + allowedFlags: ['restart'], + }, + network: { + usageOverride: + 'network dump [limit] [summary|headers|body|all] [--include summary|headers|body|all] | network log [limit] [summary|headers|body|all] [--include summary|headers|body|all]', + helpDescription: 'Dump recent HTTP(s) traffic parsed from the session app log', + summary: 'Show recent HTTP traffic', + positionalArgs: ['dump|log', 'limit?', 'include?'], + allowedFlags: ['networkInclude'], + }, + settings: { + 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?'], + }, + session: { + usageOverride: 'session list', + positionalArgs: ['list?'], + }, +} as const satisfies Partial>; + +export function getSchemaOnlyCliCommandSchema(command: string): CommandSchema | undefined { + return Object.hasOwn(SCHEMA_ONLY_CLI_COMMAND_SCHEMAS, command) + ? SCHEMA_ONLY_CLI_COMMAND_SCHEMAS[command as keyof typeof SCHEMA_ONLY_CLI_COMMAND_SCHEMAS] + : undefined; +} + +export function getCliCommandOverride(command: string): CommandSchemaOverride | undefined { + return Object.hasOwn(CLI_COMMAND_OVERRIDES, command) + ? CLI_COMMAND_OVERRIDES[command as keyof typeof CLI_COMMAND_OVERRIDES] + : undefined; +} diff --git a/src/utils/cli-command-schema-types.ts b/src/utils/cli-command-schema-types.ts new file mode 100644 index 000000000..ce5a66e61 --- /dev/null +++ b/src/utils/cli-command-schema-types.ts @@ -0,0 +1,14 @@ +import type { CliFlags, FlagKey } from './cli-flags.ts'; + +export type CommandSchema = { + helpDescription: string; + summary?: string; + positionalArgs?: readonly string[]; + allowsExtraPositionals?: boolean; + allowedFlags?: readonly FlagKey[]; + defaults?: Partial; + usageOverride?: string; + listUsageOverride?: string; +}; + +export type CommandSchemaOverride = Partial; diff --git a/src/utils/cli-flags.ts b/src/utils/cli-flags.ts new file mode 100644 index 000000000..0238db260 --- /dev/null +++ b/src/utils/cli-flags.ts @@ -0,0 +1,986 @@ +import { SESSION_SURFACES } from '../core/session-surface.ts'; +import type { DaemonInstallSource } from '../contracts.ts'; +import type { RemoteConfigMetroOptions } from '../remote-config-schema.ts'; +import { + SCREENSHOT_SPECIFIC_FLAG_DEFINITIONS, + type ScreenshotRequestFlags, +} from '../commands/capture-screenshot-options.ts'; + +export type CliFlags = RemoteConfigMetroOptions & + ScreenshotRequestFlags & { + json: boolean; + config?: string; + remoteConfig?: string; + stateDir?: string; + daemonBaseUrl?: string; + daemonAuthToken?: string; + daemonTransport?: 'auto' | 'socket' | 'http'; + daemonServerMode?: 'socket' | 'http' | 'dual'; + tenant?: string; + sessionIsolation?: 'none' | 'tenant'; + runId?: string; + leaseId?: string; + leaseBackend?: 'ios-simulator' | 'ios-instance' | 'android-instance'; + force?: boolean; + noLogin?: boolean; + sessionLock?: 'reject' | 'strip'; + sessionLocked?: boolean; + sessionLockConflicts?: 'reject' | 'strip'; + platform?: 'ios' | 'macos' | 'android' | 'linux' | 'apple'; + target?: 'mobile' | 'tv' | 'desktop'; + device?: string; + udid?: string; + serial?: string; + iosSimulatorDeviceSet?: string; + androidDeviceAllowlist?: string; + session?: string; + metroHost?: string; + metroPort?: number; + bundleUrl?: string; + launchUrl?: string; + verbose?: boolean; + snapshotInteractiveOnly?: boolean; + snapshotDiff?: boolean; + snapshotCompact?: boolean; + snapshotDepth?: number; + snapshotScope?: string; + snapshotRaw?: boolean; + snapshotForceFull?: boolean; + networkInclude?: 'summary' | 'headers' | 'body' | 'all'; + baseline?: string; + threshold?: string; + appsFilter?: 'user-installed' | 'all'; + count?: number; + fps?: number; + quality?: number; + hideTouches?: boolean; + intervalMs?: number; + delayMs?: number; + holdMs?: number; + jitterPx?: number; + pixels?: number; + doubleTap?: boolean; + clickButton?: 'primary' | 'secondary' | 'middle'; + backMode?: 'in-app' | 'system'; + pauseMs?: number; + pattern?: 'one-way' | 'ping-pong'; + activity?: string; + launchConsole?: string; + header?: string[]; + githubActionsArtifact?: string; + installSource?: DaemonInstallSource; + saveScript?: boolean | string; + shutdown?: boolean; + relaunch?: boolean; + surface?: 'app' | 'frontmost-app' | 'desktop' | 'menubar'; + headless?: boolean; + restart?: boolean; + noRecord?: boolean; + retainPaths?: boolean; + retentionMs?: number; + replayUpdate?: boolean; + replayMaestro?: boolean; + replayEnv?: string[]; + replayShellEnv?: Record; + failFast?: boolean; + timeoutMs?: number; + retries?: number; + artifactsDir?: string; + reportJunit?: string; + steps?: string; + stepsFile?: string; + findFirst?: boolean; + findLast?: boolean; + batchOnError?: 'stop'; + batchMaxSteps?: number; + batchSteps?: Array<{ + command: string; + input: Record; + runtime?: unknown; + }>; + help: boolean; + version: boolean; + }; + +export type DaemonExcludedCliFlag = 'json' | 'help' | 'version' | 'batchSteps' | 'replayMaestro'; + +export type FlagKey = keyof CliFlags; +type FlagType = 'boolean' | 'int' | 'enum' | 'string' | 'booleanOrString'; + +export type FlagDefinition = { + key: FlagKey; + names: readonly string[]; + type: FlagType; + multiple?: boolean; + enumValues?: readonly string[]; + min?: number; + max?: number; + setValue?: CliFlags[FlagKey]; + usageLabel?: string; + usageDescription?: string; +}; + +function flagKeys(...keys: TKeys): TKeys { + return keys; +} + +export const SNAPSHOT_FLAGS = flagKeys( + 'snapshotInteractiveOnly', + 'snapshotCompact', + 'snapshotDepth', + 'snapshotScope', + 'snapshotRaw', +); + +export const SELECTOR_SNAPSHOT_FLAGS = flagKeys('snapshotDepth', 'snapshotScope', 'snapshotRaw'); + +export const METRO_PREPARE_FLAGS = flagKeys( + 'metroProjectRoot', + 'metroKind', + 'metroPublicBaseUrl', + 'metroProxyBaseUrl', + 'metroBearerToken', + 'metroPreparePort', + 'metroListenHost', + 'metroStatusHost', + 'metroStartupTimeoutMs', + 'metroProbeTimeoutMs', + 'metroRuntimeFile', + 'metroNoReuseExisting', + 'metroNoInstallDeps', +); + +export const METRO_RELOAD_FLAGS = flagKeys('metroHost', 'metroPort', 'bundleUrl'); +export const REPEATED_TOUCH_FLAGS = flagKeys( + 'count', + 'intervalMs', + 'holdMs', + 'jitterPx', + 'doubleTap', +); +export const REPLAY_FLAGS = flagKeys('replayUpdate', 'replayEnv'); + +const FLAG_DEFINITIONS: readonly FlagDefinition[] = [ + { + key: 'config', + names: ['--config'], + type: 'string', + usageLabel: '--config ', + usageDescription: 'Load CLI defaults from a specific config file', + }, + { + key: 'remoteConfig', + names: ['--remote-config'], + type: 'string', + usageLabel: '--remote-config ', + usageDescription: 'Load remote host + Metro workflow settings from a specific profile file', + }, + { + key: 'stateDir', + names: ['--state-dir'], + type: 'string', + usageLabel: '--state-dir ', + usageDescription: 'Daemon state directory (defaults to ~/.agent-device)', + }, + { + key: 'daemonBaseUrl', + names: ['--daemon-base-url'], + type: 'string', + usageLabel: '--daemon-base-url ', + usageDescription: 'Explicit remote HTTP daemon base URL (skip local daemon discovery/startup)', + }, + { + key: 'daemonAuthToken', + names: ['--daemon-auth-token'], + type: 'string', + usageLabel: '--daemon-auth-token ', + usageDescription: 'Remote HTTP daemon auth token (sent as request token and bearer header)', + }, + { + key: 'daemonTransport', + names: ['--daemon-transport'], + type: 'enum', + enumValues: ['auto', 'socket', 'http'], + usageLabel: '--daemon-transport auto|socket|http', + usageDescription: 'Daemon client transport preference', + }, + { + key: 'daemonServerMode', + names: ['--daemon-server-mode'], + type: 'enum', + enumValues: ['socket', 'http', 'dual'], + usageLabel: '--daemon-server-mode socket|http|dual', + usageDescription: 'Daemon server mode used when spawning daemon', + }, + { + key: 'tenant', + names: ['--tenant'], + type: 'string', + usageLabel: '--tenant ', + usageDescription: 'Tenant scope identifier for isolated daemon sessions', + }, + { + key: 'sessionIsolation', + names: ['--session-isolation'], + type: 'enum', + enumValues: ['none', 'tenant'], + usageLabel: '--session-isolation none|tenant', + usageDescription: 'Session isolation strategy (tenant prefixes session namespace)', + }, + { + key: 'runId', + names: ['--run-id'], + type: 'string', + usageLabel: '--run-id ', + usageDescription: 'Run identifier used for tenant lease admission checks', + }, + { + key: 'leaseId', + names: ['--lease-id'], + type: 'string', + usageLabel: '--lease-id ', + usageDescription: 'Lease identifier bound to tenant/run admission scope', + }, + { + key: 'leaseBackend', + names: ['--lease-backend'], + type: 'enum', + enumValues: ['ios-simulator', 'ios-instance', 'android-instance'], + usageLabel: '--lease-backend ios-simulator|ios-instance|android-instance', + usageDescription: 'Lease backend for remote tenant connection admission', + }, + { + key: 'force', + names: ['--force'], + type: 'boolean', + usageLabel: '--force', + usageDescription: 'Force connection state replacement when reconnecting', + }, + { + key: 'noLogin', + names: ['--no-login'], + type: 'boolean', + usageLabel: '--no-login', + usageDescription: 'Connect: fail instead of starting implicit cloud login', + }, + { + key: 'sessionLock', + names: ['--session-lock'], + type: 'enum', + enumValues: ['reject', 'strip'], + usageLabel: '--session-lock reject|strip', + usageDescription: + 'Lock bound-session device routing for this CLI invocation and nested batch steps', + }, + { + key: 'sessionLocked', + names: ['--session-locked'], + type: 'boolean', + usageLabel: '--session-locked', + usageDescription: 'Deprecated alias for --session-lock reject', + }, + { + key: 'sessionLockConflicts', + names: ['--session-lock-conflicts'], + type: 'enum', + enumValues: ['reject', 'strip'], + usageLabel: '--session-lock-conflicts reject|strip', + usageDescription: 'Deprecated alias for --session-lock', + }, + { + key: 'platform', + names: ['--platform'], + type: 'enum', + enumValues: ['ios', 'macos', 'android', 'linux', 'apple'], + usageLabel: '--platform ios|macos|android|linux|apple', + usageDescription: 'Platform to target (`apple` aliases the Apple automation backend)', + }, + { + key: 'target', + names: ['--target'], + type: 'enum', + enumValues: ['mobile', 'tv', 'desktop'], + usageLabel: '--target mobile|tv|desktop', + usageDescription: 'Device target class to match', + }, + { + key: 'device', + names: ['--device'], + type: 'string', + usageLabel: '--device ', + usageDescription: 'Device name to target', + }, + { + key: 'udid', + names: ['--udid'], + type: 'string', + usageLabel: '--udid ', + usageDescription: 'iOS device UDID', + }, + { + key: 'serial', + names: ['--serial'], + type: 'string', + usageLabel: '--serial ', + usageDescription: 'Android device serial', + }, + { + key: 'surface', + names: ['--surface'], + type: 'enum', + enumValues: SESSION_SURFACES, + usageLabel: '--surface app|frontmost-app|desktop|menubar', + usageDescription: 'macOS session surface for open (defaults to app)', + }, + { + key: 'headless', + names: ['--headless'], + type: 'boolean', + usageLabel: '--headless', + usageDescription: 'Boot: launch Android emulator without a GUI window', + }, + { + key: 'metroHost', + names: ['--metro-host'], + type: 'string', + usageLabel: '--metro-host ', + usageDescription: 'Session-scoped Metro/debug host hint', + }, + { + key: 'metroPort', + names: ['--metro-port'], + type: 'int', + min: 1, + max: 65535, + usageLabel: '--metro-port ', + usageDescription: 'Session-scoped Metro/debug port hint', + }, + { + key: 'metroProjectRoot', + names: ['--project-root'], + type: 'string', + usageLabel: '--project-root ', + usageDescription: 'metro prepare: React Native project root (default: cwd)', + }, + { + key: 'metroKind', + names: ['--kind'], + type: 'enum', + enumValues: ['auto', 'react-native', 'expo'], + usageLabel: '--kind auto|react-native|expo', + usageDescription: 'metro prepare: detect or force the Metro launcher kind', + }, + { + key: 'metroPublicBaseUrl', + names: ['--public-base-url'], + type: 'string', + usageLabel: '--public-base-url ', + usageDescription: 'metro prepare: public base URL used for direct bundle hints', + }, + { + key: 'metroProxyBaseUrl', + names: ['--proxy-base-url'], + type: 'string', + usageLabel: '--proxy-base-url ', + usageDescription: 'metro prepare: optional bridge origin for remote Metro access', + }, + { + key: 'metroBearerToken', + names: ['--bearer-token'], + type: 'string', + usageLabel: '--bearer-token ', + usageDescription: + 'metro prepare: host bridge bearer token (or AGENT_DEVICE_METRO_BEARER_TOKEN; falls back to AGENT_DEVICE_DAEMON_AUTH_TOKEN)', + }, + { + key: 'metroPreparePort', + names: ['--port'], + type: 'int', + min: 1, + max: 65535, + usageLabel: '--port ', + usageDescription: 'metro prepare: local Metro port (default: 8081)', + }, + { + key: 'metroListenHost', + names: ['--listen-host'], + type: 'string', + usageLabel: '--listen-host ', + usageDescription: 'metro prepare: host Metro listens on (default: 0.0.0.0)', + }, + { + key: 'metroStatusHost', + names: ['--status-host'], + type: 'string', + usageLabel: '--status-host ', + usageDescription: 'metro prepare: host used for local /status polling (default: 127.0.0.1)', + }, + { + key: 'metroStartupTimeoutMs', + names: ['--startup-timeout-ms'], + type: 'int', + min: 1, + usageLabel: '--startup-timeout-ms ', + usageDescription: 'metro prepare: timeout while waiting for Metro to become ready', + }, + { + key: 'metroProbeTimeoutMs', + names: ['--probe-timeout-ms'], + type: 'int', + min: 1, + usageLabel: '--probe-timeout-ms ', + usageDescription: 'metro prepare: timeout for /status and proxy bridge calls', + }, + { + key: 'metroRuntimeFile', + names: ['--runtime-file'], + type: 'string', + usageLabel: '--runtime-file ', + usageDescription: 'metro prepare: optional file path to persist the JSON result', + }, + { + key: 'metroNoReuseExisting', + names: ['--no-reuse-existing'], + type: 'boolean', + usageLabel: '--no-reuse-existing', + usageDescription: 'metro prepare: always start a fresh Metro process', + }, + { + key: 'metroNoInstallDeps', + names: ['--no-install-deps'], + type: 'boolean', + usageLabel: '--no-install-deps', + usageDescription: 'metro prepare: skip package-manager install when node_modules is missing', + }, + { + key: 'bundleUrl', + names: ['--bundle-url'], + type: 'string', + usageLabel: '--bundle-url ', + usageDescription: 'Session-scoped bundle URL hint', + }, + { + key: 'launchUrl', + names: ['--launch-url'], + type: 'string', + usageLabel: '--launch-url ', + usageDescription: 'Session-scoped deep link / launch URL hint', + }, + { + key: 'iosSimulatorDeviceSet', + names: ['--ios-simulator-device-set'], + type: 'string', + usageLabel: '--ios-simulator-device-set ', + usageDescription: 'Scope iOS simulator discovery/commands to this simulator device set', + }, + { + key: 'androidDeviceAllowlist', + names: ['--android-device-allowlist'], + type: 'string', + usageLabel: '--android-device-allowlist ', + usageDescription: 'Comma/space separated Android serial allowlist for discovery/selection', + }, + { + key: 'activity', + names: ['--activity'], + type: 'string', + usageLabel: '--activity ', + usageDescription: 'Android app launch activity (package/Activity); not for URL opens', + }, + { + key: 'launchConsole', + names: ['--launch-console'], + type: 'string', + usageLabel: '--launch-console ', + usageDescription: 'open: capture the initial iOS simulator launch console window to a file', + }, + { + key: 'header', + names: ['--header'], + type: 'string', + multiple: true, + usageLabel: '--header ', + usageDescription: 'install-from-source: repeatable HTTP header for URL downloads', + }, + { + key: 'githubActionsArtifact', + names: ['--github-actions-artifact'], + type: 'string', + usageLabel: '--github-actions-artifact ', + usageDescription: 'install-from-source: GitHub Actions artifact resolved by a remote daemon', + }, + { + key: 'installSource', + // Config-only virtual option; parsed explicitly from JSON before generic string options. + names: [], + type: 'string', + }, + { + key: 'session', + names: ['--session'], + type: 'string', + usageLabel: '--session ', + usageDescription: 'Named session', + }, + { + key: 'count', + names: ['--count'], + type: 'int', + min: 1, + max: 200, + usageLabel: '--count ', + usageDescription: 'Repeat count for press/swipe series', + }, + { + key: 'fps', + names: ['--fps'], + type: 'int', + min: 1, + max: 120, + usageLabel: '--fps ', + usageDescription: 'Record: target frames per second (iOS physical device runner)', + }, + { + key: 'quality', + names: ['--quality'], + type: 'int', + min: 5, + max: 10, + usageLabel: '--quality <5-10>', + usageDescription: + 'Record: scale recording resolution from 5 (50%) through 10 (native resolution)', + }, + { + key: 'hideTouches', + names: ['--hide-touches'], + type: 'boolean', + usageLabel: '--hide-touches', + usageDescription: 'Record: skip touch-overlay post-processing for faster raw benchmark videos', + }, + { + key: 'intervalMs', + names: ['--interval-ms'], + type: 'int', + min: 0, + max: 10_000, + usageLabel: '--interval-ms ', + usageDescription: 'Delay between press iterations', + }, + { + key: 'delayMs', + names: ['--delay-ms'], + type: 'int', + min: 0, + max: 10_000, + usageLabel: '--delay-ms ', + usageDescription: 'Delay between typed characters', + }, + { + key: 'holdMs', + names: ['--hold-ms'], + type: 'int', + min: 0, + max: 10_000, + usageLabel: '--hold-ms ', + usageDescription: 'Press hold duration for each iteration', + }, + { + key: 'jitterPx', + names: ['--jitter-px'], + type: 'int', + min: 0, + max: 100, + usageLabel: '--jitter-px ', + usageDescription: 'Deterministic coordinate jitter radius for press', + }, + { + key: 'pixels', + names: ['--pixels'], + type: 'int', + min: 1, + max: 100_000, + usageLabel: '--pixels ', + usageDescription: 'Scroll: explicit gesture distance in pixels', + }, + { + key: 'doubleTap', + names: ['--double-tap'], + type: 'boolean', + usageLabel: '--double-tap', + usageDescription: 'Use double-tap gesture per press iteration', + }, + { + key: 'clickButton', + names: ['--button'], + type: 'enum', + enumValues: ['primary', 'secondary', 'middle'], + usageLabel: '--button primary|secondary|middle', + usageDescription: 'Click: choose mouse button (middle reserved for future macOS support)', + }, + // These aliases encode the value directly in the flag name so `back` reads naturally as + // `back --in-app` or `back --system` without introducing a separate `--back-mode` flag. + { + key: 'backMode', + names: ['--in-app'], + type: 'enum', + enumValues: ['in-app', 'system'], + setValue: 'in-app', + usageLabel: '--in-app', + usageDescription: 'Back: use app-provided back UI when available', + }, + { + key: 'backMode', + names: ['--system'], + type: 'enum', + enumValues: ['in-app', 'system'], + setValue: 'system', + usageLabel: '--system', + usageDescription: 'Back: use system back input or gesture when available', + }, + { + key: 'pauseMs', + names: ['--pause-ms'], + type: 'int', + min: 0, + max: 10_000, + usageLabel: '--pause-ms ', + usageDescription: 'Delay between swipe iterations', + }, + { + key: 'pattern', + names: ['--pattern'], + type: 'enum', + enumValues: ['one-way', 'ping-pong'], + usageLabel: '--pattern one-way|ping-pong', + usageDescription: 'Swipe repeat pattern', + }, + { + key: 'verbose', + names: ['--debug', '--verbose', '-v'], + type: 'boolean', + usageLabel: '--debug, --verbose, -v', + usageDescription: 'Enable debug diagnostics and stream daemon/runner logs', + }, + { + key: 'json', + names: ['--json'], + type: 'boolean', + usageLabel: '--json', + usageDescription: 'JSON output', + }, + { + key: 'help', + names: ['--help', '-h'], + type: 'boolean', + usageLabel: '--help, -h', + usageDescription: 'Print help and exit', + }, + { + key: 'version', + names: ['--version', '-V'], + type: 'boolean', + usageLabel: '--version, -V', + usageDescription: 'Print version and exit', + }, + { + key: 'snapshotDiff', + names: ['--diff'], + type: 'boolean', + usageLabel: '--diff', + usageDescription: 'Snapshot: show structural diff against the previous session baseline', + }, + { + key: 'saveScript', + names: ['--save-script'], + type: 'booleanOrString', + usageLabel: '--save-script [path]', + usageDescription: 'Save session script (.ad) on close; optional custom output path', + }, + { + key: 'networkInclude', + names: ['--include'], + type: 'enum', + enumValues: ['summary', 'headers', 'body', 'all'], + usageLabel: '--include summary|headers|body|all', + usageDescription: 'Network: include headers, bodies, or both in output', + }, + { + key: 'shutdown', + names: ['--shutdown'], + type: 'boolean', + usageLabel: '--shutdown', + usageDescription: 'close: shutdown associated simulator/emulator after ending session', + }, + { + key: 'relaunch', + names: ['--relaunch'], + type: 'boolean', + usageLabel: '--relaunch', + usageDescription: 'open: terminate app process before launching it', + }, + { + key: 'restart', + names: ['--restart'], + type: 'boolean', + usageLabel: '--restart', + usageDescription: 'logs clear: stop active stream, clear logs, then start streaming again', + }, + { + key: 'retainPaths', + names: ['--retain-paths'], + type: 'boolean', + usageLabel: '--retain-paths', + usageDescription: 'install-from-source: keep materialized artifact paths after install', + }, + { + key: 'retentionMs', + names: ['--retention-ms'], + type: 'int', + min: 1, + usageLabel: '--retention-ms ', + usageDescription: 'install-from-source: retention TTL for materialized artifact paths', + }, + { + key: 'noRecord', + names: ['--no-record'], + type: 'boolean', + usageLabel: '--no-record', + usageDescription: 'Do not record this action', + }, + { + key: 'replayUpdate', + names: ['--update', '-u'], + type: 'boolean', + usageLabel: '--update, -u', + usageDescription: 'Replay: update selectors and rewrite replay file in place', + }, + { + key: 'replayMaestro', + names: ['--maestro'], + type: 'boolean', + usageLabel: '--maestro', + usageDescription: + 'Replay: treat input as a Maestro YAML compatibility flow. Supported subset: launchApp without state-reset side effects, runFlow file/inline with when.platform, onFlowStart/onFlowComplete, deterministic repeat.times, ordered trusted runScript, tapOn, doubleTapOn, longPressOn, inputText, pasteText, openLink, assertVisible, assertNotVisible, assertTrue literal true/false, extendedWaitUntil, scroll, absolute/percentage swipe, takeScreenshot, hideKeyboard, pressKey back/enter/home, back, waitForAnimationToEnd, stopApp/killApp, setAirplaneMode, setLocation, setOrientation, supported setPermissions targets, and startRecording/stopRecording. ' + + 'Unsupported syntax fails loudly with a link to https://github.com/callstackincubator/agent-device/issues/558', + }, + { + key: 'replayEnv', + names: ['-e', '--env'], + type: 'string', + multiple: true, + usageLabel: '-e KEY=VALUE, --env KEY=VALUE', + usageDescription: + 'Replay/Test: inject or override a ${KEY} variable for the script (repeatable)', + }, + { + key: 'failFast', + names: ['--fail-fast'], + type: 'boolean', + usageLabel: '--fail-fast', + usageDescription: 'Test: stop the suite after the first failing script', + }, + { + key: 'timeoutMs', + names: ['--timeout'], + type: 'int', + min: 1, + usageLabel: '--timeout ', + usageDescription: 'Test: maximum wall-clock time per script attempt', + }, + { + key: 'retries', + names: ['--retries'], + type: 'int', + min: 0, + max: 3, + usageLabel: '--retries ', + usageDescription: 'Test: retry each failed script up to n additional times', + }, + { + key: 'artifactsDir', + names: ['--artifacts-dir'], + type: 'string', + usageLabel: '--artifacts-dir ', + usageDescription: 'Test: root directory for suite artifacts', + }, + { + key: 'reportJunit', + names: ['--report-junit'], + type: 'string', + usageLabel: '--report-junit ', + usageDescription: 'Test: write a JUnit XML report for the replay suite', + }, + { + key: 'steps', + names: ['--steps'], + type: 'string', + usageLabel: '--steps ', + usageDescription: 'Batch: JSON array of steps', + }, + { + key: 'stepsFile', + names: ['--steps-file'], + type: 'string', + usageLabel: '--steps-file ', + usageDescription: 'Batch: read steps JSON from file', + }, + { + key: 'batchOnError', + names: ['--on-error'], + type: 'enum', + enumValues: ['stop'], + usageLabel: '--on-error stop', + usageDescription: 'Batch: stop when a step fails', + }, + { + key: 'batchMaxSteps', + names: ['--max-steps'], + type: 'int', + min: 1, + max: 1000, + usageLabel: '--max-steps ', + usageDescription: 'Batch: maximum number of allowed steps', + }, + { + key: 'appsFilter', + names: ['--all'], + type: 'enum', + enumValues: ['user-installed', 'all'], + setValue: 'all', + usageLabel: '--all', + usageDescription: 'Apps: include system/OEM apps', + }, + { + key: 'snapshotInteractiveOnly', + names: ['-i'], + type: 'boolean', + usageLabel: '-i', + usageDescription: 'Snapshot: interactive elements only', + }, + { + key: 'snapshotCompact', + names: ['-c'], + type: 'boolean', + usageLabel: '-c', + usageDescription: 'Snapshot: compact output (drop empty structure)', + }, + { + key: 'snapshotDepth', + names: ['--depth', '-d'], + type: 'int', + min: 0, + usageLabel: '--depth, -d ', + usageDescription: 'Snapshot: limit snapshot depth', + }, + { + key: 'snapshotScope', + names: ['--scope', '-s'], + type: 'string', + usageLabel: '--scope, -s ', + usageDescription: 'Snapshot: scope snapshot to label/identifier', + }, + { + key: 'snapshotRaw', + names: ['--raw'], + type: 'boolean', + usageLabel: '--raw', + usageDescription: 'Snapshot: raw node output', + }, + { + key: 'snapshotForceFull', + names: ['--force-full'], + type: 'boolean', + usageLabel: '--force-full', + usageDescription: 'Snapshot: re-emit the full tree even when unchanged', + }, + { + key: 'findFirst', + names: ['--first'], + type: 'boolean', + usageLabel: '--first', + usageDescription: 'Find: pick the first match when ambiguous', + }, + { + key: 'findLast', + names: ['--last'], + type: 'boolean', + usageLabel: '--last', + usageDescription: 'Find: pick the last match when ambiguous', + }, + { + key: 'out', + names: ['--out'], + type: 'string', + usageLabel: '--out ', + usageDescription: 'Output path', + }, + { + key: 'overlayRefs', + names: ['--overlay-refs'], + type: 'boolean', + usageLabel: '--overlay-refs', + usageDescription: + 'Screenshot: draw current snapshot refs and target rectangles onto the saved PNG; diff screenshot: also write a separate current-screen overlay guide', + }, + ...SCREENSHOT_SPECIFIC_FLAG_DEFINITIONS, + { + key: 'baseline', + names: ['--baseline', '-b'], + type: 'string', + usageLabel: '--baseline, -b ', + usageDescription: 'Diff screenshot: path to baseline image file', + }, + { + key: 'threshold', + names: ['--threshold'], + type: 'string', + usageLabel: '--threshold <0-1>', + usageDescription: 'Diff screenshot: color distance threshold (default 0.1)', + }, +]; + +export const GLOBAL_FLAG_KEYS = new Set([ + 'json', + 'config', + 'remoteConfig', + 'stateDir', + 'daemonBaseUrl', + 'daemonAuthToken', + 'daemonTransport', + 'daemonServerMode', + 'tenant', + 'sessionIsolation', + 'runId', + 'leaseId', + 'leaseBackend', + 'sessionLock', + 'sessionLocked', + 'sessionLockConflicts', + 'help', + 'version', + 'verbose', + 'platform', + 'target', + 'device', + 'udid', + 'serial', + 'iosSimulatorDeviceSet', + 'androidDeviceAllowlist', + 'session', + 'noRecord', +]); + +const flagDefinitionByName = new Map(); +for (const definition of FLAG_DEFINITIONS) { + for (const name of definition.names) { + flagDefinitionByName.set(name, definition); + } +} + +export function getFlagDefinition(token: string): FlagDefinition | undefined { + return flagDefinitionByName.get(token); +} + +export function getFlagDefinitions(): readonly FlagDefinition[] { + return FLAG_DEFINITIONS; +} diff --git a/src/utils/cli-help.ts b/src/utils/cli-help.ts new file mode 100644 index 000000000..d08e84eaf --- /dev/null +++ b/src/utils/cli-help.ts @@ -0,0 +1,712 @@ +import { listCliCommandNames } from '../command-catalog.ts'; +import { + getCliCommandSchema, + getCommandSchema, + getFlagDefinitions, + GLOBAL_FLAG_KEYS, + type CommandSchema, + type FlagDefinition, + type FlagKey, +} from './command-schema.ts'; + +const AGENT_WORKFLOWS = [ + { label: 'help workflow', description: 'Normal bootstrap, exploration, and validation loop' }, + { label: 'help debugging', description: 'Logs, network, alerts, diagnostics, and traces' }, + { + label: 'help react-native', + description: 'React Native app automation hazards, overlays, Metro, and routing', + }, + { + label: 'help react-devtools', + description: 'React Native performance, profiling, component tree, and renders', + }, + { + label: 'help remote', + description: 'Remote/cloud config, tenants, leases, and local service tunnels', + }, + { label: 'help macos', description: 'Desktop, frontmost-app, and menu bar surfaces' }, + { label: 'help dogfood', description: 'Exploratory QA report workflow' }, +] as const; + +const AGENT_QUICKSTART_LINES = [ + 'Default loop: devices/apps -> open -> snapshot -i -> press/fill/get/is/wait/find -> verify -> close.', + 'Use selectors or refs as positional targets: id="submit", label="Allow", or @e12 from snapshot -i.', + 'Plain snapshot reads state; snapshot -i refreshes current interactive refs only.', + 'Default snapshot text is an agent-facing, token-efficient view for planning and targeting actions.', + 'Read-only visible/state question: use snapshot/get/is/find; use snapshot -i only when refs are needed.', + 'Anti-pattern: snapshot -i followed by snapshot -i | grep ...; prior refs stay valid until app state changes, and --force-full is the explicit full re-read.', + 'Truncated text/input preview: expand first with snapshot -s @e12, not get text.', + 'React Native apps: read help react-native for Metro, DevTools routing, and RN-specific blockers; use react-native dismiss-overlay for LogBox/RedBox overlays.', + 'Android RN/Expo Metro: direct Android localhost URL opens with a port auto-configure host reachability.', + 'Expo Go/dev clients: use the provided URL when given; on iOS prefer open "Expo Go" ; Android URL opens infer the foreground package for logs/perf when possible.', + 'Install flows: install/install-from-source first, then open the installed id with --relaunch.', + 'Text: fill \'id="field-email"\' "qa@example.com" replaces; type appends after press.', + 'Clearing text: do not use fill ""; use a visible clear/reset control or report that clearing is unsupported.', + 'Android IME capture: if fill says input was captured by the keyboard/IME, inspect keyboard state and switch/disable handwriting before retrying; do not loop fill/type.', + 'Run mutating commands serially against one session; parallelize only read-only commands or separate sessions.', + 'Before taking over a shared device, run session list and reuse the active session name when one already owns the device.', + 'Clipboard limits: iOS Allow Paste cannot be automated through XCUITest; prefill with clipboard write. Android non-ASCII should use fill/type, not raw adb input.', + 'After mutation: refs are stale. If the next target is known, use its selector directly; otherwise refresh with snapshot -i, scoped with -s when a stable container is known.', + 'Raw coordinates are fallback-only: use snapshot -i -c --json rects when iOS refs no-op or child refs are missing.', + 'Batch JSON steps use "command" and structured "input"; legacy "positionals"/"flags" steps still run in CLI but are deprecated until the next major version.', + 'Navigation: app-owned back uses back; system back uses back --system.', + 'Verification commands must name the expected text/selector; bare screenshots/snapshots are not enough.', + 'Debug evidence: logs clear --restart/mark/path; trace start ./path; trace stop ./path; network dump --include headers.', + 'Use agent-device commands in final plans; raw platform tools, pseudo commands, and helper prose are wrong.', + 'Full operating guide: agent-device help workflow. Exploratory QA: agent-device help dogfood.', +] as const; + +const CONFIGURATION_LINES = [ + 'Default config files: ~/.agent-device/config.json, ./agent-device.json', + 'Use --config or AGENT_DEVICE_CONFIG to load one explicit config file.', +] as const; + +const ENVIRONMENT_LINES = [ + { label: 'AGENT_DEVICE_SESSION', description: 'Default session name' }, + { label: 'AGENT_DEVICE_PLATFORM', description: 'Default platform binding' }, + { label: 'AGENT_DEVICE_SESSION_LOCK', description: 'Bound-session conflict mode' }, + { label: 'AGENT_DEVICE_DAEMON_BASE_URL', description: 'Connect to remote daemon' }, + { + label: 'AGENT_DEVICE_DAEMON_AUTH_TOKEN', + description: 'Remote daemon service/API token', + }, + { + label: 'AGENT_DEVICE_CLOUD_BASE_URL', + description: 'Bridge/control-plane API origin for cloud auth and /api-keys', + }, +] as const; + +const EXAMPLE_LINES = [ + 'agent-device open Settings --platform ios', + 'agent-device open TextEdit --platform macos', + 'agent-device snapshot -i', + 'agent-device react-devtools get tree --depth 3', + 'agent-device fill @e3 "test@example.com"', + 'agent-device replay ./session.ad', + 'agent-device test ./suite --platform android', +] as const; + +const HELP_TOPICS = { + workflow: { + summary: 'Normal agent-device bootstrap, exploration, and validation loop', + body: `agent-device help workflow + +Version-matched operating guide for normal agent-device work. + +Core loop: + devices/apps -> open -> snapshot or snapshot -i -> get/is/find/wait or press/fill/scroll/back -> verify -> close + +Command shape: + Plans should use agent-device commands, not raw platform tools, pseudo commands, package-manager aliases, or helper prose. + Put subcommand first, then positionals, then flags: + agent-device open com.example.app --session checkout --platform android --relaunch + agent-device record start ./checkout.mp4 --session checkout + Snapshot refs look like @e12. After snapshot -i, use the exact @eN ref from that output. + If the exact ref is not known yet, first output snapshot -i, then use a concrete example shape like press @e12 in the next command; do not write @, @ref, @Label_Name, or @eN placeholders. + Close means agent-device close. App-owned back means back; system back means back --system. + Taps are press or click. Gestures use swipe, longpress, or gesture . Android pinch, rotate, and transform use provider-native touch injection when available, then the bundled multi-touch helper. iOS simulator transform uses private XCTest synthesis for a continuous two-finger pan/scale/rotation path; otherwise it reports UNSUPPORTED_OPERATION. + +Bootstrap: + agent-device devices --platform ios + agent-device apps --platform android + agent-device open MyApp --platform ios --device "iPhone 17 Pro" + agent-device open --session checkout --platform android + agent-device install com.example.app ./dist/app.apk --platform android + agent-device reinstall com.example.app ./build/MyApp.app --platform ios + agent-device install-from-source --github-actions-artifact org/repo:app-debug --platform android + agent-device open com.example.app --platform android --relaunch + If app id is unknown, plan devices, apps, then open . Discovery is not enough when the task asks to open/start the app. + Install arguments are app/package id then artifact path. If the task says install, use install; use reinstall only when explicitly requested. Fresh runtime state is open --relaunch after install. + Do not open artifact paths or invent package ids. If apps lookup misses the target and no URL/artifact is provided, ask or stop. + +Snapshots and refs: + snapshot reads visible state. snapshot -i gets current interactive refs only; it is the fast path when the next step is an interaction. + Default snapshot text is an agent-facing, token-efficient view for planning and targeting actions; use --raw or --json only when you need the full provider tree. + Snapshot legend: + @e12 [button] label="Add to cart" id="add-cart" enabled hittable -> press @e12 or press 'id="add-cart"'. + @e13 [textinput] label="Notes" preview="Leave at side..." truncated -> snapshot -s @e13 before reading. + @e14 [cell] label="Profiles" focused -> tvOS focus is currently on this row. + [off-screen below] 4 items: "Privacy", "About" -> scroll down, then snapshot -i; those are hints, not refs. + Re-snapshot after navigation, submit, typing/fill, modal/list/reload/dynamic changes when you need new refs. + Anti-pattern: snapshot -i followed by snapshot -i | grep ... + Refs from the first snapshot remain valid until you press, fill, type, scroll, go back, wait for async UI, or otherwise change app state. + After a mutation, prefer a known selector/label directly (for example press 'label="Send"') because interaction commands refresh interactive state internally. If you need to discover the new control, use snapshot -i, or snapshot -i -s "Composer" when a stable container label/id can scope the refresh. + For a targeted query, use find/get/is. If you truly need the full tree again, pass --force-full. + Off-screen summaries are scroll hints; use scroll, not swipe, then snapshot -i. + Missing target in a long list: use a short manual scroll + snapshot loop with a max attempt count. If a named target is summarized as off-screen below/above, use scroll down/up, then snapshot -i; do not use scroll bottom/top because the target may appear before the absolute list edge. Use scroll bottom/top only when the task explicitly asks for the list edge. Edge scrolls verify hidden content with snapshots and stop when no matching hidden content remains. + Truncated text/input previews: do not use get text first; expand with snapshot -s @ref (for example snapshot -s @e7), then read the scoped output. + Rare iOS accessibility gaps: if a row ref is shown disabled/hittable:false and press @ref reports success but no UI change, or a horizontal tab/filter bar is collapsed into one composite/seekbar with no child refs, run agent-device snapshot -i -c --json to read rects, compute the target center, press x y, then diff snapshot -i. Coordinates are fallback-only; document why you used them. + +Selectors: + Use selectors as positional targets: id="field-email" or label="Allow". + Do not use CSS selectors, pseudo refs, --selector, --text, or raw x/y when refs/selectors exist. + agent-device fill 'id="catalog-search"' "tart" --delay-ms 80 + agent-device press 'id="submit-order"' + agent-device is visible 'label="Online"' + agent-device get text 'id="quantity-value"' + +Text entry: + fill replaces; type appends to focused field. + agent-device fill @e5 "qa@example.com" + agent-device fill 'id="field-email"' "qa@example.com" + agent-device press 'id="product-note"' + agent-device type "Handle with care" --delay-ms 80 + Empty replacement is not a supported clear-field command: do not plan fill "" or fill ''. Prefer a visible clear/reset control; if the app exposes none, report the tool gap instead of inventing a clear command. + Debounced field with no result selector: agent-device wait 1000. Keyboard read-only: keyboard status/get. Blocked control: try keyboard dismiss when supported. + On iOS, prefer keyboard dismiss before manually pressing visible Done; the runner can use safe native keyboard controls and still reports unsupported layouts explicitly. If it returns UNSUPPORTED_OPERATION, prefer a visible app dismiss control, or use back --system only when system navigation is an acceptable side effect. + Search-as-you-type fields on iOS can drop characters when driven too fast; use --delay-ms on fill/type before trying clipboard paste. + iOS Allow Paste prompt cannot be exercised under XCUITest. To test paste-driven app behavior, prefill first with agent-device clipboard write "some text"; test the system prompt manually. + Android Gboard handwriting/stylus UI can capture text in an IME-owned input instead of the app field. If fill reports that input was captured by the keyboard/IME, use the diagnostic targetInput/actualInput details, inspect keyboard status/get if needed, and switch or disable handwriting outside the command plan before retrying. Do not keep retrying fill/type against the same field while the IME owns focus. + Android text entry is owned by agent-device: provider-native text injection when available, then chunk-safe ASCII shell input. Do not switch to raw adb, clipboard, or paste as an agent fallback. If non-ASCII is unsupported in the current backend, report the tool/device gap. + +Session ordering: + Stateful commands against one --session must run serially. Do not run open/press/fill/type/scroll/back/alert/replay/batch/close commands in parallel against the same session. + It is fine to parallelize independent read-only collection or commands that use different sessions/devices. + +Read-only and waits: + Read-only visible/state question: use snapshot/get/is/find. + agent-device snapshot + agent-device get text 'id="product-title"' + agent-device get attrs @e4 + agent-device is visible 'label="Online"' + agent-device wait text "Refreshing metrics..." 3000 + agent-device wait 'label="Ready"' 3000 + agent-device find "Increment" press --json + For async/list text presence, prefer wait text over is visible when no interaction is needed. + Use snapshot -i only when refs are needed for an action or targeted query. + Ambiguous find: add --first or --last. If info is not visible/exposed, report that gap instead of typing/searching/navigating to reveal it. + +Navigation and gestures: + Use scroll for lists; swipe for coordinate gestures/carousels; gesture pan for deliberate drags; gesture fling for fast directional throws. + For raw coordinate gestures, run snapshot -i first and choose a point near the center of the intended app-owned target. Avoid screen edges, tab bars, navigation bars, and home indicators because those areas can trigger system or app navigation instead of the gesture under test. + If app-owned back is ambiguous or has just misrouted, prefer a visible nav/back button ref, tab-bar ref, or deep link over repeated back/system back. + App-owned action sheets, menus, and camera/scan screens are normal UI. After opening one, run snapshot -i or wait for the option, press by label/ref, handle visible permission sheets through UI or platform-supported native alerts, then wait for a concrete result before returning to chat/form state. + Keep count/pause/pattern on one swipe; flags are --count, --pause-ms, --pattern ping-pong. + longpress accepts coordinates, @refs, or selectors. Prefer @ref/selector from snapshot -i; use coordinates only as a fallback when accessibility refs miss the exact target. Duration and gesture scale/center are positional: + agent-device longpress 300 500 800 + agent-device longpress @e12 800 + agent-device swipe 320 500 40 500 --count 8 --pause-ms 30 --pattern ping-pong + agent-device gesture pan 200 420 0 -80 500 + agent-device gesture fling right 200 420 180 + agent-device gesture pinch 0.5 200 400 + agent-device gesture rotate 35 200 420 + agent-device gesture transform 200 420 80 -40 2 35 700 + iOS simulator transform uses private XCTest synthesis for a continuous two-finger pan/scale/rotation path; verify app metrics instead of assuming requested values map exactly to recognizer output. + Android transform injects a geometric two-finger path; app recognizers may report non-exact pan/scale/rotation. For Android combined transforms, verify qualitative state such as "pan changed yes" / "pinch changed yes" / "rotate changed yes" unless the app explicitly promises exact centroid metrics. + If Android needs exact app-state values, prefer isolated gesture pan, gesture pinch, or gesture rotate commands over one combined transform. + +Validation and evidence: + Nearby mutation diff: agent-device diff snapshot -i. + Expected text/selector verification must include the exact text or selector via wait, is, get, or find; bare screenshots/snapshots are insufficient for named expectations. + Prefer provided testIDs/ids/selectors for verification; use visible text when no durable selector is provided. + If task says snapshot, use snapshot. If it asks visual evidence, use screenshot. + Icon/tappable visual proof: screenshot --overlay-refs. Flag is --overlay-refs. + Startup/frame health/CPU/memory: perf --json or metrics. Replay maintenance: replay -u ./flow.ad. + Recording: record start/stop. By default, stop burns touch overlays into the video; use record start --hide-touches for the fastest raw recording. For gesture-heavy iOS simulator proof videos, prefer --hide-touches because overlay timing depends on a stable runner session while gestures are executing. Tracing: trace start ./trace.log, trace stop ./trace.log. Paths are positional. + Stable known flow: batch ./steps.json, not workflow batch. + Inline batch JSON example: + agent-device batch --steps '[{"command":"open","input":{"app":"settings"}},{"command":"wait","input":{"kind":"duration","durationMs":100}}]' + Batch step keys are command, input, and optional runtime. Put command arguments inside input using the same fields as the MCP/Node command. CLI still accepts legacy positionals/flags steps with a deprecation warning until the next major version. + Never use args, step positionals, or flags for new batch JSON; put command inputs under input. + Android animations: settings animations off/on, not animations disable/restore. + Debug logs: logs clear --restart, logs mark, reproduce, then logs path; do not split clear/restart into separate stop/start commands. + Network headers: network dump --include headers; do not write network log headers. + Remote/cloud: connect to discover a cloud profile, or connect --remote-config ./remote-config.json for a local profile; then open, snapshot, disconnect. + macOS menu bar: open ... --platform macos --surface menubar; snapshot -i --platform macos --surface menubar. + +React Native dev loop: + JS-only change with Metro connected: + agent-device metro reload + agent-device find "Home" + Do not use agent-device reload. Use open --relaunch for native startup reset. + React Native apps: use help react-native for Metro/Fast Refresh, DevTools routing, and RN-specific blockers; use react-native dismiss-overlay for LogBox/RedBox overlays. + Android RN/Expo Metro: direct Android URL opens to localhost/127.0.0.1/[::1] with a port auto-configure host reachability. Manual adb reverse tcp: tcp: is only needed for app/package launches or unsupported flows where the app cannot reach local Metro. + Expo Go is a host shell. Use a provided project URL instead of inventing a bundle id; if no URL is provided but a target/app name is provided, open that target and do not inspect project files to find one. On iOS, prefer host + URL when the host shell is known because direct URL open can report success while leaving the runner/shell focused; verify with snapshot -i after opening: + agent-device open "Expo Go" exp://127.0.0.1:8081 --platform ios + agent-device snapshot -i --platform ios + There is no open-url command; use open with the URL target or host + URL form. + Direct iOS URL open remains valid when no host shell is known, but verify that the app UI loaded: + agent-device open exp://127.0.0.1:8081 --platform ios + Android uses the URL target directly; do not write open there: + agent-device open exp://127.0.0.1:8081 --platform android + Android URL/deep-link opens infer the foreground package after launch when possible, so logs/perf can remain package-bound. If perf still says no package is associated, open the host package/app id first, then open the URL in the same session. + If apps lookup misses the project but shows Expo Go/dev-client and a project URL is available, open the URL/host shell; if no URL is available, ask instead of inventing an app id. + Expo Dev Client/development builds: open the installed dev-client app id/name; if a dev-client URL is provided, open that URL next. For Metro setup use metro prepare --kind expo. + +Escalate: + help debugging logs, network, alerts, traces, flaky runtime failures + help react-devtools React Native performance, profiling, props/state/hooks, slow renders, rerenders + help react-native React Native app automation hazards, overlays, Metro, and routing + help remote remote/cloud config, tenant, lease, local service tunnels + help macos desktop, frontmost-app, menu bar surfaces + help dogfood exploratory QA report workflow`, + }, + debugging: { + summary: 'Targeted failure evidence without dumping stale context', + body: `agent-device help debugging + +Use this when behavior fails, hangs, times out, throws alerts, or needs runtime evidence. + +Logs: + Keep log windows small. Prefer clear, mark, reproduce, then path. + agent-device logs clear --restart + agent-device logs mark "before diagnostics retry" + agent-device press 'id="load-diagnostics"' + agent-device logs path + Do not cat a full stale log into agent context. Open or grep only the relevant window when needed. + logs clear --restart is the compact command to clear old logs and start a fresh capture; do not split it into logs stop, logs clear, logs start. + On iOS simulators, logs scope by bundle id and resolved app executable, so use this instead of raw simctl log stream predicates. + For iOS simulator launch-time stdout/stderr, use --launch-console on the direct app launch: + agent-device open MyApp --platform ios --relaunch --launch-console ./artifacts/app.console.log + --launch-console is only for direct iOS simulator app launches, not URL opens. + +Network: + Use network dump for recent session HTTP traffic parsed from app logs. + agent-device network dump --include headers + agent-device network dump 20 --include all + Use this instead of logs path when the question is request/response metadata. + network log is a supported alias, but network dump --include headers is the clearest plan form. Do not write network log headers. + +Alerts: + Native and platform dialogs: + agent-device alert wait 3000 + agent-device alert accept + agent-device alert dismiss + Android support is snapshot-derived for runtime permission prompts and native app dialogs. iOS support is runner-derived for XCTest alerts, app-owned modal popups with native blocking markers, and blocking system dialogs. Use cheap alert get for an immediate check; use alert wait only when a prompt may appear after async work. + If alert says no alert but a sheet is visibly on screen, treat it as app-owned UI: + agent-device snapshot -i + agent-device press 'label="Allow"' + Do not use settings permission to answer a dialog already on screen. Reserve settings permission for setup/resetting permission state before a flow. + +Diagnostics and traces: + Use --debug for CLI/daemon diagnostic ids and log paths. + Use trace for low-level session diagnostics around one repro: + agent-device trace start ./traces/diagnostics.trace + agent-device press 'id="load-diagnostics"' + agent-device trace stop ./traces/diagnostics.trace + The trace path is positional. Do not use --path for trace start or trace stop. + +Stabilizers: + Android animation-sensitive flows: + agent-device settings animations off + agent-device snapshot + agent-device settings animations on + Re-enable settings you changed before finishing. + +React Native internals: + If the question is about React Native performance, profiling, props, state, hooks, render causes, slow components, or rerenders, use help react-devtools instead of inferring from screenshots or logs.`, + }, + 'react-devtools': { + summary: 'React Native performance, profiling, and component internals', + body: `agent-device help react-devtools + +Use this for React Native performance/profiling and internals that the accessibility tree cannot expose: components, props, state, hooks, ownership, slow renders, and rerenders. + +Core commands: + agent-device react-devtools start + agent-device react-devtools stop + agent-device react-devtools status + agent-device react-devtools wait --connected + agent-device react-devtools wait --component + agent-device react-devtools count + agent-device react-devtools get tree --depth 3 + agent-device react-devtools find + agent-device react-devtools find --exact + agent-device react-devtools get component @c5 + agent-device react-devtools errors + agent-device react-devtools profile start + agent-device react-devtools profile stop + agent-device react-devtools profile slow --limit 5 + agent-device react-devtools profile rerenders --limit 5 + agent-device react-devtools profile report @c5 + agent-device react-devtools profile timeline --limit 20 + agent-device react-devtools profile export profile.json + agent-device react-devtools profile diff before.json after.json --limit 10 + +Profiling loop: + 1. Verify the app is connected: react-devtools status, then wait --connected if needed. + 2. If correlating with logs or network, run logs clear --restart before the first logs mark. + 3. Start profiling immediately before the interaction. + 4. Drive the interaction with normal agent-device commands and mark before/after the repro when timing matters. + 5. Stop profiling. + 6. Make one bounded first-pass survey: profile stop for the summary, profile slow --limit 5 once, profile rerenders --limit 5 once, and profile timeline --limit 20 only when commit timing matters. + 7. Use profile report @cN for targeted render causes and changed props/state/hooks; use get component @cN for current props/state/hooks. + +Rules: + Every React DevTools command is an agent-device subcommand: agent-device react-devtools ... + Do not write agent-devtools, agent-react-devtools, or bare react-devtools commands in final command plans. + Start with get tree --depth 3 or find ; use find --exact when fuzzy results are noisy. + @c refs reset after reload/remount. After reload, wait --connected and inspect again. + Keep the profile window narrow; unrelated navigation makes render data noisy. + Do not repeatedly raise broad profile slow limits such as --limit 50, --limit 200, or --limit 500. Drill into a specific @c ref with profile report unless you have a specific target that needs more rows. + For network evidence, use agent-device network dump --include headers; headers is not a positional argument. + For cross-platform validation with explicit device selectors, prefer isolated --state-dir and restart react-devtools between platforms. + Remote Android and iOS bridge runs normally through agent-device react-devtools; the CLI keeps the needed local service tunnel alive until agent-device react-devtools stop or disconnect. Expo support depends on the SDK's bundled React Native runtime. + Remote iOS apps attempt the legacy React DevTools websocket during JavaScript startup. If the app was already open before react-devtools start, run open --platform ios --relaunch, then wait --connected. + +Example: + agent-device react-devtools status + agent-device react-devtools wait --connected + agent-device logs clear --restart + agent-device logs mark "before catalog search" + agent-device react-devtools profile start + agent-device fill 'id="catalog-search"' "tart" --delay-ms 80 + agent-device logs mark "after catalog search" + agent-device react-devtools profile stop + agent-device react-devtools profile slow --limit 5 + agent-device react-devtools profile rerenders --limit 5 + agent-device react-devtools profile timeline --limit 20 + agent-device react-devtools profile report @c5 + agent-device network dump --include headers + +Use snapshot, screenshot, logs, network, and perf for device/app runtime evidence. Use react-devtools only when component internals or React rendering behavior matters.`, + }, + 'react-native': { + summary: 'React Native app automation hazards and routing', + body: `agent-device help react-native + +Use this when the target app is React Native, Expo, or a React Native dev client. +This topic covers React Native-specific automation hazards and routes deeper +questions to the owning help topic. + +Choose the next help topic: + Generic navigation, selectors, refs, verification, serial commands: help workflow. + Logs, network, diagnostics, traces, permission dialogs, or runtime failures: help debugging. + Component tree, props/state/hooks, slow renders, rerenders, or render causes: help react-devtools. + Remote/cloud config, leases, and local service tunnels: help remote. + +React Native dev loop: + For "start from screen X" flows, prefer open --relaunch before the first snapshot so the app does not reuse a prior in-progress navigation state. + JS-only change with Metro connected: + agent-device metro reload + agent-device find "Home" + Do not use agent-device reload. Use open --relaunch for native startup reset. + Android RN/Expo Metro: direct Android localhost URL opens with a port auto-configure host reachability. For app/package launches, use help react-native if the app cannot reach local Metro. + Expo Go/dev clients are host shells. Use provided project URLs, verify with snapshot -i after opening, and ask instead of inventing app ids or URLs. Help workflow owns the full Expo URL command shapes. + +Overlays and busy RN UIs: + If snapshot reports a React Native warning/error overlay, handle it before interacting with the app: run agent-device react-native dismiss-overlay, then agent-device snapshot -i -c. Use refs from the new snapshot. + Do not manually press warning/error text bodies, collapsed banner bodies, full-screen warning parents, or broad LogBox/RedBox refs. The dismiss-overlay command owns the narrow LogBox/RedBox targeting policy. + Report the overlay in the final summary. Use screenshot --overlay-refs before dismissing only if visual evidence is required. + If snapshot times out because the UI never becomes idle, Android accessibility may be blocked by busy or continuously changing app UI. After that timeout, use screenshot as visual truth instead of repeatedly retrying snapshots. + Android runtime permission dialogs and native alerts are handled by alert wait/accept/dismiss. If alert reports no alert, treat the visible surface as app-owned UI and use snapshot -i plus press by label/ref. + +React DevTools routing: + Keep the agent-device react-devtools prefix on every React DevTools command. + Use help react-devtools for status/wait, component trees, props/state/hooks, profile windows, slow renders, rerenders, and remote bridge rules. + If React DevTools cannot connect, report status and continue with logs, network, perf, screenshot, and trace evidence instead of blocking the whole flow. + +Slow-flow investigation: + Keep one named session, start with session list, open, and snapshot -i. + Use help react-devtools for the narrow React profile window. + Use help debugging for logs clear --restart, logs mark, network dump --include headers, perf --json, traces, and runtime failure evidence. + For 15-20s async work, use wait with the exact expected text or selector instead of repeated snapshots. + Report React render offenders separately from network/backend waits and device frame/CPU/memory findings.`, + }, + remote: { + summary: 'Remote config, tenant, lease, and remote host flow', + body: `agent-device help remote + +Use remote config or the cloud connection profile when a profile owns daemon URL, auth, tenant, run, lease, device scope, and Metro hints. Do not restate those as individual flags unless overriding intentionally. + +Cloud profile flow: + agent-device connect + agent-device open com.example.app + agent-device snapshot + agent-device disconnect + +Local profile flow: + agent-device connect --remote-config ./remote-config.json + agent-device open com.example.app + agent-device snapshot + agent-device disconnect + +Script flow, per-command config: + agent-device open com.example.app --remote-config ./remote-config.json + agent-device snapshot --remote-config ./remote-config.json + agent-device disconnect --remote-config ./remote-config.json + +Rules: + connect and disconnect are top-level commands. Do not write agent-device remote connect or agent-device remote disconnect. + Use connect without --remote-config when the cloud control plane owns the connection profile. + Prefer --remote-config over --daemon-base-url, --tenant, --run-id, and --lease-id when using a local profile. + For self-contained scripts, pass the same --remote-config to every operational command, including disconnect; a preceding connect is optional but not required. + For remote artifact installs, use install-from-source or install-from-source --github-actions-artifact org/repo:artifact; do not download CI artifacts locally first. + After connect, let the active remote connection supply runtime hints. + For remote Android and iOS bridge React DevTools, run agent-device react-devtools normally. The CLI opens the needed local service tunnel for the DevTools daemon and keeps it alive until agent-device react-devtools stop or disconnect. + Use --debug when remote connection or transport errors need diagnostic ids and remote log hints.`, + }, + macos: { + summary: 'macOS desktop, frontmost-app, and menu bar surfaces', + body: `agent-device help macos + +Use macOS only when the task targets desktop apps, desktop surfaces, or menu bar extras. + +Open and inspect: + agent-device open TextEdit --platform macos + agent-device snapshot -i --platform macos + +Surfaces: + --surface app normal app session + --surface frontmost-app inspect whichever app is frontmost + --surface desktop desktop-wide surface + --surface menubar menu bar extras and menu bar-only apps + +Menu bar app example: + agent-device open "Agent Device Tester Menu" --platform macos --surface menubar + agent-device snapshot -i --platform macos --surface menubar + +Context menu example: + agent-device click @e66 --button secondary --platform macos + agent-device snapshot -i --platform macos + +Rules: + Use open and snapshot -i for menu bar inspection. Do not output inspect as a command. + Context menus are not ambient UI: secondary-click a visible target, then re-snapshot and use the new menu-item refs. + Do not let iOS simulator-set scoping hide macOS desktop targets. + Prefer refs/selectors over raw coordinates. + macOS snapshot rects are window-space; use current refs or overlay refs instead of guessing coordinates.`, + }, + dogfood: { + summary: 'Exploratory QA workflow with reproducible evidence', + body: `agent-device help dogfood + +Use this when asked to dogfood, exploratory test, bug hunt, QA, or find issues in an app. + +Goal: + Find user-visible issues from runtime behavior. Do not read app source or invent findings from code. + Produce a concise report with severity, repro commands, expected/actual behavior, and evidence paths. + +Loop: + 1. Identify target app/platform; ask only if missing. + 2. Create output dirs and open a named session. If auth or OTP is required, sign in or ask the user for the code. + 3. Capture baseline snapshot -i and screenshot. + 4. Map top-level navigation, then exercise primary flows and edge states. + 5. For each issue, capture evidence and write the finding immediately, then continue. + 6. Close the session and reconcile the report summary. + Keep stateful commands serial within the same session. Parallel runs can pollute text fields, focus, alerts, and navigation state. + +Coverage: + Navigation, forms, empty/error/loading states, offline or retry behavior, permissions, settings, accessibility labels, orientation/keyboard, and obvious performance stalls. + React Native warning/error overlays can be real findings or test blockers. Capture them, use react-native dismiss-overlay if unrelated, re-snapshot, and report them. + Expo Go/dev-client shells: use the provided exp:// or dev-client URL and record whether the shell, project load, or app UI is being tested. On iOS dogfood, prefer agent-device open "Expo Go" when Expo Go is the known shell, then snapshot -i to confirm the project UI rather than the runner splash. + Android RN/Expo Metro: direct Android localhost URL opens with a port auto-configure host reachability. + Categories: visual, functional, UX, content, performance, diagnostics, permissions, accessibility. + Severity: critical blocks a core flow/data/crashes; high breaks a major feature; medium has friction or workaround; low is polish. + +Evidence commands: + mkdir -p ./dogfood-output/screenshots ./dogfood-output/videos ./dogfood-output/traces + agent-device --session qa open --platform ios + agent-device --session qa snapshot -i + agent-device --session qa screenshot ./dogfood-output/screenshots/initial.png + agent-device --session qa screenshot ./dogfood-output/screenshots/issue-001.png --overlay-refs + agent-device --session qa logs clear --restart + agent-device --session qa logs mark "issue-001 repro" + agent-device --session qa logs path + agent-device --session qa record start ./dogfood-output/videos/issue-001.mp4 + agent-device --session qa record start ./dogfood-output/videos/benchmark.mp4 --hide-touches + agent-device --session qa record stop + agent-device --session qa close + +Evidence rules: + Interactive/behavioral issues need step screenshots and usually a repro video. + Static/on-load issues can use one screenshot; set repro video to N/A. + Use screenshot --overlay-refs when showing the tappable target or broken state helps repro. + +Report shape: + ./dogfood-output/report.md + Include date, platform, target app, session, scope, severity counts, and issues. + For each finding: ID, severity, category, title, affected flow/screen, repro commands, expected, actual, evidence files, notes. + Target 5-10 well-evidenced issues when available. If no issues are found, report coverage completed and residual risk instead of claiming the app is bug-free. + +Rules: + Findings must come from observed runtime behavior, not source reads. + Re-snapshot after each mutation. + Keep commands in the report reproducible; use selectors or refs from fresh snapshots, not guessed coordinates. + Prefer refs for exploration and selectors for deterministic replay. + Use logs, network, screenshot --overlay-refs, trace, perf, or react-devtools only when they add evidence to a specific issue. + Never delete screenshots, videos, traces, or report artifacts during a session. + Escalate to help debugging or help react-devtools when runtime symptoms require those tools.`, + }, +} as const satisfies Record; + +export type HelpTopicName = keyof typeof HELP_TOPICS; + +function formatPositionalArg(arg: string): string { + const optional = arg.endsWith('?'); + const name = optional ? arg.slice(0, -1) : arg; + return optional ? `[${name}]` : `<${name}>`; +} + +function formatCommandListArg(commandName: string, schema: CommandSchema, arg: string): string { + const optional = arg.endsWith('?'); + const name = optional ? arg.slice(0, -1) : arg; + const isChoiceLiteral = /^[a-z-]+(?:\|[a-z-]+)+$/i.test(name); + const isLiteralToken = + isChoiceLiteral || + (schema.usageOverride !== undefined && + schema.usageOverride.startsWith(`${commandName} ${name}`)); + if (optional) { + if (isChoiceLiteral) return `[${name}]`; + if (isLiteralToken) return name; + return `[${name}]`; + } + return isLiteralToken ? name : `<${name}>`; +} + +function buildCommandUsage(commandName: string, schema: CommandSchema): string { + if (schema.usageOverride) return schema.usageOverride; + const positionals = (schema.positionalArgs ?? []).map(formatPositionalArg); + const flagLabels = (schema.allowedFlags ?? []).flatMap((key) => + flagDefinitionsForKey(key).map((definition) => definition.usageLabel ?? definition.names[0]), + ); + const optionalFlags = flagLabels.map((label) => `[${label}]`); + return [commandName, ...positionals, ...optionalFlags].join(' '); +} + +function flagDefinitionsForKey(key: FlagKey): FlagDefinition[] { + return getFlagDefinitions().filter((definition) => definition.key === key); +} + +function buildCommandListUsage(commandName: string, schema: CommandSchema): string { + if (schema.listUsageOverride) return schema.listUsageOverride; + const positionals = (schema.positionalArgs ?? []).map((arg) => + formatCommandListArg(commandName, schema, arg), + ); + return [commandName, ...positionals].join(' '); +} + +function renderUsageText(): string { + const header = `agent-device [args] [--json] + +CLI to control iOS and Android devices for AI agents. +`; + + const commands = listCliCommandNames().map((name) => { + const schema = getCliCommandSchema(name); + return { + name, + schema, + usage: buildCommandListUsage(name, schema), + }; + }); + const commandLines = renderCommandSection(commands); + + const helpFlags = listHelpFlags(GLOBAL_FLAG_KEYS); + const flagsSection = renderFlagSection('Flags:', helpFlags); + const quickstartSection = renderTextSection('Agent Quickstart:', AGENT_QUICKSTART_LINES); + const workflowsSection = renderAlignedSection('Agent Workflows:', AGENT_WORKFLOWS); + const configSection = renderTextSection('Configuration:', CONFIGURATION_LINES); + const environmentSection = renderAlignedSection('Environment:', ENVIRONMENT_LINES); + const examplesSection = renderTextSection('Examples:', EXAMPLE_LINES); + + return `${header} +${commandLines} + +${flagsSection} + +${quickstartSection} + +${workflowsSection} + +${configSection} + +${environmentSection} + +${examplesSection} +`; +} + +export function buildUsageText(): string { + return renderUsageText(); +} + +function listHelpFlags(keys: ReadonlySet): FlagDefinition[] { + return getFlagDefinitions().filter( + (definition) => + keys.has(definition.key) && + definition.usageLabel !== undefined && + definition.usageDescription !== undefined, + ); +} + +function renderFlagSection(title: string, definitions: FlagDefinition[]): string { + return renderAlignedSection( + title, + definitions.map((flag) => ({ + label: flag.usageLabel ?? '', + description: flag.usageDescription ?? '', + })), + ); +} + +function renderAlignedSection( + title: string, + items: ReadonlyArray<{ label: string; description: string }>, +): string { + if (items.length === 0) { + return `${title}\n (none)`; + } + const maxLabelLength = Math.max(...items.map((item) => item.label.length)) + 2; + const lines = [title]; + for (const item of items) { + lines.push(` ${item.label.padEnd(maxLabelLength)}${item.description}`); + } + return lines.join('\n'); +} + +function renderTextSection(title: string, lines: ReadonlyArray): string { + if (lines.length === 0) { + return `${title}\n (none)`; + } + return [title, ...lines.map((line) => ` ${line}`)].join('\n'); +} + +function renderCommandSection( + commands: Array<{ name: string; schema: CommandSchema; usage: string }>, +): string { + return renderAlignedSection( + 'Commands:', + commands.map((command) => ({ + label: command.usage, + description: command.schema.summary ?? command.schema.helpDescription, + })), + ); +} + +export function buildCommandUsageText(commandName: string): string | null { + const topicHelp = buildHelpTopicUsageText(commandName); + if (topicHelp) return topicHelp; + const schema = getCommandSchema(commandName); + if (!schema) return null; + const usage = buildCommandUsage(commandName, schema); + const commandFlags = listHelpFlags(new Set(schema.allowedFlags ?? [])); + const globalFlags = listHelpFlags(GLOBAL_FLAG_KEYS); + const sections: string[] = []; + if (commandFlags.length > 0) { + sections.push(renderFlagSection('Command flags:', commandFlags)); + } + sections.push(renderFlagSection('Global flags:', globalFlags)); + + return `agent-device ${usage} + +${schema.helpDescription} + +Usage: + agent-device ${usage} + +${sections.join('\n\n')} +`; +} + +function buildHelpTopicUsageText(topicName: string): string | null { + const topic = HELP_TOPICS[topicName as keyof typeof HELP_TOPICS]; + if (!topic) return null; + return `${topic.body} + +Related: + agent-device help command list and global flags + agent-device help command-specific flags + agent-device help workflow normal app automation loop +`; +} diff --git a/src/utils/cli-option-schema.ts b/src/utils/cli-option-schema.ts index 41b85986d..563f2d993 100644 --- a/src/utils/cli-option-schema.ts +++ b/src/utils/cli-option-schema.ts @@ -1,7 +1,7 @@ import { buildPrimaryEnvVarName, parseSourceValue } from './source-value.ts'; +import { listCliCommandNames } from '../command-catalog.ts'; import { - getCliCommandNames, - getCommandSchema, + getCliCommandSchema, getFlagDefinition, getFlagDefinitions, GLOBAL_FLAG_KEYS, @@ -80,10 +80,9 @@ function buildOptionSpecs(): OptionSpec[] { for (const key of GLOBAL_FLAG_KEYS) { supportedCommandsByKey.set(key, new Set(['*'])); } - for (const command of getCliCommandNames()) { - const schema = getCommandSchema(command); - if (!schema) continue; - for (const key of schema.allowedFlags) { + for (const command of listCliCommandNames()) { + const schema = getCliCommandSchema(command); + for (const key of schema.allowedFlags ?? []) { const existing = supportedCommandsByKey.get(key); if (existing && existing.has('*')) continue; if (existing) existing.add(command); diff --git a/src/utils/command-schema.ts b/src/utils/command-schema.ts index f94fbd3c1..9d47dfb37 100644 --- a/src/utils/command-schema.ts +++ b/src/utils/command-schema.ts @@ -1,1860 +1,48 @@ -import { SETTINGS_USAGE_OVERRIDE } from '../core/settings-contract.ts'; -import { SESSION_SURFACES } from '../core/session-surface.ts'; -import type { DaemonInstallSource } from '../contracts.ts'; -import type { RemoteConfigMetroOptions } from '../remote-config-schema.ts'; -import { CAPTURE_COMMAND_SCHEMAS } from '../commands/capture-definition.ts'; -import { INTERACTION_COMMAND_SCHEMAS } from '../commands/interactions/definition.ts'; -import { REACT_NATIVE_COMMAND_SCHEMAS } from '../commands/react-native/definition.ts'; +import { listCommandDefinitions } from '../commands/command-surface.ts'; +import type { CliCommandName } from '../command-catalog.ts'; +import type { CommandSchema, CommandSchemaOverride } from './cli-command-schema-types.ts'; +import { getCliCommandOverride, getSchemaOnlyCliCommandSchema } from './cli-command-overrides.ts'; import { - SELECTOR_COMMAND_SCHEMAS, - SELECTOR_SNAPSHOT_FLAGS, -} from '../commands/selectors-definition.ts'; -import { SESSION_LIFECYCLE_COMMAND_SCHEMAS } from '../commands/session-lifecycle/definition.ts'; -import { - SCREENSHOT_SPECIFIC_FLAG_DEFINITIONS, - type ScreenshotRequestFlags, -} from '../commands/capture-screenshot-options.ts'; - -export type CliFlags = RemoteConfigMetroOptions & - ScreenshotRequestFlags & { - json: boolean; - config?: string; - remoteConfig?: string; - stateDir?: string; - daemonBaseUrl?: string; - daemonAuthToken?: string; - daemonTransport?: 'auto' | 'socket' | 'http'; - daemonServerMode?: 'socket' | 'http' | 'dual'; - tenant?: string; - sessionIsolation?: 'none' | 'tenant'; - runId?: string; - leaseId?: string; - leaseBackend?: 'ios-simulator' | 'ios-instance' | 'android-instance'; - force?: boolean; - noLogin?: boolean; - sessionLock?: 'reject' | 'strip'; - sessionLocked?: boolean; - sessionLockConflicts?: 'reject' | 'strip'; - platform?: 'ios' | 'macos' | 'android' | 'linux' | 'apple'; - target?: 'mobile' | 'tv' | 'desktop'; - device?: string; - udid?: string; - serial?: string; - iosSimulatorDeviceSet?: string; - androidDeviceAllowlist?: string; - session?: string; - metroHost?: string; - metroPort?: number; - bundleUrl?: string; - launchUrl?: string; - verbose?: boolean; - snapshotInteractiveOnly?: boolean; - snapshotDiff?: boolean; - snapshotCompact?: boolean; - snapshotDepth?: number; - snapshotScope?: string; - snapshotRaw?: boolean; - snapshotForceFull?: boolean; - networkInclude?: 'summary' | 'headers' | 'body' | 'all'; - baseline?: string; - threshold?: string; - appsFilter?: 'user-installed' | 'all'; - count?: number; - fps?: number; - quality?: number; - hideTouches?: boolean; - intervalMs?: number; - delayMs?: number; - holdMs?: number; - jitterPx?: number; - pixels?: number; - doubleTap?: boolean; - clickButton?: 'primary' | 'secondary' | 'middle'; - backMode?: 'in-app' | 'system'; - pauseMs?: number; - pattern?: 'one-way' | 'ping-pong'; - activity?: string; - launchConsole?: string; - header?: string[]; - githubActionsArtifact?: string; - installSource?: DaemonInstallSource; - saveScript?: boolean | string; - shutdown?: boolean; - relaunch?: boolean; - surface?: 'app' | 'frontmost-app' | 'desktop' | 'menubar'; - headless?: boolean; - restart?: boolean; - noRecord?: boolean; - retainPaths?: boolean; - retentionMs?: number; - replayUpdate?: boolean; - replayMaestro?: boolean; - replayEnv?: string[]; - replayShellEnv?: Record; - failFast?: boolean; - timeoutMs?: number; - retries?: number; - artifactsDir?: string; - reportJunit?: string; - steps?: string; - stepsFile?: string; - findFirst?: boolean; - findLast?: boolean; - batchOnError?: 'stop'; - batchMaxSteps?: number; - batchSteps?: Array<{ - command: string; - positionals?: string[]; - flags?: Record; - }>; - help: boolean; - version: boolean; - }; - -export type DaemonExcludedCliFlag = 'json' | 'help' | 'version' | 'batchSteps' | 'replayMaestro'; - -export type FlagKey = keyof CliFlags; -type FlagType = 'boolean' | 'int' | 'enum' | 'string' | 'booleanOrString'; - -export type FlagDefinition = { - key: FlagKey; - names: readonly string[]; - type: FlagType; - multiple?: boolean; - enumValues?: readonly string[]; - min?: number; - max?: number; - setValue?: CliFlags[FlagKey]; - usageLabel?: string; - usageDescription?: string; -}; - -export type CommandSchema = { - helpDescription: string; - summary?: string; - positionalArgs: readonly string[]; - allowsExtraPositionals?: boolean; - allowedFlags: readonly FlagKey[]; - defaults?: Partial; - skipCapabilityCheck?: boolean; - usageOverride?: string; - listUsageOverride?: string; -}; - -const AGENT_WORKFLOWS = [ - { label: 'help workflow', description: 'Normal bootstrap, exploration, and validation loop' }, - { label: 'help debugging', description: 'Logs, network, alerts, diagnostics, and traces' }, - { - label: 'help react-native', - description: 'React Native app automation hazards, overlays, Metro, and routing', - }, - { - label: 'help react-devtools', - description: 'React Native performance, profiling, component tree, and renders', - }, - { - label: 'help remote', - description: 'Remote/cloud config, tenants, leases, and local service tunnels', - }, - { label: 'help macos', description: 'Desktop, frontmost-app, and menu bar surfaces' }, - { label: 'help dogfood', description: 'Exploratory QA report workflow' }, -] as const; - -const AGENT_QUICKSTART_LINES = [ - 'Default loop: devices/apps -> open -> snapshot -i -> press/fill/get/is/wait/find -> verify -> close.', - 'Use selectors or refs as positional targets: id="submit", label="Allow", or @e12 from snapshot -i.', - 'Plain snapshot reads state; snapshot -i refreshes current interactive refs only.', - 'Default snapshot text is an agent-facing, token-efficient view for planning and targeting actions.', - 'Read-only visible/state question: use snapshot/get/is/find; use snapshot -i only when refs are needed.', - 'Anti-pattern: snapshot -i followed by snapshot -i | grep ...; prior refs stay valid until app state changes, and --force-full is the explicit full re-read.', - 'Truncated text/input preview: expand first with snapshot -s @e12, not get text.', - 'React Native apps: read help react-native for Metro, DevTools routing, and RN-specific blockers; use react-native dismiss-overlay for LogBox/RedBox overlays.', - 'Android RN/Expo Metro: direct Android localhost URL opens with a port auto-configure host reachability.', - 'Expo Go/dev clients: use the provided URL when given; on iOS prefer open "Expo Go" ; Android URL opens infer the foreground package for logs/perf when possible.', - 'Install flows: install/install-from-source first, then open the installed id with --relaunch.', - 'Text: fill \'id="field-email"\' "qa@example.com" replaces; type appends after press.', - 'Clearing text: do not use fill ""; use a visible clear/reset control or report that clearing is unsupported.', - 'Android IME capture: if fill says input was captured by the keyboard/IME, inspect keyboard state and switch/disable handwriting before retrying; do not loop fill/type.', - 'Run mutating commands serially against one session; parallelize only read-only commands or separate sessions.', - 'Before taking over a shared device, run session list and reuse the active session name when one already owns the device.', - 'Clipboard limits: iOS Allow Paste cannot be automated through XCUITest; prefill with clipboard write. Android non-ASCII should use fill/type, not raw adb input.', - 'After mutation: refs are stale. If the next target is known, use its selector directly; otherwise refresh with snapshot -i, scoped with -s when a stable container is known.', - 'Raw coordinates are fallback-only: use snapshot -i -c --json rects when iOS refs no-op or child refs are missing.', - 'Batch JSON steps use "command", "positionals", "flags"; never "args" or "step".', - 'Navigation: app-owned back uses back; system back uses back --system.', - 'Verification commands must name the expected text/selector; bare screenshots/snapshots are not enough.', - 'Debug evidence: logs clear --restart/mark/path; trace start ./path; trace stop ./path; network dump --include headers.', - 'Use agent-device commands in final plans; raw platform tools, pseudo commands, and helper prose are wrong.', - 'Full operating guide: agent-device help workflow. Exploratory QA: agent-device help dogfood.', -] as const; - -const CONFIGURATION_LINES = [ - 'Default config files: ~/.agent-device/config.json, ./agent-device.json', - 'Use --config or AGENT_DEVICE_CONFIG to load one explicit config file.', -] as const; - -const ENVIRONMENT_LINES = [ - { label: 'AGENT_DEVICE_SESSION', description: 'Default session name' }, - { label: 'AGENT_DEVICE_PLATFORM', description: 'Default platform binding' }, - { label: 'AGENT_DEVICE_SESSION_LOCK', description: 'Bound-session conflict mode' }, - { label: 'AGENT_DEVICE_DAEMON_BASE_URL', description: 'Connect to remote daemon' }, - { - label: 'AGENT_DEVICE_DAEMON_AUTH_TOKEN', - description: 'Remote daemon service/API token', - }, - { - label: 'AGENT_DEVICE_CLOUD_BASE_URL', - description: 'Bridge/control-plane API origin for cloud auth and /api-keys', - }, -] as const; - -const EXAMPLE_LINES = [ - 'agent-device open Settings --platform ios', - 'agent-device open TextEdit --platform macos', - 'agent-device snapshot -i', - 'agent-device react-devtools get tree --depth 3', - 'agent-device fill @e3 "test@example.com"', - 'agent-device replay ./session.ad', - 'agent-device test ./suite --platform android', -] as const; - -const HELP_TOPICS = { - workflow: { - summary: 'Normal agent-device bootstrap, exploration, and validation loop', - body: `agent-device help workflow - -Version-matched operating guide for normal agent-device work. - -Core loop: - devices/apps -> open -> snapshot or snapshot -i -> get/is/find/wait or press/fill/scroll/back -> verify -> close - -Command shape: - Plans should use agent-device commands, not raw platform tools, pseudo commands, package-manager aliases, or helper prose. - Put subcommand first, then positionals, then flags: - agent-device open com.example.app --session checkout --platform android --relaunch - agent-device record start ./checkout.mp4 --session checkout - Snapshot refs look like @e12. After snapshot -i, use the exact @eN ref from that output. - If the exact ref is not known yet, first output snapshot -i, then use a concrete example shape like press @e12 in the next command; do not write @, @ref, @Label_Name, or @eN placeholders. - Close means agent-device close. App-owned back means back; system back means back --system. - Taps are press or click. Gestures use swipe, longpress, or gesture . Android pinch, rotate, and transform use provider-native touch injection when available, then the bundled multi-touch helper. iOS simulator transform uses private XCTest synthesis for a continuous two-finger pan/scale/rotation path; otherwise it reports UNSUPPORTED_OPERATION. - -Bootstrap: - agent-device devices --platform ios - agent-device apps --platform android - agent-device open MyApp --platform ios --device "iPhone 17 Pro" - agent-device open --session checkout --platform android - agent-device install com.example.app ./dist/app.apk --platform android - agent-device reinstall com.example.app ./build/MyApp.app --platform ios - agent-device install-from-source --github-actions-artifact org/repo:app-debug --platform android - agent-device open com.example.app --platform android --relaunch - If app id is unknown, plan devices, apps, then open . Discovery is not enough when the task asks to open/start the app. - Install arguments are app/package id then artifact path. If the task says install, use install; use reinstall only when explicitly requested. Fresh runtime state is open --relaunch after install. - Do not open artifact paths or invent package ids. If apps lookup misses the target and no URL/artifact is provided, ask or stop. - -Snapshots and refs: - snapshot reads visible state. snapshot -i gets current interactive refs only; it is the fast path when the next step is an interaction. - Default snapshot text is an agent-facing, token-efficient view for planning and targeting actions; use --raw or --json only when you need the full provider tree. - Snapshot legend: - @e12 [button] label="Add to cart" id="add-cart" enabled hittable -> press @e12 or press 'id="add-cart"'. - @e13 [textinput] label="Notes" preview="Leave at side..." truncated -> snapshot -s @e13 before reading. - @e14 [cell] label="Profiles" focused -> tvOS focus is currently on this row. - [off-screen below] 4 items: "Privacy", "About" -> scroll down, then snapshot -i; those are hints, not refs. - Re-snapshot after navigation, submit, typing/fill, modal/list/reload/dynamic changes when you need new refs. - Anti-pattern: snapshot -i followed by snapshot -i | grep ... - Refs from the first snapshot remain valid until you press, fill, type, scroll, go back, wait for async UI, or otherwise change app state. - After a mutation, prefer a known selector/label directly (for example press 'label="Send"') because interaction commands refresh interactive state internally. If you need to discover the new control, use snapshot -i, or snapshot -i -s "Composer" when a stable container label/id can scope the refresh. - For a targeted query, use find/get/is. If you truly need the full tree again, pass --force-full. - Off-screen summaries are scroll hints; use scroll, not swipe, then snapshot -i. - Missing target in a long list: use a short manual scroll + snapshot loop with a max attempt count. If a named target is summarized as off-screen below/above, use scroll down/up, then snapshot -i; do not use scroll bottom/top because the target may appear before the absolute list edge. Use scroll bottom/top only when the task explicitly asks for the list edge. Edge scrolls verify hidden content with snapshots and stop when no matching hidden content remains. - Truncated text/input previews: do not use get text first; expand with snapshot -s @ref (for example snapshot -s @e7), then read the scoped output. - Rare iOS accessibility gaps: if a row ref is shown disabled/hittable:false and press @ref reports success but no UI change, or a horizontal tab/filter bar is collapsed into one composite/seekbar with no child refs, run agent-device snapshot -i -c --json to read rects, compute the target center, press x y, then diff snapshot -i. Coordinates are fallback-only; document why you used them. - -Selectors: - Use selectors as positional targets: id="field-email" or label="Allow". - Do not use CSS selectors, pseudo refs, --selector, --text, or raw x/y when refs/selectors exist. - agent-device fill 'id="catalog-search"' "tart" --delay-ms 80 - agent-device press 'id="submit-order"' - agent-device is visible 'label="Online"' - agent-device get text 'id="quantity-value"' - -Text entry: - fill replaces; type appends to focused field. - agent-device fill @e5 "qa@example.com" - agent-device fill 'id="field-email"' "qa@example.com" - agent-device press 'id="product-note"' - agent-device type "Handle with care" --delay-ms 80 - Empty replacement is not a supported clear-field command: do not plan fill "" or fill ''. Prefer a visible clear/reset control; if the app exposes none, report the tool gap instead of inventing a clear command. - Debounced field with no result selector: agent-device wait 1000. Keyboard read-only: keyboard status/get. Blocked control: try keyboard dismiss when supported. - On iOS, prefer keyboard dismiss before manually pressing visible Done; the runner can use safe native keyboard controls and still reports unsupported layouts explicitly. If it returns UNSUPPORTED_OPERATION, prefer a visible app dismiss control, or use back --system only when system navigation is an acceptable side effect. - Search-as-you-type fields on iOS can drop characters when driven too fast; use --delay-ms on fill/type before trying clipboard paste. - iOS Allow Paste prompt cannot be exercised under XCUITest. To test paste-driven app behavior, prefill first with agent-device clipboard write "some text"; test the system prompt manually. - Android Gboard handwriting/stylus UI can capture text in an IME-owned input instead of the app field. If fill reports that input was captured by the keyboard/IME, use the diagnostic targetInput/actualInput details, inspect keyboard status/get if needed, and switch or disable handwriting outside the command plan before retrying. Do not keep retrying fill/type against the same field while the IME owns focus. - Android text entry is owned by agent-device: provider-native text injection when available, then chunk-safe ASCII shell input. Do not switch to raw adb, clipboard, or paste as an agent fallback. If non-ASCII is unsupported in the current backend, report the tool/device gap. - -Session ordering: - Stateful commands against one --session must run serially. Do not run open/press/fill/type/scroll/back/alert/replay/batch/close commands in parallel against the same session. - It is fine to parallelize independent read-only collection or commands that use different sessions/devices. - -Read-only and waits: - Read-only visible/state question: use snapshot/get/is/find. - agent-device snapshot - agent-device get text 'id="product-title"' - agent-device get attrs @e4 - agent-device is visible 'label="Online"' - agent-device wait text "Refreshing metrics..." 3000 - agent-device wait 'label="Ready"' 3000 - agent-device find "Increment" press --json - For async/list text presence, prefer wait text over is visible when no interaction is needed. - Use snapshot -i only when refs are needed for an action or targeted query. - Ambiguous find: add --first or --last. If info is not visible/exposed, report that gap instead of typing/searching/navigating to reveal it. - -Navigation and gestures: - Use scroll for lists; swipe for coordinate gestures/carousels; gesture pan for deliberate drags; gesture fling for fast directional throws. - For raw coordinate gestures, run snapshot -i first and choose a point near the center of the intended app-owned target. Avoid screen edges, tab bars, navigation bars, and home indicators because those areas can trigger system or app navigation instead of the gesture under test. - If app-owned back is ambiguous or has just misrouted, prefer a visible nav/back button ref, tab-bar ref, or deep link over repeated back/system back. - App-owned action sheets, menus, and camera/scan screens are normal UI. After opening one, run snapshot -i or wait for the option, press by label/ref, handle visible permission sheets through UI or platform-supported native alerts, then wait for a concrete result before returning to chat/form state. - Keep count/pause/pattern on one swipe; flags are --count, --pause-ms, --pattern ping-pong. - longpress accepts coordinates, @refs, or selectors. Prefer @ref/selector from snapshot -i; use coordinates only as a fallback when accessibility refs miss the exact target. Duration and gesture scale/center are positional: - agent-device longpress 300 500 800 - agent-device longpress @e12 800 - agent-device swipe 320 500 40 500 --count 8 --pause-ms 30 --pattern ping-pong - agent-device gesture pan 200 420 0 -80 500 - agent-device gesture fling right 200 420 180 - agent-device gesture pinch 0.5 200 400 - agent-device gesture rotate 35 200 420 - agent-device gesture transform 200 420 80 -40 2 35 700 - iOS simulator transform uses private XCTest synthesis for a continuous two-finger pan/scale/rotation path; verify app metrics instead of assuming requested values map exactly to recognizer output. - Android transform injects a geometric two-finger path; app recognizers may report non-exact pan/scale/rotation. For Android combined transforms, verify qualitative state such as "pan changed yes" / "pinch changed yes" / "rotate changed yes" unless the app explicitly promises exact centroid metrics. - If Android needs exact app-state values, prefer isolated gesture pan, gesture pinch, or gesture rotate commands over one combined transform. - -Validation and evidence: - Nearby mutation diff: agent-device diff snapshot -i. - Expected text/selector verification must include the exact text or selector via wait, is, get, or find; bare screenshots/snapshots are insufficient for named expectations. - Prefer provided testIDs/ids/selectors for verification; use visible text when no durable selector is provided. - If task says snapshot, use snapshot. If it asks visual evidence, use screenshot. - Icon/tappable visual proof: screenshot --overlay-refs. Flag is --overlay-refs. - Startup/frame health/CPU/memory: perf --json or metrics. Replay maintenance: replay -u ./flow.ad. - Recording: record start/stop. By default, stop burns touch overlays into the video; use record start --hide-touches for the fastest raw recording. For gesture-heavy iOS simulator proof videos, prefer --hide-touches because overlay timing depends on a stable runner session while gestures are executing. Tracing: trace start ./trace.log, trace stop ./trace.log. Paths are positional. - Stable known flow: batch ./steps.json, not workflow batch. - Inline batch JSON example: - agent-device batch --steps '[{"command":"open","positionals":["settings"],"flags":{}},{"command":"wait","positionals":["100"],"flags":{}}]' - Batch step keys are command, positionals, flags, and optional runtime. Never use args, step, text, or target as batch step fields. - Android animations: settings animations off/on, not animations disable/restore. - Debug logs: logs clear --restart, logs mark, reproduce, then logs path; do not split clear/restart into separate stop/start commands. - Network headers: network dump --include headers; do not write network log headers. - Remote/cloud: connect to discover a cloud profile, or connect --remote-config ./remote-config.json for a local profile; then open, snapshot, disconnect. - macOS menu bar: open ... --platform macos --surface menubar; snapshot -i --platform macos --surface menubar. - -React Native dev loop: - JS-only change with Metro connected: - agent-device metro reload - agent-device find "Home" - Do not use agent-device reload. Use open --relaunch for native startup reset. - React Native apps: use help react-native for Metro/Fast Refresh, DevTools routing, and RN-specific blockers; use react-native dismiss-overlay for LogBox/RedBox overlays. - Android RN/Expo Metro: direct Android URL opens to localhost/127.0.0.1/[::1] with a port auto-configure host reachability. Manual adb reverse tcp: tcp: is only needed for app/package launches or unsupported flows where the app cannot reach local Metro. - Expo Go is a host shell. Use a provided project URL instead of inventing a bundle id; if no URL is provided but a target/app name is provided, open that target and do not inspect project files to find one. On iOS, prefer host + URL when the host shell is known because direct URL open can report success while leaving the runner/shell focused; verify with snapshot -i after opening: - agent-device open "Expo Go" exp://127.0.0.1:8081 --platform ios - agent-device snapshot -i --platform ios - There is no open-url command; use open with the URL target or host + URL form. - Direct iOS URL open remains valid when no host shell is known, but verify that the app UI loaded: - agent-device open exp://127.0.0.1:8081 --platform ios - Android Expo URLs can be opened directly when no specific app package must be forced: - agent-device open exp://127.0.0.1:8081 --platform android - Android app-bound deep links can use open when a known package must handle the link: - agent-device open com.example.app example://screen --platform android - Android URL/deep-link opens infer the foreground package after launch when possible, so logs/perf can remain package-bound. If perf still says no package is associated, use the app-bound form when the package id is known. - If apps lookup misses the project but shows Expo Go/dev-client and a project URL is available, open the URL/host shell; if no URL is available, ask instead of inventing an app id. - Expo Dev Client/development builds: open the installed dev-client app id/name; if a dev-client URL is provided, open that URL next. For Metro setup use metro prepare --kind expo. - -Escalate: - help debugging logs, network, alerts, traces, flaky runtime failures - help react-devtools React Native performance, profiling, props/state/hooks, slow renders, rerenders - help react-native React Native app automation hazards, overlays, Metro, and routing - help remote remote/cloud config, tenant, lease, local service tunnels - help macos desktop, frontmost-app, menu bar surfaces - help dogfood exploratory QA report workflow`, - }, - debugging: { - summary: 'Targeted failure evidence without dumping stale context', - body: `agent-device help debugging - -Use this when behavior fails, hangs, times out, throws alerts, or needs runtime evidence. - -Logs: - Keep log windows small. Prefer clear, mark, reproduce, then path. - agent-device logs clear --restart - agent-device logs mark "before diagnostics retry" - agent-device press 'id="load-diagnostics"' - agent-device logs path - Do not cat a full stale log into agent context. Open or grep only the relevant window when needed. - logs clear --restart is the compact command to clear old logs and start a fresh capture; do not split it into logs stop, logs clear, logs start. - On iOS simulators, logs scope by bundle id and resolved app executable, so use this instead of raw simctl log stream predicates. - For iOS simulator launch-time stdout/stderr, use --launch-console on the direct app launch: - agent-device open MyApp --platform ios --relaunch --launch-console ./artifacts/app.console.log - --launch-console is only for direct iOS simulator app launches, not URL opens. - -Network: - Use network dump for recent session HTTP traffic parsed from app logs. - agent-device network dump --include headers - agent-device network dump 20 --include all - Use this instead of logs path when the question is request/response metadata. - network log is a supported alias, but network dump --include headers is the clearest plan form. Do not write network log headers. - -Alerts: - Native and platform dialogs: - agent-device alert wait 3000 - agent-device alert accept - agent-device alert dismiss - Android support is snapshot-derived for runtime permission prompts and native app dialogs. iOS support is runner-derived for XCTest alerts, app-owned modal popups with native blocking markers, and blocking system dialogs. Use cheap alert get for an immediate check; use alert wait only when a prompt may appear after async work. - If alert says no alert but a sheet is visibly on screen, treat it as app-owned UI: - agent-device snapshot -i - agent-device press 'label="Allow"' - Do not use settings permission to answer a dialog already on screen. Reserve settings permission for setup/resetting permission state before a flow. - -Diagnostics and traces: - Use --debug for CLI/daemon diagnostic ids and log paths. - Use trace for low-level session diagnostics around one repro: - agent-device trace start ./traces/diagnostics.trace - agent-device press 'id="load-diagnostics"' - agent-device trace stop ./traces/diagnostics.trace - The trace path is positional. Do not use --path for trace start or trace stop. - -Stabilizers: - Android animation-sensitive flows: - agent-device settings animations off - agent-device snapshot - agent-device settings animations on - Re-enable settings you changed before finishing. - -React Native internals: - If the question is about React Native performance, profiling, props, state, hooks, render causes, slow components, or rerenders, use help react-devtools instead of inferring from screenshots or logs.`, - }, - 'react-devtools': { - summary: 'React Native performance, profiling, and component internals', - body: `agent-device help react-devtools - -Use this for React Native performance/profiling and internals that the accessibility tree cannot expose: components, props, state, hooks, ownership, slow renders, and rerenders. - -Core commands: - agent-device react-devtools start - agent-device react-devtools stop - agent-device react-devtools status - agent-device react-devtools wait --connected - agent-device react-devtools wait --component - agent-device react-devtools count - agent-device react-devtools get tree --depth 3 - agent-device react-devtools find - agent-device react-devtools find --exact - agent-device react-devtools get component @c5 - agent-device react-devtools errors - agent-device react-devtools profile start - agent-device react-devtools profile stop - agent-device react-devtools profile slow --limit 5 - agent-device react-devtools profile rerenders --limit 5 - agent-device react-devtools profile report @c5 - agent-device react-devtools profile timeline --limit 20 - agent-device react-devtools profile export profile.json - agent-device react-devtools profile diff before.json after.json --limit 10 - -Profiling loop: - 1. Verify the app is connected: react-devtools status, then wait --connected if needed. - 2. If correlating with logs or network, run logs clear --restart before the first logs mark. - 3. Start profiling immediately before the interaction. - 4. Drive the interaction with normal agent-device commands and mark before/after the repro when timing matters. - 5. Stop profiling. - 6. Make one bounded first-pass survey: profile stop for the summary, profile slow --limit 5 once, profile rerenders --limit 5 once, and profile timeline --limit 20 only when commit timing matters. - 7. Use profile report @cN for targeted render causes and changed props/state/hooks; use get component @cN for current props/state/hooks. - -Rules: - Every React DevTools command is an agent-device subcommand: agent-device react-devtools ... - Do not write agent-devtools, agent-react-devtools, or bare react-devtools commands in final command plans. - Start with get tree --depth 3 or find ; use find --exact when fuzzy results are noisy. - @c refs reset after reload/remount. After reload, wait --connected and inspect again. - Keep the profile window narrow; unrelated navigation makes render data noisy. - Do not repeatedly raise broad profile slow limits such as --limit 50, --limit 200, or --limit 500. Drill into a specific @c ref with profile report unless you have a specific target that needs more rows. - For network evidence, use agent-device network dump --include headers; headers is not a positional argument. - For cross-platform validation with explicit device selectors, prefer isolated --state-dir and restart react-devtools between platforms. - Remote Android and iOS bridge runs normally through agent-device react-devtools; the CLI keeps the needed local service tunnel alive until agent-device react-devtools stop or disconnect. Expo support depends on the SDK's bundled React Native runtime. - Remote iOS apps attempt the legacy React DevTools websocket during JavaScript startup. If the app was already open before react-devtools start, run open --platform ios --relaunch, then wait --connected. - -Example: - agent-device react-devtools status - agent-device react-devtools wait --connected - agent-device logs clear --restart - agent-device logs mark "before catalog search" - agent-device react-devtools profile start - agent-device fill 'id="catalog-search"' "tart" --delay-ms 80 - agent-device logs mark "after catalog search" - agent-device react-devtools profile stop - agent-device react-devtools profile slow --limit 5 - agent-device react-devtools profile rerenders --limit 5 - agent-device react-devtools profile timeline --limit 20 - agent-device react-devtools profile report @c5 - agent-device network dump --include headers - -Use snapshot, screenshot, logs, network, and perf for device/app runtime evidence. Use react-devtools only when component internals or React rendering behavior matters.`, - }, - 'react-native': { - summary: 'React Native app automation hazards and routing', - body: `agent-device help react-native - -Use this when the target app is React Native, Expo, or a React Native dev client. -This topic covers React Native-specific automation hazards and routes deeper -questions to the owning help topic. - -Choose the next help topic: - Generic navigation, selectors, refs, verification, serial commands: help workflow. - Logs, network, diagnostics, traces, permission dialogs, or runtime failures: help debugging. - Component tree, props/state/hooks, slow renders, rerenders, or render causes: help react-devtools. - Remote/cloud config, leases, and local service tunnels: help remote. - -React Native dev loop: - For "start from screen X" flows, prefer open --relaunch before the first snapshot so the app does not reuse a prior in-progress navigation state. - JS-only change with Metro connected: - agent-device metro reload - agent-device find "Home" - Do not use agent-device reload. Use open --relaunch for native startup reset. - Android RN/Expo Metro: direct Android localhost URL opens with a port auto-configure host reachability. For app/package launches, use help react-native if the app cannot reach local Metro. - Expo Go/dev clients are host shells. Use provided project URLs, verify with snapshot -i after opening, and ask instead of inventing app ids or URLs. Help workflow owns the full Expo URL command shapes. - -Overlays and busy RN UIs: - If snapshot reports a React Native warning/error overlay, handle it before interacting with the app: run agent-device react-native dismiss-overlay, then agent-device snapshot -i -c. Use refs from the new snapshot. - Do not manually press warning/error text bodies, collapsed banner bodies, full-screen warning parents, or broad LogBox/RedBox refs. The dismiss-overlay command owns the narrow LogBox/RedBox targeting policy. - Report the overlay in the final summary. Use screenshot --overlay-refs before dismissing only if visual evidence is required. - If snapshot times out because the UI never becomes idle, Android accessibility may be blocked by busy or continuously changing app UI. After that timeout, use screenshot as visual truth instead of repeatedly retrying snapshots. - Android runtime permission dialogs and native alerts are handled by alert wait/accept/dismiss. If alert reports no alert, treat the visible surface as app-owned UI and use snapshot -i plus press by label/ref. - -React DevTools routing: - Keep the agent-device react-devtools prefix on every React DevTools command. - Use help react-devtools for status/wait, component trees, props/state/hooks, profile windows, slow renders, rerenders, and remote bridge rules. - If React DevTools cannot connect, report status and continue with logs, network, perf, screenshot, and trace evidence instead of blocking the whole flow. - -Slow-flow investigation: - Keep one named session, start with session list, open, and snapshot -i. - Use help react-devtools for the narrow React profile window. - Use help debugging for logs clear --restart, logs mark, network dump --include headers, perf --json, traces, and runtime failure evidence. - For 15-20s async work, use wait with the exact expected text or selector instead of repeated snapshots. - Report React render offenders separately from network/backend waits and device frame/CPU/memory findings.`, - }, - remote: { - summary: 'Remote config, tenant, lease, and remote host flow', - body: `agent-device help remote - -Use remote config or the cloud connection profile when a profile owns daemon URL, auth, tenant, run, lease, device scope, and Metro hints. Do not restate those as individual flags unless overriding intentionally. - -Cloud profile flow: - agent-device connect - agent-device open com.example.app - agent-device snapshot - agent-device disconnect - -Local profile flow: - agent-device connect --remote-config ./remote-config.json - agent-device open com.example.app - agent-device snapshot - agent-device disconnect - -Script flow, per-command config: - agent-device open com.example.app --remote-config ./remote-config.json - agent-device snapshot --remote-config ./remote-config.json - agent-device disconnect --remote-config ./remote-config.json - -Rules: - connect and disconnect are top-level commands. Do not write agent-device remote connect or agent-device remote disconnect. - Use connect without --remote-config when the cloud control plane owns the connection profile. - Prefer --remote-config over --daemon-base-url, --tenant, --run-id, and --lease-id when using a local profile. - For self-contained scripts, pass the same --remote-config to every operational command, including disconnect; a preceding connect is optional but not required. - For remote artifact installs, use install-from-source or install-from-source --github-actions-artifact org/repo:artifact; do not download CI artifacts locally first. - After connect, let the active remote connection supply runtime hints. - For remote Android and iOS bridge React DevTools, run agent-device react-devtools normally. The CLI opens the needed local service tunnel for the DevTools daemon and keeps it alive until agent-device react-devtools stop or disconnect. - Use --debug when remote connection or transport errors need diagnostic ids and remote log hints.`, - }, - macos: { - summary: 'macOS desktop, frontmost-app, and menu bar surfaces', - body: `agent-device help macos - -Use macOS only when the task targets desktop apps, desktop surfaces, or menu bar extras. - -Open and inspect: - agent-device open TextEdit --platform macos - agent-device snapshot -i --platform macos - -Surfaces: - --surface app normal app session - --surface frontmost-app inspect whichever app is frontmost - --surface desktop desktop-wide surface - --surface menubar menu bar extras and menu bar-only apps - -Menu bar app example: - agent-device open "Agent Device Tester Menu" --platform macos --surface menubar - agent-device snapshot -i --platform macos --surface menubar - -Context menu example: - agent-device click @e66 --button secondary --platform macos - agent-device snapshot -i --platform macos - -Rules: - Use open and snapshot -i for menu bar inspection. Do not output inspect as a command. - Context menus are not ambient UI: secondary-click a visible target, then re-snapshot and use the new menu-item refs. - Do not let iOS simulator-set scoping hide macOS desktop targets. - Prefer refs/selectors over raw coordinates. - macOS snapshot rects are window-space; use current refs or overlay refs instead of guessing coordinates.`, - }, - dogfood: { - summary: 'Exploratory QA workflow with reproducible evidence', - body: `agent-device help dogfood - -Use this when asked to dogfood, exploratory test, bug hunt, QA, or find issues in an app. - -Goal: - Find user-visible issues from runtime behavior. Do not read app source or invent findings from code. - Produce a concise report with severity, repro commands, expected/actual behavior, and evidence paths. - -Loop: - 1. Identify target app/platform; ask only if missing. - 2. Create output dirs and open a named session. If auth or OTP is required, sign in or ask the user for the code. - 3. Capture baseline snapshot -i and screenshot. - 4. Map top-level navigation, then exercise primary flows and edge states. - 5. For each issue, capture evidence and write the finding immediately, then continue. - 6. Close the session and reconcile the report summary. - Keep stateful commands serial within the same session. Parallel runs can pollute text fields, focus, alerts, and navigation state. - -Coverage: - Navigation, forms, empty/error/loading states, offline or retry behavior, permissions, settings, accessibility labels, orientation/keyboard, and obvious performance stalls. - React Native warning/error overlays can be real findings or test blockers. Capture them, use react-native dismiss-overlay if unrelated, re-snapshot, and report them. - Expo Go/dev-client shells: use the provided exp:// or dev-client URL and record whether the shell, project load, or app UI is being tested. On iOS dogfood, prefer agent-device open "Expo Go" when Expo Go is the known shell, then snapshot -i to confirm the project UI rather than the runner splash. - Android RN/Expo Metro: direct Android localhost URL opens with a port auto-configure host reachability. - Categories: visual, functional, UX, content, performance, diagnostics, permissions, accessibility. - Severity: critical blocks a core flow/data/crashes; high breaks a major feature; medium has friction or workaround; low is polish. - -Evidence commands: - mkdir -p ./dogfood-output/screenshots ./dogfood-output/videos ./dogfood-output/traces - agent-device --session qa open --platform ios - agent-device --session qa snapshot -i - agent-device --session qa screenshot ./dogfood-output/screenshots/initial.png - agent-device --session qa screenshot ./dogfood-output/screenshots/issue-001.png --overlay-refs - agent-device --session qa logs clear --restart - agent-device --session qa logs mark "issue-001 repro" - agent-device --session qa logs path - agent-device --session qa record start ./dogfood-output/videos/issue-001.mp4 - agent-device --session qa record start ./dogfood-output/videos/benchmark.mp4 --hide-touches - agent-device --session qa record stop - agent-device --session qa close - -Evidence rules: - Interactive/behavioral issues need step screenshots and usually a repro video. - Static/on-load issues can use one screenshot; set repro video to N/A. - Use screenshot --overlay-refs when showing the tappable target or broken state helps repro. - -Report shape: - ./dogfood-output/report.md - Include date, platform, target app, session, scope, severity counts, and issues. - For each finding: ID, severity, category, title, affected flow/screen, repro commands, expected, actual, evidence files, notes. - Target 5-10 well-evidenced issues when available. If no issues are found, report coverage completed and residual risk instead of claiming the app is bug-free. - -Rules: - Findings must come from observed runtime behavior, not source reads. - Re-snapshot after each mutation. - Keep commands in the report reproducible; use selectors or refs from fresh snapshots, not guessed coordinates. - Prefer refs for exploration and selectors for deterministic replay. - Use logs, network, screenshot --overlay-refs, trace, perf, or react-devtools only when they add evidence to a specific issue. - Never delete screenshots, videos, traces, or report artifacts during a session. - Escalate to help debugging or help react-devtools when runtime symptoms require those tools.`, - }, -} as const satisfies Record; + getFlagDefinition, + getFlagDefinitions, + GLOBAL_FLAG_KEYS, + type CliFlags, + type DaemonExcludedCliFlag, + type FlagDefinition, + type FlagKey, +} from './cli-flags.ts'; + +export type { CliFlags, DaemonExcludedCliFlag, FlagDefinition, FlagKey }; +export type { CommandSchema, CommandSchemaOverride }; +export { getFlagDefinition, getFlagDefinitions, GLOBAL_FLAG_KEYS }; + +const COMMAND_SCHEMA_BASES = new Map( + listCommandDefinitions().map((definition) => [ + definition.name, + { helpDescription: definition.description }, + ]), +); -export type HelpTopicName = keyof typeof HELP_TOPICS; - -const FLAG_DEFINITIONS: readonly FlagDefinition[] = [ - { - key: 'config', - names: ['--config'], - type: 'string', - usageLabel: '--config ', - usageDescription: 'Load CLI defaults from a specific config file', - }, - { - key: 'remoteConfig', - names: ['--remote-config'], - type: 'string', - usageLabel: '--remote-config ', - usageDescription: 'Load remote host + Metro workflow settings from a specific profile file', - }, - { - key: 'stateDir', - names: ['--state-dir'], - type: 'string', - usageLabel: '--state-dir ', - usageDescription: 'Daemon state directory (defaults to ~/.agent-device)', - }, - { - key: 'daemonBaseUrl', - names: ['--daemon-base-url'], - type: 'string', - usageLabel: '--daemon-base-url ', - usageDescription: 'Explicit remote HTTP daemon base URL (skip local daemon discovery/startup)', - }, - { - key: 'daemonAuthToken', - names: ['--daemon-auth-token'], - type: 'string', - usageLabel: '--daemon-auth-token ', - usageDescription: 'Remote HTTP daemon auth token (sent as request token and bearer header)', - }, - { - key: 'daemonTransport', - names: ['--daemon-transport'], - type: 'enum', - enumValues: ['auto', 'socket', 'http'], - usageLabel: '--daemon-transport auto|socket|http', - usageDescription: 'Daemon client transport preference', - }, - { - key: 'daemonServerMode', - names: ['--daemon-server-mode'], - type: 'enum', - enumValues: ['socket', 'http', 'dual'], - usageLabel: '--daemon-server-mode socket|http|dual', - usageDescription: 'Daemon server mode used when spawning daemon', - }, - { - key: 'tenant', - names: ['--tenant'], - type: 'string', - usageLabel: '--tenant ', - usageDescription: 'Tenant scope identifier for isolated daemon sessions', - }, - { - key: 'sessionIsolation', - names: ['--session-isolation'], - type: 'enum', - enumValues: ['none', 'tenant'], - usageLabel: '--session-isolation none|tenant', - usageDescription: 'Session isolation strategy (tenant prefixes session namespace)', - }, - { - key: 'runId', - names: ['--run-id'], - type: 'string', - usageLabel: '--run-id ', - usageDescription: 'Run identifier used for tenant lease admission checks', - }, - { - key: 'leaseId', - names: ['--lease-id'], - type: 'string', - usageLabel: '--lease-id ', - usageDescription: 'Lease identifier bound to tenant/run admission scope', - }, - { - key: 'leaseBackend', - names: ['--lease-backend'], - type: 'enum', - enumValues: ['ios-simulator', 'ios-instance', 'android-instance'], - usageLabel: '--lease-backend ios-simulator|ios-instance|android-instance', - usageDescription: 'Lease backend for remote tenant connection admission', - }, - { - key: 'force', - names: ['--force'], - type: 'boolean', - usageLabel: '--force', - usageDescription: 'Force connection state replacement when reconnecting', - }, - { - key: 'noLogin', - names: ['--no-login'], - type: 'boolean', - usageLabel: '--no-login', - usageDescription: 'Connect: fail instead of starting implicit cloud login', - }, - { - key: 'sessionLock', - names: ['--session-lock'], - type: 'enum', - enumValues: ['reject', 'strip'], - usageLabel: '--session-lock reject|strip', - usageDescription: - 'Lock bound-session device routing for this CLI invocation and nested batch steps', - }, - { - key: 'sessionLocked', - names: ['--session-locked'], - type: 'boolean', - usageLabel: '--session-locked', - usageDescription: 'Deprecated alias for --session-lock reject', - }, - { - key: 'sessionLockConflicts', - names: ['--session-lock-conflicts'], - type: 'enum', - enumValues: ['reject', 'strip'], - usageLabel: '--session-lock-conflicts reject|strip', - usageDescription: 'Deprecated alias for --session-lock', - }, - { - key: 'platform', - names: ['--platform'], - type: 'enum', - enumValues: ['ios', 'macos', 'android', 'linux', 'apple'], - usageLabel: '--platform ios|macos|android|linux|apple', - usageDescription: 'Platform to target (`apple` aliases the Apple automation backend)', - }, - { - key: 'target', - names: ['--target'], - type: 'enum', - enumValues: ['mobile', 'tv', 'desktop'], - usageLabel: '--target mobile|tv|desktop', - usageDescription: 'Device target class to match', - }, - { - key: 'device', - names: ['--device'], - type: 'string', - usageLabel: '--device ', - usageDescription: 'Device name to target', - }, - { - key: 'udid', - names: ['--udid'], - type: 'string', - usageLabel: '--udid ', - usageDescription: 'iOS device UDID', - }, - { - key: 'serial', - names: ['--serial'], - type: 'string', - usageLabel: '--serial ', - usageDescription: 'Android device serial', - }, - { - key: 'surface', - names: ['--surface'], - type: 'enum', - enumValues: SESSION_SURFACES, - usageLabel: '--surface app|frontmost-app|desktop|menubar', - usageDescription: 'macOS session surface for open (defaults to app)', - }, - { - key: 'headless', - names: ['--headless'], - type: 'boolean', - usageLabel: '--headless', - usageDescription: 'Boot: launch Android emulator without a GUI window', - }, - { - key: 'metroHost', - names: ['--metro-host'], - type: 'string', - usageLabel: '--metro-host ', - usageDescription: 'Session-scoped Metro/debug host hint', - }, - { - key: 'metroPort', - names: ['--metro-port'], - type: 'int', - min: 1, - max: 65535, - usageLabel: '--metro-port ', - usageDescription: 'Session-scoped Metro/debug port hint', - }, - { - key: 'metroProjectRoot', - names: ['--project-root'], - type: 'string', - usageLabel: '--project-root ', - usageDescription: 'metro prepare: React Native project root (default: cwd)', - }, - { - key: 'metroKind', - names: ['--kind'], - type: 'enum', - enumValues: ['auto', 'react-native', 'expo'], - usageLabel: '--kind auto|react-native|expo', - usageDescription: 'metro prepare: detect or force the Metro launcher kind', - }, - { - key: 'metroPublicBaseUrl', - names: ['--public-base-url'], - type: 'string', - usageLabel: '--public-base-url ', - usageDescription: 'metro prepare: public base URL used for direct bundle hints', - }, - { - key: 'metroProxyBaseUrl', - names: ['--proxy-base-url'], - type: 'string', - usageLabel: '--proxy-base-url ', - usageDescription: 'metro prepare: optional bridge origin for remote Metro access', - }, - { - key: 'metroBearerToken', - names: ['--bearer-token'], - type: 'string', - usageLabel: '--bearer-token ', - usageDescription: - 'metro prepare: host bridge bearer token (or AGENT_DEVICE_METRO_BEARER_TOKEN; falls back to AGENT_DEVICE_DAEMON_AUTH_TOKEN)', - }, - { - key: 'metroPreparePort', - names: ['--port'], - type: 'int', - min: 1, - max: 65535, - usageLabel: '--port ', - usageDescription: 'metro prepare: local Metro port (default: 8081)', - }, - { - key: 'metroListenHost', - names: ['--listen-host'], - type: 'string', - usageLabel: '--listen-host ', - usageDescription: 'metro prepare: host Metro listens on (default: 0.0.0.0)', - }, - { - key: 'metroStatusHost', - names: ['--status-host'], - type: 'string', - usageLabel: '--status-host ', - usageDescription: 'metro prepare: host used for local /status polling (default: 127.0.0.1)', - }, - { - key: 'metroStartupTimeoutMs', - names: ['--startup-timeout-ms'], - type: 'int', - min: 1, - usageLabel: '--startup-timeout-ms ', - usageDescription: 'metro prepare: timeout while waiting for Metro to become ready', - }, - { - key: 'metroProbeTimeoutMs', - names: ['--probe-timeout-ms'], - type: 'int', - min: 1, - usageLabel: '--probe-timeout-ms ', - usageDescription: 'metro prepare: timeout for /status and proxy bridge calls', - }, - { - key: 'metroRuntimeFile', - names: ['--runtime-file'], - type: 'string', - usageLabel: '--runtime-file ', - usageDescription: 'metro prepare: optional file path to persist the JSON result', - }, - { - key: 'metroNoReuseExisting', - names: ['--no-reuse-existing'], - type: 'boolean', - usageLabel: '--no-reuse-existing', - usageDescription: 'metro prepare: always start a fresh Metro process', - }, - { - key: 'metroNoInstallDeps', - names: ['--no-install-deps'], - type: 'boolean', - usageLabel: '--no-install-deps', - usageDescription: 'metro prepare: skip package-manager install when node_modules is missing', - }, - { - key: 'bundleUrl', - names: ['--bundle-url'], - type: 'string', - usageLabel: '--bundle-url ', - usageDescription: 'Session-scoped bundle URL hint', - }, - { - key: 'launchUrl', - names: ['--launch-url'], - type: 'string', - usageLabel: '--launch-url ', - usageDescription: 'Session-scoped deep link / launch URL hint', - }, - { - key: 'iosSimulatorDeviceSet', - names: ['--ios-simulator-device-set'], - type: 'string', - usageLabel: '--ios-simulator-device-set ', - usageDescription: 'Scope iOS simulator discovery/commands to this simulator device set', - }, - { - key: 'androidDeviceAllowlist', - names: ['--android-device-allowlist'], - type: 'string', - usageLabel: '--android-device-allowlist ', - usageDescription: 'Comma/space separated Android serial allowlist for discovery/selection', - }, - { - key: 'activity', - names: ['--activity'], - type: 'string', - usageLabel: '--activity ', - usageDescription: 'Android app launch activity (package/Activity); not for URL opens', - }, - { - key: 'launchConsole', - names: ['--launch-console'], - type: 'string', - usageLabel: '--launch-console ', - usageDescription: 'open: capture the initial iOS simulator launch console window to a file', - }, - { - key: 'header', - names: ['--header'], - type: 'string', - multiple: true, - usageLabel: '--header ', - usageDescription: 'install-from-source: repeatable HTTP header for URL downloads', - }, - { - key: 'githubActionsArtifact', - names: ['--github-actions-artifact'], - type: 'string', - usageLabel: '--github-actions-artifact ', - usageDescription: 'install-from-source: GitHub Actions artifact resolved by a remote daemon', - }, - { - key: 'installSource', - // Config-only virtual option; parsed explicitly from JSON before generic string options. - names: [], - type: 'string', - }, - { - key: 'session', - names: ['--session'], - type: 'string', - usageLabel: '--session ', - usageDescription: 'Named session', - }, - { - key: 'count', - names: ['--count'], - type: 'int', - min: 1, - max: 200, - usageLabel: '--count ', - usageDescription: 'Repeat count for press/swipe series', - }, - { - key: 'fps', - names: ['--fps'], - type: 'int', - min: 1, - max: 120, - usageLabel: '--fps ', - usageDescription: 'Record: target frames per second (iOS physical device runner)', - }, - { - key: 'quality', - names: ['--quality'], - type: 'int', - min: 5, - max: 10, - usageLabel: '--quality <5-10>', - usageDescription: - 'Record: scale recording resolution from 5 (50%) through 10 (native resolution)', - }, - { - key: 'hideTouches', - names: ['--hide-touches'], - type: 'boolean', - usageLabel: '--hide-touches', - usageDescription: 'Record: skip touch-overlay post-processing for faster raw benchmark videos', - }, - { - key: 'intervalMs', - names: ['--interval-ms'], - type: 'int', - min: 0, - max: 10_000, - usageLabel: '--interval-ms ', - usageDescription: 'Delay between press iterations', - }, - { - key: 'delayMs', - names: ['--delay-ms'], - type: 'int', - min: 0, - max: 10_000, - usageLabel: '--delay-ms ', - usageDescription: 'Delay between typed characters', - }, - { - key: 'holdMs', - names: ['--hold-ms'], - type: 'int', - min: 0, - max: 10_000, - usageLabel: '--hold-ms ', - usageDescription: 'Press hold duration for each iteration', - }, - { - key: 'jitterPx', - names: ['--jitter-px'], - type: 'int', - min: 0, - max: 100, - usageLabel: '--jitter-px ', - usageDescription: 'Deterministic coordinate jitter radius for press', - }, - { - key: 'pixels', - names: ['--pixels'], - type: 'int', - min: 1, - max: 100_000, - usageLabel: '--pixels ', - usageDescription: 'Scroll: explicit gesture distance in pixels', - }, - { - key: 'doubleTap', - names: ['--double-tap'], - type: 'boolean', - usageLabel: '--double-tap', - usageDescription: 'Use double-tap gesture per press iteration', - }, - { - key: 'clickButton', - names: ['--button'], - type: 'enum', - enumValues: ['primary', 'secondary', 'middle'], - usageLabel: '--button primary|secondary|middle', - usageDescription: 'Click: choose mouse button (middle reserved for future macOS support)', - }, - // These aliases encode the value directly in the flag name so `back` reads naturally as - // `back --in-app` or `back --system` without introducing a separate `--back-mode` flag. - { - key: 'backMode', - names: ['--in-app'], - type: 'enum', - enumValues: ['in-app', 'system'], - setValue: 'in-app', - usageLabel: '--in-app', - usageDescription: 'Back: use app-provided back UI when available', - }, - { - key: 'backMode', - names: ['--system'], - type: 'enum', - enumValues: ['in-app', 'system'], - setValue: 'system', - usageLabel: '--system', - usageDescription: 'Back: use system back input or gesture when available', - }, - { - key: 'pauseMs', - names: ['--pause-ms'], - type: 'int', - min: 0, - max: 10_000, - usageLabel: '--pause-ms ', - usageDescription: 'Delay between swipe iterations', - }, - { - key: 'pattern', - names: ['--pattern'], - type: 'enum', - enumValues: ['one-way', 'ping-pong'], - usageLabel: '--pattern one-way|ping-pong', - usageDescription: 'Swipe repeat pattern', - }, - { - key: 'verbose', - names: ['--debug', '--verbose', '-v'], - type: 'boolean', - usageLabel: '--debug, --verbose, -v', - usageDescription: 'Enable debug diagnostics and stream daemon/runner logs', - }, - { - key: 'json', - names: ['--json'], - type: 'boolean', - usageLabel: '--json', - usageDescription: 'JSON output', - }, - { - key: 'help', - names: ['--help', '-h'], - type: 'boolean', - usageLabel: '--help, -h', - usageDescription: 'Print help and exit', - }, - { - key: 'version', - names: ['--version', '-V'], - type: 'boolean', - usageLabel: '--version, -V', - usageDescription: 'Print version and exit', - }, - { - key: 'snapshotDiff', - names: ['--diff'], - type: 'boolean', - usageLabel: '--diff', - usageDescription: 'Snapshot: show structural diff against the previous session baseline', - }, - { - key: 'saveScript', - names: ['--save-script'], - type: 'booleanOrString', - usageLabel: '--save-script [path]', - usageDescription: 'Save session script (.ad) on close; optional custom output path', - }, - { - key: 'networkInclude', - names: ['--include'], - type: 'enum', - enumValues: ['summary', 'headers', 'body', 'all'], - usageLabel: '--include summary|headers|body|all', - usageDescription: 'Network: include headers, bodies, or both in output', - }, - { - key: 'shutdown', - names: ['--shutdown'], - type: 'boolean', - usageLabel: '--shutdown', - usageDescription: 'close: shutdown associated simulator/emulator after ending session', - }, - { - key: 'relaunch', - names: ['--relaunch'], - type: 'boolean', - usageLabel: '--relaunch', - usageDescription: 'open: terminate app process before launching it', - }, - { - key: 'restart', - names: ['--restart'], - type: 'boolean', - usageLabel: '--restart', - usageDescription: 'logs clear: stop active stream, clear logs, then start streaming again', - }, - { - key: 'retainPaths', - names: ['--retain-paths'], - type: 'boolean', - usageLabel: '--retain-paths', - usageDescription: 'install-from-source: keep materialized artifact paths after install', - }, - { - key: 'retentionMs', - names: ['--retention-ms'], - type: 'int', - min: 1, - usageLabel: '--retention-ms ', - usageDescription: 'install-from-source: retention TTL for materialized artifact paths', - }, - { - key: 'noRecord', - names: ['--no-record'], - type: 'boolean', - usageLabel: '--no-record', - usageDescription: 'Do not record this action', - }, - { - key: 'replayUpdate', - names: ['--update', '-u'], - type: 'boolean', - usageLabel: '--update, -u', - usageDescription: 'Replay: update selectors and rewrite replay file in place', - }, - { - key: 'replayMaestro', - names: ['--maestro'], - type: 'boolean', - usageLabel: '--maestro', - usageDescription: - 'Replay: treat input as a Maestro YAML compatibility flow. Supported subset: launchApp with Apple-platform launch arguments and iOS simulator clearState, runFlow file/inline with when.platform/visible/notVisible/true, ordered trusted runScript file/env steps with http.post/json/output variables, onFlowStart/onFlowComplete, deterministic repeat.times, tapOn including optional, index, childOf, label, and absolute/percentage point taps, doubleTapOn, longPressOn, inputText, eraseText for focused fields, pasteText, openLink, assertVisible, assertNotVisible, extendedWaitUntil, scroll, scrollUntilVisible, absolute/percentage swipe plus swipe.label, takeScreenshot, hideKeyboard, pressKey back/enter/home, back, waitForAnimationToEnd, and stopApp. ' + - 'Unsupported syntax fails loudly with a link to https://github.com/callstackincubator/agent-device/issues/558', - }, - { - key: 'replayEnv', - names: ['-e', '--env'], - type: 'string', - multiple: true, - usageLabel: '-e KEY=VALUE, --env KEY=VALUE', - usageDescription: - 'Replay/Test: inject or override a ${KEY} variable for the script (repeatable)', - }, - { - key: 'failFast', - names: ['--fail-fast'], - type: 'boolean', - usageLabel: '--fail-fast', - usageDescription: 'Test: stop the suite after the first failing script', - }, - { - key: 'timeoutMs', - names: ['--timeout'], - type: 'int', - min: 1, - usageLabel: '--timeout ', - usageDescription: 'Replay/Test: maximum wall-clock time per script attempt', - }, - { - key: 'retries', - names: ['--retries'], - type: 'int', - min: 0, - max: 3, - usageLabel: '--retries ', - usageDescription: 'Test: retry each failed script up to n additional times', - }, - { - key: 'artifactsDir', - names: ['--artifacts-dir'], - type: 'string', - usageLabel: '--artifacts-dir ', - usageDescription: 'Test: root directory for suite artifacts', - }, - { - key: 'reportJunit', - names: ['--report-junit'], - type: 'string', - usageLabel: '--report-junit ', - usageDescription: 'Test: write a JUnit XML report for the replay suite', - }, - { - key: 'steps', - names: ['--steps'], - type: 'string', - usageLabel: '--steps ', - usageDescription: 'Batch: JSON array of steps', - }, - { - key: 'stepsFile', - names: ['--steps-file'], - type: 'string', - usageLabel: '--steps-file ', - usageDescription: 'Batch: read steps JSON from file', - }, - { - key: 'batchOnError', - names: ['--on-error'], - type: 'enum', - enumValues: ['stop'], - usageLabel: '--on-error stop', - usageDescription: 'Batch: stop when a step fails', - }, - { - key: 'batchMaxSteps', - names: ['--max-steps'], - type: 'int', - min: 1, - max: 1000, - usageLabel: '--max-steps ', - usageDescription: 'Batch: maximum number of allowed steps', - }, - { - key: 'appsFilter', - names: ['--all'], - type: 'enum', - enumValues: ['user-installed', 'all'], - setValue: 'all', - usageLabel: '--all', - usageDescription: 'Apps: include system/OEM apps', - }, - { - key: 'snapshotInteractiveOnly', - names: ['-i'], - type: 'boolean', - usageLabel: '-i', - usageDescription: 'Snapshot: interactive elements only', - }, - { - key: 'snapshotCompact', - names: ['-c'], - type: 'boolean', - usageLabel: '-c', - usageDescription: 'Snapshot: compact output (drop empty structure)', - }, - { - key: 'snapshotDepth', - names: ['--depth', '-d'], - type: 'int', - min: 0, - usageLabel: '--depth, -d ', - usageDescription: 'Snapshot: limit snapshot depth', - }, - { - key: 'snapshotScope', - names: ['--scope', '-s'], - type: 'string', - usageLabel: '--scope, -s ', - usageDescription: 'Snapshot: scope snapshot to label/identifier', - }, - { - key: 'snapshotRaw', - names: ['--raw'], - type: 'boolean', - usageLabel: '--raw', - usageDescription: 'Snapshot: raw node output', - }, - { - key: 'snapshotForceFull', - names: ['--force-full'], - type: 'boolean', - usageLabel: '--force-full', - usageDescription: 'Snapshot: re-emit the full tree even when unchanged', - }, - { - key: 'findFirst', - names: ['--first'], - type: 'boolean', - usageLabel: '--first', - usageDescription: 'Find: pick the first match when ambiguous', - }, - { - key: 'findLast', - names: ['--last'], - type: 'boolean', - usageLabel: '--last', - usageDescription: 'Find: pick the last match when ambiguous', - }, - { - key: 'out', - names: ['--out'], - type: 'string', - usageLabel: '--out ', - usageDescription: 'Output path', - }, - { - key: 'overlayRefs', - names: ['--overlay-refs'], - type: 'boolean', - usageLabel: '--overlay-refs', - usageDescription: - 'Screenshot: draw current snapshot refs and target rectangles onto the saved PNG; diff screenshot: also write a separate current-screen overlay guide', - }, - ...SCREENSHOT_SPECIFIC_FLAG_DEFINITIONS, - { - key: 'baseline', - names: ['--baseline', '-b'], - type: 'string', - usageLabel: '--baseline, -b ', - usageDescription: 'Diff screenshot: path to baseline image file', - }, - { - key: 'threshold', - names: ['--threshold'], - type: 'string', - usageLabel: '--threshold <0-1>', - usageDescription: 'Diff screenshot: color distance threshold (default 0.1)', - }, -]; - -export const GLOBAL_FLAG_KEYS = new Set([ - 'json', - 'config', - 'remoteConfig', - 'stateDir', - 'daemonBaseUrl', - 'daemonAuthToken', - 'daemonTransport', - 'daemonServerMode', - 'tenant', - 'sessionIsolation', - 'runId', - 'leaseId', - 'leaseBackend', - 'sessionLock', - 'sessionLocked', - 'sessionLockConflicts', - 'help', - 'version', - 'verbose', - 'platform', - 'target', - 'device', - 'udid', - 'serial', - 'iosSimulatorDeviceSet', - 'androidDeviceAllowlist', - 'session', - 'noRecord', -]); - -const COMMAND_SCHEMAS: Record = { - boot: { - helpDescription: 'Ensure target device/simulator is booted and ready', - summary: 'Boot target device/simulator', - positionalArgs: [], - allowedFlags: ['headless'], - }, - ...SESSION_LIFECYCLE_COMMAND_SCHEMAS, - connect: { - usageOverride: - 'connect [--remote-config ] [--tenant ] [--run-id ] [--lease-backend ] [--force] [--no-login]', - helpDescription: - 'Connect to a remote daemon, authenticate when needed, and save remote session state. AGENT_DEVICE_CLOUD_BASE_URL is the bridge/control-plane API origin; use AGENT_DEVICE_DAEMON_AUTH_TOKEN=adc_live_... for CI/service-token automation.', - summary: 'Connect to remote daemon', - positionalArgs: [], - allowedFlags: [ - 'force', - 'noLogin', - 'metroProjectRoot', - 'metroKind', - 'metroPublicBaseUrl', - 'metroProxyBaseUrl', - 'metroBearerToken', - 'metroPreparePort', - 'metroListenHost', - 'metroStatusHost', - 'metroStartupTimeoutMs', - 'metroProbeTimeoutMs', - 'metroRuntimeFile', - 'metroNoReuseExisting', - 'metroNoInstallDeps', - 'launchUrl', - ], - skipCapabilityCheck: true, - }, - mcp: { - helpDescription: - 'Start the official stdio MCP discovery router. It exposes only a status tool with CLI install, verify, and starting-help guidance; device automation still runs through terminal CLI commands.', - summary: 'Start MCP discovery router', - positionalArgs: [], - allowedFlags: [], - skipCapabilityCheck: true, - }, - disconnect: { - helpDescription: - 'Disconnect remote daemon state, stop owned Metro companion, and release lease', - summary: 'Disconnect remote daemon', - positionalArgs: [], - allowedFlags: ['shutdown'], - skipCapabilityCheck: true, - }, - connection: { - usageOverride: 'connection status', - listUsageOverride: 'connection status', - helpDescription: 'Inspect active remote connection state', - summary: 'Inspect remote connection', - positionalArgs: ['status'], - allowedFlags: [], - skipCapabilityCheck: true, - }, - auth: { - usageOverride: 'auth status|login|logout', - listUsageOverride: 'auth status|login|logout', - helpDescription: 'Manage cloud CLI authentication', - summary: 'Manage cloud authentication', - positionalArgs: ['status|login|logout'], - allowedFlags: [], - skipCapabilityCheck: true, - }, - push: { - helpDescription: 'Simulate push notification payload delivery', - summary: 'Deliver push payload', - positionalArgs: ['bundleOrPackage', 'payloadOrJson'], - allowedFlags: [], - }, - ...CAPTURE_COMMAND_SCHEMAS, - devices: { - helpDescription: 'List available devices', - positionalArgs: [], - allowedFlags: [], - skipCapabilityCheck: true, - }, - appstate: { - helpDescription: 'Show foreground app/activity', - positionalArgs: [], - allowedFlags: [], - skipCapabilityCheck: true, - }, - metro: { - usageOverride: - 'metro prepare (--public-base-url | --proxy-base-url ) [--project-root ] [--port ] [--kind auto|react-native|expo]\n agent-device metro reload [--metro-host ] [--metro-port ] [--bundle-url ]', - listUsageOverride: - 'metro prepare --public-base-url | --proxy-base-url ; metro reload', - helpDescription: - 'Prepare a local Metro runtime or ask Metro to reload connected React Native apps', - summary: 'Prepare Metro or reload apps', - positionalArgs: ['prepare|reload'], - allowedFlags: [ - 'metroHost', - 'metroPort', - 'metroProjectRoot', - 'metroKind', - 'metroPublicBaseUrl', - 'metroProxyBaseUrl', - 'metroBearerToken', - 'metroPreparePort', - 'metroListenHost', - 'metroStatusHost', - 'metroStartupTimeoutMs', - 'metroProbeTimeoutMs', - 'metroRuntimeFile', - 'metroNoReuseExisting', - 'metroNoInstallDeps', - 'bundleUrl', - ], - skipCapabilityCheck: true, - }, - clipboard: { - usageOverride: 'clipboard read | clipboard write ', - listUsageOverride: 'clipboard read | clipboard write ', - helpDescription: 'Read or write device clipboard text', - positionalArgs: ['read|write', 'text?'], - allowsExtraPositionals: true, - allowedFlags: [], - }, - keyboard: { - usageOverride: 'keyboard [status|get|dismiss|enter|return]', - helpDescription: - 'Inspect Android keyboard visibility/type or press/dismiss the device keyboard', - summary: 'Inspect, press, or dismiss the device keyboard', - positionalArgs: ['action?'], - allowedFlags: [], - }, - perf: { - helpDescription: - 'Show session performance metrics, including frame health on Android and iOS devices', - summary: 'Show performance metrics', - positionalArgs: [], - allowedFlags: [], - }, - 'react-devtools': { - usageOverride: 'react-devtools [...args]', - listUsageOverride: 'react-devtools [...args]', - helpDescription: - 'Run pinned agent-react-devtools commands for React Native performance profiling, component trees, props/state/hooks, and render analysis', - summary: 'Profile React Native performance and component renders', - positionalArgs: ['args?'], - allowsExtraPositionals: true, - allowedFlags: [], - skipCapabilityCheck: true, - }, - back: { - usageOverride: 'back [--in-app|--system]', - helpDescription: 'Navigate back with explicit app or system semantics', - summary: 'Go back', - positionalArgs: [], - allowedFlags: ['backMode'], - }, - home: { - helpDescription: 'Go to home screen (where supported)', - summary: 'Go home', - positionalArgs: [], - allowedFlags: [], - }, - rotate: { - usageOverride: 'rotate ', - helpDescription: 'Rotate device orientation on iOS and Android', - summary: 'Rotate device orientation', - positionalArgs: ['orientation'], - allowedFlags: [], - }, - 'app-switcher': { - helpDescription: 'Open app switcher (where supported)', - summary: 'Open app switcher', - positionalArgs: [], - allowedFlags: [], - }, - ...SELECTOR_COMMAND_SCHEMAS, - alert: { - usageOverride: 'alert [get|accept|dismiss|wait] [timeout]', - helpDescription: 'Inspect or handle platform alerts/dialogs', - summary: 'Inspect or handle platform alerts', - positionalArgs: ['action?', 'timeout?'], - allowedFlags: [], - }, - click: { - usageOverride: 'click ', - helpDescription: 'Tap/click by coordinates, snapshot ref, or selector', - summary: 'Tap by coordinates, ref, or selector', - positionalArgs: ['target'], - allowsExtraPositionals: true, - allowedFlags: [ - 'count', - 'intervalMs', - 'holdMs', - 'jitterPx', - 'doubleTap', - 'clickButton', - ...SELECTOR_SNAPSHOT_FLAGS, - ], - }, - replay: { - helpDescription: 'Replay a recorded session', - positionalArgs: ['path'], - allowedFlags: ['replayUpdate', 'replayMaestro', 'replayEnv', 'timeoutMs'], - skipCapabilityCheck: true, - }, - test: { - usageOverride: 'test ...', - listUsageOverride: 'test ...', - helpDescription: 'Run one or more replay scripts as a serial test suite', - summary: 'Run replay test suites', - positionalArgs: ['pathOrGlob'], - allowsExtraPositionals: true, - allowedFlags: [ - 'replayUpdate', - 'replayMaestro', - 'replayEnv', - 'failFast', - 'timeoutMs', - 'retries', - 'artifactsDir', - 'reportJunit', - ], - skipCapabilityCheck: true, - }, - batch: { - usageOverride: 'batch [--steps | --steps-file ]', - listUsageOverride: 'batch --steps | --steps-file ', - helpDescription: 'Execute multiple commands in one daemon request', - summary: 'Run multiple commands', - positionalArgs: [], - allowedFlags: ['steps', 'stepsFile', 'batchOnError', 'batchMaxSteps', 'out'], - skipCapabilityCheck: true, - }, - press: { - usageOverride: 'press ', - helpDescription: - 'Tap/press by coordinates, snapshot ref, or selector (supports repeated series)', - summary: 'Press by coordinates, ref, or selector', - positionalArgs: ['targetOrX', 'y?'], - allowsExtraPositionals: true, - allowedFlags: [ - 'count', - 'intervalMs', - 'holdMs', - 'jitterPx', - 'doubleTap', - ...SELECTOR_SNAPSHOT_FLAGS, - ], - }, - longpress: { - usageOverride: 'longpress [durationMs]', - helpDescription: 'Long press a coordinate, ref, or selector (iOS and Android)', - summary: 'Long press a target', - positionalArgs: ['targetOrX', 'yOrDurationMs?', 'durationMs?'], - allowsExtraPositionals: true, - allowedFlags: [...SELECTOR_SNAPSHOT_FLAGS], - }, - swipe: { - helpDescription: 'Swipe coordinates with optional repeat pattern', - summary: 'Swipe coordinates', - positionalArgs: ['x1', 'y1', 'x2', 'y2', 'durationMs?'], - allowedFlags: ['count', 'pauseMs', 'pattern'], - }, - gesture: { - usageOverride: 'gesture ...', - listUsageOverride: 'gesture ...', - helpDescription: - 'Run touch gestures: pan [durationMs], fling [distance] [durationMs], pinch [x] [y], rotate [x] [y] [velocity], or transform [durationMs]', - summary: 'Run pan, fling, pinch, rotate, or transform gestures', - positionalArgs: ['pan|fling|pinch|rotate|transform', 'args?'], - allowsExtraPositionals: true, - allowedFlags: [], - skipCapabilityCheck: true, - }, - focus: { - helpDescription: 'Focus input at coordinates', - positionalArgs: ['x', 'y'], - allowedFlags: [], - }, - ...INTERACTION_COMMAND_SCHEMAS, - fill: { - usageOverride: 'fill | fill <@ref|selector> ', - helpDescription: 'Tap then type', - positionalArgs: ['targetOrX', 'yOrText', 'text?'], - allowsExtraPositionals: true, - allowedFlags: [...SELECTOR_SNAPSHOT_FLAGS, 'delayMs'], - }, - scroll: { - usageOverride: 'scroll [amount] [--pixels ]', - helpDescription: 'Scroll in direction, or verify hidden content and scroll toward top/bottom', - summary: 'Scroll in a direction or to an edge', - positionalArgs: ['directionOrEdge', 'amount?'], - allowedFlags: ['pixels'], - }, - 'trigger-app-event': { - usageOverride: 'trigger-app-event [payloadJson]', - helpDescription: 'Trigger app-defined event hook via deep link template', - summary: 'Trigger app event hook', - positionalArgs: ['event', 'payloadJson?'], - allowedFlags: [], - }, - record: { - usageOverride: - 'record start [path] [--fps ] [--quality <5-10>] [--hide-touches] | record stop', - listUsageOverride: 'record start [path] | record stop', - helpDescription: 'Start/stop screen recording', - summary: 'Start or stop screen recording', - positionalArgs: ['start|stop', 'path?'], - allowedFlags: ['fps', 'quality', 'hideTouches'], - }, - ...REACT_NATIVE_COMMAND_SCHEMAS, - trace: { - 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?'], - allowedFlags: [], - skipCapabilityCheck: true, - }, - logs: { - usageOverride: - 'logs path | logs start | logs stop | logs clear [--restart] | logs doctor | logs mark [message...]', - helpDescription: 'Session app log info, start/stop streaming, diagnostics, and markers', - summary: 'Manage session app logs', - positionalArgs: ['path|start|stop|clear|doctor|mark', 'message?'], - allowsExtraPositionals: true, - allowedFlags: ['restart'], - }, - network: { - usageOverride: - 'network dump [limit] [summary|headers|body|all] [--include summary|headers|body|all] | network log [limit] [summary|headers|body|all] [--include summary|headers|body|all]', - helpDescription: 'Dump recent HTTP(s) traffic parsed from the session app log', - summary: 'Show recent HTTP traffic', - positionalArgs: ['dump|log', 'limit?', 'include?'], - allowedFlags: ['networkInclude'], - }, - settings: { - 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?'], - allowedFlags: [], - }, - session: { - usageOverride: 'session list', - helpDescription: 'List active sessions', - positionalArgs: ['list?'], - allowedFlags: [], - skipCapabilityCheck: true, - }, -}; - -const flagDefinitionByName = new Map(); -const flagDefinitionsByKey = new Map(); -for (const definition of FLAG_DEFINITIONS) { - for (const name of definition.names) { - flagDefinitionByName.set(name, definition); - } - const list = flagDefinitionsByKey.get(definition.key); - if (list) list.push(definition); - else flagDefinitionsByKey.set(definition.key, [definition]); -} - -export function getFlagDefinition(token: string): FlagDefinition | undefined { - return flagDefinitionByName.get(token); +export function getCommandSchema(command: string | null): CommandSchema | undefined { + if (!command) return undefined; + return readCommandSchema(command); } -export function getFlagDefinitions(): readonly FlagDefinition[] { - return FLAG_DEFINITIONS; +export function getCliCommandSchema(command: CliCommandName): CommandSchema { + const schema = readCommandSchema(command); + if (!schema) { + throw new Error(`Missing command schema for ${command}`); + } + return schema; } -export function getCommandSchema(command: string | null): CommandSchema | undefined { - if (!command) return undefined; - return COMMAND_SCHEMAS[command]; +function readCommandSchema(command: string): CommandSchema | undefined { + const schemaOnly = getSchemaOnlyCliCommandSchema(command); + if (schemaOnly) return schemaOnly; + const base = COMMAND_SCHEMA_BASES.get(command); + const override = getCliCommandOverride(command); + if (!base) return undefined; + return override ? { ...base, ...override } : base; } export function applyCommandDefaults( @@ -1872,190 +60,3 @@ export function applyCommandDefaults( } return changed; } - -export function getCliCommandNames(): string[] { - return Object.keys(COMMAND_SCHEMAS); -} - -export function getSchemaCapabilityKeys(): string[] { - return Object.entries(COMMAND_SCHEMAS) - .filter(([, schema]) => !schema.skipCapabilityCheck) - .map(([name]) => name) - .sort(); -} - -function formatPositionalArg(arg: string): string { - const optional = arg.endsWith('?'); - const name = optional ? arg.slice(0, -1) : arg; - return optional ? `[${name}]` : `<${name}>`; -} - -function formatCommandListArg(commandName: string, schema: CommandSchema, arg: string): string { - const optional = arg.endsWith('?'); - const name = optional ? arg.slice(0, -1) : arg; - const isChoiceLiteral = /^[a-z-]+(?:\|[a-z-]+)+$/i.test(name); - const isLiteralToken = - isChoiceLiteral || - (schema.usageOverride !== undefined && - schema.usageOverride.startsWith(`${commandName} ${name}`)); - if (optional) { - if (isChoiceLiteral) return `[${name}]`; - if (isLiteralToken) return name; - return `[${name}]`; - } - return isLiteralToken ? name : `<${name}>`; -} - -function buildCommandUsage(commandName: string, schema: CommandSchema): string { - if (schema.usageOverride) return schema.usageOverride; - const positionals = schema.positionalArgs.map(formatPositionalArg); - const flagLabels = schema.allowedFlags.flatMap((key) => - (flagDefinitionsByKey.get(key) ?? []).map( - (definition) => definition.usageLabel ?? definition.names[0], - ), - ); - const optionalFlags = flagLabels.map((label) => `[${label}]`); - return [commandName, ...positionals, ...optionalFlags].join(' '); -} - -function buildCommandListUsage(commandName: string, schema: CommandSchema): string { - if (schema.listUsageOverride) return schema.listUsageOverride; - const positionals = schema.positionalArgs.map((arg) => - formatCommandListArg(commandName, schema, arg), - ); - return [commandName, ...positionals].join(' '); -} - -function renderUsageText(): string { - const header = `agent-device [args] [--json] - -CLI to control iOS and Android devices for AI agents. -`; - - const commands = getCliCommandNames().map((name) => { - const schema = COMMAND_SCHEMAS[name]; - if (!schema) throw new Error(`Missing command schema for ${name}`); - return { name, schema, usage: buildCommandListUsage(name, schema) }; - }); - const commandLines = renderCommandSection(commands); - - const helpFlags = listHelpFlags(GLOBAL_FLAG_KEYS); - const flagsSection = renderFlagSection('Flags:', helpFlags); - const quickstartSection = renderTextSection('Agent Quickstart:', AGENT_QUICKSTART_LINES); - const workflowsSection = renderAlignedSection('Agent Workflows:', AGENT_WORKFLOWS); - const configSection = renderTextSection('Configuration:', CONFIGURATION_LINES); - const environmentSection = renderAlignedSection('Environment:', ENVIRONMENT_LINES); - const examplesSection = renderTextSection('Examples:', EXAMPLE_LINES); - - return `${header} -${commandLines} - -${flagsSection} - -${quickstartSection} - -${workflowsSection} - -${configSection} - -${environmentSection} - -${examplesSection} -`; -} - -const USAGE_TEXT = renderUsageText(); - -export function buildUsageText(): string { - return USAGE_TEXT; -} - -function listHelpFlags(keys: ReadonlySet): FlagDefinition[] { - return FLAG_DEFINITIONS.filter( - (definition) => - keys.has(definition.key) && - definition.usageLabel !== undefined && - definition.usageDescription !== undefined, - ); -} - -function renderFlagSection(title: string, definitions: FlagDefinition[]): string { - return renderAlignedSection( - title, - definitions.map((flag) => ({ - label: flag.usageLabel ?? '', - description: flag.usageDescription ?? '', - })), - ); -} - -function renderAlignedSection( - title: string, - items: ReadonlyArray<{ label: string; description: string }>, -): string { - if (items.length === 0) { - return `${title}\n (none)`; - } - const maxLabelLength = Math.max(...items.map((item) => item.label.length)) + 2; - const lines = [title]; - for (const item of items) { - lines.push(` ${item.label.padEnd(maxLabelLength)}${item.description}`); - } - return lines.join('\n'); -} - -function renderTextSection(title: string, lines: ReadonlyArray): string { - if (lines.length === 0) { - return `${title}\n (none)`; - } - return [title, ...lines.map((line) => ` ${line}`)].join('\n'); -} - -function renderCommandSection( - commands: Array<{ name: string; schema: CommandSchema; usage: string }>, -): string { - return renderAlignedSection( - 'Commands:', - commands.map((command) => ({ - label: command.usage, - description: command.schema.summary ?? command.schema.helpDescription, - })), - ); -} - -export function buildCommandUsageText(commandName: string): string | null { - const topicHelp = buildHelpTopicUsageText(commandName); - if (topicHelp) return topicHelp; - const schema = getCommandSchema(commandName); - if (!schema) return null; - const usage = buildCommandUsage(commandName, schema); - const commandFlags = listHelpFlags(new Set(schema.allowedFlags)); - const globalFlags = listHelpFlags(GLOBAL_FLAG_KEYS); - const sections: string[] = []; - if (commandFlags.length > 0) { - sections.push(renderFlagSection('Command flags:', commandFlags)); - } - sections.push(renderFlagSection('Global flags:', globalFlags)); - - return `agent-device ${usage} - -${schema.helpDescription} - -Usage: - agent-device ${usage} - -${sections.join('\n\n')} -`; -} - -function buildHelpTopicUsageText(topicName: string): string | null { - const topic = HELP_TOPICS[topicName as keyof typeof HELP_TOPICS]; - if (!topic) return null; - return `${topic.body} - -Related: - agent-device help command list and global flags - agent-device help command-specific flags - agent-device help workflow normal app automation loop -`; -} diff --git a/test/integration/provider-scenarios/android-lifecycle.test.ts b/test/integration/provider-scenarios/android-lifecycle.test.ts index b4dc93d42..219c273ca 100644 --- a/test/integration/provider-scenarios/android-lifecycle.test.ts +++ b/test/integration/provider-scenarios/android-lifecycle.test.ts @@ -1010,8 +1010,7 @@ async function runAndroidCaptureInteractionAndReplayWorkflow( steps: [ { command: 'press', - positionals: ['10', '20'], - flags: { count: 2, intervalMs: 1 }, + input: { target: { kind: 'point', x: 10, y: 20 }, count: 2, intervalMs: 1 }, }, ], onError: 'stop', diff --git a/test/skillgym/suites/agent-device-smoke-suite.ts b/test/skillgym/suites/agent-device-smoke-suite.ts index a599d244d..7bb0bf540 100644 --- a/test/skillgym/suites/agent-device-smoke-suite.ts +++ b/test/skillgym/suites/agent-device-smoke-suite.ts @@ -1823,17 +1823,17 @@ const SKILL_GUIDANCE_CASES: Case[] = [ ], }), makeCase({ - id: 'batch-inline-step-schema-positionals', + id: 'batch-inline-step-schema-input', contract: [ 'Need one inline batch command', 'Step 1: open settings', 'Step 2: wait 100 ms', - 'Batch step schema supports command, positionals, flags, and runtime', + 'Batch step schema supports command, input, and runtime', 'The args field is invalid and must not be used', ], - task: 'Plan the batch command with inline JSON steps using the supported step field for positional arguments.', - outputs: [plannedCommand('batch'), /--steps/i, /"positionals"\s*:/i, /"open"/i, /"wait"/i], - forbiddenOutputs: [/"args"\s*:/i, /workflow batch/i], + task: 'Plan the batch command with inline JSON steps using the supported structured input field.', + outputs: [plannedCommand('batch'), /--steps/i, /"input"\s*:/i, /"open"/i, /"wait"/i], + forbiddenOutputs: [/"args"\s*:/i, /"positionals"\s*:/i, /workflow batch/i], }), ]; diff --git a/website/docs/docs/agent-setup.md b/website/docs/docs/agent-setup.md index c509e3a8b..8d4c76837 100644 --- a/website/docs/docs/agent-setup.md +++ b/website/docs/docs/agent-setup.md @@ -9,7 +9,7 @@ description: Configure Cursor, Codex, Claude Code, Windsurf, Cline, Goose, skill Use this page to wire Cursor, Codex, Claude Code, Windsurf, Cline, Goose, or another coding agent into mobile, TV, and desktop app verification. It covers skills, project rules, and MCP setup for React Native QA, Expo app verification, iOS Simulator automation, Android Emulator automation, tvOS checks, Android TV checks, debugging, profiling, and exploratory QA. -The short version: install the CLI, make the agent read version-matched help, and let the agent run CLI commands in a terminal. MCP is available for discovery and help, not broad device control. +The short version: install the CLI, make the agent read version-matched help, and let the agent use either MCP tools or CLI commands. MCP tools use command contracts backed by the same `AgentDeviceClient` execution path as the CLI adapters. ## Prerequisite: install the CLI @@ -49,14 +49,16 @@ Add this as a project rule, custom instruction, or skill equivalent when your ag ```text Use agent-device only for app/device automation tasks. Before planning commands, run `agent-device --version` and read `agent-device help workflow`. For exploratory QA, read `agent-device help dogfood`. For logs, network, traces, or runtime failures, read `agent-device help debugging`. For React Native component trees, props/state/hooks, slow renders, or rerenders, read `agent-device help react-devtools`. For React Native apps, overlays, Metro/Fast Refresh blockers, and routing to React DevTools or debugging evidence, read `agent-device help react-native`. -Use the CLI in the integrated terminal. If `agent-device` is not on PATH but the user installed it globally in another shell, resolve the command the same way the user would from a normal terminal session and run that absolute path instead. This may require inspecting shell startup behavior or package-manager/global bin locations; do not assume the agent process `PATH` is the user's `PATH`. Do not silently fall back to `npx -y agent-device@latest`; ask or use an exact version. MCP is discovery-only, exposes only status handoff metadata, and does not expose device automation tools. Prefer `open -> snapshot -i -> act -> re-snapshot -> verify -> close`. Use current refs such as `@e3` for exploration and selectors for durable replay. Keep mutating commands against one session serial. Capture screenshots, logs, network, perf, traces, recordings, and `.ad` replay scripts only when they add evidence. +Use MCP tools or the CLI in the integrated terminal. If `agent-device` is not on PATH but the user installed it globally in another shell, resolve the command the same way the user would from a normal terminal session and run that absolute path instead. This may require inspecting shell startup behavior or package-manager/global bin locations; do not assume the agent process `PATH` is the user's `PATH`. Do not silently fall back to `npx -y agent-device@latest`; ask or use an exact version. MCP exposes structured tools backed by the agent-device client; it does not expose generic shell execution. Prefer `open -> snapshot -i -> act -> re-snapshot -> verify -> close`. Use current refs such as `@e3` for exploration and selectors for durable replay. Keep mutating commands against one session serial. Capture screenshots, logs, network, perf, traces, recordings, and `.ad` replay scripts only when they add evidence. ``` -## MCP router +## MCP server -`agent-device mcp` starts the official stdio MCP router for discovery-oriented clients. It exposes only a `status` tool that returns structured CLI handoff guidance: npm package name, installed version, CLI command name, install command, verify command, starting help command, and an explicit note that automation happens through the CLI. +`agent-device mcp` starts the official stdio MCP server. It exposes direct structured tools for installed CLI commands. Tools run through command contracts and `AgentDeviceClient`; local-only workflows stay CLI-only rather than subprocess fallbacks. -MCP clients must not use this server as a device automation surface or generic shell runner. If the CLI is missing, agents should ask a human before installing or updating packages, then verify with `agent-device --version` and start with `agent-device help workflow`. +Tool execution failures are returned as MCP tool results with `isError: true`; clients and agents should inspect the tool result, not only the successful JSON-RPC envelope. + +MCP clients must not use this server as a generic shell runner. If the CLI is missing, agents should ask a human before installing or updating packages, then verify with `agent-device --version` and start with `agent-device help workflow`. Global install configuration: @@ -88,16 +90,80 @@ Registry metadata uses MCP name `io.github.callstackincubator/agent-device`, npm ## Cursor -Use Agent mode with the integrated terminal. Add the recommended rule above as a project rule, then run: +Cursor works well with either the plain CLI or MCP tools. Use the CLI path when you want the most auditable setup and terminal-visible commands. Add MCP when you want Cursor Agent to discover structured `agent-device` tools directly from chat. + +### Cursor path A: CLI only + +Create a project rule: ```bash +mkdir -p .cursor/rules +cat > .cursor/rules/agent-device.mdc <<'EOF' +--- +description: Use agent-device for app and device automation +alwaysApply: true +--- + +Use agent-device only for app/device automation tasks. +Before planning device work, run `agent-device --version` and read `agent-device help workflow`. +For exploratory QA, read `agent-device help dogfood`. +For logs, network, traces, or runtime failures, read `agent-device help debugging`. +For React Native component trees, props/state/hooks, slow renders, or rerenders, read `agent-device help react-devtools`. +For React Native apps, overlays, Metro/Fast Refresh blockers, and routing to React DevTools or debugging evidence, read `agent-device help react-native`. + +Use the CLI in Cursor's integrated terminal. +If `agent-device` is not on PATH but the user installed it globally in another shell, resolve the absolute binary path instead of using `npx -y agent-device@latest`. +Prefer `open -> snapshot -i -> act -> re-snapshot -> verify -> close`. +Keep mutating commands against one session serial. +EOF +``` + +Then ask Cursor Agent to run: + +```bash +agent-device --version agent-device help workflow agent-device apps --platform ios agent-device open --platform ios agent-device snapshot -i ``` -Optional: paste the [MCP router](#mcp-router) configuration into `.cursor/mcp.json`. +### Cursor path B: MCP tools + +Create project MCP config: + +```bash +mkdir -p .cursor +cat > .cursor/mcp.json <<'JSON' +{ + "mcpServers": { + "agent-device": { + "command": "agent-device", + "args": ["mcp"] + } + } +} +JSON +``` + +Restart Cursor or reconnect MCP from Cursor settings, then ask Cursor Agent: + +```text +Use the agent-device MCP tools to inspect the iOS app. Open the app, take an interactive snapshot, act on visible refs/selectors, verify with another snapshot, and close the session. +``` + +If the MCP server fails because Cursor cannot find the global binary, use the absolute binary path in `.cursor/mcp.json`: + +```json +{ + "mcpServers": { + "agent-device": { + "command": "/absolute/path/to/agent-device", + "args": ["mcp"] + } + } +} +``` ## Codex @@ -116,7 +182,31 @@ For reviews or planning-only tasks, tell the agent not to run devices unless exp ## Claude Code -Use the bundled skill when your Claude setup supports skills. Otherwise put the recommended rule in `CLAUDE.md`. +Claude Code works through the terminal CLI and through the VS Code extension panel. The VS Code extension can use MCP servers configured by the Claude CLI and managed with `/mcp`. + +### Claude path A: CLI only + +Put this in `CLAUDE.md`: + +```bash +cat > CLAUDE.md <<'EOF' +# agent-device + +Use agent-device only for app/device automation tasks. +Before planning device work, run `agent-device --version` and read `agent-device help workflow`. +For exploratory QA, read `agent-device help dogfood`. +For logs, network, traces, or runtime failures, read `agent-device help debugging`. +For React Native component trees, props/state/hooks, slow renders, or rerenders, read `agent-device help react-devtools`. +For React Native apps, overlays, Metro/Fast Refresh blockers, and routing to React DevTools or debugging evidence, read `agent-device help react-native`. + +Use the CLI in the integrated terminal. +If `agent-device` is not on PATH but the user installed it globally in another shell, resolve the absolute binary path instead of using `npx -y agent-device@latest`. +Prefer `open -> snapshot -i -> act -> re-snapshot -> verify -> close`. +Keep mutating commands against one session serial. +EOF +``` + +Then ask Claude Code to run: ```bash agent-device --version @@ -125,17 +215,51 @@ agent-device help dogfood agent-device help react-native ``` -If you configure MCP, keep using CLI commands for automation. The MCP router gives Claude discovery/status handoff metadata only. +### Claude path B: MCP tools + +Add a user-scoped server: + +```bash +claude mcp add --transport stdio --scope user agent-device -- agent-device mcp +claude mcp list +``` + +Or add it to the current project so teammates can review the generated `.mcp.json`: + +```bash +claude mcp add --transport stdio --scope project agent-device -- agent-device mcp +``` + +In Claude Code or the VS Code extension, run: + +```text +/mcp +``` + +Confirm `agent-device` is connected, then ask: + +```text +Use the agent-device MCP tools to verify the app. Open the app, take an interactive snapshot, use refs/selectors for actions, verify with another snapshot, and close the session. +``` + +If Claude cannot start the MCP server because the extension process cannot find the global binary, remove and re-add it with an absolute path: + +```bash +claude mcp remove agent-device +claude mcp add --transport stdio --scope user agent-device -- /absolute/path/to/agent-device mcp +``` + +The same CLI commands remain available in the integrated terminal for long-running or manual workflows. ## Windsurf, Cline, Goose, and other MCP clients -Use the [MCP router](#mcp-router) configuration when the client supports `mcpServers`, then tell the agent to run device commands through the terminal. +Use the [MCP server](#mcp-server) configuration when the client supports `mcpServers`, then tell the agent to use MCP tools or terminal CLI commands for device workflows. If the client has project rules or custom instructions, add the recommended agent rule above. If it does not, start the conversation by asking the agent to run `agent-device help workflow` before planning. ## Why this setup works -The CLI stays the auditable automation surface, installed help stays version-matched with the commands, skills and rules route agents toward the right help topics, and MCP gives discovery-oriented clients a small status handoff entry point. +The CLI stays the auditable automation surface, installed help stays version-matched with the commands, skills and rules route agents toward the right help topics, and MCP gives compatible clients direct structured tools backed by the same daemon/client implementation. For the broader positioning, supported targets, observability features, and how `agent-device` differs from scripted test frameworks, see [Introduction](/docs/introduction). For exact command groups and platform behavior, see [Commands](/docs/commands). diff --git a/website/docs/docs/batching.md b/website/docs/docs/batching.md index a8440b7a7..427278240 100644 --- a/website/docs/docs/batching.md +++ b/website/docs/docs/batching.md @@ -24,7 +24,7 @@ agent-device batch \ Inline for small payloads: ```bash -agent-device batch --steps '[{"command":"open","positionals":["settings"]},{"command":"wait","positionals":["100"]}]' +agent-device batch --steps '[{"command":"open","input":{"app":"settings"}},{"command":"wait","input":{"kind":"duration","durationMs":100}}]' ``` ## Step payload format @@ -33,18 +33,27 @@ agent-device batch --steps '[{"command":"open","positionals":["settings"]},{"com ```json [ - { "command": "open", "positionals": ["settings"], "flags": {} }, - { "command": "wait", "positionals": ["label=\"Privacy & Security\"", "3000"], "flags": {} }, - { "command": "click", "positionals": ["label=\"Privacy & Security\""], "flags": {} }, - { "command": "get", "positionals": ["text", "label=\"Tracking\""], "flags": {} } + { "command": "open", "input": { "app": "settings" } }, + { + "command": "wait", + "input": { "kind": "selector", "selector": "label=\"Privacy & Security\"", "timeoutMs": 3000 } + }, + { + "command": "click", + "input": { "target": { "kind": "selector", "selector": "label=\"Privacy & Security\"" } } + }, + { + "command": "get", + "input": { "format": "text", "target": { "kind": "selector", "selector": "label=\"Tracking\"" } } + } ] ``` Notes: -- `positionals` is optional (defaults to `[]`). -- `flags` is optional (defaults to `{}`). -- Unknown top-level step fields are rejected. Supported keys are `command`, `positionals`, `flags`, and `runtime`. +- `input` is required and uses the same fields as the matching MCP/Node command. +- Unknown top-level step fields are rejected. Supported keys are `command`, `input`, and `runtime`. +- CLI `--steps` and `--steps-file` still accept the legacy `positionals`/`flags` step shape with a deprecation warning. That compatibility path will be removed in the next major version. - nested `batch` and `replay` steps are rejected. - `--on-error stop` is the supported behavior. @@ -82,7 +91,6 @@ Failure: "details": { "step": 3, "command": "click", - "positionals": ["label=\"Privacy & Security\""], "executed": 2, "total": 4, "partialResults": [ @@ -109,17 +117,19 @@ Open app -> open thread -> type -> send ```json [ - { "command": "open", "positionals": ["com.example.chat"], "flags": { "platform": "android" } }, - { "command": "wait", "positionals": ["text", "Inbox", "3000"], "flags": {} }, - { "command": "press", "positionals": ["label=\"Inbox\" role=button"], "flags": {} }, - { "command": "press", "positionals": ["label=\"Morgan Lee\""], "flags": {} }, + { "command": "open", "input": { "app": "com.example.chat", "platform": "android" } }, + { "command": "wait", "input": { "kind": "text", "text": "Inbox", "timeoutMs": 3000 } }, + { "command": "press", "input": { "target": { "kind": "selector", "selector": "label=\"Inbox\" role=button" } } }, + { "command": "press", "input": { "target": { "kind": "selector", "selector": "label=\"Morgan Lee\"" } } }, { "command": "fill", - "positionals": ["label=\"Message\" role=text-field", "sent the update"], - "flags": {} + "input": { + "target": { "kind": "selector", "selector": "label=\"Message\" role=text-field" }, + "text": "sent the update" + } }, - { "command": "press", "positionals": ["label=\"Send\" role=button"], "flags": {} }, - { "command": "wait", "positionals": ["text", "sent the update", "3000"], "flags": {} } + { "command": "press", "input": { "target": { "kind": "selector", "selector": "label=\"Send\" role=button" } } }, + { "command": "wait", "input": { "kind": "text", "text": "sent the update", "timeoutMs": 3000 } } ] ``` @@ -127,13 +137,16 @@ Open app -> open action menu -> choose option -> verify ```json [ - { "command": "open", "positionals": ["com.example.app"], "flags": { "platform": "android" } }, - { "command": "wait", "positionals": ["text", "Home", "3000"], "flags": {} }, - { "command": "press", "positionals": ["label=\"More actions\" role=button"], "flags": {} }, - { "command": "wait", "positionals": ["text", "Scan document", "2000"], "flags": {} }, - { "command": "press", "positionals": ["label=\"Scan document\""], "flags": {} }, - { "command": "wait", "positionals": ["text", "Document uploaded", "15000"], "flags": {} }, - { "command": "is", "positionals": ["visible", "label=\"Document uploaded\""], "flags": {} } + { "command": "open", "input": { "app": "com.example.app", "platform": "android" } }, + { "command": "wait", "input": { "kind": "text", "text": "Home", "timeoutMs": 3000 } }, + { + "command": "press", + "input": { "target": { "kind": "selector", "selector": "label=\"More actions\" role=button" } } + }, + { "command": "wait", "input": { "kind": "text", "text": "Scan document", "timeoutMs": 2000 } }, + { "command": "press", "input": { "target": { "kind": "selector", "selector": "label=\"Scan document\"" } } }, + { "command": "wait", "input": { "kind": "text", "text": "Document uploaded", "timeoutMs": 15000 } }, + { "command": "is", "input": { "predicate": "visible", "selector": "label=\"Document uploaded\"" } } ] ``` diff --git a/website/docs/docs/client-api.md b/website/docs/docs/client-api.md index 1c5e99393..caacd7342 100644 --- a/website/docs/docs/client-api.md +++ b/website/docs/docs/client-api.md @@ -27,7 +27,7 @@ Public subpath API exposed for Node consumers: - `DEFAULT_BATCH_MAX_STEPS` - `BATCH_BLOCKED_COMMANDS` - `INHERITED_PARENT_FLAG_KEYS` - - types: `BatchInvoke`, `BatchRequest`, `BatchStep`, `BatchStepResult`, `NormalizedBatchStep` + - types: `BatchInvoke`, `BatchRequest`, `DaemonBatchStep`, `BatchStepResult`, `NormalizedBatchStep` - `agent-device/remote-config` - `resolveRemoteConfigPath(options)` - `resolveRemoteConfigProfile(options)` @@ -257,6 +257,11 @@ Additional CLI-backed methods are exposed on their domain groups with typed opti `client.recording.record({ action: 'start', path, quality: 5 })` starts a smaller 50% resolution video; omit `quality` to keep native/current resolution. +`client.batch.run({ steps })` accepts structured steps: +`{ command: 'open', input: { app: 'settings' } }`. Step `input` uses the same fields as the +matching client command; daemon-shaped `positionals`/`flags` steps are internal to the daemon batch +executor. + ## Batch orchestration for custom transports Use `agent-device/batch` when a bridge or in-process runner receives daemon-shaped requests but owns command dispatch itself. The helper keeps validation, inherited flags, serial execution, partial results, and error envelopes aligned with the daemon batch command. diff --git a/website/docs/docs/commands.md b/website/docs/docs/commands.md index 53d0d83aa..6d4d26f5a 100644 --- a/website/docs/docs/commands.md +++ b/website/docs/docs/commands.md @@ -23,13 +23,13 @@ agent-device help dogfood Skills are recommended for auto-routing when your agent runtime supports them, but they are not required. The CLI help topics are the version-matched operating contract. -For MCP-aware clients that need discovery instead of direct device control, run: +For MCP-aware clients that support direct tools, run: ```bash agent-device mcp ``` -The MCP router exposes only a `status` tool with CLI install, verify, and starting-help guidance. It does not expose device automation or generic shell execution over MCP. +The MCP server exposes direct structured tools for installed commands. Tools use structured input contracts through `AgentDeviceClient`; local-only workflows stay CLI-only rather than subprocess fallbacks. It does not expose generic shell execution over MCP. ## Navigation @@ -356,11 +356,13 @@ See [Replay & E2E](/docs/replay-e2e) for recording, Maestro compatibility, and C ```bash agent-device batch --steps-file /tmp/batch-steps.json --json -agent-device batch --steps '[{"command":"open","positionals":["settings"]}]' +agent-device batch --steps '[{"command":"open","input":{"app":"settings"}}]' ``` - `batch` runs a JSON array of steps in a single daemon request. -- Each step has `command`, optional `positionals`, optional `flags`, and optional `runtime`. +- Each step has `command`, `input`, and optional `runtime`. +- `input` uses the same fields as the matching MCP/Node command. +- Legacy CLI step payloads with `positionals`/`flags` still run with a deprecation warning and will be removed in the next major version. - Unknown top-level step fields are rejected. - Stop-on-first-error is the supported behavior (`--on-error stop`). - Use `--max-steps ` to tighten per-request safety limits. diff --git a/website/docs/docs/installation.md b/website/docs/docs/installation.md index 07fa8eb43..7aa516ee0 100644 --- a/website/docs/docs/installation.md +++ b/website/docs/docs/installation.md @@ -38,13 +38,13 @@ Set `AGENT_DEVICE_NO_UPDATE_NOTIFIER=1` to disable the notice. ## Agent clients and MCP -The official MCP router is discovery-only. It exposes a `status` tool with the package name, installed version, CLI command name, install command, verify command, and starting help command, while app and device automation remains explicit CLI activity in the terminal. +The official MCP server exposes direct structured tools for installed `agent-device` commands. Tools use command contracts through `AgentDeviceClient`, so app and device automation still uses the same daemon implementation. ```bash agent-device mcp ``` -Use [AI Agent Setup](/docs/agent-setup#mcp-router) for copy-paste MCP client configuration. +Use [AI Agent Setup](/docs/agent-setup#mcp-server) for copy-paste MCP client configuration. ## Without installing diff --git a/website/docs/docs/introduction.md b/website/docs/docs/introduction.md index 0f36b1382..d772752ef 100644 --- a/website/docs/docs/introduction.md +++ b/website/docs/docs/introduction.md @@ -56,7 +56,7 @@ Use [AI Agent Setup](/docs/agent-setup) for Cursor, Codex, Claude Code, Windsurf It complements scripted test frameworks such as Appium, Maestro, Detox, XCTest, and Espresso. Keep those for stable human-authored coverage. Use `agent-device` when an agent needs to explore, reproduce, debug, profile, collect evidence, or record a replay from live app behavior. -MCP support is discovery-only and returns status handoff metadata for installing, verifying, and starting with the CLI. App and device automation remains explicit CLI activity in the terminal. +MCP support exposes direct structured tools for installed `agent-device` commands. Tools use structured input contracts through `AgentDeviceClient`, so MCP clients can call device workflows directly while the daemon remains the execution source of truth. ## Next steps diff --git a/website/docs/docs/quick-start.md b/website/docs/docs/quick-start.md index 1af1e705d..1c217ee8b 100644 --- a/website/docs/docs/quick-start.md +++ b/website/docs/docs/quick-start.md @@ -91,11 +91,20 @@ Example batch payload for a known chat flow: ```json [ - { "command": "open", "positionals": ["ChatApp"], "flags": { "platform": "android" } }, - { "command": "click", "positionals": ["label=\"Travel chat\""], "flags": {} }, - { "command": "wait", "positionals": ["label=\"Message\"", "3000"], "flags": {} }, - { "command": "fill", "positionals": ["label=\"Message\"", "Sent the update"], "flags": {} }, - { "command": "press", "positionals": ["label=\"Send\""], "flags": {} } + { "command": "open", "input": { "app": "ChatApp", "platform": "android" } }, + { "command": "click", "input": { "target": { "kind": "selector", "selector": "label=\"Travel chat\"" } } }, + { + "command": "wait", + "input": { "kind": "selector", "selector": "label=\"Message\"", "timeoutMs": 3000 } + }, + { + "command": "fill", + "input": { + "target": { "kind": "selector", "selector": "label=\"Message\"" }, + "text": "Sent the update" + } + }, + { "command": "press", "input": { "target": { "kind": "selector", "selector": "label=\"Send\"" } } } ] ``` diff --git a/website/docs/docs/security-trust.md b/website/docs/docs/security-trust.md index 0d34140b3..86f9ff965 100644 --- a/website/docs/docs/security-trust.md +++ b/website/docs/docs/security-trust.md @@ -10,7 +10,7 @@ description: Security and trust guidance for agent-device local app automation, ## Local control - Device automation runs through the installed CLI and platform tooling such as Xcode, ADB, macOS accessibility APIs, and Linux AT-SPI. -- The MCP server is discovery-only. It exposes a single status tool with CLI handoff metadata; it does not expose device automation or generic shell execution over MCP. +- The MCP server exposes direct structured tools for `agent-device` commands. Tools use command contracts through `AgentDeviceClient`; local-only workflows stay CLI-only rather than subprocess fallbacks. It does not expose generic shell execution over MCP. - Mutating commands should run serially against one session. Use separate sessions/devices for parallel work. ## Sensitive artifacts