[object Object]

← back to Big Red

fix(chat): replace shell-out to /root/.local/bin/claude (ENOENT on Kamatera) with @anthropic-ai/sdk direct call.

a1189595f8277d4c750d0ede6b793b38a570612f · 2026-05-23 23:45:42 -0700 · Steve Abrams

Files touched

Diff

commit a1189595f8277d4c750d0ede6b793b38a570612f
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Sat May 23 23:45:42 2026 -0700

    fix(chat): replace shell-out to /root/.local/bin/claude (ENOENT on Kamatera) with @anthropic-ai/sdk direct call.
---
 package-lock.json |  72 ++++++++++++++++++++++++++++++
 package.json      |   1 +
 server.js         | 129 +++++++++++++++++++++++++++---------------------------
 3 files changed, 138 insertions(+), 64 deletions(-)

diff --git a/package-lock.json b/package-lock.json
index 39f04bd..d50abcf 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -8,6 +8,7 @@
       "name": "big-red",
       "version": "0.1.0",
       "dependencies": {
+        "@anthropic-ai/sdk": "^0.98.0",
         "dotenv": "^16.4.5",
         "express": "^4.21.0",
         "multer": "^1.4.5-lts.1",
@@ -16,6 +17,42 @@
         "ws": "^8.18.0"
       }
     },
+    "node_modules/@anthropic-ai/sdk": {
+      "version": "0.98.0",
+      "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.98.0.tgz",
+      "integrity": "sha512-N7aXtCvC5g6T1Y4V29lJjceu/zTkVkIZF0jdBvagr0TRFHuKeImffalGWEfqZKrvjH+IQbzJWw6TmSmUzrlMgg==",
+      "license": "MIT",
+      "dependencies": {
+        "json-schema-to-ts": "^3.1.1",
+        "standardwebhooks": "^1.0.0"
+      },
+      "bin": {
+        "anthropic-ai-sdk": "bin/cli"
+      },
+      "peerDependencies": {
+        "zod": "^3.25.0 || ^4.0.0"
+      },
+      "peerDependenciesMeta": {
+        "zod": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@babel/runtime": {
+      "version": "7.29.2",
+      "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz",
+      "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@stablelib/base64": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz",
+      "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==",
+      "license": "MIT"
+    },
     "node_modules/accepts": {
       "version": "1.3.8",
       "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
@@ -435,6 +472,12 @@
         "url": "https://opencollective.com/express"
       }
     },
+    "node_modules/fast-sha256": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz",
+      "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==",
+      "license": "Unlicense"
+    },
     "node_modules/finalhandler": {
       "version": "1.3.2",
       "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
@@ -637,6 +680,19 @@
       "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
       "license": "MIT"
     },
+    "node_modules/json-schema-to-ts": {
+      "version": "3.1.1",
+      "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz",
+      "integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/runtime": "^7.18.3",
+        "ts-algebra": "^2.0.0"
+      },
+      "engines": {
+        "node": ">=16"
+      }
+    },
     "node_modules/locate-path": {
       "version": "5.0.0",
       "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
@@ -1278,6 +1334,16 @@
         "node": ">= 10.x"
       }
     },
+    "node_modules/standardwebhooks": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz",
+      "integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==",
+      "license": "MIT",
+      "dependencies": {
+        "@stablelib/base64": "^1.0.0",
+        "fast-sha256": "^1.3.0"
+      }
+    },
     "node_modules/statuses": {
       "version": "2.0.2",
       "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
@@ -1345,6 +1411,12 @@
         "node": ">=0.6"
       }
     },
