@mauribadnights/clooks 0.2.2 → 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 CHANGED
@@ -65,6 +65,11 @@ After `clooks migrate`, your `settings.json` is rewritten so that `SessionStart`
65
65
  | `clooks doctor` | Run diagnostic health checks |
66
66
  | `clooks init` | Create default config directory and example manifest |
67
67
  | `clooks ensure-running` | Start daemon if not already running (used by SessionStart hook) |
68
+ | `clooks add <path>` | Install a plugin from a local directory |
69
+ | `clooks remove <name>` | Uninstall a plugin and its contributed handlers |
70
+ | `clooks plugins` | List installed plugins and their handlers |
71
+ | `clooks rotate-token` | Generate a new auth token, update manifest + settings.json, hot-reload daemon |
72
+ | `clooks costs` | Show LLM token usage and cost breakdown |
68
73
 
69
74
  ## Manifest Format
70
75
 
@@ -281,9 +286,75 @@ LLM Cost Summary
281
286
  - Batching savings are estimated based on shared input tokens
282
287
  - Cost data also appears in `clooks stats` when LLM handlers have been used
283
288
 
289
+ ## v0.3 Features
290
+
291
+ ### Plugin System
292
+
293
+ Plugins let you package and share sets of handlers. A plugin is any directory with a `clooks-plugin.yaml` spec:
294
+
295
+ ```yaml
296
+ # clooks-plugin.yaml
297
+ name: my-security-suite
298
+ version: 1.0.0
299
+ description: Security guards for tool calls
300
+ handlers:
301
+ PreToolUse:
302
+ - id: bash-guard
303
+ type: inline
304
+ module: ./handlers/bash-guard.js
305
+ timeout: 3000
306
+ - id: file-guard
307
+ type: inline
308
+ module: ./handlers/file-guard.js
309
+ timeout: 2000
310
+ ```
311
+
312
+ Install, remove, and list plugins:
313
+
314
+ ```bash
315
+ clooks add ./my-security-suite # install from local path
316
+ clooks remove my-security-suite # uninstall
317
+ clooks plugins # list installed plugins + handlers
318
+ ```
319
+
320
+ Handler IDs are namespaced to the plugin (`my-security-suite:bash-guard`) to avoid collisions with user-defined handlers or other plugins.
321
+
322
+ ### Dependency Resolution
323
+
324
+ Handlers can declare dependencies on other handlers using the `depends` field. clooks resolves dependencies into topological execution waves -- handlers in the same wave run in parallel, waves execute sequentially.
325
+
326
+ ```yaml
327
+ handlers:
328
+ PreToolUse:
329
+ - id: context-loader
330
+ type: inline
331
+ module: ~/hooks/context.js
332
+
333
+ - id: security-check
334
+ type: llm
335
+ model: claude-haiku-4-5
336
+ prompt: "Check $TOOL_NAME for issues given context: $CONTEXT"
337
+ depends: [context-loader] # waits for context-loader to finish first
338
+ ```
339
+
340
+ In this example, `context-loader` runs in wave 1, and `security-check` runs in wave 2 after it completes. Handlers with no dependencies (or whose dependencies are already satisfied) run in parallel within the same wave.
341
+
342
+ ### Short-Circuit Chains
343
+
344
+ When a `PreToolUse` handler returns a deny decision, clooks automatically skips the corresponding `PostToolUse` handlers for that tool call. This avoids wasted work (and wasted LLM calls) on tool invocations that were blocked.
345
+
346
+ Deny results are cached with a 30-second TTL, so repeated calls to the same tool with the same arguments short-circuit without re-evaluating handlers.
347
+
348
+ ### Other v0.3 Improvements
349
+
350
+ - **Auth token rotation:** `clooks rotate-token` generates a new token, updates manifest and settings.json, and hot-reloads the daemon -- no restart required.
351
+ - **Health endpoint split:** `/health` is now public (returns `{ status: "ok" }` only). `/health/detail` requires auth and returns uptime, handler count, and plugin list.
352
+ - **Rate limiting on auth failures:** In-memory rate limiter rejects with 429 after repeated failed auth attempts within a time window. Resets on successful auth.
353
+ - **Session-scoped LLM batch groups:** Batch groups are now scoped to `{batchGroup}:{session_id}`, preventing cross-session batching violations.
354
+ - **Manifest reload resets handler state:** Reloading the manifest now diffs old vs new handlers and resets session-isolated state for changed or new handlers.
355
+
284
356
  ## Roadmap
