@phnx-labs/agents-cli 1.18.6 → 1.19.0

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 (104) hide show
  1. package/CHANGELOG.md +13 -2
  2. package/README.md +22 -20
  3. package/dist/commands/browser.js +25 -2
  4. package/dist/commands/cloud.js +3 -3
  5. package/dist/commands/computer.d.ts +6 -0
  6. package/dist/commands/computer.js +477 -0
  7. package/dist/commands/doctor.js +19 -17
  8. package/dist/commands/exec.js +37 -59
  9. package/dist/commands/factory.js +12 -5
  10. package/dist/commands/import.js +6 -1
  11. package/dist/commands/mcp.js +9 -4
  12. package/dist/commands/packages.d.ts +3 -0
  13. package/dist/commands/packages.js +20 -12
  14. package/dist/commands/permissions.d.ts +2 -0
  15. package/dist/commands/permissions.js +20 -1
  16. package/dist/commands/plugins.d.ts +2 -0
  17. package/dist/commands/plugins.js +23 -4
  18. package/dist/commands/profiles.js +1 -1
  19. package/dist/commands/pty.js +126 -112
  20. package/dist/commands/pull.js +29 -25
  21. package/dist/commands/repo.js +24 -26
  22. package/dist/commands/routines.js +29 -26
  23. package/dist/commands/secrets.js +66 -73
  24. package/dist/commands/sessions-tail.js +21 -22
  25. package/dist/commands/sessions.js +36 -68
  26. package/dist/commands/setup.js +20 -24
  27. package/dist/commands/teams.js +30 -39
  28. package/dist/commands/versions.js +60 -68
  29. package/dist/commands/worktree.d.ts +20 -0
  30. package/dist/commands/worktree.js +242 -0
  31. package/dist/computer.d.ts +2 -0
  32. package/dist/computer.js +7 -0
  33. package/dist/index.js +70 -26
  34. package/dist/lib/agents.d.ts +4 -1
  35. package/dist/lib/agents.js +23 -5
  36. package/dist/lib/browser/cdp.d.ts +15 -1
  37. package/dist/lib/browser/cdp.js +77 -8
  38. package/dist/lib/browser/chrome.js +17 -24
  39. package/dist/lib/browser/drivers/ssh.d.ts +1 -0
  40. package/dist/lib/browser/drivers/ssh.js +20 -8
  41. package/dist/lib/browser/ipc.js +38 -5
  42. package/dist/lib/browser/profiles.js +34 -2
  43. package/dist/lib/browser/runtime-state.d.ts +1 -2
  44. package/dist/lib/browser/runtime-state.js +11 -3
  45. package/dist/lib/browser/service.d.ts +5 -0
  46. package/dist/lib/browser/service.js +32 -4
  47. package/dist/lib/browser/types.d.ts +1 -1
  48. package/dist/lib/browser/upload.d.ts +2 -0
  49. package/dist/lib/browser/upload.js +34 -0
  50. package/dist/lib/cloud/rush.d.ts +2 -1
  51. package/dist/lib/cloud/rush.js +28 -9
  52. package/dist/lib/computer-rpc.d.ts +24 -0
  53. package/dist/lib/computer-rpc.js +263 -0
  54. package/dist/lib/daemon.js +7 -7
  55. package/dist/lib/exec.d.ts +2 -1
  56. package/dist/lib/exec.js +3 -2
  57. package/dist/lib/fs-atomic.d.ts +18 -0
  58. package/dist/lib/fs-atomic.js +76 -0
  59. package/dist/lib/git.js +2 -4
  60. package/dist/lib/help.d.ts +15 -0
  61. package/dist/lib/help.js +41 -0
  62. package/dist/lib/hooks/match.d.ts +1 -0
  63. package/dist/lib/hooks/match.js +57 -12
  64. package/dist/lib/hooks.d.ts +1 -0
  65. package/dist/lib/hooks.js +27 -10
  66. package/dist/lib/import.d.ts +1 -0
  67. package/dist/lib/import.js +7 -0
  68. package/dist/lib/manifest.js +27 -1
  69. package/dist/lib/mcp.d.ts +14 -0
  70. package/dist/lib/mcp.js +79 -14
  71. package/dist/lib/migrate.js +3 -3
  72. package/dist/lib/models.js +3 -1
  73. package/dist/lib/permissions.d.ts +5 -0
  74. package/dist/lib/permissions.js +35 -0
  75. package/dist/lib/plugin-marketplace.d.ts +3 -1
  76. package/dist/lib/plugin-marketplace.js +36 -1
  77. package/dist/lib/plugins.d.ts +19 -1
  78. package/dist/lib/plugins.js +99 -8
  79. package/dist/lib/redact.d.ts +4 -0
  80. package/dist/lib/redact.js +18 -0
  81. package/dist/lib/registry.d.ts +2 -0
  82. package/dist/lib/registry.js +15 -0
  83. package/dist/lib/sandbox.js +15 -5
  84. package/dist/lib/secrets/bundles.d.ts +7 -12
  85. package/dist/lib/secrets/bundles.js +45 -29
  86. package/dist/lib/secrets/index.js +4 -4
  87. package/dist/lib/session/cloud.d.ts +2 -0
  88. package/dist/lib/session/cloud.js +34 -6
  89. package/dist/lib/session/parse.js +7 -2
  90. package/dist/lib/session/render.d.ts +4 -1
  91. package/dist/lib/session/render.js +81 -35
  92. package/dist/lib/shims.d.ts +5 -2
  93. package/dist/lib/shims.js +29 -7
  94. package/dist/lib/state.d.ts +5 -5
  95. package/dist/lib/state.js +43 -13
  96. package/dist/lib/teams/agents.js +1 -1
  97. package/dist/lib/types.d.ts +4 -3
  98. package/dist/lib/types.js +0 -2
  99. package/dist/lib/versions.js +65 -40
  100. package/dist/lib/workflows.d.ts +7 -0
  101. package/dist/lib/workflows.js +42 -1
  102. package/npm-shrinkwrap.json +3256 -0
  103. package/package.json +32 -26
  104. package/scripts/postinstall.js +8 -2
