pennyrouter 0.1.20 → 0.2.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/README.md +40 -3
- package/bin/penny.js +9 -0
- package/claude-plugin/.claude-plugin/plugin.json +8 -0
- package/claude-plugin/skills/penny/SKILL.md +8 -0
- package/package.json +3 -2
- package/src/cli.js +275 -50
- package/src/harnesses/claude-code.js +1 -1
- package/src/harnesses/codex.js +4 -3
- package/src/harnesses/codex.test.js +37 -0
- package/src/launch.js +339 -0
- package/src/launch.test.js +140 -0
- package/src/session.js +56 -1
- package/src/session.test.js +43 -0
- package/src/statusline.js +3 -4
package/src/harnesses/codex.js
CHANGED
|
@@ -27,10 +27,11 @@ export const codexHarness = {
|
|
|
27
27
|
};
|
|
28
28
|
},
|
|
29
29
|
|
|
30
|
-
async install({ apiKey, gatewayBaseUrl, plan }) {
|
|
30
|
+
async install({ apiKey, gatewayBaseUrl, plan, existingRecord }) {
|
|
31
31
|
const current = await readText(CONFIG_PATH, "");
|
|
32
32
|
const subscriptionAuth = await detectCodexChatgptAuth();
|
|
33
|
-
const previousModelProvider =
|
|
33
|
+
const previousModelProvider = existingRecord?.previous_model_provider
|
|
34
|
+
|| rootAssignment(removeManagedCodexConfig(current), "model_provider");
|
|
34
35
|
const backupPath = await backupFile(CONFIG_PATH, "codex");
|
|
35
36
|
const next = renderCodexConfig(current, {
|
|
36
37
|
apiKey,
|
|
@@ -75,7 +76,7 @@ export function codexUsesChatgptAuth(auth) {
|
|
|
75
76
|
return Boolean(auth.tokens && typeof auth.tokens === "object" && auth.tokens.account_id);
|
|
76
77
|
}
|
|
77
78
|
|
|
78
|
-
async function detectCodexChatgptAuth() {
|
|
79
|
+
export async function detectCodexChatgptAuth() {
|
|
79
80
|
const auth = await readJsonFile(AUTH_PATH, null);
|
|
80
81
|
if (codexUsesChatgptAuth(auth)) return true;
|
|
81
82
|
// auth.json may be absent when Codex uses the OS keyring. `codex login status` reports
|
|
@@ -1,6 +1,10 @@
|
|
|
1
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";
|
|
2
5
|
|
|
3
6
|
import {
|
|
7
|
+
codexHarness,
|
|
4
8
|
codexUsesChatgptAuth,
|
|
5
9
|
removeManagedCodexConfig,
|
|
6
10
|
renderCodexConfig,
|
|
@@ -74,9 +78,42 @@ function testUninstallLeavesUserConfig() {
|
|
|
74
78
|
assert.ok(!removed.includes("pr-test"));
|
|
75
79
|
}
|
|
76
80
|
|
|
81
|
+
async function testReinstallPreservesRecordedProvider() {
|
|
82
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), "pr-codex-reinstall-"));
|
|
83
|
+
const previousHome = process.env.HOME;
|
|
84
|
+
const previousState = process.env.XDG_STATE_HOME;
|
|
85
|
+
process.env.HOME = root;
|
|
86
|
+
process.env.XDG_STATE_HOME = path.join(root, ".state");
|
|
87
|
+
fs.mkdirSync(path.join(root, ".codex"), { recursive: true });
|
|
88
|
+
fs.writeFileSync(path.join(root, ".codex", "auth.json"), '{"auth_mode":"apikey"}\n');
|
|
89
|
+
fs.writeFileSync(path.join(root, ".codex", "config.toml"), renderCodexConfig("", {
|
|
90
|
+
apiKey: "pr-old", gatewayBaseUrl: "https://old.example", subscriptionAuth: false,
|
|
91
|
+
}));
|
|
92
|
+
try {
|
|
93
|
+
const plan = await codexHarness.planInstall();
|
|
94
|
+
const record = await codexHarness.install({
|
|
95
|
+
apiKey: "pr-new",
|
|
96
|
+
gatewayBaseUrl: "https://new.example",
|
|
97
|
+
plan,
|
|
98
|
+
existingRecord: { previous_model_provider: 'model_provider = "native"' },
|
|
99
|
+
});
|
|
100
|
+
assert.equal(record.previous_model_provider, 'model_provider = "native"');
|
|
101
|
+
await codexHarness.uninstall(record);
|
|
102
|
+
assert.ok(fs.readFileSync(path.join(root, ".codex", "config.toml"), "utf8")
|
|
103
|
+
.includes('model_provider = "native"'));
|
|
104
|
+
} finally {
|
|
105
|
+
if (previousHome === undefined) delete process.env.HOME;
|
|
106
|
+
else process.env.HOME = previousHome;
|
|
107
|
+
if (previousState === undefined) delete process.env.XDG_STATE_HOME;
|
|
108
|
+
else process.env.XDG_STATE_HOME = previousState;
|
|
109
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
77
113
|
testAuthDetection();
|
|
78
114
|
testSubscriptionProviderIsRootSafe();
|
|
79
115
|
testLegacyMigrationAndIdempotence();
|
|
80
116
|
testProviderCollisionFailsClosed();
|
|
81
117
|
testUninstallLeavesUserConfig();
|
|
118
|
+
await testReinstallPreservesRecordedProvider();
|
|
82
119
|
console.log("codex.test.js: all checks passed");
|
package/src/launch.js
ADDED
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import {
|
|
3
|
+
chmod,
|
|
4
|
+
cp,
|
|
5
|
+
mkdir,
|
|
6
|
+
rm,
|
|
7
|
+
stat,
|
|
8
|
+
unlink,
|
|
9
|
+
writeFile,
|
|
10
|
+
} from "node:fs/promises";
|
|
11
|
+
import { constants as osConstants } from "node:os";
|
|
12
|
+
import { fileURLToPath } from "node:url";
|
|
13
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
14
|
+
|
|
15
|
+
import { configureClaudeCodeStatusline } from "./statusline.js";
|
|
16
|
+
import { atomicWrite, homeDir, readJsonFile, readText } from "./state.js";
|
|
17
|
+
|
|
18
|
+
const CONFIG_VERSION = 1;
|
|
19
|
+
const PATH_START = "# >>> pennyrouter PATH >>>";
|
|
20
|
+
const PATH_END = "# <<< pennyrouter PATH <<<";
|
|
21
|
+
const CODEX_PROFILE_MARKER = "# PennyRouter per-command profile (managed; no secrets)";
|
|
22
|
+
|
|
23
|
+
export function pennyConfigDir() {
|
|
24
|
+
return join(process.env.XDG_CONFIG_HOME || join(homeDir(), ".config"), "pennyrouter");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function pennyConfigPath() {
|
|
28
|
+
return join(pennyConfigDir(), "config.json");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function pennyDataDir() {
|
|
32
|
+
return join(process.env.XDG_DATA_HOME || join(homeDir(), ".local", "share"), "pennyrouter");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function pennyRuntimeDir() {
|
|
36
|
+
return join(pennyDataDir(), "runtime");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function pennyBinDir() {
|
|
40
|
+
return join(homeDir(), ".local", "bin");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function pennyShimPath() {
|
|
44
|
+
return join(pennyBinDir(), "penny");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function claudeLaunchSettingsPath() {
|
|
48
|
+
return join(pennyConfigDir(), "claude-settings.json");
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function codexLaunchProfilePath() {
|
|
52
|
+
const codexHome = process.env.CODEX_HOME || join(homeDir(), ".codex");
|
|
53
|
+
return join(codexHome, "pennyrouter.config.toml");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function claudePluginPath() {
|
|
57
|
+
return join(pennyRuntimeDir(), "claude-plugin");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function privateWrite(path, text) {
|
|
61
|
+
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
62
|
+
await atomicWrite(path, text);
|
|
63
|
+
await chmod(path, 0o600);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export async function loadPennyConfig() {
|
|
67
|
+
return (await readJsonFile(pennyConfigPath(), null)) || {
|
|
68
|
+
version: CONFIG_VERSION,
|
|
69
|
+
harnesses: {},
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export async function savePennyConfig(config) {
|
|
74
|
+
const next = { version: CONFIG_VERSION, ...config };
|
|
75
|
+
await privateWrite(pennyConfigPath(), `${JSON.stringify(next, null, 2)}\n`);
|
|
76
|
+
return next;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function packageRoot() {
|
|
80
|
+
return resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function copyRuntime() {
|
|
84
|
+
const source = packageRoot();
|
|
85
|
+
const destination = pennyRuntimeDir();
|
|
86
|
+
await mkdir(destination, { recursive: true, mode: 0o700 });
|
|
87
|
+
for (const entry of ["bin", "src", "claude-plugin"]) {
|
|
88
|
+
await cp(join(source, entry), join(destination, entry), {
|
|
89
|
+
recursive: true,
|
|
90
|
+
force: true,
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
await cp(join(source, "package.json"), join(destination, "package.json"), { force: true });
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function writeShim() {
|
|
97
|
+
await mkdir(pennyBinDir(), { recursive: true, mode: 0o755 });
|
|
98
|
+
const entry = join(pennyRuntimeDir(), "bin", "penny.js").replaceAll('"', '\\"');
|
|
99
|
+
await writeFile(pennyShimPath(), `#!/bin/sh\nexec node "${entry}" "$@"\n`, {
|
|
100
|
+
encoding: "utf8",
|
|
101
|
+
mode: 0o755,
|
|
102
|
+
});
|
|
103
|
+
await chmod(pennyShimPath(), 0o755);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function pathContains(dir) {
|
|
107
|
+
return String(process.env.PATH || "").split(":").some((candidate) => resolve(candidate) === resolve(dir));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function shellRcPath() {
|
|
111
|
+
const shell = basename(process.env.SHELL || "");
|
|
112
|
+
if (shell === "zsh") return join(homeDir(), ".zshrc");
|
|
113
|
+
if (shell === "bash") return join(homeDir(), ".bashrc");
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export async function ensurePennyOnPath({ updatePath = true } = {}) {
|
|
118
|
+
if (pathContains(pennyBinDir())) return { available: true, changed: false, rc_path: null };
|
|
119
|
+
if (!updatePath) return { available: false, changed: false, rc_path: null };
|
|
120
|
+
const rcPath = shellRcPath();
|
|
121
|
+
if (!rcPath) return { available: false, changed: false, rc_path: null };
|
|
122
|
+
const current = await readText(rcPath, "");
|
|
123
|
+
if (!current.includes(PATH_START)) {
|
|
124
|
+
const block = `${PATH_START}\nexport PATH="$HOME/.local/bin:$PATH"\n${PATH_END}`;
|
|
125
|
+
const prefix = current && !current.endsWith("\n") ? "\n" : "";
|
|
126
|
+
await atomicWrite(rcPath, `${current}${prefix}${block}\n`);
|
|
127
|
+
}
|
|
128
|
+
return { available: true, changed: !current.includes(PATH_START), rc_path: rcPath };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export async function installPennyRuntime({ updatePath = true } = {}) {
|
|
132
|
+
if (!(["darwin", "linux"].includes(process.platform))) {
|
|
133
|
+
return { supported: false, available: false, changed: false, rc_path: null };
|
|
134
|
+
}
|
|
135
|
+
await copyRuntime();
|
|
136
|
+
await writeShim();
|
|
137
|
+
const path = await ensurePennyOnPath({ updatePath });
|
|
138
|
+
const config = await loadPennyConfig();
|
|
139
|
+
if (path.rc_path) {
|
|
140
|
+
config.path_rc = path.rc_path;
|
|
141
|
+
await savePennyConfig(config);
|
|
142
|
+
}
|
|
143
|
+
return { supported: true, ...path, shim_path: pennyShimPath() };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function renderCodexLaunchProfile({ gatewayBaseUrl, subscriptionAuth }) {
|
|
147
|
+
const authLines = subscriptionAuth
|
|
148
|
+
? [
|
|
149
|
+
"requires_openai_auth = true",
|
|
150
|
+
'env_http_headers = { "x-pennyrouter-key" = "PENNYROUTER_API_KEY" }',
|
|
151
|
+
]
|
|
152
|
+
: ['env_key = "PENNYROUTER_API_KEY"'];
|
|
153
|
+
return [
|
|
154
|
+
CODEX_PROFILE_MARKER,
|
|
155
|
+
'model_provider = "pennyrouter"',
|
|
156
|
+
"",
|
|
157
|
+
"[model_providers.pennyrouter]",
|
|
158
|
+
'name = "PennyRouter"',
|
|
159
|
+
`base_url = ${JSON.stringify(`${String(gatewayBaseUrl).replace(/\/+$/, "")}/v1`)}`,
|
|
160
|
+
'wire_api = "responses"',
|
|
161
|
+
"supports_websockets = false",
|
|
162
|
+
...authLines,
|
|
163
|
+
"",
|
|
164
|
+
].join("\n");
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async function writeClaudeLaunchSettings() {
|
|
168
|
+
const native = (await readJsonFile(join(homeDir(), ".claude", "settings.json"), null)) || {};
|
|
169
|
+
const overlay = { disableClaudeAiConnectors: true };
|
|
170
|
+
if (native.statusLine) overlay.statusLine = native.statusLine;
|
|
171
|
+
await configureClaudeCodeStatusline(overlay);
|
|
172
|
+
await privateWrite(claudeLaunchSettingsPath(), `${JSON.stringify(overlay, null, 2)}\n`);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async function writeCodexLaunchProfile({ gatewayBaseUrl, subscriptionAuth }) {
|
|
176
|
+
const path = codexLaunchProfilePath();
|
|
177
|
+
const existing = await readText(path, "");
|
|
178
|
+
if (existing.trim() && !existing.startsWith(CODEX_PROFILE_MARKER)) {
|
|
179
|
+
throw new Error(
|
|
180
|
+
`${path} already exists and is not managed by PennyRouter. Rename it before installing.`,
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
await privateWrite(path, renderCodexLaunchProfile({ gatewayBaseUrl, subscriptionAuth }));
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export async function configurePennyHarness(harness, {
|
|
187
|
+
apiKey,
|
|
188
|
+
gatewayBaseUrl,
|
|
189
|
+
subscriptionAuth = false,
|
|
190
|
+
models = null,
|
|
191
|
+
}) {
|
|
192
|
+
const config = await loadPennyConfig();
|
|
193
|
+
config.gateway_base_url = gatewayBaseUrl;
|
|
194
|
+
config.api_key = apiKey;
|
|
195
|
+
config.harnesses ||= {};
|
|
196
|
+
if (harness === "claude-code") {
|
|
197
|
+
await writeClaudeLaunchSettings();
|
|
198
|
+
config.harnesses.claude = {
|
|
199
|
+
enabled: true,
|
|
200
|
+
subscription_auth: Boolean(subscriptionAuth),
|
|
201
|
+
models: models || {},
|
|
202
|
+
};
|
|
203
|
+
} else if (harness === "codex") {
|
|
204
|
+
await writeCodexLaunchProfile({ gatewayBaseUrl, subscriptionAuth });
|
|
205
|
+
config.harnesses.codex = {
|
|
206
|
+
enabled: true,
|
|
207
|
+
subscription_auth: Boolean(subscriptionAuth),
|
|
208
|
+
};
|
|
209
|
+
} else {
|
|
210
|
+
throw new Error(`The penny command does not support ${harness}.`);
|
|
211
|
+
}
|
|
212
|
+
await savePennyConfig(config);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export async function setPennyHarnessEnabled(harness, enabled) {
|
|
216
|
+
const config = await loadPennyConfig();
|
|
217
|
+
const key = harness === "claude-code" ? "claude" : harness;
|
|
218
|
+
if (config.harnesses?.[key]) {
|
|
219
|
+
config.harnesses[key].enabled = Boolean(enabled);
|
|
220
|
+
await savePennyConfig(config);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export async function removePennyHarness(harness) {
|
|
225
|
+
const config = await loadPennyConfig();
|
|
226
|
+
const key = harness === "claude-code" ? "claude" : harness;
|
|
227
|
+
if (harness === "claude-code") {
|
|
228
|
+
await unlink(claudeLaunchSettingsPath()).catch((error) => {
|
|
229
|
+
if (error.code !== "ENOENT") throw error;
|
|
230
|
+
});
|
|
231
|
+
} else if (harness === "codex") {
|
|
232
|
+
const existing = await readText(codexLaunchProfilePath(), "");
|
|
233
|
+
if (existing.startsWith(CODEX_PROFILE_MARKER)) {
|
|
234
|
+
await unlink(codexLaunchProfilePath()).catch((error) => {
|
|
235
|
+
if (error.code !== "ENOENT") throw error;
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
if (config.harnesses) delete config.harnesses[key];
|
|
240
|
+
await savePennyConfig(config);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export async function hasConfiguredPennyHarnesses() {
|
|
244
|
+
const config = await loadPennyConfig();
|
|
245
|
+
return Object.keys(config.harnesses || {}).length > 0;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
async function removeManagedPathBlock(rcPath) {
|
|
249
|
+
if (!rcPath) return;
|
|
250
|
+
const current = await readText(rcPath, "");
|
|
251
|
+
if (!current.includes(PATH_START)) return;
|
|
252
|
+
const escapedStart = PATH_START.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
253
|
+
const escapedEnd = PATH_END.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
254
|
+
const next = current
|
|
255
|
+
.replace(new RegExp(`(?:^|\\n)${escapedStart}\\n[\\s\\S]*?\\n${escapedEnd}\\n?`, "m"), "\n")
|
|
256
|
+
.replace(/^\n+/, "");
|
|
257
|
+
await atomicWrite(rcPath, next);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export async function removePennyRuntime() {
|
|
261
|
+
const config = await loadPennyConfig();
|
|
262
|
+
await removeManagedPathBlock(config.path_rc);
|
|
263
|
+
await unlink(pennyShimPath()).catch((error) => {
|
|
264
|
+
if (error.code !== "ENOENT") throw error;
|
|
265
|
+
});
|
|
266
|
+
await rm(pennyRuntimeDir(), { recursive: true, force: true });
|
|
267
|
+
await unlink(pennyConfigPath()).catch((error) => {
|
|
268
|
+
if (error.code !== "ENOENT") throw error;
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function assertNoConflictingArgs(harness, args) {
|
|
273
|
+
if (harness === "claude" && args.some((arg) => arg === "--settings" || arg.startsWith("--settings="))) {
|
|
274
|
+
throw new Error("penny claude manages --settings; remove that flag or run native claude.");
|
|
275
|
+
}
|
|
276
|
+
if (harness === "codex" && args.some((arg) => arg === "--profile" || arg === "-p" || arg.startsWith("--profile="))) {
|
|
277
|
+
throw new Error("penny codex manages --profile; remove that flag or run native codex.");
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
export function buildPennyInvocation(harness, args, config) {
|
|
282
|
+
const item = config.harnesses?.[harness];
|
|
283
|
+
if (!item || !item.enabled) {
|
|
284
|
+
throw new Error(`PennyRouter is not enabled for ${harness}. Run npx pennyrouter install.`);
|
|
285
|
+
}
|
|
286
|
+
assertNoConflictingArgs(harness, args);
|
|
287
|
+
const env = { ...process.env, PENNYROUTER_API_KEY: config.api_key };
|
|
288
|
+
if (harness === "claude") {
|
|
289
|
+
const models = item.models || {};
|
|
290
|
+
env.ANTHROPIC_BASE_URL = config.gateway_base_url;
|
|
291
|
+
env.ANTHROPIC_AUTH_TOKEN = config.api_key;
|
|
292
|
+
delete env.ANTHROPIC_API_KEY;
|
|
293
|
+
if (models.haiku) env.ANTHROPIC_DEFAULT_HAIKU_MODEL = models.haiku;
|
|
294
|
+
if (models.sonnet) env.ANTHROPIC_DEFAULT_SONNET_MODEL = models.sonnet;
|
|
295
|
+
if (models.opus) env.ANTHROPIC_DEFAULT_OPUS_MODEL = models.opus;
|
|
296
|
+
if (models.fable) env.ANTHROPIC_DEFAULT_FABLE_MODEL = models.fable;
|
|
297
|
+
return {
|
|
298
|
+
command: "claude",
|
|
299
|
+
args: ["--settings", claudeLaunchSettingsPath(), "--plugin-dir", claudePluginPath(), ...args],
|
|
300
|
+
env,
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
if (harness === "codex") {
|
|
304
|
+
return { command: "codex", args: ["--profile", "pennyrouter", ...args], env };
|
|
305
|
+
}
|
|
306
|
+
throw new Error(`Unknown Penny harness "${harness}". Try penny claude or penny codex.`);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
async function waitForChild(child) {
|
|
310
|
+
return new Promise((resolvePromise, reject) => {
|
|
311
|
+
child.once("error", reject);
|
|
312
|
+
child.once("exit", (code, signal) => resolvePromise({ code, signal }));
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
export async function runPenny(argv = process.argv.slice(2)) {
|
|
317
|
+
const harness = argv[0];
|
|
318
|
+
if (!harness || harness === "help" || harness === "--help" || harness === "-h") {
|
|
319
|
+
console.log("Usage: penny <claude|codex> [native arguments...]");
|
|
320
|
+
return 0;
|
|
321
|
+
}
|
|
322
|
+
if (!(["claude", "codex"].includes(harness))) {
|
|
323
|
+
throw new Error(`Unknown harness "${harness}". Try penny claude or penny codex.`);
|
|
324
|
+
}
|
|
325
|
+
const config = await loadPennyConfig();
|
|
326
|
+
const invocation = buildPennyInvocation(harness, argv.slice(1), config);
|
|
327
|
+
const child = spawn(invocation.command, invocation.args, {
|
|
328
|
+
cwd: process.cwd(),
|
|
329
|
+
env: invocation.env,
|
|
330
|
+
stdio: "inherit",
|
|
331
|
+
});
|
|
332
|
+
const result = await waitForChild(child);
|
|
333
|
+
if (result.signal) return 128 + (osConstants.signals[result.signal] || 0);
|
|
334
|
+
return result.code ?? 1;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
export async function fileMode(path) {
|
|
338
|
+
return (await stat(path)).mode & 0o777;
|
|
339
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
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
|
+
claudePluginPath,
|
|
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
|
+
const ENV_KEYS = [
|
|
24
|
+
"HOME", "XDG_CONFIG_HOME", "XDG_DATA_HOME", "XDG_STATE_HOME", "CODEX_HOME", "PATH", "SHELL",
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
async function main() {
|
|
28
|
+
if (!(["darwin", "linux"].includes(process.platform))) {
|
|
29
|
+
console.log("launch.test.js: skipped on unsupported platform");
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), "pr-launch-test-"));
|
|
34
|
+
const previous = Object.fromEntries(ENV_KEYS.map((key) => [key, process.env[key]]));
|
|
35
|
+
const home = path.join(root, "home");
|
|
36
|
+
fs.mkdirSync(path.join(home, ".claude"), { recursive: true });
|
|
37
|
+
fs.mkdirSync(path.join(home, ".codex"), { recursive: true });
|
|
38
|
+
const nativeClaude = `${JSON.stringify({
|
|
39
|
+
permissions: { allow: ["Read"] },
|
|
40
|
+
statusLine: { type: "command", command: "native-status" },
|
|
41
|
+
}, null, 2)}\n`;
|
|
42
|
+
const nativeCodex = 'model = "native-model"\n';
|
|
43
|
+
fs.writeFileSync(path.join(home, ".claude", "settings.json"), nativeClaude);
|
|
44
|
+
fs.writeFileSync(path.join(home, ".codex", "config.toml"), nativeCodex);
|
|
45
|
+
fs.writeFileSync(path.join(home, ".zshrc"), "# user setting\n");
|
|
46
|
+
|
|
47
|
+
Object.assign(process.env, {
|
|
48
|
+
HOME: home,
|
|
49
|
+
XDG_CONFIG_HOME: path.join(home, ".config"),
|
|
50
|
+
XDG_DATA_HOME: path.join(home, ".local", "share"),
|
|
51
|
+
XDG_STATE_HOME: path.join(home, ".local", "state"),
|
|
52
|
+
CODEX_HOME: path.join(home, ".codex"),
|
|
53
|
+
PATH: "/usr/bin:/bin",
|
|
54
|
+
SHELL: "/bin/zsh",
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
try {
|
|
58
|
+
const first = await installPennyRuntime();
|
|
59
|
+
const second = await installPennyRuntime();
|
|
60
|
+
assert.equal(first.supported, true);
|
|
61
|
+
assert.equal(first.changed, true);
|
|
62
|
+
assert.equal(second.changed, false);
|
|
63
|
+
assert.equal(await fileMode(pennyShimPath()), 0o755);
|
|
64
|
+
assert.ok(fs.existsSync(path.join(pennyRuntimeDir(), "bin", "penny.js")));
|
|
65
|
+
assert.ok(fs.existsSync(path.join(claudePluginPath(), ".claude-plugin", "plugin.json")));
|
|
66
|
+
const rc = fs.readFileSync(path.join(home, ".zshrc"), "utf8");
|
|
67
|
+
assert.equal((rc.match(/>>> pennyrouter PATH >>>/g) || []).length, 1);
|
|
68
|
+
|
|
69
|
+
await configurePennyHarness("claude-code", {
|
|
70
|
+
apiKey: "pr-super-secret",
|
|
71
|
+
gatewayBaseUrl: "https://gateway.example/",
|
|
72
|
+
subscriptionAuth: true,
|
|
73
|
+
models: { haiku: "Penny Haiku", sonnet: "Penny Sonnet", opus: "Penny Opus" },
|
|
74
|
+
});
|
|
75
|
+
await configurePennyHarness("codex", {
|
|
76
|
+
apiKey: "pr-super-secret",
|
|
77
|
+
gatewayBaseUrl: "https://gateway.example/",
|
|
78
|
+
subscriptionAuth: true,
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
assert.equal(fs.readFileSync(path.join(home, ".claude", "settings.json"), "utf8"), nativeClaude);
|
|
82
|
+
assert.equal(fs.readFileSync(path.join(home, ".codex", "config.toml"), "utf8"), nativeCodex);
|
|
83
|
+
assert.equal(await fileMode(pennyConfigPath()), 0o600);
|
|
84
|
+
assert.equal(await fileMode(claudeLaunchSettingsPath()), 0o600);
|
|
85
|
+
assert.equal(await fileMode(codexLaunchProfilePath()), 0o600);
|
|
86
|
+
|
|
87
|
+
const config = await loadPennyConfig();
|
|
88
|
+
assert.equal(config.api_key, "pr-super-secret");
|
|
89
|
+
const codexProfile = fs.readFileSync(codexLaunchProfilePath(), "utf8");
|
|
90
|
+
assert.ok(codexProfile.includes('env_http_headers = { "x-pennyrouter-key" = "PENNYROUTER_API_KEY" }'));
|
|
91
|
+
assert.ok(!codexProfile.includes("pr-super-secret"));
|
|
92
|
+
assert.ok(!rc.includes("pr-super-secret"));
|
|
93
|
+
|
|
94
|
+
const claude = buildPennyInvocation("claude", ["--resume", "abc"], config);
|
|
95
|
+
assert.equal(claude.command, "claude");
|
|
96
|
+
assert.deepEqual(claude.args.slice(-2), ["--resume", "abc"]);
|
|
97
|
+
assert.ok(claude.args.includes("--plugin-dir"));
|
|
98
|
+
assert.equal(claude.env.ANTHROPIC_AUTH_TOKEN, "pr-super-secret");
|
|
99
|
+
assert.equal(claude.env.ANTHROPIC_BASE_URL, "https://gateway.example/");
|
|
100
|
+
assert.equal(claude.env.ANTHROPIC_DEFAULT_OPUS_MODEL, "Penny Opus");
|
|
101
|
+
|
|
102
|
+
const codex = buildPennyInvocation("codex", ["resume", "--last"], config);
|
|
103
|
+
assert.deepEqual(codex.args, ["--profile", "pennyrouter", "resume", "--last"]);
|
|
104
|
+
assert.equal(codex.env.PENNYROUTER_API_KEY, "pr-super-secret");
|
|
105
|
+
assert.throws(() => buildPennyInvocation("claude", ["--settings=x"], config), /manages --settings/);
|
|
106
|
+
assert.throws(() => buildPennyInvocation("codex", ["-p", "other"], config), /manages --profile/);
|
|
107
|
+
|
|
108
|
+
const skill = fs.readFileSync(path.join(claudePluginPath(), "skills", "penny", "SKILL.md"), "utf8");
|
|
109
|
+
assert.ok(skill.includes("@penny $ARGUMENTS"));
|
|
110
|
+
|
|
111
|
+
await removePennyHarness("claude-code");
|
|
112
|
+
assert.ok(fs.existsSync(codexLaunchProfilePath()));
|
|
113
|
+
assert.ok(fs.existsSync(pennyShimPath()));
|
|
114
|
+
await removePennyHarness("codex");
|
|
115
|
+
await removePennyRuntime();
|
|
116
|
+
assert.ok(!fs.existsSync(pennyShimPath()));
|
|
117
|
+
assert.ok(!fs.existsSync(pennyRuntimeDir()));
|
|
118
|
+
assert.ok(!fs.existsSync(pennyConfigPath()));
|
|
119
|
+
assert.equal(fs.readFileSync(path.join(home, ".zshrc"), "utf8"), "# user setting\n");
|
|
120
|
+
|
|
121
|
+
const paid = renderCodexLaunchProfile({
|
|
122
|
+
gatewayBaseUrl: "https://gateway.example/", subscriptionAuth: false,
|
|
123
|
+
});
|
|
124
|
+
assert.ok(paid.includes('env_key = "PENNYROUTER_API_KEY"'));
|
|
125
|
+
assert.ok(!paid.includes("requires_openai_auth"));
|
|
126
|
+
} finally {
|
|
127
|
+
for (const key of ENV_KEYS) {
|
|
128
|
+
if (previous[key] === undefined) delete process.env[key];
|
|
129
|
+
else process.env[key] = previous[key];
|
|
130
|
+
}
|
|
131
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
console.log("launch.test.js: all checks passed");
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
main().catch((error) => {
|
|
138
|
+
console.error(error);
|
|
139
|
+
process.exit(1);
|
|
140
|
+
});
|
package/src/session.js
CHANGED
|
@@ -5,7 +5,7 @@ export function maskKey(key) {
|
|
|
5
5
|
return `${key.slice(0, 3)}...${key.slice(-4)}`;
|
|
6
6
|
}
|
|
7
7
|
|
|
8
|
-
export async function createCliSession({ appUrl = APP_URL, harnesses, accountMode = "
|
|
8
|
+
export async function createCliSession({ appUrl = APP_URL, harnesses, accountMode = "existing" }) {
|
|
9
9
|
const label = `${platformName()} / ${harnesses.join(",") || "PennyRouter"}`;
|
|
10
10
|
const response = await fetch(`${appUrl}/api/cli/session`, {
|
|
11
11
|
method: "POST",
|
|
@@ -71,6 +71,61 @@ export async function storeAnthropicToken({ gatewayBaseUrl, apiKey, token }) {
|
|
|
71
71
|
return response.json();
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
+
// Exercise the exact modern Claude Code request feature that has historically broken at
|
|
75
|
+
// proxy boundaries BEFORE changing ~/.claude/settings.json (a user-global redirect). This
|
|
76
|
+
// is intentionally a real, tiny Messages call: a generic /health check cannot prove that
|
|
77
|
+
// Anthropic beta headers survive the gateway's subscription/native relay.
|
|
78
|
+
export async function probeClaudeCodeGateway({ gatewayBaseUrl, apiKey }) {
|
|
79
|
+
const url = `${gatewayBaseUrl.replace(/\/+$/, "")}/v1/messages?beta=true`;
|
|
80
|
+
const controller = new AbortController();
|
|
81
|
+
const timer = setTimeout(() => controller.abort(), 20_000);
|
|
82
|
+
let response;
|
|
83
|
+
try {
|
|
84
|
+
response = await fetch(url, {
|
|
85
|
+
method: "POST",
|
|
86
|
+
signal: controller.signal,
|
|
87
|
+
headers: {
|
|
88
|
+
"content-type": "application/json",
|
|
89
|
+
authorization: `Bearer ${apiKey}`,
|
|
90
|
+
"anthropic-beta": "compact-2026-01-12",
|
|
91
|
+
},
|
|
92
|
+
body: JSON.stringify({
|
|
93
|
+
model: "Penny Sonnet",
|
|
94
|
+
max_tokens: 16,
|
|
95
|
+
stream: false,
|
|
96
|
+
messages: [{ role: "user", content: "Reply OK." }],
|
|
97
|
+
tools: [{
|
|
98
|
+
name: "pennyrouter_install_probe",
|
|
99
|
+
description: "No-op compatibility probe; do not call it.",
|
|
100
|
+
input_schema: { type: "object", properties: {} },
|
|
101
|
+
}],
|
|
102
|
+
context_management: { edits: [{ type: "compact_20260112" }] },
|
|
103
|
+
}),
|
|
104
|
+
});
|
|
105
|
+
} catch (error) {
|
|
106
|
+
if (error?.name === "AbortError") {
|
|
107
|
+
throw new Error("Gateway compatibility check timed out; no Claude Code settings were changed.");
|
|
108
|
+
}
|
|
109
|
+
throw new Error(`Gateway compatibility check failed; no Claude Code settings were changed: ${error?.message || error}`);
|
|
110
|
+
} finally {
|
|
111
|
+
clearTimeout(timer);
|
|
112
|
+
}
|
|
113
|
+
if (!response.ok) {
|
|
114
|
+
let detail = "";
|
|
115
|
+
try {
|
|
116
|
+
detail = (await response.text()).slice(0, 500).replace(/\s+/g, " ").trim();
|
|
117
|
+
} catch {
|
|
118
|
+
// Status alone is enough for a safe failure.
|
|
119
|
+
}
|
|
120
|
+
throw new Error(
|
|
121
|
+
`Gateway compatibility check failed: HTTP ${response.status}${detail ? ` — ${detail}` : ""}. `
|
|
122
|
+
+ "The PennyRouter gateway rejected a modern Claude Code request; deploy/fix the gateway "
|
|
123
|
+
+ "before retrying. No Claude Code settings were changed.",
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
return true;
|
|
127
|
+
}
|
|
128
|
+
|
|
74
129
|
// Best-effort "forget my Claude token" on uninstall. Never throws — a cleanup that
|
|
75
130
|
// can't reach the network shouldn't block removing the local integration.
|
|
76
131
|
export async function forgetAnthropicToken({ gatewayBaseUrl, apiKey }) {
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { createCliSession, probeClaudeCodeGateway } from "./session.js";
|
|
3
|
+
|
|
4
|
+
const originalFetch = globalThis.fetch;
|
|
5
|
+
|
|
6
|
+
try {
|
|
7
|
+
let sessionRequest;
|
|
8
|
+
globalThis.fetch = async (url, options) => {
|
|
9
|
+
sessionRequest = { url, options };
|
|
10
|
+
return {
|
|
11
|
+
ok: true,
|
|
12
|
+
json: async () => ({ session_id: "session-test" }),
|
|
13
|
+
};
|
|
14
|
+
};
|
|
15
|
+
await createCliSession({ appUrl: "https://app.example", harnesses: ["claude-code"] });
|
|
16
|
+
assert.equal(sessionRequest.url, "https://app.example/api/cli/session");
|
|
17
|
+
assert.equal(JSON.parse(sessionRequest.options.body).account_mode, "existing");
|
|
18
|
+
|
|
19
|
+
let seen;
|
|
20
|
+
globalThis.fetch = async (url, options) => {
|
|
21
|
+
seen = { url, options };
|
|
22
|
+
return { ok: true, status: 200 };
|
|
23
|
+
};
|
|
24
|
+
assert.equal(await probeClaudeCodeGateway({
|
|
25
|
+
gatewayBaseUrl: "https://gateway.example/", apiKey: "pr-test",
|
|
26
|
+
}), true);
|
|
27
|
+
assert.equal(seen.url, "https://gateway.example/v1/messages?beta=true");
|
|
28
|
+
assert.equal(seen.options.headers.authorization, "Bearer pr-test");
|
|
29
|
+
assert.equal(seen.options.headers["anthropic-beta"], "compact-2026-01-12");
|
|
30
|
+
const body = JSON.parse(seen.options.body);
|
|
31
|
+
assert.equal(body.context_management.edits[0].type, "compact_20260112");
|
|
32
|
+
assert.equal(body.tools[0].name, "pennyrouter_install_probe");
|
|
33
|
+
|
|
34
|
+
globalThis.fetch = async () => ({
|
|
35
|
+
ok: false, status: 502, text: async () => "context_management rejected",
|
|
36
|
+
});
|
|
37
|
+
await assert.rejects(
|
|
38
|
+
probeClaudeCodeGateway({ gatewayBaseUrl: "https://gateway.example", apiKey: "pr-test" }),
|
|
39
|
+
/HTTP 502.*No Claude Code settings were changed/,
|
|
40
|
+
);
|
|
41
|
+
} finally {
|
|
42
|
+
globalThis.fetch = originalFetch;
|
|
43
|
+
}
|
package/src/statusline.js
CHANGED
|
@@ -153,10 +153,9 @@ function authSuffix(mode) {
|
|
|
153
153
|
return mode === "sub" || mode === "byok" ? " (" + mode + ")" : "";
|
|
154
154
|
}
|
|
155
155
|
|
|
156
|
-
// Teammate badges (@penny ask)
|
|
157
|
-
//
|
|
158
|
-
//
|
|
159
|
-
// back to you. Both absent on an older gateway, which renders nothing.
|
|
156
|
+
// Teammate badges (@penny ask): 📨 questions waiting on you, 📬 answers that came back.
|
|
157
|
+
// Read from the structured fields rather than the gateway's prebuilt \`label\`, since this
|
|
158
|
+
// line is composed here. Absent on an older gateway, which renders nothing.
|
|
160
159
|
function askBadges(c) {
|
|
161
160
|
if (!c) return "";
|
|
162
161
|
let out = "";
|