@phnx-labs/agents-cli 1.20.70 → 1.20.72

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 (80) hide show
  1. package/CHANGELOG.md +85 -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/commands.js +2 -0
  6. package/dist/commands/exec.js +12 -0
  7. package/dist/commands/fleet-capture.d.ts +14 -0
  8. package/dist/commands/fleet-capture.js +107 -0
  9. package/dist/commands/fork.js +13 -7
  10. package/dist/commands/hosts.js +11 -0
  11. package/dist/commands/logs.js +38 -9
  12. package/dist/commands/mcp.js +2 -0
  13. package/dist/commands/resource-view.d.ts +2 -0
  14. package/dist/commands/resource-view.js +8 -0
  15. package/dist/commands/secrets.d.ts +1 -0
  16. package/dist/commands/secrets.js +24 -4
  17. package/dist/commands/sessions-tail.js +1 -2
  18. package/dist/commands/sessions.d.ts +6 -0
  19. package/dist/commands/sessions.js +14 -6
  20. package/dist/commands/skills.js +2 -0
  21. package/dist/commands/ssh.js +24 -6
  22. package/dist/commands/status.js +8 -2
  23. package/dist/commands/subagents.js +3 -1
  24. package/dist/commands/uninstall.d.ts +11 -0
  25. package/dist/commands/uninstall.js +158 -0
  26. package/dist/commands/usage.d.ts +13 -0
  27. package/dist/commands/usage.js +50 -28
  28. package/dist/commands/utils.d.ts +29 -0
  29. package/dist/commands/utils.js +24 -0
  30. package/dist/commands/versions.js +120 -11
  31. package/dist/index.js +5 -3
  32. package/dist/lib/commands.d.ts +10 -0
  33. package/dist/lib/commands.js +31 -7
  34. package/dist/lib/daemon.d.ts +9 -0
  35. package/dist/lib/daemon.js +43 -0
  36. package/dist/lib/devices/connect.d.ts +13 -0
  37. package/dist/lib/devices/connect.js +20 -0
  38. package/dist/lib/devices/fleet.d.ts +36 -3
  39. package/dist/lib/devices/fleet.js +42 -4
  40. package/dist/lib/devices/sync.d.ts +30 -0
  41. package/dist/lib/devices/sync.js +50 -0
  42. package/dist/lib/doctor-diff.js +38 -16
  43. package/dist/lib/fleet/apply.d.ts +4 -0
  44. package/dist/lib/fleet/apply.js +28 -5
  45. package/dist/lib/fleet/capture.d.ts +35 -0
  46. package/dist/lib/fleet/capture.js +51 -0
  47. package/dist/lib/fleet/manifest.d.ts +6 -0
  48. package/dist/lib/fleet/manifest.js +24 -1
  49. package/dist/lib/fleet/types.d.ts +24 -1
  50. package/dist/lib/git.d.ts +14 -0
  51. package/dist/lib/git.js +39 -1
  52. package/dist/lib/hosts/logs.d.ts +12 -0
  53. package/dist/lib/hosts/logs.js +18 -0
  54. package/dist/lib/menubar/install-menubar.d.ts +12 -0
  55. package/dist/lib/menubar/install-menubar.js +26 -13
  56. package/dist/lib/secrets/agent.d.ts +5 -0
  57. package/dist/lib/secrets/agent.js +35 -3
  58. package/dist/lib/secrets/bundles.js +28 -0
  59. package/dist/lib/secrets/index.d.ts +14 -0
  60. package/dist/lib/secrets/index.js +36 -5
  61. package/dist/lib/secrets/session-store.d.ts +90 -0
  62. package/dist/lib/secrets/session-store.js +221 -0
  63. package/dist/lib/session/render.d.ts +9 -1
  64. package/dist/lib/session/render.js +35 -4
  65. package/dist/lib/share/capture.d.ts +2 -0
  66. package/dist/lib/share/capture.js +12 -5
  67. package/dist/lib/shims.d.ts +27 -0
  68. package/dist/lib/shims.js +29 -3
  69. package/dist/lib/skills.d.ts +8 -1
  70. package/dist/lib/skills.js +11 -1
  71. package/dist/lib/startup/command-registry.d.ts +1 -0
  72. package/dist/lib/startup/command-registry.js +2 -0
  73. package/dist/lib/types.d.ts +5 -1
  74. package/dist/lib/uninstall.d.ts +84 -0
  75. package/dist/lib/uninstall.js +347 -0
  76. package/dist/lib/usage.d.ts +13 -1
  77. package/dist/lib/usage.js +32 -14
  78. package/dist/lib/versions.d.ts +16 -0
  79. package/dist/lib/versions.js +40 -3
  80. package/package.json +1 -1
