#!/usr/bin/env bun import { spawn, spawnSync } from "node:child_process"; import { chmodSync, copyFileSync, cpSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, unlinkSync, writeFileSync, } from "node:fs"; import { homedir, tmpdir } from "node:os"; import { basename, dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { Command } from "commander"; import { RESERVED_EXTENSION_NAMES } from "../extensions/reserved.js"; import { Db } from "../storage/db.js"; import { authenticate } from "./whatsapp-auth.js"; const __dirname = dirname(fileURLToPath(import.meta.url)); const PACKAGE_ROOT = join(__dirname, "../.."); const CWD = process.cwd(); const TEMPLATES_DIR = join(PACKAGE_ROOT, "resources/templates"); const VALID_EXT_NAME_RE = /^[a-z0-9][a-z0-9-]*$/; function getVersion(): string { try { const pkg = JSON.parse( readFileSync(join(PACKAGE_ROOT, "package.json"), "utf-8"), ); return pkg.version; } catch { return "0.0.0"; } } function loadEnvFile(envPath: string): Record { const content = readFileSync(envPath, "utf-8"); const vars: Record = {}; for (const line of content.split("\n")) { const trimmed = line.trim(); if (!trimmed || trimmed.startsWith("#")) continue; const match = trimmed.match(/^([^=]+)=(.*)$/); if (match) { vars[match[1]] = match[2]; } } return vars; } function getProjectDataDir(): string { const envPath = join(CWD, ".env"); if (existsSync(envPath)) { const envVars = loadEnvFile(envPath); if (envVars.MERCURY_DATA_DIR) return envVars.MERCURY_DATA_DIR; } return ".mercury"; } function withProjectDb(fn: (db: Db) => T): T { const dbPath = join(CWD, getProjectDataDir(), "state.db"); const db = new Db(dbPath); try { return fn(db); } finally { db.close(); } } // Commands function initAction(): void { console.log("🪽 Initializing mercury project...\n"); // Create .env if it doesn't exist const envPath = join(CWD, ".env"); if (!existsSync(envPath)) { copyFileSync(join(TEMPLATES_DIR, "env.template"), envPath); console.log(" āœ“ .env"); } else { console.log(" • .env (already exists)"); } // Create data directories const dirs = [".mercury", ".mercury/spaces", ".mercury/global"]; for (const dir of dirs) { const fullPath = join(CWD, dir); if (!existsSync(fullPath)) { mkdirSync(fullPath, { recursive: true }); console.log(` āœ“ ${dir}/`); } } // Create AGENTS.md for the agent const agentsMdPath = join(CWD, ".mercury/global/AGENTS.md"); if (!existsSync(agentsMdPath)) { copyFileSync(join(TEMPLATES_DIR, "AGENTS.md"), agentsMdPath); console.log(" āœ“ .mercury/global/AGENTS.md"); } else { console.log(" • .mercury/global/AGENTS.md (already exists)"); } // Copy subagent extension console.log("\nCopying subagent extension:"); const extensionsDir = join(CWD, ".mercury/global/extensions/subagent"); mkdirSync(extensionsDir, { recursive: true }); const srcExtDir = join(PACKAGE_ROOT, "resources/extensions/subagent"); for (const file of readdirSync(srcExtDir)) { copyFileSync(join(srcExtDir, file), join(extensionsDir, file)); console.log(` āœ“ .mercury/global/extensions/subagent/${file}`); } // Copy agent definitions console.log("\nCopying agent definitions:"); const agentsDir = join(CWD, ".mercury/global/agents"); mkdirSync(agentsDir, { recursive: true }); const srcAgentsDir = join(PACKAGE_ROOT, "resources/agents"); for (const file of readdirSync(srcAgentsDir)) { copyFileSync(join(srcAgentsDir, file), join(agentsDir, file)); console.log(` āœ“ .mercury/global/agents/${file}`); } console.log("\n🪽 Initialization complete!"); console.log("\nNext steps:"); console.log(" 1. Edit .env to set your API keys and enable adapters"); console.log( " 2. Run 'mercury service install' to start as a system service", ); } async function runAction(): Promise { const envPath = join(CWD, ".env"); if (!existsSync(envPath)) { console.error("Error: .env file not found in current directory."); console.error("Run 'mercury init' first, or cd into your mercury project."); process.exit(1); } const envVars = loadEnvFile(envPath); const imageName = envVars.MERCURY_AGENT_IMAGE || "mercury-agent:latest"; const imageCheck = spawnSync("docker", ["image", "inspect", imageName], { stdio: "pipe", }); if (imageCheck.status !== 0) { console.error(`Error: Container image '${imageName}' not found.`); if (imageName.startsWith("ghcr.io/")) { console.error(`Run 'docker pull ${imageName}' to pull it.`); } else { console.error("Run 'mercury build' to build it."); } process.exit(1); } console.log("🪽 Starting mercury...\n"); const entryPoint = join(PACKAGE_ROOT, "src/main.ts"); const child = spawn("bun", ["run", entryPoint], { stdio: "inherit", cwd: CWD, env: { ...process.env, ...envVars }, }); child.on("error", (err) => { console.error("Failed to start:", err.message); process.exit(1); }); child.on("exit", (code) => { process.exit(code ?? 0); }); } function buildAction(): void { // Build from package sources using a temp context — no files needed in user project const tmpDir = join(CWD, ".mercury", ".build-context"); mkdirSync(tmpDir, { recursive: true }); try { // Copy container files from package into temp context const filesToCopy = [ "container/Dockerfile", "src/agent/container-entry.ts", "src/cli/mrctl.ts", "src/extensions/reserved.ts", "src/extensions/permission-guard.ts", "src/types.ts", ]; for (const file of filesToCopy) { const src = join(PACKAGE_ROOT, file); const dest = join(tmpDir, file); mkdirSync(dirname(dest), { recursive: true }); copyFileSync(src, dest); } console.log("šŸ“¦ Building container image...\n"); const result = spawnSync( "docker", [ "build", "-t", "mercury-agent:latest", "-f", join(tmpDir, "container/Dockerfile"), tmpDir, ], { stdio: "inherit" }, ); if (result.status !== 0) { process.exit(result.status ?? 1); } } finally { // Clean up temp context rmSync(tmpDir, { recursive: true, force: true }); } } function statusAction(): void { console.log("🪽 mercury status\n"); console.log(`Project directory: ${CWD}\n`); const envPath = join(CWD, ".env"); const hasEnv = existsSync(envPath); console.log( `Configuration: ${hasEnv ? "āœ“ .env exists" : "āœ— .env missing (run 'mercury init')"}`, ); const imageCheck = spawnSync( "docker", ["image", "inspect", "mercury-agent:latest"], { stdio: "pipe", }, ); const hasImage = imageCheck.status === 0; console.log( `Container image: ${hasImage ? "āœ“ mercury-agent:latest" : "āœ— not built (run 'mercury build')"}`, ); if (hasEnv) { console.log("\nConfigured adapters:"); const envContent = readFileSync(envPath, "utf-8"); const hasWhatsApp = /MERCURY_ENABLE_WHATSAPP\s*=\s*true/i.test(envContent); const hasSlack = /^[^#]*SLACK_BOT_TOKEN=\S+/m.test(envContent); const hasDiscord = /^[^#]*DISCORD_BOT_TOKEN=\S+/m.test(envContent); console.log(` WhatsApp: ${hasWhatsApp ? "āœ“ enabled" : "ā—‹ disabled"}`); console.log( ` Slack: ${hasSlack ? "āœ“ configured" : "ā—‹ not configured"}`, ); console.log( ` Discord: ${hasDiscord ? "āœ“ configured" : "ā—‹ not configured"}`, ); const portMatch = envContent.match(/MERCURY_PORT\s*=\s*(\d+)/); const port = portMatch ? portMatch[1] : "3000"; const portCheck = spawnSync("lsof", ["-i", `:${port}`, "-t"], { stdio: "pipe", }); const isRunning = portCheck.status === 0 && portCheck.stdout.toString().trim().length > 0; console.log( `\nStatus: ${isRunning ? `🟢 running (port ${port})` : "⚪ not running"}`, ); } } function doctorAction(): void { console.log("🩺 mercury doctor\n"); let passed = 0; let warned = 0; let failed = 0; function pass(msg: string): void { console.log(` āœ… ${msg}`); passed++; } function warn(msg: string, fix?: string): void { console.log(` āš ļø ${msg}`); if (fix) console.log(` → ${fix}`); warned++; } function fail(msg: string, fix?: string): void { console.log(` āŒ ${msg}`); if (fix) console.log(` → ${fix}`); failed++; } // 1. .env exists console.log("Configuration:"); const envPath = join(CWD, ".env"); const hasEnv = existsSync(envPath); if (hasEnv) { pass(".env file found"); } else { fail(".env file missing", "Run 'mercury init' to create one"); } const envVars = hasEnv ? loadEnvFile(envPath) : {}; // 2. Docker installed and running console.log("\nDocker:"); const dockerCheck = spawnSync("docker", ["info"], { stdio: "pipe", timeout: 10_000, }); if (dockerCheck.status === 0) { pass("Docker is installed and running"); } else { const hasDocker = spawnSync("which", ["docker"], { stdio: "pipe" }).status === 0; if (hasDocker) { fail( "Docker is installed but daemon is not running", "Start Docker Desktop or run 'sudo systemctl start docker'", ); } else { fail( "Docker is not installed", "Install from https://docs.docker.com/get-docker/", ); } } // 3. Agent image available const image = envVars.MERCURY_AGENT_IMAGE || "ghcr.io/michaelliv/mercury-agent:latest"; const imageCheck = spawnSync("docker", ["image", "inspect", image], { stdio: "pipe", timeout: 10_000, }); if (imageCheck.status === 0) { pass(`Agent image found: ${image}`); } else { warn( `Agent image not found locally: ${image}`, `Mercury will auto-pull on first start, or run 'docker pull ${image}'`, ); } // 4. AI credentials console.log("\nAI Credentials:"); const dataDir = getProjectDataDir(); const authPath = join(CWD, dataDir, "global", "auth.json"); const hasOAuth = existsSync(authPath); const hasApiKey = !!( envVars.MERCURY_ANTHROPIC_API_KEY || envVars.MERCURY_ANTHROPIC_OAUTH_TOKEN ); if (hasOAuth || hasApiKey) { if (hasOAuth) pass("OAuth credentials found (auth.json)"); if (hasApiKey) pass("API key found in .env"); } else { fail( "No AI credentials configured", "Run 'mercury auth login' or set MERCURY_ANTHROPIC_API_KEY in .env", ); } // 5. Adapters console.log("\nAdapters:"); const whatsappEnabled = envVars.MERCURY_ENABLE_WHATSAPP?.toLowerCase() === "true"; const discordEnabled = envVars.MERCURY_ENABLE_DISCORD?.toLowerCase() === "true"; const slackEnabled = envVars.MERCURY_ENABLE_SLACK?.toLowerCase() === "true"; if (!whatsappEnabled && !discordEnabled && !slackEnabled) { fail( "No adapters enabled", "Set MERCURY_ENABLE_WHATSAPP, MERCURY_ENABLE_DISCORD, or MERCURY_ENABLE_SLACK to true in .env", ); } else { if (whatsappEnabled) { const whatsappAuthDir = envVars.MERCURY_WHATSAPP_AUTH_DIR || join(CWD, dataDir, "whatsapp-auth"); const credsFile = join(whatsappAuthDir, "creds.json"); if (existsSync(credsFile)) { pass("WhatsApp: enabled and authenticated"); } else { fail( "WhatsApp: enabled but not authenticated", "Run 'mercury auth whatsapp' first", ); } } if (discordEnabled) { if (envVars.MERCURY_DISCORD_BOT_TOKEN) { pass("Discord: enabled and token configured"); } else { fail( "Discord: enabled but MERCURY_DISCORD_BOT_TOKEN not set", "Add your bot token to .env", ); } } if (slackEnabled) { const hasToken = !!envVars.MERCURY_SLACK_BOT_TOKEN; const hasSecret = !!envVars.MERCURY_SLACK_SIGNING_SECRET; if (hasToken && hasSecret) { pass("Slack: enabled and configured"); } else { const missing = [ !hasToken && "MERCURY_SLACK_BOT_TOKEN", !hasSecret && "MERCURY_SLACK_SIGNING_SECRET", ].filter(Boolean); fail( `Slack: enabled but missing ${missing.join(", ")}`, "Add to .env — see docs/setup-slack.md", ); } } } // 6. MERCURY_ADMINS console.log("\nPermissions:"); if (envVars.MERCURY_ADMINS) { pass( `MERCURY_ADMINS set (${envVars.MERCURY_ADMINS.split(",").length} admin(s))`, ); } else { warn( "MERCURY_ADMINS not set — no one will have admin permissions", "Add your platform ID to MERCURY_ADMINS in .env", ); } // 7. Port available console.log("\nNetwork:"); const port = envVars.MERCURY_PORT || "3000"; const portCheck = spawnSync("lsof", ["-i", `:${port}`, "-t"], { stdio: "pipe", }); const portInUse = portCheck.status === 0 && portCheck.stdout.toString().trim().length > 0; if (portInUse) { warn( `Port ${port} is in use (Mercury may already be running)`, `Change MERCURY_PORT in .env or stop the existing process`, ); } else { pass(`Port ${port} is available`); } // 8. Spaces exist console.log("\nSpaces:"); const dbPath = join(CWD, dataDir, "state.db"); if (existsSync(dbPath)) { try { const db = new Db(dbPath); const spaces = db.listSpaces(); if (spaces.length > 0) { pass(`${spaces.length} space(s) configured`); } else { warn( "No spaces created yet — incoming messages will be dropped", "Run 'mercury spaces create '", ); } db.close(); } catch { warn("Could not read database"); } } else { warn("No database yet (created on first run)"); } // Summary console.log(`\n─────────────────────────────────`); console.log(` ${passed} passed ${warned} warnings ${failed} errors`); if (failed > 0) { console.log("\n Fix the errors above before starting Mercury."); process.exit(1); } else if (warned > 0) { console.log("\n Mercury should work, but review the warnings above."); } else { console.log("\n Everything looks good! šŸš€"); } } // CLI setup const program = new Command(); program .name("mercury") .description("Personal AI assistant for chat platforms") .version(getVersion()); program .command("init") .description("Initialize a new mercury project in current directory") .action(initAction); program .command("run") .description("Start the chat adapters (WhatsApp/Slack/Discord)") .action(runAction); program .command("build") .description("Build the agent container image") .action(buildAction); program .command("status") .description("Show current status and configuration") .action(statusAction); program .command("doctor") .description("Check environment and configuration for common issues") .action(doctorAction); // Auth subcommand const authCommand = program .command("auth") .description("Authenticate with providers and platforms"); authCommand .command("login [provider]") .description( "Login with an OAuth provider (anthropic, github-copilot, google-gemini-cli, antigravity, openai-codex)", ) .action(async (providerArg?: string) => { const { getOAuthProviders, getOAuthProvider } = await import( "@mariozechner/pi-ai" ); const readline = await import("node:readline"); const { exec } = await import("node:child_process"); const providers = getOAuthProviders(); let providerId: string; if (providerArg) { providerArg = providerArg.trim(); const provider = getOAuthProvider(providerArg); if (!provider) { console.error( `Unknown provider: ${providerArg}\nAvailable: ${providers.map((p: { id: string }) => p.id).join(", ")}`, ); process.exit(1); } providerId = providerArg; } else { // Interactive selection console.log("Available OAuth providers:\n"); for (let i = 0; i < providers.length; i++) { console.log(` ${i + 1}. ${providers[i].name} (${providers[i].id})`); } console.log(); const rl = readline.createInterface({ input: process.stdin, output: process.stdout, }); const answer = await new Promise((resolve) => { rl.question("Select provider (number or id): ", resolve); }); rl.close(); const num = Number.parseInt(answer, 10); if (num >= 1 && num <= providers.length) { providerId = providers[num - 1].id; } else { const provider = getOAuthProvider(answer.trim()); if (!provider) { console.error("Invalid selection."); process.exit(1); } providerId = answer.trim(); } } const provider = getOAuthProvider(providerId)!; console.log(`\nLogging in to ${provider.name}...`); // Resolve auth.json path const dataDir = getProjectDataDir(); const authPath = join(CWD, dataDir, "global", "auth.json"); const authDir = dirname(authPath); if (!existsSync(authDir)) { mkdirSync(authDir, { recursive: true }); } // Read existing auth let authData: Record = {}; if (existsSync(authPath)) { try { authData = JSON.parse(readFileSync(authPath, "utf-8")); } catch { // ignore } } try { const rl = readline.createInterface({ input: process.stdin, output: process.stdout, }); const credentials = await provider.login({ onAuth: (info: { url: string; instructions?: string }) => { console.log(`\nOpen this URL to authenticate:\n\n ${info.url}\n`); if (info.instructions) { console.log(info.instructions); } // Try to open browser const openCmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open"; exec(`${openCmd} "${info.url}"`); }, onPrompt: async (prompt: { message: string; placeholder?: string }) => { const answer = await new Promise((resolve) => { rl.question( `${prompt.message}${prompt.placeholder ? ` (${prompt.placeholder})` : ""}: `, resolve, ); }); return answer; }, onProgress: (message: string) => { console.log(message); }, onManualCodeInput: async () => { const answer = await new Promise((resolve) => { rl.question("Paste redirect URL or code: ", resolve); }); return answer; }, }); rl.close(); // Save to auth.json authData[providerId] = { type: "oauth", ...credentials }; writeFileSync(authPath, JSON.stringify(authData, null, 2), "utf-8"); chmodSync(authPath, 0o600); console.log(`\nāœ“ Logged in to ${provider.name}`); console.log(` Credentials saved to ${authPath}`); } catch (err) { const message = err instanceof Error ? err.message : String(err); if (message === "Login cancelled") { console.log("\nLogin cancelled."); } else { console.error(`\nLogin failed: ${message}`); } process.exit(1); } }); authCommand .command("logout [provider]") .description("Remove saved OAuth credentials for a provider") .action(async (providerArg?: string) => { const dataDir = getProjectDataDir(); const authPath = join(CWD, dataDir, "global", "auth.json"); if (!existsSync(authPath)) { console.log("No credentials found."); return; } let authData: Record; try { authData = JSON.parse(readFileSync(authPath, "utf-8")); } catch { console.log("No credentials found."); return; } if (providerArg) { if (!(providerArg in authData)) { console.log(`No credentials for ${providerArg}.`); return; } delete authData[providerArg]; writeFileSync(authPath, JSON.stringify(authData, null, 2), "utf-8"); console.log(`āœ“ Removed credentials for ${providerArg}`); } else { const keys = Object.keys(authData); if (keys.length === 0) { console.log("No credentials found."); return; } console.log("Logged in providers:"); for (const key of keys) { console.log(` - ${key}`); } console.log('\nRun "mercury auth logout " to remove.'); } }); authCommand .command("status") .description("Show authentication status for all providers") .action(async () => { const { getOAuthProviders } = await import("@mariozechner/pi-ai"); const dataDir = getProjectDataDir(); const authPath = join(CWD, dataDir, "global", "auth.json"); let authData: Record = {}; if (existsSync(authPath)) { try { authData = JSON.parse(readFileSync(authPath, "utf-8")); } catch { // ignore } } // Check env vars too const envPath = join(CWD, ".env"); const envVars = existsSync(envPath) ? loadEnvFile(envPath) : {}; const providers = getOAuthProviders(); console.log("Authentication status:\n"); for (const provider of providers) { const cred = authData[provider.id]; if (cred?.type === "oauth") { const expired = cred.expires ? Date.now() >= cred.expires : false; const status = expired ? "expired (will auto-refresh)" : "āœ“ logged in"; console.log(` ${provider.name}: ${status}`); } else { console.log(` ${provider.name}: not logged in`); } } // Check for API keys in env console.log(); const apiKeyVars = [ "MERCURY_ANTHROPIC_API_KEY", "MERCURY_ANTHROPIC_OAUTH_TOKEN", "MERCURY_OPENAI_API_KEY", ]; let hasEnvKeys = false; for (const key of apiKeyVars) { if (envVars[key]) { console.log(` ${key}: āœ“ set in .env`); hasEnvKeys = true; } } if (!hasEnvKeys) { console.log(" No API keys found in .env"); } }); authCommand .command("whatsapp") .description("Authenticate with WhatsApp via QR code or pairing code") .option("--pairing-code", "Use pairing code instead of QR code") .option( "--phone ", "Phone number for pairing code (e.g., 14155551234)", ) .action(async (options: { pairingCode?: boolean; phone?: string }) => { const envPath = join(CWD, ".env"); let dataDir = ".mercury"; if (existsSync(envPath)) { const envVars = loadEnvFile(envPath); if (envVars.MERCURY_DATA_DIR) { dataDir = envVars.MERCURY_DATA_DIR; } } const authDir = process.env.MERCURY_WHATSAPP_AUTH_DIR || join(CWD, dataDir, "whatsapp-auth"); const statusDir = join(CWD, dataDir); try { await authenticate({ authDir, statusDir, usePairingCode: options.pairingCode, phoneNumber: options.phone, }); } catch (err: unknown) { const message = err instanceof Error ? err.message : String(err); console.error("Authentication failed:", message); process.exit(1); } }); // Service management commands const SERVICE_NAME = "mercury"; const LAUNCHD_LABEL = "com.mercury.agent"; function getServicePaths(): { systemdUser: string; systemdSystem: string; launchdPlist: string; logDir: string; } { return { systemdUser: join(homedir(), ".config/systemd/user/mercury.service"), systemdSystem: "/etc/systemd/system/mercury.service", launchdPlist: join( homedir(), "Library/LaunchAgents/com.mercury.agent.plist", ), logDir: join(CWD, ".mercury/logs"), }; } function checkCommandExists(cmd: string): boolean { const result = spawnSync("which", [cmd], { stdio: "pipe" }); return result.status === 0; } function generateSystemdService(userMode: boolean): string { const bunPath = resolve(process.execPath); const mercuryScript = resolve(process.argv[1]); const workDir = CWD; const currentPath = process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin"; return `[Unit] Description=Mercury Chat Agent After=network.target [Service] Type=simple ExecStart=${bunPath} run ${mercuryScript} run WorkingDirectory=${workDir} Environment=PATH=${currentPath} Restart=on-failure RestartSec=10 [Install] WantedBy=${userMode ? "default.target" : "multi-user.target"} `; } function generateLaunchdPlist(): string { const bunPath = resolve(process.execPath); const mercuryScript = resolve(process.argv[1]); const workDir = CWD; const { logDir } = getServicePaths(); // Capture current PATH so docker and other tools are available const currentPath = process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin"; return ` Label ${LAUNCHD_LABEL} ProgramArguments ${bunPath} run ${mercuryScript} run WorkingDirectory ${workDir} EnvironmentVariables PATH ${currentPath} RunAtLoad KeepAlive StandardOutPath ${logDir}/mercury.log StandardErrorPath ${logDir}/mercury.error.log `; } function installSystemd(userMode: boolean): void { if (!checkCommandExists("systemctl")) { console.error("Error: systemctl not found. Is systemd installed?"); process.exit(1); } const paths = getServicePaths(); const servicePath = userMode ? paths.systemdUser : paths.systemdSystem; const serviceContent = generateSystemdService(userMode); // Check if we need sudo for system-level install if (!userMode) { console.log("Installing system-level service requires sudo."); console.log("Consider using --user flag for user-level service instead."); } // Create directory if needed mkdirSync(dirname(servicePath), { recursive: true }); // Write service file try { writeFileSync(servicePath, serviceContent); } catch (err) { if (!userMode) { console.error( "Error: Cannot write to system directory. Try with sudo or use --user flag.", ); } else { console.error(`Error writing service file: ${err}`); } process.exit(1); } // Enable and start service const systemctlBase = userMode ? ["systemctl", "--user"] : ["systemctl"]; console.log("Reloading systemd daemon..."); const reloadResult = spawnSync( systemctlBase[0], [...systemctlBase.slice(1), "daemon-reload"], { stdio: "inherit", }, ); if (reloadResult.status !== 0) { console.error("Failed to reload systemd daemon"); process.exit(1); } console.log("Enabling mercury service..."); const enableResult = spawnSync( systemctlBase[0], [...systemctlBase.slice(1), "enable", SERVICE_NAME], { stdio: "inherit", }, ); if (enableResult.status !== 0) { console.error("Failed to enable service"); process.exit(1); } console.log("Starting mercury service..."); const startResult = spawnSync( systemctlBase[0], [...systemctlBase.slice(1), "start", SERVICE_NAME], { stdio: "inherit", }, ); if (startResult.status !== 0) { console.error("Failed to start service"); process.exit(1); } console.log("\nāœ“ Mercury service installed and started"); console.log(` Service file: ${servicePath}`); console.log( ` View logs: journalctl ${userMode ? "--user " : ""}-u mercury -f`, ); } function installLaunchd(): void { if (!checkCommandExists("launchctl")) { console.error("Error: launchctl not found. Are you on macOS?"); process.exit(1); } const paths = getServicePaths(); const plistContent = generateLaunchdPlist(); // Create log directory mkdirSync(paths.logDir, { recursive: true }); // Create LaunchAgents directory if needed mkdirSync(dirname(paths.launchdPlist), { recursive: true }); // Unload existing service if present if (existsSync(paths.launchdPlist)) { spawnSync("launchctl", ["unload", paths.launchdPlist], { stdio: "pipe" }); } // Write plist file writeFileSync(paths.launchdPlist, plistContent); // Load service const loadResult = spawnSync("launchctl", ["load", paths.launchdPlist], { stdio: "inherit", }); if (loadResult.status !== 0) { console.error("Failed to load service"); process.exit(1); } console.log("\nāœ“ Mercury service installed and started"); console.log(` Plist: ${paths.launchdPlist}`); console.log(` Logs: ${paths.logDir}/mercury.log`); console.log(` View logs: tail -f ${paths.logDir}/mercury.log`); } function serviceInstallAction(options: { user?: boolean }): void { // Verify we're in a mercury project const envPath = join(CWD, ".env"); if (!existsSync(envPath)) { console.error("Error: .env file not found in current directory."); console.error("Run 'mercury init' first, or cd into your mercury project."); process.exit(1); } const platform = process.platform; if (platform === "darwin") { installLaunchd(); } else if (platform === "linux") { // Default to user mode unless explicitly installing system-wide installSystemd(options.user ?? true); } else { console.error(`Unsupported platform: ${platform}`); console.log("See docs/deployment.md for manual setup instructions."); process.exit(1); } } function serviceUninstallAction(): void { const platform = process.platform; const paths = getServicePaths(); if (platform === "darwin") { if (existsSync(paths.launchdPlist)) { console.log("Unloading mercury service..."); spawnSync("launchctl", ["unload", paths.launchdPlist], { stdio: "inherit", }); unlinkSync(paths.launchdPlist); console.log("āœ“ Mercury service uninstalled"); } else { console.log("Service not installed"); } } else if (platform === "linux") { // Try user service first, then system if (existsSync(paths.systemdUser)) { console.log("Stopping mercury user service..."); spawnSync("systemctl", ["--user", "stop", SERVICE_NAME], { stdio: "inherit", }); console.log("Disabling mercury user service..."); spawnSync("systemctl", ["--user", "disable", SERVICE_NAME], { stdio: "inherit", }); unlinkSync(paths.systemdUser); spawnSync("systemctl", ["--user", "daemon-reload"], { stdio: "inherit" }); console.log("āœ“ Mercury user service uninstalled"); } else if (existsSync(paths.systemdSystem)) { console.log("Stopping mercury system service..."); spawnSync("systemctl", ["stop", SERVICE_NAME], { stdio: "inherit" }); console.log("Disabling mercury system service..."); spawnSync("systemctl", ["disable", SERVICE_NAME], { stdio: "inherit" }); try { unlinkSync(paths.systemdSystem); } catch { console.error( "Error: Cannot remove system service file. Try with sudo.", ); process.exit(1); } spawnSync("systemctl", ["daemon-reload"], { stdio: "inherit" }); console.log("āœ“ Mercury system service uninstalled"); } else { console.log("Service not installed"); } } else { console.error(`Unsupported platform: ${platform}`); process.exit(1); } } function serviceStatusAction(): void { const platform = process.platform; const paths = getServicePaths(); if (platform === "darwin") { if (!existsSync(paths.launchdPlist)) { console.log("Mercury service is not installed"); return; } console.log("Mercury service status:\n"); spawnSync("launchctl", ["list", LAUNCHD_LABEL], { stdio: "inherit" }); } else if (platform === "linux") { // Try user service first if (existsSync(paths.systemdUser)) { spawnSync("systemctl", ["--user", "status", SERVICE_NAME], { stdio: "inherit", }); } else if (existsSync(paths.systemdSystem)) { spawnSync("systemctl", ["status", SERVICE_NAME], { stdio: "inherit" }); } else { console.log("Mercury service is not installed"); } } else { console.error(`Unsupported platform: ${platform}`); process.exit(1); } } function serviceLogsAction(options: { follow?: boolean }): void { const platform = process.platform; const paths = getServicePaths(); if (platform === "darwin") { const logPath = join(paths.logDir, "mercury.log"); if (!existsSync(logPath)) { console.error(`Log file not found: ${logPath}`); console.log("The service may not have been started yet."); process.exit(1); } const args = options.follow ? ["-f", logPath] : ["-n", "100", logPath]; spawnSync("tail", args, { stdio: "inherit" }); } else if (platform === "linux") { // Determine if user or system service const isUserService = existsSync(paths.systemdUser); const isSystemService = existsSync(paths.systemdSystem); if (!isUserService && !isSystemService) { console.error("Mercury service is not installed"); process.exit(1); } const args = isUserService ? ["--user", "-u", SERVICE_NAME] : ["-u", SERVICE_NAME]; if (options.follow) args.push("-f"); spawnSync("journalctl", args, { stdio: "inherit" }); } else { console.error(`Unsupported platform: ${platform}`); process.exit(1); } } // Service subcommand const serviceCommand = program .command("service") .description("Manage Mercury as a system service"); serviceCommand .command("install") .description("Install Mercury as a system service") .option( "--user", "Install as user service (default on Linux, no sudo required)", ) .action(serviceInstallAction); serviceCommand .command("uninstall") .description("Uninstall Mercury service") .action(serviceUninstallAction); serviceCommand .command("status") .description("Show service status") .action(serviceStatusAction); serviceCommand .command("logs") .description("View service logs") .option("-f, --follow", "Follow log output") .action(serviceLogsAction); // ─── Extension management ───────────────────────────────────────────────── function getDataDir(): string { const envPath = join(CWD, ".env"); if (existsSync(envPath)) { const envVars = loadEnvFile(envPath); if (envVars.MERCURY_DATA_DIR) return envVars.MERCURY_DATA_DIR; } return ".mercury"; } function getUserExtensionsDir(): string { return join(CWD, getDataDir(), "extensions"); } function getGlobalDir(): string { const envPath = join(CWD, ".env"); if (existsSync(envPath)) { const envVars = loadEnvFile(envPath); if (envVars.MERCURY_GLOBAL_DIR) return envVars.MERCURY_GLOBAL_DIR; } return join(CWD, getDataDir(), "global"); } /** * Resolve an extension source to a local directory path. * * Supports: * - Local paths: `./path/to/extension` or `/absolute/path` * - npm packages: `npm:` * - git repos: `git:` * * For npm/git, downloads to a temp dir and returns that path. * Returns { dir, name, cleanup } — call cleanup() to remove temp dirs. */ function resolveExtensionSource(source: string): { dir: string; name: string; cleanup: () => void; } { // npm: prefix if (source.startsWith("npm:")) { const pkg = source.slice(4); const maybeName = pkg.includes("/") ? pkg.split("/").pop() : pkg; const name = maybeName || pkg; const tmp = join(tmpdir(), `mercury-ext-npm-${Date.now()}`); mkdirSync(tmp, { recursive: true }); console.log(`Fetching ${pkg} from npm...`); const packResult = spawnSync( "npm", ["pack", pkg, "--pack-destination", tmp], { stdio: ["pipe", "pipe", "pipe"], cwd: tmp, }, ); if (packResult.status !== 0) { rmSync(tmp, { recursive: true, force: true }); console.error(`Error: failed to fetch npm package "${pkg}"`); console.error(packResult.stderr?.toString().trim()); process.exit(1); } // Find the tarball const tarballs = readdirSync(tmp).filter((f) => f.endsWith(".tgz")); if (tarballs.length === 0) { rmSync(tmp, { recursive: true, force: true }); console.error(`Error: npm pack produced no tarball for "${pkg}"`); process.exit(1); } // Extract tarball const tarball = join(tmp, tarballs[0]); const extractDir = join(tmp, "extracted"); mkdirSync(extractDir, { recursive: true }); const extractResult = spawnSync( "tar", ["xzf", tarball, "-C", extractDir, "--strip-components=1"], { stdio: ["pipe", "pipe", "pipe"], }, ); if (extractResult.status !== 0) { rmSync(tmp, { recursive: true, force: true }); console.error(`Error: failed to extract tarball for "${pkg}"`); process.exit(1); } return { dir: extractDir, name, cleanup: () => rmSync(tmp, { recursive: true, force: true }), }; } // git: prefix — supports optional #subdir (e.g. git:https://repo.git#packages/media) if (source.startsWith("git:")) { const raw = source.slice(4); // Split off optional #subdirectory fragment const hashIdx = raw.indexOf("#"); const urlPart = hashIdx >= 0 ? raw.slice(0, hashIdx) : raw; const subdir = hashIdx >= 0 ? raw.slice(hashIdx + 1) : undefined; // Accept git:github.com/user/repo or git:https://github.com/user/repo const gitUrl = urlPart.startsWith("http") ? urlPart : `https://${urlPart}`; const tmp = join(tmpdir(), `mercury-ext-git-${Date.now()}`); console.log(`Cloning ${gitUrl}...`); const cloneResult = spawnSync( "git", ["clone", "--depth", "1", gitUrl, tmp], { stdio: ["pipe", "pipe", "pipe"], }, ); if (cloneResult.status !== 0) { rmSync(tmp, { recursive: true, force: true }); console.error(`Error: failed to clone "${gitUrl}"`); console.error(cloneResult.stderr?.toString().trim()); process.exit(1); } const extDir = subdir ? join(tmp, subdir) : tmp; if (subdir && !existsSync(extDir)) { rmSync(tmp, { recursive: true, force: true }); console.error(`Error: subdirectory "${subdir}" not found in cloned repo`); process.exit(1); } const name = basename(extDir); return { dir: extDir, name, cleanup: () => rmSync(tmp, { recursive: true, force: true }), }; } // Local path const absPath = resolve(CWD, source); if (!existsSync(absPath)) { console.error(`Error: path not found: ${source}`); process.exit(1); } if (!existsSync(join(absPath, "index.ts"))) { console.error(`Error: no index.ts found in ${source}`); process.exit(1); } const name = basename(absPath); return { dir: absPath, name, cleanup: () => {} }; } /** * Validate extension before installation. */ function validateExtension( name: string, sourceDir: string, extensionsDir: string, ): void { // Name format if (!VALID_EXT_NAME_RE.test(name)) { console.error( `Error: invalid extension name "${name}" (must be lowercase alphanumeric + hyphens)`, ); process.exit(1); } // Reserved name if (RESERVED_EXTENSION_NAMES.has(name)) { console.error(`Error: "${name}" is a reserved built-in command name`); process.exit(1); } // index.ts must exist if (!existsSync(join(sourceDir, "index.ts"))) { console.error(`Error: no index.ts found in extension source`); process.exit(1); } // Already installed if (existsSync(join(extensionsDir, name))) { console.error( `Error: extension "${name}" is already installed. Run 'mercury remove ${name}' first.`, ); process.exit(1); } } /** * Try loading an extension to check for syntax/import errors. */ async function dryRunExtension(dir: string, name: string): Promise { const indexPath = join(dir, "index.ts"); try { const mod = await import(indexPath); if (typeof mod.default !== "function") { console.error(`Error: ${name}/index.ts must export a default function`); process.exit(1); } } catch (err) { console.error(`Error: failed to load ${name}/index.ts:`); console.error(err instanceof Error ? err.message : String(err)); process.exit(1); } } /** * Install skill files for a newly added extension. */ function installSkillIfPresent(extDir: string, name: string): boolean { const skillDir = join(extDir, "skill"); if (!existsSync(join(skillDir, "SKILL.md"))) return false; const globalDir = getGlobalDir(); const dst = join(globalDir, "skills", name); mkdirSync(dirname(dst), { recursive: true }); rmSync(dst, { recursive: true, force: true }); cpSync(skillDir, dst, { recursive: true }); return true; } /** * Read extension metadata by doing a quick dry-run load. * Returns partial info for the install report. */ async function readExtensionInfo(dir: string): Promise<{ hasCli: boolean; hasSkill: boolean; cliNames: string[]; permissionRoles?: string[]; }> { const { MercuryExtensionAPIImpl } = await import("../extensions/api.js"); const { Db } = await import("../storage/db.js"); // Create a temporary in-memory DB for dry-run const tmpDbPath = join(tmpdir(), `mercury-dryrun-${Date.now()}.db`); const db = new Db(tmpDbPath); try { const name = basename(dir); const api = new MercuryExtensionAPIImpl(name, dir, db); const mod = await import(join(dir, "index.ts")); try { mod.default(api); } catch { // Best-effort — some extensions may fail without full runtime } const meta = api.getMeta(); return { hasCli: meta.clis.length > 0, hasSkill: !!meta.skillDir, cliNames: meta.clis.map((c) => c.name), permissionRoles: meta.permission?.defaultRoles, }; } finally { db.close(); rmSync(tmpDbPath, { force: true }); } } async function addAction(source: string): Promise { const extensionsDir = getUserExtensionsDir(); mkdirSync(extensionsDir, { recursive: true }); const { dir: sourceDir, name, cleanup } = resolveExtensionSource(source); try { // Validate validateExtension(name, sourceDir, extensionsDir); // Dry-run load to catch errors early await dryRunExtension(sourceDir, name); // Copy to extensions dir const destDir = join(extensionsDir, name); cpSync(sourceDir, destDir, { recursive: true }); // Install dependencies if package.json present if (existsSync(join(destDir, "package.json"))) { console.log("Installing dependencies..."); const installResult = spawnSync("bun", ["install"], { stdio: "inherit", cwd: destDir, }); if (installResult.status !== 0) { console.error("Warning: dependency installation failed"); } } // Install skill const hasSkill = installSkillIfPresent(destDir, name); // Read extension info for report let info: Awaited>; try { info = await readExtensionInfo(destDir); } catch { info = { hasCli: false, hasSkill: hasSkill, cliNames: [] }; } // Report console.log(`\nāœ“ Extension "${name}" installed`); if (info.hasCli) { console.log( ` CLI: ${info.cliNames.join(", ")} (available after image rebuild)`, ); } if (hasSkill) { console.log(` Skill: ${name} (available to agent)`); } if (info.permissionRoles) { console.log( ` Permission: ${name} (default: ${info.permissionRoles.join(", ")})`, ); } if (info.hasCli) { console.log("\nRebuild the agent image to include the CLI:"); console.log(" mercury build"); } console.log("\nRestart mercury to activate:"); console.log(" mercury service restart"); } catch (err) { // Rollback on unexpected error const destDir = join(extensionsDir, name); if (existsSync(destDir)) { rmSync(destDir, { recursive: true, force: true }); } throw err; } finally { cleanup(); } } function removeAction(name: string): void { const extensionsDir = getUserExtensionsDir(); const extDir = join(extensionsDir, name); if (!existsSync(extDir)) { console.error(`Error: extension "${name}" is not installed`); process.exit(1); } // Remove skill const globalDir = getGlobalDir(); const skillDst = join(globalDir, "skills", name); if (existsSync(skillDst)) { rmSync(skillDst, { recursive: true }); } // Remove extension rmSync(extDir, { recursive: true }); console.log(`āœ“ Extension "${name}" removed`); console.log("\nRestart mercury to apply:"); console.log(" mercury service restart"); } function extensionsListAction(): void { const userExtDir = getUserExtensionsDir(); const builtinExtDir = join(__dirname, "..", "extensions"); const extensions: Array<{ name: string; features: string[]; description: string; builtin: boolean; }> = []; // Scan a directory for extensions function scanDir(dir: string, builtin: boolean): void { if (!existsSync(dir)) return; for (const entry of readdirSync(dir, { withFileTypes: true })) { if (!entry.isDirectory()) continue; const name = entry.name; if (!VALID_EXT_NAME_RE.test(name)) continue; if (RESERVED_EXTENSION_NAMES.has(name)) continue; const extDir = join(dir, name); if (!existsSync(join(extDir, "index.ts"))) continue; const features: string[] = []; if (existsSync(join(extDir, "skill", "SKILL.md"))) features.push("Skill"); // Read SKILL.md for description let description = ""; const skillMd = join(extDir, "skill", "SKILL.md"); if (existsSync(skillMd)) { const content = readFileSync(skillMd, "utf-8"); const descMatch = content.match( /^description:\s*(.+?)(?:\n[a-z]|\n---)/ms, ); if (descMatch) { description = descMatch[1].replace(/\n\s*/g, " ").trim(); } } extensions.push({ name, features, description, builtin }); } } scanDir(userExtDir, false); scanDir(builtinExtDir, true); if (extensions.length === 0) { console.log("No extensions installed."); console.log("\nInstall one with:"); console.log(" mercury add ./path/to/extension"); console.log(" mercury add npm:"); console.log(" mercury add git:"); return; } // Sort: user extensions first, then built-in, alphabetically within extensions.sort((a, b) => { if (a.builtin !== b.builtin) return a.builtin ? 1 : -1; return a.name.localeCompare(b.name); }); // Calculate column widths const nameWidth = Math.max(12, ...extensions.map((e) => e.name.length)); const featWidth = Math.max( 10, ...extensions.map((e) => e.features.join(" + ").length || 3), ); for (const ext of extensions) { const features = ext.features.length > 0 ? ext.features.join(" + ") : "—"; const tag = ext.builtin ? " (built-in)" : ""; const desc = ext.description ? ` ${ext.description.slice(0, 60)}${ext.description.length > 60 ? "…" : ""}` : ""; console.log( `${ext.name.padEnd(nameWidth)} ${features.padEnd(featWidth)}${tag}${desc}`, ); } } program .command("chat [text...]") .description("Send a message to Mercury and get a reply") .option("-p, --port ", "Mercury server port", "8787") .option("-s, --space ", "Space to route the message to", "main") .option("-f, --file ", "Attach files to the message") .option("--caller ", "Caller ID", "cli:user") .option("--json", "Output raw JSON response") .action( async ( textParts: string[], options: { port: string; space: string; file?: string[]; caller: string; json?: boolean; }, ) => { let text: string; if (textParts.length > 0) { text = textParts.join(" "); } else if (!process.stdin.isTTY) { text = readFileSync("/dev/stdin", "utf-8").trim(); } else { console.error("Usage: mercury chat "); console.error(' echo "message" | mercury chat'); process.exit(1); } if (!text) { console.error("Error: empty message"); process.exit(1); } const url = `http://localhost:${options.port}/chat`; const body: Record = { text, callerId: options.caller, spaceId: options.space, }; if (options.file && options.file.length > 0) { const files: Array<{ name: string; data: string }> = []; for (const filePath of options.file) { const abs = resolve(CWD, filePath); if (!existsSync(abs)) { console.error(`Error: file not found: ${filePath}`); process.exit(1); } files.push({ name: basename(abs), data: readFileSync(abs).toString("base64"), }); } body.files = files; } try { const res = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }); if (!res.ok) { const err = await res.json().catch(() => ({ error: res.statusText })); console.error( `Error: ${(err as { error?: string }).error || res.statusText}`, ); process.exit(1); } const data = (await res.json()) as { reply: string; files: Array<{ filename: string; mimeType: string; sizeBytes: number; data: string; }>; error?: string; }; if (options.json) { console.log(JSON.stringify(data, null, 2)); } else { if (data.reply) console.log(data.reply); if (data.files && data.files.length > 0) { for (const f of data.files) { const outPath = join(CWD, f.filename); writeFileSync(outPath, Buffer.from(f.data, "base64")); const kb = (f.sizeBytes / 1024).toFixed(1); console.error(`→ ${outPath} (${kb} KB)`); } } } } catch (err) { if ( err instanceof TypeError && (err.message.includes("fetch") || err.message.includes("ECONNREFUSED")) ) { console.error( `Error: cannot connect to Mercury at localhost:${options.port}`, ); console.error("Is Mercury running? Try: mercury service status"); } else { console.error( `Error: ${err instanceof Error ? err.message : String(err)}`, ); } process.exit(1); } }, ); const spacesCommand = program.command("spaces").description("Manage spaces"); spacesCommand .command("list") .description("List all spaces") .action(() => { const spaces = withProjectDb((db) => db.listSpaces()); if (spaces.length === 0) { console.log("No spaces found."); return; } for (const space of spaces) { const tags = space.tags ? ` [${space.tags}]` : ""; console.log(`${space.id}\t${space.name}${tags}`); } }); spacesCommand .command("create ") .description("Create a new space") .option("-n, --name ", "Display name (defaults to id)") .option("-t, --tags ", "Comma-separated tags") .action((id: string, options: { name?: string; tags?: string }) => { const name = options.name?.trim() || id; const space = withProjectDb((db) => db.createSpace(id, name, options.tags)); console.log(`Created space '${space.id}' (${space.name})`); }); program .command("conversations") .alias("convos") .description("List conversations") .option("--unlinked", "Show only unlinked conversations") .action((options: { unlinked?: boolean }) => { const conversations = withProjectDb((db) => db.listConversations(options.unlinked ? { linked: false } : undefined), ); if (conversations.length === 0) { console.log("No conversations found."); return; } for (const convo of conversations) { const title = convo.observedTitle || convo.externalId; const status = convo.spaceId ? `→ ${convo.spaceId}` : "(unlinked)"; console.log(`${convo.id}\t${convo.platform}\t${title}\t${status}`); } }); program .command("link ") .description("Link a conversation to a space") .action((conversation: string, space: string) => { withProjectDb((db) => { const targetSpace = db.getSpace(space); if (!targetSpace) { console.error(`Error: space not found: ${space}`); process.exit(1); } let target = Number.isFinite(Number(conversation)) ? db.listConversations().find((c) => c.id === Number(conversation)) : null; if (!target) { const q = conversation.toLowerCase(); const matches = db.listConversations().filter((c) => { const observed = c.observedTitle?.toLowerCase() ?? ""; const external = c.externalId.toLowerCase(); return observed.includes(q) || external.includes(q); }); if (matches.length === 0) { console.error(`Error: conversation not found: ${conversation}`); process.exit(1); } if (matches.length > 1) { console.error("Error: conversation is ambiguous. Matches:"); for (const match of matches) { const title = match.observedTitle || match.externalId; const status = match.spaceId ? `→ ${match.spaceId}` : "(unlinked)"; console.error( ` ${match.id}\t${match.platform}\t${title}\t${status}`, ); } process.exit(1); } target = matches[0]; } const ok = db.linkConversation(target.id, space); if (!ok) { console.error(`Error: failed to link conversation ${target.id}`); process.exit(1); } const title = target.observedTitle || target.externalId; console.log(`Linked conversation ${target.id} (${title}) → ${space}`); }); }); // Extension commands program .command("add ") .description("Install an extension (local path, npm:, or git:)") .action(addAction); program .command("remove ") .description("Remove an installed extension") .action(removeAction); const extCommand = program .command("extensions") .alias("ext") .description("Manage extensions"); extCommand .command("list") .description("List installed extensions") .action(extensionsListAction); program.parse();