@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 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
 
@@ -97,6 +102,7 @@ settings:
97
102
  **Handler types:**
98
103
  - `script` -- runs a shell command, pipes hook JSON to stdin, reads JSON from stdout.
99
104
  - `inline` -- imports a JS module and calls its default export. Faster; no subprocess overhead.
105
+ - `llm` -- calls Anthropic Messages API. Supports prompt templates, batching, and cost tracking. *(v0.2+)*
100
106
 
101
107
  ## Observability
102
108
 
@@ -171,11 +177,54 @@ handlers:
171
177
  batchGroup: analysis # batched with code-review into one API call
172
178
  ```
173
179
 
174
- Requires `@anthropic-ai/sdk` as a peer dependency and `ANTHROPIC_API_KEY` env var.
180
+ **Setup:**
181
+
182
+ ```bash
183
+ npm install @anthropic-ai/sdk # peer dependency, only needed for llm handlers
184
+ export ANTHROPIC_API_KEY=sk-... # or set in manifest: settings.anthropicApiKey
185
+ ```
186
+
187
+ **Prompt template variables:**
188
+
189
+ | Variable | Source | Description |
190
+ |----------|--------|-------------|
191
+ | `$TRANSCRIPT` | Pre-fetched transcript file | Last 50KB of session transcript |
192
+ | `$GIT_STATUS` | `git status --porcelain` | Current working tree status |
193
+ | `$GIT_DIFF` | `git diff --stat` | Changed files summary (max 20KB) |
194
+ | `$ARGUMENTS` | `hook_input.tool_input` | JSON-stringified tool arguments |
195
+ | `$TOOL_NAME` | `hook_input.tool_name` | Name of the tool being called |
196
+ | `$PROMPT` | `hook_input.prompt` | User's prompt (UserPromptSubmit only) |
197
+ | `$CWD` | `hook_input.cwd` | Current working directory |
198
+
199
+ **LLM handler options:**
200
+
201
+ | Field | Type | Default | Description |
202
+ |-------|------|---------|-------------|
203
+ | `model` | string | required | `claude-haiku-4-5`, `claude-sonnet-4-6`, or `claude-opus-4-6` |
204
+ | `prompt` | string | required | Prompt template with `$VARIABLE` interpolation |
205
+ | `batchGroup` | string | optional | Group ID -- handlers with same group make one API call |
206
+ | `maxTokens` | number | `1024` | Maximum output tokens |
207
+ | `temperature` | number | `1.0` | Sampling temperature |
208
+ | `filter` | string | optional | Keyword filter (see Filtering) |
209
+ | `timeout` | number | `30000` | Timeout in milliseconds |
210
+
211
+ **How batching works:**
212
+
213
+ When multiple LLM handlers share a `batchGroup` on the same event, clooks combines their prompts into a single multi-task API call and splits the structured response back to each handler. This means 3 Haiku calls become 1, saving ~2/3 of the input token cost and eliminating 2 round-trips.
175
214
 
176
215
  ### Intelligent Filtering
177
216
 
178
- Skip handlers based on keywords. Supports OR (`|`) and NOT (`!`) operators. Matching is case-insensitive against the full hook input JSON.
217
+ Skip handlers based on keywords. The `filter` field works on **all handler types** -- script, inline, and llm.
218
+
219
+ **Filter syntax:**
220
+
221
+ ```
222
+ filter: "word1|word2" # run if input contains word1 OR word2
223
+ filter: "!word" # run unless input contains word
224
+ filter: "word1|!word2" # run if word1 present AND word2 absent
225
+ ```
226
+
227
+ Matching is case-insensitive against the full JSON-serialized hook input.
179
228
 
180
229
  ```yaml
181
230
  handlers:
@@ -204,9 +253,19 @@ handlers:
204
253
  prompt: "Summarize this session:\n$TRANSCRIPT\n\nGit changes:\n$GIT_DIFF"
205
254
  ```
206
255
 
256
+ **Available prefetch keys:**
257
+
258
+ | Key | Source | Max size | Description |
259
+ |-----|--------|----------|-------------|
260
+ | `transcript` | `transcript_path` file | 50KB (tail) | Session conversation history |
261
+ | `git_status` | `git status --porcelain` | unbounded | Working tree status |
262
+ | `git_diff` | `git diff --stat` | 20KB | Changed files summary |
263
+
264
+ Pre-fetched data is cached for the duration of a single event dispatch. Errors on individual keys are silently caught -- a failed `git_status` won't prevent `transcript` from loading.
265
+
207
266
  ### Cost Tracking
208
267
 
209
- Track LLM token usage and costs per handler and model. Pricing is built-in for Haiku 4.5, Sonnet 4.6, and Opus 4.6.
268
+ Track LLM token usage and costs per handler and model.
210
269
 
211
270
  ```
212
271
  $ clooks costs
@@ -222,11 +281,80 @@ LLM Cost Summary
222
281
  security-check $0.0053 (12 calls, avg 178 tokens)
