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
1 change: 1 addition & 0 deletions skills/agent-device/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ Escalate only when relevant:
agent-device help debugging
agent-device help react-native
agent-device help react-devtools
agent-device help cdp
agent-device help remote
agent-device help macos
agent-device help dogfood
Expand Down
92 changes: 92 additions & 0 deletions src/__tests__/cli-agent-cdp.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import fs from 'node:fs';
import assert from 'node:assert/strict';
import { afterEach, test, vi } from 'vitest';

vi.mock('../utils/exec.ts', () => ({
runCmdStreaming: vi.fn(),
}));

import { runCmdStreaming } from '../utils/exec.ts';
import {
AGENT_CDP_PACKAGE,
buildAgentCdpNpmExecArgs,
runAgentCdpCommand,
} from '../cli/commands/agent-cdp.ts';

afterEach(() => {
vi.clearAllMocks();
});

test('cdp wrapper pins agent-cdp package version', () => {
assert.equal(AGENT_CDP_PACKAGE, 'agent-cdp@1.6.0');
assert.deepEqual(
buildAgentCdpNpmExecArgs(['memory', 'usage', 'sample', '--label', 'baseline', '--gc']),
[
'exec',
'--yes',
'--package',
'agent-cdp@1.6.0',
'--',
'agent-cdp',
'memory',
'usage',
'sample',
'--label',
'baseline',
'--gc',
],
);
});

test('cdp docs hide the implementation package name', () => {
assert.doesNotMatch(fs.readFileSync('website/docs/docs/commands.md', 'utf8'), /agent-cdp/);
assert.doesNotMatch(
fs.readFileSync('website/docs/docs/debugging-profiling.md', 'utf8'),
/agent-cdp/,
);
});

test('cdp workflow docs live in debugging and profiling guide', () => {
assert.match(
fs.readFileSync('website/docs/docs/commands.md', 'utf8'),
/agent-device cdp memory usage sample --label baseline --gc/,
);
assert.doesNotMatch(
fs.readFileSync('website/docs/docs/commands.md', 'utf8'),
/React Native JS memory through CDP/,
);
assert.match(
fs.readFileSync('website/docs/docs/debugging-profiling.md', 'utf8'),
/React Native JS memory through CDP/,
);
});

