refactor(config/loader): extract defaults-merger, config-getters, polish orchestrator

Phases 4-6 of #1164. Final extractions and orchestrator cleanup:

- src/config/loader/defaults-merger.ts (323 LOC):
  mergeWithDefaults — pure transform that fills defaults across browser,
  websearch, dashboard auth, image analysis, logging, cursor, continuity,
  official channels.
- src/config/loader/config-getters.ts (339 LOC):
  getWebSearchConfig, getGlobalEnvConfig, getContinuityInheritanceMap,
  getCliproxySafetyConfig, getThinkingConfig, getOfficialChannelsConfig,
  isDashboardAuthEnabled, getDashboardAuthConfig, getBrowserConfig,
  getImageAnalysisConfig, getLoggingConfig, getCursorConfig,
  GeminiWebSearchInfo. Lazy require('../unified-config-loader') call-time
  resolution preserves spy-based test compatibility while breaking the
  config-getters -> orchestrator cycle.
- unified-config-loader.ts: rewritten as 268-LOC orchestrator. Hosts core
  load/save/mutate logic + re-exports of all moved symbols for backwards
  compat with codebase imports.

Forward-reference callbacks in io-locks.ts kept as-is — defaults-merger
and normalizers don't import from io-locks (no cycle), but simplifying
io-locks's internal callback API risks lock-semantics regression.
Deferred per KISS.

unified-config-loader.ts: 1508 -> 268 LOC (-1240, -82%). Behavior
unchanged; full suite passes 1824/1824.

