add-model-to-persona-providers (#324)
* takt: add-model-to-persona-providers * refactor: loadConfigを廃止しresolveConfigValueにキー単位解決を一元化 loadConfig()による一括マージを廃止し、resolveConfigValue()でキーごとに global/project/piece/envの優先順位を宣言的に解決する方式に移行。 providerOptionsの優先順位をglobal < piece < project < envに修正し、 sourceトラッキングでOptionsBuilderのマージ方向を制御する。
This commit is contained in:
parent
22901cd8cb
commit
dec77e069e
@ -55,12 +55,17 @@ concurrency: 2 # Concurrent task execution for takt run (1-10)
|
|||||||
# =====================================
|
# =====================================
|
||||||
# Piece-related settings (global defaults)
|
# Piece-related settings (global defaults)
|
||||||
# =====================================
|
# =====================================
|
||||||
# 1) Route provider per persona
|
# 1) Route provider/model per persona
|
||||||
# persona_providers:
|
# persona_providers:
|
||||||
# coder: codex # Run coder persona on codex
|
# coder:
|
||||||
# reviewer: claude # Run reviewer persona on claude
|
# 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 (global < project < piece)
|
# 2) Provider options
|
||||||
|
# Priority (for piece-capable keys such as provider/model/provider_options):
|
||||||
|
# global < piece < project < env
|
||||||
# provider_options:
|
# provider_options:
|
||||||
# codex:
|
# codex:
|
||||||
# network_access: true # Allow network access for Codex
|
# network_access: true # Allow network access for Codex
|
||||||
|
|||||||
@ -55,12 +55,17 @@ concurrency: 2 # takt run の同時実行数(1-10)
|
|||||||
# =====================================
|
# =====================================
|
||||||
# ピースにも関わる設定(global defaults)
|
# ピースにも関わる設定(global defaults)
|
||||||
# =====================================
|
# =====================================
|
||||||
# 1) ペルソナ単位でプロバイダーを切り替える
|
# 1) ペルソナ単位でプロバイダー・モデルを切り替える
|
||||||
# persona_providers:
|
# persona_providers:
|
||||||
# coder: codex # coderペルソナはcodexで実行
|
# coder:
|
||||||
# reviewer: claude # reviewerペルソナはclaudeで実行
|
# provider: codex # coderペルソナはcodexで実行
|
||||||
|
# model: o3-mini # 使用モデル(省略可)
|
||||||
|
# reviewer:
|
||||||
|
# provider: claude # reviewerペルソナはclaudeで実行
|
||||||
|
|
||||||
# 2) provider 固有オプション(global < project < piece)
|
# 2) provider 固有オプション
|
||||||
|
# 優先順位(provider/model/provider_options 等の piece 対応キー):
|
||||||
|
# global < piece < project < env
|
||||||
# provider_options:
|
# provider_options:
|
||||||
# codex:
|
# codex:
|
||||||
# network_access: true # Codex実行時のネットワークアクセス許可
|
# network_access: true # Codex実行時のネットワークアクセス許可
|
||||||
|
|||||||
@ -34,11 +34,14 @@ interactive_preview_movements: 3 # インタラクティブモードでの move
|
|||||||
# - gradle # .runtime/ に Gradle キャッシュ/設定を準備
|
# - gradle # .runtime/ に Gradle キャッシュ/設定を準備
|
||||||
# - node # .runtime/ に npm キャッシュを準備
|
# - node # .runtime/ に npm キャッシュを準備
|
||||||
|
|
||||||
# persona ごとの provider 上書き(省略可)
|
# persona ごとの provider / model 上書き(省略可)
|
||||||
# piece を複製せずに特定の persona を別の provider にルーティング
|
# piece を複製せずに特定の persona を別の provider / model にルーティング
|
||||||
# persona_providers:
|
# persona_providers:
|
||||||
# coder: codex # coder を Codex で実行
|
# coder:
|
||||||
# ai-antipattern-reviewer: claude # レビュアーは Claude のまま
|
# provider: codex # coder を Codex で実行
|
||||||
|
# model: o3-mini # 使用モデル(省略可)
|
||||||
|
# ai-antipattern-reviewer:
|
||||||
|
# provider: claude # レビュアーは Claude のまま
|
||||||
|
|
||||||
# provider 固有のパーミッションプロファイル(省略可)
|
# provider 固有のパーミッションプロファイル(省略可)
|
||||||
# 優先順位: プロジェクト上書き > グローバル上書き > プロジェクトデフォルト > グローバルデフォルト > required_permission_mode(下限)
|
# 優先順位: プロジェクト上書き > グローバル上書き > プロジェクトデフォルト > グローバルデフォルト > required_permission_mode(下限)
|
||||||
@ -97,7 +100,7 @@ interactive_preview_movements: 3 # インタラクティブモードでの move
|
|||||||
| `verbose` | boolean | - | 詳細出力モード |
|
| `verbose` | boolean | - | 詳細出力モード |
|
||||||
| `minimal_output` | boolean | `false` | AI 出力を抑制(CI 向け) |
|
| `minimal_output` | boolean | `false` | AI 出力を抑制(CI 向け) |
|
||||||
| `runtime` | object | - | ランタイム環境デフォルト(例: `prepare: [gradle, node]`) |
|
| `runtime` | object | - | ランタイム環境デフォルト(例: `prepare: [gradle, node]`) |
|
||||||
| `persona_providers` | object | - | persona ごとの provider 上書き(例: `coder: codex`) |
|
| `persona_providers` | object | - | persona ごとの provider / model 上書き(例: `coder: { provider: codex, model: o3-mini }`) |
|
||||||
| `provider_options` | object | - | グローバルな provider 固有オプション |
|
| `provider_options` | object | - | グローバルな provider 固有オプション |
|
||||||
| `provider_profiles` | object | - | provider 固有のパーミッションプロファイル |
|
| `provider_profiles` | object | - | provider 固有のパーミッションプロファイル |
|
||||||
| `anthropic_api_key` | string | - | Claude 用 Anthropic API キー |
|
| `anthropic_api_key` | string | - | Claude 用 Anthropic API キー |
|
||||||
@ -286,16 +289,21 @@ movement の `required_permission_mode` は最低限の下限を設定します
|
|||||||
|
|
||||||
### Persona Provider
|
### Persona Provider
|
||||||
|
|
||||||
piece を複製せずに、特定の persona を別の provider にルーティングできます。
|
piece を複製せずに、特定の persona を別の provider や model にルーティングできます。
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
# ~/.takt/config.yaml
|
# ~/.takt/config.yaml
|
||||||
persona_providers:
|
persona_providers:
|
||||||
coder: codex # coder persona を Codex で実行
|
coder:
|
||||||
ai-antipattern-reviewer: claude # レビュアーは Claude のまま
|
provider: codex # coder persona を Codex で実行
|
||||||
|
model: o3-mini # 使用モデル(省略可)
|
||||||
|
ai-antipattern-reviewer:
|
||||||
|
provider: claude # レビュアーは Claude のまま
|
||||||
```
|
```
|
||||||
|
|
||||||
これにより、単一の piece 内で provider を混在させることができます。persona 名は movement 定義の `persona` キーに対してマッチされます。
|
`provider` と `model` はいずれも省略可能です。`model` の解決優先度: movement YAML の `model` > `persona_providers[persona].model` > グローバル `model`。
|
||||||
|
|
||||||
|
これにより、単一の piece 内で provider や model を混在させることができます。persona 名は movement 定義の `persona` キーに対してマッチされます。
|
||||||
|
|
||||||
## Piece カテゴリ
|
## Piece カテゴリ
|
||||||
|
|
||||||
|
|||||||
@ -34,11 +34,14 @@ interactive_preview_movements: 3 # Movement previews in interactive mode (0-10,
|
|||||||
# - gradle # Prepare Gradle cache/config in .runtime/
|
# - gradle # Prepare Gradle cache/config in .runtime/
|
||||||
# - node # Prepare npm cache in .runtime/
|
# - node # Prepare npm cache in .runtime/
|
||||||
|
|
||||||
# Per-persona provider overrides (optional)
|
# Per-persona provider/model overrides (optional)
|
||||||
# Route specific personas to different providers without duplicating pieces
|
# Route specific personas to different providers and models without duplicating pieces
|
||||||
# persona_providers:
|
# persona_providers:
|
||||||
# coder: codex # Run coder on Codex
|
# coder:
|
||||||
# ai-antipattern-reviewer: claude # Keep reviewers on Claude
|
# provider: codex # Run coder on Codex
|
||||||
|
# model: o3-mini # Use o3-mini model (optional)
|
||||||
|
# ai-antipattern-reviewer:
|
||||||
|
# provider: claude # Keep reviewers on Claude
|
||||||
|
|
||||||
# Provider-specific permission profiles (optional)
|
# Provider-specific permission profiles (optional)
|
||||||
# Priority: project override > global override > project default > global default > required_permission_mode (floor)
|
# Priority: project override > global override > project default > global default > required_permission_mode (floor)
|
||||||
@ -97,7 +100,7 @@ interactive_preview_movements: 3 # Movement previews in interactive mode (0-10,
|
|||||||
| `verbose` | boolean | - | Verbose output mode |
|
| `verbose` | boolean | - | Verbose output mode |
|
||||||
| `minimal_output` | boolean | `false` | Suppress AI output (for CI) |
|
| `minimal_output` | boolean | `false` | Suppress AI output (for CI) |
|
||||||
| `runtime` | object | - | Runtime environment defaults (e.g., `prepare: [gradle, node]`) |
|
| `runtime` | object | - | Runtime environment defaults (e.g., `prepare: [gradle, node]`) |
|
||||||
| `persona_providers` | object | - | Per-persona provider overrides (e.g., `coder: codex`) |
|
| `persona_providers` | object | - | Per-persona provider/model overrides (e.g., `coder: { provider: codex, model: o3-mini }`) |
|
||||||
| `provider_options` | object | - | Global provider-specific options |
|
| `provider_options` | object | - | Global provider-specific options |
|
||||||
| `provider_profiles` | object | - | Provider-specific permission profiles |
|
| `provider_profiles` | object | - | Provider-specific permission profiles |
|
||||||
| `anthropic_api_key` | string | - | Anthropic API key for Claude |
|
| `anthropic_api_key` | string | - | Anthropic API key for Claude |
|
||||||
@ -286,16 +289,21 @@ The `required_permission_mode` on a movement sets the minimum floor. If the reso
|
|||||||
|
|
||||||
### Persona Providers
|
### Persona Providers
|
||||||
|
|
||||||
Route specific personas to different providers without duplicating pieces:
|
Route specific personas to different providers and models without duplicating pieces:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
# ~/.takt/config.yaml
|
# ~/.takt/config.yaml
|
||||||
persona_providers:
|
persona_providers:
|
||||||
coder: codex # Run coder persona on Codex
|
coder:
|
||||||
ai-antipattern-reviewer: claude # Keep reviewers on Claude
|
provider: codex # Run coder persona on Codex
|
||||||
|
model: o3-mini # Use o3-mini model (optional)
|
||||||
|
ai-antipattern-reviewer:
|
||||||
|
provider: claude # Keep reviewers on Claude
|
||||||
```
|
```
|
||||||
|
|
||||||
This allows mixing providers within a single piece. The persona name is matched against the `persona` key in the movement definition.
|
Both `provider` and `model` are optional. `model` resolution priority: movement YAML `model` > `persona_providers[persona].model` > global `model`.
|
||||||
|
|
||||||
|
This allows mixing providers and models within a single piece. The persona name is matched against the `persona` key in the movement definition.
|
||||||
|
|
||||||
## Piece Categories
|
## Piece Categories
|
||||||
|
|
||||||
|
|||||||
@ -34,6 +34,11 @@ vi.mock('../features/tasks/execute/selectAndExecute.js', () => ({
|
|||||||
determinePiece: vi.fn(),
|
determinePiece: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock('../infra/task/index.js', async (importOriginal) => ({
|
||||||
|
...(await importOriginal<Record<string, unknown>>()),
|
||||||
|
summarizeTaskName: vi.fn().mockResolvedValue('test-task'),
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock('../infra/github/issue.js', () => ({
|
vi.mock('../infra/github/issue.js', () => ({
|
||||||
isIssueReference: vi.fn((s: string) => /^#\d+$/.test(s)),
|
isIssueReference: vi.fn((s: string) => /^#\d+$/.test(s)),
|
||||||
resolveIssueTask: vi.fn(),
|
resolveIssueTask: vi.fn(),
|
||||||
|
|||||||
@ -20,16 +20,6 @@ vi.mock('../infra/config/global/globalConfig.js', () => ({
|
|||||||
loadGlobalConfig: () => ({}),
|
loadGlobalConfig: () => ({}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../infra/config/loadConfig.js', () => ({
|
|
||||||
loadConfig: () => ({
|
|
||||||
global: {
|
|
||||||
language: 'en',
|
|
||||||
enableBuiltinPieces: true,
|
|
||||||
},
|
|
||||||
project: {},
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const mockLogError = vi.fn();
|
const mockLogError = vi.fn();
|
||||||
const mockInfo = vi.fn();
|
const mockInfo = vi.fn();
|
||||||
vi.mock('../shared/ui/index.js', () => ({
|
vi.mock('../shared/ui/index.js', () => ({
|
||||||
|
|||||||
@ -35,9 +35,9 @@ import {
|
|||||||
getLanguage,
|
getLanguage,
|
||||||
loadProjectConfig,
|
loadProjectConfig,
|
||||||
isVerboseMode,
|
isVerboseMode,
|
||||||
|
resolveConfigValue,
|
||||||
invalidateGlobalConfigCache,
|
invalidateGlobalConfigCache,
|
||||||
} from '../infra/config/index.js';
|
} from '../infra/config/index.js';
|
||||||
import { loadConfig } from '../infra/config/loadConfig.js';
|
|
||||||
|
|
||||||
describe('getBuiltinPiece', () => {
|
describe('getBuiltinPiece', () => {
|
||||||
it('should return builtin piece when it exists in resources', () => {
|
it('should return builtin piece when it exists in resources', () => {
|
||||||
@ -472,7 +472,7 @@ describe('analytics config resolution', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should merge analytics as project > global in loadConfig', () => {
|
it('should merge analytics as project > global in resolveConfigValue', () => {
|
||||||
const globalConfigDir = process.env.TAKT_CONFIG_DIR!;
|
const globalConfigDir = process.env.TAKT_CONFIG_DIR!;
|
||||||
mkdirSync(globalConfigDir, { recursive: true });
|
mkdirSync(globalConfigDir, { recursive: true });
|
||||||
writeFileSync(join(globalConfigDir, 'config.yaml'), [
|
writeFileSync(join(globalConfigDir, 'config.yaml'), [
|
||||||
@ -492,8 +492,8 @@ describe('analytics config resolution', () => {
|
|||||||
' retention_days: 14',
|
' retention_days: 14',
|
||||||
].join('\n'));
|
].join('\n'));
|
||||||
|
|
||||||
const config = loadConfig(testDir);
|
const analytics = resolveConfigValue(testDir, 'analytics');
|
||||||
expect(config.analytics).toEqual({
|
expect(analytics).toEqual({
|
||||||
enabled: true,
|
enabled: true,
|
||||||
eventsPath: '/tmp/project-analytics',
|
eventsPath: '/tmp/project-analytics',
|
||||||
retentionDays: 14,
|
retentionDays: 14,
|
||||||
|
|||||||
@ -1,10 +1,10 @@
|
|||||||
/**
|
/**
|
||||||
* Tests for persona_providers config-level provider override.
|
* Tests for persona_providers config-level provider/model override.
|
||||||
*
|
*
|
||||||
* Verifies movement-level provider resolution for stepProvider:
|
* Verifies movement-level provider/model resolution for stepProvider/stepModel:
|
||||||
* 1. Movement YAML provider (highest)
|
* 1. Movement YAML provider (highest)
|
||||||
* 2. persona_providers[personaDisplayName]
|
* 2. persona_providers[personaDisplayName].provider / .model
|
||||||
* 3. CLI provider (lowest)
|
* 3. CLI provider / model (lowest)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
@ -46,7 +46,7 @@ describe('PieceEngine persona_providers override', () => {
|
|||||||
applyDefaultMocks();
|
applyDefaultMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should use persona_providers when movement has no provider and persona matches', async () => {
|
it('should use persona_providers.provider when movement has no provider and persona matches', async () => {
|
||||||
const movement = makeMovement('implement', {
|
const movement = makeMovement('implement', {
|
||||||
personaDisplayName: 'coder',
|
personaDisplayName: 'coder',
|
||||||
rules: [makeRule('done', 'COMPLETE')],
|
rules: [makeRule('done', 'COMPLETE')],
|
||||||
@ -66,7 +66,7 @@ describe('PieceEngine persona_providers override', () => {
|
|||||||
const engine = new PieceEngine(config, '/tmp/project', 'test task', {
|
const engine = new PieceEngine(config, '/tmp/project', 'test task', {
|
||||||
projectCwd: '/tmp/project',
|
projectCwd: '/tmp/project',
|
||||||
provider: 'claude',
|
provider: 'claude',
|
||||||
personaProviders: { coder: 'codex' },
|
personaProviders: { coder: { provider: 'codex' } },
|
||||||
});
|
});
|
||||||
|
|
||||||
await engine.run();
|
await engine.run();
|
||||||
@ -96,7 +96,7 @@ describe('PieceEngine persona_providers override', () => {
|
|||||||
const engine = new PieceEngine(config, '/tmp/project', 'test task', {
|
const engine = new PieceEngine(config, '/tmp/project', 'test task', {
|
||||||
projectCwd: '/tmp/project',
|
projectCwd: '/tmp/project',
|
||||||
provider: 'claude',
|
provider: 'claude',
|
||||||
personaProviders: { coder: 'codex' },
|
personaProviders: { coder: { provider: 'codex' } },
|
||||||
});
|
});
|
||||||
|
|
||||||
await engine.run();
|
await engine.run();
|
||||||
@ -127,7 +127,7 @@ describe('PieceEngine persona_providers override', () => {
|
|||||||
const engine = new PieceEngine(config, '/tmp/project', 'test task', {
|
const engine = new PieceEngine(config, '/tmp/project', 'test task', {
|
||||||
projectCwd: '/tmp/project',
|
projectCwd: '/tmp/project',
|
||||||
provider: 'mock',
|
provider: 'mock',
|
||||||
personaProviders: { coder: 'codex' },
|
personaProviders: { coder: { provider: 'codex' } },
|
||||||
});
|
});
|
||||||
|
|
||||||
await engine.run();
|
await engine.run();
|
||||||
@ -194,7 +194,7 @@ describe('PieceEngine persona_providers override', () => {
|
|||||||
const engine = new PieceEngine(config, '/tmp/project', 'test task', {
|
const engine = new PieceEngine(config, '/tmp/project', 'test task', {
|
||||||
projectCwd: '/tmp/project',
|
projectCwd: '/tmp/project',
|
||||||
provider: 'claude',
|
provider: 'claude',
|
||||||
personaProviders: { coder: 'codex' },
|
personaProviders: { coder: { provider: 'codex' } },
|
||||||
});
|
});
|
||||||
|
|
||||||
await engine.run();
|
await engine.run();
|
||||||
@ -207,4 +207,66 @@ describe('PieceEngine persona_providers override', () => {
|
|||||||
expect(calls[1][2].provider).toBe('claude');
|
expect(calls[1][2].provider).toBe('claude');
|
||||||
expect(calls[1][2].stepProvider).toBe('codex');
|
expect(calls[1][2].stepProvider).toBe('codex');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should use persona_providers.model as stepModel when step.model is undefined', async () => {
|
||||||
|
const movement = makeMovement('implement', {
|
||||||
|
personaDisplayName: 'coder',
|
||||||
|
rules: [makeRule('done', 'COMPLETE')],
|
||||||
|
});
|
||||||
|
const config: PieceConfig = {
|
||||||
|
name: 'persona-model-test',
|
||||||
|
movements: [movement],
|
||||||
|
initialMovement: 'implement',
|
||||||
|
maxMovements: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
mockRunAgentSequence([
|
||||||
|
makeResponse({ persona: movement.persona, content: 'done' }),
|
||||||
|
]);
|
||||||
|
mockDetectMatchedRuleSequence([{ index: 0, method: 'phase1_tag' }]);
|
||||||
|
|
||||||
|
const engine = new PieceEngine(config, '/tmp/project', 'test task', {
|
||||||
|
projectCwd: '/tmp/project',
|
||||||
|
provider: 'claude',
|
||||||
|
model: 'global-model',
|
||||||
|
personaProviders: { coder: { provider: 'codex', model: 'o3-mini' } },
|
||||||
|
});
|
||||||
|
|
||||||
|
await engine.run();
|
||||||
|
|
||||||
|
const options = vi.mocked(runAgent).mock.calls[0][2];
|
||||||
|
expect(options.stepProvider).toBe('codex');
|
||||||
|
expect(options.stepModel).toBe('o3-mini');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fallback to input.model when persona_providers.model is not set', async () => {
|
||||||
|
const movement = makeMovement('implement', {
|
||||||
|
personaDisplayName: 'coder',
|
||||||
|
rules: [makeRule('done', 'COMPLETE')],
|
||||||
|
});
|
||||||
|
const config: PieceConfig = {
|
||||||
|
name: 'persona-model-fallback',
|
||||||
|
movements: [movement],
|
||||||
|
initialMovement: 'implement',
|
||||||
|
maxMovements: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
mockRunAgentSequence([
|
||||||
|
makeResponse({ persona: movement.persona, content: 'done' }),
|
||||||
|
]);
|
||||||
|
mockDetectMatchedRuleSequence([{ index: 0, method: 'phase1_tag' }]);
|
||||||
|
|
||||||
|
const engine = new PieceEngine(config, '/tmp/project', 'test task', {
|
||||||
|
projectCwd: '/tmp/project',
|
||||||
|
provider: 'claude',
|
||||||
|
model: 'global-model',
|
||||||
|
personaProviders: { coder: { provider: 'codex' } },
|
||||||
|
});
|
||||||
|
|
||||||
|
await engine.run();
|
||||||
|
|
||||||
|
const options = vi.mocked(runAgent).mock.calls[0][2];
|
||||||
|
expect(options.stepProvider).toBe('codex');
|
||||||
|
expect(options.stepModel).toBe('global-model');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -54,7 +54,7 @@ describe('PieceEngine provider_options resolution', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should merge provider_options in order: global < project < movement', async () => {
|
it('should merge provider_options in order: global < piece/movement < project', async () => {
|
||||||
const movement = makeMovement('implement', {
|
const movement = makeMovement('implement', {
|
||||||
providerOptions: {
|
providerOptions: {
|
||||||
codex: { networkAccess: false },
|
codex: { networkAccess: false },
|
||||||
@ -78,6 +78,7 @@ describe('PieceEngine provider_options resolution', () => {
|
|||||||
engine = new PieceEngine(config, tmpDir, 'test task', {
|
engine = new PieceEngine(config, tmpDir, 'test task', {
|
||||||
projectCwd: tmpDir,
|
projectCwd: tmpDir,
|
||||||
provider: 'claude',
|
provider: 'claude',
|
||||||
|
providerOptionsSource: 'project',
|
||||||
providerOptions: {
|
providerOptions: {
|
||||||
codex: { networkAccess: true },
|
codex: { networkAccess: true },
|
||||||
claude: { sandbox: { allowUnsandboxedCommands: false } },
|
claude: { sandbox: { allowUnsandboxedCommands: false } },
|
||||||
@ -89,7 +90,7 @@ describe('PieceEngine provider_options resolution', () => {
|
|||||||
|
|
||||||
const options = vi.mocked(runAgent).mock.calls[0]?.[2];
|
const options = vi.mocked(runAgent).mock.calls[0]?.[2];
|
||||||
expect(options?.providerOptions).toEqual({
|
expect(options?.providerOptions).toEqual({
|
||||||
codex: { networkAccess: false },
|
codex: { networkAccess: true },
|
||||||
opencode: { networkAccess: true },
|
opencode: { networkAccess: true },
|
||||||
claude: {
|
claude: {
|
||||||
sandbox: {
|
sandbox: {
|
||||||
|
|||||||
@ -7,32 +7,20 @@ import { tmpdir } from 'node:os';
|
|||||||
import { dirname, join } from 'node:path';
|
import { dirname, join } from 'node:path';
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
const loadConfigMock = vi.hoisted(() => vi.fn());
|
const resolvedState = vi.hoisted(() => ({ value: {} as Record<string, unknown> }));
|
||||||
|
|
||||||
vi.mock('../infra/config/paths.js', () => ({
|
vi.mock('../infra/config/paths.js', () => ({
|
||||||
getGlobalConfigDir: () => '/tmp/.takt',
|
getGlobalConfigDir: () => '/tmp/.takt',
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../infra/config/loadConfig.js', () => ({
|
|
||||||
loadConfig: loadConfigMock,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock('../infra/config/resolvePieceConfigValue.js', () => ({
|
vi.mock('../infra/config/resolvePieceConfigValue.js', () => ({
|
||||||
resolvePieceConfigValue: (_projectDir: string, key: string) => {
|
resolvePieceConfigValue: (_projectDir: string, key: string) => {
|
||||||
const loaded = loadConfigMock() as Record<string, Record<string, unknown>>;
|
return resolvedState.value[key];
|
||||||
const global = loaded?.global ?? {};
|
|
||||||
const project = loaded?.project ?? {};
|
|
||||||
const merged: Record<string, unknown> = { ...global, ...project };
|
|
||||||
return merged[key];
|
|
||||||
},
|
},
|
||||||
resolvePieceConfigValues: (_projectDir: string, keys: readonly string[]) => {
|
resolvePieceConfigValues: (_projectDir: string, keys: readonly string[]) => {
|
||||||
const loaded = loadConfigMock() as Record<string, Record<string, unknown>>;
|
|
||||||
const global = loaded?.global ?? {};
|
|
||||||
const project = loaded?.project ?? {};
|
|
||||||
const merged: Record<string, unknown> = { ...global, ...project };
|
|
||||||
const result: Record<string, unknown> = {};
|
const result: Record<string, unknown> = {};
|
||||||
for (const key of keys) {
|
for (const key of keys) {
|
||||||
result[key] = merged[key];
|
result[key] = resolvedState.value[key];
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
},
|
},
|
||||||
@ -49,15 +37,12 @@ function createTempCategoriesPath(): string {
|
|||||||
|
|
||||||
describe('getPieceCategoriesPath', () => {
|
describe('getPieceCategoriesPath', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
loadConfigMock.mockReset();
|
resolvedState.value = {};
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return configured path when pieceCategoriesFile is set', () => {
|
it('should return configured path when pieceCategoriesFile is set', () => {
|
||||||
// Given
|
// Given
|
||||||
loadConfigMock.mockReturnValue({
|
resolvedState.value = { pieceCategoriesFile: '/custom/piece-categories.yaml' };
|
||||||
global: { pieceCategoriesFile: '/custom/piece-categories.yaml' },
|
|
||||||
project: {},
|
|
||||||
});
|
|
||||||
|
|
||||||
// When
|
// When
|
||||||
const path = getPieceCategoriesPath(process.cwd());
|
const path = getPieceCategoriesPath(process.cwd());
|
||||||
@ -68,7 +53,7 @@ describe('getPieceCategoriesPath', () => {
|
|||||||
|
|
||||||
it('should return default path when pieceCategoriesFile is not set', () => {
|
it('should return default path when pieceCategoriesFile is not set', () => {
|
||||||
// Given
|
// Given
|
||||||
loadConfigMock.mockReturnValue({ global: {}, project: {} });
|
resolvedState.value = {};
|
||||||
|
|
||||||
// When
|
// When
|
||||||
const path = getPieceCategoriesPath(process.cwd());
|
const path = getPieceCategoriesPath(process.cwd());
|
||||||
@ -79,9 +64,11 @@ describe('getPieceCategoriesPath', () => {
|
|||||||
|
|
||||||
it('should rethrow when global config loading fails', () => {
|
it('should rethrow when global config loading fails', () => {
|
||||||
// Given
|
// Given
|
||||||
loadConfigMock.mockImplementation(() => {
|
resolvedState.value = new Proxy({}, {
|
||||||
throw new Error('invalid global config');
|
get() {
|
||||||
});
|
throw new Error('invalid global config');
|
||||||
|
},
|
||||||
|
}) as Record<string, unknown>;
|
||||||
|
|
||||||
// When / Then
|
// When / Then
|
||||||
expect(() => getPieceCategoriesPath(process.cwd())).toThrow('invalid global config');
|
expect(() => getPieceCategoriesPath(process.cwd())).toThrow('invalid global config');
|
||||||
@ -92,7 +79,7 @@ describe('resetPieceCategories', () => {
|
|||||||
const tempRoots: string[] = [];
|
const tempRoots: string[] = [];
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
loadConfigMock.mockReset();
|
resolvedState.value = {};
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@ -106,10 +93,7 @@ describe('resetPieceCategories', () => {
|
|||||||
// Given
|
// Given
|
||||||
const categoriesPath = createTempCategoriesPath();
|
const categoriesPath = createTempCategoriesPath();
|
||||||
tempRoots.push(dirname(dirname(categoriesPath)));
|
tempRoots.push(dirname(dirname(categoriesPath)));
|
||||||
loadConfigMock.mockReturnValue({
|
resolvedState.value = { pieceCategoriesFile: categoriesPath };
|
||||||
global: { pieceCategoriesFile: categoriesPath },
|
|
||||||
project: {},
|
|
||||||
});
|
|
||||||
|
|
||||||
// When
|
// When
|
||||||
resetPieceCategories(process.cwd());
|
resetPieceCategories(process.cwd());
|
||||||
@ -125,10 +109,7 @@ describe('resetPieceCategories', () => {
|
|||||||
const categoriesDir = dirname(categoriesPath);
|
const categoriesDir = dirname(categoriesPath);
|
||||||
const tempRoot = dirname(categoriesDir);
|
const tempRoot = dirname(categoriesDir);
|
||||||
tempRoots.push(tempRoot);
|
tempRoots.push(tempRoot);
|
||||||
loadConfigMock.mockReturnValue({
|
resolvedState.value = { pieceCategoriesFile: categoriesPath };
|
||||||
global: { pieceCategoriesFile: categoriesPath },
|
|
||||||
project: {},
|
|
||||||
});
|
|
||||||
mkdirSync(categoriesDir, { recursive: true });
|
mkdirSync(categoriesDir, { recursive: true });
|
||||||
writeFileSync(categoriesPath, 'piece_categories:\n old:\n - stale-piece\n', 'utf-8');
|
writeFileSync(categoriesPath, 'piece_categories:\n old:\n - stale-piece\n', 'utf-8');
|
||||||
|
|
||||||
|
|||||||
@ -42,7 +42,7 @@ describe('loadGlobalConfig', () => {
|
|||||||
expect(config.logLevel).toBe('info');
|
expect(config.logLevel).toBe('info');
|
||||||
expect(config.provider).toBe('claude');
|
expect(config.provider).toBe('claude');
|
||||||
expect(config.model).toBeUndefined();
|
expect(config.model).toBeUndefined();
|
||||||
expect(config.debug).toBeUndefined();
|
expect(config.verbose).toBeUndefined();
|
||||||
expect(config.pipeline).toBeUndefined();
|
expect(config.pipeline).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -451,8 +451,11 @@ describe('loadGlobalConfig', () => {
|
|||||||
[
|
[
|
||||||
'language: en',
|
'language: en',
|
||||||
'persona_providers:',
|
'persona_providers:',
|
||||||
' coder: codex',
|
' coder:',
|
||||||
' reviewer: claude',
|
' provider: codex',
|
||||||
|
' reviewer:',
|
||||||
|
' provider: claude',
|
||||||
|
' model: claude-3-5-sonnet-latest',
|
||||||
].join('\n'),
|
].join('\n'),
|
||||||
'utf-8',
|
'utf-8',
|
||||||
);
|
);
|
||||||
@ -460,8 +463,29 @@ describe('loadGlobalConfig', () => {
|
|||||||
const config = loadGlobalConfig();
|
const config = loadGlobalConfig();
|
||||||
|
|
||||||
expect(config.personaProviders).toEqual({
|
expect(config.personaProviders).toEqual({
|
||||||
coder: 'codex',
|
coder: { provider: 'codex' },
|
||||||
reviewer: 'claude',
|
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' },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -471,12 +495,28 @@ describe('loadGlobalConfig', () => {
|
|||||||
writeFileSync(getGlobalConfigPath(), 'language: en\n', 'utf-8');
|
writeFileSync(getGlobalConfigPath(), 'language: en\n', 'utf-8');
|
||||||
|
|
||||||
const config = loadGlobalConfig();
|
const config = loadGlobalConfig();
|
||||||
config.personaProviders = { coder: 'codex' };
|
config.personaProviders = { coder: { provider: 'codex', model: 'o3-mini' } };
|
||||||
saveGlobalConfig(config);
|
saveGlobalConfig(config);
|
||||||
invalidateGlobalConfigCache();
|
invalidateGlobalConfigCache();
|
||||||
|
|
||||||
const reloaded = loadGlobalConfig();
|
const reloaded = loadGlobalConfig();
|
||||||
expect(reloaded.personaProviders).toEqual({ coder: 'codex' });
|
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', () => {
|
it('should have undefined personaProviders by default', () => {
|
||||||
@ -497,6 +537,42 @@ describe('loadGlobalConfig', () => {
|
|||||||
const reloaded = loadGlobalConfig();
|
const reloaded = loadGlobalConfig();
|
||||||
expect(reloaded.personaProviders).toBeUndefined();
|
expect(reloaded.personaProviders).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should throw when persona entry has codex provider with Claude model alias', () => {
|
||||||
|
const taktDir = join(testHomeDir, '.takt');
|
||||||
|
mkdirSync(taktDir, { recursive: true });
|
||||||
|
writeFileSync(
|
||||||
|
getGlobalConfigPath(),
|
||||||
|
'language: en\npersona_providers:\n coder:\n provider: codex\n model: opus\n',
|
||||||
|
'utf-8',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(() => loadGlobalConfig()).toThrow(/Claude model alias/);
|
||||||
|
});
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('runtime', () => {
|
describe('runtime', () => {
|
||||||
|
|||||||
@ -19,9 +19,9 @@ describe('Schemas accept opencode provider', () => {
|
|||||||
|
|
||||||
it('should accept opencode in GlobalConfigSchema persona_providers field', () => {
|
it('should accept opencode in GlobalConfigSchema persona_providers field', () => {
|
||||||
const result = GlobalConfigSchema.parse({
|
const result = GlobalConfigSchema.parse({
|
||||||
persona_providers: { coder: 'opencode' },
|
persona_providers: { coder: { provider: 'opencode' } },
|
||||||
});
|
});
|
||||||
expect(result.persona_providers).toEqual({ coder: 'opencode' });
|
expect(result.persona_providers).toEqual({ coder: { provider: 'opencode' } });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should accept opencode_api_key in GlobalConfigSchema', () => {
|
it('should accept opencode_api_key in GlobalConfigSchema', () => {
|
||||||
|
|||||||
@ -68,7 +68,7 @@ describe('OptionsBuilder.buildBaseOptions', () => {
|
|||||||
expect(options.permissionMode).toBe('edit');
|
expect(options.permissionMode).toBe('edit');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('merges provider options with precedence: global < project < movement', () => {
|
it('merges provider options with precedence: global < movement < project', () => {
|
||||||
const step = createMovement({
|
const step = createMovement({
|
||||||
providerOptions: {
|
providerOptions: {
|
||||||
codex: { networkAccess: false },
|
codex: { networkAccess: false },
|
||||||
@ -76,6 +76,7 @@ describe('OptionsBuilder.buildBaseOptions', () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
const builder = createBuilder(step, {
|
const builder = createBuilder(step, {
|
||||||
|
providerOptionsSource: 'project',
|
||||||
providerOptions: {
|
providerOptions: {
|
||||||
codex: { networkAccess: true },
|
codex: { networkAccess: true },
|
||||||
claude: { sandbox: { allowUnsandboxedCommands: true } },
|
claude: { sandbox: { allowUnsandboxedCommands: true } },
|
||||||
@ -86,7 +87,7 @@ describe('OptionsBuilder.buildBaseOptions', () => {
|
|||||||
const options = builder.buildBaseOptions(step);
|
const options = builder.buildBaseOptions(step);
|
||||||
|
|
||||||
expect(options.providerOptions).toEqual({
|
expect(options.providerOptions).toEqual({
|
||||||
codex: { networkAccess: false },
|
codex: { networkAccess: true },
|
||||||
opencode: { networkAccess: true },
|
opencode: { networkAccess: true },
|
||||||
claude: {
|
claude: {
|
||||||
sandbox: {
|
sandbox: {
|
||||||
|
|||||||
@ -248,7 +248,7 @@ describe('executePiece session loading', () => {
|
|||||||
projectCwd: '/tmp/project',
|
projectCwd: '/tmp/project',
|
||||||
provider: 'codex',
|
provider: 'codex',
|
||||||
model: 'gpt-5',
|
model: 'gpt-5',
|
||||||
personaProviders: { coder: 'opencode' },
|
personaProviders: { coder: { provider: 'opencode' } },
|
||||||
});
|
});
|
||||||
|
|
||||||
const mockInfo = vi.mocked(info);
|
const mockInfo = vi.mocked(info);
|
||||||
|
|||||||
@ -31,10 +31,9 @@ vi.mock('../features/tasks/index.js', () => ({
|
|||||||
executeTask: mockExecuteTask,
|
executeTask: mockExecuteTask,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Mock loadGlobalConfig
|
const mockResolveConfigValues = vi.fn();
|
||||||
const mockLoadGlobalConfig = vi.fn();
|
vi.mock('../infra/config/index.js', () => ({
|
||||||
vi.mock('../infra/config/global/globalConfig.js', async (importOriginal) => ({ ...(await importOriginal<Record<string, unknown>>()),
|
resolveConfigValues: mockResolveConfigValues,
|
||||||
loadGlobalConfig: mockLoadGlobalConfig,
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Mock execFileSync for git operations
|
// Mock execFileSync for git operations
|
||||||
@ -73,12 +72,7 @@ describe('executePipeline', () => {
|
|||||||
// Default: git operations succeed
|
// Default: git operations succeed
|
||||||
mockExecFileSync.mockReturnValue('abc1234\n');
|
mockExecFileSync.mockReturnValue('abc1234\n');
|
||||||
// Default: no pipeline config
|
// Default: no pipeline config
|
||||||
mockLoadGlobalConfig.mockReturnValue({
|
mockResolveConfigValues.mockReturnValue({ pipeline: undefined });
|
||||||
language: 'en',
|
|
||||||
defaultPiece: 'default',
|
|
||||||
logLevel: 'info',
|
|
||||||
provider: 'claude',
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return exit code 2 when neither --issue nor --task is specified', async () => {
|
it('should return exit code 2 when neither --issue nor --task is specified', async () => {
|
||||||
@ -311,11 +305,7 @@ describe('executePipeline', () => {
|
|||||||
|
|
||||||
describe('PipelineConfig template expansion', () => {
|
describe('PipelineConfig template expansion', () => {
|
||||||
it('should use commit_message_template when configured', async () => {
|
it('should use commit_message_template when configured', async () => {
|
||||||
mockLoadGlobalConfig.mockReturnValue({
|
mockResolveConfigValues.mockReturnValue({
|
||||||
language: 'en',
|
|
||||||
defaultPiece: 'default',
|
|
||||||
logLevel: 'info',
|
|
||||||
provider: 'claude',
|
|
||||||
pipeline: {
|
pipeline: {
|
||||||
commitMessageTemplate: 'fix: {title} (#{issue})',
|
commitMessageTemplate: 'fix: {title} (#{issue})',
|
||||||
},
|
},
|
||||||
@ -347,11 +337,7 @@ describe('executePipeline', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should use default_branch_prefix when configured', async () => {
|
it('should use default_branch_prefix when configured', async () => {
|
||||||
mockLoadGlobalConfig.mockReturnValue({
|
mockResolveConfigValues.mockReturnValue({
|
||||||
language: 'en',
|
|
||||||
defaultPiece: 'default',
|
|
||||||
logLevel: 'info',
|
|
||||||
provider: 'claude',
|
|
||||||
pipeline: {
|
pipeline: {
|
||||||
defaultBranchPrefix: 'feat/',
|
defaultBranchPrefix: 'feat/',
|
||||||
},
|
},
|
||||||
@ -383,11 +369,7 @@ describe('executePipeline', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should use pr_body_template when configured for PR creation', async () => {
|
it('should use pr_body_template when configured for PR creation', async () => {
|
||||||
mockLoadGlobalConfig.mockReturnValue({
|
mockResolveConfigValues.mockReturnValue({
|
||||||
language: 'en',
|
|
||||||
defaultPiece: 'default',
|
|
||||||
logLevel: 'info',
|
|
||||||
provider: 'claude',
|
|
||||||
pipeline: {
|
pipeline: {
|
||||||
prBodyTemplate: '## Summary\n{issue_body}\n\nCloses #{issue}',
|
prBodyTemplate: '## Summary\n{issue_body}\n\nCloses #{issue}',
|
||||||
},
|
},
|
||||||
|
|||||||
@ -7,7 +7,7 @@ describe('resolveMovementProviderModel', () => {
|
|||||||
const result = resolveMovementProviderModel({
|
const result = resolveMovementProviderModel({
|
||||||
step: { provider: 'codex', model: undefined, personaDisplayName: 'coder' },
|
step: { provider: 'codex', model: undefined, personaDisplayName: 'coder' },
|
||||||
provider: 'claude',
|
provider: 'claude',
|
||||||
personaProviders: { coder: 'opencode' },
|
personaProviders: { coder: { provider: 'opencode' } },
|
||||||
});
|
});
|
||||||
|
|
||||||
// When: provider/model を解決する
|
// When: provider/model を解決する
|
||||||
@ -15,16 +15,16 @@ describe('resolveMovementProviderModel', () => {
|
|||||||
expect(result.provider).toBe('codex');
|
expect(result.provider).toBe('codex');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should use personaProviders when step.provider is undefined', () => {
|
it('should use personaProviders.provider when step.provider is undefined', () => {
|
||||||
// Given: step.provider が未定義で personaProviders に対応がある
|
// Given: step.provider が未定義で personaProviders に対応がある
|
||||||
const result = resolveMovementProviderModel({
|
const result = resolveMovementProviderModel({
|
||||||
step: { provider: undefined, model: undefined, personaDisplayName: 'reviewer' },
|
step: { provider: undefined, model: undefined, personaDisplayName: 'reviewer' },
|
||||||
provider: 'claude',
|
provider: 'claude',
|
||||||
personaProviders: { reviewer: 'opencode' },
|
personaProviders: { reviewer: { provider: 'opencode' } },
|
||||||
});
|
});
|
||||||
|
|
||||||
// When: provider/model を解決する
|
// When: provider/model を解決する
|
||||||
// Then: personaProviders の値が使われる
|
// Then: personaProviders の provider が使われる
|
||||||
expect(result.provider).toBe('opencode');
|
expect(result.provider).toBe('opencode');
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -33,7 +33,7 @@ describe('resolveMovementProviderModel', () => {
|
|||||||
const result = resolveMovementProviderModel({
|
const result = resolveMovementProviderModel({
|
||||||
step: { provider: undefined, model: undefined, personaDisplayName: 'unknown' },
|
step: { provider: undefined, model: undefined, personaDisplayName: 'unknown' },
|
||||||
provider: 'mock',
|
provider: 'mock',
|
||||||
personaProviders: { reviewer: 'codex' },
|
personaProviders: { reviewer: { provider: 'codex' } },
|
||||||
});
|
});
|
||||||
|
|
||||||
// When: provider/model を解決する
|
// When: provider/model を解決する
|
||||||
@ -54,11 +54,12 @@ describe('resolveMovementProviderModel', () => {
|
|||||||
expect(result.provider).toBeUndefined();
|
expect(result.provider).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should prefer step.model over input.model', () => {
|
it('should prefer step.model over personaProviders.model and input.model', () => {
|
||||||
// Given: step.model と input.model が両方指定されている
|
// Given: step.model と personaProviders.model と input.model が指定されている
|
||||||
const result = resolveMovementProviderModel({
|
const result = resolveMovementProviderModel({
|
||||||
step: { provider: undefined, model: 'step-model', personaDisplayName: 'coder' },
|
step: { provider: undefined, model: 'step-model', personaDisplayName: 'coder' },
|
||||||
model: 'input-model',
|
model: 'input-model',
|
||||||
|
personaProviders: { coder: { provider: 'codex', model: 'persona-model' } },
|
||||||
});
|
});
|
||||||
|
|
||||||
// When: provider/model を解決する
|
// When: provider/model を解決する
|
||||||
@ -66,15 +67,54 @@ describe('resolveMovementProviderModel', () => {
|
|||||||
expect(result.model).toBe('step-model');
|
expect(result.model).toBe('step-model');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should fallback to input.model when step.model is undefined', () => {
|
it('should use personaProviders.model when step.model is undefined', () => {
|
||||||
// Given: step.model が未定義で input.model が指定されている
|
// Given: step.model が未定義で personaProviders.model が指定されている
|
||||||
const result = resolveMovementProviderModel({
|
const result = resolveMovementProviderModel({
|
||||||
step: { provider: undefined, model: undefined, personaDisplayName: 'coder' },
|
step: { provider: undefined, model: undefined, personaDisplayName: 'coder' },
|
||||||
model: 'input-model',
|
model: 'input-model',
|
||||||
|
personaProviders: { coder: { provider: 'codex', model: 'persona-model' } },
|
||||||
|
});
|
||||||
|
|
||||||
|
// When: provider/model を解決する
|
||||||
|
// Then: personaProviders.model が使われる
|
||||||
|
expect(result.model).toBe('persona-model');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fallback to input.model when step.model and personaProviders.model are undefined', () => {
|
||||||
|
// Given: step.model と personaProviders.model が未定義で input.model が指定されている
|
||||||
|
const result = resolveMovementProviderModel({
|
||||||
|
step: { provider: undefined, model: undefined, personaDisplayName: 'coder' },
|
||||||
|
model: 'input-model',
|
||||||
|
personaProviders: { coder: { provider: 'codex' } },
|
||||||
});
|
});
|
||||||
|
|
||||||
// When: provider/model を解決する
|
// When: provider/model を解決する
|
||||||
// Then: input.model が使われる
|
// Then: input.model が使われる
|
||||||
expect(result.model).toBe('input-model');
|
expect(result.model).toBe('input-model');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should return undefined model when all model candidates are missing', () => {
|
||||||
|
// Given: model の候補がすべて未定義
|
||||||
|
const result = resolveMovementProviderModel({
|
||||||
|
step: { provider: undefined, model: undefined, personaDisplayName: 'coder' },
|
||||||
|
model: undefined,
|
||||||
|
personaProviders: { coder: { provider: 'codex' } },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Then: model は undefined になる
|
||||||
|
expect(result.model).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should resolve provider from personaProviders entry with only model specified', () => {
|
||||||
|
// Given: personaProviders エントリに provider が指定されていない(model のみ)
|
||||||
|
const result = resolveMovementProviderModel({
|
||||||
|
step: { provider: undefined, model: undefined, personaDisplayName: 'coder' },
|
||||||
|
provider: 'claude',
|
||||||
|
personaProviders: { coder: { model: 'o3-mini' } },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Then: provider は input.provider、model は personaProviders.model になる
|
||||||
|
expect(result.provider).toBe('claude');
|
||||||
|
expect(result.model).toBe('o3-mini');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -40,6 +40,13 @@ vi.mock('../infra/config/index.js', () => ({
|
|||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
},
|
},
|
||||||
|
resolveConfigValueWithSource: (_projectDir: string, key: string) => {
|
||||||
|
const raw = mockLoadConfigRaw() as Record<string, unknown>;
|
||||||
|
const config = ('global' in raw && 'project' in raw)
|
||||||
|
? { ...raw.global as Record<string, unknown>, ...raw.project as Record<string, unknown> }
|
||||||
|
: { ...raw, piece: 'default', provider: 'claude', verbose: false };
|
||||||
|
return { value: config[key], source: 'project' };
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const mockLoadConfig = mockLoadConfigRaw;
|
const mockLoadConfig = mockLoadConfigRaw;
|
||||||
|
|||||||
@ -8,9 +8,8 @@ vi.mock('../infra/providers/index.js', () => ({
|
|||||||
getProvider: vi.fn(),
|
getProvider: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../infra/config/global/globalConfig.js', () => ({
|
vi.mock('../infra/config/index.js', () => ({
|
||||||
loadGlobalConfig: vi.fn(),
|
resolveConfigValues: vi.fn(),
|
||||||
getBuiltinPiecesEnabled: vi.fn().mockReturnValue(true),
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../shared/utils/index.js', async (importOriginal) => ({
|
vi.mock('../shared/utils/index.js', async (importOriginal) => ({
|
||||||
@ -23,11 +22,11 @@ vi.mock('../shared/utils/index.js', async (importOriginal) => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
import { getProvider } from '../infra/providers/index.js';
|
import { getProvider } from '../infra/providers/index.js';
|
||||||
import { loadGlobalConfig } from '../infra/config/global/globalConfig.js';
|
import { resolveConfigValues } from '../infra/config/index.js';
|
||||||
import { summarizeTaskName } from '../infra/task/summarize.js';
|
import { summarizeTaskName } from '../infra/task/summarize.js';
|
||||||
|
|
||||||
const mockGetProvider = vi.mocked(getProvider);
|
const mockGetProvider = vi.mocked(getProvider);
|
||||||
const mockLoadGlobalConfig = vi.mocked(loadGlobalConfig);
|
const mockResolveConfigValues = vi.mocked(resolveConfigValues);
|
||||||
|
|
||||||
const mockProviderCall = vi.fn();
|
const mockProviderCall = vi.fn();
|
||||||
const mockProvider = {
|
const mockProvider = {
|
||||||
@ -37,10 +36,7 @@ const mockProvider = {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
mockGetProvider.mockReturnValue(mockProvider);
|
mockGetProvider.mockReturnValue(mockProvider);
|
||||||
mockLoadGlobalConfig.mockReturnValue({
|
mockResolveConfigValues.mockReturnValue({
|
||||||
language: 'ja',
|
|
||||||
defaultPiece: 'default',
|
|
||||||
logLevel: 'info',
|
|
||||||
provider: 'claude',
|
provider: 'claude',
|
||||||
model: undefined,
|
model: undefined,
|
||||||
branchNameStrategy: 'ai',
|
branchNameStrategy: 'ai',
|
||||||
@ -166,10 +162,7 @@ describe('summarizeTaskName', () => {
|
|||||||
|
|
||||||
it('should use provider from config.yaml', async () => {
|
it('should use provider from config.yaml', async () => {
|
||||||
// Given: config has codex provider with branchNameStrategy: 'ai'
|
// Given: config has codex provider with branchNameStrategy: 'ai'
|
||||||
mockLoadGlobalConfig.mockReturnValue({
|
mockResolveConfigValues.mockReturnValue({
|
||||||
language: 'ja',
|
|
||||||
defaultPiece: 'default',
|
|
||||||
logLevel: 'info',
|
|
||||||
provider: 'codex',
|
provider: 'codex',
|
||||||
model: 'gpt-4',
|
model: 'gpt-4',
|
||||||
branchNameStrategy: 'ai',
|
branchNameStrategy: 'ai',
|
||||||
@ -228,7 +221,7 @@ describe('summarizeTaskName', () => {
|
|||||||
|
|
||||||
it('should throw error when config load fails', async () => {
|
it('should throw error when config load fails', async () => {
|
||||||
// Given: config loading throws error
|
// Given: config loading throws error
|
||||||
mockLoadGlobalConfig.mockImplementation(() => {
|
mockResolveConfigValues.mockImplementation(() => {
|
||||||
throw new Error('Config not found');
|
throw new Error('Config not found');
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -257,10 +250,7 @@ describe('summarizeTaskName', () => {
|
|||||||
|
|
||||||
it('should use romaji by default', async () => {
|
it('should use romaji by default', async () => {
|
||||||
// Given: branchNameStrategy is not set (undefined)
|
// Given: branchNameStrategy is not set (undefined)
|
||||||
mockLoadGlobalConfig.mockReturnValue({
|
mockResolveConfigValues.mockReturnValue({
|
||||||
language: 'ja',
|
|
||||||
defaultPiece: 'default',
|
|
||||||
logLevel: 'info',
|
|
||||||
provider: 'claude',
|
provider: 'claude',
|
||||||
model: undefined,
|
model: undefined,
|
||||||
branchNameStrategy: undefined,
|
branchNameStrategy: undefined,
|
||||||
@ -276,10 +266,7 @@ describe('summarizeTaskName', () => {
|
|||||||
|
|
||||||
it('should use AI when branchNameStrategy is ai', async () => {
|
it('should use AI when branchNameStrategy is ai', async () => {
|
||||||
// Given: branchNameStrategy is 'ai'
|
// Given: branchNameStrategy is 'ai'
|
||||||
mockLoadGlobalConfig.mockReturnValue({
|
mockResolveConfigValues.mockReturnValue({
|
||||||
language: 'ja',
|
|
||||||
defaultPiece: 'default',
|
|
||||||
logLevel: 'info',
|
|
||||||
provider: 'claude',
|
provider: 'claude',
|
||||||
model: undefined,
|
model: undefined,
|
||||||
branchNameStrategy: 'ai',
|
branchNameStrategy: 'ai',
|
||||||
@ -301,10 +288,7 @@ describe('summarizeTaskName', () => {
|
|||||||
|
|
||||||
it('should use romaji when branchNameStrategy is romaji', async () => {
|
it('should use romaji when branchNameStrategy is romaji', async () => {
|
||||||
// Given: branchNameStrategy is 'romaji'
|
// Given: branchNameStrategy is 'romaji'
|
||||||
mockLoadGlobalConfig.mockReturnValue({
|
mockResolveConfigValues.mockReturnValue({
|
||||||
language: 'ja',
|
|
||||||
defaultPiece: 'default',
|
|
||||||
logLevel: 'info',
|
|
||||||
provider: 'claude',
|
provider: 'claude',
|
||||||
model: undefined,
|
model: undefined,
|
||||||
branchNameStrategy: 'romaji',
|
branchNameStrategy: 'romaji',
|
||||||
@ -320,10 +304,7 @@ describe('summarizeTaskName', () => {
|
|||||||
|
|
||||||
it('should respect explicit useLLM option over config', async () => {
|
it('should respect explicit useLLM option over config', async () => {
|
||||||
// Given: branchNameStrategy is 'romaji' but useLLM is explicitly true
|
// Given: branchNameStrategy is 'romaji' but useLLM is explicitly true
|
||||||
mockLoadGlobalConfig.mockReturnValue({
|
mockResolveConfigValues.mockReturnValue({
|
||||||
language: 'ja',
|
|
||||||
defaultPiece: 'default',
|
|
||||||
logLevel: 'info',
|
|
||||||
provider: 'claude',
|
provider: 'claude',
|
||||||
model: undefined,
|
model: undefined,
|
||||||
branchNameStrategy: 'romaji',
|
branchNameStrategy: 'romaji',
|
||||||
@ -345,10 +326,7 @@ describe('summarizeTaskName', () => {
|
|||||||
|
|
||||||
it('should respect explicit useLLM false over config with ai strategy', async () => {
|
it('should respect explicit useLLM false over config with ai strategy', async () => {
|
||||||
// Given: branchNameStrategy is 'ai' but useLLM is explicitly false
|
// Given: branchNameStrategy is 'ai' but useLLM is explicitly false
|
||||||
mockLoadGlobalConfig.mockReturnValue({
|
mockResolveConfigValues.mockReturnValue({
|
||||||
language: 'ja',
|
|
||||||
defaultPiece: 'default',
|
|
||||||
logLevel: 'info',
|
|
||||||
provider: 'claude',
|
provider: 'claude',
|
||||||
model: undefined,
|
model: undefined,
|
||||||
branchNameStrategy: 'ai',
|
branchNameStrategy: 'ai',
|
||||||
|
|||||||
@ -5,12 +5,13 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
import type { TaskInfo } from '../infra/task/index.js';
|
import type { TaskInfo } from '../infra/task/index.js';
|
||||||
|
|
||||||
const { mockResolveTaskExecution, mockExecutePiece, mockLoadPieceByIdentifier, mockResolvePieceConfigValues, mockBuildTaskResult, mockPersistTaskResult, mockPersistTaskError, mockPostExecutionFlow } =
|
const { mockResolveTaskExecution, mockExecutePiece, mockLoadPieceByIdentifier, mockResolvePieceConfigValues, mockResolveConfigValueWithSource, mockBuildTaskResult, mockPersistTaskResult, mockPersistTaskError, mockPostExecutionFlow } =
|
||||||
vi.hoisted(() => ({
|
vi.hoisted(() => ({
|
||||||
mockResolveTaskExecution: vi.fn(),
|
mockResolveTaskExecution: vi.fn(),
|
||||||
mockExecutePiece: vi.fn(),
|
mockExecutePiece: vi.fn(),
|
||||||
mockLoadPieceByIdentifier: vi.fn(),
|
mockLoadPieceByIdentifier: vi.fn(),
|
||||||
mockResolvePieceConfigValues: vi.fn(),
|
mockResolvePieceConfigValues: vi.fn(),
|
||||||
|
mockResolveConfigValueWithSource: vi.fn(),
|
||||||
mockBuildTaskResult: vi.fn(),
|
mockBuildTaskResult: vi.fn(),
|
||||||
mockPersistTaskResult: vi.fn(),
|
mockPersistTaskResult: vi.fn(),
|
||||||
mockPersistTaskError: vi.fn(),
|
mockPersistTaskError: vi.fn(),
|
||||||
@ -40,6 +41,7 @@ vi.mock('../infra/config/index.js', () => ({
|
|||||||
loadPieceByIdentifier: (...args: unknown[]) => mockLoadPieceByIdentifier(...args),
|
loadPieceByIdentifier: (...args: unknown[]) => mockLoadPieceByIdentifier(...args),
|
||||||
isPiecePath: () => false,
|
isPiecePath: () => false,
|
||||||
resolvePieceConfigValues: (...args: unknown[]) => mockResolvePieceConfigValues(...args),
|
resolvePieceConfigValues: (...args: unknown[]) => mockResolvePieceConfigValues(...args),
|
||||||
|
resolveConfigValueWithSource: (...args: unknown[]) => mockResolveConfigValueWithSource(...args),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../shared/ui/index.js', () => ({
|
vi.mock('../shared/ui/index.js', () => ({
|
||||||
@ -90,14 +92,17 @@ describe('executeAndCompleteTask', () => {
|
|||||||
model: undefined,
|
model: undefined,
|
||||||
personaProviders: {},
|
personaProviders: {},
|
||||||
providerProfiles: {},
|
providerProfiles: {},
|
||||||
providerOptions: {
|
|
||||||
claude: { sandbox: { allowUnsandboxedCommands: true } },
|
|
||||||
},
|
|
||||||
notificationSound: true,
|
notificationSound: true,
|
||||||
notificationSoundEvents: {},
|
notificationSoundEvents: {},
|
||||||
concurrency: 1,
|
concurrency: 1,
|
||||||
taskPollIntervalMs: 500,
|
taskPollIntervalMs: 500,
|
||||||
});
|
});
|
||||||
|
mockResolveConfigValueWithSource.mockReturnValue({
|
||||||
|
value: {
|
||||||
|
claude: { sandbox: { allowUnsandboxedCommands: true } },
|
||||||
|
},
|
||||||
|
source: 'project',
|
||||||
|
});
|
||||||
mockBuildTaskResult.mockReturnValue({ success: true });
|
mockBuildTaskResult.mockReturnValue({ success: true });
|
||||||
mockResolveTaskExecution.mockResolvedValue({
|
mockResolveTaskExecution.mockResolvedValue({
|
||||||
execCwd: '/project',
|
execCwd: '/project',
|
||||||
@ -136,11 +141,13 @@ describe('executeAndCompleteTask', () => {
|
|||||||
taskDisplayLabel?: string;
|
taskDisplayLabel?: string;
|
||||||
taskPrefix?: string;
|
taskPrefix?: string;
|
||||||
providerOptions?: unknown;
|
providerOptions?: unknown;
|
||||||
|
providerOptionsSource?: string;
|
||||||
};
|
};
|
||||||
expect(pieceExecutionOptions?.taskDisplayLabel).toBe(taskDisplayLabel);
|
expect(pieceExecutionOptions?.taskDisplayLabel).toBe(taskDisplayLabel);
|
||||||
expect(pieceExecutionOptions?.taskPrefix).toBe(taskDisplayLabel);
|
expect(pieceExecutionOptions?.taskPrefix).toBe(taskDisplayLabel);
|
||||||
expect(pieceExecutionOptions?.providerOptions).toEqual({
|
expect(pieceExecutionOptions?.providerOptions).toEqual({
|
||||||
claude: { sandbox: { allowUnsandboxedCommands: true } },
|
claude: { sandbox: { allowUnsandboxedCommands: true } },
|
||||||
});
|
});
|
||||||
|
expect(pieceExecutionOptions?.providerOptionsSource).toBe('project');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -30,7 +30,6 @@ export type {
|
|||||||
ObservabilityConfig,
|
ObservabilityConfig,
|
||||||
Language,
|
Language,
|
||||||
PipelineConfig,
|
PipelineConfig,
|
||||||
GlobalConfig,
|
|
||||||
ProjectConfig,
|
ProjectConfig,
|
||||||
ProviderProfileName,
|
ProviderProfileName,
|
||||||
ProviderPermissionProfile,
|
ProviderPermissionProfile,
|
||||||
|
|||||||
@ -5,6 +5,11 @@
|
|||||||
import type { MovementProviderOptions, PieceRuntimeConfig } from './piece-types.js';
|
import type { MovementProviderOptions, PieceRuntimeConfig } from './piece-types.js';
|
||||||
import type { ProviderPermissionProfiles } from './provider-profiles.js';
|
import type { ProviderPermissionProfiles } from './provider-profiles.js';
|
||||||
|
|
||||||
|
export interface PersonaProviderEntry {
|
||||||
|
provider?: 'claude' | 'codex' | 'opencode' | 'mock';
|
||||||
|
model?: string;
|
||||||
|
}
|
||||||
|
|
||||||
/** Custom agent configuration */
|
/** Custom agent configuration */
|
||||||
export interface CustomAgentConfig {
|
export interface CustomAgentConfig {
|
||||||
name: string;
|
name: string;
|
||||||
@ -60,8 +65,8 @@ export interface NotificationSoundEventsConfig {
|
|||||||
runAbort?: boolean;
|
runAbort?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Global configuration for takt */
|
/** Persisted global configuration for ~/.takt/config.yaml */
|
||||||
export interface GlobalConfig {
|
export interface PersistedGlobalConfig {
|
||||||
language: Language;
|
language: Language;
|
||||||
logLevel: 'debug' | 'info' | 'warn' | 'error';
|
logLevel: 'debug' | 'info' | 'warn' | 'error';
|
||||||
provider?: 'claude' | 'codex' | 'opencode' | 'mock';
|
provider?: 'claude' | 'codex' | 'opencode' | 'mock';
|
||||||
@ -94,8 +99,8 @@ export interface GlobalConfig {
|
|||||||
bookmarksFile?: string;
|
bookmarksFile?: string;
|
||||||
/** Path to piece categories file (default: ~/.takt/preferences/piece-categories.yaml) */
|
/** Path to piece categories file (default: ~/.takt/preferences/piece-categories.yaml) */
|
||||||
pieceCategoriesFile?: string;
|
pieceCategoriesFile?: string;
|
||||||
/** Per-persona provider overrides (e.g., { coder: 'codex' }) */
|
/** Per-persona provider and model overrides (e.g., { coder: { provider: 'codex', model: 'o3-mini' } }) */
|
||||||
personaProviders?: Record<string, 'claude' | 'codex' | 'opencode' | 'mock'>;
|
personaProviders?: Record<string, PersonaProviderEntry>;
|
||||||
/** Global provider-specific options (lowest priority) */
|
/** Global provider-specific options (lowest priority) */
|
||||||
providerOptions?: MovementProviderOptions;
|
providerOptions?: MovementProviderOptions;
|
||||||
/** Provider-specific permission profiles */
|
/** Provider-specific permission profiles */
|
||||||
@ -359,6 +359,11 @@ export const PieceConfigRawSchema = z.object({
|
|||||||
interactive_mode: InteractiveModeSchema.optional(),
|
interactive_mode: InteractiveModeSchema.optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const PersonaProviderEntrySchema = z.object({
|
||||||
|
provider: z.enum(['claude', 'codex', 'opencode', 'mock']).optional(),
|
||||||
|
model: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
/** Custom agent configuration schema */
|
/** Custom agent configuration schema */
|
||||||
export const CustomAgentConfigSchema = z.object({
|
export const CustomAgentConfigSchema = z.object({
|
||||||
name: z.string().min(1),
|
name: z.string().min(1),
|
||||||
@ -443,8 +448,11 @@ export const GlobalConfigSchema = z.object({
|
|||||||
bookmarks_file: z.string().optional(),
|
bookmarks_file: z.string().optional(),
|
||||||
/** Path to piece categories file (default: ~/.takt/preferences/piece-categories.yaml) */
|
/** Path to piece categories file (default: ~/.takt/preferences/piece-categories.yaml) */
|
||||||
piece_categories_file: z.string().optional(),
|
piece_categories_file: z.string().optional(),
|
||||||
/** Per-persona provider overrides (e.g., { coder: 'codex' }) */
|
/** Per-persona provider and model overrides. */
|
||||||
persona_providers: z.record(z.string(), z.enum(['claude', 'codex', 'opencode', 'mock'])).optional(),
|
persona_providers: z.record(z.string(), z.union([
|
||||||
|
z.enum(['claude', 'codex', 'opencode', 'mock']),
|
||||||
|
PersonaProviderEntrySchema,
|
||||||
|
])).optional(),
|
||||||
/** Global provider-specific options (lowest priority) */
|
/** Global provider-specific options (lowest priority) */
|
||||||
provider_options: MovementProviderOptionsSchema,
|
provider_options: MovementProviderOptionsSchema,
|
||||||
/** Provider-specific permission profiles */
|
/** Provider-specific permission profiles */
|
||||||
|
|||||||
@ -61,10 +61,10 @@ export type {
|
|||||||
|
|
||||||
// Configuration types (global and project)
|
// Configuration types (global and project)
|
||||||
export type {
|
export type {
|
||||||
|
PersonaProviderEntry,
|
||||||
CustomAgentConfig,
|
CustomAgentConfig,
|
||||||
ObservabilityConfig,
|
ObservabilityConfig,
|
||||||
Language,
|
Language,
|
||||||
PipelineConfig,
|
PipelineConfig,
|
||||||
GlobalConfig,
|
|
||||||
ProjectConfig,
|
ProjectConfig,
|
||||||
} from './global-config.js';
|
} from './persisted-global-config.js';
|
||||||
|
|||||||
@ -29,6 +29,17 @@ function mergeProviderOptions(
|
|||||||
return Object.keys(result).length > 0 ? result : undefined;
|
return Object.keys(result).length > 0 ? result : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveMovementProviderOptions(
|
||||||
|
source: 'env' | 'project' | 'global' | 'default' | undefined,
|
||||||
|
resolvedConfigOptions: MovementProviderOptions | undefined,
|
||||||
|
movementOptions: MovementProviderOptions | undefined,
|
||||||
|
): MovementProviderOptions | undefined {
|
||||||
|
if (source === 'env' || source === 'project') {
|
||||||
|
return mergeProviderOptions(movementOptions, resolvedConfigOptions);
|
||||||
|
}
|
||||||
|
return mergeProviderOptions(resolvedConfigOptions, movementOptions);
|
||||||
|
}
|
||||||
|
|
||||||
export class OptionsBuilder {
|
export class OptionsBuilder {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly engineOptions: PieceEngineOptions,
|
private readonly engineOptions: PieceEngineOptions,
|
||||||
@ -53,11 +64,8 @@ export class OptionsBuilder {
|
|||||||
model: this.engineOptions.model,
|
model: this.engineOptions.model,
|
||||||
personaProviders: this.engineOptions.personaProviders,
|
personaProviders: this.engineOptions.personaProviders,
|
||||||
});
|
});
|
||||||
|
const resolvedProvider = resolved.provider ?? this.engineOptions.provider ?? 'claude';
|
||||||
const resolvedProviderForPermissions =
|
const resolvedModel = resolved.model ?? this.engineOptions.model;
|
||||||
this.engineOptions.provider
|
|
||||||
?? resolved.provider
|
|
||||||
?? 'claude';
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
cwd: this.getCwd(),
|
cwd: this.getCwd(),
|
||||||
@ -65,16 +73,17 @@ export class OptionsBuilder {
|
|||||||
personaPath: step.personaPath,
|
personaPath: step.personaPath,
|
||||||
provider: this.engineOptions.provider,
|
provider: this.engineOptions.provider,
|
||||||
model: this.engineOptions.model,
|
model: this.engineOptions.model,
|
||||||
stepProvider: resolved.provider,
|
stepProvider: resolvedProvider,
|
||||||
stepModel: resolved.model,
|
stepModel: resolvedModel,
|
||||||
permissionMode: resolveMovementPermissionMode({
|
permissionMode: resolveMovementPermissionMode({
|
||||||
movementName: step.name,
|
movementName: step.name,
|
||||||
requiredPermissionMode: step.requiredPermissionMode,
|
requiredPermissionMode: step.requiredPermissionMode,
|
||||||
provider: resolvedProviderForPermissions,
|
provider: resolvedProvider,
|
||||||
projectProviderProfiles: this.engineOptions.providerProfiles,
|
projectProviderProfiles: this.engineOptions.providerProfiles,
|
||||||
globalProviderProfiles: DEFAULT_PROVIDER_PERMISSION_PROFILES,
|
globalProviderProfiles: DEFAULT_PROVIDER_PERMISSION_PROFILES,
|
||||||
}),
|
}),
|
||||||
providerOptions: mergeProviderOptions(
|
providerOptions: resolveMovementProviderOptions(
|
||||||
|
this.engineOptions.providerOptionsSource,
|
||||||
this.engineOptions.providerOptions,
|
this.engineOptions.providerOptions,
|
||||||
step.providerOptions,
|
step.providerOptions,
|
||||||
),
|
),
|
||||||
|
|||||||
@ -1,12 +1,12 @@
|
|||||||
import type { PieceMovement } from '../models/types.js';
|
import type { PieceMovement } from '../models/types.js';
|
||||||
|
import type { PersonaProviderEntry } from '../models/persisted-global-config.js';
|
||||||
export type ProviderType = 'claude' | 'codex' | 'opencode' | 'mock';
|
import type { ProviderType } from './types.js';
|
||||||
|
|
||||||
export interface MovementProviderModelInput {
|
export interface MovementProviderModelInput {
|
||||||
step: Pick<PieceMovement, 'provider' | 'model' | 'personaDisplayName'>;
|
step: Pick<PieceMovement, 'provider' | 'model' | 'personaDisplayName'>;
|
||||||
provider?: ProviderType;
|
provider?: ProviderType;
|
||||||
model?: string;
|
model?: string;
|
||||||
personaProviders?: Record<string, ProviderType>;
|
personaProviders?: Record<string, PersonaProviderEntry>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MovementProviderModelOutput {
|
export interface MovementProviderModelOutput {
|
||||||
@ -15,10 +15,11 @@ export interface MovementProviderModelOutput {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function resolveMovementProviderModel(input: MovementProviderModelInput): MovementProviderModelOutput {
|
export function resolveMovementProviderModel(input: MovementProviderModelInput): MovementProviderModelOutput {
|
||||||
|
const personaEntry = input.personaProviders?.[input.step.personaDisplayName];
|
||||||
return {
|
return {
|
||||||
provider: input.step.provider
|
provider: input.step.provider
|
||||||
?? input.personaProviders?.[input.step.personaDisplayName]
|
?? personaEntry?.provider
|
||||||
?? input.provider,
|
?? input.provider,
|
||||||
model: input.step.model ?? input.model,
|
model: input.step.model ?? personaEntry?.model ?? input.model,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,10 +7,12 @@
|
|||||||
|
|
||||||
import type { PermissionResult, PermissionUpdate } from '@anthropic-ai/claude-agent-sdk';
|
import type { PermissionResult, PermissionUpdate } from '@anthropic-ai/claude-agent-sdk';
|
||||||
import type { PieceMovement, AgentResponse, PieceState, Language, LoopMonitorConfig } from '../models/types.js';
|
import type { PieceMovement, AgentResponse, PieceState, Language, LoopMonitorConfig } from '../models/types.js';
|
||||||
|
import type { PersonaProviderEntry } from '../models/persisted-global-config.js';
|
||||||
import type { ProviderPermissionProfiles } from '../models/provider-profiles.js';
|
import type { ProviderPermissionProfiles } from '../models/provider-profiles.js';
|
||||||
import type { MovementProviderOptions } from '../models/piece-types.js';
|
import type { MovementProviderOptions } from '../models/piece-types.js';
|
||||||
|
|
||||||
export type ProviderType = 'claude' | 'codex' | 'opencode' | 'mock';
|
export type ProviderType = 'claude' | 'codex' | 'opencode' | 'mock';
|
||||||
|
export type ProviderOptionsSource = 'env' | 'project' | 'global' | 'default';
|
||||||
|
|
||||||
export interface StreamInitEventData {
|
export interface StreamInitEventData {
|
||||||
model: string;
|
model: string;
|
||||||
@ -182,8 +184,10 @@ export interface PieceEngineOptions {
|
|||||||
model?: string;
|
model?: string;
|
||||||
/** Resolved provider options */
|
/** Resolved provider options */
|
||||||
providerOptions?: MovementProviderOptions;
|
providerOptions?: MovementProviderOptions;
|
||||||
/** Per-persona provider overrides (e.g., { coder: 'codex' }) */
|
/** Source layer for resolved provider options */
|
||||||
personaProviders?: Record<string, ProviderType>;
|
providerOptionsSource?: ProviderOptionsSource;
|
||||||
|
/** Per-persona provider and model overrides (e.g., { coder: { provider: 'codex', model: 'o3-mini' } }) */
|
||||||
|
personaProviders?: Record<string, PersonaProviderEntry>;
|
||||||
/** Resolved provider permission profiles */
|
/** Resolved provider permission profiles */
|
||||||
providerProfiles?: ProviderPermissionProfiles;
|
providerProfiles?: ProviderPermissionProfiles;
|
||||||
/** Enable interactive-only rules and user-input transitions */
|
/** Enable interactive-only rules and user-input transitions */
|
||||||
|
|||||||
@ -467,6 +467,7 @@ export async function executePiece(
|
|||||||
provider: options.provider,
|
provider: options.provider,
|
||||||
model: options.model,
|
model: options.model,
|
||||||
providerOptions: options.providerOptions,
|
providerOptions: options.providerOptions,
|
||||||
|
providerOptionsSource: options.providerOptionsSource,
|
||||||
personaProviders: options.personaProviders,
|
personaProviders: options.personaProviders,
|
||||||
providerProfiles: options.providerProfiles,
|
providerProfiles: options.providerProfiles,
|
||||||
interactive: interactiveUserInput,
|
interactive: interactiveUserInput,
|
||||||
@ -547,8 +548,9 @@ export async function executePiece(
|
|||||||
model: options.model,
|
model: options.model,
|
||||||
personaProviders: options.personaProviders,
|
personaProviders: options.personaProviders,
|
||||||
});
|
});
|
||||||
const movementProvider = resolved.provider ?? currentProvider;
|
const movementProvider = resolved.provider ?? 'claude';
|
||||||
const movementModel = resolved.model ?? globalConfig.model ?? '(default)';
|
const resolvedModel = resolved.model;
|
||||||
|
const movementModel = resolvedModel ?? '(default)';
|
||||||
currentMovementProvider = movementProvider;
|
currentMovementProvider = movementProvider;
|
||||||
currentMovementModel = movementModel;
|
currentMovementModel = movementModel;
|
||||||
providerEventLogger.setMovement(step.name);
|
providerEventLogger.setMovement(step.name);
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
* Task execution logic
|
* Task execution logic
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { loadPieceByIdentifier, isPiecePath, resolvePieceConfigValues } from '../../../infra/config/index.js';
|
import { loadPieceByIdentifier, isPiecePath, resolveConfigValueWithSource, resolvePieceConfigValues } from '../../../infra/config/index.js';
|
||||||
import { TaskRunner, type TaskInfo } from '../../../infra/task/index.js';
|
import { TaskRunner, type TaskInfo } from '../../../infra/task/index.js';
|
||||||
import {
|
import {
|
||||||
header,
|
header,
|
||||||
@ -66,16 +66,17 @@ async function executeTaskWithResult(options: ExecuteTaskOptions): Promise<Piece
|
|||||||
'language',
|
'language',
|
||||||
'provider',
|
'provider',
|
||||||
'model',
|
'model',
|
||||||
'providerOptions',
|
|
||||||
'personaProviders',
|
'personaProviders',
|
||||||
'providerProfiles',
|
'providerProfiles',
|
||||||
]);
|
]);
|
||||||
|
const providerOptions = resolveConfigValueWithSource(projectCwd, 'providerOptions');
|
||||||
return await executePiece(pieceConfig, task, cwd, {
|
return await executePiece(pieceConfig, task, cwd, {
|
||||||
projectCwd,
|
projectCwd,
|
||||||
language: config.language,
|
language: config.language,
|
||||||
provider: agentOverrides?.provider ?? config.provider,
|
provider: agentOverrides?.provider ?? config.provider,
|
||||||
model: agentOverrides?.model ?? config.model,
|
model: agentOverrides?.model ?? config.model,
|
||||||
providerOptions: config.providerOptions,
|
providerOptions: providerOptions.value,
|
||||||
|
providerOptionsSource: providerOptions.source === 'piece' ? 'global' : providerOptions.source,
|
||||||
personaProviders: config.personaProviders,
|
personaProviders: config.personaProviders,
|
||||||
providerProfiles: config.providerProfiles,
|
providerProfiles: config.providerProfiles,
|
||||||
interactiveUserInput,
|
interactiveUserInput,
|
||||||
|
|||||||
@ -3,10 +3,12 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Language } from '../../../core/models/index.js';
|
import type { Language } from '../../../core/models/index.js';
|
||||||
|
import type { PersonaProviderEntry } from '../../../core/models/persisted-global-config.js';
|
||||||
import type { ProviderPermissionProfiles } from '../../../core/models/provider-profiles.js';
|
import type { ProviderPermissionProfiles } from '../../../core/models/provider-profiles.js';
|
||||||
import type { MovementProviderOptions } from '../../../core/models/piece-types.js';
|
import type { MovementProviderOptions } from '../../../core/models/piece-types.js';
|
||||||
import type { ProviderType } from '../../../infra/providers/index.js';
|
import type { ProviderType } from '../../../infra/providers/index.js';
|
||||||
import type { GitHubIssue } from '../../../infra/github/index.js';
|
import type { GitHubIssue } from '../../../infra/github/index.js';
|
||||||
|
import type { ProviderOptionsSource } from '../../../core/piece/types.js';
|
||||||
|
|
||||||
/** Result of piece execution */
|
/** Result of piece execution */
|
||||||
export interface PieceExecutionResult {
|
export interface PieceExecutionResult {
|
||||||
@ -36,8 +38,10 @@ export interface PieceExecutionOptions {
|
|||||||
model?: string;
|
model?: string;
|
||||||
/** Resolved provider options */
|
/** Resolved provider options */
|
||||||
providerOptions?: MovementProviderOptions;
|
providerOptions?: MovementProviderOptions;
|
||||||
/** Per-persona provider overrides (e.g., { coder: 'codex' }) */
|
/** Source layer for resolved provider options */
|
||||||
personaProviders?: Record<string, ProviderType>;
|
providerOptionsSource?: ProviderOptionsSource;
|
||||||
|
/** Per-persona provider and model overrides (e.g., { coder: { provider: 'codex', model: 'o3-mini' } }) */
|
||||||
|
personaProviders?: Record<string, PersonaProviderEntry>;
|
||||||
/** Resolved provider permission profiles */
|
/** Resolved provider permission profiles */
|
||||||
providerProfiles?: ProviderPermissionProfiles;
|
providerProfiles?: ProviderPermissionProfiles;
|
||||||
/** Enable interactive user input during step transitions */
|
/** Enable interactive user input during step transitions */
|
||||||
|
|||||||
@ -29,9 +29,6 @@ export {
|
|||||||
isPiecePath,
|
isPiecePath,
|
||||||
} from './infra/config/loaders/index.js';
|
} from './infra/config/loaders/index.js';
|
||||||
export type { PieceSource, PieceWithSource, PieceDirEntry } from './infra/config/loaders/index.js';
|
export type { PieceSource, PieceWithSource, PieceDirEntry } from './infra/config/loaders/index.js';
|
||||||
export {
|
|
||||||
loadConfig,
|
|
||||||
} from './infra/config/loadConfig.js';
|
|
||||||
export {
|
export {
|
||||||
saveProjectConfig,
|
saveProjectConfig,
|
||||||
updateProjectConfig,
|
updateProjectConfig,
|
||||||
|
|||||||
@ -9,13 +9,15 @@ import { readFileSync, existsSync, writeFileSync, statSync, accessSync, constant
|
|||||||
import { isAbsolute } from 'node:path';
|
import { isAbsolute } from 'node:path';
|
||||||
import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
|
import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
|
||||||
import { GlobalConfigSchema } from '../../../core/models/index.js';
|
import { GlobalConfigSchema } from '../../../core/models/index.js';
|
||||||
import type { GlobalConfig, Language } 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 type { ProviderPermissionProfiles } from '../../../core/models/provider-profiles.js';
|
import type { ProviderPermissionProfiles } from '../../../core/models/provider-profiles.js';
|
||||||
import { normalizeProviderOptions } from '../loaders/pieceParser.js';
|
import { normalizeProviderOptions } from '../loaders/pieceParser.js';
|
||||||
import { getGlobalConfigPath } from '../paths.js';
|
import { getGlobalConfigPath } from '../paths.js';
|
||||||
import { DEFAULT_LANGUAGE } from '../../../shared/constants.js';
|
import { DEFAULT_LANGUAGE } from '../../../shared/constants.js';
|
||||||
import { parseProviderModel } from '../../../shared/utils/providerModel.js';
|
import { parseProviderModel } from '../../../shared/utils/providerModel.js';
|
||||||
import { applyGlobalConfigEnvOverrides, envVarNameFromPath } from '../env/config-env-overrides.js';
|
import { applyGlobalConfigEnvOverrides, envVarNameFromPath } from '../env/config-env-overrides.js';
|
||||||
|
import { invalidateAllResolvedConfigCache } from '../resolutionCache.js';
|
||||||
|
|
||||||
/** Claude-specific model aliases that are not valid for other providers */
|
/** Claude-specific model aliases that are not valid for other providers */
|
||||||
const CLAUDE_MODEL_ALIASES = new Set(['opus', 'sonnet', 'haiku']);
|
const CLAUDE_MODEL_ALIASES = new Set(['opus', 'sonnet', 'haiku']);
|
||||||
@ -56,7 +58,6 @@ function validateCodexCliPath(pathValue: string, sourceName: 'TAKT_CODEX_CLI_PAT
|
|||||||
return trimmed;
|
return trimmed;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Validate that provider and model are compatible */
|
|
||||||
function validateProviderModelCompatibility(provider: string | undefined, model: string | undefined): void {
|
function validateProviderModelCompatibility(provider: string | undefined, model: string | undefined): void {
|
||||||
if (!provider) return;
|
if (!provider) return;
|
||||||
|
|
||||||
@ -80,6 +81,19 @@ function validateProviderModelCompatibility(provider: string | undefined, model:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizePersonaProviders(
|
||||||
|
raw: Record<string, NonNullable<PersonaProviderEntry['provider']> | PersonaProviderEntry> | undefined,
|
||||||
|
): Record<string, PersonaProviderEntry> | undefined {
|
||||||
|
if (!raw) return undefined;
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(raw).map(([persona, entry]) => {
|
||||||
|
const normalized: PersonaProviderEntry = typeof entry === 'string' ? { provider: entry } : entry;
|
||||||
|
validateProviderModelCompatibility(normalized.provider, normalized.model);
|
||||||
|
return [persona, normalized];
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeProviderProfiles(
|
function normalizeProviderProfiles(
|
||||||
raw: Record<string, { default_permission_mode: unknown; movement_permission_overrides?: Record<string, unknown> }> | undefined,
|
raw: Record<string, { default_permission_mode: unknown; movement_permission_overrides?: Record<string, unknown> }> | undefined,
|
||||||
): ProviderPermissionProfiles | undefined {
|
): ProviderPermissionProfiles | undefined {
|
||||||
@ -114,7 +128,7 @@ function denormalizeProviderProfiles(
|
|||||||
*/
|
*/
|
||||||
export class GlobalConfigManager {
|
export class GlobalConfigManager {
|
||||||
private static instance: GlobalConfigManager | null = null;
|
private static instance: GlobalConfigManager | null = null;
|
||||||
private cachedConfig: GlobalConfig | null = null;
|
private cachedConfig: PersistedGlobalConfig | null = null;
|
||||||
|
|
||||||
private constructor() {}
|
private constructor() {}
|
||||||
|
|
||||||
@ -136,7 +150,7 @@ export class GlobalConfigManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Load global configuration (cached) */
|
/** Load global configuration (cached) */
|
||||||
load(): GlobalConfig {
|
load(): PersistedGlobalConfig {
|
||||||
if (this.cachedConfig !== null) {
|
if (this.cachedConfig !== null) {
|
||||||
return this.cachedConfig;
|
return this.cachedConfig;
|
||||||
}
|
}
|
||||||
@ -156,7 +170,7 @@ export class GlobalConfigManager {
|
|||||||
applyGlobalConfigEnvOverrides(rawConfig);
|
applyGlobalConfigEnvOverrides(rawConfig);
|
||||||
|
|
||||||
const parsed = GlobalConfigSchema.parse(rawConfig);
|
const parsed = GlobalConfigSchema.parse(rawConfig);
|
||||||
const config: GlobalConfig = {
|
const config: PersistedGlobalConfig = {
|
||||||
language: parsed.language,
|
language: parsed.language,
|
||||||
logLevel: parsed.log_level,
|
logLevel: parsed.log_level,
|
||||||
provider: parsed.provider,
|
provider: parsed.provider,
|
||||||
@ -186,7 +200,7 @@ export class GlobalConfigManager {
|
|||||||
minimalOutput: parsed.minimal_output,
|
minimalOutput: parsed.minimal_output,
|
||||||
bookmarksFile: parsed.bookmarks_file,
|
bookmarksFile: parsed.bookmarks_file,
|
||||||
pieceCategoriesFile: parsed.piece_categories_file,
|
pieceCategoriesFile: parsed.piece_categories_file,
|
||||||
personaProviders: parsed.persona_providers,
|
personaProviders: normalizePersonaProviders(parsed.persona_providers as Record<string, NonNullable<PersonaProviderEntry['provider']> | PersonaProviderEntry> | undefined),
|
||||||
providerOptions: normalizeProviderOptions(parsed.provider_options),
|
providerOptions: normalizeProviderOptions(parsed.provider_options),
|
||||||
providerProfiles: normalizeProviderProfiles(parsed.provider_profiles as Record<string, { default_permission_mode: unknown; movement_permission_overrides?: Record<string, unknown> }> | undefined),
|
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
|
runtime: parsed.runtime?.prepare && parsed.runtime.prepare.length > 0
|
||||||
@ -213,7 +227,7 @@ export class GlobalConfigManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Save global configuration to disk and invalidate cache */
|
/** Save global configuration to disk and invalidate cache */
|
||||||
save(config: GlobalConfig): void {
|
save(config: PersistedGlobalConfig): void {
|
||||||
const configPath = getGlobalConfigPath();
|
const configPath = getGlobalConfigPath();
|
||||||
const raw: Record<string, unknown> = {
|
const raw: Record<string, unknown> = {
|
||||||
language: config.language,
|
language: config.language,
|
||||||
@ -338,18 +352,20 @@ export class GlobalConfigManager {
|
|||||||
}
|
}
|
||||||
writeFileSync(configPath, stringifyYaml(raw), 'utf-8');
|
writeFileSync(configPath, stringifyYaml(raw), 'utf-8');
|
||||||
this.invalidateCache();
|
this.invalidateCache();
|
||||||
|
invalidateAllResolvedConfigCache();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function invalidateGlobalConfigCache(): void {
|
export function invalidateGlobalConfigCache(): void {
|
||||||
GlobalConfigManager.getInstance().invalidateCache();
|
GlobalConfigManager.getInstance().invalidateCache();
|
||||||
|
invalidateAllResolvedConfigCache();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function loadGlobalConfig(): GlobalConfig {
|
export function loadGlobalConfig(): PersistedGlobalConfig {
|
||||||
return GlobalConfigManager.getInstance().load();
|
return GlobalConfigManager.getInstance().load();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function saveGlobalConfig(config: GlobalConfig): void {
|
export function saveGlobalConfig(config: PersistedGlobalConfig): void {
|
||||||
GlobalConfigManager.getInstance().save(config);
|
GlobalConfigManager.getInstance().save(config);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -434,7 +450,7 @@ export function resolveCodexCliPath(): string | undefined {
|
|||||||
return validateCodexCliPath(envPath, 'TAKT_CODEX_CLI_PATH');
|
return validateCodexCliPath(envPath, 'TAKT_CODEX_CLI_PATH');
|
||||||
}
|
}
|
||||||
|
|
||||||
let config: GlobalConfig;
|
let config: PersistedGlobalConfig;
|
||||||
try {
|
try {
|
||||||
config = loadGlobalConfig();
|
config = loadGlobalConfig();
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@ -1,131 +0,0 @@
|
|||||||
import type { GlobalConfig } from '../../core/models/index.js';
|
|
||||||
import type { MovementProviderOptions } from '../../core/models/piece-types.js';
|
|
||||||
import type { ProviderPermissionProfiles } from '../../core/models/provider-profiles.js';
|
|
||||||
import type { AnalyticsConfig } from '../../core/models/global-config.js';
|
|
||||||
import { loadGlobalConfig } from './global/globalConfig.js';
|
|
||||||
import { loadProjectConfig } from './project/projectConfig.js';
|
|
||||||
import { envVarNameFromPath } from './env/config-env-overrides.js';
|
|
||||||
|
|
||||||
export interface LoadedConfig extends GlobalConfig {
|
|
||||||
piece: string;
|
|
||||||
provider: NonNullable<GlobalConfig['provider']>;
|
|
||||||
verbose: boolean;
|
|
||||||
providerOptions?: MovementProviderOptions;
|
|
||||||
providerProfiles?: ProviderPermissionProfiles;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function loadConfig(projectDir: string): LoadedConfig {
|
|
||||||
const global = loadGlobalConfig();
|
|
||||||
const project = loadProjectConfig(projectDir);
|
|
||||||
const provider = (project.provider ?? global.provider ?? 'claude') as NonNullable<GlobalConfig['provider']>;
|
|
||||||
|
|
||||||
return {
|
|
||||||
...global,
|
|
||||||
piece: project.piece ?? 'default',
|
|
||||||
provider,
|
|
||||||
autoPr: project.auto_pr ?? global.autoPr,
|
|
||||||
draftPr: project.draft_pr ?? global.draftPr,
|
|
||||||
model: resolveModel(global, provider),
|
|
||||||
verbose: resolveVerbose(project.verbose, global.verbose),
|
|
||||||
analytics: mergeAnalytics(global.analytics, project.analytics),
|
|
||||||
providerOptions: mergeProviderOptions(global.providerOptions, project.providerOptions),
|
|
||||||
providerProfiles: mergeProviderProfiles(global.providerProfiles, project.providerProfiles),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveModel(global: GlobalConfig, provider: GlobalConfig['provider']): string | undefined {
|
|
||||||
if (!global.model) return undefined;
|
|
||||||
const globalProvider = global.provider ?? 'claude';
|
|
||||||
const resolvedProvider = provider ?? 'claude';
|
|
||||||
if (globalProvider !== resolvedProvider) return undefined;
|
|
||||||
return global.model;
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveVerbose(projectVerbose: boolean | undefined, globalVerbose: boolean | undefined): boolean {
|
|
||||||
const envVerbose = loadEnvBooleanSetting('verbose');
|
|
||||||
if (envVerbose !== undefined) return envVerbose;
|
|
||||||
if (projectVerbose !== undefined) return projectVerbose;
|
|
||||||
if (globalVerbose !== undefined) return globalVerbose;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadEnvBooleanSetting(configKey: string): boolean | undefined {
|
|
||||||
const envKey = envVarNameFromPath(configKey);
|
|
||||||
const raw = process.env[envKey];
|
|
||||||
if (raw === undefined) return undefined;
|
|
||||||
|
|
||||||
const normalized = raw.trim().toLowerCase();
|
|
||||||
if (normalized === 'true') return true;
|
|
||||||
if (normalized === 'false') return false;
|
|
||||||
|
|
||||||
throw new Error(`${envKey} must be one of: true, false`);
|
|
||||||
}
|
|
||||||
|
|
||||||
function mergeProviderOptions(
|
|
||||||
globalOptions: MovementProviderOptions | undefined,
|
|
||||||
projectOptions: MovementProviderOptions | undefined,
|
|
||||||
): MovementProviderOptions | undefined {
|
|
||||||
if (!globalOptions && !projectOptions) return undefined;
|
|
||||||
|
|
||||||
const result: MovementProviderOptions = {};
|
|
||||||
if (globalOptions?.codex || projectOptions?.codex) {
|
|
||||||
result.codex = { ...globalOptions?.codex, ...projectOptions?.codex };
|
|
||||||
}
|
|
||||||
if (globalOptions?.opencode || projectOptions?.opencode) {
|
|
||||||
result.opencode = { ...globalOptions?.opencode, ...projectOptions?.opencode };
|
|
||||||
}
|
|
||||||
if (globalOptions?.claude?.sandbox || projectOptions?.claude?.sandbox) {
|
|
||||||
result.claude = {
|
|
||||||
sandbox: {
|
|
||||||
...globalOptions?.claude?.sandbox,
|
|
||||||
...projectOptions?.claude?.sandbox,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return Object.keys(result).length > 0 ? result : undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
function mergeAnalytics(
|
|
||||||
globalAnalytics: AnalyticsConfig | undefined,
|
|
||||||
projectAnalytics: AnalyticsConfig | undefined,
|
|
||||||
): AnalyticsConfig | undefined {
|
|
||||||
if (!globalAnalytics && !projectAnalytics) return undefined;
|
|
||||||
|
|
||||||
const merged: AnalyticsConfig = {
|
|
||||||
enabled: projectAnalytics?.enabled ?? globalAnalytics?.enabled,
|
|
||||||
eventsPath: projectAnalytics?.eventsPath ?? globalAnalytics?.eventsPath,
|
|
||||||
retentionDays: projectAnalytics?.retentionDays ?? globalAnalytics?.retentionDays,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (merged.enabled === undefined && merged.eventsPath === undefined && merged.retentionDays === undefined) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
return merged;
|
|
||||||
}
|
|
||||||
|
|
||||||
function mergeProviderProfiles(
|
|
||||||
globalProfiles: ProviderPermissionProfiles | undefined,
|
|
||||||
projectProfiles: ProviderPermissionProfiles | undefined,
|
|
||||||
): ProviderPermissionProfiles | undefined {
|
|
||||||
if (!globalProfiles && !projectProfiles) return undefined;
|
|
||||||
|
|
||||||
const merged: ProviderPermissionProfiles = { ...(globalProfiles ?? {}) };
|
|
||||||
for (const [provider, profile] of Object.entries(projectProfiles ?? {})) {
|
|
||||||
const key = provider as keyof ProviderPermissionProfiles;
|
|
||||||
const existing = merged[key];
|
|
||||||
if (!existing) {
|
|
||||||
merged[key] = profile;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
merged[key] = {
|
|
||||||
defaultPermissionMode: profile.defaultPermissionMode,
|
|
||||||
movementPermissionOverrides: {
|
|
||||||
...(existing.movementPermissionOverrides ?? {}),
|
|
||||||
...(profile.movementPermissionOverrides ?? {}),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return Object.keys(merged).length > 0 ? merged : undefined;
|
|
||||||
}
|
|
||||||
@ -10,9 +10,10 @@ import { parse, stringify } from 'yaml';
|
|||||||
import { copyProjectResourcesToDir } from '../../resources/index.js';
|
import { copyProjectResourcesToDir } from '../../resources/index.js';
|
||||||
import type { ProjectLocalConfig } from '../types.js';
|
import type { ProjectLocalConfig } from '../types.js';
|
||||||
import type { ProviderPermissionProfiles } from '../../../core/models/provider-profiles.js';
|
import type { ProviderPermissionProfiles } from '../../../core/models/provider-profiles.js';
|
||||||
import type { AnalyticsConfig } from '../../../core/models/global-config.js';
|
import type { AnalyticsConfig } from '../../../core/models/persisted-global-config.js';
|
||||||
import { applyProjectConfigEnvOverrides } from '../env/config-env-overrides.js';
|
import { applyProjectConfigEnvOverrides } from '../env/config-env-overrides.js';
|
||||||
import { normalizeProviderOptions } from '../loaders/pieceParser.js';
|
import { normalizeProviderOptions } from '../loaders/pieceParser.js';
|
||||||
|
import { invalidateResolvedConfigCache } from '../resolutionCache.js';
|
||||||
|
|
||||||
export type { ProjectLocalConfig } from '../types.js';
|
export type { ProjectLocalConfig } from '../types.js';
|
||||||
|
|
||||||
@ -154,6 +155,7 @@ export function saveProjectConfig(projectDir: string, config: ProjectLocalConfig
|
|||||||
|
|
||||||
const content = stringify(savePayload, { indent: 2 });
|
const content = stringify(savePayload, { indent: 2 });
|
||||||
writeFileSync(configPath, content, 'utf-8');
|
writeFileSync(configPath, content, 'utf-8');
|
||||||
|
invalidateResolvedConfigCache(projectDir);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -1,32 +1,5 @@
|
|||||||
import { envVarNameFromPath } from '../env/config-env-overrides.js';
|
import { resolveConfigValue } from '../resolveConfigValue.js';
|
||||||
import { loadConfig } from '../loadConfig.js';
|
|
||||||
|
|
||||||
function resolveValue<T>(
|
|
||||||
envValue: T | undefined,
|
|
||||||
localValue: T | undefined,
|
|
||||||
globalValue: T | undefined,
|
|
||||||
defaultValue: T,
|
|
||||||
): T {
|
|
||||||
if (envValue !== undefined) return envValue;
|
|
||||||
if (localValue !== undefined) return localValue;
|
|
||||||
if (globalValue !== undefined) return globalValue;
|
|
||||||
return defaultValue;
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadEnvBooleanSetting(configKey: string): boolean | undefined {
|
|
||||||
const envKey = envVarNameFromPath(configKey);
|
|
||||||
const raw = process.env[envKey];
|
|
||||||
if (raw === undefined) return undefined;
|
|
||||||
|
|
||||||
const normalized = raw.trim().toLowerCase();
|
|
||||||
if (normalized === 'true') return true;
|
|
||||||
if (normalized === 'false') return false;
|
|
||||||
|
|
||||||
throw new Error(`${envKey} must be one of: true, false`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isVerboseMode(projectDir: string): boolean {
|
export function isVerboseMode(projectDir: string): boolean {
|
||||||
const envValue = loadEnvBooleanSetting('verbose');
|
return resolveConfigValue(projectDir, 'verbose');
|
||||||
const config = loadConfig(projectDir);
|
|
||||||
return resolveValue(envValue, undefined, config.verbose, false);
|
|
||||||
}
|
}
|
||||||
|
|||||||
50
src/infra/config/resolutionCache.ts
Normal file
50
src/infra/config/resolutionCache.ts
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
import { resolve } from 'node:path';
|
||||||
|
import type { ProjectLocalConfig } from './types.js';
|
||||||
|
import type { ConfigParameterKey } from './resolvedConfig.js';
|
||||||
|
|
||||||
|
const projectConfigCache = new Map<string, ProjectLocalConfig>();
|
||||||
|
const resolvedValueCache = new Map<string, unknown>();
|
||||||
|
|
||||||
|
function normalizeProjectDir(projectDir: string): string {
|
||||||
|
return resolve(projectDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolvedValueKey(projectDir: string, key: ConfigParameterKey): string {
|
||||||
|
return `${normalizeProjectDir(projectDir)}::${key}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCachedProjectConfig(projectDir: string): ProjectLocalConfig | undefined {
|
||||||
|
return projectConfigCache.get(normalizeProjectDir(projectDir));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setCachedProjectConfig(projectDir: string, config: ProjectLocalConfig): void {
|
||||||
|
projectConfigCache.set(normalizeProjectDir(projectDir), config);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasCachedResolvedValue(projectDir: string, key: ConfigParameterKey): boolean {
|
||||||
|
return resolvedValueCache.has(resolvedValueKey(projectDir, key));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCachedResolvedValue(projectDir: string, key: ConfigParameterKey): unknown {
|
||||||
|
return resolvedValueCache.get(resolvedValueKey(projectDir, key));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setCachedResolvedValue(projectDir: string, key: ConfigParameterKey, value: unknown): void {
|
||||||
|
resolvedValueCache.set(resolvedValueKey(projectDir, key), value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function invalidateResolvedConfigCache(projectDir: string): void {
|
||||||
|
const normalizedProjectDir = normalizeProjectDir(projectDir);
|
||||||
|
projectConfigCache.delete(normalizedProjectDir);
|
||||||
|
const prefix = `${normalizedProjectDir}::`;
|
||||||
|
for (const key of resolvedValueCache.keys()) {
|
||||||
|
if (key.startsWith(prefix)) {
|
||||||
|
resolvedValueCache.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function invalidateAllResolvedConfigCache(): void {
|
||||||
|
projectConfigCache.clear();
|
||||||
|
resolvedValueCache.clear();
|
||||||
|
}
|
||||||
@ -1,22 +1,230 @@
|
|||||||
import { loadConfig, type LoadedConfig } from './loadConfig.js';
|
import { loadGlobalConfig } from './global/globalConfig.js';
|
||||||
|
import { loadProjectConfig } from './project/projectConfig.js';
|
||||||
|
import { envVarNameFromPath } from './env/config-env-overrides.js';
|
||||||
|
import {
|
||||||
|
getCachedProjectConfig,
|
||||||
|
getCachedResolvedValue,
|
||||||
|
hasCachedResolvedValue,
|
||||||
|
setCachedProjectConfig,
|
||||||
|
setCachedResolvedValue,
|
||||||
|
} from './resolutionCache.js';
|
||||||
|
import type { ConfigParameterKey, LoadedConfig } from './resolvedConfig.js';
|
||||||
|
|
||||||
export type ConfigParameterKey = keyof LoadedConfig;
|
export type { ConfigParameterKey } from './resolvedConfig.js';
|
||||||
|
export { invalidateResolvedConfigCache, invalidateAllResolvedConfigCache } from './resolutionCache.js';
|
||||||
|
|
||||||
|
export interface PieceContext {
|
||||||
|
provider?: LoadedConfig['provider'];
|
||||||
|
model?: LoadedConfig['model'];
|
||||||
|
providerOptions?: LoadedConfig['providerOptions'];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResolveConfigOptions {
|
||||||
|
pieceContext?: PieceContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ConfigValueSource = 'env' | 'project' | 'piece' | 'global' | 'default';
|
||||||
|
|
||||||
|
export interface ResolvedConfigValue<K extends ConfigParameterKey> {
|
||||||
|
value: LoadedConfig[K];
|
||||||
|
source: ConfigValueSource;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ResolutionLayer = 'local' | 'piece' | 'global';
|
||||||
|
interface ResolutionRule<K extends ConfigParameterKey> {
|
||||||
|
layers: readonly ResolutionLayer[];
|
||||||
|
defaultValue?: LoadedConfig[K];
|
||||||
|
mergeMode?: 'analytics';
|
||||||
|
pieceValue?: (pieceContext: PieceContext | undefined) => LoadedConfig[K] | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadProjectConfigCached(projectDir: string) {
|
||||||
|
const cached = getCachedProjectConfig(projectDir);
|
||||||
|
if (cached !== undefined) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
const loaded = loadProjectConfig(projectDir);
|
||||||
|
setCachedProjectConfig(projectDir, loaded);
|
||||||
|
return loaded;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_RULE: ResolutionRule<ConfigParameterKey> = {
|
||||||
|
layers: ['local', 'global'],
|
||||||
|
};
|
||||||
|
|
||||||
|
const PROVIDER_OPTIONS_ENV_PATHS = [
|
||||||
|
'provider_options',
|
||||||
|
'provider_options.codex.network_access',
|
||||||
|
'provider_options.opencode.network_access',
|
||||||
|
'provider_options.claude.sandbox.allow_unsandboxed_commands',
|
||||||
|
'provider_options.claude.sandbox.excluded_commands',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const RESOLUTION_REGISTRY: Partial<{ [K in ConfigParameterKey]: ResolutionRule<K> }> = {
|
||||||
|
piece: { layers: ['local', 'global'], defaultValue: 'default' },
|
||||||
|
provider: {
|
||||||
|
layers: ['local', 'piece', 'global'],
|
||||||
|
defaultValue: 'claude',
|
||||||
|
pieceValue: (pieceContext) => pieceContext?.provider,
|
||||||
|
},
|
||||||
|
model: {
|
||||||
|
layers: ['local', 'piece', 'global'],
|
||||||
|
pieceValue: (pieceContext) => pieceContext?.model,
|
||||||
|
},
|
||||||
|
providerOptions: {
|
||||||
|
layers: ['local', 'piece', 'global'],
|
||||||
|
pieceValue: (pieceContext) => pieceContext?.providerOptions,
|
||||||
|
},
|
||||||
|
autoPr: { layers: ['local', 'global'] },
|
||||||
|
draftPr: { layers: ['local', 'global'] },
|
||||||
|
analytics: { layers: ['local', 'global'], mergeMode: 'analytics' },
|
||||||
|
verbose: { layers: ['local', 'global'], defaultValue: false },
|
||||||
|
};
|
||||||
|
|
||||||
|
function resolveAnalyticsMerged(
|
||||||
|
project: ReturnType<typeof loadProjectConfigCached>,
|
||||||
|
global: ReturnType<typeof loadGlobalConfig>,
|
||||||
|
): LoadedConfig['analytics'] {
|
||||||
|
const localAnalytics = project.analytics;
|
||||||
|
const globalAnalytics = global.analytics;
|
||||||
|
|
||||||
|
const enabled = localAnalytics?.enabled ?? globalAnalytics?.enabled;
|
||||||
|
const eventsPath = localAnalytics?.eventsPath ?? globalAnalytics?.eventsPath;
|
||||||
|
const retentionDays = localAnalytics?.retentionDays ?? globalAnalytics?.retentionDays;
|
||||||
|
|
||||||
|
if (enabled === undefined && eventsPath === undefined && retentionDays === undefined) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return { enabled, eventsPath, retentionDays };
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveAnalyticsSource(
|
||||||
|
project: ReturnType<typeof loadProjectConfigCached>,
|
||||||
|
global: ReturnType<typeof loadGlobalConfig>,
|
||||||
|
): ConfigValueSource {
|
||||||
|
if (project.analytics !== undefined) return 'project';
|
||||||
|
if (global.analytics !== undefined) return 'global';
|
||||||
|
return 'default';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLocalLayerValue<K extends ConfigParameterKey>(
|
||||||
|
project: ReturnType<typeof loadProjectConfigCached>,
|
||||||
|
key: K,
|
||||||
|
): LoadedConfig[K] | undefined {
|
||||||
|
switch (key) {
|
||||||
|
case 'piece':
|
||||||
|
return project.piece as LoadedConfig[K] | undefined;
|
||||||
|
case 'provider':
|
||||||
|
return project.provider as LoadedConfig[K] | undefined;
|
||||||
|
case 'autoPr':
|
||||||
|
return project.auto_pr as LoadedConfig[K] | undefined;
|
||||||
|
case 'draftPr':
|
||||||
|
return project.draft_pr as LoadedConfig[K] | undefined;
|
||||||
|
case 'verbose':
|
||||||
|
return project.verbose as LoadedConfig[K] | undefined;
|
||||||
|
case 'analytics':
|
||||||
|
return project.analytics as LoadedConfig[K] | undefined;
|
||||||
|
case 'providerOptions':
|
||||||
|
return project.providerOptions as LoadedConfig[K] | undefined;
|
||||||
|
case 'providerProfiles':
|
||||||
|
return project.providerProfiles as LoadedConfig[K] | undefined;
|
||||||
|
default:
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getGlobalLayerValue<K extends ConfigParameterKey>(
|
||||||
|
global: ReturnType<typeof loadGlobalConfig>,
|
||||||
|
key: K,
|
||||||
|
): 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>,
|
||||||
|
options: ResolveConfigOptions | undefined,
|
||||||
|
): ResolvedConfigValue<K> {
|
||||||
|
const rule = (RESOLUTION_REGISTRY[key] ?? DEFAULT_RULE) as ResolutionRule<K>;
|
||||||
|
if (rule.mergeMode === 'analytics') {
|
||||||
|
return {
|
||||||
|
value: resolveAnalyticsMerged(project, global) as LoadedConfig[K],
|
||||||
|
source: resolveAnalyticsSource(project, global),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const layer of rule.layers) {
|
||||||
|
let value: LoadedConfig[K] | undefined;
|
||||||
|
if (layer === 'local') {
|
||||||
|
value = getLocalLayerValue(project, key);
|
||||||
|
} else if (layer === 'piece') {
|
||||||
|
value = rule.pieceValue?.(options?.pieceContext);
|
||||||
|
} else {
|
||||||
|
value = getGlobalLayerValue(global, key);
|
||||||
|
}
|
||||||
|
if (value !== undefined) {
|
||||||
|
if (layer === 'local') {
|
||||||
|
if (key === 'providerOptions' && hasProviderOptionsEnvOverride()) {
|
||||||
|
return { value, source: 'env' };
|
||||||
|
}
|
||||||
|
return { value, source: 'project' };
|
||||||
|
}
|
||||||
|
if (layer === 'piece') {
|
||||||
|
return { value, source: 'piece' };
|
||||||
|
}
|
||||||
|
return { value, source: 'global' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { value: rule.defaultValue as LoadedConfig[K], source: 'default' };
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasProviderOptionsEnvOverride(): boolean {
|
||||||
|
return PROVIDER_OPTIONS_ENV_PATHS.some((path) => process.env[envVarNameFromPath(path)] !== undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveUncachedConfigValue<K extends ConfigParameterKey>(
|
||||||
|
projectDir: string,
|
||||||
|
key: K,
|
||||||
|
options?: ResolveConfigOptions,
|
||||||
|
): ResolvedConfigValue<K> {
|
||||||
|
const project = loadProjectConfigCached(projectDir);
|
||||||
|
const global = loadGlobalConfig();
|
||||||
|
return resolveByRegistry(key, project, global, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveConfigValueWithSource<K extends ConfigParameterKey>(
|
||||||
|
projectDir: string,
|
||||||
|
key: K,
|
||||||
|
options?: ResolveConfigOptions,
|
||||||
|
): ResolvedConfigValue<K> {
|
||||||
|
const resolved = resolveUncachedConfigValue(projectDir, key, options);
|
||||||
|
if (!options?.pieceContext) {
|
||||||
|
setCachedResolvedValue(projectDir, key, resolved.value);
|
||||||
|
}
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
|
|
||||||
export function resolveConfigValue<K extends ConfigParameterKey>(
|
export function resolveConfigValue<K extends ConfigParameterKey>(
|
||||||
projectDir: string,
|
projectDir: string,
|
||||||
key: K,
|
key: K,
|
||||||
|
options?: ResolveConfigOptions,
|
||||||
): LoadedConfig[K] {
|
): LoadedConfig[K] {
|
||||||
return loadConfig(projectDir)[key];
|
if (!options?.pieceContext && hasCachedResolvedValue(projectDir, key)) {
|
||||||
|
return getCachedResolvedValue(projectDir, key) as LoadedConfig[K];
|
||||||
|
}
|
||||||
|
return resolveConfigValueWithSource(projectDir, key, options).value;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveConfigValues<K extends ConfigParameterKey>(
|
export function resolveConfigValues<K extends ConfigParameterKey>(
|
||||||
projectDir: string,
|
projectDir: string,
|
||||||
keys: readonly K[],
|
keys: readonly K[],
|
||||||
|
options?: ResolveConfigOptions,
|
||||||
): Pick<LoadedConfig, K> {
|
): Pick<LoadedConfig, K> {
|
||||||
const config = loadConfig(projectDir);
|
|
||||||
const result = {} as Pick<LoadedConfig, K>;
|
const result = {} as Pick<LoadedConfig, K>;
|
||||||
for (const key of keys) {
|
for (const key of keys) {
|
||||||
result[key] = config[key];
|
result[key] = resolveConfigValue(projectDir, key, options);
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,17 +1,20 @@
|
|||||||
import type { ConfigParameterKey } from './resolveConfigValue.js';
|
import type { ConfigParameterKey } from './resolveConfigValue.js';
|
||||||
import { resolveConfigValue, resolveConfigValues } from './resolveConfigValue.js';
|
import { resolveConfigValue, resolveConfigValues } from './resolveConfigValue.js';
|
||||||
import type { LoadedConfig } from './loadConfig.js';
|
import type { ResolveConfigOptions } from './resolveConfigValue.js';
|
||||||
|
import type { LoadedConfig } from './resolvedConfig.js';
|
||||||
|
|
||||||
export function resolvePieceConfigValue<K extends ConfigParameterKey>(
|
export function resolvePieceConfigValue<K extends ConfigParameterKey>(
|
||||||
projectDir: string,
|
projectDir: string,
|
||||||
key: K,
|
key: K,
|
||||||
|
options?: ResolveConfigOptions,
|
||||||
): LoadedConfig[K] {
|
): LoadedConfig[K] {
|
||||||
return resolveConfigValue(projectDir, key);
|
return resolveConfigValue(projectDir, key, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolvePieceConfigValues<K extends ConfigParameterKey>(
|
export function resolvePieceConfigValues<K extends ConfigParameterKey>(
|
||||||
projectDir: string,
|
projectDir: string,
|
||||||
keys: readonly K[],
|
keys: readonly K[],
|
||||||
|
options?: ResolveConfigOptions,
|
||||||
): Pick<LoadedConfig, K> {
|
): Pick<LoadedConfig, K> {
|
||||||
return resolveConfigValues(projectDir, keys);
|
return resolveConfigValues(projectDir, keys, options);
|
||||||
}
|
}
|
||||||
|
|||||||
9
src/infra/config/resolvedConfig.ts
Normal file
9
src/infra/config/resolvedConfig.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import type { PersistedGlobalConfig } from '../../core/models/persisted-global-config.js';
|
||||||
|
|
||||||
|
export interface LoadedConfig extends Omit<PersistedGlobalConfig, 'provider' | 'verbose'> {
|
||||||
|
piece: string;
|
||||||
|
provider: NonNullable<PersistedGlobalConfig['provider']>;
|
||||||
|
verbose: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ConfigParameterKey = keyof LoadedConfig;
|
||||||
@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
import type { MovementProviderOptions } from '../../core/models/piece-types.js';
|
import type { MovementProviderOptions } from '../../core/models/piece-types.js';
|
||||||
import type { ProviderPermissionProfiles } from '../../core/models/provider-profiles.js';
|
import type { ProviderPermissionProfiles } from '../../core/models/provider-profiles.js';
|
||||||
import type { AnalyticsConfig } from '../../core/models/global-config.js';
|
import type { AnalyticsConfig } from '../../core/models/persisted-global-config.js';
|
||||||
|
|
||||||
/** Project configuration stored in .takt/config.yaml */
|
/** Project configuration stored in .takt/config.yaml */
|
||||||
export interface ProjectLocalConfig {
|
export interface ProjectLocalConfig {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user