mercury-agent 0.4.5

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 (218) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +438 -0
  3. package/container/Dockerfile +127 -0
  4. package/container/Dockerfile.base +109 -0
  5. package/container/Dockerfile.power +17 -0
  6. package/container/agent-package.json +8 -0
  7. package/container/build.sh +54 -0
  8. package/docs/TODOS.md +147 -0
  9. package/docs/auth/dashboard.md +28 -0
  10. package/docs/auth/overview.md +109 -0
  11. package/docs/auth/whatsapp.md +173 -0
  12. package/docs/configuration.md +54 -0
  13. package/docs/container-lifecycle.md +349 -0
  14. package/docs/context-architecture.md +87 -0
  15. package/docs/deployment.md +199 -0
  16. package/docs/extensions.md +375 -0
  17. package/docs/graceful-shutdown.md +62 -0
  18. package/docs/kb-distillation.md +77 -0
  19. package/docs/media/overview.md +140 -0
  20. package/docs/media/whatsapp.md +171 -0
  21. package/docs/memory.md +137 -0
  22. package/docs/permissions.md +217 -0
  23. package/docs/pipeline.md +228 -0
  24. package/docs/prd-chat-memory.md +76 -0
  25. package/docs/prd-config-load.md +82 -0
  26. package/docs/rate-limiting.md +166 -0
  27. package/docs/scheduler.md +288 -0
  28. package/docs/setup-discord.md +100 -0
  29. package/docs/setup-slack.md +119 -0
  30. package/docs/setup-whatsapp.md +94 -0
  31. package/docs/subagents.md +166 -0
  32. package/docs/web-search.md +62 -0
  33. package/examples/extensions/README.md +12 -0
  34. package/examples/extensions/charts/index.ts +13 -0
  35. package/examples/extensions/charts/skill/SKILL.md +98 -0
  36. package/examples/extensions/gws/README.md +52 -0
  37. package/examples/extensions/gws/index.ts +106 -0
  38. package/examples/extensions/gws/skill/SKILL.md +57 -0
  39. package/examples/extensions/gws/skill/references/calendar.md +101 -0
  40. package/examples/extensions/gws/skill/references/docs.md +65 -0
  41. package/examples/extensions/gws/skill/references/drive.md +79 -0
  42. package/examples/extensions/gws/skill/references/gmail.md +85 -0
  43. package/examples/extensions/gws/skill/references/sheets.md +60 -0
  44. package/examples/extensions/napkin/index.ts +821 -0
  45. package/examples/extensions/napkin/prompts/consolidation-monthly.md +73 -0
  46. package/examples/extensions/napkin/prompts/consolidation-weekly.md +67 -0
  47. package/examples/extensions/napkin/prompts/kb-distillation.md +176 -0
  48. package/examples/extensions/napkin/skill/SKILL.md +728 -0
  49. package/examples/extensions/pdf/index.ts +23 -0
  50. package/examples/extensions/pdf/skill/LICENSE.txt +30 -0
  51. package/examples/extensions/pdf/skill/SKILL.md +314 -0
  52. package/examples/extensions/pdf/skill/forms.md +294 -0
  53. package/examples/extensions/pdf/skill/reference.md +612 -0
  54. package/examples/extensions/pdf/skill/scripts/check_bounding_boxes.py +65 -0
  55. package/examples/extensions/pdf/skill/scripts/check_fillable_fields.py +11 -0
  56. package/examples/extensions/pdf/skill/scripts/convert_pdf_to_images.py +33 -0
  57. package/examples/extensions/pdf/skill/scripts/create_validation_image.py +37 -0
  58. package/examples/extensions/pdf/skill/scripts/extract_form_field_info.py +122 -0
  59. package/examples/extensions/pdf/skill/scripts/extract_form_structure.py +115 -0
  60. package/examples/extensions/pdf/skill/scripts/fill_fillable_fields.py +98 -0
  61. package/examples/extensions/pdf/skill/scripts/fill_pdf_form_with_annotations.py +107 -0
  62. package/examples/extensions/permission-guard/index.ts +65 -0
  63. package/examples/extensions/pinchtab/index.ts +199 -0
  64. package/examples/extensions/pinchtab/lib/session-injector.ts +144 -0
  65. package/examples/extensions/pinchtab/skill/SKILL.md +224 -0
  66. package/examples/extensions/pinchtab/skill/TRUST.md +69 -0
  67. package/examples/extensions/pinchtab/skill/references/api.md +297 -0
  68. package/examples/extensions/pinchtab/skill/references/env.md +45 -0
  69. package/examples/extensions/pinchtab/skill/references/profiles.md +107 -0
  70. package/examples/extensions/tradestation/host/refresh.ts +102 -0
  71. package/examples/extensions/tradestation/index.ts +153 -0
  72. package/examples/extensions/tradestation/skill/SKILL.md +67 -0
  73. package/examples/extensions/tradestation/skill/scripts/ts-cli.ts +111 -0
  74. package/examples/extensions/voice-synth/index.ts +94 -0
  75. package/examples/extensions/voice-synth/skill/SKILL.md +38 -0
  76. package/examples/extensions/voice-transcribe/index.ts +381 -0
  77. package/examples/extensions/voice-transcribe/requirements.txt +8 -0
  78. package/examples/extensions/voice-transcribe/scripts/transcribe.py +179 -0
  79. package/examples/extensions/voice-transcribe/skill/SKILL.md +53 -0
  80. package/examples/extensions/web-search/index.ts +22 -0
  81. package/examples/extensions/web-search/skill/SKILL.md +114 -0
  82. package/examples/extensions/web-search/skill/references/apartments.md +178 -0
  83. package/examples/extensions/web-search/skill/references/car-purchase.md +132 -0
  84. package/examples/extensions/web-search/skill/references/car-rental.md +113 -0
  85. package/examples/extensions/web-search/skill/references/flights.md +133 -0
  86. package/examples/extensions/web-search/skill/references/hotels.md +148 -0
  87. package/examples/extensions/yahoo-mail/cli/bun.lock +66 -0
  88. package/examples/extensions/yahoo-mail/cli/package.json +13 -0
  89. package/examples/extensions/yahoo-mail/cli/ymail.mjs +353 -0
  90. package/examples/extensions/yahoo-mail/index.ts +57 -0
  91. package/examples/extensions/yahoo-mail/skill/SKILL.md +78 -0
  92. package/package.json +106 -0
  93. package/resources/agents/explore.md +50 -0
  94. package/resources/agents/worker.md +24 -0
  95. package/resources/builtin-extensions.txt +3 -0
  96. package/resources/connection-env-vars.json +25 -0
  97. package/resources/extensions/.gitkeep +0 -0
  98. package/resources/pi-extensions/subagent/agents.ts +126 -0
  99. package/resources/pi-extensions/subagent/index.ts +964 -0
  100. package/resources/profiles/coding/AGENTS.md +43 -0
  101. package/resources/profiles/coding/mercury-profile.yaml +15 -0
  102. package/resources/profiles/general/AGENTS.md +31 -0
  103. package/resources/profiles/general/mercury-profile.yaml +15 -0
  104. package/resources/profiles/research/AGENTS.md +40 -0
  105. package/resources/profiles/research/mercury-profile.yaml +15 -0
  106. package/resources/skills/config/SKILL.md +25 -0
  107. package/resources/skills/context/SKILL.md +33 -0
  108. package/resources/skills/conversation-recap/SKILL.md +19 -0
  109. package/resources/skills/media/SKILL.md +27 -0
  110. package/resources/skills/mutes/SKILL.md +31 -0
  111. package/resources/skills/permissions/SKILL.md +19 -0
  112. package/resources/skills/preferences/SKILL.md +31 -0
  113. package/resources/skills/recall/SKILL.md +24 -0
  114. package/resources/skills/roles/SKILL.md +18 -0
  115. package/resources/skills/spaces/SKILL.md +18 -0
  116. package/resources/skills/tasks/SKILL.md +45 -0
  117. package/resources/templates/AGENTS.md +157 -0
  118. package/resources/templates/env.template +34 -0
  119. package/resources/templates/mercury.example.yaml +75 -0
  120. package/src/adapters/discord-native.ts +534 -0
  121. package/src/adapters/discord.ts +38 -0
  122. package/src/adapters/setup.ts +89 -0
  123. package/src/adapters/slack.ts +9 -0
  124. package/src/adapters/whatsapp-media.ts +337 -0
  125. package/src/adapters/whatsapp.ts +629 -0
  126. package/src/agent/api-socket.ts +127 -0
  127. package/src/agent/container-entry.ts +967 -0
  128. package/src/agent/container-error.ts +49 -0
  129. package/src/agent/container-runner.ts +1272 -0
  130. package/src/agent/model-capabilities-core.ts +23 -0
  131. package/src/agent/model-capabilities.ts +231 -0
  132. package/src/agent/pi-failure-class.ts +83 -0
  133. package/src/agent/pi-jsonl-parser.ts +306 -0
  134. package/src/agent/preferences-prompt.ts +20 -0
  135. package/src/agent/user-error-messages.ts +78 -0
  136. package/src/bridges/discord.ts +171 -0
  137. package/src/bridges/slack.ts +177 -0
  138. package/src/bridges/teams.ts +160 -0
  139. package/src/bridges/telegram.ts +571 -0
  140. package/src/bridges/whatsapp.ts +290 -0
  141. package/src/chat-shim.ts +259 -0
  142. package/src/cli/mercury.ts +2508 -0
  143. package/src/cli/mrctl-http.ts +27 -0
  144. package/src/cli/mrctl.ts +611 -0
  145. package/src/cli/whatsapp-auth.ts +260 -0
  146. package/src/config-file.ts +397 -0
  147. package/src/config-model-chain.ts +30 -0
  148. package/src/config.ts +316 -0
  149. package/src/core/api-types.ts +58 -0
  150. package/src/core/api.ts +105 -0
  151. package/src/core/commands.ts +76 -0
  152. package/src/core/conversation.ts +47 -0
  153. package/src/core/handler.ts +206 -0
  154. package/src/core/media.ts +200 -0
  155. package/src/core/mute-duration.ts +22 -0
  156. package/src/core/outbox.ts +76 -0
  157. package/src/core/permissions.ts +192 -0
  158. package/src/core/profiles.ts +245 -0
  159. package/src/core/rate-limiter.ts +127 -0
  160. package/src/core/router.ts +191 -0
  161. package/src/core/routes/chat.ts +172 -0
  162. package/src/core/routes/config-builtin.ts +107 -0
  163. package/src/core/routes/config.ts +81 -0
  164. package/src/core/routes/connections.ts +190 -0
  165. package/src/core/routes/console.ts +668 -0
  166. package/src/core/routes/control.ts +46 -0
  167. package/src/core/routes/conversations.ts +66 -0
  168. package/src/core/routes/dashboard.ts +2491 -0
  169. package/src/core/routes/extensions.ts +37 -0
  170. package/src/core/routes/index.ts +14 -0
  171. package/src/core/routes/media.ts +72 -0
  172. package/src/core/routes/messages.ts +37 -0
  173. package/src/core/routes/mutes.ts +89 -0
  174. package/src/core/routes/prefs.ts +95 -0
  175. package/src/core/routes/roles.ts +125 -0
  176. package/src/core/routes/spaces.ts +60 -0
  177. package/src/core/routes/storage.ts +126 -0
  178. package/src/core/routes/tasks.ts +189 -0
  179. package/src/core/routes/tradestation.ts +268 -0
  180. package/src/core/routes/tts.ts +51 -0
  181. package/src/core/runtime.ts +1140 -0
  182. package/src/core/space-queue.ts +103 -0
  183. package/src/core/storage-cleanup.ts +140 -0
  184. package/src/core/storage-guard.ts +24 -0
  185. package/src/core/task-scheduler.ts +132 -0
  186. package/src/core/telegram-format.ts +178 -0
  187. package/src/core/trigger.ts +142 -0
  188. package/src/dashboard/index.html +729 -0
  189. package/src/dashboard/tokens.css +53 -0
  190. package/src/extensions/api.ts +252 -0
  191. package/src/extensions/catalog.ts +117 -0
  192. package/src/extensions/config-registry.ts +83 -0
  193. package/src/extensions/context.ts +36 -0
  194. package/src/extensions/hooks.ts +156 -0
  195. package/src/extensions/image-builder.ts +617 -0
  196. package/src/extensions/installer.ts +306 -0
  197. package/src/extensions/jobs.ts +122 -0
  198. package/src/extensions/loader.ts +271 -0
  199. package/src/extensions/permission-guard.ts +52 -0
  200. package/src/extensions/reserved.ts +28 -0
  201. package/src/extensions/skills.ts +123 -0
  202. package/src/extensions/types.ts +462 -0
  203. package/src/logger.ts +174 -0
  204. package/src/main.ts +586 -0
  205. package/src/server.ts +391 -0
  206. package/src/storage/db.ts +1624 -0
  207. package/src/storage/memory.ts +45 -0
  208. package/src/storage/pi-auth.ts +95 -0
  209. package/src/text/markdown.ts +117 -0
  210. package/src/text/rtl.ts +38 -0
  211. package/src/tradestation/host-api.ts +77 -0
  212. package/src/tradestation/pending-orders.ts +69 -0
  213. package/src/tts/azure.ts +52 -0
  214. package/src/tts/google.ts +128 -0
  215. package/src/tts/index.ts +8 -0
  216. package/src/tts/language.ts +20 -0
  217. package/src/tts/synthesize.ts +133 -0
  218. package/src/types.ts +295 -0
