pi-anthropic-oauth 0.1.14 → 0.1.16

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
@@ -1,15 +1,15 @@
1
1
  # pi-anthropic-oauth
2
2
 
3
- [![npm](https://img.shields.io/npm/v/pi-anthropic-oauth?style=flat-square&logo=npm&logoColor=white&label=npm&color=7c3aed)](https://www.npmjs.com/package/pi-anthropic-oauth) [![node](https://img.shields.io/badge/node-%3E%3D18-7c3aed?style=flat-square&logo=nodedotjs&logoColor=white)](https://nodejs.org)
3
+ [![npm](https://img.shields.io/npm/v/pi-anthropic-oauth?style=flat-square&logo=npm&logoColor=white&label=npm&color=7c3aed)](https://www.npmjs.com/package/pi-anthropic-oauth)
4
4
 
5
5
  Use Claude Pro/Max in Pi with browser OAuth.
6
6
 
7
7
  ## Features
8
8
 
9
- - Claude Pro/Max login from `/login anthropic`
9
+ - Claude Pro/Max login from `/login`
10
10
  - Automatic token refresh
11
11
  - Claude Code-compatible OAuth headers and prompt shaping
12
- - No API key or extra usage needed.
12
+ - No Anthropic API key needed
13
13
  - Uses Pi's Anthropic model registry
14
14
  - Adds Claude Opus 4.8 by default
15
15
  - Auto-creates `~/.Claude Code` → `~/.pi` symlink when missing
@@ -20,10 +20,10 @@ Use Claude Pro/Max in Pi with browser OAuth.
20
20
  pi install npm:pi-anthropic-oauth
21
21
  ```
22
22
 
23
- Start Pi, then run:
23
+ Start Pi, then run Pi's login command:
24
24
 
25
25
  ```text
26
- /login anthropic
26
+ /login
27
27
  ```
28
28
 
29
29
  Choose:
@@ -32,12 +32,47 @@ Choose:
32
32
  Claude Pro/Max
33
33
  ```
34
34
 
35
- > [!WARNING]
36
- > Use at your own risk. This may go against Anthropic's terms.
35
+ Complete the browser login. After login, select an Anthropic model in Pi.
36
+
37
+ Update with:
38
+
39
+ ```bash
40
+ pi update npm:pi-anthropic-oauth
41
+ ```
37
42
 
38
43
  > [!NOTE]
39
44
  > Anthropic auth changes are closely monitored for quick compatibility updates.
40
45
 
46
+ ## System prompt rewriting
47
+
48
+ When using Claude Pro/Max OAuth, the extension prepends Claude Code identity text and rewrites standalone `Pi` / `pi` references in Pi's system prompt to `Claude Code`. The rewrite mode defaults to `aggressive`:
49
+
50
+ ```bash
51
+ PI_ANTHROPIC_OAUTH_REWRITE_MODE=aggressive
52
+ ```
53
+
54
+ Other modes are opt-in and avoid rewriting some technical strings such as paths, package names, or repository names. If OAuth starts failing, switch back to `aggressive`.
55
+
56
+ Available modes:
57
+
58
+ | Mode | Behavior |
59
+ | ---------------- | ----------------------------------------------------------------------------------------------------------- |
60
+ | `aggressive` | Default. Replaces standalone `Pi` / `pi` wherever the word-boundary pattern matches. |
61
+ | `path-safe` | Avoids replacing `Pi` / `pi` immediately after `/` or `\`, preserving path segments like `/srv/dev/pi-foo`. |
62
+ | `technical-safe` | Avoids replacing `Pi` / `pi` next to common technical-token separators: `/`, `\`, `.`, `@`, `:`, `_`, `-`. |
63
+ | `custom` | Uses `PI_ANTHROPIC_OAUTH_REWRITE_PATTERN` as the replacement regex. |
64
+
65
+ Custom patterns can be a regex source or a JavaScript-style regex literal. The `g` flag is added automatically when omitted:
66
+
67
+ ```bash
68
+ PI_ANTHROPIC_OAUTH_REWRITE_MODE=custom
69
+ PI_ANTHROPIC_OAUTH_REWRITE_PATTERN='(?<![/\\])\b[Pp]i\b'
70
+ # or
71
+ PI_ANTHROPIC_OAUTH_REWRITE_PATTERN='/\bpi\b/gi'
72
+ ```
73
+
74
+ There is no separate off mode. If you need to disable rewriting entirely, use a custom pattern that never matches, such as `(?!)`. Disabling or weakening the rewrite may affect OAuth compatibility.
75
+
41
76
  ## Extra models
42
77
 
43
78
  To add another Anthropic model, create `~/.pi/agent/models.json`:
@@ -47,7 +82,7 @@ To add another Anthropic model, create `~/.pi/agent/models.json`:
47
82
  "providers": {
48
83
  "anthropic": {
49
84
  "baseUrl": "https://api.anthropic.com",
50
- "apiKey": "REQUIRED_BY_PI_FOR_CUSTOM_MODELS",
85
+ "apiKey": "unused",
51
86
  "api": "anthropic-messages",
52
87
  "models": [
53
88
  {
@@ -61,12 +96,17 @@ To add another Anthropic model, create `~/.pi/agent/models.json`:
61
96
  ```
62
97
 
63
98
  > [!NOTE]
64
- > Pi requires `baseUrl`, `apiKey`, and `api` when defining custom models in `models.json`. With this extension, requests normally authenticate through Claude Pro/Max OAuth after `/login anthropic`, so `apiKey` is present to satisfy Pi's config requirements and does not need to be a valid Anthropic API key.
99
+ > Custom models are not needed for Claude Opus 4.8; this extension registers `claude-opus-4-8` automatically.
100
+ >
101
+ > Pi requires `baseUrl`, `apiKey`, and `api` when defining custom models in `models.json`. With this extension, requests normally authenticate through Claude Pro/Max OAuth after `/login`, so `apiKey` is only a placeholder to satisfy Pi's config requirements and does not need to be a valid Anthropic API key.
102
+ >
103
+ > Do not use a fake `sk-ant-oat...` value as the placeholder. If OAuth login has not completed, Pi may try to use that fake token and Anthropic will return `401 Invalid bearer token`. Use a harmless placeholder such as `"unused"`, then run `/login` and choose `Claude Pro/Max`.
65
104
 
66
105
  ## Troubleshooting
67
106
 
68
- - Re-run `/login anthropic` if auth looks stale
107
+ - Run `/login` with no arguments, then choose `Claude Pro/Max`.
69
108
  - If local callback login does not complete, paste the final callback URL or `code#state` when prompted
109
+ - If you see `401 Invalid bearer token`, remove any fake `sk-ant-oat...` placeholder from `~/.pi/agent/models.json` and log in again
70
110
  - If something breaks, please open an issue with your Pi version, extension version, and error output
71
111
 
72
112
  ## License
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-anthropic-oauth",
3
- "version": "0.1.14",
3
+ "version": "0.1.16",
4
4
  "description": "Claude Pro/Max OAuth extension for Pi",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -25,8 +25,9 @@
25
25
  },
26
26
  "scripts": {
27
27
  "check": "tsc -p tsconfig.json --noEmit",
28
+ "test": "node -e \"require('node:fs').rmSync('.test-dist', { recursive: true, force: true })\" && tsc -p tsconfig.json --outDir .test-dist && node --test test/*.test.mjs",
28
29
  "pack:check": "npm pack --dry-run",
29
- "prepublishOnly": "npm run check && npm run pack:check"
30
+ "prepublishOnly": "npm run check && npm test && npm run pack:check"
30
31
  },
31
32
  "pi": {
32
33
  "extensions": [
package/src/prompt.ts CHANGED
@@ -6,6 +6,25 @@ const PI_REMOVAL_ANCHORS = [
6
6
  "badlogic/pi-mono",
7
7
  ] as const;
8
8
 
9
+ export const PI_REWRITE_MODE_ENV = "PI_ANTHROPIC_OAUTH_REWRITE_MODE";
10
+ export const PI_REWRITE_PATTERN_ENV = "PI_ANTHROPIC_OAUTH_REWRITE_PATTERN";
11
+
12
+ export type PiRewriteMode = "aggressive" | "path-safe" | "technical-safe" | "custom";
13
+
14
+ const DEFAULT_PI_REWRITE_MODE: PiRewriteMode = "aggressive";
15
+ const PI_REWRITE_PATTERN_SOURCES: Record<Exclude<PiRewriteMode, "custom">, string> = {
16
+ aggressive: String.raw`\b[Pp]i\b`,
17
+ "path-safe": String.raw`(?<![/\\])\b[Pp]i\b`,
18
+ "technical-safe": String.raw`(?<![/\\.@:_-])\b[Pp]i\b(?![/\\.@:_-])`,
19
+ };
20
+
21
+ const PI_REWRITE_MODES = new Set<PiRewriteMode>([
22
+ "aggressive",
23
+ "path-safe",
24
+ "technical-safe",
25
+ "custom",
26
+ ]);
27
+
9
28
  type MessageContentBlock = {
10
29
  type: string;
11
30
  text?: string;
@@ -42,7 +61,10 @@ export function buildAnthropicSystemPrompt(
42
61
  return blocks.length > 0 ? blocks : undefined;
43
62
  }
44
63
 
45
- function sanitizeSystemText(text: string): string {
64
+ export function sanitizeSystemText(
65
+ text: string,
66
+ env: NodeJS.ProcessEnv = process.env,
67
+ ): string {
46
68
  const paragraphs = text.split(/\n\n+/);
47
69
  const filtered = paragraphs.filter((paragraph) => {
48
70
  const lower = paragraph.toLowerCase();
@@ -52,7 +74,56 @@ function sanitizeSystemText(text: string): string {
52
74
 
53
75
  return filtered
54
76
  .join("\n\n")
55
- .replace(/\bpi\b/g, "Claude Code")
56
- .replace(/\bPi\b/g, "Claude Code")
77
+ .replace(resolvePiRewritePattern(env), "Claude Code")
57
78
  .trim();
58
79
  }
80
+
81
+ function resolvePiRewritePattern(env: NodeJS.ProcessEnv): RegExp {
82
+ const mode = parsePiRewriteMode(env[PI_REWRITE_MODE_ENV]);
83
+ if (mode === "custom") return compileCustomPiRewritePattern(env[PI_REWRITE_PATTERN_ENV]);
84
+ return new RegExp(PI_REWRITE_PATTERN_SOURCES[mode], "g");
85
+ }
86
+
87
+ function parsePiRewriteMode(value: string | undefined): PiRewriteMode {
88
+ if (!value?.trim()) return DEFAULT_PI_REWRITE_MODE;
89
+ const mode = value.trim().toLowerCase();
90
+ if (PI_REWRITE_MODES.has(mode as PiRewriteMode)) return mode as PiRewriteMode;
91
+ throw new Error(
92
+ `Invalid ${PI_REWRITE_MODE_ENV}: ${value}. Expected one of ${Array.from(PI_REWRITE_MODES).join(", ")}.`,
93
+ );
94
+ }
95
+
96
+ function compileCustomPiRewritePattern(value: string | undefined): RegExp {
97
+ const pattern = value?.trim();
98
+ if (!pattern) {
99
+ throw new Error(`${PI_REWRITE_PATTERN_ENV} must be set when ${PI_REWRITE_MODE_ENV}=custom.`);
100
+ }
101
+
102
+ const parsed = parseRegexLiteral(pattern) ?? { source: pattern, flags: "" };
103
+ const flags = parsed.flags.includes("g") ? parsed.flags : `${parsed.flags}g`;
104
+ try {
105
+ return new RegExp(parsed.source, flags);
106
+ } catch (error) {
107
+ const message = error instanceof Error ? error.message : String(error);
108
+ throw new Error(`Invalid ${PI_REWRITE_PATTERN_ENV}: ${message}`);
109
+ }
110
+ }
111
+
112
+ function parseRegexLiteral(value: string): { source: string; flags: string } | undefined {
113
+ if (!value.startsWith("/")) return undefined;
114
+
115
+ for (let index = value.length - 1; index > 0; index--) {
116
+ if (value[index] !== "/" || isEscaped(value, index)) continue;
117
+ return { source: value.slice(1, index), flags: value.slice(index + 1) };
118
+ }
119
+
120
+ return undefined;
121
+ }
122
+
123
+ function isEscaped(value: string, index: number): boolean {
124
+ let slashCount = 0;
125
+ for (let cursor = index - 1; cursor >= 0 && value[cursor] === "\\"; cursor--) {
126
+ slashCount++;
127
+ }
128
+ return slashCount % 2 === 1;
129
+ }
package/src/stream.ts CHANGED
@@ -24,7 +24,12 @@ import { buildAnthropicSystemPrompt } from "./prompt.js";
24
24
  const REQUIRED_BETAS = [
25
25
  "claude-code-20250219",
26
26
  "oauth-2025-04-20",
27
- "fine-grained-tool-streaming-2025-05-14",
27
+ // fine-grained-tool-streaming removed: it ships the model's raw, unvalidated
28
+ // tool-input JSON. For large edits full of quotes/newlines the streamed
29
+ // string escaping breaks, so a field (e.g. edit.oldText) swallows the rest of
30
+ // the structure — surfacing as either a hard JSON.parse crash or a wrong-shape
31
+ // schema-validation failure. Default streaming has the server validate/buffer
32
+ // tool JSON, guaranteeing well-formed, correctly-structured input.
28
33
  "interleaved-thinking-2025-05-14",
29
34
  ] as const;
30
35
 
@@ -43,6 +48,14 @@ function mapStopReason(reason: string | null | undefined): StopReason {
43
48
  }
44
49
  }
45
50
 
51
+ function headersToRecord(headers: Headers): Record<string, string> {
52
+ const record: Record<string, string> = {};
53
+ headers.forEach((value, key) => {
54
+ record[key] = value;
55
+ });
56
+ return record;
57
+ }
58
+
46
59
  function makeDefaultHeaders(
47
60
  isOAuth: boolean,
48
61
  options?: SimpleStreamOptions,
@@ -57,10 +70,7 @@ function makeDefaultHeaders(
57
70
  headers["user-agent"] = USER_AGENT;
58
71
  headers["x-app"] = "cli";
59
72
  } else {
60
- headers["anthropic-beta"] = [
61
- "fine-grained-tool-streaming-2025-05-14",
62
- "interleaved-thinking-2025-05-14",
63
- ].join(",");
73
+ headers["anthropic-beta"] = ["interleaved-thinking-2025-05-14"].join(",");
64
74
  }
65
75
 
66
76
  if (options?.headers) {
@@ -118,7 +128,9 @@ export function streamAnthropicOAuth(
118
128
  try {
119
129
  const apiKey = options?.apiKey;
120
130
  if (!apiKey) {
121
- throw new Error("No Anthropic auth available. Run /login anthropic.");
131
+ throw new Error(
132
+ "No Anthropic auth available. Run /login and choose Claude Pro/Max.",
133
+ );
122
134
  }
123
135
 
124
136
  const isOAuth = isClaudeOAuthAccessToken(apiKey);
@@ -170,9 +182,32 @@ export function streamAnthropicOAuth(
170
182
  };
171
183
  }
172
184
 
173
- const anthropicStream = client.messages.stream(params, {
174
- signal: options?.signal,
175
- });
185
+ // Raw stream instead of the MessageStream helper: MessageStream
186
+ // accumulates tool_use input and JSON.parses it on content_block_stop,
187
+ // which throws under fine-grained-tool-streaming (input may be invalid
188
+ // mid-flight) and aborts the turn. The raw stream yields the same
189
+ // RawMessageStreamEvents; tool args are already parsed leniently below.
190
+ const { data: anthropicStream, response: httpResponse } =
191
+ await client.messages
192
+ .create(params, {
193
+ signal: options?.signal,
194
+ })
195
+ .withResponse();
196
+
197
+ if (options?.onResponse) {
198
+ try {
199
+ await options.onResponse(
200
+ {
201
+ status: httpResponse.status,
202
+ headers: headersToRecord(httpResponse.headers),
203
+ },
204
+ model,
205
+ );
206
+ } catch {
207
+ // Response hooks are best-effort and should not break streaming.
208
+ }
209
+ }
210
+
176
211
  stream.push({ type: "start", partial: output });
177
212
 
178
213
  const blocks = output.content as IndexedBlock[];