diff --git a/builtins/en/config.yaml b/builtins/en/config.yaml index 5beca2d..6210f0e 100644 --- a/builtins/en/config.yaml +++ b/builtins/en/config.yaml @@ -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) diff --git a/builtins/ja/config.yaml b/builtins/ja/config.yaml index 75d21bc..6e0c99f 100644 --- a/builtins/ja/config.yaml +++ b/builtins/ja/config.yaml @@ -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 のタスク監視間隔ms(100-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) diff --git a/src/__tests__/cli-routing-pr-resolve.test.ts b/src/__tests__/cli-routing-pr-resolve.test.ts index 218822c..01add68 100644 --- a/src/__tests__/cli-routing-pr-resolve.test.ts +++ b/src/__tests__/cli-routing-pr-resolve.test.ts @@ -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 }); + }); }); }); diff --git a/src/__tests__/config-api-boundary.test.ts b/src/__tests__/config-api-boundary.test.ts new file mode 100644 index 0000000..879b01a --- /dev/null +++ b/src/__tests__/config-api-boundary.test.ts @@ -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); + }); +}); diff --git a/src/__tests__/config-env-overrides.test.ts b/src/__tests__/config-env-overrides.test.ts index 4ef95fc..53ccc38 100644 --- a/src/__tests__/config-env-overrides.test.ts +++ b/src/__tests__/config-env-overrides.test.ts @@ -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 = {}; 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 = {}; 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 = {}; 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'); }); }); diff --git a/src/__tests__/config-migrated-keys-contract.test.ts b/src/__tests__/config-migrated-keys-contract.test.ts new file mode 100644 index 0000000..b9a12f2 --- /dev/null +++ b/src/__tests__/config-migrated-keys-contract.test.ts @@ -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; +type IsNever = [T] extends [never] ? true : false; + +const globalConfigTypeBoundaryGuard: Assert< + IsNever> +> = true; +void globalConfigTypeBoundaryGuard; + +const projectConfigTypeBoundaryGuard: Assert< + IsNever> +> = 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); + }); +}); diff --git a/src/__tests__/config-modularity-boundary.test.ts b/src/__tests__/config-modularity-boundary.test.ts new file mode 100644 index 0000000..ebe66cd --- /dev/null +++ b/src/__tests__/config-modularity-boundary.test.ts @@ -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); + }); +}); diff --git a/src/__tests__/config-normalizers-provider-options.test.ts b/src/__tests__/config-normalizers-provider-options.test.ts new file mode 100644 index 0000000..67f0862 --- /dev/null +++ b/src/__tests__/config-normalizers-provider-options.test.ts @@ -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(); + }); +}); diff --git a/src/__tests__/config.test.ts b/src/__tests__/config.test.ts index 7dea5e8..63a5956 100644 --- a/src/__tests__/config.test.ts +++ b/src/__tests__/config.test.ts @@ -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); diff --git a/src/__tests__/globalConfig-defaults.test.ts b/src/__tests__/globalConfig-defaults.test.ts index fb6626b..5ea1842 100644 --- a/src/__tests__/globalConfig-defaults.test.ts +++ b/src/__tests__/globalConfig-defaults.test.ts @@ -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; + + 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; + 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; + 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[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).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; + 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).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).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; + 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).interactivePreviewMovements = 7; saveGlobalConfig(config); invalidateGlobalConfigCache(); const reloaded = loadGlobalConfig(); - expect(reloaded.interactivePreviewMovements).toBe(7); + expect((reloaded as Record).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).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; + 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( diff --git a/src/__tests__/globalConfig-resolvers.test.ts b/src/__tests__/globalConfig-resolvers.test.ts index 8a0d66d..b9f0940 100644 --- a/src/__tests__/globalConfig-resolvers.test.ts +++ b/src/__tests__/globalConfig-resolvers.test.ts @@ -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(); }); }); diff --git a/src/__tests__/globalConfig.test.ts b/src/__tests__/globalConfig.test.ts index 8fed982..35f25f2 100644 --- a/src/__tests__/globalConfig.test.ts +++ b/src/__tests__/globalConfig.test.ts @@ -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; diff --git a/src/__tests__/it-config-project-local-priority.test.ts b/src/__tests__/it-config-project-local-priority.test.ts new file mode 100644 index 0000000..f1a251e --- /dev/null +++ b/src/__tests__/it-config-project-local-priority.test.ts @@ -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; + 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', + }); + }); +}); diff --git a/src/__tests__/it-error-recovery.test.ts b/src/__tests__/it-error-recovery.test.ts index 75199ba..ee7a1a8 100644 --- a/src/__tests__/it-error-recovery.test.ts +++ b/src/__tests__/it-error-recovery.test.ts @@ -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), diff --git a/src/__tests__/it-piece-execution.test.ts b/src/__tests__/it-piece-execution.test.ts index 912fa8b..efb6d33 100644 --- a/src/__tests__/it-piece-execution.test.ts +++ b/src/__tests__/it-piece-execution.test.ts @@ -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), })); diff --git a/src/__tests__/it-three-phase-execution.test.ts b/src/__tests__/it-three-phase-execution.test.ts index 82338a4..0c52612 100644 --- a/src/__tests__/it-three-phase-execution.test.ts +++ b/src/__tests__/it-three-phase-execution.test.ts @@ -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), diff --git a/src/__tests__/models.test.ts b/src/__tests__/models.test.ts index 40b5289..cc72dcf 100644 --- a/src/__tests__/models.test.ts +++ b/src/__tests__/models.test.ts @@ -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).persona_providers as Record | undefined; - const coder = personaProviders?.coder as Record | 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: { diff --git a/src/__tests__/opencode-config.test.ts b/src/__tests__/opencode-config.test.ts index 6bccfe9..f980e6a 100644 --- a/src/__tests__/opencode-config.test.ts +++ b/src/__tests__/opencode-config.test.ts @@ -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', () => { diff --git a/src/__tests__/option-resolution-order.test.ts b/src/__tests__/option-resolution-order.test.ts index 94d4268..5f51a80 100644 --- a/src/__tests__/option-resolution-order.test.ts +++ b/src/__tests__/option-resolution-order.test.ts @@ -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' }, }, diff --git a/src/__tests__/paths.test.ts b/src/__tests__/paths.test.ts index bc209f9..2a18543 100644 --- a/src/__tests__/paths.test.ts +++ b/src/__tests__/paths.test.ts @@ -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', () => { diff --git a/src/__tests__/piece-dual-parallel.test.ts b/src/__tests__/piece-dual-parallel.test.ts index 47a3aab..a4b5c84 100644 --- a/src/__tests__/piece-dual-parallel.test.ts +++ b/src/__tests__/piece-dual-parallel.test.ts @@ -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; + return { + ...original, + loadGlobalConfig: () => ({ + language: 'en', + provider: 'claude', + autoFetch: false, + }), + }; +}); + import { loadPiece } from '../infra/config/index.js'; describe('dual piece parallel structure', () => { diff --git a/src/__tests__/piece-selection.test.ts b/src/__tests__/piece-selection.test.ts index 688bd61..34ee192 100644 --- a/src/__tests__/piece-selection.test.ts +++ b/src/__tests__/piece-selection.test.ts @@ -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']); diff --git a/src/__tests__/previewPrompts.test.ts b/src/__tests__/previewPrompts.test.ts new file mode 100644 index 0000000..0523ab2 --- /dev/null +++ b/src/__tests__/previewPrompts.test.ts @@ -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'); + }); +}); diff --git a/src/__tests__/projectConfig.test.ts b/src/__tests__/projectConfig.test.ts index c60ccd8..06310c0 100644 --- a/src/__tests__/projectConfig.test.ts +++ b/src/__tests__/projectConfig.test.ts @@ -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; + 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(); + }); + }); }); diff --git a/src/__tests__/reset-global-config.test.ts b/src/__tests__/reset-global-config.test.ts index ec19ad3..f93fbb0 100644 --- a/src/__tests__/reset-global-config.test.ts +++ b/src/__tests__/reset-global-config.test.ts @@ -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:'); }); }); diff --git a/src/__tests__/resolveConfigValue-call-chain.test.ts b/src/__tests__/resolveConfigValue-call-chain.test.ts new file mode 100644 index 0000000..8e0fdb5 --- /dev/null +++ b/src/__tests__/resolveConfigValue-call-chain.test.ts @@ -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(); + }); +}); diff --git a/src/__tests__/resolveConfigValue-no-defaultValue.test.ts b/src/__tests__/resolveConfigValue-no-defaultValue.test.ts index 787bc5a..10c94ab 100644 --- a/src/__tests__/resolveConfigValue-no-defaultValue.test.ts +++ b/src/__tests__/resolveConfigValue-no-defaultValue.test.ts @@ -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> = { + 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'); diff --git a/src/__tests__/test-setup.ts b/src/__tests__/test-setup.ts index 924773e..94f2289 100644 --- a/src/__tests__/test-setup.ts +++ b/src/__tests__/test-setup.ts @@ -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 }); + } +}); diff --git a/src/__tests__/watchTasks.test.ts b/src/__tests__/watchTasks.test.ts index 20f6a7b..a63c77c 100644 --- a/src/__tests__/watchTasks.test.ts +++ b/src/__tests__/watchTasks.test.ts @@ -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, + ); + }); }); diff --git a/src/agents/runner.ts b/src/agents/runner.ts index f406163..fb3cfbc 100644 --- a/src/agents/runner.ts +++ b/src/agents/runner.ts @@ -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, diff --git a/src/app/cli/routing.ts b/src/app/cli/routing.ts index e902e30..e3a9da6 100644 --- a/src/app/cli/routing.ts +++ b/src/app/cli/routing.ts @@ -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 { const opts = program.opts(); if (!pipelineMode && (opts.autoPr === true || opts.draft === true)) { @@ -38,7 +39,9 @@ export async function executeDefaultAction(task?: string): Promise { 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); diff --git a/src/core/models/persisted-global-config.ts b/src/core/models/persisted-global-config.ts index 024bf7f..7473910 100644 --- a/src/core/models/persisted-global-config.ts +++ b/src/core/models/persisted-global-config.ts @@ -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; /** 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; + /** 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.) */ diff --git a/src/core/models/schemas.ts b/src/core/models/schemas.ts index 41888df..615642c 100644 --- a/src/core/models/schemas.ts +++ b/src/core/models/schemas.ts @@ -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(); diff --git a/src/features/pieceSelection/index.ts b/src/features/pieceSelection/index.ts index 2f6fdfd..dac1de9 100644 --- a/src/features/pieceSelection/index.ts +++ b/src/features/pieceSelection/index.ts @@ -505,7 +505,7 @@ export async function selectPiece( ): Promise { 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); diff --git a/src/features/prompt/preview.ts b/src/features/prompt/preview.ts index a4faa7b..453747c 100644 --- a/src/features/prompt/preview.ts +++ b/src/features/prompt/preview.ts @@ -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 { - const identifier = pieceIdentifier ?? resolvePieceConfigValue(cwd, 'piece'); + const identifier = pieceIdentifier ?? resolvePieceConfigValue(cwd, 'piece') ?? DEFAULT_PIECE_NAME; const config = loadPieceByIdentifier(identifier, cwd); if (!config) { diff --git a/src/features/tasks/watch/index.ts b/src/features/tasks/watch/index.ts index 5a8018d..2e5a8fd 100644 --- a/src/features/tasks/watch/index.ts +++ b/src/features/tasks/watch/index.ts @@ -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 { - 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(); diff --git a/src/infra/config/configNormalizers.ts b/src/infra/config/configNormalizers.ts index 455fe92..4bdb537 100644 --- a/src/infra/config/configNormalizers.ts +++ b/src/infra/config/configNormalizers.ts @@ -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 }> | undefined, @@ -77,3 +79,78 @@ export function denormalizePieceOverrides( } return Object.keys(result).length > 0 ? result : undefined; } + +export function normalizePersonaProviders( + raw: Record | undefined, +): Record | 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 | undefined { + if (!providerOptions) { + return undefined; + } + + const raw: Record = {}; + 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 = {}; + 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; +} diff --git a/src/infra/config/env/config-env-overrides.ts b/src/infra/config/env/config-env-overrides.ts index 44c2bf8..45fd57f 100644 --- a/src/infra/config/env/config-env-overrides.ts +++ b/src/infra/config/env/config-env-overrides.ts @@ -77,7 +77,6 @@ function applyEnvOverrides(target: Record, 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): void { diff --git a/src/infra/config/global/cliPathValidator.ts b/src/infra/config/global/cliPathValidator.ts new file mode 100644 index 0000000..b355752 --- /dev/null +++ b/src/infra/config/global/cliPathValidator.ts @@ -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; +} diff --git a/src/infra/config/global/globalConfig.ts b/src/infra/config/global/globalConfig.ts index 658b3eb..0b8310d 100644 --- a/src/infra/config/global/globalConfig.ts +++ b/src/infra/config/global/globalConfig.ts @@ -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; -type RawPersonaProviderBlock = { - type: ProviderType; - model?: string; -}; -type RawProviderReference = ConfigProviderReference; - -/** 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 | undefined, -): Record | 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; +type IsNever = [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> +> = 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 = {}; - 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); - } 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 | 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 | undefined, - ), - providerOptions: normalizedProvider.providerOptions, - providerProfiles: normalizeProviderProfiles(parsed.provider_profiles as Record }> | 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 } | 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 = { - 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 = {}; - 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 = {}; - 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 = {}; - 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'; diff --git a/src/infra/config/global/globalConfigAccessors.ts b/src/infra/config/global/globalConfigAccessors.ts new file mode 100644 index 0000000..f13b92d --- /dev/null +++ b/src/infra/config/global/globalConfigAccessors.ts @@ -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); +} diff --git a/src/infra/config/global/globalConfigCore.ts b/src/infra/config/global/globalConfigCore.ts new file mode 100644 index 0000000..ae2fae2 --- /dev/null +++ b/src/infra/config/global/globalConfigCore.ts @@ -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; +type RawProviderReference = ConfigProviderReference; +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 = {}; + 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); + } 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 | 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 }> | 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 } | 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 = { + 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 = {}; + 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 = {}; + 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); +} diff --git a/src/infra/config/global/globalConfigResolvers.ts b/src/infra/config/global/globalConfigResolvers.ts new file mode 100644 index 0000000..da5f024 --- /dev/null +++ b/src/infra/config/global/globalConfigResolvers.ts @@ -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; +} diff --git a/src/infra/config/global/globalMigratedProjectLocalFallback.ts b/src/infra/config/global/globalMigratedProjectLocalFallback.ts new file mode 100644 index 0000000..b96f5e9 --- /dev/null +++ b/src/infra/config/global/globalMigratedProjectLocalFallback.ts @@ -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 +>; + +export function removeMigratedProjectLocalKeys(config: Record): void { + for (const metadata of Object.values(MIGRATED_PROJECT_LOCAL_CONFIG_METADATA)) { + delete config[metadata.legacyGlobalYamlKey]; + } +} + +export function extractMigratedProjectLocalFallback( + rawConfig: Record, +): GlobalMigratedProjectLocalFallback { + const rawMigratedConfig: Record = {}; + 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 | 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'], + }; +} diff --git a/src/infra/config/global/index.ts b/src/infra/config/global/index.ts index b89c3ab..c2c0603 100644 --- a/src/infra/config/global/index.ts +++ b/src/infra/config/global/index.ts @@ -3,7 +3,6 @@ */ export { - GlobalConfigManager, invalidateGlobalConfigCache, loadGlobalConfig, saveGlobalConfig, diff --git a/src/infra/config/migratedProjectLocalDefaults.ts b/src/infra/config/migratedProjectLocalDefaults.ts new file mode 100644 index 0000000..bf7c41e --- /dev/null +++ b/src/infra/config/migratedProjectLocalDefaults.ts @@ -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 = {}; +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>; diff --git a/src/infra/config/migratedProjectLocalKeys.ts b/src/infra/config/migratedProjectLocalKeys.ts new file mode 100644 index 0000000..b46e77f --- /dev/null +++ b/src/infra/config/migratedProjectLocalKeys.ts @@ -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; + +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[], +); diff --git a/src/infra/config/paths.ts b/src/infra/config/paths.ts index a5e36da..09ba533 100644 --- a/src/infra/config/paths.ts +++ b/src/infra/config/paths.ts @@ -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 diff --git a/src/infra/config/project/projectConfig.ts b/src/infra/config/project/projectConfig.ts index c86bd40..c122eff 100644 --- a/src/infra/config/project/projectConfig.ts +++ b/src/infra/config/project/projectConfig.ts @@ -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; +type IsNever = [T] extends [never] ? true : false; + +/** + * Compile-time guard: + * migrated fields must be owned by ProjectLocalConfig. + */ +const projectLocalConfigMigratedFieldGuard: + Assert>> = true; +void projectLocalConfigMigratedFieldGuard; -const SUBMODULES_ALL = 'all'; type ProviderType = NonNullable; type RawProviderReference = ConfigProviderReference; -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 | 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 | undefined { - if (!config) return undefined; - const raw: Record = {}; - 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 = {}; if (existsSync(configPath)) { + const content = readFileSync(configPath, 'utf-8'); + let parsed: unknown; try { - const content = readFileSync(configPath, 'utf-8'); - const parsed = (parse(content) as Record | null) ?? {}; - Object.assign(rawConfig, parsed); - } catch { - return { ...DEFAULT_PROJECT_CONFIG }; + 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); + } 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 | 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 }> | undefined), pieceOverrides: normalizePieceOverrides(piece_overrides as { quality_gates?: string[]; quality_gates_edit_only?: boolean; movements?: Record } | 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 = {}; + 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( projectDir: string, key: K, @@ -260,9 +284,6 @@ export function updateProjectConfig( saveProjectConfig(projectDir, config); } -/** - * Set current piece in project config - */ export function setCurrentPiece(projectDir: string, piece: string): void { updateProjectConfig(projectDir, 'piece', piece); } diff --git a/src/infra/config/project/projectConfigPaths.ts b/src/infra/config/project/projectConfigPaths.ts new file mode 100644 index 0000000..fcad940 --- /dev/null +++ b/src/infra/config/project/projectConfigPaths.ts @@ -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'); +} diff --git a/src/infra/config/project/projectConfigTransforms.ts b/src/infra/config/project/projectConfigTransforms.ts new file mode 100644 index 0000000..6df9399 --- /dev/null +++ b/src/infra/config/project/projectConfigTransforms.ts @@ -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 | 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 | undefined { + if (!config) return undefined; + const raw: Record = {}; + 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('.'); +} diff --git a/src/infra/config/providerModelCompatibility.ts b/src/infra/config/providerModelCompatibility.ts new file mode 100644 index 0000000..a4d0ba6 --- /dev/null +++ b/src/infra/config/providerModelCompatibility.ts @@ -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); + } +} diff --git a/src/infra/config/resolveConfigValue.ts b/src/infra/config/resolveConfigValue.ts index 3ec05bd..6207bd4 100644 --- a/src/infra/config/resolveConfigValue.ts +++ b/src/infra/config/resolveConfigValue.ts @@ -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 { mergeMode?: 'analytics'; pieceValue?: (pieceContext: PieceContext | undefined) => LoadedConfig[K] | undefined; } +type GlobalMigratedProjectLocalFallback = Partial< + Pick +>; 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 }>; +const MIGRATED_PROJECT_LOCAL_CONFIG_KEY_SET = new Set( + MIGRATED_PROJECT_LOCAL_CONFIG_KEYS as ConfigParameterKey[], +); + const RESOLUTION_REGISTRY: Partial<{ [K in ConfigParameterKey]: ResolutionRule }> = { piece: { layers: ['local', 'global'] }, provider: { @@ -76,7 +91,7 @@ const RESOLUTION_REGISTRY: Partial<{ [K in ConfigParameterKey]: ResolutionRule, - global: ReturnType, + global: ReturnType, ): LoadedConfig['analytics'] { const localAnalytics = project.analytics; const globalAnalytics = global.analytics; @@ -101,7 +116,7 @@ function resolveAnalyticsMerged( function resolveAnalyticsSource( project: ReturnType, - global: ReturnType, + global: ReturnType, ): ConfigValueSource { if (project.analytics !== undefined) return 'project'; if (global.analytics !== undefined) return 'global'; @@ -116,16 +131,21 @@ function getLocalLayerValue( } function getGlobalLayerValue( - global: ReturnType, + global: ReturnType, + 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( key: K, project: ReturnType, - global: ReturnType, + global: ReturnType, + globalMigratedProjectLocalFallback: GlobalMigratedProjectLocalFallback, options: ResolveConfigOptions | undefined, ): ResolvedConfigValue { const rule = (RESOLUTION_REGISTRY[key] ?? DEFAULT_RULE) as ResolutionRule; @@ -143,7 +163,7 @@ function resolveByRegistry( } 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( } } + 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( options?: ResolveConfigOptions, ): ResolvedConfigValue { 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( diff --git a/src/infra/config/resolvedConfig.ts b/src/infra/config/resolvedConfig.ts index f3266c5..54fce07 100644 --- a/src/infra/config/resolvedConfig.ts +++ b/src/infra/config/resolvedConfig.ts @@ -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 { + piece?: string; + logLevel: NonNullable; + minimalOutput: NonNullable; + verbose: NonNullable; + concurrency: NonNullable; + taskPollIntervalMs: NonNullable; + interactivePreviewMovements: NonNullable; } export type ConfigParameterKey = keyof LoadedConfig; diff --git a/src/infra/config/types.ts b/src/infra/config/types.ts index 5307a64..6aa45ad 100644 --- a/src/infra/config/types.ts +++ b/src/infra/config/types.ts @@ -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; + /** 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 */ diff --git a/src/infra/providers/claude.ts b/src/infra/providers/claude.ts index 1b5455c..6df66b6 100644 --- a/src/infra/providers/claude.ts +++ b/src/infra/providers/claude.ts @@ -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(), }; } diff --git a/src/infra/providers/codex.ts b/src/infra/providers/codex.ts index 160ead7..a32c6b9 100644 --- a/src/infra/providers/codex.ts +++ b/src/infra/providers/codex.ts @@ -4,7 +4,7 @@ import { execFileSync } from 'node:child_process'; import { callCodex, callCodexCustom, type CodexCallOptions } from '../codex/index.js'; -import { resolveOpenaiApiKey, resolveCodexCliPath, 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, }; } diff --git a/src/infra/providers/copilot.ts b/src/infra/providers/copilot.ts index 77ee946..ae8154c 100644 --- a/src/infra/providers/copilot.ts +++ b/src/infra/providers/copilot.ts @@ -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(), }; } diff --git a/src/infra/providers/cursor.ts b/src/infra/providers/cursor.ts index 8e1e2cd..be4ecd6 100644 --- a/src/infra/providers/cursor.ts +++ b/src/infra/providers/cursor.ts @@ -3,7 +3,7 @@ */ import { callCursor, callCursorCustom, type CursorCallOptions } from '../cursor/index.js'; -import { resolveCursorApiKey, 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(), }; }