@phnx-labs/agents-cli 1.20.70 → 1.20.71

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 (51) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/dist/bin/agents +0 -0
  3. package/dist/commands/apply.js +36 -3
  4. package/dist/commands/check.js +3 -1
  5. package/dist/commands/exec.js +12 -0
  6. package/dist/commands/fleet-capture.d.ts +14 -0
  7. package/dist/commands/fleet-capture.js +107 -0
  8. package/dist/commands/fork.js +13 -7
  9. package/dist/commands/hosts.js +11 -0
  10. package/dist/commands/secrets.d.ts +1 -0
  11. package/dist/commands/secrets.js +24 -4
  12. package/dist/commands/sessions-tail.js +1 -2
  13. package/dist/commands/sessions.js +6 -6
  14. package/dist/commands/ssh.js +24 -6
  15. package/dist/commands/usage.d.ts +13 -0
  16. package/dist/commands/usage.js +50 -28
  17. package/dist/lib/commands.d.ts +10 -0
  18. package/dist/lib/commands.js +31 -7
  19. package/dist/lib/devices/connect.d.ts +13 -0
  20. package/dist/lib/devices/connect.js +20 -0
  21. package/dist/lib/devices/fleet.d.ts +36 -3
  22. package/dist/lib/devices/fleet.js +42 -4
  23. package/dist/lib/devices/sync.d.ts +30 -0
  24. package/dist/lib/devices/sync.js +50 -0
  25. package/dist/lib/doctor-diff.js +38 -16
  26. package/dist/lib/fleet/apply.d.ts +4 -0
  27. package/dist/lib/fleet/apply.js +28 -5
  28. package/dist/lib/fleet/capture.d.ts +35 -0
  29. package/dist/lib/fleet/capture.js +51 -0
  30. package/dist/lib/fleet/manifest.d.ts +6 -0
  31. package/dist/lib/fleet/manifest.js +24 -1
  32. package/dist/lib/fleet/types.d.ts +24 -1
  33. package/dist/lib/git.d.ts +14 -0
  34. package/dist/lib/git.js +39 -1
  35. package/dist/lib/menubar/install-menubar.d.ts +12 -0
  36. package/dist/lib/menubar/install-menubar.js +26 -13
  37. package/dist/lib/secrets/agent.d.ts +5 -0
  38. package/dist/lib/secrets/agent.js +35 -3
  39. package/dist/lib/secrets/bundles.js +28 -0
  40. package/dist/lib/secrets/index.d.ts +14 -0
  41. package/dist/lib/secrets/index.js +36 -5
  42. package/dist/lib/secrets/session-store.d.ts +90 -0
  43. package/dist/lib/secrets/session-store.js +221 -0
  44. package/dist/lib/session/render.d.ts +9 -1
  45. package/dist/lib/session/render.js +35 -4
  46. package/dist/lib/share/capture.d.ts +2 -0
  47. package/dist/lib/share/capture.js +12 -5
  48. package/dist/lib/skills.d.ts +8 -1
  49. package/dist/lib/skills.js +11 -1
  50. package/dist/lib/types.d.ts +5 -1
  51. package/package.json +1 -1
@@ -18,6 +18,7 @@ import ora from 'ora';
18
18
  import { getCliVersion } from '../lib/version.js';
19
19
  import { readAndResolveBundleEnv, isHeadlessSecretsContext } from '../lib/secrets/bundles.js';
20
20
  import { machineId } from '../lib/session/sync/config.js';
21
+ import { registerFleetCaptureCommand } from './fleet-capture.js';
21
22
  import { addIgnored, getDevice, loadDevices, loadIgnored, removeDevice, removeIgnored, upsertDevice, } from '../lib/devices/registry.js';
22
23
  import { addControlToken } from '../lib/serve/token.js';
23
24
  import { DEFAULT_SERVE_PORT } from '../lib/serve/server.js';
@@ -27,7 +28,7 @@ import { resolveDeviceTarget, splitUserHost } from '../lib/devices/resolve-targe
27
28
  import { clearPendingSentinel } from '../lib/devices/pending.js';
28
29
  import { isInteractiveTerminal, isPromptCancelled } from './utils.js';
29
30
  import { hostNameFor, renderSshConfig } from '../lib/devices/ssh-config.js';
30
- import { ASKPASS_BUNDLE_ENV, ASKPASS_KEY_ENV, buildSshInvocation, writeAskpassShim, } from '../lib/devices/connect.js';
31
+ import { ASKPASS_BUNDLE_ENV, ASKPASS_KEY_ENV, buildSshInvocation, fleetDialTarget, writeAskpassShim, } from '../lib/devices/connect.js';
31
32
  import { ensureManagedKnownHostsDir, isHostPinned } from '../lib/devices/known-hosts.js';
