agent-device 0.4.0 → 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/README.md +20 -12
  2. package/dist/src/797.js +1 -0
  3. package/dist/src/bin.js +40 -29
  4. package/dist/src/daemon.js +21 -17
  5. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift +8 -2
  6. package/package.json +2 -2
  7. package/skills/agent-device/SKILL.md +23 -14
  8. package/skills/agent-device/references/permissions.md +7 -2
  9. package/skills/agent-device/references/session-management.md +5 -1
  10. package/src/__tests__/cli-close.test.ts +155 -0
  11. package/src/__tests__/cli-help.test.ts +102 -0
  12. package/src/cli.ts +68 -22
  13. package/src/core/__tests__/capabilities.test.ts +2 -1
  14. package/src/core/__tests__/dispatch-open.test.ts +25 -0
  15. package/src/core/__tests__/open-target.test.ts +40 -1
  16. package/src/core/capabilities.ts +1 -1
  17. package/src/core/dispatch.ts +22 -0
  18. package/src/core/open-target.ts +14 -0
  19. package/src/daemon/__tests__/device-ready.test.ts +52 -0
  20. package/src/daemon/__tests__/session-store.test.ts +23 -0
  21. package/src/daemon/device-ready.ts +146 -4
  22. package/src/daemon/handlers/__tests__/session.test.ts +477 -0
  23. package/src/daemon/handlers/session.ts +198 -93
  24. package/src/daemon/handlers/snapshot.ts +210 -185
  25. package/src/daemon/session-store.ts +16 -6
  26. package/src/daemon/types.ts +2 -1
  27. package/src/daemon-client.ts +138 -17
  28. package/src/daemon.ts +99 -9
  29. package/src/platforms/android/__tests__/index.test.ts +118 -1
  30. package/src/platforms/android/index.ts +77 -47
  31. package/src/platforms/ios/__tests__/index.test.ts +292 -4
  32. package/src/platforms/ios/__tests__/runner-client.test.ts +42 -0
  33. package/src/platforms/ios/apps.ts +358 -0
  34. package/src/platforms/ios/config.ts +28 -0
  35. package/src/platforms/ios/devicectl.ts +134 -0
  36. package/src/platforms/ios/devices.ts +15 -2
  37. package/src/platforms/ios/index.ts +20 -455
  38. package/src/platforms/ios/runner-client.ts +171 -69
  39. package/src/platforms/ios/simulator.ts +164 -0
  40. package/src/utils/__tests__/args.test.ts +66 -2
  41. package/src/utils/__tests__/daemon-client.test.ts +95 -0
  42. package/src/utils/__tests__/keyed-lock.test.ts +55 -0
  43. package/src/utils/__tests__/process-identity.test.ts +33 -0
  44. package/src/utils/args.ts +37 -1
  45. package/src/utils/command-schema.ts +58 -27
  46. package/src/utils/interactors.ts +2 -2
  47. package/src/utils/keyed-lock.ts +14 -0
  48. package/src/utils/process-identity.ts +100 -0
  49. package/src/utils/timeouts.ts +9 -0
  50. package/dist/src/274.js +0 -1
  51. package/src/daemon/__tests__/app-state.test.ts +0 -138
  52. package/src/daemon/app-state.ts +0 -65
@@ -216,14 +216,20 @@ final class RunnerTests: XCTestCase {
216
216
  }
217
217
 
