feat: プロジェクト単位のCLIパス設定(Claude/Cursor/Codex) (#413)

* feat: プロジェクト単位のCLIパス設定を支援するconfig層を追加

validateCliPath汎用関数、Global/Project設定スキーマ拡張、
env override、3プロバイダ向けresolve関数(env→project→global→undefined)を追加。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: Claude/Cursor/CodexプロバイダにCLIパス解決を統合

各プロバイダのtoXxxOptions()でproject configを読み込み、
resolveXxxCliPath()経由でCLIパスを解決してSDKに渡す。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* test: per-project CLIパス機能のテストを追加

validateCliPath, resolveClaudeCliPath, resolveCursorCliPath,
resolveCodexCliPath(project config層)のユニットテスト、
および既存プロバイダテストのモック更新。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Junichi Kato 2026-02-28 09:44:16 +09:00 committed by GitHub
parent 52c5e29000
commit b8b64f858b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 482 additions and 11 deletions

View File

@ -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';

View File

@ -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', () => {

View File

@ -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);
});
});

View File

@ -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 をバイパス

View File

@ -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) */

View File

@ -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(),
});

View File

@ -53,6 +53,7 @@ export class ClaudeClient {
anthropicApiKey: options.anthropicApiKey,
outputSchema: options.outputSchema,
sandbox: options.sandbox,
pathToClaudeCodeExecutable: options.pathToClaudeCodeExecutable,
};
}

View File

@ -100,6 +100,10 @@ export class SdkOptionsBuilder {
sdkOptions.sandbox = this.options.sandbox;
}
if (this.options.pathToClaudeCodeExecutable) {
sdkOptions.pathToClaudeCodeExecutable = this.options.pathToClaudeCodeExecutable;
}
return sdkOptions;
}

View File

@ -149,6 +149,8 @@ export interface ClaudeCallOptions {
outputSchema?: Record<string, unknown>;
/** 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;
}

View File

@ -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<string, unknown>): void {

View File

@ -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');
}
/**

View File

@ -15,8 +15,11 @@ export {
resolveAnthropicApiKey,
resolveOpenaiApiKey,
resolveCodexCliPath,
resolveClaudeCliPath,
resolveCursorCliPath,
resolveOpencodeApiKey,
resolveCursorApiKey,
validateCliPath,
} from './globalConfig.js';
export {

View File

@ -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<string, { default_permission_mode: unknown; movement_permission_overrides?: Record<string, unknown> }> | undefined),
claudeCliPath: claude_cli_path as string | undefined,
codexCliPath: codex_cli_path as string | undefined,
cursorCliPath: cursor_cli_path as string | undefined,
};
}

View File

@ -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 */

View File

@ -101,7 +101,7 @@ function createExecError(
function execCursor(args: string[], options: CursorCallOptions): Promise<CursorExecResult> {
return new Promise<CursorExecResult>((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'],

View File

@ -15,4 +15,6 @@ export interface CursorCallOptions {
permissionMode?: PermissionMode;
onStream?: StreamCallback;
cursorApiKey?: string;
/** Custom path to cursor-agent executable */
cursorCliPath?: string;
}

View File

@ -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),
};
}

View File

@ -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,
};
}

View File

@ -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),
};
}