Skip to content

Commit be1e1c9

Browse files
authored
feat: add cdp command agent-cdp passthrough (#873)
* feat: add agent-cdp passthrough * docs: narrow agent-cdp memory guidance * chore: pin agent-cdp 1.6.0 * test: cover agent-cdp guidance * fix: preserve agent-cdp passthrough flags * fix: expose CDP wrapper as cdp * docs: move CDP workflow to debugging guide * docs: mention cdp in command reference
1 parent 8c49d54 commit be1e1c9

16 files changed

Lines changed: 440 additions & 4 deletions

File tree

skills/agent-device/SKILL.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ Escalate only when relevant:
2727
agent-device help debugging
2828
agent-device help react-native
2929
agent-device help react-devtools
30+
agent-device help cdp
3031
agent-device help remote
3132
agent-device help macos
3233
agent-device help dogfood
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import fs from 'node:fs';
2+
import assert from 'node:assert/strict';
3+
import { afterEach, test, vi } from 'vitest';
4+
5+
vi.mock('../utils/exec.ts', () => ({
6+
runCmdStreaming: vi.fn(),
7+
}));
8+
9+
import { runCmdStreaming } from '../utils/exec.ts';
10+
import {
11+
AGENT_CDP_PACKAGE,
12+
buildAgentCdpNpmExecArgs,
13+
runAgentCdpCommand,
14+
} from '../cli/commands/agent-cdp.ts';
15+
16+
afterEach(() => {
17+
vi.clearAllMocks();
18+
});
19+
20+
test('cdp wrapper pins agent-cdp package version', () => {
21+
assert.equal(AGENT_CDP_PACKAGE, 'agent-cdp@1.6.0');
22+
assert.deepEqual(
23+
buildAgentCdpNpmExecArgs(['memory', 'usage', 'sample', '--label', 'baseline', '--gc']),
24+
[
25+
'exec',
26+
'--yes',
27+
'--package',
28+
'agent-cdp@1.6.0',
29+
'--',
30+
'agent-cdp',
31+
'memory',
32+
'usage',
33+
'sample',
34+
'--label',
35+
'baseline',
36+
'--gc',
37+
],
38+
);
39+
});
40+
41+
test('cdp docs hide the implementation package name', () => {
42+
assert.doesNotMatch(fs.readFileSync('website/docs/docs/commands.md', 'utf8'), /agent-cdp/);
43+
assert.doesNotMatch(
44+
fs.readFileSync('website/docs/docs/debugging-profiling.md', 'utf8'),
45+
/agent-cdp/,
46+
);
47+
});
48+
49+
test('cdp workflow docs live in debugging and profiling guide', () => {
50+
assert.match(
51+
fs.readFileSync('website/docs/docs/commands.md', 'utf8'),
52+
/agent-device cdp memory usage sample --label baseline --gc/,
53+
);
54+
assert.doesNotMatch(
55+
fs.readFileSync('website/docs/docs/commands.md', 'utf8'),
56+
/React Native JS memory through CDP/,
57+
);
58+
assert.match(
59+
fs.readFileSync('website/docs/docs/debugging-profiling.md', 'utf8'),
60+
/React Native JS memory through CDP/,
61+
);
62+
});
63+
64+
test('cdp wrapper streams through npm exec and returns downstream exit code', async () => {
65+
const env = { ...process.env };
66+
vi.mocked(runCmdStreaming).mockResolvedValueOnce({
67+
exitCode: 7,
68+
stdout: '',
69+
stderr: '',
70+
});
71+
72+
const exitCode = await runAgentCdpCommand(['target', 'list'], {
73+
cwd: '/tmp/project',
74+
env,
75+
});
76+
77+
assert.equal(exitCode, 7);
78+
assert.equal(vi.mocked(runCmdStreaming).mock.calls[0]?.[0], 'npm');
79+
assert.deepEqual(vi.mocked(runCmdStreaming).mock.calls[0]?.[1], [
80+
'exec',
81+
'--yes',
82+
'--package',
83+
'agent-cdp@1.6.0',
84+
'--',
85+
'agent-cdp',
86+
'target',
87+
'list',
88+
]);
89+
assert.equal(vi.mocked(runCmdStreaming).mock.calls[0]?.[2]?.cwd, '/tmp/project');
90+
assert.equal(vi.mocked(runCmdStreaming).mock.calls[0]?.[2]?.env, env);
91+
assert.equal(vi.mocked(runCmdStreaming).mock.calls[0]?.[2]?.allowFailure, true);
92+
});

src/cli.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
} from './client.ts';
1414
import { materializeRemoteConnectionForCommand } from './cli/commands/connection-runtime.ts';
1515
import { tryRunClientBackedCommand } from './cli/commands/router.ts';
16+
import { runAgentCdpCommand } from './cli/commands/agent-cdp.ts';
1617
import { runReactDevtoolsCommand } from './cli/commands/react-devtools.ts';
1718
import { runWebCommand } from './cli/commands/web.ts';
1819
import { readCliBatchStepsJson } from './cli/batch-steps.ts';
@@ -196,6 +197,14 @@ export async function runCli(argv: string[], deps: CliDeps = DEFAULT_CLI_DEPS):
196197
}
197198
let logTailStopper: (() => void) | null = null;
198199
try {
200+
if (command === 'cdp') {
201+
const exitCode = await runAgentCdpCommand(positionals, {
202+
cwd: process.cwd(),
203+
env: process.env,
204+
});
205+
process.exit(exitCode);
206+
return;
207+
}
199208
if (command === 'react-devtools') {
200209
const exitCode = await runReactDevtoolsCommand(positionals, {
201210
flags: effectiveFlags,

src/cli/commands/agent-cdp.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { runCmdStreaming } from '../../utils/exec.ts';
2+
3+
const AGENT_CDP_VERSION = '1.6.0';
4+
export const AGENT_CDP_PACKAGE = `agent-cdp@${AGENT_CDP_VERSION}`;
5+
const AGENT_CDP_BIN = 'agent-cdp';
6+
7+
type AgentCdpCommandOptions = {
8+
cwd?: string;
9+
env?: NodeJS.ProcessEnv;
10+
};
11+
12+
export function buildAgentCdpNpmExecArgs(args: string[]): string[] {
13+
return ['exec', '--yes', '--package', AGENT_CDP_PACKAGE, '--', AGENT_CDP_BIN, ...args];
14+
}
15+
16+
export async function runAgentCdpCommand(
17+
args: string[],
18+
options: AgentCdpCommandOptions = {},
19+
): Promise<number> {
20+
const result = await runCmdStreaming('npm', buildAgentCdpNpmExecArgs(args), {
21+
cwd: options.cwd ?? process.cwd(),
22+
env: options.env ?? process.env,
23+
allowFailure: true,
24+
onStdoutChunk: (chunk) => {
25+
process.stdout.write(chunk);
26+
},
27+
onStderrChunk: (chunk) => {
28+
process.stderr.write(chunk);
29+
},
30+
});
31+
return result.exitCode;
32+
}

src/command-catalog.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ export const INTERNAL_COMMANDS = {
5959
} as const;
6060

6161
const LOCAL_CLI_COMMANDS = {
62+
cdp: 'cdp',
6263
auth: 'auth',
6364
connect: 'connect',
6465
connection: 'connection',
@@ -87,6 +88,7 @@ export type ClientBackedCliCommandName =
8788

8889
const MCP_UNEXPOSED_CLI_COMMANDS = commandSet(
8990
LOCAL_CLI_COMMANDS.auth,
91+
LOCAL_CLI_COMMANDS.cdp,
9092
LOCAL_CLI_COMMANDS.connect,
9193
LOCAL_CLI_COMMANDS.connection,
9294
LOCAL_CLI_COMMANDS.disconnect,
@@ -99,6 +101,7 @@ const MCP_UNEXPOSED_CLI_COMMANDS = commandSet(
99101

100102
const CAPABILITY_EXEMPT_CLI_COMMANDS = commandSet(
101103
LOCAL_CLI_COMMANDS.auth,
104+
LOCAL_CLI_COMMANDS.cdp,
102105
LOCAL_CLI_COMMANDS.connect,
103106
LOCAL_CLI_COMMANDS.connection,
104107
LOCAL_CLI_COMMANDS.debug,

src/utils/__tests__/args.test.ts

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -289,6 +289,87 @@ test('parseArgs supports explicit passthrough boundary for react-devtools global
289289
assert.deepEqual(parsed.positionals, ['status', '--json']);
290290
});
291291

292+
test('parseArgs preserves cdp arguments as passthrough positionals', () => {
293+
const parsed = parseArgs(
294+
[
295+
'cdp',
296+
'memory',
297+
'snapshot',
298+
'diff',
299+
'--base',
300+
'ms_1',
301+
'--compare',
302+
'ms_2',
303+
'--limit=10',
304+
'--json',
305+
'--session',
306+
'rn',
307+
],
308+
{ strictFlags: true },
309+
);
310+
assert.equal(parsed.command, 'cdp');
311+
assert.equal(parsed.flags.json, false);
312+
assert.equal(parsed.flags.session, undefined);
313+
assert.deepEqual(parsed.positionals, [
314+
'memory',
315+
'snapshot',
316+
'diff',
317+
'--base',
318+
'ms_1',
319+
'--compare',
320+
'ms_2',
321+
'--limit=10',
322+
'--json',
323+
'--session',
324+
'rn',
325+
]);
326+
});
327+
328+
test('parseArgs preserves cdp help as a downstream flag', () => {
329+
const parsed = parseArgs(['cdp', '--help'], { strictFlags: true });
330+
assert.equal(parsed.command, 'cdp');
331+
assert.equal(parsed.flags.help, false);
332+
assert.deepEqual(parsed.positionals, ['--help']);
333+
});
334+
335+
test('parseArgs accepts agent-device globals before cdp passthrough args', () => {
336+
const parsed = parseArgs(
337+
[
338+
'--session',
339+
'outer-session',
340+
'cdp',
341+
'target',
342+
'list',
343+
'--target',
344+
'Hermes',
345+
'--device',
346+
'rn-app',
347+
'--json',
348+
],
349+
{ strictFlags: true },
350+
);
351+
assert.equal(parsed.command, 'cdp');
352+
assert.equal(parsed.flags.session, 'outer-session');
353+
assert.equal(parsed.flags.json, false);
354+
assert.deepEqual(parsed.positionals, [
355+
'target',
356+
'list',
357+
'--target',
358+
'Hermes',
359+
'--device',
360+
'rn-app',
361+
'--json',
362+
]);
363+
});
364+
365+
test('parseArgs supports explicit passthrough boundary for cdp global flag names', () => {
366+
const parsed = parseArgs(['cdp', '--', 'target', 'list', '--url', 'http://127.0.0.1:8081'], {
367+
strictFlags: true,
368+
});
369+
assert.equal(parsed.command, 'cdp');
370+
assert.deepEqual(parsed.positionals, ['target', 'list', '--url', 'http://127.0.0.1:8081']);
371+
});
372+
292373
test('parseArgs accepts push with payload file', () => {
293374
const parsed = parseArgs(['push', 'com.example.app', './payload.json'], { strictFlags: true });
294375
assert.equal(parsed.command, 'push');
@@ -1560,6 +1641,18 @@ test('usageForCommand resolves react-devtools help topic', () => {
15601641
assert.match(help, /Remote iOS apps attempt the legacy React DevTools websocket/);
15611642
});
15621643

1644+
test('usageForCommand resolves cdp help topic', () => {
1645+
const help = usageForCommand('cdp');
1646+
if (help === null) throw new Error('Expected cdp help text');
1647+
assert.match(help, /agent-device cdp target list --url http:\/\/127\.0\.0\.1:8081/);
1648+
assert.match(help, /memory usage sample --label baseline --gc/);
1649+
assert.match(help, /memory snapshot leak-triplet --baseline ms_1 --action ms_2 --cleanup ms_3/);
1650+
assert.match(help, /memory snapshot retainers --snapshot ms_3 --id <node-id>/);
1651+
assert.match(help, /Until cdp has a compact leak report command/);
1652+
assert.match(help, /Avoid cdp profile cpu, trace, network, and console by default/);
1653+
assert.match(help, /React Native\/Hermes implements a subset of browser CDP/);
1654+
});
1655+
15631656
test('usageForCommand resolves react-native help topic', () => {
15641657
const help = usageForCommand('react-native');
15651658
if (help === null) throw new Error('Expected react-native help text');
@@ -1578,6 +1671,8 @@ test('usageForCommand resolves react-native help topic', () => {
15781671
assert.match(help, /One simulator cannot run two copies of the same bundle id/);
15791672
assert.match(help, /Keep the agent-device react-devtools prefix/);
15801673
assert.match(help, /Use help react-devtools for status\/wait/);
1674+
assert.match(help, /Keep the agent-device cdp prefix/);
1675+
assert.match(help, /Use help cdp for JS heap usage samples/);
15811676
assert.match(help, /logs clear --restart/);
15821677
assert.match(help, /network dump --include headers/);
15831678
assert.match(help, /agent-device open "Agent Device Tester" --platform android/);
@@ -1819,6 +1914,7 @@ test('usage renders concise commands inline with descriptions', () => {
18191914
assert.match(help, / prepare\s{2,}Pre-warm platform helpers/);
18201915
assert.match(help, / metro\s{2,}Prepare Metro reachability for React Native\/Expo apps/);
18211916
assert.match(help, / perf\s{2,}Check runtime metrics, frames, memory, CPU profiles/);
1917+
assert.match(help, / cdp\s{2,}Inspect React Native CDP targets, JS heap growth/);
18221918
assert.match(help, / react-devtools\s{2,}Inspect React Native components, props, hooks/);
18231919
assert.match(help, / proxy\s{2,}Expose a local daemon through cloudflared, ngrok/);
18241920
assert.match(help, / batch --steps <json> \| --steps-file <path>\s{2,}Run multiple commands/);

src/utils/args.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,10 @@ export function parseRawArgs(argv: string[]): RawParsedArgs {
5959
else positionals.push(arg);
6060
continue;
6161
}
62+
if (shouldPreservePostCommandArgs(command)) {
63+
positionals.push(arg);
64+
continue;
65+
}
6266
const isLongFlag = arg.startsWith('--');
6367
const isShortFlag = arg.startsWith('-') && arg.length > 1;
6468
if (!isLongFlag && !isShortFlag) {
@@ -72,7 +76,7 @@ export function parseRawArgs(argv: string[]): RawParsedArgs {
7276
continue;
7377
}
7478
const definition = resolveFlagDefinition(token, command);
75-
if (shouldPassThroughReactDevtoolsFlag(command, definition)) {
79+
if (shouldPassThroughLocalToolFlag(command, definition)) {
7680
positionals.push(arg);
7781
continue;
7882
}
@@ -108,7 +112,7 @@ function isLegacyIgnoredSnapshotShortFlag(command: string | null, token: string)
108112
return token === '-c' && (command === 'snapshot' || command === 'diff');
109113
}
110114

111-
function shouldPassThroughReactDevtoolsFlag(
115+
function shouldPassThroughLocalToolFlag(
112116
command: string | null,
113117
definition: FlagDefinition | undefined,
114118
): boolean {
@@ -117,6 +121,10 @@ function shouldPassThroughReactDevtoolsFlag(
117121
return !isFlagSupportedForCommand(definition.key, command);
118122
}
119123

124+
function shouldPreservePostCommandArgs(command: string | null): boolean {
125+
return command === 'cdp';
126+
}
127+
120128
function resolveFlagDefinition(token: string, command: string | null): FlagDefinition | undefined {
121129
const definitions = getFlagDefinitions().filter((definition) => definition.names.includes(token));
122130
if (definitions.length <= 1) return definitions[0] ?? getFlagDefinition(token);

src/utils/cli-command-overrides.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,17 @@ import { COMMON_COMMAND_SUPPORTED_FLAG_KEYS, METRO_PREPARE_FLAGS } from './cli-f
77
type SchemaOnlyCliCommandName = Exclude<LocalCliCommandName, CommandName>;
88

99
const SCHEMA_ONLY_CLI_COMMAND_SCHEMAS = {
10+
cdp: {
11+
usageOverride: 'cdp [...args]',
12+
listUsageOverride: 'cdp',
13+
helpDescription:
14+
'Run CDP commands for React Native diagnostics, JS heap usage, heap snapshots, and leak analysis',
15+
summary:
16+
'Inspect React Native CDP targets, JS heap growth, heap snapshots, retainers, and leak signals',
17+
positionalArgs: ['args?'],
18+
allowsExtraPositionals: true,
19+
supportedFlags: COMMON_COMMAND_SUPPORTED_FLAG_KEYS,
20+
},
1021
auth: {
1122
usageOverride: 'auth status|login|logout',
1223
listUsageOverride: 'auth',

0 commit comments

Comments
 (0)