Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions src/main/claude/pty-run-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import { join } from 'path'
import { execSync } from 'child_process'
import { appendFileSync, chmodSync, existsSync, statSync } from 'fs'
import type { NormalizedEvent, RunOptions, EnrichedError } from '../../shared/types'
import { getCliEnv } from '../cli-env'
import { extractAbsoluteShellPath, getCliEnv } from '../cli-env'

// node-pty is a native module — require at runtime to avoid Vite bundling issues
// eslint-disable-next-line @typescript-eslint/no-var-requires
Expand Down Expand Up @@ -312,6 +312,7 @@ export class PtyRunManager extends EventEmitter {

private _findClaudeBinary(): string {
const candidates = [
join(homedir(), '.local/bin/claude'),
'/usr/local/bin/claude',
'/opt/homebrew/bin/claude',
join(homedir(), '.npm-global/bin/claude'),
Expand All @@ -324,12 +325,17 @@ export class PtyRunManager extends EventEmitter {
} catch {}
}

// Non-interactive login shell to avoid shell integration escape sequences
try {
return execSync('/bin/zsh -ilc "whence -p claude"', { encoding: 'utf-8', env: getCliEnv() }).trim()
const raw = execSync('/bin/zsh -lc "whence -p claude"', { encoding: 'utf-8', env: getCliEnv(), timeout: 3000 })
const result = extractAbsoluteShellPath(raw)
if (result) return result
} catch {}

try {
return execSync('/bin/bash -lc "which claude"', { encoding: 'utf-8', env: getCliEnv() }).trim()
const raw = execSync('/bin/bash -lc "which claude"', { encoding: 'utf-8', env: getCliEnv(), timeout: 3000 })
const result = extractAbsoluteShellPath(raw)
if (result) return result
} catch {}

return 'claude'
Expand Down
12 changes: 9 additions & 3 deletions src/main/claude/run-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { join } from 'path'
import { StreamParser } from '../stream-parser'
import { normalize } from './event-normalizer'
import { log as _log } from '../logger'
import { getCliEnv } from '../cli-env'
import { extractAbsoluteShellPath, getCliEnv } from '../cli-env'
import type { ClaudeEvent, NormalizedEvent, RunOptions, EnrichedError } from '../../shared/types'

const MAX_RING_LINES = 100
Expand Down Expand Up @@ -102,6 +102,7 @@ export class RunManager extends EventEmitter {

private _findClaudeBinary(): string {
const candidates = [
join(homedir(), '.local/bin/claude'),
'/usr/local/bin/claude',
'/opt/homebrew/bin/claude',
join(homedir(), '.npm-global/bin/claude'),
Expand All @@ -114,12 +115,17 @@ export class RunManager extends EventEmitter {
} catch {}
}

// Non-interactive login shell to avoid shell integration escape sequences
try {
return execSync('/bin/zsh -ilc "whence -p claude"', { encoding: 'utf-8', env: getCliEnv() }).trim()
const raw = execSync('/bin/zsh -lc "whence -p claude"', { encoding: 'utf-8', env: getCliEnv(), timeout: 3000 })
const result = extractAbsoluteShellPath(raw)
if (result) return result
} catch {}

try {
return execSync('/bin/bash -lc "which claude"', { encoding: 'utf-8', env: getCliEnv() }).trim()
const raw = execSync('/bin/bash -lc "which claude"', { encoding: 'utf-8', env: getCliEnv(), timeout: 3000 })
const result = extractAbsoluteShellPath(raw)
if (result) return result
} catch {}

return 'claude'
Expand Down
52 changes: 46 additions & 6 deletions src/main/cli-env.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,39 @@
import { execSync } from 'child_process'
import { homedir } from 'os'
import { join } from 'path'

let cachedPath: string | null = null

/**
* Strip terminal escape sequences (ANSI CSI, OSC, etc.) that interactive shells
* may emit via shell integrations (e.g. iTerm2, VS Code terminal).
*
* NOTE: similar to stripAnsi in pty-run-manager.ts but with additional
* patterns for bare OSC sequences common in captured shell output.
*/
function stripShellEscapes(str: string): string {
return str
.replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, '') // CSI sequences
.replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, '') // OSC sequences (BEL or ST terminated)
.replace(/\x1b[()][0-9A-Za-z]/g, '') // character set selection
.replace(/\x1b[#=>\[\]]/g, '') // misc escapes
.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f]/g, '') // control chars except \n \r \t
.replace(/\][0-9]+;[^\x07\n]*(?:\x07)?/g, '') // bare OSC without leading ESC (common in captured output)
}

/** Strip shell escape sequences and return the first absolute path found, or null. */
export function extractAbsoluteShellPath(str: string): string | null {
const cleaned = stripShellEscapes(str).trim()
if (!cleaned) return null

for (const line of cleaned.split(/\r?\n/)) {
const candidate = line.trim()
if (candidate.startsWith('/')) return candidate
}

return null
}

function appendPathEntries(target: string[], seen: Set<string>, rawPath: string | undefined): void {
if (!rawPath) return
for (const entry of rawPath.split(':')) {
Expand All @@ -21,19 +53,28 @@ export function getCliPath(): string {
// Start from current process PATH.
appendPathEntries(ordered, seen, process.env.PATH)

// Add common binary locations used on macOS (Homebrew + system).
appendPathEntries(ordered, seen, '/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin')
// Add common binary locations used on macOS (Homebrew + system + user-local).
appendPathEntries(ordered, seen, [
join(homedir(), '.local/bin'),
'/opt/homebrew/bin',
'/usr/local/bin',
'/usr/bin',
'/bin',
'/usr/sbin',
'/sbin',
].join(':'))

// Try interactive login shell first so nvm/asdf/etc. PATH hooks are loaded.
// Try login shell (non-interactive) so nvm/asdf/etc. PATH hooks are loaded
// without triggering shell integration escape sequences.
const pathCommands = [
'/bin/zsh -ilc "echo $PATH"',
'/bin/zsh -lc "echo $PATH"',
'/bin/bash -lc "echo $PATH"',
]

for (const cmd of pathCommands) {
try {
const discovered = execSync(cmd, { encoding: 'utf-8', timeout: 3000 }).trim()
const raw = execSync(cmd, { encoding: 'utf-8', timeout: 3000 })
const discovered = stripShellEscapes(raw).trim()
appendPathEntries(ordered, seen, discovered)
} catch {
// Keep trying fallbacks.
Expand All @@ -53,4 +94,3 @@ export function getCliEnv(extraEnv?: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
delete env.CLAUDECODE
return env
}

8 changes: 5 additions & 3 deletions src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { ControlPlane } from './claude/control-plane'
import { ensureSkills, type SkillStatus } from './skills/installer'
import { fetchCatalog, listInstalled, installPlugin, uninstallPlugin } from './marketplace/catalog'
import { log as _log, LOG_FILE, flushLogs } from './logger'
import { getCliEnv } from './cli-env'
import { extractAbsoluteShellPath, getCliEnv } from './cli-env'
import { IPC } from '../shared/types'
import type { RunOptions, NormalizedEvent, EnrichedError } from '../shared/types'

Expand Down Expand Up @@ -672,12 +672,14 @@ ipcMain.handle(IPC.TRANSCRIBE_AUDIO, async (_event, audioBase64: string) => {

if (!whisperBin) {
try {
whisperBin = execSync('/bin/zsh -lc "whence -p whisper-cli"', { encoding: 'utf-8' }).trim()
const raw = execSync('/bin/zsh -lc "whence -p whisper-cli"', { encoding: 'utf-8', env: getCliEnv(), timeout: 3000 })
whisperBin = extractAbsoluteShellPath(raw) || ''
} catch {}
}
if (!whisperBin) {
try {
whisperBin = execSync('/bin/zsh -lc "whence -p whisper"', { encoding: 'utf-8' }).trim()
const raw = execSync('/bin/zsh -lc "whence -p whisper"', { encoding: 'utf-8', env: getCliEnv(), timeout: 3000 })
whisperBin = extractAbsoluteShellPath(raw) || ''
} catch {}
}

Expand Down
11 changes: 7 additions & 4 deletions src/main/process-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { homedir } from 'os'
import { appendFileSync } from 'fs'
import { join } from 'path'
import { StreamParser } from './stream-parser'
import { getCliEnv } from './cli-env'
import { extractAbsoluteShellPath, getCliEnv } from './cli-env'
import type { ClaudeEvent, RunOptions } from '../shared/types'

const LOG_FILE = join(homedir(), '.clui-debug.log')
Expand Down Expand Up @@ -38,6 +38,7 @@ export class ProcessManager extends EventEmitter {
private findClaudeBinary(): string {
// Try common locations
const candidates = [
join(homedir(), '.local/bin/claude'),
'/usr/local/bin/claude',
'/opt/homebrew/bin/claude',
join(homedir(), '.npm-global/bin/claude'),
Expand All @@ -51,14 +52,16 @@ export class ProcessManager extends EventEmitter {
} catch {}
}

// Fallback: ask a login shell
// Non-interactive login shell to avoid shell integration escape sequences
try {
const result = execSync('/bin/zsh -ilc "whence -p claude"', { encoding: 'utf-8', env: getCliEnv() }).trim()
const raw = execSync('/bin/zsh -lc "whence -p claude"', { encoding: 'utf-8', env: getCliEnv(), timeout: 3000 })
const result = extractAbsoluteShellPath(raw)
if (result) return result
} catch {}

try {
const result = execSync('/bin/bash -lc "which claude"', { encoding: 'utf-8', env: getCliEnv() }).trim()
const raw = execSync('/bin/bash -lc "which claude"', { encoding: 'utf-8', env: getCliEnv(), timeout: 3000 })
const result = extractAbsoluteShellPath(raw)
if (result) return result
} catch {}

Expand Down
Loading