@phnx-labs/agents-cli 1.20.27 → 1.20.29

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. package/CHANGELOG.md +3 -0
  2. package/dist/commands/doctor.js +57 -4
  3. package/dist/commands/exec.d.ts +1 -1
  4. package/dist/commands/exec.js +198 -15
  5. package/dist/commands/hosts.d.ts +11 -0
  6. package/dist/commands/hosts.js +229 -0
  7. package/dist/commands/repo.d.ts +29 -0
  8. package/dist/commands/repo.js +174 -38
  9. package/dist/commands/secrets.d.ts +2 -7
  10. package/dist/commands/secrets.js +108 -29
  11. package/dist/commands/sessions.d.ts +2 -0
  12. package/dist/commands/sessions.js +8 -24
  13. package/dist/commands/ssh.d.ts +14 -0
  14. package/dist/commands/ssh.js +263 -0
  15. package/dist/commands/sync.d.ts +2 -0
  16. package/dist/commands/sync.js +22 -5
  17. package/dist/commands/view.js +27 -11
  18. package/dist/index.js +3 -1
  19. package/dist/lib/agents.d.ts +1 -0
  20. package/dist/lib/agents.js +44 -4
  21. package/dist/lib/browser/drivers/ssh.d.ts +47 -2
  22. package/dist/lib/browser/drivers/ssh.js +113 -24
  23. package/dist/lib/browser/profiles.js +28 -1
  24. package/dist/lib/browser/runtime-state.js +28 -8
  25. package/dist/lib/browser/types.d.ts +10 -1
  26. package/dist/lib/cli-resources.js +10 -1
  27. package/dist/lib/devices/connect.d.ts +34 -0
  28. package/dist/lib/devices/connect.js +101 -0
  29. package/dist/lib/devices/registry.d.ts +78 -0
  30. package/dist/lib/devices/registry.js +168 -0
  31. package/dist/lib/devices/ssh-config.d.ts +21 -0
  32. package/dist/lib/devices/ssh-config.js +33 -0
  33. package/dist/lib/devices/tailscale.d.ts +31 -0
  34. package/dist/lib/devices/tailscale.js +126 -0
  35. package/dist/lib/doctor-diff.d.ts +12 -0
  36. package/dist/lib/doctor-diff.js +89 -2
  37. package/dist/lib/exec.d.ts +27 -0
  38. package/dist/lib/exec.js +62 -19
  39. package/dist/lib/hooks.d.ts +17 -0
  40. package/dist/lib/hooks.js +127 -3
  41. package/dist/lib/hosts/dispatch.d.ts +26 -0
  42. package/dist/lib/hosts/dispatch.js +71 -0
  43. package/dist/lib/hosts/progress.d.ts +21 -0
  44. package/dist/lib/hosts/progress.js +49 -0
  45. package/dist/lib/hosts/providers/local.d.ts +17 -0
  46. package/dist/lib/hosts/providers/local.js +81 -0
  47. package/dist/lib/hosts/ready.d.ts +37 -0
  48. package/dist/lib/hosts/ready.js +88 -0
  49. package/dist/lib/hosts/registry.d.ts +22 -0
  50. package/dist/lib/hosts/registry.js +65 -0
  51. package/dist/lib/hosts/ssh-config.d.ts +37 -0
  52. package/dist/lib/hosts/ssh-config.js +157 -0
  53. package/dist/lib/hosts/tasks.d.ts +32 -0
  54. package/dist/lib/hosts/tasks.js +58 -0
  55. package/dist/lib/hosts/types.d.ts +51 -0
  56. package/dist/lib/hosts/types.js +21 -0
  57. package/dist/lib/loop.d.ts +9 -0
  58. package/dist/lib/loop.js +13 -1
  59. package/dist/lib/mcp.js +12 -3
  60. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  61. package/dist/lib/migrate.js +9 -5
  62. package/dist/lib/platform/exec.d.ts +10 -0
  63. package/dist/lib/platform/exec.js +17 -0
  64. package/dist/lib/platform/index.d.ts +1 -0
  65. package/dist/lib/platform/index.js +1 -0
  66. package/dist/lib/platform/links.d.ts +15 -0
  67. package/dist/lib/platform/links.js +42 -0
  68. package/dist/lib/platform/paths.d.ts +18 -0
  69. package/dist/lib/platform/paths.js +22 -0
  70. package/dist/lib/platform/posixpath.d.ts +28 -0
  71. package/dist/lib/platform/posixpath.js +153 -0
  72. package/dist/lib/plugins.d.ts +10 -0
  73. package/dist/lib/plugins.js +1 -1
  74. package/dist/lib/project-launch.js +6 -3
  75. package/dist/lib/sandbox.js +5 -2
  76. package/dist/lib/secrets/remote.d.ts +67 -0
  77. package/dist/lib/secrets/remote.js +133 -0
  78. package/dist/lib/self-update.js +7 -2
  79. package/dist/lib/session/db.d.ts +24 -0
  80. package/dist/lib/session/db.js +80 -5
  81. package/dist/lib/session/discover.d.ts +28 -0
  82. package/dist/lib/session/discover.js +303 -4
  83. package/dist/lib/session/parse.d.ts +7 -0
  84. package/dist/lib/session/parse.js +110 -0
  85. package/dist/lib/session/relative-time.d.ts +7 -0
  86. package/dist/lib/session/relative-time.js +28 -0
  87. package/dist/lib/session/remote.d.ts +31 -3
  88. package/dist/lib/session/remote.js +121 -14
  89. package/dist/lib/session/types.d.ts +1 -1
  90. package/dist/lib/session/types.js +1 -1
  91. package/dist/lib/ssh-exec.d.ts +45 -0
  92. package/dist/lib/ssh-exec.js +61 -0
  93. package/dist/lib/startup/command-registry.d.ts +2 -0
  94. package/dist/lib/startup/command-registry.js +5 -0
  95. package/dist/lib/state.d.ts +2 -0
  96. package/dist/lib/state.js +2 -0
  97. package/dist/lib/types.d.ts +21 -0
  98. package/dist/lib/versions.d.ts +6 -2
  99. package/dist/lib/versions.js +8 -4
  100. package/package.json +1 -1
  101. package/scripts/postinstall.js +62 -0
