@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.
Files changed (95) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/dist/commands/doctor.d.ts +5 -2
  3. package/dist/commands/doctor.js +182 -30
  4. package/dist/commands/exec.d.ts +1 -1
  5. package/dist/commands/exec.js +177 -6
  6. package/dist/commands/hosts.d.ts +11 -0
  7. package/dist/commands/hosts.js +229 -0
  8. package/dist/commands/repo.d.ts +29 -0
  9. package/dist/commands/repo.js +174 -38
  10. package/dist/commands/secrets.d.ts +2 -7
  11. package/dist/commands/secrets.js +15 -23
  12. package/dist/commands/sessions.d.ts +2 -0
  13. package/dist/commands/sessions.js +27 -25
  14. package/dist/commands/sync.d.ts +2 -0
  15. package/dist/commands/sync.js +22 -5
  16. package/dist/commands/view.js +27 -11
  17. package/dist/index.js +4 -13
  18. package/dist/lib/agent-spec.d.ts +36 -0
  19. package/dist/lib/agent-spec.js +157 -0
  20. package/dist/lib/agents.d.ts +1 -0
  21. package/dist/lib/agents.js +45 -4
  22. package/dist/lib/browser/drivers/ssh.d.ts +47 -2
  23. package/dist/lib/browser/drivers/ssh.js +113 -24
  24. package/dist/lib/browser/profiles.js +28 -1
  25. package/dist/lib/browser/runtime-state.js +28 -8
  26. package/dist/lib/browser/types.d.ts +10 -1
  27. package/dist/lib/cli-resources.js +10 -1
  28. package/dist/lib/daemon.js +32 -0
  29. package/dist/lib/doctor-diff.d.ts +19 -0
  30. package/dist/lib/doctor-diff.js +107 -15
  31. package/dist/lib/exec.d.ts +27 -0
  32. package/dist/lib/exec.js +62 -19
  33. package/dist/lib/heal.d.ts +107 -0
  34. package/dist/lib/heal.js +279 -0
  35. package/dist/lib/hooks.d.ts +17 -0
  36. package/dist/lib/hooks.js +127 -3
  37. package/dist/lib/hosts/dispatch.d.ts +26 -0
  38. package/dist/lib/hosts/dispatch.js +71 -0
  39. package/dist/lib/hosts/progress.d.ts +21 -0
  40. package/dist/lib/hosts/progress.js +49 -0
  41. package/dist/lib/hosts/providers/local.d.ts +17 -0
  42. package/dist/lib/hosts/providers/local.js +81 -0
  43. package/dist/lib/hosts/ready.d.ts +37 -0
  44. package/dist/lib/hosts/ready.js +88 -0
  45. package/dist/lib/hosts/registry.d.ts +22 -0
  46. package/dist/lib/hosts/registry.js +65 -0
  47. package/dist/lib/hosts/ssh-config.d.ts +37 -0
  48. package/dist/lib/hosts/ssh-config.js +157 -0
  49. package/dist/lib/hosts/tasks.d.ts +32 -0
  50. package/dist/lib/hosts/tasks.js +58 -0
  51. package/dist/lib/hosts/types.d.ts +51 -0
  52. package/dist/lib/hosts/types.js +21 -0
  53. package/dist/lib/loop.d.ts +9 -0
  54. package/dist/lib/loop.js +13 -1
  55. package/dist/lib/mcp.js +12 -3
  56. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  57. package/dist/lib/migrate.js +9 -5
  58. package/dist/lib/platform/exec.d.ts +10 -0
  59. package/dist/lib/platform/exec.js +17 -0
  60. package/dist/lib/platform/index.d.ts +1 -0
  61. package/dist/lib/platform/index.js +1 -0
  62. package/dist/lib/platform/links.d.ts +15 -0
  63. package/dist/lib/platform/links.js +42 -0
  64. package/dist/lib/platform/paths.d.ts +18 -0
  65. package/dist/lib/platform/paths.js +22 -0
  66. package/dist/lib/platform/posixpath.d.ts +28 -0
  67. package/dist/lib/platform/posixpath.js +153 -0
  68. package/dist/lib/plugin-marketplace.d.ts +18 -0
  69. package/dist/lib/plugin-marketplace.js +67 -1
  70. package/dist/lib/plugins.d.ts +33 -1
  71. package/dist/lib/plugins.js +56 -11
  72. package/dist/lib/project-launch.js +6 -3
  73. package/dist/lib/sandbox.js +5 -2
  74. package/dist/lib/self-update.js +7 -2
  75. package/dist/lib/session/db.d.ts +23 -0
  76. package/dist/lib/session/db.js +76 -1
  77. package/dist/lib/session/discover.d.ts +26 -0
  78. package/dist/lib/session/discover.js +75 -4
  79. package/dist/lib/session/relative-time.d.ts +7 -0
  80. package/dist/lib/session/relative-time.js +28 -0
  81. package/dist/lib/session/remote.d.ts +61 -0
  82. package/dist/lib/session/remote.js +221 -0
  83. package/dist/lib/ssh-exec.d.ts +45 -0
  84. package/dist/lib/ssh-exec.js +61 -0
  85. package/dist/lib/staleness/detectors/commands.js +7 -6
  86. package/dist/lib/staleness/writers/commands.js +7 -12
  87. package/dist/lib/startup/command-registry.d.ts +1 -0
  88. package/dist/lib/startup/command-registry.js +2 -0
  89. package/dist/lib/startup/dev-build.d.ts +22 -0
  90. package/dist/lib/startup/dev-build.js +41 -0
  91. package/dist/lib/types.d.ts +28 -0
  92. package/dist/lib/versions.d.ts +9 -3
  93. package/dist/lib/versions.js +43 -7
  94. package/package.json +3 -3
  95. 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 grok and
4
- * Codex >= 0.117.0; falls back to scanning `{agentDir}/<commandsSubdir>/` for
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 — see that file for the
33
- // openclaw vs grok asymmetry.
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 === '') && id !== 'grok')
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. The signal is an
84
- // empty `commandsSubdir`: there's no directory to write to AND the agent
85
- // doesn't want commands-as-skills either (it has its own runtime command
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
- // Native non-file slash-command runtime no version-home write.
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
- // Grok has empty commandsSubdir AND wants commands-as-skills.
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
+ }
@@ -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 {
@@ -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" -> undefined (caller falls back to project pin or global 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
  */