@mauribadnights/clooks 0.2.0 → 0.3.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/README.md +133 -5
- package/dist/auth.d.ts +17 -0
- package/dist/auth.js +109 -0
- package/dist/builtin-hooks.d.ts +11 -0
- package/dist/builtin-hooks.js +67 -0
- package/dist/cli.js +136 -5
- package/dist/constants.d.ts +4 -0
- package/dist/constants.js +5 -1
- package/dist/deps.d.ts +13 -0
- package/dist/deps.js +83 -0
- package/dist/doctor.js +110 -0
- package/dist/handlers.d.ts +11 -3
- package/dist/handlers.js +99 -46
- package/dist/index.d.ts +11 -4
- package/dist/index.js +30 -1
- package/dist/llm.d.ts +1 -1
- package/dist/llm.js +8 -4
- package/dist/manifest.d.ts +5 -1
- package/dist/manifest.js +33 -5
- package/dist/metrics.d.ts +8 -1
- package/dist/metrics.js +32 -6
- package/dist/migrate.js +21 -2
- package/dist/plugin.d.ts +50 -0
- package/dist/plugin.js +279 -0
- package/dist/ratelimit.d.ts +12 -0
- package/dist/ratelimit.js +44 -0
- package/dist/server.d.ts +13 -2
- package/dist/server.js +168 -11
- package/dist/shortcircuit.d.ts +20 -0
- package/dist/shortcircuit.js +49 -0
- package/dist/types.d.ts +36 -0
- package/dist/watcher.d.ts +18 -0
- package/dist/watcher.js +120 -0
- package/hooks/check-update.js +37 -0
- package/package.json +2 -1
package/dist/server.js
CHANGED
|
@@ -11,7 +11,12 @@ const fs_1 = require("fs");
|
|
|
11
11
|
const child_process_1 = require("child_process");
|
|
12
12
|
const handlers_js_1 = require("./handlers.js");
|
|
13
13
|
const prefetch_js_1 = require("./prefetch.js");
|
|
14
|
+
const watcher_js_1 = require("./watcher.js");
|
|
15
|
+
const auth_js_1 = require("./auth.js");
|
|
16
|
+
const shortcircuit_js_1 = require("./shortcircuit.js");
|
|
17
|
+
const ratelimit_js_1 = require("./ratelimit.js");
|
|
14
18
|
const constants_js_1 = require("./constants.js");
|
|
19
|
+
const manifest_js_1 = require("./manifest.js");
|
|
15
20
|
function log(msg) {
|
|
16
21
|
const line = `[${new Date().toISOString()}] ${msg}\n`;
|
|
17
22
|
try {
|
|
@@ -79,21 +84,69 @@ function sendJson(res, status, data) {
|
|
|
79
84
|
*/
|
|
80
85
|
function createServer(manifest, metrics) {
|
|
81
86
|
const startTime = Date.now();
|
|
87
|
+
const denyCache = new shortcircuit_js_1.DenyCache();
|
|
88
|
+
const rateLimiter = new ratelimit_js_1.RateLimiter();
|
|
89
|
+
const ctx = {
|
|
90
|
+
server: null,
|
|
91
|
+
metrics,
|
|
92
|
+
startTime,
|
|
93
|
+
manifest,
|
|
94
|
+
denyCache,
|
|
95
|
+
rateLimiter,
|
|
96
|
+
};
|
|
97
|
+
const authToken = manifest.settings?.authToken ?? '';
|
|
98
|
+
// Periodic cleanup for deny cache and rate limiter (every 60s)
|
|
99
|
+
ctx.cleanupInterval = setInterval(() => {
|
|
100
|
+
denyCache.cleanup();
|
|
101
|
+
rateLimiter.cleanup();
|
|
102
|
+
}, 60_000);
|
|
103
|
+
// Unref so it doesn't keep the process alive
|
|
104
|
+
if (ctx.cleanupInterval && typeof ctx.cleanupInterval === 'object' && 'unref' in ctx.cleanupInterval) {
|
|
105
|
+
ctx.cleanupInterval.unref();
|
|
106
|
+
}
|
|
82
107
|
const server = (0, http_1.createServer)(async (req, res) => {
|
|
83
108
|
const url = req.url ?? '/';
|
|
84
109
|
const method = req.method ?? 'GET';
|
|
85
|
-
//
|
|
110
|
+
// Public health endpoint — minimal, no auth
|
|
86
111
|
if (method === 'GET' && url === '/health') {
|
|
87
|
-
|
|
112
|
+
sendJson(res, 200, { status: 'ok' });
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
// Detailed health endpoint — authenticated if authToken configured
|
|
116
|
+
if (method === 'GET' && url === '/health/detail') {
|
|
117
|
+
if (authToken) {
|
|
118
|
+
const authHeader = req.headers['authorization'];
|
|
119
|
+
if (!(0, auth_js_1.validateAuth)(authHeader, authToken)) {
|
|
120
|
+
sendJson(res, 401, { error: 'Unauthorized' });
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
const handlerCount = Object.values(ctx.manifest.handlers)
|
|
88
125
|
.reduce((sum, arr) => sum + (arr?.length ?? 0), 0);
|
|
89
126
|
sendJson(res, 200, {
|
|
90
127
|
status: 'ok',
|
|
91
128
|
uptime: Math.floor((Date.now() - startTime) / 1000),
|
|
92
129
|
handlers_loaded: handlerCount,
|
|
93
|
-
port: manifest.settings?.port ?? constants_js_1.DEFAULT_PORT,
|
|
130
|
+
port: ctx.manifest.settings?.port ?? constants_js_1.DEFAULT_PORT,
|
|
94
131
|
});
|
|
95
132
|
return;
|
|
96
133
|
}
|
|
134
|
+
// Auth check for all POST requests
|
|
135
|
+
if (method === 'POST' && authToken) {
|
|
136
|
+
const source = req.socket.remoteAddress ?? 'unknown';
|
|
137
|
+
// Rate limiting check
|
|
138
|
+
if (!rateLimiter.check(source)) {
|
|
139
|
+
sendJson(res, 429, { error: 'Too many requests' });
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
const authHeader = req.headers['authorization'];
|
|
143
|
+
if (!(0, auth_js_1.validateAuth)(authHeader, authToken)) {
|
|
144
|
+
rateLimiter.record(source);
|
|
145
|
+
log(`Auth failure from ${source}`);
|
|
146
|
+
sendJson(res, 401, { error: 'Unauthorized' });
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
97
150
|
// Hook endpoint: POST /hooks/:eventName
|
|
98
151
|
const hookMatch = url.match(/^\/hooks\/([A-Za-z]+)$/);
|
|
99
152
|
if (method === 'POST' && hookMatch) {
|
|
@@ -103,7 +156,14 @@ function createServer(manifest, metrics) {
|
|
|
103
156
|
return;
|
|
104
157
|
}
|
|
105
158
|
const event = eventName;
|
|
106
|
-
|
|
159
|
+
// On SessionStart, reset session-isolated handlers across ALL events
|
|
160
|
+
if (event === 'SessionStart') {
|
|
161
|
+
const allHandlers = Object.values(ctx.manifest.handlers)
|
|
162
|
+
.flat()
|
|
163
|
+
.filter((h) => h != null);
|
|
164
|
+
(0, handlers_js_1.resetSessionIsolatedHandlers)(allHandlers);
|
|
165
|
+
}
|
|
166
|
+
const handlers = ctx.manifest.handlers[event] ?? [];
|
|
107
167
|
if (handlers.length === 0) {
|
|
108
168
|
sendJson(res, 200, {});
|
|
109
169
|
return;
|
|
@@ -118,12 +178,20 @@ function createServer(manifest, metrics) {
|
|
|
118
178
|
sendJson(res, 400, { error: 'Invalid JSON body' });
|
|
119
179
|
return;
|
|
120
180
|
}
|
|
181
|
+
// Short-circuit: skip PostToolUse if PreToolUse denied this tool
|
|
182
|
+
if (event === 'PostToolUse' && input.tool_name && input.session_id) {
|
|
183
|
+
if (denyCache.isDenied(input.session_id, input.tool_name)) {
|
|
184
|
+
log(`PostToolUse skipped — PreToolUse denied for ${input.tool_name}`);
|
|
185
|
+
sendJson(res, 200, {});
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
121
189
|
log(`Hook: ${eventName} (${handlers.length} handler${handlers.length > 1 ? 's' : ''})`);
|
|
122
190
|
try {
|
|
123
191
|
// Pre-fetch shared context if configured
|
|
124
192
|
let context;
|
|
125
|
-
if (manifest.prefetch && manifest.prefetch.length > 0) {
|
|
126
|
-
context = await (0, prefetch_js_1.prefetchContext)(manifest.prefetch, input);
|
|
193
|
+
if (ctx.manifest.prefetch && ctx.manifest.prefetch.length > 0) {
|
|
194
|
+
context = await (0, prefetch_js_1.prefetchContext)(ctx.manifest.prefetch, input);
|
|
127
195
|
}
|
|
128
196
|
const results = await (0, handlers_js_1.executeHandlers)(event, input, handlers, context);
|
|
129
197
|
// Record metrics and costs
|
|
@@ -138,10 +206,10 @@ function createServer(manifest, metrics) {
|
|
|
138
206
|
filtered: result.filtered,
|
|
139
207
|
usage: result.usage,
|
|
140
208
|
cost_usd: result.cost_usd,
|
|
209
|
+
session_id: input.session_id,
|
|
141
210
|
});
|
|
142
211
|
// Track cost for LLM handlers
|
|
143
212
|
if (result.usage && result.cost_usd !== undefined && result.cost_usd > 0) {
|
|
144
|
-
// Find the handler config to get model info
|
|
145
213
|
const handlerConfig = handlers.find(h => h.id === result.id);
|
|
146
214
|
if (handlerConfig && handlerConfig.type === 'llm') {
|
|
147
215
|
const llmConfig = handlerConfig;
|
|
@@ -157,6 +225,27 @@ function createServer(manifest, metrics) {
|
|
|
157
225
|
}
|
|
158
226
|
}
|
|
159
227
|
}
|
|
228
|
+
// Short-circuit: if PreToolUse had a deny, record it in the cache
|
|
229
|
+
if (event === 'PreToolUse' && input.tool_name && input.session_id) {
|
|
230
|
+
const hasDeny = results.some(r => {
|
|
231
|
+
if (!r.ok || !r.output || typeof r.output !== 'object')
|
|
232
|
+
return false;
|
|
233
|
+
const out = r.output;
|
|
234
|
+
// Check hookSpecificOutput.permissionDecision === 'deny'
|
|
235
|
+
if (out.hookSpecificOutput && typeof out.hookSpecificOutput === 'object') {
|
|
236
|
+
const hso = out.hookSpecificOutput;
|
|
237
|
+
if (hso.permissionDecision === 'deny')
|
|
238
|
+
return true;
|
|
239
|
+
}
|
|
240
|
+
// Check decision === 'block'
|
|
241
|
+
if (out.decision === 'block')
|
|
242
|
+
return true;
|
|
243
|
+
return false;
|
|
244
|
+
});
|
|
245
|
+
if (hasDeny) {
|
|
246
|
+
denyCache.recordDeny(input.session_id, input.tool_name);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
160
249
|
const merged = mergeResults(results);
|
|
161
250
|
log(` -> ${results.filter((r) => r.ok).length}/${results.length} ok, response keys: ${Object.keys(merged).join(', ') || '(empty)'}`);
|
|
162
251
|
sendJson(res, 200, merged);
|
|
@@ -170,12 +259,13 @@ function createServer(manifest, metrics) {
|
|
|
170
259
|
// 404 for everything else
|
|
171
260
|
sendJson(res, 404, { error: 'Not found' });
|
|
172
261
|
});
|
|
173
|
-
|
|
262
|
+
ctx.server = server;
|
|
263
|
+
return ctx;
|
|
174
264
|
}
|
|
175
265
|
/**
|
|
176
266
|
* Start the daemon: bind the server and write PID file.
|
|
177
267
|
*/
|
|
178
|
-
function startDaemon(manifest, metrics) {
|
|
268
|
+
function startDaemon(manifest, metrics, options) {
|
|
179
269
|
return new Promise((resolve, reject) => {
|
|
180
270
|
const ctx = createServer(manifest, metrics);
|
|
181
271
|
const port = manifest.settings?.port ?? constants_js_1.DEFAULT_PORT;
|
|
@@ -195,12 +285,75 @@ function startDaemon(manifest, metrics) {
|
|
|
195
285
|
(0, fs_1.mkdirSync)(constants_js_1.CONFIG_DIR, { recursive: true });
|
|
196
286
|
}
|
|
197
287
|
(0, fs_1.writeFileSync)(constants_js_1.PID_FILE, String(process.pid), 'utf-8');
|
|
288
|
+
// Start file watcher unless disabled
|
|
289
|
+
if (!options?.noWatch) {
|
|
290
|
+
ctx.watcher = (0, watcher_js_1.startWatcher)(constants_js_1.MANIFEST_PATH, () => {
|
|
291
|
+
try {
|
|
292
|
+
const newManifest = (0, manifest_js_1.loadCompositeManifest)();
|
|
293
|
+
// Diff handlers: find removed, added, and changed handlers
|
|
294
|
+
const oldIds = new Set();
|
|
295
|
+
const oldHandlerMap = new Map();
|
|
296
|
+
for (const handlers of Object.values(ctx.manifest.handlers)) {
|
|
297
|
+
if (!handlers)
|
|
298
|
+
continue;
|
|
299
|
+
for (const h of handlers) {
|
|
300
|
+
oldIds.add(h.id);
|
|
301
|
+
oldHandlerMap.set(h.id, h);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
const newIds = new Set();
|
|
305
|
+
const newHandlerMap = new Map();
|
|
306
|
+
for (const handlers of Object.values(newManifest.handlers)) {
|
|
307
|
+
if (!handlers)
|
|
308
|
+
continue;
|
|
309
|
+
for (const h of handlers) {
|
|
310
|
+
newIds.add(h.id);
|
|
311
|
+
newHandlerMap.set(h.id, h);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
// Removed handlers: clean up their state
|
|
315
|
+
for (const id of oldIds) {
|
|
316
|
+
if (!newIds.has(id)) {
|
|
317
|
+
(0, handlers_js_1.cleanupHandlerState)(id);
|
|
318
|
+
log(` Handler removed: ${id}`);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
// Added handlers: initialize fresh state (happens automatically on first use)
|
|
322
|
+
for (const id of newIds) {
|
|
323
|
+
if (!oldIds.has(id)) {
|
|
324
|
+
log(` Handler added: ${id}`);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
// Changed handlers with sessionIsolation: reset state
|
|
328
|
+
for (const id of newIds) {
|
|
329
|
+
if (oldIds.has(id)) {
|
|
330
|
+
const newH = newHandlerMap.get(id);
|
|
331
|
+
const oldH = oldHandlerMap.get(id);
|
|
332
|
+
if (newH.sessionIsolation && JSON.stringify(oldH) !== JSON.stringify(newH)) {
|
|
333
|
+
(0, handlers_js_1.cleanupHandlerState)(id);
|
|
334
|
+
log(` Handler changed (session-isolated, state reset): ${id}`);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
ctx.manifest = newManifest;
|
|
339
|
+
log('Manifest reloaded successfully');
|
|
340
|
+
}
|
|
341
|
+
catch (err) {
|
|
342
|
+
log(`Manifest reload failed (keeping previous config): ${err instanceof Error ? err.message : err}`);
|
|
343
|
+
}
|
|
344
|
+
}, (err) => {
|
|
345
|
+
log(`Watcher error: ${err.message}`);
|
|
346
|
+
}) ?? undefined;
|
|
347
|
+
}
|
|
198
348
|
log(`Daemon started on 127.0.0.1:${port} (pid ${process.pid})`);
|
|
199
349
|
resolve(ctx);
|
|
200
350
|
});
|
|
201
351
|
// Graceful shutdown
|
|
202
352
|
const shutdown = () => {
|
|
203
353
|
log('Shutting down...');
|
|
354
|
+
(0, watcher_js_1.stopWatcher)(ctx.watcher ?? null);
|
|
355
|
+
if (ctx.cleanupInterval)
|
|
356
|
+
clearInterval(ctx.cleanupInterval);
|
|
204
357
|
ctx.server.close(() => {
|
|
205
358
|
try {
|
|
206
359
|
if ((0, fs_1.existsSync)(constants_js_1.PID_FILE))
|
|
@@ -283,8 +436,12 @@ function isDaemonRunning() {
|
|
|
283
436
|
/**
|
|
284
437
|
* Start daemon as a detached background process.
|
|
285
438
|
*/
|
|
286
|
-
function startDaemonBackground() {
|
|
287
|
-
const
|
|
439
|
+
function startDaemonBackground(options) {
|
|
440
|
+
const args = [process.argv[1], 'start', '--foreground'];
|
|
441
|
+
if (options?.noWatch) {
|
|
442
|
+
args.push('--no-watch');
|
|
443
|
+
}
|
|
444
|
+
const child = (0, child_process_1.spawn)(process.execPath, args, {
|
|
288
445
|
detached: true,
|
|
289
446
|
stdio: 'ignore',
|
|
290
447
|
});
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-memory cache of denied PreToolUse calls.
|
|
3
|
+
* Keyed by session_id + tool_name.
|
|
4
|
+
* Entries expire after TTL_MS (30 seconds).
|
|
5
|
+
*/
|
|
6
|
+
export declare class DenyCache {
|
|
7
|
+
private cache;
|
|
8
|
+
private static TTL_MS;
|
|
9
|
+
private makeKey;
|
|
10
|
+
/** Record a denied PreToolUse. */
|
|
11
|
+
recordDeny(sessionId: string, toolName: string): void;
|
|
12
|
+
/** Check if a tool call was recently denied. */
|
|
13
|
+
isDenied(sessionId: string, toolName: string): boolean;
|
|
14
|
+
/** Clean up expired entries. */
|
|
15
|
+
cleanup(): void;
|
|
16
|
+
/** Clear all entries. */
|
|
17
|
+
clear(): void;
|
|
18
|
+
/** Get current cache size (for testing). */
|
|
19
|
+
get size(): number;
|
|
20
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// clooks short-circuit chains — deny cache for PreToolUse → PostToolUse
|
|
3
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
|
+
exports.DenyCache = void 0;
|
|
5
|
+
/**
|
|
6
|
+
* In-memory cache of denied PreToolUse calls.
|
|
7
|
+
* Keyed by session_id + tool_name.
|
|
8
|
+
* Entries expire after TTL_MS (30 seconds).
|
|
9
|
+
*/
|
|
10
|
+
class DenyCache {
|
|
11
|
+
cache = new Map(); // key → timestamp
|
|
12
|
+
static TTL_MS = 30_000;
|
|
13
|
+
makeKey(sessionId, toolName) {
|
|
14
|
+
return `${sessionId}:${toolName}`;
|
|
15
|
+
}
|
|
16
|
+
/** Record a denied PreToolUse. */
|
|
17
|
+
recordDeny(sessionId, toolName) {
|
|
18
|
+
this.cache.set(this.makeKey(sessionId, toolName), Date.now());
|
|
19
|
+
}
|
|
20
|
+
/** Check if a tool call was recently denied. */
|
|
21
|
+
isDenied(sessionId, toolName) {
|
|
22
|
+
const ts = this.cache.get(this.makeKey(sessionId, toolName));
|
|
23
|
+
if (ts === undefined)
|
|
24
|
+
return false;
|
|
25
|
+
if (Date.now() - ts > DenyCache.TTL_MS) {
|
|
26
|
+
this.cache.delete(this.makeKey(sessionId, toolName));
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
return true;
|
|
30
|
+
}
|
|
31
|
+
/** Clean up expired entries. */
|
|
32
|
+
cleanup() {
|
|
33
|
+
const now = Date.now();
|
|
34
|
+
for (const [key, ts] of this.cache) {
|
|
35
|
+
if (now - ts > DenyCache.TTL_MS) {
|
|
36
|
+
this.cache.delete(key);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
/** Clear all entries. */
|
|
41
|
+
clear() {
|
|
42
|
+
this.cache.clear();
|
|
43
|
+
}
|
|
44
|
+
/** Get current cache size (for testing). */
|
|
45
|
+
get size() {
|
|
46
|
+
return this.cache.size;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
exports.DenyCache = DenyCache;
|
package/dist/types.d.ts
CHANGED
|
@@ -30,6 +30,8 @@ export interface LLMHandlerConfig {
|
|
|
30
30
|
filter?: string;
|
|
31
31
|
timeout?: number;
|
|
32
32
|
enabled?: boolean;
|
|
33
|
+
sessionIsolation?: boolean;
|
|
34
|
+
depends?: string[];
|
|
33
35
|
}
|
|
34
36
|
/** Script handler config */
|
|
35
37
|
export interface ScriptHandlerConfig {
|
|
@@ -39,6 +41,8 @@ export interface ScriptHandlerConfig {
|
|
|
39
41
|
filter?: string;
|
|
40
42
|
timeout?: number;
|
|
41
43
|
enabled?: boolean;
|
|
44
|
+
sessionIsolation?: boolean;
|
|
45
|
+
depends?: string[];
|
|
42
46
|
}
|
|
43
47
|
/** Inline handler config */
|
|
44
48
|
export interface InlineHandlerConfig {
|
|
@@ -48,6 +52,8 @@ export interface InlineHandlerConfig {
|
|
|
48
52
|
filter?: string;
|
|
49
53
|
timeout?: number;
|
|
50
54
|
enabled?: boolean;
|
|
55
|
+
sessionIsolation?: boolean;
|
|
56
|
+
depends?: string[];
|
|
51
57
|
}
|
|
52
58
|
/** Union of all handler configs */
|
|
53
59
|
export type HandlerConfig = ScriptHandlerConfig | InlineHandlerConfig | LLMHandlerConfig;
|
|
@@ -67,6 +73,7 @@ export interface Manifest {
|
|
|
67
73
|
port?: number;
|
|
68
74
|
logLevel?: 'debug' | 'info' | 'warn' | 'error';
|
|
69
75
|
anthropicApiKey?: string;
|
|
76
|
+
authToken?: string;
|
|
70
77
|
};
|
|
71
78
|
}
|
|
72
79
|
/** Token usage from API response */
|
|
@@ -95,6 +102,7 @@ export interface MetricEntry {
|
|
|
95
102
|
filtered?: boolean;
|
|
96
103
|
usage?: TokenUsage;
|
|
97
104
|
cost_usd?: number;
|
|
105
|
+
session_id?: string;
|
|
98
106
|
}
|
|
99
107
|
/** Extended handler result with cost info */
|
|
100
108
|
export interface HandlerResult {
|
|
@@ -120,3 +128,31 @@ export interface DiagnosticResult {
|
|
|
120
128
|
status: 'ok' | 'warn' | 'error';
|
|
121
129
|
message: string;
|
|
122
130
|
}
|
|
131
|
+
/** Plugin extras metadata (freeform, extensible) */
|
|
132
|
+
export interface PluginExtras {
|
|
133
|
+
skills?: string[];
|
|
134
|
+
agents?: string[];
|
|
135
|
+
readme?: string;
|
|
136
|
+
[key: string]: unknown;
|
|
137
|
+
}
|
|
138
|
+
/** Plugin manifest (clooks-plugin.yaml) */
|
|
139
|
+
export interface PluginManifest {
|
|
140
|
+
name: string;
|
|
141
|
+
version: string;
|
|
142
|
+
description?: string;
|
|
143
|
+
author?: string;
|
|
144
|
+
handlers: Partial<Record<HookEvent, HandlerConfig[]>>;
|
|
145
|
+
prefetch?: PrefetchKey[];
|
|
146
|
+
extras?: PluginExtras;
|
|
147
|
+
}
|
|
148
|
+
/** Installed plugin registry entry */
|
|
149
|
+
export interface InstalledPlugin {
|
|
150
|
+
name: string;
|
|
151
|
+
version: string;
|
|
152
|
+
path: string;
|
|
153
|
+
installedAt: string;
|
|
154
|
+
}
|
|
155
|
+
/** Plugin registry file format (installed.json) */
|
|
156
|
+
export interface PluginRegistry {
|
|
157
|
+
plugins: InstalledPlugin[];
|
|
158
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { type FSWatcher } from 'fs';
|
|
2
|
+
type ReloadCallback = () => void;
|
|
3
|
+
type ErrorCallback = (err: Error) => void;
|
|
4
|
+
export interface WatcherOptions {
|
|
5
|
+
onReload: ReloadCallback;
|
|
6
|
+
onError?: ErrorCallback;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Watch manifest.yaml for changes.
|
|
10
|
+
* Calls onReload when changes detected (debounced).
|
|
11
|
+
* If the manifest file doesn't exist, watches the config directory for its creation.
|
|
12
|
+
*/
|
|
13
|
+
export declare function startWatcher(manifestPath: string, onReload: ReloadCallback, onError?: ErrorCallback): FSWatcher | null;
|
|
14
|
+
/**
|
|
15
|
+
* Stop watching for file changes.
|
|
16
|
+
*/
|
|
17
|
+
export declare function stopWatcher(watcher: FSWatcher | null): void;
|
|
18
|
+
export {};
|
package/dist/watcher.js
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// clooks file watcher — watch manifest.yaml for changes and hot-reload
|
|
3
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
|
+
exports.startWatcher = startWatcher;
|
|
5
|
+
exports.stopWatcher = stopWatcher;
|
|
6
|
+
const fs_1 = require("fs");
|
|
7
|
+
const path_1 = require("path");
|
|
8
|
+
const DEBOUNCE_MS = 500;
|
|
9
|
+
/**
|
|
10
|
+
* Watch manifest.yaml for changes.
|
|
11
|
+
* Calls onReload when changes detected (debounced).
|
|
12
|
+
* If the manifest file doesn't exist, watches the config directory for its creation.
|
|
13
|
+
*/
|
|
14
|
+
function startWatcher(manifestPath, onReload, onError) {
|
|
15
|
+
let lastChange = 0;
|
|
16
|
+
// If manifest exists, watch it directly
|
|
17
|
+
if ((0, fs_1.existsSync)(manifestPath)) {
|
|
18
|
+
return watchFile(manifestPath, onReload, onError);
|
|
19
|
+
}
|
|
20
|
+
// Manifest doesn't exist — watch the config directory for its creation
|
|
21
|
+
const configDir = (0, path_1.dirname)(manifestPath);
|
|
22
|
+
if (!(0, fs_1.existsSync)(configDir)) {
|
|
23
|
+
try {
|
|
24
|
+
(0, fs_1.mkdirSync)(configDir, { recursive: true });
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
// Can't create config dir — give up
|
|
28
|
+
if (onError)
|
|
29
|
+
onError(new Error(`Cannot create config directory: ${configDir}`));
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
const baseName = manifestPath.split('/').pop() ?? manifestPath.split('\\').pop() ?? '';
|
|
34
|
+
let dirWatcher = null;
|
|
35
|
+
try {
|
|
36
|
+
dirWatcher = (0, fs_1.watch)(configDir, (eventType, filename) => {
|
|
37
|
+
if (filename !== baseName)
|
|
38
|
+
return;
|
|
39
|
+
if (!(0, fs_1.existsSync)(manifestPath))
|
|
40
|
+
return;
|
|
41
|
+
const now = Date.now();
|
|
42
|
+
if (now - lastChange < DEBOUNCE_MS)
|
|
43
|
+
return;
|
|
44
|
+
lastChange = now;
|
|
45
|
+
// Manifest appeared — close directory watcher, start file watcher
|
|
46
|
+
try {
|
|
47
|
+
dirWatcher?.close();
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
// ignore
|
|
51
|
+
}
|
|
52
|
+
// Switch to watching the file directly
|
|
53
|
+
const fileWatcher = watchFile(manifestPath, onReload, onError);
|
|
54
|
+
if (fileWatcher) {
|
|
55
|
+
// Copy the ref so stopWatcher can close it (caller still holds the dir watcher ref)
|
|
56
|
+
// We can't replace the caller's reference, but the dir watcher is closed.
|
|
57
|
+
// The onReload fires so the caller picks up the new manifest.
|
|
58
|
+
}
|
|
59
|
+
try {
|
|
60
|
+
onReload();
|
|
61
|
+
}
|
|
62
|
+
catch (err) {
|
|
63
|
+
if (onError)
|
|
64
|
+
onError(err instanceof Error ? err : new Error(String(err)));
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
dirWatcher.on('error', (err) => {
|
|
68
|
+
if (onError)
|
|
69
|
+
onError(err);
|
|
70
|
+
});
|
|
71
|
+
return dirWatcher;
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
if (onError)
|
|
75
|
+
onError(new Error(`Failed to watch config directory: ${configDir}`));
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
/** Watch an existing file directly. */
|
|
80
|
+
function watchFile(filePath, onReload, onError) {
|
|
81
|
+
let lastChange = 0;
|
|
82
|
+
try {
|
|
83
|
+
const watcher = (0, fs_1.watch)(filePath, (eventType) => {
|
|
84
|
+
if (eventType !== 'change' && eventType !== 'rename')
|
|
85
|
+
return;
|
|
86
|
+
const now = Date.now();
|
|
87
|
+
if (now - lastChange < DEBOUNCE_MS)
|
|
88
|
+
return;
|
|
89
|
+
lastChange = now;
|
|
90
|
+
try {
|
|
91
|
+
onReload();
|
|
92
|
+
}
|
|
93
|
+
catch (err) {
|
|
94
|
+
if (onError)
|
|
95
|
+
onError(err instanceof Error ? err : new Error(String(err)));
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
watcher.on('error', (err) => {
|
|
99
|
+
if (onError)
|
|
100
|
+
onError(err);
|
|
101
|
+
});
|
|
102
|
+
return watcher;
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Stop watching for file changes.
|
|
110
|
+
*/
|
|
111
|
+
function stopWatcher(watcher) {
|
|
112
|
+
if (watcher) {
|
|
113
|
+
try {
|
|
114
|
+
watcher.close();
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
// Ignore close errors
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// clooks built-in: check for updates on session start
|
|
4
|
+
// Runs in background, non-blocking. Injects a notice if update available.
|
|
5
|
+
|
|
6
|
+
const { execSync } = require('child_process');
|
|
7
|
+
|
|
8
|
+
try {
|
|
9
|
+
// Get installed version
|
|
10
|
+
const pkgPath = require.resolve('@mauribadnights/clooks/package.json');
|
|
11
|
+
const pkg = JSON.parse(require('fs').readFileSync(pkgPath, 'utf-8'));
|
|
12
|
+
const current = pkg.version;
|
|
13
|
+
|
|
14
|
+
// Check npm (with short timeout to not block session start)
|
|
15
|
+
const latest = execSync('npm view @mauribadnights/clooks version 2>/dev/null', {
|
|
16
|
+
encoding: 'utf-8',
|
|
17
|
+
timeout: 5000,
|
|
18
|
+
}).trim();
|
|
19
|
+
|
|
20
|
+
if (latest && latest !== current && isNewer(latest, current)) {
|
|
21
|
+
// Output as additionalContext so it's injected into Claude's context
|
|
22
|
+
const msg = `[clooks] Update available: ${current} \u2192 ${latest}. Run: clooks update`;
|
|
23
|
+
process.stdout.write(JSON.stringify({ additionalContext: msg }));
|
|
24
|
+
}
|
|
25
|
+
} catch {
|
|
26
|
+
// Silently fail — update checks should never block sessions
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function isNewer(a, b) {
|
|
30
|
+
const pa = a.split('.').map(Number);
|
|
31
|
+
const pb = b.split('.').map(Number);
|
|
32
|
+
for (let i = 0; i < 3; i++) {
|
|
33
|
+
if ((pa[i] || 0) > (pb[i] || 0)) return true;
|
|
34
|
+
if ((pa[i] || 0) < (pb[i] || 0)) return false;
|
|
35
|
+
}
|
|
36
|
+
return false;
|
|
37
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mauribadnights/clooks",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Persistent hook runtime for Claude Code — eliminates process spawning overhead and gives you observability",
|
|
5
5
|
"bin": {
|
|
6
6
|
"clooks": "./dist/cli.js"
|
|
@@ -32,6 +32,7 @@
|
|
|
32
32
|
},
|
|
33
33
|
"files": [
|
|
34
34
|
"dist",
|
|
35
|
+
"hooks",
|
|
35
36
|
"README.md",
|
|
36
37
|
"LICENSE"
|
|
37
38
|
],
|