#!/usr/bin/env node // aivibe: connect your AI agents to your room in Inkubatoren (AI Vibe Danmark). // // What this sends, and nothing else: the NAME of a hook event (for example // "Stop"), a one-way hash of the session id, the agent name you chose and the // tool name ("claude-code"). Never prompts, code, file paths or tool input. // Every call has a 2 second timeout and exits 0, so it can never slow or break // your coding session. No dependencies. Read it all before you install it. import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import crypto from "node:crypto"; import readline from "node:readline/promises"; import { execFileSync } from "node:child_process"; const SERVER = process.env.AIVIBE_SERVER || "https://inkubator.aivibedanmark.dk"; const DIR = path.join(os.homedir(), ".config", "aivibe"); const CONF = path.join(DIR, "config.json"); const STATE = path.join(DIR, "state.json"); const SELF = fs.realpathSync(process.argv[1]); const readJson = (f, d) => { try { return JSON.parse(fs.readFileSync(f, "utf8")); } catch { return d; } }; function writePrivate(f, obj) { fs.mkdirSync(path.dirname(f), { recursive: true, mode: 0o700 }); fs.writeFileSync(f, JSON.stringify(obj, null, 2), { mode: 0o600 }); fs.chmodSync(f, 0o600); } const conf = () => readJson(CONF, null); const say = (s = "") => process.stdout.write(`${s}\n`); async function post(p, body, token, timeoutMs = 10000) { const res = await fetch(`${SERVER}${p}`, { method: "POST", signal: AbortSignal.timeout(timeoutMs), headers: { "content-type": "application/json", ...(token ? { authorization: `Bearer ${token}` } : {}) }, body: JSON.stringify(body), }); let data = {}; try { data = await res.json(); } catch {} return { status: res.status, data }; } async function connect(args) { const label = (args.find((a) => a.startsWith("--label="))?.slice(8) || "Kodeagent").slice(0, 32); const start = await post("/api/device", { kind: "agent", label }); if (start.status !== 200) throw new Error(start.data.error || `Serveren svarede ${start.status}`); say(`\nDin kode: ${start.data.user_code}\n`); say("Åbn Discord, gå til Inkubatoren, tryk «Forbind dine agenter» og skriv koden."); say("Koden virker i 10 minutter. Venter på godkendelse ...\n"); const until = Date.now() + start.data.expires_in * 1000; while (Date.now() < until) { await new Promise((r) => setTimeout(r, start.data.interval * 1000)); const r = await post("/api/device/token", { device_code: start.data.device_code }); if (r.status === 202) continue; if (r.status === 200 && r.data.token) { writePrivate(CONF, { server: SERVER, token: r.data.token, label }); say(`Forbundet som «${label}». Nøglen ligger i ${CONF} (kun læsbar for dig).`); say("Næste skridt: aivibe hooks (viser hvad der installeres, før noget ændres)"); return; } throw new Error(r.data.error || "Koden blev ikke godkendt."); } throw new Error("Koden udløb. Kør aivibe connect igen."); } // ---------- Claude Code hooks ---------- const EVENTS = ["SessionStart", "UserPromptSubmit", "PreToolUse", "Notification", "Stop", "SessionEnd"]; function hookBlock() { const cmd = (e) => `node ${JSON.stringify(SELF)} beacon ${e}`; return Object.fromEntries(EVENTS.map((e) => [e, [{ ...(e === "PreToolUse" ? { matcher: "*" } : {}), hooks: [{ type: "command", command: cmd(e), timeout: 3 }] }]])); } async function hooks(args) { const file = path.join(os.homedir(), ".claude", "settings.json"); const block = hookBlock(); say("Disse hooks tilføjes til Claude Code:\n"); say(JSON.stringify({ hooks: block }, null, 2)); say("\nHver hook sender kun hændelsens navn, en hash af sessionen og dit agentnavn."); if (!args.includes("--install")) { say("\nKør «aivibe hooks --install» for at tilføje dem."); return; } const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); const ok = (await rl.question(`\nTilføj til ${file}? (ja/nej) `)).trim().toLowerCase(); rl.close(); if (ok !== "ja" && ok !== "j" && ok !== "y" && ok !== "yes") { say("Intet ændret."); return; } const s = readJson(file, {}); if (fs.existsSync(file)) fs.copyFileSync(file, `${file}.bak-aivibe`); s.hooks = s.hooks || {}; for (const [e, entries] of Object.entries(block)) { const kept = (s.hooks[e] || []).filter((x) => !JSON.stringify(x).includes("aivibe")); s.hooks[e] = [...kept, ...entries]; } fs.mkdirSync(path.dirname(file), { recursive: true }); fs.writeFileSync(file, JSON.stringify(s, null, 2)); say(`Tilføjet. En kopi af den gamle fil ligger i ${file}.bak-aivibe. Fjern igen med «aivibe hooks --remove».`); } function removeHooks() { const file = path.join(os.homedir(), ".claude", "settings.json"); const s = readJson(file, null); if (!s?.hooks) return say("Ingen hooks at fjerne."); for (const e of Object.keys(s.hooks)) { s.hooks[e] = s.hooks[e].filter((x) => !JSON.stringify(x).includes("aivibe")); if (!s.hooks[e].length) delete s.hooks[e]; } fs.writeFileSync(file, JSON.stringify(s, null, 2)); say("aivibe-hooks fjernet."); } // ---------- the beacon itself (called by the hooks) ---------- async function beacon(event) { const c = conf(); if (!c?.token || !event) return; let input = ""; if (!process.stdin.isTTY) { input = await Promise.race([ new Promise((r) => { let d = ""; process.stdin.on("data", (x) => { d += x; if (d.length > 65536) process.stdin.destroy(); }); process.stdin.on("end", () => r(d)); }), new Promise((r) => setTimeout(() => r(""), 300)), ]); } let sid = "default"; try { sid = JSON.parse(input).session_id || sid; } catch {} const session = crypto.createHash("sha256").update(`aivibe:${sid}`).digest("hex").slice(0, 32); // Throttle: the same state for the same session at most once a minute. const st = readJson(STATE, {}); const key = `${session}:${event === "PreToolUse" || event === "UserPromptSubmit" ? "work" : event}`; if (event !== "SessionEnd" && st[key] && Date.now() - st[key] < 60000) return; st[key] = Date.now(); for (const k of Object.keys(st)) if (Date.now() - st[k] > 3 * 3600000) delete st[k]; writePrivate(STATE, st); await post("/api/beacon", { event, session, agent: c.label, runtime: "claude-code" }, c.token, 2000); } function mcpInstall() { const c = conf(); if (!c?.token) throw new Error("Kør «aivibe connect» først."); // execFile without a shell: the key never lands in your shell history. execFileSync("claude", ["mcp", "add", "--transport", "http", "--scope", "user", "inkubatoren", `${SERVER}/mcp`, "--header", `Authorization: Bearer ${c.token}`], { stdio: "inherit" }); say("MCP-serveren «inkubatoren» er tilføjet til Claude Code."); } const HELP = `aivibe: forbind dine AI-agenter til Inkubatoren aivibe connect [--label=Kodeagent] få en kode og godkend den i Discord aivibe hooks vis de Claude Code-hooks, der sender status aivibe hooks --install tilføj dem (spørger først, tager backup) aivibe hooks --remove fjern dem igen aivibe mcp tilføj Inkubatoren som MCP-server i Claude Code aivibe status vis forbindelsen aivibe disconnect slet nøglen på denne maskine Hvad der sendes: hændelsens navn, en hash af sessionen og dit agentnavn. Intet andet.`; const [cmd, ...args] = process.argv.slice(2); try { if (cmd === "beacon") await beacon(args[0]).catch(() => {}); else if (cmd === "connect") await connect(args); else if (cmd === "hooks") args.includes("--remove") ? removeHooks() : await hooks(args); else if (cmd === "mcp") mcpInstall(); else if (cmd === "status") { const c = conf(); say(c ? `Forbundet som «${c.label}» til ${c.server}` : "Ikke forbundet."); } else if (cmd === "disconnect") { fs.rmSync(CONF, { force: true }); say("Nøglen er slettet fra denne maskine. Tilbagekald den også i Inkubatoren."); } else say(HELP); } catch (e) { if (cmd === "beacon") process.exit(0); console.error(`Fejl: ${e.message}`); process.exit(1); } process.exit(0);