@mauribadnights/clooks 0.2.2 → 0.3.1

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.1');
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})`);
@@ -116,6 +120,9 @@ program
116
120
  .action(() => {
117
121
  const metrics = new metrics_js_1.MetricsCollector();
118
122
  console.log(metrics.formatStatsTable());
123
+ console.log('');
124
+ console.log('Per Handler:');
125
+ console.log(metrics.formatHandlerStatsTable());
119
126
  // Append cost summary if LLM data exists
120
127
  const costStats = metrics.getCostStats();
121
128
  if (costStats.totalCost > 0) {
@@ -215,6 +222,128 @@ program
215
222
  console.log(`Auth token: ${token}`);
216
223
  console.log('Edit this file to configure your hook handlers.');
217
224
  });
225
+ // --- rotate-token ---
226
+ program
227
+ .command('rotate-token')
228
+ .description('Generate new auth token, update manifest and settings.json')
229
+ .action(() => {
230
+ try {
231
+ const newToken = (0, auth_js_1.rotateToken)();
232
+ console.log(`Auth token rotated successfully.`);
233
+ console.log(`New token: ${newToken}`);
234
+ console.log('If daemon is running, the file watcher will pick up the manifest change.');
235
+ }
236
+ catch (err) {
237
+ console.error('Token rotation failed:', err instanceof Error ? err.message : err);
238
+ process.exit(1);
239
+ }
240
+ });
241
+ // --- update ---
242
+ program
243
+ .command('update')
244
+ .description('Update clooks to the latest version')
245
+ .action(async () => {
246
+ console.log('Checking for updates...');
247
+ const currentVersion = program.version();
248
+ try {
249
+ const { execSync } = await import('child_process');
250
+ const latest = execSync('npm view @mauribadnights/clooks version', { encoding: 'utf-8' }).trim();
251
+ if (latest === currentVersion) {
252
+ console.log(`Already on latest version (${currentVersion}).`);
253
+ return;
254
+ }
255
+ console.log(`Updating: ${currentVersion} \u2192 ${latest}`);
256
+ execSync('npm install -g @mauribadnights/clooks@latest', { stdio: 'inherit' });
257
+ console.log(`Updated to ${latest}.`);
258
+ // Restart daemon if running
259
+ if ((0, server_js_1.isDaemonRunning)()) {
260
+ console.log('Restarting daemon...');
261
+ (0, server_js_1.stopDaemon)();
262
+ await new Promise(r => setTimeout(r, 1000));
263
+ (0, server_js_1.startDaemonBackground)();
264
+ await new Promise(r => setTimeout(r, 500));
265
+ console.log('Daemon restarted.');
266
+ }
267
+ }
268
+ catch (err) {
269
+ console.error('Update failed:', err instanceof Error ? err.message : err);
270
+ process.exit(1);
271
+ }
272
+ });
273
+ // --- add (install plugin) ---
274
+ program
275
+ .command('add <path>')
276
+ .description('Install a plugin from a local directory')
277
+ .action((pluginPath) => {
278
+ try {
279
+ const resolvedPath = (0, path_1.resolve)(pluginPath);
280
+ if (!(0, fs_1.existsSync)(resolvedPath)) {
281
+ console.error(`Path does not exist: ${resolvedPath}`);
282
+ process.exit(1);
283
+ }
284
+ const manifestFile = (0, path_1.resolve)(resolvedPath, constants_js_1.PLUGIN_MANIFEST_NAME);
285
+ if (!(0, fs_1.existsSync)(manifestFile)) {
286
+ console.error(`No ${constants_js_1.PLUGIN_MANIFEST_NAME} found at ${resolvedPath}`);
287
+ process.exit(1);
288
+ }
289
+ const plugin = (0, plugin_js_1.installPlugin)(resolvedPath);
290
+ // Count handlers in the installed plugin
291
+ const plugins = (0, plugin_js_1.loadPlugins)();
292
+ const installed = plugins.find(p => p.name === plugin.name);
293
+ const handlerCount = installed
294
+ ? Object.values(installed.manifest.handlers).reduce((sum, arr) => sum + (arr?.length ?? 0), 0)
295
+ : 0;
296
+ console.log(`Installed plugin ${plugin.name} v${plugin.version} (${handlerCount} handlers)`);
297
+ }
298
+ catch (err) {
299
+ console.error('Plugin install failed:', err instanceof Error ? err.message : err);
300
+ process.exit(1);
301
+ }
302
+ });
303
+ // --- remove (uninstall plugin) ---
304
+ program
305
+ .command('remove <name>')
306
+ .description('Uninstall a plugin')
307
+ .action((name) => {
308
+ try {
309
+ (0, plugin_js_1.uninstallPlugin)(name);
310
+ console.log(`Removed plugin ${name}`);
311
+ }
312
+ catch (err) {
313
+ console.error('Plugin removal failed:', err instanceof Error ? err.message : err);
314
+ process.exit(1);
315
+ }
316
+ });
317
+ // --- plugins (list installed plugins) ---
318
+ program
319
+ .command('plugins')
320
+ .description('List installed plugins')
321
+ .action(() => {
322
+ const plugins = (0, plugin_js_1.listPlugins)();
323
+ if (plugins.length === 0) {
324
+ console.log('No plugins installed.');
325
+ return;
326
+ }
327
+ // Load full manifests to access extras and handler counts
328
+ const loaded = (0, plugin_js_1.loadPlugins)();
329
+ const manifestMap = new Map(loaded.map(l => [l.name, l.manifest]));
330
+ console.log('Installed Plugins:');
331
+ for (const p of plugins) {
332
+ const manifest = manifestMap.get(p.name);
333
+ const handlerCount = manifest
334
+ ? Object.values(manifest.handlers).reduce((sum, arr) => sum + (arr?.length ?? 0), 0)
335
+ : 0;
336
+ console.log(` ${p.name} v${p.version} (${handlerCount} handler${handlerCount !== 1 ? 's' : ''})`);
337
+ if (manifest?.extras) {
338
+ if (manifest.extras.skills && manifest.extras.skills.length > 0) {
339
+ console.log(` Skills: ${manifest.extras.skills.join(', ')}`);
340
+ }
341
+ if (manifest.extras.agents && manifest.extras.agents.length > 0) {
342
+ console.log(` Agents: ${manifest.extras.agents.join(', ')}`);
343
+ }
344
+ }
345
+ }
346
+ });
218
347
  program.parse();
219
348
  function formatUptime(seconds) {
220
349
  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
+ }