tangled
alpha
login
or
join now
dunkirk.sh
/
anthropic-api-key
0
fork
atom
get your claude code tokens here
0
fork
atom
overview
issues
pulls
pipelines
feat: simplify
dunkirk.sh
7 months ago
17e70ba0
25367c4b
verified
This commit was signed with the committer's
known signature
.
dunkirk.sh
SSH Key Fingerprint:
SHA256:DqcG0RXYExE26KiWo3VxJnsxswN1QNfTBvB+bdSpk80=
+126
-657
4 changed files
expand all
collapse all
unified
split
anthropic.sh
package.json
src
index.ts
lib
token.ts
-531
anthropic.sh
···
1
1
-
#!/bin/sh
2
2
-
3
3
-
# Anthropic OAuth client ID
4
4
-
CLIENT_ID="9d1c250a-e61b-44d9-88ed-5944d1962f5e"
5
5
-
6
6
-
# Token cache file location
7
7
-
CACHE_DIR="${HOME}/.config/crush/anthropic"
8
8
-
CACHE_FILE="${CACHE_DIR}/bearer_token"
9
9
-
REFRESH_TOKEN_FILE="${CACHE_DIR}/refresh_token"
10
10
-
11
11
-
# Function to extract expiration from cached token file
12
12
-
extract_expiration() {
13
13
-
if [ -f "${CACHE_FILE}.expires" ]; then
14
14
-
cat "${CACHE_FILE}.expires"
15
15
-
fi
16
16
-
}
17
17
-
18
18
-
# Function to check if token is valid
19
19
-
is_token_valid() {
20
20
-
local expires="$1"
21
21
-
22
22
-
if [ -z "$expires" ]; then
23
23
-
return 1
24
24
-
fi
25
25
-
26
26
-
local current_time=$(date +%s)
27
27
-
# Add 60 second buffer before expiration
28
28
-
local buffer_time=$((expires - 60))
29
29
-
30
30
-
if [ "$current_time" -lt "$buffer_time" ]; then
31
31
-
return 0
32
32
-
else
33
33
-
return 1
34
34
-
fi
35
35
-
}
36
36
-
37
37
-
# Function to generate PKCE challenge (requires openssl)
38
38
-
generate_pkce() {
39
39
-
# Generate 32 random bytes, base64url encode
40
40
-
local verifier=$(openssl rand -base64 32 | tr -d "=" | tr "/" "_" | tr "+" "-" | tr -d "\n")
41
41
-
# Create SHA256 hash of verifier, base64url encode
42
42
-
local challenge=$(printf '%s' "$verifier" | openssl dgst -sha256 -binary | openssl base64 | tr -d "=" | tr "/" "_" | tr "+" "-" | tr -d "\n")
43
43
-
44
44
-
echo "$verifier|$challenge"
45
45
-
}
46
46
-
47
47
-
# Function to exchange refresh token for new access token
48
48
-
exchange_refresh_token() {
49
49
-
local refresh_token="$1"
50
50
-
51
51
-
local bearer_response=$(curl -s -X POST "https://console.anthropic.com/v1/oauth/token" \
52
52
-
-H "Content-Type: application/json" \
53
53
-
-H "User-Agent: CRUSH/1.0" \
54
54
-
-d "{\"grant_type\":\"refresh_token\",\"refresh_token\":\"${refresh_token}\",\"client_id\":\"${CLIENT_ID}\"}")
55
55
-
56
56
-
# Parse JSON response - try jq first, fallback to sed
57
57
-
local access_token=""
58
58
-
local new_refresh_token=""
59
59
-
local expires_in=""
60
60
-
61
61
-
if command -v jq >/dev/null 2>&1; then
62
62
-
access_token=$(echo "$bearer_response" | jq -r '.access_token // empty')
63
63
-
new_refresh_token=$(echo "$bearer_response" | jq -r '.refresh_token // empty')
64
64
-
expires_in=$(echo "$bearer_response" | jq -r '.expires_in // empty')
65
65
-
else
66
66
-
# Fallback to sed parsing
67
67
-
access_token=$(echo "$bearer_response" | sed -n 's/.*"access_token"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p')
68
68
-
new_refresh_token=$(echo "$bearer_response" | sed -n 's/.*"refresh_token"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p')
69
69
-
expires_in=$(echo "$bearer_response" | sed -n 's/.*"expires_in"[[:space:]]*:[[:space:]]*\([0-9]*\).*/\1/p')
70
70
-
fi
71
71
-
72
72
-
if [ -n "$access_token" ] && [ -n "$expires_in" ]; then
73
73
-
# Calculate expiration timestamp
74
74
-
local current_time=$(date +%s)
75
75
-
local expires_timestamp=$((current_time + expires_in))
76
76
-
77
77
-
# Cache the new tokens
78
78
-
mkdir -p "$CACHE_DIR"
79
79
-
echo "$access_token" > "$CACHE_FILE"
80
80
-
chmod 600 "$CACHE_FILE"
81
81
-
82
82
-
if [ -n "$new_refresh_token" ]; then
83
83
-
echo "$new_refresh_token" > "$REFRESH_TOKEN_FILE"
84
84
-
chmod 600 "$REFRESH_TOKEN_FILE"
85
85
-
fi
86
86
-
87
87
-
# Store expiration for future reference
88
88
-
echo "$expires_timestamp" > "${CACHE_FILE}.expires"
89
89
-
chmod 600 "${CACHE_FILE}.expires"
90
90
-
91
91
-
echo "$access_token"
92
92
-
return 0
93
93
-
fi
94
94
-
95
95
-
return 1
96
96
-
}
97
97
-
98
98
-
# Function to exchange authorization code for tokens
99
99
-
exchange_authorization_code() {
100
100
-
local auth_code="$1"
101
101
-
local verifier="$2"
102
102
-
103
103
-
# Split code if it contains state (format: code#state)
104
104
-
local code=$(echo "$auth_code" | cut -d'#' -f1)
105
105
-
local state=""
106
106
-
if echo "$auth_code" | grep -q '#'; then
107
107
-
state=$(echo "$auth_code" | cut -d'#' -f2)
108
108
-
fi
109
109
-
110
110
-
# Use the working endpoint
111
111
-
local bearer_response=$(curl -s -X POST "https://console.anthropic.com/v1/oauth/token" \
112
112
-
-H "Content-Type: application/json" \
113
113
-
-H "User-Agent: CRUSH/1.0" \
114
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
115
-
116
116
-
# Parse JSON response - try jq first, fallback to sed
117
117
-
local access_token=""
118
118
-
local refresh_token=""
119
119
-
local expires_in=""
120
120
-
121
121
-
if command -v jq >/dev/null 2>&1; then
122
122
-
access_token=$(echo "$bearer_response" | jq -r '.access_token // empty')
123
123
-
refresh_token=$(echo "$bearer_response" | jq -r '.refresh_token // empty')
124
124
-
expires_in=$(echo "$bearer_response" | jq -r '.expires_in // empty')
125
125
-
else
126
126
-
# Fallback to sed parsing
127
127
-
access_token=$(echo "$bearer_response" | sed -n 's/.*"access_token"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p')
128
128
-
refresh_token=$(echo "$bearer_response" | sed -n 's/.*"refresh_token"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p')
129
129
-
expires_in=$(echo "$bearer_response" | sed -n 's/.*"expires_in"[[:space:]]*:[[:space:]]*\([0-9]*\).*/\1/p')
130
130
-
fi
131
131
-
132
132
-
if [ -n "$access_token" ] && [ -n "$refresh_token" ] && [ -n "$expires_in" ]; then
133
133
-
# Calculate expiration timestamp
134
134
-
local current_time=$(date +%s)
135
135
-
local expires_timestamp=$((current_time + expires_in))
136
136
-
137
137
-
# Cache the tokens
138
138
-
mkdir -p "$CACHE_DIR"
139
139
-
echo "$access_token" > "$CACHE_FILE"
140
140
-
echo "$refresh_token" > "$REFRESH_TOKEN_FILE"
141
141
-
echo "$expires_timestamp" > "${CACHE_FILE}.expires"
142
142
-
chmod 600 "$CACHE_FILE" "$REFRESH_TOKEN_FILE" "${CACHE_FILE}.expires"
143
143
-
144
144
-
echo "$access_token"
145
145
-
return 0
146
146
-
else
147
147
-
return 1
148
148
-
fi
149
149
-
}
150
150
-
151
151
-
# Check for cached bearer token
152
152
-
if [ -f "$CACHE_FILE" ] && [ -f "${CACHE_FILE}.expires" ]; then
153
153
-
CACHED_TOKEN=$(cat "$CACHE_FILE")
154
154
-
CACHED_EXPIRES=$(cat "${CACHE_FILE}.expires")
155
155
-
if is_token_valid "$CACHED_EXPIRES"; then
156
156
-
# Token is still valid, output and exit
157
157
-
echo "$CACHED_TOKEN"
158
158
-
exit 0
159
159
-
fi
160
160
-
fi
161
161
-
162
162
-
# Bearer token is expired/missing, try to use cached refresh token
163
163
-
if [ -f "$REFRESH_TOKEN_FILE" ]; then
164
164
-
REFRESH_TOKEN=$(cat "$REFRESH_TOKEN_FILE")
165
165
-
if [ -n "$REFRESH_TOKEN" ]; then
166
166
-
# Try to exchange refresh token for new bearer token
167
167
-
BEARER_TOKEN=$(exchange_refresh_token "$REFRESH_TOKEN")
168
168
-
if [ -n "$BEARER_TOKEN" ]; then
169
169
-
# Successfully got new bearer token, output and exit
170
170
-
echo "$BEARER_TOKEN"
171
171
-
exit 0
172
172
-
fi
173
173
-
fi
174
174
-
fi
175
175
-
176
176
-
# No valid tokens found, start OAuth flow
177
177
-
# Check if openssl is available for PKCE
178
178
-
if ! command -v openssl >/dev/null 2>&1; then
179
179
-
exit 1
180
180
-
fi
181
181
-
182
182
-
# Generate PKCE challenge
183
183
-
PKCE_DATA=$(generate_pkce)
184
184
-
VERIFIER=$(echo "$PKCE_DATA" | cut -d'|' -f1)
185
185
-
CHALLENGE=$(echo "$PKCE_DATA" | cut -d'|' -f2)
186
186
-
187
187
-
# Build OAuth URL
188
188
-
AUTH_URL="https://claude.ai/oauth/authorize"
189
189
-
AUTH_URL="${AUTH_URL}?response_type=code"
190
190
-
AUTH_URL="${AUTH_URL}&client_id=${CLIENT_ID}"
191
191
-
AUTH_URL="${AUTH_URL}&redirect_uri=https://console.anthropic.com/oauth/code/callback"
192
192
-
AUTH_URL="${AUTH_URL}&scope=org:create_api_key%20user:profile%20user:inference"
193
193
-
AUTH_URL="${AUTH_URL}&code_challenge=${CHALLENGE}"
194
194
-
AUTH_URL="${AUTH_URL}&code_challenge_method=S256"
195
195
-
AUTH_URL="${AUTH_URL}&state=${VERIFIER}"
196
196
-
197
197
-
# Create a temporary HTML file with the authentication form
198
198
-
TEMP_HTML="/tmp/anthropic_auth_$$.html"
199
199
-
cat > "$TEMP_HTML" << EOF
200
200
-
<!DOCTYPE html>
201
201
-
<html>
202
202
-
<head>
203
203
-
<title>Anthropic Authentication</title>
204
204
-
<style>
205
205
-
* {
206
206
-
box-sizing: border-box;
207
207
-
margin: 0;
208
208
-
padding: 0;
209
209
-
}
210
210
-
211
211
-
body {
212
212
-
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
213
213
-
background: linear-gradient(135deg, #1a1a1a 0%, #2d1810 100%);
214
214
-
color: #ffffff;
215
215
-
min-height: 100vh;
216
216
-
display: flex;
217
217
-
align-items: center;
218
218
-
justify-content: center;
219
219
-
padding: 20px;
220
220
-
}
221
221
-
222
222
-
.container {
223
223
-
background: rgba(40, 40, 40, 0.95);
224
224
-
border: 1px solid #4a4a4a;
225
225
-
border-radius: 16px;
226
226
-
padding: 48px;
227
227
-
max-width: 480px;
228
228
-
width: 100%;
229
229
-
text-align: center;
230
230
-
backdrop-filter: blur(10px);
231
231
-
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.3);
232
232
-
}
233
233
-
234
234
-
.logo {
235
235
-
width: 48px;
236
236
-
height: 48px;
237
237
-
margin: 0 auto 24px;
238
238
-
background: linear-gradient(135deg, #ff6b35 0%, #ff8e53 100%);
239
239
-
border-radius: 12px;
240
240
-
display: flex;
241
241
-
align-items: center;
242
242
-
justify-content: center;
243
243
-
font-weight: bold;
244
244
-
font-size: 24px;
245
245
-
color: white;
246
246
-
}
247
247
-
248
248
-
h1 {
249
249
-
font-size: 28px;
250
250
-
font-weight: 600;
251
251
-
margin-bottom: 12px;
252
252
-
color: #ffffff;
253
253
-
}
254
254
-
255
255
-
.subtitle {
256
256
-
color: #a0a0a0;
257
257
-
margin-bottom: 32px;
258
258
-
font-size: 16px;
259
259
-
line-height: 1.5;
260
260
-
}
261
261
-
262
262
-
.step {
263
263
-
margin-bottom: 32px;
264
264
-
text-align: left;
265
265
-
}
266
266
-
267
267
-
.step-number {
268
268
-
display: inline-flex;
269
269
-
align-items: center;
270
270
-
justify-content: center;
271
271
-
width: 24px;
272
272
-
height: 24px;
273
273
-
background: #ff6b35;
274
274
-
color: white;
275
275
-
border-radius: 50%;
276
276
-
font-size: 14px;
277
277
-
font-weight: 600;
278
278
-
margin-right: 12px;
279
279
-
}
280
280
-
281
281
-
.step-title {
282
282
-
font-weight: 600;
283
283
-
margin-bottom: 8px;
284
284
-
color: #ffffff;
285
285
-
}
286
286
-
287
287
-
.step-description {
288
288
-
color: #a0a0a0;
289
289
-
font-size: 14px;
290
290
-
margin-left: 36px;
291
291
-
}
292
292
-
293
293
-
.button {
294
294
-
display: inline-block;
295
295
-
background: linear-gradient(135deg, #ff6b35 0%, #ff8e53 100%);
296
296
-
color: white;
297
297
-
padding: 16px 32px;
298
298
-
text-decoration: none;
299
299
-
border-radius: 12px;
300
300
-
font-weight: 600;
301
301
-
font-size: 16px;
302
302
-
margin-bottom: 24px;
303
303
-
transition: all 0.2s ease;
304
304
-
box-shadow: 0 4px 12px rgba(255, 107, 53, 0.3);
305
305
-
}
306
306
-
307
307
-
.button:hover {
308
308
-
transform: translateY(-2px);
309
309
-
box-shadow: 0 8px 20px rgba(255, 107, 53, 0.4);
310
310
-
}
311
311
-
312
312
-
.input-group {
313
313
-
margin-bottom: 24px;
314
314
-
text-align: left;
315
315
-
}
316
316
-
317
317
-
label {
318
318
-
display: block;
319
319
-
margin-bottom: 8px;
320
320
-
font-weight: 500;
321
321
-
color: #ffffff;
322
322
-
}
323
323
-
324
324
-
textarea {
325
325
-
width: 100%;
326
326
-
background: #2a2a2a;
327
327
-
border: 2px solid #4a4a4a;
328
328
-
border-radius: 8px;
329
329
-
padding: 16px;
330
330
-
color: #ffffff;
331
331
-
font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace;
332
332
-
font-size: 14px;
333
333
-
line-height: 1.4;
334
334
-
resize: vertical;
335
335
-
min-height: 120px;
336
336
-
transition: border-color 0.2s ease;
337
337
-
}
338
338
-
339
339
-
textarea:focus {
340
340
-
outline: none;
341
341
-
border-color: #ff6b35;
342
342
-
box-shadow: 0 0 0 3px rgba(255, 107, 53, 0.1);
343
343
-
}
344
344
-
345
345
-
textarea::placeholder {
346
346
-
color: #666;
347
347
-
}
348
348
-
349
349
-
.submit-btn {
350
350
-
background: linear-gradient(135deg, #ff6b35 0%, #ff8e53 100%);
351
351
-
color: white;
352
352
-
border: none;
353
353
-
padding: 16px 32px;
354
354
-
border-radius: 12px;
355
355
-
font-weight: 600;
356
356
-
font-size: 16px;
357
357
-
cursor: pointer;
358
358
-
transition: all 0.2s ease;
359
359
-
box-shadow: 0 4px 12px rgba(255, 107, 53, 0.3);
360
360
-
width: 100%;
361
361
-
}
362
362
-
363
363
-
.submit-btn:hover {
364
364
-
transform: translateY(-2px);
365
365
-
box-shadow: 0 8px 20px rgba(255, 107, 53, 0.4);
366
366
-
}
367
367
-
368
368
-
.submit-btn:disabled {
369
369
-
opacity: 0.6;
370
370
-
cursor: not-allowed;
371
371
-
transform: none;
372
372
-
}
373
373
-
374
374
-
.status {
375
375
-
margin-top: 16px;
376
376
-
padding: 12px;
377
377
-
border-radius: 8px;
378
378
-
font-size: 14px;
379
379
-
display: none;
380
380
-
}
381
381
-
382
382
-
.status.success {
383
383
-
background: rgba(52, 168, 83, 0.1);
384
384
-
border: 1px solid rgba(52, 168, 83, 0.3);
385
385
-
color: #34a853;
386
386
-
}
387
387
-
388
388
-
.status.error {
389
389
-
background: rgba(234, 67, 53, 0.1);
390
390
-
border: 1px solid rgba(234, 67, 53, 0.3);
391
391
-
color: #ea4335;
392
392
-
}
393
393
-
</style>
394
394
-
</head>
395
395
-
<body>
396
396
-
<div class="container">
397
397
-
<div class="logo">A</div>
398
398
-
<h1>Anthropic Authentication</h1>
399
399
-
<p class="subtitle">Connect your Anthropic account to continue</p>
400
400
-
401
401
-
<div class="step">
402
402
-
<div class="step-title">
403
403
-
<span class="step-number">1</span>
404
404
-
Authorize with Anthropic
405
405
-
</div>
406
406
-
<div class="step-description">
407
407
-
Click the button below to open the Anthropic authorization page
408
408
-
</div>
409
409
-
</div>
410
410
-
411
411
-
<a href="$AUTH_URL" class="button" target="_blank">
412
412
-
Open Anthropic Authorization
413
413
-
</a>
414
414
-
415
415
-
<div class="step">
416
416
-
<div class="step-title">
417
417
-
<span class="step-number">2</span>
418
418
-
Paste your authorization token
419
419
-
</div>
420
420
-
<div class="step-description">
421
421
-
After authorizing, copy the token and paste it below
422
422
-
</div>
423
423
-
</div>
424
424
-
425
425
-
<form id="tokenForm">
426
426
-
<div class="input-group">
427
427
-
<label for="token">Authorization Token:</label>
428
428
-
<textarea
429
429
-
id="token"
430
430
-
name="token"
431
431
-
placeholder="Paste your token here..."
432
432
-
required
433
433
-
></textarea>
434
434
-
</div>
435
435
-
<button type="submit" class="submit-btn" id="submitBtn">
436
436
-
Complete Authentication
437
437
-
</button>
438
438
-
</form>
439
439
-
440
440
-
<div id="status" class="status"></div>
441
441
-
</div>
442
442
-
443
443
-
<script>
444
444
-
document.getElementById('tokenForm').addEventListener('submit', function(e) {
445
445
-
e.preventDefault();
446
446
-
447
447
-
const token = document.getElementById('token').value.trim();
448
448
-
if (!token) {
449
449
-
showStatus('Please paste your authorization token', 'error');
450
450
-
return;
451
451
-
}
452
452
-
453
453
-
// Ensure token has content before creating file
454
454
-
if (token.length > 0) {
455
455
-
// Save the token as a downloadable file
456
456
-
const blob = new Blob([token], { type: 'text/plain' });
457
457
-
const a = document.createElement('a');
458
458
-
a.href = URL.createObjectURL(blob);
459
459
-
a.download = "anthropic_token.txt";
460
460
-
document.body.appendChild(a); // Append to body to ensure it works in all browsers
461
461
-
a.click();
462
462
-
document.body.removeChild(a); // Clean up
463
463
-
464
464
-
// Verify file creation
465
465
-
console.log("Token file created with content length: " + token.length);
466
466
-
} else {
467
467
-
showStatus('Empty token detected, please provide a valid token', 'error');
468
468
-
return;
469
469
-
}
470
470
-
471
471
-
document.getElementById('submitBtn').disabled = true;
472
472
-
document.getElementById('submitBtn').textContent = "Token saved, you may close this tab.";
473
473
-
showStatus('Token file downloaded! You can close this window.', 'success');
474
474
-
475
475
-
// setTimeout(() => {
476
476
-
// window.close();
477
477
-
// }, 2000);
478
478
-
});
479
479
-
480
480
-
function showStatus(message, type) {
481
481
-
const status = document.getElementById('status');
482
482
-
status.textContent = message;
483
483
-
status.className = 'status ' + type;
484
484
-
status.style.display = 'block';
485
485
-
}
486
486
-
487
487
-
// Auto-close after 10 minutes
488
488
-
setTimeout(() => {
489
489
-
window.close();
490
490
-
}, 600000);
491
491
-
</script>
492
492
-
</body>
493
493
-
</html>
494
494
-
EOF
495
495
-
496
496
-
# Open the HTML file
497
497
-
if command -v xdg-open >/dev/null 2>&1; then
498
498
-
xdg-open "$TEMP_HTML" >/dev/null 2>&1 &
499
499
-
elif command -v open >/dev/null 2>&1; then
500
500
-
open "$TEMP_HTML" >/dev/null 2>&1 &
501
501
-
elif command -v start >/dev/null 2>&1; then
502
502
-
start "$TEMP_HTML" >/dev/null 2>&1 &
503
503
-
fi
504
504
-
505
505
-
# Wait for user to download the token file
506
506
-
TOKEN_FILE="$HOME/Downloads/anthropic_token.txt"
507
507
-
508
508
-
for i in $(seq 1 60); do
509
509
-
if [ -f "$TOKEN_FILE" ]; then
510
510
-
AUTH_CODE=$(cat "$TOKEN_FILE" | tr -d '\r\n')
511
511
-
rm -f "$TOKEN_FILE"
512
512
-
break
513
513
-
fi
514
514
-
sleep 2
515
515
-
done
516
516
-
517
517
-
# Clean up the temporary HTML file
518
518
-
rm -f "$TEMP_HTML"
519
519
-
520
520
-
if [ -z "$AUTH_CODE" ]; then
521
521
-
exit 1
522
522
-
fi
523
523
-
524
524
-
# Exchange code for tokens
525
525
-
ACCESS_TOKEN=$(exchange_authorization_code "$AUTH_CODE" "$VERIFIER")
526
526
-
if [ -n "$ACCESS_TOKEN" ]; then
527
527
-
echo "$ACCESS_TOKEN"
528
528
-
exit 0
529
529
-
else
530
530
-
exit 1
531
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
4
+
import {
5
5
+
bootstrapFromDisk,
6
6
+
exchangeRefreshToken,
7
7
+
loadFromDisk,
8
8
+
saveToDisk,
9
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
36
+
37
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
66
-
const CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e";
67
67
-
68
68
-
async function exchangeRefreshToken(refreshToken: string) {
69
69
-
const res = await fetch("https://console.anthropic.com/v1/oauth/token", {
70
70
-
method: "POST",
71
71
-
headers: {
72
72
-
"content-type": "application/json",
73
73
-
"user-agent": "CRUSH/1.0",
74
74
-
},
75
75
-
body: JSON.stringify({
76
76
-
grant_type: "refresh_token",
77
77
-
refresh_token: refreshToken,
78
78
-
client_id: CLIENT_ID,
79
79
-
}),
80
80
-
});
81
81
-
if (!res.ok) throw new Error(`refresh failed: ${res.status}`);
82
82
-
return (await res.json()) as {
83
83
-
access_token: string;
84
84
-
refresh_token?: string;
85
85
-
expires_in: number;
86
86
-
};
87
87
-
}
88
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
125
-
const memory = new Map<
126
126
-
string,
127
127
-
{ accessToken: string; refreshToken: string; expiresAt: number }
128
128
-
>();
129
129
-
130
130
-
const HOME = Bun.env.HOME || Bun.env.USERPROFILE || ".";
131
131
-
const CACHE_DIR = `${HOME}/.config/crush/anthropic`;
132
132
-
const BEARER_FILE = `${CACHE_DIR}/bearer_token`;
133
133
-
const REFRESH_FILE = `${CACHE_DIR}/refresh_token`;
134
134
-
const EXPIRES_FILE = `${CACHE_DIR}/bearer_token.expires`;
135
135
-
136
136
-
async function ensureDir() {
137
137
-
await Bun.$`mkdir -p ${CACHE_DIR}`;
138
138
-
}
139
139
-
140
140
-
async function writeSecret(path: string, data: string) {
141
141
-
await Bun.write(path, data);
142
142
-
await Bun.$`chmod 600 ${path}`;
143
143
-
}
144
144
-
145
145
-
async function readText(path: string) {
146
146
-
const f = Bun.file(path);
147
147
-
if (!(await f.exists())) return undefined;
148
148
-
return await f.text();
149
149
-
}
150
150
-
151
151
-
async function loadFromDisk() {
152
152
-
const [bearer, refresh, expires] = await Promise.all([
153
153
-
readText(BEARER_FILE),
154
154
-
readText(REFRESH_FILE),
155
155
-
readText(EXPIRES_FILE),
156
156
-
]);
157
157
-
if (!bearer || !refresh || !expires) return undefined;
158
158
-
const exp = Number.parseInt(expires, 10) || 0;
159
159
-
return {
160
160
-
accessToken: bearer.trim(),
161
161
-
refreshToken: refresh.trim(),
162
162
-
expiresAt: exp,
163
163
-
};
164
164
-
}
165
165
-
166
166
-
async function saveToDisk(entry: {
167
167
-
accessToken: string;
168
168
-
refreshToken: string;
169
169
-
expiresAt: number;
170
170
-
}) {
171
171
-
await ensureDir();
172
172
-
await writeSecret(BEARER_FILE, `${entry.accessToken}\n`);
173
173
-
await writeSecret(REFRESH_FILE, `${entry.refreshToken}\n`);
174
174
-
await writeSecret(EXPIRES_FILE, `${String(entry.expiresAt)}\n`);
175
175
-
}
176
176
-
177
177
-
let serverStarted = false;
178
178
-
179
179
-
async function bootstrapFromDisk() {
180
180
-
const entry = await loadFromDisk();
181
181
-
if (!entry) return false;
182
182
-
const now = Math.floor(Date.now() / 1000);
183
183
-
if (now < entry.expiresAt - 60) {
184
184
-
Bun.write(Bun.stdout, `${entry.accessToken}\n`);
185
185
-
setTimeout(() => process.exit(0), 50);
186
186
-
memory.set("tokens", entry);
187
187
-
return true;
188
188
-
}
189
189
-
try {
190
190
-
const refreshed = await exchangeRefreshToken(entry.refreshToken);
191
191
-
entry.accessToken = refreshed.access_token;
192
192
-
entry.expiresAt = Math.floor(Date.now() / 1000) + refreshed.expires_in;
193
193
-
if (refreshed.refresh_token) entry.refreshToken = refreshed.refresh_token;
194
194
-
await saveToDisk(entry);
195
195
-
memory.set("tokens", entry);
196
196
-
Bun.write(Bun.stdout, `${entry.accessToken}\n`);
197
197
-
setTimeout(() => process.exit(0), 50);
198
198
-
return true;
199
199
-
} catch {
200
200
-
return false;
201
201
-
}
202
202
-
}
203
203
-
110
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
132
+
// Only start the server and open the browser if we didn't bootstrap from disk
133
133
+
const memory = new Map<
134
134
+
string,
135
135
+
{ accessToken: string; refreshToken: string; expiresAt: number }
136
136
+
>();
137
137
+
225
138
serve({
226
139
port: PORT,
227
140
development: { console: false },
···
301
214
error() {},
302
215
});
303
216
304
304
-
if (!serverStarted) {
305
305
-
serverStarted = true;
306
306
-
const url = `http://localhost:${PORT}`;
307
307
-
const tryRun = async (cmd: string, ...args: string[]) => {
308
308
-
try {
309
309
-
await Bun.$`${[cmd, ...args]}`.quiet();
310
310
-
return true;
311
311
-
} catch {
312
312
-
return false;
313
313
-
}
314
314
-
};
315
315
-
(async () => {
316
316
-
if (process.platform === "darwin") {
317
317
-
if (await tryRun("open", url)) return;
318
318
-
} else if (process.platform === "win32") {
319
319
-
if (await tryRun("cmd", "/c", "start", "", url)) return;
320
320
-
} else {
321
321
-
if (await tryRun("xdg-open", url)) return;
322
322
-
}
323
323
-
})();
217
217
+
// Open browser
218
218
+
const url = `http://localhost:${PORT}`;
219
219
+
if (process.platform === "darwin") {
220
220
+
Bun.$`open ${url}`.catch(() => {});
221
221
+
} else if (process.platform === "win32") {
222
222
+
Bun.$`cmd /c start "" ${url}`.catch(() => {});
223
223
+
} else {
224
224
+
Bun.$`xdg-open ${url}`.catch(() => {});
324
225
}
325
226
}
+5
-4
package.json
···
1
1
{
2
2
"name": "anthropic-api-key",
3
3
-
"version": "0.1.2",
3
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
18
-
"anthropic": "dist/anthropic.js"
18
18
+
"anthropic": "dist/index.js"
19
19
},
20
20
"exports": {
21
21
-
".": "./dist/anthropic.js"
21
21
+
".": "./dist/index.js",
22
22
+
"./lib/token": "./dist/lib/token.js"
22
23
},
23
24
"files": [
24
25
"dist",
25
26
"public"
26
27
],
27
28
"scripts": {
28
28
-
"build": "bun build bin/anthropic.ts --outdir=dist --target=bun --sourcemap=external",
29
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
1
+
const CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e";
2
2
+
3
3
+
const HOME = Bun.env.HOME || Bun.env.USERPROFILE || ".";
4
4
+
const CACHE_DIR = `${HOME}/.config/crush/anthropic`;
5
5
+
const BEARER_FILE = `${CACHE_DIR}/bearer_token`;
6
6
+
const REFRESH_FILE = `${CACHE_DIR}/refresh_token`;
7
7
+
const EXPIRES_FILE = `${CACHE_DIR}/bearer_token.expires`;
8
8
+
9
9
+
export type TokenEntry = {
10
10
+
accessToken: string;
11
11
+
refreshToken: string;
12
12
+
expiresAt: number;
13
13
+
};
14
14
+
15
15
+
export async function ensureDir() {
16
16
+
await Bun.$`mkdir -p ${CACHE_DIR}`;
17
17
+
}
18
18
+
19
19
+
export async function writeSecret(path: string, data: string) {
20
20
+
await Bun.write(path, data);
21
21
+
await Bun.$`chmod 600 ${path}`;
22
22
+
}
23
23
+
24
24
+
export async function readText(path: string) {
25
25
+
const f = Bun.file(path);
26
26
+
if (!(await f.exists())) return undefined;
27
27
+
return await f.text();
28
28
+
}
29
29
+
30
30
+
export async function loadFromDisk(): Promise<TokenEntry | undefined> {
31
31
+
const [bearer, refresh, expires] = await Promise.all([
32
32
+
readText(BEARER_FILE),
33
33
+
readText(REFRESH_FILE),
34
34
+
readText(EXPIRES_FILE),
35
35
+
]);
36
36
+
if (!bearer || !refresh || !expires) return undefined;
37
37
+
const exp = Number.parseInt(expires, 10) || 0;
38
38
+
return {
39
39
+
accessToken: bearer.trim(),
40
40
+
refreshToken: refresh.trim(),
41
41
+
expiresAt: exp,
42
42
+
};
43
43
+
}
44
44
+
45
45
+
export async function saveToDisk(entry: TokenEntry) {
46
46
+
await ensureDir();
47
47
+
await writeSecret(BEARER_FILE, `${entry.accessToken}\n`);
48
48
+
await writeSecret(REFRESH_FILE, `${entry.refreshToken}\n`);
49
49
+
await writeSecret(EXPIRES_FILE, `${String(entry.expiresAt)}\n`);
50
50
+
}
51
51
+
52
52
+
export async function exchangeRefreshToken(refreshToken: string) {
53
53
+
const res = await fetch("https://console.anthropic.com/v1/oauth/token", {
54
54
+
method: "POST",
55
55
+
headers: {
56
56
+
"content-type": "application/json",
57
57
+
"user-agent": "CRUSH/1.0",
58
58
+
},
59
59
+
body: JSON.stringify({
60
60
+
grant_type: "refresh_token",
61
61
+
refresh_token: refreshToken,
62
62
+
client_id: CLIENT_ID,
63
63
+
}),
64
64
+
});
65
65
+
if (!res.ok) throw new Error(`refresh failed: ${res.status}`);
66
66
+
return (await res.json()) as {
67
67
+
access_token: string;
68
68
+
refresh_token?: string;
69
69
+
expires_in: number;
70
70
+
};
71
71
+
}
72
72
+
73
73
+
/**
74
74
+
* Attempts to load a valid token from disk, refresh if needed, and print it to stdout.
75
75
+
* Returns true if a valid token was found and printed, false otherwise.
76
76
+
*/
77
77
+
export async function bootstrapFromDisk(): Promise<boolean> {
78
78
+
const entry = await loadFromDisk();
79
79
+
if (!entry) return false;
80
80
+
const now = Math.floor(Date.now() / 1000);
81
81
+
if (now < entry.expiresAt - 60) {
82
82
+
Bun.write(Bun.stdout, `${entry.accessToken}\n`);
83
83
+
setTimeout(() => process.exit(0), 50);
84
84
+
return true;
85
85
+
}
86
86
+
try {
87
87
+
const refreshed = await exchangeRefreshToken(entry.refreshToken);
88
88
+
entry.accessToken = refreshed.access_token;
89
89
+
entry.expiresAt = Math.floor(Date.now() / 1000) + refreshed.expires_in;
90
90
+
if (refreshed.refresh_token) entry.refreshToken = refreshed.refresh_token;
91
91
+
await saveToDisk(entry);
92
92
+
Bun.write(Bun.stdout, `${entry.accessToken}\n`);
93
93
+
setTimeout(() => process.exit(0), 50);
94
94
+
return true;
95
95
+
} catch {
96
96
+
return false;
97
97
+
}
98
98
+
}