get your claude code tokens here

feat: init package

dunkirk.sh a09cdafa

+1238
+40
.gitignore
··· 1 + # dependencies (bun install) 2 + node_modules 3 + 4 + # output 5 + out 6 + dist 7 + *.tgz 8 + 9 + # code coverage 10 + coverage 11 + *.lcov 12 + 13 + # logs 14 + logs 15 + _.log 16 + report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json 17 + 18 + # dotenv environment variable files 19 + .env 20 + .env.development.local 21 + .env.test.local 22 + .env.production.local 23 + .env.local 24 + 25 + # caches 26 + .eslintcache 27 + .cache 28 + *.tsbuildinfo 29 + 30 + # IntelliJ based IDEs 31 + .idea 32 + 33 + # Finder (MacOS) folder config 34 + .DS_Store 35 + 36 + # Crush agent workspace 37 + .crush 38 + 39 + # Bun install cache 40 + bun.lockb
+28
CRUSH.md
··· 1 + # CRUSH.md 2 + 3 + Build/Lint/Test 4 + - Install: bun install 5 + - Typecheck: bun x tsc --noEmit 6 + - Run: bun run index.ts or bun index.ts 7 + - Test all: bun test 8 + - Test watch: bun test --watch 9 + - Test single: bun test path/to/file.test.ts -t "name" 10 + - Lint: bun x biome check --write || bun x eslint . (if configured) 11 + 12 + Conventions 13 + - Runtime: Bun (see CLAUDE.md). Prefer Bun APIs (Bun.serve, Bun.file, Bun.$) over Node shims. Bun auto-loads .env. 14 + - Modules: ESM only ("type": "module"). Use extensionless TS imports within project. 15 + - Formatting: Prettier/biome if present; otherwise 2-space indent, trailing commas where valid, semicolons optional but consistent. 16 + - Types: Strict TypeScript. Prefer explicit types on public APIs; infer locals via const. Use unknown over any. Narrow with guards. 17 + - Imports: Group std/bun, third-party, then local. Use named imports; avoid default exports for libs. 18 + - Naming: camelCase for vars/functions, PascalCase for types/classes, UPPER_SNAKE for env constants. 19 + - Errors: Throw Error (or subclasses) with actionable messages; never swallow. Use Result-like returns only if established. 20 + - Async: Prefer async/await. Always handle rejections. Avoid top-level await outside Bun entrypoints. 21 + - Logging: Use console.* sparingly; no secrets in logs. Prefer structured messages. 22 + - Env/config: Read via process.env or Bun.env at startup; validate and fail fast. 23 + - Files: Prefer Bun.file and Response over fs. Avoid sync IO. 24 + - Tests: bun:test (import { test, expect } from "bun:test"). Keep tests deterministic, no network without mocking. 25 + 26 + Repo Notes 27 + - No Cursor/Copilot rules detected. 28 + - Add ".crush" dir to .gitignore (keeps agent scratch files untracked).
+15
README.md
··· 1 + # anthropic-api-key 2 + 3 + To install dependencies: 4 + 5 + ```bash 6 + bun install 7 + ``` 8 + 9 + To run: 10 + 11 + ```bash 12 + bun run index.ts 13 + ``` 14 + 15 + This project was created using `bun init` in bun v1.2.19. [Bun](https://bun.com) is a fast all-in-one JavaScript runtime.
+531
anthropic.sh
··· 1 + #!/bin/sh 2 + 3 + # Anthropic OAuth client ID 4 + CLIENT_ID="9d1c250a-e61b-44d9-88ed-5944d1962f5e" 5 + 6 + # Token cache file location 7 + CACHE_DIR="${HOME}/.config/crush/anthropic" 8 + CACHE_FILE="${CACHE_DIR}/bearer_token" 9 + REFRESH_TOKEN_FILE="${CACHE_DIR}/refresh_token" 10 + 11 + # Function to extract expiration from cached token file 12 + extract_expiration() { 13 + if [ -f "${CACHE_FILE}.expires" ]; then 14 + cat "${CACHE_FILE}.expires" 15 + fi 16 + } 17 + 18 + # Function to check if token is valid 19 + is_token_valid() { 20 + local expires="$1" 21 + 22 + if [ -z "$expires" ]; then 23 + return 1 24 + fi 25 + 26 + local current_time=$(date +%s) 27 + # Add 60 second buffer before expiration 28 + local buffer_time=$((expires - 60)) 29 + 30 + if [ "$current_time" -lt "$buffer_time" ]; then 31 + return 0 32 + else 33 + return 1 34 + fi 35 + } 36 + 37 + # Function to generate PKCE challenge (requires openssl) 38 + generate_pkce() { 39 + # Generate 32 random bytes, base64url encode 40 + local verifier=$(openssl rand -base64 32 | tr -d "=" | tr "/" "_" | tr "+" "-" | tr -d "\n") 41 + # Create SHA256 hash of verifier, base64url encode 42 + local challenge=$(printf '%s' "$verifier" | openssl dgst -sha256 -binary | openssl base64 | tr -d "=" | tr "/" "_" | tr "+" "-" | tr -d "\n") 43 + 44 + echo "$verifier|$challenge" 45 + } 46 + 47 + # Function to exchange refresh token for new access token 48 + exchange_refresh_token() { 49 + local refresh_token="$1" 50 + 51 + local bearer_response=$(curl -s -X POST "https://console.anthropic.com/v1/oauth/token" \ 52 + -H "Content-Type: application/json" \ 53 + -H "User-Agent: CRUSH/1.0" \ 54 + -d "{\"grant_type\":\"refresh_token\",\"refresh_token\":\"${refresh_token}\",\"client_id\":\"${CLIENT_ID}\"}") 55 + 56 + # Parse JSON response - try jq first, fallback to sed 57 + local access_token="" 58 + local new_refresh_token="" 59 + local expires_in="" 60 + 61 + if command -v jq >/dev/null 2>&1; then 62 + access_token=$(echo "$bearer_response" | jq -r '.access_token // empty') 63 + new_refresh_token=$(echo "$bearer_response" | jq -r '.refresh_token // empty') 64 + expires_in=$(echo "$bearer_response" | jq -r '.expires_in // empty') 65 + else 66 + # Fallback to sed parsing 67 + access_token=$(echo "$bearer_response" | sed -n 's/.*"access_token"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p') 68 + new_refresh_token=$(echo "$bearer_response" | sed -n 's/.*"refresh_token"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p') 69 + expires_in=$(echo "$bearer_response" | sed -n 's/.*"expires_in"[[:space:]]*:[[:space:]]*\([0-9]*\).*/\1/p') 70 + fi 71 + 72 + if [ -n "$access_token" ] && [ -n "$expires_in" ]; then 73 + # Calculate expiration timestamp 74 + local current_time=$(date +%s) 75 + local expires_timestamp=$((current_time + expires_in)) 76 + 77 + # Cache the new tokens 78 + mkdir -p "$CACHE_DIR" 79 + echo "$access_token" > "$CACHE_FILE" 80 + chmod 600 "$CACHE_FILE" 81 + 82 + if [ -n "$new_refresh_token" ]; then 83 + echo "$new_refresh_token" > "$REFRESH_TOKEN_FILE" 84 + chmod 600 "$REFRESH_TOKEN_FILE" 85 + fi 86 + 87 + # Store expiration for future reference 88 + echo "$expires_timestamp" > "${CACHE_FILE}.expires" 89 + chmod 600 "${CACHE_FILE}.expires" 90 + 91 + echo "$access_token" 92 + return 0 93 + fi 94 + 95 + return 1 96 + } 97 + 98 + # Function to exchange authorization code for tokens 99 + exchange_authorization_code() { 100 + local auth_code="$1" 101 + local verifier="$2" 102 + 103 + # Split code if it contains state (format: code#state) 104 + local code=$(echo "$auth_code" | cut -d'#' -f1) 105 + local state="" 106 + if echo "$auth_code" | grep -q '#'; then 107 + state=$(echo "$auth_code" | cut -d'#' -f2) 108 + fi 109 + 110 + # Use the working endpoint 111 + local bearer_response=$(curl -s -X POST "https://console.anthropic.com/v1/oauth/token" \ 112 + -H "Content-Type: application/json" \ 113 + -H "User-Agent: CRUSH/1.0" \ 114 + -d "{\"code\":\"${code}\",\"state\":\"${state}\",\"grant_type\":\"authorization_code\",\"client_id\":\"${CLIENT_ID}\",\"redirect_uri\":\"https://console.anthropic.com/oauth/code/callback\",\"code_verifier\":\"${verifier}\"}") 115 + 116 + # Parse JSON response - try jq first, fallback to sed 117 + local access_token="" 118 + local refresh_token="" 119 + local expires_in="" 120 + 121 + if command -v jq >/dev/null 2>&1; then 122 + access_token=$(echo "$bearer_response" | jq -r '.access_token // empty') 123 + refresh_token=$(echo "$bearer_response" | jq -r '.refresh_token // empty') 124 + expires_in=$(echo "$bearer_response" | jq -r '.expires_in // empty') 125 + else 126 + # Fallback to sed parsing 127 + access_token=$(echo "$bearer_response" | sed -n 's/.*"access_token"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p') 128 + refresh_token=$(echo "$bearer_response" | sed -n 's/.*"refresh_token"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p') 129 + expires_in=$(echo "$bearer_response" | sed -n 's/.*"expires_in"[[:space:]]*:[[:space:]]*\([0-9]*\).*/\1/p') 130 + fi 131 + 132 + if [ -n "$access_token" ] && [ -n "$refresh_token" ] && [ -n "$expires_in" ]; then 133 + # Calculate expiration timestamp 134 + local current_time=$(date +%s) 135 + local expires_timestamp=$((current_time + expires_in)) 136 + 137 + # Cache the tokens 138 + mkdir -p "$CACHE_DIR" 139 + echo "$access_token" > "$CACHE_FILE" 140 + echo "$refresh_token" > "$REFRESH_TOKEN_FILE" 141 + echo "$expires_timestamp" > "${CACHE_FILE}.expires" 142 + chmod 600 "$CACHE_FILE" "$REFRESH_TOKEN_FILE" "${CACHE_FILE}.expires" 143 + 144 + echo "$access_token" 145 + return 0 146 + else 147 + return 1 148 + fi 149 + } 150 + 151 + # Check for cached bearer token 152 + if [ -f "$CACHE_FILE" ] && [ -f "${CACHE_FILE}.expires" ]; then 153 + CACHED_TOKEN=$(cat "$CACHE_FILE") 154 + CACHED_EXPIRES=$(cat "${CACHE_FILE}.expires") 155 + if is_token_valid "$CACHED_EXPIRES"; then 156 + # Token is still valid, output and exit 157 + echo "$CACHED_TOKEN" 158 + exit 0 159 + fi 160 + fi 161 + 162 + # Bearer token is expired/missing, try to use cached refresh token 163 + if [ -f "$REFRESH_TOKEN_FILE" ]; then 164 + REFRESH_TOKEN=$(cat "$REFRESH_TOKEN_FILE") 165 + if [ -n "$REFRESH_TOKEN" ]; then 166 + # Try to exchange refresh token for new bearer token 167 + BEARER_TOKEN=$(exchange_refresh_token "$REFRESH_TOKEN") 168 + if [ -n "$BEARER_TOKEN" ]; then 169 + # Successfully got new bearer token, output and exit 170 + echo "$BEARER_TOKEN" 171 + exit 0 172 + fi 173 + fi 174 + fi 175 + 176 + # No valid tokens found, start OAuth flow 177 + # Check if openssl is available for PKCE 178 + if ! command -v openssl >/dev/null 2>&1; then 179 + exit 1 180 + fi 181 + 182 + # Generate PKCE challenge 183 + PKCE_DATA=$(generate_pkce) 184 + VERIFIER=$(echo "$PKCE_DATA" | cut -d'|' -f1) 185 + CHALLENGE=$(echo "$PKCE_DATA" | cut -d'|' -f2) 186 + 187 + # Build OAuth URL 188 + AUTH_URL="https://claude.ai/oauth/authorize" 189 + AUTH_URL="${AUTH_URL}?response_type=code" 190 + AUTH_URL="${AUTH_URL}&client_id=${CLIENT_ID}" 191 + AUTH_URL="${AUTH_URL}&redirect_uri=https://console.anthropic.com/oauth/code/callback" 192 + AUTH_URL="${AUTH_URL}&scope=org:create_api_key%20user:profile%20user:inference" 193 + AUTH_URL="${AUTH_URL}&code_challenge=${CHALLENGE}" 194 + AUTH_URL="${AUTH_URL}&code_challenge_method=S256" 195 + AUTH_URL="${AUTH_URL}&state=${VERIFIER}" 196 + 197 + # Create a temporary HTML file with the authentication form 198 + TEMP_HTML="/tmp/anthropic_auth_$$.html" 199 + cat > "$TEMP_HTML" << EOF 200 + <!DOCTYPE html> 201 + <html> 202 + <head> 203 + <title>Anthropic Authentication</title> 204 + <style> 205 + * { 206 + box-sizing: border-box; 207 + margin: 0; 208 + padding: 0; 209 + } 210 + 211 + body { 212 + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; 213 + background: linear-gradient(135deg, #1a1a1a 0%, #2d1810 100%); 214 + color: #ffffff; 215 + min-height: 100vh; 216 + display: flex; 217 + align-items: center; 218 + justify-content: center; 219 + padding: 20px; 220 + } 221 + 222 + .container { 223 + background: rgba(40, 40, 40, 0.95); 224 + border: 1px solid #4a4a4a; 225 + border-radius: 16px; 226 + padding: 48px; 227 + max-width: 480px; 228 + width: 100%; 229 + text-align: center; 230 + backdrop-filter: blur(10px); 231 + box-shadow: 0 20px 40px rgba(0, 0, 0, 0.3); 232 + } 233 + 234 + .logo { 235 + width: 48px; 236 + height: 48px; 237 + margin: 0 auto 24px; 238 + background: linear-gradient(135deg, #ff6b35 0%, #ff8e53 100%); 239 + border-radius: 12px; 240 + display: flex; 241 + align-items: center; 242 + justify-content: center; 243 + font-weight: bold; 244 + font-size: 24px; 245 + color: white; 246 + } 247 + 248 + h1 { 249 + font-size: 28px; 250 + font-weight: 600; 251 + margin-bottom: 12px; 252 + color: #ffffff; 253 + } 254 + 255 + .subtitle { 256 + color: #a0a0a0; 257 + margin-bottom: 32px; 258 + font-size: 16px; 259 + line-height: 1.5; 260 + } 261 + 262 + .step { 263 + margin-bottom: 32px; 264 + text-align: left; 265 + } 266 + 267 + .step-number { 268 + display: inline-flex; 269 + align-items: center; 270 + justify-content: center; 271 + width: 24px; 272 + height: 24px; 273 + background: #ff6b35; 274 + color: white; 275 + border-radius: 50%; 276 + font-size: 14px; 277 + font-weight: 600; 278 + margin-right: 12px; 279 + } 280 + 281 + .step-title { 282 + font-weight: 600; 283 + margin-bottom: 8px; 284 + color: #ffffff; 285 + } 286 + 287 + .step-description { 288 + color: #a0a0a0; 289 + font-size: 14px; 290 + margin-left: 36px; 291 + } 292 + 293 + .button { 294 + display: inline-block; 295 + background: linear-gradient(135deg, #ff6b35 0%, #ff8e53 100%); 296 + color: white; 297 + padding: 16px 32px; 298 + text-decoration: none; 299 + border-radius: 12px; 300 + font-weight: 600; 301 + font-size: 16px; 302 + margin-bottom: 24px; 303 + transition: all 0.2s ease; 304 + box-shadow: 0 4px 12px rgba(255, 107, 53, 0.3); 305 + } 306 + 307 + .button:hover { 308 + transform: translateY(-2px); 309 + box-shadow: 0 8px 20px rgba(255, 107, 53, 0.4); 310 + } 311 + 312 + .input-group { 313 + margin-bottom: 24px; 314 + text-align: left; 315 + } 316 + 317 + label { 318 + display: block; 319 + margin-bottom: 8px; 320 + font-weight: 500; 321 + color: #ffffff; 322 + } 323 + 324 + textarea { 325 + width: 100%; 326 + background: #2a2a2a; 327 + border: 2px solid #4a4a4a; 328 + border-radius: 8px; 329 + padding: 16px; 330 + color: #ffffff; 331 + font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace; 332 + font-size: 14px; 333 + line-height: 1.4; 334 + resize: vertical; 335 + min-height: 120px; 336 + transition: border-color 0.2s ease; 337 + } 338 + 339 + textarea:focus { 340 + outline: none; 341 + border-color: #ff6b35; 342 + box-shadow: 0 0 0 3px rgba(255, 107, 53, 0.1); 343 + } 344 + 345 + textarea::placeholder { 346 + color: #666; 347 + } 348 + 349 + .submit-btn { 350 + background: linear-gradient(135deg, #ff6b35 0%, #ff8e53 100%); 351 + color: white; 352 + border: none; 353 + padding: 16px 32px; 354 + border-radius: 12px; 355 + font-weight: 600; 356 + font-size: 16px; 357 + cursor: pointer; 358 + transition: all 0.2s ease; 359 + box-shadow: 0 4px 12px rgba(255, 107, 53, 0.3); 360 + width: 100%; 361 + } 362 + 363 + .submit-btn:hover { 364 + transform: translateY(-2px); 365 + box-shadow: 0 8px 20px rgba(255, 107, 53, 0.4); 366 + } 367 + 368 + .submit-btn:disabled { 369 + opacity: 0.6; 370 + cursor: not-allowed; 371 + transform: none; 372 + } 373 + 374 + .status { 375 + margin-top: 16px; 376 + padding: 12px; 377 + border-radius: 8px; 378 + font-size: 14px; 379 + display: none; 380 + } 381 + 382 + .status.success { 383 + background: rgba(52, 168, 83, 0.1); 384 + border: 1px solid rgba(52, 168, 83, 0.3); 385 + color: #34a853; 386 + } 387 + 388 + .status.error { 389 + background: rgba(234, 67, 53, 0.1); 390 + border: 1px solid rgba(234, 67, 53, 0.3); 391 + color: #ea4335; 392 + } 393 + </style> 394 + </head> 395 + <body> 396 + <div class="container"> 397 + <div class="logo">A</div> 398 + <h1>Anthropic Authentication</h1> 399 + <p class="subtitle">Connect your Anthropic account to continue</p> 400 + 401 + <div class="step"> 402 + <div class="step-title"> 403 + <span class="step-number">1</span> 404 + Authorize with Anthropic 405 + </div> 406 + <div class="step-description"> 407 + Click the button below to open the Anthropic authorization page 408 + </div> 409 + </div> 410 + 411 + <a href="$AUTH_URL" class="button" target="_blank"> 412 + Open Anthropic Authorization 413 + </a> 414 + 415 + <div class="step"> 416 + <div class="step-title"> 417 + <span class="step-number">2</span> 418 + Paste your authorization token 419 + </div> 420 + <div class="step-description"> 421 + After authorizing, copy the token and paste it below 422 + </div> 423 + </div> 424 + 425 + <form id="tokenForm"> 426 + <div class="input-group"> 427 + <label for="token">Authorization Token:</label> 428 + <textarea 429 + id="token" 430 + name="token" 431 + placeholder="Paste your token here..." 432 + required 433 + ></textarea> 434 + </div> 435 + <button type="submit" class="submit-btn" id="submitBtn"> 436 + Complete Authentication 437 + </button> 438 + </form> 439 + 440 + <div id="status" class="status"></div> 441 + </div> 442 + 443 + <script> 444 + document.getElementById('tokenForm').addEventListener('submit', function(e) { 445 + e.preventDefault(); 446 + 447 + const token = document.getElementById('token').value.trim(); 448 + if (!token) { 449 + showStatus('Please paste your authorization token', 'error'); 450 + return; 451 + } 452 + 453 + // Ensure token has content before creating file 454 + if (token.length > 0) { 455 + // Save the token as a downloadable file 456 + const blob = new Blob([token], { type: 'text/plain' }); 457 + const a = document.createElement('a'); 458 + a.href = URL.createObjectURL(blob); 459 + a.download = "anthropic_token.txt"; 460 + document.body.appendChild(a); // Append to body to ensure it works in all browsers 461 + a.click(); 462 + document.body.removeChild(a); // Clean up 463 + 464 + // Verify file creation 465 + console.log("Token file created with content length: " + token.length); 466 + } else { 467 + showStatus('Empty token detected, please provide a valid token', 'error'); 468 + return; 469 + } 470 + 471 + document.getElementById('submitBtn').disabled = true; 472 + document.getElementById('submitBtn').textContent = "Token saved, you may close this tab."; 473 + showStatus('Token file downloaded! You can close this window.', 'success'); 474 + 475 + // setTimeout(() => { 476 + // window.close(); 477 + // }, 2000); 478 + }); 479 + 480 + function showStatus(message, type) { 481 + const status = document.getElementById('status'); 482 + status.textContent = message; 483 + status.className = 'status ' + type; 484 + status.style.display = 'block'; 485 + } 486 + 487 + // Auto-close after 10 minutes 488 + setTimeout(() => { 489 + window.close(); 490 + }, 600000); 491 + </script> 492 + </body> 493 + </html> 494 + EOF 495 + 496 + # Open the HTML file 497 + if command -v xdg-open >/dev/null 2>&1; then 498 + xdg-open "$TEMP_HTML" >/dev/null 2>&1 & 499 + elif command -v open >/dev/null 2>&1; then 500 + open "$TEMP_HTML" >/dev/null 2>&1 & 501 + elif command -v start >/dev/null 2>&1; then 502 + start "$TEMP_HTML" >/dev/null 2>&1 & 503 + fi 504 + 505 + # Wait for user to download the token file 506 + TOKEN_FILE="$HOME/Downloads/anthropic_token.txt" 507 + 508 + for i in $(seq 1 60); do 509 + if [ -f "$TOKEN_FILE" ]; then 510 + AUTH_CODE=$(cat "$TOKEN_FILE" | tr -d '\r\n') 511 + rm -f "$TOKEN_FILE" 512 + break 513 + fi 514 + sleep 2 515 + done 516 + 517 + # Clean up the temporary HTML file 518 + rm -f "$TEMP_HTML" 519 + 520 + if [ -z "$AUTH_CODE" ]; then 521 + exit 1 522 + fi 523 + 524 + # Exchange code for tokens 525 + ACCESS_TOKEN=$(exchange_authorization_code "$AUTH_CODE" "$VERIFIER") 526 + if [ -n "$ACCESS_TOKEN" ]; then 527 + echo "$ACCESS_TOKEN" 528 + exit 0 529 + else 530 + exit 1 531 + fi
+314
bin/anthropic.ts
··· 1 + #!/usr/bin/env bun 2 + 3 + import { serve } from "bun"; 4 + 5 + const PORT = Number(Bun.env.PORT || 8787); 6 + const ROOT = new URL("../", import.meta.url).pathname; 7 + const PUBLIC_DIR = `${ROOT}public`; 8 + 9 + function notFound() { 10 + return new Response("Not found", { status: 404 }); 11 + } 12 + 13 + async function serveStatic(pathname: string) { 14 + const filePath = PUBLIC_DIR + (pathname === "/" ? "/index.html" : pathname); 15 + try { 16 + const file = Bun.file(filePath); 17 + if (!(await file.exists())) return null; 18 + return new Response(file); 19 + } catch { 20 + return null; 21 + } 22 + } 23 + 24 + function json(data: unknown, init: ResponseInit = {}) { 25 + return new Response(JSON.stringify(data), { 26 + headers: { "content-type": "application/json", ...(init.headers || {}) }, 27 + ...init, 28 + }); 29 + } 30 + 31 + function authorizeUrl(verifier: string, challenge: string) { 32 + const u = new URL("https://claude.ai/oauth/authorize"); 33 + u.searchParams.set("response_type", "code"); 34 + u.searchParams.set("client_id", CLIENT_ID); 35 + u.searchParams.set( 36 + "redirect_uri", 37 + "https://console.anthropic.com/oauth/code/callback", 38 + ); 39 + u.searchParams.set("scope", "org:create_api_key user:profile user:inference"); 40 + u.searchParams.set("code_challenge", challenge); 41 + u.searchParams.set("code_challenge_method", "S256"); 42 + u.searchParams.set("state", verifier); 43 + return u.toString(); 44 + } 45 + 46 + function base64url(input: ArrayBuffer | Uint8Array) { 47 + const buf = input instanceof Uint8Array ? input : new Uint8Array(input); 48 + return Buffer.from(buf) 49 + .toString("base64") 50 + .replace(/=/g, "") 51 + .replace(/\+/g, "-") 52 + .replace(/\//g, "_"); 53 + } 54 + 55 + async function pkcePair() { 56 + const bytes = crypto.getRandomValues(new Uint8Array(32)); 57 + const verifier = base64url(bytes); 58 + const digest = await crypto.subtle.digest( 59 + "SHA-256", 60 + new TextEncoder().encode(verifier), 61 + ); 62 + const challenge = base64url(digest as ArrayBuffer); 63 + return { verifier, challenge }; 64 + } 65 + 66 + const CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"; 67 + 68 + async function exchangeRefreshToken(refreshToken: string) { 69 + const res = await fetch("https://console.anthropic.com/v1/oauth/token", { 70 + method: "POST", 71 + headers: { 72 + "content-type": "application/json", 73 + "user-agent": "CRUSH/1.0", 74 + }, 75 + body: JSON.stringify({ 76 + grant_type: "refresh_token", 77 + refresh_token: refreshToken, 78 + client_id: CLIENT_ID, 79 + }), 80 + }); 81 + if (!res.ok) throw new Error(`refresh failed: ${res.status}`); 82 + return (await res.json()) as { 83 + access_token: string; 84 + refresh_token?: string; 85 + expires_in: number; 86 + }; 87 + } 88 + 89 + function cleanPastedCode(input: string) { 90 + let v = input.trim(); 91 + v = v.replace(/^code\s*[:=]\s*/i, ""); 92 + v = v.replace(/^["'`]/, "").replace(/["'`]$/, ""); 93 + const m = v.match(/[A-Za-z0-9._~-]+(?:#[A-Za-z0-9._~-]+)?/); 94 + if (m) return m[0]; 95 + return v; 96 + } 97 + 98 + async function exchangeAuthorizationCode(code: string, verifier: string) { 99 + const cleaned = cleanPastedCode(code); 100 + const [pure, state = ""] = cleaned.split("#"); 101 + const body = { 102 + code: pure ?? "", 103 + state: state ?? "", 104 + grant_type: "authorization_code", 105 + client_id: CLIENT_ID, 106 + redirect_uri: "https://console.anthropic.com/oauth/code/callback", 107 + code_verifier: verifier, 108 + } satisfies Record<string, string>; 109 + const res = await fetch("https://console.anthropic.com/v1/oauth/token", { 110 + method: "POST", 111 + headers: { 112 + "content-type": "application/json", 113 + "user-agent": "CRUSH/1.0", 114 + }, 115 + body: JSON.stringify(body), 116 + }); 117 + if (!res.ok) throw new Error(`code exchange failed: ${res.status}`); 118 + return (await res.json()) as { 119 + access_token: string; 120 + refresh_token: string; 121 + expires_in: number; 122 + }; 123 + } 124 + 125 + const memory = new Map< 126 + string, 127 + { accessToken: string; refreshToken: string; expiresAt: number } 128 + >(); 129 + 130 + const HOME = Bun.env.HOME || Bun.env.USERPROFILE || "."; 131 + const CACHE_DIR = `${HOME}/.config/crush/anthropic`; 132 + const BEARER_FILE = `${CACHE_DIR}/bearer_token`; 133 + const REFRESH_FILE = `${CACHE_DIR}/refresh_token`; 134 + const EXPIRES_FILE = `${CACHE_DIR}/bearer_token.expires`; 135 + 136 + async function ensureDir() { 137 + await Bun.$`mkdir -p ${CACHE_DIR}`; 138 + } 139 + 140 + async function writeSecret(path: string, data: string) { 141 + await Bun.write(path, data); 142 + await Bun.$`chmod 600 ${path}`; 143 + } 144 + 145 + async function readText(path: string) { 146 + const f = Bun.file(path); 147 + if (!(await f.exists())) return undefined; 148 + return await f.text(); 149 + } 150 + 151 + async function loadFromDisk() { 152 + const [bearer, refresh, expires] = await Promise.all([ 153 + readText(BEARER_FILE), 154 + readText(REFRESH_FILE), 155 + readText(EXPIRES_FILE), 156 + ]); 157 + if (!bearer || !refresh || !expires) return undefined; 158 + const exp = Number.parseInt(expires, 10) || 0; 159 + return { 160 + accessToken: bearer.trim(), 161 + refreshToken: refresh.trim(), 162 + expiresAt: exp, 163 + }; 164 + } 165 + 166 + async function saveToDisk(entry: { 167 + accessToken: string; 168 + refreshToken: string; 169 + expiresAt: number; 170 + }) { 171 + await ensureDir(); 172 + await writeSecret(BEARER_FILE, `${entry.accessToken}\n`); 173 + await writeSecret(REFRESH_FILE, `${entry.refreshToken}\n`); 174 + await writeSecret(EXPIRES_FILE, `${String(entry.expiresAt)}\n`); 175 + } 176 + 177 + let serverStarted = false; 178 + 179 + async function bootstrapFromDisk() { 180 + const entry = await loadFromDisk(); 181 + if (!entry) return false; 182 + const now = Math.floor(Date.now() / 1000); 183 + if (now < entry.expiresAt - 60) { 184 + Bun.write(Bun.stdout, `${entry.accessToken}\n`); 185 + setTimeout(() => process.exit(0), 50); 186 + memory.set("tokens", entry); 187 + return true; 188 + } 189 + try { 190 + const refreshed = await exchangeRefreshToken(entry.refreshToken); 191 + entry.accessToken = refreshed.access_token; 192 + entry.expiresAt = Math.floor(Date.now() / 1000) + refreshed.expires_in; 193 + if (refreshed.refresh_token) entry.refreshToken = refreshed.refresh_token; 194 + await saveToDisk(entry); 195 + memory.set("tokens", entry); 196 + Bun.write(Bun.stdout, `${entry.accessToken}\n`); 197 + setTimeout(() => process.exit(0), 50); 198 + return true; 199 + } catch { 200 + return false; 201 + } 202 + } 203 + 204 + await bootstrapFromDisk(); 205 + 206 + const argv = process.argv.slice(2); 207 + if (argv.includes("-h") || argv.includes("--help")) { 208 + Bun.write(Bun.stdout, `Usage: anthropic\n\n`); 209 + Bun.write(Bun.stdout, ` anthropic Start UI and flow; prints token on success and exits.\n`); 210 + Bun.write(Bun.stdout, ` PORT=xxxx anthropic Override port (default 8787).\n`); 211 + Bun.write(Bun.stdout, `\nTokens are cached at ~/.config/crush/anthropic and reused on later runs.\n`); 212 + process.exit(0); 213 + } 214 + 215 + serve({ 216 + port: PORT, 217 + development: { console: false }, 218 + async fetch(req) { 219 + const url = new URL(req.url); 220 + 221 + if (url.pathname.startsWith("/api/")) { 222 + if (url.pathname === "/api/ping") 223 + return json({ ok: true, ts: Date.now() }); 224 + 225 + if (url.pathname === "/api/auth/start" && req.method === "POST") { 226 + const { verifier, challenge } = await pkcePair(); 227 + const authUrl = authorizeUrl(verifier, challenge); 228 + return json({ authUrl, verifier }); 229 + } 230 + 231 + if (url.pathname === "/api/auth/complete" && req.method === "POST") { 232 + const body = (await req.json().catch(() => ({}))) as { 233 + code?: string; 234 + verifier?: string; 235 + }; 236 + const code = String(body.code ?? ""); 237 + const verifier = String(body.verifier ?? ""); 238 + if (!code || !verifier) 239 + return json({ error: "missing code or verifier" }, { status: 400 }); 240 + const tokens = await exchangeAuthorizationCode(code, verifier); 241 + const expiresAt = 242 + Math.floor(Date.now() / 1000) + (tokens.expires_in ?? 0); 243 + const entry = { 244 + accessToken: tokens.access_token, 245 + refreshToken: tokens.refresh_token, 246 + expiresAt, 247 + }; 248 + memory.set("tokens", entry); 249 + await saveToDisk(entry); 250 + Bun.write(Bun.stdout, `${entry.accessToken}\n`); 251 + setTimeout(() => process.exit(0), 100); 252 + return json({ ok: true }); 253 + } 254 + 255 + if (url.pathname === "/api/token" && req.method === "GET") { 256 + let entry = memory.get("tokens"); 257 + if (!entry) { 258 + const disk = await loadFromDisk(); 259 + if (disk) { 260 + entry = disk; 261 + memory.set("tokens", entry); 262 + } 263 + } 264 + if (!entry) 265 + return json({ error: "not_authenticated" }, { status: 401 }); 266 + const now = Math.floor(Date.now() / 1000); 267 + if (now >= entry.expiresAt - 60) { 268 + const refreshed = await exchangeRefreshToken(entry.refreshToken); 269 + entry.accessToken = refreshed.access_token; 270 + entry.expiresAt = 271 + Math.floor(Date.now() / 1000) + refreshed.expires_in; 272 + if (refreshed.refresh_token) 273 + entry.refreshToken = refreshed.refresh_token; 274 + memory.set("tokens", entry); 275 + await saveToDisk(entry); 276 + } 277 + return json({ 278 + accessToken: entry.accessToken, 279 + expiresAt: entry.expiresAt, 280 + }); 281 + } 282 + 283 + return notFound(); 284 + } 285 + 286 + const staticResp = await serveStatic(url.pathname); 287 + if (staticResp) return staticResp; 288 + 289 + return notFound(); 290 + }, 291 + error() {}, 292 + }); 293 + 294 + if (!serverStarted) { 295 + serverStarted = true; 296 + const url = `http://localhost:${PORT}`; 297 + const tryRun = async (cmd: string, ...args: string[]) => { 298 + try { 299 + await Bun.$`${[cmd, ...args]}`.quiet(); 300 + return true; 301 + } catch { 302 + return false; 303 + } 304 + }; 305 + (async () => { 306 + if (process.platform === "darwin") { 307 + if (await tryRun("open", url)) return; 308 + } else if (process.platform === "win32") { 309 + if (await tryRun("cmd", "/c", "start", "", url)) return; 310 + } else { 311 + if (await tryRun("xdg-open", url)) return; 312 + } 313 + })(); 314 + }
+23
bin/open.ts
··· 1 + #!/usr/bin/env bun 2 + 3 + const PORT = Number(Bun.env.PORT || 8787); 4 + 5 + async function open(url: string) { 6 + const tryRun = async (cmd: string, ...args: string[]) => { 7 + try { 8 + await Bun.$`${[cmd, ...args]}`.quiet(); 9 + return true; 10 + } catch { 11 + return false; 12 + } 13 + }; 14 + if (process.platform === "darwin") { 15 + if (await tryRun("open", url)) return; 16 + } else if (process.platform === "win32") { 17 + if (await tryRun("cmd", "/c", "start", "", url)) return; 18 + } else { 19 + if (await tryRun("xdg-open", url)) return; 20 + } 21 + } 22 + 23 + await open(`http://localhost:${PORT}`);
+29
bun.lock
··· 1 + { 2 + "lockfileVersion": 1, 3 + "workspaces": { 4 + "": { 5 + "name": "anthropic-api-key", 6 + "devDependencies": { 7 + "@types/bun": "latest", 8 + }, 9 + "peerDependencies": { 10 + "typescript": "^5", 11 + }, 12 + }, 13 + }, 14 + "packages": { 15 + "@types/bun": ["@types/bun@1.2.19", "", { "dependencies": { "bun-types": "1.2.19" } }, "sha512-d9ZCmrH3CJ2uYKXQIUuZ/pUnTqIvLDS0SK7pFmbx8ma+ziH/FRMoAq5bYpRG7y+w1gl+HgyNZbtqgMq4W4e2Lg=="], 16 + 17 + "@types/node": ["@types/node@24.1.0", "", { "dependencies": { "undici-types": "~7.8.0" } }, "sha512-ut5FthK5moxFKH2T1CUOC6ctR67rQRvvHdFLCD2Ql6KXmMuCrjsSsRI9UsLCm9M18BMwClv4pn327UvB7eeO1w=="], 18 + 19 + "@types/react": ["@types/react@19.1.9", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-WmdoynAX8Stew/36uTSVMcLJJ1KRh6L3IZRx1PZ7qJtBqT3dYTgyDTx8H1qoRghErydW7xw9mSJ3wS//tCRpFA=="], 20 + 21 + "bun-types": ["bun-types@1.2.19", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-uAOTaZSPuYsWIXRpj7o56Let0g/wjihKCkeRqUBhlLVM/Bt+Fj9xTo+LhC1OV1XDaGkz4hNC80et5xgy+9KTHQ=="], 22 + 23 + "csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="], 24 + 25 + "typescript": ["typescript@5.9.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A=="], 26 + 27 + "undici-types": ["undici-types@7.8.0", "", {}, "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw=="], 28 + } 29 + }
+12
crush.json
··· 1 + { 2 + "$schema": "https://charm.land/crush.json", 3 + "lsp": { 4 + "biome": { 5 + "command": "npx", 6 + "args": [ 7 + "biome", 8 + "lsp-proxy" 9 + ] 10 + } 11 + } 12 + }
+35
package.json
··· 1 + { 2 + "name": "anthropic-api-key", 3 + "version": "0.1.0", 4 + "description": "CLI to fetch Anthropic API access tokens via OAuth with PKCE using Bun.", 5 + "type": "module", 6 + "private": false, 7 + "license": "MIT", 8 + "author": "taciturnaxolotl", 9 + "repository": { 10 + "type": "git", 11 + "url": "https://github.com/taciturnaxolotl/anthropic-api-key.git" 12 + }, 13 + "bugs": { 14 + "url": "https://github.com/taciturnaxolotl/anthropic-api-key/issues" 15 + }, 16 + "homepage": "https://github.com/taciturnaxolotl/anthropic-api-key#readme", 17 + "bin": { 18 + "anthropic": "dist/anthropic.js" 19 + }, 20 + "exports": { 21 + ".": "./dist/anthropic.js" 22 + }, 23 + "files": [ 24 + "dist", 25 + "public" 26 + ], 27 + "scripts": { 28 + "build": "bun build bin/anthropic.ts --outdir=dist --target=bun --sourcemap=external", 29 + "prepare": "bun run build" 30 + }, 31 + "devDependencies": { 32 + "@types/bun": "latest", 33 + "typescript": "^5" 34 + } 35 + }
+181
public/index.html
··· 1 + <!doctype html> 2 + <html> 3 + <head> 4 + <meta charset="utf-8" /> 5 + <meta name="viewport" content="width=device-width, initial-scale=1" /> 6 + <title>Anthropic Auth</title> 7 + <style> 8 + body { 9 + font-family: 10 + system-ui, 11 + -apple-system, 12 + Segoe UI, 13 + Roboto, 14 + Ubuntu, 15 + Cantarell, 16 + Noto Sans, 17 + sans-serif; 18 + background: #0f0f10; 19 + color: #fff; 20 + margin: 0; 21 + display: flex; 22 + min-height: 100vh; 23 + align-items: center; 24 + justify-content: center; 25 + } 26 + .card { 27 + background: #1a1a1b; 28 + border: 1px solid #2b2b2c; 29 + border-radius: 14px; 30 + padding: 28px; 31 + max-width: 560px; 32 + width: 100%; 33 + } 34 + h1 { 35 + margin: 0 0 8px; 36 + } 37 + p { 38 + color: #9aa0a6; 39 + } 40 + button, 41 + a.button { 42 + background: linear-gradient(135deg, #ff6b35, #ff8e53); 43 + color: #fff; 44 + border: none; 45 + border-radius: 10px; 46 + padding: 12px 16px; 47 + font-weight: 600; 48 + cursor: pointer; 49 + text-decoration: none; 50 + display: inline-block; 51 + } 52 + textarea { 53 + width: 100%; 54 + min-height: 120px; 55 + background: #111; 56 + border: 1px solid #2b2b2c; 57 + border-radius: 10px; 58 + color: #fff; 59 + padding: 10px; 60 + } 61 + .row { 62 + margin: 16px 0; 63 + } 64 + .muted { 65 + color: #9aa0a6; 66 + } 67 + .status { 68 + margin-top: 8px; 69 + font-size: 14px; 70 + } 71 + </style> 72 + </head> 73 + <body> 74 + <div class="card"> 75 + <h1>Anthropic Authentication</h1> 76 + <p class="muted"> 77 + Start the OAuth flow, authorize in the new tab, then paste the 78 + returned token here. 79 + </p> 80 + 81 + <div class="row"> 82 + <a 83 + id="authlink" 84 + class="button" 85 + href="#" 86 + target="_blank" 87 + style="display: none" 88 + >Open Anthropic Authorization</a 89 + > 90 + </div> 91 + 92 + <div class="row"> 93 + <label for="code">Authorization code</label> 94 + <textarea 95 + id="code" 96 + placeholder="Paste the exact code shown by Anthropic (not a URL). If it includes a #, keep the part after it too." 97 + ></textarea> 98 + </div> 99 + 100 + <div class="row"> 101 + <button id="complete">Complete Authentication</button> 102 + </div> 103 + 104 + <div id="status" class="status"></div> 105 + </div> 106 + 107 + <script> 108 + let verifier = ""; 109 + const statusEl = document.getElementById("status"); 110 + 111 + function setStatus(msg, ok) { 112 + statusEl.textContent = msg; 113 + statusEl.style.color = ok ? "#34a853" : "#ea4335"; 114 + } 115 + 116 + (async () => { 117 + setStatus("Preparing authorization...", true); 118 + const res = await fetch("/api/auth/start", { method: "POST" }); 119 + if (!res.ok) { 120 + setStatus("Failed to prepare auth", false); 121 + return; 122 + } 123 + const data = await res.json(); 124 + verifier = data.verifier; 125 + const a = document.getElementById("authlink"); 126 + a.href = data.authUrl; 127 + a.style.display = "inline-block"; 128 + setStatus( 129 + 'Ready. Click "Open Authorization" to continue.', 130 + true, 131 + ); 132 + })(); 133 + 134 + const completeBtn = document.getElementById("complete"); 135 + document 136 + .getElementById("complete") 137 + .addEventListener("click", async () => { 138 + if (completeBtn.disabled) return; 139 + completeBtn.disabled = true; 140 + const code = document.getElementById("code").value.trim(); 141 + if (!code || !verifier) { 142 + setStatus( 143 + "Missing code or verifier. Click Start first.", 144 + false, 145 + ); 146 + completeBtn.disabled = false; 147 + return; 148 + } 149 + const res = await fetch("/api/auth/complete", { 150 + method: "POST", 151 + headers: { "content-type": "application/json" }, 152 + body: JSON.stringify({ code, verifier }), 153 + }); 154 + if (!res.ok) { 155 + setStatus("Code exchange failed", false); 156 + completeBtn.disabled = false; 157 + return; 158 + } 159 + setStatus("Authenticated! Fetching token...", true); 160 + const t = await fetch("/api/token"); 161 + if (!t.ok) { 162 + setStatus("Could not fetch token", false); 163 + completeBtn.disabled = false; 164 + return; 165 + } 166 + const tok = await t.json(); 167 + setStatus( 168 + "Access token acquired (expires " + 169 + new Date(tok.expiresAt * 1000).toLocaleString() + 170 + ")", 171 + true, 172 + ); 173 + setTimeout(() => { 174 + try { 175 + window.close(); 176 + } catch {} 177 + }, 500); 178 + }); 179 + </script> 180 + </body> 181 + </html>
+1
src/server.ts
··· 1 + export {};
+29
tsconfig.json
··· 1 + { 2 + "compilerOptions": { 3 + // Environment setup & latest features 4 + "lib": ["ESNext"], 5 + "target": "ESNext", 6 + "module": "Preserve", 7 + "moduleDetection": "force", 8 + "jsx": "react-jsx", 9 + "allowJs": true, 10 + 11 + // Bundler mode 12 + "moduleResolution": "bundler", 13 + "allowImportingTsExtensions": true, 14 + "verbatimModuleSyntax": true, 15 + "noEmit": true, 16 + 17 + // Best practices 18 + "strict": true, 19 + "skipLibCheck": true, 20 + "noFallthroughCasesInSwitch": true, 21 + "noUncheckedIndexedAccess": true, 22 + "noImplicitOverride": true, 23 + 24 + // Some stricter flags (disabled by default) 25 + "noUnusedLocals": false, 26 + "noUnusedParameters": false, 27 + "noPropertyAccessFromIndexSignature": false 28 + } 29 + }