daemora 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (115) hide show
  1. package/README.md +666 -0
  2. package/SOUL.md +104 -0
  3. package/config/hooks.json +14 -0
  4. package/config/mcp.json +145 -0
  5. package/package.json +86 -0
  6. package/skills/.gitkeep +0 -0
  7. package/skills/apple-notes.md +193 -0
  8. package/skills/apple-reminders.md +189 -0
  9. package/skills/camsnap.md +162 -0
  10. package/skills/coding.md +14 -0
  11. package/skills/documents.md +13 -0
  12. package/skills/email.md +13 -0
  13. package/skills/gif-search.md +196 -0
  14. package/skills/healthcheck.md +225 -0
  15. package/skills/image-gen.md +147 -0
  16. package/skills/model-usage.md +182 -0
  17. package/skills/obsidian.md +207 -0
  18. package/skills/pdf.md +211 -0
  19. package/skills/research.md +13 -0
  20. package/skills/skill-creator.md +142 -0
  21. package/skills/spotify.md +149 -0
  22. package/skills/summarize.md +230 -0
  23. package/skills/things.md +199 -0
  24. package/skills/tmux.md +204 -0
  25. package/skills/trello.md +183 -0
  26. package/skills/video-frames.md +202 -0
  27. package/skills/weather.md +127 -0
  28. package/src/a2a/A2AClient.js +136 -0
  29. package/src/a2a/A2AServer.js +316 -0
  30. package/src/a2a/AgentCard.js +79 -0
  31. package/src/agents/SubAgentManager.js +369 -0
  32. package/src/agents/Supervisor.js +192 -0
  33. package/src/channels/BaseChannel.js +104 -0
  34. package/src/channels/DiscordChannel.js +288 -0
  35. package/src/channels/EmailChannel.js +172 -0
  36. package/src/channels/GoogleChatChannel.js +316 -0
  37. package/src/channels/HttpChannel.js +26 -0
  38. package/src/channels/LineChannel.js +168 -0
  39. package/src/channels/SignalChannel.js +186 -0
  40. package/src/channels/SlackChannel.js +329 -0
  41. package/src/channels/TeamsChannel.js +272 -0
  42. package/src/channels/TelegramChannel.js +347 -0
  43. package/src/channels/WhatsAppChannel.js +219 -0
  44. package/src/channels/index.js +198 -0
  45. package/src/cli.js +1267 -0
  46. package/src/config/agentProfiles.js +120 -0
  47. package/src/config/channels.js +32 -0
  48. package/src/config/default.js +206 -0
  49. package/src/config/models.js +123 -0
  50. package/src/config/permissions.js +167 -0
  51. package/src/core/AgentLoop.js +446 -0
  52. package/src/core/Compaction.js +143 -0
  53. package/src/core/CostTracker.js +116 -0
  54. package/src/core/EventBus.js +46 -0
  55. package/src/core/Task.js +67 -0
  56. package/src/core/TaskQueue.js +206 -0
  57. package/src/core/TaskRunner.js +226 -0
  58. package/src/daemon/DaemonManager.js +301 -0
  59. package/src/hooks/HookRunner.js +230 -0
  60. package/src/index.js +482 -0
  61. package/src/mcp/MCPAgentRunner.js +112 -0
  62. package/src/mcp/MCPClient.js +186 -0
  63. package/src/mcp/MCPManager.js +412 -0
  64. package/src/models/ModelRouter.js +180 -0
  65. package/src/safety/AuditLog.js +135 -0
  66. package/src/safety/CircuitBreaker.js +126 -0
  67. package/src/safety/FilesystemGuard.js +169 -0
  68. package/src/safety/GitRollback.js +139 -0
  69. package/src/safety/HumanApproval.js +156 -0
  70. package/src/safety/InputSanitizer.js +72 -0
  71. package/src/safety/PermissionGuard.js +83 -0
  72. package/src/safety/Sandbox.js +70 -0
  73. package/src/safety/SecretScanner.js +100 -0
  74. package/src/safety/SecretVault.js +250 -0
  75. package/src/scheduler/Heartbeat.js +115 -0
  76. package/src/scheduler/Scheduler.js +228 -0
  77. package/src/services/models/outputSchema.js +15 -0
  78. package/src/services/openai.js +25 -0
  79. package/src/services/sessions.js +65 -0
  80. package/src/setup/theme.js +110 -0
  81. package/src/setup/wizard.js +788 -0
  82. package/src/skills/SkillLoader.js +168 -0
  83. package/src/storage/TaskStore.js +69 -0
  84. package/src/systemPrompt.js +526 -0
  85. package/src/tenants/TenantContext.js +19 -0
  86. package/src/tenants/TenantManager.js +379 -0
  87. package/src/tools/ToolRegistry.js +141 -0
  88. package/src/tools/applyPatch.js +144 -0
  89. package/src/tools/browserAutomation.js +223 -0
  90. package/src/tools/createDocument.js +265 -0
  91. package/src/tools/cronTool.js +105 -0
  92. package/src/tools/editFile.js +139 -0
  93. package/src/tools/executeCommand.js +123 -0
  94. package/src/tools/glob.js +67 -0
  95. package/src/tools/grep.js +121 -0
  96. package/src/tools/imageAnalysis.js +120 -0
  97. package/src/tools/index.js +173 -0
  98. package/src/tools/listDirectory.js +47 -0
  99. package/src/tools/manageAgents.js +47 -0
  100. package/src/tools/manageMCP.js +159 -0
  101. package/src/tools/memory.js +478 -0
  102. package/src/tools/messageChannel.js +45 -0
  103. package/src/tools/projectTracker.js +259 -0
  104. package/src/tools/readFile.js +52 -0
  105. package/src/tools/screenCapture.js +112 -0
  106. package/src/tools/searchContent.js +76 -0
  107. package/src/tools/searchFiles.js +75 -0
  108. package/src/tools/sendEmail.js +118 -0
  109. package/src/tools/sendFile.js +63 -0
  110. package/src/tools/textToSpeech.js +161 -0
  111. package/src/tools/transcribeAudio.js +82 -0
  112. package/src/tools/useMCP.js +29 -0
  113. package/src/tools/webFetch.js +150 -0
  114. package/src/tools/webSearch.js +134 -0
  115. package/src/tools/writeFile.js +26 -0
