@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.
Files changed (50) hide show
  1. package/CHANGELOG.md +88 -0
  2. package/README.md +3 -0
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/check.js +96 -2
  5. package/dist/commands/doctor.js +37 -9
  6. package/dist/commands/exec.js +45 -1
  7. package/dist/commands/plugins.js +11 -2
  8. package/dist/commands/repo.js +47 -2
  9. package/dist/commands/ssh.js +103 -2
  10. package/dist/commands/teams.d.ts +11 -0
  11. package/dist/commands/teams.js +40 -2
  12. package/dist/commands/view.d.ts +1 -0
  13. package/dist/commands/view.js +12 -8
  14. package/dist/lib/agents.d.ts +11 -5
  15. package/dist/lib/agents.js +35 -24
  16. package/dist/lib/browser/profiles.js +4 -25
  17. package/dist/lib/cli-entry.d.ts +23 -0
  18. package/dist/lib/cli-entry.js +116 -0
  19. package/dist/lib/daemon.d.ts +2 -2
  20. package/dist/lib/daemon.js +8 -89
  21. package/dist/lib/devices/fleet.d.ts +23 -0
  22. package/dist/lib/devices/fleet.js +38 -0
  23. package/dist/lib/devices/health-report.d.ts +46 -0
  24. package/dist/lib/devices/health-report.js +159 -0
  25. package/dist/lib/devices/health.d.ts +14 -4
  26. package/dist/lib/devices/health.js +49 -8
  27. package/dist/lib/exec.d.ts +15 -0
  28. package/dist/lib/exec.js +31 -6
  29. package/dist/lib/git.d.ts +10 -0
  30. package/dist/lib/git.js +23 -0
  31. package/dist/lib/hosts/option.js +1 -1
  32. package/dist/lib/hosts/ready.js +5 -3
  33. package/dist/lib/hosts/remote-cmd.d.ts +12 -0
  34. package/dist/lib/hosts/remote-cmd.js +17 -1
  35. package/dist/lib/mcp.d.ts +21 -0
  36. package/dist/lib/mcp.js +278 -13
  37. package/dist/lib/resources/mcp.js +20 -342
  38. package/dist/lib/routines.d.ts +13 -0
  39. package/dist/lib/routines.js +21 -0
  40. package/dist/lib/runner.js +24 -47
  41. package/dist/lib/secrets/agent.js +11 -15
  42. package/dist/lib/share/capture.js +21 -3
  43. package/dist/lib/signin-badge.d.ts +41 -0
  44. package/dist/lib/signin-badge.js +64 -0
  45. package/dist/lib/ssh-exec.d.ts +5 -0
  46. package/dist/lib/ssh-exec.js +55 -1
  47. package/dist/lib/types.d.ts +10 -0
  48. package/dist/lib/usage.d.ts +17 -3
  49. package/dist/lib/usage.js +63 -16
  50. 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 resolveMode, the chosen mode is guaranteed to be in template.modeFlags.
578
- const resolvedMode = resolveMode(options.agent, normalizeMode(options.mode));
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 (read-only) has no headless equivalent, so fail closed rather
613
- // than silently letting a plan-mode run mutate the workspace.
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('kimi has no headless read-only mode: `--prompt` cannot be combined with `--plan`. ' +
616
- 'Run kimi in plan mode interactively (omit the prompt), or use --mode edit, auto, or skip.');
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
  */
@@ -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>', 'Working directory on the host for --host runs.')
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
  }
@@ -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 = `npm install -g ${powershellQuote(spec)} 2>&1 | Select-Object -Last 3; ` +
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 = `agents --version 2>$null; Write-Output "${READY_MARKER}"; ` +
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
- applied.push(server.name);
885
+ handled = true;
665
886
  }
666
887
  else if (agentId === 'codex') {
667
888
  installMcpViaCodex(binaryPath, server, versionHome);
668
- applied.push(server.name);
889
+ handled = true;
669
890
  }
670
891
  else if (agentId === 'gemini') {
671
892
  installMcpToGeminiConfig(server, versionHome);
672
- applied.push(server.name);
893
+ handled = true;
673
894
  }
674
895
  else if (agentId === 'cursor') {
675
896
  installMcpToCursorConfig(server, versionHome);
676
- applied.push(server.name);
897
+ handled = true;
677
898
  }
678
899
  else if (agentId === 'opencode') {
679
900
  installMcpToOpenCodeConfig(server, versionHome);
680
- applied.push(server.name);
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 primarily uses [mcp_servers] in ~/.grok/config.toml (or project .grok/config.toml).
684
- // We have the path helper; full writer can be added (reuse codex toml pattern).
685
- // For now the general sync + toml editing via agents mcp works via the path helpers.
686
- applied.push(server.name);
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
- applied.push(server.name);
936
+ handled = true;
691
937
  }
692
938
  else if (agentId === 'droid') {
693
939
  installMcpToFactoryConfig(server, versionHome);
694
- applied.push(server.name);
940
+ handled = true;
695
941
  }
696
942
  else if (agentId === 'hermes') {
697
943
  installMcpToHermesConfig(server, versionHome);
698
- applied.push(server.name);
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
  }