Skip to content

Commit cb650cf

Browse files
committed
feat: expose semantic MCP tools
1 parent bbe7c06 commit cb650cf

49 files changed

Lines changed: 3822 additions & 1408 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ Snapshots assign refs like `@e1`, `@e2`, and `@e3` to elements on the current sc
8383

8484
## Next Steps
8585

86-
- **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).
86+
- **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).
8787
- **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).
8888
- **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.
8989

docs/prds/semantic-command-contracts-for-cli-node-mcp.md

Lines changed: 130 additions & 0 deletions
Large diffs are not rendered by default.

src/__tests__/command-codecs.test.ts

Lines changed: 3 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -24,25 +24,17 @@ test('command catalog owns daemon routing groups', () => {
2424
assert.equal(DAEMON_COMMAND_GROUPS.replay.has(PUBLIC_COMMANDS.test), true);
2525
});
2626

27-
test('wait codec preserves CLI bare text and client selector forms', () => {
27+
test('wait codec preserves CLI bare text forms', () => {
2828
const options = waitCommandCodec.decode(['Continue', '1500'], BASE_FLAGS);
2929
assert.equal(options.text, 'Continue');
3030
assert.equal(options.timeoutMs, 1500);
31-
assert.deepEqual(
32-
waitCommandCodec.encode({
33-
selector: 'id=submit',
34-
timeoutMs: 2000,
35-
}),
36-
['id=submit', '2000'],
37-
);
3831
});
3932

4033
test('interaction and fill codecs share ref, selector, and point grammar', () => {
4134
assert.deepEqual(interactionTargetCodec.decode(['@e3', 'Email']), {
4235
ref: '@e3',
4336
label: 'Email',
4437
});
45-
assert.deepEqual(interactionTargetCodec.encode({ selector: 'id=submit' }), ['id=submit']);
4638
assert.deepEqual(fillCommandCodec.decode(['id=email', 'qa@example.com']), {
4739
kind: 'selector',
4840
target: { selector: 'id=email' },
@@ -58,14 +50,6 @@ test('interaction and fill codecs share ref, selector, and point grammar', () =>
5850
target: { x: 10, y: 20 },
5951
text: 'hello',
6052
});
61-
assert.deepEqual(
62-
fillCommandCodec.encode({
63-
ref: '@e4',
64-
label: 'Email',
65-
text: 'qa@example.com',
66-
}),
67-
['@e4', 'Email', 'qa@example.com'],
68-
);
6953
assert.deepEqual(longPressCommandCodec.decode(['@e4', '800']), {
7054
ref: '@e4',
7155
durationMs: 800,
@@ -75,13 +59,9 @@ test('interaction and fill codecs share ref, selector, and point grammar', () =>
7559
y: 20,
7660
durationMs: 800,
7761
});
78-
assert.deepEqual(
79-
longPressCommandCodec.encode({ selector: 'label="Last message"', durationMs: 800 }),
80-
['label="Last message"', '800'],
81-
);
8262
});
8363

84-
test('find and is codecs round-trip command action positionals', () => {
64+
test('find and is codecs decode command action positionals', () => {
8565
const findOptions = findCommandCodec.decode(['label', 'Continue', 'wait', '3000'], {
8666
...BASE_FLAGS,
8767
platform: 'ios',
@@ -93,16 +73,14 @@ test('find and is codecs round-trip command action positionals', () => {
9373
assert.equal(findOptions.action, 'wait');
9474
assert.equal(findOptions.timeoutMs, 3000);
9575
assert.equal(findOptions.first, true);
96-
assert.deepEqual(findCommandCodec.encode(findOptions), ['label', 'Continue', 'wait', '3000']);
9776

9877
const isOptions = isCommandCodec.decode(['text', 'id=title', 'Welcome'], BASE_FLAGS);
9978
assert.equal(isOptions.predicate, 'text');
10079
assert.equal(isOptions.selector, 'id=title');
10180
assert.equal(isOptions.value, 'Welcome');
102-
assert.deepEqual(isCommandCodec.encode(isOptions), ['text', 'id=title', 'Welcome']);
10381
});
10482

105-
test('settings codec owns positional grammar for command and client paths', () => {
83+
test('settings codec owns positional grammar for CLI commands', () => {
10684
const location = settingsCommandCodec.decode(['location', 'set', '37.3349', '-122.009'], {
10785
...BASE_FLAGS,
10886
platform: 'ios',
@@ -112,20 +90,4 @@ test('settings codec owns positional grammar for command and client paths', () =
11290
assert.equal(location.state, 'set');
11391
assert.equal(location.latitude, 37.3349);
11492
assert.equal(location.longitude, -122.009);
115-
assert.deepEqual(settingsCommandCodec.encode(location), [
116-
'location',
117-
'set',
118-
'37.3349',
119-
'-122.009',
120-
]);
121-
122-
assert.deepEqual(
123-
settingsCommandCodec.encode({
124-
setting: 'permission',
125-
state: 'grant',
126-
permission: 'camera',
127-
mode: 'limited',
128-
}),
129-
['permission', 'grant', 'camera', 'limited'],
130-
);
13193
});

src/cli/commands/apps.ts

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,16 @@
1-
import { buildSelectionOptions, writeCommandOutput } from './shared.ts';
1+
import { runSemanticCliCommand } from '../../commands/semantic-cli.ts';
2+
import { writeCommandOutput } from './shared.ts';
23
import { assertResolvedAppsFilter } from '../../commands/app-inventory-contract.ts';
34
import type { ClientCommandHandler } from './router-types.ts';
45

56
export const appsCommand: ClientCommandHandler = async ({ flags, client }) => {
67
const appsFilter = assertResolvedAppsFilter(flags.appsFilter);
7-
const apps = await client.apps.list({
8-
...buildSelectionOptions(flags),
9-
appsFilter,
10-
});
8+
const apps = (await runSemanticCliCommand({
9+
client,
10+
command: 'apps',
11+
positionals: [],
12+
flags,
13+
})) as unknown as string[];
1114
const data = { apps };
1215
writeCommandOutput(flags, data, () => {
1316
if (!flags.json) {

src/cli/commands/client-command.ts

Lines changed: 56 additions & 103 deletions
Original file line numberDiff line numberDiff line change
@@ -1,104 +1,111 @@
11
import type {
2-
AlertCommandOptions,
32
AppStateCommandResult,
4-
ClipboardCommandOptions,
53
ClipboardCommandResult,
6-
KeyboardCommandOptions,
74
KeyboardCommandResult,
8-
RotateCommandOptions,
95
} from '../../client.ts';
106
import type { CliFlags } from '../../utils/command-schema.ts';
11-
import { AppError } from '../../utils/errors.ts';
127
import { readCommandMessage } from '../../utils/success-text.ts';
13-
import { waitCommandCodec } from '../../command-codecs.ts';
14-
import { parseDeviceRotation } from '../../core/device-rotation.ts';
15-
import { buildSelectionOptions, writeCommandMessage, writeCommandOutput } from './shared.ts';
8+
import { runSemanticCliCommand, type SemanticCliCommand } from '../../commands/semantic-cli.ts';
9+
import { writeCommandMessage, writeCommandOutput } from './shared.ts';
1610
import type { ClientCommandHandlerMap } from './router-types.ts';
1711

1812
export const clientCommandMethodHandlers = {
1913
wait: async ({ positionals, flags, client }) => {
2014
writeCommandMessage(
2115
flags,
22-
await client.command.wait(waitCommandCodec.decode(positionals, flags)),
16+
await runClientCommand({ command: 'wait', positionals, flags, client }),
2317
);
2418
return true;
2519
},
2620
alert: async ({ positionals, flags, client }) => {
27-
writeCommandMessage(flags, await client.command.alert(readAlertOptions(positionals, flags)));
21+
writeCommandMessage(
22+
flags,
23+
await runClientCommand({ command: 'alert', positionals, flags, client }),
24+
);
2825
return true;
2926
},
3027
appstate: async ({ flags, client }) => {
31-
const result = await client.command.appState(buildSelectionOptions(flags));
28+
const result = (await runClientCommand({
29+
command: 'appstate',
30+
positionals: [],
31+
flags,
32+
client,
33+
})) as AppStateCommandResult;
3234
writeCommandOutput(flags, result, () => formatAppState(result));
3335
return true;
3436
},
3537
back: async ({ flags, client }) => {
3638
writeCommandMessage(
3739
flags,
38-
await client.command.back({ ...buildSelectionOptions(flags), mode: flags.backMode }),
40+
await runClientCommand({ command: 'back', positionals: [], flags, client }),
3941
);
4042
return true;
4143
},
4244
home: async ({ flags, client }) => {
43-
writeCommandMessage(flags, await client.command.home(buildSelectionOptions(flags)));
45+
writeCommandMessage(
46+
flags,
47+
await runClientCommand({ command: 'home', positionals: [], flags, client }),
48+
);
4449
return true;
4550
},
4651
rotate: async ({ positionals, flags, client }) => {
47-
writeCommandMessage(flags, await client.command.rotate(readRotateOptions(positionals, flags)));
52+
writeCommandMessage(
53+
flags,
54+
await runClientCommand({ command: 'rotate', positionals, flags, client }),
55+
);
4856
return true;
4957
},
5058
'app-switcher': async ({ flags, client }) => {
51-
writeCommandMessage(flags, await client.command.appSwitcher(buildSelectionOptions(flags)));
59+
writeCommandMessage(
60+
flags,
61+
await runClientCommand({ command: 'app-switcher', positionals: [], flags, client }),
62+
);
5263
return true;
5364
},
5465
keyboard: async ({ positionals, flags, client }) => {
5566
writeKeyboardOutput(
5667
flags,
57-
await client.command.keyboard(readKeyboardOptions(positionals, flags)),
68+
(await runClientCommand({
69+
command: 'keyboard',
70+
positionals,
71+
flags,
72+
client,
73+
})) as KeyboardCommandResult,
5874
);
5975
return true;
6076
},
6177
clipboard: async ({ positionals, flags, client }) => {
6278
writeClipboardOutput(
6379
flags,
64-
await client.command.clipboard(readClipboardOptions(positionals, flags)),
80+
(await runClientCommand({
81+
command: 'clipboard',
82+
positionals,
83+
flags,
84+
client,
85+
})) as ClipboardCommandResult,
6586
);
6687
return true;
6788
},
6889
} satisfies ClientCommandHandlerMap;
6990

70-
function readAlertOptions(positionals: string[], flags: CliFlags): AlertCommandOptions {
71-
if (positionals.length > 2) {
72-
throw new AppError('INVALID_ARGS', 'alert accepts at most action and timeout arguments.');
73-
}
74-
const action = readAlertAction(positionals[0]);
75-
const timeoutMs = readFiniteNumber(positionals[1], 'alert timeout');
76-
return {
77-
...buildSelectionOptions(flags),
78-
...(action ? { action } : {}),
79-
...(timeoutMs !== undefined ? { timeoutMs } : {}),
80-
};
81-
}
82-
83-
function readRotateOptions(positionals: string[], flags: CliFlags): RotateCommandOptions {
84-
if (positionals.length > 1) {
85-
throw new AppError('INVALID_ARGS', 'rotate accepts exactly one orientation argument.');
86-
}
87-
return {
88-
...buildSelectionOptions(flags),
89-
orientation: parseDeviceRotation(positionals[0]),
90-
};
91-
}
92-
93-
function readKeyboardOptions(positionals: string[], flags: CliFlags): KeyboardCommandOptions {
94-
if (positionals.length > 1) {
95-
throw new AppError('INVALID_ARGS', 'keyboard accepts at most one action argument.');
96-
}
97-
const action = readKeyboardAction(positionals[0]);
98-
return {
99-
...buildSelectionOptions(flags),
100-
...(action ? { action } : {}),
101-
};
91+
function runClientCommand(options: {
92+
command: Extract<
93+
SemanticCliCommand,
94+
| 'wait'
95+
| 'alert'
96+
| 'appstate'
97+
| 'back'
98+
| 'home'
99+
| 'rotate'
100+
| 'app-switcher'
101+
| 'keyboard'
102+
| 'clipboard'
103+
>;
104+
positionals: string[];
105+
flags: CliFlags;
106+
client: Parameters<typeof runSemanticCliCommand>[0]['client'];
107+
}) {
108+
return runSemanticCliCommand(options);
102109
}
103110

104111
function writeKeyboardOutput(flags: CliFlags, result: KeyboardCommandResult): void {
@@ -132,60 +139,6 @@ function androidKeyboardNextAction(
132139
return 'Keyboard is hidden; focus an app field before type, or use fill with a concrete target.';
133140
}
134141

135-
function readClipboardOptions(positionals: string[], flags: CliFlags): ClipboardCommandOptions {
136-
const action = positionals[0]?.toLowerCase();
137-
if (action !== 'read' && action !== 'write') {
138-
throw new AppError('INVALID_ARGS', 'clipboard requires a subcommand: read or write.');
139-
}
140-
const base = buildSelectionOptions(flags);
141-
if (action === 'read') {
142-
if (positionals.length !== 1) {
143-
throw new AppError('INVALID_ARGS', 'clipboard read does not accept additional arguments.');
144-
}
145-
return { ...base, action };
146-
}
147-
if (positionals.length < 2) {
148-
throw new AppError('INVALID_ARGS', 'clipboard write requires text.');
149-
}
150-
return {
151-
...base,
152-
action,
153-
text: positionals.slice(1).join(' '),
154-
};
155-
}
156-
157-
function readAlertAction(value: string | undefined): AlertCommandOptions['action'] | undefined {
158-
const action = value?.toLowerCase();
159-
if (
160-
action === undefined ||
161-
action === 'get' ||
162-
action === 'accept' ||
163-
action === 'dismiss' ||
164-
action === 'wait'
165-
) {
166-
return action;
167-
}
168-
throw new AppError('INVALID_ARGS', 'alert action must be get, accept, dismiss, or wait.');
169-
}
170-
171-
function readKeyboardAction(
172-
value: string | undefined,
173-
): KeyboardCommandOptions['action'] | undefined {
174-
const action = value?.toLowerCase();
175-
if (action === 'get') return 'status';
176-
if (action === undefined || action === 'status' || action === 'dismiss') {
177-
return action;
178-
}
179-
throw new AppError('INVALID_ARGS', 'keyboard action must be status, get, or dismiss.');
180-
}
181-
182-
function readFiniteNumber(value: string | undefined, label: string): number | undefined {
183-
if (value === undefined) return undefined;
184-
const parsed = Number(value);
185-
if (Number.isFinite(parsed)) return parsed;
186-
throw new AppError('INVALID_ARGS', `${label} must be a finite number.`);
187-
}
188-
189142
function formatAppState(data: AppStateCommandResult): string | null {
190143
if (data.platform === 'ios') {
191144
const lines = [`Foreground app: ${data.appName ?? data.appBundleId ?? 'unknown'}`];

src/cli/commands/devices.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,16 @@
11
import { serializeDevice } from '../../client-shared.ts';
22
import type { AgentDeviceDevice } from '../../client.ts';
3-
import { buildSelectionOptions, writeCommandOutput } from './shared.ts';
3+
import { runSemanticCliCommand } from '../../commands/semantic-cli.ts';
4+
import { writeCommandOutput } from './shared.ts';
45
import type { ClientCommandHandler } from './router-types.ts';
56

67
export const devicesCommand: ClientCommandHandler = async ({ flags, client }) => {
7-
const devices = await client.devices.list(buildSelectionOptions(flags));
8+
const devices = (await runSemanticCliCommand({
9+
client,
10+
command: 'devices',
11+
positionals: [],
12+
flags,
13+
})) as unknown as AgentDeviceDevice[];
814
const data = { devices: devices.map(serializeDevice) };
915
writeCommandOutput(flags, data, () => devices.map(formatDeviceLine).join('\n'));
1016
return true;

0 commit comments

Comments
 (0)