@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.
- package/dist/commands/commands.js +3 -3
- package/dist/commands/computer-actions.js +1 -0
- package/dist/commands/cost.js +2 -2
- package/dist/commands/doctor.js +2 -2
- package/dist/commands/exec.js +56 -1
- package/dist/commands/hooks.js +3 -3
- package/dist/commands/inspect.js +13 -17
- package/dist/commands/mcp.js +3 -3
- package/dist/commands/permissions.js +3 -3
- package/dist/commands/rules.js +2 -2
- package/dist/commands/sessions.js +18 -1
- package/dist/commands/skills.js +3 -3
- package/dist/commands/sync.js +2 -2
- package/dist/commands/teams.js +7 -12
- package/dist/commands/usage.js +2 -2
- package/dist/commands/utils.d.ts +8 -0
- package/dist/commands/utils.js +20 -0
- package/dist/commands/versions.js +2 -2
- package/dist/commands/view.js +23 -9
- package/dist/commands/workflows.js +3 -3
- package/dist/index.js +12 -0
- package/dist/lib/agent-spec/index.d.ts +18 -0
- package/dist/lib/agent-spec/index.js +35 -0
- package/dist/lib/agent-spec/primitives.d.ts +28 -0
- package/dist/lib/agent-spec/primitives.js +57 -0
- package/dist/lib/agent-spec/provider.d.ts +2 -0
- package/dist/lib/agent-spec/provider.js +9 -0
- package/dist/lib/agent-spec/resolve.d.ts +33 -0
- package/dist/lib/agent-spec/resolve.js +174 -0
- package/dist/lib/agent-spec/types.d.ts +57 -0
- package/dist/lib/agent-spec/types.js +18 -0
- package/dist/lib/crabbox/cli.d.ts +98 -0
- package/dist/lib/crabbox/cli.js +218 -0
- package/dist/lib/crabbox/lease.d.ts +41 -0
- package/dist/lib/crabbox/lease.js +73 -0
- package/dist/lib/crabbox/runtimes.d.ts +57 -0
- package/dist/lib/crabbox/runtimes.js +109 -0
- package/dist/lib/hosts/dispatch.d.ts +27 -10
- package/dist/lib/hosts/dispatch.js +55 -19
- package/dist/lib/hosts/option.d.ts +14 -0
- package/dist/lib/hosts/option.js +19 -0
- package/dist/lib/hosts/passthrough.d.ts +30 -0
- package/dist/lib/hosts/passthrough.js +141 -0
- package/dist/lib/hosts/remote-cmd.d.ts +36 -0
- package/dist/lib/hosts/remote-cmd.js +56 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- package/dist/lib/secrets/bundles.js +29 -20
- package/dist/lib/secrets/index.d.ts +11 -0
- package/dist/lib/secrets/index.js +18 -1
- package/dist/lib/secrets/linux.d.ts +14 -0
- package/dist/lib/secrets/linux.js +21 -0
- package/dist/lib/session/active.d.ts +8 -0
- package/dist/lib/session/active.js +18 -1
- package/dist/lib/session/provenance.d.ts +56 -0
- package/dist/lib/session/provenance.js +157 -0
- package/dist/lib/ssh-exec.d.ts +22 -0
- package/dist/lib/ssh-exec.js +59 -2
- package/dist/lib/ssh-tunnel.d.ts +0 -5
- package/dist/lib/ssh-tunnel.js +65 -8
- package/dist/lib/versions.d.ts +2 -4
- package/dist/lib/versions.js +7 -20
- package/package.json +2 -1
- package/dist/lib/agent-spec.d.ts +0 -36
- 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
|
+
}
|
package/dist/lib/ssh-exec.d.ts
CHANGED
|
@@ -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;
|
package/dist/lib/ssh-exec.js
CHANGED
|
@@ -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
|
|
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
|
}
|
package/dist/lib/ssh-tunnel.d.ts
CHANGED
|
@@ -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;
|
package/dist/lib/ssh-tunnel.js
CHANGED
|
@@ -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 {
|
|
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:
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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()
|
|
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)), {
|
package/dist/lib/versions.d.ts
CHANGED
|
@@ -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
|
*/
|
package/dist/lib/versions.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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": [
|
package/dist/lib/agent-spec.d.ts
DELETED
|
@@ -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
|
-
};
|
package/dist/lib/agent-spec.js
DELETED
|
@@ -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
|
-
}
|