import chalk from "chalk"; import { Command } from "commander"; import { browserClickRef, browserCloseTab, browserDom, browserEval, browserFocusTab, browserOpenTab, browserQuery, browserScreenshot, browserSnapshot, browserStart, browserStatus, browserStop, browserTabs, resolveBrowserControlUrl, } from "../browser/client.js"; import { agentCommand } from "../commands/agent.js"; import { healthCommand } from "../commands/health.js"; import { sendCommand } from "../commands/send.js"; import { sessionsCommand } from "../commands/sessions.js"; import { statusCommand } from "../commands/status.js"; import { danger, info, setVerbose } from "../globals.js"; import { runClawdisMac } from "../infra/clawdis-mac.js"; import { loginWeb, logoutWeb } from "../provider-web.js"; import { defaultRuntime } from "../runtime.js"; import { VERSION } from "../version.js"; import { startWebChatServer } from "../webchat/server.js"; import { registerCronCli } from "./cron-cli.js"; import { createDefaultDeps } from "./deps.js"; import { registerGatewayCli } from "./gateway-cli.js"; import { registerNodesCli } from "./nodes-cli.js"; import { forceFreePort } from "./ports.js"; export { forceFreePort }; export function buildProgram() { const program = new Command(); const PROGRAM_VERSION = VERSION; const TAGLINE = "Send, receive, and auto-reply on WhatsApp (web) and Telegram (bot)."; program.name("clawdis").description("").version(PROGRAM_VERSION); const formatIntroLine = (version: string, rich = true) => { const base = `📡 clawdis ${version} — ${TAGLINE}`; return rich && chalk.level > 0 ? `${chalk.bold.cyan("📡 clawdis")} ${chalk.white(version)} ${chalk.gray("—")} ${chalk.green(TAGLINE)}` : base; }; program.configureHelp({ optionTerm: (option) => chalk.yellow(option.flags), subcommandTerm: (cmd) => chalk.green(cmd.name()), }); program.configureOutput({ writeOut: (str) => { const colored = str .replace(/^Usage:/gm, chalk.bold.cyan("Usage:")) .replace(/^Options:/gm, chalk.bold.cyan("Options:")) .replace(/^Commands:/gm, chalk.bold.cyan("Commands:")); process.stdout.write(colored); }, writeErr: (str) => process.stderr.write(str), outputError: (str, write) => write(chalk.red(str)), }); if (process.argv.includes("-V") || process.argv.includes("--version")) { console.log(formatIntroLine(PROGRAM_VERSION)); process.exit(0); } program.addHelpText("beforeAll", `\n${formatIntroLine(PROGRAM_VERSION)}\n`); const examples = [ [ "clawdis login --verbose", "Link personal WhatsApp Web and show QR + connection logs.", ], [ 'clawdis send --to +15555550123 --message "Hi" --json', "Send via your web session and print JSON result.", ], ["clawdis gateway --port 18789", "Run the WebSocket Gateway locally."], [ "clawdis gateway --force", "Kill anything bound to the default gateway port, then start it.", ], ["clawdis gateway ...", "Gateway control via WebSocket."], [ 'clawdis agent --to +15555550123 --message "Run summary" --deliver', "Talk directly to the agent using the Gateway; optionally send the WhatsApp reply.", ], [ 'clawdis send --provider telegram --to @mychat --message "Hi"', "Send via your Telegram bot.", ], ] as const; const fmtExamples = examples .map(([cmd, desc]) => ` ${chalk.green(cmd)}\n ${chalk.gray(desc)}`) .join("\n"); program.addHelpText( "afterAll", `\n${chalk.bold.cyan("Examples:")}\n${fmtExamples}\n`, ); program .command("login") .description("Link your personal WhatsApp via QR (web provider)") .option("--verbose", "Verbose connection logs", false) .option("--provider ", "Provider alias (default: whatsapp)") .action(async (opts) => { setVerbose(Boolean(opts.verbose)); try { const provider = opts.provider ?? "whatsapp"; await loginWeb(Boolean(opts.verbose), provider); } catch (err) { defaultRuntime.error(danger(`Web login failed: ${String(err)}`)); defaultRuntime.exit(1); } }); program .command("logout") .description("Clear cached WhatsApp Web credentials") .option("--provider ", "Provider alias (default: whatsapp)") .action(async (opts) => { try { void opts.provider; // placeholder for future multi-provider; currently web only. await logoutWeb(defaultRuntime); } catch (err) { defaultRuntime.error(danger(`Logout failed: ${String(err)}`)); defaultRuntime.exit(1); } }); program .command("send") .description("Send a message (WhatsApp web or Telegram bot)") .requiredOption( "-t, --to ", "Recipient: E.164 for WhatsApp (e.g. +15555550123) or Telegram chat id/@username", ) .requiredOption("-m, --message ", "Message body") .option( "--media ", "Attach media (image/audio/video/document). Accepts local paths or URLs.", ) .option( "--provider ", "Delivery provider: whatsapp|telegram (default: whatsapp)", ) .option("--dry-run", "Print payload and skip sending", false) .option("--json", "Output result as JSON", false) .option("--verbose", "Verbose logging", false) .addHelpText( "after", ` Examples: clawdis send --to +15555550123 --message "Hi" clawdis send --to +15555550123 --message "Hi" --media photo.jpg clawdis send --to +15555550123 --message "Hi" --dry-run # print payload only clawdis send --to +15555550123 --message "Hi" --json # machine-readable result`, ) .action(async (opts) => { setVerbose(Boolean(opts.verbose)); const deps = createDefaultDeps(); try { await sendCommand(opts, deps, defaultRuntime); } catch (err) { defaultRuntime.error(String(err)); defaultRuntime.exit(1); } }); program .command("agent") .description( "Talk directly to the configured agent (no chat send; optional WhatsApp delivery)", ) .requiredOption("-m, --message ", "Message body for the agent") .option( "-t, --to ", "Recipient number in E.164 used to derive the session key", ) .option("--session-id ", "Use an explicit session id") .option( "--thinking ", "Thinking level: off | minimal | low | medium | high", ) .option("--verbose ", "Persist agent verbose level for the session") .option( "--deliver", "Send the agent's reply back to WhatsApp (requires --to)", false, ) .option("--json", "Output result as JSON", false) .option( "--timeout ", "Override agent command timeout (seconds, default 600 or config value)", ) .addHelpText( "after", ` Examples: clawdis agent --to +15555550123 --message "status update" clawdis agent --session-id 1234 --message "Summarize inbox" --thinking medium clawdis agent --to +15555550123 --message "Trace logs" --verbose on --json clawdis agent --to +15555550123 --message "Summon reply" --deliver `, ) .action(async (opts) => { const verboseLevel = typeof opts.verbose === "string" ? opts.verbose.toLowerCase() : ""; setVerbose(verboseLevel === "on"); // Build default deps (keeps parity with other commands; future-proofing). void createDefaultDeps(); try { await agentCommand(opts, defaultRuntime); } catch (err) { defaultRuntime.error(String(err)); defaultRuntime.exit(1); } }); registerGatewayCli(program); registerNodesCli(program); registerCronCli(program); program .command("ui") .description("macOS UI automation via Clawdis.app (PeekabooBridge)") .option("--json", "Output JSON (passthrough from clawdis-mac)", false) .allowUnknownOption(true) .argument( "[uiArgs...]", "Args passed through to: clawdis-mac ui ...", ) .addHelpText( "after", ` Examples: clawdis ui permissions status clawdis ui frontmost clawdis ui screenshot clawdis ui see --bundle-id com.apple.Safari clawdis ui click --bundle-id com.apple.Safari --on B1 clawdis ui --json see --bundle-id com.apple.Safari `, ) .action(async (_unused: string[], opts, cmd) => { try { const raw = (cmd.parent?.rawArgs ?? []).map((a: unknown) => String(a)); const idx = raw.indexOf("ui"); const tail = idx >= 0 ? raw.slice(idx + 1) : []; const forwarded = tail.length > 0 && tail[0] === "--json" ? tail.slice(1) : tail; const res = await runClawdisMac(["ui", ...forwarded], { json: Boolean(opts.json), timeoutMs: 45_000, }); if (res.stdout) process.stdout.write(res.stdout); if (res.stderr) process.stderr.write(res.stderr); defaultRuntime.exit(res.code ?? 1); } catch (err) { defaultRuntime.error(danger(String(err))); defaultRuntime.exit(1); } }); program .command("status") .description("Show web session health and recent session recipients") .option("--json", "Output JSON instead of text", false) .option("--deep", "Probe providers (WA connect + Telegram API)", false) .option("--timeout ", "Probe timeout in milliseconds", "10000") .option("--verbose", "Verbose logging", false) .addHelpText( "after", ` Examples: clawdis status # show linked account + session store summary clawdis status --json # machine-readable output clawdis status --deep # run provider probes (WA + Telegram) clawdis status --deep --timeout 5000 # tighten probe timeout`, ) .action(async (opts) => { setVerbose(Boolean(opts.verbose)); const timeout = opts.timeout ? Number.parseInt(String(opts.timeout), 10) : undefined; if (timeout !== undefined && (Number.isNaN(timeout) || timeout <= 0)) { defaultRuntime.error( "--timeout must be a positive integer (milliseconds)", ); defaultRuntime.exit(1); return; } try { await statusCommand( { json: Boolean(opts.json), deep: Boolean(opts.deep), timeoutMs: timeout, }, defaultRuntime, ); } catch (err) { defaultRuntime.error(String(err)); defaultRuntime.exit(1); } }); program .command("health") .description("Fetch health from the running gateway") .option("--json", "Output JSON instead of text", false) .option("--timeout ", "Connection timeout in milliseconds", "10000") .option("--verbose", "Verbose logging", false) .action(async (opts) => { setVerbose(Boolean(opts.verbose)); const timeout = opts.timeout ? Number.parseInt(String(opts.timeout), 10) : undefined; if (timeout !== undefined && (Number.isNaN(timeout) || timeout <= 0)) { defaultRuntime.error( "--timeout must be a positive integer (milliseconds)", ); defaultRuntime.exit(1); return; } try { await healthCommand( { json: Boolean(opts.json), timeoutMs: timeout, }, defaultRuntime, ); } catch (err) { defaultRuntime.error(String(err)); defaultRuntime.exit(1); } }); program .command("sessions") .description("List stored conversation sessions") .option("--json", "Output as JSON", false) .option("--verbose", "Verbose logging", false) .option( "--store ", "Path to session store (default: resolved from config)", ) .option( "--active ", "Only show sessions updated within the past N minutes", ) .addHelpText( "after", ` Examples: clawdis sessions # list all sessions clawdis sessions --active 120 # only last 2 hours clawdis sessions --json # machine-readable output clawdis sessions --store ./tmp/sessions.json Shows token usage per session when the agent reports it; set inbound.reply.agent.contextTokens to see % of your model window.`, ) .action(async (opts) => { setVerbose(Boolean(opts.verbose)); await sessionsCommand( { json: Boolean(opts.json), store: opts.store as string | undefined, active: opts.active as string | undefined, }, defaultRuntime, ); }); program .command("webchat") .description("Start or query the loopback-only web chat server") .option("--port ", "Port to bind (default 18788)") .option("--json", "Return JSON", false) .action(async (opts) => { const port = opts.port ? Number.parseInt(String(opts.port), 10) : undefined; const server = await startWebChatServer(port); if (!server) { const targetPort = port ?? 18788; const msg = `webchat failed to start on http://127.0.0.1:${targetPort}/`; if (opts.json) { defaultRuntime.error( JSON.stringify({ error: msg, port: targetPort }), ); } else { defaultRuntime.error(danger(msg)); } defaultRuntime.exit(1); return; } const payload = { port: server.port, basePath: "/", host: "127.0.0.1", }; if (opts.json) { defaultRuntime.log(JSON.stringify(payload)); } else { defaultRuntime.log( info(`webchat listening on http://127.0.0.1:${server.port}/`), ); } }); const browser = program .command("browser") .description("Manage clawd's dedicated browser (Chrome/Chromium)") .option( "--url ", "Override browser control URL (default from ~/.clawdis/clawdis.json)", ) .option("--json", "Output machine-readable JSON", false) .addHelpText( "after", ` Examples: clawdis browser status clawdis browser start clawdis browser tabs clawdis browser open https://example.com clawdis browser screenshot # emits MEDIA: clawdis browser screenshot --full-page clawdis browser eval "location.href" clawdis browser query "a" --limit 5 clawdis browser dom --format text --max-chars 5000 clawdis browser snapshot --format aria --limit 200 clawdis browser snapshot --format ai clawdis browser click 76 `, ) .action(() => { defaultRuntime.error( danger('Missing subcommand. Try: "clawdis browser status"'), ); defaultRuntime.exit(1); }); const parentOpts = (cmd: Command) => cmd.parent?.opts?.() as { url?: string; json?: boolean }; browser .command("status") .description("Show browser status") .action(async (_opts, cmd) => { const parent = parentOpts(cmd); const baseUrl = resolveBrowserControlUrl(parent?.url); try { const status = await browserStatus(baseUrl); if (parent?.json) { defaultRuntime.log(JSON.stringify(status, null, 2)); return; } defaultRuntime.log( [ `enabled: ${status.enabled}`, `running: ${status.running}`, `controlUrl: ${status.controlUrl}`, `cdpPort: ${status.cdpPort}`, `browser: ${status.chosenBrowser ?? "unknown"}`, `profileColor: ${status.color}`, ].join("\n"), ); } catch (err) { defaultRuntime.error(danger(String(err))); defaultRuntime.exit(1); } }); browser .command("start") .description("Start the clawd browser (no-op if already running)") .action(async (_opts, cmd) => { const parent = parentOpts(cmd); const baseUrl = resolveBrowserControlUrl(parent?.url); try { await browserStart(baseUrl); const status = await browserStatus(baseUrl); if (parent?.json) { defaultRuntime.log(JSON.stringify(status, null, 2)); return; } defaultRuntime.log(info(`🦞 clawd browser running: ${status.running}`)); } catch (err) { defaultRuntime.error(danger(String(err))); defaultRuntime.exit(1); } }); browser .command("stop") .description("Stop the clawd browser (best-effort)") .action(async (_opts, cmd) => { const parent = parentOpts(cmd); const baseUrl = resolveBrowserControlUrl(parent?.url); try { await browserStop(baseUrl); const status = await browserStatus(baseUrl); if (parent?.json) { defaultRuntime.log(JSON.stringify(status, null, 2)); return; } defaultRuntime.log(info(`🦞 clawd browser running: ${status.running}`)); } catch (err) { defaultRuntime.error(danger(String(err))); defaultRuntime.exit(1); } }); browser .command("tabs") .description("List open tabs") .action(async (_opts, cmd) => { const parent = parentOpts(cmd); const baseUrl = resolveBrowserControlUrl(parent?.url); try { const tabs = await browserTabs(baseUrl); if (parent?.json) { defaultRuntime.log(JSON.stringify({ tabs }, null, 2)); return; } if (tabs.length === 0) { defaultRuntime.log("No tabs (browser closed or no targets)."); return; } defaultRuntime.log( tabs .map( (t, i) => `${i + 1}. ${t.title || "(untitled)"}\n ${t.url}\n id: ${t.targetId}`, ) .join("\n"), ); } catch (err) { defaultRuntime.error(danger(String(err))); defaultRuntime.exit(1); } }); browser .command("open") .description("Open a URL in a new tab") .argument("", "URL to open") .action(async (url: string, cmd) => { const parent = parentOpts(cmd); const baseUrl = resolveBrowserControlUrl(parent?.url); try { const tab = await browserOpenTab(baseUrl, url); if (parent?.json) { defaultRuntime.log(JSON.stringify(tab, null, 2)); return; } defaultRuntime.log(`opened: ${tab.url}\nid: ${tab.targetId}`); } catch (err) { defaultRuntime.error(danger(String(err))); defaultRuntime.exit(1); } }); browser .command("focus") .description("Focus/activate a tab by target id") .argument("", "CDP target id") .action(async (targetId: string, cmd) => { const parent = parentOpts(cmd); const baseUrl = resolveBrowserControlUrl(parent?.url); try { await browserFocusTab(baseUrl, targetId); if (parent?.json) { defaultRuntime.log(JSON.stringify({ ok: true }, null, 2)); return; } defaultRuntime.log("ok"); } catch (err) { defaultRuntime.error(danger(String(err))); defaultRuntime.exit(1); } }); browser .command("close") .description("Close a tab by target id") .argument("", "CDP target id") .action(async (targetId: string, cmd) => { const parent = parentOpts(cmd); const baseUrl = resolveBrowserControlUrl(parent?.url); try { await browserCloseTab(baseUrl, targetId); if (parent?.json) { defaultRuntime.log(JSON.stringify({ ok: true }, null, 2)); return; } defaultRuntime.log("ok"); } catch (err) { defaultRuntime.error(danger(String(err))); defaultRuntime.exit(1); } }); browser .command("screenshot") .description("Capture a screenshot (defaults to first tab)") .argument("[targetId]", "CDP target id") .option("--full-page", "Capture full page (best-effort)", false) .action(async (targetId: string | undefined, opts, cmd) => { const parent = parentOpts(cmd); const baseUrl = resolveBrowserControlUrl(parent?.url); try { const result = await browserScreenshot(baseUrl, { targetId: targetId?.trim() || undefined, fullPage: Boolean(opts.fullPage), }); if (parent?.json) { defaultRuntime.log(JSON.stringify(result, null, 2)); return; } // Print MEDIA: token so the agent can forward the image as an attachment. defaultRuntime.log(`MEDIA:${result.path}`); } catch (err) { defaultRuntime.error(danger(String(err))); defaultRuntime.exit(1); } }); browser .command("eval") .description("Evaluate JavaScript in the page context") .argument("[js]", "JavaScript expression (or use --js-file/--js-stdin)") .option("--target-id ", "CDP target id (or unique prefix)") .option("--await", "Await promises (Runtime.evaluate awaitPromise)", false) .option("--js-file ", "Read JavaScript from a file") .option("--js-stdin", "Read JavaScript from stdin", false) .action(async (jsArg: string | undefined, opts, cmd) => { const parent = parentOpts(cmd); const baseUrl = resolveBrowserControlUrl(parent?.url); let js = jsArg?.trim() ?? ""; if (opts.jsFile && opts.jsStdin) { defaultRuntime.error(danger("Use either --js-file or --js-stdin.")); defaultRuntime.exit(2); return; } if (opts.jsFile) { const fs = await import("node:fs/promises"); js = await fs.readFile(opts.jsFile, "utf8"); } else if (opts.jsStdin) { js = await new Promise((resolve, reject) => { let buf = ""; process.stdin.setEncoding("utf8"); process.stdin.on("data", (c) => { buf += c; }); process.stdin.on("end", () => resolve(buf)); process.stdin.on("error", (e) => reject(e)); }); } if (!js.trim()) { defaultRuntime.error( danger("Missing JavaScript. Pass or use --js-file/--js-stdin."), ); defaultRuntime.exit(2); return; } try { const result = await browserEval(baseUrl, { js, targetId: opts.targetId?.trim() || undefined, awaitPromise: Boolean(opts.await), }); if (parent?.json) { defaultRuntime.log(JSON.stringify(result, null, 2)); return; } const v = result.result; if (Object.hasOwn(v, "value")) { const value = (v as { value?: unknown }).value; defaultRuntime.log( typeof value === "string" ? value : JSON.stringify(value, null, 2), ); return; } defaultRuntime.log(v.description ?? JSON.stringify(v, null, 2)); } catch (err) { defaultRuntime.error(danger(String(err))); defaultRuntime.exit(1); } }); browser .command("query") .description("Query elements by CSS selector") .argument("", "CSS selector") .option("--target-id ", "CDP target id (or unique prefix)") .option("--limit ", "Max matches (default: 20)", (v: string) => Number(v), ) .option( "--format ", "Text output format (default: text)", "text", ) .action(async (selector: string, opts, cmd) => { const parent = parentOpts(cmd); const baseUrl = resolveBrowserControlUrl(parent?.url); try { const result = await browserQuery(baseUrl, { selector, targetId: opts.targetId?.trim() || undefined, limit: Number.isFinite(opts.limit) ? opts.limit : undefined, }); if (parent?.json || opts.format === "json") { defaultRuntime.log(JSON.stringify(result, null, 2)); return; } if (!result.matches.length) { defaultRuntime.log("No matches."); return; } defaultRuntime.log( result.matches .map((m) => { const id = m.id ? `#${m.id}` : ""; const cls = m.className ? `.${m.className .split(/\s+/) .filter(Boolean) .slice(0, 3) .join(".")}` : ""; const head = `${m.index}. <${m.tag}${id}${cls}>`; const text = m.text ? `\n ${m.text}` : ""; return `${head}${text}`; }) .join("\n"), ); } catch (err) { defaultRuntime.error(danger(String(err))); defaultRuntime.exit(1); } }); browser .command("dom") .description("Dump DOM (html or text) with truncation") .option("--format ", "Output format (default: html)", "html") .option("--target-id ", "CDP target id (or unique prefix)") .option("--selector ", "Optional CSS selector to scope the dump") .option( "--max-chars ", "Max characters (default: 200000)", (v: string) => Number(v), ) .option("--out ", "Write output to a file") .action(async (opts, cmd) => { const parent = parentOpts(cmd); const baseUrl = resolveBrowserControlUrl(parent?.url); const format = opts.format === "text" ? "text" : "html"; try { const result = await browserDom(baseUrl, { format, targetId: opts.targetId?.trim() || undefined, maxChars: Number.isFinite(opts.maxChars) ? opts.maxChars : undefined, selector: opts.selector?.trim() || undefined, }); if (opts.out) { const fs = await import("node:fs/promises"); await fs.writeFile(opts.out, result.text, "utf8"); if (parent?.json) { defaultRuntime.log( JSON.stringify({ ok: true, out: opts.out }, null, 2), ); } else { defaultRuntime.log(opts.out); } return; } if (parent?.json) { defaultRuntime.log(JSON.stringify(result, null, 2)); return; } defaultRuntime.log(result.text); } catch (err) { defaultRuntime.error(danger(String(err))); defaultRuntime.exit(1); } }); browser .command("snapshot") .description("Capture an AI-friendly snapshot (aria, domSnapshot, or ai)") .option( "--format ", "Snapshot format (default: aria)", "aria", ) .option("--target-id ", "CDP target id (or unique prefix)") .option("--limit ", "Max nodes (default: 500/800)", (v: string) => Number(v), ) .option("--out ", "Write snapshot to a file") .action(async (opts, cmd) => { const parent = parentOpts(cmd); const baseUrl = resolveBrowserControlUrl(parent?.url); const format = opts.format === "domSnapshot" ? "domSnapshot" : opts.format === "ai" ? "ai" : "aria"; try { const result = await browserSnapshot(baseUrl, { format, targetId: opts.targetId?.trim() || undefined, limit: Number.isFinite(opts.limit) ? opts.limit : undefined, }); if (opts.out) { const fs = await import("node:fs/promises"); if (result.format === "ai") { await fs.writeFile(opts.out, result.snapshot, "utf8"); } else { const payload = JSON.stringify(result, null, 2); await fs.writeFile(opts.out, payload, "utf8"); } if (parent?.json) { defaultRuntime.log( JSON.stringify({ ok: true, out: opts.out }, null, 2), ); } else { defaultRuntime.log(opts.out); } return; } if (parent?.json) { defaultRuntime.log(JSON.stringify(result, null, 2)); return; } if (result.format === "ai") { defaultRuntime.log(result.snapshot); return; } if (result.format === "domSnapshot") { defaultRuntime.log(JSON.stringify(result, null, 2)); return; } // aria text rendering const nodes = "nodes" in result ? result.nodes : []; defaultRuntime.log( nodes .map((n) => { const indent = " ".repeat(Math.min(20, n.depth)); const name = n.name ? ` "${n.name}"` : ""; const value = n.value ? ` = "${n.value}"` : ""; return `${indent}- ${n.role}${name}${value}`; }) .join("\n"), ); } catch (err) { defaultRuntime.error(danger(String(err))); defaultRuntime.exit(1); } }); browser .command("click") .description("Click an element by ref from an ai snapshot (e.g. 76)") .argument("", "Ref id from ai snapshot") .option("--target-id ", "CDP target id (or unique prefix)") .action(async (ref: string, opts, cmd) => { const parent = parentOpts(cmd); const baseUrl = resolveBrowserControlUrl(parent?.url); try { const result = await browserClickRef(baseUrl, { ref, targetId: opts.targetId?.trim() || undefined, }); if (parent?.json) { defaultRuntime.log(JSON.stringify(result, null, 2)); return; } defaultRuntime.log(`clicked ref ${ref} on ${result.url}`); } catch (err) { defaultRuntime.error(danger(String(err))); defaultRuntime.exit(1); } }); return program; }