+    "node_modules/ts-algebra": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz",
+      "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==",
+      "license": "MIT"
+    },
     "node_modules/type-is": {
       "version": "1.6.18",
       "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
diff --git a/package.json b/package.json
index 4df2743..16b4ff2 100644
--- a/package.json
+++ b/package.json
@@ -7,6 +7,7 @@
     "start": "node server.js"
   },
   "dependencies": {
+    "@anthropic-ai/sdk": "^0.98.0",
     "dotenv": "^16.4.5",
     "express": "^4.21.0",
     "multer": "^1.4.5-lts.1",
diff --git a/server.js b/server.js
index dbf0d6c..98ce364 100644
--- a/server.js
+++ b/server.js
@@ -12,6 +12,7 @@ const { WebSocketServer } = require('ws');
 const QRCode = require('qrcode');
 const multer = require('multer');
 const { Pool } = require('pg');
+const Anthropic = require('@anthropic-ai/sdk');
 
 const PORT = parseInt(process.env.PORT || '9935', 10);
 const ELEVEN_KEY = process.env.ELEVENLABS_API_KEY || '';
@@ -315,76 +316,76 @@ app.get('/api/pair/new', async (req, res) => {
   res.json({ code, phoneUrl, qrDataUrl, lanHost: LAN_HOST, port: PORT });
 });
 
-// ---------- claude CLI helpers ----------
-// Cold-start cost on the Max-plan claude CLI is ~10-25s, so we use the lightest
-// flags possible: haiku model + no session persistence + slash-commands off.
-// API keys MUST be stripped (Steve's standing rule — Max plan only, not API).
-//
-// IMPORTANT: Node's spawn() does PATH lookup against the PARENT process's env,
-// not the env option passed to spawn. pm2 strips ~/.local/bin from PATH so we
-// must resolve `claude` to an absolute path ONCE at module load. Falls back to
-// the bare command if no install dir matches (lets the error surface clearly).
-const CLAUDE_BIN = (() => {
-  const fs = require('fs');
-  const home = process.env.HOME || '/root';
-  const candidates = [
-    `${home}/.local/bin/claude`,
-    '/usr/local/bin/claude',
-    '/opt/homebrew/bin/claude',
-    '/root/.local/bin/claude',
-  ];
-  for (const p of candidates) {
-    try { fs.accessSync(p, fs.constants.X_OK); return p; } catch {}
-  }
-  console.warn('[big-red] WARNING: claude CLI not found in any known location; falling back to bare "claude" (may fail with ENOENT)');
-  return 'claude';
-})();
-console.log(`[big-red] claude CLI resolved to: ${CLAUDE_BIN}`);
+// ---------- Anthropic SDK helpers ----------
+// Was: shelling out to the `claude` CLI via spawn(). That broke in prod
+// (Kamatera) because /root/.local/bin/claude doesn't exist there, so every
+// /api/chat request 500'd with ENOENT. Now we call the Anthropic Messages API
+// directly via @anthropic-ai/sdk. Same signature as the old runClaude so all
+// three callsites (chat, analyze, warm-up) keep working unchanged.
+const ANTHROPIC_KEY = process.env.ANTHROPIC_API_KEY || '';
+if (!ANTHROPIC_KEY) {
+  console.warn('[big-red] WARNING: ANTHROPIC_API_KEY not set — /api/chat and /api/analyze will 500. Add it to .env.');
+}
+const anthropic = ANTHROPIC_KEY ? new Anthropic({ apiKey: ANTHROPIC_KEY }) : null;
+
+// Map the legacy `model: 'haiku'` shorthand (used by old CLI callers) to a
+// current API model ID. Anything else is passed through verbatim so callers
+// can ask for a specific snapshot.
+function resolveModelId(m) {
+  if (!m || m === 'haiku') return 'claude-haiku-4-5-20251001';
+  if (m === 'sonnet') return 'claude-sonnet-4-5-20251022';
+  if (m === 'opus') return 'claude-opus-4-7-20251022';
+  return m;
+}
 
 function runClaude(prompt, { imagePath = null, model = 'haiku', timeoutMs = 240_000 } = {}) {
-  return new Promise((resolve, reject) => {
-    const env = { ...process.env };
-    delete env.ANTHROPIC_API_KEY;
-    delete env.ANTHROPIC_AUTH_TOKEN;
-    // Defensive: pm2 sometimes strips ~/.local/bin from PATH so spawn('claude')
-    // hits ENOENT. Prepend likely claude install dirs on both Mac2 and Kamatera.
-    const home = env.HOME || process.env.HOME || '/root';
-    const extraPaths = [`${home}/.local/bin`, '/usr/local/bin', '/opt/homebrew/bin'];
-    env.PATH = [...extraPaths, env.PATH || '/usr/bin:/bin'].join(':');
-
-    let fullPrompt = prompt;
-    const baseArgs = [];
-    if (imagePath) {
-      // --add-dir must come before --print; reference the image via @path in the prompt
-      baseArgs.push('--add-dir', path.dirname(imagePath));
-      fullPrompt = `${prompt}\n\nImage: @${imagePath}`;
-    }
-    const args = [
-      ...baseArgs,
-      '--print',
-      '--model', model,
-      '--no-session-persistence',
-      '--disable-slash-commands',
-      fullPrompt
+  if (!anthropic) {
+    return Promise.reject(new Error('ANTHROPIC_API_KEY missing — cannot call Anthropic API'));
+  }
+
+  // Build the user message content. Plain text by default; if an image path
+  // is provided, base64-encode it and send a multi-part content array so the
+  // model can see the image (replaces the old `@path` CLI mechanic).
+  let content;
+  if (imagePath) {
+    const buf = fs.readFileSync(imagePath);
+    const ext = path.extname(imagePath).slice(1).toLowerCase();
+    const media_type =
+      ext === 'png' ? 'image/png' :
+      ext === 'webp' ? 'image/webp' :
+      ext === 'gif' ? 'image/gif' :
+      'image/jpeg';
+    content = [
+      { type: 'image', source: { type: 'base64', media_type, data: buf.toString('base64') } },
+      { type: 'text', text: prompt },
     ];
+  } else {
+    content = prompt;
+  }
+
+  const call = anthropic.messages.create({
+    model: resolveModelId(model),
+    max_tokens: 1024,
+    messages: [{ role: 'user', content }],
+  });
 
-    // Run claude from a sandbox dir with no CLAUDE.md so it doesn't waste 60-90s
-    // walking up the dir tree loading Steve's 34KB ~/.claude/CLAUDE.md every call.
-    const child = spawn(CLAUDE_BIN, args, { env, cwd: '/tmp/big-red-claude-sandbox' });
-    let out = '';
-    let err = '';
-    const t = setTimeout(() => {
-      try { child.kill('SIGTERM'); } catch {}
-      reject(new Error('claude CLI timed out'));
-    }, timeoutMs);
-    child.stdout.on('data', (d) => { out += d.toString(); });
-    child.stderr.on('data', (d) => { err += d.toString(); });
-    child.on('close', (code) => {
+  // Preserve the old timeout semantics — reject if the API call hasn't
+  // returned by timeoutMs. The SDK has its own timeout but this gives us
+  // the exact ceiling per-callsite that the old CLI runner enforced.
+  return new Promise((resolve, reject) => {
+    const t = setTimeout(() => reject(new Error('Anthropic API timed out')), timeoutMs);
+    call.then((resp) => {
+      clearTimeout(t);
+      const text = (resp.content || [])
+        .filter((b) => b.type === 'text')
+        .map((b) => b.text)
+        .join('')
+        .trim();
+      resolve(text);
+    }, (err) => {
       clearTimeout(t);
-      if (code !== 0) return reject(new Error(`claude exited ${code}: ${err.slice(0, 400)}`));
-      resolve(out.trim());
+      reject(err);
     });
-    child.on('error', (e) => { clearTimeout(t); reject(e); });
   });
 }
 

← 9b0c0a8 fix(embed): string-escape parse error in embed.html — '\\'  ·  back to Big Red  ·  Add per-site favicon (kills /favicon.ico 404) 0e3f87e →