@mauribadnights/clooks 0.3.0 → 0.3.2

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 CHANGED
@@ -10,6 +10,7 @@ const migrate_js_1 = require("./migrate.js");
10
10
  const doctor_js_1 = require("./doctor.js");
11
11
  const auth_js_1 = require("./auth.js");
12
12
  const plugin_js_1 = require("./plugin.js");
13
+ const sync_js_1 = require("./sync.js");
13
14
  const constants_js_1 = require("./constants.js");
14
15
  const fs_1 = require("fs");
15
16
  const path_1 = require("path");
@@ -17,7 +18,7 @@ const program = new commander_1.Command();
17
18
  program
18
19
  .name('clooks')
19
20
  .description('Persistent hook runtime for Claude Code')
20
- .version('0.3.0');
21
+ .version('0.3.2');
21
22
  // --- start ---
22
23
  program
23
24
  .command('start')
@@ -40,6 +41,11 @@ program
40
41
  (0, server_js_1.startDaemonBackground)({ noWatch });
41
42
  // Give it a moment to start
42
43
  await new Promise((r) => setTimeout(r, 500));
44
+ // Sync settings.json with manifest
45
+ const syncAdded = (0, sync_js_1.syncSettings)();
46
+ if (syncAdded.length > 0) {
47
+ console.log(`Synced HTTP hooks for: ${syncAdded.join(', ')}`);
48
+ }
43
49
  if ((0, server_js_1.isDaemonRunning)()) {
44
50
  const pid = (0, fs_1.readFileSync)(constants_js_1.PID_FILE, 'utf-8').trim();
45
51
  console.log(`Daemon started (pid ${pid}), listening on 127.0.0.1:${constants_js_1.DEFAULT_PORT}`);
@@ -120,6 +126,9 @@ program
120
126
  .action(() => {
121
127
  const metrics = new metrics_js_1.MetricsCollector();
122
128
  console.log(metrics.formatStatsTable());
129
+ console.log('');
130
+ console.log('Per Handler:');
131
+ console.log(metrics.formatHandlerStatsTable());
123
132
  // Append cost summary if LLM data exists
124
133
  const costStats = metrics.getCostStats();
125
134
  if (costStats.totalCost > 0) {
@@ -184,13 +193,31 @@ program
184
193
  if (errors > 0)
185
194
  process.exit(1);
186
195
  });
196
+ // --- sync ---
197
+ program
198
+ .command('sync')
199
+ .description('Sync settings.json with manifest (add missing HTTP hook entries)')
200
+ .action(() => {
201
+ const added = (0, sync_js_1.syncSettings)();
202
+ if (added.length === 0) {
203
+ console.log('Settings already in sync.');
204
+ }
205
+ else {
206
+ console.log(`Added HTTP hooks for: ${added.join(', ')}`);
207
+ const settingsPath = (0, migrate_js_1.getSettingsPath)();
208
+ if (settingsPath) {
209
+ console.log(`Settings updated: ${settingsPath}`);
210
+ }
211
+ }
212
+ });
187
213
  // --- ensure-running ---
188
214
  program
189
215
  .command('ensure-running')
190
216
  .description('Start daemon if not already running (used by SessionStart hook)')
191
217
  .action(async () => {
192
218
  if ((0, server_js_1.isDaemonRunning)()) {
193
- // Already running — exit silently and fast
219
+ // Already running — sync settings silently and exit fast
220
+ (0, sync_js_1.syncSettings)();
194
221
  process.exit(0);
195
222
  }
196
223
  // Ensure config dir exists
@@ -203,6 +230,8 @@ program
203
230
  (0, manifest_js_1.createDefaultManifest)();
204
231
  }
205
232
  (0, server_js_1.startDaemonBackground)();
233
+ // Sync settings silently after starting
234
+ (0, sync_js_1.syncSettings)();
206
235
  process.exit(0);
207
236
  });
208
237
  // --- init ---
@@ -291,6 +320,11 @@ program
291
320
  ? Object.values(installed.manifest.handlers).reduce((sum, arr) => sum + (arr?.length ?? 0), 0)
292
321
  : 0;
293
322
  console.log(`Installed plugin ${plugin.name} v${plugin.version} (${handlerCount} handlers)`);
323
+ // Sync settings.json to add HTTP hooks for any new events
324
+ const syncAdded = (0, sync_js_1.syncSettings)();
325
+ if (syncAdded.length > 0) {
326
+ console.log(`Synced HTTP hooks for: ${syncAdded.join(', ')}`);
327
+ }
294
328
  }
