@mauribadnights/clooks 0.4.1 → 0.5.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/dist/cli.js +121 -10
- package/dist/import-plugins.d.ts +50 -0
- package/dist/import-plugins.js +196 -0
- package/dist/migrate.js +9 -0
- package/dist/server.d.ts +22 -0
- package/dist/server.js +120 -11
- package/dist/sync.js +15 -0
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -15,13 +15,14 @@ const service_js_1 = require("./service.js");
|
|
|
15
15
|
const constants_js_1 = require("./constants.js");
|
|
16
16
|
const tui_js_1 = require("./tui.js");
|
|
17
17
|
const agent_js_1 = require("./agent.js");
|
|
18
|
+
const import_plugins_js_1 = require("./import-plugins.js");
|
|
18
19
|
const fs_1 = require("fs");
|
|
19
20
|
const path_1 = require("path");
|
|
20
21
|
const program = new commander_1.Command();
|
|
21
22
|
program
|
|
22
23
|
.name('clooks')
|
|
23
24
|
.description('Persistent hook runtime for Claude Code')
|
|
24
|
-
.version('0.
|
|
25
|
+
.version('0.5.1');
|
|
25
26
|
// --- start ---
|
|
26
27
|
program
|
|
27
28
|
.command('start')
|
|
@@ -31,11 +32,13 @@ program
|
|
|
31
32
|
.action(async (opts) => {
|
|
32
33
|
const noWatch = opts.watch === false;
|
|
33
34
|
if (!opts.foreground) {
|
|
34
|
-
//
|
|
35
|
+
// Fix 6: Idempotent start — check if daemon is already healthy first
|
|
36
|
+
// This covers both normal PID file case AND orphaned daemon (no PID file)
|
|
35
37
|
if ((0, server_js_1.isDaemonRunning)()) {
|
|
36
38
|
const healthy = await (0, server_js_1.isDaemonHealthy)();
|
|
37
39
|
if (healthy) {
|
|
38
|
-
|
|
40
|
+
const pid = (0, fs_1.existsSync)(constants_js_1.PID_FILE) ? (0, fs_1.readFileSync)(constants_js_1.PID_FILE, 'utf-8').trim() : '?';
|
|
41
|
+
console.log(`Daemon is already running (pid ${pid}).`);
|
|
39
42
|
process.exit(0);
|
|
40
43
|
}
|
|
41
44
|
// PID alive but daemon unhealthy — stale process after sleep/lid-close
|
|
@@ -44,6 +47,16 @@ program
|
|
|
44
47
|
console.log(`Cleaned up stale daemon (pid ${stalePid}), starting fresh`);
|
|
45
48
|
}
|
|
46
49
|
}
|
|
50
|
+
else {
|
|
51
|
+
// No PID file or dead PID — check if an orphaned daemon is on the port
|
|
52
|
+
const health = await (0, server_js_1.probeHealth)();
|
|
53
|
+
if (health && health.pid) {
|
|
54
|
+
// Orphaned daemon found — re-adopt it
|
|
55
|
+
(0, server_js_1.writePidFile)(health.pid);
|
|
56
|
+
console.log(`Adopted existing daemon (pid ${health.pid}).`);
|
|
57
|
+
process.exit(0);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
47
60
|
// Ensure config dir exists
|
|
48
61
|
if (!(0, fs_1.existsSync)(constants_js_1.CONFIG_DIR)) {
|
|
49
62
|
(0, fs_1.mkdirSync)(constants_js_1.CONFIG_DIR, { recursive: true });
|
|
@@ -62,7 +75,15 @@ program
|
|
|
62
75
|
console.log(`Daemon started (pid ${pid}), listening on 127.0.0.1:${constants_js_1.DEFAULT_PORT}`);
|
|
63
76
|
}
|
|
64
77
|
else {
|
|
65
|
-
|
|
78
|
+
// Fix 1: After spawn, if PID file missing, check if port responded (orphan recovery)
|
|
79
|
+
const health = await (0, server_js_1.probeHealth)();
|
|
80
|
+
if (health && health.pid) {
|
|
81
|
+
(0, server_js_1.writePidFile)(health.pid);
|
|
82
|
+
console.log(`Adopted existing daemon (pid ${health.pid}).`);
|
|
83
|
+
}
|
|
84
|
+
else {
|
|
85
|
+
console.log('Daemon started. Check ~/.clooks/daemon.log if issues arise.');
|
|
86
|
+
}
|
|
66
87
|
}
|
|
67
88
|
process.exit(0);
|
|
68
89
|
}
|
|
@@ -85,12 +106,24 @@ program
|
|
|
85
106
|
program
|
|
86
107
|
.command('stop')
|
|
87
108
|
.description('Stop the clooks daemon')
|
|
88
|
-
.action(() => {
|
|
109
|
+
.action(async () => {
|
|
110
|
+
// Try sync stop first (fast path with PID file)
|
|
89
111
|
if ((0, server_js_1.stopDaemon)()) {
|
|
90
112
|
console.log('Daemon stopped.');
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
// Fix 4: No PID file — try async recovery via /health
|
|
116
|
+
const result = await (0, server_js_1.stopDaemonAsync)();
|
|
117
|
+
if (result.stopped) {
|
|
118
|
+
if (result.recovered) {
|
|
119
|
+
console.log(`Daemon stopped (recovered orphan, pid ${result.pid}).`);
|
|
120
|
+
}
|
|
121
|
+
else {
|
|
122
|
+
console.log('Daemon stopped.');
|
|
123
|
+
}
|
|
91
124
|
}
|
|
92
125
|
else {
|
|
93
|
-
console.log('Daemon is not running
|
|
126
|
+
console.log('Daemon is not running.');
|
|
94
127
|
}
|
|
95
128
|
});
|
|
96
129
|
// --- status ---
|
|
@@ -99,18 +132,28 @@ program
|
|
|
99
132
|
.description('Show daemon status')
|
|
100
133
|
.action(async () => {
|
|
101
134
|
const running = (0, server_js_1.isDaemonRunning)();
|
|
135
|
+
const serviceStatus = (0, service_js_1.getServiceStatus)();
|
|
102
136
|
if (!running) {
|
|
137
|
+
// Fix 2: No PID file or dead PID — check if orphaned daemon is on the port
|
|
138
|
+
const health = await (0, server_js_1.probeHealth)();
|
|
139
|
+
if (health && health.pid) {
|
|
140
|
+
// Orphaned daemon found — recover PID file
|
|
141
|
+
(0, server_js_1.writePidFile)(health.pid);
|
|
142
|
+
console.log(`Status: running (recovered, pid ${health.pid})`);
|
|
143
|
+
console.log(`Port: ${constants_js_1.DEFAULT_PORT}`);
|
|
144
|
+
console.log(`Service: ${serviceStatus}`);
|
|
145
|
+
console.log('Note: PID file was missing. Re-adopted orphaned daemon.');
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
103
148
|
console.log('Status: stopped');
|
|
104
149
|
return;
|
|
105
150
|
}
|
|
106
151
|
const pid = (0, fs_1.existsSync)(constants_js_1.PID_FILE) ? (0, fs_1.readFileSync)(constants_js_1.PID_FILE, 'utf-8').trim() : '?';
|
|
107
|
-
// Try to hit health endpoint
|
|
108
|
-
// Service status
|
|
109
|
-
const serviceStatus = (0, service_js_1.getServiceStatus)();
|
|
152
|
+
// Try to hit health endpoint for detailed info
|
|
110
153
|
try {
|
|
111
154
|
const { get } = await import('http');
|
|
112
155
|
const data = await new Promise((resolve, reject) => {
|
|
113
|
-
const req = get(`http://127.0.0.1:${constants_js_1.DEFAULT_PORT}/health`, (res) => {
|
|
156
|
+
const req = get(`http://127.0.0.1:${constants_js_1.DEFAULT_PORT}/health/detail`, (res) => {
|
|
114
157
|
let body = '';
|
|
115
158
|
res.on('data', (chunk) => { body += chunk.toString(); });
|
|
116
159
|
res.on('end', () => resolve(body));
|
|
@@ -247,6 +290,65 @@ program
|
|
|
247
290
|
}
|
|
248
291
|
}
|
|
249
292
|
});
|
|
293
|
+
// --- import-plugins ---
|
|
294
|
+
program
|
|
295
|
+
.command('import-plugins')
|
|
296
|
+
.description('Import hooks from installed Claude Code plugins')
|
|
297
|
+
.action(async () => {
|
|
298
|
+
try {
|
|
299
|
+
const { plugins, handlers } = (0, import_plugins_js_1.importPlugins)();
|
|
300
|
+
if (plugins.length === 0) {
|
|
301
|
+
console.log('No Claude Code plugins with hooks found.');
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
// Load current manifest
|
|
305
|
+
const manifest = (0, manifest_js_1.loadCompositeManifest)();
|
|
306
|
+
// Remove previously imported plugin handlers (those with "/" matching discovered plugin names)
|
|
307
|
+
const pluginNames = new Set(plugins.map(p => p.name));
|
|
308
|
+
for (const [event, eventHandlers] of Object.entries(manifest.handlers)) {
|
|
309
|
+
if (!eventHandlers)
|
|
310
|
+
continue;
|
|
311
|
+
manifest.handlers[event] = eventHandlers.filter(h => {
|
|
312
|
+
const slashIdx = h.id.indexOf('/');
|
|
313
|
+
if (slashIdx === -1)
|
|
314
|
+
return true; // user handler, keep
|
|
315
|
+
const prefix = h.id.substring(0, slashIdx);
|
|
316
|
+
return !pluginNames.has(prefix); // remove if prefix matches a discovered plugin
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
// Add new imported handlers
|
|
320
|
+
for (const [event, eventHandlers] of Object.entries(handlers)) {
|
|
321
|
+
const hookEvent = event;
|
|
322
|
+
if (!manifest.handlers[hookEvent])
|
|
323
|
+
manifest.handlers[hookEvent] = [];
|
|
324
|
+
manifest.handlers[hookEvent].push(...eventHandlers);
|
|
325
|
+
}
|
|
326
|
+
// Write updated manifest
|
|
327
|
+
const { stringify: stringifyYaml } = await import('yaml');
|
|
328
|
+
const { writeFileSync } = await import('fs');
|
|
329
|
+
const yamlStr = '# clooks manifest — updated by import-plugins\n' +
|
|
330
|
+
`# Date: ${new Date().toISOString()}\n\n` +
|
|
331
|
+
stringifyYaml(manifest);
|
|
332
|
+
writeFileSync(constants_js_1.MANIFEST_PATH, yamlStr, 'utf-8');
|
|
333
|
+
// Sync settings.json
|
|
334
|
+
const syncAdded = (0, sync_js_1.syncSettings)();
|
|
335
|
+
// Report
|
|
336
|
+
const totalHandlers = Object.values(handlers).reduce((sum, arr) => sum + arr.length, 0);
|
|
337
|
+
console.log(`Imported ${totalHandlers} handler(s) from ${plugins.length} CC plugin(s):`);
|
|
338
|
+
for (const p of plugins) {
|
|
339
|
+
const count = Object.values(p.hooks).reduce((sum, arr) => sum + arr.filter(h => h.type === 'command').length, 0);
|
|
340
|
+
const enhanced = p.clooksEnhancements ? ' (with clooks.yaml)' : '';
|
|
341
|
+
console.log(` ${p.name} v${p.version}: ${count} handler(s)${enhanced}`);
|
|
342
|
+
}
|
|
343
|
+
if (syncAdded.length > 0) {
|
|
344
|
+
console.log(`Synced HTTP hooks for: ${syncAdded.join(', ')}`);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
catch (err) {
|
|
348
|
+
console.error('Import failed:', err instanceof Error ? err.message : err);
|
|
349
|
+
process.exit(1);
|
|
350
|
+
}
|
|
351
|
+
});
|
|
250
352
|
// --- ensure-running ---
|
|
251
353
|
program
|
|
252
354
|
.command('ensure-running')
|
|
@@ -273,6 +375,15 @@ program
|
|
|
273
375
|
}
|
|
274
376
|
}
|
|
275
377
|
}
|
|
378
|
+
else {
|
|
379
|
+
// No PID file — check for orphaned daemon on the port
|
|
380
|
+
const health = await (0, server_js_1.probeHealth)();
|
|
381
|
+
if (health && health.pid) {
|
|
382
|
+
(0, server_js_1.writePidFile)(health.pid);
|
|
383
|
+
(0, sync_js_1.syncSettings)();
|
|
384
|
+
process.exit(0);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
276
387
|
// Ensure config dir exists
|
|
277
388
|
if (!(0, fs_1.existsSync)(constants_js_1.CONFIG_DIR)) {
|
|
278
389
|
(0, fs_1.mkdirSync)(constants_js_1.CONFIG_DIR, { recursive: true });
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { HandlerConfig, HookEvent } from './types.js';
|
|
2
|
+
/** A discovered Claude Code plugin */
|
|
3
|
+
export interface CCPlugin {
|
|
4
|
+
name: string;
|
|
5
|
+
version: string;
|
|
6
|
+
path: string;
|
|
7
|
+
hooks: Partial<Record<HookEvent, CCHookEntry[]>>;
|
|
8
|
+
clooksEnhancements?: Partial<Record<string, ClooksEnhancement>>;
|
|
9
|
+
}
|
|
10
|
+
interface CCHookEntry {
|
|
11
|
+
type: 'command' | 'http' | 'prompt' | 'agent';
|
|
12
|
+
command?: string;
|
|
13
|
+
url?: string;
|
|
14
|
+
matcher?: string;
|
|
15
|
+
timeout?: number;
|
|
16
|
+
}
|
|
17
|
+
/** clooks.yaml enhancement overlay per handler */
|
|
18
|
+
interface ClooksEnhancement {
|
|
19
|
+
filter?: string;
|
|
20
|
+
project?: string;
|
|
21
|
+
agent?: string;
|
|
22
|
+
async?: boolean;
|
|
23
|
+
depends?: string[];
|
|
24
|
+
sessionIsolation?: boolean;
|
|
25
|
+
batchGroup?: string;
|
|
26
|
+
type?: 'llm';
|
|
27
|
+
model?: string;
|
|
28
|
+
prompt?: string;
|
|
29
|
+
maxTokens?: number;
|
|
30
|
+
temperature?: number;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Discover all installed Claude Code plugins that have hooks.
|
|
34
|
+
*/
|
|
35
|
+
export declare function discoverCCPlugins(ccPluginsDir?: string): CCPlugin[];
|
|
36
|
+
/**
|
|
37
|
+
* Convert discovered CC plugins into clooks manifest handler entries.
|
|
38
|
+
* Applies clooks.yaml enhancements if present.
|
|
39
|
+
* Handler IDs namespaced as "pluginName/derivedId".
|
|
40
|
+
*/
|
|
41
|
+
export declare function convertPluginsToHandlers(plugins: CCPlugin[]): Partial<Record<HookEvent, HandlerConfig[]>>;
|
|
42
|
+
/**
|
|
43
|
+
* Import all Claude Code plugins into clooks.
|
|
44
|
+
* Returns the discovered plugins and generated handlers.
|
|
45
|
+
*/
|
|
46
|
+
export declare function importPlugins(ccPluginsDir?: string): {
|
|
47
|
+
plugins: CCPlugin[];
|
|
48
|
+
handlers: Partial<Record<HookEvent, HandlerConfig[]>>;
|
|
49
|
+
};
|
|
50
|
+
export {};
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.discoverCCPlugins = discoverCCPlugins;
|
|
4
|
+
exports.convertPluginsToHandlers = convertPluginsToHandlers;
|
|
5
|
+
exports.importPlugins = importPlugins;
|
|
6
|
+
const fs_1 = require("fs");
|
|
7
|
+
const path_1 = require("path");
|
|
8
|
+
const os_1 = require("os");
|
|
9
|
+
const yaml_1 = require("yaml");
|
|
10
|
+
const constants_js_1 = require("./constants.js");
|
|
11
|
+
/** Path to Claude Code's plugin cache */
|
|
12
|
+
const CC_PLUGINS_DIR = (0, path_1.join)((0, os_1.homedir)(), '.claude', 'plugins', 'cache');
|
|
13
|
+
/**
|
|
14
|
+
* Discover all installed Claude Code plugins that have hooks.
|
|
15
|
+
*/
|
|
16
|
+
function discoverCCPlugins(ccPluginsDir) {
|
|
17
|
+
const dir = ccPluginsDir ?? CC_PLUGINS_DIR;
|
|
18
|
+
if (!(0, fs_1.existsSync)(dir))
|
|
19
|
+
return [];
|
|
20
|
+
const results = [];
|
|
21
|
+
for (const entry of (0, fs_1.readdirSync)(dir, { withFileTypes: true })) {
|
|
22
|
+
if (!entry.isDirectory())
|
|
23
|
+
continue;
|
|
24
|
+
const pluginDir = (0, path_1.join)(dir, entry.name);
|
|
25
|
+
// Read plugin.json
|
|
26
|
+
const pluginJsonPath = (0, path_1.join)(pluginDir, '.claude-plugin', 'plugin.json');
|
|
27
|
+
if (!(0, fs_1.existsSync)(pluginJsonPath))
|
|
28
|
+
continue;
|
|
29
|
+
let pluginJson;
|
|
30
|
+
try {
|
|
31
|
+
pluginJson = JSON.parse((0, fs_1.readFileSync)(pluginJsonPath, 'utf-8'));
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
// Read hooks
|
|
37
|
+
const hooksJsonPath = (0, path_1.join)(pluginDir, 'hooks', 'hooks.json');
|
|
38
|
+
let hooks = {};
|
|
39
|
+
if ((0, fs_1.existsSync)(hooksJsonPath)) {
|
|
40
|
+
try {
|
|
41
|
+
hooks = JSON.parse((0, fs_1.readFileSync)(hooksJsonPath, 'utf-8'));
|
|
42
|
+
}
|
|
43
|
+
catch { /* skip */ }
|
|
44
|
+
}
|
|
45
|
+
// Also check if hooks are inline in plugin.json
|
|
46
|
+
if (pluginJson.hooks && typeof pluginJson.hooks === 'string') {
|
|
47
|
+
const inlineHooksPath = (0, path_1.join)(pluginDir, pluginJson.hooks);
|
|
48
|
+
if ((0, fs_1.existsSync)(inlineHooksPath)) {
|
|
49
|
+
try {
|
|
50
|
+
hooks = JSON.parse((0, fs_1.readFileSync)(inlineHooksPath, 'utf-8'));
|
|
51
|
+
}
|
|
52
|
+
catch { /* skip */ }
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
if (!hooks || Object.keys(hooks).length === 0)
|
|
56
|
+
continue;
|
|
57
|
+
// Read optional clooks.yaml enhancement
|
|
58
|
+
let enhancements = undefined;
|
|
59
|
+
const clooksYamlPath = (0, path_1.join)(pluginDir, 'clooks.yaml');
|
|
60
|
+
if ((0, fs_1.existsSync)(clooksYamlPath)) {
|
|
61
|
+
try {
|
|
62
|
+
enhancements = (0, yaml_1.parse)((0, fs_1.readFileSync)(clooksYamlPath, 'utf-8'));
|
|
63
|
+
}
|
|
64
|
+
catch { /* skip */ }
|
|
65
|
+
}
|
|
66
|
+
results.push({
|
|
67
|
+
name: pluginJson.name || entry.name,
|
|
68
|
+
version: pluginJson.version || '0.0.0',
|
|
69
|
+
path: pluginDir,
|
|
70
|
+
hooks: parseHooks(hooks),
|
|
71
|
+
clooksEnhancements: enhancements?.handlers,
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
return results;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Parse Claude Code hooks.json format into flat event->hooks map.
|
|
78
|
+
* CC format: { "PostToolUse": [{ "matcher": "...", "hooks": [{ "type": "command", ... }] }] }
|
|
79
|
+
*/
|
|
80
|
+
function parseHooks(hooks) {
|
|
81
|
+
const result = {};
|
|
82
|
+
for (const [event, ruleGroups] of Object.entries(hooks)) {
|
|
83
|
+
if (!constants_js_1.HOOK_EVENTS.includes(event) || !Array.isArray(ruleGroups))
|
|
84
|
+
continue;
|
|
85
|
+
const entries = [];
|
|
86
|
+
for (const rule of ruleGroups) {
|
|
87
|
+
if (Array.isArray(rule.hooks)) {
|
|
88
|
+
for (const hook of rule.hooks) {
|
|
89
|
+
entries.push({ ...hook, matcher: rule.matcher });
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
if (entries.length > 0) {
|
|
94
|
+
result[event] = entries;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return result;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Convert discovered CC plugins into clooks manifest handler entries.
|
|
101
|
+
* Applies clooks.yaml enhancements if present.
|
|
102
|
+
* Handler IDs namespaced as "pluginName/derivedId".
|
|
103
|
+
*/
|
|
104
|
+
function convertPluginsToHandlers(plugins) {
|
|
105
|
+
const handlers = {};
|
|
106
|
+
for (const plugin of plugins) {
|
|
107
|
+
for (const [event, hookEntries] of Object.entries(plugin.hooks)) {
|
|
108
|
+
const hookEvent = event;
|
|
109
|
+
if (!handlers[hookEvent])
|
|
110
|
+
handlers[hookEvent] = [];
|
|
111
|
+
for (let i = 0; i < hookEntries.length; i++) {
|
|
112
|
+
const hook = hookEntries[i];
|
|
113
|
+
if (hook.type !== 'command' || !hook.command)
|
|
114
|
+
continue; // Only import command hooks
|
|
115
|
+
// Derive handler ID from command
|
|
116
|
+
const baseId = deriveId(hook.command, hookEvent, i);
|
|
117
|
+
const id = `${plugin.name}/${baseId}`;
|
|
118
|
+
// Build base handler config
|
|
119
|
+
const handler = {
|
|
120
|
+
id,
|
|
121
|
+
type: 'script',
|
|
122
|
+
command: resolvePluginVars(hook.command, plugin.path),
|
|
123
|
+
timeout: hook.timeout ? hook.timeout * 1000 : 5000,
|
|
124
|
+
enabled: true,
|
|
125
|
+
};
|
|
126
|
+
// Apply clooks.yaml enhancements if present
|
|
127
|
+
const enhancement = plugin.clooksEnhancements?.[baseId];
|
|
128
|
+
if (enhancement) {
|
|
129
|
+
applyEnhancement(handler, enhancement, plugin.name);
|
|
130
|
+
}
|
|
131
|
+
handlers[hookEvent].push(handler);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return handlers;
|
|
136
|
+
}
|
|
137
|
+
/** Apply clooks.yaml enhancement to a handler */
|
|
138
|
+
function applyEnhancement(handler, enhancement, pluginName) {
|
|
139
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- dynamic property assignment from enhancement overlay
|
|
140
|
+
const h = handler;
|
|
141
|
+
if (enhancement.filter)
|
|
142
|
+
h.filter = enhancement.filter;
|
|
143
|
+
if (enhancement.project)
|
|
144
|
+
h.project = enhancement.project;
|
|
145
|
+
if (enhancement.agent)
|
|
146
|
+
h.agent = enhancement.agent;
|
|
147
|
+
if (enhancement.async !== undefined)
|
|
148
|
+
h.async = enhancement.async;
|
|
149
|
+
if (enhancement.depends) {
|
|
150
|
+
h.depends = enhancement.depends.map(d => d.includes('/') ? d : `${pluginName}/${d}`);
|
|
151
|
+
}
|
|
152
|
+
if (enhancement.sessionIsolation)
|
|
153
|
+
h.sessionIsolation = enhancement.sessionIsolation;
|
|
154
|
+
// Allow converting to LLM handler
|
|
155
|
+
if (enhancement.type === 'llm' && enhancement.model && enhancement.prompt) {
|
|
156
|
+
h.type = 'llm';
|
|
157
|
+
h.model = enhancement.model;
|
|
158
|
+
h.prompt = enhancement.prompt;
|
|
159
|
+
delete h.command;
|
|
160
|
+
if (enhancement.batchGroup)
|
|
161
|
+
h.batchGroup = enhancement.batchGroup;
|
|
162
|
+
if (enhancement.maxTokens)
|
|
163
|
+
h.maxTokens = enhancement.maxTokens;
|
|
164
|
+
if (enhancement.temperature)
|
|
165
|
+
h.temperature = enhancement.temperature;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
/** Resolve ${CLAUDE_PLUGIN_ROOT} and similar vars in commands */
|
|
169
|
+
function resolvePluginVars(command, pluginPath) {
|
|
170
|
+
return command
|
|
171
|
+
.replace(/\$\{CLAUDE_PLUGIN_ROOT\}/g, pluginPath)
|
|
172
|
+
.replace(/\$CLAUDE_PLUGIN_ROOT/g, pluginPath);
|
|
173
|
+
}
|
|
174
|
+
/** Derive a handler ID from command */
|
|
175
|
+
function deriveId(command, event, index) {
|
|
176
|
+
// Extract script filename
|
|
177
|
+
const jsMatch = command.match(/[\\/]([^\\/]+)\.(?:js|ts|py|sh)(?:\s|"|'|$)/);
|
|
178
|
+
if (jsMatch) {
|
|
179
|
+
return jsMatch[1].toLowerCase().replace(/[^a-z0-9-]/g, '-');
|
|
180
|
+
}
|
|
181
|
+
const moduleMatch = command.match(/python3?\s+-m\s+([\w.]+)/);
|
|
182
|
+
if (moduleMatch) {
|
|
183
|
+
const parts = moduleMatch[1].split('.');
|
|
184
|
+
return parts.slice(-2).join('-').toLowerCase();
|
|
185
|
+
}
|
|
186
|
+
return `${event.toLowerCase()}-${index}`;
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* Import all Claude Code plugins into clooks.
|
|
190
|
+
* Returns the discovered plugins and generated handlers.
|
|
191
|
+
*/
|
|
192
|
+
function importPlugins(ccPluginsDir) {
|
|
193
|
+
const plugins = discoverCCPlugins(ccPluginsDir);
|
|
194
|
+
const handlers = convertPluginsToHandlers(plugins);
|
|
195
|
+
return { plugins, handlers };
|
|
196
|
+
}
|
package/dist/migrate.js
CHANGED
|
@@ -10,6 +10,7 @@ const path_1 = require("path");
|
|
|
10
10
|
const os_1 = require("os");
|
|
11
11
|
const constants_js_1 = require("./constants.js");
|
|
12
12
|
const builtin_hooks_js_1 = require("./builtin-hooks.js");
|
|
13
|
+
const import_plugins_js_1 = require("./import-plugins.js");
|
|
13
14
|
const yaml_1 = require("yaml");
|
|
14
15
|
/**
|
|
15
16
|
* Find the Claude Code settings.json path.
|
|
@@ -154,6 +155,14 @@ function migrate(options) {
|
|
|
154
155
|
timeout: 6000,
|
|
155
156
|
enabled: true,
|
|
156
157
|
});
|
|
158
|
+
// Import Claude Code plugin hooks
|
|
159
|
+
const { handlers: ccHandlers } = (0, import_plugins_js_1.importPlugins)();
|
|
160
|
+
for (const [event, eventHandlers] of Object.entries(ccHandlers)) {
|
|
161
|
+
const hookEvent = event;
|
|
162
|
+
if (!manifestHandlers[hookEvent])
|
|
163
|
+
manifestHandlers[hookEvent] = [];
|
|
164
|
+
manifestHandlers[hookEvent].push(...eventHandlers);
|
|
165
|
+
}
|
|
157
166
|
// Write manifest.yaml
|
|
158
167
|
const manifest = {
|
|
159
168
|
handlers: manifestHandlers,
|
package/dist/server.d.ts
CHANGED
|
@@ -33,8 +33,18 @@ export declare function startDaemon(manifest: Manifest, metrics: MetricsCollecto
|
|
|
33
33
|
}): Promise<ServerContext>;
|
|
34
34
|
/**
|
|
35
35
|
* Stop a running daemon by reading PID file and sending SIGTERM.
|
|
36
|
+
* If no PID file exists, tries to recover PID from the health endpoint.
|
|
36
37
|
*/
|
|
37
38
|
export declare function stopDaemon(): boolean;
|
|
39
|
+
/**
|
|
40
|
+
* Async version of stopDaemon that can recover orphaned daemons via /health.
|
|
41
|
+
* Returns { stopped: boolean, pid?: number, recovered?: boolean }.
|
|
42
|
+
*/
|
|
43
|
+
export declare function stopDaemonAsync(): Promise<{
|
|
44
|
+
stopped: boolean;
|
|
45
|
+
pid?: number;
|
|
46
|
+
recovered?: boolean;
|
|
47
|
+
}>;
|
|
38
48
|
/**
|
|
39
49
|
* Check if daemon is currently running (PID check only).
|
|
40
50
|
* Use for stop/status where a quick check is fine.
|
|
@@ -46,6 +56,18 @@ export declare function isDaemonRunning(): boolean;
|
|
|
46
56
|
* Use for ensure-running and start where correctness matters.
|
|
47
57
|
*/
|
|
48
58
|
export declare function isDaemonHealthy(): Promise<boolean>;
|
|
59
|
+
/**
|
|
60
|
+
* Probe the health endpoint on the daemon port.
|
|
61
|
+
* Returns the parsed health response (including pid) or null if unreachable.
|
|
62
|
+
*/
|
|
63
|
+
export declare function probeHealth(port?: number): Promise<{
|
|
64
|
+
status: string;
|
|
65
|
+
pid: number;
|
|
66
|
+
} | null>;
|
|
67
|
+
/**
|
|
68
|
+
* Write a PID to the daemon PID file, creating the config dir if needed.
|
|
69
|
+
*/
|
|
70
|
+
export declare function writePidFile(pid: number): void;
|
|
49
71
|
/**
|
|
50
72
|
* Clean up a stale daemon: remove PID file and attempt to kill the process.
|
|
51
73
|
* Returns the stale PID for logging purposes.
|
package/dist/server.js
CHANGED
|
@@ -5,8 +5,11 @@ exports.sessionAgents = void 0;
|
|
|
5
5
|
exports.createServer = createServer;
|
|
6
6
|
exports.startDaemon = startDaemon;
|
|
7
7
|
exports.stopDaemon = stopDaemon;
|
|
8
|
+
exports.stopDaemonAsync = stopDaemonAsync;
|
|
8
9
|
exports.isDaemonRunning = isDaemonRunning;
|
|
9
10
|
exports.isDaemonHealthy = isDaemonHealthy;
|
|
11
|
+
exports.probeHealth = probeHealth;
|
|
12
|
+
exports.writePidFile = writePidFile;
|
|
10
13
|
exports.cleanupStaleDaemon = cleanupStaleDaemon;
|
|
11
14
|
exports.startDaemonBackground = startDaemonBackground;
|
|
12
15
|
const http_1 = require("http");
|
|
@@ -123,9 +126,9 @@ function createServer(manifest, metrics) {
|
|
|
123
126
|
const server = (0, http_1.createServer)(async (req, res) => {
|
|
124
127
|
const url = req.url ?? '/';
|
|
125
128
|
const method = req.method ?? 'GET';
|
|
126
|
-
// Public health endpoint —
|
|
129
|
+
// Public health endpoint — includes PID for orphan recovery
|
|
127
130
|
if (method === 'GET' && url === '/health') {
|
|
128
|
-
sendJson(res, 200, { status: 'ok' });
|
|
131
|
+
sendJson(res, 200, { status: 'ok', pid: process.pid });
|
|
129
132
|
return;
|
|
130
133
|
}
|
|
131
134
|
// Detailed health endpoint — authenticated if authToken configured
|
|
@@ -310,10 +313,23 @@ function startDaemon(manifest, metrics, options) {
|
|
|
310
313
|
return new Promise((resolve, reject) => {
|
|
311
314
|
const ctx = createServer(manifest, metrics);
|
|
312
315
|
const port = manifest.settings?.port ?? constants_js_1.DEFAULT_PORT;
|
|
313
|
-
ctx.server.on('error', (err) => {
|
|
316
|
+
ctx.server.on('error', async (err) => {
|
|
314
317
|
if (err.code === 'EADDRINUSE') {
|
|
315
|
-
log(`Port ${port} already in use`);
|
|
316
|
-
|
|
318
|
+
log(`Port ${port} already in use — attempting orphan recovery`);
|
|
319
|
+
// Try to recover: if a clooks daemon is already on this port, re-adopt it
|
|
320
|
+
try {
|
|
321
|
+
const health = await probeHealth(port);
|
|
322
|
+
if (health && health.pid) {
|
|
323
|
+
writePidFile(health.pid);
|
|
324
|
+
log(`Re-adopted orphaned daemon (pid ${health.pid})`);
|
|
325
|
+
resolve(ctx);
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
catch {
|
|
330
|
+
// Probe failed — port is in use by something else
|
|
331
|
+
}
|
|
332
|
+
reject(new Error(`Port ${port} is already in use. Run 'clooks stop' to stop the existing daemon, or 'clooks status' to check.`));
|
|
317
333
|
}
|
|
318
334
|
else {
|
|
319
335
|
log(`Server error: ${err.message}`);
|
|
@@ -425,15 +441,21 @@ function startDaemon(manifest, metrics, options) {
|
|
|
425
441
|
}
|
|
426
442
|
/**
|
|
427
443
|
* Stop a running daemon by reading PID file and sending SIGTERM.
|
|
444
|
+
* If no PID file exists, tries to recover PID from the health endpoint.
|
|
428
445
|
*/
|
|
429
446
|
function stopDaemon() {
|
|
430
|
-
|
|
431
|
-
|
|
447
|
+
let pid = null;
|
|
448
|
+
if ((0, fs_1.existsSync)(constants_js_1.PID_FILE)) {
|
|
449
|
+
const pidStr = (0, fs_1.readFileSync)(constants_js_1.PID_FILE, 'utf-8').trim();
|
|
450
|
+
pid = parseInt(pidStr, 10);
|
|
451
|
+
if (isNaN(pid)) {
|
|
452
|
+
(0, fs_1.unlinkSync)(constants_js_1.PID_FILE);
|
|
453
|
+
pid = null;
|
|
454
|
+
}
|
|
432
455
|
}
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
if (
|
|
436
|
-
(0, fs_1.unlinkSync)(constants_js_1.PID_FILE);
|
|
456
|
+
// If no PID from file, try synchronous recovery via health endpoint
|
|
457
|
+
// We can't await here (sync function), so use stopDaemonAsync for full recovery
|
|
458
|
+
if (pid === null) {
|
|
437
459
|
return false;
|
|
438
460
|
}
|
|
439
461
|
try {
|
|
@@ -462,6 +484,57 @@ function stopDaemon() {
|
|
|
462
484
|
}, 2000);
|
|
463
485
|
return true;
|
|
464
486
|
}
|
|
487
|
+
/**
|
|
488
|
+
* Async version of stopDaemon that can recover orphaned daemons via /health.
|
|
489
|
+
* Returns { stopped: boolean, pid?: number, recovered?: boolean }.
|
|
490
|
+
*/
|
|
491
|
+
async function stopDaemonAsync() {
|
|
492
|
+
let pid = null;
|
|
493
|
+
if ((0, fs_1.existsSync)(constants_js_1.PID_FILE)) {
|
|
494
|
+
const pidStr = (0, fs_1.readFileSync)(constants_js_1.PID_FILE, 'utf-8').trim();
|
|
495
|
+
pid = parseInt(pidStr, 10);
|
|
496
|
+
if (isNaN(pid)) {
|
|
497
|
+
(0, fs_1.unlinkSync)(constants_js_1.PID_FILE);
|
|
498
|
+
pid = null;
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
// No PID file — try health endpoint recovery
|
|
502
|
+
if (pid === null) {
|
|
503
|
+
const health = await probeHealth();
|
|
504
|
+
if (health && health.pid) {
|
|
505
|
+
pid = health.pid;
|
|
506
|
+
log(`Recovered orphaned daemon PID ${pid} from /health for stop`);
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
if (pid === null) {
|
|
510
|
+
return { stopped: false };
|
|
511
|
+
}
|
|
512
|
+
const recovered = !(0, fs_1.existsSync)(constants_js_1.PID_FILE);
|
|
513
|
+
try {
|
|
514
|
+
process.kill(pid, 'SIGTERM');
|
|
515
|
+
}
|
|
516
|
+
catch {
|
|
517
|
+
try {
|
|
518
|
+
if ((0, fs_1.existsSync)(constants_js_1.PID_FILE))
|
|
519
|
+
(0, fs_1.unlinkSync)(constants_js_1.PID_FILE);
|
|
520
|
+
}
|
|
521
|
+
catch {
|
|
522
|
+
// ignore
|
|
523
|
+
}
|
|
524
|
+
return { stopped: false };
|
|
525
|
+
}
|
|
526
|
+
// Clean up PID file after a moment
|
|
527
|
+
setTimeout(() => {
|
|
528
|
+
try {
|
|
529
|
+
if ((0, fs_1.existsSync)(constants_js_1.PID_FILE))
|
|
530
|
+
(0, fs_1.unlinkSync)(constants_js_1.PID_FILE);
|
|
531
|
+
}
|
|
532
|
+
catch {
|
|
533
|
+
// ignore
|
|
534
|
+
}
|
|
535
|
+
}, 2000);
|
|
536
|
+
return { stopped: true, pid, recovered };
|
|
537
|
+
}
|
|
465
538
|
/**
|
|
466
539
|
* Check if daemon is currently running (PID check only).
|
|
467
540
|
* Use for stop/status where a quick check is fine.
|
|
@@ -521,6 +594,42 @@ async function isDaemonHealthy() {
|
|
|
521
594
|
return false;
|
|
522
595
|
}
|
|
523
596
|
}
|
|
597
|
+
/**
|
|
598
|
+
* Probe the health endpoint on the daemon port.
|
|
599
|
+
* Returns the parsed health response (including pid) or null if unreachable.
|
|
600
|
+
*/
|
|
601
|
+
async function probeHealth(port) {
|
|
602
|
+
const p = port ?? constants_js_1.DEFAULT_PORT;
|
|
603
|
+
try {
|
|
604
|
+
const { get } = await import('http');
|
|
605
|
+
const data = await new Promise((resolve, reject) => {
|
|
606
|
+
const req = get(`http://127.0.0.1:${p}/health`, (res) => {
|
|
607
|
+
let body = '';
|
|
608
|
+
res.on('data', (chunk) => { body += chunk.toString(); });
|
|
609
|
+
res.on('end', () => resolve(body));
|
|
610
|
+
});
|
|
611
|
+
req.on('error', reject);
|
|
612
|
+
req.setTimeout(2000, () => { req.destroy(); reject(new Error('timeout')); });
|
|
613
|
+
});
|
|
614
|
+
const health = JSON.parse(data);
|
|
615
|
+
if (health.status === 'ok' && typeof health.pid === 'number') {
|
|
616
|
+
return health;
|
|
617
|
+
}
|
|
618
|
+
return null;
|
|
619
|
+
}
|
|
620
|
+
catch {
|
|
621
|
+
return null;
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
/**
|
|
625
|
+
* Write a PID to the daemon PID file, creating the config dir if needed.
|
|
626
|
+
*/
|
|
627
|
+
function writePidFile(pid) {
|
|
628
|
+
if (!(0, fs_1.existsSync)(constants_js_1.CONFIG_DIR)) {
|
|
629
|
+
(0, fs_1.mkdirSync)(constants_js_1.CONFIG_DIR, { recursive: true });
|
|
630
|
+
}
|
|
631
|
+
(0, fs_1.writeFileSync)(constants_js_1.PID_FILE, String(pid), 'utf-8');
|
|
632
|
+
}
|
|
524
633
|
/**
|
|
525
634
|
* Clean up a stale daemon: remove PID file and attempt to kill the process.
|
|
526
635
|
* Returns the stale PID for logging purposes.
|
package/dist/sync.js
CHANGED
|
@@ -6,6 +6,7 @@ const fs_1 = require("fs");
|
|
|
6
6
|
const path_1 = require("path");
|
|
7
7
|
const os_1 = require("os");
|
|
8
8
|
const manifest_js_1 = require("./manifest.js");
|
|
9
|
+
const import_plugins_js_1 = require("./import-plugins.js");
|
|
9
10
|
const constants_js_1 = require("./constants.js");
|
|
10
11
|
/**
|
|
11
12
|
* Find the Claude Code settings.json path.
|
|
@@ -42,6 +43,20 @@ function syncSettings(options) {
|
|
|
42
43
|
}
|
|
43
44
|
else {
|
|
44
45
|
manifest = (0, manifest_js_1.loadCompositeManifest)();
|
|
46
|
+
// Also merge in Claude Code plugin hooks
|
|
47
|
+
const { handlers: ccHandlers } = (0, import_plugins_js_1.importPlugins)();
|
|
48
|
+
for (const [event, eventHandlers] of Object.entries(ccHandlers)) {
|
|
49
|
+
const hookEvent = event;
|
|
50
|
+
if (!manifest.handlers[hookEvent])
|
|
51
|
+
manifest.handlers[hookEvent] = [];
|
|
52
|
+
// Avoid duplicates: only add handlers whose IDs are not already present
|
|
53
|
+
const existingIds = new Set(manifest.handlers[hookEvent].map(h => h.id));
|
|
54
|
+
for (const handler of eventHandlers) {
|
|
55
|
+
if (!existingIds.has(handler.id)) {
|
|
56
|
+
manifest.handlers[hookEvent].push(handler);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
45
60
|
}
|
|
46
61
|
const port = manifest.settings?.port ?? constants_js_1.DEFAULT_PORT;
|
|
47
62
|
const authToken = manifest.settings?.authToken;
|
package/package.json
CHANGED