@mauribadnights/clooks 0.3.1 → 0.3.2
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/dist/cli.js +33 -2
- package/dist/index.d.ts +2 -0
- package/dist/index.js +4 -2
- package/dist/ratelimit.d.ts +7 -2
- package/dist/ratelimit.js +25 -4
- package/dist/server.js +11 -4
- package/dist/sync.d.ts +13 -0
- package/dist/sync.js +153 -0
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -10,6 +10,7 @@ const migrate_js_1 = require("./migrate.js");
|
|
|
10
10
|
const doctor_js_1 = require("./doctor.js");
|
|
11
11
|
const auth_js_1 = require("./auth.js");
|
|
12
12
|
const plugin_js_1 = require("./plugin.js");
|
|
13
|
+
const sync_js_1 = require("./sync.js");
|
|
13
14
|
const constants_js_1 = require("./constants.js");
|
|
14
15
|
const fs_1 = require("fs");
|
|
15
16
|
const path_1 = require("path");
|
|
@@ -17,7 +18,7 @@ const program = new commander_1.Command();
|
|
|
17
18
|
program
|
|
18
19
|
.name('clooks')
|
|
19
20
|
.description('Persistent hook runtime for Claude Code')
|
|
20
|
-
.version('0.3.
|
|
21
|
+
.version('0.3.2');
|
|
21
22
|
// --- start ---
|
|
22
23
|
program
|
|
23
24
|
.command('start')
|
|
@@ -40,6 +41,11 @@ program
|
|
|
40
41
|
(0, server_js_1.startDaemonBackground)({ noWatch });
|
|
41
42
|
// Give it a moment to start
|
|
42
43
|
await new Promise((r) => setTimeout(r, 500));
|
|
44
|
+
// Sync settings.json with manifest
|
|
45
|
+
const syncAdded = (0, sync_js_1.syncSettings)();
|
|
46
|
+
if (syncAdded.length > 0) {
|
|
47
|
+
console.log(`Synced HTTP hooks for: ${syncAdded.join(', ')}`);
|
|
48
|
+
}
|
|
43
49
|
if ((0, server_js_1.isDaemonRunning)()) {
|
|
44
50
|
const pid = (0, fs_1.readFileSync)(constants_js_1.PID_FILE, 'utf-8').trim();
|
|
45
51
|
console.log(`Daemon started (pid ${pid}), listening on 127.0.0.1:${constants_js_1.DEFAULT_PORT}`);
|
|
@@ -187,13 +193,31 @@ program
|
|
|
187
193
|
if (errors > 0)
|
|
188
194
|
process.exit(1);
|
|
189
195
|
});
|
|
196
|
+
// --- sync ---
|
|
197
|
+
program
|
|
198
|
+
.command('sync')
|
|
199
|
+
.description('Sync settings.json with manifest (add missing HTTP hook entries)')
|
|
200
|
+
.action(() => {
|
|
201
|
+
const added = (0, sync_js_1.syncSettings)();
|
|
202
|
+
if (added.length === 0) {
|
|
203
|
+
console.log('Settings already in sync.');
|
|
204
|
+
}
|
|
205
|
+
else {
|
|
206
|
+
console.log(`Added HTTP hooks for: ${added.join(', ')}`);
|
|
207
|
+
const settingsPath = (0, migrate_js_1.getSettingsPath)();
|
|
208
|
+
if (settingsPath) {
|
|
209
|
+
console.log(`Settings updated: ${settingsPath}`);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
});
|
|
190
213
|
// --- ensure-running ---
|
|
191
214
|
program
|
|
192
215
|
.command('ensure-running')
|
|
193
216
|
.description('Start daemon if not already running (used by SessionStart hook)')
|
|
194
217
|
.action(async () => {
|
|
195
218
|
if ((0, server_js_1.isDaemonRunning)()) {
|
|
196
|
-
// Already running —
|
|
219
|
+
// Already running — sync settings silently and exit fast
|
|
220
|
+
(0, sync_js_1.syncSettings)();
|
|
197
221
|
process.exit(0);
|
|
198
222
|
}
|
|
199
223
|
// Ensure config dir exists
|
|
@@ -206,6 +230,8 @@ program
|
|
|
206
230
|
(0, manifest_js_1.createDefaultManifest)();
|
|
207
231
|
}
|
|
208
232
|
(0, server_js_1.startDaemonBackground)();
|
|
233
|
+
// Sync settings silently after starting
|
|
234
|
+
(0, sync_js_1.syncSettings)();
|
|
209
235
|
process.exit(0);
|
|
210
236
|
});
|
|
211
237
|
// --- init ---
|
|
@@ -294,6 +320,11 @@ program
|
|
|
294
320
|
? Object.values(installed.manifest.handlers).reduce((sum, arr) => sum + (arr?.length ?? 0), 0)
|
|
295
321
|
: 0;
|
|
296
322
|
console.log(`Installed plugin ${plugin.name} v${plugin.version} (${handlerCount} handlers)`);
|
|
323
|
+
// Sync settings.json to add HTTP hooks for any new events
|
|
324
|
+
const syncAdded = (0, sync_js_1.syncSettings)();
|
|
325
|
+
if (syncAdded.length > 0) {
|
|
326
|
+
console.log(`Synced HTTP hooks for: ${syncAdded.join(', ')}`);
|
|
327
|
+
}
|
|
297
328
|
}
|
|
298
329
|
catch (err) {
|
|
299
330
|
console.error('Plugin install failed:', err instanceof Error ? err.message : err);
|
package/dist/index.d.ts
CHANGED
|
@@ -11,6 +11,8 @@ export { DenyCache } from './shortcircuit.js';
|
|
|
11
11
|
export { RateLimiter } from './ratelimit.js';
|
|
12
12
|
export { startWatcher, stopWatcher } from './watcher.js';
|
|
13
13
|
export { generateAuthToken, validateAuth, rotateToken } from './auth.js';
|
|
14
|
+
export { syncSettings } from './sync.js';
|
|
15
|
+
export type { SyncOptions } from './sync.js';
|
|
14
16
|
export type { RotateTokenOptions } from './auth.js';
|
|
15
17
|
export { evaluateFilter } from './filter.js';
|
|
16
18
|
export { executeLLMHandler, executeLLMHandlersBatched, calculateCost, resetClient } from './llm.js';
|
package/dist/index.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
// clooks — public API exports
|
|
3
3
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
|
-
exports.
|
|
5
|
-
exports.PLUGIN_MANIFEST_NAME = exports.PLUGIN_REGISTRY = void 0;
|
|
4
|
+
exports.LLM_PRICING = exports.DEFAULT_LLM_MAX_TOKENS = exports.DEFAULT_LLM_TIMEOUT = exports.COSTS_FILE = exports.LOG_FILE = exports.METRICS_FILE = exports.PID_FILE = exports.MANIFEST_PATH = exports.CONFIG_DIR = exports.DEFAULT_PORT = exports.renderPromptTemplate = exports.prefetchContext = exports.resetClient = exports.calculateCost = exports.executeLLMHandlersBatched = exports.executeLLMHandler = exports.evaluateFilter = exports.syncSettings = exports.rotateToken = exports.validateAuth = exports.generateAuthToken = exports.stopWatcher = exports.startWatcher = exports.RateLimiter = exports.DenyCache = exports.resolveExecutionOrder = exports.cleanupHandlerState = exports.resetSessionIsolatedHandlers = exports.executeHandlers = exports.runDoctor = exports.getSettingsPath = exports.restore = exports.migrate = exports.MetricsCollector = exports.listPlugins = exports.uninstallPlugin = exports.installPlugin = exports.saveRegistry = exports.loadRegistry = exports.validatePluginManifest = exports.mergeManifests = exports.loadPlugins = exports.createDefaultManifest = exports.validateManifest = exports.loadCompositeManifest = exports.loadManifest = exports.isDaemonRunning = exports.stopDaemon = exports.startDaemon = exports.createServer = void 0;
|
|
5
|
+
exports.PLUGIN_MANIFEST_NAME = exports.PLUGIN_REGISTRY = exports.PLUGINS_DIR = void 0;
|
|
6
6
|
var server_js_1 = require("./server.js");
|
|
7
7
|
Object.defineProperty(exports, "createServer", { enumerable: true, get: function () { return server_js_1.createServer; } });
|
|
8
8
|
Object.defineProperty(exports, "startDaemon", { enumerable: true, get: function () { return server_js_1.startDaemon; } });
|
|
@@ -47,6 +47,8 @@ var auth_js_1 = require("./auth.js");
|
|
|
47
47
|
Object.defineProperty(exports, "generateAuthToken", { enumerable: true, get: function () { return auth_js_1.generateAuthToken; } });
|
|
48
48
|
Object.defineProperty(exports, "validateAuth", { enumerable: true, get: function () { return auth_js_1.validateAuth; } });
|
|
49
49
|
Object.defineProperty(exports, "rotateToken", { enumerable: true, get: function () { return auth_js_1.rotateToken; } });
|
|
50
|
+
var sync_js_1 = require("./sync.js");
|
|
51
|
+
Object.defineProperty(exports, "syncSettings", { enumerable: true, get: function () { return sync_js_1.syncSettings; } });
|
|
50
52
|
var filter_js_1 = require("./filter.js");
|
|
51
53
|
Object.defineProperty(exports, "evaluateFilter", { enumerable: true, get: function () { return filter_js_1.evaluateFilter; } });
|
|
52
54
|
var llm_js_1 = require("./llm.js");
|
package/dist/ratelimit.d.ts
CHANGED
|
@@ -5,8 +5,13 @@ export declare class RateLimiter {
|
|
|
5
5
|
constructor(maxAttempts?: number, windowMs?: number);
|
|
6
6
|
/** Check if source is rate-limited. Returns true if allowed. */
|
|
7
7
|
check(source: string): boolean;
|
|
8
|
-
/** Record an
|
|
9
|
-
|
|
8
|
+
/** Record an auth failure from source. */
|
|
9
|
+
recordFailure(source: string): void;
|
|
10
|
+
/**
|
|
11
|
+
* How many seconds until the rate limit resets for a given source.
|
|
12
|
+
* Returns 0 if the source is not rate-limited.
|
|
13
|
+
*/
|
|
14
|
+
retryAfter(source: string): number;
|
|
10
15
|
/** Clean up old entries. */
|
|
11
16
|
cleanup(): void;
|
|
12
17
|
}
|
package/dist/ratelimit.js
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
// clooks rate limiting — protect against auth brute-force
|
|
3
|
+
//
|
|
4
|
+
// IMPORTANT: This rate limiter should ONLY be used when auth is configured.
|
|
5
|
+
// It tracks auth failures per source IP. When the limit is exceeded, requests
|
|
6
|
+
// from that source are blocked with 429 until the window expires.
|
|
3
7
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
8
|
exports.RateLimiter = void 0;
|
|
5
9
|
class RateLimiter {
|
|
6
|
-
attempts = new Map(); // source → timestamps
|
|
10
|
+
attempts = new Map(); // source → auth failure timestamps
|
|
7
11
|
maxAttempts;
|
|
8
12
|
windowMs;
|
|
9
13
|
constructor(maxAttempts = 10, windowMs = 60_000) {
|
|
@@ -16,17 +20,34 @@ class RateLimiter {
|
|
|
16
20
|
const timestamps = this.attempts.get(source);
|
|
17
21
|
if (!timestamps)
|
|
18
22
|
return true;
|
|
19
|
-
// Count recent
|
|
23
|
+
// Count recent auth failures within window
|
|
20
24
|
const recent = timestamps.filter(t => now - t <= this.windowMs);
|
|
21
25
|
return recent.length < this.maxAttempts;
|
|
22
26
|
}
|
|
23
|
-
/** Record an
|
|
24
|
-
|
|
27
|
+
/** Record an auth failure from source. */
|
|
28
|
+
recordFailure(source) {
|
|
25
29
|
const now = Date.now();
|
|
26
30
|
const timestamps = this.attempts.get(source) ?? [];
|
|
27
31
|
timestamps.push(now);
|
|
28
32
|
this.attempts.set(source, timestamps);
|
|
29
33
|
}
|
|
34
|
+
/**
|
|
35
|
+
* How many seconds until the rate limit resets for a given source.
|
|
36
|
+
* Returns 0 if the source is not rate-limited.
|
|
37
|
+
*/
|
|
38
|
+
retryAfter(source) {
|
|
39
|
+
const now = Date.now();
|
|
40
|
+
const timestamps = this.attempts.get(source);
|
|
41
|
+
if (!timestamps)
|
|
42
|
+
return 0;
|
|
43
|
+
const recent = timestamps.filter(t => now - t <= this.windowMs);
|
|
44
|
+
if (recent.length < this.maxAttempts)
|
|
45
|
+
return 0;
|
|
46
|
+
// The oldest recent attempt determines when the window expires
|
|
47
|
+
const oldest = Math.min(...recent);
|
|
48
|
+
const expiresAt = oldest + this.windowMs;
|
|
49
|
+
return Math.ceil((expiresAt - now) / 1000);
|
|
50
|
+
}
|
|
30
51
|
/** Clean up old entries. */
|
|
31
52
|
cleanup() {
|
|
32
53
|
const now = Date.now();
|
package/dist/server.js
CHANGED
|
@@ -131,17 +131,24 @@ function createServer(manifest, metrics) {
|
|
|
131
131
|
});
|
|
132
132
|
return;
|
|
133
133
|
}
|
|
134
|
-
// Auth check for all POST requests
|
|
134
|
+
// Auth check for all POST requests — only when auth token is configured
|
|
135
135
|
if (method === 'POST' && authToken) {
|
|
136
136
|
const source = req.socket.remoteAddress ?? 'unknown';
|
|
137
|
-
// Rate limiting check
|
|
137
|
+
// Rate limiting: check if this source has too many auth failures
|
|
138
138
|
if (!rateLimiter.check(source)) {
|
|
139
|
-
|
|
139
|
+
const retryAfter = rateLimiter.retryAfter(source);
|
|
140
|
+
const body = JSON.stringify({ error: 'Too many auth failures' });
|
|
141
|
+
res.writeHead(429, {
|
|
142
|
+
'Content-Type': 'application/json',
|
|
143
|
+
'Content-Length': Buffer.byteLength(body),
|
|
144
|
+
'Retry-After': String(retryAfter),
|
|
145
|
+
});
|
|
146
|
+
res.end(body);
|
|
140
147
|
return;
|
|
141
148
|
}
|
|
142
149
|
const authHeader = req.headers['authorization'];
|
|
143
150
|
if (!(0, auth_js_1.validateAuth)(authHeader, authToken)) {
|
|
144
|
-
rateLimiter.
|
|
151
|
+
rateLimiter.recordFailure(source);
|
|
145
152
|
log(`Auth failure from ${source}`);
|
|
146
153
|
sendJson(res, 401, { error: 'Unauthorized' });
|
|
147
154
|
return;
|
package/dist/sync.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { Manifest } from './types.js';
|
|
2
|
+
export interface SyncOptions {
|
|
3
|
+
settingsPath?: string;
|
|
4
|
+
manifestPath?: string;
|
|
5
|
+
/** Override composite manifest loading — used for testing */
|
|
6
|
+
manifest?: Manifest;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Ensure settings.json has HTTP hooks for every event that has handlers in the manifest.
|
|
10
|
+
* Adds missing HTTP hook entries without touching existing ones.
|
|
11
|
+
* Returns list of events that were added.
|
|
12
|
+
*/
|
|
13
|
+
export declare function syncSettings(options?: SyncOptions): string[];
|
package/dist/sync.js
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// clooks sync — ensure settings.json has HTTP hooks for every event with handlers
|
|
3
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
|
+
exports.syncSettings = syncSettings;
|
|
5
|
+
const fs_1 = require("fs");
|
|
6
|
+
const path_1 = require("path");
|
|
7
|
+
const os_1 = require("os");
|
|
8
|
+
const manifest_js_1 = require("./manifest.js");
|
|
9
|
+
const constants_js_1 = require("./constants.js");
|
|
10
|
+
/**
|
|
11
|
+
* Find the Claude Code settings.json path.
|
|
12
|
+
* Checks settings.local.json first, then settings.json.
|
|
13
|
+
*/
|
|
14
|
+
function findSettingsPath() {
|
|
15
|
+
const home = (0, os_1.homedir)();
|
|
16
|
+
const candidates = [
|
|
17
|
+
(0, path_1.join)(home, '.claude', 'settings.local.json'),
|
|
18
|
+
(0, path_1.join)(home, '.claude', 'settings.json'),
|
|
19
|
+
];
|
|
20
|
+
for (const candidate of candidates) {
|
|
21
|
+
if ((0, fs_1.existsSync)(candidate)) {
|
|
22
|
+
return candidate;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Ensure settings.json has HTTP hooks for every event that has handlers in the manifest.
|
|
29
|
+
* Adds missing HTTP hook entries without touching existing ones.
|
|
30
|
+
* Returns list of events that were added.
|
|
31
|
+
*/
|
|
32
|
+
function syncSettings(options) {
|
|
33
|
+
// Determine settings path
|
|
34
|
+
const settingsPath = options?.settingsPath ?? findSettingsPath();
|
|
35
|
+
if (!settingsPath || !(0, fs_1.existsSync)(settingsPath)) {
|
|
36
|
+
return []; // No settings file found — nothing to sync
|
|
37
|
+
}
|
|
38
|
+
// Load manifest
|
|
39
|
+
let manifest;
|
|
40
|
+
if (options?.manifest) {
|
|
41
|
+
manifest = options.manifest;
|
|
42
|
+
}
|
|
43
|
+
else {
|
|
44
|
+
manifest = (0, manifest_js_1.loadCompositeManifest)();
|
|
45
|
+
}
|
|
46
|
+
const port = manifest.settings?.port ?? constants_js_1.DEFAULT_PORT;
|
|
47
|
+
const authToken = manifest.settings?.authToken;
|
|
48
|
+
// Find all events that have at least one handler
|
|
49
|
+
const eventsWithHandlers = new Set();
|
|
50
|
+
for (const [event, handlers] of Object.entries(manifest.handlers)) {
|
|
51
|
+
if (handlers && handlers.length > 0) {
|
|
52
|
+
eventsWithHandlers.add(event);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
// Read settings.json
|
|
56
|
+
let raw;
|
|
57
|
+
try {
|
|
58
|
+
raw = (0, fs_1.readFileSync)(settingsPath, 'utf-8');
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return [];
|
|
62
|
+
}
|
|
63
|
+
let settings;
|
|
64
|
+
try {
|
|
65
|
+
settings = JSON.parse(raw);
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
return [];
|
|
69
|
+
}
|
|
70
|
+
if (!settings.hooks) {
|
|
71
|
+
settings.hooks = {};
|
|
72
|
+
}
|
|
73
|
+
const added = [];
|
|
74
|
+
// Check each event with handlers
|
|
75
|
+
for (const event of eventsWithHandlers) {
|
|
76
|
+
const hookUrl = `http://localhost:${port}/hooks/${event}`;
|
|
77
|
+
// Check if settings.json already has an HTTP hook pointing to this URL
|
|
78
|
+
const ruleGroups = settings.hooks[event] ?? [];
|
|
79
|
+
let hasHttpHook = false;
|
|
80
|
+
for (const rule of ruleGroups) {
|
|
81
|
+
if (!Array.isArray(rule.hooks))
|
|
82
|
+
continue;
|
|
83
|
+
for (const hook of rule.hooks) {
|
|
84
|
+
if (hook.type === 'http' && hook.url === hookUrl) {
|
|
85
|
+
hasHttpHook = true;
|
|
86
|
+
break;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
if (hasHttpHook)
|
|
90
|
+
break;
|
|
91
|
+
}
|
|
92
|
+
if (!hasHttpHook) {
|
|
93
|
+
// Add HTTP hook in a new rule group
|
|
94
|
+
const httpHook = {
|
|
95
|
+
type: 'http',
|
|
96
|
+
url: hookUrl,
|
|
97
|
+
};
|
|
98
|
+
if (authToken) {
|
|
99
|
+
httpHook.headers = { Authorization: `Bearer ${authToken}` };
|
|
100
|
+
}
|
|
101
|
+
if (!settings.hooks[event]) {
|
|
102
|
+
settings.hooks[event] = [];
|
|
103
|
+
}
|
|
104
|
+
// Check if there's already a rule group without a matcher we can append to
|
|
105
|
+
const existingRules = settings.hooks[event];
|
|
106
|
+
const unmatchedRule = existingRules.find(r => !r.matcher);
|
|
107
|
+
if (unmatchedRule) {
|
|
108
|
+
unmatchedRule.hooks.push(httpHook);
|
|
109
|
+
}
|
|
110
|
+
else {
|
|
111
|
+
existingRules.push({ hooks: [httpHook] });
|
|
112
|
+
}
|
|
113
|
+
added.push(event);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
// Ensure SessionStart always has the `clooks ensure-running` command hook
|
|
117
|
+
if (!settings.hooks['SessionStart']) {
|
|
118
|
+
settings.hooks['SessionStart'] = [];
|
|
119
|
+
}
|
|
120
|
+
const sessionRules = settings.hooks['SessionStart'];
|
|
121
|
+
let hasEnsureRunning = false;
|
|
122
|
+
for (const rule of sessionRules) {
|
|
123
|
+
if (!Array.isArray(rule.hooks))
|
|
124
|
+
continue;
|
|
125
|
+
for (const hook of rule.hooks) {
|
|
126
|
+
if (hook.type === 'command' && hook.command === 'clooks ensure-running') {
|
|
127
|
+
hasEnsureRunning = true;
|
|
128
|
+
break;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
if (hasEnsureRunning)
|
|
132
|
+
break;
|
|
133
|
+
}
|
|
134
|
+
if (!hasEnsureRunning) {
|
|
135
|
+
const unmatchedRule = sessionRules.find(r => !r.matcher);
|
|
136
|
+
const ensureHook = { type: 'command', command: 'clooks ensure-running' };
|
|
137
|
+
if (unmatchedRule) {
|
|
138
|
+
// Prepend ensure-running before HTTP hooks
|
|
139
|
+
unmatchedRule.hooks.unshift(ensureHook);
|
|
140
|
+
}
|
|
141
|
+
else {
|
|
142
|
+
sessionRules.unshift({ hooks: [ensureHook] });
|
|
143
|
+
}
|
|
144
|
+
if (!added.includes('SessionStart')) {
|
|
145
|
+
added.push('SessionStart');
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
// Write settings.json back only if changes were made
|
|
149
|
+
if (added.length > 0) {
|
|
150
|
+
(0, fs_1.writeFileSync)(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
|
|
151
|
+
}
|
|
152
|
+
return added;
|
|
153
|
+
}
|
package/package.json
CHANGED