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.
- package/.env.example +44 -0
- package/LICENSE +21 -0
- package/README.md +795 -0
- package/dist/agent/agent-manager.d.ts +69 -0
- package/dist/agent/agent-manager.js +441 -0
- package/dist/agent/assistant.d.ts +225 -0
- package/dist/agent/assistant.js +3888 -0
- package/dist/agent/auto-update.d.ts +32 -0
- package/dist/agent/auto-update.js +186 -0
- package/dist/agent/daily-planner.d.ts +24 -0
- package/dist/agent/daily-planner.js +379 -0
- package/dist/agent/execution-advisor.d.ts +10 -0
- package/dist/agent/execution-advisor.js +272 -0
- package/dist/agent/hooks.d.ts +45 -0
- package/dist/agent/hooks.js +564 -0
- package/dist/agent/insight-engine.d.ts +66 -0
- package/dist/agent/insight-engine.js +225 -0
- package/dist/agent/intent-classifier.d.ts +48 -0
- package/dist/agent/intent-classifier.js +214 -0
- package/dist/agent/link-extractor.d.ts +19 -0
- package/dist/agent/link-extractor.js +90 -0
- package/dist/agent/mcp-bridge.d.ts +62 -0
- package/dist/agent/mcp-bridge.js +435 -0
- package/dist/agent/metacognition.d.ts +66 -0
- package/dist/agent/metacognition.js +221 -0
- package/dist/agent/orchestrator.d.ts +81 -0
- package/dist/agent/orchestrator.js +790 -0
- package/dist/agent/profiles.d.ts +22 -0
- package/dist/agent/profiles.js +91 -0
- package/dist/agent/prompt-cache.d.ts +24 -0
- package/dist/agent/prompt-cache.js +68 -0
- package/dist/agent/prompt-evolver.d.ts +28 -0
- package/dist/agent/prompt-evolver.js +279 -0
- package/dist/agent/role-scaffolds.d.ts +28 -0
- package/dist/agent/role-scaffolds.js +433 -0
- package/dist/agent/safe-restart.d.ts +41 -0
- package/dist/agent/safe-restart.js +150 -0
- package/dist/agent/self-improve.d.ts +66 -0
- package/dist/agent/self-improve.js +1706 -0
- package/dist/agent/session-event-log.d.ts +114 -0
- package/dist/agent/session-event-log.js +233 -0
- package/dist/agent/skill-extractor.d.ts +72 -0
- package/dist/agent/skill-extractor.js +435 -0
- package/dist/agent/source-mods.d.ts +61 -0
- package/dist/agent/source-mods.js +230 -0
- package/dist/agent/source-preflight.d.ts +25 -0
- package/dist/agent/source-preflight.js +100 -0
- package/dist/agent/stall-guard.d.ts +62 -0
- package/dist/agent/stall-guard.js +109 -0
- package/dist/agent/strategic-planner.d.ts +60 -0
- package/dist/agent/strategic-planner.js +352 -0
- package/dist/agent/team-bus.d.ts +89 -0
- package/dist/agent/team-bus.js +556 -0
- package/dist/agent/team-router.d.ts +26 -0
- package/dist/agent/team-router.js +37 -0
- package/dist/agent/tool-loop-detector.d.ts +59 -0
- package/dist/agent/tool-loop-detector.js +242 -0
- package/dist/agent/workflow-runner.d.ts +36 -0
- package/dist/agent/workflow-runner.js +317 -0
- package/dist/agent/workflow-variables.d.ts +16 -0
- package/dist/agent/workflow-variables.js +62 -0
- package/dist/channels/discord-agent-bot.d.ts +101 -0
- package/dist/channels/discord-agent-bot.js +881 -0
- package/dist/channels/discord-bot-manager.d.ts +80 -0
- package/dist/channels/discord-bot-manager.js +262 -0
- package/dist/channels/discord-utils.d.ts +51 -0
- package/dist/channels/discord-utils.js +293 -0
- package/dist/channels/discord.d.ts +12 -0
- package/dist/channels/discord.js +1832 -0
- package/dist/channels/slack-agent-bot.d.ts +73 -0
- package/dist/channels/slack-agent-bot.js +320 -0
- package/dist/channels/slack-bot-manager.d.ts +66 -0
- package/dist/channels/slack-bot-manager.js +236 -0
- package/dist/channels/slack-utils.d.ts +39 -0
- package/dist/channels/slack-utils.js +189 -0
- package/dist/channels/slack.d.ts +11 -0
- package/dist/channels/slack.js +196 -0
- package/dist/channels/telegram.d.ts +10 -0
- package/dist/channels/telegram.js +235 -0
- package/dist/channels/webhook.d.ts +9 -0
- package/dist/channels/webhook.js +78 -0
- package/dist/channels/whatsapp.d.ts +11 -0
- package/dist/channels/whatsapp.js +181 -0
- package/dist/cli/chat.d.ts +14 -0
- package/dist/cli/chat.js +220 -0
- package/dist/cli/cron.d.ts +17 -0
- package/dist/cli/cron.js +552 -0
- package/dist/cli/dashboard.d.ts +15 -0
- package/dist/cli/dashboard.js +17677 -0
- package/dist/cli/index.d.ts +3 -0
- package/dist/cli/index.js +2474 -0
- package/dist/cli/routes/delegations.d.ts +19 -0
- package/dist/cli/routes/delegations.js +154 -0
- package/dist/cli/routes/digest.d.ts +17 -0
- package/dist/cli/routes/digest.js +375 -0
- package/dist/cli/routes/goals.d.ts +14 -0
- package/dist/cli/routes/goals.js +258 -0
- package/dist/cli/routes/workflows.d.ts +18 -0
- package/dist/cli/routes/workflows.js +97 -0
- package/dist/cli/setup.d.ts +8 -0
- package/dist/cli/setup.js +619 -0
- package/dist/cli/tunnel.d.ts +35 -0
- package/dist/cli/tunnel.js +141 -0
- package/dist/config.d.ts +145 -0
- package/dist/config.js +278 -0
- package/dist/events/bus.d.ts +43 -0
- package/dist/events/bus.js +136 -0
- package/dist/gateway/cron-scheduler.d.ts +166 -0
- package/dist/gateway/cron-scheduler.js +1767 -0
- package/dist/gateway/delivery-queue.d.ts +30 -0
- package/dist/gateway/delivery-queue.js +110 -0
- package/dist/gateway/heartbeat-scheduler.d.ts +99 -0
- package/dist/gateway/heartbeat-scheduler.js +1298 -0
- package/dist/gateway/heartbeat.d.ts +3 -0
- package/dist/gateway/heartbeat.js +3 -0
- package/dist/gateway/lanes.d.ts +24 -0
- package/dist/gateway/lanes.js +76 -0
- package/dist/gateway/notifications.d.ts +29 -0
- package/dist/gateway/notifications.js +75 -0
- package/dist/gateway/router.d.ts +210 -0
- package/dist/gateway/router.js +1330 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +1015 -0
- package/dist/memory/chunker.d.ts +28 -0
- package/dist/memory/chunker.js +226 -0
- package/dist/memory/consolidation.d.ts +44 -0
- package/dist/memory/consolidation.js +171 -0
- package/dist/memory/context-assembler.d.ts +50 -0
- package/dist/memory/context-assembler.js +149 -0
- package/dist/memory/embeddings.d.ts +38 -0
- package/dist/memory/embeddings.js +180 -0
- package/dist/memory/graph-store.d.ts +66 -0
- package/dist/memory/graph-store.js +613 -0
- package/dist/memory/mmr.d.ts +21 -0
- package/dist/memory/mmr.js +75 -0
- package/dist/memory/search.d.ts +26 -0
- package/dist/memory/search.js +67 -0
- package/dist/memory/store.d.ts +530 -0
- package/dist/memory/store.js +2022 -0
- package/dist/security/integrity.d.ts +24 -0
- package/dist/security/integrity.js +58 -0
- package/dist/security/patterns.d.ts +34 -0
- package/dist/security/patterns.js +110 -0
- package/dist/security/scanner.d.ts +32 -0
- package/dist/security/scanner.js +263 -0
- package/dist/tools/admin-tools.d.ts +12 -0
- package/dist/tools/admin-tools.js +1278 -0
- package/dist/tools/external-tools.d.ts +11 -0
- package/dist/tools/external-tools.js +1327 -0
- package/dist/tools/goal-tools.d.ts +9 -0
- package/dist/tools/goal-tools.js +159 -0
- package/dist/tools/mcp-server.d.ts +13 -0
- package/dist/tools/mcp-server.js +141 -0
- package/dist/tools/memory-tools.d.ts +10 -0
- package/dist/tools/memory-tools.js +568 -0
- package/dist/tools/session-tools.d.ts +6 -0
- package/dist/tools/session-tools.js +146 -0
- package/dist/tools/shared.d.ts +216 -0
- package/dist/tools/shared.js +340 -0
- package/dist/tools/team-tools.d.ts +6 -0
- package/dist/tools/team-tools.js +447 -0
- package/dist/tools/tool-meta.d.ts +34 -0
- package/dist/tools/tool-meta.js +133 -0
- package/dist/tools/vault-tools.d.ts +8 -0
- package/dist/tools/vault-tools.js +457 -0
- package/dist/types.d.ts +716 -0
- package/dist/types.js +16 -0
- package/dist/vault-migrations/0001-add-execution-framework.d.ts +10 -0
- package/dist/vault-migrations/0001-add-execution-framework.js +47 -0
- package/dist/vault-migrations/0002-add-agentic-communication.d.ts +12 -0
- package/dist/vault-migrations/0002-add-agentic-communication.js +79 -0
- package/dist/vault-migrations/0003-update-execution-pipeline-narration.d.ts +11 -0
- package/dist/vault-migrations/0003-update-execution-pipeline-narration.js +73 -0
- package/dist/vault-migrations/helpers.d.ts +14 -0
- package/dist/vault-migrations/helpers.js +44 -0
- package/dist/vault-migrations/runner.d.ts +14 -0
- package/dist/vault-migrations/runner.js +139 -0
- package/dist/vault-migrations/types.d.ts +42 -0
- package/dist/vault-migrations/types.js +9 -0
- package/install.sh +320 -0
- package/package.json +84 -0
- package/scripts/postinstall.js +125 -0
- package/vault/00-System/AGENTS.md +66 -0
- package/vault/00-System/CRON.md +71 -0
- package/vault/00-System/HEARTBEAT.md +58 -0
- package/vault/00-System/MEMORY.md +16 -0
- package/vault/00-System/SOUL.md +96 -0
- package/vault/05-Tasks/TASKS.md +19 -0
- package/vault/06-Templates/_Daily-Template.md +28 -0
- 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
|