@phnx-labs/agents-cli 1.22.41 → 1.22.42

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 (167) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +9 -3
  3. package/dist/bin/agents +0 -0
  4. package/dist/cli/command-registry.d.ts +2 -0
  5. package/dist/cli/command-registry.js +4 -0
  6. package/dist/commands/accounts.d.ts +29 -0
  7. package/dist/commands/accounts.js +220 -29
  8. package/dist/commands/alias.js +1 -1
  9. package/dist/commands/auth.d.ts +9 -0
  10. package/dist/commands/auth.js +104 -0
  11. package/dist/commands/browser-sessions-picker.js +42 -62
  12. package/dist/commands/browser.js +30 -38
  13. package/dist/commands/computer-actions.d.ts +1 -1
  14. package/dist/commands/computer-actions.js +1 -1
  15. package/dist/commands/computer-sessions-picker.js +32 -55
  16. package/dist/commands/computer.d.ts +1 -1
  17. package/dist/commands/computer.js +2 -2
  18. package/dist/commands/doctor.d.ts +1 -1
  19. package/dist/commands/doctor.js +3 -3
  20. package/dist/commands/feed.js +1 -1
  21. package/dist/commands/hooks.js +1 -1
  22. package/dist/commands/import.js +1 -1
  23. package/dist/commands/insights.js +163 -85
  24. package/dist/commands/inspect.js +2 -2
  25. package/dist/commands/mine.js +1 -1
  26. package/dist/commands/org.d.ts +6 -0
  27. package/dist/commands/org.js +174 -0
  28. package/dist/commands/packages.js +1 -1
  29. package/dist/commands/prune.js +1 -1
  30. package/dist/commands/routines.js +1 -1
  31. package/dist/commands/run-account-picker.d.ts +19 -0
  32. package/dist/commands/run-account-picker.js +81 -0
  33. package/dist/commands/sessions-picker-factory.d.ts +40 -0
  34. package/dist/commands/sessions-picker-factory.js +67 -0
  35. package/dist/commands/setup-browser.js +4 -4
  36. package/dist/commands/setup-mine.js +1 -1
  37. package/dist/commands/setup-preferences.js +2 -2
  38. package/dist/commands/setup.js +5 -3
  39. package/dist/commands/sync.js +1 -1
  40. package/dist/commands/versions.js +1 -1
  41. package/dist/commands/view.js +1 -1
  42. package/dist/lib/account-capabilities.d.ts +10 -0
  43. package/dist/lib/account-capabilities.js +34 -0
  44. package/dist/lib/agent-spec/agents.d.ts +584 -0
  45. package/dist/lib/agent-spec/agents.js +3127 -0
  46. package/dist/lib/agent-spec/provider.js +1 -1
  47. package/dist/lib/agents.d.ts +2 -584
  48. package/dist/lib/agents.js +2 -3127
  49. package/dist/lib/browser/drivers/local.d.ts +8 -2
  50. package/dist/lib/browser/drivers/local.js +8 -2
  51. package/dist/lib/browser/drivers/ssh.d.ts +8 -2
  52. package/dist/lib/browser/drivers/ssh.js +10 -4
  53. package/dist/lib/browser/hygiene.d.ts +1 -1
  54. package/dist/lib/browser/hygiene.js +5 -1
  55. package/dist/lib/browser/profiles.d.ts +74 -1
  56. package/dist/lib/browser/profiles.js +114 -5
  57. package/dist/lib/browser/runtime-state.d.ts +11 -5
  58. package/dist/lib/browser/runtime-state.js +13 -9
  59. package/dist/lib/browser/service.d.ts +68 -30
  60. package/dist/lib/browser/service.js +305 -194
  61. package/dist/lib/browser/types.d.ts +95 -3
  62. package/dist/lib/browser/types.js +51 -0
  63. package/dist/lib/catchup.js +1 -1
  64. package/dist/lib/computer/actions.d.ts +1 -1
  65. package/dist/lib/computer/actions.js +1 -1
  66. package/dist/lib/{computer-rpc.js → computer/computer-rpc.js} +5 -5
  67. package/dist/lib/computer/dispatch.d.ts +1 -1
  68. package/dist/lib/computer/download.d.ts +1 -1
  69. package/dist/lib/computer/download.js +3 -3
  70. package/dist/lib/{ssh-tunnel.d.ts → computer/ssh-tunnel.d.ts} +1 -1
  71. package/dist/lib/{ssh-tunnel.js → computer/ssh-tunnel.js} +12 -12
  72. package/dist/lib/daemon/daemon.js +1 -1
  73. package/dist/lib/{runner.d.ts → daemon/runner.d.ts} +10 -10
  74. package/dist/lib/{runner.js → daemon/runner.js} +40 -114
  75. package/dist/lib/devices/doctor-findings.d.ts +1 -1
  76. package/dist/lib/devices/doctor-findings.js +3 -3
  77. package/dist/lib/devices/fleet-inventory.js +1 -1
  78. package/dist/lib/devices/harness-inventory.js +1 -1
  79. package/dist/lib/devices/registry.js +4 -22
  80. package/dist/lib/doctor-diff.d.ts +1 -1
  81. package/dist/lib/doctor-diff.js +1 -1
  82. package/dist/lib/drift.js +1 -1
  83. package/dist/lib/entitlement.d.ts +31 -0
  84. package/dist/lib/entitlement.js +137 -0
  85. package/dist/lib/exec.js +39 -202
  86. package/dist/lib/feed/feed.js +2 -8
  87. package/dist/lib/fs-atomic.d.ts +7 -0
  88. package/dist/lib/fs-atomic.js +9 -0
  89. package/dist/lib/harness/adapter.d.ts +160 -0
  90. package/dist/lib/harness/adapter.js +47 -0
  91. package/dist/lib/harness/adapters/claude.d.ts +2 -0
  92. package/dist/lib/harness/adapters/claude.js +110 -0
  93. package/dist/lib/harness/adapters/codex.d.ts +2 -0
  94. package/dist/lib/harness/adapters/codex.js +83 -0
  95. package/dist/lib/harness/adapters/copilot.d.ts +2 -0
  96. package/dist/lib/harness/adapters/copilot.js +23 -0
  97. package/dist/lib/harness/adapters/cursor.d.ts +2 -0
  98. package/dist/lib/harness/adapters/cursor.js +38 -0
  99. package/dist/lib/harness/adapters/droid.d.ts +2 -0
  100. package/dist/lib/harness/adapters/droid.js +17 -0
  101. package/dist/lib/harness/adapters/grok.d.ts +2 -0
  102. package/dist/lib/harness/adapters/grok.js +14 -0
  103. package/dist/lib/harness/adapters/kimi.d.ts +2 -0
  104. package/dist/lib/harness/adapters/kimi.js +49 -0
  105. package/dist/lib/harness/adapters/muse.d.ts +2 -0
  106. package/dist/lib/harness/adapters/muse.js +44 -0
  107. package/dist/lib/harness/adapters/opencode.d.ts +2 -0
  108. package/dist/lib/harness/adapters/opencode.js +14 -0
  109. package/dist/lib/harness/exec-config-version.d.ts +8 -0
  110. package/dist/lib/harness/exec-config-version.js +22 -0
  111. package/dist/lib/harness/index.d.ts +1 -0
  112. package/dist/lib/harness/index.js +34 -0
  113. package/dist/lib/{hooks.d.ts → hooks/install.d.ts} +1 -1
  114. package/dist/lib/{hooks.js → hooks/install.js} +11 -11
  115. package/dist/lib/import.js +1 -1
  116. package/dist/lib/installations/index.d.ts +4 -4
  117. package/dist/lib/installations/index.js +3 -4
  118. package/dist/lib/installations/migrate.js +6 -6
  119. package/dist/lib/{shims.d.ts → installations/shims.d.ts} +2 -2
  120. package/dist/lib/{shims.js → installations/shims.js} +12 -114
  121. package/dist/lib/installations/store.d.ts +141 -3
  122. package/dist/lib/installations/store.js +475 -5
  123. package/dist/lib/installations/versions.d.ts +1 -126
  124. package/dist/lib/installations/versions.js +5 -465
  125. package/dist/lib/isolation-boundary-report.d.ts +1 -1
  126. package/dist/lib/isolation-boundary-report.js +1 -1
  127. package/dist/lib/mailbox-gc.js +2 -3
  128. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  129. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  130. package/dist/lib/monitors/dispatch.d.ts +1 -1
  131. package/dist/lib/monitors/dispatch.js +2 -2
  132. package/dist/lib/monitors/state.d.ts +1 -1
  133. package/dist/lib/plugins/plugins.js +1 -1
  134. package/dist/lib/plugins/skills.js +1 -1
  135. package/dist/lib/prix-account.d.ts +158 -0
  136. package/dist/lib/prix-account.js +214 -0
  137. package/dist/lib/project-key.js +2 -2
  138. package/dist/lib/project-launch.js +1 -1
  139. package/dist/lib/refresh.js +2 -2
  140. package/dist/lib/resource-inventory.d.ts +1 -1
  141. package/dist/lib/resource-inventory.js +1 -1
  142. package/dist/lib/rules/rules.js +1 -1
  143. package/dist/lib/scheduling/routines.js +1 -1
  144. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  145. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  146. package/dist/lib/self-heal/checks/hook-runtime.js +1 -1
  147. package/dist/lib/self-heal/checks/path.js +1 -1
  148. package/dist/lib/self-heal/checks/shadowing.js +1 -1
  149. package/dist/lib/self-heal/checks/shims.js +1 -1
  150. package/dist/lib/session/discover.d.ts +39 -0
  151. package/dist/lib/session/discover.js +3 -3
  152. package/dist/lib/session/parse.js +24 -42
  153. package/dist/lib/session/recovery.js +1 -1
  154. package/dist/lib/staleness/prune.d.ts +1 -1
  155. package/dist/lib/staleness/prune.js +1 -1
  156. package/dist/lib/staleness/writers/hooks.js +1 -1
  157. package/dist/lib/startup/command-registry.js +1 -1
  158. package/dist/lib/teams/agents.js +2 -19
  159. package/dist/lib/teams/parsers.js +20 -26
  160. package/dist/lib/teams/registry.js +2 -21
  161. package/dist/lib/triggers/handlers.js +1 -1
  162. package/dist/lib/triggers/webhook.js +1 -1
  163. package/dist/lib/uninstall.js +1 -1
  164. package/dist/lib/version.d.ts +9 -2
  165. package/dist/lib/version.js +11 -9
  166. package/package.json +1 -1
  167. /package/dist/lib/{computer-rpc.d.ts → computer/computer-rpc.d.ts} +0 -0
