@phnx-labs/agents-cli 1.20.26 → 1.20.27

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.
@@ -11,14 +11,14 @@
11
11
  import * as fs from 'fs';
12
12
  import * as path from 'path';
13
13
  import { execFileSync } from 'child_process';
14
- import { getPluginsDir, getTrashPluginsDir, getExtraPluginsDir, getProjectPluginsDir } from './state.js';
14
+ import { getPluginsDir, getTrashPluginsDir, getExtraPluginsDir, getProjectPluginsDir, getSystemPluginsDir } from './state.js';
15
15
  import { IS_WINDOWS, isWindowsAbsolutePath, homeDir } from './platform/index.js';
16
16
  import { assertSafeGitTransport } from './git.js';
17
17
  import { listInstalledVersions, getVersionHomePath } from './versions.js';
18
18
  import { AGENTS, agentConfigDirName } from './agents.js';
19
19
  import { capableAgents, isCapable } from './capabilities.js';
20
20
  import { shouldInstallCommandAsSkill, installCommandSkillToVersion } from './command-skills.js';
21
- import { copyPluginToMarketplace, syncMarketplaceManifest, registerMarketplace, unregisterMarketplace, addPluginToSettings, removePluginFromSettings, removePluginFromMarketplace, marketplaceIsEmpty, removeEmptyMarketplaceDir, isInstalledInMarketplace, marketplaceRoot, discoverMarketplaces, marketplaceNameFor, MARKETPLACE_NAME, PROJECT_MARKETPLACE_NAME, } from './plugin-marketplace.js';
21
+ import { copyPluginToMarketplace, syncMarketplaceManifest, registerMarketplace, unregisterMarketplace, addPluginToSettings, removePluginFromSettings, removePluginFromMarketplace, marketplaceIsEmpty, removeEmptyMarketplaceDir, isInstalledInMarketplace, marketplaceRoot, discoverMarketplaces, marketplaceNameFor, MARKETPLACE_NAME, PROJECT_MARKETPLACE_NAME, SYSTEM_MARKETPLACE_NAME, } from './plugin-marketplace.js';
22
22
  const PLUGIN_MANIFEST_DIR = '.claude-plugin';
23
23
  const PLUGIN_MANIFEST_FILE = 'plugin.json';
24
24
  const USER_CONFIG_FILE = '.user-config.json';
@@ -186,11 +186,20 @@ export function assertPluginTargetContained(targetRoot, pluginsDir) {
186
186
  }
187
187
  }
188
188
  /**
189
- * Get a specific plugin by name.
189
+ * Get a specific plugin by name. On a cross-marketplace name collision the
190
+ * highest-precedence scope wins (project > extra > user > system) — the same
191
+ * resolution the sync writer's Map(last-wins) dedupe and collectPluginScopes()
192
+ * use. discoverPlugins() yields low→high precedence order, so the LAST match is
193
+ * the winner; returning the first match would resolve to the lowest scope (e.g.
194
+ * a system plugin over the user's same-named one), which is exactly backwards.
190
195
  */
191
196
  export function getPlugin(name) {
192
197
  const plugins = discoverPlugins();
193
- return plugins.find(p => p.name === name) || null;
198
+ for (let i = plugins.length - 1; i >= 0; i--) {
199
+ if (plugins[i].name === name)
200
+ return plugins[i];
201
+ }
202
+ return null;
194
203
  }
195
204
  /**
196
205
  * Check if an agent supports a specific plugin.
@@ -382,13 +391,18 @@ export function checkPluginDependencies(manifest) {
382
391
  /**
383
392
  * Reconstruct a MarketplaceSpec from a marketplace name. The inverse of
384
393
  * marketplaceNameFor(): "agents-cli" → user, "agents-project" → project,
385
- * "agents-<alias>" → extra. The per-version marketplace operations only key off
386
- * the name (never spec.root), but we resolve the real source root anyway so the
387
- * spec is honest for any caller that inspects it.
394
+ * "agents-system" → system, "agents-<alias>" → extra. The per-version
395
+ * marketplace operations only key off the name (never spec.root), but we
396
+ * resolve the real source root anyway so the spec is honest for any caller that
397
+ * inspects it (e.g. descriptionFor, which would otherwise label the system
398
+ * marketplace as an extra repo named "system").
388
399
  */
