import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs'; import { parse, stringify } from 'yaml'; import { ProjectConfigSchema } from '../../../core/models/index.js'; import { copyProjectResourcesToDir } from '../../resources/index.js'; import type { ProjectLocalConfig } from '../types.js'; import { applyProjectConfigEnvOverrides } from '../env/config-env-overrides.js'; import { normalizeConfigProviderReference, type ConfigProviderReference, } from '../providerReference.js'; import { normalizePipelineConfig, normalizeProviderProfiles, denormalizeProviderProfiles, denormalizeProviderOptions, normalizePersonaProviders, normalizePieceOverrides, denormalizePieceOverrides, normalizeRuntime, } from '../configNormalizers.js'; import { invalidateResolvedConfigCache } from '../resolutionCache.js'; import { MIGRATED_PROJECT_LOCAL_DEFAULTS } from '../migratedProjectLocalDefaults.js'; import type { MigratedProjectLocalConfigKey } from '../migratedProjectLocalKeys.js'; import { getProjectConfigDir, getProjectConfigPath } from './projectConfigPaths.js'; import { normalizeSubmodules, normalizeWithSubmodules, normalizeAnalytics, denormalizeAnalytics, formatIssuePath, } from './projectConfigTransforms.js'; export type { ProjectLocalConfig } from '../types.js'; type Assert = T; type IsNever = [T] extends [never] ? true : false; /** * Compile-time guard: * migrated fields must be owned by ProjectLocalConfig. */ const projectLocalConfigMigratedFieldGuard: Assert>> = true; void projectLocalConfigMigratedFieldGuard; type ProviderType = NonNullable; type RawProviderReference = ConfigProviderReference; /** * Load project configuration from .takt/config.yaml */ export function loadProjectConfig(projectDir: string): ProjectLocalConfig { const configPath = getProjectConfigPath(projectDir); const rawConfig: Record = {}; if (existsSync(configPath)) { const content = readFileSync(configPath, 'utf-8'); let parsed: unknown; try { parsed = parse(content); } catch (error) { const message = error instanceof Error ? error.message : String(error); throw new Error(`Configuration error: failed to parse ${configPath}: ${message}`); } if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { Object.assign(rawConfig, parsed as Record); } else if (parsed != null) { throw new Error(`Configuration error: ${configPath} must be a YAML object.`); } } applyProjectConfigEnvOverrides(rawConfig); const parsedResult = ProjectConfigSchema.safeParse(rawConfig); if (!parsedResult.success) { const firstIssue = parsedResult.error.issues[0]; const issuePath = firstIssue ? formatIssuePath(firstIssue.path) : '(root)'; const issueMessage = firstIssue?.message ?? 'Invalid configuration value'; throw new Error( `Configuration error: invalid ${issuePath} in ${configPath}: ${issueMessage}`, ); } const parsedConfig = parsedResult.data; const { provider, model, auto_pr, draft_pr, base_branch, submodules, with_submodules, provider_options, provider_profiles, analytics, log_level, pipeline, persona_providers, verbose, branch_name_strategy, minimal_output, concurrency, task_poll_interval_ms, interactive_preview_movements, piece_overrides, runtime, ...rest } = parsedConfig; const normalizedProvider = normalizeConfigProviderReference( provider as RawProviderReference, model as string | undefined, provider_options as Record | undefined, ); const normalizedSubmodules = normalizeSubmodules(submodules); const normalizedWithSubmodules = normalizeWithSubmodules(with_submodules); const effectiveWithSubmodules = normalizedSubmodules === undefined ? normalizedWithSubmodules : undefined; const normalizedPipeline = normalizePipelineConfig( pipeline as { default_branch_prefix?: string; commit_message_template?: string; pr_body_template?: string } | undefined, ); const personaProviders = normalizePersonaProviders( persona_providers as Record | undefined, ); return { ...(rest as ProjectLocalConfig), logLevel: log_level as ProjectLocalConfig['logLevel'], pipeline: normalizedPipeline, personaProviders, branchNameStrategy: branch_name_strategy as ProjectLocalConfig['branchNameStrategy'], minimalOutput: minimal_output as boolean | undefined, concurrency: concurrency as number | undefined, taskPollIntervalMs: task_poll_interval_ms as number | undefined, interactivePreviewMovements: interactive_preview_movements as number | undefined, verbose: verbose as boolean | undefined, autoPr: auto_pr as boolean | undefined, draftPr: draft_pr as boolean | undefined, baseBranch: base_branch as string | undefined, submodules: normalizedSubmodules, withSubmodules: effectiveWithSubmodules, analytics: normalizeAnalytics(analytics as Record | undefined), provider: normalizedProvider.provider, model: normalizedProvider.model, providerOptions: normalizedProvider.providerOptions, providerProfiles: normalizeProviderProfiles(provider_profiles as Record }> | undefined), pieceOverrides: normalizePieceOverrides(piece_overrides as { quality_gates?: string[]; quality_gates_edit_only?: boolean; movements?: Record } | undefined), runtime: normalizeRuntime(runtime), }; } /** * Save project configuration to .takt/config.yaml */ export function saveProjectConfig(projectDir: string, config: ProjectLocalConfig): void { const configDir = getProjectConfigDir(projectDir); const configPath = getProjectConfigPath(projectDir); if (!existsSync(configDir)) { mkdirSync(configDir, { recursive: true }); } copyProjectResourcesToDir(configDir); const savePayload: Record = { ...config }; const normalizedSubmodules = normalizeSubmodules(config.submodules); const rawAnalytics = denormalizeAnalytics(config.analytics); if (rawAnalytics) { savePayload.analytics = rawAnalytics; } else { delete savePayload.analytics; } const rawProfiles = denormalizeProviderProfiles(config.providerProfiles); if (rawProfiles && Object.keys(rawProfiles).length > 0) { savePayload.provider_profiles = rawProfiles; } else { delete savePayload.provider_profiles; } const rawProviderOptions = denormalizeProviderOptions(config.providerOptions); if (rawProviderOptions) { savePayload.provider_options = rawProviderOptions; } else { delete savePayload.provider_options; } delete savePayload.providerProfiles; delete savePayload.providerOptions; delete savePayload.concurrency; delete savePayload.verbose; if (config.autoPr !== undefined) savePayload.auto_pr = config.autoPr; if (config.draftPr !== undefined) savePayload.draft_pr = config.draftPr; if (config.baseBranch !== undefined) savePayload.base_branch = config.baseBranch; if ( config.logLevel !== undefined && config.logLevel !== MIGRATED_PROJECT_LOCAL_DEFAULTS.logLevel ) { savePayload.log_level = config.logLevel; } if (config.branchNameStrategy !== undefined) savePayload.branch_name_strategy = config.branchNameStrategy; if ( config.minimalOutput !== undefined && config.minimalOutput !== MIGRATED_PROJECT_LOCAL_DEFAULTS.minimalOutput ) { savePayload.minimal_output = config.minimalOutput; } if ( config.taskPollIntervalMs !== undefined && config.taskPollIntervalMs !== MIGRATED_PROJECT_LOCAL_DEFAULTS.taskPollIntervalMs ) { savePayload.task_poll_interval_ms = config.taskPollIntervalMs; } if ( config.interactivePreviewMovements !== undefined && config.interactivePreviewMovements !== MIGRATED_PROJECT_LOCAL_DEFAULTS.interactivePreviewMovements ) { savePayload.interactive_preview_movements = config.interactivePreviewMovements; } if ( config.concurrency !== undefined && config.concurrency !== MIGRATED_PROJECT_LOCAL_DEFAULTS.concurrency ) { savePayload.concurrency = config.concurrency; } if ( config.verbose !== undefined && config.verbose !== MIGRATED_PROJECT_LOCAL_DEFAULTS.verbose ) { savePayload.verbose = config.verbose; } delete savePayload.pipeline; if (config.pipeline) { const pipelineRaw: Record = {}; if (config.pipeline.defaultBranchPrefix !== undefined) { pipelineRaw.default_branch_prefix = config.pipeline.defaultBranchPrefix; } if (config.pipeline.commitMessageTemplate !== undefined) { pipelineRaw.commit_message_template = config.pipeline.commitMessageTemplate; } if (config.pipeline.prBodyTemplate !== undefined) { pipelineRaw.pr_body_template = config.pipeline.prBodyTemplate; } if (Object.keys(pipelineRaw).length > 0) savePayload.pipeline = pipelineRaw; } if (config.personaProviders && Object.keys(config.personaProviders).length > 0) { savePayload.persona_providers = config.personaProviders; } if (normalizedSubmodules !== undefined) { savePayload.submodules = normalizedSubmodules; delete savePayload.with_submodules; } else { delete savePayload.submodules; if (config.withSubmodules !== undefined) { savePayload.with_submodules = config.withSubmodules; } else { delete savePayload.with_submodules; } } delete savePayload.autoPr; delete savePayload.draftPr; delete savePayload.baseBranch; delete savePayload.withSubmodules; delete savePayload.logLevel; delete savePayload.branchNameStrategy; delete savePayload.minimalOutput; delete savePayload.taskPollIntervalMs; delete savePayload.interactivePreviewMovements; delete savePayload.personaProviders; const rawPieceOverrides = denormalizePieceOverrides(config.pieceOverrides); if (rawPieceOverrides) { savePayload.piece_overrides = rawPieceOverrides; } delete savePayload.pieceOverrides; const normalizedRuntime = normalizeRuntime(config.runtime); if (normalizedRuntime) { savePayload.runtime = normalizedRuntime; } else { delete savePayload.runtime; } const content = stringify(savePayload, { indent: 2 }); writeFileSync(configPath, content, 'utf-8'); invalidateResolvedConfigCache(projectDir); } export function updateProjectConfig( projectDir: string, key: K, value: ProjectLocalConfig[K] ): void { const config = loadProjectConfig(projectDir); config[key] = value; saveProjectConfig(projectDir, config); }