@inetafrica/open-claudia 1.7.0 → 1.7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/Dockerfile CHANGED
@@ -11,6 +11,9 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
11
11
  RUN curl -fsSL https://claude.ai/install.sh | sh || \
12
12
  npm install -g @anthropic-ai/claude-code
13
13
 
14
+ # Create non-root user (Claude Code refuses --dangerously-skip-permissions as root)
15
+ RUN groupadd -r claudia && useradd -r -g claudia -m -d /data claudia
16
+
14
17
  # Create app directory
15
18
  WORKDIR /app
16
19
 
@@ -21,6 +24,14 @@ RUN npm ci --production
21
24
  # Copy app source
22
25
  COPY . .
23
26
 
27
+ # Entrypoint auto-configures from env vars on first run
28
+ COPY docker-entrypoint.sh /usr/local/bin/
29
+ RUN chmod +x /usr/local/bin/docker-entrypoint.sh
30
+
31
+ # Set up data directory ownership
32
+ RUN mkdir -p /data/Workspace /data/.open-claudia /data/.claude && \
33
+ chown -R claudia:claudia /data
34
+
24
35
  # Config and data volume
25
36
  ENV HOME=/data
26
37
  ENV WEB_UI=true
@@ -29,10 +40,8 @@ VOLUME /data
29
40
 
30
41
  EXPOSE 8080
31
42
 
32
- # Entrypoint auto-configures from env vars on first run
33
- COPY docker-entrypoint.sh /usr/local/bin/
34
- RUN chmod +x /usr/local/bin/docker-entrypoint.sh
35
- ENTRYPOINT ["docker-entrypoint.sh"]
43
+ # Switch to non-root user
44
+ USER claudia
36
45
 
37
- # Start with web UI enabled
46
+ ENTRYPOINT ["docker-entrypoint.sh"]
38
47
  CMD ["node", "bin/cli.js", "web"]
package/bin/cli.js CHANGED
@@ -9,6 +9,7 @@ const command = args[0] || "help";
9
9
 
10
10
  const botDir = path.join(__dirname, "..");
11
11
  const configDir = require(path.join(botDir, "config-dir"));
12
+ const health = require(path.join(botDir, "health"));
12
13
 
