pennyrouter 0.1.7 → 0.1.8

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.
Files changed (3) hide show
  1. package/README.md +31 -4
  2. package/package.json +1 -1
  3. package/src/cli.js +153 -17
package/README.md CHANGED
@@ -22,6 +22,10 @@ npx pennyrouter status
22
22
  With no `--harness` flag, the interactive installer shows a checkbox list. Tools with
23
23
  detected configs start checked; press Enter to accept or type tool IDs to toggle them.
24
24
 
25
+ When Claude Code is selected, the installer also offers an optional Claude Pro/Max
26
+ subscription-auth step. Users can skip it during install and enable it later with
27
+ `@penny auth` or `npx pennyrouter auth anthropic`.
28
+
25
29
  ## Supported Tools
26
30
 
27
31
  - Claude Code
@@ -47,8 +51,31 @@ model you chose. "Penny Custom" is the dynamic option: PennyRouter cost-routes e
47
51
 
48
52
  ## Claude Pro/Max Subscription Auth
49
53
 
50
- After installing PennyRouter for Claude Code, users with an Anthropic OAuth login can route
51
- Anthropic-native Claude turns through their own Claude subscription token:
54
+ When installing PennyRouter for Claude Code, the interactive installer offers to route
55
+ Anthropic-native Claude turns through the user's own Claude subscription token. To force this
56
+ in non-interactive installs:
57
+
58
+ ```bash
59
+ npx pennyrouter install --harness claude-code --anthropic-auth --token <token>
60
+ ```
61
+
62
+ If Claude Code is already logged in to a Pro/Max subscription, use Claude Code's token setup
63
+ flow:
64
+
65
+ ```bash
66
+ claude setup-token
67
+ ANTHROPIC_AUTH_TOKEN=<token> npx pennyrouter auth anthropic
68
+ ```
69
+
70
+ Other token sources are supported:
71
+
72
+ ```bash
73
+ npx pennyrouter auth anthropic --token <token>
74
+ npx pennyrouter auth anthropic --token-command '<command that prints the token>'
75
+ ```
76
+
77
+ If the separate Anthropic `ant` CLI is installed and logged in, PennyRouter can use it as a
78
+ convenience fallback:
52
79
 