Refs #1164
This commit is contained in:
Tam Nhu Tran
2026-05-03 01:04:57 -04:00
parent 315ae19387
commit f3e79fd4e8
3 changed files with 721 additions and 618 deletions
+339
View File
@@ -0,0 +1,339 @@
/**
* config-getters.ts
*
* Typed sub-config accessor functions extracted from unified-config-loader.ts
* (Phase 5 split — issue #1164).
*
* All functions read the loaded config via loadOrCreateUnifiedConfig and
* return typed sub-configs with defaults applied.
*
* No I/O beyond what loadOrCreateUnifiedConfig performs internally.
*/
import {
DEFAULT_CLIPROXY_SAFETY_CONFIG,
DEFAULT_CURSOR_CONFIG,
DEFAULT_GLOBAL_ENV,
DEFAULT_IMAGE_ANALYSIS_CONFIG,
DEFAULT_LOGGING_CONFIG,
DEFAULT_OFFICIAL_CHANNELS_CONFIG,
DEFAULT_THINKING_CONFIG,
} from '../unified-config-types';
import type {
BrowserConfig,
CLIProxySafetyConfig,
CursorConfig,
DashboardAuthConfig,
GlobalEnvConfig,
ImageAnalysisConfig,
LoggingConfig,
OfficialChannelsConfig,
ThinkingConfig,
} from '../unified-config-types';
import { canonicalizeBrowserConfig } from './normalizers';
import { canonicalizeImageAnalysisConfig } from '../../utils/hooks/image-analysis-backend-resolver';
import { normalizeOfficialChannelIds } from '../../channels/official-channels-runtime';
import { normalizeSearxngBaseUrl } from '../../utils/websearch/types';
// ---------------------------------------------------------------------------
// Circular-import safety: loadOrCreateUnifiedConfig lives in
// unified-config-loader.ts which imports this file. We break the cycle by
// using a lazy require() inside getConfig() so the module is resolved at
// call time (after both modules have finished loading) rather than at import
// time. This also preserves spy/mock compatibility: test spies replace the
// function on the module namespace object, and require() returns that live
// namespace, so the spy is always picked up.
// ---------------------------------------------------------------------------
function getConfig(): import('../unified-config-types').UnifiedConfig {
const loader = require('../unified-config-loader') as {
loadOrCreateUnifiedConfig: () => import('../unified-config-types').UnifiedConfig;
};
return loader.loadOrCreateUnifiedConfig();
}
// ---------------------------------------------------------------------------
// GeminiWebSearchInfo interface
// ---------------------------------------------------------------------------
/**
* Gemini CLI WebSearch configuration
*/
export interface GeminiWebSearchInfo {
enabled: boolean;
model: string;
timeout: number;
}
// ---------------------------------------------------------------------------
// Accessor functions
// ---------------------------------------------------------------------------
/**
* Get websearch configuration.
* Returns defaults if not configured.
* Supports deterministic providers and optional Gemini/OpenCode/Grok CLI fallbacks.
*/
export function getWebSearchConfig(): {
enabled: boolean;
providers?: {
exa?: { enabled?: boolean; max_results?: number };
tavily?: { enabled?: boolean; max_results?: number };
brave?: { enabled?: boolean; max_results?: number };
searxng?: { enabled?: boolean; url?: string; max_results?: number };
duckduckgo?: { enabled?: boolean; max_results?: number };
gemini?: GeminiWebSearchInfo;
opencode?: { enabled?: boolean; model?: string; timeout?: number };
grok?: { enabled?: boolean; timeout?: number };
};
// Legacy fields (deprecated)
gemini?: { enabled?: boolean; timeout?: number };
} {
const config = getConfig();
// Build provider configs
const exaConfig = {
enabled: config.websearch?.providers?.exa?.enabled ?? false,
max_results: config.websearch?.providers?.exa?.max_results ?? 5,
};
const tavilyConfig = {
enabled: config.websearch?.providers?.tavily?.enabled ?? false,
max_results: config.websearch?.providers?.tavily?.max_results ?? 5,
};
const duckDuckGoConfig = {
enabled: config.websearch?.providers?.duckduckgo?.enabled ?? true,
max_results: config.websearch?.providers?.duckduckgo?.max_results ?? 5,
};
const braveConfig = {
enabled: config.websearch?.providers?.brave?.enabled ?? false,
max_results: config.websearch?.providers?.brave?.max_results ?? 5,
};
const searxngConfig = {
enabled: config.websearch?.providers?.searxng?.enabled ?? false,
url: normalizeSearxngBaseUrl(config.websearch?.providers?.searxng?.url) ?? '',
max_results: config.websearch?.providers?.searxng?.max_results ?? 5,
};
const geminiConfig: GeminiWebSearchInfo = {
enabled:
config.websearch?.providers?.gemini?.enabled ?? config.websearch?.gemini?.enabled ?? false,
model: config.websearch?.providers?.gemini?.model ?? 'gemini-2.5-flash',
timeout:
config.websearch?.providers?.gemini?.timeout ?? config.websearch?.gemini?.timeout ?? 55,
};
const opencodeConfig = {
enabled: config.websearch?.providers?.opencode?.enabled ?? false,
model: config.websearch?.providers?.opencode?.model ?? 'opencode/grok-code',
timeout: config.websearch?.providers?.opencode?.timeout ?? 90,
};
const grokConfig = {
enabled: config.websearch?.providers?.grok?.enabled ?? false,
timeout: config.websearch?.providers?.grok?.timeout ?? 55,
};
// Auto-enable master switch if ANY provider is enabled
const anyProviderEnabled =
exaConfig.enabled ||
tavilyConfig.enabled ||
braveConfig.enabled ||
searxngConfig.enabled ||
duckDuckGoConfig.enabled ||
geminiConfig.enabled ||
opencodeConfig.enabled ||
grokConfig.enabled;
const enabled = anyProviderEnabled && (config.websearch?.enabled ?? true);
return {
enabled,
providers: {
exa: exaConfig,
tavily: tavilyConfig,
brave: braveConfig,
searxng: searxngConfig,
duckduckgo: duckDuckGoConfig,
gemini: geminiConfig,
opencode: opencodeConfig,
grok: grokConfig,
},
// Legacy field for backwards compatibility
gemini: config.websearch?.gemini,
};
}
/**
* Get global_env configuration.
* Returns defaults if not configured.
*/
export function getGlobalEnvConfig(): GlobalEnvConfig {
const config = getConfig();
return {
enabled: config.global_env?.enabled ?? true,
env: config.global_env?.env ?? { ...DEFAULT_GLOBAL_ENV },
};
}
/**
* Get continuity inheritance mapping.
* Returns empty mapping when not configured.
*/
export function getContinuityInheritanceMap(): Record<string, string> {
const config = getConfig();
return config.continuity?.inherit_from_account ?? {};
}
/**
* Get cliproxy safety configuration.
* Returns defaults if not configured.
*/
export function getCliproxySafetyConfig(): CLIProxySafetyConfig {
const config = getConfig();
return {
antigravity_ack_bypass:
config.cliproxy?.safety?.antigravity_ack_bypass ??
DEFAULT_CLIPROXY_SAFETY_CONFIG.antigravity_ack_bypass,
};
}
/**
* Get thinking configuration.
* Returns defaults if not configured.
*/
export function getThinkingConfig(): ThinkingConfig {
const config = getConfig();
// W2: Check for invalid thinking config (e.g., thinking: true instead of object)
if (config.thinking !== undefined && typeof config.thinking !== 'object') {
console.warn(
`[!] Invalid thinking config: expected object, got ${typeof config.thinking}. Using defaults.`
);
console.warn(` Tip: Use 'thinking: { mode: auto }' instead of 'thinking: true'`);
return DEFAULT_THINKING_CONFIG;
}
return {
mode: config.thinking?.mode ?? DEFAULT_THINKING_CONFIG.mode,
override: config.thinking?.override,
tier_defaults: {
opus: config.thinking?.tier_defaults?.opus ?? DEFAULT_THINKING_CONFIG.tier_defaults.opus,
sonnet:
config.thinking?.tier_defaults?.sonnet ?? DEFAULT_THINKING_CONFIG.tier_defaults.sonnet,
haiku: config.thinking?.tier_defaults?.haiku ?? DEFAULT_THINKING_CONFIG.tier_defaults.haiku,
},
provider_overrides: config.thinking?.provider_overrides,
show_warnings: config.thinking?.show_warnings ?? DEFAULT_THINKING_CONFIG.show_warnings,
};
}
/**
* Get Official Channels configuration.
* Returns defaults if not configured.
*/
export function getOfficialChannelsConfig(): OfficialChannelsConfig {
const config = getConfig();
return {
selected:
config.channels?.selected && config.channels.selected.length > 0
? normalizeOfficialChannelIds(config.channels.selected)
: DEFAULT_OFFICIAL_CHANNELS_CONFIG.selected,
unattended: config.channels?.unattended ?? DEFAULT_OFFICIAL_CHANNELS_CONFIG.unattended,
};
}
/**
* Check if dashboard auth is enabled.
* Priority: ENV vars > config.yaml > defaults
*/
export function isDashboardAuthEnabled(): boolean {
const envEnabled = process.env.CCS_DASHBOARD_AUTH_ENABLED;
if (envEnabled !== undefined) {
return envEnabled === 'true' || envEnabled === '1';
}
const config = getConfig();
return config.dashboard_auth?.enabled ?? false;
}
/**
* Get dashboard_auth configuration with ENV var override.
* Priority: ENV vars > config.yaml > defaults
*/
export function getDashboardAuthConfig(): DashboardAuthConfig {
const config = getConfig();
// ENV vars take precedence
const envEnabled = process.env.CCS_DASHBOARD_AUTH_ENABLED;
const envUsername = process.env.CCS_DASHBOARD_USERNAME;
const envPasswordHash = process.env.CCS_DASHBOARD_PASSWORD_HASH;
return {
enabled:
envEnabled !== undefined
? envEnabled === 'true' || envEnabled === '1'
: (config.dashboard_auth?.enabled ?? false),
username: envUsername ?? config.dashboard_auth?.username ?? '',
password_hash: envPasswordHash ?? config.dashboard_auth?.password_hash ?? '',
session_timeout_hours: config.dashboard_auth?.session_timeout_hours ?? 24,
};
}
/**
* Get browser automation configuration.
* Returns canonicalized defaults if not configured.
*/
export function getBrowserConfig(): BrowserConfig {
const config = getConfig();
return canonicalizeBrowserConfig(config.browser);
}
/**
* Get image_analysis configuration.
* Returns defaults if not configured.
*/
export function getImageAnalysisConfig(): ImageAnalysisConfig {
const config = getConfig();
return canonicalizeImageAnalysisConfig({
enabled: config.image_analysis?.enabled ?? DEFAULT_IMAGE_ANALYSIS_CONFIG.enabled,
timeout: config.image_analysis?.timeout ?? DEFAULT_IMAGE_ANALYSIS_CONFIG.timeout,
provider_models:
config.image_analysis?.provider_models ?? DEFAULT_IMAGE_ANALYSIS_CONFIG.provider_models,
fallback_backend:
config.image_analysis?.fallback_backend ?? DEFAULT_IMAGE_ANALYSIS_CONFIG.fallback_backend,
profile_backends:
config.image_analysis?.profile_backends ?? DEFAULT_IMAGE_ANALYSIS_CONFIG.profile_backends,
});
}
/**
* Get logging configuration.
* Returns defaults if not configured.
*/
export function getLoggingConfig(): LoggingConfig {
const config = getConfig();
return {
enabled: config.logging?.enabled ?? DEFAULT_LOGGING_CONFIG.enabled,
level: config.logging?.level ?? DEFAULT_LOGGING_CONFIG.level,
rotate_mb: config.logging?.rotate_mb ?? DEFAULT_LOGGING_CONFIG.rotate_mb,
retain_days: config.logging?.retain_days ?? DEFAULT_LOGGING_CONFIG.retain_days,
redact: config.logging?.redact ?? DEFAULT_LOGGING_CONFIG.redact,
live_buffer_size: config.logging?.live_buffer_size ?? DEFAULT_LOGGING_CONFIG.live_buffer_size,
};
}
/**
* Get cursor configuration.
* Returns defaults if not configured.
*/
export function getCursorConfig(): CursorConfig {
const config = getConfig();
return config.cursor ?? { ...DEFAULT_CURSOR_CONFIG };
}
+323
View File
@@ -0,0 +1,323 @@
/**
* defaults-merger.ts
*
* mergeWithDefaults function extracted from unified-config-loader.ts
* (Phase 4 split — issue #1164).
*
* Pure transform: no I/O, no side effects. Merges a partial UnifiedConfig
* with defaults, filling in missing sections.
*
* Circular-import note: this module imports from normalizers.ts (Phase 2)
* and io-locks.ts (Phase 1) is NOT imported here, so there is no cycle.
* io-locks.ts callbacks (mergeWithDefaults, validateCompositeVariants) are
* now replaced with direct imports in unified-config-loader.ts (Phase 6).
*/
import {
createEmptyUnifiedConfig,
DEFAULT_COPILOT_CONFIG,
DEFAULT_CURSOR_CONFIG,
DEFAULT_GLOBAL_ENV,
DEFAULT_CLIPROXY_SERVER_CONFIG,
DEFAULT_CLIPROXY_SAFETY_CONFIG,
DEFAULT_OPENAI_COMPAT_PROXY_CONFIG,
DEFAULT_QUOTA_MANAGEMENT_CONFIG,
DEFAULT_THINKING_CONFIG,
DEFAULT_DASHBOARD_AUTH_CONFIG,
DEFAULT_IMAGE_ANALYSIS_CONFIG,
DEFAULT_LOGGING_CONFIG,
} from '../unified-config-types';
import type { UnifiedConfig } from '../unified-config-types';
import { canonicalizeBrowserConfig, normalizeSessionAffinityTtl } from './normalizers';
import { normalizeContinuityConfig, normalizeOfficialChannelsConfig } from './normalizers';
import type { LegacyDiscordChannelsConfig } from './normalizers';
import { canonicalizeImageAnalysisConfig } from '../../utils/hooks/image-analysis-backend-resolver';
import { normalizeSearxngBaseUrl } from '../../utils/websearch/types';
// ---------------------------------------------------------------------------
// mergeWithDefaults
// ---------------------------------------------------------------------------
/**
* Merge partial config with defaults.
* Preserves existing data while filling in missing sections.
*/
export function mergeWithDefaults(partial: Partial<UnifiedConfig>): UnifiedConfig {
const defaults = createEmptyUnifiedConfig();
const continuity = normalizeContinuityConfig(partial);
return {
version: partial.version ?? defaults.version,
setup_completed: partial.setup_completed,
default: partial.default ?? defaults.default,
accounts: partial.accounts ?? defaults.accounts,
profiles: partial.profiles ?? defaults.profiles,
cliproxy: {
...partial.cliproxy,
oauth_accounts: partial.cliproxy?.oauth_accounts ?? defaults.cliproxy.oauth_accounts,
providers: defaults.cliproxy.providers, // Always use defaults for providers
variants: partial.cliproxy?.variants ?? defaults.cliproxy.variants,
logging: {
enabled: partial.cliproxy?.logging?.enabled ?? defaults.cliproxy.logging?.enabled ?? false,
request_log:
partial.cliproxy?.logging?.request_log ?? defaults.cliproxy.logging?.request_log ?? false,
},
safety: {
antigravity_ack_bypass:
partial.cliproxy?.safety?.antigravity_ack_bypass ??
DEFAULT_CLIPROXY_SAFETY_CONFIG.antigravity_ack_bypass,
},
// Kiro browser behavior setting (optional)
kiro_no_incognito: partial.cliproxy?.kiro_no_incognito,
// Auth config - preserve user values, no defaults (uses constants as fallback)
auth: partial.cliproxy?.auth,
// Background token refresh config (optional)
token_refresh: partial.cliproxy?.token_refresh,
// Backend selection - validate and preserve user choice (original vs plus)
backend:
partial.cliproxy?.backend === 'original' || partial.cliproxy?.backend === 'plus'
? partial.cliproxy.backend
: undefined, // Invalid values become undefined (defaults to 'original' at runtime)
// Auto-sync - default to true
auto_sync: partial.cliproxy?.auto_sync ?? defaults.cliproxy.auto_sync ?? true,
routing: {
strategy:
partial.cliproxy?.routing?.strategy === 'fill-first' ||
partial.cliproxy?.routing?.strategy === 'round-robin'
? partial.cliproxy.routing.strategy
: defaults.cliproxy.routing?.strategy,
session_affinity:
typeof partial.cliproxy?.routing?.session_affinity === 'boolean'
? partial.cliproxy.routing.session_affinity
: defaults.cliproxy.routing?.session_affinity,
session_affinity_ttl: normalizeSessionAffinityTtl(
partial.cliproxy?.routing?.session_affinity_ttl,
defaults.cliproxy.routing?.session_affinity_ttl ?? '1h'
),
},
},
proxy: {
port: partial.proxy?.port ?? DEFAULT_OPENAI_COMPAT_PROXY_CONFIG.port,
profile_ports: partial.proxy?.profile_ports ?? {
...DEFAULT_OPENAI_COMPAT_PROXY_CONFIG.profile_ports,
},
routing: {
default: partial.proxy?.routing?.default ?? defaults.proxy?.routing?.default,
background: partial.proxy?.routing?.background ?? defaults.proxy?.routing?.background,
think: partial.proxy?.routing?.think ?? defaults.proxy?.routing?.think,
longContext: partial.proxy?.routing?.longContext ?? defaults.proxy?.routing?.longContext,
webSearch: partial.proxy?.routing?.webSearch ?? defaults.proxy?.routing?.webSearch,
longContextThreshold:
partial.proxy?.routing?.longContextThreshold ??
defaults.proxy?.routing?.longContextThreshold,
},
},
logging: {
enabled: partial.logging?.enabled ?? DEFAULT_LOGGING_CONFIG.enabled,
level: partial.logging?.level ?? DEFAULT_LOGGING_CONFIG.level,
rotate_mb: partial.logging?.rotate_mb ?? DEFAULT_LOGGING_CONFIG.rotate_mb,
retain_days: partial.logging?.retain_days ?? DEFAULT_LOGGING_CONFIG.retain_days,
redact: partial.logging?.redact ?? DEFAULT_LOGGING_CONFIG.redact,
live_buffer_size:
partial.logging?.live_buffer_size ?? DEFAULT_LOGGING_CONFIG.live_buffer_size,
},
preferences: {
...defaults.preferences,
...partial.preferences,
},
websearch: {
enabled: partial.websearch?.enabled ?? defaults.websearch?.enabled ?? true,
providers: {
exa: {
enabled: partial.websearch?.providers?.exa?.enabled ?? false,
max_results: partial.websearch?.providers?.exa?.max_results ?? 5,
},
tavily: {
enabled: partial.websearch?.providers?.tavily?.enabled ?? false,
max_results: partial.websearch?.providers?.tavily?.max_results ?? 5,
},
brave: {
enabled: partial.websearch?.providers?.brave?.enabled ?? false,
max_results: partial.websearch?.providers?.brave?.max_results ?? 5,
},
searxng: {
enabled: partial.websearch?.providers?.searxng?.enabled ?? false,
url: normalizeSearxngBaseUrl(partial.websearch?.providers?.searxng?.url) ?? '',
max_results: partial.websearch?.providers?.searxng?.max_results ?? 5,
},
duckduckgo: {
enabled: partial.websearch?.providers?.duckduckgo?.enabled ?? true,
max_results: partial.websearch?.providers?.duckduckgo?.max_results ?? 5,
},
gemini: {
enabled:
partial.websearch?.providers?.gemini?.enabled ??
partial.websearch?.gemini?.enabled ?? // Legacy fallback
false,
model: partial.websearch?.providers?.gemini?.model ?? 'gemini-2.5-flash',
timeout:
partial.websearch?.providers?.gemini?.timeout ??
partial.websearch?.gemini?.timeout ?? // Legacy fallback
55,
},
opencode: {
enabled: partial.websearch?.providers?.opencode?.enabled ?? false,
model: partial.websearch?.providers?.opencode?.model ?? 'opencode/grok-code',
timeout: partial.websearch?.providers?.opencode?.timeout ?? 90,
},
grok: {
enabled: partial.websearch?.providers?.grok?.enabled ?? false,
timeout: partial.websearch?.providers?.grok?.timeout ?? 55,
},
},
// Legacy fields (keep for backwards compatibility during read)
gemini: partial.websearch?.gemini,
},
// Copilot config - strictly opt-in, merge with defaults
copilot: {
enabled: partial.copilot?.enabled ?? DEFAULT_COPILOT_CONFIG.enabled,
auto_start: partial.copilot?.auto_start ?? DEFAULT_COPILOT_CONFIG.auto_start,
port: partial.copilot?.port ?? DEFAULT_COPILOT_CONFIG.port,
account_type: partial.copilot?.account_type ?? DEFAULT_COPILOT_CONFIG.account_type,
rate_limit: partial.copilot?.rate_limit ?? DEFAULT_COPILOT_CONFIG.rate_limit,
wait_on_limit: partial.copilot?.wait_on_limit ?? DEFAULT_COPILOT_CONFIG.wait_on_limit,
model: partial.copilot?.model ?? DEFAULT_COPILOT_CONFIG.model,
},
// Cursor config - disabled by default, merge with defaults
cursor: {
enabled: partial.cursor?.enabled ?? DEFAULT_CURSOR_CONFIG.enabled,
port: partial.cursor?.port ?? DEFAULT_CURSOR_CONFIG.port,
auto_start: partial.cursor?.auto_start ?? DEFAULT_CURSOR_CONFIG.auto_start,
ghost_mode: partial.cursor?.ghost_mode ?? DEFAULT_CURSOR_CONFIG.ghost_mode,
model: partial.cursor?.model ?? DEFAULT_CURSOR_CONFIG.model,
opus_model: partial.cursor?.opus_model,
sonnet_model: partial.cursor?.sonnet_model,
haiku_model: partial.cursor?.haiku_model,
},
// Global env - injected into all non-Claude subscription profiles
global_env: {
enabled: partial.global_env?.enabled ?? true,
env: partial.global_env?.env ?? { ...DEFAULT_GLOBAL_ENV },
},
continuity,
// CLIProxy server config - remote/local CLIProxyAPI settings
cliproxy_server: {
remote: {
enabled:
partial.cliproxy_server?.remote?.enabled ?? DEFAULT_CLIPROXY_SERVER_CONFIG.remote.enabled,
host: partial.cliproxy_server?.remote?.host ?? DEFAULT_CLIPROXY_SERVER_CONFIG.remote.host,
// Port is optional - undefined means use protocol default (443 for HTTPS, 8317 for HTTP)
port: partial.cliproxy_server?.remote?.port,
protocol:
partial.cliproxy_server?.remote?.protocol ??
DEFAULT_CLIPROXY_SERVER_CONFIG.remote.protocol,
auth_token:
partial.cliproxy_server?.remote?.auth_token ??
DEFAULT_CLIPROXY_SERVER_CONFIG.remote.auth_token,
// management_key is optional - falls back to auth_token when not set
management_key: partial.cliproxy_server?.remote?.management_key,
},
fallback: {
enabled:
partial.cliproxy_server?.fallback?.enabled ??
DEFAULT_CLIPROXY_SERVER_CONFIG.fallback.enabled,
auto_start:
partial.cliproxy_server?.fallback?.auto_start ??
DEFAULT_CLIPROXY_SERVER_CONFIG.fallback.auto_start,
},
local: {
port: partial.cliproxy_server?.local?.port ?? DEFAULT_CLIPROXY_SERVER_CONFIG.local.port,
auto_start:
partial.cliproxy_server?.local?.auto_start ??
DEFAULT_CLIPROXY_SERVER_CONFIG.local.auto_start,
},
},
// Quota management config - hybrid auto+manual account selection
quota_management: {
mode: partial.quota_management?.mode ?? DEFAULT_QUOTA_MANAGEMENT_CONFIG.mode,
auto: {
preflight_check:
partial.quota_management?.auto?.preflight_check ??
DEFAULT_QUOTA_MANAGEMENT_CONFIG.auto.preflight_check,
exhaustion_threshold:
partial.quota_management?.auto?.exhaustion_threshold ??
DEFAULT_QUOTA_MANAGEMENT_CONFIG.auto.exhaustion_threshold,
tier_priority:
partial.quota_management?.auto?.tier_priority ??
DEFAULT_QUOTA_MANAGEMENT_CONFIG.auto.tier_priority,
cooldown_minutes:
partial.quota_management?.auto?.cooldown_minutes ??
DEFAULT_QUOTA_MANAGEMENT_CONFIG.auto.cooldown_minutes,
},
manual: {
paused_accounts:
partial.quota_management?.manual?.paused_accounts ??
DEFAULT_QUOTA_MANAGEMENT_CONFIG.manual.paused_accounts,
forced_default:
partial.quota_management?.manual?.forced_default ??
DEFAULT_QUOTA_MANAGEMENT_CONFIG.manual.forced_default,
tier_lock:
partial.quota_management?.manual?.tier_lock ??
DEFAULT_QUOTA_MANAGEMENT_CONFIG.manual.tier_lock,
},
runtime_monitor: {
enabled:
partial.quota_management?.runtime_monitor?.enabled ??
DEFAULT_QUOTA_MANAGEMENT_CONFIG.runtime_monitor.enabled,
normal_interval_seconds:
partial.quota_management?.runtime_monitor?.normal_interval_seconds ??
DEFAULT_QUOTA_MANAGEMENT_CONFIG.runtime_monitor.normal_interval_seconds,
critical_interval_seconds:
partial.quota_management?.runtime_monitor?.critical_interval_seconds ??
DEFAULT_QUOTA_MANAGEMENT_CONFIG.runtime_monitor.critical_interval_seconds,
warn_threshold:
partial.quota_management?.runtime_monitor?.warn_threshold ??
DEFAULT_QUOTA_MANAGEMENT_CONFIG.runtime_monitor.warn_threshold,
exhaustion_threshold:
partial.quota_management?.runtime_monitor?.exhaustion_threshold ??
DEFAULT_QUOTA_MANAGEMENT_CONFIG.runtime_monitor.exhaustion_threshold,
cooldown_minutes:
partial.quota_management?.runtime_monitor?.cooldown_minutes ??
DEFAULT_QUOTA_MANAGEMENT_CONFIG.runtime_monitor.cooldown_minutes,
},
},
// Thinking config - auto/manual/off control for reasoning budget
thinking: {
mode: partial.thinking?.mode ?? DEFAULT_THINKING_CONFIG.mode,
override: partial.thinking?.override,
tier_defaults: {
opus: partial.thinking?.tier_defaults?.opus ?? DEFAULT_THINKING_CONFIG.tier_defaults.opus,
sonnet:
partial.thinking?.tier_defaults?.sonnet ?? DEFAULT_THINKING_CONFIG.tier_defaults.sonnet,
haiku:
partial.thinking?.tier_defaults?.haiku ?? DEFAULT_THINKING_CONFIG.tier_defaults.haiku,
},
provider_overrides: partial.thinking?.provider_overrides,
show_warnings: partial.thinking?.show_warnings ?? DEFAULT_THINKING_CONFIG.show_warnings,
},
channels: normalizeOfficialChannelsConfig(
partial as Partial<UnifiedConfig> & { discord_channels?: LegacyDiscordChannelsConfig }
),
// Dashboard auth config - disabled by default
dashboard_auth: {
enabled: partial.dashboard_auth?.enabled ?? DEFAULT_DASHBOARD_AUTH_CONFIG.enabled,
username: partial.dashboard_auth?.username ?? DEFAULT_DASHBOARD_AUTH_CONFIG.username,
password_hash:
partial.dashboard_auth?.password_hash ?? DEFAULT_DASHBOARD_AUTH_CONFIG.password_hash,
session_timeout_hours:
partial.dashboard_auth?.session_timeout_hours ??
DEFAULT_DASHBOARD_AUTH_CONFIG.session_timeout_hours,
},
browser: canonicalizeBrowserConfig(partial.browser),
// Image analysis config - enabled by default for CLIProxy providers
image_analysis: canonicalizeImageAnalysisConfig({
enabled: partial.image_analysis?.enabled ?? DEFAULT_IMAGE_ANALYSIS_CONFIG.enabled,
timeout: partial.image_analysis?.timeout ?? DEFAULT_IMAGE_ANALYSIS_CONFIG.timeout,
provider_models:
partial.image_analysis?.provider_models ?? DEFAULT_IMAGE_ANALYSIS_CONFIG.provider_models,
fallback_backend:
partial.image_analysis?.fallback_backend ?? DEFAULT_IMAGE_ANALYSIS_CONFIG.fallback_backend,
profile_backends:
partial.image_analysis?.profile_backends ?? DEFAULT_IMAGE_ANALYSIS_CONFIG.profile_backends,
}),
};
}
+59 -618
View File
@@ -1,12 +1,18 @@
/**
* Unified Config Loader
* Unified Config Loader — orchestrator
*
* Loads and saves the unified YAML configuration.
* Provides fallback to legacy JSON format for backward compatibility.
*
* Phase 1-3 refactor (issue #1164): io-locks, normalizers, and yaml-serializer
* have been extracted to src/config/loader/. This file re-exports everything
* needed by callers so existing import sites continue to work unchanged.
* Phase 1-6 refactor (issue #1164):
* Phase 1 → src/config/loader/io-locks.ts
* Phase 2 → src/config/loader/normalizers.ts
* Phase 3 → src/config/loader/yaml-serializer.ts
* Phase 4 → src/config/loader/defaults-merger.ts
* Phase 5 → src/config/loader/config-getters.ts
*
* This file re-exports the full public API so all existing import sites
* continue to work without modification.
*/
import * as fs from 'fs';
@@ -15,37 +21,14 @@ import {
isUnifiedConfig,
createEmptyUnifiedConfig,
UNIFIED_CONFIG_VERSION,
DEFAULT_COPILOT_CONFIG,
DEFAULT_CURSOR_CONFIG,
DEFAULT_GLOBAL_ENV,
DEFAULT_CLIPROXY_SERVER_CONFIG,
DEFAULT_CLIPROXY_SAFETY_CONFIG,
DEFAULT_OPENAI_COMPAT_PROXY_CONFIG,
DEFAULT_QUOTA_MANAGEMENT_CONFIG,
DEFAULT_THINKING_CONFIG,
DEFAULT_OFFICIAL_CHANNELS_CONFIG,
DEFAULT_DASHBOARD_AUTH_CONFIG,
DEFAULT_IMAGE_ANALYSIS_CONFIG,
DEFAULT_LOGGING_CONFIG,
} from './unified-config-types';
import type {
UnifiedConfig,
CLIProxySafetyConfig,
GlobalEnvConfig,
ThinkingConfig,
OfficialChannelsConfig,
DashboardAuthConfig,
BrowserConfig,
ImageAnalysisConfig,
LoggingConfig,
CursorConfig,
} from './unified-config-types';
import type { UnifiedConfig } from './unified-config-types';
import { isUnifiedConfigEnabled } from './feature-flags';
import { normalizeOfficialChannelIds } from '../channels/official-channels-runtime';
import { canonicalizeImageAnalysisConfig } from '../utils/hooks/image-analysis-backend-resolver';
import { normalizeSearxngBaseUrl } from '../utils/websearch/types';
// Phase 1: io-locks
// ---------------------------------------------------------------------------
// Phase 1 re-exports: io-locks
// ---------------------------------------------------------------------------
export {
CONFIG_YAML,
CONFIG_JSON,
@@ -71,7 +54,10 @@ import {
writeUnifiedConfigWithLockHeld,
} from './loader/io-locks';
// Phase 2: normalizers
// ---------------------------------------------------------------------------
// Phase 2 re-exports: normalizers
// ---------------------------------------------------------------------------
export {
normalizeBrowserDevtoolsPort,
normalizeBrowserPolicy,
@@ -84,21 +70,44 @@ export {
normalizeContinuityConfig,
normalizeOfficialChannelsConfig,
} from './loader/normalizers';
import type { LegacyDiscordChannelsConfig } from './loader/normalizers';
import {
canonicalizeBrowserConfig,
validateCompositeVariants,
normalizeContinuityConfig,
normalizeOfficialChannelsConfig,
normalizeSessionAffinityTtl,
} from './loader/normalizers';
import { canonicalizeBrowserConfig, validateCompositeVariants } from './loader/normalizers';
// ---------------------------------------------------------------------------
// Phase 3 re-exports: yaml-serializer
// ---------------------------------------------------------------------------
// Phase 3: yaml-serializer
export { generateYamlHeader, generateYamlWithComments } from './loader/yaml-serializer';
import { generateYamlHeader, generateYamlWithComments } from './loader/yaml-serializer';
// ---------------------------------------------------------------------------
// getConfigFormat (depends on hasUnifiedConfig, hasLegacyConfig, isUnifiedConfigEnabled)
// Phase 4 re-exports: defaults-merger
// ---------------------------------------------------------------------------
export { mergeWithDefaults } from './loader/defaults-merger';
import { mergeWithDefaults } from './loader/defaults-merger';
// ---------------------------------------------------------------------------
// Phase 5 re-exports: config-getters
// ---------------------------------------------------------------------------
export type { GeminiWebSearchInfo } from './loader/config-getters';
export {
getWebSearchConfig,
getGlobalEnvConfig,
getContinuityInheritanceMap,
getCliproxySafetyConfig,
getThinkingConfig,
getOfficialChannelsConfig,
isDashboardAuthEnabled,
getDashboardAuthConfig,
getBrowserConfig,
getImageAnalysisConfig,
getLoggingConfig,
getCursorConfig,
} from './loader/config-getters';
// ---------------------------------------------------------------------------
// getConfigFormat
// ---------------------------------------------------------------------------
/**
@@ -115,295 +124,7 @@ export function getConfigFormat(): 'yaml' | 'json' | 'none' {
}
// ---------------------------------------------------------------------------
// mergeWithDefaults (Phase 4 territory — kept here until then)
// ---------------------------------------------------------------------------
/**
* Merge partial config with defaults.
* Preserves existing data while filling in missing sections.
*/
function mergeWithDefaults(partial: Partial<UnifiedConfig>): UnifiedConfig {
const defaults = createEmptyUnifiedConfig();
const continuity = normalizeContinuityConfig(partial);
return {
version: partial.version ?? defaults.version,
setup_completed: partial.setup_completed,
default: partial.default ?? defaults.default,
accounts: partial.accounts ?? defaults.accounts,
profiles: partial.profiles ?? defaults.profiles,
cliproxy: {
...partial.cliproxy,
oauth_accounts: partial.cliproxy?.oauth_accounts ?? defaults.cliproxy.oauth_accounts,
providers: defaults.cliproxy.providers, // Always use defaults for providers
variants: partial.cliproxy?.variants ?? defaults.cliproxy.variants,
logging: {
enabled: partial.cliproxy?.logging?.enabled ?? defaults.cliproxy.logging?.enabled ?? false,
request_log:
partial.cliproxy?.logging?.request_log ?? defaults.cliproxy.logging?.request_log ?? false,
},
safety: {
antigravity_ack_bypass:
partial.cliproxy?.safety?.antigravity_ack_bypass ??
DEFAULT_CLIPROXY_SAFETY_CONFIG.antigravity_ack_bypass,
},
// Kiro browser behavior setting (optional)
kiro_no_incognito: partial.cliproxy?.kiro_no_incognito,
// Auth config - preserve user values, no defaults (uses constants as fallback)
auth: partial.cliproxy?.auth,
// Background token refresh config (optional)
token_refresh: partial.cliproxy?.token_refresh,
// Backend selection - validate and preserve user choice (original vs plus)
backend:
partial.cliproxy?.backend === 'original' || partial.cliproxy?.backend === 'plus'
? partial.cliproxy.backend
: undefined, // Invalid values become undefined (defaults to 'original' at runtime)
// Auto-sync - default to true
auto_sync: partial.cliproxy?.auto_sync ?? defaults.cliproxy.auto_sync ?? true,
routing: {
strategy:
partial.cliproxy?.routing?.strategy === 'fill-first' ||
partial.cliproxy?.routing?.strategy === 'round-robin'
? partial.cliproxy.routing.strategy
: defaults.cliproxy.routing?.strategy,
session_affinity:
typeof partial.cliproxy?.routing?.session_affinity === 'boolean'
? partial.cliproxy.routing.session_affinity
: defaults.cliproxy.routing?.session_affinity,
session_affinity_ttl: normalizeSessionAffinityTtl(
partial.cliproxy?.routing?.session_affinity_ttl,
defaults.cliproxy.routing?.session_affinity_ttl ?? '1h'
),
},
},
proxy: {
port: partial.proxy?.port ?? DEFAULT_OPENAI_COMPAT_PROXY_CONFIG.port,
profile_ports: partial.proxy?.profile_ports ?? {
...DEFAULT_OPENAI_COMPAT_PROXY_CONFIG.profile_ports,
},
routing: {
default: partial.proxy?.routing?.default ?? defaults.proxy?.routing?.default,
background: partial.proxy?.routing?.background ?? defaults.proxy?.routing?.background,
think: partial.proxy?.routing?.think ?? defaults.proxy?.routing?.think,
longContext: partial.proxy?.routing?.longContext ?? defaults.proxy?.routing?.longContext,
webSearch: partial.proxy?.routing?.webSearch ?? defaults.proxy?.routing?.webSearch,
longContextThreshold:
partial.proxy?.routing?.longContextThreshold ??
defaults.proxy?.routing?.longContextThreshold,
},
},
logging: {
enabled: partial.logging?.enabled ?? DEFAULT_LOGGING_CONFIG.enabled,
level: partial.logging?.level ?? DEFAULT_LOGGING_CONFIG.level,
rotate_mb: partial.logging?.rotate_mb ?? DEFAULT_LOGGING_CONFIG.rotate_mb,
retain_days: partial.logging?.retain_days ?? DEFAULT_LOGGING_CONFIG.retain_days,
redact: partial.logging?.redact ?? DEFAULT_LOGGING_CONFIG.redact,
live_buffer_size:
partial.logging?.live_buffer_size ?? DEFAULT_LOGGING_CONFIG.live_buffer_size,
},
preferences: {
...defaults.preferences,
...partial.preferences,
},
websearch: {
enabled: partial.websearch?.enabled ?? defaults.websearch?.enabled ?? true,
providers: {
exa: {
enabled: partial.websearch?.providers?.exa?.enabled ?? false,
max_results: partial.websearch?.providers?.exa?.max_results ?? 5,
},
tavily: {
enabled: partial.websearch?.providers?.tavily?.enabled ?? false,
max_results: partial.websearch?.providers?.tavily?.max_results ?? 5,
},
brave: {
enabled: partial.websearch?.providers?.brave?.enabled ?? false,
max_results: partial.websearch?.providers?.brave?.max_results ?? 5,
},
searxng: {
enabled: partial.websearch?.providers?.searxng?.enabled ?? false,
url: normalizeSearxngBaseUrl(partial.websearch?.providers?.searxng?.url) ?? '',
max_results: partial.websearch?.providers?.searxng?.max_results ?? 5,
},
duckduckgo: {
enabled: partial.websearch?.providers?.duckduckgo?.enabled ?? true,
max_results: partial.websearch?.providers?.duckduckgo?.max_results ?? 5,
},
gemini: {
enabled:
partial.websearch?.providers?.gemini?.enabled ??
partial.websearch?.gemini?.enabled ?? // Legacy fallback
false,
model: partial.websearch?.providers?.gemini?.model ?? 'gemini-2.5-flash',
timeout:
partial.websearch?.providers?.gemini?.timeout ??
partial.websearch?.gemini?.timeout ?? // Legacy fallback
55,
},
opencode: {
enabled: partial.websearch?.providers?.opencode?.enabled ?? false,
model: partial.websearch?.providers?.opencode?.model ?? 'opencode/grok-code',
timeout: partial.websearch?.providers?.opencode?.timeout ?? 90,
},
grok: {
enabled: partial.websearch?.providers?.grok?.enabled ?? false,
timeout: partial.websearch?.providers?.grok?.timeout ?? 55,
},
},
// Legacy fields (keep for backwards compatibility during read)
gemini: partial.websearch?.gemini,
},
// Copilot config - strictly opt-in, merge with defaults
copilot: {
enabled: partial.copilot?.enabled ?? DEFAULT_COPILOT_CONFIG.enabled,
auto_start: partial.copilot?.auto_start ?? DEFAULT_COPILOT_CONFIG.auto_start,
port: partial.copilot?.port ?? DEFAULT_COPILOT_CONFIG.port,
account_type: partial.copilot?.account_type ?? DEFAULT_COPILOT_CONFIG.account_type,
rate_limit: partial.copilot?.rate_limit ?? DEFAULT_COPILOT_CONFIG.rate_limit,
wait_on_limit: partial.copilot?.wait_on_limit ?? DEFAULT_COPILOT_CONFIG.wait_on_limit,
model: partial.copilot?.model ?? DEFAULT_COPILOT_CONFIG.model,
},
// Cursor config - disabled by default, merge with defaults
cursor: {
enabled: partial.cursor?.enabled ?? DEFAULT_CURSOR_CONFIG.enabled,
port: partial.cursor?.port ?? DEFAULT_CURSOR_CONFIG.port,
auto_start: partial.cursor?.auto_start ?? DEFAULT_CURSOR_CONFIG.auto_start,
ghost_mode: partial.cursor?.ghost_mode ?? DEFAULT_CURSOR_CONFIG.ghost_mode,
model: partial.cursor?.model ?? DEFAULT_CURSOR_CONFIG.model,
opus_model: partial.cursor?.opus_model,
sonnet_model: partial.cursor?.sonnet_model,
haiku_model: partial.cursor?.haiku_model,
},
// Global env - injected into all non-Claude subscription profiles
global_env: {
enabled: partial.global_env?.enabled ?? true,
env: partial.global_env?.env ?? { ...DEFAULT_GLOBAL_ENV },
},
continuity,
// CLIProxy server config - remote/local CLIProxyAPI settings
cliproxy_server: {
remote: {
enabled:
partial.cliproxy_server?.remote?.enabled ?? DEFAULT_CLIPROXY_SERVER_CONFIG.remote.enabled,
host: partial.cliproxy_server?.remote?.host ?? DEFAULT_CLIPROXY_SERVER_CONFIG.remote.host,
// Port is optional - undefined means use protocol default (443 for HTTPS, 8317 for HTTP)
port: partial.cliproxy_server?.remote?.port,
protocol:
partial.cliproxy_server?.remote?.protocol ??
DEFAULT_CLIPROXY_SERVER_CONFIG.remote.protocol,
auth_token:
partial.cliproxy_server?.remote?.auth_token ??
DEFAULT_CLIPROXY_SERVER_CONFIG.remote.auth_token,
// management_key is optional - falls back to auth_token when not set
management_key: partial.cliproxy_server?.remote?.management_key,
},
fallback: {
enabled:
partial.cliproxy_server?.fallback?.enabled ??
DEFAULT_CLIPROXY_SERVER_CONFIG.fallback.enabled,
auto_start:
partial.cliproxy_server?.fallback?.auto_start ??
DEFAULT_CLIPROXY_SERVER_CONFIG.fallback.auto_start,
},
local: {
port: partial.cliproxy_server?.local?.port ?? DEFAULT_CLIPROXY_SERVER_CONFIG.local.port,
auto_start:
partial.cliproxy_server?.local?.auto_start ??
DEFAULT_CLIPROXY_SERVER_CONFIG.local.auto_start,
},
},
// Quota management config - hybrid auto+manual account selection
quota_management: {
mode: partial.quota_management?.mode ?? DEFAULT_QUOTA_MANAGEMENT_CONFIG.mode,
auto: {
preflight_check:
partial.quota_management?.auto?.preflight_check ??
DEFAULT_QUOTA_MANAGEMENT_CONFIG.auto.preflight_check,
exhaustion_threshold:
partial.quota_management?.auto?.exhaustion_threshold ??
DEFAULT_QUOTA_MANAGEMENT_CONFIG.auto.exhaustion_threshold,
tier_priority:
partial.quota_management?.auto?.tier_priority ??
DEFAULT_QUOTA_MANAGEMENT_CONFIG.auto.tier_priority,
cooldown_minutes:
partial.quota_management?.auto?.cooldown_minutes ??
DEFAULT_QUOTA_MANAGEMENT_CONFIG.auto.cooldown_minutes,
},
manual: {
paused_accounts:
partial.quota_management?.manual?.paused_accounts ??
DEFAULT_QUOTA_MANAGEMENT_CONFIG.manual.paused_accounts,
forced_default:
partial.quota_management?.manual?.forced_default ??
DEFAULT_QUOTA_MANAGEMENT_CONFIG.manual.forced_default,
tier_lock:
partial.quota_management?.manual?.tier_lock ??
DEFAULT_QUOTA_MANAGEMENT_CONFIG.manual.tier_lock,
},
runtime_monitor: {
enabled:
partial.quota_management?.runtime_monitor?.enabled ??
DEFAULT_QUOTA_MANAGEMENT_CONFIG.runtime_monitor.enabled,
normal_interval_seconds:
partial.quota_management?.runtime_monitor?.normal_interval_seconds ??
DEFAULT_QUOTA_MANAGEMENT_CONFIG.runtime_monitor.normal_interval_seconds,
critical_interval_seconds:
partial.quota_management?.runtime_monitor?.critical_interval_seconds ??
DEFAULT_QUOTA_MANAGEMENT_CONFIG.runtime_monitor.critical_interval_seconds,
warn_threshold:
partial.quota_management?.runtime_monitor?.warn_threshold ??
DEFAULT_QUOTA_MANAGEMENT_CONFIG.runtime_monitor.warn_threshold,
exhaustion_threshold:
partial.quota_management?.runtime_monitor?.exhaustion_threshold ??
DEFAULT_QUOTA_MANAGEMENT_CONFIG.runtime_monitor.exhaustion_threshold,
cooldown_minutes:
partial.quota_management?.runtime_monitor?.cooldown_minutes ??
DEFAULT_QUOTA_MANAGEMENT_CONFIG.runtime_monitor.cooldown_minutes,
},
},
// Thinking config - auto/manual/off control for reasoning budget
thinking: {
mode: partial.thinking?.mode ?? DEFAULT_THINKING_CONFIG.mode,
override: partial.thinking?.override,
tier_defaults: {
opus: partial.thinking?.tier_defaults?.opus ?? DEFAULT_THINKING_CONFIG.tier_defaults.opus,
sonnet:
partial.thinking?.tier_defaults?.sonnet ?? DEFAULT_THINKING_CONFIG.tier_defaults.sonnet,
haiku:
partial.thinking?.tier_defaults?.haiku ?? DEFAULT_THINKING_CONFIG.tier_defaults.haiku,
},
provider_overrides: partial.thinking?.provider_overrides,
show_warnings: partial.thinking?.show_warnings ?? DEFAULT_THINKING_CONFIG.show_warnings,
},
channels: normalizeOfficialChannelsConfig(
partial as Partial<UnifiedConfig> & { discord_channels?: LegacyDiscordChannelsConfig }
),
// Dashboard auth config - disabled by default
dashboard_auth: {
enabled: partial.dashboard_auth?.enabled ?? DEFAULT_DASHBOARD_AUTH_CONFIG.enabled,
username: partial.dashboard_auth?.username ?? DEFAULT_DASHBOARD_AUTH_CONFIG.username,
password_hash:
partial.dashboard_auth?.password_hash ?? DEFAULT_DASHBOARD_AUTH_CONFIG.password_hash,
session_timeout_hours:
partial.dashboard_auth?.session_timeout_hours ??
DEFAULT_DASHBOARD_AUTH_CONFIG.session_timeout_hours,
},
browser: canonicalizeBrowserConfig(partial.browser),
// Image analysis config - enabled by default for CLIProxy providers
image_analysis: canonicalizeImageAnalysisConfig({
enabled: partial.image_analysis?.enabled ?? DEFAULT_IMAGE_ANALYSIS_CONFIG.enabled,
timeout: partial.image_analysis?.timeout ?? DEFAULT_IMAGE_ANALYSIS_CONFIG.timeout,
provider_models:
partial.image_analysis?.provider_models ?? DEFAULT_IMAGE_ANALYSIS_CONFIG.provider_models,
fallback_backend:
partial.image_analysis?.fallback_backend ?? DEFAULT_IMAGE_ANALYSIS_CONFIG.fallback_backend,
profile_backends:
partial.image_analysis?.profile_backends ?? DEFAULT_IMAGE_ANALYSIS_CONFIG.profile_backends,
}),
};
}
// ---------------------------------------------------------------------------
// Public API — load / save / mutate
// Core load / save / mutate
// ---------------------------------------------------------------------------
/**
@@ -414,7 +135,6 @@ function mergeWithDefaults(partial: Partial<UnifiedConfig>): UnifiedConfig {
export function loadUnifiedConfig(): UnifiedConfig | null {
const yamlPath = getConfigYamlPath();
// If file doesn't exist, return null
if (!fs.existsSync(yamlPath)) {
return null;
}
@@ -429,7 +149,6 @@ export function loadUnifiedConfig(): UnifiedConfig | null {
// Auto-upgrade if version is outdated (regenerates YAML with new comments and fields)
if ((parsed.version ?? 1) < UNIFIED_CONFIG_VERSION) {
// Merge with defaults to add new fields (e.g., model for websearch providers)
const upgraded = mergeWithDefaults(parsed);
upgraded.version = UNIFIED_CONFIG_VERSION;
try {
@@ -474,16 +193,11 @@ export function loadUnifiedConfig(): UnifiedConfig | null {
export function loadOrCreateUnifiedConfig(): UnifiedConfig {
const existing = loadUnifiedConfig();
if (existing) {
// Merge with defaults to fill any missing sections
const merged = mergeWithDefaults(existing);
// Validate composite variant provider strings
validateCompositeVariants(merged);
return merged;
}
// Create empty config
const config = createEmptyUnifiedConfig();
return config;
return createEmptyUnifiedConfig();
}
/**
@@ -527,301 +241,28 @@ export function updateUnifiedConfig(updates: Partial<UnifiedConfig>): UnifiedCon
}
// ---------------------------------------------------------------------------
// Public API — getters / derived helpers
// Derived helpers
// ---------------------------------------------------------------------------
/**
* Check if unified config mode is active.
* Returns true if config.yaml exists OR CCS_UNIFIED_CONFIG=1.
*
* Use this centralized function instead of duplicating the logic.
*/
export function isUnifiedMode(): boolean {
return hasUnifiedConfig() || isUnifiedConfigEnabled();
}
/**
* Get or set default profile name.
* Get default profile name from config.
*/
export function getDefaultProfile(): string | undefined {
const config = loadUnifiedConfig();
return config?.default;
}
/**
* Set default profile name in config.
*/
export function setDefaultProfile(name: string): void {
updateUnifiedConfig({ default: name });
}
/**
* Gemini CLI WebSearch configuration
*/
export interface GeminiWebSearchInfo {
enabled: boolean;
model: string;
timeout: number;
}
/**
* Get websearch configuration.
* Returns defaults if not configured.
* Supports deterministic providers and optional Gemini/OpenCode/Grok CLI fallbacks.
*/
export function getWebSearchConfig(): {
enabled: boolean;
providers?: {
exa?: { enabled?: boolean; max_results?: number };
tavily?: { enabled?: boolean; max_results?: number };
brave?: { enabled?: boolean; max_results?: number };
searxng?: { enabled?: boolean; url?: string; max_results?: number };
duckduckgo?: { enabled?: boolean; max_results?: number };
gemini?: GeminiWebSearchInfo;
opencode?: { enabled?: boolean; model?: string; timeout?: number };
grok?: { enabled?: boolean; timeout?: number };
};
// Legacy fields (deprecated)
gemini?: { enabled?: boolean; timeout?: number };
} {
const config = loadOrCreateUnifiedConfig();
// Build provider configs
const exaConfig = {
enabled: config.websearch?.providers?.exa?.enabled ?? false,
max_results: config.websearch?.providers?.exa?.max_results ?? 5,
};
const tavilyConfig = {
enabled: config.websearch?.providers?.tavily?.enabled ?? false,
max_results: config.websearch?.providers?.tavily?.max_results ?? 5,
};
const duckDuckGoConfig = {
enabled: config.websearch?.providers?.duckduckgo?.enabled ?? true,
max_results: config.websearch?.providers?.duckduckgo?.max_results ?? 5,
};
const braveConfig = {
enabled: config.websearch?.providers?.brave?.enabled ?? false,
max_results: config.websearch?.providers?.brave?.max_results ?? 5,
};
const searxngConfig = {
enabled: config.websearch?.providers?.searxng?.enabled ?? false,
url: normalizeSearxngBaseUrl(config.websearch?.providers?.searxng?.url) ?? '',
max_results: config.websearch?.providers?.searxng?.max_results ?? 5,
};
const geminiConfig: GeminiWebSearchInfo = {
enabled:
config.websearch?.providers?.gemini?.enabled ?? config.websearch?.gemini?.enabled ?? false,
model: config.websearch?.providers?.gemini?.model ?? 'gemini-2.5-flash',
timeout:
config.websearch?.providers?.gemini?.timeout ?? config.websearch?.gemini?.timeout ?? 55,
};
const opencodeConfig = {
enabled: config.websearch?.providers?.opencode?.enabled ?? false,
model: config.websearch?.providers?.opencode?.model ?? 'opencode/grok-code',
timeout: config.websearch?.providers?.opencode?.timeout ?? 90,
};
const grokConfig = {
enabled: config.websearch?.providers?.grok?.enabled ?? false,
timeout: config.websearch?.providers?.grok?.timeout ?? 55,
};
// Auto-enable master switch if ANY provider is enabled
const anyProviderEnabled =
exaConfig.enabled ||
tavilyConfig.enabled ||
braveConfig.enabled ||
searxngConfig.enabled ||
duckDuckGoConfig.enabled ||
geminiConfig.enabled ||
opencodeConfig.enabled ||
grokConfig.enabled;
const enabled = anyProviderEnabled && (config.websearch?.enabled ?? true);
return {
enabled,
providers: {
exa: exaConfig,
tavily: tavilyConfig,
brave: braveConfig,
searxng: searxngConfig,
duckduckgo: duckDuckGoConfig,
gemini: geminiConfig,
opencode: opencodeConfig,
grok: grokConfig,
},
// Legacy field for backwards compatibility
gemini: config.websearch?.gemini,
};
}
/**
* Get global_env configuration.
* Returns defaults if not configured.
*/
export function getGlobalEnvConfig(): GlobalEnvConfig {
const config = loadOrCreateUnifiedConfig();
return {
enabled: config.global_env?.enabled ?? true,
env: config.global_env?.env ?? { ...DEFAULT_GLOBAL_ENV },
};
}
/**
* Get continuity inheritance mapping.
* Returns empty mapping when not configured.
*/
export function getContinuityInheritanceMap(): Record<string, string> {
const config = loadOrCreateUnifiedConfig();
return config.continuity?.inherit_from_account ?? {};
}
/**
* Get cliproxy safety configuration.
* Returns defaults if not configured.
*/
export function getCliproxySafetyConfig(): CLIProxySafetyConfig {
const config = loadOrCreateUnifiedConfig();
return {
antigravity_ack_bypass:
config.cliproxy?.safety?.antigravity_ack_bypass ??
DEFAULT_CLIPROXY_SAFETY_CONFIG.antigravity_ack_bypass,
};
}
/**
* Get thinking configuration.
* Returns defaults if not configured.
*/
export function getThinkingConfig(): ThinkingConfig {
const config = loadOrCreateUnifiedConfig();
// W2: Check for invalid thinking config (e.g., thinking: true instead of object)
if (config.thinking !== undefined && typeof config.thinking !== 'object') {
console.warn(
`[!] Invalid thinking config: expected object, got ${typeof config.thinking}. Using defaults.`
);
console.warn(` Tip: Use 'thinking: { mode: auto }' instead of 'thinking: true'`);
return DEFAULT_THINKING_CONFIG;
}
return {
mode: config.thinking?.mode ?? DEFAULT_THINKING_CONFIG.mode,
override: config.thinking?.override,
tier_defaults: {
opus: config.thinking?.tier_defaults?.opus ?? DEFAULT_THINKING_CONFIG.tier_defaults.opus,
sonnet:
config.thinking?.tier_defaults?.sonnet ?? DEFAULT_THINKING_CONFIG.tier_defaults.sonnet,
haiku: config.thinking?.tier_defaults?.haiku ?? DEFAULT_THINKING_CONFIG.tier_defaults.haiku,
},
provider_overrides: config.thinking?.provider_overrides,
show_warnings: config.thinking?.show_warnings ?? DEFAULT_THINKING_CONFIG.show_warnings,
};
}
/**
* Get Official Channels configuration.
* Returns defaults if not configured.
*/
export function getOfficialChannelsConfig(): OfficialChannelsConfig {
const config = loadOrCreateUnifiedConfig();
return {
selected:
config.channels?.selected && config.channels.selected.length > 0
? normalizeOfficialChannelIds(config.channels.selected)
: DEFAULT_OFFICIAL_CHANNELS_CONFIG.selected,
unattended: config.channels?.unattended ?? DEFAULT_OFFICIAL_CHANNELS_CONFIG.unattended,
};
}
/**
* Get dashboard_auth configuration with ENV var override.
* Priority: ENV vars > config.yaml > defaults
*/
export function isDashboardAuthEnabled(): boolean {
const envEnabled = process.env.CCS_DASHBOARD_AUTH_ENABLED;
if (envEnabled !== undefined) {
return envEnabled === 'true' || envEnabled === '1';
}
const config = loadOrCreateUnifiedConfig();
return config.dashboard_auth?.enabled ?? false;
}
/**
* Get dashboard_auth configuration with ENV var override.
* Priority: ENV vars > config.yaml > defaults
*/
export function getDashboardAuthConfig(): DashboardAuthConfig {
const config = loadOrCreateUnifiedConfig();
// ENV vars take precedence
const envEnabled = process.env.CCS_DASHBOARD_AUTH_ENABLED;
const envUsername = process.env.CCS_DASHBOARD_USERNAME;
const envPasswordHash = process.env.CCS_DASHBOARD_PASSWORD_HASH;
return {
enabled:
envEnabled !== undefined
? envEnabled === 'true' || envEnabled === '1'
: (config.dashboard_auth?.enabled ?? false),
username: envUsername ?? config.dashboard_auth?.username ?? '',
password_hash: envPasswordHash ?? config.dashboard_auth?.password_hash ?? '',
session_timeout_hours: config.dashboard_auth?.session_timeout_hours ?? 24,
};
}
/**
* Get browser automation configuration.
* Returns canonicalized defaults if not configured.
*/
export function getBrowserConfig(): BrowserConfig {
const config = loadOrCreateUnifiedConfig();
return canonicalizeBrowserConfig(config.browser);
}
/**
* Get image_analysis configuration.
* Returns defaults if not configured.
*/
export function getImageAnalysisConfig(): ImageAnalysisConfig {
const config = loadOrCreateUnifiedConfig();
return canonicalizeImageAnalysisConfig({
enabled: config.image_analysis?.enabled ?? DEFAULT_IMAGE_ANALYSIS_CONFIG.enabled,
timeout: config.image_analysis?.timeout ?? DEFAULT_IMAGE_ANALYSIS_CONFIG.timeout,
provider_models:
config.image_analysis?.provider_models ?? DEFAULT_IMAGE_ANALYSIS_CONFIG.provider_models,
fallback_backend:
config.image_analysis?.fallback_backend ?? DEFAULT_IMAGE_ANALYSIS_CONFIG.fallback_backend,
profile_backends:
config.image_analysis?.profile_backends ?? DEFAULT_IMAGE_ANALYSIS_CONFIG.profile_backends,
});
}
export function getLoggingConfig(): LoggingConfig {
const config = loadOrCreateUnifiedConfig();
return {
enabled: config.logging?.enabled ?? DEFAULT_LOGGING_CONFIG.enabled,
level: config.logging?.level ?? DEFAULT_LOGGING_CONFIG.level,
rotate_mb: config.logging?.rotate_mb ?? DEFAULT_LOGGING_CONFIG.rotate_mb,
retain_days: config.logging?.retain_days ?? DEFAULT_LOGGING_CONFIG.retain_days,
redact: config.logging?.redact ?? DEFAULT_LOGGING_CONFIG.redact,
live_buffer_size: config.logging?.live_buffer_size ?? DEFAULT_LOGGING_CONFIG.live_buffer_size,
};
}
/**
* Get cursor configuration.
* Returns defaults if not configured.
*/
export function getCursorConfig(): CursorConfig {
const config = loadOrCreateUnifiedConfig();
return config.cursor ?? { ...DEFAULT_CURSOR_CONFIG };
}