import { AsyncLocalStorage } from 'async_hooks'; import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; import { isConfig, isSettings } from '../types'; import type { Config, Settings, CLIProxyVariantsConfig, CLIProxyVariantConfig } from '../types'; import { expandPath, error } from './helpers'; import { info } from './ui'; import { isUnifiedMode, loadOrCreateUnifiedConfig } from '../config/unified-config-loader'; // TODO: Replace with proper imports after converting these files // const { ErrorManager } = require('./error-manager'); // const RecoveryManager = require('./recovery-manager'); // Module-level state for --config-dir CLI flag override let _globalConfigDir: string | undefined; const configScopeStorage = new AsyncLocalStorage<{ ccsHome?: string; ccsDir?: string }>(); function normalizeScopedPath(dir: string | undefined): string | undefined { return dir ? path.resolve(dir) : undefined; } export async function runWithScopedConfig( scope: { ccsHome?: string; ccsDir?: string }, fn: () => Promise | T ): Promise { return await configScopeStorage.run( { ccsHome: normalizeScopedPath(scope.ccsHome), ccsDir: normalizeScopedPath(scope.ccsDir), }, fn ); } export async function runWithScopedCcsHome( ccsHome: string, fn: () => Promise | T ): Promise { return await runWithScopedConfig({ ccsHome }, fn); } export async function runWithScopedConfigDir( ccsDir: string, fn: () => Promise | T ): Promise { return await runWithScopedConfig({ ccsDir }, fn); } /** * Set global config directory from --config-dir CLI flag. * Must be called before any config loading. * Pass undefined to clear (useful for tests). */ export function setGlobalConfigDir(dir: string | undefined): void { _globalConfigDir = dir ? path.resolve(dir) : undefined; } /** * Get the CCS home directory (respects CCS_HOME env var for test isolation) * @returns Home directory path */ export function getCcsHome(): string { const scopedHome = configScopeStorage.getStore()?.ccsHome; if (scopedHome) { return scopedHome; } return process.env.CCS_HOME || os.homedir(); } /** * Resolve the CCS directory with source information. * Single source of truth for precedence logic. */ function _resolveCcsDir(): { source: string; dir: string } { const scopedConfig = configScopeStorage.getStore(); if (scopedConfig?.ccsDir) return { source: 'scoped:CCS_DIR', dir: scopedConfig.ccsDir }; if (scopedConfig?.ccsHome) return { source: 'scoped:CCS_HOME', dir: path.join(scopedConfig.ccsHome, '.ccs') }; if (_globalConfigDir) return { source: '--config-dir', dir: _globalConfigDir }; if (process.env.CCS_DIR) return { source: 'CCS_DIR', dir: path.resolve(process.env.CCS_DIR) }; if (process.env.CCS_HOME) return { source: 'CCS_HOME', dir: path.join(path.resolve(process.env.CCS_HOME), '.ccs') }; return { source: 'default', dir: path.join(os.homedir(), '.ccs') }; } /** * Get the CCS directory path. * Precedence: --config-dir flag > CCS_DIR env > CCS_HOME env (legacy, appends .ccs) > ~/.ccs default * @returns Path to CCS config directory */ export function getCcsDir(): string { return _resolveCcsDir().dir; } /** * Get which source determined the CCS directory (for diagnostics). * @returns [source_label, resolved_path] tuple */ export function getCcsDirSource(): [string, string] { const r = _resolveCcsDir(); return [r.source, r.dir]; } /** * Get the CCS directory as a user-facing display path. * Keeps the default path concise while preserving explicit overrides. */ export function getCcsDirDisplay(): string { return getCcsPathDisplay(); } /** * Get a CCS path as a user-facing display path. * Keeps the default location concise while preserving explicit overrides. */ export function getCcsPathDisplay(...segments: string[]): string { const [source, dir] = getCcsDirSource(); if (source === 'default') { return process.platform === 'win32' ? path.win32.join('%USERPROFILE%\\.ccs', ...segments) : path.posix.join('~/.ccs', ...segments.map((segment) => segment.replace(/\\/g, '/'))); } return path.join(dir, ...segments); } /** * Cloud sync folder patterns for security warning. */ const CLOUD_SYNC_PATTERNS = [ 'Dropbox', 'OneDrive', 'Google Drive', 'iCloud Drive', 'iCloudDrive', 'pCloud', 'MEGA', 'Box Sync', ]; /** * Check if a path is under a cloud-synced folder. * @returns Detected service name or null */ export function detectCloudSyncPath(dir: string): string | null { const normalized = dir.replace(/\\/g, '/').toLowerCase(); const segments = normalized.split('/'); for (const pattern of CLOUD_SYNC_PATTERNS) { const patternLower = pattern.toLowerCase(); // Exact path segment match to avoid false positives (e.g., "megauser" != "MEGA") if (segments.some((s) => s === patternLower)) return pattern; } return null; } /** * Get CCS hooks directory (respects CCS_HOME for test isolation) * @returns Path to hooks directory */ export function getCcsHooksDir(): string { return path.join(getCcsDir(), 'hooks'); } /** * Get config file path (legacy JSON path) * @deprecated Use getActiveConfigPath() for mode-aware config path */ export function getConfigPath(): string { return process.env.CCS_CONFIG || path.join(getCcsDir(), 'config.json'); } /** * Get the active config file path based on current mode. * Returns config.yaml in unified mode, config.json in legacy mode. * @returns Path to the active config file */ export function getActiveConfigPath(): string { const ccsDir = getCcsDir(); if (isUnifiedMode()) { return path.join(ccsDir, 'config.yaml'); } return path.join(ccsDir, 'config.json'); } /** * Load and validate config.json */ export function loadConfig(): Config { const configPath = getConfigPath(); if (!fs.existsSync(configPath)) { // TODO: Add recovery manager logic // const recovery = new RecoveryManager(); // recovery.ensureConfigJson(); error(`Config not found: ${configPath}`); } const raw = fs.readFileSync(configPath, 'utf8'); const parsed: unknown = JSON.parse(raw); if (!isConfig(parsed)) { error(`Invalid config format: ${configPath}`); } return parsed; } /** * Load and validate settings.json */ export function loadSettings(settingsPath: string): Settings { if (!fs.existsSync(settingsPath)) { error(`Settings not found: ${settingsPath}`); } const raw = fs.readFileSync(settingsPath, 'utf8'); const parsed: unknown = JSON.parse(raw); if (!isSettings(parsed)) { error(`Invalid settings format: ${settingsPath}`); } return parsed; } /** * Read and parse config (legacy compatibility) */ export function readConfig(): Config { return loadConfig(); } /** * Load config safely with unified mode support. * Returns Config with profiles from unified config.yaml or legacy config.json. * Throws on error (catchable) instead of process.exit. * Use this in web server routes where try/catch is needed. */ export function loadConfigSafe(): Config { // Unified mode: extract profiles from config.yaml if (isUnifiedMode()) { const unifiedConfig = loadOrCreateUnifiedConfig(); // Convert unified profiles to legacy format for compatibility const profiles: Record = {}; for (const [name, profile] of Object.entries(unifiedConfig.profiles)) { if (profile.settings) { profiles[name] = profile.settings; } } // Convert unified cliproxy variants to legacy format let cliproxy: CLIProxyVariantsConfig | undefined; if (unifiedConfig.cliproxy?.variants) { cliproxy = {}; for (const [name, variant] of Object.entries(unifiedConfig.cliproxy.variants)) { if ('type' in variant && variant.type === 'composite') { // Composite variants: use default tier's provider cliproxy[name] = { provider: variant.tiers[variant.default_tier].provider, settings: variant.settings, port: variant.port, }; } else { const single = variant as CLIProxyVariantConfig; cliproxy[name] = { provider: single.provider, settings: single.settings, account: single.account, port: single.port, }; } } } return { profiles, cliproxy, }; } // Legacy mode: read config.json const configPath = getConfigPath(); if (!fs.existsSync(configPath)) { // Return empty config for graceful degradation (matches unified mode behavior) return { profiles: {} }; } const raw = fs.readFileSync(configPath, 'utf8'); let parsed: unknown; try { parsed = JSON.parse(raw); } catch (e) { throw new Error(`Malformed JSON in config: ${configPath} - ${(e as Error).message}`); } if (!isConfig(parsed)) { throw new Error(`Invalid config format: ${configPath}`); } return parsed; } /** * Get settings path for profile. * In unified mode (config.yaml exists), reads from config.yaml first, * then falls back to config.json for backward compatibility. */ export function getSettingsPath(profile: string): string { let settingsPath: string | undefined; let availableProfiles: string[] = []; // Check unified config first (config.yaml) if (isUnifiedMode()) { const unifiedConfig = loadOrCreateUnifiedConfig(); // Check if profile exists in unified config const profileConfig = unifiedConfig.profiles[profile]; if (profileConfig?.settings) { settingsPath = profileConfig.settings; } // Collect available profiles from unified config availableProfiles = Object.keys(unifiedConfig.profiles); // If not found in unified config, try legacy config.json as fallback if (!settingsPath) { // Use inline safe logic - loadConfig() calls process.exit() which cannot be caught const configPath = getConfigPath(); if (fs.existsSync(configPath)) { try { const raw = fs.readFileSync(configPath, 'utf8'); const legacyConfig: unknown = JSON.parse(raw); if (isConfig(legacyConfig) && legacyConfig.profiles[profile]) { settingsPath = legacyConfig.profiles[profile]; // Merge legacy profiles into available list (avoid duplicates) for (const p of Object.keys(legacyConfig.profiles)) { if (!availableProfiles.includes(p)) { availableProfiles.push(p); } } } } catch { // Legacy config is invalid JSON - that's OK in unified mode } } } } else { // Legacy mode - read from config.json only const config = readConfig(); settingsPath = config.profiles[profile]; availableProfiles = Object.keys(config.profiles); } if (!settingsPath) { const profileList = availableProfiles.map((p) => ` - ${p}`); error(`Profile '${profile}' not found. Available profiles:\n${profileList.join('\n')}`); } // Expand path const expandedPath = expandPath(settingsPath); // Validate settings file exists if (!fs.existsSync(expandedPath)) { // Auto-create if it's ~/.claude/settings.json if (expandedPath.includes('.claude') && expandedPath.endsWith('settings.json')) { // TODO: Add recovery manager logic // const recovery = new RecoveryManager(); // recovery.ensureClaudeSettings(); console.log(info('Auto-created missing settings file')); } else { error(`Settings file not found: ${expandedPath}`); } } // Validate settings file is valid JSON try { const settingsContent = fs.readFileSync(expandedPath, 'utf8'); JSON.parse(settingsContent); } catch (e) { if (e instanceof Error) { error(`Invalid JSON in settings file: ${expandedPath} - ${e.message}`); } else { error(`Invalid JSON in settings file: ${expandedPath}`); } } return expandedPath; } /** * Get display name for a profile by reading ANTHROPIC_MODEL from settings * @param profile - Profile name (glm, km, glmt compatibility, custom, etc.) * @returns Formatted display name (e.g., 'GLM-4.7', 'Kimi', 'Custom-Model') */ export function getModelDisplayName(profile: string): string { if (!profile) { return ''; } const settingsPath = path.join(getCcsDir(), `${profile}.settings.json`); try { if (fs.existsSync(settingsPath)) { const content = fs.readFileSync(settingsPath, 'utf8'); const settings = JSON.parse(content) as { env?: { ANTHROPIC_MODEL?: string } }; const model = settings.env?.ANTHROPIC_MODEL; if (model) { // Format: 'glm-5' -> 'GLM-5' (uppercase letters, preserve numbers) return model .split('-') .map((part) => part.toUpperCase()) .join('-'); } } } catch { // Fall through to default } // Fallback: profile name uppercase return profile.toUpperCase(); }