@phnx-labs/agents-cli 1.20.5 → 1.20.7

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 (70) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +1 -1
  3. package/dist/commands/browser.js +31 -4
  4. package/dist/commands/computer-actions.d.ts +36 -0
  5. package/dist/commands/computer-actions.js +328 -0
  6. package/dist/commands/computer.js +74 -55
  7. package/dist/commands/defaults.d.ts +7 -0
  8. package/dist/commands/defaults.js +89 -0
  9. package/dist/commands/exec.js +24 -6
  10. package/dist/commands/inspect.d.ts +38 -7
  11. package/dist/commands/inspect.js +194 -24
  12. package/dist/commands/rules.js +3 -3
  13. package/dist/commands/secrets.js +46 -9
  14. package/dist/commands/sessions.js +9 -12
  15. package/dist/commands/setup.js +2 -2
  16. package/dist/commands/teams.js +108 -11
  17. package/dist/commands/view.d.ts +12 -1
  18. package/dist/commands/view.js +121 -38
  19. package/dist/index.js +61 -22
  20. package/dist/lib/agents.d.ts +10 -6
  21. package/dist/lib/agents.js +23 -14
  22. package/dist/lib/browser/chrome.d.ts +10 -0
  23. package/dist/lib/browser/chrome.js +84 -3
  24. package/dist/lib/daemon.js +4 -7
  25. package/dist/lib/exec.d.ts +9 -0
  26. package/dist/lib/exec.js +85 -9
  27. package/dist/lib/migrate.js +6 -4
  28. package/dist/lib/permissions.d.ts +23 -0
  29. package/dist/lib/permissions.js +89 -7
  30. package/dist/lib/platform/exec.d.ts +9 -0
  31. package/dist/lib/platform/exec.js +24 -0
  32. package/dist/lib/platform/index.d.ts +20 -0
  33. package/dist/lib/platform/index.js +20 -0
  34. package/dist/lib/platform/paths.d.ts +22 -0
  35. package/dist/lib/platform/paths.js +49 -0
  36. package/dist/lib/platform/process.d.ts +12 -0
  37. package/dist/lib/platform/process.js +22 -0
  38. package/dist/lib/plugin-marketplace.js +1 -1
  39. package/dist/lib/project-launch.d.ts +5 -0
  40. package/dist/lib/project-launch.js +37 -0
  41. package/dist/lib/pty-client.js +13 -5
  42. package/dist/lib/pty-server.d.ts +24 -1
  43. package/dist/lib/pty-server.js +109 -29
  44. package/dist/lib/resources/rules.js +1 -1
  45. package/dist/lib/resources/skills.js +1 -1
  46. package/dist/lib/resources.d.ts +2 -0
  47. package/dist/lib/resources.js +2 -1
  48. package/dist/lib/rotate.js +6 -18
  49. package/dist/lib/run-config.d.ts +9 -0
  50. package/dist/lib/run-config.js +35 -0
  51. package/dist/lib/run-defaults.d.ts +42 -0
  52. package/dist/lib/run-defaults.js +180 -0
  53. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  54. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  55. package/dist/lib/secrets/install-helper.d.ts +11 -3
  56. package/dist/lib/secrets/install-helper.js +48 -6
  57. package/dist/lib/secrets/linux.d.ts +12 -0
  58. package/dist/lib/secrets/linux.js +30 -16
  59. package/dist/lib/session/artifacts.js +8 -2
  60. package/dist/lib/shims.d.ts +9 -1
  61. package/dist/lib/shims.js +80 -3
  62. package/dist/lib/staleness/detectors/hooks.js +1 -1
  63. package/dist/lib/staleness/writers/hooks.js +1 -1
  64. package/dist/lib/teams/agents.js +5 -7
  65. package/dist/lib/teams/api.d.ts +67 -0
  66. package/dist/lib/teams/api.js +78 -0
  67. package/dist/lib/types.d.ts +15 -6
  68. package/dist/lib/versions.js +4 -4
  69. package/package.json +5 -2
  70. package/scripts/postinstall.js +18 -1
@@ -15,6 +15,7 @@ import * as crypto from 'crypto';
15
15
  import { execFileSync } from 'child_process';
16
16
  import { fileURLToPath } from 'url';
17
17
  import { getPtyDir as getPtyDirRoot } from './state.js';
