@h1d3rone/claude-proxy 0.1.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.
@@ -0,0 +1,369 @@
1
+ const fs = require("fs/promises");
2
+ const path = require("path");
3
+ const { spawn } = require("child_process");
4
+ const { DEFAULT_CLIENT_API_KEY } = require("../config");
5
+ const {
6
+ backupFile,
7
+ execFileAsync,
8
+ ensureDir,
9
+ isProcessAlive,
10
+ openLogFile,
11
+ readHookPayload,
12
+ readJson,
13
+ readSessionFile,
14
+ readText,
15
+ sleep,
16
+ writeJson,
17
+ writeSessionFile,
18
+ writeText
19
+ } = require("../utils");
20
+
21
+ const MANAGED_ENV_KEYS = [
22
+ "ANTHROPIC_API_KEY",
23
+ "ANTHROPIC_BASE_URL",
24
+ "ANTHROPIC_DEFAULT_HAIKU_MODEL",
25
+ "ANTHROPIC_DEFAULT_OPUS_MODEL",
26
+ "ANTHROPIC_DEFAULT_SONNET_MODEL",
27
+ "ANTHROPIC_MODEL",
28
+ "ANTHROPIC_REASONING_MODEL"
29
+ ];
30
+
31
+ const LEGACY_HOOK_TOKENS = {
32
+ "ensure-proxy": ["ensure-claude-code-proxy.sh"],
33
+ "stop-proxy": ["stop-claude-code-proxy.sh"]
34
+ };
35
+
36
+ function makeManagedCommand(action, configPath) {
37
+ return ["claude-proxy", "internal", action, "--config", `'${String(configPath).replace(/'/g, "'\\''")}'`].join(" ");
38
+ }
39
+
40
+ function stripManagedHooks(groups, actionToken) {
41
+ const legacyTokens = LEGACY_HOOK_TOKENS[actionToken] || [];
42
+ if (!Array.isArray(groups)) {
43
+ return [];
44
+ }
45
+
46
+ return groups
47
+ .map((group) => ({
48
+ ...group,
49
+ hooks: Array.isArray(group.hooks)
50
+ ? group.hooks.filter((hook) => {
51
+ if (!hook || typeof hook.command !== "string") {
52
+ return true;
53
+ }
54
+ if (hook.command.includes(`internal ${actionToken}`)) {
55
+ return false;
56
+ }
57
+ return !legacyTokens.some((token) => hook.command.includes(token));
58
+ })
59
+ : []
60
+ }))
61
+ .filter((group) => group.hooks.length > 0);
62
+ }
63
+
64
+ async function patchClaudeSettings(config, host, options = {}) {
65
+ const settings = await readJson(host.settings_path, {});
66
+ await ensureDir(host.backups_dir);
67
+ await ensureDir(host.runtime_dir);
68
+ await ensureDir(host.state_dir);
69
+ await ensureDir(host.logs_dir);
70
+ await backupFile(host.settings_path, host.backups_dir);
71
+
72
+ settings.env = settings.env || {};
73
+ settings.hooks = settings.hooks || {};
74
+
75
+ const clientApiKey = host.client_api_key || DEFAULT_CLIENT_API_KEY;
76
+ const smallModel = host.small_model || config.small_model;
77
+ const middleModel = host.middle_model || config.middle_model;
78
+ const bigModel = host.big_model || config.big_model;
79
+ const defaultClaudeModel = host.default_claude_model || config.default_claude_model;
80
+
81
+ settings.env.ANTHROPIC_API_KEY = clientApiKey;
82
+ settings.env.ANTHROPIC_BASE_URL = `http://localhost:${config.server_port}`;
83
+ settings.env.ANTHROPIC_DEFAULT_HAIKU_MODEL = smallModel;
84
+ settings.env.ANTHROPIC_DEFAULT_OPUS_MODEL = bigModel;
85
+ settings.env.ANTHROPIC_DEFAULT_SONNET_MODEL = middleModel;
86
+ settings.env.ANTHROPIC_MODEL = bigModel;
87
+ settings.env.ANTHROPIC_REASONING_MODEL = bigModel;
88
+ settings.model = defaultClaudeModel;
89
+ settings.includeCoAuthoredBy = false;
90
+
91
+ const configPath = options.configPath || config.__configPath || host.config_path;
92
+
93
+ const ensureCommand = makeManagedCommand("ensure-proxy", configPath);
94
+ const stopCommand = makeManagedCommand("stop-proxy", configPath);
95
+
96
+ const startGroups = stripManagedHooks(settings.hooks.SessionStart, "ensure-proxy");
97
+ startGroups.push({
98
+ hooks: [
99
+ {
100
+ type: "command",
101
+ command: ensureCommand,
102
+ timeout: 15,
103
+ statusMessage: "Ensuring claude-proxy is running"
104
+ }
105
+ ]
106
+ });
107
+ settings.hooks.SessionStart = startGroups;
108
+
109
+ const endGroups = stripManagedHooks(settings.hooks.SessionEnd, "stop-proxy");
110
+ endGroups.push({
111
+ hooks: [
112
+ {
113
+ type: "command",
114
+ command: stopCommand,
115
+ timeout: 15,
116
+ statusMessage: "Stopping claude-proxy"
117
+ }
118
+ ]
119
+ });
120
+ settings.hooks.SessionEnd = endGroups;
121
+
122
+ await writeJson(host.settings_path, settings);
123
+ }
124
+
125
+ async function cleanClaudeSettings(config, host) {
126
+ const settings = await readJson(host.settings_path, {});
127
+ await ensureDir(host.backups_dir);
128
+ await backupFile(host.settings_path, host.backups_dir);
129
+
130
+ if (settings.env && typeof settings.env === "object") {
131
+ for (const key of MANAGED_ENV_KEYS) {
132
+ delete settings.env[key];
133
+ }
134
+ if (Object.keys(settings.env).length === 0) {
135
+ delete settings.env;
136
+ }
137
+ }
138
+
139
+ if (settings.hooks && typeof settings.hooks === "object") {
140
+ const startGroups = stripManagedHooks(settings.hooks.SessionStart, "ensure-proxy");
141
+ const endGroups = stripManagedHooks(settings.hooks.SessionEnd, "stop-proxy");
142
+
143
+ if (startGroups.length > 0) {
144
+ settings.hooks.SessionStart = startGroups;
145
+ } else {
146
+ delete settings.hooks.SessionStart;
147
+ }
148
+
149
+ if (endGroups.length > 0) {
150
+ settings.hooks.SessionEnd = endGroups;
151
+ } else {
152
+ delete settings.hooks.SessionEnd;
153
+ }
154
+
155
+ if (Object.keys(settings.hooks).length === 0) {
156
+ delete settings.hooks;
157
+ }
158
+ }
159
+
160
+ const defaultClaudeModel = host.default_claude_model || config.default_claude_model;
161
+ if (settings.model === defaultClaudeModel) {
162
+ delete settings.model;
163
+ }
164
+
165
+ await writeJson(host.settings_path, settings);
166
+ }
167
+
168
+ async function registerSession(host, sessionId) {
169
+ if (!sessionId) {
170
+ return 0;
171
+ }
172
+
173
+ const sessions = await readSessionFile(host.sessions_file);
174
+ sessions.push(sessionId);
175
+ await ensureDir(host.state_dir);
176
+ await writeSessionFile(host.sessions_file, sessions);
177
+ return sessions.length;
178
+ }
179
+
180
+ async function unregisterSession(host, sessionId) {
181
+ const sessions = await readSessionFile(host.sessions_file);
182
+ const filtered = sessionId ? sessions.filter((item) => item !== sessionId) : sessions;
183
+ await writeSessionFile(host.sessions_file, filtered);
184
+ return filtered.length;
185
+ }
186
+
187
+ async function startDetachedProxy(host, configPath) {
188
+ await ensureDir(host.logs_dir);
189
+ await ensureDir(host.state_dir);
190
+ const cliPath = path.join(host.project_root, "src", "cli.js");
191
+ const logFd = openLogFile(host.server_log_file);
192
+
193
+ const child = spawn(
194
+ process.execPath,
195
+ [cliPath, "start", "--config", configPath],
196
+ {
197
+ cwd: host.project_root,
198
+ detached: true,
199
+ stdio: ["ignore", logFd, logFd]
200
+ }
201
+ );
202
+
203
+ child.unref();
204
+ await writeText(host.pid_file, `${child.pid}\n`);
205
+ return child.pid;
206
+ }
207
+
208
+ async function stopProxyProcess(host) {
209
+ const pidRaw = await readText(host.pid_file, "");
210
+ const pid = Number(pidRaw.trim());
211
+ if (!isProcessAlive(pid)) {
212
+ await fs.rm(host.pid_file, { force: true });
213
+ return false;
214
+ }
215
+
216
+ process.kill(pid, "SIGTERM");
217
+ await new Promise((resolve) => setTimeout(resolve, 1000));
218
+
219
+ if (isProcessAlive(pid)) {
220
+ process.kill(pid, "SIGKILL");
221
+ }
222
+
223
+ await fs.rm(host.pid_file, { force: true });
224
+ return true;
225
+ }
226
+
227
+ async function findPidsListeningOnPort(port) {
228
+ if (process.platform === "win32") {
229
+ const { stdout } = await execFileAsync("netstat", ["-ano", "-p", "tcp"], {
230
+ maxBuffer: 10 * 1024 * 1024
231
+ });
232
+
233
+ return Array.from(
234
+ new Set(
235
+ stdout
236
+ .split(/\r?\n/)
237
+ .map((line) => line.trim())
238
+ .filter((line) => line && line.includes(`:${port}`) && /LISTENING$/i.test(line))
239
+ .map((line) => line.split(/\s+/).pop())
240
+ .map((pid) => Number(pid))
241
+ .filter((pid) => Number.isInteger(pid) && pid > 0)
242
+ )
243
+ );
244
+ }
245
+
246
+ const candidates = ["lsof", "/usr/sbin/lsof", "/usr/bin/lsof"];
247
+ for (const binary of candidates) {
248
+ try {
249
+ const { stdout } = await execFileAsync(binary, ["-ti", `tcp:${port}`], {
250
+ maxBuffer: 1024 * 1024
251
+ });
252
+
253
+ return Array.from(
254
+ new Set(
255
+ stdout
256
+ .split(/\r?\n/)
257
+ .map((line) => Number(line.trim()))
258
+ .filter((pid) => Number.isInteger(pid) && pid > 0)
259
+ )
260
+ );
261
+ } catch (error) {
262
+ if (error.code === "ENOENT") {
263
+ continue;
264
+ }
265
+
266
+ const output = String(error.stdout || "").trim();
267
+ if (!output) {
268
+ return [];
269
+ }
270
+
271
+ return Array.from(
272
+ new Set(
273
+ output
274
+ .split(/\r?\n/)
275
+ .map((line) => Number(line.trim()))
276
+ .filter((pid) => Number.isInteger(pid) && pid > 0)
277
+ )
278
+ );
279
+ }
280
+ }
281
+
282
+ return [];
283
+ }
284
+
285
+ async function killPid(pid) {
286
+ if (!isProcessAlive(pid)) {
287
+ return;
288
+ }
289
+
290
+ if (process.platform === "win32") {
291
+ try {
292
+ await execFileAsync("taskkill", ["/PID", String(pid), "/T", "/F"], {
293
+ maxBuffer: 1024 * 1024
294
+ });
295
+ } catch {}
296
+ return;
297
+ }
298
+
299
+ try {
300
+ process.kill(pid, "SIGTERM");
301
+ } catch {}
302
+
303
+ await sleep(1000);
304
+
305
+ if (isProcessAlive(pid)) {
306
+ try {
307
+ process.kill(pid, "SIGKILL");
308
+ } catch {}
309
+ }
310
+ }
311
+
312
+ async function killProxyPortOccupants(port, excludePids = []) {
313
+ const excluded = new Set(excludePids.filter(Boolean).map((pid) => Number(pid)));
314
+ const pids = await findPidsListeningOnPort(port);
315
+
316
+ for (const pid of pids) {
317
+ if (excluded.has(pid) || pid === process.pid) {
318
+ continue;
319
+ }
320
+ await killPid(pid);
321
+ }
322
+ }
323
+
324
+ async function ensureProxy(config, host, configPath) {
325
+ const payload = await readHookPayload();
326
+ const sessionId = payload && payload.session_id ? payload.session_id : null;
327
+ await registerSession(host, sessionId);
328
+
329
+ const pidRaw = await readText(host.pid_file, "");
330
+ const pid = Number(pidRaw.trim());
331
+ if (isProcessAlive(pid)) {
332
+ return pid;
333
+ }
334
+
335
+ return startDetachedProxy(host, configPath);
336
+ }
337
+
338
+ async function stopProxy(config, host, options = {}) {
339
+ const payload = options.force ? null : await readHookPayload();
340
+ const sessionId = payload && payload.session_id ? payload.session_id : null;
341
+ const remaining = options.force ? 0 : await unregisterSession(host, sessionId);
342
+
343
+ if (!options.force && remaining > 0) {
344
+ return { stopped: false, remaining };
345
+ }
346
+
347
+ await stopProxyProcess(host);
348
+ if (options.killPort) {
349
+ await killProxyPortOccupants(config.server_port);
350
+ }
351
+ await writeSessionFile(host.sessions_file, []);
352
+ return { stopped: true, remaining: 0 };
353
+ }
354
+
355
+ async function restartProxy(config, host, configPath) {
356
+ const pidRaw = await readText(host.pid_file, "");
357
+ const previousPid = Number(pidRaw.trim());
358
+ await stopProxy(config, host, { force: true, killPort: true });
359
+ await killProxyPortOccupants(config.server_port, [previousPid]);
360
+ return startDetachedProxy(host, configPath);
361
+ }
362
+
363
+ module.exports = {
364
+ cleanClaudeSettings,
365
+ ensureProxy,
366
+ patchClaudeSettings,
367
+ restartProxy,
368
+ stopProxy
369
+ };
package/src/utils.js ADDED
@@ -0,0 +1,224 @@
1
+ const fs = require("fs/promises");
2
+ const fssync = require("fs");
3
+ const path = require("path");
4
+ const os = require("os");
5
+ const crypto = require("crypto");
6
+ const { execFile, spawn } = require("child_process");
7
+
8
+ function nowStamp() {
9
+ return new Date().toISOString().replace(/[:.]/g, "-");
10
+ }
11
+
12
+ async function ensureDir(dirPath) {
13
+ await fs.mkdir(dirPath, { recursive: true });
14
+ }
15
+
16
+ async function pathExists(targetPath) {
17
+ try {
18
+ await fs.access(targetPath);
19
+ return true;
20
+ } catch {
21
+ return false;
22
+ }
23
+ }
24
+
25
+ async function readJson(targetPath, fallback = {}) {
26
+ try {
27
+ const raw = await fs.readFile(targetPath, "utf8");
28
+ return JSON.parse(raw);
29
+ } catch (error) {
30
+ if (error.code === "ENOENT") {
31
+ return fallback;
32
+ }
33
+ throw error;
34
+ }
35
+ }
36
+
37
+ async function writeJson(targetPath, value) {
38
+ await ensureDir(path.dirname(targetPath));
39
+ await fs.writeFile(targetPath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
40
+ }
41
+
42
+ async function backupFile(filePath, backupsDir) {
43
+ if (!(await pathExists(filePath))) {
44
+ return null;
45
+ }
46
+
47
+ await ensureDir(backupsDir);
48
+ const backupPath = path.join(
49
+ backupsDir,
50
+ `${path.basename(filePath)}.before-claude-proxy-${nowStamp()}`
51
+ );
52
+ await fs.copyFile(filePath, backupPath);
53
+ return backupPath;
54
+ }
55
+
56
+ async function readText(filePath, fallback = "") {
57
+ try {
58
+ return await fs.readFile(filePath, "utf8");
59
+ } catch (error) {
60
+ if (error.code === "ENOENT") {
61
+ return fallback;
62
+ }
63
+ throw error;
64
+ }
65
+ }
66
+
67
+ async function writeText(filePath, content) {
68
+ await ensureDir(path.dirname(filePath));
69
+ await fs.writeFile(filePath, content, "utf8");
70
+ }
71
+
72
+ async function copyFile(sourcePath, targetPath) {
73
+ await ensureDir(path.dirname(targetPath));
74
+ await fs.copyFile(sourcePath, targetPath);
75
+ }
76
+
77
+ async function readHookPayload() {
78
+ if (process.stdin.isTTY) {
79
+ return null;
80
+ }
81
+
82
+ const chunks = [];
83
+ for await (const chunk of process.stdin) {
84
+ chunks.push(chunk);
85
+ }
86
+
87
+ const text = Buffer.concat(chunks).toString("utf8").trim();
88
+ if (!text) {
89
+ return null;
90
+ }
91
+
92
+ try {
93
+ return JSON.parse(text);
94
+ } catch {
95
+ return null;
96
+ }
97
+ }
98
+
99
+ async function readSessionFile(sessionFile) {
100
+ const content = await readText(sessionFile, "");
101
+ return content
102
+ .split("\n")
103
+ .map((line) => line.trim())
104
+ .filter(Boolean);
105
+ }
106
+
107
+ async function writeSessionFile(sessionFile, sessions) {
108
+ const unique = Array.from(new Set(sessions));
109
+ if (unique.length === 0) {
110
+ if (await pathExists(sessionFile)) {
111
+ await fs.unlink(sessionFile);
112
+ }
113
+ return;
114
+ }
115
+ await writeText(sessionFile, `${unique.join("\n")}\n`);
116
+ }
117
+
118
+ function isProcessAlive(pid) {
119
+ if (!pid || Number.isNaN(Number(pid))) {
120
+ return false;
121
+ }
122
+
123
+ try {
124
+ process.kill(Number(pid), 0);
125
+ return true;
126
+ } catch {
127
+ return false;
128
+ }
129
+ }
130
+
131
+ function execFileAsync(file, args, options = {}) {
132
+ return new Promise((resolve, reject) => {
133
+ execFile(file, args, options, (error, stdout, stderr) => {
134
+ if (error) {
135
+ error.stdout = stdout;
136
+ error.stderr = stderr;
137
+ reject(error);
138
+ return;
139
+ }
140
+ resolve({ stdout, stderr });
141
+ });
142
+ });
143
+ }
144
+
145
+ async function execFileWithFallbacks(files, args, options = {}) {
146
+ const candidates = Array.from(new Set((Array.isArray(files) ? files : [files]).filter(Boolean)));
147
+ let lastError = null;
148
+
149
+ for (const file of candidates) {
150
+ try {
151
+ return await execFileAsync(file, args, options);
152
+ } catch (error) {
153
+ if (error.code === "ENOENT") {
154
+ lastError = error;
155
+ continue;
156
+ }
157
+ throw error;
158
+ }
159
+ }
160
+
161
+ const error = new Error(`Command not found: ${candidates.join(", ")}`);
162
+ error.code = "ENOENT";
163
+ error.cause = lastError || null;
164
+ throw error;
165
+ }
166
+
167
+ function quoteCommandArg(input, platform = process.platform) {
168
+ const value = String(input);
169
+ if (platform === "win32") {
170
+ return `"${value.replace(/"/g, '\\"')}"`;
171
+ }
172
+ return shellQuote(value);
173
+ }
174
+
175
+ function sleep(ms) {
176
+ return new Promise((resolve) => setTimeout(resolve, ms));
177
+ }
178
+
179
+ function spawnDetached(command, args, options = {}) {
180
+ const child = spawn(command, args, {
181
+ detached: true,
182
+ stdio: "ignore",
183
+ ...options
184
+ });
185
+ child.unref();
186
+ return child;
187
+ }
188
+
189
+ function randomId() {
190
+ return crypto.randomBytes(12).toString("hex");
191
+ }
192
+
193
+ function shellQuote(input) {
194
+ return `'${String(input).replace(/'/g, `'\"'\"'`)}'`;
195
+ }
196
+
197
+ function openLogFile(logPath) {
198
+ fssync.mkdirSync(path.dirname(logPath), { recursive: true });
199
+ return fssync.openSync(logPath, "a");
200
+ }
201
+
202
+ module.exports = {
203
+ backupFile,
204
+ copyFile,
205
+ ensureDir,
206
+ execFileAsync,
207
+ execFileWithFallbacks,
208
+ isProcessAlive,
209
+ nowStamp,
210
+ openLogFile,
211
+ pathExists,
212
+ quoteCommandArg,
213
+ randomId,
214
+ readHookPayload,
215
+ readJson,
216
+ readSessionFile,
217
+ readText,
218
+ shellQuote,
219
+ sleep,
220
+ spawnDetached,
221
+ writeJson,
222
+ writeSessionFile,
223
+ writeText
224
+ };