pennyrouter 0.3.19 → 0.3.21

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
@@ -18,6 +18,7 @@ npx pennyrouter disable
18
18
  npx pennyrouter enable
19
19
  npx pennyrouter uninstall
20
20
  npx pennyrouter status
21
+ npx pennyrouter compare
21
22
  ```
22
23
 
23
24
  Installation opens a browser and requires login to the PennyRouter account that should own
@@ -46,9 +47,9 @@ ones:
46
47
  "harnesses": ["claude-code", "codex"],
47
48
  "gatewayBaseUrl": "https://penny-router.apps-dev.gtri.org",
48
49
  "claudeFunding": ["anthropic-proxy"],
49
- "anthropicProxyBaseUrl": "https://litellm.core.gtri.org/v1",
50
+ "anthropicProxyBaseUrl": "https://litellm.core.gtri.org",
50
51
  "codexFunding": ["openai-proxy"],
51
- "openaiProxyBaseUrl": "https://litellm.core.gtri.org/v1"
52
+ "openaiProxyBaseUrl": "https://litellm.core.gtri.org"
52
53
  }
53
54
  ```
54
55
 
@@ -79,6 +80,16 @@ OpenRouter key. PennyRouter-funded provider keys are never an installed-harness
79
80
 
80
81
  ## Explicit Penny Commands
81
82
 
83
+ Penny Compare is bundled with the CLI on macOS and Linux. It is installed automatically as part
84
+ of `npx pennyrouter install`; to install or refresh it separately, run:
85
+
86
+ ```sh
87
+ npx pennyrouter compare
88
+ ```
89
+
90
+ Then use `penny-compare` for a side-by-side run or `penny-session` for one Penny-routed session.
91
+ The bundle requires `tmux`, Python 3.10+, and the harnesses being compared.
92
+
82
93
  On macOS and Linux, a normal install adds a small `penny` launcher under `~/.local/bin`.
83
94
  On Windows it installs `penny.cmd` under `%LOCALAPPDATA%\\PennyRouter\\bin` and adds that
84
95
  directory to the user PATH:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pennyrouter",
3
- "version": "0.3.19",
3
+ "version": "0.3.21",
4
4
  "description": "Install and manage PennyRouter local coding-agent integrations.",
5
5
  "homepage": "https://pennyrouter.com",
