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 +21 -0
- package/bin/cli.js +174 -0
- package/package.json +20 -0
- package/src/hook.js +624 -0
- package/src/installer.js +520 -0
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
|
+
}
|
package/src/installer.js
ADDED
|
@@ -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" ] && cp "$B/managed-settings.json" "$M" 2>/dev/null
|
|
232
|
+
[ ! -f "$H" ] && cp "$B/hook.js" "$H" 2>/dev/null
|
|
233
|
+
[ ! -f "$C" ] && 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" ] && [ -n "$HHASH" ] && [ "$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 };
|