32
33
  import { shouldSyncTerminfo, syncTerminfoToDevice, terminfoHostKey } from '../lib/devices/terminfo.js';
33
34
  import { fanOutDevices, planFleetTargets, remoteFleetTargets, runFleet, skipLabel, upgradeCommand, } from '../lib/devices/fleet.js';
@@ -244,10 +245,10 @@ async function probeRemoteHealth(target) {
244
245
  const isWin = /^win/i.test((target.platform ?? '').trim());
245
246
  const env = isWin ? undefined : { PATH: '$HOME/.agents/.cache/shims:$HOME/.local/bin:$PATH' };
246
247
  const versionCmd = buildRemoteAgentsInvocation(['--version'], undefined, isWin ? 'windows' : undefined, env);
247
- const versionRes = await sshExecAsync(target.name, versionCmd, { timeoutMs: 15000, multiplex: true });
248
+ const versionRes = await sshExecAsync(target.dialTarget, versionCmd, { timeoutMs: 15000, multiplex: true });
248
249
  const version = versionRes.code === 0 ? versionRes.stdout.trim().split(/\s+/)[0] || null : null;
249
250
  const doctorCmd = buildRemoteAgentsInvocation(['doctor', '--json'], undefined, isWin ? 'windows' : undefined, env);
250
- const doctorRes = await sshExecAsync(target.name, doctorCmd, { timeoutMs: 30000, multiplex: true });
251
+ const doctorRes = await sshExecAsync(target.dialTarget, doctorCmd, { timeoutMs: 30000, multiplex: true });
251
252
  if (doctorRes.code !== 0) {
252
253
  throw new Error(doctorRes.timedOut ? 'timed out' : (doctorRes.stderr.trim() || `exit ${doctorRes.code ?? 'unknown'}`));
253
254
  }
@@ -280,6 +281,20 @@ async function runFleetStatus(opts) {
280
281
  name: t.device.name,
281
282
  platform: t.device.platform,
282
283
  skip: t.skip,
284
+ dialTarget: fleetDialTarget(t.device),
285
+ // Fail fast: the stats probe already tried this box with the same
286
+ // (registry) address. If it came back unreachable, don't spend another
287
+ // 15s+30s on version+doctor — skip straight to an unreachable row so one
288
+ // genuinely-offline device can't stall the whole matrix.
289
+ //
290
+ // Only trust this on a FRESH probe (`--refresh`/`--live`). loadFleetStats
291
+ // is cache-first (daemon-warmed ~3min), so a box that came back online
292
+ // since the last warm would otherwise be skipped to "unreachable" without
293
+ // a live attempt — a false negative. On a default run we still do the live
294
+ // probe (fast now that it dials the registry address).
295
+ ...(forceRefresh && statsMap.get(t.device.name)?.reachable === false && !t.skip
296
+ ? { skip: 'unreachable' }
297
+ : {}),
283
298
  }));
284
299
  const remote = await fanOutDevices(remoteTargets, probeRemoteHealth);
285
300
  for (const result of remote) {
@@ -333,7 +348,7 @@ async function probeRemoteAuth(target) {
333
348
  const isWin = /^win/i.test((target.platform ?? '').trim());
334
349
  const env = isWin ? undefined : { PATH: '$HOME/.agents/.cache/shims:$HOME/.local/bin:$PATH' };
335
350
  const cmd = buildRemoteAgentsInvocation(['devices', 'ping', '--local', '--json'], undefined, isWin ? 'windows' : undefined, env);
336
- const res = await sshExecAsync(target.name, cmd, { timeoutMs: 60000, multiplex: true });
351
+ const res = await sshExecAsync(target.dialTarget, cmd, { timeoutMs: 60000, multiplex: true });
337
352
  if (res.code !== 0) {
338
353
  throw new Error(res.timedOut ? 'timed out' : (res.stderr.trim() || `exit ${res.code ?? 'unknown'}`));
339
354
  }
@@ -370,6 +385,7 @@ async function runFleetPing(opts) {
370
385
  name: t.device.name,
371
386
  platform: t.device.platform,
372
387
  skip: t.skip,
388
+ dialTarget: fleetDialTarget(t.device),
373
389
  }));
374
390
  const probeable = remoteTargets.filter((t) => !t.skip).length;
375
391
  const spinner = isInteractiveTerminal() && !opts.json
