|
| 1 | +import type { Hooks, PluginInput } from "@altimate/cli-plugin" |
| 2 | +import { generatePKCE } from "@openauthjs/openauth/pkce" |
| 3 | +import { Auth, OAUTH_DUMMY_KEY } from "@/auth" |
| 4 | + |
| 5 | +const CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e" |
| 6 | +const TOOL_PREFIX = "mcp_" |
| 7 | + |
| 8 | +async function authorize(mode: "max" | "console"): Promise<{ url: string; verifier: string }> { |
| 9 | + const pkce = await generatePKCE() |
| 10 | + const base = mode === "console" ? "console.anthropic.com" : "claude.ai" |
| 11 | + const url = new URL(`https://${base}/oauth/authorize`) |
| 12 | + url.searchParams.set("code", "true") |
| 13 | + url.searchParams.set("client_id", CLIENT_ID) |
| 14 | + url.searchParams.set("response_type", "code") |
| 15 | + url.searchParams.set("redirect_uri", "https://console.anthropic.com/oauth/code/callback") |
| 16 | + url.searchParams.set("scope", "org:create_api_key user:profile user:inference") |
| 17 | + url.searchParams.set("code_challenge", pkce.challenge) |
| 18 | + url.searchParams.set("code_challenge_method", "S256") |
| 19 | + url.searchParams.set("state", pkce.verifier) |
| 20 | + return { url: url.toString(), verifier: pkce.verifier } |
| 21 | +} |
| 22 | + |
| 23 | +interface TokenResponse { |
| 24 | + access_token: string |
| 25 | + refresh_token: string |
| 26 | + expires_in: number |
| 27 | +} |
| 28 | + |
| 29 | +async function exchange(code: string, verifier: string) { |
| 30 | + const splits = code.split("#") |
| 31 | + const result = await fetch("https://console.anthropic.com/v1/oauth/token", { |
| 32 | + method: "POST", |
| 33 | + headers: { "Content-Type": "application/json" }, |
| 34 | + body: JSON.stringify({ |
| 35 | + code: splits[0], |
| 36 | + state: splits[1], |
| 37 | + grant_type: "authorization_code", |
| 38 | + client_id: CLIENT_ID, |
| 39 | + redirect_uri: "https://console.anthropic.com/oauth/code/callback", |
| 40 | + code_verifier: verifier, |
| 41 | + }), |
| 42 | + }) |
| 43 | + if (!result.ok) return { type: "failed" as const } |
| 44 | + const json: TokenResponse = await result.json() |
| 45 | + return { |
| 46 | + type: "success" as const, |
| 47 | + refresh: json.refresh_token, |
| 48 | + access: json.access_token, |
| 49 | + expires: Date.now() + json.expires_in * 1000, |
| 50 | + } |
| 51 | +} |
| 52 | + |
| 53 | +export async function AnthropicAuthPlugin(input: PluginInput): Promise<Hooks> { |
| 54 | + return { |
| 55 | + "experimental.chat.system.transform": (hookInput, output) => { |
| 56 | + const prefix = "You are Claude Code, Anthropic's official CLI for Claude." |
| 57 | + if (hookInput.model?.providerID === "anthropic") { |
| 58 | + output.system.unshift(prefix) |
| 59 | + if (output.system[1]) output.system[1] = prefix + "\n\n" + output.system[1] |
| 60 | + } |
| 61 | + }, |
| 62 | + auth: { |
| 63 | + provider: "anthropic", |
| 64 | + async loader(getAuth, provider) { |
| 65 | + const auth = await getAuth() |
| 66 | + if (auth.type !== "oauth") return {} |
| 67 | + |
| 68 | + // Zero out costs for Pro/Max subscription |
| 69 | + for (const model of Object.values(provider.models)) { |
| 70 | + model.cost = { input: 0, output: 0, cache: { read: 0, write: 0 } } |
| 71 | + } |
| 72 | + |
| 73 | + return { |
| 74 | + apiKey: OAUTH_DUMMY_KEY, |
| 75 | + async fetch(requestInput: RequestInfo | URL, init?: RequestInit) { |
| 76 | + const currentAuth = await getAuth() |
| 77 | + if (currentAuth.type !== "oauth") return fetch(requestInput, init) |
| 78 | + |
| 79 | + // Refresh token if expired |
| 80 | + if (!currentAuth.access || currentAuth.expires < Date.now()) { |
| 81 | + const response = await fetch("https://console.anthropic.com/v1/oauth/token", { |
| 82 | + method: "POST", |
| 83 | + headers: { "Content-Type": "application/json" }, |
| 84 | + body: JSON.stringify({ |
| 85 | + grant_type: "refresh_token", |
| 86 | + refresh_token: currentAuth.refresh, |
| 87 | + client_id: CLIENT_ID, |
| 88 | + }), |
| 89 | + }) |
| 90 | + if (!response.ok) throw new Error(`Token refresh failed: ${response.status}`) |
| 91 | + const json: TokenResponse = await response.json() |
| 92 | + await input.client.auth.set({ |
| 93 | + path: { id: "anthropic" }, |
| 94 | + body: { |
| 95 | + type: "oauth", |
| 96 | + refresh: json.refresh_token, |
| 97 | + access: json.access_token, |
| 98 | + expires: Date.now() + json.expires_in * 1000, |
| 99 | + }, |
| 100 | + }) |
| 101 | + currentAuth.access = json.access_token |
| 102 | + } |
| 103 | + |
| 104 | + // Build headers from incoming request |
| 105 | + const requestHeaders = new Headers() |
| 106 | + if (requestInput instanceof Request) { |
| 107 | + requestInput.headers.forEach((value, key) => requestHeaders.set(key, value)) |
| 108 | + } |
| 109 | + const requestInit = init ?? {} |
| 110 | + if (requestInit.headers) { |
| 111 | + if (requestInit.headers instanceof Headers) { |
| 112 | + requestInit.headers.forEach((value, key) => requestHeaders.set(key, value)) |
| 113 | + } else if (Array.isArray(requestInit.headers)) { |
| 114 | + for (const [key, value] of requestInit.headers) { |
| 115 | + if (value !== undefined) requestHeaders.set(key, String(value)) |
| 116 | + } |
| 117 | + } else { |
| 118 | + for (const [key, value] of Object.entries(requestInit.headers)) { |
| 119 | + if (value !== undefined) requestHeaders.set(key, String(value)) |
| 120 | + } |
| 121 | + } |
| 122 | + } |
| 123 | + |
| 124 | + // Merge required OAuth betas with any existing betas |
| 125 | + const incomingBetas = (requestHeaders.get("anthropic-beta") || "") |
| 126 | + .split(",") |
| 127 | + .map((b) => b.trim()) |
| 128 | + .filter(Boolean) |
| 129 | + const mergedBetas = [...new Set(["oauth-2025-04-20", "interleaved-thinking-2025-05-14", ...incomingBetas])].join(",") |
| 130 | + |
| 131 | + requestHeaders.set("authorization", `Bearer ${currentAuth.access}`) |
| 132 | + requestHeaders.set("anthropic-beta", mergedBetas) |
| 133 | + requestHeaders.set("user-agent", "claude-cli/2.1.2 (external, cli)") |
| 134 | + requestHeaders.delete("x-api-key") |
| 135 | + |
| 136 | + // Prefix tool names with mcp_ (required by Anthropic's OAuth endpoint) |
| 137 | + let body = requestInit.body |
| 138 | + if (body && typeof body === "string") { |
| 139 | + try { |
| 140 | + const parsed = JSON.parse(body) |
| 141 | + |
| 142 | + // Sanitize system prompt |
| 143 | + if (parsed.system && Array.isArray(parsed.system)) { |
| 144 | + parsed.system = parsed.system.map((item: any) => { |
| 145 | + if (item.type === "text" && item.text) { |
| 146 | + return { |
| 147 | + ...item, |
| 148 | + text: item.text.replace(/OpenCode/g, "Claude Code").replace(/opencode/gi, "Claude"), |
| 149 | + } |
| 150 | + } |
| 151 | + return item |
| 152 | + }) |
| 153 | + } |
| 154 | + |
| 155 | + if (parsed.tools && Array.isArray(parsed.tools)) { |
| 156 | + parsed.tools = parsed.tools.map((tool: any) => ({ |
| 157 | + ...tool, |
| 158 | + name: tool.name ? `${TOOL_PREFIX}${tool.name}` : tool.name, |
| 159 | + })) |
| 160 | + } |
| 161 | + |
| 162 | + if (parsed.messages && Array.isArray(parsed.messages)) { |
| 163 | + parsed.messages = parsed.messages.map((msg: any) => { |
| 164 | + if (msg.content && Array.isArray(msg.content)) { |
| 165 | + msg.content = msg.content.map((block: any) => { |
| 166 | + if (block.type === "tool_use" && block.name) { |
| 167 | + return { ...block, name: `${TOOL_PREFIX}${block.name}` } |
| 168 | + } |
| 169 | + return block |
| 170 | + }) |
| 171 | + } |
| 172 | + return msg |
| 173 | + }) |
| 174 | + } |
| 175 | + |
| 176 | + body = JSON.stringify(parsed) |
| 177 | + } catch { |
| 178 | + // ignore parse errors |
| 179 | + } |
| 180 | + } |
| 181 | + |
| 182 | + // Add ?beta=true to /v1/messages requests |
| 183 | + let finalInput = requestInput |
| 184 | + try { |
| 185 | + let requestUrl: URL | null = null |
| 186 | + if (typeof requestInput === "string" || requestInput instanceof URL) { |
| 187 | + requestUrl = new URL(requestInput.toString()) |
| 188 | + } else if (requestInput instanceof Request) { |
| 189 | + requestUrl = new URL(requestInput.url) |
| 190 | + } |
| 191 | + if (requestUrl && requestUrl.pathname === "/v1/messages" && !requestUrl.searchParams.has("beta")) { |
| 192 | + requestUrl.searchParams.set("beta", "true") |
| 193 | + finalInput = requestInput instanceof Request ? new Request(requestUrl.toString(), requestInput) : requestUrl |
| 194 | + } |
| 195 | + } catch { |
| 196 | + // ignore URL parse errors |
| 197 | + } |
| 198 | + |
| 199 | + const response = await fetch(finalInput, { ...requestInit, body, headers: requestHeaders }) |
| 200 | + |
| 201 | + // Strip mcp_ prefix from tool names in streaming response |
| 202 | + if (response.body) { |
| 203 | + const reader = response.body.getReader() |
| 204 | + const decoder = new TextDecoder() |
| 205 | + const encoder = new TextEncoder() |
| 206 | + const stream = new ReadableStream({ |
| 207 | + async pull(controller) { |
| 208 | + const { done, value } = await reader.read() |
| 209 | + if (done) { |
| 210 | + controller.close() |
| 211 | + return |
| 212 | + } |
| 213 | + let text = decoder.decode(value, { stream: true }) |
| 214 | + text = text.replace(/"name"\s*:\s*"mcp_([^"]+)"/g, '"name": "$1"') |
| 215 | + controller.enqueue(encoder.encode(text)) |
| 216 | + }, |
| 217 | + }) |
| 218 | + return new Response(stream, { |
| 219 | + status: response.status, |
| 220 | + statusText: response.statusText, |
| 221 | + headers: response.headers, |
| 222 | + }) |
| 223 | + } |
| 224 | + |
| 225 | + return response |
| 226 | + }, |
| 227 | + } |
| 228 | + }, |
| 229 | + methods: [ |
| 230 | + { |
| 231 | + label: "Claude Pro/Max", |
| 232 | + type: "oauth", |
| 233 | + authorize: async () => { |
| 234 | + const { url, verifier } = await authorize("max") |
| 235 | + return { |
| 236 | + url, |
| 237 | + instructions: "Paste the authorization code here: ", |
| 238 | + method: "code" as const, |
| 239 | + callback: async (code: string) => exchange(code, verifier), |
| 240 | + } |
| 241 | + }, |
| 242 | + }, |
| 243 | + { |
| 244 | + label: "Create an API Key", |
| 245 | + type: "oauth", |
| 246 | + authorize: async () => { |
| 247 | + const { url, verifier } = await authorize("console") |
| 248 | + return { |
| 249 | + url, |
| 250 | + instructions: "Paste the authorization code here: ", |
| 251 | + method: "code" as const, |
| 252 | + callback: async (code: string) => { |
| 253 | + const credentials = await exchange(code, verifier) |
| 254 | + if (credentials.type === "failed") return credentials |
| 255 | + const result = await fetch("https://api.anthropic.com/api/oauth/claude_cli/create_api_key", { |
| 256 | + method: "POST", |
| 257 | + headers: { |
| 258 | + "Content-Type": "application/json", |
| 259 | + authorization: `Bearer ${credentials.access}`, |
| 260 | + }, |
| 261 | + }).then((r) => r.json()) |
| 262 | + return { type: "success" as const, key: result.raw_key } |
| 263 | + }, |
| 264 | + } |
| 265 | + }, |
| 266 | + }, |
| 267 | + { |
| 268 | + label: "Manually enter API Key", |
| 269 | + type: "api", |
| 270 | + }, |
| 271 | + ], |
| 272 | + }, |
| 273 | + } |
| 274 | +} |
0 commit comments