refactor(config/loader): extract io-locks, normalizers, yaml-serializer

Phases 1-3 of #1164. Pure code-motion split of unified-config-loader.ts
(1508 LOC) into focused modules:

- src/config/loader/io-locks.ts (301 LOC):
  Path constants, lock primitives (acquireLock, releaseLock,
  processExists), hasUnifiedConfig, hasLegacyConfig, getConfigFormat,
  sleepSync, withConfigWriteLock, loadUnifiedConfigWithLockHeld,
  writeUnifiedConfigWithLockHeld.
- src/config/loader/normalizers.ts (228 LOC):
  Browser config normalizers, session affinity TTL,
  validateCompositeVariants, continuity normalizers,
  normalizeOfficialChannelsConfig, LegacyDiscordChannelsConfig.
- src/config/loader/yaml-serializer.ts (349 LOC):
  generateYamlHeader, generateYamlWithComments.

unified-config-loader.ts: 1508 -> 827 LOC (-681). Orchestrator
re-exports moved symbols for backwards compat.

Forward-reference workaround: loadUnifiedConfigWithLockHeld and
writeUnifiedConfigWithLockHeld take mergeWithDefaults / serializer
callbacks as parameters to avoid circular imports. Phases 4-5 can
replace with direct imports once those modules extract their
dependencies.

Behavior unchanged; full suite passes 1824/1824.

Refs #1164
This commit is contained in:
Tam Nhu Tran
2026-05-03 00:28:22 -04:00
parent 74898210f9
commit 315ae19387
4 changed files with 1010 additions and 813 deletions
+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