218
218
  private func executeOnMain(command: Command) throws -> Response {
219
- let bundleId = command.appBundleId ?? currentBundleId ?? "com.apple.Preferences"
220
- if currentBundleId != bundleId {
219
+ let normalizedBundleId = command.appBundleId?
220
+ .trimmingCharacters(in: .whitespacesAndNewlines)
221
+ let requestedBundleId = (normalizedBundleId?.isEmpty == true) ? nil : normalizedBundleId
222
+ if let bundleId = requestedBundleId, currentBundleId != bundleId {
221
223
  let target = XCUIApplication(bundleIdentifier: bundleId)
222
224
  NSLog("AGENT_DEVICE_RUNNER_ACTIVATE bundle=%@ state=%d", bundleId, target.state.rawValue)
223
225
  // activate avoids terminating and relaunching the target app
224
226
  target.activate()
225
227
  currentApp = target
226
228
  currentBundleId = bundleId
229
+ } else if requestedBundleId == nil {
230
+ // Do not reuse stale bundle targets when the caller does not explicitly request one.
231
+ currentApp = nil
232
+ currentBundleId = nil
227
233
  }
228
234
  let activeApp = currentApp ?? app
229
235
  _ = activeApp.waitForExistence(timeout: 5)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-device",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
4
4
  "description": "Unified control plane for physical and virtual devices via an agent-driven CLI.",
5
5
  "license": "MIT",
6
6
  "author": "Callstack",
@@ -26,7 +26,7 @@
26
26
  "prepack": "pnpm build:node && pnpm build:axsnapshot",
27
27
  "typecheck": "tsc -p tsconfig.json",
28
28
  "test": "node --test",
29
- "test:unit": "node --test src/core/__tests__/*.test.ts src/daemon/__tests__/*.test.ts src/daemon/handlers/__tests__/*.test.ts src/platforms/**/__tests__/*.test.ts src/utils/**/__tests__/*.test.ts",
29
+ "test:unit": "node --test src/__tests__/*.test.ts src/core/__tests__/*.test.ts src/daemon/__tests__/*.test.ts src/daemon/handlers/__tests__/*.test.ts src/platforms/**/__tests__/*.test.ts src/utils/**/__tests__/*.test.ts",
30
30
  "test:smoke": "node --test test/integration/smoke-*.test.ts",
31
31
  "test:integration": "node --test test/integration/*.test.ts"
32
32
  },
@@ -27,7 +27,7 @@ npx -y agent-device
27
27
 
28
28
  ## Core workflow
29
29
 
30
- 1. Open app or deep link: `open [app|url]` (`open` handles target selection + boot/activation in the normal flow)
30
+ 1. Open app or deep link: `open [app|url] [url]` (`open` handles target selection + boot/activation in the normal flow)
31
31
  2. Snapshot: `snapshot` to get refs from accessibility tree
32
32
  3. Interact using refs (`click @ref`, `fill @ref "text"`)
33
33
  4. Re-snapshot after navigation/UI changes
@@ -39,13 +39,14 @@ npx -y agent-device
39
39
 
40
40
  ```bash
41
41
  agent-device boot # Ensure target is booted/ready without opening app
42
- agent-device boot --platform ios # Boot iOS simulator/device target
42
+ agent-device boot --platform ios # Boot iOS target
43
43
  agent-device boot --platform android # Boot Android emulator/device target
44
- agent-device open [app|url] # Boot device/simulator; optionally launch app or deep link URL
44
+ agent-device open [app|url] [url] # Boot device/simulator; optionally launch app or deep link URL
45
45
  agent-device open [app] --relaunch # Terminate app process first, then launch (fresh runtime)
46
46
  agent-device open [app] --activity com.example/.MainActivity # Android: open specific activity (app targets only)
47
47
  agent-device open "myapp://home" --platform android # Android deep link
48
- agent-device open "https://example.com" --platform ios # iOS simulator deep link (device unsupported)
48
+ agent-device open "https://example.com" --platform ios # iOS deep link (opens in browser)
49
+ agent-device open MyApp "myapp://screen/to" --platform ios # iOS deep link in app context
49
50
  agent-device close [app] # Close app or just end session
50
51
  agent-device reinstall <app> <path> # Uninstall + install app in one command
51
52
  agent-device session list # List active sessions
@@ -101,10 +102,12 @@ iOS settings helpers are simulator-only.
101
102
 
102
103
  ```bash
103
104
  agent-device appstate
104
- agent-device apps --metadata --platform ios
105
- agent-device apps --metadata --platform android
106
105
  ```
107
106
 
107
+ - Android: `appstate` reports live foreground package/activity.
108
+ - iOS: `appstate` is session-scoped and reports the app tracked by the active session on the target device.
109
+ - For iOS `appstate`, ensure a matching session exists (for example `open --session <name> --platform ios --device "<name>" <app>`).
110
+
108
111
  ### Interactions (use @refs from snapshot)
109
112
 
110
113
  ```bash
@@ -143,13 +146,16 @@ agent-device screenshot out.png
143
146
 
144
147
  ```bash
145
148
  agent-device open App --relaunch # Fresh app process restart in the current session
146
- agent-device open App --save-script # Save session script (.ad) on close
149
+ agent-device open App --save-script # Save session script (.ad) on close (default path)
150
+ agent-device open App --save-script ./workflows/app-flow.ad # Save to custom file path
147
151
  agent-device replay ./session.ad # Run deterministic replay from .ad script
148
152
  agent-device replay -u ./session.ad # Update selector drift and rewrite .ad script in place
149
153
  ```
150
154
 
151
155
  `replay` reads `.ad` recordings.
152
156
  `--relaunch` controls launch semantics; `--save-script` controls recording. Combine only when both are needed.
157
+ `--save-script` path is a file path; parent directories are created automatically.
158
+ For ambiguous bare values, use `--save-script=workflow.ad` or `./workflow.ad`.
153
159
 
154
160
  ### Trace logs (AX/XCTest)
155
161
 
@@ -164,9 +170,11 @@ agent-device trace stop ./trace.log # Stop and move trace log
164
170
 
165
171
  ```bash
166
172
  agent-device devices
167
- agent-device apps --platform ios
168
- agent-device apps --platform android # default: launchable only
169
- agent-device apps --platform android --all
173
+ agent-device apps --platform ios # iOS simulator + iOS device, includes default/system apps
174
+ agent-device apps --platform ios --all # explicit include-all (same as default)
175
+ agent-device apps --platform ios --user-installed
176
+ agent-device apps --platform android # includes default/system apps
177
+ agent-device apps --platform android --all # explicit include-all (same as default)
170
178
  agent-device apps --platform android --user-installed
171
179
  ```
172
180
 
@@ -181,15 +189,16 @@ agent-device apps --platform android --user-installed
181
189
  - Prefer `snapshot -i` to reduce output size.
182
190
  - On iOS, `xctest` is the default and does not require Accessibility permission.
183
191
  - If XCTest returns 0 nodes (foreground app changed), treat it as an explicit failure and retry the flow/app state.
184
- - `open <app|url>` can be used within an existing session to switch apps or open deep links.
185
- - `open <app>` updates session app bundle context; URL opens do not set an app bundle id.
192
+ - `open <app|url> [url]` can be used within an existing session to switch apps or open deep links.
193
+ - `open <app>` updates session app bundle context; `open <app> <url>` opens a deep link on iOS.
186
194
  - Use `open <app> --relaunch` during React Native/Fast Refresh debugging when you need a fresh app process without ending the session.
187
195
  - If AX returns the Simulator window or empty tree, restart Simulator or use `--backend xctest`.
188
196
  - Use `--session <name>` for parallel sessions; avoid device contention.
189
197
  - Use `--activity <component>` on Android to launch a specific activity (e.g. TV apps with LEANBACK); do not combine with URL opens.
190
- - iOS deep-link opens are simulator-only.
198
+ - On iOS devices, `http(s)://` URLs fall back to Safari automatically; custom scheme URLs require an active app in the session.
191
199
  - iOS physical-device runner requires Xcode signing/provisioning; optional overrides: `AGENT_DEVICE_IOS_TEAM_ID`, `AGENT_DEVICE_IOS_SIGNING_IDENTITY`, `AGENT_DEVICE_IOS_PROVISIONING_PROFILE`.
192
- - For long first-run physical-device setup/build, increase daemon timeout: `AGENT_DEVICE_DAEMON_TIMEOUT_MS=180000` (or higher).
200
+ - Default daemon request timeout is `45000`ms. For slow physical-device setup/build, increase `AGENT_DEVICE_DAEMON_TIMEOUT_MS` (for example `120000`).
201
+ - For daemon startup troubleshooting, follow stale metadata hints for `~/.agent-device/daemon.json` / `~/.agent-device/daemon.lock`.
193
202
  - Use `fill` when you want clear-then-type semantics.
194
203
  - Use `type` when you want to append/enter text without clearing.
195
204
  - On Android, prefer `fill` for important fields; it verifies entered text and retries once when IME reorders characters.
@@ -24,9 +24,14 @@ Use Automatic Signing in Xcode, or provide optional overrides:
24
24
  - `AGENT_DEVICE_IOS_SIGNING_IDENTITY`
25
25
  - `AGENT_DEVICE_IOS_PROVISIONING_PROFILE`
26
26
 
27
- If first-run setup/build takes long, increase:
27
+ If setup/build takes long, increase:
28
28
 
29
- - `AGENT_DEVICE_DAEMON_TIMEOUT_MS` (for example `180000`)
29
+ - `AGENT_DEVICE_DAEMON_TIMEOUT_MS` (default `45000`, for example `120000`)
30
+
31
+ If daemon startup fails with stale metadata hints, clean stale files and retry:
32
+
33
+ - `~/.agent-device/daemon.json`
34
+ - `~/.agent-device/daemon.lock`
30
35
 
31
36
  ## Simulator troubleshooting
32
37
 
@@ -14,8 +14,12 @@ Sessions isolate device context. A device can only be held by one session at a t
14
14
  - Name sessions semantically.
15
15
  - Close sessions when done.
16
16
  - Use separate sessions for parallel work.
17
- - In iOS sessions, use `open <app>` for simulator/device. `open <url>` is simulator-only.
17
+ - In iOS sessions, use `open <app>`. `open <url>` opens deep links; on devices `http(s)://` opens Safari when no app is active, and custom schemes require an active app in the session.
18
+ - In iOS sessions, `open <app> <url>` opens a deep link.
19
+ - On iOS, `appstate` is session-scoped and requires a matching active session on the target device.
18
20
  - For dev loops where runtime state can persist (for example React Native Fast Refresh), use `open <app> --relaunch` to restart the app process in the same session.
21
+ - Use `--save-script [path]` to record replay scripts on `close`; path is a file path and parent directories are created automatically.
22
+ - For ambiguous bare `--save-script` values, prefer `--save-script=workflow.ad` or `./workflow.ad`.
19
23
  - For deterministic replay scripts, prefer selector-based actions and assertions.
20
24
  - Use `replay -u` to update selector drift during maintenance.
21
25
 
@@ -0,0 +1,155 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { runCli } from '../cli.ts';
4
+ import { AppError } from '../utils/errors.ts';
5
+ import type { DaemonResponse } from '../daemon-client.ts';
6
+
7
+ class ExitSignal extends Error {
8
+ public readonly code: number;
9
+
10
+ constructor(code: number) {
11
+ super(`EXIT_${code}`);
12
+ this.code = code;
13
+ }
14
+ }
15
+
16
+ type RunResult = {
17
+ code: number | null;
18
+ stdout: string;
19
+ stderr: string;
20
+ daemonCalls: number;
21
+ };
22
+
23
+ async function runCliCapture(argv: string[]): Promise<RunResult> {
24
+ let daemonCalls = 0;
25
+ let stdout = '';
26
+ let stderr = '';
27
+ let code: number | null = null;
28
+
29
+ const originalExit = process.exit;
30
+ const originalStdoutWrite = process.stdout.write.bind(process.stdout);
31
+ const originalStderrWrite = process.stderr.write.bind(process.stderr);
32
+
33
+ (process as any).exit = ((nextCode?: number) => {
34
+ throw new ExitSignal(nextCode ?? 0);
35
+ }) as typeof process.exit;
36
+ (process.stdout as any).write = ((chunk: unknown) => {
37
+ stdout += String(chunk);
38
+ return true;
39
+ }) as typeof process.stdout.write;
40
+ (process.stderr as any).write = ((chunk: unknown) => {
41
+ stderr += String(chunk);
42
+ return true;
43
+ }) as typeof process.stderr.write;
44
+
45
+ const sendToDaemon = async (): Promise<DaemonResponse> => {
46
+ daemonCalls += 1;
47
+ throw new AppError('COMMAND_FAILED', 'Failed to start daemon', {
48
+ infoPath: '/tmp/daemon.json',
49
+ hint: 'stale daemon info',
50
+ });
51
+ };
52
+
53
+ try {
54
+ await runCli(argv, { sendToDaemon });
55
+ } catch (error) {
56
+ if (error instanceof ExitSignal) code = error.code;
57
+ else throw error;
58
+ } finally {
59
+ process.exit = originalExit;
60
+ process.stdout.write = originalStdoutWrite;
61
+ process.stderr.write = originalStderrWrite;
62
+ }
63
+
64
+ return { code, stdout, stderr, daemonCalls };
65
+ }
66
+
67
+ async function runCliCaptureWithErrorDetails(
68
+ argv: string[],
69
+ details: Record<string, unknown>,
70
+ message = 'Failed to start daemon',
71
+ ): Promise<RunResult> {
72
+ let daemonCalls = 0;
73
+ let stdout = '';
74
+ let stderr = '';
75
+ let code: number | null = null;
76
+
77
+ const originalExit = process.exit;
78
+ const originalStdoutWrite = process.stdout.write.bind(process.stdout);
79
+ const originalStderrWrite = process.stderr.write.bind(process.stderr);
80
+
81
+ (process as any).exit = ((nextCode?: number) => {
82
+ throw new ExitSignal(nextCode ?? 0);
83
+ }) as typeof process.exit;
84
+ (process.stdout as any).write = ((chunk: unknown) => {
85
+ stdout += String(chunk);
86
+ return true;
87
+ }) as typeof process.stdout.write;
88
+ (process.stderr as any).write = ((chunk: unknown) => {
89
+ stderr += String(chunk);
90
+ return true;
91
+ }) as typeof process.stderr.write;
92
+
93
+ const sendToDaemon = async (): Promise<DaemonResponse> => {
94
+ daemonCalls += 1;
95
+ throw new AppError('COMMAND_FAILED', message, details);
96
+ };
97
+
98
+ try {
99
+ await runCli(argv, { sendToDaemon });
100
+ } catch (error) {
101
+ if (error instanceof ExitSignal) code = error.code;
102
+ else throw error;
103
+ } finally {
104
+ process.exit = originalExit;
105
+ process.stdout.write = originalStdoutWrite;
106
+ process.stderr.write = originalStderrWrite;
107
+ }
108
+
109
+ return { code, stdout, stderr, daemonCalls };
110
+ }
111
+
112
+ test('close treats daemon startup failure as no-op', async () => {
113
+ const result = await runCliCapture(['close']);
114
+ assert.equal(result.code, null);
115
+ assert.equal(result.daemonCalls, 1);
116
+ assert.equal(result.stdout, '');
117
+ assert.equal(result.stderr, '');
118
+ });
119
+
120
+ test('close --json treats daemon startup failure as no-op success', async () => {
121
+ const result = await runCliCapture(['close', '--json']);
122
+ assert.equal(result.code, null);
123
+ assert.equal(result.daemonCalls, 1);
124
+ const payload = JSON.parse(result.stdout);
125
+ assert.equal(payload.success, true);
126
+ assert.equal(payload.data.closed, 'session');
127
+ assert.equal(payload.data.source, 'no-daemon');
128
+ assert.equal(result.stderr, '');
129
+ });
130
+
131
+ test('close treats lock-only daemon startup failure as no-op', async () => {
132
+ const result = await runCliCaptureWithErrorDetails(['close'], {
133
+ lockPath: '/tmp/daemon.lock',
134
+ hint: 'stale daemon lock',
135
+ });
136
+ assert.equal(result.code, null);
137
+ assert.equal(result.daemonCalls, 1);
138
+ assert.equal(result.stdout, '');
139
+ assert.equal(result.stderr, '');
140
+ });
141
+
142
+ test('close treats structured daemon startup failure as no-op without relying on message text', async () => {
143
+ const result = await runCliCaptureWithErrorDetails(
144
+ ['close'],
145
+ {
146
+ kind: 'daemon_startup_failed',
147
+ lockPath: '/tmp/daemon.lock',
148
+ },
149
+ 'daemon bootstrap failed',
150
+ );
151
+ assert.equal(result.code, null);
152
+ assert.equal(result.daemonCalls, 1);
153
+ assert.equal(result.stdout, '');
154
+ assert.equal(result.stderr, '');
155
+ });
@@ -0,0 +1,102 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { runCli } from '../cli.ts';
4
+ import type { 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
+ daemonCalls: number;
20
+ };
21
+
22
+ async function runCliCapture(argv: string[]): Promise<RunResult> {
23
+ let daemonCalls = 0;
24
+ let stdout = '';
25
+ let stderr = '';
26
+ let code: number | null = null;
27
+
28
+ const originalExit = process.exit;
29
+ const originalStdoutWrite = process.stdout.write.bind(process.stdout);
30
+ const originalStderrWrite = process.stderr.write.bind(process.stderr);
31
+
32
+ (process as any).exit = ((nextCode?: number) => {
33
+ throw new ExitSignal(nextCode ?? 0);
34
+ }) as typeof process.exit;
35
+ (process.stdout as any).write = ((chunk: unknown) => {
36
+ stdout += String(chunk);
37
+ return true;
38
+ }) as typeof process.stdout.write;
39
+ (process.stderr as any).write = ((chunk: unknown) => {
40
+ stderr += String(chunk);
41
+ return true;
42
+ }) as typeof process.stderr.write;
43
+
44
+ const sendToDaemon = async (): Promise<DaemonResponse> => {
45
+ daemonCalls += 1;
46
+ return { ok: true, data: {} };
47
+ };
48
+
49
+ try {
50
+ await runCli(argv, { sendToDaemon });
51
+ } catch (error) {
52
+ if (error instanceof ExitSignal) code = error.code;
53
+ else throw error;
54
+ } finally {
55
+ process.exit = originalExit;
56
+ process.stdout.write = originalStdoutWrite;
57
+ process.stderr.write = originalStderrWrite;
58
+ }
59
+
60
+ return { code, stdout, stderr, daemonCalls };
61
+ }
62
+
63
+ test('help appstate prints command help and skips daemon dispatch', async () => {
64
+ const result = await runCliCapture(['help', 'appstate']);
65
+ assert.equal(result.code, 0);
66
+ assert.equal(result.daemonCalls, 0);
67
+ assert.match(result.stdout, /Show foreground app\/activity/);
68
+ assert.doesNotMatch(result.stdout, /Command flags:/);
69
+ assert.match(result.stdout, /Global flags:/);
70
+ });
71
+
72
+ test('appstate --help prints command help and skips daemon dispatch', async () => {
73
+ const result = await runCliCapture(['appstate', '--help']);
74
+ assert.equal(result.code, 0);
75
+ assert.equal(result.daemonCalls, 0);
76
+ assert.match(result.stdout, /Usage:\n agent-device appstate/);
77
+ assert.match(result.stdout, /Global flags:/);
78
+ });
79
+
80
+ test('help unknown command prints error plus global usage and skips daemon dispatch', async () => {
81
+ const result = await runCliCapture(['help', 'not-a-command']);
82
+ assert.equal(result.code, 1);
83
+ assert.equal(result.daemonCalls, 0);
84
+ assert.match(result.stderr, /Error \(INVALID_ARGS\): Unknown command: not-a-command/);
85
+ assert.match(result.stdout, /Commands:/);
86
+ assert.match(result.stdout, /Flags:/);
87
+ });
88
+
89
+ test('unknown command --help prints error plus global usage and skips daemon dispatch', async () => {
90
+ const result = await runCliCapture(['not-a-command', '--help']);
91
+ assert.equal(result.code, 1);
92
+ assert.equal(result.daemonCalls, 0);
93
+ assert.match(result.stderr, /Error \(INVALID_ARGS\): Unknown command: not-a-command/);
94
+ assert.match(result.stdout, /Commands:/);
95
+ });
96
+
97
+ test('help rejects multiple positional commands and skips daemon dispatch', async () => {
98
+ const result = await runCliCapture(['help', 'appstate', 'extra']);
99
+ assert.equal(result.code, 1);
100
+ assert.equal(result.daemonCalls, 0);
101
+ assert.match(result.stderr, /Error \(INVALID_ARGS\): help accepts at most one command/);
102
+ });
package/src/cli.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { parseArgs, toDaemonFlags, usage } from './utils/args.ts';
1
+ import { parseArgs, toDaemonFlags, usage, usageForCommand } from './utils/args.ts';
2
2
  import { asAppError, AppError } from './utils/errors.ts';
3
3
  import { formatSnapshotText, printHumanError, printJson } from './utils/output.ts';
4
4
  import { readVersion } from './utils/version.ts';
@@ -8,7 +8,15 @@ import fs from 'node:fs';
8
8
  import os from 'node:os';
9
9
  import path from 'node:path';
10
10
 
11
- export async function runCli(argv: string[]): Promise<void> {
11
+ type CliDeps = {
12
+ sendToDaemon: typeof sendToDaemon;
13
+ };
14
+
15
+ const DEFAULT_CLI_DEPS: CliDeps = {
16
+ sendToDaemon,
17
+ };
18
+
19
+ export async function runCli(argv: string[], deps: CliDeps = DEFAULT_CLI_DEPS): Promise<void> {
12
20
  const parsed = parseArgs(argv);
13
21
  for (const warning of parsed.warnings) {
14
22
  process.stderr.write(`Warning: ${warning}\n`);
@@ -19,9 +27,31 @@ export async function runCli(argv: string[]): Promise<void> {
19
27
  process.exit(0);
20
28
  }
21
29
 
22
- if (parsed.flags.help || !parsed.command) {
30
+ const isHelpAlias = parsed.command === 'help';
31
+ const isHelpFlag = parsed.flags.help;
32
+ if (isHelpAlias || isHelpFlag) {
33
+ if (isHelpAlias && parsed.positionals.length > 1) {
34
+ printHumanError(new AppError('INVALID_ARGS', 'help accepts at most one command.'));
35
+ process.exit(1);
36
+ }
37
+ const helpTarget = isHelpAlias ? parsed.positionals[0] : parsed.command;
38
+ if (!helpTarget) {
39
+ process.stdout.write(`${usage()}\n`);
40
+ process.exit(0);
41
+ }
42
+ const commandHelp = usageForCommand(helpTarget);
43
+ if (commandHelp) {
44
+ process.stdout.write(commandHelp);
45
+ process.exit(0);
46
+ }
47
+ printHumanError(new AppError('INVALID_ARGS', `Unknown command: ${helpTarget}`));
48
+ process.stdout.write(`${usage()}\n`);
49
+ process.exit(1);
50
+ }
51
+
52
+ if (!parsed.command) {
23
53
  process.stdout.write(`${usage()}\n`);
24
- process.exit(parsed.flags.help ? 0 : 1);
54
+ process.exit(1);
25
55
  }
26
56
 
27
57
  const { command, positionals, flags } = parsed;
@@ -34,7 +64,7 @@ export async function runCli(argv: string[]): Promise<void> {
34
64
  if (sub !== 'list') {
35
65
  throw new AppError('INVALID_ARGS', 'session only supports list');
36
66
  }
37
- const response = await sendToDaemon({
67
+ const response = await deps.sendToDaemon({
38
68
  session: sessionName,
39
69
  command: 'session_list',
40
70
  positionals: [],
@@ -47,7 +77,7 @@ export async function runCli(argv: string[]): Promise<void> {
47
77
  return;
48
78
  }
49
79
 
50
- const response = await sendToDaemon({
80
+ const response = await deps.sendToDaemon({
51
81
  session: sessionName,
52
82
  command: command!,
53
83
  positionals,
@@ -149,9 +179,6 @@ export async function runCli(argv: string[]): Promise<void> {
149
179
  const bundleId = app.bundleId ?? app.package;
150
180
  const name = app.name ?? app.label;
151
181
  if (name && bundleId) return `${name} (${bundleId})`;
152
- if (bundleId && typeof app.launchable === 'boolean') {
153
- return `${bundleId} (launchable=${app.launchable})`;
154
- }
155
182
  if (bundleId) return String(bundleId);
156
183
  return JSON.stringify(app);
157
184
  }
@@ -169,7 +196,7 @@ export async function runCli(argv: string[]): Promise<void> {
169
196
  const pkg = (data as any)?.package;
170
197
  const activity = (data as any)?.activity;
171
198
  if (platform === 'ios') {
172
- process.stdout.write(`Foreground app: ${appName ?? appBundleId}\n`);
199
+ process.stdout.write(`Foreground app: ${appName ?? appBundleId ?? 'unknown'}\n`);
173
200
  if (appBundleId) process.stdout.write(`Bundle: ${appBundleId}\n`);
174
201
  if (source) process.stdout.write(`Source: ${source}\n`);
175
202
  if (logTailStopper) logTailStopper();
@@ -190,6 +217,13 @@ export async function runCli(argv: string[]): Promise<void> {
190
217
  throw new AppError(response.error.code as any, response.error.message, response.error.details);
191
218
  } catch (err) {
192
219
  const appErr = asAppError(err);
220
+ if (command === 'close' && isDaemonStartupFailure(appErr)) {
221
+ if (flags.json) {
222
+ printJson({ success: true, data: { closed: 'session', source: 'no-daemon' } });
223
+ }
224
+ if (logTailStopper) logTailStopper();
225
+ return;
226
+ }
193
227
  if (flags.json) {
194
228
  printJson({
195
229
  success: false,
@@ -199,9 +233,6 @@ export async function runCli(argv: string[]): Promise<void> {
199
233
  printHumanError(appErr);
200
234
  if (flags.verbose) {
201
235
  try {
202
- const fs = await import('node:fs');
203
- const os = await import('node:os');
204
- const path = await import('node:path');
205
236
  const logPath = path.join(os.homedir(), '.agent-device', 'daemon.log');
206
237
  if (fs.existsSync(logPath)) {
207
238
  const content = fs.readFileSync(logPath, 'utf8');
@@ -221,6 +252,13 @@ export async function runCli(argv: string[]): Promise<void> {
221
252
  }
222
253
  }
223
254
 
255
+ function isDaemonStartupFailure(error: AppError): boolean {
256
+ if (error.code !== 'COMMAND_FAILED') return false;
257
+ if (error.details?.kind === 'daemon_startup_failed') return true;
258
+ if (!error.message.toLowerCase().includes('failed to start daemon')) return false;
259
+ return typeof error.details?.infoPath === 'string' || typeof error.details?.lockPath === 'string';
260
+ }
261
+
224
262
  const isDirectRun = pathToFileURL(process.argv[1] ?? '').href === import.meta.url;
225
263
  if (isDirectRun) {
226
264
  runCli(process.argv.slice(2)).catch((err) => {
@@ -238,15 +276,23 @@ function startDaemonLogTail(): (() => void) | null {
238
276
  const interval = setInterval(() => {
239
277
  if (stopped) return;
240
278
  if (!fs.existsSync(logPath)) return;
241
- const stats = fs.statSync(logPath);
242
- if (stats.size <= offset) return;
243
- const fd = fs.openSync(logPath, 'r');
244
- const buffer = Buffer.alloc(stats.size - offset);
245
- fs.readSync(fd, buffer, 0, buffer.length, offset);
246
- fs.closeSync(fd);
247
- offset = stats.size;
248
- if (buffer.length > 0) {
249
- process.stdout.write(buffer.toString('utf8'));
279
+ try {
280
+ const stats = fs.statSync(logPath);
281
+ if (stats.size < offset) offset = 0;
282
+ if (stats.size <= offset) return;
283
+ const fd = fs.openSync(logPath, 'r');
284
+ try {
285
+ const buffer = Buffer.alloc(stats.size - offset);
286
+ fs.readSync(fd, buffer, 0, buffer.length, offset);
287
+ offset = stats.size;
288
+ if (buffer.length > 0) {
289
+ process.stdout.write(buffer.toString('utf8'));
290
+ }
291
+ } finally {
292
+ fs.closeSync(fd);
293
+ }
294
+ } catch {
295
+ // Best-effort tailing should not crash CLI flow.
250
296
  }
251
297
  }, 200);
252
298
  return () => {
@@ -33,7 +33,7 @@ test('iOS simulator-only commands reject iOS devices and Android', () => {
33
33
  });
34
34
 
35
35
  test('simulator-only iOS commands with Android support reject iOS devices', () => {
36
- for (const cmd of ['apps', 'reinstall', 'record', 'settings', 'swipe']) {
36
+ for (const cmd of ['reinstall', 'record', 'settings', 'swipe']) {
37
37
  assert.equal(isCommandSupportedOnDevice(cmd, iosSimulator), true, `${cmd} on iOS sim`);
38
38
  assert.equal(isCommandSupportedOnDevice(cmd, iosDevice), false, `${cmd} on iOS device`);
39
39
  assert.equal(isCommandSupportedOnDevice(cmd, androidDevice), true, `${cmd} on Android`);
@@ -43,6 +43,7 @@ test('simulator-only iOS commands with Android support reject iOS devices', () =
43
43
  test('core commands support iOS simulator, iOS device, and Android', () => {
44
44
  for (const cmd of [
45
45
  'app-switcher',
46
+ 'apps',
46
47
  'back',
47
48
  'boot',
48
49
  'click',
@@ -0,0 +1,25 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { dispatchCommand } from '../dispatch.ts';
4
+ import { AppError } from '../../utils/errors.ts';
5
+ import type { DeviceInfo } from '../../utils/device.ts';
6
+
7
+ test('dispatch open rejects URL as first argument when second URL is provided', async () => {
8
+ const device: DeviceInfo = {
9
+ platform: 'ios',
10
+ id: 'sim-1',
11
+ name: 'iPhone 15',
12
+ kind: 'simulator',
13
+ booted: true,
14
+ };
15
+
16
+ await assert.rejects(
17
+ () => dispatchCommand(device, 'open', ['myapp://first', 'myapp://second']),
18
+ (error: unknown) => {
19
+ assert.equal(error instanceof AppError, true);
20
+ assert.equal((error as AppError).code, 'INVALID_ARGS');
21
+ assert.match((error as AppError).message, /requires an app target as the first argument/i);
22
+ return true;
23
+ },
24
+ );
25
+ });