@phnx-labs/agents-cli 1.20.12 → 1.20.14
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 +30 -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/doctor.js +51 -7
- package/dist/commands/exec.js +25 -4
- package/dist/commands/import.js +17 -6
- package/dist/commands/inspect.d.ts +28 -1
- package/dist/commands/inspect.js +330 -47
- 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/prune.js +8 -5
- 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.d.ts +1 -0
- package/dist/commands/view.js +56 -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.d.ts +21 -1
- package/dist/lib/hooks.js +69 -7
- 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 +24 -0
- package/dist/lib/plugins.js +37 -2
- package/dist/lib/project-launch.js +110 -5
- package/dist/lib/registry.js +15 -2
- package/dist/lib/rotate.d.ts +7 -0
- package/dist/lib/rotate.js +17 -7
- 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/exec.js
CHANGED
|
@@ -34,6 +34,36 @@ export function normalizeMode(input) {
|
|
|
34
34
|
return v;
|
|
35
35
|
throw new Error(`Invalid mode '${input}'. Use one of: ${ALL_MODES.join(', ')} (or 'full' as a deprecated alias for 'skip').`);
|
|
36
36
|
}
|
|
37
|
+
/**
|
|
38
|
+
* Detect the headless-plan stall footgun.
|
|
39
|
+
*
|
|
40
|
+
* A slash command (e.g. `/code:commit`) run headless under the IMPLICIT default
|
|
41
|
+
* `plan` mode hangs forever: plan is read-only, so the agent calls ExitPlanMode
|
|
42
|
+
* to start working, and in a headless run there is no TTY to approve it. The
|
|
43
|
+
* process just sits there. Callers use this to fail fast with a fix instead.
|
|
44
|
+
*
|
|
45
|
+
* Returns the offending command token (e.g. `/code:commit`) when the run should
|
|
46
|
+
* be blocked, else null. Guards are deliberately narrow:
|
|
47
|
+
* - interactive runs / no prompt -> not headless, never blocks
|
|
48
|
+
* - explicit --mode (modeIsDefault false) -> respected; `--mode plan` is a
|
|
49
|
+
* legitimate read-only command run and must not be blocked
|
|
50
|
+
* - resolved mode is not `plan` -> only plan stalls at ExitPlanMode
|
|
51
|
+
* - prompt is not a slash command -> natural-language read-only prompts
|
|
52
|
+
* ("summarize commits") are a valid default-plan use and must not be blocked
|
|
53
|
+
*/
|
|
54
|
+
export function headlessPlanStallCommand(args) {
|
|
55
|
+
const { prompt, interactive, mode, modeIsDefault } = args;
|
|
56
|
+
if (interactive === true || prompt === undefined)
|
|
57
|
+
return null;
|
|
58
|
+
if (!modeIsDefault)
|
|
59
|
+
return null;
|
|
60
|
+
if (normalizeMode(mode) !== 'plan')
|
|
61
|
+
return null;
|
|
62
|
+
const trimmed = prompt.trimStart();
|
|
63
|
+
if (!trimmed.startsWith('/'))
|
|
64
|
+
return null;
|
|
65
|
+
return trimmed.split(/\s+/)[0];
|
|
66
|
+
}
|
|
37
67
|
/**
|
|
38
68
|
* Resolve a requested mode against an agent's capability table.
|
|
39
69
|
*
|
|
@@ -71,6 +101,19 @@ export function resolveMode(agent, requested) {
|
|
|
71
101
|
export function defaultModeFor(agent) {
|
|
72
102
|
return AGENTS[agent].capabilities.modes[0];
|
|
73
103
|
}
|
|
104
|
+
/**
|
|
105
|
+
* Resolve interactive vs headless. Explicit flags are definitive and win over
|
|
106
|
+
* inference: `--interactive` forces interactive, `--headless` forces headless.
|
|
107
|
+
* With neither flag, prompt presence decides (prompt -> headless, none -> interactive).
|
|
108
|
+
* `--interactive` takes precedence over `--headless`; the CLI layer rejects passing both.
|
|
109
|
+
*/
|
|
110
|
+
export function resolveInteractive(options) {
|
|
111
|
+
if (options.interactive === true)
|
|
112
|
+
return true;
|
|
113
|
+
if (options.headless === true)
|
|
114
|
+
return false;
|
|
115
|
+
return options.prompt === undefined;
|
|
116
|
+
}
|
|
74
117
|
/** Pattern for valid environment variable names (C identifier rules). */
|
|
75
118
|
const EXEC_ENV_KEY_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
76
119
|
/** Parse a single KEY=VALUE string into a tuple, validating the key name. */
|
|
@@ -352,16 +395,40 @@ export const AGENT_COMMANDS = {
|
|
|
352
395
|
jsonFlags: ['--output-format', 'stream-json'],
|
|
353
396
|
modelFlag: '--model',
|
|
354
397
|
},
|
|
398
|
+
// Factory AI Droid (`droid exec` for headless, `droid` for TUI). Flags from
|
|
399
|
+
// docs.factory.ai CLI reference: prompt is positional; --auto low|medium|high
|
|
400
|
+
// escalates autonomy (default is read-only); --skip-permissions-unsafe drops
|
|
401
|
+
// all guardrails; -o stream-json streams JSONL events; -m selects the model.
|
|
402
|
+
// The `exec` subcommand is dropped for interactive runs (see buildExecCommand).
|
|
403
|
+
droid: {
|
|
404
|
+
base: ['droid', 'exec'],
|
|
405
|
+
promptFlag: 'positional',
|
|
406
|
+
modeFlags: {
|
|
407
|
+
plan: [], // droid's default exec mode is read-only
|
|
408
|
+
edit: ['--auto', 'low'], // create/edit files, non-destructive
|
|
409
|
+
auto: ['--auto', 'high'], // full autonomy
|
|
410
|
+
skip: ['--skip-permissions-unsafe'],
|
|
411
|
+
},
|
|
412
|
+
jsonFlags: ['-o', 'stream-json'],
|
|
413
|
+
modelFlag: '-m',
|
|
414
|
+
},
|
|
355
415
|
};
|
|
356
416
|
/** Assemble the full CLI argument array for an agent invocation. */
|
|
357
417
|
export function buildExecCommand(options) {
|
|
358
418
|
const template = AGENT_COMMANDS[options.agent];
|
|
359
419
|
const cmd = [...template.base];
|
|
360
|
-
const interactive = options
|
|
361
|
-
// For Codex, 'exec' is the headless subcommand
|
|
362
|
-
// so we
|
|
363
|
-
|
|
364
|
-
|
|
420
|
+
const interactive = resolveInteractive(options);
|
|
421
|
+
// For Codex and Droid, 'exec' is the headless subcommand; for OpenCode, 'run'
|
|
422
|
+
// is. Drop it for interactive mode so we launch the TUI (`codex` / `droid` /
|
|
423
|
+
// `opencode`, each agent's default command) instead of the one-shot headless
|
|
424
|
+
// subcommand ('codex exec' / 'droid exec' / 'opencode run').
|
|
425
|
+
if (interactive) {
|
|
426
|
+
if ((options.agent === 'codex' || options.agent === 'droid') && cmd[1] === 'exec') {
|
|
427
|
+
cmd.splice(1, 1);
|
|
428
|
+
}
|
|
429
|
+
else if (options.agent === 'opencode' && cmd[1] === 'run') {
|
|
430
|
+
cmd.splice(1, 1);
|
|
431
|
+
}
|
|
365
432
|
}
|
|
366
433
|
// Use versioned alias if a specific version was requested (e.g., claude@2.1.98).
|
|
367
434
|
// Resolve to the absolute path of the shim so spawn doesn't depend on PATH —
|
|
@@ -455,7 +522,12 @@ export function buildExecCommand(options) {
|
|
|
455
522
|
// so the CLI launches its TUI. When --interactive is passed alongside a prompt
|
|
456
523
|
// we still forward the prompt so the agent receives it as the first message.
|
|
457
524
|
if (options.prompt !== undefined) {
|
|
458
|
-
if (
|
|
525
|
+
if (interactive && options.agent === 'opencode') {
|
|
526
|
+
// The OpenCode TUI takes an initial prompt via --prompt; a bare positional
|
|
527
|
+
// on the default command is parsed as a project path, not a message.
|
|
528
|
+
cmd.push('--prompt', options.prompt);
|
|
529
|
+
}
|
|
530
|
+
else if (template.promptFlag === 'positional') {
|
|
459
531
|
cmd.push(options.prompt);
|
|
460
532
|
}
|
|
461
533
|
else {
|
|
@@ -526,7 +598,7 @@ async function spawnAgent(options) {
|
|
|
526
598
|
const [executable, ...args] = cmd;
|
|
527
599
|
const timeoutMs = options.timeout ? parseTimeout(options.timeout) : undefined;
|
|
528
600
|
const piped = !process.stdout.isTTY;
|
|
529
|
-
const interactive = options
|
|
601
|
+
const interactive = resolveInteractive(options);
|
|
530
602
|
maybeRotate();
|
|
531
603
|
const timer = createTimer('agent.run', {
|
|
532
604
|
agent: options.agent,
|
package/dist/lib/hooks.d.ts
CHANGED
|
@@ -123,7 +123,27 @@ export declare function listCentralHooks(): HookEntry[];
|
|
|
123
123
|
*
|
|
124
124
|
* Hooks marked `enabled: false` are dropped from the returned map.
|
|
125
125
|
*/
|
|
126
|
-
export declare function parseHookManifest(
|
|
126
|
+
export declare function parseHookManifest(opts?: {
|
|
127
|
+
warn?: boolean;
|
|
128
|
+
}): Record<string, ManifestHook>;
|
|
129
|
+
/**
|
|
130
|
+
* Hook script files present on disk that no manifest entry declares — "dead"
|
|
131
|
+
* hooks. The registrar only wires manifest-declared hooks into an agent's
|
|
132
|
+
* native config (settings.json / config.toml), matching the installed file to a
|
|
133
|
+
* manifest entry by script basename. So a file whose basename matches no
|
|
134
|
+
* manifest `script:` is never registered: it occupies the hooks dir and shows
|
|
135
|
+
* up in listings, but no lifecycle event ever fires it.
|
|
136
|
+
*
|
|
137
|
+
* Pure on purpose (no disk reads) so it is trivially testable; callers pass the
|
|
138
|
+
* installed hook names and the manifest's script paths.
|
|
139
|
+
*/
|
|
140
|
+
export declare function unmanagedHookNames(installedHookNames: string[], manifestScripts: string[]): string[];
|
|
141
|
+
/**
|
|
142
|
+
* The dead hooks (see {@link unmanagedHookNames}) sitting in one version home.
|
|
143
|
+
* Reads the merged hook manifest silently — a diagnostic must not emit the
|
|
144
|
+
* shadow/override warnings the registrar path prints.
|
|
145
|
+
*/
|
|
146
|
+
export declare function listUnmanagedHooksInVersionHome(agent: AgentId, version: string): string[];
|
|
127
147
|
export declare function registerHooksToSettings(agentId: AgentId, versionHome: string, hookManifest?: Record<string, ManifestHook>, agentsDirOverride?: string): {
|
|
128
148
|
registered: string[];
|
|
129
149
|
errors: string[];
|
package/dist/lib/hooks.js
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
* and syncing them across version switches.
|
|
9
9
|
*/
|
|
10
10
|
import * as fs from 'fs';
|
|
11
|
+
import * as os from 'os';
|
|
11
12
|
import * as path from 'path';
|
|
12
13
|
import * as yaml from 'yaml';
|
|
13
14
|
import * as TOML from 'smol-toml';
|
|
@@ -53,9 +54,41 @@ function getManagedHookPrefixes() {
|
|
|
53
54
|
path.join(getSystemAgentsDir(), 'hooks') + path.sep,
|
|
54
55
|
];
|
|
55
56
|
}
|
|
57
|
+
/**
|
|
58
|
+
* Convert an absolute path under HOME to a portable ~/... form with forward
|
|
59
|
+
* slashes. Hook commands stored this way work on both macOS and Windows:
|
|
60
|
+
* absolute Windows paths break in bash because backslashes are stripped as
|
|
61
|
+
* escape characters, whereas ~/... paths expand correctly via the ~/.claude
|
|
62
|
+
* symlink/junction on both platforms.
|
|
63
|
+
*/
|
|
64
|
+
function toPortableCommand(absPath) {
|
|
65
|
+
const home = os.homedir();
|
|
66
|
+
const normalized = absPath.split(path.sep).join('/');
|
|
67
|
+
const homeNorm = home.split(path.sep).join('/');
|
|
68
|
+
if (normalized.startsWith(homeNorm + '/')) {
|
|
69
|
+
return '~/' + normalized.slice(homeNorm.length + 1);
|
|
70
|
+
}
|
|
71
|
+
return normalized;
|
|
72
|
+
}
|
|
56
73
|
function isManagedHookCommand(command, prefixes) {
|
|
74
|
+
// Expand ~/... so tilde-form portable commands can be matched against
|
|
75
|
+
// absolute managed prefixes.
|
|
76
|
+
let expanded = command;
|
|
77
|
+
if (command.startsWith('~/')) {
|
|
78
|
+
expanded = path.join(os.homedir(), command.slice(2));
|
|
79
|
+
}
|
|
80
|
+
// Resolve the directory through symlinks/junctions (e.g. ~/.claude on
|
|
81
|
+
// Windows is a junction to the versioned home dir where prefixes live).
|
|
82
|
+
// Resolve the dir, not the full path — the file may not exist after removal.
|
|
83
|
+
const dir = path.dirname(expanded);
|
|
84
|
+
let resolvedDir = dir;
|
|
85
|
+
try {
|
|
86
|
+
resolvedDir = fs.realpathSync(dir);
|
|
87
|
+
}
|
|
88
|
+
catch { /* absent or broken link */ }
|
|
89
|
+
const resolved = path.join(resolvedDir, path.basename(expanded));
|
|
57
90
|
for (const prefix of prefixes) {
|
|
58
|
-
if (
|
|
91
|
+
if (resolved.startsWith(prefix))
|
|
59
92
|
return true;
|
|
60
93
|
}
|
|
61
94
|
return false;
|
|
@@ -80,9 +113,9 @@ function resolveHookCommand(name, hookDef, resolveScript) {
|
|
|
80
113
|
// No caching opted in — make sure a previously generated shim from an
|
|
81
114
|
// earlier `cache:` config is gone so the JSONL doesn't keep claiming hits.
|
|
82
115
|
removeHookShim(name);
|
|
83
|
-
return scriptPath;
|
|
116
|
+
return toPortableCommand(scriptPath);
|
|
84
117
|
}
|
|
85
|
-
return generateHookShim({ name, scriptPath, cache });
|
|
118
|
+
return toPortableCommand(generateHookShim({ name, scriptPath, cache }));
|
|
86
119
|
}
|
|
87
120
|
/**
|
|
88
121
|
* Extensions that are NEVER hooks — docs, configuration, plain data. A file
|
|
@@ -617,7 +650,8 @@ export function listCentralHooks() {
|
|
|
617
650
|
*
|
|
618
651
|
* Hooks marked `enabled: false` are dropped from the returned map.
|
|
619
652
|
*/
|
|
620
|
-
export function parseHookManifest() {
|
|
653
|
+
export function parseHookManifest(opts = {}) {
|
|
654
|
+
const warn = opts.warn !== false;
|
|
621
655
|
const merged = {};
|
|
622
656
|
const systemHooks = {};
|
|
623
657
|
// System layer: hooks: section of agents.yaml (npm-shipped, separate repo).
|
|
@@ -640,7 +674,7 @@ export function parseHookManifest() {
|
|
|
640
674
|
const meta = yaml.parse(fs.readFileSync(userMetaPath, 'utf-8'));
|
|
641
675
|
if (meta?.hooks)
|
|
642
676
|
for (const [name, def] of Object.entries(meta.hooks)) {
|
|
643
|
-
if (systemHooks[name] && def.override !== true) {
|
|
677
|
+
if (warn && systemHooks[name] && def.override !== true) {
|
|
644
678
|
const action = def.enabled === false ? 'disables' : 'shadows';
|
|
645
679
|
console.warn(`[agents hooks] User-layer hook '${name}' ${action} system-shipped hook. Set 'override: true' to silence this warning.`);
|
|
646
680
|
}
|
|
@@ -656,6 +690,35 @@ export function parseHookManifest() {
|
|
|
656
690
|
}
|
|
657
691
|
return merged;
|
|
658
692
|
}
|
|
693
|
+
/**
|
|
694
|
+
* Hook script files present on disk that no manifest entry declares — "dead"
|
|
695
|
+
* hooks. The registrar only wires manifest-declared hooks into an agent's
|
|
696
|
+
* native config (settings.json / config.toml), matching the installed file to a
|
|
697
|
+
* manifest entry by script basename. So a file whose basename matches no
|
|
698
|
+
* manifest `script:` is never registered: it occupies the hooks dir and shows
|
|
699
|
+
* up in listings, but no lifecycle event ever fires it.
|
|
700
|
+
*
|
|
701
|
+
* Pure on purpose (no disk reads) so it is trivially testable; callers pass the
|
|
702
|
+
* installed hook names and the manifest's script paths.
|
|
703
|
+
*/
|
|
704
|
+
export function unmanagedHookNames(installedHookNames, manifestScripts) {
|
|
705
|
+
const managed = new Set(manifestScripts.map((s) => path.basename(s).replace(/\.[^.]+$/, '')));
|
|
706
|
+
return installedHookNames.filter((name) => !managed.has(name)).sort();
|
|
707
|
+
}
|
|
708
|
+
/**
|
|
709
|
+
* The dead hooks (see {@link unmanagedHookNames}) sitting in one version home.
|
|
710
|
+
* Reads the merged hook manifest silently — a diagnostic must not emit the
|
|
711
|
+
* shadow/override warnings the registrar path prints.
|
|
712
|
+
*/
|
|
713
|
+
export function listUnmanagedHooksInVersionHome(agent, version) {
|
|
714
|
+
if (!AGENTS[agent].supportsHooks)
|
|
715
|
+
return [];
|
|
716
|
+
const scripts = Object.values(parseHookManifest({ warn: false }))
|
|
717
|
+
.map((h) => h.script)
|
|
718
|
+
.filter((s) => typeof s === 'string');
|
|
719
|
+
const installed = listHooksInVersionHome(agent, version).map((e) => e.name);
|
|
720
|
+
return unmanagedHookNames(installed, scripts);
|
|
721
|
+
}
|
|
659
722
|
// Codex events that support a matcher field (matches tool name or session type).
|
|
660
723
|
// UserPromptSubmit and Stop never include a matcher.
|
|
661
724
|
const CODEX_MATCHER_EVENTS = new Set(['PreToolUse', 'PostToolUse', 'SessionStart']);
|
|
@@ -1253,8 +1316,7 @@ function registerHooksForKimi(versionHome, manifest, resolveScript, managedPrefi
|
|
|
1253
1316
|
const cmd = typeof h.command === 'string' ? h.command : '';
|
|
1254
1317
|
if (!cmd)
|
|
1255
1318
|
return true;
|
|
1256
|
-
|
|
1257
|
-
if (!isManaged)
|
|
1319
|
+
if (!isManagedHookCommand(cmd, managedPrefixes))
|
|
1258
1320
|
return true;
|
|
1259
1321
|
return currentManifestPaths.has(cmd);
|
|
1260
1322
|
});
|
package/dist/lib/mcp.js
CHANGED
|
@@ -338,6 +338,35 @@ function installMcpToKimiConfig(server, versionHome) {
|
|
|
338
338
|
fs.mkdirSync(path.dirname(configPath), { recursive: true });
|
|
339
339
|
fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8');
|
|
340
340
|
}
|
|
341
|
+
/**
|
|
342
|
+
* Install MCP server to Factory AI Droid config (`~/.factory/mcp.json`).
|
|
343
|
+
* Droid uses the standard `mcpServers` JSON shape, same as Kimi/Claude.
|
|
344
|
+
*/
|
|
345
|
+
function installMcpToFactoryConfig(server, versionHome) {
|
|
346
|
+
const configPath = path.join(versionHome, '.factory', 'mcp.json');
|
|
347
|
+
let config = {};
|
|
348
|
+
if (fs.existsSync(configPath)) {
|
|
349
|
+
config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
|
350
|
+
}
|
|
351
|
+
if (!config.mcpServers || typeof config.mcpServers !== 'object') {
|
|
352
|
+
config.mcpServers = {};
|
|
353
|
+
}
|
|
354
|
+
const mcpServers = config.mcpServers;
|
|
355
|
+
if (server.config.transport === 'stdio') {
|
|
356
|
+
mcpServers[server.name] = {
|
|
357
|
+
command: server.config.command,
|
|
358
|
+
args: server.config.args || [],
|
|
359
|
+
env: server.config.env || {},
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
else {
|
|
363
|
+
mcpServers[server.name] = {
|
|
364
|
+
url: server.config.url,
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
fs.mkdirSync(path.dirname(configPath), { recursive: true });
|
|
368
|
+
fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8');
|
|
369
|
+
}
|
|
341
370
|
function installMcpToOpenCodeConfig(server, versionHome) {
|
|
342
371
|
const configPath = path.join(versionHome, '.opencode', 'opencode.jsonc');
|
|
343
372
|
let config = {};
|
|
@@ -424,6 +453,10 @@ export function installMcpServers(agentId, version, versionHome, mcpNames, optio
|
|
|
424
453
|
installMcpToKimiConfig(server, versionHome);
|
|
425
454
|
applied.push(server.name);
|
|
426
455
|
}
|
|
456
|
+
else if (agentId === 'droid') {
|
|
457
|
+
installMcpToFactoryConfig(server, versionHome);
|
|
458
|
+
applied.push(server.name);
|
|
459
|
+
}
|
|
427
460
|
}
|
|
428
461
|
catch (err) {
|
|
429
462
|
const message = err.message;
|
package/dist/lib/models.js
CHANGED
|
@@ -772,5 +772,10 @@ export function buildReasoningFlags(agent, level) {
|
|
|
772
772
|
const codexLevel = (normalized === 'xhigh' || normalized === 'max') ? 'high' : normalized;
|
|
773
773
|
return ['-c', `model_reasoning_effort=${codexLevel}`];
|
|
774
774
|
}
|
|
775
|
+
if (agent === 'droid') {
|
|
776
|
+
// Droid: `-r off|none|low|medium|high`. xhigh/max clamp to high.
|
|
777
|
+
const droidLevel = (normalized === 'xhigh' || normalized === 'max') ? 'high' : normalized;
|
|
778
|
+
return ['-r', droidLevel];
|
|
779
|
+
}
|
|
775
780
|
return [];
|
|
776
781
|
}
|
package/dist/lib/picker.d.ts
CHANGED
|
@@ -22,5 +22,7 @@ export interface PickerConfig<T> {
|
|
|
22
22
|
export interface PickedItem<T> {
|
|
23
23
|
item: T;
|
|
24
24
|
}
|
|
25
|
+
/** Clip a picker preview so the full prompt can fit in the terminal viewport. */
|
|
26
|
+
export declare function limitPreviewHeight(preview: string, maxRows: number, width: number): string;
|
|
25
27
|
/** Show an interactive fuzzy-filter picker and return the selected item, or null on cancel. */
|
|
26
28
|
export declare function itemPicker<T>(config: PickerConfig<T>): Promise<PickedItem<T> | null>;
|
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[];
|
|
@@ -60,6 +74,16 @@ export declare function getPlugin(name: string): DiscoveredPlugin | null;
|
|
|
60
74
|
* Otherwise defaults to all plugin-capable agents.
|
|
61
75
|
*/
|
|
62
76
|
export declare function pluginSupportsAgent(plugin: DiscoveredPlugin, agent: AgentId): boolean;
|
|
77
|
+
/**
|
|
78
|
+
* The lifecycle events a plugin hooks into, read from hooks/hooks.json.
|
|
79
|
+
*
|
|
80
|
+
* The official plugin format wraps the event map under a `hooks` key
|
|
81
|
+
* (`{ description, hooks: { SessionStart: [...], PreToolUse: [...] } }`), so the
|
|
82
|
+
* meaningful keys are the events — NOT the top-level keys (`description`,
|
|
83
|
+
* `hooks`). Older/flat files put the event names at the top level directly; we
|
|
84
|
+
* read whichever object actually holds the event map.
|
|
85
|
+
*/
|
|
86
|
+
export declare function discoverPluginHooks(pluginRoot: string): string[];
|
|
63
87
|
/** Discover command .md files inside a plugin's commands/ directory. */
|
|
64
88
|
export declare function discoverPluginCommands(pluginRoot: string): string[];
|
|
65
89
|
/** Discover agent definition .md files inside a plugin's agents/ directory. */
|