@@ -511,6 +527,8 @@ Typical workflow:
511
527
  process.exit(1);
512
528
  }
513
529
  });
530
+ // `agents fleet capture` — snapshot live state into agents.yaml fleet:.
531
+ registerFleetCaptureCommand(devicesCmd);
514
532
  devicesCmd
515
533
  .command('register <name>')
516
534
  .description('Register a discovered (pending) node by name — used by the menu-bar "NEW DEVICES → Register" action.')
@@ -781,7 +799,7 @@ Typical workflow:
781
799
  return;
782
800
  }
783
801
  console.log(chalk.gray(`Running \`${cmd.join(' ')}\` on ${targets.filter((t) => !t.skip).length} online device(s)…`));
784
- const results = runFleet(targets, cmd);
802
+ const results = runFleet(targets, cmd, { self: machineId() });
785
803
  printFleetResults(results);
786
804
  });
787
805
  devicesCmd
@@ -800,7 +818,7 @@ Typical workflow:
800
818
  return;
801
819
  }
802
820
  console.log(chalk.gray(`Running \`${cmd.join(' ')}\` on ${targets.filter((t) => !t.skip).length} online device(s)…`));
803
- const results = runFleet(targets, cmd);
821
+ const results = runFleet(targets, cmd, { self: machineId() });
804
822
  printFleetResults(results);
805
823
  });
806
824
  }
@@ -10,4 +10,17 @@
10
10
  * don't publish per-account usage today)
11
11
  */
12
12
  import type { Command } from 'commander';
13
+ import type { AgentId } from '../lib/types.js';
14
+ import { getUsageInfoForIdentity } from '../lib/usage.js';
15
+ /** One agent's usage snapshot — the unit the text and --json renderers share. */
16
+ export interface AgentUsageRecord {
17
+ agent: AgentId;
18
+ /** Plain agent name (no ANSI) — safe to emit in --json; the text table colorizes it. */
19
+ label: string;
20
+ status: 'unsupported' | 'no-version' | 'not-signed-in' | 'ok';
21
+ email?: string;
22
+ usage?: Awaited<ReturnType<typeof getUsageInfoForIdentity>>;
23
+ }
13
24
  export declare function registerUsageCommand(program: Command): void;
