@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.
@@ -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
+ }
@@ -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
+ }
@@ -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
@@ -0,0 +1,3 @@
1
+ "use strict";
2
+ // clooks type definitions
3
+ Object.defineProperty(exports, "__esModule", { value: true });
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
+ }