@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
package/dist/lib/loop.js CHANGED
@@ -49,7 +49,19 @@ export function loopSignalPath(runDir) {
49
49
  * agent both recalls the prior turn AND knows what to do this iteration.
50
50
  */
51
51
  export function buildLoopContinuePrompt(prevSessionId, entrypoint) {
52
- return `/continue ${prevSessionId}\n\n${entrypoint}`;
52
+ return buildContinuePrompt(prevSessionId, entrypoint);
53
+ }
54
+ /**
55
+ * The universal (Tier-2) resume directive: a `/continue <id>` first message that
56
+ * tells the agent to load the prior transcript via `agents sessions <id>` and
57
+ * pick up. Works for ANY agent that ships the `/continue` command — the resume
58
+ * path for agents without a native `--resume` (gemini, grok, opencode, …). An
59
+ * optional follow-on prompt is appended after a blank line; omitted when empty so
60
+ * a bare resume sends just the directive.
61
+ */
62
+ export function buildContinuePrompt(sessionId, prompt) {
63
+ const directive = `/continue ${sessionId}`;
64
+ return prompt && prompt.trim() ? `${directive}\n\n${prompt}` : directive;
53
65
  }
54
66
  /**
55
67
  * Resolve a loop interval string to milliseconds. `"0"` is an explicit
package/dist/lib/mcp.js CHANGED
@@ -13,6 +13,7 @@ import * as yaml from 'yaml';
13
13
  import { execFileSync } from 'child_process';
14
14
  import { getMcpDir, getUserMcpDir, getProjectAgentsDir, getVersionsDir } from './state.js';
15
15
  import { getBinaryPath, getVersionHomePath } from './versions.js';
16
+ import { IS_WINDOWS, needsWindowsShell } from './platform/index.js';
16
17
  import { AGENTS } from './agents.js';
17
18
  import { isCapable } from './capabilities.js';
18
19
  import { setGeminiAutoUpdateDisabled, updateGeminiSettings } from './gemini-settings.js';
@@ -217,6 +218,7 @@ function installMcpViaClaude(binaryPath, server, versionHome) {
217
218
  stdio: 'pipe',
218
219
  timeout: 30000,
219
220
  env: execEnv,
221
+ shell: needsWindowsShell(binaryPath),
220
222
  });
221
223
  }
222
224
  else {
@@ -225,6 +227,7 @@ function installMcpViaClaude(binaryPath, server, versionHome) {
225
227
  stdio: 'pipe',
226
228
  timeout: 30000,
227
229
  env: execEnv,
230
+ shell: needsWindowsShell(binaryPath),
228
231
  });
229
232
  }
230
233
  }
@@ -245,6 +248,7 @@ function installMcpViaCodex(binaryPath, server, versionHome) {
245
248
  stdio: 'pipe',
246
249
  timeout: 30000,
247
250
  env: { ...process.env, HOME: versionHome },
251
+ shell: needsWindowsShell(binaryPath),
248
252
  });
249
253
  }
250
254
  // Note: Codex may not support HTTP MCPs
@@ -274,7 +278,7 @@ function registerMcpCommand(agentId, name, commandSpec, scope, transport, option
274
278
  ? ['mcp', 'add', '--transport', transport, '--scope', scope, name, '--', ...commandArgs]
275
279
  : ['mcp', 'add', name, '--', ...commandArgs];
276
280
  const env = options.home ? { ...process.env, HOME: options.home } : process.env;
277
- execFileSync(bin, args, { stdio: 'pipe', timeout: 30000, env });
281
+ execFileSync(bin, args, { stdio: 'pipe', timeout: 30000, env, shell: needsWindowsShell(bin) });
278
282
  return { success: true };
279
283
  }
280
284
  catch (err) {
@@ -442,9 +446,14 @@ export function installMcpServers(agentId, version, versionHome, mcpNames, optio
442
446
  }
443
447
  const applied = [];
444
448
  const errors = [];
445
- // Get binary path for CLI-based agents
449
+ // Get binary path for CLI-based agents. On Windows npm drops a `.cmd` launcher
450
+ // next to the extensionless POSIX wrapper in node_modules/.bin; prefer it so
451
+ // the CLI is actually executable (the bare wrapper is a shell script).
446
452
  const cliCommand = AGENTS[agentId].cliCommand;
447
- const binaryPath = path.join(getVersionsDir(), agentId, version, 'node_modules', '.bin', cliCommand);
453
+ let binaryPath = path.join(getVersionsDir(), agentId, version, 'node_modules', '.bin', cliCommand);
454
+ if (IS_WINDOWS && fs.existsSync(binaryPath + '.cmd')) {
455
+ binaryPath += '.cmd';
456
+ }
448
457
  for (const server of servers) {
449
458
  try {
450
459
  if (agentId === 'claude') {
@@ -9,6 +9,7 @@ import * as path from 'path';
9
9
  import * as os from 'os';
10
10
  import * as yaml from 'yaml';
11
11
  import { AGENTS, agentConfigDirName, findInPath } from './agents.js';
12
+ import { createLink } from './platform/index.js';
12
13
  const HOME = process.env.HOME ?? os.homedir();
13
14
  const USER_DIR = path.join(HOME, '.agents');
14
15
  /** Canonical system-repo location (post-fold). */
@@ -57,7 +58,7 @@ export function foldLegacySystemRepo() {
57
58
  try {
58
59
  fs.renameSync(LEGACY_SYSTEM_DIR, SYSTEM_DIR);
59
60
  try {
60
- fs.symlinkSync(SYSTEM_DIR, LEGACY_SYSTEM_DIR);
61
+ createLink(SYSTEM_DIR, LEGACY_SYSTEM_DIR);
61
62
  }
62
63
  catch { /* best-effort */ }
63
64
  console.error('Folded ~/.agents-system/ into ~/.agents/.system/ (left back-compat symlink)');
@@ -71,7 +72,7 @@ export function foldLegacySystemRepo() {
71
72
  copyDirSkipExisting(LEGACY_SYSTEM_DIR, SYSTEM_DIR);
72
73
  fs.rmSync(LEGACY_SYSTEM_DIR, { recursive: true, force: true });
73
74
  try {
74
- fs.symlinkSync(SYSTEM_DIR, LEGACY_SYSTEM_DIR);
75
+ createLink(SYSTEM_DIR, LEGACY_SYSTEM_DIR);
75
76
  }
76
77
  catch { /* best-effort */ }
77
78
  console.error('Merged ~/.agents-system/ into ~/.agents/.system/ (left back-compat symlink)');
@@ -698,14 +699,14 @@ function repairAgentConfigSymlinks() {
698
699
  continue; // already correct
699
700
  try {
700
701
  fs.unlinkSync(symlinkPath);
701
- fs.symlinkSync(userTarget, symlinkPath);
702
+ createLink(userTarget, symlinkPath);
702
703
  repaired++;
703
704
  }
704
705
  catch { /* best-effort */ }
705
706
  }
706
707
  else if (!stat) {
707
708
  try {
708
- fs.symlinkSync(userTarget, symlinkPath);
709
+ createLink(userTarget, symlinkPath);
709
710
  repaired++;
710
711
  }
711
712
  catch { /* best-effort */ }
@@ -798,8 +799,11 @@ export function repairSelfReferentialBinShims(versionsRoot = path.join(HISTORY_D
798
799
  const realBinary = findInPath(cli);
799
800
  try {
800
801
  fs.unlinkSync(binLink);
802
+ // createLink: a real symlink where the OS allows it (POSIX, and Windows
803
+ // with the symlink privilege), copy-fallback otherwise — so the repair
804
+ // lands a working binary on Windows runners without the symlink right.
801
805
  if (realBinary)
802
- fs.symlinkSync(realBinary, binLink);
806
+ createLink(realBinary, binLink);
803
807
  repaired++;
804
808
  }
805
809
  catch { /* best-effort */ }
@@ -1,5 +1,15 @@
1
1
  /** PATH-search command for the platform: `where` on Windows, else `which`. */
2
2
  export declare function whichCommand(platform?: NodeJS.Platform): string;
3
+ /**
4
+ * Does spawning `binary` require `shell: true` on this platform?
5
+ *
6
+ * On Windows a `.cmd`/`.bat` wrapper (npm.cmd, bun.cmd, the agent shims) cannot
7
+ * be exec'd directly — `spawn`/`execFile` look for a literal executable and miss
8
+ * the PATHEXT/cmd-interpreter step, surfacing as `ENOENT`/`EINVAL`. A bare
9
+ * command name (not an absolute path) needs the same PATHEXT resolution. Both
10
+ * cases require the shell. Always false off Windows, where direct exec is right.
11
+ */
12
+ export declare function needsWindowsShell(binary: string, platform?: NodeJS.Platform): boolean;
3
13
  /**
4
14
  * Resolve an executable name to its absolute path via the OS PATH search, or
5
15
  * `null` if not found. On Windows `where` can return several lines (one per
@@ -2,10 +2,27 @@
2
2
  * Executable resolution, platform-aware.
3
3
  */
4
4
  import { execFileSync } from 'child_process';
5
+ import * as path from 'path';
5
6
  /** PATH-search command for the platform: `where` on Windows, else `which`. */
6
7
  export function whichCommand(platform = process.platform) {
7
8
  return platform === 'win32' ? 'where' : 'which';
8
9
  }
10
+ /**
11
+ * Does spawning `binary` require `shell: true` on this platform?
12
+ *
13
+ * On Windows a `.cmd`/`.bat` wrapper (npm.cmd, bun.cmd, the agent shims) cannot
14
+ * be exec'd directly — `spawn`/`execFile` look for a literal executable and miss
15
+ * the PATHEXT/cmd-interpreter step, surfacing as `ENOENT`/`EINVAL`. A bare
16
+ * command name (not an absolute path) needs the same PATHEXT resolution. Both
17
+ * cases require the shell. Always false off Windows, where direct exec is right.
18
+ */
19
+ export function needsWindowsShell(binary, platform = process.platform) {
20
+ if (platform !== 'win32')
21
+ return false;
22
+ // path.win32.isAbsolute, not path.isAbsolute: the latter uses the HOST's rules,
23
+ // so a Windows path would read as relative when this runs on a Linux CI host.
24
+ return !path.win32.isAbsolute(binary) || /\.(cmd|bat)$/i.test(binary);
25
+ }
9
26
  /**
10
27
  * Resolve an executable name to its absolute path via the OS PATH search, or
11
28
  * `null` if not found. On Windows `where` can return several lines (one per
@@ -17,6 +17,7 @@ export declare const IS_MACOS: boolean;
17
17
  export declare const IS_LINUX: boolean;
18
18
  export * from './paths.js';
19
19
  export * from './exec.js';
20
+ export * from './links.js';
20
21
  export * from './process.js';
21
22
  export * from './ipc.js';
22
23
  export * from './winpath.js';
@@ -17,6 +17,7 @@ export const IS_MACOS = process.platform === 'darwin';
17
17
  export const IS_LINUX = process.platform === 'linux';
18
18
  export * from './paths.js';
19
19
  export * from './exec.js';
20
+ export * from './links.js';
20
21
  export * from './process.js';
21
22
  export * from './ipc.js';
22
23
  export * from './winpath.js';
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Create a link at `dst` pointing to `src`, portable across platforms.
3
+ *
4
+ * - **Directory** target: junction on Windows (no Developer Mode needed), plain
5
+ * symlink on POSIX.
6
+ * - **File** target: symlink, falling back to `copyFileSync` when Windows
7
+ * refuses the symlink (`EPERM`/`ENOSYS` — no Developer Mode). The copy is a
8
+ * point-in-time snapshot, not a live link; acceptable for the immutable
9
+ * targets we link (binaries, config files).
10
+ *
11
+ * `dst` must not already exist. Callers that replace atomically should link to a
12
+ * temp name and `rename` over the destination, exactly as before — the copy
13
+ * fallback is non-atomic, so the temp+rename stays the caller's responsibility.
14
+ */
15
+ export declare function createLink(src: string, dst: string): void;
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Filesystem linking, platform-aware.
3
+ *
4
+ * POSIX symlinks have no portable equivalent on Windows: directory symlinks and
5
+ * file symlinks both require either Administrator or Developer Mode, while
6
+ * *junctions* (directories only) need no elevation. `createLink` picks the form
7
+ * that works without privilege — junction for directories — and falls back to a
8
+ * copy for file links when the OS refuses the symlink.
9
+ */
10
+ import * as fs from 'fs';
11
+ /**
12
+ * Create a link at `dst` pointing to `src`, portable across platforms.
13
+ *
14
+ * - **Directory** target: junction on Windows (no Developer Mode needed), plain
15
+ * symlink on POSIX.
16
+ * - **File** target: symlink, falling back to `copyFileSync` when Windows
17
+ * refuses the symlink (`EPERM`/`ENOSYS` — no Developer Mode). The copy is a
18
+ * point-in-time snapshot, not a live link; acceptable for the immutable
19
+ * targets we link (binaries, config files).
20
+ *
21
+ * `dst` must not already exist. Callers that replace atomically should link to a
22
+ * temp name and `rename` over the destination, exactly as before — the copy
23
+ * fallback is non-atomic, so the temp+rename stays the caller's responsibility.
24
+ */
25
+ export function createLink(src, dst) {
26
+ const win = process.platform === 'win32';
27
+ const isDir = fs.statSync(src).isDirectory();
28
+ // Type is ignored on POSIX; on Windows, junction (dir) avoids the elevation a
29
+ // dir symlink would need, and 'file' is the explicit file-symlink form.
30
+ const type = win ? (isDir ? 'junction' : 'file') : undefined;
31
+ try {
32
+ fs.symlinkSync(src, dst, type);
33
+ }
34
+ catch (err) {
35
+ const code = err.code;
36
+ if (win && !isDir && (code === 'EPERM' || code === 'ENOSYS')) {
37
+ fs.copyFileSync(src, dst);
38
+ return;
39
+ }
40
+ throw err;
41
+ }
42
+ }
@@ -27,3 +27,21 @@ export declare function homeDir(): string;
27
27
  * whether to apply it (typically gated on win32).
28
28
  */
29
29
  export declare function isWindowsAbsolutePath(p: string): boolean;
30
+ /**
31
+ * Fold backslashes to forward slashes. Use when a path is going into a string
32
+ * that must read the same on every OS — a doc-comparable display path, a regex
33
+ * subject, a forward-slash-keyed lookup. Pure string transform; on POSIX input
34
+ * (no backslashes) it returns the value unchanged.
35
+ */
36
+ export declare function toPosix(p: string): string;
37
+ /**
38
+ * Derive a filesystem-safe key/slug from an absolute path. Drops the Windows
39
+ * drive colon (`:` is illegal in NTFS filenames / is the ADS separator) and
40
+ * folds path separators and spaces to `_`. For a POSIX path this produces the
41
+ * exact historical slug (`/a/b c` -> `_a_b_c`), so existing on-disk keys are
42
+ * unchanged; on Windows `C:\a\b` -> `C_a_b` instead of an unusable name.
43
+ *
44
+ * Shell mirror (keep byte-identical in any bash shim that recomputes this key):
45
+ * printf '%s' "$P" | tr -d ':' | tr '\\/ ' '_'
46
+ */
47
+ export declare function toPortableKey(p: string): string;
@@ -56,3 +56,25 @@ export function homeDir() {
56
56
  export function isWindowsAbsolutePath(p) {
57
57
  return WIN_DRIVE_RE.test(p) || p.startsWith('\\\\');
58
58
  }
59
+ /**
60
+ * Fold backslashes to forward slashes. Use when a path is going into a string
61
+ * that must read the same on every OS — a doc-comparable display path, a regex
62
+ * subject, a forward-slash-keyed lookup. Pure string transform; on POSIX input
63
+ * (no backslashes) it returns the value unchanged.
64
+ */
65
+ export function toPosix(p) {
66
+ return p.replace(/\\/g, '/');
67
+ }
68
+ /**
69
+ * Derive a filesystem-safe key/slug from an absolute path. Drops the Windows
70
+ * drive colon (`:` is illegal in NTFS filenames / is the ADS separator) and
71
+ * folds path separators and spaces to `_`. For a POSIX path this produces the
72
+ * exact historical slug (`/a/b c` -> `_a_b_c`), so existing on-disk keys are
73
+ * unchanged; on Windows `C:\a\b` -> `C_a_b` instead of an unusable name.
74
+ *
75
+ * Shell mirror (keep byte-identical in any bash shim that recomputes this key):
76
+ * printf '%s' "$P" | tr -d ':' | tr '\\/ ' '_'
77
+ */
78
+ export function toPortableKey(p) {
79
+ return p.replace(/^([a-zA-Z]):/, '$1').replace(/[\\/ ]/g, '_');
80
+ }
@@ -0,0 +1,28 @@
1
+ /** The XDG user-bin dir, `~/.local/bin`. */
2
+ export declare function localBinDir(home?: string): string;
3
+ export interface SymlinkResult {
4
+ /** A usable symlink to `target` exists at the path after this call. */
5
+ ok: boolean;
6
+ /** True only when this call created the symlink (false = already correct, or left untouched). */
7
+ created: boolean;
8
+ /** Why an existing entry was left untouched (set only when ok is false). */
9
+ skippedReason?: string;
10
+ path: string;
11
+ }
12
+ /**
13
+ * Ensure `<dir>/<name>` is a symlink to `target`, NEVER clobbering a
14
+ * pre-existing real file or a symlink that already points elsewhere — most
15
+ * importantly a developer's `scripts/install.sh` dev build at
16
+ * `~/.local/bin/agents`. Only an absent path, or a symlink already pointing at
17
+ * `target`, is created/treated as ok. Idempotent.
18
+ */
19
+ export declare function ensureLocalBinSymlink(name: string, target: string, dir?: string): SymlinkResult;
20
+ /**
21
+ * Does a fresh `bash -lc` resolve `cmd` on its PATH? This is the real question
22
+ * for the consumers we heal. Best-effort: any probe failure returns false so
23
+ * the caller heals rather than wrongly assuming success. `command -v` is a
24
+ * POSIX builtin.
25
+ */
26
+ export declare function loginShellResolves(cmd: string): boolean;
27
+ /** Is `dir` on a fresh `bash -lc` PATH? Best-effort; a probe failure returns false. */
28
+ export declare function dirOnLoginPath(dir: string): boolean;
@@ -0,0 +1,153 @@
1
+ /**
2
+ * POSIX login-PATH primitives for making the `agents` command resolvable.
3
+ *
4
+ * The Unix analog of winpath.ts. `agents`/`ag` land on PATH only via npm's
5
+ * bin symlink into the npm global-bin dir; under nvm (and other per-user node
6
+ * prefixes) that dir is absent from a *non-interactive login* shell's PATH, so
7
+ * `bash -lc 'agents …'` fails with command-not-found. That breaks every
8
+ * consumer that drives a login shell on the box: `agents secrets export --host`
9
+ * (which runs `bash -lc 'agents secrets import …'` on the remote) and the
10
+ * routines daemon (`src/lib/daemon.ts`, which falls back to bare `agents`).
11
+ *
12
+ * The fix mirrors the Windows postinstall branch (which registers npm's
13
+ * global-bin dir on the user PATH): symlink the entrypoint into ~/.local/bin —
14
+ * the XDG user-bin dir that mainstream distros auto-add to the login PATH when
15
+ * it exists (Debian/Ubuntu via ~/.profile, Fedora via /etc/profile.d) — and,
16
+ * where it is not yet on PATH, the caller adds it.
17
+ *
18
+ * Leaf module — imports only child_process / fs / os / path so it is cheap to
19
+ * load from the npm lifecycle script without pulling the rest of the CLI.
20
+ */
21
+ import { spawnSync } from 'child_process';
22
+ import * as fs from 'fs';
23
+ import * as os from 'os';
24
+ import * as path from 'path';
25
+ /** The XDG user-bin dir, `~/.local/bin`. */
26
+ export function localBinDir(home = os.homedir()) {
27
+ return path.join(home, '.local', 'bin');
28
+ }
29
+ /**
30
+ * Ensure `<dir>/<name>` is a symlink to `target`, NEVER clobbering a
31
+ * pre-existing real file or a symlink that already points elsewhere — most
32
+ * importantly a developer's `scripts/install.sh` dev build at
33
+ * `~/.local/bin/agents`. Only an absent path, or a symlink already pointing at
34
+ * `target`, is created/treated as ok. Idempotent.
35
+ */
36
+ export function ensureLocalBinSymlink(name, target, dir = localBinDir()) {
37
+ const linkPath = path.join(dir, name);
38
+ const want = path.resolve(target);
39
+ let current = null;
40
+ try {
41
+ current = fs.readlinkSync(linkPath);
42
+ }
43
+ catch (err) {
44
+ const code = err.code;
45
+ if (code === 'EINVAL') {
46
+ // The path exists but is not a symlink — a real file/dir. Never clobber.
47
+ return { ok: false, created: false, skippedReason: 'a non-symlink file already exists here', path: linkPath };
48
+ }
49
+ if (code !== 'ENOENT') {
50
+ return { ok: false, created: false, skippedReason: err.message, path: linkPath };
51
+ }
52
+ // ENOENT — nothing there; fall through to create.
53
+ }
54
+ if (current !== null) {
55
+ const resolved = path.isAbsolute(current) ? current : path.resolve(dir, current);
56
+ if (resolved === want)
57
+ return { ok: true, created: false, path: linkPath };
58
+ // Points somewhere else (e.g. a dev build) — the existing link wins.
59
+ return { ok: false, created: false, skippedReason: `symlink already points to ${current}`, path: linkPath };
60
+ }
61
+ fs.mkdirSync(dir, { recursive: true });
62
+ fs.symlinkSync(target, linkPath);
63
+ return { ok: true, created: true, path: linkPath };
64
+ }
65
+ /**
66
+ * Absolute path to `bash`. We probe bash specifically — NOT the user's $SHELL —
67
+ * because the consumers we are healing run `bash -lc` regardless of login shell:
68
+ * `secrets export --host` builds `bash -lc 'agents secrets import …'` for the
69
+ * remote, and the routines daemon resolves `agents` the same way. On a box whose
70
+ * login shell is zsh, zsh's login PATH may lack ~/.local/bin while bash's
71
+ * includes it (Debian/Ubuntu ~/.profile) — so probing $SHELL would give the
72
+ * wrong answer for what the consumer actually sees. Resolve from the *current*
73
+ * PATH (the install process has one) and fall back to /bin/bash.
74
+ */
75
+ function bashPath() {
76
+ const found = (process.env.PATH || '')
77
+ .split(path.delimiter)
78
+ .map((d) => (d ? path.join(d, 'bash') : ''))
79
+ .find((p) => {
80
+ if (!p)
81
+ return false;
82
+ try {
83
+ fs.accessSync(p, fs.constants.X_OK);
84
+ return true;
85
+ }
86
+ catch {
87
+ return false;
88
+ }
89
+ });
90
+ return found || '/bin/bash';
91
+ }
92
+ /**
93
+ * Environment for the probe shell that reproduces a *fresh* `bash -lc` — the
94
+ * PATH that `ssh host 'bash -lc …'` and the routines daemon actually get — and
95
+ * NOT the nvm/npm-augmented PATH of the install process.
96
+ *
97
+ * This is the crux: `npm i -g` (and a dev `bun run`) execute under nvm, so the
98
+ * caller's PATH already contains nvm's bin. A spawned shell inherits that PATH,
99
+ * so it would resolve `agents` and the heal would skip — during the very nvm
100
+ * install it exists to fix. We strip PATH (and nvm's hint vars) so the login
101
+ * profile rebuilds PATH from scratch, exactly as an incoming SSH command shell
102
+ * does. Login bash still sources /etc/profile + ~/.profile (which adds
103
+ * ~/.local/bin) but bails out of ~/.bashrc's nvm block early because it is
104
+ * non-interactive — which is precisely why bare `agents` is missing there.
105
+ */
106
+ function loginProbeEnv() {
107
+ const env = { ...process.env };
108
+ delete env.PATH;
109
+ delete env.NVM_BIN;
110
+ delete env.NVM_INC;
111
+ for (const k of Object.keys(env))
112
+ if (k.startsWith('npm_'))
113
+ delete env[k];
114
+ return env;
115
+ }
116
+ /**
117
+ * Does a fresh `bash -lc` resolve `cmd` on its PATH? This is the real question
118
+ * for the consumers we heal. Best-effort: any probe failure returns false so
119
+ * the caller heals rather than wrongly assuming success. `command -v` is a
120
+ * POSIX builtin.
121
+ */
122
+ export function loginShellResolves(cmd) {
123
+ try {
124
+ const res = spawnSync(bashPath(), ['-lc', `command -v ${cmd}`], {
125
+ stdio: ['ignore', 'pipe', 'ignore'],
126
+ encoding: 'utf-8',
127
+ timeout: 5000,
128
+ env: loginProbeEnv(),
129
+ });
130
+ return res.status === 0 && !!res.stdout && res.stdout.trim().length > 0;
131
+ }
132
+ catch {
133
+ return false;
134
+ }
135
+ }
136
+ /** Is `dir` on a fresh `bash -lc` PATH? Best-effort; a probe failure returns false. */
137
+ export function dirOnLoginPath(dir) {
138
+ try {
139
+ const res = spawnSync(bashPath(), ['-lc', 'printf %s "$PATH"'], {
140
+ stdio: ['ignore', 'pipe', 'ignore'],
141
+ encoding: 'utf-8',
142
+ timeout: 5000,
143
+ env: loginProbeEnv(),
144
+ });
145
+ if (res.status !== 0 || !res.stdout)
146
+ return false;
147
+ const want = path.resolve(dir);
148
+ return res.stdout.split(':').some((p) => p && path.resolve(p) === want);
149
+ }
150
+ catch {
151
+ return false;
152
+ }
153
+ }
@@ -113,6 +113,24 @@ export declare function copyPluginToMarketplace(plugin: DiscoveredPlugin, spec:
113
113
  * check would false-positive.
114
114
  */
115
115
  export declare function validateClaudePluginManifest(manifest: unknown): string[];
116
+ /**
117
+ * The repairable fields present-and-invalid in a parsed manifest. Drives both
118
+ * the dry-run preview (heal/doctor --fix) and the actual write below.
119
+ */
120
+ export declare function repairableManifestFields(manifest: unknown): string[];
121
+ /**
122
+ * Auto-repair a plugin's SOURCE plugin.json in place: delete any `skills`/
123
+ * `commands` field that holds bare names (Claude Code silently rejects the
124
+ * ENTIRE plugin otherwise). Claude auto-discovers both from their directories,
125
+ * so deletion is the canonical, lossless fix — exactly what the validator's
126
+ * warning already recommends. Returns the fields it dropped (empty = no change).
127
+ *
128
+ * Writes to the source manifest (not the regenerated marketplace copy) so the
129
+ * fix survives the next sync. Pass `{ dryRun }` to preview without writing.
130
+ */
131
+ export declare function repairPluginManifestFile(manifestPath: string, opts?: {
132
+ dryRun?: boolean;
133
+ }): string[];
116
134
  /**
117
135
  * Re-synthesize <marketplace>/.claude-plugin/marketplace.json from the plugins
118
136
  * already installed under <marketplace>/plugins/. Always run after add or remove
@@ -28,7 +28,7 @@
28
28
  import * as fs from 'fs';
29
29
  import { agentConfigDirName } from './agents.js';
30
30
  import * as path from 'path';
31
- import { getPluginsDir, getEnabledExtraRepos, getProjectPluginsDir } from './state.js';
31
+ import { getPluginsDir, getEnabledExtraRepos, getProjectPluginsDir, getSystemPluginsDir } from './state.js';
32
32
  /**
33
33
  * Canonical name for the user-repo marketplace (~/.agents/plugins/). Kept as an
34
34
  * exported constant for callers that operate on the user repo directly and for
@@ -76,6 +76,19 @@ function descriptionFor(spec) {
76
76
  */
77
77
  export function discoverMarketplaces(opts = {}) {
78
78
  const out = [];
79
+ // System repo — npm-shipped defaults (~/.agents/.system/plugins/) → the
80
+ // "agents-system" marketplace. Listed FIRST so it has the lowest precedence:
81
+ // consumers that dedupe by plugin name keep the LAST occurrence, letting
82
+ // user / extra / project plugins of the same name override a system one (same
83
+ // direction collectPluginScopes() in project-launch.ts uses). Without this,
84
+ // `agents sync` never discovers system plugins, so cleanOrphanedPluginSkills
85
+ // trashes whatever a project launch installed under agents-system and the
86
+ // marketplace gets unregistered on the next sync.
87
+ const systemRoot = getSystemPluginsDir();
88
+ if (dirExists(systemRoot)) {
89
+ const spec = { kind: 'system', root: systemRoot };
90
+ out.push({ spec, name: marketplaceNameFor(spec), pluginsRoot: systemRoot, description: descriptionFor(spec) });
91
+ }
79
92
  // User repo — always the canonical "agents-cli" marketplace.
80
93
  const userRoot = getPluginsDir();
81
94
  if (dirExists(userRoot)) {
@@ -233,6 +246,59 @@ export function validateClaudePluginManifest(manifest) {
233
246
  }
234
247
  return warnings;
235
248
  }
249
+ /**
250
+ * Fields safe to auto-repair by deletion. Scoped to `skills`/`commands` only —
251
+ * NOT `agents`: agents-cli overloads `agents` in plugin.json as its own
252
+ * `AgentId[]` targeting list (bare names like "claude"), so stripping it would
253
+ * destroy real metadata. (`validateClaudePluginManifest` still WARNS on a bare
254
+ * `agents` field; repairing it is a separate, deliberate non-goal here.)
255
+ */
256
+ const REPAIRABLE_PATH_FIELDS = ['skills', 'commands'];
257
+ /** True when a manifest field holds bare-name entries Claude Code rejects. */
258
+ function fieldHasBareEntries(value) {
259
+ if (value === undefined || value === null)
260
+ return false;
261
+ const entries = Array.isArray(value) ? value : [value];
262
+ return entries.some((e) => typeof e !== 'string' || !e.startsWith('./'));
263
+ }
264
+ /**
265
+ * The repairable fields present-and-invalid in a parsed manifest. Drives both
266
+ * the dry-run preview (heal/doctor --fix) and the actual write below.
267
+ */
268
+ export function repairableManifestFields(manifest) {
269
+ if (!manifest || typeof manifest !== 'object')
270
+ return [];
271
+ const m = manifest;
272
+ return REPAIRABLE_PATH_FIELDS.filter((f) => fieldHasBareEntries(m[f]));
273
+ }
274
+ /**
275
+ * Auto-repair a plugin's SOURCE plugin.json in place: delete any `skills`/
276
+ * `commands` field that holds bare names (Claude Code silently rejects the
277
+ * ENTIRE plugin otherwise). Claude auto-discovers both from their directories,
278
+ * so deletion is the canonical, lossless fix — exactly what the validator's
279
+ * warning already recommends. Returns the fields it dropped (empty = no change).
280
+ *
281
+ * Writes to the source manifest (not the regenerated marketplace copy) so the
282
+ * fix survives the next sync. Pass `{ dryRun }` to preview without writing.
283
+ */
284
+ export function repairPluginManifestFile(manifestPath, opts = {}) {
285
+ if (!fs.existsSync(manifestPath))
286
+ return [];
287
+ let manifest;
288
+ try {
289
+ manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
290
+ }
291
+ catch {
292
+ return []; // unparseable manifest is a different failure; don't touch it.
293
+ }
294
+ const dropped = repairableManifestFields(manifest);
295
+ if (dropped.length === 0 || opts.dryRun)
296
+ return dropped;
297
+ for (const f of dropped)
298
+ delete manifest[f];
299
+ fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n', 'utf-8');
300
+ return dropped;
301
+ }
236
302
  // ─── Catalog synthesis ──────────────────────────────────────────────────────
237
303
  /**
238
304
  * Re-synthesize <marketplace>/.claude-plugin/marketplace.json from the plugins
@@ -65,7 +65,12 @@ export declare function loadPluginManifest(pluginRoot: string): PluginManifest |
65
65
  export declare function validatePluginName(name: string): boolean;
66
66
  export declare function assertPluginTargetContained(targetRoot: string, pluginsDir: string): void;
67
67
  /**
68
- * Get a specific plugin by name.
68
+ * Get a specific plugin by name. On a cross-marketplace name collision the
69
+ * highest-precedence scope wins (project > extra > user > system) — the same
70
+ * resolution the sync writer's Map(last-wins) dedupe and collectPluginScopes()
71
+ * use. discoverPlugins() yields low→high precedence order, so the LAST match is
72
+ * the winner; returning the first match would resolve to the lowest scope (e.g.
73
+ * a system plugin over the user's same-named one), which is exactly backwards.
69
74
  */
70
75
  export declare function getPlugin(name: string): DiscoveredPlugin | null;
71
76
  /**
@@ -118,6 +123,16 @@ export declare function saveUserConfig(pluginName: string, config: Record<string
118
123
  * Returns names of missing dependencies (warning only — not a hard error).
119
124
  */
120
125
  export declare function checkPluginDependencies(manifest: PluginManifest): string[];
126
+ /**
127
+ * Reconstruct a MarketplaceSpec from a marketplace name. The inverse of
128
+ * marketplaceNameFor(): "agents-cli" → user, "agents-project" → project,
129
+ * "agents-system" → system, "agents-<alias>" → extra. The per-version
130
+ * marketplace operations only key off the name (never spec.root), but we
131
+ * resolve the real source root anyway so the spec is honest for any caller that
132
+ * inspects it (e.g. descriptionFor, which would otherwise label the system
133
+ * marketplace as an extra repo named "system").
134
+ */
135
+ export declare function marketplaceSpecForName(name: string | undefined, cwd?: string): MarketplaceSpec;
121
136
  /**
122
137
  * Sync a plugin to a specific agent version's home directory.
123
138
  *
@@ -212,6 +227,23 @@ export declare function installPlugin(spec: string): Promise<{
212
227
  isNew: boolean;
213
228
  capabilities: PluginCapabilities;
214
229
  }>;
230
+ /** Parsed `.source` provenance written by install/update. `version` is the
231
+ * upstream manifest version captured at the last pull (absent on pre-existing
232
+ * installs from before baseline tracking). */
233
+ export interface PluginSourceInfo {
234
+ source: string;
235
+ isGit: boolean;
236
+ version?: string;
237
+ }
238
+ /** Read a plugin's `.source` provenance, or null when absent/unreadable. */
239
+ export declare function readPluginSourceInfo(root: string): PluginSourceInfo | null;
240
+ /**
241
+ * Resolve the CURRENT upstream manifest version for a local-sourced plugin
242
+ * (the `.system`/local-path case). Returns null for git sources — reading their
243
+ * upstream version would need a network fetch, so git plugins are refreshed only
244
+ * via the explicit `agents plugins update`.
245
+ */
246
+ export declare function getUpstreamManifestVersion(info: PluginSourceInfo): string | null;
215
247
  /**
216
248
  * Update an installed plugin by re-pulling from its original source.
217
249
  * Returns true if the update succeeded.