@phnx-labs/agents-cli 1.20.27 → 1.20.29
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 +3 -0
- package/dist/commands/doctor.js +57 -4
- package/dist/commands/exec.d.ts +1 -1
- package/dist/commands/exec.js +198 -15
- 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 +108 -29
- package/dist/commands/sessions.d.ts +2 -0
- package/dist/commands/sessions.js +8 -24
- package/dist/commands/ssh.d.ts +14 -0
- package/dist/commands/ssh.js +263 -0
- 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 +3 -1
- package/dist/lib/agents.d.ts +1 -0
- package/dist/lib/agents.js +44 -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/devices/connect.d.ts +34 -0
- package/dist/lib/devices/connect.js +101 -0
- package/dist/lib/devices/registry.d.ts +78 -0
- package/dist/lib/devices/registry.js +168 -0
- package/dist/lib/devices/ssh-config.d.ts +21 -0
- package/dist/lib/devices/ssh-config.js +33 -0
- package/dist/lib/devices/tailscale.d.ts +31 -0
- package/dist/lib/devices/tailscale.js +126 -0
- package/dist/lib/doctor-diff.d.ts +12 -0
- package/dist/lib/doctor-diff.js +89 -2
- package/dist/lib/exec.d.ts +27 -0
- package/dist/lib/exec.js +62 -19
- 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/plugins.d.ts +10 -0
- package/dist/lib/plugins.js +1 -1
- package/dist/lib/project-launch.js +6 -3
- package/dist/lib/sandbox.js +5 -2
- package/dist/lib/secrets/remote.d.ts +67 -0
- package/dist/lib/secrets/remote.js +133 -0
- package/dist/lib/self-update.js +7 -2
- package/dist/lib/session/db.d.ts +24 -0
- package/dist/lib/session/db.js +80 -5
- package/dist/lib/session/discover.d.ts +28 -0
- package/dist/lib/session/discover.js +303 -4
- package/dist/lib/session/parse.d.ts +7 -0
- package/dist/lib/session/parse.js +110 -0
- 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 +31 -3
- package/dist/lib/session/remote.js +121 -14
- package/dist/lib/session/types.d.ts +1 -1
- package/dist/lib/session/types.js +1 -1
- package/dist/lib/ssh-exec.d.ts +45 -0
- package/dist/lib/ssh-exec.js +61 -0
- package/dist/lib/startup/command-registry.d.ts +2 -0
- package/dist/lib/startup/command-registry.js +5 -0
- package/dist/lib/state.d.ts +2 -0
- package/dist/lib/state.js +2 -0
- package/dist/lib/types.d.ts +21 -0
- package/dist/lib/versions.d.ts +6 -2
- package/dist/lib/versions.js +8 -4
- package/package.json +1 -1
- package/scripts/postinstall.js +62 -0
|
@@ -9,12 +9,25 @@
|
|
|
9
9
|
* upfront copy, always current, but the peer must be reachable. SSH access is the
|
|
10
10
|
* only auth — if you can `ssh <host>`, you own the box (no identity layer by design).
|
|
11
11
|
*
|
|
12
|
-
*
|
|
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`
|
|
13
21
|
* (`src/commands/secrets.ts`): `ssh -o BatchMode=yes <host> bash -lc '<cmd>'`,
|
|
14
22
|
* with `bash -lc` so the remote login PATH resolves `agents`.
|
|
15
23
|
*/
|
|
16
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';
|
|
17
28
|
import chalk from 'chalk';
|
|
29
|
+
import { getCacheDir } from '../state.js';
|
|
30
|
+
import { formatRelativeTime } from './relative-time.js';
|
|
18
31
|
/**
|
|
19
32
|
* SSH target: a bare ssh-config host alias (e.g. `yosemite-s1`) or `user@host`.
|
|
20
33
|
* The strict allowlist blocks shell metacharacters and a leading `-`, so a target
|
|
@@ -83,30 +96,124 @@ const SSH_OPTS = [
|
|
|
83
96
|
'-o', 'StrictHostKeyChecking=accept-new',
|
|
84
97
|
'-o', 'ConnectTimeout=10',
|
|
85
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
|
+
}
|
|
86
163
|
/**
|
|
87
164
|
* Run the current `agents sessions` invocation on one or more remote machines over
|
|
88
|
-
* SSH,
|
|
89
|
-
*
|
|
90
|
-
* `
|
|
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.
|
|
91
173
|
*/
|
|
92
174
|
export function runRemoteSessions(hosts, argv = process.argv) {
|
|
93
175
|
for (const host of hosts)
|
|
94
176
|
assertValidSshTarget(host); // fail fast on any bad target
|
|
95
|
-
const
|
|
177
|
+
const forwarded = buildForwardedArgs(argv, new Set(hosts));
|
|
178
|
+
const remoteCmd = buildRemoteCommand(forwarded);
|
|
96
179
|
const multi = hosts.length > 1;
|
|
97
180
|
let failures = 0;
|
|
98
181
|
for (const host of hosts) {
|
|
99
182
|
if (multi)
|
|
100
183
|
process.stdout.write(chalk.cyan(`\n── ${host} ──\n`));
|
|
101
|
-
const res = spawnSync('ssh', [...SSH_OPTS, host, remoteCmd], {
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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;
|
|
110
217
|
}
|
|
111
218
|
}
|
|
112
219
|
if (failures > 0)
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* speaks these types.
|
|
8
8
|
*/
|
|
9
9
|
/** Agents that store session data on disk and can be discovered by `agents sessions`. */
|
|
10
|
-
export type SessionAgentId = 'claude' | 'codex' | 'gemini' | 'opencode' | 'openclaw' | 'rush' | 'hermes' | 'grok' | 'kimi';
|
|
10
|
+
export type SessionAgentId = 'claude' | 'codex' | 'gemini' | 'opencode' | 'openclaw' | 'rush' | 'hermes' | 'grok' | 'kimi' | 'droid';
|
|
11
11
|
/** All agents with session discovery support, in display order. */
|
|
12
12
|
export declare const SESSION_AGENTS: SessionAgentId[];
|
|
13
13
|
/** A single normalized event within a session (message, tool call, thinking, etc.). */
|
|
@@ -7,4 +7,4 @@
|
|
|
7
7
|
* speaks these types.
|
|
8
8
|
*/
|
|
9
9
|
/** All agents with session discovery support, in display order. */
|
|
10
|
-
export const SESSION_AGENTS = ['claude', 'codex', 'gemini', 'opencode', 'openclaw', 'rush', 'hermes', 'grok', 'kimi'];
|
|
10
|
+
export const SESSION_AGENTS = ['claude', 'codex', 'gemini', 'opencode', 'openclaw', 'rush', 'hermes', 'grok', 'kimi', 'droid'];
|
|
@@ -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
|
+
}
|
|
@@ -67,6 +67,8 @@ 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;
|
|
71
|
+
export declare const loadSsh: ModuleLoader;
|
|
70
72
|
export declare const loadPull: ModuleLoader;
|
|
71
73
|
export declare const loadPush: ModuleLoader;
|
|
72
74
|
export declare const loadRepo: ModuleLoader;
|
|
@@ -45,6 +45,8 @@ 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;
|
|
49
|
+
export const loadSsh = async () => (await import('../../commands/ssh.js')).registerSshCommands;
|
|
48
50
|
export const loadPull = async () => (await import('../../commands/pull.js')).registerPullCommand;
|
|
49
51
|
export const loadPush = async () => (await import('../../commands/push.js')).registerPushCommand;
|
|
50
52
|
export const loadRepo = async () => (await import('../../commands/repo.js')).registerRepoCommands;
|
|
@@ -126,6 +128,9 @@ export const COMMAND_LOADERS = {
|
|
|
126
128
|
tmux: [loadTmux],
|
|
127
129
|
browser: [loadBrowser],
|
|
128
130
|
computer: [loadComputer],
|
|
131
|
+
hosts: [loadHosts],
|
|
132
|
+
ssh: [loadSsh],
|
|
133
|
+
devices: [loadSsh],
|
|
129
134
|
pull: [loadPull],
|
|
130
135
|
push: [loadPush],
|
|
131
136
|
repo: [loadRepo],
|
package/dist/lib/state.d.ts
CHANGED
|
@@ -152,6 +152,8 @@ export declare function getTeamsDir(): string;
|
|
|
152
152
|
export declare function getTeamsAgentsDir(): string;
|
|
153
153
|
/** Path to the team registry — list of named teams with timestamps. Durable runtime, per-machine. */
|
|
154
154
|
export declare function getTeamsRegistryPath(): string;
|
|
155
|
+
/** Path to the device registry — SSH device profiles with platform/auth metadata. Durable runtime, per-machine (host list + addresses are NOT pulled by `agents repo push`). */
|
|
156
|
+
export declare function getDevicesRegistryPath(): string;
|
|
155
157
|
/** Path to cloud dispatch cache (~/.agents/.cache/cloud/). */
|
|
156
158
|
export declare function getCloudDir(): string;
|
|
157
159
|
/** Path to terminal session metadata (~/.agents/.cache/terminals/). */
|
package/dist/lib/state.js
CHANGED
|
@@ -334,6 +334,8 @@ export function getTeamsDir() { return TEAMS_DIR; }
|
|
|
334
334
|
export function getTeamsAgentsDir() { return TEAMS_AGENTS_DIR; }
|
|
335
335
|
/** Path to the team registry — list of named teams with timestamps. Durable runtime, per-machine. */
|
|
336
336
|
export function getTeamsRegistryPath() { return path.join(HISTORY_DIR, 'teams', 'registry.json'); }
|
|
337
|
+
/** Path to the device registry — SSH device profiles with platform/auth metadata. Durable runtime, per-machine (host list + addresses are NOT pulled by `agents repo push`). */
|
|
338
|
+
export function getDevicesRegistryPath() { return path.join(HISTORY_DIR, 'devices', 'registry.json'); }
|
|
337
339
|
/** Path to cloud dispatch cache (~/.agents/.cache/cloud/). */
|
|
338
340
|
export function getCloudDir() { return CLOUD_DIR; }
|
|
339
341
|
/** Path to terminal session metadata (~/.agents/.cache/terminals/). */
|
package/dist/lib/types.d.ts
CHANGED
|
@@ -612,6 +612,27 @@ export interface Meta {
|
|
|
612
612
|
* lives separately in ~/.agents/.cache/browser/<profile>/.
|
|
613
613
|
*/
|
|
614
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;
|
|
615
636
|
}
|
|
616
637
|
/** Browser profile definition stored in agents.yaml. */
|
|
617
638
|
export interface BrowserProfileConfig {
|
package/dist/lib/versions.d.ts
CHANGED
|
@@ -241,12 +241,16 @@ export declare function resolveVersion(agent: AgentId, projectPath?: string): st
|
|
|
241
241
|
/**
|
|
242
242
|
* Normalize a user-supplied @version token across CLI subcommands.
|
|
243
243
|
*
|
|
244
|
-
* 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)
|
|
245
246
|
* "latest" -> highest installed version (process.exit if none installed)
|
|
246
247
|
* "oldest" -> lowest installed version (process.exit if none installed)
|
|
247
248
|
* "x.y.z" (installed) -> "x.y.z"
|
|
248
249
|
* "x.y.z" (not installed) -> process.exit with installed-list hint
|
|
249
250
|
*
|
|
251
|
+
* `pinned` is a synonym for `default`: both name the project pin / global
|
|
252
|
+
* default, which the caller resolves.
|
|
253
|
+
*
|
|
250
254
|
* Use this anywhere the user can type `agents <cmd> claude@<token>` to keep the
|
|
251
255
|
* vocabulary consistent. Subcommands with different semantics for `latest`
|
|
252
256
|
* (install/remove/use, where `latest` means npm-latest) keep their existing
|
|
@@ -255,7 +259,7 @@ export declare function resolveVersion(agent: AgentId, projectPath?: string): st
|
|
|
255
259
|
export declare function resolveVersionAlias(agent: AgentId, raw: string | undefined | null): string | undefined;
|
|
256
260
|
/**
|
|
257
261
|
* Loose variant of resolveVersionAlias for record-filter contexts (sessions,
|
|
258
|
-
* team history). Same `default`/`latest`/`oldest` semantics, but explicit
|
|
262
|
+
* team history). Same `default`/`pinned`/`latest`/`oldest` semantics, but explicit
|
|
259
263
|
* versions pass through unchanged so historical records of uninstalled versions
|
|
260
264
|
* remain queryable.
|
|
261
265
|
*/
|
package/dist/lib/versions.js
CHANGED
|
@@ -1363,19 +1363,23 @@ export function resolveVersion(agent, projectPath) {
|
|
|
1363
1363
|
/**
|
|
1364
1364
|
* Normalize a user-supplied @version token across CLI subcommands.
|
|
1365
1365
|
*
|
|
1366
|
-
* undefined / "" / "default"
|
|
1366
|
+
* undefined / "" / "default" / "pinned" -> undefined (caller falls back to project pin or global default)
|
|
1367
|
+
* "any" -> undefined (caller imposes no version constraint — e.g. resume across any version)
|
|
1367
1368
|
* "latest" -> highest installed version (process.exit if none installed)
|
|
1368
1369
|
* "oldest" -> lowest installed version (process.exit if none installed)
|
|
1369
1370
|
* "x.y.z" (installed) -> "x.y.z"
|
|
1370
1371
|
* "x.y.z" (not installed) -> process.exit with installed-list hint
|
|
1371
1372
|
*
|
|
1373
|
+
* `pinned` is a synonym for `default`: both name the project pin / global
|
|
1374
|
+
* default, which the caller resolves.
|
|
1375
|
+
*
|
|
1372
1376
|
* Use this anywhere the user can type `agents <cmd> claude@<token>` to keep the
|
|
1373
1377
|
* vocabulary consistent. Subcommands with different semantics for `latest`
|
|
1374
1378
|
* (install/remove/use, where `latest` means npm-latest) keep their existing
|
|
1375
1379
|
* parsing.
|
|
1376
1380
|
*/
|
|
1377
1381
|
export function resolveVersionAlias(agent, raw) {
|
|
1378
|
-
if (!raw || raw === 'default')
|
|
1382
|
+
if (!raw || raw === 'default' || raw === 'pinned' || raw === 'any')
|
|
1379
1383
|
return undefined;
|
|
1380
1384
|
if (raw === 'latest' || raw === 'oldest') {
|
|
1381
1385
|
const installed = listInstalledVersions(agent);
|
|
@@ -1399,12 +1403,12 @@ export function resolveVersionAlias(agent, raw) {
|
|
|
1399
1403
|
}
|
|
1400
1404
|
/**
|
|
1401
1405
|
* Loose variant of resolveVersionAlias for record-filter contexts (sessions,
|
|
1402
|
-
* team history). Same `default`/`latest`/`oldest` semantics, but explicit
|
|
1406
|
+
* team history). Same `default`/`pinned`/`latest`/`oldest` semantics, but explicit
|
|
1403
1407
|
* versions pass through unchanged so historical records of uninstalled versions
|
|
1404
1408
|
* remain queryable.
|
|
1405
1409
|
*/
|
|
1406
1410
|
export function resolveVersionAliasLoose(agent, raw) {
|
|
1407
|
-
if (!raw || raw === 'default')
|
|
1411
|
+
if (!raw || raw === 'default' || raw === 'pinned' || raw === 'any')
|
|
1408
1412
|
return undefined;
|
|
1409
1413
|
if (raw === 'latest' || raw === 'oldest') {
|
|
1410
1414
|
const installed = listInstalledVersions(agent);
|
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.29",
|
|
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",
|
package/scripts/postinstall.js
CHANGED
|
@@ -239,6 +239,10 @@ To enable version-aware shims, add this to your shell config:
|
|
|
239
239
|
console.log(` Installed shorthands: ${written.join(', ')}`);
|
|
240
240
|
}
|
|
241
241
|
|
|
242
|
+
if (process.platform !== 'win32') {
|
|
243
|
+
await ensureAgentsResolvablePosix();
|
|
244
|
+
}
|
|
245
|
+
|
|
242
246
|
await healLongRunningProcesses();
|
|
243
247
|
|
|
244
248
|
const version = getVersion();
|
|
@@ -252,6 +256,64 @@ To enable version-aware shims, add this to your shell config:
|
|
|
252
256
|
}
|
|
253
257
|
}
|
|
254
258
|
|
|
259
|
+
/**
|
|
260
|
+
* Make the `agents` command resolvable in a *login* shell on POSIX.
|
|
261
|
+
*
|
|
262
|
+
* `agents`/`ag` reach PATH only through npm's bin symlink in the npm global-bin
|
|
263
|
+
* dir. Under nvm (and other per-user node prefixes) that dir is missing from a
|
|
264
|
+
* non-interactive login shell's PATH, so `bash -lc 'agents …'` fails with
|
|
265
|
+
* command-not-found — which breaks `agents secrets export --host` (it runs
|
|
266
|
+
* `bash -lc 'agents secrets import …'` on the remote) and the routines daemon
|
|
267
|
+
* (src/lib/daemon.ts falls back to bare `agents`). This is the POSIX symmetric
|
|
268
|
+
* counterpart of the Windows branch in main() that registers npm's global-bin
|
|
269
|
+
* dir on the user PATH.
|
|
270
|
+
*
|
|
271
|
+
* Self-heal, not a prompt: it fires ONLY when `agents` is otherwise
|
|
272
|
+
* unresolvable — the genuinely-broken state — so it acts decisively, exactly
|
|
273
|
+
* like the Windows PATH registration. The symlink never clobbers a dev build
|
|
274
|
+
* (scripts/install.sh) or any real file. Skipped in CI (ephemeral homes) and
|
|
275
|
+
* when AGENTS_NO_HEAL=1.
|
|
276
|
+
*/
|
|
277
|
+
async function ensureAgentsResolvablePosix() {
|
|
278
|
+
if (process.env.CI || process.env.AGENTS_NO_HEAL === '1') return;
|
|
279
|
+
try {
|
|
280
|
+
const { localBinDir, ensureLocalBinSymlink, loginShellResolves, dirOnLoginPath } =
|
|
281
|
+
await import('../dist/lib/platform/posixpath.js');
|
|
282
|
+
|
|
283
|
+
// macOS/homebrew, system-node Linux, and dev-build users already resolve it.
|
|
284
|
+
if (loginShellResolves('agents')) return;
|
|
285
|
+
|
|
286
|
+
const binDir = localBinDir();
|
|
287
|
+
const results = ['agents', 'ag'].map((name) => ensureLocalBinSymlink(name, AGENTS_BIN, binDir));
|
|
288
|
+
if (!results.some((r) => r.created)) return; // nothing we could safely link
|
|
289
|
+
|
|
290
|
+
if (dirOnLoginPath(binDir)) {
|
|
291
|
+
console.log(`\n Linked 'agents' into ${binDir} (already on the login PATH) so 'bash -lc agents' resolves.`);
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// ~/.local/bin isn't on the *bash* login PATH yet. The consumers run
|
|
296
|
+
// `bash -lc`, so add it to the file a bash login shell reads (~/.bash_profile
|
|
297
|
+
// when present, else ~/.profile) — not the interactive $SHELL rc, which for a
|
|
298
|
+
// zsh user (.zshrc) bash would never source.
|
|
299
|
+
const bashRc = fs.existsSync(path.join(HOME, '.bash_profile'))
|
|
300
|
+
? path.join(HOME, '.bash_profile')
|
|
301
|
+
: path.join(HOME, '.profile');
|
|
302
|
+
const marker = '# agents-cli: ensure ~/.local/bin on PATH (so the agents command resolves)';
|
|
303
|
+
let already = false;
|
|
304
|
+
try {
|
|
305
|
+
already = fs.existsSync(bashRc) && fs.readFileSync(bashRc, 'utf-8').includes(marker);
|
|
306
|
+
} catch { /* unreadable rc — fall through and append */ }
|
|
307
|
+
if (!already) {
|
|
308
|
+
fs.appendFileSync(bashRc, `\n${marker}\nexport PATH="${binDir}:$PATH"\n`);
|
|
309
|
+
}
|
|
310
|
+
console.log(`\n Linked 'agents' into ${binDir} and added it to PATH in ${path.basename(bashRc)}.`);
|
|
311
|
+
console.log(` Restart your shell (or run: source ~/${path.basename(bashRc)}) to pick it up.`);
|
|
312
|
+
} catch {
|
|
313
|
+
/* best-effort: a failure here must never break the install */
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
255
317
|
/**
|
|
256
318
|
* Self-heal long-running processes onto the just-installed code (macOS).
|
|
257
319
|
*
|