13
14
  function getBotFile() {
14
15
  const modeFile = path.join(configDir, ".bot-mode");
@@ -19,6 +20,15 @@ function getBotFile() {
19
20
  return "bot.js";
20
21
  }
21
22
 
23
+ function startBot(botFile, args) {
24
+ // Start web UI alongside bot if WEB_UI=true or --web flag
25
+ if (process.env.WEB_UI === "true" || args.includes("--web")) {
26
+ const { startWebServer } = require(path.join(botDir, "web.js"));
27
+ startWebServer();
28
+ }
29
+ require(path.join(botDir, botFile));
30
+ }
31
+
22
32
  function findBotProcesses() {
23
33
  try {
24
34
  if (process.platform === "win32") {
@@ -49,14 +59,42 @@ switch (command) {
49
59
  break;
50
60
 
51
61
  case "start": {
62
+ const skipHealthCheck = args.includes("--skip-health") || args.includes("--force");
52
63
  const botFile = getBotFile();
53
- console.log(`Starting Open Claudia (${botFile === "bot-agent.js" ? "agent" : "direct"} mode)...`);
54
- // Start web UI alongside bot if WEB_UI=true or --web flag
55
- if (process.env.WEB_UI === "true" || args.includes("--web")) {
56
- const { startWebServer } = require(path.join(botDir, "web.js"));
57
- startWebServer();
64
+
65
+ // Run health checks before starting (unless skipped)
66
+ if (!skipHealthCheck) {
67
+ console.log("Running health checks...");
68
+ health.runHealthChecks({ quick: args.includes("--quick") }).then((results) => {
69
+ if (!results.ok) {
70
+ console.error("\n" + health.formatResults(results, true));
71
+ console.error("\nFix the issues above or use --skip-health to bypass (not recommended).");
72
+ process.exit(1);
73
+ }
74
+ if (results.warnings.length > 0) {
75
+ console.log("Warnings:");
76
+ for (const w of results.warnings) console.log(` - ${w}`);
77
+ }
78
+ console.log(`Starting Open Claudia (${botFile === "bot-agent.js" ? "agent" : "direct"} mode)...`);
79
+ startBot(botFile, args);
80
+ }).catch((err) => {
81
+ console.error("Health check failed:", err.message);
82
+ process.exit(1);
83
+ });
84
+ } else {
85
+ console.log(`Starting Open Claudia (${botFile === "bot-agent.js" ? "agent" : "direct"} mode)...`);
86
+ startBot(botFile, args);
58
87
  }
59
- require(path.join(botDir, botFile));
88
+ break;
89
+ }
90
+
91
+ case "health": {
92
+ console.log("Running health checks...\n");
93
+ const verbose = args.includes("-v") || args.includes("--verbose");
94
+ health.runHealthChecks({ quick: false }).then((results) => {
95
+ console.log(health.formatResults(results, verbose));
96
+ process.exit(results.ok ? 0 : 1);
97
+ });
60
98
  break;
61
99
  }
62
100
 
@@ -123,6 +161,15 @@ Commands:
123
161
  open-claudia web Start with web UI for setup/config
124
162
  open-claudia stop Stop the bot
125
163
  open-claudia status Check if running
164
+ open-claudia health Run environment health checks
126
165
  open-claudia logs View recent logs
166
+
167
+ Start options:
168
+ --web Also start the web UI
169
+ --quick Skip slow health checks (Claude auth, Telegram API)
170
+ --skip-health Bypass all health checks (not recommended)
171
+
172
+ Health options:
173
+ -v, --verbose Show detailed check results
127
174
  `);
128
175
  }
package/bot-agent.js CHANGED
@@ -1018,10 +1018,11 @@ bot.onText(/\/upgrade$/, async (msg) => {
1018
1018
  try {
1019
1019
  execSync("npm install -g @inetafrica/open-claudia@latest 2>&1", {
1020
1020
  encoding: "utf-8", timeout: 120000,
1021
+ cwd: process.env.HOME || require("os").homedir(),
1021
1022
  env: { ...process.env, PATH: FULL_PATH, HOME: process.env.HOME || require("os").homedir() },
1022
1023
  });
1023
1024
  // Read version from newly installed package
1024
- const root = execSync("npm root -g", { encoding: "utf-8", env: { ...process.env, PATH: FULL_PATH } }).trim();
1025
+ const root = execSync("npm root -g", { encoding: "utf-8", cwd: process.env.HOME || require("os").homedir(), env: { ...process.env, PATH: FULL_PATH } }).trim();
1025
1026
  const newPkg = JSON.parse(fs.readFileSync(path.join(root, "@inetafrica", "open-claudia", "package.json"), "utf-8"));
1026
1027
  await send(`Installed v${newPkg.version}. Going offline to restart...`);
1027
1028
  } catch (e) {
package/bot.js CHANGED
@@ -8,8 +8,39 @@ const Vault = require("./vault");
8
8
  const CONFIG_DIR = require("./config-dir");
9
9
 
10
10
  // ── Graceful shutdown & error handling ─────────────────────────────
11
- process.on("SIGINT", () => process.exit(0));
12
- process.on("SIGTERM", () => process.exit(0));
11
+ async function gracefulShutdown(signal) {
12
+ console.log(`Received ${signal}, shutting down gracefully...`);
13
+ // Stop any running Claude process
14
+ if (typeof runningProcess !== "undefined" && runningProcess) {
15
+ try {
16
+ process.kill(-runningProcess.pid, "SIGTERM");
17
+ } catch (e) {
18
+ try { runningProcess.kill("SIGTERM"); } catch (e2) {}
19
+ }
20
+ }
21
+ // Clean up temp media files older than 1 hour
22
+ try {
23
+ const CONFIG_DIR_TEMP = require("./config-dir");
24
+ const mediaDir = require("path").join(CONFIG_DIR_TEMP, "media");
25
+ if (require("fs").existsSync(mediaDir)) {
26
+ const files = require("fs").readdirSync(mediaDir);
27
+ const oneHourAgo = Date.now() - 60 * 60 * 1000;
28
+ for (const file of files) {
29
+ const filePath = require("path").join(mediaDir, file);
30
+ try {
31
+ const stat = require("fs").statSync(filePath);
32
+ if (stat.mtimeMs < oneHourAgo) {
33
+ require("fs").unlinkSync(filePath);
34
+ }
35
+ } catch (e) {}
36
+ }
37
+ }
38
+ } catch (e) {}
39
+ process.exit(0);
40
+ }
41
+
42
+ process.on("SIGINT", () => gracefulShutdown("SIGINT"));
43
+ process.on("SIGTERM", () => gracefulShutdown("SIGTERM"));
13
44
 
14
45
  // Notify user of crashes via Telegram before exiting
15
46
  function notifyError(label, err) {
@@ -76,6 +107,34 @@ const CHAT_IDS = (config.TELEGRAM_CHAT_ID || "").split(",").map((s) => s.trim())
76
107
  const CHAT_ID = CHAT_IDS[0]; // Primary owner chat for crons/notifications
77
108
  const WORKSPACE = config.WORKSPACE;
78
109
  const CLAUDE_PATH = config.CLAUDE_PATH;
110
+
111
+ // Validate critical config at startup
112
+ if (!TOKEN) { console.error("TELEGRAM_BOT_TOKEN not set"); process.exit(1); }
113
+ if (!CHAT_ID) { console.error("TELEGRAM_CHAT_ID not set"); process.exit(1); }
114
+ if (!WORKSPACE) { console.error("WORKSPACE not set"); process.exit(1); }
115
+ if (!CLAUDE_PATH) { console.error("CLAUDE_PATH not set"); process.exit(1); }
116
+
117
+ // Ensure workspace exists
118
+ if (!fs.existsSync(WORKSPACE)) {
119
+ try {
120
+ fs.mkdirSync(WORKSPACE, { recursive: true });
121
+ console.log(`Created workspace: ${WORKSPACE}`);
122
+ } catch (e) {
123
+ console.error(`Failed to create workspace: ${e.message}`);
124
+ process.exit(1);
125
+ }
126
+ }
127
+
128
+ // Verify Claude CLI exists
129
+ if (!fs.existsSync(CLAUDE_PATH)) {
130
+ const { execSync } = require("child_process");
131
+ try {
132
+ execSync(`which "${CLAUDE_PATH}" 2>/dev/null || where "${CLAUDE_PATH}" 2>nul`, { encoding: "utf-8" });
133
+ } catch (e) {
134
+ console.error(`Claude CLI not found at: ${CLAUDE_PATH}`);
135
+ process.exit(1);
136
+ }
137
+ }
79
138
  const WHISPER_CLI = config.WHISPER_CLI || "";
80
139
  const WHISPER_MODEL = config.WHISPER_MODEL || "";
81
140
  const FFMPEG = config.FFMPEG || "";
@@ -188,6 +247,13 @@ const FILES_DIR = path.join(CONFIG_DIR, "files");
188
247
  if (!fs.existsSync(TEMP_DIR)) fs.mkdirSync(TEMP_DIR, { recursive: true });
189
248
  if (!fs.existsSync(FILES_DIR)) fs.mkdirSync(FILES_DIR, { recursive: true });
190
249
 
250
+ // File size limits (in bytes)
251
+ const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50MB max for documents
252
+ const MAX_VOICE_SIZE = 10 * 1024 * 1024; // 10MB max for voice/audio
253
+
254
+ // Process timeout (30 minutes max)
255
+ const MAX_PROCESS_TIMEOUT = 30 * 60 * 1000;
256
+
191
257
  // ── Persistent state ───────────────────────────────────────────────
192
258
  const STATE_FILE = path.join(CONFIG_DIR, "state.json");
193
259
  const SESSIONS_FILE = path.join(CONFIG_DIR, "sessions.json");
@@ -695,6 +761,21 @@ async function runClaude(prompt, cwd, replyToMsgId, opts = {}) {
695
761
  const startTime = Date.now();
696
762
  let longRunningNotified = false;
697
763
 
764
+ // Hard timeout to prevent runaway processes
765
+ const processTimeout = setTimeout(async () => {
766
+ if (runningProcess === proc) {
767
+ try {
768
+ process.kill(-proc.pid, "SIGTERM");
769
+ setTimeout(() => {
770
+ try { process.kill(-proc.pid, "SIGKILL"); } catch (e) {}
771
+ }, 5000);
772
+ } catch (e) {
773
+ try { proc.kill("SIGKILL"); } catch (e2) {}
774
+ }
775
+ await send(`Task timed out after ${MAX_PROCESS_TIMEOUT / 60000} minutes. Stopped.`);
776
+ }
777
+ }, MAX_PROCESS_TIMEOUT);
778
+
698
779
  let lastUpdate = "";
699
780
  // Adaptive update interval: 2s for first 2min, then 5s to avoid rate limits
700
781
  const scheduleUpdate = () => {
@@ -756,11 +837,26 @@ async function runClaude(prompt, cwd, replyToMsgId, opts = {}) {
756
837
  }
757
838
  });
758
839
 
759
- proc.stderr.on("data", (d) => console.error("STDERR:", d.toString()));
840
+ let stderrBuffer = "";
841
+ proc.stderr.on("data", (d) => {
842
+ const chunk = d.toString();
843
+ stderrBuffer += chunk;
844
+ console.error("STDERR:", chunk);
845
+ });
760
846
 
761
847
  proc.on("close", async (code) => {
762
848
  runningProcess = null;
763
849
  clearTimeout(streamInterval); streamInterval = null;
850
+ clearTimeout(processTimeout);
851
+
852
+ // Check for auth errors in stderr
853
+ const stderrLower = stderrBuffer.toLowerCase();
854
+ if (stderrLower.includes("unauthorized") || stderrLower.includes("auth") && stderrLower.includes("fail") ||
855
+ stderrLower.includes("api key") || stderrLower.includes("not logged in")) {
856
+ await send("Claude authentication error. Run `claude auth` to re-authenticate.");
857
+ return;
858
+ }
859
+
764
860
  try {
765
861
  const finalText = assistantText || "(no output)";
766
862
  const chunks = splitMessage(finalText);
@@ -805,8 +901,11 @@ async function runClaude(prompt, cwd, replyToMsgId, opts = {}) {
805
901
  });
806
902
 
807
903
  proc.on("error", async (err) => {
808
- runningProcess = null; clearTimeout(streamInterval);
809
- await send(`Error: ${err.message}`); statusMessageId = null;
904
+ runningProcess = null;
905
+ clearTimeout(streamInterval);
906
+ clearTimeout(processTimeout);
907
+ await send(`Error: ${err.message}`);
908
+ statusMessageId = null;
810
909
  });
811
910
  }
812
911
 
@@ -959,10 +1058,11 @@ bot.onText(/\/upgrade$/, async (msg) => {
959
1058
  try {
960
1059
  execSync("npm install -g @inetafrica/open-claudia@latest 2>&1", {
961
1060
  encoding: "utf-8", timeout: 120000,
1061
+ cwd: process.env.HOME || require("os").homedir(),
962
1062
  env: { ...process.env, PATH: FULL_PATH, HOME: process.env.HOME || require("os").homedir() },
963
1063
  });
964
1064
  // Read version from newly installed package
965
- const root = execSync("npm root -g", { encoding: "utf-8", env: { ...process.env, PATH: FULL_PATH } }).trim();
1065
+ const root = execSync("npm root -g", { encoding: "utf-8", cwd: process.env.HOME || require("os").homedir(), env: { ...process.env, PATH: FULL_PATH } }).trim();
966
1066
  const newPkg = JSON.parse(fs.readFileSync(path.join(root, "@inetafrica", "open-claudia", "package.json"), "utf-8"));
967
1067
  await send(`Installed v${newPkg.version}. Going offline to restart...`);
968
1068
  } catch (e) {
@@ -1241,6 +1341,10 @@ bot.on("voice", async (msg) => {
1241
1341
  if (!isAuthorized(msg)) return;
1242
1342
  if (!requireSession(msg)) return;
1243
1343
  try {
1344
+ // Check file size
1345
+ if (msg.voice.file_size && msg.voice.file_size > MAX_VOICE_SIZE) {
1346
+ return send(`Voice note too large (${Math.round(msg.voice.file_size / 1024 / 1024)}MB). Max: ${MAX_VOICE_SIZE / 1024 / 1024}MB`);
1347
+ }
1244
1348
  bot.sendChatAction(CHAT_ID, "typing");
1245
1349
  const oggPath = await downloadFile(msg.voice.file_id, ".ogg");
1246
1350
  const transcript = transcribeAudio(oggPath);
@@ -1282,6 +1386,10 @@ bot.on("document", async (msg) => {
1282
1386
  if (!isAuthorized(msg)) return;
1283
1387
  if (!requireSession(msg)) return;
1284
1388
  try {
1389
+ // Check file size
1390
+ if (msg.document.file_size && msg.document.file_size > MAX_FILE_SIZE) {
1391
+ return send(`File too large (${Math.round(msg.document.file_size / 1024 / 1024)}MB). Max: ${MAX_FILE_SIZE / 1024 / 1024}MB`);
1392
+ }
1285
1393
  const fileName = msg.document.file_name || `file-${Date.now()}`;
1286
1394
  const mime = msg.document.mime_type || "";
1287
1395
  // Save with original name to files dir
package/health.js ADDED
@@ -0,0 +1,391 @@
1
+ const { execSync } = require("child_process");
2
+ const fs = require("fs");
3
+ const path = require("path");
4
+ const https = require("https");
5
+ const CONFIG_DIR = require("./config-dir");
6
+
7
+ const ENV_FILE = path.join(CONFIG_DIR, ".env");
8
+ const AUTH_FILE = path.join(CONFIG_DIR, "auth.json");
9
+
10
+ // Required env keys for the bot to function
11
+ const REQUIRED_ENV_KEYS = [
12
+ "TELEGRAM_BOT_TOKEN",
13
+ "TELEGRAM_CHAT_ID",
14
+ "WORKSPACE",
15
+ "CLAUDE_PATH",
16
+ ];
17
+
18
+ // Optional but recommended
19
+ const OPTIONAL_ENV_KEYS = [
20
+ "WHISPER_CLI",
21
+ "WHISPER_MODEL",
22
+ "FFMPEG",
23
+ "VAULT_FILE",
24
+ "SOUL_FILE",
25
+ "CRONS_FILE",
26
+ ];
27
+
28
+ /**
29
+ * Load and parse .env file
30
+ */
31
+ function loadEnv() {
32
+ if (!fs.existsSync(ENV_FILE)) return null;
33
+ const lines = fs.readFileSync(ENV_FILE, "utf-8").split("\n");
34
+ const env = {};
35
+ for (const line of lines) {
36
+ const idx = line.indexOf("=");
37
+ if (idx > 0) env[line.slice(0, idx).trim()] = line.slice(idx + 1).trim();
38
+ }
39
+ return env;
40
+ }
41
+
42
+ /**
43
+ * Check if a binary exists at the given path
44
+ */
45
+ function binaryExists(binPath) {
46
+ if (!binPath) return false;
47
+ try {
48
+ fs.accessSync(binPath, fs.constants.X_OK);
49
+ return true;
50
+ } catch (e) {
51
+ // Try which/where as fallback
52
+ try {
53
+ const cmd = process.platform === "win32" ? `where "${binPath}"` : `which "${binPath}"`;
54
+ execSync(cmd, { encoding: "utf-8", stdio: "pipe" });
55
+ return true;
56
+ } catch (e2) {
57
+ return false;
58
+ }
59
+ }
60
+ }
61
+
62
+ /**
63
+ * Test if Claude CLI is authenticated
64
+ */
65
+ function testClaudeAuth(claudePath) {
66
+ try {
67
+ execSync(`"${claudePath}" -p "say ok" --max-budget-usd 0.01 --output-format text 2>&1`, {
68
+ encoding: "utf-8",
69
+ timeout: 30000,
70
+ stdio: "pipe",
71
+ });
72
+ return { ok: true };
73
+ } catch (e) {
74
+ const errMsg = (e.stderr || e.stdout || e.message || "").toLowerCase();
75
+ if (errMsg.includes("auth") || errMsg.includes("login") || errMsg.includes("api key") || errMsg.includes("unauthorized")) {
76
+ return { ok: false, reason: "not_authenticated", message: "Claude CLI is not authenticated. Run 'claude auth' or 'claude login'." };
77
+ }
78
+ // Other errors (like timeout) are OK - Claude is accessible
79
+ return { ok: true };
80
+ }
81
+ }
82
+
83
+ /**
84
+ * Test Telegram bot token validity
85
+ */
86
+ function testTelegramToken(token) {
87
+ return new Promise((resolve) => {
88
+ const timeout = setTimeout(() => resolve({ ok: false, reason: "timeout", message: "Telegram API request timed out" }), 15000);
89
+
90
+ https.get(`https://api.telegram.org/bot${token}/getMe`, (res) => {
91
+ let data = "";
92
+ res.on("data", (d) => { data += d; });
93
+ res.on("end", () => {
94
+ clearTimeout(timeout);
95
+ try {
96
+ const json = JSON.parse(data);
97
+ if (json.ok) {
98
+ resolve({ ok: true, botInfo: json.result });
99
+ } else {
100
+ resolve({ ok: false, reason: "invalid_token", message: `Telegram API error: ${json.description || "Invalid token"}` });
101
+ }
102
+ } catch (e) {
103
+ resolve({ ok: false, reason: "parse_error", message: "Failed to parse Telegram API response" });
104
+ }
105
+ });
106
+ }).on("error", (err) => {
107
+ clearTimeout(timeout);
108
+ resolve({ ok: false, reason: "network_error", message: `Network error: ${err.message}` });
109
+ });
110
+ });
111
+ }
112
+
113
+ /**
114
+ * Check if directory exists and is writable
115
+ */
116
+ function checkDirectory(dirPath, createIfMissing = false) {
117
+ if (!dirPath) return { ok: false, reason: "not_configured", message: "Path not configured" };
118
+
119
+ if (!fs.existsSync(dirPath)) {
120
+ if (createIfMissing) {
121
+ try {
122
+ fs.mkdirSync(dirPath, { recursive: true });
123
+ return { ok: true, created: true };
124
+ } catch (e) {
125
+ return { ok: false, reason: "create_failed", message: `Failed to create directory: ${e.message}` };
126
+ }
127
+ }
128
+ return { ok: false, reason: "not_found", message: `Directory does not exist: ${dirPath}` };
129
+ }
130
+
131
+ // Check if writable
132
+ try {
133
+ const testFile = path.join(dirPath, `.write-test-${Date.now()}`);
134
+ fs.writeFileSync(testFile, "test");
135
+ fs.unlinkSync(testFile);
136
+ return { ok: true };
137
+ } catch (e) {
138
+ return { ok: false, reason: "not_writable", message: `Directory is not writable: ${dirPath}` };
139
+ }
140
+ }
141
+
142
+ /**
143
+ * Check if Node.js version is compatible
144
+ */
145
+ function checkNodeVersion() {
146
+ const version = process.version;
147
+ const major = parseInt(version.slice(1).split(".")[0], 10);
148
+ if (major < 18) {
149
+ return { ok: false, reason: "version_too_old", message: `Node.js ${version} is too old. Requires Node.js 18 or higher.`, version };
150
+ }
151
+ return { ok: true, version };
152
+ }
153
+
154
+ /**
155
+ * Check for conflicting bot instances
156
+ */
157
+ function checkForConflictingProcesses() {
158
+ try {
159
+ let pids;
160
+ if (process.platform === "win32") {
161
+ const wmic = execSync('wmic process where "name=\'node.exe\'" get ProcessId,CommandLine /FORMAT:CSV 2>nul', { encoding: "utf-8" });
162
+ pids = [];
163
+ for (const line of wmic.split("\n")) {
164
+ if (line.includes("bot.js") && line.includes("open-claudia")) {
165
+ const parts = line.trim().split(",");
166
+ const pid = parts[parts.length - 1];
167
+ if (pid && pid !== String(process.pid)) pids.push(pid.trim());
168
+ }
169
+ }
170
+ } else {
171
+ const out = execSync('ps -eo pid,command | grep "bot\\(-agent\\)\\?\\.js" | grep "open-claudia" | grep -v grep 2>/dev/null || true', { encoding: "utf-8" });
172
+ pids = out.trim().split("\n")
173
+ .map((l) => l.trim().split(/\s+/)[0])
174
+ .filter((pid) => pid && pid !== String(process.pid));
175
+ }
176
+
177
+ if (pids.length > 0) {
178
+ return { ok: false, reason: "conflict", message: `Another instance is already running (PID: ${pids.join(", ")})`, pids };
179
+ }
180
+ return { ok: true };
181
+ } catch (e) {
182
+ // Can't detect - assume OK
183
+ return { ok: true };
184
+ }
185
+ }
186
+
187
+ /**
188
+ * Check available disk space
189
+ */
190
+ function checkDiskSpace(dirPath) {
191
+ try {
192
+ if (process.platform === "win32") {
193
+ // Skip on Windows for now
194
+ return { ok: true, skipped: true };
195
+ }
196
+ const out = execSync(`df -k "${dirPath}" | tail -1`, { encoding: "utf-8" });
197
+ const parts = out.trim().split(/\s+/);
198
+ const availableKB = parseInt(parts[3], 10);
199
+ const availableMB = Math.floor(availableKB / 1024);
200
+
201
+ if (availableMB < 100) {
202
+ return { ok: false, reason: "low_space", message: `Low disk space: ${availableMB}MB available`, availableMB };
203
+ }
204
+ return { ok: true, availableMB };
205
+ } catch (e) {
206
+ return { ok: true, skipped: true };
207
+ }
208
+ }
209
+
210
+ /**
211
+ * Validate auth.json structure
212
+ */
213
+ function checkAuthFile() {
214
+ if (!fs.existsSync(AUTH_FILE)) {
215
+ return { ok: true, exists: false };
216
+ }
217
+ try {
218
+ const auth = JSON.parse(fs.readFileSync(AUTH_FILE, "utf-8"));
219
+ if (!Array.isArray(auth.authorized)) {
220
+ return { ok: false, reason: "invalid_structure", message: "auth.json is malformed (missing authorized array)" };
221
+ }
222
+ return { ok: true, exists: true, authorizedCount: auth.authorized.length };
223
+ } catch (e) {
224
+ return { ok: false, reason: "parse_error", message: `Failed to parse auth.json: ${e.message}` };
225
+ }
226
+ }
227
+
228
+ /**
229
+ * Run all health checks
230
+ * @param {Object} options - Options
231
+ * @param {boolean} options.quick - Skip slow checks (Claude auth, Telegram API)
232
+ * @param {boolean} options.fix - Attempt to fix issues (create directories)
233
+ * @returns {Promise<Object>} Health check results
234
+ */
235
+ async function runHealthChecks(options = {}) {
236
+ const { quick = false, fix = false } = options;
237
+ const results = {
238
+ ok: true,
239
+ checks: {},
240
+ errors: [],
241
+ warnings: [],
242
+ };
243
+
244
+ // 1. Check Node.js version
245
+ results.checks.node = checkNodeVersion();
246
+ if (!results.checks.node.ok) {
247
+ results.ok = false;
248
+ results.errors.push(results.checks.node.message);
249
+ }
250
+
251
+ // 2. Check .env file exists and has required keys
252
+ const env = loadEnv();
253
+ if (!env) {
254
+ results.checks.env = { ok: false, reason: "not_found", message: "No .env file found. Run 'open-claudia setup' first." };
255
+ results.ok = false;
256
+ results.errors.push(results.checks.env.message);
257
+ // Can't continue without env
258
+ return results;
259
+ }
260
+
261
+ const missingKeys = REQUIRED_ENV_KEYS.filter((k) => !env[k]);
262
+ if (missingKeys.length > 0) {
263
+ results.checks.env = { ok: false, reason: "missing_keys", message: `Missing required config: ${missingKeys.join(", ")}`, missingKeys };
264
+ results.ok = false;
265
+ results.errors.push(results.checks.env.message);
266
+ } else {
267
+ results.checks.env = { ok: true };
268
+ }
269
+
270
+ // 3. Check Claude CLI exists
271
+ const claudePath = env.CLAUDE_PATH;
272
+ if (!binaryExists(claudePath)) {
273
+ results.checks.claude_binary = { ok: false, reason: "not_found", message: `Claude CLI not found at: ${claudePath}` };
274
+ results.ok = false;
275
+ results.errors.push(results.checks.claude_binary.message);
276
+ } else {
277
+ results.checks.claude_binary = { ok: true, path: claudePath };
278
+
279
+ // 4. Check Claude authentication (slow)
280
+ if (!quick) {
281
+ results.checks.claude_auth = testClaudeAuth(claudePath);
282
+ if (!results.checks.claude_auth.ok) {
283
+ results.ok = false;
284
+ results.errors.push(results.checks.claude_auth.message);
285
+ }
286
+ }
287
+ }
288
+
289
+ // 5. Check Telegram token (slow)
290
+ if (!quick && env.TELEGRAM_BOT_TOKEN) {
291
+ results.checks.telegram = await testTelegramToken(env.TELEGRAM_BOT_TOKEN);
292
+ if (!results.checks.telegram.ok) {
293
+ results.ok = false;
294
+ results.errors.push(results.checks.telegram.message);
295
+ }
296
+ }
297
+
298
+ // 6. Check workspace directory
299
+ results.checks.workspace = checkDirectory(env.WORKSPACE, fix);
300
+ if (!results.checks.workspace.ok) {
301
+ results.ok = false;
302
+ results.errors.push(results.checks.workspace.message);
303
+ }
304
+
305
+ // 7. Check config directory
306
+ results.checks.config_dir = checkDirectory(CONFIG_DIR, fix);
307
+ if (!results.checks.config_dir.ok) {
308
+ results.ok = false;
309
+ results.errors.push(results.checks.config_dir.message);
310
+ }
311
+
312
+ // 8. Check for conflicting processes
313
+ results.checks.conflicts = checkForConflictingProcesses();
314
+ if (!results.checks.conflicts.ok) {
315
+ results.ok = false;
316
+ results.errors.push(results.checks.conflicts.message);
317
+ }
318
+
319
+ // 9. Check disk space
320
+ results.checks.disk = checkDiskSpace(CONFIG_DIR);
321
+ if (!results.checks.disk.ok) {
322
+ results.warnings.push(results.checks.disk.message);
323
+ }
324
+
325
+ // 10. Check auth.json
326
+ results.checks.auth_file = checkAuthFile();
327
+ if (!results.checks.auth_file.ok) {
328
+ results.warnings.push(results.checks.auth_file.message);
329
+ }
330
+
331
+ // 11. Check optional features
332
+ if (env.WHISPER_CLI && !binaryExists(env.WHISPER_CLI)) {
333
+ results.warnings.push(`Whisper CLI not found at: ${env.WHISPER_CLI} (voice notes disabled)`);
334
+ }
335
+ if (env.FFMPEG && !binaryExists(env.FFMPEG)) {
336
+ results.warnings.push(`FFmpeg not found at: ${env.FFMPEG} (voice notes disabled)`);
337
+ }
338
+
339
+ return results;
340
+ }
341
+
342
+ /**
343
+ * Format health check results for display
344
+ */
345
+ function formatResults(results, verbose = false) {
346
+ const lines = [];
347
+
348
+ if (results.ok) {
349
+ lines.push("Health check passed.");
350
+ } else {
351
+ lines.push("Health check FAILED.");
352
+ }
353
+
354
+ if (results.errors.length > 0) {
355
+ lines.push("\nErrors:");
356
+ for (const err of results.errors) {
357
+ lines.push(` - ${err}`);
358
+ }
359
+ }
360
+
361
+ if (results.warnings.length > 0) {
362
+ lines.push("\nWarnings:");
363
+ for (const warn of results.warnings) {
364
+ lines.push(` - ${warn}`);
365
+ }
366
+ }
367
+
368
+ if (verbose) {
369
+ lines.push("\nDetails:");
370
+ for (const [name, check] of Object.entries(results.checks)) {
371
+ const status = check.ok ? "OK" : "FAIL";
372
+ lines.push(` ${name}: ${status}`);
373
+ }
374
+ }
375
+
376
+ return lines.join("\n");
377
+ }
378
+
379
+ module.exports = {
380
+ runHealthChecks,
381
+ formatResults,
382
+ loadEnv,
383
+ testClaudeAuth,
384
+ testTelegramToken,
385
+ checkDirectory,
386
+ checkNodeVersion,
387
+ checkForConflictingProcesses,
388
+ binaryExists,
389
+ REQUIRED_ENV_KEYS,
390
+ OPTIONAL_ENV_KEYS,
391
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inetafrica/open-claudia",
3
- "version": "1.7.0",
3
+ "version": "1.7.2",
4
4
  "description": "Your always-on AI coding assistant — Claude Code via Telegram",
5
5
  "main": "bot.js",
6
6
  "bin": {
@@ -15,6 +15,7 @@
15
15
  "bot.js",
16
16
  "bot-agent.js",
17
17
  "vault.js",
18
+ "health.js",
18
19
  "setup.js",
19
20
  "web.js",
20
21
  "config-dir.js",