pennyrouter 0.2.29 → 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 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 one marked PATH block to zsh or bash startup files on macOS/Linux, or a single
60
- user-PATH entry on Windows, when needed; `--no-path-update` skips that edit. Open a new terminal
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. Updating a tool that owns global routing requires
116
- closing its running sessions, or passing `--allow-running-harnesses`. Unrelated keys and user
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pennyrouter",
3
- "version": "0.2.29",
3
+ "version": "0.2.31",
4
4
  "description": "Install and manage PennyRouter local coding-agent integrations.",
5
5
  "homepage": "https://pennyrouter.com",
6
6
  "bugs": {
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"
@@ -730,7 +726,7 @@ export async function install(flags = {}) {
730
726
  console.log(color(`How to Run ${harnessName}`, "bold"));
731
727
  console.log("");
732
728
  console.log(` ${color(command.padEnd(commandWidth), "bold")}→ ${harnessName} with PennyRouter`);
733
- console.log(` ${color(native.padEnd(commandWidth), "bold")}→ ${record.default_routing ? `${harnessName} with PennyRouter (default routing enabled)` : `${harnessName} native`}`);
729
+ console.log(` ${color(native.padEnd(commandWidth), "bold")}→ ${record.default_routing ? `${harnessName}` : `${harnessName} native`}`);
734
730
  } else {
735
731
  console.log(`Installed on ${harness?.name || record.harness}${installNoteForRecord(record)}.`);
736
732
  }
@@ -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] [--allow-running-harnesses]
2081
- pennyrouter update [--harness ${SUPPORTED_HARNESS_IDS}] [--all] [--dry-run] [--penny-key pr-...] [--gateway-base-url URL|--local[=URL]|--prod] [--no-mcp] [--allow-running-harnesses]
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 shellRcPath() {
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 null;
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 rcPath = shellRcPath();
212
- if (!rcPath) return { available: false, changed: false, rc_path: null };
213
- const current = await readText(rcPath, "");
214
- if (!current.includes(PATH_START)) {
215
- const block = `${PATH_START}\nexport PATH="$HOME/.local/bin:$PATH"\n${PATH_END}`;
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 { available: true, changed: !current.includes(PATH_START), rc_path: rcPath };
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.rc_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
- await removeManagedPathBlock(config.path_rc);
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;
@@ -96,6 +96,7 @@ async function main() {
96
96
 
97
97
  const config = await loadPennyConfig();
98
98
  assert.equal(config.api_key, "pr-super-secret");
99
+ assert.equal(config.harnesses.opencode.enabled, true);
99
100
  const codexProfile = fs.readFileSync(codexLaunchProfilePath(), "utf8");
100
101
  assert.ok(codexProfile.includes('env_http_headers = { "x-pennyrouter-key" = "PENNYROUTER_API_KEY" }'));
101
102
  assert.ok(codexProfile.includes("[tui]\nterminal_title = []"));
@@ -122,6 +123,7 @@ async function main() {
122
123
  assert.deepEqual(codex.args, ["--profile", "pennyrouter", "resume", "--last"]);
123
124
  assert.equal(codex.env.PENNYROUTER_API_KEY, "pr-super-secret");
124
125
  const opencode = buildPennyInvocation("opencode", ["--continue"], config);
126
+ assert.equal(opencode.env.PENNYROUTER_API_KEY, "pr-super-secret");
125
127
  assert.deepEqual(opencode, {
126
128
  command: "opencode",
127
129
  args: ["--continue"],
@@ -158,6 +160,7 @@ async function main() {
158
160
  assert.equal(fs.readFileSync(claudeUserSkillPath(), "utf8"), "user-owned command\n");
159
161
  assert.ok(fs.existsSync(codexLaunchProfilePath()));
160
162
  assert.ok(fs.existsSync(pennyShimPath()));
163
+ assert.ok(fs.readFileSync(path.join(home, ".zprofile"), "utf8").includes(">>> pennyrouter PATH >>>"));
161
164
  await removePennyHarness("codex");
162
165
  await removePennyHarness("opencode");
163
166
  await removePennyRuntime();
@@ -165,6 +168,7 @@ async function main() {
165
168
  assert.ok(!fs.existsSync(pennyRuntimeDir()));
166
169
  assert.ok(!fs.existsSync(pennyConfigPath()));
167
170
  assert.equal(fs.readFileSync(path.join(home, ".zshrc"), "utf8"), "# user setting\n");
171
+ assert.equal(fs.readFileSync(path.join(home, ".zprofile"), "utf8"), "");
168
172
 
169
173
  const paid = renderCodexLaunchProfile({
170
174
  gatewayBaseUrl: "https://gateway.example/", subscriptionAuth: false,