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