285
357
 
286
- - **v0.3:** Plugin ecosystem, dependency resolution between handlers
287
358
  - **v0.4:** Visual dashboard for hook management and metrics
288
359
 
289
360
  ## Contributing
package/dist/auth.d.ts CHANGED
@@ -2,3 +2,16 @@
2
2
  export declare function generateAuthToken(): string;
3
3
  /** Validate an auth token from request headers. */
4
4
  export declare function validateAuth(authHeader: string | undefined, expectedToken: string): boolean;
5
+ /** Options for overriding default paths (used by tests). */
6
+ export interface RotateTokenOptions {
7
+ manifestPath?: string;
8
+ settingsDir?: string;
9
+ }
10
+ /**
11
+ * Rotate the auth token:
12
+ * 1. Generate new token
13
+ * 2. Update manifest.yaml settings.authToken
14
+ * 3. Update settings.json Authorization headers in HTTP hooks
15
+ * Returns the new token.
16
+ */
17
+ export declare function rotateToken(options?: RotateTokenOptions): string;
package/dist/auth.js CHANGED
@@ -3,7 +3,13 @@
3
3
  Object.defineProperty(exports, "__esModule", { value: true });
4
4
  exports.generateAuthToken = generateAuthToken;
5
5
  exports.validateAuth = validateAuth;
6
+ exports.rotateToken = rotateToken;
6
7
  const crypto_1 = require("crypto");
8
+ const fs_1 = require("fs");
9
+ const yaml_1 = require("yaml");
10
+ const path_1 = require("path");
11
+ const os_1 = require("os");
12
+ const constants_js_1 = require("./constants.js");
7
13
  /** Generate a random auth token (32 hex chars). */