53
80
  ```bash
54
81
  ant auth login
@@ -60,8 +87,8 @@ The command updates Claude Code so `x-api-key` carries the PennyRouter key and
60
87
  bearer token to Anthropic with the required OAuth beta header and does not deduct
61
88
  PennyRouter credit for those Anthropic-native upstream calls.
62
89
 
63
- `ant auth print-credentials` refreshes the access token before the CLI stores it. If the
64
- stored token expires during a long-running session, rerun `npx pennyrouter auth anthropic`.
90
+ If the stored token expires during a long-running session, refresh the token source and rerun
91
+ `npx pennyrouter auth anthropic`.
65
92
 
66
93
  ## Agent Flow
67
94
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pennyrouter",
3
- "version": "0.1.7",
3
+ "version": "0.1.8",
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
@@ -94,10 +94,14 @@ function parseArgs(argv) {
94
94
  else if (arg === "--dry-run") flags.dryRun = true;
95
95
  else if (arg === "--no-browser") flags.noBrowser = true;
96
96
  else if (arg === "--existing-account") flags.existingAccount = true;
97
+ else if (arg === "--anthropic-auth") flags.anthropicAuth = true;
98
+ else if (arg === "--no-anthropic-auth") flags.noAnthropicAuth = true;
97
99
  else if (arg === "--penny-key") flags.pennyKey = rest[++i] || "";
98
100
  else if (arg.startsWith("--penny-key=")) flags.pennyKey = arg.slice("--penny-key=".length);
99
101
  else if (arg === "--token") flags.token = rest[++i] || "";
100
102
  else if (arg.startsWith("--token=")) flags.token = arg.slice("--token=".length);
103
+ else if (arg === "--token-command") flags.tokenCommand = rest[++i] || "";
104
+ else if (arg.startsWith("--token-command=")) flags.tokenCommand = arg.slice("--token-command=".length);
101
105
  else if (arg === "--harness") flags.harness = rest[++i] || "";
102
106
  else if (arg.startsWith("--harness=")) flags.harness = arg.slice("--harness=".length);
103
107
  else if (arg === "--app-url") flags.appUrl = rest[++i] || "";
@@ -136,10 +140,11 @@ async function authProvider(flags) {
136
140
  );
137
141
  }
138
142
 
139
- const token = flags.token || readAnthropicOAuthToken();
143
+ const tokenResult = readAnthropicOAuthToken(flags);
144
+ const token = tokenResult.token;
140
145
  if (!token) {
141
146
  throw new Error(
142
- "No Anthropic OAuth token found. Run `ant auth login`, then retry `pennyrouter auth anthropic`.",
147
+ "No Anthropic OAuth token found. Run `claude setup-token` and pass it with `--token`, set ANTHROPIC_AUTH_TOKEN, or pass `--token-command <cmd>`.",
143
148
  );
144
149
  }
145
150
 
@@ -154,24 +159,38 @@ async function authProvider(flags) {
154
159
  upsertManifestRecord(manifest, {
155
160
  ...record,
156
161
  anthropic_oauth: true,
157
- token_source: flags.token ? "flag" : "ant",
162
+ token_source: tokenResult.source,
158
163
  });
159
164
  await saveManifest(manifest);
160
165
 
161
166
  console.log("Configured Claude Code for PennyRouter + Anthropic OAuth.");
162
167
  console.log("Anthropic-native Claude turns will use your Claude subscription token upstream.");
163
- console.log("If Claude auth expires, run `pennyrouter auth anthropic` again.");
168
+ console.log("If Claude auth expires, refresh the token source and run `pennyrouter auth anthropic` again.");
164
169
  console.log(`Config: ${record.config_path}`);
165
170
  }
166
171
 
167
- function readAnthropicOAuthToken() {
168
- if (process.env.ANTHROPIC_AUTH_TOKEN) return process.env.ANTHROPIC_AUTH_TOKEN.trim();
172
+ function readAnthropicOAuthToken(flags = {}) {
173
+ if (flags.token) return { token: flags.token.trim(), source: "flag" };
174
+ if (flags.tokenCommand) {
175
+ const result = spawnSync(flags.tokenCommand, {
176
+ encoding: "utf8",
177
+ shell: true,
178
+ stdio: ["ignore", "pipe", "pipe"],
179
+ });
180
+ if (result.status === 0) {
181
+ return { token: String(result.stdout || "").trim(), source: "token-command" };
182
+ }
183
+ return { token: "", source: "token-command" };
184
+ }
185
+ if (process.env.ANTHROPIC_AUTH_TOKEN) {
186
+ return { token: process.env.ANTHROPIC_AUTH_TOKEN.trim(), source: "env" };
187
+ }
169
188
  const result = spawnSync("ant", ["auth", "print-credentials", "--access-token"], {
170
189
  encoding: "utf8",
171
190
  stdio: ["ignore", "pipe", "pipe"],
172
191
  });
173
- if (result.status !== 0) return "";
174
- return String(result.stdout || "").trim();
192
+ if (result.status !== 0) return { token: "", source: "none" };
193
+ return { token: String(result.stdout || "").trim(), source: "ant" };
175
194
  }
176
195
 
177
196
  async function install(flags) {
@@ -189,6 +208,9 @@ async function install(flags) {
189
208
  console.log(`Will install on ${harness.name}${installNoteForHarness(harness.id)}.`);
190
209
  }
191
210
  if (flags.gatewayBaseUrl) console.log(`Serving gateway: ${flags.gatewayBaseUrl}`);
211
+ if (selected.some((harness) => harness.id === "claude-code")) {
212
+ console.log("Claude Code subscription auth prompt would be shown unless skipped.");
213
+ }
192
214
  console.log("Dry run only. No files changed.");
193
215
  return;
194
216
  }
@@ -213,19 +235,32 @@ async function install(flags) {
213
235
  console.log(`Authorized. Received key ${maskKey(claim.api_key)}.`);
214
236
  console.log("");
215
237
 
238
+ const claudeAnthropicAuth = await maybeConfigureClaudeAnthropicAuth(flags, selected);
239
+
216
240
  const manifest = await loadManifest();
217
241
  const records = [];
218
242
  const failures = [];
219
243
  for (const harness of selected) {
220
244
  await runHarnessJob(failures, harness.id, "install", async () => {
221
245
  const plan = await harness.planInstall();
222
- const record = await harness.install({
223
- apiKey: claim.api_key,
224
- // An explicit --local / --gateway-base-url overrides the URL the server hands back
225
- // (which points at prod) — so a --local install serves through the local gateway.
226
- gatewayBaseUrl: flags.gatewayBaseUrl || claim.gateway_base_url,
227
- plan,
228
- });
246
+ const gatewayBaseUrl = flags.gatewayBaseUrl || claim.gateway_base_url;
247
+ const record = harness.id === "claude-code" && claudeAnthropicAuth
248
+ ? await harness.installAnthropicOAuth({
249
+ pennyKey: claim.api_key,
250
+ anthropicAuthToken: claudeAnthropicAuth.token,
251
+ gatewayBaseUrl,
252
+ })
253
+ : await harness.install({
254
+ apiKey: claim.api_key,
255
+ // An explicit --local / --gateway-base-url overrides the URL the server hands back
256
+ // (which points at prod) — so a --local install serves through the local gateway.
257
+ gatewayBaseUrl,
258
+ plan,
259
+ });
260
+ if (harness.id === "claude-code" && claudeAnthropicAuth) {
261
+ record.anthropic_oauth = true;
262
+ record.token_source = claudeAnthropicAuth.source;
263
+ }
229
264
  records.push(record);
230
265
  upsertManifestRecord(manifest, record);
231
266
  await saveManifest(manifest);
@@ -243,6 +278,56 @@ async function install(flags) {
243
278
  reportFailures(failures);
244
279
  }
245
280
 
281
+ async function maybeConfigureClaudeAnthropicAuth(flags, selected) {
282
+ if (!selected.some((harness) => harness.id === "claude-code")) return null;
283
+ if (flags.noAnthropicAuth) return null;
284
+
285
+ if (flags.token || flags.tokenCommand || process.env.ANTHROPIC_AUTH_TOKEN) {
286
+ const tokenResult = readAnthropicOAuthToken(flags);
287
+ if (tokenResult.token) return tokenResult;
288
+ }
289
+
290
+ if (flags.yes || !input.isTTY || !output.isTTY) {
291
+ if (flags.anthropicAuth) {
292
+ throw new Error(
293
+ "Claude subscription auth was requested, but no Anthropic bearer token was found. Pass --token, --token-command, or ANTHROPIC_AUTH_TOKEN.",
294
+ );
295
+ }
296
+ return null;
297
+ }
298
+
299
+ if (!flags.anthropicAuth) {
300
+ console.log("Claude Code subscription auth");
301
+ console.log("PennyRouter can use your Claude Pro/Max subscription for Anthropic Claude calls.");
302
+ console.log("PennyRouter still handles routing, optimization, and @penny commands.");
303
+ console.log("");
304
+ const ok = await confirm("Enable Claude Pro/Max subscription auth for Claude Code?");
305
+ if (!ok) {
306
+ console.log("Skipped Claude subscription auth. Enable later with `@penny auth` or `pennyrouter auth anthropic`.");
307
+ console.log("");
308
+ return null;
309
+ }
310
+ }
311
+
312
+ const tokenResult = readAnthropicOAuthToken({ tokenCommand: flags.tokenCommand });
313
+ if (tokenResult.token) return tokenResult;
314
+
315
+ console.log("");
316
+ if (commandExists("claude")) {
317
+ console.log("Run `claude setup-token` in another terminal, then paste the token here.");
318
+ } else {
319
+ console.log("Claude Code was not found on PATH. Paste an Anthropic bearer token, or leave blank to skip.");
320
+ }
321
+ const token = await promptSecretish("Anthropic token (blank to skip): ");
322
+ if (!token) {
323
+ console.log("Skipped Claude subscription auth. Enable later with `@penny auth` or `pennyrouter auth anthropic`.");
324
+ console.log("");
325
+ return null;
326
+ }
327
+ console.log("");
328
+ return { token, source: "prompt" };
329
+ }
330
+
246
331
  async function disable(flags) {
247
332
  const manifest = await loadManifest();
248
333
  if (pruneUnsupportedManifestRecords(manifest).length > 0) await saveManifest(manifest);
@@ -489,6 +574,7 @@ async function status() {
489
574
  if (record.disabled_snapshot_path) console.log(` disabled snapshot: ${record.disabled_snapshot_path}`);
490
575
  if (record.installed_at) console.log(` installed: ${record.installed_at}`);
491
576
  if (record.disabled_at) console.log(` disabled: ${record.disabled_at}`);
577
+ if (record.anthropic_oauth) console.log(` Claude subscription auth: enabled (${record.token_source || "unknown"} token)`);
492
578
  if (record.manual_required) console.log(" note: finish or remove this integration in the tool UI");
493
579
  }
494
580
  }
@@ -729,8 +815,57 @@ async function confirm(question) {
729
815
  return answer.trim().toLowerCase() === "y" || answer.trim().toLowerCase() === "yes";
730
816
  }
731
817
 
818
+ async function promptSecretish(question) {
819
+ if (!input.isTTY || !output.isTTY) {
820
+ const rl = createInterface({ input, output });
821
+ const answer = await rl.question(question);
822
+ rl.close();
823
+ return answer.trim();
824
+ }
825
+
826
+ const previousRawMode = input.isRaw;
827
+ emitKeypressEvents(input);
828
+ input.setRawMode(true);
829
+ input.resume();
830
+ output.write(question);
831
+
832
+ return new Promise((resolve, reject) => {
833
+ let value = "";
834
+ const cleanup = () => {
835
+ input.off("keypress", onKeypress);
836
+ input.setRawMode(Boolean(previousRawMode));
837
+ input.pause();
838
+ output.write("\n");
839
+ };
840
+ const onKeypress = (str, key = {}) => {
841
+ if (key.ctrl && key.name === "c") {
842
+ cleanup();
843
+ reject(new Error("Cancelled."));
844
+ return;
845
+ }
846
+ if (key.name === "return" || key.name === "enter") {
847
+ cleanup();
848
+ resolve(value.trim());
849
+ return;
850
+ }
851
+ if (key.name === "backspace") {
852
+ value = value.slice(0, -1);
853
+ return;
854
+ }
855
+ if (str) value += str;
856
+ };
857
+ input.on("keypress", onKeypress);
858
+ });
859
+ }
860
+
861
+ function commandExists(command) {
862
+ const result = spawnSync(command, ["--version"], { stdio: "ignore" });
863
+ return result.status === 0;
864
+ }
865
+
732
866
  function installNoteForRecord(record) {
733
867
  if (record.manual_required) return " (manual setup shown)";
868
+ if (record.anthropic_oauth) return " (Claude subscription auth)";
734
869
  return installNoteForHarness(record.harness);
735
870
  }
736
871
 
@@ -752,8 +887,8 @@ function printHelp() {
752
887
  console.log(`PennyRouter CLI
