@demicodes/provider-claude-code 0.3.3 → 0.4.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.
package/dist/index.d.mts CHANGED
@@ -39,11 +39,15 @@ interface ClaudeCodeAuthStore {
39
39
  forceRefresh?: boolean;
40
40
  }): Promise<ClaudeCodeOAuthAccess>;
41
41
  }
42
+ /** Renews a pool oauth secret (wired to `refreshClaudeCodeSecret`; injectable in tests). */
43
+ type ClaudeCodeSecretRefresh = (secret: Record<string, unknown>) => Promise<Record<string, unknown>>;
42
44
  interface FileClaudeCodeAuthStoreOptions {
43
45
  /** Optional path to oauth.json (pool entry). */
44
46
  oauthFile?: string;
45
47
  /** Prefer this token over env/keychain when set (tests / static). */
46
48
  accessToken?: string | null;
49
+ /** Renews the oauth file when its access token nears expiry. */
50
+ refresh?: ClaudeCodeSecretRefresh;
47
51
  }
48
52
  /**
49
53
  * Resolves Claude OAuth: explicit token → oauth file → CLAUDE_CODE_OAUTH_TOKEN → keychain.
@@ -51,6 +55,7 @@ interface FileClaudeCodeAuthStoreOptions {
51
55
  declare class FileClaudeCodeAuthStore implements ClaudeCodeAuthStore {
52
56
  private readonly oauthFile;
53
57
  private readonly accessToken;
58
+ private readonly refresh;
54
59
  constructor(options?: FileClaudeCodeAuthStoreOptions);
55
60
  status(): Promise<ProviderAuthState>;
56
61
  resolveAccess(): Promise<ClaudeCodeOAuthAccess>;
@@ -115,10 +120,44 @@ declare function openClaudeCodeCredentialPool(options?: {
115
120
  stateDir?: string;
116
121
  }): FileCredentialPool;
117
122
  declare function createClaudeCodeCredentials(pool: FileCredentialPool, authStore: ClaudeCodeAuthStore, options?: {
118
- loginCommand?: string;
119
- loginArgs?: string[];
120
123
  quota?: ProviderQuota | null;
121
124
  onActiveChange?: () => void;
125
+ /** Injectable fetch for the OAuth login flow (tests). */
126
+ loginFetch?: typeof fetch;
122
127
  }): ProviderCredentials;
123
128
  //#endregion
124
- export { type ClaudeCodeAuthStore, type ClaudeCodeModelCatalogOptions, type ClaudeCodeOAuthAccess, type ClaudeCodeProviderOptions, type ClaudeCodeQuotaOptions, FileClaudeCodeAuthStore, PoolAwareClaudeCodeAuthStore, StaticClaudeCodeAuthStore, createClaudeCodeCredentials, createClaudeCodeProvider, createClaudeCodeQuota, listClaudeCodeModels, mapClaudeUsagePayload, observeClaudeRateLimitHeaders, observeClaudeStreamBody, openClaudeCodeCredentialPool, resolveClaudeCodeOAuthAccess, resolveWireLogDir };
129
+ //#region src/login.d.ts
130
+ /** Pool-entry oauth.json shape (demi-owned; refreshable when refreshToken present). */
131
+ interface ClaudeCodeOAuthSecret {
132
+ accessToken: string;
133
+ refreshToken?: string | null;
134
+ /** ISO-8601 access token expiry. */
135
+ expiresAt?: string | null;
136
+ scopes?: string[] | null;
137
+ subscriptionType?: string | null;
138
+ rateLimitTier?: string | null;
139
+ emailAddress?: string | null;
140
+ [key: string]: unknown;
141
+ }
142
+ interface ClaudeCodeLoginOptions {
143
+ signal?: AbortSignal;
144
+ /** Fires once with the authorize URL; the flow then waits on promptForCode. */
145
+ onPending?: (pending: {
146
+ verificationUrl: string;
147
+ requiresCodeInput: true;
148
+ }) => void;
149
+ /** Collects the "code#state" string the vendor page shows after approval. */
150
+ promptForCode: () => Promise<string>;
151
+ fetch?: typeof fetch;
152
+ consoleBase?: string;
153
+ }
154
+ /** Runs the copy-back OAuth flow and returns a refreshable pool secret. */
155
+ declare function runClaudeCodeLogin(options: ClaudeCodeLoginOptions): Promise<ClaudeCodeOAuthSecret>;
156
+ /** Refreshes a pool secret in place; returns the renewed secret. */
157
+ declare function refreshClaudeCodeSecret(secret: ClaudeCodeOAuthSecret, options?: {
158
+ fetch?: typeof fetch;
159
+ consoleBase?: string;
160
+ signal?: AbortSignal;
161
+ }): Promise<ClaudeCodeOAuthSecret>;
162
+ //#endregion
163
+ export { type ClaudeCodeAuthStore, type ClaudeCodeLoginOptions, type ClaudeCodeModelCatalogOptions, type ClaudeCodeOAuthAccess, type ClaudeCodeOAuthSecret, type ClaudeCodeProviderOptions, type ClaudeCodeQuotaOptions, FileClaudeCodeAuthStore, PoolAwareClaudeCodeAuthStore, StaticClaudeCodeAuthStore, createClaudeCodeCredentials, createClaudeCodeProvider, createClaudeCodeQuota, listClaudeCodeModels, mapClaudeUsagePayload, observeClaudeRateLimitHeaders, observeClaudeStreamBody, openClaudeCodeCredentialPool, refreshClaudeCodeSecret, resolveClaudeCodeOAuthAccess, resolveWireLogDir, runClaudeCodeLogin };
package/dist/index.mjs CHANGED
@@ -1,11 +1,11 @@
1
1
  import { errorMessage, isRecord, nonEmptyString, numberOrNull, stringOrNull } from "@demicodes/utils";
