#!/usr/bin/env node // lchat-dev-bot — mint an agent invite, provision the Claude bot, and run it, // against a local lchat server (e.g. one started by tools/lchat-dev-up.mjs). // One command instead of the mint + `bot provision` + `bot run` dance. // // Usage: // ANTHROPIC_API_KEY=sk-ant-... \ // tools/lchat-dev-bot.mjs --base http://127.0.0.1:PORT --admin ADMINTOKEN // // --base URL the running server (defaults to .lchat-dev.json, then $LCHAT_BASE) // --admin TOKEN the server's admin token (defaults to .lchat-dev.json, then env) // --name NAME bot name shown in the roster (default: Claude) // --session PATH session file to keep the bot's identity in (default: // .lchat-dev-bot.json). If it exists, the bot is REUSED (no new // invite/provision); otherwise it's minted + provisioned. // --clean drop the stored session and provision a fresh bot // --available/--no-available show the bot in members' "Helpers" list (default: on) // // Uses web/static/crypto.js (the exact browser crypto) to mint the agent // invite, builds the binary from this checkout, provisions, then runs. // ANTHROPIC_API_KEY is required for the bot to actually build member apps. // The session persists by default so the bot keeps its identity (and roster // slot) across restarts, like lchat-dev-up keeps the server. A fresh lchat-dev-up // instance clears it automatically; --clean resets it by hand. Ctrl-C stops the // bot but keeps the session. import { spawn, execFileSync } from "node:child_process"; import { mkdtempSync, rmSync, existsSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { randomSecret, deriveInvite } from "../web/static/crypto.js"; import { readConfig, BOT_SESSION_PATH } from "./dev-config.mjs"; const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); function arg(name, def) { const i = process.argv.indexOf(`--${name}`); return i >= 0 && i + 1 < process.argv.length ? process.argv[i + 1] : def; } function log(msg) { process.stderr.write(`lchat-dev-bot: ${msg}\n`); } function die(msg) { log(msg); process.exit(1); } if (process.argv.includes("-h") || process.argv.includes("--help")) { process.stdout.write( "lchat-dev-bot — mint an agent invite, provision + run the Claude bot\n" + " ANTHROPIC_API_KEY=… tools/lchat-dev-bot.mjs [--base http://127.0.0.1:PORT] [--admin TOKEN]\n" + " --base/--admin default to .lchat-dev.json (written by lchat-dev-up), then $LCHAT_BASE/$LCHAT_ADMIN_TOKEN\n" + " --name NAME --session PATH --clean --no-available\n" + " The session persists by default (.lchat-dev-bot.json); --clean re-provisions a fresh bot.\n", ); process.exit(0); } // Fall back to whatever lchat-dev-up wrote so a bare `tools/lchat-dev-bot.mjs` // just works against the running local server. Order: explicit flag > env > // dev config file. const cfg = readConfig() || {}; const base = (arg("base", process.env.LCHAT_BASE || cfg.base) || "").replace(/\/$/, ""); const admin = arg("admin", process.env.LCHAT_ADMIN_TOKEN || cfg.adminToken); const name = arg("name", "Claude"); const available = !process.argv.includes("--no-available"); const apiKey = process.env.ANTHROPIC_API_KEY || ""; // Session persists by default so the bot keeps its identity + roster slot across // restarts (mirroring lchat-dev-up's server). --clean drops it to re-provision. const session = arg("session", BOT_SESSION_PATH); if (process.argv.includes("--clean")) { rmSync(session, { force: true }); log(`--clean: dropped ${path.basename(session)} — provisioning a fresh bot`); } if (!base) die("set --base (or LCHAT_BASE, or run lchat-dev-up first) to the server URL, e.g. http://127.0.0.1:8080"); if (!admin) die("set --admin (or LCHAT_ADMIN_TOKEN, or run lchat-dev-up first) to the server's admin token"); async function reachable() { try { return (await fetch(`${base}/api/version`)).ok; } catch { return false; } } // Mint an agent invite via the admin API and return its secret T. async function mintAgentInvite() { const T = randomSecret(); const d = await deriveInvite(T); const res = await fetch(`${base}/api/admin/key-invites`, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${admin}` }, body: JSON.stringify({ label: name, id: d.id, claim_hash: d.claimHash, agent: true, available_to_members: available, description: "General assistant", }), }); if (!res.ok) throw new Error(`mint agent invite -> HTTP ${res.status} ${await res.text().catch(() => "")}`.trim()); return T; } async function main() { if (!(await reachable())) die(`can't reach ${base}/api/version — is the server running?`); if (!apiKey) log("⚠️ ANTHROPIC_API_KEY is not set — the bot will connect but can't build apps."); // Build the binary from this checkout (throwaway build dir; the session is // persistent and lives outside it). const workDir = mkdtempSync(path.join(tmpdir(), "lchat-dev-bot-")); const binPath = path.join(workDir, "lchat"); log("building lchat from this checkout..."); execFileSync("go", ["build", "-o", binPath, "."], { cwd: REPO_ROOT, stdio: "inherit" }); let cleaned = false; let bot; const cleanup = () => { if (cleaned) return; cleaned = true; bot?.kill(); rmSync(workDir, { recursive: true, force: true }); // build dir only — keep the session }; process.on("SIGINT", () => { cleanup(); process.exit(0); }); process.on("SIGTERM", () => { cleanup(); process.exit(0); }); process.on("exit", cleanup); // Reuse an existing session, else mint an invite + provision into it. const reused = existsSync(session); if (reused) { log(`reusing existing session ${session} (skipping invite + provision)`); } else { log("minting an agent invite..."); const T = await mintAgentInvite(); log(`provisioning "${name}"...`); execFileSync(binPath, ["bot", "provision", "-base", base, "-session", session, "-name", name], { stdio: "inherit", env: { ...process.env, LCHAT_INVITE: T }, }); } console.log(""); console.log(`Bot: ${name}`); console.log(`Server: ${base}`); console.log(`Session: ${session} ${reused ? "(reused)" : "(new — provisioned)"}`); console.log(`AI build: ${apiKey ? "enabled" : "DISABLED (set ANTHROPIC_API_KEY)"}`); console.log(""); console.log("👉 Open (or reload) your browser now so your client grants the instance"); console.log(" key to this bot — required before it can build/deploy member apps."); console.log(" Then: + New chat → Helpers → " + name + " → “build me a habit tracker”."); console.log(""); console.log("Ctrl-C to stop."); console.log(""); log("running the bot..."); bot = spawn(binPath, ["bot", "run", "-base", base, session], { env: { ...process.env, ANTHROPIC_API_KEY: apiKey }, }); bot.stdout.pipe(process.stderr); bot.stderr.pipe(process.stderr); await new Promise((resolve) => bot.on("exit", resolve)); } main().catch((e) => die(e.message));