@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 +36 -2
- package/dist/index.d.ts +2 -0
- package/dist/index.js +4 -2
- package/dist/metrics.d.ts +14 -0
- package/dist/metrics.js +51 -0
- package/dist/migrate.d.ts +9 -0
- package/dist/migrate.js +49 -1
- package/dist/ratelimit.d.ts +7 -2
- package/dist/ratelimit.js +25 -4
- package/dist/server.js +11 -4
- package/dist/sync.d.ts +13 -0
- package/dist/sync.js +153 -0
- package/package.json +1 -1
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.
|
|
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 —
|
|
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.
|
|
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
|
|
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
|
package/dist/ratelimit.d.ts
CHANGED
|
@@ -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
|
|
9
|
-
|
|
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
|
|
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
|
|
24
|
-
|
|
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
|
-
|
|
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.
|
|
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