@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.
Files changed (67) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/README.md +3 -0
  3. package/dist/commands/computer-actions.d.ts +3 -0
  4. package/dist/commands/computer-actions.js +16 -0
  5. package/dist/commands/doctor.js +51 -7
  6. package/dist/commands/exec.js +25 -4
  7. package/dist/commands/import.js +17 -6
  8. package/dist/commands/inspect.d.ts +28 -1
  9. package/dist/commands/inspect.js +330 -47
  10. package/dist/commands/mcp.js +3 -3
  11. package/dist/commands/plugins.d.ts +2 -0
  12. package/dist/commands/plugins.js +69 -26
  13. package/dist/commands/prune.js +8 -5
  14. package/dist/commands/sync.js +1 -1
  15. package/dist/commands/teams.js +1 -0
  16. package/dist/commands/trash.d.ts +11 -0
  17. package/dist/commands/trash.js +57 -41
  18. package/dist/commands/versions.js +68 -20
  19. package/dist/commands/view.d.ts +1 -0
  20. package/dist/commands/view.js +56 -12
  21. package/dist/commands/wallet.d.ts +14 -0
  22. package/dist/commands/wallet.js +199 -0
  23. package/dist/index.js +4 -1
  24. package/dist/lib/agents.js +70 -22
  25. package/dist/lib/browser/ipc.d.ts +7 -0
  26. package/dist/lib/browser/ipc.js +43 -27
  27. package/dist/lib/capabilities.js +7 -1
  28. package/dist/lib/command-skills.d.ts +1 -0
  29. package/dist/lib/command-skills.js +23 -7
  30. package/dist/lib/exec.d.ts +32 -1
  31. package/dist/lib/exec.js +79 -7
  32. package/dist/lib/hooks.d.ts +21 -1
  33. package/dist/lib/hooks.js +69 -7
  34. package/dist/lib/mcp.js +33 -0
  35. package/dist/lib/models.js +5 -0
  36. package/dist/lib/picker.d.ts +2 -0
  37. package/dist/lib/picker.js +96 -6
  38. package/dist/lib/platform/index.d.ts +1 -0
  39. package/dist/lib/platform/index.js +1 -0
  40. package/dist/lib/platform/winpath.d.ts +35 -0
  41. package/dist/lib/platform/winpath.js +86 -0
  42. package/dist/lib/plugins.d.ts +24 -0
  43. package/dist/lib/plugins.js +37 -2
  44. package/dist/lib/project-launch.js +110 -5
  45. package/dist/lib/registry.js +15 -2
  46. package/dist/lib/rotate.d.ts +7 -0
  47. package/dist/lib/rotate.js +17 -7
  48. package/dist/lib/runner.js +14 -0
  49. package/dist/lib/sandbox.js +5 -2
  50. package/dist/lib/settings-manifest.d.ts +39 -0
  51. package/dist/lib/settings-manifest.js +163 -0
  52. package/dist/lib/shims.d.ts +1 -1
  53. package/dist/lib/shims.js +16 -31
  54. package/dist/lib/staleness/detectors/subagents.js +16 -0
  55. package/dist/lib/staleness/writers/subagents.js +11 -3
  56. package/dist/lib/subagents.d.ts +9 -0
  57. package/dist/lib/subagents.js +33 -0
  58. package/dist/lib/teams/agents.js +1 -1
  59. package/dist/lib/teams/parsers.d.ts +1 -1
  60. package/dist/lib/teams/parsers.js +6 -0
  61. package/dist/lib/types.d.ts +1 -1
  62. package/dist/lib/versions.d.ts +15 -3
  63. package/dist/lib/versions.js +88 -19
  64. package/dist/lib/wallet/index.d.ts +78 -0
  65. package/dist/lib/wallet/index.js +253 -0
  66. package/package.json +3 -3
  67. 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.interactive === true || options.prompt === undefined;
361
- // For Codex, 'exec' is the headless subcommand -- drop it for interactive mode
362
- // so we run 'codex' (TUI) instead of 'codex exec' (one-shot)
363
- if (options.agent === 'codex' && interactive && cmd[1] === 'exec') {
364
- cmd.splice(1, 1);
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 (template.promptFlag === 'positional') {
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.interactive === true || options.prompt === undefined;
601
+ const interactive = resolveInteractive(options);
530
602
  maybeRotate();
531
603
  const timer = createTimer('agent.run', {
532
604
  agent: options.agent,
@@ -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(): Record<string, ManifestHook>;
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 (command.startsWith(prefix))
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
- const isManaged = managedPrefixes.some((p) => cmd.startsWith(p));
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;
@@ -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
  }
@@ -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>;
@@ -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
- parts.push(chalk.gray('─'.repeat(Math.min(process.stdout.columns || 80, 80))));
99
- parts.push(cfg.buildPreview(selected.value));
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
  });
@@ -19,3 +19,4 @@ export * from './paths.js';
19
19
  export * from './exec.js';
20
20
  export * from './process.js';
21
21
  export * from './ipc.js';
22
+ export * from './winpath.js';
@@ -19,3 +19,4 @@ export * from './paths.js';
19
19
  export * from './exec.js';
20
20
  export * from './process.js';
21
21
  export * from './ipc.js';
22
+ export * from './winpath.js';
@@ -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
+ }
@@ -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. */