takt: refactor-config-structure

This commit is contained in:
nrslib 2026-03-04 14:16:12 +09:00
parent 54ecc38d42
commit 204d84e345
59 changed files with 2607 additions and 1293 deletions

View File

@ -4,22 +4,15 @@
# =====================================
# General settings (piece-independent)
# =====================================
# Note: this template contains global-only settings for ~/.takt/config.yaml.
language: en # UI language: en | ja
log_level: info # Log level: debug | info | warn | error
provider: claude # Default provider: claude | codex | opencode | mock
# model: sonnet # Optional model name passed to provider
# Execution control
# worktree_dir: ~/takt-worktrees # Base directory for shared clone execution
# auto_pr: false # Auto-create PR after worktree execution
branch_name_strategy: ai # Branch strategy: romaji | ai
concurrency: 2 # Concurrent task execution for takt run (1-10)
# task_poll_interval_ms: 500 # Polling interval in ms during takt run (100-5000)
# prevent_sleep: false # Prevent macOS idle sleep while running
# auto_fetch: false # Fetch before clone to keep shared clones up-to-date
# Output / notifications
# minimal_output: false # Minimized output for CI logs
# verbose: false # Verbose output mode
# notification_sound: true # Master switch for sounds
# notification_sound_events: # Per-event sound toggle (unset means true)
# iteration_limit: true
@ -29,72 +22,22 @@ concurrency: 2 # Concurrent task execution for takt run (1-10)
# run_abort: true
# observability:
# provider_events: false # Persist provider stream events
# analytics:
# enabled: true # Enable analytics metrics collection
# events_path: ~/.takt/analytics/events # Analytics event directory
# retention_days: 30 # Analytics event retention (days)
# Credentials (environment variables take priority)
# anthropic_api_key: "sk-ant-..." # Claude API key
# openai_api_key: "sk-..." # Codex/OpenAI API key
# gemini_api_key: "..." # Gemini API key
# google_api_key: "..." # Google API key
# groq_api_key: "..." # Groq API key
# openrouter_api_key: "..." # OpenRouter API key
# opencode_api_key: "..." # OpenCode API key
# codex_cli_path: "/absolute/path/to/codex" # Absolute path to Codex CLI
# Pipeline
# pipeline:
# default_branch_prefix: "takt/" # Prefix for pipeline-created branches
# commit_message_template: "feat: {title} (#{issue})" # Commit template
# pr_body_template: | # PR body template
# ## Summary
# {issue_body}
# Closes #{issue}
# Misc
# bookmarks_file: ~/.takt/preferences/bookmarks.yaml # Bookmark file location
# =====================================
# Piece-related settings (global defaults)
# =====================================
# 1) Route provider/model per persona
# persona_providers:
# coder:
# provider: codex # Run coder persona on Codex
# model: o3-mini # Use o3-mini model (optional)
# reviewer:
# provider: claude # Run reviewer persona on Claude
# 2) Provider options
# Priority (for piece-capable keys such as provider/model/provider_options):
# global < piece < project < env
# provider_options:
# codex:
# network_access: true # Allow network access for Codex
# opencode:
# network_access: true # Allow network access for OpenCode
# claude:
# sandbox:
# allow_unsandboxed_commands: false # true allows unsandboxed execution for listed commands
# excluded_commands:
# - "npm publish" # Commands excluded from sandbox
# 3) Movement permission policy
# provider_profiles:
# codex:
# default_permission_mode: full # Base permission: readonly | edit | full
# movement_permission_overrides:
# ai_review: readonly # Per-movement override
# claude:
# default_permission_mode: edit
# 4) Runtime preparation before execution (recommended: enabled)
runtime:
prepare:
- gradle # Prepare Gradle cache/env under .runtime
- node # Prepare npm cache/env under .runtime
# 5) Piece list / categories
# Piece list / categories
# enable_builtin_pieces: true # Enable built-in pieces from builtins/{lang}/pieces
# disabled_builtins:
# - magi # Built-in piece names to disable
# piece_categories_file: ~/.takt/preferences/piece-categories.yaml # Category definition file
# interactive_preview_movements: 3 # Preview movement count in interactive mode (0-10)

View File

@ -4,22 +4,15 @@
# =====================================
# 通常設定(ピース非依存)
# =====================================
# 注意: このテンプレートは global 専用設定(~/.takt/config.yamlだけを扱う
language: ja # 表示言語: ja | en
log_level: info # ログレベル: debug | info | warn | error
provider: claude # デフォルト実行プロバイダー: claude | codex | opencode | mock
# model: sonnet # 省略可。providerに渡すモデル名
# 実行制御
# worktree_dir: ~/takt-worktrees # 共有clone作成先ディレクトリ
# auto_pr: false # worktree実行後に自動PR作成するか
branch_name_strategy: ai # ブランチ名生成: romaji | ai
concurrency: 2 # takt run の同時実行数1-10
# task_poll_interval_ms: 500 # takt run のタスク監視間隔ms100-5000
# prevent_sleep: false # macOS実行中のスリープ防止caffeinate
# auto_fetch: false # clone前にfetchして最新化するか
# 出力・通知
# minimal_output: false # 出力を最小化CI向け
# verbose: false # 詳細ログを有効化
# notification_sound: true # 通知音全体のON/OFF
# notification_sound_events: # イベント別通知音未指定はtrue扱い
# iteration_limit: true
@ -29,72 +22,22 @@ concurrency: 2 # takt run の同時実行数1-10
# run_abort: true
# observability:
# provider_events: false # providerイベントログを記録
# analytics:
# enabled: true # 分析メトリクスの収集を有効化
# events_path: ~/.takt/analytics/events # 分析イベント保存先
# retention_days: 30 # 分析イベント保持日数
# 認証情報(環境変数優先)
# anthropic_api_key: "sk-ant-..." # Claude APIキー
# openai_api_key: "sk-..." # Codex APIキー
# gemini_api_key: "..." # Gemini APIキー
# google_api_key: "..." # Google APIキー
# groq_api_key: "..." # Groq APIキー
# openrouter_api_key: "..." # OpenRouter APIキー
# opencode_api_key: "..." # OpenCode APIキー
# codex_cli_path: "/absolute/path/to/codex" # Codex CLI絶対パス
# パイプライン
# pipeline:
# default_branch_prefix: "takt/" # pipeline作成ブランチの接頭辞
# commit_message_template: "feat: {title} (#{issue})" # コミット文テンプレート
# pr_body_template: | # PR本文テンプレート
# ## Summary
# {issue_body}
# Closes #{issue}
# その他
# bookmarks_file: ~/.takt/preferences/bookmarks.yaml # ブックマーク保存先
# =====================================
# ピースにも関わる設定global defaults
# =====================================
# 1) ペルソナ単位でプロバイダー・モデルを切り替える
# persona_providers:
# coder:
# provider: codex # coderペルソナはcodexで実行
# model: o3-mini # 使用モデル(省略可)
# reviewer:
# provider: claude # reviewerペルソナはclaudeで実行
# 2) provider 固有オプション
# 優先順位provider/model/provider_options 等の piece 対応キー):
# global < piece < project < env
# provider_options:
# codex:
# network_access: true # Codex実行時のネットワークアクセス許可
# opencode:
# network_access: true # OpenCode実行時のネットワークアクセス許可
# claude:
# sandbox:
# allow_unsandboxed_commands: false # trueで対象コマンドを非サンドボックス実行
# excluded_commands:
# - "npm publish" # 非サンドボックス対象コマンド
# 3) movement の権限ポリシー
# provider_profiles:
# codex:
# default_permission_mode: full # 既定権限: readonly | edit | full
# movement_permission_overrides:
# ai_review: readonly # movement単位の上書き
# claude:
# default_permission_mode: edit
# 4) 実行前のランタイム準備(推奨: 有効化)
runtime:
prepare:
- gradle # Gradleキャッシュ/環境を .runtime 配下に準備
- node # npmキャッシュ/環境を .runtime 配下に準備
# 5) ピース一覧/カテゴリ
# ピース一覧/カテゴリ
# enable_builtin_pieces: true # builtins/{lang}/pieces を有効化
# disabled_builtins:
# - magi # 無効化するビルトインピース名
# piece_categories_file: ~/.takt/preferences/piece-categories.yaml # カテゴリ定義ファイル
# interactive_preview_movements: 3 # 対話モードのプレビュー件数0-10

View File

@ -113,6 +113,7 @@ vi.mock('../app/cli/helpers.js', () => ({
import { selectAndExecuteTask, determinePiece, saveTaskFromInteractive } from '../features/tasks/index.js';
import { interactiveMode } from '../features/interactive/index.js';
import { executePipeline } from '../features/pipeline/index.js';
import { resolveConfigValue } from '../infra/config/index.js';
import { executeDefaultAction } from '../app/cli/routing.js';
import { error as logError } from '../shared/ui/index.js';
import type { InteractiveModeResult } from '../features/interactive/index.js';
@ -122,6 +123,7 @@ const mockSelectAndExecuteTask = vi.mocked(selectAndExecuteTask);
const mockDeterminePiece = vi.mocked(determinePiece);
const mockInteractiveMode = vi.mocked(interactiveMode);
const mockExecutePipeline = vi.mocked(executePipeline);
const mockResolveConfigValue = vi.mocked(resolveConfigValue);
const mockLogError = vi.mocked(logError);
const mockSaveTaskFromInteractive = vi.mocked(saveTaskFromInteractive);
@ -146,6 +148,7 @@ beforeEach(() => {
}
mockDeterminePiece.mockResolvedValue('default');
mockInteractiveMode.mockResolvedValue({ action: 'execute', task: 'summarized task' });
mockResolveConfigValue.mockImplementation((_: string, key: string) => (key === 'piece' ? 'default' : false));
mockListAllTaskItems.mockReturnValue([]);
mockIsStaleRunningTask.mockReturnValue(false);
});
@ -355,5 +358,25 @@ describe('PR resolution in routing', () => {
// Cleanup
Object.defineProperty(programModule, 'pipelineMode', { value: originalPipelineMode, writable: true });
});
it('should use DEFAULT_PIECE_NAME when resolved piece is undefined', async () => {
const programModule = await import('../app/cli/program.js');
const originalPipelineMode = programModule.pipelineMode;
Object.defineProperty(programModule, 'pipelineMode', { value: true, writable: true });
mockOpts.pr = 456;
mockExecutePipeline.mockResolvedValue(0);
mockResolveConfigValue.mockImplementation((_: string, key: string) => (key === 'piece' ? undefined : false));
await executeDefaultAction();
expect(mockExecutePipeline).toHaveBeenCalledWith(
expect.objectContaining({
piece: 'default',
}),
);
Object.defineProperty(programModule, 'pipelineMode', { value: originalPipelineMode, writable: true });
});
});
});

View File

@ -0,0 +1,17 @@
import { describe, expect, it } from 'vitest';
describe('config API boundary', () => {
it('should expose migrated fallback loader from global config module', async () => {
const globalConfig = await import('../infra/config/global/globalConfig.js');
expect('loadGlobalMigratedProjectLocalFallback' in globalConfig).toBe(true);
});
it('should not expose GlobalConfigManager from config public module', async () => {
const configApi = await import('../infra/config/index.js');
expect('loadGlobalConfig' in configApi).toBe(true);
expect('saveGlobalConfig' in configApi).toBe(true);
expect('invalidateGlobalConfigCache' in configApi).toBe(true);
expect('GlobalConfigManager' in configApi).toBe(false);
});
});

View File

