@mandujs/core 0.41.2 → 0.43.0

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 (88) hide show
  1. package/package.json +21 -4
  2. package/src/auth/__tests__/login.test.ts +420 -419
  3. package/src/auth/__tests__/reset.test.ts +296 -296
  4. package/src/brain/adapters/anthropic-oauth.ts +421 -420
  5. package/src/brain/adapters/index.ts +2 -1
  6. package/src/brain/adapters/ollama.ts +1 -1
  7. package/src/brain/adapters/openai-oauth.ts +534 -533
  8. package/src/brain/brain.ts +2 -1
  9. package/src/brain/redactor.ts +196 -196
  10. package/src/bundler/__tests__/cli-bench-utils.test.ts +149 -149
  11. package/src/bundler/__tests__/cold-start.test.ts +504 -504
  12. package/src/bundler/__tests__/fast-refresh.test.ts +607 -606
  13. package/src/bundler/__tests__/hdr.test.ts +1 -1
  14. package/src/bundler/analyzer.ts +958 -958
  15. package/src/bundler/build.ts +104 -14
  16. package/src/bundler/dev.ts +125 -0
  17. package/src/bundler/hmr-types.ts +1 -0
  18. package/src/bundler/plugins/__tests__/react-compiler-lint.test.ts +110 -0
  19. package/src/bundler/plugins/index.ts +14 -0
  20. package/src/bundler/plugins/react-compiler-lint.ts +253 -0
  21. package/src/bundler/plugins/react-compiler.ts +162 -0
  22. package/src/bundler/types.ts +12 -0
  23. package/src/change/integrity.ts +2 -1
  24. package/src/client/index.ts +10 -0
  25. package/src/client/island.ts +38 -11
  26. package/src/client/router.ts +6 -1
  27. package/src/config/mandu.ts +57 -0
  28. package/src/config/validate.ts +42 -0
  29. package/src/content/collection.ts +844 -809
  30. package/src/content/content-layer.ts +316 -314
  31. package/src/content/content.test.ts +433 -433
  32. package/src/content/digest.ts +133 -133
  33. package/src/content/generate-types.ts +168 -168
  34. package/src/content/index.ts +6 -1
  35. package/src/content/llms-txt.ts +277 -277
  36. package/src/contract/define.ts +474 -474
  37. package/src/contract/route-helpers.ts +2 -1
  38. package/src/contract/zod-utils.ts +158 -155
  39. package/src/db/index.ts +513 -513
  40. package/src/desktop/__tests__/smoke.test.ts +100 -100
  41. package/src/desktop/webview-fallback.ts +583 -583
  42. package/src/desktop/window.ts +3 -1
  43. package/src/dev-error-overlay/overlay-client.ts +300 -300
  44. package/src/devtools/ai/mcp-connector.ts +499 -498
  45. package/src/devtools/client/components/kitchen-root.tsx +7 -2
  46. package/src/email/resend.ts +163 -163
  47. package/src/guard/__tests__/tsgolint-bridge.test.ts +347 -0
  48. package/src/guard/ast-analyzer.ts +806 -806
  49. package/src/guard/graph.ts +898 -898
  50. package/src/guard/index.ts +16 -0
  51. package/src/guard/statistics.ts +578 -578
  52. package/src/guard/tsgolint-bridge.ts +512 -0
  53. package/src/i18n/locale-resolver.ts +214 -214
  54. package/src/id/__tests__/id.test.ts +120 -120
  55. package/src/intent/index.ts +321 -321
  56. package/src/island/index.ts +39 -23
  57. package/src/kitchen/api/contract-api.ts +15 -8
  58. package/src/kitchen/kitchen-ui.ts +2137 -2137
  59. package/src/lockfile/index.ts +3 -2
  60. package/src/middleware/oauth/__tests__/oauth.test.ts +575 -574
  61. package/src/middleware/rate-limit/__tests__/rate-limit.test.ts +642 -642
  62. package/src/middleware/secure/index.ts +417 -417
  63. package/src/observability/event-bus.ts +2 -2
  64. package/src/observability/metrics.ts +334 -334
  65. package/src/observability/tracing.ts +694 -694
  66. package/src/openapi/generator.ts +1 -1
  67. package/src/perf/user-marks.ts +553 -553
  68. package/src/plugins/registry.ts +387 -387
  69. package/src/resource/ddl/diff.ts +392 -392
  70. package/src/resource/ddl/snapshot.ts +448 -447
  71. package/src/resource/generator-schema.ts +477 -476
  72. package/src/resource/parser.ts +4 -2
  73. package/src/resource/schema.ts +1 -1
  74. package/src/router/fs-patterns.ts +422 -422
  75. package/src/runtime/fast-refresh-types.ts +126 -128
  76. package/src/runtime/image-handler.ts +206 -195
  77. package/src/runtime/router.test.ts +476 -476
  78. package/src/runtime/security.ts +155 -155
  79. package/src/runtime/server.ts +36 -19
  80. package/src/runtime/session-key.ts +328 -328
  81. package/src/scheduler/__tests__/scheduler.test.ts +514 -514
  82. package/src/seo/resolve/index.ts +353 -353
  83. package/src/spec/load.ts +1 -1
  84. package/src/testing/reporter.ts +676 -676
  85. package/src/testing/server.ts +196 -196
  86. package/src/testing/snapshot.ts +444 -444
  87. package/src/utils/__tests__/lru-cache.test.ts +186 -186
  88. package/src/utils/bun.ts +8 -8
