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.
- package/LICENSE +22 -0
- package/README.md +438 -0
- package/container/Dockerfile +127 -0
- package/container/Dockerfile.base +109 -0
- package/container/Dockerfile.power +17 -0
- package/container/agent-package.json +8 -0
- package/container/build.sh +54 -0
- package/docs/TODOS.md +147 -0
- package/docs/auth/dashboard.md +28 -0
- package/docs/auth/overview.md +109 -0
- package/docs/auth/whatsapp.md +173 -0
- package/docs/configuration.md +54 -0
- package/docs/container-lifecycle.md +349 -0
- package/docs/context-architecture.md +87 -0
- package/docs/deployment.md +199 -0
- package/docs/extensions.md +375 -0
- package/docs/graceful-shutdown.md +62 -0
- package/docs/kb-distillation.md +77 -0
- package/docs/media/overview.md +140 -0
- package/docs/media/whatsapp.md +171 -0
- package/docs/memory.md +137 -0
- package/docs/permissions.md +217 -0
- package/docs/pipeline.md +228 -0
- package/docs/prd-chat-memory.md +76 -0
- package/docs/prd-config-load.md +82 -0
- package/docs/rate-limiting.md +166 -0
- package/docs/scheduler.md +288 -0
- package/docs/setup-discord.md +100 -0
- package/docs/setup-slack.md +119 -0
- package/docs/setup-whatsapp.md +94 -0
- package/docs/subagents.md +166 -0
- package/docs/web-search.md +62 -0
- package/examples/extensions/README.md +12 -0
- package/examples/extensions/charts/index.ts +13 -0
- package/examples/extensions/charts/skill/SKILL.md +98 -0
- package/examples/extensions/gws/README.md +52 -0
- package/examples/extensions/gws/index.ts +106 -0
- package/examples/extensions/gws/skill/SKILL.md +57 -0
- package/examples/extensions/gws/skill/references/calendar.md +101 -0
- package/examples/extensions/gws/skill/references/docs.md +65 -0
- package/examples/extensions/gws/skill/references/drive.md +79 -0
- package/examples/extensions/gws/skill/references/gmail.md +85 -0
- package/examples/extensions/gws/skill/references/sheets.md +60 -0
- package/examples/extensions/napkin/index.ts +821 -0
- package/examples/extensions/napkin/prompts/consolidation-monthly.md +73 -0
- package/examples/extensions/napkin/prompts/consolidation-weekly.md +67 -0
- package/examples/extensions/napkin/prompts/kb-distillation.md +176 -0
- package/examples/extensions/napkin/skill/SKILL.md +728 -0
- package/examples/extensions/pdf/index.ts +23 -0
- package/examples/extensions/pdf/skill/LICENSE.txt +30 -0
- package/examples/extensions/pdf/skill/SKILL.md +314 -0
- package/examples/extensions/pdf/skill/forms.md +294 -0
- package/examples/extensions/pdf/skill/reference.md +612 -0
- package/examples/extensions/pdf/skill/scripts/check_bounding_boxes.py +65 -0
- package/examples/extensions/pdf/skill/scripts/check_fillable_fields.py +11 -0
- package/examples/extensions/pdf/skill/scripts/convert_pdf_to_images.py +33 -0
- package/examples/extensions/pdf/skill/scripts/create_validation_image.py +37 -0
- package/examples/extensions/pdf/skill/scripts/extract_form_field_info.py +122 -0
- package/examples/extensions/pdf/skill/scripts/extract_form_structure.py +115 -0
- package/examples/extensions/pdf/skill/scripts/fill_fillable_fields.py +98 -0
- package/examples/extensions/pdf/skill/scripts/fill_pdf_form_with_annotations.py +107 -0
- package/examples/extensions/permission-guard/index.ts +65 -0
- package/examples/extensions/pinchtab/index.ts +199 -0
- package/examples/extensions/pinchtab/lib/session-injector.ts +144 -0
- package/examples/extensions/pinchtab/skill/SKILL.md +224 -0
- package/examples/extensions/pinchtab/skill/TRUST.md +69 -0
- package/examples/extensions/pinchtab/skill/references/api.md +297 -0
- package/examples/extensions/pinchtab/skill/references/env.md +45 -0
- package/examples/extensions/pinchtab/skill/references/profiles.md +107 -0
- package/examples/extensions/tradestation/host/refresh.ts +102 -0
- package/examples/extensions/tradestation/index.ts +153 -0
- package/examples/extensions/tradestation/skill/SKILL.md +67 -0
- package/examples/extensions/tradestation/skill/scripts/ts-cli.ts +111 -0
- package/examples/extensions/voice-synth/index.ts +94 -0
- package/examples/extensions/voice-synth/skill/SKILL.md +38 -0
- package/examples/extensions/voice-transcribe/index.ts +381 -0
- package/examples/extensions/voice-transcribe/requirements.txt +8 -0
- package/examples/extensions/voice-transcribe/scripts/transcribe.py +179 -0
- package/examples/extensions/voice-transcribe/skill/SKILL.md +53 -0
- package/examples/extensions/web-search/index.ts +22 -0
- package/examples/extensions/web-search/skill/SKILL.md +114 -0
- package/examples/extensions/web-search/skill/references/apartments.md +178 -0
- package/examples/extensions/web-search/skill/references/car-purchase.md +132 -0
- package/examples/extensions/web-search/skill/references/car-rental.md +113 -0
- package/examples/extensions/web-search/skill/references/flights.md +133 -0
- package/examples/extensions/web-search/skill/references/hotels.md +148 -0
- package/examples/extensions/yahoo-mail/cli/bun.lock +66 -0
- package/examples/extensions/yahoo-mail/cli/package.json +13 -0
- package/examples/extensions/yahoo-mail/cli/ymail.mjs +353 -0
- package/examples/extensions/yahoo-mail/index.ts +57 -0
- package/examples/extensions/yahoo-mail/skill/SKILL.md +78 -0
- package/package.json +106 -0
- package/resources/agents/explore.md +50 -0
- package/resources/agents/worker.md +24 -0
- package/resources/builtin-extensions.txt +3 -0
- package/resources/connection-env-vars.json +25 -0
- package/resources/extensions/.gitkeep +0 -0
- package/resources/pi-extensions/subagent/agents.ts +126 -0
- package/resources/pi-extensions/subagent/index.ts +964 -0
- package/resources/profiles/coding/AGENTS.md +43 -0
- package/resources/profiles/coding/mercury-profile.yaml +15 -0
- package/resources/profiles/general/AGENTS.md +31 -0
- package/resources/profiles/general/mercury-profile.yaml +15 -0
- package/resources/profiles/research/AGENTS.md +40 -0
- package/resources/profiles/research/mercury-profile.yaml +15 -0
- package/resources/skills/config/SKILL.md +25 -0
- package/resources/skills/context/SKILL.md +33 -0
- package/resources/skills/conversation-recap/SKILL.md +19 -0
- package/resources/skills/media/SKILL.md +27 -0
- package/resources/skills/mutes/SKILL.md +31 -0
- package/resources/skills/permissions/SKILL.md +19 -0
- package/resources/skills/preferences/SKILL.md +31 -0
- package/resources/skills/recall/SKILL.md +24 -0
- package/resources/skills/roles/SKILL.md +18 -0
- package/resources/skills/spaces/SKILL.md +18 -0
- package/resources/skills/tasks/SKILL.md +45 -0
- package/resources/templates/AGENTS.md +157 -0
- package/resources/templates/env.template +34 -0
- package/resources/templates/mercury.example.yaml +75 -0
- package/src/adapters/discord-native.ts +534 -0
- package/src/adapters/discord.ts +38 -0
- package/src/adapters/setup.ts +89 -0
- package/src/adapters/slack.ts +9 -0
- package/src/adapters/whatsapp-media.ts +337 -0
- package/src/adapters/whatsapp.ts +629 -0
- package/src/agent/api-socket.ts +127 -0
- package/src/agent/container-entry.ts +967 -0
- package/src/agent/container-error.ts +49 -0
- package/src/agent/container-runner.ts +1272 -0
- package/src/agent/model-capabilities-core.ts +23 -0
- package/src/agent/model-capabilities.ts +231 -0
- package/src/agent/pi-failure-class.ts +83 -0
- package/src/agent/pi-jsonl-parser.ts +306 -0
- package/src/agent/preferences-prompt.ts +20 -0
- package/src/agent/user-error-messages.ts +78 -0
- package/src/bridges/discord.ts +171 -0
- package/src/bridges/slack.ts +177 -0
- package/src/bridges/teams.ts +160 -0
- package/src/bridges/telegram.ts +571 -0
- package/src/bridges/whatsapp.ts +290 -0
- package/src/chat-shim.ts +259 -0
- package/src/cli/mercury.ts +2508 -0
- package/src/cli/mrctl-http.ts +27 -0
- package/src/cli/mrctl.ts +611 -0
- package/src/cli/whatsapp-auth.ts +260 -0
- package/src/config-file.ts +397 -0
- package/src/config-model-chain.ts +30 -0
- package/src/config.ts +316 -0
- package/src/core/api-types.ts +58 -0
- package/src/core/api.ts +105 -0
- package/src/core/commands.ts +76 -0
- package/src/core/conversation.ts +47 -0
- package/src/core/handler.ts +206 -0
- package/src/core/media.ts +200 -0
- package/src/core/mute-duration.ts +22 -0
- package/src/core/outbox.ts +76 -0
- package/src/core/permissions.ts +192 -0
- package/src/core/profiles.ts +245 -0
- package/src/core/rate-limiter.ts +127 -0
- package/src/core/router.ts +191 -0
- package/src/core/routes/chat.ts +172 -0
- package/src/core/routes/config-builtin.ts +107 -0
- package/src/core/routes/config.ts +81 -0
- package/src/core/routes/connections.ts +190 -0
- package/src/core/routes/console.ts +668 -0
- package/src/core/routes/control.ts +46 -0
- package/src/core/routes/conversations.ts +66 -0
- package/src/core/routes/dashboard.ts +2491 -0
- package/src/core/routes/extensions.ts +37 -0
- package/src/core/routes/index.ts +14 -0
- package/src/core/routes/media.ts +72 -0
- package/src/core/routes/messages.ts +37 -0
- package/src/core/routes/mutes.ts +89 -0
- package/src/core/routes/prefs.ts +95 -0
- package/src/core/routes/roles.ts +125 -0
- package/src/core/routes/spaces.ts +60 -0
- package/src/core/routes/storage.ts +126 -0
- package/src/core/routes/tasks.ts +189 -0
- package/src/core/routes/tradestation.ts +268 -0
- package/src/core/routes/tts.ts +51 -0
- package/src/core/runtime.ts +1140 -0
- package/src/core/space-queue.ts +103 -0
- package/src/core/storage-cleanup.ts +140 -0
- package/src/core/storage-guard.ts +24 -0
- package/src/core/task-scheduler.ts +132 -0
- package/src/core/telegram-format.ts +178 -0
- package/src/core/trigger.ts +142 -0
- package/src/dashboard/index.html +729 -0
- package/src/dashboard/tokens.css +53 -0
- package/src/extensions/api.ts +252 -0
- package/src/extensions/catalog.ts +117 -0
- package/src/extensions/config-registry.ts +83 -0
- package/src/extensions/context.ts +36 -0
- package/src/extensions/hooks.ts +156 -0
- package/src/extensions/image-builder.ts +617 -0
- package/src/extensions/installer.ts +306 -0
- package/src/extensions/jobs.ts +122 -0
- package/src/extensions/loader.ts +271 -0
- package/src/extensions/permission-guard.ts +52 -0
- package/src/extensions/reserved.ts +28 -0
- package/src/extensions/skills.ts +123 -0
- package/src/extensions/types.ts +462 -0
- package/src/logger.ts +174 -0
- package/src/main.ts +586 -0
- package/src/server.ts +391 -0
- package/src/storage/db.ts +1624 -0
- package/src/storage/memory.ts +45 -0
- package/src/storage/pi-auth.ts +95 -0
- package/src/text/markdown.ts +117 -0
- package/src/text/rtl.ts +38 -0
- package/src/tradestation/host-api.ts +77 -0
- package/src/tradestation/pending-orders.ts +69 -0
- package/src/tts/azure.ts +52 -0
- package/src/tts/google.ts +128 -0
- package/src/tts/index.ts +8 -0
- package/src/tts/language.ts +20 -0
- package/src/tts/synthesize.ts +133 -0
- 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();
|