Skip to content

Commit ab33826

Browse files
authored
feat: add clipboard read and write commands (#122)
1 parent 13b2e2c commit ab33826

18 files changed

Lines changed: 652 additions & 4 deletions

File tree

README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ The project is in early development and considered experimental. Pull requests a
1616
- Platforms: iOS (simulator + physical device core automation) and Android (emulator + device).
1717
- Core commands: `open`, `back`, `home`, `app-switcher`, `press`, `long-press`, `focus`, `type`, `fill`, `scroll`, `scrollintoview`, `wait`, `alert`, `screenshot`, `close`, `reinstall`, `push`.
1818
- Inspection commands: `snapshot` (accessibility tree), `diff snapshot` (structural baseline diff), `appstate`, `apps`, `devices`.
19+
- Clipboard commands: `clipboard read`, `clipboard write <text>`.
1920
- App logs: `logs path` returns session log metadata; `logs start` / `logs stop` stream app output; `logs clear` truncates session app logs; `logs clear --restart` resets and restarts stream in one step; `logs doctor` checks readiness; `logs mark` writes timeline markers.
2021
- Device tooling: `adb` (Android), `simctl`/`devicectl` (iOS via Xcode).
2122
- Minimal dependencies; TypeScript executed directly on Node 22+ (no build step).
@@ -147,6 +148,7 @@ agent-device scrollintoview @e42
147148
- `alert`, `wait`, `screenshot`
148149
- `trace start`, `trace stop`
149150
- `logs path`, `logs start`, `logs stop`, `logs clear`, `logs clear --restart`, `logs doctor`, `logs mark` (session app log file for grep; iOS simulator + iOS device + Android)
151+
- `clipboard read`, `clipboard write <text>` (iOS simulator + Android)
150152
- `settings wifi|airplane|location on|off`
151153
- `settings appearance light|dark|toggle`
152154
- `settings faceid match|nonmatch|enroll|unenroll` (iOS simulator only)
@@ -333,6 +335,12 @@ App state:
333335
- On iOS, `appstate` returns the currently tracked session app (`source: session`) and requires an active session on the selected device.
334336
- `apps` includes default/system apps by default (use `--user-installed` to filter).
335337

338+
Clipboard:
339+
- `clipboard read` returns current clipboard text.
340+
- `clipboard write <text>` sets clipboard text (`clipboard write ""` clears it).
341+
- Supported on Android emulator/device and iOS simulator.
342+
- iOS physical devices currently return `UNSUPPORTED_OPERATION` for clipboard commands.
343+
336344
## Debug
337345

338346
- **App logs (token-efficient):** Logging is off by default in normal flows. Enable it on demand when debugging. With an active session, run `logs path` to get path + state metadata (e.g. `~/.agent-device/sessions/<session>/app.log`). Run `logs start` to stream app output to that file; use `logs stop` to stop. Run `logs clear` to truncate `app.log` (and remove rotated `app.log.N` files) before a new repro window. Run `logs doctor` for tool/runtime checks and `logs mark "step"` to insert timeline markers. Grep the file when you need to inspect errors (e.g. `grep -n "Error\|Exception" <path>`) instead of pulling full logs into context. Supported on iOS simulator, iOS physical device, and Android.

skills/agent-device/SKILL.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,8 @@ agent-device is visible 'id="anchor"'
8686

8787
```bash
8888
agent-device appstate
89+
agent-device clipboard read
90+
agent-device clipboard write "token"
8991
agent-device push <bundle|package> <payload.json|inline-json>
9092
agent-device get text @e1
9193
agent-device screenshot out.png
@@ -108,6 +110,7 @@ agent-device batch --steps-file /tmp/batch-steps.json --json
108110
- Use refs for discovery, selectors for replay/assertions.
109111
- Use `fill` for clear-then-type semantics; use `type` for focused append typing.
110112
- iOS `appstate` is session-scoped; Android `appstate` is live foreground state.
113+
- Clipboard helpers: `clipboard read` / `clipboard write <text>` are supported on Android and iOS simulators; iOS physical devices are not supported yet.
111114
- iOS settings helpers are simulator-only; use `appearance light|dark|toggle` and faceid `match|nonmatch|enroll|unenroll`.
112115
- `push` simulates notification delivery:
113116
- iOS simulator uses APNs-style payload JSON.
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import test from 'node:test';
2+
import assert from 'node:assert/strict';
3+
import { runCli } from '../cli.ts';
4+
import type { DaemonRequest, DaemonResponse } from '../daemon-client.ts';
5+
6+
class ExitSignal extends Error {
7+
public readonly code: number;
8+
9+
constructor(code: number) {
10+
super(`EXIT_${code}`);
11+
this.code = code;
12+
}
13+
}
14+
15+
type RunResult = {
16+
code: number | null;
17+
stdout: string;
18+
stderr: string;
19+
calls: Omit<DaemonRequest, 'token'>[];
20+
};
21+
22+
async function runCliCapture(
23+
argv: string[],
24+
responder: (req: Omit<DaemonRequest, 'token'>) => Promise<DaemonResponse>,
25+
): Promise<RunResult> {
26+
let stdout = '';
27+
let stderr = '';
28+
let code: number | null = null;
29+
const calls: Array<Omit<DaemonRequest, 'token'>> = [];
30+
31+
const originalExit = process.exit;
32+
const originalStdoutWrite = process.stdout.write.bind(process.stdout);
33+
const originalStderrWrite = process.stderr.write.bind(process.stderr);
34+
35+
(process as any).exit = ((nextCode?: number) => {
36+
throw new ExitSignal(nextCode ?? 0);
37+
}) as typeof process.exit;
38+
(process.stdout as any).write = ((chunk: unknown) => {
39+
stdout += String(chunk);
40+
return true;
41+
}) as typeof process.stdout.write;
42+
(process.stderr as any).write = ((chunk: unknown) => {
43+
stderr += String(chunk);
44+
return true;
45+
}) as typeof process.stderr.write;
46+
47+
const sendToDaemon = async (req: Omit<DaemonRequest, 'token'>): Promise<DaemonResponse> => {
48+
calls.push(req);
49+
return await responder(req);
50+
};
51+
52+
try {
53+
await runCli(argv, { sendToDaemon });
54+
} catch (error) {
55+
if (error instanceof ExitSignal) code = error.code;
56+
else throw error;
57+
} finally {
58+
process.exit = originalExit;
59+
process.stdout.write = originalStdoutWrite;
60+
process.stderr.write = originalStderrWrite;
61+
}
62+
63+
return { code, stdout, stderr, calls };
64+
}
65+
66+
test('clipboard read prints clipboard text', async () => {
67+
const result = await runCliCapture(['clipboard', 'read'], async () => ({
68+
ok: true,
69+
data: { action: 'read', text: 'otp-123456' },
70+
}));
71+
72+
assert.equal(result.code, null);
73+
assert.equal(result.calls.length, 1);
74+
assert.equal(result.calls[0]?.command, 'clipboard');
75+
assert.deepEqual(result.calls[0]?.positionals, ['read']);
76+
assert.equal(result.stdout, 'otp-123456\n');
77+
assert.equal(result.stderr, '');
78+
});
79+
80+
test('clipboard write prints update confirmation', async () => {
81+
const result = await runCliCapture(['clipboard', 'write', 'hello'], async () => ({
82+
ok: true,
83+
data: { action: 'write', textLength: 5 },
84+
}));
85+
86+
assert.equal(result.code, null);
87+
assert.equal(result.calls.length, 1);
88+
assert.equal(result.calls[0]?.command, 'clipboard');
89+
assert.deepEqual(result.calls[0]?.positionals, ['write', 'hello']);
90+
assert.equal(result.stdout, 'Clipboard updated\n');
91+
assert.equal(result.stderr, '');
92+
});

src/cli.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -290,6 +290,21 @@ export async function runCli(argv: string[], deps: CliDeps = DEFAULT_CLI_DEPS):
290290
if (logTailStopper) logTailStopper();
291291
return;
292292
}
293+
if (command === 'clipboard') {
294+
const data = response.data as Record<string, unknown> | undefined;
295+
const action = (positionals[0] ?? (typeof data?.action === 'string' ? data.action : '')).toLowerCase();
296+
if (action === 'read') {
297+
const text = typeof data?.text === 'string' ? data.text : '';
298+
process.stdout.write(`${text}\n`);
299+
if (logTailStopper) logTailStopper();
300+
return;
301+
}
302+
if (action === 'write') {
303+
process.stdout.write('Clipboard updated\n');
304+
if (logTailStopper) logTailStopper();
305+
return;
306+
}
307+
}
293308
if (command === 'click' || command === 'press') {
294309
const ref = (response.data as any)?.ref ?? '';
295310
const x = (response.data as any)?.x;

src/core/__tests__/capabilities.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ test('iOS simulator-only commands reject iOS devices and Android', () => {
3333
});
3434

3535
test('simulator-only iOS commands with Android support reject iOS devices', () => {
36-
for (const cmd of ['settings', 'push']) {
36+
for (const cmd of ['settings', 'push', 'clipboard']) {
3737
assert.equal(isCommandSupportedOnDevice(cmd, iosSimulator), true, `${cmd} on iOS sim`);
3838
assert.equal(isCommandSupportedOnDevice(cmd, iosDevice), false, `${cmd} on iOS device`);
3939
assert.equal(isCommandSupportedOnDevice(cmd, androidDevice), true, `${cmd} on Android`);

src/core/capabilities.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ const COMMAND_CAPABILITY_MATRIX: Record<string, CommandCapability> = {
2121
back: { ios: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true } },
2222
boot: { ios: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true } },
2323
click: { ios: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true } },
24+
clipboard: { ios: { simulator: true }, android: { emulator: true, device: true, unknown: true } },
2425
close: { ios: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true } },
2526
fill: { ios: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true } },
2627
diff: { ios: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true } },

src/core/dispatch.ts

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,20 @@ import {
99
ensureAdb,
1010
homeAndroid,
1111
pushAndroidNotification,
12+
readAndroidClipboardText,
1213
setAndroidSetting,
1314
snapshotAndroid,
15+
writeAndroidClipboardText,
1416
} from '../platforms/android/index.ts';
1517
import { listIosDevices } from '../platforms/ios/devices.ts';
1618
import { getInteractor, type RunnerContext } from '../utils/interactors.ts';
1719
import { runIosRunnerCommand } from '../platforms/ios/runner-client.ts';
18-
import { pushIosNotification, setIosSetting } from '../platforms/ios/index.ts';
20+
import {
21+
pushIosNotification,
22+
readIosClipboardText,
23+
setIosSetting,
24+
writeIosClipboardText,
25+
} from '../platforms/ios/index.ts';
1926
import { isDeepLinkTarget } from './open-target.ts';
2027
import type { RawSnapshotNode } from '../utils/snapshot.ts';
2128
import type { CliFlags } from '../utils/command-schema.ts';
@@ -413,6 +420,28 @@ export async function dispatchCommand(
413420
await appSwitcherAndroid(device);
414421
return { action: 'app-switcher' };
415422
}
423+
case 'clipboard': {
424+
const action = (positionals[0] ?? '').toLowerCase();
425+
if (action !== 'read' && action !== 'write') {
426+
throw new AppError('INVALID_ARGS', 'clipboard requires a subcommand: read or write');
427+
}
428+
if (action === 'read') {
429+
if (positionals.length !== 1) {
430+
throw new AppError('INVALID_ARGS', 'clipboard read does not accept additional arguments');
431+
}
432+
const text = device.platform === 'ios'
433+
? await readIosClipboardText(device)
434+
: await readAndroidClipboardText(device);
435+
return { action, text };
436+
}
437+
if (positionals.length < 2) {
438+
throw new AppError('INVALID_ARGS', 'clipboard write requires text (use "" to clear clipboard)');
439+
}
440+
const text = positionals.slice(1).join(' ');
441+
if (device.platform === 'ios') await writeIosClipboardText(device, text);
442+
else await writeAndroidClipboardText(device, text);
443+
return { action, textLength: Array.from(text).length };
444+
}
416445
case 'settings': {
417446
const [setting, state, target, mode, appBundleId] = positionals;
418447
const permissionOptions =

src/daemon/handlers/__tests__/session.test.ts

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -612,6 +612,158 @@ test('appstate with explicit missing session returns SESSION_NOT_FOUND', async (
612612
}
613613
});
614614

615+
test('clipboard requires an active session or explicit device selector', async () => {
616+
const sessionStore = makeSessionStore();
617+
const response = await handleSessionCommands({
618+
req: {
619+
token: 't',
620+
session: 'default',
621+
command: 'clipboard',
622+
positionals: ['read'],
623+
flags: {},
624+
},
625+
sessionName: 'default',
626+
logPath: path.join(os.tmpdir(), 'daemon.log'),
627+
sessionStore,
628+
invoke: noopInvoke,
629+
});
630+
631+
assert.ok(response);
632+
assert.equal(response?.ok, false);
633+
if (response && !response.ok) {
634+
assert.equal(response.error.code, 'INVALID_ARGS');
635+
assert.match(response.error.message, /clipboard requires an active session or an explicit device selector/i);
636+
}
637+
});
638+
639+
test('clipboard read uses active session device', async () => {
640+
const sessionStore = makeSessionStore();
641+
const sessionName = 'ios-sim-session';
642+
sessionStore.set(
643+
sessionName,
644+
makeSession(sessionName, {
645+
platform: 'ios',
646+
id: 'sim-1',
647+
name: 'iPhone 17 Pro',
648+
kind: 'simulator',
649+
booted: true,
650+
}),
651+
);
652+
653+
const response = await handleSessionCommands({
654+
req: {
655+
token: 't',
656+
session: sessionName,
657+
command: 'clipboard',
658+
positionals: ['read'],
659+
flags: {},
660+
},
661+
sessionName,
662+
logPath: path.join(os.tmpdir(), 'daemon.log'),
663+
sessionStore,
664+
invoke: noopInvoke,
665+
ensureReady: async () => {},
666+
dispatch: async (device, command, positionals) => {
667+
assert.equal(device.id, 'sim-1');
668+
assert.equal(command, 'clipboard');
669+
assert.deepEqual(positionals, ['read']);
670+
return { action: 'read', text: 'otp-123456' };
671+
},
672+
resolveTargetDevice: async () => {
673+
throw new Error('resolveTargetDevice should not run');
674+
},
675+
});
676+
677+
assert.ok(response);
678+
assert.equal(response?.ok, true);
679+
if (response && response.ok) {
680+
assert.equal(response.data?.platform, 'ios');
681+
assert.equal(response.data?.action, 'read');
682+
assert.equal(response.data?.text, 'otp-123456');
683+
}
684+
});
685+
686+
test('clipboard write supports explicit selector without active session', async () => {
687+
const sessionStore = makeSessionStore();
688+
const selectedDevice: SessionState['device'] = {
689+
platform: 'android',
690+
id: 'emulator-5554',
691+
name: 'Pixel Emulator',
692+
kind: 'emulator',
693+
booted: true,
694+
};
695+
696+
const response = await handleSessionCommands({
697+
req: {
698+
token: 't',
699+
session: 'default',
700+
command: 'clipboard',
701+
positionals: ['write', 'hello', 'clipboard'],
702+
flags: { platform: 'android', serial: 'emulator-5554' },
703+
},
704+
sessionName: 'default',
705+
logPath: path.join(os.tmpdir(), 'daemon.log'),
706+
sessionStore,
707+
invoke: noopInvoke,
708+
ensureReady: async () => {},
709+
resolveTargetDevice: async () => selectedDevice,
710+
dispatch: async (device, command, positionals) => {
711+
assert.equal(device.id, 'emulator-5554');
712+
assert.equal(command, 'clipboard');
713+
assert.deepEqual(positionals, ['write', 'hello', 'clipboard']);
714+
return { action: 'write', textLength: 15 };
715+
},
716+
});
717+
718+
assert.ok(response);
719+
assert.equal(response?.ok, true);
720+
if (response && response.ok) {
721+
assert.equal(response.data?.platform, 'android');
722+
assert.equal(response.data?.action, 'write');
723+
assert.equal(response.data?.textLength, 15);
724+
}
725+
});
726+
727+
test('clipboard rejects unsupported iOS physical devices', async () => {
728+
const sessionStore = makeSessionStore();
729+
const sessionName = 'ios-device-session';
730+
sessionStore.set(
731+
sessionName,
732+
makeSession(sessionName, {
733+
platform: 'ios',
734+
id: 'ios-device-1',
735+
name: 'iPhone Device',
736+
kind: 'device',
737+
booted: true,
738+
}),
739+
);
740+
741+
const response = await handleSessionCommands({
742+
req: {
743+
token: 't',
744+
session: sessionName,
745+
command: 'clipboard',
746+
positionals: ['read'],
747+
flags: {},
748+
},
749+
sessionName,
750+
logPath: path.join(os.tmpdir(), 'daemon.log'),
751+
sessionStore,
752+
invoke: noopInvoke,
753+
ensureReady: async () => {},
754+
dispatch: async () => {
755+
throw new Error('dispatch should not run for unsupported targets');
756+
},
757+
});
758+
759+
assert.ok(response);
760+
assert.equal(response?.ok, false);
761+
if (response && !response.ok) {
762+
assert.equal(response.error.code, 'UNSUPPORTED_OPERATION');
763+
assert.match(response.error.message, /clipboard is not supported on this device/i);
764+
}
765+
});
766+
615767
test('open URL on existing iOS session clears stale app bundle id', async () => {
616768
const sessionStore = makeSessionStore();
617769
const sessionName = 'ios-session';

0 commit comments

Comments
 (0)