diff --git a/src/__tests__/claude-provider-abort-signal.test.ts b/src/__tests__/claude-provider-abort-signal.test.ts index b3f4a8a..266f06b 100644 --- a/src/__tests__/claude-provider-abort-signal.test.ts +++ b/src/__tests__/claude-provider-abort-signal.test.ts @@ -18,6 +18,8 @@ vi.mock('../infra/claude/client.js', () => ({ vi.mock('../infra/config/index.js', () => ({ resolveAnthropicApiKey: mockResolveAnthropicApiKey, + resolveClaudeCliPath: vi.fn(() => undefined), + loadProjectConfig: vi.fn(() => ({})), })); import { ClaudeProvider } from '../infra/providers/claude.js'; diff --git a/src/__tests__/cursor-provider.test.ts b/src/__tests__/cursor-provider.test.ts index 8071129..e614124 100644 --- a/src/__tests__/cursor-provider.test.ts +++ b/src/__tests__/cursor-provider.test.ts @@ -12,8 +12,14 @@ const { mockCallCursorCustom: vi.fn(), })); -const { mockResolveCursorApiKey } = vi.hoisted(() => ({ +const { + mockResolveCursorApiKey, + mockResolveCursorCliPath, + mockLoadProjectConfig, +} = vi.hoisted(() => ({ mockResolveCursorApiKey: vi.fn(() => undefined), + mockResolveCursorCliPath: vi.fn(() => undefined), + mockLoadProjectConfig: vi.fn(() => ({})), })); vi.mock('../infra/cursor/index.js', () => ({ @@ -23,6 +29,8 @@ vi.mock('../infra/cursor/index.js', () => ({ vi.mock('../infra/config/index.js', () => ({ resolveCursorApiKey: mockResolveCursorApiKey, + resolveCursorCliPath: mockResolveCursorCliPath, + loadProjectConfig: mockLoadProjectConfig, })); import { CursorProvider } from '../infra/providers/cursor.js'; @@ -41,6 +49,8 @@ describe('CursorProvider', () => { beforeEach(() => { vi.clearAllMocks(); mockResolveCursorApiKey.mockReturnValue(undefined); + mockResolveCursorCliPath.mockReturnValue(undefined); + mockLoadProjectConfig.mockReturnValue({}); }); it('should throw when claudeAgent is specified', () => { @@ -129,6 +139,37 @@ describe('CursorProvider', () => { expect.objectContaining({ cwd: '/tmp/work' }), ); }); + + it('should pass resolved cursorCliPath to callCursor', async () => { + mockResolveCursorCliPath.mockReturnValue('/custom/bin/cursor-agent'); + mockCallCursor.mockResolvedValue(doneResponse('coder')); + + const provider = new CursorProvider(); + const agent = provider.setup({ name: 'coder' }); + + await agent.call('implement', { cwd: '/tmp/work' }); + + expect(mockCallCursor).toHaveBeenCalledWith( + 'coder', + 'implement', + expect.objectContaining({ + cursorCliPath: '/custom/bin/cursor-agent', + }), + ); + }); + + it('should pass undefined cursorCliPath when resolver returns undefined', async () => { + mockResolveCursorCliPath.mockReturnValue(undefined); + mockCallCursor.mockResolvedValue(doneResponse('coder')); + + const provider = new CursorProvider(); + const agent = provider.setup({ name: 'coder' }); + + await agent.call('implement', { cwd: '/tmp/work' }); + + const opts = mockCallCursor.mock.calls[0]?.[2]; + expect(opts.cursorCliPath).toBeUndefined(); + }); }); describe('ProviderRegistry with Cursor', () => { diff --git a/src/__tests__/globalConfig-resolvers.test.ts b/src/__tests__/globalConfig-resolvers.test.ts index a1dff71..8a0d66d 100644 --- a/src/__tests__/globalConfig-resolvers.test.ts +++ b/src/__tests__/globalConfig-resolvers.test.ts @@ -52,8 +52,11 @@ const { resolveAnthropicApiKey, resolveOpenaiApiKey, resolveCodexCliPath, + resolveClaudeCliPath, + resolveCursorCliPath, resolveOpencodeApiKey, resolveCursorApiKey, + validateCliPath, invalidateGlobalConfigCache, } = await import('../infra/config/global/globalConfig.js'); @@ -531,3 +534,311 @@ describe('resolveCursorApiKey', () => { expect(key).toBeUndefined(); }); }); + +// ============================================================ +// Task 6.1 — validateCliPath unit tests +// ============================================================ + +describe('validateCliPath', () => { + beforeEach(() => { + mkdirSync(testDir, { recursive: true }); + }); + + afterEach(() => { + rmSync(testDir, { recursive: true, force: true }); + }); + + it('should return trimmed path for a valid executable', () => { + const exePath = createExecutableFile('valid-cli'); + const result = validateCliPath(exePath, 'test_cli_path'); + expect(result).toBe(exePath); + }); + + it('should trim whitespace from the path', () => { + const exePath = createExecutableFile('valid-cli'); + const result = validateCliPath(` ${exePath} `, 'test_cli_path'); + expect(result).toBe(exePath); + }); + + it('should throw when path is empty', () => { + expect(() => validateCliPath('', 'test_cli_path')).toThrow(/must not be empty/i); + }); + + it('should throw when path is only whitespace', () => { + expect(() => validateCliPath(' ', 'test_cli_path')).toThrow(/must not be empty/i); + }); + + it('should throw when path contains control characters', () => { + expect(() => validateCliPath('/tmp/cli\nbad', 'test_cli_path')).toThrow(/control characters/i); + }); + + it('should throw when path is relative', () => { + expect(() => validateCliPath('bin/cli', 'test_cli_path')).toThrow(/absolute path/i); + }); + + it('should throw when path does not exist', () => { + expect(() => validateCliPath(join(testDir, 'missing'), 'test_cli_path')).toThrow(/does not exist/i); + }); + + it('should throw when path points to a directory', () => { + const dirPath = join(testDir, 'a-dir'); + mkdirSync(dirPath, { recursive: true }); + expect(() => validateCliPath(dirPath, 'test_cli_path')).toThrow(/executable file/i); + }); + + it('should throw when path points to a non-executable file', () => { + const filePath = createNonExecutableFile('non-exec'); + expect(() => validateCliPath(filePath, 'test_cli_path')).toThrow(/not executable/i); + }); + + it('should include source name in error messages', () => { + expect(() => validateCliPath('', 'MY_CUSTOM_SOURCE')).toThrow(/MY_CUSTOM_SOURCE/); + }); +}); + +// ============================================================ +// Task 6.2 — resolveClaudeCliPath / resolveCursorCliPath tests +// ============================================================ + +describe('resolveClaudeCliPath', () => { + const originalEnv = process.env['TAKT_CLAUDE_CLI_PATH']; + + beforeEach(() => { + invalidateGlobalConfigCache(); + mkdirSync(taktDir, { recursive: true }); + }); + + afterEach(() => { + if (originalEnv !== undefined) { + process.env['TAKT_CLAUDE_CLI_PATH'] = originalEnv; + } else { + delete process.env['TAKT_CLAUDE_CLI_PATH']; + } + rmSync(testDir, { recursive: true, force: true }); + }); + + it('should return env var path when set (highest priority)', () => { + const envPath = createExecutableFile('env-claude'); + const configPath2 = createExecutableFile('config-claude'); + process.env['TAKT_CLAUDE_CLI_PATH'] = envPath; + const yaml = [ + 'language: en', + 'log_level: info', + 'provider: claude', + `claude_cli_path: ${configPath2}`, + ].join('\n'); + writeFileSync(configPath, yaml, 'utf-8'); + + const path = resolveClaudeCliPath(); + expect(path).toBe(envPath); + }); + + it('should use project config when env var is not set', () => { + delete process.env['TAKT_CLAUDE_CLI_PATH']; + const projPath = createExecutableFile('project-claude'); + + const path = resolveClaudeCliPath({ claudeCliPath: projPath }); + expect(path).toBe(projPath); + }); + + it('should prefer project config over global config', () => { + delete process.env['TAKT_CLAUDE_CLI_PATH']; + const projPath = createExecutableFile('project-claude'); + const globalPath = createExecutableFile('global-claude'); + const yaml = [ + 'language: en', + 'log_level: info', + 'provider: claude', + `claude_cli_path: ${globalPath}`, + ].join('\n'); + writeFileSync(configPath, yaml, 'utf-8'); + + const path = resolveClaudeCliPath({ claudeCliPath: projPath }); + expect(path).toBe(projPath); + }); + + it('should fall back to global config when neither env nor project is set', () => { + delete process.env['TAKT_CLAUDE_CLI_PATH']; + const globalPath = createExecutableFile('global-claude'); + const yaml = [ + 'language: en', + 'log_level: info', + 'provider: claude', + `claude_cli_path: ${globalPath}`, + ].join('\n'); + writeFileSync(configPath, yaml, 'utf-8'); + + const path = resolveClaudeCliPath(); + expect(path).toBe(globalPath); + }); + + it('should return undefined when nothing is set', () => { + delete process.env['TAKT_CLAUDE_CLI_PATH']; + const yaml = [ + 'language: en', + 'log_level: info', + 'provider: claude', + ].join('\n'); + writeFileSync(configPath, yaml, 'utf-8'); + + const path = resolveClaudeCliPath(); + expect(path).toBeUndefined(); + }); + + it('should throw when env path is invalid', () => { + process.env['TAKT_CLAUDE_CLI_PATH'] = join(testDir, 'missing-claude'); + expect(() => resolveClaudeCliPath()).toThrow(/does not exist/i); + }); +}); + +describe('resolveCursorCliPath', () => { + const originalEnv = process.env['TAKT_CURSOR_CLI_PATH']; + + beforeEach(() => { + invalidateGlobalConfigCache(); + mkdirSync(taktDir, { recursive: true }); + }); + + afterEach(() => { + if (originalEnv !== undefined) { + process.env['TAKT_CURSOR_CLI_PATH'] = originalEnv; + } else { + delete process.env['TAKT_CURSOR_CLI_PATH']; + } + rmSync(testDir, { recursive: true, force: true }); + }); + + it('should return env var path when set (highest priority)', () => { + const envPath = createExecutableFile('env-cursor'); + const configPath2 = createExecutableFile('config-cursor'); + process.env['TAKT_CURSOR_CLI_PATH'] = envPath; + const yaml = [ + 'language: en', + 'log_level: info', + 'provider: cursor', + `cursor_cli_path: ${configPath2}`, + ].join('\n'); + writeFileSync(configPath, yaml, 'utf-8'); + + const path = resolveCursorCliPath(); + expect(path).toBe(envPath); + }); + + it('should use project config when env var is not set', () => { + delete process.env['TAKT_CURSOR_CLI_PATH']; + const projPath = createExecutableFile('project-cursor'); + + const path = resolveCursorCliPath({ cursorCliPath: projPath }); + expect(path).toBe(projPath); + }); + + it('should prefer project config over global config', () => { + delete process.env['TAKT_CURSOR_CLI_PATH']; + const projPath = createExecutableFile('project-cursor'); + const globalPath = createExecutableFile('global-cursor'); + const yaml = [ + 'language: en', + 'log_level: info', + 'provider: cursor', + `cursor_cli_path: ${globalPath}`, + ].join('\n'); + writeFileSync(configPath, yaml, 'utf-8'); + + const path = resolveCursorCliPath({ cursorCliPath: projPath }); + expect(path).toBe(projPath); + }); + + it('should fall back to global config when neither env nor project is set', () => { + delete process.env['TAKT_CURSOR_CLI_PATH']; + const globalPath = createExecutableFile('global-cursor'); + const yaml = [ + 'language: en', + 'log_level: info', + 'provider: cursor', + `cursor_cli_path: ${globalPath}`, + ].join('\n'); + writeFileSync(configPath, yaml, 'utf-8'); + + const path = resolveCursorCliPath(); + expect(path).toBe(globalPath); + }); + + it('should return undefined when nothing is set', () => { + delete process.env['TAKT_CURSOR_CLI_PATH']; + const yaml = [ + 'language: en', + 'log_level: info', + 'provider: cursor', + ].join('\n'); + writeFileSync(configPath, yaml, 'utf-8'); + + const path = resolveCursorCliPath(); + expect(path).toBeUndefined(); + }); + + it('should throw when env path is invalid', () => { + process.env['TAKT_CURSOR_CLI_PATH'] = join(testDir, 'missing-cursor'); + expect(() => resolveCursorCliPath()).toThrow(/does not exist/i); + }); +}); + +// ============================================================ +// Task 6.3 — resolveCodexCliPath project config layer tests +// ============================================================ + +describe('resolveCodexCliPath — project config layer', () => { + const originalEnv = process.env['TAKT_CODEX_CLI_PATH']; + + beforeEach(() => { + invalidateGlobalConfigCache(); + mkdirSync(taktDir, { recursive: true }); + }); + + afterEach(() => { + if (originalEnv !== undefined) { + process.env['TAKT_CODEX_CLI_PATH'] = originalEnv; + } else { + delete process.env['TAKT_CODEX_CLI_PATH']; + } + rmSync(testDir, { recursive: true, force: true }); + }); + + it('should use project config when env var is not set', () => { + delete process.env['TAKT_CODEX_CLI_PATH']; + const projPath = createExecutableFile('project-codex'); + + const path = resolveCodexCliPath({ codexCliPath: projPath }); + expect(path).toBe(projPath); + }); + + it('should prefer env var over project config', () => { + const envPath = createExecutableFile('env-codex'); + const projPath = createExecutableFile('project-codex'); + process.env['TAKT_CODEX_CLI_PATH'] = envPath; + + const path = resolveCodexCliPath({ codexCliPath: projPath }); + expect(path).toBe(envPath); + }); + + it('should prefer project config over global config', () => { + delete process.env['TAKT_CODEX_CLI_PATH']; + const projPath = createExecutableFile('project-codex'); + const globalPath = createExecutableFile('global-codex'); + const yaml = [ + 'language: en', + 'log_level: info', + 'provider: codex', + `codex_cli_path: ${globalPath}`, + ].join('\n'); + writeFileSync(configPath, yaml, 'utf-8'); + + const path = resolveCodexCliPath({ codexCliPath: projPath }); + expect(path).toBe(projPath); + }); + + it('should throw when project config path is invalid', () => { + delete process.env['TAKT_CODEX_CLI_PATH']; + expect(() => resolveCodexCliPath({ codexCliPath: join(testDir, 'missing-codex') })) + .toThrow(/does not exist/i); + }); +}); diff --git a/src/__tests__/provider-structured-output.test.ts b/src/__tests__/provider-structured-output.test.ts index 1532f23..0e609db 100644 --- a/src/__tests__/provider-structured-output.test.ts +++ b/src/__tests__/provider-structured-output.test.ts @@ -52,12 +52,14 @@ vi.mock('../infra/opencode/index.js', () => ({ callOpenCodeCustom: mockCallOpenCodeCustom, })); -// ===== Config (API key resolvers) ===== +// ===== Config (API key resolvers + CLI path resolvers) ===== vi.mock('../infra/config/index.js', () => ({ resolveAnthropicApiKey: vi.fn(() => undefined), resolveOpenaiApiKey: vi.fn(() => undefined), resolveCodexCliPath: vi.fn(() => '/opt/codex/bin/codex'), + resolveClaudeCliPath: vi.fn(() => undefined), resolveOpencodeApiKey: vi.fn(() => undefined), + loadProjectConfig: vi.fn(() => ({})), })); // Codex の isInsideGitRepo をバイパス diff --git a/src/core/models/persisted-global-config.ts b/src/core/models/persisted-global-config.ts index 6c65b36..52ce16b 100644 --- a/src/core/models/persisted-global-config.ts +++ b/src/core/models/persisted-global-config.ts @@ -90,6 +90,10 @@ export interface PersistedGlobalConfig { openaiApiKey?: string; /** External Codex CLI path for Codex SDK override (overridden by TAKT_CODEX_CLI_PATH env var) */ codexCliPath?: string; + /** External Claude Code CLI path (overridden by TAKT_CLAUDE_CLI_PATH env var) */ + claudeCliPath?: string; + /** External cursor-agent CLI path (overridden by TAKT_CURSOR_CLI_PATH env var) */ + cursorCliPath?: string; /** OpenCode API key for OpenCode SDK (overridden by TAKT_OPENCODE_API_KEY env var) */ opencodeApiKey?: string; /** Cursor API key for Cursor Agent CLI/API (overridden by TAKT_CURSOR_API_KEY env var) */ diff --git a/src/core/models/schemas.ts b/src/core/models/schemas.ts index 074b633..9b457ea 100644 --- a/src/core/models/schemas.ts +++ b/src/core/models/schemas.ts @@ -445,6 +445,10 @@ export const GlobalConfigSchema = z.object({ openai_api_key: z.string().optional(), /** External Codex CLI path for Codex SDK override (overridden by TAKT_CODEX_CLI_PATH env var) */ codex_cli_path: z.string().optional(), + /** External Claude Code CLI path (overridden by TAKT_CLAUDE_CLI_PATH env var) */ + claude_cli_path: z.string().optional(), + /** External cursor-agent CLI path (overridden by TAKT_CURSOR_CLI_PATH env var) */ + cursor_cli_path: z.string().optional(), /** OpenCode API key for OpenCode SDK (overridden by TAKT_OPENCODE_API_KEY env var) */ opencode_api_key: z.string().optional(), /** Cursor API key for Cursor Agent CLI/API (overridden by TAKT_CURSOR_API_KEY env var) */ @@ -518,4 +522,10 @@ export const ProjectConfigSchema = z.object({ ]).optional(), /** Compatibility flag for full submodule acquisition when submodules is unset */ with_submodules: z.boolean().optional(), + /** Claude Code CLI path override (project-level) */ + claude_cli_path: z.string().optional(), + /** Codex CLI path override (project-level) */ + codex_cli_path: z.string().optional(), + /** cursor-agent CLI path override (project-level) */ + cursor_cli_path: z.string().optional(), }); diff --git a/src/infra/claude/client.ts b/src/infra/claude/client.ts index 03da8c5..cdb1174 100644 --- a/src/infra/claude/client.ts +++ b/src/infra/claude/client.ts @@ -53,6 +53,7 @@ export class ClaudeClient { anthropicApiKey: options.anthropicApiKey, outputSchema: options.outputSchema, sandbox: options.sandbox, + pathToClaudeCodeExecutable: options.pathToClaudeCodeExecutable, }; } diff --git a/src/infra/claude/options-builder.ts b/src/infra/claude/options-builder.ts index 02176af..7c37a51 100644 --- a/src/infra/claude/options-builder.ts +++ b/src/infra/claude/options-builder.ts @@ -100,6 +100,10 @@ export class SdkOptionsBuilder { sdkOptions.sandbox = this.options.sandbox; } + if (this.options.pathToClaudeCodeExecutable) { + sdkOptions.pathToClaudeCodeExecutable = this.options.pathToClaudeCodeExecutable; + } + return sdkOptions; } diff --git a/src/infra/claude/types.ts b/src/infra/claude/types.ts index 1964aaa..69dac84 100644 --- a/src/infra/claude/types.ts +++ b/src/infra/claude/types.ts @@ -149,6 +149,8 @@ export interface ClaudeCallOptions { outputSchema?: Record; /** Sandbox settings for Claude SDK */ sandbox?: SandboxSettings; + /** Custom path to Claude Code executable */ + pathToClaudeCodeExecutable?: string; } /** Options for spawning a Claude SDK query (low-level, used by executor/process) */ @@ -182,4 +184,6 @@ export interface ClaudeSpawnOptions { onStderr?: (data: string) => void; /** Sandbox settings for Claude SDK */ sandbox?: SandboxSettings; + /** Custom path to Claude Code executable */ + pathToClaudeCodeExecutable?: string; } diff --git a/src/infra/config/env/config-env-overrides.ts b/src/infra/config/env/config-env-overrides.ts index af697c1..271b3d3 100644 --- a/src/infra/config/env/config-env-overrides.ts +++ b/src/infra/config/env/config-env-overrides.ts @@ -94,6 +94,8 @@ const GLOBAL_ENV_SPECS: readonly EnvSpec[] = [ { path: 'anthropic_api_key', type: 'string' }, { path: 'openai_api_key', type: 'string' }, { path: 'codex_cli_path', type: 'string' }, + { path: 'claude_cli_path', type: 'string' }, + { path: 'cursor_cli_path', type: 'string' }, { path: 'opencode_api_key', type: 'string' }, { path: 'cursor_api_key', type: 'string' }, { path: 'pipeline', type: 'json' }, @@ -145,6 +147,9 @@ const PROJECT_ENV_SPECS: readonly EnvSpec[] = [ { path: 'provider_options.claude.sandbox.excluded_commands', type: 'json' }, { path: 'provider_profiles', type: 'json' }, { path: 'base_branch', type: 'string' }, + { path: 'claude_cli_path', type: 'string' }, + { path: 'codex_cli_path', type: 'string' }, + { path: 'cursor_cli_path', type: 'string' }, ]; export function applyGlobalConfigEnvOverrides(target: Record): void { diff --git a/src/infra/config/global/globalConfig.ts b/src/infra/config/global/globalConfig.ts index 26e2738..e76aee2 100644 --- a/src/infra/config/global/globalConfig.ts +++ b/src/infra/config/global/globalConfig.ts @@ -32,7 +32,8 @@ function hasControlCharacters(value: string): boolean { return false; } -function validateCodexCliPath(pathValue: string, sourceName: 'TAKT_CODEX_CLI_PATH' | 'codex_cli_path'): string { +/** Validate a CLI path value: must be non-empty, absolute, existing, executable file without control characters. */ +export function validateCliPath(pathValue: string, sourceName: string): string { const trimmed = pathValue.trim(); if (trimmed.length === 0) { throw new Error(`Configuration error: ${sourceName} must not be empty.`); @@ -191,6 +192,8 @@ export class GlobalConfigManager { anthropicApiKey: parsed.anthropic_api_key, openaiApiKey: parsed.openai_api_key, codexCliPath: parsed.codex_cli_path, + claudeCliPath: parsed.claude_cli_path, + cursorCliPath: parsed.cursor_cli_path, opencodeApiKey: parsed.opencode_api_key, cursorApiKey: parsed.cursor_api_key, pipeline: parsed.pipeline ? { @@ -278,6 +281,12 @@ export class GlobalConfigManager { if (config.codexCliPath) { raw.codex_cli_path = config.codexCliPath; } + if (config.claudeCliPath) { + raw.claude_cli_path = config.claudeCliPath; + } + if (config.cursorCliPath) { + raw.cursor_cli_path = config.cursorCliPath; + } if (config.opencodeApiKey) { raw.opencode_api_key = config.opencodeApiKey; } @@ -456,10 +465,14 @@ export function resolveOpenaiApiKey(): string | undefined { * Resolve the Codex CLI path override. * Priority: TAKT_CODEX_CLI_PATH env var > config.yaml > undefined (SDK vendored binary fallback) */ -export function resolveCodexCliPath(): string | undefined { +export function resolveCodexCliPath(projectConfig?: { codexCliPath?: string }): string | undefined { const envPath = process.env[envVarNameFromPath('codex_cli_path')]; if (envPath !== undefined) { - return validateCodexCliPath(envPath, 'TAKT_CODEX_CLI_PATH'); + return validateCliPath(envPath, 'TAKT_CODEX_CLI_PATH'); + } + + if (projectConfig?.codexCliPath !== undefined) { + return validateCliPath(projectConfig.codexCliPath, 'codex_cli_path (project)'); } let config: PersistedGlobalConfig; @@ -471,7 +484,59 @@ export function resolveCodexCliPath(): string | undefined { if (config.codexCliPath === undefined) { return undefined; } - return validateCodexCliPath(config.codexCliPath, 'codex_cli_path'); + return validateCliPath(config.codexCliPath, 'codex_cli_path'); +} + +/** + * Resolve the Claude Code CLI path override. + * Priority: TAKT_CLAUDE_CLI_PATH env var > project config > global config > undefined (SDK default) + */ +export function resolveClaudeCliPath(projectConfig?: { claudeCliPath?: string }): string | undefined { + const envPath = process.env[envVarNameFromPath('claude_cli_path')]; + if (envPath !== undefined) { + return validateCliPath(envPath, 'TAKT_CLAUDE_CLI_PATH'); + } + + if (projectConfig?.claudeCliPath !== undefined) { + return validateCliPath(projectConfig.claudeCliPath, 'claude_cli_path (project)'); + } + + let config: PersistedGlobalConfig; + try { + config = loadGlobalConfig(); + } catch { + return undefined; + } + if (config.claudeCliPath === undefined) { + return undefined; + } + return validateCliPath(config.claudeCliPath, 'claude_cli_path'); +} + +/** + * Resolve the cursor-agent CLI path override. + * Priority: TAKT_CURSOR_CLI_PATH env var > project config > global config > undefined (default 'cursor-agent') + */ +export function resolveCursorCliPath(projectConfig?: { cursorCliPath?: string }): string | undefined { + const envPath = process.env[envVarNameFromPath('cursor_cli_path')]; + if (envPath !== undefined) { + return validateCliPath(envPath, 'TAKT_CURSOR_CLI_PATH'); + } + + if (projectConfig?.cursorCliPath !== undefined) { + return validateCliPath(projectConfig.cursorCliPath, 'cursor_cli_path (project)'); + } + + let config: PersistedGlobalConfig; + try { + config = loadGlobalConfig(); + } catch { + return undefined; + } + if (config.cursorCliPath === undefined) { + return undefined; + } + return validateCliPath(config.cursorCliPath, 'cursor_cli_path'); } /** diff --git a/src/infra/config/global/index.ts b/src/infra/config/global/index.ts index 17e262f..c969f9e 100644 --- a/src/infra/config/global/index.ts +++ b/src/infra/config/global/index.ts @@ -15,8 +15,11 @@ export { resolveAnthropicApiKey, resolveOpenaiApiKey, resolveCodexCliPath, + resolveClaudeCliPath, + resolveCursorCliPath, resolveOpencodeApiKey, resolveCursorApiKey, + validateCliPath, } from './globalConfig.js'; export { diff --git a/src/infra/config/project/projectConfig.ts b/src/infra/config/project/projectConfig.ts index f65c3f2..70c666d 100644 --- a/src/infra/config/project/projectConfig.ts +++ b/src/infra/config/project/projectConfig.ts @@ -157,6 +157,9 @@ export function loadProjectConfig(projectDir: string): ProjectLocalConfig { provider_options, provider_profiles, analytics, + claude_cli_path, + codex_cli_path, + cursor_cli_path, ...rest } = parsedConfig; @@ -184,6 +187,9 @@ export function loadProjectConfig(projectDir: string): ProjectLocalConfig { }; } | undefined), providerProfiles: normalizeProviderProfiles(provider_profiles as Record }> | undefined), + claudeCliPath: claude_cli_path as string | undefined, + codexCliPath: codex_cli_path as string | undefined, + cursorCliPath: cursor_cli_path as string | undefined, }; } diff --git a/src/infra/config/types.ts b/src/infra/config/types.ts index 77b6c4f..2e11fc1 100644 --- a/src/infra/config/types.ts +++ b/src/infra/config/types.ts @@ -34,6 +34,12 @@ export interface ProjectLocalConfig { providerOptions?: MovementProviderOptions; /** Provider-specific permission profiles (project-level override) */ providerProfiles?: ProviderPermissionProfiles; + /** Claude Code CLI path override (project-level) */ + claudeCliPath?: string; + /** Codex CLI path override (project-level) */ + codexCliPath?: string; + /** cursor-agent CLI path override (project-level) */ + cursorCliPath?: string; } /** Persona session data for persistence */ diff --git a/src/infra/cursor/client.ts b/src/infra/cursor/client.ts index dae47e8..92e21ab 100644 --- a/src/infra/cursor/client.ts +++ b/src/infra/cursor/client.ts @@ -101,7 +101,7 @@ function createExecError( function execCursor(args: string[], options: CursorCallOptions): Promise { return new Promise((resolve, reject) => { - const child = spawn(CURSOR_COMMAND, args, { + const child = spawn(options.cursorCliPath ?? CURSOR_COMMAND, args, { cwd: options.cwd, env: buildEnv(options.cursorApiKey), stdio: ['ignore', 'pipe', 'pipe'], diff --git a/src/infra/cursor/types.ts b/src/infra/cursor/types.ts index 74a3af9..5c61574 100644 --- a/src/infra/cursor/types.ts +++ b/src/infra/cursor/types.ts @@ -15,4 +15,6 @@ export interface CursorCallOptions { permissionMode?: PermissionMode; onStream?: StreamCallback; cursorApiKey?: string; + /** Custom path to cursor-agent executable */ + cursorCliPath?: string; } diff --git a/src/infra/providers/claude.ts b/src/infra/providers/claude.ts index 962afd5..1b5455c 100644 --- a/src/infra/providers/claude.ts +++ b/src/infra/providers/claude.ts @@ -4,12 +4,13 @@ import { callClaude, callClaudeCustom, callClaudeAgent, callClaudeSkill } from '../claude/client.js'; import type { ClaudeCallOptions } from '../claude/types.js'; -import { resolveAnthropicApiKey } from '../config/index.js'; +import { resolveAnthropicApiKey, resolveClaudeCliPath, loadProjectConfig } from '../config/index.js'; import type { AgentResponse } from '../../core/models/index.js'; import type { AgentSetup, Provider, ProviderAgent, ProviderCallOptions } from './types.js'; function toClaudeOptions(options: ProviderCallOptions): ClaudeCallOptions { const claudeSandbox = options.providerOptions?.claude?.sandbox; + const projectConfig = loadProjectConfig(options.cwd); return { cwd: options.cwd, abortSignal: options.abortSignal, @@ -29,6 +30,7 @@ function toClaudeOptions(options: ProviderCallOptions): ClaudeCallOptions { allowUnsandboxedCommands: claudeSandbox.allowUnsandboxedCommands, excludedCommands: claudeSandbox.excludedCommands, } : undefined, + pathToClaudeCodeExecutable: resolveClaudeCliPath(projectConfig), }; } diff --git a/src/infra/providers/codex.ts b/src/infra/providers/codex.ts index a32c6b9..160ead7 100644 --- a/src/infra/providers/codex.ts +++ b/src/infra/providers/codex.ts @@ -4,7 +4,7 @@ import { execFileSync } from 'node:child_process'; import { callCodex, callCodexCustom, type CodexCallOptions } from '../codex/index.js'; -import { resolveOpenaiApiKey, resolveCodexCliPath } from '../config/index.js'; +import { resolveOpenaiApiKey, resolveCodexCliPath, loadProjectConfig } from '../config/index.js'; import type { AgentResponse } from '../../core/models/index.js'; import type { AgentSetup, Provider, ProviderAgent, ProviderCallOptions } from './types.js'; @@ -25,6 +25,7 @@ function isInsideGitRepo(cwd: string): boolean { } function toCodexOptions(options: ProviderCallOptions): CodexCallOptions { + const projectConfig = loadProjectConfig(options.cwd); return { cwd: options.cwd, abortSignal: options.abortSignal, @@ -34,7 +35,7 @@ function toCodexOptions(options: ProviderCallOptions): CodexCallOptions { networkAccess: options.providerOptions?.codex?.networkAccess, onStream: options.onStream, openaiApiKey: options.openaiApiKey ?? resolveOpenaiApiKey(), - codexPathOverride: resolveCodexCliPath(), + codexPathOverride: resolveCodexCliPath(projectConfig), outputSchema: options.outputSchema, }; } diff --git a/src/infra/providers/cursor.ts b/src/infra/providers/cursor.ts index dab456d..8e1e2cd 100644 --- a/src/infra/providers/cursor.ts +++ b/src/infra/providers/cursor.ts @@ -3,7 +3,7 @@ */ import { callCursor, callCursorCustom, type CursorCallOptions } from '../cursor/index.js'; -import { resolveCursorApiKey } from '../config/index.js'; +import { resolveCursorApiKey, resolveCursorCliPath, loadProjectConfig } from '../config/index.js'; import { createLogger } from '../../shared/utils/index.js'; import type { AgentResponse } from '../../core/models/index.js'; import type { AgentSetup, Provider, ProviderAgent, ProviderCallOptions } from './types.js'; @@ -21,6 +21,7 @@ function toCursorOptions(options: ProviderCallOptions): CursorCallOptions { log.info('Cursor provider does not support outputSchema; ignoring'); } + const projectConfig = loadProjectConfig(options.cwd); return { cwd: options.cwd, abortSignal: options.abortSignal, @@ -29,6 +30,7 @@ function toCursorOptions(options: ProviderCallOptions): CursorCallOptions { permissionMode: options.permissionMode, onStream: options.onStream, cursorApiKey: options.cursorApiKey ?? resolveCursorApiKey(), + cursorCliPath: resolveCursorCliPath(projectConfig), }; }