@@ -0,0 +1,3127 @@
1
+ /**
2
+ * Core agent configuration and detection module.
3
+ *
4
+ * Defines the canonical registry of current and legacy AI coding agents (Claude,
5
+ * Codex, Gemini, Cursor, OpenCode, OpenClaw, Copilot, Amp, Kiro, Goose, Grok)
6
+ * with their CLI commands, config paths, capability flags, and MCP integration
7
+ * points.
8
+ *
9
+ * Provides functions for detecting installed CLIs, resolving version-managed binaries,
10
+ * reading account/auth info, and managing MCP server registrations across agents.
11
+ */
12
+ import { execFile } from 'child_process';
13
+ import { promisify } from 'util';
14
+ import * as crypto from 'crypto';
15
+ import * as fs from 'fs';
16
+ import * as path from 'path';
17
+ import * as os from 'os';
18
+ import * as TOML from 'smol-toml';
19
+ import * as yaml from 'yaml';
20
+ import chalk from 'chalk';
21
+ import { execFileShellSpec } from '../platform/index.js';
22
+ import { latestFileMtimeMs } from '../fs-walk.js';
23
+ import { damerauLevenshtein } from '../fuzzy.js';
24
+ import { getCacheDir, getVersionsDir, getShimsDir, getHistoryDir, getCliVersionCachePath } from '../state.js';
25
+ import { resolveVersion, getVersionHomePath, getBinaryPath } from '../installations/versions.js';
26
+ import { supports } from '../capabilities.js';
27
+ import { MCP_TARGETS } from '../mcp-registry.js';
28
+ import { VERSION_RE } from './primitives.js';
29
+ const execFileAsync = promisify(execFile);
30
+ const HOME = os.homedir();
31
+ /**
32
+ * Minimum Codex CLI version that supports hooks.
33
+ * Mirrored on `AGENTS.codex.capabilities.hooks.since` -- kept exported for
34
+ * legacy import sites that haven't migrated to `supports()` yet.
35
+ */
36
+ export const CODEX_HOOKS_MIN_VERSION = '0.116.0';
37
+ /** Minimum Gemini CLI version that supports the hooks system (v0.26.0, Jan 2026). */
38
+ export const GEMINI_HOOKS_MIN_VERSION = '0.26.0';
39
+ const CLI_VERSION_CACHE_PATH = getCliVersionCachePath();
40
+ let cliVersionCache = null;
41
+ function loadCliVersionCache() {
42
+ if (cliVersionCache)
43
+ return cliVersionCache;
44
+ try {
45
+ cliVersionCache = JSON.parse(fs.readFileSync(CLI_VERSION_CACHE_PATH, 'utf-8'));
46
+ }
47
+ catch {
48
+ /* missing or corrupt cache, rebuild */
49
+ cliVersionCache = {};
50
+ }
51
+ return cliVersionCache;
52
+ }
53
+ function saveCliVersionCache() {
54
+ if (!cliVersionCache)
55
+ return;
56
+ try {
57
+ const dir = path.dirname(CLI_VERSION_CACHE_PATH);
58
+ if (!fs.existsSync(dir))
59
+ fs.mkdirSync(dir, { recursive: true });
60
+ fs.writeFileSync(CLI_VERSION_CACHE_PATH, JSON.stringify(cliVersionCache));
61
+ }
62
+ catch {
63
+ /* best-effort cache persist */
64
+ }
65
+ }
66
+ function pathIsWithin(candidate, directory) {
67
+ const relative = path.relative(directory, candidate);
68
+ return relative === '' || (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative));
69
+ }
70
+ /**
71
+ * Resolve a PATH candidate to the immutable native executable agents-cli may
72
+ * safely register. An adopted launcher can live outside the shims directory
73
+ * while resolving back into it; in that case its durable adoption record is
74
+ * the source of truth for the original executable.
75
+ */
76
+ export function resolveNativeBinaryPath(command, candidate, options = {}) {
77
+ const shimsDir = options.shimsDir ?? getShimsDir();
78
+ const historyDir = options.historyDir ?? getHistoryDir();
79
+ let canonicalCandidate;
80
+ try {
81
+ canonicalCandidate = fs.realpathSync(candidate);
82
+ }
83
+ catch {
84
+ return null;
85
+ }
86
+ let canonicalShimsDir = path.resolve(shimsDir);
87
+ try {
88
+ canonicalShimsDir = fs.realpathSync(shimsDir);
89
+ }
90
+ catch {
91
+ /* An absent shims dir cannot make an existing native binary invalid. */
92
+ }
93
+ if (!pathIsWithin(canonicalCandidate, canonicalShimsDir))
94
+ return canonicalCandidate;
95
+ const recordPath = path.join(historyDir, 'adopted-launchers', command);
96
+ try {
97
+ const [original] = fs.readFileSync(recordPath, 'utf-8').split(/\r?\n/, 1);
98
+ if (!original)
99
+ return null;
100
+ const canonicalOriginal = fs.realpathSync(original);
101
+ if (pathIsWithin(canonicalOriginal, canonicalShimsDir))
102
+ return null;
103
+ const stat = fs.statSync(canonicalOriginal);
104
+ if (!stat.isFile())
105
+ return null;
106
+ fs.accessSync(canonicalOriginal, fs.constants.X_OK);
107
+ return canonicalOriginal;
108
+ }
109
+ catch {
110
+ return null;
111
+ }
112
+ }
113
+ export function findInPath(command, options = {}) {
114
+ const pathEnv = process.env.PATH || '';
115
+ const pathExt = process.platform === 'win32' ? (process.env.PATHEXT || '').split(';') : [''];
116
+ const shimsDir = options.shimsDir ?? getShimsDir();
117
+ for (const dir of pathEnv.split(path.delimiter)) {
118
+ if (!dir)
119
+ continue;
120
+ if (path.resolve(dir) === path.resolve(shimsDir))
121
+ continue;
122
+ for (const ext of pathExt) {
123
+ const full = path.join(dir, command + ext);
124
+ try {
125
+ const stat = fs.statSync(full);
126
+ if (!stat.isFile())
127
+ continue;
128
+ const native = resolveNativeBinaryPath(command, full, options);
129
+ if (native)
130
+ return native;
131
+ }
132
+ catch {
133
+ /* not in this dir */
134
+ }
135
+ }
136
+ }
137
+ return null;
138
+ }
139
+ /** Grok-specific binary resolution.
140
+ * Grok does not live in node_modules/.bin. Its versioned binaries live in each
141
+ * managed version home under `.grok/downloads/`, so detection must not follow
142
+ * the host ~/.grok config symlink.
143
+ */
144
+ function resolveGrokBinary(version) {
145
+ if (version && version !== 'latest') {
146
+ const binaryPath = getBinaryPath('grok', version);
147
+ if (fs.existsSync(binaryPath))
148
+ return binaryPath;
149
+ return null;
150
+ }
151
+ const resolvedVersion = resolveVersion('grok', process.cwd());
152
+ if (resolvedVersion) {
153
+ const binaryPath = getBinaryPath('grok', resolvedVersion);
154
+ if (fs.existsSync(binaryPath))
155
+ return binaryPath;
156
+ }
157
+ const grokVersionsDir = path.join(getVersionsDir(), 'grok');
158
+ if (!fs.existsSync(grokVersionsDir))
159
+ return null;
160
+ let latest = null;
161
+ let latestMtime = 0;
162
+ for (const entry of fs.readdirSync(grokVersionsDir, { withFileTypes: true })) {
163
+ if (!entry.isDirectory())
164
+ continue;
165
+ const binaryPath = getBinaryPath('grok', entry.name);
166
+ if (!fs.existsSync(binaryPath))
167
+ continue;
168
+ try {
169
+ const stat = fs.statSync(binaryPath);
170
+ if (stat.mtimeMs > latestMtime) {
171
+ latestMtime = stat.mtimeMs;
172
+ latest = binaryPath;
173
+ }
174
+ }
175
+ catch { }
176
+ }
177
+ return latest;
178
+ }
179
+ function splitCommandLine(command) {
180
+ const args = [];
181
+ let current = '';
182
+ let quote = null;
183
+ let tokenStarted = false;
184
+ for (let i = 0; i < command.length; i++) {
185
+ const char = command[i];
186
+ if (quote) {
187
+ if (char === quote) {
188
+ quote = null;
189
+ tokenStarted = true;
190
+ }
191
+ else if (char === '\\' && quote === '"' && i + 1 < command.length) {
192
+ current += command[++i];
193
+ tokenStarted = true;
194
+ }
195
+ else {
196
+ current += char;
197
+ tokenStarted = true;
198
+ }
199
+ continue;
200
+ }
201
+ if (char === '"' || char === "'") {
202
+ quote = char;
203
+ tokenStarted = true;
204
+ continue;
205
+ }
206
+ if (/\s/.test(char)) {
207
+ if (tokenStarted) {
208
+ args.push(current);
209
+ current = '';
210
+ tokenStarted = false;
211
+ }
212
+ continue;
213
+ }
214
+ if (char === '\\' && i + 1 < command.length) {
215
+ current += command[++i];
216
+ tokenStarted = true;
217
+ continue;
218
+ }
219
+ current += char;
220
+ tokenStarted = true;
221
+ }
222
+ if (quote) {
223
+ throw new Error('Unterminated quote in MCP command');
224
+ }
225
+ if (tokenStarted) {
226
+ args.push(current);
227
+ }
228
+ if (args.length === 0) {
229
+ throw new Error('MCP command is required');
230
+ }
231
+ return args;
232
+ }
233
+ /**
234
+ * Master registry of all supported agents keyed by AgentId.
235
+ *
236
+ * Each entry defines the agent's CLI command, npm package, config directory layout,
237
+ * instructions file name, slash-command format, and capability flags. This is the
238
+ * single source of truth for agent metadata consumed throughout the codebase.
239
+ */
240
+ export const AGENTS = {
241
+ claude: {
242
+ id: 'claude',
243
+ name: 'Claude',
244
+ sessionDir: ['.claude', 'projects'],
245
+ sessionFileExt: '.jsonl',
246
+ versionStdoutMatch: 'semver',
247
+ unmanagedBinary: 'path',
248
+ mcpRegister: 'cli',
249
+ mcpAddHttp: 'transport',
250
+ mcpAddStdio: 'scope',
251
+ mcpConfigWrite: 'json-mcpServers',
252
+ color: 'magenta',
253
+ cliCommand: 'claude',
254
+ npmPackage: '@anthropic-ai/claude-code',
255
+ configDir: path.join(HOME, '.claude'),
256
+ homeFiles: ['.claude.json'],
257
+ commandsDir: path.join(HOME, '.claude', 'commands'),
258
+ commandsSubdir: 'commands',
259
+ skillsDir: path.join(HOME, '.claude', 'skills'),
260
+ hooksDir: 'hooks',
261
+ instructionsFile: 'CLAUDE.md',
262
+ format: 'markdown',
263
+ variableSyntax: '$ARGUMENTS',
264
+ supportsHooks: true,
265
+ // Claude Code grew a native `claude --cloud "<prompt>"` (Anthropic-managed
266
+ // infra, claude.ai/code; requires claude.ai subscription auth). Routing
267
+ // still goes to Rush Cloud deliberately — it keeps cloud tasks in one
268
+ // tracked fleet (agents cloud list/status/logs) regardless of harness.
269
+ cloudProvider: 'rush',
270
+ capabilities: { hooks: true, mcp: true, mcpHttp: true, mcpHeaders: true, allowlist: true, skills: true, commands: true, plugins: true, subagents: true, rules: { file: 'CLAUDE.md' }, workflows: true, memory: true, modes: ['plan', 'edit', 'auto', 'skip'], rulesImports: true, interactiveRepl: true },
271
+ },
272
+ // codex hooks: gated to >= 0.116.0 (introduced [features] codex_hooks flag).
273
+ codex: {
274
+ id: 'codex',
275
+ name: 'Codex',
276
+ sessionDir: ['.codex', 'sessions'],
277
+ sessionFileExt: '.jsonl',
278
+ versionStdoutMatch: 'semver',
279
+ unmanagedBinary: 'path',
280
+ mcpRegister: 'cli',
281
+ mcpAddHttp: 'url',
282
+ mcpAddStdio: 'simple',
283
+ mcpConfigWrite: 'json-mcpServers',
284
+ color: 'green',
285
+ cliCommand: 'codex',
286
+ npmPackage: '@openai/codex',
287
+ configDir: path.join(HOME, '.codex'),
288
+ commandsDir: path.join(HOME, '.codex', 'prompts'),
289
+ commandsSubdir: 'prompts',
290
+ skillsDir: path.join(HOME, '.codex', 'skills'),
291
+ hooksDir: 'hooks',
292
+ pluginManifestDir: '.codex-plugin',
293
+ instructionsFile: 'AGENTS.md',
294
+ format: 'markdown',
295
+ variableSyntax: '$ARGUMENTS',
296
+ supportsHooks: true,
297
+ cloudProvider: 'codex',
298
+ // Subagents: multi-agent plumbing since 0.117.0; custom agents as
299
+ // ~/.codex/agents/*.toml (name, description, developer_instructions).
300
+ capabilities: { hooks: { since: '0.116.0' }, mcp: true, mcpHttp: true, mcpHeaders: false, allowlist: { since: '0.138.0' }, skills: true, commands: { until: '0.117.0' }, plugins: { since: '0.128.0' }, subagents: { since: '0.117.0' }, rules: { file: 'AGENTS.md' }, workflows: false, memory: true, modes: ['plan', 'edit', 'skip'], interactiveRepl: true },
301
+ },
302
+ gemini: {
303
+ id: 'gemini',
304
+ name: 'Gemini',
305
+ sessionDir: ['.gemini', 'tmp'],
306
+ sessionFileExt: '.json',
307
+ versionStdoutMatch: 'semver',
308
+ unmanagedBinary: 'path',
309
+ mcpRegister: 'cli',
310
+ mcpAddHttp: 'transport',
311
+ mcpAddStdio: 'simple',
312
+ mcpConfigWrite: 'json-mcpServers',
313
+ color: 'blue',
314
+ cliCommand: 'gemini',
315
+ npmPackage: '@google/gemini-cli',
316
+ configDir: path.join(HOME, '.gemini'),
317
+ commandsDir: path.join(HOME, '.gemini', 'commands'),
318
+ commandsSubdir: 'commands',
319
+ skillsDir: path.join(HOME, '.gemini', 'skills'),
320
+ hooksDir: 'hooks',
321
+ instructionsFile: 'GEMINI.md',
322
+ format: 'toml',
323
+ variableSyntax: '{{args}}',
324
+ supportsHooks: true,
325
+ nativeAgentsSkillsDir: true,
326
+ // Google retired the Gemini CLI (announced at Google I/O 2026, May 19); the `gemini`
327
+ // command stopped serving free/Pro/Ultra requests on June 18, 2026. Antigravity CLI
328
+ // (`agy`) is the official successor. Hard deprecation keeps legacy Gemini
329
+ // sessions/config parseable while blocking install/import/sync.
330
+ deprecated: {
331
+ by: 'Google',
332
+ date: 'June 18, 2026',
333
+ reason: 'The Gemini CLI was retired for free, Pro, and Ultra tiers and no longer serves requests (announced at Google I/O 2026 on May 19).',
334
+ replacement: 'antigravity',
335
+ url: 'https://developers.googleblog.com/an-important-update-transitioning-gemini-cli-to-antigravity-cli/',
336
+ hard: true,
337
+ },
338
+ // gemini hooks: shipped in v0.26.0 (Jan 2026); older binaries silently ignore the `hooks` key.
339
+ // extensions: gemini-extension.json bundles shipped in v0.8.0; custom subagents in v0.36.0.
340
+ capabilities: { hooks: { since: '0.26.0' }, mcp: true, mcpHttp: true, mcpHeaders: false, allowlist: true, skills: true, commands: true, plugins: { since: '0.8.0' }, subagents: { since: '0.36.0' }, rules: { file: 'GEMINI.md' }, workflows: false, memory: false, modes: ['plan', 'edit', 'skip'], rulesImports: true, interactiveRepl: false },
341
+ },
342
+ cursor: {
343
+ id: 'cursor',
344
+ name: 'Cursor',
345
+ sessionDir: null,
346
+ sessionFileExt: null,
347
+ versionStdoutMatch: 'semver',
348
+ unmanagedBinary: 'path',
349
+ mcpRegister: 'cli',
350
+ mcpAddHttp: 'transport',
351
+ mcpAddStdio: 'simple',
352
+ mcpConfigWrite: 'json-mcpServers',
353
+ color: 'cyan',
354
+ cliCommand: 'cursor-agent',
355
+ npmPackage: '',
356
+ cloudProvider: 'cursor',
357
+ installScript: 'curl https://cursor.com/install -fsS | bash && mv ~/.local/bin/agent ~/.local/bin/cursor-agent && grep -q "/.local/bin" ~/.zshrc || echo \'export PATH="$HOME/.local/bin:$PATH"\' >> ~/.zshrc',
358
+ configDir: path.join(HOME, '.cursor'),
359
+ commandsDir: path.join(HOME, '.cursor', 'commands'),
360
+ commandsSubdir: 'commands',
361
+ skillsDir: path.join(HOME, '.cursor', 'skills'),
362
+ // Hooks: ~/.cursor/hooks.json (`{ "version": 1, "hooks": { event: [{ command }] } }`).
363
+ // CLI hooks since 2026-01-16. See registerHooksForCursor — only CLI-fired events.
364
+ hooksDir: 'hooks',
365
+ // Plugins: `.cursor-plugin/plugin.json` (re-enabled in CLI 2026-05). Mirror the
366
+ // Claude marketplace layout into ~/.cursor/plugins/ and copy the manifest into
367
+ // pluginManifestDir so Cursor's native loader sees it (same pattern as droid/
368
+ // codex).
369
+ pluginManifestDir: '.cursor-plugin',
370
+ instructionsFile: '.cursorrules',
371
+ format: 'markdown',
372
+ variableSyntax: '$ARGUMENTS',
373
+ supportsHooks: true,
374
+ // Subagents: `.cursor/agents/<name>.md` (project) or `~/.cursor/agents/<name>.md`
375
+ // (user), Markdown with YAML frontmatter (name, description, model, readonly,
376
+ // is_background — no `color`). Shipped in cursor-agent CLI 2026.01 (Cursor 2.4,
377
+ // 2026-01-22); cursor-agent uses CalVer build tags (e.g. 2025.11.25-<hash>), so
378
+ // gate at `>= 2026.1.22`. The `agents sync` path enforces this (versions.ts skips
379
+ // + warns for pre-2.4 installs); the direct `subagents add --agents cursor` path
380
+ // writes unconditionally, same as the other since-gated agents.
381
+ // See transformSubagentForCursor / https://cursor.com/docs/subagents.
382
+ // Current cursor-agent builds open their interactive TUI with no argv.
383
+ capabilities: { hooks: true, mcp: true, mcpHttp: false, mcpHeaders: false, allowlist: true, skills: true, commands: true, plugins: true, subagents: { since: '2026.1.22' }, rules: { file: '.cursorrules' }, workflows: false, memory: false, modes: ['plan', 'edit', 'skip'], interactiveRepl: true }, // allowlist: ~/.cursor/cli-config.json
384
+ },
385
+ opencode: {
386
+ id: 'opencode',
387
+ name: 'OpenCode',
388
+ sessionDir: null,
389
+ sessionFileExt: null,
390
+ versionStdoutMatch: 'semver',
391
+ unmanagedBinary: 'path',
392
+ mcpRegister: 'cli',
393
+ mcpAddHttp: 'transport',
394
+ mcpAddStdio: 'simple',
395
+ mcpConfigWrite: 'json-mcpServers',
396
+ color: 'yellowBright',
397
+ cliCommand: 'opencode',
398
+ npmPackage: 'opencode-ai',
399
+ configDir: path.join(HOME, '.opencode'),
400
+ commandsDir: path.join(HOME, '.opencode', 'commands'),
401
+ commandsSubdir: 'commands',
402
+ skillsDir: path.join(HOME, '.opencode', 'skills'),
403
+ // Plugins: TS/JS modules auto-loaded from ~/.config/opencode/plugins/ (global)
404
+ // and .opencode/plugins/ (project). Not Claude marketplace format — see
405
+ // installOpenCodePlugin in plugins.ts. OpenCode v1.18.4 exposes lifecycle
406
+ // hooks through plugin modules (event/tool/etc. functions), not a native
407
+ // opencode.json shell-command hooks block.
408
+ hooksDir: 'hooks',
409
+ instructionsFile: 'AGENTS.md',
410
+ format: 'markdown',
411
+ variableSyntax: '$ARGUMENTS',
412
+ supportsHooks: true,
413
+ capabilities: { hooks: { since: '0.3.130' }, mcp: true, mcpHttp: false, mcpHeaders: false, allowlist: { since: '1.1.1' }, skills: true, commands: true, plugins: true, subagents: true, rules: { file: 'AGENTS.md' }, workflows: false, memory: false, modes: ['plan', 'edit'], interactiveRepl: true },
414
+ },
415
+ // Oh My Pi (`omp`, omp.sh) — a Bun-based, terminal-first coding agent that runs
416
+ // against many model providers (OpenRouter, OpenAI, Anthropic, xAI, DeepSeek,
417
+ // Ollama, LM Studio, …). It is Claude-compatible: it natively discovers
418
+ // `.claude/commands`, `.mcp.json`, and Claude-shaped subagents, and keeps its
419
+ // own native resources under `~/.omp/agent/` (config dir reported by
420
+ // `omp config path`). configDir points AT the agent dir (not `~/.omp`) so the
421
+ // rules file lands at `~/.omp/agent/AGENTS.md`, the user context file omp reads.
422
+ pi: {
423
+ id: 'pi',
424
+ name: 'Pi',
425
+ sessionDir: null,
426
+ sessionFileExt: null,
427
+ versionStdoutMatch: 'semver',
428
+ unmanagedBinary: 'path',
429
+ mcpRegister: 'cli',
430
+ mcpAddHttp: 'transport',
431
+ mcpAddStdio: 'simple',
432
+ mcpConfigWrite: 'json-mcpServers',
433
+ color: 'magenta',
434
+ cliCommand: 'omp',
435
+ npmPackage: '@oh-my-pi/pi-coding-agent',
436
+ configDir: path.join(HOME, '.omp', 'agent'),
437
+ commandsDir: path.join(HOME, '.omp', 'agent', 'commands'),
438
+ commandsSubdir: 'commands',
439
+ skillsDir: path.join(HOME, '.omp', 'agent', 'skills'),
440
+ hooksDir: 'hooks',
441
+ instructionsFile: 'AGENTS.md',
442
+ format: 'markdown',
443
+ variableSyntax: '$ARGUMENTS',
444
+ // omp hooks are per-tool JS/TS extension modules discovered from
445
+ // `~/.omp/agent/hooks/{pre,post}/<tool>.<ext>` (loaded as HookFactory code),
446
+ // NOT the event->shell-command registrations agents-cli's hook sync writes.
447
+ // The two models don't map, so hooks stay off (capabilities.hooks:false).
448
+ supportsHooks: false,
449
+ // MCP: omp reads `.mcp.json` with the Claude `{ "mcpServers": {...} }` schema
450
+ // at user scope (`~/.omp/agent/.mcp.json`) and project scope (`<root>/.mcp.json`),
451
+ // stdio + http + sse with headers. skills (`~/.omp/agent/skills/<name>/SKILL.md`),
452
+ // commands (`~/.omp/agent/commands/*.md`), and subagents (`~/.omp/agent/agents/*.md`,
453
+ // Claude-shaped) are all native. allowlist is OFF: omp gates approval per-TOOL
454
+ // only (`tools.approval` record: allow|prompt|deny) with no command/path/domain
455
+ // patterns, so agents-cli's granular permission format has nothing to map to.
456
+ // plugins are npm packages / TS modules, not the Claude marketplace manifest.
457
+ // interactiveRepl: true — bare `omp` runs the TUI; `omp -p` is the one-shot
458
+ // form that answers a prompt and exits (upstream README, "Four entry points").
459
+ capabilities: { hooks: false, mcp: true, mcpHttp: true, mcpHeaders: true, allowlist: false, skills: true, commands: true, plugins: false, subagents: true, rules: { file: 'AGENTS.md' }, workflows: false, memory: false, modes: ['plan', 'edit', 'skip'], interactiveRepl: true },
460
+ },
461
+ openclaw: {
462
+ id: 'openclaw',
463
+ name: 'OpenClaw',
464
+ sessionDir: null,
465
+ sessionFileExt: null,
466
+ versionStdoutMatch: 'openclaw',
467
+ unmanagedBinary: 'path',
468
+ mcpRegister: 'cli',
469
+ mcpAddHttp: 'transport',
470
+ mcpAddStdio: 'simple',
471
+ mcpConfigWrite: 'json-mcpServers',
472
+ color: 'redBright',
473
+ cliCommand: 'openclaw',
474
+ npmPackage: 'openclaw',
475
+ configDir: path.join(HOME, '.openclaw'),
476
+ commandsDir: '', // OpenClaw uses Gateway-based slash commands, not file-based
477
+ commandsSubdir: '',
478
+ skillsDir: path.join(HOME, '.openclaw', 'skills'),
479
+ nativeCommandRuntime: true, // Gateway resolves slash commands — don't convert commands to skills
480
+ hooksDir: 'hooks',
481
+ instructionsFile: 'workspace/AGENTS.md', // Primary memory file (also has SOUL.md, IDENTITY.md, etc.)
482
+ format: 'markdown',
483
+ variableSyntax: '{{ARGUMENTS}}',
484
+ // hooks: NOT supported. OpenClaw only exposes a fixed set of internal,
485
+ // named hooks (e.g. `boot-md`, which runs BOOT.md on gateway restart) —
486
+ // there is no general event->shell-command registration surface (no
487
+ // PreToolUse/PostToolUse/UserPromptSubmit equivalent an agents-cli
488
+ // hooks.yaml manifest could target). registerHooksToSettings has no
489
+ // `openclaw` case and silently no-ops (RUSH-2122: capability claimed
490
+ // hooks:true with zero hooks ever installed). Flip back to `true` only
491
+ // alongside a real registerHooksForOpenClaw implementation.
492
+ supportsHooks: false,
493
+ // allowlist: maps blanket (whole-tool) rules to ~/.openclaw/openclaw.json
494
+ // tools.alsoAllow (allow) / tools.deny (deny). OpenClaw gates at tool
495
+ // granularity only, so sub-command/path/domain patterns are skipped.
496
+ // OpenClaw is self-updating (no pinned since), so `true` is correct.
497
+ // Workflows sync as Lobster `.lobster` files under `.openclaw/workflows/`;
498
+ // the Lobster tool runs them by receiving the file path as `pipeline`.
499
+ capabilities: { hooks: false, mcp: true, mcpHttp: false, mcpHeaders: false, allowlist: true, skills: true, commands: false, plugins: true, subagents: true, rules: { file: 'workspace/AGENTS.md' }, workflows: true, memory: true, modes: ['plan', 'edit', 'skip'], interactiveRepl: true },
500
+ },
501
+ copilot: {
502
+ id: 'copilot',
503
+ name: 'Copilot',
504
+ sessionDir: ['.copilot', 'session-state'],
505
+ sessionFileExt: '.jsonl',
506
+ versionStdoutMatch: 'semver',
507
+ unmanagedBinary: 'path',
508
+ mcpRegister: 'cli',
509
+ mcpAddHttp: 'transport',
510
+ mcpAddStdio: 'simple',
511
+ mcpConfigWrite: 'json-mcpServers',
512
+ color: 'whiteBright',
513
+ cliCommand: 'copilot',
514
+ npmPackage: '@github/copilot',
515
+ configDir: path.join(HOME, '.copilot'),
516
+ commandsDir: path.join(HOME, '.copilot', 'commands'),
517
+ commandsSubdir: 'commands',
518
+ skillsDir: path.join(HOME, '.copilot', 'skills'),
519
+ // Hooks: user-level `~/.copilot/hooks/*.json` (GA @github/copilot, every
520
+ // 1.x). Schema `{ "version": 1, "hooks": { event: [...] } }` with camelCase
521
+ // event names (sessionStart, preToolUse, …). See registerHooksForCopilot.
522
+ hooksDir: 'hooks',
523
+ // Copilot reads a plugin's manifest from the plugin ROOT (plugin.json),
524
+ // not `.claude-plugin/plugin.json`. Mirror it there. Verified against the
525
+ // GitHub Copilot CLI (1.0.56): `copilot plugin install` produces an
526
+ // installed plugin dir whose manifest sits at the root.
527
+ pluginManifestDir: '.',
528
+ instructionsFile: 'AGENTS.md',
529
+ format: 'markdown',
530
+ variableSyntax: '$ARGUMENTS',
531
+ supportsHooks: true,
532
+ // interactiveRepl: false — copilot requires a prompt for meaningful work; bare invocation
533
+ // opens a welcome screen but not a persistent coding REPL suitable for agents focus.
534
+ capabilities: { hooks: true, mcp: true, mcpHttp: false, mcpHeaders: false, allowlist: true, skills: true, commands: true, plugins: true, subagents: { since: '0.0.353' }, rules: { file: 'AGENTS.md' }, workflows: false, memory: false, modes: ['plan', 'edit', 'auto', 'skip'], interactiveRepl: false },
535
+ },
536
+ amp: {
537
+ id: 'amp',
538
+ name: 'Amp',
539
+ sessionDir: null,
540
+ sessionFileExt: null,
541
+ versionStdoutMatch: 'semver',
542
+ unmanagedBinary: 'path',
543
+ mcpRegister: 'cli',
544
+ mcpAddHttp: 'transport',
545
+ mcpAddStdio: 'simple',
546
+ mcpConfigWrite: 'json-mcpServers',
547
+ color: 'blueBright',
548
+ cliCommand: 'amp',
549
+ npmPackage: '@sourcegraph/amp',
550
+ configDir: path.join(HOME, '.config', 'amp'),
551
+ commandsDir: path.join(HOME, '.config', 'amp', 'commands'),
552
+ commandsSubdir: 'commands',
553
+ skillsDir: path.join(HOME, '.config', 'amp', 'skills'),
554
+ hooksDir: 'hooks',
555
+ instructionsFile: 'AGENTS.md',
556
+ format: 'markdown',
557
+ variableSyntax: '$ARGUMENTS',
558
+ supportsHooks: false,
559
+ // interactiveRepl: false — amp requires a prompt; bare invocation exits immediately.
560
+ capabilities: { hooks: false, mcp: true, mcpHttp: false, mcpHeaders: false, allowlist: false, skills: true, commands: true, plugins: false, subagents: false, rules: { file: 'AGENTS.md' }, workflows: false, memory: false, modes: ['plan', 'edit'], interactiveRepl: false },
561
+ },
562
+ kiro: {
563
+ id: 'kiro',
564
+ name: 'Kiro',
565
+ sessionDir: null,
566
+ sessionFileExt: null,
567
+ versionStdoutMatch: 'semver',
568
+ unmanagedBinary: 'path',
569
+ mcpRegister: 'cli',
570
+ mcpAddHttp: 'transport',
571
+ mcpAddStdio: 'simple',
572
+ mcpConfigWrite: 'json-mcpServers',
573
+ color: 'greenBright',
574
+ cliCommand: 'kiro-cli',
575
+ npmPackage: '',
576
+ installScript: 'brew install --cask kiro-cli',
577
+ configDir: path.join(HOME, '.kiro'),
578
+ commandsDir: path.join(HOME, '.kiro', 'commands'),
579
+ commandsSubdir: 'commands',
580
+ skillsDir: path.join(HOME, '.kiro', 'skills'),
581
+ // Hooks: v3 standalone files under ~/.kiro/hooks/*.json
582
+ // (`{ "version": "v1", "hooks": [...] }`). Fixed PreToolUse/PostToolUse
583
+ // firing in kiro-cli 0.10; fully stable by 2.6.1. Launch always passes
584
+ // --v3 (see AGENT_COMMANDS.kiro) so the standalone files actually load.
585
+ // See registerHooksForKiro.
586
+ hooksDir: 'hooks',
587
+ instructionsFile: 'AGENTS.md',
588
+ format: 'markdown',
589
+ variableSyntax: '$ARGUMENTS',
590
+ supportsHooks: true,
591
+ // interactiveRepl: false — kiro-cli requires a prompt for coding sessions; bare
592
+ // invocation does not open a persistent REPL.
593
+ capabilities: { hooks: { since: '0.10.0' }, mcp: true, mcpHttp: false, mcpHeaders: false, allowlist: { since: '2.8.0' }, skills: true, commands: true, plugins: false, subagents: { since: '1.23.0' }, rules: { file: 'AGENTS.md' }, workflows: false, memory: false, modes: ['edit'], interactiveRepl: false },
594
+ },
595
+ goose: {
596
+ id: 'goose',
597
+ name: 'Goose',
598
+ sessionDir: null,
599
+ sessionFileExt: null,
600
+ versionStdoutMatch: 'semver',
601
+ unmanagedBinary: 'path',
602
+ mcpRegister: 'cli',
603
+ mcpAddHttp: 'transport',
604
+ mcpAddStdio: 'simple',
605
+ mcpConfigWrite: 'json-mcpServers',
606
+ color: 'magentaBright',
607
+ cliCommand: 'goose',
608
+ npmPackage: '',
609
+ installScript: 'brew install block-goose-cli',
610
+ configDir: path.join(HOME, '.config', 'goose'),
611
+ commandsDir: path.join(HOME, '.config', 'goose', 'commands'),
612
+ commandsSubdir: 'commands',
613
+ // Goose reads skills directly from central ~/.agents/skills/ via the Summon
614
+ // extension (block-goose-cli ≥ 1.25.0). No per-version copy is written.
615
+ skillsDir: path.join(HOME, '.agents', 'skills'),
616
+ nativeAgentsSkillsDir: true,
617
+ // Hooks: Open Plugins format — auto-discovered from
618
+ // ~/.agents/plugins/<name>/hooks/hooks.json (shipped block-goose-cli
619
+ // ≥ 1.34.0). See registerHooksForGoose.
620
+ hooksDir: 'hooks',
621
+ instructionsFile: 'AGENTS.md',
622
+ format: 'markdown',
623
+ variableSyntax: '$ARGUMENTS',
624
+ supportsHooks: true,
625
+ // Plugins: Open Plugins under ~/.agents/plugins/<name>/ (same layout as
626
+ // agents-cli source). Version isolation copies into versionHome/.agents/plugins/.
627
+ // Workflows sync as Goose recipe YAML. Permissions are NOT supported:
628
+ // permission.yaml gates whole tools, so canonical rules cannot round-trip.
629
+ // Commands: a Goose slash command is a recipe YAML under
630
+ // ~/.config/goose/commands/<name>.yaml, registered in ~/.config/goose/config.yaml
631
+ // under `slash_commands: [{ command, recipe_path }]` (see goose-commands.ts).
632
+ // Subagents: recipe YAML named agents under ~/.config/goose/agents/<name>.yaml
633
+ // (goose auto-discovers and delegates to them by name in autonomous mode).
634
+ capabilities: { hooks: { since: '1.34.0' }, mcp: true, mcpHttp: false, mcpHeaders: false, allowlist: false, skills: { since: '1.25.0' }, commands: true, plugins: true, subagents: true, rules: { file: 'AGENTS.md' }, workflows: true, memory: false, modes: ['edit'], interactiveRepl: true },
635
+ },
636
+ // Google Antigravity CLI (`agy`) — official replacement for Gemini CLI as of IO 2026.
637
+ // configDir nests inside `~/.gemini/` since agy shares the parent dir with the Gemini
638
+ // CLI but isolates its own state in the `antigravity-cli/` subdir. Per-version HOME
639
+ // isolation works because the shim's configDirName carries the full nested path.
640
+ // Auth: Google OAuth on first launch, or ANTIGRAVITY_API_KEY env var for headless.
641
+ // Hooks: JSON entries under a top-level `hooks` key in settings.json; events are
642
+ // before_tool_call, after_model_call, on_loop_stop, on_error. Plugins are the
643
+ // renamed Gemini CLI extensions. Permissions live in settings.json under a
644
+ // `permissions` key with allow/deny arrays.
645
+ antigravity: {
646
+ id: 'antigravity',
647
+ name: 'Antigravity',
648
+ sessionDir: null,
649
+ sessionFileExt: null,
650
+ versionStdoutMatch: 'semver',
651
+ unmanagedBinary: 'path',
652
+ mcpRegister: 'cli',
653
+ mcpAddHttp: 'transport',
654
+ mcpAddStdio: 'simple',
655
+ mcpConfigWrite: 'json-mcpServers',
656
+ color: 'blueBright',
657
+ cliCommand: 'agy',
658
+ npmPackage: '',
659
+ installScript: 'curl -fsSL https://antigravity.google/cli/install.sh | bash',
660
+ configDir: path.join(HOME, '.gemini', 'antigravity-cli'),
661
+ authFiles: ['antigravity-oauth-token'],
662
+ commandsDir: path.join(HOME, '.gemini', 'antigravity-cli', 'commands'),
663
+ commandsSubdir: 'commands',
664
+ skillsDir: path.join(HOME, '.gemini', 'antigravity-cli', 'skills'),
665
+ hooksDir: 'hooks',
666
+ instructionsFile: 'AGENTS.md',
667
+ format: 'markdown',
668
+ variableSyntax: '{{args}}',
669
+ supportsHooks: true,
670
+ cloudProvider: 'antigravity',
671
+ capabilities: { hooks: true, mcp: true, mcpHttp: false, mcpHeaders: false, allowlist: true, skills: true, commands: true, plugins: true, subagents: { since: '1.0.16' }, rules: { file: 'AGENTS.md' }, workflows: { since: '1.0.6' }, memory: false, modes: ['edit', 'skip'], rulesImports: false, interactiveRepl: true }, // workflows: markdown files in the shared, HOME-global ~/.gemini/config/global_workflows/ (agy scans it at startup; not version-isolated — see workflows.ts), invoked as /<name> slash commands
672
+ },
673
+ // xAI Grok Build CLI (`grok`) — early beta, SuperGrok Heavy. Auth via OAuth on
674
+ // first launch, or XAI_API_KEY env var for headless. MCP servers configured inline
675
+ // under [mcp_servers] in ~/.grok/config.toml. Hooks auto-discovered from
676
+ // ~/.grok/hooks/ (+ project .grok/hooks/) — events PreToolUse, PostToolUse, etc.
677
+ // Plugins live in ~/.grok/plugins/ with marketplaces. Permissions: --allow/--deny
678
+ // CLI flags or [permission] TOML block in ~/.grok/config.toml.
679
+ // Workflows (native Rhai orchestration) enabled by default as of v0.2.111 —
680
+ // projected into ~/.grok/workflows/<name>.rhai (under GROK_HOME isolation).
681
+ grok: {
682
+ id: 'grok',
683
+ name: 'Grok',
684
+ sessionDir: ['.grok', 'sessions'],
685
+ sessionFileExt: '.json',
686
+ versionStdoutMatch: 'semver',
687
+ unmanagedBinary: 'grok-downloads',
688
+ mcpRegister: 'cli',
689
+ mcpAddHttp: 'transport',
690
+ mcpAddStdio: 'simple',
691
+ mcpConfigWrite: 'json-mcpServers',
692
+ color: 'cyanBright',
693
+ cliCommand: 'grok',
694
+ npmPackage: '',
695
+ installScript: 'curl -fsSL https://x.ai/cli/install.sh | bash',
696
+ configDir: path.join(HOME, '.grok'),
697
+ // Grok discovers file-based slash commands from ~/.agents/commands/ (the
698
+ // cross-agent compat dir it mirrors from Claude Code) as well as the legacy
699
+ // ~/.claude/commands/ symlink. We write there directly so the per-agent
700
+ // install path and the central user repo stay in sync.
701
+ commandsDir: path.join(HOME, '.agents', 'commands'),
702
+ commandsSubdir: path.join('..', '.agents', 'commands'),
703
+ skillsDir: path.join(HOME, '.grok', 'skills'),
704
+ hooksDir: path.join(HOME, '.grok', 'hooks'),
705
+ instructionsFile: 'AGENTS.md',
706
+ format: 'markdown',
707
+ variableSyntax: '$ARGUMENTS',
708
+ supportsHooks: true,
709
+ capabilities: {
710
+ hooks: true,
711
+ mcp: true,
712
+ mcpHttp: false,
713
+ mcpHeaders: false,
714
+ allowlist: true, // maps to Grok's granular Bash/Edit/Write/Read/Grep/WebFetch/MCPTool rules
715
+ skills: true,
716
+ commands: true, // Grok >= 0.2.111: file-based slash commands from ~/.agents/commands/ (docs) + ~/.claude/commands/ (Claude Code compat)
717
+ plugins: true,
718
+ subagents: true, // ~/.grok/agents/*.md (Claude-compatible agent defs)
719
+ rules: { file: 'AGENTS.md' },
720
+ // Native workflows (`.rhai` under ~/.grok/workflows/) shipped on-by-default
721
+ // in v0.2.111 (2026-07-22). See transformWorkflowForGrok in workflows.ts.
722
+ workflows: { since: '0.2.111' },
723
+ memory: true,
724
+ modes: ['plan', 'edit', 'skip'],
725
+ // grok's `--permission-mode plan` silently stalls a headless `-p` run at
726
+ // its ExitPlanMode gate (no TTY to approve). Headless plan auto-downgrades
727
+ // to auto (→ edit via resolveMode). Interactive plan is unaffected.
728
+ headlessPlan: false,
729
+ rulesImports: true,
730
+ interactiveRepl: true,
731
+ },
732
+ },
733
+ // Kimi Code CLI (`kimi`) — Moonshot AI coding agent.
734
+ // Install: `curl -fsSL https://code.kimi.com/kimi-code/install.sh | bash`
735
+ // or: `npm install -g @moonshot-ai/kimi-code`
736
+ // Config: `~/.kimi-code/config.toml`, `~/.kimi-code/mcp.json`,
737
+ // `~/.kimi-code/skills/`, `~/.kimi-code/hooks/`
738
+ kimi: {
739
+ id: 'kimi',
740
+ name: 'Kimi',
741
+ sessionDir: null,
742
+ sessionFileExt: null,
743
+ versionStdoutMatch: 'semver',
744
+ unmanagedBinary: 'path',
745
+ mcpRegister: 'cli',
746
+ mcpAddHttp: 'transport',
747
+ mcpAddStdio: 'simple',
748
+ mcpConfigWrite: 'json-mcpServers',
749
+ color: 'magentaBright',
750
+ cliCommand: 'kimi',
751
+ npmPackage: '@moonshot-ai/kimi-code',
752
+ installScript: 'curl -fsSL https://code.kimi.com/kimi-code/install.sh | bash',
753
+ configDir: path.join(HOME, '.kimi-code'),
754
+ authFiles: ['credentials/kimi-code.json'],
755
+ commandsDir: '',
756
+ commandsSubdir: '',
757
+ skillsDir: path.join(HOME, '.kimi-code', 'skills'),
758
+ hooksDir: path.join(HOME, '.kimi-code', 'hooks'),
759
+ instructionsFile: 'AGENTS.md',
760
+ format: 'markdown',
761
+ variableSyntax: '$ARGUMENTS',
762
+ supportsHooks: true,
763
+ capabilities: {
764
+ hooks: true,
765
+ mcp: true,
766
+ mcpHttp: false,
767
+ mcpHeaders: false,
768
+ allowlist: true,
769
+ skills: true,
770
+ commands: false,
771
+ plugins: true,
772
+ // Claude-shaped agent markdown under ~/.kimi-code/agents/. kimi-code
773
+ // discovers that dir from `USER_BRAND_DIRS = ["agents"]` (agent-file
774
+ // discovery, added in 0.29.0); 0.28.x and older compile their four agent
775
+ // profiles into the bundle with no filesystem loader at all, so a synced
776
+ // file there is never read.
777
+ subagents: { since: '0.29.0' },
778
+ rules: { file: 'AGENTS.md' },
779
+ workflows: true,
780
+ memory: false,
781
+ modes: ['plan', 'edit', 'auto', 'skip'],
782
+ // kimi's headless `-p` refuses to combine with `--plan` (`Cannot combine
783
+ // --prompt with --plan`). Headless plan auto-downgrades to auto (kimi -p
784
+ // auto-runs). Interactive plan is unaffected.
785
+ headlessPlan: false,
786
+ rulesImports: false,
787
+ interactiveRepl: true,
788
+ },
789
+ },
790
+ // Factory AI Droid CLI (`droid`) — agentic coding CLI from factory.ai.
791
+ // Install: `curl -fsSL https://app.factory.ai/cli | sh` (no npm package).
792
+ // Binary is NOT in node_modules/.bin — the shim resolves the fixed install
793
+ // path ~/.local/bin/droid directly (see the droid branch in shims.ts).
794
+ // Config: `~/.factory/` (settings.json, mcp.json, droids/, commands/, hooks/,
795
+ // plugins/). Memory: native AGENTS.md. Subagents = custom droids (top-level
796
+ // .md files in ~/.factory/droids/). Config isolation rides the ~/.factory
797
+ // symlink switch (no FACTORY_HOME env var exists). Headless:
798
+ // `droid exec "<prompt>"` with --auto low|medium|high, -o stream-json,
799
+ // -m <model>, -r <effort>.
800
+ //
801
+ // Hooks: Claude-shaped. settings.json carries a top-level `hooks` object keyed
802
+ // by event (PreToolUse, PostToolUse, UserPromptSubmit, SessionStart,
803
+ // SessionEnd, Stop, SubagentStop, Notification, PreCompact), each an array of
804
+ // `{ matcher?, hooks: [{ type: "command", command, timeout? }] }` matcher
805
+ // groups — verified against the droid binary's zod schema. So the Claude
806
+ // registrar is reused verbatim, just targeting `.factory/settings.json`.
807
+ //
808
+ // Plugins: native `droid plugin` command group + ~/.factory/plugins/ with the
809
+ // same marketplace layout Claude uses (known_marketplaces.json +
810
+ // marketplaces/<name>/plugins/<plugin>/). Droid's plugin manifest dir is
811
+ // `.factory-plugin/` (it also reads `.claude-plugin/` for compatibility) — set
812
+ // pluginManifestDir so syncPluginToVersion mirrors the manifest into it, the
813
+ // same pattern codex uses with `.codex-plugin`.
814
+ //
815
+ // Workflows / Factory Missions (RUSH-1864, probed droid v0.177.0; ticket cited
816
+ // v0.161.0 — self-updating, no pinnable semver): Factory ships genuine multi-
817
+ // step orchestration ("Missions") via `/missions` (interactive) and
818
+ // `droid exec --mission` (headless; optional `-f <prompt.md>` is a *prompt*
819
+ // file, not a named mission template). Docs:
820
+ // https://docs.factory.ai/cli/features/missions and
821
+ // https://docs.factory.ai/docs/droid-exec/overview (Mission Mode).
822
+ //
823
+ // Mission state may land under `~/.factory/missions/<sessionId>/` at runtime
824
+ // (`getMissionsDir` = join(home, ".factory", "missions") + per-session
825
+ // mission.md / features.json / progress_log.jsonl) — that is **session
826
+ // runtime state**, not a discovery directory of installable named workflows.
827
+ // There is no discoverMission / template registry, and a cold install has no
828
+ // `~/.factory/missions/` until a mission is run. agents-cli's workflows
829
+ // capability means "sync WORKFLOW.md into an auto-discovered dir" — Droid
830
+ // has no such dir, so workflows stays false (invoke-only; do not fabricate a
831
+ // writer target).
832
+ droid: {
833
+ id: 'droid',
834
+ name: 'Droid',
835
+ sessionDir: ['.factory', 'sessions'],
836
+ sessionFileExt: '.jsonl',
837
+ versionStdoutMatch: 'semver',
838
+ unmanagedBinary: 'path',
839
+ mcpRegister: 'cli',
840
+ mcpAddHttp: 'transport',
841
+ mcpAddStdio: 'simple',
842
+ mcpConfigWrite: 'json-mcpServers',
843
+ color: 'yellowBright',
844
+ cliCommand: 'droid',
845
+ npmPackage: '',
846
+ installScript: 'curl -fsSL https://app.factory.ai/cli | sh',
847
+ configDir: path.join(HOME, '.factory'),
848
+ authFiles: ['auth.v2.file', 'auth.v2.key'],
849
+ commandsDir: path.join(HOME, '.factory', 'commands'),
850
+ commandsSubdir: 'commands',
851
+ skillsDir: path.join(HOME, '.factory', 'skills'),
852
+ hooksDir: 'hooks',
853
+ pluginManifestDir: '.factory-plugin',
854
+ instructionsFile: 'AGENTS.md',
855
+ format: 'markdown',
856
+ variableSyntax: '$ARGUMENTS',
857
+ supportsHooks: true,
858
+ // Factory Droid Computers (cloud VMs) reached via `droid computer ssh` +
859
+ // remote headless `droid exec`.
860
+ cloudProvider: 'factory',
861
+ capabilities: {
862
+ hooks: true,
863
+ mcp: true,
864
+ mcpHttp: false,
865
+ mcpHeaders: false,
866
+ allowlist: { since: '0.57.5' },
867
+ skills: { since: '0.26.0' },
868
+ commands: true,
869
+ plugins: true,
870
+ subagents: true,
871
+ rules: { file: 'AGENTS.md' },
872
+ // Factory Missions are invoke-only — no installable discovery dir (RUSH-1864).
873
+ workflows: false,
874
+ memory: false,
875
+ modes: ['plan', 'edit', 'auto', 'skip'],
876
+ rulesImports: false,
877
+ interactiveRepl: true,
878
+ },
879
+ },
880
+ // Nous Hermes Agent. Config lives under ~/.hermes/config.yaml; MCP servers
881
+ // are YAML `mcp_servers`, skills are local SKILL.md directories, and durable
882
+ // memory is file-backed.
883
+ hermes: {
884
+ id: 'hermes',
885
+ name: 'Hermes',
886
+ sessionDir: null,
887
+ sessionFileExt: null,
888
+ versionStdoutMatch: 'semver',
889
+ unmanagedBinary: 'path',
890
+ mcpRegister: 'config',
891
+ mcpAddHttp: 'transport',
892
+ mcpAddStdio: 'simple',
893
+ mcpConfigWrite: 'yaml-mcp_servers',
894
+ color: 'cyanBright',
895
+ cliCommand: 'hermes',
896
+ npmPackage: '',
897
+ installScript: 'curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash',
898
+ configDir: path.join(HOME, '.hermes'),
899
+ commandsDir: '',
900
+ commandsSubdir: '',
901
+ skillsDir: path.join(HOME, '.hermes', 'skills'),
902
+ hooksDir: 'hooks',
903
+ instructionsFile: 'MEMORY.md',
904
+ format: 'markdown',
905
+ variableSyntax: '$ARGUMENTS',
906
+ supportsHooks: true,
907
+ // Plugins: Hermes loads plugins from a flat `~/.hermes/plugins/<name>/` dir
908
+ // with a `plugin.yaml` manifest; a plugin only loads once its name is in the
909
+ // `plugins.enabled` allowlist in `~/.hermes/config.yaml` (deny-list
910
+ // `plugins.disabled` wins). Not the Claude marketplace layout, so it installs
911
+ // via a flat-copy branch (mirrors goose) plus a YAML allowlist toggle.
912
+ // See https://hermes-agent.nousresearch.com/docs/user-guide/features/plugins
913
+ capabilities: {
914
+ // Lifecycle hooks land in ~/.hermes/config.yaml under a `hooks:` block
915
+ // (YAML, shared with `mcp_servers`); gated to Hermes ≥ 0.11.0 which
916
+ // introduced the configurable hook runner.
917
+ hooks: { since: '0.11.0' },
918
+ mcp: true,
919
+ mcpHttp: true,
920
+ mcpHeaders: false,
921
+ // Permissions: ~/.hermes/config.yaml carries `command_allowlist` for
922
+ // always-approved command globs and `approvals.deny` for unconditional
923
+ // command blocks. It is command-glob only; session `/tools` toggles are
924
+ // intentionally not persisted by agents-cli.
925
+ allowlist: true,
926
+ skills: true,
927
+ commands: false,
928
+ plugins: true,
929
+ subagents: false,
930
+ rules: { file: 'MEMORY.md' },
931
+ workflows: false,
932
+ memory: true,
933
+ modes: ['edit'],
934
+ rulesImports: false,
935
+ interactiveRepl: true,
936
+ },
937
+ },
938
+ // Meta Muse Code (`muse`) — first-class harness for Meta's coding agent (terminal/CI), built on
939
+ // Muse Spark. Native binary via curl installer (self-updating; no npm package).
940
+ // Config: `~/.config/muse/settings.json` (requires `"schema_version": 1`).
941
+ // Sessions: `~/.local/share/muse/sessions/YYYY/MM/DD/<uuid>/session.jsonl`.
942
+ // Skills: `~/.config/muse/skills` + `~/.agents/skills` + project `.agents/skills`.
943
+ // MCP: `mcp_servers` in settings.json (stdio / streamable_http transports).
944
+ // Headless: `muse exec "<prompt>"` with --model, --reasoning-effort, --json,
945
+ // --yolo / --disable-approval / --disable-sandbox. Auth: META_API_KEY or
946
+ // browser OAuth stored at `~/.config/muse/auth.json`. Default model:
947
+ // muse-spark-1.2. Docs: https://dev.meta.ai/docs/muse-code
948
+ muse: {
949
+ id: 'muse',
950
+ name: 'Muse',
951
+ sessionDir: ['.local', 'share', 'muse', 'sessions'],
952
+ sessionFileExt: '.jsonl',
953
+ versionStdoutMatch: 'semver',
954
+ unmanagedBinary: 'path',
955
+ mcpRegister: 'cli',
956
+ mcpAddHttp: 'transport',
957
+ mcpAddStdio: 'simple',
958
+ mcpConfigWrite: 'json-mcpServers',
959
+ color: 'blueBright',
960
+ cliCommand: 'muse',
961
+ npmPackage: '',
962
+ installScript: 'curl -fsSL https://dev.meta.ai/install.sh | sh',
963
+ configDir: path.join(HOME, '.config', 'muse'),
964
+ authFiles: ['auth.json'],
965
+ commandsDir: '',
966
+ commandsSubdir: '',
967
+ skillsDir: path.join(HOME, '.config', 'muse', 'skills'),
968
+ hooksDir: 'hooks',
969
+ instructionsFile: 'AGENTS.md',
970
+ format: 'markdown',
971
+ variableSyntax: '$ARGUMENTS',
972
+ // Muse hooks use the Claude-shaped settings.json hooks block (matcher +
973
+ // command groups) under ~/.config/muse/settings.json, plus project
974
+ // `.muse/hooks.json`. registerHooksForClaude is reused with Muse's config
975
+ // dir and schema_version: 1.
976
+ supportsHooks: true,
977
+ // Claude-compatible marketplace layout + native `.muse-plugin/` manifest.
978
+ pluginManifestDir: '.muse-plugin',
979
+ capabilities: {
980
+ // Hooks: Claude-compatible event→matcher→command groups in settings.json.
981
+ hooks: true,
982
+ mcp: true,
983
+ mcpHttp: true,
984
+ mcpHeaders: true,
985
+ // Muse's safety model is approval-mode + OS sandbox (CLI flags), not a
986
+ // Claude-style tool-name allow/deny list. No settings.permissions writer.
987
+ allowlist: false,
988
+ skills: true,
989
+ // No slash-command file dir; skills double as reusable workflows.
990
+ commands: false,
991
+ // Plugins: Claude marketplace + .muse-plugin (syncPluginToVersion).
992
+ plugins: true,
993
+ // Runtime multi-agent / subagents exist, but there is no installable
994
+ // subagent-definition directory for agents-cli to sync into (capability
995
+ // table must stay truthful — see subagents-registry completeness).
996
+ subagents: false,
997
+ rules: { file: 'AGENTS.md' },
998
+ workflows: false,
999
+ // Portable memory at <repo>/.agents/memory/ + personal scopes.
1000
+ memory: true,
1001
+ modes: ['plan', 'edit', 'auto', 'skip'],
1002
+ rulesImports: false,
1003
+ interactiveRepl: true,
1004
+ },
1005
+ },
1006
+ // Warp Agent CLI (`warp`) — Warp's standalone interactive TUI coding agent
1007
+ // (docs.warp.dev/cli). Installed by the official cross-platform installer
1008
+ // `curl -fsSL https://app.warp.dev/download/agent-cli | bash` (Windows: the
1009
+ // sibling agent-cli.ps1), which drops a self-updating `warp` binary at
1010
+ // ~/.local/bin/warp — NOT the older `oz` platform runner (brew cask `oz` /
1011
+ // apt `oz-stable`), a separate headless product that no longer matches this
1012
+ // CLI. Config lives under `~/.warp/` (`.mcp.json`, `skills/`, `tui/`). It is
1013
+ // interactive-only: bare `warp` opens the TUI (streamed responses, diffs,
1014
+ // inline approvals). There is NO headless one-shot form — the documented
1015
+ // flags are --api-key / --auto-approve / --resume <token> /
1016
+ // --set-provider-api-key / --clear-provider-api-key / --version / --help; no
1017
+ // -p/--prompt, no --model (model is the `/model` picker), no JSON output.
1018
+ // Auth: interactive browser sign-in on launch, or the `WARP_API_KEY` env
1019
+ // token / `--api-key` for CI. Rules/context file is `AGENTS.md` (also reads
1020
+ // WARP.md / CLAUDE.md). Conversations sync SERVER-SIDE to the Warp/Oz
1021
+ // platform (resume a prior one with `warp --resume <token>`), so there is no
1022
+ // local transcript for `agents sessions` to index — warp is intentionally
1023
+ // absent from SESSION_AGENTS.
1024
+ // Docs: https://docs.warp.dev/cli/quickstart
1025
+ warp: {
1026
+ id: 'warp',
1027
+ name: 'Warp',
1028
+ sessionDir: null,
1029
+ sessionFileExt: null,
1030
+ versionStdoutMatch: 'semver',
1031
+ unmanagedBinary: 'path',
1032
+ mcpRegister: 'cli',
1033
+ mcpAddHttp: 'transport',
1034
+ mcpAddStdio: 'simple',
1035
+ mcpConfigWrite: 'json-mcpServers',
1036
+ color: 'blueBright',
1037
+ cliCommand: 'warp',
1038
+ npmPackage: '',
1039
+ installScript: 'curl -fsSL https://app.warp.dev/download/agent-cli | bash',
1040
+ configDir: path.join(HOME, '.warp'),
1041
+ commandsDir: '',
1042
+ commandsSubdir: '',
1043
+ skillsDir: path.join(HOME, '.warp', 'skills'),
1044
+ hooksDir: 'hooks',
1045
+ instructionsFile: 'AGENTS.md',
1046
+ format: 'markdown',
1047
+ variableSyntax: '$ARGUMENTS',
1048
+ // Warp has no event->shell-command hook registration surface (its startup
1049
+ // flags are --api-key/--auto-approve/--resume/--set-provider-api-key/
1050
+ // --clear-provider-api-key — no hooks).
1051
+ supportsHooks: false,
1052
+ capabilities: {
1053
+ hooks: false,
1054
+ // MCP: Warp reads the Claude `{ "mcpServers": {...} }` schema from
1055
+ // `.warp/.mcp.json` (user `~/.warp/.mcp.json`, project `<root>/.warp/.mcp.json`),
1056
+ // stdio + http with headers, same schema as Claude's .mcp.json.
1057
+ mcp: true,
1058
+ mcpHttp: true,
1059
+ mcpHeaders: true,
1060
+ // Autonomy is a single toggle (`--auto-approve`), not a Claude-style
1061
+ // tool-name allow/deny list agents-cli can write — so no allowlist writer
1062
+ // (mirrors muse).
1063
+ allowlist: false,
1064
+ // Skills searched in `.agents/skills/`, `.warp/skills/`, `.claude/skills/`,
1065
+ // `.codex/skills/`.
1066
+ skills: true,
1067
+ // Slash-commands are native/server-managed (no droppable markdown
1068
+ // command-file directory for agents-cli to sync into).
1069
+ commands: false,
1070
+ // No Claude marketplace / plugin manifest support.
1071
+ plugins: false,
1072
+ // Cloud agents are server-side; no installable subagent-definition
1073
+ // directory to sync into (keeps the table truthful).
1074
+ subagents: false,
1075
+ rules: { file: 'AGENTS.md' },
1076
+ workflows: false,
1077
+ memory: false,
1078
+ // No per-run permission flag beyond `--auto-approve`; the single `edit`
1079
+ // mode maps to no flags (mirrors hermes).
1080
+ modes: ['edit'],
1081
+ rulesImports: false,
1082
+ // Bare `warp` opens the interactive TUI (its only run form).
1083
+ interactiveRepl: true,
1084
+ },
1085
+ },
1086
+ };
1087
+ /** All current and legacy agent IDs derived from the AGENTS registry. */
1088
+ export const ALL_AGENT_IDS = Object.keys(AGENTS);
1089
+ /**
1090
+ * CLI command templates per agent for daemon-fired routine jobs, with
1091
+ * {prompt} as a placeholder. Lives here (not runner.ts) so routines.ts can
1092
+ * import ROUTINE_AGENT_IDS for schedule-time validation without a circular
1093
+ * import (runner.ts already imports from routines.ts).
1094
+ */
1095
+ export const ROUTINE_AGENT_COMMANDS = {
1096
+ claude: ['claude', '-p', '--verbose', '{prompt}', '--output-format', 'stream-json', '--permission-mode', 'plan'],
1097
+ codex: ['codex', 'exec', '{prompt}', '--json'],
1098
+ // gemini is hard-deprecated (Antigravity replaced it) — no routine target. A
1099
+ // legacy gemini routine now fails validateJob loud instead of firing a
1100
+ // retired backend; the id survives only for reading old sessions/config.
1101
+ cursor: ['cursor-agent', '-p', '{prompt}', '--output-format', 'stream-json'],
1102
+ kimi: ['kimi', '--prompt', '{prompt}', '--output-format', 'stream-json'],
1103
+ droid: ['droid', 'exec', '{prompt}', '-o', 'stream-json'],
1104
+ muse: ['muse', 'exec', '{prompt}', '--json'],
1105
+ };
1106
+ /** Agents the routine daemon can actually run when firing locally, derived
1107
+ * from the command table above so the `--agent` help and validateJob's
1108
+ * schedule-time check can never drift from it. */
1109
+ export const ROUTINE_AGENT_IDS = Object.freeze(Object.keys(ROUTINE_AGENT_COMMANDS));
1110
+ /** Agents retained only for legacy reads, not install/import/sync targets. */
1111
+ export const HARD_DEPRECATED_AGENT_IDS = ALL_AGENT_IDS.filter((id) => AGENTS[id].deprecated?.hard);
1112
+ /** Agents that can receive managed installs, imports, and resource sync writes. */
1113
+ export const MANAGED_AGENT_IDS = ALL_AGENT_IDS.filter((id) => !AGENTS[id].deprecated?.hard);
1114
+ /**
1115
+ * A self-updating agent is a single global binary installed by an official
1116
+ * `curl … | sh` / `brew install` script that carries NO version token — the
1117
+ * installer can only ever fetch the *current* release, and the binary then keeps
1118
+ * itself up to date in place (droid, grok, antigravity, cursor, hermes,
1119
+ * kiro, goose). There is no semver to pin, so agents-cli must not model these as
1120
+ * having multiple installable version-homes the way it does for npm-packaged
1121
+ * agents (claude, codex, kimi, …).
1122
+ *
1123
+ * The predicate is `!npmPackage && installScript && !installScript.includes('VERSION')`:
1124
+ * - `npmPackage` empty → not installed from npm, so `agents add x@1.2.3`
1125
+ * can't resolve a registry version.
1126
+ * - `installScript` present → it IS installed by a script (not unmanaged).
1127
+ * - no `VERSION` placeholder → the script has no slot for a pinned version
1128
+ * (contrast: an installer templated with `VERSION`
1129
+ * could pin, and is NOT self-updating).
1130
+ *
1131
+ * Route every "is this a pinnable, multi-version agent?" decision through here —
1132
+ * never a scattered `agent === 'droid'`.
1133
+ */
1134
+ export function isSelfUpdatingAgent(agent) {
1135
+ const cfg = AGENTS[agent];
1136
+ return !cfg.npmPackage && !!cfg.installScript && !cfg.installScript.includes('VERSION');
1137
+ }
1138
+ export function isAgentHardDeprecated(agent) {
1139
+ // Tolerate ids outside the registry (legacy YAML, test fixtures): an unknown
1140
+ // agent is not hard-deprecated — its own validation rejects it elsewhere.
1141
+ return AGENTS[agent]?.deprecated?.hard === true;
1142
+ }
1143
+ // Capability-filtered agent lists used to live here as `*_CAPABLE_AGENTS`
1144
+ // constants. They were a frequent source of silent-skip bugs (e.g. grok
1145
+ // rules sync gated on `COMMANDS_CAPABLE_AGENTS`). Use `capableAgents(cap)`
1146
+ // from `./capabilities.js` instead — it consults the AgentConfig matrix
1147
+ // directly, so a single source of truth drives every gate.
1148
+ /** Get the chalk color function for an agent. Works for any AgentId or SessionAgentId. */
1149
+ export function colorAgent(agentId) {
1150
+ const agent = AGENTS[agentId];
1151
+ if (!agent)
1152
+ return chalk.white;
1153
+ return chalk[agent.color];
1154
+ }
1155
+ /** Return the agent's display name, colored. */
1156
+ export function agentLabel(agentId) {
1157
+ const agent = AGENTS[agentId];
1158
+ if (!agent)
1159
+ return agentId;
1160
+ return chalk[agent.color](agent.name);
1161
+ }
1162
+ /** Check whether the given agent's CLI binary is present on PATH. */
1163
+ export async function isCliInstalled(agentId) {
1164
+ const agent = AGENTS[agentId];
1165
+ return findInPath(agent.cliCommand) !== null;
1166
+ }
1167
+ /** Return the installed CLI version for the given agent, or null if not found. */
1168
+ export async function getCliVersion(agentId) {
1169
+ const agent = AGENTS[agentId];
1170
+ const binaryPath = findInPath(agent.cliCommand);
1171
+ if (!binaryPath)
1172
+ return null;
1173
+ return getCachedVersionForBinary(agentId, binaryPath);
1174
+ }
1175
+ /** Return the absolute path to the agent's CLI binary on PATH, or null. */
1176
+ export async function getCliPath(agentId) {
1177
+ return findInPath(AGENTS[agentId].cliCommand);
1178
+ }
1179
+ /** Look up version from cache by (binary, mtime). On miss or stale, spawn `--version` and cache. */
1180
+ async function getCachedVersionForBinary(agentId, binaryPath) {
1181
+ let mtime = 0;
1182
+ try {
1183
+ mtime = fs.statSync(binaryPath).mtimeMs;
1184
+ }
1185
+ catch {
1186
+ /* binary vanished between findInPath and statSync */
1187
+ return null;
1188
+ }
1189
+ const cache = loadCliVersionCache();
1190
+ const cached = cache[agentId];
1191
+ if (cached && cached.binaryPath === binaryPath && cached.mtime === mtime) {
1192
+ return cached.version;
1193
+ }
1194
+ const agent = AGENTS[agentId];
1195
+ let version = null;
1196
+ try {
1197
+ const { stdout } = await execFileAsync(agent.cliCommand, ['--version'], { timeout: 3000 });
1198
+ const versionRe = agent.versionStdoutMatch === 'openclaw'
1199
+ ? /openclaw\/(\d+\.\d+\.\d+)/
1200
+ : /(\d+\.\d+\.\d+)/;
1201
+ const match = stdout.match(versionRe);
1202
+ version = match ? match[1] : stdout.trim();
1203
+ }
1204
+ catch {
1205
+ /* version command failed */
1206
+ version = null;
1207
+ }
1208
+ // Skip persisting null results — the most common cause is a transient
1209
+ // `--version` failure (slow startup, stdout race, etc.). A sticky-null
1210
+ // entry kept users in a broken state where every subsequent
1211
+ // `getCachedVersionForBinary` short-circuited to null forever, even
1212
+ // after the binary started working. Re-probing on the next call costs
1213
+ // one execFile; persisting null costs the whole feature.
1214
+ if (version !== null) {
1215
+ cache[agentId] = { binaryPath, mtime, version };
1216
+ saveCliVersionCache();
1217
+ }
1218
+ return version;
1219
+ }
1220
+ /**
1221
+ * Resolve the full CLI state for an agent: whether it is installed, its version,
1222
+ * and the path to the binary. Checks version-managed installs first, then falls
1223
+ * back to a plain PATH lookup.
1224
+ */
1225
+ export async function getCliState(agentId) {
1226
+ // Fast path: if version-managed, derive state from filesystem (no subprocesses)
1227
+ const agent = AGENTS[agentId];
1228
+ const agentVersionsDir = path.join(getVersionsDir(), agentId);
1229
+ if (fs.existsSync(agentVersionsDir)) {
1230
+ // Use resolved version (project manifest -> global default)
1231
+ const resolvedVer = resolveVersion(agentId, process.cwd());
1232
+ if (resolvedVer) {
1233
+ const binaryPath = path.join(agentVersionsDir, resolvedVer, 'node_modules', '.bin', agent.cliCommand);
1234
+ if (fs.existsSync(binaryPath)) {
1235
+ const shimPath = path.join(getShimsDir(), agent.cliCommand);
1236
+ return {
1237
+ installed: true,
1238
+ version: resolvedVer,
1239
+ path: fs.existsSync(shimPath) ? shimPath : binaryPath,
1240
+ };
1241
+ }
1242
+ }
1243
+ // Fallback: if no default set or resolved version not installed, return first available
1244
+ const entries = fs.readdirSync(agentVersionsDir, { withFileTypes: true });
1245
+ for (const entry of entries) {
1246
+ if (entry.isDirectory()) {
1247
+ const binaryPath = path.join(agentVersionsDir, entry.name, 'node_modules', '.bin', agent.cliCommand);
1248
+ if (fs.existsSync(binaryPath)) {
1249
+ const shimPath = path.join(getShimsDir(), agent.cliCommand);
1250
+ return {
1251
+ installed: true,
1252
+ version: entry.name,
1253
+ path: fs.existsSync(shimPath) ? shimPath : binaryPath,
1254
+ };
1255
+ }
1256
+ }
1257
+ }
1258
+ }
1259
+ return getUnmanagedCliState(agentId);
1260
+ }
1261
+ /**
1262
+ * Resolve the agent's OWN install — the one agents-cli does not manage — by plain
1263
+ * PATH lookup, ignoring the version dirs entirely.
1264
+ *
1265
+ * Callers that specifically mean "the user's own globally-installed CLI" must use
1266
+ * this rather than `getCliState`, whose managed fast path reports an installed
1267
+ * version (any version dir, in readdir order) and would therefore hand back an
1268
+ * isolated copy — a copy that is deliberately unreachable from PATH — labelled as
1269
+ * the global install.
1270
+ */
1271
+ export async function getUnmanagedCliState(agentId) {
1272
+ const agent = AGENTS[agentId];
1273
+ // Grok (and any future harness with unmanagedBinary: 'grok-downloads')
1274
+ // keeps versioned binaries under ~/.grok/downloads/, not on PATH.
1275
+ if (agent.unmanagedBinary === 'grok-downloads') {
1276
+ const grokBin = resolveGrokBinary();
1277
+ if (!grokBin) {
1278
+ return { installed: false, version: null, path: null };
1279
+ }
1280
+ return {
1281
+ installed: true,
1282
+ version: await getCachedVersionForBinary(agentId, grokBin),
1283
+ path: grokBin,
1284
+ };
1285
+ }
1286
+ const binaryPath = findInPath(agent.cliCommand);
1287
+ if (!binaryPath) {
1288
+ return { installed: false, version: null, path: null };
1289
+ }
1290
+ return {
1291
+ installed: true,
1292
+ version: await getCachedVersionForBinary(agentId, binaryPath),
1293
+ path: binaryPath,
1294
+ };
1295
+ }
1296
+ /** Resolve CLI state for all registered agents in parallel. */
1297
+ export async function getAllCliStates() {
1298
+ const states = {};
1299
+ const results = await Promise.all(ALL_AGENT_IDS.map(async (agentId) => ({
1300
+ agentId,
1301
+ state: await getCliState(agentId),
1302
+ })));
1303
+ for (const { agentId, state } of results) {
1304
+ states[agentId] = state;
1305
+ }
1306
+ return states;
1307
+ }
1308
+ /**
1309
+ * Agents that `agents setup` probes for pre-existing native installations
1310
+ * (i.e., a config dir present before agents-cli took over). Derived from
1311
+ * `sessionDir` so a new harness cannot be walkable yet missing from setup.
1312
+ */
1313
+ export const UNMANAGED_DETECTION_CANDIDATES = ALL_AGENT_IDS.filter((id) => AGENTS[id].sessionDir !== null);
1314
+ /**
1315
+ * Detect existing agent installations that are NOT yet managed by agents-cli.
1316
+ * Returns agents whose config dir exists as a real directory (not a symlink).
1317
+ */
1318
+ export async function getUnmanagedAgentInstalls() {
1319
+ const unmanaged = [];
1320
+ for (const agentId of UNMANAGED_DETECTION_CANDIDATES) {
1321
+ const agent = AGENTS[agentId];
1322
+ try {
1323
+ const stat = fs.lstatSync(agent.configDir);
1324
+ if (stat.isDirectory() && !stat.isSymbolicLink()) {
1325
+ const version = await getCliVersion(agentId);
1326
+ unmanaged.push({ agentId, configDir: agent.configDir, version });
1327
+ }
1328
+ }
1329
+ catch {
1330
+ // Config dir doesn't exist
1331
+ }
1332
+ }
1333
+ return unmanaged;
1334
+ }
1335
+ /** Create the agent's slash-commands directory if it does not exist. */
1336
+ export function ensureCommandsDir(agentId) {
1337
+ const agent = AGENTS[agentId];
1338
+ if (!fs.existsSync(agent.commandsDir)) {
1339
+ fs.mkdirSync(agent.commandsDir, { recursive: true });
1340
+ }
1341
+ }
1342
+ /** Create the agent's skills directory if it does not exist. */
1343
+ export function ensureSkillsDir(agentId) {
1344
+ const agent = AGENTS[agentId];
1345
+ if (!fs.existsSync(agent.skillsDir)) {
1346
+ fs.mkdirSync(agent.skillsDir, { recursive: true });
1347
+ }
1348
+ }
1349
+ /**
1350
+ * The agent's config-dir name relative to $HOME — e.g. '.claude',
1351
+ * '.gemini/antigravity-cli', '.config/amp', '.kimi-code'.
1352
+ *
1353
+ * Path segment to join onto a (version) home root when locating an agent's
1354
+ * commands/skills/plugins. Do NOT hardcode `.${agentId}`: it is wrong for
1355
+ * every agent whose config dir is nested or under ~/.config — antigravity
1356
+ * (~/.gemini/antigravity-cli), amp (~/.config/amp), goose (~/.config/goose),
1357
+ * kimi (~/.kimi-code). Mirrors the shim configDirName derivation in shims.ts.
1358
+ *
1359
+ * Relativized against the module-level HOME constant (the same value used to
1360
+ * build every `configDir`), NOT a fresh `os.homedir()` — so the result stays a
1361
+ * clean relative name even when HOME is overridden after module load (tests,
1362
+ * sandboxes). Using `os.homedir()` here would yield `../../real/home/.claude`.
1363
+ */
1364
+ export function agentConfigDirName(agentId) {
1365
+ return path.relative(HOME, AGENTS[agentId].configDir);
1366
+ }
1367
+ /**
1368
+ * Human-readable label for a Claude account's organizationType as read from
1369
+ * .claude.json's oauthAccount ("claude_team" -> "Team"). Unrecognized values
1370
+ * (future tiers) are rendered by stripping the "claude_" prefix and
1371
+ * title-casing the rest — an unfamiliar-but-visible label beats silence.
1372
+ * Returns null for missing input.
1373
+ */
1374
+ export function formatClaudeOrgLabel(orgType) {
1375
+ if (!orgType)
1376
+ return null;
1377
+ const known = {
1378
+ claude_max: 'Max',
1379
+ claude_pro: 'Pro',
1380
+ claude_team: 'Team',
1381
+ claude_enterprise: 'Enterprise',
1382
+ claude_free: 'Free',
1383
+ };
1384
+ if (known[orgType])
1385
+ return known[orgType];
1386
+ return orgType
1387
+ .replace(/^claude_/, '')
1388
+ .split('_')
1389
+ .filter(Boolean)
1390
+ .map((w) => w[0].toUpperCase() + w.slice(1))
1391
+ .join(' ');
1392
+ }
1393
+ /**
1394
+ * Short badge identifying which ORG an account belongs to — "Turing Labs" for a
1395
+ * multi-seat Team/Enterprise org, whose name is real identity that disambiguates
1396
+ * a Team seat from a same-email personal plan. Returns null for personal plans
1397
+ * (Max/Pro/Free): the tier label now lives in the aligned plan column, so a badge
1398
+ * would only duplicate it, and a personal org's name is auto-generated boilerplate
1399
+ * ("<email>'s Organization"), not identity. Also null when the account carries no
1400
+ * organizationType (signed out, non-Claude agents, configs predating the field).
1401
+ */
1402
+ export function accountOrgBadge(info) {
1403
+ const isMultiSeat = info?.organizationType === 'claude_team' || info?.organizationType === 'claude_enterprise';
1404
+ if (isMultiSeat && info?.organizationName)
1405
+ return info.organizationName;
1406
+ return null;
1407
+ }
1408
+ /** Agents whose local credential formats expose enough state for account selection. */
1409
+ export const ACCOUNT_INSPECTION_AGENT_IDS = [
1410
+ 'claude',
1411
+ 'codex',
1412
+ 'gemini',
1413
+ 'cursor',
1414
+ 'grok',
1415
+ 'antigravity',
1416
+ 'kimi',
1417
+ 'droid',
1418
+ 'opencode',
1419
+ 'muse',
1420
+ ];
1421
+ const ACCOUNT_INSPECTION_AGENTS = new Set(ACCOUNT_INSPECTION_AGENT_IDS);
1422
+ /** Whether agents-cli can determine this agent's per-version sign-in state. */
1423
+ export function supportsAccountInspection(agentId) {
1424
+ return ACCOUNT_INSPECTION_AGENTS.has(agentId);
1425
+ }
1426
+ /**
1427
+ * Human-readable account identity shared by every account-aware surface.
1428
+ * Prefer email, append a multi-seat Claude organization name when present,
1429
+ * then fall back to a non-secret account id or a generic signed-in label.
1430
+ */
1431
+ export function accountDisplayLabel(info) {
1432
+ if (!info)
1433
+ return '';
1434
+ if (info.email) {
1435
+ const badge = accountOrgBadge(info);
1436
+ return badge ? `${info.email} (${badge})` : info.email;
1437
+ }
1438
+ if (info.signedIn)
1439
+ return info.accountId ? `id:${info.accountId}` : 'signed in';
1440
+ return '';
1441
+ }
1442
+ /** Return the email address associated with the agent's auth config, or null. */
1443
+ export async function getAccountEmail(agentId, home) {
1444
+ const info = await getAccountInfo(agentId, home);
1445
+ return info.email;
1446
+ }
1447
+ /**
1448
+ * Extract full account information (identity, plan, usage status, credits) from
1449
+ * the agent's local auth/config files. Supports Claude, Codex, and Gemini.
1450
+ */
1451
+ /**
1452
+ * Resolve a file-auth agent's credential file. Sign-in is account-global, but
1453
+ * each installed version gets an isolated home; the credential physically lives
1454
+ * only in the home the user logged in under (the one the `~/.<config>` symlink
1455
+ * targets). Check the per-version `base` first, then fall back to the active
1456
+ * config location under the real HOME so every installed version reflects the
1457
+ * true account state (droid/antigravity/kimi all stored login per-version-home
1458
+ * and showed non-active versions as "not signed in"). Returns the first
1459
+ * existing path, or null.
1460
+ */
1461
+ function resolveAccountCredentialPath(base, ...segments) {
1462
+ const perVersion = path.join(base, ...segments);
1463
+ try {
1464
+ if (fs.existsSync(perVersion))
1465
+ return perVersion;
1466
+ }
1467
+ catch { /* unreadable */ }
1468
+ const active = path.join(process.env.AGENTS_REAL_HOME || os.homedir(), ...segments);
1469
+ if (active !== perVersion) {
1470
+ try {
1471
+ if (fs.existsSync(active))
1472
+ return active;
1473
+ }
1474
+ catch { /* unreadable */ }
1475
+ }
1476
+ return null;
1477
+ }
1478
+ /**
1479
+ * The on-disk credential file(s) each account-inspectable agent authenticates
1480
+ * from, expressed as path segments under a home. Mirrors the exact files
1481
+ * {@link getAccountInfo} reads, so a presence check here matches what a real
1482
+ * launch would find. Each entry is a list of alternatives — the FIRST that
1483
+ * exists counts as present (claude writes either `.claude/.claude.json` under
1484
+ * the shimmed config dir or a home-level `.claude.json`). Agents whose login is
1485
+ * stored only in the OS keychain on some platforms (antigravity, and claude's
1486
+ * token) still expose a credential FILE — the presence of that file is the
1487
+ * signal we key off; its absence on BOTH the per-version home and the active
1488
+ * home is what makes a logged-out claim provable.
1489
+ */
1490
+ const CREDENTIAL_FILE_SEGMENTS = {
1491
+ claude: [['.claude', '.claude.json'], ['.claude.json']],
1492
+ codex: [['.codex', 'auth.json']],
1493
+ gemini: [['.gemini', 'google_accounts.json']],
1494
+ grok: [['.grok', 'auth.json']],
1495
+ kimi: [['.kimi-code', 'credentials', 'kimi-code.json']],
1496
+ droid: [['.factory', 'auth.v2.file']],
1497
+ antigravity: [['.gemini', 'antigravity-cli', 'antigravity-oauth-token']],
1498
+ opencode: [['.local', 'share', 'opencode', 'auth.json']],
1499
+ // Muse Code stores OAuth / API credentials at ~/.config/muse/auth.json
1500
+ // (or META_API_KEY in the environment, which is not a file).
1501
+ muse: [['.config', 'muse', 'auth.json']],
1502
+ // Cursor's OAuth token — the login gate — is at $XDG_CONFIG_HOME/cursor/auth.json
1503
+ // (~/.config/cursor/auth.json by default). cli-config.json holds only account
1504
+ // metadata; the token file is what a real launch authenticates from, so a
1505
+ // version home with no token of its own is genuinely logged out once runs pin
1506
+ // XDG_CONFIG_HOME per home (see buildExecEnv).
1507
+ cursor: [['.config', 'cursor', 'auth.json']],
1508
+ };
1509
+ /** Whether an agent's credential file exists under a given home. */
1510
+ function credentialFileExistsUnder(agentId, home) {
1511
+ const alternatives = CREDENTIAL_FILE_SEGMENTS[agentId];
1512
+ if (!alternatives)
1513
+ return false;
1514
+ for (const segments of alternatives) {
1515
+ const p = path.join(home, ...segments);
1516
+ try {
1517
+ if (fs.existsSync(p))
1518
+ return true;
1519
+ }
1520
+ catch { /* unreadable */ }
1521
+ }
1522
+ return false;
1523
+ }
1524
+ /**
1525
+ * File-presence probe for an agent's credential, split by location: whether it
1526
+ * exists in a SPECIFIC version home (`perVersion`) and whether it exists under
1527
+ * the active/global HOME (`active`). A logged-out claim is only *provable* when
1528
+ * BOTH are absent — a version that merely lacks its own copy but shares the
1529
+ * global login is signed in, not logged out. Pure file existence; no decrypt,
1530
+ * no network, no keychain prompt. Agents with no inspectable identity return
1531
+ * `{ perVersion: false, active: false }` and must NEVER yield a provable-logout
1532
+ * claim (the caller gates on {@link supportsAccountInspection}).
1533
+ */
1534
+ export function credentialPresence(agentId, versionHome) {
1535
+ const realHome = process.env.AGENTS_REAL_HOME || os.homedir();
1536
+ const perVersion = credentialFileExistsUnder(agentId, versionHome);
1537
+ const active = credentialFileExistsUnder(agentId, realHome);
1538
+ const knownLocation = (CREDENTIAL_FILE_SEGMENTS[agentId]?.length ?? 0) > 0;
1539
+ return { perVersion, active, knownLocation };
1540
+ }
1541
+ /**
1542
+ * Factory Droid stores its OAuth credential encrypted at ~/.factory/auth.v2.file
1543
+ * (AES-256-GCM, format `ivB64:tagB64:ctB64`) with the 32-byte key base64-stored
1544
+ * in ~/.factory/auth.v2.key. On the keyfile-v2 source there is no OS-keychain /
1545
+ * device binding — the key is on disk — so we can decrypt locally with no
1546
+ * network call. Every failure (missing key file — e.g. a keyring-v2/legacy
1547
+ * login with no on-disk key, a bad GCM tag, or malformed JSON) returns null.
1548
+ * Never throws. Shared by account identity below and the Droid usage fetcher
1549
+ * in usage.ts.
1550
+ */
1551
+ export function decryptDroidAuthPayload(base) {
1552
+ const filePath = resolveAccountCredentialPath(base, '.factory', 'auth.v2.file');
1553
+ const keyPath = resolveAccountCredentialPath(base, '.factory', 'auth.v2.key');
1554
+ if (!filePath || !keyPath)
1555
+ return null;
1556
+ return decryptDroidAuthFile(filePath, keyPath);
1557
+ }
1558
+ /**
1559
+ * Decrypt a Droid `auth.v2.file` (AES-256-GCM `ivB64:tagB64:ctB64`) using the
1560
+ * raw 32-byte key stored base64 in `auth.v2.key`, given the EXACT paths to both.
1561
+ * Same crypto as decryptDroidAuthPayload but without the account-global HOME
1562
+ * fallback, so the identity of a SPECIFIC version home resolves against only
1563
+ * that home's files (carryForwardAuthFiles needs per-dir identity). Returns null
1564
+ * on any failure (missing file/key, wrong key length, bad GCM tag, malformed
1565
+ * JSON). Never throws.
1566
+ */
1567
+ export function decryptDroidAuthFile(filePath, keyPath) {
1568
+ try {
1569
+ const blob = fs.readFileSync(filePath, 'utf-8').trim();
1570
+ const key = Buffer.from(fs.readFileSync(keyPath, 'utf-8').trim(), 'base64');
1571
+ if (key.length !== 32)
1572
+ return null;
1573
+ const [ivB64, tagB64, ctB64] = blob.split(':');
1574
+ if (!ivB64 || !tagB64 || !ctB64)
1575
+ return null;
1576
+ const decipher = crypto.createDecipheriv('aes-256-gcm', key, Buffer.from(ivB64, 'base64'));
1577
+ decipher.setAuthTag(Buffer.from(tagB64, 'base64'));
1578
+ const plaintext = Buffer.concat([
1579
+ decipher.update(Buffer.from(ctB64, 'base64')),
1580
+ decipher.final(),
1581
+ ]).toString('utf-8');
1582
+ const cred = JSON.parse(plaintext);
1583
+ return cred && typeof cred === 'object' ? cred : null;
1584
+ }
1585
+ catch {
1586
+ return null;
1587
+ }
1588
+ }
1589
+ /**
1590
+ * Stable account identity for a *file-auth* agent's credential directory
1591
+ * (droid / kimi / antigravity), or null when the directory holds no decodable
1592
+ * account claim. Unlike a naive top-level JSON key-scan (which matched NO real
1593
+ * credential file), this decrypts / decodes each agent's REAL on-disk format so
1594
+ * the identity resolves against production credentials:
1595
+ * - droid: AES-256-GCM auth.v2.file (+ auth.v2.key) -> WorkOS access-token JWT
1596
+ * -> email / org_id / sub.
1597
+ * - kimi: credentials/kimi-code.json -> access-token JWT -> user_id / sub.
1598
+ * - antigravity: antigravity-oauth-token -> token.refresh_token -> JWT sub
1599
+ * when the token is a JWT, else a SHA-256 hash of the raw refresh-token
1600
+ * value (opaque Google consumer tokens are stable per login — hashed so
1601
+ * the identity key, which is persisted as a usage-cache key, never carries
1602
+ * a live credential).
1603
+ * Two directories for the SAME account compare equal; two DIFFERENT accounts
1604
+ * compare distinct. Used by carryForwardAuthFiles to refuse overwriting one
1605
+ * account's login with a credential that belongs to a DIFFERENT account
1606
+ * (RUSH-1764). Never throws.
1607
+ */
1608
+ export function readAuthAccountIdentity(agent, configDir) {
1609
+ try {
1610
+ switch (agent) {
1611
+ case 'droid': {
1612
+ const payload = decryptDroidAuthFile(path.join(configDir, 'auth.v2.file'), path.join(configDir, 'auth.v2.key'));
1613
+ const claims = typeof payload?.access_token === 'string' ? decodeJwtPayload(payload.access_token) : null;
1614
+ if (!claims)
1615
+ return null;
1616
+ return buildIdentityKey(agent, [
1617
+ ['email', normalizeIdentityPart(claims.email)],
1618
+ ['org', normalizeIdentityPart(claims.org_id ?? payload?.active_organization_id)],
1619
+ ['sub', normalizeIdentityPart(claims.sub)],
1620
+ ]);
1621
+ }
1622
+ case 'kimi': {
1623
+ const data = JSON.parse(fs.readFileSync(path.join(configDir, 'credentials', 'kimi-code.json'), 'utf-8'));
1624
+ const accessToken = data?.access_token;
1625
+ const claims = typeof accessToken === 'string' ? decodeJwtPayload(accessToken) : null;
1626
+ return buildIdentityKey(agent, [
1627
+ ['user', normalizeIdentityPart(claims?.user_id ?? claims?.sub)],
1628
+ ]);
1629
+ }
1630
+ case 'antigravity': {
1631
+ const data = JSON.parse(fs.readFileSync(path.join(configDir, 'antigravity-oauth-token'), 'utf-8'));
1632
+ const refreshToken = data?.token?.refresh_token;
1633
+ if (typeof refreshToken !== 'string' || !refreshToken)
1634
+ return null;
1635
+ const claims = decodeJwtPayload(refreshToken);
1636
+ const sub = normalizeIdentityPart(claims?.sub ?? claims?.user_id);
1637
+ // An opaque (non-JWT) Google refresh token IS the credential — hash it
1638
+ // so the identity key stays stable per login without embedding a live
1639
+ // secret (the key is persisted as a usage-cache filename key).
1640
+ const fallback = crypto.createHash('sha256').update(refreshToken).digest('hex').slice(0, 16);
1641
+ return buildIdentityKey(agent, [['sub', sub ?? fallback]]);
1642
+ }
1643
+ default:
1644
+ return null;
1645
+ }
1646
+ }
1647
+ catch {
1648
+ return null;
1649
+ }
1650
+ }
1651
+ /**
1652
+ * Derive Droid account identity from the decrypted credential. The
1653
+ * `access_token` is a WorkOS JWT carrying an `email` claim (plus org_id /
1654
+ * role). We decode the claim WITHOUT verifying `exp`: the email is stable
1655
+ * identity for display, not an authorization decision, so an expired token
1656
+ * still yields the right address. Returns null when the credential can't be
1657
+ * decrypted or has no decodable claims, so the caller falls back to the
1658
+ * file-presence signed-in signal.
1659
+ */
1660
+ function decryptDroidCredential(base) {
1661
+ const cred = decryptDroidAuthPayload(base);
1662
+ const claims = typeof cred?.access_token === 'string' ? decodeJwtPayload(cred.access_token) : null;
1663
+ if (!claims)
1664
+ return null;
1665
+ return {
1666
+ email: typeof claims.email === 'string' ? claims.email : null,
1667
+ orgId: normalizeIdentityPart(claims.org_id ?? cred?.active_organization_id),
1668
+ role: typeof claims.role === 'string' ? claims.role : null,
1669
+ };
1670
+ }
1671
+ let cachedAgyKeychainSignedIn;
1672
+ /**
1673
+ * Antigravity (`agy`) stores its OAuth token via the Go keyring library
1674
+ * (zalando/go-keyring), which is platform-split:
1675
+ *
1676
+ * - macOS: login keychain, service `gemini`, account `antigravity` — no file.
1677
+ * - Linux with Secret Service (libsecret / gnome-keyring): attributes
1678
+ * service=`gemini`, username=`antigravity` (go-keyring's Secret Service
1679
+ * mapping of service+user). Prefer this over the file when a keyring
1680
+ * daemon is running.
1681
+ * - Linux without Secret Service: file fallback at
1682
+ * `~/.gemini/antigravity-cli/antigravity-oauth-token`.
1683
+ *
1684
+ * Probe the OS keyring for existence after the file check. On macOS,
1685
+ * `security find-generic-password` without `-w` is metadata-only (never
1686
+ * prompts). On Linux, `secret-tool lookup` exit 0 means the item exists
1687
+ * (stdout is the secret — discarded, never logged). Cached per process —
1688
+ * the keyring is account-global, so one probe covers every installed version.
1689
+ * Returns false when the platform has no probe (Windows) or the tool is
1690
+ * missing. Guard with `AGENTS_NO_KEYCHAIN_PROBE=1` for hermetic tests.
1691
+ */
1692
+ export function antigravityOsKeyringProbe(platform = process.platform) {
1693
+ if (platform === 'darwin') {
1694
+ return {
1695
+ cmd: 'security',
1696
+ args: ['find-generic-password', '-s', 'gemini', '-a', 'antigravity'],
1697
+ };
1698
+ }
1699
+ if (platform === 'linux') {
1700
+ // go-keyring secret_service attributes: "service" + "username" (not
1701
+ // "account" — that flag is the macOS security(1) spelling of the same user).
1702
+ return {
1703
+ cmd: 'secret-tool',
1704
+ args: ['lookup', 'service', 'gemini', 'username', 'antigravity'],
1705
+ };
1706
+ }
1707
+ return null;
1708
+ }
1709
+ /** @internal test hook — clear the per-process keyring probe cache. */
1710
+ export function __resetAntigravityKeychainCacheForTest() {
1711
+ cachedAgyKeychainSignedIn = undefined;
1712
+ }
1713
+ async function antigravityKeychainSignedIn() {
1714
+ // Test isolation first (before cache): real OS keyrings can't be sandboxed
1715
+ // per-test. Same spirit as AGENTS_REAL_HOME. Not cached, so tests can toggle.
1716
+ if (process.env.AGENTS_NO_KEYCHAIN_PROBE === '1')
1717
+ return false;
1718
+ if (cachedAgyKeychainSignedIn !== undefined)
1719
+ return cachedAgyKeychainSignedIn;
1720
+ const probe = antigravityOsKeyringProbe();
1721
+ if (!probe) {
1722
+ cachedAgyKeychainSignedIn = false;
1723
+ return false;
1724
+ }
1725
+ try {
1726
+ // Discard stdout: Linux secret-tool lookup prints the secret value.
1727
+ await execFileAsync(probe.cmd, probe.args, {
1728
+ timeout: 3000,
1729
+ // encoding so stdout is a string we can drop without ever logging it
1730
+ encoding: 'utf8',
1731
+ });
1732
+ cachedAgyKeychainSignedIn = true;
1733
+ }
1734
+ catch {
1735
+ // Missing tool (ENOENT), missing item, locked collection, timeout → signed out.
1736
+ cachedAgyKeychainSignedIn = false;
1737
+ }
1738
+ return cachedAgyKeychainSignedIn;
1739
+ }
1740
+ /**
1741
+ * OpenCode (sst/opencode) stores provider credentials in a single JSON file at
1742
+ * `$XDG_DATA_HOME/opencode/auth.json`, defaulting to
1743
+ * `~/.local/share/opencode/auth.json` on EVERY platform — its `xdg-basedir`
1744
+ * dependency does not special-case macOS, so there is no
1745
+ * `~/Library/Application Support` variant. The path is account-global (not
1746
+ * per-version), matching how `session/discover.ts` already resolves
1747
+ * `~/.local/share/opencode/opencode.db`.
1748
+ *
1749
+ * Resolution order, first existing wins:
1750
+ * 1. `<base>/.local/share/opencode/auth.json` — the passed per-version home.
1751
+ * This is primarily a test hook (suites write a hermetic auth file under a
1752
+ * temp home) but also covers any relocated install.
1753
+ * 2. `$XDG_DATA_HOME/opencode/auth.json` — an explicit XDG override, exactly
1754
+ * what OpenCode itself honours.
1755
+ * 3. `<realHome>/.local/share/opencode/auth.json` — the active default, under
1756
+ * `AGENTS_REAL_HOME` or `os.homedir()`, so every installed version reflects
1757
+ * the one account-global login (same fallback shape as
1758
+ * resolveAccountCredentialPath).
1759
+ * Returns the first existing path, or null. Never throws.
1760
+ */
1761
+ function resolveOpenCodeAuthPath(base) {
1762
+ const candidates = [path.join(base, '.local', 'share', 'opencode', 'auth.json')];
1763
+ const xdgData = process.env.XDG_DATA_HOME;
1764
+ if (xdgData)
1765
+ candidates.push(path.join(xdgData, 'opencode', 'auth.json'));
1766
+ const realHome = process.env.AGENTS_REAL_HOME || os.homedir();
1767
+ candidates.push(path.join(realHome, '.local', 'share', 'opencode', 'auth.json'));
1768
+ for (const candidate of candidates) {
1769
+ try {
1770
+ if (fs.existsSync(candidate))
1771
+ return candidate;
1772
+ }
1773
+ catch { /* unreadable */ }
1774
+ }
1775
+ return null;
1776
+ }
1777
+ /**
1778
+ * Validate one OpenCode auth.json entry against its discriminated union
1779
+ * (`type: 'oauth' | 'api' | 'wellknown'`) and confirm the credential actually
1780
+ * carries its required secret field(s) non-empty. This guards against a
1781
+ * corrupt/half-written entry reading as signed-in — the same "must have a real
1782
+ * credential" floor grok/antigravity apply. We only INSPECT the shape here; the
1783
+ * secret values (`access`/`refresh`/`key`/`token`) are never read out or
1784
+ * surfaced anywhere.
1785
+ */
1786
+ function isValidOpenCodeCredential(value) {
1787
+ if (!value || typeof value !== 'object')
1788
+ return false;
1789
+ const cred = value;
1790
+ const nonEmpty = (v) => typeof v === 'string' && v.length > 0;
1791
+ switch (cred.type) {
1792
+ case 'oauth': return nonEmpty(cred.access) || nonEmpty(cred.refresh);
1793
+ case 'api': return nonEmpty(cred.key);
1794
+ case 'wellknown': return nonEmpty(cred.key) && nonEmpty(cred.token);
1795
+ default: return false;
1796
+ }
1797
+ }
1798
+ /**
1799
+ * OpenCode's account identity: the sorted, "+"-joined list of provider ids
1800
+ * that hold a valid credential in `auth.json` (e.g. `"anthropic+muse-spark"`).
1801
+ * `auth.json` carries no email/identity claim (see `isValidOpenCodeCredential`),
1802
+ * so this join is the closest thing to "which account is this" available — the
1803
+ * same value `agents view`/`agents doctor` show for OpenCode's signed-in state.
1804
+ *
1805
+ * This is the ONLY correct source for an OpenCode "account". OpenCode's SQLite
1806
+ * `opencode.db` also carries `account`/`account_state`/`control_account` tables,
1807
+ * but on a real, actively-used install (yosemite-s1, 1.16.0, 35 applied
1808
+ * migrations) all three are permanently empty — no migration ever populates
1809
+ * them, and no session has ever written a row. Reading from them instead of
1810
+ * `auth.json` always yields `undefined`, credential or not; `session/discover.ts`
1811
+ * uses this function rather than duplicating a sqlite lookup against those
1812
+ * dead tables.
1813
+ *
1814
+ * Sync (`fs.readFileSync`), no network. Returns undefined when `auth.json` is
1815
+ * missing, unreadable, or carries no valid credential.
1816
+ */
1817
+ export function resolveOpenCodeAccountId(base) {
1818
+ const authPath = resolveOpenCodeAuthPath(base);
1819
+ if (!authPath)
1820
+ return undefined;
1821
+ try {
1822
+ const data = JSON.parse(fs.readFileSync(authPath, 'utf-8'));
1823
+ if (!data || typeof data !== 'object')
1824
+ return undefined;
1825
+ const providers = Object.entries(data)
1826
+ .filter(([, cred]) => isValidOpenCodeCredential(cred))
1827
+ .map(([id]) => id)
1828
+ .sort();
1829
+ return providers.length ? providers.join('+') : undefined;
1830
+ }
1831
+ catch {
1832
+ return undefined;
1833
+ }
1834
+ }
1835
+ /**
1836
+ * Whether a Muse Code `~/.config/muse/auth.json` document holds any usable
1837
+ * access token. Live shape from `muse login` (device OAuth, Muse Code 0.1.0):
1838
+ * { schema_version: 1, providers: { meta: { access_token, api_key, user_email, … } } }
1839
+ * Also accept top-level or one-level-nested tokens for older/alternate writers.
1840
+ * Recurses into objects so `providers.meta.access_token` is found — a flat
1841
+ * one-level walk only saw `providers` and reported signed-out after a successful
1842
+ * login (balanced then excluded the account). Never returns the secret itself.
1843
+ */
1844
+ function museAuthHasToken(value, depth = 0) {
1845
+ if (depth > 4)
1846
+ return false;
1847
+ if (!value || typeof value !== 'object' || Array.isArray(value))
1848
+ return false;
1849
+ const root = value;
1850
+ if (typeof root.access_token === 'string' && root.access_token.length > 0)
1851
+ return true;
1852
+ if (typeof root.api_key === 'string' && root.api_key.length > 0)
1853
+ return true;
1854
+ for (const slot of Object.values(root)) {
1855
+ if (museAuthHasToken(slot, depth + 1))
1856
+ return true;
1857
+ }
1858
+ return false;
1859
+ }
1860
+ /** Best-effort email from a Muse auth.json (providers.meta.user_email, etc.). */
1861
+ function museAuthEmail(value, depth = 0) {
1862
+ if (depth > 4)
1863
+ return null;
1864
+ if (!value || typeof value !== 'object' || Array.isArray(value))
1865
+ return null;
1866
+ const root = value;
1867
+ if (typeof root.user_email === 'string' && root.user_email.includes('@')) {
1868
+ return root.user_email;
1869
+ }
1870
+ if (typeof root.email === 'string' && root.email.includes('@')) {
1871
+ return root.email;
1872
+ }
1873
+ for (const slot of Object.values(root)) {
1874
+ const found = museAuthEmail(slot, depth + 1);
1875
+ if (found)
1876
+ return found;
1877
+ }
1878
+ return null;
1879
+ }
1880
+ /**
1881
+ * Whether a Claude version home's credential file is present but carries no
1882
+ * token — the "must have a real credential" floor (see
1883
+ * `isValidOpenCodeCredential`) applied to claude.
1884
+ *
1885
+ * A FAILED OAuth refresh leaves exactly this state behind: Claude Code rewrites
1886
+ * `.claude/.credentials.json` with `accessToken: ""`, `refreshToken: ""` and
1887
+ * `expiresAt: 0`, keeping only the descriptive fields (`subscriptionType`,
1888
+ * `rateLimitTier`, `refreshTokenExpiresAt`). Everything we derive from
1889
+ * `.claude.json` — email, plan — still looks healthy, so the install reported
1890
+ * `signedIn: true`, `agents view` drew usage bars for it, and balanced rotation
1891
+ * (whose `authValid` is just "email present") kept picking it — every pick dying
1892
+ * at spawn on "OAuth session expired and could not be refreshed".
1893
+ *
1894
+ * Only decidable off macOS: there the login Keychain is the canonical store and
1895
+ * this file is not authoritative, and probing the Keychain would raise an
1896
+ * authorization sheet per installed version on every `agents run` — the reason
1897
+ * rotation stopped calling `isClaudeAuthValid` at all. Off macOS the file IS the
1898
+ * only store, so a token-less file is proof of signed-out. `platform` is a
1899
+ * parameter so both branches are testable on any host.
1900
+ *
1901
+ * Sync, no Keychain, no network — safe on the `agents run` hot path.
1902
+ */
1903
+ export function isClaudeCredentialFileBlank(base, platform = process.platform) {
1904
+ if (platform === 'darwin')
1905
+ return false;
1906
+ try {
1907
+ const raw = fs.readFileSync(path.join(base, '.claude', '.credentials.json'), 'utf-8');
1908
+ const oauth = JSON.parse(raw).claudeAiOauth;
1909
+ if (!oauth)
1910
+ return false;
1911
+ const nonEmpty = (v) => typeof v === 'string' && v.trim().length > 0;
1912
+ return !nonEmpty(oauth.accessToken) && !nonEmpty(oauth.refreshToken);
1913
+ }
1914
+ catch {
1915
+ // No file (a Keychain-backed home, or never logged in here) or an
1916
+ // unreadable/corrupt one: not positive evidence of a blank credential, so
1917
+ // leave the existing signal alone rather than declaring a working install
1918
+ // signed out.
1919
+ return false;
1920
+ }
1921
+ }
1922
+ /**
1923
+ * Read a Claude home's config and account identity. Returns null when the home has
1924
+ * no readable `.claude.json`, or has one with no `oauthAccount` (never signed in).
1925
+ *
1926
+ * Sync because the session scanner calls it once per home on a hot path, and the
1927
+ * file is a few KB of local JSON. No Keychain access — see `getAccountInfo`.
1928
+ */
1929
+ export function readClaudeHomeConfig(base) {
1930
+ // Claude reads/writes config at $CLAUDE_CONFIG_DIR/.claude.json when set, falling
1931
+ // back to $HOME/.claude.json. Our shim sets CLAUDE_CONFIG_DIR to the per-version
1932
+ // .claude dir, so prefer that file; fall back to home-level for versions ever
1933
+ // launched without the shim (IDE extension, direct binary).
1934
+ const configDirFile = path.join(base, '.claude', '.claude.json');
1935
+ const homeLevelFile = path.join(base, '.claude.json');
1936
+ const activeFile = fs.existsSync(configDirFile) ? configDirFile : homeLevelFile;
1937
+ let config;
1938
+ try {
1939
+ config = JSON.parse(fs.readFileSync(activeFile, 'utf-8'));
1940
+ }
1941
+ catch {
1942
+ return null;
1943
+ }
1944
+ const oa = config.oauthAccount;
1945
+ if (!oa)
1946
+ return null;
1947
+ const accountId = normalizeIdentityPart(oa.accountUuid);
1948
+ const organizationId = normalizeIdentityPart(oa.organizationUuid);
1949
+ return {
1950
+ path: activeFile,
1951
+ config,
1952
+ identity: {
1953
+ email: oa.emailAddress || null,
1954
+ accountId,
1955
+ organizationId,
1956
+ organizationName: oa.organizationName ?? null,
1957
+ organizationType: oa.organizationType ?? null,
1958
+ usageKey: buildIdentityKey('claude', [['org', organizationId]]),
1959
+ accountKey: buildIdentityKey('claude', [
1960
+ ['account', accountId],
1961
+ ['org', organizationId],
1962
+ ]),
1963
+ },
1964
+ };
1965
+ }
1966
+ export async function getAccountInfo(agentId, home) {
1967
+ const base = home || os.homedir();
1968
+ const empty = {
1969
+ accountKey: null,
1970
+ usageKey: null,
1971
+ accountId: null,
1972
+ organizationId: null,
1973
+ userId: null,
1974
+ email: null,
1975
+ plan: null,
1976
+ usageStatus: null,
1977
+ overageCredits: null,
1978
+ lastActive: null,
1979
+ signedIn: false,
1980
+ };
1981
+ const configFiles = {
1982
+ claude: path.join(base, '.claude.json'),
1983
+ codex: path.join(base, '.codex', 'auth.json'),
1984
+ gemini: path.join(base, '.gemini', 'google_accounts.json'),
1985
+ };
1986
+ const lastActive = resolveLastActive(agentId, base, configFiles[agentId]);
1987
+ try {
1988
+ switch (agentId) {
1989
+ case 'claude': {
1990
+ // Identity extraction is shared with the session scanner's account
1991
+ // attribution — see readClaudeHomeConfig. A home with no readable config or
1992
+ // no oauthAccount is signed out, which is what the pre-refactor code
1993
+ // produced when JSON.parse threw or oauthAccount was absent.
1994
+ const claudeHome = readClaudeHomeConfig(base);
1995
+ if (!claudeHome)
1996
+ return { ...empty, lastActive };
1997
+ const { config: data, identity } = claudeHome;
1998
+ const oa = data.oauthAccount;
1999
+ const { accountId, organizationId, email, accountKey, usageKey } = identity;
2000
+ // Credential floor: a blanked credential file means this home cannot
2001
+ // authenticate, whatever `.claude.json` still says. Report it signed out
2002
+ // so `agents view` prompts a re-login and rotation routes around it,
2003
+ // instead of handing runs to an install that dies at spawn.
2004
+ if (email && isClaudeCredentialFileBlank(base)) {
2005
+ return { ...empty, lastActive };
2006
+ }
2007
+ // Plan tier is derived from .claude.json's organizationType, which carries
2008
+ // the TRUE tier (claude_max → "Max", claude_pro → "Pro", claude_team →
2009
+ // "Team") and is already in-hand from the config we just read — no Keychain
2010
+ // prompt. billingType only distinguishes "has a Stripe subscription" and so
2011
+ // mislabels every Max account as "Pro"; keep it as a fallback for older
2012
+ // configs predating organizationType. (Reading subscriptionType from the
2013
+ // keychain item would force a macOS Keychain ACL prompt on every `agents
2014
+ // run`, so we deliberately avoid it — organizationType gives us the tier
2015
+ // without that cost.)
2016
+ let plan = formatClaudeOrgLabel(oa?.organizationType);
2017
+ if (!plan) {
2018
+ if (oa?.billingType === 'stripe_subscription') {
2019
+ plan = 'Pro';
2020
+ }
2021
+ else if (oa?.billingType) {
2022
+ plan = oa.billingType;
2023
+ }
2024
+ }
2025
+ // usageStatus is NOT derived from cachedExtraUsageDisabledReason. That
2026
+ // field reports why pay-as-you-go overage is off (out_of_credits = no
2027
+ // overage credits purchased; org_level_disabled = admin turned overage
2028
+ // off), which says nothing about whether the account is throttled — a
2029
+ // Pro account at 5% weekly usage with overage disabled is fully usable.
2030
+ // Real throttle state comes from the live usage windows; callers derive
2031
+ // it via deriveUsageStatusFromSnapshot(). Here we only report whether
2032
+ // the account is signed in at all. Overage state stays visible through
2033
+ // overageCredits below.
2034
+ const usageStatus = email ? 'available' : null;
2035
+ let overageCredits = null;
2036
+ const orgId = oa?.organizationUuid;
2037
+ const creditCache = orgId && data.overageCreditGrantCache?.[orgId];
2038
+ if (creditCache?.info?.available && creditCache.info.amount_minor_units) {
2039
+ overageCredits = {
2040
+ amount: creditCache.info.amount_minor_units / 100,
2041
+ currency: creditCache.info.currency || 'USD',
2042
+ };
2043
+ }
2044
+ return {
2045
+ accountKey,
2046
+ usageKey,
2047
+ accountId,
2048
+ organizationId,
2049
+ userId: null,
2050
+ email,
2051
+ plan,
2052
+ usageStatus,
2053
+ overageCredits,
2054
+ lastActive,
2055
+ signedIn: !!email,
2056
+ organizationType: oa?.organizationType ?? null,
2057
+ organizationName: oa?.organizationName ?? null,
2058
+ };
2059
+ }
2060
+ case 'codex': {
2061
+ const data = JSON.parse(await fs.promises.readFile(path.join(base, '.codex', 'auth.json'), 'utf-8'));
2062
+ const token = data.tokens?.id_token || data.tokens?.access_token;
2063
+ if (!token)
2064
+ return { ...empty, lastActive };
2065
+ const decoded = decodeJwtPayload(token);
2066
+ if (!decoded)
2067
+ return { ...empty, lastActive };
2068
+ const email = decoded.email || null;
2069
+ // Plan and subscription from OpenAI auth claim
2070
+ const authClaim = decoded['https://api.openai.com/auth'] || {};
2071
+ const accountId = normalizeIdentityPart(authClaim.chatgpt_account_id);
2072
+ const userId = normalizeIdentityPart(authClaim.chatgpt_user_id || authClaim.user_id);
2073
+ const organizationId = normalizeIdentityPart(getCodexDefaultOrgId(authClaim));
2074
+ const accountKey = buildIdentityKey(agentId, [
2075
+ ['account', accountId],
2076
+ ['user', userId],
2077
+ ['org', organizationId],
2078
+ ]);
2079
+ const rawPlan = authClaim.chatgpt_plan_type;
2080
+ const plan = rawPlan ? rawPlan.charAt(0).toUpperCase() + rawPlan.slice(1) : null;
2081
+ // Subscription status: expired = out_of_credits
2082
+ let usageStatus = null;
2083
+ const activeUntil = authClaim.chatgpt_subscription_active_until;
2084
+ if (activeUntil) {
2085
+ const expired = new Date(activeUntil).getTime() < Date.now();
2086
+ usageStatus = expired ? 'out_of_credits' : 'available';
2087
+ }
2088
+ return {
2089
+ accountKey,
2090
+ usageKey: accountKey,
2091
+ accountId,
2092
+ organizationId,
2093
+ userId,
2094
+ email,
2095
+ plan,
2096
+ usageStatus,
2097
+ overageCredits: null,
2098
+ lastActive,
2099
+ signedIn: !!email,
2100
+ };
2101
+ }
2102
+ case 'gemini': {
2103
+ const data = JSON.parse(await fs.promises.readFile(path.join(base, '.gemini', 'google_accounts.json'), 'utf-8'));
2104
+ const email = data.active || null;
2105
+ return { ...empty, email, signedIn: !!email, lastActive };
2106
+ }
2107
+ case 'cursor': {
2108
+ // Cursor CLI keeps account metadata in ~/.cursor/cli-config.json
2109
+ // (authInfo: { email, userId, authId }) and its OAuth tokens SEPARATELY
2110
+ // in ~/.config/cursor/auth.json ({ accessToken, refreshToken }). Presence
2111
+ // of an access token is the signed-in signal; email/ids come from
2112
+ // cli-config. authId is the OAuth subject (e.g. "google-oauth2|<n>") — the
2113
+ // same value the usage endpoint keys on (see getCursorUsageInfo).
2114
+ const cfgPath = resolveAccountCredentialPath(base, '.cursor', 'cli-config.json');
2115
+ if (!cfgPath)
2116
+ return { ...empty, lastActive };
2117
+ try {
2118
+ const cfg = JSON.parse(await fs.promises.readFile(cfgPath, 'utf-8'));
2119
+ const authInfo = cfg?.authInfo;
2120
+ const email = typeof authInfo?.email === 'string' ? authInfo.email : null;
2121
+ const accountId = normalizeIdentityPart(authInfo?.authId ?? authInfo?.userId);
2122
+ if (!email && !accountId)
2123
+ return { ...empty, lastActive };
2124
+ const authPath = resolveAccountCredentialPath(base, '.config', 'cursor', 'auth.json');
2125
+ let hasToken = false;
2126
+ if (authPath) {
2127
+ try {
2128
+ const tok = JSON.parse(fs.readFileSync(authPath, 'utf-8'));
2129
+ hasToken = typeof tok?.accessToken === 'string' && tok.accessToken.length > 0;
2130
+ }
2131
+ catch { /* unreadable token file */ }
2132
+ }
2133
+ const accountKey = buildIdentityKey(agentId, [['user', accountId]]);
2134
+ return { ...empty, email, accountId, accountKey, signedIn: hasToken || !!email, lastActive };
2135
+ }
2136
+ catch { }
2137
+ return { ...empty, lastActive };
2138
+ }
2139
+ case 'grok': {
2140
+ // Grok stores auth in ~/.grok/auth.json as a map keyed by
2141
+ // "<oidc_issuer>::<client_id>" -> { email, user_id, refresh_token,
2142
+ // create_time, expires_at, team_id, ... }. (Older builds wrote a flat
2143
+ // object with a top-level email.) The old code only read a TOP-LEVEL
2144
+ // `email`, so the current nested format always looked signed-out even
2145
+ // when logged in. Read the newest account record: a refresh token means
2146
+ // signed in, and we surface the email/ids like claude/codex.
2147
+ const authPath = resolveAccountCredentialPath(base, '.grok', 'auth.json');
2148
+ if (!authPath)
2149
+ return { ...empty, lastActive };
2150
+ try {
2151
+ const data = JSON.parse(await fs.promises.readFile(authPath, 'utf-8'));
2152
+ const records = (data && typeof data === 'object' ? [data, ...Object.values(data)] : [])
2153
+ .filter((r) => !!r && typeof r === 'object');
2154
+ const account = records
2155
+ .filter(r => typeof r.refresh_token === 'string' || typeof r.email === 'string')
2156
+ .sort((a, b) => String(b.create_time || '').localeCompare(String(a.create_time || '')))[0];
2157
+ if (account) {
2158
+ const email = typeof account.email === 'string' ? account.email : null;
2159
+ const accountId = normalizeIdentityPart(account.user_id ?? account.principal_id);
2160
+ const organizationId = normalizeIdentityPart(account.team_id);
2161
+ const accountKey = buildIdentityKey(agentId, [['user', accountId], ['org', organizationId]]);
2162
+ return { ...empty, email, accountId, organizationId, accountKey, signedIn: true, lastActive };
2163
+ }
2164
+ }
2165
+ catch { }
2166
+ return { ...empty, lastActive };
2167
+ }
2168
+ case 'antigravity': {
2169
+ // Antigravity (`agy`) stores a consumer Google OAuth grant (access +
2170
+ // refresh token, no id_token) — presence of a refresh token is the only
2171
+ // signed-in signal we can derive without a network call. Storage is
2172
+ // platform-split via go-keyring:
2173
+ // - file ~/.gemini/antigravity-cli/antigravity-oauth-token (Linux
2174
+ // fallback when no Secret Service is available)
2175
+ // - macOS keychain / Linux libsecret (service gemini + user
2176
+ // antigravity) when a keyring daemon is present
2177
+ // Check the file first, then the OS keyring probe.
2178
+ const tokenPath = resolveAccountCredentialPath(base, '.gemini', 'antigravity-cli', 'antigravity-oauth-token');
2179
+ if (tokenPath) {
2180
+ const data = JSON.parse(await fs.promises.readFile(tokenPath, 'utf-8'));
2181
+ if (typeof data?.token?.refresh_token === 'string' && data.token.refresh_token) {
2182
+ // A stable account/usage key (derived from the refresh token — see
2183
+ // readAuthAccountIdentity) lets `agents view` dedupe and cache the
2184
+ // per-model quota bars for this login.
2185
+ const identity = readAuthAccountIdentity('antigravity', path.dirname(tokenPath));
2186
+ return { ...empty, signedIn: true, lastActive, accountKey: identity, usageKey: identity };
2187
+ }
2188
+ }
2189
+ if (await antigravityKeychainSignedIn()) {
2190
+ // Keyring-only login (the macOS case): the OS keyring holds exactly
2191
+ // ONE antigravity credential, so a stable singleton key identifies it
2192
+ // for usage-cache dedup without reading the secret value here.
2193
+ const identity = buildIdentityKey('antigravity', [['sub', 'keychain']]);
2194
+ return { ...empty, signedIn: true, lastActive, accountKey: identity, usageKey: identity };
2195
+ }
2196
+ return { ...empty, lastActive };
2197
+ }
2198
+ case 'kimi': {
2199
+ // Kimi Code stores OAuth credentials at
2200
+ // ~/.kimi-code/credentials/kimi-code.json. The access token is a JWT
2201
+ // whose payload carries an opaque user_id (no email), so we report
2202
+ // signed-in state plus a stable account key for usage dedup.
2203
+ const credPath = resolveAccountCredentialPath(base, '.kimi-code', 'credentials', 'kimi-code.json');
2204
+ if (!credPath)
2205
+ return { ...empty, lastActive };
2206
+ const data = JSON.parse(await fs.promises.readFile(credPath, 'utf-8'));
2207
+ const accessToken = data?.access_token;
2208
+ if (typeof accessToken !== 'string' || !accessToken)
2209
+ return { ...empty, lastActive };
2210
+ const decoded = decodeJwtPayload(accessToken);
2211
+ const userId = normalizeIdentityPart(decoded?.user_id ?? decoded?.sub);
2212
+ const accountKey = buildIdentityKey(agentId, [['user', userId]]);
2213
+ return { ...empty, signedIn: true, accountId: userId, accountKey, lastActive };
2214
+ }
2215
+ case 'droid': {
2216
+ // Factory Droid stores auth at ~/.factory/auth.v2.file (AES-256-GCM,
2217
+ // decrypted with the on-disk ~/.factory/auth.v2.key). We decrypt locally
2218
+ // — no network — and surface the email/org/role from the WorkOS
2219
+ // access-token JWT, same as claude/codex/grok. If the credential can't be
2220
+ // decrypted (a keyring-v2/legacy login with no on-disk key, or a decrypt
2221
+ // failure) we fall back to the file-presence signed-in signal so the row
2222
+ // still reads as logged in — the conservative floor antigravity/kimi use.
2223
+ // `.factory` is the config dir on every platform (macOS/Linux
2224
+ // ~/.factory, Windows %USERPROFILE%\.factory).
2225
+ const decoded = decryptDroidCredential(base);
2226
+ if (decoded?.email) {
2227
+ const organizationId = decoded.orgId;
2228
+ const accountKey = buildIdentityKey(agentId, [['org', organizationId]]);
2229
+ return {
2230
+ ...empty,
2231
+ email: decoded.email,
2232
+ organizationId,
2233
+ accountId: organizationId,
2234
+ accountKey,
2235
+ signedIn: true,
2236
+ lastActive,
2237
+ };
2238
+ }
2239
+ const authPath = resolveAccountCredentialPath(base, '.factory', 'auth.v2.file');
2240
+ if (!authPath)
2241
+ return { ...empty, lastActive };
2242
+ return { ...empty, signedIn: true, lastActive };
2243
+ }
2244
+ case 'opencode': {
2245
+ // OpenCode's auth.json is a record keyed by provider id ->
2246
+ // { type: 'oauth'|'api'|'wellknown', ...secret fields }. There is no
2247
+ // email/identity claim to surface, so — like antigravity/kimi — we
2248
+ // report signed-in state plus the NON-SECRET provider metadata (which
2249
+ // provider ids hold a valid credential) and never read the tokens/keys
2250
+ // themselves. The user's complaint was the row read "not signed in"
2251
+ // despite a live login; a valid provider entry now shows e.g.
2252
+ // "id:muse-spark" so they can see exactly which provider is configured.
2253
+ // resolveOpenCodeAccountId is the single source of truth for this join —
2254
+ // session/discover.ts reuses it for the indexed `account` field.
2255
+ const accountId = resolveOpenCodeAccountId(base);
2256
+ if (!accountId)
2257
+ return { ...empty, lastActive };
2258
+ const accountKey = buildIdentityKey(agentId, [['providers', accountId]]);
2259
+ return { ...empty, signedIn: true, accountId, accountKey, lastActive };
2260
+ }
2261
+ case 'muse': {
2262
+ // Muse Code authenticates with META_API_KEY (env, highest priority) or
2263
+ // a stored OAuth/API credential at ~/.config/muse/auth.json. Live file
2264
+ // shape nests under providers.meta (access_token + optional user_email).
2265
+ if (process.env.META_API_KEY?.trim() || process.env.MODEL_API_KEY?.trim()) {
2266
+ const accountKey = buildIdentityKey(agentId, [['auth', 'env']]);
2267
+ return { ...empty, signedIn: true, accountId: 'env', accountKey, lastActive };
2268
+ }
2269
+ const authPath = resolveAccountCredentialPath(base, '.config', 'muse', 'auth.json');
2270
+ if (!authPath)
2271
+ return { ...empty, lastActive };
2272
+ const data = JSON.parse(await fs.promises.readFile(authPath, 'utf-8'));
2273
+ if (!data || typeof data !== 'object')
2274
+ return { ...empty, lastActive };
2275
+ if (!museAuthHasToken(data))
2276
+ return { ...empty, lastActive };
2277
+ const email = museAuthEmail(data);
2278
+ const accountKey = buildIdentityKey(agentId, email ? [['email', email]] : [['auth', 'file']]);
2279
+ return {
2280
+ ...empty,
2281
+ signedIn: true,
2282
+ email,
2283
+ accountId: email ?? 'file',
2284
+ accountKey,
2285
+ lastActive,
2286
+ };
2287
+ }
2288
+ default:
2289
+ return { ...empty, lastActive };
2290
+ }
2291
+ }
2292
+ catch {
2293
+ /* auth/config file missing or unreadable */
2294
+ return { ...empty, lastActive };
2295
+ }
2296
+ }
2297
+ // Fresh window for the cached session walk. Matches USAGE_CACHE_FRESH_MS in
2298
+ // usage.ts (5 minutes) so a launch storm reuses both probes for the same period.
2299
+ const LAST_ACTIVE_CACHE_FRESH_MS = 5 * 60 * 1000;
2300
+ const getLastActiveCachePath = () => path.join(getCacheDir(), 'last-active.json');
2301
+ /**
2302
+ * Determine when the agent was last used by checking session file mtimes,
2303
+ * falling back to config mtime.
2304
+ *
2305
+ * The session walk stats every transcript under the home's session dir —
2306
+ * thousands of files on long-lived installs — and `agents run` rotation calls
2307
+ * this once per installed version on every launch. The walk result is cached
2308
+ * on disk for a short window so back-to-back launches skip it entirely.
2309
+ * Cache read/write is best-effort: any failure falls back to walking.
2310
+ */
2311
+ export function resolveLastActive(agentId, base, configPath, cachePath = getLastActiveCachePath(), now = new Date()) {
2312
+ const sessionDir = getSessionDir(agentId, base);
2313
+ const sessionExt = getSessionExtension(agentId);
2314
+ if (sessionDir && sessionExt) {
2315
+ const key = `${agentId}:${base}`;
2316
+ const cache = readLastActiveCacheFile(cachePath);
2317
+ const entry = cache[key];
2318
+ const fresh = entry &&
2319
+ typeof entry.computedAt === 'number' &&
2320
+ now.getTime() - entry.computedAt >= 0 &&
2321
+ now.getTime() - entry.computedAt < LAST_ACTIVE_CACHE_FRESH_MS;
2322
+ if (fresh) {
2323
+ if (entry.mtimeMs !== null)
2324
+ return new Date(entry.mtimeMs);
2325
+ // Fresh entry with no sessions: fall through to the config mtime below.
2326
+ }
2327
+ else {
2328
+ const mtimeMs = latestFileMtimeMs(sessionDir, sessionExt);
2329
+ cache[key] = { mtimeMs, computedAt: now.getTime() };
2330
+ // Stale entries are never served, so drop them on write — keeps homes
2331
+ // that no longer exist (removed versions, test temp dirs) from
2332
+ // accumulating in the file.
2333
+ for (const [k, v] of Object.entries(cache)) {
2334
+ if (k !== key && !(typeof v?.computedAt === 'number' && now.getTime() - v.computedAt < LAST_ACTIVE_CACHE_FRESH_MS)) {
2335
+ delete cache[k];
2336
+ }
2337
+ }
2338
+ writeLastActiveCacheFile(cache, cachePath);
2339
+ if (mtimeMs !== null)
2340
+ return new Date(mtimeMs);
2341
+ }
2342
+ }
2343
+ if (!configPath)
2344
+ return null;
2345
+ try {
2346
+ return fs.statSync(configPath).mtime;
2347
+ }
2348
+ catch {
2349
+ return null;
2350
+ }
2351
+ }
2352
+ /** Read the entire last-active cache file. Missing or corrupt file reads as empty. */
2353
+ function readLastActiveCacheFile(cachePath) {
2354
+ if (!fs.existsSync(cachePath))
2355
+ return {};
2356
+ try {
2357
+ const parsed = JSON.parse(fs.readFileSync(cachePath, 'utf-8'));
2358
+ return parsed && typeof parsed === 'object' ? parsed : {};
2359
+ }
2360
+ catch {
2361
+ return {};
2362
+ }
2363
+ }
2364
+ /** Write the entire last-active cache. Best-effort; a failed write just means the next call walks again. */
2365
+ function writeLastActiveCacheFile(cache, cachePath) {
2366
+ try {
2367
+ fs.mkdirSync(path.dirname(cachePath), { recursive: true });
2368
+ fs.writeFileSync(cachePath, JSON.stringify(cache), 'utf-8');
2369
+ }
2370
+ catch {
2371
+ /* best-effort */
2372
+ }
2373
+ }
2374
+ /** Return the root directory where the agent stores session files, or null if unknown. */
2375
+ function getSessionDir(agentId, base) {
2376
+ const rel = AGENTS[agentId].sessionDir;
2377
+ return rel ? path.join(base, ...rel) : null;
2378
+ }
2379
+ /** Return the file extension used for session files by the given agent. */
2380
+ function getSessionExtension(agentId) {
2381
+ return AGENTS[agentId].sessionFileExt;
2382
+ }
2383
+ /**
2384
+ * Quick count of session files for an agent (without full DB scan).
2385
+ * Used during init to show approximate session count to user.
2386
+ */
2387
+ export function countSessionFiles(agentId) {
2388
+ const sessionDir = getSessionDir(agentId, HOME);
2389
+ const ext = getSessionExtension(agentId);
2390
+ if (!sessionDir || !ext || !fs.existsSync(sessionDir))
2391
+ return 0;
2392
+ let count = 0;
2393
+ const walk = (dir) => {
2394
+ try {
2395
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
2396
+ for (const entry of entries) {
2397
+ if (entry.isDirectory()) {
2398
+ walk(path.join(dir, entry.name));
2399
+ }
2400
+ else if (entry.isFile() && entry.name.endsWith(ext)) {
2401
+ count++;
2402
+ }
2403
+ }
2404
+ }
2405
+ catch {
2406
+ // Permission denied or other error
2407
+ }
2408
+ };
2409
+ walk(sessionDir);
2410
+ return count;
2411
+ }
2412
+ /** Decode the payload section of a JWT token without verifying its signature. */
2413
+ export function decodeJwtPayload(token) {
2414
+ const payload = token.split('.')[1];
2415
+ if (!payload)
2416
+ return null;
2417
+ try {
2418
+ return JSON.parse(Buffer.from(payload, 'base64url').toString());
2419
+ }
2420
+ catch {
2421
+ return null;
2422
+ }
2423
+ }
2424
+ /** Extract the default organization ID from a Codex/OpenAI auth claim. */
2425
+ function getCodexDefaultOrgId(authClaim) {
2426
+ const organizations = authClaim?.organizations;
2427
+ if (!Array.isArray(organizations))
2428
+ return null;
2429
+ const first = organizations[0];
2430
+ return typeof first?.id === 'string' ? first.id : null;
2431
+ }
2432
+ /** Trim and normalize an identity string, returning null for empty or non-string values. */
2433
+ function normalizeIdentityPart(value) {
2434
+ if (typeof value !== 'string')
2435
+ return null;
2436
+ const trimmed = value.trim();
2437
+ return trimmed || null;
2438
+ }
2439
+ /** Build a composite identity key like "claude:account=abc:org=xyz" from labeled parts. */
2440
+ function buildIdentityKey(agentId, parts) {
2441
+ const encoded = parts
2442
+ .filter(([, value]) => value)
2443
+ .map(([label, value]) => `${label}=${value}`);
2444
+ if (encoded.length === 0)
2445
+ return null;
2446
+ return `${agentId}:${encoded.join(':')}`;
2447
+ }
2448
+ /** Register an MCP server with an agent's CLI via `mcp add`. */
2449
+ export async function registerMcp(agentId, name, command, scope = 'user', transport = 'stdio', options) {
2450
+ const agent = AGENTS[agentId];
2451
+ if (!supports(agentId, 'mcp').ok) {
2452
+ return { success: false, error: 'Agent does not support MCP' };
2453
+ }
2454
+ if (transport === 'http' && !supports(agentId, 'mcpHttp').ok) {
2455
+ return { success: false, error: 'skipped: agent does not support HTTP MCP registration' };
2456
+ }
2457
+ if (transport === 'http' && options?.headers && Object.keys(options.headers).length > 0 && !supports(agentId, 'mcpHeaders').ok) {
2458
+ return { success: false, error: 'skipped: HTTP MCP headers are only supported for Claude registration' };
2459
+ }
2460
+ if (agent.mcpRegister === 'config') {
2461
+ try {
2462
+ writeMcpToConfig(agentId, name, command, scope, transport, options?.home);
2463
+ return { success: true };
2464
+ }
2465
+ catch (err) {
2466
+ return { success: false, error: err.message };
2467
+ }
2468
+ }
2469
+ if (!options?.binary && !(await isCliInstalled(agentId))) {
2470
+ return { success: false, error: 'CLI not installed' };
2471
+ }
2472
+ try {
2473
+ // Use explicit binary path when provided (bypasses shim for version-managed agents)
2474
+ const bin = options?.binary || agent.cliCommand;
2475
+ let args;
2476
+ if (transport === 'http') {
2477
+ if (agent.mcpAddHttp === 'url') {
2478
+ args = ['mcp', 'add', name, '--url', command];
2479
+ }
2480
+ else {
2481
+ const headerArgs = Object.entries(options?.headers || {}).flatMap(([key, value]) => ['--header', `${key}: ${value}`]);
2482
+ args = ['mcp', 'add', '--transport', 'http', '--scope', scope, name, command, ...headerArgs];
2483
+ }
2484
+ }
2485
+ else if (agent.mcpAddStdio === 'scope') {
2486
+ const commandArgs = splitCommandLine(command);
2487
+ args = ['mcp', 'add', '--transport', transport, '--scope', scope, name, '--', ...commandArgs];
2488
+ }
2489
+ else {
2490
+ const commandArgs = splitCommandLine(command);
2491
+ args = ['mcp', 'add', name, '--', ...commandArgs];
2492
+ }
2493
+ // When home is specified, override HOME so MCP config writes to the version's config dir
2494
+ const env = options?.home ? { ...process.env, HOME: options.home } : undefined;
2495
+ // On Windows a bare command name / `.cmd` wrapper (the npm-installed agent
2496
+ // CLI) can't be exec'd directly — it needs shell:true for PATHEXT/cmd. Off
2497
+ // Windows this is always false, so the no-shell argv path is unchanged.
2498
+ // RUSH-1752: when shell is needed, compose a fully-quoted command line and
2499
+ // pass EMPTY argv so user-controlled MCP command/args never reach cmd.exe unescaped.
2500
+ const spec = execFileShellSpec(bin, args);
2501
+ await execFileAsync(spec.command, spec.args, { ...(env ? { env } : {}), shell: spec.shell });
2502
+ return { success: true };
2503
+ }
2504
+ catch (err) {
2505
+ return { success: false, error: err.message };
2506
+ }
2507
+ }
2508
+ /** Unregister (remove) a named MCP server from an agent's CLI config. */
2509
+ export async function unregisterMcp(agentId, name, options) {
2510
+ const agent = AGENTS[agentId];
2511
+ if (!supports(agentId, 'mcp').ok) {
2512
+ return { success: false, error: 'Agent does not support MCP' };
2513
+ }
2514
+ if (agent.mcpRegister === 'config') {
2515
+ try {
2516
+ removeMcpFromConfig(agentId, name, options?.home);
2517
+ return { success: true };
2518
+ }
2519
+ catch (err) {
2520
+ return { success: false, error: err.message };
2521
+ }
2522
+ }
2523
+ if (!options?.binary && !(await isCliInstalled(agentId))) {
2524
+ return { success: false, error: 'CLI not installed' };
2525
+ }
2526
+ try {
2527
+ const bin = options?.binary || agent.cliCommand;
2528
+ const env = options?.home ? { ...process.env, HOME: options.home } : undefined;
2529
+ // RUSH-1752: same shell-safe path as registerMcp — attacker-controlled MCP
2530
+ // `name` must not reach cmd.exe unescaped when shell:true is required.
2531
+ const spec = execFileShellSpec(bin, ['mcp', 'remove', name]);
2532
+ await execFileAsync(spec.command, spec.args, { ...(env ? { env } : {}), shell: spec.shell });
2533
+ return { success: true };
2534
+ }
2535
+ catch (err) {
2536
+ return { success: false, error: err.message };
2537
+ }
2538
+ }
2539
+ /**
2540
+ * Register an MCP server across multiple agent targets, including both direct
2541
+ * (non-version-managed) agents and specific version-managed installs.
2542
+ */
2543
+ export async function registerMcpToTargets(targets, name, command, scope = 'user', transport = 'stdio', options = {}) {
2544
+ const results = [];
2545
+ for (const agentId of targets.directAgents) {
2546
+ const result = await registerMcp(agentId, name, command, scope, transport, options);
2547
+ results.push({ agentId, success: result.success, error: result.error });
2548
+ }
2549
+ for (const [agentId, versions] of targets.versionSelections) {
2550
+ for (const version of versions) {
2551
+ const result = await registerMcp(agentId, name, command, scope, transport, {
2552
+ ...options,
2553
+ home: getVersionHomePath(agentId, version),
2554
+ binary: getBinaryPath(agentId, version),
2555
+ });
2556
+ results.push({ agentId, version, success: result.success, error: result.error });
2557
+ }
2558
+ }
2559
+ return results;
2560
+ }
2561
+ /**
2562
+ * Unregister an MCP server from multiple agent targets, including both direct
2563
+ * agents and specific version-managed installs.
2564
+ */
2565
+ export async function unregisterMcpFromTargets(targets, name) {
2566
+ const results = [];
2567
+ for (const agentId of targets.directAgents) {
2568
+ const result = await unregisterMcp(agentId, name);
2569
+ results.push({ agentId, success: result.success, error: result.error });
2570
+ }
2571
+ for (const [agentId, versions] of targets.versionSelections) {
2572
+ for (const version of versions) {
2573
+ const result = await unregisterMcp(agentId, name, {
2574
+ home: getVersionHomePath(agentId, version),
2575
+ binary: getBinaryPath(agentId, version),
2576
+ });
2577
+ results.push({ agentId, version, success: result.success, error: result.error });
2578
+ }
2579
+ }
2580
+ return results;
2581
+ }
2582
+ function userMcpConfigPath(agentId, home) {
2583
+ if (home)
2584
+ return getMcpConfigPathForHome(agentId, home);
2585
+ return getUserMcpConfigPath(agentId);
2586
+ }
2587
+ function scopedMcpConfigPath(agentId, scope, home) {
2588
+ if (scope === 'project')
2589
+ return getProjectMcpConfigPath(agentId);
2590
+ return userMcpConfigPath(agentId, home);
2591
+ }
2592
+ function mcpEntryFromCommand(command, transport) {
2593
+ if (transport === 'http') {
2594
+ return { url: command };
2595
+ }
2596
+ const commandArgs = splitCommandLine(command);
2597
+ return {
2598
+ command: commandArgs[0],
2599
+ args: commandArgs.slice(1),
2600
+ };
2601
+ }
2602
+ function readYamlConfig(configPath) {
2603
+ if (!fs.existsSync(configPath))
2604
+ return {};
2605
+ const parsed = yaml.parse(fs.readFileSync(configPath, 'utf-8'));
2606
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
2607
+ ? parsed
2608
+ : {};
2609
+ }
2610
+ function readJsonConfig(configPath) {
2611
+ if (!fs.existsSync(configPath))
2612
+ return {};
2613
+ const content = configPath.endsWith('.jsonc')
2614
+ ? stripJsonComments(fs.readFileSync(configPath, 'utf-8'))
2615
+ : fs.readFileSync(configPath, 'utf-8');
2616
+ const parsed = JSON.parse(content);
2617
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
2618
+ ? parsed
2619
+ : {};
2620
+ }
2621
+ function writeMcpToConfig(agentId, name, command, scope, transport, home) {
2622
+ const configPath = scopedMcpConfigPath(agentId, scope, home);
2623
+ const entry = mcpEntryFromCommand(command, transport);
2624
+ if (AGENTS[agentId].mcpConfigWrite === 'yaml-mcp_servers') {
2625
+ const config = readYamlConfig(configPath);
2626
+ if (!config.mcp_servers || typeof config.mcp_servers !== 'object' || Array.isArray(config.mcp_servers)) {
2627
+ config.mcp_servers = {};
2628
+ }
2629
+ config.mcp_servers[name] = entry;
2630
+ fs.mkdirSync(path.dirname(configPath), { recursive: true });
2631
+ fs.writeFileSync(configPath, yaml.stringify(config), 'utf-8');
2632
+ return;
2633
+ }
2634
+ const config = readJsonConfig(configPath);
2635
+ if (!config.mcpServers || typeof config.mcpServers !== 'object' || Array.isArray(config.mcpServers)) {
2636
+ config.mcpServers = {};
2637
+ }
2638
+ config.mcpServers[name] = entry;
2639
+ fs.mkdirSync(path.dirname(configPath), { recursive: true });
2640
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8');
2641
+ }
2642
+ function removeMcpFromConfig(agentId, name, home) {
2643
+ const configPath = userMcpConfigPath(agentId, home);
2644
+ if (!fs.existsSync(configPath))
2645
+ return;
2646
+ if (AGENTS[agentId].mcpConfigWrite === 'yaml-mcp_servers') {
2647
+ const config = readYamlConfig(configPath);
2648
+ const servers = config.mcp_servers;
2649
+ if (servers && typeof servers === 'object' && !Array.isArray(servers)) {
2650
+ delete servers[name];
2651
+ fs.writeFileSync(configPath, yaml.stringify(config), 'utf-8');
2652
+ }
2653
+ return;
2654
+ }
2655
+ const config = readJsonConfig(configPath);
2656
+ const servers = config.mcpServers;
2657
+ if (servers && typeof servers === 'object' && !Array.isArray(servers)) {
2658
+ delete servers[name];
2659
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8');
2660
+ }
2661
+ }
2662
+ /**
2663
+ * Extract version from npm package specification.
2664
+ * Examples: @scope/package@latest -> latest
2665
+ * @scope/package@1.2.3 -> 1.2.3
2666
+ * some-package -> undefined
2667
+ */
2668
+ function extractNpmVersion(args) {
2669
+ // Find npm package argument (looks like @scope/package@version or package@version)
2670
+ for (const arg of args) {
2671
+ // Match @scope/package@version or package@version
2672
+ const match = arg.match(/@([^@]+)$|^([^@]+)@(.+)$/);
2673
+ if (match) {
2674
+ // @scope/package@version pattern
2675
+ const versionMatch = arg.match(/@([^@/]+)$/);
2676
+ if (versionMatch) {
2677
+ return versionMatch[1];
2678
+ }
2679
+ }
2680
+ }
2681
+ return undefined;
2682
+ }
2683
+ /**
2684
+ * Strip JSON comments for JSONC parsing.
2685
+ * Only removes comments outside of strings.
2686
+ *
2687
+ * Exported because the MCP writer needs the SAME parser the reader uses: a
2688
+ * naive `//`-to-end-of-line regex eats the `//` inside
2689
+ * `"$schema": "https://opencode.ai/config.json"`, which every opencode-generated
2690
+ * config carries.
2691
+ */
2692
+ export function stripJsonComments(content) {
2693
+ let result = '';
2694
+ let inString = false;
2695
+ let escape = false;
2696
+ let i = 0;
2697
+ while (i < content.length) {
2698
+ const char = content[i];
2699
+ const next = content[i + 1];
2700
+ if (escape) {
2701
+ result += char;
2702
+ escape = false;
2703
+ i++;
2704
+ continue;
2705
+ }
2706
+ if (char === '\\' && inString) {
2707
+ result += char;
2708
+ escape = true;
2709
+ i++;
2710
+ continue;
2711
+ }
2712
+ if (char === '"') {
2713
+ inString = !inString;
2714
+ result += char;
2715
+ i++;
2716
+ continue;
2717
+ }
2718
+ if (!inString) {
2719
+ // Check for single-line comment
2720
+ if (char === '/' && next === '/') {
2721
+ // Skip until end of line
2722
+ while (i < content.length && content[i] !== '\n') {
2723
+ i++;
2724
+ }
2725
+ continue;
2726
+ }
2727
+ // Check for multi-line comment
2728
+ if (char === '/' && next === '*') {
2729
+ i += 2;
2730
+ while (i < content.length && !(content[i] === '*' && content[i + 1] === '/')) {
2731
+ i++;
2732
+ }
2733
+ i += 2; // Skip */
2734
+ continue;
2735
+ }
2736
+ }
2737
+ result += char;
2738
+ i++;
2739
+ }
2740
+ return result;
2741
+ }
2742
+ /**
2743
+ * Parse MCP servers from a JSON/JSONC config file.
2744
+ */
2745
+ function parseMcpFromJsonConfig(configPath) {
2746
+ if (!fs.existsSync(configPath)) {
2747
+ return {};
2748
+ }
2749
+ try {
2750
+ let content = fs.readFileSync(configPath, 'utf-8');
2751
+ // Handle JSONC (JSON with comments)
2752
+ if (configPath.endsWith('.jsonc')) {
2753
+ content = stripJsonComments(content);
2754
+ }
2755
+ const config = JSON.parse(content);
2756
+ // Claude uses mcpServers, others may use mcp_servers or mcp
2757
+ return config.mcpServers || config.mcp_servers || config.mcp || {};
2758
+ }
2759
+ catch {
2760
+ /* JSON config corrupt or unreadable */
2761
+ return {};
2762
+ }
2763
+ }
2764
+ /**
2765
+ * Parse MCP servers from a TOML config file (Codex).
2766
+ * Codex stores MCPs as [mcp_servers.ServerName] sections.
2767
+ */
2768
+ function parseMcpFromTomlConfig(configPath) {
2769
+ if (!fs.existsSync(configPath)) {
2770
+ return {};
2771
+ }
2772
+ try {
2773
+ const content = fs.readFileSync(configPath, 'utf-8');
2774
+ const config = TOML.parse(content);
2775
+ // Codex uses mcp_servers as a table with server names as keys
2776
+ const mcpServers = config.mcp_servers;
2777
+ return mcpServers || {};
2778
+ }
2779
+ catch {
2780
+ /* TOML config corrupt or unreadable */
2781
+ return {};
2782
+ }
2783
+ }
2784
+ function parseMcpFromYamlConfig(configPath) {
2785
+ if (!fs.existsSync(configPath)) {
2786
+ return {};
2787
+ }
2788
+ try {
2789
+ const config = readYamlConfig(configPath);
2790
+ const mcpServers = config.mcp_servers;
2791
+ return mcpServers || {};
2792
+ }
2793
+ catch {
2794
+ /* YAML config corrupt or unreadable */
2795
+ return {};
2796
+ }
2797
+ }
2798
+ /**
2799
+ * Parse MCP servers from OpenCode's JSONC config.
2800
+ * OpenCode stores MCPs in the "mcp" object with different structure.
2801
+ */
2802
+ function parseMcpFromOpenCodeConfig(configPath) {
2803
+ if (!fs.existsSync(configPath)) {
2804
+ return {};
2805
+ }
2806
+ try {
2807
+ const content = stripJsonComments(fs.readFileSync(configPath, 'utf-8'));
2808
+ const config = JSON.parse(content);
2809
+ const mcpConfig = config.mcp;
2810
+ if (!mcpConfig)
2811
+ return {};
2812
+ // Convert OpenCode format to our McpConfigEntry format
2813
+ const result = {};
2814
+ for (const [name, entry] of Object.entries(mcpConfig)) {
2815
+ if (entry.type === 'local' && entry.command) {
2816
+ // Local MCP: command is an array like ["npx", "-y", "@pkg@version"]
2817
+ result[name] = {
2818
+ command: entry.command[0],
2819
+ args: entry.command.slice(1),
2820
+ };
2821
+ }
2822
+ else if (entry.type === 'remote' && entry.url) {
2823
+ // Remote MCP: HTTP URL
2824
+ result[name] = {
2825
+ url: entry.url,
2826
+ };
2827
+ }
2828
+ }
2829
+ return result;
2830
+ }
2831
+ catch {
2832
+ /* OpenCode JSONC config corrupt or unreadable */
2833
+ return {};
2834
+ }
2835
+ }
2836
+ /**
2837
+ * Get user-scoped MCP config path for an agent.
2838
+ *
2839
+ * All three MCP path resolvers read `MCP_TARGETS`, so the file the writer
2840
+ * targets, the file the parser reads, and the file the staleness detector
2841
+ * watches cannot drift apart. An agent with no MCP target (the hard-deprecated
2842
+ * `gemini`) keeps the historical settings.json default so old configs still
2843
+ * parse.
2844
+ */
2845
+ export function getUserMcpConfigPath(agentId) {
2846
+ return getMcpConfigPathForHome(agentId, HOME);
2847
+ }
2848
+ /**
2849
+ * Get MCP config path for a specific HOME directory (used for version-managed agents).
2850
+ */
2851
+ export function getMcpConfigPathForHome(agentId, home) {
2852
+ const target = MCP_TARGETS[agentId];
2853
+ if (target)
2854
+ return target.home(home);
2855
+ return path.join(home, agentConfigDirName(agentId), 'settings.json');
2856
+ }
2857
+ /**
2858
+ * Get project-scoped MCP config path for an agent.
2859
+ */
2860
+ export function getProjectMcpConfigPath(agentId, cwd = process.cwd()) {
2861
+ const target = MCP_TARGETS[agentId];
2862
+ if (target)
2863
+ return target.project(cwd);
2864
+ return path.join(cwd, `.${agentId}`, 'settings.json');
2865
+ }
2866
+ /**
2867
+ * Parse MCP servers from OpenClaw's JSON config.
2868
+ * OpenClaw stores MCPs under mcp.servers with a similar structure to other agents.
2869
+ */
2870
+ function parseMcpFromOpenClawConfig(configPath) {
2871
+ if (!fs.existsSync(configPath)) {
2872
+ return {};
2873
+ }
2874
+ try {
2875
+ const content = fs.readFileSync(configPath, 'utf-8');
2876
+ const config = JSON.parse(content);
2877
+ // OpenClaw uses mcp.servers for MCP configuration
2878
+ const mcpServers = config.mcp?.servers;
2879
+ if (!mcpServers)
2880
+ return {};
2881
+ const result = {};
2882
+ for (const [name, entry] of Object.entries(mcpServers)) {
2883
+ if (entry.command) {
2884
+ result[name] = {
2885
+ command: entry.command,
2886
+ args: entry.args,
2887
+ env: entry.env,
2888
+ };
2889
+ }
2890
+ else if (entry.url) {
2891
+ result[name] = {
2892
+ url: entry.url,
2893
+ type: entry.transport || 'sse',
2894
+ };
2895
+ }
2896
+ }
2897
+ return result;
2898
+ }
2899
+ catch {
2900
+ /* OpenClaw JSON config corrupt or unreadable */
2901
+ return {};
2902
+ }
2903
+ }
2904
+ /**
2905
+ * Parse MCP config based on agent type.
2906
+ */
2907
+ export function parseMcpConfig(agentId, configPath) {
2908
+ // Dispatch on the registry's declared format, not the agent id, so the parser
2909
+ // can never disagree with the writer about a file's serialization -- grok's
2910
+ // TOML config was previously read as JSON and always came back empty.
2911
+ switch (MCP_TARGETS[agentId]?.format) {
2912
+ case 'toml':
2913
+ return parseMcpFromTomlConfig(configPath);
2914
+ case 'opencode-jsonc':
2915
+ return parseMcpFromOpenCodeConfig(configPath);
2916
+ case 'openclaw-json':
2917
+ return parseMcpFromOpenClawConfig(configPath);
2918
+ case 'yaml':
2919
+ return parseMcpFromYamlConfig(configPath);
2920
+ default:
2921
+ // claude-json / antigravity-json / muse-json all live in a JSON object;
2922
+ // parseMcpFromJsonConfig accepts mcpServers | mcp_servers | mcp. Agents
2923
+ // with no declared format fall here too, matching prior behavior.
2924
+ return parseMcpFromJsonConfig(configPath);
2925
+ }
2926
+ }
2927
+ /**
2928
+ * List installed MCP servers with scope information.
2929
+ * Pass options.home to read from a version-managed agent's home directory.
2930
+ */
2931
+ export function listInstalledMcpsWithScope(agentId, cwd = process.cwd(), options) {
2932
+ const results = [];
2933
+ // Helper to build full command string
2934
+ const buildCommand = (config) => {
2935
+ if (config.command && config.args?.length) {
2936
+ return `${config.command} ${config.args.join(' ')}`;
2937
+ }
2938
+ return config.command || (config.args ? config.args.join(' ') : undefined);
2939
+ };
2940
+ // User-scoped MCPs (version-aware when home is provided)
2941
+ const userConfigPath = options?.home
2942
+ ? getMcpConfigPathForHome(agentId, options.home)
2943
+ : getUserMcpConfigPath(agentId);
2944
+ const userMcps = parseMcpConfig(agentId, userConfigPath);
2945
+ for (const [name, config] of Object.entries(userMcps)) {
2946
+ results.push({
2947
+ name,
2948
+ scope: 'user',
2949
+ command: buildCommand(config),
2950
+ version: config.args ? extractNpmVersion(config.args) : undefined,
2951
+ });
2952
+ }
2953
+ // Project-scoped MCPs
2954
+ const projectConfigPath = getProjectMcpConfigPath(agentId, cwd);
2955
+ const projectMcps = parseMcpConfig(agentId, projectConfigPath);
2956
+ for (const [name, config] of Object.entries(projectMcps)) {
2957
+ // Skip if already in user scope (project can override, but we show both)
2958
+ results.push({
2959
+ name,
2960
+ scope: 'project',
2961
+ command: buildCommand(config),
2962
+ version: config.args ? extractNpmVersion(config.args) : undefined,
2963
+ });
2964
+ }
2965
+ return results;
2966
+ }
2967
+ /** Map of agent name aliases and shorthand identifiers to canonical AgentId values. */
2968
+ export const AGENT_NAME_ALIASES = {
2969
+ claude: 'claude',
2970
+ 'claude-code': 'claude',
2971
+ cc: 'claude',
2972
+ codex: 'codex',
2973
+ 'openai-codex': 'codex',
2974
+ cx: 'codex',
2975
+ gemini: 'gemini',
2976
+ 'gemini-cli': 'gemini',
2977
+ gx: 'gemini',
2978
+ cursor: 'cursor',
2979
+ 'cursor-agent': 'cursor',
2980
+ cr: 'cursor',
2981
+ opencode: 'opencode',
2982
+ oc: 'opencode',
2983
+ openclaw: 'openclaw',
2984
+ claw: 'openclaw',
2985
+ ocl: 'openclaw',
2986
+ copilot: 'copilot',
2987
+ 'copilot-cli': 'copilot',
2988
+ 'github-copilot': 'copilot',
2989
+ gh: 'copilot',
2990
+ amp: 'amp',
2991
+ sourcegraph: 'amp',
2992
+ kiro: 'kiro',
2993
+ 'kiro-cli': 'kiro',
2994
+ goose: 'goose',
2995
+ 'block-goose': 'goose',
2996
+ antigravity: 'antigravity',
2997
+ 'google-antigravity': 'antigravity',
2998
+ agy: 'antigravity',
2999
+ ag: 'antigravity',
3000
+ grok: 'grok',
3001
+ 'grok-build': 'grok',
3002
+ 'xai-grok': 'grok',
3003
+ gk: 'grok',
3004
+ kimi: 'kimi',
3005
+ 'kimi-code': 'kimi',
3006
+ factory: 'droid',
3007
+ 'factory-ai': 'droid',
3008
+ droid: 'droid',
3009
+ hermes: 'hermes',
3010
+ 'hermes-agent': 'hermes',
3011
+ muse: 'muse',
3012
+ 'muse-code': 'muse',
3013
+ 'muse-spark': 'muse',
3014
+ 'meta-muse': 'muse',
3015
+ warp: 'warp',
3016
+ oz: 'warp',
3017
+ 'warp-agent': 'warp',
3018
+ 'warp-cli': 'warp',
3019
+ };
3020
+ /**
3021
+ * Resolve a user-provided agent name (alias, shorthand, or canonical) to its AgentId.
3022
+ * Tolerates a single typo (insertion/deletion/substitution/transposition) against
3023
+ * canonical ids and aliases — `cladue` -> claude, `kim` -> kimi, `codx` -> codex —
3024
+ * but only when the correction is unambiguous (all distance-1 candidates agree on
3025
+ * one agent). Two-letter shorthands are excluded as fuzzy candidates.
3026
+ */
3027
+ export function resolveAgentName(input) {
3028
+ const lower = input.toLowerCase();
3029
+ const exact = AGENT_NAME_ALIASES[lower] ?? (AGENTS[lower] ? lower : null);
3030
+ if (exact || lower.length < 3)
3031
+ return exact;
3032
+ const hits = new Set();
3033
+ for (const id of ALL_AGENT_IDS) {
3034
+ if (damerauLevenshtein(lower, id) === 1)
3035
+ hits.add(id);
3036
+ }
3037
+ for (const [key, id] of Object.entries(AGENT_NAME_ALIASES)) {
3038
+ if (key.length >= 3 && damerauLevenshtein(lower, key) === 1)
3039
+ hits.add(id);
3040
+ }
3041
+ return hits.size === 1 ? hits.values().next().value : null;
3042
+ }
3043
+ /** Check whether the input string matches any known agent name or alias. */
3044
+ export function isAgentName(input) {
3045
+ return resolveAgentName(input) !== null;
3046
+ }
3047
+ /**
3048
+ * Split a CLI-provided `<agent>[@<version>]` spec into its bare agent id and
3049
+ * optional exact version token, the same way `agents run` does
3050
+ * (commands/exec.ts parses its positional with a plain `split('@')`) — NOT the
3051
+ * agent-spec qualifier engine (`@latest`/`@all`), which enumerates installed
3052
+ * versions for read/diagnostic commands, not a single launch target. Returns an
3053
+ * error message rather than throwing so callers decide exit-vs-continue.
3054
+ */
3055
+ export function parseAgentVersionSpec(raw) {
3056
+ const parts = raw.split('@');
3057
+ if (parts.length > 2) {
3058
+ return { error: `Invalid agent spec '${raw}': at most one '@version' is allowed` };
3059
+ }
3060
+ const [rawAgent, rawVersion] = parts;
3061
+ const agent = resolveAgentName(rawAgent);
3062
+ if (!agent) {
3063
+ return { error: `Unknown agent, profile, or workflow: ${rawAgent}. See \`agents list\` for the installed harnesses.` };
3064
+ }
3065
+ if (rawVersion !== undefined && (rawVersion === '' || !VERSION_RE.test(rawVersion))) {
3066
+ return { error: `Invalid version '${rawVersion}' in '${raw}'` };
3067
+ }
3068
+ return { agent, ...(rawVersion ? { version: rawVersion } : {}) };
3069
+ }
3070
+ /**
3071
+ * Build the deprecation notice lines for an agent, or null if it isn't
3072
+ * deprecated. Split from the printer so tests can assert the content without
3073
+ * capturing stdout. Lines are plain (uncolored) text.
3074
+ */
3075
+ export function deprecationNotice(agent) {
3076
+ const dep = AGENTS[agent].deprecated;
3077
+ if (!dep)
3078
+ return null;
3079
+ const name = AGENTS[agent].name;
3080
+ const lines = [
3081
+ `Warning: ${name} was deprecated by ${dep.by} (${dep.date}).`,
3082
+ ` ${dep.reason}`,
3083
+ ];
3084
+ if (dep.replacement) {
3085
+ const rep = AGENTS[dep.replacement];
3086
+ lines.push(` Consider using ${rep.name} instead: agents add ${rep.id}`);
3087
+ }
3088
+ if (dep.url)
3089
+ lines.push(` ${dep.url}`);
3090
+ return lines;
3091
+ }
3092
+ export function hardDeprecationNotice(agent) {
3093
+ const dep = AGENTS[agent].deprecated;
3094
+ if (!dep?.hard)
3095
+ return null;
3096
+ const name = AGENTS[agent].name;
3097
+ const lines = [
3098
+ `${name} is no longer supported by agents-cli because ${dep.by} retired it (${dep.date}).`,
3099
+ ` ${dep.reason}`,
3100
+ ];
3101
+ if (dep.replacement) {
3102
+ const rep = AGENTS[dep.replacement];
3103
+ lines.push(` Use ${rep.name} instead: agents add ${rep.id}`);
3104
+ }
3105
+ if (dep.url)
3106
+ lines.push(` ${dep.url}`);
3107
+ return lines;
3108
+ }
3109
+ export function hardDeprecationError(agent) {
3110
+ return hardDeprecationNotice(agent)?.join('\n') ?? `${AGENTS[agent].name} is no longer supported.`;
3111
+ }
3112
+ /**
3113
+ * Print a deprecation warning (yellow) if the agent's registry entry carries a
3114
+ * `deprecated` marker; no-op otherwise. Call from any user entry point that
3115
+ * acts on a chosen agent — install (`agents add`) and `agents teams add`.
3116
+ */
3117
+ export function warnAgentDeprecated(agent) {
3118
+ const lines = deprecationNotice(agent);
3119
+ if (!lines)
3120
+ return;
3121
+ for (const line of lines)
3122
+ console.log(chalk.yellow(line));
3123
+ }
3124
+ /** Format an error message for an unrecognized agent name, listing valid options. */
3125
+ export function formatAgentError(agentName, validAgents = ALL_AGENT_IDS) {
3126
+ return `Unknown agent '${agentName}'. Valid agents: ${validAgents.join(', ')}`;
3127
+ }