clementine-agent 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (190) hide show
  1. package/.env.example +44 -0
  2. package/LICENSE +21 -0
  3. package/README.md +795 -0
  4. package/dist/agent/agent-manager.d.ts +69 -0
  5. package/dist/agent/agent-manager.js +441 -0
  6. package/dist/agent/assistant.d.ts +225 -0
  7. package/dist/agent/assistant.js +3888 -0
  8. package/dist/agent/auto-update.d.ts +32 -0
  9. package/dist/agent/auto-update.js +186 -0
  10. package/dist/agent/daily-planner.d.ts +24 -0
  11. package/dist/agent/daily-planner.js +379 -0
  12. package/dist/agent/execution-advisor.d.ts +10 -0
  13. package/dist/agent/execution-advisor.js +272 -0
  14. package/dist/agent/hooks.d.ts +45 -0
  15. package/dist/agent/hooks.js +564 -0
  16. package/dist/agent/insight-engine.d.ts +66 -0
  17. package/dist/agent/insight-engine.js +225 -0
  18. package/dist/agent/intent-classifier.d.ts +48 -0
  19. package/dist/agent/intent-classifier.js +214 -0
  20. package/dist/agent/link-extractor.d.ts +19 -0
  21. package/dist/agent/link-extractor.js +90 -0
  22. package/dist/agent/mcp-bridge.d.ts +62 -0
  23. package/dist/agent/mcp-bridge.js +435 -0
  24. package/dist/agent/metacognition.d.ts +66 -0
  25. package/dist/agent/metacognition.js +221 -0
  26. package/dist/agent/orchestrator.d.ts +81 -0
  27. package/dist/agent/orchestrator.js +790 -0
  28. package/dist/agent/profiles.d.ts +22 -0
  29. package/dist/agent/profiles.js +91 -0
  30. package/dist/agent/prompt-cache.d.ts +24 -0
  31. package/dist/agent/prompt-cache.js +68 -0
  32. package/dist/agent/prompt-evolver.d.ts +28 -0
  33. package/dist/agent/prompt-evolver.js +279 -0
  34. package/dist/agent/role-scaffolds.d.ts +28 -0
  35. package/dist/agent/role-scaffolds.js +433 -0
  36. package/dist/agent/safe-restart.d.ts +41 -0
  37. package/dist/agent/safe-restart.js +150 -0
  38. package/dist/agent/self-improve.d.ts +66 -0
  39. package/dist/agent/self-improve.js +1706 -0
  40. package/dist/agent/session-event-log.d.ts +114 -0
  41. package/dist/agent/session-event-log.js +233 -0
  42. package/dist/agent/skill-extractor.d.ts +72 -0
  43. package/dist/agent/skill-extractor.js +435 -0
  44. package/dist/agent/source-mods.d.ts +61 -0
  45. package/dist/agent/source-mods.js +230 -0
  46. package/dist/agent/source-preflight.d.ts +25 -0
  47. package/dist/agent/source-preflight.js +100 -0
  48. package/dist/agent/stall-guard.d.ts +62 -0
  49. package/dist/agent/stall-guard.js +109 -0
  50. package/dist/agent/strategic-planner.d.ts +60 -0
  51. package/dist/agent/strategic-planner.js +352 -0
  52. package/dist/agent/team-bus.d.ts +89 -0
  53. package/dist/agent/team-bus.js +556 -0
  54. package/dist/agent/team-router.d.ts +26 -0
  55. package/dist/agent/team-router.js +37 -0
  56. package/dist/agent/tool-loop-detector.d.ts +59 -0
  57. package/dist/agent/tool-loop-detector.js +242 -0
  58. package/dist/agent/workflow-runner.d.ts +36 -0
  59. package/dist/agent/workflow-runner.js +317 -0
  60. package/dist/agent/workflow-variables.d.ts +16 -0
  61. package/dist/agent/workflow-variables.js +62 -0
  62. package/dist/channels/discord-agent-bot.d.ts +101 -0
  63. package/dist/channels/discord-agent-bot.js +881 -0
  64. package/dist/channels/discord-bot-manager.d.ts +80 -0
  65. package/dist/channels/discord-bot-manager.js +262 -0
  66. package/dist/channels/discord-utils.d.ts +51 -0
  67. package/dist/channels/discord-utils.js +293 -0
  68. package/dist/channels/discord.d.ts +12 -0
  69. package/dist/channels/discord.js +1832 -0
  70. package/dist/channels/slack-agent-bot.d.ts +73 -0
  71. package/dist/channels/slack-agent-bot.js +320 -0
  72. package/dist/channels/slack-bot-manager.d.ts +66 -0
  73. package/dist/channels/slack-bot-manager.js +236 -0
  74. package/dist/channels/slack-utils.d.ts +39 -0
  75. package/dist/channels/slack-utils.js +189 -0
  76. package/dist/channels/slack.d.ts +11 -0
  77. package/dist/channels/slack.js +196 -0
  78. package/dist/channels/telegram.d.ts +10 -0
  79. package/dist/channels/telegram.js +235 -0
  80. package/dist/channels/webhook.d.ts +9 -0
  81. package/dist/channels/webhook.js +78 -0
  82. package/dist/channels/whatsapp.d.ts +11 -0
  83. package/dist/channels/whatsapp.js +181 -0
  84. package/dist/cli/chat.d.ts +14 -0
  85. package/dist/cli/chat.js +220 -0
  86. package/dist/cli/cron.d.ts +17 -0
  87. package/dist/cli/cron.js +552 -0
  88. package/dist/cli/dashboard.d.ts +15 -0
  89. package/dist/cli/dashboard.js +17677 -0
  90. package/dist/cli/index.d.ts +3 -0
  91. package/dist/cli/index.js +2474 -0
  92. package/dist/cli/routes/delegations.d.ts +19 -0
  93. package/dist/cli/routes/delegations.js +154 -0
  94. package/dist/cli/routes/digest.d.ts +17 -0
  95. package/dist/cli/routes/digest.js +375 -0
  96. package/dist/cli/routes/goals.d.ts +14 -0
  97. package/dist/cli/routes/goals.js +258 -0
  98. package/dist/cli/routes/workflows.d.ts +18 -0
  99. package/dist/cli/routes/workflows.js +97 -0
  100. package/dist/cli/setup.d.ts +8 -0
  101. package/dist/cli/setup.js +619 -0
  102. package/dist/cli/tunnel.d.ts +35 -0
  103. package/dist/cli/tunnel.js +141 -0
  104. package/dist/config.d.ts +145 -0
  105. package/dist/config.js +278 -0
  106. package/dist/events/bus.d.ts +43 -0
  107. package/dist/events/bus.js +136 -0
  108. package/dist/gateway/cron-scheduler.d.ts +166 -0
  109. package/dist/gateway/cron-scheduler.js +1767 -0
  110. package/dist/gateway/delivery-queue.d.ts +30 -0
  111. package/dist/gateway/delivery-queue.js +110 -0
  112. package/dist/gateway/heartbeat-scheduler.d.ts +99 -0
  113. package/dist/gateway/heartbeat-scheduler.js +1298 -0
  114. package/dist/gateway/heartbeat.d.ts +3 -0
  115. package/dist/gateway/heartbeat.js +3 -0
  116. package/dist/gateway/lanes.d.ts +24 -0
  117. package/dist/gateway/lanes.js +76 -0
  118. package/dist/gateway/notifications.d.ts +29 -0
  119. package/dist/gateway/notifications.js +75 -0
  120. package/dist/gateway/router.d.ts +210 -0
  121. package/dist/gateway/router.js +1330 -0
  122. package/dist/index.d.ts +12 -0
  123. package/dist/index.js +1015 -0
  124. package/dist/memory/chunker.d.ts +28 -0
  125. package/dist/memory/chunker.js +226 -0
  126. package/dist/memory/consolidation.d.ts +44 -0
  127. package/dist/memory/consolidation.js +171 -0
  128. package/dist/memory/context-assembler.d.ts +50 -0
  129. package/dist/memory/context-assembler.js +149 -0
  130. package/dist/memory/embeddings.d.ts +38 -0
  131. package/dist/memory/embeddings.js +180 -0
  132. package/dist/memory/graph-store.d.ts +66 -0
  133. package/dist/memory/graph-store.js +613 -0
  134. package/dist/memory/mmr.d.ts +21 -0
  135. package/dist/memory/mmr.js +75 -0
  136. package/dist/memory/search.d.ts +26 -0
  137. package/dist/memory/search.js +67 -0
  138. package/dist/memory/store.d.ts +530 -0
  139. package/dist/memory/store.js +2022 -0
  140. package/dist/security/integrity.d.ts +24 -0
  141. package/dist/security/integrity.js +58 -0
  142. package/dist/security/patterns.d.ts +34 -0
  143. package/dist/security/patterns.js +110 -0
  144. package/dist/security/scanner.d.ts +32 -0
  145. package/dist/security/scanner.js +263 -0
  146. package/dist/tools/admin-tools.d.ts +12 -0
  147. package/dist/tools/admin-tools.js +1278 -0
  148. package/dist/tools/external-tools.d.ts +11 -0
  149. package/dist/tools/external-tools.js +1327 -0
  150. package/dist/tools/goal-tools.d.ts +9 -0
  151. package/dist/tools/goal-tools.js +159 -0
  152. package/dist/tools/mcp-server.d.ts +13 -0
  153. package/dist/tools/mcp-server.js +141 -0
  154. package/dist/tools/memory-tools.d.ts +10 -0
  155. package/dist/tools/memory-tools.js +568 -0
  156. package/dist/tools/session-tools.d.ts +6 -0
  157. package/dist/tools/session-tools.js +146 -0
  158. package/dist/tools/shared.d.ts +216 -0
  159. package/dist/tools/shared.js +340 -0
  160. package/dist/tools/team-tools.d.ts +6 -0
  161. package/dist/tools/team-tools.js +447 -0
  162. package/dist/tools/tool-meta.d.ts +34 -0
  163. package/dist/tools/tool-meta.js +133 -0
  164. package/dist/tools/vault-tools.d.ts +8 -0
  165. package/dist/tools/vault-tools.js +457 -0
  166. package/dist/types.d.ts +716 -0
  167. package/dist/types.js +16 -0
  168. package/dist/vault-migrations/0001-add-execution-framework.d.ts +10 -0
  169. package/dist/vault-migrations/0001-add-execution-framework.js +47 -0
  170. package/dist/vault-migrations/0002-add-agentic-communication.d.ts +12 -0
  171. package/dist/vault-migrations/0002-add-agentic-communication.js +79 -0
  172. package/dist/vault-migrations/0003-update-execution-pipeline-narration.d.ts +11 -0
  173. package/dist/vault-migrations/0003-update-execution-pipeline-narration.js +73 -0
  174. package/dist/vault-migrations/helpers.d.ts +14 -0
  175. package/dist/vault-migrations/helpers.js +44 -0
  176. package/dist/vault-migrations/runner.d.ts +14 -0
  177. package/dist/vault-migrations/runner.js +139 -0
  178. package/dist/vault-migrations/types.d.ts +42 -0
  179. package/dist/vault-migrations/types.js +9 -0
  180. package/install.sh +320 -0
  181. package/package.json +84 -0
  182. package/scripts/postinstall.js +125 -0
  183. package/vault/00-System/AGENTS.md +66 -0
  184. package/vault/00-System/CRON.md +71 -0
  185. package/vault/00-System/HEARTBEAT.md +58 -0
  186. package/vault/00-System/MEMORY.md +16 -0
  187. package/vault/00-System/SOUL.md +96 -0
  188. package/vault/05-Tasks/TASKS.md +19 -0
  189. package/vault/06-Templates/_Daily-Template.md +28 -0
  190. package/vault/06-Templates/_People-Template.md +22 -0
