@phnx-labs/agents-cli 1.20.28 → 1.20.30

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 (63) hide show
  1. package/dist/commands/computer-actions.js +6 -2
  2. package/dist/commands/computer.d.ts +12 -0
  3. package/dist/commands/computer.js +88 -13
  4. package/dist/commands/exec.js +22 -10
  5. package/dist/commands/inspect.js +1 -1
  6. package/dist/commands/models.js +8 -2
  7. package/dist/commands/secrets.js +93 -6
  8. package/dist/commands/sessions.js +157 -44
  9. package/dist/commands/ssh.d.ts +14 -0
  10. package/dist/commands/ssh.js +263 -0
  11. package/dist/commands/sync.js +70 -14
  12. package/dist/index.js +2 -1
  13. package/dist/lib/agents.d.ts +0 -4
  14. package/dist/lib/agents.js +54 -5
  15. package/dist/lib/browser/drivers/ssh.js +4 -35
  16. package/dist/lib/computer-rpc.d.ts +6 -1
  17. package/dist/lib/computer-rpc.js +86 -3
  18. package/dist/lib/devices/connect.d.ts +34 -0
  19. package/dist/lib/devices/connect.js +101 -0
  20. package/dist/lib/devices/registry.d.ts +78 -0
  21. package/dist/lib/devices/registry.js +168 -0
  22. package/dist/lib/devices/ssh-config.d.ts +21 -0
  23. package/dist/lib/devices/ssh-config.js +33 -0
  24. package/dist/lib/devices/tailscale.d.ts +31 -0
  25. package/dist/lib/devices/tailscale.js +126 -0
  26. package/dist/lib/exec.js +14 -0
  27. package/dist/lib/models.js +138 -5
  28. package/dist/lib/runner.js +7 -7
  29. package/dist/lib/secrets/remote.d.ts +67 -0
  30. package/dist/lib/secrets/remote.js +133 -0
  31. package/dist/lib/session/active.d.ts +13 -0
  32. package/dist/lib/session/active.js +79 -18
  33. package/dist/lib/session/cloud.js +2 -0
  34. package/dist/lib/session/db.d.ts +12 -0
  35. package/dist/lib/session/db.js +66 -9
  36. package/dist/lib/session/discover.d.ts +7 -0
  37. package/dist/lib/session/discover.js +309 -0
  38. package/dist/lib/session/parse.d.ts +22 -0
  39. package/dist/lib/session/parse.js +132 -2
  40. package/dist/lib/session/remote.d.ts +1 -1
  41. package/dist/lib/session/remote.js +8 -3
  42. package/dist/lib/session/state.d.ts +82 -0
  43. package/dist/lib/session/state.js +221 -0
  44. package/dist/lib/session/tail.d.ts +18 -0
  45. package/dist/lib/session/tail.js +57 -0
  46. package/dist/lib/session/types.d.ts +10 -1
  47. package/dist/lib/session/types.js +1 -1
  48. package/dist/lib/session/width.d.ts +29 -0
  49. package/dist/lib/session/width.js +91 -0
  50. package/dist/lib/shims.d.ts +17 -1
  51. package/dist/lib/shims.js +130 -6
  52. package/dist/lib/ssh-tunnel.d.ts +127 -0
  53. package/dist/lib/ssh-tunnel.js +346 -0
  54. package/dist/lib/startup/command-registry.d.ts +1 -0
  55. package/dist/lib/startup/command-registry.js +3 -0
  56. package/dist/lib/state.d.ts +4 -0
  57. package/dist/lib/state.js +19 -1
  58. package/dist/lib/teams/agents.d.ts +11 -1
  59. package/dist/lib/teams/agents.js +16 -2
  60. package/dist/lib/types.d.ts +1 -0
  61. package/dist/lib/versions.d.ts +19 -0
  62. package/dist/lib/versions.js +84 -24
  63. package/package.json +1 -1
@@ -17,6 +17,7 @@ import { SESSION_AGENTS } from '../lib/session/types.js';
17
17
  import { discoverArtifacts, readArtifact, resolveArtifact } from '../lib/session/artifacts.js';
18
18
  import { looksLikePath, toComparablePath, homeDir } from '../lib/platform/index.js';
19
19
  import { getActiveSessions } from '../lib/session/active.js';
20
+ import { stringWidth, truncateToWidth, padToWidth, terminalWidth } from '../lib/session/width.js';
20
21
  import { discoverSessions, countSessionsInScope, resolveSessionById, searchContentIndex } from '../lib/session/discover.js';
