From 03a0cc862e1d6cdb29ba3101b192cac9449abf0f Mon Sep 17 00:00:00 2001 From: arkon Date: Sat, 24 Jan 2026 17:43:31 +0100 Subject: [PATCH] fix: resolve claude CLI absolute path to prevent execvp failures When the web server runs in environments where `claude` isn't in PATH (e.g., systemd services, non-login shells), node-pty's execvp(3) fails with "No such file or directory". Fix by resolving the absolute path to the claude binary using `which` with fallback to common installation locations (~/.local/bin, /usr/local/bin, etc.). The resolved path is cached for performance. Co-Authored-By: Claude Opus 4.5 --- src/screen-manager.ts | 50 +++++++++++++++++++++++++++++++++- src/session.ts | 63 +++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 110 insertions(+), 3 deletions(-) diff --git a/src/screen-manager.ts b/src/screen-manager.ts index 8359b203..79d97205 100644 --- a/src/screen-manager.ts +++ b/src/screen-manager.ts @@ -21,6 +21,53 @@ import { dirname, join } from 'node:path'; import { homedir } from 'node:os'; import { ScreenSession, ProcessStats, ScreenSessionWithStats, PersistedRespawnConfig, getErrorMessage } from './types.js'; +// ============================================================================ +// Claude CLI Path Resolution +// ============================================================================ + +/** Common installation paths for the Claude CLI binary */ +const CLAUDE_COMMON_PATHS = [ + `${homedir()}/.local/bin/claude`, + `${homedir()}/.claude/local/claude`, + '/usr/local/bin/claude', + '/usr/bin/claude', + `${homedir()}/.npm-global/bin/claude`, + `${homedir()}/bin/claude`, +]; + +/** Cached resolved path to the claude binary */ +let _resolvedClaudePath: string | null = null; + +/** + * Resolves the absolute path to the `claude` CLI binary. + * Uses `which` first, then falls back to common installation locations. + */ +function resolveClaudePath(): string { + if (_resolvedClaudePath) return _resolvedClaudePath; + + try { + const result = execSync('which claude', { encoding: 'utf-8', timeout: 5000 }).trim(); + if (result && existsSync(result)) { + _resolvedClaudePath = result; + return _resolvedClaudePath; + } + } catch { + // `which` failed, try common paths + } + + for (const p of CLAUDE_COMMON_PATHS) { + if (existsSync(p)) { + _resolvedClaudePath = p; + return _resolvedClaudePath; + } + } + + throw new Error( + 'Claude CLI not found. Ensure `claude` is installed and available in PATH. ' + + 'Checked: PATH lookup and common paths: ' + CLAUDE_COMMON_PATHS.join(', ') + ); +} + /** Path to persisted screen session metadata */ const SCREENS_FILE = join(homedir(), '.claudeman', 'screens.json'); @@ -169,8 +216,9 @@ export class ScreenManager extends EventEmitter { // Set CLAUDEMAN_SCREEN=1 so Claude sessions know they're running in Claudeman // This helps prevent Claude from attempting to kill its own screen session const envVars = `CLAUDEMAN_SCREEN=1 CLAUDEMAN_SESSION_ID=${sessionId} CLAUDEMAN_SCREEN_NAME=${screenName} CLAUDEMAN_API_URL=${process.env.CLAUDEMAN_API_URL || 'http://localhost:3000'}`; + const claudePath = resolveClaudePath(); const cmd = mode === 'claude' - ? `${envVars} claude --dangerously-skip-permissions` + ? `${envVars} ${claudePath} --dangerously-skip-permissions` : `${envVars} $SHELL`; try { diff --git a/src/session.ts b/src/session.ts index f01a4660..a9cd19ee 100644 --- a/src/session.ts +++ b/src/session.ts @@ -16,6 +16,8 @@ */ import { EventEmitter } from 'node:events'; +import { execSync } from 'node:child_process'; +import { existsSync } from 'node:fs'; import { v4 as uuidv4 } from 'uuid'; import * as pty from 'node-pty'; import { SessionState, SessionStatus, SessionConfig, ScreenSession, RalphTrackerState, RalphTodoItem } from './types.js'; @@ -61,6 +63,61 @@ const FOCUS_ESCAPE_FILTER = /\x1b\[\?1004[hl]|\x1b\[[IO]/g; const ANSI_ESCAPE_PATTERN = /\x1b\[[0-9;]*m/g; const TOKEN_PATTERN = /(\d+(?:\.\d+)?)\s*([kKmM])?\s*tokens/; +// ============================================================================ +// Claude CLI Path Resolution +// ============================================================================ + +/** Common installation paths for the Claude CLI binary */ +const CLAUDE_COMMON_PATHS = [ + `${process.env.HOME}/.local/bin/claude`, + `${process.env.HOME}/.claude/local/claude`, + '/usr/local/bin/claude', + '/usr/bin/claude', + `${process.env.HOME}/.npm-global/bin/claude`, + `${process.env.HOME}/bin/claude`, +]; + +/** Cached resolved path to the claude binary */ +let _resolvedClaudePath: string | null = null; + +/** + * Resolves the absolute path to the `claude` CLI binary. + * + * Uses `which` to find the binary in PATH first, then falls back to common + * installation locations. The result is cached for subsequent calls. + * + * @throws {Error} If claude CLI cannot be found in PATH or common locations + */ +function resolveClaudePath(): string { + if (_resolvedClaudePath) return _resolvedClaudePath; + + // Try `which` first (respects PATH) + try { + const result = execSync('which claude', { encoding: 'utf-8', timeout: 5000 }).trim(); + if (result && existsSync(result)) { + _resolvedClaudePath = result; + console.log('[Session] Resolved claude CLI path:', _resolvedClaudePath); + return _resolvedClaudePath; + } + } catch { + // `which` failed, try common paths + } + + // Check common installation paths + for (const p of CLAUDE_COMMON_PATHS) { + if (existsSync(p)) { + _resolvedClaudePath = p; + console.log('[Session] Found claude CLI at common path:', _resolvedClaudePath); + return _resolvedClaudePath; + } + } + + throw new Error( + 'Claude CLI not found. Ensure `claude` is installed and available in PATH. ' + + 'Checked: PATH lookup and common paths: ' + CLAUDE_COMMON_PATHS.join(', ') + ); +} + // ============================================================================ // Buffer Accumulator (reduces GC pressure from string concatenation) // ============================================================================ @@ -736,7 +793,8 @@ export class Session extends EventEmitter { // Fallback to direct PTY if screen is not used if (!this.ptyProcess) { - this.ptyProcess = pty.spawn('claude', [ + const claudePath = resolveClaudePath(); + this.ptyProcess = pty.spawn(claudePath, [ '--dangerously-skip-permissions' ], { name: 'xterm-256color', @@ -1014,7 +1072,8 @@ export class Session extends EventEmitter { // Spawn claude in a real PTY console.log('[Session] Spawning PTY for claude with prompt:', prompt.substring(0, 50)); - this.ptyProcess = pty.spawn('claude', [ + const claudePath = resolveClaudePath(); + this.ptyProcess = pty.spawn(claudePath, [ '-p', '--dangerously-skip-permissions', '--output-format', 'stream-json',