package/src/cli.js ADDED
@@ -0,0 +1,1267 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Daemora CLI
5
+ *
6
+ * Usage:
7
+ * daemora start Start the agent (foreground)
8
+ * daemora setup Interactive setup wizard
9
+ * daemora daemon <action> Manage OS daemon service
10
+ * daemora vault <action> Manage encrypted secret vault
11
+ * daemora help Show help
12
+ */
13
+
14
+ import chalk from "chalk";
15
+ import { config } from "./config/default.js";
16
+ import daemonManager from "./daemon/DaemonManager.js";
17
+ import secretVault from "./safety/SecretVault.js";
18
+ import { readFileSync, writeFileSync, existsSync } from "fs";
19
+ import { join } from "path";
20
+
21
+ const P = {
22
+ brand: "#7C6AFF",
23
+ accent: "#4ECDC4",
24
+ success: "#2ECC71",
25
+ error: "#E74C3C",
26
+ muted: "#7F8C8D",
27
+ dim: "#555E68",
28
+ };
29
+
30
+ const t = {
31
+ brand: (s) => chalk.hex(P.brand)(s),
32
+ accent: (s) => chalk.hex(P.accent)(s),
33
+ success: (s) => chalk.hex(P.success)(s),
34
+ error: (s) => chalk.hex(P.error)(s),
35
+ muted: (s) => chalk.hex(P.muted)(s),
36
+ bold: (s) => chalk.bold(s),
37
+ h: (s) => chalk.bold.hex(P.brand)(s),
38
+ cmd: (s) => chalk.hex(P.accent)(s),
39
+ dim: (s) => chalk.hex(P.dim)(s),
40
+ };
41
+
42
+ const S = {
43
+ check: chalk.hex(P.success)("\u2714"),
44
+ cross: chalk.hex(P.error)("\u2718"),
45
+ arrow: chalk.hex(P.brand)("\u25B8"),
46
+ dot: chalk.hex(P.muted)("\u00B7"),
47
+ bar: chalk.hex(P.dim)("\u2502"),
48
+ info: chalk.hex(P.accent)("\u25C6"),
49
+ lock: chalk.hex("#F1C40F")("\u25A3"),
50
+ };
51
+
52
+ const [,, command, subcommand, ...rest] = process.argv;
53
+
54
+ async function main() {
55
+ switch (command) {
56
+ case "start":
57
+ // If vault exists, prompt for passphrase and inject secrets before server boot
58
+ if (secretVault.exists()) {
59
+ const { password } = await import("@clack/prompts");
60
+ console.log("");
61
+ const passphrase = await password({
62
+ message: "Vault detected. Enter passphrase to unlock",
63
+ });
64
+ if (passphrase && typeof passphrase === "string") {
65
+ try {
66
+ secretVault.unlock(passphrase);
67
+ const secrets = secretVault.getAsEnv();
68
+ for (const [key, value] of Object.entries(secrets)) {
69
+ process.env[key] = value;
70
+ }
71
+ console.log(`\n ${S.check} Vault unlocked \u2014 ${Object.keys(secrets).length} secret(s) loaded\n`);
72
+ } catch {
73
+ console.log(`\n ${S.cross} Wrong passphrase. Starting without vault secrets.\n`);
74
+ }
75
+ } else {
76
+ console.log(`\n ${S.arrow} Skipped vault. Starting without secrets.\n`);
77
+ }
78
+ }
79
+ await import("./index.js");
80
+ break;
81
+
82
+ case "daemon":
83
+ handleDaemon(subcommand);
84
+ break;
85
+
86
+ case "vault":
87
+ handleVault(subcommand, rest);
88
+ break;
89
+
90
+ case "mcp":
91
+ await handleMCP(subcommand, rest);
92
+ break;
93
+
94
+ case "sandbox":
95
+ handleSandbox(subcommand, rest);
96
+ break;
97
+
98
+ case "tenant":
99
+ await handleTenant(subcommand, rest);
100
+ break;
101
+
102
+ case "doctor":
103
+ await handleDoctor();
104
+ break;
105
+
106
+ case "setup":
107
+ const { runSetupWizard } = await import("./setup/wizard.js");
108
+ await runSetupWizard();
109
+ break;
110
+
111
+ case "help":
112
+ case "--help":
113
+ case "-h":
114
+ case undefined:
115
+ printHelp();
116
+ break;
117
+
118
+ default:
119
+ console.error(`\n ${S.cross} Unknown command: ${chalk.bold(command)}`);
120
+ printHelp();
121
+ process.exit(1);
122
+ }
123
+ }
124
+
125
+ function handleDaemon(action) {
126
+ const header = `\n ${t.h("Daemora Daemon")}\n`;
127
+
128
+ switch (action) {
129
+ case "install":
130
+ console.log(header);
131
+ console.log(` ${S.arrow} Installing daemon service...`);
132
+ daemonManager.install();
133
+ console.log(`\n ${S.check} Daemon installed. Will auto-start on boot.`);
134
+ console.log(` ${S.arrow} Run ${t.cmd("daemora daemon start")} to start now.\n`);
135
+ break;
136
+
137
+ case "uninstall":
138
+ console.log(header);
139
+ console.log(` ${S.arrow} Uninstalling daemon service...`);
140
+ daemonManager.uninstall();
141
+ console.log(`\n ${S.check} Daemon uninstalled.\n`);
142
+ break;
143
+
144
+ case "start":
145
+ console.log(header);
146
+ daemonManager.start();
147
+ console.log(` ${S.check} Daemon started.\n`);
148
+ break;
149
+
150
+ case "stop":
151
+ console.log(header);
152
+ daemonManager.stop();
153
+ console.log(` ${S.check} Daemon stopped.\n`);
154
+ break;
155
+
156
+ case "restart":
157
+ console.log(header);
158
+ daemonManager.restart();
159
+ console.log(` ${S.check} Daemon restarted.\n`);
160
+ break;
161
+
162
+ case "status": {
163
+ console.log(header);
164
+ const st = daemonManager.status();
165
+ const status = st.running
166
+ ? t.success("\u25CF Running")
167
+ : t.muted("\u25CB Stopped");
168
+ console.log(` ${S.bar} Status ${status}`);
169
+ console.log(` ${S.bar} Platform ${t.bold(st.platform)}`);
170
+ if (st.pid) console.log(` ${S.bar} PID ${t.bold(st.pid)}`);
171
+ console.log("");
172
+ break;
173
+ }
174
+
175
+ default:
176
+ console.error(`\n ${S.cross} Unknown daemon command: ${action || "(none)"}`);
177
+ console.log(` ${t.muted("Usage:")} daemora daemon ${t.dim("[install|uninstall|start|stop|restart|status]")}\n`);
178
+ process.exit(1);
179
+ }
180
+ }
181
+
182
+ function handleVault(action, args) {
183
+ const header = `\n ${t.h("Daemora Vault")}\n`;
184
+
185
+ switch (action) {
186
+ case "set": {
187
+ const [passphrase, key, value] = args;
188
+ if (!passphrase || !key || !value) {
189
+ console.error(`\n ${S.cross} Usage: daemora vault set ${t.dim("<passphrase> <key> <value>")}\n`);
190
+ process.exit(1);
191
+ }
192
+ secretVault.unlock(passphrase);
193
+ secretVault.set(key, value);
194
+ console.log(`${header} ${S.check} Secret ${t.bold(key)} stored.\n`);
195
+ secretVault.lock();
196
+ break;
197
+ }
198
+
199
+ case "get": {
200
+ const [p2, k2] = args;
201
+ if (!p2 || !k2) {
202
+ console.error(`\n ${S.cross} Usage: daemora vault get ${t.dim("<passphrase> <key>")}\n`);
203
+ process.exit(1);
204
+ }
205
+ secretVault.unlock(p2);
206
+ const val = secretVault.get(k2);
207
+ if (val) {
208
+ console.log(val);
209
+ } else {
210
+ console.log(`${header} ${S.cross} Secret ${t.bold(k2)} not found.\n`);
211
+ }
212
+ secretVault.lock();
213
+ break;
214
+ }
215
+
216
+ case "list": {
217
+ const p3 = args[0];
218
+ if (!p3) {
219
+ console.error(`\n ${S.cross} Usage: daemora vault list ${t.dim("<passphrase>")}\n`);
220
+ process.exit(1);
221
+ }
222
+ console.log(header);
223
+ secretVault.unlock(p3);
224
+ const secrets = secretVault.list();
225
+ if (secrets.length === 0) {
226
+ console.log(` ${t.muted("No secrets stored.")}\n`);
227
+ } else {
228
+ const maxLen = Math.max(...secrets.map((s) => s.key.length));
229
+ for (const s of secrets) {
230
+ const k = t.bold(s.key.padEnd(maxLen));
231
+ const v = t.dim(`${s.length} chars`);
232
+ const preview = t.muted(s.preview);
233
+ console.log(` ${S.bar} ${k} ${v} ${preview}`);
234
+ }
235
+ console.log(`\n ${t.muted(`${secrets.length} secret(s) stored.`)}\n`);
236
+ }
237
+ secretVault.lock();
238
+ break;
239
+ }
240
+
241
+ case "import": {
242
+ const p4 = args[0];
243
+ const envPath = args[1] || join(config.rootDir, ".env");
244
+ if (!p4) {
245
+ console.error(`\n ${S.cross} Usage: daemora vault import ${t.dim("<passphrase> [path-to-.env]")}\n`);
246
+ process.exit(1);
247
+ }
248
+ secretVault.unlock(p4);
249
+ const count = secretVault.importFromEnv(envPath);
250
+ console.log(`${header} ${S.check} Imported ${t.bold(count)} secrets from ${t.dim(envPath)}\n`);
251
+ secretVault.lock();
252
+ break;
253
+ }
254
+
255
+ case "status": {
256
+ console.log(header);
257
+ const exists = secretVault.exists();
258
+ const unlocked = secretVault.isUnlocked();
259
+ console.log(` ${S.bar} Vault exists ${exists ? t.success("Yes") : t.muted("No")}`);
260
+ console.log(` ${S.bar} Unlocked ${unlocked ? t.success("Yes") : t.muted("No")}`);
261
+ console.log("");
262
+ break;
263
+ }
264
+
265
+ default:
266
+ console.error(`\n ${S.cross} Unknown vault command: ${action || "(none)"}`);
267
+ console.log(` ${t.muted("Usage:")} daemora vault ${t.dim("[set|get|list|import|status]")}\n`);
268
+ process.exit(1);
269
+ }
270
+ }
271
+
272
+ // ── MCP config helpers ────────────────────────────────────────────────────────
273
+
274
+ const MCP_CONFIG_PATH = join(config.rootDir, "config", "mcp.json");
275
+
276
+ function readMCPConfig() {
277
+ if (!existsSync(MCP_CONFIG_PATH)) return { mcpServers: {} };
278
+ try { return JSON.parse(readFileSync(MCP_CONFIG_PATH, "utf-8")); } catch { return { mcpServers: {} }; }
279
+ }
280
+
281
+ function writeMCPConfig(cfg) {
282
+ writeFileSync(MCP_CONFIG_PATH, JSON.stringify(cfg, null, 2));
283
+ }
284
+
285
+ async function handleMCP(action, args) {
286
+ const header = `\n ${t.h("Daemora MCP Servers")}\n`;
287
+
288
+ switch (action) {
289
+
290
+ case "list":
291
+ case undefined: {
292
+ console.log(header);
293
+ const cfg = readMCPConfig();
294
+ const servers = Object.entries(cfg.mcpServers || {}).filter(([k]) => !k.startsWith("_comment"));
295
+ if (servers.length === 0) {
296
+ console.log(` ${t.muted("No MCP servers configured.")}`);
297
+ console.log(` ${S.arrow} Run ${t.cmd("daemora mcp add <name> <command-or-url> [args...]")} to add one.\n`);
298
+ return;
299
+ }
300
+ for (const [name, srv] of servers) {
301
+ const enabled = srv.enabled !== false;
302
+ const icon = enabled ? S.check : t.muted("○");
303
+ const type = srv.command ? t.accent("stdio") : srv.transport === "sse" ? t.accent("sse") : t.accent("http");
304
+ const target = srv.command
305
+ ? `${t.dim(srv.command)} ${t.dim((srv.args || []).join(" "))}`
306
+ : t.dim(srv.url);
307
+ const envKeys = Object.keys(srv.env || {});
308
+ const envStr = envKeys.length > 0 ? t.muted(` [env: ${envKeys.join(", ")}]`) : "";
309
+ const disabledStr = !enabled ? t.muted(" (disabled)") : "";
310
+ console.log(` ${icon} ${t.bold(name)} ${type} ${target}${envStr}${disabledStr}`);
311
+ }
312
+ console.log(`\n ${t.muted(`${servers.length} server(s) configured.`)}\n`);
313
+ break;
314
+ }
315
+
316
+ case "add": {
317
+ const [name, commandOrUrl, ...restArgs] = args;
318
+
319
+ // ── Interactive mode (no args or name-only) ────────────────────────────
320
+ if (!name || !commandOrUrl) {
321
+ const { default: pi } = await import("@clack/prompts");
322
+
323
+ const pGuard = (val) => { if (pi.isCancel(val)) { pi.cancel("Cancelled."); process.exit(0); } return val; };
324
+
325
+ const cfg = readMCPConfig();
326
+ cfg.mcpServers = cfg.mcpServers || {};
327
+
328
+ pi.intro(t.h("Add MCP Server"));
329
+
330
+ const serverName = name || pGuard(await pi.text({
331
+ message: "Server name (no spaces)",
332
+ validate: (v) => {
333
+ if (!v) return "Required";
334
+ if (/\s/.test(v)) return "No spaces allowed";
335
+ if (cfg.mcpServers[v] && !String(v).startsWith("_comment")) return `"${v}" already exists`;
336
+ },
337
+ }));
338
+
339
+ const description = pGuard(await pi.text({
340
+ message: "Description (what does this server do? helps the agent know when to use it)",
341
+ placeholder: "e.g. Manage GitHub repos, PRs, and issues",
342
+ initialValue: "",
343
+ }));
344
+
345
+ const transport = pGuard(await pi.select({
346
+ message: "Transport type",
347
+ options: [
348
+ { value: "stdio", label: "stdio", hint: "Local subprocess — npx, node, python, binary" },
349
+ { value: "http", label: "http", hint: "Remote HTTP (streamable MCP)" },
350
+ { value: "sse", label: "sse", hint: "Remote SSE (Server-Sent Events)" },
351
+ ],
352
+ }));
353
+
354
+ let serverConfig = { enabled: true };
355
+
356
+ if (transport === "stdio") {
357
+ const cmd = pGuard(await pi.text({
358
+ message: "Command (e.g. npx, node, python3)",
359
+ validate: (v) => !v ? "Required" : undefined,
360
+ }));
361
+ const argsRaw = pGuard(await pi.text({
362
+ message: "Arguments (space-separated, or leave blank)",
363
+ initialValue: "",
364
+ }));
365
+ serverConfig.command = cmd;
366
+ serverConfig.args = argsRaw.trim() ? argsRaw.trim().split(/\s+/) : [];
367
+
368
+ // stdio: credentials go as env vars passed to the subprocess
369
+ const needsEnv = pGuard(await pi.confirm({
370
+ message: "Does this server need environment variables (API keys, tokens)?",
371
+ initialValue: false,
372
+ }));
373
+ if (needsEnv) {
374
+ serverConfig.env = {};
375
+ p.log.info(` Tip: use \${MY_VAR} to reference existing env vars instead of pasting secrets`);
376
+ let more = true;
377
+ while (more) {
378
+ const key = pGuard(await pi.text({
379
+ message: "Env var name (e.g. GITHUB_TOKEN)",
380
+ validate: (v) => !v ? "Required" : undefined,
381
+ }));
382
+ const val = pGuard(await pi.password({
383
+ message: `Value for ${key} (or type \${VAR_NAME} to reference an existing env var)`,
384
+ validate: (v) => !v ? "Required" : undefined,
385
+ }));
386
+ serverConfig.env[key] = val;
387
+ more = pGuard(await pi.confirm({ message: "Add another env var?", initialValue: false }));
388
+ }
389
+ }
390
+
391
+ } else {
392
+ // http / sse
393
+ const url = pGuard(await pi.text({
394
+ message: transport === "sse"
395
+ ? "SSE endpoint URL (e.g. https://api.example.com/sse)"
396
+ : "HTTP endpoint URL (e.g. https://api.example.com/mcp)",
397
+ validate: (v) => {
398
+ if (!v) return "Required";
399
+ if (!v.startsWith("http://") && !v.startsWith("https://")) return "Must start with http(s)://";
400
+ },
401
+ }));
402
+ serverConfig.url = url;
403
+ if (transport === "sse") serverConfig.transport = "sse";
404
+
405
+ // http/sse: credentials go as HTTP request headers (Authorization, X-API-Key, etc.)
406
+ const authType = pGuard(await pi.select({
407
+ message: "Authentication / headers",
408
+ options: [
409
+ { value: "none", label: "None", hint: "No auth needed" },
410
+ { value: "bearer", label: "Bearer token", hint: "Authorization: Bearer <token>" },
411
+ { value: "apikey", label: "API key header", hint: "X-API-Key: <key> or custom header name" },
412
+ { value: "custom", label: "Custom headers", hint: "Any headers — you name them" },
413
+ ],
414
+ }));
415
+
416
+ if (authType !== "none") {
417
+ serverConfig.headers = {};
418
+ pi.log.info(` Tip: use \${MY_SECRET} to reference env vars instead of pasting values`);
419
+
420
+ if (authType === "bearer") {
421
+ const token = pGuard(await pi.password({
422
+ message: "Bearer token (or \${MY_ENV_VAR} to reference an env var)",
423
+ validate: (v) => !v ? "Required" : undefined,
424
+ }));
425
+ serverConfig.headers["Authorization"] = `Bearer ${token}`;
426
+
427
+ } else if (authType === "apikey") {
428
+ const headerName = pGuard(await pi.text({
429
+ message: "Header name",
430
+ initialValue: "X-API-Key",
431
+ validate: (v) => !v ? "Required" : undefined,
432
+ }));
433
+ const apiKey = pGuard(await pi.password({
434
+ message: `Value for ${headerName} (or \${MY_ENV_VAR})`,
435
+ validate: (v) => !v ? "Required" : undefined,
436
+ }));
437
+ serverConfig.headers[headerName] = apiKey;
438
+
439
+ } else {
440
+ // custom — loop
441
+ let more = true;
442
+ while (more) {
443
+ const headerName = pGuard(await pi.text({
444
+ message: "Header name (e.g. Authorization, X-Tenant-ID)",
445
+ validate: (v) => !v ? "Required" : undefined,
446
+ }));
447
+ const headerVal = pGuard(await pi.password({
448
+ message: `Value for ${headerName} (or \${MY_ENV_VAR})`,
449
+ validate: (v) => !v ? "Required" : undefined,
450
+ }));
451
+ serverConfig.headers[headerName] = headerVal;
452
+ more = pGuard(await pi.confirm({ message: "Add another header?", initialValue: false }));
453
+ }
454
+ }
455
+ }
456
+ }
457
+
458
+ if (description?.trim()) serverConfig.description = description.trim();
459
+ cfg.mcpServers[serverName] = serverConfig;
460
+ writeMCPConfig(cfg);
461
+
462
+ const typeLabel = transport === "stdio"
463
+ ? `${serverConfig.command} ${(serverConfig.args || []).join(" ")}`.trim()
464
+ : serverConfig.url;
465
+ const credCount = Object.keys(serverConfig.env || serverConfig.headers || {}).length;
466
+ const credLabel = transport === "stdio" ? "env var(s)" : "header(s)";
467
+
468
+ pi.outro(
469
+ `${S.check} Server ${t.bold(serverName)} saved.` +
470
+ (credCount ? ` ${credCount} ${credLabel}.` : "") +
471
+ `\n ${S.arrow} Restart the agent or run: daemora mcp reload ${serverName}`
472
+ );
473
+ break;
474
+ }
475
+
476
+ // ── Non-interactive (args provided) ────────────────────────────────────
477
+ const cfg = readMCPConfig();
478
+ cfg.mcpServers = cfg.mcpServers || {};
479
+
480
+ let serverConfig;
481
+ if (commandOrUrl.startsWith("http://") || commandOrUrl.startsWith("https://")) {
482
+ const isSSE = restArgs.includes("--sse");
483
+ serverConfig = { url: commandOrUrl, enabled: true };
484
+ if (isSSE) serverConfig.transport = "sse";
485
+ } else {
486
+ const filteredArgs = restArgs.filter(a => !a.startsWith("--"));
487
+ serverConfig = { command: commandOrUrl, args: filteredArgs, enabled: true };
488
+ }
489
+
490
+ cfg.mcpServers[name] = serverConfig;
491
+ writeMCPConfig(cfg);
492
+
493
+ const typeLabel = serverConfig.command ? "stdio" : (serverConfig.transport || "http");
494
+ console.log(`\n ${S.check} Server ${t.bold(name)} added (${typeLabel}).`);
495
+ console.log(` ${S.arrow} Run ${t.cmd(`daemora mcp env ${name} KEY value`)} to add environment variables.`);
496
+ console.log(` ${S.arrow} Restart the agent or run: ${t.cmd(`daemora mcp reload ${name}`)}\n`);
497
+ break;
498
+ }
499
+
500
+ case "remove": {
501
+ const [name] = args;
502
+ if (!name) {
503
+ console.error(`\n ${S.cross} Usage: daemora mcp remove ${t.dim("<name>")}\n`);
504
+ process.exit(1);
505
+ }
506
+ const cfg = readMCPConfig();
507
+ if (!cfg.mcpServers?.[name]) {
508
+ console.error(`\n ${S.cross} Server "${name}" not found in config.\n`);
509
+ process.exit(1);
510
+ }
511
+ delete cfg.mcpServers[name];
512
+ writeMCPConfig(cfg);
513
+ console.log(`\n ${S.check} Server ${t.bold(name)} removed from config.\n`);
514
+ break;
515
+ }
516
+
517
+ case "enable":
518
+ case "disable": {
519
+ const enabled = action === "enable";
520
+ const [name] = args;
521
+ if (!name) {
522
+ console.error(`\n ${S.cross} Usage: daemora mcp ${action} ${t.dim("<name>")}\n`);
523
+ process.exit(1);
524
+ }
525
+ const cfg = readMCPConfig();
526
+ if (!cfg.mcpServers?.[name]) {
527
+ console.error(`\n ${S.cross} Server "${name}" not found in config.\n`);
528
+ process.exit(1);
529
+ }
530
+ cfg.mcpServers[name].enabled = enabled;
531
+ writeMCPConfig(cfg);
532
+ const icon = enabled ? S.check : t.muted("○");
533
+ console.log(`\n ${icon} Server ${t.bold(name)} ${enabled ? "enabled" : "disabled"}.\n`);
534
+ break;
535
+ }
536
+
537
+ case "env": {
538
+ // daemora mcp env <name> <KEY> <value>
539
+ const [name, key, value] = args;
540
+ if (!name || !key || !value) {
541
+ console.error(`\n ${S.cross} Usage: daemora mcp env ${t.dim("<name> <KEY> <value>")}\n`);
542
+ process.exit(1);
543
+ }
544
+ const cfg = readMCPConfig();
545
+ if (!cfg.mcpServers?.[name]) {
546
+ console.error(`\n ${S.cross} Server "${name}" not found. Use ${t.cmd("daemora mcp add")} first.\n`);
547
+ process.exit(1);
548
+ }
549
+ cfg.mcpServers[name].env = cfg.mcpServers[name].env || {};
550
+ cfg.mcpServers[name].env[key] = value;
551
+ writeMCPConfig(cfg);
552
+ console.log(`\n ${S.check} Env var ${t.bold(key)} set for server ${t.bold(name)}.\n`);
553
+ break;
554
+ }
555
+
556
+ case "reload": {
557
+ // daemora mcp reload <name> — tells the live agent to reconnect, or just validates config
558
+ const [name] = args;
559
+ if (!name) {
560
+ console.error(`\n ${S.cross} Usage: daemora mcp reload ${t.dim("<name>")}\n`);
561
+ process.exit(1);
562
+ }
563
+ const cfg2 = readMCPConfig();
564
+ if (!cfg2.mcpServers?.[name]) {
565
+ console.error(`\n ${S.cross} Server "${name}" not found in config.\n`);
566
+ process.exit(1);
567
+ }
568
+ // Try to hit the live API (best-effort, non-fatal)
569
+ try {
570
+ const port = process.env.PORT || "8081";
571
+ const { default: https } = await import("https");
572
+ const { default: http } = await import("http");
573
+ const url = `http://localhost:${port}/mcp/${name}/reload`;
574
+ const mod = url.startsWith("https") ? https : http;
575
+ await new Promise((resolve) => {
576
+ const req = mod.request(url, { method: "POST" }, (res) => {
577
+ console.log(`\n ${S.check} Agent reloaded server "${t.bold(name)}" (HTTP ${res.statusCode}).\n`);
578
+ resolve();
579
+ });
580
+ req.on("error", () => {
581
+ console.log(`\n ${S.arrow} Agent not running. Server "${t.bold(name)}" will connect on next start.\n`);
582
+ resolve();
583
+ });
584
+ req.end();
585
+ });
586
+ } catch {
587
+ console.log(`\n ${S.arrow} Config saved. Restart the agent for changes to take effect.\n`);
588
+ }
589
+ break;
590
+ }
591
+
592
+ default:
593
+ console.error(`\n ${S.cross} Unknown mcp command: ${action || "(none)"}`);
594
+ console.log(` ${t.muted("Usage:")} daemora mcp ${t.dim("[list|add|remove|enable|disable|reload|env]")}\n`);
595
+ process.exit(1);
596
+ }
597
+ }
598
+
599
+ // ── Sandbox (filesystem scoping) helpers ──────────────────────────────────────
600
+
601
+ function readEnvFile() {
602
+ const envPath = join(config.rootDir, ".env");
603
+ if (!existsSync(envPath)) return {};
604
+ const lines = readFileSync(envPath, "utf-8").split("\n");
605
+ const result = {};
606
+ for (const line of lines) {
607
+ const trimmed = line.trim();
608
+ if (!trimmed || trimmed.startsWith("#")) continue;
609
+ const eqIdx = trimmed.indexOf("=");
610
+ if (eqIdx === -1) continue;
611
+ result[trimmed.slice(0, eqIdx)] = trimmed.slice(eqIdx + 1);
612
+ }
613
+ return result;
614
+ }
615
+
616
+ function writeEnvKey(key, value) {
617
+ const envPath = join(config.rootDir, ".env");
618
+ let content = existsSync(envPath) ? readFileSync(envPath, "utf-8") : "";
619
+
620
+ // Replace existing key or append
621
+ const regex = new RegExp(`^${key}=.*$`, "m");
622
+ if (regex.test(content)) {
623
+ content = content.replace(regex, `${key}=${value}`);
624
+ } else {
625
+ content = content.trimEnd() + `\n${key}=${value}\n`;
626
+ }
627
+ writeFileSync(envPath, content, "utf-8");
628
+ }
629
+
630
+ function deleteEnvKey(key) {
631
+ const envPath = join(config.rootDir, ".env");
632
+ if (!existsSync(envPath)) return;
633
+ const content = readFileSync(envPath, "utf-8");
634
+ const updated = content.replace(new RegExp(`^${key}=.*\n?`, "m"), "");
635
+ writeFileSync(envPath, updated, "utf-8");
636
+ }
637
+
638
+ function handleSandbox(action, args) {
639
+ const header = `\n ${t.h("Daemora Sandbox")} ${t.muted("Filesystem scoping")}\n`;
640
+ const env = readEnvFile();
641
+ const allowedPaths = env.ALLOWED_PATHS ? env.ALLOWED_PATHS.split(",").map(s => s.trim()).filter(Boolean) : [];
642
+ const blockedPaths = env.BLOCKED_PATHS ? env.BLOCKED_PATHS.split(",").map(s => s.trim()).filter(Boolean) : [];
643
+ const restrictCmds = env.RESTRICT_COMMANDS === "true";
644
+
645
+ switch (action) {
646
+ case "show":
647
+ case undefined: {
648
+ console.log(header);
649
+ if (allowedPaths.length === 0) {
650
+ console.log(` ${S.info} Mode ${t.success("Global")} ${t.muted("(no directory restrictions)")}`);
651
+ } else {
652
+ console.log(` ${S.info} Mode ${t.accent("Scoped")}`);
653
+ console.log(` ${S.bar} Allowed:`);
654
+ for (const p of allowedPaths) console.log(` ${S.bar} ${t.bold(S.check)} ${p}`);
655
+ }
656
+ if (blockedPaths.length > 0) {
657
+ console.log(` ${S.bar} Blocked:`);
658
+ for (const p of blockedPaths) console.log(` ${S.bar} ${t.error(S.cross)} ${p}`);
659
+ }
660
+ console.log(` ${S.bar} Restrict commands ${restrictCmds ? t.accent("true") : t.muted("false")}`);
661
+ console.log(`\n ${t.muted("Manage:")} daemora sandbox ${t.dim("[add|block|remove|unblock|restrict|unrestrict|clear]")}\n`);
662
+ break;
663
+ }
664
+
665
+ case "add": {
666
+ const [newPath] = args;
667
+ if (!newPath) {
668
+ console.error(`\n ${S.cross} Usage: daemora sandbox add ${t.dim("<absolute-path>")}\n`);
669
+ process.exit(1);
670
+ }
671
+ if (!newPath.startsWith("/") && !newPath.match(/^[A-Za-z]:\\/)) {
672
+ console.error(`\n ${S.cross} Path must be absolute (start with / or C:\\)\n`);
673
+ process.exit(1);
674
+ }
675
+ const updated = [...new Set([...allowedPaths, newPath])];
676
+ writeEnvKey("ALLOWED_PATHS", updated.join(","));
677
+ console.log(`\n${header} ${S.check} ${t.bold(newPath)} added to allowed paths.`);
678
+ console.log(` ${S.arrow} Scoped mode active — agent can only access: ${t.bold(updated.join(", "))}\n`);
679
+ break;
680
+ }
681
+
682
+ case "remove": {
683
+ const [rmPath] = args;
684
+ if (!rmPath) {
685
+ console.error(`\n ${S.cross} Usage: daemora sandbox remove ${t.dim("<path>")}\n`);
686
+ process.exit(1);
687
+ }
688
+ const updated = allowedPaths.filter(p => p !== rmPath);
689
+ if (updated.length === allowedPaths.length) {
690
+ console.log(`\n ${S.cross} "${rmPath}" not found in allowed paths.\n`);
691
+ process.exit(1);
692
+ }
693
+ if (updated.length === 0) {
694
+ deleteEnvKey("ALLOWED_PATHS");
695
+ console.log(`\n${header} ${S.check} ${t.bold(rmPath)} removed. No allowed paths left — switching to global mode.\n`);
696
+ } else {
697
+ writeEnvKey("ALLOWED_PATHS", updated.join(","));
698
+ console.log(`\n${header} ${S.check} ${t.bold(rmPath)} removed. Remaining: ${t.bold(updated.join(", "))}\n`);
699
+ }
700
+ break;
701
+ }
702
+
703
+ case "block": {
704
+ const [blockPath] = args;
705
+ if (!blockPath) {
706
+ console.error(`\n ${S.cross} Usage: daemora sandbox block ${t.dim("<absolute-path>")}\n`);
707
+ process.exit(1);
708
+ }
709
+ const updated = [...new Set([...blockedPaths, blockPath])];
710
+ writeEnvKey("BLOCKED_PATHS", updated.join(","));
711
+ console.log(`\n${header} ${S.check} ${t.bold(blockPath)} added to blocked paths.\n`);
712
+ break;
713
+ }
714
+
715
+ case "unblock": {
716
+ const [unblockPath] = args;
717
+ if (!unblockPath) {
718
+ console.error(`\n ${S.cross} Usage: daemora sandbox unblock ${t.dim("<path>")}\n`);
719
+ process.exit(1);
720
+ }
721
+ const updated = blockedPaths.filter(p => p !== unblockPath);
722
+ if (updated.length === blockedPaths.length) {
723
+ console.log(`\n ${S.cross} "${unblockPath}" not found in blocked paths.\n`);
724
+ process.exit(1);
725
+ }
726
+ if (updated.length === 0) {
727
+ deleteEnvKey("BLOCKED_PATHS");
728
+ } else {
729
+ writeEnvKey("BLOCKED_PATHS", updated.join(","));
730
+ }
731
+ console.log(`\n${header} ${S.check} ${t.bold(unblockPath)} unblocked.\n`);
732
+ break;
733
+ }
734
+
735
+ case "restrict": {
736
+ writeEnvKey("RESTRICT_COMMANDS", "true");
737
+ console.log(`\n${header} ${S.check} RESTRICT_COMMANDS=true`);
738
+ console.log(` ${t.muted("Shell commands will now enforce ALLOWED_PATHS (cwd + path scanning).")}\n`);
739
+ break;
740
+ }
741
+
742
+ case "unrestrict": {
743
+ writeEnvKey("RESTRICT_COMMANDS", "false");
744
+ console.log(`\n${header} ${S.check} RESTRICT_COMMANDS=false`);
745
+ console.log(` ${t.muted("Shell commands are no longer path-restricted (file tools still are).")}\n`);
746
+ break;
747
+ }
748
+
749
+ case "clear": {
750
+ deleteEnvKey("ALLOWED_PATHS");
751
+ deleteEnvKey("BLOCKED_PATHS");
752
+ deleteEnvKey("RESTRICT_COMMANDS");
753
+ console.log(`\n${header} ${S.check} Filesystem scoping cleared — global mode restored.`);
754
+ console.log(` ${t.muted("Agent can now access any file the OS allows (hardcoded security patterns still active).")}\n`);
755
+ break;
756
+ }
757
+
758
+ default:
759
+ console.error(`\n ${S.cross} Unknown sandbox command: ${action}`);
760
+ console.log(` ${t.muted("Usage:")} daemora sandbox ${t.dim("[show|add|remove|block|unblock|restrict|unrestrict|clear]")}\n`);
761
+ process.exit(1);
762
+ }
763
+ }
764
+
765
+ // ── Tenant management helpers ─────────────────────────────────────────────────
766
+
767
+ async function handleTenant(action, args) {
768
+ const header = `\n ${t.h("Daemora Tenants")} ${t.muted("Per-user configuration & isolation")}\n`;
769
+ const port = process.env.PORT || "8081";
770
+ const base = `http://localhost:${port}`;
771
+
772
+ async function apiCall(method, path, body) {
773
+ const { default: http } = await import("http");
774
+ return new Promise((resolve, reject) => {
775
+ const opts = {
776
+ hostname: "localhost",
777
+ port: parseInt(port),
778
+ path,
779
+ method,
780
+ headers: { "Content-Type": "application/json" },
781
+ };
782
+ const req = http.request(opts, (res) => {
783
+ let data = "";
784
+ res.on("data", (c) => (data += c));
785
+ res.on("end", () => {
786
+ try { resolve({ status: res.statusCode, body: JSON.parse(data) }); }
787
+ catch { resolve({ status: res.statusCode, body: data }); }
788
+ });
789
+ });
790
+ req.on("error", reject);
791
+ if (body) req.write(JSON.stringify(body));
792
+ req.end();
793
+ });
794
+ }
795
+
796
+ switch (action) {
797
+ case "list":
798
+ case undefined: {
799
+ console.log(header);
800
+ try {
801
+ const res = await apiCall("GET", "/tenants");
802
+ const { tenants, stats } = res.body;
803
+ if (!tenants || tenants.length === 0) {
804
+ console.log(` ${t.muted("No tenants registered yet.")}`);
805
+ console.log(` ${S.arrow} Enable ${t.cmd("MULTI_TENANT_ENABLED=true")} and start the agent to auto-register users.\n`);
806
+ return;
807
+ }
808
+ const planColor = { free: t.muted, pro: t.accent, admin: t.brand };
809
+ for (const tenant of tenants) {
810
+ const statusIcon = tenant.suspended ? t.error(S.cross) : t.success(S.check);
811
+ const plan = (planColor[tenant.plan] || t.muted)(tenant.plan || "free");
812
+ const cost = `$${(tenant.totalCost || 0).toFixed(4)}`;
813
+ const tasks = tenant.taskCount || 0;
814
+ const model = tenant.model ? t.dim(tenant.model) : t.muted("default");
815
+ console.log(` ${statusIcon} ${t.bold(tenant.id)} ${plan} ${t.muted(cost)} ${t.muted(`${tasks} tasks`)} ${model}`);
816
+ }
817
+ console.log(`\n ${t.muted(`${stats.total} tenant(s) | ${stats.suspended} suspended | total spend: $${stats.totalCost} | total tasks: ${stats.totalTasks}`)}\n`);
818
+ } catch {
819
+ console.error(`\n ${S.cross} Agent not running. Start it with ${t.cmd("daemora start")} first.\n`);
820
+ }
821
+ break;
822
+ }
823
+
824
+ case "show": {
825
+ const [id] = args;
826
+ if (!id) {
827
+ console.error(`\n ${S.cross} Usage: daemora tenant show ${t.dim("<tenantId>")}\n`);
828
+ process.exit(1);
829
+ }
830
+ try {
831
+ const res = await apiCall("GET", `/tenants/${encodeURIComponent(id)}`);
832
+ if (res.status === 404) {
833
+ console.error(`\n ${S.cross} Tenant "${id}" not found.\n`);
834
+ process.exit(1);
835
+ }
836
+ const t2 = res.body;
837
+ console.log(header);
838
+ console.log(` ${S.bar} ID ${t.bold(t2.id)}`);
839
+ console.log(` ${S.bar} Plan ${t.accent(t2.plan || "free")}`);
840
+ console.log(` ${S.bar} Suspended ${t2.suspended ? t.error("Yes") + (t2.suspendReason ? ` (${t2.suspendReason})` : "") : t.success("No")}`);
841
+ console.log(` ${S.bar} Model ${t2.model ? t.accent(t2.model) : t.muted("(default)")}`);
842
+ console.log(` ${S.bar} Total cost ${t.bold("$" + (t2.totalCost || 0).toFixed(4))}`);
843
+ console.log(` ${S.bar} Task count ${t.bold(t2.taskCount || 0)}`);
844
+ console.log(` ${S.bar} Max cost/task ${t2.maxCostPerTask != null ? t.bold("$" + t2.maxCostPerTask) : t.muted("(global default)")}`);
845
+ console.log(` ${S.bar} Max daily cost ${t2.maxDailyCost != null ? t.bold("$" + t2.maxDailyCost) : t.muted("(global default)")}`);
846
+ if (t2.allowedPaths?.length) console.log(` ${S.bar} Allowed paths ${t.dim(t2.allowedPaths.join(", "))}`);
847
+ if (t2.blockedPaths?.length) console.log(` ${S.bar} Blocked paths ${t.dim(t2.blockedPaths.join(", "))}`);
848
+ if (t2.tools?.length) console.log(` ${S.bar} Tools ${t.dim(t2.tools.join(", "))}`);
849
+ if (t2.notes) console.log(` ${S.bar} Notes ${t.muted(t2.notes)}`);
850
+ console.log(` ${S.bar} Created ${t.dim(t2.createdAt)}`);
851
+ console.log(` ${S.bar} Last seen ${t.dim(t2.lastSeenAt)}`);
852
+ console.log("");
853
+ } catch {
854
+ console.error(`\n ${S.cross} Agent not running.\n`);
855
+ }
856
+ break;
857
+ }
858
+
859
+ case "set": {
860
+ // daemora tenant set <tenantId> <key> <value>
861
+ const [id, key, ...valueParts] = args;
862
+ const value = valueParts.join(" ");
863
+ if (!id || !key || !value) {
864
+ console.error(`\n ${S.cross} Usage: daemora tenant set ${t.dim("<tenantId> <key> <value>")}`);
865
+ console.error(` ${t.muted("Keys: model, plan, maxCostPerTask, maxDailyCost, notes")}\n`);
866
+ process.exit(1);
867
+ }
868
+ const body = {};
869
+ if (key === "maxCostPerTask" || key === "maxDailyCost") {
870
+ body[key] = parseFloat(value);
871
+ } else {
872
+ body[key] = value;
873
+ }
874
+ try {
875
+ const res = await apiCall("PATCH", `/tenants/${encodeURIComponent(id)}`, body);
876
+ if (res.status === 404) { console.error(`\n ${S.cross} Tenant "${id}" not found.\n`); process.exit(1); }
877
+ console.log(`\n${header} ${S.check} Tenant ${t.bold(id)}: ${t.accent(key)} = ${t.bold(value)}\n`);
878
+ } catch { console.error(`\n ${S.cross} Agent not running.\n`); }
879
+ break;
880
+ }
881
+
882
+ case "plan": {
883
+ // daemora tenant plan <tenantId> <free|pro|admin>
884
+ const [id, plan] = args;
885
+ if (!id || !plan) {
886
+ console.error(`\n ${S.cross} Usage: daemora tenant plan ${t.dim("<tenantId> <free|pro|admin>")}\n`);
887
+ process.exit(1);
888
+ }
889
+ try {
890
+ const res = await apiCall("PATCH", `/tenants/${encodeURIComponent(id)}`, { plan });
891
+ if (res.status === 404) { console.error(`\n ${S.cross} Tenant "${id}" not found.\n`); process.exit(1); }
892
+ console.log(`\n${header} ${S.check} Tenant ${t.bold(id)} plan set to ${t.accent(plan)}\n`);
893
+ } catch { console.error(`\n ${S.cross} Agent not running.\n`); }
894
+ break;
895
+ }
896
+
897
+ case "suspend": {
898
+ const [id, ...reasonParts] = args;
899
+ if (!id) {
900
+ console.error(`\n ${S.cross} Usage: daemora tenant suspend ${t.dim("<tenantId> [reason]")}\n`);
901
+ process.exit(1);
902
+ }
903
+ const reason = reasonParts.join(" ");
904
+ try {
905
+ const res = await apiCall("POST", `/tenants/${encodeURIComponent(id)}/suspend`, { reason });
906
+ if (res.status === 404) { console.error(`\n ${S.cross} Tenant "${id}" not found.\n`); process.exit(1); }
907
+ console.log(`\n${header} ${S.check} Tenant ${t.bold(id)} suspended.${reason ? ` Reason: ${t.muted(reason)}` : ""}\n`);
908
+ } catch { console.error(`\n ${S.cross} Agent not running.\n`); }
909
+ break;
910
+ }
911
+
912
+ case "unsuspend": {
913
+ const [id] = args;
914
+ if (!id) {
915
+ console.error(`\n ${S.cross} Usage: daemora tenant unsuspend ${t.dim("<tenantId>")}\n`);
916
+ process.exit(1);
917
+ }
918
+ try {
919
+ const res = await apiCall("POST", `/tenants/${encodeURIComponent(id)}/unsuspend`);
920
+ if (res.status === 404) { console.error(`\n ${S.cross} Tenant "${id}" not found.\n`); process.exit(1); }
921
+ console.log(`\n${header} ${S.check} Tenant ${t.bold(id)} unsuspended.\n`);
922
+ } catch { console.error(`\n ${S.cross} Agent not running.\n`); }
923
+ break;
924
+ }
925
+
926
+ case "reset": {
927
+ const [id] = args;
928
+ if (!id) {
929
+ console.error(`\n ${S.cross} Usage: daemora tenant reset ${t.dim("<tenantId>")}\n`);
930
+ process.exit(1);
931
+ }
932
+ try {
933
+ const res = await apiCall("POST", `/tenants/${encodeURIComponent(id)}/reset`);
934
+ if (res.status === 404) { console.error(`\n ${S.cross} Tenant "${id}" not found.\n`); process.exit(1); }
935
+ console.log(`\n${header} ${S.check} Tenant ${t.bold(id)} config reset (cost history preserved).\n`);
936
+ } catch { console.error(`\n ${S.cross} Agent not running.\n`); }
937
+ break;
938
+ }
939
+
940
+ case "delete": {
941
+ const [id] = args;
942
+ if (!id) {
943
+ console.error(`\n ${S.cross} Usage: daemora tenant delete ${t.dim("<tenantId>")}\n`);
944
+ process.exit(1);
945
+ }
946
+ try {
947
+ const res = await apiCall("DELETE", `/tenants/${encodeURIComponent(id)}`);
948
+ if (res.status === 404) { console.error(`\n ${S.cross} Tenant "${id}" not found.\n`); process.exit(1); }
949
+ console.log(`\n${header} ${S.check} Tenant ${t.bold(id)} deleted.\n`);
950
+ } catch { console.error(`\n ${S.cross} Agent not running.\n`); }
951
+ break;
952
+ }
953
+
954
+ case "apikey": {
955
+ // daemora tenant apikey set <tenantId> <KEY_NAME> <value>
956
+ // daemora tenant apikey delete <tenantId> <KEY_NAME>
957
+ // daemora tenant apikey list <tenantId>
958
+ const [apikeyAction, ...apikeyArgs] = args;
959
+ if (!apikeyAction) {
960
+ console.error(`\n ${S.cross} Usage: daemora tenant apikey ${t.dim("[set|delete|list]")} ${t.dim("<tenantId> ...")}\n`);
961
+ process.exit(1);
962
+ }
963
+ const { default: tm } = await import("./tenants/TenantManager.js");
964
+
965
+ switch (apikeyAction) {
966
+ case "set": {
967
+ const [tenantId, keyName, keyValue] = apikeyArgs;
968
+ if (!tenantId || !keyName || !keyValue) {
969
+ console.error(`\n ${S.cross} Usage: daemora tenant apikey set ${t.dim("<tenantId> <KEY_NAME> <value>")}\n`);
970
+ process.exit(1);
971
+ }
972
+ tm.setApiKey(tenantId, keyName, keyValue);
973
+ console.log(`\n${header} ${S.check} API key ${t.bold(keyName)} stored (encrypted) for tenant ${t.bold(tenantId)}.\n`);
974
+ break;
975
+ }
976
+ case "delete": {
977
+ const [tenantId, keyName] = apikeyArgs;
978
+ if (!tenantId || !keyName) {
979
+ console.error(`\n ${S.cross} Usage: daemora tenant apikey delete ${t.dim("<tenantId> <KEY_NAME>")}\n`);
980
+ process.exit(1);
981
+ }
982
+ const deleted = tm.deleteApiKey(tenantId, keyName);
983
+ if (deleted) {
984
+ console.log(`\n${header} ${S.check} API key ${t.bold(keyName)} deleted for tenant ${t.bold(tenantId)}.\n`);
985
+ } else {
986
+ console.log(`\n ${S.cross} API key ${t.bold(keyName)} not found for tenant ${t.bold(tenantId)}.\n`);
987
+ }
988
+ break;
989
+ }
990
+ case "list": {
991
+ const [tenantId] = apikeyArgs;
992
+ if (!tenantId) {
993
+ console.error(`\n ${S.cross} Usage: daemora tenant apikey list ${t.dim("<tenantId>")}\n`);
994
+ process.exit(1);
995
+ }
996
+ const keys = tm.listApiKeyNames(tenantId);
997
+ console.log(header);
998
+ if (keys.length === 0) {
999
+ console.log(` ${t.muted("No API keys stored for this tenant.")}\n`);
1000
+ } else {
1001
+ for (const k of keys) {
1002
+ console.log(` ${S.lock} ${t.bold(k)} ${t.dim("(encrypted)")}`);
1003
+ }
1004
+ console.log(`\n ${t.muted(`${keys.length} key(s) stored.`)}\n`);
1005
+ }
1006
+ break;
1007
+ }
1008
+ default:
1009
+ console.error(`\n ${S.cross} Unknown apikey command: ${apikeyAction}`);
1010
+ console.log(` ${t.muted("Usage:")} daemora tenant apikey ${t.dim("[set|delete|list]")}\n`);
1011
+ process.exit(1);
1012
+ }
1013
+ break;
1014
+ }
1015
+
1016
+ default:
1017
+ console.error(`\n ${S.cross} Unknown tenant command: ${action || "(none)"}`);
1018
+ console.log(` ${t.muted("Usage:")} daemora tenant ${t.dim("[list|show|set|plan|suspend|unsuspend|reset|delete|apikey]")}\n`);
1019
+ process.exit(1);
1020
+ }
1021
+ }
1022
+
1023
+ // ── Security Doctor ────────────────────────────────────────────────────────────
1024
+
1025
+ async function handleDoctor() {
1026
+ const header = `\n ${t.h("Daemora Doctor")} ${t.muted("Security audit")}\n`;
1027
+ console.log(header);
1028
+
1029
+ const checks = [];
1030
+ const warn = (label, msg) => checks.push({ icon: chalk.hex("#F1C40F")("⚠"), label, msg, score: 0 });
1031
+ const fail = (label, msg) => checks.push({ icon: t.error("✘"), label, msg, score: 0 });
1032
+ const pass = (label) => checks.push({ icon: t.success("✔"), label, msg: null, score: 1 });
1033
+
1034
+ // Read .env for checks (non-secret values only)
1035
+ const env = {};
1036
+ try {
1037
+ const { readFileSync: rfs, existsSync: exs } = await import("fs");
1038
+ const { join: pjoin } = await import("path");
1039
+ const envPath = pjoin(config.rootDir, ".env");
1040
+ if (exs(envPath)) {
1041
+ for (const line of rfs(envPath, "utf-8").split("\n")) {
1042
+ const trimmed = line.trim();
1043
+ if (!trimmed || trimmed.startsWith("#")) continue;
1044
+ const eqIdx = trimmed.indexOf("=");
1045
+ if (eqIdx === -1) continue;
1046
+ env[trimmed.slice(0, eqIdx)] = trimmed.slice(eqIdx + 1);
1047
+ }
1048
+ }
1049
+ } catch {}
1050
+
1051
+ // Merge process.env for checks (already loaded at startup)
1052
+ const cfg = { ...env, ...process.env };
1053
+
1054
+ // 1. Secret vault configured
1055
+ if (secretVault.exists()) {
1056
+ pass("Secret vault configured");
1057
+ } else {
1058
+ warn("Secret vault not configured", "API keys are stored in plaintext .env. Run: daemora vault import <passphrase>");
1059
+ }
1060
+
1061
+ // 2. DAEMORA_TENANT_KEY set (for per-tenant API key encryption)
1062
+ if (cfg.DAEMORA_TENANT_KEY && cfg.DAEMORA_TENANT_KEY.length >= 16) {
1063
+ pass("DAEMORA_TENANT_KEY set");
1064
+ } else {
1065
+ warn("DAEMORA_TENANT_KEY not set", "Per-tenant API keys encrypted with insecure dev fallback. Set DAEMORA_TENANT_KEY=<32-hex-chars>");
1066
+ }
1067
+
1068
+ // 3. HTTP channel disabled (no unauthenticated /chat endpoint)
1069
+ // The HTTP chat endpoint is permanently disabled in Daemora — always passes
1070
+ pass("HTTP /chat endpoint disabled");
1071
+
1072
+ // 4. All enabled channels have allowlists
1073
+ const channelAllowlistKeys = {
1074
+ TELEGRAM_BOT_TOKEN: "TELEGRAM_ALLOWLIST",
1075
+ DISCORD_BOT_TOKEN: "DISCORD_ALLOWLIST",
1076
+ SLACK_BOT_TOKEN: "SLACK_ALLOWLIST",
1077
+ LINE_CHANNEL_ACCESS_TOKEN: "LINE_ALLOWLIST",
1078
+ SIGNAL_PHONE_NUMBER: "SIGNAL_ALLOWLIST",
1079
+ TEAMS_APP_ID: "TEAMS_ALLOWLIST",
1080
+ GOOGLE_CHAT_PROJECT_NUMBER: "GOOGLE_CHAT_ALLOWLIST",
1081
+ EMAIL_USER: "EMAIL_ALLOWLIST",
1082
+ TWILIO_ACCOUNT_SID: "WHATSAPP_ALLOWLIST",
1083
+ };
1084
+ const openChannels = [];
1085
+ for (const [tokenKey, allowlistKey] of Object.entries(channelAllowlistKeys)) {
1086
+ if (cfg[tokenKey] && !cfg[allowlistKey]) {
1087
+ openChannels.push(tokenKey.replace(/_BOT_TOKEN|_ACCOUNT_SID|_APP_ID|_ACCESS_TOKEN|_PHONE_NUMBER|_PROJECT_NUMBER|_USER/, "").toLowerCase());
1088
+ }
1089
+ }
1090
+ if (openChannels.length === 0) {
1091
+ pass("All enabled channels have allowlists");
1092
+ } else {
1093
+ warn(`Open channels (no allowlist): ${openChannels.join(", ")}`, "Set CHANNEL_ALLOWLIST=id1,id2 to restrict access");
1094
+ }
1095
+
1096
+ // 5. Filesystem sandbox active (ALLOWED_PATHS or SANDBOX_MODE=docker)
1097
+ const hasAllowedPaths = cfg.ALLOWED_PATHS && cfg.ALLOWED_PATHS.trim().length > 0;
1098
+ const hasDockerSandbox = cfg.SANDBOX_MODE === "docker";
1099
+ const hasTenantIsolation = cfg.TENANT_ISOLATE_FILESYSTEM === "true";
1100
+ if (hasAllowedPaths || hasDockerSandbox || hasTenantIsolation) {
1101
+ pass("Filesystem sandbox active");
1102
+ } else {
1103
+ warn("Filesystem sandbox not active", "Agent can access any file. Set ALLOWED_PATHS or TENANT_ISOLATE_FILESYSTEM=true");
1104
+ }
1105
+
1106
+ // 6. Multi-tenant + filesystem isolation
1107
+ const multiTenantEnabled = cfg.MULTI_TENANT_ENABLED === "true";
1108
+ if (multiTenantEnabled && hasTenantIsolation) {
1109
+ pass("Multi-tenant filesystem isolation enabled");
1110
+ } else if (multiTenantEnabled && !hasTenantIsolation) {
1111
+ warn("Multi-tenant enabled but filesystem not isolated", "Set TENANT_ISOLATE_FILESYSTEM=true to isolate tenants");
1112
+ } else {
1113
+ pass("Single-user mode (no tenant isolation needed)");
1114
+ }
1115
+
1116
+ // 7. Daily cost limit set
1117
+ const hasDailyCost = cfg.MAX_DAILY_COST && parseFloat(cfg.MAX_DAILY_COST) > 0;
1118
+ if (hasDailyCost) {
1119
+ pass(`Daily cost limit set ($${cfg.MAX_DAILY_COST})`);
1120
+ } else {
1121
+ warn("No daily cost limit set", "Set MAX_DAILY_COST=10.00 to prevent runaway spend");
1122
+ }
1123
+
1124
+ // 8. A2A secured (if enabled)
1125
+ const a2aEnabled = cfg.A2A_ENABLED === "true";
1126
+ if (!a2aEnabled) {
1127
+ pass("A2A disabled (secure)");
1128
+ } else if (cfg.A2A_AUTH_TOKEN && cfg.A2A_ALLOWED_AGENTS) {
1129
+ pass("A2A enabled with auth token + agent allowlist");
1130
+ } else if (cfg.A2A_AUTH_TOKEN) {
1131
+ warn("A2A enabled with auth token but no agent allowlist", "Set A2A_ALLOWED_AGENTS=https://trusted-agent.com");
1132
+ } else {
1133
+ fail("A2A enabled without auth token", "Any agent can submit tasks! Set A2A_AUTH_TOKEN=<secret>");
1134
+ }
1135
+
1136
+ // Print results
1137
+ const score = checks.reduce((s, c) => s + c.score, 0);
1138
+ const total = checks.length;
1139
+
1140
+ for (const check of checks) {
1141
+ console.log(` ${check.icon} ${check.label}`);
1142
+ if (check.msg) console.log(` ${t.dim(check.msg)}`);
1143
+ }
1144
+
1145
+ console.log("");
1146
+
1147
+ const scoreColor = score === total ? t.success : score >= total * 0.75 ? chalk.hex("#F1C40F") : t.error;
1148
+ console.log(` ${t.bold("Security score:")} ${scoreColor(`${score}/${total}`)}`);
1149
+
1150
+ if (score === total) {
1151
+ console.log(` ${t.success("All checks passed. Daemora is production-ready.")}\n`);
1152
+ } else {
1153
+ const issues = checks.filter(c => c.score === 0);
1154
+ const critical = issues.filter(c => c.icon === t.error("✘")).length;
1155
+ if (critical > 0) {
1156
+ console.log(` ${t.error(`${critical} critical issue(s) — fix immediately.`)}`);
1157
+ }
1158
+ const warnings = issues.filter(c => c.icon !== t.error("✘")).length;
1159
+ if (warnings > 0) {
1160
+ console.log(` ${chalk.hex("#F1C40F")(`${warnings} warning(s) — recommended fixes.`)}`);
1161
+ }
1162
+ console.log("");
1163
+ }
1164
+ }
1165
+
1166
+ function printHelp() {
1167
+ const w = 56;
1168
+ const line = chalk.hex(P.brand)("\u2501".repeat(w));
1169
+ const dimLine = chalk.hex(P.dim)("\u2500".repeat(w));
1170
+
1171
+ console.log(`
1172
+ ${line}
1173
+ ${t.h("Daemora")} ${t.muted("Your 24/7 AI Digital Worker")}
1174
+ ${line}
1175
+
1176
+ ${t.bold("USAGE")}
1177
+ ${dimLine}
1178
+ ${t.cmd("daemora")} ${t.dim("<command>")} ${t.dim("[options]")}
1179
+
1180
+ ${t.bold("COMMANDS")}
1181
+ ${dimLine}
1182
+ ${t.cmd("start")} Start the agent server
1183
+ ${t.cmd("setup")} Interactive setup wizard
1184
+
1185
+ ${t.cmd("daemon install")} Install as OS service (auto-start)
1186
+ ${t.cmd("daemon uninstall")} Remove OS service
1187
+ ${t.cmd("daemon start")} Start the background daemon
1188
+ ${t.cmd("daemon stop")} Stop the daemon
1189
+ ${t.cmd("daemon restart")} Restart the daemon
1190
+ ${t.cmd("daemon status")} Check daemon status
1191
+
1192
+ ${t.cmd("vault set")} ${t.dim("<pass> <key> <val>")} Store an encrypted secret
1193
+ ${t.cmd("vault get")} ${t.dim("<pass> <key>")} Retrieve a secret
1194
+ ${t.cmd("vault list")} ${t.dim("<pass>")} List secret keys
1195
+ ${t.cmd("vault import")} ${t.dim("<pass> [.env]")} Import from .env file
1196
+ ${t.cmd("vault status")} Check vault status
1197
+
1198
+ ${t.cmd("mcp list")} List configured MCP servers
1199
+ ${t.cmd("mcp add")} ${t.dim("<name> <cmd-or-url> [args]")} Add stdio/HTTP server
1200
+ ${t.cmd("mcp add")} ${t.dim("<name> <url> --sse")} Add SSE server
1201
+ ${t.cmd("mcp remove")} ${t.dim("<name>")} Remove a server
1202
+ ${t.cmd("mcp enable")} ${t.dim("<name>")} Enable a disabled server
1203
+ ${t.cmd("mcp disable")} ${t.dim("<name>")} Disable a server
1204
+ ${t.cmd("mcp reload")} ${t.dim("<name>")} Reconnect server (live if agent is running)
1205
+ ${t.cmd("mcp env")} ${t.dim("<name> <KEY> <value>")} Set env var for a server
1206
+
1207
+ ${t.cmd("sandbox show")} Show current filesystem access rules
1208
+ ${t.cmd("sandbox add")} ${t.dim("<path>")} Allow agent to access a directory
1209
+ ${t.cmd("sandbox remove")} ${t.dim("<path>")} Remove a directory from allowed list
1210
+ ${t.cmd("sandbox block")} ${t.dim("<path>")} Always block a directory (even if allowed)
1211
+ ${t.cmd("sandbox unblock")} ${t.dim("<path>")} Remove a directory from blocked list
1212
+ ${t.cmd("sandbox restrict")} Enforce path limits in shell commands too
1213
+ ${t.cmd("sandbox unrestrict")} Remove shell command path enforcement
1214
+ ${t.cmd("sandbox clear")} Remove all path limits (global mode)
1215
+
1216
+ ${t.cmd("tenant list")} List all tenants with stats
1217
+ ${t.cmd("tenant show")} ${t.dim("<id>")} Show full tenant config
1218
+ ${t.cmd("tenant set")} ${t.dim("<id> <key> <value>")} Update a tenant setting
1219
+ ${t.cmd("tenant plan")} ${t.dim("<id> <free|pro|admin>")} Set tenant plan
1220
+ ${t.cmd("tenant suspend")} ${t.dim("<id> [reason]")} Suspend a tenant (blocks all tasks)
1221
+ ${t.cmd("tenant unsuspend")} ${t.dim("<id>")} Unsuspend a tenant
1222
+ ${t.cmd("tenant reset")} ${t.dim("<id>")} Reset tenant config (keep cost history)
1223
+ ${t.cmd("tenant delete")} ${t.dim("<id>")} Delete a tenant record
1224
+ ${t.cmd("tenant apikey set")} ${t.dim("<id> <KEY> <val>")} Store encrypted API key for tenant
1225
+ ${t.cmd("tenant apikey delete")} ${t.dim("<id> <KEY>")} Delete a tenant API key
1226
+ ${t.cmd("tenant apikey list")} ${t.dim("<id>")} List tenant API key names (not values)
1227
+
1228
+ ${t.cmd("doctor")} Security audit — check for misconfigurations
1229
+
1230
+ ${t.cmd("help")} Show this help
1231
+
1232
+ ${t.bold("EXAMPLES")}
1233
+ ${dimLine}
1234
+ ${t.dim("$")} daemora setup
1235
+ ${t.dim("$")} daemora start
1236
+ ${t.dim("$")} daemora daemon install
1237
+ ${t.dim("$")} daemora vault set mypass123 OPENAI_API_KEY sk-...
1238
+ ${t.dim("$")} daemora vault list mypass123
1239
+ ${t.dim("$")} daemora mcp list
1240
+ ${t.dim("$")} daemora mcp add github npx -y @modelcontextprotocol/server-github
1241
+ ${t.dim("$")} daemora mcp env github GITHUB_PERSONAL_ACCESS_TOKEN ghp_...
1242
+ ${t.dim("$")} daemora mcp add notion http://localhost:3100/mcp
1243
+ ${t.dim("$")} daemora mcp add myserver http://localhost:3100/sse --sse
1244
+ ${t.dim("$")} daemora mcp remove github
1245
+ ${t.dim("$")} daemora mcp add (interactive — prompts for everything)
1246
+ ${t.dim("$")} daemora mcp reload github (reconnects live if agent running)
1247
+ ${t.dim("$")} daemora sandbox add ~/Downloads (lock agent to Downloads folder)
1248
+ ${t.dim("$")} daemora sandbox block ~/Downloads/private
1249
+ ${t.dim("$")} daemora sandbox show
1250
+ ${t.dim("$")} daemora sandbox clear (back to global mode)
1251
+ ${t.dim("$")} daemora tenant list
1252
+ ${t.dim("$")} daemora tenant show telegram:123456789
1253
+ ${t.dim("$")} daemora tenant plan telegram:123456789 pro
1254
+ ${t.dim("$")} daemora tenant set telegram:123456789 model anthropic:claude-opus-4-6
1255
+ ${t.dim("$")} daemora tenant suspend telegram:999 "Terms of service violation"
1256
+ ${t.dim("$")} daemora tenant unsuspend telegram:999
1257
+ ${t.dim("$")} daemora tenant apikey set telegram:123 OPENAI_API_KEY sk-...
1258
+ ${t.dim("$")} daemora tenant apikey list telegram:123
1259
+ ${t.dim("$")} daemora tenant apikey delete telegram:123 OPENAI_API_KEY
1260
+ ${t.dim("$")} daemora doctor
1261
+ `);
1262
+ }
1263
+
1264
+ main().catch((err) => {
1265
+ console.error(`\n ${S.cross} ${t.error(err.message)}\n`);
1266
+ process.exit(1);
1267
+ });