@phnx-labs/agents-cli 1.20.26 → 1.20.28
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/CHANGELOG.md +29 -0
- package/dist/commands/doctor.d.ts +5 -2
- package/dist/commands/doctor.js +182 -30
- package/dist/commands/exec.d.ts +1 -1
- package/dist/commands/exec.js +177 -6
- package/dist/commands/hosts.d.ts +11 -0
- package/dist/commands/hosts.js +229 -0
- package/dist/commands/repo.d.ts +29 -0
- package/dist/commands/repo.js +174 -38
- package/dist/commands/secrets.d.ts +2 -7
- package/dist/commands/secrets.js +15 -23
- package/dist/commands/sessions.d.ts +2 -0
- package/dist/commands/sessions.js +27 -25
- package/dist/commands/sync.d.ts +2 -0
- package/dist/commands/sync.js +22 -5
- package/dist/commands/view.js +27 -11
- package/dist/index.js +4 -13
- package/dist/lib/agent-spec.d.ts +36 -0
- package/dist/lib/agent-spec.js +157 -0
- package/dist/lib/agents.d.ts +1 -0
- package/dist/lib/agents.js +45 -4
- package/dist/lib/browser/drivers/ssh.d.ts +47 -2
- package/dist/lib/browser/drivers/ssh.js +113 -24
- package/dist/lib/browser/profiles.js +28 -1
- package/dist/lib/browser/runtime-state.js +28 -8
- package/dist/lib/browser/types.d.ts +10 -1
- package/dist/lib/cli-resources.js +10 -1
- package/dist/lib/daemon.js +32 -0
- package/dist/lib/doctor-diff.d.ts +19 -0
- package/dist/lib/doctor-diff.js +107 -15
- package/dist/lib/exec.d.ts +27 -0
- package/dist/lib/exec.js +62 -19
- package/dist/lib/heal.d.ts +107 -0
- package/dist/lib/heal.js +279 -0
- package/dist/lib/hooks.d.ts +17 -0
- package/dist/lib/hooks.js +127 -3
- package/dist/lib/hosts/dispatch.d.ts +26 -0
- package/dist/lib/hosts/dispatch.js +71 -0
- package/dist/lib/hosts/progress.d.ts +21 -0
- package/dist/lib/hosts/progress.js +49 -0
- package/dist/lib/hosts/providers/local.d.ts +17 -0
- package/dist/lib/hosts/providers/local.js +81 -0
- package/dist/lib/hosts/ready.d.ts +37 -0
- package/dist/lib/hosts/ready.js +88 -0
- package/dist/lib/hosts/registry.d.ts +22 -0
- package/dist/lib/hosts/registry.js +65 -0
- package/dist/lib/hosts/ssh-config.d.ts +37 -0
- package/dist/lib/hosts/ssh-config.js +157 -0
- package/dist/lib/hosts/tasks.d.ts +32 -0
- package/dist/lib/hosts/tasks.js +58 -0
- package/dist/lib/hosts/types.d.ts +51 -0
- package/dist/lib/hosts/types.js +21 -0
- package/dist/lib/loop.d.ts +9 -0
- package/dist/lib/loop.js +13 -1
- package/dist/lib/mcp.js +12 -3
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- package/dist/lib/migrate.js +9 -5
- package/dist/lib/platform/exec.d.ts +10 -0
- package/dist/lib/platform/exec.js +17 -0
- package/dist/lib/platform/index.d.ts +1 -0
- package/dist/lib/platform/index.js +1 -0
- package/dist/lib/platform/links.d.ts +15 -0
- package/dist/lib/platform/links.js +42 -0
- package/dist/lib/platform/paths.d.ts +18 -0
- package/dist/lib/platform/paths.js +22 -0
- package/dist/lib/platform/posixpath.d.ts +28 -0
- package/dist/lib/platform/posixpath.js +153 -0
- package/dist/lib/plugin-marketplace.d.ts +18 -0
- package/dist/lib/plugin-marketplace.js +67 -1
- package/dist/lib/plugins.d.ts +33 -1
- package/dist/lib/plugins.js +56 -11
- package/dist/lib/project-launch.js +6 -3
- package/dist/lib/sandbox.js +5 -2
- package/dist/lib/self-update.js +7 -2
- package/dist/lib/session/db.d.ts +23 -0
- package/dist/lib/session/db.js +76 -1
- package/dist/lib/session/discover.d.ts +26 -0
- package/dist/lib/session/discover.js +75 -4
- package/dist/lib/session/relative-time.d.ts +7 -0
- package/dist/lib/session/relative-time.js +28 -0
- package/dist/lib/session/remote.d.ts +61 -0
- package/dist/lib/session/remote.js +221 -0
- package/dist/lib/ssh-exec.d.ts +45 -0
- package/dist/lib/ssh-exec.js +61 -0
- package/dist/lib/staleness/detectors/commands.js +7 -6
- package/dist/lib/staleness/writers/commands.js +7 -12
- package/dist/lib/startup/command-registry.d.ts +1 -0
- package/dist/lib/startup/command-registry.js +2 -0
- package/dist/lib/startup/dev-build.d.ts +22 -0
- package/dist/lib/startup/dev-build.js +41 -0
- package/dist/lib/types.d.ts +28 -0
- package/dist/lib/versions.d.ts +9 -3
- package/dist/lib/versions.js +43 -7
- package/package.json +3 -3
- package/scripts/postinstall.js +62 -0
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `agents sessions --host <target>` — run the session query on a remote machine
|
|
3
|
+
* over SSH and stream its output back. Session transcripts and the index DB live
|
|
4
|
+
* on the machine that produced them (see `discover.ts`, all `os.homedir()`-rooted),
|
|
5
|
+
* so instead of syncing the bytes here we invoke the *remote's own* `agents
|
|
6
|
+
* sessions` against its already-built index and forward stdout verbatim.
|
|
7
|
+
*
|
|
8
|
+
* This is the live counterpart to `agents sessions sync` (R2/CRDT, eventual): no
|
|
9
|
+
* upfront copy, always current, but the peer must be reachable. SSH access is the
|
|
10
|
+
* only auth — if you can `ssh <host>`, you own the box (no identity layer by design).
|
|
11
|
+
*
|
|
12
|
+
* Offline degradation (no sync, still fetch-first): every *successful* fetch is
|
|
13
|
+
* cached to `~/.agents/.cache/remote-sessions/`, keyed by host + the exact query.
|
|
14
|
+
* When a later run finds the host unreachable, the cache is replayed with a clearly
|
|
15
|
+
* labelled "showing cached results" banner instead of returning nothing. The cache
|
|
16
|
+
* is a byproduct of fetches you already made — never a background job, freely
|
|
17
|
+
* deletable — so the fetch-don't-replicate model holds; this is just graceful
|
|
18
|
+
* degradation when the peer is asleep.
|
|
19
|
+
*
|
|
20
|
+
* Mirrors the transport already used by `agents secrets export --host`
|
|
21
|
+
* (`src/commands/secrets.ts`): `ssh -o BatchMode=yes <host> bash -lc '<cmd>'`,
|
|
22
|
+
* with `bash -lc` so the remote login PATH resolves `agents`.
|
|
23
|
+
*/
|
|
24
|
+
import { spawnSync } from 'child_process';
|
|
25
|
+
import { mkdirSync, writeFileSync, readFileSync, existsSync, statSync } from 'fs';
|
|
26
|
+
import { join } from 'path';
|
|
27
|
+
import { createHash } from 'crypto';
|
|
28
|
+
import chalk from 'chalk';
|
|
29
|
+
import { getCacheDir } from '../state.js';
|
|
30
|
+
import { formatRelativeTime } from './relative-time.js';
|
|
31
|
+
/**
|
|
32
|
+
* SSH target: a bare ssh-config host alias (e.g. `yosemite-s1`) or `user@host`.
|
|
33
|
+
* The strict allowlist blocks shell metacharacters and a leading `-`, so a target
|
|
34
|
+
* can never be smuggled in as an ssh argv flag.
|
|
35
|
+
*/
|
|
36
|
+
export const SSH_TARGET_RE = /^[a-zA-Z0-9._-]+(@[a-zA-Z0-9._-]+)?$/;
|
|
37
|
+
export function assertValidSshTarget(host) {
|
|
38
|
+
if (!SSH_TARGET_RE.test(host)) {
|
|
39
|
+
throw new Error(`Invalid SSH target ${JSON.stringify(host)}. Expected a host alias or user@host ` +
|
|
40
|
+
`(letters, digits, '.', '_', '-').`);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
/** POSIX single-quote a string for safe interpolation into a remote shell command. */
|
|
44
|
+
export function shellQuote(s) {
|
|
45
|
+
return `'${s.replace(/'/g, `'\\''`)}'`;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Strip the `--host`/`-H` flag (and its value) from a raw `agents sessions` argv,
|
|
49
|
+
* leaving the args to forward to the remote unchanged. The remote runs the same
|
|
50
|
+
* binary, so every other flag (`--since`, `--last`, `--json`, query, …) carries
|
|
51
|
+
* over for free. Handles every form commander accepts: `--host h`, `--host=h`,
|
|
52
|
+
* `-H h`, `-H=h`, and the glued short form `-Hh`.
|
|
53
|
+
*
|
|
54
|
+
* @param argv full process argv; the sessions args begin at index 2
|
|
55
|
+
* (`[runtime, script, 'sessions', ...]`).
|
|
56
|
+
*/
|
|
57
|
+
export function buildForwardedArgs(argv, hosts = new Set()) {
|
|
58
|
+
const args = argv.slice(2);
|
|
59
|
+
const out = [];
|
|
60
|
+
for (let i = 0; i < args.length; i++) {
|
|
61
|
+
const a = args[i];
|
|
62
|
+
if (a === '--host' || a === '-H') {
|
|
63
|
+
// Commander's `<target...>` variadic accepts both `--host a --host b` and
|
|
64
|
+
// `--host a b` — consume every consecutive token that is a known host so
|
|
65
|
+
// the variadic form doesn't leak the extra hosts into the remote argv.
|
|
66
|
+
// Fall back to consuming the single next token when we have no host set
|
|
67
|
+
// (e.g. malformed input) so the flag value never leaks either way.
|
|
68
|
+
if (hosts.size > 0) {
|
|
69
|
+
while (i + 1 < args.length && hosts.has(args[i + 1]))
|
|
70
|
+
i++;
|
|
71
|
+
}
|
|
72
|
+
else {
|
|
73
|
+
i++; // also consume the separate value token
|
|
74
|
+
}
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
if (a.startsWith('--host=') || a.startsWith('-H='))
|
|
78
|
+
continue;
|
|
79
|
+
if (/^-H.+/.test(a))
|
|
80
|
+
continue; // glued short form: -Hyosemite-s1
|
|
81
|
+
out.push(a);
|
|
82
|
+
}
|
|
83
|
+
return out;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Build the single remote command string for `ssh <host> <cmd>`. Forwarded args
|
|
87
|
+
* are quoted for the inner login shell, then the whole `agents …` invocation is
|
|
88
|
+
* quoted again so it survives `bash -lc <...>`.
|
|
89
|
+
*/
|
|
90
|
+
export function buildRemoteCommand(forwardedArgs) {
|
|
91
|
+
const inner = ['agents', ...forwardedArgs].map(shellQuote).join(' ');
|
|
92
|
+
return `bash -lc ${shellQuote(inner)}`;
|
|
93
|
+
}
|
|
94
|
+
const SSH_OPTS = [
|
|
95
|
+
'-o', 'BatchMode=yes',
|
|
96
|
+
'-o', 'StrictHostKeyChecking=accept-new',
|
|
97
|
+
'-o', 'ConnectTimeout=10',
|
|
98
|
+
];
|
|
99
|
+
/**
|
|
100
|
+
* Classify an ssh `spawnSync` result. ssh(1) reserves exit 255 for its own
|
|
101
|
+
* connection-layer failures (host down, timeout, refused, auth, changed host
|
|
102
|
+
* key) — distinct from any other non-zero, which is the remote `agents sessions`
|
|
103
|
+
* exit code forwarded back (the query ran but failed). The two must be handled
|
|
104
|
+
* differently: 255 may fall back to cache, a forwarded failure must surface.
|
|
105
|
+
*/
|
|
106
|
+
export function classifySshFailure(res) {
|
|
107
|
+
if (res.error)
|
|
108
|
+
return 'spawn-error';
|
|
109
|
+
if (res.status === 0)
|
|
110
|
+
return 'ok';
|
|
111
|
+
if (res.status === 255)
|
|
112
|
+
return 'unreachable';
|
|
113
|
+
return 'query-failed';
|
|
114
|
+
}
|
|
115
|
+
/** Root of the offline-replay cache (`~/.agents/.cache/remote-sessions/`). */
|
|
116
|
+
const REMOTE_CACHE_DIR = join(getCacheDir(), 'remote-sessions');
|
|
117
|
+
/**
|
|
118
|
+
* Deterministic cache path for a (host, forwarded-args) pair. The forwarded args
|
|
119
|
+
* are hashed so distinct queries cache independently; the host stays readable in
|
|
120
|
+
* the filename (sanitised so `user@host` and aliases are filesystem-safe).
|
|
121
|
+
*/
|
|
122
|
+
export function remoteCachePath(host, forwardedArgs) {
|
|
123
|
+
const hash = createHash('sha256').update(forwardedArgs.join('\u0000')).digest('hex').slice(0, 16);
|
|
124
|
+
const safeHost = host.replace(/[^a-zA-Z0-9._@-]/g, '_');
|
|
125
|
+
return join(REMOTE_CACHE_DIR, `${safeHost}__${hash}.txt`);
|
|
126
|
+
}
|
|
127
|
+
/** Banner shown above replayed cache rows when the peer is offline. */
|
|
128
|
+
export function formatStaleBanner(host, mtimeMs) {
|
|
129
|
+
const ago = formatRelativeTime(new Date(mtimeMs).toISOString());
|
|
130
|
+
return chalk.yellow(`${host}: offline — showing cached results from ${ago}`);
|
|
131
|
+
}
|
|
132
|
+
/** Message shown when a host is unreachable and there is no cache to fall back to. */
|
|
133
|
+
export function formatUnreachable(host) {
|
|
134
|
+
return chalk.red(`${host}: unreachable over SSH (asleep, offline, or host key changed?) — ConnectTimeout 10s`);
|
|
135
|
+
}
|
|
136
|
+
/** Persist a successful fetch for later offline replay. Best-effort: a cache
|
|
137
|
+
* write must never break the live query. */
|
|
138
|
+
function writeRemoteCache(host, forwardedArgs, output) {
|
|
139
|
+
try {
|
|
140
|
+
mkdirSync(REMOTE_CACHE_DIR, { recursive: true });
|
|
141
|
+
writeFileSync(remoteCachePath(host, forwardedArgs), output);
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
// ignore — caching is an optimisation, not a guarantee
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
/** Replay a cached fetch for an unreachable host. Banner goes to stderr (so a
|
|
148
|
+
* piped stdout stays exactly the cached rows); returns false when nothing is
|
|
149
|
+
* cached for this exact (host, query). */
|
|
150
|
+
function replayRemoteCache(host, forwardedArgs) {
|
|
151
|
+
try {
|
|
152
|
+
const p = remoteCachePath(host, forwardedArgs);
|
|
153
|
+
if (!existsSync(p))
|
|
154
|
+
return false;
|
|
155
|
+
process.stderr.write(formatStaleBanner(host, statSync(p).mtimeMs) + '\n');
|
|
156
|
+
process.stdout.write(readFileSync(p, 'utf8'));
|
|
157
|
+
return true;
|
|
158
|
+
}
|
|
159
|
+
catch {
|
|
160
|
+
return false;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Run the current `agents sessions` invocation on one or more remote machines over
|
|
165
|
+
* SSH, writing each remote's output to the terminal. A successful fetch is cached;
|
|
166
|
+
* an unreachable host falls back to that cache (with a stale banner) when present.
|
|
167
|
+
* Sets `process.exitCode = 1` if any host could not be answered (live or cached).
|
|
168
|
+
* Reads the invocation from `process.argv` (override via `argv` for testing).
|
|
169
|
+
*
|
|
170
|
+
* Output is captured rather than `stdio: 'inherit'`-streamed so it can be cached.
|
|
171
|
+
* Session output is small and the remote returns quickly, so buffering is
|
|
172
|
+
* imperceptible; `maxBuffer` is generous for the rare large `--markdown <id>` dump.
|
|
173
|
+
*/
|
|
174
|
+
export function runRemoteSessions(hosts, argv = process.argv) {
|
|
175
|
+
for (const host of hosts)
|
|
176
|
+
assertValidSshTarget(host); // fail fast on any bad target
|
|
177
|
+
const forwarded = buildForwardedArgs(argv, new Set(hosts));
|
|
178
|
+
const remoteCmd = buildRemoteCommand(forwarded);
|
|
179
|
+
const multi = hosts.length > 1;
|
|
180
|
+
let failures = 0;
|
|
181
|
+
for (const host of hosts) {
|
|
182
|
+
if (multi)
|
|
183
|
+
process.stdout.write(chalk.cyan(`\n── ${host} ──\n`));
|
|
184
|
+
const res = spawnSync('ssh', [...SSH_OPTS, host, remoteCmd], {
|
|
185
|
+
encoding: 'utf8',
|
|
186
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
187
|
+
});
|
|
188
|
+
switch (classifySshFailure(res)) {
|
|
189
|
+
case 'ok':
|
|
190
|
+
process.stdout.write(res.stdout ?? '');
|
|
191
|
+
if (res.stderr)
|
|
192
|
+
process.stderr.write(res.stderr);
|
|
193
|
+
writeRemoteCache(host, forwarded, res.stdout ?? '');
|
|
194
|
+
break;
|
|
195
|
+
case 'unreachable':
|
|
196
|
+
// Served-from-cache counts as answered (degraded, but with data + a clear
|
|
197
|
+
// banner), so it does not increment failures. No cache → a real failure.
|
|
198
|
+
if (!replayRemoteCache(host, forwarded)) {
|
|
199
|
+
failures++;
|
|
200
|
+
console.error(formatUnreachable(host));
|
|
201
|
+
}
|
|
202
|
+
break;
|
|
203
|
+
case 'spawn-error':
|
|
204
|
+
failures++;
|
|
205
|
+
console.error(chalk.red(`${host}: ${res.error?.message ?? 'failed to launch ssh'}`));
|
|
206
|
+
break;
|
|
207
|
+
case 'query-failed':
|
|
208
|
+
// The remote ran but its query exited non-zero — surface its own output
|
|
209
|
+
// and exit code; never mask a genuine error with stale cache.
|
|
210
|
+
failures++;
|
|
211
|
+
if (res.stdout)
|
|
212
|
+
process.stdout.write(res.stdout);
|
|
213
|
+
if (res.stderr)
|
|
214
|
+
process.stderr.write(res.stderr);
|
|
215
|
+
console.error(chalk.red(`${host}: remote query failed (exit ${res.status ?? 'signal'}).`));
|
|
216
|
+
break;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
if (failures > 0)
|
|
220
|
+
process.exitCode = 1;
|
|
221
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared SSH exec primitive — the single hardened choke point for running a
|
|
3
|
+
* command on a remote host over the system `ssh`.
|
|
4
|
+
*
|
|
5
|
+
* `agents hosts` dispatch and the browser driver both go through here so the
|
|
6
|
+
* connection hardening (`BatchMode`, `accept-new`, `ConnectTimeout`) and the
|
|
7
|
+
* target-injection guard live in exactly one place. Target validation is the
|
|
8
|
+
* canonical definition; `commands/secrets.ts` re-exports it.
|
|
9
|
+
*/
|
|
10
|
+
/**
|
|
11
|
+
* SSH target: a bare ssh-config host alias (e.g. `yosemite-s0`) or `user@host`.
|
|
12
|
+
* The strict allowlist blocks shell metacharacters so a target can't be
|
|
13
|
+
* smuggled in as part of a remote command, and `sshExec` additionally rejects a
|
|
14
|
+
* leading `-` so it can never be parsed as an ssh argv flag.
|
|
15
|
+
*/
|
|
16
|
+
export declare const SSH_TARGET_RE: RegExp;
|
|
17
|
+
export declare function assertValidSshTarget(host: string): void;
|
|
18
|
+
/** POSIX single-quote a string for safe interpolation into a remote shell command. */
|
|
19
|
+
export declare function shellQuote(s: string): string;
|
|
20
|
+
/** Hardened ssh options applied to every connection. */
|
|
21
|
+
export declare const SSH_OPTS: readonly string[];
|
|
22
|
+
export interface SshExecOptions {
|
|
23
|
+
/** Piped to the remote command's stdin (never interpolated into the shell). */
|
|
24
|
+
input?: string;
|
|
25
|
+
/** Kill the ssh process after this many ms. */
|
|
26
|
+
timeoutMs?: number;
|
|
27
|
+
/** Extra ssh flags inserted before the target (e.g. `-tt`). */
|
|
28
|
+
extraSshArgs?: string[];
|
|
29
|
+
}
|
|
30
|
+
export interface SshExecResult {
|
|
31
|
+
/** Remote exit status, or null if ssh itself failed / timed out. */
|
|
32
|
+
code: number | null;
|
|
33
|
+
stdout: string;
|
|
34
|
+
stderr: string;
|
|
35
|
+
timedOut: boolean;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Run `remoteCmd` on `target` over ssh and capture stdout/stderr/exit.
|
|
39
|
+
*
|
|
40
|
+
* `remoteCmd` is passed as a single argv to ssh (the remote login shell parses
|
|
41
|
+
* it); callers that build it from user input must `shellQuote` the pieces.
|
|
42
|
+
*/
|
|
43
|
+
export declare function sshExec(target: string, remoteCmd: string, opts?: SshExecOptions): SshExecResult;
|
|
44
|
+
/** True if `target` is reachable over ssh (a passwordless `true` succeeds quickly). */
|
|
45
|
+
export declare function sshReachable(target: string, timeoutMs?: number): boolean;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared SSH exec primitive — the single hardened choke point for running a
|
|
3
|
+
* command on a remote host over the system `ssh`.
|
|
4
|
+
*
|
|
5
|
+
* `agents hosts` dispatch and the browser driver both go through here so the
|
|
6
|
+
* connection hardening (`BatchMode`, `accept-new`, `ConnectTimeout`) and the
|
|
7
|
+
* target-injection guard live in exactly one place. Target validation is the
|
|
8
|
+
* canonical definition; `commands/secrets.ts` re-exports it.
|
|
9
|
+
*/
|
|
10
|
+
import { spawnSync } from 'child_process';
|
|
11
|
+
/**
|
|
12
|
+
* SSH target: a bare ssh-config host alias (e.g. `yosemite-s0`) or `user@host`.
|
|
13
|
+
* The strict allowlist blocks shell metacharacters so a target can't be
|
|
14
|
+
* smuggled in as part of a remote command, and `sshExec` additionally rejects a
|
|
15
|
+
* leading `-` so it can never be parsed as an ssh argv flag.
|
|
16
|
+
*/
|
|
17
|
+
export const SSH_TARGET_RE = /^[a-zA-Z0-9._-]+(@[a-zA-Z0-9._-]+)?$/;
|
|
18
|
+
export function assertValidSshTarget(host) {
|
|
19
|
+
if (host.startsWith('-') || !SSH_TARGET_RE.test(host)) {
|
|
20
|
+
throw new Error(`Invalid SSH target ${JSON.stringify(host)}. Expected a host alias or user@host (letters, digits, '.', '_', '-').`);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
/** POSIX single-quote a string for safe interpolation into a remote shell command. */
|
|
24
|
+
export function shellQuote(s) {
|
|
25
|
+
if (/^[A-Za-z0-9_./:=@%+-]+$/.test(s))
|
|
26
|
+
return s;
|
|
27
|
+
return "'" + s.replace(/'/g, "'\\''") + "'";
|
|
28
|
+
}
|
|
29
|
+
/** Hardened ssh options applied to every connection. */
|
|
30
|
+
export const SSH_OPTS = [
|
|
31
|
+
'-o', 'StrictHostKeyChecking=accept-new',
|
|
32
|
+
'-o', 'BatchMode=yes',
|
|
33
|
+
'-o', 'ConnectTimeout=10',
|
|
34
|
+
];
|
|
35
|
+
/**
|
|
36
|
+
* Run `remoteCmd` on `target` over ssh and capture stdout/stderr/exit.
|
|
37
|
+
*
|
|
38
|
+
* `remoteCmd` is passed as a single argv to ssh (the remote login shell parses
|
|
39
|
+
* it); callers that build it from user input must `shellQuote` the pieces.
|
|
40
|
+
*/
|
|
41
|
+
export function sshExec(target, remoteCmd, opts = {}) {
|
|
42
|
+
assertValidSshTarget(target);
|
|
43
|
+
const args = [...SSH_OPTS, ...(opts.extraSshArgs ?? []), target, remoteCmd];
|
|
44
|
+
const res = spawnSync('ssh', args, {
|
|
45
|
+
input: opts.input,
|
|
46
|
+
encoding: 'utf-8',
|
|
47
|
+
timeout: opts.timeoutMs,
|
|
48
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
49
|
+
});
|
|
50
|
+
const timedOut = !!(res.error && res.error.code === 'ETIMEDOUT');
|
|
51
|
+
return {
|
|
52
|
+
code: typeof res.status === 'number' ? res.status : null,
|
|
53
|
+
stdout: res.stdout ?? '',
|
|
54
|
+
stderr: res.stderr ?? '',
|
|
55
|
+
timedOut,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
/** True if `target` is reachable over ssh (a passwordless `true` succeeds quickly). */
|
|
59
|
+
export function sshReachable(target, timeoutMs = 10000) {
|
|
60
|
+
return sshExec(target, 'true', { timeoutMs }).code === 0;
|
|
61
|
+
}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Commands detector — mirrors versions.ts:343-357. Inspects the version home,
|
|
3
|
-
* returns command names. Honors the commands-as-skills marker for
|
|
4
|
-
* Codex >= 0.117.0; falls back to scanning
|
|
5
|
-
* the native path.
|
|
3
|
+
* returns command names. Honors the commands-as-skills marker for skills-only
|
|
4
|
+
* agents (grok, kimi, Codex >= 0.117.0, …); falls back to scanning
|
|
5
|
+
* `{agentDir}/<commandsSubdir>/` for the native path.
|
|
6
6
|
*/
|
|
7
7
|
import * as fs from 'fs';
|
|
8
8
|
import * as path from 'path';
|
|
@@ -29,13 +29,14 @@ function buildCommandsDetector(agent) {
|
|
|
29
29
|
},
|
|
30
30
|
};
|
|
31
31
|
}
|
|
32
|
-
// Detector registration mirrors writers/commands.ts —
|
|
33
|
-
//
|
|
32
|
+
// Detector registration mirrors writers/commands.ts — skills-capable agents
|
|
33
|
+
// with no native command-file dir convert commands to skills by default; only
|
|
34
|
+
// agents with their own slash-command runtime (nativeCommandRuntime) opt out.
|
|
34
35
|
export const commandsDetectors = lazyAgentMap(() => {
|
|
35
36
|
const m = {};
|
|
36
37
|
for (const id of Object.keys(AGENTS)) {
|
|
37
38
|
const cfg = AGENTS[id];
|
|
38
|
-
if (cfg.capabilities.commands === false && (!cfg.commandsSubdir || cfg.commandsSubdir === '') &&
|
|
39
|
+
if (cfg.capabilities.commands === false && (!cfg.commandsSubdir || cfg.commandsSubdir === '') && cfg.nativeCommandRuntime)
|
|
39
40
|
continue;
|
|
40
41
|
const hasCommands = cfg.capabilities.commands !== false;
|
|
41
42
|
const hasSkills = cfg.capabilities.skills !== false;
|
|
@@ -80,25 +80,20 @@ function buildCommandsWriter(agent) {
|
|
|
80
80
|
// - commands-as-skills (grok, codex >= 0.117.0)
|
|
81
81
|
//
|
|
82
82
|
// Agents that have skills but use a NATIVE non-file slash-command system
|
|
83
|
-
// (openclaw → Gateway-based commands) are NOT registered.
|
|
84
|
-
//
|
|
85
|
-
//
|
|
86
|
-
// resolver).
|
|
83
|
+
// (openclaw → Gateway-based commands) are NOT registered. They declare
|
|
84
|
+
// `nativeCommandRuntime: true` to opt out — their own runtime resolves slash
|
|
85
|
+
// commands, so there's nothing to write and nothing to convert.
|
|
87
86
|
export const commandsWriters = lazyAgentMap(() => {
|
|
88
87
|
const m = {};
|
|
89
88
|
for (const id of Object.keys(AGENTS)) {
|
|
90
89
|
const cfg = AGENTS[id];
|
|
91
90
|
if (cfg.capabilities.commands === false && (!cfg.skillsDir || cfg.skillsDir === ''))
|
|
92
91
|
continue;
|
|
93
|
-
//
|
|
92
|
+
// Skills-capable agent with no native command-file dir: convert commands to
|
|
93
|
+
// skills by default (grok, kimi, …). Opt out only agents with their own
|
|
94
|
+
// slash-command runtime (openclaw).
|
|
94
95
|
if (cfg.capabilities.commands === false && (!cfg.commandsSubdir || cfg.commandsSubdir === '')) {
|
|
95
|
-
|
|
96
|
-
// Distinguish: grok has skillsDir set; openclaw also has skillsDir, so we
|
|
97
|
-
// can't use that. The cleanest signal is the agent's `cliCommand` set —
|
|
98
|
-
// openclaw flags `commands: false` AND has its Gateway runtime, while
|
|
99
|
-
// grok flags `commands: false` because grok's slash commands are skills.
|
|
100
|
-
// We opt in explicitly: only grok takes commands-as-skills today.
|
|
101
|
-
if (id !== 'grok')
|
|
96
|
+
if (cfg.nativeCommandRuntime)
|
|
102
97
|
continue;
|
|
103
98
|
}
|
|
104
99
|
const hasCommands = cfg.capabilities.commands !== false;
|
|
@@ -67,6 +67,7 @@ export declare const loadPty: ModuleLoader;
|
|
|
67
67
|
export declare const loadTmux: ModuleLoader;
|
|
68
68
|
export declare const loadBrowser: ModuleLoader;
|
|
69
69
|
export declare const loadComputer: ModuleLoader;
|
|
70
|
+
export declare const loadHosts: ModuleLoader;
|
|
70
71
|
export declare const loadPull: ModuleLoader;
|
|
71
72
|
export declare const loadPush: ModuleLoader;
|
|
72
73
|
export declare const loadRepo: ModuleLoader;
|
|
@@ -45,6 +45,7 @@ export const loadPty = async () => (await import('../../commands/pty.js')).regis
|
|
|
45
45
|
export const loadTmux = async () => (await import('../../commands/tmux.js')).registerTmuxCommands;
|
|
46
46
|
export const loadBrowser = async () => (await import('../../commands/browser.js')).registerBrowserCommand;
|
|
47
47
|
export const loadComputer = async () => (await import('../../commands/computer.js')).registerComputerCommand;
|
|
48
|
+
export const loadHosts = async () => (await import('../../commands/hosts.js')).registerHostsCommand;
|
|
48
49
|
export const loadPull = async () => (await import('../../commands/pull.js')).registerPullCommand;
|
|
49
50
|
export const loadPush = async () => (await import('../../commands/push.js')).registerPushCommand;
|
|
50
51
|
export const loadRepo = async () => (await import('../../commands/repo.js')).registerRepoCommands;
|
|
@@ -126,6 +127,7 @@ export const COMMAND_LOADERS = {
|
|
|
126
127
|
tmux: [loadTmux],
|
|
127
128
|
browser: [loadBrowser],
|
|
128
129
|
computer: [loadComputer],
|
|
130
|
+
hosts: [loadHosts],
|
|
129
131
|
pull: [loadPull],
|
|
130
132
|
push: [loadPush],
|
|
131
133
|
repo: [loadRepo],
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Decide whether the CLI is running from a source checkout (a "dev build") vs an
|
|
3
|
+
* installed package. Dev builds suppress autopull / migrations / auto-update so
|
|
4
|
+
* iterating on the repo never mutates the user's real setup.
|
|
5
|
+
*
|
|
6
|
+
* Two signals:
|
|
7
|
+
* 1. A `0.0.0-dev*` version stamp (scripts/install.sh dev installs).
|
|
8
|
+
* 2. Running out of an actual agents-cli git checkout.
|
|
9
|
+
*
|
|
10
|
+
* Signal 2 must be precise. The naive check —
|
|
11
|
+
* `existsSync(dirname(dirname(argv[1])) + '/.git')` — false-positives badly:
|
|
12
|
+
* - npm-global bins are symlinks. `/opt/homebrew/bin/agents` →
|
|
13
|
+
* `…/node_modules/@phnx-labs/agents-cli/dist/index.js`. Without resolving the
|
|
14
|
+
* symlink, `dirname(dirname())` walks to `/opt/homebrew`, which is **itself a
|
|
15
|
+
* git repo** (Homebrew). So every Homebrew-node user looked like a dev build
|
|
16
|
+
* and had migrations + the menu-bar self-heal silently disabled.
|
|
17
|
+
*
|
|
18
|
+
* Fix: resolve the symlink with realpath, then require the `.git`'s repo root to
|
|
19
|
+
* actually be the agents-cli package (its package.json `name`), not some
|
|
20
|
+
* unrelated ancestor that happens to be version-controlled.
|
|
21
|
+
*/
|
|
22
|
+
export declare function detectDevBuild(argv1: string, version: string): boolean;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
/**
|
|
4
|
+
* Decide whether the CLI is running from a source checkout (a "dev build") vs an
|
|
5
|
+
* installed package. Dev builds suppress autopull / migrations / auto-update so
|
|
6
|
+
* iterating on the repo never mutates the user's real setup.
|
|
7
|
+
*
|
|
8
|
+
* Two signals:
|
|
9
|
+
* 1. A `0.0.0-dev*` version stamp (scripts/install.sh dev installs).
|
|
10
|
+
* 2. Running out of an actual agents-cli git checkout.
|
|
11
|
+
*
|
|
12
|
+
* Signal 2 must be precise. The naive check —
|
|
13
|
+
* `existsSync(dirname(dirname(argv[1])) + '/.git')` — false-positives badly:
|
|
14
|
+
* - npm-global bins are symlinks. `/opt/homebrew/bin/agents` →
|
|
15
|
+
* `…/node_modules/@phnx-labs/agents-cli/dist/index.js`. Without resolving the
|
|
16
|
+
* symlink, `dirname(dirname())` walks to `/opt/homebrew`, which is **itself a
|
|
17
|
+
* git repo** (Homebrew). So every Homebrew-node user looked like a dev build
|
|
18
|
+
* and had migrations + the menu-bar self-heal silently disabled.
|
|
19
|
+
*
|
|
20
|
+
* Fix: resolve the symlink with realpath, then require the `.git`'s repo root to
|
|
21
|
+
* actually be the agents-cli package (its package.json `name`), not some
|
|
22
|
+
* unrelated ancestor that happens to be version-controlled.
|
|
23
|
+
*/
|
|
24
|
+
export function detectDevBuild(argv1, version) {
|
|
25
|
+
if (version.startsWith('0.0.0-dev'))
|
|
26
|
+
return true;
|
|
27
|
+
try {
|
|
28
|
+
const cliPath = fs.realpathSync(argv1 || '');
|
|
29
|
+
const repoRoot = path.dirname(path.dirname(cliPath));
|
|
30
|
+
if (!fs.existsSync(path.join(repoRoot, '.git')))
|
|
31
|
+
return false;
|
|
32
|
+
const pkgPath = path.join(repoRoot, 'package.json');
|
|
33
|
+
if (!fs.existsSync(pkgPath))
|
|
34
|
+
return false;
|
|
35
|
+
const name = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'))?.name;
|
|
36
|
+
return name === '@phnx-labs/agents-cli';
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
}
|
package/dist/lib/types.d.ts
CHANGED
|
@@ -77,6 +77,13 @@ export interface AgentConfig {
|
|
|
77
77
|
commandsDir: string;
|
|
78
78
|
commandsSubdir: string;
|
|
79
79
|
skillsDir: string;
|
|
80
|
+
/**
|
|
81
|
+
* Agent resolves slash-commands through its own runtime (e.g. openclaw's
|
|
82
|
+
* Gateway), so agents-cli commands must NOT be converted into skills for it.
|
|
83
|
+
* Skills-capable agents WITHOUT a native command-file dir convert commands to
|
|
84
|
+
* skills by default; set this to opt such an agent out of that conversion.
|
|
85
|
+
*/
|
|
86
|
+
nativeCommandRuntime?: boolean;
|
|
80
87
|
hooksDir: string;
|
|
81
88
|
pluginManifestDir?: string;
|
|
82
89
|
instructionsFile: string;
|
|
@@ -605,6 +612,27 @@ export interface Meta {
|
|
|
605
612
|
* lives separately in ~/.agents/.cache/browser/<profile>/.
|
|
606
613
|
*/
|
|
607
614
|
browser?: Record<string, BrowserProfileConfig>;
|
|
615
|
+
/**
|
|
616
|
+
* Agent-host registry keyed by host name (`agents hosts`). Portable user
|
|
617
|
+
* config synced with `agents repo push/pull`. For `ssh-config` hosts this is
|
|
618
|
+
* just an overlay (caps/os) — the connection details stay in ~/.ssh/config and
|
|
619
|
+
* are never copied. `inline` hosts carry their own address/user.
|
|
620
|
+
*/
|
|
621
|
+
hosts?: Record<string, HostEntry>;
|
|
622
|
+
}
|
|
623
|
+
/** Persisted agent-host entry in agents.yaml (overlay or inline). */
|
|
624
|
+
export interface HostEntry {
|
|
625
|
+
/** `ssh-config`: reach via the bare name (ssh resolves). `inline`: use address/user below. */
|
|
626
|
+
source: 'ssh-config' | 'inline';
|
|
627
|
+
/** SSH-reachable target — inline hosts only (ssh-config hosts omit it). */
|
|
628
|
+
address?: string;
|
|
629
|
+
/** SSH user — inline hosts only. */
|
|
630
|
+
user?: string;
|
|
631
|
+
/** Captured at enroll probe. */
|
|
632
|
+
os?: string;
|
|
633
|
+
/** Free-form capability tags for routing (e.g. ['gpu']). */
|
|
634
|
+
caps?: string[];
|
|
635
|
+
addedAt?: string;
|
|
608
636
|
}
|
|
609
637
|
/** Browser profile definition stored in agents.yaml. */
|
|
610
638
|
export interface BrowserProfileConfig {
|
package/dist/lib/versions.d.ts
CHANGED
|
@@ -144,8 +144,10 @@ export declare function isOldestInstalled(agent: AgentId): Promise<{
|
|
|
144
144
|
installed: boolean;
|
|
145
145
|
version: string | null;
|
|
146
146
|
}>;
|
|
147
|
+
/** Drop the installed-versions cache (call after install/remove mutations). */
|
|
148
|
+
export declare function invalidateInstalledVersionsCache(agent?: AgentId): void;
|
|
147
149
|
/**
|
|
148
|
-
* List all installed versions for an agent.
|
|
150
|
+
* List all installed versions for an agent (cached by versions-dir mtime).
|
|
149
151
|
*/
|
|
150
152
|
export declare function listInstalledVersions(agent: AgentId): string[];
|
|
151
153
|
/**
|
|
@@ -239,12 +241,16 @@ export declare function resolveVersion(agent: AgentId, projectPath?: string): st
|
|
|
239
241
|
/**
|
|
240
242
|
* Normalize a user-supplied @version token across CLI subcommands.
|
|
241
243
|
*
|
|
242
|
-
* undefined / "" / "default"
|
|
244
|
+
* undefined / "" / "default" / "pinned" -> undefined (caller falls back to project pin or global default)
|
|
245
|
+
* "any" -> undefined (caller imposes no version constraint — e.g. resume across any version)
|
|
243
246
|
* "latest" -> highest installed version (process.exit if none installed)
|
|
244
247
|
* "oldest" -> lowest installed version (process.exit if none installed)
|
|
245
248
|
* "x.y.z" (installed) -> "x.y.z"
|
|
246
249
|
* "x.y.z" (not installed) -> process.exit with installed-list hint
|
|
247
250
|
*
|
|
251
|
+
* `pinned` is a synonym for `default`: both name the project pin / global
|
|
252
|
+
* default, which the caller resolves.
|
|
253
|
+
*
|
|
248
254
|
* Use this anywhere the user can type `agents <cmd> claude@<token>` to keep the
|
|
249
255
|
* vocabulary consistent. Subcommands with different semantics for `latest`
|
|
250
256
|
* (install/remove/use, where `latest` means npm-latest) keep their existing
|
|
@@ -253,7 +259,7 @@ export declare function resolveVersion(agent: AgentId, projectPath?: string): st
|
|
|
253
259
|
export declare function resolveVersionAlias(agent: AgentId, raw: string | undefined | null): string | undefined;
|
|
254
260
|
/**
|
|
255
261
|
* Loose variant of resolveVersionAlias for record-filter contexts (sessions,
|
|
256
|
-
* team history). Same `default`/`latest`/`oldest` semantics, but explicit
|
|
262
|
+
* team history). Same `default`/`pinned`/`latest`/`oldest` semantics, but explicit
|
|
257
263
|
* versions pass through unchanged so historical records of uninstalled versions
|
|
258
264
|
* remain queryable.
|
|
259
265
|
*/
|