@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/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 across ALL events
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
- log(`Hook: ${eventName} (${handlers.length} handler${handlers.length > 1 ? 's' : ''})`);
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
- const results = await (0, handlers_js_1.executeHandlers)(event, input, handlers, context);
204
- // Record metrics and costs
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 = handlers.find(h => h.id === result.id);
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 };
@@ -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;