@phnx-labs/agents-cli 1.20.6 → 1.20.8

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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,19 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 1.20.7
6
+
7
+ **`agents inspect` — DotAgents repo targets (#256)**
8
+
9
+ - `agents inspect` now accepts a DotAgents repo as the target, not just an installed agent: `user` (~/.agents/), `system` (~/.agents/.system/), `project` (nearest `.agents/` from cwd), any extra-repo alias registered via `agents repo add`, or a filesystem path. Paths accept either a repo containing a `.agents/` dir or a DotAgents root directly.
10
+ - Repo summary shows the root (OSC-8 linked), git branch / dirty count / origin URL, manifest files (`agents.yaml`, `hooks.yaml`), and per-kind resource counts. All existing drill-down flags (`--commands`, `--skills`, `--plugins`, ... with fuzzy queries and `--json`) work against the single repo root — what is physically in that repo, with no layered resolution or same-name overrides.
11
+ - Resolution precedence: a directory that is itself a DotAgents root wins over its nested `.agents/`, so extra repos that keep resources at the top level and use `.agents/` only for worktrees resolve to their real resources.
12
+ - Unknown targets now error with both halves of the namespace: the known agent ids and the available repo targets (built-in layers plus registered aliases).
13
+
14
+ **`scripts/install.sh` — bash 3.2 fix (#256)**
15
+
16
+ - `set -u` plus `"${BUILD_ARGS[@]}"` on an empty array aborted the dev install with `BUILD_ARGS[@]: unbound variable` under macOS system bash; the expansion is now guarded with `${BUILD_ARGS[@]+...}`.
17
+
5
18
  ## 1.20.5
6
19
 
7
20
  **`agents inspect` — per-agent+version detail view with drill-down (#217)**
@@ -0,0 +1,55 @@
1
+ import { Command } from 'commander';
2
+ import { type ComputerClient, type RPCResponse } from '../lib/computer-rpc.js';
3
+ export interface AppInfo {
4
+ pid: number;
5
+ name: string;
6
+ bundle_id: string;
7
+ active: boolean;
8
+ }
9
+ export declare function pickTarget(list: AppInfo[], opts: {
10
+ pid?: number;
11
+ bundle?: string;
12
+ }): {
13
+ ok: true;
14
+ app: AppInfo;
15
+ } | {
16
+ ok: false;
17
+ error: string;
18
+ };
19
+ export declare function parseXY(s: string, flag: string): {
20
+ x: number;
21
+ y: number;
22
+ };
23
+ export declare function buildElementOrCoords(opts: {
24
+ id?: string;
25
+ x?: number;
26
+ y?: number;
27
+ }): {
28
+ ok: true;
29
+ params: Record<string, unknown>;
30
+ } | {
31
+ ok: false;
32
+ error: string;
33
+ };
34
+ export declare function buildRaiseParams(opts: {
35
+ windowId?: number;
36
+ title?: string;
37
+ }): Record<string, unknown>;
38
+ export declare function buildWaitParams(opts: {
39
+ duration?: number;
40
+ id?: string;
41
+ until?: string;
42
+ role?: string;
43
+ label?: string;
44
+ identifier?: string;
45
+ timeout?: number;
46
+ }): {
47
+ ok: true;
48
+ params: Record<string, unknown>;
49
+ } | {
50
+ ok: false;
51
+ error: string;
52
+ };
53
+ export declare function withClient<T>(fn: (client: ComputerClient) => Promise<T>): Promise<T>;
54
+ export declare function unwrap(r: RPCResponse): Record<string, unknown>;
55
+ export declare function registerActionCommands(program: Command): void;
@@ -0,0 +1,486 @@
1
+ // Action verbs for `agents computer` — the interaction surface over the
2
+ // computer-helper daemon's RPC methods (click, type, key, drag, scroll,
3
+ // describe, ax-action, focus, ...). These mirror `agents browser`'s verb
4
+ // layout: flat verbs under the noun, bundle-id targeting, --json on reads.
5
+ //
6
+ // The daemon already implements every method; this file is the thin, typed
7
+ // CLI skin over it plus a shared target resolver so callers stay in bundle-id
8
+ // space and never hand-manage pids.
9
+ import { openComputerClient, describeTransport, } from '../lib/computer-rpc.js';
10
+ // Pure target picker — exercised by unit tests. Precedence: explicit --pid,
11
+ // then --bundle, then the frontmost active allow-listed app (the same default
12
+ // `screenshot` uses). Kept side-effect-free so the resolution rules are
13
+ // testable without a live daemon.
14
+ export function pickTarget(list, opts) {
15
+ if (opts.pid != null) {
16
+ const app = list.find((a) => a.pid === opts.pid);
17
+ // A --pid the daemon doesn't list (not allow-listed / not running) is
18
+ // still passed through: the daemon is the authority and will return a
19
+ // precise permission_denied / app_not_found. We don't second-guess it.
20
+ return { ok: true, app: app ?? { pid: opts.pid, name: '', bundle_id: '', active: false } };
21
+ }
22
+ if (opts.bundle) {
23
+ const app = list.find((a) => a.bundle_id === opts.bundle);
24
+ if (!app) {
25
+ return {
26
+ ok: false,
27
+ error: `bundle not in allow list (or not running): ${opts.bundle}\nadd Computer(${opts.bundle}) to a permissions group, then \`agents computer reload\``,
28
+ };
29
+ }
30
+ return { ok: true, app };
31
+ }
32
+ const active = list.find((a) => a.active);
33
+ if (!active) {
34
+ return {
35
+ ok: false,
36
+ error: 'no active app found in allow list\nadd Computer(<bundle-id>) to a permissions group, then `agents computer reload`',
37
+ };
38
+ }
39
+ return { ok: true, app: active };
40
+ }
41
+ // Parse an "x,y" coordinate pair. Pure + tested.
42
+ export function parseXY(s, flag) {
43
+ const parts = s.split(',').map((v) => v.trim());
44
+ if (parts.length !== 2) {
45
+ throw new Error(`${flag} must be "x,y" (got: ${s})`);
46
+ }
47
+ const x = Number(parts[0]);
48
+ const y = Number(parts[1]);
49
+ if (!Number.isFinite(x) || !Number.isFinite(y)) {
50
+ throw new Error(`${flag} must be two numbers "x,y" (got: ${s})`);
51
+ }
52
+ return { x, y };
53
+ }
54
+ // Build the element-or-coords target spec shared by click/type/scroll/etc.
55
+ // Pure + tested: returns the params fragment or an error string.
56
+ export function buildElementOrCoords(opts) {
57
+ if (opts.id)
58
+ return { ok: true, params: { element_id: opts.id } };
59
+ if (opts.x != null && opts.y != null)
60
+ return { ok: true, params: { x: opts.x, y: opts.y } };
61
+ return { ok: false, error: 'pass --id <@eN> (from `describe`) or --x <n> --y <n>' };
62
+ }
63
+ // Build the focus_window params for `raise`. Pure + tested: window_id and
64
+ // title are both optional refinements over the app-level activate.
65
+ export function buildRaiseParams(opts) {
66
+ const params = {};
67
+ if (opts.windowId != null)
68
+ params.window_id = opts.windowId;
69
+ if (opts.title)
70
+ params.title = opts.title;
71
+ return params;
72
+ }
73
+ // Build the wait RPC params. Pure + tested. Three modes, mirroring the
74
+ // daemon's Wait.run: --duration (unconditional sleep), --id + --until
75
+ // (cached-element poll), or --role/--label/--identifier (live locator poll).
76
+ export function buildWaitParams(opts) {
77
+ if (opts.duration != null) {
78
+ return { ok: true, params: { duration_ms: opts.duration } };
79
+ }
80
+ const params = {};
81
+ if (opts.until)
82
+ params.until = opts.until;
83
+ if (opts.timeout != null)
84
+ params.timeout_ms = opts.timeout;
85
+ if (opts.id) {
86
+ return { ok: true, params: { ...params, element_id: opts.id } };
87
+ }
88
+ const locator = {};
89
+ if (opts.role)
90
+ locator.role = opts.role;
91
+ if (opts.label)
92
+ locator.label = opts.label;
93
+ if (opts.identifier)
94
+ locator.identifier = opts.identifier;
95
+ if (Object.keys(locator).length === 0) {
96
+ return { ok: false, error: 'pass --duration <ms>, --id <@eN>, or a locator (--role/--label/--identifier)' };
97
+ }
98
+ return { ok: true, params: { ...params, locator } };
99
+ }
100
+ // postToPid keyboard delivery is dropped by key-window-gated apps (Parallels
101
+ // VMs and friends) — when the daemon reports the target was not frontmost,
102
+ // the keystrokes may have landed nowhere. Surface that loudly on stderr.
103
+ function warnIfNotFrontmost(res) {
104
+ if (res.frontmost === false) {
105
+ console.error('warning: target was not the frontmost app — keystrokes may have been dropped. Run `agents computer raise` first.');
106
+ }
107
+ }
108
+ function reportMissingHelper() {
109
+ console.error('helper not built. Run: ./packages/computer-helper/scripts/build.sh debug');
110
+ process.exit(1);
111
+ }
112
+ // Open a client, run fn, always close. Fails fast if no helper is present.
113
+ export async function withClient(fn) {
114
+ if (describeTransport().kind === 'none')
115
+ reportMissingHelper();
116
+ const client = openComputerClient();
117
+ try {
118
+ return await fn(client);
119
+ }
120
+ finally {
121
+ await client.close();
122
+ }
123
+ }
124
+ // Unwrap an RPC response: print + exit on error, else return result.
125
+ export function unwrap(r) {
126
+ if (r.error) {
127
+ console.error(`error: ${r.error.code}: ${r.error.message}`);
128
+ process.exit(1);
129
+ }
130
+ return r.result ?? {};
131
+ }
132
+ // Resolve the target pid via list_apps + pickTarget, printing a precise error
133
+ // and exiting when no target matches.
134
+ async function resolveTargetPid(client, opts) {
135
+ // A directly-supplied pid skips the list_apps roundtrip — the daemon gates.
136
+ if (opts.pid != null)
137
+ return opts.pid;
138
+ const apps = unwrap(await client.call('list_apps'));
139
+ const list = apps.apps || [];
140
+ const picked = pickTarget(list, opts);
141
+ if (!picked.ok) {
142
+ console.error(picked.error);
143
+ process.exit(1);
144
+ }
145
+ return picked.app.pid;
146
+ }
147
+ // --raise flag: app-level focus_window before the main action so coordinate
148
+ // clicks and keystrokes land on a visible, key window.
149
+ async function raiseIfRequested(client, pid, raise) {
150
+ if (raise)
151
+ unwrap(await client.call('focus_window', { pid }));
152
+ }
153
+ function emit(result, json, human) {
154
+ if (json) {
155
+ console.log(JSON.stringify(result, null, 2));
156
+ }
157
+ else {
158
+ console.log(human());
159
+ }
160
+ }
161
+ // Add the shared --pid/--bundle target options to a verb.
162
+ function addTargetOpts(cmd) {
163
+ return cmd
164
+ .option('--bundle <id>', 'Bundle id of the target app (default: frontmost allow-listed app)')
165
+ .option('--pid <n>', 'Target pid directly (overrides --bundle)', (v) => parseInt(v, 10));
166
+ }
167
+ // Add the shared --id/--x/--y element-or-coords options to a verb.
168
+ function addElementOrCoordOpts(cmd) {
169
+ return cmd
170
+ .option('--id <@eN>', 'Element id from `describe`')
171
+ .option('--x <n>', 'X coordinate (global, points)', (v) => parseInt(v, 10))
172
+ .option('--y <n>', 'Y coordinate (global, points)', (v) => parseInt(v, 10));
173
+ }
174
+ export function registerActionCommands(program) {
175
+ // apps — list_apps
176
+ addTargetOpts(program
177
+ .command('apps')
178
+ .description('List apps the daemon may drive (allow-listed + running)')
179
+ .option('--json', 'Emit JSON')).action(async (opts) => {
180
+ await withClient(async (client) => {
181
+ const res = unwrap(await client.call('list_apps'));
182
+ const list = res.apps || [];
183
+ emit(res, Boolean(opts.json), () => list.length === 0
184
+ ? '(no allow-listed apps running)'
185
+ : list
186
+ .map((a) => `${a.active ? '*' : ' '} ${String(a.pid).padStart(6)} ${a.bundle_id} ${a.name}`)
187
+ .join('\n'));
188
+ });
189
+ });
190
+ // describe — AX tree
191
+ addTargetOpts(program
192
+ .command('describe')
193
+ .description('Dump the accessibility tree (element ids feed click/type --id)')
194
+ .option('--depth <n>', 'Max tree depth', (v) => parseInt(v, 10))
195
+ .option('--json', 'Emit compact JSON (default: pretty)')).action(async (opts) => {
196
+ await withClient(async (client) => {
197
+ const pid = await resolveTargetPid(client, opts);
198
+ const params = { pid };
199
+ if (opts.depth != null)
200
+ params.max_depth = opts.depth;
201
+ const res = unwrap(await client.call('describe', params));
202
+ // The tree is inherently structured — always JSON, pretty unless --json.
203
+ console.log(JSON.stringify(opts.json ? res : res.tree ?? res, null, 2));
204
+ });
205
+ });
206
+ // click
207
+ addElementOrCoordOpts(addTargetOpts(program
208
+ .command('click')
209
+ .description('Click an element (--id) or screen coordinate (--x --y)')
210
+ .option('--count <n>', 'Click count (2 = double-click)', (v) => parseInt(v, 10))
211
+ .option('--background', 'Focus-safe postToPid delivery (plain AppKit only; skips HID tap)')
212
+ .option('--raise', 'Bring the target app to the front first')
213
+ .option('--json', 'Emit JSON'))).action(async (opts) => {
214
+ await withClient(async (client) => {
215
+ const pid = await resolveTargetPid(client, opts);
216
+ const spec = buildElementOrCoords(opts);
217
+ if (!spec.ok) {
218
+ console.error(spec.error);
219
+ process.exit(1);
220
+ }
221
+ await raiseIfRequested(client, pid, opts.raise);
222
+ const params = { pid, ...spec.params };
223
+ if (opts.count != null)
224
+ params.count = opts.count;
225
+ if (opts.background)
226
+ params.background = true;
227
+ const res = unwrap(await client.call('click', params));
228
+ emit(res, Boolean(opts.json), () => `clicked (${res.action ?? 'ok'})`);
229
+ });
230
+ });
231
+ // right-click
232
+ addElementOrCoordOpts(addTargetOpts(program
233
+ .command('right-click')
234
+ .description('Right-click (context menu) an element or coordinate')
235
+ .option('--json', 'Emit JSON'))).action(async (opts) => {
236
+ await withClient(async (client) => {
237
+ const pid = await resolveTargetPid(client, opts);
238
+ const spec = buildElementOrCoords(opts);
239
+ if (!spec.ok) {
240
+ console.error(spec.error);
241
+ process.exit(1);
242
+ }
243
+ const res = unwrap(await client.call('right_click', { pid, ...spec.params }));
244
+ emit(res, Boolean(opts.json), () => `right-clicked (${res.method ?? 'ok'})`);
245
+ });
246
+ });
247
+ // type — set value on a field (--id) or paste at coords, optional commit
248
+ addElementOrCoordOpts(addTargetOpts(program
249
+ .command('type')
250
+ .description('Set a field value (--id) or paste at a coordinate (--x --y)')
251
+ .requiredOption('--text <s>', 'Text to enter')
252
+ .option('--commit', 'Commit after typing (AXConfirm / Return) so the value reaches the model')
253
+ .option('--allow-secure-field', 'Permit typing into a password field')
254
+ .option('--json', 'Emit JSON'))).action(async (opts) => {
255
+ await withClient(async (client) => {
256
+ const pid = await resolveTargetPid(client, opts);
257
+ const spec = buildElementOrCoords(opts);
258
+ if (!spec.ok) {
259
+ console.error(spec.error);
260
+ process.exit(1);
261
+ }
262
+ const params = { pid, ...spec.params, text: opts.text };
263
+ if (opts.commit)
264
+ params.commit = true;
265
+ if (opts.allowSecureField)
266
+ params.allow_secure_field = true;
267
+ const res = unwrap(await client.call('type', params));
268
+ emit(res, Boolean(opts.json), () => `typed ${opts.text.length} char(s)${res.committed ? ' (committed)' : ''}`);
269
+ });
270
+ });
271
+ // type-text — stream an arbitrary unicode string into the focused field
272
+ addTargetOpts(program
273
+ .command('type-text')
274
+ .description('Type an arbitrary unicode string into the focused field (focus first via click/focus)')
275
+ .requiredOption('--text <s>', 'Text to type')
276
+ .option('--commit', 'Press Return after typing')
277
+ .option('--raise', 'Bring the target app to the front first')
278
+ .option('--require-frontmost', 'Fail (not warn) if the target is not the frontmost app')
279
+ .option('--json', 'Emit JSON')).action(async (opts) => {
280
+ await withClient(async (client) => {
281
+ const pid = await resolveTargetPid(client, opts);
282
+ await raiseIfRequested(client, pid, opts.raise);
283
+ const params = { pid, text: opts.text };
284
+ if (opts.commit)
285
+ params.commit = true;
286
+ if (opts.requireFrontmost)
287
+ params.require_frontmost = true;
288
+ const res = unwrap(await client.call('type_text', params));
289
+ warnIfNotFrontmost(res);
290
+ emit(res, Boolean(opts.json), () => `typed ${res.chars ?? opts.text.length} char(s)`);
291
+ });
292
+ });
293
+ // key — single chord
294
+ addTargetOpts(program
295
+ .command('key')
296
+ .description('Send a key chord, e.g. "cmd+shift+s", "enter", "esc"')
297
+ .requiredOption('--keys <chord>', 'Key chord')
298
+ .option('--raise', 'Bring the target app to the front first')
299
+ .option('--require-frontmost', 'Fail (not warn) if the target is not the frontmost app')
300
+ .option('--json', 'Emit JSON')).action(async (opts) => {
301
+ await withClient(async (client) => {
302
+ const pid = await resolveTargetPid(client, opts);
303
+ await raiseIfRequested(client, pid, opts.raise);
304
+ const params = { pid, keys: opts.keys };
305
+ if (opts.requireFrontmost)
306
+ params.require_frontmost = true;
307
+ const res = unwrap(await client.call('key', params));
308
+ warnIfNotFrontmost(res);
309
+ emit(res, Boolean(opts.json), () => `sent ${opts.keys}`);
310
+ });
311
+ });
312
+ // drag — from one point to another
313
+ addTargetOpts(program
314
+ .command('drag')
315
+ .description('Drag from one coordinate to another')
316
+ .requiredOption('--from <x,y>', 'Start coordinate "x,y"')
317
+ .requiredOption('--to <x,y>', 'End coordinate "x,y"')
318
+ .option('--button <left|right>', 'Mouse button', 'left')
319
+ .option('--background', 'Focus-safe postToPid delivery (plain AppKit only)')
320
+ .option('--raise', 'Bring the target app to the front first')
321
+ .option('--json', 'Emit JSON')).action(async (opts) => {
322
+ let from;
323
+ let to;
324
+ try {
325
+ from = parseXY(opts.from, '--from');
326
+ to = parseXY(opts.to, '--to');
327
+ }
328
+ catch (err) {
329
+ console.error(err.message);
330
+ process.exit(1);
331
+ }
332
+ await withClient(async (client) => {
333
+ const pid = await resolveTargetPid(client, opts);
334
+ await raiseIfRequested(client, pid, opts.raise);
335
+ const params = {
336
+ pid,
337
+ from: [from.x, from.y],
338
+ to: [to.x, to.y],
339
+ button: opts.button,
340
+ };
341
+ if (opts.background)
342
+ params.background = true;
343
+ const res = unwrap(await client.call('drag', params));
344
+ emit(res, Boolean(opts.json), () => `dragged ${opts.from} -> ${opts.to} (${res.method ?? 'ok'})`);
345
+ });
346
+ });
347
+ // scroll — by delta at an element or coordinate
348
+ addElementOrCoordOpts(addTargetOpts(program
349
+ .command('scroll')
350
+ .description('Scroll by a pixel delta at an element or coordinate')
351
+ .option('--dy <n>', 'Vertical delta (negative = down)', (v) => parseInt(v, 10))
352
+ .option('--dx <n>', 'Horizontal delta', (v) => parseInt(v, 10))
353
+ .option('--raise', 'Bring the target app to the front first')
354
+ .option('--json', 'Emit JSON'))).action(async (opts) => {
355
+ await withClient(async (client) => {
356
+ const pid = await resolveTargetPid(client, opts);
357
+ await raiseIfRequested(client, pid, opts.raise);
358
+ const params = { pid };
359
+ if (opts.id)
360
+ params.element_id = opts.id;
361
+ if (opts.x != null)
362
+ params.x = opts.x;
363
+ if (opts.y != null)
364
+ params.y = opts.y;
365
+ if (opts.dy != null)
366
+ params.dy = opts.dy;
367
+ if (opts.dx != null)
368
+ params.dx = opts.dx;
369
+ const res = unwrap(await client.call('scroll', params));
370
+ emit(res, Boolean(opts.json), () => `scrolled (${res.method ?? 'ok'})`);
371
+ });
372
+ });
373
+ // ax-action — perform any advertised AX action on an element
374
+ addTargetOpts(program
375
+ .command('ax-action')
376
+ .description('Perform an arbitrary AX action (AXConfirm, AXCancel, AXRaise, ...) on an element')
377
+ .requiredOption('--id <@eN>', 'Element id from `describe`')
378
+ .requiredOption('--action <name>', 'AX action name')
379
+ .option('--json', 'Emit JSON')).action(async (opts) => {
380
+ await withClient(async (client) => {
381
+ const pid = await resolveTargetPid(client, opts);
382
+ const res = unwrap(await client.call('ax_action', { pid, element_id: opts.id, action: opts.action }));
383
+ emit(res, Boolean(opts.json), () => `performed ${opts.action}`);
384
+ });
385
+ });
386
+ // focus — set keyboard focus to an element
387
+ addTargetOpts(program
388
+ .command('focus')
389
+ .description('Set keyboard focus to an element (so type-text/key land there)')
390
+ .requiredOption('--id <@eN>', 'Element id from `describe`')
391
+ .option('--json', 'Emit JSON')).action(async (opts) => {
392
+ await withClient(async (client) => {
393
+ const pid = await resolveTargetPid(client, opts);
394
+ const res = unwrap(await client.call('set_focus', { pid, element_id: opts.id }));
395
+ emit(res, Boolean(opts.json), () => `focused ${opts.id}`);
396
+ });
397
+ });
398
+ // raise — bring an app (or one of its windows) to the front. The window
399
+ // forms (--window-id/--title) also switch macOS Spaces, which is the only
400
+ // way to reach a fullscreen-Space window (VM, fullscreen editor) for
401
+ // capture and HID-tap input.
402
+ addTargetOpts(program
403
+ .command('raise')
404
+ .description('Bring an app (or a specific window) to the front — switches Spaces for fullscreen windows')
405
+ .option('--window-id <n>', 'Raise a specific window by id (from `screenshot --list`)', (v) => parseInt(v, 10))
406
+ .option('--title <s>', 'Raise the window whose title contains this string')
407
+ .option('--json', 'Emit JSON')).action(async (opts) => {
408
+ await withClient(async (client) => {
409
+ const pid = await resolveTargetPid(client, opts);
410
+ const res = unwrap(await client.call('focus_window', { pid, ...buildRaiseParams(opts) }));
411
+ emit(res, Boolean(opts.json), () => {
412
+ const scope = res.raised_window ? `window ${res.title ?? res.window_id ?? ''}`.trim() : 'app';
413
+ return `raised ${scope} (${res.focus_elapsed_ms ?? 0}ms)`;
414
+ });
415
+ });
416
+ });
417
+ // wait — settle the UI before the next action
418
+ addTargetOpts(program
419
+ .command('wait')
420
+ .description('Wait for a duration (--duration) or for an element (--id / --role/--label) to satisfy --until')
421
+ .option('--duration <ms>', 'Unconditional sleep in ms (50-30000)', (v) => parseInt(v, 10))
422
+ .option('--id <@eN>', 'Element id from `describe` to poll')
423
+ .option('--until <cond>', 'Condition: exists | enabled | disappears (default: exists)')
424
+ .option('--role <s>', 'Locator: AX role (e.g. AXButton)')
425
+ .option('--label <s>', 'Locator: element label')
426
+ .option('--identifier <s>', 'Locator: AX identifier')
427
+ .option('--timeout <ms>', 'Poll timeout in ms (default 5000)', (v) => parseInt(v, 10))
428
+ .option('--json', 'Emit JSON')).action(async (opts) => {
429
+ const spec = buildWaitParams(opts);
430
+ if (!spec.ok) {
431
+ console.error(spec.error);
432
+ process.exit(1);
433
+ }
434
+ await withClient(async (client) => {
435
+ const params = { ...spec.params };
436
+ // duration-only waits don't need a target pid
437
+ if (params.duration_ms == null)
438
+ params.pid = await resolveTargetPid(client, opts);
439
+ const res = unwrap(await client.call('wait', params));
440
+ emit(res, Boolean(opts.json), () => res.satisfied ? `satisfied (${res.waited_ms}ms)` : `timed out (${res.waited_ms}ms)`);
441
+ });
442
+ });
443
+ // get-text — read text without OCR
444
+ addTargetOpts(program
445
+ .command('get-text')
446
+ .description('Extract visible text from the app (or a subtree via --id)')
447
+ .option('--id <@eN>', 'Element id from `describe` to scope the extraction')
448
+ .option('--max-chars <n>', 'Cap the extracted text length', (v) => parseInt(v, 10))
449
+ .option('--json', 'Emit JSON')).action(async (opts) => {
450
+ await withClient(async (client) => {
451
+ const pid = await resolveTargetPid(client, opts);
452
+ const params = { pid };
453
+ if (opts.id)
454
+ params.element_id = opts.id;
455
+ if (opts.maxChars != null)
456
+ params.max_chars = opts.maxChars;
457
+ const res = unwrap(await client.call('get_text', params));
458
+ emit(res, Boolean(opts.json), () => String(res.text ?? ''));
459
+ });
460
+ });
461
+ // launch — start an app (no target resolution: it isn't running yet)
462
+ program
463
+ .command('launch')
464
+ .description('Launch an app by bundle id, path, or name')
465
+ .option('--bundle <id>', 'Bundle id (e.g. com.apple.TextEdit)')
466
+ .option('--path <p>', 'Path to the .app bundle')
467
+ .option('--name <s>', 'App name (resolved via /Applications and LaunchServices)')
468
+ .option('--json', 'Emit JSON')
469
+ .action(async (opts) => {
470
+ if (!opts.bundle && !opts.path && !opts.name) {
471
+ console.error('pass one of --bundle, --path, --name');
472
+ process.exit(1);
473
+ }
474
+ await withClient(async (client) => {
475
+ const params = {};
476
+ if (opts.bundle)
477
+ params.bundle_id = opts.bundle;
478
+ if (opts.path)
479
+ params.path = opts.path;
480
+ if (opts.name)
481
+ params.name = opts.name;
482
+ const res = unwrap(await client.call('launch_app', params));
483
+ emit(res, Boolean(opts.json), () => `launched ${res.name} (pid ${res.pid})`);
484
+ });
485
+ });
486
+ }