295
329
  catch (err) {
296
330
  console.error('Plugin install failed:', err instanceof Error ? err.message : err);
package/dist/index.d.ts CHANGED
@@ -11,6 +11,8 @@ export { DenyCache } from './shortcircuit.js';
11
11
  export { RateLimiter } from './ratelimit.js';
12
12
  export { startWatcher, stopWatcher } from './watcher.js';
13
13
  export { generateAuthToken, validateAuth, rotateToken } from './auth.js';
14
+ export { syncSettings } from './sync.js';
15
+ export type { SyncOptions } from './sync.js';
14
16
  export type { RotateTokenOptions } from './auth.js';
15
17
  export { evaluateFilter } from './filter.js';
16
18
  export { executeLLMHandler, executeLLMHandlersBatched, calculateCost, resetClient } from './llm.js';
package/dist/index.js CHANGED
@@ -1,8 +1,8 @@
1
1
  "use strict";
2
2
  // clooks — public API exports
3
3
  Object.defineProperty(exports, "__esModule", { value: true });
4
- exports.PLUGINS_DIR = exports.LLM_PRICING = exports.DEFAULT_LLM_MAX_TOKENS = exports.DEFAULT_LLM_TIMEOUT = exports.COSTS_FILE = exports.LOG_FILE = exports.METRICS_FILE = exports.PID_FILE = exports.MANIFEST_PATH = exports.CONFIG_DIR = exports.DEFAULT_PORT = exports.renderPromptTemplate = exports.prefetchContext = exports.resetClient = exports.calculateCost = exports.executeLLMHandlersBatched = exports.executeLLMHandler = exports.evaluateFilter = exports.rotateToken = exports.validateAuth = exports.generateAuthToken = exports.stopWatcher = exports.startWatcher = exports.RateLimiter = exports.DenyCache = exports.resolveExecutionOrder = exports.cleanupHandlerState = exports.resetSessionIsolatedHandlers = exports.executeHandlers = exports.runDoctor = exports.getSettingsPath = exports.restore = exports.migrate = exports.MetricsCollector = exports.listPlugins = exports.uninstallPlugin = exports.installPlugin = exports.saveRegistry = exports.loadRegistry = exports.validatePluginManifest = exports.mergeManifests = exports.loadPlugins = exports.createDefaultManifest = exports.validateManifest = exports.loadCompositeManifest = exports.loadManifest = exports.isDaemonRunning = exports.stopDaemon = exports.startDaemon = exports.createServer = void 0;
5
- exports.PLUGIN_MANIFEST_NAME = exports.PLUGIN_REGISTRY = void 0;
4
+ exports.LLM_PRICING = exports.DEFAULT_LLM_MAX_TOKENS = exports.DEFAULT_LLM_TIMEOUT = exports.COSTS_FILE = exports.LOG_FILE = exports.METRICS_FILE = exports.PID_FILE = exports.MANIFEST_PATH = exports.CONFIG_DIR = exports.DEFAULT_PORT = exports.renderPromptTemplate = exports.prefetchContext = exports.resetClient = exports.calculateCost = exports.executeLLMHandlersBatched = exports.executeLLMHandler = exports.evaluateFilter = exports.syncSettings = exports.rotateToken = exports.validateAuth = exports.generateAuthToken = exports.stopWatcher = exports.startWatcher = exports.RateLimiter = exports.DenyCache = exports.resolveExecutionOrder = exports.cleanupHandlerState = exports.resetSessionIsolatedHandlers = exports.executeHandlers = exports.runDoctor = exports.getSettingsPath = exports.restore = exports.migrate = exports.MetricsCollector = exports.listPlugins = exports.uninstallPlugin = exports.installPlugin = exports.saveRegistry = exports.loadRegistry = exports.validatePluginManifest = exports.mergeManifests = exports.loadPlugins = exports.createDefaultManifest = exports.validateManifest = exports.loadCompositeManifest = exports.loadManifest = exports.isDaemonRunning = exports.stopDaemon = exports.startDaemon = exports.createServer = void 0;
5
+ exports.PLUGIN_MANIFEST_NAME = exports.PLUGIN_REGISTRY = exports.PLUGINS_DIR = void 0;
6
6
  var server_js_1 = require("./server.js");
7
7
  Object.defineProperty(exports, "createServer", { enumerable: true, get: function () { return server_js_1.createServer; } });
8
8
  Object.defineProperty(exports, "startDaemon", { enumerable: true, get: function () { return server_js_1.startDaemon; } });
@@ -47,6 +47,8 @@ var auth_js_1 = require("./auth.js");
47
47
  Object.defineProperty(exports, "generateAuthToken", { enumerable: true, get: function () { return auth_js_1.generateAuthToken; } });
48
48
  Object.defineProperty(exports, "validateAuth", { enumerable: true, get: function () { return auth_js_1.validateAuth; } });
49
49
  Object.defineProperty(exports, "rotateToken", { enumerable: true, get: function () { return auth_js_1.rotateToken; } });
50
+ var sync_js_1 = require("./sync.js");
51
+ Object.defineProperty(exports, "syncSettings", { enumerable: true, get: function () { return sync_js_1.syncSettings; } });
50
52
  var filter_js_1 = require("./filter.js");
51
53
  Object.defineProperty(exports, "evaluateFilter", { enumerable: true, get: function () { return filter_js_1.evaluateFilter; } });
52
54
  var llm_js_1 = require("./llm.js");
package/dist/metrics.d.ts CHANGED
@@ -7,6 +7,16 @@ interface AggregatedStats {
7
7
  minDuration: number;
8
8
  maxDuration: number;
9
9
  }
10
+ export interface HandlerStats {
11
+ handler: string;
12
+ event: string;
13
+ fires: number;
14
+ errors: number;
15
+ filtered: number;
16
+ avgDuration: number;
17
+ minDuration: number;
18
+ maxDuration: number;
19
+ }
10
20
  export declare class MetricsCollector {
11
21
  private static readonly MAX_ENTRIES;
12
22
  private entries;
@@ -22,6 +32,10 @@ export declare class MetricsCollector {
22
32
  getStats(): AggregatedStats[];
23
33
  /** Get stats for a specific session. */
24
34
  getSessionStats(sessionId: string): AggregatedStats[];
35
+ /** Get per-handler stats (not just per-event). */
36
+ getHandlerStats(): HandlerStats[];
37
+ /** Format per-handler stats as a CLI-friendly table. */
38
+ formatHandlerStatsTable(): string;
25
39
  /** Flush is a no-op since we append on every record, but provided for API completeness. */
26
40
  flush(): void;
27
41
  /** Format stats as a CLI-friendly table. */
package/dist/metrics.js CHANGED
@@ -95,6 +95,53 @@ class MetricsCollector {
95
95
  }
96
96
  return stats.sort((a, b) => b.fires - a.fires);
97
97
  }
98
+ /** Get per-handler stats (not just per-event). */
99
+ getHandlerStats() {
100
+ const all = this.loadAll();
101
+ const byHandler = new Map();
102
+ for (const entry of all) {
103
+ const existing = byHandler.get(entry.handler) ?? [];
104
+ existing.push(entry);
105
+ byHandler.set(entry.handler, existing);
106
+ }
107
+ const stats = [];
108
+ for (const [handler, entries] of byHandler) {
109
+ const durations = entries.map((e) => e.duration_ms);
110
+ stats.push({
111
+ handler,
112
+ event: entries[0].event,
113
+ fires: entries.length,
114
+ errors: entries.filter((e) => !e.ok).length,
115
+ filtered: entries.filter((e) => e.filtered).length,
116
+ avgDuration: durations.reduce((a, b) => a + b, 0) / durations.length,
117
+ minDuration: Math.min(...durations),
118
+ maxDuration: Math.max(...durations),
119
+ });
120
+ }
121
+ return stats.sort((a, b) => {
122
+ if (b.fires !== a.fires)
123
+ return b.fires - a.fires;
124
+ return b.avgDuration - a.avgDuration;
125
+ });
126
+ }
127
+ /** Format per-handler stats as a CLI-friendly table. */
128
+ formatHandlerStatsTable() {
129
+ const stats = this.getHandlerStats();
130
+ if (stats.length === 0) {
131
+ return 'No per-handler metrics recorded yet.';
132
+ }
133
+ const header = padHandlerRow(['Handler', 'Event', 'Fires', 'Errors', 'Avg ms', 'Max ms']);
134
+ const separator = '-'.repeat(header.length);
135
+ const rows = stats.map((s) => padHandlerRow([
136
+ s.handler,
137
+ s.event,
138
+ String(s.fires),
139
+ String(s.errors),
140
+ s.avgDuration.toFixed(1),
141
+ s.maxDuration.toFixed(1),
142
+ ]));
143
+ return [header, separator, ...rows].join('\n');
144
+ }
98
145
  /** Flush is a no-op since we append on every record, but provided for API completeness. */
99
146
  flush() {
100
147
  // Already written on each record()
@@ -249,3 +296,7 @@ function padRow(cols) {
249
296
  const widths = [20, 8, 8, 10, 10, 10];
250
297
  return cols.map((col, i) => col.padEnd(widths[i])).join(' ');
251
298
  }
299
+ function padHandlerRow(cols) {
300
+ const widths = [35, 20, 7, 7, 8, 8];
301
+ return cols.map((col, i) => col.padEnd(widths[i])).join(' ');
302
+ }
package/dist/migrate.d.ts CHANGED
@@ -11,6 +11,15 @@ export interface MigratePathOptions {
11
11
  * Find the Claude Code settings.json path.
12
12
  */
13
13
  export declare function getSettingsPath(options?: MigratePathOptions): string | null;
14
+ /**
15
+ * Derive a readable handler ID from a hook command.
16
+ *
17
+ * "node /path/to/gsd-check-update.js" -> "gsd-check-update"
18
+ * "node /path/to/hooks/post-action.js" -> "post-action"
19
+ * "python3 -m almicio.hooks.session_context" -> "almicio-session-context"
20
+ * "bash -c 'source ~/.zshrc; python3 /path/to/tts-summary.py'" -> "tts-summary"
21
+ */
22
+ export declare function deriveHandlerId(command: string, event: string, index: number): string;
14
23
  /**
15
24
  * Migrate Claude Code settings.json command hooks to clooks HTTP hooks.
16
25
  *
package/dist/migrate.js CHANGED
@@ -2,6 +2,7 @@
2
2
  // clooks migration utilities — convert shell hooks to HTTP hooks
3
3
  Object.defineProperty(exports, "__esModule", { value: true });
4
4
  exports.getSettingsPath = getSettingsPath;
5
+ exports.deriveHandlerId = deriveHandlerId;
5
6
  exports.migrate = migrate;
6
7
  exports.restore = restore;
7
8
  const fs_1 = require("fs");
@@ -26,6 +27,46 @@ function getSettingsPath(options) {
26
27
  }
27
28
  return null;
28
29
  }
30
+ /**
31
+ * Derive a readable handler ID from a hook command.
32
+ *
33
+ * "node /path/to/gsd-check-update.js" -> "gsd-check-update"
34
+ * "node /path/to/hooks/post-action.js" -> "post-action"
35
+ * "python3 -m almicio.hooks.session_context" -> "almicio-session-context"
36
+ * "bash -c 'source ~/.zshrc; python3 /path/to/tts-summary.py'" -> "tts-summary"
37
+ */
38
+ function deriveHandlerId(command, event, index) {
39
+ let basename = null;
40
+ // Try python3 -m module.name pattern
41
+ const moduleMatch = command.match(/python3?\s+-m\s+([\w.]+)/);
42
+ if (moduleMatch) {
43
+ const modulePath = moduleMatch[1];
44
+ const lastSegment = modulePath.split('.').pop() ?? '';
45
+ if (lastSegment)
46
+ basename = lastSegment;
47
+ }
48
+ // Try to find the last .js or .py file in the command
49
+ if (!basename) {
50
+ // Match quoted or unquoted file paths ending in .js or .py
51
+ const fileMatches = [...command.matchAll(/(?:["'])?([^\s"']+\.(?:js|py))(?:["'])?/g)];
52
+ if (fileMatches.length > 0) {
53
+ const lastFile = fileMatches[fileMatches.length - 1][1];
54
+ // Extract basename without extension
55
+ const parts = lastFile.split('/');
56
+ const filename = parts[parts.length - 1];
57
+ basename = filename.replace(/\.(js|py)$/, '');
58
+ }
59
+ }
60
+ if (!basename) {
61
+ return `migrated-${event.toLowerCase()}-${index}`;
62
+ }
63
+ // Sanitize: replace non-alphanumeric chars with hyphens, lowercase
64
+ let id = basename.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
65
+ if (!id) {
66
+ return `migrated-${event.toLowerCase()}-${index}`;
67
+ }
68
+ return id;
69
+ }
29
70
  /**
30
71
  * Migrate Claude Code settings.json command hooks to clooks HTTP hooks.
31
72
  *
@@ -61,6 +102,7 @@ function migrate(options) {
61
102
  // Extract command hooks and build manifest
62
103
  const manifestHandlers = {};
63
104
  let handlerIndex = 0;
105
+ const usedIds = new Set();
64
106
  // NOTE: In v0.1, matchers from the original rule groups are not preserved in the
65
107
  // migrated HTTP hooks — all command hooks are consolidated into matcher-less rule groups.
66
108
  // This is acceptable because clooks dispatches based on event type, not matchers.
@@ -83,8 +125,14 @@ function migrate(options) {
83
125
  continue;
84
126
  manifestHandlers[event] = commandHooks.map((hook) => {
85
127
  handlerIndex++;
128
+ let id = deriveHandlerId(hook.command, event, handlerIndex);
129
+ // Ensure uniqueness: if ID already used, append index
130
+ if (usedIds.has(id)) {
131
+ id = `${id}-${handlerIndex}`;
132
+ }
133
+ usedIds.add(id);
86
134
  return {
87
- id: `migrated-${event.toLowerCase()}-${handlerIndex}`,
135
+ id,
88
136
  type: 'script',
89
137
  command: hook.command,
90
138
  timeout: hook.timeout ? hook.timeout * 1000 : 5000, // Claude uses seconds, we use ms
@@ -5,8 +5,13 @@ export declare class RateLimiter {
5
5
  constructor(maxAttempts?: number, windowMs?: number);
6
6
  /** Check if source is rate-limited. Returns true if allowed. */
7
7
  check(source: string): boolean;
8
- /** Record an attempt from source. */
9
- record(source: string): void;
8
+ /** Record an auth failure from source. */
9
+ recordFailure(source: string): void;
10
+ /**
11
+ * How many seconds until the rate limit resets for a given source.
12
+ * Returns 0 if the source is not rate-limited.
13
+ */
14
+ retryAfter(source: string): number;
10
15
  /** Clean up old entries. */
11
16
  cleanup(): void;
12
17
  }
package/dist/ratelimit.js CHANGED
@@ -1,9 +1,13 @@
1
1
  "use strict";
2
2
  // clooks rate limiting — protect against auth brute-force
3
+ //
4
+ // IMPORTANT: This rate limiter should ONLY be used when auth is configured.
5
+ // It tracks auth failures per source IP. When the limit is exceeded, requests
6
+ // from that source are blocked with 429 until the window expires.
3
7
  Object.defineProperty(exports, "__esModule", { value: true });
4
8
  exports.RateLimiter = void 0;
5
9
  class RateLimiter {
6
- attempts = new Map(); // source → timestamps
10
+ attempts = new Map(); // source → auth failure timestamps
7
11
  maxAttempts;
8
12
  windowMs;
9
13
  constructor(maxAttempts = 10, windowMs = 60_000) {
@@ -16,17 +20,34 @@ class RateLimiter {
16
20
  const timestamps = this.attempts.get(source);
17
21
  if (!timestamps)
18
22
  return true;
19
- // Count recent attempts within window
23
+ // Count recent auth failures within window
20
24
  const recent = timestamps.filter(t => now - t <= this.windowMs);
21
25
  return recent.length < this.maxAttempts;
22
26
  }
23
- /** Record an attempt from source. */
24
- record(source) {
27
+ /** Record an auth failure from source. */
28
+ recordFailure(source) {
25
29
  const now = Date.now();
26
30
  const timestamps = this.attempts.get(source) ?? [];
27
31
  timestamps.push(now);
28
32
  this.attempts.set(source, timestamps);
29
33
  }
34
+ /**
35
+ * How many seconds until the rate limit resets for a given source.
36
+ * Returns 0 if the source is not rate-limited.
37
+ */
38
+ retryAfter(source) {
39
+ const now = Date.now();
40
+ const timestamps = this.attempts.get(source);
41
+ if (!timestamps)
42
+ return 0;
43
+ const recent = timestamps.filter(t => now - t <= this.windowMs);
44
+ if (recent.length < this.maxAttempts)
45
+ return 0;
46
+ // The oldest recent attempt determines when the window expires
47
+ const oldest = Math.min(...recent);
48
+ const expiresAt = oldest + this.windowMs;
49
+ return Math.ceil((expiresAt - now) / 1000);
50
+ }
30
51
  /** Clean up old entries. */
31
52
  cleanup() {
32
53
  const now = Date.now();
package/dist/server.js CHANGED
@@ -131,17 +131,24 @@ function createServer(manifest, metrics) {
131
131
  });
132
132
  return;
133
133
  }
134
- // Auth check for all POST requests
134
+ // Auth check for all POST requests — only when auth token is configured
135
135
  if (method === 'POST' && authToken) {
136
136
  const source = req.socket.remoteAddress ?? 'unknown';
137
- // Rate limiting check
137
+ // Rate limiting: check if this source has too many auth failures
138
138
  if (!rateLimiter.check(source)) {
139
- sendJson(res, 429, { error: 'Too many requests' });
139
+ const retryAfter = rateLimiter.retryAfter(source);
140
+ const body = JSON.stringify({ error: 'Too many auth failures' });
141
+ res.writeHead(429, {
142
+ 'Content-Type': 'application/json',
143
+ 'Content-Length': Buffer.byteLength(body),
144
+ 'Retry-After': String(retryAfter),
145
+ });
146
+ res.end(body);
140
147
  return;
141
148
  }
142
149
  const authHeader = req.headers['authorization'];
143
150
  if (!(0, auth_js_1.validateAuth)(authHeader, authToken)) {
144
- rateLimiter.record(source);
151
+ rateLimiter.recordFailure(source);
145
152
  log(`Auth failure from ${source}`);
146
153
  sendJson(res, 401, { error: 'Unauthorized' });
147
154
  return;
package/dist/sync.d.ts ADDED
@@ -0,0 +1,13 @@
1
+ import type { Manifest } from './types.js';
2
+ export interface SyncOptions {
3
+ settingsPath?: string;
4
+ manifestPath?: string;
5
+ /** Override composite manifest loading — used for testing */
6
+ manifest?: Manifest;
7
+ }
8
+ /**
9
+ * Ensure settings.json has HTTP hooks for every event that has handlers in the manifest.
10
+ * Adds missing HTTP hook entries without touching existing ones.
11
+ * Returns list of events that were added.
12
+ */
13
+ export declare function syncSettings(options?: SyncOptions): string[];
package/dist/sync.js ADDED
@@ -0,0 +1,153 @@
1
+ "use strict";
2
+ // clooks sync — ensure settings.json has HTTP hooks for every event with handlers
3
+ Object.defineProperty(exports, "__esModule", { value: true });
4
+ exports.syncSettings = syncSettings;
5
+ const fs_1 = require("fs");
6
+ const path_1 = require("path");
7
+ const os_1 = require("os");
8
+ const manifest_js_1 = require("./manifest.js");
9
+ const constants_js_1 = require("./constants.js");
10
+ /**
11
+ * Find the Claude Code settings.json path.
12
+ * Checks settings.local.json first, then settings.json.
13
+ */
14
+ function findSettingsPath() {
15
+ const home = (0, os_1.homedir)();
16
+ const candidates = [
17
+ (0, path_1.join)(home, '.claude', 'settings.local.json'),
18
+ (0, path_1.join)(home, '.claude', 'settings.json'),
19
+ ];
20
+ for (const candidate of candidates) {
21
+ if ((0, fs_1.existsSync)(candidate)) {
22
+ return candidate;
23
+ }
24
+ }
25
+ return null;
26
+ }
27
+ /**
28
+ * Ensure settings.json has HTTP hooks for every event that has handlers in the manifest.
29
+ * Adds missing HTTP hook entries without touching existing ones.
30
+ * Returns list of events that were added.
31
+ */
32
+ function syncSettings(options) {
33
+ // Determine settings path
34
+ const settingsPath = options?.settingsPath ?? findSettingsPath();
35
+ if (!settingsPath || !(0, fs_1.existsSync)(settingsPath)) {
36
+ return []; // No settings file found — nothing to sync
37
+ }
38
+ // Load manifest
39
+ let manifest;
40
+ if (options?.manifest) {
41
+ manifest = options.manifest;
42
+ }
43
+ else {
44
+ manifest = (0, manifest_js_1.loadCompositeManifest)();
45
+ }
46
+ const port = manifest.settings?.port ?? constants_js_1.DEFAULT_PORT;
47
+ const authToken = manifest.settings?.authToken;
48
+ // Find all events that have at least one handler
49
+ const eventsWithHandlers = new Set();
50
+ for (const [event, handlers] of Object.entries(manifest.handlers)) {
51
+ if (handlers && handlers.length > 0) {
52
+ eventsWithHandlers.add(event);
53
+ }
54
+ }
55
+ // Read settings.json
56
+ let raw;
57
+ try {
58
+ raw = (0, fs_1.readFileSync)(settingsPath, 'utf-8');
59
+ }
60
+ catch {
61
+ return [];
62
+ }
63
+ let settings;
64
+ try {
65
+ settings = JSON.parse(raw);
66
+ }
67
+ catch {
68
+ return [];
69
+ }
70
+ if (!settings.hooks) {
71
+ settings.hooks = {};
72
+ }
73
+ const added = [];
74
+ // Check each event with handlers
75
+ for (const event of eventsWithHandlers) {
76
+ const hookUrl = `http://localhost:${port}/hooks/${event}`;
77
+ // Check if settings.json already has an HTTP hook pointing to this URL
78
+ const ruleGroups = settings.hooks[event] ?? [];
79
+ let hasHttpHook = false;
80
+ for (const rule of ruleGroups) {
81
+ if (!Array.isArray(rule.hooks))
82
+ continue;
83
+ for (const hook of rule.hooks) {
84
+ if (hook.type === 'http' && hook.url === hookUrl) {
85
+ hasHttpHook = true;
86
+ break;
87
+ }
88
+ }
89
+ if (hasHttpHook)
90
+ break;
91
+ }
92
+ if (!hasHttpHook) {
93
+ // Add HTTP hook in a new rule group
94
+ const httpHook = {
95
+ type: 'http',
96
+ url: hookUrl,
97
+ };
98
+ if (authToken) {
99
+ httpHook.headers = { Authorization: `Bearer ${authToken}` };
100
+ }
101
+ if (!settings.hooks[event]) {
102
+ settings.hooks[event] = [];
103
+ }
104
+ // Check if there's already a rule group without a matcher we can append to
105
+ const existingRules = settings.hooks[event];
106
+ const unmatchedRule = existingRules.find(r => !r.matcher);
107
+ if (unmatchedRule) {
108
+ unmatchedRule.hooks.push(httpHook);
109
+ }
110
+ else {
111
+ existingRules.push({ hooks: [httpHook] });
112
+ }
113
+ added.push(event);
114
+ }
115
+ }
116
+ // Ensure SessionStart always has the `clooks ensure-running` command hook
117
+ if (!settings.hooks['SessionStart']) {
118
+ settings.hooks['SessionStart'] = [];
119
+ }
120
+ const sessionRules = settings.hooks['SessionStart'];
121
+ let hasEnsureRunning = false;
122
+ for (const rule of sessionRules) {
123
+ if (!Array.isArray(rule.hooks))
124
+ continue;
125
+ for (const hook of rule.hooks) {
126
+ if (hook.type === 'command' && hook.command === 'clooks ensure-running') {
127
+ hasEnsureRunning = true;
128
+ break;
129
+ }
130
+ }
131
+ if (hasEnsureRunning)
132
+ break;
133
+ }
134
+ if (!hasEnsureRunning) {
135
+ const unmatchedRule = sessionRules.find(r => !r.matcher);
136
+ const ensureHook = { type: 'command', command: 'clooks ensure-running' };
137
+ if (unmatchedRule) {
138
+ // Prepend ensure-running before HTTP hooks
139
+ unmatchedRule.hooks.unshift(ensureHook);
140
+ }
141
+ else {
142
+ sessionRules.unshift({ hooks: [ensureHook] });
143
+ }
144
+ if (!added.includes('SessionStart')) {
145
+ added.push('SessionStart');
146
+ }
147
+ }
148
+ // Write settings.json back only if changes were made
149
+ if (added.length > 0) {
150
+ (0, fs_1.writeFileSync)(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
151
+ }
152
+ return added;
153
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mauribadnights/clooks",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
4
4
  "description": "Persistent hook runtime for Claude Code — eliminates process spawning overhead and gives you observability",
5
5
  "bin": {
6
6
  "clooks": "./dist/cli.js"