kimaki 0.4.78 → 0.4.80
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/dist/anthropic-auth-plugin.js +628 -0
- package/dist/channel-management.js +2 -2
- package/dist/cli.js +316 -129
- package/dist/commands/action-buttons.js +1 -1
- package/dist/commands/login.js +634 -277
- package/dist/commands/model.js +91 -6
- package/dist/commands/paginated-select.js +57 -0
- package/dist/commands/resume.js +2 -2
- package/dist/commands/tasks.js +205 -0
- package/dist/commands/undo-redo.js +80 -18
- package/dist/context-awareness-plugin.js +347 -0
- package/dist/database.js +103 -7
- package/dist/db.js +39 -1
- package/dist/discord-bot.js +42 -19
- package/dist/discord-urls.js +11 -0
- package/dist/discord-ws-proxy.js +350 -0
- package/dist/discord-ws-proxy.test.js +500 -0
- package/dist/errors.js +1 -1
- package/dist/gateway-session.js +163 -0
- package/dist/hrana-server.js +114 -4
- package/dist/interaction-handler.js +30 -7
- package/dist/ipc-tools-plugin.js +186 -0
- package/dist/message-preprocessing.js +56 -11
- package/dist/onboarding-welcome.js +1 -1
- package/dist/opencode-interrupt-plugin.js +133 -75
- package/dist/opencode-plugin.js +12 -389
- package/dist/opencode.js +59 -5
- package/dist/parse-permission-rules.test.js +117 -0
- package/dist/queue-drain-after-interactive-ui.e2e.test.js +119 -0
- package/dist/session-handler/thread-session-runtime.js +68 -29
- package/dist/startup-time.e2e.test.js +295 -0
- package/dist/store.js +1 -0
- package/dist/system-message.js +3 -1
- package/dist/task-runner.js +7 -3
- package/dist/task-schedule.js +12 -0
- package/dist/thread-message-queue.e2e.test.js +13 -1
- package/dist/undo-redo.e2e.test.js +166 -0
- package/dist/utils.js +4 -1
- package/dist/voice-attachment.js +34 -0
- package/dist/voice-handler.js +11 -9
- package/dist/voice-message.e2e.test.js +78 -0
- package/dist/voice.test.js +31 -0
- package/package.json +12 -7
- package/skills/egaki/SKILL.md +80 -15
- package/skills/errore/SKILL.md +13 -0
- package/skills/lintcn/SKILL.md +749 -0
- package/skills/npm-package/SKILL.md +17 -3
- package/skills/spiceflow/SKILL.md +14 -0
- package/skills/zele/SKILL.md +9 -0
- package/src/anthropic-auth-plugin.ts +732 -0
- package/src/channel-management.ts +2 -2
- package/src/cli.ts +354 -132
- package/src/commands/action-buttons.ts +1 -0
- package/src/commands/login.ts +836 -337
- package/src/commands/model.ts +102 -7
- package/src/commands/paginated-select.ts +81 -0
- package/src/commands/resume.ts +6 -1
- package/src/commands/tasks.ts +293 -0
- package/src/commands/undo-redo.ts +87 -20
- package/src/context-awareness-plugin.ts +469 -0
- package/src/database.ts +138 -7
- package/src/db.ts +40 -1
- package/src/discord-bot.ts +46 -19
- package/src/discord-urls.ts +12 -0
- package/src/errors.ts +1 -1
- package/src/hrana-server.ts +124 -3
- package/src/interaction-handler.ts +41 -9
- package/src/ipc-tools-plugin.ts +228 -0
- package/src/message-preprocessing.ts +82 -11
- package/src/onboarding-welcome.ts +1 -1
- package/src/opencode-interrupt-plugin.ts +164 -91
- package/src/opencode-plugin.ts +13 -483
- package/src/opencode.ts +60 -5
- package/src/parse-permission-rules.test.ts +127 -0
- package/src/queue-drain-after-interactive-ui.e2e.test.ts +151 -0
- package/src/session-handler/thread-runtime-state.ts +4 -1
- package/src/session-handler/thread-session-runtime.ts +82 -20
- package/src/startup-time.e2e.test.ts +372 -0
- package/src/store.ts +8 -0
- package/src/system-message.ts +10 -1
- package/src/task-runner.ts +9 -22
- package/src/task-schedule.ts +15 -0
- package/src/thread-message-queue.e2e.test.ts +14 -1
- package/src/undo-redo.e2e.test.ts +207 -0
- package/src/utils.ts +7 -0
- package/src/voice-attachment.ts +51 -0
- package/src/voice-handler.ts +15 -7
- package/src/voice-message.e2e.test.ts +95 -0
- package/src/voice.test.ts +36 -0
- package/src/onboarding-tutorial-plugin.ts +0 -93
|
@@ -0,0 +1,732 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Anthropic OAuth authentication plugin for OpenCode.
|
|
3
|
+
*
|
|
4
|
+
* Handles two concerns:
|
|
5
|
+
* 1. OAuth login + token refresh (PKCE flow against claude.ai)
|
|
6
|
+
* 2. Request/response rewriting (tool names, system prompt, beta headers)
|
|
7
|
+
* so the Anthropic API treats requests as Claude Code CLI requests.
|
|
8
|
+
*
|
|
9
|
+
* Login mode is chosen from environment:
|
|
10
|
+
* - `KIMAKI` set: remote-first pasted callback URL/raw code flow
|
|
11
|
+
* - otherwise: standard localhost auto-complete flow
|
|
12
|
+
*
|
|
13
|
+
* Source references:
|
|
14
|
+
* - https://github.com/badlogic/pi-mono/blob/main/packages/ai/src/utils/oauth/anthropic.ts
|
|
15
|
+
* - https://github.com/badlogic/pi-mono/blob/main/packages/ai/src/providers/anthropic.ts
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type { Plugin } from "@opencode-ai/plugin";
|
|
19
|
+
import { generatePKCE } from "@openauthjs/openauth/pkce";
|
|
20
|
+
import * as fs from "node:fs/promises";
|
|
21
|
+
import { createServer, type Server } from "node:http";
|
|
22
|
+
import { homedir } from "node:os";
|
|
23
|
+
import path from "node:path";
|
|
24
|
+
import lockfile from "proper-lockfile";
|
|
25
|
+
|
|
26
|
+
// --- Constants ---
|
|
27
|
+
|
|
28
|
+
const CLIENT_ID = (() => {
|
|
29
|
+
const encoded = "OWQxYzI1MGEtZTYxYi00NGQ5LTg4ZWQtNTk0NGQxOTYyZjVl";
|
|
30
|
+
return typeof atob === "function"
|
|
31
|
+
? atob(encoded)
|
|
32
|
+
: Buffer.from(encoded, "base64").toString("utf8");
|
|
33
|
+
})();
|
|
34
|
+
|
|
35
|
+
const TOKEN_URL = "https://platform.claude.com/v1/oauth/token";
|
|
36
|
+
const CREATE_API_KEY_URL = "https://api.anthropic.com/api/oauth/claude_cli/create_api_key";
|
|
37
|
+
const CALLBACK_PORT = 53692;
|
|
38
|
+
const CALLBACK_PATH = "/callback";
|
|
39
|
+
const REDIRECT_URI = `http://localhost:${CALLBACK_PORT}${CALLBACK_PATH}`;
|
|
40
|
+
const SCOPES =
|
|
41
|
+
"org:create_api_key user:profile user:inference user:sessions:claude_code user:mcp_servers user:file_upload";
|
|
42
|
+
const OAUTH_TIMEOUT_MS = 5 * 60 * 1000;
|
|
43
|
+
const CLAUDE_CODE_VERSION = "2.1.75";
|
|
44
|
+
const CLAUDE_CODE_IDENTITY = "You are Claude Code, Anthropic's official CLI for Claude.";
|
|
45
|
+
const OPENCODE_IDENTITY = "You are OpenCode, the best coding agent on the planet.";
|
|
46
|
+
const CLAUDE_CODE_BETA = "claude-code-20250219";
|
|
47
|
+
const OAUTH_BETA = "oauth-2025-04-20";
|
|
48
|
+
const FINE_GRAINED_TOOL_STREAMING_BETA = "fine-grained-tool-streaming-2025-05-14";
|
|
49
|
+
const INTERLEAVED_THINKING_BETA = "interleaved-thinking-2025-05-14";
|
|
50
|
+
|
|
51
|
+
const ANTHROPIC_HOSTS = new Set([
|
|
52
|
+
"api.anthropic.com",
|
|
53
|
+
"claude.ai",
|
|
54
|
+
"console.anthropic.com",
|
|
55
|
+
"platform.claude.com",
|
|
56
|
+
]);
|
|
57
|
+
|
|
58
|
+
const OPENCODE_TO_CLAUDE_CODE_TOOL_NAME: Record<string, string> = {
|
|
59
|
+
bash: "Bash",
|
|
60
|
+
edit: "Edit",
|
|
61
|
+
glob: "Glob",
|
|
62
|
+
grep: "Grep",
|
|
63
|
+
question: "AskUserQuestion",
|
|
64
|
+
read: "Read",
|
|
65
|
+
skill: "Skill",
|
|
66
|
+
task: "Task",
|
|
67
|
+
todowrite: "TodoWrite",
|
|
68
|
+
webfetch: "WebFetch",
|
|
69
|
+
websearch: "WebSearch",
|
|
70
|
+
write: "Write",
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
// --- Types ---
|
|
74
|
+
|
|
75
|
+
type OAuthStored = {
|
|
76
|
+
type: "oauth";
|
|
77
|
+
refresh: string;
|
|
78
|
+
access: string;
|
|
79
|
+
expires: number;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
type OAuthSuccess = {
|
|
83
|
+
type: "success";
|
|
84
|
+
provider?: string;
|
|
85
|
+
refresh: string;
|
|
86
|
+
access: string;
|
|
87
|
+
expires: number;
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
type ApiKeySuccess = {
|
|
91
|
+
type: "success";
|
|
92
|
+
provider?: string;
|
|
93
|
+
key: string;
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
type AuthResult = OAuthSuccess | ApiKeySuccess | { type: "failed" };
|
|
97
|
+
|
|
98
|
+
// --- HTTP helpers ---
|
|
99
|
+
|
|
100
|
+
const MAX_RETRIES = 3;
|
|
101
|
+
const BASE_DELAY_MS = 2_000;
|
|
102
|
+
|
|
103
|
+
async function sleep(ms: number) {
|
|
104
|
+
return new Promise<void>((resolve) => { setTimeout(resolve, ms); });
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function postJson(url: string, body: Record<string, string | number>): Promise<unknown> {
|
|
108
|
+
const jsonBody = JSON.stringify(body);
|
|
109
|
+
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
|
110
|
+
const response = await fetch(url, {
|
|
111
|
+
method: "POST",
|
|
112
|
+
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
|
113
|
+
body: jsonBody,
|
|
114
|
+
signal: AbortSignal.timeout(30_000),
|
|
115
|
+
});
|
|
116
|
+
if (response.status === 429 && attempt < MAX_RETRIES) {
|
|
117
|
+
// Respect Retry-After header if present, otherwise exponential backoff
|
|
118
|
+
const retryAfter = response.headers.get("retry-after");
|
|
119
|
+
const delayMs = retryAfter
|
|
120
|
+
? Math.min(Number(retryAfter) * 1000, 60_000)
|
|
121
|
+
: BASE_DELAY_MS * 2 ** attempt;
|
|
122
|
+
console.warn(`[anthropic-auth] 429 from ${url}, retrying in ${delayMs}ms (attempt ${attempt + 1}/${MAX_RETRIES})`);
|
|
123
|
+
await sleep(delayMs);
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
if (!response.ok) {
|
|
127
|
+
const text = await response.text().catch(() => "");
|
|
128
|
+
throw new Error(`HTTP ${response.status} from ${url}: ${text}`);
|
|
129
|
+
}
|
|
130
|
+
return response.json();
|
|
131
|
+
}
|
|
132
|
+
throw new Error(`Exhausted retries for ${url}`);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// --- File lock for token refresh ---
|
|
136
|
+
|
|
137
|
+
let pendingRefresh: Promise<OAuthStored> | undefined;
|
|
138
|
+
|
|
139
|
+
function authFilePath() {
|
|
140
|
+
if (process.env.XDG_DATA_HOME) {
|
|
141
|
+
return path.join(process.env.XDG_DATA_HOME, "opencode", "auth.json");
|
|
142
|
+
}
|
|
143
|
+
return path.join(homedir(), ".local", "share", "opencode", "auth.json");
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async function withAuthRefreshLock<T>(fn: () => Promise<T>) {
|
|
147
|
+
const file = authFilePath();
|
|
148
|
+
await fs.mkdir(path.dirname(file), { recursive: true });
|
|
149
|
+
await fs.appendFile(file, "");
|
|
150
|
+
|
|
151
|
+
const release = await lockfile.lock(file, {
|
|
152
|
+
realpath: false,
|
|
153
|
+
stale: 30_000,
|
|
154
|
+
update: 15_000,
|
|
155
|
+
retries: { factor: 1.3, forever: true, maxTimeout: 1_000, minTimeout: 100 },
|
|
156
|
+
onCompromised: () => {},
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
try {
|
|
160
|
+
return await fn();
|
|
161
|
+
} finally {
|
|
162
|
+
await release().catch(() => {});
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// --- OAuth token exchange & refresh ---
|
|
167
|
+
|
|
168
|
+
function parseTokenResponse(json: unknown): { access_token: string; refresh_token: string; expires_in: number } {
|
|
169
|
+
const data = json as { access_token: string; refresh_token: string; expires_in: number };
|
|
170
|
+
if (!data.access_token || !data.refresh_token) {
|
|
171
|
+
throw new Error(`Invalid token response: ${JSON.stringify(json)}`);
|
|
172
|
+
}
|
|
173
|
+
return data;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function tokenExpiry(expiresIn: number) {
|
|
177
|
+
return Date.now() + expiresIn * 1000 - 5 * 60 * 1000;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async function exchangeAuthorizationCode(
|
|
181
|
+
code: string,
|
|
182
|
+
state: string,
|
|
183
|
+
verifier: string,
|
|
184
|
+
redirectUri: string,
|
|
185
|
+
): Promise<OAuthSuccess> {
|
|
186
|
+
const json = await postJson(TOKEN_URL, {
|
|
187
|
+
grant_type: "authorization_code",
|
|
188
|
+
client_id: CLIENT_ID,
|
|
189
|
+
code,
|
|
190
|
+
state,
|
|
191
|
+
redirect_uri: redirectUri,
|
|
192
|
+
code_verifier: verifier,
|
|
193
|
+
});
|
|
194
|
+
const data = parseTokenResponse(json);
|
|
195
|
+
return {
|
|
196
|
+
type: "success",
|
|
197
|
+
refresh: data.refresh_token,
|
|
198
|
+
access: data.access_token,
|
|
199
|
+
expires: tokenExpiry(data.expires_in),
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
async function refreshAnthropicToken(refreshToken: string): Promise<OAuthStored> {
|
|
204
|
+
const json = await postJson(TOKEN_URL, {
|
|
205
|
+
grant_type: "refresh_token",
|
|
206
|
+
client_id: CLIENT_ID,
|
|
207
|
+
refresh_token: refreshToken,
|
|
208
|
+
});
|
|
209
|
+
const data = parseTokenResponse(json);
|
|
210
|
+
return {
|
|
211
|
+
type: "oauth",
|
|
212
|
+
refresh: data.refresh_token,
|
|
213
|
+
access: data.access_token,
|
|
214
|
+
expires: tokenExpiry(data.expires_in),
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async function createApiKey(accessToken: string): Promise<ApiKeySuccess> {
|
|
219
|
+
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
|
220
|
+
const response = await fetch(CREATE_API_KEY_URL, {
|
|
221
|
+
method: "POST",
|
|
222
|
+
headers: {
|
|
223
|
+
Accept: "application/json",
|
|
224
|
+
authorization: `Bearer ${accessToken}`,
|
|
225
|
+
"Content-Type": "application/json",
|
|
226
|
+
},
|
|
227
|
+
signal: AbortSignal.timeout(30_000),
|
|
228
|
+
});
|
|
229
|
+
if (response.status === 429 && attempt < MAX_RETRIES) {
|
|
230
|
+
const retryAfter = response.headers.get("retry-after");
|
|
231
|
+
const delayMs = retryAfter
|
|
232
|
+
? Math.min(Number(retryAfter) * 1000, 60_000)
|
|
233
|
+
: BASE_DELAY_MS * 2 ** attempt;
|
|
234
|
+
console.warn(`[anthropic-auth] 429 creating API key, retrying in ${delayMs}ms (attempt ${attempt + 1}/${MAX_RETRIES})`);
|
|
235
|
+
await sleep(delayMs);
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
if (!response.ok) {
|
|
239
|
+
const text = await response.text().catch(() => "");
|
|
240
|
+
throw new Error(`HTTP ${response.status} creating API key: ${text}`);
|
|
241
|
+
}
|
|
242
|
+
const json = (await response.json()) as { raw_key: string };
|
|
243
|
+
return { type: "success", key: json.raw_key };
|
|
244
|
+
}
|
|
245
|
+
throw new Error(`Exhausted retries for ${CREATE_API_KEY_URL}`);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// --- Localhost callback server ---
|
|
249
|
+
|
|
250
|
+
type CallbackResult = { code: string; state: string };
|
|
251
|
+
|
|
252
|
+
async function startCallbackServer(expectedState: string) {
|
|
253
|
+
return new Promise<{
|
|
254
|
+
server: Server;
|
|
255
|
+
cancelWait: () => void;
|
|
256
|
+
waitForCode: () => Promise<CallbackResult | null>;
|
|
257
|
+
}>((resolve, reject) => {
|
|
258
|
+
let settle: ((value: CallbackResult | null) => void) | undefined;
|
|
259
|
+
let settled = false;
|
|
260
|
+
const waitPromise = new Promise<CallbackResult | null>((res) => {
|
|
261
|
+
settle = (v) => {
|
|
262
|
+
if (settled) return;
|
|
263
|
+
settled = true;
|
|
264
|
+
res(v);
|
|
265
|
+
};
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
const server = createServer((req, res) => {
|
|
269
|
+
try {
|
|
270
|
+
const url = new URL(req.url || "", "http://localhost");
|
|
271
|
+
if (url.pathname !== CALLBACK_PATH) {
|
|
272
|
+
res.writeHead(404).end("Not found");
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
const code = url.searchParams.get("code");
|
|
276
|
+
const state = url.searchParams.get("state");
|
|
277
|
+
const error = url.searchParams.get("error");
|
|
278
|
+
if (error || !code || !state || state !== expectedState) {
|
|
279
|
+
res.writeHead(400).end("Authentication failed: " + (error || "missing code/state"));
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
res.writeHead(200, { "Content-Type": "text/plain" }).end("Authentication successful. You can close this window.");
|
|
283
|
+
settle?.({ code, state });
|
|
284
|
+
} catch {
|
|
285
|
+
res.writeHead(500).end("Internal error");
|
|
286
|
+
}
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
server.once("error", reject);
|
|
290
|
+
server.listen(CALLBACK_PORT, "127.0.0.1", () => {
|
|
291
|
+
resolve({
|
|
292
|
+
server,
|
|
293
|
+
cancelWait: () => { settle?.(null); },
|
|
294
|
+
waitForCode: () => waitPromise,
|
|
295
|
+
});
|
|
296
|
+
});
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function closeServer(server: Server) {
|
|
301
|
+
return new Promise<void>((resolve) => { server.close(() => { resolve(); }); });
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// --- Authorization flow ---
|
|
305
|
+
// Unified flow: beginAuthorizationFlow starts PKCE + callback server,
|
|
306
|
+
// then waitForCallback handles both auto (localhost) and manual (pasted code) paths.
|
|
307
|
+
|
|
308
|
+
async function beginAuthorizationFlow() {
|
|
309
|
+
const pkce = await generatePKCE();
|
|
310
|
+
const callbackServer = await startCallbackServer(pkce.verifier);
|
|
311
|
+
|
|
312
|
+
const authParams = new URLSearchParams({
|
|
313
|
+
code: "true",
|
|
314
|
+
client_id: CLIENT_ID,
|
|
315
|
+
response_type: "code",
|
|
316
|
+
redirect_uri: REDIRECT_URI,
|
|
317
|
+
scope: SCOPES,
|
|
318
|
+
code_challenge: pkce.challenge,
|
|
319
|
+
code_challenge_method: "S256",
|
|
320
|
+
state: pkce.verifier,
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
return {
|
|
324
|
+
url: `https://claude.ai/oauth/authorize?${authParams.toString()}`,
|
|
325
|
+
verifier: pkce.verifier,
|
|
326
|
+
callbackServer,
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
async function waitForCallback(
|
|
331
|
+
callbackServer: Awaited<ReturnType<typeof startCallbackServer>>,
|
|
332
|
+
manualInput?: string,
|
|
333
|
+
): Promise<CallbackResult> {
|
|
334
|
+
try {
|
|
335
|
+
// Try localhost callback first (instant check)
|
|
336
|
+
const quick = await Promise.race([
|
|
337
|
+
callbackServer.waitForCode(),
|
|
338
|
+
new Promise<null>((r) => { setTimeout(() => { r(null); }, 50); }),
|
|
339
|
+
]);
|
|
340
|
+
if (quick?.code) return quick;
|
|
341
|
+
|
|
342
|
+
// If manual input was provided, parse it
|
|
343
|
+
const trimmed = manualInput?.trim();
|
|
344
|
+
if (trimmed) {
|
|
345
|
+
return parseManualInput(trimmed);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// Wait for localhost callback with timeout
|
|
349
|
+
const result = await Promise.race([
|
|
350
|
+
callbackServer.waitForCode(),
|
|
351
|
+
new Promise<null>((r) => { setTimeout(() => { r(null); }, OAUTH_TIMEOUT_MS); }),
|
|
352
|
+
]);
|
|
353
|
+
if (!result?.code) {
|
|
354
|
+
throw new Error("Timed out waiting for OAuth callback");
|
|
355
|
+
}
|
|
356
|
+
return result;
|
|
357
|
+
} finally {
|
|
358
|
+
callbackServer.cancelWait();
|
|
359
|
+
await closeServer(callbackServer.server);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function parseManualInput(input: string): CallbackResult {
|
|
364
|
+
try {
|
|
365
|
+
const url = new URL(input);
|
|
366
|
+
const code = url.searchParams.get("code");
|
|
367
|
+
const state = url.searchParams.get("state");
|
|
368
|
+
if (code) return { code, state: state || "" };
|
|
369
|
+
} catch {
|
|
370
|
+
// not a URL
|
|
371
|
+
}
|
|
372
|
+
if (input.includes("#")) {
|
|
373
|
+
const [code = "", state = ""] = input.split("#", 2);
|
|
374
|
+
return { code, state };
|
|
375
|
+
}
|
|
376
|
+
if (input.includes("code=")) {
|
|
377
|
+
const params = new URLSearchParams(input);
|
|
378
|
+
const code = params.get("code");
|
|
379
|
+
if (code) return { code, state: params.get("state") || "" };
|
|
380
|
+
}
|
|
381
|
+
return { code: input, state: "" };
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// Unified authorize handler: returns either OAuth tokens or an API key,
|
|
385
|
+
// for both auto and remote-first modes.
|
|
386
|
+
function buildAuthorizeHandler(mode: "oauth" | "apikey") {
|
|
387
|
+
return async () => {
|
|
388
|
+
const auth = await beginAuthorizationFlow();
|
|
389
|
+
const isRemote = Boolean(process.env.KIMAKI);
|
|
390
|
+
|
|
391
|
+
const finalize = async (result: CallbackResult): Promise<AuthResult> => {
|
|
392
|
+
const verifier = auth.verifier;
|
|
393
|
+
const creds = await exchangeAuthorizationCode(
|
|
394
|
+
result.code,
|
|
395
|
+
result.state || verifier,
|
|
396
|
+
verifier,
|
|
397
|
+
REDIRECT_URI,
|
|
398
|
+
);
|
|
399
|
+
if (mode === "apikey") {
|
|
400
|
+
return createApiKey(creds.access);
|
|
401
|
+
}
|
|
402
|
+
return creds;
|
|
403
|
+
};
|
|
404
|
+
|
|
405
|
+
if (!isRemote) {
|
|
406
|
+
return {
|
|
407
|
+
url: auth.url,
|
|
408
|
+
instructions: "Complete login in your browser on this machine. OpenCode will catch the localhost callback automatically.",
|
|
409
|
+
method: "auto" as const,
|
|
410
|
+
callback: async (): Promise<AuthResult> => {
|
|
411
|
+
try {
|
|
412
|
+
const result = await waitForCallback(auth.callbackServer);
|
|
413
|
+
return finalize(result);
|
|
414
|
+
} catch (error) {
|
|
415
|
+
console.error(`[anthropic-auth] ${error}`);
|
|
416
|
+
return { type: "failed" };
|
|
417
|
+
}
|
|
418
|
+
},
|
|
419
|
+
};
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
return {
|
|
423
|
+
url: auth.url,
|
|
424
|
+
instructions: "Complete login in your browser, then paste the final redirect URL from the address bar here. Pasting just the authorization code also works.",
|
|
425
|
+
method: "code" as const,
|
|
426
|
+
callback: async (input: string): Promise<AuthResult> => {
|
|
427
|
+
try {
|
|
428
|
+
const result = await waitForCallback(auth.callbackServer, input);
|
|
429
|
+
return finalize(result);
|
|
430
|
+
} catch (error) {
|
|
431
|
+
console.error(`[anthropic-auth] ${error}`);
|
|
432
|
+
return { type: "failed" };
|
|
433
|
+
}
|
|
434
|
+
},
|
|
435
|
+
};
|
|
436
|
+
};
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// --- Request/response rewriting ---
|
|
440
|
+
// Renames opencode tool names to Claude Code tool names in requests,
|
|
441
|
+
// and reverses the mapping in streamed responses.
|
|
442
|
+
|
|
443
|
+
function toClaudeCodeToolName(name: string) {
|
|
444
|
+
return OPENCODE_TO_CLAUDE_CODE_TOOL_NAME[name.toLowerCase()] ?? name;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
function sanitizeSystemText(text: string) {
|
|
448
|
+
return text.replaceAll(OPENCODE_IDENTITY, CLAUDE_CODE_IDENTITY);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
function prependClaudeCodeIdentity(system: unknown) {
|
|
452
|
+
const identityBlock = { type: "text", text: CLAUDE_CODE_IDENTITY };
|
|
453
|
+
|
|
454
|
+
if (typeof system === "undefined") return [identityBlock];
|
|
455
|
+
|
|
456
|
+
if (typeof system === "string") {
|
|
457
|
+
const sanitized = sanitizeSystemText(system);
|
|
458
|
+
if (sanitized === CLAUDE_CODE_IDENTITY) return [identityBlock];
|
|
459
|
+
return [identityBlock, { type: "text", text: sanitized }];
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
if (!Array.isArray(system)) return [identityBlock, system];
|
|
463
|
+
|
|
464
|
+
const sanitized = system.map((item) => {
|
|
465
|
+
if (typeof item === "string") return { type: "text", text: sanitizeSystemText(item) };
|
|
466
|
+
if (item && typeof item === "object" && (item as { type?: unknown }).type === "text") {
|
|
467
|
+
const text = (item as { text?: unknown }).text;
|
|
468
|
+
if (typeof text === "string") {
|
|
469
|
+
return { ...(item as Record<string, unknown>), text: sanitizeSystemText(text) };
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
return item;
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
const first = sanitized[0];
|
|
476
|
+
if (
|
|
477
|
+
first &&
|
|
478
|
+
typeof first === "object" &&
|
|
479
|
+
(first as { type?: unknown }).type === "text" &&
|
|
480
|
+
(first as { text?: unknown }).text === CLAUDE_CODE_IDENTITY
|
|
481
|
+
) {
|
|
482
|
+
return sanitized;
|
|
483
|
+
}
|
|
484
|
+
return [identityBlock, ...sanitized];
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
function rewriteRequestPayload(body: string | undefined) {
|
|
488
|
+
if (!body) return { body, modelId: undefined, reverseToolNameMap: new Map<string, string>() };
|
|
489
|
+
|
|
490
|
+
try {
|
|
491
|
+
const payload = JSON.parse(body) as Record<string, unknown>;
|
|
492
|
+
const reverseToolNameMap = new Map<string, string>();
|
|
493
|
+
const modelId = typeof payload.model === "string" ? payload.model : undefined;
|
|
494
|
+
|
|
495
|
+
// Build reverse map and rename tools
|
|
496
|
+
if (Array.isArray(payload.tools)) {
|
|
497
|
+
payload.tools = payload.tools.map((tool) => {
|
|
498
|
+
if (!tool || typeof tool !== "object") return tool;
|
|
499
|
+
const name = (tool as { name?: unknown }).name;
|
|
500
|
+
if (typeof name !== "string") return tool;
|
|
501
|
+
const mapped = toClaudeCodeToolName(name);
|
|
502
|
+
reverseToolNameMap.set(mapped, name);
|
|
503
|
+
return { ...(tool as Record<string, unknown>), name: mapped };
|
|
504
|
+
});
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
// Rename system prompt
|
|
508
|
+
payload.system = prependClaudeCodeIdentity(payload.system);
|
|
509
|
+
|
|
510
|
+
// Rename tool_choice
|
|
511
|
+
if (
|
|
512
|
+
payload.tool_choice &&
|
|
513
|
+
typeof payload.tool_choice === "object" &&
|
|
514
|
+
(payload.tool_choice as { type?: unknown }).type === "tool"
|
|
515
|
+
) {
|
|
516
|
+
const name = (payload.tool_choice as { name?: unknown }).name;
|
|
517
|
+
if (typeof name === "string") {
|
|
518
|
+
payload.tool_choice = {
|
|
519
|
+
...(payload.tool_choice as Record<string, unknown>),
|
|
520
|
+
name: toClaudeCodeToolName(name),
|
|
521
|
+
};
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
// Rename tool_use blocks in messages
|
|
526
|
+
if (Array.isArray(payload.messages)) {
|
|
527
|
+
payload.messages = payload.messages.map((message) => {
|
|
528
|
+
if (!message || typeof message !== "object") return message;
|
|
529
|
+
const content = (message as { content?: unknown }).content;
|
|
530
|
+
if (!Array.isArray(content)) return message;
|
|
531
|
+
return {
|
|
532
|
+
...(message as Record<string, unknown>),
|
|
533
|
+
content: content.map((block) => {
|
|
534
|
+
if (!block || typeof block !== "object") return block;
|
|
535
|
+
const b = block as { type?: unknown; name?: unknown };
|
|
536
|
+
if (b.type !== "tool_use" || typeof b.name !== "string") return block;
|
|
537
|
+
return { ...(block as Record<string, unknown>), name: toClaudeCodeToolName(b.name) };
|
|
538
|
+
}),
|
|
539
|
+
};
|
|
540
|
+
});
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
return { body: JSON.stringify(payload), modelId, reverseToolNameMap };
|
|
544
|
+
} catch {
|
|
545
|
+
return { body, modelId: undefined, reverseToolNameMap: new Map<string, string>() };
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
function wrapResponseStream(response: Response, reverseToolNameMap: Map<string, string>) {
|
|
550
|
+
if (!response.body || reverseToolNameMap.size === 0) return response;
|
|
551
|
+
|
|
552
|
+
const reader = response.body.getReader();
|
|
553
|
+
const decoder = new TextDecoder();
|
|
554
|
+
const encoder = new TextEncoder();
|
|
555
|
+
let carry = "";
|
|
556
|
+
|
|
557
|
+
const transform = (text: string) => {
|
|
558
|
+
return text.replace(/"name"\s*:\s*"([^"]+)"/g, (full, name: string) => {
|
|
559
|
+
const original = reverseToolNameMap.get(name);
|
|
560
|
+
return original ? full.replace(`"${name}"`, `"${original}"`) : full;
|
|
561
|
+
});
|
|
562
|
+
};
|
|
563
|
+
|
|
564
|
+
const stream = new ReadableStream<Uint8Array>({
|
|
565
|
+
async pull(controller) {
|
|
566
|
+
const { done, value } = await reader.read();
|
|
567
|
+
if (done) {
|
|
568
|
+
const finalText = carry + decoder.decode();
|
|
569
|
+
if (finalText) controller.enqueue(encoder.encode(transform(finalText)));
|
|
570
|
+
controller.close();
|
|
571
|
+
return;
|
|
572
|
+
}
|
|
573
|
+
carry += decoder.decode(value, { stream: true });
|
|
574
|
+
// Buffer 256 chars to avoid splitting JSON keys across chunks
|
|
575
|
+
if (carry.length <= 256) return;
|
|
576
|
+
const output = carry.slice(0, -256);
|
|
577
|
+
carry = carry.slice(-256);
|
|
578
|
+
controller.enqueue(encoder.encode(transform(output)));
|
|
579
|
+
},
|
|
580
|
+
async cancel(reason) {
|
|
581
|
+
await reader.cancel(reason);
|
|
582
|
+
},
|
|
583
|
+
});
|
|
584
|
+
|
|
585
|
+
return new Response(stream, {
|
|
586
|
+
status: response.status,
|
|
587
|
+
statusText: response.statusText,
|
|
588
|
+
headers: response.headers,
|
|
589
|
+
});
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
// --- Beta headers ---
|
|
593
|
+
|
|
594
|
+
function getRequiredBetas(modelId: string | undefined) {
|
|
595
|
+
const betas = [CLAUDE_CODE_BETA, OAUTH_BETA, FINE_GRAINED_TOOL_STREAMING_BETA];
|
|
596
|
+
const isAdaptive =
|
|
597
|
+
modelId?.includes("opus-4-6") ||
|
|
598
|
+
modelId?.includes("opus-4.6") ||
|
|
599
|
+
modelId?.includes("sonnet-4-6") ||
|
|
600
|
+
modelId?.includes("sonnet-4.6");
|
|
601
|
+
if (!isAdaptive) betas.push(INTERLEAVED_THINKING_BETA);
|
|
602
|
+
return betas;
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
function mergeBetas(existing: string | null, required: string[]) {
|
|
606
|
+
return [
|
|
607
|
+
...new Set([
|
|
608
|
+
...required,
|
|
609
|
+
...(existing || "").split(",").map((s) => s.trim()).filter(Boolean),
|
|
610
|
+
]),
|
|
611
|
+
].join(",");
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
// --- Token refresh with dedup ---
|
|
615
|
+
|
|
616
|
+
function isOAuthStored(auth: { type: string }): auth is OAuthStored {
|
|
617
|
+
return auth.type === "oauth";
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
async function getFreshOAuth(
|
|
621
|
+
getAuth: () => Promise<OAuthStored | { type: string }>,
|
|
622
|
+
client: Parameters<Plugin>[0]["client"],
|
|
623
|
+
) {
|
|
624
|
+
const auth = await getAuth();
|
|
625
|
+
if (!isOAuthStored(auth)) return undefined;
|
|
626
|
+
if (auth.access && auth.expires > Date.now()) return auth;
|
|
627
|
+
|
|
628
|
+
if (!pendingRefresh) {
|
|
629
|
+
pendingRefresh = withAuthRefreshLock(async () => {
|
|
630
|
+
const latest = await getAuth();
|
|
631
|
+
if (!isOAuthStored(latest)) {
|
|
632
|
+
throw new Error("Anthropic OAuth credentials disappeared during refresh");
|
|
633
|
+
}
|
|
634
|
+
if (latest.access && latest.expires > Date.now()) return latest;
|
|
635
|
+
|
|
636
|
+
const refreshed = await refreshAnthropicToken(latest.refresh);
|
|
637
|
+
await client.auth.set({ path: { id: "anthropic" }, body: refreshed });
|
|
638
|
+
return refreshed;
|
|
639
|
+
}).finally(() => {
|
|
640
|
+
pendingRefresh = undefined;
|
|
641
|
+
});
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
return pendingRefresh;
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
// --- Plugin export ---
|
|
648
|
+
|
|
649
|
+
const AnthropicAuthPlugin: Plugin = async ({ client }) => {
|
|
650
|
+
return {
|
|
651
|
+
auth: {
|
|
652
|
+
provider: "anthropic",
|
|
653
|
+
async loader(
|
|
654
|
+
getAuth: () => Promise<OAuthStored | { type: string }>,
|
|
655
|
+
provider: { models: Record<string, { cost?: unknown }> },
|
|
656
|
+
) {
|
|
657
|
+
const auth = await getAuth();
|
|
658
|
+
if (auth.type !== "oauth") return {};
|
|
659
|
+
|
|
660
|
+
// Zero out costs for OAuth users (Claude Pro/Max subscription)
|
|
661
|
+
for (const model of Object.values(provider.models)) {
|
|
662
|
+
model.cost = { input: 0, output: 0, cache: { read: 0, write: 0 } };
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
return {
|
|
666
|
+
apiKey: "",
|
|
667
|
+
async fetch(input: Request | string | URL, init?: RequestInit) {
|
|
668
|
+
const url = (() => {
|
|
669
|
+
try {
|
|
670
|
+
return new URL(input instanceof Request ? input.url : input.toString());
|
|
671
|
+
} catch {
|
|
672
|
+
return null;
|
|
673
|
+
}
|
|
674
|
+
})();
|
|
675
|
+
if (!url || !ANTHROPIC_HOSTS.has(url.hostname)) return fetch(input, init);
|
|
676
|
+
|
|
677
|
+
const freshAuth = await getFreshOAuth(getAuth, client);
|
|
678
|
+
if (!freshAuth) return fetch(input, init);
|
|
679
|
+
|
|
680
|
+
const originalBody = typeof init?.body === "string"
|
|
681
|
+
? init.body
|
|
682
|
+
: input instanceof Request
|
|
683
|
+
? await input.clone().text().catch(() => undefined)
|
|
684
|
+
: undefined;
|
|
685
|
+
|
|
686
|
+
const rewritten = rewriteRequestPayload(originalBody);
|
|
687
|
+
const headers = new Headers(init?.headers);
|
|
688
|
+
if (input instanceof Request) {
|
|
689
|
+
input.headers.forEach((v, k) => { if (!headers.has(k)) headers.set(k, v); });
|
|
690
|
+
}
|
|
691
|
+
const betas = getRequiredBetas(rewritten.modelId);
|
|
692
|
+
|
|
693
|
+
headers.set("accept", "application/json");
|
|
694
|
+
headers.set("anthropic-beta", mergeBetas(headers.get("anthropic-beta"), betas));
|
|
695
|
+
headers.set("anthropic-dangerous-direct-browser-access", "true");
|
|
696
|
+
headers.set("authorization", `Bearer ${freshAuth.access}`);
|
|
697
|
+
headers.set("user-agent", process.env.OPENCODE_ANTHROPIC_USER_AGENT || `claude-cli/${CLAUDE_CODE_VERSION}`);
|
|
698
|
+
headers.set("x-app", "cli");
|
|
699
|
+
headers.delete("x-api-key");
|
|
700
|
+
|
|
701
|
+
const response = await fetch(input, {
|
|
702
|
+
...(init ?? {}),
|
|
703
|
+
body: rewritten.body,
|
|
704
|
+
headers,
|
|
705
|
+
});
|
|
706
|
+
|
|
707
|
+
return wrapResponseStream(response, rewritten.reverseToolNameMap);
|
|
708
|
+
},
|
|
709
|
+
};
|
|
710
|
+
},
|
|
711
|
+
methods: [
|
|
712
|
+
{
|
|
713
|
+
label: "Claude Pro/Max",
|
|
714
|
+
type: "oauth",
|
|
715
|
+
authorize: buildAuthorizeHandler("oauth"),
|
|
716
|
+
},
|
|
717
|
+
{
|
|
718
|
+
label: "Create an API Key",
|
|
719
|
+
type: "oauth",
|
|
720
|
+
authorize: buildAuthorizeHandler("apikey"),
|
|
721
|
+
},
|
|
722
|
+
{
|
|
723
|
+
provider: "anthropic",
|
|
724
|
+
label: "Manually enter API Key",
|
|
725
|
+
type: "api",
|
|
726
|
+
},
|
|
727
|
+
],
|
|
728
|
+
},
|
|
729
|
+
};
|
|
730
|
+
};
|
|
731
|
+
|
|
732
|
+
export { AnthropicAuthPlugin as anthropicAuthPlugin };
|