← 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
M package-lock.jsonM package.jsonM server.js
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 →