claude-code-limiter 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) 2026 Basha
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/bin/cli.js ADDED
@@ -0,0 +1,174 @@
1
+ #!/usr/bin/env node
2
+
3
+ 'use strict';
4
+
5
+ // ---- Color helpers (no external deps) ----
6
+
7
+ const supportsColor = process.stdout.isTTY && !process.env.NO_COLOR;
8
+ const c = {
9
+ red: (s) => supportsColor ? `\x1b[31m${s}\x1b[0m` : s,
10
+ green: (s) => supportsColor ? `\x1b[32m${s}\x1b[0m` : s,
11
+ yellow: (s) => supportsColor ? `\x1b[33m${s}\x1b[0m` : s,
12
+ cyan: (s) => supportsColor ? `\x1b[36m${s}\x1b[0m` : s,
13
+ bold: (s) => supportsColor ? `\x1b[1m${s}\x1b[0m` : s,
14
+ dim: (s) => supportsColor ? `\x1b[2m${s}\x1b[0m` : s,
15
+ };
16
+
17
+ // ---- Help text ----
18
+
19
+ const HELP = `
20
+ ${c.bold('claude-code-limiter')} - Share one Claude Code subscription across multiple users
21
+
22
+ ${c.bold('USAGE')}
23
+ claude-code-limiter <command> [options]
24
+
25
+ ${c.bold('COMMANDS')}
26
+ ${c.cyan('setup')} Install the limiter hook on this machine (requires sudo)
27
+ ${c.cyan('uninstall')} Remove all limiter files and restore original settings (requires sudo)
28
+ ${c.cyan('status')} Show current usage and limits for this machine's user
29
+ ${c.cyan('sync')} Force re-sync config from the server (requires sudo)
30
+ ${c.cyan('serve')} Start the limiter server (REST API + dashboard)
31
+
32
+ ${c.bold('OPTIONS')}
33
+ --code <CODE> One-time install code from the admin dashboard (setup only)
34
+ --server <URL> Server URL, e.g. https://limiter.example.com (setup only)
35
+ --port <PORT> Port for the server to listen on (serve only, default: 3000)
36
+ --yes, -y Skip confirmation prompts
37
+ --help, -h Show this help message
38
+
39
+ ${c.bold('EXAMPLES')}
40
+ ${c.dim('# Install on a user\'s machine (admin gives them the code)')}
41
+ sudo npx claude-code-limiter setup --code CLM-alice-a8f3e2 --server https://your-server:3000
42
+
43
+ ${c.dim('# Check current usage')}
44
+ npx claude-code-limiter status
45
+
46
+ ${c.dim('# Force re-sync config from server')}
47
+ sudo npx claude-code-limiter sync
48
+
49
+ ${c.dim('# Remove the limiter from this machine')}
50
+ sudo npx claude-code-limiter uninstall
51
+
52
+ ${c.dim('# Start the server')}
53
+ npx claude-code-limiter serve --port 3000
54
+
55
+ ${c.dim('# Start the server via Docker')}
56
+ docker run -p 3000:3000 -v data:/data -e ADMIN_PASSWORD=xxx claude-code-limiter
57
+ `.trim();
58
+
59
+ // ---- Argument parsing (no external deps, just process.argv) ----
60
+
61
+ const argv = process.argv.slice(2);
62
+
63
+ function hasFlag(name) {
64
+ return argv.includes(name);
65
+ }
66
+
67
+ function getOption(name) {
68
+ const idx = argv.indexOf(name);
69
+ if (idx !== -1 && idx + 1 < argv.length) {
70
+ return argv[idx + 1];
71
+ }
72
+ return undefined;
73
+ }
74
+
75
+ // Find the first positional arg (not a flag and not a flag's value)
76
+ function getCommand() {
77
+ const flagsWithValue = new Set(['--code', '--server', '--port']);
78
+ const skip = new Set();
79
+ for (let i = 0; i < argv.length; i++) {
80
+ if (flagsWithValue.has(argv[i])) {
81
+ skip.add(i);
82
+ skip.add(i + 1);
83
+ }
84
+ }
85
+ for (let i = 0; i < argv.length; i++) {
86
+ if (!skip.has(i) && !argv[i].startsWith('-')) {
87
+ return argv[i];
88
+ }
89
+ }
90
+ return undefined;
91
+ }
92
+
93
+ const showHelp = hasFlag('--help') || hasFlag('-h');
94
+ const skipConfirm = hasFlag('--yes') || hasFlag('-y');
95
+ const code = getOption('--code');
96
+ const server = getOption('--server');
97
+ const port = getOption('--port');
98
+ const command = getCommand();
99
+
100
+ // ---- Help / no command ----
101
+
102
+ if (showHelp || !command) {
103
+ console.log(HELP);
104
+ process.exit(showHelp ? 0 : 1);
105
+ }
106
+
107
+ // ---- Error helpers ----
108
+
109
+ function die(msg) {
110
+ console.error(c.red('Error: ') + msg);
111
+ process.exit(1);
112
+ }
113
+
114
+ // ---- Route commands ----
115
+
116
+ async function main() {
117
+ switch (command) {
118
+ case 'setup': {
119
+ if (!code) {
120
+ die('--code is required for setup.\n'
121
+ + c.dim('Usage: sudo npx claude-code-limiter setup --code <CODE> --server <URL>'));
122
+ }
123
+ if (!server) {
124
+ die('--server is required for setup.\n'
125
+ + c.dim('Usage: sudo npx claude-code-limiter setup --code <CODE> --server <URL>'));
126
+ }
127
+ const installer = require('../src/installer.js');
128
+ await installer.setup({ code, server, skipConfirm });
129
+ break;
130
+ }
131
+
132
+ case 'uninstall': {
133
+ const installer = require('../src/installer.js');
134
+ await installer.uninstall({ skipConfirm });
135
+ break;
136
+ }
137
+
138
+ case 'status': {
139
+ const installer = require('../src/installer.js');
140
+ await installer.status();
141
+ break;
142
+ }
143
+
144
+ case 'sync': {
145
+ const installer = require('../src/installer.js');
146
+ await installer.sync();
147
+ break;
148
+ }
149
+
150
+ case 'serve': {
151
+ const serverPort = parseInt(port || process.env.PORT || '3000', 10);
152
+ if (isNaN(serverPort) || serverPort < 1 || serverPort > 65535) {
153
+ die('Invalid port number. Must be between 1 and 65535.');
154
+ }
155
+ const app = require('../../server/src/server/index.js');
156
+ app.start(serverPort);
157
+ break;
158
+ }
159
+
160
+ default:
161
+ console.error(c.red(`Unknown command: ${command}`));
162
+ console.log('');
163
+ console.log(HELP);
164
+ process.exit(1);
165
+ }
166
+ }
167
+
168
+ main().catch((err) => {
169
+ console.error(c.red('Fatal error: ') + err.message);
170
+ if (process.env.DEBUG) {
171
+ console.error(err.stack);
172
+ }
173
+ process.exit(1);
174
+ });
package/package.json ADDED
@@ -0,0 +1,20 @@
1
+ {
2
+ "name": "claude-code-limiter",
3
+ "version": "1.0.0",
4
+ "description": "Per-user rate limits for Claude Code — share one subscription fairly",
5
+ "bin": { "claude-code-limiter": "./bin/cli.js" },
6
+ "files": ["bin/", "src/", "LICENSE"],
7
+ "engines": { "node": ">=18.0.0" },
8
+ "license": "MIT",
9
+ "author": "howincodes",
10
+ "contributors": [
11
+ "farisbasha"
12
+ ],
13
+ "keywords": ["claude", "claude-code", "rate-limiter", "hooks", "managed-settings", "usage-limits", "quota"],
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "https://github.com/howincodes/claude-code-limiter"
17
+ },
18
+ "homepage": "https://github.com/howincodes/claude-code-limiter#readme",
19
+ "bugs": "https://github.com/howincodes/claude-code-limiter/issues"
20
+ }
package/src/hook.js ADDED
@@ -0,0 +1,624 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * claude-code-limiter — Hook Script
5
+ * ==================================
6
+ * Standalone rate limiter invoked by Claude Code managed hooks.
7
+ * Zero npm dependencies. Uses only Node.js built-ins.
8
+ * Gets copied to the system-protected directory during setup.
9
+ *
10
+ * Invoked by managed-settings.json hooks:
11
+ * node hook.js sync → SessionStart (cache model, sync config from server)
12
+ * node hook.js check → UserPromptSubmit (gate: block if over limit)
13
+ * node hook.js count → Stop (increment turn counter)
14
+ * node hook.js enforce → PreToolUse (local-only kill/pause check)
15
+ * node hook.js status → Terminal (human-readable status)
16
+ */
17
+
18
+ "use strict";
19
+
20
+ const fs = require("fs");
21
+ const path = require("path");
22
+ const os = require("os");
23
+ const http = require("http");
24
+ const https = require("https");
25
+
26
+ // ════════════════════════════════════════════════════════════
27
+ // PATHS — System-protected locations per platform
28
+ // ════════════════════════════════════════════════════════════
29
+
30
+ const IS_WIN = process.platform === "win32";
31
+ const IS_MAC = process.platform === "darwin";
32
+
33
+ // CLAUDE_LIMITER_DIR override: for testing or custom install locations.
34
+ const LIMITER_DIR =
35
+ process.env.CLAUDE_LIMITER_DIR ||
36
+ (IS_WIN
37
+ ? path.join("C:", "Program Files", "ClaudeCode", "limiter")
38
+ : IS_MAC
39
+ ? path.join("/Library", "Application Support", "ClaudeCode", "limiter")
40
+ : path.join("/etc", "claude-code", "limiter"));
41
+
42
+ const CONFIG_FILE = path.join(LIMITER_DIR, "config.json");
43
+ const SERVER_FILE = path.join(LIMITER_DIR, "server.json");
44
+ const CACHE_FILE = path.join(LIMITER_DIR, "cache.json");
45
+ const MODEL_FILE = path.join(LIMITER_DIR, "session-model.txt");
46
+ const USAGE_DIR = path.join(LIMITER_DIR, "usage");
47
+ const DEBUG_LOG = path.join(LIMITER_DIR, "debug.log");
48
+
49
+ const TODAY = new Date().toISOString().slice(0, 10);
50
+ const USAGE_FILE = path.join(USAGE_DIR, `${TODAY}.json`);
51
+
52
+ // ════════════════════════════════════════════════════════════
53
+ // SAFE I/O — Every read/write is wrapped. Hook must never crash.
54
+ // ════════════════════════════════════════════════════════════
55
+
56
+ function readJSON(filepath) {
57
+ try {
58
+ return JSON.parse(fs.readFileSync(filepath, "utf-8"));
59
+ } catch {
60
+ return null;
61
+ }
62
+ }
63
+
64
+ function writeJSON(filepath, data) {
65
+ try {
66
+ fs.mkdirSync(path.dirname(filepath), { recursive: true });
67
+ const tmp = filepath + ".tmp." + process.pid;
68
+ fs.writeFileSync(tmp, JSON.stringify(data, null, 2));
69
+ fs.renameSync(tmp, filepath);
70
+ } catch (err) {
71
+ debugLog(`WRITE_ERROR: ${filepath}: ${err.message}`);
72
+ }
73
+ }
74
+
75
+ function readText(filepath) {
76
+ try {
77
+ return fs.readFileSync(filepath, "utf-8").trim();
78
+ } catch {
79
+ return null;
80
+ }
81
+ }
82
+
83
+ function writeText(filepath, text) {
84
+ try {
85
+ fs.mkdirSync(path.dirname(filepath), { recursive: true });
86
+ fs.writeFileSync(filepath, text);
87
+ } catch {}
88
+ }
89
+
90
+ let _debugEnabled = false;
91
+ function debugLog(msg) {
92
+ if (!_debugEnabled) return;
93
+ try {
94
+ const ts = new Date().toISOString();
95
+ fs.appendFileSync(DEBUG_LOG, `[${ts}] ${msg}\n`);
96
+ } catch {}
97
+ }
98
+
99
+ function readStdin() {
100
+ try {
101
+ if (process.stdin.isTTY) return {};
102
+ return JSON.parse(fs.readFileSync(0, "utf-8").trim());
103
+ } catch {
104
+ return {};
105
+ }
106
+ }
107
+
108
+ // ════════════════════════════════════════════════════════════
109
+ // MODEL DETECTION
110
+ //
111
+ // Priority:
112
+ // 1. SessionStart stdin (only in sync action)
113
+ // 2. ~/.claude/settings.json → model field (catches /model changes)
114
+ // 3. ~/.claude/settings.local.json → model
115
+ // 4. .claude/settings.json (project) → model
116
+ // 5. session-model.txt (cached from SessionStart)
117
+ // 6. ANTHROPIC_MODEL env var
118
+ // 7. CLAUDE_MODEL env var
119
+ // 8. Falls back to "opus" (default when no model key present)
120
+ //
121
+ // Normalization: string containing "opus"/"sonnet"/"haiku"
122
+ // maps to that family. Everything else → "default".
123
+ // ════════════════════════════════════════════════════════════
124
+
125
+ function normalizeModel(raw) {
126
+ const lower = String(raw || "").toLowerCase();
127
+ if (lower.includes("opus")) return "opus";
128
+ if (lower.includes("sonnet")) return "sonnet";
129
+ if (lower.includes("haiku")) return "haiku";
130
+ return "default";
131
+ }
132
+
133
+ function detectModel(stdinData) {
134
+ // Source 1: Hook input (SessionStart only)
135
+ if (stdinData && stdinData.model) {
136
+ return normalizeModel(stdinData.model);
137
+ }
138
+
139
+ // Source 2: User settings (updated by /model command)
140
+ const home = os.homedir();
141
+ const userSettings = readJSON(path.join(home, ".claude", "settings.json"));
142
+ if (userSettings && userSettings.model) {
143
+ return normalizeModel(userSettings.model);
144
+ }
145
+
146
+ // Source 3: Local project settings
147
+ const localSettings = readJSON(
148
+ path.join(process.cwd(), ".claude", "settings.local.json"),
149
+ );
150
+ if (localSettings && localSettings.model) {
151
+ return normalizeModel(localSettings.model);
152
+ }
153
+
154
+ // Source 4: Project settings
155
+ const projectSettings = readJSON(
156
+ path.join(process.cwd(), ".claude", "settings.json"),
157
+ );
158
+ if (projectSettings && projectSettings.model) {
159
+ return normalizeModel(projectSettings.model);
160
+ }
161
+
162
+ // Source 5: Cached from SessionStart
163
+ const cached = readText(MODEL_FILE);
164
+ if (cached) return normalizeModel(cached);
165
+
166
+ // Source 6-7: Environment variables
167
+ if (process.env.ANTHROPIC_MODEL) return normalizeModel(process.env.ANTHROPIC_MODEL);
168
+ if (process.env.CLAUDE_MODEL) return normalizeModel(process.env.CLAUDE_MODEL);
169
+
170
+ // Default: opus (Claude Code's default model — absence of model key = opus)
171
+ return "opus";
172
+ }
173
+
174
+ // ════════════════════════════════════════════════════════════
175
+ // SERVER COMMUNICATION
176
+ // ════════════════════════════════════════════════════════════
177
+
178
+ function serverRequest(endpoint, payload, timeoutMs) {
179
+ const serverConfig = readJSON(SERVER_FILE);
180
+ if (!serverConfig || !serverConfig.url) return Promise.resolve(null);
181
+
182
+ return new Promise((resolve) => {
183
+ try {
184
+ const url = new URL(endpoint, serverConfig.url);
185
+ const body = JSON.stringify(payload);
186
+ const client = url.protocol === "https:" ? https : http;
187
+
188
+ const req = client.request(
189
+ url,
190
+ {
191
+ method: "POST",
192
+ headers: {
193
+ "Content-Type": "application/json",
194
+ "Authorization": `Bearer ${serverConfig.auth_token}`,
195
+ "Content-Length": Buffer.byteLength(body),
196
+ },
197
+ },
198
+ (res) => {
199
+ let data = "";
200
+ res.on("data", (chunk) => (data += chunk));
201
+ res.on("end", () => {
202
+ try {
203
+ resolve(JSON.parse(data));
204
+ } catch {
205
+ resolve(null);
206
+ }
207
+ });
208
+ },
209
+ );
210
+
211
+ req.on("error", () => resolve(null));
212
+ req.setTimeout(timeoutMs || 3000, () => {
213
+ req.destroy();
214
+ resolve(null);
215
+ });
216
+
217
+ req.write(body);
218
+ req.end();
219
+ } catch {
220
+ resolve(null);
221
+ }
222
+ });
223
+ }
224
+
225
+ // ════════════════════════════════════════════════════════════
226
+ // USAGE TRACKING (local fallback)
227
+ // ════════════════════════════════════════════════════════════
228
+
229
+ function loadUsage() {
230
+ try { fs.mkdirSync(USAGE_DIR, { recursive: true }); } catch {}
231
+ return readJSON(USAGE_FILE) || {};
232
+ }
233
+
234
+ function saveUsage(usage) {
235
+ writeJSON(USAGE_FILE, usage);
236
+ }
237
+
238
+ function cleanupOldUsage(keepDays) {
239
+ keepDays = keepDays || 7;
240
+ try {
241
+ const cutoff = Date.now() - keepDays * 86400000;
242
+ for (const f of fs.readdirSync(USAGE_DIR)) {
243
+ if (!f.endsWith(".json")) continue;
244
+ const d = new Date(f.replace(".json", "") + "T00:00:00Z");
245
+ if (!isNaN(d.getTime()) && d.getTime() < cutoff) {
246
+ try { fs.unlinkSync(path.join(USAGE_DIR, f)); } catch {}
247
+ }
248
+ }
249
+ } catch {}
250
+ }
251
+
252
+ // ════════════════════════════════════════════════════════════
253
+ // LOCAL LIMIT EVALUATION (offline fallback)
254
+ // ════════════════════════════════════════════════════════════
255
+
256
+ function evaluateLimitsLocally(config, model, usage) {
257
+ if (!config || !config.limits) return { allowed: true };
258
+
259
+ const limits = config.limits;
260
+ const creditWeights = config.credit_weights || { opus: 10, sonnet: 3, haiku: 1 };
261
+
262
+ // Check kill/pause status
263
+ if (config.status === "killed" || config.status === "paused") {
264
+ return {
265
+ allowed: false,
266
+ reason: config.status === "killed"
267
+ ? "Your Claude Code access has been revoked by the admin.\nContact your admin to restore access."
268
+ : "Your Claude Code access has been paused by the admin.\nContact your admin to resume access.",
269
+ };
270
+ }
271
+
272
+ for (const rule of limits) {
273
+ // 1. Time-of-day
274
+ if (rule.type === "time_of_day") {
275
+ if (rule.model && rule.model !== model) continue;
276
+ if (rule.schedule_start && rule.schedule_end) {
277
+ const now = new Date();
278
+ const hhmm = String(now.getHours()).padStart(2, "0") + ":" +
279
+ String(now.getMinutes()).padStart(2, "0");
280
+ if (hhmm < rule.schedule_start || hhmm >= rule.schedule_end) {
281
+ return {
282
+ allowed: false,
283
+ reason: `${model} is only available ${rule.schedule_start} - ${rule.schedule_end}.\nCurrent time: ${hhmm}. Try another model.`,
284
+ };
285
+ }
286
+ }
287
+ continue;
288
+ }
289
+
290
+ // 2. Per-model cap
291
+ if (rule.type === "per_model") {
292
+ if (rule.model && rule.model !== model) continue;
293
+ const limit = rule.value;
294
+ if (limit < 0) continue;
295
+ if (limit === 0) return { allowed: false, reason: `${model} is blocked for your account.` };
296
+ const used = usage[rule.model || model] || 0;
297
+ if (used >= limit) {
298
+ return { allowed: false, reason: `Daily ${model} limit reached.\nUsed ${used}/${limit} prompts today.` };
299
+ }
300
+ }
301
+
302
+ // 3. Credit budget
303
+ if (rule.type === "credits") {
304
+ const budget = rule.value;
305
+ if (budget < 0) continue;
306
+ let totalCredits = 0;
307
+ for (const [m, count] of Object.entries(usage)) {
308
+ totalCredits += count * (creditWeights[m] || 1);
309
+ }
310
+ const nextCost = creditWeights[model] || 1;
311
+ if (totalCredits + nextCost > budget) {
312
+ return {
313
+ allowed: false,
314
+ reason: `Daily credit budget exhausted.\nUsed ${totalCredits}/${budget} credits.\nNext ${model} prompt costs ${nextCost} credits.`,
315
+ };
316
+ }
317
+ }
318
+ }
319
+
320
+ return { allowed: true };
321
+ }
322
+
323
+ // ════════════════════════════════════════════════════════════
324
+ // BUILD BLOCK MESSAGE
325
+ // ════════════════════════════════════════════════════════════
326
+
327
+ function buildBlockMessage(config, model, usage, reason) {
328
+ const creditWeights = config.credit_weights || { opus: 10, sonnet: 3, haiku: 1 };
329
+ const limits = config.limits || [];
330
+ const lines = [reason, ""];
331
+
332
+ // Usage summary
333
+ const models = ["opus", "sonnet", "haiku"];
334
+ const summaryLines = [];
335
+ for (const m of models) {
336
+ const used = usage[m] || 0;
337
+ const rule = limits.find((r) => r.type === "per_model" && (r.model === m || !r.model));
338
+ const lim = rule ? rule.value : -1;
339
+ const limStr = lim < 0 ? "∞" : lim;
340
+ const remaining = lim < 0 ? "∞" : Math.max(0, lim - used);
341
+ summaryLines.push(` ${m}: ${used}/${limStr} (${remaining} left)`);
342
+ }
343
+
344
+ if (summaryLines.length > 0) {
345
+ lines.push("All usage today:", ...summaryLines, "");
346
+ }
347
+
348
+ // Credit balance
349
+ const creditRule = limits.find((r) => r.type === "credits");
350
+ if (creditRule && creditRule.value >= 0) {
351
+ let totalCredits = 0;
352
+ for (const [m, count] of Object.entries(usage)) {
353
+ totalCredits += count * (creditWeights[m] || 1);
354
+ }
355
+ lines.push(`Credit balance: ${Math.max(0, creditRule.value - totalCredits)}/${creditRule.value}`, "");
356
+ }
357
+
358
+ lines.push("Options:", " Switch to another model (if quota remains)", " Try again later");
359
+ return lines.join("\n");
360
+ }
361
+
362
+ // ════════════════════════════════════════════════════════════
363
+ // KILL SWITCH — LOGOUT HELPER
364
+ // ════════════════════════════════════════════════════════════
365
+
366
+ function triggerLogout() {
367
+ try {
368
+ const { spawn } = require("child_process");
369
+ const child = spawn("claude", ["auth", "logout"], {
370
+ detached: true,
371
+ stdio: "ignore",
372
+ });
373
+ child.unref();
374
+ debugLog("KILL triggered claude auth logout");
375
+ } catch (err) {
376
+ debugLog(`KILL logout error: ${err.message}`);
377
+ }
378
+ }
379
+
380
+ // ════════════════════════════════════════════════════════════
381
+ // ACTIONS
382
+ // ════════════════════════════════════════════════════════════
383
+
384
+ /**
385
+ * SYNC — SessionStart hook.
386
+ * Cache model, sync config from server.
387
+ */
388
+ async function actionSync(config) {
389
+ const stdinData = readStdin();
390
+ const model = detectModel(stdinData);
391
+ writeText(MODEL_FILE, model);
392
+ debugLog(`SYNC model=${model} source=${stdinData.source || "unknown"}`);
393
+
394
+ const serverResp = await serverRequest("/api/v1/sync", {
395
+ model,
396
+ hostname: os.hostname(),
397
+ platform: process.platform,
398
+ }, 8000);
399
+
400
+ if (serverResp) {
401
+ writeJSON(CACHE_FILE, serverResp);
402
+ if (serverResp.limits) {
403
+ const updated = { ...config, limits: serverResp.limits };
404
+ if (serverResp.credit_weights) updated.credit_weights = serverResp.credit_weights;
405
+ if (serverResp.status) updated.status = serverResp.status;
406
+ writeJSON(CONFIG_FILE, updated);
407
+ }
408
+ debugLog(`SYNC server_response status=${serverResp.status}`);
409
+ }
410
+ }
411
+
412
+ /**
413
+ * CHECK — UserPromptSubmit hook.
414
+ * Gate: block prompt if over limit.
415
+ */
416
+ async function actionCheck(config) {
417
+ const stdinData = readStdin();
418
+ const model = detectModel(stdinData);
419
+ const usage = loadUsage();
420
+ debugLog(`CHECK model=${model} usage=${JSON.stringify(usage)}`);
421
+
422
+ const serverResp = await serverRequest("/api/v1/check", {
423
+ model,
424
+ local_usage: usage,
425
+ }, 3000);
426
+
427
+ let allowed, reason;
428
+
429
+ if (serverResp) {
430
+ writeJSON(CACHE_FILE, serverResp);
431
+ if (serverResp.limits) {
432
+ const updated = { ...config, limits: serverResp.limits, status: serverResp.status };
433
+ if (serverResp.credit_weights) updated.credit_weights = serverResp.credit_weights;
434
+ writeJSON(CONFIG_FILE, updated);
435
+ }
436
+ allowed = serverResp.allowed;
437
+ reason = serverResp.reason;
438
+
439
+ if (serverResp.status === "killed") {
440
+ allowed = false;
441
+ reason = "Your Claude Code access has been revoked by the admin.\nContact your admin to restore access.";
442
+ triggerLogout();
443
+ }
444
+ } else {
445
+ // Offline: evaluate locally
446
+ const cached = readJSON(CACHE_FILE);
447
+ const evalConfig = cached || config;
448
+ const result = evaluateLimitsLocally(evalConfig, model, usage);
449
+ allowed = result.allowed;
450
+ reason = result.reason;
451
+ debugLog("CHECK offline_mode");
452
+ }
453
+
454
+ if (!allowed) {
455
+ const fullMessage = buildBlockMessage(config, model, usage, reason);
456
+ process.stdout.write(JSON.stringify({ decision: "block", reason: fullMessage }));
457
+ debugLog(`CHECK BLOCKED: ${reason}`);
458
+ }
459
+ }
460
+
461
+ /**
462
+ * COUNT — Stop hook.
463
+ * Increment counter, report to server.
464
+ */
465
+ async function actionCount(config) {
466
+ const stdinData = readStdin();
467
+ const model = detectModel(stdinData);
468
+ const usage = loadUsage();
469
+ const prev = usage[model] || 0;
470
+ usage[model] = prev + 1;
471
+ saveUsage(usage);
472
+ cleanupOldUsage();
473
+ debugLog(`COUNT model=${model} ${prev} → ${prev + 1}`);
474
+
475
+ // Fire and forget
476
+ serverRequest("/api/v1/count", {
477
+ model,
478
+ timestamp: new Date().toISOString(),
479
+ }, 3000);
480
+ }
481
+
482
+ /**
483
+ * ENFORCE — PreToolUse hook.
484
+ * Fast local-only kill/pause check. No server call.
485
+ */
486
+ function actionEnforce() {
487
+ const stdinData = readStdin();
488
+ const cached = readJSON(CACHE_FILE);
489
+ const status = (cached && cached.status) || "active";
490
+
491
+ if (status === "killed" || status === "paused") {
492
+ const msg = status === "killed"
493
+ ? "Your Claude Code access has been revoked by the admin.\nContact your admin to restore access."
494
+ : "Your Claude Code access has been paused by the admin.\nContact your admin to resume access.";
495
+ process.stdout.write(JSON.stringify({
496
+ hookSpecificOutput: {
497
+ hookEventName: "PreToolUse",
498
+ permissionDecision: "deny",
499
+ permissionDecisionReason: msg,
500
+ },
501
+ }));
502
+ }
503
+ // Active — no output = allow
504
+ }
505
+
506
+ /**
507
+ * STATUS — Terminal command for humans.
508
+ */
509
+ function actionStatus(config) {
510
+ const model = detectModel({});
511
+ const usage = loadUsage();
512
+ const userName = config.user_name || "Unknown";
513
+ const serverConfig = readJSON(SERVER_FILE);
514
+
515
+ console.log("");
516
+ console.log("╔══════════════════════════════════════════════╗");
517
+ console.log("║ claude-code-limiter — Status ║");
518
+ console.log("╚══════════════════════════════════════════════╝");
519
+ console.log("");
520
+ console.log(` User: ${userName}`);
521
+ console.log(` Date: ${TODAY}`);
522
+ console.log(` Active model: ${model}`);
523
+ console.log(` Config: ${CONFIG_FILE}`);
524
+ if (serverConfig && serverConfig.url) console.log(` Server: ${serverConfig.url}`);
525
+ console.log("");
526
+
527
+ const limits = (config && config.limits) || [];
528
+ const creditWeights = (config && config.credit_weights) || { opus: 10, sonnet: 3, haiku: 1 };
529
+
530
+ if (limits.length === 0) {
531
+ console.log(" No limits configured — unlimited mode\n");
532
+ return;
533
+ }
534
+
535
+ console.log(" ┌────────────┬───────┬───────┬──────────────────────┬──────────┐");
536
+ console.log(" │ Model │ Used │ Limit │ Progress │ Left │");
537
+ console.log(" ├────────────┼───────┼───────┼──────────────────────┼──────────┤");
538
+
539
+ for (const m of ["opus", "sonnet", "haiku"]) {
540
+ const used = usage[m] || 0;
541
+ const rule = limits.find((r) => r.type === "per_model" && (r.model === m || !r.model));
542
+ const limit = rule ? rule.value : -1;
543
+ let limitStr, bar, leftStr;
544
+ if (limit < 0) {
545
+ limitStr = " ∞ "; bar = " ∞ unlimited "; leftStr = " ∞ ";
546
+ } else {
547
+ const remaining = Math.max(0, limit - used);
548
+ limitStr = String(limit).padStart(3) + " ";
549
+ leftStr = String(remaining).padStart(4) + " ";
550
+ const total = 18;
551
+ const filled = limit > 0 ? Math.min(total, Math.round((used / limit) * total)) : 0;
552
+ bar = "█".repeat(filled) + "░".repeat(total - filled);
553
+ }
554
+ console.log(` │ ${m.padEnd(10)} │ ${String(used).padStart(3)} │ ${limitStr} │ ${bar} │ ${leftStr} │`);
555
+ }
556
+
557
+ console.log(" └────────────┴───────┴───────┴──────────────────────┴──────────┘");
558
+
559
+ const creditRule = limits.find((r) => r.type === "credits");
560
+ if (creditRule && creditRule.value >= 0) {
561
+ let totalCredits = 0;
562
+ for (const [m, count] of Object.entries(usage)) {
563
+ totalCredits += count * (creditWeights[m] || 1);
564
+ }
565
+ console.log(`\n Credits: ${Math.max(0, creditRule.value - totalCredits)}/${creditRule.value} remaining`);
566
+ }
567
+ console.log("");
568
+ }
569
+
570
+ // ════════════════════════════════════════════════════════════
571
+ // MAIN
572
+ // ════════════════════════════════════════════════════════════
573
+
574
+ async function main() {
575
+ const action = process.argv[2] || "check";
576
+ const config = readJSON(CONFIG_FILE);
577
+ _debugEnabled = !!(config && config.debug);
578
+
579
+ if (!config || Object.keys(config).length === 0) {
580
+ if (action === "status") {
581
+ console.log("\n No limiter config found — unlimited mode.");
582
+ console.log(` Config path: ${CONFIG_FILE}\n`);
583
+ return;
584
+ }
585
+ if (action !== "status") {
586
+ // Fail-closed if server.json exists (limiter installed but config deleted)
587
+ const serverConfig = readJSON(SERVER_FILE);
588
+ if (serverConfig && serverConfig.url) {
589
+ if (action === "check") {
590
+ readStdin();
591
+ process.stdout.write(JSON.stringify({ decision: "block", reason: "Limiter configuration missing. Contact your admin." }));
592
+ } else if (action === "enforce") {
593
+ readStdin();
594
+ process.stdout.write(JSON.stringify({ hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: "Limiter configuration missing. Contact your admin." } }));
595
+ } else {
596
+ readStdin();
597
+ }
598
+ return;
599
+ }
600
+ // No server.json — limiter not installed, allow
601
+ readStdin();
602
+ }
603
+ return;
604
+ }
605
+
606
+ switch (action) {
607
+ case "sync": await actionSync(config); break;
608
+ case "check": await actionCheck(config); break;
609
+ case "count": await actionCount(config); break;
610
+ case "enforce": actionEnforce(); break;
611
+ case "status": actionStatus(config); break;
612
+ default:
613
+ process.stderr.write(`claude-code-limiter hook: unknown action "${action}"\n`);
614
+ process.exit(1);
615
+ }
616
+ }
617
+
618
+ try {
619
+ main().catch((err) => {
620
+ try { fs.appendFileSync(DEBUG_LOG, `[${new Date().toISOString()}] ASYNC_ERROR: ${err.stack || err.message}\n`); } catch {}
621
+ });
622
+ } catch (err) {
623
+ try { fs.appendFileSync(DEBUG_LOG, `[${new Date().toISOString()}] FATAL: ${err.stack || err.message}\n`); } catch {}
624
+ }
@@ -0,0 +1,520 @@
1
+ /**
2
+ * claude-code-limiter — Installer
3
+ * ================================
4
+ * Handles setup, uninstall, status, and sync for the client side.
5
+ * Cross-platform: Linux, macOS, Windows.
6
+ */
7
+
8
+ "use strict";
9
+
10
+ const fs = require("fs");
11
+ const path = require("path");
12
+ const os = require("os");
13
+ const https = require("https");
14
+ const http = require("http");
15
+ const readline = require("readline");
16
+ const { execSync } = require("child_process");
17
+ const crypto = require("crypto");
18
+
19
+ // ════════════════════════════════════════════════════════════
20
+ // PLATFORM CONSTANTS
21
+ // ════════════════════════════════════════════════════════════
22
+
23
+ const IS_WIN = process.platform === "win32";
24
+ const IS_MAC = process.platform === "darwin";
25
+ const IS_LINUX = process.platform === "linux";
26
+
27
+ const PATHS = (() => {
28
+ const base = IS_WIN
29
+ ? path.join("C:", "Program Files", "ClaudeCode")
30
+ : IS_MAC
31
+ ? path.join("/Library", "Application Support", "ClaudeCode")
32
+ : "/etc/claude-code";
33
+ const limiter = path.join(base, "limiter");
34
+ return {
35
+ base,
36
+ managedSettings: path.join(base, "managed-settings.json"),
37
+ limiterDir: limiter,
38
+ hook: path.join(limiter, "hook.js"),
39
+ config: path.join(limiter, "config.json"),
40
+ server: path.join(limiter, "server.json"),
41
+ meta: path.join(limiter, "meta.json"),
42
+ usageDir: path.join(limiter, "usage"),
43
+ backupDir: path.join(base, ".backup"),
44
+ debugLog: path.join(limiter, "debug.log"),
45
+ };
46
+ })();
47
+
48
+ // ════════════════════════════════════════════════════════════
49
+ // TERMINAL UI
50
+ // ════════════════════════════════════════════════════════════
51
+
52
+ const C = {
53
+ bold: (s) => `\x1b[1m${s}\x1b[0m`,
54
+ dim: (s) => `\x1b[2m${s}\x1b[0m`,
55
+ green: (s) => `\x1b[32m${s}\x1b[0m`,
56
+ red: (s) => `\x1b[31m${s}\x1b[0m`,
57
+ yellow: (s) => `\x1b[33m${s}\x1b[0m`,
58
+ cyan: (s) => `\x1b[36m${s}\x1b[0m`,
59
+ };
60
+
61
+ function log(msg = "") { console.log(msg); }
62
+ function ok(msg) { console.log(C.green(` ✅ ${msg}`)); }
63
+ function warn(msg) { console.log(C.yellow(` ⚠️ ${msg}`)); }
64
+ function fail(msg) { console.error(C.red(` ❌ ${msg}`)); }
65
+ function info(msg) { console.log(C.cyan(` ℹ ${msg}`)); }
66
+
67
+ function ask(question) {
68
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
69
+ return new Promise((resolve) => {
70
+ rl.question(` ${C.bold(question)} `, (answer) => { rl.close(); resolve(answer.trim()); });
71
+ });
72
+ }
73
+
74
+ // ════════════════════════════════════════════════════════════
75
+ // NETWORK
76
+ // ════════════════════════════════════════════════════════════
77
+
78
+ function fetchJSON(url, options) {
79
+ return new Promise((resolve, reject) => {
80
+ const urlObj = new URL(url);
81
+ const client = urlObj.protocol === "https:" ? https : http;
82
+ const body = options.body ? JSON.stringify(options.body) : null;
83
+
84
+ const req = client.request(urlObj, {
85
+ method: options.method || "GET",
86
+ headers: {
87
+ "Content-Type": "application/json",
88
+ ...(body ? { "Content-Length": Buffer.byteLength(body) } : {}),
89
+ ...(options.headers || {}),
90
+ },
91
+ }, (res) => {
92
+ let data = "";
93
+ res.on("data", (chunk) => (data += chunk));
94
+ res.on("end", () => {
95
+ if (res.statusCode >= 400) return reject(new Error(`HTTP ${res.statusCode}: ${data.slice(0, 200)}`));
96
+ try { resolve(JSON.parse(data)); } catch { reject(new Error("Invalid JSON response")); }
97
+ });
98
+ });
99
+
100
+ req.on("error", reject);
101
+ req.setTimeout(15000, () => { req.destroy(); reject(new Error("Request timed out")); });
102
+ if (body) req.write(body);
103
+ req.end();
104
+ });
105
+ }
106
+
107
+ // ════════════════════════════════════════════════════════════
108
+ // PRIVILEGE CHECKS
109
+ // ════════════════════════════════════════════════════════════
110
+
111
+ function isElevated() {
112
+ if (IS_WIN) {
113
+ try { execSync("net session", { stdio: "ignore" }); return true; } catch { return false; }
114
+ }
115
+ return process.getuid && process.getuid() === 0;
116
+ }
117
+
118
+ function requireElevated(action) {
119
+ if (isElevated()) return;
120
+ log("");
121
+ fail(`"${action}" requires elevated privileges.`);
122
+ log("");
123
+ if (IS_WIN) {
124
+ info("Right-click PowerShell → 'Run as administrator', then retry.");
125
+ } else {
126
+ info(`Run with sudo: sudo npx claude-code-limiter ${action}`);
127
+ }
128
+ log("");
129
+ process.exit(1);
130
+ }
131
+
132
+ // ════════════════════════════════════════════════════════════
133
+ // MANAGED-SETTINGS.JSON GENERATOR
134
+ // ════════════════════════════════════════════════════════════
135
+
136
+ function generateManagedSettings(existingSettings) {
137
+ const hookPath = PATHS.hook;
138
+ const nodeCmd = IS_WIN
139
+ ? `"${process.execPath.replace(/\\/g, "\\\\")}" "${hookPath.replace(/\\/g, "\\\\")}"`
140
+ : `node "${hookPath}"`;
141
+
142
+ const base = existingSettings || {};
143
+ return {
144
+ ...base,
145
+ _readme: "Auto-generated by claude-code-limiter. Do not edit manually.",
146
+ allowManagedHooksOnly: true,
147
+ hooks: {
148
+ ...(base.hooks || {}),
149
+ SessionStart: [{
150
+ matcher: "",
151
+ hooks: [{ type: "command", command: `${nodeCmd} sync`, timeout: 10 }],
152
+ }],
153
+ UserPromptSubmit: [{
154
+ hooks: [{ type: "command", command: `${nodeCmd} check`, timeout: 5 }],
155
+ }],
156
+ Stop: [{
157
+ hooks: [{ type: "command", command: `${nodeCmd} count`, timeout: 5 }],
158
+ }],
159
+ PreToolUse: [{
160
+ hooks: [{ type: "command", command: `${nodeCmd} enforce`, timeout: 2 }],
161
+ }],
162
+ },
163
+ };
164
+ }
165
+
166
+ // ════════════════════════════════════════════════════════════
167
+ // FILE PERMISSIONS
168
+ // ════════════════════════════════════════════════════════════
169
+
170
+ function lockPermissions() {
171
+ try {
172
+ if (IS_WIN) {
173
+ execSync(`icacls "${PATHS.limiterDir}" /inheritance:r /grant:r Administrators:(OI)(CI)F /grant:r Users:(OI)(CI)RX /T`, { stdio: "ignore" });
174
+ execSync(`icacls "${PATHS.usageDir}" /grant:r Users:(OI)(CI)M`, { stdio: "ignore" });
175
+ execSync(`icacls "${PATHS.managedSettings}" /inheritance:r /grant:r Administrators:F /grant:r Users:R`, { stdio: "ignore" });
176
+ execSync(`icacls "${PATHS.backupDir}" /inheritance:r /grant:r Administrators:(OI)(CI)F /T`, { stdio: "ignore" });
177
+ } else {
178
+ const owner = IS_MAC ? "root:wheel" : "root:root";
179
+ execSync(`chown -R ${owner} "${PATHS.limiterDir}"`, { stdio: "ignore" });
180
+ execSync(`chmod 755 "${PATHS.limiterDir}"`, { stdio: "ignore" });
181
+ execSync(`chmod 644 "${PATHS.hook}" "${PATHS.config}"`, { stdio: "ignore" });
182
+ if (fs.existsSync(PATHS.server)) execSync(`chmod 644 "${PATHS.server}"`, { stdio: "ignore" });
183
+ if (fs.existsSync(PATHS.meta)) execSync(`chmod 644 "${PATHS.meta}"`, { stdio: "ignore" });
184
+ execSync(`chmod 1777 "${PATHS.usageDir}"`, { stdio: "ignore" });
185
+ execSync(`chown ${owner} "${PATHS.managedSettings}" && chmod 644 "${PATHS.managedSettings}"`, { stdio: "ignore" });
186
+ execSync(`chown -R ${owner} "${PATHS.backupDir}" && chmod -R 700 "${PATHS.backupDir}"`, { stdio: "ignore" });
187
+ }
188
+ ok("File permissions locked");
189
+ } catch (err) {
190
+ warn(`Permissions partially set: ${err.message}`);
191
+ }
192
+ }
193
+
194
+ // ════════════════════════════════════════════════════════════
195
+ // WATCHDOG
196
+ // ════════════════════════════════════════════════════════════
197
+
198
+ function setupWatchdog() {
199
+ if (IS_WIN) {
200
+ const script = [
201
+ `$b = "${PATHS.backupDir.replace(/\\/g, "\\\\")}"`,
202
+ `$m = "${PATHS.managedSettings.replace(/\\/g, "\\\\")}"`,
203
+ `$h = "${PATHS.hook.replace(/\\/g, "\\\\")}"`,
204
+ `$c = "${PATHS.config.replace(/\\/g, "\\\\")}"`,
205
+ `if (!(Test-Path $m)) { Copy-Item "$b\\managed-settings.json" $m -Force -EA SilentlyContinue }`,
206
+ `if (!(Test-Path $h)) { Copy-Item "$b\\hook.js" $h -Force -EA SilentlyContinue }`,
207
+ `if (!(Test-Path $c)) { Copy-Item "$b\\config.json" $c -Force -EA SilentlyContinue }`,
208
+ `$bHash = (Get-FileHash "$b\\hook.js" -Algorithm SHA256 -EA SilentlyContinue).Hash`,
209
+ `$hHash = (Get-FileHash $h -Algorithm SHA256 -EA SilentlyContinue).Hash`,
210
+ `if ($bHash -and $hHash -and ($bHash -ne $hHash)) {`,
211
+ ` Copy-Item "$b\\hook.js" $h -Force; Copy-Item "$b\\config.json" $c -Force; Copy-Item "$b\\managed-settings.json" $m -Force`,
212
+ `}`,
213
+ ].join("\n");
214
+ const scriptPath = path.join(PATHS.base, "watchdog.ps1");
215
+ fs.writeFileSync(scriptPath, script);
216
+ try { execSync('schtasks /delete /tn "ClaudeLimiterWatchdog" /f', { stdio: "ignore" }); } catch {}
217
+ execSync(`schtasks /create /tn "ClaudeLimiterWatchdog" /tr "powershell -NoProfile -ExecutionPolicy Bypass -File \\"${scriptPath}\\"" /sc minute /mo 5 /ru SYSTEM /f`, { stdio: "ignore" });
218
+ ok("Watchdog scheduled task created");
219
+ } else if (IS_MAC) {
220
+ const plist = `<?xml version="1.0" encoding="UTF-8"?>
221
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
222
+ <plist version="1.0">
223
+ <dict>
224
+ <key>Label</key><string>com.claude-limiter.watchdog</string>
225
+ <key>ProgramArguments</key>
226
+ <array>
227
+ <string>/bin/bash</string>
228
+ <string>-c</string>
229
+ <string>
230
+ B="${PATHS.backupDir}"; M="${PATHS.managedSettings}"; H="${PATHS.hook}"; C="${PATHS.config}"
231
+ [ ! -f "$M" ] &amp;&amp; cp "$B/managed-settings.json" "$M" 2>/dev/null
232
+ [ ! -f "$H" ] &amp;&amp; cp "$B/hook.js" "$H" 2>/dev/null
233
+ [ ! -f "$C" ] &amp;&amp; cp "$B/config.json" "$C" 2>/dev/null
234
+ BHASH=$(shasum -a 256 "$B/hook.js" 2>/dev/null | cut -d' ' -f1)
235
+ HHASH=$(shasum -a 256 "$H" 2>/dev/null | cut -d' ' -f1)
236
+ if [ -n "$BHASH" ] &amp;&amp; [ -n "$HHASH" ] &amp;&amp; [ "$BHASH" != "$HHASH" ]; then
237
+ cp "$B/hook.js" "$H"; cp "$B/config.json" "$C"; cp "$B/managed-settings.json" "$M"
238
+ chown root:wheel "$M" "$H" "$C"; chmod 644 "$M" "$H" "$C"
239
+ fi
240
+ </string>
241
+ </array>
242
+ <key>StartInterval</key><integer>300</integer>
243
+ <key>RunAtLoad</key><true/>
244
+ </dict>
245
+ </plist>`;
246
+ const plistPath = "/Library/LaunchDaemons/com.claude-limiter.watchdog.plist";
247
+ fs.writeFileSync(plistPath, plist);
248
+ try { execSync(`launchctl unload "${plistPath}" 2>/dev/null`, { stdio: "ignore" }); } catch {}
249
+ execSync(`launchctl load "${plistPath}"`, { stdio: "ignore" });
250
+ ok("Watchdog launchd daemon created");
251
+ } else {
252
+ const service = `[Unit]\nDescription=Claude Code Limiter Watchdog\n[Service]\nType=oneshot\nExecStart=/bin/bash -c 'B="${PATHS.backupDir}"; M="${PATHS.managedSettings}"; H="${PATHS.hook}"; C="${PATHS.config}"; [ ! -f "$M" ] && cp "$B/managed-settings.json" "$M"; [ ! -f "$H" ] && cp "$B/hook.js" "$H"; [ ! -f "$C" ] && cp "$B/config.json" "$C"; BHASH=$(sha256sum "$B/hook.js" 2>/dev/null | cut -d" " -f1); HHASH=$(sha256sum "$H" 2>/dev/null | cut -d" " -f1); if [ -n "$BHASH" ] && [ -n "$HHASH" ] && [ "$BHASH" != "$HHASH" ]; then cp "$B/hook.js" "$H"; cp "$B/config.json" "$C"; cp "$B/managed-settings.json" "$M"; chown root:root "$M" "$H" "$C"; chmod 644 "$M" "$H" "$C"; fi'`;
253
+ const timer = `[Unit]\nDescription=Claude Code Limiter Watchdog Timer\n[Timer]\nOnBootSec=1min\nOnUnitActiveSec=5min\n[Install]\nWantedBy=timers.target`;
254
+ fs.writeFileSync("/etc/systemd/system/claude-limiter-watchdog.service", service);
255
+ fs.writeFileSync("/etc/systemd/system/claude-limiter-watchdog.timer", timer);
256
+ execSync("systemctl daemon-reload", { stdio: "ignore" });
257
+ execSync("systemctl enable --now claude-limiter-watchdog.timer", { stdio: "ignore" });
258
+ ok("Watchdog systemd timer created");
259
+ }
260
+ }
261
+
262
+ function removeWatchdog() {
263
+ if (IS_WIN) {
264
+ try { execSync('schtasks /delete /tn "ClaudeLimiterWatchdog" /f', { stdio: "ignore" }); ok("Removed watchdog task"); } catch { warn("No watchdog task found"); }
265
+ try { fs.unlinkSync(path.join(PATHS.base, "watchdog.ps1")); } catch {}
266
+ } else if (IS_MAC) {
267
+ const plist = "/Library/LaunchDaemons/com.claude-limiter.watchdog.plist";
268
+ try { execSync(`launchctl unload "${plist}" 2>/dev/null`, { stdio: "ignore" }); fs.unlinkSync(plist); ok("Removed watchdog"); } catch { warn("No watchdog found"); }
269
+ } else {
270
+ try {
271
+ execSync("systemctl stop claude-limiter-watchdog.timer 2>/dev/null", { stdio: "ignore" });
272
+ execSync("systemctl disable claude-limiter-watchdog.timer 2>/dev/null", { stdio: "ignore" });
273
+ for (const f of ["claude-limiter-watchdog.service", "claude-limiter-watchdog.timer"]) {
274
+ try { fs.unlinkSync(`/etc/systemd/system/${f}`); } catch {}
275
+ }
276
+ execSync("systemctl daemon-reload 2>/dev/null", { stdio: "ignore" });
277
+ ok("Removed watchdog");
278
+ } catch { warn("No watchdog found"); }
279
+ }
280
+ }
281
+
282
+ // ════════════════════════════════════════════════════════════
283
+ // BACKUP
284
+ // ════════════════════════════════════════════════════════════
285
+
286
+ function createBackup() {
287
+ fs.mkdirSync(PATHS.backupDir, { recursive: true });
288
+ const checksums = {};
289
+ for (const [src, name] of [[PATHS.managedSettings, "managed-settings.json"], [PATHS.hook, "hook.js"], [PATHS.config, "config.json"]]) {
290
+ if (fs.existsSync(src)) {
291
+ fs.copyFileSync(src, path.join(PATHS.backupDir, name));
292
+ const hash = crypto.createHash("sha256").update(fs.readFileSync(src)).digest("hex");
293
+ checksums[name] = hash;
294
+ }
295
+ }
296
+ fs.writeFileSync(path.join(PATHS.backupDir, "checksums.json"), JSON.stringify(checksums, null, 2));
297
+ ok("Backup created");
298
+ }
299
+
300
+ // ════════════════════════════════════════════════════════════
301
+ // SETUP
302
+ // ════════════════════════════════════════════════════════════
303
+
304
+ async function setup(flags) {
305
+ requireElevated("setup");
306
+
307
+ log("");
308
+ log(C.bold(" ╔══════════════════════════════════════════╗"));
309
+ log(C.bold(" ║ Claude Code Limiter — Setup ║"));
310
+ log(C.bold(" ╚══════════════════════════════════════════╝"));
311
+ log("");
312
+
313
+ let code = flags.code;
314
+ let serverUrl = flags.server;
315
+
316
+ if (!code) code = await ask("Install code:");
317
+ if (!serverUrl) serverUrl = await ask("Server URL:");
318
+
319
+ if (!code || !serverUrl) {
320
+ fail("Install code and server URL are required.");
321
+ process.exit(1);
322
+ }
323
+
324
+ // Normalize server URL
325
+ serverUrl = serverUrl.replace(/\/+$/, "");
326
+
327
+ log("");
328
+ info("Registering with server...");
329
+
330
+ let registration;
331
+ try {
332
+ registration = await fetchJSON(`${serverUrl}/api/v1/register`, {
333
+ method: "POST",
334
+ body: { code },
335
+ });
336
+ } catch (err) {
337
+ fail(`Registration failed: ${err.message}`);
338
+ process.exit(1);
339
+ }
340
+
341
+ if (!registration || !registration.auth_token) {
342
+ fail("Invalid server response — missing auth_token.");
343
+ process.exit(1);
344
+ }
345
+
346
+ const { auth_token, user_name, limits, credit_weights, status } = registration;
347
+ ok(`Registered as "${user_name}"`);
348
+
349
+ // Show limits
350
+ info("Limits:");
351
+ for (const rule of (limits || [])) {
352
+ if (rule.type === "per_model") {
353
+ log(` ${rule.model || "all"}: ${rule.value < 0 ? "∞" : rule.value}/${rule.window || "daily"}`);
354
+ } else if (rule.type === "credits") {
355
+ log(` credits: ${rule.value}/${rule.window || "daily"}`);
356
+ } else if (rule.type === "time_of_day") {
357
+ log(` ${rule.model || "all"}: ${rule.schedule_start}-${rule.schedule_end} (${rule.schedule_tz || "local"})`);
358
+ }
359
+ }
360
+
361
+ if (!flags.yes) {
362
+ log("");
363
+ const confirm = await ask("Proceed with installation? (y/N):");
364
+ if (!confirm.match(/^y(es)?$/i)) { log("\n Cancelled.\n"); process.exit(0); }
365
+ }
366
+
367
+ log("");
368
+ info("Installing...");
369
+ log("");
370
+
371
+ // Create directories
372
+ for (const dir of [PATHS.limiterDir, PATHS.usageDir, PATHS.backupDir]) {
373
+ fs.mkdirSync(dir, { recursive: true });
374
+ }
375
+
376
+ // Copy hook script
377
+ const hookSource = path.join(__dirname, "hook.js");
378
+ fs.copyFileSync(hookSource, PATHS.hook);
379
+ ok(`Hook → ${PATHS.hook}`);
380
+
381
+ // Write config
382
+ const localConfig = {
383
+ user_name,
384
+ status: status || "active",
385
+ debug: false,
386
+ limits: limits || [],
387
+ credit_weights: credit_weights || { opus: 10, sonnet: 3, haiku: 1 },
388
+ };
389
+ fs.writeFileSync(PATHS.config, JSON.stringify(localConfig, null, 2));
390
+ ok(`Config → ${PATHS.config}`);
391
+
392
+ // Write server connection info
393
+ const serverConfig = { url: serverUrl, auth_token };
394
+ fs.writeFileSync(PATHS.server, JSON.stringify(serverConfig, null, 2));
395
+ ok(`Server → ${PATHS.server}`);
396
+
397
+ // Write meta
398
+ const meta = {
399
+ serverUrl,
400
+ installedAt: new Date().toISOString(),
401
+ platform: process.platform,
402
+ arch: process.arch,
403
+ nodeVersion: process.version,
404
+ };
405
+ fs.writeFileSync(PATHS.meta, JSON.stringify(meta, null, 2));
406
+
407
+ // Generate managed-settings.json
408
+ let existingMS = null;
409
+ try { existingMS = JSON.parse(fs.readFileSync(PATHS.managedSettings, "utf-8")); } catch {}
410
+ const managedSettings = generateManagedSettings(existingMS);
411
+ fs.writeFileSync(PATHS.managedSettings, JSON.stringify(managedSettings, null, 2));
412
+ ok(`Managed settings → ${PATHS.managedSettings}`);
413
+ ok("allowManagedHooksOnly: true");
414
+
415
+ // Backup
416
+ createBackup();
417
+
418
+ // Permissions
419
+ lockPermissions();
420
+
421
+ // Watchdog
422
+ setupWatchdog();
423
+
424
+ log("");
425
+ log(C.green(C.bold(" Installation complete!")));
426
+ log("");
427
+ log(" Enforcement layers:");
428
+ log(" 1. managed-settings.json (highest priority)");
429
+ log(" 2. allowManagedHooksOnly (user hooks blocked)");
430
+ log(` 3. File permissions (${IS_WIN ? "admin" : "root"}-only write)`);
431
+ log(" 4. Watchdog (auto-restores every 5 min)");
432
+ log(" 5. Server-side tracking (tamper-proof usage)");
433
+ log("");
434
+ log(C.yellow(" Restart Claude Code for hooks to take effect."));
435
+ log("");
436
+ }
437
+
438
+ // ════════════════════════════════════════════════════════════
439
+ // UNINSTALL
440
+ // ════════════════════════════════════════════════════════════
441
+
442
+ async function uninstall(flags) {
443
+ requireElevated("uninstall");
444
+ log("");
445
+ log(C.bold(" Uninstalling Claude Code Limiter"));
446
+ log("");
447
+
448
+ if (!flags.yes) {
449
+ const confirm = await ask("Remove all limiter files and watchdog? (y/N):");
450
+ if (!confirm.match(/^y(es)?$/i)) { log("\n Cancelled.\n"); return; }
451
+ log("");
452
+ }
453
+
454
+ removeWatchdog();
455
+
456
+ for (const p of [PATHS.limiterDir, PATHS.backupDir]) {
457
+ try { fs.rmSync(p, { recursive: true, force: true }); ok(`Removed ${p}`); } catch { warn(`Could not remove ${p}`); }
458
+ }
459
+
460
+ try {
461
+ const ms = JSON.parse(fs.readFileSync(PATHS.managedSettings, "utf-8"));
462
+ if (ms._readme && ms._readme.includes("claude-code-limiter")) {
463
+ fs.unlinkSync(PATHS.managedSettings);
464
+ ok(`Removed ${PATHS.managedSettings}`);
465
+ } else {
466
+ delete ms.allowManagedHooksOnly;
467
+ delete ms._readme;
468
+ if (ms.hooks) {
469
+ delete ms.hooks.SessionStart;
470
+ delete ms.hooks.UserPromptSubmit;
471
+ delete ms.hooks.Stop;
472
+ delete ms.hooks.PreToolUse;
473
+ if (Object.keys(ms.hooks).length === 0) delete ms.hooks;
474
+ }
475
+ fs.writeFileSync(PATHS.managedSettings, JSON.stringify(ms, null, 2));
476
+ ok(`Cleaned limiter hooks from managed-settings`);
477
+ }
478
+ } catch { warn(`Could not clean ${PATHS.managedSettings}`); }
479
+
480
+ log("");
481
+ log(C.green(" Done. Restart Claude Code for changes to take effect."));
482
+ log("");
483
+ }
484
+
485
+ // ════════════════════════════════════════════════════════════
486
+ // STATUS
487
+ // ════════════════════════════════════════════════════════════
488
+
489
+ function status() {
490
+ if (!fs.existsSync(PATHS.hook)) {
491
+ log("");
492
+ warn("Limiter not installed on this machine.");
493
+ log(C.dim(` Expected hook at: ${PATHS.hook}`));
494
+ log(C.dim(" Run: sudo npx claude-code-limiter setup"));
495
+ log("");
496
+ return;
497
+ }
498
+ try { execSync(`node "${PATHS.hook}" status`, { stdio: "inherit" }); } catch { fail("Could not run status"); }
499
+ }
500
+
501
+ // ════════════════════════════════════════════════════════════
502
+ // SYNC
503
+ // ════════════════════════════════════════════════════════════
504
+
505
+ async function sync(flags) {
506
+ requireElevated("sync");
507
+ if (!fs.existsSync(PATHS.hook)) { fail("Limiter not installed."); process.exit(1); }
508
+
509
+ info("Syncing with server...");
510
+ try {
511
+ execSync(`node "${PATHS.hook}" sync < /dev/null`, { stdio: "inherit" });
512
+ ok("Sync complete.");
513
+ } catch { fail("Sync failed."); }
514
+ }
515
+
516
+ // ════════════════════════════════════════════════════════════
517
+ // EXPORTS
518
+ // ════════════════════════════════════════════════════════════
519
+
520
+ module.exports = { setup, uninstall, status, sync };