cdp-toolkit 1.10.0 → 2.0.0
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 +60 -0
- package/README.md +61 -12
- package/dist/cli.js +1233 -312
- package/dist/index.js +11 -11
- package/dist/mcp.js +1314 -320
- package/package.json +4 -2
- package/src/bidi/driver.ts +219 -29
- package/src/bidi/launch.ts +51 -1
- package/src/bidi/marionette.ts +276 -0
- package/src/bidi/session-lease.ts +265 -0
- package/src/cli.ts +10 -0
- package/src/install/browser.ts +211 -0
- package/src/install/harness.ts +224 -0
- package/src/install/wizard.ts +412 -0
- package/src/manifest.ts +297 -297
- package/src/mcp.ts +123 -11
- package/src/toolDocs.ts +468 -0
- package/src/toolGroups.ts +49 -0
- package/src/tools/navigation.ts +4 -2
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser half of `cdp install`: binary detection, debug-launch alias generation, and an
|
|
3
|
+
* idempotent shell-rc append. The other half (harness/MCP config) lives in a sibling module and
|
|
4
|
+
* is not this file's concern — this file only knows about browsers and shell rc files.
|
|
5
|
+
*
|
|
6
|
+
* Zero runtime dependencies beyond node:fs, node:fs/promises, node:os, node:path.
|
|
7
|
+
*/
|
|
8
|
+
import { existsSync } from "node:fs";
|
|
9
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
10
|
+
import { homedir } from "node:os";
|
|
11
|
+
import { delimiter, dirname, join } from "node:path";
|
|
12
|
+
|
|
13
|
+
export type BrowserChoice = "arc" | "chrome" | "firefox";
|
|
14
|
+
|
|
15
|
+
export interface BrowserInfo {
|
|
16
|
+
choice: BrowserChoice;
|
|
17
|
+
/**
|
|
18
|
+
* The binary path or command name to launch. When a PATH-relative name (e.g. "google-chrome")
|
|
19
|
+
* is the chosen candidate, this is returned UNRESOLVED — as the bare name, not an absolute path
|
|
20
|
+
* scanned off today's PATH — so the alias stays portable and lets the shell re-resolve it against
|
|
21
|
+
* PATH at invocation time rather than pinning it to wherever it happened to be found right now.
|
|
22
|
+
*/
|
|
23
|
+
binary: string;
|
|
24
|
+
found: boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface AliasOptions {
|
|
28
|
+
choice: BrowserChoice;
|
|
29
|
+
port: number;
|
|
30
|
+
aliasName?: string;
|
|
31
|
+
profileDir?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface RcAppendResult {
|
|
35
|
+
rcPath: string;
|
|
36
|
+
action: "created-block" | "replaced-block" | "unchanged";
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/* ------------------------------- binary detection ------------------------------- */
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Per-choice candidate lists, built fresh on every call so env var overrides (CHROME_BIN /
|
|
43
|
+
* ARC_BIN / FIREFOX_BIN) are read live rather than captured once at module load — important for
|
|
44
|
+
* tests that set/restore these env vars around a single call.
|
|
45
|
+
*
|
|
46
|
+
* macOS-first, Linux fallback. Order mirrors the spec: env override, then the macOS .app bundle
|
|
47
|
+
* path, then PATH-relative command names. Arc is macOS-mostly, so found=false on Linux (nothing
|
|
48
|
+
* in this list resolves there) is expected, not a bug.
|
|
49
|
+
*/
|
|
50
|
+
const CANDIDATE_BUILDERS: Record<BrowserChoice, () => Array<string | undefined>> = {
|
|
51
|
+
chrome: () => [
|
|
52
|
+
process.env.CHROME_BIN,
|
|
53
|
+
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
|
54
|
+
"google-chrome",
|
|
55
|
+
"google-chrome-stable",
|
|
56
|
+
"chromium",
|
|
57
|
+
],
|
|
58
|
+
arc: () => [process.env.ARC_BIN, "/Applications/Arc.app/Contents/MacOS/Arc", "arc"],
|
|
59
|
+
firefox: () => [
|
|
60
|
+
process.env.FIREFOX_BIN,
|
|
61
|
+
"/Applications/Firefox.app/Contents/MacOS/firefox",
|
|
62
|
+
"firefox",
|
|
63
|
+
"/usr/bin/firefox",
|
|
64
|
+
],
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
/** Scan $PATH for `cmd`. Used only to decide `found` for a bare candidate — never to rewrite it. */
|
|
68
|
+
function existsOnPath(cmd: string): boolean {
|
|
69
|
+
const pathEnv = process.env.PATH;
|
|
70
|
+
if (!pathEnv) return false;
|
|
71
|
+
for (const dir of pathEnv.split(delimiter)) {
|
|
72
|
+
if (!dir) continue;
|
|
73
|
+
if (existsSync(join(dir, cmd))) return true;
|
|
74
|
+
}
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Mirrors the resolveBinary logic in src/bidi/launch.ts: an ABSOLUTE candidate counts only if it
|
|
80
|
+
* exists on disk (otherwise a hardcoded macOS path would shadow a real PATH fallback on Linux and
|
|
81
|
+
* yield a not-found result at spawn time); a bare PATH name is returned as-is, unresolved, for the
|
|
82
|
+
* shell to resolve — but IS checked against $PATH here purely to set an accurate `found` flag.
|
|
83
|
+
*/
|
|
84
|
+
function resolveCandidate(candidates: Array<string | undefined>): { binary: string; found: boolean } {
|
|
85
|
+
let lastDefined = "";
|
|
86
|
+
for (const c of candidates) {
|
|
87
|
+
if (!c) continue;
|
|
88
|
+
lastDefined = c;
|
|
89
|
+
if (c.startsWith("/")) {
|
|
90
|
+
if (existsSync(c)) return { binary: c, found: true };
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
if (existsOnPath(c)) return { binary: c, found: true };
|
|
94
|
+
}
|
|
95
|
+
return { binary: lastDefined, found: false };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function detectBrowser(choice: BrowserChoice): BrowserInfo {
|
|
99
|
+
const { binary, found } = resolveCandidate(CANDIDATE_BUILDERS[choice]());
|
|
100
|
+
return { choice, binary, found };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/* ------------------------------- alias generation ------------------------------- */
|
|
104
|
+
|
|
105
|
+
function defaultProfileDir(choice: BrowserChoice): string {
|
|
106
|
+
// Literal $HOME (not resolved via os.homedir()) so it survives into the alias's stored text and
|
|
107
|
+
// expands fresh every time the alias runs — see the two-phase alias substitution note below.
|
|
108
|
+
return `$HOME/.cdp-toolkit/${choice}-profile`;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function defaultAliasName(choice: BrowserChoice): string {
|
|
112
|
+
return `cdp-${choice}`;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* The launch flags only (no binary). Chrome and Arc are both Chromium-based and Arc accepts
|
|
117
|
+
* Chrome's flags, so they share a shape: --remote-debugging-port plus a DEDICATED
|
|
118
|
+
* --user-data-dir. The dedicated profile is required, not cosmetic — Chrome silently ignores
|
|
119
|
+
* --remote-debugging-port when it hands off to an already-running instance on the default
|
|
120
|
+
* profile, so the port never opens and there is no error, just a hung attach later.
|
|
121
|
+
*
|
|
122
|
+
* Firefox's --marionette flag is likewise required, not optional: cdp-toolkit's Firefox
|
|
123
|
+
* orphan-recovery path (src/bidi/launch.ts's header comment) uses the Marionette side channel to
|
|
124
|
+
* recover a dropped BiDi connection, and that recovery cannot fire without it.
|
|
125
|
+
*/
|
|
126
|
+
export function browserLaunchArgs(o: AliasOptions): string[] {
|
|
127
|
+
const profileDir = o.profileDir ?? defaultProfileDir(o.choice);
|
|
128
|
+
if (o.choice === "firefox") {
|
|
129
|
+
return ["--remote-debugging-port", String(o.port), "--marionette", "--no-remote", "--profile", profileDir];
|
|
130
|
+
}
|
|
131
|
+
return [`--remote-debugging-port=${o.port}`, `--user-data-dir=${profileDir}`];
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Quote only if needed: wrapping a plain token in quotes is harmless but unquoted-with-a-space breaks word splitting. */
|
|
135
|
+
function shellQuoteIfSpaced(token: string): string {
|
|
136
|
+
return /\s/.test(token) ? `"${token}"` : token;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* A single `alias name='<binary> <flags>'` line. The whole value is wrapped in single quotes (so
|
|
141
|
+
* it is stored literally, unexpanded, when the alias is DEFINED); the binary is additionally
|
|
142
|
+
* double-quoted when it contains a space (the macOS "Google Chrome.app" / future spaced paths),
|
|
143
|
+
* because shell alias substitution is a two-phase process — text substitution first, then a fresh
|
|
144
|
+
* parse of the substituted text — so the inner double quotes and the bare $HOME in the profile
|
|
145
|
+
* dir both take effect correctly at every INVOCATION of the alias, not at definition time.
|
|
146
|
+
*/
|
|
147
|
+
export function aliasLine(o: AliasOptions): string {
|
|
148
|
+
const name = o.aliasName ?? defaultAliasName(o.choice);
|
|
149
|
+
const { binary } = detectBrowser(o.choice);
|
|
150
|
+
const parts = [shellQuoteIfSpaced(binary), ...browserLaunchArgs(o)];
|
|
151
|
+
return `alias ${name}='${parts.join(" ")}'`;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/* ------------------------------- shell rc detection ------------------------------- */
|
|
155
|
+
|
|
156
|
+
export function detectShellRc(): string {
|
|
157
|
+
const shell = process.env.SHELL ?? "";
|
|
158
|
+
if (shell.endsWith("zsh")) return join(homedir(), ".zshrc");
|
|
159
|
+
if (shell.endsWith("bash")) return join(homedir(), ".bashrc");
|
|
160
|
+
return join(homedir(), process.platform === "darwin" ? ".zshrc" : ".bashrc");
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/* ------------------------------- idempotent rc append ------------------------------- */
|
|
164
|
+
|
|
165
|
+
const MARKER_START = "# >>> cdp-toolkit alias >>>";
|
|
166
|
+
const MARKER_END = "# <<< cdp-toolkit alias <<<";
|
|
167
|
+
|
|
168
|
+
function ensureTrailingNewline(s: string): string {
|
|
169
|
+
return s.endsWith("\n") ? s : `${s}\n`;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Idempotently write ONE marker block into rcPath, replacing its body in place on re-runs rather
|
|
174
|
+
* than accumulating duplicates. The wizard owns composing aliasBlockBody (e.g. joining several
|
|
175
|
+
* browsers' alias lines with "\n") — this function only manages the single marker block, so
|
|
176
|
+
* calling it again with a fuller body updates the same block rather than adding a second one.
|
|
177
|
+
*/
|
|
178
|
+
export async function appendAliasToRc(rcPath: string, aliasBlockBody: string): Promise<RcAppendResult> {
|
|
179
|
+
let existing = "";
|
|
180
|
+
try {
|
|
181
|
+
existing = await readFile(rcPath, "utf8");
|
|
182
|
+
} catch (err) {
|
|
183
|
+
if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const block = `${MARKER_START}\n${aliasBlockBody}\n${MARKER_END}`;
|
|
187
|
+
|
|
188
|
+
const startIdx = existing.indexOf(MARKER_START);
|
|
189
|
+
const endIdx = existing.indexOf(MARKER_END);
|
|
190
|
+
|
|
191
|
+
if (startIdx !== -1 && endIdx !== -1 && endIdx > startIdx) {
|
|
192
|
+
const endOfEndLine = existing.indexOf("\n", endIdx);
|
|
193
|
+
const before = existing.slice(0, startIdx);
|
|
194
|
+
const after = endOfEndLine === -1 ? "" : existing.slice(endOfEndLine + 1);
|
|
195
|
+
const existingBlock = existing.slice(startIdx, endOfEndLine === -1 ? existing.length : endOfEndLine);
|
|
196
|
+
|
|
197
|
+
if (existingBlock === block) {
|
|
198
|
+
return { rcPath, action: "unchanged" };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const next = ensureTrailingNewline(`${before}${block}\n${after}`);
|
|
202
|
+
await writeFile(rcPath, next, "utf8");
|
|
203
|
+
return { rcPath, action: "replaced-block" };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
await mkdir(dirname(rcPath), { recursive: true });
|
|
207
|
+
const sep = existing.length === 0 || existing.endsWith("\n") ? "" : "\n";
|
|
208
|
+
const next = `${existing}${sep}${block}\n`;
|
|
209
|
+
await writeFile(rcPath, next, "utf8");
|
|
210
|
+
return { rcPath, action: "created-block" };
|
|
211
|
+
}
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Registering the cdp-toolkit MCP stdio server into a coding-agent harness.
|
|
3
|
+
*
|
|
4
|
+
* Three harnesses, three different registration mechanisms, chosen per-harness
|
|
5
|
+
* from what was actually verified against real on-disk configs, the official
|
|
6
|
+
* docs, and the installed CLIs (not assumed):
|
|
7
|
+
*
|
|
8
|
+
* - claude: `claude mcp add` is NOT an upsert — it errors on a duplicate
|
|
9
|
+
* server name. So this does a direct read-modify-write of the JSON config
|
|
10
|
+
* (~/.claude.json) instead, which is naturally idempotent: same input in,
|
|
11
|
+
* same file out, unrelated keys and servers preserved untouched.
|
|
12
|
+
* - codex: no confirmed idempotent single command, so this shells out to
|
|
13
|
+
* `codex mcp add`, first checking `codex mcp get <name>` and doing a
|
|
14
|
+
* remove-then-add when the name already exists.
|
|
15
|
+
* - opencode: `opencode mcp add` is a confirmed idempotent upsert (verified
|
|
16
|
+
* against its source), so this just shells out to it directly.
|
|
17
|
+
*
|
|
18
|
+
* Only the pure argv builders (codexAddArgs, opencodeAddArgs) and the
|
|
19
|
+
* claude-side JSON shape are exported for unit testing; the codex/opencode
|
|
20
|
+
* shell-outs are not covered by tests here because doing so would mean either
|
|
21
|
+
* running the real CLIs or mocking node:child_process, and the spec for this
|
|
22
|
+
* module calls only for testing the pure builders plus the claude RMW path.
|
|
23
|
+
*/
|
|
24
|
+
import { spawnSync } from "node:child_process";
|
|
25
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
26
|
+
import { homedir } from "node:os";
|
|
27
|
+
import { dirname, join } from "node:path";
|
|
28
|
+
|
|
29
|
+
export type HarnessChoice = "claude" | "codex" | "opencode";
|
|
30
|
+
|
|
31
|
+
export interface McpServerConfig {
|
|
32
|
+
command: string;
|
|
33
|
+
args: string[];
|
|
34
|
+
env: Record<string, string>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface InstallResult {
|
|
38
|
+
harness: HarnessChoice;
|
|
39
|
+
method: "file" | "cli";
|
|
40
|
+
path?: string;
|
|
41
|
+
action: "added" | "updated" | "unchanged";
|
|
42
|
+
detail?: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface InstallOpts {
|
|
46
|
+
/** Overrides the on-disk config path (claude only). For tests. */
|
|
47
|
+
configPath?: string;
|
|
48
|
+
/** Overrides os.homedir() when deriving the default config path (claude only). For tests. */
|
|
49
|
+
homeDir?: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function installMcpServer(
|
|
53
|
+
harness: HarnessChoice,
|
|
54
|
+
name: string,
|
|
55
|
+
cfg: McpServerConfig,
|
|
56
|
+
opts: InstallOpts = {},
|
|
57
|
+
): Promise<InstallResult> {
|
|
58
|
+
switch (harness) {
|
|
59
|
+
case "claude":
|
|
60
|
+
return installClaude(name, cfg, opts);
|
|
61
|
+
case "codex":
|
|
62
|
+
return installCodex(name, cfg);
|
|
63
|
+
case "opencode":
|
|
64
|
+
return installOpencode(name, cfg);
|
|
65
|
+
default: {
|
|
66
|
+
const exhaustive: never = harness;
|
|
67
|
+
throw new Error(`unknown harness: ${String(exhaustive)}`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/* ------------------------------- claude: file RMW ------------------------------- */
|
|
73
|
+
|
|
74
|
+
async function installClaude(name: string, cfg: McpServerConfig, opts: InstallOpts): Promise<InstallResult> {
|
|
75
|
+
const path = opts.configPath ?? join(opts.homeDir ?? homedir(), ".claude.json");
|
|
76
|
+
|
|
77
|
+
let root: Record<string, unknown>;
|
|
78
|
+
let fileExisted: boolean;
|
|
79
|
+
try {
|
|
80
|
+
const text = await readFile(path, "utf8");
|
|
81
|
+
const parsed: unknown = JSON.parse(text);
|
|
82
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
83
|
+
throw new Error(`${path} does not contain a JSON object at its top level`);
|
|
84
|
+
}
|
|
85
|
+
root = parsed as Record<string, unknown>;
|
|
86
|
+
fileExisted = true;
|
|
87
|
+
} catch (err) {
|
|
88
|
+
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
|
|
89
|
+
root = {};
|
|
90
|
+
fileExisted = false;
|
|
91
|
+
} else {
|
|
92
|
+
throw err;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const rawServers = root.mcpServers;
|
|
97
|
+
const mcpServers: Record<string, unknown> =
|
|
98
|
+
typeof rawServers === "object" && rawServers !== null && !Array.isArray(rawServers)
|
|
99
|
+
? (rawServers as Record<string, unknown>)
|
|
100
|
+
: {};
|
|
101
|
+
root.mcpServers = mcpServers;
|
|
102
|
+
|
|
103
|
+
// No `type` field: stdio is the implicit default for this shape in
|
|
104
|
+
// ~/.claude.json, and adding one would diverge from what `claude mcp add`
|
|
105
|
+
// itself writes for a stdio server.
|
|
106
|
+
const entry: Record<string, unknown> = { command: cfg.command, args: cfg.args };
|
|
107
|
+
if (Object.keys(cfg.env).length > 0) entry.env = cfg.env;
|
|
108
|
+
|
|
109
|
+
const prior = mcpServers[name];
|
|
110
|
+
const action: InstallResult["action"] =
|
|
111
|
+
prior === undefined ? "added" : canonicalJson(prior) === canonicalJson(entry) ? "unchanged" : "updated";
|
|
112
|
+
|
|
113
|
+
mcpServers[name] = entry;
|
|
114
|
+
|
|
115
|
+
if (action === "unchanged") {
|
|
116
|
+
return { harness: "claude", method: "file", path, action, detail: "no change needed" };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
await mkdir(dirname(path), { recursive: true });
|
|
120
|
+
await writeFile(path, JSON.stringify(root, null, 2) + "\n", "utf8");
|
|
121
|
+
|
|
122
|
+
return {
|
|
123
|
+
harness: "claude",
|
|
124
|
+
method: "file",
|
|
125
|
+
path,
|
|
126
|
+
action,
|
|
127
|
+
detail: fileExisted ? undefined : "created new config file",
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Deep-equality via a key-sorted JSON string, so key order never causes a false "updated". */
|
|
132
|
+
function canonicalJson(value: unknown): string {
|
|
133
|
+
return JSON.stringify(canonicalize(value));
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function canonicalize(value: unknown): unknown {
|
|
137
|
+
if (Array.isArray(value)) return value.map(canonicalize);
|
|
138
|
+
if (value !== null && typeof value === "object") {
|
|
139
|
+
const out: Record<string, unknown> = {};
|
|
140
|
+
for (const key of Object.keys(value as Record<string, unknown>).sort()) {
|
|
141
|
+
out[key] = canonicalize((value as Record<string, unknown>)[key]);
|
|
142
|
+
}
|
|
143
|
+
return out;
|
|
144
|
+
}
|
|
145
|
+
return value;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/* -------------------------------- codex: CLI shell-out -------------------------------- */
|
|
149
|
+
|
|
150
|
+
/** Pure argv builder (argv AFTER `codex`), exported for unit tests. */
|
|
151
|
+
export function codexAddArgs(name: string, cfg: McpServerConfig): string[] {
|
|
152
|
+
const envArgs = Object.entries(cfg.env).flatMap(([k, v]) => ["--env", `${k}=${v}`]);
|
|
153
|
+
return ["mcp", "add", name, ...envArgs, "--", cfg.command, ...cfg.args];
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async function installCodex(name: string, cfg: McpServerConfig): Promise<InstallResult> {
|
|
157
|
+
if (!isOnPath("codex")) {
|
|
158
|
+
throw new Error("codex CLI not found on PATH — install Codex or pick a different harness");
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// codex mcp add is not confirmed idempotent upstream, so probe first and
|
|
162
|
+
// remove-then-add when the name already exists.
|
|
163
|
+
const probe = spawnSync("codex", ["mcp", "get", name], { encoding: "utf8" });
|
|
164
|
+
const exists = probe.status === 0;
|
|
165
|
+
|
|
166
|
+
if (exists) {
|
|
167
|
+
const removed = spawnSync("codex", ["mcp", "remove", name], { encoding: "utf8" });
|
|
168
|
+
if (removed.status !== 0) {
|
|
169
|
+
throw new Error(`codex mcp remove ${name} failed: ${(removed.stderr || removed.stdout || "").trim()}`);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const addArgs = codexAddArgs(name, cfg);
|
|
174
|
+
const added = spawnSync("codex", addArgs, { encoding: "utf8" });
|
|
175
|
+
if (added.status !== 0) {
|
|
176
|
+
throw new Error(`codex ${addArgs.join(" ")} failed: ${(added.stderr || added.stdout || "").trim()}`);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return {
|
|
180
|
+
harness: "codex",
|
|
181
|
+
method: "cli",
|
|
182
|
+
action: exists ? "updated" : "added",
|
|
183
|
+
detail: added.stdout?.trim() || undefined,
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/* ------------------------------ opencode: CLI shell-out ------------------------------- */
|
|
188
|
+
|
|
189
|
+
/** Pure argv builder (argv AFTER `opencode`), exported for unit tests. */
|
|
190
|
+
export function opencodeAddArgs(name: string, cfg: McpServerConfig): string[] {
|
|
191
|
+
const envArgs = Object.entries(cfg.env).flatMap(([k, v]) => ["--env", `${k}=${v}`]);
|
|
192
|
+
return ["mcp", "add", name, ...envArgs, "--", cfg.command, ...cfg.args];
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async function installOpencode(name: string, cfg: McpServerConfig): Promise<InstallResult> {
|
|
196
|
+
if (!isOnPath("opencode")) {
|
|
197
|
+
throw new Error("opencode CLI not found on PATH — install OpenCode or pick a different harness");
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const addArgs = opencodeAddArgs(name, cfg);
|
|
201
|
+
const added = spawnSync("opencode", addArgs, { encoding: "utf8" });
|
|
202
|
+
if (added.status !== 0) {
|
|
203
|
+
throw new Error(`opencode ${addArgs.join(" ")} failed: ${(added.stderr || added.stdout || "").trim()}`);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// `opencode mcp add` is a confirmed idempotent upsert, and telling "added"
|
|
207
|
+
// from "updated" cheaply would mean an extra `opencode mcp get` round trip
|
|
208
|
+
// whose output shape isn't confirmed; "added" is accurate enough either way
|
|
209
|
+
// since the end state (server registered with this config) is identical.
|
|
210
|
+
return {
|
|
211
|
+
harness: "opencode",
|
|
212
|
+
method: "cli",
|
|
213
|
+
action: "added",
|
|
214
|
+
detail: added.stdout?.trim() || undefined,
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/* ---------------------------------- shared helpers ------------------------------------ */
|
|
219
|
+
|
|
220
|
+
function isOnPath(bin: string): boolean {
|
|
221
|
+
const finder = process.platform === "win32" ? "where" : "which";
|
|
222
|
+
const result = spawnSync(finder, [bin], { encoding: "utf8" });
|
|
223
|
+
return result.status === 0;
|
|
224
|
+
}
|