2
- import { createHash, randomUUID } from "node:crypto";
2
+ import { createHash, randomBytes, randomUUID } from "node:crypto";
3
3
  import { applyModelPolicy, clampUsedPercent, createProviderQuota, defineProvider, numberHeader, severityFromUsedPercent, toolResultContentToText, unixSecondsToIso } from "@demicodes/provider";
4
4
  import { execFile, spawn } from "node:child_process";
5
- import { readFile } from "node:fs/promises";
5
+ import { readFile, writeFile } from "node:fs/promises";
6
6
  import { promisify } from "node:util";
7
7
  import process$1 from "node:process";
8
- import { FileCredentialPool, credentialIdFromIdentity, runVendorLoginCommand } from "@demicodes/provider/credentials-pool";
8
+ import { FileCredentialPool, credentialIdFromIdentity } from "@demicodes/provider/credentials-pool";
9
9
  import { Buffer as Buffer$1 } from "node:buffer";
10
10
  import { appendFileSync, mkdirSync, statSync } from "node:fs";
11
11
  import { createInterface } from "node:readline";
@@ -202,15 +202,18 @@ function reasoningEfforts(value) {
202
202
  //#endregion
203
203
  //#region src/auth.ts
204
204
  const execFileAsync = promisify(execFile);
205
+ const OAUTH_EXPIRY_SKEW_MS = 300 * 1e3;
205
206
  /**
206
207
  * Resolves Claude OAuth: explicit token → oauth file → CLAUDE_CODE_OAUTH_TOKEN → keychain.
207
208
  */
208
209
  var FileClaudeCodeAuthStore = class {
209
210
  oauthFile;
210
211
  accessToken;
212
+ refresh;
211
213
  constructor(options = {}) {
212
214
  this.oauthFile = options.oauthFile ?? null;
213
215
  this.accessToken = nonEmptyString(options.accessToken) ?? null;
216
+ this.refresh = options.refresh ?? null;
214
217
  }
215
218
  async status() {
216
219
  try {
@@ -235,7 +238,13 @@ var FileClaudeCodeAuthStore = class {
235
238
  source: "static"
236
239
  };
237
240
  if (this.oauthFile) try {
238
- const raw = JSON.parse(await readFile(this.oauthFile, "utf8"));
241
+ let raw = JSON.parse(await readFile(this.oauthFile, "utf8"));
242
+ if (!isRecord(raw)) throw new ClaudeCodeAuthError("auth_invalid", `Invalid OAuth file: ${this.oauthFile}`);
243
+ const expiresAt = nonEmptyString(raw.expiresAt);
244
+ if (expiresAt !== void 0 && Date.parse(expiresAt) - Date.now() < OAUTH_EXPIRY_SKEW_MS && nonEmptyString(raw.refreshToken) && this.refresh) {
245
+ raw = await this.refresh(raw);
246
+ await writeFile(this.oauthFile, `${JSON.stringify(raw, null, 2)}\n`);
247
+ }
239
248
  if (!isRecord(raw)) throw new ClaudeCodeAuthError("auth_invalid", `Invalid OAuth file: ${this.oauthFile}`);
240
249
  const accessToken = nonEmptyString(raw.accessToken) ?? nonEmptyString(raw.access_token);
241
250
  if (!accessToken) throw new ClaudeCodeAuthError("auth_missing", `No accessToken in ${this.oauthFile}`);
@@ -306,6 +315,93 @@ var ClaudeCodeAuthError = class extends Error {
306
315
  }
307
316
  };
308
317
  //#endregion
318
+ //#region src/login.ts
319
+ const CLAUDE_AUTHORIZE_URL = "https://claude.ai/oauth/authorize";
320
+ const CLAUDE_CONSOLE_BASE = "https://console.anthropic.com";
321
+ const CLAUDE_CODE_CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e";
322
+ const CLAUDE_LOGIN_SCOPE = "org:create_api_key user:profile user:inference";
323
+ function tokenEndpoint(consoleBase) {
324
+ return `${consoleBase.replace(/\/+$/, "")}/v1/oauth/token`;
325
+ }
326
+ async function requestTokens(fetchImpl, consoleBase, body, signal) {
327
+ const response = await fetchImpl(tokenEndpoint(consoleBase), {
328
+ method: "POST",
329
+ headers: { "content-type": "application/json" },
330
+ body: JSON.stringify(body),
331
+ signal
332
+ });
333
+ if (!response.ok) throw new ClaudeCodeAuthError("auth_invalid", `Claude OAuth token request failed with HTTP ${response.status}`);
334
+ const parsed = await response.json().catch(() => null);
335
+ if (!isRecord(parsed)) throw new ClaudeCodeAuthError("auth_invalid", "Claude OAuth token response is not a JSON object");
336
+ const accessToken = nonEmptyString(parsed.access_token);
337
+ if (!accessToken) throw new ClaudeCodeAuthError("auth_invalid", "Claude OAuth token response is missing access_token");
338
+ const expiresIn = Number(parsed.expires_in);
339
+ const account = isRecord(parsed.account) ? parsed.account : {};
340
+ return {
341
+ accessToken,
342
+ refreshToken: nonEmptyString(parsed.refresh_token) ?? null,
343
+ expiresAt: Number.isFinite(expiresIn) && expiresIn > 0 ? new Date(Date.now() + expiresIn * 1e3).toISOString() : null,
344
+ scopes: typeof parsed.scope === "string" ? parsed.scope.split(" ").filter(Boolean) : null,
345
+ subscriptionType: nonEmptyString(parsed.subscription_type) ?? nonEmptyString(account.subscription_type) ?? null,
346
+ ...nonEmptyString(account.email_address) ? { emailAddress: nonEmptyString(account.email_address) } : {}
347
+ };
348
+ }
349
+ /** Runs the copy-back OAuth flow and returns a refreshable pool secret. */
350
+ async function runClaudeCodeLogin(options) {
351
+ const fetchImpl = options.fetch ?? fetch;
352
+ const consoleBase = options.consoleBase ?? CLAUDE_CONSOLE_BASE;
353
+ const verifier = randomBytes(32).toString("base64url");
354
+ const challenge = createHash("sha256").update(verifier).digest("base64url");
355
+ const state = randomBytes(32).toString("base64url");
356
+ const redirectUri = `${consoleBase.replace(/\/+$/, "")}/oauth/code/callback`;
357
+ const params = new URLSearchParams({
358
+ code: "true",
359
+ client_id: CLAUDE_CODE_CLIENT_ID,
360
+ response_type: "code",
361
+ redirect_uri: redirectUri,
362
+ scope: CLAUDE_LOGIN_SCOPE,
363
+ code_challenge: challenge,
364
+ code_challenge_method: "S256",
365
+ state
366
+ });
367
+ options.onPending?.({
368
+ verificationUrl: `${CLAUDE_AUTHORIZE_URL}?${params.toString()}`,
369
+ requiresCodeInput: true
370
+ });
371
+ const pasted = (await options.promptForCode()).trim();
372
+ if (!pasted) throw new ClaudeCodeAuthError("auth_invalid", "Empty authorization code");
373
+ const [code, returnedState] = pasted.split("#");
374
+ if (!nonEmptyString(code)) throw new ClaudeCodeAuthError("auth_invalid", "Authorization code is missing the code part");
375
+ if (returnedState && returnedState !== state) throw new ClaudeCodeAuthError("auth_invalid", "Authorization code state mismatch — copy the full string from the callback page");
376
+ return requestTokens(fetchImpl, consoleBase, {
377
+ grant_type: "authorization_code",
378
+ code,
379
+ state: returnedState ?? state,
380
+ client_id: CLAUDE_CODE_CLIENT_ID,
381
+ redirect_uri: redirectUri,
382
+ code_verifier: verifier
383
+ }, options.signal);
384
+ }
385
+ /** Refreshes a pool secret in place; returns the renewed secret. */
386
+ async function refreshClaudeCodeSecret(secret, options = {}) {
387
+ const refreshToken = nonEmptyString(secret.refreshToken);
388
+ if (!refreshToken) throw new ClaudeCodeAuthError("auth_missing", "Claude OAuth secret has no refreshToken to renew with");
389
+ const renewed = await requestTokens(options.fetch ?? fetch, options.consoleBase ?? CLAUDE_CONSOLE_BASE, {
390
+ grant_type: "refresh_token",
391
+ refresh_token: refreshToken,
392
+ client_id: CLAUDE_CODE_CLIENT_ID
393
+ }, options.signal);
394
+ return {
395
+ ...secret,
396
+ accessToken: renewed.accessToken,
397
+ refreshToken: renewed.refreshToken ?? refreshToken,
398
+ expiresAt: renewed.expiresAt ?? secret.expiresAt ?? null,
399
+ scopes: renewed.scopes ?? secret.scopes ?? null,
400
+ subscriptionType: renewed.subscriptionType ?? secret.subscriptionType ?? null,
401
+ ...nonEmptyString(renewed.emailAddress) ? { emailAddress: renewed.emailAddress } : {}
402
+ };
403
+ }
404
+ //#endregion
309
405
  //#region src/credentials.ts
310
406
  var PoolAwareClaudeCodeAuthStore = class {
311
407
  pool;
@@ -321,7 +417,10 @@ var PoolAwareClaudeCodeAuthStore = class {
321
417
  async currentStore() {
322
418
  await this.pool.ensureActivePointer();
323
419
  const activeId = await this.pool.getActiveId();
324
- if (activeId) return new FileClaudeCodeAuthStore({ oauthFile: this.pool.secretPath(activeId) });
420
+ if (activeId) return new FileClaudeCodeAuthStore({
421
+ oauthFile: this.pool.secretPath(activeId),
422
+ refresh: (secret) => refreshClaudeCodeSecret(secret)
423
+ });
325
424
  return new FileClaudeCodeAuthStore();
326
425
  }
327
426
  };
@@ -333,8 +432,6 @@ function openClaudeCodeCredentialPool(options = {}) {
333
432
  });
334
433
  }
335
434
  function createClaudeCodeCredentials(pool, authStore, options = {}) {
336
- const loginCommand = options.loginCommand ?? "claude";
337
- const loginArgs = options.loginArgs ?? ["auth", "login"];
338
435
  const capability = () => ({
339
436
  mode: "supported",
340
437
  canBeginLogin: true,
@@ -385,23 +482,58 @@ function createClaudeCodeCredentials(pool, authStore, options = {}) {
385
482
  updatedAt: meta.updatedAt
386
483
  };
387
484
  };
485
+ const importSecret = async (secret, source) => {
486
+ const email = nonEmptyString(secret.emailAddress);
487
+ const identityKey = email ? `email:${email}` : `token:${createHash("sha256").update(secret.accessToken).digest("hex").slice(0, 16)}`;
488
+ const label = email ?? nonEmptyString(secret.subscriptionType) ?? `claude-${identityKey.slice(-8)}`;
489
+ const id = (await pool.findByIdentityKey(identityKey))?.id ?? credentialIdFromIdentity(identityKey, label);
490
+ const meta = {
491
+ id,
492
+ label,
493
+ detail: nonEmptyString(secret.subscriptionType) ?? nonEmptyString(secret.rateLimitTier) ?? null,
494
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
495
+ source,
496
+ identityKey
497
+ };
498
+ await pool.writeEntry(meta, `${JSON.stringify(secret, null, 2)}\n`);
499
+ if (!await pool.getActiveId()) await pool.setActiveId(id);
500
+ options.quota?.clearLatest?.();
501
+ options.onActiveChange?.();
502
+ return {
503
+ id: meta.id,
504
+ label: meta.label,
505
+ detail: meta.detail,
506
+ updatedAt: meta.updatedAt
507
+ };
508
+ };
388
509
  return {
389
510
  capability,
390
511
  list: () => pool.list(),
391
512
  getActive,
392
513
  setActive,
393
514
  beginLogin: async (loginOptions) => {
394
- const result = await runVendorLoginCommand(loginCommand, loginArgs, { signal: loginOptions?.signal });
395
- if (result.status === "completed") return { status: "completed" };
396
- if (result.status === "cancelled") return { status: "cancelled" };
397
- if (result.status === "unavailable") return {
515
+ if (!loginOptions?.promptForCode) return {
398
516
  status: "unavailable",
399
- message: result.message ?? "Login unavailable"
400
- };
401
- return {
402
- status: "failed",
403
- message: result.message ?? "Login failed"
517
+ message: "Claude login requires promptForCode to collect the pasted authorization code"
404
518
  };
519
+ try {
520
+ const secret = await runClaudeCodeLogin({
521
+ signal: loginOptions.signal,
522
+ onPending: loginOptions.onPending,
523
+ promptForCode: loginOptions.promptForCode,
524
+ fetch: options.loginFetch
525
+ });
526
+ return {
527
+ status: "completed",
528
+ credentialId: (await importSecret(secret, "login:oauth")).id
529
+ };
530
+ } catch (error) {
531
+ if (loginOptions.signal?.aborted) return { status: "cancelled" };
532
+ return {
533
+ status: "failed",
534
+ message: errorMessage(error)
535
+ };
536
+ }
405
537
  },
406
538
  importDefault: async () => {
407
539
  const vendor = new FileClaudeCodeAuthStore();
@@ -1649,4 +1781,4 @@ function itemsDiverged(active, items) {
1649
1781
  return false;
1650
1782
  }
1651
1783
  //#endregion
1652
- export { FileClaudeCodeAuthStore, PoolAwareClaudeCodeAuthStore, StaticClaudeCodeAuthStore, createClaudeCodeCredentials, createClaudeCodeProvider, createClaudeCodeQuota, listClaudeCodeModels, mapClaudeUsagePayload, observeClaudeRateLimitHeaders, observeClaudeStreamBody, openClaudeCodeCredentialPool, resolveClaudeCodeOAuthAccess, resolveWireLogDir };
1784
+ export { FileClaudeCodeAuthStore, PoolAwareClaudeCodeAuthStore, StaticClaudeCodeAuthStore, createClaudeCodeCredentials, createClaudeCodeProvider, createClaudeCodeQuota, listClaudeCodeModels, mapClaudeUsagePayload, observeClaudeRateLimitHeaders, observeClaudeStreamBody, openClaudeCodeCredentialPool, refreshClaudeCodeSecret, resolveClaudeCodeOAuthAccess, resolveWireLogDir, runClaudeCodeLogin };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@demicodes/provider-claude-code",
3
3
  "description": "Claude Code provider adapter for Demi.",
4
- "version": "0.3.3",
4
+ "version": "0.4.0",
5
5
  "private": false,
6
6
  "type": "module",
7
7
  "exports": {
@@ -12,11 +12,11 @@
12
12
  },
13
13
  "dependencies": {
14
14
  "@demicodes/core": "^0.3.2",
15
- "@demicodes/provider": "^0.3.2",
15
+ "@demicodes/provider": "^0.4.0",
16
16
  "@demicodes/utils": "^0.3.2"
17
17
  },
18
18
  "devDependencies": {
19
- "@demicodes/agent": "^0.3.2",
19
+ "@demicodes/agent": "^0.3.3",
20
20
  "@demicodes/shell": "^0.3.2"
21
21
  },
22
22
  "license": "Apache-2.0",