@mauribadnights/clooks 0.3.2 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +276 -201
- package/agents/clooks.md +146 -0
- package/dist/agent.d.ts +9 -0
- package/dist/agent.js +43 -0
- package/dist/cli.js +126 -17
- package/dist/doctor.js +20 -0
- package/dist/handlers.d.ts +7 -1
- package/dist/handlers.js +105 -3
- package/dist/index.d.ts +3 -0
- package/dist/index.js +10 -2
- package/dist/manifest.js +33 -0
- package/dist/server.d.ts +21 -1
- package/dist/server.js +126 -8
- package/dist/service.d.ts +27 -0
- package/dist/service.js +242 -0
- package/dist/tui.d.ts +1 -0
- package/dist/tui.js +730 -0
- package/dist/types.d.ts +11 -0
- package/docs/DASHBOARD-VISION.md +66 -0
- package/docs/DEFERRED-FIXES.md +35 -0
- package/docs/architecture.png +0 -0
- package/docs/generate-diagram.py +177 -0
- package/package.json +3 -1
package/dist/manifest.js
CHANGED
|
@@ -74,6 +74,39 @@ function validateManifest(manifest) {
|
|
|
74
74
|
throw new Error(`LLM handler "${handler.id}" model must be one of: ${validModels.join(', ')}`);
|
|
75
75
|
}
|
|
76
76
|
}
|
|
77
|
+
// Validate async field type
|
|
78
|
+
if ('async' in handler && typeof handler.async !== 'boolean') {
|
|
79
|
+
throw new Error(`Handler "${handler.id}" async field must be a boolean`);
|
|
80
|
+
}
|
|
81
|
+
// Validate agent field type
|
|
82
|
+
if ('agent' in handler && typeof handler.agent !== 'string') {
|
|
83
|
+
throw new Error(`Handler "${handler.id}" agent field must be a string`);
|
|
84
|
+
}
|
|
85
|
+
// Validate project field type
|
|
86
|
+
if ('project' in handler && typeof handler.project !== 'string') {
|
|
87
|
+
throw new Error(`Handler "${handler.id}" project field must be a string`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
// Warn about async handlers with dependency relationships
|
|
91
|
+
const eventHandlerIds = new Set(handlers.map(h => h.id));
|
|
92
|
+
const dependedUponIds = new Set();
|
|
93
|
+
for (const h of handlers) {
|
|
94
|
+
if (h.depends) {
|
|
95
|
+
for (const dep of h.depends) {
|
|
96
|
+
if (eventHandlerIds.has(dep))
|
|
97
|
+
dependedUponIds.add(dep);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
for (const h of handlers) {
|
|
102
|
+
if (h.async) {
|
|
103
|
+
if (dependedUponIds.has(h.id)) {
|
|
104
|
+
console.warn(`[clooks] Warning: async handler "${h.id}" has dependents — will run synchronously at runtime`);
|
|
105
|
+
}
|
|
106
|
+
if (h.depends?.some(d => eventHandlerIds.has(d))) {
|
|
107
|
+
console.warn(`[clooks] Warning: async handler "${h.id}" has dependencies — will run synchronously at runtime`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
77
110
|
}
|
|
78
111
|
}
|
|
79
112
|
// Validate prefetch if present
|
package/dist/server.d.ts
CHANGED
|
@@ -4,6 +4,13 @@ import { MetricsCollector } from './metrics.js';
|
|
|
4
4
|
import { DenyCache } from './shortcircuit.js';
|
|
5
5
|
import { RateLimiter } from './ratelimit.js';
|
|
6
6
|
import type { Manifest } from './types.js';
|
|
7
|
+
/** Session agent cache: session_id → { agent_type, timestamp } */
|
|
8
|
+
declare const sessionAgents: Map<string, {
|
|
9
|
+
agent: string;
|
|
10
|
+
ts: number;
|
|
11
|
+
}>;
|
|
12
|
+
/** Exported for testing */
|
|
13
|
+
export { sessionAgents };
|
|
7
14
|
export interface ServerContext {
|
|
8
15
|
server: Server;
|
|
9
16
|
metrics: MetricsCollector;
|
|
@@ -29,11 +36,24 @@ export declare function startDaemon(manifest: Manifest, metrics: MetricsCollecto
|
|
|
29
36
|
*/
|
|
30
37
|
export declare function stopDaemon(): boolean;
|
|
31
38
|
/**
|
|
32
|
-
* Check if daemon is currently running.
|
|
39
|
+
* Check if daemon is currently running (PID check only).
|
|
40
|
+
* Use for stop/status where a quick check is fine.
|
|
33
41
|
*/
|
|
34
42
|
export declare function isDaemonRunning(): boolean;
|
|
43
|
+
/**
|
|
44
|
+
* Check if daemon is running AND healthy (PID + health endpoint).
|
|
45
|
+
* Defends against stale PIDs reused by macOS after sleep/lid-close.
|
|
46
|
+
* Use for ensure-running and start where correctness matters.
|
|
47
|
+
*/
|
|
48
|
+
export declare function isDaemonHealthy(): Promise<boolean>;
|
|
49
|
+
/**
|
|
50
|
+
* Clean up a stale daemon: remove PID file and attempt to kill the process.
|
|
51
|
+
* Returns the stale PID for logging purposes.
|
|
52
|
+
*/
|
|
53
|
+
export declare function cleanupStaleDaemon(): number | null;
|
|
35
54
|
/**
|
|
36
55
|
* Start daemon as a detached background process.
|
|
56
|
+
* Always removes any existing PID file first — the new daemon writes its own.
|
|
37
57
|
*/
|
|
38
58
|
export declare function startDaemonBackground(options?: {
|
|
39
59
|
noWatch?: boolean;
|
package/dist/server.js
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
// clooks HTTP server — persistent hook daemon
|
|
3
3
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
|
+
exports.sessionAgents = void 0;
|
|
4
5
|
exports.createServer = createServer;
|
|
5
6
|
exports.startDaemon = startDaemon;
|
|
6
7
|
exports.stopDaemon = stopDaemon;
|
|
7
8
|
exports.isDaemonRunning = isDaemonRunning;
|
|
9
|
+
exports.isDaemonHealthy = isDaemonHealthy;
|
|
10
|
+
exports.cleanupStaleDaemon = cleanupStaleDaemon;
|
|
8
11
|
exports.startDaemonBackground = startDaemonBackground;
|
|
9
12
|
const http_1 = require("http");
|
|
10
13
|
const fs_1 = require("fs");
|
|
@@ -17,6 +20,18 @@ const shortcircuit_js_1 = require("./shortcircuit.js");
|
|
|
17
20
|
const ratelimit_js_1 = require("./ratelimit.js");
|
|
18
21
|
const constants_js_1 = require("./constants.js");
|
|
19
22
|
const manifest_js_1 = require("./manifest.js");
|
|
23
|
+
/** Session agent cache: session_id → { agent_type, timestamp } */
|
|
24
|
+
const sessionAgents = new Map();
|
|
25
|
+
exports.sessionAgents = sessionAgents;
|
|
26
|
+
const SESSION_AGENT_TTL = 24 * 60 * 60 * 1000; // 24 hours
|
|
27
|
+
function cleanupSessionAgents() {
|
|
28
|
+
const now = Date.now();
|
|
29
|
+
for (const [id, entry] of sessionAgents) {
|
|
30
|
+
if (now - entry.ts > SESSION_AGENT_TTL) {
|
|
31
|
+
sessionAgents.delete(id);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
20
35
|
function log(msg) {
|
|
21
36
|
const line = `[${new Date().toISOString()}] ${msg}\n`;
|
|
22
37
|
try {
|
|
@@ -99,6 +114,7 @@ function createServer(manifest, metrics) {
|
|
|
99
114
|
ctx.cleanupInterval = setInterval(() => {
|
|
100
115
|
denyCache.cleanup();
|
|
101
116
|
rateLimiter.cleanup();
|
|
117
|
+
cleanupSessionAgents();
|
|
102
118
|
}, 60_000);
|
|
103
119
|
// Unref so it doesn't keep the process alive
|
|
104
120
|
if (ctx.cleanupInterval && typeof ctx.cleanupInterval === 'object' && 'unref' in ctx.cleanupInterval) {
|
|
@@ -163,7 +179,7 @@ function createServer(manifest, metrics) {
|
|
|
163
179
|
return;
|
|
164
180
|
}
|
|
165
181
|
const event = eventName;
|
|
166
|
-
// On SessionStart, reset session-isolated handlers
|
|
182
|
+
// On SessionStart, cache agent and reset session-isolated handlers
|
|
167
183
|
if (event === 'SessionStart') {
|
|
168
184
|
const allHandlers = Object.values(ctx.manifest.handlers)
|
|
169
185
|
.flat()
|
|
@@ -185,6 +201,12 @@ function createServer(manifest, metrics) {
|
|
|
185
201
|
sendJson(res, 400, { error: 'Invalid JSON body' });
|
|
186
202
|
return;
|
|
187
203
|
}
|
|
204
|
+
// Cache agent_type on SessionStart
|
|
205
|
+
if (event === 'SessionStart' && input.agent_type && input.session_id) {
|
|
206
|
+
sessionAgents.set(input.session_id, { agent: input.agent_type, ts: Date.now() });
|
|
207
|
+
}
|
|
208
|
+
// Resolve current agent for this session
|
|
209
|
+
const currentAgent = input.session_id ? sessionAgents.get(input.session_id)?.agent : undefined;
|
|
188
210
|
// Short-circuit: skip PostToolUse if PreToolUse denied this tool
|
|
189
211
|
if (event === 'PostToolUse' && input.tool_name && input.session_id) {
|
|
190
212
|
if (denyCache.isDenied(input.session_id, input.tool_name)) {
|
|
@@ -193,16 +215,23 @@ function createServer(manifest, metrics) {
|
|
|
193
215
|
return;
|
|
194
216
|
}
|
|
195
217
|
}
|
|
196
|
-
|
|
218
|
+
const allHandlerConfigs = handlers;
|
|
219
|
+
const syncCount = allHandlerConfigs.filter(h => !h.async).length;
|
|
220
|
+
const asyncCount = allHandlerConfigs.filter(h => h.async).length;
|
|
221
|
+
if (asyncCount > 0) {
|
|
222
|
+
log(`Hook: ${eventName} (${syncCount} sync, ${asyncCount} async handler${syncCount + asyncCount > 1 ? 's' : ''})`);
|
|
223
|
+
}
|
|
224
|
+
else {
|
|
225
|
+
log(`Hook: ${eventName} (${handlers.length} handler${handlers.length > 1 ? 's' : ''})`);
|
|
226
|
+
}
|
|
197
227
|
try {
|
|
198
228
|
// Pre-fetch shared context if configured
|
|
199
229
|
let context;
|
|
200
230
|
if (ctx.manifest.prefetch && ctx.manifest.prefetch.length > 0) {
|
|
201
231
|
context = await (0, prefetch_js_1.prefetchContext)(ctx.manifest.prefetch, input);
|
|
202
232
|
}
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
for (const result of results) {
|
|
233
|
+
// Callback for recording async handler metrics when they complete
|
|
234
|
+
const recordResult = (result) => {
|
|
206
235
|
metrics.record({
|
|
207
236
|
ts: new Date().toISOString(),
|
|
208
237
|
event,
|
|
@@ -214,10 +243,10 @@ function createServer(manifest, metrics) {
|
|
|
214
243
|
usage: result.usage,
|
|
215
244
|
cost_usd: result.cost_usd,
|
|
216
245
|
session_id: input.session_id,
|
|
246
|
+
agent_type: currentAgent,
|
|
217
247
|
});
|
|
218
|
-
// Track cost for LLM handlers
|
|
219
248
|
if (result.usage && result.cost_usd !== undefined && result.cost_usd > 0) {
|
|
220
|
-
const handlerConfig =
|
|
249
|
+
const handlerConfig = allHandlerConfigs.find(h => h.id === result.id);
|
|
221
250
|
if (handlerConfig && handlerConfig.type === 'llm') {
|
|
222
251
|
const llmConfig = handlerConfig;
|
|
223
252
|
metrics.trackCost({
|
|
@@ -231,6 +260,11 @@ function createServer(manifest, metrics) {
|
|
|
231
260
|
});
|
|
232
261
|
}
|
|
233
262
|
}
|
|
263
|
+
};
|
|
264
|
+
const results = await (0, handlers_js_1.executeHandlers)(event, input, allHandlerConfigs, context, recordResult, currentAgent);
|
|
265
|
+
// Record metrics and costs for sync results
|
|
266
|
+
for (const result of results) {
|
|
267
|
+
recordResult(result);
|
|
234
268
|
}
|
|
235
269
|
// Short-circuit: if PreToolUse had a deny, record it in the cache
|
|
236
270
|
if (event === 'PreToolUse' && input.tool_name && input.session_id) {
|
|
@@ -380,6 +414,13 @@ function startDaemon(manifest, metrics, options) {
|
|
|
380
414
|
};
|
|
381
415
|
process.on('SIGTERM', shutdown);
|
|
382
416
|
process.on('SIGINT', shutdown);
|
|
417
|
+
// Visibility into macOS sleep/wake cycles
|
|
418
|
+
process.on('SIGTSTP', () => {
|
|
419
|
+
log('Daemon suspended (system sleep)');
|
|
420
|
+
});
|
|
421
|
+
process.on('SIGCONT', () => {
|
|
422
|
+
log('Daemon resumed (system wake)');
|
|
423
|
+
});
|
|
383
424
|
});
|
|
384
425
|
}
|
|
385
426
|
/**
|
|
@@ -422,7 +463,8 @@ function stopDaemon() {
|
|
|
422
463
|
return true;
|
|
423
464
|
}
|
|
424
465
|
/**
|
|
425
|
-
* Check if daemon is currently running.
|
|
466
|
+
* Check if daemon is currently running (PID check only).
|
|
467
|
+
* Use for stop/status where a quick check is fine.
|
|
426
468
|
*/
|
|
427
469
|
function isDaemonRunning() {
|
|
428
470
|
if (!(0, fs_1.existsSync)(constants_js_1.PID_FILE))
|
|
@@ -440,10 +482,86 @@ function isDaemonRunning() {
|
|
|
440
482
|
return false;
|
|
441
483
|
}
|
|
442
484
|
}
|
|
485
|
+
/**
|
|
486
|
+
* Check if daemon is running AND healthy (PID + health endpoint).
|
|
487
|
+
* Defends against stale PIDs reused by macOS after sleep/lid-close.
|
|
488
|
+
* Use for ensure-running and start where correctness matters.
|
|
489
|
+
*/
|
|
490
|
+
async function isDaemonHealthy() {
|
|
491
|
+
if (!(0, fs_1.existsSync)(constants_js_1.PID_FILE))
|
|
492
|
+
return false;
|
|
493
|
+
const pidStr = (0, fs_1.readFileSync)(constants_js_1.PID_FILE, 'utf-8').trim();
|
|
494
|
+
const pid = parseInt(pidStr, 10);
|
|
495
|
+
if (isNaN(pid))
|
|
496
|
+
return false;
|
|
497
|
+
// Step 1: PID alive?
|
|
498
|
+
try {
|
|
499
|
+
process.kill(pid, 0);
|
|
500
|
+
}
|
|
501
|
+
catch {
|
|
502
|
+
return false;
|
|
503
|
+
}
|
|
504
|
+
// Step 2: Health endpoint responds?
|
|
505
|
+
const port = constants_js_1.DEFAULT_PORT; // health check always on default port
|
|
506
|
+
try {
|
|
507
|
+
const { get } = await import('http');
|
|
508
|
+
const data = await new Promise((resolve, reject) => {
|
|
509
|
+
const req = get(`http://127.0.0.1:${port}/health`, (res) => {
|
|
510
|
+
let body = '';
|
|
511
|
+
res.on('data', (chunk) => { body += chunk.toString(); });
|
|
512
|
+
res.on('end', () => resolve(body));
|
|
513
|
+
});
|
|
514
|
+
req.on('error', reject);
|
|
515
|
+
req.setTimeout(2000, () => { req.destroy(); reject(new Error('timeout')); });
|
|
516
|
+
});
|
|
517
|
+
const health = JSON.parse(data);
|
|
518
|
+
return health.status === 'ok';
|
|
519
|
+
}
|
|
520
|
+
catch {
|
|
521
|
+
return false;
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
/**
|
|
525
|
+
* Clean up a stale daemon: remove PID file and attempt to kill the process.
|
|
526
|
+
* Returns the stale PID for logging purposes.
|
|
527
|
+
*/
|
|
528
|
+
function cleanupStaleDaemon() {
|
|
529
|
+
if (!(0, fs_1.existsSync)(constants_js_1.PID_FILE))
|
|
530
|
+
return null;
|
|
531
|
+
const pidStr = (0, fs_1.readFileSync)(constants_js_1.PID_FILE, 'utf-8').trim();
|
|
532
|
+
const pid = parseInt(pidStr, 10);
|
|
533
|
+
// Remove stale PID file
|
|
534
|
+
try {
|
|
535
|
+
(0, fs_1.unlinkSync)(constants_js_1.PID_FILE);
|
|
536
|
+
}
|
|
537
|
+
catch {
|
|
538
|
+
// ignore
|
|
539
|
+
}
|
|
540
|
+
// Try to kill the stale process (might be our daemon but unhealthy)
|
|
541
|
+
if (!isNaN(pid)) {
|
|
542
|
+
try {
|
|
543
|
+
process.kill(pid, 'SIGTERM');
|
|
544
|
+
}
|
|
545
|
+
catch {
|
|
546
|
+
// Process doesn't exist — that's fine
|
|
547
|
+
}
|
|
548
|
+
return pid;
|
|
549
|
+
}
|
|
550
|
+
return null;
|
|
551
|
+
}
|
|
443
552
|
/**
|
|
444
553
|
* Start daemon as a detached background process.
|
|
554
|
+
* Always removes any existing PID file first — the new daemon writes its own.
|
|
445
555
|
*/
|
|
446
556
|
function startDaemonBackground(options) {
|
|
557
|
+
// Clean any stale PID file before spawning
|
|
558
|
+
try {
|
|
559
|
+
if ((0, fs_1.existsSync)(constants_js_1.PID_FILE))
|
|
560
|
+
(0, fs_1.unlinkSync)(constants_js_1.PID_FILE);
|
|
561
|
+
}
|
|
562
|
+
catch {
|
|
563
|
+
// ignore
|
|
564
|
+
}
|
|
447
565
|
const args = [process.argv[1], 'start', '--foreground'];
|
|
448
566
|
if (options?.noWatch) {
|
|
449
567
|
args.push('--no-watch');
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Find the clooks binary path by checking PATH or falling back to process.argv[1].
|
|
3
|
+
*/
|
|
4
|
+
declare function findClooksPath(): string;
|
|
5
|
+
declare const PLIST_LABEL = "com.clooks.daemon";
|
|
6
|
+
declare function getPlistPath(): string;
|
|
7
|
+
declare const SYSTEMD_SERVICE_NAME = "clooks";
|
|
8
|
+
declare function getSystemdServicePath(): string;
|
|
9
|
+
declare const TASK_NAME = "clooks";
|
|
10
|
+
export type ServiceStatus = 'running' | 'stopped' | 'not-installed';
|
|
11
|
+
/**
|
|
12
|
+
* Install clooks as an OS service (launchd on macOS, systemd on Linux, schtasks on Windows).
|
|
13
|
+
*/
|
|
14
|
+
export declare function installService(): void;
|
|
15
|
+
/**
|
|
16
|
+
* Uninstall the clooks OS service.
|
|
17
|
+
*/
|
|
18
|
+
export declare function uninstallService(): void;
|
|
19
|
+
/**
|
|
20
|
+
* Check if the clooks OS service is installed.
|
|
21
|
+
*/
|
|
22
|
+
export declare function isServiceInstalled(): boolean;
|
|
23
|
+
/**
|
|
24
|
+
* Get the current service status.
|
|
25
|
+
*/
|
|
26
|
+
export declare function getServiceStatus(): ServiceStatus;
|
|
27
|
+
export { findClooksPath, getPlistPath, getSystemdServicePath, PLIST_LABEL, SYSTEMD_SERVICE_NAME, TASK_NAME };
|
package/dist/service.js
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// clooks service management — cross-platform OS service installer/uninstaller
|
|
3
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
|
+
exports.TASK_NAME = exports.SYSTEMD_SERVICE_NAME = exports.PLIST_LABEL = void 0;
|
|
5
|
+
exports.installService = installService;
|
|
6
|
+
exports.uninstallService = uninstallService;
|
|
7
|
+
exports.isServiceInstalled = isServiceInstalled;
|
|
8
|
+
exports.getServiceStatus = getServiceStatus;
|
|
9
|
+
exports.findClooksPath = findClooksPath;
|
|
10
|
+
exports.getPlistPath = getPlistPath;
|
|
11
|
+
exports.getSystemdServicePath = getSystemdServicePath;
|
|
12
|
+
const os_1 = require("os");
|
|
13
|
+
const path_1 = require("path");
|
|
14
|
+
const fs_1 = require("fs");
|
|
15
|
+
const child_process_1 = require("child_process");
|
|
16
|
+
const constants_js_1 = require("./constants.js");
|
|
17
|
+
/**
|
|
18
|
+
* Find the clooks binary path by checking PATH or falling back to process.argv[1].
|
|
19
|
+
*/
|
|
20
|
+
function findClooksPath() {
|
|
21
|
+
try {
|
|
22
|
+
const cmd = (0, os_1.platform)() === 'win32' ? 'where clooks' : 'which clooks';
|
|
23
|
+
return (0, child_process_1.execSync)(cmd, { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim().split('\n')[0];
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
// Fallback: resolve from current process
|
|
27
|
+
return process.argv[1];
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
// --- macOS (launchd) ---
|
|
31
|
+
const PLIST_LABEL = 'com.clooks.daemon';
|
|
32
|
+
exports.PLIST_LABEL = PLIST_LABEL;
|
|
33
|
+
function getPlistPath() {
|
|
34
|
+
return (0, path_1.join)((0, os_1.homedir)(), 'Library', 'LaunchAgents', `${PLIST_LABEL}.plist`);
|
|
35
|
+
}
|
|
36
|
+
function installMacOS() {
|
|
37
|
+
const plistPath = getPlistPath();
|
|
38
|
+
const launchAgentsDir = (0, path_1.join)((0, os_1.homedir)(), 'Library', 'LaunchAgents');
|
|
39
|
+
if (!(0, fs_1.existsSync)(launchAgentsDir)) {
|
|
40
|
+
(0, fs_1.mkdirSync)(launchAgentsDir, { recursive: true });
|
|
41
|
+
}
|
|
42
|
+
const nodePath = process.execPath;
|
|
43
|
+
const clooksPath = findClooksPath();
|
|
44
|
+
const plist = `<?xml version="1.0" encoding="UTF-8"?>
|
|
45
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
46
|
+
<plist version="1.0">
|
|
47
|
+
<dict>
|
|
48
|
+
<key>Label</key>
|
|
49
|
+
<string>${PLIST_LABEL}</string>
|
|
50
|
+
<key>ProgramArguments</key>
|
|
51
|
+
<array>
|
|
52
|
+
<string>${nodePath}</string>
|
|
53
|
+
<string>${clooksPath}</string>
|
|
54
|
+
<string>start</string>
|
|
55
|
+
<string>--foreground</string>
|
|
56
|
+
</array>
|
|
57
|
+
<key>KeepAlive</key>
|
|
58
|
+
<true/>
|
|
59
|
+
<key>RunAtLoad</key>
|
|
60
|
+
<true/>
|
|
61
|
+
<key>StandardOutPath</key>
|
|
62
|
+
<string>${(0, path_1.join)(constants_js_1.CONFIG_DIR, 'daemon-stdout.log')}</string>
|
|
63
|
+
<key>StandardErrorPath</key>
|
|
64
|
+
<string>${(0, path_1.join)(constants_js_1.CONFIG_DIR, 'daemon-stderr.log')}</string>
|
|
65
|
+
<key>EnvironmentVariables</key>
|
|
66
|
+
<dict>
|
|
67
|
+
<key>PATH</key>
|
|
68
|
+
<string>${process.env.PATH ?? '/usr/local/bin:/usr/bin:/bin'}</string>
|
|
69
|
+
</dict>
|
|
70
|
+
</dict>
|
|
71
|
+
</plist>`;
|
|
72
|
+
(0, fs_1.writeFileSync)(plistPath, plist, 'utf-8');
|
|
73
|
+
(0, child_process_1.execSync)(`launchctl load ${plistPath}`, { stdio: 'pipe' });
|
|
74
|
+
}
|
|
75
|
+
function uninstallMacOS() {
|
|
76
|
+
const plistPath = getPlistPath();
|
|
77
|
+
if ((0, fs_1.existsSync)(plistPath)) {
|
|
78
|
+
try {
|
|
79
|
+
(0, child_process_1.execSync)(`launchctl unload ${plistPath}`, { stdio: 'pipe' });
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
// May fail if not loaded — that's fine
|
|
83
|
+
}
|
|
84
|
+
(0, fs_1.unlinkSync)(plistPath);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
function isInstalledMacOS() {
|
|
88
|
+
return (0, fs_1.existsSync)(getPlistPath());
|
|
89
|
+
}
|
|
90
|
+
// --- Linux (systemd user service) ---
|
|
91
|
+
const SYSTEMD_SERVICE_NAME = 'clooks';
|
|
92
|
+
exports.SYSTEMD_SERVICE_NAME = SYSTEMD_SERVICE_NAME;
|
|
93
|
+
function getSystemdServicePath() {
|
|
94
|
+
return (0, path_1.join)((0, os_1.homedir)(), '.config', 'systemd', 'user', `${SYSTEMD_SERVICE_NAME}.service`);
|
|
95
|
+
}
|
|
96
|
+
function installLinux() {
|
|
97
|
+
const serviceDir = (0, path_1.join)((0, os_1.homedir)(), '.config', 'systemd', 'user');
|
|
98
|
+
(0, fs_1.mkdirSync)(serviceDir, { recursive: true });
|
|
99
|
+
const servicePath = getSystemdServicePath();
|
|
100
|
+
const nodePath = process.execPath;
|
|
101
|
+
const clooksPath = findClooksPath();
|
|
102
|
+
const unit = `[Unit]
|
|
103
|
+
Description=clooks - Persistent hook runtime for Claude Code
|
|
104
|
+
After=network.target
|
|
105
|
+
|
|
106
|
+
[Service]
|
|
107
|
+
Type=simple
|
|
108
|
+
ExecStart=${nodePath} ${clooksPath} start --foreground
|
|
109
|
+
Restart=always
|
|
110
|
+
RestartSec=3
|
|
111
|
+
Environment=PATH=${process.env.PATH ?? '/usr/local/bin:/usr/bin:/bin'}
|
|
112
|
+
|
|
113
|
+
[Install]
|
|
114
|
+
WantedBy=default.target`;
|
|
115
|
+
(0, fs_1.writeFileSync)(servicePath, unit, 'utf-8');
|
|
116
|
+
(0, child_process_1.execSync)('systemctl --user daemon-reload', { stdio: 'pipe' });
|
|
117
|
+
(0, child_process_1.execSync)(`systemctl --user enable ${SYSTEMD_SERVICE_NAME}`, { stdio: 'pipe' });
|
|
118
|
+
(0, child_process_1.execSync)(`systemctl --user start ${SYSTEMD_SERVICE_NAME}`, { stdio: 'pipe' });
|
|
119
|
+
}
|
|
120
|
+
function uninstallLinux() {
|
|
121
|
+
try {
|
|
122
|
+
(0, child_process_1.execSync)(`systemctl --user stop ${SYSTEMD_SERVICE_NAME}`, { stdio: 'pipe' });
|
|
123
|
+
}
|
|
124
|
+
catch { /* ignore */ }
|
|
125
|
+
try {
|
|
126
|
+
(0, child_process_1.execSync)(`systemctl --user disable ${SYSTEMD_SERVICE_NAME}`, { stdio: 'pipe' });
|
|
127
|
+
}
|
|
128
|
+
catch { /* ignore */ }
|
|
129
|
+
const servicePath = getSystemdServicePath();
|
|
130
|
+
if ((0, fs_1.existsSync)(servicePath))
|
|
131
|
+
(0, fs_1.unlinkSync)(servicePath);
|
|
132
|
+
try {
|
|
133
|
+
(0, child_process_1.execSync)('systemctl --user daemon-reload', { stdio: 'pipe' });
|
|
134
|
+
}
|
|
135
|
+
catch { /* ignore */ }
|
|
136
|
+
}
|
|
137
|
+
function isInstalledLinux() {
|
|
138
|
+
try {
|
|
139
|
+
const result = (0, child_process_1.execSync)(`systemctl --user is-enabled ${SYSTEMD_SERVICE_NAME} 2>/dev/null`, { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] });
|
|
140
|
+
return result.trim() === 'enabled';
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
return false;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
// --- Windows (Task Scheduler) ---
|
|
147
|
+
const TASK_NAME = 'clooks';
|
|
148
|
+
exports.TASK_NAME = TASK_NAME;
|
|
149
|
+
function installWindows() {
|
|
150
|
+
const nodePath = process.execPath;
|
|
151
|
+
const clooksPath = findClooksPath();
|
|
152
|
+
(0, child_process_1.execSync)(`schtasks /create /tn "${TASK_NAME}" /tr "\\"${nodePath}\\" \\"${clooksPath}\\" start --foreground" ` +
|
|
153
|
+
`/sc onlogon /rl limited /f`, { stdio: 'pipe' });
|
|
154
|
+
// Start it now
|
|
155
|
+
(0, child_process_1.execSync)(`schtasks /run /tn "${TASK_NAME}"`, { stdio: 'pipe' });
|
|
156
|
+
}
|
|
157
|
+
function uninstallWindows() {
|
|
158
|
+
try {
|
|
159
|
+
(0, child_process_1.execSync)(`schtasks /end /tn "${TASK_NAME}"`, { stdio: 'pipe' });
|
|
160
|
+
}
|
|
161
|
+
catch { /* ignore */ }
|
|
162
|
+
try {
|
|
163
|
+
(0, child_process_1.execSync)(`schtasks /delete /tn "${TASK_NAME}" /f`, { stdio: 'pipe' });
|
|
164
|
+
}
|
|
165
|
+
catch { /* ignore */ }
|
|
166
|
+
}
|
|
167
|
+
function isInstalledWindows() {
|
|
168
|
+
try {
|
|
169
|
+
(0, child_process_1.execSync)(`schtasks /query /tn "${TASK_NAME}" 2>nul`, { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] });
|
|
170
|
+
return true;
|
|
171
|
+
}
|
|
172
|
+
catch {
|
|
173
|
+
return false;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Install clooks as an OS service (launchd on macOS, systemd on Linux, schtasks on Windows).
|
|
178
|
+
*/
|
|
179
|
+
function installService() {
|
|
180
|
+
const os = (0, os_1.platform)();
|
|
181
|
+
if (os === 'darwin')
|
|
182
|
+
installMacOS();
|
|
183
|
+
else if (os === 'linux')
|
|
184
|
+
installLinux();
|
|
185
|
+
else if (os === 'win32')
|
|
186
|
+
installWindows();
|
|
187
|
+
else
|
|
188
|
+
throw new Error(`Unsupported platform: ${os}`);
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* Uninstall the clooks OS service.
|
|
192
|
+
*/
|
|
193
|
+
function uninstallService() {
|
|
194
|
+
const os = (0, os_1.platform)();
|
|
195
|
+
if (os === 'darwin')
|
|
196
|
+
uninstallMacOS();
|
|
197
|
+
else if (os === 'linux')
|
|
198
|
+
uninstallLinux();
|
|
199
|
+
else if (os === 'win32')
|
|
200
|
+
uninstallWindows();
|
|
201
|
+
else
|
|
202
|
+
throw new Error(`Unsupported platform: ${os}`);
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* Check if the clooks OS service is installed.
|
|
206
|
+
*/
|
|
207
|
+
function isServiceInstalled() {
|
|
208
|
+
const os = (0, os_1.platform)();
|
|
209
|
+
if (os === 'darwin')
|
|
210
|
+
return isInstalledMacOS();
|
|
211
|
+
else if (os === 'linux')
|
|
212
|
+
return isInstalledLinux();
|
|
213
|
+
else if (os === 'win32')
|
|
214
|
+
return isInstalledWindows();
|
|
215
|
+
return false;
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* Get the current service status.
|
|
219
|
+
*/
|
|
220
|
+
function getServiceStatus() {
|
|
221
|
+
if (!isServiceInstalled())
|
|
222
|
+
return 'not-installed';
|
|
223
|
+
const os = (0, os_1.platform)();
|
|
224
|
+
try {
|
|
225
|
+
if (os === 'darwin') {
|
|
226
|
+
const result = (0, child_process_1.execSync)(`launchctl list ${PLIST_LABEL} 2>/dev/null`, { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] });
|
|
227
|
+
return result.includes('PID') ? 'running' : 'stopped';
|
|
228
|
+
}
|
|
229
|
+
else if (os === 'linux') {
|
|
230
|
+
const result = (0, child_process_1.execSync)(`systemctl --user is-active ${SYSTEMD_SERVICE_NAME} 2>/dev/null`, { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] });
|
|
231
|
+
return result.trim() === 'active' ? 'running' : 'stopped';
|
|
232
|
+
}
|
|
233
|
+
else if (os === 'win32') {
|
|
234
|
+
const result = (0, child_process_1.execSync)(`schtasks /query /tn "${TASK_NAME}" /fo csv 2>nul`, { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] });
|
|
235
|
+
return result.includes('Running') ? 'running' : 'stopped';
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
catch {
|
|
239
|
+
// Fall through
|
|
240
|
+
}
|
|
241
|
+
return 'stopped';
|
|
242
|
+
}
|
package/dist/tui.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function launchDashboard(): void;
|