25
+ /** Render one usage record as the human table section. */
26
+ export declare function formatAgentUsage(rec: AgentUsageRecord): string;
@@ -13,13 +13,15 @@ const USAGE_SUPPORTED = new Set(['claude', 'codex', 'kimi', 'droid']);
13
13
  export function registerUsageCommand(program) {
14
14
  addHostOption(program.command('usage [agent]'))
15
15
  .description('Show rate-limit / quota usage per agent')
16
+ .option('--json', 'Emit machine-readable JSON (per-agent usage snapshot) instead of the table')
16
17
  .addHelpText('after', `
17
18
  Examples:
18
19
  agents usage Show usage for all installed agents
19
20
  agents usage claude Show usage for Claude only
20
21
  agents usage codex Show usage for Codex only
22
+ agents usage --json Machine-readable snapshot for scripts
21
23
  `)
22
- .action(async (agentFilter) => {
24
+ .action(async (agentFilter, options) => {
23
25
  let filter;
24
26
  if (agentFilter) {
25
27
  const resolved = resolveAgentName(agentFilter);
@@ -33,47 +35,67 @@ Examples:
33
35
  ? [filter]
34
36
  : ALL_AGENT_IDS.filter((id) => listInstalledVersions(id).length > 0);
35
37
  if (targets.length === 0) {
38
+ if (options.json) {
39
+ console.log('[]');
40
+ return;
41
+ }
36
42
  console.log(chalk.gray('No agents installed. Run `agents add <agent>` first.'));
37
43
  return;
38
44
  }
39
- const sections = await Promise.all(targets.map(async (agentId) => renderAgentUsage(agentId)));
40
- console.log(sections.filter(Boolean).join('\n\n'));
45
+ const records = await Promise.all(targets.map((agentId) => collectAgentUsage(agentId)));
46
+ if (options.json) {
47
+ console.log(JSON.stringify(records, null, 2));
48
+ return;
49
+ }
50
+ console.log(records.map(formatAgentUsage).filter(Boolean).join('\n\n'));
41
51
  });
42
52
  }
43
- async function renderAgentUsage(agentId) {
44
- const cfg = AGENTS[agentId];
45
- const heading = agentLabel(agentId);
53
+ /** Gather one agent's usage snapshot as structured data (shared by both renderers). */
54
+ async function collectAgentUsage(agentId) {
55
+ // Plain name — color is applied only at text-render time (formatAgentUsage), so
56
+ // `--json` never emits ANSI escapes in `label` (e.g. under FORCE_COLOR=1).
57
+ const label = AGENTS[agentId].name;
46
58
  if (!USAGE_SUPPORTED.has(agentId)) {
47
- return [
48
- `${heading}`,
49
- ` ${chalk.dim(`${cfg.name} CLI does not publish usage data.`)}`,
50
- ].join('\n');
59
+ return { agent: agentId, label, status: 'unsupported' };
51
60
  }
52
61
  const versions = listInstalledVersions(agentId);
53
62
  const version = getGlobalDefault(agentId) || versions[0];
54
63
  if (!version) {
55
- return [`${heading}`, ` ${chalk.dim('No version installed.')}`].join('\n');
64
+ return { agent: agentId, label, status: 'no-version' };
56
65
  }
57
66
  const home = getVersionHomePath(agentId, version);
58
67
  const info = await getAccountInfo(agentId, home);
59
68
  if (!info.usageKey && !info.accountKey) {
60
- return [`${heading}`, ` ${chalk.dim('Not signed in.')}`].join('\n');
61
- }
62
- const usage = await getUsageInfoForIdentity({
63
- agentId,
64
- home,
65
- info,
66
- cliVersion: null,
67
- });
68
- const lines = [heading];
69
- if (info.email)
70
- lines.push(` ${chalk.dim(info.email)}`);
71
- const section = formatUsageSection(usage);
72
- if (section.length === 0) {
73
- lines.push(` ${chalk.dim('No usage data available right now.')}`);
69
+ return { agent: agentId, label, status: 'not-signed-in' };
74
70
  }
75
- else {
76
- lines.push(...section);
71
+ const usage = await getUsageInfoForIdentity({ agentId, home, info, cliVersion: null });
72
+ return { agent: agentId, label, status: 'ok', email: info.email ?? undefined, usage };
73
+ }
74
+ /** Render one usage record as the human table section. */
75
+ export function formatAgentUsage(rec) {
76
+ const cfg = AGENTS[rec.agent];
77
+ // Colorize the heading here (not in the record) so the plain `label` stays
78
+ // clean for --json while the text table keeps its per-agent color.
79
+ const heading = agentLabel(rec.agent);
80
+ switch (rec.status) {
81
+ case 'unsupported':
82
+ return [heading, ` ${chalk.dim(`${cfg.name} CLI does not publish usage data.`)}`].join('\n');
83
+ case 'no-version':
84
+ return [heading, ` ${chalk.dim('No version installed.')}`].join('\n');
85
+ case 'not-signed-in':
86
+ return [heading, ` ${chalk.dim('Not signed in.')}`].join('\n');
87
+ case 'ok': {
88
+ const lines = [heading];
89
+ if (rec.email)
90
+ lines.push(` ${chalk.dim(rec.email)}`);
91
+ const section = formatUsageSection(rec.usage);
92
+ if (section.length === 0) {
93
+ lines.push(` ${chalk.dim('No usage data available right now.')}`);
94
+ }
95
+ else {
96
+ lines.push(...section);
97
+ }
98
+ return lines.join('\n');
99
+ }
77
100
  }
78
- return lines.join('\n');
79
101
  }
@@ -89,6 +89,16 @@ export interface VersionCommandDiff {
89
89
  /**
90
90
  * Compare a version home's commands against central. Returns the reconciliation diff.
91
91
  */
92
+ /**
93
+ * Flattened names of plugin-bundled commands (`<plugin>-<command>`), matching
94
+ * exactly how `syncPluginToVersion` installs a plugin's `commands/<cmd>.md` as a
95
+ * command-skill (plugins.ts → `installCommandSkillToVersion(agentDir,
96
+ * `${plugin.name}-${cmd}`, …)`). These are source-managed by their plugin, so
97
+ * the orphan detector must NOT flag them — else `prune cleanup` proposes
98
+ * deleting live plugin commands (swarm-plan, code-review, …). Scans user +
99
+ * system + extra marketplaces (no project layer), matching the trusted sources.
100
+ */
101
+ export declare function listPluginCommandNames(): Set<string>;
92
102
  export declare function diffVersionCommands(agent: AgentId, version: string): VersionCommandDiff;
93
103
  /**
94
104
  * Install a single command from central into a specific version home.
@@ -14,6 +14,7 @@ import { capableAgents, isCapable, supports } from './capabilities.js';
14
14
  import { markdownToToml } from './convert.js';
15
15
  import { getCommandsDir, getUserCommandsDir, getEnabledExtraRepos, getProjectAgentsDir, getSkillsDir, getTrashCommandsDir } from './state.js';
16
16
  import { getEffectiveHome, getVersionHomePath, listInstalledVersions, resolveVersion } from './versions.js';
17
+ import { discoverPlugins } from './plugins.js';
17
18
  import { commandSkillMatches, installCommandSkillToVersion, listCommandSkillsInVersion, removeCommandSkillFromVersion, shouldInstallCommandAsSkill, } from './command-skills.js';
18
19
  import { installGooseCommandToVersion, listGooseCommandsInVersion, gooseCommandMatches, removeGooseCommandFromVersion, } from './goose-commands.js';
19
20
  function compareVersions(a, b) {
@@ -311,8 +312,26 @@ function versionCommandMatches(agent, version, commandName) {
311
312
  /**
312
313
  * Compare a version home's commands against central. Returns the reconciliation diff.
313
314
  */
315
+ /**
316
+ * Flattened names of plugin-bundled commands (`<plugin>-<command>`), matching
317
+ * exactly how `syncPluginToVersion` installs a plugin's `commands/<cmd>.md` as a
318
+ * command-skill (plugins.ts → `installCommandSkillToVersion(agentDir,
319
+ * `${plugin.name}-${cmd}`, …)`). These are source-managed by their plugin, so
320
+ * the orphan detector must NOT flag them — else `prune cleanup` proposes
321
+ * deleting live plugin commands (swarm-plan, code-review, …). Scans user +
322
+ * system + extra marketplaces (no project layer), matching the trusted sources.
323
+ */
324
+ export function listPluginCommandNames() {
325
+ const names = new Set();
326
+ for (const plugin of discoverPlugins()) {
327
+ for (const cmd of plugin.commands)
328
+ names.add(`${plugin.name}-${cmd}`);
329
+ }
330
+ return names;
331
+ }
314
332
  export function diffVersionCommands(agent, version) {
315
333
  const central = new Set(listCentralCommands());
334
+ const pluginCommands = listPluginCommandNames();
316
335
  const installed = new Set(listCommandsInVersionHome(agent, version));
317
336
  const toAdd = [];
318
337
  const toUpdate = [];
@@ -335,15 +354,20 @@ export function diffVersionCommands(agent, version) {
335
354
  }
336
355
  }
337
356
  for (const name of installed) {
338
- if (!central.has(name)) {
339
- orphans.push(name);
357
+ if (central.has(name)) {
358
+ const sourcePath = path.join(getCommandsDir(), `${name}.md`);
359
+ const metadata = parseCommandMetadata(sourcePath);
360
+ if (!commandAppliesTo(agent, version, metadata).ok) {
361
+ toRemove.push(name);
362
+ }
340
363
  continue;
341
364
  }
342
- const sourcePath = path.join(getCommandsDir(), `${name}.md`);
343
- const metadata = parseCommandMetadata(sourcePath);
344
- if (!commandAppliesTo(agent, version, metadata).ok) {
345
- toRemove.push(name);
346
- }
365
+ // A plugin-bundled command (installed as `<plugin>-<cmd>`) is source-managed
366
+ // by its plugin — not an orphan. Only a name with no central AND no plugin
367
+ // source is genuinely unmanaged.
368
+ if (pluginCommands.has(name))
369
+ continue;
370
+ orphans.push(name);
347
371
  }
348
372
  return {
349
373
  agent,
@@ -8,6 +8,19 @@ export declare const ASKPASS_KEY_ENV = "AGENTS_SSH_KEY";
8
8
  * it against the shared injection guard. Throws if the device has no address.
9
9
  */
10
10
  export declare function sshTargetFor(device: DeviceProfile): string;
11
+ /**
12
+ * The address a fleet fan-out probe should hand to `ssh` for a device: the
13
+ * registry's known-good Tailscale dnsName/IP (via {@link sshTargetFor}) when
14
+ * present, else the bare name (an address-less manual device dials by name as
15
+ * before — never worse than the old behaviour).
16
+ *
17
+ * Prefer the registry address so a stale `~/.ssh/config` alias can never shadow
18
+ * it: dialing the bare device name lets ssh resolve it through the user's config,
19
+ * where a hand-written `Host <name>` block with a DHCP-drifted LAN IP silently
20
+ * shadows the correct entry, times out, and makes a reachable box look dead.
21
+ * Pure/testable.
22
+ */
23
+ export declare function fleetDialTarget(device: DeviceProfile): string;
11
24
  /**
12
25
  * Wrap a remote command for the device's shell. Windows devices speak
13
26
  * PowerShell, so a bare command is run through `powershell -NoProfile
@@ -38,6 +38,26 @@ export function sshTargetFor(device) {
38
38
  assertValidSshTarget(target);
39
39
  return target;
40
40
  }
41
+ /**
42
+ * The address a fleet fan-out probe should hand to `ssh` for a device: the
43
+ * registry's known-good Tailscale dnsName/IP (via {@link sshTargetFor}) when
44
+ * present, else the bare name (an address-less manual device dials by name as
45
+ * before — never worse than the old behaviour).
46
+ *
47
+ * Prefer the registry address so a stale `~/.ssh/config` alias can never shadow
48
+ * it: dialing the bare device name lets ssh resolve it through the user's config,
49
+ * where a hand-written `Host <name>` block with a DHCP-drifted LAN IP silently
50
+ * shadows the correct entry, times out, and makes a reachable box look dead.
51
+ * Pure/testable.
52
+ */
53
+ export function fleetDialTarget(device) {
54
+ try {
55
+ return sshTargetFor(device);
56
+ }
57
+ catch {
58
+ return device.user ? `${device.user}@${device.name}` : device.name;
59
+ }
60
+ }
41
61
  /**
42
62
  * Wrap a remote command for the device's shell. Windows devices speak
43
63
  * PowerShell, so a bare command is run through `powershell -NoProfile
@@ -70,17 +70,50 @@ export declare function runOnDevice(device: DeviceProfile, cmd: string[], opts?:
70
70
  stdout: string;
71
71
  stderr: string;
72
72
  };
73
+ /**
74
+ * Run `cmd` on THIS machine directly — no ssh. Used by {@link runFleet} for the
75
+ * self target: a box frequently can't ssh to itself (no self-authorized key, as
76
+ * `agents fleet update` hit trying to reach zion from zion) and doesn't need to —
77
+ * `agents upgrade` etc. runs identically as a local process. Mirrors
78
+ * {@link runOnDevice}'s return shape and never throws. The argv is space-joined
79
+ * and evaluated by a shell — matching the POSIX-shell ssh path (so PATH-resolved
80
+ * `agents`, quoting, and `;`/`&&` behave the same). It does NOT replicate the
81
+ * powershell-device encoding runOnDevice uses (`connect.ts` base64 path): a
82
+ * Windows self runs under the default OS shell (cmd.exe), which still resolves
83
+ * `agents` on PATH for the only self commands that matter (`agents upgrade …`).
84
+ */
85
+ export declare function runLocalCommand(cmd: string[], opts?: {
86
+ timeoutMs?: number;
87
+ }): {
88
+ code: number | null;
89
+ stdout: string;
90
+ stderr: string;
91
+ };
73
92
  /**
74
93
  * Build `agents upgrade --yes` argv, optionally pinned to a version/dist-tag.
75
94
  * Rejects anything that is not a plain npm version/tag token so a version pin
76
95
  * cannot inject shell metacharacters into the remote command line.
77
96
  */
78
97
  export declare function upgradeCommand(version?: string): string[];
98
+ export interface RunFleetOptions {
99
+ /**
100
+ * Name of THIS machine. Its target runs the command **locally** (no ssh) — a
101
+ * box can't reliably ssh to itself and doesn't need to. Omit to ssh every
102
+ * target (the old behaviour). Callers pass `machineId()`.
103
+ */
104
+ self?: string;
105
+ /** Injectable ssh runner (tests). */
106
+ runner?: typeof runOnDevice;
107
+ /** Injectable local runner (tests). */
108
+ localRunner?: typeof runLocalCommand;
109
+ }
79
110
  /**
80
111
  * Execute a command across planned targets. Pure orchestration over
81
- * {@link runOnDevice}; testable by injecting `runner`. Per-device throws from
82
- * the runner are recorded as `failed` so one bad device never aborts the rest.
112
+ * {@link runOnDevice} / {@link runLocalCommand}; testable by injecting either.
113
+ * The `self` target runs locally so `agents fleet update` upgrades this machine
114
+ * too instead of failing to ssh to itself. Per-device throws from a runner are
115
+ * recorded as `failed` so one bad device never aborts the rest.
83
116
  */
84
- export declare function runFleet(targets: FleetTarget[], cmd: string[], runner?: typeof runOnDevice): FleetRunResult[];
117
+ export declare function runFleet(targets: FleetTarget[], cmd: string[], opts?: RunFleetOptions): FleetRunResult[];
85
118
  /** Run one async probe per device in parallel, preserving input order. */
86
119
  export declare function fanOutDevices<T, Target extends FanOutDeviceTarget = FanOutDeviceTarget>(targets: Target[], probe: (target: Target) => Promise<T>): Promise<FanOutDeviceResult<T>[]>;
@@ -94,6 +94,39 @@ export function runOnDevice(device, cmd, opts = {}) {
94
94
  };
95
95
  }
96
96
  }
97
+ /**
98
+ * Run `cmd` on THIS machine directly — no ssh. Used by {@link runFleet} for the
99
+ * self target: a box frequently can't ssh to itself (no self-authorized key, as
100
+ * `agents fleet update` hit trying to reach zion from zion) and doesn't need to —
101
+ * `agents upgrade` etc. runs identically as a local process. Mirrors
102
+ * {@link runOnDevice}'s return shape and never throws. The argv is space-joined
103
+ * and evaluated by a shell — matching the POSIX-shell ssh path (so PATH-resolved
104
+ * `agents`, quoting, and `;`/`&&` behave the same). It does NOT replicate the
105
+ * powershell-device encoding runOnDevice uses (`connect.ts` base64 path): a
106
+ * Windows self runs under the default OS shell (cmd.exe), which still resolves
107
+ * `agents` on PATH for the only self commands that matter (`agents upgrade …`).
108
+ */
109
+ export function runLocalCommand(cmd, opts = {}) {
110
+ try {
111
+ const res = spawnSync(cmd.join(' '), {
112
+ shell: true,
113
+ encoding: 'utf-8',
114
+ timeout: opts.timeoutMs ?? 600_000,
115
+ });
116
+ return {
117
+ code: res.status,
118
+ stdout: res.stdout?.toString() ?? '',
119
+ stderr: (res.stderr?.toString() ?? '') + (res.error ? String(res.error.message) : ''),
120
+ };
121
+ }
122
+ catch (err) {
123
+ return {
124
+ code: 1,
125
+ stdout: '',
126
+ stderr: err instanceof Error ? err.message : String(err),
127
+ };
128
+ }
129
+ }
97
130
  /**
98
131
  * Build `agents upgrade --yes` argv, optionally pinned to a version/dist-tag.
99
132
  * Rejects anything that is not a plain npm version/tag token so a version pin
@@ -110,10 +143,14 @@ export function upgradeCommand(version) {
110
143
  }
111
144
  /**
112
145
  * Execute a command across planned targets. Pure orchestration over
113
- * {@link runOnDevice}; testable by injecting `runner`. Per-device throws from
114
- * the runner are recorded as `failed` so one bad device never aborts the rest.
146
+ * {@link runOnDevice} / {@link runLocalCommand}; testable by injecting either.
147
+ * The `self` target runs locally so `agents fleet update` upgrades this machine
148
+ * too instead of failing to ssh to itself. Per-device throws from a runner are
149
+ * recorded as `failed` so one bad device never aborts the rest.
115
150
  */
116
- export function runFleet(targets, cmd, runner = runOnDevice) {
151
+ export function runFleet(targets, cmd, opts = {}) {
152
+ const runner = opts.runner ?? runOnDevice;
153
+ const localRunner = opts.localRunner ?? runLocalCommand;
117
154
  const results = [];
118
155
  for (const t of targets) {
119
156
  if (t.skip) {
@@ -126,7 +163,8 @@ export function runFleet(targets, cmd, runner = runOnDevice) {
126
163
  continue;
127
164
  }
128
165
  try {
129
- const res = runner(t.device, cmd);
166
+ const isSelf = opts.self !== undefined && t.device.name === opts.self;
167
+ const res = isSelf ? localRunner(cmd) : runner(t.device, cmd);
130
168
  const ok = res.code === 0;
131
169
  const detail = (res.stderr || res.stdout).trim().slice(0, 200);
132
170
  results.push({
@@ -69,6 +69,36 @@ export declare function runDeviceSync(opts?: {
69
69
  soft?: boolean;
70
70
  mode?: DeviceSyncMode;
71
71
  }): Promise<DeviceSyncResult>;
72
+ export interface EnsureDevicesResult {
73
+ /** Names newly resolved from Tailscale and upserted into the registry. */
74
+ registered: string[];
75
+ /** Names that could not be resolved (not on the tailnet / tailscale absent). */
76
+ unresolved: string[];
77
+ }
78
+ /**
79
+ * Pure decision for `ensureDevicesRegistered`: split the wanted names into those
80
+ * to resolve+register (missing from the registry, present on the tailnet, not
81
+ * ignored) vs. unresolved (missing and either off-tailnet or ignored). Already-
82
+ * registered names need nothing. Pure so the bootstrap's filter matrix is
83
+ * unit-testable without a tailnet or registry writes.
84
+ */
85
+ export interface WantedPartition {
86
+ toRegister: string[];
87
+ unresolved: string[];
88
+ }
89
+ export declare function partitionWantedDevices(wanted: string[], registered: Set<string>, tailscaleNames: Set<string>, ignored: Set<string>): WantedPartition;
90
+ /**
91
+ * Fresh-machine bootstrap for `agents apply`: given the device names a `fleet:`
92
+ * manifest wants, register any that aren't in the local registry by resolving
93
+ * them LIVE from Tailscale — so a freshly-cloned `agents.yaml` (which carries
94
+ * names only, never IPs/usernames) reconstructs its roster with zero committed
95
+ * connection details. Soft by design: a missing tailscale binary or an offline
96
+ * name yields `unresolved` rather than throwing, so `apply` degrades to "these
97
+ * devices aren't reachable yet" instead of aborting. Reuses the same
98
+ * parse→withDefaultUser→upsert path as `runDeviceSync`, so a bootstrapped device
99
+ * is identical to a synced one.
100
+ */
101
+ export declare function ensureDevicesRegistered(wantedNames: string[]): Promise<EnsureDevicesResult>;
72
102
  /**
73
103
  * The register/remove/ignore decision for the interactive curation picker.
74
104
  * Pure so the highest-risk reconcile logic is unit-testable without a tailnet
@@ -125,6 +125,56 @@ export async function runDeviceSync(opts = {}) {
125
125
  throw err;
126
126
  }
127
127
  }
128
+ export function partitionWantedDevices(wanted, registered, tailscaleNames, ignored) {
129
+ const out = { toRegister: [], unresolved: [] };
130
+ for (const name of wanted) {
131
+ if (registered.has(name))
132
+ continue;
133
+ if (tailscaleNames.has(name) && !ignored.has(name))
134
+ out.toRegister.push(name);
135
+ else
136
+ out.unresolved.push(name);
137
+ }
138
+ return out;
139
+ }
140
+ /**
141
+ * Fresh-machine bootstrap for `agents apply`: given the device names a `fleet:`
142
+ * manifest wants, register any that aren't in the local registry by resolving
143
+ * them LIVE from Tailscale — so a freshly-cloned `agents.yaml` (which carries
144
+ * names only, never IPs/usernames) reconstructs its roster with zero committed
145
+ * connection details. Soft by design: a missing tailscale binary or an offline
146
+ * name yields `unresolved` rather than throwing, so `apply` degrades to "these
147
+ * devices aren't reachable yet" instead of aborting. Reuses the same
148
+ * parse→withDefaultUser→upsert path as `runDeviceSync`, so a bootstrapped device
149
+ * is identical to a synced one.
150
+ */
151
+ export async function ensureDevicesRegistered(wantedNames) {
152
+ const registryBefore = await loadDevices();
153
+ const registered = new Set(Object.keys(registryBefore));
154
+ const missing = wantedNames.filter((n) => !registered.has(n));
155
+ if (missing.length === 0)
156
+ return { registered: [], unresolved: [] };
157
+ let nodes;
158
+ try {
159
+ nodes = parseTailscaleStatus(tailscaleStatusJson());
160
+ }
161
+ catch {
162
+ // Tailscale absent/unreachable — nothing resolvable; report all missing.
163
+ return { registered: [], unresolved: missing };
164
+ }
165
+ const ignored = await loadIgnored();
166
+ const tailscaleNames = new Set(nodes.map((n) => n.name));
167
+ const { toRegister, unresolved } = partitionWantedDevices(missing, registered, tailscaleNames, ignored);
168
+ const byName = new Map(nodes.map((n) => [n.name, n]));
169
+ const localUser = localLoginUser();
170
+ const done = [];
171
+ for (const name of toRegister) {
172
+ const input = withDefaultUser(nodeToDeviceInput(byName.get(name)), registryBefore[name]?.user, localUser);
173
+ await upsertDevice(name, input);
174
+ done.push(name);
175
+ }
176
+ return { registered: done, unresolved };
177
+ }
128
178
  export function planDeviceReconciliation(allNames, keep, registered, ignored) {
129
179
  const keepSet = new Set(keep);
130
180
  const regSet = new Set(registered);