6
6
  "bugs": {
@@ -20,6 +20,7 @@
20
20
  "files": [
21
21
  "bin",
22
22
  "src",
23
+ "standalone",
23
24
  "README.md"
24
25
  ],
25
26
  "scripts": {
package/src/cli.js CHANGED
@@ -59,6 +59,7 @@ import {
59
59
  configurePennyHarness,
60
60
  hasConfiguredPennyHarnesses,
61
61
  installPennyRuntime,
62
+ installPennyCompare,
62
63
  loadPennyConfig,
63
64
  pennyConfigPath,
64
65
  removePennyHarness,
@@ -120,6 +121,17 @@ export async function main(argv = process.argv.slice(2)) {
120
121
  return;
121
122
  }
122
123
 
124
+ if (command === "compare") {
125
+ const result = await installPennyCompare();
126
+ if (!result.installed) {
127
+ if (result.supported) throw new Error("Penny Compare is not included in this CLI package.");
128
+ throw new Error("Penny Compare requires macOS or Linux with tmux and Python 3.10+.");
129
+ }
130
+ console.log(`Penny Compare installed at ${result.path}`);
131
+ console.log("Run `penny-compare` or `penny-session` to use it.");
132
+ return;
133
+ }
134
+
123
135
  if (command === "update") {
124
136
  await update(flags);
125
137
  return;
@@ -2436,6 +2448,7 @@ Usage:
2436
2448
  pennyrouter enable [--harness ${SUPPORTED_HARNESS_IDS}] [--all] [--yes]
2437
2449
  pennyrouter uninstall [--harness ${SUPPORTED_HARNESS_IDS}] [--all] [--yes] [--forget-token]
2438
2450
  pennyrouter status
2451
+ pennyrouter compare install or refresh Penny Compare
2439
2452
  pennyrouter profiles [name]
2440
2453
  pennyrouter mcp install|uninstall|status [--harness claude-code,codex]
2441
2454
 
package/src/launch.js CHANGED
@@ -65,6 +65,10 @@ export function pennyRuntimeDir() {
65
65
  return join(pennyDataDir(), "runtime");
66
66
  }
67
67
 
68
+ export function pennyCompareDir() {
69
+ return join(pennyDataDir(), "compare");
70
+ }
71
+
68
72
  export function pennyBinDir() {
69
73
  if (process.platform === "win32") return join(pennyDataDir(), "bin");
70
74
  return join(homeDir(), ".local", "bin");
@@ -257,6 +261,32 @@ async function copyRuntime() {
257
261
  await cp(join(source, "package.json"), join(destination, "package.json"), { force: true });
258
262
  }
259
263
 
264
+ /** Install the bundled, repository-independent Penny Compare tools. */
265
+ export async function installPennyCompare() {
266
+ if (!(process.platform === "darwin" || process.platform === "linux")) {
267
+ return { supported: false, installed: false, path: pennyCompareDir() };
268
+ }
269
+ const source = join(packageRoot(), "standalone");
270
+ const destination = pennyCompareDir();
271
+ try {
272
+ await stat(source);
273
+ } catch {
274
+ return { supported: true, installed: false, path: destination };
275
+ }
276
+ await mkdir(destination, { recursive: true, mode: 0o700 });
277
+ // Preserve user state while replacing the shipped code on every CLI upgrade.
278
+ for (const entry of await readdir(destination)) {
279
+ if (entry === ".env" || entry === "reports") continue;
280
+ await rm(join(destination, entry), { recursive: true, force: true });
281
+ }
282
+ for (const entry of await readdir(source)) {
283
+ if (entry === ".env" || entry === "reports") continue;
284
+ await cp(join(source, entry), join(destination, entry), { recursive: true, force: true });
285
+ }
286
+ await mkdir(join(destination, "reports"), { recursive: true, mode: 0o700 });
287
+ return { supported: true, installed: true, path: destination };
288
+ }
289
+
260
290
  async function writeShim() {
261
291
  await mkdir(pennyBinDir(), { recursive: true, mode: 0o755 });
262
292
  const entry = join(pennyRuntimeDir(), "bin", "penny.js");
@@ -386,7 +416,8 @@ export async function installPennyRuntime({ updatePath = true } = {}) {
386
416
  config.windows_path_managed = true;
387
417
  await savePennyConfig(config);
388
418
  }
389
- return { supported: true, ...path, shim_path: pennyShimPath() };
419
+ const compare = await installPennyCompare();
420
+ return { supported: true, ...path, shim_path: pennyShimPath(), compare };
390
421
  }
391
422
 
392
423
  export function renderCodexLaunchProfile({ gatewayBaseUrl, subscriptionAuth }) {
@@ -775,9 +806,26 @@ export function startPennyTerminalTitle({ gatewayBaseUrl, apiKey, titleSessionId
775
806
  };
776
807
  }
777
808
 
778
- async function writeClaudeLaunchSettings() {
809
+ async function writeClaudeLaunchSettings({ gatewayBaseUrl, apiKey } = {}) {
779
810
  const native = (await readJsonFile(join(homeDir(), ".claude", "settings.json"), null)) || {};
780
- const overlay = { disableClaudeAiConnectors: true };
811
+ // Claude Code gives values from --settings precedence over the parent process
812
+ // environment. Keep this overlay authoritative so a separately-managed
813
+ // ~/.claude/settings.json (for example scripts/proxy/claude-proxy.sh) cannot
814
+ // redirect a `penny claude` session away from the selected Penny profile.
815
+ const overlay = {
816
+ disableClaudeAiConnectors: true,
817
+ ...(gatewayBaseUrl && apiKey
818
+ ? {
819
+ env: {
820
+ ANTHROPIC_BASE_URL: gatewayBaseUrl,
821
+ ANTHROPIC_AUTH_TOKEN: apiKey,
822
+ // An empty value is treated as unset by Claude Code, and prevents a
823
+ // stale API key in the shared settings from creating dual auth.
824
+ ANTHROPIC_API_KEY: "",
825
+ },
826
+ }
827
+ : {}),
828
+ };
781
829
  if (native.statusLine) overlay.statusLine = native.statusLine;
782
830
  await configureClaudeCodeStatusline(overlay);
783
831
  await privateWrite(claudeLaunchSettingsPath(), `${JSON.stringify(overlay, null, 2)}\n`);
@@ -843,7 +891,7 @@ export async function configurePennyHarness(harness, {
843
891
  config.harnesses ||= {};
844
892
  if (harness === "claude-code") {
845
893
  await writeClaudeUserSkill();
846
- await writeClaudeLaunchSettings();
894
+ await writeClaudeLaunchSettings({ gatewayBaseUrl, apiKey });
847
895
  config.harnesses.claude = {
848
896
  enabled: true,
849
897
  subscription_auth: Boolean(subscriptionAuth),
@@ -1237,6 +1285,14 @@ export async function runPenny(argv = process.argv.slice(2)) {
1237
1285
  profile: config.profile,
1238
1286
  });
1239
1287
  }
1288
+ if (harness === "claude") {
1289
+ // Refresh on every launch so profile selection is honored immediately,
1290
+ // including --private, --cloud, and named profiles.
1291
+ await writeClaudeLaunchSettings({
1292
+ gatewayBaseUrl: config.gateway_base_url,
1293
+ apiKey: config.api_key,
1294
+ });
1295
+ }
1240
1296
  const models = harness === "opencode" ? await resolveOpenCodeLaunchModels(config) : null;
1241
1297
  const invocation = buildPennyInvocation(harness, argv.slice(1), config, models);
1242
1298
  const child = spawn(invocation.command, invocation.args, {
@@ -123,6 +123,14 @@ async function main() {
123
123
  });
124
124
 
125
125
  assert.equal(fs.readFileSync(path.join(home, ".claude", "settings.json"), "utf8"), nativeClaude);
126
+ const claudeLaunchSettings = JSON.parse(
127
+ fs.readFileSync(claudeLaunchSettingsPath(), "utf8"),
128
+ );
129
+ assert.deepEqual(claudeLaunchSettings.env, {
130
+ ANTHROPIC_BASE_URL: "https://gateway.example/",
131
+ ANTHROPIC_AUTH_TOKEN: "pr-super-secret",
132
+ ANTHROPIC_API_KEY: "",
133
+ });
126
134
  assert.equal(fs.readFileSync(path.join(home, ".codex", "config.toml"), "utf8"), nativeCodex);
127
135
  assert.equal(await fileMode(pennyConfigPath()), 0o600);
128
136
  assert.equal(await fileMode(claudeLaunchSettingsPath()), 0o600);
@@ -0,0 +1,8 @@
1
+ # Copy to .env (gitignored) and fill in.
2
+ #
3
+ # Used only by the NATIVE pane, and only when penny-compare is run with --native-proxy
4
+ # or you answer yes to the proxy question in the menu. Without that, the NATIVE pane
5
+ # always talks to Anthropic directly.
6
+
7
+ PENNY_COMPARE_NATIVE_BASE_URL=
8
+ PENNY_COMPARE_NATIVE_AUTH_TOKEN=
@@ -0,0 +1,79 @@
1
+ Comparison tool
2
+
3
+ ## Single-session HUD
4
+
5
+ For one normal interactive session with the same live token/cost panel used by the comparison
6
+ tool, run:
7
+
8
+ apps/compare/penny-session
9
+
10
+ It starts a Penny-routed Claude Code session by default. Choose another client explicitly when
11
+ needed:
12
+
13
+ apps/compare/penny-session penny-codex
14
+ apps/compare/penny-session penny-opencode
15
+ apps/compare/penny-session claude --model sonnet
16
+
17
+ OpenCode can be compared directly or through PennyRouter:
18
+
19
+ penny-compare opencode penny-opencode
20
+ penny-compare --model pennyrouter/auto opencode penny-opencode
21
+
22
+ OpenCode usage is read from its local SQLite session store, including cache and output tokens.
23
+ The compare tool separates native and PennyRouter sessions using OpenCode's recorded provider id.
24
+
25
+ The launcher accepts `--dir PATH`, `--label NAME`, `--model MODEL`, `--pricing-profile PROFILE`,
26
+ and `--native-proxy`. When the client exits, the HUD exits with it; no comparison window or tmux
27
+ session is created.
28
+
29
+ The live HUD and saved reports use the same model prices on both sides. A direct comparison uses
30
+ standard rates. Selecting `--native-proxy` switches both panes to the GovCloud schedule. Its
31
+ Opus 5 rates are $5.50/M input, $27.50/M output, $0.55/M cache read, $6.875/M 5-minute cache
32
+ write, and $11.00/M 1-hour cache write. The GovCloud profile also carries dedicated rates for
33
+ Opus 4.8, Sonnet 5, Haiku 4.5, and the context-tiered GPT 5.6 Luna, Terra, and Sol models. GPT's
34
+ high rate is selected per call only when that call's input context exceeds 272K.
35
+
36
+ Ctrl-Shift-X (or Ctrl-B then X) snapshots the interval since launch or the last reset, writes a
37
+ per-call JSON report to `apps/compare/reports`, shows total token and cost savings, and waits for
38
+ Enter before closing the tmux session.
39
+
40
+ ## Report viewer
41
+
42
+ Start the local web viewer with:
43
+
44
+ apps/compare/penny-compare-viewer
45
+
46
+ Then open http://127.0.0.1:8788. It lists saved comparisons, shows side-by-side token and cost
47
+ metrics, and lets you select any linked transcript message to read its complete output. New
48
+ reports record each pane's stable `session_id` and `transcript_path` so the viewer can resolve the
49
+ original Claude Code or Codex JSONL session. Reports created before this metadata was added are
50
+ still viewable for their summary metrics, but cannot identify a transcript automatically.
51
+
52
+ To use another port for a one-off run:
53
+
54
+ apps/compare/penny-compare-viewer --port 8790
55
+
56
+ penny-compare \
57
+ --model opus \
58
+ --dir-left /Users/jduke34/Documents/GitHub/test-session/native \
59
+ --dir-right /Users/jduke34/Documents/GitHub/test-session/penny \
60
+ claude penny-claude
61
+
62
+ #with proxy
63
+ penny-compare \
64
+ --dir-left ~/Documents/GitHub/test-session/native \
65
+ --dir-right ~/Documents/GitHub/test-session/penny \
66
+ --label-left NATIVE \
67
+ --label-right PENNY \
68
+ --model sonnet \
69
+ --native-proxy \
70
+ claude penny-claude
71
+
72
+ penny-compare \
73
+ --dir-left ~/Documents/GitHub/test-session/native \
74
+ --dir-right ~/Documents/GitHub/test-session/penny \
75
+ --label-left "NATIVE BEDROCK" \
76
+ --label-right "PENNY BEDROCK" \
77
+ --model opus \
78
+ --native-proxy \
79
+ claude penny-claude
@@ -0,0 +1,60 @@
1
+ # Penny Compare — portable bundle
2
+
3
+ Live token/cost HUDs for coding-agent sessions. Run two harnesses side by side
4
+ (`penny-compare`) or one on its own (`penny-session`).
5
+
6
+ Self-contained: stdlib Python only, no pip, no npm.
7
+
8
+ ## Install
9
+
10
+ ./install.sh # symlinks into ~/.local/bin
11
+ ./install.sh ~/bin # or a directory you choose
12
+
13
+ Keep this folder where it is — the commands are symlinks back to it. Move it and
14
+ re-run `install.sh` to repoint.
15
+
16
+ ### Requirements
17
+
18
+ - `tmux` and `python3` 3.10+ (`install.sh` checks both and stops if either is missing)
19
+ - Whichever harnesses you're comparing: `claude`, `codex`, `opencode`
20
+ - For the penny-routed panes: `npx pennyrouter install`
21
+
22
+ ## Use
23
+
24
+ penny-compare menu
25
+ penny-compare claude penny-claude native vs penny, side by side
26
+ penny-compare --model opus claude penny-claude both panes on one model
27
+ penny-session one penny-routed Claude session
28
+ penny-session claude --model sonnet one native session
29
+ penny-compare-viewer browse saved reports
30
+
31
+ Keys, in-session: `ctrl-shift-s` sync · `ctrl-shift-r` reset counters ·
32
+ `ctrl-shift-x` write report and quit. Each also has a `ctrl-b` prefix spelling
33
+ for terminals that can't send ctrl-shift (Terminal.app among them).
34
+
35
+ Reset before the run that counts — a comparison is only fair once both sides
36
+ have a warm cache.
37
+
38
+ ## `.env` (optional)
39
+
40
+ Only needed for `--native-proxy`, which routes the NATIVE pane through a proxy
41
+ instead of straight to Anthropic. **Not included in this bundle** — it holds auth
42
+ tokens. Copy `.env.example` to `.env` and fill it in, or copy your existing
43
+ `.env` across separately.
44
+
45
+ Without it, everything else works; the NATIVE pane just talks to Anthropic directly.
46
+
47
+ ## Reports
48
+
49
+ New runs land in `reports/` beside the bundle. `penny-compare-viewer` serves them at
50
+ http://localhost:8765. A release bundle starts with an empty reports directory; reports are
51
+ local runtime data and are never copied into a release.
52
+
53
+ ## Caveat: pricing goes stale
54
+
55
+ `pricing.py` hardcodes per-token rates, and the Codex model list in
56
+ `penny-compare` mirrors the gateway's catalog. Neither updates itself away from
57
+ the repo, and a stale table misprices silently. Re-copy those two files when
58
+ rates change.
59
+
60
+ Original repo docs: `README-original.md`.
@@ -0,0 +1,220 @@
1
+ #!/usr/bin/env bash
2
+ # Point Claude Code (and penny-compare's NATIVE pane) at an OpenAI/Anthropic-compatible
3
+ # proxy, and put it back.
4
+ #
5
+ # This does two things together, since both are needed to fully switch over:
6
+ #
7
+ # 1. Writes ANTHROPIC_BASE_URL / ANTHROPIC_AUTH_TOKEN into ~/.claude/settings.json's
8
+ # `env` block, which is what a bare `claude` invocation reads on startup. Every
9
+ # other setting in that file is preserved -- the edit is a key-level merge, not a
10
+ # rewrite.
11
+ # 2. Writes PENNY_COMPARE_NATIVE_BASE_URL / PENNY_COMPARE_NATIVE_AUTH_TOKEN into this
12
+ # repo's .env file. penny-compare's pane.py reads those two vars from its own
13
+ # process environment when run with --native-proxy, and injects them into the
14
+ # NATIVE pane's subprocess as ANTHROPIC_BASE_URL / ANTHROPIC_AUTH_TOKEN. Without
15
+ # this, --native-proxy has nothing to read and the NATIVE pane falls back to
16
+ # Anthropic direct even with settings.json pointed at the proxy, because pane.py
17
+ # strips ambient ANTHROPIC_* vars before optionally reinjecting its own.
18
+ # 3. Sets settings.json's disableClaudeAiConnectors to true while the proxy is on.
19
+ # Once ANTHROPIC_BASE_URL/ANTHROPIC_AUTH_TOKEN override auth, Claude Code is no
20
+ # longer authenticated against claude.ai, so claude.ai-hosted MCP connectors
21
+ # (Gmail, Calendar, Drive, etc.) are unreachable regardless -- this just says so
22
+ # up front instead of showing the "disabled because an auth source is set"
23
+ # warning on every startup. Cleared on `off`.
24
+ #
25
+ # claude-proxy.sh on [url] point Claude Code (and --native-proxy) at the proxy
26
+ # claude-proxy.sh off remove it, back to Anthropic direct everywhere
27
+ # claude-proxy.sh status show what is set right now, for both
28
+ #
29
+ # The token is read from CLAUDE_PROXY_TOKEN, or prompted for without echo.
30
+ # It is never passed on the command line, where it would land in shell history.
31
+ #
32
+ # export CLAUDE_PROXY_URL=https://litellm.core.gtri.org
33
+ # export CLAUDE_PROXY_TOKEN=sk-example
34
+ # ./claude-proxy.sh on
35
+ #
36
+ # The first `on` backs up settings.json and .env once per invocation (whichever
37
+ # already exist), before any write. Recoverable from settings.json.bak.<timestamp>
38
+ # and .env.bak.<timestamp> in ~/.claude and this repo respectively.
39
+
40
+ set -euo pipefail
41
+
42
+ SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
43
+ ENV_FILE="${PROXY_ENV_FILE:-$SCRIPT_DIR/.env}"
44
+ if [ -f "$ENV_FILE" ]; then
45
+ set -a
46
+ . "$ENV_FILE"
47
+ set +a
48
+ fi
49
+
50
+ SETTINGS="${CLAUDE_SETTINGS:-$HOME/.claude/settings.json}"
51
+ # Falls back to whatever URL is already saved in .env from the last `on` run,
52
+ # so a bare `./claude-proxy.sh on` reuses it without retyping.
53
+ DEFAULT_URL="${CLAUDE_PROXY_URL:-${PENNY_COMPARE_NATIVE_BASE_URL:-}}"
54
+
55
+ NATIVE_URL_VAR="PENNY_COMPARE_NATIVE_BASE_URL"
56
+ NATIVE_TOKEN_VAR="PENNY_COMPARE_NATIVE_AUTH_TOKEN"
57
+
58
+ die() { echo "error: $*" >&2; exit 1; }
59
+
60
+ command -v python3 >/dev/null || die "python3 is required"
61
+
62
+ # Claude Code creates this on first run; an install that has never been launched
63
+ # has no file, and writing one here would guess at the rest of its contents.
64
+ [ -f "$SETTINGS" ] || die "no settings file at $SETTINGS (run claude once first)"
65
+
66
+ # One backup per run, before any write.
67
+ backup() {
68
+ local dest="$SETTINGS.bak.$(date +%Y%m%dT%H%M%SZ)"
69
+ cp "$SETTINGS" "$dest"
70
+ echo "backed up settings to $dest"
71
+
72
+ if [ -f "$ENV_FILE" ]; then
73
+ local env_dest="$ENV_FILE.bak.$(date +%Y%m%dT%H%M%SZ)"
74
+ cp "$ENV_FILE" "$env_dest"
75
+ echo "backed up .env to $env_dest"
76
+ fi
77
+ }
78
+
79
+ # Merge or delete the two env keys in settings.json. Reads the file, edits the
80
+ # parsed object, and writes to a temp file in the same directory before renaming
81
+ # over the original, so an interrupted run cannot leave a half-written file behind.
82
+ edit_settings() {
83
+ ACTION="$1" URL="${2:-}" TOKEN="${3:-}" SETTINGS="$SETTINGS" python3 <<'PY'
84
+ import json, os, tempfile
85
+
86
+ path = os.environ["SETTINGS"]
87
+ action = os.environ["ACTION"]
88
+
89
+ with open(path) as fh:
90
+ settings = json.load(fh)
91
+
92
+ env = settings.setdefault("env", {})
93
+ if action == "on":
94
+ env["ANTHROPIC_BASE_URL"] = os.environ["URL"]
95
+ env["ANTHROPIC_AUTH_TOKEN"] = os.environ["TOKEN"]
96
+ # claude.ai connectors rely on claude.ai OAuth; a proxy auth source means
97
+ # Claude Code is no longer authenticated against that account, so the
98
+ # connectors would be unreachable anyway -- this just says so up front.
99
+ settings["disableClaudeAiConnectors"] = True
100
+ else:
101
+ env.pop("ANTHROPIC_BASE_URL", None)
102
+ env.pop("ANTHROPIC_AUTH_TOKEN", None)
103
+ # Leave no empty env block behind if this script put everything in it.
104
+ if not env:
105
+ settings.pop("env")
106
+ settings.pop("disableClaudeAiConnectors", None)
107
+
108
+ directory = os.path.dirname(path) or "."
109
+ with tempfile.NamedTemporaryFile("w", dir=directory, delete=False) as tmp:
110
+ json.dump(settings, tmp, indent=2)
111
+ tmp.write("\n")
112
+ temp_path = tmp.name
113
+ os.chmod(temp_path, 0o600)
114
+ os.replace(temp_path, path)
115
+ PY
116
+ }
117
+
118
+ # Remove any existing line for the given var name from .env.
119
+ strip_env_var() {
120
+ local var="$1"
121
+ [ -f "$ENV_FILE" ] || return 0
122
+ grep -v -e "^${var}=" "$ENV_FILE" > "$ENV_FILE.tmp" || true
123
+ mv "$ENV_FILE.tmp" "$ENV_FILE"
124
+ }
125
+
126
+ # Merge or delete the two penny-compare native-proxy keys in .env, preserving any
127
+ # other lines already in the file.
128
+ edit_native_env() {
129
+ local action="$1" url="${2:-}" token="${3:-}"
130
+ touch "$ENV_FILE"
131
+ strip_env_var "$NATIVE_URL_VAR"
132
+ strip_env_var "$NATIVE_TOKEN_VAR"
133
+ if [ "$action" = "on" ]; then
134
+ printf '%s=%s\n' "$NATIVE_URL_VAR" "$url" >> "$ENV_FILE"
135
+ printf '%s=%s\n' "$NATIVE_TOKEN_VAR" "$token" >> "$ENV_FILE"
136
+ fi
137
+ chmod 600 "$ENV_FILE"
138
+ }
139
+
140
+ show_status() {
141
+ SETTINGS="$SETTINGS" python3 <<'PY'
142
+ import json, os
143
+
144
+ with open(os.environ["SETTINGS"]) as fh:
145
+ settings = json.load(fh)
146
+
147
+ env = settings.get("env", {})
148
+ url = env.get("ANTHROPIC_BASE_URL")
149
+ token = env.get("ANTHROPIC_AUTH_TOKEN")
150
+ if url:
151
+ shown = f"{token[:6]}...{token[-4:]}" if token and len(token) > 12 else ("set" if token else "MISSING")
152
+ connectors = "disabled" if settings.get("disableClaudeAiConnectors") else "enabled"
153
+ print(f"claude (settings.json): ON\n ANTHROPIC_BASE_URL: {url}\n ANTHROPIC_AUTH_TOKEN: {shown}\n claude.ai connectors: {connectors}")
154
+ else:
155
+ print("claude (settings.json): OFF (Claude Code talks to Anthropic directly)")
156
+ PY
157
+
158
+ if [ ! -f "$ENV_FILE" ]; then
159
+ echo "penny-compare --native-proxy (.env): OFF (no .env file)"
160
+ return
161
+ fi
162
+
163
+ local url token
164
+ url="$(grep -e "^${NATIVE_URL_VAR}=" "$ENV_FILE" | tail -n1 | cut -d= -f2-)"
165
+ token="$(grep -e "^${NATIVE_TOKEN_VAR}=" "$ENV_FILE" | tail -n1 | cut -d= -f2-)"
166
+
167
+ if [ -n "$url" ]; then
168
+ local shown="MISSING"
169
+ if [ -n "$token" ]; then
170
+ if [ "${#token}" -gt 12 ]; then
171
+ shown="${token:0:6}...${token: -4}"
172
+ else
173
+ shown="set"
174
+ fi
175
+ fi
176
+ echo "penny-compare --native-proxy (.env): ON"
177
+ echo " $NATIVE_URL_VAR: $url"
178
+ echo " $NATIVE_TOKEN_VAR: $shown"
179
+ else
180
+ echo "penny-compare --native-proxy (.env): OFF"
181
+ fi
182
+ }
183
+
184
+ case "${1:-status}" in
185
+ on)
186
+ url="${2:-$DEFAULT_URL}"
187
+ [ -n "$url" ] || die "usage: $(basename "$0") on <proxy-url> (or set CLAUDE_PROXY_URL)"
188
+
189
+ # Falls back to whatever token is already saved in .env from the last
190
+ # `on` run, so a bare `./claude-proxy.sh on` reuses it without retyping.
191
+ token="${CLAUDE_PROXY_TOKEN:-${PENNY_COMPARE_NATIVE_AUTH_TOKEN:-}}"
192
+ if [ -z "$token" ]; then
193
+ # -s so the key does not linger on screen or in scrollback.
194
+ read -rsp "Proxy API key: " token < /dev/tty
195
+ echo
196
+ fi
197
+ [ -n "$token" ] || die "no token given"
198
+
199
+ backup
200
+ edit_settings on "$url" "$token"
201
+ edit_native_env on "$url" "$token"
202
+ echo "Claude Code -> $url"
203
+ echo "penny-compare --native-proxy -> $url"
204
+ echo "Restart any running claude/penny-compare session to pick this up."
205
+ ;;
206
+ off)
207
+ backup
208
+ edit_settings off
209
+ edit_native_env off
210
+ echo "Claude Code -> Anthropic direct"
211
+ echo "penny-compare --native-proxy -> Anthropic direct"
212
+ echo "Restart any running claude/penny-compare session to pick this up."
213
+ ;;
214
+ status)
215
+ show_status
216
+ ;;
217
+ *)
218
+ die "usage: $(basename "$0") {on [url]|off|status}"
219
+ ;;
220
+ esac