@mauribadnights/clooks 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +133 -5
- package/dist/auth.d.ts +17 -0
- package/dist/auth.js +109 -0
- package/dist/builtin-hooks.d.ts +11 -0
- package/dist/builtin-hooks.js +67 -0
- package/dist/cli.js +136 -5
- package/dist/constants.d.ts +4 -0
- package/dist/constants.js +5 -1
- package/dist/deps.d.ts +13 -0
- package/dist/deps.js +83 -0
- package/dist/doctor.js +110 -0
- package/dist/handlers.d.ts +11 -3
- package/dist/handlers.js +99 -46
- package/dist/index.d.ts +11 -4
- package/dist/index.js +30 -1
- package/dist/llm.d.ts +1 -1
- package/dist/llm.js +8 -4
- package/dist/manifest.d.ts +5 -1
- package/dist/manifest.js +33 -5
- package/dist/metrics.d.ts +8 -1
- package/dist/metrics.js +32 -6
- package/dist/migrate.js +21 -2
- package/dist/plugin.d.ts +50 -0
- package/dist/plugin.js +279 -0
- package/dist/ratelimit.d.ts +12 -0
- package/dist/ratelimit.js +44 -0
- package/dist/server.d.ts +13 -2
- package/dist/server.js +168 -11
- package/dist/shortcircuit.d.ts +20 -0
- package/dist/shortcircuit.js +49 -0
- package/dist/types.d.ts +36 -0
- package/dist/watcher.d.ts +18 -0
- package/dist/watcher.js +120 -0
- package/hooks/check-update.js +37 -0
- package/package.json +2 -1
package/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
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
|
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)
|
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[][];
|