Merge pull request #460 from nrslib/takt/452/refactor-config-structure
[#452] refactor-config-structure
This commit is contained in:
commit
8ffe0592ef
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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 });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
17
src/__tests__/config-api-boundary.test.ts
Normal file
17
src/__tests__/config-api-boundary.test.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
describe('config API boundary', () => {
|
||||
it('should expose migrated fallback loader from global config module', async () => {
|
||||
const globalConfig = await import('../infra/config/global/globalConfig.js');
|
||||
expect('loadGlobalMigratedProjectLocalFallback' in globalConfig).toBe(true);
|
||||
});
|
||||
|
||||
it('should not expose GlobalConfigManager from config public module', async () => {
|
||||
const configApi = await import('../infra/config/index.js');
|
||||
|
||||
expect('loadGlobalConfig' in configApi).toBe(true);
|
||||
expect('saveGlobalConfig' in configApi).toBe(true);
|
||||
expect('invalidateGlobalConfigCache' in configApi).toBe(true);
|
||||
expect('GlobalConfigManager' in configApi).toBe(false);
|
||||
});
|
||||
});
|
||||
@ -26,13 +26,13 @@ describe('config env overrides', () => {
|
||||
});
|
||||
|
||||
it('should apply global env overrides from generated env names', () => {
|
||||
process.env.TAKT_LOG_LEVEL = 'debug';
|
||||
process.env.TAKT_PROVIDER = 'codex';
|
||||
process.env.TAKT_PROVIDER_OPTIONS_CLAUDE_SANDBOX_ALLOW_UNSANDBOXED_COMMANDS = 'true';
|
||||
|
||||
const raw: Record<string, unknown> = {};
|
||||
applyGlobalConfigEnvOverrides(raw);
|
||||
|
||||
expect(raw.log_level).toBe('debug');
|
||||
expect(raw.provider).toBe('codex');
|
||||
expect(raw.provider_options).toEqual({
|
||||
claude: {
|
||||
sandbox: {
|
||||
@ -52,6 +52,8 @@ describe('config env overrides', () => {
|
||||
});
|
||||
|
||||
it('should apply project env overrides from generated env names', () => {
|
||||
process.env.TAKT_LOG_LEVEL = 'debug';
|
||||
process.env.TAKT_MODEL = 'gpt-5';
|
||||
process.env.TAKT_VERBOSE = 'true';
|
||||
process.env.TAKT_CONCURRENCY = '3';
|
||||
process.env.TAKT_ANALYTICS_EVENTS_PATH = '/tmp/project-analytics';
|
||||
@ -59,6 +61,8 @@ describe('config env overrides', () => {
|
||||
const raw: Record<string, unknown> = {};
|
||||
applyProjectConfigEnvOverrides(raw);
|
||||
|
||||
expect(raw.log_level).toBe('debug');
|
||||
expect(raw.model).toBe('gpt-5');
|
||||
expect(raw.verbose).toBe(true);
|
||||
expect(raw.concurrency).toBe(3);
|
||||
expect(raw.analytics).toEqual({
|
||||
@ -83,10 +87,18 @@ describe('config env overrides', () => {
|
||||
|
||||
it('should apply cursor API key override for global config', () => {
|
||||
process.env.TAKT_CURSOR_API_KEY = 'cursor-key-from-env';
|
||||
process.env.TAKT_GEMINI_API_KEY = 'gemini-key-from-env';
|
||||
process.env.TAKT_GOOGLE_API_KEY = 'google-key-from-env';
|
||||
process.env.TAKT_GROQ_API_KEY = 'groq-key-from-env';
|
||||
process.env.TAKT_OPENROUTER_API_KEY = 'openrouter-key-from-env';
|
||||
|
||||
const raw: Record<string, unknown> = {};
|
||||
applyGlobalConfigEnvOverrides(raw);
|
||||
|
||||
expect(raw.cursor_api_key).toBe('cursor-key-from-env');
|
||||
expect(raw.gemini_api_key).toBe('gemini-key-from-env');
|
||||
expect(raw.google_api_key).toBe('google-key-from-env');
|
||||
expect(raw.groq_api_key).toBe('groq-key-from-env');
|
||||
expect(raw.openrouter_api_key).toBe('openrouter-key-from-env');
|
||||
});
|
||||
});
|
||||
|
||||
31
src/__tests__/config-migrated-keys-contract.test.ts
Normal file
31
src/__tests__/config-migrated-keys-contract.test.ts
Normal file
@ -0,0 +1,31 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { PersistedGlobalConfig } from '../core/models/persisted-global-config.js';
|
||||
import type { ProjectLocalConfig } from '../infra/config/types.js';
|
||||
import type { MigratedProjectLocalConfigKey } from '../infra/config/migratedProjectLocalKeys.js';
|
||||
import * as migratedProjectLocalKeysModule from '../infra/config/migratedProjectLocalKeys.js';
|
||||
|
||||
type Assert<T extends true> = T;
|
||||
type IsNever<T> = [T] extends [never] ? true : false;
|
||||
|
||||
const globalConfigTypeBoundaryGuard: Assert<
|
||||
IsNever<Extract<keyof PersistedGlobalConfig, MigratedProjectLocalConfigKey>>
|
||||
> = true;
|
||||
void globalConfigTypeBoundaryGuard;
|
||||
|
||||
const projectConfigTypeBoundaryGuard: Assert<
|
||||
IsNever<Exclude<MigratedProjectLocalConfigKey, keyof ProjectLocalConfig>>
|
||||
> = true;
|
||||
void projectConfigTypeBoundaryGuard;
|
||||
|
||||
describe('migrated config key contracts', () => {
|
||||
it('should expose only runtime exports needed by migrated key metadata module', () => {
|
||||
expect(Object.keys(migratedProjectLocalKeysModule).sort()).toEqual([
|
||||
'MIGRATED_PROJECT_LOCAL_CONFIG_KEYS',
|
||||
'MIGRATED_PROJECT_LOCAL_CONFIG_METADATA',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should not expose helper exports that bypass metadata contract', () => {
|
||||
expect('isMigratedProjectLocalConfigKey' in migratedProjectLocalKeysModule).toBe(false);
|
||||
});
|
||||
});
|
||||
19
src/__tests__/config-modularity-boundary.test.ts
Normal file
19
src/__tests__/config-modularity-boundary.test.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
function getLineCount(path: string): number {
|
||||
const content = readFileSync(new URL(path, import.meta.url), 'utf-8');
|
||||
return content.trimEnd().split(/\r?\n/).length;
|
||||
}
|
||||
|
||||
describe('config module file-size boundary', () => {
|
||||
it('keeps globalConfigCore.ts under 300 lines', () => {
|
||||
const lineCount = getLineCount('../infra/config/global/globalConfigCore.ts');
|
||||
expect(lineCount).toBeLessThanOrEqual(300);
|
||||
});
|
||||
|
||||
it('keeps projectConfig.ts under 300 lines', () => {
|
||||
const lineCount = getLineCount('../infra/config/project/projectConfig.ts');
|
||||
expect(lineCount).toBeLessThanOrEqual(300);
|
||||
});
|
||||
});
|
||||
36
src/__tests__/config-normalizers-provider-options.test.ts
Normal file
36
src/__tests__/config-normalizers-provider-options.test.ts
Normal file
@ -0,0 +1,36 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { denormalizeProviderOptions } from '../infra/config/configNormalizers.js';
|
||||
|
||||
describe('denormalizeProviderOptions', () => {
|
||||
it('should convert camelCase provider options into persisted snake_case format', () => {
|
||||
const result = denormalizeProviderOptions({
|
||||
codex: { networkAccess: true },
|
||||
opencode: { networkAccess: false },
|
||||
claude: {
|
||||
sandbox: {
|
||||
allowUnsandboxedCommands: true,
|
||||
excludedCommands: ['npm test'],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
codex: { network_access: true },
|
||||
opencode: { network_access: false },
|
||||
claude: {
|
||||
sandbox: {
|
||||
allow_unsandboxed_commands: true,
|
||||
excluded_commands: ['npm test'],
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should return undefined when provider options do not contain persisted fields', () => {
|
||||
const result = denormalizeProviderOptions({
|
||||
claude: { sandbox: {} },
|
||||
});
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@ -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);
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { mkdirSync, rmSync, writeFileSync, existsSync } from 'node:fs';
|
||||
import { mkdirSync, rmSync, writeFileSync, existsSync, readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { vi } from 'vitest';
|
||||
@ -20,7 +20,11 @@ vi.mock('node:os', async () => {
|
||||
});
|
||||
|
||||
// Import after mocks are set up
|
||||
const { loadGlobalConfig, saveGlobalConfig, invalidateGlobalConfigCache } = await import('../infra/config/global/globalConfig.js');
|
||||
const {
|
||||
loadGlobalConfig,
|
||||
saveGlobalConfig,
|
||||
invalidateGlobalConfigCache,
|
||||
} = await import('../infra/config/global/globalConfig.js');
|
||||
const { getGlobalConfigPath } = await import('../infra/config/paths.js');
|
||||
|
||||
describe('loadGlobalConfig', () => {
|
||||
@ -39,11 +43,87 @@ describe('loadGlobalConfig', () => {
|
||||
const config = loadGlobalConfig();
|
||||
|
||||
expect(config.language).toBe('en');
|
||||
expect(config.logLevel).toBe('info');
|
||||
expect(config.provider).toBe('claude');
|
||||
expect(config.model).toBeUndefined();
|
||||
expect(config.verbose).toBe(false);
|
||||
expect(config.pipeline).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should not expose migrated project-local fields from global config', () => {
|
||||
const config = loadGlobalConfig() as Record<string, unknown>;
|
||||
|
||||
expect(config).not.toHaveProperty('logLevel');
|
||||
expect(config).not.toHaveProperty('pipeline');
|
||||
expect(config).not.toHaveProperty('personaProviders');
|
||||
expect(config).not.toHaveProperty('branchNameStrategy');
|
||||
expect(config).not.toHaveProperty('minimalOutput');
|
||||
expect(config).not.toHaveProperty('concurrency');
|
||||
expect(config).not.toHaveProperty('taskPollIntervalMs');
|
||||
expect(config).not.toHaveProperty('interactivePreviewMovements');
|
||||
expect(config).not.toHaveProperty('verbose');
|
||||
});
|
||||
|
||||
it('should accept migrated project-local keys in global config.yaml for resolver fallback', () => {
|
||||
const taktDir = join(testHomeDir, '.takt');
|
||||
mkdirSync(taktDir, { recursive: true });
|
||||
writeFileSync(
|
||||
getGlobalConfigPath(),
|
||||
[
|
||||
'language: en',
|
||||
'log_level: debug',
|
||||
'pipeline:',
|
||||
' default_branch_prefix: "global/"',
|
||||
'persona_providers:',
|
||||
' coder:',
|
||||
' provider: codex',
|
||||
'branch_name_strategy: ai',
|
||||
'minimal_output: true',
|
||||
'concurrency: 3',
|
||||
'task_poll_interval_ms: 1000',
|
||||
'interactive_preview_movements: 2',
|
||||
'verbose: true',
|
||||
].join('\n'),
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
expect(() => loadGlobalConfig()).not.toThrow();
|
||||
const config = loadGlobalConfig() as Record<string, unknown>;
|
||||
expect(config).not.toHaveProperty('logLevel');
|
||||
expect(config).not.toHaveProperty('pipeline');
|
||||
expect(config).not.toHaveProperty('personaProviders');
|
||||
expect(config).not.toHaveProperty('branchNameStrategy');
|
||||
expect(config).not.toHaveProperty('minimalOutput');
|
||||
expect(config).not.toHaveProperty('concurrency');
|
||||
expect(config).not.toHaveProperty('taskPollIntervalMs');
|
||||
expect(config).not.toHaveProperty('interactivePreviewMovements');
|
||||
expect(config).not.toHaveProperty('verbose');
|
||||
});
|
||||
|
||||
it('should not persist migrated project-local keys when saving global config', () => {
|
||||
const taktDir = join(testHomeDir, '.takt');
|
||||
mkdirSync(taktDir, { recursive: true });
|
||||
writeFileSync(getGlobalConfigPath(), 'language: en\n', 'utf-8');
|
||||
|
||||
const config = loadGlobalConfig() as Record<string, unknown>;
|
||||
config.logLevel = 'debug';
|
||||
config.pipeline = { defaultBranchPrefix: 'global/' };
|
||||
config.personaProviders = { coder: { provider: 'codex' } };
|
||||
config.branchNameStrategy = 'ai';
|
||||
config.minimalOutput = true;
|
||||
config.concurrency = 4;
|
||||
config.taskPollIntervalMs = 1200;
|
||||
config.interactivePreviewMovements = 1;
|
||||
config.verbose = true;
|
||||
saveGlobalConfig(config as Parameters<typeof saveGlobalConfig>[0]);
|
||||
|
||||
const raw = readFileSync(getGlobalConfigPath(), 'utf-8');
|
||||
expect(raw).not.toContain('log_level:');
|
||||
expect(raw).not.toContain('pipeline:');
|
||||
expect(raw).not.toContain('persona_providers:');
|
||||
expect(raw).not.toContain('branch_name_strategy:');
|
||||
expect(raw).not.toContain('minimal_output:');
|
||||
expect(raw).not.toContain('concurrency:');
|
||||
expect(raw).not.toContain('task_poll_interval_ms:');
|
||||
expect(raw).not.toContain('interactive_preview_movements:');
|
||||
expect(raw).not.toContain('verbose:');
|
||||
});
|
||||
|
||||
it('should return the same cached object on subsequent calls', () => {
|
||||
@ -67,7 +147,7 @@ describe('loadGlobalConfig', () => {
|
||||
mkdirSync(taktDir, { recursive: true });
|
||||
writeFileSync(
|
||||
getGlobalConfigPath(),
|
||||
'language: ja\nprovider: codex\nlog_level: debug\n',
|
||||
'language: ja\nprovider: codex\n',
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
@ -75,7 +155,7 @@ describe('loadGlobalConfig', () => {
|
||||
|
||||
expect(config.language).toBe('ja');
|
||||
expect(config.provider).toBe('codex');
|
||||
expect(config.logLevel).toBe('debug');
|
||||
expect((config as Record<string, unknown>).logLevel).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should load provider block from config.yaml and normalize model/providerOptions', () => {
|
||||
@ -101,28 +181,69 @@ describe('loadGlobalConfig', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should load persona_providers provider block and normalize to provider/model', () => {
|
||||
it('should preserve provider_options when saveGlobalConfig is called with loaded config', () => {
|
||||
const taktDir = join(testHomeDir, '.takt');
|
||||
mkdirSync(taktDir, { recursive: true });
|
||||
writeFileSync(
|
||||
getGlobalConfigPath(),
|
||||
[
|
||||
'persona_providers:',
|
||||
' coder:',
|
||||
' type: opencode',
|
||||
' model: openai/gpt-5',
|
||||
'language: en',
|
||||
'provider: claude',
|
||||
'provider_options:',
|
||||
' codex:',
|
||||
' network_access: true',
|
||||
' opencode:',
|
||||
' network_access: false',
|
||||
' claude:',
|
||||
' sandbox:',
|
||||
' allow_unsandboxed_commands: true',
|
||||
' excluded_commands:',
|
||||
' - git push',
|
||||
].join('\n'),
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
const config = loadGlobalConfig();
|
||||
const loaded = loadGlobalConfig();
|
||||
saveGlobalConfig(loaded);
|
||||
invalidateGlobalConfigCache();
|
||||
|
||||
expect(config.personaProviders).toEqual({
|
||||
coder: {
|
||||
provider: 'opencode',
|
||||
model: 'openai/gpt-5',
|
||||
const reloaded = loadGlobalConfig();
|
||||
expect(reloaded.providerOptions).toEqual({
|
||||
codex: { networkAccess: true },
|
||||
opencode: { networkAccess: false },
|
||||
claude: {
|
||||
sandbox: {
|
||||
allowUnsandboxedCommands: true,
|
||||
excludedCommands: ['git push'],
|
||||
},
|
||||
},
|
||||
});
|
||||
const raw = readFileSync(getGlobalConfigPath(), 'utf-8');
|
||||
expect(raw).toContain('provider_options:');
|
||||
});
|
||||
|
||||
it('should round-trip copilot global fields', () => {
|
||||
const taktDir = join(testHomeDir, '.takt');
|
||||
mkdirSync(taktDir, { recursive: true });
|
||||
writeFileSync(
|
||||
getGlobalConfigPath(),
|
||||
[
|
||||
'language: en',
|
||||
'copilot_cli_path: /tmp/copilot',
|
||||
'copilot_github_token: ghp_test_token',
|
||||
].join('\n'),
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
const loaded = loadGlobalConfig();
|
||||
expect(loaded.copilotCliPath).toBe('/tmp/copilot');
|
||||
expect(loaded.copilotGithubToken).toBe('ghp_test_token');
|
||||
|
||||
saveGlobalConfig(loaded);
|
||||
invalidateGlobalConfigCache();
|
||||
const reloaded = loadGlobalConfig();
|
||||
expect(reloaded.copilotCliPath).toBe('/tmp/copilot');
|
||||
expect(reloaded.copilotGithubToken).toBe('ghp_test_token');
|
||||
});
|
||||
|
||||
it('should apply env override for nested provider_options key', () => {
|
||||
@ -142,7 +263,7 @@ describe('loadGlobalConfig', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('should load pipeline config from config.yaml', () => {
|
||||
it('should accept pipeline in global config for migrated fallback', () => {
|
||||
const taktDir = join(testHomeDir, '.takt');
|
||||
mkdirSync(taktDir, { recursive: true });
|
||||
writeFileSync(
|
||||
@ -156,12 +277,9 @@ describe('loadGlobalConfig', () => {
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
const config = loadGlobalConfig();
|
||||
|
||||
expect(config.pipeline).toBeDefined();
|
||||
expect(config.pipeline!.defaultBranchPrefix).toBe('feat/');
|
||||
expect(config.pipeline!.commitMessageTemplate).toBe('fix: {title} (#{issue})');
|
||||
expect(config.pipeline!.prBodyTemplate).toBeUndefined();
|
||||
expect(() => loadGlobalConfig()).not.toThrow();
|
||||
const config = loadGlobalConfig() as Record<string, unknown>;
|
||||
expect(config).not.toHaveProperty('pipeline');
|
||||
});
|
||||
|
||||
it('should save and reload pipeline config', () => {
|
||||
@ -171,7 +289,7 @@ describe('loadGlobalConfig', () => {
|
||||
writeFileSync(getGlobalConfigPath(), 'language: en\n', 'utf-8');
|
||||
|
||||
const config = loadGlobalConfig();
|
||||
config.pipeline = {
|
||||
(config as Record<string, unknown>).pipeline = {
|
||||
defaultBranchPrefix: 'takt/',
|
||||
commitMessageTemplate: 'feat: {title} (#{issue})',
|
||||
};
|
||||
@ -179,9 +297,7 @@ describe('loadGlobalConfig', () => {
|
||||
invalidateGlobalConfigCache();
|
||||
|
||||
const reloaded = loadGlobalConfig();
|
||||
expect(reloaded.pipeline).toBeDefined();
|
||||
expect(reloaded.pipeline!.defaultBranchPrefix).toBe('takt/');
|
||||
expect(reloaded.pipeline!.commitMessageTemplate).toBe('feat: {title} (#{issue})');
|
||||
expect((reloaded as Record<string, unknown>).pipeline).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should load auto_pr config from config.yaml', () => {
|
||||
@ -440,7 +556,7 @@ describe('loadGlobalConfig', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should load interactive_preview_movements config from config.yaml', () => {
|
||||
it('should accept interactive_preview_movements in global config for migrated fallback', () => {
|
||||
const taktDir = join(testHomeDir, '.takt');
|
||||
mkdirSync(taktDir, { recursive: true });
|
||||
writeFileSync(
|
||||
@ -449,8 +565,9 @@ describe('loadGlobalConfig', () => {
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
const config = loadGlobalConfig();
|
||||
expect(config.interactivePreviewMovements).toBe(5);
|
||||
expect(() => loadGlobalConfig()).not.toThrow();
|
||||
const config = loadGlobalConfig() as Record<string, unknown>;
|
||||
expect(config).not.toHaveProperty('interactivePreviewMovements');
|
||||
});
|
||||
|
||||
it('should save and reload interactive_preview_movements config', () => {
|
||||
@ -459,12 +576,12 @@ describe('loadGlobalConfig', () => {
|
||||
writeFileSync(getGlobalConfigPath(), 'language: en\n', 'utf-8');
|
||||
|
||||
const config = loadGlobalConfig();
|
||||
config.interactivePreviewMovements = 7;
|
||||
(config as Record<string, unknown>).interactivePreviewMovements = 7;
|
||||
saveGlobalConfig(config);
|
||||
invalidateGlobalConfigCache();
|
||||
|
||||
const reloaded = loadGlobalConfig();
|
||||
expect(reloaded.interactivePreviewMovements).toBe(7);
|
||||
expect((reloaded as Record<string, unknown>).interactivePreviewMovements).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should default interactive_preview_movements to 3', () => {
|
||||
@ -473,10 +590,10 @@ describe('loadGlobalConfig', () => {
|
||||
writeFileSync(getGlobalConfigPath(), 'language: en\n', 'utf-8');
|
||||
|
||||
const config = loadGlobalConfig();
|
||||
expect(config.interactivePreviewMovements).toBe(3);
|
||||
expect((config as Record<string, unknown>).interactivePreviewMovements).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should accept interactive_preview_movements: 0 to disable', () => {
|
||||
it('should accept interactive_preview_movements=0 in global config for migrated fallback', () => {
|
||||
const taktDir = join(testHomeDir, '.takt');
|
||||
mkdirSync(taktDir, { recursive: true });
|
||||
writeFileSync(
|
||||
@ -485,107 +602,13 @@ describe('loadGlobalConfig', () => {
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
const config = loadGlobalConfig();
|
||||
expect(config.interactivePreviewMovements).toBe(0);
|
||||
expect(() => loadGlobalConfig()).not.toThrow();
|
||||
const config = loadGlobalConfig() as Record<string, unknown>;
|
||||
expect(config).not.toHaveProperty('interactivePreviewMovements');
|
||||
});
|
||||
|
||||
describe('persona_providers', () => {
|
||||
it('should load persona_providers from config.yaml', () => {
|
||||
const taktDir = join(testHomeDir, '.takt');
|
||||
mkdirSync(taktDir, { recursive: true });
|
||||
writeFileSync(
|
||||
getGlobalConfigPath(),
|
||||
[
|
||||
'language: en',
|
||||
'persona_providers:',
|
||||
' coder:',
|
||||
' provider: codex',
|
||||
' reviewer:',
|
||||
' provider: claude',
|
||||
' model: claude-3-5-sonnet-latest',
|
||||
].join('\n'),
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
const config = loadGlobalConfig();
|
||||
|
||||
expect(config.personaProviders).toEqual({
|
||||
coder: { provider: 'codex' },
|
||||
reviewer: { provider: 'claude', model: 'claude-3-5-sonnet-latest' },
|
||||
});
|
||||
});
|
||||
|
||||
it('should load persona_providers with model only (no provider)', () => {
|
||||
const taktDir = join(testHomeDir, '.takt');
|
||||
mkdirSync(taktDir, { recursive: true });
|
||||
writeFileSync(
|
||||
getGlobalConfigPath(),
|
||||
[
|
||||
'language: en',
|
||||
'persona_providers:',
|
||||
' coder:',
|
||||
' model: o3-mini',
|
||||
].join('\n'),
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
const config = loadGlobalConfig();
|
||||
|
||||
expect(config.personaProviders).toEqual({
|
||||
coder: { model: 'o3-mini' },
|
||||
});
|
||||
});
|
||||
|
||||
it('should save and reload persona_providers', () => {
|
||||
const taktDir = join(testHomeDir, '.takt');
|
||||
mkdirSync(taktDir, { recursive: true });
|
||||
writeFileSync(getGlobalConfigPath(), 'language: en\n', 'utf-8');
|
||||
|
||||
const config = loadGlobalConfig();
|
||||
config.personaProviders = { coder: { provider: 'codex', model: 'o3-mini' } };
|
||||
saveGlobalConfig(config);
|
||||
invalidateGlobalConfigCache();
|
||||
|
||||
const reloaded = loadGlobalConfig();
|
||||
expect(reloaded.personaProviders).toEqual({ coder: { provider: 'codex', model: 'o3-mini' } });
|
||||
});
|
||||
|
||||
it('should normalize legacy string format to object format', () => {
|
||||
const taktDir = join(testHomeDir, '.takt');
|
||||
mkdirSync(taktDir, { recursive: true });
|
||||
writeFileSync(
|
||||
getGlobalConfigPath(),
|
||||
'language: en\npersona_providers:\n coder: codex\n',
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
const config = loadGlobalConfig();
|
||||
|
||||
expect(config.personaProviders).toEqual({
|
||||
coder: { provider: 'codex' },
|
||||
});
|
||||
});
|
||||
|
||||
it('should have undefined personaProviders by default', () => {
|
||||
const config = loadGlobalConfig();
|
||||
expect(config.personaProviders).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should not save persona_providers when empty', () => {
|
||||
const taktDir = join(testHomeDir, '.takt');
|
||||
mkdirSync(taktDir, { recursive: true });
|
||||
writeFileSync(getGlobalConfigPath(), 'language: en\n', 'utf-8');
|
||||
|
||||
const config = loadGlobalConfig();
|
||||
config.personaProviders = {};
|
||||
saveGlobalConfig(config);
|
||||
invalidateGlobalConfigCache();
|
||||
|
||||
const reloaded = loadGlobalConfig();
|
||||
expect(reloaded.personaProviders).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should throw when persona entry has codex provider with Claude model alias', () => {
|
||||
it('should fail fast when persona_providers provider/model alias combination is invalid', () => {
|
||||
const taktDir = join(testHomeDir, '.takt');
|
||||
mkdirSync(taktDir, { recursive: true });
|
||||
writeFileSync(
|
||||
@ -594,34 +617,10 @@ describe('loadGlobalConfig', () => {
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
expect(() => loadGlobalConfig()).toThrow(/Claude model alias/);
|
||||
expect(() => loadGlobalConfig()).toThrow();
|
||||
});
|
||||
|
||||
it('should throw when persona entry has opencode provider without model', () => {
|
||||
const taktDir = join(testHomeDir, '.takt');
|
||||
mkdirSync(taktDir, { recursive: true });
|
||||
writeFileSync(
|
||||
getGlobalConfigPath(),
|
||||
'language: en\npersona_providers:\n reviewer:\n provider: opencode\n',
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
expect(() => loadGlobalConfig()).toThrow(/requires model/);
|
||||
});
|
||||
|
||||
it('should not throw when persona entry has opencode provider with compatible model', () => {
|
||||
const taktDir = join(testHomeDir, '.takt');
|
||||
mkdirSync(taktDir, { recursive: true });
|
||||
writeFileSync(
|
||||
getGlobalConfigPath(),
|
||||
'language: en\npersona_providers:\n coder:\n provider: opencode\n model: opencode/big-pickle\n',
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
expect(() => loadGlobalConfig()).not.toThrow();
|
||||
});
|
||||
|
||||
it('should throw when persona provider block includes provider options', () => {
|
||||
it('should fail fast when persona provider block includes provider options', () => {
|
||||
const taktDir = join(testHomeDir, '.takt');
|
||||
mkdirSync(taktDir, { recursive: true });
|
||||
writeFileSync(
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@ -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;
|
||||
|
||||
156
src/__tests__/it-config-project-local-priority.test.ts
Normal file
156
src/__tests__/it-config-project-local-priority.test.ts
Normal file
@ -0,0 +1,156 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { tmpdir } from 'node:os';
|
||||
|
||||
const testId = randomUUID();
|
||||
const rootDir = join(tmpdir(), `takt-it-config-project-priority-${testId}`);
|
||||
const projectDir = join(rootDir, 'project');
|
||||
|
||||
vi.mock('../infra/config/global/globalConfig.js', async (importOriginal) => {
|
||||
const original = await importOriginal() as Record<string, unknown>;
|
||||
const globalMigratedValues = {
|
||||
logLevel: 'info',
|
||||
pipeline: { defaultBranchPrefix: 'global/' },
|
||||
personaProviders: { coder: { provider: 'claude', model: 'claude-3-5-sonnet-latest' } },
|
||||
branchNameStrategy: 'ai',
|
||||
minimalOutput: false,
|
||||
concurrency: 2,
|
||||
taskPollIntervalMs: 2000,
|
||||
interactivePreviewMovements: 4,
|
||||
verbose: false,
|
||||
} as const;
|
||||
return {
|
||||
...original,
|
||||
loadGlobalConfig: () => ({
|
||||
language: 'en',
|
||||
provider: 'claude',
|
||||
autoFetch: false,
|
||||
}),
|
||||
loadGlobalMigratedProjectLocalFallback: () => globalMigratedValues,
|
||||
invalidateGlobalConfigCache: () => undefined,
|
||||
};
|
||||
});
|
||||
|
||||
const {
|
||||
resolveConfigValues,
|
||||
resolveConfigValueWithSource,
|
||||
invalidateAllResolvedConfigCache,
|
||||
invalidateGlobalConfigCache,
|
||||
} = await import('../infra/config/index.js');
|
||||
|
||||
describe('IT: migrated config keys should prefer project over global', () => {
|
||||
beforeEach(() => {
|
||||
mkdirSync(projectDir, { recursive: true });
|
||||
mkdirSync(join(projectDir, '.takt'), { recursive: true });
|
||||
|
||||
writeFileSync(
|
||||
join(projectDir, '.takt', 'config.yaml'),
|
||||
[
|
||||
'log_level: debug',
|
||||
'pipeline:',
|
||||
' default_branch_prefix: "project/"',
|
||||
'persona_providers:',
|
||||
' coder:',
|
||||
' provider: opencode',
|
||||
' model: opencode/big-pickle',
|
||||
'branch_name_strategy: ai',
|
||||
'minimal_output: true',
|
||||
'concurrency: 5',
|
||||
'task_poll_interval_ms: 1300',
|
||||
'interactive_preview_movements: 1',
|
||||
'verbose: true',
|
||||
].join('\n'),
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
invalidateGlobalConfigCache();
|
||||
invalidateAllResolvedConfigCache();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
invalidateGlobalConfigCache();
|
||||
invalidateAllResolvedConfigCache();
|
||||
if (existsSync(rootDir)) {
|
||||
rmSync(rootDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('should resolve migrated keys from project config when global has conflicting values', () => {
|
||||
const resolved = resolveConfigValues(projectDir, [
|
||||
'logLevel',
|
||||
'pipeline',
|
||||
'personaProviders',
|
||||
'branchNameStrategy',
|
||||
'minimalOutput',
|
||||
'concurrency',
|
||||
'taskPollIntervalMs',
|
||||
'interactivePreviewMovements',
|
||||
'verbose',
|
||||
]);
|
||||
|
||||
expect(resolved.logLevel).toBe('debug');
|
||||
expect(resolved.pipeline).toEqual({
|
||||
defaultBranchPrefix: 'project/',
|
||||
});
|
||||
expect(resolved.personaProviders).toEqual({
|
||||
coder: { provider: 'opencode', model: 'opencode/big-pickle' },
|
||||
});
|
||||
expect(resolved.branchNameStrategy).toBe('ai');
|
||||
expect(resolved.minimalOutput).toBe(true);
|
||||
expect(resolved.concurrency).toBe(5);
|
||||
expect(resolved.taskPollIntervalMs).toBe(1300);
|
||||
expect(resolved.interactivePreviewMovements).toBe(1);
|
||||
expect(resolved.verbose).toBe(true);
|
||||
});
|
||||
|
||||
it('should resolve migrated keys from global when project config does not set them', () => {
|
||||
writeFileSync(
|
||||
join(projectDir, '.takt', 'config.yaml'),
|
||||
'piece: default\n',
|
||||
'utf-8',
|
||||
);
|
||||
invalidateGlobalConfigCache();
|
||||
invalidateAllResolvedConfigCache();
|
||||
|
||||
const resolved = resolveConfigValues(projectDir, [
|
||||
'logLevel',
|
||||
'pipeline',
|
||||
'personaProviders',
|
||||
'branchNameStrategy',
|
||||
'minimalOutput',
|
||||
'concurrency',
|
||||
'taskPollIntervalMs',
|
||||
'interactivePreviewMovements',
|
||||
'verbose',
|
||||
]);
|
||||
|
||||
expect(resolved.logLevel).toBe('info');
|
||||
expect(resolved.pipeline).toEqual({ defaultBranchPrefix: 'global/' });
|
||||
expect(resolved.personaProviders).toEqual({
|
||||
coder: { provider: 'claude', model: 'claude-3-5-sonnet-latest' },
|
||||
});
|
||||
expect(resolved.branchNameStrategy).toBe('ai');
|
||||
expect(resolved.minimalOutput).toBe(false);
|
||||
expect(resolved.concurrency).toBe(2);
|
||||
expect(resolved.taskPollIntervalMs).toBe(2000);
|
||||
expect(resolved.interactivePreviewMovements).toBe(4);
|
||||
expect(resolved.verbose).toBe(false);
|
||||
});
|
||||
|
||||
it('should mark migrated key source as global when only global defines the key', () => {
|
||||
writeFileSync(
|
||||
join(projectDir, '.takt', 'config.yaml'),
|
||||
'piece: default\n',
|
||||
'utf-8',
|
||||
);
|
||||
invalidateGlobalConfigCache();
|
||||
invalidateAllResolvedConfigCache();
|
||||
|
||||
expect(resolveConfigValueWithSource(projectDir, 'logLevel')).toEqual({
|
||||
value: 'info',
|
||||
source: 'global',
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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),
|
||||
|
||||
@ -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),
|
||||
}));
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -577,21 +577,18 @@ describe('GlobalConfigSchema', () => {
|
||||
const config = {};
|
||||
const result = GlobalConfigSchema.parse(config);
|
||||
|
||||
expect(result.log_level).toBe('info');
|
||||
expect(result.provider).toBe('claude');
|
||||
expect(result.observability).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should accept valid config', () => {
|
||||
const config = {
|
||||
log_level: 'debug' as const,
|
||||
observability: {
|
||||
provider_events: false,
|
||||
},
|
||||
};
|
||||
|
||||
const result = GlobalConfigSchema.parse(config);
|
||||
expect(result.log_level).toBe('debug');
|
||||
expect(result.observability?.provider_events).toBe(false);
|
||||
});
|
||||
|
||||
@ -609,22 +606,7 @@ describe('GlobalConfigSchema', () => {
|
||||
expect(provider?.network_access).toBe(true);
|
||||
});
|
||||
|
||||
it('should parse persona_providers entry with provider object block', () => {
|
||||
const result = GlobalConfigSchema.parse({
|
||||
persona_providers: {
|
||||
coder: {
|
||||
type: 'opencode',
|
||||
model: 'openai/gpt-5',
|
||||
},
|
||||
},
|
||||
} as unknown);
|
||||
const personaProviders = (result as Record<string, unknown>).persona_providers as Record<string, unknown> | undefined;
|
||||
const coder = personaProviders?.coder as Record<string, unknown> | undefined;
|
||||
expect(coder?.type).toBe('opencode');
|
||||
expect(coder?.model).toBe('openai/gpt-5');
|
||||
});
|
||||
|
||||
it('should reject persona_providers provider object block with provider options', () => {
|
||||
it('should reject persona_providers because it is project-local only', () => {
|
||||
expect(() => GlobalConfigSchema.parse({
|
||||
persona_providers: {
|
||||
coder: {
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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' },
|
||||
},
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -10,7 +10,20 @@
|
||||
* - Sub-movement rules use simple approved/needs_fix conditions
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
|
||||
vi.mock('../infra/config/global/globalConfig.js', async (importOriginal) => {
|
||||
const original = await importOriginal() as Record<string, unknown>;
|
||||
return {
|
||||
...original,
|
||||
loadGlobalConfig: () => ({
|
||||
language: 'en',
|
||||
provider: 'claude',
|
||||
autoFetch: false,
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
import { loadPiece } from '../infra/config/index.js';
|
||||
|
||||
describe('dual piece parallel structure', () => {
|
||||
|
||||
@ -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']);
|
||||
|
||||
92
src/__tests__/previewPrompts.test.ts
Normal file
92
src/__tests__/previewPrompts.test.ts
Normal file
@ -0,0 +1,92 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const {
|
||||
mockLoadPieceByIdentifier,
|
||||
mockResolvePieceConfigValue,
|
||||
mockHeader,
|
||||
mockInfo,
|
||||
mockError,
|
||||
mockBlankLine,
|
||||
mockInstructionBuild,
|
||||
mockReportBuild,
|
||||
mockJudgmentBuild,
|
||||
} = vi.hoisted(() => ({
|
||||
mockLoadPieceByIdentifier: vi.fn(),
|
||||
mockResolvePieceConfigValue: vi.fn(),
|
||||
mockHeader: vi.fn(),
|
||||
mockInfo: vi.fn(),
|
||||
mockError: vi.fn(),
|
||||
mockBlankLine: vi.fn(),
|
||||
mockInstructionBuild: vi.fn(() => 'phase1'),
|
||||
mockReportBuild: vi.fn(() => 'phase2'),
|
||||
mockJudgmentBuild: vi.fn(() => 'phase3'),
|
||||
}));
|
||||
|
||||
vi.mock('../infra/config/index.js', () => ({
|
||||
loadPieceByIdentifier: mockLoadPieceByIdentifier,
|
||||
resolvePieceConfigValue: mockResolvePieceConfigValue,
|
||||
}));
|
||||
|
||||
vi.mock('../core/piece/instruction/InstructionBuilder.js', () => ({
|
||||
InstructionBuilder: vi.fn().mockImplementation(() => ({
|
||||
build: mockInstructionBuild,
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('../core/piece/instruction/ReportInstructionBuilder.js', () => ({
|
||||
ReportInstructionBuilder: vi.fn().mockImplementation(() => ({
|
||||
build: mockReportBuild,
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('../core/piece/instruction/StatusJudgmentBuilder.js', () => ({
|
||||
StatusJudgmentBuilder: vi.fn().mockImplementation(() => ({
|
||||
build: mockJudgmentBuild,
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('../core/piece/index.js', () => ({
|
||||
needsStatusJudgmentPhase: vi.fn(() => false),
|
||||
}));
|
||||
|
||||
vi.mock('../shared/ui/index.js', () => ({
|
||||
header: mockHeader,
|
||||
info: mockInfo,
|
||||
error: mockError,
|
||||
blankLine: mockBlankLine,
|
||||
}));
|
||||
|
||||
import { previewPrompts } from '../features/prompt/preview.js';
|
||||
|
||||
describe('previewPrompts', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockResolvePieceConfigValue.mockImplementation((_: string, key: string) => {
|
||||
if (key === 'piece') return undefined;
|
||||
if (key === 'language') return 'en';
|
||||
return undefined;
|
||||
});
|
||||
mockLoadPieceByIdentifier.mockReturnValue({
|
||||
name: 'default',
|
||||
maxMovements: 1,
|
||||
movements: [
|
||||
{
|
||||
name: 'implement',
|
||||
personaDisplayName: 'coder',
|
||||
outputContracts: [],
|
||||
},
|
||||
],
|
||||
});
|
||||
vi.spyOn(console, 'log').mockImplementation(() => undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('piece未設定時はDEFAULT_PIECE_NAMEでロードする', async () => {
|
||||
await previewPrompts('/project');
|
||||
|
||||
expect(mockLoadPieceByIdentifier).toHaveBeenCalledWith('default', '/project');
|
||||
});
|
||||
});
|
||||
@ -6,7 +6,7 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { loadProjectConfig, saveProjectConfig } from '../infra/config/project/projectConfig.js';
|
||||
@ -96,4 +96,260 @@ piece_overrides:
|
||||
expect(reloaded.pieceOverrides?.qualityGates).toEqual(['Test 1', 'Test 2']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('migrated project-local fields', () => {
|
||||
it('should load migrated fields from project config yaml', () => {
|
||||
const configPath = join(testDir, '.takt', 'config.yaml');
|
||||
const configContent = [
|
||||
'log_level: debug',
|
||||
'pipeline:',
|
||||
' default_branch_prefix: "proj/"',
|
||||
' commit_message_template: "feat: {title} (#{issue})"',
|
||||
'persona_providers:',
|
||||
' coder:',
|
||||
' provider: opencode',
|
||||
' model: opencode/big-pickle',
|
||||
'branch_name_strategy: ai',
|
||||
'minimal_output: true',
|
||||
'concurrency: 3',
|
||||
'task_poll_interval_ms: 1200',
|
||||
'interactive_preview_movements: 2',
|
||||
'verbose: true',
|
||||
].join('\n');
|
||||
writeFileSync(configPath, configContent, 'utf-8');
|
||||
|
||||
const loaded = loadProjectConfig(testDir) as Record<string, unknown>;
|
||||
expect(loaded.logLevel).toBe('debug');
|
||||
expect(loaded.pipeline).toEqual({
|
||||
defaultBranchPrefix: 'proj/',
|
||||
commitMessageTemplate: 'feat: {title} (#{issue})',
|
||||
});
|
||||
expect(loaded.personaProviders).toEqual({
|
||||
coder: { provider: 'opencode', model: 'opencode/big-pickle' },
|
||||
});
|
||||
expect(loaded.branchNameStrategy).toBe('ai');
|
||||
expect(loaded.minimalOutput).toBe(true);
|
||||
expect(loaded.concurrency).toBe(3);
|
||||
expect(loaded.taskPollIntervalMs).toBe(1200);
|
||||
expect(loaded.interactivePreviewMovements).toBe(2);
|
||||
expect(loaded.verbose).toBe(true);
|
||||
});
|
||||
|
||||
it('should save migrated fields as snake_case keys', () => {
|
||||
const config = {
|
||||
logLevel: 'warn',
|
||||
pipeline: {
|
||||
defaultBranchPrefix: 'task/',
|
||||
prBodyTemplate: 'Body {report}',
|
||||
},
|
||||
personaProviders: {
|
||||
reviewer: { provider: 'codex', model: 'gpt-5' },
|
||||
},
|
||||
branchNameStrategy: 'romaji',
|
||||
minimalOutput: true,
|
||||
concurrency: 4,
|
||||
taskPollIntervalMs: 1500,
|
||||
interactivePreviewMovements: 1,
|
||||
verbose: false,
|
||||
} as ProjectLocalConfig;
|
||||
|
||||
saveProjectConfig(testDir, config);
|
||||
|
||||
const raw = readFileSync(join(testDir, '.takt', 'config.yaml'), 'utf-8');
|
||||
expect(raw).toContain('log_level: warn');
|
||||
expect(raw).toContain('pipeline:');
|
||||
expect(raw).toContain('default_branch_prefix: task/');
|
||||
expect(raw).toContain('pr_body_template: Body {report}');
|
||||
expect(raw).toContain('persona_providers:');
|
||||
expect(raw).toContain('provider: codex');
|
||||
expect(raw).toContain('branch_name_strategy: romaji');
|
||||
expect(raw).toContain('minimal_output: true');
|
||||
expect(raw).toContain('concurrency: 4');
|
||||
expect(raw).toContain('task_poll_interval_ms: 1500');
|
||||
expect(raw).toContain('interactive_preview_movements: 1');
|
||||
expect(raw).not.toContain('verbose: false');
|
||||
});
|
||||
|
||||
it('should not persist schema-injected default values on save', () => {
|
||||
const loaded = loadProjectConfig(testDir);
|
||||
saveProjectConfig(testDir, loaded);
|
||||
|
||||
const raw = readFileSync(join(testDir, '.takt', 'config.yaml'), 'utf-8');
|
||||
expect(raw).not.toContain('log_level: info');
|
||||
expect(raw).not.toContain('minimal_output: false');
|
||||
expect(raw).not.toContain('concurrency: 1');
|
||||
expect(raw).not.toContain('task_poll_interval_ms: 500');
|
||||
expect(raw).not.toContain('interactive_preview_movements: 3');
|
||||
expect(raw).not.toContain('verbose: false');
|
||||
});
|
||||
|
||||
it('should fail fast when project config contains global-only cli path keys', () => {
|
||||
const configPath = join(testDir, '.takt', 'config.yaml');
|
||||
writeFileSync(
|
||||
configPath,
|
||||
[
|
||||
'claude_cli_path: /tmp/bin/claude',
|
||||
'codex_cli_path: /tmp/bin/codex',
|
||||
'cursor_cli_path: /tmp/bin/cursor-agent',
|
||||
'copilot_cli_path: /tmp/bin/copilot',
|
||||
].join('\n'),
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
expect(() => loadProjectConfig(testDir)).toThrow(/unrecognized/i);
|
||||
});
|
||||
|
||||
it('should fail fast when project config contains other global-only keys', () => {
|
||||
const configPath = join(testDir, '.takt', 'config.yaml');
|
||||
writeFileSync(
|
||||
configPath,
|
||||
[
|
||||
'language: ja',
|
||||
'anthropic_api_key: sk-test',
|
||||
].join('\n'),
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
expect(() => loadProjectConfig(testDir)).toThrow(/unrecognized/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fail fast validation', () => {
|
||||
it('should throw on invalid yaml syntax', () => {
|
||||
const configPath = join(testDir, '.takt', 'config.yaml');
|
||||
writeFileSync(configPath, 'pipeline: [unclosed', 'utf-8');
|
||||
|
||||
expect(() => loadProjectConfig(testDir)).toThrow(/failed to parse/);
|
||||
});
|
||||
|
||||
it('should throw when yaml root is not an object', () => {
|
||||
const configPath = join(testDir, '.takt', 'config.yaml');
|
||||
writeFileSync(configPath, '- item1\n- item2\n', 'utf-8');
|
||||
|
||||
expect(() => loadProjectConfig(testDir)).toThrow(/must be a YAML object/);
|
||||
});
|
||||
|
||||
it('should throw when pipeline has unknown field', () => {
|
||||
const configPath = join(testDir, '.takt', 'config.yaml');
|
||||
writeFileSync(
|
||||
configPath,
|
||||
[
|
||||
'pipeline:',
|
||||
' default_branch_prefix: "task/"',
|
||||
' unknown_field: "x"',
|
||||
].join('\n'),
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
expect(() => loadProjectConfig(testDir)).toThrow(/Configuration error: invalid pipeline/);
|
||||
});
|
||||
|
||||
it('should throw when pipeline value has invalid type', () => {
|
||||
const configPath = join(testDir, '.takt', 'config.yaml');
|
||||
writeFileSync(
|
||||
configPath,
|
||||
[
|
||||
'pipeline:',
|
||||
' commit_message_template: 123',
|
||||
].join('\n'),
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
expect(() => loadProjectConfig(testDir)).toThrow(/Configuration error: invalid pipeline\.commit_message_template/);
|
||||
});
|
||||
|
||||
it('should throw when persona_providers entry has unknown field', () => {
|
||||
const configPath = join(testDir, '.takt', 'config.yaml');
|
||||
writeFileSync(
|
||||
configPath,
|
||||
[
|
||||
'persona_providers:',
|
||||
' coder:',
|
||||
' provider: codex',
|
||||
' unsupported: true',
|
||||
].join('\n'),
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
expect(() => loadProjectConfig(testDir)).toThrow(/Configuration error: invalid persona_providers\.coder/);
|
||||
});
|
||||
|
||||
it('should throw when persona_providers entry has invalid provider', () => {
|
||||
const configPath = join(testDir, '.takt', 'config.yaml');
|
||||
writeFileSync(
|
||||
configPath,
|
||||
[
|
||||
'persona_providers:',
|
||||
' coder:',
|
||||
' provider: invalid-provider',
|
||||
].join('\n'),
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
expect(() => loadProjectConfig(testDir)).toThrow(/Configuration error: invalid persona_providers\.coder/);
|
||||
});
|
||||
|
||||
it('should throw when persona_providers entry has both provider and type', () => {
|
||||
const configPath = join(testDir, '.takt', 'config.yaml');
|
||||
writeFileSync(
|
||||
configPath,
|
||||
[
|
||||
'persona_providers:',
|
||||
' coder:',
|
||||
' provider: codex',
|
||||
' type: opencode',
|
||||
].join('\n'),
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
expect(() => loadProjectConfig(testDir)).toThrow(/Configuration error: invalid persona_providers\.coder/);
|
||||
});
|
||||
|
||||
it('should throw when persona_providers entry has codex provider with Claude model alias', () => {
|
||||
const configPath = join(testDir, '.takt', 'config.yaml');
|
||||
writeFileSync(
|
||||
configPath,
|
||||
[
|
||||
'persona_providers:',
|
||||
' coder:',
|
||||
' provider: codex',
|
||||
' model: opus',
|
||||
].join('\n'),
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
expect(() => loadProjectConfig(testDir)).toThrow(/Claude model alias/);
|
||||
});
|
||||
|
||||
it('should throw when persona_providers entry has opencode provider without model', () => {
|
||||
const configPath = join(testDir, '.takt', 'config.yaml');
|
||||
writeFileSync(
|
||||
configPath,
|
||||
[
|
||||
'persona_providers:',
|
||||
' reviewer:',
|
||||
' provider: opencode',
|
||||
].join('\n'),
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
expect(() => loadProjectConfig(testDir)).toThrow(/provider 'opencode' requires model/);
|
||||
});
|
||||
|
||||
it('should allow persona_providers entry with opencode provider and provider/model value', () => {
|
||||
const configPath = join(testDir, '.takt', 'config.yaml');
|
||||
writeFileSync(
|
||||
configPath,
|
||||
[
|
||||
'persona_providers:',
|
||||
' coder:',
|
||||
' provider: opencode',
|
||||
' model: opencode/big-pickle',
|
||||
].join('\n'),
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
expect(() => loadProjectConfig(testDir)).not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -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:');
|
||||
});
|
||||
});
|
||||
|
||||
22
src/__tests__/resolveConfigValue-call-chain.test.ts
Normal file
22
src/__tests__/resolveConfigValue-call-chain.test.ts
Normal file
@ -0,0 +1,22 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
describe('resolveConfigValue call-chain contract', () => {
|
||||
afterEach(() => {
|
||||
vi.resetModules();
|
||||
vi.doUnmock('../infra/config/global/globalConfig.js');
|
||||
vi.doUnmock('../infra/config/project/projectConfig.js');
|
||||
});
|
||||
|
||||
it('should fail fast when migrated fallback loader is missing and migrated key is resolved', async () => {
|
||||
vi.doMock('../infra/config/project/projectConfig.js', () => ({
|
||||
loadProjectConfig: () => ({ piece: 'default' }),
|
||||
}));
|
||||
vi.doMock('../infra/config/global/globalConfig.js', () => ({
|
||||
loadGlobalConfig: () => ({ language: 'en' }),
|
||||
}));
|
||||
|
||||
const { resolveConfigValue } = await import('../infra/config/resolveConfigValue.js');
|
||||
|
||||
expect(() => resolveConfigValue('/tmp/takt-project', 'logLevel')).toThrow();
|
||||
});
|
||||
});
|
||||
@ -26,9 +26,16 @@ vi.mock('../infra/config/paths.js', async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
const { resolveConfigValue, resolveConfigValueWithSource, invalidateAllResolvedConfigCache } = await import('../infra/config/resolveConfigValue.js');
|
||||
const {
|
||||
resolveConfigValue,
|
||||
resolveConfigValueWithSource,
|
||||
invalidateAllResolvedConfigCache,
|
||||
} = await import('../infra/config/resolveConfigValue.js');
|
||||
const { invalidateGlobalConfigCache } = await import('../infra/config/global/globalConfig.js');
|
||||
const { getProjectConfigDir } = await import('../infra/config/paths.js');
|
||||
const { MIGRATED_PROJECT_LOCAL_CONFIG_KEYS } = await import('../infra/config/migratedProjectLocalKeys.js');
|
||||
const { MIGRATED_PROJECT_LOCAL_DEFAULTS } = await import('../infra/config/migratedProjectLocalDefaults.js');
|
||||
type ConfigParameterKey = import('../infra/config/resolveConfigValue.js').ConfigParameterKey;
|
||||
|
||||
describe('RESOLUTION_REGISTRY defaultValue removal', () => {
|
||||
let projectDir: string;
|
||||
@ -82,27 +89,29 @@ describe('RESOLUTION_REGISTRY defaultValue removal', () => {
|
||||
});
|
||||
|
||||
describe('verbose', () => {
|
||||
it('should resolve verbose to false via schema default when not set anywhere', () => {
|
||||
it('should resolve verbose to false via resolver default when not set anywhere', () => {
|
||||
const value = resolveConfigValue(projectDir, 'verbose');
|
||||
expect(value).toBe(false);
|
||||
});
|
||||
|
||||
it('should report source as global when verbose comes from schema default', () => {
|
||||
it('should report source as default when verbose comes from resolver default', () => {
|
||||
const result = resolveConfigValueWithSource(projectDir, 'verbose');
|
||||
expect(result.value).toBe(false);
|
||||
expect(result.source).toBe('global');
|
||||
expect(result.source).toBe('default');
|
||||
});
|
||||
|
||||
it('should resolve verbose from global config when explicitly set', () => {
|
||||
writeFileSync(globalConfigPath, 'language: en\nverbose: true\n', 'utf-8');
|
||||
it('should resolve verbose default when project does not set it', () => {
|
||||
writeFileSync(globalConfigPath, 'language: en\n', 'utf-8');
|
||||
invalidateGlobalConfigCache();
|
||||
|
||||
const value = resolveConfigValue(projectDir, 'verbose');
|
||||
expect(value).toBe(true);
|
||||
expect(resolveConfigValueWithSource(projectDir, 'verbose')).toEqual({
|
||||
value: false,
|
||||
source: 'default',
|
||||
});
|
||||
});
|
||||
|
||||
it('should resolve verbose from project config over global', () => {
|
||||
writeFileSync(globalConfigPath, 'language: en\nverbose: false\n', 'utf-8');
|
||||
it('should resolve verbose from project config when project sets it', () => {
|
||||
writeFileSync(globalConfigPath, 'language: en\n', 'utf-8');
|
||||
invalidateGlobalConfigCache();
|
||||
|
||||
const configDir = getProjectConfigDir(projectDir);
|
||||
@ -114,6 +123,255 @@ describe('RESOLUTION_REGISTRY defaultValue removal', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('project-local priority for migrated keys', () => {
|
||||
it.each([
|
||||
{
|
||||
key: 'logLevel',
|
||||
projectYaml: 'log_level: debug\n',
|
||||
expected: 'debug',
|
||||
},
|
||||
{
|
||||
key: 'minimalOutput',
|
||||
projectYaml: 'minimal_output: true\n',
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
key: 'branchNameStrategy',
|
||||
projectYaml: 'branch_name_strategy: ai\n',
|
||||
expected: 'ai',
|
||||
},
|
||||
{
|
||||
key: 'taskPollIntervalMs',
|
||||
projectYaml: 'task_poll_interval_ms: 1200\n',
|
||||
expected: 1200,
|
||||
},
|
||||
{
|
||||
key: 'interactivePreviewMovements',
|
||||
projectYaml: 'interactive_preview_movements: 1\n',
|
||||
expected: 1,
|
||||
},
|
||||
{
|
||||
key: 'concurrency',
|
||||
projectYaml: 'concurrency: 3\n',
|
||||
expected: 3,
|
||||
},
|
||||
{
|
||||
key: 'verbose',
|
||||
projectYaml: 'verbose: true\n',
|
||||
expected: true,
|
||||
},
|
||||
])('should resolve $key from project config', ({ key, projectYaml, expected }) => {
|
||||
writeFileSync(globalConfigPath, 'language: en\n', 'utf-8');
|
||||
invalidateGlobalConfigCache();
|
||||
|
||||
const configDir = getProjectConfigDir(projectDir);
|
||||
mkdirSync(configDir, { recursive: true });
|
||||
writeFileSync(join(configDir, 'config.yaml'), projectYaml, 'utf-8');
|
||||
|
||||
const result = resolveConfigValueWithSource(projectDir, key as ConfigParameterKey);
|
||||
expect(result.value).toBe(expected);
|
||||
expect(result.source).toBe('project');
|
||||
});
|
||||
|
||||
it('should resolve personaProviders from project config', () => {
|
||||
writeFileSync(globalConfigPath, 'language: en\n', 'utf-8');
|
||||
invalidateGlobalConfigCache();
|
||||
|
||||
const configDir = getProjectConfigDir(projectDir);
|
||||
mkdirSync(configDir, { recursive: true });
|
||||
writeFileSync(
|
||||
join(configDir, 'config.yaml'),
|
||||
[
|
||||
'persona_providers:',
|
||||
' coder:',
|
||||
' provider: opencode',
|
||||
' model: project-model',
|
||||
].join('\n'),
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
const result = resolveConfigValueWithSource(projectDir, 'personaProviders' as ConfigParameterKey);
|
||||
expect(result.source).toBe('project');
|
||||
expect(result.value).toEqual({
|
||||
coder: {
|
||||
provider: 'opencode',
|
||||
model: 'project-model',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should resolve pipeline from project config', () => {
|
||||
writeFileSync(globalConfigPath, 'language: en\n', 'utf-8');
|
||||
invalidateGlobalConfigCache();
|
||||
|
||||
const configDir = getProjectConfigDir(projectDir);
|
||||
mkdirSync(configDir, { recursive: true });
|
||||
writeFileSync(
|
||||
join(configDir, 'config.yaml'),
|
||||
[
|
||||
'pipeline:',
|
||||
' default_branch_prefix: "project/"',
|
||||
' commit_message_template: "feat: {title} (#{issue})"',
|
||||
].join('\n'),
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
const result = resolveConfigValueWithSource(projectDir, 'pipeline' as ConfigParameterKey);
|
||||
expect(result.source).toBe('project');
|
||||
expect(result.value).toEqual({
|
||||
defaultBranchPrefix: 'project/',
|
||||
commitMessageTemplate: 'feat: {title} (#{issue})',
|
||||
});
|
||||
});
|
||||
|
||||
it('should resolve migrated non-default keys as undefined when project keys are unset', () => {
|
||||
const configDir = getProjectConfigDir(projectDir);
|
||||
mkdirSync(configDir, { recursive: true });
|
||||
writeFileSync(join(configDir, 'config.yaml'), 'piece: default\n', 'utf-8');
|
||||
writeFileSync(
|
||||
globalConfigPath,
|
||||
['language: en'].join('\n'),
|
||||
'utf-8',
|
||||
);
|
||||
invalidateGlobalConfigCache();
|
||||
|
||||
const pipelineResult = resolveConfigValueWithSource(projectDir, 'pipeline' as ConfigParameterKey);
|
||||
const personaResult = resolveConfigValueWithSource(projectDir, 'personaProviders' as ConfigParameterKey);
|
||||
const branchStrategyResult = resolveConfigValueWithSource(projectDir, 'branchNameStrategy' as ConfigParameterKey);
|
||||
|
||||
expect(pipelineResult).toEqual({
|
||||
value: undefined,
|
||||
source: 'default',
|
||||
});
|
||||
expect(personaResult).toEqual({
|
||||
value: undefined,
|
||||
source: 'default',
|
||||
});
|
||||
expect(branchStrategyResult).toEqual({
|
||||
value: undefined,
|
||||
source: 'default',
|
||||
});
|
||||
});
|
||||
|
||||
it('should resolve default-backed migrated keys from defaults when project keys are unset', () => {
|
||||
const configDir = getProjectConfigDir(projectDir);
|
||||
mkdirSync(configDir, { recursive: true });
|
||||
writeFileSync(join(configDir, 'config.yaml'), 'piece: default\n', 'utf-8');
|
||||
writeFileSync(
|
||||
globalConfigPath,
|
||||
['language: en'].join('\n'),
|
||||
'utf-8',
|
||||
);
|
||||
invalidateGlobalConfigCache();
|
||||
|
||||
expect(resolveConfigValueWithSource(projectDir, 'logLevel')).toEqual({ value: 'info', source: 'default' });
|
||||
expect(resolveConfigValueWithSource(projectDir, 'minimalOutput')).toEqual({ value: false, source: 'default' });
|
||||
expect(resolveConfigValueWithSource(projectDir, 'concurrency')).toEqual({ value: 1, source: 'default' });
|
||||
expect(resolveConfigValueWithSource(projectDir, 'taskPollIntervalMs')).toEqual({ value: 500, source: 'default' });
|
||||
expect(resolveConfigValueWithSource(projectDir, 'interactivePreviewMovements')).toEqual({ value: 3, source: 'default' });
|
||||
});
|
||||
|
||||
it('should resolve migrated keys from global legacy fields when project keys are unset', () => {
|
||||
writeFileSync(
|
||||
globalConfigPath,
|
||||
[
|
||||
'language: en',
|
||||
'log_level: warn',
|
||||
'pipeline:',
|
||||
' default_branch_prefix: "legacy/"',
|
||||
'persona_providers:',
|
||||
' coder:',
|
||||
' provider: codex',
|
||||
' model: gpt-5',
|
||||
'branch_name_strategy: ai',
|
||||
'minimal_output: true',
|
||||
'verbose: true',
|
||||
'concurrency: 3',
|
||||
'task_poll_interval_ms: 1200',
|
||||
'interactive_preview_movements: 2',
|
||||
].join('\n'),
|
||||
'utf-8',
|
||||
);
|
||||
invalidateGlobalConfigCache();
|
||||
|
||||
expect(resolveConfigValueWithSource(projectDir, 'logLevel')).toEqual({ value: 'warn', source: 'global' });
|
||||
expect(resolveConfigValueWithSource(projectDir, 'pipeline')).toEqual({
|
||||
value: { defaultBranchPrefix: 'legacy/' },
|
||||
source: 'global',
|
||||
});
|
||||
expect(resolveConfigValueWithSource(projectDir, 'personaProviders')).toEqual({
|
||||
value: { coder: { provider: 'codex', model: 'gpt-5' } },
|
||||
source: 'global',
|
||||
});
|
||||
expect(resolveConfigValueWithSource(projectDir, 'branchNameStrategy')).toEqual({
|
||||
value: 'ai',
|
||||
source: 'global',
|
||||
});
|
||||
expect(resolveConfigValueWithSource(projectDir, 'minimalOutput')).toEqual({ value: true, source: 'global' });
|
||||
expect(resolveConfigValueWithSource(projectDir, 'verbose')).toEqual({ value: true, source: 'global' });
|
||||
expect(resolveConfigValueWithSource(projectDir, 'concurrency')).toEqual({ value: 3, source: 'global' });
|
||||
expect(resolveConfigValueWithSource(projectDir, 'taskPollIntervalMs')).toEqual({ value: 1200, source: 'global' });
|
||||
expect(resolveConfigValueWithSource(projectDir, 'interactivePreviewMovements')).toEqual({
|
||||
value: 2,
|
||||
source: 'global',
|
||||
});
|
||||
});
|
||||
|
||||
it('should resolve migrated numeric key from default when project key is unset', () => {
|
||||
writeFileSync(globalConfigPath, 'language: en\n', 'utf-8');
|
||||
invalidateGlobalConfigCache();
|
||||
|
||||
expect(resolveConfigValueWithSource(projectDir, 'concurrency' as ConfigParameterKey)).toEqual({
|
||||
value: 1,
|
||||
source: 'default',
|
||||
});
|
||||
});
|
||||
|
||||
it('should resolve migrated persona_providers key from default when project key is unset', () => {
|
||||
writeFileSync(
|
||||
globalConfigPath,
|
||||
['language: en'].join('\n'),
|
||||
'utf-8',
|
||||
);
|
||||
invalidateGlobalConfigCache();
|
||||
|
||||
expect(resolveConfigValueWithSource(projectDir, 'personaProviders' as ConfigParameterKey)).toEqual({
|
||||
value: undefined,
|
||||
source: 'default',
|
||||
});
|
||||
});
|
||||
|
||||
it('should resolve all migrated keys from project or defaults when project config has no migrated keys', () => {
|
||||
const configDir = getProjectConfigDir(projectDir);
|
||||
mkdirSync(configDir, { recursive: true });
|
||||
writeFileSync(join(configDir, 'config.yaml'), 'piece: default\n', 'utf-8');
|
||||
writeFileSync(
|
||||
globalConfigPath,
|
||||
['language: en'].join('\n'),
|
||||
'utf-8',
|
||||
);
|
||||
invalidateGlobalConfigCache();
|
||||
|
||||
const expectedByKey: Partial<Record<ConfigParameterKey, unknown>> = {
|
||||
logLevel: MIGRATED_PROJECT_LOCAL_DEFAULTS.logLevel,
|
||||
pipeline: undefined,
|
||||
personaProviders: undefined,
|
||||
branchNameStrategy: undefined,
|
||||
minimalOutput: MIGRATED_PROJECT_LOCAL_DEFAULTS.minimalOutput,
|
||||
concurrency: MIGRATED_PROJECT_LOCAL_DEFAULTS.concurrency,
|
||||
taskPollIntervalMs: MIGRATED_PROJECT_LOCAL_DEFAULTS.taskPollIntervalMs,
|
||||
interactivePreviewMovements: MIGRATED_PROJECT_LOCAL_DEFAULTS.interactivePreviewMovements,
|
||||
verbose: MIGRATED_PROJECT_LOCAL_DEFAULTS.verbose,
|
||||
};
|
||||
|
||||
for (const key of MIGRATED_PROJECT_LOCAL_CONFIG_KEYS) {
|
||||
const resolved = resolveConfigValueWithSource(projectDir, key);
|
||||
expect(resolved.source).toBe('default');
|
||||
expect(resolved.value).toEqual(expectedByKey[key as ConfigParameterKey]);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('autoFetch', () => {
|
||||
it('should resolve autoFetch to false via schema default when not set', () => {
|
||||
const value = resolveConfigValue(projectDir, 'autoFetch');
|
||||
|
||||
@ -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 });
|
||||
}
|
||||
});
|
||||
|
||||
@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -19,6 +19,7 @@ import { program, resolvedCwd, pipelineMode } from './program.js';
|
||||
import { resolveAgentOverrides } from './helpers.js';
|
||||
import { loadTaskHistory } from './taskHistory.js';
|
||||
import { resolveIssueInput, resolvePrInput } from './routing-inputs.js';
|
||||
import { DEFAULT_PIECE_NAME } from '../../shared/constants.js';
|
||||
export async function executeDefaultAction(task?: string): Promise<void> {
|
||||
const opts = program.opts();
|
||||
if (!pipelineMode && (opts.autoPr === true || opts.draft === true)) {
|
||||
@ -38,7 +39,9 @@ export async function executeDefaultAction(task?: string): Promise<void> {
|
||||
process.exit(1);
|
||||
}
|
||||
const agentOverrides = resolveAgentOverrides(program);
|
||||
const resolvedPipelinePiece = (opts.piece as string | undefined) ?? resolveConfigValue(resolvedCwd, 'piece');
|
||||
const resolvedPipelinePiece = (opts.piece as string | undefined)
|
||||
?? resolveConfigValue(resolvedCwd, 'piece')
|
||||
?? DEFAULT_PIECE_NAME;
|
||||
const resolvedPipelineAutoPr = opts.autoPr === true
|
||||
? true
|
||||
: (resolveConfigValue(resolvedCwd, 'autoPr') ?? false);
|
||||
|
||||
@ -83,74 +83,94 @@ export interface NotificationSoundEventsConfig {
|
||||
|
||||
/** Persisted global configuration for ~/.takt/config.yaml */
|
||||
export interface PersistedGlobalConfig {
|
||||
/**
|
||||
* このインターフェースにはマシン/ユーザー固有の設定のみを定義する。
|
||||
* プロジェクト単位で変えたい設定は ProjectConfig に追加すること。
|
||||
* グローバル専用フィールドを追加する場合は @globalOnly を付ける。
|
||||
*/
|
||||
/** @globalOnly */
|
||||
language: Language;
|
||||
logLevel: 'debug' | 'info' | 'warn' | 'error';
|
||||
provider?: 'claude' | 'codex' | 'opencode' | 'cursor' | 'copilot' | 'mock';
|
||||
model?: string;
|
||||
/** Default piece name for new tasks (resolved via config layers: project > global > 'default') */
|
||||
piece?: string;
|
||||
/** @globalOnly */
|
||||
observability?: ObservabilityConfig;
|
||||
analytics?: AnalyticsConfig;
|
||||
/** @globalOnly */
|
||||
/** Directory for shared clones (worktree_dir in config). If empty, uses ../{clone-name} relative to project */
|
||||
worktreeDir?: string;
|
||||
/** Auto-create PR after worktree execution (default: prompt in interactive mode) */
|
||||
autoPr?: boolean;
|
||||
/** Create PR as draft (default: prompt in interactive mode when autoPr is true) */
|
||||
draftPr?: boolean;
|
||||
/** @globalOnly */
|
||||
/** List of builtin piece/agent names to exclude from fallback loading */
|
||||
disabledBuiltins?: string[];
|
||||
/** @globalOnly */
|
||||
/** Enable builtin pieces from builtins/{lang}/pieces */
|
||||
enableBuiltinPieces?: boolean;
|
||||
/** @globalOnly */
|
||||
/** Anthropic API key for Claude Code SDK (overridden by TAKT_ANTHROPIC_API_KEY env var) */
|
||||
anthropicApiKey?: string;
|
||||
/** @globalOnly */
|
||||
/** OpenAI API key for Codex SDK (overridden by TAKT_OPENAI_API_KEY env var) */
|
||||
openaiApiKey?: string;
|
||||
/** @globalOnly */
|
||||
/** Gemini API key (overridden by TAKT_GEMINI_API_KEY env var) */
|
||||
geminiApiKey?: string;
|
||||
/** @globalOnly */
|
||||
/** Google API key (overridden by TAKT_GOOGLE_API_KEY env var) */
|
||||
googleApiKey?: string;
|
||||
/** @globalOnly */
|
||||
/** Groq API key (overridden by TAKT_GROQ_API_KEY env var) */
|
||||
groqApiKey?: string;
|
||||
/** @globalOnly */
|
||||
/** OpenRouter API key (overridden by TAKT_OPENROUTER_API_KEY env var) */
|
||||
openrouterApiKey?: string;
|
||||
/** @globalOnly */
|
||||
/** External Codex CLI path for Codex SDK override (overridden by TAKT_CODEX_CLI_PATH env var) */
|
||||
codexCliPath?: string;
|
||||
/** @globalOnly */
|
||||
/** External Claude Code CLI path (overridden by TAKT_CLAUDE_CLI_PATH env var) */
|
||||
claudeCliPath?: string;
|
||||
/** @globalOnly */
|
||||
/** External cursor-agent CLI path (overridden by TAKT_CURSOR_CLI_PATH env var) */
|
||||
cursorCliPath?: string;
|
||||
/** @globalOnly */
|
||||
/** External Copilot CLI path (overridden by TAKT_COPILOT_CLI_PATH env var) */
|
||||
copilotCliPath?: string;
|
||||
/** @globalOnly */
|
||||
/** Copilot GitHub token (overridden by TAKT_COPILOT_GITHUB_TOKEN env var) */
|
||||
copilotGithubToken?: string;
|
||||
/** @globalOnly */
|
||||
/** OpenCode API key for OpenCode SDK (overridden by TAKT_OPENCODE_API_KEY env var) */
|
||||
opencodeApiKey?: string;
|
||||
/** @globalOnly */
|
||||
/** Cursor API key for Cursor Agent CLI/API (overridden by TAKT_CURSOR_API_KEY env var) */
|
||||
cursorApiKey?: string;
|
||||
/** Pipeline execution settings */
|
||||
pipeline?: PipelineConfig;
|
||||
/** Minimal output mode for CI - suppress AI output to prevent sensitive information leaks */
|
||||
minimalOutput?: boolean;
|
||||
/** @globalOnly */
|
||||
/** Path to bookmarks file (default: ~/.takt/preferences/bookmarks.yaml) */
|
||||
bookmarksFile?: string;
|
||||
/** @globalOnly */
|
||||
/** Path to piece categories file (default: ~/.takt/preferences/piece-categories.yaml) */
|
||||
pieceCategoriesFile?: string;
|
||||
/** Per-persona provider and model overrides (e.g., { coder: { provider: 'codex', model: 'o3-mini' } }) */
|
||||
personaProviders?: Record<string, PersonaProviderEntry>;
|
||||
/** Global provider-specific options (lowest priority) */
|
||||
providerOptions?: MovementProviderOptions;
|
||||
/** Provider-specific permission profiles */
|
||||
providerProfiles?: ProviderPermissionProfiles;
|
||||
/** Global runtime environment defaults (can be overridden by piece runtime) */
|
||||
runtime?: PieceRuntimeConfig;
|
||||
/** Branch name generation strategy: 'romaji' (fast, default) or 'ai' (slow) */
|
||||
branchNameStrategy?: 'romaji' | 'ai';
|
||||
/** @globalOnly */
|
||||
/** Prevent macOS idle sleep during takt execution using caffeinate (default: false) */
|
||||
preventSleep?: boolean;
|
||||
/** @globalOnly */
|
||||
/** Enable notification sounds (default: true when undefined) */
|
||||
notificationSound?: boolean;
|
||||
/** @globalOnly */
|
||||
/** Notification sound toggles per event timing */
|
||||
notificationSoundEvents?: NotificationSoundEventsConfig;
|
||||
/** Number of movement previews to inject into interactive mode (0 to disable, max 10) */
|
||||
interactivePreviewMovements?: number;
|
||||
/** Verbose output mode */
|
||||
verbose: boolean;
|
||||
/** Number of tasks to run concurrently in takt run (default: 1 = sequential) */
|
||||
concurrency: number;
|
||||
/** Polling interval in ms for picking up new tasks during takt run (default: 500, range: 100-5000) */
|
||||
taskPollIntervalMs: number;
|
||||
/** @globalOnly */
|
||||
/** Opt-in: fetch remote before cloning to keep clones up-to-date (default: false) */
|
||||
autoFetch: boolean;
|
||||
/** Base branch to clone from (default: current branch) */
|
||||
@ -162,13 +182,31 @@ export interface PersistedGlobalConfig {
|
||||
/** Project-level configuration */
|
||||
export interface ProjectConfig {
|
||||
piece?: string;
|
||||
verbose?: boolean;
|
||||
provider?: 'claude' | 'codex' | 'opencode' | 'cursor' | 'copilot' | 'mock';
|
||||
model?: string;
|
||||
analytics?: AnalyticsConfig;
|
||||
autoPr?: boolean;
|
||||
draftPr?: boolean;
|
||||
providerOptions?: MovementProviderOptions;
|
||||
/** Provider-specific permission profiles */
|
||||
providerProfiles?: ProviderPermissionProfiles;
|
||||
/** Project log level */
|
||||
logLevel?: 'debug' | 'info' | 'warn' | 'error';
|
||||
/** Pipeline execution settings */
|
||||
pipeline?: PipelineConfig;
|
||||
/** Per-persona provider/model overrides */
|
||||
personaProviders?: Record<string, PersonaProviderEntry>;
|
||||
/** Branch name generation strategy */
|
||||
branchNameStrategy?: 'romaji' | 'ai';
|
||||
/** Minimal output mode */
|
||||
minimalOutput?: boolean;
|
||||
/** Number of tasks to run concurrently in takt run (1-10) */
|
||||
concurrency?: number;
|
||||
/** Polling interval in ms for task pickup */
|
||||
taskPollIntervalMs?: number;
|
||||
/** Number of movement previews in interactive mode */
|
||||
interactivePreviewMovements?: number;
|
||||
/** Base branch to clone from (overrides global baseBranch) */
|
||||
baseBranch?: string;
|
||||
/** Piece-level overrides (quality_gates, etc.) */
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -505,7 +505,7 @@ export async function selectPiece(
|
||||
): Promise<string | null> {
|
||||
const fallbackToDefault = options?.fallbackToDefault !== false;
|
||||
const categoryConfig = getPieceCategories(cwd);
|
||||
const currentPiece = resolveConfigValue(cwd, 'piece');
|
||||
const currentPiece = resolveConfigValue(cwd, 'piece') ?? DEFAULT_PIECE_NAME;
|
||||
|
||||
if (categoryConfig) {
|
||||
const allPieces = loadAllPiecesWithSources(cwd);
|
||||
|
||||
@ -13,6 +13,7 @@ import { needsStatusJudgmentPhase } from '../../core/piece/index.js';
|
||||
import type { InstructionContext } from '../../core/piece/instruction/instruction-context.js';
|
||||
import type { Language } from '../../core/models/types.js';
|
||||
import { header, info, error, blankLine } from '../../shared/ui/index.js';
|
||||
import { DEFAULT_PIECE_NAME } from '../../shared/constants.js';
|
||||
|
||||
/**
|
||||
* Preview all prompts for a piece.
|
||||
@ -21,7 +22,7 @@ import { header, info, error, blankLine } from '../../shared/ui/index.js';
|
||||
* the Phase 1, Phase 2, and Phase 3 prompts with sample variable values.
|
||||
*/
|
||||
export async function previewPrompts(cwd: string, pieceIdentifier?: string): Promise<void> {
|
||||
const identifier = pieceIdentifier ?? resolvePieceConfigValue(cwd, 'piece');
|
||||
const identifier = pieceIdentifier ?? resolvePieceConfigValue(cwd, 'piece') ?? DEFAULT_PIECE_NAME;
|
||||
const config = loadPieceByIdentifier(identifier, cwd);
|
||||
|
||||
if (!config) {
|
||||
|
||||
@ -18,13 +18,14 @@ import { executeAndCompleteTask } from '../execute/taskExecution.js';
|
||||
import { EXIT_SIGINT } from '../../../shared/exitCodes.js';
|
||||
import { ShutdownManager } from '../execute/shutdownManager.js';
|
||||
import type { TaskExecutionOptions } from '../execute/types.js';
|
||||
import { DEFAULT_PIECE_NAME } from '../../../shared/constants.js';
|
||||
|
||||
/**
|
||||
* Watch for tasks and execute them as they appear.
|
||||
* Runs until Ctrl+C.
|
||||
*/
|
||||
export async function watchTasks(cwd: string, options?: TaskExecutionOptions): Promise<void> {
|
||||
const pieceName = resolveConfigValue(cwd, 'piece');
|
||||
const pieceName = resolveConfigValue(cwd, 'piece') ?? DEFAULT_PIECE_NAME;
|
||||
const taskRunner = new TaskRunner(cwd);
|
||||
const watcher = new TaskWatcher(cwd);
|
||||
const recovered = taskRunner.recoverInterruptedRunningTasks();
|
||||
|
||||
@ -4,8 +4,10 @@
|
||||
* Used by both globalConfig.ts and projectConfig.ts.
|
||||
*/
|
||||
|
||||
import type { MovementProviderOptions } from '../../core/models/piece-types.js';
|
||||
import type { ProviderPermissionProfiles } from '../../core/models/provider-profiles.js';
|
||||
import type { PieceOverrides } from '../../core/models/persisted-global-config.js';
|
||||
import type { PieceOverrides, PersonaProviderEntry, PipelineConfig } from '../../core/models/persisted-global-config.js';
|
||||
import { validateProviderModelCompatibility } from './providerModelCompatibility.js';
|
||||
|
||||
export function normalizeProviderProfiles(
|
||||
raw: Record<string, { default_permission_mode: unknown; movement_permission_overrides?: Record<string, unknown> }> | undefined,
|
||||
@ -77,3 +79,78 @@ export function denormalizePieceOverrides(
|
||||
}
|
||||
return Object.keys(result).length > 0 ? result : undefined;
|
||||
}
|
||||
|
||||
export function normalizePersonaProviders(
|
||||
raw: Record<string, string | { type?: string; provider?: string; model?: string }> | undefined,
|
||||
): Record<string, PersonaProviderEntry> | undefined {
|
||||
if (!raw) return undefined;
|
||||
const entries = Object.entries(raw);
|
||||
if (entries.length === 0) return undefined;
|
||||
|
||||
return Object.fromEntries(entries.map(([persona, entry]) => {
|
||||
const normalizedEntry: PersonaProviderEntry = typeof entry === 'string'
|
||||
? { provider: entry as PersonaProviderEntry['provider'] }
|
||||
: {
|
||||
...(entry.provider !== undefined || entry.type !== undefined
|
||||
? { provider: (entry.provider ?? entry.type) as PersonaProviderEntry['provider'] }
|
||||
: {}),
|
||||
...(entry.model !== undefined ? { model: entry.model } : {}),
|
||||
};
|
||||
validateProviderModelCompatibility(
|
||||
normalizedEntry.provider,
|
||||
normalizedEntry.model,
|
||||
{
|
||||
modelFieldName: `Configuration error: persona_providers.${persona}.model`,
|
||||
requireProviderQualifiedModelForOpencode: false,
|
||||
},
|
||||
);
|
||||
return [persona, normalizedEntry];
|
||||
}));
|
||||
}
|
||||
|
||||
export function normalizePipelineConfig(raw: {
|
||||
default_branch_prefix?: string;
|
||||
commit_message_template?: string;
|
||||
pr_body_template?: string;
|
||||
} | undefined): PipelineConfig | undefined {
|
||||
if (!raw) return undefined;
|
||||
const { default_branch_prefix, commit_message_template, pr_body_template } = raw;
|
||||
if (default_branch_prefix === undefined && commit_message_template === undefined && pr_body_template === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
defaultBranchPrefix: default_branch_prefix,
|
||||
commitMessageTemplate: commit_message_template,
|
||||
prBodyTemplate: pr_body_template,
|
||||
};
|
||||
}
|
||||
|
||||
export function denormalizeProviderOptions(
|
||||
providerOptions: MovementProviderOptions | undefined,
|
||||
): Record<string, unknown> | undefined {
|
||||
if (!providerOptions) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const raw: Record<string, unknown> = {};
|
||||
if (providerOptions.codex?.networkAccess !== undefined) {
|
||||
raw.codex = { network_access: providerOptions.codex.networkAccess };
|
||||
}
|
||||
if (providerOptions.opencode?.networkAccess !== undefined) {
|
||||
raw.opencode = { network_access: providerOptions.opencode.networkAccess };
|
||||
}
|
||||
if (providerOptions.claude?.sandbox) {
|
||||
const sandbox: Record<string, unknown> = {};
|
||||
if (providerOptions.claude.sandbox.allowUnsandboxedCommands !== undefined) {
|
||||
sandbox.allow_unsandboxed_commands = providerOptions.claude.sandbox.allowUnsandboxedCommands;
|
||||
}
|
||||
if (providerOptions.claude.sandbox.excludedCommands !== undefined) {
|
||||
sandbox.excluded_commands = providerOptions.claude.sandbox.excludedCommands;
|
||||
}
|
||||
if (Object.keys(sandbox).length > 0) {
|
||||
raw.claude = { sandbox };
|
||||
}
|
||||
}
|
||||
|
||||
return Object.keys(raw).length > 0 ? raw : undefined;
|
||||
}
|
||||
|
||||
31
src/infra/config/env/config-env-overrides.ts
vendored
31
src/infra/config/env/config-env-overrides.ts
vendored
@ -77,7 +77,6 @@ function applyEnvOverrides(target: Record<string, unknown>, specs: readonly EnvS
|
||||
|
||||
const GLOBAL_ENV_SPECS: readonly EnvSpec[] = [
|
||||
{ path: 'language', type: 'string' },
|
||||
{ path: 'log_level', type: 'string' },
|
||||
{ path: 'provider', type: 'string' },
|
||||
{ path: 'model', type: 'string' },
|
||||
{ path: 'observability', type: 'json' },
|
||||
@ -93,6 +92,10 @@ const GLOBAL_ENV_SPECS: readonly EnvSpec[] = [
|
||||
{ path: 'enable_builtin_pieces', type: 'boolean' },
|
||||
{ path: 'anthropic_api_key', type: 'string' },
|
||||
{ path: 'openai_api_key', type: 'string' },
|
||||
{ path: 'gemini_api_key', type: 'string' },
|
||||
{ path: 'google_api_key', type: 'string' },
|
||||
{ path: 'groq_api_key', type: 'string' },
|
||||
{ path: 'openrouter_api_key', type: 'string' },
|
||||
{ path: 'codex_cli_path', type: 'string' },
|
||||
{ path: 'claude_cli_path', type: 'string' },
|
||||
{ path: 'cursor_cli_path', type: 'string' },
|
||||
@ -100,14 +103,8 @@ const GLOBAL_ENV_SPECS: readonly EnvSpec[] = [
|
||||
{ path: 'copilot_github_token', type: 'string' },
|
||||
{ path: 'opencode_api_key', type: 'string' },
|
||||
{ path: 'cursor_api_key', type: 'string' },
|
||||
{ path: 'pipeline', type: 'json' },
|
||||
{ path: 'pipeline.default_branch_prefix', type: 'string' },
|
||||
{ path: 'pipeline.commit_message_template', type: 'string' },
|
||||
{ path: 'pipeline.pr_body_template', type: 'string' },
|
||||
{ path: 'minimal_output', type: 'boolean' },
|
||||
{ path: 'bookmarks_file', type: 'string' },
|
||||
{ path: 'piece_categories_file', type: 'string' },
|
||||
{ path: 'persona_providers', type: 'json' },
|
||||
{ path: 'provider_options', type: 'json' },
|
||||
{ path: 'provider_options.codex.network_access', type: 'boolean' },
|
||||
{ path: 'provider_options.opencode.network_access', type: 'boolean' },
|
||||
@ -116,7 +113,6 @@ const GLOBAL_ENV_SPECS: readonly EnvSpec[] = [
|
||||
{ path: 'provider_profiles', type: 'json' },
|
||||
{ path: 'runtime', type: 'json' },
|
||||
{ path: 'runtime.prepare', type: 'json' },
|
||||
{ path: 'branch_name_strategy', type: 'string' },
|
||||
{ path: 'prevent_sleep', type: 'boolean' },
|
||||
{ path: 'notification_sound', type: 'boolean' },
|
||||
{ path: 'notification_sound_events', type: 'json' },
|
||||
@ -125,19 +121,26 @@ const GLOBAL_ENV_SPECS: readonly EnvSpec[] = [
|
||||
{ path: 'notification_sound_events.piece_abort', type: 'boolean' },
|
||||
{ path: 'notification_sound_events.run_complete', type: 'boolean' },
|
||||
{ path: 'notification_sound_events.run_abort', type: 'boolean' },
|
||||
{ path: 'interactive_preview_movements', type: 'number' },
|
||||
{ path: 'verbose', type: 'boolean' },
|
||||
{ path: 'concurrency', type: 'number' },
|
||||
{ path: 'task_poll_interval_ms', type: 'number' },
|
||||
{ path: 'auto_fetch', type: 'boolean' },
|
||||
{ path: 'base_branch', type: 'string' },
|
||||
];
|
||||
|
||||
const PROJECT_ENV_SPECS: readonly EnvSpec[] = [
|
||||
{ path: 'piece', type: 'string' },
|
||||
{ path: 'log_level', type: 'string' },
|
||||
{ path: 'provider', type: 'string' },
|
||||
{ path: 'model', type: 'string' },
|
||||
{ path: 'verbose', type: 'boolean' },
|
||||
{ path: 'concurrency', type: 'number' },
|
||||
{ path: 'pipeline', type: 'json' },
|
||||
{ path: 'pipeline.default_branch_prefix', type: 'string' },
|
||||
{ path: 'pipeline.commit_message_template', type: 'string' },
|
||||
{ path: 'pipeline.pr_body_template', type: 'string' },
|
||||
{ path: 'persona_providers', type: 'json' },
|
||||
{ path: 'branch_name_strategy', type: 'string' },
|
||||
{ path: 'minimal_output', type: 'boolean' },
|
||||
{ path: 'task_poll_interval_ms', type: 'number' },
|
||||
{ path: 'interactive_preview_movements', type: 'number' },
|
||||
{ path: 'analytics', type: 'json' },
|
||||
{ path: 'analytics.enabled', type: 'boolean' },
|
||||
{ path: 'analytics.events_path', type: 'string' },
|
||||
@ -149,10 +152,6 @@ const PROJECT_ENV_SPECS: readonly EnvSpec[] = [
|
||||
{ path: 'provider_options.claude.sandbox.excluded_commands', type: 'json' },
|
||||
{ path: 'provider_profiles', type: 'json' },
|
||||
{ path: 'base_branch', type: 'string' },
|
||||
{ path: 'claude_cli_path', type: 'string' },
|
||||
{ path: 'codex_cli_path', type: 'string' },
|
||||
{ path: 'cursor_cli_path', type: 'string' },
|
||||
{ path: 'copilot_cli_path', type: 'string' },
|
||||
];
|
||||
|
||||
export function applyGlobalConfigEnvOverrides(target: Record<string, unknown>): void {
|
||||
|
||||
38
src/infra/config/global/cliPathValidator.ts
Normal file
38
src/infra/config/global/cliPathValidator.ts
Normal file
@ -0,0 +1,38 @@
|
||||
import { accessSync, constants, existsSync, statSync } from 'node:fs';
|
||||
import { isAbsolute } from 'node:path';
|
||||
|
||||
function hasControlCharacters(value: string): boolean {
|
||||
for (let index = 0; index < value.length; index++) {
|
||||
const code = value.charCodeAt(index);
|
||||
if (code < 32 || code === 127) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function validateCliPath(pathValue: string, sourceName: string): string {
|
||||
const trimmed = pathValue.trim();
|
||||
if (trimmed.length === 0) {
|
||||
throw new Error(`Configuration error: ${sourceName} must not be empty.`);
|
||||
}
|
||||
if (hasControlCharacters(trimmed)) {
|
||||
throw new Error(`Configuration error: ${sourceName} contains control characters.`);
|
||||
}
|
||||
if (!isAbsolute(trimmed)) {
|
||||
throw new Error(`Configuration error: ${sourceName} must be an absolute path: ${trimmed}`);
|
||||
}
|
||||
if (!existsSync(trimmed)) {
|
||||
throw new Error(`Configuration error: ${sourceName} path does not exist: ${trimmed}`);
|
||||
}
|
||||
const stats = statSync(trimmed);
|
||||
if (!stats.isFile()) {
|
||||
throw new Error(`Configuration error: ${sourceName} must point to an executable file: ${trimmed}`);
|
||||
}
|
||||
try {
|
||||
accessSync(trimmed, constants.X_OK);
|
||||
} catch {
|
||||
throw new Error(`Configuration error: ${sourceName} file is not executable: ${trimmed}`);
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
@ -1,624 +1,48 @@
|
||||
/**
|
||||
* Global configuration loader
|
||||
*
|
||||
* Manages ~/.takt/config.yaml.
|
||||
* GlobalConfigManager encapsulates the config cache as a singleton.
|
||||
* Global configuration public API.
|
||||
* Keep this file as a stable facade and delegate implementations to focused modules.
|
||||
* Global-only field ownership is defined in PersistedGlobalConfig via `@globalOnly` markers.
|
||||
*/
|
||||
|
||||
import { readFileSync, existsSync, writeFileSync, statSync, accessSync, constants } from 'node:fs';
|
||||
import { isAbsolute } from 'node:path';
|
||||
import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
|
||||
import { GlobalConfigSchema } from '../../../core/models/index.js';
|
||||
import type { Language } from '../../../core/models/index.js';
|
||||
import type { PersistedGlobalConfig, PersonaProviderEntry } from '../../../core/models/persisted-global-config.js';
|
||||
import {
|
||||
normalizeConfigProviderReference,
|
||||
type ConfigProviderReference,
|
||||
} from '../providerReference.js';
|
||||
import {
|
||||
normalizeProviderProfiles,
|
||||
denormalizeProviderProfiles,
|
||||
normalizePieceOverrides,
|
||||
denormalizePieceOverrides,
|
||||
} from '../configNormalizers.js';
|
||||
import { getGlobalConfigPath } from '../paths.js';
|
||||
import { DEFAULT_LANGUAGE } from '../../../shared/constants.js';
|
||||
import { parseProviderModel } from '../../../shared/utils/providerModel.js';
|
||||
import { applyGlobalConfigEnvOverrides, envVarNameFromPath } from '../env/config-env-overrides.js';
|
||||
import { invalidateAllResolvedConfigCache } from '../resolutionCache.js';
|
||||
import type { PersistedGlobalConfig } from '../../../core/models/persisted-global-config.js';
|
||||
import type { MigratedProjectLocalConfigKey } from '../migratedProjectLocalKeys.js';
|
||||
|
||||
type ProviderType = NonNullable<PersistedGlobalConfig['provider']>;
|
||||
type RawPersonaProviderBlock = {
|
||||
type: ProviderType;
|
||||
model?: string;
|
||||
};
|
||||
type RawProviderReference = ConfigProviderReference<ProviderType>;
|
||||
|
||||
/** Claude-specific model aliases that are not valid for other providers */
|
||||
const CLAUDE_MODEL_ALIASES = new Set(['opus', 'sonnet', 'haiku']);
|
||||
|
||||
function hasControlCharacters(value: string): boolean {
|
||||
for (let index = 0; index < value.length; index++) {
|
||||
const code = value.charCodeAt(index);
|
||||
if (code < 32 || code === 127) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Validate a CLI path value: must be non-empty, absolute, existing, executable file without control characters. */
|
||||
export function validateCliPath(pathValue: string, sourceName: string): string {
|
||||
const trimmed = pathValue.trim();
|
||||
if (trimmed.length === 0) {
|
||||
throw new Error(`Configuration error: ${sourceName} must not be empty.`);
|
||||
}
|
||||
if (hasControlCharacters(trimmed)) {
|
||||
throw new Error(`Configuration error: ${sourceName} contains control characters.`);
|
||||
}
|
||||
if (!isAbsolute(trimmed)) {
|
||||
throw new Error(`Configuration error: ${sourceName} must be an absolute path: ${trimmed}`);
|
||||
}
|
||||
if (!existsSync(trimmed)) {
|
||||
throw new Error(`Configuration error: ${sourceName} path does not exist: ${trimmed}`);
|
||||
}
|
||||
const stats = statSync(trimmed);
|
||||
if (!stats.isFile()) {
|
||||
throw new Error(`Configuration error: ${sourceName} must point to an executable file: ${trimmed}`);
|
||||
}
|
||||
try {
|
||||
accessSync(trimmed, constants.X_OK);
|
||||
} catch {
|
||||
throw new Error(`Configuration error: ${sourceName} file is not executable: ${trimmed}`);
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function validateProviderModelCompatibility(provider: string | undefined, model: string | undefined): void {
|
||||
if (!provider) return;
|
||||
|
||||
if (provider === 'opencode' && !model) {
|
||||
throw new Error(
|
||||
"Configuration error: provider 'opencode' requires model in 'provider/model' format (e.g. 'opencode/big-pickle')."
|
||||
);
|
||||
}
|
||||
|
||||
if (!model) return;
|
||||
|
||||
if ((provider === 'codex' || provider === 'opencode') && CLAUDE_MODEL_ALIASES.has(model)) {
|
||||
throw new Error(
|
||||
`Configuration error: model '${model}' is a Claude model alias but provider is '${provider}'. ` +
|
||||
`Either change the provider to 'claude' or specify a ${provider}-compatible model.`
|
||||
);
|
||||
}
|
||||
|
||||
if (provider === 'opencode') {
|
||||
parseProviderModel(model, "Configuration error: model");
|
||||
}
|
||||
}
|
||||
|
||||
function normalizePersonaProviders(
|
||||
raw: Record<string, ProviderType | PersonaProviderEntry | RawPersonaProviderBlock> | undefined,
|
||||
): Record<string, PersonaProviderEntry> | undefined {
|
||||
if (!raw) return undefined;
|
||||
return Object.fromEntries(
|
||||
Object.entries(raw).map(([persona, entry]) => {
|
||||
let normalized: PersonaProviderEntry;
|
||||
if (typeof entry === 'string') {
|
||||
normalized = { provider: entry };
|
||||
} else if ('type' in entry) {
|
||||
normalized = { provider: entry.type, model: entry.model };
|
||||
} else {
|
||||
normalized = entry;
|
||||
}
|
||||
validateProviderModelCompatibility(normalized.provider, normalized.model);
|
||||
return [persona, normalized];
|
||||
}),
|
||||
);
|
||||
}
|
||||
type Assert<T extends true> = T;
|
||||
type IsNever<T> = [T] extends [never] ? true : false;
|
||||
|
||||
/**
|
||||
* Manages global configuration loading and caching.
|
||||
* Singleton — use GlobalConfigManager.getInstance().
|
||||
* Compile-time guard:
|
||||
* migrated project-local fields must not exist on PersistedGlobalConfig.
|
||||
*/
|
||||
export class GlobalConfigManager {
|
||||
private static instance: GlobalConfigManager | null = null;
|
||||
private cachedConfig: PersistedGlobalConfig | null = null;
|
||||
const globalConfigMigratedFieldGuard: Assert<
|
||||
IsNever<Extract<keyof PersistedGlobalConfig, MigratedProjectLocalConfigKey>>
|
||||
> = true;
|
||||
void globalConfigMigratedFieldGuard;
|
||||
|
||||
private constructor() {}
|
||||
export {
|
||||
invalidateGlobalConfigCache,
|
||||
loadGlobalConfig,
|
||||
loadGlobalMigratedProjectLocalFallback,
|
||||
saveGlobalConfig,
|
||||
validateCliPath,
|
||||
} from './globalConfigCore.js';
|
||||
|
||||
static getInstance(): GlobalConfigManager {
|
||||
if (!GlobalConfigManager.instance) {
|
||||
GlobalConfigManager.instance = new GlobalConfigManager();
|
||||
}
|
||||
return GlobalConfigManager.instance;
|
||||
}
|
||||
export {
|
||||
getDisabledBuiltins,
|
||||
getBuiltinPiecesEnabled,
|
||||
getLanguage,
|
||||
setLanguage,
|
||||
setProvider,
|
||||
} from './globalConfigAccessors.js';
|
||||
|
||||
/** Reset singleton for testing */
|
||||
static resetInstance(): void {
|
||||
GlobalConfigManager.instance = null;
|
||||
}
|
||||
|
||||
/** Invalidate the cached configuration */
|
||||
invalidateCache(): void {
|
||||
this.cachedConfig = null;
|
||||
}
|
||||
|
||||
/** Load global configuration (cached) */
|
||||
load(): PersistedGlobalConfig {
|
||||
if (this.cachedConfig !== null) {
|
||||
return this.cachedConfig;
|
||||
}
|
||||
const configPath = getGlobalConfigPath();
|
||||
|
||||
const rawConfig: Record<string, unknown> = {};
|
||||
if (existsSync(configPath)) {
|
||||
const content = readFileSync(configPath, 'utf-8');
|
||||
const parsedRaw = parseYaml(content);
|
||||
if (parsedRaw && typeof parsedRaw === 'object' && !Array.isArray(parsedRaw)) {
|
||||
Object.assign(rawConfig, parsedRaw as Record<string, unknown>);
|
||||
} else if (parsedRaw != null) {
|
||||
throw new Error('Configuration error: ~/.takt/config.yaml must be a YAML object.');
|
||||
}
|
||||
}
|
||||
|
||||
applyGlobalConfigEnvOverrides(rawConfig);
|
||||
|
||||
const parsed = GlobalConfigSchema.parse(rawConfig);
|
||||
const normalizedProvider = normalizeConfigProviderReference(
|
||||
parsed.provider as RawProviderReference,
|
||||
parsed.model,
|
||||
parsed.provider_options as Record<string, unknown> | undefined,
|
||||
);
|
||||
const config: PersistedGlobalConfig = {
|
||||
language: parsed.language,
|
||||
logLevel: parsed.log_level,
|
||||
provider: normalizedProvider.provider,
|
||||
model: normalizedProvider.model,
|
||||
piece: parsed.piece,
|
||||
observability: parsed.observability ? {
|
||||
providerEvents: parsed.observability.provider_events,
|
||||
} : undefined,
|
||||
analytics: parsed.analytics ? {
|
||||
enabled: parsed.analytics.enabled,
|
||||
eventsPath: parsed.analytics.events_path,
|
||||
retentionDays: parsed.analytics.retention_days,
|
||||
} : undefined,
|
||||
worktreeDir: parsed.worktree_dir,
|
||||
autoPr: parsed.auto_pr,
|
||||
draftPr: parsed.draft_pr,
|
||||
disabledBuiltins: parsed.disabled_builtins,
|
||||
enableBuiltinPieces: parsed.enable_builtin_pieces,
|
||||
anthropicApiKey: parsed.anthropic_api_key,
|
||||
openaiApiKey: parsed.openai_api_key,
|
||||
codexCliPath: parsed.codex_cli_path,
|
||||
claudeCliPath: parsed.claude_cli_path,
|
||||
cursorCliPath: parsed.cursor_cli_path,
|
||||
opencodeApiKey: parsed.opencode_api_key,
|
||||
cursorApiKey: parsed.cursor_api_key,
|
||||
pipeline: parsed.pipeline ? {
|
||||
defaultBranchPrefix: parsed.pipeline.default_branch_prefix,
|
||||
commitMessageTemplate: parsed.pipeline.commit_message_template,
|
||||
prBodyTemplate: parsed.pipeline.pr_body_template,
|
||||
} : undefined,
|
||||
minimalOutput: parsed.minimal_output,
|
||||
bookmarksFile: parsed.bookmarks_file,
|
||||
pieceCategoriesFile: parsed.piece_categories_file,
|
||||
personaProviders: normalizePersonaProviders(
|
||||
parsed.persona_providers as Record<string, ProviderType | PersonaProviderEntry | RawPersonaProviderBlock> | undefined,
|
||||
),
|
||||
providerOptions: normalizedProvider.providerOptions,
|
||||
providerProfiles: normalizeProviderProfiles(parsed.provider_profiles as Record<string, { default_permission_mode: unknown; movement_permission_overrides?: Record<string, unknown> }> | undefined),
|
||||
runtime: parsed.runtime?.prepare && parsed.runtime.prepare.length > 0
|
||||
? { prepare: [...new Set(parsed.runtime.prepare)] }
|
||||
: undefined,
|
||||
branchNameStrategy: parsed.branch_name_strategy,
|
||||
preventSleep: parsed.prevent_sleep,
|
||||
notificationSound: parsed.notification_sound,
|
||||
notificationSoundEvents: parsed.notification_sound_events ? {
|
||||
iterationLimit: parsed.notification_sound_events.iteration_limit,
|
||||
pieceComplete: parsed.notification_sound_events.piece_complete,
|
||||
pieceAbort: parsed.notification_sound_events.piece_abort,
|
||||
runComplete: parsed.notification_sound_events.run_complete,
|
||||
runAbort: parsed.notification_sound_events.run_abort,
|
||||
} : undefined,
|
||||
interactivePreviewMovements: parsed.interactive_preview_movements,
|
||||
verbose: parsed.verbose,
|
||||
concurrency: parsed.concurrency,
|
||||
taskPollIntervalMs: parsed.task_poll_interval_ms,
|
||||
autoFetch: parsed.auto_fetch,
|
||||
baseBranch: parsed.base_branch,
|
||||
pieceOverrides: normalizePieceOverrides(parsed.piece_overrides as { quality_gates?: string[]; quality_gates_edit_only?: boolean; movements?: Record<string, { quality_gates?: string[] }> } | undefined),
|
||||
};
|
||||
validateProviderModelCompatibility(config.provider, config.model);
|
||||
this.cachedConfig = config;
|
||||
return config;
|
||||
}
|
||||
|
||||
/** Save global configuration to disk and invalidate cache */
|
||||
save(config: PersistedGlobalConfig): void {
|
||||
const configPath = getGlobalConfigPath();
|
||||
const raw: Record<string, unknown> = {
|
||||
language: config.language,
|
||||
log_level: config.logLevel,
|
||||
provider: config.provider,
|
||||
};
|
||||
if (config.model) {
|
||||
raw.model = config.model;
|
||||
}
|
||||
if (config.piece) {
|
||||
raw.piece = config.piece;
|
||||
}
|
||||
if (config.observability && config.observability.providerEvents !== undefined) {
|
||||
raw.observability = {
|
||||
provider_events: config.observability.providerEvents,
|
||||
};
|
||||
}
|
||||
if (config.analytics) {
|
||||
const analyticsRaw: Record<string, unknown> = {};
|
||||
if (config.analytics.enabled !== undefined) analyticsRaw.enabled = config.analytics.enabled;
|
||||
if (config.analytics.eventsPath) analyticsRaw.events_path = config.analytics.eventsPath;
|
||||
if (config.analytics.retentionDays !== undefined) analyticsRaw.retention_days = config.analytics.retentionDays;
|
||||
if (Object.keys(analyticsRaw).length > 0) {
|
||||
raw.analytics = analyticsRaw;
|
||||
}
|
||||
}
|
||||
if (config.worktreeDir) {
|
||||
raw.worktree_dir = config.worktreeDir;
|
||||
}
|
||||
if (config.autoPr !== undefined) {
|
||||
raw.auto_pr = config.autoPr;
|
||||
}
|
||||
if (config.draftPr !== undefined) {
|
||||
raw.draft_pr = config.draftPr;
|
||||
}
|
||||
if (config.disabledBuiltins && config.disabledBuiltins.length > 0) {
|
||||
raw.disabled_builtins = config.disabledBuiltins;
|
||||
}
|
||||
if (config.enableBuiltinPieces !== undefined) {
|
||||
raw.enable_builtin_pieces = config.enableBuiltinPieces;
|
||||
}
|
||||
if (config.anthropicApiKey) {
|
||||
raw.anthropic_api_key = config.anthropicApiKey;
|
||||
}
|
||||
if (config.openaiApiKey) {
|
||||
raw.openai_api_key = config.openaiApiKey;
|
||||
}
|
||||
if (config.codexCliPath) {
|
||||
raw.codex_cli_path = config.codexCliPath;
|
||||
}
|
||||
if (config.claudeCliPath) {
|
||||
raw.claude_cli_path = config.claudeCliPath;
|
||||
}
|
||||
if (config.cursorCliPath) {
|
||||
raw.cursor_cli_path = config.cursorCliPath;
|
||||
}
|
||||
if (config.opencodeApiKey) {
|
||||
raw.opencode_api_key = config.opencodeApiKey;
|
||||
}
|
||||
if (config.cursorApiKey) {
|
||||
raw.cursor_api_key = config.cursorApiKey;
|
||||
}
|
||||
if (config.pipeline) {
|
||||
const pipelineRaw: Record<string, unknown> = {};
|
||||
if (config.pipeline.defaultBranchPrefix) pipelineRaw.default_branch_prefix = config.pipeline.defaultBranchPrefix;
|
||||
if (config.pipeline.commitMessageTemplate) pipelineRaw.commit_message_template = config.pipeline.commitMessageTemplate;
|
||||
if (config.pipeline.prBodyTemplate) pipelineRaw.pr_body_template = config.pipeline.prBodyTemplate;
|
||||
if (Object.keys(pipelineRaw).length > 0) {
|
||||
raw.pipeline = pipelineRaw;
|
||||
}
|
||||
}
|
||||
if (config.minimalOutput !== undefined) {
|
||||
raw.minimal_output = config.minimalOutput;
|
||||
}
|
||||
if (config.bookmarksFile) {
|
||||
raw.bookmarks_file = config.bookmarksFile;
|
||||
}
|
||||
if (config.pieceCategoriesFile) {
|
||||
raw.piece_categories_file = config.pieceCategoriesFile;
|
||||
}
|
||||
if (config.personaProviders && Object.keys(config.personaProviders).length > 0) {
|
||||
raw.persona_providers = config.personaProviders;
|
||||
}
|
||||
const rawProviderProfiles = denormalizeProviderProfiles(config.providerProfiles);
|
||||
if (rawProviderProfiles && Object.keys(rawProviderProfiles).length > 0) {
|
||||
raw.provider_profiles = rawProviderProfiles;
|
||||
}
|
||||
if (config.runtime?.prepare && config.runtime.prepare.length > 0) {
|
||||
raw.runtime = {
|
||||
prepare: [...new Set(config.runtime.prepare)],
|
||||
};
|
||||
}
|
||||
if (config.branchNameStrategy) {
|
||||
raw.branch_name_strategy = config.branchNameStrategy;
|
||||
}
|
||||
if (config.preventSleep !== undefined) {
|
||||
raw.prevent_sleep = config.preventSleep;
|
||||
}
|
||||
if (config.notificationSound !== undefined) {
|
||||
raw.notification_sound = config.notificationSound;
|
||||
}
|
||||
if (config.notificationSoundEvents) {
|
||||
const eventRaw: Record<string, unknown> = {};
|
||||
if (config.notificationSoundEvents.iterationLimit !== undefined) {
|
||||
eventRaw.iteration_limit = config.notificationSoundEvents.iterationLimit;
|
||||
}
|
||||
if (config.notificationSoundEvents.pieceComplete !== undefined) {
|
||||
eventRaw.piece_complete = config.notificationSoundEvents.pieceComplete;
|
||||
}
|
||||
if (config.notificationSoundEvents.pieceAbort !== undefined) {
|
||||
eventRaw.piece_abort = config.notificationSoundEvents.pieceAbort;
|
||||
}
|
||||
if (config.notificationSoundEvents.runComplete !== undefined) {
|
||||
eventRaw.run_complete = config.notificationSoundEvents.runComplete;
|
||||
}
|
||||
if (config.notificationSoundEvents.runAbort !== undefined) {
|
||||
eventRaw.run_abort = config.notificationSoundEvents.runAbort;
|
||||
}
|
||||
if (Object.keys(eventRaw).length > 0) {
|
||||
raw.notification_sound_events = eventRaw;
|
||||
}
|
||||
}
|
||||
if (config.interactivePreviewMovements !== undefined) {
|
||||
raw.interactive_preview_movements = config.interactivePreviewMovements;
|
||||
}
|
||||
if (config.verbose) {
|
||||
raw.verbose = config.verbose;
|
||||
}
|
||||
if (config.concurrency !== undefined && config.concurrency > 1) {
|
||||
raw.concurrency = config.concurrency;
|
||||
}
|
||||
if (config.taskPollIntervalMs !== undefined && config.taskPollIntervalMs !== 500) {
|
||||
raw.task_poll_interval_ms = config.taskPollIntervalMs;
|
||||
}
|
||||
if (config.autoFetch) {
|
||||
raw.auto_fetch = config.autoFetch;
|
||||
}
|
||||
if (config.baseBranch) {
|
||||
raw.base_branch = config.baseBranch;
|
||||
}
|
||||
const denormalizedPieceOverrides = denormalizePieceOverrides(config.pieceOverrides);
|
||||
if (denormalizedPieceOverrides) {
|
||||
raw.piece_overrides = denormalizedPieceOverrides;
|
||||
}
|
||||
writeFileSync(configPath, stringifyYaml(raw), 'utf-8');
|
||||
this.invalidateCache();
|
||||
invalidateAllResolvedConfigCache();
|
||||
}
|
||||
}
|
||||
|
||||
export function invalidateGlobalConfigCache(): void {
|
||||
GlobalConfigManager.getInstance().invalidateCache();
|
||||
invalidateAllResolvedConfigCache();
|
||||
}
|
||||
|
||||
export function loadGlobalConfig(): PersistedGlobalConfig {
|
||||
return GlobalConfigManager.getInstance().load();
|
||||
}
|
||||
|
||||
export function saveGlobalConfig(config: PersistedGlobalConfig): void {
|
||||
GlobalConfigManager.getInstance().save(config);
|
||||
}
|
||||
|
||||
export function getDisabledBuiltins(): string[] {
|
||||
try {
|
||||
const config = loadGlobalConfig();
|
||||
return config.disabledBuiltins ?? [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function getBuiltinPiecesEnabled(): boolean {
|
||||
try {
|
||||
const config = loadGlobalConfig();
|
||||
return config.enableBuiltinPieces !== false;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
export function getLanguage(): Language {
|
||||
try {
|
||||
const config = loadGlobalConfig();
|
||||
return config.language;
|
||||
} catch {
|
||||
return DEFAULT_LANGUAGE;
|
||||
}
|
||||
}
|
||||
|
||||
export function setLanguage(language: Language): void {
|
||||
const config = loadGlobalConfig();
|
||||
config.language = language;
|
||||
saveGlobalConfig(config);
|
||||
}
|
||||
|
||||
export function setProvider(provider: 'claude' | 'codex' | 'opencode' | 'cursor' | 'copilot'): void {
|
||||
const config = loadGlobalConfig();
|
||||
config.provider = provider;
|
||||
saveGlobalConfig(config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the Anthropic API key.
|
||||
* Priority: TAKT_ANTHROPIC_API_KEY env var > config.yaml > undefined (CLI auth fallback)
|
||||
*/
|
||||
export function resolveAnthropicApiKey(): string | undefined {
|
||||
const envKey = process.env[envVarNameFromPath('anthropic_api_key')];
|
||||
if (envKey) return envKey;
|
||||
|
||||
try {
|
||||
const config = loadGlobalConfig();
|
||||
return config.anthropicApiKey;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the OpenAI API key.
|
||||
* Priority: TAKT_OPENAI_API_KEY env var > config.yaml > undefined (CLI auth fallback)
|
||||
*/
|
||||
export function resolveOpenaiApiKey(): string | undefined {
|
||||
const envKey = process.env[envVarNameFromPath('openai_api_key')];
|
||||
if (envKey) return envKey;
|
||||
|
||||
try {
|
||||
const config = loadGlobalConfig();
|
||||
return config.openaiApiKey;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the Codex CLI path override.
|
||||
* Priority: TAKT_CODEX_CLI_PATH env var > config.yaml > undefined (SDK vendored binary fallback)
|
||||
*/
|
||||
export function resolveCodexCliPath(projectConfig?: { codexCliPath?: string }): string | undefined {
|
||||
const envPath = process.env[envVarNameFromPath('codex_cli_path')];
|
||||
if (envPath !== undefined) {
|
||||
return validateCliPath(envPath, 'TAKT_CODEX_CLI_PATH');
|
||||
}
|
||||
|
||||
if (projectConfig?.codexCliPath !== undefined) {
|
||||
return validateCliPath(projectConfig.codexCliPath, 'codex_cli_path (project)');
|
||||
}
|
||||
|
||||
let config: PersistedGlobalConfig;
|
||||
try {
|
||||
config = loadGlobalConfig();
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
if (config.codexCliPath === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
return validateCliPath(config.codexCliPath, 'codex_cli_path');
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the Claude Code CLI path override.
|
||||
* Priority: TAKT_CLAUDE_CLI_PATH env var > project config > global config > undefined (SDK default)
|
||||
*/
|
||||
export function resolveClaudeCliPath(projectConfig?: { claudeCliPath?: string }): string | undefined {
|
||||
const envPath = process.env[envVarNameFromPath('claude_cli_path')];
|
||||
if (envPath !== undefined) {
|
||||
return validateCliPath(envPath, 'TAKT_CLAUDE_CLI_PATH');
|
||||
}
|
||||
|
||||
if (projectConfig?.claudeCliPath !== undefined) {
|
||||
return validateCliPath(projectConfig.claudeCliPath, 'claude_cli_path (project)');
|
||||
}
|
||||
|
||||
let config: PersistedGlobalConfig;
|
||||
try {
|
||||
config = loadGlobalConfig();
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
if (config.claudeCliPath === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
return validateCliPath(config.claudeCliPath, 'claude_cli_path');
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the cursor-agent CLI path override.
|
||||
* Priority: TAKT_CURSOR_CLI_PATH env var > project config > global config > undefined (default 'cursor-agent')
|
||||
*/
|
||||
export function resolveCursorCliPath(projectConfig?: { cursorCliPath?: string }): string | undefined {
|
||||
const envPath = process.env[envVarNameFromPath('cursor_cli_path')];
|
||||
if (envPath !== undefined) {
|
||||
return validateCliPath(envPath, 'TAKT_CURSOR_CLI_PATH');
|
||||
}
|
||||
|
||||
if (projectConfig?.cursorCliPath !== undefined) {
|
||||
return validateCliPath(projectConfig.cursorCliPath, 'cursor_cli_path (project)');
|
||||
}
|
||||
|
||||
let config: PersistedGlobalConfig;
|
||||
try {
|
||||
config = loadGlobalConfig();
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
if (config.cursorCliPath === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
return validateCliPath(config.cursorCliPath, 'cursor_cli_path');
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the OpenCode API key.
|
||||
* Priority: TAKT_OPENCODE_API_KEY env var > config.yaml > undefined
|
||||
*/
|
||||
export function resolveOpencodeApiKey(): string | undefined {
|
||||
const envKey = process.env[envVarNameFromPath('opencode_api_key')];
|
||||
if (envKey) return envKey;
|
||||
|
||||
try {
|
||||
const config = loadGlobalConfig();
|
||||
return config.opencodeApiKey;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the Cursor API key.
|
||||
* Priority: TAKT_CURSOR_API_KEY env var > config.yaml > undefined (cursor-agent login fallback)
|
||||
*/
|
||||
export function resolveCursorApiKey(): string | undefined {
|
||||
const envKey = process.env[envVarNameFromPath('cursor_api_key')];
|
||||
if (envKey) return envKey;
|
||||
|
||||
try {
|
||||
const config = loadGlobalConfig();
|
||||
return config.cursorApiKey;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the Copilot CLI path override.
|
||||
* Priority: TAKT_COPILOT_CLI_PATH env var > project config > global config > undefined (default 'copilot')
|
||||
*/
|
||||
export function resolveCopilotCliPath(projectConfig?: { copilotCliPath?: string }): string | undefined {
|
||||
const envPath = process.env[envVarNameFromPath('copilot_cli_path')];
|
||||
if (envPath !== undefined) {
|
||||
return validateCliPath(envPath, 'TAKT_COPILOT_CLI_PATH');
|
||||
}
|
||||
|
||||
if (projectConfig?.copilotCliPath !== undefined) {
|
||||
return validateCliPath(projectConfig.copilotCliPath, 'copilot_cli_path (project)');
|
||||
}
|
||||
|
||||
let config: PersistedGlobalConfig;
|
||||
try {
|
||||
config = loadGlobalConfig();
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
if (config.copilotCliPath === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
return validateCliPath(config.copilotCliPath, 'copilot_cli_path');
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the Copilot GitHub token.
|
||||
* Priority: TAKT_COPILOT_GITHUB_TOKEN env var > config.yaml > undefined
|
||||
*/
|
||||
export function resolveCopilotGithubToken(): string | undefined {
|
||||
const envKey = process.env[envVarNameFromPath('copilot_github_token')];
|
||||
if (envKey) return envKey;
|
||||
|
||||
try {
|
||||
const config = loadGlobalConfig();
|
||||
return config.copilotGithubToken;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
export {
|
||||
resolveAnthropicApiKey,
|
||||
resolveOpenaiApiKey,
|
||||
resolveCodexCliPath,
|
||||
resolveClaudeCliPath,
|
||||
resolveCursorCliPath,
|
||||
resolveCopilotCliPath,
|
||||
resolveCopilotGithubToken,
|
||||
resolveOpencodeApiKey,
|
||||
resolveCursorApiKey,
|
||||
} from './globalConfigResolvers.js';
|
||||
|
||||
30
src/infra/config/global/globalConfigAccessors.ts
Normal file
30
src/infra/config/global/globalConfigAccessors.ts
Normal file
@ -0,0 +1,30 @@
|
||||
import type { Language } from '../../../core/models/index.js';
|
||||
import { DEFAULT_LANGUAGE } from '../../../shared/constants.js';
|
||||
import { loadGlobalConfig, saveGlobalConfig } from './globalConfigCore.js';
|
||||
|
||||
export function getDisabledBuiltins(): string[] {
|
||||
const config = loadGlobalConfig();
|
||||
return config.disabledBuiltins ?? [];
|
||||
}
|
||||
|
||||
export function getBuiltinPiecesEnabled(): boolean {
|
||||
const config = loadGlobalConfig();
|
||||
return config.enableBuiltinPieces !== false;
|
||||
}
|
||||
|
||||
export function getLanguage(): Language {
|
||||
const config = loadGlobalConfig();
|
||||
return config.language ?? DEFAULT_LANGUAGE;
|
||||
}
|
||||
|
||||
export function setLanguage(language: Language): void {
|
||||
const config = loadGlobalConfig();
|
||||
config.language = language;
|
||||
saveGlobalConfig(config);
|
||||
}
|
||||
|
||||
export function setProvider(provider: 'claude' | 'codex' | 'opencode' | 'cursor' | 'copilot'): void {
|
||||
const config = loadGlobalConfig();
|
||||
config.provider = provider;
|
||||
saveGlobalConfig(config);
|
||||
}
|
||||
300
src/infra/config/global/globalConfigCore.ts
Normal file
300
src/infra/config/global/globalConfigCore.ts
Normal file
@ -0,0 +1,300 @@
|
||||
import { readFileSync, existsSync, writeFileSync } from 'node:fs';
|
||||
import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
|
||||
import { GlobalConfigSchema } from '../../../core/models/index.js';
|
||||
import type { PersistedGlobalConfig } from '../../../core/models/persisted-global-config.js';
|
||||
import {
|
||||
normalizeConfigProviderReference,
|
||||
type ConfigProviderReference,
|
||||
} from '../providerReference.js';
|
||||
import {
|
||||
normalizeProviderProfiles,
|
||||
denormalizeProviderProfiles,
|
||||
normalizePieceOverrides,
|
||||
denormalizePieceOverrides,
|
||||
denormalizeProviderOptions,
|
||||
} from '../configNormalizers.js';
|
||||
import { getGlobalConfigPath } from '../paths.js';
|
||||
import { applyGlobalConfigEnvOverrides } from '../env/config-env-overrides.js';
|
||||
import { invalidateAllResolvedConfigCache } from '../resolutionCache.js';
|
||||
import { validateProviderModelCompatibility } from '../providerModelCompatibility.js';
|
||||
import {
|
||||
extractMigratedProjectLocalFallback,
|
||||
removeMigratedProjectLocalKeys,
|
||||
type GlobalMigratedProjectLocalFallback,
|
||||
} from './globalMigratedProjectLocalFallback.js';
|
||||
export { validateCliPath } from './cliPathValidator.js';
|
||||
type ProviderType = NonNullable<PersistedGlobalConfig['provider']>;
|
||||
type RawProviderReference = ConfigProviderReference<ProviderType>;
|
||||
export class GlobalConfigManager {
|
||||
private static instance: GlobalConfigManager | null = null;
|
||||
private cachedConfig: PersistedGlobalConfig | null = null;
|
||||
private cachedMigratedProjectLocalFallback: GlobalMigratedProjectLocalFallback | null = null;
|
||||
private constructor() {}
|
||||
|
||||
static getInstance(): GlobalConfigManager {
|
||||
if (!GlobalConfigManager.instance) {
|
||||
GlobalConfigManager.instance = new GlobalConfigManager();
|
||||
}
|
||||
return GlobalConfigManager.instance;
|
||||
}
|
||||
|
||||
static resetInstance(): void {
|
||||
GlobalConfigManager.instance = null;
|
||||
}
|
||||
|
||||
invalidateCache(): void {
|
||||
this.cachedConfig = null;
|
||||
this.cachedMigratedProjectLocalFallback = null;
|
||||
}
|
||||
|
||||
load(): PersistedGlobalConfig {
|
||||
if (this.cachedConfig !== null) {
|
||||
return this.cachedConfig;
|
||||
}
|
||||
const configPath = getGlobalConfigPath();
|
||||
|
||||
const rawConfig: Record<string, unknown> = {};
|
||||
if (existsSync(configPath)) {
|
||||
const content = readFileSync(configPath, 'utf-8');
|
||||
const parsedRaw = parseYaml(content);
|
||||
if (parsedRaw && typeof parsedRaw === 'object' && !Array.isArray(parsedRaw)) {
|
||||
Object.assign(rawConfig, parsedRaw as Record<string, unknown>);
|
||||
} else if (parsedRaw != null) {
|
||||
throw new Error('Configuration error: ~/.takt/config.yaml must be a YAML object.');
|
||||
}
|
||||
}
|
||||
|
||||
applyGlobalConfigEnvOverrides(rawConfig);
|
||||
const migratedProjectLocalFallback = extractMigratedProjectLocalFallback(rawConfig);
|
||||
const schemaInput = { ...rawConfig };
|
||||
removeMigratedProjectLocalKeys(schemaInput);
|
||||
|
||||
const parsed = GlobalConfigSchema.parse(schemaInput);
|
||||
const normalizedProvider = normalizeConfigProviderReference(
|
||||
parsed.provider as RawProviderReference,
|
||||
parsed.model,
|
||||
parsed.provider_options as Record<string, unknown> | undefined,
|
||||
);
|
||||
const config: PersistedGlobalConfig = {
|
||||
language: parsed.language,
|
||||
provider: normalizedProvider.provider,
|
||||
model: normalizedProvider.model,
|
||||
piece: parsed.piece,
|
||||
observability: parsed.observability ? {
|
||||
providerEvents: parsed.observability.provider_events,
|
||||
} : undefined,
|
||||
analytics: parsed.analytics ? {
|
||||
enabled: parsed.analytics.enabled,
|
||||
eventsPath: parsed.analytics.events_path,
|
||||
retentionDays: parsed.analytics.retention_days,
|
||||
} : undefined,
|
||||
worktreeDir: parsed.worktree_dir,
|
||||
autoPr: parsed.auto_pr,
|
||||
draftPr: parsed.draft_pr,
|
||||
disabledBuiltins: parsed.disabled_builtins,
|
||||
enableBuiltinPieces: parsed.enable_builtin_pieces,
|
||||
anthropicApiKey: parsed.anthropic_api_key,
|
||||
openaiApiKey: parsed.openai_api_key,
|
||||
geminiApiKey: parsed.gemini_api_key,
|
||||
googleApiKey: parsed.google_api_key,
|
||||
groqApiKey: parsed.groq_api_key,
|
||||
openrouterApiKey: parsed.openrouter_api_key,
|
||||
codexCliPath: parsed.codex_cli_path,
|
||||
claudeCliPath: parsed.claude_cli_path,
|
||||
cursorCliPath: parsed.cursor_cli_path,
|
||||
copilotCliPath: parsed.copilot_cli_path,
|
||||
copilotGithubToken: parsed.copilot_github_token,
|
||||
opencodeApiKey: parsed.opencode_api_key,
|
||||
cursorApiKey: parsed.cursor_api_key,
|
||||
bookmarksFile: parsed.bookmarks_file,
|
||||
pieceCategoriesFile: parsed.piece_categories_file,
|
||||
providerOptions: normalizedProvider.providerOptions,
|
||||
providerProfiles: normalizeProviderProfiles(parsed.provider_profiles as Record<string, { default_permission_mode: unknown; movement_permission_overrides?: Record<string, unknown> }> | undefined),
|
||||
runtime: parsed.runtime?.prepare && parsed.runtime.prepare.length > 0
|
||||
? { prepare: [...new Set(parsed.runtime.prepare)] }
|
||||
: undefined,
|
||||
preventSleep: parsed.prevent_sleep,
|
||||
notificationSound: parsed.notification_sound,
|
||||
notificationSoundEvents: parsed.notification_sound_events ? {
|
||||
iterationLimit: parsed.notification_sound_events.iteration_limit,
|
||||
pieceComplete: parsed.notification_sound_events.piece_complete,
|
||||
pieceAbort: parsed.notification_sound_events.piece_abort,
|
||||
runComplete: parsed.notification_sound_events.run_complete,
|
||||
runAbort: parsed.notification_sound_events.run_abort,
|
||||
} : undefined,
|
||||
autoFetch: parsed.auto_fetch,
|
||||
baseBranch: parsed.base_branch,
|
||||
pieceOverrides: normalizePieceOverrides(parsed.piece_overrides as { quality_gates?: string[]; quality_gates_edit_only?: boolean; movements?: Record<string, { quality_gates?: string[] }> } | undefined),
|
||||
};
|
||||
validateProviderModelCompatibility(config.provider, config.model);
|
||||
this.cachedConfig = config;
|
||||
this.cachedMigratedProjectLocalFallback = migratedProjectLocalFallback;
|
||||
return config;
|
||||
}
|
||||
|
||||
loadMigratedProjectLocalFallback(): GlobalMigratedProjectLocalFallback {
|
||||
if (this.cachedMigratedProjectLocalFallback !== null) {
|
||||
return this.cachedMigratedProjectLocalFallback;
|
||||
}
|
||||
this.load();
|
||||
return this.cachedMigratedProjectLocalFallback ?? {};
|
||||
}
|
||||
|
||||
save(config: PersistedGlobalConfig): void {
|
||||
const configPath = getGlobalConfigPath();
|
||||
const raw: Record<string, unknown> = {
|
||||
language: config.language,
|
||||
provider: config.provider,
|
||||
};
|
||||
if (config.model) {
|
||||
raw.model = config.model;
|
||||
}
|
||||
if (config.piece) {
|
||||
raw.piece = config.piece;
|
||||
}
|
||||
if (config.observability && config.observability.providerEvents !== undefined) {
|
||||
raw.observability = {
|
||||
provider_events: config.observability.providerEvents,
|
||||
};
|
||||
}
|
||||
if (config.analytics) {
|
||||
const analyticsRaw: Record<string, unknown> = {};
|
||||
if (config.analytics.enabled !== undefined) analyticsRaw.enabled = config.analytics.enabled;
|
||||
if (config.analytics.eventsPath) analyticsRaw.events_path = config.analytics.eventsPath;
|
||||
if (config.analytics.retentionDays !== undefined) analyticsRaw.retention_days = config.analytics.retentionDays;
|
||||
if (Object.keys(analyticsRaw).length > 0) {
|
||||
raw.analytics = analyticsRaw;
|
||||
}
|
||||
}
|
||||
if (config.worktreeDir) {
|
||||
raw.worktree_dir = config.worktreeDir;
|
||||
}
|
||||
if (config.autoPr !== undefined) {
|
||||
raw.auto_pr = config.autoPr;
|
||||
}
|
||||
if (config.draftPr !== undefined) {
|
||||
raw.draft_pr = config.draftPr;
|
||||
}
|
||||
if (config.disabledBuiltins && config.disabledBuiltins.length > 0) {
|
||||
raw.disabled_builtins = config.disabledBuiltins;
|
||||
}
|
||||
if (config.enableBuiltinPieces !== undefined) {
|
||||
raw.enable_builtin_pieces = config.enableBuiltinPieces;
|
||||
}
|
||||
if (config.anthropicApiKey) {
|
||||
raw.anthropic_api_key = config.anthropicApiKey;
|
||||
}
|
||||
if (config.openaiApiKey) {
|
||||
raw.openai_api_key = config.openaiApiKey;
|
||||
}
|
||||
if (config.geminiApiKey) {
|
||||
raw.gemini_api_key = config.geminiApiKey;
|
||||
}
|
||||
if (config.googleApiKey) {
|
||||
raw.google_api_key = config.googleApiKey;
|
||||
}
|
||||
if (config.groqApiKey) {
|
||||
raw.groq_api_key = config.groqApiKey;
|
||||
}
|
||||
if (config.openrouterApiKey) {
|
||||
raw.openrouter_api_key = config.openrouterApiKey;
|
||||
}
|
||||
if (config.codexCliPath) {
|
||||
raw.codex_cli_path = config.codexCliPath;
|
||||
}
|
||||
if (config.claudeCliPath) {
|
||||
raw.claude_cli_path = config.claudeCliPath;
|
||||
}
|
||||
if (config.cursorCliPath) {
|
||||
raw.cursor_cli_path = config.cursorCliPath;
|
||||
}
|
||||
if (config.copilotCliPath) {
|
||||
raw.copilot_cli_path = config.copilotCliPath;
|
||||
}
|
||||
if (config.copilotGithubToken) {
|
||||
raw.copilot_github_token = config.copilotGithubToken;
|
||||
}
|
||||
if (config.opencodeApiKey) {
|
||||
raw.opencode_api_key = config.opencodeApiKey;
|
||||
}
|
||||
if (config.cursorApiKey) {
|
||||
raw.cursor_api_key = config.cursorApiKey;
|
||||
}
|
||||
if (config.bookmarksFile) {
|
||||
raw.bookmarks_file = config.bookmarksFile;
|
||||
}
|
||||
if (config.pieceCategoriesFile) {
|
||||
raw.piece_categories_file = config.pieceCategoriesFile;
|
||||
}
|
||||
const rawProviderOptions = denormalizeProviderOptions(config.providerOptions);
|
||||
if (rawProviderOptions) {
|
||||
raw.provider_options = rawProviderOptions;
|
||||
}
|
||||
const rawProviderProfiles = denormalizeProviderProfiles(config.providerProfiles);
|
||||
if (rawProviderProfiles && Object.keys(rawProviderProfiles).length > 0) {
|
||||
raw.provider_profiles = rawProviderProfiles;
|
||||
}
|
||||
if (config.runtime?.prepare && config.runtime.prepare.length > 0) {
|
||||
raw.runtime = {
|
||||
prepare: [...new Set(config.runtime.prepare)],
|
||||
};
|
||||
}
|
||||
if (config.preventSleep !== undefined) {
|
||||
raw.prevent_sleep = config.preventSleep;
|
||||
}
|
||||
if (config.notificationSound !== undefined) {
|
||||
raw.notification_sound = config.notificationSound;
|
||||
}
|
||||
if (config.notificationSoundEvents) {
|
||||
const eventRaw: Record<string, unknown> = {};
|
||||
if (config.notificationSoundEvents.iterationLimit !== undefined) {
|
||||
eventRaw.iteration_limit = config.notificationSoundEvents.iterationLimit;
|
||||
}
|
||||
if (config.notificationSoundEvents.pieceComplete !== undefined) {
|
||||
eventRaw.piece_complete = config.notificationSoundEvents.pieceComplete;
|
||||
}
|
||||
if (config.notificationSoundEvents.pieceAbort !== undefined) {
|
||||
eventRaw.piece_abort = config.notificationSoundEvents.pieceAbort;
|
||||
}
|
||||
if (config.notificationSoundEvents.runComplete !== undefined) {
|
||||
eventRaw.run_complete = config.notificationSoundEvents.runComplete;
|
||||
}
|
||||
if (config.notificationSoundEvents.runAbort !== undefined) {
|
||||
eventRaw.run_abort = config.notificationSoundEvents.runAbort;
|
||||
}
|
||||
if (Object.keys(eventRaw).length > 0) {
|
||||
raw.notification_sound_events = eventRaw;
|
||||
}
|
||||
}
|
||||
if (config.autoFetch) {
|
||||
raw.auto_fetch = config.autoFetch;
|
||||
}
|
||||
if (config.baseBranch) {
|
||||
raw.base_branch = config.baseBranch;
|
||||
}
|
||||
const denormalizedPieceOverrides = denormalizePieceOverrides(config.pieceOverrides);
|
||||
if (denormalizedPieceOverrides) {
|
||||
raw.piece_overrides = denormalizedPieceOverrides;
|
||||
}
|
||||
writeFileSync(configPath, stringifyYaml(raw), 'utf-8');
|
||||
this.invalidateCache();
|
||||
invalidateAllResolvedConfigCache();
|
||||
}
|
||||
}
|
||||
|
||||
export function invalidateGlobalConfigCache(): void {
|
||||
GlobalConfigManager.getInstance().invalidateCache();
|
||||
invalidateAllResolvedConfigCache();
|
||||
}
|
||||
|
||||
export function loadGlobalConfig(): PersistedGlobalConfig {
|
||||
return GlobalConfigManager.getInstance().load();
|
||||
}
|
||||
|
||||
export function loadGlobalMigratedProjectLocalFallback(): GlobalMigratedProjectLocalFallback {
|
||||
return GlobalConfigManager.getInstance().loadMigratedProjectLocalFallback();
|
||||
}
|
||||
|
||||
export function saveGlobalConfig(config: PersistedGlobalConfig): void {
|
||||
GlobalConfigManager.getInstance().save(config);
|
||||
}
|
||||
95
src/infra/config/global/globalConfigResolvers.ts
Normal file
95
src/infra/config/global/globalConfigResolvers.ts
Normal file
@ -0,0 +1,95 @@
|
||||
import type { PersistedGlobalConfig } from '../../../core/models/persisted-global-config.js';
|
||||
import { envVarNameFromPath } from '../env/config-env-overrides.js';
|
||||
import { loadGlobalConfig, validateCliPath } from './globalConfigCore.js';
|
||||
|
||||
export function resolveAnthropicApiKey(): string | undefined {
|
||||
const envKey = process.env[envVarNameFromPath('anthropic_api_key')];
|
||||
if (envKey) return envKey;
|
||||
|
||||
const config = loadGlobalConfig();
|
||||
return config.anthropicApiKey;
|
||||
}
|
||||
|
||||
export function resolveOpenaiApiKey(): string | undefined {
|
||||
const envKey = process.env[envVarNameFromPath('openai_api_key')];
|
||||
if (envKey) return envKey;
|
||||
|
||||
const config = loadGlobalConfig();
|
||||
return config.openaiApiKey;
|
||||
}
|
||||
|
||||
export function resolveCodexCliPath(): string | undefined {
|
||||
const envPath = process.env[envVarNameFromPath('codex_cli_path')];
|
||||
if (envPath !== undefined) {
|
||||
return validateCliPath(envPath, 'TAKT_CODEX_CLI_PATH');
|
||||
}
|
||||
|
||||
const config: PersistedGlobalConfig = loadGlobalConfig();
|
||||
if (config.codexCliPath === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
return validateCliPath(config.codexCliPath, 'codex_cli_path');
|
||||
}
|
||||
|
||||
export function resolveClaudeCliPath(): string | undefined {
|
||||
const envPath = process.env[envVarNameFromPath('claude_cli_path')];
|
||||
if (envPath !== undefined) {
|
||||
return validateCliPath(envPath, 'TAKT_CLAUDE_CLI_PATH');
|
||||
}
|
||||
|
||||
const config: PersistedGlobalConfig = loadGlobalConfig();
|
||||
if (config.claudeCliPath === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
return validateCliPath(config.claudeCliPath, 'claude_cli_path');
|
||||
}
|
||||
|
||||
export function resolveCursorCliPath(): string | undefined {
|
||||
const envPath = process.env[envVarNameFromPath('cursor_cli_path')];
|
||||
if (envPath !== undefined) {
|
||||
return validateCliPath(envPath, 'TAKT_CURSOR_CLI_PATH');
|
||||
}
|
||||
|
||||
const config: PersistedGlobalConfig = loadGlobalConfig();
|
||||
if (config.cursorCliPath === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
return validateCliPath(config.cursorCliPath, 'cursor_cli_path');
|
||||
}
|
||||
|
||||
export function resolveOpencodeApiKey(): string | undefined {
|
||||
const envKey = process.env[envVarNameFromPath('opencode_api_key')];
|
||||
if (envKey) return envKey;
|
||||
|
||||
const config = loadGlobalConfig();
|
||||
return config.opencodeApiKey;
|
||||
}
|
||||
|
||||
export function resolveCursorApiKey(): string | undefined {
|
||||
const envKey = process.env[envVarNameFromPath('cursor_api_key')];
|
||||
if (envKey) return envKey;
|
||||
|
||||
const config = loadGlobalConfig();
|
||||
return config.cursorApiKey;
|
||||
}
|
||||
|
||||
export function resolveCopilotCliPath(): string | undefined {
|
||||
const envPath = process.env[envVarNameFromPath('copilot_cli_path')];
|
||||
if (envPath !== undefined) {
|
||||
return validateCliPath(envPath, 'TAKT_COPILOT_CLI_PATH');
|
||||
}
|
||||
|
||||
const config: PersistedGlobalConfig = loadGlobalConfig();
|
||||
if (config.copilotCliPath === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
return validateCliPath(config.copilotCliPath, 'copilot_cli_path');
|
||||
}
|
||||
|
||||
export function resolveCopilotGithubToken(): string | undefined {
|
||||
const envKey = process.env[envVarNameFromPath('copilot_github_token')];
|
||||
if (envKey) return envKey;
|
||||
|
||||
const config = loadGlobalConfig();
|
||||
return config.copilotGithubToken;
|
||||
}
|
||||
@ -0,0 +1,68 @@
|
||||
import { ProjectConfigSchema } from '../../../core/models/index.js';
|
||||
import {
|
||||
normalizePipelineConfig,
|
||||
normalizePersonaProviders,
|
||||
} from '../configNormalizers.js';
|
||||
import {
|
||||
MIGRATED_PROJECT_LOCAL_CONFIG_METADATA,
|
||||
type MigratedProjectLocalConfigKey,
|
||||
} from '../migratedProjectLocalKeys.js';
|
||||
import type { ProjectLocalConfig } from '../types.js';
|
||||
|
||||
export type GlobalMigratedProjectLocalFallback = Partial<
|
||||
Pick<ProjectLocalConfig, MigratedProjectLocalConfigKey>
|
||||
>;
|
||||
|
||||
export function removeMigratedProjectLocalKeys(config: Record<string, unknown>): void {
|
||||
for (const metadata of Object.values(MIGRATED_PROJECT_LOCAL_CONFIG_METADATA)) {
|
||||
delete config[metadata.legacyGlobalYamlKey];
|
||||
}
|
||||
}
|
||||
|
||||
export function extractMigratedProjectLocalFallback(
|
||||
rawConfig: Record<string, unknown>,
|
||||
): GlobalMigratedProjectLocalFallback {
|
||||
const rawMigratedConfig: Record<string, unknown> = {};
|
||||
for (const metadata of Object.values(MIGRATED_PROJECT_LOCAL_CONFIG_METADATA)) {
|
||||
const value = rawConfig[metadata.legacyGlobalYamlKey];
|
||||
if (value !== undefined) {
|
||||
rawMigratedConfig[metadata.legacyGlobalYamlKey] = value;
|
||||
}
|
||||
}
|
||||
if (Object.keys(rawMigratedConfig).length === 0) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const parsedMigratedConfig = ProjectConfigSchema.partial().parse(rawMigratedConfig);
|
||||
const {
|
||||
log_level,
|
||||
pipeline,
|
||||
persona_providers,
|
||||
branch_name_strategy,
|
||||
minimal_output,
|
||||
verbose,
|
||||
concurrency,
|
||||
task_poll_interval_ms,
|
||||
interactive_preview_movements,
|
||||
} = parsedMigratedConfig;
|
||||
|
||||
return {
|
||||
logLevel: log_level as ProjectLocalConfig['logLevel'],
|
||||
pipeline: normalizePipelineConfig(
|
||||
pipeline as {
|
||||
default_branch_prefix?: string;
|
||||
commit_message_template?: string;
|
||||
pr_body_template?: string;
|
||||
} | undefined,
|
||||
),
|
||||
personaProviders: normalizePersonaProviders(
|
||||
persona_providers as Record<string, string | { type?: string; provider?: string; model?: string }> | undefined,
|
||||
),
|
||||
branchNameStrategy: branch_name_strategy as ProjectLocalConfig['branchNameStrategy'],
|
||||
minimalOutput: minimal_output as ProjectLocalConfig['minimalOutput'],
|
||||
verbose: verbose as ProjectLocalConfig['verbose'],
|
||||
concurrency: concurrency as ProjectLocalConfig['concurrency'],
|
||||
taskPollIntervalMs: task_poll_interval_ms as ProjectLocalConfig['taskPollIntervalMs'],
|
||||
interactivePreviewMovements: interactive_preview_movements as ProjectLocalConfig['interactivePreviewMovements'],
|
||||
};
|
||||
}
|
||||
@ -3,7 +3,6 @@
|
||||
*/
|
||||
|
||||
export {
|
||||
GlobalConfigManager,
|
||||
invalidateGlobalConfigCache,
|
||||
loadGlobalConfig,
|
||||
saveGlobalConfig,
|
||||
|
||||
18
src/infra/config/migratedProjectLocalDefaults.ts
Normal file
18
src/infra/config/migratedProjectLocalDefaults.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import type { LoadedConfig } from './resolvedConfig.js';
|
||||
import {
|
||||
MIGRATED_PROJECT_LOCAL_CONFIG_KEYS,
|
||||
MIGRATED_PROJECT_LOCAL_CONFIG_METADATA,
|
||||
type MigratedProjectLocalConfigKey,
|
||||
} from './migratedProjectLocalKeys.js';
|
||||
|
||||
const defaults: Record<string, unknown> = {};
|
||||
for (const key of MIGRATED_PROJECT_LOCAL_CONFIG_KEYS) {
|
||||
const metadata = MIGRATED_PROJECT_LOCAL_CONFIG_METADATA[key] as { defaultValue?: unknown };
|
||||
const defaultValue = metadata.defaultValue;
|
||||
if (defaultValue !== undefined) {
|
||||
defaults[key] = defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
export const MIGRATED_PROJECT_LOCAL_DEFAULTS =
|
||||
defaults as Partial<Pick<LoadedConfig, MigratedProjectLocalConfigKey>>;
|
||||
26
src/infra/config/migratedProjectLocalKeys.ts
Normal file
26
src/infra/config/migratedProjectLocalKeys.ts
Normal file
@ -0,0 +1,26 @@
|
||||
type MigratedProjectLocalConfigMetadata = {
|
||||
readonly defaultValue?: unknown;
|
||||
readonly legacyGlobalYamlKey: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Project-local keys migrated from persisted global config.
|
||||
* Keep this metadata as the single source of truth.
|
||||
*/
|
||||
export const MIGRATED_PROJECT_LOCAL_CONFIG_METADATA = {
|
||||
logLevel: { defaultValue: 'info', legacyGlobalYamlKey: 'log_level' },
|
||||
pipeline: { legacyGlobalYamlKey: 'pipeline' },
|
||||
personaProviders: { legacyGlobalYamlKey: 'persona_providers' },
|
||||
branchNameStrategy: { legacyGlobalYamlKey: 'branch_name_strategy' },
|
||||
minimalOutput: { defaultValue: false, legacyGlobalYamlKey: 'minimal_output' },
|
||||
verbose: { defaultValue: false, legacyGlobalYamlKey: 'verbose' },
|
||||
concurrency: { defaultValue: 1, legacyGlobalYamlKey: 'concurrency' },
|
||||
taskPollIntervalMs: { defaultValue: 500, legacyGlobalYamlKey: 'task_poll_interval_ms' },
|
||||
interactivePreviewMovements: { defaultValue: 3, legacyGlobalYamlKey: 'interactive_preview_movements' },
|
||||
} as const satisfies Record<string, MigratedProjectLocalConfigMetadata>;
|
||||
|
||||
export type MigratedProjectLocalConfigKey = keyof typeof MIGRATED_PROJECT_LOCAL_CONFIG_METADATA;
|
||||
|
||||
export const MIGRATED_PROJECT_LOCAL_CONFIG_KEYS = Object.freeze(
|
||||
Object.keys(MIGRATED_PROJECT_LOCAL_CONFIG_METADATA) as MigratedProjectLocalConfigKey[],
|
||||
);
|
||||
@ -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
|
||||
|
||||
@ -1,140 +1,84 @@
|
||||
/**
|
||||
* Project-level configuration management
|
||||
*
|
||||
* Manages .takt/config.yaml for project-specific settings.
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { parse, stringify } from 'yaml';
|
||||
import { ProjectConfigSchema } from '../../../core/models/index.js';
|
||||
import { copyProjectResourcesToDir } from '../../resources/index.js';
|
||||
import type { ProjectLocalConfig } from '../types.js';
|
||||
import type { AnalyticsConfig, SubmoduleSelection } from '../../../core/models/persisted-global-config.js';
|
||||
import { applyProjectConfigEnvOverrides } from '../env/config-env-overrides.js';
|
||||
import {
|
||||
normalizeConfigProviderReference,
|
||||
type ConfigProviderReference,
|
||||
} from '../providerReference.js';
|
||||
import {
|
||||
normalizePipelineConfig,
|
||||
normalizeProviderProfiles,
|
||||
denormalizeProviderProfiles,
|
||||
denormalizeProviderOptions,
|
||||
normalizePersonaProviders,
|
||||
normalizePieceOverrides,
|
||||
denormalizePieceOverrides,
|
||||
} from '../configNormalizers.js';
|
||||
import { invalidateResolvedConfigCache } from '../resolutionCache.js';
|
||||
import { MIGRATED_PROJECT_LOCAL_DEFAULTS } from '../migratedProjectLocalDefaults.js';
|
||||
import type { MigratedProjectLocalConfigKey } from '../migratedProjectLocalKeys.js';
|
||||
import { getProjectConfigDir, getProjectConfigPath } from './projectConfigPaths.js';
|
||||
import {
|
||||
normalizeSubmodules,
|
||||
normalizeWithSubmodules,
|
||||
normalizeAnalytics,
|
||||
denormalizeAnalytics,
|
||||
formatIssuePath,
|
||||
} from './projectConfigTransforms.js';
|
||||
|
||||
export type { ProjectLocalConfig } from '../types.js';
|
||||
|
||||
/** Default project configuration */
|
||||
const DEFAULT_PROJECT_CONFIG: ProjectLocalConfig = {};
|
||||
type Assert<T extends true> = T;
|
||||
type IsNever<T> = [T] extends [never] ? true : false;
|
||||
|
||||
/**
|
||||
* Compile-time guard:
|
||||
* migrated fields must be owned by ProjectLocalConfig.
|
||||
*/
|
||||
const projectLocalConfigMigratedFieldGuard:
|
||||
Assert<IsNever<Exclude<MigratedProjectLocalConfigKey, keyof ProjectLocalConfig>>> = true;
|
||||
void projectLocalConfigMigratedFieldGuard;
|
||||
|
||||
const SUBMODULES_ALL = 'all';
|
||||
type ProviderType = NonNullable<ProjectLocalConfig['provider']>;
|
||||
type RawProviderReference = ConfigProviderReference<ProviderType>;
|
||||
|
||||
function normalizeSubmodules(raw: unknown): SubmoduleSelection | undefined {
|
||||
if (raw === undefined) return undefined;
|
||||
|
||||
if (typeof raw === 'string') {
|
||||
const normalized = raw.trim().toLowerCase();
|
||||
if (normalized === SUBMODULES_ALL) {
|
||||
return SUBMODULES_ALL;
|
||||
}
|
||||
throw new Error('Invalid submodules: string value must be "all"');
|
||||
}
|
||||
|
||||
if (Array.isArray(raw)) {
|
||||
if (raw.length === 0) {
|
||||
throw new Error('Invalid submodules: explicit path list must not be empty');
|
||||
}
|
||||
|
||||
const normalizedPaths = raw.map((entry) => {
|
||||
if (typeof entry !== 'string') {
|
||||
throw new Error('Invalid submodules: path entries must be strings');
|
||||
}
|
||||
const trimmed = entry.trim();
|
||||
if (trimmed.length === 0) {
|
||||
throw new Error('Invalid submodules: path entries must not be empty');
|
||||
}
|
||||
if (trimmed.includes('*')) {
|
||||
throw new Error(`Invalid submodules: wildcard is not supported (${trimmed})`);
|
||||
}
|
||||
return trimmed;
|
||||
});
|
||||
|
||||
return normalizedPaths;
|
||||
}
|
||||
|
||||
throw new Error('Invalid submodules: must be "all" or an explicit path list');
|
||||
}
|
||||
|
||||
function normalizeWithSubmodules(raw: unknown): boolean | undefined {
|
||||
if (raw === undefined) return undefined;
|
||||
if (typeof raw === 'boolean') return raw;
|
||||
throw new Error('Invalid with_submodules: value must be boolean');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get project takt config directory (.takt in project)
|
||||
* Note: Defined locally to avoid circular dependency with paths.ts
|
||||
*/
|
||||
function getConfigDir(projectDir: string): string {
|
||||
return join(resolve(projectDir), '.takt');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get project config file path
|
||||
* Note: Defined locally to avoid circular dependency with paths.ts
|
||||
*/
|
||||
function getConfigPath(projectDir: string): string {
|
||||
return join(getConfigDir(projectDir), 'config.yaml');
|
||||
}
|
||||
|
||||
function normalizeAnalytics(raw: Record<string, unknown> | undefined): AnalyticsConfig | undefined {
|
||||
if (!raw) return undefined;
|
||||
const enabled = typeof raw.enabled === 'boolean' ? raw.enabled : undefined;
|
||||
const eventsPath = typeof raw.events_path === 'string'
|
||||
? raw.events_path
|
||||
: (typeof raw.eventsPath === 'string' ? raw.eventsPath : undefined);
|
||||
const retentionDays = typeof raw.retention_days === 'number'
|
||||
? raw.retention_days
|
||||
: (typeof raw.retentionDays === 'number' ? raw.retentionDays : undefined);
|
||||
|
||||
if (enabled === undefined && eventsPath === undefined && retentionDays === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
return { enabled, eventsPath, retentionDays };
|
||||
}
|
||||
|
||||
function denormalizeAnalytics(config: AnalyticsConfig | undefined): Record<string, unknown> | undefined {
|
||||
if (!config) return undefined;
|
||||
const raw: Record<string, unknown> = {};
|
||||
if (config.enabled !== undefined) raw.enabled = config.enabled;
|
||||
if (config.eventsPath) raw.events_path = config.eventsPath;
|
||||
if (config.retentionDays !== undefined) raw.retention_days = config.retentionDays;
|
||||
return Object.keys(raw).length > 0 ? raw : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load project configuration from .takt/config.yaml
|
||||
*/
|
||||
export function loadProjectConfig(projectDir: string): ProjectLocalConfig {
|
||||
const configPath = getConfigPath(projectDir);
|
||||
const configPath = getProjectConfigPath(projectDir);
|
||||
|
||||
const rawConfig: Record<string, unknown> = {};
|
||||
if (existsSync(configPath)) {
|
||||
const content = readFileSync(configPath, 'utf-8');
|
||||
let parsed: unknown;
|
||||
try {
|
||||
const content = readFileSync(configPath, 'utf-8');
|
||||
const parsed = (parse(content) as Record<string, unknown> | null) ?? {};
|
||||
Object.assign(rawConfig, parsed);
|
||||
} catch {
|
||||
return { ...DEFAULT_PROJECT_CONFIG };
|
||||
parsed = parse(content);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
throw new Error(`Configuration error: failed to parse ${configPath}: ${message}`);
|
||||
}
|
||||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||
Object.assign(rawConfig, parsed as Record<string, unknown>);
|
||||
} else if (parsed != null) {
|
||||
throw new Error(`Configuration error: ${configPath} must be a YAML object.`);
|
||||
}
|
||||
}
|
||||
|
||||
applyProjectConfigEnvOverrides(rawConfig);
|
||||
const parsedConfig = ProjectConfigSchema.parse(rawConfig);
|
||||
const parsedResult = ProjectConfigSchema.safeParse(rawConfig);
|
||||
if (!parsedResult.success) {
|
||||
const firstIssue = parsedResult.error.issues[0];
|
||||
const issuePath = firstIssue ? formatIssuePath(firstIssue.path) : '(root)';
|
||||
const issueMessage = firstIssue?.message ?? 'Invalid configuration value';
|
||||
throw new Error(
|
||||
`Configuration error: invalid ${issuePath} in ${configPath}: ${issueMessage}`,
|
||||
);
|
||||
}
|
||||
const parsedConfig = parsedResult.data;
|
||||
|
||||
const {
|
||||
provider,
|
||||
@ -147,11 +91,16 @@ export function loadProjectConfig(projectDir: string): ProjectLocalConfig {
|
||||
provider_options,
|
||||
provider_profiles,
|
||||
analytics,
|
||||
log_level,
|
||||
pipeline,
|
||||
persona_providers,
|
||||
verbose,
|
||||
branch_name_strategy,
|
||||
minimal_output,
|
||||
concurrency,
|
||||
task_poll_interval_ms,
|
||||
interactive_preview_movements,
|
||||
piece_overrides,
|
||||
claude_cli_path,
|
||||
codex_cli_path,
|
||||
cursor_cli_path,
|
||||
copilot_cli_path,
|
||||
...rest
|
||||
} = parsedConfig;
|
||||
const normalizedProvider = normalizeConfigProviderReference(
|
||||
@ -163,10 +112,24 @@ export function loadProjectConfig(projectDir: string): ProjectLocalConfig {
|
||||
const normalizedSubmodules = normalizeSubmodules(submodules);
|
||||
const normalizedWithSubmodules = normalizeWithSubmodules(with_submodules);
|
||||
const effectiveWithSubmodules = normalizedSubmodules === undefined ? normalizedWithSubmodules : undefined;
|
||||
const normalizedPipeline = normalizePipelineConfig(
|
||||
pipeline as { default_branch_prefix?: string; commit_message_template?: string; pr_body_template?: string } | undefined,
|
||||
);
|
||||
const personaProviders = normalizePersonaProviders(
|
||||
persona_providers as Record<string, string | { type?: string; provider?: string; model?: string }> | undefined,
|
||||
);
|
||||
|
||||
return {
|
||||
...DEFAULT_PROJECT_CONFIG,
|
||||
...(rest as ProjectLocalConfig),
|
||||
logLevel: log_level as ProjectLocalConfig['logLevel'],
|
||||
pipeline: normalizedPipeline,
|
||||
personaProviders,
|
||||
branchNameStrategy: branch_name_strategy as ProjectLocalConfig['branchNameStrategy'],
|
||||
minimalOutput: minimal_output as boolean | undefined,
|
||||
concurrency: concurrency as number | undefined,
|
||||
taskPollIntervalMs: task_poll_interval_ms as number | undefined,
|
||||
interactivePreviewMovements: interactive_preview_movements as number | undefined,
|
||||
verbose: verbose as boolean | undefined,
|
||||
autoPr: auto_pr as boolean | undefined,
|
||||
draftPr: draft_pr as boolean | undefined,
|
||||
baseBranch: base_branch as string | undefined,
|
||||
@ -178,10 +141,6 @@ export function loadProjectConfig(projectDir: string): ProjectLocalConfig {
|
||||
providerOptions: normalizedProvider.providerOptions,
|
||||
providerProfiles: normalizeProviderProfiles(provider_profiles as Record<string, { default_permission_mode: unknown; movement_permission_overrides?: Record<string, unknown> }> | undefined),
|
||||
pieceOverrides: normalizePieceOverrides(piece_overrides as { quality_gates?: string[]; quality_gates_edit_only?: boolean; movements?: Record<string, { quality_gates?: string[] }> } | undefined),
|
||||
claudeCliPath: claude_cli_path as string | undefined,
|
||||
codexCliPath: codex_cli_path as string | undefined,
|
||||
cursorCliPath: cursor_cli_path as string | undefined,
|
||||
copilotCliPath: copilot_cli_path as string | undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@ -189,8 +148,8 @@ export function loadProjectConfig(projectDir: string): ProjectLocalConfig {
|
||||
* Save project configuration to .takt/config.yaml
|
||||
*/
|
||||
export function saveProjectConfig(projectDir: string, config: ProjectLocalConfig): void {
|
||||
const configDir = getConfigDir(projectDir);
|
||||
const configPath = getConfigPath(projectDir);
|
||||
const configDir = getProjectConfigDir(projectDir);
|
||||
const configPath = getProjectConfigPath(projectDir);
|
||||
|
||||
if (!existsSync(configDir)) {
|
||||
mkdirSync(configDir, { recursive: true });
|
||||
@ -214,12 +173,74 @@ export function saveProjectConfig(projectDir: string, config: ProjectLocalConfig
|
||||
} else {
|
||||
delete savePayload.provider_profiles;
|
||||
}
|
||||
const rawProviderOptions = denormalizeProviderOptions(config.providerOptions);
|
||||
if (rawProviderOptions) {
|
||||
savePayload.provider_options = rawProviderOptions;
|
||||
} else {
|
||||
delete savePayload.provider_options;
|
||||
}
|
||||
delete savePayload.providerProfiles;
|
||||
delete savePayload.providerOptions;
|
||||
delete savePayload.concurrency;
|
||||
delete savePayload.verbose;
|
||||
|
||||
if (config.autoPr !== undefined) savePayload.auto_pr = config.autoPr;
|
||||
if (config.draftPr !== undefined) savePayload.draft_pr = config.draftPr;
|
||||
if (config.baseBranch !== undefined) savePayload.base_branch = config.baseBranch;
|
||||
if (
|
||||
config.logLevel !== undefined
|
||||
&& config.logLevel !== MIGRATED_PROJECT_LOCAL_DEFAULTS.logLevel
|
||||
) {
|
||||
savePayload.log_level = config.logLevel;
|
||||
}
|
||||
if (config.branchNameStrategy !== undefined) savePayload.branch_name_strategy = config.branchNameStrategy;
|
||||
if (
|
||||
config.minimalOutput !== undefined
|
||||
&& config.minimalOutput !== MIGRATED_PROJECT_LOCAL_DEFAULTS.minimalOutput
|
||||
) {
|
||||
savePayload.minimal_output = config.minimalOutput;
|
||||
}
|
||||
if (
|
||||
config.taskPollIntervalMs !== undefined
|
||||
&& config.taskPollIntervalMs !== MIGRATED_PROJECT_LOCAL_DEFAULTS.taskPollIntervalMs
|
||||
) {
|
||||
savePayload.task_poll_interval_ms = config.taskPollIntervalMs;
|
||||
}
|
||||
if (
|
||||
config.interactivePreviewMovements !== undefined
|
||||
&& config.interactivePreviewMovements !== MIGRATED_PROJECT_LOCAL_DEFAULTS.interactivePreviewMovements
|
||||
) {
|
||||
savePayload.interactive_preview_movements = config.interactivePreviewMovements;
|
||||
}
|
||||
if (
|
||||
config.concurrency !== undefined
|
||||
&& config.concurrency !== MIGRATED_PROJECT_LOCAL_DEFAULTS.concurrency
|
||||
) {
|
||||
savePayload.concurrency = config.concurrency;
|
||||
}
|
||||
if (
|
||||
config.verbose !== undefined
|
||||
&& config.verbose !== MIGRATED_PROJECT_LOCAL_DEFAULTS.verbose
|
||||
) {
|
||||
savePayload.verbose = config.verbose;
|
||||
}
|
||||
delete savePayload.pipeline;
|
||||
if (config.pipeline) {
|
||||
const pipelineRaw: Record<string, unknown> = {};
|
||||
if (config.pipeline.defaultBranchPrefix !== undefined) {
|
||||
pipelineRaw.default_branch_prefix = config.pipeline.defaultBranchPrefix;
|
||||
}
|
||||
if (config.pipeline.commitMessageTemplate !== undefined) {
|
||||
pipelineRaw.commit_message_template = config.pipeline.commitMessageTemplate;
|
||||
}
|
||||
if (config.pipeline.prBodyTemplate !== undefined) {
|
||||
pipelineRaw.pr_body_template = config.pipeline.prBodyTemplate;
|
||||
}
|
||||
if (Object.keys(pipelineRaw).length > 0) savePayload.pipeline = pipelineRaw;
|
||||
}
|
||||
if (config.personaProviders && Object.keys(config.personaProviders).length > 0) {
|
||||
savePayload.persona_providers = config.personaProviders;
|
||||
}
|
||||
if (normalizedSubmodules !== undefined) {
|
||||
savePayload.submodules = normalizedSubmodules;
|
||||
delete savePayload.with_submodules;
|
||||
@ -235,6 +256,12 @@ export function saveProjectConfig(projectDir: string, config: ProjectLocalConfig
|
||||
delete savePayload.draftPr;
|
||||
delete savePayload.baseBranch;
|
||||
delete savePayload.withSubmodules;
|
||||
delete savePayload.logLevel;
|
||||
delete savePayload.branchNameStrategy;
|
||||
delete savePayload.minimalOutput;
|
||||
delete savePayload.taskPollIntervalMs;
|
||||
delete savePayload.interactivePreviewMovements;
|
||||
delete savePayload.personaProviders;
|
||||
|
||||
const rawPieceOverrides = denormalizePieceOverrides(config.pieceOverrides);
|
||||
if (rawPieceOverrides) {
|
||||
@ -247,9 +274,6 @@ export function saveProjectConfig(projectDir: string, config: ProjectLocalConfig
|
||||
invalidateResolvedConfigCache(projectDir);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a single field in project configuration
|
||||
*/
|
||||
export function updateProjectConfig<K extends keyof ProjectLocalConfig>(
|
||||
projectDir: string,
|
||||
key: K,
|
||||
@ -260,9 +284,6 @@ export function updateProjectConfig<K extends keyof ProjectLocalConfig>(
|
||||
saveProjectConfig(projectDir, config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set current piece in project config
|
||||
*/
|
||||
export function setCurrentPiece(projectDir: string, piece: string): void {
|
||||
updateProjectConfig(projectDir, 'piece', piece);
|
||||
}
|
||||
|
||||
9
src/infra/config/project/projectConfigPaths.ts
Normal file
9
src/infra/config/project/projectConfigPaths.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { join, resolve } from 'node:path';
|
||||
|
||||
export function getProjectConfigDir(projectDir: string): string {
|
||||
return join(resolve(projectDir), '.takt');
|
||||
}
|
||||
|
||||
export function getProjectConfigPath(projectDir: string): string {
|
||||
return join(getProjectConfigDir(projectDir), 'config.yaml');
|
||||
}
|
||||
75
src/infra/config/project/projectConfigTransforms.ts
Normal file
75
src/infra/config/project/projectConfigTransforms.ts
Normal file
@ -0,0 +1,75 @@
|
||||
import type { AnalyticsConfig, SubmoduleSelection } from '../../../core/models/persisted-global-config.js';
|
||||
|
||||
const SUBMODULES_ALL = 'all';
|
||||
|
||||
export function normalizeSubmodules(raw: unknown): SubmoduleSelection | undefined {
|
||||
if (raw === undefined) return undefined;
|
||||
|
||||
if (typeof raw === 'string') {
|
||||
const normalized = raw.trim().toLowerCase();
|
||||
if (normalized === SUBMODULES_ALL) {
|
||||
return SUBMODULES_ALL;
|
||||
}
|
||||
throw new Error('Invalid submodules: string value must be "all"');
|
||||
}
|
||||
|
||||
if (Array.isArray(raw)) {
|
||||
if (raw.length === 0) {
|
||||
throw new Error('Invalid submodules: explicit path list must not be empty');
|
||||
}
|
||||
|
||||
const normalizedPaths = raw.map((entry) => {
|
||||
if (typeof entry !== 'string') {
|
||||
throw new Error('Invalid submodules: path entries must be strings');
|
||||
}
|
||||
const trimmed = entry.trim();
|
||||
if (trimmed.length === 0) {
|
||||
throw new Error('Invalid submodules: path entries must not be empty');
|
||||
}
|
||||
if (trimmed.includes('*')) {
|
||||
throw new Error(`Invalid submodules: wildcard is not supported (${trimmed})`);
|
||||
}
|
||||
return trimmed;
|
||||
});
|
||||
|
||||
return normalizedPaths;
|
||||
}
|
||||
|
||||
throw new Error('Invalid submodules: must be "all" or an explicit path list');
|
||||
}
|
||||
|
||||
export function normalizeWithSubmodules(raw: unknown): boolean | undefined {
|
||||
if (raw === undefined) return undefined;
|
||||
if (typeof raw === 'boolean') return raw;
|
||||
throw new Error('Invalid with_submodules: value must be boolean');
|
||||
}
|
||||
|
||||
export function normalizeAnalytics(raw: Record<string, unknown> | undefined): AnalyticsConfig | undefined {
|
||||
if (!raw) return undefined;
|
||||
const enabled = typeof raw.enabled === 'boolean' ? raw.enabled : undefined;
|
||||
const eventsPath = typeof raw.events_path === 'string'
|
||||
? raw.events_path
|
||||
: (typeof raw.eventsPath === 'string' ? raw.eventsPath : undefined);
|
||||
const retentionDays = typeof raw.retention_days === 'number'
|
||||
? raw.retention_days
|
||||
: (typeof raw.retentionDays === 'number' ? raw.retentionDays : undefined);
|
||||
|
||||
if (enabled === undefined && eventsPath === undefined && retentionDays === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
return { enabled, eventsPath, retentionDays };
|
||||
}
|
||||
|
||||
export function denormalizeAnalytics(config: AnalyticsConfig | undefined): Record<string, unknown> | undefined {
|
||||
if (!config) return undefined;
|
||||
const raw: Record<string, unknown> = {};
|
||||
if (config.enabled !== undefined) raw.enabled = config.enabled;
|
||||
if (config.eventsPath) raw.events_path = config.eventsPath;
|
||||
if (config.retentionDays !== undefined) raw.retention_days = config.retentionDays;
|
||||
return Object.keys(raw).length > 0 ? raw : undefined;
|
||||
}
|
||||
|
||||
export function formatIssuePath(path: readonly PropertyKey[]): string {
|
||||
if (path.length === 0) return '(root)';
|
||||
return path.map((segment) => String(segment)).join('.');
|
||||
}
|
||||
40
src/infra/config/providerModelCompatibility.ts
Normal file
40
src/infra/config/providerModelCompatibility.ts
Normal file
@ -0,0 +1,40 @@
|
||||
import { parseProviderModel } from '../../shared/utils/providerModel.js';
|
||||
|
||||
const CLAUDE_MODEL_ALIASES = new Set(['opus', 'sonnet', 'haiku']);
|
||||
|
||||
type ProviderModelCompatibilityOptions = {
|
||||
modelFieldName?: string;
|
||||
requireProviderQualifiedModelForOpencode?: boolean;
|
||||
};
|
||||
|
||||
export function validateProviderModelCompatibility(
|
||||
provider: string | undefined,
|
||||
model: string | undefined,
|
||||
options: ProviderModelCompatibilityOptions = {},
|
||||
): void {
|
||||
const {
|
||||
modelFieldName = 'Configuration error: model',
|
||||
requireProviderQualifiedModelForOpencode = true,
|
||||
} = options;
|
||||
|
||||
if (!provider) return;
|
||||
|
||||
if (provider === 'opencode' && !model) {
|
||||
throw new Error(
|
||||
"Configuration error: provider 'opencode' requires model in 'provider/model' format (e.g. 'opencode/big-pickle')."
|
||||
);
|
||||
}
|
||||
|
||||
if (!model) return;
|
||||
|
||||
if ((provider === 'codex' || provider === 'opencode') && CLAUDE_MODEL_ALIASES.has(model)) {
|
||||
throw new Error(
|
||||
`Configuration error: model '${model}' is a Claude model alias but provider is '${provider}'. ` +
|
||||
`Either change the provider to 'claude' or specify a ${provider}-compatible model.`
|
||||
);
|
||||
}
|
||||
|
||||
if (provider === 'opencode' && requireProviderQualifiedModelForOpencode) {
|
||||
parseProviderModel(model, modelFieldName);
|
||||
}
|
||||
}
|
||||
@ -1,4 +1,4 @@
|
||||
import { loadGlobalConfig } from './global/globalConfig.js';
|
||||
import * as globalConfigModule from './global/globalConfig.js';
|
||||
import { loadProjectConfig } from './project/projectConfig.js';
|
||||
import { envVarNameFromPath } from './env/config-env-overrides.js';
|
||||
import {
|
||||
@ -9,6 +9,11 @@ import {
|
||||
setCachedResolvedValue,
|
||||
} from './resolutionCache.js';
|
||||
import type { ConfigParameterKey, LoadedConfig } from './resolvedConfig.js';
|
||||
import { MIGRATED_PROJECT_LOCAL_DEFAULTS } from './migratedProjectLocalDefaults.js';
|
||||
import {
|
||||
MIGRATED_PROJECT_LOCAL_CONFIG_KEYS,
|
||||
type MigratedProjectLocalConfigKey,
|
||||
} from './migratedProjectLocalKeys.js';
|
||||
|
||||
export type { ConfigParameterKey } from './resolvedConfig.js';
|
||||
export { invalidateResolvedConfigCache, invalidateAllResolvedConfigCache } from './resolutionCache.js';
|
||||
@ -36,6 +41,9 @@ interface ResolutionRule<K extends ConfigParameterKey> {
|
||||
mergeMode?: 'analytics';
|
||||
pieceValue?: (pieceContext: PieceContext | undefined) => LoadedConfig[K] | undefined;
|
||||
}
|
||||
type GlobalMigratedProjectLocalFallback = Partial<
|
||||
Pick<LoadedConfig, MigratedProjectLocalConfigKey>
|
||||
>;
|
||||
|
||||
function loadProjectConfigCached(projectDir: string) {
|
||||
const cached = getCachedProjectConfig(projectDir);
|
||||
@ -59,6 +67,13 @@ const PROVIDER_OPTIONS_ENV_PATHS = [
|
||||
'provider_options.claude.sandbox.excluded_commands',
|
||||
] as const;
|
||||
|
||||
const MIGRATED_PROJECT_LOCAL_RESOLUTION_REGISTRY = Object.fromEntries(
|
||||
MIGRATED_PROJECT_LOCAL_CONFIG_KEYS.map((key) => [key, { layers: ['local', 'global'] as const }]),
|
||||
) as Partial<{ [K in ConfigParameterKey]: ResolutionRule<K> }>;
|
||||
const MIGRATED_PROJECT_LOCAL_CONFIG_KEY_SET = new Set(
|
||||
MIGRATED_PROJECT_LOCAL_CONFIG_KEYS as ConfigParameterKey[],
|
||||
);
|
||||
|
||||
const RESOLUTION_REGISTRY: Partial<{ [K in ConfigParameterKey]: ResolutionRule<K> }> = {
|
||||
piece: { layers: ['local', 'global'] },
|
||||
provider: {
|
||||
@ -76,7 +91,7 @@ const RESOLUTION_REGISTRY: Partial<{ [K in ConfigParameterKey]: ResolutionRule<K
|
||||
autoPr: { layers: ['local', 'global'] },
|
||||
draftPr: { layers: ['local', 'global'] },
|
||||
analytics: { layers: ['local', 'global'], mergeMode: 'analytics' },
|
||||
verbose: { layers: ['local', 'global'] },
|
||||
...MIGRATED_PROJECT_LOCAL_RESOLUTION_REGISTRY,
|
||||
autoFetch: { layers: ['global'] },
|
||||
baseBranch: { layers: ['local', 'global'] },
|
||||
pieceOverrides: { layers: ['local', 'global'] },
|
||||
@ -84,7 +99,7 @@ const RESOLUTION_REGISTRY: Partial<{ [K in ConfigParameterKey]: ResolutionRule<K
|
||||
|
||||
function resolveAnalyticsMerged(
|
||||
project: ReturnType<typeof loadProjectConfigCached>,
|
||||
global: ReturnType<typeof loadGlobalConfig>,
|
||||
global: ReturnType<typeof globalConfigModule.loadGlobalConfig>,
|
||||
): LoadedConfig['analytics'] {
|
||||
const localAnalytics = project.analytics;
|
||||
const globalAnalytics = global.analytics;
|
||||
@ -101,7 +116,7 @@ function resolveAnalyticsMerged(
|
||||
|
||||
function resolveAnalyticsSource(
|
||||
project: ReturnType<typeof loadProjectConfigCached>,
|
||||
global: ReturnType<typeof loadGlobalConfig>,
|
||||
global: ReturnType<typeof globalConfigModule.loadGlobalConfig>,
|
||||
): ConfigValueSource {
|
||||
if (project.analytics !== undefined) return 'project';
|
||||
if (global.analytics !== undefined) return 'global';
|
||||
@ -116,16 +131,21 @@ function getLocalLayerValue<K extends ConfigParameterKey>(
|
||||
}
|
||||
|
||||
function getGlobalLayerValue<K extends ConfigParameterKey>(
|
||||
global: ReturnType<typeof loadGlobalConfig>,
|
||||
global: ReturnType<typeof globalConfigModule.loadGlobalConfig>,
|
||||
globalMigratedProjectLocalFallback: GlobalMigratedProjectLocalFallback,
|
||||
key: K,
|
||||
): LoadedConfig[K] | undefined {
|
||||
if (isMigratedProjectLocalConfigKey(key)) {
|
||||
return globalMigratedProjectLocalFallback[key] as LoadedConfig[K] | undefined;
|
||||
}
|
||||
return global[key as keyof typeof global] as LoadedConfig[K] | undefined;
|
||||
}
|
||||
|
||||
function resolveByRegistry<K extends ConfigParameterKey>(
|
||||
key: K,
|
||||
project: ReturnType<typeof loadProjectConfigCached>,
|
||||
global: ReturnType<typeof loadGlobalConfig>,
|
||||
global: ReturnType<typeof globalConfigModule.loadGlobalConfig>,
|
||||
globalMigratedProjectLocalFallback: GlobalMigratedProjectLocalFallback,
|
||||
options: ResolveConfigOptions | undefined,
|
||||
): ResolvedConfigValue<K> {
|
||||
const rule = (RESOLUTION_REGISTRY[key] ?? DEFAULT_RULE) as ResolutionRule<K>;
|
||||
@ -143,7 +163,7 @@ function resolveByRegistry<K extends ConfigParameterKey>(
|
||||
} else if (layer === 'piece') {
|
||||
value = rule.pieceValue?.(options?.pieceContext);
|
||||
} else {
|
||||
value = getGlobalLayerValue(global, key);
|
||||
value = getGlobalLayerValue(global, globalMigratedProjectLocalFallback, key);
|
||||
}
|
||||
if (value !== undefined) {
|
||||
if (layer === 'local') {
|
||||
@ -159,6 +179,11 @@ function resolveByRegistry<K extends ConfigParameterKey>(
|
||||
}
|
||||
}
|
||||
|
||||
const fallbackDefaultValue = MIGRATED_PROJECT_LOCAL_DEFAULTS[key as keyof typeof MIGRATED_PROJECT_LOCAL_DEFAULTS];
|
||||
if (fallbackDefaultValue !== undefined) {
|
||||
return { value: fallbackDefaultValue as LoadedConfig[K], source: 'default' };
|
||||
}
|
||||
|
||||
return { value: undefined as LoadedConfig[K], source: 'default' };
|
||||
}
|
||||
|
||||
@ -172,8 +197,17 @@ function resolveUncachedConfigValue<K extends ConfigParameterKey>(
|
||||
options?: ResolveConfigOptions,
|
||||
): ResolvedConfigValue<K> {
|
||||
const project = loadProjectConfigCached(projectDir);
|
||||
const global = loadGlobalConfig();
|
||||
return resolveByRegistry(key, project, global, options);
|
||||
const global = globalConfigModule.loadGlobalConfig();
|
||||
const globalMigratedProjectLocalFallback = isMigratedProjectLocalConfigKey(key)
|
||||
? globalConfigModule.loadGlobalMigratedProjectLocalFallback()
|
||||
: {};
|
||||
return resolveByRegistry(key, project, global, globalMigratedProjectLocalFallback, options);
|
||||
}
|
||||
|
||||
function isMigratedProjectLocalConfigKey(
|
||||
key: ConfigParameterKey,
|
||||
): key is MigratedProjectLocalConfigKey {
|
||||
return MIGRATED_PROJECT_LOCAL_CONFIG_KEY_SET.has(key);
|
||||
}
|
||||
|
||||
export function resolveConfigValueWithSource<K extends ConfigParameterKey>(
|
||||
|
||||
@ -1,7 +1,17 @@
|
||||
import type { PersistedGlobalConfig } from '../../core/models/persisted-global-config.js';
|
||||
import type { ProjectLocalConfig } from './types.js';
|
||||
import type { MigratedProjectLocalConfigKey } from './migratedProjectLocalKeys.js';
|
||||
|
||||
export interface LoadedConfig extends PersistedGlobalConfig {
|
||||
piece: string;
|
||||
export interface LoadedConfig
|
||||
extends PersistedGlobalConfig,
|
||||
Pick<ProjectLocalConfig, MigratedProjectLocalConfigKey> {
|
||||
piece?: string;
|
||||
logLevel: NonNullable<ProjectLocalConfig['logLevel']>;
|
||||
minimalOutput: NonNullable<ProjectLocalConfig['minimalOutput']>;
|
||||
verbose: NonNullable<ProjectLocalConfig['verbose']>;
|
||||
concurrency: NonNullable<ProjectLocalConfig['concurrency']>;
|
||||
taskPollIntervalMs: NonNullable<ProjectLocalConfig['taskPollIntervalMs']>;
|
||||
interactivePreviewMovements: NonNullable<ProjectLocalConfig['interactivePreviewMovements']>;
|
||||
}
|
||||
|
||||
export type ConfigParameterKey = keyof LoadedConfig;
|
||||
|
||||
@ -4,7 +4,13 @@
|
||||
|
||||
import type { MovementProviderOptions } from '../../core/models/piece-types.js';
|
||||
import type { ProviderPermissionProfiles } from '../../core/models/provider-profiles.js';
|
||||
import type { AnalyticsConfig, PieceOverrides, SubmoduleSelection } from '../../core/models/persisted-global-config.js';
|
||||
import type {
|
||||
AnalyticsConfig,
|
||||
PersonaProviderEntry,
|
||||
PieceOverrides,
|
||||
PipelineConfig,
|
||||
SubmoduleSelection,
|
||||
} from '../../core/models/persisted-global-config.js';
|
||||
|
||||
/** Project configuration stored in .takt/config.yaml */
|
||||
export interface ProjectLocalConfig {
|
||||
@ -26,8 +32,22 @@ export interface ProjectLocalConfig {
|
||||
withSubmodules?: boolean;
|
||||
/** Verbose output mode */
|
||||
verbose?: boolean;
|
||||
/** Project log level */
|
||||
logLevel?: 'debug' | 'info' | 'warn' | 'error';
|
||||
/** Pipeline execution settings */
|
||||
pipeline?: PipelineConfig;
|
||||
/** Per-persona provider/model overrides */
|
||||
personaProviders?: Record<string, PersonaProviderEntry>;
|
||||
/** Branch name generation strategy */
|
||||
branchNameStrategy?: 'romaji' | 'ai';
|
||||
/** Minimal output mode */
|
||||
minimalOutput?: boolean;
|
||||
/** Number of tasks to run concurrently in takt run (1-10) */
|
||||
concurrency?: number;
|
||||
/** Polling interval in ms for task pickup */
|
||||
taskPollIntervalMs?: number;
|
||||
/** Number of movement previews in interactive mode */
|
||||
interactivePreviewMovements?: number;
|
||||
/** Project-level analytics overrides */
|
||||
analytics?: AnalyticsConfig;
|
||||
/** Provider-specific options (overrides global, overridden by piece/movement) */
|
||||
@ -36,14 +56,6 @@ export interface ProjectLocalConfig {
|
||||
providerProfiles?: ProviderPermissionProfiles;
|
||||
/** Piece-level overrides (quality_gates, etc.) */
|
||||
pieceOverrides?: PieceOverrides;
|
||||
/** Claude Code CLI path override (project-level) */
|
||||
claudeCliPath?: string;
|
||||
/** Codex CLI path override (project-level) */
|
||||
codexCliPath?: string;
|
||||
/** cursor-agent CLI path override (project-level) */
|
||||
cursorCliPath?: string;
|
||||
/** Copilot CLI path override (project-level) */
|
||||
copilotCliPath?: string;
|
||||
}
|
||||
|
||||
/** Persona session data for persistence */
|
||||
|
||||
@ -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(),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@ -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(),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -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(),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user