@phnx-labs/agents-cli 1.20.31 → 1.20.33

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 (74) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/dist/commands/commands.js +3 -3
  3. package/dist/commands/computer-actions.js +1 -0
  4. package/dist/commands/cost.js +2 -2
  5. package/dist/commands/doctor.js +2 -2
  6. package/dist/commands/exec.js +56 -1
  7. package/dist/commands/hooks.js +3 -3
  8. package/dist/commands/inspect.js +13 -17
  9. package/dist/commands/mcp.js +3 -3
  10. package/dist/commands/permissions.js +3 -3
  11. package/dist/commands/rules.js +2 -2
  12. package/dist/commands/sessions.js +18 -1
  13. package/dist/commands/skills.js +3 -3
  14. package/dist/commands/ssh.js +23 -0
  15. package/dist/commands/sync.js +2 -2
  16. package/dist/commands/teams.js +7 -12
  17. package/dist/commands/usage.js +2 -2
  18. package/dist/commands/utils.d.ts +8 -0
  19. package/dist/commands/utils.js +20 -0
  20. package/dist/commands/versions.js +2 -2
  21. package/dist/commands/view.js +33 -9
  22. package/dist/commands/workflows.js +3 -3
  23. package/dist/index.js +12 -0
  24. package/dist/lib/agent-spec/index.d.ts +18 -0
  25. package/dist/lib/agent-spec/index.js +35 -0
  26. package/dist/lib/agent-spec/primitives.d.ts +28 -0
  27. package/dist/lib/agent-spec/primitives.js +57 -0
  28. package/dist/lib/agent-spec/provider.d.ts +2 -0
  29. package/dist/lib/agent-spec/provider.js +9 -0
  30. package/dist/lib/agent-spec/resolve.d.ts +33 -0
  31. package/dist/lib/agent-spec/resolve.js +174 -0
  32. package/dist/lib/agent-spec/types.d.ts +57 -0
  33. package/dist/lib/agent-spec/types.js +18 -0
  34. package/dist/lib/crabbox/cli.d.ts +98 -0
  35. package/dist/lib/crabbox/cli.js +218 -0
  36. package/dist/lib/crabbox/lease.d.ts +41 -0
  37. package/dist/lib/crabbox/lease.js +73 -0
  38. package/dist/lib/crabbox/runtimes.d.ts +57 -0
  39. package/dist/lib/crabbox/runtimes.js +109 -0
  40. package/dist/lib/daemon.js +32 -0
  41. package/dist/lib/devices/pending.d.ts +18 -0
  42. package/dist/lib/devices/pending.js +103 -0
  43. package/dist/lib/devices/sync.d.ts +21 -2
  44. package/dist/lib/devices/sync.js +26 -10
  45. package/dist/lib/hosts/dispatch.d.ts +27 -10
  46. package/dist/lib/hosts/dispatch.js +55 -19
  47. package/dist/lib/hosts/option.d.ts +14 -0
  48. package/dist/lib/hosts/option.js +19 -0
  49. package/dist/lib/hosts/passthrough.d.ts +30 -0
  50. package/dist/lib/hosts/passthrough.js +141 -0
  51. package/dist/lib/hosts/remote-cmd.d.ts +36 -0
  52. package/dist/lib/hosts/remote-cmd.js +56 -0
  53. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  54. package/dist/lib/secrets/bundles.js +29 -20
  55. package/dist/lib/secrets/index.d.ts +11 -0
  56. package/dist/lib/secrets/index.js +18 -1
  57. package/dist/lib/secrets/linux.d.ts +14 -0
  58. package/dist/lib/secrets/linux.js +21 -0
  59. package/dist/lib/session/active.d.ts +8 -0
  60. package/dist/lib/session/active.js +18 -1
  61. package/dist/lib/session/provenance.d.ts +56 -0
  62. package/dist/lib/session/provenance.js +157 -0
  63. package/dist/lib/ssh-exec.d.ts +22 -0
  64. package/dist/lib/ssh-exec.js +59 -2
  65. package/dist/lib/ssh-tunnel.d.ts +0 -5
  66. package/dist/lib/ssh-tunnel.js +65 -8
  67. package/dist/lib/state.d.ts +2 -0
  68. package/dist/lib/state.js +2 -0
  69. package/dist/lib/sync-umbrella.js +10 -6
  70. package/dist/lib/versions.d.ts +13 -4
  71. package/dist/lib/versions.js +27 -20
  72. package/package.json +2 -1
  73. package/dist/lib/agent-spec.d.ts +0 -36
  74. package/dist/lib/agent-spec.js +0 -157