@@ -1,420 +1,421 @@
1
- /**
2
- * Brain — Anthropic OAuth adapter (Issue #235).
3
- *
4
- * Mirrors the OpenAI adapter but targets Anthropic's Messages API
5
- * (`POST /v1/messages`). Mandu does not own Anthropic API keys — the
6
- * user's OAuth credentials live in the OS keychain and are forwarded
7
- * on each request.
8
- *
9
- * Why the default model is `claude-haiku-4-5-20251001`: brain-doctor
10
- * triage prompts are short and latency-sensitive. Haiku is the cheapest
11
- * model in the family that still produces syntactically valid code
12
- * suggestions in our exploratory tests. Users who prefer Sonnet /
13
- * Opus override via `ManduConfig.brain.anthropic.model`.
14
- */
15
-
16
- import { promises as fs } from "node:fs";
17
- import path from "node:path";
18
-
19
- import { BaseLLMAdapter } from "./base";
20
- import type {
21
- AdapterConfig,
22
- AdapterStatus,
23
- ChatMessage,
24
- CompletionOptions,
25
- CompletionResult,
26
- } from "../types";
27
- import {
28
- CredentialStore,
29
- getCredentialStore,
30
- type StoredToken,
31
- } from "../credentials";
32
- import {
33
- ensureConsent,
34
- type ConsentPromptDeps,
35
- } from "../consent";
36
- import { redactSecrets } from "../redactor";
37
- import {
38
- refreshAccessToken,
39
- runAuthorizationCodeFlow,
40
- type HttpClient,
41
- type OAuthEndpoints,
42
- } from "./oauth-flow";
43
-
44
- /* -------------------------------------------------------------------- */
45
- /* Defaults */
46
- /* -------------------------------------------------------------------- */
47
-
48
- export const ANTHROPIC_OAUTH_ENDPOINTS: OAuthEndpoints = {
49
- authorizationUrl: "https://console.anthropic.com/oauth/authorize",
50
- tokenUrl: "https://console.anthropic.com/oauth/token",
51
- };
52
-
53
- /** Public client id registered by Mandu — PKCE secures the exchange. */
54
- export const ANTHROPIC_OAUTH_CLIENT_ID = "mandu-brain-cli";
55
- export const ANTHROPIC_OAUTH_SCOPE = "messages:write";
56
- export const ANTHROPIC_API_BASE = "https://api.anthropic.com/v1";
57
- /** Must be bumped whenever Anthropic ships a breaking Messages API version. */
58
- export const ANTHROPIC_API_VERSION = "2023-06-01";
59
-
60
- /**
61
- * Default model — Haiku 4.5. Fast, cheap, and good enough for the
62
- * triage prompts brain-doctor issues.
63
- */
64
- export const ANTHROPIC_DEFAULT_MODEL = "claude-haiku-4-5-20251001";
65
-
66
- export const DEFAULT_ANTHROPIC_CONFIG: AdapterConfig = {
67
- baseUrl: ANTHROPIC_API_BASE,
68
- model: ANTHROPIC_DEFAULT_MODEL,
69
- timeout: 60_000,
70
- };
71
-
72
- /* -------------------------------------------------------------------- */
73
- /* Options */
74
- /* -------------------------------------------------------------------- */
75
-
76
- export interface AnthropicOAuthAdapterOptions extends Partial<AdapterConfig> {
77
- httpClient?: HttpClient;
78
- endpoints?: OAuthEndpoints;
79
- clientId?: string;
80
- scope?: string;
81
- credentialStore?: CredentialStore;
82
- projectRoot?: string;
83
- skipConsent?: boolean;
84
- consentDeps?: ConsentPromptDeps;
85
- /** Anthropic API version header — override only for enterprise proxies. */
86
- apiVersion?: string;
87
- strict?: boolean;
88
- }
89
-
90
- /* -------------------------------------------------------------------- */
91
- /* Adapter */
92
- /* -------------------------------------------------------------------- */
93
-
94
- export class AnthropicOAuthAdapter extends BaseLLMAdapter {
95
- readonly name = "anthropic-oauth";
96
- private httpClient: HttpClient;
97
- private endpoints: OAuthEndpoints;
98
- private clientId: string;
99
- private scope: string;
100
- private credentialStore: CredentialStore;
101
- private projectRoot: string;
102
- private skipConsent: boolean;
103
- private consentDeps?: ConsentPromptDeps;
104
- private apiVersion: string;
105
- private strict: boolean;
106
- private refreshInFlight: Promise<StoredToken | null> | null = null;
107
-
108
- constructor(options: AnthropicOAuthAdapterOptions = {}) {
109
- super({
110
- ...DEFAULT_ANTHROPIC_CONFIG,
111
- ...options,
112
- });
113
- this.httpClient =
114
- options.httpClient ?? globalThis.fetch.bind(globalThis);
115
- this.endpoints = options.endpoints ?? ANTHROPIC_OAUTH_ENDPOINTS;
116
- this.clientId = options.clientId ?? ANTHROPIC_OAUTH_CLIENT_ID;
117
- this.scope = options.scope ?? ANTHROPIC_OAUTH_SCOPE;
118
- this.credentialStore = options.credentialStore ?? getCredentialStore();
119
- this.projectRoot = options.projectRoot ?? process.cwd();
120
- this.skipConsent = options.skipConsent ?? false;
121
- this.consentDeps = options.consentDeps;
122
- this.apiVersion = options.apiVersion ?? ANTHROPIC_API_VERSION;
123
- this.strict = options.strict ?? false;
124
- }
125
-
126
- async checkStatus(): Promise<AdapterStatus> {
127
- const token = await this.credentialStore.load("anthropic");
128
- if (!token) {
129
- return {
130
- available: false,
131
- model: null,
132
- error:
133
- "No Anthropic OAuth token stored. Run `mandu brain login --provider=anthropic` first.",
134
- };
135
- }
136
- return {
137
- available: true,
138
- model: this.config.model,
139
- };
140
- }
141
-
142
- async login(
143
- opts: {
144
- onAuthUrl?: (url: string) => void;
145
- openBrowser?: (url: string) => Promise<void> | void;
146
- timeoutMs?: number;
147
- } = {},
148
- ): Promise<StoredToken> {
149
- const tokenResponse = await runAuthorizationCodeFlow({
150
- endpoints: this.endpoints,
151
- client: { clientId: this.clientId, scope: this.scope },
152
- httpClient: this.httpClient,
153
- onAuthUrl: opts.onAuthUrl,
154
- openBrowser: opts.openBrowser,
155
- timeoutMs: opts.timeoutMs,
156
- });
157
-
158
- const stored: StoredToken = {
159
- access_token: tokenResponse.access_token,
160
- refresh_token: tokenResponse.refresh_token,
161
- expires_at:
162
- typeof tokenResponse.expires_in === "number"
163
- ? Math.floor(Date.now() / 1000) + tokenResponse.expires_in
164
- : undefined,
165
- scope: tokenResponse.scope ?? this.scope,
166
- default_model: this.config.model,
167
- provider: "anthropic",
168
- last_used_at: new Date().toISOString(),
169
- };
170
- await this.credentialStore.save("anthropic", stored);
171
- return stored;
172
- }
173
-
174
- async logout(): Promise<void> {
175
- await this.credentialStore.delete("anthropic");
176
- }
177
-
178
- async complete(
179
- messages: ChatMessage[],
180
- options: CompletionOptions = {},
181
- ): Promise<CompletionResult> {
182
- const token = await this.credentialStore.load("anthropic");
183
- if (!token) {
184
- if (this.strict) {
185
- throw new Error(
186
- "AnthropicOAuthAdapter.complete() called without a stored token",
187
- );
188
- }
189
- return emptyCompletion();
190
- }
191
-
192
- if (!this.skipConsent) {
193
- const ok = await ensureConsent(
194
- {
195
- projectRoot: this.projectRoot,
196
- provider: "anthropic",
197
- model: this.config.model,
198
- payloadDescription: describeChatPayload(messages),
199
- },
200
- this.consentDeps,
201
- );
202
- if (!ok) return emptyCompletion();
203
- }
204
-
205
- const redactedMessages: ChatMessage[] = [];
206
- const audit: string[] = [];
207
- for (const m of messages) {
208
- const { redacted, hits } = redactSecrets(m.content);
209
- redactedMessages.push({ role: m.role, content: redacted });
210
- for (const hit of hits) {
211
- audit.push(
212
- JSON.stringify({
213
- ts: new Date().toISOString(),
214
- provider: "anthropic",
215
- model: this.config.model,
216
- role: m.role,
217
- kind: hit.kind,
218
- sample: hit.sample,
219
- }),
220
- );
221
- }
222
- }
223
- if (audit.length > 0) {
224
- await appendRedactionLog(this.projectRoot, audit);
225
- }
226
-
227
- let attemptToken = token.access_token;
228
- let result = await this.callMessagesApi(
229
- attemptToken,
230
- redactedMessages,
231
- options,
232
- );
233
- if (result.status === 401 && token.refresh_token) {
234
- const refreshed = await this.trySilentRefresh(token);
235
- if (refreshed) {
236
- attemptToken = refreshed.access_token;
237
- result = await this.callMessagesApi(
238
- attemptToken,
239
- redactedMessages,
240
- options,
241
- );
242
- }
243
- }
244
- if (result.status === 401) {
245
- await this.credentialStore.delete("anthropic");
246
- return emptyCompletion();
247
- }
248
- if (!result.ok) {
249
- throw new Error(
250
- `Anthropic request failed (${result.status}): ${result.bodySnippet}`,
251
- );
252
- }
253
- await this.credentialStore.touch("anthropic");
254
- return result.completion;
255
- }
256
-
257
- private async trySilentRefresh(
258
- existing: StoredToken,
259
- ): Promise<StoredToken | null> {
260
- if (!existing.refresh_token) return null;
261
- if (this.refreshInFlight) return this.refreshInFlight;
262
- this.refreshInFlight = (async () => {
263
- try {
264
- const refreshed = await refreshAccessToken({
265
- endpoints: this.endpoints,
266
- clientId: this.clientId,
267
- refreshToken: existing.refresh_token!,
268
- httpClient: this.httpClient,
269
- scope: existing.scope ?? this.scope,
270
- });
271
- const stored: StoredToken = {
272
- access_token: refreshed.access_token,
273
- refresh_token: refreshed.refresh_token ?? existing.refresh_token,
274
- expires_at:
275
- typeof refreshed.expires_in === "number"
276
- ? Math.floor(Date.now() / 1000) + refreshed.expires_in
277
- : undefined,
278
- scope: refreshed.scope ?? existing.scope,
279
- default_model: existing.default_model,
280
- provider: "anthropic",
281
- last_used_at: new Date().toISOString(),
282
- };
283
- await this.credentialStore.save("anthropic", stored);
284
- return stored;
285
- } catch {
286
- return null;
287
- } finally {
288
- this.refreshInFlight = null;
289
- }
290
- })();
291
- return this.refreshInFlight;
292
- }
293
-
294
- /**
295
- * Transform our shared ChatMessage shape into Anthropic's Messages
296
- * API request body. Anthropic separates the system message from
297
- * the conversation turns; we concatenate any leading system
298
- * messages into one `system` string.
299
- */
300
- private async callMessagesApi(
301
- accessToken: string,
302
- messages: ChatMessage[],
303
- options: CompletionOptions,
304
- ): Promise<
305
- | { ok: true; status: number; completion: CompletionResult }
306
- | { ok: false; status: number; bodySnippet: string; completion: CompletionResult }
307
- > {
308
- const systemParts: string[] = [];
309
- const conversation: Array<{
310
- role: "user" | "assistant";
311
- content: string;
312
- }> = [];
313
- for (const m of messages) {
314
- if (m.role === "system") {
315
- systemParts.push(m.content);
316
- } else if (m.role === "user" || m.role === "assistant") {
317
- conversation.push({ role: m.role, content: m.content });
318
- }
319
- }
320
-
321
- const body: Record<string, unknown> = {
322
- model: this.config.model,
323
- messages: conversation,
324
- max_tokens: options.maxTokens ?? 2048,
325
- temperature: options.temperature ?? 0.2,
326
- };
327
- if (systemParts.length > 0) body.system = systemParts.join("\n\n");
328
- if (options.stop && options.stop.length > 0) {
329
- body.stop_sequences = options.stop;
330
- }
331
-
332
- const res = await this.httpClient(`${this.baseUrl}/messages`, {
333
- method: "POST",
334
- headers: {
335
- authorization: `Bearer ${accessToken}`,
336
- "content-type": "application/json",
337
- accept: "application/json",
338
- "anthropic-version": this.apiVersion,
339
- },
340
- body: JSON.stringify(body),
341
- });
342
-
343
- if (!res.ok) {
344
- const txt = await res.text().catch(() => "");
345
- return {
346
- ok: false,
347
- status: res.status,
348
- bodySnippet: txt.slice(0, 256),
349
- completion: emptyCompletion(),
350
- };
351
- }
352
-
353
- const json = (await res.json()) as {
354
- content?: Array<{ type?: string; text?: string }>;
355
- usage?: { input_tokens?: number; output_tokens?: number };
356
- };
357
- const text =
358
- json.content
359
- ?.filter((c) => c.type === "text")
360
- .map((c) => c.text ?? "")
361
- .join("") ?? "";
362
- const input = json.usage?.input_tokens ?? 0;
363
- const output = json.usage?.output_tokens ?? 0;
364
- return {
365
- ok: true,
366
- status: res.status,
367
- completion: {
368
- content: text,
369
- usage: {
370
- promptTokens: input,
371
- completionTokens: output,
372
- totalTokens: input + output,
373
- },
374
- },
375
- };
376
- }
377
- }
378
-
379
- /* -------------------------------------------------------------------- */
380
- /* Helpers */
381
- /* -------------------------------------------------------------------- */
382
-
383
- function emptyCompletion(): CompletionResult {
384
- return {
385
- content: "",
386
- usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
387
- };
388
- }
389
-
390
- function describeChatPayload(messages: ChatMessage[]): string {
391
- const totalChars = messages.reduce((a, m) => a + m.content.length, 0);
392
- const roleCounts = new Map<string, number>();
393
- for (const m of messages) {
394
- roleCounts.set(m.role, (roleCounts.get(m.role) ?? 0) + 1);
395
- }
396
- const roleSummary = [...roleCounts.entries()]
397
- .map(([r, n]) => `${n} ${r}`)
398
- .join(", ");
399
- return `${messages.length} messages (${roleSummary}), ~${totalChars} chars`;
400
- }
401
-
402
- async function appendRedactionLog(
403
- projectRoot: string,
404
- entries: string[],
405
- ): Promise<void> {
406
- try {
407
- const dir = path.join(projectRoot, ".mandu");
408
- await fs.mkdir(dir, { recursive: true });
409
- const file = path.join(dir, "brain-redactions.jsonl");
410
- await fs.appendFile(file, `${entries.join("\n")}\n`, { mode: 0o600 });
411
- } catch {
412
- /* best-effort */
413
- }
414
- }
415
-
416
- export function createAnthropicOAuthAdapter(
417
- options: AnthropicOAuthAdapterOptions = {},
418
- ): AnthropicOAuthAdapter {
419
- return new AnthropicOAuthAdapter(options);
420
- }
1
+ /**
2
+ * Brain — Anthropic OAuth adapter (Issue #235).
3
+ *
4
+ * Mirrors the OpenAI adapter but targets Anthropic's Messages API
5
+ * (`POST /v1/messages`). Mandu does not own Anthropic API keys — the
6
+ * user's OAuth credentials live in the OS keychain and are forwarded
7
+ * on each request.
8
+ *
9
+ * Why the default model is `claude-haiku-4-5-20251001`: brain-doctor
10
+ * triage prompts are short and latency-sensitive. Haiku is the cheapest
11
+ * model in the family that still produces syntactically valid code
12
+ * suggestions in our exploratory tests. Users who prefer Sonnet /
13
+ * Opus override via `ManduConfig.brain.anthropic.model`.
14
+ */
15
+
16
+ import { promises as fs } from "node:fs";
17
+ import path from "node:path";
18
+
19
+ import { BaseLLMAdapter } from "./base";
20
+ import type {
21
+ AdapterConfig,
22
+ AdapterStatus,
23
+ ChatMessage,
24
+ CompletionOptions,
25
+ CompletionResult,
26
+ } from "../types";
27
+ import type {
28
+ CredentialStore} from "../credentials";
29
+ import {
30
+ getCredentialStore,
31
+ type StoredToken,
32
+ } from "../credentials";
33
+ import {
34
+ ensureConsent,
35
+ type ConsentPromptDeps,
36
+ } from "../consent";
37
+ import { redactSecrets } from "../redactor";
38
+ import {
39
+ refreshAccessToken,
40
+ runAuthorizationCodeFlow,
41
+ type HttpClient,
42
+ type OAuthEndpoints,
43
+ } from "./oauth-flow";
44
+
45
+ /* -------------------------------------------------------------------- */
46
+ /* Defaults */
47
+ /* -------------------------------------------------------------------- */
48
+
49
+ export const ANTHROPIC_OAUTH_ENDPOINTS: OAuthEndpoints = {
50
+ authorizationUrl: "https://console.anthropic.com/oauth/authorize",
51
+ tokenUrl: "https://console.anthropic.com/oauth/token",
52
+ };
53
+
54
+ /** Public client id registered by Mandu — PKCE secures the exchange. */
55
+ export const ANTHROPIC_OAUTH_CLIENT_ID = "mandu-brain-cli";
56
+ export const ANTHROPIC_OAUTH_SCOPE = "messages:write";
57
+ export const ANTHROPIC_API_BASE = "https://api.anthropic.com/v1";
58
+ /** Must be bumped whenever Anthropic ships a breaking Messages API version. */
59
+ export const ANTHROPIC_API_VERSION = "2023-06-01";
60
+
61
+ /**
62
+ * Default model Haiku 4.5. Fast, cheap, and good enough for the
63
+ * triage prompts brain-doctor issues.
64
+ */
65
+ export const ANTHROPIC_DEFAULT_MODEL = "claude-haiku-4-5-20251001";
66
+
67
+ export const DEFAULT_ANTHROPIC_CONFIG: AdapterConfig = {
68
+ baseUrl: ANTHROPIC_API_BASE,
69
+ model: ANTHROPIC_DEFAULT_MODEL,
70
+ timeout: 60_000,
71
+ };
72
+
73
+ /* -------------------------------------------------------------------- */
74
+ /* Options */
75
+ /* -------------------------------------------------------------------- */
76
+
77
+ export interface AnthropicOAuthAdapterOptions extends Partial<AdapterConfig> {
78
+ httpClient?: HttpClient;
79
+ endpoints?: OAuthEndpoints;
80
+ clientId?: string;
81
+ scope?: string;
82
+ credentialStore?: CredentialStore;
83
+ projectRoot?: string;
84
+ skipConsent?: boolean;
85
+ consentDeps?: ConsentPromptDeps;
86
+ /** Anthropic API version header — override only for enterprise proxies. */
87
+ apiVersion?: string;
88
+ strict?: boolean;
89
+ }
90
+
91
+ /* -------------------------------------------------------------------- */
92
+ /* Adapter */
93
+ /* -------------------------------------------------------------------- */
94
+
95
+ export class AnthropicOAuthAdapter extends BaseLLMAdapter {
96
+ readonly name = "anthropic-oauth";
97
+ private httpClient: HttpClient;
98
+ private endpoints: OAuthEndpoints;
99
+ private clientId: string;
100
+ private scope: string;
101
+ private credentialStore: CredentialStore;
102
+ private projectRoot: string;
103
+ private skipConsent: boolean;
104
+ private consentDeps?: ConsentPromptDeps;
105
+ private apiVersion: string;
106
+ private strict: boolean;
107
+ private refreshInFlight: Promise<StoredToken | null> | null = null;
108
+
109
+ constructor(options: AnthropicOAuthAdapterOptions = {}) {
110
+ super({
111
+ ...DEFAULT_ANTHROPIC_CONFIG,
112
+ ...options,
113
+ });
114
+ this.httpClient =
115
+ options.httpClient ?? globalThis.fetch.bind(globalThis);
116
+ this.endpoints = options.endpoints ?? ANTHROPIC_OAUTH_ENDPOINTS;
117
+ this.clientId = options.clientId ?? ANTHROPIC_OAUTH_CLIENT_ID;
118
+ this.scope = options.scope ?? ANTHROPIC_OAUTH_SCOPE;
119
+ this.credentialStore = options.credentialStore ?? getCredentialStore();
120
+ this.projectRoot = options.projectRoot ?? process.cwd();
121
+ this.skipConsent = options.skipConsent ?? false;
122
+ this.consentDeps = options.consentDeps;
123
+ this.apiVersion = options.apiVersion ?? ANTHROPIC_API_VERSION;
124
+ this.strict = options.strict ?? false;
125
+ }
126
+
127
+ async checkStatus(): Promise<AdapterStatus> {
128
+ const token = await this.credentialStore.load("anthropic");
129
+ if (!token) {
130
+ return {
131
+ available: false,
132
+ model: null,
133
+ error:
134
+ "No Anthropic OAuth token stored. Run `mandu brain login --provider=anthropic` first.",
135
+ };
136
+ }
137
+ return {
138
+ available: true,
139
+ model: this.config.model,
140
+ };
141
+ }
142
+
143
+ async login(
144
+ opts: {
145
+ onAuthUrl?: (url: string) => void;
146
+ openBrowser?: (url: string) => Promise<void> | void;
147
+ timeoutMs?: number;
148
+ } = {},
149
+ ): Promise<StoredToken> {
150
+ const tokenResponse = await runAuthorizationCodeFlow({
151
+ endpoints: this.endpoints,
152
+ client: { clientId: this.clientId, scope: this.scope },
153
+ httpClient: this.httpClient,
154
+ onAuthUrl: opts.onAuthUrl,
155
+ openBrowser: opts.openBrowser,
156
+ timeoutMs: opts.timeoutMs,
157
+ });
158
+
159
+ const stored: StoredToken = {
160
+ access_token: tokenResponse.access_token,
161
+ refresh_token: tokenResponse.refresh_token,
162
+ expires_at:
163
+ typeof tokenResponse.expires_in === "number"
164
+ ? Math.floor(Date.now() / 1000) + tokenResponse.expires_in
165
+ : undefined,
166
+ scope: tokenResponse.scope ?? this.scope,
167
+ default_model: this.config.model,
168
+ provider: "anthropic",
169
+ last_used_at: new Date().toISOString(),
170
+ };
171
+ await this.credentialStore.save("anthropic", stored);
172
+ return stored;
173
+ }
174
+
175
+ async logout(): Promise<void> {
176
+ await this.credentialStore.delete("anthropic");
177
+ }
178
+
179
+ async complete(
180
+ messages: ChatMessage[],
181
+ options: CompletionOptions = {},
182
+ ): Promise<CompletionResult> {
183
+ const token = await this.credentialStore.load("anthropic");
184
+ if (!token) {
185
+ if (this.strict) {
186
+ throw new Error(
187
+ "AnthropicOAuthAdapter.complete() called without a stored token",
188
+ );
189
+ }
190
+ return emptyCompletion();
191
+ }
192
+
193
+ if (!this.skipConsent) {
194
+ const ok = await ensureConsent(
195
+ {
196
+ projectRoot: this.projectRoot,
197
+ provider: "anthropic",
198
+ model: this.config.model,
199
+ payloadDescription: describeChatPayload(messages),
200
+ },
201
+ this.consentDeps,
202
+ );
203
+ if (!ok) return emptyCompletion();
204
+ }
205
+
206
+ const redactedMessages: ChatMessage[] = [];
207
+ const audit: string[] = [];
208
+ for (const m of messages) {
209
+ const { redacted, hits } = redactSecrets(m.content);
210
+ redactedMessages.push({ role: m.role, content: redacted });
211
+ for (const hit of hits) {
212
+ audit.push(
213
+ JSON.stringify({
214
+ ts: new Date().toISOString(),
215
+ provider: "anthropic",
216
+ model: this.config.model,
217
+ role: m.role,
218
+ kind: hit.kind,
219
+ sample: hit.sample,
220
+ }),
221
+ );
222
+ }
223
+ }
224
+ if (audit.length > 0) {
225
+ await appendRedactionLog(this.projectRoot, audit);
226
+ }
227
+
228
+ let attemptToken = token.access_token;
229
+ let result = await this.callMessagesApi(
230
+ attemptToken,
231
+ redactedMessages,
232
+ options,
233
+ );
234
+ if (result.status === 401 && token.refresh_token) {
235
+ const refreshed = await this.trySilentRefresh(token);
236
+ if (refreshed) {
237
+ attemptToken = refreshed.access_token;
238
+ result = await this.callMessagesApi(
239
+ attemptToken,
240
+ redactedMessages,
241
+ options,
242
+ );
243
+ }
244
+ }
245
+ if (result.status === 401) {
246
+ await this.credentialStore.delete("anthropic");
247
+ return emptyCompletion();
248
+ }
249
+ if (!result.ok) {
250
+ throw new Error(
251
+ `Anthropic request failed (${result.status}): ${result.bodySnippet}`,
252
+ );
253
+ }
254
+ await this.credentialStore.touch("anthropic");
255
+ return result.completion;
256
+ }
257
+
258
+ private async trySilentRefresh(
259
+ existing: StoredToken,
260
+ ): Promise<StoredToken | null> {
261
+ if (!existing.refresh_token) return null;
262
+ if (this.refreshInFlight) return this.refreshInFlight;
263
+ this.refreshInFlight = (async () => {
264
+ try {
265
+ const refreshed = await refreshAccessToken({
266
+ endpoints: this.endpoints,
267
+ clientId: this.clientId,
268
+ refreshToken: existing.refresh_token!,
269
+ httpClient: this.httpClient,
270
+ scope: existing.scope ?? this.scope,
271
+ });
272
+ const stored: StoredToken = {
273
+ access_token: refreshed.access_token,
274
+ refresh_token: refreshed.refresh_token ?? existing.refresh_token,
275
+ expires_at:
276
+ typeof refreshed.expires_in === "number"
277
+ ? Math.floor(Date.now() / 1000) + refreshed.expires_in
278
+ : undefined,
279
+ scope: refreshed.scope ?? existing.scope,
280
+ default_model: existing.default_model,
281
+ provider: "anthropic",
282
+ last_used_at: new Date().toISOString(),
283
+ };
284
+ await this.credentialStore.save("anthropic", stored);
285
+ return stored;
286
+ } catch {
287
+ return null;
288
+ } finally {
289
+ this.refreshInFlight = null;
290
+ }
291
+ })();
292
+ return this.refreshInFlight;
293
+ }
294
+
295
+ /**
296
+ * Transform our shared ChatMessage shape into Anthropic's Messages
297
+ * API request body. Anthropic separates the system message from
298
+ * the conversation turns; we concatenate any leading system
299
+ * messages into one `system` string.
300
+ */
301
+ private async callMessagesApi(
302
+ accessToken: string,
303
+ messages: ChatMessage[],
304
+ options: CompletionOptions,
305
+ ): Promise<
306
+ | { ok: true; status: number; completion: CompletionResult }
307
+ | { ok: false; status: number; bodySnippet: string; completion: CompletionResult }
308
+ > {
309
+ const systemParts: string[] = [];
310
+ const conversation: Array<{
311
+ role: "user" | "assistant";
312
+ content: string;
313
+ }> = [];
314
+ for (const m of messages) {
315
+ if (m.role === "system") {
316
+ systemParts.push(m.content);
317
+ } else if (m.role === "user" || m.role === "assistant") {
318
+ conversation.push({ role: m.role, content: m.content });
319
+ }
320
+ }
321
+
322
+ const body: Record<string, unknown> = {
323
+ model: this.config.model,
324
+ messages: conversation,
325
+ max_tokens: options.maxTokens ?? 2048,
326
+ temperature: options.temperature ?? 0.2,
327
+ };
328
+ if (systemParts.length > 0) body.system = systemParts.join("\n\n");
329
+ if (options.stop && options.stop.length > 0) {
330
+ body.stop_sequences = options.stop;
331
+ }
332
+
333
+ const res = await this.httpClient(`${this.baseUrl}/messages`, {
334
+ method: "POST",
335
+ headers: {
336
+ authorization: `Bearer ${accessToken}`,
337
+ "content-type": "application/json",
338
+ accept: "application/json",
339
+ "anthropic-version": this.apiVersion,
340
+ },
341
+ body: JSON.stringify(body),
342
+ });
343
+
344
+ if (!res.ok) {
345
+ const txt = await res.text().catch(() => "");
346
+ return {
347
+ ok: false,
348
+ status: res.status,
349
+ bodySnippet: txt.slice(0, 256),
350
+ completion: emptyCompletion(),
351
+ };
352
+ }
353
+
354
+ const json = (await res.json()) as {
355
+ content?: Array<{ type?: string; text?: string }>;
356
+ usage?: { input_tokens?: number; output_tokens?: number };
357
+ };
358
+ const text =
359
+ json.content
360
+ ?.filter((c) => c.type === "text")
361
+ .map((c) => c.text ?? "")
362
+ .join("") ?? "";
363
+ const input = json.usage?.input_tokens ?? 0;
364
+ const output = json.usage?.output_tokens ?? 0;
365
+ return {
366
+ ok: true,
367
+ status: res.status,
368
+ completion: {
369
+ content: text,
370
+ usage: {
371
+ promptTokens: input,
372
+ completionTokens: output,
373
+ totalTokens: input + output,
374
+ },
375
+ },
376
+ };
377
+ }
378
+ }
379
+
380
+ /* -------------------------------------------------------------------- */
381
+ /* Helpers */
382
+ /* -------------------------------------------------------------------- */
383
+
384
+ function emptyCompletion(): CompletionResult {
385
+ return {
386
+ content: "",
387
+ usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
388
+ };
389
+ }
390
+
391
+ function describeChatPayload(messages: ChatMessage[]): string {
392
+ const totalChars = messages.reduce((a, m) => a + m.content.length, 0);
393
+ const roleCounts = new Map<string, number>();
394
+ for (const m of messages) {
395
+ roleCounts.set(m.role, (roleCounts.get(m.role) ?? 0) + 1);
396
+ }
397
+ const roleSummary = [...roleCounts.entries()]
398
+ .map(([r, n]) => `${n} ${r}`)
399
+ .join(", ");
400
+ return `${messages.length} messages (${roleSummary}), ~${totalChars} chars`;
401
+ }
402
+
403
+ async function appendRedactionLog(
404
+ projectRoot: string,
405
+ entries: string[],
406
+ ): Promise<void> {
407
+ try {
408
+ const dir = path.join(projectRoot, ".mandu");
409
+ await fs.mkdir(dir, { recursive: true });
410
+ const file = path.join(dir, "brain-redactions.jsonl");
411
+ await fs.appendFile(file, `${entries.join("\n")}\n`, { mode: 0o600 });
412
+ } catch {
413
+ /* best-effort */
414
+ }
415
+ }
416
+
417
+ export function createAnthropicOAuthAdapter(
418
+ options: AnthropicOAuthAdapterOptions = {},
419
+ ): AnthropicOAuthAdapter {
420
+ return new AnthropicOAuthAdapter(options);
421
+ }