@ -26,13 +26,13 @@ describe('config env overrides', () => {
});
it('should apply global env overrides from generated env names', () => {
process.env.TAKT_LOG_LEVEL = 'debug';
process.env.TAKT_PROVIDER = 'codex';
process.env.TAKT_PROVIDER_OPTIONS_CLAUDE_SANDBOX_ALLOW_UNSANDBOXED_COMMANDS = 'true';
const raw: Record<string, unknown> = {};
applyGlobalConfigEnvOverrides(raw);
expect(raw.log_level).toBe('debug');
expect(raw.provider).toBe('codex');
expect(raw.provider_options).toEqual({
claude: {
sandbox: {
@ -52,6 +52,8 @@ describe('config env overrides', () => {
});
it('should apply project env overrides from generated env names', () => {
process.env.TAKT_LOG_LEVEL = 'debug';
process.env.TAKT_MODEL = 'gpt-5';
process.env.TAKT_VERBOSE = 'true';
process.env.TAKT_CONCURRENCY = '3';
process.env.TAKT_ANALYTICS_EVENTS_PATH = '/tmp/project-analytics';
@ -59,6 +61,8 @@ describe('config env overrides', () => {
const raw: Record<string, unknown> = {};
applyProjectConfigEnvOverrides(raw);
expect(raw.log_level).toBe('debug');
expect(raw.model).toBe('gpt-5');
expect(raw.verbose).toBe(true);
expect(raw.concurrency).toBe(3);
expect(raw.analytics).toEqual({
@ -83,10 +87,18 @@ describe('config env overrides', () => {
it('should apply cursor API key override for global config', () => {
process.env.TAKT_CURSOR_API_KEY = 'cursor-key-from-env';
process.env.TAKT_GEMINI_API_KEY = 'gemini-key-from-env';
process.env.TAKT_GOOGLE_API_KEY = 'google-key-from-env';
process.env.TAKT_GROQ_API_KEY = 'groq-key-from-env';
process.env.TAKT_OPENROUTER_API_KEY = 'openrouter-key-from-env';
const raw: Record<string, unknown> = {};
applyGlobalConfigEnvOverrides(raw);
expect(raw.cursor_api_key).toBe('cursor-key-from-env');
expect(raw.gemini_api_key).toBe('gemini-key-from-env');
expect(raw.google_api_key).toBe('google-key-from-env');
expect(raw.groq_api_key).toBe('groq-key-from-env');
expect(raw.openrouter_api_key).toBe('openrouter-key-from-env');
});
});

View File

@ -0,0 +1,31 @@
import { describe, expect, it } from 'vitest';
import type { PersistedGlobalConfig } from '../core/models/persisted-global-config.js';
import type { ProjectLocalConfig } from '../infra/config/types.js';
import type { MigratedProjectLocalConfigKey } from '../infra/config/migratedProjectLocalKeys.js';
import * as migratedProjectLocalKeysModule from '../infra/config/migratedProjectLocalKeys.js';
type Assert<T extends true> = T;
type IsNever<T> = [T] extends [never] ? true : false;
const globalConfigTypeBoundaryGuard: Assert<
IsNever<Extract<keyof PersistedGlobalConfig, MigratedProjectLocalConfigKey>>
> = true;
void globalConfigTypeBoundaryGuard;
const projectConfigTypeBoundaryGuard: Assert<
IsNever<Exclude<MigratedProjectLocalConfigKey, keyof ProjectLocalConfig>>
> = true;
void projectConfigTypeBoundaryGuard;
describe('migrated config key contracts', () => {
it('should expose only runtime exports needed by migrated key metadata module', () => {
expect(Object.keys(migratedProjectLocalKeysModule).sort()).toEqual([
'MIGRATED_PROJECT_LOCAL_CONFIG_KEYS',
'MIGRATED_PROJECT_LOCAL_CONFIG_METADATA',
]);
});
it('should not expose helper exports that bypass metadata contract', () => {
expect('isMigratedProjectLocalConfigKey' in migratedProjectLocalKeysModule).toBe(false);
});
});

View File

@ -0,0 +1,19 @@
import { readFileSync } from 'node:fs';
import { describe, expect, it } from 'vitest';
function getLineCount(path: string): number {
const content = readFileSync(new URL(path, import.meta.url), 'utf-8');
return content.trimEnd().split(/\r?\n/).length;
}
describe('config module file-size boundary', () => {
it('keeps globalConfigCore.ts under 300 lines', () => {
const lineCount = getLineCount('../infra/config/global/globalConfigCore.ts');
expect(lineCount).toBeLessThanOrEqual(300);
});
it('keeps projectConfig.ts under 300 lines', () => {
const lineCount = getLineCount('../infra/config/project/projectConfig.ts');
expect(lineCount).toBeLessThanOrEqual(300);
});
});

View File

@ -0,0 +1,36 @@
import { describe, expect, it } from 'vitest';
import { denormalizeProviderOptions } from '../infra/config/configNormalizers.js';
describe('denormalizeProviderOptions', () => {
it('should convert camelCase provider options into persisted snake_case format', () => {
const result = denormalizeProviderOptions({
codex: { networkAccess: true },
opencode: { networkAccess: false },
claude: {
sandbox: {
allowUnsandboxedCommands: true,
excludedCommands: ['npm test'],
},
},
});
expect(result).toEqual({
codex: { network_access: true },
opencode: { network_access: false },
claude: {
sandbox: {
allow_unsandboxed_commands: true,
excluded_commands: ['npm test'],
},
},
});
});
it('should return undefined when provider options do not contain persisted fields', () => {
const result = denormalizeProviderOptions({
claude: { sandbox: {} },
});
expect(result).toBeUndefined();
});
});

View File

@ -40,6 +40,30 @@ import {
invalidateGlobalConfigCache,
} from '../infra/config/index.js';
let isolatedGlobalConfigDir: string;
let originalTaktConfigDirForFile: string | undefined;
beforeEach(() => {
originalTaktConfigDirForFile = process.env.TAKT_CONFIG_DIR;
isolatedGlobalConfigDir = join(tmpdir(), `takt-config-test-global-${randomUUID()}`);
mkdirSync(isolatedGlobalConfigDir, { recursive: true });
process.env.TAKT_CONFIG_DIR = isolatedGlobalConfigDir;
writeFileSync(join(isolatedGlobalConfigDir, 'config.yaml'), 'language: en\n', 'utf-8');
invalidateGlobalConfigCache();
});
afterEach(() => {
if (originalTaktConfigDirForFile === undefined) {
delete process.env.TAKT_CONFIG_DIR;
} else {
process.env.TAKT_CONFIG_DIR = originalTaktConfigDirForFile;
}
invalidateGlobalConfigCache();
if (existsSync(isolatedGlobalConfigDir)) {
rmSync(isolatedGlobalConfigDir, { recursive: true, force: true });
}
});
describe('getBuiltinPiece', () => {
it('should return builtin piece when it exists in resources', () => {
const piece = getBuiltinPiece('default', process.cwd());
@ -347,6 +371,28 @@ describe('setCurrentPiece', () => {
expect(piece).toBe('second');
});
it('should preserve provider_options when updating piece', () => {
const configDir = getProjectConfigDir(testDir);
mkdirSync(configDir, { recursive: true });
writeFileSync(
join(configDir, 'config.yaml'),
[
'piece: first',
'provider_options:',
' codex:',
' network_access: true',
].join('\n'),
'utf-8',
);
setCurrentPiece(testDir, 'updated');
const saved = readFileSync(join(configDir, 'config.yaml'), 'utf-8');
expect(saved).toContain('piece: updated');
expect(saved).toContain('provider_options:');
expect(saved).toContain('network_access: true');
});
});
describe('loadProjectConfig provider_options', () => {
@ -457,7 +503,7 @@ describe('loadProjectConfig provider_options', () => {
' unknown_option: true',
].join('\n'));
expect(() => loadProjectConfig(testDir)).toThrow(/unknown fields|unrecognized key/i);
expect(() => loadProjectConfig(testDir)).toThrow(/Configuration error: invalid provider/);
});
it('should throw when project provider has unsupported type', () => {
@ -622,7 +668,7 @@ describe('isVerboseMode', () => {
const globalConfigDir = process.env.TAKT_CONFIG_DIR!;
mkdirSync(globalConfigDir, { recursive: true });
writeFileSync(join(globalConfigDir, 'config.yaml'), 'verbose: false\n');
writeFileSync(join(globalConfigDir, 'config.yaml'), 'language: en\n');
expect(isVerboseMode(testDir)).toBe(true);
});
@ -634,21 +680,21 @@ describe('isVerboseMode', () => {
const globalConfigDir = process.env.TAKT_CONFIG_DIR!;
mkdirSync(globalConfigDir, { recursive: true });
writeFileSync(join(globalConfigDir, 'config.yaml'), 'verbose: true\n');
writeFileSync(join(globalConfigDir, 'config.yaml'), 'language: en\n');
expect(isVerboseMode(testDir)).toBe(false);
});
it('should fallback to global verbose when project verbose is not set', () => {
it('should use default verbose=false when project verbose is not set', () => {
const projectConfigDir = getProjectConfigDir(testDir);
mkdirSync(projectConfigDir, { recursive: true });
writeFileSync(join(projectConfigDir, 'config.yaml'), 'piece: default\n');
const globalConfigDir = process.env.TAKT_CONFIG_DIR!;
mkdirSync(globalConfigDir, { recursive: true });
writeFileSync(join(globalConfigDir, 'config.yaml'), 'verbose: true\n');
writeFileSync(join(globalConfigDir, 'config.yaml'), 'language: en\n');
expect(isVerboseMode(testDir)).toBe(true);
expect(isVerboseMode(testDir)).toBe(false);
});
it('should return false when neither project nor global verbose is set', () => {
@ -662,7 +708,7 @@ describe('isVerboseMode', () => {
const globalConfigDir = process.env.TAKT_CONFIG_DIR!;
mkdirSync(globalConfigDir, { recursive: true });
writeFileSync(join(globalConfigDir, 'config.yaml'), 'verbose: false\n');
writeFileSync(join(globalConfigDir, 'config.yaml'), 'language: en\n');
process.env.TAKT_VERBOSE = 'true';
expect(isVerboseMode(testDir)).toBe(true);

View File

@ -3,7 +3,7 @@
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdirSync, rmSync, writeFileSync, existsSync } from 'node:fs';
import { mkdirSync, rmSync, writeFileSync, existsSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { vi } from 'vitest';
@ -20,7 +20,11 @@ vi.mock('node:os', async () => {
});
// Import after mocks are set up
const { loadGlobalConfig, saveGlobalConfig, invalidateGlobalConfigCache } = await import('../infra/config/global/globalConfig.js');
const {
loadGlobalConfig,
saveGlobalConfig,
invalidateGlobalConfigCache,
} = await import('../infra/config/global/globalConfig.js');
const { getGlobalConfigPath } = await import('../infra/config/paths.js');
describe('loadGlobalConfig', () => {
@ -39,11 +43,87 @@ describe('loadGlobalConfig', () => {
const config = loadGlobalConfig();
expect(config.language).toBe('en');
expect(config.logLevel).toBe('info');
expect(config.provider).toBe('claude');
expect(config.model).toBeUndefined();
expect(config.verbose).toBe(false);
expect(config.pipeline).toBeUndefined();
});
it('should not expose migrated project-local fields from global config', () => {
const config = loadGlobalConfig() as Record<string, unknown>;
expect(config).not.toHaveProperty('logLevel');
expect(config).not.toHaveProperty('pipeline');
expect(config).not.toHaveProperty('personaProviders');
expect(config).not.toHaveProperty('branchNameStrategy');
expect(config).not.toHaveProperty('minimalOutput');
expect(config).not.toHaveProperty('concurrency');
expect(config).not.toHaveProperty('taskPollIntervalMs');
expect(config).not.toHaveProperty('interactivePreviewMovements');
expect(config).not.toHaveProperty('verbose');
});
it('should accept migrated project-local keys in global config.yaml for resolver fallback', () => {
const taktDir = join(testHomeDir, '.takt');
mkdirSync(taktDir, { recursive: true });
writeFileSync(
getGlobalConfigPath(),
[
'language: en',
'log_level: debug',
'pipeline:',
' default_branch_prefix: "global/"',
'persona_providers:',
' coder:',
' provider: codex',
'branch_name_strategy: ai',
'minimal_output: true',
'concurrency: 3',
'task_poll_interval_ms: 1000',
'interactive_preview_movements: 2',
'verbose: true',
].join('\n'),
'utf-8',
);
expect(() => loadGlobalConfig()).not.toThrow();
const config = loadGlobalConfig() as Record<string, unknown>;
expect(config).not.toHaveProperty('logLevel');
expect(config).not.toHaveProperty('pipeline');
expect(config).not.toHaveProperty('personaProviders');
expect(config).not.toHaveProperty('branchNameStrategy');
expect(config).not.toHaveProperty('minimalOutput');
expect(config).not.toHaveProperty('concurrency');
expect(config).not.toHaveProperty('taskPollIntervalMs');
expect(config).not.toHaveProperty('interactivePreviewMovements');
expect(config).not.toHaveProperty('verbose');
});
it('should not persist migrated project-local keys when saving global config', () => {
const taktDir = join(testHomeDir, '.takt');
mkdirSync(taktDir, { recursive: true });
writeFileSync(getGlobalConfigPath(), 'language: en\n', 'utf-8');
const config = loadGlobalConfig() as Record<string, unknown>;
config.logLevel = 'debug';
config.pipeline = { defaultBranchPrefix: 'global/' };
config.personaProviders = { coder: { provider: 'codex' } };
config.branchNameStrategy = 'ai';
config.minimalOutput = true;
config.concurrency = 4;
config.taskPollIntervalMs = 1200;
config.interactivePreviewMovements = 1;
config.verbose = true;
saveGlobalConfig(config as Parameters<typeof saveGlobalConfig>[0]);
const raw = readFileSync(getGlobalConfigPath(), 'utf-8');
expect(raw).not.toContain('log_level:');
expect(raw).not.toContain('pipeline:');
expect(raw).not.toContain('persona_providers:');
expect(raw).not.toContain('branch_name_strategy:');
expect(raw).not.toContain('minimal_output:');
expect(raw).not.toContain('concurrency:');
expect(raw).not.toContain('task_poll_interval_ms:');
expect(raw).not.toContain('interactive_preview_movements:');
expect(raw).not.toContain('verbose:');
});
it('should return the same cached object on subsequent calls', () => {
@ -67,7 +147,7 @@ describe('loadGlobalConfig', () => {
mkdirSync(taktDir, { recursive: true });
writeFileSync(
getGlobalConfigPath(),
'language: ja\nprovider: codex\nlog_level: debug\n',
'language: ja\nprovider: codex\n',
'utf-8',
);
@ -75,7 +155,7 @@ describe('loadGlobalConfig', () => {
expect(config.language).toBe('ja');
expect(config.provider).toBe('codex');
expect(config.logLevel).toBe('debug');
expect((config as Record<string, unknown>).logLevel).toBeUndefined();
});
it('should load provider block from config.yaml and normalize model/providerOptions', () => {
@ -101,28 +181,69 @@ describe('loadGlobalConfig', () => {
});
});
it('should load persona_providers provider block and normalize to provider/model', () => {
it('should preserve provider_options when saveGlobalConfig is called with loaded config', () => {
const taktDir = join(testHomeDir, '.takt');
mkdirSync(taktDir, { recursive: true });
writeFileSync(
getGlobalConfigPath(),
[
'persona_providers:',
' coder:',
' type: opencode',
' model: openai/gpt-5',
'language: en',
'provider: claude',
'provider_options:',
' codex:',
' network_access: true',
' opencode:',
' network_access: false',
' claude:',
' sandbox:',
' allow_unsandboxed_commands: true',
' excluded_commands:',
' - git push',
].join('\n'),
'utf-8',
);
const config = loadGlobalConfig();
const loaded = loadGlobalConfig();
saveGlobalConfig(loaded);
invalidateGlobalConfigCache();
expect(config.personaProviders).toEqual({
coder: {
provider: 'opencode',
model: 'openai/gpt-5',
const reloaded = loadGlobalConfig();
expect(reloaded.providerOptions).toEqual({
codex: { networkAccess: true },
opencode: { networkAccess: false },
claude: {
sandbox: {
allowUnsandboxedCommands: true,
excludedCommands: ['git push'],
},
},
});
const raw = readFileSync(getGlobalConfigPath(), 'utf-8');
expect(raw).toContain('provider_options:');
});
it('should round-trip copilot global fields', () => {
const taktDir = join(testHomeDir, '.takt');
mkdirSync(taktDir, { recursive: true });
writeFileSync(
getGlobalConfigPath(),
[
'language: en',
'copilot_cli_path: /tmp/copilot',
'copilot_github_token: ghp_test_token',
].join('\n'),
'utf-8',
);
const loaded = loadGlobalConfig();
expect(loaded.copilotCliPath).toBe('/tmp/copilot');
expect(loaded.copilotGithubToken).toBe('ghp_test_token');
saveGlobalConfig(loaded);
invalidateGlobalConfigCache();
const reloaded = loadGlobalConfig();
expect(reloaded.copilotCliPath).toBe('/tmp/copilot');
expect(reloaded.copilotGithubToken).toBe('ghp_test_token');
});
it('should apply env override for nested provider_options key', () => {
@ -142,7 +263,7 @@ describe('loadGlobalConfig', () => {
}
});
it('should load pipeline config from config.yaml', () => {
it('should accept pipeline in global config for migrated fallback', () => {
const taktDir = join(testHomeDir, '.takt');
mkdirSync(taktDir, { recursive: true });
writeFileSync(
@ -156,12 +277,9 @@ describe('loadGlobalConfig', () => {
'utf-8',
);
const config = loadGlobalConfig();
expect(config.pipeline).toBeDefined();
expect(config.pipeline!.defaultBranchPrefix).toBe('feat/');
expect(config.pipeline!.commitMessageTemplate).toBe('fix: {title} (#{issue})');
expect(config.pipeline!.prBodyTemplate).toBeUndefined();
expect(() => loadGlobalConfig()).not.toThrow();
const config = loadGlobalConfig() as Record<string, unknown>;
expect(config).not.toHaveProperty('pipeline');
});
it('should save and reload pipeline config', () => {
@ -171,7 +289,7 @@ describe('loadGlobalConfig', () => {
writeFileSync(getGlobalConfigPath(), 'language: en\n', 'utf-8');
const config = loadGlobalConfig();
config.pipeline = {
(config as Record<string, unknown>).pipeline = {
defaultBranchPrefix: 'takt/',
commitMessageTemplate: 'feat: {title} (#{issue})',
};
@ -179,9 +297,7 @@ describe('loadGlobalConfig', () => {
invalidateGlobalConfigCache();
const reloaded = loadGlobalConfig();
expect(reloaded.pipeline).toBeDefined();
expect(reloaded.pipeline!.defaultBranchPrefix).toBe('takt/');
expect(reloaded.pipeline!.commitMessageTemplate).toBe('feat: {title} (#{issue})');
expect((reloaded as Record<string, unknown>).pipeline).toBeUndefined();
});
it('should load auto_pr config from config.yaml', () => {
@ -440,7 +556,7 @@ describe('loadGlobalConfig', () => {
});
});
it('should load interactive_preview_movements config from config.yaml', () => {
it('should accept interactive_preview_movements in global config for migrated fallback', () => {
const taktDir = join(testHomeDir, '.takt');
mkdirSync(taktDir, { recursive: true });
writeFileSync(
@ -449,8 +565,9 @@ describe('loadGlobalConfig', () => {
'utf-8',
);
const config = loadGlobalConfig();
expect(config.interactivePreviewMovements).toBe(5);
expect(() => loadGlobalConfig()).not.toThrow();
const config = loadGlobalConfig() as Record<string, unknown>;
expect(config).not.toHaveProperty('interactivePreviewMovements');
});
it('should save and reload interactive_preview_movements config', () => {
@ -459,12 +576,12 @@ describe('loadGlobalConfig', () => {
writeFileSync(getGlobalConfigPath(), 'language: en\n', 'utf-8');
const config = loadGlobalConfig();
config.interactivePreviewMovements = 7;
(config as Record<string, unknown>).interactivePreviewMovements = 7;
saveGlobalConfig(config);
invalidateGlobalConfigCache();
const reloaded = loadGlobalConfig();
expect(reloaded.interactivePreviewMovements).toBe(7);
expect((reloaded as Record<string, unknown>).interactivePreviewMovements).toBeUndefined();
});
it('should default interactive_preview_movements to 3', () => {
@ -473,10 +590,10 @@ describe('loadGlobalConfig', () => {
writeFileSync(getGlobalConfigPath(), 'language: en\n', 'utf-8');
const config = loadGlobalConfig();
expect(config.interactivePreviewMovements).toBe(3);
expect((config as Record<string, unknown>).interactivePreviewMovements).toBeUndefined();
});
it('should accept interactive_preview_movements: 0 to disable', () => {
it('should accept interactive_preview_movements=0 in global config for migrated fallback', () => {
const taktDir = join(testHomeDir, '.takt');
mkdirSync(taktDir, { recursive: true });
writeFileSync(
@ -485,107 +602,13 @@ describe('loadGlobalConfig', () => {
'utf-8',
);
const config = loadGlobalConfig();
expect(config.interactivePreviewMovements).toBe(0);
expect(() => loadGlobalConfig()).not.toThrow();
const config = loadGlobalConfig() as Record<string, unknown>;
expect(config).not.toHaveProperty('interactivePreviewMovements');
});
describe('persona_providers', () => {
it('should load persona_providers from config.yaml', () => {
const taktDir = join(testHomeDir, '.takt');
mkdirSync(taktDir, { recursive: true });
writeFileSync(
getGlobalConfigPath(),
[
'language: en',
'persona_providers:',
' coder:',
' provider: codex',
' reviewer:',
' provider: claude',
' model: claude-3-5-sonnet-latest',
].join('\n'),
'utf-8',
);
const config = loadGlobalConfig();
expect(config.personaProviders).toEqual({
coder: { provider: 'codex' },
reviewer: { provider: 'claude', model: 'claude-3-5-sonnet-latest' },
});
});
it('should load persona_providers with model only (no provider)', () => {
const taktDir = join(testHomeDir, '.takt');
mkdirSync(taktDir, { recursive: true });
writeFileSync(
getGlobalConfigPath(),
[
'language: en',
'persona_providers:',
' coder:',
' model: o3-mini',
].join('\n'),
'utf-8',
);
const config = loadGlobalConfig();
expect(config.personaProviders).toEqual({
coder: { model: 'o3-mini' },
});
});
it('should save and reload persona_providers', () => {
const taktDir = join(testHomeDir, '.takt');
mkdirSync(taktDir, { recursive: true });
writeFileSync(getGlobalConfigPath(), 'language: en\n', 'utf-8');
const config = loadGlobalConfig();
config.personaProviders = { coder: { provider: 'codex', model: 'o3-mini' } };
saveGlobalConfig(config);
invalidateGlobalConfigCache();
const reloaded = loadGlobalConfig();
expect(reloaded.personaProviders).toEqual({ coder: { provider: 'codex', model: 'o3-mini' } });
});
it('should normalize legacy string format to object format', () => {
const taktDir = join(testHomeDir, '.takt');
mkdirSync(taktDir, { recursive: true });
writeFileSync(
getGlobalConfigPath(),
'language: en\npersona_providers:\n coder: codex\n',
'utf-8',
);
const config = loadGlobalConfig();
expect(config.personaProviders).toEqual({
coder: { provider: 'codex' },
});
});
it('should have undefined personaProviders by default', () => {
const config = loadGlobalConfig();
expect(config.personaProviders).toBeUndefined();
});
it('should not save persona_providers when empty', () => {
const taktDir = join(testHomeDir, '.takt');
mkdirSync(taktDir, { recursive: true });
writeFileSync(getGlobalConfigPath(), 'language: en\n', 'utf-8');
const config = loadGlobalConfig();
config.personaProviders = {};
saveGlobalConfig(config);
invalidateGlobalConfigCache();
const reloaded = loadGlobalConfig();
expect(reloaded.personaProviders).toBeUndefined();
});
it('should throw when persona entry has codex provider with Claude model alias', () => {
it('should fail fast when persona_providers provider/model alias combination is invalid', () => {
const taktDir = join(testHomeDir, '.takt');
mkdirSync(taktDir, { recursive: true });
writeFileSync(
@ -594,34 +617,10 @@ describe('loadGlobalConfig', () => {
'utf-8',
);
expect(() => loadGlobalConfig()).toThrow(/Claude model alias/);
expect(() => loadGlobalConfig()).toThrow();
});
it('should throw when persona entry has opencode provider without model', () => {
const taktDir = join(testHomeDir, '.takt');
mkdirSync(taktDir, { recursive: true });
writeFileSync(
getGlobalConfigPath(),
'language: en\npersona_providers:\n reviewer:\n provider: opencode\n',
'utf-8',
);
expect(() => loadGlobalConfig()).toThrow(/requires model/);
});
it('should not throw when persona entry has opencode provider with compatible model', () => {
const taktDir = join(testHomeDir, '.takt');
mkdirSync(taktDir, { recursive: true });
writeFileSync(
getGlobalConfigPath(),
'language: en\npersona_providers:\n coder:\n provider: opencode\n model: opencode/big-pickle\n',
'utf-8',
);
expect(() => loadGlobalConfig()).not.toThrow();
});
it('should throw when persona provider block includes provider options', () => {
it('should fail fast when persona provider block includes provider options', () => {
const taktDir = join(testHomeDir, '.takt');
mkdirSync(taktDir, { recursive: true });
writeFileSync(

View File

@ -54,6 +54,8 @@ const {
resolveCodexCliPath,
resolveClaudeCliPath,
resolveCursorCliPath,
resolveCopilotCliPath,
resolveCopilotGithubToken,
resolveOpencodeApiKey,
resolveCursorApiKey,
validateCliPath,
@ -67,6 +69,10 @@ describe('GlobalConfigSchema API key fields', () => {
});
expect(result.anthropic_api_key).toBeUndefined();
expect(result.openai_api_key).toBeUndefined();
expect(result.gemini_api_key).toBeUndefined();
expect(result.google_api_key).toBeUndefined();
expect(result.groq_api_key).toBeUndefined();
expect(result.openrouter_api_key).toBeUndefined();
});
it('should accept config with anthropic_api_key', () => {
@ -95,6 +101,20 @@ describe('GlobalConfigSchema API key fields', () => {
expect(result.openai_api_key).toBe('sk-openai-key');
});
it('should accept config with global API key fields', () => {
const result = GlobalConfigSchema.parse({
language: 'en',
gemini_api_key: 'gemini-test-key',
google_api_key: 'google-test-key',
groq_api_key: 'groq-test-key',
openrouter_api_key: 'openrouter-test-key',
});
expect(result.gemini_api_key).toBe('gemini-test-key');
expect(result.google_api_key).toBe('google-test-key');
expect(result.groq_api_key).toBe('groq-test-key');
expect(result.openrouter_api_key).toBe('openrouter-test-key');
});
it('should accept config with cursor_api_key', () => {
const result = GlobalConfigSchema.parse({
language: 'en',
@ -117,10 +137,13 @@ describe('GlobalConfig load/save with API keys', () => {
it('should load config with API keys from YAML', () => {
const yaml = [
'language: en',
'log_level: info',
'provider: claude',
'anthropic_api_key: sk-ant-from-yaml',
'openai_api_key: sk-openai-from-yaml',
'gemini_api_key: gemini-from-yaml',
'google_api_key: google-from-yaml',
'groq_api_key: groq-from-yaml',
'openrouter_api_key: openrouter-from-yaml',
'cursor_api_key: cursor-from-yaml',
].join('\n');
writeFileSync(configPath, yaml, 'utf-8');
@ -128,13 +151,16 @@ describe('GlobalConfig load/save with API keys', () => {
const config = loadGlobalConfig();
expect(config.anthropicApiKey).toBe('sk-ant-from-yaml');
expect(config.openaiApiKey).toBe('sk-openai-from-yaml');
expect(config.geminiApiKey).toBe('gemini-from-yaml');
expect(config.googleApiKey).toBe('google-from-yaml');
expect(config.groqApiKey).toBe('groq-from-yaml');
expect(config.openrouterApiKey).toBe('openrouter-from-yaml');
expect(config.cursorApiKey).toBe('cursor-from-yaml');
});
it('should load config without API keys', () => {
const yaml = [
'language: en',
'log_level: info',
'provider: claude',
].join('\n');
writeFileSync(configPath, yaml, 'utf-8');
@ -142,13 +168,16 @@ describe('GlobalConfig load/save with API keys', () => {
const config = loadGlobalConfig();
expect(config.anthropicApiKey).toBeUndefined();
expect(config.openaiApiKey).toBeUndefined();
expect(config.geminiApiKey).toBeUndefined();
expect(config.googleApiKey).toBeUndefined();
expect(config.groqApiKey).toBeUndefined();
expect(config.openrouterApiKey).toBeUndefined();
});
it('should save and reload config with API keys', () => {
// Write initial config
const yaml = [
'language: en',
'log_level: info',
'provider: claude',
].join('\n');
writeFileSync(configPath, yaml, 'utf-8');
@ -156,19 +185,26 @@ describe('GlobalConfig load/save with API keys', () => {
const config = loadGlobalConfig();
config.anthropicApiKey = 'sk-ant-saved';
config.openaiApiKey = 'sk-openai-saved';
config.geminiApiKey = 'gemini-saved';
config.googleApiKey = 'google-saved';
config.groqApiKey = 'groq-saved';
config.openrouterApiKey = 'openrouter-saved';
config.cursorApiKey = 'cursor-saved';
saveGlobalConfig(config);
const reloaded = loadGlobalConfig();
expect(reloaded.anthropicApiKey).toBe('sk-ant-saved');
expect(reloaded.openaiApiKey).toBe('sk-openai-saved');
expect(reloaded.geminiApiKey).toBe('gemini-saved');
expect(reloaded.googleApiKey).toBe('google-saved');
expect(reloaded.groqApiKey).toBe('groq-saved');
expect(reloaded.openrouterApiKey).toBe('openrouter-saved');
expect(reloaded.cursorApiKey).toBe('cursor-saved');
});
it('should not persist API keys when not set', () => {
const yaml = [
'language: en',
'log_level: info',
'provider: claude',
].join('\n');
writeFileSync(configPath, yaml, 'utf-8');
@ -179,6 +215,10 @@ describe('GlobalConfig load/save with API keys', () => {
const content = readFileSync(configPath, 'utf-8');
expect(content).not.toContain('anthropic_api_key');
expect(content).not.toContain('openai_api_key');
expect(content).not.toContain('gemini_api_key');
expect(content).not.toContain('google_api_key');
expect(content).not.toContain('groq_api_key');
expect(content).not.toContain('openrouter_api_key');
expect(content).not.toContain('cursor_api_key');
});
});
@ -204,7 +244,6 @@ describe('resolveAnthropicApiKey', () => {
process.env['TAKT_ANTHROPIC_API_KEY'] = 'sk-ant-from-env';
const yaml = [
'language: en',
'log_level: info',
'provider: claude',
'anthropic_api_key: sk-ant-from-yaml',
].join('\n');
@ -218,7 +257,6 @@ describe('resolveAnthropicApiKey', () => {
delete process.env['TAKT_ANTHROPIC_API_KEY'];
const yaml = [
'language: en',
'log_level: info',
'provider: claude',
'anthropic_api_key: sk-ant-from-yaml',
].join('\n');
@ -232,7 +270,6 @@ describe('resolveAnthropicApiKey', () => {
delete process.env['TAKT_ANTHROPIC_API_KEY'];
const yaml = [
'language: en',
'log_level: info',
'provider: claude',
].join('\n');
writeFileSync(configPath, yaml, 'utf-8');
@ -249,6 +286,13 @@ describe('resolveAnthropicApiKey', () => {
const key = resolveAnthropicApiKey();
expect(key).toBeUndefined();
});
it('should throw when config yaml is invalid', () => {
delete process.env['TAKT_ANTHROPIC_API_KEY'];
writeFileSync(configPath, 'language: [\n', 'utf-8');
expect(() => resolveAnthropicApiKey()).toThrow();
});
});
describe('resolveOpenaiApiKey', () => {
@ -272,7 +316,6 @@ describe('resolveOpenaiApiKey', () => {
process.env['TAKT_OPENAI_API_KEY'] = 'sk-openai-from-env';
const yaml = [
'language: en',
'log_level: info',
'provider: claude',
'openai_api_key: sk-openai-from-yaml',
].join('\n');
@ -286,7 +329,6 @@ describe('resolveOpenaiApiKey', () => {
delete process.env['TAKT_OPENAI_API_KEY'];
const yaml = [
'language: en',
'log_level: info',
'provider: claude',
'openai_api_key: sk-openai-from-yaml',
].join('\n');
@ -300,7 +342,6 @@ describe('resolveOpenaiApiKey', () => {
delete process.env['TAKT_OPENAI_API_KEY'];
const yaml = [
'language: en',
'log_level: info',
'provider: claude',
].join('\n');
writeFileSync(configPath, yaml, 'utf-8');
@ -308,6 +349,13 @@ describe('resolveOpenaiApiKey', () => {
const key = resolveOpenaiApiKey();
expect(key).toBeUndefined();
});
it('should throw when config yaml is invalid', () => {
delete process.env['TAKT_OPENAI_API_KEY'];
writeFileSync(configPath, 'language: [\n', 'utf-8');
expect(() => resolveOpenaiApiKey()).toThrow();
});
});
describe('resolveCodexCliPath', () => {
@ -333,7 +381,6 @@ describe('resolveCodexCliPath', () => {
process.env['TAKT_CODEX_CLI_PATH'] = envCodexPath;
const yaml = [
'language: en',
'log_level: info',
'provider: codex',
`codex_cli_path: ${configCodexPath}`,
].join('\n');
@ -348,7 +395,6 @@ describe('resolveCodexCliPath', () => {
const configCodexPath = createExecutableFile('config-codex');
const yaml = [
'language: en',
'log_level: info',
'provider: codex',
`codex_cli_path: ${configCodexPath}`,
].join('\n');
@ -362,7 +408,6 @@ describe('resolveCodexCliPath', () => {
delete process.env['TAKT_CODEX_CLI_PATH'];
const yaml = [
'language: en',
'log_level: info',
'provider: codex',
].join('\n');
writeFileSync(configPath, yaml, 'utf-8');
@ -407,7 +452,6 @@ describe('resolveCodexCliPath', () => {
delete process.env['TAKT_CODEX_CLI_PATH'];
const yaml = [
'language: en',
'log_level: info',
'provider: codex',
`codex_cli_path: ${join(testDir, 'missing-codex-from-config')}`,
].join('\n');
@ -438,7 +482,6 @@ describe('resolveOpencodeApiKey', () => {
process.env['TAKT_OPENCODE_API_KEY'] = 'sk-opencode-from-env';
const yaml = [
'language: en',
'log_level: info',
'provider: claude',
'opencode_api_key: sk-opencode-from-yaml',
].join('\n');
@ -452,7 +495,6 @@ describe('resolveOpencodeApiKey', () => {
delete process.env['TAKT_OPENCODE_API_KEY'];
const yaml = [
'language: en',
'log_level: info',
'provider: claude',
'opencode_api_key: sk-opencode-from-yaml',
].join('\n');
@ -466,7 +508,6 @@ describe('resolveOpencodeApiKey', () => {
delete process.env['TAKT_OPENCODE_API_KEY'];
const yaml = [
'language: en',
'log_level: info',
'provider: claude',
].join('\n');
writeFileSync(configPath, yaml, 'utf-8');
@ -474,6 +515,13 @@ describe('resolveOpencodeApiKey', () => {
const key = resolveOpencodeApiKey();
expect(key).toBeUndefined();
});
it('should throw when config yaml is invalid', () => {
delete process.env['TAKT_OPENCODE_API_KEY'];
writeFileSync(configPath, 'language: [\n', 'utf-8');
expect(() => resolveOpencodeApiKey()).toThrow();
});
});
describe('resolveCursorApiKey', () => {
@ -497,7 +545,6 @@ describe('resolveCursorApiKey', () => {
process.env['TAKT_CURSOR_API_KEY'] = 'cursor-from-env';
const yaml = [
'language: en',
'log_level: info',
'provider: cursor',
'cursor_api_key: cursor-from-yaml',
].join('\n');
@ -511,7 +558,6 @@ describe('resolveCursorApiKey', () => {
delete process.env['TAKT_CURSOR_API_KEY'];
const yaml = [
'language: en',
'log_level: info',
'provider: cursor',
'cursor_api_key: cursor-from-yaml',
].join('\n');
@ -525,7 +571,6 @@ describe('resolveCursorApiKey', () => {
delete process.env['TAKT_CURSOR_API_KEY'];
const yaml = [
'language: en',
'log_level: info',
'provider: cursor',
].join('\n');
writeFileSync(configPath, yaml, 'utf-8');
@ -533,6 +578,13 @@ describe('resolveCursorApiKey', () => {
const key = resolveCursorApiKey();
expect(key).toBeUndefined();
});
it('should throw when config yaml is invalid', () => {
delete process.env['TAKT_CURSOR_API_KEY'];
writeFileSync(configPath, 'language: [\n', 'utf-8');
expect(() => resolveCursorApiKey()).toThrow();
});
});
// ============================================================
@ -623,7 +675,6 @@ describe('resolveClaudeCliPath', () => {
process.env['TAKT_CLAUDE_CLI_PATH'] = envPath;
const yaml = [
'language: en',
'log_level: info',
'provider: claude',
`claude_cli_path: ${configPath2}`,
].join('\n');
@ -633,36 +684,11 @@ describe('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', () => {
it('should use global config when env var is not 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');
@ -676,7 +702,6 @@ describe('resolveClaudeCliPath', () => {
delete process.env['TAKT_CLAUDE_CLI_PATH'];
const yaml = [
'language: en',
'log_level: info',
'provider: claude',
].join('\n');
writeFileSync(configPath, yaml, 'utf-8');
@ -714,7 +739,6 @@ describe('resolveCursorCliPath', () => {
process.env['TAKT_CURSOR_CLI_PATH'] = envPath;
const yaml = [
'language: en',
'log_level: info',
'provider: cursor',
`cursor_cli_path: ${configPath2}`,
].join('\n');
@ -724,36 +748,11 @@ describe('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', () => {
it('should use global config when env var is not 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');
@ -767,7 +766,6 @@ describe('resolveCursorCliPath', () => {
delete process.env['TAKT_CURSOR_CLI_PATH'];
const yaml = [
'language: en',
'log_level: info',
'provider: cursor',
].join('\n');
writeFileSync(configPath, yaml, 'utf-8');
@ -782,12 +780,8 @@ describe('resolveCursorCliPath', () => {
});
});
// ============================================================
// Task 6.3 — resolveCodexCliPath project config layer tests
// ============================================================
describe('resolveCodexCliPath — project config layer', () => {
const originalEnv = process.env['TAKT_CODEX_CLI_PATH'];
describe('resolveCopilotCliPath', () => {
const originalEnv = process.env['TAKT_COPILOT_CLI_PATH'];
beforeEach(() => {
invalidateGlobalConfigCache();
@ -796,49 +790,119 @@ describe('resolveCodexCliPath — project config layer', () => {
afterEach(() => {
if (originalEnv !== undefined) {
process.env['TAKT_CODEX_CLI_PATH'] = originalEnv;
process.env['TAKT_COPILOT_CLI_PATH'] = originalEnv;
} else {
delete process.env['TAKT_CODEX_CLI_PATH'];
delete process.env['TAKT_COPILOT_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');
it('should return env var path when set (highest priority)', () => {
const envPath = createExecutableFile('env-copilot');
const configPath2 = createExecutableFile('config-copilot');
process.env['TAKT_COPILOT_CLI_PATH'] = envPath;
const yaml = [
'language: en',
'log_level: info',
'provider: codex',
`codex_cli_path: ${globalPath}`,
'provider: copilot',
`copilot_cli_path: ${configPath2}`,
].join('\n');
writeFileSync(configPath, yaml, 'utf-8');
const path = resolveCodexCliPath({ codexCliPath: projPath });
expect(path).toBe(projPath);
const path = resolveCopilotCliPath();
expect(path).toBe(envPath);
});
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);
it('should use global config when env var is not set', () => {
delete process.env['TAKT_COPILOT_CLI_PATH'];
const globalPath = createExecutableFile('global-copilot');
const yaml = [
'language: en',
'provider: copilot',
`copilot_cli_path: ${globalPath}`,
].join('\n');
writeFileSync(configPath, yaml, 'utf-8');
const path = resolveCopilotCliPath();
expect(path).toBe(globalPath);
});
it('should return undefined when nothing is set', () => {
delete process.env['TAKT_COPILOT_CLI_PATH'];
const yaml = [
'language: en',
'provider: copilot',
].join('\n');
writeFileSync(configPath, yaml, 'utf-8');
const path = resolveCopilotCliPath();
expect(path).toBeUndefined();
});
it('should throw when env path is invalid', () => {
process.env['TAKT_COPILOT_CLI_PATH'] = join(testDir, 'missing-copilot');
expect(() => resolveCopilotCliPath()).toThrow(/does not exist/i);
});
});
describe('resolveCopilotGithubToken', () => {
const originalEnv = process.env['TAKT_COPILOT_GITHUB_TOKEN'];
beforeEach(() => {
invalidateGlobalConfigCache();
mkdirSync(taktDir, { recursive: true });
});
afterEach(() => {
if (originalEnv !== undefined) {
process.env['TAKT_COPILOT_GITHUB_TOKEN'] = originalEnv;
} else {
delete process.env['TAKT_COPILOT_GITHUB_TOKEN'];
}
rmSync(testDir, { recursive: true, force: true });
});
it('should return env var when set', () => {
process.env['TAKT_COPILOT_GITHUB_TOKEN'] = 'ghu-from-env';
const yaml = [
'language: en',
'provider: copilot',
'copilot_github_token: ghu-from-yaml',
].join('\n');
writeFileSync(configPath, yaml, 'utf-8');
const token = resolveCopilotGithubToken();
expect(token).toBe('ghu-from-env');
});
it('should fall back to config when env var is not set', () => {
delete process.env['TAKT_COPILOT_GITHUB_TOKEN'];
const yaml = [
'language: en',
'provider: copilot',
'copilot_github_token: ghu-from-yaml',
].join('\n');
writeFileSync(configPath, yaml, 'utf-8');
const token = resolveCopilotGithubToken();
expect(token).toBe('ghu-from-yaml');
});
it('should return undefined when neither env var nor config is set', () => {
delete process.env['TAKT_COPILOT_GITHUB_TOKEN'];
const yaml = [
'language: en',
'provider: copilot',
].join('\n');
writeFileSync(configPath, yaml, 'utf-8');
const token = resolveCopilotGithubToken();
expect(token).toBeUndefined();
});
it('should throw when config yaml is invalid', () => {
delete process.env['TAKT_COPILOT_GITHUB_TOKEN'];
writeFileSync(configPath, 'language: [\n', 'utf-8');
expect(() => resolveCopilotGithubToken()).toThrow();
});
});

View File

@ -20,7 +20,7 @@ vi.mock('../infra/config/paths.js', () => ({
getProjectCwd: vi.fn(),
}));
import { GlobalConfigManager } from '../infra/config/global/globalConfig.js';
import { GlobalConfigManager } from '../infra/config/global/globalConfigCore.js';
describe('globalConfig', () => {
let testDir: string;

View File

@ -0,0 +1,156 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { randomUUID } from 'node:crypto';
import { tmpdir } from 'node:os';
const testId = randomUUID();
const rootDir = join(tmpdir(), `takt-it-config-project-priority-${testId}`);
const projectDir = join(rootDir, 'project');
vi.mock('../infra/config/global/globalConfig.js', async (importOriginal) => {
const original = await importOriginal() as Record<string, unknown>;
const globalMigratedValues = {
logLevel: 'info',
pipeline: { defaultBranchPrefix: 'global/' },
personaProviders: { coder: { provider: 'claude', model: 'claude-3-5-sonnet-latest' } },
branchNameStrategy: 'ai',
minimalOutput: false,
concurrency: 2,
taskPollIntervalMs: 2000,
interactivePreviewMovements: 4,
verbose: false,
} as const;
return {
...original,
loadGlobalConfig: () => ({
language: 'en',
provider: 'claude',
autoFetch: false,
}),
loadGlobalMigratedProjectLocalFallback: () => globalMigratedValues,
invalidateGlobalConfigCache: () => undefined,
};
});
const {
resolveConfigValues,
resolveConfigValueWithSource,
invalidateAllResolvedConfigCache,
invalidateGlobalConfigCache,
} = await import('../infra/config/index.js');
describe('IT: migrated config keys should prefer project over global', () => {
beforeEach(() => {
mkdirSync(projectDir, { recursive: true });
mkdirSync(join(projectDir, '.takt'), { recursive: true });
writeFileSync(
join(projectDir, '.takt', 'config.yaml'),
[
'log_level: debug',
'pipeline:',
' default_branch_prefix: "project/"',
'persona_providers:',
' coder:',
' provider: opencode',
' model: opencode/big-pickle',
'branch_name_strategy: ai',
'minimal_output: true',
'concurrency: 5',
'task_poll_interval_ms: 1300',
'interactive_preview_movements: 1',
'verbose: true',
].join('\n'),
'utf-8',
);
invalidateGlobalConfigCache();
invalidateAllResolvedConfigCache();
});
afterEach(() => {
invalidateGlobalConfigCache();
invalidateAllResolvedConfigCache();
if (existsSync(rootDir)) {
rmSync(rootDir, { recursive: true, force: true });
}
});
it('should resolve migrated keys from project config when global has conflicting values', () => {
const resolved = resolveConfigValues(projectDir, [
'logLevel',
'pipeline',
'personaProviders',
'branchNameStrategy',
'minimalOutput',
'concurrency',
'taskPollIntervalMs',
'interactivePreviewMovements',
'verbose',
]);
expect(resolved.logLevel).toBe('debug');
expect(resolved.pipeline).toEqual({
defaultBranchPrefix: 'project/',
});
expect(resolved.personaProviders).toEqual({
coder: { provider: 'opencode', model: 'opencode/big-pickle' },
});
expect(resolved.branchNameStrategy).toBe('ai');
expect(resolved.minimalOutput).toBe(true);
expect(resolved.concurrency).toBe(5);
expect(resolved.taskPollIntervalMs).toBe(1300);
expect(resolved.interactivePreviewMovements).toBe(1);
expect(resolved.verbose).toBe(true);
});
it('should resolve migrated keys from global when project config does not set them', () => {
writeFileSync(
join(projectDir, '.takt', 'config.yaml'),
'piece: default\n',
'utf-8',
);
invalidateGlobalConfigCache();
invalidateAllResolvedConfigCache();
const resolved = resolveConfigValues(projectDir, [
'logLevel',
'pipeline',
'personaProviders',
'branchNameStrategy',
'minimalOutput',
'concurrency',
'taskPollIntervalMs',
'interactivePreviewMovements',
'verbose',
]);
expect(resolved.logLevel).toBe('info');
expect(resolved.pipeline).toEqual({ defaultBranchPrefix: 'global/' });
expect(resolved.personaProviders).toEqual({
coder: { provider: 'claude', model: 'claude-3-5-sonnet-latest' },
});
expect(resolved.branchNameStrategy).toBe('ai');
expect(resolved.minimalOutput).toBe(false);
expect(resolved.concurrency).toBe(2);
expect(resolved.taskPollIntervalMs).toBe(2000);
expect(resolved.interactivePreviewMovements).toBe(4);
expect(resolved.verbose).toBe(false);
});
it('should mark migrated key source as global when only global defines the key', () => {
writeFileSync(
join(projectDir, '.takt', 'config.yaml'),
'piece: default\n',
'utf-8',
);
invalidateGlobalConfigCache();
invalidateAllResolvedConfigCache();
expect(resolveConfigValueWithSource(projectDir, 'logLevel')).toEqual({
value: 'info',
source: 'global',
});
});
});

View File

@ -42,6 +42,7 @@ vi.mock('../shared/utils/index.js', async (importOriginal) => ({
vi.mock('../infra/config/global/globalConfig.js', () => ({
loadGlobalConfig: vi.fn().mockReturnValue({}),
loadGlobalMigratedProjectLocalFallback: vi.fn().mockReturnValue({}),
getLanguage: vi.fn().mockReturnValue('en'),
getDisabledBuiltins: vi.fn().mockReturnValue([]),
getBuiltinPiecesEnabled: vi.fn().mockReturnValue(true),

View File

@ -46,6 +46,7 @@ vi.mock('../shared/utils/index.js', async (importOriginal) => ({
vi.mock('../infra/config/global/globalConfig.js', () => ({
loadGlobalConfig: vi.fn().mockReturnValue({}),
loadGlobalMigratedProjectLocalFallback: vi.fn().mockReturnValue({}),
getLanguage: vi.fn().mockReturnValue('en'),
getBuiltinPiecesEnabled: vi.fn().mockReturnValue(true),
}));

View File

@ -47,6 +47,7 @@ vi.mock('../shared/utils/index.js', async (importOriginal) => ({
vi.mock('../infra/config/global/globalConfig.js', () => ({
loadGlobalConfig: vi.fn().mockReturnValue({}),
loadGlobalMigratedProjectLocalFallback: vi.fn().mockReturnValue({}),
getLanguage: vi.fn().mockReturnValue('en'),
getDisabledBuiltins: vi.fn().mockReturnValue([]),
getBuiltinPiecesEnabled: vi.fn().mockReturnValue(true),

View File

@ -577,21 +577,18 @@ describe('GlobalConfigSchema', () => {
const config = {};
const result = GlobalConfigSchema.parse(config);
expect(result.log_level).toBe('info');
expect(result.provider).toBe('claude');
expect(result.observability).toBeUndefined();
});
it('should accept valid config', () => {
const config = {
log_level: 'debug' as const,
observability: {
provider_events: false,
},
};
const result = GlobalConfigSchema.parse(config);
expect(result.log_level).toBe('debug');
expect(result.observability?.provider_events).toBe(false);
});
@ -609,22 +606,7 @@ describe('GlobalConfigSchema', () => {
expect(provider?.network_access).toBe(true);
});
it('should parse persona_providers entry with provider object block', () => {
const result = GlobalConfigSchema.parse({
persona_providers: {
coder: {
type: 'opencode',
model: 'openai/gpt-5',
},
},
} as unknown);
const personaProviders = (result as Record<string, unknown>).persona_providers as Record<string, unknown> | undefined;
const coder = personaProviders?.coder as Record<string, unknown> | undefined;
expect(coder?.type).toBe('opencode');
expect(coder?.model).toBe('openai/gpt-5');
});
it('should reject persona_providers provider object block with provider options', () => {
it('should reject persona_providers because it is project-local only', () => {
expect(() => GlobalConfigSchema.parse({
persona_providers: {
coder: {

View File

@ -16,11 +16,10 @@ describe('Schemas accept opencode provider', () => {
expect(result.provider).toBe('opencode');
});
it('should accept opencode in GlobalConfigSchema persona_providers field', () => {
const result = GlobalConfigSchema.parse({
it('should reject persona_providers in GlobalConfigSchema', () => {
expect(() => GlobalConfigSchema.parse({
persona_providers: { coder: { provider: 'opencode' } },
});
expect(result.persona_providers).toEqual({ coder: { provider: 'opencode' } });
})).toThrow();
});
it('should accept opencode_api_key in GlobalConfigSchema', () => {

View File

@ -6,6 +6,7 @@ const {
loadAgentPromptMock,
loadProjectConfigMock,
loadGlobalConfigMock,
resolveConfigValueMock,
loadTemplateMock,
providerSetupMock,
providerCallMock,
@ -19,6 +20,7 @@ const {
loadAgentPromptMock: vi.fn(),
loadProjectConfigMock: vi.fn(),
loadGlobalConfigMock: vi.fn(),
resolveConfigValueMock: vi.fn(),
loadTemplateMock: vi.fn(),
providerSetupMock: providerSetup,
providerCallMock: providerCall,
@ -32,6 +34,7 @@ vi.mock('../infra/providers/index.js', () => ({
vi.mock('../infra/config/index.js', () => ({
loadProjectConfig: loadProjectConfigMock,
loadGlobalConfig: loadGlobalConfigMock,
resolveConfigValue: resolveConfigValueMock,
loadCustomAgents: loadCustomAgentsMock,
loadAgentPrompt: loadAgentPromptMock,
}));
@ -53,6 +56,12 @@ describe('option resolution order', () => {
concurrency: 1,
taskPollIntervalMs: 500,
});
resolveConfigValueMock.mockImplementation((_cwd: string, key: string) => {
if (key === 'personaProviders') {
return loadProjectConfigMock.mock.results.at(-1)?.value?.personaProviders;
}
return undefined;
});
loadCustomAgentsMock.mockReturnValue(new Map());
loadAgentPromptMock.mockReturnValue('prompt');
loadTemplateMock.mockReturnValue('template');
@ -98,12 +107,14 @@ describe('option resolution order', () => {
});
it('should apply persona provider override before local/global config', async () => {
loadProjectConfigMock.mockReturnValue({ provider: 'opencode' });
loadGlobalConfigMock.mockReturnValue({
provider: 'mock',
loadProjectConfigMock.mockReturnValue({
provider: 'opencode',
personaProviders: {
coder: { provider: 'claude' },
},
});
loadGlobalConfigMock.mockReturnValue({
provider: 'mock',
language: 'en',
concurrency: 1,
taskPollIntervalMs: 500,
@ -117,16 +128,16 @@ describe('option resolution order', () => {
});
it('should resolve model in order: CLI > persona > step > local > global', async () => {
loadProjectConfigMock.mockReturnValue({
provider: 'claude',
model: 'local-model',
});
loadGlobalConfigMock.mockReturnValue({
provider: 'claude',
model: 'global-model',
language: 'en',
concurrency: 1,
taskPollIntervalMs: 500,
});
loadProjectConfigMock.mockReturnValue({
provider: 'claude',
model: 'local-model',
personaProviders: {
coder: { model: 'persona-model' },
},

View File

@ -10,6 +10,7 @@ describe('isPathSafe', () => {
it('should reject paths outside base directory', () => {
expect(isPathSafe('/home/user/project', '/home/user/other/file.ts')).toBe(false);
expect(isPathSafe('/home/user/project', '/etc/passwd')).toBe(false);
expect(isPathSafe('/home/user/project', '/home/user/project_malicious/file.ts')).toBe(false);
});
it('should reject directory traversal attempts', () => {

View File

@ -10,7 +10,20 @@
* - Sub-movement rules use simple approved/needs_fix conditions
*/
import { describe, it, expect } from 'vitest';
import { describe, it, expect, vi } from 'vitest';
vi.mock('../infra/config/global/globalConfig.js', async (importOriginal) => {
const original = await importOriginal() as Record<string, unknown>;
return {
...original,
loadGlobalConfig: () => ({
language: 'en',
provider: 'claude',
autoFetch: false,
}),
};
});
import { loadPiece } from '../infra/config/index.js';
describe('dual piece parallel structure', () => {

View File

@ -376,6 +376,25 @@ describe('selectPiece', () => {
expect(configMock.buildCategorizedPieces).toHaveBeenCalled();
});
it('should fall back to default current piece when config piece is undefined', async () => {
const pieceMap = createPieceMap([{ name: 'default', source: 'builtin' }]);
const categorized: CategorizedPieces = {
categories: [{ name: 'Quick Start', pieces: ['default'], children: [] }],
allPieces: pieceMap,
missingPieces: [],
};
configMock.getPieceCategories.mockReturnValue({ categories: ['Quick Start'] });
configMock.loadAllPiecesWithSources.mockReturnValue(pieceMap);
configMock.buildCategorizedPieces.mockReturnValue(categorized);
configMock.resolveConfigValue.mockReturnValue(undefined);
selectOptionMock.mockResolvedValueOnce('__current__');
const result = await selectPiece('/cwd');
expect(result).toBe('default');
});
it('should use directory-based selection when no category config', async () => {
configMock.getPieceCategories.mockReturnValue(null);
configMock.listPieces.mockReturnValue(['piece-a', 'piece-b']);

View File

@ -0,0 +1,92 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const {
mockLoadPieceByIdentifier,
mockResolvePieceConfigValue,
mockHeader,
mockInfo,
mockError,
mockBlankLine,
mockInstructionBuild,
mockReportBuild,
mockJudgmentBuild,
} = vi.hoisted(() => ({
mockLoadPieceByIdentifier: vi.fn(),
mockResolvePieceConfigValue: vi.fn(),
mockHeader: vi.fn(),
mockInfo: vi.fn(),
mockError: vi.fn(),
mockBlankLine: vi.fn(),
mockInstructionBuild: vi.fn(() => 'phase1'),
mockReportBuild: vi.fn(() => 'phase2'),
mockJudgmentBuild: vi.fn(() => 'phase3'),
}));
vi.mock('../infra/config/index.js', () => ({
loadPieceByIdentifier: mockLoadPieceByIdentifier,
resolvePieceConfigValue: mockResolvePieceConfigValue,
}));
vi.mock('../core/piece/instruction/InstructionBuilder.js', () => ({
InstructionBuilder: vi.fn().mockImplementation(() => ({
build: mockInstructionBuild,
})),
}));
vi.mock('../core/piece/instruction/ReportInstructionBuilder.js', () => ({
ReportInstructionBuilder: vi.fn().mockImplementation(() => ({
build: mockReportBuild,
})),
}));
vi.mock('../core/piece/instruction/StatusJudgmentBuilder.js', () => ({
StatusJudgmentBuilder: vi.fn().mockImplementation(() => ({
build: mockJudgmentBuild,
})),
}));
vi.mock('../core/piece/index.js', () => ({
needsStatusJudgmentPhase: vi.fn(() => false),
}));
vi.mock('../shared/ui/index.js', () => ({
header: mockHeader,
info: mockInfo,
error: mockError,
blankLine: mockBlankLine,
}));
import { previewPrompts } from '../features/prompt/preview.js';
describe('previewPrompts', () => {
beforeEach(() => {
vi.clearAllMocks();
mockResolvePieceConfigValue.mockImplementation((_: string, key: string) => {
if (key === 'piece') return undefined;
if (key === 'language') return 'en';
return undefined;
});
mockLoadPieceByIdentifier.mockReturnValue({
name: 'default',
maxMovements: 1,
movements: [
{
name: 'implement',
personaDisplayName: 'coder',
outputContracts: [],
},
],
});
vi.spyOn(console, 'log').mockImplementation(() => undefined);
});
afterEach(() => {
vi.restoreAllMocks();
});
it('piece未設定時はDEFAULT_PIECE_NAMEでロードする', async () => {
await previewPrompts('/project');
expect(mockLoadPieceByIdentifier).toHaveBeenCalledWith('default', '/project');
});
});

View File

@ -6,7 +6,7 @@
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { loadProjectConfig, saveProjectConfig } from '../infra/config/project/projectConfig.js';
@ -96,4 +96,260 @@ piece_overrides:
expect(reloaded.pieceOverrides?.qualityGates).toEqual(['Test 1', 'Test 2']);
});
});
describe('migrated project-local fields', () => {
it('should load migrated fields from project config yaml', () => {
const configPath = join(testDir, '.takt', 'config.yaml');
const configContent = [
'log_level: debug',
'pipeline:',
' default_branch_prefix: "proj/"',
' commit_message_template: "feat: {title} (#{issue})"',
'persona_providers:',
' coder:',
' provider: opencode',
' model: opencode/big-pickle',
'branch_name_strategy: ai',
'minimal_output: true',
'concurrency: 3',
'task_poll_interval_ms: 1200',
'interactive_preview_movements: 2',
'verbose: true',
].join('\n');
writeFileSync(configPath, configContent, 'utf-8');
const loaded = loadProjectConfig(testDir) as Record<string, unknown>;
expect(loaded.logLevel).toBe('debug');
expect(loaded.pipeline).toEqual({
defaultBranchPrefix: 'proj/',
commitMessageTemplate: 'feat: {title} (#{issue})',
});
expect(loaded.personaProviders).toEqual({
coder: { provider: 'opencode', model: 'opencode/big-pickle' },
});
expect(loaded.branchNameStrategy).toBe('ai');
expect(loaded.minimalOutput).toBe(true);
expect(loaded.concurrency).toBe(3);
expect(loaded.taskPollIntervalMs).toBe(1200);
expect(loaded.interactivePreviewMovements).toBe(2);
expect(loaded.verbose).toBe(true);
});
it('should save migrated fields as snake_case keys', () => {
const config = {
logLevel: 'warn',
pipeline: {
defaultBranchPrefix: 'task/',
prBodyTemplate: 'Body {report}',
},
personaProviders: {
reviewer: { provider: 'codex', model: 'gpt-5' },
},
branchNameStrategy: 'romaji',
minimalOutput: true,
concurrency: 4,
taskPollIntervalMs: 1500,
interactivePreviewMovements: 1,
verbose: false,
} as ProjectLocalConfig;
saveProjectConfig(testDir, config);
const raw = readFileSync(join(testDir, '.takt', 'config.yaml'), 'utf-8');
expect(raw).toContain('log_level: warn');
expect(raw).toContain('pipeline:');
expect(raw).toContain('default_branch_prefix: task/');
expect(raw).toContain('pr_body_template: Body {report}');
expect(raw).toContain('persona_providers:');
expect(raw).toContain('provider: codex');
expect(raw).toContain('branch_name_strategy: romaji');
expect(raw).toContain('minimal_output: true');
expect(raw).toContain('concurrency: 4');
expect(raw).toContain('task_poll_interval_ms: 1500');
expect(raw).toContain('interactive_preview_movements: 1');
expect(raw).not.toContain('verbose: false');
});
it('should not persist schema-injected default values on save', () => {
const loaded = loadProjectConfig(testDir);
saveProjectConfig(testDir, loaded);
const raw = readFileSync(join(testDir, '.takt', 'config.yaml'), 'utf-8');
expect(raw).not.toContain('log_level: info');
expect(raw).not.toContain('minimal_output: false');
expect(raw).not.toContain('concurrency: 1');
expect(raw).not.toContain('task_poll_interval_ms: 500');
expect(raw).not.toContain('interactive_preview_movements: 3');
expect(raw).not.toContain('verbose: false');
});
it('should fail fast when project config contains global-only cli path keys', () => {
const configPath = join(testDir, '.takt', 'config.yaml');
writeFileSync(
configPath,
[
'claude_cli_path: /tmp/bin/claude',
'codex_cli_path: /tmp/bin/codex',
'cursor_cli_path: /tmp/bin/cursor-agent',
'copilot_cli_path: /tmp/bin/copilot',
].join('\n'),
'utf-8',
);
expect(() => loadProjectConfig(testDir)).toThrow(/unrecognized/i);
});
it('should fail fast when project config contains other global-only keys', () => {
const configPath = join(testDir, '.takt', 'config.yaml');
writeFileSync(
configPath,
[
'language: ja',
'anthropic_api_key: sk-test',
].join('\n'),
'utf-8',
);
expect(() => loadProjectConfig(testDir)).toThrow(/unrecognized/i);
});
});
describe('fail fast validation', () => {
it('should throw on invalid yaml syntax', () => {
const configPath = join(testDir, '.takt', 'config.yaml');
writeFileSync(configPath, 'pipeline: [unclosed', 'utf-8');
expect(() => loadProjectConfig(testDir)).toThrow(/failed to parse/);
});
it('should throw when yaml root is not an object', () => {
const configPath = join(testDir, '.takt', 'config.yaml');
writeFileSync(configPath, '- item1\n- item2\n', 'utf-8');
expect(() => loadProjectConfig(testDir)).toThrow(/must be a YAML object/);
});
it('should throw when pipeline has unknown field', () => {
const configPath = join(testDir, '.takt', 'config.yaml');
writeFileSync(
configPath,
[
'pipeline:',
' default_branch_prefix: "task/"',
' unknown_field: "x"',
].join('\n'),
'utf-8',
);
expect(() => loadProjectConfig(testDir)).toThrow(/Configuration error: invalid pipeline/);
});
it('should throw when pipeline value has invalid type', () => {
const configPath = join(testDir, '.takt', 'config.yaml');
writeFileSync(
configPath,
[
'pipeline:',
' commit_message_template: 123',
].join('\n'),
'utf-8',
);
expect(() => loadProjectConfig(testDir)).toThrow(/Configuration error: invalid pipeline\.commit_message_template/);
});
it('should throw when persona_providers entry has unknown field', () => {
const configPath = join(testDir, '.takt', 'config.yaml');
writeFileSync(
configPath,
[
'persona_providers:',
' coder:',
' provider: codex',
' unsupported: true',
].join('\n'),
'utf-8',
);
expect(() => loadProjectConfig(testDir)).toThrow(/Configuration error: invalid persona_providers\.coder/);
});
it('should throw when persona_providers entry has invalid provider', () => {
const configPath = join(testDir, '.takt', 'config.yaml');
writeFileSync(
configPath,
[
'persona_providers:',
' coder:',
' provider: invalid-provider',
].join('\n'),
'utf-8',
);
expect(() => loadProjectConfig(testDir)).toThrow(/Configuration error: invalid persona_providers\.coder/);
});
it('should throw when persona_providers entry has both provider and type', () => {
const configPath = join(testDir, '.takt', 'config.yaml');
writeFileSync(
configPath,
[
'persona_providers:',
' coder:',
' provider: codex',
' type: opencode',
].join('\n'),
'utf-8',
);
expect(() => loadProjectConfig(testDir)).toThrow(/Configuration error: invalid persona_providers\.coder/);
});
it('should throw when persona_providers entry has codex provider with Claude model alias', () => {
const configPath = join(testDir, '.takt', 'config.yaml');
writeFileSync(
configPath,
[
'persona_providers:',
' coder:',
' provider: codex',
' model: opus',
].join('\n'),
'utf-8',
);
expect(() => loadProjectConfig(testDir)).toThrow(/Claude model alias/);
});
it('should throw when persona_providers entry has opencode provider without model', () => {
const configPath = join(testDir, '.takt', 'config.yaml');
writeFileSync(
configPath,
[
'persona_providers:',
' reviewer:',
' provider: opencode',
].join('\n'),
'utf-8',
);
expect(() => loadProjectConfig(testDir)).toThrow(/provider 'opencode' requires model/);
});
it('should allow persona_providers entry with opencode provider and provider/model value', () => {
const configPath = join(testDir, '.takt', 'config.yaml');
writeFileSync(
configPath,
[
'persona_providers:',
' coder:',
' provider: opencode',
' model: opencode/big-pickle',
].join('\n'),
'utf-8',
);
expect(() => loadProjectConfig(testDir)).not.toThrow();
});
});
});

View File

@ -34,9 +34,16 @@ describe('resetGlobalConfigToTemplate', () => {
expect(readFileSync(result.backupPath!, 'utf-8')).toContain('provider: mock');
const newConfig = readFileSync(configPath, 'utf-8');
expect(newConfig).toContain('# TAKT グローバル設定サンプル');
expect(newConfig).toContain('language: ja');
expect(newConfig).toContain('branch_name_strategy: ai');
expect(newConfig).toContain('concurrency: 2');
expect(newConfig).not.toContain('provider:');
expect(newConfig).not.toContain('runtime:');
expect(newConfig).not.toContain('branch_name_strategy:');
expect(newConfig).not.toContain('concurrency:');
expect(newConfig).not.toContain('minimal_output:');
expect(newConfig).not.toContain('task_poll_interval_ms:');
expect(newConfig).not.toContain('persona_providers:');
expect(newConfig).not.toContain('pipeline:');
});
it('should create config from default language template when config does not exist', () => {
@ -48,7 +55,11 @@ describe('resetGlobalConfigToTemplate', () => {
expect(result.language).toBe('en');
expect(existsSync(configPath)).toBe(true);
const newConfig = readFileSync(configPath, 'utf-8');
expect(newConfig).toContain('# TAKT global configuration sample');
expect(newConfig).toContain('language: en');
expect(newConfig).toContain('branch_name_strategy: ai');
expect(newConfig).not.toContain('provider:');
expect(newConfig).not.toContain('runtime:');
expect(newConfig).not.toContain('branch_name_strategy:');
expect(newConfig).not.toContain('concurrency:');
});
});

View File

@ -0,0 +1,22 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
describe('resolveConfigValue call-chain contract', () => {
afterEach(() => {
vi.resetModules();
vi.doUnmock('../infra/config/global/globalConfig.js');
vi.doUnmock('../infra/config/project/projectConfig.js');
});
it('should fail fast when migrated fallback loader is missing and migrated key is resolved', async () => {
vi.doMock('../infra/config/project/projectConfig.js', () => ({
loadProjectConfig: () => ({ piece: 'default' }),
}));
vi.doMock('../infra/config/global/globalConfig.js', () => ({
loadGlobalConfig: () => ({ language: 'en' }),
}));
const { resolveConfigValue } = await import('../infra/config/resolveConfigValue.js');
expect(() => resolveConfigValue('/tmp/takt-project', 'logLevel')).toThrow();
});
});

View File

@ -26,9 +26,16 @@ vi.mock('../infra/config/paths.js', async (importOriginal) => {
};
});
const { resolveConfigValue, resolveConfigValueWithSource, invalidateAllResolvedConfigCache } = await import('../infra/config/resolveConfigValue.js');
const {
resolveConfigValue,
resolveConfigValueWithSource,
invalidateAllResolvedConfigCache,
} = await import('../infra/config/resolveConfigValue.js');
const { invalidateGlobalConfigCache } = await import('../infra/config/global/globalConfig.js');
const { getProjectConfigDir } = await import('../infra/config/paths.js');
const { MIGRATED_PROJECT_LOCAL_CONFIG_KEYS } = await import('../infra/config/migratedProjectLocalKeys.js');
const { MIGRATED_PROJECT_LOCAL_DEFAULTS } = await import('../infra/config/migratedProjectLocalDefaults.js');
type ConfigParameterKey = import('../infra/config/resolveConfigValue.js').ConfigParameterKey;
describe('RESOLUTION_REGISTRY defaultValue removal', () => {
let projectDir: string;
@ -82,27 +89,29 @@ describe('RESOLUTION_REGISTRY defaultValue removal', () => {
});
describe('verbose', () => {
it('should resolve verbose to false via schema default when not set anywhere', () => {
it('should resolve verbose to false via resolver default when not set anywhere', () => {
const value = resolveConfigValue(projectDir, 'verbose');
expect(value).toBe(false);
});
it('should report source as global when verbose comes from schema default', () => {
it('should report source as default when verbose comes from resolver default', () => {
const result = resolveConfigValueWithSource(projectDir, 'verbose');
expect(result.value).toBe(false);
expect(result.source).toBe('global');
expect(result.source).toBe('default');
});
it('should resolve verbose from global config when explicitly set', () => {
writeFileSync(globalConfigPath, 'language: en\nverbose: true\n', 'utf-8');
it('should resolve verbose default when project does not set it', () => {
writeFileSync(globalConfigPath, 'language: en\n', 'utf-8');
invalidateGlobalConfigCache();
const value = resolveConfigValue(projectDir, 'verbose');
expect(value).toBe(true);
expect(resolveConfigValueWithSource(projectDir, 'verbose')).toEqual({
value: false,
source: 'default',
});
});
it('should resolve verbose from project config over global', () => {
writeFileSync(globalConfigPath, 'language: en\nverbose: false\n', 'utf-8');
it('should resolve verbose from project config when project sets it', () => {
writeFileSync(globalConfigPath, 'language: en\n', 'utf-8');
invalidateGlobalConfigCache();
const configDir = getProjectConfigDir(projectDir);
@ -114,6 +123,255 @@ describe('RESOLUTION_REGISTRY defaultValue removal', () => {
});
});
describe('project-local priority for migrated keys', () => {
it.each([
{
key: 'logLevel',
projectYaml: 'log_level: debug\n',
expected: 'debug',
},
{
key: 'minimalOutput',
projectYaml: 'minimal_output: true\n',
expected: true,
},
{
key: 'branchNameStrategy',
projectYaml: 'branch_name_strategy: ai\n',
expected: 'ai',
},
{
key: 'taskPollIntervalMs',
projectYaml: 'task_poll_interval_ms: 1200\n',
expected: 1200,
},
{
key: 'interactivePreviewMovements',
projectYaml: 'interactive_preview_movements: 1\n',
expected: 1,
},
{
key: 'concurrency',
projectYaml: 'concurrency: 3\n',
expected: 3,
},
{
key: 'verbose',
projectYaml: 'verbose: true\n',
expected: true,
},
])('should resolve $key from project config', ({ key, projectYaml, expected }) => {
writeFileSync(globalConfigPath, 'language: en\n', 'utf-8');
invalidateGlobalConfigCache();
const configDir = getProjectConfigDir(projectDir);
mkdirSync(configDir, { recursive: true });
writeFileSync(join(configDir, 'config.yaml'), projectYaml, 'utf-8');
const result = resolveConfigValueWithSource(projectDir, key as ConfigParameterKey);
expect(result.value).toBe(expected);
expect(result.source).toBe('project');
});
it('should resolve personaProviders from project config', () => {
writeFileSync(globalConfigPath, 'language: en\n', 'utf-8');
invalidateGlobalConfigCache();
const configDir = getProjectConfigDir(projectDir);
mkdirSync(configDir, { recursive: true });
writeFileSync(
join(configDir, 'config.yaml'),
[
'persona_providers:',
' coder:',
' provider: opencode',
' model: project-model',
].join('\n'),
'utf-8',
);
const result = resolveConfigValueWithSource(projectDir, 'personaProviders' as ConfigParameterKey);
expect(result.source).toBe('project');
expect(result.value).toEqual({
coder: {
provider: 'opencode',
model: 'project-model',
},
});
});
it('should resolve pipeline from project config', () => {
writeFileSync(globalConfigPath, 'language: en\n', 'utf-8');
invalidateGlobalConfigCache();
const configDir = getProjectConfigDir(projectDir);
mkdirSync(configDir, { recursive: true });
writeFileSync(
join(configDir, 'config.yaml'),
[
'pipeline:',
' default_branch_prefix: "project/"',
' commit_message_template: "feat: {title} (#{issue})"',
].join('\n'),
'utf-8',
);
const result = resolveConfigValueWithSource(projectDir, 'pipeline' as ConfigParameterKey);
expect(result.source).toBe('project');
expect(result.value).toEqual({
defaultBranchPrefix: 'project/',
commitMessageTemplate: 'feat: {title} (#{issue})',
});
});
it('should resolve migrated non-default keys as undefined when project keys are unset', () => {
const configDir = getProjectConfigDir(projectDir);
mkdirSync(configDir, { recursive: true });
writeFileSync(join(configDir, 'config.yaml'), 'piece: default\n', 'utf-8');
writeFileSync(
globalConfigPath,
['language: en'].join('\n'),
'utf-8',
);
invalidateGlobalConfigCache();
const pipelineResult = resolveConfigValueWithSource(projectDir, 'pipeline' as ConfigParameterKey);
const personaResult = resolveConfigValueWithSource(projectDir, 'personaProviders' as ConfigParameterKey);
const branchStrategyResult = resolveConfigValueWithSource(projectDir, 'branchNameStrategy' as ConfigParameterKey);
expect(pipelineResult).toEqual({
value: undefined,
source: 'default',
});
expect(personaResult).toEqual({
value: undefined,
source: 'default',
});
expect(branchStrategyResult).toEqual({
value: undefined,
source: 'default',
});
});
it('should resolve default-backed migrated keys from defaults when project keys are unset', () => {
const configDir = getProjectConfigDir(projectDir);
mkdirSync(configDir, { recursive: true });
writeFileSync(join(configDir, 'config.yaml'), 'piece: default\n', 'utf-8');
writeFileSync(
globalConfigPath,
['language: en'].join('\n'),
'utf-8',
);
invalidateGlobalConfigCache();
expect(resolveConfigValueWithSource(projectDir, 'logLevel')).toEqual({ value: 'info', source: 'default' });
expect(resolveConfigValueWithSource(projectDir, 'minimalOutput')).toEqual({ value: false, source: 'default' });
expect(resolveConfigValueWithSource(projectDir, 'concurrency')).toEqual({ value: 1, source: 'default' });
expect(resolveConfigValueWithSource(projectDir, 'taskPollIntervalMs')).toEqual({ value: 500, source: 'default' });
expect(resolveConfigValueWithSource(projectDir, 'interactivePreviewMovements')).toEqual({ value: 3, source: 'default' });
});
it('should resolve migrated keys from global legacy fields when project keys are unset', () => {
writeFileSync(
globalConfigPath,
[
'language: en',
'log_level: warn',
'pipeline:',
' default_branch_prefix: "legacy/"',
'persona_providers:',
' coder:',
' provider: codex',
' model: gpt-5',
'branch_name_strategy: ai',
'minimal_output: true',
'verbose: true',
'concurrency: 3',
'task_poll_interval_ms: 1200',
'interactive_preview_movements: 2',
].join('\n'),
'utf-8',
);
invalidateGlobalConfigCache();
expect(resolveConfigValueWithSource(projectDir, 'logLevel')).toEqual({ value: 'warn', source: 'global' });
expect(resolveConfigValueWithSource(projectDir, 'pipeline')).toEqual({
value: { defaultBranchPrefix: 'legacy/' },
source: 'global',
});
expect(resolveConfigValueWithSource(projectDir, 'personaProviders')).toEqual({
value: { coder: { provider: 'codex', model: 'gpt-5' } },
source: 'global',
});
expect(resolveConfigValueWithSource(projectDir, 'branchNameStrategy')).toEqual({
value: 'ai',
source: 'global',
});
expect(resolveConfigValueWithSource(projectDir, 'minimalOutput')).toEqual({ value: true, source: 'global' });
expect(resolveConfigValueWithSource(projectDir, 'verbose')).toEqual({ value: true, source: 'global' });
expect(resolveConfigValueWithSource(projectDir, 'concurrency')).toEqual({ value: 3, source: 'global' });
expect(resolveConfigValueWithSource(projectDir, 'taskPollIntervalMs')).toEqual({ value: 1200, source: 'global' });
expect(resolveConfigValueWithSource(projectDir, 'interactivePreviewMovements')).toEqual({
value: 2,
source: 'global',
});
});
it('should resolve migrated numeric key from default when project key is unset', () => {
writeFileSync(globalConfigPath, 'language: en\n', 'utf-8');
invalidateGlobalConfigCache();
expect(resolveConfigValueWithSource(projectDir, 'concurrency' as ConfigParameterKey)).toEqual({
value: 1,
source: 'default',
});
});
it('should resolve migrated persona_providers key from default when project key is unset', () => {
writeFileSync(
globalConfigPath,
['language: en'].join('\n'),
'utf-8',
);
invalidateGlobalConfigCache();
expect(resolveConfigValueWithSource(projectDir, 'personaProviders' as ConfigParameterKey)).toEqual({
value: undefined,
source: 'default',
});
});
it('should resolve all migrated keys from project or defaults when project config has no migrated keys', () => {
const configDir = getProjectConfigDir(projectDir);
mkdirSync(configDir, { recursive: true });
writeFileSync(join(configDir, 'config.yaml'), 'piece: default\n', 'utf-8');
writeFileSync(
globalConfigPath,
['language: en'].join('\n'),
'utf-8',
);
invalidateGlobalConfigCache();
const expectedByKey: Partial<Record<ConfigParameterKey, unknown>> = {
logLevel: MIGRATED_PROJECT_LOCAL_DEFAULTS.logLevel,
pipeline: undefined,
personaProviders: undefined,
branchNameStrategy: undefined,
minimalOutput: MIGRATED_PROJECT_LOCAL_DEFAULTS.minimalOutput,
concurrency: MIGRATED_PROJECT_LOCAL_DEFAULTS.concurrency,
taskPollIntervalMs: MIGRATED_PROJECT_LOCAL_DEFAULTS.taskPollIntervalMs,
interactivePreviewMovements: MIGRATED_PROJECT_LOCAL_DEFAULTS.interactivePreviewMovements,
verbose: MIGRATED_PROJECT_LOCAL_DEFAULTS.verbose,
};
for (const key of MIGRATED_PROJECT_LOCAL_CONFIG_KEYS) {
const resolved = resolveConfigValueWithSource(projectDir, key);
expect(resolved.source).toBe('default');
expect(resolved.value).toEqual(expectedByKey[key as ConfigParameterKey]);
}
});
});
describe('autoFetch', () => {
it('should resolve autoFetch to false via schema default when not set', () => {
const value = resolveConfigValue(projectDir, 'autoFetch');

View File

@ -1,3 +1,28 @@
import { mkdtempSync, mkdirSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { afterEach, beforeEach } from 'vitest';
if (process.env.TAKT_TEST_FLG_TOUCH_TTY !== '1') {
process.env.TAKT_NO_TTY = '1';
}
let isolatedRootDir: string | undefined;
let previousTaktConfigDir: string | undefined;
beforeEach(() => {
previousTaktConfigDir = process.env.TAKT_CONFIG_DIR;
isolatedRootDir = mkdtempSync(join(tmpdir(), 'takt-test-global-'));
process.env.TAKT_CONFIG_DIR = join(isolatedRootDir, '.takt');
mkdirSync(process.env.TAKT_CONFIG_DIR, { recursive: true });
});
afterEach(() => {
if (previousTaktConfigDir === undefined) {
delete process.env.TAKT_CONFIG_DIR;
} else {
process.env.TAKT_CONFIG_DIR = previousTaktConfigDir;
}
if (isolatedRootDir) {
rmSync(isolatedRootDir, { recursive: true, force: true });
}
});

View File

@ -96,4 +96,19 @@ describe('watchTasks', () => {
expect(mockWatch).toHaveBeenCalledTimes(1);
expect(mockExecuteAndCompleteTask).toHaveBeenCalledTimes(1);
});
it('piece設定が未定義の場合はデフォルトpiece名を使う', async () => {
mockResolveConfigValue.mockReturnValue(undefined);
await watchTasks('/project');
expect(mockInfo).toHaveBeenCalledWith('Piece: default');
expect(mockExecuteAndCompleteTask).toHaveBeenCalledWith(
expect.any(Object),
expect.any(Object),
'/project',
'default',
undefined,
);
});
});

View File

@ -4,7 +4,13 @@
import { existsSync, readFileSync } from 'node:fs';
import { basename, dirname } from 'node:path';
import { loadCustomAgents, loadAgentPrompt, loadGlobalConfig, loadProjectConfig } from '../infra/config/index.js';
import {
loadCustomAgents,
loadAgentPrompt,
loadGlobalConfig,
loadProjectConfig,
resolveConfigValue,
} from '../infra/config/index.js';
import { getProvider, type ProviderType, type ProviderCallOptions } from '../infra/providers/index.js';
import type { AgentResponse, CustomAgentConfig } from '../core/models/index.js';
import { resolveAgentProviderModel } from '../core/piece/provider-resolution.js';
@ -36,10 +42,11 @@ export class AgentRunner {
} {
const localConfig = loadProjectConfig(cwd);
const globalConfig = loadGlobalConfig();
const personaProviders = resolveConfigValue(cwd, 'personaProviders');
const resolved = resolveAgentProviderModel({
cliProvider: options?.provider,
cliModel: options?.model,
personaProviders: globalConfig.personaProviders,
personaProviders,
personaDisplayName,
stepProvider: options?.stepProvider,
stepModel: options?.stepModel,

View File

@ -19,6 +19,7 @@ import { program, resolvedCwd, pipelineMode } from './program.js';
import { resolveAgentOverrides } from './helpers.js';
import { loadTaskHistory } from './taskHistory.js';
import { resolveIssueInput, resolvePrInput } from './routing-inputs.js';
import { DEFAULT_PIECE_NAME } from '../../shared/constants.js';
export async function executeDefaultAction(task?: string): Promise<void> {
const opts = program.opts();
if (!pipelineMode && (opts.autoPr === true || opts.draft === true)) {
@ -38,7 +39,9 @@ export async function executeDefaultAction(task?: string): Promise<void> {
process.exit(1);
}
const agentOverrides = resolveAgentOverrides(program);
const resolvedPipelinePiece = (opts.piece as string | undefined) ?? resolveConfigValue(resolvedCwd, 'piece');
const resolvedPipelinePiece = (opts.piece as string | undefined)
?? resolveConfigValue(resolvedCwd, 'piece')
?? DEFAULT_PIECE_NAME;
const resolvedPipelineAutoPr = opts.autoPr === true
? true
: (resolveConfigValue(resolvedCwd, 'autoPr') ?? false);

View File

@ -83,74 +83,94 @@ export interface NotificationSoundEventsConfig {
/** Persisted global configuration for ~/.takt/config.yaml */
export interface PersistedGlobalConfig {
/**
* /
* ProjectConfig
* @globalOnly
*/
/** @globalOnly */
language: Language;
logLevel: 'debug' | 'info' | 'warn' | 'error';
provider?: 'claude' | 'codex' | 'opencode' | 'cursor' | 'copilot' | 'mock';
model?: string;
/** Default piece name for new tasks (resolved via config layers: project > global > 'default') */
piece?: string;
/** @globalOnly */
observability?: ObservabilityConfig;
analytics?: AnalyticsConfig;
/** @globalOnly */
/** Directory for shared clones (worktree_dir in config). If empty, uses ../{clone-name} relative to project */
worktreeDir?: string;
/** Auto-create PR after worktree execution (default: prompt in interactive mode) */
autoPr?: boolean;
/** Create PR as draft (default: prompt in interactive mode when autoPr is true) */
draftPr?: boolean;
/** @globalOnly */
/** List of builtin piece/agent names to exclude from fallback loading */
disabledBuiltins?: string[];
/** @globalOnly */
/** Enable builtin pieces from builtins/{lang}/pieces */
enableBuiltinPieces?: boolean;
/** @globalOnly */
/** Anthropic API key for Claude Code SDK (overridden by TAKT_ANTHROPIC_API_KEY env var) */
anthropicApiKey?: string;
/** @globalOnly */
/** OpenAI API key for Codex SDK (overridden by TAKT_OPENAI_API_KEY env var) */
openaiApiKey?: string;
/** @globalOnly */
/** Gemini API key (overridden by TAKT_GEMINI_API_KEY env var) */
geminiApiKey?: string;
/** @globalOnly */
/** Google API key (overridden by TAKT_GOOGLE_API_KEY env var) */
googleApiKey?: string;
/** @globalOnly */
/** Groq API key (overridden by TAKT_GROQ_API_KEY env var) */
groqApiKey?: string;
/** @globalOnly */
/** OpenRouter API key (overridden by TAKT_OPENROUTER_API_KEY env var) */
openrouterApiKey?: string;
/** @globalOnly */
/** External Codex CLI path for Codex SDK override (overridden by TAKT_CODEX_CLI_PATH env var) */
codexCliPath?: string;
/** @globalOnly */
/** External Claude Code CLI path (overridden by TAKT_CLAUDE_CLI_PATH env var) */
claudeCliPath?: string;
/** @globalOnly */
/** External cursor-agent CLI path (overridden by TAKT_CURSOR_CLI_PATH env var) */
cursorCliPath?: string;
/** @globalOnly */
/** External Copilot CLI path (overridden by TAKT_COPILOT_CLI_PATH env var) */
copilotCliPath?: string;
/** @globalOnly */
/** Copilot GitHub token (overridden by TAKT_COPILOT_GITHUB_TOKEN env var) */
copilotGithubToken?: string;
/** @globalOnly */
/** OpenCode API key for OpenCode SDK (overridden by TAKT_OPENCODE_API_KEY env var) */
opencodeApiKey?: string;
/** @globalOnly */
/** Cursor API key for Cursor Agent CLI/API (overridden by TAKT_CURSOR_API_KEY env var) */
cursorApiKey?: string;
/** Pipeline execution settings */
pipeline?: PipelineConfig;
/** Minimal output mode for CI - suppress AI output to prevent sensitive information leaks */
minimalOutput?: boolean;
/** @globalOnly */
/** Path to bookmarks file (default: ~/.takt/preferences/bookmarks.yaml) */
bookmarksFile?: string;
/** @globalOnly */
/** Path to piece categories file (default: ~/.takt/preferences/piece-categories.yaml) */
pieceCategoriesFile?: string;
/** Per-persona provider and model overrides (e.g., { coder: { provider: 'codex', model: 'o3-mini' } }) */
personaProviders?: Record<string, PersonaProviderEntry>;
/** Global provider-specific options (lowest priority) */
providerOptions?: MovementProviderOptions;
/** Provider-specific permission profiles */
providerProfiles?: ProviderPermissionProfiles;
/** Global runtime environment defaults (can be overridden by piece runtime) */
runtime?: PieceRuntimeConfig;
/** Branch name generation strategy: 'romaji' (fast, default) or 'ai' (slow) */
branchNameStrategy?: 'romaji' | 'ai';
/** @globalOnly */
/** Prevent macOS idle sleep during takt execution using caffeinate (default: false) */
preventSleep?: boolean;
/** @globalOnly */
/** Enable notification sounds (default: true when undefined) */
notificationSound?: boolean;
/** @globalOnly */
/** Notification sound toggles per event timing */
notificationSoundEvents?: NotificationSoundEventsConfig;
/** Number of movement previews to inject into interactive mode (0 to disable, max 10) */
interactivePreviewMovements?: number;
/** Verbose output mode */
verbose: boolean;
/** Number of tasks to run concurrently in takt run (default: 1 = sequential) */
concurrency: number;
/** Polling interval in ms for picking up new tasks during takt run (default: 500, range: 100-5000) */
taskPollIntervalMs: number;
/** @globalOnly */
/** Opt-in: fetch remote before cloning to keep clones up-to-date (default: false) */
autoFetch: boolean;
/** Base branch to clone from (default: current branch) */
@ -162,13 +182,31 @@ export interface PersistedGlobalConfig {
/** Project-level configuration */
export interface ProjectConfig {
piece?: string;
verbose?: boolean;
provider?: 'claude' | 'codex' | 'opencode' | 'cursor' | 'copilot' | 'mock';
model?: string;
analytics?: AnalyticsConfig;
autoPr?: boolean;
draftPr?: boolean;
providerOptions?: MovementProviderOptions;
/** Provider-specific permission profiles */
providerProfiles?: ProviderPermissionProfiles;
/** Project log level */
logLevel?: 'debug' | 'info' | 'warn' | 'error';
/** Pipeline execution settings */
pipeline?: PipelineConfig;
/** Per-persona provider/model overrides */
personaProviders?: Record<string, PersonaProviderEntry>;
/** Branch name generation strategy */
branchNameStrategy?: 'romaji' | 'ai';
/** Minimal output mode */
minimalOutput?: boolean;
/** Number of tasks to run concurrently in takt run (1-10) */
concurrency?: number;
/** Polling interval in ms for task pickup */
taskPollIntervalMs?: number;
/** Number of movement previews in interactive mode */
interactivePreviewMovements?: number;
/** Base branch to clone from (overrides global baseBranch) */
baseBranch?: string;
/** Piece-level overrides (quality_gates, etc.) */

View File

@ -479,7 +479,7 @@ export const PipelineConfigSchema = z.object({
default_branch_prefix: z.string().optional(),
commit_message_template: z.string().optional(),
pr_body_template: z.string().optional(),
});
}).strict();
/** Piece category config schema (recursive) */
export type PieceCategoryConfigNode = {
@ -498,7 +498,6 @@ export const PieceCategoryConfigSchema = z.record(z.string(), PieceCategoryConfi
/** Global config schema */
export const GlobalConfigSchema = z.object({
language: LanguageSchema.optional().default(DEFAULT_LANGUAGE),
log_level: z.enum(['debug', 'info', 'warn', 'error']).optional().default('info'),
provider: ProviderReferenceSchema.optional().default('claude'),
model: z.string().optional(),
/** Default piece name for new tasks */
@ -519,6 +518,14 @@ export const GlobalConfigSchema = z.object({
anthropic_api_key: z.string().optional(),
/** OpenAI API key for Codex SDK (overridden by TAKT_OPENAI_API_KEY env var) */
openai_api_key: z.string().optional(),
/** Gemini API key (overridden by TAKT_GEMINI_API_KEY env var) */
gemini_api_key: z.string().optional(),
/** Google API key (overridden by TAKT_GOOGLE_API_KEY env var) */
google_api_key: z.string().optional(),
/** Groq API key (overridden by TAKT_GROQ_API_KEY env var) */
groq_api_key: z.string().optional(),
/** OpenRouter API key (overridden by TAKT_OPENROUTER_API_KEY env var) */
openrouter_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) */
@ -533,24 +540,16 @@ export const GlobalConfigSchema = z.object({
opencode_api_key: z.string().optional(),
/** Cursor API key for Cursor Agent CLI/API (overridden by TAKT_CURSOR_API_KEY env var) */
cursor_api_key: z.string().optional(),
/** Pipeline execution settings */
pipeline: PipelineConfigSchema.optional(),
/** Minimal output mode for CI - suppress AI output to prevent sensitive information leaks */
minimal_output: z.boolean().optional().default(false),
/** Path to bookmarks file (default: ~/.takt/preferences/bookmarks.yaml) */
bookmarks_file: z.string().optional(),
/** Path to piece categories file (default: ~/.takt/preferences/piece-categories.yaml) */
piece_categories_file: z.string().optional(),
/** Per-persona provider and model overrides. */
persona_providers: z.record(z.string(), PersonaProviderReferenceSchema).optional(),
/** Global provider-specific options (lowest priority) */
provider_options: MovementProviderOptionsSchema,
/** Provider-specific permission profiles */
provider_profiles: ProviderPermissionProfilesSchema,
/** Global runtime defaults (piece runtime overrides this) */
runtime: RuntimeConfigSchema,
/** Branch name generation strategy: 'romaji' (fast, default) or 'ai' (slow) */
branch_name_strategy: z.enum(['romaji', 'ai']).optional(),
/** Prevent macOS idle sleep during takt execution using caffeinate (default: false) */
prevent_sleep: z.boolean().optional(),
/** Enable notification sounds (default: true when undefined) */
@ -563,25 +562,18 @@ export const GlobalConfigSchema = z.object({
run_complete: z.boolean().optional(),
run_abort: z.boolean().optional(),
}).optional(),
/** Number of movement previews to inject into interactive mode (0 to disable, max 10) */
interactive_preview_movements: z.number().int().min(0).max(10).optional().default(3),
/** Verbose output mode */
verbose: z.boolean().optional().default(false),
/** Number of tasks to run concurrently in takt run (default: 1 = sequential, max: 10) */
concurrency: z.number().int().min(1).max(10).optional().default(1),
/** Polling interval in ms for picking up new tasks during takt run (default: 500, range: 100-5000) */
task_poll_interval_ms: z.number().int().min(100).max(5000).optional().default(500),
/** Opt-in: fetch remote before cloning to keep clones up-to-date (default: false) */
auto_fetch: z.boolean().optional().default(false),
/** Base branch to clone from (default: current branch) */
base_branch: z.string().optional(),
/** Piece-level overrides (quality_gates, etc.) */
piece_overrides: PieceOverridesSchema,
});
}).strict();
/** Project config schema */
export const ProjectConfigSchema = z.object({
piece: z.string().optional(),
log_level: z.enum(['debug', 'info', 'warn', 'error']).optional(),
verbose: z.boolean().optional(),
provider: ProviderReferenceSchema.optional(),
model: z.string().optional(),
@ -590,10 +582,18 @@ export const ProjectConfigSchema = z.object({
auto_pr: z.boolean().optional(),
/** Create PR as draft (project override) */
draft_pr: z.boolean().optional(),
pipeline: PipelineConfigSchema.optional(),
persona_providers: z.record(z.string(), PersonaProviderReferenceSchema).optional(),
branch_name_strategy: z.enum(['romaji', 'ai']).optional(),
minimal_output: z.boolean().optional(),
provider_options: MovementProviderOptionsSchema,
provider_profiles: ProviderPermissionProfilesSchema,
/** Number of tasks to run concurrently in takt run (default from global: 1, max: 10) */
concurrency: z.number().int().min(1).max(10).optional(),
/** Polling interval in ms for picking up new tasks during takt run (default: 500, range: 100-5000) */
task_poll_interval_ms: z.number().int().min(100).max(5000).optional(),
/** Number of movement previews to inject into interactive mode (0 to disable, max 10) */
interactive_preview_movements: z.number().int().min(0).max(10).optional(),
/** Base branch to clone from (overrides global base_branch) */
base_branch: z.string().optional(),
/** Piece-level overrides (quality_gates, etc.) */
@ -609,12 +609,4 @@ 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(),
/** Copilot CLI path override (project-level) */
copilot_cli_path: z.string().optional(),
});
}).strict();

View File

@ -505,7 +505,7 @@ export async function selectPiece(
): Promise<string | null> {
const fallbackToDefault = options?.fallbackToDefault !== false;
const categoryConfig = getPieceCategories(cwd);
const currentPiece = resolveConfigValue(cwd, 'piece');
const currentPiece = resolveConfigValue(cwd, 'piece') ?? DEFAULT_PIECE_NAME;
if (categoryConfig) {
const allPieces = loadAllPiecesWithSources(cwd);

View File

@ -13,6 +13,7 @@ import { needsStatusJudgmentPhase } from '../../core/piece/index.js';
import type { InstructionContext } from '../../core/piece/instruction/instruction-context.js';
import type { Language } from '../../core/models/types.js';
import { header, info, error, blankLine } from '../../shared/ui/index.js';
import { DEFAULT_PIECE_NAME } from '../../shared/constants.js';
/**
* Preview all prompts for a piece.
@ -21,7 +22,7 @@ import { header, info, error, blankLine } from '../../shared/ui/index.js';
* the Phase 1, Phase 2, and Phase 3 prompts with sample variable values.
*/
export async function previewPrompts(cwd: string, pieceIdentifier?: string): Promise<void> {
const identifier = pieceIdentifier ?? resolvePieceConfigValue(cwd, 'piece');
const identifier = pieceIdentifier ?? resolvePieceConfigValue(cwd, 'piece') ?? DEFAULT_PIECE_NAME;
const config = loadPieceByIdentifier(identifier, cwd);
if (!config) {

View File

@ -18,13 +18,14 @@ import { executeAndCompleteTask } from '../execute/taskExecution.js';
import { EXIT_SIGINT } from '../../../shared/exitCodes.js';
import { ShutdownManager } from '../execute/shutdownManager.js';
import type { TaskExecutionOptions } from '../execute/types.js';
import { DEFAULT_PIECE_NAME } from '../../../shared/constants.js';
/**
* Watch for tasks and execute them as they appear.
* Runs until Ctrl+C.
*/
export async function watchTasks(cwd: string, options?: TaskExecutionOptions): Promise<void> {
const pieceName = resolveConfigValue(cwd, 'piece');
const pieceName = resolveConfigValue(cwd, 'piece') ?? DEFAULT_PIECE_NAME;
const taskRunner = new TaskRunner(cwd);
const watcher = new TaskWatcher(cwd);
const recovered = taskRunner.recoverInterruptedRunningTasks();

View File

@ -4,8 +4,10 @@
* Used by both globalConfig.ts and projectConfig.ts.
*/
import type { MovementProviderOptions } from '../../core/models/piece-types.js';
import type { ProviderPermissionProfiles } from '../../core/models/provider-profiles.js';
import type { PieceOverrides } from '../../core/models/persisted-global-config.js';
import type { PieceOverrides, PersonaProviderEntry, PipelineConfig } from '../../core/models/persisted-global-config.js';
import { validateProviderModelCompatibility } from './providerModelCompatibility.js';
export function normalizeProviderProfiles(
raw: Record<string, { default_permission_mode: unknown; movement_permission_overrides?: Record<string, unknown> }> | undefined,
@ -77,3 +79,78 @@ export function denormalizePieceOverrides(
}
return Object.keys(result).length > 0 ? result : undefined;
}
export function normalizePersonaProviders(
raw: Record<string, string | { type?: string; provider?: string; model?: string }> | undefined,
): Record<string, PersonaProviderEntry> | undefined {
if (!raw) return undefined;
const entries = Object.entries(raw);
if (entries.length === 0) return undefined;
return Object.fromEntries(entries.map(([persona, entry]) => {
const normalizedEntry: PersonaProviderEntry = typeof entry === 'string'
? { provider: entry as PersonaProviderEntry['provider'] }
: {
...(entry.provider !== undefined || entry.type !== undefined
? { provider: (entry.provider ?? entry.type) as PersonaProviderEntry['provider'] }
: {}),
...(entry.model !== undefined ? { model: entry.model } : {}),
};
validateProviderModelCompatibility(
normalizedEntry.provider,
normalizedEntry.model,
{
modelFieldName: `Configuration error: persona_providers.${persona}.model`,
requireProviderQualifiedModelForOpencode: false,
},
);
return [persona, normalizedEntry];
}));
}
export function normalizePipelineConfig(raw: {
default_branch_prefix?: string;
commit_message_template?: string;
pr_body_template?: string;
} | undefined): PipelineConfig | undefined {
if (!raw) return undefined;
const { default_branch_prefix, commit_message_template, pr_body_template } = raw;
if (default_branch_prefix === undefined && commit_message_template === undefined && pr_body_template === undefined) {
return undefined;
}
return {
defaultBranchPrefix: default_branch_prefix,
commitMessageTemplate: commit_message_template,
prBodyTemplate: pr_body_template,
};
}
export function denormalizeProviderOptions(
providerOptions: MovementProviderOptions | undefined,
): Record<string, unknown> | undefined {
if (!providerOptions) {
return undefined;
}
const raw: Record<string, unknown> = {};
if (providerOptions.codex?.networkAccess !== undefined) {
raw.codex = { network_access: providerOptions.codex.networkAccess };
}
if (providerOptions.opencode?.networkAccess !== undefined) {
raw.opencode = { network_access: providerOptions.opencode.networkAccess };
}
if (providerOptions.claude?.sandbox) {
const sandbox: Record<string, unknown> = {};
if (providerOptions.claude.sandbox.allowUnsandboxedCommands !== undefined) {
sandbox.allow_unsandboxed_commands = providerOptions.claude.sandbox.allowUnsandboxedCommands;
}
if (providerOptions.claude.sandbox.excludedCommands !== undefined) {
sandbox.excluded_commands = providerOptions.claude.sandbox.excludedCommands;
}
if (Object.keys(sandbox).length > 0) {
raw.claude = { sandbox };
}
}
return Object.keys(raw).length > 0 ? raw : undefined;
}

View File

@ -77,7 +77,6 @@ function applyEnvOverrides(target: Record<string, unknown>, specs: readonly EnvS
const GLOBAL_ENV_SPECS: readonly EnvSpec[] = [
{ path: 'language', type: 'string' },
{ path: 'log_level', type: 'string' },
{ path: 'provider', type: 'string' },
{ path: 'model', type: 'string' },
{ path: 'observability', type: 'json' },
@ -93,6 +92,10 @@ const GLOBAL_ENV_SPECS: readonly EnvSpec[] = [
{ path: 'enable_builtin_pieces', type: 'boolean' },
{ path: 'anthropic_api_key', type: 'string' },
{ path: 'openai_api_key', type: 'string' },
{ path: 'gemini_api_key', type: 'string' },
{ path: 'google_api_key', type: 'string' },
{ path: 'groq_api_key', type: 'string' },
{ path: 'openrouter_api_key', type: 'string' },
{ path: 'codex_cli_path', type: 'string' },
{ path: 'claude_cli_path', type: 'string' },
{ path: 'cursor_cli_path', type: 'string' },
@ -100,14 +103,8 @@ const GLOBAL_ENV_SPECS: readonly EnvSpec[] = [
{ path: 'copilot_github_token', type: 'string' },
{ path: 'opencode_api_key', type: 'string' },
{ path: 'cursor_api_key', type: 'string' },
{ path: 'pipeline', type: 'json' },
{ path: 'pipeline.default_branch_prefix', type: 'string' },
{ path: 'pipeline.commit_message_template', type: 'string' },
{ path: 'pipeline.pr_body_template', type: 'string' },
{ path: 'minimal_output', type: 'boolean' },
{ path: 'bookmarks_file', type: 'string' },
{ path: 'piece_categories_file', type: 'string' },
{ path: 'persona_providers', type: 'json' },
{ path: 'provider_options', type: 'json' },
{ path: 'provider_options.codex.network_access', type: 'boolean' },
{ path: 'provider_options.opencode.network_access', type: 'boolean' },
@ -116,7 +113,6 @@ const GLOBAL_ENV_SPECS: readonly EnvSpec[] = [
{ path: 'provider_profiles', type: 'json' },
{ path: 'runtime', type: 'json' },
{ path: 'runtime.prepare', type: 'json' },
{ path: 'branch_name_strategy', type: 'string' },
{ path: 'prevent_sleep', type: 'boolean' },
{ path: 'notification_sound', type: 'boolean' },
{ path: 'notification_sound_events', type: 'json' },
@ -125,19 +121,26 @@ const GLOBAL_ENV_SPECS: readonly EnvSpec[] = [
{ path: 'notification_sound_events.piece_abort', type: 'boolean' },
{ path: 'notification_sound_events.run_complete', type: 'boolean' },
{ path: 'notification_sound_events.run_abort', type: 'boolean' },
{ path: 'interactive_preview_movements', type: 'number' },
{ path: 'verbose', type: 'boolean' },
{ path: 'concurrency', type: 'number' },
{ path: 'task_poll_interval_ms', type: 'number' },
{ path: 'auto_fetch', type: 'boolean' },
{ path: 'base_branch', type: 'string' },
];
const PROJECT_ENV_SPECS: readonly EnvSpec[] = [
{ path: 'piece', type: 'string' },
{ path: 'log_level', type: 'string' },
{ path: 'provider', type: 'string' },
{ path: 'model', type: 'string' },
{ path: 'verbose', type: 'boolean' },
{ path: 'concurrency', type: 'number' },
{ path: 'pipeline', type: 'json' },
{ path: 'pipeline.default_branch_prefix', type: 'string' },
{ path: 'pipeline.commit_message_template', type: 'string' },
{ path: 'pipeline.pr_body_template', type: 'string' },
{ path: 'persona_providers', type: 'json' },
{ path: 'branch_name_strategy', type: 'string' },
{ path: 'minimal_output', type: 'boolean' },
{ path: 'task_poll_interval_ms', type: 'number' },
{ path: 'interactive_preview_movements', type: 'number' },
{ path: 'analytics', type: 'json' },
{ path: 'analytics.enabled', type: 'boolean' },
{ path: 'analytics.events_path', type: 'string' },
@ -149,10 +152,6 @@ 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' },
{ path: 'copilot_cli_path', type: 'string' },
];
export function applyGlobalConfigEnvOverrides(target: Record<string, unknown>): void {

View File

@ -0,0 +1,38 @@
import { accessSync, constants, existsSync, statSync } from 'node:fs';
import { isAbsolute } from 'node:path';
function hasControlCharacters(value: string): boolean {
for (let index = 0; index < value.length; index++) {
const code = value.charCodeAt(index);
if (code < 32 || code === 127) {
return true;
}
}
return false;
}
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.`);
}
if (hasControlCharacters(trimmed)) {
throw new Error(`Configuration error: ${sourceName} contains control characters.`);
}
if (!isAbsolute(trimmed)) {
throw new Error(`Configuration error: ${sourceName} must be an absolute path: ${trimmed}`);
}
if (!existsSync(trimmed)) {
throw new Error(`Configuration error: ${sourceName} path does not exist: ${trimmed}`);
}
const stats = statSync(trimmed);
if (!stats.isFile()) {
throw new Error(`Configuration error: ${sourceName} must point to an executable file: ${trimmed}`);
}
try {
accessSync(trimmed, constants.X_OK);
} catch {
throw new Error(`Configuration error: ${sourceName} file is not executable: ${trimmed}`);
}
return trimmed;
}

View File

@ -1,624 +1,48 @@
/**
* Global configuration loader
*
* Manages ~/.takt/config.yaml.
* GlobalConfigManager encapsulates the config cache as a singleton.
* Global configuration public API.
* Keep this file as a stable facade and delegate implementations to focused modules.
* Global-only field ownership is defined in PersistedGlobalConfig via `@globalOnly` markers.
*/
import { readFileSync, existsSync, writeFileSync, statSync, accessSync, constants } from 'node:fs';
import { isAbsolute } from 'node:path';
import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
import { GlobalConfigSchema } from '../../../core/models/index.js';
import type { Language } from '../../../core/models/index.js';
import type { PersistedGlobalConfig, PersonaProviderEntry } from '../../../core/models/persisted-global-config.js';
import {
normalizeConfigProviderReference,
type ConfigProviderReference,
} from '../providerReference.js';
import {
normalizeProviderProfiles,
denormalizeProviderProfiles,
normalizePieceOverrides,
denormalizePieceOverrides,
} from '../configNormalizers.js';
import { getGlobalConfigPath } from '../paths.js';
import { DEFAULT_LANGUAGE } from '../../../shared/constants.js';
import { parseProviderModel } from '../../../shared/utils/providerModel.js';
import { applyGlobalConfigEnvOverrides, envVarNameFromPath } from '../env/config-env-overrides.js';
import { invalidateAllResolvedConfigCache } from '../resolutionCache.js';
import type { PersistedGlobalConfig } from '../../../core/models/persisted-global-config.js';
import type { MigratedProjectLocalConfigKey } from '../migratedProjectLocalKeys.js';
type ProviderType = NonNullable<PersistedGlobalConfig['provider']>;
type RawPersonaProviderBlock = {
type: ProviderType;
model?: string;
};
type RawProviderReference = ConfigProviderReference<ProviderType>;
/** Claude-specific model aliases that are not valid for other providers */
const CLAUDE_MODEL_ALIASES = new Set(['opus', 'sonnet', 'haiku']);
function hasControlCharacters(value: string): boolean {
for (let index = 0; index < value.length; index++) {
const code = value.charCodeAt(index);
if (code < 32 || code === 127) {
return true;
}
}
return false;
}
/** 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.`);
}
if (hasControlCharacters(trimmed)) {
throw new Error(`Configuration error: ${sourceName} contains control characters.`);
}
if (!isAbsolute(trimmed)) {
throw new Error(`Configuration error: ${sourceName} must be an absolute path: ${trimmed}`);
}
if (!existsSync(trimmed)) {
throw new Error(`Configuration error: ${sourceName} path does not exist: ${trimmed}`);
}
const stats = statSync(trimmed);
if (!stats.isFile()) {
throw new Error(`Configuration error: ${sourceName} must point to an executable file: ${trimmed}`);
}
try {
accessSync(trimmed, constants.X_OK);
} catch {
throw new Error(`Configuration error: ${sourceName} file is not executable: ${trimmed}`);
}
return trimmed;
}
function validateProviderModelCompatibility(provider: string | undefined, model: string | undefined): void {
if (!provider) return;
if (provider === 'opencode' && !model) {
throw new Error(
"Configuration error: provider 'opencode' requires model in 'provider/model' format (e.g. 'opencode/big-pickle')."
);
}
if (!model) return;
if ((provider === 'codex' || provider === 'opencode') && CLAUDE_MODEL_ALIASES.has(model)) {
throw new Error(
`Configuration error: model '${model}' is a Claude model alias but provider is '${provider}'. ` +
`Either change the provider to 'claude' or specify a ${provider}-compatible model.`
);
}
if (provider === 'opencode') {
parseProviderModel(model, "Configuration error: model");
}
}
function normalizePersonaProviders(
raw: Record<string, ProviderType | PersonaProviderEntry | RawPersonaProviderBlock> | undefined,
): Record<string, PersonaProviderEntry> | undefined {
if (!raw) return undefined;
return Object.fromEntries(
Object.entries(raw).map(([persona, entry]) => {
let normalized: PersonaProviderEntry;
if (typeof entry === 'string') {
normalized = { provider: entry };
} else if ('type' in entry) {
normalized = { provider: entry.type, model: entry.model };
} else {
normalized = entry;
}
validateProviderModelCompatibility(normalized.provider, normalized.model);
return [persona, normalized];
}),
);
}
type Assert<T extends true> = T;
type IsNever<T> = [T] extends [never] ? true : false;
/**
* Manages global configuration loading and caching.
* Singleton use GlobalConfigManager.getInstance().
* Compile-time guard:
* migrated project-local fields must not exist on PersistedGlobalConfig.
*/
export class GlobalConfigManager {
private static instance: GlobalConfigManager | null = null;
private cachedConfig: PersistedGlobalConfig | null = null;
const globalConfigMigratedFieldGuard: Assert<
IsNever<Extract<keyof PersistedGlobalConfig, MigratedProjectLocalConfigKey>>
> = true;
void globalConfigMigratedFieldGuard;
private constructor() {}
export {
invalidateGlobalConfigCache,
loadGlobalConfig,
loadGlobalMigratedProjectLocalFallback,
saveGlobalConfig,
validateCliPath,
} from './globalConfigCore.js';
static getInstance(): GlobalConfigManager {
if (!GlobalConfigManager.instance) {
GlobalConfigManager.instance = new GlobalConfigManager();
}
return GlobalConfigManager.instance;
}
export {
getDisabledBuiltins,
getBuiltinPiecesEnabled,
getLanguage,
setLanguage,
setProvider,
} from './globalConfigAccessors.js';
/** Reset singleton for testing */
static resetInstance(): void {
GlobalConfigManager.instance = null;
}
/** Invalidate the cached configuration */
invalidateCache(): void {
this.cachedConfig = null;
}
/** Load global configuration (cached) */
load(): PersistedGlobalConfig {
if (this.cachedConfig !== null) {
return this.cachedConfig;
}
const configPath = getGlobalConfigPath();
const rawConfig: Record<string, unknown> = {};
if (existsSync(configPath)) {
const content = readFileSync(configPath, 'utf-8');
const parsedRaw = parseYaml(content);
if (parsedRaw && typeof parsedRaw === 'object' && !Array.isArray(parsedRaw)) {
Object.assign(rawConfig, parsedRaw as Record<string, unknown>);
} else if (parsedRaw != null) {
throw new Error('Configuration error: ~/.takt/config.yaml must be a YAML object.');
}
}
applyGlobalConfigEnvOverrides(rawConfig);
const parsed = GlobalConfigSchema.parse(rawConfig);
const normalizedProvider = normalizeConfigProviderReference(
parsed.provider as RawProviderReference,
parsed.model,
parsed.provider_options as Record<string, unknown> | undefined,
);
const config: PersistedGlobalConfig = {
language: parsed.language,
logLevel: parsed.log_level,
provider: normalizedProvider.provider,
model: normalizedProvider.model,
piece: parsed.piece,
observability: parsed.observability ? {
providerEvents: parsed.observability.provider_events,
} : undefined,
analytics: parsed.analytics ? {
enabled: parsed.analytics.enabled,
eventsPath: parsed.analytics.events_path,
retentionDays: parsed.analytics.retention_days,
} : undefined,
worktreeDir: parsed.worktree_dir,
autoPr: parsed.auto_pr,
draftPr: parsed.draft_pr,
disabledBuiltins: parsed.disabled_builtins,
enableBuiltinPieces: parsed.enable_builtin_pieces,
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 ? {
defaultBranchPrefix: parsed.pipeline.default_branch_prefix,
commitMessageTemplate: parsed.pipeline.commit_message_template,
prBodyTemplate: parsed.pipeline.pr_body_template,
} : undefined,
minimalOutput: parsed.minimal_output,
bookmarksFile: parsed.bookmarks_file,
pieceCategoriesFile: parsed.piece_categories_file,
personaProviders: normalizePersonaProviders(
parsed.persona_providers as Record<string, ProviderType | PersonaProviderEntry | RawPersonaProviderBlock> | undefined,
),
providerOptions: normalizedProvider.providerOptions,
providerProfiles: normalizeProviderProfiles(parsed.provider_profiles as Record<string, { default_permission_mode: unknown; movement_permission_overrides?: Record<string, unknown> }> | undefined),
runtime: parsed.runtime?.prepare && parsed.runtime.prepare.length > 0
? { prepare: [...new Set(parsed.runtime.prepare)] }
: undefined,
branchNameStrategy: parsed.branch_name_strategy,
preventSleep: parsed.prevent_sleep,
notificationSound: parsed.notification_sound,
notificationSoundEvents: parsed.notification_sound_events ? {
iterationLimit: parsed.notification_sound_events.iteration_limit,
pieceComplete: parsed.notification_sound_events.piece_complete,
pieceAbort: parsed.notification_sound_events.piece_abort,
runComplete: parsed.notification_sound_events.run_complete,
runAbort: parsed.notification_sound_events.run_abort,
} : undefined,
interactivePreviewMovements: parsed.interactive_preview_movements,
verbose: parsed.verbose,
concurrency: parsed.concurrency,
taskPollIntervalMs: parsed.task_poll_interval_ms,
autoFetch: parsed.auto_fetch,
baseBranch: parsed.base_branch,
pieceOverrides: normalizePieceOverrides(parsed.piece_overrides as { quality_gates?: string[]; quality_gates_edit_only?: boolean; movements?: Record<string, { quality_gates?: string[] }> } | undefined),
};
validateProviderModelCompatibility(config.provider, config.model);
this.cachedConfig = config;
return config;
}
/** Save global configuration to disk and invalidate cache */
save(config: PersistedGlobalConfig): void {
const configPath = getGlobalConfigPath();
const raw: Record<string, unknown> = {
language: config.language,
log_level: config.logLevel,
provider: config.provider,
};
if (config.model) {
raw.model = config.model;
}
if (config.piece) {
raw.piece = config.piece;
}
if (config.observability && config.observability.providerEvents !== undefined) {
raw.observability = {
provider_events: config.observability.providerEvents,
};
}
if (config.analytics) {
const analyticsRaw: Record<string, unknown> = {};
if (config.analytics.enabled !== undefined) analyticsRaw.enabled = config.analytics.enabled;
if (config.analytics.eventsPath) analyticsRaw.events_path = config.analytics.eventsPath;
if (config.analytics.retentionDays !== undefined) analyticsRaw.retention_days = config.analytics.retentionDays;
if (Object.keys(analyticsRaw).length > 0) {
raw.analytics = analyticsRaw;
}
}
if (config.worktreeDir) {
raw.worktree_dir = config.worktreeDir;
}
if (config.autoPr !== undefined) {
raw.auto_pr = config.autoPr;
}
if (config.draftPr !== undefined) {
raw.draft_pr = config.draftPr;
}
if (config.disabledBuiltins && config.disabledBuiltins.length > 0) {
raw.disabled_builtins = config.disabledBuiltins;
}
if (config.enableBuiltinPieces !== undefined) {
raw.enable_builtin_pieces = config.enableBuiltinPieces;
}
if (config.anthropicApiKey) {
raw.anthropic_api_key = config.anthropicApiKey;
}
if (config.openaiApiKey) {
raw.openai_api_key = config.openaiApiKey;
}
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;
}
if (config.cursorApiKey) {
raw.cursor_api_key = config.cursorApiKey;
}
if (config.pipeline) {
const pipelineRaw: Record<string, unknown> = {};
if (config.pipeline.defaultBranchPrefix) pipelineRaw.default_branch_prefix = config.pipeline.defaultBranchPrefix;
if (config.pipeline.commitMessageTemplate) pipelineRaw.commit_message_template = config.pipeline.commitMessageTemplate;
if (config.pipeline.prBodyTemplate) pipelineRaw.pr_body_template = config.pipeline.prBodyTemplate;
if (Object.keys(pipelineRaw).length > 0) {
raw.pipeline = pipelineRaw;
}
}
if (config.minimalOutput !== undefined) {
raw.minimal_output = config.minimalOutput;
}
if (config.bookmarksFile) {
raw.bookmarks_file = config.bookmarksFile;
}
if (config.pieceCategoriesFile) {
raw.piece_categories_file = config.pieceCategoriesFile;
}
if (config.personaProviders && Object.keys(config.personaProviders).length > 0) {
raw.persona_providers = config.personaProviders;
}
const rawProviderProfiles = denormalizeProviderProfiles(config.providerProfiles);
if (rawProviderProfiles && Object.keys(rawProviderProfiles).length > 0) {
raw.provider_profiles = rawProviderProfiles;
}
if (config.runtime?.prepare && config.runtime.prepare.length > 0) {
raw.runtime = {
prepare: [...new Set(config.runtime.prepare)],
};
}
if (config.branchNameStrategy) {
raw.branch_name_strategy = config.branchNameStrategy;
}
if (config.preventSleep !== undefined) {
raw.prevent_sleep = config.preventSleep;
}
if (config.notificationSound !== undefined) {
raw.notification_sound = config.notificationSound;
}
if (config.notificationSoundEvents) {
const eventRaw: Record<string, unknown> = {};
if (config.notificationSoundEvents.iterationLimit !== undefined) {
eventRaw.iteration_limit = config.notificationSoundEvents.iterationLimit;
}
if (config.notificationSoundEvents.pieceComplete !== undefined) {
eventRaw.piece_complete = config.notificationSoundEvents.pieceComplete;
}
if (config.notificationSoundEvents.pieceAbort !== undefined) {
eventRaw.piece_abort = config.notificationSoundEvents.pieceAbort;
}
if (config.notificationSoundEvents.runComplete !== undefined) {
eventRaw.run_complete = config.notificationSoundEvents.runComplete;
}
if (config.notificationSoundEvents.runAbort !== undefined) {
eventRaw.run_abort = config.notificationSoundEvents.runAbort;
}
if (Object.keys(eventRaw).length > 0) {
raw.notification_sound_events = eventRaw;
}
}
if (config.interactivePreviewMovements !== undefined) {
raw.interactive_preview_movements = config.interactivePreviewMovements;
}
if (config.verbose) {
raw.verbose = config.verbose;
}
if (config.concurrency !== undefined && config.concurrency > 1) {
raw.concurrency = config.concurrency;
}
if (config.taskPollIntervalMs !== undefined && config.taskPollIntervalMs !== 500) {
raw.task_poll_interval_ms = config.taskPollIntervalMs;
}
if (config.autoFetch) {
raw.auto_fetch = config.autoFetch;
}
if (config.baseBranch) {
raw.base_branch = config.baseBranch;
}
const denormalizedPieceOverrides = denormalizePieceOverrides(config.pieceOverrides);
if (denormalizedPieceOverrides) {
raw.piece_overrides = denormalizedPieceOverrides;
}
writeFileSync(configPath, stringifyYaml(raw), 'utf-8');
this.invalidateCache();
invalidateAllResolvedConfigCache();
}
}
export function invalidateGlobalConfigCache(): void {
GlobalConfigManager.getInstance().invalidateCache();
invalidateAllResolvedConfigCache();
}
export function loadGlobalConfig(): PersistedGlobalConfig {
return GlobalConfigManager.getInstance().load();
}
export function saveGlobalConfig(config: PersistedGlobalConfig): void {
GlobalConfigManager.getInstance().save(config);
}
export function getDisabledBuiltins(): string[] {
try {
const config = loadGlobalConfig();
return config.disabledBuiltins ?? [];
} catch {
return [];
}
}
export function getBuiltinPiecesEnabled(): boolean {
try {
const config = loadGlobalConfig();
return config.enableBuiltinPieces !== false;
} catch {
return true;
}
}
export function getLanguage(): Language {
try {
const config = loadGlobalConfig();
return config.language;
} catch {
return DEFAULT_LANGUAGE;
}
}
export function setLanguage(language: Language): void {
const config = loadGlobalConfig();
config.language = language;
saveGlobalConfig(config);
}
export function setProvider(provider: 'claude' | 'codex' | 'opencode' | 'cursor' | 'copilot'): void {
const config = loadGlobalConfig();
config.provider = provider;
saveGlobalConfig(config);
}
/**
* Resolve the Anthropic API key.
* Priority: TAKT_ANTHROPIC_API_KEY env var > config.yaml > undefined (CLI auth fallback)
*/
export function resolveAnthropicApiKey(): string | undefined {
const envKey = process.env[envVarNameFromPath('anthropic_api_key')];
if (envKey) return envKey;
try {
const config = loadGlobalConfig();
return config.anthropicApiKey;
} catch {
return undefined;
}
}
/**
* Resolve the OpenAI API key.
* Priority: TAKT_OPENAI_API_KEY env var > config.yaml > undefined (CLI auth fallback)
*/
export function resolveOpenaiApiKey(): string | undefined {
const envKey = process.env[envVarNameFromPath('openai_api_key')];
if (envKey) return envKey;
try {
const config = loadGlobalConfig();
return config.openaiApiKey;
} catch {
return undefined;
}
}
/**
* Resolve the Codex CLI path override.
* Priority: TAKT_CODEX_CLI_PATH env var > config.yaml > undefined (SDK vendored binary fallback)
*/
export function resolveCodexCliPath(projectConfig?: { codexCliPath?: string }): string | undefined {
const envPath = process.env[envVarNameFromPath('codex_cli_path')];
if (envPath !== undefined) {
return validateCliPath(envPath, 'TAKT_CODEX_CLI_PATH');
}
if (projectConfig?.codexCliPath !== undefined) {
return validateCliPath(projectConfig.codexCliPath, 'codex_cli_path (project)');
}
let config: PersistedGlobalConfig;
try {
config = loadGlobalConfig();
} catch {
return undefined;
}
if (config.codexCliPath === undefined) {
return undefined;
}
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');
}
/**
* Resolve the OpenCode API key.
* Priority: TAKT_OPENCODE_API_KEY env var > config.yaml > undefined
*/
export function resolveOpencodeApiKey(): string | undefined {
const envKey = process.env[envVarNameFromPath('opencode_api_key')];
if (envKey) return envKey;
try {
const config = loadGlobalConfig();
return config.opencodeApiKey;
} catch {
return undefined;
}
}
/**
* Resolve the Cursor API key.
* Priority: TAKT_CURSOR_API_KEY env var > config.yaml > undefined (cursor-agent login fallback)
*/
export function resolveCursorApiKey(): string | undefined {
const envKey = process.env[envVarNameFromPath('cursor_api_key')];
if (envKey) return envKey;
try {
const config = loadGlobalConfig();
return config.cursorApiKey;
} catch {
return undefined;
}
}
/**
* Resolve the Copilot CLI path override.
* Priority: TAKT_COPILOT_CLI_PATH env var > project config > global config > undefined (default 'copilot')
*/
export function resolveCopilotCliPath(projectConfig?: { copilotCliPath?: string }): string | undefined {
const envPath = process.env[envVarNameFromPath('copilot_cli_path')];
if (envPath !== undefined) {
return validateCliPath(envPath, 'TAKT_COPILOT_CLI_PATH');
}
if (projectConfig?.copilotCliPath !== undefined) {
return validateCliPath(projectConfig.copilotCliPath, 'copilot_cli_path (project)');
}
let config: PersistedGlobalConfig;
try {
config = loadGlobalConfig();
} catch {
return undefined;
}
if (config.copilotCliPath === undefined) {
return undefined;
}
return validateCliPath(config.copilotCliPath, 'copilot_cli_path');
}
/**
* Resolve the Copilot GitHub token.
* Priority: TAKT_COPILOT_GITHUB_TOKEN env var > config.yaml > undefined
*/
export function resolveCopilotGithubToken(): string | undefined {
const envKey = process.env[envVarNameFromPath('copilot_github_token')];
if (envKey) return envKey;
try {
const config = loadGlobalConfig();
return config.copilotGithubToken;
} catch {
return undefined;
}
}
export {
resolveAnthropicApiKey,
resolveOpenaiApiKey,
resolveCodexCliPath,
resolveClaudeCliPath,
resolveCursorCliPath,
resolveCopilotCliPath,
resolveCopilotGithubToken,
resolveOpencodeApiKey,
resolveCursorApiKey,
} from './globalConfigResolvers.js';

View File

@ -0,0 +1,30 @@
import type { Language } from '../../../core/models/index.js';
import { DEFAULT_LANGUAGE } from '../../../shared/constants.js';
import { loadGlobalConfig, saveGlobalConfig } from './globalConfigCore.js';
export function getDisabledBuiltins(): string[] {
const config = loadGlobalConfig();
return config.disabledBuiltins ?? [];
}
export function getBuiltinPiecesEnabled(): boolean {
const config = loadGlobalConfig();
return config.enableBuiltinPieces !== false;
}
export function getLanguage(): Language {
const config = loadGlobalConfig();
return config.language ?? DEFAULT_LANGUAGE;
}
export function setLanguage(language: Language): void {
const config = loadGlobalConfig();
config.language = language;
saveGlobalConfig(config);
}
export function setProvider(provider: 'claude' | 'codex' | 'opencode' | 'cursor' | 'copilot'): void {
const config = loadGlobalConfig();
config.provider = provider;
saveGlobalConfig(config);
}

View File

@ -0,0 +1,300 @@
import { readFileSync, existsSync, writeFileSync } from 'node:fs';
import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
import { GlobalConfigSchema } from '../../../core/models/index.js';
import type { PersistedGlobalConfig } from '../../../core/models/persisted-global-config.js';
import {
normalizeConfigProviderReference,
type ConfigProviderReference,
} from '../providerReference.js';
import {
normalizeProviderProfiles,
denormalizeProviderProfiles,
normalizePieceOverrides,
denormalizePieceOverrides,
denormalizeProviderOptions,
} from '../configNormalizers.js';
import { getGlobalConfigPath } from '../paths.js';
import { applyGlobalConfigEnvOverrides } from '../env/config-env-overrides.js';
import { invalidateAllResolvedConfigCache } from '../resolutionCache.js';
import { validateProviderModelCompatibility } from '../providerModelCompatibility.js';
import {
extractMigratedProjectLocalFallback,
removeMigratedProjectLocalKeys,
type GlobalMigratedProjectLocalFallback,
} from './globalMigratedProjectLocalFallback.js';
export { validateCliPath } from './cliPathValidator.js';
type ProviderType = NonNullable<PersistedGlobalConfig['provider']>;
type RawProviderReference = ConfigProviderReference<ProviderType>;
export class GlobalConfigManager {
private static instance: GlobalConfigManager | null = null;
private cachedConfig: PersistedGlobalConfig | null = null;
private cachedMigratedProjectLocalFallback: GlobalMigratedProjectLocalFallback | null = null;
private constructor() {}
static getInstance(): GlobalConfigManager {
if (!GlobalConfigManager.instance) {
GlobalConfigManager.instance = new GlobalConfigManager();
}
return GlobalConfigManager.instance;
}
static resetInstance(): void {
GlobalConfigManager.instance = null;
}
invalidateCache(): void {
this.cachedConfig = null;
this.cachedMigratedProjectLocalFallback = null;
}
load(): PersistedGlobalConfig {
if (this.cachedConfig !== null) {
return this.cachedConfig;
}
const configPath = getGlobalConfigPath();
const rawConfig: Record<string, unknown> = {};
if (existsSync(configPath)) {
const content = readFileSync(configPath, 'utf-8');
const parsedRaw = parseYaml(content);
if (parsedRaw && typeof parsedRaw === 'object' && !Array.isArray(parsedRaw)) {
Object.assign(rawConfig, parsedRaw as Record<string, unknown>);
} else if (parsedRaw != null) {
throw new Error('Configuration error: ~/.takt/config.yaml must be a YAML object.');
}
}
applyGlobalConfigEnvOverrides(rawConfig);
const migratedProjectLocalFallback = extractMigratedProjectLocalFallback(rawConfig);
const schemaInput = { ...rawConfig };
removeMigratedProjectLocalKeys(schemaInput);
const parsed = GlobalConfigSchema.parse(schemaInput);
const normalizedProvider = normalizeConfigProviderReference(
parsed.provider as RawProviderReference,
parsed.model,
parsed.provider_options as Record<string, unknown> | undefined,
);
const config: PersistedGlobalConfig = {
language: parsed.language,
provider: normalizedProvider.provider,
model: normalizedProvider.model,
piece: parsed.piece,
observability: parsed.observability ? {
providerEvents: parsed.observability.provider_events,
} : undefined,
analytics: parsed.analytics ? {
enabled: parsed.analytics.enabled,
eventsPath: parsed.analytics.events_path,
retentionDays: parsed.analytics.retention_days,
} : undefined,
worktreeDir: parsed.worktree_dir,
autoPr: parsed.auto_pr,
draftPr: parsed.draft_pr,
disabledBuiltins: parsed.disabled_builtins,
enableBuiltinPieces: parsed.enable_builtin_pieces,
anthropicApiKey: parsed.anthropic_api_key,
openaiApiKey: parsed.openai_api_key,
geminiApiKey: parsed.gemini_api_key,
googleApiKey: parsed.google_api_key,
groqApiKey: parsed.groq_api_key,
openrouterApiKey: parsed.openrouter_api_key,
codexCliPath: parsed.codex_cli_path,
claudeCliPath: parsed.claude_cli_path,
cursorCliPath: parsed.cursor_cli_path,
copilotCliPath: parsed.copilot_cli_path,
copilotGithubToken: parsed.copilot_github_token,
opencodeApiKey: parsed.opencode_api_key,
cursorApiKey: parsed.cursor_api_key,
bookmarksFile: parsed.bookmarks_file,
pieceCategoriesFile: parsed.piece_categories_file,
providerOptions: normalizedProvider.providerOptions,
providerProfiles: normalizeProviderProfiles(parsed.provider_profiles as Record<string, { default_permission_mode: unknown; movement_permission_overrides?: Record<string, unknown> }> | undefined),
runtime: parsed.runtime?.prepare && parsed.runtime.prepare.length > 0
? { prepare: [...new Set(parsed.runtime.prepare)] }
: undefined,
preventSleep: parsed.prevent_sleep,
notificationSound: parsed.notification_sound,
notificationSoundEvents: parsed.notification_sound_events ? {
iterationLimit: parsed.notification_sound_events.iteration_limit,
pieceComplete: parsed.notification_sound_events.piece_complete,
pieceAbort: parsed.notification_sound_events.piece_abort,
runComplete: parsed.notification_sound_events.run_complete,
runAbort: parsed.notification_sound_events.run_abort,
} : undefined,
autoFetch: parsed.auto_fetch,
baseBranch: parsed.base_branch,
pieceOverrides: normalizePieceOverrides(parsed.piece_overrides as { quality_gates?: string[]; quality_gates_edit_only?: boolean; movements?: Record<string, { quality_gates?: string[] }> } | undefined),
};
validateProviderModelCompatibility(config.provider, config.model);
this.cachedConfig = config;
this.cachedMigratedProjectLocalFallback = migratedProjectLocalFallback;
return config;
}
loadMigratedProjectLocalFallback(): GlobalMigratedProjectLocalFallback {
if (this.cachedMigratedProjectLocalFallback !== null) {
return this.cachedMigratedProjectLocalFallback;
}
this.load();
return this.cachedMigratedProjectLocalFallback ?? {};
}
save(config: PersistedGlobalConfig): void {
const configPath = getGlobalConfigPath();
const raw: Record<string, unknown> = {
language: config.language,
provider: config.provider,
};
if (config.model) {
raw.model = config.model;
}
if (config.piece) {
raw.piece = config.piece;
}
if (config.observability && config.observability.providerEvents !== undefined) {
raw.observability = {
provider_events: config.observability.providerEvents,
};
}
if (config.analytics) {
const analyticsRaw: Record<string, unknown> = {};
if (config.analytics.enabled !== undefined) analyticsRaw.enabled = config.analytics.enabled;
if (config.analytics.eventsPath) analyticsRaw.events_path = config.analytics.eventsPath;
if (config.analytics.retentionDays !== undefined) analyticsRaw.retention_days = config.analytics.retentionDays;
if (Object.keys(analyticsRaw).length > 0) {
raw.analytics = analyticsRaw;
}
}
if (config.worktreeDir) {
raw.worktree_dir = config.worktreeDir;
}
if (config.autoPr !== undefined) {
raw.auto_pr = config.autoPr;
}
if (config.draftPr !== undefined) {
raw.draft_pr = config.draftPr;
}
if (config.disabledBuiltins && config.disabledBuiltins.length > 0) {
raw.disabled_builtins = config.disabledBuiltins;
}
if (config.enableBuiltinPieces !== undefined) {
raw.enable_builtin_pieces = config.enableBuiltinPieces;
}
if (config.anthropicApiKey) {
raw.anthropic_api_key = config.anthropicApiKey;
}
if (config.openaiApiKey) {
raw.openai_api_key = config.openaiApiKey;
}
if (config.geminiApiKey) {
raw.gemini_api_key = config.geminiApiKey;
}
if (config.googleApiKey) {
raw.google_api_key = config.googleApiKey;
}
if (config.groqApiKey) {
raw.groq_api_key = config.groqApiKey;
}
if (config.openrouterApiKey) {
raw.openrouter_api_key = config.openrouterApiKey;
}
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.copilotCliPath) {
raw.copilot_cli_path = config.copilotCliPath;
}
if (config.copilotGithubToken) {
raw.copilot_github_token = config.copilotGithubToken;
}
if (config.opencodeApiKey) {
raw.opencode_api_key = config.opencodeApiKey;
}
if (config.cursorApiKey) {
raw.cursor_api_key = config.cursorApiKey;
}
if (config.bookmarksFile) {
raw.bookmarks_file = config.bookmarksFile;
}
if (config.pieceCategoriesFile) {
raw.piece_categories_file = config.pieceCategoriesFile;
}
const rawProviderOptions = denormalizeProviderOptions(config.providerOptions);
if (rawProviderOptions) {
raw.provider_options = rawProviderOptions;
}
const rawProviderProfiles = denormalizeProviderProfiles(config.providerProfiles);
if (rawProviderProfiles && Object.keys(rawProviderProfiles).length > 0) {
raw.provider_profiles = rawProviderProfiles;
}
if (config.runtime?.prepare && config.runtime.prepare.length > 0) {
raw.runtime = {
prepare: [...new Set(config.runtime.prepare)],
};
}
if (config.preventSleep !== undefined) {
raw.prevent_sleep = config.preventSleep;
}
if (config.notificationSound !== undefined) {
raw.notification_sound = config.notificationSound;
}
if (config.notificationSoundEvents) {
const eventRaw: Record<string, unknown> = {};
if (config.notificationSoundEvents.iterationLimit !== undefined) {
eventRaw.iteration_limit = config.notificationSoundEvents.iterationLimit;
}
if (config.notificationSoundEvents.pieceComplete !== undefined) {
eventRaw.piece_complete = config.notificationSoundEvents.pieceComplete;
}
if (config.notificationSoundEvents.pieceAbort !== undefined) {
eventRaw.piece_abort = config.notificationSoundEvents.pieceAbort;
}
if (config.notificationSoundEvents.runComplete !== undefined) {
eventRaw.run_complete = config.notificationSoundEvents.runComplete;
}
if (config.notificationSoundEvents.runAbort !== undefined) {
eventRaw.run_abort = config.notificationSoundEvents.runAbort;
}
if (Object.keys(eventRaw).length > 0) {
raw.notification_sound_events = eventRaw;
}
}
if (config.autoFetch) {
raw.auto_fetch = config.autoFetch;
}
if (config.baseBranch) {
raw.base_branch = config.baseBranch;
}
const denormalizedPieceOverrides = denormalizePieceOverrides(config.pieceOverrides);
if (denormalizedPieceOverrides) {
raw.piece_overrides = denormalizedPieceOverrides;
}
writeFileSync(configPath, stringifyYaml(raw), 'utf-8');
this.invalidateCache();
invalidateAllResolvedConfigCache();
}
}
export function invalidateGlobalConfigCache(): void {
GlobalConfigManager.getInstance().invalidateCache();
invalidateAllResolvedConfigCache();
}
export function loadGlobalConfig(): PersistedGlobalConfig {
return GlobalConfigManager.getInstance().load();
}
export function loadGlobalMigratedProjectLocalFallback(): GlobalMigratedProjectLocalFallback {
return GlobalConfigManager.getInstance().loadMigratedProjectLocalFallback();
}
export function saveGlobalConfig(config: PersistedGlobalConfig): void {
GlobalConfigManager.getInstance().save(config);
}

View File

@ -0,0 +1,95 @@
import type { PersistedGlobalConfig } from '../../../core/models/persisted-global-config.js';
import { envVarNameFromPath } from '../env/config-env-overrides.js';
import { loadGlobalConfig, validateCliPath } from './globalConfigCore.js';
export function resolveAnthropicApiKey(): string | undefined {
const envKey = process.env[envVarNameFromPath('anthropic_api_key')];
if (envKey) return envKey;
const config = loadGlobalConfig();
return config.anthropicApiKey;
}
export function resolveOpenaiApiKey(): string | undefined {
const envKey = process.env[envVarNameFromPath('openai_api_key')];
if (envKey) return envKey;
const config = loadGlobalConfig();
return config.openaiApiKey;
}
export function resolveCodexCliPath(): string | undefined {
const envPath = process.env[envVarNameFromPath('codex_cli_path')];
if (envPath !== undefined) {
return validateCliPath(envPath, 'TAKT_CODEX_CLI_PATH');
}
const config: PersistedGlobalConfig = loadGlobalConfig();
if (config.codexCliPath === undefined) {
return undefined;
}
return validateCliPath(config.codexCliPath, 'codex_cli_path');
}
export function resolveClaudeCliPath(): string | undefined {
const envPath = process.env[envVarNameFromPath('claude_cli_path')];
if (envPath !== undefined) {
return validateCliPath(envPath, 'TAKT_CLAUDE_CLI_PATH');
}
const config: PersistedGlobalConfig = loadGlobalConfig();
if (config.claudeCliPath === undefined) {
return undefined;
}
return validateCliPath(config.claudeCliPath, 'claude_cli_path');
}
export function resolveCursorCliPath(): string | undefined {
const envPath = process.env[envVarNameFromPath('cursor_cli_path')];
if (envPath !== undefined) {
return validateCliPath(envPath, 'TAKT_CURSOR_CLI_PATH');
}
const config: PersistedGlobalConfig = loadGlobalConfig();
if (config.cursorCliPath === undefined) {
return undefined;
}
return validateCliPath(config.cursorCliPath, 'cursor_cli_path');
}
export function resolveOpencodeApiKey(): string | undefined {
const envKey = process.env[envVarNameFromPath('opencode_api_key')];
if (envKey) return envKey;
const config = loadGlobalConfig();
return config.opencodeApiKey;
}
export function resolveCursorApiKey(): string | undefined {
const envKey = process.env[envVarNameFromPath('cursor_api_key')];
if (envKey) return envKey;
const config = loadGlobalConfig();
return config.cursorApiKey;
}
export function resolveCopilotCliPath(): string | undefined {
const envPath = process.env[envVarNameFromPath('copilot_cli_path')];
if (envPath !== undefined) {
return validateCliPath(envPath, 'TAKT_COPILOT_CLI_PATH');
}
const config: PersistedGlobalConfig = loadGlobalConfig();
if (config.copilotCliPath === undefined) {
return undefined;
}
return validateCliPath(config.copilotCliPath, 'copilot_cli_path');
}
export function resolveCopilotGithubToken(): string | undefined {
const envKey = process.env[envVarNameFromPath('copilot_github_token')];
if (envKey) return envKey;
const config = loadGlobalConfig();
return config.copilotGithubToken;
}

View File

@ -0,0 +1,68 @@
import { ProjectConfigSchema } from '../../../core/models/index.js';
import {
normalizePipelineConfig,
normalizePersonaProviders,
} from '../configNormalizers.js';
import {
MIGRATED_PROJECT_LOCAL_CONFIG_METADATA,
type MigratedProjectLocalConfigKey,
} from '../migratedProjectLocalKeys.js';
import type { ProjectLocalConfig } from '../types.js';
export type GlobalMigratedProjectLocalFallback = Partial<
Pick<ProjectLocalConfig, MigratedProjectLocalConfigKey>
>;
export function removeMigratedProjectLocalKeys(config: Record<string, unknown>): void {
for (const metadata of Object.values(MIGRATED_PROJECT_LOCAL_CONFIG_METADATA)) {
delete config[metadata.legacyGlobalYamlKey];
}
}
export function extractMigratedProjectLocalFallback(
rawConfig: Record<string, unknown>,
): GlobalMigratedProjectLocalFallback {
const rawMigratedConfig: Record<string, unknown> = {};
for (const metadata of Object.values(MIGRATED_PROJECT_LOCAL_CONFIG_METADATA)) {
const value = rawConfig[metadata.legacyGlobalYamlKey];
if (value !== undefined) {
rawMigratedConfig[metadata.legacyGlobalYamlKey] = value;
}
}
if (Object.keys(rawMigratedConfig).length === 0) {
return {};
}
const parsedMigratedConfig = ProjectConfigSchema.partial().parse(rawMigratedConfig);
const {
log_level,
pipeline,
persona_providers,
branch_name_strategy,
minimal_output,
verbose,
concurrency,
task_poll_interval_ms,
interactive_preview_movements,
} = parsedMigratedConfig;
return {
logLevel: log_level as ProjectLocalConfig['logLevel'],
pipeline: normalizePipelineConfig(
pipeline as {
default_branch_prefix?: string;
commit_message_template?: string;
pr_body_template?: string;
} | undefined,
),
personaProviders: normalizePersonaProviders(
persona_providers as Record<string, string | { type?: string; provider?: string; model?: string }> | undefined,
),
branchNameStrategy: branch_name_strategy as ProjectLocalConfig['branchNameStrategy'],
minimalOutput: minimal_output as ProjectLocalConfig['minimalOutput'],
verbose: verbose as ProjectLocalConfig['verbose'],
concurrency: concurrency as ProjectLocalConfig['concurrency'],
taskPollIntervalMs: task_poll_interval_ms as ProjectLocalConfig['taskPollIntervalMs'],
interactivePreviewMovements: interactive_preview_movements as ProjectLocalConfig['interactivePreviewMovements'],
};
}

View File

@ -3,7 +3,6 @@
*/
export {
GlobalConfigManager,
invalidateGlobalConfigCache,
loadGlobalConfig,
saveGlobalConfig,

View File

@ -0,0 +1,18 @@
import type { LoadedConfig } from './resolvedConfig.js';
import {
MIGRATED_PROJECT_LOCAL_CONFIG_KEYS,
MIGRATED_PROJECT_LOCAL_CONFIG_METADATA,
type MigratedProjectLocalConfigKey,
} from './migratedProjectLocalKeys.js';
const defaults: Record<string, unknown> = {};
for (const key of MIGRATED_PROJECT_LOCAL_CONFIG_KEYS) {
const metadata = MIGRATED_PROJECT_LOCAL_CONFIG_METADATA[key] as { defaultValue?: unknown };
const defaultValue = metadata.defaultValue;
if (defaultValue !== undefined) {
defaults[key] = defaultValue;
}
}
export const MIGRATED_PROJECT_LOCAL_DEFAULTS =
defaults as Partial<Pick<LoadedConfig, MigratedProjectLocalConfigKey>>;

View File

@ -0,0 +1,26 @@
type MigratedProjectLocalConfigMetadata = {
readonly defaultValue?: unknown;
readonly legacyGlobalYamlKey: string;
};
/**
* Project-local keys migrated from persisted global config.
* Keep this metadata as the single source of truth.
*/
export const MIGRATED_PROJECT_LOCAL_CONFIG_METADATA = {
logLevel: { defaultValue: 'info', legacyGlobalYamlKey: 'log_level' },
pipeline: { legacyGlobalYamlKey: 'pipeline' },
personaProviders: { legacyGlobalYamlKey: 'persona_providers' },
branchNameStrategy: { legacyGlobalYamlKey: 'branch_name_strategy' },
minimalOutput: { defaultValue: false, legacyGlobalYamlKey: 'minimal_output' },
verbose: { defaultValue: false, legacyGlobalYamlKey: 'verbose' },
concurrency: { defaultValue: 1, legacyGlobalYamlKey: 'concurrency' },
taskPollIntervalMs: { defaultValue: 500, legacyGlobalYamlKey: 'task_poll_interval_ms' },
interactivePreviewMovements: { defaultValue: 3, legacyGlobalYamlKey: 'interactive_preview_movements' },
} as const satisfies Record<string, MigratedProjectLocalConfigMetadata>;
export type MigratedProjectLocalConfigKey = keyof typeof MIGRATED_PROJECT_LOCAL_CONFIG_METADATA;
export const MIGRATED_PROJECT_LOCAL_CONFIG_KEYS = Object.freeze(
Object.keys(MIGRATED_PROJECT_LOCAL_CONFIG_METADATA) as MigratedProjectLocalConfigKey[],
);

View File

@ -6,13 +6,17 @@
*/
import { homedir } from 'node:os';
import { join, resolve } from 'node:path';
import { isAbsolute, join, relative, resolve } from 'node:path';
import { existsSync, mkdirSync } from 'node:fs';
import type { Language } from '../../core/models/index.js';
import { getLanguageResourcesDir } from '../resources/index.js';
import type { FacetKind } from '../../faceted-prompting/index.js';
import { REPERTOIRE_DIR_NAME } from './constants.js';
import {
getProjectConfigDir as resolveProjectConfigDir,
getProjectConfigPath as resolveProjectConfigPath,
} from './project/projectConfigPaths.js';
/** Facet types used in layer resolution */
export type { FacetKind as FacetType } from '../../faceted-prompting/index.js';
@ -56,7 +60,7 @@ export function getBuiltinPersonasDir(lang: Language): string {
/** Get project takt config directory (.takt in project) */
export function getProjectConfigDir(projectDir: string): string {
return join(resolve(projectDir), '.takt');
return resolveProjectConfigDir(projectDir);
}
/** Get project pieces directory (.takt/pieces in project) */
@ -66,7 +70,7 @@ export function getProjectPiecesDir(projectDir: string): string {
/** Get project config file path */
export function getProjectConfigPath(projectDir: string): string {
return join(getProjectConfigDir(projectDir), 'config.yaml');
return resolveProjectConfigPath(projectDir);
}
/** Get project tasks directory */
@ -132,7 +136,8 @@ export function getRepertoireFacetDir(owner: string, repo: string, facetType: Fa
export function isPathSafe(basePath: string, targetPath: string): boolean {
const resolvedBase = resolve(basePath);
const resolvedTarget = resolve(targetPath);
return resolvedTarget.startsWith(resolvedBase);
const rel = relative(resolvedBase, resolvedTarget);
return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel));
}
// Re-export project config functions

View File

@ -1,140 +1,84 @@
/**
* Project-level configuration management
*
* Manages .takt/config.yaml for project-specific settings.
*/
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
import { join, resolve } from 'node:path';
import { parse, stringify } from 'yaml';
import { ProjectConfigSchema } from '../../../core/models/index.js';
import { copyProjectResourcesToDir } from '../../resources/index.js';
import type { ProjectLocalConfig } from '../types.js';
import type { AnalyticsConfig, SubmoduleSelection } from '../../../core/models/persisted-global-config.js';
import { applyProjectConfigEnvOverrides } from '../env/config-env-overrides.js';
import {
normalizeConfigProviderReference,
type ConfigProviderReference,
} from '../providerReference.js';
import {
normalizePipelineConfig,
normalizeProviderProfiles,
denormalizeProviderProfiles,
denormalizeProviderOptions,
normalizePersonaProviders,
normalizePieceOverrides,
denormalizePieceOverrides,
} from '../configNormalizers.js';
import { invalidateResolvedConfigCache } from '../resolutionCache.js';
import { MIGRATED_PROJECT_LOCAL_DEFAULTS } from '../migratedProjectLocalDefaults.js';
import type { MigratedProjectLocalConfigKey } from '../migratedProjectLocalKeys.js';
import { getProjectConfigDir, getProjectConfigPath } from './projectConfigPaths.js';
import {
normalizeSubmodules,
normalizeWithSubmodules,
normalizeAnalytics,
denormalizeAnalytics,
formatIssuePath,
} from './projectConfigTransforms.js';
export type { ProjectLocalConfig } from '../types.js';
/** Default project configuration */
const DEFAULT_PROJECT_CONFIG: ProjectLocalConfig = {};
type Assert<T extends true> = T;
type IsNever<T> = [T] extends [never] ? true : false;
/**
* Compile-time guard:
* migrated fields must be owned by ProjectLocalConfig.
*/
const projectLocalConfigMigratedFieldGuard:
Assert<IsNever<Exclude<MigratedProjectLocalConfigKey, keyof ProjectLocalConfig>>> = true;
void projectLocalConfigMigratedFieldGuard;
const SUBMODULES_ALL = 'all';
type ProviderType = NonNullable<ProjectLocalConfig['provider']>;
type RawProviderReference = ConfigProviderReference<ProviderType>;
function normalizeSubmodules(raw: unknown): SubmoduleSelection | undefined {
if (raw === undefined) return undefined;
if (typeof raw === 'string') {
const normalized = raw.trim().toLowerCase();
if (normalized === SUBMODULES_ALL) {
return SUBMODULES_ALL;
}
throw new Error('Invalid submodules: string value must be "all"');
}
if (Array.isArray(raw)) {
if (raw.length === 0) {
throw new Error('Invalid submodules: explicit path list must not be empty');
}
const normalizedPaths = raw.map((entry) => {
if (typeof entry !== 'string') {
throw new Error('Invalid submodules: path entries must be strings');
}
const trimmed = entry.trim();
if (trimmed.length === 0) {
throw new Error('Invalid submodules: path entries must not be empty');
}
if (trimmed.includes('*')) {
throw new Error(`Invalid submodules: wildcard is not supported (${trimmed})`);
}
return trimmed;
});
return normalizedPaths;
}
throw new Error('Invalid submodules: must be "all" or an explicit path list');
}
function normalizeWithSubmodules(raw: unknown): boolean | undefined {
if (raw === undefined) return undefined;
if (typeof raw === 'boolean') return raw;
throw new Error('Invalid with_submodules: value must be boolean');
}
/**
* Get project takt config directory (.takt in project)
* Note: Defined locally to avoid circular dependency with paths.ts
*/
function getConfigDir(projectDir: string): string {
return join(resolve(projectDir), '.takt');
}
/**
* Get project config file path
* Note: Defined locally to avoid circular dependency with paths.ts
*/
function getConfigPath(projectDir: string): string {
return join(getConfigDir(projectDir), 'config.yaml');
}
function normalizeAnalytics(raw: Record<string, unknown> | undefined): AnalyticsConfig | undefined {
if (!raw) return undefined;
const enabled = typeof raw.enabled === 'boolean' ? raw.enabled : undefined;
const eventsPath = typeof raw.events_path === 'string'
? raw.events_path
: (typeof raw.eventsPath === 'string' ? raw.eventsPath : undefined);
const retentionDays = typeof raw.retention_days === 'number'
? raw.retention_days
: (typeof raw.retentionDays === 'number' ? raw.retentionDays : undefined);
if (enabled === undefined && eventsPath === undefined && retentionDays === undefined) {
return undefined;
}
return { enabled, eventsPath, retentionDays };
}
function denormalizeAnalytics(config: AnalyticsConfig | undefined): Record<string, unknown> | undefined {
if (!config) return undefined;
const raw: Record<string, unknown> = {};
if (config.enabled !== undefined) raw.enabled = config.enabled;
if (config.eventsPath) raw.events_path = config.eventsPath;
if (config.retentionDays !== undefined) raw.retention_days = config.retentionDays;
return Object.keys(raw).length > 0 ? raw : undefined;
}
/**
* Load project configuration from .takt/config.yaml
*/
export function loadProjectConfig(projectDir: string): ProjectLocalConfig {
const configPath = getConfigPath(projectDir);
const configPath = getProjectConfigPath(projectDir);
const rawConfig: Record<string, unknown> = {};
if (existsSync(configPath)) {
try {
const content = readFileSync(configPath, 'utf-8');
const parsed = (parse(content) as Record<string, unknown> | null) ?? {};
Object.assign(rawConfig, parsed);
} catch {
return { ...DEFAULT_PROJECT_CONFIG };
let parsed: unknown;
try {
parsed = parse(content);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw new Error(`Configuration error: failed to parse ${configPath}: ${message}`);
}
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
Object.assign(rawConfig, parsed as Record<string, unknown>);
} else if (parsed != null) {
throw new Error(`Configuration error: ${configPath} must be a YAML object.`);
}
}
applyProjectConfigEnvOverrides(rawConfig);
const parsedConfig = ProjectConfigSchema.parse(rawConfig);
const parsedResult = ProjectConfigSchema.safeParse(rawConfig);
if (!parsedResult.success) {
const firstIssue = parsedResult.error.issues[0];
const issuePath = firstIssue ? formatIssuePath(firstIssue.path) : '(root)';
const issueMessage = firstIssue?.message ?? 'Invalid configuration value';
throw new Error(
`Configuration error: invalid ${issuePath} in ${configPath}: ${issueMessage}`,
);
}
const parsedConfig = parsedResult.data;
const {
provider,
@ -147,11 +91,16 @@ export function loadProjectConfig(projectDir: string): ProjectLocalConfig {
provider_options,
provider_profiles,
analytics,
log_level,
pipeline,
persona_providers,
verbose,
branch_name_strategy,
minimal_output,
concurrency,
task_poll_interval_ms,
interactive_preview_movements,
piece_overrides,
claude_cli_path,
codex_cli_path,
cursor_cli_path,
copilot_cli_path,
...rest
} = parsedConfig;
const normalizedProvider = normalizeConfigProviderReference(
@ -163,10 +112,24 @@ export function loadProjectConfig(projectDir: string): ProjectLocalConfig {
const normalizedSubmodules = normalizeSubmodules(submodules);
const normalizedWithSubmodules = normalizeWithSubmodules(with_submodules);
const effectiveWithSubmodules = normalizedSubmodules === undefined ? normalizedWithSubmodules : undefined;
const normalizedPipeline = normalizePipelineConfig(
pipeline as { default_branch_prefix?: string; commit_message_template?: string; pr_body_template?: string } | undefined,
);
const personaProviders = normalizePersonaProviders(
persona_providers as Record<string, string | { type?: string; provider?: string; model?: string }> | undefined,
);
return {
...DEFAULT_PROJECT_CONFIG,
...(rest as ProjectLocalConfig),
logLevel: log_level as ProjectLocalConfig['logLevel'],
pipeline: normalizedPipeline,
personaProviders,
branchNameStrategy: branch_name_strategy as ProjectLocalConfig['branchNameStrategy'],
minimalOutput: minimal_output as boolean | undefined,
concurrency: concurrency as number | undefined,
taskPollIntervalMs: task_poll_interval_ms as number | undefined,
interactivePreviewMovements: interactive_preview_movements as number | undefined,
verbose: verbose as boolean | undefined,
autoPr: auto_pr as boolean | undefined,
draftPr: draft_pr as boolean | undefined,
baseBranch: base_branch as string | undefined,
@ -178,10 +141,6 @@ export function loadProjectConfig(projectDir: string): ProjectLocalConfig {
providerOptions: normalizedProvider.providerOptions,
providerProfiles: normalizeProviderProfiles(provider_profiles as Record<string, { default_permission_mode: unknown; movement_permission_overrides?: Record<string, unknown> }> | undefined),
pieceOverrides: normalizePieceOverrides(piece_overrides as { quality_gates?: string[]; quality_gates_edit_only?: boolean; movements?: Record<string, { quality_gates?: string[] }> } | undefined),
claudeCliPath: claude_cli_path as string | undefined,
codexCliPath: codex_cli_path as string | undefined,
cursorCliPath: cursor_cli_path as string | undefined,
copilotCliPath: copilot_cli_path as string | undefined,
};
}
@ -189,8 +148,8 @@ export function loadProjectConfig(projectDir: string): ProjectLocalConfig {
* Save project configuration to .takt/config.yaml
*/
export function saveProjectConfig(projectDir: string, config: ProjectLocalConfig): void {
const configDir = getConfigDir(projectDir);
const configPath = getConfigPath(projectDir);
const configDir = getProjectConfigDir(projectDir);
const configPath = getProjectConfigPath(projectDir);
if (!existsSync(configDir)) {
mkdirSync(configDir, { recursive: true });
@ -214,12 +173,74 @@ export function saveProjectConfig(projectDir: string, config: ProjectLocalConfig
} else {
delete savePayload.provider_profiles;
}
const rawProviderOptions = denormalizeProviderOptions(config.providerOptions);
if (rawProviderOptions) {
savePayload.provider_options = rawProviderOptions;
} else {
delete savePayload.provider_options;
}
delete savePayload.providerProfiles;
delete savePayload.providerOptions;
delete savePayload.concurrency;
delete savePayload.verbose;
if (config.autoPr !== undefined) savePayload.auto_pr = config.autoPr;
if (config.draftPr !== undefined) savePayload.draft_pr = config.draftPr;
if (config.baseBranch !== undefined) savePayload.base_branch = config.baseBranch;
if (
config.logLevel !== undefined
&& config.logLevel !== MIGRATED_PROJECT_LOCAL_DEFAULTS.logLevel
) {
savePayload.log_level = config.logLevel;
}
if (config.branchNameStrategy !== undefined) savePayload.branch_name_strategy = config.branchNameStrategy;
if (
config.minimalOutput !== undefined
&& config.minimalOutput !== MIGRATED_PROJECT_LOCAL_DEFAULTS.minimalOutput
) {
savePayload.minimal_output = config.minimalOutput;
}
if (
config.taskPollIntervalMs !== undefined
&& config.taskPollIntervalMs !== MIGRATED_PROJECT_LOCAL_DEFAULTS.taskPollIntervalMs
) {
savePayload.task_poll_interval_ms = config.taskPollIntervalMs;
}
if (
config.interactivePreviewMovements !== undefined
&& config.interactivePreviewMovements !== MIGRATED_PROJECT_LOCAL_DEFAULTS.interactivePreviewMovements
) {
savePayload.interactive_preview_movements = config.interactivePreviewMovements;
}
if (
config.concurrency !== undefined
&& config.concurrency !== MIGRATED_PROJECT_LOCAL_DEFAULTS.concurrency
) {
savePayload.concurrency = config.concurrency;
}
if (
config.verbose !== undefined
&& config.verbose !== MIGRATED_PROJECT_LOCAL_DEFAULTS.verbose
) {
savePayload.verbose = config.verbose;
}
delete savePayload.pipeline;
if (config.pipeline) {
const pipelineRaw: Record<string, unknown> = {};
if (config.pipeline.defaultBranchPrefix !== undefined) {
pipelineRaw.default_branch_prefix = config.pipeline.defaultBranchPrefix;
}
if (config.pipeline.commitMessageTemplate !== undefined) {
pipelineRaw.commit_message_template = config.pipeline.commitMessageTemplate;
}
if (config.pipeline.prBodyTemplate !== undefined) {
pipelineRaw.pr_body_template = config.pipeline.prBodyTemplate;
}
if (Object.keys(pipelineRaw).length > 0) savePayload.pipeline = pipelineRaw;
}
if (config.personaProviders && Object.keys(config.personaProviders).length > 0) {
savePayload.persona_providers = config.personaProviders;
}
if (normalizedSubmodules !== undefined) {
savePayload.submodules = normalizedSubmodules;
delete savePayload.with_submodules;
@ -235,6 +256,12 @@ export function saveProjectConfig(projectDir: string, config: ProjectLocalConfig
delete savePayload.draftPr;
delete savePayload.baseBranch;
delete savePayload.withSubmodules;
delete savePayload.logLevel;
delete savePayload.branchNameStrategy;
delete savePayload.minimalOutput;
delete savePayload.taskPollIntervalMs;
delete savePayload.interactivePreviewMovements;
delete savePayload.personaProviders;
const rawPieceOverrides = denormalizePieceOverrides(config.pieceOverrides);
if (rawPieceOverrides) {
@ -247,9 +274,6 @@ export function saveProjectConfig(projectDir: string, config: ProjectLocalConfig
invalidateResolvedConfigCache(projectDir);
}
/**
* Update a single field in project configuration
*/
export function updateProjectConfig<K extends keyof ProjectLocalConfig>(
projectDir: string,
key: K,
@ -260,9 +284,6 @@ export function updateProjectConfig<K extends keyof ProjectLocalConfig>(
saveProjectConfig(projectDir, config);
}
/**
* Set current piece in project config
*/
export function setCurrentPiece(projectDir: string, piece: string): void {
updateProjectConfig(projectDir, 'piece', piece);
}

View File

@ -0,0 +1,9 @@
import { join, resolve } from 'node:path';
export function getProjectConfigDir(projectDir: string): string {
return join(resolve(projectDir), '.takt');
}
export function getProjectConfigPath(projectDir: string): string {
return join(getProjectConfigDir(projectDir), 'config.yaml');
}

View File

@ -0,0 +1,75 @@
import type { AnalyticsConfig, SubmoduleSelection } from '../../../core/models/persisted-global-config.js';
const SUBMODULES_ALL = 'all';
export function normalizeSubmodules(raw: unknown): SubmoduleSelection | undefined {
if (raw === undefined) return undefined;
if (typeof raw === 'string') {
const normalized = raw.trim().toLowerCase();
if (normalized === SUBMODULES_ALL) {
return SUBMODULES_ALL;
}
throw new Error('Invalid submodules: string value must be "all"');
}
if (Array.isArray(raw)) {
if (raw.length === 0) {
throw new Error('Invalid submodules: explicit path list must not be empty');
}
const normalizedPaths = raw.map((entry) => {
if (typeof entry !== 'string') {
throw new Error('Invalid submodules: path entries must be strings');
}
const trimmed = entry.trim();
if (trimmed.length === 0) {
throw new Error('Invalid submodules: path entries must not be empty');
}
if (trimmed.includes('*')) {
throw new Error(`Invalid submodules: wildcard is not supported (${trimmed})`);
}
return trimmed;
});
return normalizedPaths;
}
throw new Error('Invalid submodules: must be "all" or an explicit path list');
}
export function normalizeWithSubmodules(raw: unknown): boolean | undefined {
if (raw === undefined) return undefined;
if (typeof raw === 'boolean') return raw;
throw new Error('Invalid with_submodules: value must be boolean');
}
export function normalizeAnalytics(raw: Record<string, unknown> | undefined): AnalyticsConfig | undefined {
if (!raw) return undefined;
const enabled = typeof raw.enabled === 'boolean' ? raw.enabled : undefined;
const eventsPath = typeof raw.events_path === 'string'
? raw.events_path
: (typeof raw.eventsPath === 'string' ? raw.eventsPath : undefined);
const retentionDays = typeof raw.retention_days === 'number'
? raw.retention_days
: (typeof raw.retentionDays === 'number' ? raw.retentionDays : undefined);
if (enabled === undefined && eventsPath === undefined && retentionDays === undefined) {
return undefined;
}
return { enabled, eventsPath, retentionDays };
}
export function denormalizeAnalytics(config: AnalyticsConfig | undefined): Record<string, unknown> | undefined {
if (!config) return undefined;
const raw: Record<string, unknown> = {};
if (config.enabled !== undefined) raw.enabled = config.enabled;
if (config.eventsPath) raw.events_path = config.eventsPath;
if (config.retentionDays !== undefined) raw.retention_days = config.retentionDays;
return Object.keys(raw).length > 0 ? raw : undefined;
}
export function formatIssuePath(path: readonly PropertyKey[]): string {
if (path.length === 0) return '(root)';
return path.map((segment) => String(segment)).join('.');
}

View File

@ -0,0 +1,40 @@
import { parseProviderModel } from '../../shared/utils/providerModel.js';
const CLAUDE_MODEL_ALIASES = new Set(['opus', 'sonnet', 'haiku']);
type ProviderModelCompatibilityOptions = {
modelFieldName?: string;
requireProviderQualifiedModelForOpencode?: boolean;
};
export function validateProviderModelCompatibility(
provider: string | undefined,
model: string | undefined,
options: ProviderModelCompatibilityOptions = {},
): void {
const {
modelFieldName = 'Configuration error: model',
requireProviderQualifiedModelForOpencode = true,
} = options;
if (!provider) return;
if (provider === 'opencode' && !model) {
throw new Error(
"Configuration error: provider 'opencode' requires model in 'provider/model' format (e.g. 'opencode/big-pickle')."
);
}
if (!model) return;
if ((provider === 'codex' || provider === 'opencode') && CLAUDE_MODEL_ALIASES.has(model)) {
throw new Error(
`Configuration error: model '${model}' is a Claude model alias but provider is '${provider}'. ` +
`Either change the provider to 'claude' or specify a ${provider}-compatible model.`
);
}
if (provider === 'opencode' && requireProviderQualifiedModelForOpencode) {
parseProviderModel(model, modelFieldName);
}
}

View File

@ -1,4 +1,4 @@
import { loadGlobalConfig } from './global/globalConfig.js';
import * as globalConfigModule from './global/globalConfig.js';
import { loadProjectConfig } from './project/projectConfig.js';
import { envVarNameFromPath } from './env/config-env-overrides.js';
import {
@ -9,6 +9,11 @@ import {
setCachedResolvedValue,
} from './resolutionCache.js';
import type { ConfigParameterKey, LoadedConfig } from './resolvedConfig.js';
import { MIGRATED_PROJECT_LOCAL_DEFAULTS } from './migratedProjectLocalDefaults.js';
import {
MIGRATED_PROJECT_LOCAL_CONFIG_KEYS,
type MigratedProjectLocalConfigKey,
} from './migratedProjectLocalKeys.js';
export type { ConfigParameterKey } from './resolvedConfig.js';
export { invalidateResolvedConfigCache, invalidateAllResolvedConfigCache } from './resolutionCache.js';
@ -36,6 +41,9 @@ interface ResolutionRule<K extends ConfigParameterKey> {
mergeMode?: 'analytics';
pieceValue?: (pieceContext: PieceContext | undefined) => LoadedConfig[K] | undefined;
}
type GlobalMigratedProjectLocalFallback = Partial<
Pick<LoadedConfig, MigratedProjectLocalConfigKey>
>;
function loadProjectConfigCached(projectDir: string) {
const cached = getCachedProjectConfig(projectDir);
@ -59,6 +67,13 @@ const PROVIDER_OPTIONS_ENV_PATHS = [
'provider_options.claude.sandbox.excluded_commands',
] as const;
const MIGRATED_PROJECT_LOCAL_RESOLUTION_REGISTRY = Object.fromEntries(
MIGRATED_PROJECT_LOCAL_CONFIG_KEYS.map((key) => [key, { layers: ['local', 'global'] as const }]),
) as Partial<{ [K in ConfigParameterKey]: ResolutionRule<K> }>;
const MIGRATED_PROJECT_LOCAL_CONFIG_KEY_SET = new Set(
MIGRATED_PROJECT_LOCAL_CONFIG_KEYS as ConfigParameterKey[],
);
const RESOLUTION_REGISTRY: Partial<{ [K in ConfigParameterKey]: ResolutionRule<K> }> = {
piece: { layers: ['local', 'global'] },
provider: {
@ -76,7 +91,7 @@ const RESOLUTION_REGISTRY: Partial<{ [K in ConfigParameterKey]: ResolutionRule<K
autoPr: { layers: ['local', 'global'] },
draftPr: { layers: ['local', 'global'] },
analytics: { layers: ['local', 'global'], mergeMode: 'analytics' },
verbose: { layers: ['local', 'global'] },
...MIGRATED_PROJECT_LOCAL_RESOLUTION_REGISTRY,
autoFetch: { layers: ['global'] },
baseBranch: { layers: ['local', 'global'] },
pieceOverrides: { layers: ['local', 'global'] },
@ -84,7 +99,7 @@ const RESOLUTION_REGISTRY: Partial<{ [K in ConfigParameterKey]: ResolutionRule<K
function resolveAnalyticsMerged(
project: ReturnType<typeof loadProjectConfigCached>,
global: ReturnType<typeof loadGlobalConfig>,
global: ReturnType<typeof globalConfigModule.loadGlobalConfig>,
): LoadedConfig['analytics'] {
const localAnalytics = project.analytics;
const globalAnalytics = global.analytics;
@ -101,7 +116,7 @@ function resolveAnalyticsMerged(
function resolveAnalyticsSource(
project: ReturnType<typeof loadProjectConfigCached>,
global: ReturnType<typeof loadGlobalConfig>,
global: ReturnType<typeof globalConfigModule.loadGlobalConfig>,
): ConfigValueSource {
if (project.analytics !== undefined) return 'project';
if (global.analytics !== undefined) return 'global';
@ -116,16 +131,21 @@ function getLocalLayerValue<K extends ConfigParameterKey>(
}
function getGlobalLayerValue<K extends ConfigParameterKey>(
global: ReturnType<typeof loadGlobalConfig>,
global: ReturnType<typeof globalConfigModule.loadGlobalConfig>,
globalMigratedProjectLocalFallback: GlobalMigratedProjectLocalFallback,
key: K,
): LoadedConfig[K] | undefined {
if (isMigratedProjectLocalConfigKey(key)) {
return globalMigratedProjectLocalFallback[key] as LoadedConfig[K] | undefined;
}
return global[key as keyof typeof global] as LoadedConfig[K] | undefined;
}
function resolveByRegistry<K extends ConfigParameterKey>(
key: K,
project: ReturnType<typeof loadProjectConfigCached>,
global: ReturnType<typeof loadGlobalConfig>,
global: ReturnType<typeof globalConfigModule.loadGlobalConfig>,
globalMigratedProjectLocalFallback: GlobalMigratedProjectLocalFallback,
options: ResolveConfigOptions | undefined,
): ResolvedConfigValue<K> {
const rule = (RESOLUTION_REGISTRY[key] ?? DEFAULT_RULE) as ResolutionRule<K>;
@ -143,7 +163,7 @@ function resolveByRegistry<K extends ConfigParameterKey>(
} else if (layer === 'piece') {
value = rule.pieceValue?.(options?.pieceContext);
} else {
value = getGlobalLayerValue(global, key);
value = getGlobalLayerValue(global, globalMigratedProjectLocalFallback, key);
}
if (value !== undefined) {
if (layer === 'local') {
@ -159,6 +179,11 @@ function resolveByRegistry<K extends ConfigParameterKey>(
}
}
const fallbackDefaultValue = MIGRATED_PROJECT_LOCAL_DEFAULTS[key as keyof typeof MIGRATED_PROJECT_LOCAL_DEFAULTS];
if (fallbackDefaultValue !== undefined) {
return { value: fallbackDefaultValue as LoadedConfig[K], source: 'default' };
}
return { value: undefined as LoadedConfig[K], source: 'default' };
}
@ -172,8 +197,17 @@ function resolveUncachedConfigValue<K extends ConfigParameterKey>(
options?: ResolveConfigOptions,
): ResolvedConfigValue<K> {
const project = loadProjectConfigCached(projectDir);
const global = loadGlobalConfig();
return resolveByRegistry(key, project, global, options);
const global = globalConfigModule.loadGlobalConfig();
const globalMigratedProjectLocalFallback = isMigratedProjectLocalConfigKey(key)
? globalConfigModule.loadGlobalMigratedProjectLocalFallback()
: {};
return resolveByRegistry(key, project, global, globalMigratedProjectLocalFallback, options);
}
function isMigratedProjectLocalConfigKey(
key: ConfigParameterKey,
): key is MigratedProjectLocalConfigKey {
return MIGRATED_PROJECT_LOCAL_CONFIG_KEY_SET.has(key);
}
export function resolveConfigValueWithSource<K extends ConfigParameterKey>(

View File

@ -1,7 +1,17 @@
import type { PersistedGlobalConfig } from '../../core/models/persisted-global-config.js';
import type { ProjectLocalConfig } from './types.js';
import type { MigratedProjectLocalConfigKey } from './migratedProjectLocalKeys.js';
export interface LoadedConfig extends PersistedGlobalConfig {
piece: string;
export interface LoadedConfig
extends PersistedGlobalConfig,
Pick<ProjectLocalConfig, MigratedProjectLocalConfigKey> {
piece?: string;
logLevel: NonNullable<ProjectLocalConfig['logLevel']>;
minimalOutput: NonNullable<ProjectLocalConfig['minimalOutput']>;
verbose: NonNullable<ProjectLocalConfig['verbose']>;
concurrency: NonNullable<ProjectLocalConfig['concurrency']>;
taskPollIntervalMs: NonNullable<ProjectLocalConfig['taskPollIntervalMs']>;
interactivePreviewMovements: NonNullable<ProjectLocalConfig['interactivePreviewMovements']>;
}
export type ConfigParameterKey = keyof LoadedConfig;

View File

@ -4,7 +4,13 @@
import type { MovementProviderOptions } from '../../core/models/piece-types.js';
import type { ProviderPermissionProfiles } from '../../core/models/provider-profiles.js';
import type { AnalyticsConfig, PieceOverrides, SubmoduleSelection } from '../../core/models/persisted-global-config.js';
import type {
AnalyticsConfig,
PersonaProviderEntry,
PieceOverrides,
PipelineConfig,
SubmoduleSelection,
} from '../../core/models/persisted-global-config.js';
/** Project configuration stored in .takt/config.yaml */
export interface ProjectLocalConfig {
@ -26,8 +32,22 @@ export interface ProjectLocalConfig {
withSubmodules?: boolean;
/** Verbose output mode */
verbose?: boolean;
/** Project log level */
logLevel?: 'debug' | 'info' | 'warn' | 'error';
/** Pipeline execution settings */
pipeline?: PipelineConfig;
/** Per-persona provider/model overrides */
personaProviders?: Record<string, PersonaProviderEntry>;
/** Branch name generation strategy */
branchNameStrategy?: 'romaji' | 'ai';
/** Minimal output mode */
minimalOutput?: boolean;
/** Number of tasks to run concurrently in takt run (1-10) */
concurrency?: number;
/** Polling interval in ms for task pickup */
taskPollIntervalMs?: number;
/** Number of movement previews in interactive mode */
interactivePreviewMovements?: number;
/** Project-level analytics overrides */
analytics?: AnalyticsConfig;
/** Provider-specific options (overrides global, overridden by piece/movement) */
@ -36,14 +56,6 @@ export interface ProjectLocalConfig {
providerProfiles?: ProviderPermissionProfiles;
/** Piece-level overrides (quality_gates, etc.) */
pieceOverrides?: PieceOverrides;
/** 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;
/** Copilot CLI path override (project-level) */
copilotCliPath?: string;
}
/** Persona session data for persistence */

View File

@ -4,13 +4,12 @@
import { callClaude, callClaudeCustom, callClaudeAgent, callClaudeSkill } from '../claude/client.js';
import type { ClaudeCallOptions } from '../claude/types.js';
import { resolveAnthropicApiKey, resolveClaudeCliPath, loadProjectConfig } from '../config/index.js';
import { resolveAnthropicApiKey, resolveClaudeCliPath } 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,
@ -30,7 +29,7 @@ function toClaudeOptions(options: ProviderCallOptions): ClaudeCallOptions {
allowUnsandboxedCommands: claudeSandbox.allowUnsandboxedCommands,
excludedCommands: claudeSandbox.excludedCommands,
} : undefined,
pathToClaudeCodeExecutable: resolveClaudeCliPath(projectConfig),
pathToClaudeCodeExecutable: resolveClaudeCliPath(),
};
}

View File

@ -4,7 +4,7 @@
import { execFileSync } from 'node:child_process';
import { callCodex, callCodexCustom, type CodexCallOptions } from '../codex/index.js';
import { resolveOpenaiApiKey, resolveCodexCliPath, loadProjectConfig } from '../config/index.js';
import { resolveOpenaiApiKey, resolveCodexCliPath } from '../config/index.js';
import type { AgentResponse } from '../../core/models/index.js';
import type { AgentSetup, Provider, ProviderAgent, ProviderCallOptions } from './types.js';
@ -25,7 +25,6 @@ function isInsideGitRepo(cwd: string): boolean {
}
function toCodexOptions(options: ProviderCallOptions): CodexCallOptions {
const projectConfig = loadProjectConfig(options.cwd);
return {
cwd: options.cwd,
abortSignal: options.abortSignal,
@ -35,7 +34,7 @@ function toCodexOptions(options: ProviderCallOptions): CodexCallOptions {
networkAccess: options.providerOptions?.codex?.networkAccess,
onStream: options.onStream,
openaiApiKey: options.openaiApiKey ?? resolveOpenaiApiKey(),
codexPathOverride: resolveCodexCliPath(projectConfig),
codexPathOverride: resolveCodexCliPath(),
outputSchema: options.outputSchema,
};
}

View File

@ -3,7 +3,7 @@
*/
import { callCopilot, callCopilotCustom, type CopilotCallOptions } from '../copilot/index.js';
import { resolveCopilotGithubToken, resolveCopilotCliPath, loadProjectConfig } from '../config/index.js';
import { resolveCopilotGithubToken, resolveCopilotCliPath } 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,7 +21,6 @@ function toCopilotOptions(options: ProviderCallOptions): CopilotCallOptions {
log.info('Copilot provider does not support outputSchema; ignoring');
}
const projectConfig = loadProjectConfig(options.cwd);
return {
cwd: options.cwd,
abortSignal: options.abortSignal,
@ -30,7 +29,7 @@ function toCopilotOptions(options: ProviderCallOptions): CopilotCallOptions {
permissionMode: options.permissionMode,
onStream: options.onStream,
copilotGithubToken: options.copilotGithubToken ?? resolveCopilotGithubToken(),
copilotCliPath: resolveCopilotCliPath(projectConfig),
copilotCliPath: resolveCopilotCliPath(),
};
}

View File

@ -3,7 +3,7 @@
*/
import { callCursor, callCursorCustom, type CursorCallOptions } from '../cursor/index.js';
import { resolveCursorApiKey, resolveCursorCliPath, loadProjectConfig } from '../config/index.js';
import { resolveCursorApiKey, resolveCursorCliPath } 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,7 +21,6 @@ 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,
@ -30,7 +29,7 @@ function toCursorOptions(options: ProviderCallOptions): CursorCallOptions {
permissionMode: options.permissionMode,
onStream: options.onStream,
cursorApiKey: options.cursorApiKey ?? resolveCursorApiKey(),
cursorCliPath: resolveCursorCliPath(projectConfig),
cursorCliPath: resolveCursorCliPath(),
};
}