get your claude code tokens here

feat: simplify

dunkirk.sh 17e70ba0 25367c4b

verified
Changed files
+126 -657
src
-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
+23 -122
bin/anthropic.ts src/index.ts
··· 1 1 #!/usr/bin/env bun 2 2 3 3 import { serve } from "bun"; 4 + import { 5 + bootstrapFromDisk, 6 + exchangeRefreshToken, 7 + loadFromDisk, 8 + saveToDisk, 9 + } from "./lib/token"; 4 10 5 11 const PORT = Number(Bun.env.PORT || 8787); 6 12 const ROOT = new URL("../", import.meta.url).pathname; ··· 27 33 ...init, 28 34 }); 29 35 } 36 + 37 + const CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"; 30 38 31 39 function authorizeUrl(verifier: string, challenge: string) { 32 40 const u = new URL("https://claude.ai/oauth/authorize"); ··· 63 71 return { verifier, challenge }; 64 72 } 65 73 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 74 function cleanPastedCode(input: string) { 90 75 let v = input.trim(); 91 76 v = v.replace(/^code\s*[:=]\s*/i, ""); ··· 122 107 }; 123 108 } 124 109 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 - 110 + // Try to bootstrap from disk and exit if successful 204 111 const didBootstrap = await bootstrapFromDisk(); 205 112 206 113 const argv = process.argv.slice(2); ··· 222 129 } 223 130 224 131 if (!didBootstrap) { 132 + // Only start the server and open the browser if we didn't bootstrap from disk 133 + const memory = new Map< 134 + string, 135 + { accessToken: string; refreshToken: string; expiresAt: number } 136 + >(); 137 + 225 138 serve({ 226 139 port: PORT, 227 140 development: { console: false }, ··· 301 214 error() {}, 302 215 }); 303 216 304 - if (!serverStarted) { 305 - serverStarted = true; 306 - const url = `http://localhost:${PORT}`; 307 - const tryRun = async (cmd: string, ...args: string[]) => { 308 - try { 309 - await Bun.$`${[cmd, ...args]}`.quiet(); 310 - return true; 311 - } catch { 312 - return false; 313 - } 314 - }; 315 - (async () => { 316 - if (process.platform === "darwin") { 317 - if (await tryRun("open", url)) return; 318 - } else if (process.platform === "win32") { 319 - if (await tryRun("cmd", "/c", "start", "", url)) return; 320 - } else { 321 - if (await tryRun("xdg-open", url)) return; 322 - } 323 - })(); 217 + // Open browser 218 + const url = `http://localhost:${PORT}`; 219 + if (process.platform === "darwin") { 220 + Bun.$`open ${url}`.catch(() => {}); 221 + } else if (process.platform === "win32") { 222 + Bun.$`cmd /c start "" ${url}`.catch(() => {}); 223 + } else { 224 + Bun.$`xdg-open ${url}`.catch(() => {}); 324 225 } 325 226 }
+5 -4
package.json
··· 1 1 { 2 2 "name": "anthropic-api-key", 3 - "version": "0.1.2", 3 + "version": "0.1.3", 4 4 "description": "CLI to fetch Anthropic API access tokens via OAuth with PKCE using Bun.", 5 5 "type": "module", 6 6 "private": false, ··· 15 15 }, 16 16 "homepage": "https://github.com/taciturnaxolotl/anthropic-api-key#readme", 17 17 "bin": { 18 - "anthropic": "dist/anthropic.js" 18 + "anthropic": "dist/index.js" 19 19 }, 20 20 "exports": { 21 - ".": "./dist/anthropic.js" 21 + ".": "./dist/index.js", 22 + "./lib/token": "./dist/lib/token.js" 22 23 }, 23 24 "files": [ 24 25 "dist", 25 26 "public" 26 27 ], 27 28 "scripts": { 28 - "build": "bun build bin/anthropic.ts --outdir=dist --target=bun --sourcemap=external", 29 + "build": "bun build src/index.ts src/lib/token.ts --outdir=dist --target=bun --sourcemap=external", 29 30 "prepare": "bun run build" 30 31 }, 31 32 "devDependencies": {
+98
src/lib/token.ts
··· 1 + const CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"; 2 + 3 + const HOME = Bun.env.HOME || Bun.env.USERPROFILE || "."; 4 + const CACHE_DIR = `${HOME}/.config/crush/anthropic`; 5 + const BEARER_FILE = `${CACHE_DIR}/bearer_token`; 6 + const REFRESH_FILE = `${CACHE_DIR}/refresh_token`; 7 + const EXPIRES_FILE = `${CACHE_DIR}/bearer_token.expires`; 8 + 9 + export type TokenEntry = { 10 + accessToken: string; 11 + refreshToken: string; 12 + expiresAt: number; 13 + }; 14 + 15 + export async function ensureDir() { 16 + await Bun.$`mkdir -p ${CACHE_DIR}`; 17 + } 18 + 19 + export async function writeSecret(path: string, data: string) { 20 + await Bun.write(path, data); 21 + await Bun.$`chmod 600 ${path}`; 22 + } 23 + 24 + export async function readText(path: string) { 25 + const f = Bun.file(path); 26 + if (!(await f.exists())) return undefined; 27 + return await f.text(); 28 + } 29 + 30 + export async function loadFromDisk(): Promise<TokenEntry | undefined> { 31 + const [bearer, refresh, expires] = await Promise.all([ 32 + readText(BEARER_FILE), 33 + readText(REFRESH_FILE), 34 + readText(EXPIRES_FILE), 35 + ]); 36 + if (!bearer || !refresh || !expires) return undefined; 37 + const exp = Number.parseInt(expires, 10) || 0; 38 + return { 39 + accessToken: bearer.trim(), 40 + refreshToken: refresh.trim(), 41 + expiresAt: exp, 42 + }; 43 + } 44 + 45 + export async function saveToDisk(entry: TokenEntry) { 46 + await ensureDir(); 47 + await writeSecret(BEARER_FILE, `${entry.accessToken}\n`); 48 + await writeSecret(REFRESH_FILE, `${entry.refreshToken}\n`); 49 + await writeSecret(EXPIRES_FILE, `${String(entry.expiresAt)}\n`); 50 + } 51 + 52 + export async function exchangeRefreshToken(refreshToken: string) { 53 + const res = await fetch("https://console.anthropic.com/v1/oauth/token", { 54 + method: "POST", 55 + headers: { 56 + "content-type": "application/json", 57 + "user-agent": "CRUSH/1.0", 58 + }, 59 + body: JSON.stringify({ 60 + grant_type: "refresh_token", 61 + refresh_token: refreshToken, 62 + client_id: CLIENT_ID, 63 + }), 64 + }); 65 + if (!res.ok) throw new Error(`refresh failed: ${res.status}`); 66 + return (await res.json()) as { 67 + access_token: string; 68 + refresh_token?: string; 69 + expires_in: number; 70 + }; 71 + } 72 + 73 + /** 74 + * Attempts to load a valid token from disk, refresh if needed, and print it to stdout. 75 + * Returns true if a valid token was found and printed, false otherwise. 76 + */ 77 + export async function bootstrapFromDisk(): Promise<boolean> { 78 + const entry = await loadFromDisk(); 79 + if (!entry) return false; 80 + const now = Math.floor(Date.now() / 1000); 81 + if (now < entry.expiresAt - 60) { 82 + Bun.write(Bun.stdout, `${entry.accessToken}\n`); 83 + setTimeout(() => process.exit(0), 50); 84 + return true; 85 + } 86 + try { 87 + const refreshed = await exchangeRefreshToken(entry.refreshToken); 88 + entry.accessToken = refreshed.access_token; 89 + entry.expiresAt = Math.floor(Date.now() / 1000) + refreshed.expires_in; 90 + if (refreshed.refresh_token) entry.refreshToken = refreshed.refresh_token; 91 + await saveToDisk(entry); 92 + Bun.write(Bun.stdout, `${entry.accessToken}\n`); 93 + setTimeout(() => process.exit(0), 50); 94 + return true; 95 + } catch { 96 + return false; 97 + } 98 + }