Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"name": "agent-device",
"version": "0.20.5",
"description": "Agent-native CLI for AI app automation across iOS, Android, tvOS, Android TV, macOS, Linux, and web.",
"mcpName": "io.github.callstackincubator/agent-device",
"mcpName": "io.github.callstack/agent-device",
"license": "MIT",
"author": "Callstack",
"homepage": "https://agent-device.dev/",
Expand Down
2 changes: 1 addition & 1 deletion server.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
"name": "io.github.callstackincubator/agent-device",
"name": "io.github.callstack/agent-device",
"title": "agent-device",
"description": "Let AI agents inspect, control, and debug real iOS, Android, desktop, and TV apps",
"repository": {
Expand Down
113 changes: 56 additions & 57 deletions src/cli-schema/command-overrides.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { CommandName } from '../commands/command-metadata.ts';
import { listCommandFamilyCliSchemas } from '../commands/family/registry.ts';
import type { LocalCliCommandName } from '../command-catalog.ts';
import type { CommandSchema, CommandSchemaOverride } from './types.ts';
import type { CommandSchema } from './types.ts';
import {
COMMON_COMMAND_SUPPORTED_FLAG_KEYS,
METRO_PREPARE_FLAGS,
Expand All @@ -11,53 +11,61 @@ type SchemaOnlyCliCommandName = Exclude<LocalCliCommandName, CommandName>;

const SCHEMA_ONLY_CLI_COMMAND_SCHEMAS = {
cdp: {
text: {
summary: 'Inspect CDP targets, JS heap, and leaks',
description:
'Run CDP commands for React Native diagnostics, JS heap usage, heap snapshots, and leak analysis',
},
usageOverride: 'cdp [...args]',
listUsageOverride: 'cdp',
helpDescription:
'Run CDP commands for React Native diagnostics, JS heap usage, heap snapshots, and leak analysis',
summary:
'Inspect React Native CDP targets, JS heap growth, heap snapshots, retainers, and leak signals',
positionalArgs: ['args?'],
allowsExtraPositionals: true,
supportedFlags: COMMON_COMMAND_SUPPORTED_FLAG_KEYS,
},
auth: {
text: {
summary: 'Manage cloud login state',
description: 'Manage cloud login state used by remote daemon and cloud device workflows.',
},
usageOverride: 'auth status|login|logout',
listUsageOverride: 'auth',
helpDescription: 'Manage cloud login state used by remote daemon and cloud device workflows.',
summary: 'Manage cloud login state used by remote daemon and cloud device workflows',
positionalArgs: ['status|login|logout'],
supportedFlags: ['remoteConfig', 'stateDir'],
},
daemon: {
text: {
summary: 'Stop a local daemon and clean up resources',
description:
'Stop a local daemon after verifying its PID/start-time identity. Use --clean to remove retained Apple runner processes and leases owned by that daemon.',
},
usageOverride: 'daemon stop [--state-dir <path>] [--clean]',
listUsageOverride: 'daemon stop',
helpDescription:
'Stop a local daemon after verifying its PID/start-time identity. Use --clean to remove retained Apple runner processes and leases owned by that daemon.',
summary: 'Safely stop a local daemon and optionally clean retained runner resources',
positionalArgs: ['stop'],
allowedFlags: ['clean'],
supportedFlags: ['stateDir'],
},
device: {
text: {
summary: 'Inspect local advisory device ownership without daemon side effects',
description:
'Inspect advisory host-local device ownership claims without starting or contacting a daemon. --stale only inspects proven-stale claims; it does not reclaim claims or clean platform resources.',
},
usageOverride:
'device status [--platform <platform>] [--udid <udid>] [--serial <serial>] [--stale]',
listUsageOverride: 'device status',
helpDescription:
'Inspect advisory host-local device ownership claims without starting or contacting a daemon. --stale only inspects proven-stale claims; it does not reclaim claims or clean platform resources.',
summary: 'Inspect local advisory device ownership without daemon side effects',
positionalArgs: ['status'],
allowedFlags: ['stale'],
supportedFlags: ['platform', 'device', 'udid', 'serial'],
},
connect: {
text: {
summary: 'Configure remote or provider state',
description:
'Configure remote access without allocating a device. Direct providers validate credentials/resources before saving state and print the exact device/app preparation needed before open. AGENT_DEVICE_CLOUD_BASE_URL is the bridge/control-plane API origin; use AGENT_DEVICE_DAEMON_AUTH_TOKEN=adc_live_... for CI/service-token automation.',
},
usageOverride:
'connect [cloud|proxy|limrun|browserstack|aws-device-farm] [--remote-config <path>] [--daemon-base-url <url>] [--tenant <id>] [--run-id <id>] [--lease-id <id>] [--lease-backend <backend>] [--force] [--no-login]',
helpDescription:
'Configure remote access without allocating a device. Direct providers validate credentials/resources before saving state and print the exact device/app preparation needed before open. AGENT_DEVICE_CLOUD_BASE_URL is the bridge/control-plane API origin; use AGENT_DEVICE_DAEMON_AUTH_TOKEN=adc_live_... for CI/service-token automation.',
listUsageOverride: 'connect',
summary:
'Configure remote/provider state without allocating a live device; direct providers verify access',
positionalArgs: ['provider?'],
allowedFlags: [
'remoteConfig',
Expand Down Expand Up @@ -98,79 +106,70 @@ const SCHEMA_ONLY_CLI_COMMAND_SCHEMAS = {
],
},
connection: {
text: {
summary: 'Inspect the saved remote connection',
description: 'Inspect active remote connection state',
},
usageOverride: 'connection status',
listUsageOverride: 'connection',
helpDescription: 'Inspect active remote connection state',
summary: 'Inspect the active saved remote connection before assuming commands are local',
positionalArgs: ['status'],
supportedFlags: ['remoteConfig', 'stateDir', 'session'],
},
disconnect: {
helpDescription:
'Disconnect remote daemon state, stop owned Metro companion, and release lease',
text: {
summary: 'Clear remote connection state and release leases',
description: 'Disconnect remote daemon state, stop owned Metro companion, and release lease',
},
listUsageOverride: 'disconnect',
summary:
'Clear remote connection state, stop owned Metro companions, and release remote leases',
allowedFlags: ['shutdown'],
supportedFlags: ['remoteConfig', 'stateDir', 'session'],
},
mcp: {
helpDescription:
'Start the official stdio MCP server. It exposes structured command tools backed by the agent-device client.',
summary: 'Start MCP server',
text: {
summary: 'Start MCP server',
description:
'Start the official stdio MCP server. It exposes structured command tools backed by the agent-device client.',
},
},
proxy: {
text: {
summary: 'Expose a local daemon through an HTTP tunnel',
description:
'Expose the local daemon HTTP contract through a tunnel-friendly reverse proxy.\n\nRun this on the host that has access to simulators/devices, expose the printed local proxy URL through a tunnel, then point another machine at the tunnel URL with connect proxy.\n\nThe proxy starts or reuses a local HTTP daemon, accepts /health, /rpc, /upload and resumable /upload/* routes, and /artifacts plus /artifacts/*, and also accepts the same routes under /agent-device/*. Health is unauthenticated for reachability probes. Other routes require the generated bearer token printed at startup, or the explicit --daemon-auth-token value when provided. The proxy rewrites authorized client requests to the upstream daemon token instead of exposing the local daemon token.\n\nUse the /agent-device base path when connecting through cloudflared, ngrok, or another shared origin. Treat the bearer token as a secret; anyone with it can control the proxied daemon. This direct proxy flow does not use agent-device auth.\n\nExamples:\n agent-device proxy --port 4310\n cloudflared tunnel --url http://127.0.0.1:4310\n agent-device connect proxy --daemon-base-url https://example.trycloudflare.com/agent-device --daemon-auth-token <token>',
},
usageOverride:
'proxy [--host <host>] [--port <port>] [--daemon-auth-token <token>] [--state-dir <path>]',
listUsageOverride: 'proxy',
helpDescription: `Expose the local daemon HTTP contract through a tunnel-friendly reverse proxy.

Run this on the host that has access to simulators/devices, expose the printed local proxy URL through a tunnel, then point another machine at the tunnel URL with connect proxy.

The proxy starts or reuses a local HTTP daemon, accepts /health, /rpc, /upload and resumable /upload/* routes, and /artifacts plus /artifacts/*, and also accepts the same routes under /agent-device/*. Health is unauthenticated for reachability probes. Other routes require the generated bearer token printed at startup, or the explicit --daemon-auth-token value when provided. The proxy rewrites authorized client requests to the upstream daemon token instead of exposing the local daemon token.

Use the /agent-device base path when connecting through cloudflared, ngrok, or another shared origin. Treat the bearer token as a secret; anyone with it can control the proxied daemon. This direct proxy flow does not use agent-device auth.

Examples:
agent-device proxy --port 4310
cloudflared tunnel --url http://127.0.0.1:4310
agent-device connect proxy --daemon-base-url https://example.trycloudflare.com/agent-device --daemon-auth-token <token>`,
summary: 'Expose a local daemon through cloudflared, ngrok, or another HTTP tunnel',
allowedFlags: ['proxyHost', 'proxyPort', 'daemonAuthToken', 'stateDir'],
},
'react-devtools': {
text: {
summary: 'Inspect components, hooks, and render profiles',
description:
'Run pinned agent-react-devtools commands for React Native performance profiling, component trees, props/state/hooks, and render analysis',
},
usageOverride: 'react-devtools [...args]',
listUsageOverride: 'react-devtools',
helpDescription:
'Run pinned agent-react-devtools commands for React Native performance profiling, component trees, props/state/hooks, and render analysis',
summary:
'Inspect React Native components, props, hooks, errors, slow renders, and rerender profiles',
positionalArgs: ['args?'],
allowsExtraPositionals: true,
supportedFlags: COMMON_COMMAND_SUPPORTED_FLAG_KEYS,
},
web: {
text: {
summary: 'Manage web automation backend',
description:
'Install and inspect the managed web automation backend used by --platform web.\n\nFirst-run flow:\n agent-device web setup\n agent-device open "https://example.com" --platform web\n agent-device snapshot -i --platform web\n agent-device close --platform web\n\nRuntime web commands do not install the backend implicitly. If the managed backend is missing, run agent-device web setup. The backend is resolved only from the managed install in the effective agent-device state dir.\n\nUse web setup to install or reuse the pinned backend. Use web doctor after setup to verify browser backend health.',
},
usageOverride: 'web setup | web doctor',
listUsageOverride: 'web setup|doctor',
helpDescription: `Install and inspect the managed web automation backend used by --platform web.

First-run flow:
agent-device web setup
agent-device open "https://example.com" --platform web
agent-device snapshot -i --platform web
agent-device close --platform web

Runtime web commands do not install the backend implicitly. If the managed backend is missing, run agent-device web setup. The backend is resolved only from the managed install in the effective agent-device state dir.

Use web setup to install or reuse the pinned backend. Use web doctor after setup to verify browser backend health.`,
summary: 'Manage web automation backend',
positionalArgs: ['setup|doctor'],
supportedFlags: ['stateDir'],
},
} as const satisfies Record<SchemaOnlyCliCommandName, CommandSchema>;

// Facet schemas are complete: `defineCommandFacet` attaches the resolved text to every one.
const CLI_COMMAND_OVERRIDES = listCommandFamilyCliSchemas() as Partial<
Record<CommandName, CommandSchemaOverride>
Record<CommandName, CommandSchema>
>;

export function getSchemaOnlyCliCommandSchema(command: string): CommandSchema | undefined {
Expand All @@ -179,7 +178,7 @@ export function getSchemaOnlyCliCommandSchema(command: string): CommandSchema |
: undefined;
}

export function getCliCommandOverride(command: string): CommandSchemaOverride | undefined {
export function getCliCommandOverride(command: string): CommandSchema | undefined {
return Object.hasOwn(CLI_COMMAND_OVERRIDES, command)
? CLI_COMMAND_OVERRIDES[command as keyof typeof CLI_COMMAND_OVERRIDES]
: undefined;
Expand Down
10 changes: 6 additions & 4 deletions src/cli-schema/command-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,12 @@ export type { CliFlags, FlagDefinition, FlagKey };
export type { CommandSchema };
export { getFlagDefinition, getFlagDefinitions, GLOBAL_FLAG_KEYS };

const COMMAND_SCHEMA_BASES = new Map<string, CommandSchema>(
// Bases hold only the flags every command supports; prose arrives with the facet's schema,
// which always carries a complete `text`.
const COMMAND_SCHEMA_BASES = new Map<string, Omit<CommandSchema, 'text'>>(
listCommandMetadata().map((metadata) => [
metadata.name,
{ helpDescription: metadata.description, supportedFlags: COMMON_COMMAND_SUPPORTED_FLAG_KEYS },
{ supportedFlags: COMMON_COMMAND_SUPPORTED_FLAG_KEYS },
]),
);

Expand Down Expand Up @@ -59,8 +61,8 @@ function readCommandSchema(command: string): CommandSchema | undefined {
if (schemaOnly) return schemaOnly;
const base = COMMAND_SCHEMA_BASES.get(command);
const override = getCliCommandOverride(command);
if (!base) return undefined;
return override ? { ...base, ...override } : base;
if (!base || !override) return undefined;
return { ...base, ...override };
}

export function applyCommandDefaults(
Expand Down
13 changes: 10 additions & 3 deletions src/cli-schema/types.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,27 @@
import type { FlagKey } from '../commands/cli-grammar/flag-types.ts';
import type { CliFlags } from '@agent-device/contracts/command';
import type { CommandText } from '../commands/command-text.ts';

/**
* Command grammar plus its resolved text. Prose lives entirely in `text`; everything else here
* describes what the parser accepts, so no field is both authored and generated.
*/
export type CommandSchema = {
helpDescription: string;
summary?: string;
text: CommandText;
positionalArgs?: readonly string[];
allowsExtraPositionals?: boolean;
allowedFlags?: readonly FlagKey[];
supportedFlags?: readonly FlagKey[];
defaults?: Partial<CliFlags>;
/** Replaces the generated synopsis in `--help`, for shapes the generator cannot express. */
usageOverride?: string;
/** Replaces the generated synopsis in the command list, which stays terser than `--help`. */
listUsageOverride?: string;
// Swaps a shared flag's usageDescription for this command only, when the flag's generic
// documentation (flag-definitions-*.ts) does not fit every command it is allowed on — for
// example `--save-script` arms authoring on open/close but a repair transaction on replay.
flagDescriptionOverrides?: Partial<Record<FlagKey, string>>;
};

export type CommandSchemaOverride = Partial<CommandSchema>;
/** Grammar a facet may override. Its prose is authored as the facet's `text`, never here. */
export type CommandSchemaOverride = Partial<Omit<CommandSchema, 'text'>>;
11 changes: 7 additions & 4 deletions src/cli/parser/__tests__/cli-help-command-usage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ test('usageForCommand documents tv-remote longpress preset', async () => {
assert.equal(help === null, false);
assert.match(help ?? '', /agent-device tv-remote \[press\|longpress\]/);
assert.match(help ?? '', /--duration-ms <ms>/);
assert.match(help ?? '', /Use longpress for a 500ms held remote button/);
assert.match(help ?? '', /longpress holds for 500ms by default/);
});

test('usageForCommand supports legacy long-press alias', async () => {
Expand Down Expand Up @@ -244,7 +244,10 @@ test('snapshot command usage documents diff alias', async () => {
if (help === null) throw new Error('Expected command help text');
assert.match(help, /agent-device snapshot \[--diff\]/);
assert.match(help, /--timeout <ms>/);
assert.match(help, /Capture accessibility tree or diff against the previous session baseline/);
assert.match(
help,
/Capture the accessibility tree or compare it with the previous session baseline/,
);
assert.match(help, /inspect rects with snapshot -i --json/);
assert.match(help, /verify with diff snapshot -i or snapshot --diff/);
});
Expand Down Expand Up @@ -314,7 +317,7 @@ test('command usage shows record touch-overlay opt-out flag', async () => {
test('command usage keeps detailed descriptions', async () => {
const help = await usageForCommand('metro');
if (help === null) throw new Error('Expected command help text');
assert.match(help, /Prepare a local React Native dev-server runtime/);
assert.match(help, /Prepare a React Native development server or ask connected apps to reload/);
assert.match(help, /metro reload/);
assert.match(help, /--metro-host <host>/);
assert.match(help, /AGENT_DEVICE_METRO_BEARER_TOKEN/);
Expand Down Expand Up @@ -356,7 +359,7 @@ test('clipboard command usage is documented', async () => {
const help = await usageForCommand('clipboard');
if (help === null) throw new Error('Expected command help text');
assert.match(help, /clipboard read \| clipboard write <text>/);
assert.match(help, /Read or write device clipboard text/);
assert.match(help, /Read the current device clipboard text, or replace its contents/);
});

test('keyboard command usage is documented', async () => {
Expand Down
Loading
Loading