@moxxy/plugin-provider-claude-code 0.21.1

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/src/login.ts ADDED
@@ -0,0 +1,426 @@
1
+ /**
2
+ * Claude subscription login + token lifecycle.
3
+ *
4
+ * Claude's OAuth is an out-of-band (manual code-paste) authorization-code +
5
+ * PKCE flow whose token endpoint speaks JSON (not the form-encoded dialect
6
+ * the framework's `exchangeCodeForToken` / `refreshAccessToken` assume), so
7
+ * the HTTP exchange + refresh are implemented here. Everything else is reused
8
+ * from `@moxxy/plugin-oauth`: PKCE, the auth-URL builder, the browser opener,
9
+ * vault storage layout, and `parseTokenResponse`.
10
+ */
11
+
12
+ import {
13
+ buildAuthUrl,
14
+ clearStoredCreds,
15
+ computeCodeChallenge,
16
+ generateCodeVerifier,
17
+ generateState,
18
+ isAuthRejection,
19
+ isExpired,
20
+ openInBrowser,
21
+ parseTokenResponse,
22
+ readStoredCreds,
23
+ storeTokenSet,
24
+ withCredentialLock,
25
+ type OAuthVault,
26
+ type StoredCreds,
27
+ type TokenSet,
28
+ } from '@moxxy/plugin-oauth';
29
+ import { MoxxyError } from '@moxxy/sdk';
30
+ import type {
31
+ ProviderAuthContext,
32
+ ProviderOAuthResult,
33
+ ProviderOAuthStatus,
34
+ } from '@moxxy/sdk';
35
+ import {
36
+ CLAUDE_AUTHORIZE_URL,
37
+ CLAUDE_CLIENT_ID,
38
+ CLAUDE_CODE_PROVIDER_ID,
39
+ CLAUDE_CODE_SERVICE_NAME,
40
+ CLAUDE_REDIRECT_URI,
41
+ CLAUDE_SCOPES,
42
+ CLAUDE_TOKEN_URL,
43
+ } from './constants.js';
44
+
45
+ const PASTE_PROMPT =
46
+ 'Paste a token from `claude setup-token` (or press Enter to sign in via browser): ';
47
+ const CODE_PROMPT = 'Paste the authorization code shown after you approve: ';
48
+
49
+ /**
50
+ * Drive an interactive Claude sign-in. Two ways in, both through one command:
51
+ * 1. paste an existing `claude setup-token` token (stored verbatim), or
52
+ * 2. press Enter to run the browser out-of-band authorization-code flow.
53
+ * Needs `ctx.prompt` (a TTY); headless callers use `CLAUDE_CODE_OAUTH_TOKEN`.
54
+ */
55
+ export async function claudeLogin(ctx: ProviderAuthContext): Promise<ProviderOAuthResult> {
56
+ if (!ctx.prompt) {
57
+ throw new MoxxyError({
58
+ code: 'OAUTH_FLOW_NOT_SUPPORTED',
59
+ message: 'Claude sign-in needs an interactive terminal.',
60
+ hint:
61
+ 'Run `claude setup-token` and set CLAUDE_CODE_OAUTH_TOKEN, or run ' +
62
+ '`moxxy login claude-code` in a terminal.',
63
+ context: { provider: CLAUDE_CODE_PROVIDER_ID },
64
+ });
65
+ }
66
+
67
+ const pasted = (await ctx.prompt(PASTE_PROMPT, { mask: true })).trim();
68
+ if (pasted) {
69
+ // A `setup-token` is a long-lived bearer with no refresh token; store it
70
+ // as-is. `isExpired` treats a missing expiry as non-expiring.
71
+ await storeTokenSet(
72
+ ctx.vault,
73
+ CLAUDE_CODE_PROVIDER_ID,
74
+ { accessToken: pasted, tokenType: 'Bearer' },
75
+ { clientId: CLAUDE_CLIENT_ID, tokenUrl: CLAUDE_TOKEN_URL },
76
+ );
77
+ ctx.write('\nStored your Claude token.\n');
78
+ return {};
79
+ }
80
+
81
+ const verifier = generateCodeVerifier();
82
+ const challenge = computeCodeChallenge(verifier);
83
+ const state = generateState();
84
+ const authUrl = buildAuthUrl({
85
+ authUrl: CLAUDE_AUTHORIZE_URL,
86
+ clientId: CLAUDE_CLIENT_ID,
87
+ redirectUri: CLAUDE_REDIRECT_URI,
88
+ scopes: [...CLAUDE_SCOPES],
89
+ codeChallenge: challenge,
90
+ state,
91
+ extraAuthParams: { code: 'true' },
92
+ });
93
+
94
+ ctx.write(
95
+ `\nSign in to ${CLAUDE_CODE_SERVICE_NAME} to authorize moxxy.\n\n` +
96
+ `If your browser doesn't open automatically, paste this URL:\n\n ${authUrl}\n\n` +
97
+ `After approving, copy the authorization code shown and paste it back here.\n` +
98
+ `(Anthropic's sign-in page sometimes shows "Internal server error" on the\n` +
99
+ ` first attempt — just click "Try again" and it goes through.)\n\n`,
100
+ );
101
+ try {
102
+ await openBrowserImpl(authUrl);
103
+ } catch {
104
+ // Non-fatal — the user can open the URL surfaced above by hand.
105
+ }
106
+
107
+ // The authorization code is a single-use, exchangeable credential — mask it
108
+ // so it doesn't echo into scrollback / screen-share, matching the token paste.
109
+ const entered = (await ctx.prompt(CODE_PROMPT, { mask: true })).trim();
110
+ if (!entered) {
111
+ throw new MoxxyError({
112
+ code: 'AUTH_DENIED',
113
+ message: 'No authorization code entered — sign-in cancelled.',
114
+ context: { provider: CLAUDE_CODE_PROVIDER_ID },
115
+ });
116
+ }
117
+ // Anthropic returns `code#state`, but often shows just the bare code, so the
118
+ // state check below is best-effort: it only fires when a state was pasted.
119
+ // The real CSRF/code-injection defense is PKCE — the `code_verifier` binds
120
+ // the code to this client session, so a foreign code fails the exchange.
121
+ const hash = entered.indexOf('#');
122
+ const code = hash >= 0 ? entered.slice(0, hash) : entered;
123
+ const returnedState = hash >= 0 ? entered.slice(hash + 1) : '';
124
+ if (returnedState && returnedState !== state) {
125
+ throw new MoxxyError({
126
+ code: 'AUTH_INVALID',
127
+ message: 'Authorization state mismatch — please run sign-in again.',
128
+ context: { provider: CLAUDE_CODE_PROVIDER_ID },
129
+ });
130
+ }
131
+
132
+ const { tokenSet, accountEmail } = await exchangeClaudeCode(code, returnedState || state, verifier);
133
+ await persistClaudeTokens(ctx.vault, tokenSet, accountEmail);
134
+ ctx.write(`\nSigned in to Claude${accountEmail ? ` as ${accountEmail}` : ''}.\n`);
135
+ // Omit `expiresAt` when the credential never expires (setup-token paste, or a
136
+ // token response without `expires_in`). Surfacing `0` would read as "epoch =
137
+ // already expired" to the CLI status renderer, which only checks `!== undefined`.
138
+ return {
139
+ ...(accountEmail ? { accountId: accountEmail } : {}),
140
+ ...(tokenSet.expiresAt !== undefined ? { expiresAt: tokenSet.expiresAt } : {}),
141
+ };
142
+ }
143
+
144
+ export async function claudeLogout(ctx: ProviderAuthContext): Promise<boolean> {
145
+ try {
146
+ return (await clearStoredCreds(ctx.vault, CLAUDE_CODE_PROVIDER_ID)) > 0;
147
+ } catch {
148
+ return false;
149
+ }
150
+ }
151
+
152
+ export async function claudeStatus(ctx: ProviderAuthContext): Promise<ProviderOAuthStatus | null> {
153
+ const stored = await readStoredCreds(ctx.vault, CLAUDE_CODE_PROVIDER_ID);
154
+ if (!stored) return null;
155
+ return {
156
+ accountId: stored.extras.account_email ?? null,
157
+ // Omit when absent — a stored setup-token has no expiry, and `0` would be
158
+ // mis-rendered as "expired" (the CLI only treats `!== undefined` as set).
159
+ ...(stored.tokenSet.expiresAt !== undefined ? { expiresAt: stored.tokenSet.expiresAt } : {}),
160
+ vaultKey: `oauth/${CLAUDE_CODE_PROVIDER_ID}/*`,
161
+ };
162
+ }
163
+
164
+ export interface FreshClaudeTokens {
165
+ readonly accessToken: string;
166
+ readonly expiresAt?: number;
167
+ /** True when a refresh_token is stored, so a 401 can be recovered. */
168
+ readonly canRefresh: boolean;
169
+ }
170
+
171
+ /**
172
+ * Read the stored Claude creds, refreshing first if the access token is near
173
+ * expiry and a refresh_token is available. Returns null when nothing is
174
+ * stored. Persists rotated tokens BEFORE returning so a crash can't strand a
175
+ * single-use refresh_token. The refresh itself is serialized per credential
176
+ * (in-process + cross-process) and coalesces with concurrent refreshers.
177
+ */
178
+ export async function ensureFreshClaudeTokens(vault: OAuthVault): Promise<FreshClaudeTokens | null> {
179
+ const stored = await readStoredCreds(vault, CLAUDE_CODE_PROVIDER_ID);
180
+ if (!stored) return null;
181
+ const { tokenSet } = stored;
182
+ if (tokenSet.refreshToken && isExpired(tokenSet)) {
183
+ const refreshed = await refreshClaudeUnderLock(vault, stored);
184
+ return { accessToken: refreshed.accessToken, ...(refreshed.expiresAt !== undefined ? { expiresAt: refreshed.expiresAt } : {}), canRefresh: true };
185
+ }
186
+ return {
187
+ accessToken: tokenSet.accessToken,
188
+ ...(tokenSet.expiresAt !== undefined ? { expiresAt: tokenSet.expiresAt } : {}),
189
+ canRefresh: tokenSet.refreshToken !== undefined,
190
+ };
191
+ }
192
+
193
+ /**
194
+ * Force a refresh of the stored Claude tokens and persist the rotated bundle.
195
+ * Used as the provider's reactive 401 recovery. Throws if no refresh_token is
196
+ * stored (e.g. a pasted `setup-token`).
197
+ */
198
+ export async function refreshClaudeAccessToken(
199
+ vault: OAuthVault,
200
+ ): Promise<{ token: string; expiresAt?: number }> {
201
+ const stored = await readStoredCreds(vault, CLAUDE_CODE_PROVIDER_ID);
202
+ if (!stored?.tokenSet.refreshToken) {
203
+ throw new MoxxyError({
204
+ code: 'AUTH_EXPIRED',
205
+ message: 'Claude token expired and no refresh_token is stored.',
206
+ hint: 'Run `moxxy login claude-code` again, or refresh `CLAUDE_CODE_OAUTH_TOKEN` via `claude setup-token`.',
207
+ context: { provider: CLAUDE_CODE_PROVIDER_ID },
208
+ });
209
+ }
210
+ const refreshed = await refreshClaudeUnderLock(vault, stored);
211
+ return { token: refreshed.accessToken, ...(refreshed.expiresAt !== undefined ? { expiresAt: refreshed.expiresAt } : {}) };
212
+ }
213
+
214
+ /**
215
+ * Refresh + persist under the per-credential lock. Anthropic ROTATES the
216
+ * refresh_token on every refresh and invalidates the previous one, so two
217
+ * concurrent refreshes (a second consumer in this process, or another moxxy
218
+ * process — TUI alongside a desktop runner) must not both burn the same
219
+ * stored token. Under the lock we:
220
+ * 1. re-read the vault — if someone else already rotated and the new access
221
+ * token is still fresh, reuse it (coalesce; no IdP call at all);
222
+ * 2. otherwise refresh with the freshest stored refresh_token;
223
+ * 3. on an invalid_grant-style rejection, re-read once more — if the
224
+ * on-disk refresh_token changed under us (another process won a race we
225
+ * couldn't see), retry ONCE with the fresher token before declaring
226
+ * re-auth necessary.
227
+ */
228
+ async function refreshClaudeUnderLock(vault: OAuthVault, baseline: StoredCreds): Promise<TokenSet> {
229
+ return withCredentialLock(`oauth-${CLAUDE_CODE_PROVIDER_ID}`, async () => {
230
+ const current = (await readStoredCreds(vault, CLAUDE_CODE_PROVIDER_ID)) ?? baseline;
231
+ if (current.tokenSet.accessToken !== baseline.tokenSet.accessToken && !isExpired(current.tokenSet)) {
232
+ return current.tokenSet;
233
+ }
234
+ const refreshToken = current.tokenSet.refreshToken ?? baseline.tokenSet.refreshToken;
235
+ if (!refreshToken) {
236
+ throw new MoxxyError({
237
+ code: 'AUTH_EXPIRED',
238
+ message: 'Claude token expired and no refresh_token is stored.',
239
+ hint: 'Run `moxxy login claude-code` again, or refresh `CLAUDE_CODE_OAUTH_TOKEN` via `claude setup-token`.',
240
+ context: { provider: CLAUDE_CODE_PROVIDER_ID },
241
+ });
242
+ }
243
+ const email = current.extras.account_email ?? baseline.extras.account_email;
244
+ try {
245
+ return await refreshAndPersist(vault, refreshToken, email);
246
+ } catch (err) {
247
+ if (isAuthRejection(err)) {
248
+ const latest = await readStoredCreds(vault, CLAUDE_CODE_PROVIDER_ID);
249
+ const latestRefresh = latest?.tokenSet.refreshToken;
250
+ if (latestRefresh && latestRefresh !== refreshToken) {
251
+ return refreshAndPersist(vault, latestRefresh, latest.extras.account_email ?? email);
252
+ }
253
+ }
254
+ throw err;
255
+ }
256
+ });
257
+ }
258
+
259
+ // --- internal HTTP (JSON dialect) -----------------------------------------
260
+
261
+ interface ClaudeExchangeResult {
262
+ readonly tokenSet: TokenSet;
263
+ readonly accountEmail?: string;
264
+ }
265
+
266
+ /**
267
+ * Anthropic's OAuth endpoints (both `claude.ai/oauth/authorize` and the token
268
+ * endpoint) intermittently return a transient HTTP 500 — the *same* request
269
+ * then succeeds on retry. So retry 5xx/429/network failures with a short
270
+ * backoff before giving up. 4xx (bad/expired/already-used code, invalid_grant)
271
+ * is deterministic and surfaced immediately, never retried.
272
+ */
273
+ const TOKEN_POST_MAX_ATTEMPTS = 3;
274
+ const TOKEN_POST_BACKOFF_MS = [600, 1800] as const;
275
+ /**
276
+ * Per-attempt deadline on the token POST. Node's `fetch` has NO default
277
+ * timeout, so a half-open TCP socket (server accepts but never responds — a
278
+ * stalled edge node, a black-holing proxy, a captive portal that keeps the
279
+ * connection alive) would hang the whole login/refresh indefinitely. Bound
280
+ * each attempt; a timeout aborts the request and is classed as a transient
281
+ * network error, so the retry loop tries again and ultimately surfaces the
282
+ * "endpoint kept failing" hint instead of wedging forever.
283
+ */
284
+ const TOKEN_POST_TIMEOUT_MS = 30_000;
285
+
286
+ /** Test seam so the exchange/refresh can run against a fake fetch. */
287
+ let fetchImpl: typeof fetch = fetch;
288
+ export function __setClaudeFetch(f: typeof fetch): void {
289
+ fetchImpl = f;
290
+ }
291
+
292
+ /** Test seam so the OOB login can run without launching a real browser. */
293
+ let openBrowserImpl: (url: string) => Promise<void> = openInBrowser;
294
+ export function __setClaudeOpenBrowser(f: (url: string) => Promise<void>): void {
295
+ openBrowserImpl = f;
296
+ }
297
+
298
+ /** Test seam so the retry backoff doesn't actually sleep under test. */
299
+ let sleepImpl = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms));
300
+ export function __setClaudeSleep(f: (ms: number) => Promise<void>): void {
301
+ sleepImpl = f;
302
+ }
303
+
304
+ /** Test seam to shrink the per-attempt network deadline (default {@link TOKEN_POST_TIMEOUT_MS}). */
305
+ let timeoutMs = TOKEN_POST_TIMEOUT_MS;
306
+ export function __setClaudeTimeoutMs(ms: number): void {
307
+ timeoutMs = ms;
308
+ }
309
+
310
+ async function exchangeClaudeCode(
311
+ code: string,
312
+ state: string,
313
+ verifier: string,
314
+ ): Promise<ClaudeExchangeResult> {
315
+ return postClaudeToken({
316
+ grant_type: 'authorization_code',
317
+ code,
318
+ state,
319
+ client_id: CLAUDE_CLIENT_ID,
320
+ redirect_uri: CLAUDE_REDIRECT_URI,
321
+ code_verifier: verifier,
322
+ });
323
+ }
324
+
325
+ async function refreshAndPersist(
326
+ vault: OAuthVault,
327
+ refreshToken: string,
328
+ priorEmail: string | undefined,
329
+ ): Promise<TokenSet> {
330
+ const { tokenSet, accountEmail } = await postClaudeToken({
331
+ grant_type: 'refresh_token',
332
+ refresh_token: refreshToken,
333
+ client_id: CLAUDE_CLIENT_ID,
334
+ });
335
+ // Claude rotates the refresh_token on every refresh; if a response ever
336
+ // omits one, keep the prior so we don't lock ourselves out.
337
+ const merged: TokenSet = tokenSet.refreshToken
338
+ ? tokenSet
339
+ : { ...tokenSet, refreshToken };
340
+ await persistClaudeTokens(vault, merged, accountEmail ?? priorEmail);
341
+ return merged;
342
+ }
343
+
344
+ async function persistClaudeTokens(
345
+ vault: OAuthVault,
346
+ tokenSet: TokenSet,
347
+ accountEmail: string | undefined,
348
+ ): Promise<void> {
349
+ await storeTokenSet(vault, CLAUDE_CODE_PROVIDER_ID, tokenSet, {
350
+ clientId: CLAUDE_CLIENT_ID,
351
+ tokenUrl: CLAUDE_TOKEN_URL,
352
+ ...(accountEmail ? { extras: { account_email: accountEmail } } : {}),
353
+ });
354
+ }
355
+
356
+ async function postClaudeToken(body: Record<string, string>): Promise<ClaudeExchangeResult> {
357
+ let transient = '';
358
+ for (let attempt = 1; attempt <= TOKEN_POST_MAX_ATTEMPTS; attempt++) {
359
+ if (attempt > 1) await sleepImpl(TOKEN_POST_BACKOFF_MS[attempt - 2] ?? 1800);
360
+
361
+ let res: Response;
362
+ try {
363
+ res = await fetchImpl(CLAUDE_TOKEN_URL, {
364
+ method: 'POST',
365
+ headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
366
+ body: JSON.stringify(body),
367
+ // Bound a hung connection so it surfaces as a retryable network error
368
+ // (TimeoutError) rather than blocking the login/refresh forever.
369
+ signal: AbortSignal.timeout(timeoutMs),
370
+ });
371
+ } catch (err) {
372
+ transient = `network error (${err instanceof Error ? err.message : String(err)})`;
373
+ continue;
374
+ }
375
+
376
+ if (res.ok) {
377
+ // A captive portal / proxy / misbehaving edge can return 200 with an HTML
378
+ // or empty body, or a JSON primitive/array. Treat any of these as a
379
+ // transient and retry rather than letting a raw SyntaxError escape the
380
+ // retry/classify path.
381
+ let parsed: unknown;
382
+ try {
383
+ parsed = await res.json();
384
+ } catch {
385
+ transient = 'HTTP 200 with non-JSON body';
386
+ continue;
387
+ }
388
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
389
+ transient = 'HTTP 200 with malformed token response';
390
+ continue;
391
+ }
392
+ const json = parsed as Record<string, unknown>;
393
+ return { tokenSet: parseTokenResponse(json), ...extractAccountEmail(json) };
394
+ }
395
+
396
+ const text = await res.text().catch(() => '');
397
+ // Deterministic client errors: surface immediately, don't burn retries.
398
+ if (res.status < 500 && res.status !== 429) {
399
+ throw new MoxxyError({
400
+ code: res.status === 401 || res.status === 403 ? 'AUTH_DENIED' : 'AUTH_INVALID',
401
+ message: `Claude token endpoint returned HTTP ${res.status}: ${text.slice(0, 300)}`,
402
+ context: { provider: CLAUDE_CODE_PROVIDER_ID, status: res.status },
403
+ });
404
+ }
405
+ // 5xx / 429 — Anthropic-side flake; loop and try the same request again.
406
+ transient = `HTTP ${res.status}: ${text.slice(0, 200)}`;
407
+ }
408
+
409
+ throw new MoxxyError({
410
+ code: 'AUTH_INVALID',
411
+ message: `Claude token endpoint kept failing after ${TOKEN_POST_MAX_ATTEMPTS} attempts (last: ${transient}).`,
412
+ hint:
413
+ "Anthropic's OAuth endpoint is returning transient errors right now — " +
414
+ 'wait a few seconds and run `moxxy login claude-code` again.',
415
+ context: { provider: CLAUDE_CODE_PROVIDER_ID },
416
+ });
417
+ }
418
+
419
+ function extractAccountEmail(json: Record<string, unknown>): { accountEmail?: string } {
420
+ const account = json.account;
421
+ if (account && typeof account === 'object') {
422
+ const email = (account as { email_address?: unknown }).email_address;
423
+ if (typeof email === 'string' && email) return { accountEmail: email };
424
+ }
425
+ return {};
426
+ }
@@ -0,0 +1,36 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { CLAUDE_CODE_SYSTEM, CLAUDE_OAUTH_BETA } from './constants.js';
3
+ import { claudeCodeProviderDef, createClaudeCodeClient } from './index.js';
4
+
5
+ describe('claude-code provider definition', () => {
6
+ it('registers as an OAuth provider named claude-code with Claude models', () => {
7
+ expect(claudeCodeProviderDef.name).toBe('claude-code');
8
+ expect(claudeCodeProviderDef.auth?.kind).toBe('oauth');
9
+ expect(claudeCodeProviderDef.models.length).toBeGreaterThan(0);
10
+ expect(claudeCodeProviderDef.models.map((m) => m.id)).toContain('claude-sonnet-4-6');
11
+ });
12
+
13
+ it('builds an OAuth-mode client that reports the claude-code name', () => {
14
+ const client = createClaudeCodeClient({ oauthToken: 'tok' });
15
+ expect(client.name).toBe('claude-code');
16
+ const inner = (client as unknown as { client: { apiKey: unknown; authToken: unknown } }).client;
17
+ expect(inner.apiKey).toBeNull();
18
+ expect(inner.authToken).toBe('tok');
19
+ });
20
+
21
+ it('forwards the OAuth beta headers and identity preamble to AnthropicProvider', () => {
22
+ // These two are load-bearing: a subscription token is rejected by the
23
+ // Messages API unless `anthropic-beta: oauth-2025-04-20` is sent and the
24
+ // system prompt leads with the exact CLAUDE_CODE_SYSTEM line. A silent
25
+ // drop/typo in the forwarding object compiles fine and only surfaces as a
26
+ // runtime 401/400 — pin the contract here.
27
+ const client = createClaudeCodeClient({ oauthToken: 'tok' });
28
+ const oauth = (
29
+ client as unknown as { oauth: { beta: ReadonlyArray<string>; systemPreamble?: string } }
30
+ ).oauth;
31
+ expect(oauth.beta).toEqual([...CLAUDE_OAUTH_BETA]);
32
+ expect(oauth.beta).toContain('oauth-2025-04-20');
33
+ expect(oauth.beta).toContain('claude-code-20250219');
34
+ expect(oauth.systemPreamble).toBe(CLAUDE_CODE_SYSTEM);
35
+ });
36
+ });
@@ -0,0 +1,34 @@
1
+ /**
2
+ * The Claude subscription provider is the standard Anthropic Messages API
3
+ * with a bearer (OAuth) credential instead of an API key, so it reuses
4
+ * `AnthropicProvider` in OAuth mode rather than duplicating the streaming /
5
+ * tool-call event handling. This module just stamps in the Claude-specific
6
+ * constants (beta headers + the required identity preamble) and forwards the
7
+ * resolved token + refresh callback.
8
+ */
9
+
10
+ import { AnthropicProvider } from '@moxxy/plugin-provider-anthropic';
11
+ import { CLAUDE_CODE_PROVIDER_ID, CLAUDE_CODE_SYSTEM, CLAUDE_OAUTH_BETA } from './constants.js';
12
+
13
+ export interface ClaudeCodeProviderConfig {
14
+ /** Claude Code OAuth access token (bearer). Resolved by the host. */
15
+ readonly oauthToken?: string;
16
+ /** Epoch-ms expiry of `oauthToken`, when known (drives proactive refresh). */
17
+ readonly oauthExpiresAt?: number;
18
+ /** Refresh callback wired by the host; omitted for non-refreshable tokens. */
19
+ readonly oauthRefresh?: () => Promise<{ readonly token: string; readonly expiresAt?: number }>;
20
+ /** Optional default model override. */
21
+ readonly defaultModel?: string;
22
+ }
23
+
24
+ export function createClaudeCodeClient(config: ClaudeCodeProviderConfig = {}): AnthropicProvider {
25
+ return new AnthropicProvider({
26
+ name: CLAUDE_CODE_PROVIDER_ID,
27
+ oauthBeta: [...CLAUDE_OAUTH_BETA],
28
+ systemPreamble: CLAUDE_CODE_SYSTEM,
29
+ ...(config.oauthToken ? { oauthToken: config.oauthToken } : {}),
30
+ ...(config.oauthExpiresAt !== undefined ? { oauthExpiresAt: config.oauthExpiresAt } : {}),
31
+ ...(config.oauthRefresh ? { oauthRefresh: config.oauthRefresh } : {}),
32
+ ...(config.defaultModel ? { defaultModel: config.defaultModel } : {}),
33
+ });
34
+ }