eleven / ocho Public

Clone
git clone https://amadan.net/eleven/ocho

Plain git — no account needed to clone.

Download

Download this file

1#!/usr/bin/env node
2// lchat-dev-bot — mint an agent invite, provision the Claude bot, and run it,
3// against a local lchat server (e.g. one started by tools/lchat-dev-up.mjs).
4// One command instead of the mint + `bot provision` + `bot run` dance.
5//
6// Usage:
7// ANTHROPIC_API_KEY=sk-ant-... \
8// tools/lchat-dev-bot.mjs --base http://127.0.0.1:PORT --admin ADMINTOKEN
9//
10// --base URL the running server (defaults to .lchat-dev.json, then $LCHAT_BASE)
11// --admin TOKEN the server's admin token (defaults to .lchat-dev.json, then env)
12// --name NAME bot name shown in the roster (default: Claude)
13// --session PATH session file to keep the bot's identity in (default:
14// .lchat-dev-bot.json). If it exists, the bot is REUSED (no new
15// invite/provision); otherwise it's minted + provisioned.
16// --clean drop the stored session and provision a fresh bot
17// --available/--no-available show the bot in members' "Helpers" list (default: on)
18//
19// Uses web/static/crypto.js (the exact browser crypto) to mint the agent
20// invite, builds the binary from this checkout, provisions, then runs.
21// ANTHROPIC_API_KEY is required for the bot to actually build member apps.
22// The session persists by default so the bot keeps its identity (and roster
23// slot) across restarts, like lchat-dev-up keeps the server. A fresh lchat-dev-up
24// instance clears it automatically; --clean resets it by hand. Ctrl-C stops the
25// bot but keeps the session.
26
27import { spawn, execFileSync } from "node:child_process";
28import { mkdtempSync, rmSync, existsSync } from "node:fs";
29import { tmpdir } from "node:os";
30import path from "node:path";
31import { fileURLToPath } from "node:url";
32import { randomSecret, deriveInvite } from "../web/static/crypto.js";
33import { readConfig, BOT_SESSION_PATH } from "./dev-config.mjs";
34
35const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
36
37function arg(name, def) {
38 const i = process.argv.indexOf(`--${name}`);
39 return i >= 0 && i + 1 < process.argv.length ? process.argv[i + 1] : def;
40}
41function log(msg) {
42 process.stderr.write(`lchat-dev-bot: ${msg}\n`);
43}
44function die(msg) {
45 log(msg);
46 process.exit(1);
47}
48
49if (process.argv.includes("-h") || process.argv.includes("--help")) {
50 process.stdout.write(
51 "lchat-dev-bot — mint an agent invite, provision + run the Claude bot\n" +
52 " ANTHROPIC_API_KEY=… tools/lchat-dev-bot.mjs [--base http://127.0.0.1:PORT] [--admin TOKEN]\n" +
53 " --base/--admin default to .lchat-dev.json (written by lchat-dev-up), then $LCHAT_BASE/$LCHAT_ADMIN_TOKEN\n" +
54 " --name NAME --session PATH --clean --no-available\n" +
55 " The session persists by default (.lchat-dev-bot.json); --clean re-provisions a fresh bot.\n",
56 );
57 process.exit(0);
58}
59
60// Fall back to whatever lchat-dev-up wrote so a bare `tools/lchat-dev-bot.mjs`
61// just works against the running local server. Order: explicit flag > env >
62// dev config file.
63const cfg = readConfig() || {};
64const base = (arg("base", process.env.LCHAT_BASE || cfg.base) || "").replace(/\/$/, "");
65const admin = arg("admin", process.env.LCHAT_ADMIN_TOKEN || cfg.adminToken);
66const name = arg("name", "Claude");
67const available = !process.argv.includes("--no-available");
68const apiKey = process.env.ANTHROPIC_API_KEY || "";
69
70// Session persists by default so the bot keeps its identity + roster slot across
71// restarts (mirroring lchat-dev-up's server). --clean drops it to re-provision.
72const session = arg("session", BOT_SESSION_PATH);
73if (process.argv.includes("--clean")) {
74 rmSync(session, { force: true });
75 log(`--clean: dropped ${path.basename(session)} — provisioning a fresh bot`);
76}
77
78if (!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");
79if (!admin) die("set --admin (or LCHAT_ADMIN_TOKEN, or run lchat-dev-up first) to the server's admin token");
80
81async function reachable() {
82 try {
83 return (await fetch(`${base}/api/version`)).ok;
84 } catch {
85 return false;
86 }
87}
88
89// Mint an agent invite via the admin API and return its secret T.
90async function mintAgentInvite() {
91 const T = randomSecret();
92 const d = await deriveInvite(T);
93 const res = await fetch(`${base}/api/admin/key-invites`, {
94 method: "POST",
95 headers: { "Content-Type": "application/json", Authorization: `Bearer ${admin}` },
96 body: JSON.stringify({
97 label: name,
98 id: d.id,
99 claim_hash: d.claimHash,
100 agent: true,
101 available_to_members: available,
102 description: "General assistant",
103 }),
104 });
105 if (!res.ok) throw new Error(`mint agent invite -> HTTP ${res.status} ${await res.text().catch(() => "")}`.trim());
106 return T;
107}
108
109async function main() {
110 if (!(await reachable())) die(`can't reach ${base}/api/version — is the server running?`);
111 if (!apiKey) log("⚠️ ANTHROPIC_API_KEY is not set — the bot will connect but can't build apps.");
112
113 // Build the binary from this checkout (throwaway build dir; the session is
114 // persistent and lives outside it).
115 const workDir = mkdtempSync(path.join(tmpdir(), "lchat-dev-bot-"));
116 const binPath = path.join(workDir, "lchat");
117 log("building lchat from this checkout...");
118 execFileSync("go", ["build", "-o", binPath, "."], { cwd: REPO_ROOT, stdio: "inherit" });
119
120 let cleaned = false;
121 let bot;
122 const cleanup = () => {
123 if (cleaned) return;
124 cleaned = true;
125 bot?.kill();
126 rmSync(workDir, { recursive: true, force: true }); // build dir only — keep the session
127 };
128 process.on("SIGINT", () => { cleanup(); process.exit(0); });
129 process.on("SIGTERM", () => { cleanup(); process.exit(0); });
130 process.on("exit", cleanup);
131
132 // Reuse an existing session, else mint an invite + provision into it.
133 const reused = existsSync(session);
134 if (reused) {
135 log(`reusing existing session ${session} (skipping invite + provision)`);
136 } else {
137 log("minting an agent invite...");
138 const T = await mintAgentInvite();
139 log(`provisioning "${name}"...`);
140 execFileSync(binPath, ["bot", "provision", "-base", base, "-session", session, "-name", name], {
141 stdio: "inherit",
142 env: { ...process.env, LCHAT_INVITE: T },
143 });
144 }
145
146 console.log("");
147 console.log(`Bot: ${name}`);
148 console.log(`Server: ${base}`);
149 console.log(`Session: ${session} ${reused ? "(reused)" : "(new — provisioned)"}`);
150 console.log(`AI build: ${apiKey ? "enabled" : "DISABLED (set ANTHROPIC_API_KEY)"}`);
151 console.log("");
152 console.log("👉 Open (or reload) your browser now so your client grants the instance");
153 console.log(" key to this bot — required before it can build/deploy member apps.");
154 console.log(" Then: + New chat → Helpers → " + name + " → “build me a habit tracker”.");
155 console.log("");
156 console.log("Ctrl-C to stop.");
157 console.log("");
158
159 log("running the bot...");
160 bot = spawn(binPath, ["bot", "run", "-base", base, session], {
161 env: { ...process.env, ANTHROPIC_API_KEY: apiKey },
162 });
163 bot.stdout.pipe(process.stderr);
164 bot.stderr.pipe(process.stderr);
165 await new Promise((resolve) => bot.on("exit", resolve));
166}
167
168main().catch((e) => die(e.message));
169