@@ -4,6 +4,7 @@
4
4
  * Small helpers used across multiple commands: prompt cancellation detection,
5
5
  * table formatting, spinner management, and platform-specific workarounds.
6
6
  */
7
+ import type { Command } from 'commander';
7
8
  import type { AgentId } from '../lib/types.js';
8
9
  import { type InstalledAgentTargetResult, type VersionSelectionResult } from '../lib/versions.js';
9
10
  /**
@@ -22,6 +23,34 @@ export declare function isPromptCancelled(err: unknown): boolean;
22
23
  * True when stdin/stdout are attached to a real terminal.
23
24
  */
24
25
  export declare function isInteractiveTerminal(): boolean;
26
+ /** The resolved I/O surface for one command invocation — the human/agent split. */
27
+ export interface Surface {
28
+ /** Machine-readable output was requested (`--json`). */
29
+ json: boolean;
30
+ /** Skip confirmation prompts — explicit `--yes`/`-y`, or a non-interactive shell. */
31
+ assumeYes: boolean;
32
+ /** Suppress non-essential human chrome (`--quiet`). */
33
+ quiet: boolean;
34
+ /**
35
+ * Safe to show an interactive picker/prompt: a real terminal AND not asking for
36
+ * `--json` (a JSON consumer is a machine and never wants a picker).
37
+ */
38
+ interactive: boolean;
39
+ }
40
+ /**
41
+ * Compute a command's I/O surface once, in one place, instead of each command
42
+ * re-deriving the human-vs-agent split (and raw-sniffing `process.std*.isTTY`
43
+ * with inconsistent stream choices — the audit's R4). Reads the merged option
44
+ * set via `optsWithGlobals()`, so it sees the command's own flags and any
45
+ * inherited global ones, and folds in the terminal state:
46
+ *
47
+ * - `assumeYes` = explicit `--yes` OR a non-interactive shell (no one to prompt).
48
+ * - `interactive` = a real TTY AND not `--json`.
49
+ *
50
+ * Adopt incrementally: a command switches its ad-hoc `isTTY`/`options.yes` checks
51
+ * to a single `const s = resolveSurface(cmd)` without changing its flag surface.
52
+ */
53
+ export declare function resolveSurface(cmd: Command): Surface;
25
54
  /**
26
55
  * Exit with a clean message when a picker would be required in a non-interactive shell.
27
56
  */