8
14
  function generateAuthToken() {
9
15
  return (0, crypto_1.randomBytes)(16).toString('hex');
@@ -25,3 +31,79 @@ function validateAuth(authHeader, expectedToken) {
25
31
  const bufB = Buffer.from(expectedToken);
26
32
  return (0, crypto_1.timingSafeEqual)(bufA, bufB);
27
33
  }
34
+ /**
35
+ * Rotate the auth token:
36
+ * 1. Generate new token
37
+ * 2. Update manifest.yaml settings.authToken
38
+ * 3. Update settings.json Authorization headers in HTTP hooks
39
+ * Returns the new token.
40
+ */
41
+ function rotateToken(options) {
42
+ const manifestPath = options?.manifestPath ?? constants_js_1.MANIFEST_PATH;
43
+ const home = options?.settingsDir ?? (0, path_1.join)((0, os_1.homedir)(), '.claude');
44
+ if (!(0, fs_1.existsSync)(manifestPath)) {
45
+ throw new Error(`Manifest not found at ${manifestPath}`);
46
+ }
47
+ const newToken = generateAuthToken();
48
+ // Update manifest
49
+ const manifestRaw = (0, fs_1.readFileSync)(manifestPath, 'utf-8');
50
+ const manifest = (0, yaml_1.parse)(manifestRaw);
51
+ if (!manifest.settings) {
52
+ manifest.settings = {};
53
+ }
54
+ manifest.settings.authToken = newToken;
55
+ // Preserve comments at the top by only replacing the YAML body portion
56
+ const yamlBody = (0, yaml_1.stringify)(manifest);
57
+ // Check if there's a comment header to preserve
58
+ const lines = manifestRaw.split('\n');
59
+ const commentLines = [];
60
+ for (const line of lines) {
61
+ if (line.startsWith('#') || line.trim() === '') {
62
+ commentLines.push(line);
63
+ }
64
+ else {
65
+ break;
66
+ }
67
+ }
68
+ const header = commentLines.length > 0 ? commentLines.join('\n') + '\n' : '';
69
+ (0, fs_1.writeFileSync)(manifestPath, header + yamlBody, 'utf-8');
70
+ // Update settings.json Authorization headers
71
+ const settingsCandidates = [
72
+ (0, path_1.join)(home, 'settings.local.json'),
73
+ (0, path_1.join)(home, 'settings.json'),
74
+ ];
75
+ for (const settingsPath of settingsCandidates) {
76
+ if (!(0, fs_1.existsSync)(settingsPath))
77
+ continue;
78
+ try {
79
+ const raw = (0, fs_1.readFileSync)(settingsPath, 'utf-8');
80
+ const settings = JSON.parse(raw);
81
+ if (!settings.hooks || typeof settings.hooks !== 'object')
82
+ continue;
83
+ let updated = false;
84
+ for (const ruleGroups of Object.values(settings.hooks)) {
85
+ if (!Array.isArray(ruleGroups))
86
+ continue;
87
+ for (const rule of ruleGroups) {
88
+ if (!Array.isArray(rule.hooks))
89
+ continue;
90
+ for (const hook of rule.hooks) {
91
+ if (hook.type === 'http' && hook.url?.includes(`localhost:`)) {
92
+ if (!hook.headers)
93
+ hook.headers = {};
94
+ hook.headers['Authorization'] = `Bearer ${newToken}`;
95
+ updated = true;
96
+ }
97
+ }
98
+ }
99
+ }
100
+ if (updated) {
101
+ (0, fs_1.writeFileSync)(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
102
+ }
103
+ }
104
+ catch {
105
+ // Skip files that can't be parsed
106
+ }
107
+ }
108
+ return newToken;
109
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Content of the check-update.js hook script.
3
+ * Kept as a constant so it can be written to disk during init/migrate
4
+ * without depending on the npm package install path.
5
+ */
6
+ export declare const CHECK_UPDATE_SCRIPT = "#!/usr/bin/env node\n\n// clooks built-in: check for updates on session start\n// Runs in background, non-blocking. Injects a notice if update available.\n\nconst { execSync } = require('child_process');\n\ntry {\n // Get installed version\n const pkgPath = require.resolve('@mauribadnights/clooks/package.json');\n const pkg = JSON.parse(require('fs').readFileSync(pkgPath, 'utf-8'));\n const current = pkg.version;\n\n // Check npm (with short timeout to not block session start)\n const latest = execSync('npm view @mauribadnights/clooks version 2>/dev/null', {\n encoding: 'utf-8',\n timeout: 5000,\n }).trim();\n\n if (latest && latest !== current && isNewer(latest, current)) {\n const msg = `[clooks] Update available: ${current} \\u2192 ${latest}. Run: clooks update`;\n process.stdout.write(JSON.stringify({ additionalContext: msg }));\n }\n} catch {\n // Silently fail \u2014 update checks should never block sessions\n}\n\nfunction isNewer(a, b) {\n const pa = a.split('.').map(Number);\n const pb = b.split('.').map(Number);\n for (let i = 0; i < 3; i++) {\n if ((pa[i] || 0) > (pb[i] || 0)) return true;\n if ((pa[i] || 0) < (pb[i] || 0)) return false;\n }\n return false;\n}\n";
7
+ /**
8
+ * Ensure the built-in hooks directory exists and write/update the check-update script.
9
+ * Safe to call multiple times — overwrites with the latest version.
10
+ */
11
+ export declare function installBuiltinHooks(): void;
@@ -0,0 +1,67 @@
1
+ "use strict";
2
+ // clooks built-in hook scripts — written to CONFIG_DIR/hooks/ during init/migrate
3
+ Object.defineProperty(exports, "__esModule", { value: true });
4
+ exports.CHECK_UPDATE_SCRIPT = void 0;
5
+ exports.installBuiltinHooks = installBuiltinHooks;
6
+ const fs_1 = require("fs");
7
+ const path_1 = require("path");
8
+ const constants_js_1 = require("./constants.js");
9
+ /**
10
+ * Content of the check-update.js hook script.
11
+ * Kept as a constant so it can be written to disk during init/migrate
12
+ * without depending on the npm package install path.
13
+ */
14
+ exports.CHECK_UPDATE_SCRIPT = `#!/usr/bin/env node
15
+
16
+ // clooks built-in: check for updates on session start
17
+ // Runs in background, non-blocking. Injects a notice if update available.
18
+
19
+ const { execSync } = require('child_process');
20
+
21
+ try {
22
+ // Get installed version
23
+ const pkgPath = require.resolve('@mauribadnights/clooks/package.json');
24
+ const pkg = JSON.parse(require('fs').readFileSync(pkgPath, 'utf-8'));
25
+ const current = pkg.version;
26
+
27
+ // Check npm (with short timeout to not block session start)
28
+ const latest = execSync('npm view @mauribadnights/clooks version 2>/dev/null', {
29
+ encoding: 'utf-8',
30
+ timeout: 5000,
31
+ }).trim();
32
+
33
+ if (latest && latest !== current && isNewer(latest, current)) {
34
+ const msg = \`[clooks] Update available: \${current} \\u2192 \${latest}. Run: clooks update\`;
35
+ process.stdout.write(JSON.stringify({ additionalContext: msg }));
36
+ }
37
+ } catch {
38
+ // Silently fail — update checks should never block sessions
39
+ }
40
+
41
+ function isNewer(a, b) {
42
+ const pa = a.split('.').map(Number);
43
+ const pb = b.split('.').map(Number);
44
+ for (let i = 0; i < 3; i++) {
45
+ if ((pa[i] || 0) > (pb[i] || 0)) return true;
46
+ if ((pa[i] || 0) < (pb[i] || 0)) return false;
47
+ }
48
+ return false;
49
+ }
50
+ `;
51
+ /**
52
+ * Ensure the built-in hooks directory exists and write/update the check-update script.
53
+ * Safe to call multiple times — overwrites with the latest version.
54
+ */
55
+ function installBuiltinHooks() {
56
+ if (!(0, fs_1.existsSync)(constants_js_1.HOOKS_DIR)) {
57
+ (0, fs_1.mkdirSync)(constants_js_1.HOOKS_DIR, { recursive: true });
58
+ }
59
+ const checkUpdatePath = (0, path_1.join)(constants_js_1.HOOKS_DIR, 'check-update.js');
60
+ // Only overwrite if content differs (avoids unnecessary writes)
61
+ if ((0, fs_1.existsSync)(checkUpdatePath)) {
62
+ const existing = (0, fs_1.readFileSync)(checkUpdatePath, 'utf-8');
63
+ if (existing === exports.CHECK_UPDATE_SCRIPT)
64
+ return;
65
+ }
66
+ (0, fs_1.writeFileSync)(checkUpdatePath, exports.CHECK_UPDATE_SCRIPT, { mode: 0o755 });
67
+ }
package/dist/cli.js CHANGED
@@ -9,13 +9,15 @@ const server_js_1 = require("./server.js");
9
9
  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
+ const plugin_js_1 = require("./plugin.js");
12
13
  const constants_js_1 = require("./constants.js");
13
14
  const fs_1 = require("fs");
15
+ const path_1 = require("path");
14
16
  const program = new commander_1.Command();
15
17
  program
16
18
  .name('clooks')
17
19
  .description('Persistent hook runtime for Claude Code')
18
- .version('0.2.2');
20
+ .version('0.3.0');
19
21
  // --- start ---
20
22
  program
21
23
  .command('start')
@@ -49,7 +51,7 @@ program
49
51
  }
50
52
  // Foreground mode: run the actual server
51
53
  try {
52
- const manifest = (0, manifest_js_1.loadManifest)();
54
+ const manifest = (0, manifest_js_1.loadCompositeManifest)();
53
55
  const metrics = new metrics_js_1.MetricsCollector();
54
56
  const port = manifest.settings?.port ?? constants_js_1.DEFAULT_PORT;
55
57
  const handlerCount = Object.values(manifest.handlers)
@@ -98,11 +100,13 @@ program
98
100
  req.setTimeout(3000, () => { req.destroy(); reject(new Error('timeout')); });
99
101
  });
100
102
  const health = JSON.parse(data);
103
+ const pluginCount = (0, plugin_js_1.listPlugins)().length;
101
104
  console.log(`Status: running`);
102
105
  console.log(`PID: ${pid}`);
103
106
  console.log(`Port: ${health.port}`);
104
107
  console.log(`Uptime: ${formatUptime(health.uptime)}`);
105
108
  console.log(`Handlers loaded: ${health.handlers_loaded}`);
109
+ console.log(`Plugins: ${pluginCount}`);
106
110
  }
