pennyrouter 0.2.9 → 0.2.11
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/LICENSE +21 -21
- package/README.md +225 -225
- package/bin/penny.js +9 -9
- package/bin/pennyrouter.js +7 -7
- package/package.json +30 -30
- package/src/cli.js +1882 -1727
- package/src/harnesses/aider.js +48 -48
- package/src/harnesses/claude-code.js +346 -318
- package/src/harnesses/claude-code.test.js +57 -57
- package/src/harnesses/cline.js +77 -77
- package/src/harnesses/codegpt.js +76 -76
- package/src/harnesses/codex.js +180 -180
- package/src/harnesses/codex.test.js +119 -119
- package/src/harnesses/opencode.js +151 -151
- package/src/harnesses/shared.js +51 -51
- package/src/harnesses/windsurf.js +57 -57
- package/src/harnesses/zed.js +61 -61
- package/src/launch.js +487 -487
- package/src/launch.test.js +170 -153
- package/src/mcp-register.js +193 -0
- package/src/mcp-register.test.js +171 -0
- package/src/mcp.js +256 -0
- package/src/mcp.test.js +237 -0
- package/src/session.js +157 -157
- package/src/session.test.js +44 -44
- package/src/state.js +103 -103
- package/src/statusline.js +202 -202
- package/src/statusline.test.js +72 -72
- package/src/thread-session-codex.js +152 -0
- package/src/thread-session-codex.test.js +136 -0
- package/src/thread-session.js +204 -0
- package/src/thread-session.test.js +140 -0
package/src/launch.test.js
CHANGED
|
@@ -1,153 +1,170 @@
|
|
|
1
|
-
import assert from "node:assert/strict";
|
|
2
|
-
import fs from "node:fs";
|
|
3
|
-
import os from "node:os";
|
|
4
|
-
import path from "node:path";
|
|
5
|
-
|
|
6
|
-
import {
|
|
7
|
-
buildPennyInvocation,
|
|
8
|
-
claudeLaunchSettingsPath,
|
|
9
|
-
claudeUserSkillPath,
|
|
10
|
-
codexLaunchProfilePath,
|
|
11
|
-
configurePennyHarness,
|
|
12
|
-
fileMode,
|
|
13
|
-
installPennyRuntime,
|
|
14
|
-
loadPennyConfig,
|
|
15
|
-
pennyConfigPath,
|
|
16
|
-
pennyRuntimeDir,
|
|
17
|
-
pennyShimPath,
|
|
18
|
-
removePennyHarness,
|
|
19
|
-
removePennyRuntime,
|
|
20
|
-
renderCodexLaunchProfile,
|
|
21
|
-
} from "./launch.js";
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
const
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
fs.mkdirSync(path.join(home, ".
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
fs.writeFileSync(path.join(home, ".
|
|
45
|
-
fs.writeFileSync(path.join(home, ".
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
const
|
|
60
|
-
|
|
61
|
-
fs.
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
assert.equal(first.
|
|
65
|
-
assert.equal(
|
|
66
|
-
assert.equal(
|
|
67
|
-
assert.
|
|
68
|
-
assert.ok(
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
assert.equal(
|
|
88
|
-
assert.equal(
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
assert.equal(
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
assert.
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
assert.
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
assert.equal(claude.
|
|
102
|
-
assert.
|
|
103
|
-
assert.
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
assert.
|
|
107
|
-
assert.equal(
|
|
108
|
-
assert.
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
assert.
|
|
113
|
-
assert.
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
assert.
|
|
130
|
-
assert.
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
});
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
buildPennyInvocation,
|
|
8
|
+
claudeLaunchSettingsPath,
|
|
9
|
+
claudeUserSkillPath,
|
|
10
|
+
codexLaunchProfilePath,
|
|
11
|
+
configurePennyHarness,
|
|
12
|
+
fileMode,
|
|
13
|
+
installPennyRuntime,
|
|
14
|
+
loadPennyConfig,
|
|
15
|
+
pennyConfigPath,
|
|
16
|
+
pennyRuntimeDir,
|
|
17
|
+
pennyShimPath,
|
|
18
|
+
removePennyHarness,
|
|
19
|
+
removePennyRuntime,
|
|
20
|
+
renderCodexLaunchProfile,
|
|
21
|
+
} from "./launch.js";
|
|
22
|
+
import { claudeCodeHarness } from "./harnesses/claude-code.js";
|
|
23
|
+
|
|
24
|
+
const ENV_KEYS = [
|
|
25
|
+
"HOME", "XDG_CONFIG_HOME", "XDG_DATA_HOME", "XDG_STATE_HOME", "CODEX_HOME", "PATH", "SHELL",
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
async function main() {
|
|
29
|
+
if (!(["darwin", "linux"].includes(process.platform))) {
|
|
30
|
+
console.log("launch.test.js: skipped on unsupported platform");
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), "pr-launch-test-"));
|
|
35
|
+
const previous = Object.fromEntries(ENV_KEYS.map((key) => [key, process.env[key]]));
|
|
36
|
+
const home = path.join(root, "home");
|
|
37
|
+
fs.mkdirSync(path.join(home, ".claude"), { recursive: true });
|
|
38
|
+
fs.mkdirSync(path.join(home, ".codex"), { recursive: true });
|
|
39
|
+
const nativeClaude = `${JSON.stringify({
|
|
40
|
+
permissions: { allow: ["Read"] },
|
|
41
|
+
statusLine: { type: "command", command: "native-status" },
|
|
42
|
+
}, null, 2)}\n`;
|
|
43
|
+
const nativeCodex = 'model = "native-model"\n';
|
|
44
|
+
fs.writeFileSync(path.join(home, ".claude", "settings.json"), nativeClaude);
|
|
45
|
+
fs.writeFileSync(path.join(home, ".codex", "config.toml"), nativeCodex);
|
|
46
|
+
fs.writeFileSync(path.join(home, ".zshrc"), "# user setting\n");
|
|
47
|
+
|
|
48
|
+
Object.assign(process.env, {
|
|
49
|
+
HOME: home,
|
|
50
|
+
XDG_CONFIG_HOME: path.join(home, ".config"),
|
|
51
|
+
XDG_DATA_HOME: path.join(home, ".local", "share"),
|
|
52
|
+
XDG_STATE_HOME: path.join(home, ".local", "state"),
|
|
53
|
+
CODEX_HOME: path.join(home, ".codex"),
|
|
54
|
+
PATH: "/usr/bin:/bin",
|
|
55
|
+
SHELL: "/bin/zsh",
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
try {
|
|
59
|
+
const first = await installPennyRuntime();
|
|
60
|
+
const staleSkill = path.join(pennyRuntimeDir(), "claude-plugin", "skills", "penny", "SKILL.md");
|
|
61
|
+
fs.mkdirSync(path.dirname(staleSkill), { recursive: true });
|
|
62
|
+
fs.writeFileSync(staleSkill, "stale plugin file");
|
|
63
|
+
const second = await installPennyRuntime();
|
|
64
|
+
assert.equal(first.supported, true);
|
|
65
|
+
assert.equal(first.changed, true);
|
|
66
|
+
assert.equal(second.changed, false);
|
|
67
|
+
assert.equal(await fileMode(pennyShimPath()), 0o755);
|
|
68
|
+
assert.ok(fs.existsSync(path.join(pennyRuntimeDir(), "bin", "penny.js")));
|
|
69
|
+
assert.ok(!fs.existsSync(staleSkill), "runtime upgrade removes stale plugin files");
|
|
70
|
+
const rc = fs.readFileSync(path.join(home, ".zshrc"), "utf8");
|
|
71
|
+
assert.equal((rc.match(/>>> pennyrouter PATH >>>/g) || []).length, 1);
|
|
72
|
+
|
|
73
|
+
await configurePennyHarness("claude-code", {
|
|
74
|
+
apiKey: "pr-super-secret",
|
|
75
|
+
gatewayBaseUrl: "https://gateway.example/",
|
|
76
|
+
subscriptionAuth: true,
|
|
77
|
+
// Simulate an existing install. `penny claude` must fix this at launch,
|
|
78
|
+
// without requiring a separate reinstall before its context limits are right.
|
|
79
|
+
models: { haiku: "Haiku 4.5 (PennyRouter)", sonnet: "Sonnet 5 (PennyRouter)", opus: "Opus 5 (PennyRouter)" },
|
|
80
|
+
});
|
|
81
|
+
await configurePennyHarness("codex", {
|
|
82
|
+
apiKey: "pr-super-secret",
|
|
83
|
+
gatewayBaseUrl: "https://gateway.example/",
|
|
84
|
+
subscriptionAuth: true,
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
assert.equal(fs.readFileSync(path.join(home, ".claude", "settings.json"), "utf8"), nativeClaude);
|
|
88
|
+
assert.equal(fs.readFileSync(path.join(home, ".codex", "config.toml"), "utf8"), nativeCodex);
|
|
89
|
+
assert.equal(await fileMode(pennyConfigPath()), 0o600);
|
|
90
|
+
assert.equal(await fileMode(claudeLaunchSettingsPath()), 0o600);
|
|
91
|
+
assert.equal(await fileMode(codexLaunchProfilePath()), 0o600);
|
|
92
|
+
|
|
93
|
+
const config = await loadPennyConfig();
|
|
94
|
+
assert.equal(config.api_key, "pr-super-secret");
|
|
95
|
+
const codexProfile = fs.readFileSync(codexLaunchProfilePath(), "utf8");
|
|
96
|
+
assert.ok(codexProfile.includes('env_http_headers = { "x-pennyrouter-key" = "PENNYROUTER_API_KEY" }'));
|
|
97
|
+
assert.ok(!codexProfile.includes("pr-super-secret"));
|
|
98
|
+
assert.ok(!rc.includes("pr-super-secret"));
|
|
99
|
+
|
|
100
|
+
const claude = buildPennyInvocation("claude", ["--resume", "abc"], config);
|
|
101
|
+
assert.equal(claude.command, "claude");
|
|
102
|
+
assert.deepEqual(claude.args.slice(-2), ["--resume", "abc"]);
|
|
103
|
+
assert.ok(!claude.args.includes("--plugin-dir"));
|
|
104
|
+
assert.equal(claude.env.ANTHROPIC_AUTH_TOKEN, "pr-super-secret");
|
|
105
|
+
assert.equal(claude.env.ANTHROPIC_BASE_URL, "https://gateway.example/");
|
|
106
|
+
assert.equal(claude.env.ANTHROPIC_DEFAULT_OPUS_MODEL, "claude-opus-5");
|
|
107
|
+
assert.equal(claude.env.ANTHROPIC_DEFAULT_HAIKU_MODEL, "claude-haiku-4-5-20251001");
|
|
108
|
+
assert.equal(claude.env.CLAUDE_CODE_MAX_CONTEXT_TOKENS, undefined);
|
|
109
|
+
|
|
110
|
+
const codex = buildPennyInvocation("codex", ["resume", "--last"], config);
|
|
111
|
+
assert.deepEqual(codex.args, ["--profile", "pennyrouter", "resume", "--last"]);
|
|
112
|
+
assert.equal(codex.env.PENNYROUTER_API_KEY, "pr-super-secret");
|
|
113
|
+
assert.throws(() => buildPennyInvocation("claude", ["--settings=x"], config), /manages --settings/);
|
|
114
|
+
assert.throws(() => buildPennyInvocation("codex", ["-p", "other"], config), /manages --profile/);
|
|
115
|
+
|
|
116
|
+
const command = fs.readFileSync(claudeUserSkillPath(), "utf8");
|
|
117
|
+
assert.ok(command.includes("@penny $ARGUMENTS"));
|
|
118
|
+
assert.ok(command.includes("name: penny"));
|
|
119
|
+
|
|
120
|
+
// Persistent direct-Claude installs use the same native ids and must not
|
|
121
|
+
// leave the former global 1M override behind.
|
|
122
|
+
await claudeCodeHarness.install({
|
|
123
|
+
apiKey: "pr-super-secret",
|
|
124
|
+
gatewayBaseUrl: "https://gateway.example/",
|
|
125
|
+
plan: { configPath: path.join(home, ".claude", "settings.json") },
|
|
126
|
+
});
|
|
127
|
+
const persistedClaude = JSON.parse(fs.readFileSync(path.join(home, ".claude", "settings.json"), "utf8"));
|
|
128
|
+
assert.equal(persistedClaude.env.ANTHROPIC_DEFAULT_HAIKU_MODEL, "claude-haiku-4-5-20251001");
|
|
129
|
+
assert.equal(persistedClaude.env.ANTHROPIC_DEFAULT_SONNET_MODEL, "claude-sonnet-5");
|
|
130
|
+
assert.equal("CLAUDE_CODE_MAX_CONTEXT_TOKENS" in persistedClaude.env, false);
|
|
131
|
+
|
|
132
|
+
await removePennyHarness("claude-code");
|
|
133
|
+
assert.ok(!fs.existsSync(claudeUserSkillPath()));
|
|
134
|
+
fs.mkdirSync(path.dirname(claudeUserSkillPath()), { recursive: true });
|
|
135
|
+
fs.writeFileSync(claudeUserSkillPath(), "user-owned command\n");
|
|
136
|
+
await assert.rejects(configurePennyHarness("claude-code", {
|
|
137
|
+
apiKey: "pr-super-secret",
|
|
138
|
+
gatewayBaseUrl: "https://gateway.example/",
|
|
139
|
+
models: {},
|
|
140
|
+
}), /not managed by PennyRouter/);
|
|
141
|
+
assert.equal(fs.readFileSync(claudeUserSkillPath(), "utf8"), "user-owned command\n");
|
|
142
|
+
assert.ok(fs.existsSync(codexLaunchProfilePath()));
|
|
143
|
+
assert.ok(fs.existsSync(pennyShimPath()));
|
|
144
|
+
await removePennyHarness("codex");
|
|
145
|
+
await removePennyRuntime();
|
|
146
|
+
assert.ok(!fs.existsSync(pennyShimPath()));
|
|
147
|
+
assert.ok(!fs.existsSync(pennyRuntimeDir()));
|
|
148
|
+
assert.ok(!fs.existsSync(pennyConfigPath()));
|
|
149
|
+
assert.equal(fs.readFileSync(path.join(home, ".zshrc"), "utf8"), "# user setting\n");
|
|
150
|
+
|
|
151
|
+
const paid = renderCodexLaunchProfile({
|
|
152
|
+
gatewayBaseUrl: "https://gateway.example/", subscriptionAuth: false,
|
|
153
|
+
});
|
|
154
|
+
assert.ok(paid.includes('env_key = "PENNYROUTER_API_KEY"'));
|
|
155
|
+
assert.ok(!paid.includes("requires_openai_auth"));
|
|
156
|
+
} finally {
|
|
157
|
+
for (const key of ENV_KEYS) {
|
|
158
|
+
if (previous[key] === undefined) delete process.env[key];
|
|
159
|
+
else process.env[key] = previous[key];
|
|
160
|
+
}
|
|
161
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
console.log("launch.test.js: all checks passed");
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
main().catch((error) => {
|
|
168
|
+
console.error(error);
|
|
169
|
+
process.exit(1);
|
|
170
|
+
});
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/* Register the Threads MCP server with the harnesses that can run it.
|
|
2
|
+
|
|
3
|
+
Without this, `pennyrouter install` gives a user routing and nothing else: the MCP server
|
|
4
|
+
exists as a command they would have to discover and register by hand, which almost nobody
|
|
5
|
+
does. Registration is what makes thread_read/thread_resume simply be there.
|
|
6
|
+
|
|
7
|
+
Two harnesses, two config files, neither of them the one the installer already manages:
|
|
8
|
+
|
|
9
|
+
Claude Code ~/.claude.json -> mcpServers.pennyrouter (JSON)
|
|
10
|
+
Codex ~/.codex/config.toml -> [mcp_servers.pennyrouter] (TOML)
|
|
11
|
+
|
|
12
|
+
NOTE the file for Claude Code. Its MCP registry is `~/.claude.json`, NOT the
|
|
13
|
+
`~/.claude/settings.json` that harnesses/claude-code.js writes — different file, different
|
|
14
|
+
shape. Writing the entry into settings.json looks right and does nothing.
|
|
15
|
+
|
|
16
|
+
The command is `npx -y pennyrouter mcp`, deliberately unpinned. An absolute path to the
|
|
17
|
+
current bin breaks the moment the user switches node versions or reinstalls, and the server
|
|
18
|
+
then silently stops appearing rather than failing loudly. A pinned version would freeze the
|
|
19
|
+
session writers, which is wrong for a feature whose whole risk is that Claude Code and Codex
|
|
20
|
+
change their undocumented formats — the writers need to be updatable. */
|
|
21
|
+
|
|
22
|
+
import { atomicWrite, backupFile, exists, expandHome, readJsonFile } from "./state.js";
|
|
23
|
+
import { readFile } from "node:fs/promises";
|
|
24
|
+
|
|
25
|
+
export const SERVER_NAME = "pennyrouter";
|
|
26
|
+
const CLAUDE_REGISTRY = "~/.claude.json";
|
|
27
|
+
const CODEX_CONFIG = "~/.codex/config.toml";
|
|
28
|
+
|
|
29
|
+
const MANAGED_START = "# --- PennyRouter managed MCP server ---";
|
|
30
|
+
const MANAGED_END = "# --- end PennyRouter managed MCP server ---";
|
|
31
|
+
|
|
32
|
+
/** What we register. Shared by both harnesses so the two configs cannot drift apart. */
|
|
33
|
+
export function serverEntry() {
|
|
34
|
+
return { command: "npx", args: ["-y", "pennyrouter", "mcp"] };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** True when an entry is one of ours, so re-installing updates rather than duplicating and
|
|
38
|
+
uninstalling never removes a server the user registered themselves. */
|
|
39
|
+
export function isManagedEntry(entry) {
|
|
40
|
+
if (!entry || typeof entry !== "object") return false;
|
|
41
|
+
const args = Array.isArray(entry.args) ? entry.args.join(" ") : "";
|
|
42
|
+
return /\bpennyrouter\b/.test(`${entry.command || ""} ${args}`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// -- Claude Code ----------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
export function applyClaudeRegistry(config, { remove = false } = {}) {
|
|
48
|
+
const next = { ...(config || {}) };
|
|
49
|
+
const servers = { ...(next.mcpServers || {}) };
|
|
50
|
+
|
|
51
|
+
if (remove) {
|
|
52
|
+
// Only remove what we wrote. A user who pointed the name at their own build keeps it.
|
|
53
|
+
if (isManagedEntry(servers[SERVER_NAME])) delete servers[SERVER_NAME];
|
|
54
|
+
} else {
|
|
55
|
+
const existing = servers[SERVER_NAME];
|
|
56
|
+
if (existing && !isManagedEntry(existing)) {
|
|
57
|
+
throw new Error(
|
|
58
|
+
`Claude Code already has an MCP server named "${SERVER_NAME}" that PennyRouter did not ` +
|
|
59
|
+
"write. Rename it before installing so your configuration is not overwritten.",
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
servers[SERVER_NAME] = serverEntry();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (Object.keys(servers).length) next.mcpServers = servers;
|
|
66
|
+
else delete next.mcpServers;
|
|
67
|
+
return next;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function writeClaude({ remove }) {
|
|
71
|
+
if (!(await exists(CLAUDE_REGISTRY))) {
|
|
72
|
+
// Claude Code writes this on first run. Creating it ourselves risks clobbering a shape we
|
|
73
|
+
// do not own, so skip rather than guess.
|
|
74
|
+
return { changed: false, reason: "Claude Code config not found" };
|
|
75
|
+
}
|
|
76
|
+
const current = (await readJsonFile(CLAUDE_REGISTRY, null)) || {};
|
|
77
|
+
const next = applyClaudeRegistry(current, { remove });
|
|
78
|
+
if (JSON.stringify(next) === JSON.stringify(current)) return { changed: false };
|
|
79
|
+
|
|
80
|
+
await backupFile(CLAUDE_REGISTRY, "mcp");
|
|
81
|
+
await atomicWrite(CLAUDE_REGISTRY, `${JSON.stringify(next, null, 2)}\n`);
|
|
82
|
+
return { changed: true, path: expandHome(CLAUDE_REGISTRY) };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// -- Codex ----------------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
export function renderCodexMcp(text, { remove = false } = {}) {
|
|
88
|
+
const clean = removeManagedCodexMcp(text).trimEnd();
|
|
89
|
+
if (remove) return clean ? `${clean}\n` : "";
|
|
90
|
+
|
|
91
|
+
const pattern = new RegExp(`^\\s*\\[mcp_servers\\.${SERVER_NAME}\\]\\s*$`, "m");
|
|
92
|
+
if (pattern.test(clean)) {
|
|
93
|
+
throw new Error(
|
|
94
|
+
`Codex already defines [mcp_servers.${SERVER_NAME}] outside PennyRouter's managed block. ` +
|
|
95
|
+
"Rename that server before installing so PennyRouter does not overwrite it.",
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const entry = serverEntry();
|
|
100
|
+
const block = [
|
|
101
|
+
MANAGED_START,
|
|
102
|
+
`[mcp_servers.${SERVER_NAME}]`,
|
|
103
|
+
`command = "${entry.command}"`,
|
|
104
|
+
`args = [${entry.args.map((a) => `"${a}"`).join(", ")}]`,
|
|
105
|
+
MANAGED_END,
|
|
106
|
+
].join("\n");
|
|
107
|
+
|
|
108
|
+
// Appended rather than spliced at the first table: a TOML table header captures every key
|
|
109
|
+
// that follows it, so inserting this block above existing tables would silently reparent
|
|
110
|
+
// their keys into ours.
|
|
111
|
+
return `${clean ? `${clean}\n\n` : ""}${block}\n`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function removeManagedCodexMcp(text) {
|
|
115
|
+
return String(text || "").replace(
|
|
116
|
+
new RegExp(
|
|
117
|
+
`${escapeRegex(MANAGED_START)}[\\s\\S]*?${escapeRegex(MANAGED_END)}\\n?`,
|
|
118
|
+
"g",
|
|
119
|
+
),
|
|
120
|
+
"",
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function escapeRegex(value) {
|
|
125
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function writeCodex({ remove }) {
|
|
129
|
+
if (!(await exists(CODEX_CONFIG))) return { changed: false, reason: "Codex config not found" };
|
|
130
|
+
const current = await readFile(expandHome(CODEX_CONFIG), "utf8").catch(() => "");
|
|
131
|
+
const next = renderCodexMcp(current, { remove });
|
|
132
|
+
if (next === current) return { changed: false };
|
|
133
|
+
|
|
134
|
+
await backupFile(CODEX_CONFIG, "mcp");
|
|
135
|
+
await atomicWrite(CODEX_CONFIG, next);
|
|
136
|
+
return { changed: true, path: expandHome(CODEX_CONFIG) };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// -- entry points ---------------------------------------------------------
|
|
140
|
+
|
|
141
|
+
const WRITERS = { "claude-code": writeClaude, codex: writeCodex };
|
|
142
|
+
|
|
143
|
+
/** Whether each harness currently has our server registered, and whether it could. */
|
|
144
|
+
export async function mcpStatus(harnessIds = Object.keys(WRITERS)) {
|
|
145
|
+
const out = [];
|
|
146
|
+
for (const id of harnessIds) {
|
|
147
|
+
if (id === "claude-code") {
|
|
148
|
+
const present = await exists(CLAUDE_REGISTRY);
|
|
149
|
+
const config = present ? (await readJsonFile(CLAUDE_REGISTRY, null)) || {} : {};
|
|
150
|
+
out.push({
|
|
151
|
+
harness: id,
|
|
152
|
+
configFound: present,
|
|
153
|
+
registered: isManagedEntry((config.mcpServers || {})[SERVER_NAME]),
|
|
154
|
+
conflict: Boolean(
|
|
155
|
+
(config.mcpServers || {})[SERVER_NAME] &&
|
|
156
|
+
!isManagedEntry((config.mcpServers || {})[SERVER_NAME]),
|
|
157
|
+
),
|
|
158
|
+
});
|
|
159
|
+
} else if (id === "codex") {
|
|
160
|
+
const present = await exists(CODEX_CONFIG);
|
|
161
|
+
const text = present ? await readFile(expandHome(CODEX_CONFIG), "utf8").catch(() => "") : "";
|
|
162
|
+
const managed = text.includes(MANAGED_START);
|
|
163
|
+
out.push({
|
|
164
|
+
harness: id,
|
|
165
|
+
configFound: present,
|
|
166
|
+
registered: managed,
|
|
167
|
+
conflict:
|
|
168
|
+
!managed && new RegExp(`^\\s*\\[mcp_servers\\.${SERVER_NAME}\\]`, "m").test(text),
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return out;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Register (or with `remove`, unregister) the MCP server for `harnessIds`.
|
|
177
|
+
* Returns one result per harness; a harness whose config is absent is skipped, not an error.
|
|
178
|
+
*/
|
|
179
|
+
export async function registerMcp(harnessIds, { remove = false } = {}) {
|
|
180
|
+
const results = [];
|
|
181
|
+
for (const id of harnessIds) {
|
|
182
|
+
const writer = WRITERS[id];
|
|
183
|
+
if (!writer) continue;
|
|
184
|
+
try {
|
|
185
|
+
results.push({ harness: id, ...(await writer({ remove })) });
|
|
186
|
+
} catch (error) {
|
|
187
|
+
// A conflict must not fail the whole install: routing is the primary job and is already
|
|
188
|
+
// done by this point. Report it and let the user resolve the name.
|
|
189
|
+
results.push({ harness: id, changed: false, error: error?.message || String(error) });
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
return results;
|
|
193
|
+
}
|