@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 +72 -1
- package/dist/auth.d.ts +13 -0
- package/dist/auth.js +82 -0
- package/dist/builtin-hooks.d.ts +11 -0
- package/dist/builtin-hooks.js +67 -0
- package/dist/cli.js +131 -2
- 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 +56 -0
- package/dist/handlers.d.ts +6 -3
- package/dist/handlers.js +81 -46
- package/dist/index.d.ts +10 -5
- package/dist/index.js +23 -1
- package/dist/llm.d.ts +1 -1
- package/dist/llm.js +8 -4
- package/dist/manifest.d.ts +4 -0
- package/dist/manifest.js +24 -0
- package/dist/metrics.d.ts +14 -0
- package/dist/metrics.js +51 -0
- package/dist/migrate.d.ts +9 -0
- package/dist/migrate.js +64 -1
- 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 +5 -0
- package/dist/server.js +119 -5
- package/dist/shortcircuit.d.ts +20 -0
- package/dist/shortcircuit.js +49 -0
- package/dist/types.d.ts +31 -0
- package/hooks/check-update.js +37 -0
- package/package.json +2 -1
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.
|
|
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.
|
|
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)
|
package/dist/constants.d.ts
CHANGED
|
@@ -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
|
+
}
|