107
111
  catch {
108
112
  console.log(`Status: running (pid ${pid})`);
@@ -215,6 +219,128 @@ program
215
219
  console.log(`Auth token: ${token}`);
216
220
  console.log('Edit this file to configure your hook handlers.');
217
221
  });
222
+ // --- rotate-token ---
223
+ program
224
+ .command('rotate-token')
225
+ .description('Generate new auth token, update manifest and settings.json')
226
+ .action(() => {
227
+ try {
228
+ const newToken = (0, auth_js_1.rotateToken)();
229
+ console.log(`Auth token rotated successfully.`);
230
+ console.log(`New token: ${newToken}`);
231
+ console.log('If daemon is running, the file watcher will pick up the manifest change.');
232
+ }
233
+ catch (err) {
234
+ console.error('Token rotation failed:', err instanceof Error ? err.message : err);
235
+ process.exit(1);
236
+ }
237
+ });
238
+ // --- update ---
239
+ program
240
+ .command('update')
241
+ .description('Update clooks to the latest version')
242
+ .action(async () => {
243
+ console.log('Checking for updates...');
244
+ const currentVersion = program.version();
245
+ try {
246
+ const { execSync } = await import('child_process');
247
+ const latest = execSync('npm view @mauribadnights/clooks version', { encoding: 'utf-8' }).trim();
248
+ if (latest === currentVersion) {
249
+ console.log(`Already on latest version (${currentVersion}).`);
250
+ return;
251
+ }
252
+ console.log(`Updating: ${currentVersion} \u2192 ${latest}`);
253
+ execSync('npm install -g @mauribadnights/clooks@latest', { stdio: 'inherit' });
254
+ console.log(`Updated to ${latest}.`);
255
+ // Restart daemon if running
256
+ if ((0, server_js_1.isDaemonRunning)()) {
257
+ console.log('Restarting daemon...');
258
+ (0, server_js_1.stopDaemon)();
259
+ await new Promise(r => setTimeout(r, 1000));
260
+ (0, server_js_1.startDaemonBackground)();
261
+ await new Promise(r => setTimeout(r, 500));
262
+ console.log('Daemon restarted.');
263
+ }
264
+ }
265
+ catch (err) {
266
+ console.error('Update failed:', err instanceof Error ? err.message : err);
267
+ process.exit(1);
268
+ }
269
+ });
270
+ // --- add (install plugin) ---
271
+ program
272
+ .command('add <path>')
273
+ .description('Install a plugin from a local directory')
274
+ .action((pluginPath) => {
275
+ try {
276
+ const resolvedPath = (0, path_1.resolve)(pluginPath);
277
+ if (!(0, fs_1.existsSync)(resolvedPath)) {
278
+ console.error(`Path does not exist: ${resolvedPath}`);
279
+ process.exit(1);
280
+ }
281
+ const manifestFile = (0, path_1.resolve)(resolvedPath, constants_js_1.PLUGIN_MANIFEST_NAME);
282
+ if (!(0, fs_1.existsSync)(manifestFile)) {
283
+ console.error(`No ${constants_js_1.PLUGIN_MANIFEST_NAME} found at ${resolvedPath}`);
284
+ process.exit(1);
285
+ }
286
+ const plugin = (0, plugin_js_1.installPlugin)(resolvedPath);
287
+ // Count handlers in the installed plugin
288
+ const plugins = (0, plugin_js_1.loadPlugins)();
289
+ const installed = plugins.find(p => p.name === plugin.name);
290
+ const handlerCount = installed
291
+ ? Object.values(installed.manifest.handlers).reduce((sum, arr) => sum + (arr?.length ?? 0), 0)
292
+ : 0;
293
+ console.log(`Installed plugin ${plugin.name} v${plugin.version} (${handlerCount} handlers)`);
294
+ }
295
+ catch (err) {
296
+ console.error('Plugin install failed:', err instanceof Error ? err.message : err);
297
+ process.exit(1);
298
+ }
299
+ });
300
+ // --- remove (uninstall plugin) ---
301
+ program
302
+ .command('remove <name>')
303
+ .description('Uninstall a plugin')
304
+ .action((name) => {
305
+ try {
306
+ (0, plugin_js_1.uninstallPlugin)(name);
307
+ console.log(`Removed plugin ${name}`);
308
+ }
309
+ catch (err) {
310
+ console.error('Plugin removal failed:', err instanceof Error ? err.message : err);
311
+ process.exit(1);
312
+ }
313
+ });
314
+ // --- plugins (list installed plugins) ---
315
+ program
316
+ .command('plugins')
317
+ .description('List installed plugins')
318
+ .action(() => {
319
+ const plugins = (0, plugin_js_1.listPlugins)();
320
+ if (plugins.length === 0) {
321
+ console.log('No plugins installed.');
322
+ return;
323
+ }
324
+ // Load full manifests to access extras and handler counts
325
+ const loaded = (0, plugin_js_1.loadPlugins)();
326
+ const manifestMap = new Map(loaded.map(l => [l.name, l.manifest]));
327
+ console.log('Installed Plugins:');
328
+ for (const p of plugins) {
329
+ const manifest = manifestMap.get(p.name);
330
+ const handlerCount = manifest
331
+ ? Object.values(manifest.handlers).reduce((sum, arr) => sum + (arr?.length ?? 0), 0)
332
+ : 0;
333
+ console.log(` ${p.name} v${p.version} (${handlerCount} handler${handlerCount !== 1 ? 's' : ''})`);
334
+ if (manifest?.extras) {
335
+ if (manifest.extras.skills && manifest.extras.skills.length > 0) {
336
+ console.log(` Skills: ${manifest.extras.skills.join(', ')}`);
337
+ }
338
+ if (manifest.extras.agents && manifest.extras.agents.length > 0) {
339
+ console.log(` Agents: ${manifest.extras.agents.join(', ')}`);
340
+ }
341
+ }
342
+ }
343
+ });
218
344
  program.parse();
