@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 +21 -0
- package/README.md +95 -0
- package/bin/crush-auth-proxy.mjs +735 -0
- package/package.json +32 -0
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
|
+
}
|