21
22
  import { filterTeamSessions } from '../lib/session/team-filter.js';
22
23
  import { parseSession } from '../lib/session/parse.js';
@@ -162,43 +163,77 @@ function formatStartedAt(startedAtMs) {
162
163
  return '-';
163
164
  return formatRelativeTime(new Date(startedAtMs).toISOString());
164
165
  }
165
- /** Build a display-friendly description for an active session (label or topic). */
166
+ /**
167
+ * Build the live description for an active session: prefer the state engine's
168
+ * preview (the latest turn), then a user label, then the first-prompt topic.
169
+ */
166
170
  function buildSessionDescription(s) {
167
171
  if (s.context === 'cloud') {
168
- return `${s.cloudProvider ?? ''}${s.cloudTaskId ? ` · ${s.cloudTaskId.slice(0, 12)}` : ''}`;
172
+ return s.preview || `${s.cloudProvider ?? ''}${s.cloudTaskId ? ` · ${s.cloudTaskId.slice(0, 12)}` : ''}`;
169
173
  }
170
174
  if (s.context === 'teams') {
171
175
  const parts = [s.teamName];
172
- if (s.label)
176
+ if (s.preview)
177
+ parts.push(s.preview);
178
+ else if (s.label)
173
179
  parts.push(s.label);
174
180
  else if (s.topic)
175
181
  parts.push(s.topic);
176
182
  return parts.filter(Boolean).join(' · ');
177
183
  }
178
- // Terminal or headless: prefer label, then topic
179
- if (s.label)
180
- return s.label;
181
- if (s.topic)
182
- return s.topic;
183
- return '';
184
+ // Terminal or headless: prefer the live preview, then label, then topic.
185
+ return s.preview || s.label || s.topic || '';
186
+ }
187
+ /** Short human word for a session's activity (falls back to the coarse status). */
188
+ function activityLabel(s) {
189
+ if (s.activity === 'waiting_input')
190
+ return 'waiting';
191
+ if (s.activity === 'working')
192
+ return 'working';
193
+ if (s.activity === 'idle')
194
+ return 'idle';
195
+ return s.status === 'input_required' ? 'waiting' : s.status;
196
+ }
197
+ /**
198
+ * Compact, colour-coded badges for the durable/awaiting signals. Text-only (no
199
+ * emoji, per repo convention): `plan` / `ask` / `perm` for why it's waiting,
200
+ * `PR#N`, `wt:slug`, `TICKET-123`.
201
+ */
202
+ function signalBadges(s) {
203
+ const parts = [];
204
+ if (s.awaitingReason === 'plan_review')
205
+ parts.push(chalk.yellow('plan'));
206
+ else if (s.awaitingReason === 'question')
207
+ parts.push(chalk.yellow('ask'));
208
+ else if (s.awaitingReason === 'permission')
209
+ parts.push(chalk.yellow('perm'));
210
+ if (s.ticket)
211
+ parts.push(chalk.cyan(s.ticket.id));
212
+ if (s.pr)
213
+ parts.push(chalk.blue(`PR#${s.pr.number ?? '?'}`));
214
+ if (s.worktree)
215
+ parts.push(chalk.magenta(`wt:${s.worktree.slug}`));
216
+ return parts.join(' ');
184
217
  }
185
218
  /**
186
219
  * Render a single agent-session row inside an already-printed group header.
187
220
  * Indent is the leading whitespace (2 spaces for flat groups, 4 inside a
188
- * window sub-group).
221
+ * window sub-group). Leads with the 8-char session id (the address to read or
222
+ * resume it); status, badges, and the live preview fill the rest, sized to the
223
+ * terminal width so the row never wraps.
189
224
  */