@@ -0,0 +1,2508 @@
1
+ #!/usr/bin/env bun
2
+
3
+ import { spawn, spawnSync } from "node:child_process";
4
+ import {
5
+ chmodSync,
6
+ copyFileSync,
7
+ cpSync,
8
+ existsSync,
9
+ mkdirSync,
10
+ readdirSync,
11
+ readFileSync,
12
+ rmSync,
13
+ unlinkSync,
14
+ writeFileSync,
15
+ } from "node:fs";
16
+ import { homedir, tmpdir } from "node:os";
17
+ import { basename, dirname, join, resolve } from "node:path";
18
+ import { fileURLToPath } from "node:url";
19
+ import { Command } from "commander";
20
+ import { loadConfig, resolveProjectPath } from "../config.js";
21
+ import {
22
+ checkExtensionIndexLoads,
23
+ getProjectDataDir,
24
+ getUserExtensionsDir,
25
+ installExtensionFromDirectory,
26
+ removeInstalledExtension,
27
+ } from "../extensions/installer.js";
28
+ import { RESERVED_EXTENSION_NAMES } from "../extensions/reserved.js";
29
+ import { Db } from "../storage/db.js";
30
+ import { removeSpaceWorkspace } from "../storage/memory.js";
31
+ import { authenticate } from "./whatsapp-auth.js";
32
+
33
+ const __dirname = dirname(fileURLToPath(import.meta.url));
34
+ const PACKAGE_ROOT = join(__dirname, "../..");
35
+ const CWD = process.cwd();
36
+ const TEMPLATES_DIR = join(PACKAGE_ROOT, "resources/templates");
37
+ const PROFILES_DIR = join(PACKAGE_ROOT, "resources/profiles");
38
+ const VALID_EXT_NAME_RE = /^[a-z0-9][a-z0-9-]*$/;
39
+
40
+ function isPortInUse(port: string): boolean {
41
+ if (process.platform === "win32") {
42
+ const result = spawnSync("netstat", ["-ano"], {
43
+ stdio: "pipe",
44
+ encoding: "utf-8",
45
+ });
46
+ return result.status === 0 && result.stdout.includes(`:${port} `);
47
+ }
48
+ const result = spawnSync("lsof", ["-i", `:${port}`, "-t"], {
49
+ stdio: "pipe",
50
+ });
51
+ return result.status === 0 && result.stdout.toString().trim().length > 0;
52
+ }
53
+
54
+ function getVersion(): string {
55
+ try {
56
+ const pkg = JSON.parse(
57
+ readFileSync(join(PACKAGE_ROOT, "package.json"), "utf-8"),
58
+ );
59
+ return pkg.version;
60
+ } catch {
61
+ return "0.0.0";
62
+ }
63
+ }
64
+
65
+ function loadEnvFile(envPath: string): Record<string, string> {
66
+ const content = readFileSync(envPath, "utf-8");
67
+ const vars: Record<string, string> = {};
68
+ for (const line of content.split("\n")) {
69
+ const trimmed = line.trim();
70
+ if (!trimmed || trimmed.startsWith("#")) continue;
71
+ const match = trimmed.match(/^([^=]+)=(.*)$/);
72
+ if (match) {
73
+ vars[match[1]] = match[2];
74
+ }
75
+ }
76
+ return vars;
77
+ }
78
+
79
+ function withProjectDb<T>(fn: (db: Db) => T): T {
80
+ const dbPath = join(CWD, getProjectDataDir(CWD), "state.db");
81
+ const db = new Db(dbPath);
82
+ try {
83
+ return fn(db);
84
+ } finally {
85
+ db.close();
86
+ }
87
+ }
88
+
89
+ // Commands
90
+ function initAction(): void {
91
+ console.log("🪽 Initializing mercury project...\n");
92
+
93
+ // Create .env if it doesn't exist
94
+ const envPath = join(CWD, ".env");
95
+ if (!existsSync(envPath)) {
96
+ copyFileSync(join(TEMPLATES_DIR, "env.template"), envPath);
97
+ console.log(" ✓ .env");
98
+ } else {
99
+ console.log(" • .env (already exists)");
100
+ }
101
+
102
+ const mercuryExamplePath = join(CWD, "mercury.example.yaml");
103
+ if (!existsSync(mercuryExamplePath)) {
104
+ copyFileSync(
105
+ join(TEMPLATES_DIR, "mercury.example.yaml"),
106
+ mercuryExamplePath,
107
+ );
108
+ console.log(" ✓ mercury.example.yaml (rename to mercury.yaml to use)");
109
+ } else {
110
+ console.log(" • mercury.example.yaml (already exists)");
111
+ }
112
+
113
+ // Create data directories
114
+ const dirs = [".mercury", ".mercury/spaces", ".mercury/global"];
115
+ for (const dir of dirs) {
116
+ const fullPath = join(CWD, dir);
117
+ if (!existsSync(fullPath)) {
118
+ mkdirSync(fullPath, { recursive: true });
119
+ console.log(` ✓ ${dir}/`);
120
+ }
121
+ }
122
+
123
+ // Create AGENTS.md for the agent
124
+ const agentsMdPath = join(CWD, ".mercury/global/AGENTS.md");
125
+ if (!existsSync(agentsMdPath)) {
126
+ copyFileSync(join(TEMPLATES_DIR, "AGENTS.md"), agentsMdPath);
127
+ console.log(" ✓ .mercury/global/AGENTS.md");
128
+ } else {
129
+ console.log(" • .mercury/global/AGENTS.md (already exists)");
130
+ }
131
+
132
+ // Copy agent definitions
133
+ console.log("\nCopying agent definitions:");
134
+ const agentsDir = join(CWD, ".mercury/global/agents");
135
+ mkdirSync(agentsDir, { recursive: true });
136
+ const srcAgentsDir = join(PACKAGE_ROOT, "resources/agents");
137
+ for (const file of readdirSync(srcAgentsDir)) {
138
+ copyFileSync(join(srcAgentsDir, file), join(agentsDir, file));
139
+ console.log(` ✓ .mercury/global/agents/${file}`);
140
+ }
141
+
142
+ console.log("\n🪽 Initialization complete!");
143
+ console.log("\nNext steps:");
144
+ console.log(" 1. Edit .env to set your API keys and enable adapters");
145
+ console.log(
146
+ " 2. Run 'mercury service install' to start as a system service",
147
+ );
148
+ }
149
+
150
+ async function runAction(): Promise<void> {
151
+ const envPath = join(CWD, ".env");
152
+ if (!existsSync(envPath)) {
153
+ console.error("Error: .env file not found in current directory.");
154
+ console.error("Run 'mercury init' first, or cd into your mercury project.");
155
+ process.exit(1);
156
+ }
157
+
158
+ const envVars = loadEnvFile(envPath);
159
+ Object.assign(process.env, envVars);
160
+ const cfg = loadConfig();
161
+ const imageName = cfg.agentContainerImage;
162
+
163
+ const imageCheck = spawnSync("docker", ["image", "inspect", imageName], {
164
+ stdio: "pipe",
165
+ });
166
+ if (imageCheck.status !== 0) {
167
+ console.error(`Error: Container image '${imageName}' not found.`);
168
+ if (imageName.startsWith("ghcr.io/")) {
169
+ console.error(`Run 'docker pull ${imageName}' to pull it.`);
170
+ } else {
171
+ console.error("Run 'mercury build' to build it.");
172
+ }
173
+ process.exit(1);
174
+ }
175
+
176
+ console.log("🪽 Starting mercury...\n");
177
+
178
+ const entryPoint = join(PACKAGE_ROOT, "src/main.ts");
179
+
180
+ const child = spawn("bun", ["run", entryPoint], {
181
+ stdio: "inherit",
182
+ cwd: CWD,
183
+ env: { ...process.env, ...envVars },
184
+ });
185
+
186
+ child.on("error", (err) => {
187
+ console.error("Failed to start:", err.message);
188
+ process.exit(1);
189
+ });
190
+
191
+ child.on("exit", (code) => {
192
+ process.exit(code ?? 0);
193
+ });
194
+ }
195
+
196
+ function buildAction(): void {
197
+ // Build from package sources using a temp context — no files needed in user project
198
+ const tmpDir = join(CWD, ".mercury", ".build-context");
199
+ mkdirSync(tmpDir, { recursive: true });
200
+
201
+ try {
202
+ // Copy container files from package into temp context
203
+ const filesToCopy = [
204
+ "container/Dockerfile",
205
+ "container/agent-package.json",
206
+ "src/agent/container-entry.ts",
207
+ "src/agent/model-capabilities-core.ts",
208
+ "src/agent/pi-failure-class.ts",
209
+ "src/agent/pi-jsonl-parser.ts",
210
+ "src/agent/preferences-prompt.ts",
211
+ "src/cli/mrctl.ts",
212
+ "src/cli/mrctl-http.ts",
213
+ "src/extensions/reserved.ts",
214
+ "src/extensions/permission-guard.ts",
215
+ "src/types.ts",
216
+ ];
217
+
218
+ for (const file of filesToCopy) {
219
+ const src = join(PACKAGE_ROOT, file);
220
+ const dest = join(tmpDir, file);
221
+ mkdirSync(dirname(dest), { recursive: true });
222
+ copyFileSync(src, dest);
223
+ }
224
+
225
+ cpSync(join(PACKAGE_ROOT, "resources"), join(tmpDir, "resources"), {
226
+ recursive: true,
227
+ filter: (src) => !src.split(/[\\/]/).includes("node_modules"),
228
+ });
229
+
230
+ cpSync(
231
+ join(PACKAGE_ROOT, "examples", "extensions"),
232
+ join(tmpDir, "examples", "extensions"),
233
+ { recursive: true },
234
+ );
235
+
236
+ console.log("📦 Building container image...\n");
237
+ const result = spawnSync(
238
+ "docker",
239
+ [
240
+ "build",
241
+ "-t",
242
+ "mercury-agent:latest",
243
+ "-f",
244
+ join(tmpDir, "container/Dockerfile"),
245
+ tmpDir,
246
+ ],
247
+ { stdio: "inherit" },
248
+ );
249
+
250
+ if (result.status !== 0) {
251
+ process.exit(result.status ?? 1);
252
+ }
253
+ } finally {
254
+ // Clean up temp context
255
+ rmSync(tmpDir, { recursive: true, force: true });
256
+ }
257
+ }
258
+
259
+ function statusAction(): void {
260
+ console.log("🪽 mercury status\n");
261
+ console.log(`Project directory: ${CWD}\n`);
262
+
263
+ const envPath = join(CWD, ".env");
264
+ const hasEnv = existsSync(envPath);
265
+ console.log(
266
+ `Configuration: ${hasEnv ? "✓ .env exists" : "✗ .env missing (run 'mercury init')"}`,
267
+ );
268
+
269
+ const imageCheck = spawnSync(
270
+ "docker",
271
+ ["image", "inspect", "mercury-agent:latest"],
272
+ {
273
+ stdio: "pipe",
274
+ },
275
+ );
276
+ const hasImage = imageCheck.status === 0;
277
+ console.log(
278
+ `Container image: ${hasImage ? "✓ mercury-agent:latest" : "✗ not built (run 'mercury build')"}`,
279
+ );
280
+
281
+ if (hasEnv) {
282
+ console.log("\nConfigured adapters:");
283
+ const envContent = readFileSync(envPath, "utf-8");
284
+
285
+ const hasWhatsApp = /MERCURY_ENABLE_WHATSAPP\s*=\s*true/i.test(envContent);
286
+ const hasSlack = /^[^#]*SLACK_BOT_TOKEN=\S+/m.test(envContent);
287
+ const hasDiscord = /^[^#]*DISCORD_BOT_TOKEN=\S+/m.test(envContent);
288
+ const hasTelegram = /^[^#]*TELEGRAM_BOT_TOKEN=\S+/m.test(envContent);
289
+
290
+ console.log(` WhatsApp: ${hasWhatsApp ? "✓ enabled" : "○ disabled"}`);
291
+ console.log(
292
+ ` Slack: ${hasSlack ? "✓ configured" : "○ not configured"}`,
293
+ );
294
+ console.log(
295
+ ` Discord: ${hasDiscord ? "✓ configured" : "○ not configured"}`,
296
+ );
297
+ console.log(
298
+ ` Telegram: ${hasTelegram ? "✓ configured" : "○ not configured"}`,
299
+ );
300
+
301
+ const portMatch = envContent.match(/MERCURY_PORT\s*=\s*(\d+)/);
302
+ const port = portMatch ? portMatch[1] : "8787";
303
+
304
+ const isRunning = isPortInUse(port);
305
+ console.log(
306
+ `\nStatus: ${isRunning ? `🟢 running (port ${port})` : "⚪ not running"}`,
307
+ );
308
+ }
309
+ }
310
+
311
+ function doctorAction(): void {
312
+ console.log("🩺 mercury doctor\n");
313
+
314
+ let passed = 0;
315
+ let warned = 0;
316
+ let failed = 0;
317
+
318
+ function pass(msg: string): void {
319
+ console.log(` ✅ ${msg}`);
320
+ passed++;
321
+ }
322
+ function warn(msg: string, fix?: string): void {
323
+ console.log(` ⚠️ ${msg}`);
324
+ if (fix) console.log(` → ${fix}`);
325
+ warned++;
326
+ }
327
+ function fail(msg: string, fix?: string): void {
328
+ console.log(` ❌ ${msg}`);
329
+ if (fix) console.log(` → ${fix}`);
330
+ failed++;
331
+ }
332
+
333
+ // 1. .env exists
334
+ console.log("Configuration:");
335
+ const envPath = join(CWD, ".env");
336
+ const hasEnv = existsSync(envPath);
337
+ if (hasEnv) {
338
+ pass(".env file found");
339
+ } else {
340
+ fail(".env file missing", "Run 'mercury init' to create one");
341
+ }
342
+
343
+ const envVars = hasEnv ? loadEnvFile(envPath) : {};
344
+ if (hasEnv) Object.assign(process.env, envVars);
345
+ const cfg = loadConfig();
346
+
347
+ // 2. Docker installed and running
348
+ console.log("\nDocker:");
349
+ const dockerCheck = spawnSync("docker", ["info"], {
350
+ stdio: "pipe",
351
+ timeout: 10_000,
352
+ });
353
+ if (dockerCheck.status === 0) {
354
+ pass("Docker is installed and running");
355
+ } else {
356
+ const hasDocker =
357
+ spawnSync("which", ["docker"], { stdio: "pipe" }).status === 0;
358
+ if (hasDocker) {
359
+ fail(
360
+ "Docker is installed but daemon is not running",
361
+ "Start Docker Desktop or run 'sudo systemctl start docker'",
362
+ );
363
+ } else {
364
+ fail(
365
+ "Docker is not installed",
366
+ "Install from https://docs.docker.com/get-docker/",
367
+ );
368
+ }
369
+ }
370
+
371
+ // 3. Agent image available
372
+ const image = cfg.agentContainerImage;
373
+ const imageCheck = spawnSync("docker", ["image", "inspect", image], {
374
+ stdio: "pipe",
375
+ timeout: 10_000,
376
+ });
377
+ if (imageCheck.status === 0) {
378
+ pass(`Agent image found: ${image}`);
379
+ } else {
380
+ warn(
381
+ `Agent image not found locally: ${image}`,
382
+ `Mercury will auto-pull on first start, or run 'docker pull ${image}'`,
383
+ );
384
+ }
385
+
386
+ // 4. AI credentials
387
+ console.log("\nAI Credentials:");
388
+ const dataDir = getProjectDataDir(CWD);
389
+ const authPath = join(CWD, dataDir, "global", "auth.json");
390
+ const hasOAuth = existsSync(authPath);
391
+ const hasApiKey = !!(
392
+ process.env.MERCURY_ANTHROPIC_API_KEY ||
393
+ process.env.MERCURY_ANTHROPIC_OAUTH_TOKEN
394
+ );
395
+ if (hasOAuth || hasApiKey) {
396
+ if (hasOAuth) pass("OAuth credentials found (auth.json)");
397
+ if (hasApiKey) pass("API key found in .env");
398
+ } else {
399
+ fail(
400
+ "No AI credentials configured",
401
+ "Run 'mercury auth login' or set MERCURY_ANTHROPIC_API_KEY in .env",
402
+ );
403
+ }
404
+
405
+ // 5. Adapters
406
+ console.log("\nAdapters:");
407
+ const whatsappEnabled = cfg.enableWhatsApp;
408
+ const discordEnabled = cfg.enableDiscord;
409
+ const slackEnabled = cfg.enableSlack;
410
+ const telegramEnabled = cfg.enableTelegram;
411
+
412
+ if (
413
+ !whatsappEnabled &&
414
+ !discordEnabled &&
415
+ !slackEnabled &&
416
+ !telegramEnabled
417
+ ) {
418
+ fail(
419
+ "No adapters enabled",
420
+ "Enable at least one adapter in mercury.yaml (ingress section) or .env",
421
+ );
422
+ } else {
423
+ if (whatsappEnabled) {
424
+ const whatsappAuthDir = resolveProjectPath(cfg.whatsappAuthDir);
425
+ const credsFile = join(whatsappAuthDir, "creds.json");
426
+ if (existsSync(credsFile)) {
427
+ pass("WhatsApp: enabled and authenticated");
428
+ } else {
429
+ fail(
430
+ "WhatsApp: enabled but not authenticated",
431
+ "Run 'mercury auth whatsapp' first",
432
+ );
433
+ }
434
+ }
435
+ if (discordEnabled) {
436
+ if (process.env.MERCURY_DISCORD_BOT_TOKEN) {
437
+ pass("Discord: enabled and token configured");
438
+ } else {
439
+ fail(
440
+ "Discord: enabled but MERCURY_DISCORD_BOT_TOKEN not set",
441
+ "Add your bot token to .env",
442
+ );
443
+ }
444
+ }
445
+ if (slackEnabled) {
446
+ const hasToken = !!process.env.MERCURY_SLACK_BOT_TOKEN;
447
+ const hasSecret = !!process.env.MERCURY_SLACK_SIGNING_SECRET;
448
+ if (hasToken && hasSecret) {
449
+ pass("Slack: enabled and configured");
450
+ } else {
451
+ const missing = [
452
+ !hasToken && "MERCURY_SLACK_BOT_TOKEN",
453
+ !hasSecret && "MERCURY_SLACK_SIGNING_SECRET",
454
+ ].filter(Boolean);
455
+ fail(
456
+ `Slack: enabled but missing ${missing.join(", ")}`,
457
+ "Add to .env — see docs/setup-slack.md",
458
+ );
459
+ }
460
+ }
461
+ if (telegramEnabled) {
462
+ if (process.env.MERCURY_TELEGRAM_BOT_TOKEN) {
463
+ pass("Telegram: enabled and token configured");
464
+ } else {
465
+ fail(
466
+ "Telegram: enabled but MERCURY_TELEGRAM_BOT_TOKEN not set",
467
+ "Add your bot token to .env",
468
+ );
469
+ }
470
+ }
471
+ }
472
+
473
+ // 6. Admins
474
+ console.log("\nPermissions:");
475
+ if (cfg.admins) {
476
+ pass(`Admins configured (${cfg.admins.split(",").length} admin(s))`);
477
+ } else {
478
+ warn(
479
+ "No admins configured — no one will have admin permissions",
480
+ "Add your platform ID to the admins field in mercury.yaml or MERCURY_ADMINS in .env",
481
+ );
482
+ }
483
+
484
+ // 7. Bun version
485
+ console.log("\nRuntime:");
486
+ const bunVersionCheck = spawnSync("bun", ["--version"], {
487
+ stdio: "pipe",
488
+ encoding: "utf-8",
489
+ });
490
+ if (bunVersionCheck.status === 0) {
491
+ const bunVersion = bunVersionCheck.stdout.trim();
492
+ pass(`Bun ${bunVersion} installed`);
493
+ } else {
494
+ fail(
495
+ "Bun is not installed",
496
+ "Install from https://bun.sh — required to run Mercury",
497
+ );
498
+ }
499
+
500
+ // 8. Port available
501
+ console.log("\nNetwork:");
502
+ const port = String(cfg.port);
503
+ const portInUse = isPortInUse(port);
504
+ if (portInUse) {
505
+ warn(
506
+ `Port ${port} is in use (Mercury may already be running)`,
507
+ `Change MERCURY_PORT in .env or stop the existing process`,
508
+ );
509
+ } else {
510
+ pass(`Port ${port} is available`);
511
+ }
512
+
513
+ // 8. Spaces exist
514
+ console.log("\nSpaces:");
515
+ const dbPath = join(CWD, dataDir, "state.db");
516
+ if (existsSync(dbPath)) {
517
+ try {
518
+ const db = new Db(dbPath);
519
+ const spaces = db.listSpaces();
520
+ if (spaces.length > 0) {
521
+ pass(`${spaces.length} space(s) configured`);
522
+ } else {
523
+ warn(
524
+ "No spaces created yet — incoming messages will be dropped",
525
+ "Run 'mercury spaces create <name>'",
526
+ );
527
+ }
528
+ db.close();
529
+ } catch {
530
+ warn("Could not read database");
531
+ }
532
+ } else {
533
+ warn("No database yet (created on first run)");
534
+ }
535
+
536
+ // Summary
537
+ console.log(`\n─────────────────────────────────`);
538
+ console.log(` ${passed} passed ${warned} warnings ${failed} errors`);
539
+ if (failed > 0) {
540
+ console.log("\n Fix the errors above before starting Mercury.");
541
+ process.exit(1);
542
+ } else if (warned > 0) {
543
+ console.log("\n Mercury should work, but review the warnings above.");
544
+ } else {
545
+ console.log("\n Everything looks good! 🚀");
546
+ }
547
+ }
548
+
549
+ // CLI setup
550
+ const program = new Command();
551
+
552
+ program
553
+ .name("mercury")
554
+ .description("Personal AI assistant for chat platforms")
555
+ .version(getVersion());
556
+
557
+ program
558
+ .command("init")
559
+ .description("Initialize a new mercury project in current directory")
560
+ .action(initAction);
561
+
562
+ program
563
+ .command("setup")
564
+ .description("Interactive guided setup for a new Mercury project")
565
+ .option("--profile <name>", "Start from a built-in or external profile")
566
+ .action(async (options: { profile?: string }) => {
567
+ const readline = await import("node:readline");
568
+ const { randomBytes } = await import("node:crypto");
569
+
570
+ const rl = readline.createInterface({
571
+ input: process.stdin,
572
+ output: process.stdout,
573
+ });
574
+ const ask = (q: string): Promise<string> =>
575
+ new Promise((r) => rl.question(q, r));
576
+ const pick = async (
577
+ prompt: string,
578
+ choices: string[],
579
+ defaultChoice?: string,
580
+ ): Promise<string> => {
581
+ const def = defaultChoice ? ` [${defaultChoice}]` : "";
582
+ const answer = await ask(` ${prompt} (${choices.join(" / ")})${def}: `);
583
+ const trimmed = answer.trim().toLowerCase();
584
+ if (!trimmed && defaultChoice) return defaultChoice;
585
+ if (choices.includes(trimmed)) return trimmed;
586
+ console.log(
587
+ ` Invalid choice. Using default: ${defaultChoice || choices[0]}`,
588
+ );
589
+ return defaultChoice || choices[0];
590
+ };
591
+
592
+ console.log("\n Mercury Setup");
593
+ console.log(` ${"─".repeat(45)}\n`);
594
+
595
+ // Prerequisite checks
596
+ console.log(" Checking prerequisites...");
597
+ const dockerCheck = spawnSync("docker", ["info"], {
598
+ stdio: "pipe",
599
+ timeout: 10_000,
600
+ });
601
+ if (dockerCheck.status !== 0) {
602
+ console.error("\n Error: Docker is not running.");
603
+ console.error(" Install from https://docs.docker.com/get-docker/");
604
+ rl.close();
605
+ process.exit(1);
606
+ }
607
+ console.log(" Docker: OK");
608
+
609
+ const bunCheck = spawnSync("bun", ["--version"], {
610
+ stdio: "pipe",
611
+ encoding: "utf-8",
612
+ });
613
+ if (bunCheck.status !== 0) {
614
+ console.error("\n Error: Bun is not installed.");
615
+ console.error(" Install from https://bun.sh");
616
+ rl.close();
617
+ process.exit(1);
618
+ }
619
+ console.log(` Bun: OK (${bunCheck.stdout.trim()})\n`);
620
+
621
+ // Step 1: AI Provider
622
+ console.log(" Step 1/4: AI Provider");
623
+ const provider = await pick(
624
+ "Which AI provider?",
625
+ ["anthropic", "openai", "google", "groq"],
626
+ "anthropic",
627
+ );
628
+
629
+ const providerKeyMap: Record<
630
+ string,
631
+ { envKey: string; label: string; defaultModel: string }
632
+ > = {
633
+ anthropic: {
634
+ envKey: "MERCURY_ANTHROPIC_API_KEY",
635
+ label: "Anthropic API key",
636
+ defaultModel: "claude-sonnet-4-20250514",
637
+ },
638
+ openai: {
639
+ envKey: "MERCURY_OPENAI_API_KEY",
640
+ label: "OpenAI API key",
641
+ defaultModel: "gpt-4o",
642
+ },
643
+ google: {
644
+ envKey: "MERCURY_GEMINI_API_KEY",
645
+ label: "Gemini API key",
646
+ defaultModel: "gemini-2.5-flash",
647
+ },
648
+ groq: {
649
+ envKey: "MERCURY_GROQ_API_KEY",
650
+ label: "Groq API key",
651
+ defaultModel: "llama-3.3-70b-versatile",
652
+ },
653
+ };
654
+
655
+ const providerInfo = providerKeyMap[provider];
656
+ const apiKey = (await ask(` ${providerInfo.label}: `)).trim();
657
+ if (!apiKey) {
658
+ console.error(" Error: API key is required.");
659
+ rl.close();
660
+ process.exit(1);
661
+ }
662
+
663
+ const modelAnswer = (
664
+ await ask(` Model [${providerInfo.defaultModel}]: `)
665
+ ).trim();
666
+ const model = modelAnswer || providerInfo.defaultModel;
667
+ console.log();
668
+
669
+ // Step 2: Chat Platform
670
+ console.log(" Step 2/4: Chat Platform");
671
+ const platform = await pick(
672
+ "Which platform?",
673
+ ["telegram", "discord", "slack", "whatsapp", "none"],
674
+ "none",
675
+ );
676
+
677
+ let platformToken = "";
678
+ let platformSecret = "";
679
+ if (platform === "telegram") {
680
+ platformToken = (await ask(" Telegram bot token: ")).trim();
681
+ } else if (platform === "discord") {
682
+ platformToken = (await ask(" Discord bot token: ")).trim();
683
+ } else if (platform === "slack") {
684
+ platformToken = (await ask(" Slack bot token: ")).trim();
685
+ platformSecret = (await ask(" Slack signing secret: ")).trim();
686
+ }
687
+ console.log();
688
+
689
+ // Step 3: Profile
690
+ console.log(" Step 3/4: Agent Profile");
691
+ let profileChoice = options.profile;
692
+ if (!profileChoice) {
693
+ const builtinProfiles: string[] = [];
694
+ if (existsSync(PROFILES_DIR)) {
695
+ for (const entry of readdirSync(PROFILES_DIR, {
696
+ withFileTypes: true,
697
+ })) {
698
+ if (entry.isDirectory()) builtinProfiles.push(entry.name);
699
+ }
700
+ }
701
+ const profileChoices =
702
+ builtinProfiles.length > 0 ? [...builtinProfiles, "blank"] : ["blank"];
703
+ profileChoice = await pick(
704
+ "Start from a template?",
705
+ profileChoices,
706
+ profileChoices[0],
707
+ );
708
+ }
709
+ console.log();
710
+
711
+ // Step 4: Security
712
+ console.log(" Step 4/4: Security");
713
+ const secret = `mrc_${randomBytes(24).toString("hex")}`;
714
+ console.log(` Generated API secret: ${secret.slice(0, 12)}...`);
715
+ console.log(" (saved to .env)\n");
716
+
717
+ rl.close();
718
+
719
+ // Run init
720
+ initAction();
721
+
722
+ // Write .env with collected values
723
+ const envPath = join(CWD, ".env");
724
+ let envContent = readFileSync(envPath, "utf-8");
725
+
726
+ const setEnv = (key: string, value: string) => {
727
+ const regex = new RegExp(`^#?\\s*${key}=.*$`, "m");
728
+ if (regex.test(envContent)) {
729
+ envContent = envContent.replace(regex, `${key}=${value}`);
730
+ } else {
731
+ envContent += `\n${key}=${value}`;
732
+ }
733
+ };
734
+
735
+ setEnv("MERCURY_MODEL_PROVIDER", provider);
736
+ setEnv("MERCURY_MODEL", model);
737
+ setEnv(providerInfo.envKey, apiKey);
738
+ setEnv("MERCURY_API_SECRET", secret);
739
+ setEnv("MERCURY_PORT", "8787");
740
+
741
+ if (platform !== "none") {
742
+ setEnv(`MERCURY_ENABLE_${platform.toUpperCase()}`, "true");
743
+ if (platform === "telegram" && platformToken) {
744
+ setEnv("MERCURY_TELEGRAM_BOT_TOKEN", platformToken);
745
+ } else if (platform === "discord" && platformToken) {
746
+ setEnv("MERCURY_DISCORD_BOT_TOKEN", platformToken);
747
+ } else if (platform === "slack") {
748
+ if (platformToken) setEnv("MERCURY_SLACK_BOT_TOKEN", platformToken);
749
+ if (platformSecret)
750
+ setEnv("MERCURY_SLACK_SIGNING_SECRET", platformSecret);
751
+ }
752
+ }
753
+
754
+ writeFileSync(envPath, envContent);
755
+
756
+ // Apply profile if not blank
757
+ if (profileChoice && profileChoice !== "blank") {
758
+ const profileDir = join(PROFILES_DIR, profileChoice);
759
+ if (existsSync(profileDir)) {
760
+ const agentsMd = join(profileDir, "AGENTS.md");
761
+ if (existsSync(agentsMd)) {
762
+ copyFileSync(agentsMd, join(CWD, ".mercury/global/AGENTS.md"));
763
+ }
764
+ const profileExtDir = join(profileDir, "extensions");
765
+ if (existsSync(profileExtDir)) {
766
+ const userExtDir = join(CWD, ".mercury/extensions");
767
+ mkdirSync(userExtDir, { recursive: true });
768
+ cpSync(profileExtDir, userExtDir, { recursive: true });
769
+ }
770
+ }
771
+ }
772
+
773
+ // Create default space
774
+ const dbPath = join(CWD, ".mercury", "state.db");
775
+ const db = new Db(dbPath);
776
+ try {
777
+ db.ensureSpace("main");
778
+ } finally {
779
+ db.close();
780
+ }
781
+
782
+ console.log(`\n ${"─".repeat(45)}`);
783
+ console.log(" Setup complete!\n");
784
+ console.log(" Start: mercury service install");
785
+ console.log(" Status: mercury service status");
786
+ console.log(" Logs: mercury service logs -f");
787
+ console.log(' Chat: mercury chat "hello"');
788
+ console.log(` ${"─".repeat(45)}\n`);
789
+ });
790
+
791
+ program
792
+ .command("run")
793
+ .description("Start the chat adapters (WhatsApp/Slack/Discord)")
794
+ .action(runAction);
795
+
796
+ program
797
+ .command("build")
798
+ .description("Build the agent container image")
799
+ .action(buildAction);
800
+
801
+ program
802
+ .command("status")
803
+ .description("Show current status and configuration")
804
+ .action(statusAction);
805
+
806
+ program
807
+ .command("doctor")
808
+ .description("Check environment and configuration for common issues")
809
+ .action(doctorAction);
810
+
811
+ // Auth subcommand
812
+ const authCommand = program
813
+ .command("auth")
814
+ .description("Authenticate with providers and platforms");
815
+
816
+ authCommand
817
+ .command("login [provider]")
818
+ .description(
819
+ "Login with an OAuth provider (anthropic, github-copilot, google-gemini-cli, antigravity, openai-codex)",
820
+ )
821
+ .action(async (providerArg?: string) => {
822
+ const { getOAuthProviders, getOAuthProvider } = await import(
823
+ "@mariozechner/pi-ai/oauth"
824
+ );
825
+ const readline = await import("node:readline");
826
+ const { exec } = await import("node:child_process");
827
+
828
+ const providers = getOAuthProviders();
829
+
830
+ let providerId: string;
831
+
832
+ if (providerArg) {
833
+ providerArg = providerArg.trim();
834
+ const provider = getOAuthProvider(providerArg);
835
+ if (!provider) {
836
+ console.error(
837
+ `Unknown provider: ${providerArg}\nAvailable: ${providers.map((p: { id: string }) => p.id).join(", ")}`,
838
+ );
839
+ process.exit(1);
840
+ }
841
+ providerId = providerArg;
842
+ } else {
843
+ // Interactive selection
844
+ console.log("Available OAuth providers:\n");
845
+ for (let i = 0; i < providers.length; i++) {
846
+ console.log(` ${i + 1}. ${providers[i].name} (${providers[i].id})`);
847
+ }
848
+ console.log();
849
+
850
+ const rl = readline.createInterface({
851
+ input: process.stdin,
852
+ output: process.stdout,
853
+ });
854
+
855
+ const answer = await new Promise<string>((resolve) => {
856
+ rl.question("Select provider (number or id): ", resolve);
857
+ });
858
+ rl.close();
859
+
860
+ const num = Number.parseInt(answer, 10);
861
+ if (num >= 1 && num <= providers.length) {
862
+ providerId = providers[num - 1].id;
863
+ } else {
864
+ const provider = getOAuthProvider(answer.trim());
865
+ if (!provider) {
866
+ console.error("Invalid selection.");
867
+ process.exit(1);
868
+ }
869
+ providerId = answer.trim();
870
+ }
871
+ }
872
+
873
+ const provider = getOAuthProvider(providerId);
874
+ if (!provider) throw new Error(`Unknown provider: ${providerId}`);
875
+ console.log(`\nLogging in to ${provider.name}...`);
876
+
877
+ // Resolve auth.json path
878
+ const dataDir = getProjectDataDir(CWD);
879
+ const authPath = join(CWD, dataDir, "global", "auth.json");
880
+ const authDir = dirname(authPath);
881
+ if (!existsSync(authDir)) {
882
+ mkdirSync(authDir, { recursive: true });
883
+ }
884
+
885
+ // Read existing auth
886
+ let authData: Record<string, unknown> = {};
887
+ if (existsSync(authPath)) {
888
+ try {
889
+ authData = JSON.parse(readFileSync(authPath, "utf-8"));
890
+ } catch {
891
+ // ignore
892
+ }
893
+ }
894
+
895
+ try {
896
+ const rl = readline.createInterface({
897
+ input: process.stdin,
898
+ output: process.stdout,
899
+ });
900
+
901
+ const credentials = await provider.login({
902
+ onAuth: (info: { url: string; instructions?: string }) => {
903
+ console.log(`\nOpen this URL to authenticate:\n\n ${info.url}\n`);
904
+ if (info.instructions) {
905
+ console.log(info.instructions);
906
+ }
907
+ // Try to open browser
908
+ const openCmd =
909
+ process.platform === "darwin"
910
+ ? "open"
911
+ : process.platform === "win32"
912
+ ? "start"
913
+ : "xdg-open";
914
+ exec(`${openCmd} "${info.url}"`);
915
+ },
916
+ onPrompt: async (prompt: { message: string; placeholder?: string }) => {
917
+ const answer = await new Promise<string>((resolve) => {
918
+ rl.question(
919
+ `${prompt.message}${prompt.placeholder ? ` (${prompt.placeholder})` : ""}: `,
920
+ resolve,
921
+ );
922
+ });
923
+ return answer;
924
+ },
925
+ onProgress: (message: string) => {
926
+ console.log(message);
927
+ },
928
+ onManualCodeInput: async () => {
929
+ const answer = await new Promise<string>((resolve) => {
930
+ rl.question("Paste redirect URL or code: ", resolve);
931
+ });
932
+ return answer;
933
+ },
934
+ });
935
+
936
+ rl.close();
937
+
938
+ // Save to auth.json
939
+ authData[providerId] = { type: "oauth", ...credentials };
940
+ writeFileSync(authPath, JSON.stringify(authData, null, 2), "utf-8");
941
+ chmodSync(authPath, 0o600);
942
+
943
+ console.log(`\n✓ Logged in to ${provider.name}`);
944
+ console.log(` Credentials saved to ${authPath}`);
945
+ } catch (err) {
946
+ const message = err instanceof Error ? err.message : String(err);
947
+ if (message === "Login cancelled") {
948
+ console.log("\nLogin cancelled.");
949
+ } else {
950
+ console.error(`\nLogin failed: ${message}`);
951
+ }
952
+ process.exit(1);
953
+ }
954
+ });
955
+
956
+ authCommand
957
+ .command("logout [provider]")
958
+ .description("Remove saved OAuth credentials for a provider")
959
+ .action(async (providerArg?: string) => {
960
+ const dataDir = getProjectDataDir(CWD);
961
+ const authPath = join(CWD, dataDir, "global", "auth.json");
962
+
963
+ if (!existsSync(authPath)) {
964
+ console.log("No credentials found.");
965
+ return;
966
+ }
967
+
968
+ let authData: Record<string, unknown>;
969
+ try {
970
+ authData = JSON.parse(readFileSync(authPath, "utf-8"));
971
+ } catch {
972
+ console.log("No credentials found.");
973
+ return;
974
+ }
975
+
976
+ if (providerArg) {
977
+ if (!(providerArg in authData)) {
978
+ console.log(`No credentials for ${providerArg}.`);
979
+ return;
980
+ }
981
+ delete authData[providerArg];
982
+ writeFileSync(authPath, JSON.stringify(authData, null, 2), "utf-8");
983
+ console.log(`✓ Removed credentials for ${providerArg}`);
984
+ } else {
985
+ const keys = Object.keys(authData);
986
+ if (keys.length === 0) {
987
+ console.log("No credentials found.");
988
+ return;
989
+ }
990
+ console.log("Logged in providers:");
991
+ for (const key of keys) {
992
+ console.log(` - ${key}`);
993
+ }
994
+ console.log('\nRun "mercury auth logout <provider>" to remove.');
995
+ }
996
+ });
997
+
998
+ authCommand
999
+ .command("status")
1000
+ .description("Show authentication status for all providers")
1001
+ .action(async () => {
1002
+ const { getOAuthProviders } = await import("@mariozechner/pi-ai/oauth");
1003
+
1004
+ const dataDir = getProjectDataDir(CWD);
1005
+ const authPath = join(CWD, dataDir, "global", "auth.json");
1006
+
1007
+ let authData: Record<string, { type?: string; expires?: number }> = {};
1008
+ if (existsSync(authPath)) {
1009
+ try {
1010
+ authData = JSON.parse(readFileSync(authPath, "utf-8"));
1011
+ } catch {
1012
+ // ignore
1013
+ }
1014
+ }
1015
+
1016
+ // Check env vars too
1017
+ const envPath = join(CWD, ".env");
1018
+ const envVars = existsSync(envPath) ? loadEnvFile(envPath) : {};
1019
+
1020
+ const providers = getOAuthProviders();
1021
+ console.log("Authentication status:\n");
1022
+
1023
+ for (const provider of providers) {
1024
+ const cred = authData[provider.id];
1025
+ if (cred?.type === "oauth") {
1026
+ const expired = cred.expires ? Date.now() >= cred.expires : false;
1027
+ const status = expired ? "expired (will auto-refresh)" : "✓ logged in";
1028
+ console.log(` ${provider.name}: ${status}`);
1029
+ } else {
1030
+ console.log(` ${provider.name}: not logged in`);
1031
+ }
1032
+ }
1033
+
1034
+ // Check for API keys in env
1035
+ console.log();
1036
+ const apiKeyVars = [
1037
+ "MERCURY_ANTHROPIC_API_KEY",
1038
+ "MERCURY_ANTHROPIC_OAUTH_TOKEN",
1039
+ "MERCURY_OPENAI_API_KEY",
1040
+ ];
1041
+ let hasEnvKeys = false;
1042
+ for (const key of apiKeyVars) {
1043
+ if (envVars[key]) {
1044
+ console.log(` ${key}: ✓ set in .env`);
1045
+ hasEnvKeys = true;
1046
+ }
1047
+ }
1048
+ if (!hasEnvKeys) {
1049
+ console.log(" No API keys found in .env");
1050
+ }
1051
+ });
1052
+
1053
+ authCommand
1054
+ .command("whatsapp")
1055
+ .description("Authenticate with WhatsApp via QR code or pairing code")
1056
+ .option("--pairing-code", "Use pairing code instead of QR code")
1057
+ .option(
1058
+ "--phone <number>",
1059
+ "Phone number for pairing code (e.g., 14155551234)",
1060
+ )
1061
+ .action(async (options: { pairingCode?: boolean; phone?: string }) => {
1062
+ const cfg = loadConfig();
1063
+ const authDir = resolveProjectPath(cfg.whatsappAuthDir);
1064
+ const statusDir = resolveProjectPath(cfg.dataDir);
1065
+
1066
+ try {
1067
+ await authenticate({
1068
+ authDir,
1069
+ statusDir,
1070
+ usePairingCode: options.pairingCode,
1071
+ phoneNumber: options.phone,
1072
+ });
1073
+ } catch (err: unknown) {
1074
+ const message = err instanceof Error ? err.message : String(err);
1075
+ console.error("Authentication failed:", message);
1076
+ process.exit(1);
1077
+ }
1078
+ });
1079
+
1080
+ // Service management commands
1081
+ const SERVICE_NAME = "mercury";
1082
+ const LAUNCHD_LABEL = "com.mercury.agent";
1083
+
1084
+ function getServicePaths(): {
1085
+ systemdUser: string;
1086
+ systemdSystem: string;
1087
+ launchdPlist: string;
1088
+ logDir: string;
1089
+ } {
1090
+ return {
1091
+ systemdUser: join(homedir(), ".config/systemd/user/mercury.service"),
1092
+ systemdSystem: "/etc/systemd/system/mercury.service",
1093
+ launchdPlist: join(
1094
+ homedir(),
1095
+ "Library/LaunchAgents/com.mercury.agent.plist",
1096
+ ),
1097
+ logDir: join(CWD, ".mercury/logs"),
1098
+ };
1099
+ }
1100
+
1101
+ function checkCommandExists(cmd: string): boolean {
1102
+ const result = spawnSync("which", [cmd], { stdio: "pipe" });
1103
+ return result.status === 0;
1104
+ }
1105
+
1106
+ function generateSystemdService(userMode: boolean): string {
1107
+ const bunPath = resolve(process.execPath);
1108
+ const mercuryScript = resolve(process.argv[1]);
1109
+ const workDir = CWD;
1110
+
1111
+ const currentPath = process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin";
1112
+
1113
+ return `[Unit]
1114
+ Description=Mercury Chat Agent
1115
+ After=network.target
1116
+
1117
+ [Service]
1118
+ Type=simple
1119
+ ExecStart=${bunPath} run ${mercuryScript} run
1120
+ WorkingDirectory=${workDir}
1121
+ Environment=PATH=${currentPath}
1122
+ Restart=on-failure
1123
+ RestartSec=10
1124
+
1125
+ [Install]
1126
+ WantedBy=${userMode ? "default.target" : "multi-user.target"}
1127
+ `;
1128
+ }
1129
+
1130
+ function generateLaunchdPlist(): string {
1131
+ const bunPath = resolve(process.execPath);
1132
+ const mercuryScript = resolve(process.argv[1]);
1133
+ const workDir = CWD;
1134
+ const { logDir } = getServicePaths();
1135
+
1136
+ // Capture current PATH so docker and other tools are available
1137
+ const currentPath = process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin";
1138
+
1139
+ return `<?xml version="1.0" encoding="UTF-8"?>
1140
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
1141
+ <plist version="1.0">
1142
+ <dict>
1143
+ <key>Label</key>
1144
+ <string>${LAUNCHD_LABEL}</string>
1145
+ <key>ProgramArguments</key>
1146
+ <array>
1147
+ <string>${bunPath}</string>
1148
+ <string>run</string>
1149
+ <string>${mercuryScript}</string>
1150
+ <string>run</string>
1151
+ </array>
1152
+ <key>WorkingDirectory</key>
1153
+ <string>${workDir}</string>
1154
+ <key>EnvironmentVariables</key>
1155
+ <dict>
1156
+ <key>PATH</key>
1157
+ <string>${currentPath}</string>
1158
+ </dict>
1159
+ <key>RunAtLoad</key>
1160
+ <true/>
1161
+ <key>KeepAlive</key>
1162
+ <true/>
1163
+ <key>StandardOutPath</key>
1164
+ <string>${logDir}/mercury.log</string>
1165
+ <key>StandardErrorPath</key>
1166
+ <string>${logDir}/mercury.error.log</string>
1167
+ </dict>
1168
+ </plist>`;
1169
+ }
1170
+
1171
+ function installSystemd(userMode: boolean): void {
1172
+ if (!checkCommandExists("systemctl")) {
1173
+ console.error("Error: systemctl not found. Is systemd installed?");
1174
+ process.exit(1);
1175
+ }
1176
+
1177
+ const paths = getServicePaths();
1178
+ const servicePath = userMode ? paths.systemdUser : paths.systemdSystem;
1179
+ const serviceContent = generateSystemdService(userMode);
1180
+
1181
+ // Check if we need sudo for system-level install
1182
+ if (!userMode) {
1183
+ console.log("Installing system-level service requires sudo.");
1184
+ console.log("Consider using --user flag for user-level service instead.");
1185
+ }
1186
+
1187
+ // Create directory if needed
1188
+ mkdirSync(dirname(servicePath), { recursive: true });
1189
+
1190
+ // Write service file
1191
+ try {
1192
+ writeFileSync(servicePath, serviceContent);
1193
+ } catch (err) {
1194
+ if (!userMode) {
1195
+ console.error(
1196
+ "Error: Cannot write to system directory. Try with sudo or use --user flag.",
1197
+ );
1198
+ } else {
1199
+ console.error(`Error writing service file: ${err}`);
1200
+ }
1201
+ process.exit(1);
1202
+ }
1203
+
1204
+ // Enable and start service
1205
+ const systemctlBase = userMode ? ["systemctl", "--user"] : ["systemctl"];
1206
+
1207
+ console.log("Reloading systemd daemon...");
1208
+ const reloadResult = spawnSync(
1209
+ systemctlBase[0],
1210
+ [...systemctlBase.slice(1), "daemon-reload"],
1211
+ {
1212
+ stdio: "inherit",
1213
+ },
1214
+ );
1215
+ if (reloadResult.status !== 0) {
1216
+ console.error("Failed to reload systemd daemon");
1217
+ process.exit(1);
1218
+ }
1219
+
1220
+ console.log("Enabling mercury service...");
1221
+ const enableResult = spawnSync(
1222
+ systemctlBase[0],
1223
+ [...systemctlBase.slice(1), "enable", SERVICE_NAME],
1224
+ {
1225
+ stdio: "inherit",
1226
+ },
1227
+ );
1228
+ if (enableResult.status !== 0) {
1229
+ console.error("Failed to enable service");
1230
+ process.exit(1);
1231
+ }
1232
+
1233
+ console.log("Starting mercury service...");
1234
+ const startResult = spawnSync(
1235
+ systemctlBase[0],
1236
+ [...systemctlBase.slice(1), "start", SERVICE_NAME],
1237
+ {
1238
+ stdio: "inherit",
1239
+ },
1240
+ );
1241
+ if (startResult.status !== 0) {
1242
+ console.error("Failed to start service");
1243
+ process.exit(1);
1244
+ }
1245
+
1246
+ console.log("\n✓ Mercury service installed and started");
1247
+ console.log(` Service file: ${servicePath}`);
1248
+ console.log(
1249
+ ` View logs: journalctl ${userMode ? "--user " : ""}-u mercury -f`,
1250
+ );
1251
+ }
1252
+
1253
+ function installLaunchd(): void {
1254
+ if (!checkCommandExists("launchctl")) {
1255
+ console.error("Error: launchctl not found. Are you on macOS?");
1256
+ process.exit(1);
1257
+ }
1258
+
1259
+ const paths = getServicePaths();
1260
+ const plistContent = generateLaunchdPlist();
1261
+
1262
+ // Create log directory
1263
+ mkdirSync(paths.logDir, { recursive: true });
1264
+
1265
+ // Create LaunchAgents directory if needed
1266
+ mkdirSync(dirname(paths.launchdPlist), { recursive: true });
1267
+
1268
+ // Unload existing service if present
1269
+ if (existsSync(paths.launchdPlist)) {
1270
+ spawnSync("launchctl", ["unload", paths.launchdPlist], { stdio: "pipe" });
1271
+ }
1272
+
1273
+ // Write plist file
1274
+ writeFileSync(paths.launchdPlist, plistContent);
1275
+
1276
+ // Load service
1277
+ const loadResult = spawnSync("launchctl", ["load", paths.launchdPlist], {
1278
+ stdio: "inherit",
1279
+ });
1280
+ if (loadResult.status !== 0) {
1281
+ console.error("Failed to load service");
1282
+ process.exit(1);
1283
+ }
1284
+
1285
+ console.log("\n✓ Mercury service installed and started");
1286
+ console.log(` Plist: ${paths.launchdPlist}`);
1287
+ console.log(` Logs: ${paths.logDir}/mercury.log`);
1288
+ console.log(` View logs: tail -f ${paths.logDir}/mercury.log`);
1289
+ }
1290
+
1291
+ function serviceInstallAction(options: { user?: boolean }): void {
1292
+ // Verify we're in a mercury project
1293
+ const envPath = join(CWD, ".env");
1294
+ if (!existsSync(envPath)) {
1295
+ console.error("Error: .env file not found in current directory.");
1296
+ console.error("Run 'mercury init' first, or cd into your mercury project.");
1297
+ process.exit(1);
1298
+ }
1299
+
1300
+ const platform = process.platform;
1301
+
1302
+ if (platform === "darwin") {
1303
+ installLaunchd();
1304
+ } else if (platform === "linux") {
1305
+ // Default to user mode unless explicitly installing system-wide
1306
+ installSystemd(options.user ?? true);
1307
+ } else {
1308
+ console.error(`Unsupported platform: ${platform}`);
1309
+ console.log("See docs/deployment.md for manual setup instructions.");
1310
+ process.exit(1);
1311
+ }
1312
+ }
1313
+
1314
+ function serviceUninstallAction(): void {
1315
+ const platform = process.platform;
1316
+ const paths = getServicePaths();
1317
+
1318
+ if (platform === "darwin") {
1319
+ if (existsSync(paths.launchdPlist)) {
1320
+ console.log("Unloading mercury service...");
1321
+ spawnSync("launchctl", ["unload", paths.launchdPlist], {
1322
+ stdio: "inherit",
1323
+ });
1324
+ unlinkSync(paths.launchdPlist);
1325
+ console.log("✓ Mercury service uninstalled");
1326
+ } else {
1327
+ console.log("Service not installed");
1328
+ }
1329
+ } else if (platform === "linux") {
1330
+ // Try user service first, then system
1331
+ if (existsSync(paths.systemdUser)) {
1332
+ console.log("Stopping mercury user service...");
1333
+ spawnSync("systemctl", ["--user", "stop", SERVICE_NAME], {
1334
+ stdio: "inherit",
1335
+ });
1336
+ console.log("Disabling mercury user service...");
1337
+ spawnSync("systemctl", ["--user", "disable", SERVICE_NAME], {
1338
+ stdio: "inherit",
1339
+ });
1340
+ unlinkSync(paths.systemdUser);
1341
+ spawnSync("systemctl", ["--user", "daemon-reload"], { stdio: "inherit" });
1342
+ console.log("✓ Mercury user service uninstalled");
1343
+ } else if (existsSync(paths.systemdSystem)) {
1344
+ console.log("Stopping mercury system service...");
1345
+ spawnSync("systemctl", ["stop", SERVICE_NAME], { stdio: "inherit" });
1346
+ console.log("Disabling mercury system service...");
1347
+ spawnSync("systemctl", ["disable", SERVICE_NAME], { stdio: "inherit" });
1348
+ try {
1349
+ unlinkSync(paths.systemdSystem);
1350
+ } catch {
1351
+ console.error(
1352
+ "Error: Cannot remove system service file. Try with sudo.",
1353
+ );
1354
+ process.exit(1);
1355
+ }
1356
+ spawnSync("systemctl", ["daemon-reload"], { stdio: "inherit" });
1357
+ console.log("✓ Mercury system service uninstalled");
1358
+ } else {
1359
+ console.log("Service not installed");
1360
+ }
1361
+ } else {
1362
+ console.error(`Unsupported platform: ${platform}`);
1363
+ process.exit(1);
1364
+ }
1365
+ }
1366
+
1367
+ function serviceStatusAction(): void {
1368
+ const platform = process.platform;
1369
+ const paths = getServicePaths();
1370
+
1371
+ if (platform === "darwin") {
1372
+ if (!existsSync(paths.launchdPlist)) {
1373
+ console.log("Mercury service is not installed");
1374
+ return;
1375
+ }
1376
+ console.log("Mercury service status:\n");
1377
+ spawnSync("launchctl", ["list", LAUNCHD_LABEL], { stdio: "inherit" });
1378
+ } else if (platform === "linux") {
1379
+ // Try user service first
1380
+ if (existsSync(paths.systemdUser)) {
1381
+ spawnSync("systemctl", ["--user", "status", SERVICE_NAME], {
1382
+ stdio: "inherit",
1383
+ });
1384
+ } else if (existsSync(paths.systemdSystem)) {
1385
+ spawnSync("systemctl", ["status", SERVICE_NAME], { stdio: "inherit" });
1386
+ } else {
1387
+ console.log("Mercury service is not installed");
1388
+ }
1389
+ } else {
1390
+ console.error(`Unsupported platform: ${platform}`);
1391
+ process.exit(1);
1392
+ }
1393
+ }
1394
+
1395
+ function serviceLogsAction(options: { follow?: boolean }): void {
1396
+ const platform = process.platform;
1397
+ const paths = getServicePaths();
1398
+
1399
+ if (platform === "darwin") {
1400
+ const logPath = join(paths.logDir, "mercury.log");
1401
+ if (!existsSync(logPath)) {
1402
+ console.error(`Log file not found: ${logPath}`);
1403
+ console.log("The service may not have been started yet.");
1404
+ process.exit(1);
1405
+ }
1406
+ const args = options.follow ? ["-f", logPath] : ["-n", "100", logPath];
1407
+ spawnSync("tail", args, { stdio: "inherit" });
1408
+ } else if (platform === "linux") {
1409
+ // Determine if user or system service
1410
+ const isUserService = existsSync(paths.systemdUser);
1411
+ const isSystemService = existsSync(paths.systemdSystem);
1412
+
1413
+ if (!isUserService && !isSystemService) {
1414
+ console.error("Mercury service is not installed");
1415
+ process.exit(1);
1416
+ }
1417
+
1418
+ const args = isUserService
1419
+ ? ["--user", "-u", SERVICE_NAME]
1420
+ : ["-u", SERVICE_NAME];
1421
+ if (options.follow) args.push("-f");
1422
+ spawnSync("journalctl", args, { stdio: "inherit" });
1423
+ } else {
1424
+ console.error(`Unsupported platform: ${platform}`);
1425
+ process.exit(1);
1426
+ }
1427
+ }
1428
+
1429
+ // Service subcommand
1430
+ const serviceCommand = program
1431
+ .command("service")
1432
+ .description("Manage Mercury as a system service");
1433
+
1434
+ serviceCommand
1435
+ .command("install")
1436
+ .description("Install Mercury as a system service")
1437
+ .option(
1438
+ "--user",
1439
+ "Install as user service (default on Linux, no sudo required)",
1440
+ )
1441
+ .action(serviceInstallAction);
1442
+
1443
+ serviceCommand
1444
+ .command("uninstall")
1445
+ .description("Uninstall Mercury service")
1446
+ .action(serviceUninstallAction);
1447
+
1448
+ serviceCommand
1449
+ .command("status")
1450
+ .description("Show service status")
1451
+ .action(serviceStatusAction);
1452
+
1453
+ serviceCommand
1454
+ .command("logs")
1455
+ .description("View service logs")
1456
+ .option("-f, --follow", "Follow log output")
1457
+ .action(serviceLogsAction);
1458
+
1459
+ // ─── Extension management ─────────────────────────────────────────────────
1460
+
1461
+ /**
1462
+ * Resolve an extension source to a local directory path.
1463
+ *
1464
+ * Supports:
1465
+ * - Local paths: `./path/to/extension` or `/absolute/path`
1466
+ * - npm packages: `npm:<package-name>`
1467
+ * - git repos: `git:<url>`
1468
+ *
1469
+ * For npm/git, downloads to a temp dir and returns that path.
1470
+ * Returns { dir, name, cleanup } — call cleanup() to remove temp dirs.
1471
+ */
1472
+ function resolveExtensionSource(source: string): {
1473
+ dir: string;
1474
+ name: string;
1475
+ cleanup: () => void;
1476
+ } {
1477
+ // npm: prefix
1478
+ if (source.startsWith("npm:")) {
1479
+ const pkg = source.slice(4);
1480
+ const maybeName = pkg.includes("/") ? pkg.split("/").pop() : pkg;
1481
+ const name = maybeName || pkg;
1482
+ const tmp = join(tmpdir(), `mercury-ext-npm-${Date.now()}`);
1483
+ mkdirSync(tmp, { recursive: true });
1484
+
1485
+ console.log(`Fetching ${pkg} from npm...`);
1486
+ const packResult = spawnSync(
1487
+ "npm",
1488
+ ["pack", pkg, "--pack-destination", tmp],
1489
+ {
1490
+ stdio: ["pipe", "pipe", "pipe"],
1491
+ cwd: tmp,
1492
+ },
1493
+ );
1494
+ if (packResult.status !== 0) {
1495
+ rmSync(tmp, { recursive: true, force: true });
1496
+ console.error(`Error: failed to fetch npm package "${pkg}"`);
1497
+ console.error(packResult.stderr?.toString().trim());
1498
+ process.exit(1);
1499
+ }
1500
+
1501
+ // Find the tarball
1502
+ const tarballs = readdirSync(tmp).filter((f) => f.endsWith(".tgz"));
1503
+ if (tarballs.length === 0) {
1504
+ rmSync(tmp, { recursive: true, force: true });
1505
+ console.error(`Error: npm pack produced no tarball for "${pkg}"`);
1506
+ process.exit(1);
1507
+ }
1508
+
1509
+ // Extract tarball
1510
+ const tarball = join(tmp, tarballs[0]);
1511
+ const extractDir = join(tmp, "extracted");
1512
+ mkdirSync(extractDir, { recursive: true });
1513
+ const extractResult = spawnSync(
1514
+ "tar",
1515
+ ["xzf", tarball, "-C", extractDir, "--strip-components=1"],
1516
+ {
1517
+ stdio: ["pipe", "pipe", "pipe"],
1518
+ },
1519
+ );
1520
+ if (extractResult.status !== 0) {
1521
+ rmSync(tmp, { recursive: true, force: true });
1522
+ console.error(`Error: failed to extract tarball for "${pkg}"`);
1523
+ process.exit(1);
1524
+ }
1525
+
1526
+ return {
1527
+ dir: extractDir,
1528
+ name,
1529
+ cleanup: () => rmSync(tmp, { recursive: true, force: true }),
1530
+ };
1531
+ }
1532
+
1533
+ // git: prefix — supports optional #subdir (e.g. git:https://repo.git#packages/media)
1534
+ if (source.startsWith("git:")) {
1535
+ const raw = source.slice(4);
1536
+ // Split off optional #subdirectory fragment
1537
+ const hashIdx = raw.indexOf("#");
1538
+ const urlPart = hashIdx >= 0 ? raw.slice(0, hashIdx) : raw;
1539
+ const subdir = hashIdx >= 0 ? raw.slice(hashIdx + 1) : undefined;
1540
+ // Accept git:github.com/user/repo or git:https://github.com/user/repo
1541
+ const gitUrl = urlPart.startsWith("http") ? urlPart : `https://${urlPart}`;
1542
+ const tmp = join(tmpdir(), `mercury-ext-git-${Date.now()}`);
1543
+
1544
+ console.log(`Cloning ${gitUrl}...`);
1545
+ const cloneResult = spawnSync(
1546
+ "git",
1547
+ ["clone", "--depth", "1", gitUrl, tmp],
1548
+ {
1549
+ stdio: ["pipe", "pipe", "pipe"],
1550
+ },
1551
+ );
1552
+ if (cloneResult.status !== 0) {
1553
+ rmSync(tmp, { recursive: true, force: true });
1554
+ console.error(`Error: failed to clone "${gitUrl}"`);
1555
+ console.error(cloneResult.stderr?.toString().trim());
1556
+ process.exit(1);
1557
+ }
1558
+
1559
+ const extDir = subdir ? join(tmp, subdir) : tmp;
1560
+ if (subdir && !existsSync(extDir)) {
1561
+ rmSync(tmp, { recursive: true, force: true });
1562
+ console.error(`Error: subdirectory "${subdir}" not found in cloned repo`);
1563
+ process.exit(1);
1564
+ }
1565
+
1566
+ const name = basename(extDir);
1567
+
1568
+ return {
1569
+ dir: extDir,
1570
+ name,
1571
+ cleanup: () => rmSync(tmp, { recursive: true, force: true }),
1572
+ };
1573
+ }
1574
+
1575
+ // GitHub shorthand: user/repo or user/repo#subdir
1576
+ if (
1577
+ /^[a-zA-Z0-9_-]+\/[a-zA-Z0-9_.-]+/.test(source) &&
1578
+ !source.startsWith("/") &&
1579
+ !source.startsWith(".")
1580
+ ) {
1581
+ const hashIdx = source.indexOf("#");
1582
+ const repoPart = hashIdx >= 0 ? source.slice(0, hashIdx) : source;
1583
+ const subdir = hashIdx >= 0 ? source.slice(hashIdx + 1) : undefined;
1584
+ const gitUrl = `https://github.com/${repoPart}`;
1585
+ const tmp = join(tmpdir(), `mercury-ext-git-${Date.now()}`);
1586
+
1587
+ console.log(`Cloning ${gitUrl}...`);
1588
+ const cloneResult = spawnSync(
1589
+ "git",
1590
+ ["clone", "--depth", "1", gitUrl, tmp],
1591
+ { stdio: ["pipe", "pipe", "pipe"] },
1592
+ );
1593
+ if (cloneResult.status !== 0) {
1594
+ rmSync(tmp, { recursive: true, force: true });
1595
+ console.error(`Error: failed to clone "${gitUrl}"`);
1596
+ console.error(cloneResult.stderr?.toString().trim());
1597
+ process.exit(1);
1598
+ }
1599
+
1600
+ const extDir = subdir ? join(tmp, subdir) : tmp;
1601
+ if (subdir && !existsSync(extDir)) {
1602
+ rmSync(tmp, { recursive: true, force: true });
1603
+ console.error(`Error: subdirectory "${subdir}" not found in cloned repo`);
1604
+ process.exit(1);
1605
+ }
1606
+
1607
+ const name = basename(extDir);
1608
+ return {
1609
+ dir: extDir,
1610
+ name,
1611
+ cleanup: () => rmSync(tmp, { recursive: true, force: true }),
1612
+ };
1613
+ }
1614
+
1615
+ // Local path
1616
+ const absPath = resolve(CWD, source);
1617
+ if (!existsSync(absPath)) {
1618
+ console.error(`Error: path not found: ${source}`);
1619
+ console.error("\nSupported sources:");
1620
+ console.error(" mercury add ./path/to/extension (local path)");
1621
+ console.error(" mercury add npm:<package-name> (npm package)");
1622
+ console.error(" mercury add git:<repo-url> (git repository)");
1623
+ console.error(" mercury add user/repo (GitHub shorthand)");
1624
+ console.error(
1625
+ " mercury add user/repo#subdir (GitHub subdirectory)",
1626
+ );
1627
+ process.exit(1);
1628
+ }
1629
+ if (!existsSync(join(absPath, "index.ts"))) {
1630
+ console.error(`Error: no index.ts found in ${source}`);
1631
+ process.exit(1);
1632
+ }
1633
+
1634
+ const name = basename(absPath);
1635
+ return { dir: absPath, name, cleanup: () => {} };
1636
+ }
1637
+
1638
+ /**
1639
+ * Read extension metadata by doing a quick dry-run load.
1640
+ * Returns partial info for the install report.
1641
+ */
1642
+ async function readExtensionInfo(dir: string): Promise<{
1643
+ hasCli: boolean;
1644
+ hasSkill: boolean;
1645
+ cliNames: string[];
1646
+ permissionRoles?: string[];
1647
+ }> {
1648
+ const { MercuryExtensionAPIImpl } = await import("../extensions/api.js");
1649
+ const { Db } = await import("../storage/db.js");
1650
+
1651
+ // Create a temporary in-memory DB for dry-run
1652
+ const tmpDbPath = join(tmpdir(), `mercury-dryrun-${Date.now()}.db`);
1653
+ const db = new Db(tmpDbPath);
1654
+ try {
1655
+ const name = basename(dir);
1656
+ const api = new MercuryExtensionAPIImpl(name, dir, db);
1657
+ const mod = await import(join(dir, "index.ts"));
1658
+ try {
1659
+ mod.default(api);
1660
+ } catch {
1661
+ // Best-effort — some extensions may fail without full runtime
1662
+ }
1663
+ const meta = api.getMeta();
1664
+ return {
1665
+ hasCli: meta.clis.length > 0,
1666
+ hasSkill: !!meta.skillDir,
1667
+ cliNames: meta.clis.map((c) => c.name),
1668
+ permissionRoles: meta.permission?.defaultRoles,
1669
+ };
1670
+ } finally {
1671
+ db.close();
1672
+ rmSync(tmpDbPath, { force: true });
1673
+ }
1674
+ }
1675
+
1676
+ async function addAction(source: string): Promise<void> {
1677
+ const extensionsDir = getUserExtensionsDir(CWD);
1678
+ mkdirSync(extensionsDir, { recursive: true });
1679
+
1680
+ const { dir: sourceDir, name, cleanup } = resolveExtensionSource(source);
1681
+
1682
+ try {
1683
+ const result = await installExtensionFromDirectory({
1684
+ cwd: CWD,
1685
+ sourceDir,
1686
+ destName: name,
1687
+ });
1688
+ if (!result.ok) {
1689
+ console.error(`Error: ${result.error}`);
1690
+ process.exit(1);
1691
+ }
1692
+
1693
+ const destDir = join(extensionsDir, name);
1694
+
1695
+ // Read extension info for report
1696
+ let info: Awaited<ReturnType<typeof readExtensionInfo>>;
1697
+ try {
1698
+ info = await readExtensionInfo(destDir);
1699
+ } catch {
1700
+ info = { hasCli: false, hasSkill: false, cliNames: [] };
1701
+ }
1702
+
1703
+ const hasSkill = existsSync(join(destDir, "skill", "SKILL.md"));
1704
+
1705
+ // Report
1706
+ console.log(`\n✓ Extension "${name}" installed`);
1707
+ if (info.hasCli) {
1708
+ console.log(
1709
+ ` CLI: ${info.cliNames.join(", ")} (available after image rebuild)`,
1710
+ );
1711
+ }
1712
+ if (hasSkill || info.hasSkill) {
1713
+ console.log(` Skill: ${name} (available to agent)`);
1714
+ }
1715
+ if (info.permissionRoles) {
1716
+ console.log(
1717
+ ` Permission: ${name} (default: ${info.permissionRoles.join(", ")})`,
1718
+ );
1719
+ }
1720
+
1721
+ if (info.hasCli) {
1722
+ console.log("\nRebuild the agent image to include the CLI:");
1723
+ console.log(" mercury build");
1724
+ }
1725
+
1726
+ console.log("\nRestart mercury to activate:");
1727
+ console.log(" mercury service restart");
1728
+ } finally {
1729
+ cleanup();
1730
+ }
1731
+ }
1732
+
1733
+ function removeAction(name: string): void {
1734
+ const result = removeInstalledExtension({ cwd: CWD, name });
1735
+ if (!result.ok) {
1736
+ console.error(`Error: ${result.error}`);
1737
+ process.exit(1);
1738
+ }
1739
+
1740
+ console.log(`✓ Extension "${name}" removed`);
1741
+ console.log("\nRestart mercury to apply:");
1742
+ console.log(" mercury service restart");
1743
+ }
1744
+
1745
+ function extensionsListAction(): void {
1746
+ const userExtDir = getUserExtensionsDir(CWD);
1747
+ const builtinExtDir = join(PACKAGE_ROOT, "resources/extensions");
1748
+
1749
+ const extensions: Array<{
1750
+ name: string;
1751
+ features: string[];
1752
+ description: string;
1753
+ builtin: boolean;
1754
+ }> = [];
1755
+
1756
+ // Scan a directory for extensions
1757
+ function scanDir(dir: string, builtin: boolean): void {
1758
+ if (!existsSync(dir)) return;
1759
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
1760
+ if (!entry.isDirectory()) continue;
1761
+ const name = entry.name;
1762
+ if (!VALID_EXT_NAME_RE.test(name)) continue;
1763
+ if (RESERVED_EXTENSION_NAMES.has(name)) continue;
1764
+
1765
+ const extDir = join(dir, name);
1766
+ if (!existsSync(join(extDir, "index.ts"))) continue;
1767
+
1768
+ const features: string[] = [];
1769
+ if (existsSync(join(extDir, "skill", "SKILL.md"))) features.push("Skill");
1770
+
1771
+ // Read SKILL.md for description
1772
+ let description = "";
1773
+ const skillMd = join(extDir, "skill", "SKILL.md");
1774
+ if (existsSync(skillMd)) {
1775
+ const content = readFileSync(skillMd, "utf-8");
1776
+ const descMatch = content.match(
1777
+ /^description:\s*(.+?)(?:\n[a-z]|\n---)/ms,
1778
+ );
1779
+ if (descMatch) {
1780
+ description = descMatch[1].replace(/\n\s*/g, " ").trim();
1781
+ }
1782
+ }
1783
+
1784
+ extensions.push({ name, features, description, builtin });
1785
+ }
1786
+ }
1787
+
1788
+ scanDir(userExtDir, false);
1789
+ scanDir(builtinExtDir, true);
1790
+
1791
+ if (extensions.length === 0) {
1792
+ console.log("No extensions installed.");
1793
+ console.log("\nInstall one with:");
1794
+ console.log(" mercury add ./path/to/extension");
1795
+ console.log(" mercury add npm:<package>");
1796
+ console.log(" mercury add git:<repo-url>");
1797
+ return;
1798
+ }
1799
+
1800
+ // Sort: user extensions first, then built-in, alphabetically within
1801
+ extensions.sort((a, b) => {
1802
+ if (a.builtin !== b.builtin) return a.builtin ? 1 : -1;
1803
+ return a.name.localeCompare(b.name);
1804
+ });
1805
+
1806
+ // Calculate column widths
1807
+ const nameWidth = Math.max(12, ...extensions.map((e) => e.name.length));
1808
+ const featWidth = Math.max(
1809
+ 10,
1810
+ ...extensions.map((e) => e.features.join(" + ").length || 3),
1811
+ );
1812
+
1813
+ for (const ext of extensions) {
1814
+ const features = ext.features.length > 0 ? ext.features.join(" + ") : "—";
1815
+ const tag = ext.builtin ? " (built-in)" : "";
1816
+ const desc = ext.description
1817
+ ? ` ${ext.description.slice(0, 60)}${ext.description.length > 60 ? "…" : ""}`
1818
+ : "";
1819
+ console.log(
1820
+ `${ext.name.padEnd(nameWidth)} ${features.padEnd(featWidth)}${tag}${desc}`,
1821
+ );
1822
+ }
1823
+ }
1824
+
1825
+ program
1826
+ .command("chat [text...]")
1827
+ .description("Send a message to Mercury and get a reply")
1828
+ .option("-p, --port <port>", "Mercury server port", "8787")
1829
+ .option("-s, --space <spaceId>", "Space to route the message to", "main")
1830
+ .option("-f, --file <paths...>", "Attach files to the message")
1831
+ .option("--caller <callerId>", "Caller ID", "cli:user")
1832
+ .option("--json", "Output raw JSON response")
1833
+ .action(
1834
+ async (
1835
+ textParts: string[],
1836
+ options: {
1837
+ port: string;
1838
+ space: string;
1839
+ file?: string[];
1840
+ caller: string;
1841
+ json?: boolean;
1842
+ },
1843
+ ) => {
1844
+ let text: string;
1845
+ if (textParts.length > 0) {
1846
+ text = textParts.join(" ");
1847
+ } else if (!process.stdin.isTTY) {
1848
+ text = readFileSync("/dev/stdin", "utf-8").trim();
1849
+ } else {
1850
+ console.error("Usage: mercury chat <message>");
1851
+ console.error(' echo "message" | mercury chat');
1852
+ process.exit(1);
1853
+ }
1854
+
1855
+ if (!text) {
1856
+ console.error("Error: empty message");
1857
+ process.exit(1);
1858
+ }
1859
+
1860
+ const url = `http://localhost:${options.port}/chat`;
1861
+ const body: Record<string, unknown> = {
1862
+ text,
1863
+ callerId: options.caller,
1864
+ spaceId: options.space,
1865
+ };
1866
+
1867
+ if (options.file && options.file.length > 0) {
1868
+ const files: Array<{ name: string; data: string }> = [];
1869
+ for (const filePath of options.file) {
1870
+ const abs = resolve(CWD, filePath);
1871
+ if (!existsSync(abs)) {
1872
+ console.error(`Error: file not found: ${filePath}`);
1873
+ process.exit(1);
1874
+ }
1875
+ files.push({
1876
+ name: basename(abs),
1877
+ data: readFileSync(abs).toString("base64"),
1878
+ });
1879
+ }
1880
+ body.files = files;
1881
+ }
1882
+
1883
+ try {
1884
+ const res = await fetch(url, {
1885
+ method: "POST",
1886
+ headers: { "Content-Type": "application/json" },
1887
+ body: JSON.stringify(body),
1888
+ });
1889
+
1890
+ if (!res.ok) {
1891
+ const err = await res.json().catch(() => ({ error: res.statusText }));
1892
+ console.error(
1893
+ `Error: ${(err as { error?: string }).error || res.statusText}`,
1894
+ );
1895
+ process.exit(1);
1896
+ }
1897
+
1898
+ const data = (await res.json()) as {
1899
+ reply: string;
1900
+ files: Array<{
1901
+ filename: string;
1902
+ mimeType: string;
1903
+ sizeBytes: number;
1904
+ data: string;
1905
+ }>;
1906
+ error?: string;
1907
+ };
1908
+
1909
+ if (options.json) {
1910
+ console.log(JSON.stringify(data, null, 2));
1911
+ } else {
1912
+ if (data.reply) console.log(data.reply);
1913
+ if (data.files && data.files.length > 0) {
1914
+ for (const f of data.files) {
1915
+ const outPath = join(CWD, f.filename);
1916
+ writeFileSync(outPath, Buffer.from(f.data, "base64"));
1917
+ const kb = (f.sizeBytes / 1024).toFixed(1);
1918
+ console.error(`→ ${outPath} (${kb} KB)`);
1919
+ }
1920
+ }
1921
+ }
1922
+ } catch (err) {
1923
+ if (
1924
+ err instanceof TypeError &&
1925
+ (err.message.includes("fetch") ||
1926
+ err.message.includes("ECONNREFUSED"))
1927
+ ) {
1928
+ console.error(
1929
+ `Error: cannot connect to Mercury at localhost:${options.port}`,
1930
+ );
1931
+ console.error("Is Mercury running? Try: mercury service status");
1932
+ } else {
1933
+ console.error(
1934
+ `Error: ${err instanceof Error ? err.message : String(err)}`,
1935
+ );
1936
+ }
1937
+ process.exit(1);
1938
+ }
1939
+ },
1940
+ );
1941
+
1942
+ // ─── Profile management ─────────────────────────────────────────────────
1943
+
1944
+ const profilesCommand = program
1945
+ .command("profiles")
1946
+ .description("Manage agent profiles");
1947
+
1948
+ profilesCommand
1949
+ .command("list")
1950
+ .description("List available built-in profiles")
1951
+ .action(async () => {
1952
+ const { listBuiltinProfiles } = await import("../core/profiles.js");
1953
+ const profiles = listBuiltinProfiles(PROFILES_DIR);
1954
+
1955
+ if (profiles.length === 0) {
1956
+ console.log("No built-in profiles found.");
1957
+ return;
1958
+ }
1959
+
1960
+ console.log("Available profiles:\n");
1961
+ const nameWidth = Math.max(...profiles.map((p) => p.name.length), 10);
1962
+ for (const profile of profiles) {
1963
+ const desc = profile.description || "";
1964
+ const extCount = profile.extensions.length;
1965
+ const extras =
1966
+ extCount > 0
1967
+ ? ` (${extCount} extension${extCount > 1 ? "s" : ""})`
1968
+ : "";
1969
+ console.log(` ${profile.name.padEnd(nameWidth)} ${desc}${extras}`);
1970
+ }
1971
+
1972
+ console.log("\nUse with: mercury setup --profile <name>");
1973
+ });
1974
+
1975
+ profilesCommand
1976
+ .command("show <name>")
1977
+ .description("Show details of a profile")
1978
+ .action(async (name: string) => {
1979
+ const { loadProfileFromDir } = await import("../core/profiles.js");
1980
+ const profileDir = join(PROFILES_DIR, name);
1981
+
1982
+ if (!existsSync(join(profileDir, "mercury-profile.yaml"))) {
1983
+ console.error(`Profile not found: ${name}`);
1984
+ console.log("\nRun 'mercury profiles list' to see available profiles.");
1985
+ process.exit(1);
1986
+ }
1987
+
1988
+ const profile = loadProfileFromDir(profileDir);
1989
+ console.log(`Profile: ${profile.name}`);
1990
+ if (profile.description) console.log(`Description: ${profile.description}`);
1991
+ console.log(`Version: ${profile.version}`);
1992
+
1993
+ if (profile.defaults) {
1994
+ console.log("\nDefaults:");
1995
+ for (const [key, value] of Object.entries(profile.defaults)) {
1996
+ if (value) console.log(` ${key}: ${value}`);
1997
+ }
1998
+ }
1999
+
2000
+ if (profile.extensions.length > 0) {
2001
+ console.log("\nExtensions:");
2002
+ for (const ext of profile.extensions) {
2003
+ console.log(` ${ext.name} (${ext.source})`);
2004
+ }
2005
+ }
2006
+
2007
+ if (profile.env.length > 0) {
2008
+ console.log("\nRequired env vars:");
2009
+ for (const v of profile.env) {
2010
+ const req = v.required ? " (required)" : " (optional)";
2011
+ console.log(
2012
+ ` ${v.key}${req}${v.description ? ` — ${v.description}` : ""}`,
2013
+ );
2014
+ }
2015
+ }
2016
+ });
2017
+
2018
+ profilesCommand
2019
+ .command("export <output-dir>")
2020
+ .description("Export the current project as a reusable profile")
2021
+ .action(async (outputDir: string) => {
2022
+ const absOutput = resolve(CWD, outputDir);
2023
+ mkdirSync(absOutput, { recursive: true });
2024
+
2025
+ // Read merged config (mercury.yaml + .env) for defaults
2026
+ const envPath = join(CWD, ".env");
2027
+ if (existsSync(envPath)) Object.assign(process.env, loadEnvFile(envPath));
2028
+ const exportCfg = loadConfig();
2029
+
2030
+ const projectName = basename(CWD)
2031
+ .toLowerCase()
2032
+ .replace(/[^a-z0-9-]/g, "-");
2033
+
2034
+ // Copy AGENTS.md if present
2035
+ const agentsMd = join(CWD, ".mercury/global/AGENTS.md");
2036
+ if (existsSync(agentsMd)) {
2037
+ copyFileSync(agentsMd, join(absOutput, "AGENTS.md"));
2038
+ }
2039
+
2040
+ // Copy extensions
2041
+ const userExtDir = join(CWD, ".mercury/extensions");
2042
+ if (existsSync(userExtDir)) {
2043
+ cpSync(userExtDir, join(absOutput, "extensions"), { recursive: true });
2044
+ }
2045
+
2046
+ // Generate manifest
2047
+ const extNames: string[] = [];
2048
+ if (existsSync(userExtDir)) {
2049
+ for (const entry of readdirSync(userExtDir, { withFileTypes: true })) {
2050
+ if (entry.isDirectory()) extNames.push(entry.name);
2051
+ }
2052
+ }
2053
+
2054
+ const extensions = extNames.map((name) => ({
2055
+ name,
2056
+ source: `./extensions/${name}`,
2057
+ }));
2058
+
2059
+ const yaml = [
2060
+ `name: ${projectName}`,
2061
+ `description: Exported from ${basename(CWD)}`,
2062
+ "version: 0.1.0",
2063
+ "",
2064
+ existsSync(agentsMd) ? "agents_md: ./AGENTS.md" : "",
2065
+ "",
2066
+ extensions.length > 0
2067
+ ? `extensions:\n${extensions.map((e) => ` - name: ${e.name}\n source: "${e.source}"`).join("\n")}`
2068
+ : "extensions: []",
2069
+ "",
2070
+ "env: []",
2071
+ "",
2072
+ "defaults:",
2073
+ ` model_provider: ${exportCfg.modelProvider}`,
2074
+ ` model: ${exportCfg.model}`,
2075
+ exportCfg.triggerPatterns !== "@Pi,Pi"
2076
+ ? ` trigger_patterns: "${exportCfg.triggerPatterns}"`
2077
+ : "",
2078
+ exportCfg.botUsername !== "mercury"
2079
+ ? ` bot_username: ${exportCfg.botUsername}`
2080
+ : "",
2081
+ ]
2082
+ .filter(Boolean)
2083
+ .join("\n");
2084
+
2085
+ writeFileSync(join(absOutput, "mercury-profile.yaml"), `${yaml}\n`);
2086
+
2087
+ console.log(`Exported profile to ${absOutput}/`);
2088
+ console.log("\nContents:");
2089
+ console.log(" mercury-profile.yaml");
2090
+ if (existsSync(agentsMd)) console.log(" AGENTS.md");
2091
+ if (extNames.length > 0) {
2092
+ for (const name of extNames) {
2093
+ console.log(` extensions/${name}/`);
2094
+ }
2095
+ }
2096
+ console.log(`\nUse with: mercury setup --profile ${absOutput}`);
2097
+ });
2098
+
2099
+ const spacesCommand = program.command("spaces").description("Manage spaces");
2100
+
2101
+ spacesCommand
2102
+ .command("list")
2103
+ .description("List all spaces")
2104
+ .action(() => {
2105
+ const spaces = withProjectDb((db) => db.listSpaces());
2106
+ if (spaces.length === 0) {
2107
+ console.log("No spaces found.");
2108
+ return;
2109
+ }
2110
+ for (const space of spaces) {
2111
+ const tags = space.tags ? ` [${space.tags}]` : "";
2112
+ console.log(`${space.id}\t${space.name}${tags}`);
2113
+ }
2114
+ });
2115
+
2116
+ spacesCommand
2117
+ .command("create <id>")
2118
+ .description("Create a new space")
2119
+ .option("-n, --name <name>", "Display name (defaults to id)")
2120
+ .option("-t, --tags <tags>", "Comma-separated tags")
2121
+ .action((id: string, options: { name?: string; tags?: string }) => {
2122
+ const name = options.name?.trim() || id;
2123
+ const space = withProjectDb((db) => db.createSpace(id, name, options.tags));
2124
+ console.log(`Created space '${space.id}' (${space.name})`);
2125
+ });
2126
+
2127
+ spacesCommand
2128
+ .command("delete <id>")
2129
+ .description("Delete a space and all its data")
2130
+ .option("-y, --yes", "Skip confirmation")
2131
+ .action((id: string, options: { yes?: boolean }) => {
2132
+ const space = withProjectDb((db) => db.getSpace(id));
2133
+ if (!space) {
2134
+ console.error(`Error: space not found: ${id}`);
2135
+ process.exit(1);
2136
+ }
2137
+
2138
+ if (!options.yes) {
2139
+ const rl = require("node:readline").createInterface({
2140
+ input: process.stdin,
2141
+ output: process.stdout,
2142
+ });
2143
+ rl.question(
2144
+ `Delete space '${space.id}' (${space.name}) and all its data? [y/N] `,
2145
+ (answer: string) => {
2146
+ rl.close();
2147
+ if (answer.trim().toLowerCase() !== "y") {
2148
+ console.log("Aborted.");
2149
+ return;
2150
+ }
2151
+ const result = withProjectDb((db) => db.deleteSpace(id));
2152
+ const spacesDir = join(CWD, getProjectDataDir(CWD), "spaces");
2153
+ try {
2154
+ removeSpaceWorkspace(spacesDir, id);
2155
+ } catch (err) {
2156
+ console.warn(
2157
+ `Warning: could not remove workspace directory for space '${id}':`,
2158
+ err instanceof Error ? err.message : err,
2159
+ );
2160
+ }
2161
+ console.log(
2162
+ `Deleted space '${id}' — removed ${result.removed.messages} messages, ${result.removed.tasks} tasks`,
2163
+ );
2164
+ },
2165
+ );
2166
+ return;
2167
+ }
2168
+
2169
+ const result = withProjectDb((db) => db.deleteSpace(id));
2170
+ const spacesDir = join(CWD, getProjectDataDir(CWD), "spaces");
2171
+ try {
2172
+ removeSpaceWorkspace(spacesDir, id);
2173
+ } catch (err) {
2174
+ console.warn(
2175
+ `Warning: could not remove workspace directory for space '${id}':`,
2176
+ err instanceof Error ? err.message : err,
2177
+ );
2178
+ }
2179
+ console.log(
2180
+ `Deleted space '${id}' — removed ${result.removed.messages} messages, ${result.removed.tasks} tasks`,
2181
+ );
2182
+ });
2183
+
2184
+ program
2185
+ .command("conversations")
2186
+ .alias("convos")
2187
+ .description("List conversations")
2188
+ .option("--unlinked", "Show only unlinked conversations")
2189
+ .action((options: { unlinked?: boolean }) => {
2190
+ const conversations = withProjectDb((db) =>
2191
+ db.listConversations(options.unlinked ? { linked: false } : undefined),
2192
+ );
2193
+ if (conversations.length === 0) {
2194
+ console.log("No conversations found.");
2195
+ return;
2196
+ }
2197
+ for (const convo of conversations) {
2198
+ const title = convo.observedTitle || convo.externalId;
2199
+ const status = convo.spaceId ? `→ ${convo.spaceId}` : "(unlinked)";
2200
+ console.log(`${convo.id}\t${convo.platform}\t${title}\t${status}`);
2201
+ }
2202
+ });
2203
+
2204
+ program
2205
+ .command("link <conversation> <space>")
2206
+ .description("Link a conversation to a space")
2207
+ .action((conversation: string, space: string) => {
2208
+ withProjectDb((db) => {
2209
+ const targetSpace = db.getSpace(space);
2210
+ if (!targetSpace) {
2211
+ console.error(`Error: space not found: ${space}`);
2212
+ process.exit(1);
2213
+ }
2214
+
2215
+ let target = Number.isFinite(Number(conversation))
2216
+ ? db.listConversations().find((c) => c.id === Number(conversation))
2217
+ : null;
2218
+
2219
+ if (!target) {
2220
+ const q = conversation.toLowerCase();
2221
+ const matches = db.listConversations().filter((c) => {
2222
+ const observed = c.observedTitle?.toLowerCase() ?? "";
2223
+ const external = c.externalId.toLowerCase();
2224
+ return observed.includes(q) || external.includes(q);
2225
+ });
2226
+
2227
+ if (matches.length === 0) {
2228
+ console.error(`Error: conversation not found: ${conversation}`);
2229
+ process.exit(1);
2230
+ }
2231
+ if (matches.length > 1) {
2232
+ console.error("Error: conversation is ambiguous. Matches:");
2233
+ for (const match of matches) {
2234
+ const title = match.observedTitle || match.externalId;
2235
+ const status = match.spaceId ? `→ ${match.spaceId}` : "(unlinked)";
2236
+ console.error(
2237
+ ` ${match.id}\t${match.platform}\t${title}\t${status}`,
2238
+ );
2239
+ }
2240
+ process.exit(1);
2241
+ }
2242
+ target = matches[0];
2243
+ }
2244
+
2245
+ const ok = db.linkConversation(target.id, space);
2246
+ if (!ok) {
2247
+ console.error(`Error: failed to link conversation ${target.id}`);
2248
+ process.exit(1);
2249
+ }
2250
+
2251
+ const title = target.observedTitle || target.externalId;
2252
+ console.log(`Linked conversation ${target.id} (${title}) → ${space}`);
2253
+ });
2254
+ });
2255
+
2256
+ // Extension commands
2257
+ program
2258
+ .command("add <source>")
2259
+ .description(
2260
+ "Install an extension (local path, npm:<pkg>, git:<url>, or user/repo)",
2261
+ )
2262
+ .action(addAction);
2263
+
2264
+ program
2265
+ .command("remove <name>")
2266
+ .description("Remove an installed extension")
2267
+ .action(removeAction);
2268
+
2269
+ const extCommand = program
2270
+ .command("extensions")
2271
+ .alias("ext")
2272
+ .description("Manage extensions");
2273
+
2274
+ extCommand
2275
+ .command("list")
2276
+ .description("List installed extensions")
2277
+ .action(extensionsListAction);
2278
+
2279
+ extCommand
2280
+ .command("create <name>")
2281
+ .description("Scaffold a new extension")
2282
+ .action((name: string) => {
2283
+ if (!VALID_EXT_NAME_RE.test(name)) {
2284
+ console.error(
2285
+ `Error: invalid extension name "${name}" (must be lowercase alphanumeric + hyphens)`,
2286
+ );
2287
+ process.exit(1);
2288
+ }
2289
+ if (RESERVED_EXTENSION_NAMES.has(name)) {
2290
+ console.error(`Error: "${name}" is a reserved built-in command name`);
2291
+ process.exit(1);
2292
+ }
2293
+
2294
+ const extensionsDir = getUserExtensionsDir(CWD);
2295
+ const extDir = join(extensionsDir, name);
2296
+
2297
+ if (existsSync(extDir)) {
2298
+ console.error(`Error: extension "${name}" already exists at ${extDir}`);
2299
+ process.exit(1);
2300
+ }
2301
+
2302
+ mkdirSync(extDir, { recursive: true });
2303
+ mkdirSync(join(extDir, "skill"), { recursive: true });
2304
+
2305
+ // index.ts scaffold
2306
+ writeFileSync(
2307
+ join(extDir, "index.ts"),
2308
+ `import type { MercuryExtensionAPI } from "mercury-agent";
2309
+
2310
+ export default function (mercury: MercuryExtensionAPI) {
2311
+ // Register a skill for the AI agent
2312
+ mercury.skill(import.meta.dir);
2313
+
2314
+ // Register CLI commands available inside the container
2315
+ // mercury.cli({
2316
+ // name: "${name}",
2317
+ // description: "Description of your CLI",
2318
+ // install: ["npm install -g your-tool"],
2319
+ // });
2320
+
2321
+ // Register environment variables your extension needs
2322
+ // mercury.env({
2323
+ // key: "MERCURY_${name.toUpperCase().replace(/-/g, "_")}_API_KEY",
2324
+ // description: "API key for ${name}",
2325
+ // required: true,
2326
+ // });
2327
+
2328
+ // Register hooks
2329
+ // mercury.hook("before_container", async (ctx) => {
2330
+ // ctx.env["MY_VAR"] = "value";
2331
+ // });
2332
+ }
2333
+ `,
2334
+ );
2335
+
2336
+ // SKILL.md scaffold
2337
+ writeFileSync(
2338
+ join(extDir, "skill", "SKILL.md"),
2339
+ `---
2340
+ name: ${name}
2341
+ description: TODO — describe what this extension does
2342
+ ---
2343
+
2344
+ # ${name}
2345
+
2346
+ ## When to Use
2347
+
2348
+ Describe when the agent should use this skill.
2349
+
2350
+ ## Instructions
2351
+
2352
+ Provide instructions for the agent on how to use this extension.
2353
+ `,
2354
+ );
2355
+
2356
+ // package.json
2357
+ writeFileSync(
2358
+ join(extDir, "package.json"),
2359
+ `${JSON.stringify(
2360
+ {
2361
+ name: `mercury-ext-${name}`,
2362
+ version: "0.1.0",
2363
+ type: "module",
2364
+ main: "index.ts",
2365
+ description: `Mercury extension: ${name}`,
2366
+ keywords: ["mercury", "extension"],
2367
+ files: ["index.ts", "skill/"],
2368
+ },
2369
+ null,
2370
+ 2,
2371
+ )}\n`,
2372
+ );
2373
+
2374
+ console.log(`Created extension scaffold at ${extDir}/`);
2375
+ console.log("\nFiles:");
2376
+ console.log(` ${name}/index.ts — Extension entry point`);
2377
+ console.log(` ${name}/skill/SKILL.md — Agent skill document`);
2378
+ console.log(` ${name}/package.json — Package manifest`);
2379
+ console.log("\nNext steps:");
2380
+ console.log(` 1. Edit ${name}/index.ts to add your extension logic`);
2381
+ console.log(` 2. Edit ${name}/skill/SKILL.md with agent instructions`);
2382
+ console.log(
2383
+ ` 3. Run 'mercury ext validate ${name}' to check your extension`,
2384
+ );
2385
+ console.log(" 4. Restart Mercury to activate");
2386
+ });
2387
+
2388
+ extCommand
2389
+ .command("validate <name>")
2390
+ .description("Validate an extension for correctness")
2391
+ .action(async (name: string) => {
2392
+ const extensionsDir = getUserExtensionsDir(CWD);
2393
+ const extDir = join(extensionsDir, name);
2394
+
2395
+ if (!existsSync(extDir)) {
2396
+ console.error(`Error: extension "${name}" not found at ${extDir}`);
2397
+ process.exit(1);
2398
+ }
2399
+
2400
+ console.log(`Validating extension "${name}"...\n`);
2401
+
2402
+ let errors = 0;
2403
+ let warnings = 0;
2404
+
2405
+ // Check index.ts
2406
+ if (existsSync(join(extDir, "index.ts"))) {
2407
+ console.log(" ✅ index.ts found");
2408
+ } else {
2409
+ console.log(" ❌ index.ts missing (required)");
2410
+ errors++;
2411
+ }
2412
+
2413
+ // Check skill
2414
+ if (existsSync(join(extDir, "skill", "SKILL.md"))) {
2415
+ const skillContent = readFileSync(
2416
+ join(extDir, "skill", "SKILL.md"),
2417
+ "utf-8",
2418
+ );
2419
+ if (skillContent.includes("TODO")) {
2420
+ console.log(" ⚠️ skill/SKILL.md contains TODO placeholders");
2421
+ warnings++;
2422
+ } else {
2423
+ console.log(" ✅ skill/SKILL.md found");
2424
+ }
2425
+ } else {
2426
+ console.log(" ⚠️ skill/SKILL.md not found (optional but recommended)");
2427
+ warnings++;
2428
+ }
2429
+
2430
+ // Check package.json
2431
+ if (existsSync(join(extDir, "package.json"))) {
2432
+ console.log(" ✅ package.json found");
2433
+ } else {
2434
+ console.log(" ⚠️ package.json missing (needed for npm publish)");
2435
+ warnings++;
2436
+ }
2437
+
2438
+ // Dry-run load
2439
+ if (existsSync(join(extDir, "index.ts"))) {
2440
+ const loadErr = await checkExtensionIndexLoads(extDir, name);
2441
+ if (loadErr) {
2442
+ console.log(` ❌ Extension failed to load: ${loadErr}`);
2443
+ errors++;
2444
+ } else {
2445
+ console.log(" ✅ Extension loads successfully");
2446
+ }
2447
+ }
2448
+
2449
+ // Name validation
2450
+ if (!VALID_EXT_NAME_RE.test(name)) {
2451
+ console.log(
2452
+ " ❌ Extension name is invalid (must be lowercase alphanumeric + hyphens)",
2453
+ );
2454
+ errors++;
2455
+ } else {
2456
+ console.log(" ✅ Extension name is valid");
2457
+ }
2458
+
2459
+ if (RESERVED_EXTENSION_NAMES.has(name)) {
2460
+ console.log(" ❌ Extension name conflicts with a reserved command");
2461
+ errors++;
2462
+ }
2463
+
2464
+ console.log(`\n─────────────────────────────────`);
2465
+ console.log(` ${errors} errors ${warnings} warnings`);
2466
+ if (errors > 0) {
2467
+ console.log("\n Fix the errors above before publishing.");
2468
+ process.exit(1);
2469
+ } else {
2470
+ console.log("\n Extension is valid! ✅");
2471
+ }
2472
+ });
2473
+
2474
+ extCommand
2475
+ .command("test <name>")
2476
+ .description("Test an extension by performing a dry-run load")
2477
+ .action(async (name: string) => {
2478
+ const extensionsDir = getUserExtensionsDir(CWD);
2479
+ const extDir = join(extensionsDir, name);
2480
+
2481
+ if (!existsSync(extDir)) {
2482
+ console.error(`Error: extension "${name}" not found at ${extDir}`);
2483
+ process.exit(1);
2484
+ }
2485
+
2486
+ console.log(`Testing extension "${name}"...\n`);
2487
+
2488
+ try {
2489
+ const info = await readExtensionInfo(extDir);
2490
+
2491
+ console.log(` Extension loaded successfully`);
2492
+ console.log(
2493
+ ` CLIs: ${info.cliNames.length > 0 ? info.cliNames.join(", ") : "none"}`,
2494
+ );
2495
+ console.log(` Skill: ${info.hasSkill ? "yes" : "no"}`);
2496
+ if (info.permissionRoles) {
2497
+ console.log(` Permission roles: ${info.permissionRoles.join(", ")}`);
2498
+ }
2499
+ console.log("\n Extension test passed! ✅");
2500
+ } catch (err) {
2501
+ console.error(
2502
+ `\n Extension test failed: ${err instanceof Error ? err.message : String(err)}`,
2503
+ );
2504
+ process.exit(1);
2505
+ }
2506
+ });
2507
+
2508
+ program.parse();