@@ -22,7 +22,8 @@ import * as fs from 'fs';
22
22
  import * as path from 'path';
23
23
  import { fileURLToPath } from 'url';
24
24
  import { randomBytes } from 'crypto';
25
- import { sshExec } from './ssh-exec.js';
25
+ import { Transform } from 'stream';
26
+ import { sshExec, SSH_OPTS } from './ssh-exec.js';
26
27
  import { encodePowerShell } from './browser/drivers/ssh.js';
27
28
  import { getDevice } from './devices/registry.js';
28
29
  import { sshTargetFor } from './devices/connect.js';
@@ -199,20 +200,76 @@ export function buildUnregisterTaskScript(taskName) {
199
200
  * go through `sshExec` (BatchMode key auth — the same hardening the browser
200
201
  * driver and `agents ssh` use). Throws with the remote stderr on any failure.
201
202
  */
203
+ /**
204
+ * Base64-encode a byte stream in 3-byte-aligned chunks so the concatenated
205
+ * output is valid (every chunk boundary lands on a base64 quantum).
206
+ */
207
+ class Base64Encode extends Transform {
208
+ leftover = Buffer.alloc(0);
209
+ _transform(chunk, _enc, cb) {
210
+ const buf = this.leftover.length ? Buffer.concat([this.leftover, chunk]) : chunk;
211
+ const usable = buf.length - (buf.length % 3);
212
+ this.leftover = Buffer.from(buf.subarray(usable));
213
+ if (usable > 0)
214
+ this.push(buf.subarray(0, usable).toString('base64'));
215
+ cb();
216
+ }
217
+ _flush(cb) {
218
+ if (this.leftover.length)
219
+ this.push(this.leftover.toString('base64'));
220
+ cb();
221
+ }
222
+ }
223
+ /**
224
+ * Stream a local file to a remote command's stdin over ssh, base64-encoded on
225
+ * the fly. Async spawn + piping honors backpressure; the previous
226
+ * `spawnSync({ input })` blob deadlocked once the ssh socket buffer filled
227
+ * (~4MB) on large files (the 157MB Windows helper reproduced this reliably),
228
+ * and worse, reported a false success leaving a 0-byte remote file. Rejects on
229
+ * any pipe error so a broken transfer fails loudly instead.
230
+ */
231
+ function streamFileOverSsh(target, remoteCmd, filePath, timeoutMs = 600_000) {
232
+ return new Promise((resolve, reject) => {
233
+ const child = spawn('ssh', [...SSH_OPTS, target, remoteCmd], {
234
+ stdio: ['pipe', 'pipe', 'pipe'],
235
+ });
236
+ let stderr = '';
237
+ let stdout = '';
238
+ child.stderr.on('data', (d) => (stderr += d.toString()));
239
+ child.stdout.on('data', (d) => (stdout += d.toString()));
240
+ const timer = setTimeout(() => {
241
+ child.kill('SIGKILL');
242
+ reject(new Error(`ssh push to ${target} timed out after ${timeoutMs}ms`));
243
+ }, timeoutMs);
244
+ const fail = (e) => {
245
+ clearTimeout(timer);
246
+ child.kill('SIGKILL');
247
+ reject(e);
248
+ };
249
+ child.on('error', fail);
250
+ child.stdin.on('error', fail); // EPIPE if the remote decoder dies mid-stream
251
+ child.on('close', (code) => {
252
+ clearTimeout(timer);
253
+ resolve({ code, stderr: stderr || stdout });
254
+ });
255
+ const src = fs.createReadStream(filePath);
256
+ src.on('error', fail);
257
+ // disk -> aligned base64 -> ssh stdin; .pipe() applies backpressure
258
+ src.pipe(new Base64Encode()).pipe(child.stdin);
259
+ });
260
+ }
202
261
  export async function setupRemoteHelper(name) {
203
262
  const { target } = await resolveRemoteDevice(name);
204
263
  const exe = resolveWinHelperExe();
205
264
  if (!exe) {
206
265
  throw new Error(`Windows helper exe not built. Run: bash scripts/build-win.sh`);
207
266
  }
208
- // Push: base64 the exe locally, stream it over ssh stdin to the decoder.
209
- const b64 = fs.readFileSync(exe).toString('base64');
210
- const push = sshExec(target, encodePowerShell(buildPushScript()), {
211
- input: b64,
212
- timeoutMs: 600_000, // ~156MB over the wire — allow up to 10 minutes
213
- });
267
+ // Push: stream the exe from disk, base64-encoded on the fly, to the remote
268
+ // decoder. Streaming (vs a single spawnSync `input` blob) honors ssh socket
269
+ // backpressure the blob path deadlocks once the socket buffer fills (~4MB).
270
+ const push = await streamFileOverSsh(target, encodePowerShell(buildPushScript()), exe);
214
271
  if (push.code !== 0) {
215
- throw new Error(`pushing helper exe to '${name}' failed (exit ${push.code ?? 'null'}): ${push.stderr.trim() || push.stdout.trim()}`);
272
+ throw new Error(`pushing helper exe to '${name}' failed (exit ${push.code ?? 'null'}): ${push.stderr.trim()}`);
216
273
  }
217
274
  // Register + start the LOGON task.
218
275
  const reg = sshExec(target, encodePowerShell(buildRegisterTaskScript(REMOTE_HELPER_PORT, REMOTE_TASK_NAME)), {
@@ -158,6 +158,8 @@ export declare function getTeamsRegistryPath(): string;
158
158
  export declare function getDevicesRegistryPath(): string;
159
159
  /** Path to the device ignore-list — tailscale node names the user dismissed, so auto-discovery never re-suggests them. Per-machine, same dir as the registry. */
160
160
  export declare function getDevicesIgnoredPath(): string;
161
+ /** Dir of "pending device" sentinels (~/.agents/.cache/state/devices-pending/) — one empty-ish file per newly-discovered, not-yet-approved tailnet node. Written by the daemon probe, read by the menu-bar helper (mirrors the attention sentinel dir). */
162
+ export declare function getDevicesPendingDir(): string;
161
163
  /** Path to cloud dispatch cache (~/.agents/.cache/cloud/). */
162
164
  export declare function getCloudDir(): string;
163
165
  /** Path to terminal session metadata (~/.agents/.cache/terminals/). */
package/dist/lib/state.js CHANGED
@@ -354,6 +354,8 @@ export function getTeamsRegistryPath() { return path.join(HISTORY_DIR, 'teams',
354
354
  export function getDevicesRegistryPath() { return path.join(HISTORY_DIR, 'devices', 'registry.json'); }
355
355
  /** Path to the device ignore-list — tailscale node names the user dismissed, so auto-discovery never re-suggests them. Per-machine, same dir as the registry. */
356
356
  export function getDevicesIgnoredPath() { return path.join(HISTORY_DIR, 'devices', 'ignored.json'); }
357
+ /** Dir of "pending device" sentinels (~/.agents/.cache/state/devices-pending/) — one empty-ish file per newly-discovered, not-yet-approved tailnet node. Written by the daemon probe, read by the menu-bar helper (mirrors the attention sentinel dir). */
358
+ export function getDevicesPendingDir() { return path.join(RUNTIME_STATE_DIR, 'devices-pending'); }
357
359
  /** Path to cloud dispatch cache (~/.agents/.cache/cloud/). */
358
360
  export function getCloudDir() { return CLOUD_DIR; }
359
361
  /** Path to terminal session metadata (~/.agents/.cache/terminals/). */
@@ -120,15 +120,19 @@ export async function runUmbrellaSync(args) {
120
120
  const { refresh } = await import('./refresh.js');
121
121
  await refresh({ skipPrompts: yes });
122
122
  result.reconciled = true;
123
- // Keep the local device registry current with the tailnet. Soft: a machine
124
- // without tailscale is a clean no-op, never a sync failure. This is the
125
- // wiring that fixes the "registry stays empty until you remember to run
126
- // `agents devices sync`" gap the SessionStart autosync now populates it.
123
+ // Keep already-registered devices' reachability current, and surface newly
124
+ // appeared tailnet nodes as "pending" for the menu-bar Register/Ignore gate
125
+ // rather than silently adding them (refresh mode). Soft: a machine without
126
+ // tailscale is a clean no-op, never a sync failure. First-run population is
127
+ // `agents setup` / manual `agents devices sync` (bootstrap).
127
128
  const { runDeviceSync } = await import('./devices/sync.js');
128
- const dev = await runDeviceSync({ soft: true });
129
+ const { reconcilePendingSentinels } = await import('./devices/pending.js');
130
+ const dev = await runDeviceSync({ soft: true, mode: 'refresh' });
131
+ if (dev.ok)
132
+ reconcilePendingSentinels(dev.pending);
129
133
  result.devices = { synced: dev.synced, pending: dev.pending.length, skipped: !dev.ok };
130
134
  if (dev.ok) {
131
- log(`devices: ${dev.synced} synced${dev.pending.length ? `, ${dev.pending.length} new` : ''}`);
135
+ log(`devices: ${dev.synced} refreshed${dev.pending.length ? `, ${dev.pending.length} new pending` : ''}`);
132
136
  }
133
137
  }
134
138
  return result;
@@ -1,4 +1,5 @@
1
1
  import type { AgentId } from './types.js';
2
+ import { compareVersions } from './agent-spec/primitives.js';
2
3
  /**
3
4
  * Resource selection for syncing to a version.
4
5
  * Each field can be:
@@ -197,6 +198,17 @@ export declare function installVersion(agent: AgentId, version: string, onProgre
197
198
  * when nothing was resolved or no stale dir is present, so it is safe to call
198
199
  * on every script-based install. Returns the action taken (for tests/logging).
199
200
  */
201
+ /**
202
+ * Proactively fold a stale `latest` version-home into its concrete version,
203
+ * WITHOUT needing a fresh install (RUSH-1320). `reconcileStaleLatestDir` only
204
+ * fires at install time, so a `latest` dir left by an old probe-failed install
205
+ * lingers in `agents view` indefinitely. This resolves the live CLI version and
206
+ * reconciles — cheap no-op when there's no `latest` dir (the common case, so
207
+ * `agents view` pays a `--version` shell-out only the once, until it's folded).
208
+ * Skipped when the active config symlink still points at `latest`, since
209
+ * renaming that dir would dangle the live symlink.
210
+ */
211
+ export declare function reconcileStaleLatestForAgent(agent: AgentId): Promise<void>;
200
212
  export declare function reconcileStaleLatestDir(agent: AgentId, installedVersion: string): Promise<'none' | 'renamed' | 'trashed'>;
201
213
  /**
202
214
  * Soft-delete a version directory by moving it to ~/.agents/.system/trash/versions/.
@@ -268,10 +280,7 @@ export declare function resolveVersionAliasLoose(agent: AgentId, raw: string | u
268
280
  * Get version specified in a project-root agents.yaml (not the user ~/.agents/.system/agents.yaml).
269
281
  */
270
282
  export declare function getProjectVersion(agent: AgentId, startPath: string): string | null;
271
- /**
272
- * Compare semver versions for sorting.
273
- */
274
- export declare function compareVersions(a: string, b: string): number;
283
+ export { compareVersions };
275
284
  /**
276
285
  * Get actual version from an installed 'latest' directory.
277
286
  */
@@ -24,6 +24,10 @@ import { checkbox, select } from '@inquirer/prompts';
24
24
  import { getVersionsDir, ensureAgentsDir, readMeta, writeMeta, getCommandsDir, getSkillsDir, getHooksDir, getResolvedRulesDir, getUserRulesDir, getVersionResources, ensureVersionResourcePatterns, getProjectAgentsDir, getPromptcutsPath, getUserPromptcutsPath, getEnabledExtraRepos, getAgentsDir, getUserAgentsDir, getTrashVersionsDir, getActiveRulesPreset, getHomeDir } from './state.js';
25
25
  import { defaultPatterns, expandPatterns } from './resource-patterns.js';
26
26
  import { listResources } from './resources.js';
27
+ // VERSION_RE + compareVersions are owned by the agent-spec engine primitives
28
+ // (single source of truth). Re-exported below so existing importers of
29
+ // `compareVersions` from './versions.js' keep working.
30
+ import { VERSION_RE, compareVersions } from './agent-spec/primitives.js';
27
31
  import { AGENTS, agentConfigDirName, getAccountEmail, resolveAgentName, formatAgentError, findInPath } from './agents.js';
28
32
  import { discoverPermissionGroups, getActivePermissionPresetName, readPermissionPresetRecipe, PERMISSION_PRESET_ENV_VAR } from './permissions.js';
29
33
  import { parseMcpServerConfig } from './mcp.js';
@@ -41,11 +45,6 @@ import { getWriter, getDetector } from './staleness/registry.js';
41
45
  const execAsync = promisify(exec);
42
46
  const execFileAsync = promisify(execFile);
43
47
  const RULES_DOC_FILENAME = 'README.md';
44
- // Strict shape for an agent version string. Anything outside this is rejected
45
- // at parse time so it can't reach an exec/shell boundary or get interpolated
46
- // into a generated bash alias. Must allow "latest" plus npm-dist-tag /
47
- // semver-shaped values (digits, dots, dashes, +, _).
48
- const VERSION_RE = /^(?:latest|(?!.*\.\.)[A-Za-z0-9._+-]{1,64})$/;
49
48
  function getResourceBases(cwd) {
50
49
  const projectAgentsDir = getProjectAgentsDir(cwd);
51
50
  const userBase = getUserAgentsDir();
@@ -1209,6 +1208,26 @@ function removeInstallArtifacts(versionDir) {
1209
1208
  * when nothing was resolved or no stale dir is present, so it is safe to call
1210
1209
  * on every script-based install. Returns the action taken (for tests/logging).
1211
1210
  */
1211
+ /**
1212
+ * Proactively fold a stale `latest` version-home into its concrete version,
1213
+ * WITHOUT needing a fresh install (RUSH-1320). `reconcileStaleLatestDir` only
1214
+ * fires at install time, so a `latest` dir left by an old probe-failed install
1215
+ * lingers in `agents view` indefinitely. This resolves the live CLI version and
1216
+ * reconciles — cheap no-op when there's no `latest` dir (the common case, so
1217
+ * `agents view` pays a `--version` shell-out only the once, until it's folded).
1218
+ * Skipped when the active config symlink still points at `latest`, since
1219
+ * renaming that dir would dangle the live symlink.
1220
+ */
1221
+ export async function reconcileStaleLatestForAgent(agent) {
1222
+ if (!fs.existsSync(getVersionDir(agent, 'latest')))
1223
+ return;
1224
+ if (getConfigSymlinkVersion(agent) === 'latest')
1225
+ return;
1226
+ const concrete = await getCliVersionFromPath(agent);
1227
+ if (concrete && concrete !== 'latest') {
1228
+ await reconcileStaleLatestDir(agent, concrete);
1229
+ }
1230
+ }
1212
1231
  export async function reconcileStaleLatestDir(agent, installedVersion) {
1213
1232
  if (installedVersion === 'latest')
1214
1233
  return 'none';
@@ -1455,21 +1474,9 @@ export function getProjectVersion(agent, startPath) {
1455
1474
  }
1456
1475
  return null;
1457
1476
  }
1458
- /**
1459
- * Compare semver versions for sorting.
1460
- */
1461
- export function compareVersions(a, b) {
1462
- const aParts = a.split('.').map((n) => parseInt(n, 10) || 0);
1463
- const bParts = b.split('.').map((n) => parseInt(n, 10) || 0);
1464
- for (let i = 0; i < Math.max(aParts.length, bParts.length); i++) {
1465
- const aVal = aParts[i] || 0;
1466
- const bVal = bParts[i] || 0;
1467
- if (aVal !== bVal) {
1468
- return aVal - bVal;
1469
- }
1470
- }
1471
- return 0;
1472
- }
1477
+ // compareVersions is defined in ./agent-spec/primitives.ts and re-exported here
1478
+ // so existing `import { compareVersions } from './versions.js'` sites keep working.
1479
+ export { compareVersions };
1473
1480
  /**
1474
1481
  * Get actual version from an installed 'latest' directory.
1475
1482
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@phnx-labs/agents-cli",
3
- "version": "1.20.31",
3
+ "version": "1.20.33",
4
4
  "description": "One CLI for all your AI coding agents - versions, config, cloud dispatch, sessions, and teams (now with first-class Grok Build CLI support)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -51,6 +51,7 @@
51
51
  "dev": "tsx src/index.ts",
52
52
  "start": "node dist/index.js",
53
53
  "test": "node ./node_modules/vitest/vitest.mjs run",
54
+ "test:remote": "scripts/sandbox.sh 'bun install && bun run build && bun run test'",
54
55
  "test:watch": "node ./node_modules/vitest/vitest.mjs"
55
56
  },
56
57
  "keywords": [
@@ -1,36 +0,0 @@
1
- import type { AgentId } from './types.js';
2
- export interface AgentTarget {
3
- agent: AgentId;
4
- /** Resolved exact version, or null when the agent has no installed versions yet. */
5
- version: string | null;
6
- }
7
- /** Canonical qualifier set, in help/display order. `pinned` ≡ `default`. */
8
- export declare const AGENT_QUALIFIERS: readonly ["latest", "oldest", "pinned", "default", "all"];
9
- export type AgentQualifier = (typeof AGENT_QUALIFIERS)[number];
10
- /** Shared `--help` epilog so every agent-spec command documents the same grammar. */
11
- export declare const AGENT_SPEC_HELP: string;
12
- export declare class AgentSpecError extends Error {
13
- constructor(message: string);
14
- }
15
- export interface ResolveAgentTargetsOptions {
16
- /** Project dir for resolving a bare spec's project pin. Defaults to process.cwd(). */
17
- cwd?: string;
18
- /** Restrict the agents a spec may name (e.g. only mcp-capable). Defaults to all. */
19
- availableAgents?: readonly AgentId[];
20
- }
21
- /**
22
- * Resolve an agent spec (single or comma-list) into concrete installed targets.
23
- * Domain = installed: `@latest`/`@oldest`/`@all` range over installed versions
24
- * (`add`/`install` use a separate available-version path). Throws AgentSpecError
25
- * on bad input — never calls process.exit, so it is safe on the hot path and in
26
- * library contexts.
27
- */
28
- export declare function resolveAgentTargets(spec: string, opts?: ResolveAgentTargetsOptions): AgentTarget[];
29
- /**
30
- * Convenience for single-target commands (`use`, `run`): resolve a spec that
31
- * must name exactly one installed version. Rejects `@all` / multi-target specs.
32
- */
33
- export declare function resolveSingleAgentTarget(spec: string, opts?: ResolveAgentTargetsOptions): {
34
- agent: AgentId;
35
- version: string;
36
- };
@@ -1,157 +0,0 @@
1
- // Centralized agent-spec resolution — one vocabulary, one resolver, reused by
2
- // every subcommand that accepts `<agent>[@<qualifier>]`.
3
- //
4
- // The qualifier vocabulary used to be split across three functions in
5
- // versions.ts (parseAgentSpec, resolveVersionAlias, resolveInstalledAgentTargets)
6
- // with diverging support — `@latest`/`@oldest` in one, `@all`/`@default` in
7
- // another, `@pinned` nowhere. This module is the single source of truth.
8
- //
9
- // Built for the hot path (`--launch`, ~100ms budget): the common specs resolve
10
- // with NO directory enumeration —
11
- // exact `claude@2.1.181` → one isVersionInstalled() (existsSync)
12
- // `claude@pinned|@default` → memoized getGlobalDefault() + existsSync
13
- // bare `claude` → resolveVersion() (memoized meta), no readdir
14
- // Only the relative qualifiers `@latest`/`@oldest`/`@all` enumerate, and even
15
- // then via the mtime-cached listInstalledVersions().
16
- import { AGENTS, ALL_AGENT_IDS, resolveAgentName, formatAgentError } from './agents.js';
17
- import { listInstalledVersions, getGlobalDefault, isVersionInstalled, resolveVersion, } from './versions.js';
18
- /** Canonical qualifier set, in help/display order. `pinned` ≡ `default`. */
19
- export const AGENT_QUALIFIERS = ['latest', 'oldest', 'pinned', 'default', 'all'];
20
- /** Shared `--help` epilog so every agent-spec command documents the same grammar. */
21
- export const AGENT_SPEC_HELP = 'Agent spec: <agent>[@<qualifier>]. Qualifiers: ' +
22
- '@latest (highest installed), @oldest (lowest installed), ' +
23
- '@pinned / @default (your configured default — synonyms), ' +
24
- '@all (every installed version), or an exact @x.y.z. ' +
25
- 'Bare <agent> uses the resolved default (project pin → global default). ' +
26
- 'Comma-separate to combine: claude@all,codex@latest.';
27
- export class AgentSpecError extends Error {
28
- constructor(message) {
29
- super(message);
30
- this.name = 'AgentSpecError';
31
- }
32
- }
33
- /**
34
- * Resolve an agent spec (single or comma-list) into concrete installed targets.
35
- * Domain = installed: `@latest`/`@oldest`/`@all` range over installed versions
36
- * (`add`/`install` use a separate available-version path). Throws AgentSpecError
37
- * on bad input — never calls process.exit, so it is safe on the hot path and in
38
- * library contexts.
39
- */
40
- export function resolveAgentTargets(spec, opts = {}) {
41
- const cwd = opts.cwd ?? process.cwd();
42
- const available = opts.availableAgents ?? ALL_AGENT_IDS;
43
- const rawEntries = spec
44
- .split(',')
45
- .map((s) => s.trim())
46
- .filter(Boolean);
47
- if (rawEntries.length === 0) {
48
- throw new AgentSpecError('Empty agent spec.');
49
- }
50
- // Expand the bare literal `all` (or `all@all`) into every available agent that
51
- // has at least one installed version. Lenient: agents with nothing installed
52
- // are skipped rather than erroring.
53
- const entries = [];
54
- for (const e of rawEntries) {
55
- if (e === 'all' || e === 'all@all') {
56
- for (const a of available) {
57
- if (listInstalledVersions(a).length > 0)
58
- entries.push(`${a}@all`);
59
- }
60
- }
61
- else {
62
- entries.push(e);
63
- }
64
- }
65
- const out = [];
66
- const seen = new Set();
67
- const push = (agent, version) => {
68
- const key = `${agent}@${version ?? ''}`;
69
- if (!seen.has(key)) {
70
- seen.add(key);
71
- out.push({ agent, version });
72
- }
73
- };
74
- for (const entry of entries) {
75
- const at = entry.indexOf('@');
76
- const agentToken = (at === -1 ? entry : entry.slice(0, at)).trim();
77
- const qualifier = at === -1 ? null : entry.slice(at + 1).trim();
78
- if (!agentToken)
79
- continue;
80
- if (at !== -1 && !qualifier) {
81
- throw new AgentSpecError(`Missing version in '${entry}'. Use ${agentToken}@x.y.z, @latest, @oldest, @pinned, @default, or @all.`);
82
- }
83
- const agent = resolveAgentName(agentToken);
84
- if (!agent || !available.includes(agent)) {
85
- throw new AgentSpecError(formatAgentError(agentToken, [...available]));
86
- }
87
- const name = AGENTS[agent].name;
88
- // ----- bare: resolved default, NO enumeration in the common case -----
89
- if (qualifier === null) {
90
- const resolved = resolveVersion(agent, cwd); // project pin → global default (meta-only)
91
- if (resolved) {
92
- push(agent, resolved);
93
- }
94
- else {
95
- const installed = listInstalledVersions(agent);
96
- if (installed.length === 0)
97
- push(agent, null);
98
- else if (installed.length === 1)
99
- push(agent, installed[0]);
100
- else
101
- throw new AgentSpecError(`No default version set for ${name}. Specify one (${agent}@<version>) or set it: agents use ${agent}@<version>.`);
102
- }
103
- continue;
104
- }
105
- // ----- @pinned / @default: synonyms, meta-only fast path -----
106
- if (qualifier === 'pinned' || qualifier === 'default') {
107
- const def = getGlobalDefault(agent);
108
- if (!def) {
109
- throw new AgentSpecError(`No default version set for ${name}. Run: agents use ${agent}@<version>`);
110
- }
111
- push(agent, def);
112
- continue;
113
- }
114
- // ----- @all: every installed version -----
115
- if (qualifier === 'all') {
116
- const installed = listInstalledVersions(agent);
117
- if (installed.length === 0) {
118
- throw new AgentSpecError(`No managed versions are installed for ${name}. Run: agents add ${agent}@latest`);
119
- }
120
- for (const v of installed)
121
- push(agent, v);
122
- continue;
123
- }
124
- // ----- @latest / @oldest: enumerate (mtime-cached), pick an end -----
125
- if (qualifier === 'latest' || qualifier === 'oldest') {
126
- const installed = listInstalledVersions(agent); // already sorted ascending
127
- if (installed.length === 0) {
128
- throw new AgentSpecError(`No managed versions are installed for ${name}. Run: agents add ${agent}@latest`);
129
- }
130
- push(agent, qualifier === 'oldest' ? installed[0] : installed[installed.length - 1]);
131
- continue;
132
- }
133
- // ----- exact version: one existsSync, NO enumeration -----
134
- if (!isVersionInstalled(agent, qualifier)) {
135
- const installed = listInstalledVersions(agent);
136
- const hint = installed.length ? ` Installed: ${installed.join(', ')}.` : '';
137
- throw new AgentSpecError(`${name}@${qualifier} is not installed.${hint} Install it: agents add ${agent}@${qualifier}`);
138
- }
139
- push(agent, qualifier);
140
- }
141
- return out;
142
- }
143
- /**
144
- * Convenience for single-target commands (`use`, `run`): resolve a spec that
145
- * must name exactly one installed version. Rejects `@all` / multi-target specs.
146
- */
147
- export function resolveSingleAgentTarget(spec, opts = {}) {
148
- const targets = resolveAgentTargets(spec, opts);
149
- if (targets.length !== 1) {
150
- throw new AgentSpecError(`'${spec}' resolves to ${targets.length} targets; this command needs exactly one.`);
151
- }
152
- const t = targets[0];
153
- if (t.version === null) {
154
- throw new AgentSpecError(`No installed version for ${AGENTS[t.agent].name}. Run: agents add ${t.agent}@latest`);
155
- }
156
- return { agent: t.agent, version: t.version };
157
- }