@mauribadnights/clooks 0.1.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/LICENSE +21 -0
- package/README.md +153 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +208 -0
- package/dist/constants.d.ts +10 -0
- package/dist/constants.js +26 -0
- package/dist/doctor.d.ts +5 -0
- package/dist/doctor.js +166 -0
- package/dist/handlers.d.ts +19 -0
- package/dist/handlers.js +192 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +30 -0
- package/dist/manifest.d.ts +15 -0
- package/dist/manifest.js +115 -0
- package/dist/metrics.d.ts +27 -0
- package/dist/metrics.js +122 -0
- package/dist/migrate.d.ts +31 -0
- package/dist/migrate.js +171 -0
- package/dist/server.d.ts +29 -0
- package/dist/server.js +266 -0
- package/dist/types.d.ts +65 -0
- package/dist/types.js +3 -0
- package/package.json +46 -0
package/dist/migrate.js
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// clooks migration utilities — convert shell hooks to HTTP hooks
|
|
3
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
|
+
exports.getSettingsPath = getSettingsPath;
|
|
5
|
+
exports.migrate = migrate;
|
|
6
|
+
exports.restore = restore;
|
|
7
|
+
const fs_1 = require("fs");
|
|
8
|
+
const path_1 = require("path");
|
|
9
|
+
const os_1 = require("os");
|
|
10
|
+
const constants_js_1 = require("./constants.js");
|
|
11
|
+
const yaml_1 = require("yaml");
|
|
12
|
+
/**
|
|
13
|
+
* Find the Claude Code settings.json path.
|
|
14
|
+
*/
|
|
15
|
+
function getSettingsPath(options) {
|
|
16
|
+
const home = options?.homeDir ?? (0, os_1.homedir)();
|
|
17
|
+
const candidates = [
|
|
18
|
+
(0, path_1.join)(home, '.claude', 'settings.local.json'),
|
|
19
|
+
(0, path_1.join)(home, '.claude', 'settings.json'),
|
|
20
|
+
];
|
|
21
|
+
for (const candidate of candidates) {
|
|
22
|
+
if ((0, fs_1.existsSync)(candidate)) {
|
|
23
|
+
return candidate;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Migrate Claude Code settings.json command hooks to clooks HTTP hooks.
|
|
30
|
+
*
|
|
31
|
+
* 1. Read settings.json
|
|
32
|
+
* 2. Extract command hooks → generate manifest.yaml handler entries
|
|
33
|
+
* 3. Back up original settings.json
|
|
34
|
+
* 4. Rewrite settings with HTTP hooks pointing to localhost:7890
|
|
35
|
+
* 5. Keep SessionStart with ensure-running command hook + HTTP hook
|
|
36
|
+
*/
|
|
37
|
+
function migrate(options) {
|
|
38
|
+
const configDir = options?.configDir ?? constants_js_1.CONFIG_DIR;
|
|
39
|
+
const settingsBackup = options?.settingsBackup ?? constants_js_1.SETTINGS_BACKUP;
|
|
40
|
+
const settingsPath = getSettingsPath(options);
|
|
41
|
+
if (!settingsPath) {
|
|
42
|
+
throw new Error('Could not find Claude Code settings.json (checked ~/.claude/settings.json and ~/.claude/settings.local.json)');
|
|
43
|
+
}
|
|
44
|
+
const raw = (0, fs_1.readFileSync)(settingsPath, 'utf-8');
|
|
45
|
+
const settings = JSON.parse(raw);
|
|
46
|
+
if (!settings.hooks || typeof settings.hooks !== 'object') {
|
|
47
|
+
throw new Error('No hooks found in settings.json — nothing to migrate');
|
|
48
|
+
}
|
|
49
|
+
// Check if already migrated (HTTP hooks pointing to clooks inside nested rule groups)
|
|
50
|
+
const alreadyMigrated = Object.values(settings.hooks).some((ruleGroups) => ruleGroups?.some((rule) => rule.hooks?.some((e) => e.type === 'http' && e.url?.includes(`localhost:${constants_js_1.DEFAULT_PORT}`))));
|
|
51
|
+
if (alreadyMigrated) {
|
|
52
|
+
throw new Error('Settings already contain HTTP hooks pointing to clooks. Use "clooks restore" first if you want to re-migrate.');
|
|
53
|
+
}
|
|
54
|
+
// Ensure config dir exists
|
|
55
|
+
if (!(0, fs_1.existsSync)(configDir)) {
|
|
56
|
+
(0, fs_1.mkdirSync)(configDir, { recursive: true });
|
|
57
|
+
}
|
|
58
|
+
// Back up original settings
|
|
59
|
+
(0, fs_1.writeFileSync)(settingsBackup, raw, 'utf-8');
|
|
60
|
+
// Extract command hooks and build manifest
|
|
61
|
+
const manifestHandlers = {};
|
|
62
|
+
let handlerIndex = 0;
|
|
63
|
+
// NOTE: In v0.1, matchers from the original rule groups are not preserved in the
|
|
64
|
+
// migrated HTTP hooks — all command hooks are consolidated into matcher-less rule groups.
|
|
65
|
+
// This is acceptable because clooks dispatches based on event type, not matchers.
|
|
66
|
+
for (const [eventName, ruleGroups] of Object.entries(settings.hooks)) {
|
|
67
|
+
if (!constants_js_1.HOOK_EVENTS.includes(eventName) || !Array.isArray(ruleGroups))
|
|
68
|
+
continue;
|
|
69
|
+
const event = eventName;
|
|
70
|
+
// Flatten command hooks from ALL rule groups for this event
|
|
71
|
+
const commandHooks = [];
|
|
72
|
+
for (const rule of ruleGroups) {
|
|
73
|
+
if (!Array.isArray(rule.hooks))
|
|
74
|
+
continue;
|
|
75
|
+
for (const entry of rule.hooks) {
|
|
76
|
+
if (entry.type === 'command' && entry.command) {
|
|
77
|
+
commandHooks.push(entry);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
if (commandHooks.length === 0)
|
|
82
|
+
continue;
|
|
83
|
+
manifestHandlers[event] = commandHooks.map((hook) => {
|
|
84
|
+
handlerIndex++;
|
|
85
|
+
return {
|
|
86
|
+
id: `migrated-${event.toLowerCase()}-${handlerIndex}`,
|
|
87
|
+
type: 'script',
|
|
88
|
+
command: hook.command,
|
|
89
|
+
timeout: hook.timeout ? hook.timeout * 1000 : 5000, // Claude uses seconds, we use ms
|
|
90
|
+
enabled: true,
|
|
91
|
+
};
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
// Write manifest.yaml
|
|
95
|
+
const manifest = {
|
|
96
|
+
handlers: manifestHandlers,
|
|
97
|
+
settings: { port: constants_js_1.DEFAULT_PORT, logLevel: 'info' },
|
|
98
|
+
};
|
|
99
|
+
const yamlStr = '# clooks manifest — auto-generated by migrate\n' +
|
|
100
|
+
`# Migrated from: ${settingsPath}\n` +
|
|
101
|
+
`# Date: ${new Date().toISOString()}\n\n` +
|
|
102
|
+
(0, yaml_1.stringify)(manifest);
|
|
103
|
+
const manifestPath = (0, path_1.join)(configDir, 'manifest.yaml');
|
|
104
|
+
(0, fs_1.writeFileSync)(manifestPath, yamlStr, 'utf-8');
|
|
105
|
+
// Rewrite settings.json with HTTP hooks in the nested rule group format
|
|
106
|
+
const newHooks = {};
|
|
107
|
+
for (const eventName of constants_js_1.HOOK_EVENTS) {
|
|
108
|
+
const hadHandlers = manifestHandlers[eventName]?.length ?? 0;
|
|
109
|
+
// Also check if there were existing non-command hooks to preserve (flatten from rule groups)
|
|
110
|
+
const existingNonCommand = [];
|
|
111
|
+
for (const rule of (settings.hooks[eventName] ?? [])) {
|
|
112
|
+
if (!Array.isArray(rule.hooks))
|
|
113
|
+
continue;
|
|
114
|
+
for (const entry of rule.hooks) {
|
|
115
|
+
if (entry.type !== 'command') {
|
|
116
|
+
existingNonCommand.push(entry);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
if (hadHandlers === 0 && existingNonCommand.length === 0 && eventName !== 'SessionStart')
|
|
121
|
+
continue;
|
|
122
|
+
const hookEntries = [...existingNonCommand];
|
|
123
|
+
// For SessionStart, add ensure-running command hook
|
|
124
|
+
if (eventName === 'SessionStart') {
|
|
125
|
+
hookEntries.push({
|
|
126
|
+
type: 'command',
|
|
127
|
+
command: 'clooks ensure-running',
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
// Add HTTP hook
|
|
131
|
+
if (hadHandlers > 0) {
|
|
132
|
+
hookEntries.push({
|
|
133
|
+
type: 'http',
|
|
134
|
+
url: `http://localhost:${constants_js_1.DEFAULT_PORT}/hooks/${eventName}`,
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
if (hookEntries.length > 0) {
|
|
138
|
+
// Wrap in a single rule group (no matcher — clooks handles dispatch)
|
|
139
|
+
newHooks[eventName] = [{ hooks: hookEntries }];
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
// Ensure SessionStart always has ensure-running even if no hooks were migrated for it
|
|
143
|
+
if (!newHooks['SessionStart']) {
|
|
144
|
+
newHooks['SessionStart'] = [
|
|
145
|
+
{ hooks: [{ type: 'command', command: 'clooks ensure-running' }] },
|
|
146
|
+
];
|
|
147
|
+
}
|
|
148
|
+
settings.hooks = newHooks;
|
|
149
|
+
(0, fs_1.writeFileSync)(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
|
|
150
|
+
return {
|
|
151
|
+
manifestPath,
|
|
152
|
+
settingsPath,
|
|
153
|
+
handlersCreated: handlerIndex,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Restore original settings.json from backup.
|
|
158
|
+
*/
|
|
159
|
+
function restore(options) {
|
|
160
|
+
const settingsBackup = options?.settingsBackup ?? constants_js_1.SETTINGS_BACKUP;
|
|
161
|
+
if (!(0, fs_1.existsSync)(settingsBackup)) {
|
|
162
|
+
throw new Error('No backup found at ' + settingsBackup);
|
|
163
|
+
}
|
|
164
|
+
const settingsPath = getSettingsPath(options);
|
|
165
|
+
if (!settingsPath) {
|
|
166
|
+
throw new Error('Could not find Claude Code settings.json to restore');
|
|
167
|
+
}
|
|
168
|
+
const backup = (0, fs_1.readFileSync)(settingsBackup, 'utf-8');
|
|
169
|
+
(0, fs_1.writeFileSync)(settingsPath, backup, 'utf-8');
|
|
170
|
+
return settingsPath;
|
|
171
|
+
}
|
package/dist/server.d.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { type Server } from 'http';
|
|
2
|
+
import { MetricsCollector } from './metrics.js';
|
|
3
|
+
import type { Manifest } from './types.js';
|
|
4
|
+
export interface ServerContext {
|
|
5
|
+
server: Server;
|
|
6
|
+
metrics: MetricsCollector;
|
|
7
|
+
startTime: number;
|
|
8
|
+
manifest: Manifest;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Create the HTTP server for hook handling.
|
|
12
|
+
*/
|
|
13
|
+
export declare function createServer(manifest: Manifest, metrics: MetricsCollector): ServerContext;
|
|
14
|
+
/**
|
|
15
|
+
* Start the daemon: bind the server and write PID file.
|
|
16
|
+
*/
|
|
17
|
+
export declare function startDaemon(manifest: Manifest, metrics: MetricsCollector): Promise<ServerContext>;
|
|
18
|
+
/**
|
|
19
|
+
* Stop a running daemon by reading PID file and sending SIGTERM.
|
|
20
|
+
*/
|
|
21
|
+
export declare function stopDaemon(): boolean;
|
|
22
|
+
/**
|
|
23
|
+
* Check if daemon is currently running.
|
|
24
|
+
*/
|
|
25
|
+
export declare function isDaemonRunning(): boolean;
|
|
26
|
+
/**
|
|
27
|
+
* Start daemon as a detached background process.
|
|
28
|
+
*/
|
|
29
|
+
export declare function startDaemonBackground(): void;
|
package/dist/server.js
ADDED
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// clooks HTTP server — persistent hook daemon
|
|
3
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
|
+
exports.createServer = createServer;
|
|
5
|
+
exports.startDaemon = startDaemon;
|
|
6
|
+
exports.stopDaemon = stopDaemon;
|
|
7
|
+
exports.isDaemonRunning = isDaemonRunning;
|
|
8
|
+
exports.startDaemonBackground = startDaemonBackground;
|
|
9
|
+
const http_1 = require("http");
|
|
10
|
+
const fs_1 = require("fs");
|
|
11
|
+
const child_process_1 = require("child_process");
|
|
12
|
+
const handlers_js_1 = require("./handlers.js");
|
|
13
|
+
const constants_js_1 = require("./constants.js");
|
|
14
|
+
function log(msg) {
|
|
15
|
+
const line = `[${new Date().toISOString()}] ${msg}\n`;
|
|
16
|
+
try {
|
|
17
|
+
if (!(0, fs_1.existsSync)(constants_js_1.CONFIG_DIR)) {
|
|
18
|
+
(0, fs_1.mkdirSync)(constants_js_1.CONFIG_DIR, { recursive: true });
|
|
19
|
+
}
|
|
20
|
+
(0, fs_1.appendFileSync)(constants_js_1.LOG_FILE, line, 'utf-8');
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
// If we can't write logs, continue anyway
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Merge multiple handler results into a single HTTP response body.
|
|
28
|
+
*
|
|
29
|
+
* - additionalContext: joined with newlines from all results that have it
|
|
30
|
+
* - hookSpecificOutput: last writer wins
|
|
31
|
+
* - decision/reason: last writer wins (for PostToolUse/Stop block decisions)
|
|
32
|
+
*/
|
|
33
|
+
function mergeResults(results) {
|
|
34
|
+
const merged = {};
|
|
35
|
+
const contexts = [];
|
|
36
|
+
for (const result of results) {
|
|
37
|
+
if (!result.ok || !result.output || typeof result.output !== 'object')
|
|
38
|
+
continue;
|
|
39
|
+
const out = result.output;
|
|
40
|
+
if (typeof out.additionalContext === 'string' && out.additionalContext) {
|
|
41
|
+
contexts.push(out.additionalContext);
|
|
42
|
+
}
|
|
43
|
+
if (out.hookSpecificOutput !== undefined) {
|
|
44
|
+
merged.hookSpecificOutput = out.hookSpecificOutput;
|
|
45
|
+
}
|
|
46
|
+
if (out.decision !== undefined) {
|
|
47
|
+
merged.decision = out.decision;
|
|
48
|
+
}
|
|
49
|
+
if (out.reason !== undefined) {
|
|
50
|
+
merged.reason = out.reason;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
if (contexts.length > 0) {
|
|
54
|
+
merged.additionalContext = contexts.join('\n');
|
|
55
|
+
}
|
|
56
|
+
return merged;
|
|
57
|
+
}
|
|
58
|
+
function readBody(req) {
|
|
59
|
+
return new Promise((resolve, reject) => {
|
|
60
|
+
let body = '';
|
|
61
|
+
req.on('data', (chunk) => {
|
|
62
|
+
body += chunk.toString();
|
|
63
|
+
});
|
|
64
|
+
req.on('end', () => resolve(body));
|
|
65
|
+
req.on('error', reject);
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
function sendJson(res, status, data) {
|
|
69
|
+
const body = JSON.stringify(data);
|
|
70
|
+
res.writeHead(status, {
|
|
71
|
+
'Content-Type': 'application/json',
|
|
72
|
+
'Content-Length': Buffer.byteLength(body),
|
|
73
|
+
});
|
|
74
|
+
res.end(body);
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Create the HTTP server for hook handling.
|
|
78
|
+
*/
|
|
79
|
+
function createServer(manifest, metrics) {
|
|
80
|
+
const startTime = Date.now();
|
|
81
|
+
const server = (0, http_1.createServer)(async (req, res) => {
|
|
82
|
+
const url = req.url ?? '/';
|
|
83
|
+
const method = req.method ?? 'GET';
|
|
84
|
+
// Health check endpoint
|
|
85
|
+
if (method === 'GET' && url === '/health') {
|
|
86
|
+
const handlerCount = Object.values(manifest.handlers)
|
|
87
|
+
.reduce((sum, arr) => sum + (arr?.length ?? 0), 0);
|
|
88
|
+
sendJson(res, 200, {
|
|
89
|
+
status: 'ok',
|
|
90
|
+
uptime: Math.floor((Date.now() - startTime) / 1000),
|
|
91
|
+
handlers_loaded: handlerCount,
|
|
92
|
+
port: manifest.settings?.port ?? constants_js_1.DEFAULT_PORT,
|
|
93
|
+
});
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
// Hook endpoint: POST /hooks/:eventName
|
|
97
|
+
const hookMatch = url.match(/^\/hooks\/([A-Za-z]+)$/);
|
|
98
|
+
if (method === 'POST' && hookMatch) {
|
|
99
|
+
const eventName = hookMatch[1];
|
|
100
|
+
if (!constants_js_1.HOOK_EVENTS.includes(eventName)) {
|
|
101
|
+
sendJson(res, 400, { error: `Unknown hook event: ${eventName}` });
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
const event = eventName;
|
|
105
|
+
const handlers = manifest.handlers[event] ?? [];
|
|
106
|
+
if (handlers.length === 0) {
|
|
107
|
+
sendJson(res, 200, {});
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
let input;
|
|
111
|
+
try {
|
|
112
|
+
const body = await readBody(req);
|
|
113
|
+
input = JSON.parse(body);
|
|
114
|
+
}
|
|
115
|
+
catch (err) {
|
|
116
|
+
log(`Failed to parse request body for ${eventName}: ${err}`);
|
|
117
|
+
sendJson(res, 400, { error: 'Invalid JSON body' });
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
log(`Hook: ${eventName} (${handlers.length} handler${handlers.length > 1 ? 's' : ''})`);
|
|
121
|
+
try {
|
|
122
|
+
const results = await (0, handlers_js_1.executeHandlers)(event, input, handlers);
|
|
123
|
+
// Record metrics
|
|
124
|
+
for (const result of results) {
|
|
125
|
+
metrics.record({
|
|
126
|
+
ts: new Date().toISOString(),
|
|
127
|
+
event,
|
|
128
|
+
handler: result.id,
|
|
129
|
+
duration_ms: result.duration_ms,
|
|
130
|
+
ok: result.ok,
|
|
131
|
+
error: result.error,
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
const merged = mergeResults(results);
|
|
135
|
+
log(` -> ${results.filter((r) => r.ok).length}/${results.length} ok, response keys: ${Object.keys(merged).join(', ') || '(empty)'}`);
|
|
136
|
+
sendJson(res, 200, merged);
|
|
137
|
+
}
|
|
138
|
+
catch (err) {
|
|
139
|
+
log(`Error executing handlers for ${eventName}: ${err}`);
|
|
140
|
+
sendJson(res, 500, { error: 'Internal handler error' });
|
|
141
|
+
}
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
// 404 for everything else
|
|
145
|
+
sendJson(res, 404, { error: 'Not found' });
|
|
146
|
+
});
|
|
147
|
+
return { server, metrics, startTime, manifest };
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Start the daemon: bind the server and write PID file.
|
|
151
|
+
*/
|
|
152
|
+
function startDaemon(manifest, metrics) {
|
|
153
|
+
return new Promise((resolve, reject) => {
|
|
154
|
+
const ctx = createServer(manifest, metrics);
|
|
155
|
+
const port = manifest.settings?.port ?? constants_js_1.DEFAULT_PORT;
|
|
156
|
+
ctx.server.on('error', (err) => {
|
|
157
|
+
if (err.code === 'EADDRINUSE') {
|
|
158
|
+
log(`Port ${port} already in use`);
|
|
159
|
+
reject(new Error(`Port ${port} is already in use. Is another clooks instance running?`));
|
|
160
|
+
}
|
|
161
|
+
else {
|
|
162
|
+
log(`Server error: ${err.message}`);
|
|
163
|
+
reject(err);
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
ctx.server.listen(port, '127.0.0.1', () => {
|
|
167
|
+
// Write PID file
|
|
168
|
+
if (!(0, fs_1.existsSync)(constants_js_1.CONFIG_DIR)) {
|
|
169
|
+
(0, fs_1.mkdirSync)(constants_js_1.CONFIG_DIR, { recursive: true });
|
|
170
|
+
}
|
|
171
|
+
(0, fs_1.writeFileSync)(constants_js_1.PID_FILE, String(process.pid), 'utf-8');
|
|
172
|
+
log(`Daemon started on 127.0.0.1:${port} (pid ${process.pid})`);
|
|
173
|
+
resolve(ctx);
|
|
174
|
+
});
|
|
175
|
+
// Graceful shutdown
|
|
176
|
+
const shutdown = () => {
|
|
177
|
+
log('Shutting down...');
|
|
178
|
+
ctx.server.close(() => {
|
|
179
|
+
try {
|
|
180
|
+
if ((0, fs_1.existsSync)(constants_js_1.PID_FILE))
|
|
181
|
+
(0, fs_1.unlinkSync)(constants_js_1.PID_FILE);
|
|
182
|
+
}
|
|
183
|
+
catch {
|
|
184
|
+
// ignore
|
|
185
|
+
}
|
|
186
|
+
metrics.flush();
|
|
187
|
+
log('Daemon stopped.');
|
|
188
|
+
process.exit(0);
|
|
189
|
+
});
|
|
190
|
+
// Force exit after 5s
|
|
191
|
+
setTimeout(() => {
|
|
192
|
+
process.exit(1);
|
|
193
|
+
}, 5000);
|
|
194
|
+
};
|
|
195
|
+
process.on('SIGTERM', shutdown);
|
|
196
|
+
process.on('SIGINT', shutdown);
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* Stop a running daemon by reading PID file and sending SIGTERM.
|
|
201
|
+
*/
|
|
202
|
+
function stopDaemon() {
|
|
203
|
+
if (!(0, fs_1.existsSync)(constants_js_1.PID_FILE)) {
|
|
204
|
+
return false;
|
|
205
|
+
}
|
|
206
|
+
const pidStr = (0, fs_1.readFileSync)(constants_js_1.PID_FILE, 'utf-8').trim();
|
|
207
|
+
const pid = parseInt(pidStr, 10);
|
|
208
|
+
if (isNaN(pid)) {
|
|
209
|
+
(0, fs_1.unlinkSync)(constants_js_1.PID_FILE);
|
|
210
|
+
return false;
|
|
211
|
+
}
|
|
212
|
+
try {
|
|
213
|
+
process.kill(pid, 'SIGTERM');
|
|
214
|
+
}
|
|
215
|
+
catch (err) {
|
|
216
|
+
// Process doesn't exist — clean up stale PID
|
|
217
|
+
try {
|
|
218
|
+
(0, fs_1.unlinkSync)(constants_js_1.PID_FILE);
|
|
219
|
+
}
|
|
220
|
+
catch {
|
|
221
|
+
// ignore
|
|
222
|
+
}
|
|
223
|
+
return false;
|
|
224
|
+
}
|
|
225
|
+
// Give it a moment, then clean up PID file
|
|
226
|
+
// The daemon itself should remove it, but clean up just in case
|
|
227
|
+
setTimeout(() => {
|
|
228
|
+
try {
|
|
229
|
+
if ((0, fs_1.existsSync)(constants_js_1.PID_FILE))
|
|
230
|
+
(0, fs_1.unlinkSync)(constants_js_1.PID_FILE);
|
|
231
|
+
}
|
|
232
|
+
catch {
|
|
233
|
+
// ignore
|
|
234
|
+
}
|
|
235
|
+
}, 2000);
|
|
236
|
+
return true;
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* Check if daemon is currently running.
|
|
240
|
+
*/
|
|
241
|
+
function isDaemonRunning() {
|
|
242
|
+
if (!(0, fs_1.existsSync)(constants_js_1.PID_FILE))
|
|
243
|
+
return false;
|
|
244
|
+
const pidStr = (0, fs_1.readFileSync)(constants_js_1.PID_FILE, 'utf-8').trim();
|
|
245
|
+
const pid = parseInt(pidStr, 10);
|
|
246
|
+
if (isNaN(pid))
|
|
247
|
+
return false;
|
|
248
|
+
try {
|
|
249
|
+
// Signal 0 tests if process exists
|
|
250
|
+
process.kill(pid, 0);
|
|
251
|
+
return true;
|
|
252
|
+
}
|
|
253
|
+
catch {
|
|
254
|
+
return false;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
/**
|
|
258
|
+
* Start daemon as a detached background process.
|
|
259
|
+
*/
|
|
260
|
+
function startDaemonBackground() {
|
|
261
|
+
const child = (0, child_process_1.spawn)(process.execPath, [process.argv[1], 'start', '--foreground'], {
|
|
262
|
+
detached: true,
|
|
263
|
+
stdio: 'ignore',
|
|
264
|
+
});
|
|
265
|
+
child.unref();
|
|
266
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/** Hook event names supported by Claude Code */
|
|
2
|
+
export type HookEvent = 'SessionStart' | 'UserPromptSubmit' | 'PreToolUse' | 'PostToolUse' | 'Stop' | 'SubagentStart' | 'SubagentStop' | 'Notification' | 'ConfigChange';
|
|
3
|
+
/** Hook input payload from Claude Code */
|
|
4
|
+
export interface HookInput {
|
|
5
|
+
session_id: string;
|
|
6
|
+
transcript_path: string;
|
|
7
|
+
cwd: string;
|
|
8
|
+
permission_mode: string;
|
|
9
|
+
hook_event_name: HookEvent;
|
|
10
|
+
prompt?: string;
|
|
11
|
+
tool_name?: string;
|
|
12
|
+
tool_input?: Record<string, unknown>;
|
|
13
|
+
source?: string;
|
|
14
|
+
stop_hook_active?: boolean;
|
|
15
|
+
[key: string]: unknown;
|
|
16
|
+
}
|
|
17
|
+
/** Handler types in manifest */
|
|
18
|
+
export type HandlerType = 'script' | 'inline';
|
|
19
|
+
/** Configuration for a single handler */
|
|
20
|
+
export interface HandlerConfig {
|
|
21
|
+
id: string;
|
|
22
|
+
type: HandlerType;
|
|
23
|
+
command?: string;
|
|
24
|
+
module?: string;
|
|
25
|
+
timeout?: number;
|
|
26
|
+
enabled?: boolean;
|
|
27
|
+
}
|
|
28
|
+
/** The full manifest structure */
|
|
29
|
+
export interface Manifest {
|
|
30
|
+
handlers: Partial<Record<HookEvent, HandlerConfig[]>>;
|
|
31
|
+
settings?: {
|
|
32
|
+
port?: number;
|
|
33
|
+
logLevel?: 'debug' | 'info' | 'warn' | 'error';
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
/** Result from executing a single handler */
|
|
37
|
+
export interface HandlerResult {
|
|
38
|
+
id: string;
|
|
39
|
+
ok: boolean;
|
|
40
|
+
output?: unknown;
|
|
41
|
+
error?: string;
|
|
42
|
+
duration_ms: number;
|
|
43
|
+
}
|
|
44
|
+
/** A single metrics entry */
|
|
45
|
+
export interface MetricEntry {
|
|
46
|
+
ts: string;
|
|
47
|
+
event: HookEvent;
|
|
48
|
+
handler: string;
|
|
49
|
+
duration_ms: number;
|
|
50
|
+
ok: boolean;
|
|
51
|
+
error?: string;
|
|
52
|
+
}
|
|
53
|
+
/** Runtime state for tracking consecutive failures */
|
|
54
|
+
export interface HandlerState {
|
|
55
|
+
consecutiveFailures: number;
|
|
56
|
+
disabled: boolean;
|
|
57
|
+
totalFires: number;
|
|
58
|
+
totalErrors: number;
|
|
59
|
+
}
|
|
60
|
+
/** Diagnostic result from doctor checks */
|
|
61
|
+
export interface DiagnosticResult {
|
|
62
|
+
check: string;
|
|
63
|
+
status: 'ok' | 'warn' | 'error';
|
|
64
|
+
message: string;
|
|
65
|
+
}
|
package/dist/types.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@mauribadnights/clooks",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Persistent hook runtime for Claude Code — eliminates process spawning overhead and gives you observability",
|
|
5
|
+
"bin": {
|
|
6
|
+
"clooks": "./dist/cli.js"
|
|
7
|
+
},
|
|
8
|
+
"main": "./dist/index.js",
|
|
9
|
+
"types": "./dist/index.d.ts",
|
|
10
|
+
"scripts": {
|
|
11
|
+
"build": "tsc",
|
|
12
|
+
"dev": "tsc --watch",
|
|
13
|
+
"test": "vitest run",
|
|
14
|
+
"test:watch": "vitest"
|
|
15
|
+
},
|
|
16
|
+
"license": "MIT",
|
|
17
|
+
"author": "mauribadnights",
|
|
18
|
+
"repository": {
|
|
19
|
+
"type": "git",
|
|
20
|
+
"url": "https://github.com/mauribadnights/clooks.git"
|
|
21
|
+
},
|
|
22
|
+
"keywords": [
|
|
23
|
+
"claude",
|
|
24
|
+
"claude-code",
|
|
25
|
+
"hooks",
|
|
26
|
+
"daemon",
|
|
27
|
+
"cli"
|
|
28
|
+
],
|
|
29
|
+
"engines": {
|
|
30
|
+
"node": ">=18.0.0"
|
|
31
|
+
},
|
|
32
|
+
"files": [
|
|
33
|
+
"dist",
|
|
34
|
+
"README.md",
|
|
35
|
+
"LICENSE"
|
|
36
|
+
],
|
|
37
|
+
"dependencies": {
|
|
38
|
+
"commander": "^14.0.3",
|
|
39
|
+
"yaml": "^2.8.3"
|
|
40
|
+
},
|
|
41
|
+
"devDependencies": {
|
|
42
|
+
"@types/node": "^25.5.0",
|
|
43
|
+
"typescript": "^6.0.2",
|
|
44
|
+
"vitest": "^4.1.1"
|
|
45
|
+
}
|
|
46
|
+
}
|