18
+ import { isAlive } from './platform/index.js';
18
19
  /**
19
20
  * Capture a stable identifier for a process at the moment it was started.
20
21
  * Used to defeat PID reuse: a kill(pid, ...) is only safe when the process
@@ -52,6 +53,7 @@ export function captureProcessStartTime(pid) {
52
53
  }
53
54
  }
54
55
  // --- Constants ---
56
+ const IS_WINDOWS = process.platform === 'win32';
55
57
  const SENTINEL = '__AGENTS_PTY_DONE__';
56
58
  const SOCKET_NAME = 'pty.sock';
57
59
  const PID_FILE = 'pty.pid';
@@ -72,24 +74,81 @@ const PTY_ENV_ALLOWLIST = [
72
74
  'EDITOR', 'VISUAL', 'PAGER', 'LESS',
73
75
  'NO_COLOR', 'FORCE_COLOR',
74
76
  ];
77
+ /**
78
+ * Windows allowlist. cmd.exe / PowerShell refuse to start (or misbehave) without
79
+ * SystemRoot, ComSpec, PATHEXT and the USERPROFILE/APPDATA family, so a Unix-style
80
+ * allowlist would spawn a broken shell. PATH/TERM/color/NODE vars are shared with
81
+ * the Unix list; the rest are Windows-specific.
82
+ */
83
+ const PTY_ENV_ALLOWLIST_WIN = [
84
+ 'SystemRoot', 'SystemDrive', 'windir', 'ComSpec', 'PATH', 'PATHEXT',
85
+ 'TEMP', 'TMP', 'USERPROFILE', 'HOMEDRIVE', 'HOMEPATH', 'HOME',
86
+ 'APPDATA', 'LOCALAPPDATA', 'PROGRAMFILES', 'PROGRAMDATA',
87
+ 'USERNAME', 'USERDOMAIN', 'COMPUTERNAME', 'OS',
88
+ 'PROCESSOR_ARCHITECTURE', 'NUMBER_OF_PROCESSORS',
89
+ 'TERM', 'COLORTERM', 'NO_COLOR', 'FORCE_COLOR',
90
+ 'NODE_PATH', 'BUN_INSTALL',
91
+ ];
75
92
  function buildPtyEnv() {
76
93
  const env = {};
77
- for (const key of PTY_ENV_ALLOWLIST) {
94
+ const allowlist = IS_WINDOWS ? PTY_ENV_ALLOWLIST_WIN : PTY_ENV_ALLOWLIST;
95
+ for (const key of allowlist) {
78
96
  const v = process.env[key];
79
97
  if (v !== undefined)
80
98
  env[key] = v;
81
99
  }
82
100
  return env;
83
101
  }
102
+ /**
103
+ * Wrap a user command so a `__SENTINEL__:<exit>` line is printed after it
104
+ * finishes — that line drives completion detection in the exec/read flow.
105
+ * The separator and exit-code variable are shell-family specific:
106
+ * POSIX sh/zsh/bash : `cmd; echo "S:$?"`
107
+ * PowerShell : `cmd; echo "S:$LASTEXITCODE"`
108
+ * cmd.exe : `cmd & echo S:%errorlevel%` (`&` always runs the echo)
109
+ * Only the completion marker matters; the numeric exit code is informational
110
+ * (the authoritative code comes from node-pty's onExit).
111
+ */
112
+ export function buildSentinelCommand(shell, command) {
113
+ // Split on both separators: a Windows shell path (`C:\…\cmd.exe`) must be
114
+ // recognized even when this code runs under POSIX path.basename, which does
115
+ // not treat `\` as a separator.
116
+ const name = (shell.split(/[\\/]/).pop() || shell).toLowerCase();
117
+ if (name === 'cmd.exe' || name === 'cmd') {
118
+ return `${command} & echo ${SENTINEL}:%errorlevel%`;
119
+ }
120
+ if (name === 'powershell.exe' || name === 'powershell' || name === 'pwsh.exe' || name === 'pwsh') {
121
+ return `${command}; echo "${SENTINEL}:$LASTEXITCODE"`;
122
+ }
123
+ return `${command}; echo "${SENTINEL}:$?"`;
124
+ }
84
125
  /** Get the PTY helper directory, creating it if needed. */
85
126
  function getPtyDir() {
86
127
  const dir = getPtyDirRoot();
87
128
  fs.mkdirSync(dir, { recursive: true });
88
129
  return dir;
89
130
  }
90
- /** Get the unix socket path for the PTY server. */
131
+ /**
132
+ * Resolve the IPC endpoint for a given platform + PTY scratch dir. Pure so both
133
+ * branches are testable without stubbing process.platform.
134
+ *
135
+ * Unix: an AF_UNIX socket file inside the scratch dir.
136
+ * Windows: a named pipe (`\\.\pipe\…`). Named pipes are NOT filesystem objects,
137
+ * so the name is derived from a hash of the (per-user) scratch dir to keep it
138
+ * stable across invocations and isolated per user — and callers must never probe
139
+ * it with fs.existsSync (it always reports false). Both forms are accepted by
140
+ * net.createServer/createConnection.
141
+ */
142
+ export function derivePtyEndpoint(platform, ptyDir) {
143
+ if (platform === 'win32') {
144
+ const hash = crypto.createHash('sha1').update(ptyDir).digest('hex').slice(0, 16);
145
+ return `\\\\.\\pipe\\agents-pty-${hash}`;
146
+ }
147
+ return path.join(ptyDir, SOCKET_NAME);
148
+ }
149
+ /** Get the IPC endpoint the PTY server listens on / clients connect to. */
91
150
  export function getSocketPath() {
92
- return path.join(getPtyDir(), SOCKET_NAME);
151
+ return derivePtyEndpoint(process.platform, getPtyDir());
93
152
  }
94
153
  /** Get the path to the PTY server PID file. */
95
154
  export function getPtyPidPath() {
@@ -109,16 +168,17 @@ export function isPtyServerRunning() {
109
168
  const pid = parseInt(fs.readFileSync(pidPath, 'utf-8').trim(), 10);
110
169
  if (isNaN(pid))
111
170
  return false;
112
- process.kill(pid, 0);
113
- return true;
171
+ if (isAlive(pid))
172
+ return true;
114
173
  }
115
174
  catch {
116
- try {
117
- fs.unlinkSync(pidPath);
118
- }
119
- catch { }
120
- return false;
175
+ // read failed — fall through and treat the pid file as stale
121
176
  }
177
+ try {
178
+ fs.unlinkSync(pidPath);
179
+ }
180
+ catch { }
181
+ return false;
122
182
  }
123
183
  // --- Logging ---
124
184
  function rotateLogsIfNeeded(logPath) {
@@ -155,14 +215,17 @@ export async function runPtyServer() {
155
215
  let nodePty;
156
216
  let XtermTerminal;
157
217
  try {
158
- nodePty = await import('node-pty');
218
+ // The Homebridge multiarch fork of node-pty: API-identical (same 1.x N-API
219
+ // codebase) but ships prebuilt binaries for Linux glibc + musl, x64 + arm64
220
+ // (plus macOS/Windows), so no compiler is needed on Linux/Alpine/arm64.
221
+ nodePty = await import('@homebridge/node-pty-prebuilt-multiarch');
159
222
  // Handle ESM default export
160
223
  if (nodePty.default?.spawn)
161
224
  nodePty = nodePty.default;
162
225
  // Ensure spawn-helper is executable (bun install doesn't set +x on prebuilds)
163
226
  try {
164
227
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
165
- const ptyBase = path.resolve(__dirname, '..', '..', 'node_modules', 'node-pty');
228
+ const ptyBase = path.resolve(__dirname, '..', '..', 'node_modules', '@homebridge', 'node-pty-prebuilt-multiarch');
166
229
  const helpers = [
167
230
  path.join(ptyBase, 'prebuilds', `${process.platform}-${process.arch}`, 'spawn-helper'),
168
231
  path.join(ptyBase, 'build', 'Release', 'spawn-helper'),
@@ -176,8 +239,8 @@ export async function runPtyServer() {
176
239
  catch { }
177
240
  }
178
241
  catch (err) {
179
- console.error('node-pty is required for PTY support.');
180
- console.error('Install: cd ' + '~/agents-cli && bun add node-pty');
242
+ console.error('node-pty (@homebridge/node-pty-prebuilt-multiarch) is required for PTY support.');
243
+ console.error('Install: bun add @homebridge/node-pty-prebuilt-multiarch');
181
244
  process.exit(1);
182
245
  }
183
246
  try {
@@ -218,8 +281,10 @@ export async function runPtyServer() {
218
281
  fs.unlinkSync(pidPath);
219
282
  }
220
283
  catch { } });
221
- // Remove stale socket from a prior crashed server. Safe now that we hold the PID slot.
222
- if (fs.existsSync(socketPath)) {
284
+ // Remove stale socket from a prior crashed server. Safe now that we hold the PID
285
+ // slot. Windows named pipes are not filesystem inodes — they vanish with their
286
+ // owning process, so there's nothing to unlink (and existsSync always reports false).
287
+ if (!IS_WINDOWS && fs.existsSync(socketPath)) {
223
288
  try {
224
289
  fs.unlinkSync(socketPath);
225
290
  }
@@ -283,8 +348,10 @@ export async function runPtyServer() {
283
348
  case 'start': {
284
349
  const rows = req.params?.rows || 24;
285
350
  const cols = req.params?.cols || 120;
286
- const shell = req.params?.shell || process.env.SHELL || 'zsh';
287
- const cwd = req.params?.cwd || process.env.HOME || '/';
351
+ const shell = req.params?.shell
352
+ || (IS_WINDOWS ? (process.env.ComSpec || 'powershell.exe') : (process.env.SHELL || 'zsh'));
353
+ const cwd = req.params?.cwd
354
+ || (IS_WINDOWS ? (process.env.USERPROFILE || process.env.HOME || process.cwd()) : (process.env.HOME || '/'));
288
355
  const id = generateId();
289
356
  let ptyProcess;
290
357
  try {
@@ -355,7 +422,9 @@ export async function runPtyServer() {
355
422
  session.appActive = true;
356
423
  session.activeCommand = command;
357
424
  session.pendingOutput = '';
358
- session.pty.write(`${command}; echo "${SENTINEL}:$?"\n`);
425
+ // Windows conpty submits on CR; POSIX line discipline expects LF.
426
+ const submit = IS_WINDOWS ? '\r' : '\n';
427
+ session.pty.write(`${buildSentinelCommand(session.shell, command)}${submit}`);
359
428
  session.lastActivity = Date.now();
360
429
  return { ok: true, submitted: true };
361
430
  }
@@ -522,20 +591,28 @@ export async function runPtyServer() {
522
591
  // any local user with execute on the parent dir could connect to the socket
523
592
  // during the listen()-to-chmod() window. macOS BSD AF_UNIX semantics make
524
593
  // socket mode advisory only, so the parent dir is the real boundary.
594
+ //
595
+ // On Windows the transport is a named pipe, not a filesystem inode: chmod/umask
596
+ // are no-ops (and umask throws in some Node builds), and pipe ACLs default to
597
+ // the creating user. So we skip the Unix hardening entirely there.
525
598
  const agentsDir = getPtyDirRoot();
526
599
  fs.mkdirSync(agentsDir, { recursive: true });
527
- fs.chmodSync(agentsDir, 0o700);
528
- // umask covers any inherited group/other bits while listen() is creating
529
- // the socket inode it only matters for the unobservable instant before
530
- // we can chmod the inode itself.
531
- process.umask(0o077);
600
+ if (!IS_WINDOWS) {
601
+ fs.chmodSync(agentsDir, 0o700);
602
+ // umask covers any inherited group/other bits while listen() is creating
603
+ // the socket inode — it only matters for the unobservable instant before
604
+ // we can chmod the inode itself.
605
+ process.umask(0o077);
606
+ }
532
607
  await new Promise((resolve) => {
533
608
  server.listen(socketPath, () => resolve());
534
609
  });
535
610
  // Surface chmod failures: a 0o600 socket is a load-bearing security
536
611
  // assumption, not a nice-to-have. If we can't lock it down, refuse to
537
- // start so the caller learns immediately.
538
- fs.chmodSync(socketPath, 0o600);
612
+ // start so the caller learns immediately. (No-op on Windows named pipes.)
613
+ if (!IS_WINDOWS) {
614
+ fs.chmodSync(socketPath, 0o600);
615
+ }
539
616
  log('INFO', `PTY server started (PID: ${process.pid}, socket: ${socketPath})`);
540
617
  // Shutdown handler
541
618
  function shutdown() {
@@ -546,10 +623,13 @@ export async function runPtyServer() {
546
623
  sessions.clear();
547
624
  clearInterval(cleanupInterval);
548
625
  server.close();
549
- try {
550
- fs.unlinkSync(socketPath);
626
+ // Named pipes are reclaimed by the OS on close; only Unix sockets leave a file.
627
+ if (!IS_WINDOWS) {
628
+ try {
629
+ fs.unlinkSync(socketPath);
630
+ }
631
+ catch { }
551
632
  }
552
- catch { }
553
633
  try {
554
634
  fs.unlinkSync(getPtyPidPath());
555
635
  }
@@ -9,9 +9,9 @@
9
9
  * - All unique subrules across layers are unioned
10
10
  */
11
11
  import * as fs from 'fs';
12
+ import { agentConfigDirName } from '../agents.js';
12
13
  import * as path from 'path';
13
14
  import { getSystemRulesDir, getUserRulesDir, getProjectAgentsDir, getEnabledExtraRepos, } from '../state.js';
14
- import { agentConfigDirName } from '../agents.js';
15
15
  const SUBRULES_DIR = 'subrules';
16
16
  const SUBRULES_README = 'README.md';
17
17
  /**
@@ -5,10 +5,10 @@
5
5
  * Format is the same for all agents. Resolution order: project > user > system.
6
6
  */
7
7
  import * as fs from 'fs';
8
+ import { agentConfigDirName } from '../agents.js';
8
9
  import * as path from 'path';
9
10
  import * as yaml from 'yaml';
10
11
  import { getSystemSkillsDir, getUserSkillsDir, getProjectAgentsDir, getEnabledExtraRepos, } from '../state.js';
11
- import { agentConfigDirName } from '../agents.js';
12
12
  /** Default provider uses the real state module. */
13
13
  const defaultProvider = {
14
14
  getSystemSkillsDir,
@@ -36,6 +36,8 @@ export interface ResourceEntry {
36
36
  name: string;
37
37
  path: string;
38
38
  scope: 'user' | 'project';
39
+ /** One-line description pulled from frontmatter; not all resource kinds have one. */
40
+ description?: string;
39
41
  }
40
42
  /** A skill resource entry with optional rule count. */
41
43
  export interface SkillResourceEntry extends ResourceEntry {
@@ -106,7 +106,7 @@ export function getAgentResources(agentId, options = {}) {
106
106
  const commands = [];
107
107
  for (const cmd of listInstalledCommandsWithScope(agentId, cwd, { home })) {
108
108
  if (shouldInclude(cmd.scope)) {
109
- commands.push({ name: cmd.name, path: cmd.path, scope: cmd.scope });
109
+ commands.push({ name: cmd.name, path: cmd.path, scope: cmd.scope, description: cmd.description });
110
110
  }
111
111
  }
112
112
  // Skills
@@ -119,6 +119,7 @@ export function getAgentResources(agentId, options = {}) {
119
119
  path: skill.path,
120
120
  scope: skill.scope,
121
121
  ruleCount: skill.ruleCount,
122
+ description: skill.metadata.description || undefined,
122
123
  });
123
124
  }
124
125
  }
@@ -6,10 +6,10 @@
6
6
  */
7
7
  import * as fs from 'fs';
8
8
  import * as path from 'path';
9
- import * as yaml from 'yaml';
10
9
  import { getAccountInfo } from './agents.js';
11
- import { readMeta, writeMeta, getHelpersDir, getUserAgentsDir } from './state.js';
10
+ import { readMeta, writeMeta, getHelpersDir } from './state.js';
12
11
  import { listInstalledVersions, getVersionHomePath, resolveVersion } from './versions.js';
12
+ import { getProjectRunConfigs } from './run-config.js';
13
13
  import { getUsageInfoByIdentity, getUsageLookupKey, } from './usage.js';
14
14
  function getRotateDir() {
15
15
  const dir = path.join(getHelpersDir(), 'rotate');
@@ -33,22 +33,10 @@ export function normalizeRunStrategy(value) {
33
33
  }
34
34
  /** Read project-local run strategy from the nearest agents.yaml, if present. */
35
35
  export function getProjectRunStrategy(agent, startPath) {
36
- let dir = path.resolve(startPath);
37
- const userAgentsYaml = path.join(getUserAgentsDir(), 'agents.yaml');
38
- while (dir !== path.dirname(dir)) {
39
- const manifestPath = path.join(dir, 'agents.yaml');
40
- if (manifestPath !== userAgentsYaml && fs.existsSync(manifestPath)) {
41
- try {
42
- const parsed = yaml.parse(fs.readFileSync(manifestPath, 'utf-8'));
43
- const strategy = normalizeRunStrategy(parsed?.run?.[agent]?.strategy);
44
- if (strategy)
45
- return strategy;
46
- }
47
- catch {
48
- // Ignore malformed project config and keep walking, matching version resolution.
49
- }
50
- }
51
- dir = path.dirname(dir);
36
+ for (const runConfig of getProjectRunConfigs(startPath)) {
37
+ const strategy = normalizeRunStrategy(runConfig[agent]?.strategy);
38
+ if (strategy)
39
+ return strategy;
52
40
  }
53
41
  return null;
54
42
  }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Project-local `run:` config discovery.
3
+ *
4
+ * The user/system `agents.yaml` is read through state.ts. Project-local
5
+ * agents.yaml files are discovered from the current working directory upward.
6
+ */
7
+ import type { RunConfig } from './types.js';
8
+ /** Return project-local run configs from nearest directory upward. */
9
+ export declare function getProjectRunConfigs(startPath?: string): RunConfig[];
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Project-local `run:` config discovery.
3
+ *
4
+ * The user/system `agents.yaml` is read through state.ts. Project-local
5
+ * agents.yaml files are discovered from the current working directory upward.
6
+ */
7
+ import * as fs from 'fs';
8
+ import * as path from 'path';
9
+ import * as yaml from 'yaml';
10
+ import { getUserAgentsDir } from './state.js';
11
+ function isRecord(value) {
12
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
13
+ }
14
+ /** Return project-local run configs from nearest directory upward. */
15
+ export function getProjectRunConfigs(startPath = process.cwd()) {
16
+ const configs = [];
17
+ let dir = path.resolve(startPath);
18
+ const userAgentsYaml = path.join(getUserAgentsDir(), 'agents.yaml');
19
+ while (dir !== path.dirname(dir)) {
20
+ const manifestPath = path.join(dir, 'agents.yaml');
21
+ if (manifestPath !== userAgentsYaml && fs.existsSync(manifestPath)) {
22
+ try {
23
+ const parsed = yaml.parse(fs.readFileSync(manifestPath, 'utf-8'));
24
+ if (isRecord(parsed?.run)) {
25
+ configs.push(parsed.run);
26
+ }
27
+ }
28
+ catch {
29
+ // Ignore malformed project config and keep walking.
30
+ }
31
+ }
32
+ dir = path.dirname(dir);
33
+ }
34
+ return configs;
35
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Selector-based defaults for `agents run`.
3
+ *
4
+ * Stored under agents.yaml:
5
+ *
6
+ * run:
7
+ * defaults:
8
+ * "claude:*":
9
+ * mode: auto
10
+ * model: opus
11
+ * "claude:2.1.45":
12
+ * mode: plan
13
+ */
14
+ import type { AgentId, Mode, RunConfig, RunDefaults } from './types.js';
15
+ export interface ParsedRunDefaultSelector {
16
+ agent: AgentId;
17
+ version: string;
18
+ selector: string;
19
+ }
20
+ export interface ResolvedRunDefaults extends RunDefaults {
21
+ sources: {
22
+ mode?: string;
23
+ model?: string;
24
+ };
25
+ }
26
+ export interface RunDefaultEntry {
27
+ selector: string;
28
+ defaults: RunDefaults;
29
+ }
30
+ type RunDefaultsInput = {
31
+ mode?: unknown;
32
+ model?: unknown;
33
+ };
34
+ export declare function normalizeRunDefaultMode(input: string): Mode;
35
+ export declare function parseRunDefaultSelector(input: string): ParsedRunDefaultSelector;
36
+ export declare function resolveRunDefaultsFromConfig(runConfig: RunConfig | undefined, agent: AgentId, version?: string | null): ResolvedRunDefaults;
37
+ export declare function resolveRunDefaultsFromConfigs(runConfigs: Array<RunConfig | undefined>, agent: AgentId, version?: string | null): ResolvedRunDefaults;
38
+ export declare function resolveRunDefaults(agent: AgentId, version?: string | null, startPath?: string): ResolvedRunDefaults;
39
+ export declare function listRunDefaults(): RunDefaultEntry[];
40
+ export declare function setRunDefault(selectorInput: string, defaultsInput: RunDefaultsInput): RunDefaultEntry;
41
+ export declare function unsetRunDefault(selectorInput: string): boolean;
42
+ export {};
@@ -0,0 +1,180 @@
1
+ /**
2
+ * Selector-based defaults for `agents run`.
3
+ *
4
+ * Stored under agents.yaml:
5
+ *
6
+ * run:
7
+ * defaults:
8
+ * "claude:*":
9
+ * mode: auto
10
+ * model: opus
11
+ * "claude:2.1.45":
12
+ * mode: plan
13
+ */
14
+ import { ALL_MODES } from './types.js';
15
+ import { AGENTS } from './agents.js';
16
+ import { readMeta, updateMeta } from './state.js';
17
+ import { getProjectRunConfigs } from './run-config.js';
18
+ const VERSION_RE = /^(?:\*|latest|(?!.*\.\.)[A-Za-z0-9._+-]{1,64})$/;
19
+ function isAgentId(value) {
20
+ return value in AGENTS;
21
+ }
22
+ export function normalizeRunDefaultMode(input) {
23
+ const mode = input.trim().toLowerCase();
24
+ if (mode === 'full')
25
+ return 'skip';
26
+ if (ALL_MODES.includes(mode))
27
+ return mode;
28
+ throw new Error(`Invalid mode '${input}'. Use one of: ${ALL_MODES.join(', ')} (or 'full' as an alias for 'skip').`);
29
+ }
30
+ function normalizeRunDefaults(defaults, selector) {
31
+ const out = {};
32
+ if (defaults.mode !== undefined) {
33
+ if (typeof defaults.mode !== 'string') {
34
+ throw new Error(`Invalid mode in run.defaults.${selector}: expected a string.`);
35
+ }
36
+ out.mode = normalizeRunDefaultMode(defaults.mode);
37
+ }
38
+ if (defaults.model !== undefined) {
39
+ if (typeof defaults.model !== 'string' || defaults.model.trim() === '') {
40
+ throw new Error(`Invalid model in run.defaults.${selector}: expected a non-empty string.`);
41
+ }
42
+ out.model = defaults.model.trim();
43
+ }
44
+ return out;
45
+ }
46
+ export function parseRunDefaultSelector(input) {
47
+ const raw = input.trim();
48
+ if (!raw)
49
+ throw new Error('Selector is required. Use <agent>:<version>, <agent>@<version>, or <agent>:*.');
50
+ let agentPart;
51
+ let versionPart;
52
+ if (raw.includes('@')) {
53
+ const parts = raw.split('@');
54
+ if (parts.length !== 2)
55
+ throw new Error(`Invalid selector '${input}'. Use <agent>@<version>.`);
56
+ [agentPart, versionPart] = parts;
57
+ }
58
+ else if (raw.includes(':')) {
59
+ const idx = raw.indexOf(':');
60
+ agentPart = raw.slice(0, idx);
61
+ versionPart = raw.slice(idx + 1);
62
+ }
63
+ else {
64
+ agentPart = raw;
65
+ versionPart = '*';
66
+ }
67
+ const agent = agentPart.toLowerCase();
68
+ if (!isAgentId(agent)) {
69
+ throw new Error(`Invalid agent '${agentPart}'. Available agents: ${Object.keys(AGENTS).join(', ')}.`);
70
+ }
71
+ if (!VERSION_RE.test(versionPart)) {
72
+ throw new Error(`Invalid selector version '${versionPart}'. Use *, latest, or [A-Za-z0-9._+-]{1,64}.`);
73
+ }
74
+ return {
75
+ agent,
76
+ version: versionPart,
77
+ selector: `${agent}:${versionPart}`,
78
+ };
79
+ }
80
+ function sortedDefaults(defaults) {
81
+ return Object.fromEntries(Object.entries(defaults).sort(([a], [b]) => a.localeCompare(b)));
82
+ }
83
+ export function resolveRunDefaultsFromConfig(runConfig, agent, version) {
84
+ const defaults = runConfig?.defaults ?? {};
85
+ const wildcardSelector = `${agent}:*`;
86
+ const exactSelector = version ? `${agent}:${version}` : null;
87
+ const resolved = { sources: {} };
88
+ const wildcard = defaults[wildcardSelector]
89
+ ? normalizeRunDefaults(defaults[wildcardSelector], wildcardSelector)
90
+ : null;
91
+ if (wildcard?.mode) {
92
+ resolved.mode = wildcard.mode;
93
+ resolved.sources.mode = wildcardSelector;
94
+ }
95
+ if (wildcard?.model) {
96
+ resolved.model = wildcard.model;
97
+ resolved.sources.model = wildcardSelector;
98
+ }
99
+ if (exactSelector && defaults[exactSelector]) {
100
+ const exact = normalizeRunDefaults(defaults[exactSelector], exactSelector);
101
+ if (exact.mode) {
102
+ resolved.mode = exact.mode;
103
+ resolved.sources.mode = exactSelector;
104
+ }
105
+ if (exact.model) {
106
+ resolved.model = exact.model;
107
+ resolved.sources.model = exactSelector;
108
+ }
109
+ }
110
+ return resolved;
111
+ }
112
+ export function resolveRunDefaultsFromConfigs(runConfigs, agent, version) {
113
+ const resolved = { sources: {} };
114
+ for (const runConfig of runConfigs) {
115
+ const next = resolveRunDefaultsFromConfig(runConfig, agent, version);
116
+ if (next.mode) {
117
+ resolved.mode = next.mode;
118
+ resolved.sources.mode = next.sources.mode;
119
+ }
120
+ if (next.model) {
121
+ resolved.model = next.model;
122
+ resolved.sources.model = next.sources.model;
123
+ }
124
+ }
125
+ return resolved;
126
+ }
127
+ export function resolveRunDefaults(agent, version, startPath = process.cwd()) {
128
+ const projectRunConfigs = getProjectRunConfigs(startPath).reverse();
129
+ return resolveRunDefaultsFromConfigs([readMeta().run, ...projectRunConfigs], agent, version);
130
+ }
131
+ export function listRunDefaults() {
132
+ const defaults = readMeta().run?.defaults ?? {};
133
+ return Object.entries(defaults)
134
+ .sort(([a], [b]) => a.localeCompare(b))
135
+ .map(([selector, value]) => ({
136
+ selector,
137
+ defaults: normalizeRunDefaults(value, selector),
138
+ }));
139
+ }
140
+ export function setRunDefault(selectorInput, defaultsInput) {
141
+ const parsed = parseRunDefaultSelector(selectorInput);
142
+ const defaults = normalizeRunDefaults(defaultsInput, parsed.selector);
143
+ if (!defaults.mode && !defaults.model) {
144
+ throw new Error('Set at least one default: --mode <mode> or --model <model>.');
145
+ }
146
+ updateMeta((meta) => {
147
+ const run = { ...(meta.run ?? {}) };
148
+ const currentDefaults = { ...(run.defaults ?? {}) };
149
+ currentDefaults[parsed.selector] = {
150
+ ...(currentDefaults[parsed.selector] ?? {}),
151
+ ...defaults,
152
+ };
153
+ run.defaults = sortedDefaults(currentDefaults);
154
+ return { ...meta, run };
155
+ });
156
+ return {
157
+ selector: parsed.selector,
158
+ defaults: {
159
+ ...(readMeta().run?.defaults?.[parsed.selector] ?? {}),
160
+ },
161
+ };
162
+ }
163
+ export function unsetRunDefault(selectorInput) {
164
+ const parsed = parseRunDefaultSelector(selectorInput);
165
+ let removed = false;
166
+ updateMeta((meta) => {
167
+ const run = { ...(meta.run ?? {}) };
168
+ const currentDefaults = { ...(run.defaults ?? {}) };
169
+ removed = Object.prototype.hasOwnProperty.call(currentDefaults, parsed.selector);
170
+ delete currentDefaults[parsed.selector];
171
+ if (Object.keys(currentDefaults).length > 0) {
172
+ run.defaults = sortedDefaults(currentDefaults);
173
+ }
174
+ else {
175
+ delete run.defaults;
176
+ }
177
+ return { ...meta, run };
178
+ });
179
+ return removed;
180
+ }
@@ -12,10 +12,14 @@
12
12
  * modules in `src/lib/secrets/` must import `getKeychainHelperPath()` rather
13
13
  * than recomputing it.
14
14
  */
15
+ /** Redirect the install root (test only). Returns the previous override so callers can restore. */
16
+ export declare function setInstallRootForTest(dir: string | null): string | null;
15
17
  /**
16
18
  * Idempotent install. Copies the bundled `.app` to the stable user path. Skips
17
- * if the destination already exists and `codesign --verify` passes, unless
18
- * `forceReinstall=true`.
19
+ * if the destination already exists, `codesign --verify` passes, AND the
20
+ * installed executable matches the bundled one byte-for-byte — a valid
21
+ * signature alone is not enough, because an outdated helper signs clean too.
22
+ * `forceReinstall=true` skips all checks and always copies.
19
23
  *
20
24
  * Notarization is checked via `spctl --assess` after install — a failure is
21
25
  * logged as a warning but does NOT throw. Notarization checks require network
@@ -27,7 +31,11 @@ export declare function ensureKeychainHelperInstalled(opts?: {
27
31
  }): void;
28
32
  /**
29
33
  * Return the absolute path to the helper executable. If the installed bundle
30
- * is missing, performs a lazy install first.
34
+ * is missing, or is stale relative to the bundled source helper, performs a
35
+ * lazy (re)install first. The staleness check is what lets an upgraded CLI
36
+ * replace a helper a previous version installed — `agents helper install`
37
+ * never runs on `npm i -g`, so this call site is the only one every machine
38
+ * is guaranteed to pass through.
31
39
  *
32
40
  * Throws on non-darwin.
33
41
  */