@phnx-labs/agents-cli 1.22.40 → 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.
- package/CHANGELOG.md +36 -0
- package/README.md +14 -3
- package/dist/bin/agents +0 -0
- package/dist/bootstrap.js +0 -1
- package/dist/cli/command-registry.d.ts +2 -0
- package/dist/cli/command-registry.js +5 -2
- package/dist/commands/accounts.d.ts +29 -0
- package/dist/commands/accounts.js +220 -29
- package/dist/commands/alias.js +1 -1
- package/dist/commands/auth.d.ts +9 -0
- package/dist/commands/auth.js +104 -0
- package/dist/commands/browser-sessions-picker.js +42 -62
- package/dist/commands/browser.d.ts +15 -0
- package/dist/commands/browser.js +145 -78
- package/dist/commands/computer-actions.d.ts +1 -1
- package/dist/commands/computer-actions.js +1 -1
- package/dist/commands/computer-sessions-picker.js +32 -55
- package/dist/commands/computer.d.ts +1 -1
- package/dist/commands/computer.js +2 -2
- package/dist/commands/doctor.d.ts +1 -1
- package/dist/commands/doctor.js +3 -3
- package/dist/commands/feed.js +4 -12
- package/dist/commands/hooks.js +1 -1
- package/dist/commands/import.js +1 -1
- package/dist/commands/insights.js +163 -85
- package/dist/commands/inspect.js +2 -2
- package/dist/commands/mine.js +1 -1
- package/dist/commands/org.d.ts +6 -0
- package/dist/commands/org.js +174 -0
- package/dist/commands/packages.js +1 -1
- package/dist/commands/prune.js +1 -1
- package/dist/commands/routines.js +1 -1
- package/dist/commands/run-account-picker.d.ts +19 -0
- package/dist/commands/run-account-picker.js +81 -0
- package/dist/commands/secrets.js +87 -97
- package/dist/commands/sessions-picker-factory.d.ts +40 -0
- package/dist/commands/sessions-picker-factory.js +67 -0
- package/dist/commands/sessions-picker.d.ts +1 -0
- package/dist/commands/sessions-picker.js +27 -5
- package/dist/commands/sessions-share.d.ts +25 -0
- package/dist/commands/sessions-share.js +166 -0
- package/dist/commands/sessions.js +2 -0
- package/dist/commands/setup-browser.js +5 -5
- package/dist/commands/setup-mine.js +1 -1
- package/dist/commands/setup-preferences.js +4 -4
- package/dist/commands/setup.js +5 -3
- package/dist/commands/sync.js +1 -1
- package/dist/commands/versions.js +1 -1
- package/dist/commands/view.js +1 -1
- package/dist/commands/webhook.js +14 -9
- package/dist/lib/account-capabilities.d.ts +10 -0
- package/dist/lib/account-capabilities.js +34 -0
- package/dist/lib/agent-spec/agents.d.ts +584 -0
- package/dist/lib/agent-spec/agents.js +3127 -0
- package/dist/lib/agent-spec/provider.js +1 -1
- package/dist/lib/agents.d.ts +2 -584
- package/dist/lib/agents.js +2 -3127
- package/dist/lib/browser/cdp.js +4 -0
- package/dist/lib/browser/chrome.js +12 -1
- package/dist/lib/browser/drivers/local.d.ts +8 -2
- package/dist/lib/browser/drivers/local.js +8 -2
- package/dist/lib/browser/drivers/ssh.d.ts +8 -2
- package/dist/lib/browser/drivers/ssh.js +11 -4
- package/dist/lib/browser/hygiene.d.ts +1 -1
- package/dist/lib/browser/hygiene.js +5 -1
- package/dist/lib/browser/profiles.d.ts +77 -4
- package/dist/lib/browser/profiles.js +121 -12
- package/dist/lib/browser/runtime-state.d.ts +11 -5
- package/dist/lib/browser/runtime-state.js +13 -9
- package/dist/lib/browser/service.d.ts +91 -30
- package/dist/lib/browser/service.js +342 -207
- package/dist/lib/browser/types.d.ts +96 -4
- package/dist/lib/browser/types.js +51 -0
- package/dist/lib/catchup.js +1 -1
- package/dist/lib/computer/actions.d.ts +1 -1
- package/dist/lib/computer/actions.js +1 -1
- package/dist/lib/{computer-rpc.js → computer/computer-rpc.js} +5 -5
- package/dist/lib/computer/dispatch.d.ts +1 -1
- package/dist/lib/computer/download.d.ts +1 -1
- package/dist/lib/computer/download.js +3 -3
- package/dist/lib/{ssh-tunnel.d.ts → computer/ssh-tunnel.d.ts} +1 -1
- package/dist/lib/{ssh-tunnel.js → computer/ssh-tunnel.js} +12 -12
- package/dist/lib/daemon/daemon.js +1 -1
- package/dist/lib/{runner.d.ts → daemon/runner.d.ts} +10 -10
- package/dist/lib/{runner.js → daemon/runner.js} +59 -118
- package/dist/lib/daemon-webhooks.d.ts +3 -1
- package/dist/lib/daemon-webhooks.js +12 -7
- package/dist/lib/device-config.js +1 -1
- package/dist/lib/devices/doctor-findings.d.ts +1 -1
- package/dist/lib/devices/doctor-findings.js +3 -3
- package/dist/lib/devices/fleet-inventory.js +1 -1
- package/dist/lib/devices/harness-inventory.js +1 -1
- package/dist/lib/devices/registry.js +4 -22
- package/dist/lib/doctor-diff.d.ts +1 -1
- package/dist/lib/doctor-diff.js +1 -1
- package/dist/lib/drift.js +1 -1
- package/dist/lib/entitlement.d.ts +31 -0
- package/dist/lib/entitlement.js +137 -0
- package/dist/lib/exec.js +39 -202
- package/dist/lib/feed/feed.js +2 -8
- package/dist/lib/fs-atomic.d.ts +7 -0
- package/dist/lib/fs-atomic.js +9 -0
- package/dist/lib/harness/adapter.d.ts +160 -0
- package/dist/lib/harness/adapter.js +47 -0
- package/dist/lib/harness/adapters/claude.d.ts +2 -0
- package/dist/lib/harness/adapters/claude.js +110 -0
- package/dist/lib/harness/adapters/codex.d.ts +2 -0
- package/dist/lib/harness/adapters/codex.js +83 -0
- package/dist/lib/harness/adapters/copilot.d.ts +2 -0
- package/dist/lib/harness/adapters/copilot.js +23 -0
- package/dist/lib/harness/adapters/cursor.d.ts +2 -0
- package/dist/lib/harness/adapters/cursor.js +38 -0
- package/dist/lib/harness/adapters/droid.d.ts +2 -0
- package/dist/lib/harness/adapters/droid.js +17 -0
- package/dist/lib/harness/adapters/grok.d.ts +2 -0
- package/dist/lib/harness/adapters/grok.js +14 -0
- package/dist/lib/harness/adapters/kimi.d.ts +2 -0
- package/dist/lib/harness/adapters/kimi.js +49 -0
- package/dist/lib/harness/adapters/muse.d.ts +2 -0
- package/dist/lib/harness/adapters/muse.js +44 -0
- package/dist/lib/harness/adapters/opencode.d.ts +2 -0
- package/dist/lib/harness/adapters/opencode.js +14 -0
- package/dist/lib/harness/exec-config-version.d.ts +8 -0
- package/dist/lib/harness/exec-config-version.js +22 -0
- package/dist/lib/harness/index.d.ts +1 -0
- package/dist/lib/harness/index.js +34 -0
- package/dist/lib/{hooks.d.ts → hooks/install.d.ts} +1 -1
- package/dist/lib/{hooks.js → hooks/install.js} +11 -11
- package/dist/lib/import.js +1 -1
- package/dist/lib/installations/index.d.ts +4 -4
- package/dist/lib/installations/index.js +3 -4
- package/dist/lib/installations/migrate.js +6 -6
- package/dist/lib/{shims.d.ts → installations/shims.d.ts} +2 -2
- package/dist/lib/{shims.js → installations/shims.js} +12 -114
- package/dist/lib/installations/store.d.ts +141 -3
- package/dist/lib/installations/store.js +475 -5
- package/dist/lib/installations/versions.d.ts +1 -126
- package/dist/lib/installations/versions.js +5 -465
- package/dist/lib/isolation-boundary-report.d.ts +1 -1
- package/dist/lib/isolation-boundary-report.js +1 -1
- package/dist/lib/mailbox-gc.js +2 -3
- package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- package/dist/lib/monitors/dispatch.d.ts +1 -1
- package/dist/lib/monitors/dispatch.js +2 -2
- package/dist/lib/monitors/state.d.ts +1 -1
- package/dist/lib/observe-aliases.d.ts +2 -2
- package/dist/lib/observe-aliases.js +2 -11
- package/dist/lib/plugins/plugins.js +1 -1
- package/dist/lib/plugins/skills.js +1 -1
- package/dist/lib/prix-account.d.ts +158 -0
- package/dist/lib/prix-account.js +214 -0
- package/dist/lib/project-key.js +7 -0
- package/dist/lib/project-launch.js +1 -1
- package/dist/lib/refresh.js +2 -2
- package/dist/lib/resource-inventory.d.ts +1 -1
- package/dist/lib/resource-inventory.js +1 -1
- package/dist/lib/rules/rules.js +1 -1
- package/dist/lib/scheduling/routines.js +1 -1
- package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
- package/dist/lib/secrets/bundles.d.ts +1 -1
- package/dist/lib/secrets/bundles.js +1 -1
- package/dist/lib/secrets/headless.d.ts +16 -0
- package/dist/lib/secrets/headless.js +21 -0
- package/dist/lib/secrets/remote.d.ts +9 -7
- package/dist/lib/secrets/remote.js +18 -9
- package/dist/lib/self-heal/checks/hook-runtime.js +1 -1
- package/dist/lib/self-heal/checks/path.js +1 -1
- package/dist/lib/self-heal/checks/shadowing.js +1 -1
- package/dist/lib/self-heal/checks/shims.js +1 -1
- package/dist/lib/session/discover.d.ts +39 -0
- package/dist/lib/session/discover.js +3 -3
- package/dist/lib/session/parse.js +24 -42
- package/dist/lib/session/recovery.js +1 -1
- package/dist/lib/session/share-html.d.ts +55 -0
- package/dist/lib/session/share-html.js +319 -0
- package/dist/lib/settings-manifest.d.ts +2 -0
- package/dist/lib/settings-manifest.js +81 -3
- package/dist/lib/share/publish.d.ts +38 -0
- package/dist/lib/share/publish.js +81 -8
- package/dist/lib/staleness/prune.d.ts +1 -1
- package/dist/lib/staleness/prune.js +1 -1
- package/dist/lib/staleness/writers/hooks.js +1 -1
- package/dist/lib/startup/command-registry.d.ts +2 -1
- package/dist/lib/startup/command-registry.js +5 -3
- package/dist/lib/teams/agents.js +2 -19
- package/dist/lib/teams/parsers.js +20 -26
- package/dist/lib/teams/registry.js +2 -21
- package/dist/lib/triggers/handlers.d.ts +37 -2
- package/dist/lib/triggers/handlers.js +57 -6
- package/dist/lib/triggers/webhook.d.ts +80 -6
- package/dist/lib/triggers/webhook.js +128 -4
- package/dist/lib/types.d.ts +1 -1
- package/dist/lib/uninstall.js +1 -1
- package/dist/lib/version.d.ts +9 -2
- package/dist/lib/version.js +11 -9
- package/dist/lib/wrap.d.ts +33 -0
- package/dist/lib/wrap.js +70 -0
- package/package.json +1 -1
- /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
|
+
}
|