pennyrouter 0.2.30 → 0.2.31
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 +5 -5
- package/package.json +1 -1
- package/src/cli.js +2 -48
- package/src/launch.js +22 -12
- package/src/launch.test.js +2 -0
package/README.md
CHANGED
|
@@ -56,9 +56,9 @@ the user explicitly changes it. Close running Claude Code or Codex sessions only
|
|
|
56
56
|
removing global bare-command routing. A Penny-only install does not require closing native sessions.
|
|
57
57
|
|
|
58
58
|
The launcher uses a private config, a Claude settings overlay, and a secret-free Codex profile.
|
|
59
|
-
The installer adds
|
|
60
|
-
user-PATH entry on Windows, when needed; `--no-path-update` skips that
|
|
61
|
-
after a PATH update.
|
|
59
|
+
The installer adds marked PATH blocks to the interactive and login startup files for zsh or bash
|
|
60
|
+
on macOS/Linux, or a single user-PATH entry on Windows, when needed; `--no-path-update` skips that
|
|
61
|
+
edit. Open a new terminal after a PATH update.
|
|
62
62
|
|
|
63
63
|
In Claude Code sessions launched with `penny claude`, `/penny help` is an alias for the existing
|
|
64
64
|
`@penny help` syntax. Both forms remain supported. Codex continues to use `@penny` because Codex
|
|
@@ -112,8 +112,8 @@ forward. Use `install` instead when the intent is to change those choices.
|
|
|
112
112
|
|
|
113
113
|
Because the managed tool config is rewritten only for tools whose bare command already routes
|
|
114
114
|
through PennyRouter, a Penny-only install leaves files such as `~/.claude/settings.json`
|
|
115
|
-
untouched and needs no session shutdown.
|
|
116
|
-
|
|
115
|
+
untouched and needs no session shutdown. Updates do not inspect or require stopping running
|
|
116
|
+
harness processes. Unrelated keys and user
|
|
117
117
|
settings inside managed config files are preserved, and re-running `update` is idempotent.
|
|
118
118
|
|
|
119
119
|
Restart Claude Code or Codex afterwards to pick up the refreshed configuration.
|
package/package.json
CHANGED
package/src/cli.js
CHANGED
|
@@ -364,7 +364,6 @@ async function authProvider(flags) {
|
|
|
364
364
|
const ownsGlobalRouting = existingRecord
|
|
365
365
|
? existingRecord.default_routing !== false
|
|
366
366
|
: Boolean(await readClaudeCodePennyRouterKey());
|
|
367
|
-
if (ownsGlobalRouting) assertNoRunningHarness("claude-code", flags);
|
|
368
367
|
await storeAnthropicToken({ gatewayBaseUrl, apiKey: pennyKey, token });
|
|
369
368
|
await probeClaudeCodeGateway({ gatewayBaseUrl, apiKey: pennyKey });
|
|
370
369
|
await installPennyRuntime({ updatePath: !flags.noPathUpdate });
|
|
@@ -633,9 +632,6 @@ export async function install(flags = {}) {
|
|
|
633
632
|
const supportsPennyCommand = ["claude-code", "codex", "opencode"].includes(harness.id);
|
|
634
633
|
const makeDefault = defaultRouting.get(harness.id) ?? true;
|
|
635
634
|
const hadGlobalRouting = Boolean(existingRecord && existingRecord.default_routing !== false);
|
|
636
|
-
if (["claude-code", "codex"].includes(harness.id) && (makeDefault || hadGlobalRouting)) {
|
|
637
|
-
assertNoRunningHarness(harness.id, flags);
|
|
638
|
-
}
|
|
639
635
|
|
|
640
636
|
const harnessNeededAuth = needsAuth.includes(harness);
|
|
641
637
|
const codexSubscriptionAuth = harness.id === "codex"
|
|
@@ -947,7 +943,6 @@ async function update(flags) {
|
|
|
947
943
|
// rewrite — i.e. the bare command still routes through the gateway. In penny-only
|
|
948
944
|
// mode the harness config belongs to the user and we touch just the launch profile.
|
|
949
945
|
const ownsGlobalRouting = !record.launch_available || record.default_routing !== false;
|
|
950
|
-
if (ownsGlobalRouting) assertNoRunningHarness(record.harness, flags);
|
|
951
946
|
|
|
952
947
|
const claudeSubscriptionAuth = record.harness === "claude-code"
|
|
953
948
|
&& Boolean(record.anthropic_token_stored);
|
|
@@ -2032,53 +2027,12 @@ function openBrowser(url) {
|
|
|
2032
2027
|
spawnSync(command, args, { stdio: "ignore", detached: true });
|
|
2033
2028
|
}
|
|
2034
2029
|
|
|
2035
|
-
function runningClaudeCodeProcesses() {
|
|
2036
|
-
if (process.platform === "win32") {
|
|
2037
|
-
const result = spawnSync("tasklist", ["/FO", "CSV", "/NH"], { encoding: "utf8" });
|
|
2038
|
-
if (result.status !== 0) return [];
|
|
2039
|
-
return String(result.stdout || "").split(/\r?\n/)
|
|
2040
|
-
.filter((line) => /^"claude(?:\.exe)?"/i.test(line.trim()));
|
|
2041
|
-
}
|
|
2042
|
-
const result = spawnSync("ps", ["-ax", "-o", "pid=,command="], { encoding: "utf8" });
|
|
2043
|
-
if (result.status !== 0) return [];
|
|
2044
|
-
return String(result.stdout || "").split(/\r?\n/)
|
|
2045
|
-
.map((line) => line.trim())
|
|
2046
|
-
// Case-sensitive on purpose: match the Claude Code executable, not Claude.app.
|
|
2047
|
-
.filter((line) => /(^|[\/\s])claude(?:\s|$)/.test(line));
|
|
2048
|
-
}
|
|
2049
|
-
|
|
2050
|
-
function runningCodexProcesses() {
|
|
2051
|
-
if (process.platform === "win32") return [];
|
|
2052
|
-
const result = spawnSync("ps", ["-ax", "-o", "pid=,command="], { encoding: "utf8" });
|
|
2053
|
-
if (result.status !== 0) return [];
|
|
2054
|
-
return String(result.stdout || "").split(/\r?\n/)
|
|
2055
|
-
.map((line) => line.trim())
|
|
2056
|
-
.filter((line) => /(^|[\/\s])codex(?:\s|$)/.test(line));
|
|
2057
|
-
}
|
|
2058
|
-
|
|
2059
|
-
function assertNoRunningHarness(harnessId, flags) {
|
|
2060
|
-
if (flags.allowRunningHarnesses || (harnessId === "claude-code" && flags.allowRunningClaude)) {
|
|
2061
|
-
return;
|
|
2062
|
-
}
|
|
2063
|
-
const running = harnessId === "claude-code"
|
|
2064
|
-
? runningClaudeCodeProcesses()
|
|
2065
|
-
: runningCodexProcesses();
|
|
2066
|
-
if (running.length === 0) return;
|
|
2067
|
-
const name = harnessId === "claude-code" ? "Claude Code" : "Codex";
|
|
2068
|
-
throw new Error(
|
|
2069
|
-
`${name} is currently running. Close all ${name} sessions before changing its global routing. `
|
|
2070
|
-
+ "Installing only the explicit `penny` command does not require this. If you intentionally "
|
|
2071
|
-
+ "accept the risk, pass --allow-running-harnesses.\nDetected:\n "
|
|
2072
|
-
+ running.slice(0, 5).join("\n "),
|
|
2073
|
-
);
|
|
2074
|
-
}
|
|
2075
|
-
|
|
2076
2030
|
function printHelp() {
|
|
2077
2031
|
console.log(`PennyRouter CLI
|
|
2078
2032
|
|
|
2079
2033
|
Usage:
|
|
2080
|
-
pennyrouter install [--harness ${SUPPORTED_HARNESS_IDS}] [--all] [--yes] [--dry-run] [--make-default|--penny-only] [--no-path-update] [--no-mcp] [--anthropic-auth|--no-anthropic-auth] [--anthropic-api-key KEY] [--openai-api-key KEY] [--openrouter-api-key KEY] [--proxy-base-url URL --proxy-api-key KEY] [--local[=URL]|--prod] [--config PATH]
|
|
2081
|
-
pennyrouter update [--harness ${SUPPORTED_HARNESS_IDS}] [--all] [--dry-run] [--penny-key pr-...] [--gateway-base-url URL|--local[=URL]|--prod] [--no-mcp]
|
|
2034
|
+
pennyrouter install [--harness ${SUPPORTED_HARNESS_IDS}] [--all] [--yes] [--dry-run] [--make-default|--penny-only] [--no-path-update] [--no-mcp] [--anthropic-auth|--no-anthropic-auth] [--anthropic-api-key KEY] [--openai-api-key KEY] [--openrouter-api-key KEY] [--proxy-base-url URL --proxy-api-key KEY] [--local[=URL]|--prod] [--config PATH]
|
|
2035
|
+
pennyrouter update [--harness ${SUPPORTED_HARNESS_IDS}] [--all] [--dry-run] [--penny-key pr-...] [--gateway-base-url URL|--local[=URL]|--prod] [--no-mcp]
|
|
2082
2036
|
pennyrouter auth anthropic [--penny-key pr-...] [--token oauth-token] [--token-command CMD] [--gateway-base-url URL] [--forget]
|
|
2083
2037
|
pennyrouter disable [--harness ${SUPPORTED_HARNESS_IDS}] [--all] [--yes]
|
|
2084
2038
|
pennyrouter enable [--harness ${SUPPORTED_HARNESS_IDS}] [--all] [--yes]
|
package/src/launch.js
CHANGED
|
@@ -197,26 +197,34 @@ async function ensurePennyOnWindowsPath({ updatePath }) {
|
|
|
197
197
|
return { available: true, changed: true, path_managed: true };
|
|
198
198
|
}
|
|
199
199
|
|
|
200
|
-
function
|
|
200
|
+
function shellRcPaths() {
|
|
201
201
|
const shell = basename(process.env.SHELL || "");
|
|
202
|
-
if (shell === "zsh") return join(homeDir(), ".zshrc");
|
|
203
|
-
if (shell === "bash") return join(homeDir(), ".bashrc");
|
|
204
|
-
return
|
|
202
|
+
if (shell === "zsh") return [join(homeDir(), ".zshrc"), join(homeDir(), ".zprofile")];
|
|
203
|
+
if (shell === "bash") return [join(homeDir(), ".bashrc"), join(homeDir(), ".bash_profile")];
|
|
204
|
+
return [];
|
|
205
205
|
}
|
|
206
206
|
|
|
207
207
|
export async function ensurePennyOnPath({ updatePath = true } = {}) {
|
|
208
208
|
if (process.platform === "win32") return ensurePennyOnWindowsPath({ updatePath });
|
|
209
209
|
if (pathContains(pennyBinDir())) return { available: true, changed: false, rc_path: null };
|
|
210
210
|
if (!updatePath) return { available: false, changed: false, rc_path: null };
|
|
211
|
-
const
|
|
212
|
-
if (
|
|
213
|
-
const
|
|
214
|
-
|
|
215
|
-
|
|
211
|
+
const rcPaths = shellRcPaths();
|
|
212
|
+
if (rcPaths.length === 0) return { available: false, changed: false, rc_path: null, rc_paths: [] };
|
|
213
|
+
const block = `${PATH_START}\nexport PATH="$HOME/.local/bin:$PATH"\n${PATH_END}`;
|
|
214
|
+
const changedPaths = [];
|
|
215
|
+
for (const rcPath of rcPaths) {
|
|
216
|
+
const current = await readText(rcPath, "");
|
|
217
|
+
if (current.includes(PATH_START)) continue;
|
|
216
218
|
const prefix = current && !current.endsWith("\n") ? "\n" : "";
|
|
217
219
|
await atomicWrite(rcPath, `${current}${prefix}${block}\n`);
|
|
220
|
+
changedPaths.push(rcPath);
|
|
218
221
|
}
|
|
219
|
-
return {
|
|
222
|
+
return {
|
|
223
|
+
available: true,
|
|
224
|
+
changed: changedPaths.length > 0,
|
|
225
|
+
rc_path: rcPaths[0],
|
|
226
|
+
rc_paths: rcPaths,
|
|
227
|
+
};
|
|
220
228
|
}
|
|
221
229
|
|
|
222
230
|
export async function installPennyRuntime({ updatePath = true } = {}) {
|
|
@@ -227,8 +235,9 @@ export async function installPennyRuntime({ updatePath = true } = {}) {
|
|
|
227
235
|
await writeShim();
|
|
228
236
|
const path = await ensurePennyOnPath({ updatePath });
|
|
229
237
|
const config = await loadPennyConfig();
|
|
230
|
-
if (path.
|
|
238
|
+
if (path.rc_paths?.length > 0) {
|
|
231
239
|
config.path_rc = path.rc_path;
|
|
240
|
+
config.path_rcs = path.rc_paths;
|
|
232
241
|
await savePennyConfig(config);
|
|
233
242
|
}
|
|
234
243
|
if (process.platform === "win32" && path.path_managed) {
|
|
@@ -493,7 +502,8 @@ export async function removePennyRuntime() {
|
|
|
493
502
|
if (process.platform === "win32") {
|
|
494
503
|
if (config.windows_path_managed) removeWindowsPathEntry();
|
|
495
504
|
} else {
|
|
496
|
-
|
|
505
|
+
const paths = config.path_rcs || [config.path_rc];
|
|
506
|
+
for (const rcPath of paths) await removeManagedPathBlock(rcPath);
|
|
497
507
|
}
|
|
498
508
|
await unlink(pennyShimPath()).catch((error) => {
|
|
499
509
|
if (error.code !== "ENOENT") throw error;
|
package/src/launch.test.js
CHANGED
|
@@ -160,6 +160,7 @@ async function main() {
|
|
|
160
160
|
assert.equal(fs.readFileSync(claudeUserSkillPath(), "utf8"), "user-owned command\n");
|
|
161
161
|
assert.ok(fs.existsSync(codexLaunchProfilePath()));
|
|
162
162
|
assert.ok(fs.existsSync(pennyShimPath()));
|
|
163
|
+
assert.ok(fs.readFileSync(path.join(home, ".zprofile"), "utf8").includes(">>> pennyrouter PATH >>>"));
|
|
163
164
|
await removePennyHarness("codex");
|
|
164
165
|
await removePennyHarness("opencode");
|
|
165
166
|
await removePennyRuntime();
|
|
@@ -167,6 +168,7 @@ async function main() {
|
|
|
167
168
|
assert.ok(!fs.existsSync(pennyRuntimeDir()));
|
|
168
169
|
assert.ok(!fs.existsSync(pennyConfigPath()));
|
|
169
170
|
assert.equal(fs.readFileSync(path.join(home, ".zshrc"), "utf8"), "# user setting\n");
|
|
171
|
+
assert.equal(fs.readFileSync(path.join(home, ".zprofile"), "utf8"), "");
|
|
170
172
|
|
|
171
173
|
const paid = renderCodexLaunchProfile({
|
|
172
174
|
gatewayBaseUrl: "https://gateway.example/", subscriptionAuth: false,
|