190
225
  function printActiveRow(s, indent) {
191
- const kindCol = colorAgent(s.kind)(padRight(truncate(s.kind, 8), 9));
192
- const hostCol = chalk.gray(padRight(truncate(s.host ?? '-', 8), 9));
193
- const statusCol = statusColor(s.status)(padRight(truncate(s.status, 7), 8));
194
- const pidCol = chalk.yellow(padRight(s.pid ? String(s.pid) : '-', 7));
195
- const desc = buildSessionDescription(s);
196
- console.log(indent +
197
- pidCol +
198
- kindCol +
199
- hostCol +
200
- statusCol +
201
- chalk.white(truncate(desc || '-', 50)));
226
+ const idCol = chalk.dim(padToWidth((s.sessionId?.slice(0, 8)) ?? '-', 9));
227
+ const kindCol = colorAgent(s.kind)(padToWidth(truncateToWidth(s.kind, 8), 9));
228
+ const hostCol = chalk.gray(padToWidth(truncateToWidth(s.host ?? '-', 8), 9));
229
+ const statusCol = statusColor(s.status)(padToWidth(truncateToWidth(activityLabel(s), 8), 9));
230
+ const badges = signalBadges(s);
231
+ const desc = buildSessionDescription(s) || '-';
232
+ // Fill the remaining width with the preview so nothing wraps under tmux/SSH.
233
+ const fixed = stringWidth(indent) + 9 + 9 + 9 + 9 + (badges ? stringWidth(badges) + 1 : 0);
234
+ const room = Math.max(12, terminalWidth() - fixed - 1);
235
+ const descCol = chalk.white(truncateToWidth(desc, room));
236
+ console.log(indent + idCol + kindCol + hostCol + statusCol + (badges ? badges + ' ' : '') + descCol);
202
237
  }
203
238
  /**
204
239
  * Short label for an IDE window. The slice key in live-terminals.json is
@@ -266,14 +301,21 @@ export function groupActiveSessions(sessions) {
266
301
  return { workspaces };
267
302
  }
268
303
  /** Render the unified active-session view. */