@@ -0,0 +1,2474 @@
1
+ #!/usr/bin/env node
2
+ // Enable Node.js module compile cache for faster startup
3
+ import { enableCompileCache } from 'node:module';
4
+ try {
5
+ enableCompileCache?.();
6
+ }
7
+ catch {
8
+ // Not available in older Node.js versions — ignore
9
+ }
10
+ /**
11
+ * Clementine CLI — launch, stop, restart, status, doctor, config.
12
+ *
13
+ * Works from any directory. Data lives in ~/.clementine/ (or CLEMENTINE_HOME).
14
+ * Code lives wherever npm installed the package.
15
+ */
16
+ import { Command } from 'commander';
17
+ import { spawn, execSync } from 'node:child_process';
18
+ import { cpSync, existsSync, openSync, closeSync, readSync, readFileSync, readdirSync, writeFileSync, unlinkSync, mkdirSync, statSync, } from 'node:fs';
19
+ import os from 'node:os';
20
+ import path from 'node:path';
21
+ import { fileURLToPath } from 'node:url';
22
+ import { runSetup } from './setup.js';
23
+ import { cmdCronList, cmdCronRun, cmdCronRunDue, cmdCronRuns, cmdCronAdd, cmdCronTest, cmdHeartbeat } from './cron.js';
24
+ import { cmdDashboard } from './dashboard.js';
25
+ import { cmdChat } from './chat.js';
26
+ const __filename = fileURLToPath(import.meta.url);
27
+ const __dirname = path.dirname(__filename);
28
+ // ── Path resolution ─────────────────────────────────────────────────
29
+ /** Data home — vault, .env, logs, sessions, PID file. */
30
+ const BASE_DIR = process.env.CLEMENTINE_HOME || path.join(os.homedir(), '.clementine');
31
+ /**
32
+ * Package root (wherever npm installed the package).
33
+ * CLI lives at dist/cli/index.js, so two levels up = package root.
34
+ */
35
+ const PACKAGE_ROOT = path.resolve(__dirname, '..', '..');
36
+ /** Compiled entry point for the main process. */
37
+ const DIST_ENTRY = path.join(PACKAGE_ROOT, 'dist', 'index.js');
38
+ const ENV_PATH = path.join(BASE_DIR, '.env');
39
+ // ── Helpers ──────────────────────────────────────────────────────────
40
+ function getAssistantName() {
41
+ if (existsSync(ENV_PATH)) {
42
+ const content = readFileSync(ENV_PATH, 'utf-8');
43
+ const match = content.match(/^ASSISTANT_NAME=(.+)$/m);
44
+ if (match)
45
+ return match[1].trim();
46
+ }
47
+ return 'Clementine';
48
+ }
49
+ function getPidFilePath() {
50
+ const name = getAssistantName().toLowerCase();
51
+ return path.join(BASE_DIR, `.${name}.pid`);
52
+ }
53
+ function getLaunchdLabel() {
54
+ return `com.${getAssistantName().toLowerCase()}.assistant`;
55
+ }
56
+ function getLaunchdPlistPath() {
57
+ const home = process.env.HOME ?? '';
58
+ return path.join(home, 'Library', 'LaunchAgents', `${getLaunchdLabel()}.plist`);
59
+ }
60
+ function readPid() {
61
+ const pidFile = getPidFilePath();
62
+ if (!existsSync(pidFile))
63
+ return null;
64
+ try {
65
+ const pid = parseInt(readFileSync(pidFile, 'utf-8').trim(), 10);
66
+ return isNaN(pid) ? null : pid;
67
+ }
68
+ catch {
69
+ return null;
70
+ }
71
+ }
72
+ function isProcessAlive(pid) {
73
+ try {
74
+ process.kill(pid, 0);
75
+ return true;
76
+ }
77
+ catch {
78
+ return false;
79
+ }
80
+ }
81
+ function killPid(pid) {
82
+ try {
83
+ process.kill(pid, 'SIGTERM');
84
+ }
85
+ catch {
86
+ return;
87
+ }
88
+ // Wait up to 5 seconds for graceful shutdown
89
+ const deadline = Date.now() + 5000;
90
+ while (Date.now() < deadline) {
91
+ if (!isProcessAlive(pid))
92
+ return;
93
+ const waitMs = 100;
94
+ const waitUntil = Date.now() + waitMs;
95
+ while (Date.now() < waitUntil) {
96
+ // busy-wait (short)
97
+ }
98
+ }
99
+ // Force kill
100
+ try {
101
+ process.kill(pid, 'SIGKILL');
102
+ }
103
+ catch {
104
+ // already dead
105
+ }
106
+ }
107
+ /** Stop the daemon safely: unload LaunchAgent first (prevents respawn), then kill the process. */
108
+ function stopDaemon(pid) {
109
+ // Unload LaunchAgent BEFORE killing — otherwise launchd respawns it immediately
110
+ if (process.platform === 'darwin') {
111
+ const plist = getLaunchdPlistPath();
112
+ if (existsSync(plist)) {
113
+ try {
114
+ execSync(`launchctl unload "${plist}"`, { stdio: 'pipe' });
115
+ }
116
+ catch {
117
+ // not loaded — that's fine
118
+ }
119
+ }
120
+ }
121
+ killPid(pid);
122
+ }
123
+ /** Bootstrap ~/.clementine/ on first run — create data dir and copy vault templates. */
124
+ function ensureDataHome() {
125
+ if (!existsSync(BASE_DIR)) {
126
+ mkdirSync(BASE_DIR, { recursive: true });
127
+ console.log(` Created ${BASE_DIR}`);
128
+ }
129
+ const vaultDir = path.join(BASE_DIR, 'vault');
130
+ const pkgVault = path.join(PACKAGE_ROOT, 'vault');
131
+ if (!existsSync(vaultDir) && existsSync(pkgVault)) {
132
+ cpSync(pkgVault, vaultDir, { recursive: true });
133
+ console.log(' Copied vault templates.');
134
+ }
135
+ }
136
+ // ── Commands ─────────────────────────────────────────────────────────
137
+ function cmdLaunch(options) {
138
+ if (options.uninstall) {
139
+ const plistPath = getLaunchdPlistPath();
140
+ if (existsSync(plistPath)) {
141
+ try {
142
+ execSync(`launchctl unload "${plistPath}"`, { stdio: 'ignore' });
143
+ }
144
+ catch {
145
+ // not loaded
146
+ }
147
+ unlinkSync(plistPath);
148
+ console.log(` Uninstalled LaunchAgent: ${getLaunchdLabel()}`);
149
+ }
150
+ else {
151
+ console.log(' LaunchAgent not installed.');
152
+ }
153
+ return;
154
+ }
155
+ if (options.install) {
156
+ const plistPath = getLaunchdPlistPath();
157
+ const plistDir = path.dirname(plistPath);
158
+ if (!existsSync(plistDir)) {
159
+ mkdirSync(plistDir, { recursive: true });
160
+ }
161
+ // Unload existing plist if already installed (idempotent reinstall)
162
+ if (existsSync(plistPath)) {
163
+ try {
164
+ execSync(`launchctl unload "${plistPath}"`, { stdio: 'ignore' });
165
+ }
166
+ catch {
167
+ // not loaded — fine
168
+ }
169
+ }
170
+ const nodePath = process.execPath;
171
+ const logDir = path.join(BASE_DIR, 'logs');
172
+ if (!existsSync(logDir)) {
173
+ mkdirSync(logDir, { recursive: true });
174
+ }
175
+ const plist = `<?xml version="1.0" encoding="UTF-8"?>
176
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
177
+ <plist version="1.0">
178
+ <dict>
179
+ <key>Label</key>
180
+ <string>${getLaunchdLabel()}</string>
181
+ <key>ProgramArguments</key>
182
+ <array>
183
+ <string>${nodePath}</string>
184
+ <string>${DIST_ENTRY}</string>
185
+ </array>
186
+ <key>WorkingDirectory</key>
187
+ <string>${BASE_DIR}</string>
188
+ <key>RunAtLoad</key>
189
+ <true/>
190
+ <key>KeepAlive</key>
191
+ <true/>
192
+ <key>ThrottleInterval</key>
193
+ <integer>5</integer>
194
+ <key>StandardOutPath</key>
195
+ <string>${path.join(logDir, 'clementine.log')}</string>
196
+ <key>StandardErrorPath</key>
197
+ <string>${path.join(logDir, 'clementine-error.log')}</string>
198
+ <key>EnvironmentVariables</key>
199
+ <dict>
200
+ <key>PATH</key>
201
+ <string>${buildLaunchdPath()}</string>
202
+ <key>CLEMENTINE_HOME</key>
203
+ <string>${BASE_DIR}</string>
204
+ </dict>
205
+ </dict>
206
+ </plist>`;
207
+ writeFileSync(plistPath, plist);
208
+ try {
209
+ execSync(`launchctl load "${plistPath}"`);
210
+ console.log(` Installed and loaded LaunchAgent: ${getLaunchdLabel()}`);
211
+ console.log(` Plist: ${plistPath}`);
212
+ console.log(` Logs: ${logDir}/`);
213
+ }
214
+ catch (err) {
215
+ console.error(` Failed to load LaunchAgent: ${err}`);
216
+ }
217
+ // Also install the cron scheduler alongside the daemon
218
+ console.log();
219
+ cmdCronInstall();
220
+ return;
221
+ }
222
+ // First-run bootstrap
223
+ ensureDataHome();
224
+ if (!existsSync(ENV_PATH)) {
225
+ console.log(` No .env file found at ${ENV_PATH}`);
226
+ console.log(' Run: clementine config setup');
227
+ console.log();
228
+ return;
229
+ }
230
+ // Stop any existing instance first (unload LaunchAgent to prevent respawn)
231
+ const existingPid = readPid();
232
+ if (existingPid && isProcessAlive(existingPid)) {
233
+ console.log(` Stopping existing instance (PID ${existingPid})...`);
234
+ stopDaemon(existingPid);
235
+ }
236
+ if (options.foreground) {
237
+ // Foreground mode: import and run the entry point directly
238
+ process.env.CLEMENTINE_HOME = BASE_DIR;
239
+ import('../index.js').catch((err) => {
240
+ console.error('Failed to start:', err);
241
+ process.exit(1);
242
+ });
243
+ return;
244
+ }
245
+ // Daemon mode (default) — redirect stdout+stderr to log file
246
+ const logDir = path.join(BASE_DIR, 'logs');
247
+ if (!existsSync(logDir)) {
248
+ mkdirSync(logDir, { recursive: true });
249
+ }
250
+ const logFile = path.join(logDir, 'clementine.log');
251
+ const logFd = openSync(logFile, 'a');
252
+ const child = spawn('node', [DIST_ENTRY], {
253
+ detached: true,
254
+ stdio: ['ignore', logFd, logFd],
255
+ cwd: BASE_DIR,
256
+ env: { ...process.env, CLEMENTINE_HOME: BASE_DIR },
257
+ });
258
+ if (child.pid) {
259
+ writeFileSync(getPidFilePath(), String(child.pid));
260
+ console.log(` ${getAssistantName()} started in background (PID ${child.pid})`);
261
+ console.log(` Logs: ${logFile}`);
262
+ }
263
+ child.unref();
264
+ closeSync(logFd);
265
+ }
266
+ function cmdStop() {
267
+ const pid = readPid();
268
+ if (!pid) {
269
+ console.log(' No running instance found.');
270
+ return;
271
+ }
272
+ if (!isProcessAlive(pid)) {
273
+ console.log(` PID ${pid} is not running. Cleaning up PID file.`);
274
+ try {
275
+ unlinkSync(getPidFilePath());
276
+ }
277
+ catch { /* ignore */ }
278
+ return;
279
+ }
280
+ console.log(` Stopping ${getAssistantName()} (PID ${pid})...`);
281
+ stopDaemon(pid);
282
+ if (isProcessAlive(pid)) {
283
+ console.log(' Process did not exit cleanly.');
284
+ }
285
+ else {
286
+ console.log(' Stopped.');
287
+ try {
288
+ unlinkSync(getPidFilePath());
289
+ }
290
+ catch { /* ignore */ }
291
+ }
292
+ }
293
+ async function cmdRestart(options) {
294
+ cmdStop();
295
+ // Kill ALL dashboard processes (not just PID file — catches orphans)
296
+ let dashboardWasRunning = false;
297
+ try {
298
+ const { killExistingDashboards } = await import('./dashboard.js');
299
+ const killed = killExistingDashboards();
300
+ if (killed > 0) {
301
+ dashboardWasRunning = true;
302
+ console.log(` Stopped ${killed} dashboard process(es).`);
303
+ }
304
+ }
305
+ catch { /* dashboard module may not be available */ }
306
+ cmdLaunch({ foreground: options.foreground });
307
+ if (dashboardWasRunning) {
308
+ try {
309
+ const { spawn: spawnProc } = await import('node:child_process');
310
+ const child = spawnProc('node', [path.join(PACKAGE_ROOT, 'dist/cli/index.js'), 'dashboard'], { detached: true, stdio: 'ignore' });
311
+ child.unref();
312
+ console.log(' Dashboard relaunched.');
313
+ }
314
+ catch {
315
+ console.log(' Could not relaunch dashboard — run: clementine dashboard');
316
+ }
317
+ }
318
+ }
319
+ function cmdStatus() {
320
+ const pid = readPid();
321
+ const name = getAssistantName();
322
+ if (!pid) {
323
+ console.log(` ${name} is not running (no PID file).`);
324
+ return;
325
+ }
326
+ if (!isProcessAlive(pid)) {
327
+ console.log(` ${name} is not running (stale PID ${pid}).`);
328
+ return;
329
+ }
330
+ console.log(` ${name} is running (PID ${pid})`);
331
+ // Show uptime from PID file mtime
332
+ try {
333
+ const { mtimeMs } = statSync(getPidFilePath());
334
+ const uptimeMs = Date.now() - mtimeMs;
335
+ const hours = Math.floor(uptimeMs / 3600000);
336
+ const minutes = Math.floor((uptimeMs % 3600000) / 60000);
337
+ console.log(` Uptime: ${hours}h ${minutes}m`);
338
+ }
339
+ catch {
340
+ // ignore
341
+ }
342
+ // Show active channels from env
343
+ const channels = [];
344
+ if (existsSync(ENV_PATH)) {
345
+ const env = readFileSync(ENV_PATH, 'utf-8');
346
+ if (/^DISCORD_TOKEN=.+$/m.test(env))
347
+ channels.push('Discord');
348
+ if (/^SLACK_BOT_TOKEN=.+$/m.test(env) && /^SLACK_APP_TOKEN=.+$/m.test(env))
349
+ channels.push('Slack');
350
+ if (/^TELEGRAM_BOT_TOKEN=.+$/m.test(env))
351
+ channels.push('Telegram');
352
+ if (/^TWILIO_ACCOUNT_SID=.+$/m.test(env))
353
+ channels.push('WhatsApp');
354
+ if (/^WEBHOOK_ENABLED=true$/m.test(env))
355
+ channels.push('Webhook');
356
+ }
357
+ if (channels.length > 0) {
358
+ console.log(` Channels: ${channels.join(', ')}`);
359
+ }
360
+ }
361
+ function cmdDoctor(opts = {}) {
362
+ const DIM = '\x1b[0;90m';
363
+ const GREEN = '\x1b[0;32m';
364
+ const RED = '\x1b[0;31m';
365
+ const YELLOW = '\x1b[1;33m';
366
+ const CYAN = '\x1b[0;36m';
367
+ const RESET = '\x1b[0m';
368
+ const fix = opts.fix ?? false;
369
+ console.log();
370
+ console.log(` ${DIM}Data home: ${BASE_DIR}${RESET}`);
371
+ console.log(` ${DIM}Running health checks...${fix ? ` (auto-fix enabled)` : ''}${RESET}`);
372
+ console.log();
373
+ let issues = 0;
374
+ let fixed = 0;
375
+ const isMac = process.platform === 'darwin';
376
+ const isLinux = process.platform === 'linux';
377
+ const hasBrew = isMac && (() => { try {
378
+ execSync('which brew', { stdio: 'pipe' });
379
+ return true;
380
+ }
381
+ catch {
382
+ return false;
383
+ } })();
384
+ const hasApt = isLinux && (() => { try {
385
+ execSync('which apt-get', { stdio: 'pipe' });
386
+ return true;
387
+ }
388
+ catch {
389
+ return false;
390
+ } })();
391
+ /** Attempt a fix command, return true on success. */
392
+ function tryFix(label, cmd, opts) {
393
+ if (!fix)
394
+ return false;
395
+ console.log(` ${CYAN}Fixing:${RESET} ${cmd}`);
396
+ try {
397
+ execSync(cmd, {
398
+ stdio: ['pipe', 'inherit', 'inherit'], // No stdin — prevent interactive prompts
399
+ timeout: opts?.timeout ?? 120000,
400
+ cwd: opts?.cwd,
401
+ env: { ...process.env, NONINTERACTIVE: '1', HOMEBREW_NO_AUTO_UPDATE: '1' },
402
+ });
403
+ console.log(` ${GREEN}Fixed!${RESET} ${label}`);
404
+ fixed++;
405
+ return true;
406
+ }
407
+ catch {
408
+ console.log(` ${RED}Fix failed.${RESET} Run manually: ${cmd}`);
409
+ return false;
410
+ }
411
+ }
412
+ // Node version (require 20–24 LTS)
413
+ const nodeVersion = process.version;
414
+ const major = parseInt(nodeVersion.slice(1), 10);
415
+ if (major >= 20 && major <= 24) {
416
+ console.log(` ${GREEN}OK${RESET} Node.js ${nodeVersion}`);
417
+ }
418
+ else if (major > 24) {
419
+ console.log(` ${RED}FAIL${RESET} Node.js ${nodeVersion} — SDK requires Node 20–24 LTS`);
420
+ console.log(` Install Node 22: nvm install 22`);
421
+ issues++;
422
+ }
423
+ else {
424
+ console.log(` ${RED}FAIL${RESET} Node.js ${nodeVersion} (need >= 20)`);
425
+ issues++;
426
+ }
427
+ // Claude CLI
428
+ try {
429
+ execSync('which claude', { stdio: 'pipe' });
430
+ console.log(` ${GREEN}OK${RESET} claude CLI found`);
431
+ }
432
+ catch {
433
+ console.log(` ${RED}FAIL${RESET} claude CLI not found`);
434
+ if (!tryFix('claude CLI', 'npm install -g @anthropic-ai/claude-code')) {
435
+ console.log(` Install: npm install -g @anthropic-ai/claude-code`);
436
+ issues++;
437
+ }
438
+ }
439
+ // SDK smoke test — verify claude CLI can actually execute
440
+ try {
441
+ execSync('claude --version', { stdio: 'pipe', timeout: 10000 });
442
+ console.log(` ${GREEN}OK${RESET} claude CLI executes successfully`);
443
+ }
444
+ catch {
445
+ console.log(` ${RED}FAIL${RESET} claude CLI found but failed to execute`);
446
+ console.log(` Check Node version compatibility and run: npm install -g @anthropic-ai/claude-code`);
447
+ issues++;
448
+ }
449
+ // better-sqlite3 native module
450
+ try {
451
+ execSync('node -e "require(\'better-sqlite3\')"', {
452
+ cwd: PACKAGE_ROOT,
453
+ stdio: 'pipe',
454
+ timeout: 10000,
455
+ });
456
+ console.log(` ${GREEN}OK${RESET} better-sqlite3 native module loads`);
457
+ }
458
+ catch {
459
+ console.log(` ${RED}FAIL${RESET} better-sqlite3 native module broken (Node version mismatch)`);
460
+ if (!tryFix('better-sqlite3', 'npm rebuild better-sqlite3', { cwd: PACKAGE_ROOT })) {
461
+ console.log(` Fix: cd ${PACKAGE_ROOT} && npm rebuild better-sqlite3`);
462
+ issues++;
463
+ }
464
+ }
465
+ // FalkorDB graph engine — system dependencies: redis
466
+ try {
467
+ execSync('which redis-server', { stdio: 'pipe' });
468
+ console.log(` ${GREEN}OK${RESET} redis-server found`);
469
+ }
470
+ catch {
471
+ console.log(` ${RED}FAIL${RESET} redis-server not found (required for knowledge graph)`);
472
+ const fixCmd = hasBrew ? 'brew install redis' : hasApt ? 'sudo apt-get install -y redis-server' : null;
473
+ if (fixCmd && tryFix('redis-server', fixCmd)) {
474
+ // fixed
475
+ }
476
+ else if (!fixCmd && fix) {
477
+ console.log(` ${YELLOW}Cannot auto-fix:${RESET} no supported package manager found`);
478
+ console.log(` Install redis-server manually`);
479
+ issues++;
480
+ }
481
+ else {
482
+ console.log(` Fix: brew install redis (macOS) or sudo apt install redis-server (Linux)`);
483
+ issues++;
484
+ }
485
+ }
486
+ // FalkorDB graph engine — system dependencies: libomp
487
+ try {
488
+ const libompPaths = process.platform === 'darwin'
489
+ ? ['/opt/homebrew/opt/libomp/lib/libomp.dylib', '/usr/local/opt/libomp/lib/libomp.dylib']
490
+ : ['/usr/lib/libomp.so', '/usr/lib/x86_64-linux-gnu/libomp.so'];
491
+ if (!libompPaths.some(p => existsSync(p))) {
492
+ throw new Error('not found');
493
+ }
494
+ console.log(` ${GREEN}OK${RESET} libomp (OpenMP runtime) found`);
495
+ }
496
+ catch {
497
+ console.log(` ${RED}FAIL${RESET} libomp (OpenMP runtime) not found (required for knowledge graph)`);
498
+ const fixCmd = hasBrew ? 'brew install libomp' : hasApt ? 'sudo apt-get install -y libomp-dev' : null;
499
+ if (fixCmd && tryFix('libomp', fixCmd)) {
500
+ // fixed
501
+ }
502
+ else if (!fixCmd && fix) {
503
+ console.log(` ${YELLOW}Cannot auto-fix:${RESET} no supported package manager found`);
504
+ console.log(` Install libomp manually`);
505
+ issues++;
506
+ }
507
+ else {
508
+ console.log(` Fix: brew install libomp (macOS) or sudo apt install libomp-dev (Linux)`);
509
+ issues++;
510
+ }
511
+ }
512
+ // FalkorDB graph engine — module binaries
513
+ try {
514
+ execSync(`node -e "const{BinaryManager}=require('falkordblite/dist/binary-manager.js');new BinaryManager().ensureBinaries().then(p=>{console.log(JSON.stringify(p));process.exit(0)}).catch(e=>{console.error(e.message);process.exit(1)})"`, { cwd: PACKAGE_ROOT, stdio: 'pipe', timeout: 30000 });
515
+ console.log(` ${GREEN}OK${RESET} FalkorDB graph engine binaries installed`);
516
+ }
517
+ catch {
518
+ console.log(` ${RED}FAIL${RESET} FalkorDB graph engine binaries not available`);
519
+ if (!tryFix('FalkorDB binaries', `node node_modules/falkordblite/scripts/postinstall.js`, { cwd: PACKAGE_ROOT, timeout: 180000 })) {
520
+ console.log(` Fix: cd ${PACKAGE_ROOT} && node node_modules/falkordblite/scripts/postinstall.js`);
521
+ issues++;
522
+ }
523
+ }
524
+ // Data home
525
+ if (existsSync(BASE_DIR)) {
526
+ console.log(` ${GREEN}OK${RESET} Data home exists (${BASE_DIR})`);
527
+ }
528
+ else {
529
+ console.log(` ${YELLOW}WARN${RESET} Data home not found (run: clementine launch)`);
530
+ issues++;
531
+ }
532
+ // .env file
533
+ if (existsSync(ENV_PATH)) {
534
+ console.log(` ${GREEN}OK${RESET} .env file exists`);
535
+ }
536
+ else {
537
+ console.log(` ${YELLOW}WARN${RESET} .env file not found (run: clementine config setup)`);
538
+ issues++;
539
+ }
540
+ // Vault files
541
+ const vaultDir = path.join(BASE_DIR, 'vault');
542
+ const requiredVaultFiles = [
543
+ ['00-System/SOUL.md', 'SOUL.md'],
544
+ ['00-System/AGENTS.md', 'AGENTS.md'],
545
+ ];
546
+ for (const [filePath, _label] of requiredVaultFiles) {
547
+ if (existsSync(path.join(vaultDir, filePath))) {
548
+ console.log(` ${GREEN}OK${RESET} vault/${filePath}`);
549
+ }
550
+ else {
551
+ console.log(` ${RED}FAIL${RESET} vault/${filePath} missing`);
552
+ issues++;
553
+ }
554
+ }
555
+ // Vault directories & assets summary
556
+ const vaultDirs = [
557
+ ['00-System/skills', 'Skills (procedural memory)'],
558
+ ['00-System/agents', 'Agent configs'],
559
+ ];
560
+ for (const [dirPath] of vaultDirs) {
561
+ const fullPath = path.join(vaultDir, dirPath);
562
+ if (existsSync(fullPath)) {
563
+ const count = readdirSync(fullPath).filter(f => f.endsWith('.md')).length;
564
+ console.log(` ${GREEN}OK${RESET} vault/${dirPath}/ (${count} file${count !== 1 ? 's' : ''})`);
565
+ }
566
+ else {
567
+ console.log(` ${YELLOW}WARN${RESET} vault/${dirPath}/ missing (will be created on launch)`);
568
+ }
569
+ }
570
+ // Optional vault files (informational, not failures)
571
+ const optionalFiles = [
572
+ ['00-System/MEMORY.md', 'Long-term memory'],
573
+ ['00-System/HEARTBEAT.md', 'Heartbeat config'],
574
+ ['00-System/CRON.md', 'Cron jobs'],
575
+ ['00-System/FEEDBACK.md', 'Communication preferences'],
576
+ ];
577
+ for (const [filePath, label] of optionalFiles) {
578
+ if (existsSync(path.join(vaultDir, filePath))) {
579
+ console.log(` ${GREEN}OK${RESET} vault/${filePath}`);
580
+ }
581
+ else {
582
+ console.log(` ${DIM} ○ vault/${filePath} (${label} — created on use)${RESET}`);
583
+ }
584
+ }
585
+ // Memory database
586
+ const memDbPath = path.join(vaultDir, '.memory.db');
587
+ if (existsSync(memDbPath)) {
588
+ const sizeKb = Math.round(statSync(memDbPath).size / 1024);
589
+ console.log(` ${GREEN}OK${RESET} memory database (${sizeKb} KB)`);
590
+ }
591
+ else {
592
+ console.log(` ${DIM} ○ memory database (created on first launch)${RESET}`);
593
+ }
594
+ // Channel tokens (informational)
595
+ if (existsSync(ENV_PATH)) {
596
+ const env = readFileSync(ENV_PATH, 'utf-8');
597
+ const channelChecks = [
598
+ ['DISCORD_TOKEN', 'Discord'],
599
+ ['TELEGRAM_BOT_TOKEN', 'Telegram'],
600
+ ['SLACK_BOT_TOKEN', 'Slack'],
601
+ ];
602
+ let anyChannel = false;
603
+ for (const [key, name] of channelChecks) {
604
+ const re = new RegExp(`^${key}=(.+)$`, 'm');
605
+ if (re.test(env)) {
606
+ console.log(` ${GREEN}OK${RESET} ${name} token configured`);
607
+ anyChannel = true;
608
+ }
609
+ }
610
+ if (!anyChannel) {
611
+ console.log(` ${YELLOW}WARN${RESET} No channel tokens configured`);
612
+ issues++;
613
+ }
614
+ }
615
+ // Daemon runtime check — verify it's running and channels connected
616
+ const pidFilePath = getPidFilePath();
617
+ if (existsSync(pidFilePath)) {
618
+ const daemonPid = parseInt(readFileSync(pidFilePath, 'utf-8').trim(), 10);
619
+ let daemonAlive = false;
620
+ try {
621
+ process.kill(daemonPid, 0);
622
+ daemonAlive = true;
623
+ }
624
+ catch { /* dead */ }
625
+ if (daemonAlive) {
626
+ console.log(` ${GREEN}OK${RESET} Daemon running (PID ${daemonPid})`);
627
+ // Check recent logs for startup errors
628
+ const logPath = path.join(BASE_DIR, 'logs', 'clementine.log');
629
+ if (existsSync(logPath)) {
630
+ try {
631
+ const logLines = readFileSync(logPath, 'utf-8').split('\n').filter(Boolean);
632
+ // Check last 200 lines for startup message, last 30 for errors
633
+ const logTail = logLines.slice(-200);
634
+ const recentTail = logLines.slice(-30);
635
+ let discordOk = false;
636
+ const recentErrors = [];
637
+ for (const line of logTail) {
638
+ try {
639
+ const entry = JSON.parse(line);
640
+ // Startup confirmation
641
+ if (entry.msg?.includes('online as') || entry.msg?.includes('Clementine online'))
642
+ discordOk = true;
643
+ // Any discord activity (message processing, reactions, etc.) confirms connection
644
+ if (entry.name === 'clementine.discord' && entry.pid === daemonPid)
645
+ discordOk = true;
646
+ }
647
+ catch { /* skip */ }
648
+ }
649
+ for (const line of recentTail) {
650
+ try {
651
+ const entry = JSON.parse(line);
652
+ if (entry.level >= 50 && entry.pid === daemonPid)
653
+ recentErrors.push(entry.msg?.slice(0, 100) ?? '');
654
+ }
655
+ catch { /* skip */ }
656
+ }
657
+ if (discordOk) {
658
+ console.log(` ${GREEN}OK${RESET} Discord connected`);
659
+ }
660
+ else {
661
+ console.log(` ${YELLOW}WARN${RESET} Discord connection not confirmed in recent logs`);
662
+ issues++;
663
+ }
664
+ if (recentErrors.length > 0) {
665
+ console.log(` ${YELLOW}WARN${RESET} ${recentErrors.length} error(s) in recent logs:`);
666
+ for (const err of recentErrors.slice(0, 3)) {
667
+ console.log(` ${DIM}${err}${RESET}`);
668
+ }
669
+ issues++;
670
+ }
671
+ }
672
+ catch { /* log read failed */ }
673
+ }
674
+ }
675
+ else {
676
+ console.log(` ${RED}FAIL${RESET} Daemon not running (stale PID file: ${daemonPid})`);
677
+ console.log(` Start it: clementine launch`);
678
+ issues++;
679
+ }
680
+ }
681
+ else {
682
+ console.log(` ${DIM} ○ Daemon not running${RESET}`);
683
+ }
684
+ // LaunchAgent health check (macOS only)
685
+ if (process.platform === 'darwin') {
686
+ const plistPath = getLaunchdPlistPath();
687
+ if (existsSync(plistPath)) {
688
+ try {
689
+ execSync(`launchctl list ${getLaunchdLabel()}`, { stdio: 'pipe' });
690
+ console.log(` ${GREEN}OK${RESET} LaunchAgent installed and loaded`);
691
+ }
692
+ catch {
693
+ console.log(` ${YELLOW}WARN${RESET} LaunchAgent installed but not loaded`);
694
+ console.log(` Load it: launchctl load "${plistPath}"`);
695
+ issues++;
696
+ }
697
+ }
698
+ else {
699
+ console.log(` ${YELLOW}WARN${RESET} LaunchAgent not installed (run: clementine launch --install)`);
700
+ issues++;
701
+ }
702
+ }
703
+ console.log();
704
+ if (issues === 0 && fixed === 0) {
705
+ console.log(` ${GREEN}All checks passed.${RESET}`);
706
+ }
707
+ else if (issues === 0 && fixed > 0) {
708
+ console.log(` ${GREEN}All issues fixed!${RESET} (${fixed} auto-fixed)`);
709
+ }
710
+ else if (fixed > 0) {
711
+ console.log(` ${YELLOW}${issues} issue(s) remaining${RESET} (${fixed} auto-fixed)`);
712
+ }
713
+ else {
714
+ console.log(` ${YELLOW}${issues} issue(s) found.${RESET}${!fix ? ` Run ${CYAN}clementine doctor --fix${RESET} to auto-install dependencies.` : ''}`);
715
+ }
716
+ console.log();
717
+ }
718
+ function cmdConfigSet(key, value) {
719
+ ensureDataHome();
720
+ let content = '';
721
+ if (existsSync(ENV_PATH)) {
722
+ content = readFileSync(ENV_PATH, 'utf-8');
723
+ }
724
+ const upperKey = key.toUpperCase();
725
+ const re = new RegExp(`^${upperKey}=.*$`, 'm');
726
+ if (re.test(content)) {
727
+ content = content.replace(re, `${upperKey}=${value}`);
728
+ }
729
+ else {
730
+ content = content.trimEnd() + `\n${upperKey}=${value}\n`;
731
+ }
732
+ writeFileSync(ENV_PATH, content);
733
+ console.log(` Set ${upperKey}=${value}`);
734
+ }
735
+ function cmdConfigGet(key) {
736
+ if (!existsSync(ENV_PATH)) {
737
+ console.log(' No .env file found.');
738
+ return;
739
+ }
740
+ const content = readFileSync(ENV_PATH, 'utf-8');
741
+ const upperKey = key.toUpperCase();
742
+ const re = new RegExp(`^${upperKey}=(.*)$`, 'm');
743
+ const match = content.match(re);
744
+ if (match) {
745
+ console.log(` ${upperKey}=${match[1]}`);
746
+ }
747
+ else {
748
+ console.log(` ${upperKey} is not set.`);
749
+ }
750
+ }
751
+ function cmdConfigList() {
752
+ if (!existsSync(ENV_PATH)) {
753
+ console.log(' No .env file found. Run: clementine config setup');
754
+ return;
755
+ }
756
+ const content = readFileSync(ENV_PATH, 'utf-8');
757
+ const DIM = '\x1b[0;90m';
758
+ const RESET = '\x1b[0m';
759
+ console.log();
760
+ for (const line of content.split('\n')) {
761
+ if (line.startsWith('#')) {
762
+ console.log(` ${DIM}${line}${RESET}`);
763
+ }
764
+ else if (line.trim()) {
765
+ // Mask secret values
766
+ const match = line.match(/^([A-Z_]+)=(.+)$/);
767
+ if (match) {
768
+ const [, k, v] = match;
769
+ const sensitiveKeys = ['TOKEN', 'SECRET', 'API_KEY', 'AUTH_TOKEN', 'SID'];
770
+ const isSensitive = sensitiveKeys.some((s) => k.includes(s));
771
+ if (isSensitive && v.length > 8) {
772
+ console.log(` ${k}=${v.slice(0, 4)}${'*'.repeat(v.length - 8)}${v.slice(-4)}`);
773
+ }
774
+ else {
775
+ console.log(` ${line}`);
776
+ }
777
+ }
778
+ else {
779
+ console.log(` ${line}`);
780
+ }
781
+ }
782
+ }
783
+ console.log();
784
+ }
785
+ // ── Tools command ───────────────────────────────────────────────────
786
+ function cmdTools() {
787
+ const DIM = '\x1b[0;90m';
788
+ const GREEN = '\x1b[0;32m';
789
+ const YELLOW = '\x1b[1;33m';
790
+ const CYAN = '\x1b[0;36m';
791
+ const BOLD = '\x1b[1m';
792
+ const RESET = '\x1b[0m';
793
+ console.log();
794
+ // ── 1. Clementine MCP tools (parse from source) ──────────────────
795
+ const mcpServerSrc = path.join(PACKAGE_ROOT, 'src', 'tools', 'mcp-server.ts');
796
+ const mcpTools = [];
797
+ if (existsSync(mcpServerSrc)) {
798
+ const src = readFileSync(mcpServerSrc, 'utf-8');
799
+ // Match: server.tool(\n 'name',\n 'description' or "description",
800
+ const toolPattern = /server\.tool\(\s*'([^']+)',\s*(['"])(.+?)\2/gs;
801
+ let match;
802
+ while ((match = toolPattern.exec(src)) !== null) {
803
+ mcpTools.push({ name: match[1], description: match[3] });
804
+ }
805
+ }
806
+ if (mcpTools.length > 0) {
807
+ console.log(` ${BOLD}Clementine MCP Tools${RESET} ${DIM}(${mcpTools.length} tools)${RESET}`);
808
+ console.log();
809
+ const maxName = Math.max(...mcpTools.map((t) => t.name.length));
810
+ for (const tool of mcpTools) {
811
+ console.log(` ${CYAN}${tool.name.padEnd(maxName)}${RESET} ${DIM}${tool.description}${RESET}`);
812
+ }
813
+ console.log();
814
+ }
815
+ // ── 2. SDK built-in tools ────────────────────────────────────────
816
+ const sdkTools = [
817
+ { name: 'Read', description: 'Read files from the filesystem' },
818
+ { name: 'Write', description: 'Write/create files' },
819
+ { name: 'Edit', description: 'Edit files with string replacements' },
820
+ { name: 'Bash', description: 'Execute shell commands' },
821
+ { name: 'Glob', description: 'Find files by pattern' },
822
+ { name: 'Grep', description: 'Search file contents' },
823
+ { name: 'WebSearch', description: 'Search the web' },
824
+ { name: 'WebFetch', description: 'Fetch and process web pages' },
825
+ { name: 'Agent', description: 'Spawn sub-agents for complex tasks' },
826
+ { name: 'Task', description: 'Multi-agent task coordination' },
827
+ ];
828
+ console.log(` ${BOLD}SDK Built-in Tools${RESET} ${DIM}(${sdkTools.length} tools)${RESET}`);
829
+ console.log();
830
+ const maxSdk = Math.max(...sdkTools.map((t) => t.name.length));
831
+ for (const tool of sdkTools) {
832
+ console.log(` ${CYAN}${tool.name.padEnd(maxSdk)}${RESET} ${DIM}${tool.description}${RESET}`);
833
+ }
834
+ console.log();
835
+ // ── 3. Claude Code plugins ───────────────────────────────────────
836
+ const home = process.env.HOME ?? '';
837
+ const settingsPath = path.join(home, '.claude', 'settings.json');
838
+ const plugins = [];
839
+ if (existsSync(settingsPath)) {
840
+ try {
841
+ const settings = JSON.parse(readFileSync(settingsPath, 'utf-8'));
842
+ const enabledPlugins = settings.enabledPlugins ?? {};
843
+ for (const [pluginId, enabled] of Object.entries(enabledPlugins)) {
844
+ if (enabled) {
845
+ const [name, source] = pluginId.split('@');
846
+ plugins.push({ name, source: source ?? 'unknown' });
847
+ }
848
+ }
849
+ }
850
+ catch { /* ignore */ }
851
+ }
852
+ if (plugins.length > 0) {
853
+ console.log(` ${BOLD}Claude Code Plugins${RESET} ${DIM}(global)${RESET}`);
854
+ console.log();
855
+ const maxPlugin = Math.max(...plugins.map((p) => p.name.length));
856
+ for (const plugin of plugins) {
857
+ console.log(` ${GREEN}${plugin.name.padEnd(maxPlugin)}${RESET} ${DIM}${plugin.source}${RESET}`);
858
+ }
859
+ console.log();
860
+ }
861
+ // ── 4. Project MCP servers ───────────────────────────────────────
862
+ const projectSettingsPath = path.join(PACKAGE_ROOT, '.claude', 'settings.json');
863
+ const projectMcpServers = [];
864
+ if (existsSync(projectSettingsPath)) {
865
+ try {
866
+ const projSettings = JSON.parse(readFileSync(projectSettingsPath, 'utf-8'));
867
+ const servers = projSettings.mcpServers ?? {};
868
+ for (const serverName of Object.keys(servers)) {
869
+ projectMcpServers.push(serverName);
870
+ }
871
+ }
872
+ catch { /* ignore */ }
873
+ }
874
+ if (projectMcpServers.length > 0) {
875
+ console.log(` ${BOLD}Project MCP Servers${RESET} ${DIM}(from .claude/settings.json)${RESET}`);
876
+ console.log();
877
+ for (const name of projectMcpServers) {
878
+ console.log(` ${YELLOW}${name}${RESET}`);
879
+ }
880
+ console.log();
881
+ }
882
+ // ── 5. Active channels ──────────────────────────────────────────
883
+ const channels = [];
884
+ if (existsSync(ENV_PATH)) {
885
+ const envContent = readFileSync(ENV_PATH, 'utf-8');
886
+ if (/^DISCORD_TOKEN=.+$/m.test(envContent))
887
+ channels.push('Discord');
888
+ if (/^SLACK_BOT_TOKEN=.+$/m.test(envContent) && /^SLACK_APP_TOKEN=.+$/m.test(envContent))
889
+ channels.push('Slack');
890
+ if (/^TELEGRAM_BOT_TOKEN=.+$/m.test(envContent))
891
+ channels.push('Telegram');
892
+ if (/^TWILIO_ACCOUNT_SID=.+$/m.test(envContent))
893
+ channels.push('WhatsApp');
894
+ if (/^WEBHOOK_ENABLED=true$/m.test(envContent))
895
+ channels.push('Webhook');
896
+ }
897
+ if (channels.length > 0) {
898
+ console.log(` ${BOLD}Active Channels${RESET}`);
899
+ console.log();
900
+ for (const ch of channels) {
901
+ console.log(` ${GREEN}${ch}${RESET}`);
902
+ }
903
+ console.log();
904
+ }
905
+ }
906
+ // ── Program ──────────────────────────────────────────────────────────
907
+ const program = new Command();
908
+ program
909
+ .name('clementine')
910
+ .description('Clementine Personal AI Assistant')
911
+ .version('1.0.0');
912
+ program
913
+ .command('launch')
914
+ .description('Start the assistant (daemon by default)')
915
+ .option('-f, --foreground', 'Run in foreground (attached to terminal)')
916
+ .option('--install', 'Install as macOS LaunchAgent')
917
+ .option('--uninstall', 'Remove macOS LaunchAgent')
918
+ .action(cmdLaunch);
919
+ program
920
+ .command('stop')
921
+ .description('Stop the running assistant')
922
+ .action(cmdStop);
923
+ program
924
+ .command('restart')
925
+ .description('Restart the assistant (daemon by default)')
926
+ .option('-f, --foreground', 'Run in foreground after restart')
927
+ .action(cmdRestart);
928
+ program
929
+ .command('rebuild')
930
+ .description('Rebuild from source and restart all processes (daemon + dashboard)')
931
+ .action(async () => {
932
+ const DIM = '\x1b[0;90m';
933
+ const GREEN = '\x1b[0;32m';
934
+ const RED = '\x1b[0;31m';
935
+ const RESET = '\x1b[0m';
936
+ console.log();
937
+ console.log(` ${DIM}Rebuilding ${getAssistantName()}...${RESET}`);
938
+ // 1. Build
939
+ console.log(` [1] Building...`);
940
+ try {
941
+ execSync('npm run build', { cwd: PACKAGE_ROOT, stdio: 'pipe' });
942
+ console.log(` ${GREEN}OK${RESET} Build succeeded`);
943
+ }
944
+ catch (err) {
945
+ const msg = err.stderr?.toString() || String(err);
946
+ console.error(` ${RED}FAIL${RESET} Build failed:\n${msg}`);
947
+ process.exit(1);
948
+ }
949
+ // 2. Reinstall globally so the `clementine` bin points to fresh code
950
+ console.log(` [2] Installing...`);
951
+ try {
952
+ execSync('npm install -g .', { cwd: PACKAGE_ROOT, stdio: 'pipe' });
953
+ console.log(` ${GREEN}OK${RESET} Installed`);
954
+ }
955
+ catch {
956
+ console.log(` ${DIM}(global install skipped — not fatal)${RESET}`);
957
+ }
958
+ // 3. Restart everything
959
+ console.log(` [3] Restarting...`);
960
+ cmdRestart({});
961
+ console.log();
962
+ console.log(` ${GREEN}Done.${RESET} All processes restarted with fresh code.`);
963
+ console.log();
964
+ });
965
+ program
966
+ .command('login')
967
+ .description('Authenticate with Anthropic and save credentials to ~/.clementine/.env')
968
+ .option('--api-key', 'Skip OAuth and use an API key instead')
969
+ .action(async (opts) => {
970
+ const { default: Anthropic } = await import('@anthropic-ai/sdk');
971
+ const envPath = path.join(BASE_DIR, '.env');
972
+ const testAuth = async (opts) => {
973
+ try {
974
+ const client = new Anthropic(opts);
975
+ await client.models.list({ limit: 1 });
976
+ return true;
977
+ }
978
+ catch {
979
+ return false;
980
+ }
981
+ };
982
+ const saveToEnv = (credKey, value) => {
983
+ mkdirSync(BASE_DIR, { recursive: true });
984
+ let content = existsSync(envPath) ? readFileSync(envPath, 'utf-8') : '';
985
+ content = content.replace(new RegExp(`^${credKey}=.*$\\n?`, 'm'), '').trimEnd();
986
+ content += `\n${credKey}=${value}\n`;
987
+ writeFileSync(envPath, content, { mode: 0o600 });
988
+ };
989
+ // Read explicit credentials from .env
990
+ let oauthToken;
991
+ let authToken;
992
+ let apiKey;
993
+ if (existsSync(envPath)) {
994
+ const content = readFileSync(envPath, 'utf-8');
995
+ const oauthMatch = content.match(/^CLAUDE_CODE_OAUTH_TOKEN=(.+)$/m);
996
+ const tokenMatch = content.match(/^ANTHROPIC_AUTH_TOKEN=(.+)$/m);
997
+ const keyMatch = content.match(/^ANTHROPIC_API_KEY=(.+)$/m);
998
+ if (oauthMatch)
999
+ oauthToken = oauthMatch[1].trim();
1000
+ if (tokenMatch)
1001
+ authToken = tokenMatch[1].trim();
1002
+ if (keyMatch)
1003
+ apiKey = keyMatch[1].trim();
1004
+ }
1005
+ if (!oauthToken)
1006
+ oauthToken = process.env.CLAUDE_CODE_OAUTH_TOKEN;
1007
+ if (!authToken)
1008
+ authToken = process.env.ANTHROPIC_AUTH_TOKEN;
1009
+ if (!apiKey)
1010
+ apiKey = process.env.ANTHROPIC_API_KEY;
1011
+ // ── --api-key flag: skip straight to manual key entry ────────────
1012
+ if (opts.apiKey) {
1013
+ const CONSOLE_URL = 'https://console.anthropic.com/settings/keys';
1014
+ console.log('\n Opening Anthropic API keys page...');
1015
+ try {
1016
+ const opener = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
1017
+ execSync(`${opener} "${CONSOLE_URL}"`, { stdio: 'ignore' });
1018
+ }
1019
+ catch { /* non-fatal */ }
1020
+ console.log(` ${CONSOLE_URL}`);
1021
+ console.log(' Paste your API key below and press Enter:\n');
1022
+ process.stdout.write(' Paste key > ');
1023
+ const key = await new Promise((resolve) => {
1024
+ process.stdin.setRawMode?.(false);
1025
+ process.stdin.resume();
1026
+ process.stdin.setEncoding('utf-8');
1027
+ process.stdin.once('data', (chunk) => { process.stdin.pause(); resolve(String(chunk).trim()); });
1028
+ });
1029
+ if (!key) {
1030
+ console.error('\n No input.\n');
1031
+ process.exit(1);
1032
+ }
1033
+ process.stdout.write('\n Verifying...');
1034
+ const isOAuth = key.startsWith('sk-ant-oat') || key.startsWith('sk-ant-rt');
1035
+ const credKey = isOAuth ? 'CLAUDE_CODE_OAUTH_TOKEN' : 'ANTHROPIC_API_KEY';
1036
+ const ok = await testAuth(isOAuth ? { authToken: key } : { apiKey: key });
1037
+ if (!ok) {
1038
+ console.error(' invalid.\n');
1039
+ process.exit(1);
1040
+ }
1041
+ console.log(' ✓\n');
1042
+ saveToEnv(credKey, key);
1043
+ console.log(` ✓ Saved ${credKey} to ~/.clementine/.env\n`);
1044
+ return;
1045
+ }
1046
+ console.log('\nChecking Anthropic authentication...\n');
1047
+ // Test existing explicit credentials first
1048
+ if (oauthToken) {
1049
+ process.stdout.write(` CLAUDE_CODE_OAUTH_TOKEN ${oauthToken.slice(0, 16)}... `);
1050
+ if (await testAuth({ authToken: oauthToken })) {
1051
+ console.log('✓ valid\n');
1052
+ return;
1053
+ }
1054
+ console.log('✗ expired');
1055
+ }
1056
+ if (authToken) {
1057
+ process.stdout.write(` ANTHROPIC_AUTH_TOKEN ${authToken.slice(0, 16)}... `);
1058
+ if (await testAuth({ authToken })) {
1059
+ console.log('✓ valid\n');
1060
+ return;
1061
+ }
1062
+ console.log('✗ expired');
1063
+ }
1064
+ if (apiKey) {
1065
+ process.stdout.write(` ANTHROPIC_API_KEY ${apiKey.slice(0, 16)}... `);
1066
+ if (await testAuth({ apiKey })) {
1067
+ console.log('✓ valid\n');
1068
+ return;
1069
+ }
1070
+ console.log('✗ expired');
1071
+ }
1072
+ // ── Try to pull token from Claude Code keychain (macOS) ──────────
1073
+ if (process.platform === 'darwin') {
1074
+ process.stdout.write('\n Looking for Claude Code session in Keychain... ');
1075
+ try {
1076
+ const raw = execSync('security find-generic-password -s "Claude Code-credentials" -w 2>/dev/null', {
1077
+ encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'],
1078
+ }).trim();
1079
+ const parsed = JSON.parse(raw);
1080
+ const token = parsed?.claudeAiOauth?.accessToken;
1081
+ if (token) {
1082
+ process.stdout.write('found. Verifying... ');
1083
+ if (await testAuth({ authToken: token })) {
1084
+ console.log('✓\n');
1085
+ saveToEnv('ANTHROPIC_AUTH_TOKEN', token);
1086
+ console.log(' ✓ Authenticated via Claude Code subscription');
1087
+ console.log(' Saved to ~/.clementine/.env — no API key needed.\n');
1088
+ return;
1089
+ }
1090
+ console.log('expired.');
1091
+ }
1092
+ else {
1093
+ console.log('not found.');
1094
+ }
1095
+ }
1096
+ catch {
1097
+ console.log('not found.');
1098
+ }
1099
+ }
1100
+ // ── Generate a long-lived token via claude setup-token ───────────
1101
+ console.log('\n Generating a long-lived OAuth token via Claude Code...');
1102
+ console.log(' A browser window will open — complete the authorization, then come back here.\n');
1103
+ // Detect claude binary
1104
+ let claudeBin = 'claude';
1105
+ try {
1106
+ execSync('claude --version', { stdio: 'pipe' });
1107
+ }
1108
+ catch {
1109
+ console.error(' `claude` not found on PATH.');
1110
+ console.error(' Install Claude Code first: https://claude.ai/code\n');
1111
+ process.exit(1);
1112
+ }
1113
+ const token = await new Promise((resolve) => {
1114
+ let output = '';
1115
+ const child = spawn(claudeBin, ['setup-token'], { stdio: ['inherit', 'pipe', 'inherit'] });
1116
+ child.stdout?.on('data', (chunk) => {
1117
+ const text = chunk.toString();
1118
+ process.stdout.write(text);
1119
+ output += text;
1120
+ });
1121
+ child.on('close', () => {
1122
+ // Token is printed to stdout — extract it
1123
+ const match = output.match(/sk-ant-[A-Za-z0-9_-]+/);
1124
+ resolve(match ? match[0] : null);
1125
+ });
1126
+ child.on('error', () => resolve(null));
1127
+ });
1128
+ if (!token) {
1129
+ console.error('\n Could not extract token from output. Try again or use an API key:\n');
1130
+ console.error(' clementine login --api-key\n');
1131
+ process.exit(1);
1132
+ }
1133
+ // CLAUDE_CODE_OAUTH_TOKEN is only usable by the Claude Code subprocess —
1134
+ // not by the raw @anthropic-ai/sdk client. Trust that claude setup-token
1135
+ // already verified it during the OAuth flow; just save it directly.
1136
+ saveToEnv('CLAUDE_CODE_OAUTH_TOKEN', token);
1137
+ console.log('\n ✓ Saved CLAUDE_CODE_OAUTH_TOKEN to ~/.clementine/.env');
1138
+ console.log(' This token is valid for one year and uses your Claude subscription.');
1139
+ console.log(' Run `clementine rebuild` to restart the daemon with the new token.\n');
1140
+ });
1141
+ program
1142
+ .command('auth')
1143
+ .description('Show current authentication status')
1144
+ .action(async () => {
1145
+ const { default: Anthropic } = await import('@anthropic-ai/sdk');
1146
+ const envPath = path.join(BASE_DIR, '.env');
1147
+ let oauthToken;
1148
+ let authToken;
1149
+ let apiKey;
1150
+ if (existsSync(envPath)) {
1151
+ const content = readFileSync(envPath, 'utf-8');
1152
+ const oauthMatch = content.match(/^CLAUDE_CODE_OAUTH_TOKEN=(.+)$/m);
1153
+ const tokenMatch = content.match(/^ANTHROPIC_AUTH_TOKEN=(.+)$/m);
1154
+ const keyMatch = content.match(/^ANTHROPIC_API_KEY=(.+)$/m);
1155
+ if (oauthMatch)
1156
+ oauthToken = oauthMatch[1].trim();
1157
+ if (tokenMatch)
1158
+ authToken = tokenMatch[1].trim();
1159
+ if (keyMatch)
1160
+ apiKey = keyMatch[1].trim();
1161
+ }
1162
+ if (!oauthToken)
1163
+ oauthToken = process.env.CLAUDE_CODE_OAUTH_TOKEN;
1164
+ if (!authToken)
1165
+ authToken = process.env.ANTHROPIC_AUTH_TOKEN;
1166
+ if (!apiKey)
1167
+ apiKey = process.env.ANTHROPIC_API_KEY;
1168
+ const testAuth = async (opts) => {
1169
+ try {
1170
+ const client = new Anthropic(opts);
1171
+ await client.models.list({ limit: 1 });
1172
+ return true;
1173
+ }
1174
+ catch {
1175
+ return false;
1176
+ }
1177
+ };
1178
+ console.log('\nClementine Auth Status');
1179
+ console.log('──────────────────────');
1180
+ if (oauthToken) {
1181
+ // CLAUDE_CODE_OAUTH_TOKEN is only valid for the SDK subprocess, not the raw API client.
1182
+ // Just confirm it's present — the daemon will surface auth errors if it's actually expired.
1183
+ console.log(` CLAUDE_CODE_OAUTH_TOKEN ${oauthToken.slice(0, 16)}... ✓ set (1-year subscription token)`);
1184
+ }
1185
+ if (authToken) {
1186
+ process.stdout.write(` ANTHROPIC_AUTH_TOKEN ${authToken.slice(0, 16)}... `);
1187
+ console.log(await testAuth({ authToken }) ? '✓ valid' : '✗ expired');
1188
+ }
1189
+ if (apiKey) {
1190
+ process.stdout.write(` ANTHROPIC_API_KEY ${apiKey.slice(0, 16)}... `);
1191
+ console.log(await testAuth({ apiKey }) ? '✓ valid' : '✗ expired or revoked');
1192
+ }
1193
+ if (!oauthToken && !authToken && !apiKey) {
1194
+ console.log(' No explicit credentials in ~/.clementine/.env');
1195
+ console.log(' Daemon subprocess reads from macOS Keychain if Claude Code is installed.\n');
1196
+ console.log(' Run `clementine login` to set up credentials.');
1197
+ }
1198
+ console.log('\n To refresh: clementine login');
1199
+ console.log(' API key only: clementine login --api-key\n');
1200
+ });
1201
+ program
1202
+ .command('status')
1203
+ .description('Show assistant status')
1204
+ .action(cmdStatus);
1205
+ program
1206
+ .command('doctor')
1207
+ .description('Run health checks')
1208
+ .option('--fix', 'Auto-install missing dependencies')
1209
+ .action((opts) => cmdDoctor(opts));
1210
+ program
1211
+ .command('tools')
1212
+ .description('List available MCP tools, plugins, and channels')
1213
+ .action(cmdTools);
1214
+ const dashCmd = program
1215
+ .command('dashboard')
1216
+ .description('Launch local command center')
1217
+ .option('-p, --port <n>', 'Port (default 3030)', '3030')
1218
+ .action((opts) => {
1219
+ cmdDashboard(opts).catch((err) => {
1220
+ console.error('Dashboard error:', err);
1221
+ process.exit(1);
1222
+ });
1223
+ });
1224
+ dashCmd
1225
+ .command('restart')
1226
+ .description('Kill all running dashboard processes and relaunch')
1227
+ .option('-p, --port <n>', 'Port (default 3030)', '3030')
1228
+ .action(async (opts) => {
1229
+ const { killExistingDashboards } = await import('./dashboard.js');
1230
+ const killed = killExistingDashboards();
1231
+ console.log(killed > 0 ? ` Killed ${killed} dashboard process(es).` : ' No dashboard processes found.');
1232
+ console.log(' Relaunching dashboard...');
1233
+ const { spawn } = await import('node:child_process');
1234
+ const child = spawn('node', [path.join(PACKAGE_ROOT, 'dist/cli/index.js'), 'dashboard', '-p', opts.port ?? '3030'], { detached: true, stdio: 'ignore' });
1235
+ child.unref();
1236
+ console.log(' Dashboard restarted.');
1237
+ process.exit(0);
1238
+ });
1239
+ dashCmd
1240
+ .command('stop')
1241
+ .description('Stop all running dashboard processes')
1242
+ .action(async () => {
1243
+ const { killExistingDashboards } = await import('./dashboard.js');
1244
+ const killed = killExistingDashboards();
1245
+ console.log(killed > 0 ? ` Killed ${killed} dashboard process(es).` : ' No dashboard processes running.');
1246
+ });
1247
+ program
1248
+ .command('chat')
1249
+ .description('Interactive REPL chat session')
1250
+ .option('-m, --model <tier>', 'Model tier (haiku, sonnet, opus)')
1251
+ .option('--project <name>', 'Set active project context')
1252
+ .option('--profile <slug>', 'Set agent profile')
1253
+ .action((opts) => {
1254
+ cmdChat(opts).catch((err) => {
1255
+ console.error('Chat error:', err);
1256
+ process.exit(1);
1257
+ });
1258
+ });
1259
+ program
1260
+ .command('update')
1261
+ .description('Pull latest code, rebuild, and reinstall (preserves config)')
1262
+ .argument('[action]', 'Optional: "restart" to restart daemon after update')
1263
+ .option('--restart', 'Restart daemon after update')
1264
+ .option('--dry-run', 'Preview what would happen without making changes')
1265
+ .action((action, options) => {
1266
+ if (action === 'restart')
1267
+ options.restart = true;
1268
+ cmdUpdate(options).catch((err) => {
1269
+ console.error('Update failed:', err);
1270
+ process.exit(1);
1271
+ });
1272
+ });
1273
+ const configCmd = program
1274
+ .command('config')
1275
+ .description('Manage configuration');
1276
+ configCmd
1277
+ .command('setup')
1278
+ .description('Run interactive setup wizard')
1279
+ .action(() => {
1280
+ ensureDataHome();
1281
+ runSetup().catch((err) => {
1282
+ console.error('Setup failed:', err);
1283
+ process.exit(1);
1284
+ });
1285
+ });
1286
+ configCmd
1287
+ .command('set <key> <value>')
1288
+ .description('Set a config value in .env')
1289
+ .action(cmdConfigSet);
1290
+ configCmd
1291
+ .command('get <key>')
1292
+ .description('Get a config value from .env')
1293
+ .action(cmdConfigGet);
1294
+ configCmd
1295
+ .command('list')
1296
+ .description('List all config values')
1297
+ .action(cmdConfigList);
1298
+ // ── Update command ──────────────────────────────────────────────────
1299
+ async function cmdUpdate(options) {
1300
+ const DIM = '\x1b[0;90m';
1301
+ const GREEN = '\x1b[0;32m';
1302
+ const YELLOW = '\x1b[1;33m';
1303
+ const RED = '\x1b[0;31m';
1304
+ const RESET = '\x1b[0m';
1305
+ console.log();
1306
+ console.log(` ${DIM}Updating ${getAssistantName()}...${RESET}`);
1307
+ console.log();
1308
+ // 1. Check we're in a git repo
1309
+ if (!existsSync(path.join(PACKAGE_ROOT, '.git'))) {
1310
+ console.error(` ${RED}FAIL${RESET} Package root is not a git repository: ${PACKAGE_ROOT}`);
1311
+ console.error(' Update requires a git-cloned installation.');
1312
+ process.exit(1);
1313
+ }
1314
+ let step = 0;
1315
+ const S = () => `[${++step}]`;
1316
+ // 2. Ensure we're on main and reset any local src/ changes.
1317
+ // Source modifications are tracked in ~/.clementine/ (not git),
1318
+ // so resetting the working tree is safe — mods get re-applied after pull.
1319
+ if (!options.dryRun) {
1320
+ try {
1321
+ const currentBranch = execSync('git rev-parse --abbrev-ref HEAD', {
1322
+ cwd: PACKAGE_ROOT,
1323
+ encoding: 'utf-8',
1324
+ }).trim();
1325
+ if (currentBranch !== 'main') {
1326
+ console.log(` ${S()} Switching to main branch...`);
1327
+ execSync('git checkout main', { cwd: PACKAGE_ROOT, stdio: 'pipe' });
1328
+ console.log(` ${GREEN}OK${RESET} Switched to main`);
1329
+ }
1330
+ }
1331
+ catch { /* best effort */ }
1332
+ try {
1333
+ execSync('git checkout -- src/', { cwd: PACKAGE_ROOT, stdio: 'pipe' });
1334
+ }
1335
+ catch { /* no local src/ changes to reset */ }
1336
+ }
1337
+ // 3. Stash any remaining local changes (package-lock.json, etc.)
1338
+ let didStash = false;
1339
+ try {
1340
+ const status = execSync('git status --porcelain', { cwd: PACKAGE_ROOT, encoding: 'utf-8' }).trim();
1341
+ if (status) {
1342
+ console.log(` ${S()} Stashing local changes...`);
1343
+ const stashOut = execSync('git stash', { cwd: PACKAGE_ROOT, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
1344
+ didStash = !stashOut.includes('No local changes');
1345
+ if (didStash) {
1346
+ console.log(` ${GREEN}OK${RESET} Stashed local changes`);
1347
+ }
1348
+ }
1349
+ }
1350
+ catch {
1351
+ // not fatal — pull may still succeed if changes don't conflict
1352
+ }
1353
+ // 3. Back up user config
1354
+ const backupDir = path.join(BASE_DIR, 'backups', `pre-update-${new Date().toISOString().slice(0, 19).replace(/[T:]/g, '-')}`);
1355
+ console.log(` ${S()} Backing up config...`);
1356
+ if (!options.dryRun) {
1357
+ mkdirSync(backupDir, { recursive: true });
1358
+ // .env
1359
+ if (existsSync(ENV_PATH)) {
1360
+ const envContent = readFileSync(ENV_PATH, 'utf-8');
1361
+ writeFileSync(path.join(backupDir, '.env'), envContent);
1362
+ }
1363
+ // Cron state
1364
+ const cronStateFile = path.join(BASE_DIR, '.cron_last_run.json');
1365
+ if (existsSync(cronStateFile)) {
1366
+ writeFileSync(path.join(backupDir, '.cron_last_run.json'), readFileSync(cronStateFile, 'utf-8'));
1367
+ }
1368
+ // Heartbeat state
1369
+ const hbStateFile = path.join(BASE_DIR, '.heartbeat_state.json');
1370
+ if (existsSync(hbStateFile)) {
1371
+ writeFileSync(path.join(backupDir, '.heartbeat_state.json'), readFileSync(hbStateFile, 'utf-8'));
1372
+ }
1373
+ // Sessions
1374
+ const sessionsFile = path.join(BASE_DIR, '.sessions.json');
1375
+ if (existsSync(sessionsFile)) {
1376
+ writeFileSync(path.join(backupDir, '.sessions.json'), readFileSync(sessionsFile, 'utf-8'));
1377
+ }
1378
+ console.log(` ${GREEN}OK${RESET} Config backed up`);
1379
+ }
1380
+ else {
1381
+ console.log(` ${DIM}(dry run — skipping backup)${RESET}`);
1382
+ }
1383
+ // 4. Stop running daemon
1384
+ const pid = readPid();
1385
+ const wasRunning = pid && isProcessAlive(pid);
1386
+ if (wasRunning) {
1387
+ console.log(` ${S()} Stopping daemon (PID ${pid})...`);
1388
+ if (!options.dryRun) {
1389
+ stopDaemon(pid);
1390
+ try {
1391
+ unlinkSync(getPidFilePath());
1392
+ }
1393
+ catch { /* ignore */ }
1394
+ }
1395
+ console.log(` ${GREEN}OK${RESET} Daemon stopped`);
1396
+ }
1397
+ // Helper: if update fails after stopping daemon, relaunch before exiting
1398
+ function failAndRestart(backupDir) {
1399
+ if (wasRunning) {
1400
+ console.log();
1401
+ console.log(` Restarting daemon (was running before update)...`);
1402
+ try {
1403
+ cmdLaunch({});
1404
+ console.log(` ${GREEN}OK${RESET} Daemon restarted`);
1405
+ }
1406
+ catch {
1407
+ console.error(` ${YELLOW}WARN${RESET} Could not restart daemon — run: clementine launch`);
1408
+ }
1409
+ }
1410
+ console.log();
1411
+ console.log(` ${DIM}Config backup is at: ${backupDir}${RESET}`);
1412
+ process.exit(1);
1413
+ }
1414
+ if (options.dryRun) {
1415
+ console.log();
1416
+ console.log(` ${DIM}Dry run — would execute:${RESET}`);
1417
+ console.log(` ${S()} Reset local src/ (mods tracked in ~/.clementine/)`);
1418
+ console.log(` ${S()} Pull latest (git pull --ff-only)`);
1419
+ console.log(` ${S()} Install dependencies (npm install)`);
1420
+ console.log(` ${S()} Build (clean)`);
1421
+ console.log(` ${S()} Verify build output`);
1422
+ console.log(` ${S()} Reinstall CLI globally`);
1423
+ console.log(` ${S()} Restore local changes`);
1424
+ console.log(` ${S()} Reconcile source modifications`);
1425
+ console.log(` ${S()} Run vault migrations`);
1426
+ console.log(` ${S()} Run health check (clementine doctor)`);
1427
+ if (options.restart || wasRunning) {
1428
+ console.log(` ${S()} Restart daemon`);
1429
+ }
1430
+ console.log();
1431
+ return;
1432
+ }
1433
+ // 5. Git pull
1434
+ console.log(` ${S()} Pulling latest...`);
1435
+ let commitsPulled = 0;
1436
+ let pullSummary = '';
1437
+ try {
1438
+ // Count how many commits we're behind before pulling
1439
+ try {
1440
+ execSync('git fetch origin main --quiet', { cwd: PACKAGE_ROOT, stdio: 'pipe', timeout: 30_000 });
1441
+ const countStr = execSync('git rev-list HEAD..origin/main --count', {
1442
+ cwd: PACKAGE_ROOT, encoding: 'utf-8',
1443
+ }).trim();
1444
+ commitsPulled = parseInt(countStr, 10) || 0;
1445
+ if (commitsPulled > 0) {
1446
+ pullSummary = execSync('git log HEAD..origin/main --oneline --no-decorate', {
1447
+ cwd: PACKAGE_ROOT, encoding: 'utf-8',
1448
+ }).trim();
1449
+ }
1450
+ }
1451
+ catch { /* non-fatal — we'll still pull */ }
1452
+ const pullOutput = execSync('git pull --ff-only', {
1453
+ cwd: PACKAGE_ROOT,
1454
+ encoding: 'utf-8',
1455
+ stdio: ['pipe', 'pipe', 'pipe'],
1456
+ }).trim();
1457
+ if (pullOutput.includes('Already up to date')) {
1458
+ console.log(` ${GREEN}OK${RESET} Already up to date`);
1459
+ }
1460
+ else {
1461
+ console.log(` ${GREEN}OK${RESET} Pulled updates`);
1462
+ }
1463
+ }
1464
+ catch (err) {
1465
+ const errStr = String(err);
1466
+ if (errStr.includes('local changes') || errStr.includes('overwritten by merge')) {
1467
+ console.error(` ${RED}FAIL${RESET} Local file changes conflict with the update.`);
1468
+ console.error();
1469
+ console.error(` Fix — run these commands, then retry:`);
1470
+ console.error(` cd ${PACKAGE_ROOT}`);
1471
+ console.error(` git stash`);
1472
+ console.error(` clementine update`);
1473
+ console.error();
1474
+ console.error(` ${DIM}Your local changes will be saved. Restore after update with: git stash pop${RESET}`);
1475
+ }
1476
+ else if (errStr.includes('Not possible to fast-forward')) {
1477
+ console.error(` ${RED}FAIL${RESET} Cannot fast-forward. Local commits conflict with upstream.`);
1478
+ console.error();
1479
+ console.error(` Fix — run these commands, then retry:`);
1480
+ console.error(` cd ${PACKAGE_ROOT}`);
1481
+ console.error(` git stash`);
1482
+ console.error(` git pull --rebase`);
1483
+ console.error(` git stash pop`);
1484
+ }
1485
+ else {
1486
+ console.error(` ${RED}FAIL${RESET} git pull failed: ${errStr.slice(0, 200)}`);
1487
+ }
1488
+ if (didStash) {
1489
+ console.log(` ${DIM}Restoring stashed changes...${RESET}`);
1490
+ try {
1491
+ execSync('git stash pop', { cwd: PACKAGE_ROOT, stdio: 'pipe' });
1492
+ }
1493
+ catch { /* best effort */ }
1494
+ }
1495
+ failAndRestart(backupDir);
1496
+ }
1497
+ // 6. npm install
1498
+ console.log(` ${S()} Installing dependencies...`);
1499
+ try {
1500
+ execSync('npm install --loglevel=error --no-audit', {
1501
+ cwd: PACKAGE_ROOT,
1502
+ stdio: ['pipe', 'pipe', 'pipe'],
1503
+ });
1504
+ console.log(` ${GREEN}OK${RESET} Dependencies installed`);
1505
+ }
1506
+ catch (err) {
1507
+ console.error(` ${RED}FAIL${RESET} npm install failed: ${String(err).slice(0, 200)}`);
1508
+ failAndRestart(backupDir);
1509
+ }
1510
+ // 6b. Rebuild native modules (better-sqlite3) for current Node version
1511
+ try {
1512
+ execSync('npm rebuild better-sqlite3', {
1513
+ cwd: PACKAGE_ROOT,
1514
+ stdio: ['pipe', 'pipe', 'pipe'],
1515
+ });
1516
+ console.log(` ${GREEN}OK${RESET} Native modules rebuilt`);
1517
+ }
1518
+ catch {
1519
+ console.error(` ${YELLOW}WARN${RESET} Native module rebuild failed — memory search may not work`);
1520
+ }
1521
+ // 6c. Verify graph engine system dependencies + binaries
1522
+ console.log(` ${S()} Verifying graph engine...`);
1523
+ const missingDeps = [];
1524
+ try {
1525
+ execSync('which redis-server', { stdio: 'pipe' });
1526
+ }
1527
+ catch {
1528
+ missingDeps.push('redis-server');
1529
+ }
1530
+ const libompPath = process.platform === 'darwin'
1531
+ ? '/opt/homebrew/opt/libomp/lib/libomp.dylib'
1532
+ : '/usr/lib/libomp.so';
1533
+ if (!existsSync(libompPath))
1534
+ missingDeps.push('libomp');
1535
+ if (missingDeps.length > 0) {
1536
+ console.error(` ${YELLOW}WARN${RESET} Knowledge graph dependencies missing: ${missingDeps.join(', ')}`);
1537
+ if (process.platform === 'darwin') {
1538
+ console.error(` Fix: brew install ${missingDeps.map(d => d === 'redis-server' ? 'redis' : d).join(' ')}`);
1539
+ }
1540
+ else {
1541
+ console.error(` Fix: sudo apt install ${missingDeps.map(d => d === 'redis-server' ? 'redis-server' : 'libomp-dev').join(' ')}`);
1542
+ }
1543
+ }
1544
+ try {
1545
+ execSync(`node -e "const{BinaryManager}=require('falkordblite/dist/binary-manager.js');new BinaryManager().ensureBinaries().then(()=>process.exit(0)).catch(()=>process.exit(1))"`, { cwd: PACKAGE_ROOT, stdio: ['pipe', 'pipe', 'pipe'], timeout: 60000 });
1546
+ if (missingDeps.length === 0) {
1547
+ console.log(` ${GREEN}OK${RESET} FalkorDB graph engine ready`);
1548
+ }
1549
+ else {
1550
+ console.log(` ${GREEN}OK${RESET} FalkorDB binaries ready (install system deps above for full graph support)`);
1551
+ }
1552
+ }
1553
+ catch {
1554
+ console.error(` ${YELLOW}WARN${RESET} FalkorDB graph engine setup failed — knowledge graph features will be disabled`);
1555
+ console.error(` Run: cd ${PACKAGE_ROOT} && node node_modules/falkordblite/scripts/postinstall.js`);
1556
+ }
1557
+ // 6d. Ensure cloudflared is installed (for remote dashboard access)
1558
+ try {
1559
+ execSync('which cloudflared', { stdio: 'pipe' });
1560
+ console.log(` ${GREEN}OK${RESET} cloudflared available`);
1561
+ }
1562
+ catch {
1563
+ if (process.platform === 'darwin') {
1564
+ console.log(` ${S()} Installing cloudflared (remote dashboard access)...`);
1565
+ try {
1566
+ execSync('brew install cloudflared', { stdio: ['pipe', 'pipe', 'pipe'], timeout: 120_000 });
1567
+ console.log(` ${GREEN}OK${RESET} cloudflared installed`);
1568
+ }
1569
+ catch {
1570
+ console.error(` ${YELLOW}WARN${RESET} Could not install cloudflared — remote access won't be available`);
1571
+ console.error(` Fix: brew install cloudflared`);
1572
+ }
1573
+ }
1574
+ else {
1575
+ console.error(` ${YELLOW}WARN${RESET} cloudflared not installed — remote dashboard access won't be available`);
1576
+ console.error(` See: https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/`);
1577
+ }
1578
+ }
1579
+ // 7. Build (clean)
1580
+ console.log(` ${S()} Building (clean)...`);
1581
+ try {
1582
+ execSync('npm run build', {
1583
+ cwd: PACKAGE_ROOT,
1584
+ stdio: ['pipe', 'pipe', 'pipe'],
1585
+ });
1586
+ console.log(` ${GREEN}OK${RESET} Build succeeded`);
1587
+ }
1588
+ catch (err) {
1589
+ // Build failed — retry with fresh npm install (handles missing typescript after pull)
1590
+ console.error(` ${YELLOW}WARN${RESET} Build failed — retrying with fresh dependency install...`);
1591
+ try {
1592
+ execSync('npm install --loglevel=error --no-audit && npm run build', {
1593
+ cwd: PACKAGE_ROOT,
1594
+ stdio: ['pipe', 'pipe', 'pipe'],
1595
+ });
1596
+ console.log(` ${GREEN}OK${RESET} Build succeeded (after reinstall)`);
1597
+ }
1598
+ catch (retryErr) {
1599
+ console.error(` ${RED}FAIL${RESET} Build failed after update: ${String(retryErr).slice(0, 200)}`);
1600
+ failAndRestart(backupDir);
1601
+ }
1602
+ }
1603
+ // 7b. Verify build output is fresh
1604
+ const distEntry = path.join(PACKAGE_ROOT, 'dist', 'cli', 'index.js');
1605
+ if (existsSync(distEntry)) {
1606
+ const distStat = statSync(distEntry);
1607
+ const ageMs = Date.now() - distStat.mtimeMs;
1608
+ if (ageMs > 30_000) {
1609
+ console.error(` ${YELLOW}WARN${RESET} Build output appears stale (${Math.round(ageMs / 1000)}s old) — retrying with clean build...`);
1610
+ try {
1611
+ execSync('rm -rf dist && npm run build', { cwd: PACKAGE_ROOT, stdio: ['pipe', 'pipe', 'pipe'] });
1612
+ console.log(` ${GREEN}OK${RESET} Clean rebuild succeeded`);
1613
+ }
1614
+ catch (err) {
1615
+ console.error(` ${RED}FAIL${RESET} Clean rebuild failed: ${String(err).slice(0, 200)}`);
1616
+ failAndRestart(backupDir);
1617
+ }
1618
+ }
1619
+ }
1620
+ // 7c. Smoke test — verify the build is actually runnable.
1621
+ // CLEMENTINE_SMOKE_TEST causes main() to exit(0) immediately, so
1622
+ // this just verifies the module loads without starting the full daemon.
1623
+ try {
1624
+ execSync('node -e "require(\'./dist/index.js\')"', {
1625
+ cwd: PACKAGE_ROOT,
1626
+ stdio: 'pipe',
1627
+ timeout: 15000,
1628
+ env: { ...process.env, CLEMENTINE_SMOKE_TEST: '1' },
1629
+ });
1630
+ console.log(` ${GREEN}OK${RESET} Build output verified`);
1631
+ }
1632
+ catch {
1633
+ console.log(` ${YELLOW}WARN${RESET} Build output may have issues — check after restart`);
1634
+ }
1635
+ // 8. Reinstall globally
1636
+ console.log(` ${S()} Reinstalling CLI globally...`);
1637
+ try {
1638
+ execSync('npm install -g . --loglevel=error --no-audit', {
1639
+ cwd: PACKAGE_ROOT,
1640
+ stdio: ['pipe', 'pipe', 'pipe'],
1641
+ });
1642
+ console.log(` ${GREEN}OK${RESET} CLI reinstalled`);
1643
+ }
1644
+ catch (err) {
1645
+ console.error(` ${YELLOW}WARN${RESET} Global reinstall failed (may need sudo): ${String(err).slice(0, 200)}`);
1646
+ // Non-fatal — local dist is already updated
1647
+ }
1648
+ // 9. Restore stashed local changes
1649
+ if (didStash) {
1650
+ console.log(` ${S()} Restoring local changes...`);
1651
+ try {
1652
+ execSync('git stash pop', {
1653
+ cwd: PACKAGE_ROOT,
1654
+ stdio: ['pipe', 'pipe', 'pipe'],
1655
+ });
1656
+ console.log(` ${GREEN}OK${RESET} Local changes restored`);
1657
+ }
1658
+ catch {
1659
+ console.error(` ${YELLOW}WARN${RESET} Could not auto-restore stashed changes — falling back to backup`);
1660
+ // Restore .env from backup if stash pop failed
1661
+ const backupEnv = path.join(backupDir, '.env');
1662
+ if (existsSync(backupEnv)) {
1663
+ try {
1664
+ cpSync(backupEnv, ENV_PATH);
1665
+ console.log(` ${GREEN}OK${RESET} .env restored from backup`);
1666
+ }
1667
+ catch {
1668
+ console.error(` ${RED}FAIL${RESET} Could not restore .env — copy manually from: ${backupEnv}`);
1669
+ }
1670
+ }
1671
+ // Drop the stash so it doesn't interfere with future updates
1672
+ try {
1673
+ execSync('git stash drop', { cwd: PACKAGE_ROOT, stdio: 'pipe' });
1674
+ }
1675
+ catch { /* ignore */ }
1676
+ }
1677
+ }
1678
+ // 9b. Verify .env survived the update
1679
+ if (existsSync(ENV_PATH)) {
1680
+ const envContent = readFileSync(ENV_PATH, 'utf-8');
1681
+ if (envContent.trim().length < 10) {
1682
+ console.error(` ${RED}FAIL${RESET} .env appears empty — restoring from backup`);
1683
+ const backupEnv = path.join(backupDir, '.env');
1684
+ if (existsSync(backupEnv)) {
1685
+ try {
1686
+ cpSync(backupEnv, ENV_PATH);
1687
+ console.log(` ${GREEN}OK${RESET} .env restored from backup`);
1688
+ }
1689
+ catch {
1690
+ console.error(` ${RED}FAIL${RESET} Restore failed — copy manually from: ${backupEnv}`);
1691
+ }
1692
+ }
1693
+ }
1694
+ }
1695
+ else {
1696
+ console.error(` ${RED}FAIL${RESET} .env missing after update — restoring from backup`);
1697
+ const backupEnv = path.join(backupDir, '.env');
1698
+ if (existsSync(backupEnv)) {
1699
+ try {
1700
+ const { copyFileSync } = require('node:fs');
1701
+ copyFileSync(backupEnv, ENV_PATH);
1702
+ console.log(` ${GREEN}OK${RESET} .env restored from backup`);
1703
+ }
1704
+ catch {
1705
+ console.error(` ${RED}FAIL${RESET} Restore failed — run: clementine config setup`);
1706
+ }
1707
+ }
1708
+ }
1709
+ // 10. Reconcile source modifications from self-improve
1710
+ // Source mods are tracked in ~/.clementine/self-improve/source-mods/
1711
+ // After pulling new code, we check each active mod and re-apply if needed.
1712
+ console.log(` ${S()} Reconciling source modifications...`);
1713
+ let reconcileResult = null;
1714
+ try {
1715
+ const { reconcileSourceMods } = await import('../agent/source-mods.js');
1716
+ const result = reconcileSourceMods(PACKAGE_ROOT);
1717
+ reconcileResult = result;
1718
+ const total = result.reapplied.length + result.superseded.length +
1719
+ result.needsReconciliation.length + result.failed.length;
1720
+ if (total === 0) {
1721
+ console.log(` ${GREEN}OK${RESET} No source modifications to reconcile`);
1722
+ }
1723
+ else {
1724
+ if (result.superseded.length > 0) {
1725
+ console.log(` ${GREEN}OK${RESET} ${result.superseded.length} mod(s) already in upstream — marked superseded`);
1726
+ }
1727
+ if (result.reapplied.length > 0) {
1728
+ console.log(` ${GREEN}OK${RESET} ${result.reapplied.length} mod(s) re-applied successfully`);
1729
+ // Rebuild with re-applied mods
1730
+ console.log(` ${S()} Rebuilding with re-applied modifications...`);
1731
+ try {
1732
+ execSync('npm run build', { cwd: PACKAGE_ROOT, stdio: ['pipe', 'pipe', 'pipe'] });
1733
+ console.log(` ${GREEN}OK${RESET} Rebuild succeeded`);
1734
+ }
1735
+ catch {
1736
+ console.error(` ${YELLOW}WARN${RESET} Rebuild failed — continuing with base build`);
1737
+ try {
1738
+ execSync('git checkout -- src/', { cwd: PACKAGE_ROOT, stdio: 'pipe' });
1739
+ }
1740
+ catch { /* best effort */ }
1741
+ }
1742
+ }
1743
+ if (result.needsReconciliation.length > 0) {
1744
+ console.log(` ${YELLOW}NOTE${RESET} ${result.needsReconciliation.length} mod(s) need reconciliation`);
1745
+ console.log(` ${getAssistantName()} will re-apply these intelligently on next startup.`);
1746
+ }
1747
+ if (result.failed.length > 0) {
1748
+ console.error(` ${YELLOW}WARN${RESET} ${result.failed.length} mod(s) failed typecheck — reverted`);
1749
+ }
1750
+ }
1751
+ }
1752
+ catch (err) {
1753
+ console.error(` ${YELLOW}WARN${RESET} Source mod reconciliation failed: ${String(err).slice(0, 150)}`);
1754
+ }
1755
+ // 10b. Run vault migrations (structural updates to user vault files)
1756
+ console.log(` ${S()} Running vault migrations...`);
1757
+ try {
1758
+ const { runVaultMigrations } = await import('../vault-migrations/runner.js');
1759
+ const migResult = await runVaultMigrations(path.join(BASE_DIR, 'vault'), backupDir);
1760
+ const migApplied = migResult.applied.length;
1761
+ const migSkipped = migResult.skipped.length;
1762
+ const migFailed = migResult.failed.length;
1763
+ if (migApplied > 0) {
1764
+ console.log(` ${GREEN}OK${RESET} Applied ${migApplied} vault migration(s): ${migResult.applied.join(', ')}`);
1765
+ }
1766
+ if (migSkipped > 0) {
1767
+ console.log(` ${GREEN}OK${RESET} ${migSkipped} migration(s) already present — skipped`);
1768
+ }
1769
+ if (migFailed > 0) {
1770
+ console.error(` ${YELLOW}WARN${RESET} ${migFailed} migration(s) failed — will retry on next update`);
1771
+ for (const e of migResult.errors) {
1772
+ console.error(` ${e.id}: ${e.error}`);
1773
+ }
1774
+ }
1775
+ if (migApplied === 0 && migSkipped === 0 && migFailed === 0) {
1776
+ console.log(` ${GREEN}OK${RESET} No new vault migrations`);
1777
+ }
1778
+ }
1779
+ catch (err) {
1780
+ console.error(` ${YELLOW}WARN${RESET} Vault migration failed: ${String(err).slice(0, 150)}`);
1781
+ }
1782
+ // 11. Doctor check (auto-fix during updates)
1783
+ // Shell out to the newly built dist/ so the latest doctor code runs, not the old in-memory version.
1784
+ console.log();
1785
+ console.log(` ${S()} Running health check...`);
1786
+ try {
1787
+ execSync(`node "${path.join(PACKAGE_ROOT, 'dist', 'cli', 'index.js')}" doctor --fix`, {
1788
+ cwd: PACKAGE_ROOT,
1789
+ stdio: 'inherit',
1790
+ timeout: 300000,
1791
+ });
1792
+ }
1793
+ catch {
1794
+ // Doctor exits cleanly even with issues; a throw here means something unexpected.
1795
+ cmdDoctor({ fix: true }); // Fallback to in-memory version
1796
+ }
1797
+ // 11. Kill ALL running dashboard processes (not just PID file) and relaunch
1798
+ let dashboardWasRunning = false;
1799
+ try {
1800
+ const { killExistingDashboards } = await import('./dashboard.js');
1801
+ const killed = killExistingDashboards();
1802
+ if (killed > 0) {
1803
+ dashboardWasRunning = true;
1804
+ console.log(` ${GREEN}OK${RESET} Stopped ${killed} dashboard process(es)`);
1805
+ }
1806
+ }
1807
+ catch { /* no dashboard running */ }
1808
+ // Don't auto-relaunch dashboard during update — it causes duplicate process issues.
1809
+ // The daemon restart below will handle it, or user can run: clementine dashboard
1810
+ if (dashboardWasRunning) {
1811
+ console.log(` Dashboard stopped. Relaunch with: ${DIM}clementine dashboard${RESET}`);
1812
+ }
1813
+ // 12. Write update sentinel so the daemon can report what happened
1814
+ let commitHash = '';
1815
+ let commitDate = '';
1816
+ try {
1817
+ commitHash = execSync('git rev-parse --short HEAD', {
1818
+ cwd: PACKAGE_ROOT, encoding: 'utf-8',
1819
+ }).trim();
1820
+ commitDate = execSync('git log -1 --format=%ci HEAD', {
1821
+ cwd: PACKAGE_ROOT, encoding: 'utf-8',
1822
+ }).trim().slice(0, 10);
1823
+ }
1824
+ catch { /* best effort */ }
1825
+ if (options.restart || wasRunning) {
1826
+ const sentinelPath = path.join(BASE_DIR, '.restart-sentinel.json');
1827
+ const sentinel = {
1828
+ previousPid: process.pid,
1829
+ restartedAt: new Date().toISOString(),
1830
+ reason: 'update',
1831
+ updateDetails: {
1832
+ commitHash,
1833
+ commitDate,
1834
+ commitsBehind: commitsPulled,
1835
+ summary: pullSummary.split('\n').slice(0, 5).join('; '),
1836
+ modsReapplied: reconcileResult?.reapplied.length ?? 0,
1837
+ modsSuperseded: reconcileResult?.superseded.length ?? 0,
1838
+ modsNeedReconciliation: reconcileResult?.needsReconciliation.length ?? 0,
1839
+ modsFailed: reconcileResult?.failed.length ?? 0,
1840
+ },
1841
+ };
1842
+ writeFileSync(sentinelPath, JSON.stringify(sentinel, null, 2));
1843
+ // Ensure build output is fully flushed before spawning new process
1844
+ execSync('sync', { stdio: 'pipe' });
1845
+ console.log(` ${S()} Restarting daemon...`);
1846
+ cmdLaunch({});
1847
+ }
1848
+ // 13. Post-restart health check — verify daemon started and channels connected
1849
+ if (options.restart || wasRunning) {
1850
+ console.log(` ${S()} Verifying startup...`);
1851
+ // Wait for daemon to initialize
1852
+ await new Promise(resolve => setTimeout(resolve, 5000));
1853
+ // Check if process is alive
1854
+ const newPid = (() => {
1855
+ try {
1856
+ const pidFile = getPidFilePath();
1857
+ return existsSync(pidFile) ? parseInt(readFileSync(pidFile, 'utf-8').trim(), 10) : null;
1858
+ }
1859
+ catch {
1860
+ return null;
1861
+ }
1862
+ })();
1863
+ if (newPid) {
1864
+ try {
1865
+ process.kill(newPid, 0); // Signal 0 = check if alive
1866
+ console.log(` ${GREEN}OK${RESET} Daemon running (PID ${newPid})`);
1867
+ }
1868
+ catch {
1869
+ console.log(` ${RED}FAIL${RESET} Daemon crashed after restart — check: tail ~/.clementine/logs/clementine.log`);
1870
+ }
1871
+ }
1872
+ else {
1873
+ console.log(` ${RED}FAIL${RESET} No PID file — daemon may not have started`);
1874
+ }
1875
+ // Check logs for startup errors
1876
+ try {
1877
+ const logPath = path.join(BASE_DIR, 'logs', 'clementine.log');
1878
+ if (existsSync(logPath)) {
1879
+ const logTail = readFileSync(logPath, 'utf-8').split('\n').filter(Boolean).slice(-20);
1880
+ const startupErrors = [];
1881
+ let discordOnline = false;
1882
+ for (const line of logTail) {
1883
+ try {
1884
+ const entry = JSON.parse(line);
1885
+ if (entry.level >= 50)
1886
+ startupErrors.push(entry.msg?.slice(0, 120) ?? 'Unknown error');
1887
+ if (entry.msg?.includes('online as') || entry.msg?.includes('Clementine online'))
1888
+ discordOnline = true;
1889
+ }
1890
+ catch { /* skip */ }
1891
+ }
1892
+ if (discordOnline) {
1893
+ console.log(` ${GREEN}OK${RESET} Discord connected`);
1894
+ }
1895
+ else {
1896
+ console.log(` ${YELLOW}WARN${RESET} Discord connection not confirmed — check logs`);
1897
+ }
1898
+ if (startupErrors.length > 0) {
1899
+ for (const err of startupErrors.slice(0, 3)) {
1900
+ console.log(` ${RED}ERR${RESET} ${err}`);
1901
+ }
1902
+ }
1903
+ }
1904
+ }
1905
+ catch { /* non-fatal */ }
1906
+ // Verify .env survived the update (critical keys still present)
1907
+ try {
1908
+ const envContent = readFileSync(ENV_PATH, 'utf-8');
1909
+ const criticalKeys = ['DISCORD_TOKEN', 'DISCORD_OWNER_ID'];
1910
+ const missingKeys = criticalKeys.filter(k => {
1911
+ const re = new RegExp(`^${k}=.+`, 'm');
1912
+ return !re.test(envContent);
1913
+ });
1914
+ if (missingKeys.length > 0) {
1915
+ console.log(` ${RED}FAIL${RESET} .env missing: ${missingKeys.join(', ')} — run: clementine config setup`);
1916
+ }
1917
+ }
1918
+ catch { /* .env read failed */ }
1919
+ }
1920
+ // 14. Show current version
1921
+ console.log();
1922
+ if (commitHash) {
1923
+ console.log(` ${GREEN}Updated to ${commitHash} (${commitDate})${RESET}`);
1924
+ }
1925
+ else {
1926
+ console.log(` ${GREEN}Update complete.${RESET}`);
1927
+ }
1928
+ console.log(` ${DIM}Config backup: ${backupDir}${RESET}`);
1929
+ console.log();
1930
+ }
1931
+ // ── Cron commands ───────────────────────────────────────────────────
1932
+ const cronCmd = program
1933
+ .command('cron')
1934
+ .description('Manage and run cron jobs');
1935
+ cronCmd
1936
+ .command('list')
1937
+ .description('List all cron jobs from CRON.md')
1938
+ .action(() => {
1939
+ cmdCronList().catch((err) => {
1940
+ console.error('Error:', err);
1941
+ process.exit(1);
1942
+ });
1943
+ });
1944
+ cronCmd
1945
+ .command('run <jobName>')
1946
+ .description('Run a specific cron job')
1947
+ .action((jobName) => {
1948
+ cmdCronRun(jobName).catch((err) => {
1949
+ console.error('Error:', err);
1950
+ process.exit(1);
1951
+ });
1952
+ });
1953
+ cronCmd
1954
+ .command('run-due')
1955
+ .description('Run all jobs that are due now (for OS scheduler)')
1956
+ .action(() => {
1957
+ cmdCronRunDue().catch((err) => {
1958
+ console.error('Error:', err);
1959
+ process.exit(1);
1960
+ });
1961
+ });
1962
+ cronCmd
1963
+ .command('runs [jobName]')
1964
+ .description('View run history (all jobs or a specific job)')
1965
+ .action((jobName) => {
1966
+ cmdCronRuns(jobName).catch((err) => {
1967
+ console.error('Error:', err);
1968
+ process.exit(1);
1969
+ });
1970
+ });
1971
+ cronCmd
1972
+ .command('add <name> <schedule> <prompt>')
1973
+ .description('Add a new cron job to CRON.md')
1974
+ .option('--tier <n>', 'Security tier (1-3)', '1')
1975
+ .action(async (name, schedule, prompt, opts) => {
1976
+ await cmdCronAdd(name, schedule, prompt, opts).catch((err) => {
1977
+ console.error('Error:', err);
1978
+ process.exit(1);
1979
+ });
1980
+ });
1981
+ cronCmd
1982
+ .command('test <job>')
1983
+ .description('Dry-run a cron job immediately (does not log to history)')
1984
+ .action(async (job) => {
1985
+ await cmdCronTest(job).catch((err) => {
1986
+ console.error('Error:', err);
1987
+ process.exit(1);
1988
+ });
1989
+ });
1990
+ cronCmd
1991
+ .command('install')
1992
+ .description('Install OS-level scheduler (launchd on macOS, crontab on Linux)')
1993
+ .action(cmdCronInstall);
1994
+ cronCmd
1995
+ .command('uninstall')
1996
+ .description('Remove OS-level cron scheduler')
1997
+ .action(cmdCronUninstall);
1998
+ // ── Workflow commands ────────────────────────────────────────────────
1999
+ const workflowCmd = program
2000
+ .command('workflow')
2001
+ .description('Manage and run multi-step workflows');
2002
+ workflowCmd
2003
+ .command('list')
2004
+ .description('List all workflows from vault/00-System/workflows/')
2005
+ .action(async () => {
2006
+ try {
2007
+ const { parseAllWorkflows } = await import('../agent/workflow-runner.js');
2008
+ const config = await import('../config.js');
2009
+ const workflows = parseAllWorkflows(config.WORKFLOWS_DIR);
2010
+ if (workflows.length === 0) {
2011
+ console.log('No workflows found. Add .md files to vault/00-System/workflows/.');
2012
+ return;
2013
+ }
2014
+ for (const wf of workflows) {
2015
+ const status = wf.enabled ? 'enabled' : 'disabled';
2016
+ const trigger = wf.trigger.schedule ? `schedule: ${wf.trigger.schedule}` : 'manual';
2017
+ console.log(` ${wf.name} [${status}] — ${trigger}`);
2018
+ if (wf.description)
2019
+ console.log(` ${wf.description}`);
2020
+ console.log(` Steps: ${wf.steps.map(s => s.id).join(' → ')}`);
2021
+ if (Object.keys(wf.inputs).length > 0) {
2022
+ const inputStr = Object.entries(wf.inputs)
2023
+ .map(([k, v]) => `${k}${v.default ? `="${v.default}"` : ''}`)
2024
+ .join(', ');
2025
+ console.log(` Inputs: ${inputStr}`);
2026
+ }
2027
+ }
2028
+ }
2029
+ catch (err) {
2030
+ console.error('Error:', err);
2031
+ process.exit(1);
2032
+ }
2033
+ });
2034
+ workflowCmd
2035
+ .command('run <name>')
2036
+ .description('Run a workflow by name')
2037
+ .option('--input <key=val...>', 'Input overrides', (val, prev) => {
2038
+ prev.push(val);
2039
+ return prev;
2040
+ }, [])
2041
+ .action(async (name, opts) => {
2042
+ try {
2043
+ const { parseAllWorkflows, WorkflowRunner } = await import('../agent/workflow-runner.js');
2044
+ const config = await import('../config.js');
2045
+ const { PersonalAssistant } = await import('../agent/assistant.js');
2046
+ const workflows = parseAllWorkflows(config.WORKFLOWS_DIR);
2047
+ const wf = workflows.find(w => w.name === name);
2048
+ if (!wf) {
2049
+ const available = workflows.map(w => w.name).join(', ');
2050
+ console.error(`Workflow "${name}" not found. Available: ${available || 'none'}`);
2051
+ process.exit(1);
2052
+ }
2053
+ // Parse inputs
2054
+ const inputs = {};
2055
+ for (const kv of opts.input) {
2056
+ const eq = kv.indexOf('=');
2057
+ if (eq > 0)
2058
+ inputs[kv.slice(0, eq)] = kv.slice(eq + 1);
2059
+ }
2060
+ console.log(`Running workflow: ${name} (${wf.steps.length} steps)`);
2061
+ const assistant = new PersonalAssistant();
2062
+ const runner = new WorkflowRunner(assistant);
2063
+ const result = await runner.run(wf, inputs, (updates) => {
2064
+ // Print progress
2065
+ for (const u of updates) {
2066
+ if (u.status === 'running')
2067
+ console.log(` [running] ${u.stepId}`);
2068
+ else if (u.status === 'done')
2069
+ console.log(` [done] ${u.stepId} (${Math.round((u.durationMs ?? 0) / 1000)}s)`);
2070
+ else if (u.status === 'failed')
2071
+ console.log(` [failed] ${u.stepId}`);
2072
+ }
2073
+ });
2074
+ console.log(`\nResult (${result.status}):\n${result.output}`);
2075
+ }
2076
+ catch (err) {
2077
+ console.error('Error:', err);
2078
+ process.exit(1);
2079
+ }
2080
+ });
2081
+ // ── Self-Improvement commands ────────────────────────────────────────
2082
+ const siCmd = program
2083
+ .command('self-improve')
2084
+ .description('Manage Clementine self-improvement');
2085
+ siCmd
2086
+ .command('status')
2087
+ .description('Show self-improvement state and baseline metrics')
2088
+ .action(async () => {
2089
+ try {
2090
+ const { SelfImproveLoop } = await import('../agent/self-improve.js');
2091
+ const { PersonalAssistant } = await import('../agent/assistant.js');
2092
+ const assistant = new PersonalAssistant();
2093
+ const loop = new SelfImproveLoop(assistant);
2094
+ const state = loop.loadState();
2095
+ const m = state.baselineMetrics;
2096
+ console.log(`Status: ${state.status}`);
2097
+ console.log(`Last run: ${state.lastRunAt || 'never'}`);
2098
+ console.log(`Total experiments: ${state.totalExperiments}`);
2099
+ console.log(`Pending approvals: ${state.pendingApprovals}`);
2100
+ console.log(`Baseline — Feedback: ${(m.feedbackPositiveRatio * 100).toFixed(0)}% positive, Cron: ${(m.cronSuccessRate * 100).toFixed(0)}% success, Quality: ${m.avgResponseQuality.toFixed(2)}`);
2101
+ }
2102
+ catch (err) {
2103
+ console.error('Error:', err);
2104
+ process.exit(1);
2105
+ }
2106
+ });
2107
+ siCmd
2108
+ .command('run')
2109
+ .description('Trigger a self-improvement cycle')
2110
+ .action(async () => {
2111
+ try {
2112
+ const { SelfImproveLoop } = await import('../agent/self-improve.js');
2113
+ const { PersonalAssistant } = await import('../agent/assistant.js');
2114
+ const assistant = new PersonalAssistant();
2115
+ const loop = new SelfImproveLoop(assistant);
2116
+ console.log('Starting self-improvement cycle...');
2117
+ const state = await loop.run(async (experiment) => {
2118
+ console.log(` Proposal: ${experiment.area} | "${experiment.hypothesis.slice(0, 60)}" | ${(experiment.score * 10).toFixed(1)}/10`);
2119
+ });
2120
+ console.log(`\nCompleted: ${state.status}, ${state.currentIteration} iterations, ${state.pendingApprovals} pending approvals`);
2121
+ }
2122
+ catch (err) {
2123
+ console.error('Error:', err);
2124
+ process.exit(1);
2125
+ }
2126
+ });
2127
+ siCmd
2128
+ .command('history')
2129
+ .description('Show experiment history')
2130
+ .option('-n, --limit <n>', 'Number of entries to show', '10')
2131
+ .action(async (opts) => {
2132
+ try {
2133
+ const { SelfImproveLoop } = await import('../agent/self-improve.js');
2134
+ const { PersonalAssistant } = await import('../agent/assistant.js');
2135
+ const assistant = new PersonalAssistant();
2136
+ const loop = new SelfImproveLoop(assistant);
2137
+ const limit = parseInt(opts.limit, 10) || 10;
2138
+ const log = loop.loadExperimentLog().slice(-limit).reverse();
2139
+ if (log.length === 0) {
2140
+ console.log('No experiment history yet.');
2141
+ return;
2142
+ }
2143
+ for (const e of log) {
2144
+ const status = e.accepted
2145
+ ? (e.approvalStatus === 'approved' ? '✅ approved' : '⏳ pending')
2146
+ : '❌ rejected';
2147
+ console.log(`#${e.iteration} | ${e.area} | ${(e.score * 10).toFixed(1)}/10 | ${status}`);
2148
+ console.log(` ${e.hypothesis.slice(0, 80)}`);
2149
+ }
2150
+ }
2151
+ catch (err) {
2152
+ console.error('Error:', err);
2153
+ process.exit(1);
2154
+ }
2155
+ });
2156
+ siCmd
2157
+ .command('apply <id>')
2158
+ .description('Approve and apply a pending change')
2159
+ .action(async (id) => {
2160
+ try {
2161
+ const { SelfImproveLoop } = await import('../agent/self-improve.js');
2162
+ const { PersonalAssistant } = await import('../agent/assistant.js');
2163
+ const assistant = new PersonalAssistant();
2164
+ const loop = new SelfImproveLoop(assistant);
2165
+ const result = await loop.applyApprovedChange(id);
2166
+ console.log(result);
2167
+ }
2168
+ catch (err) {
2169
+ console.error('Error:', err);
2170
+ process.exit(1);
2171
+ }
2172
+ });
2173
+ // ── Heartbeat command ───────────────────────────────────────────────
2174
+ program
2175
+ .command('heartbeat')
2176
+ .description('Run a one-shot heartbeat check')
2177
+ .action(() => {
2178
+ cmdHeartbeat().catch((err) => {
2179
+ console.error('Error:', err);
2180
+ process.exit(1);
2181
+ });
2182
+ });
2183
+ // ── OS scheduler install/uninstall ──────────────────────────────────
2184
+ const CRON_LAUNCHD_LABEL = `com.${getAssistantName().toLowerCase()}.cron`;
2185
+ function getCronPlistPath() {
2186
+ const home = process.env.HOME ?? '';
2187
+ return path.join(home, 'Library', 'LaunchAgents', `${CRON_LAUNCHD_LABEL}.plist`);
2188
+ }
2189
+ /**
2190
+ * Build a PATH string for launchd plists that includes all directories needed
2191
+ * to find node, claude CLI, and standard system binaries.
2192
+ */
2193
+ function buildLaunchdPath() {
2194
+ const dirs = new Set();
2195
+ // Include the directory containing the current node binary (nvm, homebrew, etc.)
2196
+ dirs.add(path.dirname(process.execPath));
2197
+ // Include directories where claude CLI might live
2198
+ const home = process.env.HOME ?? '';
2199
+ if (home) {
2200
+ dirs.add(path.join(home, '.local', 'bin')); // common claude CLI location
2201
+ }
2202
+ // Standard system paths
2203
+ dirs.add('/usr/local/bin');
2204
+ dirs.add('/opt/homebrew/bin');
2205
+ dirs.add('/usr/bin');
2206
+ dirs.add('/bin');
2207
+ return [...dirs].join(':');
2208
+ }
2209
+ function cmdCronInstall() {
2210
+ const cliEntry = path.join(PACKAGE_ROOT, 'dist', 'cli', 'index.js');
2211
+ const nodePath = process.execPath;
2212
+ const logDir = path.join(BASE_DIR, 'logs');
2213
+ if (!existsSync(logDir)) {
2214
+ mkdirSync(logDir, { recursive: true });
2215
+ }
2216
+ const cronLog = path.join(logDir, 'cron.log');
2217
+ if (process.platform === 'darwin') {
2218
+ // macOS: launchd plist
2219
+ const plistPath = getCronPlistPath();
2220
+ const plistDir = path.dirname(plistPath);
2221
+ if (!existsSync(plistDir)) {
2222
+ mkdirSync(plistDir, { recursive: true });
2223
+ }
2224
+ // Unload existing plist if already installed (idempotent reinstall)
2225
+ if (existsSync(plistPath)) {
2226
+ try {
2227
+ execSync(`launchctl unload "${plistPath}"`, { stdio: 'ignore' });
2228
+ }
2229
+ catch {
2230
+ // not loaded — fine
2231
+ }
2232
+ }
2233
+ // Generate StartCalendarInterval entries for every 5th minute (wall-clock aligned)
2234
+ const calendarEntries = [0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55]
2235
+ .map((m) => ` <dict>\n <key>Minute</key>\n <integer>${m}</integer>\n </dict>`)
2236
+ .join('\n');
2237
+ const plist = `<?xml version="1.0" encoding="UTF-8"?>
2238
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
2239
+ <plist version="1.0">
2240
+ <dict>
2241
+ <key>Label</key>
2242
+ <string>${CRON_LAUNCHD_LABEL}</string>
2243
+ <key>ProgramArguments</key>
2244
+ <array>
2245
+ <string>${nodePath}</string>
2246
+ <string>${cliEntry}</string>
2247
+ <string>cron</string>
2248
+ <string>run-due</string>
2249
+ </array>
2250
+ <key>StartCalendarInterval</key>
2251
+ <array>
2252
+ ${calendarEntries}
2253
+ </array>
2254
+ <key>StandardOutPath</key>
2255
+ <string>${cronLog}</string>
2256
+ <key>StandardErrorPath</key>
2257
+ <string>${cronLog}</string>
2258
+ <key>EnvironmentVariables</key>
2259
+ <dict>
2260
+ <key>PATH</key>
2261
+ <string>${buildLaunchdPath()}</string>
2262
+ <key>CLEMENTINE_HOME</key>
2263
+ <string>${BASE_DIR}</string>
2264
+ </dict>
2265
+ </dict>
2266
+ </plist>`;
2267
+ writeFileSync(plistPath, plist);
2268
+ try {
2269
+ execSync(`launchctl load "${plistPath}"`);
2270
+ console.log(` Installed cron scheduler: ${CRON_LAUNCHD_LABEL}`);
2271
+ console.log(` Runs every 5 minutes via launchd`);
2272
+ console.log(` Plist: ${plistPath}`);
2273
+ console.log(` Logs: ${cronLog}`);
2274
+ console.log();
2275
+ console.log(` Note: This is a fallback for when the daemon is not running.`);
2276
+ console.log(` If the daemon is active, its built-in scheduler handles cron jobs`);
2277
+ console.log(` and the standalone runner will skip automatically.`);
2278
+ }
2279
+ catch (err) {
2280
+ console.error(` Failed to load LaunchAgent: ${err}`);
2281
+ }
2282
+ }
2283
+ else {
2284
+ // Linux: crontab entry
2285
+ const marker = `# clementine-cron-runner`;
2286
+ const entry = `*/5 * * * * ${nodePath} ${cliEntry} cron run-due >> ${cronLog} 2>&1 ${marker}`;
2287
+ let existing = '';
2288
+ try {
2289
+ existing = execSync('crontab -l 2>/dev/null', { encoding: 'utf-8' });
2290
+ }
2291
+ catch {
2292
+ // no existing crontab
2293
+ }
2294
+ if (existing.includes(marker)) {
2295
+ // Replace existing entry
2296
+ const lines = existing.split('\n').filter((l) => !l.includes(marker));
2297
+ lines.push(entry);
2298
+ const tempFile = path.join(os.tmpdir(), 'clementine-crontab.tmp');
2299
+ writeFileSync(tempFile, lines.join('\n') + '\n');
2300
+ execSync(`crontab "${tempFile}"`);
2301
+ unlinkSync(tempFile);
2302
+ console.log(' Updated existing crontab entry.');
2303
+ }
2304
+ else {
2305
+ const tempFile = path.join(os.tmpdir(), 'clementine-crontab.tmp');
2306
+ writeFileSync(tempFile, existing.trimEnd() + '\n' + entry + '\n');
2307
+ execSync(`crontab "${tempFile}"`);
2308
+ unlinkSync(tempFile);
2309
+ console.log(' Installed crontab entry.');
2310
+ }
2311
+ console.log(` Runs every 5 minutes`);
2312
+ console.log(` Logs: ${cronLog}`);
2313
+ }
2314
+ }
2315
+ function cmdCronUninstall() {
2316
+ if (process.platform === 'darwin') {
2317
+ const plistPath = getCronPlistPath();
2318
+ if (existsSync(plistPath)) {
2319
+ try {
2320
+ execSync(`launchctl unload "${plistPath}"`, { stdio: 'ignore' });
2321
+ }
2322
+ catch {
2323
+ // not loaded
2324
+ }
2325
+ unlinkSync(plistPath);
2326
+ console.log(` Uninstalled cron scheduler: ${CRON_LAUNCHD_LABEL}`);
2327
+ }
2328
+ else {
2329
+ console.log(' Cron scheduler not installed.');
2330
+ }
2331
+ }
2332
+ else {
2333
+ const marker = `# clementine-cron-runner`;
2334
+ let existing = '';
2335
+ try {
2336
+ existing = execSync('crontab -l 2>/dev/null', { encoding: 'utf-8' });
2337
+ }
2338
+ catch {
2339
+ console.log(' No crontab found.');
2340
+ return;
2341
+ }
2342
+ if (!existing.includes(marker)) {
2343
+ console.log(' Cron scheduler not installed in crontab.');
2344
+ return;
2345
+ }
2346
+ const lines = existing.split('\n').filter((l) => !l.includes(marker));
2347
+ const tempFile = path.join(os.tmpdir(), 'clementine-crontab.tmp');
2348
+ writeFileSync(tempFile, lines.join('\n'));
2349
+ execSync(`crontab "${tempFile}"`);
2350
+ unlinkSync(tempFile);
2351
+ console.log(' Removed crontab entry.');
2352
+ }
2353
+ }
2354
+ // ── Logs command ────────────────────────────────────────────────────
2355
+ function formatLogLine(line) {
2356
+ try {
2357
+ const entry = JSON.parse(line);
2358
+ const ts = typeof entry.time === 'number'
2359
+ ? new Date(entry.time).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit', second: '2-digit' })
2360
+ : String(entry.time ?? '').slice(11, 19);
2361
+ const level = entry.level ?? 30;
2362
+ const levelName = level <= 20 ? 'DEBUG' : level <= 30 ? 'INFO' : level <= 40 ? 'WARN' : 'ERROR';
2363
+ const levelColors = {
2364
+ DEBUG: '\x1b[0;90m', INFO: '\x1b[0;32m', WARN: '\x1b[1;33m', ERROR: '\x1b[0;31m',
2365
+ };
2366
+ const color = levelColors[levelName] ?? '';
2367
+ const RESET = '\x1b[0m';
2368
+ const DIM = '\x1b[0;90m';
2369
+ const component = entry.name ? entry.name.replace('clementine.', '') : '';
2370
+ const msg = entry.msg ?? '';
2371
+ return `${DIM}${ts}${RESET} ${color}${levelName.padEnd(5)}${RESET} ${DIM}[${component}]${RESET} ${msg}`;
2372
+ }
2373
+ catch {
2374
+ return line;
2375
+ }
2376
+ }
2377
+ function cmdLogs(opts) {
2378
+ const logDir = path.join(BASE_DIR, 'logs');
2379
+ const logFile = opts.cron
2380
+ ? path.join(logDir, 'cron.log')
2381
+ : path.join(logDir, 'clementine.log');
2382
+ if (!existsSync(logFile)) {
2383
+ console.error(`Log file not found: ${logFile}`);
2384
+ process.exit(1);
2385
+ }
2386
+ const numLines = parseInt(opts.lines ?? '50', 10) || 50;
2387
+ const filter = opts.filter?.toLowerCase();
2388
+ // Read last N lines
2389
+ const content = readFileSync(logFile, 'utf-8');
2390
+ let lines = content.split('\n').filter(Boolean);
2391
+ lines = lines.slice(-numLines);
2392
+ // Apply component filter
2393
+ if (filter) {
2394
+ lines = lines.filter(line => {
2395
+ try {
2396
+ const entry = JSON.parse(line);
2397
+ const name = String(entry.name ?? '').toLowerCase();
2398
+ return name.includes(filter);
2399
+ }
2400
+ catch {
2401
+ return line.toLowerCase().includes(filter);
2402
+ }
2403
+ });
2404
+ }
2405
+ // Output
2406
+ for (const line of lines) {
2407
+ if (opts.json) {
2408
+ console.log(line);
2409
+ }
2410
+ else {
2411
+ console.log(formatLogLine(line));
2412
+ }
2413
+ }
2414
+ // Follow mode
2415
+ if (opts.follow) {
2416
+ let lastSize = statSync(logFile).size;
2417
+ const poll = setInterval(() => {
2418
+ try {
2419
+ const currentSize = statSync(logFile).size;
2420
+ if (currentSize < lastSize) {
2421
+ // Log rotation — reset
2422
+ lastSize = 0;
2423
+ }
2424
+ if (currentSize === lastSize)
2425
+ return;
2426
+ // Read new bytes
2427
+ const fd = openSync(logFile, 'r');
2428
+ const buf = Buffer.alloc(currentSize - lastSize);
2429
+ readSync(fd, buf, 0, buf.length, lastSize);
2430
+ closeSync(fd);
2431
+ lastSize = currentSize;
2432
+ const newLines = buf.toString('utf-8').split('\n').filter(Boolean);
2433
+ for (const line of newLines) {
2434
+ if (filter) {
2435
+ try {
2436
+ const entry = JSON.parse(line);
2437
+ const name = String(entry.name ?? '').toLowerCase();
2438
+ if (!name.includes(filter))
2439
+ continue;
2440
+ }
2441
+ catch {
2442
+ if (!line.toLowerCase().includes(filter))
2443
+ continue;
2444
+ }
2445
+ }
2446
+ if (opts.json) {
2447
+ console.log(line);
2448
+ }
2449
+ else {
2450
+ console.log(formatLogLine(line));
2451
+ }
2452
+ }
2453
+ }
2454
+ catch {
2455
+ // File may be temporarily unavailable during rotation
2456
+ }
2457
+ }, 500);
2458
+ process.on('SIGINT', () => {
2459
+ clearInterval(poll);
2460
+ process.exit(0);
2461
+ });
2462
+ }
2463
+ }
2464
+ program
2465
+ .command('logs')
2466
+ .description('Tail and filter daemon logs')
2467
+ .option('-f, --follow', 'Follow mode (tail -f)')
2468
+ .option('-n, --lines <n>', 'Number of lines (default 50)', '50')
2469
+ .option('--filter <component>', 'Filter by component (e.g. discord, cron, gateway)')
2470
+ .option('--cron', 'Show cron log instead of daemon log')
2471
+ .option('--json', 'Raw JSON output')
2472
+ .action(cmdLogs);
2473
+ program.parse();
2474
+ //# sourceMappingURL=index.js.map