219
345
  function formatUptime(seconds) {
220
346
  if (seconds < 60)
@@ -15,4 +15,8 @@ export declare const LLM_PRICING: Record<string, {
15
15
  input: number;
16
16
  output: number;
17
17
  }>;
18
+ export declare const HOOKS_DIR: string;
19
+ export declare const PLUGINS_DIR: string;
20
+ export declare const PLUGIN_REGISTRY: string;
21
+ export declare const PLUGIN_MANIFEST_NAME = "clooks-plugin.yaml";
18
22
  export declare const HOOK_EVENTS: string[];
package/dist/constants.js CHANGED
@@ -1,7 +1,7 @@
1
1
  "use strict";
2
2
  // clooks constants
3
3
  Object.defineProperty(exports, "__esModule", { value: true });
4
- exports.HOOK_EVENTS = exports.LLM_PRICING = exports.DEFAULT_LLM_MAX_TOKENS = exports.DEFAULT_LLM_TIMEOUT = exports.COSTS_FILE = exports.DEFAULT_HANDLER_TIMEOUT = exports.MAX_CONSECUTIVE_FAILURES = exports.SETTINGS_BACKUP = exports.LOG_FILE = exports.METRICS_FILE = exports.PID_FILE = exports.MANIFEST_PATH = exports.CONFIG_DIR = exports.DEFAULT_PORT = void 0;
4
+ exports.HOOK_EVENTS = exports.PLUGIN_MANIFEST_NAME = exports.PLUGIN_REGISTRY = exports.PLUGINS_DIR = exports.HOOKS_DIR = exports.LLM_PRICING = exports.DEFAULT_LLM_MAX_TOKENS = exports.DEFAULT_LLM_TIMEOUT = exports.COSTS_FILE = exports.DEFAULT_HANDLER_TIMEOUT = exports.MAX_CONSECUTIVE_FAILURES = exports.SETTINGS_BACKUP = exports.LOG_FILE = exports.METRICS_FILE = exports.PID_FILE = exports.MANIFEST_PATH = exports.CONFIG_DIR = exports.DEFAULT_PORT = void 0;
5
5
  const os_1 = require("os");
6
6
  const path_1 = require("path");
7
7
  exports.DEFAULT_PORT = 7890;
@@ -22,6 +22,10 @@ exports.LLM_PRICING = {
22
22
  'claude-sonnet-4-6': { input: 3.00, output: 15.00 },
23
23
  'claude-opus-4-6': { input: 15.00, output: 75.00 },
24
24
  };
25
+ exports.HOOKS_DIR = (0, path_1.join)(exports.CONFIG_DIR, 'hooks');
26
+ exports.PLUGINS_DIR = (0, path_1.join)(exports.CONFIG_DIR, 'plugins');
27
+ exports.PLUGIN_REGISTRY = (0, path_1.join)(exports.PLUGINS_DIR, 'installed.json');
28
+ exports.PLUGIN_MANIFEST_NAME = 'clooks-plugin.yaml';
25
29
  exports.HOOK_EVENTS = [
26
30
  'SessionStart',
27
31
  'UserPromptSubmit',
package/dist/deps.d.ts ADDED
@@ -0,0 +1,13 @@
1
+ import type { HandlerConfig } from './types.js';
2
+ /**
3
+ * Build a directed acyclic graph from handler dependencies.
4
+ * Returns handlers grouped into "waves" — each wave contains handlers
5
+ * that can execute in parallel (all their deps are in previous waves).
6
+ *
7
+ * Wave 0: handlers with no deps
8
+ * Wave 1: handlers whose deps are all in wave 0
9
+ * etc.
10
+ *
11
+ * Uses Kahn's algorithm. Throws on cycles.
12
+ */
13
+ export declare function resolveExecutionOrder(handlers: HandlerConfig[]): HandlerConfig[][];
package/dist/deps.js ADDED
@@ -0,0 +1,83 @@
1
+ "use strict";
2
+ // clooks dependency resolution — topological ordering of handler execution
3
+ Object.defineProperty(exports, "__esModule", { value: true });
4
+ exports.resolveExecutionOrder = resolveExecutionOrder;
5
+ /**
6
+ * Build a directed acyclic graph from handler dependencies.
7
+ * Returns handlers grouped into "waves" — each wave contains handlers
8
+ * that can execute in parallel (all their deps are in previous waves).
9
+ *
10
+ * Wave 0: handlers with no deps
11
+ * Wave 1: handlers whose deps are all in wave 0
12
+ * etc.
13
+ *
14
+ * Uses Kahn's algorithm. Throws on cycles.
15
+ */
16
+ function resolveExecutionOrder(handlers) {
17
+ if (handlers.length === 0)
18
+ return [];
19
+ // Build lookup and adjacency
20
+ const handlerMap = new Map();
21
+ const inDegree = new Map();
22
+ const dependents = new Map(); // depId → [handlers that depend on it]
23
+ for (const h of handlers) {
24
+ handlerMap.set(h.id, h);
25
+ inDegree.set(h.id, 0);
26
+ if (!dependents.has(h.id)) {
27
+ dependents.set(h.id, []);
28
+ }
29
+ }
30
+ // Only consider deps that reference handlers in this set
31
+ const handlerIds = new Set(handlers.map(h => h.id));
32
+ for (const h of handlers) {
33
+ if (!h.depends || h.depends.length === 0)
34
+ continue;
35
+ for (const dep of h.depends) {
36
+ if (!handlerIds.has(dep)) {
37
+ // Dependency references a handler not in this event's set — skip silently
38
+ // (cross-event deps are not supported within a single executeHandlers call)
39
+ continue;
40
+ }
41
+ inDegree.set(h.id, (inDegree.get(h.id) ?? 0) + 1);
42
+ const existing = dependents.get(dep) ?? [];
43
+ existing.push(h.id);
44
+ dependents.set(dep, existing);
45
+ }
46
+ }
47
+ // BFS — Kahn's algorithm, collecting waves
48
+ const waves = [];
49
+ let queue = [];
50
+ // Wave 0: all handlers with in-degree 0
51
+ for (const [id, degree] of inDegree) {
52
+ if (degree === 0) {
53
+ queue.push(id);
54
+ }
55
+ }
56
+ let processedCount = 0;
57
+ while (queue.length > 0) {
58
+ const wave = [];
59
+ const nextQueue = [];
60
+ for (const id of queue) {
61
+ wave.push(handlerMap.get(id));
62
+ processedCount++;
63
+ // Decrement in-degree of dependents
64
+ for (const depId of dependents.get(id) ?? []) {
65
+ const newDegree = (inDegree.get(depId) ?? 1) - 1;
66
+ inDegree.set(depId, newDegree);
67
+ if (newDegree === 0) {
68
+ nextQueue.push(depId);
69
+ }
70
+ }
71
+ }
72
+ waves.push(wave);
73
+ queue = nextQueue;
74
+ }
75
+ // Cycle detection: if not all handlers were processed, there's a cycle
76
+ if (processedCount < handlers.length) {
77
+ const cycleIds = handlers
78
+ .filter(h => (inDegree.get(h.id) ?? 0) > 0)
79
+ .map(h => h.id);
80
+ throw new Error(`Dependency cycle detected among handlers: ${cycleIds.join(', ')}`);
81
+ }
82
+ return waves;
83
+ }
package/dist/doctor.js CHANGED
@@ -10,6 +10,8 @@ const os_1 = require("os");
10
10
  const constants_js_1 = require("./constants.js");
11
11
  const manifest_js_1 = require("./manifest.js");
12
12
  const server_js_1 = require("./server.js");
13
+ const plugin_js_1 = require("./plugin.js");
14
+ const yaml_1 = require("yaml");
13
15
  /**
14
16
  * Run all diagnostic checks and return results.
15
17
  */
@@ -31,6 +33,8 @@ async function runDoctor() {
31
33
  results.push(checkStalePid());
32
34
  // 8. Auth token consistency (if configured)
33
35
  results.push(checkAuthToken());
36
+ // 9. Plugin health checks
37
+ results.push(...checkPluginHealth());
34
38
  return results;
35
39
  }
36
40
  function checkConfigDir() {
@@ -220,3 +224,55 @@ function checkAuthToken() {
220
224
  return { check: 'Auth token', status: 'ok', message: 'Could not load manifest for auth check' };
221
225
  }
222
226
  }
227
+ function checkPluginHealth() {
228
+ const results = [];
229
+ try {
230
+ const registry = (0, plugin_js_1.loadRegistry)();
231
+ if (registry.plugins.length === 0) {
232
+ results.push({ check: 'Plugins', status: 'ok', message: 'No plugins installed' });
233
+ return results;
234
+ }
235
+ for (const plugin of registry.plugins) {
236
+ // Check directory exists
237
+ if (!(0, fs_1.existsSync)(plugin.path)) {
238
+ results.push({
239
+ check: `Plugin "${plugin.name}"`,
240
+ status: 'error',
241
+ message: `Plugin directory missing: ${plugin.path}`,
242
+ });
243
+ continue;
244
+ }
245
+ // Check manifest exists and is valid
246
+ const manifestPath = (0, path_1.join)(plugin.path, constants_js_1.PLUGIN_MANIFEST_NAME);
247
+ if (!(0, fs_1.existsSync)(manifestPath)) {
248
+ results.push({
249
+ check: `Plugin "${plugin.name}"`,
250
+ status: 'error',
251
+ message: `Plugin manifest missing: ${manifestPath}`,
252
+ });
253
+ continue;
254
+ }
255
+ try {
256
+ const raw = (0, fs_1.readFileSync)(manifestPath, 'utf-8');
257
+ const parsed = (0, yaml_1.parse)(raw);
258
+ (0, plugin_js_1.validatePluginManifest)(parsed);
259
+ results.push({
260
+ check: `Plugin "${plugin.name}"`,
261
+ status: 'ok',
262
+ message: `v${plugin.version} — manifest valid`,
263
+ });
264
+ }
265
+ catch (err) {
266
+ results.push({
267
+ check: `Plugin "${plugin.name}"`,
268
+ status: 'error',
269
+ message: `Invalid manifest: ${err instanceof Error ? err.message : String(err)}`,
270
+ });
271
+ }
272
+ }
273
+ }
274
+ catch {
275
+ results.push({ check: 'Plugins', status: 'ok', message: 'Could not load plugin registry' });
276
+ }
277
+ return results;
278
+ }
@@ -3,15 +3,18 @@ import type { HandlerConfig, HandlerResult, HandlerState, HookEvent, HookInput,
3
3
  export declare function resetHandlerStates(): void;
4
4
  /** Get a copy of the handler states map */
5
5
  export declare function getHandlerStates(): Map<string, HandlerState>;
6
+ /** Clean up state for a specific handler ID (used during manifest reload diffs). */
7
+ export declare function cleanupHandlerState(handlerId: string): void;
6
8
  /**
7
9
  * Reset handler states for handlers that have sessionIsolation: true.
8
10
  * Called on SessionStart events.
9
11
  */
10
12
  export declare function resetSessionIsolatedHandlers(handlers: HandlerConfig[]): void;
11
13
  /**
12
- * Execute all handlers for an event in parallel.
13
- * Returns merged results array.
14
- * Optionally accepts pre-fetched context for LLM prompt rendering.
14
+ * Execute all handlers for an event, respecting dependency order.
15
+ * Handlers are grouped into "waves" via topological sort.
16
+ * Within each wave, handlers run in parallel.
17
+ * Outputs from previous waves are available to dependent handlers via _handlerOutputs.
15
18
  */
16
19
  export declare function executeHandlers(_event: HookEvent, input: HookInput, handlers: HandlerConfig[], context?: PrefetchContext): Promise<HandlerResult[]>;
17
20
  /**