269
- async function renderActiveSessions(asJson) {
270
- const sessions = await getActiveSessions();
304
+ async function renderActiveSessions(asJson, waitingOnly = false) {
305
+ const all = await getActiveSessions();
306
+ // --waiting: only sessions blocked on the user. Exits non-zero when any are
307
+ // present so a supervising agent or hook can poll it as a gate.
308
+ const sessions = waitingOnly
309
+ ? all.filter(s => s.status === 'input_required')
310
+ : all;
271
311
  if (asJson) {
272
312
  process.stdout.write(JSON.stringify(sessions, null, 2) + '\n');
313
+ if (waitingOnly && sessions.length > 0)
314
+ process.exitCode = 1;
273
315
  return;
274
316
  }
275
317
  if (sessions.length === 0) {
276
- console.log(chalk.gray('No active agent sessions.'));
318
+ console.log(chalk.gray(waitingOnly ? 'No sessions waiting on input.' : 'No active agent sessions.'));
277
319
  return;
278
320
  }
279
321
  const layout = groupActiveSessions(sessions);
@@ -312,6 +354,9 @@ async function renderActiveSessions(asJson) {
312
354
  if (queuedCount > 0)
313
355
  parts.push(`${queuedCount} queued`);
314
356
  console.log(chalk.gray(`\n${sessions.length} active (${parts.join(', ')}).`));
357
+ // Scriptable gate: a non-zero exit when anything is waiting on the user.
358
+ if (waitingOnly && sessions.length > 0)
359
+ process.exitCode = 1;
315
360
  }
316
361
  /** Main action handler for `agents sessions`. Routes to picker, table, or single-session render. */
317
362
  async function sessionsAction(query, options) {
@@ -326,7 +371,7 @@ async function sessionsAction(query, options) {
326
371
  return;
327
372
  }
328
373
  if (options.active) {
329
- await renderActiveSessions(options.json === true);
374
+ await renderActiveSessions(options.json === true, options.waiting === true);
330
375
  return;
331
376
  }
332
377
  if (options.cloud) {
@@ -455,7 +500,9 @@ async function sessionsAction(query, options) {
455
500
  }
456
501
  return;
457
502
  }
458
- if (isInteractiveTerminal()) {
503
+ // --tree is a printed grouped listing, not an interactive pick — render it
504
+ // directly even in a TTY.
505
+ if (isInteractiveTerminal() && !options.tree) {
459
506
  const message = pathFilter
460
507
  ? `Search sessions (${path.basename(pathFilter)}):`
461
508
  : formatSearchMessage(options);
@@ -468,7 +515,7 @@ async function sessionsAction(query, options) {
468
515
  }
469
516
  // Non-interactive fallback (piped output)
470
517
  const filtered = searchQuery ? filterSessionsByQuery(sessions, searchQuery) : sessions;
471
- printSessionTable(filtered, hiddenCount);
518
+ printSessionTable(filtered, hiddenCount, options.tree === true);
472
519
  }
473
520
  catch (err) {
474
521
  tracker.stop();
@@ -487,22 +534,83 @@ function teamTag(session) {
487
534
  const parts = [origin.handle, origin.mode].filter(Boolean).join(' · ');
488
535
  return parts ? `[${parts}] ` : '[team] ';
489
536
  }
490
- function printSessionTable(sessions, hiddenCount = 0) {
491
- for (const session of sessions) {
492
- const agentColor = colorAgent(session.agent);
493
- const when = formatRelativeTime(session.timestamp);
494
- const project = session.project || '-';
495
- const tag = teamTag(session);
496
- const label = session.label;
497
- const topic = tag ? `${tag}${session.topic ?? ''}` : session.topic;
498
- const versionStr = session.version || '-';
499
- console.log(chalk.white(padRight(session.shortId, 10)) +
500
- agentColor(padRight(truncate(session.agent, 8), 9)) +
501
- chalk.yellow(padRight(truncate(versionStr, 7), 8)) +
502
- chalk.cyan(padRight(truncate(project, 14), 16)) +
503
- renderTopicCell(label, topic, '', 48, 50) +
504
- chalk.gray(when));
537
+ /** Adapt a SessionMeta's persisted signals to the badge renderer's shape. */
538
+ function metaSignals(s) {
539
+ return {
540
+ pr: s.prUrl ? { url: s.prUrl, number: s.prNumber } : undefined,
541
+ worktree: s.worktreeSlug ? { path: s.cwd ?? '', slug: s.worktreeSlug } : undefined,
542
+ ticket: s.ticketId ? { id: s.ticketId } : undefined,
543
+ };
544
+ }
545
+ /** One flat table row: shortId · agent · version · project · topic(+badges) · time. */
546
+ function flatSessionRow(session) {
547
+ const agentColor = colorAgent(session.agent);
548
+ const when = formatRelativeTime(session.timestamp);
549
+ const project = session.project || '-';
550
+ const tag = teamTag(session);
551
+ const label = session.label;
552
+ const topic = tag ? `${tag}${session.topic ?? ''}` : session.topic;
553
+ const versionStr = session.version || '-';
554
+ const badges = signalBadges(metaSignals(session));
555
+ const badgeW = badges ? stringWidth(badges) + 1 : 0;
556
+ const topicW = Math.max(16, terminalWidth() - (10 + 9 + 8 + 16) - badgeW - stringWidth(when) - 1);
557
+ return (chalk.white(padToWidth(truncateToWidth(session.shortId, 9), 10)) +
558
+ agentColor(padToWidth(truncateToWidth(session.agent, 8), 9)) +
559
+ chalk.yellow(padToWidth(truncateToWidth(versionStr, 7), 8)) +
560
+ chalk.cyan(padToWidth(truncateToWidth(project, 14), 16)) +
561
+ renderTopicCell(label, topic, '', topicW, topicW) +
562
+ (badges ? badges + ' ' : '') +
563
+ chalk.gray(when));
564
+ }
565
+ /** One tree-mode row (grouped under a dir header): id · agent · badges · topic · time. No version/project column. */
566
+ function treeSessionRow(session) {
567
+ const agentColor = colorAgent(session.agent);
568
+ const when = formatRelativeTime(session.timestamp);
569
+ const tag = teamTag(session);
570
+ const label = session.label;
571
+ const topic = (tag ? `${tag}${session.topic ?? ''}` : session.topic) || '-';
572
+ const badges = signalBadges(metaSignals(session));
573
+ const badgeW = badges ? stringWidth(badges) + 1 : 0;
574
+ const head = label ? `${label} · ${topic}` : topic;
575
+ const topicW = Math.max(12, terminalWidth() - (2 + 9 + 8) - badgeW - stringWidth(when) - 1);
576
+ return (' ' +
577
+ chalk.dim(padToWidth(session.shortId, 9)) +
578
+ agentColor(padToWidth(truncateToWidth(session.agent, 7), 8)) +
579
+ (badges ? badges + ' ' : '') +
580
+ padToWidth(chalk.white(truncateToWidth(head, topicW)), topicW) +
581
+ ' ' + chalk.gray(when));
582
+ }
583
+ function printSessionTable(sessions, hiddenCount = 0, tree = false) {
584
+ if (tree) {
585
+ // Group by directory; drop the id/version columns from view. The short id
586
+ // stays as each row's leading handle (the address to read/resume it).
587
+ const byDir = new Map();
588
+ for (const s of sessions) {
589
+ const key = s.cwd || s.project || 'unknown';
590
+ (byDir.get(key) ?? byDir.set(key, []).get(key)).push(s);
591
+ }
592
+ const keys = [...byDir.keys()].sort((a, b) => {
593
+ const d = byDir.get(b).length - byDir.get(a).length;
594
+ return d !== 0 ? d : a.localeCompare(b);
595
+ });
596
+ let first = true;
597
+ for (const key of keys) {
598
+ if (!first)
599
+ console.log();
600
+ first = false;
601
+ const group = byDir.get(key);
602
+ console.log(`${chalk.cyan.bold(shortCwd(key))} ${chalk.gray(`(${group.length})`)}`);
603
+ for (const s of group)
604
+ console.log(treeSessionRow(s));
605
+ }
606
+ const dirWord = keys.length === 1 ? 'directory' : 'directories';
607
+ console.log(chalk.gray(`\n${sessions.length} session${sessions.length === 1 ? '' : 's'} across ${keys.length} ${dirWord}.`));
608
+ if (hiddenCount > 0)
609
+ console.log(chalk.gray(formatTeamHiddenFooter(hiddenCount)));
610
+ return;
505
611
  }
612
+ for (const session of sessions)
613
+ console.log(flatSessionRow(session));
506
614
  const countLine = `${sessions.length} session${sessions.length === 1 ? '' : 's'}.`;
507
615
  console.log(chalk.gray(`\n${countLine}`));
508
616
  if (hiddenCount > 0) {
@@ -608,8 +716,10 @@ function renderTopicCell(label, topic, query, visibleWidth, paddedWidth) {
608
716
  const tpc = (topic ?? '').trim();
609
717
  const sep = ' · ';
610
718
  const raw = lbl && tpc ? `${lbl}${sep}${tpc}` : (lbl || tpc);
611
- const visible = truncate(raw, visibleWidth);
612
- const padding = ' '.repeat(Math.max(0, paddedWidth - visible.length));
719
+ // Width-aware: measure/truncate/pad by display cells, not String.length, so
720
+ // ANSI escapes and wide (CJK/emoji) glyphs don't drift the column.
721
+ const visible = truncateToWidth(raw, visibleWidth);
722
+ const padding = ' '.repeat(Math.max(0, paddedWidth - stringWidth(visible)));
613
723
  const labelEnd = lbl ? Math.min(lbl.length, visible.length) : 0;
614
724
  let matchStart = -1, matchEnd = -1;
615
725
  const q = query.trim().toLowerCase();
@@ -753,6 +863,7 @@ export function buildResumeCommand(session) {
753
863
  case 'hermes':
754
864
  case 'grok':
755
865
  case 'kimi':
866
+ case 'droid':
756
867
  // Grok (and some others) sessions are captured artifacts, not resumable the same way.
757
868
  return null;
758
869
  }
@@ -1128,6 +1239,8 @@ export function registerSessionsCommands(program) {
1128
1239
  .option('--artifacts', 'List all files written or edited during a session')
1129
1240
  .option('--artifact <name>', 'Read a specific artifact by filename or path (outputs to stdout)')
1130
1241
  .option('--active', 'Show only sessions running right now across terminals, teams, cloud, and headless agents')
1242
+ .option('--waiting', 'With --active: show only sessions waiting on your input (exits non-zero if any)')
1243
+ .option('--tree', 'Group the listing by directory; drops the id/version columns for readability')
1131
1244
  .option('--cloud', 'Source sessions from Rush Cloud (captured runs) instead of local disk')
1132
1245
  .option('-H, --host <target...>', 'Run this query on remote machine(s) over SSH (host alias or user@host; repeatable)');
1133
1246
  setHelpSections(sessionsCmd, {
@@ -0,0 +1,14 @@
1
+ /**
2
+ * `agents devices` (registry) + `agents ssh` (smart wrapper).
3
+ *
4
+ * `agents devices` keeps a registry of SSH device profiles — platform, login
5
+ * user, address, and auth — self-populated from `tailscale status --json`.
6
+ * `agents ssh <name>` then connects through one hardened path: preflight
7
+ * (offline → fail fast instead of a 2-minute hang), platform-aware exec
8
+ * (PowerShell on Windows), and password-from-bundle auth via an askpass shim.
9
+ * Rendering the registry to an ssh_config include also lets plain ssh / scp /
10
+ * rsync / `agents sessions --host` resolve the same logical names.
11
+ */
12
+ import type { Command } from 'commander';
13
+ /** Register both `agents ssh` and `agents devices`. */
14
+ export declare function registerSshCommands(program: Command): void;
@@ -0,0 +1,263 @@
1
+ /**
2
+ * `agents devices` (registry) + `agents ssh` (smart wrapper).
3
+ *
4
+ * `agents devices` keeps a registry of SSH device profiles — platform, login
5
+ * user, address, and auth — self-populated from `tailscale status --json`.
6
+ * `agents ssh <name>` then connects through one hardened path: preflight
7
+ * (offline → fail fast instead of a 2-minute hang), platform-aware exec
8
+ * (PowerShell on Windows), and password-from-bundle auth via an askpass shim.
9
+ * Rendering the registry to an ssh_config include also lets plain ssh / scp /
10
+ * rsync / `agents sessions --host` resolve the same logical names.
11
+ */
12
+ import { spawnSync } from 'child_process';
13
+ import * as fs from 'fs';
14
+ import * as os from 'os';
15
+ import * as path from 'path';
16
+ import chalk from 'chalk';
17
+ import ora from 'ora';
18
+ import { readAndResolveBundleEnv } from '../lib/secrets/bundles.js';
19
+ import { getDevice, loadDevices, removeDevice, upsertDevice, } from '../lib/devices/registry.js';
20
+ import { nodeToDeviceInput, parseTailscaleStatus, tailscaleStatusJson, } from '../lib/devices/tailscale.js';
21
+ import { hostNameFor, renderSshConfig } from '../lib/devices/ssh-config.js';
22
+ import { ASKPASS_BUNDLE_ENV, ASKPASS_KEY_ENV, buildSshInvocation, writeAskpassShim, } from '../lib/devices/connect.js';
23
+ /** Parse `user@host` or `host` into pieces. */
24
+ function parseTarget(target) {
25
+ const at = target.indexOf('@');
26
+ if (at === -1)
27
+ return { host: target };
28
+ return { user: target.slice(0, at), host: target.slice(at + 1) };
29
+ }
30
+ /** One-line summary of a device for `list`. */
31
+ function deviceSummary(d) {
32
+ const addr = hostNameFor(d) ?? chalk.gray('no address');
33
+ const online = d.tailscale
34
+ ? d.tailscale.online
35
+ ? chalk.green('online')
36
+ : chalk.gray('offline')
37
+ : chalk.gray('unknown');
38
+ const reach = d.tailscale?.online && !d.tailscale.direct ? chalk.yellow(' (relayed)') : '';
39
+ return ` ${chalk.bold(d.name.padEnd(16))} ${String(d.platform).padEnd(8)} ${(d.user ? d.user + '@' : '') + addr} ${online}${reach}`;
40
+ }
41
+ /** Resolve a device or exit with a clear error. */
42
+ async function mustGetDevice(name) {
43
+ const d = await getDevice(name);
44
+ if (!d) {
45
+ console.error(chalk.red(`Unknown device '${name}'. See 'agents devices list'.`));
46
+ process.exit(1);
47
+ }
48
+ return d;
49
+ }
50
+ /** Register the `agents devices` command tree. */
51
+ function registerDevicesCommands(program) {
52
+ const devicesCmd = program
53
+ .command('devices')
54
+ .description('Registry of SSH device profiles (platform, user, address, auth), self-populated from Tailscale.')
55
+ .addHelpText('after', `
56
+ Typical workflow:
57
+ agents devices sync # ingest tailscale nodes (auto-detect platform)
58
+ agents devices list # see what's registered
59
+ agents devices set win-mini --auth password --bundle muqsit
60
+ agents devices render --write # write ~/.ssh/config.d/agents include
61
+ `);
62
+ devicesCmd
63
+ .command('sync')
64
+ .description('Ingest `tailscale status --json` and create/update device profiles (auto-detects platform, address, reachability).')
65
+ .action(async () => {
66
+ const spinner = ora('Reading tailscale status...').start();
67
+ try {
68
+ const nodes = parseTailscaleStatus(tailscaleStatusJson());
69
+ spinner.text = `Updating ${nodes.length} device${nodes.length === 1 ? '' : 's'}...`;
70
+ for (const node of nodes) {
71
+ await upsertDevice(node.name, nodeToDeviceInput(node));
72
+ }
73
+ spinner.succeed(`Synced ${nodes.length} device${nodes.length === 1 ? '' : 's'} from Tailscale`);
74
+ }
75
+ catch (err) {
76
+ spinner.fail(err.message);
77
+ process.exit(1);
78
+ }
79
+ });
80
+ devicesCmd
81
+ .command('list')
82
+ .alias('ls')
83
+ .description('List registered devices with platform, address, and reachability.')
84
+ .action(async () => {
85
+ const reg = await loadDevices();
86
+ const names = Object.keys(reg).sort();
87
+ if (names.length === 0) {
88
+ console.log(chalk.gray("No devices. Run 'agents devices sync' or 'agents devices add <name> <user@host>'."));
89
+ return;
90
+ }
91
+ console.log(chalk.bold(`Devices (${names.length})`));
92
+ for (const name of names)
93
+ console.log(deviceSummary(reg[name]));
94
+ });
95
+ devicesCmd
96
+ .command('show <name>')
97
+ .description('Show the full profile for one device.')
98
+ .action(async (name) => {
99
+ const d = await mustGetDevice(name);
100
+ console.log(JSON.stringify(d, null, 2));
101
+ });
102
+ devicesCmd
103
+ .command('add <name> <target>')
104
+ .description('Add a device manually (target is user@host or host).')
105
+ .option('--platform <platform>', 'windows | linux | macos')
106
+ .action(async (name, target, opts) => {
107
+ try {
108
+ const { host, user } = parseTarget(target);
109
+ const isIp = /^\d{1,3}(\.\d{1,3}){3}$/.test(host);
110
+ const d = await upsertDevice(name, {
111
+ platform: opts.platform ?? undefined,
112
+ user,
113
+ address: { via: 'manual', dnsName: isIp ? undefined : host, ip: isIp ? host : undefined },
114
+ });
115
+ console.log(chalk.green(`Added device '${name}'`) + chalk.gray(` (${d.platform}, ${user ? user + '@' : ''}${host})`));
116
+ }
117
+ catch (err) {
118
+ console.error(chalk.red(err.message));
119
+ process.exit(1);
120
+ }
121
+ });
122
+ devicesCmd
123
+ .command('set <name>')
124
+ .description('Update fields on an existing device (platform, user, auth).')
125
+ .option('--platform <platform>', 'windows | linux | macos')
126
+ .option('--user <user>', 'login user')
127
+ .option('--auth <method>', 'key | password')
128
+ .option('--bundle <bundle>', 'secrets bundle holding the password (for --auth password)')
129
+ .option('--bundle-key <key>', "key within the bundle (default 'password')")
130
+ .action(async (name, opts) => {
131
+ try {
132
+ const existing = await mustGetDevice(name);
133
+ const auth = opts.auth || opts.bundle || opts.bundleKey
134
+ ? {
135
+ method: opts.auth ?? existing.auth.method,
136
+ bundle: opts.bundle ?? existing.auth.bundle,
137
+ bundleKey: opts.bundleKey ?? existing.auth.bundleKey,
138
+ }
139
+ : undefined;
140
+ const d = await upsertDevice(name, {
141
+ platform: opts.platform ?? undefined,
142
+ user: opts.user ?? undefined,
143
+ auth,
144
+ });
145
+ console.log(chalk.green(`Updated device '${name}'`) + chalk.gray(` (auth: ${d.auth.method}${d.auth.bundle ? ` via ${d.auth.bundle}` : ''})`));
146
+ }
147
+ catch (err) {
148
+ console.error(chalk.red(err.message));
149
+ process.exit(1);
150
+ }
151
+ });
152
+ devicesCmd
153
+ .command('rm <name>')
154
+ .alias('remove')
155
+ .description('Remove a device from the registry.')
156
+ .action(async (name) => {
157
+ const ok = await removeDevice(name);
158
+ if (!ok) {
159
+ console.error(chalk.red(`Unknown device '${name}'.`));
160
+ process.exit(1);
161
+ }
162
+ console.log(chalk.green(`Removed device '${name}'`));
163
+ });
164
+ devicesCmd
165
+ .command('render')
166
+ .description('Render the registry to ssh_config. Prints to stdout, or use --write to update ~/.ssh/config.d/agents.')
167
+ .option('--write', 'write to ~/.ssh/config.d/agents instead of printing')
168
+ .action(async (opts) => {
169
+ const reg = await loadDevices();
170
+ const text = renderSshConfig(reg);
171
+ if (!opts.write) {
172
+ process.stdout.write(text);
173
+ return;
174
+ }
175
+ const dir = path.join(os.homedir(), '.ssh', 'config.d');
176
+ const file = path.join(dir, 'agents');
177
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
178
+ fs.writeFileSync(file, text, { mode: 0o600 });
179
+ console.log(chalk.green(`Wrote ${file}`));
180
+ console.log(chalk.gray('Add this to ~/.ssh/config (once): Include config.d/agents'));
181
+ });
182
+ }
183
+ /** Register the `agents ssh` smart wrapper. */
184
+ function registerSshWrapper(program) {
185
+ const sshCmd = program
186
+ .command('ssh <name> [cmd...]')
187
+ .description('Connect to a registered device. Preflights reachability, picks the right shell, and authenticates (key or password-from-bundle).')
188
+ .allowUnknownOption()
189
+ .addHelpText('after', `
190
+ Examples:
191
+ agents ssh win-mini # interactive login
192
+ agents ssh win-mini hostname # run a command (PowerShell on Windows)
193
+ agents ssh yosemite-s0 uptime # run a command (POSIX)
194
+
195
+ Devices come from 'agents devices'. Password auth pulls the secret from a
196
+ secrets bundle via an askpass shim — the password never touches argv.
197
+ `)
198
+ .action(async (name, cmd) => {
199
+ // Hidden askpass bridge: ssh execs the shim, which re-invokes us here.
200
+ if (name === '__askpass') {
201
+ await runAskpass();
202
+ return;
203
+ }
204
+ const device = await mustGetDevice(name);
205
+ // Preflight: a device Tailscale last saw offline would otherwise hang
206
+ // for the full ConnectTimeout. Fail fast with a clear message instead.
207
+ if (device.tailscale && !device.tailscale.online) {
208
+ console.error(chalk.red(`Device '${name}' is offline (Tailscale last saw it ${device.tailscale.lastSeen ?? 'a while ago'}).`));
209
+ console.error(chalk.gray("Run 'agents devices sync' to refresh reachability."));
210
+ process.exit(1);
211
+ }
212
+ if (device.tailscale?.online && !device.tailscale.direct) {
213
+ console.error(chalk.yellow(`Note: connection to '${name}' is relayed (DERP ${device.tailscale.relay ?? '?'}) — expect higher latency.`));
214
+ }
215
+ try {
216
+ const shim = writeAskpassShim();
217
+ const { args, env } = buildSshInvocation(device, cmd, shim);
218
+ const res = spawnSync('ssh', args, {
219
+ stdio: 'inherit',
220
+ env: { ...process.env, ...env },
221
+ });
222
+ process.exit(res.status ?? 1);
223
+ }
224
+ catch (err) {
225
+ console.error(chalk.red(err.message));
226
+ process.exit(1);
227
+ }
228
+ });
229
+ // Keep the hidden askpass invocation out of help.
230
+ void sshCmd;
231
+ }
232
+ /**
233
+ * The askpass side of password auth. Invoked by the shim (which ssh execs with
234
+ * SSH_ASKPASS): read the target bundle/key from the environment the wrapper
235
+ * set, resolve it through the existing Keychain path, and print the password
236
+ * to stdout for ssh to consume.
237
+ */
238
+ async function runAskpass() {
239
+ const bundle = process.env[ASKPASS_BUNDLE_ENV];
240
+ const key = process.env[ASKPASS_KEY_ENV] ?? 'password';
241
+ if (!bundle) {
242
+ console.error(`askpass: ${ASKPASS_BUNDLE_ENV} not set`);
243
+ process.exit(1);
244
+ }
245
+ try {
246
+ const { env } = readAndResolveBundleEnv(bundle, { caller: 'agents ssh' });
247
+ const value = env[key];
248
+ if (value === undefined) {
249
+ console.error(`askpass: key '${key}' not found in bundle '${bundle}'`);
250
+ process.exit(1);
251
+ }
252
+ process.stdout.write(value);
253
+ }
254
+ catch (err) {
255
+ console.error(`askpass: ${err?.message ?? err}`);
256
+ process.exit(1);
257
+ }
258
+ }
259
+ /** Register both `agents ssh` and `agents devices`. */
260
+ export function registerSshCommands(program) {
261
+ registerSshWrapper(program);
262
+ registerDevicesCommands(program);
263
+ }