← back to Big Red
fix: resolve claude CLI to absolute path at startup + extend pm2 PATH env
ca5e47a19df0ba50d702bf09045c6ae9a353462a · 2026-05-19 17:34:09 -0700 · Steve Abrams
Two-part fix for "spawn claude ENOENT" that caused /api/chat to fail
under pm2 (the entire user-facing reason Big Red did nothing).
Root cause: Node's spawn() uses the parent process's PATH for binary
lookup, not the `env` option passed to it. pm2 strips ~/.local/bin
from the inherited PATH so spawn('claude') hit ENOENT every call.
- ecosystem.config.js: set PATH explicitly in pm2 env so child env
inherits the right paths on both Mac2 and Kamatera (Kamatera uses
/root/.local/bin).
- server.js: resolve `claude` to an absolute path ONCE at module load
by checking known install dirs in order, then pass the absolute
path to spawn(). Logs the resolved location on startup. Falls back
to bare "claude" if no install dir matches (so the failure is loud,
not silent).
Verified locally: warm-up now logs "claude CLI warm" instead of
"warm-up skipped: spawn claude ENOENT", and /api/chat returns a
proper retail-persona response in ~10s with live Shopify lookup.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Files touched
M ecosystem.config.jsM server.js
Diff
commit ca5e47a19df0ba50d702bf09045c6ae9a353462a
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Tue May 19 17:34:09 2026 -0700
fix: resolve claude CLI to absolute path at startup + extend pm2 PATH env
Two-part fix for "spawn claude ENOENT" that caused /api/chat to fail
under pm2 (the entire user-facing reason Big Red did nothing).
Root cause: Node's spawn() uses the parent process's PATH for binary
lookup, not the `env` option passed to it. pm2 strips ~/.local/bin
from the inherited PATH so spawn('claude') hit ENOENT every call.
- ecosystem.config.js: set PATH explicitly in pm2 env so child env
inherits the right paths on both Mac2 and Kamatera (Kamatera uses
/root/.local/bin).
- server.js: resolve `claude` to an absolute path ONCE at module load
by checking known install dirs in order, then pass the absolute
path to spawn(). Logs the resolved location on startup. Falls back
to bare "claude" if no install dir matches (so the failure is loud,
not silent).
Verified locally: warm-up now logs "claude CLI warm" instead of
"warm-up skipped: spawn claude ENOENT", and /api/chat returns a
proper retail-persona response in ~10s with live Shopify lookup.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---
ecosystem.config.js | 4 ++++
server.js | 29 ++++++++++++++++++++++++++++-
2 files changed, 32 insertions(+), 1 deletion(-)
diff --git a/ecosystem.config.js b/ecosystem.config.js
index 6be285a..e291f43 100644
--- a/ecosystem.config.js
+++ b/ecosystem.config.js
@@ -9,6 +9,10 @@ module.exports = {
env: {
NODE_ENV: 'production',
PORT: process.env.PORT || '9935',
+ // pm2 strips the interactive PATH, but server.js spawns the `claude` CLI
+ // which lives under ~/.local/bin on Mac2 and under /root/.local/bin on
+ // Kamatera. Without this, /api/chat fails with spawn claude ENOENT.
+ PATH: `${process.env.HOME || '/root'}/.local/bin:/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin`,
},
max_memory_restart: '512M',
out_file: './tmp/aichat.out.log',
diff --git a/server.js b/server.js
index 4729186..dbf0d6c 100644
--- a/server.js
+++ b/server.js
@@ -319,11 +319,38 @@ app.get('/api/pair/new', async (req, res) => {
// 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}`);
+
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 = [];
@@ -343,7 +370,7 @@ function runClaude(prompt, { imagePath = null, model = 'haiku', timeoutMs = 240_
// 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', args, { env, cwd: '/tmp/big-red-claude-sandbox' });
+ const child = spawn(CLAUDE_BIN, args, { env, cwd: '/tmp/big-red-claude-sandbox' });
let out = '';
let err = '';
const t = setTimeout(() => {
← a2d0c5a guard .bak/.pre-/.orig/.swp paths from static + broaden .git
·
back to Big Red
·
add .deploy.conf so /deploy skill can ship aichat to Kamater c69895e →