refactor(config/loader): split unified-config-loader.ts (#1168)

refactor(config/loader): split unified-config-loader.ts (#1164)
This commit is contained in:
Kai (Tam Nhu) Tran
2026-05-03 11:50:56 -04:00
committed by GitHub
6 changed files with 1643 additions and 1343 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,
}),
};
}
+301
View File
@@ -0,0 +1,301 @@
/**
* io-locks.ts
*
* Path constants, lockfile management, and config I/O helpers extracted from
* unified-config-loader.ts (Phase 1 split — issue #1164).
*
* Forward-reference note: loadUnifiedConfigWithLockHeld and
* writeUnifiedConfigWithLockHeld depend on mergeWithDefaults,
* validateCompositeVariants, generateYamlHeader, and generateYamlWithComments
* which still live in unified-config-loader.ts (extracted in later phases).
* Those are passed as callbacks to avoid a circular import. Once Phases 23
* land in separate modules with no dependency on this file, they can be
* imported directly.
*/
import * as fs from 'fs';
import * as path from 'path';
import * as crypto from 'crypto';
import * as yaml from 'js-yaml';
import { getCcsDir } from '../../utils/config-manager';
import {
isUnifiedConfig,
createEmptyUnifiedConfig,
UNIFIED_CONFIG_VERSION,
} from '../unified-config-types';
import type { UnifiedConfig } from '../unified-config-types';
// ---------------------------------------------------------------------------
// Path constants
// ---------------------------------------------------------------------------
export const CONFIG_YAML = 'config.yaml';
export const CONFIG_JSON = 'config.json';
export const CONFIG_LOCK = 'config.yaml.lock';
/** Lock is stale after this many milliseconds */
export const LOCK_STALE_MS = 5000;
export const GO_DURATION_SEGMENT = String.raw`(?:\d+(?:\.\d+)?(?:ns|us|µs|μs|ms|s|m|h))`;
export const GO_DURATION_PATTERN = new RegExp(`^${GO_DURATION_SEGMENT}+$`);
// ---------------------------------------------------------------------------
// Path helpers
// ---------------------------------------------------------------------------
/**
* Get path to unified config.yaml
*/
export function getConfigYamlPath(): string {
return path.join(getCcsDir(), CONFIG_YAML);
}
/**
* Get path to legacy config.json
*/
export function getConfigJsonPath(): string {
return path.join(getCcsDir(), CONFIG_JSON);
}
/**
* Get path to config lockfile (internal)
*/
function getLockFilePath(): string {
return path.join(getCcsDir(), CONFIG_LOCK);
}
// ---------------------------------------------------------------------------
// Process check
// ---------------------------------------------------------------------------
function processExists(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
}
// ---------------------------------------------------------------------------
// Lockfile primitives
// ---------------------------------------------------------------------------
/**
* Acquire lockfile for config write operations.
* Returns a lock token if acquired, null if already locked by another process.
* Cleans up stale locks (older than LOCK_STALE_MS).
*/
export function acquireLock(): string | null {
const lockPath = getLockFilePath();
const lockDir = path.dirname(lockPath);
const lockToken = crypto.randomUUID();
const lockData = `${process.pid}\n${Date.now()}\n${lockToken}`;
try {
if (!fs.existsSync(lockDir)) {
fs.mkdirSync(lockDir, { recursive: true, mode: 0o700 });
}
// Check if lock exists
if (fs.existsSync(lockPath)) {
const content = fs.readFileSync(lockPath, 'utf8');
const [pidStr, timestampStr] = content.trim().split('\n');
const pid = Number.parseInt(pidStr, 10);
const timestamp = Number.parseInt(timestampStr, 10);
const hasLiveOwner = Number.isInteger(pid) && pid > 0 && processExists(pid);
const isStale = !Number.isFinite(timestamp) || Date.now() - timestamp > LOCK_STALE_MS;
if (hasLiveOwner) {
return null;
}
if (isStale || !hasLiveOwner) {
fs.unlinkSync(lockPath);
}
}
// Acquire lock
fs.writeFileSync(lockPath, lockData, { flag: 'wx', mode: 0o600 });
return lockToken;
} catch (error) {
// EEXIST means another process acquired the lock between our check and write
if ((error as NodeJS.ErrnoException).code === 'EEXIST') {
return null;
}
return null;
}
}
/**
* Release lockfile after config write operation.
*/
export function releaseLock(lockToken: string): void {
const lockPath = getLockFilePath();
try {
if (fs.existsSync(lockPath)) {
const content = fs.readFileSync(lockPath, 'utf8');
const fileToken = content.trim().split('\n')[2];
if (fileToken === lockToken) {
fs.unlinkSync(lockPath);
}
}
} catch {
// Ignore cleanup errors
}
}
// ---------------------------------------------------------------------------
// Config format detection
// ---------------------------------------------------------------------------
/**
* Check if unified config.yaml exists
*/
export function hasUnifiedConfig(): boolean {
return fs.existsSync(getConfigYamlPath());
}
/**
* Check if legacy config.json exists
*/
export function hasLegacyConfig(): boolean {
return fs.existsSync(getConfigJsonPath());
}
// ---------------------------------------------------------------------------
// Sync sleep
// ---------------------------------------------------------------------------
/**
* Sync sleep helper for lock retry loops.
* Uses Atomics.wait when available to avoid CPU-intensive busy-wait.
*/
export function sleepSync(ms: number): void {
try {
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
} catch {
const end = Date.now() + ms;
while (Date.now() < end) {
/* busy-wait */
}
}
}
// ---------------------------------------------------------------------------
// Lock-aware execution wrapper
// ---------------------------------------------------------------------------
/**
* Execute a callback while holding the config lock.
*/
export function withConfigWriteLock<T>(callback: () => T): T {
// Acquire lock (retry for up to 1 second)
const maxRetries = 10;
const retryDelayMs = 100;
let lockToken: string | null = null;
for (let i = 0; i < maxRetries; i++) {
const acquiredToken = acquireLock();
if (acquiredToken) {
lockToken = acquiredToken;
break;
}
sleepSync(retryDelayMs);
}
if (!lockToken) {
throw new Error('Config file is locked by another process. Wait a moment and try again.');
}
try {
return callback();
} finally {
// Always release lock
releaseLock(lockToken);
}
}
// ---------------------------------------------------------------------------
// Lock-held read/write helpers
// ---------------------------------------------------------------------------
/**
* Load unified config directly from disk while lock is already held.
* Falls back to empty config when file doesn't exist.
*
* Forward-reference: mergeWithDefaults and validateCompositeVariants are
* passed as callbacks to avoid a circular dependency. They will be imported
* directly once all phases are complete.
*/
export function loadUnifiedConfigWithLockHeld(
mergeWithDefaults: (partial: Partial<UnifiedConfig>) => UnifiedConfig,
validateCompositeVariants: (config: UnifiedConfig) => void
): UnifiedConfig {
const yamlPath = getConfigYamlPath();
if (!fs.existsSync(yamlPath)) {
return createEmptyUnifiedConfig();
}
const content = fs.readFileSync(yamlPath, 'utf8');
const parsed = yaml.load(content);
if (!isUnifiedConfig(parsed)) {
throw new Error(`Invalid config format in ${yamlPath}`);
}
const merged = mergeWithDefaults(parsed);
validateCompositeVariants(merged);
return merged;
}
/**
* Write unified config to disk while lock is already held.
* Uses atomic write (temp file + rename) to prevent corruption.
*
* Forward-reference: generateYamlHeader and generateYamlWithComments are
* passed as callbacks to avoid a circular dependency. They will be imported
* directly once all phases are complete.
*/
export function writeUnifiedConfigWithLockHeld(
config: UnifiedConfig,
generateYamlHeader: () => string,
generateYamlWithComments: (config: UnifiedConfig) => string
): void {
const yamlPath = getConfigYamlPath();
const dir = path.dirname(yamlPath);
// Ensure directory exists
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
}
// Ensure version is set
config.version = UNIFIED_CONFIG_VERSION;
// Generate YAML with section comments
const yamlContent = generateYamlWithComments(config);
const content = generateYamlHeader() + yamlContent;
// Atomic write: write to temp file, then rename
const tempPath = `${yamlPath}.tmp.${process.pid}`;
try {
fs.writeFileSync(tempPath, content, { mode: 0o600 });
fs.renameSync(tempPath, yamlPath);
} catch (error) {
// Clean up temp file on error
if (fs.existsSync(tempPath)) {
try {
fs.unlinkSync(tempPath);
} catch {
// Ignore cleanup errors
}
}
// Classify filesystem errors
const err = error as NodeJS.ErrnoException;
if (err.code === 'ENOSPC') {
throw new Error('Disk full - cannot save config. Free up space and try again.');
} else if (err.code === 'EROFS' || err.code === 'EACCES') {
throw new Error(`Cannot write config - check file permissions: ${err.message}`);
}
throw error;
}
}
+228
View File
@@ -0,0 +1,228 @@
/**
* normalizers.ts
*
* Input normalization and validation helpers extracted from
* unified-config-loader.ts (Phase 2 split — issue #1164).
*
* Contains: browser config normalizers, session affinity TTL normalizer,
* composite variant validator, continuity config normalizer, and official
* channels config normalizer.
*/
import { DEFAULT_BROWSER_CONFIG, DEFAULT_OFFICIAL_CHANNELS_CONFIG } from '../unified-config-types';
import type {
UnifiedConfig,
BrowserConfig,
BrowserEvalMode,
BrowserToolPolicy,
OfficialChannelsConfig,
OfficialChannelId,
ContinuityConfig,
} from '../unified-config-types';
import { validateCompositeTiers } from '../../cliproxy/config/composite-validator';
import {
isOfficialChannelId,
normalizeOfficialChannelIds,
resolveLegacyDiscordSelection,
} from '../../channels/official-channels-runtime';
import { getRecommendedBrowserUserDataDir } from '../../utils/browser/browser-settings';
import { GO_DURATION_PATTERN, GO_DURATION_SEGMENT } from './io-locks';
// ---------------------------------------------------------------------------
// Browser normalizers
// ---------------------------------------------------------------------------
export function normalizeBrowserDevtoolsPort(value: number | undefined): number {
if (!Number.isFinite(value)) {
return DEFAULT_BROWSER_CONFIG.claude.devtools_port;
}
const port = Math.floor(value as number);
if (port < 1 || port > 65535) {
return DEFAULT_BROWSER_CONFIG.claude.devtools_port;
}
return port;
}
export function normalizeBrowserPolicy(value: string | undefined): BrowserToolPolicy {
return value === 'auto' || value === 'manual' ? value : DEFAULT_BROWSER_CONFIG.claude.policy;
}
export function normalizeBrowserEvalMode(value: string | undefined): BrowserEvalMode {
if (value === 'disabled' || value === 'readonly' || value === 'readwrite') {
return value;
}
return DEFAULT_BROWSER_CONFIG.claude.eval_mode ?? 'readonly';
}
export function canonicalizeBrowserConfig(
config?: BrowserConfig,
fallback: BrowserConfig = DEFAULT_BROWSER_CONFIG
): BrowserConfig {
const claudeUserDataDir =
config?.claude?.user_data_dir === undefined
? fallback.claude.user_data_dir || getRecommendedBrowserUserDataDir()
: config.claude.user_data_dir.trim() || getRecommendedBrowserUserDataDir();
return {
claude: {
enabled: config?.claude?.enabled ?? fallback.claude.enabled,
policy: normalizeBrowserPolicy(config?.claude?.policy ?? fallback.claude.policy),
user_data_dir: claudeUserDataDir,
devtools_port: normalizeBrowserDevtoolsPort(
config?.claude?.devtools_port ?? fallback.claude.devtools_port
),
eval_mode: normalizeBrowserEvalMode(config?.claude?.eval_mode ?? fallback.claude.eval_mode),
},
codex: {
enabled: config?.codex?.enabled ?? fallback.codex.enabled,
policy: normalizeBrowserPolicy(config?.codex?.policy ?? fallback.codex.policy),
eval_mode: normalizeBrowserEvalMode(config?.codex?.eval_mode ?? fallback.codex.eval_mode),
},
};
}
// ---------------------------------------------------------------------------
// Session affinity TTL normalizer
// ---------------------------------------------------------------------------
export function normalizeSessionAffinityTtl(value: unknown, fallback: string): string {
if (typeof value !== 'string') {
return fallback;
}
const trimmed = value.trim();
if (!trimmed || !GO_DURATION_PATTERN.test(trimmed) || !hasPositiveDuration(trimmed)) {
return fallback;
}
return trimmed;
}
export function hasPositiveDuration(value: string): boolean {
const segments = value.match(new RegExp(GO_DURATION_SEGMENT, 'g'));
if (!segments) {
return false;
}
return segments.some((segment) => {
const numeric = parseFloat(segment);
return Number.isFinite(numeric) && numeric > 0;
});
}
// ---------------------------------------------------------------------------
// Composite variant validator
// ---------------------------------------------------------------------------
/**
* Validate composite variant provider strings.
* Warns about invalid providers in composite variant configurations.
*/
export function validateCompositeVariants(config: UnifiedConfig): void {
const variants = config.cliproxy?.variants;
if (!variants) return;
for (const [name, variant] of Object.entries(variants)) {
if ('type' in variant && variant.type === 'composite') {
const error = validateCompositeTiers(variant.tiers, {
defaultTier: variant.default_tier,
requireAllTiers: true,
});
if (error) {
console.warn(`[!] Variant '${name}': invalid composite config (${error})`);
}
}
}
}
// ---------------------------------------------------------------------------
// Continuity config normalizer
// ---------------------------------------------------------------------------
/**
* Normalize continuity inheritance mapping payload.
* Keeps only non-empty string keys and values.
*/
export function normalizeContinuityInheritanceMap(
value: unknown
): Record<string, string> | undefined {
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
return undefined;
}
const normalized: Record<string, string> = {};
for (const [profileName, accountName] of Object.entries(value as Record<string, unknown>)) {
const normalizedProfile = profileName.trim();
const normalizedAccount = typeof accountName === 'string' ? accountName.trim() : '';
if (!normalizedProfile || !normalizedAccount) {
continue;
}
normalized[normalizedProfile] = normalizedAccount;
}
return Object.keys(normalized).length > 0 ? normalized : undefined;
}
/**
* Normalize continuity section.
* Supports legacy root key: continuity_inherit_from_account.
*/
export function normalizeContinuityConfig(
partial: Partial<UnifiedConfig>
): ContinuityConfig | undefined {
const legacyMap = normalizeContinuityInheritanceMap(
(partial as Partial<UnifiedConfig> & { continuity_inherit_from_account?: unknown })
.continuity_inherit_from_account
);
const continuityMap = normalizeContinuityInheritanceMap(partial.continuity?.inherit_from_account);
if (!legacyMap && !continuityMap) {
return undefined;
}
return {
inherit_from_account: {
...(legacyMap ?? {}),
...(continuityMap ?? {}),
},
};
}
// ---------------------------------------------------------------------------
// Official channels config normalizer
// ---------------------------------------------------------------------------
export interface LegacyDiscordChannelsConfig {
enabled?: boolean;
unattended?: boolean;
}
export function normalizeOfficialChannelsConfig(
partial: Partial<UnifiedConfig> & { discord_channels?: LegacyDiscordChannelsConfig }
): OfficialChannelsConfig {
const hasCanonicalChannelsSection = partial.channels !== undefined;
const hasExplicitSelectedField =
hasCanonicalChannelsSection &&
Object.prototype.hasOwnProperty.call(partial.channels, 'selected');
const rawSelected =
hasExplicitSelectedField && Array.isArray(partial.channels?.selected)
? partial.channels.selected.filter((value): value is OfficialChannelId =>
isOfficialChannelId(value)
)
: [];
return {
selected: hasCanonicalChannelsSection
? normalizeOfficialChannelIds(rawSelected)
: resolveLegacyDiscordSelection(partial.discord_channels?.enabled),
unattended:
partial.channels?.unattended ??
partial.discord_channels?.unattended ??
DEFAULT_OFFICIAL_CHANNELS_CONFIG.unattended,
};
}
+349
View File
@@ -0,0 +1,349 @@
/**
* yaml-serializer.ts
*
* YAML generation helpers extracted from unified-config-loader.ts
* (Phase 3 split — issue #1164).
*
* Contains: generateYamlHeader, generateYamlWithComments.
* These produce the commented YAML written to config.yaml on every save.
*/
import * as yaml from 'js-yaml';
import type { UnifiedConfig } from '../unified-config-types';
/**
* Generate YAML header with helpful comments.
*/
export function generateYamlHeader(): string {
return `# CCS Unified Configuration
# Docs: https://github.com/kaitranntt/ccs
`;
}
/**
* Generate YAML content with section comments for better readability.
*/
export function generateYamlWithComments(config: UnifiedConfig): string {
const lines: string[] = [];
// Version
lines.push(`version: ${config.version}`);
if (config.setup_completed !== undefined) {
lines.push(`setup_completed: ${config.setup_completed}`);
}
lines.push('');
// Default
if (config.default) {
lines.push(`# Default profile used when running 'ccs' without arguments`);
lines.push(`default: "${config.default}"`);
lines.push('');
}
// Accounts section
lines.push('# ----------------------------------------------------------------------------');
lines.push('# Accounts: Isolated Claude instances (each with separate auth/sessions)');
lines.push('# Manage with: ccs auth add <name>, ccs auth list, ccs auth remove <name>');
lines.push('# ----------------------------------------------------------------------------');
lines.push(
yaml.dump({ accounts: config.accounts }, { indent: 2, lineWidth: -1, quotingType: '"' }).trim()
);
lines.push('');
// Profiles section
lines.push('# ----------------------------------------------------------------------------');
lines.push('# Profiles: API-based providers (GLM, Kimi, custom endpoints)');
lines.push('# Each profile points to a *.settings.json file containing env vars.');
lines.push('# Edit the settings file directly to customize (ANTHROPIC_MAX_TOKENS, etc.)');
lines.push('# ----------------------------------------------------------------------------');
lines.push(
yaml.dump({ profiles: config.profiles }, { indent: 2, lineWidth: -1, quotingType: '"' }).trim()
);
lines.push('');
// CLIProxy section
lines.push('# ----------------------------------------------------------------------------');
lines.push('# CLIProxy: OAuth-based providers (gemini, codex, agy, qwen, iflow)');
lines.push('# Each variant can reference a *.settings.json file for custom env vars.');
lines.push('# Edit the settings file directly to customize model or other settings.');
lines.push('# ----------------------------------------------------------------------------');
lines.push(
yaml.dump({ cliproxy: config.cliproxy }, { indent: 2, lineWidth: -1, quotingType: '"' }).trim()
);
lines.push('');
if (config.proxy?.routing) {
lines.push('# ----------------------------------------------------------------------------');
lines.push('# Proxy Routing: OpenAI-compatible local proxy model selection rules');
lines.push('# Use profile:model selectors to force a target profile and upstream model.');
lines.push('# ----------------------------------------------------------------------------');
lines.push(
yaml.dump({ proxy: config.proxy }, { indent: 2, lineWidth: -1, quotingType: '"' }).trim()
);
lines.push('');
}
if (config.logging) {
lines.push('# ----------------------------------------------------------------------------');
lines.push('# Logging: CCS-owned structured runtime logs');
lines.push('# Current file: ~/.ccs/logs/current.jsonl');
lines.push('# Archives rotate automatically and are pruned by retain_days.');
lines.push('# This is separate from cliproxy.logging, which controls CLIProxy runtime files.');
lines.push('# ----------------------------------------------------------------------------');
lines.push(
yaml.dump({ logging: config.logging }, { indent: 2, lineWidth: -1, quotingType: '"' }).trim()
);
lines.push('');
}
// CLIProxy Server section (remote proxy configuration) - placed right after cliproxy
if (config.cliproxy_server) {
lines.push('# ----------------------------------------------------------------------------');
lines.push('# CLIProxy Server: Remote proxy connection settings');
lines.push('# Configure via Dashboard (`ccs config`) > Proxy tab.');
lines.push('#');
lines.push('# remote: Connect to a remote CLIProxyAPI instance');
lines.push('# fallback: Use local proxy if remote is unreachable');
lines.push('# local: Local proxy settings (port, auto-start)');
lines.push('# ----------------------------------------------------------------------------');
lines.push(
yaml
.dump(
{ cliproxy_server: config.cliproxy_server },
{ indent: 2, lineWidth: -1, quotingType: '"' }
)
.trim()
);
lines.push('');
}
// Preferences section
lines.push('# ----------------------------------------------------------------------------');
lines.push('# Preferences: User settings');
lines.push('# ----------------------------------------------------------------------------');
lines.push(
yaml
.dump({ preferences: config.preferences }, { indent: 2, lineWidth: -1, quotingType: '"' })
.trim()
);
lines.push('');
// WebSearch section
if (config.websearch) {
lines.push('# ----------------------------------------------------------------------------');
lines.push('# WebSearch: real search backends for third-party profiles');
lines.push('# Dashboard (`ccs config`) is the source of truth for provider selection.');
lines.push('#');
lines.push('# Third-party providers (gemini, codex, agy, etc.) do not have access to');
lines.push("# Anthropic's WebSearch tool. CCS intercepts that tool and runs local search.");
lines.push('#');
lines.push(
'# Priority: Exa -> Tavily -> Brave -> DuckDuckGo -> optional legacy AI CLI fallbacks'
);
lines.push('#');
lines.push('# Exa requires EXA_API_KEY in your environment.');
lines.push('# Tavily requires TAVILY_API_KEY in your environment.');
lines.push('# Brave requires BRAVE_API_KEY in your environment.');
lines.push('# DuckDuckGo works with zero extra setup and is enabled by default.');
lines.push('#');
lines.push('# Legacy LLM fallbacks remain optional if you still want them:');
lines.push('# gemini: npm i -g @google/gemini-cli');
lines.push('# opencode: curl -fsSL https://opencode.ai/install | bash');
lines.push('# grok: npm i -g @vibe-kit/grok-cli');
lines.push('# ----------------------------------------------------------------------------');
lines.push(
yaml
.dump({ websearch: config.websearch }, { indent: 2, lineWidth: -1, quotingType: '"' })
.trim()
);
lines.push('');
}
// Copilot section (GitHub Copilot proxy)
if (config.copilot) {
lines.push('# ----------------------------------------------------------------------------');
lines.push('# Copilot: GitHub Copilot API proxy (via copilot-api)');
lines.push('# Uses your existing GitHub Copilot subscription with Claude Code.');
lines.push('#');
lines.push('# !! DISCLAIMER - USE AT YOUR OWN RISK !!');
lines.push('# This uses an UNOFFICIAL reverse-engineered API.');
lines.push('# Excessive usage may trigger GitHub account restrictions.');
lines.push('# CCS provides NO WARRANTY and accepts NO RESPONSIBILITY for consequences.');
lines.push('#');
lines.push('# Setup: npx copilot-api auth (authenticate with GitHub)');
lines.push('# Usage: ccs copilot (switch to copilot profile)');
lines.push('#');
lines.push('# Models: claude-sonnet-4.5, claude-opus-4.5, gpt-5.1, gemini-2.5-pro');
lines.push('# Account types: individual, business, enterprise');
lines.push('# ----------------------------------------------------------------------------');
lines.push(
yaml.dump({ copilot: config.copilot }, { indent: 2, lineWidth: -1, quotingType: '"' }).trim()
);
lines.push('');
}
// Cursor section (Cursor IDE proxy daemon)
if (config.cursor) {
lines.push('# ----------------------------------------------------------------------------');
lines.push('# Cursor: Cursor IDE proxy daemon');
lines.push('# Enables Cursor IDE integration via local proxy daemon.');
lines.push('#');
lines.push('# enabled: Enable/disable Cursor integration (default: false)');
lines.push('# port: Port for cursor proxy daemon (default: 20129)');
lines.push('# auto_start: Auto-start daemon when CCS starts (default: false)');
lines.push('# ghost_mode: Disable telemetry for privacy (default: true)');
lines.push('# model: Default model ID (used for ANTHROPIC_MODEL)');
lines.push('# opus_model/sonnet_model/haiku_model: Optional tier model mapping');
lines.push('# ----------------------------------------------------------------------------');
lines.push(
yaml.dump({ cursor: config.cursor }, { indent: 2, lineWidth: -1, quotingType: '"' }).trim()
);
lines.push('');
}
// Global env section
if (config.global_env) {
lines.push('# ----------------------------------------------------------------------------');
lines.push(
'# Global Environment Variables: Injected into all non-Claude subscription profiles'
);
lines.push('# These env vars disable telemetry/reporting for third-party providers.');
lines.push('# Configure via Dashboard (`ccs config`) > Global Env tab.');
lines.push('#');
lines.push('# Default variables:');
lines.push('# DISABLE_BUG_COMMAND: Disables /bug command (not supported by proxy)');
lines.push('# DISABLE_ERROR_REPORTING: Disables error reporting to Anthropic');
lines.push('# DISABLE_TELEMETRY: Disables usage telemetry');
lines.push('# ----------------------------------------------------------------------------');
lines.push(
yaml
.dump({ global_env: config.global_env }, { indent: 2, lineWidth: -1, quotingType: '"' })
.trim()
);
lines.push('');
}
// Continuity inheritance section
if (config.continuity?.inherit_from_account) {
lines.push('# ----------------------------------------------------------------------------');
lines.push('# Continuity Inheritance: Reuse account continuity artifacts across profiles');
lines.push('# Map execution profile names to source account profiles (CLAUDE_CONFIG_DIR).');
lines.push('# Applies to Claude target only; credentials remain profile-specific.');
lines.push('# Example: continuity.inherit_from_account.glm: pro');
lines.push('# ----------------------------------------------------------------------------');
lines.push(
yaml
.dump({ continuity: config.continuity }, { indent: 2, lineWidth: -1, quotingType: '"' })
.trim()
);
lines.push('');
}
// Thinking section (extended thinking/reasoning configuration)
if (config.thinking) {
lines.push('# ----------------------------------------------------------------------------');
lines.push('# Thinking: Extended thinking/reasoning budget configuration');
lines.push('# Controls reasoning depth for supported providers (agy, gemini, codex).');
lines.push('#');
lines.push(
'# Modes: auto (use tier_defaults), off (disable), manual (--thinking/--effort flags)'
);
lines.push(
'# Levels: minimal (512), low (1K), medium (8K), high (24K), xhigh (32K), max (adaptive ceiling), auto'
);
lines.push('# Override: Set global override value (number or level name)');
lines.push('# Provider overrides: Per-provider tier defaults');
lines.push('# ----------------------------------------------------------------------------');
lines.push(
yaml
.dump({ thinking: config.thinking }, { indent: 2, lineWidth: -1, quotingType: '"' })
.trim()
);
lines.push('');
}
// Official Channels section
if (config.channels) {
lines.push('# ----------------------------------------------------------------------------');
lines.push('# Official Channels: Runtime auto-enable for Anthropic official channel plugins');
lines.push('# Supported channels: telegram, discord, imessage');
lines.push('# Runtime-only: CCS injects --channels at launch for compatible Claude sessions.');
lines.push('# Bot tokens live in Claude channel env files, not in config.yaml.');
lines.push('# Use selected: [telegram, discord, imessage] to choose channels.');
lines.push(
'# unattended adds --dangerously-skip-permissions only when channel auto-enable is active.'
);
lines.push('# Compatible sessions: native Claude default/account profiles only.');
lines.push('# Configure via: ccs config channels or the Settings > Channels dashboard tab.');
lines.push('# ----------------------------------------------------------------------------');
lines.push(
yaml
.dump({ channels: config.channels }, { indent: 2, lineWidth: -1, quotingType: '"' })
.trim()
);
lines.push('');
}
// Dashboard auth section (only if configured)
if (config.dashboard_auth?.enabled) {
lines.push('# ----------------------------------------------------------------------------');
lines.push('# Dashboard Auth: Optional login protection for CCS dashboard');
lines.push('# Generate password hash: npx bcrypt-cli hash "your-password"');
lines.push(
'# ENV override: CCS_DASHBOARD_AUTH_ENABLED, CCS_DASHBOARD_USERNAME, CCS_DASHBOARD_PASSWORD_HASH'
);
lines.push('# ----------------------------------------------------------------------------');
lines.push(
yaml
.dump(
{ dashboard_auth: config.dashboard_auth },
{ indent: 2, lineWidth: -1, quotingType: '"' }
)
.trim()
);
lines.push('');
}
// Browser automation section
if (config.browser) {
lines.push('# ----------------------------------------------------------------------------');
lines.push('# Browser Automation: Claude browser attach and Codex browser tooling');
lines.push('# Claude attach reuses a running Chrome/Chromium session with remote debugging.');
lines.push('# Codex tooling controls whether CCS injects Playwright MCP overrides.');
lines.push('#');
lines.push('# claude.user_data_dir should point at the Chrome user-data directory for the');
lines.push('# dedicated attach session. claude.devtools_port is the expected debugging port.');
lines.push('# Configure via: Settings > Browser or `ccs browser ...`.');
lines.push('# ----------------------------------------------------------------------------');
lines.push(
yaml.dump({ browser: config.browser }, { indent: 2, lineWidth: -1, quotingType: '"' }).trim()
);
lines.push('');
}
// Image analysis section
if (config.image_analysis) {
lines.push('# ----------------------------------------------------------------------------');
lines.push('# Image Analysis: Vision-based analysis for images and PDFs');
lines.push('# Routes Read tool requests for images/PDFs through CLIProxy vision API.');
lines.push('#');
lines.push('# When enabled: Image files trigger vision analysis instead of raw file read');
lines.push('# Provider models: Vision model used for each CLIProxy provider');
lines.push('# Timeout: Maximum seconds to wait for analysis (10-600)');
lines.push('#');
lines.push('# Supported formats: .jpg, .jpeg, .png, .gif, .webp, .heic, .bmp, .tiff, .pdf');
lines.push('# Configure via: ccs config image-analysis');
lines.push('# ----------------------------------------------------------------------------');
lines.push(
yaml
.dump(
{ image_analysis: config.image_analysis },
{ indent: 2, lineWidth: -1, quotingType: '"' }
)
.trim()
);
lines.push('');
}
return lines.join('\n');
}
File diff suppressed because it is too large Load Diff