From ecd7978dc8ac9f2fae1348541492f56358cc4ec7 Mon Sep 17 00:00:00 2001 From: Stephen Hellicar Date: Mon, 6 Apr 2026 00:44:58 +1000 Subject: [PATCH 1/9] Add initial OAuth2 flow --- .../src/private/Auth/AnthropicAuth.ts | 51 +++++++++++++++++++ .../Auth/InvalidAuthorisationCodeError.ts | 1 + .../private/Auth/TokenExchangeFailedError.ts | 1 + .../src/private/Auth/base64UrlEncode.ts | 1 + .../src/private/Auth/buildAuthUrl.ts | 21 ++++++++ .../claude-sdk/src/private/Auth/consts.ts | 8 +++ .../src/private/Auth/exchangeCode.ts | 29 +++++++++++ .../src/private/Auth/generateCodeChallenge.ts | 4 ++ .../src/private/Auth/generateCodeVerifier.ts | 4 ++ .../src/private/Auth/generateState.ts | 4 ++ .../claude-sdk/src/private/Auth/isExpired.ts | 3 ++ .../src/private/Auth/loadCredentials.ts | 13 +++++ .../src/private/Auth/parseTokenResponse.ts | 14 +++++ .../src/private/Auth/refreshCredentials.ts | 22 ++++++++ .../src/private/Auth/saveCredentials.ts | 7 +++ .../claude-sdk/src/private/Auth/schema.ts | 9 ++++ packages/claude-sdk/src/private/Auth/types.ts | 16 ++++++ .../src/private/Auth/waitForCallback.ts | 22 ++++++++ 18 files changed, 230 insertions(+) create mode 100644 packages/claude-sdk/src/private/Auth/AnthropicAuth.ts create mode 100644 packages/claude-sdk/src/private/Auth/InvalidAuthorisationCodeError.ts create mode 100644 packages/claude-sdk/src/private/Auth/TokenExchangeFailedError.ts create mode 100644 packages/claude-sdk/src/private/Auth/base64UrlEncode.ts create mode 100644 packages/claude-sdk/src/private/Auth/buildAuthUrl.ts create mode 100644 packages/claude-sdk/src/private/Auth/consts.ts create mode 100644 packages/claude-sdk/src/private/Auth/exchangeCode.ts create mode 100644 packages/claude-sdk/src/private/Auth/generateCodeChallenge.ts create mode 100644 packages/claude-sdk/src/private/Auth/generateCodeVerifier.ts create mode 100644 packages/claude-sdk/src/private/Auth/generateState.ts create mode 100644 packages/claude-sdk/src/private/Auth/isExpired.ts create mode 100644 packages/claude-sdk/src/private/Auth/loadCredentials.ts create mode 100644 packages/claude-sdk/src/private/Auth/parseTokenResponse.ts create mode 100644 packages/claude-sdk/src/private/Auth/refreshCredentials.ts create mode 100644 packages/claude-sdk/src/private/Auth/saveCredentials.ts create mode 100644 packages/claude-sdk/src/private/Auth/schema.ts create mode 100644 packages/claude-sdk/src/private/Auth/types.ts create mode 100644 packages/claude-sdk/src/private/Auth/waitForCallback.ts diff --git a/packages/claude-sdk/src/private/Auth/AnthropicAuth.ts b/packages/claude-sdk/src/private/Auth/AnthropicAuth.ts new file mode 100644 index 0000000..e3af534 --- /dev/null +++ b/packages/claude-sdk/src/private/Auth/AnthropicAuth.ts @@ -0,0 +1,51 @@ +import { execFile } from 'node:child_process'; +import { buildAuthUrl } from './buildAuthUrl'; +import { LocalRedirectUrl, PlatformRedirectUrl } from './consts'; +import { exchangeCode } from './exchangeCode'; +import { isExpired } from './isExpired'; +import { loadCredentials } from './loadCredentials'; +import { refreshCredentials } from './refreshCredentials'; +import { saveCredentials } from './saveCredentials'; +import type { AnthropicAuthOptions, AuthCredentials } from './types'; +import { waitForCallback } from './waitForCallback'; + +export class AnthropicAuth { + private readonly redirect: 'local' | 'manual'; + + public constructor(options: AnthropicAuthOptions = {}) { + this.redirect = options.redirect ?? 'local'; + } + + public async getCredentials(): Promise { + let credentials = await loadCredentials(); + + if (credentials === null) { + credentials = await this.login(); + await saveCredentials(credentials); + } else if (isExpired(credentials)) { + credentials = await refreshCredentials(credentials); + await saveCredentials(credentials); + } + + return credentials; + } + + private async login(): Promise { + if (this.redirect === 'local') { + const { url, codeVerifier, state } = buildAuthUrl(LocalRedirectUrl); + execFile('open', [url]); + const { code } = await waitForCallback(3001); + return exchangeCode(code, state, codeVerifier, LocalRedirectUrl); + } + + const { url, codeVerifier, state } = buildAuthUrl(PlatformRedirectUrl); + // biome-ignore lint/suspicious/noConsole: show url + console.log(url); + process.stdout.write('Paste code: '); + const input = await new Promise((resolve) => { + process.stdin.once('data', (data) => resolve(data.toString().trim())); + }); + const code = input.split('#')[0]; + return exchangeCode(code, state, codeVerifier, PlatformRedirectUrl); + } +} diff --git a/packages/claude-sdk/src/private/Auth/InvalidAuthorisationCodeError.ts b/packages/claude-sdk/src/private/Auth/InvalidAuthorisationCodeError.ts new file mode 100644 index 0000000..9497887 --- /dev/null +++ b/packages/claude-sdk/src/private/Auth/InvalidAuthorisationCodeError.ts @@ -0,0 +1 @@ +export class InvalidAuthorisationCodeError extends Error {} diff --git a/packages/claude-sdk/src/private/Auth/TokenExchangeFailedError.ts b/packages/claude-sdk/src/private/Auth/TokenExchangeFailedError.ts new file mode 100644 index 0000000..19f60c1 --- /dev/null +++ b/packages/claude-sdk/src/private/Auth/TokenExchangeFailedError.ts @@ -0,0 +1 @@ +export class TokenExchangeFailedError extends Error {} diff --git a/packages/claude-sdk/src/private/Auth/base64UrlEncode.ts b/packages/claude-sdk/src/private/Auth/base64UrlEncode.ts new file mode 100644 index 0000000..ab15093 --- /dev/null +++ b/packages/claude-sdk/src/private/Auth/base64UrlEncode.ts @@ -0,0 +1 @@ +export const base64UrlEncode = (buffer: Buffer) => buffer.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''); diff --git a/packages/claude-sdk/src/private/Auth/buildAuthUrl.ts b/packages/claude-sdk/src/private/Auth/buildAuthUrl.ts new file mode 100644 index 0000000..eb5b106 --- /dev/null +++ b/packages/claude-sdk/src/private/Auth/buildAuthUrl.ts @@ -0,0 +1,21 @@ +import { AuthorisationUrl, ClientId, Scopes } from './consts'; +import { generateCodeChallenge } from './generateCodeChallenge'; +import { generateCodeVerifier } from './generateCodeVerifier'; +import { generateState } from './generateState'; +import type { AuthUrlResult } from './types'; + +export const buildAuthUrl = (redirectUri: string): AuthUrlResult => { + const codeVerifier = generateCodeVerifier(); + const state = generateState(); + + const url = new URL(AuthorisationUrl); + url.searchParams.set('response_type', 'code'); + url.searchParams.set('client_id', ClientId); + url.searchParams.set('redirect_uri', redirectUri); + url.searchParams.set('scope', Scopes); + url.searchParams.set('code_challenge', generateCodeChallenge(codeVerifier)); + url.searchParams.set('code_challenge_method', 'S256'); + url.searchParams.set('state', state); + + return { url: url.href, codeVerifier, state } satisfies AuthUrlResult; +}; diff --git a/packages/claude-sdk/src/private/Auth/consts.ts b/packages/claude-sdk/src/private/Auth/consts.ts new file mode 100644 index 0000000..5723ac0 --- /dev/null +++ b/packages/claude-sdk/src/private/Auth/consts.ts @@ -0,0 +1,8 @@ +export const TokenUrl = 'https://platform.claude.com/v1/oauth/token'; +export const AuthorisationUrl = 'https://platform.claude.com/oauth/authorize'; +export const PlatformRedirectUrl = 'https://platform.claude.com/oauth/code/callback'; +export const LocalRedirectUrl = 'http://localhost:3001/callback'; +export const ClientId = '9d1c250a-e61b-44d9-88ed-5944d1962f5e'; +export const Scopes = 'user:file_upload user:inference user:mcp_servers user:profile user:sessions:claude_code'; + +export const CredentialsPath = './.credentials.json'; diff --git a/packages/claude-sdk/src/private/Auth/exchangeCode.ts b/packages/claude-sdk/src/private/Auth/exchangeCode.ts new file mode 100644 index 0000000..61cdf2f --- /dev/null +++ b/packages/claude-sdk/src/private/Auth/exchangeCode.ts @@ -0,0 +1,29 @@ +import { ClientId, TokenUrl } from './consts'; +import { InvalidAuthorisationCodeError } from './InvalidAuthorisationCodeError'; +import { parseTokenResponse } from './parseTokenResponse'; +import { TokenExchangeFailedError } from './TokenExchangeFailedError'; +import type { AuthCredentials } from './types'; + +export const exchangeCode = async (code: string, state: string, codeVerifier: string, redirectUri: string): Promise => { + const response = await fetch(TokenUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + grant_type: 'authorization_code', + code, + redirect_uri: redirectUri, + client_id: ClientId, + code_verifier: codeVerifier, + state, + }), + }); + + if (!response.ok) { + if (response.status === 401) { + throw new InvalidAuthorisationCodeError(); + } + throw new TokenExchangeFailedError(); + } + + return parseTokenResponse(await response.json()); +}; diff --git a/packages/claude-sdk/src/private/Auth/generateCodeChallenge.ts b/packages/claude-sdk/src/private/Auth/generateCodeChallenge.ts new file mode 100644 index 0000000..f1961a3 --- /dev/null +++ b/packages/claude-sdk/src/private/Auth/generateCodeChallenge.ts @@ -0,0 +1,4 @@ +import { createHash } from 'node:crypto'; +import { base64UrlEncode } from './base64UrlEncode'; + +export const generateCodeChallenge = (verifier: string) => base64UrlEncode(createHash('sha256').update(verifier).digest()); diff --git a/packages/claude-sdk/src/private/Auth/generateCodeVerifier.ts b/packages/claude-sdk/src/private/Auth/generateCodeVerifier.ts new file mode 100644 index 0000000..761b6be --- /dev/null +++ b/packages/claude-sdk/src/private/Auth/generateCodeVerifier.ts @@ -0,0 +1,4 @@ +import { randomBytes } from 'node:crypto'; +import { base64UrlEncode } from './base64UrlEncode'; + +export const generateCodeVerifier = () => base64UrlEncode(randomBytes(32)); diff --git a/packages/claude-sdk/src/private/Auth/generateState.ts b/packages/claude-sdk/src/private/Auth/generateState.ts new file mode 100644 index 0000000..3d44714 --- /dev/null +++ b/packages/claude-sdk/src/private/Auth/generateState.ts @@ -0,0 +1,4 @@ +import { randomBytes } from 'node:crypto'; +import { base64UrlEncode } from './base64UrlEncode'; + +export const generateState = () => base64UrlEncode(randomBytes(32)); diff --git a/packages/claude-sdk/src/private/Auth/isExpired.ts b/packages/claude-sdk/src/private/Auth/isExpired.ts new file mode 100644 index 0000000..017da8c --- /dev/null +++ b/packages/claude-sdk/src/private/Auth/isExpired.ts @@ -0,0 +1,3 @@ +import type { AuthCredentials } from './types'; + +export const isExpired = (credentials: AuthCredentials): boolean => Date.now() >= credentials.claudeAiOauth.expiresAt; diff --git a/packages/claude-sdk/src/private/Auth/loadCredentials.ts b/packages/claude-sdk/src/private/Auth/loadCredentials.ts new file mode 100644 index 0000000..ecc62e7 --- /dev/null +++ b/packages/claude-sdk/src/private/Auth/loadCredentials.ts @@ -0,0 +1,13 @@ +import { readFile } from 'node:fs/promises'; +import { CredentialsPath } from './consts'; +import type { AuthCredentials } from './types'; + +export const loadCredentials = async (): Promise => { + try { + const raw = await readFile(CredentialsPath, 'utf-8'); + const parsed: AuthCredentials = JSON.parse(raw); + return parsed; + } catch { + return null; + } +}; diff --git a/packages/claude-sdk/src/private/Auth/parseTokenResponse.ts b/packages/claude-sdk/src/private/Auth/parseTokenResponse.ts new file mode 100644 index 0000000..8d59456 --- /dev/null +++ b/packages/claude-sdk/src/private/Auth/parseTokenResponse.ts @@ -0,0 +1,14 @@ +import { tokenResponse } from './schema'; +import type { AuthCredentials } from './types'; + +export const parseTokenResponse = (input: unknown): AuthCredentials => { + const data = tokenResponse.parse(input); + return { + claudeAiOauth: { + accessToken: data.access_token, + refreshToken: data.refresh_token, + expiresAt: Date.now() + data.expires_in * 1000, + scopes: data.scope, + }, + } satisfies AuthCredentials; +}; diff --git a/packages/claude-sdk/src/private/Auth/refreshCredentials.ts b/packages/claude-sdk/src/private/Auth/refreshCredentials.ts new file mode 100644 index 0000000..e741176 --- /dev/null +++ b/packages/claude-sdk/src/private/Auth/refreshCredentials.ts @@ -0,0 +1,22 @@ +import { ClientId, TokenUrl } from './consts'; +import { parseTokenResponse } from './parseTokenResponse'; +import { TokenExchangeFailedError } from './TokenExchangeFailedError'; +import type { AuthCredentials } from './types'; + +export const refreshCredentials = async (credentials: AuthCredentials): Promise => { + const response = await fetch(TokenUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + grant_type: 'refresh_token', + refresh_token: credentials.claudeAiOauth.refreshToken, + client_id: ClientId, + }), + }); + + if (!response.ok) { + throw new TokenExchangeFailedError(); + } + + return parseTokenResponse(await response.json()); +}; diff --git a/packages/claude-sdk/src/private/Auth/saveCredentials.ts b/packages/claude-sdk/src/private/Auth/saveCredentials.ts new file mode 100644 index 0000000..fdeb2db --- /dev/null +++ b/packages/claude-sdk/src/private/Auth/saveCredentials.ts @@ -0,0 +1,7 @@ +import { writeFile } from 'node:fs/promises'; +import { CredentialsPath } from './consts'; +import type { AuthCredentials } from './types'; + +export const saveCredentials = async (credentials: AuthCredentials): Promise => { + await writeFile(CredentialsPath, JSON.stringify(credentials, null, 2)); +}; diff --git a/packages/claude-sdk/src/private/Auth/schema.ts b/packages/claude-sdk/src/private/Auth/schema.ts new file mode 100644 index 0000000..be1236a --- /dev/null +++ b/packages/claude-sdk/src/private/Auth/schema.ts @@ -0,0 +1,9 @@ +import { z } from 'zod'; + +export const tokenResponse = z.object({ + token_type: z.string(), + access_token: z.string(), + expires_in: z.number().int(), + refresh_token: z.string(), + scope: z.string().transform((x) => x.split(' ')), +}); diff --git a/packages/claude-sdk/src/private/Auth/types.ts b/packages/claude-sdk/src/private/Auth/types.ts new file mode 100644 index 0000000..567871f --- /dev/null +++ b/packages/claude-sdk/src/private/Auth/types.ts @@ -0,0 +1,16 @@ +export type AnthropicAuthOptions = { + redirect?: 'local' | 'manual'; +}; +export type AuthUrlResult = { + url: string; + codeVerifier: string; + state: string; +}; +export type AuthCredentials = { + claudeAiOauth: { + accessToken: string; + refreshToken: string; + expiresAt: number; + scopes: string[]; + }; +}; diff --git a/packages/claude-sdk/src/private/Auth/waitForCallback.ts b/packages/claude-sdk/src/private/Auth/waitForCallback.ts new file mode 100644 index 0000000..d2a5165 --- /dev/null +++ b/packages/claude-sdk/src/private/Auth/waitForCallback.ts @@ -0,0 +1,22 @@ +import { createServer } from 'node:http'; + +export const waitForCallback = (port: number): Promise<{ code: string; state: string }> => + new Promise((resolve, reject) => { + const server = createServer((req, res) => { + const url = new URL(req.url ?? '', `http://localhost:${port}`); + const code = url.searchParams.get('code'); + const state = url.searchParams.get('state'); + + res.writeHead(200, { 'Content-Type': 'text/html' }); + res.end('

Login successful. You can close this tab.

'); + server.close(); + + if (!code || !state) { + reject(new Error('Missing code or state in callback')); + return; + } + resolve({ code, state }); + }); + + server.listen(port); + }); From 06f2657aae5ab4f3c269674f19bb554609143c3a Mon Sep 17 00:00:00 2001 From: Stephen Hellicar Date: Mon, 6 Apr 2026 03:29:26 +1000 Subject: [PATCH 2/9] feat: OAuth2 auth in claude-sdk-cli; replace static API key with token getter - AnthropicAgentOptions.apiKey replaced with authToken: () => Promise - customFetch injects Authorization: Bearer header per-request, enabling transparent token refresh without SDK changes - AnthropicAgent passes authToken: '-' to satisfy SDK null-check; real token comes through fetch layer - AnthropicAuth.getCredentials() called at startup; triggers OAuth flow if no credentials, refreshes if expired - fetchProfile() called after first login to store subscriptionType and rateLimitTier in credentials - credentialsPath() moved to ~/.claude/.credentials.json (was ./credentials.json next to the binary) - loadCredentials/saveCredentials now validate through authCredentials Zod schema - AuthCredentials type gains subscriptionType and rateLimitTier fields - AuthorisationUrl updated to claude.com/cai/oauth/authorize - claude-cli src/main.ts moved to src/entry/main.ts to match entryPoints glob - Build improvements: dropLabels wired, minify tied to !watch, shebang banner added, splitting enabled - Compaction trigger threshold bumped 125k -> 150k tokens --- apps/claude-cli/build.ts | 12 +++++++----- apps/claude-cli/src/{ => entry}/main.ts | 6 +++--- apps/claude-sdk-cli/.gitignore | 1 + apps/claude-sdk-cli/build.ts | 16 +++++++++------- apps/claude-sdk-cli/src/entry/main.ts | 15 ++++++++------- packages/claude-sdk/src/index.ts | 7 ++++--- packages/claude-sdk/src/private/AgentRun.ts | 2 +- .../claude-sdk/src/private/AnthropicAgent.ts | 5 +++-- .../src/private/Auth/AnthropicAuth.ts | 3 +++ packages/claude-sdk/src/private/Auth/consts.ts | 5 +++-- .../src/private/Auth/credentialsPath.ts | 7 +++++++ .../src/private/Auth/fetchProfile.ts | 18 ++++++++++++++++++ .../src/private/Auth/loadCredentials.ts | 8 +++++--- .../src/private/Auth/parseTokenResponse.ts | 2 ++ .../src/private/Auth/saveCredentials.ts | 7 +++++-- packages/claude-sdk/src/private/Auth/schema.ts | 18 ++++++++++++++++++ packages/claude-sdk/src/private/Auth/types.ts | 2 ++ .../claude-sdk/src/private/http/customFetch.ts | 15 +++++++++++---- packages/claude-sdk/src/public/types.ts | 2 +- 19 files changed, 111 insertions(+), 40 deletions(-) rename apps/claude-cli/src/{ => entry}/main.ts (87%) create mode 100644 packages/claude-sdk/src/private/Auth/credentialsPath.ts create mode 100644 packages/claude-sdk/src/private/Auth/fetchProfile.ts diff --git a/apps/claude-cli/build.ts b/apps/claude-cli/build.ts index 0c37036..865785d 100644 --- a/apps/claude-cli/build.ts +++ b/apps/claude-cli/build.ts @@ -13,23 +13,25 @@ const plugins = [cleanPlugin({ destructive: true }), versionPlugin({ versionCalc const inject = await Array.fromAsync(glob('./inject/*.ts')); const ctx = await esbuild.context({ + dropLabels: watch ? [] : ['DEBUG'], banner: { js: '#!/usr/bin/env node' }, bundle: true, - entryPoints: ['src/main.ts'], + chunkNames: 'chunks/[name]-[hash]', + entryNames: 'entry/[name]', + entryPoints: ['src/entry/*.ts'], + external: ['@anthropic-ai/claude-agent-sdk', 'sharp'], + format: 'esm', inject, - entryNames: '[name]', keepNames: true, - format: 'esm', minify, outdir: 'dist', platform: 'node', plugins, sourcemap: true, + splitting: true, target: 'node24', treeShaking: true, - // dropLabels: watch ? [] : ['DEBUG'], tsconfig: 'tsconfig.json', - external: ['@anthropic-ai/claude-agent-sdk', 'sharp'], }); if (watch) { diff --git a/apps/claude-cli/src/main.ts b/apps/claude-cli/src/entry/main.ts similarity index 87% rename from apps/claude-cli/src/main.ts rename to apps/claude-cli/src/entry/main.ts index ef8857a..22ae67b 100644 --- a/apps/claude-cli/src/main.ts +++ b/apps/claude-cli/src/entry/main.ts @@ -1,7 +1,7 @@ import { parseArgs } from 'node:util'; -import { ClaudeCli } from './ClaudeCli.js'; -import { initConfig } from './cli-config/initConfig.js'; -import { printUsage, printVersion, printVersionInfo } from './help.js'; +import { ClaudeCli } from '../ClaudeCli.js'; +import { initConfig } from '../cli-config/initConfig.js'; +import { printUsage, printVersion, printVersionInfo } from '../help.js'; const { values } = parseArgs({ options: { diff --git a/apps/claude-sdk-cli/.gitignore b/apps/claude-sdk-cli/.gitignore index f80d99c..84d590d 100644 --- a/apps/claude-sdk-cli/.gitignore +++ b/apps/claude-sdk-cli/.gitignore @@ -23,3 +23,4 @@ dist-ssr *.sln *.sw? .sdk-history.jsonl +.credentials.json diff --git a/apps/claude-sdk-cli/build.ts b/apps/claude-sdk-cli/build.ts index 2671602..e54b627 100644 --- a/apps/claude-sdk-cli/build.ts +++ b/apps/claude-sdk-cli/build.ts @@ -4,28 +4,30 @@ import versionPlugin from '@shellicar/build-version/esbuild'; import * as esbuild from 'esbuild'; const watch = process.argv.some((x) => x === '--watch'); +const minify = !watch; const plugins = [cleanPlugin({ destructive: true }), versionPlugin({ versionCalculator: 'gitversion' })]; const inject = await Array.fromAsync(glob('./inject/*.ts')); const ctx = await esbuild.context({ + dropLabels: watch ? [] : ['DEBUG'], + banner: { js: '#!/usr/bin/env node' }, bundle: true, + chunkNames: 'chunks/[name]-[hash]', + entryNames: 'entry/[name]', entryPoints: ['src/entry/*.ts'], + external: ['@anthropic-ai/sdk'], + format: 'esm', inject, - entryNames: 'entry/[name]', - chunkNames: 'chunks/[name]-[hash]', keepNames: true, - format: 'esm', - minify: false, + minify, outdir: 'dist', platform: 'node', plugins, - splitting: true, - external: ['@anthropic-ai/sdk'], sourcemap: true, + splitting: true, target: 'node24', treeShaking: true, - // dropLabels: ['DEBUG'], tsconfig: 'tsconfig.json', }); diff --git a/apps/claude-sdk-cli/src/entry/main.ts b/apps/claude-sdk-cli/src/entry/main.ts index fad7534..e217048 100644 --- a/apps/claude-sdk-cli/src/entry/main.ts +++ b/apps/claude-sdk-cli/src/entry/main.ts @@ -1,5 +1,5 @@ import { parseArgs } from 'node:util'; -import { createAnthropicAgent } from '@shellicar/claude-sdk'; +import { AnthropicAuth, createAnthropicAgent } from '@shellicar/claude-sdk'; import { RefStore } from '@shellicar/claude-sdk-tools/RefStore'; import { AppLayout } from '../AppLayout.js'; import { printUsage, printVersion, printVersionInfo } from '../help.js'; @@ -42,11 +42,12 @@ if (!process.stdin.isTTY) { const HISTORY_FILE = '.sdk-history.jsonl'; const main = async () => { - const apiKey = process.env.CLAUDE_CODE_API_KEY; - if (!apiKey) { - logger.error('CLAUDE_CODE_API_KEY is not set'); - process.exit(1); - } + const auth = new AnthropicAuth({ redirect: 'local' }); + await auth.getCredentials(); + const authToken = async () => { + const credentials = await auth.getCredentials(); + return credentials.claudeAiOauth.accessToken; + }; using rl = new ReadLine(); const layout = new AppLayout(); @@ -61,7 +62,7 @@ const main = async () => { rl.setLayout(layout); layout.enter(); - const agent = createAnthropicAgent({ apiKey, logger, historyFile: HISTORY_FILE }); + const agent = createAnthropicAgent({ authToken, logger, historyFile: HISTORY_FILE }); const store = new RefStore(); while (true) { const prompt = await layout.waitForInput(); diff --git a/packages/claude-sdk/src/index.ts b/packages/claude-sdk/src/index.ts index 191658a..bea605f 100644 --- a/packages/claude-sdk/src/index.ts +++ b/packages/claude-sdk/src/index.ts @@ -1,3 +1,5 @@ +import { AnthropicAuth } from './private/Auth/AnthropicAuth'; +import type { AuthCredentials } from './private/Auth/types'; import { calculateCost } from './private/pricing'; import { createAnthropicAgent } from './public/createAnthropicAgent'; import { defineTool } from './public/defineTool'; @@ -6,6 +8,5 @@ import { IAnthropicAgent } from './public/interfaces'; import type { AnthropicAgentOptions, AnthropicBetaFlags, AnyToolDefinition, CacheTtl, ConsumerMessage, ILogger, RunAgentQuery, RunAgentResult, SdkDone, SdkError, SdkMessage, SdkMessageEnd, SdkMessageStart, SdkMessageText, SdkMessageUsage, SdkToolApprovalRequest, ToolDefinition, ToolOperation } from './public/types'; export type { BetaMessageParam } from '@anthropic-ai/sdk/resources/beta.js'; - -export type { AnthropicAgentOptions, AnthropicBetaFlags, AnyToolDefinition, CacheTtl, ConsumerMessage, ILogger, RunAgentQuery, RunAgentResult, SdkDone, SdkError, SdkMessage, SdkMessageEnd, SdkMessageStart, SdkMessageText, SdkMessageUsage, SdkToolApprovalRequest, ToolDefinition, ToolOperation }; -export { AnthropicBeta, calculateCost, createAnthropicAgent, defineTool, IAnthropicAgent }; +export type { AnthropicAgentOptions, AnthropicBetaFlags, AnyToolDefinition, AuthCredentials, CacheTtl, ConsumerMessage, ILogger, RunAgentQuery, RunAgentResult, SdkDone, SdkError, SdkMessage, SdkMessageEnd, SdkMessageStart, SdkMessageText, SdkMessageUsage, SdkToolApprovalRequest, ToolDefinition, ToolOperation }; +export { AnthropicAuth, AnthropicBeta, calculateCost, createAnthropicAgent, defineTool, IAnthropicAgent }; diff --git a/packages/claude-sdk/src/private/AgentRun.ts b/packages/claude-sdk/src/private/AgentRun.ts index d759edb..28046a1 100644 --- a/packages/claude-sdk/src/private/AgentRun.ts +++ b/packages/claude-sdk/src/private/AgentRun.ts @@ -150,7 +150,7 @@ export class AgentRun { context_management.edits?.push({ type: 'clear_tool_uses_20250919' } satisfies BetaClearToolUses20250919Edit); } if (betas[AnthropicBeta.Compact]) { - context_management.edits?.push({ type: 'compact_20260112', pause_after_compaction: this.#options.pauseAfterCompact ?? false, trigger: { type: 'input_tokens', value: 125000 } } satisfies BetaCompact20260112Edit); + context_management.edits?.push({ type: 'compact_20260112', pause_after_compaction: this.#options.pauseAfterCompact ?? false, trigger: { type: 'input_tokens', value: 150000 } } satisfies BetaCompact20260112Edit); } const body: BetaMessageStreamParams = { diff --git a/packages/claude-sdk/src/private/AnthropicAgent.ts b/packages/claude-sdk/src/private/AnthropicAgent.ts index 6254e2f..9f74f74 100644 --- a/packages/claude-sdk/src/private/AnthropicAgent.ts +++ b/packages/claude-sdk/src/private/AnthropicAgent.ts @@ -19,8 +19,9 @@ export class AnthropicAgent extends IAnthropicAgent { 'user-agent': `@shellicar/claude-sdk/${versionJson.version}`, }; const clientOptions = { - authToken: `${options.apiKey}`, - fetch: customFetch(options.logger), + // The SDK will error if it thinks there's no authToken + authToken: '-', + fetch: customFetch(options.logger, options.authToken), logger: options.logger, defaultHeaders, } satisfies ClientOptions; diff --git a/packages/claude-sdk/src/private/Auth/AnthropicAuth.ts b/packages/claude-sdk/src/private/Auth/AnthropicAuth.ts index e3af534..4948a6f 100644 --- a/packages/claude-sdk/src/private/Auth/AnthropicAuth.ts +++ b/packages/claude-sdk/src/private/Auth/AnthropicAuth.ts @@ -2,6 +2,7 @@ import { execFile } from 'node:child_process'; import { buildAuthUrl } from './buildAuthUrl'; import { LocalRedirectUrl, PlatformRedirectUrl } from './consts'; import { exchangeCode } from './exchangeCode'; +import { fetchProfile } from './fetchProfile'; import { isExpired } from './isExpired'; import { loadCredentials } from './loadCredentials'; import { refreshCredentials } from './refreshCredentials'; @@ -21,6 +22,8 @@ export class AnthropicAuth { if (credentials === null) { credentials = await this.login(); + const profile = await fetchProfile(credentials.claudeAiOauth.accessToken); + credentials = { claudeAiOauth: { ...credentials.claudeAiOauth, ...profile } }; await saveCredentials(credentials); } else if (isExpired(credentials)) { credentials = await refreshCredentials(credentials); diff --git a/packages/claude-sdk/src/private/Auth/consts.ts b/packages/claude-sdk/src/private/Auth/consts.ts index 5723ac0..17b0ac7 100644 --- a/packages/claude-sdk/src/private/Auth/consts.ts +++ b/packages/claude-sdk/src/private/Auth/consts.ts @@ -1,8 +1,9 @@ export const TokenUrl = 'https://platform.claude.com/v1/oauth/token'; -export const AuthorisationUrl = 'https://platform.claude.com/oauth/authorize'; +export const AuthorisationUrl = 'https://claude.com/cai/oauth/authorize'; export const PlatformRedirectUrl = 'https://platform.claude.com/oauth/code/callback'; export const LocalRedirectUrl = 'http://localhost:3001/callback'; export const ClientId = '9d1c250a-e61b-44d9-88ed-5944d1962f5e'; export const Scopes = 'user:file_upload user:inference user:mcp_servers user:profile user:sessions:claude_code'; -export const CredentialsPath = './.credentials.json'; +export const CredentialsPath = '.credentials.json'; +export const ProfileUrl = 'https://api.anthropic.com/api/oauth/profile'; diff --git a/packages/claude-sdk/src/private/Auth/credentialsPath.ts b/packages/claude-sdk/src/private/Auth/credentialsPath.ts new file mode 100644 index 0000000..4a25579 --- /dev/null +++ b/packages/claude-sdk/src/private/Auth/credentialsPath.ts @@ -0,0 +1,7 @@ +import { homedir } from 'node:os'; +import { join } from 'node:path'; +import { CredentialsPath } from './consts'; + +export function credentialsPath() { + return join(homedir(), '.claude', CredentialsPath); +} diff --git a/packages/claude-sdk/src/private/Auth/fetchProfile.ts b/packages/claude-sdk/src/private/Auth/fetchProfile.ts new file mode 100644 index 0000000..dd64a2f --- /dev/null +++ b/packages/claude-sdk/src/private/Auth/fetchProfile.ts @@ -0,0 +1,18 @@ +import { ProfileUrl } from './consts'; +import { profileResponse } from './schema'; + +export type ProfileData = { + subscriptionType: string; + rateLimitTier: string; +}; + +export const fetchProfile = async (accessToken: string): Promise => { + const response = await fetch(ProfileUrl, { + headers: { Authorization: `Bearer ${accessToken}` }, + }); + const data = profileResponse.parse(await response.json()); + return { + subscriptionType: data.organization.organization_type, + rateLimitTier: data.organization.rate_limit_tier, + } satisfies ProfileData; +}; diff --git a/packages/claude-sdk/src/private/Auth/loadCredentials.ts b/packages/claude-sdk/src/private/Auth/loadCredentials.ts index ecc62e7..8e83308 100644 --- a/packages/claude-sdk/src/private/Auth/loadCredentials.ts +++ b/packages/claude-sdk/src/private/Auth/loadCredentials.ts @@ -1,11 +1,13 @@ import { readFile } from 'node:fs/promises'; -import { CredentialsPath } from './consts'; +import { credentialsPath } from './credentialsPath'; +import { authCredentials } from './schema'; import type { AuthCredentials } from './types'; export const loadCredentials = async (): Promise => { try { - const raw = await readFile(CredentialsPath, 'utf-8'); - const parsed: AuthCredentials = JSON.parse(raw); + const path = credentialsPath(); + const raw = await readFile(path, 'utf-8'); + const parsed = authCredentials.parse(JSON.parse(raw)); return parsed; } catch { return null; diff --git a/packages/claude-sdk/src/private/Auth/parseTokenResponse.ts b/packages/claude-sdk/src/private/Auth/parseTokenResponse.ts index 8d59456..b7ad07e 100644 --- a/packages/claude-sdk/src/private/Auth/parseTokenResponse.ts +++ b/packages/claude-sdk/src/private/Auth/parseTokenResponse.ts @@ -9,6 +9,8 @@ export const parseTokenResponse = (input: unknown): AuthCredentials => { refreshToken: data.refresh_token, expiresAt: Date.now() + data.expires_in * 1000, scopes: data.scope, + subscriptionType: '', + rateLimitTier: '', }, } satisfies AuthCredentials; }; diff --git a/packages/claude-sdk/src/private/Auth/saveCredentials.ts b/packages/claude-sdk/src/private/Auth/saveCredentials.ts index fdeb2db..c935e9d 100644 --- a/packages/claude-sdk/src/private/Auth/saveCredentials.ts +++ b/packages/claude-sdk/src/private/Auth/saveCredentials.ts @@ -1,7 +1,10 @@ import { writeFile } from 'node:fs/promises'; -import { CredentialsPath } from './consts'; +import { credentialsPath } from './credentialsPath'; +import { authCredentials } from './schema'; import type { AuthCredentials } from './types'; export const saveCredentials = async (credentials: AuthCredentials): Promise => { - await writeFile(CredentialsPath, JSON.stringify(credentials, null, 2)); + const value = authCredentials.parse(credentials); + const path = credentialsPath(); + await writeFile(path, JSON.stringify(value, null, 2)); }; diff --git a/packages/claude-sdk/src/private/Auth/schema.ts b/packages/claude-sdk/src/private/Auth/schema.ts index be1236a..3ad5703 100644 --- a/packages/claude-sdk/src/private/Auth/schema.ts +++ b/packages/claude-sdk/src/private/Auth/schema.ts @@ -7,3 +7,21 @@ export const tokenResponse = z.object({ refresh_token: z.string(), scope: z.string().transform((x) => x.split(' ')), }); + +export const profileResponse = z.object({ + organization: z.object({ + organization_type: z.string().transform((x) => x.replace(/^claude_/, '')), + rate_limit_tier: z.string(), + }), +}); + +export const authCredentials = z.object({ + claudeAiOauth: z.object({ + accessToken: z.string(), + refreshToken: z.string(), + expiresAt: z.number().int(), + scopes: z.string().array(), + subscriptionType: z.string(), + rateLimitTier: z.string(), + }), +}); diff --git a/packages/claude-sdk/src/private/Auth/types.ts b/packages/claude-sdk/src/private/Auth/types.ts index 567871f..d024049 100644 --- a/packages/claude-sdk/src/private/Auth/types.ts +++ b/packages/claude-sdk/src/private/Auth/types.ts @@ -12,5 +12,7 @@ export type AuthCredentials = { refreshToken: string; expiresAt: number; scopes: string[]; + subscriptionType: string; + rateLimitTier: string; }; }; diff --git a/packages/claude-sdk/src/private/http/customFetch.ts b/packages/claude-sdk/src/private/http/customFetch.ts index bb7d279..00d3ab3 100644 --- a/packages/claude-sdk/src/private/http/customFetch.ts +++ b/packages/claude-sdk/src/private/http/customFetch.ts @@ -2,17 +2,24 @@ import type { ILogger } from '../../public/types'; import { getBody } from './getBody'; import { getHeaders } from './getHeaders'; -export const customFetch = (logger: ILogger | undefined) => { +export const customFetch = (logger: ILogger | undefined, getToken?: () => Promise) => { return async (input: string | URL | Request, init?: RequestInit) => { - const headers = getHeaders(init?.headers); - const body = getBody(init?.body, headers); + let resolvedInit = init; + if (getToken) { + const token = await getToken(); + const headers = new Headers(init?.headers); + headers.set('Authorization', `Bearer ${token}`); + resolvedInit = { ...init, headers }; + } + const headers = getHeaders(resolvedInit?.headers); + const body = getBody(resolvedInit?.body, headers); logger?.info('HTTP Request', { headers, method: init?.method, body, }); - const response = await fetch(input, init); + const response = await fetch(input, resolvedInit); const isStream = response.headers.get('content-type')?.includes('text/event-stream') ?? false; if (!isStream) { const text = await response.clone().text(); diff --git a/packages/claude-sdk/src/public/types.ts b/packages/claude-sdk/src/public/types.ts index 5e7bfdd..ef794ed 100644 --- a/packages/claude-sdk/src/public/types.ts +++ b/packages/claude-sdk/src/public/types.ts @@ -75,7 +75,7 @@ export type ILogger = { }; export type AnthropicAgentOptions = { - apiKey: string; + authToken: () => Promise; logger?: ILogger; historyFile?: string; }; From b68f073019ce6e9f08238d348dd326889afae88f Mon Sep 17 00:00:00 2001 From: Stephen Hellicar Date: Mon, 6 Apr 2026 14:25:45 +1000 Subject: [PATCH 3/9] refactor: replace customFetch token injection with TokenRefreshingAnthropic subclass MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ApiKeySetter is a type lie in @anthropic-ai/sdk@0.82.0 — the constructor discards non-string values (typeof apiKey === 'string' ? apiKey : null) so the function is never called. authToken is string-only. Instead, subclass Anthropic and override the protected bearerAuth() method which is called per-request before validateHeaders. Returns a plain Record which buildHeaders accepts via Object.entries — no imports from @anthropic-ai/sdk/internal/* needed. customFetch is now logging-only again. --- apps/claude-cli/package.json | 2 +- package.json | 2 +- .../claude-sdk/src/private/AnthropicAgent.ts | 13 ++-- .../private/http/TokenRefreshingAnthropic.ts | 32 ++++++++ .../src/private/http/customFetch.ts | 15 +--- pnpm-lock.yaml | 76 +++++++++---------- 6 files changed, 81 insertions(+), 59 deletions(-) create mode 100644 packages/claude-sdk/src/private/http/TokenRefreshingAnthropic.ts diff --git a/apps/claude-cli/package.json b/apps/claude-cli/package.json index a971f33..05e2948 100644 --- a/apps/claude-cli/package.json +++ b/apps/claude-cli/package.json @@ -38,7 +38,7 @@ "watch": "tsx build.ts --watch" }, "dependencies": { - "@anthropic-ai/claude-agent-sdk": "^0.2.90", + "@anthropic-ai/claude-agent-sdk": "^0.2.92", "@js-joda/core": "^5.7.0", "@shellicar/claude-core": "workspace:*", "@shellicar/mcp-exec": "1.0.0-preview.6", diff --git a/package.json b/package.json index 0d100a6..f8db981 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ "lefthook": "^2.1.4", "npm-check-updates": "^19.6.6", "syncpack": "^14.3.0", - "turbo": "^2.9.3", + "turbo": "^2.9.4", "vitest": "^4.1.2" } } diff --git a/packages/claude-sdk/src/private/AnthropicAgent.ts b/packages/claude-sdk/src/private/AnthropicAgent.ts index 9f74f74..b4126f1 100644 --- a/packages/claude-sdk/src/private/AnthropicAgent.ts +++ b/packages/claude-sdk/src/private/AnthropicAgent.ts @@ -1,4 +1,3 @@ -import { Anthropic, type ClientOptions } from '@anthropic-ai/sdk'; import type { BetaMessageParam } from '@anthropic-ai/sdk/resources/beta.js'; import versionJson from '@shellicar/build-version/version'; import { IAnthropicAgent } from '../public/interfaces'; @@ -6,9 +5,10 @@ import type { AnthropicAgentOptions, ILogger, RunAgentQuery, RunAgentResult } fr import { AgentRun } from './AgentRun'; import { ConversationHistory } from './ConversationHistory'; import { customFetch } from './http/customFetch'; +import { TokenRefreshingAnthropic } from './http/TokenRefreshingAnthropic'; export class AnthropicAgent extends IAnthropicAgent { - readonly #client: Anthropic; + readonly #client: TokenRefreshingAnthropic; readonly #logger: ILogger | undefined; readonly #history: ConversationHistory; @@ -18,14 +18,11 @@ export class AnthropicAgent extends IAnthropicAgent { const defaultHeaders = { 'user-agent': `@shellicar/claude-sdk/${versionJson.version}`, }; - const clientOptions = { - // The SDK will error if it thinks there's no authToken - authToken: '-', - fetch: customFetch(options.logger, options.authToken), + this.#client = new TokenRefreshingAnthropic(options.authToken, { + fetch: customFetch(options.logger), logger: options.logger, defaultHeaders, - } satisfies ClientOptions; - this.#client = new Anthropic(clientOptions); + }); this.#history = new ConversationHistory(options.historyFile); } diff --git a/packages/claude-sdk/src/private/http/TokenRefreshingAnthropic.ts b/packages/claude-sdk/src/private/http/TokenRefreshingAnthropic.ts new file mode 100644 index 0000000..a99c211 --- /dev/null +++ b/packages/claude-sdk/src/private/http/TokenRefreshingAnthropic.ts @@ -0,0 +1,32 @@ +import { Anthropic, type ClientOptions } from '@anthropic-ai/sdk'; + +/** + * Subclass of Anthropic that overrides bearerAuth to support async token refresh. + * + * The SDK types include ApiKeySetter = () => Promise but the runtime + * discards non-string values in the constructor: + * this.apiKey = typeof apiKey === 'string' ? apiKey : null; + * So ApiKeySetter is a type lie in 0.82.0 — the function is never called. + * + * bearerAuth is protected and is called per-request, before validateHeaders, + * so overriding it is the cleanest way to inject a refreshed token each call. + * We return a plain Record which buildHeaders accepts via + * Object.entries — no need to import from @anthropic-ai/sdk/internal/*. + */ +export class TokenRefreshingAnthropic extends Anthropic { + readonly #getToken: () => Promise; + + public constructor(getToken: () => Promise, opts?: Omit) { + // Explicitly null both auth fields so the SDK doesn't read from env vars. + // validateHeaders will see the Authorization header we inject in bearerAuth + // (authHeaders runs before validateHeaders in the request pipeline). + super({ ...opts, apiKey: null, authToken: null }); + this.#getToken = getToken; + } + + // biome-ignore lint/suspicious/noExplicitAny: overriding SDK internal method; types not exported + protected override async bearerAuth(_opts: any): Promise { + const token = await this.#getToken(); + return { Authorization: `Bearer ${token}` }; + } +} diff --git a/packages/claude-sdk/src/private/http/customFetch.ts b/packages/claude-sdk/src/private/http/customFetch.ts index 00d3ab3..bb7d279 100644 --- a/packages/claude-sdk/src/private/http/customFetch.ts +++ b/packages/claude-sdk/src/private/http/customFetch.ts @@ -2,24 +2,17 @@ import type { ILogger } from '../../public/types'; import { getBody } from './getBody'; import { getHeaders } from './getHeaders'; -export const customFetch = (logger: ILogger | undefined, getToken?: () => Promise) => { +export const customFetch = (logger: ILogger | undefined) => { return async (input: string | URL | Request, init?: RequestInit) => { - let resolvedInit = init; - if (getToken) { - const token = await getToken(); - const headers = new Headers(init?.headers); - headers.set('Authorization', `Bearer ${token}`); - resolvedInit = { ...init, headers }; - } - const headers = getHeaders(resolvedInit?.headers); - const body = getBody(resolvedInit?.body, headers); + const headers = getHeaders(init?.headers); + const body = getBody(init?.body, headers); logger?.info('HTTP Request', { headers, method: init?.method, body, }); - const response = await fetch(input, resolvedInit); + const response = await fetch(input, init); const isStream = response.headers.get('content-type')?.includes('text/event-stream') ?? false; if (!isStream) { const text = await response.clone().text(); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b09de43..37c80c9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -34,8 +34,8 @@ importers: specifier: ^14.3.0 version: 14.3.0 turbo: - specifier: ^2.9.3 - version: 2.9.3 + specifier: ^2.9.4 + version: 2.9.4 vitest: specifier: ^4.1.2 version: 4.1.2(@types/node@25.5.0)(vite@6.4.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3)) @@ -43,8 +43,8 @@ importers: apps/claude-cli: dependencies: '@anthropic-ai/claude-agent-sdk': - specifier: ^0.2.90 - version: 0.2.90(zod@4.3.6) + specifier: ^0.2.92 + version: 0.2.92(zod@4.3.6) '@js-joda/core': specifier: ^5.7.0 version: 5.7.0 @@ -272,14 +272,14 @@ importers: packages: - '@anthropic-ai/claude-agent-sdk@0.2.90': - resolution: {integrity: sha512-up5bK0pUbthKIZtNE18WDrIYi0KNpZUhdgjGbkfH/mFQJxI6W/uE3mTiLrCX3UF0SqNl0fMtojBTZPJr2b3O4g==} + '@anthropic-ai/claude-agent-sdk@0.2.92': + resolution: {integrity: sha512-loYyxVUC5gBwHjGi9Fv0b84mduJTp9Z3Pum+y/7IVQDb4NynKfVQl6l4VeDKZaW+1QTQtd25tY4hwUznD7Krqw==} engines: {node: '>=18.0.0'} peerDependencies: zod: ^4.0.0 - '@anthropic-ai/sdk@0.74.0': - resolution: {integrity: sha512-srbJV7JKsc5cQ6eVuFzjZO7UR3xEPJqPamHFIe29bs38Ij2IripoAhC0S5NslNbaFUYqBKypmmpzMTpqfHEUDw==} + '@anthropic-ai/sdk@0.80.0': + resolution: {integrity: sha512-WeXLn7zNVk3yjeshn+xZHvld6AoFUOR3Sep6pSoHho5YbSi6HwcirqgPA5ccFuW8QTVJAAU7N8uQQC6Wa9TG+g==} hasBin: true peerDependencies: zod: ^3.25.0 || ^4.0.0 @@ -1233,33 +1233,33 @@ packages: '@tsconfig/node24@24.0.4': resolution: {integrity: sha512-2A933l5P5oCbv6qSxHs7ckKwobs8BDAe9SJ/Xr2Hy+nDlwmLE1GhFh/g/vXGRZWgxBg9nX/5piDtHR9Dkw/XuA==} - '@turbo/darwin-64@2.9.3': - resolution: {integrity: sha512-P8foouaP+y/p+hhEGBoZpzMbpVvUMwPjDpcy6wN7EYfvvyISD1USuV27qWkczecihwuPJzQ1lDBuL8ERcavTyg==} + '@turbo/darwin-64@2.9.4': + resolution: {integrity: sha512-ZSlPqJ5Vqg/wgVw8P3AOVCIosnbBilOxLq7TMz3MN/9U46DUYfdG2jtfevNDufyxyrg98pcPs/GBgDRaaids6g==} cpu: [x64] os: [darwin] - '@turbo/darwin-arm64@2.9.3': - resolution: {integrity: sha512-SIzEkvtNdzdI50FJDaIQ6kQGqgSSdFPcdn0wqmmONN6iGKjy6hsT+EH99GP65FsfV7DLZTh2NmtTIRl2kdoz5Q==} + '@turbo/darwin-arm64@2.9.4': + resolution: {integrity: sha512-9cjTWe4OiNlFMSRggPNh+TJlRs7MS5FWrHc96MOzft5vESWjjpvaadYPv5ykDW7b45mVHOF2U/W+48LoX9USWw==} cpu: [arm64] os: [darwin] - '@turbo/linux-64@2.9.3': - resolution: {integrity: sha512-pLRwFmcHHNBvsCySLS6OFabr/07kDT2pxEt/k6eBf/3asiVQZKJ7Rk88AafQx2aYA641qek4RsXvYO3JYpiBug==} + '@turbo/linux-64@2.9.4': + resolution: {integrity: sha512-Cl1GjxqBXQ+r9KKowmXG+lhD1gclLp48/SE7NxL//66iaMytRw0uiphWGOkccD92iPiRjHLRUaA9lOTtgr5OCA==} cpu: [x64] os: [linux] - '@turbo/linux-arm64@2.9.3': - resolution: {integrity: sha512-gy6ApUroC2Nzv+qjGtE/uPNkhHAFU4c8God+zd5Aiv9L9uBgHlxVJpHT3XWl5xwlJZ2KWuMrlHTaS5kmNB+q1Q==} + '@turbo/linux-arm64@2.9.4': + resolution: {integrity: sha512-j2hPAKVmGNN2EsKigEWD+43y9m7zaPhNAs6ptsyfq0u7evHHBAXAwOfv86OEMg/gvC+pwGip0i1CIm1bR1vYug==} cpu: [arm64] os: [linux] - '@turbo/windows-64@2.9.3': - resolution: {integrity: sha512-d0YelTX6hAsB7kIEtGB3PzIzSfAg3yDoUlHwuwJc3adBXUsyUIs0YLG+1NNtuhcDOUGnWQeKUoJ2pGWvbpRj7w==} + '@turbo/windows-64@2.9.4': + resolution: {integrity: sha512-1jWPjCe9ZRmsDTXE7uzqfySNQspnUx0g6caqvwps+k/sc+fm9hC/4zRQKlXZLbVmP3Xxp601Ju71boegHdnYGw==} cpu: [x64] os: [win32] - '@turbo/windows-arm64@2.9.3': - resolution: {integrity: sha512-/08CwpKJl3oRY8nOlh2YgilZVJDHsr60XTNxRhuDeuFXONpUZ5X+Nv65izbG/xBew9qxcJFbDX9/sAmAX+ITcQ==} + '@turbo/windows-arm64@2.9.4': + resolution: {integrity: sha512-dlko15TQVu/BFYmIY018Y3covWMRQlUgAkD+OOk+Rokcfj6VY02Vv4mCfT/Zns6B4q8jGbOd6IZhnCFYsE8Viw==} cpu: [arm64] os: [win32] @@ -2264,8 +2264,8 @@ packages: engines: {node: '>=18.0.0'} hasBin: true - turbo@2.9.3: - resolution: {integrity: sha512-J/VUvsGRykPb9R8Kh8dHVBOqioDexLk9BhLCU/ZybRR+HN9UR3cURdazFvNgMDt9zPP8TF6K73Z+tplfmi0PqQ==} + turbo@2.9.4: + resolution: {integrity: sha512-wZ/kMcZCuK5oEp7sXSSo/5fzKjP9I2EhoiarZjyCm2Ixk0WxFrC/h0gF3686eHHINoFQOOSWgB/pGfvkR8rkgQ==} hasBin: true type-is@2.0.1: @@ -2442,9 +2442,9 @@ packages: snapshots: - '@anthropic-ai/claude-agent-sdk@0.2.90(zod@4.3.6)': + '@anthropic-ai/claude-agent-sdk@0.2.92(zod@4.3.6)': dependencies: - '@anthropic-ai/sdk': 0.74.0(zod@4.3.6) + '@anthropic-ai/sdk': 0.80.0(zod@4.3.6) '@modelcontextprotocol/sdk': 1.29.0(zod@4.3.6) zod: 4.3.6 optionalDependencies: @@ -2461,7 +2461,7 @@ snapshots: - '@cfworker/json-schema' - supports-color - '@anthropic-ai/sdk@0.74.0(zod@4.3.6)': + '@anthropic-ai/sdk@0.80.0(zod@4.3.6)': dependencies: json-schema-to-ts: 3.1.1 optionalDependencies: @@ -3046,22 +3046,22 @@ snapshots: '@tsconfig/node24@24.0.4': {} - '@turbo/darwin-64@2.9.3': + '@turbo/darwin-64@2.9.4': optional: true - '@turbo/darwin-arm64@2.9.3': + '@turbo/darwin-arm64@2.9.4': optional: true - '@turbo/linux-64@2.9.3': + '@turbo/linux-64@2.9.4': optional: true - '@turbo/linux-arm64@2.9.3': + '@turbo/linux-arm64@2.9.4': optional: true - '@turbo/windows-64@2.9.3': + '@turbo/windows-64@2.9.4': optional: true - '@turbo/windows-arm64@2.9.3': + '@turbo/windows-arm64@2.9.4': optional: true '@tybys/wasm-util@0.10.1': @@ -4142,14 +4142,14 @@ snapshots: optionalDependencies: fsevents: 2.3.3 - turbo@2.9.3: + turbo@2.9.4: optionalDependencies: - '@turbo/darwin-64': 2.9.3 - '@turbo/darwin-arm64': 2.9.3 - '@turbo/linux-64': 2.9.3 - '@turbo/linux-arm64': 2.9.3 - '@turbo/windows-64': 2.9.3 - '@turbo/windows-arm64': 2.9.3 + '@turbo/darwin-64': 2.9.4 + '@turbo/darwin-arm64': 2.9.4 + '@turbo/linux-64': 2.9.4 + '@turbo/linux-arm64': 2.9.4 + '@turbo/windows-64': 2.9.4 + '@turbo/windows-arm64': 2.9.4 type-is@2.0.1: dependencies: From 33cf2868896eac78e6ceaad8ac7b968f7dba7b33 Mon Sep 17 00:00:00 2001 From: Stephen Hellicar Date: Mon, 6 Apr 2026 14:47:32 +1000 Subject: [PATCH 4/9] refactor: split SDK internal re-implementations into sdkInternals.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirror of @anthropic-ai/sdk/internal/* types and buildHeaders — those paths are not in the package exports map so they cannot be imported directly. sdkInternals.ts is intentionally written to match SDK source conventions not our own; linting is disabled on it via biome.json overrides. TokenRefreshingAnthropic.ts imports from sdkInternals and stays clean. --- biome.json | 8 +- .../claude-sdk/src/private/AnthropicAgent.ts | 3 +- .../private/http/TokenRefreshingAnthropic.ts | 67 ++++++--- .../src/private/http/sdkInternals.ts | 135 ++++++++++++++++++ 4 files changed, 191 insertions(+), 22 deletions(-) create mode 100644 packages/claude-sdk/src/private/http/sdkInternals.ts diff --git a/biome.json b/biome.json index 4fd9854..66317df 100644 --- a/biome.json +++ b/biome.json @@ -7,7 +7,7 @@ }, "files": { "ignoreUnknown": false, - "includes": ["**", "!!**/dist", "!.claude-bot-audit", "!**/schema/cli-config.schema.json"] + "includes": ["**", "!!**/dist", "!.claude-bot-audit", "!**/schema/cli-config.schema.json", "!packages/claude-sdk/src/private/http/sdkInternals.ts"] }, "formatter": { "enabled": true, @@ -70,6 +70,12 @@ } }, "overrides": [ + { + "includes": ["**/sdkInternals.ts"], + "linter": { + "enabled": false + } + }, { "includes": ["**/logger.ts", "**/build.ts"], "linter": { diff --git a/packages/claude-sdk/src/private/AnthropicAgent.ts b/packages/claude-sdk/src/private/AnthropicAgent.ts index b4126f1..fa93c27 100644 --- a/packages/claude-sdk/src/private/AnthropicAgent.ts +++ b/packages/claude-sdk/src/private/AnthropicAgent.ts @@ -18,7 +18,8 @@ export class AnthropicAgent extends IAnthropicAgent { const defaultHeaders = { 'user-agent': `@shellicar/claude-sdk/${versionJson.version}`, }; - this.#client = new TokenRefreshingAnthropic(options.authToken, { + this.#client = new TokenRefreshingAnthropic({ + authToken: options.authToken, fetch: customFetch(options.logger), logger: options.logger, defaultHeaders, diff --git a/packages/claude-sdk/src/private/http/TokenRefreshingAnthropic.ts b/packages/claude-sdk/src/private/http/TokenRefreshingAnthropic.ts index a99c211..855944e 100644 --- a/packages/claude-sdk/src/private/http/TokenRefreshingAnthropic.ts +++ b/packages/claude-sdk/src/private/http/TokenRefreshingAnthropic.ts @@ -1,32 +1,59 @@ import { Anthropic, type ClientOptions } from '@anthropic-ai/sdk'; +import { buildHeaders, type FinalRequestOptions, type NullableHeaders } from './sdkInternals'; /** - * Subclass of Anthropic that overrides bearerAuth to support async token refresh. + * Extended ClientOptions that allows apiKey and authToken to be async getter + * functions in addition to static strings. * - * The SDK types include ApiKeySetter = () => Promise but the runtime - * discards non-string values in the constructor: - * this.apiKey = typeof apiKey === 'string' ? apiKey : null; - * So ApiKeySetter is a type lie in 0.82.0 — the function is never called. + * - apiKey getter → called in apiKeyAuth(), sets X-Api-Key per request + * - authToken getter → called in bearerAuth(), sets Authorization: Bearer per request * - * bearerAuth is protected and is called per-request, before validateHeaders, - * so overriding it is the cleanest way to inject a refreshed token each call. - * We return a plain Record which buildHeaders accepts via - * Object.entries — no need to import from @anthropic-ai/sdk/internal/*. + * ClientOptions.apiKey already declares ApiKeySetter = () => Promise in + * its type, but the SDK constructor discards non-string values at runtime: + * this.apiKey = typeof apiKey === 'string' ? apiKey : null + * We capture the getter before super() silently drops it. + * + * ClientOptions.authToken is string-only; we extend it here to also accept a getter. + */ +export type TokenRefreshingClientOptions = Omit & { + authToken?: string | (() => Promise) | null; +}; + +/** + * Subclass of Anthropic that properly implements ApiKeySetter and adds getter + * support for authToken. Both auth methods are called per-request so tokens + * are always fresh. */ export class TokenRefreshingAnthropic extends Anthropic { - readonly #getToken: () => Promise; + readonly #apiKeyGetter: (() => Promise) | undefined; + readonly #authTokenGetter: (() => Promise) | undefined; + + public constructor(opts: TokenRefreshingClientOptions) { + const { apiKey, authToken, ...rest } = opts; + // Pass static strings through to super as-is; pass null for functions so + // the SDK doesn't read env vars and doesn't try to use the discarded value. + super({ + ...rest, + apiKey: typeof apiKey === 'string' ? apiKey : null, + authToken: typeof authToken === 'string' ? authToken : null, + }); + this.#apiKeyGetter = typeof apiKey === 'function' ? apiKey : undefined; + this.#authTokenGetter = typeof authToken === 'function' ? authToken : undefined; + } - public constructor(getToken: () => Promise, opts?: Omit) { - // Explicitly null both auth fields so the SDK doesn't read from env vars. - // validateHeaders will see the Authorization header we inject in bearerAuth - // (authHeaders runs before validateHeaders in the request pipeline). - super({ ...opts, apiKey: null, authToken: null }); - this.#getToken = getToken; + protected override async apiKeyAuth(_opts: FinalRequestOptions): Promise { + if (this.#apiKeyGetter != null) { + const token = await this.#apiKeyGetter(); + return buildHeaders([{ 'X-Api-Key': token }]); + } + return super.apiKeyAuth(_opts); } - // biome-ignore lint/suspicious/noExplicitAny: overriding SDK internal method; types not exported - protected override async bearerAuth(_opts: any): Promise { - const token = await this.#getToken(); - return { Authorization: `Bearer ${token}` }; + protected override async bearerAuth(_opts: FinalRequestOptions): Promise { + if (this.#authTokenGetter != null) { + const token = await this.#authTokenGetter(); + return buildHeaders([{ Authorization: `Bearer ${token}` }]); + } + return super.bearerAuth(_opts); } } diff --git a/packages/claude-sdk/src/private/http/sdkInternals.ts b/packages/claude-sdk/src/private/http/sdkInternals.ts new file mode 100644 index 0000000..ec606b6 --- /dev/null +++ b/packages/claude-sdk/src/private/http/sdkInternals.ts @@ -0,0 +1,135 @@ +/** + * Re-implementations of @anthropic-ai/sdk/internal/* types and utilities. + * + * Those paths are not included in the package exports map and cannot be + * imported directly. We mirror them structurally so our subclass overrides + * satisfy the same contract as the SDK's own protected methods. + * + * Linting is disabled on this file (see biome.json overrides) — it is + * intentionally written to match the SDK source, not our own conventions. + */ + +import type { Stream } from '@anthropic-ai/sdk/core/streaming'; + +// --------------------------------------------------------------------------- +// NullableHeaders (mirrors @anthropic-ai/sdk/internal/headers) +// --------------------------------------------------------------------------- + +export const brand_privateNullableHeaders = Symbol.for('brand.privateNullableHeaders') as symbol & { + description: 'brand.privateNullableHeaders'; +}; + +export type NullableHeaders = { + [_: typeof brand_privateNullableHeaders]: true; + values: Headers; + nulls: Set; +}; + +type HeaderValue = string | undefined | null; + +export type HeadersLike = + | Headers + | readonly HeaderValue[][] + | Record + | undefined + | null + | NullableHeaders; + +// --------------------------------------------------------------------------- +// MergedRequestInit (mirrors @anthropic-ai/sdk/internal/types) +// The SDK definition is a union of platform-specific RequestInit variants. +// The meaningful constraint is that body/headers/method/signal are excluded. +// --------------------------------------------------------------------------- + +export type MergedRequestInit = Omit; + +// --------------------------------------------------------------------------- +// RequestOptions / FinalRequestOptions (mirrors @anthropic-ai/sdk/internal/request-options) +// --------------------------------------------------------------------------- + +export type HTTPMethod = 'get' | 'post' | 'put' | 'patch' | 'delete'; + +export type RequestOptions = { + method?: HTTPMethod; + path?: string; + query?: object | undefined | null; + body?: unknown; + headers?: HeadersLike; + maxRetries?: number; + stream?: boolean | undefined; + timeout?: number; + fetchOptions?: MergedRequestInit; + signal?: AbortSignal | undefined | null; + idempotencyKey?: string; + defaultBaseURL?: string | undefined; + __binaryResponse?: boolean | undefined; + __streamClass?: typeof Stream; +}; + +export type FinalRequestOptions = RequestOptions & { method: HTTPMethod; path: string }; + +// --------------------------------------------------------------------------- +// buildHeaders (mirrors @anthropic-ai/sdk/internal/headers) +// --------------------------------------------------------------------------- + +const isReadonlyArray = Array.isArray as (val: unknown) => val is readonly unknown[]; + +function* iterateHeaders(headers: HeadersLike): IterableIterator { + if (!headers) return; + + if (brand_privateNullableHeaders in headers) { + const { values, nulls } = headers as NullableHeaders; + yield* values.entries(); + for (const name of nulls) yield [name, null]; + return; + } + + let shouldClear = false; + let iter: Iterable; + if (headers instanceof Headers) { + iter = headers.entries(); + } else if (isReadonlyArray(headers)) { + iter = headers; + } else { + shouldClear = true; + iter = Object.entries(headers ?? {}); + } + + for (const row of iter) { + const name = row[0]; + if (typeof name !== 'string') throw new TypeError('expected header name to be a string'); + const values = isReadonlyArray(row[1]) ? row[1] : [row[1]]; + let didClear = false; + for (const value of values) { + if (value === undefined) continue; + if (shouldClear && !didClear) { + didClear = true; + yield [name, null]; + } + yield [name, value]; + } + } +} + +export const buildHeaders = (newHeaders: HeadersLike[]): NullableHeaders => { + const targetHeaders = new Headers(); + const nullHeaders = new Set(); + for (const headers of newHeaders) { + const seenHeaders = new Set(); + for (const [name, value] of iterateHeaders(headers)) { + const lowerName = name.toLowerCase(); + if (!seenHeaders.has(lowerName)) { + targetHeaders.delete(name); + seenHeaders.add(lowerName); + } + if (value === null) { + targetHeaders.delete(name); + nullHeaders.add(lowerName); + } else { + targetHeaders.append(name, value); + nullHeaders.delete(lowerName); + } + } + } + return { [brand_privateNullableHeaders]: true, values: targetHeaders, nulls: nullHeaders }; +}; From b2b3a65223b4ea0062005d69eb64a0b85a279153 Mon Sep 17 00:00:00 2001 From: Stephen Hellicar Date: Mon, 6 Apr 2026 14:51:09 +1000 Subject: [PATCH 5/9] docs: note SDK version and files to verify on upgrade in sdkInternals.ts --- packages/claude-sdk/src/private/http/sdkInternals.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/claude-sdk/src/private/http/sdkInternals.ts b/packages/claude-sdk/src/private/http/sdkInternals.ts index ec606b6..764f696 100644 --- a/packages/claude-sdk/src/private/http/sdkInternals.ts +++ b/packages/claude-sdk/src/private/http/sdkInternals.ts @@ -1,10 +1,16 @@ /** * Re-implementations of @anthropic-ai/sdk/internal/* types and utilities. + * Mirrored from @anthropic-ai/sdk@0.82.0. * * Those paths are not included in the package exports map and cannot be * imported directly. We mirror them structurally so our subclass overrides * satisfy the same contract as the SDK's own protected methods. * + * If the SDK version is upgraded, verify these against the new source: + * node_modules/@anthropic-ai/sdk/internal/headers.js + * node_modules/@anthropic-ai/sdk/internal/request-options.d.ts + * node_modules/@anthropic-ai/sdk/internal/types.d.ts + * * Linting is disabled on this file (see biome.json overrides) — it is * intentionally written to match the SDK source, not our own conventions. */ From eb70dc45fed1902d39e0fdc8785379f32763a71f Mon Sep 17 00:00:00 2001 From: Stephen Hellicar Date: Mon, 6 Apr 2026 15:09:24 +1000 Subject: [PATCH 6/9] Add input tokens option --- apps/claude-sdk-cli/src/runAgent.ts | 1 + packages/claude-sdk/src/private/AgentRun.ts | 11 ++++++++++- packages/claude-sdk/src/public/types.ts | 1 + 3 files changed, 12 insertions(+), 1 deletion(-) diff --git a/apps/claude-sdk-cli/src/runAgent.ts b/apps/claude-sdk-cli/src/runAgent.ts index aa2dddb..bb21a39 100644 --- a/apps/claude-sdk-cli/src/runAgent.ts +++ b/apps/claude-sdk-cli/src/runAgent.ts @@ -115,6 +115,7 @@ export async function runAgent(agent: IAnthropicAgent, prompt: string, layout: A messages: [prompt], transformToolResult, pauseAfterCompact: true, + compactInputTokens: 150_000, tools, requireToolApproval: true, thinking: true, diff --git a/packages/claude-sdk/src/private/AgentRun.ts b/packages/claude-sdk/src/private/AgentRun.ts index 28046a1..280f5e7 100644 --- a/packages/claude-sdk/src/private/AgentRun.ts +++ b/packages/claude-sdk/src/private/AgentRun.ts @@ -150,7 +150,16 @@ export class AgentRun { context_management.edits?.push({ type: 'clear_tool_uses_20250919' } satisfies BetaClearToolUses20250919Edit); } if (betas[AnthropicBeta.Compact]) { - context_management.edits?.push({ type: 'compact_20260112', pause_after_compaction: this.#options.pauseAfterCompact ?? false, trigger: { type: 'input_tokens', value: 150000 } } satisfies BetaCompact20260112Edit); + context_management.edits?.push({ + type: 'compact_20260112', + pause_after_compaction: this.#options.pauseAfterCompact ?? false, + trigger: this.#options.compactInputTokens + ? { + type: 'input_tokens', + value: this.#options.compactInputTokens, + } + : null, + } satisfies BetaCompact20260112Edit); } const body: BetaMessageStreamParams = { diff --git a/packages/claude-sdk/src/public/types.ts b/packages/claude-sdk/src/public/types.ts index ef794ed..8515292 100644 --- a/packages/claude-sdk/src/public/types.ts +++ b/packages/claude-sdk/src/public/types.ts @@ -36,6 +36,7 @@ export type RunAgentQuery = { betas?: AnthropicBetaFlags; requireToolApproval?: boolean; pauseAfterCompact?: boolean; + compactInputTokens?: number; cacheTtl?: CacheTtl; /** Called with the raw tool output (pre-serialisation). Return value is serialised and stored in history. Use to ref-swap large values before they enter the context window. */ transformToolResult?: (toolName: string, output: unknown) => unknown; From ac3584de6cfaff5386302dbda859a6f7388170f6 Mon Sep 17 00:00:00 2001 From: Stephen Hellicar Date: Mon, 6 Apr 2026 15:13:05 +1000 Subject: [PATCH 7/9] Fix for Agent SDK using vuln messages API SDK --- pnpm-lock.yaml | 18 ++---------------- pnpm-workspace.yaml | 1 + 2 files changed, 3 insertions(+), 16 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 37c80c9..3b26c9c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5,6 +5,7 @@ settings: excludeLinksFromLockfile: false overrides: + '@anthropic-ai/sdk@>=0.79.0 <0.81.0': '>=0.81.0' minimatch@>=10.0.0 <10.2.3: '>=10.2.3' picomatch@<2.3.2: '>=2.3.2' picomatch@>=4.0.0 <4.0.4: '>=4.0.4' @@ -278,15 +279,6 @@ packages: peerDependencies: zod: ^4.0.0 - '@anthropic-ai/sdk@0.80.0': - resolution: {integrity: sha512-WeXLn7zNVk3yjeshn+xZHvld6AoFUOR3Sep6pSoHho5YbSi6HwcirqgPA5ccFuW8QTVJAAU7N8uQQC6Wa9TG+g==} - hasBin: true - peerDependencies: - zod: ^3.25.0 || ^4.0.0 - peerDependenciesMeta: - zod: - optional: true - '@anthropic-ai/sdk@0.82.0': resolution: {integrity: sha512-xdHTjL1GlUlDugHq/I47qdOKp/ROPvuHl7ROJCgUQigbvPu7asf9KcAcU1EqdrP2LuVhEKaTs7Z+ShpZDRzHdQ==} hasBin: true @@ -2444,7 +2436,7 @@ snapshots: '@anthropic-ai/claude-agent-sdk@0.2.92(zod@4.3.6)': dependencies: - '@anthropic-ai/sdk': 0.80.0(zod@4.3.6) + '@anthropic-ai/sdk': 0.82.0(zod@4.3.6) '@modelcontextprotocol/sdk': 1.29.0(zod@4.3.6) zod: 4.3.6 optionalDependencies: @@ -2461,12 +2453,6 @@ snapshots: - '@cfworker/json-schema' - supports-color - '@anthropic-ai/sdk@0.80.0(zod@4.3.6)': - dependencies: - json-schema-to-ts: 3.1.1 - optionalDependencies: - zod: 4.3.6 - '@anthropic-ai/sdk@0.82.0(zod@4.3.6)': dependencies: json-schema-to-ts: 3.1.1 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index d1479ca..5569a42 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -6,6 +6,7 @@ onlyBuiltDependencies: - sharp overrides: + '@anthropic-ai/sdk@>=0.79.0 <0.81.0': '>=0.81.0' minimatch@>=10.0.0 <10.2.3: '>=10.2.3' picomatch@<2.3.2: '>=2.3.2' picomatch@>=4.0.0 <4.0.4: '>=4.0.4' From b30eba3e0e2307fb932ecaf7ac34c7668e06fc91 Mon Sep 17 00:00:00 2001 From: Stephen Hellicar Date: Mon, 6 Apr 2026 15:24:42 +1000 Subject: [PATCH 8/9] Very slow ci --- apps/claude-cli/test/terminal-perf.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/claude-cli/test/terminal-perf.spec.ts b/apps/claude-cli/test/terminal-perf.spec.ts index f495655..5df7d93 100644 --- a/apps/claude-cli/test/terminal-perf.spec.ts +++ b/apps/claude-cli/test/terminal-perf.spec.ts @@ -82,7 +82,7 @@ describe('Terminal wrapping cache', () => { const end = process.hrtime.bigint(); const actual = Number(end - start) / 1_000_000; - const expected = process.env.CI ? 20 : 2; + const expected = process.env.CI ? 25 : 2; expect(actual).toBeLessThan(expected); }); From 6ff9ed9658eb4079ba074793af01292e406d60f5 Mon Sep 17 00:00:00 2001 From: Stephen Hellicar Date: Mon, 6 Apr 2026 15:25:31 +1000 Subject: [PATCH 9/9] Yay performance tests --- apps/claude-cli/test/terminal-perf.spec.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/claude-cli/test/terminal-perf.spec.ts b/apps/claude-cli/test/terminal-perf.spec.ts index 5df7d93..83f37e7 100644 --- a/apps/claude-cli/test/terminal-perf.spec.ts +++ b/apps/claude-cli/test/terminal-perf.spec.ts @@ -106,6 +106,7 @@ describe('Terminal wrapping cache', () => { const end = process.hrtime.bigint(); const elapsedMs = Number(end - start) / 1_000_000; - expect(elapsedMs).toBeLessThan(1); + const expected = process.env.CI ? 5 : 1; + expect(elapsedMs).toBeLessThan(expected); }); });