diff --git a/apps/frontend/src/main/__tests__/claude-cli-utils.test.ts b/apps/frontend/src/main/__tests__/claude-cli-utils.test.ts new file mode 100644 index 00000000..42bd919b --- /dev/null +++ b/apps/frontend/src/main/__tests__/claude-cli-utils.test.ts @@ -0,0 +1,126 @@ +import path from 'path'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mockGetToolPath = vi.fn<() => string>(); +const mockGetAugmentedEnv = vi.fn<() => Record>(); + +vi.mock('../cli-tool-manager', () => ({ + getToolPath: mockGetToolPath, +})); + +vi.mock('../env-utils', () => ({ + getAugmentedEnv: mockGetAugmentedEnv, +})); + +describe('claude-cli-utils', () => { + beforeEach(() => { + mockGetToolPath.mockReset(); + mockGetAugmentedEnv.mockReset(); + vi.resetModules(); + }); + + it('prepends the CLI directory to PATH when the command is absolute', async () => { + const command = process.platform === 'win32' + ? 'C:\\Tools\\claude\\claude.exe' + : '/opt/claude/bin/claude'; + const env = { + PATH: process.platform === 'win32' + ? 'C:\\Windows\\System32' + : '/usr/bin', + HOME: '/tmp', + }; + mockGetToolPath.mockReturnValue(command); + mockGetAugmentedEnv.mockReturnValue(env); + + const { getClaudeCliInvocation } = await import('../claude-cli-utils'); + const result = getClaudeCliInvocation(); + + const separator = process.platform === 'win32' ? ';' : ':'; + expect(result.command).toBe(command); + expect(result.env.PATH.split(separator)[0]).toBe(path.dirname(command)); + expect(result.env.HOME).toBe(env.HOME); + }); + + it('sets PATH to the command directory when PATH is empty', async () => { + const command = process.platform === 'win32' + ? 'C:\\Tools\\claude\\claude.exe' + : '/opt/claude/bin/claude'; + const env = { PATH: '' }; + mockGetToolPath.mockReturnValue(command); + mockGetAugmentedEnv.mockReturnValue(env); + + const { getClaudeCliInvocation } = await import('../claude-cli-utils'); + const result = getClaudeCliInvocation(); + + expect(result.env.PATH).toBe(path.dirname(command)); + }); + + it('sets PATH to the command directory when PATH is missing', async () => { + const command = process.platform === 'win32' + ? 'C:\\Tools\\claude\\claude.exe' + : '/opt/claude/bin/claude'; + const env = {}; + mockGetToolPath.mockReturnValue(command); + mockGetAugmentedEnv.mockReturnValue(env); + + const { getClaudeCliInvocation } = await import('../claude-cli-utils'); + const result = getClaudeCliInvocation(); + + expect(result.env.PATH).toBe(path.dirname(command)); + }); + + it('keeps PATH unchanged when the command is not absolute', async () => { + const env = { + PATH: process.platform === 'win32' + ? 'C:\\Windows;C:\\Windows\\System32' + : '/usr/bin:/bin', + }; + mockGetToolPath.mockReturnValue('claude'); + mockGetAugmentedEnv.mockReturnValue(env); + + const { getClaudeCliInvocation } = await import('../claude-cli-utils'); + const result = getClaudeCliInvocation(); + + expect(result.command).toBe('claude'); + expect(result.env.PATH).toBe(env.PATH); + }); + + it('does not duplicate the command directory in PATH', async () => { + const command = process.platform === 'win32' + ? 'C:\\Tools\\claude\\claude.exe' + : '/opt/claude/bin/claude'; + const commandDir = path.dirname(command); + const separator = process.platform === 'win32' ? ';' : ':'; + const env = { PATH: `${commandDir}${separator}/usr/bin` }; + + mockGetToolPath.mockReturnValue(command); + mockGetAugmentedEnv.mockReturnValue(env); + + const { getClaudeCliInvocation } = await import('../claude-cli-utils'); + const result = getClaudeCliInvocation(); + + expect(result.env.PATH).toBe(env.PATH); + }); + + it('treats PATH entries case-insensitively on Windows', async () => { + const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform'); + Object.defineProperty(process, 'platform', { value: 'win32' }); + + try { + const command = 'C:\\Tools\\claude\\claude.exe'; + const env = { PATH: 'c:\\tools\\claude;C:\\Windows' }; + + mockGetToolPath.mockReturnValue(command); + mockGetAugmentedEnv.mockReturnValue(env); + + const { getClaudeCliInvocation } = await import('../claude-cli-utils'); + const result = getClaudeCliInvocation(); + + expect(result.env.PATH).toBe(env.PATH); + } finally { + if (originalPlatform) { + Object.defineProperty(process, 'platform', originalPlatform); + } + } + }); +}); diff --git a/apps/frontend/src/main/__tests__/env-handlers-claude-cli.test.ts b/apps/frontend/src/main/__tests__/env-handlers-claude-cli.test.ts new file mode 100644 index 00000000..a0d3caf3 --- /dev/null +++ b/apps/frontend/src/main/__tests__/env-handlers-claude-cli.test.ts @@ -0,0 +1,219 @@ +import { EventEmitter } from 'events'; +import path from 'path'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { IPC_CHANNELS } from '../../shared/constants'; +const { + mockGetClaudeCliInvocation, + mockGetProject, + spawnMock, + mockIpcMain, +} = vi.hoisted(() => { + const ipcMain = new (class { + handlers = new Map(); + + handle(channel: string, handler: Function): void { + this.handlers.set(channel, handler); + } + + getHandler(channel: string): Function | undefined { + return this.handlers.get(channel); + } + })(); + + return { + mockGetClaudeCliInvocation: vi.fn(), + mockGetProject: vi.fn(), + spawnMock: vi.fn(), + mockIpcMain: ipcMain, + }; +}); + +vi.mock('../claude-cli-utils', () => ({ + getClaudeCliInvocation: mockGetClaudeCliInvocation, +})); + +vi.mock('../project-store', () => ({ + projectStore: { + getProject: mockGetProject, + }, +})); + +vi.mock('child_process', () => ({ + spawn: spawnMock, +})); + +vi.mock('electron', () => ({ + app: { + getPath: vi.fn((name: string) => { + if (name === 'userData') return path.join('/tmp', 'userData'); + return '/tmp'; + }), + }, + ipcMain: mockIpcMain, +})); + +import { registerEnvHandlers } from '../ipc-handlers/env-handlers'; + +function createProc(): EventEmitter & { stdout?: EventEmitter; stderr?: EventEmitter } { + const proc = new EventEmitter() as EventEmitter & { + stdout?: EventEmitter; + stderr?: EventEmitter; + }; + proc.stdout = new EventEmitter(); + proc.stderr = new EventEmitter(); + return proc; +} + +describe('env-handlers Claude CLI usage', () => { + beforeEach(() => { + mockGetClaudeCliInvocation.mockReset(); + mockGetProject.mockReset(); + spawnMock.mockReset(); + }); + + it('uses resolved Claude CLI path/env for auth checks', async () => { + const claudeEnv = { PATH: '/opt/claude/bin:/usr/bin' }; + const command = '/opt/claude/bin/claude'; + mockGetClaudeCliInvocation.mockReturnValue({ + command, + env: claudeEnv, + }); + mockGetProject.mockReturnValue({ id: 'p1', path: '/tmp/project' }); + + const procs: ReturnType[] = []; + spawnMock.mockImplementation(() => { + const proc = createProc(); + procs.push(proc); + return proc; + }); + + registerEnvHandlers(() => null); + const handler = mockIpcMain.getHandler(IPC_CHANNELS.ENV_CHECK_CLAUDE_AUTH); + if (!handler) { + throw new Error('ENV_CHECK_CLAUDE_AUTH handler not registered'); + } + + const resultPromise = handler({}, 'p1'); + expect(spawnMock).toHaveBeenCalledTimes(1); + expect(spawnMock).toHaveBeenCalledWith( + command, + ['--version'], + expect.objectContaining({ cwd: '/tmp/project', env: claudeEnv, shell: false }) + ); + + procs[0].emit('close', 0); + await Promise.resolve(); + + expect(spawnMock).toHaveBeenCalledTimes(2); + expect(spawnMock).toHaveBeenCalledWith( + command, + ['api', '--help'], + expect.objectContaining({ cwd: '/tmp/project', env: claudeEnv, shell: false }) + ); + + procs[1].emit('close', 0); + + const result = await resultPromise; + expect(result).toEqual({ success: true, data: { success: true, authenticated: true } }); + }); + + it('uses resolved Claude CLI path/env for setup-token', async () => { + const claudeEnv = { PATH: '/opt/claude/bin:/usr/bin' }; + const command = '/opt/claude/bin/claude'; + mockGetClaudeCliInvocation.mockReturnValue({ + command, + env: claudeEnv, + }); + mockGetProject.mockReturnValue({ id: 'p2', path: '/tmp/project' }); + + const proc = createProc(); + spawnMock.mockReturnValue(proc); + + registerEnvHandlers(() => null); + const handler = mockIpcMain.getHandler(IPC_CHANNELS.ENV_INVOKE_CLAUDE_SETUP); + if (!handler) { + throw new Error('ENV_INVOKE_CLAUDE_SETUP handler not registered'); + } + + const resultPromise = handler({}, 'p2'); + expect(spawnMock).toHaveBeenCalledWith( + command, + ['setup-token'], + expect.objectContaining({ + cwd: '/tmp/project', + env: claudeEnv, + shell: false, + stdio: 'inherit' + }) + ); + + proc.emit('close', 0); + const result = await resultPromise; + expect(result).toEqual({ success: true, data: { success: true, authenticated: true } }); + }); + + it('returns an error when Claude CLI resolution throws', async () => { + mockGetClaudeCliInvocation.mockImplementation(() => { + throw new Error('Claude CLI exploded'); + }); + mockGetProject.mockReturnValue({ id: 'p3', path: '/tmp/project' }); + + registerEnvHandlers(() => null); + const handler = mockIpcMain.getHandler(IPC_CHANNELS.ENV_CHECK_CLAUDE_AUTH); + if (!handler) { + throw new Error('ENV_CHECK_CLAUDE_AUTH handler not registered'); + } + + const result = await handler({}, 'p3'); + expect(result.success).toBe(false); + expect(result.error).toContain('Claude CLI exploded'); + expect(spawnMock).not.toHaveBeenCalled(); + }); + + it('returns an error when Claude CLI command is missing', async () => { + mockGetClaudeCliInvocation.mockReturnValue({ command: '', env: {} }); + mockGetProject.mockReturnValue({ id: 'p4', path: '/tmp/project' }); + + registerEnvHandlers(() => null); + const handler = mockIpcMain.getHandler(IPC_CHANNELS.ENV_CHECK_CLAUDE_AUTH); + if (!handler) { + throw new Error('ENV_CHECK_CLAUDE_AUTH handler not registered'); + } + + const result = await handler({}, 'p4'); + expect(result.success).toBe(false); + expect(result.error).toContain('Claude CLI path not resolved'); + expect(spawnMock).not.toHaveBeenCalled(); + }); + + it('returns an error when Claude CLI exits with a non-zero code', async () => { + const claudeEnv = { PATH: '/opt/claude/bin:/usr/bin' }; + const command = '/opt/claude/bin/claude'; + mockGetClaudeCliInvocation.mockReturnValue({ + command, + env: claudeEnv, + }); + mockGetProject.mockReturnValue({ id: 'p5', path: '/tmp/project' }); + + const proc = createProc(); + spawnMock.mockReturnValue(proc); + + registerEnvHandlers(() => null); + const handler = mockIpcMain.getHandler(IPC_CHANNELS.ENV_CHECK_CLAUDE_AUTH); + if (!handler) { + throw new Error('ENV_CHECK_CLAUDE_AUTH handler not registered'); + } + + const resultPromise = handler({}, 'p5'); + expect(spawnMock).toHaveBeenCalledWith( + command, + ['--version'], + expect.objectContaining({ cwd: '/tmp/project', env: claudeEnv, shell: false }) + ); + proc.emit('close', 1); + + const result = await resultPromise; + expect(result.success).toBe(false); + expect(result.error).toContain('Claude CLI not found'); + }); +}); diff --git a/apps/frontend/src/main/claude-cli-utils.ts b/apps/frontend/src/main/claude-cli-utils.ts new file mode 100644 index 00000000..3d83a555 --- /dev/null +++ b/apps/frontend/src/main/claude-cli-utils.ts @@ -0,0 +1,49 @@ +import path from 'path'; +import { getAugmentedEnv } from './env-utils'; +import { getToolPath } from './cli-tool-manager'; + +export type ClaudeCliInvocation = { + command: string; + env: Record; +}; + +function ensureCommandDirInPath(command: string, env: Record): Record { + if (!path.isAbsolute(command)) { + return env; + } + + const pathSeparator = process.platform === 'win32' ? ';' : ':'; + const commandDir = path.dirname(command); + const currentPath = env.PATH || ''; + const pathEntries = currentPath.split(pathSeparator); + const normalizedCommandDir = path.normalize(commandDir); + const hasCommandDir = process.platform === 'win32' + ? pathEntries + .map((entry) => path.normalize(entry).toLowerCase()) + .includes(normalizedCommandDir.toLowerCase()) + : pathEntries + .map((entry) => path.normalize(entry)) + .includes(normalizedCommandDir); + + if (hasCommandDir) { + return env; + } + + return { + ...env, + PATH: [commandDir, currentPath].filter(Boolean).join(pathSeparator), + }; +} + +/** + * Returns the Claude CLI command path and an environment with PATH updated to include the CLI directory. + */ +export function getClaudeCliInvocation(): ClaudeCliInvocation { + const command = getToolPath('claude'); + const env = getAugmentedEnv(); + + return { + command, + env: ensureCommandDirInPath(command, env), + }; +} diff --git a/apps/frontend/src/main/ipc-handlers/env-handlers.ts b/apps/frontend/src/main/ipc-handlers/env-handlers.ts index 9574215b..b5038003 100644 --- a/apps/frontend/src/main/ipc-handlers/env-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/env-handlers.ts @@ -8,6 +8,8 @@ import { existsSync, readFileSync, writeFileSync } from 'fs'; import { spawn } from 'child_process'; import { projectStore } from '../project-store'; import { parseEnvFile } from './utils'; +import { getClaudeCliInvocation } from '../claude-cli-utils'; +import { debugError } from '../../shared/utils/debug-logger'; // GitLab environment variable keys const GITLAB_ENV_KEYS = { @@ -25,6 +27,25 @@ function envLine(vars: Record, key: string, defaultVal: string = return vars[key] ? `${key}=${vars[key]}` : `# ${key}=${defaultVal}`; } +type ResolvedClaudeCliInvocation = + | { command: string; env: Record } + | { error: string }; + +function resolveClaudeCliInvocation(): ResolvedClaudeCliInvocation { + try { + const invocation = getClaudeCliInvocation(); + if (!invocation?.command) { + throw new Error('Claude CLI path not resolved'); + } + return { command: invocation.command, env: invocation.env }; + } catch (error) { + debugError('[IPC] Failed to resolve Claude CLI path:', error); + return { + error: error instanceof Error ? error.message : 'Failed to resolve Claude CLI path', + }; + } +} + /** * Register all env-related IPC handlers @@ -552,13 +573,20 @@ ${existingVars['GRAPHITI_DB_PATH'] ? `GRAPHITI_DB_PATH=${existingVars['GRAPHITI_ return { success: false, error: 'Project not found' }; } + const resolved = resolveClaudeCliInvocation(); + if ('error' in resolved) { + return { success: false, error: resolved.error }; + } + const claudeCmd = resolved.command; + const claudeEnv = resolved.env; + try { // Check if Claude CLI is available and authenticated const result = await new Promise((resolve) => { - const proc = spawn('claude', ['--version'], { + const proc = spawn(claudeCmd, ['--version'], { cwd: project.path, - env: { ...process.env }, - shell: true + env: claudeEnv, + shell: false }); let _stdout = ''; @@ -576,10 +604,10 @@ ${existingVars['GRAPHITI_DB_PATH'] ? `GRAPHITI_DB_PATH=${existingVars['GRAPHITI_ if (code === 0) { // Claude CLI is available, check if authenticated // Run a simple command that requires auth - const authCheck = spawn('claude', ['api', '--help'], { + const authCheck = spawn(claudeCmd, ['api', '--help'], { cwd: project.path, - env: { ...process.env }, - shell: true + env: claudeEnv, + shell: false }); authCheck.on('close', (authCode: number | null) => { @@ -614,6 +642,9 @@ ${existingVars['GRAPHITI_DB_PATH'] ? `GRAPHITI_DB_PATH=${existingVars['GRAPHITI_ }); }); + if (!result.success) { + return { success: false, error: result.error || 'Failed to check Claude auth' }; + } return { success: true, data: result }; } catch (error) { return { @@ -632,13 +663,20 @@ ${existingVars['GRAPHITI_DB_PATH'] ? `GRAPHITI_DB_PATH=${existingVars['GRAPHITI_ return { success: false, error: 'Project not found' }; } + const resolved = resolveClaudeCliInvocation(); + if ('error' in resolved) { + return { success: false, error: resolved.error }; + } + const claudeCmd = resolved.command; + const claudeEnv = resolved.env; + try { // Run claude setup-token which will open browser for OAuth const result = await new Promise((resolve) => { - const proc = spawn('claude', ['setup-token'], { + const proc = spawn(claudeCmd, ['setup-token'], { cwd: project.path, - env: { ...process.env }, - shell: true, + env: claudeEnv, + shell: false, stdio: 'inherit' // This allows the terminal to handle the interactive auth }); @@ -666,6 +704,9 @@ ${existingVars['GRAPHITI_DB_PATH'] ? `GRAPHITI_DB_PATH=${existingVars['GRAPHITI_ }); }); + if (!result.success) { + return { success: false, error: result.error || 'Failed to invoke Claude setup' }; + } return { success: true, data: result }; } catch (error) { return { diff --git a/apps/frontend/src/main/ipc-handlers/sections/integration-section.txt b/apps/frontend/src/main/ipc-handlers/sections/integration-section.txt index 5432d011..ff5bb4bd 100644 --- a/apps/frontend/src/main/ipc-handlers/sections/integration-section.txt +++ b/apps/frontend/src/main/ipc-handlers/sections/integration-section.txt @@ -304,9 +304,10 @@ ${existingVars['GRAPHITI_DATABASE'] ? `GRAPHITI_DATABASE=${existingVars['GRAPHIT try { // Check if Claude CLI is available and authenticated const result = await new Promise((resolve) => { - const proc = spawn('claude', ['--version'], { + const { command: claudeCmd, env: claudeEnv } = getClaudeCliInvocation(); + const proc = spawn(claudeCmd, ['--version'], { cwd: project.path, - env: { ...process.env }, + env: claudeEnv, shell: true }); @@ -325,9 +326,9 @@ ${existingVars['GRAPHITI_DATABASE'] ? `GRAPHITI_DATABASE=${existingVars['GRAPHIT if (code === 0) { // Claude CLI is available, check if authenticated // Run a simple command that requires auth - const authCheck = spawn('claude', ['api', '--help'], { + const authCheck = spawn(claudeCmd, ['api', '--help'], { cwd: project.path, - env: { ...process.env }, + env: claudeEnv, shell: true }); @@ -384,9 +385,10 @@ ${existingVars['GRAPHITI_DATABASE'] ? `GRAPHITI_DATABASE=${existingVars['GRAPHIT try { // Run claude setup-token which will open browser for OAuth const result = await new Promise((resolve) => { - const proc = spawn('claude', ['setup-token'], { + const { command: claudeCmd, env: claudeEnv } = getClaudeCliInvocation(); + const proc = spawn(claudeCmd, ['setup-token'], { cwd: project.path, - env: { ...process.env }, + env: claudeEnv, shell: true, stdio: 'inherit' // This allows the terminal to handle the interactive auth }); diff --git a/apps/frontend/src/main/ipc-handlers/terminal-handlers.ts b/apps/frontend/src/main/ipc-handlers/terminal-handlers.ts index e870fef4..b72a62ef 100644 --- a/apps/frontend/src/main/ipc-handlers/terminal-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/terminal-handlers.ts @@ -9,6 +9,7 @@ import { projectStore } from '../project-store'; import { terminalNameGenerator } from '../terminal-name-generator'; import { debugLog, debugError } from '../../shared/utils/debug-logger'; import { escapeShellArg, escapeShellArgWindows } from '../../shared/utils/shell-escape'; +import { getClaudeCliInvocation } from '../claude-cli-utils'; /** @@ -345,20 +346,30 @@ export function registerTerminalHandlers( // Build the login command with the profile's config dir // Use platform-specific syntax and escaping for environment variables let loginCommand: string; + const { command: claudeCmd, env: claudeEnv } = getClaudeCliInvocation(); + const pathPrefix = claudeEnv.PATH + ? (process.platform === 'win32' + ? `set "PATH=${escapeShellArgWindows(claudeEnv.PATH)}" && ` + : `export PATH=${escapeShellArg(claudeEnv.PATH)} && `) + : ''; + const shellClaudeCmd = process.platform === 'win32' + ? `"${escapeShellArgWindows(claudeCmd)}"` + : escapeShellArg(claudeCmd); + if (!profile.isDefault && profile.configDir) { if (process.platform === 'win32') { // SECURITY: Use Windows-specific escaping for cmd.exe const escapedConfigDir = escapeShellArgWindows(profile.configDir); // Windows cmd.exe syntax: set "VAR=value" with %VAR% for expansion - loginCommand = `set "CLAUDE_CONFIG_DIR=${escapedConfigDir}" && echo Config dir: %CLAUDE_CONFIG_DIR% && claude setup-token`; + loginCommand = `${pathPrefix}set "CLAUDE_CONFIG_DIR=${escapedConfigDir}" && echo Config dir: %CLAUDE_CONFIG_DIR% && ${shellClaudeCmd} setup-token`; } else { // SECURITY: Use POSIX escaping for bash/zsh const escapedConfigDir = escapeShellArg(profile.configDir); // Unix/Mac bash/zsh syntax: export VAR=value with $VAR for expansion - loginCommand = `export CLAUDE_CONFIG_DIR=${escapedConfigDir} && echo "Config dir: $CLAUDE_CONFIG_DIR" && claude setup-token`; + loginCommand = `${pathPrefix}export CLAUDE_CONFIG_DIR=${escapedConfigDir} && echo "Config dir: $CLAUDE_CONFIG_DIR" && ${shellClaudeCmd} setup-token`; } } else { - loginCommand = 'claude setup-token'; + loginCommand = `${pathPrefix}${shellClaudeCmd} setup-token`; } debugLog('[IPC] Sending login command to terminal:', loginCommand); diff --git a/apps/frontend/src/main/terminal/__tests__/claude-integration-handler.test.ts b/apps/frontend/src/main/terminal/__tests__/claude-integration-handler.test.ts new file mode 100644 index 00000000..fa61e8b3 --- /dev/null +++ b/apps/frontend/src/main/terminal/__tests__/claude-integration-handler.test.ts @@ -0,0 +1,406 @@ +import { writeFileSync } from 'fs'; +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import type * as pty from '@lydell/node-pty'; +import type { TerminalProcess } from '../types'; + +const mockGetClaudeCliInvocation = vi.fn(); +const mockGetClaudeProfileManager = vi.fn(); +const mockPersistSession = vi.fn(); +const mockReleaseSessionId = vi.fn(); + +const createMockDisposable = (): pty.IDisposable => ({ dispose: vi.fn() }); + +const createMockPty = (): pty.IPty => ({ + pid: 123, + cols: 80, + rows: 24, + process: 'bash', + handleFlowControl: false, + onData: vi.fn(() => createMockDisposable()), + onExit: vi.fn(() => createMockDisposable()), + write: vi.fn(), + resize: vi.fn(), + pause: vi.fn(), + resume: vi.fn(), + kill: vi.fn(), + clear: vi.fn(), +}); + +const createMockTerminal = (overrides: Partial = {}): TerminalProcess => ({ + id: 'term-1', + pty: createMockPty(), + outputBuffer: '', + isClaudeMode: false, + claudeSessionId: undefined, + claudeProfileId: undefined, + title: 'Claude', + cwd: '/tmp/project', + projectPath: '/tmp/project', + ...overrides, +}); + +vi.mock('../../claude-cli-utils', () => ({ + getClaudeCliInvocation: mockGetClaudeCliInvocation, +})); + +vi.mock('../../claude-profile-manager', () => ({ + getClaudeProfileManager: mockGetClaudeProfileManager, +})); + +vi.mock('fs', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + writeFileSync: vi.fn(), + }; +}); + +vi.mock('../session-handler', () => ({ + persistSession: mockPersistSession, + releaseSessionId: mockReleaseSessionId, +})); + +describe('claude-integration-handler', () => { + beforeEach(() => { + mockGetClaudeCliInvocation.mockClear(); + mockGetClaudeProfileManager.mockClear(); + mockPersistSession.mockClear(); + mockReleaseSessionId.mockClear(); + vi.mocked(writeFileSync).mockClear(); + }); + + it('uses the resolved CLI path and PATH prefix when invoking Claude', async () => { + mockGetClaudeCliInvocation.mockReturnValue({ + command: "/opt/claude bin/claude's", + env: { PATH: '/opt/claude/bin:/usr/bin' }, + }); + const profileManager = { + getActiveProfile: vi.fn(() => ({ id: 'default', name: 'Default', isDefault: true })), + getProfile: vi.fn(), + getProfileToken: vi.fn(() => null), + markProfileUsed: vi.fn(), + }; + mockGetClaudeProfileManager.mockReturnValue(profileManager); + + const terminal = createMockTerminal(); + + const { invokeClaude } = await import('../claude-integration-handler'); + invokeClaude(terminal, '/tmp/project', undefined, () => null, vi.fn()); + + const written = vi.mocked(terminal.pty.write).mock.calls[0][0] as string; + expect(written).toContain("cd '/tmp/project' && "); + expect(written).toContain("PATH='/opt/claude/bin:/usr/bin' "); + expect(written).toContain("'/opt/claude bin/claude'\\''s'"); + expect(mockReleaseSessionId).toHaveBeenCalledWith('term-1'); + expect(mockPersistSession).toHaveBeenCalledWith(terminal); + expect(profileManager.getActiveProfile).toHaveBeenCalled(); + expect(profileManager.markProfileUsed).toHaveBeenCalledWith('default'); + }); + + it('converts Windows PATH separators to colons for bash invocations', async () => { + const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform'); + Object.defineProperty(process, 'platform', { value: 'win32' }); + + try { + mockGetClaudeCliInvocation.mockReturnValue({ + command: 'C:\\Tools\\claude\\claude.exe', + env: { PATH: 'C:\\Tools\\claude;C:\\Windows' }, + }); + const profileManager = { + getActiveProfile: vi.fn(() => ({ id: 'default', name: 'Default', isDefault: true })), + getProfile: vi.fn(), + getProfileToken: vi.fn(() => null), + markProfileUsed: vi.fn(), + }; + mockGetClaudeProfileManager.mockReturnValue(profileManager); + + const terminal = createMockTerminal(); + + const { invokeClaude } = await import('../claude-integration-handler'); + invokeClaude(terminal, '/tmp/project', undefined, () => null, vi.fn()); + + const written = vi.mocked(terminal.pty.write).mock.calls[0][0] as string; + expect(written).toContain("PATH='C:\\Tools\\claude:C:\\Windows' "); + expect(written).not.toContain('C:\\Tools\\claude;C:\\Windows'); + } finally { + if (originalPlatform) { + Object.defineProperty(process, 'platform', originalPlatform); + } + } + }); + + it('throws when invokeClaude cannot resolve the CLI invocation', async () => { + mockGetClaudeCliInvocation.mockImplementation(() => { + throw new Error('boom'); + }); + const profileManager = { + getActiveProfile: vi.fn(() => ({ id: 'default', name: 'Default', isDefault: true })), + getProfile: vi.fn(), + getProfileToken: vi.fn(() => null), + markProfileUsed: vi.fn(), + }; + mockGetClaudeProfileManager.mockReturnValue(profileManager); + + const terminal = createMockTerminal({ id: 'term-err' }); + + const { invokeClaude } = await import('../claude-integration-handler'); + expect(() => invokeClaude(terminal, '/tmp/project', undefined, () => null, vi.fn())).toThrow('boom'); + expect(mockReleaseSessionId).toHaveBeenCalledWith('term-err'); + expect(terminal.pty.write).not.toHaveBeenCalled(); + }); + + it('throws when resumeClaude cannot resolve the CLI invocation', async () => { + mockGetClaudeCliInvocation.mockImplementation(() => { + throw new Error('boom'); + }); + + const terminal = createMockTerminal({ + id: 'term-err-2', + cwd: undefined, + projectPath: '/tmp/project', + }); + + const { resumeClaude } = await import('../claude-integration-handler'); + expect(() => resumeClaude(terminal, 'abc123', () => null)).toThrow('boom'); + expect(terminal.pty.write).not.toHaveBeenCalled(); + }); + + it('throws when writing the OAuth token temp file fails', async () => { + mockGetClaudeCliInvocation.mockReturnValue({ + command: '/opt/claude/bin/claude', + env: { PATH: '/opt/claude/bin:/usr/bin' }, + }); + const profileManager = { + getActiveProfile: vi.fn(), + getProfile: vi.fn(() => ({ + id: 'prof-err', + name: 'Work', + isDefault: false, + oauthToken: 'token-value', + })), + getProfileToken: vi.fn(() => 'token-value'), + markProfileUsed: vi.fn(), + }; + mockGetClaudeProfileManager.mockReturnValue(profileManager); + vi.mocked(writeFileSync).mockImplementationOnce(() => { + throw new Error('disk full'); + }); + + const terminal = createMockTerminal({ id: 'term-err-3' }); + + const { invokeClaude } = await import('../claude-integration-handler'); + expect(() => invokeClaude(terminal, '/tmp/project', 'prof-err', () => null, vi.fn())).toThrow('disk full'); + expect(terminal.pty.write).not.toHaveBeenCalled(); + }); + + it('uses the temp token flow when the active profile has an oauth token', async () => { + const command = '/opt/claude/bin/claude'; + const profileManager = { + getActiveProfile: vi.fn(), + getProfile: vi.fn(() => ({ + id: 'prof-1', + name: 'Work', + isDefault: false, + oauthToken: 'token-value', + })), + getProfileToken: vi.fn(() => 'token-value'), + markProfileUsed: vi.fn(), + }; + + mockGetClaudeCliInvocation.mockReturnValue({ + command, + env: { PATH: '/opt/claude/bin:/usr/bin' }, + }); + mockGetClaudeProfileManager.mockReturnValue(profileManager); + const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(1234); + + const terminal = createMockTerminal({ id: 'term-3' }); + + const { invokeClaude } = await import('../claude-integration-handler'); + invokeClaude(terminal, '/tmp/project', 'prof-1', () => null, vi.fn()); + + const tokenPath = vi.mocked(writeFileSync).mock.calls[0]?.[0] as string; + const tokenContents = vi.mocked(writeFileSync).mock.calls[0]?.[1] as string; + expect(tokenPath).toMatch(/^\/tmp\/\.claude-token-1234-[0-9a-f]{16}$/); + expect(tokenContents).toBe("export CLAUDE_CODE_OAUTH_TOKEN='token-value'\n"); + const written = vi.mocked(terminal.pty.write).mock.calls[0][0] as string; + expect(written).toContain("HISTFILE= HISTCONTROL=ignorespace "); + expect(written).toContain(`source '${tokenPath}'`); + expect(written).toContain(`rm -f '${tokenPath}'`); + expect(written).toContain(`exec '${command}'`); + expect(profileManager.getProfile).toHaveBeenCalledWith('prof-1'); + expect(mockPersistSession).toHaveBeenCalledWith(terminal); + + nowSpy.mockRestore(); + }); + + it('prefers the temp token flow when profile has both oauth token and config dir', async () => { + const command = '/opt/claude/bin/claude'; + const profileManager = { + getActiveProfile: vi.fn(), + getProfile: vi.fn(() => ({ + id: 'prof-both', + name: 'Work', + isDefault: false, + oauthToken: 'token-value', + configDir: '/tmp/claude-config', + })), + getProfileToken: vi.fn(() => 'token-value'), + markProfileUsed: vi.fn(), + }; + + mockGetClaudeCliInvocation.mockReturnValue({ + command, + env: { PATH: '/opt/claude/bin:/usr/bin' }, + }); + mockGetClaudeProfileManager.mockReturnValue(profileManager); + const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(5678); + + const terminal = createMockTerminal({ id: 'term-both' }); + + const { invokeClaude } = await import('../claude-integration-handler'); + invokeClaude(terminal, '/tmp/project', 'prof-both', () => null, vi.fn()); + + const tokenPath = vi.mocked(writeFileSync).mock.calls[0]?.[0] as string; + const tokenContents = vi.mocked(writeFileSync).mock.calls[0]?.[1] as string; + expect(tokenPath).toMatch(/^\/tmp\/\.claude-token-5678-[0-9a-f]{16}$/); + expect(tokenContents).toBe("export CLAUDE_CODE_OAUTH_TOKEN='token-value'\n"); + const written = vi.mocked(terminal.pty.write).mock.calls[0][0] as string; + expect(written).toContain(`source '${tokenPath}'`); + expect(written).toContain(`rm -f '${tokenPath}'`); + expect(written).toContain(`exec '${command}'`); + expect(written).not.toContain('CLAUDE_CONFIG_DIR='); + expect(profileManager.getProfile).toHaveBeenCalledWith('prof-both'); + expect(mockPersistSession).toHaveBeenCalledWith(terminal); + expect(profileManager.markProfileUsed).toHaveBeenCalledWith('prof-both'); + + nowSpy.mockRestore(); + }); + + it('handles missing profiles by falling back to the default command', async () => { + const command = '/opt/claude/bin/claude'; + const profileManager = { + getActiveProfile: vi.fn(), + getProfile: vi.fn(() => undefined), + getProfileToken: vi.fn(() => null), + markProfileUsed: vi.fn(), + }; + + mockGetClaudeCliInvocation.mockReturnValue({ + command, + env: { PATH: '/opt/claude/bin:/usr/bin' }, + }); + mockGetClaudeProfileManager.mockReturnValue(profileManager); + + const terminal = createMockTerminal({ id: 'term-6' }); + + const { invokeClaude } = await import('../claude-integration-handler'); + invokeClaude(terminal, '/tmp/project', 'missing', () => null, vi.fn()); + + const written = vi.mocked(terminal.pty.write).mock.calls[0][0] as string; + expect(written).toContain(`'${command}'`); + expect(profileManager.getProfile).toHaveBeenCalledWith('missing'); + expect(profileManager.markProfileUsed).not.toHaveBeenCalled(); + }); + + it('uses the config dir flow when the active profile has a config dir', async () => { + const command = '/opt/claude/bin/claude'; + const profileManager = { + getActiveProfile: vi.fn(), + getProfile: vi.fn(() => ({ + id: 'prof-2', + name: 'Work', + isDefault: false, + configDir: '/tmp/claude-config', + })), + getProfileToken: vi.fn(() => null), + markProfileUsed: vi.fn(), + }; + + mockGetClaudeCliInvocation.mockReturnValue({ + command, + env: { PATH: '/opt/claude/bin:/usr/bin' }, + }); + mockGetClaudeProfileManager.mockReturnValue(profileManager); + + const terminal = createMockTerminal({ id: 'term-4' }); + + const { invokeClaude } = await import('../claude-integration-handler'); + invokeClaude(terminal, '/tmp/project', 'prof-2', () => null, vi.fn()); + + const written = vi.mocked(terminal.pty.write).mock.calls[0][0] as string; + expect(written).toContain("HISTFILE= HISTCONTROL=ignorespace "); + expect(written).toContain("CLAUDE_CONFIG_DIR='/tmp/claude-config'"); + expect(written).toContain(`exec '${command}'`); + expect(profileManager.getProfile).toHaveBeenCalledWith('prof-2'); + expect(profileManager.markProfileUsed).toHaveBeenCalledWith('prof-2'); + expect(mockPersistSession).toHaveBeenCalledWith(terminal); + }); + + it('uses profile switching when a non-default profile is requested', async () => { + const command = '/opt/claude/bin/claude'; + const profileManager = { + getActiveProfile: vi.fn(), + getProfile: vi.fn(() => ({ + id: 'prof-3', + name: 'Team', + isDefault: false, + })), + getProfileToken: vi.fn(() => null), + markProfileUsed: vi.fn(), + }; + + mockGetClaudeCliInvocation.mockReturnValue({ + command, + env: { PATH: '/opt/claude/bin:/usr/bin' }, + }); + mockGetClaudeProfileManager.mockReturnValue(profileManager); + + const terminal = createMockTerminal({ id: 'term-5' }); + + const { invokeClaude } = await import('../claude-integration-handler'); + invokeClaude(terminal, '/tmp/project', 'prof-3', () => null, vi.fn()); + + const written = vi.mocked(terminal.pty.write).mock.calls[0][0] as string; + expect(written).toContain(`'${command}'`); + expect(written).toContain("PATH='/opt/claude/bin:/usr/bin' "); + expect(profileManager.getProfile).toHaveBeenCalledWith('prof-3'); + expect(profileManager.markProfileUsed).toHaveBeenCalledWith('prof-3'); + expect(mockPersistSession).toHaveBeenCalledWith(terminal); + }); + + it('uses the resolved CLI path for resume and continue', async () => { + mockGetClaudeCliInvocation.mockReturnValue({ + command: '/opt/claude/bin/claude', + env: { PATH: '/opt/claude/bin:/usr/bin' }, + }); + + const terminal = createMockTerminal({ + id: 'term-2', + cwd: undefined, + projectPath: '/tmp/project', + }); + + const { resumeClaude } = await import('../claude-integration-handler'); + resumeClaude(terminal, 'abc123', () => null); + + const resumeCall = vi.mocked(terminal.pty.write).mock.calls[0][0] as string; + expect(resumeCall).toContain("PATH='/opt/claude/bin:/usr/bin' "); + expect(resumeCall).toContain("'/opt/claude/bin/claude' --resume 'abc123'"); + expect(terminal.claudeSessionId).toBe('abc123'); + expect(terminal.isClaudeMode).toBe(true); + expect(mockPersistSession).toHaveBeenCalledWith(terminal); + + vi.mocked(terminal.pty.write).mockClear(); + mockPersistSession.mockClear(); + terminal.projectPath = undefined; + terminal.claudeSessionId = undefined; + terminal.isClaudeMode = false; + resumeClaude(terminal, undefined, () => null); + const continueCall = vi.mocked(terminal.pty.write).mock.calls[0][0] as string; + expect(continueCall).toContain("'/opt/claude/bin/claude' --continue"); + expect(terminal.isClaudeMode).toBe(true); + expect(terminal.claudeSessionId).toBeUndefined(); + expect(mockPersistSession).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/frontend/src/main/terminal/claude-integration-handler.ts b/apps/frontend/src/main/terminal/claude-integration-handler.ts index 0949a425..d82bc09b 100644 --- a/apps/frontend/src/main/terminal/claude-integration-handler.ts +++ b/apps/frontend/src/main/terminal/claude-integration-handler.ts @@ -6,12 +6,14 @@ import * as os from 'os'; import * as fs from 'fs'; import * as path from 'path'; +import * as crypto from 'crypto'; import { IPC_CHANNELS } from '../../shared/constants'; import { getClaudeProfileManager } from '../claude-profile-manager'; import * as OutputParser from './output-parser'; import * as SessionHandler from './session-handler'; import { debugLog, debugError } from '../../shared/utils/debug-logger'; import { escapeShellArg, buildCdCommand } from '../../shared/utils/shell-escape'; +import { getClaudeCliInvocation } from '../claude-cli-utils'; import type { TerminalProcess, WindowGetter, @@ -19,6 +21,10 @@ import type { OAuthTokenEvent } from './types'; +function normalizePathForBash(envPath: string): string { + return process.platform === 'win32' ? envPath.replace(/;/g, ':') : envPath; +} + /** * Handle rate limit detection and profile switching */ @@ -236,6 +242,11 @@ export function invokeClaude( // Use safe shell escaping to prevent command injection const cwdCommand = buildCdCommand(cwd); + const { command: claudeCmd, env: claudeEnv } = getClaudeCliInvocation(); + const escapedClaudeCmd = escapeShellArg(claudeCmd); + const pathPrefix = claudeEnv.PATH + ? `PATH=${escapeShellArg(normalizePathForBash(claudeEnv.PATH))} ` + : ''; const needsEnvOverride = profileId && profileId !== previousProfileId; debugLog('[ClaudeIntegration:invokeClaude] Environment override check:', { @@ -252,9 +263,15 @@ export function invokeClaude( }); if (token) { - const tempFile = path.join(os.tmpdir(), `.claude-token-${Date.now()}`); + const nonce = crypto.randomBytes(8).toString('hex'); + const tempFile = path.join(os.tmpdir(), `.claude-token-${Date.now()}-${nonce}`); + const escapedTempFile = escapeShellArg(tempFile); debugLog('[ClaudeIntegration:invokeClaude] Writing token to temp file:', tempFile); - fs.writeFileSync(tempFile, `export CLAUDE_CODE_OAUTH_TOKEN="${token}"\n`, { mode: 0o600 }); + fs.writeFileSync( + tempFile, + `export CLAUDE_CODE_OAUTH_TOKEN=${escapeShellArg(token)}\n`, + { mode: 0o600 } + ); // Clear terminal and run command without adding to shell history: // - HISTFILE= disables history file writing for the current command @@ -262,9 +279,10 @@ export function invokeClaude( // - Leading space ensures the command is ignored even if HISTCONTROL was already set // - Uses subshell (...) to isolate environment changes // This prevents temp file paths from appearing in shell history - const command = `clear && ${cwdCommand} HISTFILE= HISTCONTROL=ignorespace bash -c 'source "${tempFile}" && rm -f "${tempFile}" && exec claude'\r`; + const command = `clear && ${cwdCommand}HISTFILE= HISTCONTROL=ignorespace ${pathPrefix}bash -c "source ${escapedTempFile} && rm -f ${escapedTempFile} && exec ${escapedClaudeCmd}"\r`; debugLog('[ClaudeIntegration:invokeClaude] Executing command (temp file method, history-safe)'); terminal.pty.write(command); + profileManager.markProfileUsed(activeProfile.id); // Update terminal title and persist session const title = `Claude (${activeProfile.name})`; @@ -288,9 +306,10 @@ export function invokeClaude( // SECURITY: Use escapeShellArg for configDir to prevent command injection // Set CLAUDE_CONFIG_DIR as env var before bash -c to avoid embedding user input in the command string const escapedConfigDir = escapeShellArg(activeProfile.configDir); - const command = `clear && ${cwdCommand}HISTFILE= HISTCONTROL=ignorespace CLAUDE_CONFIG_DIR=${escapedConfigDir} bash -c 'exec claude'\r`; + const command = `clear && ${cwdCommand}HISTFILE= HISTCONTROL=ignorespace CLAUDE_CONFIG_DIR=${escapedConfigDir} ${pathPrefix}bash -c "exec ${escapedClaudeCmd}"\r`; debugLog('[ClaudeIntegration:invokeClaude] Executing command (configDir method, history-safe)'); terminal.pty.write(command); + profileManager.markProfileUsed(activeProfile.id); // Update terminal title and persist session const title = `Claude (${activeProfile.name})`; @@ -317,7 +336,7 @@ export function invokeClaude( debugLog('[ClaudeIntegration:invokeClaude] Using terminal environment for non-default profile:', activeProfile.name); } - const command = `${cwdCommand}claude\r`; + const command = `${cwdCommand}${pathPrefix}${escapedClaudeCmd}\r`; debugLog('[ClaudeIntegration:invokeClaude] Executing command (default method):', command); terminal.pty.write(command); @@ -356,14 +375,21 @@ export function resumeClaude( getWindow: WindowGetter ): void { terminal.isClaudeMode = true; + SessionHandler.releaseSessionId(terminal.id); + + const { command: claudeCmd, env: claudeEnv } = getClaudeCliInvocation(); + const escapedClaudeCmd = escapeShellArg(claudeCmd); + const pathPrefix = claudeEnv.PATH + ? `PATH=${escapeShellArg(normalizePathForBash(claudeEnv.PATH))} ` + : ''; let command: string; if (sessionId) { // SECURITY: Escape sessionId to prevent command injection - command = `claude --resume ${escapeShellArg(sessionId)}`; + command = `${pathPrefix}${escapedClaudeCmd} --resume ${escapeShellArg(sessionId)}`; terminal.claudeSessionId = sessionId; } else { - command = 'claude --continue'; + command = `${pathPrefix}${escapedClaudeCmd} --continue`; } terminal.pty.write(`${command}\r`);