test('cdp wrapper streams through npm exec and returns downstream exit code', async () => {
const env = { ...process.env };
vi.mocked(runCmdStreaming).mockResolvedValueOnce({
exitCode: 7,
stdout: '',
stderr: '',
});

const exitCode = await runAgentCdpCommand(['target', 'list'], {
cwd: '/tmp/project',
env,
});

assert.equal(exitCode, 7);
assert.equal(vi.mocked(runCmdStreaming).mock.calls[0]?.[0], 'npm');
assert.deepEqual(vi.mocked(runCmdStreaming).mock.calls[0]?.[1], [
'exec',
'--yes',
'--package',
'agent-cdp@1.6.0',
'--',
'agent-cdp',
'target',
'list',
]);
assert.equal(vi.mocked(runCmdStreaming).mock.calls[0]?.[2]?.cwd, '/tmp/project');
assert.equal(vi.mocked(runCmdStreaming).mock.calls[0]?.[2]?.env, env);
assert.equal(vi.mocked(runCmdStreaming).mock.calls[0]?.[2]?.allowFailure, true);
});
9 changes: 9 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
} from './client.ts';
import { materializeRemoteConnectionForCommand } from './cli/commands/connection-runtime.ts';
import { tryRunClientBackedCommand } from './cli/commands/router.ts';
import { runAgentCdpCommand } from './cli/commands/agent-cdp.ts';
import { runReactDevtoolsCommand } from './cli/commands/react-devtools.ts';
import { runWebCommand } from './cli/commands/web.ts';
import { readCliBatchStepsJson } from './cli/batch-steps.ts';
Expand Down Expand Up @@ -196,6 +197,14 @@ export async function runCli(argv: string[], deps: CliDeps = DEFAULT_CLI_DEPS):
}
let logTailStopper: (() => void) | null = null;
try {
if (command === 'cdp') {
const exitCode = await runAgentCdpCommand(positionals, {
cwd: process.cwd(),
env: process.env,
});
process.exit(exitCode);
return;
}
if (command === 'react-devtools') {
const exitCode = await runReactDevtoolsCommand(positionals, {
flags: effectiveFlags,
Expand Down
32 changes: 32 additions & 0 deletions src/cli/commands/agent-cdp.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { runCmdStreaming } from '../../utils/exec.ts';

const AGENT_CDP_VERSION = '1.6.0';
export const AGENT_CDP_PACKAGE = `agent-cdp@${AGENT_CDP_VERSION}`;
const AGENT_CDP_BIN = 'agent-cdp';

type AgentCdpCommandOptions = {
cwd?: string;
env?: NodeJS.ProcessEnv;
};

export function buildAgentCdpNpmExecArgs(args: string[]): string[] {
return ['exec', '--yes', '--package', AGENT_CDP_PACKAGE, '--', AGENT_CDP_BIN, ...args];
}

export async function runAgentCdpCommand(
args: string[],
options: AgentCdpCommandOptions = {},
): Promise<number> {
const result = await runCmdStreaming('npm', buildAgentCdpNpmExecArgs(args), {
cwd: options.cwd ?? process.cwd(),
env: options.env ?? process.env,
allowFailure: true,
onStdoutChunk: (chunk) => {
process.stdout.write(chunk);
},
onStderrChunk: (chunk) => {
process.stderr.write(chunk);
},
});
return result.exitCode;
}
3 changes: 3 additions & 0 deletions src/command-catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ export const INTERNAL_COMMANDS = {
} as const;

const LOCAL_CLI_COMMANDS = {
cdp: 'cdp',
auth: 'auth',
connect: 'connect',
connection: 'connection',
Expand Down Expand Up @@ -87,6 +88,7 @@ export type ClientBackedCliCommandName =

const MCP_UNEXPOSED_CLI_COMMANDS = commandSet(
LOCAL_CLI_COMMANDS.auth,
LOCAL_CLI_COMMANDS.cdp,
LOCAL_CLI_COMMANDS.connect,
LOCAL_CLI_COMMANDS.connection,
LOCAL_CLI_COMMANDS.disconnect,
Expand All @@ -99,6 +101,7 @@ const MCP_UNEXPOSED_CLI_COMMANDS = commandSet(

const CAPABILITY_EXEMPT_CLI_COMMANDS = commandSet(
LOCAL_CLI_COMMANDS.auth,
LOCAL_CLI_COMMANDS.cdp,
LOCAL_CLI_COMMANDS.connect,
LOCAL_CLI_COMMANDS.connection,
LOCAL_CLI_COMMANDS.debug,
Expand Down
96 changes: 96 additions & 0 deletions src/utils/__tests__/args.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,87 @@ test('parseArgs supports explicit passthrough boundary for react-devtools global
assert.deepEqual(parsed.positionals, ['status', '--json']);
});

test('parseArgs preserves cdp arguments as passthrough positionals', () => {
const parsed = parseArgs(
[
'cdp',
'memory',
'snapshot',
'diff',
'--base',
'ms_1',
'--compare',
'ms_2',
'--limit=10',
'--json',
'--session',
'rn',
],
{ strictFlags: true },
);
assert.equal(parsed.command, 'cdp');
assert.equal(parsed.flags.json, false);
assert.equal(parsed.flags.session, undefined);
assert.deepEqual(parsed.positionals, [
'memory',
'snapshot',
'diff',
'--base',
'ms_1',
'--compare',
'ms_2',
'--limit=10',
'--json',
'--session',
'rn',
]);
});

test('parseArgs preserves cdp help as a downstream flag', () => {
const parsed = parseArgs(['cdp', '--help'], { strictFlags: true });
assert.equal(parsed.command, 'cdp');
assert.equal(parsed.flags.help, false);
assert.deepEqual(parsed.positionals, ['--help']);
});

test('parseArgs accepts agent-device globals before cdp passthrough args', () => {
const parsed = parseArgs(
[
'--session',
'outer-session',
'cdp',
'target',
'list',
'--target',
'Hermes',
'--device',
'rn-app',
'--json',
],
{ strictFlags: true },
);
assert.equal(parsed.command, 'cdp');
assert.equal(parsed.flags.session, 'outer-session');
assert.equal(parsed.flags.json, false);
assert.deepEqual(parsed.positionals, [
'target',
'list',
'--target',
'Hermes',
'--device',
'rn-app',
'--json',
]);
});

test('parseArgs supports explicit passthrough boundary for cdp global flag names', () => {
const parsed = parseArgs(['cdp', '--', 'target', 'list', '--url', 'http://127.0.0.1:8081'], {
strictFlags: true,
});
assert.equal(parsed.command, 'cdp');
assert.deepEqual(parsed.positionals, ['target', 'list', '--url', 'http://127.0.0.1:8081']);
});

test('parseArgs accepts push with payload file', () => {
const parsed = parseArgs(['push', 'com.example.app', './payload.json'], { strictFlags: true });
assert.equal(parsed.command, 'push');
Expand Down Expand Up @@ -1535,6 +1616,18 @@ test('usageForCommand resolves react-devtools help topic', () => {
assert.match(help, /Remote iOS apps attempt the legacy React DevTools websocket/);
});

test('usageForCommand resolves cdp help topic', () => {
const help = usageForCommand('cdp');
if (help === null) throw new Error('Expected cdp help text');
assert.match(help, /agent-device cdp target list --url http:\/\/127\.0\.0\.1:8081/);
assert.match(help, /memory usage sample --label baseline --gc/);
assert.match(help, /memory snapshot leak-triplet --baseline ms_1 --action ms_2 --cleanup ms_3/);
assert.match(help, /memory snapshot retainers --snapshot ms_3 --id <node-id>/);
assert.match(help, /Until cdp has a compact leak report command/);
assert.match(help, /Avoid cdp profile cpu, trace, network, and console by default/);
assert.match(help, /React Native\/Hermes implements a subset of browser CDP/);
});

test('usageForCommand resolves react-native help topic', () => {
const help = usageForCommand('react-native');
if (help === null) throw new Error('Expected react-native help text');
Expand All @@ -1553,6 +1646,8 @@ test('usageForCommand resolves react-native help topic', () => {
assert.match(help, /One simulator cannot run two copies of the same bundle id/);
assert.match(help, /Keep the agent-device react-devtools prefix/);
assert.match(help, /Use help react-devtools for status\/wait/);
assert.match(help, /Keep the agent-device cdp prefix/);
assert.match(help, /Use help cdp for JS heap usage samples/);
assert.match(help, /logs clear --restart/);
assert.match(help, /network dump --include headers/);
assert.match(help, /agent-device open "Agent Device Tester" --platform android/);
Expand Down Expand Up @@ -1794,6 +1889,7 @@ test('usage renders concise commands inline with descriptions', () => {
assert.match(help, / prepare\s{2,}Pre-warm platform helpers/);
assert.match(help, / metro\s{2,}Prepare Metro reachability for React Native\/Expo apps/);
assert.match(help, / perf\s{2,}Check runtime metrics, frames, memory, CPU profiles/);
assert.match(help, / cdp\s{2,}Inspect React Native CDP targets, JS heap growth/);
assert.match(help, / react-devtools\s{2,}Inspect React Native components, props, hooks/);
assert.match(help, / proxy\s{2,}Expose a local daemon through cloudflared, ngrok/);
assert.match(help, / batch --steps <json> \| --steps-file <path>\s{2,}Run multiple commands/);
Expand Down
12 changes: 10 additions & 2 deletions src/utils/args.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,10 @@ export function parseRawArgs(argv: string[]): RawParsedArgs {
else positionals.push(arg);
continue;
}
if (shouldPreservePostCommandArgs(command)) {
positionals.push(arg);
continue;
}
const isLongFlag = arg.startsWith('--');
const isShortFlag = arg.startsWith('-') && arg.length > 1;
if (!isLongFlag && !isShortFlag) {
Expand All @@ -72,7 +76,7 @@ export function parseRawArgs(argv: string[]): RawParsedArgs {
continue;
}
const definition = resolveFlagDefinition(token, command);
if (shouldPassThroughReactDevtoolsFlag(command, definition)) {
if (shouldPassThroughLocalToolFlag(command, definition)) {
positionals.push(arg);
continue;
}
Expand Down Expand Up @@ -108,7 +112,7 @@ function isLegacyIgnoredSnapshotShortFlag(command: string | null, token: string)
return token === '-c' && (command === 'snapshot' || command === 'diff');
}

function shouldPassThroughReactDevtoolsFlag(
function shouldPassThroughLocalToolFlag(
command: string | null,
definition: FlagDefinition | undefined,
): boolean {
Expand All @@ -117,6 +121,10 @@ function shouldPassThroughReactDevtoolsFlag(
return !isFlagSupportedForCommand(definition.key, command);
}

function shouldPreservePostCommandArgs(command: string | null): boolean {
return command === 'cdp';
}

function resolveFlagDefinition(token: string, command: string | null): FlagDefinition | undefined {
const definitions = getFlagDefinitions().filter((definition) => definition.names.includes(token));
if (definitions.length <= 1) return definitions[0] ?? getFlagDefinition(token);
Expand Down
11 changes: 11 additions & 0 deletions src/utils/cli-command-overrides.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,17 @@ import { COMMON_COMMAND_SUPPORTED_FLAG_KEYS, METRO_PREPARE_FLAGS } from './cli-f
type SchemaOnlyCliCommandName = Exclude<LocalCliCommandName, CommandName>;

const SCHEMA_ONLY_CLI_COMMAND_SCHEMAS = {
cdp: {
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: {
usageOverride: 'auth status|login|logout',
listUsageOverride: 'auth',
Expand Down
Loading
Loading