-531
anthropic.sh
-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
+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
+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
+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
+
}