223
282
  ```
224
283
 
225
- Cost data also appears in `clooks stats` when LLM handlers have been used.
284
+ - Costs are persisted to `~/.clooks/costs.jsonl`
285
+ - Built-in pricing (per million tokens): Haiku ($0.80 / $4.00), Sonnet ($3.00 / $15.00), Opus ($15.00 / $75.00)
286
+ - Batching savings are estimated based on shared input tokens
287
+ - Cost data also appears in `clooks stats` when LLM handlers have been used
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.
226
355
 
227
356
  ## Roadmap
228
357
 
229
- - **v0.3:** Plugin ecosystem, dependency resolution between handlers
230
358
  - **v0.4:** Visual dashboard for hook management and metrics
231
359
 
232
360
  ## Contributing
package/dist/auth.d.ts ADDED
@@ -0,0 +1,17 @@
1
+ /** Generate a random auth token (32 hex chars). */
2
+ export declare function generateAuthToken(): string;
3
+ /** Validate an auth token from request headers. */
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 ADDED
@@ -0,0 +1,109 @@
1
+ "use strict";
2
+ // clooks auth — token-based request authentication
3
+ Object.defineProperty(exports, "__esModule", { value: true });
4
+ exports.generateAuthToken = generateAuthToken;
5
+ exports.validateAuth = validateAuth;
6
+ exports.rotateToken = rotateToken;
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");
13
+ /** Generate a random auth token (32 hex chars). */
14
+ function generateAuthToken() {
15
+ return (0, crypto_1.randomBytes)(16).toString('hex');
16
+ }
17
+ /** Validate an auth token from request headers. */
18
+ function validateAuth(authHeader, expectedToken) {
19
+ if (!expectedToken)
20
+ return true; // No token configured = no auth required
21
+ if (!authHeader)
22
+ return false;
23
+ // Support "Bearer <token>" format
24
+ const token = authHeader.startsWith('Bearer ')
25
+ ? authHeader.slice(7)
26
+ : authHeader;
27
+ // Constant-time comparison to prevent timing attacks
28
+ if (token.length !== expectedToken.length)
29
+ return false;
30
+ const bufA = Buffer.from(token);
31
+ const bufB = Buffer.from(expectedToken);
32
+ return (0, crypto_1.timingSafeEqual)(bufA, bufB);
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
@@ -8,19 +8,24 @@ const metrics_js_1 = require("./metrics.js");
8
8
  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
+ const auth_js_1 = require("./auth.js");
12
+ const plugin_js_1 = require("./plugin.js");
11
13
  const constants_js_1 = require("./constants.js");
12
14
  const fs_1 = require("fs");
15
+ const path_1 = require("path");
13
16
  const program = new commander_1.Command();
14
17
  program
15
18
  .name('clooks')
16
19
  .description('Persistent hook runtime for Claude Code')
17
- .version('0.2.0');
20
+ .version('0.3.0');
18
21
  // --- start ---
19
22
  program
20
23
  .command('start')
21
24
  .description('Start the clooks daemon')
22
25
  .option('-f, --foreground', 'Run in foreground (default: background/detached)')
26
+ .option('--no-watch', 'Disable file watching for manifest changes')
23
27
  .action(async (opts) => {
28
+ const noWatch = opts.watch === false;
24
29
  if (!opts.foreground) {
25
30
  // Background mode: check if already running, then spawn detached
26
31
  if ((0, server_js_1.isDaemonRunning)()) {
@@ -32,7 +37,7 @@ program
32
37
  (0, fs_1.mkdirSync)(constants_js_1.CONFIG_DIR, { recursive: true });
33
38
  }
34
39
  console.log('Starting clooks daemon in background...');
35
- (0, server_js_1.startDaemonBackground)();
40
+ (0, server_js_1.startDaemonBackground)({ noWatch });
36
41
  // Give it a moment to start
37
42
  await new Promise((r) => setTimeout(r, 500));
38
43
  if ((0, server_js_1.isDaemonRunning)()) {
@@ -46,12 +51,12 @@ program
46
51
  }
47
52
  // Foreground mode: run the actual server
48
53
  try {
49
- const manifest = (0, manifest_js_1.loadManifest)();
54
+ const manifest = (0, manifest_js_1.loadCompositeManifest)();
50
55
  const metrics = new metrics_js_1.MetricsCollector();
51
56
  const port = manifest.settings?.port ?? constants_js_1.DEFAULT_PORT;
52
57
  const handlerCount = Object.values(manifest.handlers)
53
58
  .reduce((sum, arr) => sum + (arr?.length ?? 0), 0);
54
- await (0, server_js_1.startDaemon)(manifest, metrics);
59
+ await (0, server_js_1.startDaemon)(manifest, metrics, { noWatch });
55
60
  console.log(`clooks daemon running on 127.0.0.1:${port} (${handlerCount} handler${handlerCount !== 1 ? 's' : ''})`);
56
61
  }
57
62
  catch (err) {
@@ -95,11 +100,13 @@ program
95
100
  req.setTimeout(3000, () => { req.destroy(); reject(new Error('timeout')); });
96
101
  });
97
102
  const health = JSON.parse(data);
103
+ const pluginCount = (0, plugin_js_1.listPlugins)().length;
98
104
  console.log(`Status: running`);
99
105
  console.log(`PID: ${pid}`);
100
106
  console.log(`Port: ${health.port}`);
101
107
  console.log(`Uptime: ${formatUptime(health.uptime)}`);
102
108
  console.log(`Handlers loaded: ${health.handlers_loaded}`);
109
+ console.log(`Plugins: ${pluginCount}`);
103
110
  }
104
111
  catch {
105
112
  console.log(`Status: running (pid ${pid})`);
@@ -206,10 +213,134 @@ program
206
213
  if (!(0, fs_1.existsSync)(constants_js_1.CONFIG_DIR)) {
207
214
  (0, fs_1.mkdirSync)(constants_js_1.CONFIG_DIR, { recursive: true });
208
215
  }
209
- const path = (0, manifest_js_1.createDefaultManifest)();
216
+ const token = (0, auth_js_1.generateAuthToken)();
217
+ const path = (0, manifest_js_1.createDefaultManifest)(token);
210
218
  console.log(`Created: ${path}`);
219
+ console.log(`Auth token: ${token}`);
211
220
  console.log('Edit this file to configure your hook handlers.');
212
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
+ });
213
344
  program.parse();
214
345
  function formatUptime(seconds) {
215
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[][];