389
400
  function marketplaceSpecForName(name, cwd = process.cwd()) {
390
401
  if (!name || name === MARKETPLACE_NAME)
391
402
  return { kind: 'user' };
403
+ if (name === SYSTEM_MARKETPLACE_NAME) {
404
+ return { kind: 'system', root: getSystemPluginsDir() };
405
+ }
392
406
  if (name === PROJECT_MARKETPLACE_NAME) {
393
407
  return { kind: 'project', root: getProjectPluginsDir(cwd) ?? '' };
394
408
  }
@@ -1096,10 +1110,38 @@ export async function installPlugin(spec) {
1096
1110
  throw new Error(`Installed source has no valid .claude-plugin/plugin.json`);
1097
1111
  }
1098
1112
  const capabilities = inspectPluginCapabilities(targetRoot);
1099
- // Persist source for future updates
1100
- fs.writeFileSync(path.join(targetRoot, SOURCE_FILE), JSON.stringify({ source, isGit: !isLocalPath }), 'utf-8');
1113
+ // Persist source for future updates. `version` records the manifest version
1114
+ // at pull time a baseline that lets the heal path tell "central is an
1115
+ // untouched copy of upstream" (safe to fast-forward) from "the user edited
1116
+ // it" (leave alone) without hashing the whole tree.
1117
+ fs.writeFileSync(path.join(targetRoot, SOURCE_FILE), JSON.stringify({ source, isGit: !isLocalPath, version: manifest.version }), 'utf-8');
1101
1118
  return { name: manifest.name, root: targetRoot, isNew, capabilities };
1102
1119
  }
