@suncreation/crush-auth-proxy 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 suncreation
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,95 @@
1
+ # @suncreation/crush-auth-proxy
2
+
3
+ Use your **Claude Max subscription** (OAuth) with [Crush CLI](https://github.com/charmbracelet/crush) — no separate Anthropic API key needed.
4
+
5
+ This proxy sits between Crush and Anthropic's API, injecting Claude Code OAuth credentials so you can use your existing Claude Max plan.
6
+
7
+ ## Quick Start
8
+
9
+ ```bash
10
+ # 1. Login with your Claude account (one-time)
11
+ npx @suncreation/crush-auth-proxy setup-token
12
+
13
+ # 2. Start the proxy daemon
14
+ npx @suncreation/crush-auth-proxy start
15
+
16
+ # 3. Configure Crush (see below)
17
+
18
+ # 4. Use Crush normally
19
+ crush run --model anthropic/claude-opus-4-6 "Hello!"
20
+ ```
21
+
22
+ ## Commands
23
+
24
+ | Command | Description |
25
+ |---------|-------------|
26
+ | `setup-token` | OAuth login — opens browser, saves token locally |
27
+ | `start` | Start proxy as background daemon (port 18080) |
28
+ | `stop` | Stop the daemon |
29
+ | `restart` | Restart the daemon |
30
+ | `status` | Check if proxy is running + token validity |
31
+ | `logs` | Tail daemon logs |
32
+
33
+ ### Options
34
+
35
+ | Option | Description |
36
+ |--------|-------------|
37
+ | `--port <n>` | Custom port (default: 18080) |
38
+ | `--foreground` | Run in foreground instead of daemon |
39
+
40
+ ## Crush Configuration
41
+
42
+ Create or edit `~/.config/crush/crush.json`:
43
+
44
+ ```json
45
+ {
46
+ "providers": {
47
+ "anthropic": {
48
+ "id": "anthropic",
49
+ "type": "anthropic",
50
+ "base_url": "http://127.0.0.1:18080",
51
+ "api_key": "proxy-handled"
52
+ }
53
+ }
54
+ }
55
+ ```
56
+
57
+ ## How It Works
58
+
59
+ The proxy replicates Claude Code's authentication mechanism:
60
+
61
+ 1. **OAuth PKCE Flow** — Authenticates via `claude.ai` using the same client credentials as Claude Code
62
+ 2. **Request Transformation** — Rewrites headers (User-Agent, auth), adds required beta flags, prefixes tool names with `mcp_`
63
+ 3. **System Prompt Injection** — Prepends the Claude Code system prompt identifier
64
+ 4. **Response Streaming** — Strips `mcp_` prefixes from streamed responses
65
+ 5. **Token Auto-Refresh** — Automatically refreshes expired OAuth tokens
66
+
67
+ ## Per-User Storage
68
+
69
+ All config is stored in `~/.config/crush-auth-proxy/`:
70
+
71
+ ```
72
+ ~/.config/crush-auth-proxy/
73
+ ├── claude-oauth-token.json # Your OAuth tokens (chmod 0600)
74
+ ├── proxy.pid # Daemon PID
75
+ └── proxy.log # Daemon logs
76
+ ```
77
+
78
+ ## Requirements
79
+
80
+ - Node.js >= 18
81
+ - A Claude Max subscription (claude.ai account)
82
+ - [Crush CLI](https://github.com/charmbracelet/crush) installed
83
+
84
+ ## Supported Models
85
+
86
+ All Anthropic models available through your Claude Max subscription, including:
87
+
88
+ - `anthropic/claude-opus-4-6`
89
+ - `anthropic/claude-sonnet-4-5-20250514`
90
+ - `anthropic/claude-haiku-3-5-20241022`
91
+ - And more
92
+
93
+ ## License
94
+
95
+ MIT
@@ -0,0 +1,735 @@
1
+ #!/usr/bin/env node
2
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
3
+ // crush-auth-proxy — Claude Code Auth Proxy for Crush CLI
4
+ //
5
+ // Uses your Claude Max subscription via OAuth (same as Claude Code)
6
+ // so you don't need a separate Anthropic API key.
7
+ //
8
+ // Usage:
9
+ // crush-auth-proxy setup-token # OAuth login (first time)
10
+ // crush-auth-proxy start # start as background daemon
11
+ // crush-auth-proxy stop # stop daemon
12
+ // crush-auth-proxy status # check if running
13
+ // crush-auth-proxy restart # restart daemon
14
+ // crush-auth-proxy logs # tail daemon logs
15
+ // crush-auth-proxy start --port 9090 # custom port
16
+ // crush-auth-proxy --foreground # run in foreground (no daemon)
17
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
18
+
19
+ import http from "node:http";
20
+ import https from "node:https";
21
+ import crypto from "node:crypto";
22
+ import fs from "node:fs";
23
+ import { execSync, spawn } from "node:child_process";
24
+ import path from "node:path";
25
+ import os from "node:os";
26
+ import { URL, URLSearchParams } from "node:url";
27
+ import { fileURLToPath } from "node:url";
28
+
29
+ const __filename = fileURLToPath(import.meta.url);
30
+
31
+ // ── CLI Args ───────────────────────────────────────────────────
32
+
33
+ const args = process.argv.slice(2);
34
+ const command = args[0];
35
+
36
+ // ── Constants ──────────────────────────────────────────────────
37
+
38
+ const CONFIG_DIR = path.join(os.homedir(), ".config", "crush-auth-proxy");
39
+ const TOKEN_FILE = path.join(CONFIG_DIR, "claude-oauth-token.json");
40
+ const PID_FILE = path.join(CONFIG_DIR, "proxy.pid");
41
+ const LOG_FILE = path.join(CONFIG_DIR, "proxy.log");
42
+
43
+ const ANTHROPIC_API = "api.anthropic.com";
44
+ const OAUTH_HOST = "claude.ai";
45
+ const TOKEN_HOST = "console.anthropic.com";
46
+ const CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e";
47
+ const SPOOFED_UA = "claude-cli/2.1.2 (external, cli)";
48
+ const SYSTEM_PREFIX =
49
+ "You are Claude Code, Anthropic's official CLI for Claude.";
50
+
51
+ // Port: --port flag > PORT env > 18080
52
+ let DEFAULT_PORT = 18080;
53
+ const portIdx = args.indexOf("--port");
54
+ if (portIdx !== -1 && args[portIdx + 1]) {
55
+ DEFAULT_PORT = parseInt(args[portIdx + 1], 10);
56
+ } else if (process.env.PORT) {
57
+ DEFAULT_PORT = parseInt(process.env.PORT, 10);
58
+ }
59
+
60
+ // Ensure config directory exists
61
+ fs.mkdirSync(CONFIG_DIR, { recursive: true });
62
+
63
+ // ── Help ───────────────────────────────────────────────────────
64
+
65
+ if (args.includes("--help") || args.includes("-h")) {
66
+ console.log(`
67
+ crush-auth-proxy — Claude Code Auth Proxy for Crush CLI
68
+
69
+ Uses your Claude Max subscription via OAuth so you don't need
70
+ a separate Anthropic API key with Crush.
71
+
72
+ COMMANDS:
73
+ setup-token OAuth login (run this first!)
74
+ start Start proxy as background daemon
75
+ stop Stop the background daemon
76
+ restart Restart the daemon
77
+ status Check if proxy is running
78
+ logs Tail the proxy log
79
+
80
+ OPTIONS:
81
+ --port <port> Custom port (default: 18080)
82
+ --foreground Run in foreground (no daemon)
83
+ --help, -h Show this help
84
+
85
+ ENVIRONMENT:
86
+ PORT Override default port (18080)
87
+
88
+ QUICK START:
89
+ 1. npx @suncreation/crush-auth-proxy setup-token
90
+ 2. Add to ~/.config/crush/crush.json:
91
+ {
92
+ "providers": {
93
+ "anthropic": {
94
+ "id": "anthropic",
95
+ "type": "anthropic",
96
+ "base_url": "http://127.0.0.1:18080",
97
+ "api_key": "proxy-handled"
98
+ }
99
+ }
100
+ }
101
+ 3. npx @suncreation/crush-auth-proxy start
102
+ 4. crush run --model anthropic/claude-opus-4-6 "hello"
103
+
104
+ CONFIG DIR: ~/.config/crush-auth-proxy/
105
+ `);
106
+ process.exit(0);
107
+ }
108
+
109
+ // ── PID Helpers ────────────────────────────────────────────────
110
+
111
+ function readPid() {
112
+ try {
113
+ return parseInt(fs.readFileSync(PID_FILE, "utf8").trim(), 10);
114
+ } catch {
115
+ return null;
116
+ }
117
+ }
118
+
119
+ function isProcessRunning(pid) {
120
+ try {
121
+ process.kill(pid, 0);
122
+ return true;
123
+ } catch {
124
+ return false;
125
+ }
126
+ }
127
+
128
+ function getRunningPid() {
129
+ const pid = readPid();
130
+ if (pid && isProcessRunning(pid)) return pid;
131
+ if (pid) try { fs.unlinkSync(PID_FILE); } catch {}
132
+ return null;
133
+ }
134
+
135
+ function sleepMs(ms) {
136
+ return new Promise((r) => setTimeout(r, ms));
137
+ }
138
+
139
+ // ── Token Storage ──────────────────────────────────────────────
140
+
141
+ function loadToken() {
142
+ try {
143
+ return JSON.parse(fs.readFileSync(TOKEN_FILE, "utf8"));
144
+ } catch {
145
+ return null;
146
+ }
147
+ }
148
+
149
+ function saveToken(token) {
150
+ fs.mkdirSync(path.dirname(TOKEN_FILE), { recursive: true });
151
+ fs.writeFileSync(TOKEN_FILE, JSON.stringify(token, null, 2), {
152
+ mode: 0o600,
153
+ });
154
+ }
155
+
156
+ // ── OAuth PKCE Flow ────────────────────────────────────────────
157
+
158
+ function base64url(buf) {
159
+ return buf.toString("base64url");
160
+ }
161
+
162
+ function generatePKCE() {
163
+ const verifier = base64url(crypto.randomBytes(32));
164
+ const challenge = base64url(
165
+ crypto.createHash("sha256").update(verifier).digest()
166
+ );
167
+ return { verifier, challenge };
168
+ }
169
+
170
+ async function httpsRequest(options, body) {
171
+ return new Promise((resolve, reject) => {
172
+ const req = https.request(options, (res) => {
173
+ let data = "";
174
+ res.on("data", (c) => (data += c));
175
+ res.on("end", () =>
176
+ resolve({ status: res.statusCode, body: data, headers: res.headers })
177
+ );
178
+ });
179
+ req.on("error", reject);
180
+ if (body) req.write(body);
181
+ req.end();
182
+ });
183
+ }
184
+
185
+ async function refreshToken(token) {
186
+ const params = new URLSearchParams({
187
+ grant_type: "refresh_token",
188
+ refresh_token: token.refresh_token,
189
+ client_id: CLIENT_ID,
190
+ });
191
+ const body = params.toString();
192
+ const res = await httpsRequest(
193
+ {
194
+ hostname: TOKEN_HOST,
195
+ path: "/v1/oauth/token",
196
+ method: "POST",
197
+ headers: {
198
+ "Content-Type": "application/x-www-form-urlencoded",
199
+ "Content-Length": Buffer.byteLength(body),
200
+ "User-Agent": SPOOFED_UA,
201
+ },
202
+ },
203
+ body
204
+ );
205
+ if (res.status !== 200) {
206
+ console.error("[proxy] Token refresh failed:", res.status, res.body);
207
+ return null;
208
+ }
209
+ const data = JSON.parse(res.body);
210
+ const newToken = {
211
+ access_token: data.access_token,
212
+ refresh_token: data.refresh_token || token.refresh_token,
213
+ expires_at: Date.now() + (data.expires_in || 28800) * 1000,
214
+ };
215
+ saveToken(newToken);
216
+ return newToken;
217
+ }
218
+
219
+ async function doOAuthLogin() {
220
+ const { verifier, challenge } = generatePKCE();
221
+ const state = base64url(crypto.randomBytes(16));
222
+
223
+ return new Promise((resolve, reject) => {
224
+ const callbackServer = http.createServer(async (req, res) => {
225
+ const url = new URL(req.url, "http://localhost");
226
+ if (!url.pathname.startsWith("/oauth/callback")) {
227
+ res.writeHead(404);
228
+ res.end("Not found");
229
+ return;
230
+ }
231
+ const code = url.searchParams.get("code");
232
+ const returnedState = url.searchParams.get("state");
233
+ if (returnedState !== state) {
234
+ res.writeHead(400);
235
+ res.end("State mismatch");
236
+ return;
237
+ }
238
+
239
+ const params = new URLSearchParams({
240
+ grant_type: "authorization_code",
241
+ code,
242
+ redirect_uri: `http://localhost:${callbackPort}/oauth/callback`,
243
+ client_id: CLIENT_ID,
244
+ code_verifier: verifier,
245
+ });
246
+ const body = params.toString();
247
+
248
+ try {
249
+ const tokenRes = await httpsRequest(
250
+ {
251
+ hostname: TOKEN_HOST,
252
+ path: "/v1/oauth/token",
253
+ method: "POST",
254
+ headers: {
255
+ "Content-Type": "application/x-www-form-urlencoded",
256
+ "Content-Length": Buffer.byteLength(body),
257
+ "User-Agent": SPOOFED_UA,
258
+ },
259
+ },
260
+ body
261
+ );
262
+
263
+ if (tokenRes.status !== 200) {
264
+ res.writeHead(500);
265
+ res.end("Token exchange failed: " + tokenRes.body);
266
+ callbackServer.close();
267
+ reject(new Error("Token exchange failed"));
268
+ return;
269
+ }
270
+
271
+ const data = JSON.parse(tokenRes.body);
272
+ const token = {
273
+ access_token: data.access_token,
274
+ refresh_token: data.refresh_token,
275
+ expires_at: Date.now() + (data.expires_in || 28800) * 1000,
276
+ };
277
+ saveToken(token);
278
+
279
+ res.writeHead(200, { "Content-Type": "text/html" });
280
+ res.end(
281
+ "<html><body><h2>Login successful! You can close this tab.</h2></body></html>"
282
+ );
283
+ callbackServer.close();
284
+ resolve(token);
285
+ } catch (e) {
286
+ res.writeHead(500);
287
+ res.end("Error: " + e.message);
288
+ callbackServer.close();
289
+ reject(e);
290
+ }
291
+ });
292
+
293
+ let callbackPort;
294
+ callbackServer.listen(0, () => {
295
+ callbackPort = callbackServer.address().port;
296
+ const authUrl =
297
+ `https://${OAUTH_HOST}/oauth/authorize?` +
298
+ new URLSearchParams({
299
+ response_type: "code",
300
+ client_id: CLIENT_ID,
301
+ redirect_uri: `http://localhost:${callbackPort}/oauth/callback`,
302
+ scope: "user:inference",
303
+ state,
304
+ code_challenge: challenge,
305
+ code_challenge_method: "S256",
306
+ }).toString();
307
+
308
+ console.log("[proxy] Opening browser for OAuth login...");
309
+ console.log("[proxy] If browser doesn't open, visit:", authUrl);
310
+ try {
311
+ const platform = os.platform();
312
+ if (platform === "darwin") {
313
+ execSync(`open "${authUrl}"`);
314
+ } else if (platform === "win32") {
315
+ execSync(`start "${authUrl}"`);
316
+ } else {
317
+ execSync(`xdg-open "${authUrl}"`);
318
+ }
319
+ } catch {
320
+ console.log("[proxy] Could not open browser automatically.");
321
+ }
322
+ });
323
+
324
+ setTimeout(() => {
325
+ callbackServer.close();
326
+ reject(new Error("OAuth login timed out after 120s"));
327
+ }, 120000);
328
+ });
329
+ }
330
+
331
+ // ── Get Valid Token ────────────────────────────────────────────
332
+
333
+ async function getValidToken() {
334
+ let token = loadToken();
335
+
336
+ if (!token) {
337
+ console.log("[proxy] No saved token found. Starting OAuth login...");
338
+ token = await doOAuthLogin();
339
+ }
340
+
341
+ if (token.expires_at - Date.now() < 5 * 60 * 1000) {
342
+ console.log("[proxy] Token expiring soon, refreshing...");
343
+ const refreshed = await refreshToken(token);
344
+ if (refreshed) return refreshed;
345
+ console.log("[proxy] Refresh failed, starting fresh login...");
346
+ return doOAuthLogin();
347
+ }
348
+
349
+ return token;
350
+ }
351
+
352
+ // ── Command: setup-token ───────────────────────────────────────
353
+
354
+ if (command === "setup-token") {
355
+ console.log("[setup] Starting OAuth login for Claude Max...");
356
+ console.log(`[setup] Tokens will be saved to: ${TOKEN_FILE}`);
357
+ try {
358
+ const token = await doOAuthLogin();
359
+ console.log("[setup] Login successful!");
360
+ console.log(`[setup] Token saved to: ${TOKEN_FILE}`);
361
+ console.log(`[setup] Expires: ${new Date(token.expires_at).toLocaleString()}`);
362
+ console.log("\n[setup] Now start the proxy:");
363
+ console.log(" npx @suncreation/crush-auth-proxy start\n");
364
+ } catch (e) {
365
+ console.error("[setup] Login failed:", e.message);
366
+ process.exit(1);
367
+ }
368
+ process.exit(0);
369
+ }
370
+
371
+ // ── Command: status ────────────────────────────────────────────
372
+
373
+ if (command === "status") {
374
+ const pid = getRunningPid();
375
+ if (pid) {
376
+ console.log(`[proxy] Running (PID ${pid}) on port ${DEFAULT_PORT}`);
377
+ try {
378
+ const res = execSync(`curl -s http://127.0.0.1:${DEFAULT_PORT}/health`, {
379
+ timeout: 3000,
380
+ }).toString();
381
+ console.log(`[proxy] Health: ${res}`);
382
+ } catch {
383
+ console.log("[proxy] Warning: PID exists but health check failed");
384
+ }
385
+ } else {
386
+ console.log("[proxy] Not running");
387
+ }
388
+ const token = loadToken();
389
+ if (token) {
390
+ const remaining = token.expires_at - Date.now();
391
+ if (remaining > 0) {
392
+ console.log(`[proxy] Token valid (expires: ${new Date(token.expires_at).toLocaleString()})`);
393
+ } else {
394
+ console.log(`[proxy] Token expired — will auto-refresh on next request`);
395
+ }
396
+ } else {
397
+ console.log("[proxy] No token — run: crush-auth-proxy setup-token");
398
+ }
399
+ process.exit(0);
400
+ }
401
+
402
+ // ── Command: stop ──────────────────────────────────────────────
403
+
404
+ if (command === "stop") {
405
+ const pid = getRunningPid();
406
+ if (!pid) {
407
+ console.log("[proxy] Not running");
408
+ process.exit(0);
409
+ }
410
+ try {
411
+ process.kill(pid, "SIGTERM");
412
+ for (let i = 0; i < 50; i++) {
413
+ if (!isProcessRunning(pid)) break;
414
+ await sleepMs(100);
415
+ }
416
+ if (isProcessRunning(pid)) process.kill(pid, "SIGKILL");
417
+ try { fs.unlinkSync(PID_FILE); } catch {}
418
+ console.log(`[proxy] Stopped (PID ${pid})`);
419
+ } catch (e) {
420
+ console.error(`[proxy] Failed to stop PID ${pid}:`, e.message);
421
+ try { fs.unlinkSync(PID_FILE); } catch {}
422
+ }
423
+ process.exit(0);
424
+ }
425
+
426
+ // ── Command: restart ───────────────────────────────────────────
427
+
428
+ if (command === "restart") {
429
+ const pid = getRunningPid();
430
+ if (pid) {
431
+ try {
432
+ process.kill(pid, "SIGTERM");
433
+ for (let i = 0; i < 50; i++) {
434
+ if (!isProcessRunning(pid)) break;
435
+ await sleepMs(100);
436
+ }
437
+ if (isProcessRunning(pid)) process.kill(pid, "SIGKILL");
438
+ try { fs.unlinkSync(PID_FILE); } catch {}
439
+ console.log(`[proxy] Stopped old process (PID ${pid})`);
440
+ } catch {}
441
+ }
442
+ // Fall through to start logic below
443
+ }
444
+
445
+ // ── Command: logs ──────────────────────────────────────────────
446
+
447
+ if (command === "logs") {
448
+ if (!fs.existsSync(LOG_FILE)) {
449
+ console.log("[proxy] No log file yet:", LOG_FILE);
450
+ process.exit(0);
451
+ }
452
+ try {
453
+ execSync(`tail -f "${LOG_FILE}"`, { stdio: "inherit" });
454
+ } catch {}
455
+ process.exit(0);
456
+ }
457
+
458
+ // ── Command: start (daemon mode) ──────────────────────────────
459
+
460
+ if (command === "start" || command === "restart") {
461
+ const existingPid = getRunningPid();
462
+ if (existingPid && command === "start") {
463
+ console.log(`[proxy] Already running (PID ${existingPid})`);
464
+ console.log("[proxy] Use 'restart' to restart, or 'stop' to stop");
465
+ process.exit(0);
466
+ }
467
+
468
+ const token = loadToken();
469
+ if (!token) {
470
+ console.log("[proxy] No token found. Run setup first:");
471
+ console.log(" npx @suncreation/crush-auth-proxy setup-token");
472
+ process.exit(1);
473
+ }
474
+
475
+ // Spawn detached child with --foreground
476
+ const portArgs = portIdx !== -1 ? ["--port", String(DEFAULT_PORT)] : [];
477
+ const logStream = fs.openSync(LOG_FILE, "a");
478
+
479
+ const child = spawn(process.execPath, [__filename, "--foreground", ...portArgs], {
480
+ detached: true,
481
+ stdio: ["ignore", logStream, logStream],
482
+ env: { ...process.env },
483
+ });
484
+
485
+ fs.writeFileSync(PID_FILE, String(child.pid));
486
+ child.unref();
487
+
488
+ await sleepMs(1000);
489
+
490
+ if (isProcessRunning(child.pid)) {
491
+ console.log(`[proxy] Started as daemon (PID ${child.pid})`);
492
+ console.log(`[proxy] Listening on http://127.0.0.1:${DEFAULT_PORT}`);
493
+ console.log(`[proxy] Logs: ${LOG_FILE}`);
494
+ console.log(`[proxy] Token expires: ${new Date(token.expires_at).toLocaleString()}`);
495
+ } else {
496
+ console.error("[proxy] Failed to start. Check logs:");
497
+ console.error(` tail ${LOG_FILE}`);
498
+ try { fs.unlinkSync(PID_FILE); } catch {}
499
+ process.exit(1);
500
+ }
501
+ process.exit(0);
502
+ }
503
+
504
+ // ── Default (no command) — show usage ──────────────────────────
505
+
506
+ if (!command || (command !== "--foreground" && !args.includes("--foreground"))) {
507
+ console.log(`crush-auth-proxy — Claude Code Auth Proxy for Crush CLI
508
+
509
+ COMMANDS:
510
+ setup-token OAuth login (run this first!)
511
+ start Start proxy as background daemon
512
+ stop Stop the daemon
513
+ restart Restart the daemon
514
+ status Check if running
515
+ logs Tail proxy logs
516
+ --help Full help
517
+
518
+ QUICK START:
519
+ npx @suncreation/crush-auth-proxy setup-token
520
+ npx @suncreation/crush-auth-proxy start
521
+ `);
522
+ process.exit(0);
523
+ }
524
+
525
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
526
+ // FOREGROUND SERVER — used by daemon spawn or --foreground flag
527
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
528
+
529
+ function readBody(req) {
530
+ return new Promise((resolve) => {
531
+ const chunks = [];
532
+ req.on("data", (c) => chunks.push(c));
533
+ req.on("end", () => resolve(Buffer.concat(chunks)));
534
+ });
535
+ }
536
+
537
+ // Write PID for foreground mode
538
+ fs.writeFileSync(PID_FILE, String(process.pid));
539
+
540
+ // Clean up on exit
541
+ function cleanup() {
542
+ try { fs.unlinkSync(PID_FILE); } catch {}
543
+ process.exit(0);
544
+ }
545
+ process.on("SIGTERM", cleanup);
546
+ process.on("SIGINT", cleanup);
547
+
548
+ const server = http.createServer(async (req, res) => {
549
+ // Health check
550
+ if (req.method === "GET" && req.url === "/health") {
551
+ res.writeHead(200);
552
+ res.end("ok");
553
+ return;
554
+ }
555
+
556
+ const bodyBuf = await readBody(req);
557
+ let token;
558
+ try {
559
+ token = await getValidToken();
560
+ } catch (e) {
561
+ console.error("[proxy] Auth error:", e.message);
562
+ res.writeHead(401, { "Content-Type": "application/json" });
563
+ res.end(JSON.stringify({ error: { type: "auth_error", message: e.message } }));
564
+ return;
565
+ }
566
+
567
+ // Parse and modify request body — match OpenCode's exact spoofing logic
568
+ const TOOL_PREFIX = "mcp_";
569
+ let bodyStr = bodyBuf.toString("utf8");
570
+ if (req.url?.includes("/messages") && bodyStr) {
571
+ try {
572
+ const parsed = JSON.parse(bodyStr);
573
+
574
+ // 1. System prompt: prepend Claude Code prefix
575
+ if (parsed.system) {
576
+ if (typeof parsed.system === "string") {
577
+ if (!parsed.system.includes(SYSTEM_PREFIX)) {
578
+ parsed.system = SYSTEM_PREFIX + "\n\n" + parsed.system;
579
+ }
580
+ } else if (Array.isArray(parsed.system)) {
581
+ const hasPrefix = parsed.system.some(
582
+ (b) => b.type === "text" && b.text?.includes(SYSTEM_PREFIX)
583
+ );
584
+ if (!hasPrefix) {
585
+ parsed.system.unshift({ type: "text", text: SYSTEM_PREFIX });
586
+ }
587
+ }
588
+ } else {
589
+ parsed.system = SYSTEM_PREFIX;
590
+ }
591
+
592
+ // 2. System prompt sanitization — replace app name references
593
+ if (parsed.system && Array.isArray(parsed.system)) {
594
+ parsed.system = parsed.system.map((item) => {
595
+ if (item.type === "text" && item.text) {
596
+ return {
597
+ ...item,
598
+ text: item.text
599
+ .replace(/Crush/g, "Claude Code")
600
+ .replace(/(?<!\/)crush/gi, "Claude"),
601
+ };
602
+ }
603
+ return item;
604
+ });
605
+ } else if (typeof parsed.system === "string") {
606
+ parsed.system = parsed.system
607
+ .replace(/Crush/g, "Claude Code")
608
+ .replace(/(?<!\/)crush/gi, "Claude");
609
+ }
610
+
611
+ // 3. Tool name mcp_ prefixing
612
+ if (parsed.tools && Array.isArray(parsed.tools)) {
613
+ parsed.tools = parsed.tools.map((tool) => ({
614
+ ...tool,
615
+ name: tool.name ? `${TOOL_PREFIX}${tool.name}` : tool.name,
616
+ }));
617
+ }
618
+
619
+ // 4. Prefix tool_use blocks in messages
620
+ if (parsed.messages && Array.isArray(parsed.messages)) {
621
+ parsed.messages = parsed.messages.map((msg) => {
622
+ if (msg.content && Array.isArray(msg.content)) {
623
+ msg.content = msg.content.map((block) => {
624
+ if (block.type === "tool_use" && block.name) {
625
+ return { ...block, name: `${TOOL_PREFIX}${block.name}` };
626
+ }
627
+ return block;
628
+ });
629
+ }
630
+ return msg;
631
+ });
632
+ }
633
+
634
+ bodyStr = JSON.stringify(parsed);
635
+ } catch {}
636
+ }
637
+
638
+ // Build upstream request
639
+ const upstreamPath = req.url?.includes("?")
640
+ ? req.url + "&beta=true"
641
+ : req.url + "?beta=true";
642
+
643
+ const upstreamHeaders = {
644
+ "Content-Type": "application/json",
645
+ "Content-Length": Buffer.byteLength(bodyStr),
646
+ Authorization: `Bearer ${token.access_token}`,
647
+ "anthropic-version": "2023-06-01",
648
+ "anthropic-beta": "oauth-2025-04-20,interleaved-thinking-2025-05-14",
649
+ "User-Agent": SPOOFED_UA,
650
+ };
651
+
652
+ const proxyReq = https.request(
653
+ {
654
+ hostname: ANTHROPIC_API,
655
+ port: 443,
656
+ path: upstreamPath,
657
+ method: req.method,
658
+ headers: upstreamHeaders,
659
+ },
660
+ (proxyRes) => {
661
+ if (proxyRes.statusCode === 401) {
662
+ let errBody = "";
663
+ proxyRes.on("data", (c) => (errBody += c));
664
+ proxyRes.on("end", async () => {
665
+ console.log("[proxy] Got 401, attempting token refresh...");
666
+ const refreshed = await refreshToken(token);
667
+ if (refreshed) {
668
+ console.log("[proxy] Token refreshed, retrying...");
669
+ upstreamHeaders.Authorization = `Bearer ${refreshed.access_token}`;
670
+ upstreamHeaders["Content-Length"] = Buffer.byteLength(bodyStr);
671
+ const retryReq = https.request(
672
+ {
673
+ hostname: ANTHROPIC_API,
674
+ port: 443,
675
+ path: upstreamPath,
676
+ method: req.method,
677
+ headers: upstreamHeaders,
678
+ },
679
+ (retryRes) => {
680
+ res.writeHead(retryRes.statusCode, retryRes.headers);
681
+ retryRes.on("data", (chunk) => {
682
+ let text = chunk.toString("utf8");
683
+ text = text.replace(/"name"\s*:\s*"mcp_([^"]+)"/g, '"name": "$1"');
684
+ res.write(text);
685
+ });
686
+ retryRes.on("end", () => res.end());
687
+ }
688
+ );
689
+ retryReq.on("error", (e) => {
690
+ res.writeHead(502);
691
+ res.end(JSON.stringify({ error: { message: e.message } }));
692
+ });
693
+ retryReq.write(bodyStr);
694
+ retryReq.end();
695
+ } else {
696
+ res.writeHead(401, { "Content-Type": "application/json" });
697
+ res.end(errBody);
698
+ }
699
+ });
700
+ return;
701
+ }
702
+
703
+ res.writeHead(proxyRes.statusCode, proxyRes.headers);
704
+ proxyRes.on("data", (chunk) => {
705
+ let text = chunk.toString("utf8");
706
+ text = text.replace(/"name"\s*:\s*"mcp_([^"]+)"/g, '"name": "$1"');
707
+ res.write(text);
708
+ });
709
+ proxyRes.on("end", () => res.end());
710
+ }
711
+ );
712
+
713
+ proxyReq.on("error", (e) => {
714
+ console.error("[proxy] Upstream error:", e.message);
715
+ res.writeHead(502, { "Content-Type": "application/json" });
716
+ res.end(JSON.stringify({ error: { type: "proxy_error", message: e.message } }));
717
+ });
718
+
719
+ proxyReq.write(bodyStr);
720
+ proxyReq.end();
721
+
722
+ console.log(`[proxy] ${req.method} ${req.url} → ${ANTHROPIC_API}${upstreamPath}`);
723
+ });
724
+
725
+ server.listen(DEFAULT_PORT, "127.0.0.1", () => {
726
+ console.log(`[proxy] Claude Code Auth Proxy listening on http://127.0.0.1:${DEFAULT_PORT}`);
727
+ console.log(`[proxy] PID: ${process.pid} | Config: ${CONFIG_DIR}`);
728
+
729
+ const token = loadToken();
730
+ if (token) {
731
+ console.log(`[proxy] Token loaded (expires: ${new Date(token.expires_at).toLocaleString()})`);
732
+ } else {
733
+ console.log("[proxy] No token — will prompt OAuth on first request");
734
+ }
735
+ });
package/package.json ADDED
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "@suncreation/crush-auth-proxy",
3
+ "version": "1.0.0",
4
+ "description": "Claude Code Auth Proxy for Crush CLI — Use your Claude Max subscription (OAuth) instead of API keys",
5
+ "type": "module",
6
+ "bin": {
7
+ "crush-auth-proxy": "bin/crush-auth-proxy.mjs"
8
+ },
9
+ "keywords": [
10
+ "crush",
11
+ "claude",
12
+ "anthropic",
13
+ "oauth",
14
+ "proxy",
15
+ "claude-code",
16
+ "claude-max"
17
+ ],
18
+ "author": "suncreation",
19
+ "license": "MIT",
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "git+https://github.com/suncreation/crush-auth-proxy.git"
23
+ },
24
+ "engines": {
25
+ "node": ">=18"
26
+ },
27
+ "files": [
28
+ "bin/",
29
+ "README.md",
30
+ "LICENSE"
31
+ ]
32
+ }