@@ -45,6 +45,30 @@ export function isPromptCancelled(err) {
45
45
  export function isInteractiveTerminal() {
46
46
  return Boolean(process.stdin.isTTY && process.stdout.isTTY);
47
47
  }
48
+ /**
49
+ * Compute a command's I/O surface once, in one place, instead of each command
50
+ * re-deriving the human-vs-agent split (and raw-sniffing `process.std*.isTTY`
51
+ * with inconsistent stream choices — the audit's R4). Reads the merged option
52
+ * set via `optsWithGlobals()`, so it sees the command's own flags and any
53
+ * inherited global ones, and folds in the terminal state:
54
+ *
55
+ * - `assumeYes` = explicit `--yes` OR a non-interactive shell (no one to prompt).
56
+ * - `interactive` = a real TTY AND not `--json`.
57
+ *
58
+ * Adopt incrementally: a command switches its ad-hoc `isTTY`/`options.yes` checks
59
+ * to a single `const s = resolveSurface(cmd)` without changing its flag surface.
60
+ */
61
+ export function resolveSurface(cmd) {
62
+ const opts = cmd.optsWithGlobals();
63
+ const tty = isInteractiveTerminal();
64
+ const json = opts.json === true;
65
+ return {
66
+ json,
67
+ assumeYes: opts.yes === true || !tty,
68
+ quiet: opts.quiet === true,
69
+ interactive: tty && !json,
70
+ };
71
+ }
48
72
  /**
49
73
  * Exit with a clean message when a picker would be required in a non-interactive shell.
50
74
  */
@@ -8,9 +8,9 @@ import { AGENTS, ALL_AGENT_IDS, accountOrgBadge, getAccountEmail, getAccountInfo
8
8
  import { formatUsageSummary, getUsageInfoForIdentity, getUsageInfoByIdentity, getUsageLookupKey, } from '../lib/usage.js';
9
9
  import { viewAction } from './view.js';
10
10
  import { readManifest, writeManifest, createDefaultManifest } from '../lib/manifest.js';
11
- import { installVersion, removeVersion, listInstalledVersions, isVersionInstalled, isLatestInstalled, isOldestInstalled, getGlobalDefault, setGlobalDefault, getVersionHomePath, getVersionDir, syncResourcesToVersion, parseAgentSpec, promptResourceSelection, promptNewResourceSelection, getAvailableResources, getActuallySyncedResources, getNewResources, getProjectOnlyResources, hasNewResources, printTrashFooter, } from '../lib/versions.js';
11
+ import { installVersion, removeVersion, listInstalledVersions, isVersionInstalled, isLatestInstalled, isOldestInstalled, getGlobalDefault, setGlobalDefault, markVersionIsolated, isVersionIsolated, getVersionHomePath, getVersionDir, syncResourcesToVersion, parseAgentSpec, promptResourceSelection, promptNewResourceSelection, getAvailableResources, getActuallySyncedResources, getNewResources, getProjectOnlyResources, hasNewResources, printTrashFooter, } from '../lib/versions.js';
12
12
  import { carryForwardSettings } from '../lib/settings-manifest.js';
13
- import { createShim, createVersionedAlias, removeShim, shimExists, getShimsDir, getShimPath, getPathShadowingExecutable, isShimsInPath, getPathSetupInstructions, addShimsToPath, switchConfigSymlink, switchHomeFileSymlinks, } from '../lib/shims.js';
13
+ import { createShim, createVersionedAlias, supportsIsolatedInstall, CONFIG_ENV_ISOLATED_AGENTS, removeShim, shimExists, getShimsDir, getShimPath, getPathShadowingExecutable, isShimsInPath, getPathSetupInstructions, addShimsToPath, switchConfigSymlink, switchHomeFileSymlinks, } from '../lib/shims.js';
14
14
  import { isInteractiveTerminal, isPromptCancelled, requireInteractiveSelection } from './utils.js';
15
15
  import { tryAutoPull } from '../lib/git.js';
16
16
  import { getAgentsDir, getTrashVersionsDir } from '../lib/state.js';
@@ -98,8 +98,30 @@ function warnIfShimShadowed(agent) {
98
98
  console.log(chalk.green(` Added shim directory to ${result.location}.`));
99
99
  console.log(chalk.gray(` ${result.reloadHint}`));
100
100
  }
101
+ /**
102
+ * Install an isolated copy of an agent version: a fully self-contained install
103
+ * that never touches the user's existing setup.
104
+ *
105
+ * Unlike a normal install it does NOT set (or offer to set) the global default,
106
+ * does NOT create/replace the bare `<agent>` shim, does NOT back up or symlink
107
+ * the user's real `~/.<agent>`, and does NOT carry over settings or resources.
108
+ * It only creates the versioned alias (so the copy is launchable) and records
109
+ * the isolated marker. Invoke the copy explicitly with `agents run <agent>@<v>`.
110
+ */
111
+ function finalizeIsolatedInstall(agent, version) {
112
+ const agentConfig = AGENTS[agent];
113
+ const label = agentLabel(agentConfig.id);
114
+ createVersionedAlias(agent, version);
115
+ markVersionIsolated(agent, version);
116
+ console.log(chalk.green(` Installed ${label}@${version} as an isolated copy.`));
117
+ console.log(chalk.gray(` Your existing ${agentConfig.configDir} and default ${label} are untouched.`));
118
+ console.log(chalk.gray(` Run it: agents run ${agent}@${version} "your prompt"`));
119
+ console.log(chalk.gray(` It has its own config and login — sign in the first time you run it.`));
120
+ console.log(chalk.gray(` Remove it: agents remove ${agent}@${version} --isolated`));
121
+ }
101
122
  async function versionPruneAction(specs, options, commandName) {
102
123
  const isProject = options.project;
124
+ const isIsolated = options.isolated;
103
125
  const moved = [];
104
126
  for (const spec of specs) {
105
127
  const parsed = parseAgentSpec(spec);
@@ -110,6 +132,12 @@ async function versionPruneAction(specs, options, commandName) {
110
132
  }
111
133
  const { agent, version } = parsed;
112
134
  const agentConfig = AGENTS[agent];
135
+ // --isolated only ever applies to agents that can BE installed isolated;
136
+ // for the rest no isolated version can exist, so say so plainly.
137
+ if (isIsolated && !supportsIsolatedInstall(agent)) {
138
+ console.log(chalk.gray(`${agentLabel(agentConfig.id)} has no isolated installs (--isolated is not supported for it).`));
139
+ continue;
140
+ }
113
141
  // Script-installed agents (droid, grok) can have a *literal* `latest`
114
142
  // version dir on disk when the post-install version probe failed. An
115
143
  // explicit `<agent>@latest` should remove that dir directly rather than
@@ -117,9 +145,14 @@ async function versionPruneAction(specs, options, commandName) {
117
145
  // so treat an installed literal `latest` as a concrete pinned version.
118
146
  const isLiteralLatestInstalled = version === 'latest' && spec.includes('@') && isVersionInstalled(agent, 'latest');
119
147
  if (!isLiteralLatestInstalled && (version === 'latest' || version === 'oldest' || !spec.includes('@'))) {
120
- const versions = listInstalledVersions(agent);
148
+ // With --isolated, only isolated installs are eligible for the picker, so
149
+ // a normal/default install can never be selected here by accident.
150
+ const versions = listInstalledVersions(agent)
151
+ .filter((v) => !isIsolated || isVersionIsolated(agent, v));
121
152
  if (versions.length === 0) {
122
- console.log(chalk.gray(`No versions of ${agentLabel(agentConfig.id)} installed`));
153
+ console.log(chalk.gray(isIsolated
154
+ ? `No isolated ${agentLabel(agentConfig.id)} installs`
155
+ : `No versions of ${agentLabel(agentConfig.id)} installed`));
123
156
  continue;
124
157
  }
125
158
  if (!isInteractiveTerminal()) {
@@ -157,6 +190,9 @@ async function versionPruneAction(specs, options, commandName) {
157
190
  }
158
191
  fixSessionFilePaths(agent, v, versionDir);
159
192
  console.log(chalk.green(`Moved ${agentLabel(agentConfig.id)}@${v} to trash`));
193
+ if (isIsolated) {
194
+ console.log(chalk.gray(` Your real ${agentConfig.configDir} and default ${agentLabel(agentConfig.id)} were untouched.`));
195
+ }
160
196
  moved.push({ agent, version: v });
161
197
  }
162
198
  // Default reassignment/clearing is handled at the source in
@@ -177,6 +213,13 @@ async function versionPruneAction(specs, options, commandName) {
177
213
  else if (!isVersionInstalled(agent, version)) {
178
214
  console.log(chalk.gray(`${agentLabel(agentConfig.id)}@${version} not installed`));
179
215
  }
216
+ else if (isIsolated && !isVersionIsolated(agent, version)) {
217
+ // Safety guard: `--isolated` refuses to remove a normal/default install,
218
+ // so an accidental `remove <agent>@<default> --isolated` can never delete
219
+ // the user's primary version or disturb their real ~/.<agent>.
220
+ console.log(chalk.yellow(`${agentLabel(agentConfig.id)}@${version} is not an isolated install; refusing to remove it under --isolated.`));
221
+ console.log(chalk.gray(` Drop --isolated to remove a normal version: agents ${commandName} ${agent}@${version}`));
222
+ }
180
223
  else {
181
224
  const versionDir = getVersionDir(agent, version);
182
225
  const removed = removeVersion(agent, version);
@@ -186,6 +229,9 @@ async function versionPruneAction(specs, options, commandName) {
186
229
  }
187
230
  fixSessionFilePaths(agent, version, versionDir);
188
231
  console.log(chalk.green(`Moved ${agentLabel(agentConfig.id)}@${version} to trash`));
232
+ if (isIsolated) {
233
+ console.log(chalk.gray(` Your real ${agentConfig.configDir} and default ${agentLabel(agentConfig.id)} were untouched.`));
234
+ }
189
235
  moved.push({ agent, version });
190
236
  const remaining = listInstalledVersions(agent);
191
237
  if (remaining.length === 0) {
@@ -212,7 +258,8 @@ function configureVersionPruneCommand(cmd, commandName) {
212
258
  .description(isAlias
213
259
  ? 'Alias for agents prune. Uninstalls agent CLI versions.'
214
260
  : 'Uninstall agent CLI versions. Moves version data to trash for recovery.')
215
- .option('-p, --project', 'Also clear the pinned version from .agents/agents.yaml in the current project');
261
+ .option('-p, --project', 'Also clear the pinned version from .agents/agents.yaml in the current project')
262
+ .option('--isolated', 'Only act on isolated installs (created with `agents add --isolated`). Refuses to remove a normal/default install and never touches your real ~/.<agent>.');
216
263
  setHelpSections(cmd, {
217
264
  examples: `
218
265
  # Prune a specific version
@@ -223,11 +270,15 @@ function configureVersionPruneCommand(cmd, commandName) {
223
270
 
224
271
  # Prune and also clear the project pin
225
272
  agents ${commandName} claude@2.0.50 --project
273
+
274
+ # Cleanly remove an isolated copy, leaving your normal install alone
275
+ agents ${commandName} claude@2.1.112 --isolated
226
276
  `,
227
277
  notes: `
228
278
  - Pruned version directories move to trash with their home/ data intact.
229
279
  - Session file paths are rewritten so session history remains readable.
230
280
  - Removing the default version unsets the default; run 'agents use' to pick a new one.
281
+ - --isolated restricts the operation to isolated installs and refuses to remove a normal/default version, so your existing setup is never disturbed.
231
282
  - Reinstall any time with 'agents add'.
232
283
  `,
233
284
  });
@@ -239,6 +290,7 @@ export function registerVersionsCommands(program) {
239
290
  .command('add <specs...>')
240
291
  .description('Download and install agent CLI versions. Enables subsidized API usage through managed binaries.')
241
292
  .option('-p, --project', 'Lock this version to the current project directory only, stored in project-root agents.yaml')
293
+ .option('--isolated', 'Install a fully self-contained copy that never touches your existing ~/.<agent> or default. Launch it explicitly with `agents run <agent>@<version>`. Cannot be combined with --project.')
242
294
  .option('-y, --yes', 'Auto-accept defaults without prompting (useful for scripts and CI)');
243
295
  setHelpSections(addCmd, {
244
296
  examples: `
@@ -256,16 +308,26 @@ export function registerVersionsCommands(program) {
256
308
 
257
309
  # Lock a version to this project only (won't affect global default)
258
310
  agents add claude@2.1.100 --project
311
+
312
+ # Install a clean, separate copy that leaves your existing setup alone
313
+ agents add claude@2.1.112 --isolated
259
314
  `,
260
315
  notes: `
261
316
  - The first version you install becomes the default automatically.
262
317
  - 'add' does NOT change the default if a default already exists. Use 'agents use' to switch.
263
318
  - Multi-account: each installed version has separate auth, so you can install the same agent twice for two accounts.
319
+ - --isolated installs a self-contained copy: it never sets the default, never creates the bare '<agent>' shim, and never backs up or symlinks your real ~/.<agent>. Run it with 'agents run <agent>@<version>' and remove it with 'agents remove <agent>@<version> --isolated'. Mutually exclusive with --project.
264
320
  `,
265
321
  });
266
322
  addCmd.action(async (specs, options) => {
267
323
  const isProject = options.project;
324
+ const isIsolated = options.isolated;
268
325
  const skipPrompts = options.yes || !isInteractiveTerminal();
326
+ if (isIsolated && isProject) {
327
+ console.log(chalk.red('--isolated and --project cannot be combined.'));
328
+ console.log(chalk.gray('An isolated copy is global-but-separate; a project pin selects a shared install for one directory.'));
329
+ return;
330
+ }
269
331
  for (const spec of specs) {
270
332
  const parsed = parseAgentSpec(spec);
271
333
  if (!parsed) {
@@ -276,6 +338,16 @@ export function registerVersionsCommands(program) {
276
338
  const { agent, version } = parsed;
277
339
  const agentConfig = AGENTS[agent];
278
340
  warnAgentDeprecated(agent);
341
+ // Isolation relies on a config-dir env var to redirect the copy away
342
+ // from the user's real ~/.<agent>. Agents without one isolate only by
343
+ // adopting ~/.<agent> (which --isolated skips), so an isolated copy
344
+ // would silently read/write the real config. Refuse rather than lie.
345
+ if (isIsolated && !supportsIsolatedInstall(agent)) {
346
+ console.log(chalk.red(`${agentLabel(agentConfig.id)} does not support --isolated installs.`));
347
+ console.log(chalk.gray(` It has no config-directory env var, so it can only isolate by adopting ${agentConfig.configDir} — which --isolated deliberately avoids.`));
348
+ console.log(chalk.gray(` Supported with --isolated: ${CONFIG_ENV_ISOLATED_AGENTS.join(', ')}.`));
349
+ continue;
350
+ }
279
351
  if (!agentConfig.npmPackage && !agentConfig.installScript) {
280
352
  console.log(chalk.yellow(`${agentLabel(agentConfig.id)} has no npm package. Install manually.`));
281
353
  continue;
@@ -301,6 +373,19 @@ export function registerVersionsCommands(program) {
301
373
  alreadyInstalled = isVersionInstalled(agent, version);
302
374
  }
303
375
  if (alreadyInstalled) {
376
+ if (isIsolated) {
377
+ if (!isVersionIsolated(agent, installedAsVersion)) {
378
+ // A normal and an isolated install of the SAME version share one
379
+ // on-disk dir, so they can't coexist. Refuse rather than silently
380
+ // convert the user's existing (possibly default) install.
381
+ console.log(chalk.yellow(`${agentLabel(agentConfig.id)}@${installedAsVersion} is already installed as a normal (default-eligible) version.`));
382
+ console.log(chalk.gray(` Remove it first (agents remove ${agent}@${installedAsVersion}) then re-add with --isolated, or pick a different version.`));
383
+ continue;
384
+ }
385
+ // Already isolated: re-affirm the alias + marker idempotently.
386
+ finalizeIsolatedInstall(agent, installedAsVersion);
387
+ continue;
388
+ }
304
389
  console.log(chalk.gray(`${agentLabel(agentConfig.id)}@${installedAsVersion} already installed`));
305
390
  // Ensure shim exists (in case it was deleted or needs updating)
306
391
  createShim(agent);
@@ -312,15 +397,23 @@ export function registerVersionsCommands(program) {
312
397
  });
313
398
  if (result.success) {
314
399
  spinner.succeed(`Installed ${agentLabel(agentConfig.id)}@${result.installedVersion}`);
400
+ const installedVersion = result.installedVersion || version;
401
+ // Track the concrete version so a `--project` pin records it instead
402
+ // of the `latest`/`oldest` alias.
403
+ installedAsVersion = installedVersion;
404
+ // Isolated installs stop here: no bare shim, no settings carry-over,
405
+ // no resource sync, no default switch, no PATH edits. Just a
406
+ // launchable versioned alias + the isolated marker, leaving the
407
+ // user's real ~/.<agent> and default untouched.
408
+ if (isIsolated) {
409
+ finalizeIsolatedInstall(agent, installedVersion);
410
+ continue;
411
+ }
315
412
  // Create shim if first install
316
413
  if (!shimExists(agent)) {
317
414
  createShim(agent);
318
415
  console.log(chalk.gray(` Created shim: ${getShimsDir()}/${agentConfig.cliCommand}`));
319
416
  }
320
- const installedVersion = result.installedVersion || version;
321
- // Track the concrete version so a `--project` pin records it instead
322
- // of the `latest`/`oldest` alias.
323
- installedAsVersion = installedVersion;
324
417
  // Seed the fresh version home with user settings from the current
325
418
  // default version (settings.json, keybindings, codex config/auth).
326
419
  // Gap-filling only — never overwrites what the new home has.
@@ -540,8 +633,11 @@ export function registerVersionsCommands(program) {
540
633
  const agentConfig = AGENTS[agentId];
541
634
  let selectedVersion = version;
542
635
  if (!version) {
543
- // Interactive version picker
544
- const versions = listInstalledVersions(agentId);
636
+ // Interactive version picker. Isolated installs are walled off from the
637
+ // real ~/.<agent> on purpose, so they must never be selectable here —
638
+ // setting one as the default would repoint the real config at it. Filter
639
+ // them out (same as the `remove` picker), leaving only usable versions.
640
+ const versions = listInstalledVersions(agentId).filter((v) => !isVersionIsolated(agentId, v));
545
641
  if (versions.length === 0) {
546
642
  console.log(chalk.red(`No versions of ${agentLabel(agentConfig.id)} installed`));
547
643
  console.log(chalk.gray(`Run: agents add ${agentId}@latest`));
@@ -602,6 +698,19 @@ export function registerVersionsCommands(program) {
602
698
  }
603
699
  // selectedVersion is guaranteed to be defined after the check above
604
700
  const finalVersion = selectedVersion;
701
+ // An isolated install is deliberately walled off from the real
702
+ // ~/.<agent>. `agents use` would repoint the real config symlink at it
703
+ // (switchConfigSymlink) and carry settings forward INTO it
704
+ // (carryForwardSettings) — a direct breach of the isolation guarantee.
705
+ // Refuse for both the explicit `use <agent>@<isolated>` path (the picker
706
+ // above already filters isolated versions out of the interactive path).
707
+ // Isolated copies are launched explicitly via `agents run <agent>@<v>`.
708
+ if (isVersionIsolated(agentId, finalVersion)) {
709
+ console.log(chalk.red(`${agentLabel(agentConfig.id)}@${finalVersion} is an isolated install and can't be set as your active version.`));
710
+ console.log(chalk.gray(`Isolated copies stay walled off from your real ${agentConfig.configDir}. Run it directly: agents run ${agentId}@${finalVersion}`));
711
+ console.log(chalk.gray(`To install this version normally: agents add ${agentId}@${finalVersion}`));
712
+ return;
713
+ }
605
714
  if (options.project) {
606
715
  // Set in project manifest
607
716
  const projectManifestDir = path.join(process.cwd(), '.agents');
package/dist/index.js CHANGED
@@ -50,7 +50,7 @@ if (IS_DEV_BUILD) {
50
50
  // module on each invocation (which loaded the whole ~50-module tree before the
51
51
  // first byte of output), the registry maps a command name to a thunk that
52
52
  // imports only what that command needs. See src/lib/startup/command-registry.ts.
53
- import { COMMAND_LOADERS, LAZY_COMMAND_NAMES, loadView, loadInspect, loadFeedback, loadCommands, loadHooks, loadSkills, loadRules, loadMemory, loadPermissions, loadMcp, loadCli, loadSubagents, loadPlugins, loadWorkflows, loadWorktree, loadVersions, loadImport, loadPackages, loadDaemon, loadRoutines, loadMonitors, loadRun, loadFork, loadDefaults, loadModels, loadPrune, loadTrash, loadRestore, loadDoctor, loadApply, loadCheck, loadStatus, loadProfiles, loadSecrets, loadWallet, loadHelper, loadMenubar, loadBeta, loadSync, loadLock, loadRefreshRules, loadDrive, loadFactory, loadUsage, loadCost, loadOutput, loadBudget, loadAlias, loadPty, loadTmux, loadWatchdog, loadBrowser, loadComputer, loadHosts, loadLogs, loadEvents, loadAudit, loadWebhook, loadFunnel, loadSsh, loadPull, loadPush, loadRepo, loadSetup, loadShare, loadFeed, loadMailboxes, } from './lib/startup/command-registry.js';
53
+ import { COMMAND_LOADERS, LAZY_COMMAND_NAMES, loadView, loadInspect, loadFeedback, loadCommands, loadHooks, loadSkills, loadRules, loadMemory, loadPermissions, loadMcp, loadCli, loadSubagents, loadPlugins, loadWorkflows, loadWorktree, loadVersions, loadImport, loadPackages, loadDaemon, loadRoutines, loadMonitors, loadRun, loadFork, loadDefaults, loadModels, loadPrune, loadTrash, loadRestore, loadDoctor, loadApply, loadCheck, loadStatus, loadProfiles, loadSecrets, loadWallet, loadHelper, loadMenubar, loadBeta, loadSync, loadLock, loadRefreshRules, loadDrive, loadFactory, loadUsage, loadCost, loadOutput, loadBudget, loadAlias, loadPty, loadTmux, loadWatchdog, loadBrowser, loadComputer, loadHosts, loadLogs, loadEvents, loadAudit, loadWebhook, loadFunnel, loadSsh, loadPull, loadPush, loadRepo, loadSetup, loadUninstall, loadShare, loadFeed, loadMailboxes, } from './lib/startup/command-registry.js';
54
54
  import { applyGlobalHelpConventions } from './lib/help.js';
55
55
  import { renderWhatsNew } from './lib/whats-new.js';
56
56
  import { emit, redactArgs } from './lib/events.js';
@@ -738,6 +738,7 @@ async function registerAllEagerCommands() {
738
738
  await reg(loadPush);
739
739
  await reg(loadRepo);
740
740
  await reg(loadSetup);
741
+ await reg(loadUninstall);
741
742
  }
742
743
  /** Calculate the Levenshtein edit distance between two strings. */
743
744
  function levenshtein(a, b) {
@@ -865,8 +866,9 @@ if (firstRun) {
865
866
  process.exit(0);
866
867
  }
867
868
  // Every command requires the system repo to be cloned first. `setup` is the
868
- // only exemption it's the command that does the cloning.
869
- const SETUP_EXEMPT_COMMANDS = new Set(['setup', 'help']);
869
+ // command that does the cloning; `uninstall` is its reverse and must run even
870
+ // from a broken/half-setup state (that is exactly when you want to tear down).
871
+ const SETUP_EXEMPT_COMMANDS = new Set(['setup', 'help', 'uninstall']);
870
872
  // Fold legacy ~/.agents-system/ into ~/.agents/.system/ BEFORE ensureInitialized
871
873
  // runs. ensureInitialized checks for .git inside the new path; if the user is
872
874
  // upgrading from a layout where .git lives under the legacy path, the check
@@ -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,
@@ -7,6 +7,15 @@
7
7
  * log output, reload (SIGHUP), and graceful shutdown are handled here.
8
8
  */
9
9
  import { getAgentsBinPath } from './cli-entry.js';
10
+ /**
11
+ * RUSH-1817: decide whether the daemon should (re)take over hosting the secrets
12
+ * broker. The startup host decision is one-shot; this drives the periodic
13
+ * self-heal re-check. Take over ONLY when the daemon is not already hosting AND
14
+ * no healthy broker answers a ping — i.e. a standalone the daemon deferred to at
15
+ * start has since died or crash-looped. Never take over while our in-process
16
+ * broker is hosting, and never clobber a reachable (healthy) broker.
17
+ */
18
+ export declare function shouldTakeOverBroker(isHosting: boolean, brokerReachable: boolean): boolean;
10
19
  /** Read the stored daemon PID from disk. Returns null if not present or invalid. */
11
20
  export declare function readDaemonPid(): number | null;
12
21
  /** Write the daemon PID to the pid file. */
@@ -38,6 +38,17 @@ const WEDGE_THRESHOLD_TICKS = 3;
38
38
  // session (which expires between runs and produces intermittent 401s).
39
39
  const DAEMON_OAUTH_BUNDLE = 'claude';
40
40
  const DAEMON_OAUTH_KEY = 'CLAUDE_CODE_OAUTH_TOKEN';
41
+ /**
42
+ * RUSH-1817: decide whether the daemon should (re)take over hosting the secrets
43
+ * broker. The startup host decision is one-shot; this drives the periodic
44
+ * self-heal re-check. Take over ONLY when the daemon is not already hosting AND
45
+ * no healthy broker answers a ping — i.e. a standalone the daemon deferred to at
46
+ * start has since died or crash-looped. Never take over while our in-process
47
+ * broker is hosting, and never clobber a reachable (healthy) broker.
48
+ */
49
+ export function shouldTakeOverBroker(isHosting, brokerReachable) {
50
+ return !isHosting && !brokerReachable;
51
+ }
41
52
  function getDaemonDir() {
42
53
  const dir = getDaemonDirRoot();
43
54
  fs.mkdirSync(dir, { recursive: true });
@@ -661,6 +672,37 @@ export async function runDaemon() {
661
672
  };
662
673
  const fleetCacheInterval = setInterval(() => { void runFleetCacheWarm(); }, 3 * 60_000);
663
674
  const fleetCacheKickoff = setTimeout(() => { void runFleetCacheWarm(); }, 60_000);
675
+ // RUSH-1817: the startup host decision above is one-shot. If a standalone
676
+ // broker answered agentPing() at daemon start, the daemon declined to host —
677
+ // but should that standalone later die or crash-loop, nothing takes over and
678
+ // every `agents secrets unlock|export|start` fails until a manual restart
679
+ // (this wedged all keychain-backed secrets on zion and blocked a release).
680
+ // Re-probe on a cadence: whenever the daemon is NOT itself hosting AND no
681
+ // healthy broker answers a ping, take over hosting. startHostedBroker binds
682
+ // the socket only when it is free, so a take-over never races a live broker.
683
+ let selfHealingBroker = false;
684
+ const runBrokerSelfHeal = async () => {
685
+ if (selfHealingBroker)
686
+ return;
687
+ selfHealingBroker = true;
688
+ try {
689
+ const { agentPing, startHostedBroker } = await import('./secrets/agent.js');
690
+ const reachable = (await agentPing()).reachable;
691
+ if (!shouldTakeOverBroker(hostedBroker != null, reachable))
692
+ return;
693
+ hostedBroker = await startHostedBroker();
694
+ if (hostedBroker) {
695
+ log('WARN', 'Secrets broker was unreachable; daemon took over hosting (self-heal)');
696
+ }
697
+ }
698
+ catch (err) {
699
+ log('WARN', `Secrets broker self-heal skipped: ${err.message}`);
700
+ }
701
+ finally {
702
+ selfHealingBroker = false;
703
+ }
704
+ };
705
+ const brokerSelfHealInterval = setInterval(() => { void runBrokerSelfHeal(); }, 60_000);
664
706
  const handleReload = () => {
665
707
  log('INFO', 'Reloading jobs (SIGHUP)');
666
708
  scheduler.reloadAll();
@@ -695,6 +737,7 @@ export async function runDaemon() {
695
737
  clearTimeout(launchHealthKickoff);
696
738
  clearInterval(fleetCacheInterval);
697
739
  clearTimeout(fleetCacheKickoff);
740
+ clearInterval(brokerSelfHealInterval);
698
741
  hostedBroker?.close();
699
742
  removeDaemonPid();
700
743
  removeHeartbeat();
@@ -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>[]>;