@phnx-labs/agents-cli 1.20.3 → 1.20.4

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 CHANGED
@@ -1,5 +1,13 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.20.4
4
+
5
+ **Plugin marketplace sync (skip outside-pointing symlinks)**
6
+
7
+ - `copyPluginToMarketplace` used `fs.cpSync(plugin.root, dest, { recursive: true, dereference: false })`, which faithfully preserved every symlink — including the ones plugin authors put at the top of their plugin source for prompt-side references (the rush plugin's `app -> ../../../rush/app`, `web -> rush/web`, `widgets -> rush/widgets`). Those targets resolve to the rush monorepo (~8.7 GB of `app/` including node_modules + .next builds, 782 MB of `web/`, plus 463 MB brand-assets). Every claude version got a full set of those symlinks in `~/.claude/plugins/marketplaces/agents-cli/plugins/rush/`. When the consumer (Claude Code, OpenClaw) discovers plugins, it walks the marketplace tree and follows those symlinks — producing multi-minute startup hangs.
8
+ - The copy now walks the source tree and drops symlinks whose `realpath` escapes the plugin root, leaving internal symlinks intact (cpSync rewrites internal targets to absolute paths into the source tree, which the consumer still resolves correctly). One informational line per plugin lists the skipped names so plugin authors notice.
9
+ - Existing per-version marketplace directories still hold the bloat from prior syncs; clean up with `rm` against `~/.claude/plugins/marketplaces/agents-cli/plugins/*/{app,web,widgets,*-symlinks-that-escaped}` then re-run `agents pull` or any plugin sync to re-copy with the filter.
10
+
3
11
  ## 1.20.3
4
12
 
5
13
  **`agents run` startup latency (stale-while-revalidate the usage probe + memoize agents.yaml)**
@@ -23,12 +23,13 @@
23
23
  import chalk from 'chalk';
24
24
  import ora from 'ora';
25
25
  import * as fs from 'fs';
26
+ import * as os from 'os';
26
27
  import * as path from 'path';
27
28
  import { confirm } from '@inquirer/prompts';
28
29
  import { ALL_AGENT_IDS } from '../lib/agents.js';
29
30
  import { AGENTS, getCliPath, getCliVersion, agentLabel } from '../lib/agents.js';
30
31
  import { getVersionDir } from '../lib/versions.js';
31
- import { finalizeImport, importAgentBinary, importAgentConfig, isValidImportVersion, resolvePackageDirFromBinary, } from '../lib/import.js';
32
+ import { finalizeImport, importAgentBinary, importAgentConfig, importInstallScriptBinary, isValidImportVersion, resolvePackageDirFromBinary, } from '../lib/import.js';
32
33
  import { isPromptCancelled, isInteractiveTerminal } from './utils.js';
33
34
  function isValidAgentId(value) {
34
35
  return ALL_AGENT_IDS.includes(value);
@@ -41,54 +42,94 @@ async function runImport(agentArg, opts) {
41
42
  }
42
43
  const agentId = agentArg;
43
44
  const agent = AGENTS[agentId];
44
- // Reject agents that don't ship via npm before we spin up PATH lookups and
45
- // prompts. cursor/kiro/goose/roo all have npmPackage='' and use custom
46
- // install scripts the symlink-farm import doesn't apply to them.
47
- if (!agent.npmPackage) {
48
- console.error(chalk.red(`${agentLabel(agentId)} doesn't install via npm — \`agents import\` only handles npm-style packages.`));
49
- console.error(chalk.gray(`Use \`agents add ${agentId}\` to install via its native script.`));
50
- process.exit(1);
51
- }
45
+ // installScript-based agents (Grok, Antigravity, Cursor, Kiro, Goose, Roo)
46
+ // don't have an npm package; their binary lives wherever the curl/brew
47
+ // installer dropped it. We adopt by symlinking that PATH binary directly
48
+ // into the version's `node_modules/.bin/`. No package.json walk.
49
+ const isInstallScriptAgent = !agent.npmPackage;
52
50
  let globalPath = null;
51
+ let installScriptBinary = null;
53
52
  if (opts.fromPath) {
54
53
  globalPath = path.resolve(opts.fromPath);
55
54
  if (!fs.existsSync(globalPath)) {
56
55
  console.error(chalk.red(`Path does not exist: ${globalPath}`));
57
56
  process.exit(1);
58
57
  }
58
+ if (isInstallScriptAgent) {
59
+ // With --from-path on an installScript agent, the path is the binary
60
+ // itself (or a directory containing it). Accept either.
61
+ if (fs.statSync(globalPath).isDirectory()) {
62
+ const candidate = path.join(globalPath, agent.cliCommand);
63
+ if (!fs.existsSync(candidate)) {
64
+ console.error(chalk.red(`No "${agent.cliCommand}" in ${globalPath}`));
65
+ process.exit(1);
66
+ }
67
+ installScriptBinary = candidate;
68
+ }
69
+ else {
70
+ installScriptBinary = globalPath;
71
+ }
72
+ }
59
73
  }
60
74
  else {
61
75
  const binary = await getCliPath(agentId);
62
76
  if (!binary) {
63
- // Use || (not ??) so empty-string npmPackage falls back to cliCommand.
64
- // Defensive: agents with empty npmPackage are rejected above, but keep
65
- // the operator correct in case that early check is ever relaxed.
66
- const installName = agent.npmPackage || agent.cliCommand;
77
+ const installHint = isInstallScriptAgent
78
+ ? `Run \`agents add ${agentId}\` to install via the official script, or pass --from-path.`
79
+ : `Install it first (e.g. \`npm i -g ${agent.npmPackage || agent.cliCommand}\`) or pass --from-path.`;
67
80
  console.error(chalk.red(`No "${agent.cliCommand}" found on PATH.`));
68
- console.error(chalk.gray(`Install it first (e.g. \`npm i -g ${installName}\`) or pass --from-path.`));
81
+ console.error(chalk.gray(installHint));
69
82
  process.exit(1);
70
83
  }
71
- globalPath = resolvePackageDirFromBinary(binary);
72
- if (!globalPath) {
73
- console.error(chalk.red(`Could not resolve npm package for binary: ${binary}`));
74
- console.error(chalk.gray('Pass --from-path <dir> with the package directory explicitly.'));
75
- process.exit(1);
84
+ if (isInstallScriptAgent) {
85
+ installScriptBinary = binary;
86
+ }
87
+ else {
88
+ globalPath = resolvePackageDirFromBinary(binary);
89
+ if (!globalPath) {
90
+ console.error(chalk.red(`Could not resolve npm package for binary: ${binary}`));
91
+ console.error(chalk.gray('Pass --from-path <dir> with the package directory explicitly.'));
92
+ process.exit(1);
93
+ }
94
+ }
95
+ }
96
+ // For Grok, the binary on PATH is typically `~/.grok/bin/grok` (a moving
97
+ // pointer to the latest install). Prefer the exact versioned file in
98
+ // `~/.grok/downloads/` so the v<x.y.z> alias is pinned to that file and
99
+ // doesn't drift when the user upgrades externally.
100
+ if (isInstallScriptAgent && agentId === 'grok' && !opts.fromPath) {
101
+ const detected = await getCliVersion(agentId);
102
+ if (detected) {
103
+ const downloads = path.join(os.homedir(), '.grok', 'downloads');
104
+ try {
105
+ const entries = fs.readdirSync(downloads);
106
+ const exact = entries.find((e) => e.startsWith('grok-') && e.includes(detected));
107
+ if (exact) {
108
+ installScriptBinary = path.join(downloads, exact);
109
+ }
110
+ }
111
+ catch {
112
+ /* fall back to PATH binary already set above */
113
+ }
76
114
  }
77
115
  }
78
116
  let version = opts.version;
79
117
  if (!version) {
80
- try {
81
- const pkg = JSON.parse(fs.readFileSync(path.join(globalPath, 'package.json'), 'utf8'));
82
- version = typeof pkg.version === 'string' ? pkg.version : undefined;
83
- }
84
- catch {
85
- /* fall through */
118
+ if (!isInstallScriptAgent && globalPath) {
119
+ try {
120
+ const pkg = JSON.parse(fs.readFileSync(path.join(globalPath, 'package.json'), 'utf8'));
121
+ version = typeof pkg.version === 'string' ? pkg.version : undefined;
122
+ }
123
+ catch {
124
+ /* fall through */
125
+ }
86
126
  }
87
127
  // Only fall back to running the PATH binary's --version when we're
88
- // auto-detecting. With --from-path, the PATH binary may belong to a
89
- // different install entirely; reporting its version here would silently
90
- // mis-attribute the imported version.
91
- if (!version && !opts.fromPath) {
128
+ // auto-detecting. With --from-path on an npm agent, the PATH binary may
129
+ // belong to a different install entirely; reporting its version here
130
+ // would silently mis-attribute the imported version. installScript agents
131
+ // always use `<bin> --version` since they have no package.json to read.
132
+ if (!version && (isInstallScriptAgent || !opts.fromPath)) {
92
133
  const detected = await getCliVersion(agentId);
93
134
  version = detected ?? undefined;
94
135
  }
@@ -104,8 +145,9 @@ async function runImport(agentArg, opts) {
104
145
  process.exit(1);
105
146
  }
106
147
  const versionDir = getVersionDir(agentId, version);
148
+ const fromLabel = isInstallScriptAgent ? installScriptBinary : globalPath;
107
149
  console.log(chalk.bold(`\nImport ${agentLabel(agentId)} v${version}`));
108
- console.log(` from: ${chalk.gray(globalPath)}`);
150
+ console.log(` from: ${chalk.gray(fromLabel)}`);
109
151
  console.log(` into: ${chalk.gray(versionDir)}`);
110
152
  const configDirExists = fs.existsSync(agent.configDir);
111
153
  let configAlreadyManaged = false;
@@ -146,7 +188,8 @@ async function runImport(agentArg, opts) {
146
188
  const cfgSpinner = ora(`Importing config dir for ${agentLabel(agentId)} v${version}...`).start();
147
189
  const cfgResult = await importAgentConfig(agentId, version);
148
190
  if (cfgResult.success) {
149
- cfgSpinner.succeed(`Config imported (${agent.configDir} -> ${versionDir}/home/.${agentId})`);
191
+ const relConfig = path.relative(os.homedir(), agent.configDir);
192
+ cfgSpinner.succeed(`Config imported (${agent.configDir} -> ${versionDir}/home/${relConfig})`);
150
193
  }
151
194
  else if (cfgResult.skipped) {
152
195
  cfgSpinner.warn(`Config: ${cfgResult.error}`);
@@ -157,9 +200,11 @@ async function runImport(agentArg, opts) {
157
200
  }
158
201
  }
159
202
  const binSpinner = ora(`Registering ${agentLabel(agentId)} v${version} binary...`).start();
160
- const binResult = importAgentBinary({ agentId, npmPackage: agent.npmPackage, cliCommand: agent.cliCommand }, version, globalPath, versionDir);
203
+ const binResult = isInstallScriptAgent
204
+ ? importInstallScriptBinary({ agentId, npmPackage: agent.npmPackage, cliCommand: agent.cliCommand }, version, installScriptBinary, versionDir)
205
+ : importAgentBinary({ agentId, npmPackage: agent.npmPackage, cliCommand: agent.cliCommand }, version, globalPath, versionDir);
161
206
  if (binResult.success) {
162
- binSpinner.succeed(`Binary registered (${agent.cliCommand} -> ${globalPath})`);
207
+ binSpinner.succeed(`Binary registered (${agent.cliCommand} -> ${binResult.resolvedFromPath})`);
163
208
  }
164
209
  else if (binResult.skipped) {
165
210
  binSpinner.warn(`Binary: ${binResult.error}`);
@@ -198,11 +243,19 @@ Examples:
198
243
  $ agents import openclaw --version 2026.3.8 Pin a version label
199
244
  $ agents import openclaw --from-path /opt/homebrew/lib/node_modules/openclaw
200
245
 
246
+ # installScript-based agents (curl/brew installers, no npm package):
247
+ $ agents import grok Adopt ~/.grok/downloads/grok-<ver>
248
+ $ agents import antigravity Adopt ~/.local/bin/agy
249
+ $ agents import cursor Adopt ~/.local/bin/cursor-agent
250
+ $ agents import antigravity --from-path ~/.local/bin/agy
251
+
201
252
  When to use:
202
- When an agent CLI is already installed globally via npm or homebrew and you
203
- want to bring it under agents-cli management without reinstalling. Creates a
204
- symlink farm pointing at the existing install — nothing is copied or moved
205
- (except the agent's config dir, which is moved into the version's home).
253
+ When an agent CLI is already installed globally and you want to bring it
254
+ under agents-cli management without reinstalling. Creates a symlink farm
255
+ pointing at the existing install — nothing is copied or moved (except the
256
+ agent's config dir, which is moved into the version's home). Works for both
257
+ npm-style packages (claude, codex, gemini, opencode, openclaw) and
258
+ installScript-based agents (grok, antigravity, cursor, kiro, goose, roo).
206
259
  `)
207
260
  .action(runImport);
208
261
  }
@@ -455,6 +455,11 @@ async function showInstalledVersions(filterAgentId) {
455
455
  if (agent.npmPackage && cliState?.version) {
456
456
  console.log(chalk.gray(` Manage: agents add ${agentId}@${cliState.version} -y`));
457
457
  }
458
+ else if (!agent.npmPackage && cliState?.installed) {
459
+ // installScript-based agent already on PATH — direct users to adopt the
460
+ // existing install with `agents import` instead of re-running curl.
461
+ console.log(chalk.gray(` Adopt: agents import ${agentId}`));
462
+ }
458
463
  console.log();
459
464
  }
460
465
  }
@@ -55,13 +55,25 @@ function saveCliVersionCache() {
55
55
  /* best-effort cache persist */
56
56
  }
57
57
  }
58
- /** Synchronous PATH search -- no subprocess. Returns first matching binary path. */
58
+ /**
59
+ * Synchronous PATH search -- no subprocess. Returns first matching binary path.
60
+ *
61
+ * Skips our own shims dir (`~/.agents/.cache/shims/`) — those shims are
62
+ * dispatch helpers, not real installs. Counting them as installed produced a
63
+ * false positive where agents with NO real binary on the host (e.g. a
64
+ * never-installed Cursor whose only PATH entry was our `cursor-agent` shim
65
+ * dispatcher) showed up under `agents view`'s "Not Managed by Agents CLI"
66
+ * section, even though the user had nothing to import.
67
+ */
59
68
  function findInPath(command) {
60
69
  const pathEnv = process.env.PATH || '';
61
70
  const pathExt = process.platform === 'win32' ? (process.env.PATHEXT || '').split(';') : [''];
71
+ const shimsDir = getShimsDir();
62
72
  for (const dir of pathEnv.split(path.delimiter)) {
63
73
  if (!dir)
64
74
  continue;
75
+ if (path.resolve(dir) === path.resolve(shimsDir))
76
+ continue;
65
77
  for (const ext of pathExt) {
66
78
  const full = path.join(dir, command + ext);
67
79
  try {
@@ -507,8 +519,16 @@ async function getCachedVersionForBinary(agentId, binaryPath) {
507
519
  /* version command failed */
508
520
  version = null;
509
521
  }
510
- cache[agentId] = { binaryPath, mtime, version };
511
- saveCliVersionCache();
522
+ // Skip persisting null results the most common cause is a transient
523
+ // `--version` failure (slow startup, stdout race, etc.). A sticky-null
524
+ // entry kept users in a broken state where every subsequent
525
+ // `getCachedVersionForBinary` short-circuited to null forever, even
526
+ // after the binary started working. Re-probing on the next call costs
527
+ // one execFile; persisting null costs the whole feature.
528
+ if (version !== null) {
529
+ cache[agentId] = { binaryPath, mtime, version };
530
+ saveCliVersionCache();
531
+ }
512
532
  return version;
513
533
  }
514
534
  /**
@@ -81,6 +81,27 @@ export interface AgentBinarySpec {
81
81
  * node_modules/.bin/{cliCommand} -> {binaryEntry}
82
82
  */
83
83
  export declare function importAgentBinary(spec: AgentBinarySpec, version: string, globalPath: string, versionDir: string): ImportBinaryResult;
84
+ /**
85
+ * Register an existing installScript-based binary (Grok, Antigravity, Cursor,
86
+ * etc. — anything with `npmPackage: ''` and a curl/brew installer) under the
87
+ * managed version path. Unlike `importAgentBinary` this skips the npm
88
+ * package.json walk and just symlinks the resolved PATH binary directly into
89
+ * `{versionDir}/node_modules/.bin/{cliCommand}`. The symlink is what makes
90
+ * `listInstalledVersions` consider the version Managed.
91
+ *
92
+ * Layout produced:
93
+ *
94
+ * {versionDir}/
95
+ * package.json # marker (private, imported, from)
96
+ * home/ # empty isolated $HOME
97
+ * node_modules/.bin/{cliCommand} -> {binaryPath}
98
+ *
99
+ * For agents whose binary lookup is special-cased elsewhere (e.g. Grok's
100
+ * `~/.grok/downloads/`), the symlink is still created — `getBinaryPath` won't
101
+ * read it for those agents, but it documents provenance and lets a future
102
+ * refactor consolidate the binary-resolution registry.
103
+ */
104
+ export declare function importInstallScriptBinary(spec: AgentBinarySpec, version: string, binaryPath: string, versionDir: string): ImportBinaryResult;
84
105
  /**
85
106
  * Resolve the on-disk npm package directory for an agent's CLI binary by
86
107
  * walking up from the binary, following any symlinks. Returns null if the
@@ -17,6 +17,7 @@
17
17
  * true.
18
18
  */
19
19
  import * as fs from 'fs';
20
+ import * as os from 'os';
20
21
  import * as path from 'path';
21
22
  import { AGENTS } from './agents.js';
22
23
  import { getVersionsDir } from './state.js';
@@ -42,12 +43,17 @@ export async function importAgentConfig(agentId, version) {
42
43
  const configDir = agent.configDir;
43
44
  const versionsDir = getVersionsDir();
44
45
  const versionHome = path.join(versionsDir, agentId, version, 'home');
45
- const versionConfigDir = path.join(versionHome, `.${agentId}`);
46
+ // Match the shim's derivation in generateShimScript: the per-version config
47
+ // path mirrors the original configDir's path relative to $HOME. Hardcoding
48
+ // `.${agentId}` broke for nested configDirs like Antigravity
49
+ // (`~/.gemini/antigravity-cli`) — the destination would be `.antigravity`,
50
+ // mismatching the shim's expectation of `.gemini/antigravity-cli`.
51
+ const versionConfigDir = path.join(versionHome, path.relative(os.homedir(), configDir));
46
52
  if (fs.existsSync(versionConfigDir)) {
47
53
  return { success: false, skipped: true, error: `${version} already installed` };
48
54
  }
49
55
  try {
50
- fs.mkdirSync(versionHome, { recursive: true });
56
+ fs.mkdirSync(path.dirname(versionConfigDir), { recursive: true });
51
57
  fs.renameSync(configDir, versionConfigDir);
52
58
  fs.symlinkSync(versionConfigDir, configDir);
53
59
  setGlobalDefault(agentId, version);
@@ -155,6 +161,53 @@ export function importAgentBinary(spec, version, globalPath, versionDir) {
155
161
  return { success: false, error: err.message };
156
162
  }
157
163
  }
164
+ /**
165
+ * Register an existing installScript-based binary (Grok, Antigravity, Cursor,
166
+ * etc. — anything with `npmPackage: ''` and a curl/brew installer) under the
167
+ * managed version path. Unlike `importAgentBinary` this skips the npm
168
+ * package.json walk and just symlinks the resolved PATH binary directly into
169
+ * `{versionDir}/node_modules/.bin/{cliCommand}`. The symlink is what makes
170
+ * `listInstalledVersions` consider the version Managed.
171
+ *
172
+ * Layout produced:
173
+ *
174
+ * {versionDir}/
175
+ * package.json # marker (private, imported, from)
176
+ * home/ # empty isolated $HOME
177
+ * node_modules/.bin/{cliCommand} -> {binaryPath}
178
+ *
179
+ * For agents whose binary lookup is special-cased elsewhere (e.g. Grok's
180
+ * `~/.grok/downloads/`), the symlink is still created — `getBinaryPath` won't
181
+ * read it for those agents, but it documents provenance and lets a future
182
+ * refactor consolidate the binary-resolution registry.
183
+ */
184
+ export function importInstallScriptBinary(spec, version, binaryPath, versionDir) {
185
+ const binaryLink = path.join(versionDir, 'node_modules', '.bin', spec.cliCommand);
186
+ let alreadyExists = false;
187
+ try {
188
+ fs.lstatSync(binaryLink);
189
+ alreadyExists = true;
190
+ }
191
+ catch {
192
+ /* not present */
193
+ }
194
+ if (alreadyExists) {
195
+ return { success: false, skipped: true, error: `${version} already installed`, resolvedFromPath: binaryPath };
196
+ }
197
+ if (!fs.existsSync(binaryPath)) {
198
+ return { success: false, error: `Binary does not exist: ${binaryPath}` };
199
+ }
200
+ try {
201
+ fs.mkdirSync(path.join(versionDir, 'home'), { recursive: true });
202
+ fs.mkdirSync(path.join(versionDir, 'node_modules', '.bin'), { recursive: true });
203
+ fs.writeFileSync(path.join(versionDir, 'package.json'), JSON.stringify({ name: `agents-${spec.agentId}-${version}`, version: '1.0.0', private: true, imported: true, from: binaryPath, installScriptBased: true }, null, 2));
204
+ fs.symlinkSync(binaryPath, binaryLink);
205
+ return { success: true, resolvedFromPath: binaryPath };
206
+ }
207
+ catch (err) {
208
+ return { success: false, error: err.message };
209
+ }
210
+ }
158
211
  /**
159
212
  * Resolve the on-disk npm package directory for an agent's CLI binary by
160
213
  * walking up from the binary, following any symlinks. Returns null if the
@@ -45,6 +45,16 @@ export declare function knownMarketplacesPath(agent: AgentId, versionHome: strin
45
45
  /**
46
46
  * Copy plugin source into marketplace install dir.
47
47
  * Source of truth remains ~/.agents/plugins/<name>/ — this is a per-version snapshot.
48
+ *
49
+ * Symlinks pointing OUTSIDE the plugin source root are dropped. They show up
50
+ * when plugin authors (legitimately) link prompt-side references to sibling
51
+ * codebases — e.g. the rush plugin's `app -> ../../../rush/app` for @app/...
52
+ * autocomplete in user prompts. Faithfully copying those symlinks pollutes
53
+ * the marketplace with gigabytes of node_modules / .next / brand-asset video
54
+ * that the consumer (Claude Code, OpenClaw) then walks during plugin
55
+ * discovery — which is the documented cause of multi-minute startup hangs.
56
+ *
57
+ * Internal symlinks (target stays inside the plugin root) are preserved.
48
58
  */
49
59
  export declare function copyPluginToMarketplace(plugin: DiscoveredPlugin, agent: AgentId, versionHome: string): string;
50
60
  /**
@@ -40,6 +40,16 @@ function settingsPath(agent, versionHome) {
40
40
  /**
41
41
  * Copy plugin source into marketplace install dir.
42
42
  * Source of truth remains ~/.agents/plugins/<name>/ — this is a per-version snapshot.
43
+ *
44
+ * Symlinks pointing OUTSIDE the plugin source root are dropped. They show up
45
+ * when plugin authors (legitimately) link prompt-side references to sibling
46
+ * codebases — e.g. the rush plugin's `app -> ../../../rush/app` for @app/...
47
+ * autocomplete in user prompts. Faithfully copying those symlinks pollutes
48
+ * the marketplace with gigabytes of node_modules / .next / brand-asset video
49
+ * that the consumer (Claude Code, OpenClaw) then walks during plugin
50
+ * discovery — which is the documented cause of multi-minute startup hangs.
51
+ *
52
+ * Internal symlinks (target stays inside the plugin root) are preserved.
43
53
  */
44
54
  export function copyPluginToMarketplace(plugin, agent, versionHome) {
45
55
  const dest = pluginInstallDir(plugin, agent, versionHome);
@@ -47,7 +57,43 @@ export function copyPluginToMarketplace(plugin, agent, versionHome) {
47
57
  if (fs.existsSync(dest)) {
48
58
  fs.rmSync(dest, { recursive: true, force: true });
49
59
  }
50
- fs.cpSync(plugin.root, dest, { recursive: true, dereference: false });
60
+ const sourceRealRoot = (() => {
61
+ try {
62
+ return fs.realpathSync(plugin.root);
63
+ }
64
+ catch {
65
+ return plugin.root;
66
+ }
67
+ })();
68
+ const skipped = [];
69
+ fs.cpSync(plugin.root, dest, {
70
+ recursive: true,
71
+ dereference: false,
72
+ filter: (src) => {
73
+ try {
74
+ const stat = fs.lstatSync(src);
75
+ if (!stat.isSymbolicLink())
76
+ return true;
77
+ const target = fs.realpathSync(src);
78
+ if (target === sourceRealRoot || target.startsWith(sourceRealRoot + path.sep)) {
79
+ return true;
80
+ }
81
+ skipped.push(path.relative(plugin.root, src) || path.basename(src));
82
+ return false;
83
+ }
84
+ catch {
85
+ // Dangling symlink or stat failure — drop it; it can't be useful in
86
+ // the marketplace and would error the consumer's walk anyway.
87
+ skipped.push(path.relative(plugin.root, src) || path.basename(src));
88
+ return false;
89
+ }
90
+ },
91
+ });
92
+ if (skipped.length > 0) {
93
+ process.stderr.write(`agents-cli: plugin '${plugin.name}' has ${skipped.length} symlink(s) ` +
94
+ `pointing outside its source root; not copied to marketplace ` +
95
+ `(would bloat consumer startup): ${skipped.join(', ')}\n`);
96
+ }
51
97
  return dest;
52
98
  }
53
99
  /**
@@ -192,7 +192,33 @@ export async function runPtyServer() {
192
192
  }
193
193
  const sessions = new Map();
194
194
  const socketPath = getSocketPath();
195
- // Remove stale socket
195
+ const pidPath = getPtyPidPath();
196
+ // Race resolution must happen BEFORE touching the socket file. Two clients
197
+ // racing ensureServer() in pty-client.ts can both observe
198
+ // isPtyServerRunning()=false and spawn parallel servers; without the
199
+ // O_EXCL claim below, the second spawn would unlink the first's socket
200
+ // inode and overwrite its PID file, orphaning the first server with its
201
+ // kernel socket binding intact but unreachable via the filesystem.
202
+ if (isPtyServerRunning()) {
203
+ log('INFO', 'PTY server already running; duplicate spawn exits cleanly');
204
+ process.exit(0);
205
+ }
206
+ try {
207
+ fs.writeFileSync(pidPath, String(process.pid), { flag: 'wx', encoding: 'utf-8' });
208
+ }
209
+ catch (err) {
210
+ if (err && err.code === 'EEXIST') {
211
+ log('INFO', 'PID slot claimed by a concurrent server; exiting cleanly');
212
+ process.exit(0);
213
+ }
214
+ throw err;
215
+ }
216
+ // We own the PID slot; ensure it's released on any exit path, not just SIGTERM/SIGINT.
217
+ process.on('exit', () => { try {
218
+ fs.unlinkSync(pidPath);
219
+ }
220
+ catch { } });
221
+ // Remove stale socket from a prior crashed server. Safe now that we hold the PID slot.
196
222
  if (fs.existsSync(socketPath)) {
197
223
  try {
198
224
  fs.unlinkSync(socketPath);
@@ -510,8 +536,6 @@ export async function runPtyServer() {
510
536
  // assumption, not a nice-to-have. If we can't lock it down, refuse to
511
537
  // start so the caller learns immediately.
512
538
  fs.chmodSync(socketPath, 0o600);
513
- // Write PID
514
- fs.writeFileSync(getPtyPidPath(), String(process.pid), 'utf-8');
515
539
  log('INFO', `PTY server started (PID: ${process.pid}, socket: ${socketPath})`);
516
540
  // Shutdown handler
517
541
  function shutdown() {
@@ -31,6 +31,7 @@ import { applyPermissionsToVersion as applyPermsToVersion, PERMISSIONS_CAPABLE_A
31
31
  import { installMcpServers, parseMcpServerConfig } from './mcp.js';
32
32
  import { markdownToToml } from './convert.js';
33
33
  import { createVersionedAlias, removeVersionedAlias, getConfigSymlinkVersion, ensureClaudeInsideSymlink } from './shims.js';
34
+ import { importInstallScriptBinary } from './import.js';
34
35
  import { listInstalledSubagents, transformSubagentForClaude, syncSubagentToOpenclaw, SUBAGENT_CAPABLE_AGENTS } from './subagents.js';
35
36
  import { WORKFLOW_CAPABLE_AGENTS, listInstalledWorkflows, syncWorkflowToVersion } from './workflows.js';
36
37
  import { registerHooksToSettings } from './hooks.js';
@@ -1176,6 +1177,26 @@ export async function installVersion(agent, version, onProgress) {
1176
1177
  const versionDir = getVersionDir(agent, installedVersion);
1177
1178
  fs.mkdirSync(versionDir, { recursive: true });
1178
1179
  fs.mkdirSync(path.join(versionDir, 'home'), { recursive: true });
1180
+ // Symlink the installed binary into the version's node_modules/.bin so
1181
+ // listInstalledVersions (which checks getBinaryPath) sees this version as
1182
+ // installed. Without this, `agents add antigravity@latest` succeeds
1183
+ // but `agents view` shows the agent under "Not Managed" because
1184
+ // listInstalledVersions returns [] — the installer drops the binary in
1185
+ // ~/.local/bin (or similar) rather than the version's node_modules/.bin.
1186
+ // Grok is special-cased in getBinaryPath itself (binary lives in
1187
+ // ~/.grok/downloads), so we skip the symlink there.
1188
+ if (agent !== 'grok') {
1189
+ try {
1190
+ const { stdout: whichOut } = await execFileAsync('which', [agentConfig.cliCommand]);
1191
+ const installedBinary = whichOut.trim();
1192
+ if (installedBinary && fs.existsSync(installedBinary)) {
1193
+ importInstallScriptBinary({ agentId: agent, npmPackage: agentConfig.npmPackage, cliCommand: agentConfig.cliCommand }, installedVersion, installedBinary, versionDir);
1194
+ }
1195
+ }
1196
+ catch {
1197
+ /* binary missing from PATH — install script failed silently; surface via the existing version.install error path below isn't possible here since the script returned 0. Leave the version dir empty so getBinaryPath check correctly reports it uninstalled. */
1198
+ }
1199
+ }
1179
1200
  createVersionedAlias(agent, installedVersion);
1180
1201
  emit('version.install', { agent, version: installedVersion });
1181
1202
  return { success: true, installedVersion };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@phnx-labs/agents-cli",
3
- "version": "1.20.3",
3
+ "version": "1.20.4",
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",
@@ -74,7 +74,7 @@
74
74
  "npm": ">=9"
75
75
  },
76
76
  "dependencies": {
77
- "@inquirer/prompts": "8.5.1",
77
+ "@inquirer/prompts": "8.5.2",
78
78
  "@types/proper-lockfile": "4.1.4",
79
79
  "@xterm/headless": "6.0.0",
80
80
  "@zed-industries/agent-client-protocol": "0.4.5",
@@ -85,7 +85,7 @@
85
85
  "marked": "15.0.12",
86
86
  "marked-terminal": "7.3.0",
87
87
  "node-pty": "1.1.0",
88
- "ora": "8.2.0",
88
+ "ora": "9.4.0",
89
89
  "proper-lockfile": "4.1.2",
90
90
  "simple-git": "3.36.0",
91
91
  "smol-toml": "1.6.1",
@@ -94,9 +94,9 @@
94
94
  "devDependencies": {
95
95
  "@types/diff": "8.0.0",
96
96
  "@types/marked-terminal": "6.1.1",
97
- "@types/node": "25.9.1",
98
- "tsx": "4.22.3",
97
+ "@types/node": "25.9.2",
98
+ "tsx": "4.22.4",
99
99
  "typescript": "6.0.3",
100
- "vitest": "4.1.6"
100
+ "vitest": "4.1.8"
101
101
  }
102
102
  }