753
888
 
754
889
  Usage:
755
- pennyrouter install [--harness ${SUPPORTED_HARNESS_IDS}] [--all] [--yes] [--dry-run] [--existing-account] [--local[=URL]]
756
- pennyrouter auth anthropic [--penny-key pr-...] [--token oauth-token] [--gateway-base-url URL]
890
+ pennyrouter install [--harness ${SUPPORTED_HARNESS_IDS}] [--all] [--yes] [--dry-run] [--existing-account] [--anthropic-auth|--no-anthropic-auth] [--local[=URL]]
891
+ pennyrouter auth anthropic [--penny-key pr-...] [--token oauth-token] [--token-command CMD] [--gateway-base-url URL]
757
892
  pennyrouter disable [--harness ${SUPPORTED_HARNESS_IDS}] [--all] [--yes]
758
893
  pennyrouter enable [--harness ${SUPPORTED_HARNESS_IDS}] [--all] [--yes]
759
894
  pennyrouter uninstall [--harness ${SUPPORTED_HARNESS_IDS}] [--all] [--yes]
@@ -762,6 +897,7 @@ Usage:
762
897
  Examples:
763
898
  npx pennyrouter install
764
899
  npx pennyrouter install --existing-account
900
+ npx pennyrouter install --harness claude-code --anthropic-auth --token <token>
765
901
  npx pennyrouter auth anthropic
766
902
  npx pennyrouter install --all
767
903
  npx pennyrouter install --harness windsurf,zed,codegpt,aider --yes