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:
parent
52c5e29000
commit
b8b64f858b
@ -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';
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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 をバイパス
|
||||
|
||||
@ -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) */
|
||||
|
||||
@ -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(),
|
||||
});
|
||||
|
||||
@ -53,6 +53,7 @@ export class ClaudeClient {
|
||||
anthropicApiKey: options.anthropicApiKey,
|
||||
outputSchema: options.outputSchema,
|
||||
sandbox: options.sandbox,
|
||||
pathToClaudeCodeExecutable: options.pathToClaudeCodeExecutable,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -100,6 +100,10 @@ export class SdkOptionsBuilder {
|
||||
sdkOptions.sandbox = this.options.sandbox;
|
||||
}
|
||||
|
||||
if (this.options.pathToClaudeCodeExecutable) {
|
||||
sdkOptions.pathToClaudeCodeExecutable = this.options.pathToClaudeCodeExecutable;
|
||||
}
|
||||
|
||||
return sdkOptions;
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
5
src/infra/config/env/config-env-overrides.ts
vendored
5
src/infra/config/env/config-env-overrides.ts
vendored
@ -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 {
|
||||
|
||||
@ -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');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -15,8 +15,11 @@ export {
|
||||
resolveAnthropicApiKey,
|
||||
resolveOpenaiApiKey,
|
||||
resolveCodexCliPath,
|
||||
resolveClaudeCliPath,
|
||||
resolveCursorCliPath,
|
||||
resolveOpencodeApiKey,
|
||||
resolveCursorApiKey,
|
||||
validateCliPath,
|
||||
} from './globalConfig.js';
|
||||
|
||||
export {
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -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 */
|
||||
|
||||
@ -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'],
|
||||
|
||||
@ -15,4 +15,6 @@ export interface CursorCallOptions {
|
||||
permissionMode?: PermissionMode;
|
||||
onStream?: StreamCallback;
|
||||
cursorApiKey?: string;
|
||||
/** Custom path to cursor-agent executable */
|
||||
cursorCliPath?: string;
|
||||
}
|
||||
|
||||
@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user