← 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;