@phnx-labs/agents-cli 1.20.12 → 1.20.13
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 +13 -0
- package/README.md +3 -0
- package/dist/commands/computer-actions.d.ts +3 -0
- package/dist/commands/computer-actions.js +16 -0
- package/dist/commands/exec.js +25 -4
- package/dist/commands/import.js +17 -6
- package/dist/commands/inspect.d.ts +11 -1
- package/dist/commands/inspect.js +53 -19
- package/dist/commands/mcp.js +3 -3
- package/dist/commands/plugins.d.ts +2 -0
- package/dist/commands/plugins.js +69 -26
- package/dist/commands/sync.js +1 -1
- package/dist/commands/teams.js +1 -0
- package/dist/commands/trash.d.ts +11 -0
- package/dist/commands/trash.js +57 -41
- package/dist/commands/versions.js +68 -20
- package/dist/commands/view.js +1 -12
- package/dist/commands/wallet.d.ts +14 -0
- package/dist/commands/wallet.js +199 -0
- package/dist/index.js +4 -1
- package/dist/lib/agents.js +70 -22
- package/dist/lib/browser/ipc.d.ts +7 -0
- package/dist/lib/browser/ipc.js +43 -27
- package/dist/lib/capabilities.js +7 -1
- package/dist/lib/command-skills.d.ts +1 -0
- package/dist/lib/command-skills.js +23 -7
- package/dist/lib/exec.d.ts +32 -1
- package/dist/lib/exec.js +79 -7
- package/dist/lib/hooks.js +37 -5
- package/dist/lib/mcp.js +33 -0
- package/dist/lib/models.js +5 -0
- package/dist/lib/picker.d.ts +2 -0
- package/dist/lib/picker.js +96 -6
- package/dist/lib/platform/index.d.ts +1 -0
- package/dist/lib/platform/index.js +1 -0
- package/dist/lib/platform/winpath.d.ts +35 -0
- package/dist/lib/platform/winpath.js +86 -0
- package/dist/lib/plugins.d.ts +14 -0
- package/dist/lib/plugins.js +23 -0
- package/dist/lib/project-launch.js +110 -5
- package/dist/lib/registry.js +15 -2
- package/dist/lib/runner.js +14 -0
- package/dist/lib/sandbox.js +5 -2
- package/dist/lib/settings-manifest.d.ts +39 -0
- package/dist/lib/settings-manifest.js +163 -0
- package/dist/lib/shims.d.ts +1 -1
- package/dist/lib/shims.js +16 -31
- package/dist/lib/staleness/detectors/subagents.js +16 -0
- package/dist/lib/staleness/writers/subagents.js +11 -3
- package/dist/lib/subagents.d.ts +9 -0
- package/dist/lib/subagents.js +33 -0
- package/dist/lib/teams/agents.js +1 -1
- package/dist/lib/teams/parsers.d.ts +1 -1
- package/dist/lib/teams/parsers.js +6 -0
- package/dist/lib/types.d.ts +1 -1
- package/dist/lib/versions.d.ts +15 -3
- package/dist/lib/versions.js +88 -19
- package/dist/lib/wallet/index.d.ts +78 -0
- package/dist/lib/wallet/index.js +253 -0
- package/package.json +3 -3
- package/scripts/postinstall.js +35 -7
package/dist/lib/picker.js
CHANGED
|
@@ -14,6 +14,86 @@
|
|
|
14
14
|
*/
|
|
15
15
|
import { createPrompt, useState, useKeypress, useEffect, useMemo, usePagination, usePrefix, makeTheme, isEnterKey, isUpKey, isDownKey, isSpaceKey, Separator, } from '@inquirer/core';
|
|
16
16
|
import chalk from 'chalk';
|
|
17
|
+
import { stripVTControlCharacters } from 'node:util';
|
|
18
|
+
const DEFAULT_TERMINAL_ROWS = 24;
|
|
19
|
+
const DEFAULT_TERMINAL_WIDTH = 80;
|
|
20
|
+
function terminalWidth() {
|
|
21
|
+
return Math.max(1, process.stdout.columns || DEFAULT_TERMINAL_WIDTH);
|
|
22
|
+
}
|
|
23
|
+
function terminalRows() {
|
|
24
|
+
return Math.max(1, process.stdout.rows || DEFAULT_TERMINAL_ROWS);
|
|
25
|
+
}
|
|
26
|
+
function renderedRows(text, width) {
|
|
27
|
+
const normalizedWidth = Math.max(1, width);
|
|
28
|
+
return text.split('\n').reduce((rows, line) => {
|
|
29
|
+
const visible = stripVTControlCharacters(line).length;
|
|
30
|
+
return rows + Math.max(1, Math.ceil(visible / normalizedWidth));
|
|
31
|
+
}, 0);
|
|
32
|
+
}
|
|
33
|
+
function truncateAnsiLine(line, maxVisibleWidth) {
|
|
34
|
+
if (maxVisibleWidth <= 0)
|
|
35
|
+
return '';
|
|
36
|
+
const targetWidth = Math.max(0, maxVisibleWidth - 1);
|
|
37
|
+
const ansiPattern = /\x1b(?:\[[0-?]*[ -/]*[@-~]|\][^\x07]*(?:\x07|\x1b\\))/y;
|
|
38
|
+
let out = '';
|
|
39
|
+
let visible = 0;
|
|
40
|
+
for (let i = 0; i < line.length;) {
|
|
41
|
+
ansiPattern.lastIndex = i;
|
|
42
|
+
const ansi = ansiPattern.exec(line);
|
|
43
|
+
if (ansi) {
|
|
44
|
+
out += ansi[0];
|
|
45
|
+
i = ansiPattern.lastIndex;
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
const char = line[i];
|
|
49
|
+
if (visible >= targetWidth)
|
|
50
|
+
break;
|
|
51
|
+
out += char;
|
|
52
|
+
visible += 1;
|
|
53
|
+
i += char.length;
|
|
54
|
+
}
|
|
55
|
+
return out + '\x1b[0m' + chalk.gray('…');
|
|
56
|
+
}
|
|
57
|
+
function takePreviewRows(preview, rowBudget, width) {
|
|
58
|
+
const lines = preview.split('\n');
|
|
59
|
+
const out = [];
|
|
60
|
+
let used = 0;
|
|
61
|
+
for (const line of lines) {
|
|
62
|
+
const lineRows = renderedRows(line, width);
|
|
63
|
+
if (used + lineRows <= rowBudget) {
|
|
64
|
+
out.push(line);
|
|
65
|
+
used += lineRows;
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
const remainingRows = rowBudget - used;
|
|
69
|
+
if (remainingRows > 0) {
|
|
70
|
+
out.push(truncateAnsiLine(line, remainingRows * width));
|
|
71
|
+
}
|
|
72
|
+
break;
|
|
73
|
+
}
|
|
74
|
+
return out;
|
|
75
|
+
}
|
|
76
|
+
function previewTruncatedMarker(width) {
|
|
77
|
+
const full = '... preview truncated to fit terminal';
|
|
78
|
+
const short = '... truncated';
|
|
79
|
+
const text = full.length <= width ? full : short;
|
|
80
|
+
if (text.length <= width)
|
|
81
|
+
return chalk.gray(text);
|
|
82
|
+
return chalk.gray(text.slice(0, Math.max(0, width - 1)) + '…');
|
|
83
|
+
}
|
|
84
|
+
/** Clip a picker preview so the full prompt can fit in the terminal viewport. */
|
|
85
|
+
export function limitPreviewHeight(preview, maxRows, width) {
|
|
86
|
+
const normalizedRows = Math.max(0, maxRows);
|
|
87
|
+
if (normalizedRows === 0)
|
|
88
|
+
return '';
|
|
89
|
+
if (renderedRows(preview, width) <= normalizedRows)
|
|
90
|
+
return preview;
|
|
91
|
+
if (normalizedRows === 1)
|
|
92
|
+
return previewTruncatedMarker(width);
|
|
93
|
+
const lines = takePreviewRows(preview, normalizedRows - 1, width);
|
|
94
|
+
lines.push(previewTruncatedMarker(width));
|
|
95
|
+
return lines.join('\n');
|
|
96
|
+
}
|
|
17
97
|
/** Show an interactive fuzzy-filter picker and return the selected item, or null on cancel. */
|
|
18
98
|
export function itemPicker(config) {
|
|
19
99
|
const prompt = createPrompt((cfg, done) => {
|
|
@@ -90,18 +170,28 @@ export function itemPicker(config) {
|
|
|
90
170
|
pageSize: cfg.pageSize ?? 10,
|
|
91
171
|
loop: false,
|
|
92
172
|
});
|
|
173
|
+
const enter = cfg.enterHint ?? 'select';
|
|
174
|
+
const help = previewOpen
|
|
175
|
+
? chalk.gray(`↑↓ navigate · space: close preview · ⏎ ${enter} · esc: cancel`)
|
|
176
|
+
: chalk.gray(`↑↓ navigate${hasPreview ? ' · space: preview' : ''} · ⏎ ${enter} · esc: cancel`);
|
|
93
177
|
const parts = [header, page];
|
|
94
178
|
if (results.length === 0) {
|
|
95
179
|
parts.push(chalk.gray(` ${cfg.emptyMessage ?? 'No matches.'}`));
|
|
96
180
|
}
|
|
97
181
|
if (previewOpen && selected && cfg.buildPreview) {
|
|
98
|
-
|
|
99
|
-
|
|
182
|
+
const width = terminalWidth();
|
|
183
|
+
const separator = chalk.gray('─'.repeat(Math.min(width, 80)));
|
|
184
|
+
const fixedRows = renderedRows(header, width) +
|
|
185
|
+
renderedRows(parts.slice(1).join('\n'), width) +
|
|
186
|
+
renderedRows(separator, width) +
|
|
187
|
+
renderedRows(help, width);
|
|
188
|
+
const availablePreviewRows = terminalRows() - fixedRows;
|
|
189
|
+
const preview = limitPreviewHeight(cfg.buildPreview(selected.value), availablePreviewRows, width);
|
|
190
|
+
if (preview) {
|
|
191
|
+
parts.push(separator);
|
|
192
|
+
parts.push(preview);
|
|
193
|
+
}
|
|
100
194
|
}
|
|
101
|
-
const enter = cfg.enterHint ?? 'select';
|
|
102
|
-
const help = previewOpen
|
|
103
|
-
? chalk.gray(`↑↓ navigate · space: close preview · ⏎ ${enter} · esc: cancel`)
|
|
104
|
-
: chalk.gray(`↑↓ navigate${hasPreview ? ' · space: preview' : ''} · ⏎ ${enter} · esc: cancel`);
|
|
105
195
|
parts.push(help);
|
|
106
196
|
return [header, parts.slice(1).join('\n')];
|
|
107
197
|
});
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export interface WinPathResult {
|
|
2
|
+
success: boolean;
|
|
3
|
+
/** True when `dir` was already the first PATH entry (no write performed). */
|
|
4
|
+
alreadyPresent?: boolean;
|
|
5
|
+
error?: string;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Prepend `dir` to the Windows User PATH. Idempotent: a no-op when `dir` is
|
|
9
|
+
* already first; moves it to the front when it exists but is positioned later
|
|
10
|
+
* (e.g. appended by an older install) so it overrides conflicting entries.
|
|
11
|
+
* `dir` is passed via an env var so it is never interpolated into the script
|
|
12
|
+
* text.
|
|
13
|
+
*/
|
|
14
|
+
export declare function prependToWindowsUserPath(dir: string): WinPathResult;
|
|
15
|
+
/**
|
|
16
|
+
* The effective PowerShell execution policy (e.g. `Restricted`, `RemoteSigned`),
|
|
17
|
+
* or null if it can't be determined (PowerShell missing / errored).
|
|
18
|
+
*/
|
|
19
|
+
export declare function getEffectiveExecutionPolicy(): string | null;
|
|
20
|
+
/**
|
|
21
|
+
* Whether a policy blocks running unsigned local `.ps1` scripts — which is what
|
|
22
|
+
* npm and agents-cli generate (`npm.ps1`, `agents.ps1`). Under these the bare
|
|
23
|
+
* `agents` / `npm` commands fail in PowerShell with a security error even when
|
|
24
|
+
* on PATH. Pure — testable on any host.
|
|
25
|
+
*/
|
|
26
|
+
export declare function blocksLocalScripts(policy: string | null): boolean;
|
|
27
|
+
/**
|
|
28
|
+
* Resolve the npm global-bin directory (where the generated `agents` /
|
|
29
|
+
* `agents.cmd` launchers live, and where npm expects PATH to point) from the
|
|
30
|
+
* package entrypoint. On Windows npm places bin launchers directly in the
|
|
31
|
+
* prefix root, so the bin dir is the prefix itself.
|
|
32
|
+
*
|
|
33
|
+
* entry = `<prefix>/node_modules/@phnx-labs/agents-cli/dist/index.js` → `<prefix>`
|
|
34
|
+
*/
|
|
35
|
+
export declare function npmGlobalBinFromEntry(entryJsPath: string): string;
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Windows User PATH + execution-policy primitives.
|
|
3
|
+
*
|
|
4
|
+
* The single place that mutates the Windows User PATH via the .NET environment
|
|
5
|
+
* API (which writes the registry AND broadcasts WM_SETTINGCHANGE — the correct
|
|
6
|
+
* analog of editing a shell rc file: no `setx` truncation, no manual step).
|
|
7
|
+
* Consumers: `shims.ts` (shims dir) and `scripts/postinstall.js` (npm global-bin
|
|
8
|
+
* dir, so the `agents` command itself resolves).
|
|
9
|
+
*
|
|
10
|
+
* Leaf module — imports only `child_process` and `path` so it is cheap to load
|
|
11
|
+
* from the npm lifecycle script without pulling the rest of the CLI.
|
|
12
|
+
*/
|
|
13
|
+
import { execFileSync } from 'child_process';
|
|
14
|
+
import * as path from 'path';
|
|
15
|
+
/**
|
|
16
|
+
* Prepend `dir` to the Windows User PATH. Idempotent: a no-op when `dir` is
|
|
17
|
+
* already first; moves it to the front when it exists but is positioned later
|
|
18
|
+
* (e.g. appended by an older install) so it overrides conflicting entries.
|
|
19
|
+
* `dir` is passed via an env var so it is never interpolated into the script
|
|
20
|
+
* text.
|
|
21
|
+
*/
|
|
22
|
+
export function prependToWindowsUserPath(dir) {
|
|
23
|
+
const script = [
|
|
24
|
+
'$d = $env:AGENTS_WINPATH_DIR',
|
|
25
|
+
"$u = [Environment]::GetEnvironmentVariable('Path','User')",
|
|
26
|
+
"if ($null -eq $u) { $u = '' }",
|
|
27
|
+
"$parts = @($u -split ';' | Where-Object { $_ -ne '' })",
|
|
28
|
+
// Already first — nothing to do
|
|
29
|
+
"if ($parts.Count -gt 0 -and $parts[0] -eq $d) { 'present' } else {",
|
|
30
|
+
// Remove any existing occurrence then prepend, matching POSIX `export PATH="${dir}:$PATH"`
|
|
31
|
+
" $newParts = @($d) + @($parts | Where-Object { $_ -ne $d })",
|
|
32
|
+
" [Environment]::SetEnvironmentVariable('Path', ($newParts -join ';'), 'User')",
|
|
33
|
+
" 'added'",
|
|
34
|
+
'}',
|
|
35
|
+
].join('\n');
|
|
36
|
+
try {
|
|
37
|
+
const out = execFileSync('powershell', ['-NoProfile', '-NonInteractive', '-Command', script], {
|
|
38
|
+
encoding: 'utf-8',
|
|
39
|
+
env: { ...process.env, AGENTS_WINPATH_DIR: dir },
|
|
40
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
41
|
+
}).trim();
|
|
42
|
+
return { success: true, alreadyPresent: out.includes('present') };
|
|
43
|
+
}
|
|
44
|
+
catch (err) {
|
|
45
|
+
return { success: false, error: `Could not update the Windows user PATH: ${err.message}` };
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* The effective PowerShell execution policy (e.g. `Restricted`, `RemoteSigned`),
|
|
50
|
+
* or null if it can't be determined (PowerShell missing / errored).
|
|
51
|
+
*/
|
|
52
|
+
export function getEffectiveExecutionPolicy() {
|
|
53
|
+
try {
|
|
54
|
+
const out = execFileSync('powershell', ['-NoProfile', '-NonInteractive', '-Command', 'Get-ExecutionPolicy'], {
|
|
55
|
+
encoding: 'utf-8',
|
|
56
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
57
|
+
}).trim();
|
|
58
|
+
return out || null;
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Whether a policy blocks running unsigned local `.ps1` scripts — which is what
|
|
66
|
+
* npm and agents-cli generate (`npm.ps1`, `agents.ps1`). Under these the bare
|
|
67
|
+
* `agents` / `npm` commands fail in PowerShell with a security error even when
|
|
68
|
+
* on PATH. Pure — testable on any host.
|
|
69
|
+
*/
|
|
70
|
+
export function blocksLocalScripts(policy) {
|
|
71
|
+
if (!policy)
|
|
72
|
+
return false;
|
|
73
|
+
const p = policy.trim().toLowerCase();
|
|
74
|
+
return p === 'restricted' || p === 'allsigned';
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Resolve the npm global-bin directory (where the generated `agents` /
|
|
78
|
+
* `agents.cmd` launchers live, and where npm expects PATH to point) from the
|
|
79
|
+
* package entrypoint. On Windows npm places bin launchers directly in the
|
|
80
|
+
* prefix root, so the bin dir is the prefix itself.
|
|
81
|
+
*
|
|
82
|
+
* entry = `<prefix>/node_modules/@phnx-labs/agents-cli/dist/index.js` → `<prefix>`
|
|
83
|
+
*/
|
|
84
|
+
export function npmGlobalBinFromEntry(entryJsPath) {
|
|
85
|
+
return path.resolve(path.dirname(entryJsPath), '..', '..', '..', '..');
|
|
86
|
+
}
|
package/dist/lib/plugins.d.ts
CHANGED
|
@@ -41,6 +41,20 @@ export declare function discoverPlugins(opts?: {
|
|
|
41
41
|
cwd?: string;
|
|
42
42
|
}): DiscoveredPlugin[];
|
|
43
43
|
export declare function buildDiscoveredPlugin(pluginRoot: string, manifest: PluginManifest, spec?: MarketplaceSpec): DiscoveredPlugin;
|
|
44
|
+
/** One category of resources a plugin packages, for display breakdowns. */
|
|
45
|
+
export interface PluginResourceGroup {
|
|
46
|
+
/** Category key: 'skills' | 'commands' | 'subagents' | 'hooks' | 'mcp' | 'lsp' | 'monitors' | 'bin' | 'scripts' | 'settings'. */
|
|
47
|
+
label: string;
|
|
48
|
+
/** Display names — slash-prefixed for skills/commands (e.g. `/code:dispatch`), raw names otherwise. */
|
|
49
|
+
items: string[];
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Ordered, non-empty resource groups a plugin packages. Single source of truth
|
|
53
|
+
* for the breakdown shown by the plugin picker, `agents inspect --plugins`, and
|
|
54
|
+
* its detail view. Empty categories are omitted; `settings` appears only when
|
|
55
|
+
* the plugin merges non-permission settings.
|
|
56
|
+
*/
|
|
57
|
+
export declare function pluginResourceGroups(plugin: DiscoveredPlugin): PluginResourceGroup[];
|
|
44
58
|
export declare function inspectPluginCapabilities(pluginRoot: string): PluginCapabilities;
|
|
45
59
|
export declare function hasPluginExecSurfaces(capabilities: PluginCapabilities): boolean;
|
|
46
60
|
export declare function pluginCapabilityLabels(capabilities: PluginCapabilities): string[];
|
package/dist/lib/plugins.js
CHANGED
|
@@ -105,6 +105,29 @@ export function buildDiscoveredPlugin(pluginRoot, manifest, spec = { kind: 'user
|
|
|
105
105
|
hasSettings: pluginHasNonPermissionSettings(pluginRoot),
|
|
106
106
|
};
|
|
107
107
|
}
|
|
108
|
+
/**
|
|
109
|
+
* Ordered, non-empty resource groups a plugin packages. Single source of truth
|
|
110
|
+
* for the breakdown shown by the plugin picker, `agents inspect --plugins`, and
|
|
111
|
+
* its detail view. Empty categories are omitted; `settings` appears only when
|
|
112
|
+
* the plugin merges non-permission settings.
|
|
113
|
+
*/
|
|
114
|
+
export function pluginResourceGroups(plugin) {
|
|
115
|
+
const groups = [
|
|
116
|
+
{ label: 'skills', items: plugin.skills.map((s) => `/${plugin.name}:${s}`) },
|
|
117
|
+
{ label: 'commands', items: plugin.commands.map((c) => `/${plugin.name}:${c}`) },
|
|
118
|
+
{ label: 'subagents', items: plugin.agentDefs },
|
|
119
|
+
{ label: 'hooks', items: plugin.hooks },
|
|
120
|
+
{ label: 'mcp', items: plugin.mcpServers },
|
|
121
|
+
{ label: 'lsp', items: plugin.lspServers },
|
|
122
|
+
{ label: 'monitors', items: plugin.monitors },
|
|
123
|
+
{ label: 'bin', items: plugin.bin },
|
|
124
|
+
{ label: 'scripts', items: plugin.scripts },
|
|
125
|
+
];
|
|
126
|
+
const out = groups.filter((g) => g.items.length > 0);
|
|
127
|
+
if (plugin.hasSettings)
|
|
128
|
+
out.push({ label: 'settings', items: ['settings.json'] });
|
|
129
|
+
return out;
|
|
130
|
+
}
|
|
108
131
|
export function inspectPluginCapabilities(pluginRoot) {
|
|
109
132
|
const manifest = loadPluginManifest(pluginRoot);
|
|
110
133
|
const plugin = manifest ? buildDiscoveredPlugin(pluginRoot, manifest) : null;
|
|
@@ -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 { transformSubagentForClaude } from './subagents.js';
|
|
50
51
|
import { compileRulesForProject } from './rules/compile.js';
|
|
51
52
|
import { discoverPluginsInDir, hasPluginExecSurfaces, inspectPluginCapabilities } from './plugins.js';
|
|
52
53
|
import { MARKETPLACE_NAME, PROJECT_MARKETPLACE_NAME, SYSTEM_MARKETPLACE_NAME, addPluginToSettings, copyPluginToMarketplace, marketplaceNameFor, marketplaceRoot, pluginInstallDir, registerMarketplace, removePluginFromSettings, syncMarketplaceManifest, } from './plugin-marketplace.js';
|
|
@@ -114,10 +115,22 @@ function touchLaunchSentinel(agent, version, cwd) {
|
|
|
114
115
|
}
|
|
115
116
|
}
|
|
116
117
|
const CLAUDE_MIRROR_PLANS = [
|
|
117
|
-
{ srcSubdir: 'subagents', destSubdir: 'agents',
|
|
118
|
-
{ srcSubdir: 'commands', destSubdir: 'commands',
|
|
119
|
-
{ srcSubdir: 'skills', destSubdir: 'skills',
|
|
118
|
+
{ srcSubdir: 'subagents', destSubdir: 'agents', mode: 'subagent-write' },
|
|
119
|
+
{ srcSubdir: 'commands', destSubdir: 'commands', mode: 'file-symlink' },
|
|
120
|
+
{ srcSubdir: 'skills', destSubdir: 'skills', mode: 'dir-symlink' },
|
|
120
121
|
];
|
|
122
|
+
/**
|
|
123
|
+
* Marker prepended-as-trailing-comment to every subagent file WE generate.
|
|
124
|
+
* It's an HTML comment — invisible to the markdown the agent reads — placed on
|
|
125
|
+
* the last line so it never disturbs the leading `---` frontmatter block.
|
|
126
|
+
*
|
|
127
|
+
* Ownership rule (the one don't-clobber decision for written, non-symlink
|
|
128
|
+
* files): we only overwrite a `.claude/agents/<name>.md` whose content carries
|
|
129
|
+
* this marker. A user-authored file (no marker) or a symlink at the dest is
|
|
130
|
+
* left untouched. A marker beats an mtime/sidecar sentinel because it travels
|
|
131
|
+
* with the file across copies and git, and needs no out-of-band state.
|
|
132
|
+
*/
|
|
133
|
+
const GENERATED_SUBAGENT_MARKER = '<!-- agents-cli:generated-subagent';
|
|
121
134
|
function mirrorWorkspaceResources(cwd, agent) {
|
|
122
135
|
// v1: claude-only. Other agents have workspace conventions we haven't
|
|
123
136
|
// mapped (amp: ~/.config/amp; antigravity: ~/.gemini/antigravity-cli;
|
|
@@ -147,13 +160,20 @@ function mirrorWorkspaceResources(cwd, agent) {
|
|
|
147
160
|
continue;
|
|
148
161
|
const destDir = path.join(agentWorkspaceDir, plan.destSubdir);
|
|
149
162
|
fs.mkdirSync(destDir, { recursive: true });
|
|
163
|
+
// Subagents flatten N source files into one written .md — not a symlink.
|
|
164
|
+
if (plan.mode === 'subagent-write') {
|
|
165
|
+
const r = writeProjectSubagents(srcDir, destDir, cwd);
|
|
166
|
+
links += r.links;
|
|
167
|
+
skipped.push(...r.skipped);
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
150
170
|
const entries = fs.readdirSync(srcDir, { withFileTypes: true });
|
|
151
171
|
for (const entry of entries) {
|
|
152
172
|
if (entry.name.startsWith('.'))
|
|
153
173
|
continue;
|
|
154
|
-
if (plan.
|
|
174
|
+
if (plan.mode === 'dir-symlink' && !entry.isDirectory())
|
|
155
175
|
continue;
|
|
156
|
-
if (
|
|
176
|
+
if (plan.mode === 'file-symlink' && !entry.isFile() && !entry.isSymbolicLink())
|
|
157
177
|
continue;
|
|
158
178
|
const srcPath = path.join(srcDir, entry.name);
|
|
159
179
|
const destPath = path.join(destDir, entry.name);
|
|
@@ -167,6 +187,91 @@ function mirrorWorkspaceResources(cwd, agent) {
|
|
|
167
187
|
}
|
|
168
188
|
return { links, skipped };
|
|
169
189
|
}
|
|
190
|
+
/**
|
|
191
|
+
* Mirror project subagents into `<cwd>/.claude/agents/`. The canonical source
|
|
192
|
+
* shape is a DIRECTORY containing AGENT.md (e.g. `.agents/subagents/probe/AGENT.md`)
|
|
193
|
+
* — confirmed by the detector (versions.ts) and lister (subagents.ts). Each
|
|
194
|
+
* such directory is flattened via transformSubagentForClaude (the exact writer
|
|
195
|
+
* the version-home sync uses) into a single `<name>.md`, then written under an
|
|
196
|
+
* ownership marker so a re-launch refreshes our file but never clobbers a
|
|
197
|
+
* user-authored one.
|
|
198
|
+
*
|
|
199
|
+
* Returns the same {links, skipped} shape the symlink path reports, so the
|
|
200
|
+
* caller's accounting is uniform across resource kinds.
|
|
201
|
+
*/
|
|
202
|
+
function writeProjectSubagents(srcDir, destDir, cwd) {
|
|
203
|
+
let links = 0;
|
|
204
|
+
const skipped = [];
|
|
205
|
+
for (const entry of fs.readdirSync(srcDir, { withFileTypes: true })) {
|
|
206
|
+
if (entry.name.startsWith('.'))
|
|
207
|
+
continue;
|
|
208
|
+
if (!entry.isDirectory())
|
|
209
|
+
continue;
|
|
210
|
+
const subagentDir = path.join(srcDir, entry.name);
|
|
211
|
+
if (!fs.existsSync(path.join(subagentDir, 'AGENT.md')))
|
|
212
|
+
continue;
|
|
213
|
+
const destPath = path.join(destDir, `${entry.name}.md`);
|
|
214
|
+
if (writeSubagentIfOwned(subagentDir, destPath)) {
|
|
215
|
+
links += 1;
|
|
216
|
+
}
|
|
217
|
+
else {
|
|
218
|
+
skipped.push(path.relative(cwd, destPath));
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
return { links, skipped };
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* Write a flattened subagent file at `destPath`, refusing to clobber user state.
|
|
225
|
+
*
|
|
226
|
+
* - dest missing → write fresh.
|
|
227
|
+
* - dest is our generation → overwrite (refresh; carries GENERATED_SUBAGENT_MARKER).
|
|
228
|
+
* - dest is a symlink / any
|
|
229
|
+
* non-regular file → SKIP (user state we don't own).
|
|
230
|
+
* - dest is a regular file
|
|
231
|
+
* without our marker → SKIP (hand-authored .claude/agents/<name>.md).
|
|
232
|
+
*
|
|
233
|
+
* Returns true when our file is present (written now or already current),
|
|
234
|
+
* false when we left a user-owned dest alone.
|
|
235
|
+
*/
|
|
236
|
+
function writeSubagentIfOwned(subagentDir, destPath) {
|
|
237
|
+
let existing = null;
|
|
238
|
+
let destLstat = null;
|
|
239
|
+
try {
|
|
240
|
+
destLstat = fs.lstatSync(destPath);
|
|
241
|
+
}
|
|
242
|
+
catch { /* missing — write fresh */ }
|
|
243
|
+
if (destLstat) {
|
|
244
|
+
if (!destLstat.isFile())
|
|
245
|
+
return false; // symlink/dir/etc. — user state
|
|
246
|
+
try {
|
|
247
|
+
existing = fs.readFileSync(destPath, 'utf-8');
|
|
248
|
+
}
|
|
249
|
+
catch {
|
|
250
|
+
return false;
|
|
251
|
+
}
|
|
252
|
+
if (!existing.includes(GENERATED_SUBAGENT_MARKER))
|
|
253
|
+
return false; // hand-authored
|
|
254
|
+
}
|
|
255
|
+
let body;
|
|
256
|
+
try {
|
|
257
|
+
body = transformSubagentForClaude(subagentDir);
|
|
258
|
+
}
|
|
259
|
+
catch {
|
|
260
|
+
return false; // malformed AGENT.md — don't write a broken file
|
|
261
|
+
}
|
|
262
|
+
const content = `${body}\n\n${GENERATED_SUBAGENT_MARKER} — edit .agents/subagents/${path.basename(subagentDir)}/ instead -->\n`;
|
|
263
|
+
// Skip-fast: identical content already on disk → no write (keeps mtime stable).
|
|
264
|
+
if (existing === content)
|
|
265
|
+
return true;
|
|
266
|
+
try {
|
|
267
|
+
fs.mkdirSync(path.dirname(destPath), { recursive: true });
|
|
268
|
+
fs.writeFileSync(destPath, content);
|
|
269
|
+
}
|
|
270
|
+
catch {
|
|
271
|
+
return false;
|
|
272
|
+
}
|
|
273
|
+
return true;
|
|
274
|
+
}
|
|
170
275
|
/**
|
|
171
276
|
* Create or refresh a symlink at `destPath` pointing at `srcPath`. Returns
|
|
172
277
|
* true if we wrote (or already had) the link, false if we skipped because
|
package/dist/lib/registry.js
CHANGED
|
@@ -61,6 +61,13 @@ export function removeRegistry(type, name) {
|
|
|
61
61
|
}
|
|
62
62
|
return false;
|
|
63
63
|
}
|
|
64
|
+
/**
|
|
65
|
+
* Cap every registry network call. Without this a slow or unreachable registry
|
|
66
|
+
* hangs the calling command indefinitely (`agents add`, `agents mcp`, package
|
|
67
|
+
* resolution) — and makes CI flake when the registry is unreachable. On timeout
|
|
68
|
+
* the fetch aborts, callers fall back to their git/no-match path.
|
|
69
|
+
*/
|
|
70
|
+
const REGISTRY_FETCH_TIMEOUT_MS = 8000;
|
|
64
71
|
async function fetchMcpRegistry(url, query, limit = 20, apiKey) {
|
|
65
72
|
const params = new URLSearchParams();
|
|
66
73
|
if (query)
|
|
@@ -73,7 +80,10 @@ async function fetchMcpRegistry(url, query, limit = 20, apiKey) {
|
|
|
73
80
|
if (apiKey) {
|
|
74
81
|
headers['Authorization'] = `Bearer ${apiKey}`;
|
|
75
82
|
}
|
|
76
|
-
const response = await fetch(fullUrl, {
|
|
83
|
+
const response = await fetch(fullUrl, {
|
|
84
|
+
headers,
|
|
85
|
+
signal: AbortSignal.timeout(REGISTRY_FETCH_TIMEOUT_MS),
|
|
86
|
+
});
|
|
77
87
|
if (!response.ok) {
|
|
78
88
|
throw new Error(`Registry request failed: ${response.status} ${response.statusText}`);
|
|
79
89
|
}
|
|
@@ -191,7 +201,10 @@ async function fetchSkillIndex(url, apiKey) {
|
|
|
191
201
|
const headers = { Accept: 'application/json' };
|
|
192
202
|
if (apiKey)
|
|
193
203
|
headers['Authorization'] = `Bearer ${apiKey}`;
|
|
194
|
-
const response = await fetch(url, {
|
|
204
|
+
const response = await fetch(url, {
|
|
205
|
+
headers,
|
|
206
|
+
signal: AbortSignal.timeout(REGISTRY_FETCH_TIMEOUT_MS),
|
|
207
|
+
});
|
|
195
208
|
if (!response.ok) {
|
|
196
209
|
throw new Error(`Registry request failed: ${response.status} ${response.statusText}`);
|
|
197
210
|
}
|
package/dist/lib/runner.js
CHANGED
|
@@ -22,6 +22,7 @@ const AGENT_COMMANDS = {
|
|
|
22
22
|
codex: ['codex', 'exec', '--sandbox', 'workspace-write', '{prompt}', '--json'],
|
|
23
23
|
gemini: ['gemini', '{prompt}', '--output-format', 'stream-json'],
|
|
24
24
|
kimi: ['kimi', '--prompt', '{prompt}', '--output-format', 'stream-json'],
|
|
25
|
+
droid: ['droid', 'exec', '{prompt}', '-o', 'stream-json'],
|
|
25
26
|
};
|
|
26
27
|
/** Build the full CLI argv for executing a job, applying mode, model, and permission flags. */
|
|
27
28
|
export function buildJobCommand(config, resolvedPrompt) {
|
|
@@ -105,6 +106,19 @@ export function buildJobCommand(config, resolvedPrompt) {
|
|
|
105
106
|
}
|
|
106
107
|
appendModelAndReasoning(cmd, config);
|
|
107
108
|
}
|
|
109
|
+
if (config.agent === 'droid') {
|
|
110
|
+
// droid exec defaults to read-only (plan). Escalate autonomy per mode.
|
|
111
|
+
if (mode === 'edit') {
|
|
112
|
+
cmd.push('--auto', 'low');
|
|
113
|
+
}
|
|
114
|
+
else if (mode === 'auto') {
|
|
115
|
+
cmd.push('--auto', 'high');
|
|
116
|
+
}
|
|
117
|
+
else if (mode === 'skip') {
|
|
118
|
+
cmd.push('--skip-permissions-unsafe');
|
|
119
|
+
}
|
|
120
|
+
appendModelAndReasoning(cmd, config);
|
|
121
|
+
}
|
|
108
122
|
return cmd;
|
|
109
123
|
}
|
|
110
124
|
/**
|
package/dist/lib/sandbox.js
CHANGED
|
@@ -10,7 +10,7 @@ import * as fs from 'fs';
|
|
|
10
10
|
import * as path from 'path';
|
|
11
11
|
import * as os from 'os';
|
|
12
12
|
import { setGeminiAutoUpdateDisabled, updateGeminiSettings } from './gemini-settings.js';
|
|
13
|
-
import { getRoutinesDir } from './state.js';
|
|
13
|
+
import { getRoutinesDir, getUserAgentsDir } from './state.js';
|
|
14
14
|
function resolveRealHome() {
|
|
15
15
|
const home = os.homedir();
|
|
16
16
|
try {
|
|
@@ -58,7 +58,10 @@ function tomlString(value) {
|
|
|
58
58
|
}
|
|
59
59
|
/** Build a restricted environment for a sandboxed process, setting HOME to the overlay. */
|
|
60
60
|
export function buildSpawnEnv(overlayHome, extraEnv) {
|
|
61
|
-
const env = {
|
|
61
|
+
const env = {
|
|
62
|
+
HOME: overlayHome,
|
|
63
|
+
AGENTS_USER_DIR: getUserAgentsDir(),
|
|
64
|
+
};
|
|
62
65
|
for (const key of ENV_ALLOWLIST) {
|
|
63
66
|
if (process.env[key]) {
|
|
64
67
|
env[key] = process.env[key];
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Settings carry-forward between version homes.
|
|
3
|
+
*
|
|
4
|
+
* Every installed version gets an isolated `home/`, so user-authored
|
|
5
|
+
* preferences (settings.json, config.toml, keybindings, auth) written while
|
|
6
|
+
* running one version do not exist in a freshly installed one. Resources
|
|
7
|
+
* managed in ~/.agents/ (commands, skills, hooks, rules, MCP YAML, plugins,
|
|
8
|
+
* subagents) are synced into every version home by syncResourcesToVersion and
|
|
9
|
+
* are deliberately NOT listed here — copying them would fight that sync.
|
|
10
|
+
*
|
|
11
|
+
* The manifest below classifies the remaining per-agent files, and
|
|
12
|
+
* carryForwardSettings() fills gaps in a target version home from a source
|
|
13
|
+
* version home. It never overwrites a value the target already has: scalars
|
|
14
|
+
* keep the target's value, objects merge recursively, arrays union. That makes
|
|
15
|
+
* the operation idempotent and safe to run on every `agents add` / `agents use`.
|
|
16
|
+
*/
|
|
17
|
+
import type { AgentId } from './types.js';
|
|
18
|
+
export interface CarryForwardResult {
|
|
19
|
+
/** Manifest rel paths that were created or updated in the target home. */
|
|
20
|
+
applied: string[];
|
|
21
|
+
/** Backup directory holding pre-merge copies of modified target files, if any. */
|
|
22
|
+
backupDir?: string;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Fill gaps in `target` from `source` without overwriting target values:
|
|
26
|
+
* missing keys are copied, plain objects recurse, and everything else —
|
|
27
|
+
* scalars AND arrays — keeps the target's value. Arrays deliberately do not
|
|
28
|
+
* union: other writers (factory sync, hooks registration) mutate array entries
|
|
29
|
+
* in place, so a union would keep re-appending stale pre-mutation copies from
|
|
30
|
+
* the source on every carry (e.g. a user hook duplicated after the system
|
|
31
|
+
* hooks were merged into it). Returns a new object.
|
|
32
|
+
*/
|
|
33
|
+
export declare function fillGaps(target: Record<string, unknown>, source: Record<string, unknown>): Record<string, unknown>;
|
|
34
|
+
/**
|
|
35
|
+
* Carry user settings forward from one version home into another. Both paths
|
|
36
|
+
* are version-home roots (the directory containing `.claude/` / `.codex/`).
|
|
37
|
+
* Only fills gaps — never overwrites target values — so it is idempotent.
|
|
38
|
+
*/
|
|
39
|
+
export declare function carryForwardSettings(agent: AgentId, fromHome: string, toHome: string): CarryForwardResult;
|