@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.
- package/CHANGELOG.md +29 -0
- package/dist/commands/doctor.d.ts +5 -2
- package/dist/commands/doctor.js +182 -30
- package/dist/commands/exec.d.ts +1 -1
- package/dist/commands/exec.js +177 -6
- package/dist/commands/hosts.d.ts +11 -0
- package/dist/commands/hosts.js +229 -0
- package/dist/commands/repo.d.ts +29 -0
- package/dist/commands/repo.js +174 -38
- package/dist/commands/secrets.d.ts +2 -7
- package/dist/commands/secrets.js +15 -23
- package/dist/commands/sessions.d.ts +2 -0
- package/dist/commands/sessions.js +27 -25
- package/dist/commands/sync.d.ts +2 -0
- package/dist/commands/sync.js +22 -5
- package/dist/commands/view.js +27 -11
- package/dist/index.js +4 -13
- package/dist/lib/agent-spec.d.ts +36 -0
- package/dist/lib/agent-spec.js +157 -0
- package/dist/lib/agents.d.ts +1 -0
- package/dist/lib/agents.js +45 -4
- package/dist/lib/browser/drivers/ssh.d.ts +47 -2
- package/dist/lib/browser/drivers/ssh.js +113 -24
- package/dist/lib/browser/profiles.js +28 -1
- package/dist/lib/browser/runtime-state.js +28 -8
- package/dist/lib/browser/types.d.ts +10 -1
- package/dist/lib/cli-resources.js +10 -1
- package/dist/lib/daemon.js +32 -0
- package/dist/lib/doctor-diff.d.ts +19 -0
- package/dist/lib/doctor-diff.js +107 -15
- package/dist/lib/exec.d.ts +27 -0
- package/dist/lib/exec.js +62 -19
- package/dist/lib/heal.d.ts +107 -0
- package/dist/lib/heal.js +279 -0
- package/dist/lib/hooks.d.ts +17 -0
- package/dist/lib/hooks.js +127 -3
- package/dist/lib/hosts/dispatch.d.ts +26 -0
- package/dist/lib/hosts/dispatch.js +71 -0
- package/dist/lib/hosts/progress.d.ts +21 -0
- package/dist/lib/hosts/progress.js +49 -0
- package/dist/lib/hosts/providers/local.d.ts +17 -0
- package/dist/lib/hosts/providers/local.js +81 -0
- package/dist/lib/hosts/ready.d.ts +37 -0
- package/dist/lib/hosts/ready.js +88 -0
- package/dist/lib/hosts/registry.d.ts +22 -0
- package/dist/lib/hosts/registry.js +65 -0
- package/dist/lib/hosts/ssh-config.d.ts +37 -0
- package/dist/lib/hosts/ssh-config.js +157 -0
- package/dist/lib/hosts/tasks.d.ts +32 -0
- package/dist/lib/hosts/tasks.js +58 -0
- package/dist/lib/hosts/types.d.ts +51 -0
- package/dist/lib/hosts/types.js +21 -0
- package/dist/lib/loop.d.ts +9 -0
- package/dist/lib/loop.js +13 -1
- package/dist/lib/mcp.js +12 -3
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- package/dist/lib/migrate.js +9 -5
- package/dist/lib/platform/exec.d.ts +10 -0
- package/dist/lib/platform/exec.js +17 -0
- package/dist/lib/platform/index.d.ts +1 -0
- package/dist/lib/platform/index.js +1 -0
- package/dist/lib/platform/links.d.ts +15 -0
- package/dist/lib/platform/links.js +42 -0
- package/dist/lib/platform/paths.d.ts +18 -0
- package/dist/lib/platform/paths.js +22 -0
- package/dist/lib/platform/posixpath.d.ts +28 -0
- package/dist/lib/platform/posixpath.js +153 -0
- package/dist/lib/plugin-marketplace.d.ts +18 -0
- package/dist/lib/plugin-marketplace.js +67 -1
- package/dist/lib/plugins.d.ts +33 -1
- package/dist/lib/plugins.js +56 -11
- package/dist/lib/project-launch.js +6 -3
- package/dist/lib/sandbox.js +5 -2
- package/dist/lib/self-update.js +7 -2
- package/dist/lib/session/db.d.ts +23 -0
- package/dist/lib/session/db.js +76 -1
- package/dist/lib/session/discover.d.ts +26 -0
- package/dist/lib/session/discover.js +75 -4
- package/dist/lib/session/relative-time.d.ts +7 -0
- package/dist/lib/session/relative-time.js +28 -0
- package/dist/lib/session/remote.d.ts +61 -0
- package/dist/lib/session/remote.js +221 -0
- package/dist/lib/ssh-exec.d.ts +45 -0
- package/dist/lib/ssh-exec.js +61 -0
- package/dist/lib/staleness/detectors/commands.js +7 -6
- package/dist/lib/staleness/writers/commands.js +7 -12
- package/dist/lib/startup/command-registry.d.ts +1 -0
- package/dist/lib/startup/command-registry.js +2 -0
- package/dist/lib/startup/dev-build.d.ts +22 -0
- package/dist/lib/startup/dev-build.js +41 -0
- package/dist/lib/types.d.ts +28 -0
- package/dist/lib/versions.d.ts +9 -3
- package/dist/lib/versions.js +43 -7
- package/package.json +3 -3
- 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
|
|
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
|
-
|
|
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') {
|
|
Binary file
|
package/dist/lib/migrate.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
package/dist/lib/plugins.d.ts
CHANGED
|
@@ -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.
|