@phnx-labs/agents-cli 1.20.32 → 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 (64) hide show
  1. package/dist/commands/commands.js +3 -3
  2. package/dist/commands/computer-actions.js +1 -0
  3. package/dist/commands/cost.js +2 -2
  4. package/dist/commands/doctor.js +2 -2
  5. package/dist/commands/exec.js +56 -1
  6. package/dist/commands/hooks.js +3 -3
  7. package/dist/commands/inspect.js +13 -17
  8. package/dist/commands/mcp.js +3 -3
  9. package/dist/commands/permissions.js +3 -3
  10. package/dist/commands/rules.js +2 -2
  11. package/dist/commands/sessions.js +18 -1
  12. package/dist/commands/skills.js +3 -3
  13. package/dist/commands/sync.js +2 -2
  14. package/dist/commands/teams.js +7 -12
  15. package/dist/commands/usage.js +2 -2
  16. package/dist/commands/utils.d.ts +8 -0
  17. package/dist/commands/utils.js +20 -0
  18. package/dist/commands/versions.js +2 -2
  19. package/dist/commands/view.js +23 -9
  20. package/dist/commands/workflows.js +3 -3
  21. package/dist/index.js +12 -0
  22. package/dist/lib/agent-spec/index.d.ts +18 -0
  23. package/dist/lib/agent-spec/index.js +35 -0
  24. package/dist/lib/agent-spec/primitives.d.ts +28 -0
  25. package/dist/lib/agent-spec/primitives.js +57 -0
  26. package/dist/lib/agent-spec/provider.d.ts +2 -0
  27. package/dist/lib/agent-spec/provider.js +9 -0
  28. package/dist/lib/agent-spec/resolve.d.ts +33 -0
  29. package/dist/lib/agent-spec/resolve.js +174 -0
  30. package/dist/lib/agent-spec/types.d.ts +57 -0
  31. package/dist/lib/agent-spec/types.js +18 -0
  32. package/dist/lib/crabbox/cli.d.ts +98 -0
  33. package/dist/lib/crabbox/cli.js +218 -0
  34. package/dist/lib/crabbox/lease.d.ts +41 -0
  35. package/dist/lib/crabbox/lease.js +73 -0
  36. package/dist/lib/crabbox/runtimes.d.ts +57 -0
  37. package/dist/lib/crabbox/runtimes.js +109 -0
  38. package/dist/lib/hosts/dispatch.d.ts +27 -10
  39. package/dist/lib/hosts/dispatch.js +55 -19
  40. package/dist/lib/hosts/option.d.ts +14 -0
  41. package/dist/lib/hosts/option.js +19 -0
  42. package/dist/lib/hosts/passthrough.d.ts +30 -0
  43. package/dist/lib/hosts/passthrough.js +141 -0
  44. package/dist/lib/hosts/remote-cmd.d.ts +36 -0
  45. package/dist/lib/hosts/remote-cmd.js +56 -0
  46. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  47. package/dist/lib/secrets/bundles.js +29 -20
  48. package/dist/lib/secrets/index.d.ts +11 -0
  49. package/dist/lib/secrets/index.js +18 -1
  50. package/dist/lib/secrets/linux.d.ts +14 -0
  51. package/dist/lib/secrets/linux.js +21 -0
  52. package/dist/lib/session/active.d.ts +8 -0
  53. package/dist/lib/session/active.js +18 -1
  54. package/dist/lib/session/provenance.d.ts +56 -0
  55. package/dist/lib/session/provenance.js +157 -0
  56. package/dist/lib/ssh-exec.d.ts +22 -0
  57. package/dist/lib/ssh-exec.js +59 -2
  58. package/dist/lib/ssh-tunnel.d.ts +0 -5
  59. package/dist/lib/ssh-tunnel.js +65 -8
  60. package/dist/lib/versions.d.ts +2 -4
  61. package/dist/lib/versions.js +7 -20
  62. package/package.json +2 -1
  63. package/dist/lib/agent-spec.d.ts +0 -36
  64. package/dist/lib/agent-spec.js +0 -157
