← back to Commercialrealestate
claude-chat-bridge.cjs
256 lines
#!/usr/bin/env node
"use strict";
// claude-chat-bridge.cjs — drop-in "chat with Claude inline" backend for any Express app.
//
// Mounts a single endpoint that takes a chat history + the on-screen run-notes
// context and returns Claude's reply by calling the Anthropic Messages API.
// Dependency-free (uses node:https), so it drops into any project without npm.
//
// Usage (CommonJS):
// const mountClaudeChat = require("./claude-chat-bridge.cjs");
// mountClaudeChat(app, { surface: "control-center" });
//
// Usage (ESM — Node imports CJS default export fine):
// import mountClaudeChat from "./claude-chat-bridge.cjs";
// mountClaudeChat(app, { surface: "morning-review" });
//
// Two backends:
// "cli" (DEFAULT) — shells out to the `claude` CLI in print mode (`claude -p`),
// which authenticates with the logged-in Claude Max/Pro
// subscription. No API key, no per-token billing.
// "api" — calls the Anthropic Messages API with ANTHROPIC_API_KEY
// (metered). Used only as a fallback if the CLI is missing,
// or when forced via opts.backend / CLAUDE_CHAT_BACKEND=api.
//
// Adds:
// POST <path> { messages:[{role,content}], context?, system?, model? }
// → { reply, model, backend, usage? }
// GET <path>/health → { ok, backend, cli: <bool>, key: <bool>, model }
//
// API-key resolution (api backend only), at call time, first match wins:
// 1. process.env.ANTHROPIC_API_KEY
// 2. ~/Projects/secrets-manager/.env (canonical — Steve's secrets fan-out)
// 3. ~/.claude/skills/edges-agent/.env
// 4. ~/.claude.json mcp server env blocks
const fs = require("fs");
const path = require("path");
const os = require("os");
const https = require("https");
const { spawn } = require("child_process");
const HOME = os.homedir();
const DEFAULT_MODEL = "claude-sonnet-4-6"; // fast for a notes Q&A helper
const MAX_CONTEXT_CHARS = 16000; // keep the prompt bounded
const ALLOWED_MODELS = new Set([
"claude-sonnet-4-6",
"claude-opus-4-8",
"claude-haiku-4-5-20251001",
]);
// Resolve the real `claude` binary (NOT the interactive zsh tmux wrapper).
function resolveClaudeBin() {
const candidates = [
process.env.CLAUDE_BIN,
path.join(HOME, ".npm-global/bin/claude"),
"/opt/homebrew/bin/claude",
"/usr/local/bin/claude",
path.join(HOME, ".claude/local/claude"),
].filter(Boolean);
for (const c of candidates) {
try { if (fs.existsSync(c)) return c; } catch {}
}
return null;
}
function readKeyFromEnvFile(file) {
try {
const m = fs.readFileSync(file, "utf8").match(/^ANTHROPIC_API_KEY\s*=\s*(.+)$/m);
if (m) return m[1].trim().replace(/^["']|["']$/g, "");
} catch {}
return null;
}
function readKeyFromClaudeJson() {
try {
const j = JSON.parse(fs.readFileSync(path.join(HOME, ".claude.json"), "utf8"));
const servers = j.mcpServers || {};
for (const name of Object.keys(servers)) {
const env = servers[name] && servers[name].env;
if (env && typeof env.ANTHROPIC_API_KEY === "string" && env.ANTHROPIC_API_KEY) {
return env.ANTHROPIC_API_KEY.trim();
}
}
} catch {}
return null;
}
function anthropicKey() {
if (process.env.ANTHROPIC_API_KEY) return process.env.ANTHROPIC_API_KEY.trim();
return (
readKeyFromEnvFile(path.join(HOME, "Projects/secrets-manager/.env")) ||
readKeyFromEnvFile(path.join(HOME, ".claude/skills/edges-agent/.env")) ||
readKeyFromClaudeJson() ||
null
);
}
function callAnthropic({ key, model, system, messages, maxTokens }) {
const body = JSON.stringify({
model,
max_tokens: maxTokens || 1024,
system,
messages,
});
const opts = {
hostname: "api.anthropic.com",
path: "/v1/messages",
method: "POST",
headers: {
"x-api-key": key,
"anthropic-version": "2023-06-01",
"content-type": "application/json",
"content-length": Buffer.byteLength(body),
},
};
return new Promise((resolve, reject) => {
const req = https.request(opts, (resp) => {
let buf = "";
resp.on("data", (c) => (buf += c));
resp.on("end", () => {
let json;
try { json = JSON.parse(buf); } catch { return reject(new Error("bad JSON from Anthropic: " + buf.slice(0, 300))); }
if (resp.statusCode !== 200) {
const msg = (json && json.error && json.error.message) || buf.slice(0, 300);
return reject(new Error("Anthropic " + resp.statusCode + ": " + msg));
}
const reply = (json.content || [])
.filter((b) => b.type === "text")
.map((b) => b.text)
.join("")
.trim();
resolve({ reply, usage: json.usage || null, model: json.model || model });
});
});
req.on("error", reject);
req.setTimeout(60000, () => req.destroy(new Error("Anthropic request timed out")));
req.write(body);
req.end();
});
}
// CLI backend — `claude -p` against the Max subscription. Conversation history
// is flattened into a single prompt (each call is stateless); the run-notes
// context + behavior live in --append-system-prompt.
function callClaudeCLI({ bin, model, system, messages }) {
const prompt = messages
.map((m) => (m.role === "assistant" ? "Assistant: " : "User: ") + m.content)
.join("\n\n");
const args = ["-p", "--model", model, "--append-system-prompt", system];
return new Promise((resolve, reject) => {
const child = spawn(bin, args, {
cwd: os.tmpdir(), // neutral cwd: don't load a project CLAUDE.md
env: { ...process.env },
stdio: ["pipe", "pipe", "pipe"],
});
let out = "", err = "";
const killer = setTimeout(() => child.kill("SIGKILL"), 90000);
child.stdout.on("data", (c) => (out += c));
child.stderr.on("data", (c) => (err += c));
child.on("error", (e) => { clearTimeout(killer); reject(e); });
child.on("close", (code) => {
clearTimeout(killer);
if (code !== 0) return reject(new Error("claude CLI exited " + code + ": " + (err || out).slice(0, 300)));
const reply = out.trim();
if (!reply) return reject(new Error("claude CLI returned empty output" + (err ? " (stderr: " + err.slice(0, 200) + ")" : "")));
resolve({ reply, model, backend: "cli", usage: null });
});
child.stdin.end(prompt);
});
}
function buildSystem({ surface, context, extra }) {
const ctx = String(context || "").slice(0, MAX_CONTEXT_CHARS);
return [
`You are Claude, embedded as an inline assistant inside "${surface || "a run-notes viewer"}".`,
`The user is reading the run notes / process output shown below and may ask follow-up questions about it.`,
`Answer concisely and specifically, grounded in the notes. If something isn't in the notes, say so rather than guessing.`,
`Use short paragraphs and bullet lists. You can use markdown. Do NOT invent file paths, commands, or results that aren't supported by the notes.`,
extra ? `\nAdditional instructions:\n${extra}` : "",
ctx ? `\n--- RUN NOTES IN VIEW ---\n${ctx}\n--- END RUN NOTES ---` : `\n(No run-notes context was provided.)`,
].filter(Boolean).join("\n");
}
/**
* Mount the inline-chat endpoint onto an Express app.
* @param {import('express').Express} app
* @param {object} [opts]
* @param {string} [opts.path="/api/claude-chat"]
* @param {string} [opts.surface] Human label for the surface (used in the system prompt).
* @param {string} [opts.defaultModel] Override the default model.
* @param {string} [opts.systemExtra] Extra system-prompt instructions for this surface.
*/
function mountClaudeChat(app, opts = {}) {
const route = opts.path || "/api/claude-chat";
const surface = opts.surface || "run-notes viewer";
const fallbackModel = opts.defaultModel || DEFAULT_MODEL;
// Backend: opts.backend → CLAUDE_CHAT_BACKEND env → "cli" (Max subscription).
const wantBackend = (opts.backend || process.env.CLAUDE_CHAT_BACKEND || "cli").toLowerCase();
function pickBackend() {
const bin = resolveClaudeBin();
if (wantBackend === "api") return { backend: "api", bin, key: anthropicKey() };
// default "cli": use it if the binary is present, else fall back to api.
if (bin) return { backend: "cli", bin, key: null };
return { backend: "api", bin: null, key: anthropicKey() };
}
app.get(route + "/health", (_req, res) => {
const bin = resolveClaudeBin();
const sel = pickBackend();
res.json({ ok: true, backend: sel.backend, cli: !!bin, key: !!anthropicKey(), model: fallbackModel, surface });
});
app.post(route, async (req, res) => {
const { messages, context, system, model } = req.body || {};
if (!Array.isArray(messages) || messages.length === 0) {
return res.status(400).json({ error: "messages[] required" });
}
// Sanitize incoming turns to {role, content} with role in user|assistant.
const clean = [];
for (const m of messages) {
const role = m && m.role === "assistant" ? "assistant" : "user";
const content = typeof m?.content === "string" ? m.content : String(m?.content ?? "");
if (content.trim()) clean.push({ role, content: content.slice(0, 8000) });
}
if (!clean.length) return res.status(400).json({ error: "no usable message content" });
if (clean[clean.length - 1].role !== "user") {
return res.status(400).json({ error: "last message must be from the user" });
}
const useModel = ALLOWED_MODELS.has(model) ? model : fallbackModel;
const sys = buildSystem({ surface, context, extra: typeof system === "string" ? system : opts.systemExtra });
const sel = pickBackend();
try {
if (sel.backend === "cli") {
const out = await callClaudeCLI({ bin: sel.bin, model: useModel, system: sys, messages: clean });
return res.json({ reply: out.reply, model: out.model, backend: "cli" });
}
if (!sel.key) {
return res.status(503).json({
error: "No Claude backend available: `claude` CLI not found and ANTHROPIC_API_KEY not set. Install Claude Code (Max login) or add a key via the secrets skill.",
});
}
const out = await callAnthropic({ key: sel.key, model: useModel, system: sys, messages: clean });
res.json({ reply: out.reply, model: out.model, backend: "api", usage: out.usage });
} catch (e) {
res.status(502).json({ error: String(e.message || e), backend: sel.backend });
}
});
return app;
}
mountClaudeChat.anthropicKey = anthropicKey;
mountClaudeChat.DEFAULT_MODEL = DEFAULT_MODEL;
module.exports = mountClaudeChat;