@phnx-labs/agents-cli 1.20.66 → 1.20.68
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 +88 -0
- package/README.md +3 -0
- package/dist/bin/agents +0 -0
- package/dist/commands/check.js +96 -2
- package/dist/commands/doctor.js +37 -9
- package/dist/commands/exec.js +45 -1
- package/dist/commands/plugins.js +11 -2
- package/dist/commands/repo.js +47 -2
- package/dist/commands/ssh.js +103 -2
- package/dist/commands/teams.d.ts +11 -0
- package/dist/commands/teams.js +40 -2
- package/dist/commands/view.d.ts +1 -0
- package/dist/commands/view.js +12 -8
- package/dist/lib/agents.d.ts +11 -5
- package/dist/lib/agents.js +35 -24
- package/dist/lib/browser/profiles.js +4 -25
- package/dist/lib/cli-entry.d.ts +23 -0
- package/dist/lib/cli-entry.js +116 -0
- package/dist/lib/daemon.d.ts +2 -2
- package/dist/lib/daemon.js +8 -89
- package/dist/lib/devices/fleet.d.ts +23 -0
- package/dist/lib/devices/fleet.js +38 -0
- package/dist/lib/devices/health-report.d.ts +46 -0
- package/dist/lib/devices/health-report.js +159 -0
- package/dist/lib/devices/health.d.ts +14 -4
- package/dist/lib/devices/health.js +49 -8
- package/dist/lib/exec.d.ts +15 -0
- package/dist/lib/exec.js +31 -6
- package/dist/lib/git.d.ts +10 -0
- package/dist/lib/git.js +23 -0
- package/dist/lib/hosts/option.js +1 -1
- package/dist/lib/hosts/ready.js +5 -3
- package/dist/lib/hosts/remote-cmd.d.ts +12 -0
- package/dist/lib/hosts/remote-cmd.js +17 -1
- package/dist/lib/mcp.d.ts +21 -0
- package/dist/lib/mcp.js +278 -13
- package/dist/lib/resources/mcp.js +20 -342
- package/dist/lib/routines.d.ts +13 -0
- package/dist/lib/routines.js +21 -0
- package/dist/lib/runner.js +24 -47
- package/dist/lib/secrets/agent.js +11 -15
- package/dist/lib/share/capture.js +21 -3
- package/dist/lib/signin-badge.d.ts +41 -0
- package/dist/lib/signin-badge.js +64 -0
- package/dist/lib/ssh-exec.d.ts +5 -0
- package/dist/lib/ssh-exec.js +55 -1
- package/dist/lib/types.d.ts +10 -0
- package/dist/lib/usage.d.ts +17 -3
- package/dist/lib/usage.js +63 -16
- package/package.json +1 -1
package/dist/lib/exec.js
CHANGED
|
@@ -104,6 +104,28 @@ export function resolveMode(agent, requested) {
|
|
|
104
104
|
}
|
|
105
105
|
throw new Error(`${agent} does not support '${requested}' mode. Supported modes: ${supported.join(', ')}.`);
|
|
106
106
|
}
|
|
107
|
+
/**
|
|
108
|
+
* Resolve a requested mode for a run, honoring whether the run is HEADLESS.
|
|
109
|
+
*
|
|
110
|
+
* Wraps resolveMode with one extra rule: an agent may list `plan` in its modes
|
|
111
|
+
* (so interactive plan works) yet declare `capabilities.headlessPlan === false`
|
|
112
|
+
* because plan is broken in a headless `--prompt`/`-p` run — kimi refuses
|
|
113
|
+
* `--prompt` + `--plan`, and grok's `--permission-mode plan` silently stalls at
|
|
114
|
+
* its ExitPlanMode gate. For those agents, a headless plan request degrades to
|
|
115
|
+
* `auto` (kimi -p auto-runs; grok maps auto→edit via resolveMode) with a visible
|
|
116
|
+
* one-line stderr warning, mirroring the graceful plan→edit degrade cursor and
|
|
117
|
+
* antigravity get for having no plan flag at all. Interactive runs are never
|
|
118
|
+
* downgraded. This is the single source of truth shared by buildExecCommand
|
|
119
|
+
* (agents run / teams) and the routine runner.
|
|
120
|
+
*/
|
|
121
|
+
export function resolveHeadlessMode(agent, requested, interactive) {
|
|
122
|
+
const mode = resolveMode(agent, requested);
|
|
123
|
+
if (!interactive && mode === 'plan' && AGENTS[agent].capabilities.headlessPlan === false) {
|
|
124
|
+
process.stderr.write(`warning: ${agent} has no headless plan mode; running --mode auto instead\n`);
|
|
125
|
+
return resolveMode(agent, 'auto');
|
|
126
|
+
}
|
|
127
|
+
return mode;
|
|
128
|
+
}
|
|
107
129
|
/**
|
|
108
130
|
* The mode an agent should run in when the caller has no preference.
|
|
109
131
|
*
|
|
@@ -573,9 +595,11 @@ export function buildExecCommand(options) {
|
|
|
573
595
|
// Resolve the requested mode against the agent's capability table.
|
|
574
596
|
// - `auto` on an agent without auto support → silently degrades to `edit`
|
|
575
597
|
// - `plan` on an agent without a read-only mode → degrades to modes[0]
|
|
598
|
+
// - headless `plan` on an agent with headlessPlan:false (kimi, grok) →
|
|
599
|
+
// degrades to `auto` with a stderr warning (see resolveHeadlessMode)
|
|
576
600
|
// - `skip` on an unsupported agent → throws a clear error
|
|
577
|
-
// After
|
|
578
|
-
const resolvedMode =
|
|
601
|
+
// After resolution, the chosen mode is guaranteed to be in template.modeFlags.
|
|
602
|
+
const resolvedMode = resolveHeadlessMode(options.agent, normalizeMode(options.mode), interactive);
|
|
579
603
|
const modeFlags = template.modeFlags[resolvedMode];
|
|
580
604
|
if (!modeFlags) {
|
|
581
605
|
// Defense in depth: would only fire if AGENTS.capabilities.modes and
|
|
@@ -609,11 +633,12 @@ export function buildExecCommand(options) {
|
|
|
609
633
|
// all abort with "Cannot combine --prompt with --X" (verified against the live
|
|
610
634
|
// kimi CLI). The write-capable modes (edit/auto/skip) all collapse to kimi's
|
|
611
635
|
// default `-p` behavior, which already auto-approves tool calls, so we emit no
|
|
612
|
-
// mode flag. Plan
|
|
613
|
-
//
|
|
636
|
+
// mode flag. Plan can't reach here headless — resolveHeadlessMode already
|
|
637
|
+
// downgraded it to auto (kimi's headlessPlan:false); this asserts that
|
|
638
|
+
// invariant so a plan-mode run can never silently mutate the workspace.
|
|
614
639
|
if (resolvedMode === 'plan') {
|
|
615
|
-
throw new Error(
|
|
616
|
-
|
|
640
|
+
throw new Error(`Internal error: kimi reached headless command build with resolved mode 'plan'; ` +
|
|
641
|
+
`resolveHeadlessMode should have downgraded it to auto (capabilities.headlessPlan is false).`);
|
|
617
642
|
}
|
|
618
643
|
// edit/auto/skip: emit no mode flag — `kimi -p` auto-runs.
|
|
619
644
|
}
|
package/dist/lib/git.d.ts
CHANGED
|
@@ -95,6 +95,16 @@ export declare function getGitHubUsername(): Promise<string | null>;
|
|
|
95
95
|
* Get the remote URL for origin in a git repo.
|
|
96
96
|
*/
|
|
97
97
|
export declare function getRemoteUrl(repoPath: string): Promise<string | null>;
|
|
98
|
+
/**
|
|
99
|
+
* Canonical `host/owner/repo` form of a git remote, transport-agnostic, so the
|
|
100
|
+
* same repo cloned over SSH vs HTTPS compares equal. Strips protocol, any
|
|
101
|
+
* `user@`, a trailing `.git`, and folds the scp-style `host:owner/repo` colon to
|
|
102
|
+
* a slash. Lower-cased. Used to decide whether an existing checkout is "the same
|
|
103
|
+
* repo" as a requested source before adopting it.
|
|
104
|
+
*/
|
|
105
|
+
export declare function canonicalGitRemote(url: string): string;
|
|
106
|
+
/** True when two git remote URLs point at the same repo across transport forms. */
|
|
107
|
+
export declare function sameGitRemote(a: string | null | undefined, b: string | null | undefined): boolean;
|
|
98
108
|
/**
|
|
99
109
|
* Set the remote URL for origin in a git repo.
|
|
100
110
|
*/
|
package/dist/lib/git.js
CHANGED
|
@@ -365,6 +365,29 @@ export async function getRemoteUrl(repoPath) {
|
|
|
365
365
|
return null;
|
|
366
366
|
}
|
|
367
367
|
}
|
|
368
|
+
/**
|
|
369
|
+
* Canonical `host/owner/repo` form of a git remote, transport-agnostic, so the
|
|
370
|
+
* same repo cloned over SSH vs HTTPS compares equal. Strips protocol, any
|
|
371
|
+
* `user@`, a trailing `.git`, and folds the scp-style `host:owner/repo` colon to
|
|
372
|
+
* a slash. Lower-cased. Used to decide whether an existing checkout is "the same
|
|
373
|
+
* repo" as a requested source before adopting it.
|
|
374
|
+
*/
|
|
375
|
+
export function canonicalGitRemote(url) {
|
|
376
|
+
return url
|
|
377
|
+
.trim()
|
|
378
|
+
.replace(/\/+$/, '') // trailing slashes first, so a trailing-slash-after-.git still strips
|
|
379
|
+
.replace(/\.git$/i, '')
|
|
380
|
+
.replace(/^[a-z][a-z0-9+.-]*:\/\//i, '') // strip scheme (https://, ssh://, git://)
|
|
381
|
+
.replace(/^[^@/]+@/, '') // strip user@ (git@, ssh user)
|
|
382
|
+
.replace(':', '/') // scp-style host:owner/repo → host/owner/repo (first colon only)
|
|
383
|
+
.toLowerCase();
|
|
384
|
+
}
|
|
385
|
+
/** True when two git remote URLs point at the same repo across transport forms. */
|
|
386
|
+
export function sameGitRemote(a, b) {
|
|
387
|
+
if (!a || !b)
|
|
388
|
+
return false;
|
|
389
|
+
return canonicalGitRemote(a) === canonicalGitRemote(b);
|
|
390
|
+
}
|
|
368
391
|
/**
|
|
369
392
|
* Set the remote URL for origin in a git repo.
|
|
370
393
|
*/
|
package/dist/lib/hosts/option.js
CHANGED
|
@@ -14,7 +14,7 @@ export function addHostOption(cmd) {
|
|
|
14
14
|
return cmd
|
|
15
15
|
.option('-H, --host <name>', 'Run this command on another machine over SSH instead of locally — a device, a registered host, or user@host. See `agents devices` / `agents hosts`.')
|
|
16
16
|
.option('--device <name>', 'Alias of --host: run this command on a registered device (from `agents devices`).')
|
|
17
|
-
.option('--remote-cwd <dir>',
|
|
17
|
+
.option('--remote-cwd <dir>', "Working directory on the host for --host runs. Resolves on the REMOTE host — pass a '$HOME'-relative path (single-quoted so your local shell doesn't expand it) or a valid remote absolute path; a local ~ expands here and won't exist there (/Users/you vs /home/you). No effect on 'teams add'.")
|
|
18
18
|
.option('--no-tty', 'Force non-interactive output for --host runs even from a terminal.')
|
|
19
19
|
.option('--any', 'With --host <cap> (a capability tag), pick any matching host instead of erroring when several match.');
|
|
20
20
|
}
|
package/dist/lib/hosts/ready.js
CHANGED
|
@@ -10,7 +10,7 @@ import * as path from 'path';
|
|
|
10
10
|
import { fileURLToPath } from 'url';
|
|
11
11
|
import { sshExec, shellQuote } from '../ssh-exec.js';
|
|
12
12
|
import { sshTargetFor } from './types.js';
|
|
13
|
-
import { remoteShellFor, buildWindowsAgentsCommand, encodePowershell, powershellQuote } from './remote-cmd.js';
|
|
13
|
+
import { remoteShellFor, buildWindowsAgentsCommand, encodePowershell, powershellQuote, POWERSHELL_PROGRESS_SILENCE } from './remote-cmd.js';
|
|
14
14
|
import { resolveRemoteOsSync } from './remote-os.js';
|
|
15
15
|
/** Resolve this CLI's own version by walking up to the nearest package.json. */
|
|
16
16
|
export function localCliVersion() {
|
|
@@ -75,7 +75,8 @@ export function remoteAgentsVersion(target, os) {
|
|
|
75
75
|
* equivalents (`Select-Object -Last`, `Test-Path`). Pure/exported. */
|
|
76
76
|
export function buildBootstrapCommand(spec, os) {
|
|
77
77
|
if (remoteShellFor(os) === 'powershell') {
|
|
78
|
-
const script =
|
|
78
|
+
const script = `${POWERSHELL_PROGRESS_SILENCE}; ` +
|
|
79
|
+
`npm install -g ${powershellQuote(spec)} 2>&1 | Select-Object -Last 3; ` +
|
|
79
80
|
`if (-not (Test-Path "$HOME/.agents/.system")) { agents setup 2>&1 | Select-Object -Last 3 }; ` +
|
|
80
81
|
`agents --version`;
|
|
81
82
|
return `powershell -NoProfile -EncodedCommand ${encodePowershell(script)}`;
|
|
@@ -110,7 +111,8 @@ const READY_MARKER = '@@AGENTS_READY@@';
|
|
|
110
111
|
*/
|
|
111
112
|
export function buildReadyProbeCommand(os) {
|
|
112
113
|
if (remoteShellFor(os) === 'powershell') {
|
|
113
|
-
const script =
|
|
114
|
+
const script = `${POWERSHELL_PROGRESS_SILENCE}; ` +
|
|
115
|
+
`agents --version 2>$null; Write-Output "${READY_MARKER}"; ` +
|
|
114
116
|
`agents view 2>$null; if ($LASTEXITCODE -ne 0) { agents list 2>$null }`;
|
|
115
117
|
return `powershell -NoProfile -EncodedCommand ${encodePowershell(script)}`;
|
|
116
118
|
}
|
|
@@ -117,6 +117,18 @@ export interface WindowsAgentsCommand {
|
|
|
117
117
|
* `& agents …` invokes the CLI from the machine PATH (Windows has no login
|
|
118
118
|
* shell, so there is no `bash -lc` equivalent — the shim is simply on PATH).
|
|
119
119
|
*/
|
|
120
|
+
/**
|
|
121
|
+
* PowerShell prelude that silences the progress stream. PowerShell 5.1 serializes
|
|
122
|
+
* progress records ("Preparing modules for first use.") to CLIXML when stderr is a
|
|
123
|
+
* redirected pipe (an ssh capture, not a console), so a remote `agents …` result
|
|
124
|
+
* otherwise comes back wrapped in a raw `#< CLIXML <Objs …>` blob — unreadable to
|
|
125
|
+
* humans and to the JSON parsers that consume doctor / fleet-status output. Prepend
|
|
126
|
+
* this to EVERY Windows script that invokes `agents` (there is more than one builder
|
|
127
|
+
* in this file). Verified against a live win-mini (PowerShell 5.1): this clears it.
|
|
128
|
+
* (`-OutputFormat Text` does NOT — it governs success-stream object serialization,
|
|
129
|
+
* not the progress stream.)
|
|
130
|
+
*/
|
|
131
|
+
export declare const POWERSHELL_PROGRESS_SILENCE = "$ProgressPreference = 'SilentlyContinue'";
|
|
120
132
|
export declare function windowsAgentsScript(cmd: WindowsAgentsCommand): string;
|
|
121
133
|
/**
|
|
122
134
|
* Build the `ssh <target> <cmd>` string for one `agents …` invocation on a
|
|
@@ -110,6 +110,7 @@ export const RUN_OPTION_FORWARDING = {
|
|
|
110
110
|
lease: 'local-only',
|
|
111
111
|
keepBox: 'local-only',
|
|
112
112
|
copyCreds: 'local-only', // copies creds TO the host before dispatch — local concern only
|
|
113
|
+
authCheck: 'local-only', // --no-auth-check gates the local interactive login preflight; --host runs skip that preflight entirely
|
|
113
114
|
};
|
|
114
115
|
/** Actionable messages for value-aware rejections, keyed by attribute name. */
|
|
115
116
|
export const RUN_OPTION_REJECT_MESSAGES = {
|
|
@@ -190,9 +191,21 @@ export function decodePowershell(encoded) {
|
|
|
190
191
|
* `& agents …` invokes the CLI from the machine PATH (Windows has no login
|
|
191
192
|
* shell, so there is no `bash -lc` equivalent — the shim is simply on PATH).
|
|
192
193
|
*/
|
|
194
|
+
/**
|
|
195
|
+
* PowerShell prelude that silences the progress stream. PowerShell 5.1 serializes
|
|
196
|
+
* progress records ("Preparing modules for first use.") to CLIXML when stderr is a
|
|
197
|
+
* redirected pipe (an ssh capture, not a console), so a remote `agents …` result
|
|
198
|
+
* otherwise comes back wrapped in a raw `#< CLIXML <Objs …>` blob — unreadable to
|
|
199
|
+
* humans and to the JSON parsers that consume doctor / fleet-status output. Prepend
|
|
200
|
+
* this to EVERY Windows script that invokes `agents` (there is more than one builder
|
|
201
|
+
* in this file). Verified against a live win-mini (PowerShell 5.1): this clears it.
|
|
202
|
+
* (`-OutputFormat Text` does NOT — it governs success-stream object serialization,
|
|
203
|
+
* not the progress stream.)
|
|
204
|
+
*/
|
|
205
|
+
export const POWERSHELL_PROGRESS_SILENCE = "$ProgressPreference = 'SilentlyContinue'";
|
|
193
206
|
export function windowsAgentsScript(cmd) {
|
|
194
207
|
const { args, env, cwd, propagateExit = true } = cmd;
|
|
195
|
-
const parts = [];
|
|
208
|
+
const parts = [POWERSHELL_PROGRESS_SILENCE];
|
|
196
209
|
if (env)
|
|
197
210
|
for (const [k, v] of Object.entries(env))
|
|
198
211
|
parts.push(`$env:${k} = ${powershellQuote(v)}`);
|
|
@@ -232,6 +245,9 @@ export function buildWindowsStdinImportCommand(bundle, opts = {}) {
|
|
|
232
245
|
// the secret-bearing temp file would otherwise be left behind (RUSH-1764). $tmp
|
|
233
246
|
// starts null so a GetTempFileName that itself throws leaves nothing to remove.
|
|
234
247
|
const script = [
|
|
248
|
+
// Same CLIXML guard as windowsAgentsScript — this builder also runs `& agents …`
|
|
249
|
+
// and its raw stderr is printed to the user on failure (secrets export --host <win>).
|
|
250
|
+
POWERSHELL_PROGRESS_SILENCE,
|
|
235
251
|
'$in = [Console]::In.ReadToEnd()',
|
|
236
252
|
'$tmp = $null',
|
|
237
253
|
`try { $tmp = [System.IO.Path]::GetTempFileName(); [System.IO.File]::WriteAllText($tmp, $in); ` +
|
package/dist/lib/mcp.d.ts
CHANGED
|
@@ -108,6 +108,27 @@ export declare function registerMcpCommandToTargets(targets: {
|
|
|
108
108
|
directAgents: AgentId[];
|
|
109
109
|
versionSelections: Map<AgentId, string[]>;
|
|
110
110
|
}, name: string, commandSpec: McpCommandSpec, scope?: 'user' | 'project', transport?: string): Promise<McpTargetOperationResult[]>;
|
|
111
|
+
/**
|
|
112
|
+
* MCP server shaped for direct config-file serialization.
|
|
113
|
+
*/
|
|
114
|
+
export interface WritableMcpServer {
|
|
115
|
+
name: string;
|
|
116
|
+
transport: 'stdio' | 'http' | 'sse';
|
|
117
|
+
command?: string;
|
|
118
|
+
args?: string[];
|
|
119
|
+
env?: Record<string, string>;
|
|
120
|
+
url?: string;
|
|
121
|
+
headers?: Record<string, string>;
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Serialize MCP servers into an agent-specific config file.
|
|
125
|
+
*
|
|
126
|
+
* `mode: 'overwrite'` replaces the whole MCP section (used for tool-managed
|
|
127
|
+
* version-home configs). `mode: 'merge'` updates/adds the provided server
|
|
128
|
+
* entries while preserving existing entries (used for project-level configs
|
|
129
|
+
* that users may hand-edit or populate via agent CLI commands).
|
|
130
|
+
*/
|
|
131
|
+
export declare function writeMcpConfig(agentId: AgentId, configPath: string, servers: WritableMcpServer[], mode?: 'overwrite' | 'merge'): void;
|
|
111
132
|
/**
|
|
112
133
|
* Install MCP servers to an agent.
|
|
113
134
|
* For Claude/Codex: uses CLI commands (claude mcp add, codex mcp add)
|
package/dist/lib/mcp.js
CHANGED
|
@@ -10,12 +10,13 @@
|
|
|
10
10
|
import * as fs from 'fs';
|
|
11
11
|
import * as path from 'path';
|
|
12
12
|
import * as yaml from 'yaml';
|
|
13
|
+
import * as TOML from 'smol-toml';
|
|
13
14
|
import { execFileSync } from 'child_process';
|
|
14
15
|
import * as os from 'os';
|
|
15
16
|
import { getMcpDir, getUserMcpDir, getProjectAgentsDir, getVersionsDir, getUserAgentsDir } from './state.js';
|
|
16
17
|
import { getBinaryPath, getVersionHomePath } from './versions.js';
|
|
17
18
|
import { IS_WINDOWS, execFileShellSpec } from './platform/index.js';
|
|
18
|
-
import { AGENTS } from './agents.js';
|
|
19
|
+
import { AGENTS, getMcpConfigPathForHome, getProjectMcpConfigPath } from './agents.js';
|
|
19
20
|
import { isCapable } from './capabilities.js';
|
|
20
21
|
import { setGeminiAutoUpdateDisabled, updateGeminiSettings } from './gemini-settings.js';
|
|
21
22
|
/**
|
|
@@ -634,6 +635,225 @@ function installMcpToOpenCodeConfig(server, versionHome) {
|
|
|
634
635
|
fs.mkdirSync(path.dirname(configPath), { recursive: true });
|
|
635
636
|
fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8');
|
|
636
637
|
}
|
|
638
|
+
/**
|
|
639
|
+
* Agents whose config file format is implemented by `writeMcpConfig`.
|
|
640
|
+
* Others are intentionally skipped until their schema is added.
|
|
641
|
+
*/
|
|
642
|
+
function writeMcpConfigSupportsAgent(agentId) {
|
|
643
|
+
switch (agentId) {
|
|
644
|
+
case 'claude':
|
|
645
|
+
case 'cursor':
|
|
646
|
+
case 'gemini':
|
|
647
|
+
case 'kimi':
|
|
648
|
+
case 'droid':
|
|
649
|
+
case 'forge':
|
|
650
|
+
case 'openclaw':
|
|
651
|
+
case 'codex':
|
|
652
|
+
case 'grok':
|
|
653
|
+
case 'opencode':
|
|
654
|
+
case 'hermes':
|
|
655
|
+
return true;
|
|
656
|
+
default:
|
|
657
|
+
return false;
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
/**
|
|
661
|
+
* Serialize MCP servers into an agent-specific config file.
|
|
662
|
+
*
|
|
663
|
+
* `mode: 'overwrite'` replaces the whole MCP section (used for tool-managed
|
|
664
|
+
* version-home configs). `mode: 'merge'` updates/adds the provided server
|
|
665
|
+
* entries while preserving existing entries (used for project-level configs
|
|
666
|
+
* that users may hand-edit or populate via agent CLI commands).
|
|
667
|
+
*/
|
|
668
|
+
export function writeMcpConfig(agentId, configPath, servers, mode = 'overwrite') {
|
|
669
|
+
if (servers.length === 0) {
|
|
670
|
+
return;
|
|
671
|
+
}
|
|
672
|
+
switch (agentId) {
|
|
673
|
+
case 'claude':
|
|
674
|
+
case 'cursor':
|
|
675
|
+
case 'gemini':
|
|
676
|
+
case 'kimi':
|
|
677
|
+
case 'droid':
|
|
678
|
+
case 'forge': {
|
|
679
|
+
let config = {};
|
|
680
|
+
if (fs.existsSync(configPath)) {
|
|
681
|
+
try {
|
|
682
|
+
config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
|
683
|
+
}
|
|
684
|
+
catch {
|
|
685
|
+
config = {};
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
const mcpServers = mode === 'merge' && config.mcpServers && typeof config.mcpServers === 'object'
|
|
689
|
+
? { ...config.mcpServers }
|
|
690
|
+
: {};
|
|
691
|
+
for (const server of servers) {
|
|
692
|
+
if (server.transport === 'stdio') {
|
|
693
|
+
mcpServers[server.name] = {
|
|
694
|
+
command: server.command,
|
|
695
|
+
args: server.args || [],
|
|
696
|
+
env: server.env || {},
|
|
697
|
+
};
|
|
698
|
+
}
|
|
699
|
+
else {
|
|
700
|
+
mcpServers[server.name] = {
|
|
701
|
+
url: server.url,
|
|
702
|
+
...(server.headers && { headers: server.headers }),
|
|
703
|
+
};
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
config.mcpServers = mcpServers;
|
|
707
|
+
fs.mkdirSync(path.dirname(configPath), { recursive: true });
|
|
708
|
+
fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8');
|
|
709
|
+
break;
|
|
710
|
+
}
|
|
711
|
+
case 'openclaw': {
|
|
712
|
+
let config = {};
|
|
713
|
+
if (fs.existsSync(configPath)) {
|
|
714
|
+
try {
|
|
715
|
+
config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
|
716
|
+
}
|
|
717
|
+
catch {
|
|
718
|
+
config = {};
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
if (!config.mcp || typeof config.mcp !== 'object') {
|
|
722
|
+
config.mcp = {};
|
|
723
|
+
}
|
|
724
|
+
const mcp = config.mcp;
|
|
725
|
+
const mcpServers = mode === 'merge' && mcp.servers && typeof mcp.servers === 'object'
|
|
726
|
+
? { ...mcp.servers }
|
|
727
|
+
: {};
|
|
728
|
+
for (const server of servers) {
|
|
729
|
+
if (server.transport === 'stdio') {
|
|
730
|
+
mcpServers[server.name] = {
|
|
731
|
+
command: server.command,
|
|
732
|
+
args: server.args || [],
|
|
733
|
+
env: server.env || {},
|
|
734
|
+
};
|
|
735
|
+
}
|
|
736
|
+
else {
|
|
737
|
+
mcpServers[server.name] = {
|
|
738
|
+
url: server.url,
|
|
739
|
+
transport: server.transport,
|
|
740
|
+
...(server.headers && { headers: server.headers }),
|
|
741
|
+
};
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
mcp.servers = mcpServers;
|
|
745
|
+
config.mcp = mcp;
|
|
746
|
+
fs.mkdirSync(path.dirname(configPath), { recursive: true });
|
|
747
|
+
fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8');
|
|
748
|
+
break;
|
|
749
|
+
}
|
|
750
|
+
case 'codex':
|
|
751
|
+
case 'grok': {
|
|
752
|
+
let config = {};
|
|
753
|
+
if (fs.existsSync(configPath)) {
|
|
754
|
+
try {
|
|
755
|
+
config = TOML.parse(fs.readFileSync(configPath, 'utf-8'));
|
|
756
|
+
}
|
|
757
|
+
catch {
|
|
758
|
+
config = {};
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
const mcpServers = mode === 'merge' && config.mcp_servers && typeof config.mcp_servers === 'object'
|
|
762
|
+
? { ...config.mcp_servers }
|
|
763
|
+
: {};
|
|
764
|
+
for (const server of servers) {
|
|
765
|
+
if (server.transport === 'stdio') {
|
|
766
|
+
mcpServers[server.name] = {
|
|
767
|
+
command: server.command,
|
|
768
|
+
args: server.args || [],
|
|
769
|
+
...(server.env && { env: server.env }),
|
|
770
|
+
};
|
|
771
|
+
}
|
|
772
|
+
else {
|
|
773
|
+
mcpServers[server.name] = {
|
|
774
|
+
url: server.url,
|
|
775
|
+
...(server.headers && { headers: server.headers }),
|
|
776
|
+
};
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
config.mcp_servers = mcpServers;
|
|
780
|
+
fs.mkdirSync(path.dirname(configPath), { recursive: true });
|
|
781
|
+
fs.writeFileSync(configPath, TOML.stringify(config), 'utf-8');
|
|
782
|
+
break;
|
|
783
|
+
}
|
|
784
|
+
case 'opencode': {
|
|
785
|
+
let config = {};
|
|
786
|
+
if (fs.existsSync(configPath)) {
|
|
787
|
+
try {
|
|
788
|
+
const content = fs.readFileSync(configPath, 'utf-8');
|
|
789
|
+
const jsonContent = content.replace(/\/\/.*$/gm, '').replace(/\/\*[\s\S]*?\*\//g, '');
|
|
790
|
+
config = JSON.parse(jsonContent);
|
|
791
|
+
}
|
|
792
|
+
catch {
|
|
793
|
+
config = {};
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
const mcp = mode === 'merge' && config.mcp && typeof config.mcp === 'object'
|
|
797
|
+
? { ...config.mcp }
|
|
798
|
+
: {};
|
|
799
|
+
for (const server of servers) {
|
|
800
|
+
if (server.transport === 'stdio') {
|
|
801
|
+
const commandArray = [server.command, ...(server.args || [])];
|
|
802
|
+
mcp[server.name] = {
|
|
803
|
+
type: 'local',
|
|
804
|
+
command: commandArray,
|
|
805
|
+
...(server.env && { env: server.env }),
|
|
806
|
+
};
|
|
807
|
+
}
|
|
808
|
+
else {
|
|
809
|
+
mcp[server.name] = {
|
|
810
|
+
type: 'remote',
|
|
811
|
+
url: server.url,
|
|
812
|
+
};
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
config.mcp = mcp;
|
|
816
|
+
fs.mkdirSync(path.dirname(configPath), { recursive: true });
|
|
817
|
+
fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8');
|
|
818
|
+
break;
|
|
819
|
+
}
|
|
820
|
+
case 'hermes': {
|
|
821
|
+
let config = {};
|
|
822
|
+
if (fs.existsSync(configPath)) {
|
|
823
|
+
try {
|
|
824
|
+
const parsed = yaml.parse(fs.readFileSync(configPath, 'utf-8'));
|
|
825
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
826
|
+
config = parsed;
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
catch {
|
|
830
|
+
config = {};
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
const mcpServers = mode === 'merge' && config.mcp_servers && typeof config.mcp_servers === 'object' && !Array.isArray(config.mcp_servers)
|
|
834
|
+
? { ...config.mcp_servers }
|
|
835
|
+
: {};
|
|
836
|
+
for (const server of servers) {
|
|
837
|
+
if (server.transport === 'stdio') {
|
|
838
|
+
mcpServers[server.name] = {
|
|
839
|
+
command: server.command,
|
|
840
|
+
args: server.args || [],
|
|
841
|
+
...(server.env && { env: server.env }),
|
|
842
|
+
};
|
|
843
|
+
}
|
|
844
|
+
else {
|
|
845
|
+
mcpServers[server.name] = {
|
|
846
|
+
url: server.url,
|
|
847
|
+
};
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
config.mcp_servers = mcpServers;
|
|
851
|
+
fs.mkdirSync(path.dirname(configPath), { recursive: true });
|
|
852
|
+
fs.writeFileSync(configPath, yaml.stringify(config), 'utf-8');
|
|
853
|
+
break;
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
}
|
|
637
857
|
/**
|
|
638
858
|
* Install MCP servers to an agent.
|
|
639
859
|
* For Claude/Codex: uses CLI commands (claude mcp add, codex mcp add)
|
|
@@ -658,47 +878,92 @@ export function installMcpServers(agentId, version, versionHome, mcpNames, optio
|
|
|
658
878
|
binaryPath += '.cmd';
|
|
659
879
|
}
|
|
660
880
|
for (const server of servers) {
|
|
881
|
+
let handled = false;
|
|
661
882
|
try {
|
|
662
883
|
if (agentId === 'claude') {
|
|
663
884
|
installMcpViaClaude(binaryPath, server, versionHome);
|
|
664
|
-
|
|
885
|
+
handled = true;
|
|
665
886
|
}
|
|
666
887
|
else if (agentId === 'codex') {
|
|
667
888
|
installMcpViaCodex(binaryPath, server, versionHome);
|
|
668
|
-
|
|
889
|
+
handled = true;
|
|
669
890
|
}
|
|
670
891
|
else if (agentId === 'gemini') {
|
|
671
892
|
installMcpToGeminiConfig(server, versionHome);
|
|
672
|
-
|
|
893
|
+
handled = true;
|
|
673
894
|
}
|
|
674
895
|
else if (agentId === 'cursor') {
|
|
675
896
|
installMcpToCursorConfig(server, versionHome);
|
|
676
|
-
|
|
897
|
+
handled = true;
|
|
677
898
|
}
|
|
678
899
|
else if (agentId === 'opencode') {
|
|
679
900
|
installMcpToOpenCodeConfig(server, versionHome);
|
|
680
|
-
|
|
901
|
+
handled = true;
|
|
902
|
+
}
|
|
903
|
+
else if (agentId === 'openclaw') {
|
|
904
|
+
// OpenClaw has no install CLI; write the JSON config directly.
|
|
905
|
+
// Use merge because this loop runs once per server.
|
|
906
|
+
const userConfigPath = getMcpConfigPathForHome(agentId, versionHome);
|
|
907
|
+
const writableServer = {
|
|
908
|
+
name: server.config.name,
|
|
909
|
+
transport: server.config.transport,
|
|
910
|
+
command: server.config.command,
|
|
911
|
+
args: server.config.args,
|
|
912
|
+
env: server.config.env,
|
|
913
|
+
url: server.config.url,
|
|
914
|
+
};
|
|
915
|
+
writeMcpConfig(agentId, userConfigPath, [writableServer], 'merge');
|
|
916
|
+
handled = true;
|
|
681
917
|
}
|
|
682
918
|
else if (agentId === 'grok') {
|
|
683
|
-
// Grok
|
|
684
|
-
//
|
|
685
|
-
//
|
|
686
|
-
|
|
919
|
+
// Grok has no working `grok mcp add` CLI, so write the TOML config
|
|
920
|
+
// directly into the version-home directory for all scopes.
|
|
921
|
+
// Use merge because this loop runs once per server.
|
|
922
|
+
const userConfigPath = getMcpConfigPathForHome(agentId, versionHome);
|
|
923
|
+
const writableServer = {
|
|
924
|
+
name: server.config.name,
|
|
925
|
+
transport: server.config.transport,
|
|
926
|
+
command: server.config.command,
|
|
927
|
+
args: server.config.args,
|
|
928
|
+
env: server.config.env,
|
|
929
|
+
url: server.config.url,
|
|
930
|
+
};
|
|
931
|
+
writeMcpConfig(agentId, userConfigPath, [writableServer], 'merge');
|
|
932
|
+
handled = true;
|
|
687
933
|
}
|
|
688
934
|
else if (agentId === 'kimi') {
|
|
689
935
|
installMcpToKimiConfig(server, versionHome);
|
|
690
|
-
|
|
936
|
+
handled = true;
|
|
691
937
|
}
|
|
692
938
|
else if (agentId === 'droid') {
|
|
693
939
|
installMcpToFactoryConfig(server, versionHome);
|
|
694
|
-
|
|
940
|
+
handled = true;
|
|
695
941
|
}
|
|
696
942
|
else if (agentId === 'hermes') {
|
|
697
943
|
installMcpToHermesConfig(server, versionHome);
|
|
698
|
-
|
|
944
|
+
handled = true;
|
|
699
945
|
}
|
|
700
946
|
else if (agentId === 'forge') {
|
|
701
947
|
installMcpToForgeConfig(server, versionHome);
|
|
948
|
+
handled = true;
|
|
949
|
+
}
|
|
950
|
+
// Project-layer servers also get merged into the agent's project-level
|
|
951
|
+
// config (e.g., .mcp.json, .codex/config.toml) so the CLI discovers them
|
|
952
|
+
// when run inside the repo.
|
|
953
|
+
if (server.scope === 'project' && options.cwd && writeMcpConfigSupportsAgent(agentId)) {
|
|
954
|
+
const projectConfigPath = getProjectMcpConfigPath(agentId, options.cwd);
|
|
955
|
+
const writableServer = {
|
|
956
|
+
name: server.config.name,
|
|
957
|
+
transport: server.config.transport,
|
|
958
|
+
command: server.config.command,
|
|
959
|
+
args: server.config.args,
|
|
960
|
+
env: server.config.env,
|
|
961
|
+
url: server.config.url,
|
|
962
|
+
};
|
|
963
|
+
writeMcpConfig(agentId, projectConfigPath, [writableServer], 'merge');
|
|
964
|
+
handled = true;
|
|
965
|
+
}
|
|
966
|
+
if (handled) {
|
|
702
967
|
applied.push(server.name);
|
|
703
968
|
}
|
|
704
969
|
}
|