@@ -0,0 +1,157 @@
1
+ /**
2
+ * Session provenance — where an active agent process actually lives.
3
+ *
4
+ * `detectHost()` in active.ts walks the ppid chain to name the *terminal app*
5
+ * (iterm / code / tmux). That answers "what UI is above it" but not the three
6
+ * things the Agent Feed needs to group and route:
7
+ *
8
+ * 1. Which machine — os.hostname(), for the HOSTS sidebar.
9
+ * 2. Local vs SSH — is SSH_CONNECTION in the process env?
10
+ * 3. Exact tmux pane — TMUX_PANE ('%3'), the send-keys target.
11
+ *
12
+ * All three are inherited env vars, so we read them straight off the running
13
+ * process (no cooperation from the agent needed): `/proc/<pid>/environ` on
14
+ * Linux, `ps eww` on macOS. The read is best-effort — a process we can't stat
15
+ * (gone, or owned by another uid) yields `undefined`, never a guess.
16
+ *
17
+ * `reply` is a read-only hint, not a send channel: it reports whether a rail
18
+ * that can type back into this session exists today (tmux pane => addressable;
19
+ * inherited/ignored stdin => null). The feed uses it to decide whether to show
20
+ * a Send box. Actually delivering the keystrokes is Gap 2 (pty/tmux send-keys).
21
+ */
22
+ import * as os from 'os';
23
+ import { execFile } from 'child_process';
24
+ import { promisify } from 'util';
25
+ import { readFile } from 'fs/promises';
26
+ const execFileAsync = promisify(execFile);
27
+ /** Env vars that carry provenance. Kept small so the macOS `ps` scan stays cheap. */
28
+ export const PROVENANCE_ENV_KEYS = [
29
+ 'SSH_CONNECTION',
30
+ 'SSH_TTY',
31
+ 'TMUX',
32
+ 'TMUX_PANE',
33
+ 'TERM_PROGRAM',
34
+ 'STY',
35
+ ];
36
+ /** Parse the NUL-separated body of /proc/<pid>/environ into a plain object. */
37
+ export function parseProcEnviron(buf) {
38
+ const env = {};
39
+ for (const pair of buf.split('\0')) {
40
+ if (!pair)
41
+ continue;
42
+ const eq = pair.indexOf('=');
43
+ if (eq <= 0)
44
+ continue;
45
+ env[pair.slice(0, eq)] = pair.slice(eq + 1);
46
+ }
47
+ return env;
48
+ }
49
+ /**
50
+ * How many whitespace-separated tokens each key's value spans. macOS
51
+ * `ps eww -o command=` space-joins the env after the command, so a value that
52
+ * itself contains spaces (SSH_CONNECTION is four fields) can't be recovered by
53
+ * boundary-guessing when the next token is an *unknown* var. Every provenance
54
+ * key except SSH_CONNECTION is a single token, so we read exactly its arity.
55
+ */
56
+ const ENV_VALUE_TOKENS = { SSH_CONNECTION: 4 };
57
+ /**
58
+ * Pull known env vars out of a macOS `ps eww` command+env line. For each
59
+ * `KEY=` match we consume the declared number of tokens (default 1), so
60
+ * SSH_CONNECTION's internal spaces survive while a following unknown var
61
+ * (e.g. `PWD=…`) is not swallowed into the previous value.
62
+ */
63
+ export function extractKnownEnv(text, keys) {
64
+ const alt = keys.map((k) => k.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|');
65
+ const boundary = new RegExp(`(?:^|\\s)(${alt})=`, 'g');
66
+ const env = {};
67
+ let m;
68
+ while ((m = boundary.exec(text)) !== null) {
69
+ const key = m[1];
70
+ const rest = text.slice(m.index + m[0].length);
71
+ const tokens = rest.split(/\s+/);
72
+ const want = ENV_VALUE_TOKENS[key] ?? 1;
73
+ env[key] = tokens.slice(0, want).join(' ');
74
+ }
75
+ return env;
76
+ }
77
+ /** `<client_ip> <client_port> <server_ip> <server_port>` → structured origin. */
78
+ export function parseSshConnection(value) {
79
+ const parts = value.trim().split(/\s+/);
80
+ if (parts.length < 4)
81
+ return undefined;
82
+ const clientPort = parseInt(parts[1], 10);
83
+ const serverPort = parseInt(parts[3], 10);
84
+ if (!Number.isFinite(clientPort) || !Number.isFinite(serverPort))
85
+ return undefined;
86
+ return { clientIp: parts[0], clientPort, serverIp: parts[2], serverPort };
87
+ }
88
+ /** Build a SessionProvenance from a raw env map + the local hostname. Pure. */
89
+ export function deriveProvenance(env, hostname) {
90
+ const ssh = env.SSH_CONNECTION ? parseSshConnection(env.SSH_CONNECTION) : undefined;
91
+ let mux;
92
+ if (env.TMUX) {
93
+ mux = {
94
+ kind: 'tmux',
95
+ socket: env.TMUX.split(',')[0] || undefined,
96
+ pane: env.TMUX_PANE || undefined,
97
+ };
98
+ }
99
+ else if (env.STY) {
100
+ mux = { kind: 'screen', session: env.STY };
101
+ }
102
+ // A tmux pane is the one rail that lets an external process type into an
103
+ // already-running interactive agent (`tmux send-keys -t <pane>`). Everything
104
+ // else (inherited stdin from `agents run`, ignored stdin from teams) is not
105
+ // externally addressable without relaunching under a pty/tmux rail.
106
+ const reply = mux?.kind === 'tmux' && mux.pane
107
+ ? { rail: 'tmux', target: mux.pane, socket: mux.socket }
108
+ : null;
109
+ return {
110
+ host: hostname,
111
+ transport: ssh ? 'ssh' : 'local',
112
+ ssh,
113
+ term: env.TERM_PROGRAM || undefined,
114
+ mux,
115
+ reply,
116
+ };
117
+ }
118
+ /** Read a process's environment. Linux: /proc. macOS: `ps eww`. Best-effort. */
119
+ async function readProcEnv(pid) {
120
+ if (process.platform === 'linux') {
121
+ try {
122
+ const buf = await readFile(`/proc/${pid}/environ`, 'utf8');
123
+ return parseProcEnviron(buf);
124
+ }
125
+ catch {
126
+ return undefined;
127
+ }
128
+ }
129
+ if (process.platform === 'darwin') {
130
+ try {
131
+ const { stdout } = await execFileAsync('ps', ['eww', '-p', String(pid), '-o', 'command='], {
132
+ encoding: 'utf8',
133
+ maxBuffer: 1024 * 1024,
134
+ });
135
+ if (!stdout.trim())
136
+ return undefined;
137
+ return extractKnownEnv(stdout, PROVENANCE_ENV_KEYS);
138
+ }
139
+ catch {
140
+ return undefined;
141
+ }
142
+ }
143
+ return undefined;
144
+ }
145
+ /**
146
+ * Resolve provenance for a live pid. Returns undefined when the process env
147
+ * can't be read (process gone, foreign uid, unsupported platform) — we never
148
+ * fabricate a 'local' answer we can't back with the env.
149
+ */
150
+ export async function detectProvenance(pid) {
151
+ if (!pid || pid < 1)
152
+ return undefined;
153
+ const env = await readProcEnv(pid);
154
+ if (!env)
155
+ return undefined;
156
+ return deriveProvenance(env, os.hostname());
157
+ }
@@ -19,6 +19,7 @@ export declare function assertValidSshTarget(host: string): void;
19
19
  export declare function shellQuote(s: string): string;
20
20
  /** Hardened ssh options applied to every connection. */
21
21
  export declare const SSH_OPTS: readonly string[];
22
+ export declare function controlOpts(): string[];
22
23
  export interface SshExecOptions {
23
24
  /** Piped to the remote command's stdin (never interpolated into the shell). */
24
25
  input?: string;
@@ -26,6 +27,8 @@ export interface SshExecOptions {
26
27
  timeoutMs?: number;
27
28
  /** Extra ssh flags inserted before the target (e.g. `-tt`). */
28
29
  extraSshArgs?: string[];
30
+ /** Reuse a persistent control socket across calls (see `controlOpts`). */
31
+ multiplex?: boolean;
29
32
  }
30
33
  export interface SshExecResult {
31
34
  /** Remote exit status, or null if ssh itself failed / timed out. */
@@ -43,3 +46,22 @@ export interface SshExecResult {
43
46
  export declare function sshExec(target: string, remoteCmd: string, opts?: SshExecOptions): SshExecResult;
44
47
  /** True if `target` is reachable over ssh (a passwordless `true` succeeds quickly). */
45
48
  export declare function sshReachable(target: string, timeoutMs?: number): boolean;
49
+ export interface SshStreamOptions {
50
+ /**
51
+ * Allocate a remote pseudo-terminal (`ssh -tt`) so an interactive remote
52
+ * command (a picker, a prompt) renders live on the local terminal. Callers
53
+ * pass this when the *local* process is itself a TTY; piped/scripted callers
54
+ * leave it off and forward a non-interactive invocation instead.
55
+ */
56
+ tty?: boolean;
57
+ /** Reuse a persistent control socket across calls (see `controlOpts`). */
58
+ multiplex?: boolean;
59
+ }
60
+ /**
61
+ * Foreground counterpart to `sshExec`: run `remoteCmd` on `target` with the
62
+ * local stdio wired straight through (`stdio: 'inherit'`), so output streams as
63
+ * it is produced and — with `tty` — keystrokes reach a remote picker. Blocks
64
+ * until the remote command exits and returns its exit code (255 is ssh's own
65
+ * connection-layer failure; any other non-zero is the remote command's code).
66
+ */
67
+ export declare function sshStream(target: string, remoteCmd: string, opts?: SshStreamOptions): number;
@@ -8,6 +8,9 @@
8
8
  * canonical definition; `commands/secrets.ts` re-exports it.
9
9
  */
10
10
  import { spawnSync } from 'child_process';
11
+ import * as fs from 'fs';
12
+ import * as path from 'path';
13
+ import { getCacheDir } from './state.js';
11
14
  /**
12
15
  * SSH target: a bare ssh-config host alias (e.g. `yosemite-s0`) or `user@host`.
13
16
  * The strict allowlist blocks shell metacharacters so a target can't be
@@ -32,6 +35,42 @@ export const SSH_OPTS = [
32
35
  '-o', 'BatchMode=yes',
33
36
  '-o', 'ConnectTimeout=10',
34
37
  ];
38
+ /**
39
+ * OpenSSH connection-multiplexing options. The first connection to a host opens
40
+ * a control socket; subsequent connections (even from a *separate* `agents`
41
+ * invocation) reuse it, skipping the TCP+auth handshake — so repeated
42
+ * `--host <name>` calls to the same box feel local instead of paying ~100-300ms
43
+ * each. `ControlPersist=60s` keeps the master alive briefly after the last
44
+ * client exits. `%C` (a short fixed-length hash of local-host/remote/port/user)
45
+ * keeps the socket path well under macOS's 104-char `sun_path` limit.
46
+ *
47
+ * The socket directory is created lazily; if ssh can't open the control socket
48
+ * it falls back to a normal connection (multiplexing is an optimisation, never a
49
+ * requirement), so this can never make a reachable host unreachable.
50
+ */
51
+ let controlDirEnsured = false;
52
+ export function controlOpts() {
53
+ // OpenSSH on Windows has no ControlMaster/ControlPath (unix-socket) support —
54
+ // passing those options makes ssh error out. Multiplexing is a pure latency
55
+ // optimisation, so on Windows we simply skip it and use a fresh connection.
56
+ if (process.platform === 'win32')
57
+ return [];
58
+ const dir = path.join(getCacheDir(), 'ssh');
59
+ if (!controlDirEnsured) {
60
+ try {
61
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
62
+ }
63
+ catch {
64
+ /* best-effort — ssh degrades to a fresh connection if the dir is missing */
65
+ }
66
+ controlDirEnsured = true;
67
+ }
68
+ return [
69
+ '-o', 'ControlMaster=auto',
70
+ '-o', `ControlPath=${path.join(dir, 'cm-%C')}`,
71
+ '-o', 'ControlPersist=60s',
72
+ ];
73
+ }
35
74
  /**
36
75
  * Run `remoteCmd` on `target` over ssh and capture stdout/stderr/exit.
37
76
  *
@@ -40,7 +79,8 @@ export const SSH_OPTS = [
40
79
  */
41
80
  export function sshExec(target, remoteCmd, opts = {}) {
42
81
  assertValidSshTarget(target);
43
- const args = [...SSH_OPTS, ...(opts.extraSshArgs ?? []), target, remoteCmd];
82
+ const mux = opts.multiplex ? controlOpts() : [];
83
+ const args = [...SSH_OPTS, ...mux, ...(opts.extraSshArgs ?? []), target, remoteCmd];
44
84
  const res = spawnSync('ssh', args, {
45
85
  input: opts.input,
46
86
  encoding: 'utf-8',
@@ -57,5 +97,22 @@ export function sshExec(target, remoteCmd, opts = {}) {
57
97
  }
58
98
  /** True if `target` is reachable over ssh (a passwordless `true` succeeds quickly). */
59
99
  export function sshReachable(target, timeoutMs = 10000) {
60
- return sshExec(target, 'true', { timeoutMs }).code === 0;
100
+ return sshExec(target, 'true', { timeoutMs, multiplex: true }).code === 0;
101
+ }
102
+ /**
103
+ * Foreground counterpart to `sshExec`: run `remoteCmd` on `target` with the
104
+ * local stdio wired straight through (`stdio: 'inherit'`), so output streams as
105
+ * it is produced and — with `tty` — keystrokes reach a remote picker. Blocks
106
+ * until the remote command exits and returns its exit code (255 is ssh's own
107
+ * connection-layer failure; any other non-zero is the remote command's code).
108
+ */
109
+ export function sshStream(target, remoteCmd, opts = {}) {
110
+ assertValidSshTarget(target);
111
+ const mux = opts.multiplex ? controlOpts() : [];
112
+ const tty = opts.tty ? ['-tt'] : [];
113
+ const args = [...SSH_OPTS, ...mux, ...tty, target, remoteCmd];
114
+ const res = spawnSync('ssh', args, { stdio: 'inherit' });
115
+ if (typeof res.status === 'number')
116
+ return res.status;
117
+ return 255; // spawn error / signal — treat as a connection-layer failure
61
118
  }
@@ -90,11 +90,6 @@ export declare function buildPushScript(): string;
90
90
  export declare function buildRegisterTaskScript(port: number, taskName: string): string;
91
91
  /** PowerShell that unregisters the task and stops any running daemon process. */
92
92
  export declare function buildUnregisterTaskScript(taskName: string): string;
93
- /**
94
- * `setup --host`: push the exe, then register + start the LOGON task. Both hops
95
- * go through `sshExec` (BatchMode key auth — the same hardening the browser
96
- * driver and `agents ssh` use). Throws with the remote stderr on any failure.
97
- */
98
93
  export declare function setupRemoteHelper(name: string): Promise<{
99
94
  target: string;
100
95
  taskName: string;
@@ -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)), {
@@ -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:
@@ -279,10 +280,7 @@ export declare function resolveVersionAliasLoose(agent: AgentId, raw: string | u
279
280
  * Get version specified in a project-root agents.yaml (not the user ~/.agents/.system/agents.yaml).
280
281
  */
281
282
  export declare function getProjectVersion(agent: AgentId, startPath: string): string | null;
282
- /**
283
- * Compare semver versions for sorting.
284
- */
285
- export declare function compareVersions(a: string, b: string): number;
283
+ export { compareVersions };
286
284
  /**
287
285
  * Get actual version from an installed 'latest' directory.
288
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();
@@ -1475,21 +1474,9 @@ export function getProjectVersion(agent, startPath) {
1475
1474
  }
1476
1475
  return null;
1477
1476
  }
1478
- /**
1479
- * Compare semver versions for sorting.
1480
- */
1481
- export function compareVersions(a, b) {
1482
- const aParts = a.split('.').map((n) => parseInt(n, 10) || 0);
1483
- const bParts = b.split('.').map((n) => parseInt(n, 10) || 0);
1484
- for (let i = 0; i < Math.max(aParts.length, bParts.length); i++) {
1485
- const aVal = aParts[i] || 0;
1486
- const bVal = bParts[i] || 0;
1487
- if (aVal !== bVal) {
1488
- return aVal - bVal;
1489
- }
1490
- }
1491
- return 0;
1492
- }
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 };
1493
1480
  /**
1494
1481
  * Get actual version from an installed 'latest' directory.
1495
1482
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@phnx-labs/agents-cli",
3
- "version": "1.20.32",
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
- }