get your claude code tokens here

feat: init package

dunkirk.sh a09cdafa

verified
+40
.gitignore
···
+
# dependencies (bun install)
+
node_modules
+
+
# output
+
out
+
dist
+
*.tgz
+
+
# code coverage
+
coverage
+
*.lcov
+
+
# logs
+
logs
+
_.log
+
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
+
+
# dotenv environment variable files
+
.env
+
.env.development.local
+
.env.test.local
+
.env.production.local
+
.env.local
+
+
# caches
+
.eslintcache
+
.cache
+
*.tsbuildinfo
+
+
# IntelliJ based IDEs
+
.idea
+
+
# Finder (MacOS) folder config
+
.DS_Store
+
+
# Crush agent workspace
+
.crush
+
+
# Bun install cache
+
bun.lockb
+28
CRUSH.md
···
+
# CRUSH.md
+
+
Build/Lint/Test
+
- Install: bun install
+
- Typecheck: bun x tsc --noEmit
+
- Run: bun run index.ts or bun index.ts
+
- Test all: bun test
+
- Test watch: bun test --watch
+
- Test single: bun test path/to/file.test.ts -t "name"
+
- Lint: bun x biome check --write || bun x eslint . (if configured)
+
+
Conventions
+
- Runtime: Bun (see CLAUDE.md). Prefer Bun APIs (Bun.serve, Bun.file, Bun.$) over Node shims. Bun auto-loads .env.
+
- Modules: ESM only ("type": "module"). Use extensionless TS imports within project.
+
- Formatting: Prettier/biome if present; otherwise 2-space indent, trailing commas where valid, semicolons optional but consistent.
+
- Types: Strict TypeScript. Prefer explicit types on public APIs; infer locals via const. Use unknown over any. Narrow with guards.
+
- Imports: Group std/bun, third-party, then local. Use named imports; avoid default exports for libs.
+
- Naming: camelCase for vars/functions, PascalCase for types/classes, UPPER_SNAKE for env constants.
+
- Errors: Throw Error (or subclasses) with actionable messages; never swallow. Use Result-like returns only if established.
+
- Async: Prefer async/await. Always handle rejections. Avoid top-level await outside Bun entrypoints.
+
- Logging: Use console.* sparingly; no secrets in logs. Prefer structured messages.
+
- Env/config: Read via process.env or Bun.env at startup; validate and fail fast.
+
- Files: Prefer Bun.file and Response over fs. Avoid sync IO.
+
- Tests: bun:test (import { test, expect } from "bun:test"). Keep tests deterministic, no network without mocking.
+
+
Repo Notes
+
- No Cursor/Copilot rules detected.
+
- Add ".crush" dir to .gitignore (keeps agent scratch files untracked).
+15
README.md
···
+
# anthropic-api-key
+
+
To install dependencies:
+
+
```bash
+
bun install
+
```
+
+
To run:
+
+
```bash
+
bun run index.ts
+
```
+
+
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
···
+
#!/bin/sh
+
+
# Anthropic OAuth client ID
+
CLIENT_ID="9d1c250a-e61b-44d9-88ed-5944d1962f5e"
+
+
# Token cache file location
+
CACHE_DIR="${HOME}/.config/crush/anthropic"
+
CACHE_FILE="${CACHE_DIR}/bearer_token"
+
REFRESH_TOKEN_FILE="${CACHE_DIR}/refresh_token"
+
+
# Function to extract expiration from cached token file
+
extract_expiration() {
+
if [ -f "${CACHE_FILE}.expires" ]; then
+
cat "${CACHE_FILE}.expires"
+
fi
+
}
+
+
# Function to check if token is valid
+
is_token_valid() {
+
local expires="$1"
+
+
if [ -z "$expires" ]; then
+
return 1
+
fi
+
+
local current_time=$(date +%s)
+
# Add 60 second buffer before expiration
+
local buffer_time=$((expires - 60))
+
+
if [ "$current_time" -lt "$buffer_time" ]; then
+
return 0
+
else
+
return 1
+
fi
+
}
+
+
# Function to generate PKCE challenge (requires openssl)
+
generate_pkce() {
+
# Generate 32 random bytes, base64url encode
+
local verifier=$(openssl rand -base64 32 | tr -d "=" | tr "/" "_" | tr "+" "-" | tr -d "\n")
+
# Create SHA256 hash of verifier, base64url encode
+
local challenge=$(printf '%s' "$verifier" | openssl dgst -sha256 -binary | openssl base64 | tr -d "=" | tr "/" "_" | tr "+" "-" | tr -d "\n")
+
+
echo "$verifier|$challenge"
+
}
+
+
# Function to exchange refresh token for new access token
+
exchange_refresh_token() {
+
local refresh_token="$1"
+
+
local bearer_response=$(curl -s -X POST "https://console.anthropic.com/v1/oauth/token" \
+
-H "Content-Type: application/json" \
+
-H "User-Agent: CRUSH/1.0" \
+
-d "{\"grant_type\":\"refresh_token\",\"refresh_token\":\"${refresh_token}\",\"client_id\":\"${CLIENT_ID}\"}")
+
+
# Parse JSON response - try jq first, fallback to sed
+
local access_token=""
+
local new_refresh_token=""
+
local expires_in=""
+
+
if command -v jq >/dev/null 2>&1; then
+
access_token=$(echo "$bearer_response" | jq -r '.access_token // empty')
+
new_refresh_token=$(echo "$bearer_response" | jq -r '.refresh_token // empty')
+
expires_in=$(echo "$bearer_response" | jq -r '.expires_in // empty')
+
else
+
# Fallback to sed parsing
+
access_token=$(echo "$bearer_response" | sed -n 's/.*"access_token"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p')
+
new_refresh_token=$(echo "$bearer_response" | sed -n 's/.*"refresh_token"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p')
+
expires_in=$(echo "$bearer_response" | sed -n 's/.*"expires_in"[[:space:]]*:[[:space:]]*\([0-9]*\).*/\1/p')
+
fi
+
+
if [ -n "$access_token" ] && [ -n "$expires_in" ]; then
+
# Calculate expiration timestamp
+
local current_time=$(date +%s)
+
local expires_timestamp=$((current_time + expires_in))
+
+
# Cache the new tokens
+
mkdir -p "$CACHE_DIR"
+
echo "$access_token" > "$CACHE_FILE"
+
chmod 600 "$CACHE_FILE"
+
+
if [ -n "$new_refresh_token" ]; then
+
echo "$new_refresh_token" > "$REFRESH_TOKEN_FILE"
+
chmod 600 "$REFRESH_TOKEN_FILE"
+
fi
+
+
# Store expiration for future reference
+
echo "$expires_timestamp" > "${CACHE_FILE}.expires"
+
chmod 600 "${CACHE_FILE}.expires"
+
+
echo "$access_token"
+
return 0
+
fi
+
+
return 1
+
}
+
+
# Function to exchange authorization code for tokens
+
exchange_authorization_code() {
+
local auth_code="$1"
+
local verifier="$2"
+
+
# Split code if it contains state (format: code#state)
+
local code=$(echo "$auth_code" | cut -d'#' -f1)
+
local state=""
+
if echo "$auth_code" | grep -q '#'; then
+
state=$(echo "$auth_code" | cut -d'#' -f2)
+
fi
+
+
# Use the working endpoint
+
local bearer_response=$(curl -s -X POST "https://console.anthropic.com/v1/oauth/token" \
+
-H "Content-Type: application/json" \
+
-H "User-Agent: CRUSH/1.0" \
+
-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}\"}")
+
+
# Parse JSON response - try jq first, fallback to sed
+
local access_token=""
+
local refresh_token=""
+
local expires_in=""
+
+
if command -v jq >/dev/null 2>&1; then
+
access_token=$(echo "$bearer_response" | jq -r '.access_token // empty')
+
refresh_token=$(echo "$bearer_response" | jq -r '.refresh_token // empty')
+
expires_in=$(echo "$bearer_response" | jq -r '.expires_in // empty')
+
else
+
# Fallback to sed parsing
+
access_token=$(echo "$bearer_response" | sed -n 's/.*"access_token"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p')
+
refresh_token=$(echo "$bearer_response" | sed -n 's/.*"refresh_token"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p')
+
expires_in=$(echo "$bearer_response" | sed -n 's/.*"expires_in"[[:space:]]*:[[:space:]]*\([0-9]*\).*/\1/p')
+
fi
+
+
if [ -n "$access_token" ] && [ -n "$refresh_token" ] && [ -n "$expires_in" ]; then
+
# Calculate expiration timestamp
+
local current_time=$(date +%s)
+
local expires_timestamp=$((current_time + expires_in))
+
+
# Cache the tokens
+
mkdir -p "$CACHE_DIR"
+
echo "$access_token" > "$CACHE_FILE"
+
echo "$refresh_token" > "$REFRESH_TOKEN_FILE"
+
echo "$expires_timestamp" > "${CACHE_FILE}.expires"
+
chmod 600 "$CACHE_FILE" "$REFRESH_TOKEN_FILE" "${CACHE_FILE}.expires"
+
+
echo "$access_token"
+
return 0
+
else
+
return 1
+
fi
+
}
+
+
# Check for cached bearer token
+
if [ -f "$CACHE_FILE" ] && [ -f "${CACHE_FILE}.expires" ]; then
+
CACHED_TOKEN=$(cat "$CACHE_FILE")
+
CACHED_EXPIRES=$(cat "${CACHE_FILE}.expires")
+
if is_token_valid "$CACHED_EXPIRES"; then
+
# Token is still valid, output and exit
+
echo "$CACHED_TOKEN"
+
exit 0
+
fi
+
fi
+
+
# Bearer token is expired/missing, try to use cached refresh token
+
if [ -f "$REFRESH_TOKEN_FILE" ]; then
+
REFRESH_TOKEN=$(cat "$REFRESH_TOKEN_FILE")
+
if [ -n "$REFRESH_TOKEN" ]; then
+
# Try to exchange refresh token for new bearer token
+
BEARER_TOKEN=$(exchange_refresh_token "$REFRESH_TOKEN")
+
if [ -n "$BEARER_TOKEN" ]; then
+
# Successfully got new bearer token, output and exit
+
echo "$BEARER_TOKEN"
+
exit 0
+
fi
+
fi
+
fi
+
+
# No valid tokens found, start OAuth flow
+
# Check if openssl is available for PKCE
+
if ! command -v openssl >/dev/null 2>&1; then
+
exit 1
+
fi
+
+
# Generate PKCE challenge
+
PKCE_DATA=$(generate_pkce)
+
VERIFIER=$(echo "$PKCE_DATA" | cut -d'|' -f1)
+
CHALLENGE=$(echo "$PKCE_DATA" | cut -d'|' -f2)
+
+
# Build OAuth URL
+
AUTH_URL="https://claude.ai/oauth/authorize"
+
AUTH_URL="${AUTH_URL}?response_type=code"
+
AUTH_URL="${AUTH_URL}&client_id=${CLIENT_ID}"
+
AUTH_URL="${AUTH_URL}&redirect_uri=https://console.anthropic.com/oauth/code/callback"
+
AUTH_URL="${AUTH_URL}&scope=org:create_api_key%20user:profile%20user:inference"
+
AUTH_URL="${AUTH_URL}&code_challenge=${CHALLENGE}"
+
AUTH_URL="${AUTH_URL}&code_challenge_method=S256"
+
AUTH_URL="${AUTH_URL}&state=${VERIFIER}"
+
+
# Create a temporary HTML file with the authentication form
+
TEMP_HTML="/tmp/anthropic_auth_$$.html"
+
cat > "$TEMP_HTML" << EOF
+
<!DOCTYPE html>
+
<html>
+
<head>
+
<title>Anthropic Authentication</title>
+
<style>
+
* {
+
box-sizing: border-box;
+
margin: 0;
+
padding: 0;
+
}
+
+
body {
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
+
background: linear-gradient(135deg, #1a1a1a 0%, #2d1810 100%);
+
color: #ffffff;
+
min-height: 100vh;
+
display: flex;
+
align-items: center;
+
justify-content: center;
+
padding: 20px;
+
}
+
+
.container {
+
background: rgba(40, 40, 40, 0.95);
+
border: 1px solid #4a4a4a;
+
border-radius: 16px;
+
padding: 48px;
+
max-width: 480px;
+
width: 100%;
+
text-align: center;
+
backdrop-filter: blur(10px);
+
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.3);
+
}
+
+
.logo {
+
width: 48px;
+
height: 48px;
+
margin: 0 auto 24px;
+
background: linear-gradient(135deg, #ff6b35 0%, #ff8e53 100%);
+
border-radius: 12px;
+
display: flex;
+
align-items: center;
+
justify-content: center;
+
font-weight: bold;
+
font-size: 24px;
+
color: white;
+
}
+
+
h1 {
+
font-size: 28px;
+
font-weight: 600;
+
margin-bottom: 12px;
+
color: #ffffff;
+
}
+
+
.subtitle {
+
color: #a0a0a0;
+
margin-bottom: 32px;
+
font-size: 16px;
+
line-height: 1.5;
+
}
+
+
.step {
+
margin-bottom: 32px;
+
text-align: left;
+
}
+
+
.step-number {
+
display: inline-flex;
+
align-items: center;
+
justify-content: center;
+
width: 24px;
+
height: 24px;
+
background: #ff6b35;
+
color: white;
+
border-radius: 50%;
+
font-size: 14px;
+
font-weight: 600;
+
margin-right: 12px;
+
}
+
+
.step-title {
+
font-weight: 600;
+
margin-bottom: 8px;
+
color: #ffffff;
+
}
+
+
.step-description {
+
color: #a0a0a0;
+
font-size: 14px;
+
margin-left: 36px;
+
}
+
+
.button {
+
display: inline-block;
+
background: linear-gradient(135deg, #ff6b35 0%, #ff8e53 100%);
+
color: white;
+
padding: 16px 32px;
+
text-decoration: none;
+
border-radius: 12px;
+
font-weight: 600;
+
font-size: 16px;
+
margin-bottom: 24px;
+
transition: all 0.2s ease;
+
box-shadow: 0 4px 12px rgba(255, 107, 53, 0.3);
+
}
+
+
.button:hover {
+
transform: translateY(-2px);
+
box-shadow: 0 8px 20px rgba(255, 107, 53, 0.4);
+
}
+
+
.input-group {
+
margin-bottom: 24px;
+
text-align: left;
+
}
+
+
label {
+
display: block;
+
margin-bottom: 8px;
+
font-weight: 500;
+
color: #ffffff;
+
}
+
+
textarea {
+
width: 100%;
+
background: #2a2a2a;
+
border: 2px solid #4a4a4a;
+
border-radius: 8px;
+
padding: 16px;
+
color: #ffffff;
+
font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace;
+
font-size: 14px;
+
line-height: 1.4;
+
resize: vertical;
+
min-height: 120px;
+
transition: border-color 0.2s ease;
+
}
+
+
textarea:focus {
+
outline: none;
+
border-color: #ff6b35;
+
box-shadow: 0 0 0 3px rgba(255, 107, 53, 0.1);
+
}
+
+
textarea::placeholder {
+
color: #666;
+
}
+
+
.submit-btn {
+
background: linear-gradient(135deg, #ff6b35 0%, #ff8e53 100%);
+
color: white;
+
border: none;
+
padding: 16px 32px;
+
border-radius: 12px;
+
font-weight: 600;
+
font-size: 16px;
+
cursor: pointer;
+
transition: all 0.2s ease;
+
box-shadow: 0 4px 12px rgba(255, 107, 53, 0.3);
+
width: 100%;
+
}
+
+
.submit-btn:hover {
+
transform: translateY(-2px);
+
box-shadow: 0 8px 20px rgba(255, 107, 53, 0.4);
+
}
+
+
.submit-btn:disabled {
+
opacity: 0.6;
+
cursor: not-allowed;
+
transform: none;
+
}
+
+
.status {
+
margin-top: 16px;
+
padding: 12px;
+
border-radius: 8px;
+
font-size: 14px;
+
display: none;
+
}
+
+
.status.success {
+
background: rgba(52, 168, 83, 0.1);
+
border: 1px solid rgba(52, 168, 83, 0.3);
+
color: #34a853;
+
}
+
+
.status.error {
+
background: rgba(234, 67, 53, 0.1);
+
border: 1px solid rgba(234, 67, 53, 0.3);
+
color: #ea4335;
+
}
+
</style>
+
</head>
+
<body>
+
<div class="container">
+
<div class="logo">A</div>
+
<h1>Anthropic Authentication</h1>
+
<p class="subtitle">Connect your Anthropic account to continue</p>
+
+
<div class="step">
+
<div class="step-title">
+
<span class="step-number">1</span>
+
Authorize with Anthropic
+
</div>
+
<div class="step-description">
+
Click the button below to open the Anthropic authorization page
+
</div>
+
</div>
+
+
<a href="$AUTH_URL" class="button" target="_blank">
+
Open Anthropic Authorization
+
</a>
+
+
<div class="step">
+
<div class="step-title">
+
<span class="step-number">2</span>
+
Paste your authorization token
+
</div>
+
<div class="step-description">
+
After authorizing, copy the token and paste it below
+
</div>
+
</div>
+
+
<form id="tokenForm">
+
<div class="input-group">
+
<label for="token">Authorization Token:</label>
+
<textarea
+
id="token"
+
name="token"
+
placeholder="Paste your token here..."
+
required
+
></textarea>
+
</div>
+
<button type="submit" class="submit-btn" id="submitBtn">
+
Complete Authentication
+
</button>
+
</form>
+
+
<div id="status" class="status"></div>
+
</div>
+
+
<script>
+
document.getElementById('tokenForm').addEventListener('submit', function(e) {
+
e.preventDefault();
+
+
const token = document.getElementById('token').value.trim();
+
if (!token) {
+
showStatus('Please paste your authorization token', 'error');
+
return;
+
}
+
+
// Ensure token has content before creating file
+
if (token.length > 0) {
+
// Save the token as a downloadable file
+
const blob = new Blob([token], { type: 'text/plain' });
+
const a = document.createElement('a');
+
a.href = URL.createObjectURL(blob);
+
a.download = "anthropic_token.txt";
+
document.body.appendChild(a); // Append to body to ensure it works in all browsers
+
a.click();
+
document.body.removeChild(a); // Clean up
+
+
// Verify file creation
+
console.log("Token file created with content length: " + token.length);
+
} else {
+
showStatus('Empty token detected, please provide a valid token', 'error');
+
return;
+
}
+
+
document.getElementById('submitBtn').disabled = true;
+
document.getElementById('submitBtn').textContent = "Token saved, you may close this tab.";
+
showStatus('Token file downloaded! You can close this window.', 'success');
+
+
// setTimeout(() => {
+
// window.close();
+
// }, 2000);
+
});
+
+
function showStatus(message, type) {
+
const status = document.getElementById('status');
+
status.textContent = message;
+
status.className = 'status ' + type;
+
status.style.display = 'block';
+
}
+
+
// Auto-close after 10 minutes
+
setTimeout(() => {
+
window.close();
+
}, 600000);
+
</script>
+
</body>
+
</html>
+
EOF
+
+
# Open the HTML file
+
if command -v xdg-open >/dev/null 2>&1; then
+
xdg-open "$TEMP_HTML" >/dev/null 2>&1 &
+
elif command -v open >/dev/null 2>&1; then
+
open "$TEMP_HTML" >/dev/null 2>&1 &
+
elif command -v start >/dev/null 2>&1; then
+
start "$TEMP_HTML" >/dev/null 2>&1 &
+
fi
+
+
# Wait for user to download the token file
+
TOKEN_FILE="$HOME/Downloads/anthropic_token.txt"
+
+
for i in $(seq 1 60); do
+
if [ -f "$TOKEN_FILE" ]; then
+
AUTH_CODE=$(cat "$TOKEN_FILE" | tr -d '\r\n')
+
rm -f "$TOKEN_FILE"
+
break
+
fi
+
sleep 2
+
done
+
+
# Clean up the temporary HTML file
+
rm -f "$TEMP_HTML"
+
+
if [ -z "$AUTH_CODE" ]; then
+
exit 1
+
fi
+
+
# Exchange code for tokens
+
ACCESS_TOKEN=$(exchange_authorization_code "$AUTH_CODE" "$VERIFIER")
+
if [ -n "$ACCESS_TOKEN" ]; then
+
echo "$ACCESS_TOKEN"
+
exit 0
+
else
+
exit 1
+
fi
+314
bin/anthropic.ts
···
+
#!/usr/bin/env bun
+
+
import { serve } from "bun";
+
+
const PORT = Number(Bun.env.PORT || 8787);
+
const ROOT = new URL("../", import.meta.url).pathname;
+
const PUBLIC_DIR = `${ROOT}public`;
+
+
function notFound() {
+
return new Response("Not found", { status: 404 });
+
}
+
+
async function serveStatic(pathname: string) {
+
const filePath = PUBLIC_DIR + (pathname === "/" ? "/index.html" : pathname);
+
try {
+
const file = Bun.file(filePath);
+
if (!(await file.exists())) return null;
+
return new Response(file);
+
} catch {
+
return null;
+
}
+
}
+
+
function json(data: unknown, init: ResponseInit = {}) {
+
return new Response(JSON.stringify(data), {
+
headers: { "content-type": "application/json", ...(init.headers || {}) },
+
...init,
+
});
+
}
+
+
function authorizeUrl(verifier: string, challenge: string) {
+
const u = new URL("https://claude.ai/oauth/authorize");
+
u.searchParams.set("response_type", "code");
+
u.searchParams.set("client_id", CLIENT_ID);
+
u.searchParams.set(
+
"redirect_uri",
+
"https://console.anthropic.com/oauth/code/callback",
+
);
+
u.searchParams.set("scope", "org:create_api_key user:profile user:inference");
+
u.searchParams.set("code_challenge", challenge);
+
u.searchParams.set("code_challenge_method", "S256");
+
u.searchParams.set("state", verifier);
+
return u.toString();
+
}
+
+
function base64url(input: ArrayBuffer | Uint8Array) {
+
const buf = input instanceof Uint8Array ? input : new Uint8Array(input);
+
return Buffer.from(buf)
+
.toString("base64")
+
.replace(/=/g, "")
+
.replace(/\+/g, "-")
+
.replace(/\//g, "_");
+
}
+
+
async function pkcePair() {
+
const bytes = crypto.getRandomValues(new Uint8Array(32));
+
const verifier = base64url(bytes);
+
const digest = await crypto.subtle.digest(
+
"SHA-256",
+
new TextEncoder().encode(verifier),
+
);
+
const challenge = base64url(digest as ArrayBuffer);
+
return { verifier, challenge };
+
}
+
+
const CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e";
+
+
async function exchangeRefreshToken(refreshToken: string) {
+
const res = await fetch("https://console.anthropic.com/v1/oauth/token", {
+
method: "POST",
+
headers: {
+
"content-type": "application/json",
+
"user-agent": "CRUSH/1.0",
+
},
+
body: JSON.stringify({
+
grant_type: "refresh_token",
+
refresh_token: refreshToken,
+
client_id: CLIENT_ID,
+
}),
+
});
+
if (!res.ok) throw new Error(`refresh failed: ${res.status}`);
+
return (await res.json()) as {
+
access_token: string;
+
refresh_token?: string;
+
expires_in: number;
+
};
+
}
+
+
function cleanPastedCode(input: string) {
+
let v = input.trim();
+
v = v.replace(/^code\s*[:=]\s*/i, "");
+
v = v.replace(/^["'`]/, "").replace(/["'`]$/, "");
+
const m = v.match(/[A-Za-z0-9._~-]+(?:#[A-Za-z0-9._~-]+)?/);
+
if (m) return m[0];
+
return v;
+
}
+
+
async function exchangeAuthorizationCode(code: string, verifier: string) {
+
const cleaned = cleanPastedCode(code);
+
const [pure, state = ""] = cleaned.split("#");
+
const body = {
+
code: pure ?? "",
+
state: state ?? "",
+
grant_type: "authorization_code",
+
client_id: CLIENT_ID,
+
redirect_uri: "https://console.anthropic.com/oauth/code/callback",
+
code_verifier: verifier,
+
} satisfies Record<string, string>;
+
const res = await fetch("https://console.anthropic.com/v1/oauth/token", {
+
method: "POST",
+
headers: {
+
"content-type": "application/json",
+
"user-agent": "CRUSH/1.0",
+
},
+
body: JSON.stringify(body),
+
});
+
if (!res.ok) throw new Error(`code exchange failed: ${res.status}`);
+
return (await res.json()) as {
+
access_token: string;
+
refresh_token: string;
+
expires_in: number;
+
};
+
}
+
+
const memory = new Map<
+
string,
+
{ accessToken: string; refreshToken: string; expiresAt: number }
+
>();
+
+
const HOME = Bun.env.HOME || Bun.env.USERPROFILE || ".";
+
const CACHE_DIR = `${HOME}/.config/crush/anthropic`;
+
const BEARER_FILE = `${CACHE_DIR}/bearer_token`;
+
const REFRESH_FILE = `${CACHE_DIR}/refresh_token`;
+
const EXPIRES_FILE = `${CACHE_DIR}/bearer_token.expires`;
+
+
async function ensureDir() {
+
await Bun.$`mkdir -p ${CACHE_DIR}`;
+
}
+
+
async function writeSecret(path: string, data: string) {
+
await Bun.write(path, data);
+
await Bun.$`chmod 600 ${path}`;
+
}
+
+
async function readText(path: string) {
+
const f = Bun.file(path);
+
if (!(await f.exists())) return undefined;
+
return await f.text();
+
}
+
+
async function loadFromDisk() {
+
const [bearer, refresh, expires] = await Promise.all([
+
readText(BEARER_FILE),
+
readText(REFRESH_FILE),
+
readText(EXPIRES_FILE),
+
]);
+
if (!bearer || !refresh || !expires) return undefined;
+
const exp = Number.parseInt(expires, 10) || 0;
+
return {
+
accessToken: bearer.trim(),
+
refreshToken: refresh.trim(),
+
expiresAt: exp,
+
};
+
}
+
+
async function saveToDisk(entry: {
+
accessToken: string;
+
refreshToken: string;
+
expiresAt: number;
+
}) {
+
await ensureDir();
+
await writeSecret(BEARER_FILE, `${entry.accessToken}\n`);
+
await writeSecret(REFRESH_FILE, `${entry.refreshToken}\n`);
+
await writeSecret(EXPIRES_FILE, `${String(entry.expiresAt)}\n`);
+
}
+
+
let serverStarted = false;
+
+
async function bootstrapFromDisk() {
+
const entry = await loadFromDisk();
+
if (!entry) return false;
+
const now = Math.floor(Date.now() / 1000);
+
if (now < entry.expiresAt - 60) {
+
Bun.write(Bun.stdout, `${entry.accessToken}\n`);
+
setTimeout(() => process.exit(0), 50);
+
memory.set("tokens", entry);
+
return true;
+
}
+
try {
+
const refreshed = await exchangeRefreshToken(entry.refreshToken);
+
entry.accessToken = refreshed.access_token;
+
entry.expiresAt = Math.floor(Date.now() / 1000) + refreshed.expires_in;
+
if (refreshed.refresh_token) entry.refreshToken = refreshed.refresh_token;
+
await saveToDisk(entry);
+
memory.set("tokens", entry);
+
Bun.write(Bun.stdout, `${entry.accessToken}\n`);
+
setTimeout(() => process.exit(0), 50);
+
return true;
+
} catch {
+
return false;
+
}
+
}
+
+
await bootstrapFromDisk();
+
+
const argv = process.argv.slice(2);
+
if (argv.includes("-h") || argv.includes("--help")) {
+
Bun.write(Bun.stdout, `Usage: anthropic\n\n`);
+
Bun.write(Bun.stdout, ` anthropic Start UI and flow; prints token on success and exits.\n`);
+
Bun.write(Bun.stdout, ` PORT=xxxx anthropic Override port (default 8787).\n`);
+
Bun.write(Bun.stdout, `\nTokens are cached at ~/.config/crush/anthropic and reused on later runs.\n`);
+
process.exit(0);
+
}
+
+
serve({
+
port: PORT,
+
development: { console: false },
+
async fetch(req) {
+
const url = new URL(req.url);
+
+
if (url.pathname.startsWith("/api/")) {
+
if (url.pathname === "/api/ping")
+
return json({ ok: true, ts: Date.now() });
+
+
if (url.pathname === "/api/auth/start" && req.method === "POST") {
+
const { verifier, challenge } = await pkcePair();
+
const authUrl = authorizeUrl(verifier, challenge);
+
return json({ authUrl, verifier });
+
}
+
+
if (url.pathname === "/api/auth/complete" && req.method === "POST") {
+
const body = (await req.json().catch(() => ({}))) as {
+
code?: string;
+
verifier?: string;
+
};
+
const code = String(body.code ?? "");
+
const verifier = String(body.verifier ?? "");
+
if (!code || !verifier)
+
return json({ error: "missing code or verifier" }, { status: 400 });
+
const tokens = await exchangeAuthorizationCode(code, verifier);
+
const expiresAt =
+
Math.floor(Date.now() / 1000) + (tokens.expires_in ?? 0);
+
const entry = {
+
accessToken: tokens.access_token,
+
refreshToken: tokens.refresh_token,
+
expiresAt,
+
};
+
memory.set("tokens", entry);
+
await saveToDisk(entry);
+
Bun.write(Bun.stdout, `${entry.accessToken}\n`);
+
setTimeout(() => process.exit(0), 100);
+
return json({ ok: true });
+
}
+
+
if (url.pathname === "/api/token" && req.method === "GET") {
+
let entry = memory.get("tokens");
+
if (!entry) {
+
const disk = await loadFromDisk();
+
if (disk) {
+
entry = disk;
+
memory.set("tokens", entry);
+
}
+
}
+
if (!entry)
+
return json({ error: "not_authenticated" }, { status: 401 });
+
const now = Math.floor(Date.now() / 1000);
+
if (now >= entry.expiresAt - 60) {
+
const refreshed = await exchangeRefreshToken(entry.refreshToken);
+
entry.accessToken = refreshed.access_token;
+
entry.expiresAt =
+
Math.floor(Date.now() / 1000) + refreshed.expires_in;
+
if (refreshed.refresh_token)
+
entry.refreshToken = refreshed.refresh_token;
+
memory.set("tokens", entry);
+
await saveToDisk(entry);
+
}
+
return json({
+
accessToken: entry.accessToken,
+
expiresAt: entry.expiresAt,
+
});
+
}
+
+
return notFound();
+
}
+
+
const staticResp = await serveStatic(url.pathname);
+
if (staticResp) return staticResp;
+
+
return notFound();
+
},
+
error() {},
+
});
+
+
if (!serverStarted) {
+
serverStarted = true;
+
const url = `http://localhost:${PORT}`;
+
const tryRun = async (cmd: string, ...args: string[]) => {
+
try {
+
await Bun.$`${[cmd, ...args]}`.quiet();
+
return true;
+
} catch {
+
return false;
+
}
+
};
+
(async () => {
+
if (process.platform === "darwin") {
+
if (await tryRun("open", url)) return;
+
} else if (process.platform === "win32") {
+
if (await tryRun("cmd", "/c", "start", "", url)) return;
+
} else {
+
if (await tryRun("xdg-open", url)) return;
+
}
+
})();
+
}
+23
bin/open.ts
···
+
#!/usr/bin/env bun
+
+
const PORT = Number(Bun.env.PORT || 8787);
+
+
async function open(url: string) {
+
const tryRun = async (cmd: string, ...args: string[]) => {
+
try {
+
await Bun.$`${[cmd, ...args]}`.quiet();
+
return true;
+
} catch {
+
return false;
+
}
+
};
+
if (process.platform === "darwin") {
+
if (await tryRun("open", url)) return;
+
} else if (process.platform === "win32") {
+
if (await tryRun("cmd", "/c", "start", "", url)) return;
+
} else {
+
if (await tryRun("xdg-open", url)) return;
+
}
+
}
+
+
await open(`http://localhost:${PORT}`);
+29
bun.lock
···
+
{
+
"lockfileVersion": 1,
+
"workspaces": {
+
"": {
+
"name": "anthropic-api-key",
+
"devDependencies": {
+
"@types/bun": "latest",
+
},
+
"peerDependencies": {
+
"typescript": "^5",
+
},
+
},
+
},
+
"packages": {
+
"@types/bun": ["@types/bun@1.2.19", "", { "dependencies": { "bun-types": "1.2.19" } }, "sha512-d9ZCmrH3CJ2uYKXQIUuZ/pUnTqIvLDS0SK7pFmbx8ma+ziH/FRMoAq5bYpRG7y+w1gl+HgyNZbtqgMq4W4e2Lg=="],
+
+
"@types/node": ["@types/node@24.1.0", "", { "dependencies": { "undici-types": "~7.8.0" } }, "sha512-ut5FthK5moxFKH2T1CUOC6ctR67rQRvvHdFLCD2Ql6KXmMuCrjsSsRI9UsLCm9M18BMwClv4pn327UvB7eeO1w=="],
+
+
"@types/react": ["@types/react@19.1.9", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-WmdoynAX8Stew/36uTSVMcLJJ1KRh6L3IZRx1PZ7qJtBqT3dYTgyDTx8H1qoRghErydW7xw9mSJ3wS//tCRpFA=="],
+
+
"bun-types": ["bun-types@1.2.19", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-uAOTaZSPuYsWIXRpj7o56Let0g/wjihKCkeRqUBhlLVM/Bt+Fj9xTo+LhC1OV1XDaGkz4hNC80et5xgy+9KTHQ=="],
+
+
"csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
+
+
"typescript": ["typescript@5.9.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A=="],
+
+
"undici-types": ["undici-types@7.8.0", "", {}, "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw=="],
+
}
+
}
+12
crush.json
···
+
{
+
"$schema": "https://charm.land/crush.json",
+
"lsp": {
+
"biome": {
+
"command": "npx",
+
"args": [
+
"biome",
+
"lsp-proxy"
+
]
+
}
+
}
+
}
+35
package.json
···
+
{
+
"name": "anthropic-api-key",
+
"version": "0.1.0",
+
"description": "CLI to fetch Anthropic API access tokens via OAuth with PKCE using Bun.",
+
"type": "module",
+
"private": false,
+
"license": "MIT",
+
"author": "taciturnaxolotl",
+
"repository": {
+
"type": "git",
+
"url": "https://github.com/taciturnaxolotl/anthropic-api-key.git"
+
},
+
"bugs": {
+
"url": "https://github.com/taciturnaxolotl/anthropic-api-key/issues"
+
},
+
"homepage": "https://github.com/taciturnaxolotl/anthropic-api-key#readme",
+
"bin": {
+
"anthropic": "dist/anthropic.js"
+
},
+
"exports": {
+
".": "./dist/anthropic.js"
+
},
+
"files": [
+
"dist",
+
"public"
+
],
+
"scripts": {
+
"build": "bun build bin/anthropic.ts --outdir=dist --target=bun --sourcemap=external",
+
"prepare": "bun run build"
+
},
+
"devDependencies": {
+
"@types/bun": "latest",
+
"typescript": "^5"
+
}
+
}
+181
public/index.html
···
+
<!doctype html>
+
<html>
+
<head>
+
<meta charset="utf-8" />
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
+
<title>Anthropic Auth</title>
+
<style>
+
body {
+
font-family:
+
system-ui,
+
-apple-system,
+
Segoe UI,
+
Roboto,
+
Ubuntu,
+
Cantarell,
+
Noto Sans,
+
sans-serif;
+
background: #0f0f10;
+
color: #fff;
+
margin: 0;
+
display: flex;
+
min-height: 100vh;
+
align-items: center;
+
justify-content: center;
+
}
+
.card {
+
background: #1a1a1b;
+
border: 1px solid #2b2b2c;
+
border-radius: 14px;
+
padding: 28px;
+
max-width: 560px;
+
width: 100%;
+
}
+
h1 {
+
margin: 0 0 8px;
+
}
+
p {
+
color: #9aa0a6;
+
}
+
button,
+
a.button {
+
background: linear-gradient(135deg, #ff6b35, #ff8e53);
+
color: #fff;
+
border: none;
+
border-radius: 10px;
+
padding: 12px 16px;
+
font-weight: 600;
+
cursor: pointer;
+
text-decoration: none;
+
display: inline-block;
+
}
+
textarea {
+
width: 100%;
+
min-height: 120px;
+
background: #111;
+
border: 1px solid #2b2b2c;
+
border-radius: 10px;
+
color: #fff;
+
padding: 10px;
+
}
+
.row {
+
margin: 16px 0;
+
}
+
.muted {
+
color: #9aa0a6;
+
}
+
.status {
+
margin-top: 8px;
+
font-size: 14px;
+
}
+
</style>
+
</head>
+
<body>
+
<div class="card">
+
<h1>Anthropic Authentication</h1>
+
<p class="muted">
+
Start the OAuth flow, authorize in the new tab, then paste the
+
returned token here.
+
</p>
+
+
<div class="row">
+
<a
+
id="authlink"
+
class="button"
+
href="#"
+
target="_blank"
+
style="display: none"
+
>Open Anthropic Authorization</a
+
>
+
</div>
+
+
<div class="row">
+
<label for="code">Authorization code</label>
+
<textarea
+
id="code"
+
placeholder="Paste the exact code shown by Anthropic (not a URL). If it includes a #, keep the part after it too."
+
></textarea>
+
</div>
+
+
<div class="row">
+
<button id="complete">Complete Authentication</button>
+
</div>
+
+
<div id="status" class="status"></div>
+
</div>
+
+
<script>
+
let verifier = "";
+
const statusEl = document.getElementById("status");
+
+
function setStatus(msg, ok) {
+
statusEl.textContent = msg;
+
statusEl.style.color = ok ? "#34a853" : "#ea4335";
+
}
+
+
(async () => {
+
setStatus("Preparing authorization...", true);
+
const res = await fetch("/api/auth/start", { method: "POST" });
+
if (!res.ok) {
+
setStatus("Failed to prepare auth", false);
+
return;
+
}
+
const data = await res.json();
+
verifier = data.verifier;
+
const a = document.getElementById("authlink");
+
a.href = data.authUrl;
+
a.style.display = "inline-block";
+
setStatus(
+
'Ready. Click "Open Authorization" to continue.',
+
true,
+
);
+
})();
+
+
const completeBtn = document.getElementById("complete");
+
document
+
.getElementById("complete")
+
.addEventListener("click", async () => {
+
if (completeBtn.disabled) return;
+
completeBtn.disabled = true;
+
const code = document.getElementById("code").value.trim();
+
if (!code || !verifier) {
+
setStatus(
+
"Missing code or verifier. Click Start first.",
+
false,
+
);
+
completeBtn.disabled = false;
+
return;
+
}
+
const res = await fetch("/api/auth/complete", {
+
method: "POST",
+
headers: { "content-type": "application/json" },
+
body: JSON.stringify({ code, verifier }),
+
});
+
if (!res.ok) {
+
setStatus("Code exchange failed", false);
+
completeBtn.disabled = false;
+
return;
+
}
+
setStatus("Authenticated! Fetching token...", true);
+
const t = await fetch("/api/token");
+
if (!t.ok) {
+
setStatus("Could not fetch token", false);
+
completeBtn.disabled = false;
+
return;
+
}
+
const tok = await t.json();
+
setStatus(
+
"Access token acquired (expires " +
+
new Date(tok.expiresAt * 1000).toLocaleString() +
+
")",
+
true,
+
);
+
setTimeout(() => {
+
try {
+
window.close();
+
} catch {}
+
}, 500);
+
});
+
</script>
+
</body>
+
</html>
+1
src/server.ts
···
+
export {};
+29
tsconfig.json
···
+
{
+
"compilerOptions": {
+
// Environment setup & latest features
+
"lib": ["ESNext"],
+
"target": "ESNext",
+
"module": "Preserve",
+
"moduleDetection": "force",
+
"jsx": "react-jsx",
+
"allowJs": true,
+
+
// Bundler mode
+
"moduleResolution": "bundler",
+
"allowImportingTsExtensions": true,
+
"verbatimModuleSyntax": true,
+
"noEmit": true,
+
+
// Best practices
+
"strict": true,
+
"skipLibCheck": true,
+
"noFallthroughCasesInSwitch": true,
+
"noUncheckedIndexedAccess": true,
+
"noImplicitOverride": true,
+
+
// Some stricter flags (disabled by default)
+
"noUnusedLocals": false,
+
"noUnusedParameters": false,
+
"noPropertyAccessFromIndexSignature": false
+
}
+
}