@@ -0,0 +1,477 @@
1
+ import { execFileSync } from 'child_process';
2
+ import * as fs from 'fs';
3
+ import * as os from 'os';
4
+ import * as path from 'path';
5
+ import { registerCommandGroups } from '../lib/help.js';
6
+ import { openComputerClient, resolveHelperApp, resolveHelperExec, resolveSocketPath, resolveLogPath, resolvePolicyPath, describeTransport, loadComputerAllowList, writeComputerPolicy, } from '../lib/computer-rpc.js';
7
+ // Help groups — mirror `agents browser` so the mental model carries over.
8
+ const COMPUTER_HELP_GROUPS = [
9
+ { title: 'Installation', names: ['install-helper'] },
10
+ { title: 'Daemon lifecycle', names: ['start', 'stop', 'reload', 'status'] },
11
+ { title: 'Capture evidence', names: ['screenshot'] },
12
+ ];
13
+ export function registerComputerCommand(program) {
14
+ const computer = program
15
+ .command('computer')
16
+ .description('Drive macOS apps via Accessibility — list, screenshot, click, type');
17
+ registerComputerSubcommands(computer);
18
+ registerCommandGroups(computer, COMPUTER_HELP_GROUPS);
19
+ }
20
+ export function registerComputerSubcommands(program) {
21
+ registerInstallHelperCommand(program);
22
+ registerStartCommand(program);
23
+ registerStopCommand(program);
24
+ registerReloadCommand(program);
25
+ registerStatusCommand(program);
26
+ registerScreenshotCommand(program);
27
+ registerCommandGroups(program, COMPUTER_HELP_GROUPS);
28
+ }
29
+ function reportMissingHelper() {
30
+ console.error('helper not built. Run: ./packages/computer-helper/scripts/build.sh debug');
31
+ process.exit(1);
32
+ }
33
+ function registerStatusCommand(program) {
34
+ program
35
+ .command('status')
36
+ .description('Report install state, daemon state, and Accessibility trust')
37
+ .action(async () => {
38
+ const socketPath = resolveSocketPath();
39
+ const installed = fs.existsSync(HELPER_APP_DEST);
40
+ const socketUp = fs.existsSync(socketPath);
41
+ console.log(`installed: ${installed ? 'yes' : 'no'} (${HELPER_APP_DEST})`);
42
+ console.log(`daemon: ${socketUp ? 'running' : 'stopped'}`);
43
+ // Show the current allow list — what the user has actually authorized
44
+ // via Computer(...) patterns in their permission groups.
45
+ const allowed = loadComputerAllowList();
46
+ const previewParts = allowed.slice(0, 5);
47
+ const previewSuffix = allowed.length > 5 ? ` (+${allowed.length - 5} more)` : '';
48
+ console.log(`policy: ${allowed.length} app${allowed.length === 1 ? '' : 's'} allowed${allowed.length > 0 ? `: ${previewParts.join(', ')}${previewSuffix}` : ''}`);
49
+ if (!installed) {
50
+ console.log('');
51
+ console.log('Run: agents computer install-helper');
52
+ return;
53
+ }
54
+ if (!socketUp) {
55
+ console.log('');
56
+ console.log('Run: agents computer start');
57
+ return;
58
+ }
59
+ // Daemon is up — probe trust state.
60
+ const client = openComputerClient();
61
+ try {
62
+ const r = await client.call('trust_status');
63
+ if (r.error) {
64
+ console.error(`error: ${r.error.code}: ${r.error.message}`);
65
+ process.exit(1);
66
+ }
67
+ const trusted = Boolean(r.result?.trusted);
68
+ const helperPid = r.result?.pid;
69
+ console.log(`trust: ${trusted ? 'granted' : 'denied'}`);
70
+ if (typeof helperPid === 'number')
71
+ console.log(`pid: ${helperPid}`);
72
+ if (!trusted) {
73
+ console.log('');
74
+ console.log('Grant Accessibility + Screen Recording in System Settings, then `agents computer start` again.');
75
+ }
76
+ }
77
+ finally {
78
+ await client.close();
79
+ }
80
+ });
81
+ }
82
+ function registerScreenshotCommand(program) {
83
+ program
84
+ .command('screenshot')
85
+ .description('Capture a JPEG of the frontmost window of a bundle id (default: frontmost app)')
86
+ .option('--bundle <id>', 'Bundle id to capture (default: bundle id of frontmost app)')
87
+ .option('--out <path>', 'Output JPEG path', './computer-screenshot.jpg')
88
+ .option('--quality <n>', 'JPEG quality 1-100', (v) => parseInt(v, 10), 85)
89
+ .action(async (opts) => {
90
+ const transport = describeTransport();
91
+ if (transport.kind === 'none')
92
+ reportMissingHelper();
93
+ const quality = Math.max(1, Math.min(100, opts.quality || 85));
94
+ const client = openComputerClient();
95
+ try {
96
+ // Step 1: list_apps to get the candidate set.
97
+ const apps = await client.call('list_apps');
98
+ if (apps.error) {
99
+ console.error(`error: ${apps.error.code}: ${apps.error.message}`);
100
+ process.exit(1);
101
+ }
102
+ const list = apps.result?.apps || [];
103
+ let target;
104
+ if (opts.bundle) {
105
+ target = list.find((a) => a.bundle_id === opts.bundle);
106
+ if (!target) {
107
+ console.error(`bundle not in allow list (or not running): ${opts.bundle}`);
108
+ console.error(`add Computer(${opts.bundle}) to a permissions group, then \`agents computer reload\``);
109
+ process.exit(1);
110
+ }
111
+ }
112
+ else {
113
+ target = list.find((a) => a.active);
114
+ if (!target) {
115
+ console.error('no active app found in allow list');
116
+ console.error('add Computer(<bundle-id>) to a permissions group, then `agents computer reload`');
117
+ process.exit(1);
118
+ }
119
+ }
120
+ // Step 2: screenshot.
121
+ const shot = await client.call('screenshot', { pid: target.pid, quality });
122
+ if (shot.error) {
123
+ console.error(`error: ${shot.error.code}: ${shot.error.message}`);
124
+ process.exit(1);
125
+ }
126
+ const b64 = shot.result?.image_data;
127
+ const width = shot.result?.width;
128
+ const height = shot.result?.height;
129
+ if (!b64) {
130
+ console.error('helper returned no image_data');
131
+ process.exit(1);
132
+ }
133
+ const buf = Buffer.from(b64, 'base64');
134
+ const outPath = path.resolve(opts.out);
135
+ fs.writeFileSync(outPath, buf);
136
+ console.log(`saved: ${outPath} (${width ?? '?'}x${height ?? '?'}, ${buf.byteLength} bytes)`);
137
+ }
138
+ finally {
139
+ await client.close();
140
+ }
141
+ });
142
+ }
143
+ // install-helper:
144
+ // 1. resolve dist .app
145
+ // 2. copy to /Applications/Computer Helper.app
146
+ // 3. codesign --verify the destination
147
+ // 4. write LaunchAgent plist with absolute HOME paths
148
+ // 5. launchctl bootout (ignore failure) -> bootstrap -> kickstart -k
149
+ // 6. wait for socket to appear
150
+ // 7. probe trust_status, print grant instructions if needed
151
+ //
152
+ // macOS TCC is keyed by signed-bundle identity + bundle id. Putting the
153
+ // .app at a stable absolute path under /Applications/ means the AX grant
154
+ // survives across npm updates. The CLI itself is unsigned but doesn't
155
+ // need AX — it sends JSON-RPC to the daemon, which has AX.
156
+ const HELPER_BUNDLE_ID = 'com.phnx-labs.computer-helper';
157
+ const HELPER_APP_NAME = 'Computer Helper.app';
158
+ const HELPER_APP_DEST = `/Applications/${HELPER_APP_NAME}`;
159
+ const HELPER_LABEL = HELPER_BUNDLE_ID;
160
+ function registerInstallHelperCommand(program) {
161
+ program
162
+ .command('install-helper')
163
+ .description('Install ComputerHelper.app to /Applications/ (does NOT activate the daemon — run `start` to enable)')
164
+ .action(async () => {
165
+ const srcApp = resolveHelperApp();
166
+ if (!srcApp || !fs.existsSync(srcApp)) {
167
+ console.error('helper not built. Run: ./packages/computer-helper/scripts/build.sh debug');
168
+ process.exit(1);
169
+ }
170
+ const home = os.homedir();
171
+ const socketPath = resolveSocketPath();
172
+ const logPath = resolveLogPath();
173
+ const plistPath = path.join(home, 'Library', 'LaunchAgents', `${HELPER_LABEL}.plist`);
174
+ console.log(`source: ${srcApp}`);
175
+ console.log(`dest: ${HELPER_APP_DEST}`);
176
+ // 1. Copy to /Applications/. Use ditto to preserve xattrs (Gatekeeper
177
+ // provenance + codesign metadata). Wipe any prior install first.
178
+ if (fs.existsSync(HELPER_APP_DEST)) {
179
+ try {
180
+ fs.rmSync(HELPER_APP_DEST, { recursive: true, force: true });
181
+ console.log(`removed prior install`);
182
+ }
183
+ catch (err) {
184
+ console.error(`failed to remove prior install at ${HELPER_APP_DEST}: ${err.message}`);
185
+ console.error('try: sudo rm -rf "' + HELPER_APP_DEST + '"');
186
+ process.exit(1);
187
+ }
188
+ }
189
+ try {
190
+ execFileSync('/usr/bin/ditto', [srcApp, HELPER_APP_DEST], { stdio: 'inherit' });
191
+ }
192
+ catch (err) {
193
+ console.error(`ditto copy failed: ${err.message}`);
194
+ process.exit(1);
195
+ }
196
+ console.log(`copied to ${HELPER_APP_DEST}`);
197
+ // 2. Verify codesign on the destination. Fail loud if the copy
198
+ // somehow stripped the signature — TCC needs a valid signature.
199
+ try {
200
+ execFileSync('/usr/bin/codesign', ['--verify', '--deep', '--strict', HELPER_APP_DEST], { stdio: 'inherit' });
201
+ console.log('codesign verify: OK');
202
+ }
203
+ catch {
204
+ console.error('codesign verify FAILED. The destination .app is unsigned or its signature was stripped.');
205
+ console.error('rebuild the helper with a Developer ID cert: ./packages/computer-helper/scripts/build.sh release');
206
+ process.exit(1);
207
+ }
208
+ // 3. Ensure socket + log parent dirs exist.
209
+ fs.mkdirSync(path.dirname(socketPath), { recursive: true });
210
+ fs.mkdirSync(path.dirname(logPath), { recursive: true });
211
+ // 4. Write the LaunchAgent plist but DO NOT bootstrap it. The user
212
+ // explicitly opts into running the daemon via `agents computer start`.
213
+ // Screen Recording + Accessibility are scary permissions; we don't
214
+ // want an always-on listener that can drive any app the user could.
215
+ const execInsideApp = path.join(HELPER_APP_DEST, 'Contents', 'MacOS', 'ComputerHelper');
216
+ const plistContent = renderLaunchAgentPlist({
217
+ label: HELPER_LABEL,
218
+ exec: execInsideApp,
219
+ socketPath,
220
+ logPath,
221
+ });
222
+ fs.mkdirSync(path.dirname(plistPath), { recursive: true });
223
+ fs.writeFileSync(plistPath, plistContent);
224
+ console.log(`wrote plist: ${plistPath} (NOT activated)`);
225
+ console.log('');
226
+ console.log('Helper installed (inactive).');
227
+ console.log('');
228
+ console.log(` app: ${HELPER_APP_DEST}`);
229
+ console.log(` plist: ${plistPath}`);
230
+ console.log('');
231
+ console.log('Next steps:');
232
+ console.log(' 1. Grant TCC permissions (one-time):');
233
+ console.log(' System Settings > Privacy & Security > Accessibility — add Computer Helper.app');
234
+ console.log(' System Settings > Privacy & Security > Screen Recording — add Computer Helper.app');
235
+ console.log(' 2. Whitelist the apps the daemon may drive. Add a YAML under ~/.agents/permissions/groups/:');
236
+ console.log(' name: computer');
237
+ console.log(' allow:');
238
+ console.log(' - "Computer(com.apple.mail)"');
239
+ console.log(' - "Computer(com.apple.notes)"');
240
+ console.log(' Default policy is deny-all.');
241
+ console.log(' 3. When you want to use it: agents computer start');
242
+ console.log(' 4. When you are done: agents computer stop');
243
+ });
244
+ }
245
+ function registerStartCommand(program) {
246
+ program
247
+ .command('start')
248
+ .description('Activate the helper daemon (loads launchd, opens socket)')
249
+ .action(async () => {
250
+ const home = os.homedir();
251
+ const plistPath = path.join(home, 'Library', 'LaunchAgents', `${HELPER_LABEL}.plist`);
252
+ const socketPath = resolveSocketPath();
253
+ const logPath = resolveLogPath();
254
+ if (!fs.existsSync(plistPath)) {
255
+ console.error(`plist not found at ${plistPath}`);
256
+ console.error('run: agents computer install-helper');
257
+ process.exit(1);
258
+ }
259
+ if (!fs.existsSync(HELPER_APP_DEST)) {
260
+ console.error(`helper app not found at ${HELPER_APP_DEST}`);
261
+ console.error('run: agents computer install-helper');
262
+ process.exit(1);
263
+ }
264
+ const uid = process.getuid?.();
265
+ if (typeof uid !== 'number') {
266
+ console.error('cannot resolve uid');
267
+ process.exit(1);
268
+ }
269
+ const domain = `gui/${uid}`;
270
+ // Render the policy file BEFORE launchctl bootstrap so the daemon
271
+ // reads a fresh allow list at startup. The helper falls back to an
272
+ // empty allow list (everything denied) if this file is missing or
273
+ // unparseable — fail-safe.
274
+ const allowed = loadComputerAllowList();
275
+ writeComputerPolicy(allowed);
276
+ console.log(`policy: ${allowed.length} app${allowed.length === 1 ? '' : 's'} allowed (${resolvePolicyPath()})`);
277
+ if (allowed.length > 0) {
278
+ const preview = allowed.slice(0, 5).join(', ');
279
+ const more = allowed.length > 5 ? ` (+${allowed.length - 5} more)` : '';
280
+ console.log(` ${preview}${more}`);
281
+ }
282
+ else {
283
+ console.log(` (no Computer(...) patterns found — everything will be denied)`);
284
+ console.log(` add to ~/.agents/permissions/groups/<name>.yaml under allow:`);
285
+ console.log(` - "Computer(com.apple.finder)"`);
286
+ }
287
+ // Bootout first to clear any prior registration. Best-effort.
288
+ try {
289
+ execFileSync('/bin/launchctl', ['bootout', domain, plistPath], { stdio: 'pipe' });
290
+ }
291
+ catch {
292
+ // expected when not previously loaded
293
+ }
294
+ try {
295
+ execFileSync('/bin/launchctl', ['bootstrap', domain, plistPath], { stdio: 'pipe' });
296
+ }
297
+ catch (err) {
298
+ console.error(`launchctl bootstrap failed: ${err.message}`);
299
+ process.exit(1);
300
+ }
301
+ // Force restart so we pick up the latest binary.
302
+ try {
303
+ execFileSync('/bin/launchctl', ['kickstart', '-k', `${domain}/${HELPER_LABEL}`], { stdio: 'pipe' });
304
+ }
305
+ catch (err) {
306
+ console.error(`launchctl kickstart failed: ${err.message}`);
307
+ process.exit(1);
308
+ }
309
+ // Wait up to 5s for the socket.
310
+ const deadline = Date.now() + 5000;
311
+ while (Date.now() < deadline) {
312
+ if (fs.existsSync(socketPath))
313
+ break;
314
+ await sleep(100);
315
+ }
316
+ if (!fs.existsSync(socketPath)) {
317
+ console.error(`socket did not appear at ${socketPath} within 5s`);
318
+ console.error(`check ${logPath} for helper startup errors`);
319
+ process.exit(1);
320
+ }
321
+ // Probe trust through the socket.
322
+ let trustStr = 'unknown';
323
+ try {
324
+ const client = openComputerClient();
325
+ try {
326
+ const r = await client.call('trust_status');
327
+ trustStr = r.error ? `error (${r.error.code})` : (r.result?.trusted ? 'granted' : 'denied');
328
+ }
329
+ finally {
330
+ await client.close();
331
+ }
332
+ }
333
+ catch (err) {
334
+ trustStr = `error (${err.message})`;
335
+ }
336
+ console.log(`daemon: running`);
337
+ console.log(`socket: ${socketPath}`);
338
+ console.log(`trust: ${trustStr}`);
339
+ if (trustStr === 'denied') {
340
+ console.log('');
341
+ console.log('Grant Accessibility + Screen Recording to Computer Helper.app, then run `agents computer start` again.');
342
+ }
343
+ });
344
+ }
345
+ function registerReloadCommand(program) {
346
+ program
347
+ .command('reload')
348
+ .description('Reload the allow-list policy from ~/.agents/permissions/groups/ (SIGHUP the daemon)')
349
+ .action(async () => {
350
+ const socketPath = resolveSocketPath();
351
+ if (!fs.existsSync(socketPath)) {
352
+ console.error(`daemon not running (no socket at ${socketPath})`);
353
+ console.error('run: agents computer start');
354
+ process.exit(1);
355
+ }
356
+ const allowed = loadComputerAllowList();
357
+ writeComputerPolicy(allowed);
358
+ console.log(`policy: ${allowed.length} app${allowed.length === 1 ? '' : 's'} allowed (${resolvePolicyPath()})`);
359
+ // Resolve the daemon's pid via `launchctl list <label>`. The plist
360
+ // output includes a "PID" key when the service is running.
361
+ const uid = process.getuid?.();
362
+ if (typeof uid !== 'number') {
363
+ console.error('cannot resolve uid');
364
+ process.exit(1);
365
+ }
366
+ const domain = `gui/${uid}`;
367
+ let pid = null;
368
+ try {
369
+ const out = execFileSync('/bin/launchctl', ['print', `${domain}/${HELPER_LABEL}`], { encoding: 'utf-8' });
370
+ const m = out.match(/\bpid\s*=\s*(\d+)/);
371
+ if (m)
372
+ pid = parseInt(m[1], 10);
373
+ }
374
+ catch (err) {
375
+ console.error(`launchctl print failed: ${err.message}`);
376
+ process.exit(1);
377
+ }
378
+ if (pid === null || !Number.isFinite(pid) || pid <= 0) {
379
+ console.error('could not resolve daemon pid from launchctl print output');
380
+ process.exit(1);
381
+ }
382
+ try {
383
+ process.kill(pid, 'SIGHUP');
384
+ }
385
+ catch (err) {
386
+ console.error(`kill -HUP ${pid} failed: ${err.message}`);
387
+ process.exit(1);
388
+ }
389
+ // Brief socket-up check so the user knows the daemon survived the
390
+ // signal (it should — SIGHUP just triggers a re-read).
391
+ await sleep(150);
392
+ if (!fs.existsSync(socketPath)) {
393
+ console.error(`socket disappeared after SIGHUP — check ${resolveLogPath()}`);
394
+ process.exit(1);
395
+ }
396
+ console.log(`reloaded: daemon pid ${pid}`);
397
+ if (allowed.length > 0) {
398
+ const preview = allowed.slice(0, 5).join(', ');
399
+ const more = allowed.length > 5 ? ` (+${allowed.length - 5} more)` : '';
400
+ console.log(` ${preview}${more}`);
401
+ }
402
+ });
403
+ }
404
+ function registerStopCommand(program) {
405
+ program
406
+ .command('stop')
407
+ .description('Deactivate the helper daemon (bootout, removes socket)')
408
+ .action(async () => {
409
+ const home = os.homedir();
410
+ const plistPath = path.join(home, 'Library', 'LaunchAgents', `${HELPER_LABEL}.plist`);
411
+ const socketPath = resolveSocketPath();
412
+ const uid = process.getuid?.();
413
+ if (typeof uid !== 'number') {
414
+ console.error('cannot resolve uid');
415
+ process.exit(1);
416
+ }
417
+ const domain = `gui/${uid}`;
418
+ try {
419
+ execFileSync('/bin/launchctl', ['bootout', domain, plistPath], { stdio: 'pipe' });
420
+ }
421
+ catch {
422
+ // already gone — fine
423
+ }
424
+ // launchd unlinks the socket when the daemon exits; helper also has an
425
+ // atexit unlink. Best-effort cleanup if either path didn't fire.
426
+ try {
427
+ fs.unlinkSync(socketPath);
428
+ }
429
+ catch { }
430
+ console.log('daemon: stopped');
431
+ if (fs.existsSync(socketPath)) {
432
+ console.warn(`(socket still present at ${socketPath} — may belong to a different process)`);
433
+ }
434
+ });
435
+ }
436
+ function renderLaunchAgentPlist(opts) {
437
+ return `<?xml version="1.0" encoding="UTF-8"?>
438
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
439
+ <plist version="1.0">
440
+ <dict>
441
+ <key>Label</key>
442
+ <string>${escapeXml(opts.label)}</string>
443
+ <key>ProgramArguments</key>
444
+ <array>
445
+ <string>${escapeXml(opts.exec)}</string>
446
+ <string>--socket</string>
447
+ <string>${escapeXml(opts.socketPath)}</string>
448
+ </array>
449
+ <key>RunAtLoad</key>
450
+ <true/>
451
+ <key>KeepAlive</key>
452
+ <true/>
453
+ <key>ProcessType</key>
454
+ <string>Background</string>
455
+ <key>StandardErrorPath</key>
456
+ <string>${escapeXml(opts.logPath)}</string>
457
+ <key>StandardOutPath</key>
458
+ <string>${escapeXml(opts.logPath)}</string>
459
+ </dict>
460
+ </plist>
461
+ `;
462
+ }
463
+ function escapeXml(s) {
464
+ return s
465
+ .replace(/&/g, '&amp;')
466
+ .replace(/</g, '&lt;')
467
+ .replace(/>/g, '&gt;')
468
+ .replace(/"/g, '&quot;')
469
+ .replace(/'/g, '&apos;');
470
+ }
471
+ function sleep(ms) {
472
+ return new Promise((resolve) => setTimeout(resolve, ms));
473
+ }
474
+ // Backwards-compat: a few external callers may still import these.
475
+ // Re-export from the shared lib so existing imports keep working.
476
+ export { resolveHelperExec as resolveHelperPath };
477
+ export { resolveSocketPath };
@@ -8,6 +8,7 @@ import { diffVersionSkills, iterSkillsCapableVersions } from '../lib/skills.js';
8
8
  import { diffVersionHooks, iterHooksCapableVersions } from '../lib/hooks.js';
9
9
  import { diffVersionResources, DOCTOR_ALL_KINDS, } from '../lib/doctor-diff.js';
10
10
  import { unifiedDiff, colorizeUnifiedDiff } from '../lib/diff-text.js';
11
+ import { setHelpSections } from '../lib/help.js';
11
12
  import * as fs from 'fs';
12
13
  const AGENT_NAMES = Object.fromEntries(ALL_AGENT_IDS.map((id) => [id, AGENTS[id].name]));
13
14
  // ─── overview mode (no target) ────────────────────────────────────────────────
@@ -314,31 +315,32 @@ function renderTargetText(report, options) {
314
315
  }
315
316
  // ─── command registration ────────────────────────────────────────────────────
316
317
  export function registerDoctorCommand(program) {
317
- program
318
+ const doctorCmd = program
318
319
  .command('doctor [target]')
319
- .description('Diagnose CLI availability, sync status, and resource divergence (optionally for a specific agent[@version])')
320
+ .description('Diagnose CLI availability, sync status, and resource divergence (optionally for a specific agent[@version]).')
320
321
  .option('--json', 'Output machine-readable JSON')
321
322
  .option('--diff', 'In target mode, include unified diffs for divergent files')
322
323
  .option('--kind <kinds>', 'Restrict to comma-separated resource kinds (commands,skills,hooks,rules,mcp,permissions,subagents,plugins,promptcuts)')
323
- .option('--cwd <path>', 'Resolution cwd for project layer detection (default: process.cwd())')
324
- .addHelpText('after', `
325
- Examples:
326
- # Overview across default versions (CLI availability + sync + orphans)
327
- agents doctor
324
+ .option('--cwd <path>', 'Resolution cwd for project layer detection (default: process.cwd())');
325
+ setHelpSections(doctorCmd, {
326
+ examples: `
327
+ # Overview: CLI availability + sync status + orphans across all defaults
328
+ agents doctor
328
329
 
329
- # Full per-resource report for the active default
330
- agents doctor claude@default
330
+ # Full per-resource report for the active default
331
+ agents doctor claude@default
331
332
 
332
- # Pin to a specific installed version
333
- agents doctor codex@0.117.0
333
+ # All installed versions of one agent
334
+ agents doctor gemini
334
335
 
335
- # All installed versions for one agent
336
- agents doctor gemini
336
+ # Pin to a specific installed version
337
+ agents doctor codex@0.117.0
337
338
 
338
- # Only inspect rules and hooks, with full diffs
339
- agents doctor claude@default --kind rules,hooks --diff
340
- `)
341
- .action((target, opts) => {
339
+ # Inspect only rules and hooks, with full diffs
340
+ agents doctor claude@default --kind rules,hooks --diff
341
+ `,
342
+ });
343
+ doctorCmd.action((target, opts) => {
342
344
  const cwd = opts.cwd ? opts.cwd : process.cwd();
343
345
  if (!target) {
344
346
  const clis = checkAllClis();