@@ -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
+ }
@@ -123,6 +123,16 @@ export declare function saveUserConfig(pluginName: string, config: Record<string
123
123
  * Returns names of missing dependencies (warning only — not a hard error).
124
124
  */
125
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;
126
136
  /**
127
137
  * Sync a plugin to a specific agent version's home directory.
128
138
  *
@@ -397,7 +397,7 @@ export function checkPluginDependencies(manifest) {
397
397
  * inspects it (e.g. descriptionFor, which would otherwise label the system
398
398
  * marketplace as an extra repo named "system").
399
399
  */
400
- function marketplaceSpecForName(name, cwd = process.cwd()) {
400
+ export function marketplaceSpecForName(name, cwd = process.cwd()) {
401
401
  if (!name || name === MARKETPLACE_NAME)
402
402
  return { kind: 'user' };
403
403
  if (name === SYSTEM_MARKETPLACE_NAME) {
@@ -47,6 +47,7 @@ import * as path from 'path';
47
47
  import { supports } from './capabilities.js';
48
48
  import { getEnabledExtraRepos, getExtraPluginsDir, getPluginsDir, getProjectAgentsDir, getProjectPluginsDir, getSystemPluginsDir, } from './state.js';
49
49
  import { getVersionHomePath } from './versions.js';
50
+ import { toPortableKey } from './platform/index.js';
50
51
  import { transformSubagentForClaude } from './subagents.js';
51
52
  import { compileRulesForProject } from './rules/compile.js';
52
53
  import { discoverPluginsInDir, hasPluginExecSurfaces, inspectPluginCapabilities } from './plugins.js';
@@ -88,15 +89,17 @@ export function runLaunchSync(opts) {
88
89
  }
89
90
  /**
90
91
  * Path of the shim's skip-fast sentinel for this (agent, version, cwd) tuple.
91
- * Must match the SHIM-SIDE format in src/lib/shims.ts (PROJECT_SLUG derivation):
92
- * slug = PWD with `/` `_` and ` ` `_`
92
+ * Must match the SHIM-SIDE format in src/lib/shims.ts (PROJECT_SLUG derivation),
93
+ * which is the canonical `toPortableKey` mapping: drop the Windows drive colon
94
+ * and fold `\`, `/`, and ` ` → `_`. On POSIX this is byte-identical to the old
95
+ * `/` and ` ` → `_` slug; on Windows it yields a legal filename (no `C:\`).
93
96
  *
94
97
  * Cache leak note: this dir accumulates one zero-byte file per
95
98
  * (agent, version, project) tuple ever launched. Disk impact is negligible
96
99
  * (inodes only). A periodic GC belongs in `agents prune` — follow-up.
97
100
  */
98
101
  function launchSentinelPath(agent, version, cwd) {
99
- const slug = cwd.replace(/\//g, '_').replace(/ /g, '_');
102
+ const slug = toPortableKey(cwd);
100
103
  // Prefer $HOME (respects test overrides + matches bash's $HOME expansion in
101
104
  // the shim), fall back to os.homedir() so the lookup never resolves to '/'
102
105
  // if HOME is somehow unset.
@@ -11,6 +11,7 @@ import * as path from 'path';
11
11
  import * as os from 'os';
12
12
  import { setGeminiAutoUpdateDisabled, updateGeminiSettings } from './gemini-settings.js';
13
13
  import { getRoutinesDir, getUserAgentsDir } from './state.js';
14
+ import { createLink } from './platform/index.js';
14
15
  function resolveRealHome() {
15
16
  const home = os.homedir();
16
17
  try {
@@ -126,9 +127,11 @@ export function symlinkAllowedDirs(overlayHome, dirs) {
126
127
  fs.mkdirSync(parentDir, { recursive: true });
127
128
  if (!fs.existsSync(symlinkTarget)) {
128
129
  try {
129
- fs.symlinkSync(realPath, symlinkTarget);
130
+ // createLink uses a junction for directories on Windows (no elevation),
131
+ // a plain symlink on POSIX — allowed dirs are directories.
132
+ createLink(realPath, symlinkTarget);
130
133
  }
131
- catch { /* symlink already exists */ }
134
+ catch { /* link already exists or refused */ }
132
135
  }
133
136
  }
134
137
  }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Remote secrets — read and use `agents secrets` bundles that live on another
3
+ * host, over the same hardened SSH path that `agents secrets export --host`
4
+ * (the write inverse) already uses.
5
+ *
6
+ * This is the READ / USE direction:
7
+ * - browse: drive the remote `agents secrets list|view` and stream its
8
+ * stdout back verbatim (lossless, no parsing).
9
+ * - use: resolve a remote bundle to an env map (JSON over ssh stdout) and
10
+ * inject it ephemerally — never written to this machine's keychain.
11
+ *
12
+ * Trust model: relies on the operator's existing SSH access to the host (same
13
+ * boundary as `export --host` / `run --host`). Bundle names are shell-quoted
14
+ * into the remote command; resolved VALUES return over ssh stdout; a forwarded
15
+ * file-backend passphrase travels over ssh stdin (first line) so it never lands
16
+ * in argv / `ps` / remote shell history. Nothing is persisted locally.
17
+ */
18
+ import { type SshExecResult } from '../ssh-exec.js';
19
+ /**
20
+ * Resolve a `--host` value to an ssh target string. Tries the `agents hosts`
21
+ * registry first (enrolled name → ssh-config alias / `user@host`); on a miss,
22
+ * treats the value as a raw ssh target and validates it against injection.
23
+ */
24
+ export declare function resolveSshTarget(nameOrAlias: string): Promise<string>;
25
+ /**
26
+ * Merge `--host <single>` and `--hosts <a,b,c>` into an ordered, de-duplicated
27
+ * list. Both flags compose; either alone works. Empty when neither is set.
28
+ */
29
+ export declare function parseHostsOption(opts: {
30
+ host?: string;
31
+ hosts?: string;
32
+ }): string[];
33
+ /**
34
+ * Split a `bundle@host` reference. No `@` → a local bundle (host undefined).
35
+ * Bundle names can't contain `@` (BUNDLE_NAME_PATTERN), so the FIRST `@`
36
+ * separates the bundle from the ssh target — and the target itself may be a
37
+ * `user@host` (e.g. `r2.backups@muqsit@box` → bundle `r2.backups`, host
38
+ * `muqsit@box`).
39
+ */
40
+ export declare function splitBundleRef(ref: string): {
41
+ bundle: string;
42
+ host?: string;
43
+ };
44
+ /**
45
+ * Run `agents secrets <args>` on a remote host over ssh and return the raw
46
+ * result. Used by the browse commands — the remote's human-readable stdout is
47
+ * streamed back unchanged. `tty` forces an interactive ssh session (`-tt`) so a
48
+ * remote Touch-ID / passphrase prompt can surface (e.g. `view --reveal`).
49
+ */
50
+ export declare function remoteSecretsRaw(target: string, args: string[], opts?: {
51
+ tty?: boolean;
52
+ input?: string;
53
+ }): SshExecResult;
54
+ /**
55
+ * Resolve a remote bundle to a plaintext env map by driving the remote's
56
+ * `agents secrets export <bundle> --plaintext --format json`. Values cross over
57
+ * ssh stdout (encrypted in transit), parsed in memory, never persisted.
58
+ *
59
+ * The remote unlocks the bundle with ITS OWN credentials — the owner host's
60
+ * keychain/secrets-agent, or its own `AGENTS_SECRETS_PASSPHRASE` (in the login
61
+ * env) for a file-backed bundle. We deliberately do NOT forward this machine's
62
+ * passphrase: the remote bundle is encrypted with the remote's passphrase, so
63
+ * overriding it would break the read. (A macOS remote under non-interactive
64
+ * SSH will block on Touch-ID — use `view`/`exec` with a remote `file` bundle,
65
+ * an already-unlocked remote secrets-agent, or an interactive `-tt` session.)
66
+ */
67
+ export declare function remoteResolveEnv(target: string, bundle: string): Promise<Record<string, string>>;