1120
+ /** Read a plugin's `.source` provenance, or null when absent/unreadable. */
1121
+ export function readPluginSourceInfo(root) {
1122
+ const f = path.join(root, SOURCE_FILE);
1123
+ if (!fs.existsSync(f))
1124
+ return null;
1125
+ try {
1126
+ return JSON.parse(fs.readFileSync(f, 'utf-8'));
1127
+ }
1128
+ catch {
1129
+ return null;
1130
+ }
1131
+ }
1132
+ /**
1133
+ * Resolve the CURRENT upstream manifest version for a local-sourced plugin
1134
+ * (the `.system`/local-path case). Returns null for git sources — reading their
1135
+ * upstream version would need a network fetch, so git plugins are refreshed only
1136
+ * via the explicit `agents plugins update`.
1137
+ */
1138
+ export function getUpstreamManifestVersion(info) {
1139
+ if (info.isGit)
1140
+ return null;
1141
+ const resolved = info.source.replace(/^~/, homeDir());
1142
+ const m = loadPluginManifest(resolved);
1143
+ return m?.version ?? null;
1144
+ }
1103
1145
  /**
1104
1146
  * Update an installed plugin by re-pulling from its original source.
1105
1147
  * Returns true if the update succeeded.
@@ -1136,11 +1178,14 @@ export async function updatePlugin(name) {
1136
1178
  : null;
1137
1179
  fs.rmSync(plugin.root, { recursive: true, force: true });
1138
1180
  fs.cpSync(resolvedSource, plugin.root, { recursive: true });
1139
- fs.writeFileSync(path.join(plugin.root, SOURCE_FILE), JSON.stringify(sourceInfo), 'utf-8');
1140
1181
  if (userConfigBackup !== null) {
1141
1182
  fs.writeFileSync(userConfigPath, userConfigBackup, 'utf-8');
1142
1183
  }
1143
1184
  }
1185
+ // Re-stamp .source with the freshly pulled manifest version so the baseline
1186
+ // tracks what's now on disk (keeps the heal "unmodified?" check honest).
1187
+ const freshVersion = loadPluginManifest(plugin.root)?.version;
1188
+ fs.writeFileSync(path.join(plugin.root, SOURCE_FILE), JSON.stringify({ ...sourceInfo, version: freshVersion }), 'utf-8');
1144
1189
  }
1145
1190
  catch (err) {
1146
1191
  return { success: false, error: err.message };
@@ -0,0 +1,33 @@
1
+ /**
2
+ * SSH target: a bare ssh-config host alias (e.g. `yosemite-s1`) or `user@host`.
3
+ * The strict allowlist blocks shell metacharacters and a leading `-`, so a target
4
+ * can never be smuggled in as an ssh argv flag.
5
+ */
6
+ export declare const SSH_TARGET_RE: RegExp;
7
+ export declare function assertValidSshTarget(host: string): void;
8
+ /** POSIX single-quote a string for safe interpolation into a remote shell command. */
9
+ export declare function shellQuote(s: string): string;
10
+ /**
11
+ * Strip the `--host`/`-H` flag (and its value) from a raw `agents sessions` argv,
12
+ * leaving the args to forward to the remote unchanged. The remote runs the same
13
+ * binary, so every other flag (`--since`, `--last`, `--json`, query, …) carries
14
+ * over for free. Handles every form commander accepts: `--host h`, `--host=h`,
15
+ * `-H h`, `-H=h`, and the glued short form `-Hh`.
16
+ *
17
+ * @param argv full process argv; the sessions args begin at index 2
18
+ * (`[runtime, script, 'sessions', ...]`).
19
+ */
20
+ export declare function buildForwardedArgs(argv: string[], hosts?: Set<string>): string[];
21
+ /**
22
+ * Build the single remote command string for `ssh <host> <cmd>`. Forwarded args
23
+ * are quoted for the inner login shell, then the whole `agents …` invocation is
24
+ * quoted again so it survives `bash -lc <...>`.
25
+ */
26
+ export declare function buildRemoteCommand(forwardedArgs: string[]): string;
27
+ /**
28
+ * Run the current `agents sessions` invocation on one or more remote machines over
29
+ * SSH, streaming each remote's output to the terminal. Sets `process.exitCode = 1`
30
+ * if any host fails. Reads the invocation from `process.argv` (override via
31
+ * `argv` for testing).
32
+ */
33
+ export declare function runRemoteSessions(hosts: string[], argv?: string[]): void;
@@ -0,0 +1,114 @@
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
+ * Mirrors the transport already used by `agents secrets export --to-ssh`
13
+ * (`src/commands/secrets.ts`): `ssh -o BatchMode=yes <host> bash -lc '<cmd>'`,
14
+ * with `bash -lc` so the remote login PATH resolves `agents`.
15
+ */
16
+ import { spawnSync } from 'child_process';
17
+ import chalk from 'chalk';
18
+ /**
19
+ * SSH target: a bare ssh-config host alias (e.g. `yosemite-s1`) or `user@host`.
20
+ * The strict allowlist blocks shell metacharacters and a leading `-`, so a target
21
+ * can never be smuggled in as an ssh argv flag.
22
+ */
23
+ export const SSH_TARGET_RE = /^[a-zA-Z0-9._-]+(@[a-zA-Z0-9._-]+)?$/;
24
+ export function assertValidSshTarget(host) {
25
+ if (!SSH_TARGET_RE.test(host)) {
26
+ throw new Error(`Invalid SSH target ${JSON.stringify(host)}. Expected a host alias or user@host ` +
27
+ `(letters, digits, '.', '_', '-').`);
28
+ }
29
+ }
30
+ /** POSIX single-quote a string for safe interpolation into a remote shell command. */
31
+ export function shellQuote(s) {
32
+ return `'${s.replace(/'/g, `'\\''`)}'`;
33
+ }
34
+ /**
35
+ * Strip the `--host`/`-H` flag (and its value) from a raw `agents sessions` argv,
36
+ * leaving the args to forward to the remote unchanged. The remote runs the same
37
+ * binary, so every other flag (`--since`, `--last`, `--json`, query, …) carries
38
+ * over for free. Handles every form commander accepts: `--host h`, `--host=h`,
39
+ * `-H h`, `-H=h`, and the glued short form `-Hh`.
40
+ *
41
+ * @param argv full process argv; the sessions args begin at index 2
42
+ * (`[runtime, script, 'sessions', ...]`).
43
+ */
44
+ export function buildForwardedArgs(argv, hosts = new Set()) {
45
+ const args = argv.slice(2);
46
+ const out = [];
47
+ for (let i = 0; i < args.length; i++) {
48
+ const a = args[i];
49
+ if (a === '--host' || a === '-H') {
50
+ // Commander's `<target...>` variadic accepts both `--host a --host b` and
51
+ // `--host a b` — consume every consecutive token that is a known host so
52
+ // the variadic form doesn't leak the extra hosts into the remote argv.
53
+ // Fall back to consuming the single next token when we have no host set
54
+ // (e.g. malformed input) so the flag value never leaks either way.
55
+ if (hosts.size > 0) {
56
+ while (i + 1 < args.length && hosts.has(args[i + 1]))
57
+ i++;
58
+ }
59
+ else {
60
+ i++; // also consume the separate value token
61
+ }
62
+ continue;
63
+ }
64
+ if (a.startsWith('--host=') || a.startsWith('-H='))
65
+ continue;
66
+ if (/^-H.+/.test(a))
67
+ continue; // glued short form: -Hyosemite-s1
68
+ out.push(a);
69
+ }
70
+ return out;
71
+ }
72
+ /**
73
+ * Build the single remote command string for `ssh <host> <cmd>`. Forwarded args
74
+ * are quoted for the inner login shell, then the whole `agents …` invocation is
75
+ * quoted again so it survives `bash -lc <...>`.
76
+ */
77
+ export function buildRemoteCommand(forwardedArgs) {
78
+ const inner = ['agents', ...forwardedArgs].map(shellQuote).join(' ');
79
+ return `bash -lc ${shellQuote(inner)}`;
80
+ }
81
+ const SSH_OPTS = [
82
+ '-o', 'BatchMode=yes',
83
+ '-o', 'StrictHostKeyChecking=accept-new',
84
+ '-o', 'ConnectTimeout=10',
85
+ ];
86
+ /**
87
+ * Run the current `agents sessions` invocation on one or more remote machines over
88
+ * SSH, streaming each remote's output to the terminal. Sets `process.exitCode = 1`
89
+ * if any host fails. Reads the invocation from `process.argv` (override via
90
+ * `argv` for testing).
91
+ */
92
+ export function runRemoteSessions(hosts, argv = process.argv) {
93
+ for (const host of hosts)
94
+ assertValidSshTarget(host); // fail fast on any bad target
95
+ const remoteCmd = buildRemoteCommand(buildForwardedArgs(argv, new Set(hosts)));
96
+ const multi = hosts.length > 1;
97
+ let failures = 0;
98
+ for (const host of hosts) {
99
+ if (multi)
100
+ process.stdout.write(chalk.cyan(`\n── ${host} ──\n`));
101
+ const res = spawnSync('ssh', [...SSH_OPTS, host, remoteCmd], { stdio: 'inherit' });
102
+ if (res.error) {
103
+ failures++;
104
+ console.error(chalk.red(`${host}: ${res.error.message}`));
105
+ continue;
106
+ }
107
+ if (res.status !== 0) {
108
+ failures++;
109
+ console.error(chalk.red(`${host}: remote query failed (exit ${res.status ?? 'signal'}).`));
110
+ }
111
+ }
112
+ if (failures > 0)
113
+ process.exitCode = 1;
114
+ }
@@ -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;
@@ -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;
@@ -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
  /**
@@ -898,14 +898,36 @@ export async function isOldestInstalled(agent) {
898
898
  }
899
899
  return { installed: isVersionInstalled(agent, oldestVersion), version: oldestVersion };
900
900
  }
901
+ // Per-process cache for listInstalledVersions. The agent's versions dir mtime
902
+ // changes whenever a version dir is added or removed (install/remove), so a
903
+ // stamp match means the installed set is unchanged and we skip the readdir +
904
+ // N binary stats. Mirrors the readMeta() cache in state.ts. Hot path:
905
+ // resolveAgentTargets and every enumerate-style consumer hit this.
906
+ const installedVersionsCache = new Map();
907
+ /** Drop the installed-versions cache (call after install/remove mutations). */
908
+ export function invalidateInstalledVersionsCache(agent) {
909
+ if (agent)
910
+ installedVersionsCache.delete(agent);
911
+ else
912
+ installedVersionsCache.clear();
913
+ }
901
914
  /**
902
- * List all installed versions for an agent.
915
+ * List all installed versions for an agent (cached by versions-dir mtime).
903
916
  */
904
917
  export function listInstalledVersions(agent) {
905
918
  const agentVersionsDir = path.join(getVersionsDir(), agent);
906
- if (!fs.existsSync(agentVersionsDir)) {
919
+ let stamp;
920
+ try {
921
+ stamp = fs.statSync(agentVersionsDir).mtimeMs;
922
+ }
923
+ catch {
924
+ installedVersionsCache.set(agent, { stamp: 0, versions: [] });
907
925
  return [];
908
926
  }
927
+ const cached = installedVersionsCache.get(agent);
928
+ if (cached && cached.stamp === stamp) {
929
+ return cached.versions;
930
+ }
909
931
  const entries = fs.readdirSync(agentVersionsDir, { withFileTypes: true });
910
932
  const versions = [];
911
933
  for (const entry of entries) {
@@ -916,7 +938,9 @@ export function listInstalledVersions(agent) {
916
938
  }
917
939
  }
918
940
  }
919
- return versions.sort(compareVersions);
941
+ versions.sort(compareVersions);
942
+ installedVersionsCache.set(agent, { stamp, versions });
943
+ return versions;
920
944
  }
921
945
  /**
922
946
  * List every version directory for an agent, including ones missing the
@@ -1831,7 +1855,15 @@ export function syncResourcesToVersion(agent, version, selection, options = {})
1831
1855
  // dot-dirs to keep plugin-managed subtrees (.plugins/, .promptcuts) intact.
1832
1856
  const skillsTargetSweep = path.join(agentDir, 'skills');
1833
1857
  if (!userPassedSelection && fs.existsSync(skillsTargetSweep) && !fs.lstatSync(skillsTargetSweep).isSymbolicLink()) {
1858
+ // Trust real skills AND command-skills: when commandsAsSkills, the
1859
+ // commands writer (above) materialized each command as a skill dir under
1860
+ // skills/. Those names are not in skillsToSync, so without this they'd be
1861
+ // swept as orphans — silently deleting every converted command (e.g.
1862
+ // /recap on kimi/grok).
1834
1863
  const trustedSkills = new Set(skillsToSync);
1864
+ if (commandsAsSkills)
1865
+ for (const cmd of commandsToSync)
1866
+ trustedSkills.add(cmd);
1835
1867
  for (const entry of fs.readdirSync(skillsTargetSweep, { withFileTypes: true })) {
1836
1868
  if (!entry.isDirectory() || entry.name.startsWith('.'))
1837
1869
  continue;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@phnx-labs/agents-cli",
3
- "version": "1.20.26",
3
+ "version": "1.20.27",
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",
@@ -92,10 +92,10 @@
92
92
  "diff": "9.0.0",
93
93
  "marked": "15.0.12",
94
94
  "marked-terminal": "7.3.0",
95
- "ora": "9.4.0",
95
+ "ora": "9.4.1",
96
96
  "proper-lockfile": "4.1.2",
97
97
  "simple-git": "3.36.0",
98
- "smol-toml": "1.6.1",
98
+ "smol-toml": "1.7.0",
99
99
  "yaml": "2.9.0"
100
100
  },
101
101
  "devDependencies": {