From 4fca7d16edc6985d14422a21d45dccd619ef9aba Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Fri, 26 Dec 2025 12:45:12 -0500 Subject: [PATCH 1/2] fix(doctor): prefer config.yaml and make settings files optional - Check config.yaml first (v2 format), fallback to config.json (legacy) - Make glm.settings.json and kimi.settings.json optional - Only validate settings files if they exist - Fix false positives: postinstall no longer creates these files Fixes health check reporting errors for missing files that are now optional and created on-demand when user configures a profile. --- src/management/checks/config-check.ts | 168 +++++++++++++++++++------- 1 file changed, 123 insertions(+), 45 deletions(-) diff --git a/src/management/checks/config-check.ts b/src/management/checks/config-check.ts index 4a643814..e56846a8 100644 --- a/src/management/checks/config-check.ts +++ b/src/management/checks/config-check.ts @@ -5,13 +5,15 @@ import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; -import { ok, fail, warn } from '../../utils/ui'; +import { ok, fail, warn, info } from '../../utils/ui'; import { HealthCheck, IHealthChecker, createSpinner } from './types'; const ora = createSpinner(); /** - * Check CCS config files exist and are valid JSON + * Check CCS config files exist and are valid + * - Prefers config.yaml (v2) over config.json (legacy) + * - Settings files are optional (only checked if profile exists) */ export class ConfigFilesChecker implements IHealthChecker { name = 'Config Files'; @@ -22,63 +24,139 @@ export class ConfigFilesChecker implements IHealthChecker { } run(results: HealthCheck): void { - const files = [ - { path: path.join(this.ccsDir, 'config.json'), name: 'config.json', key: 'config.json' }, - { - path: path.join(this.ccsDir, 'glm.settings.json'), - name: 'glm.settings.json', - key: 'GLM Settings', - profile: 'glm', - }, - { - path: path.join(this.ccsDir, 'kimi.settings.json'), - name: 'kimi.settings.json', - key: 'Kimi Settings', - profile: 'kimi', - }, - ]; - const { DelegationValidator } = require('../../utils/delegation-validator'); - for (const file of files) { - const spinner = ora(`Checking ${file.name}`).start(); + // Check main config file (yaml preferred, json fallback) + this.checkMainConfig(results); - if (!fs.existsSync(file.path)) { + // Check optional settings files (only if profile exists in config) + this.checkOptionalSettingsFiles(results, DelegationValidator); + } + + /** + * Check main configuration file (config.yaml preferred, config.json fallback) + */ + private checkMainConfig(results: HealthCheck): void { + const configYamlPath = path.join(this.ccsDir, 'config.yaml'); + const configJsonPath = path.join(this.ccsDir, 'config.json'); + + const yamlExists = fs.existsSync(configYamlPath); + const jsonExists = fs.existsSync(configJsonPath); + + // Check config.yaml first (preferred format) + if (yamlExists) { + const spinner = ora('Checking config.yaml').start(); + try { + const yaml = require('js-yaml'); + const content = fs.readFileSync(configYamlPath, 'utf8'); + yaml.load(content); + spinner.succeed(); + console.log(` ${ok('config.yaml'.padEnd(22))} Valid`); + results.addCheck('config.yaml', 'success', undefined, undefined, { + status: 'OK', + info: 'Valid', + }); + + // Inform if legacy config.json also exists (purely informational, not a check) + if (jsonExists) { + console.log(` ${info('config.json'.padEnd(22))} Legacy (ignored)`); + } + return; + } catch (e) { spinner.fail(); - console.log(` ${fail(file.name.padEnd(22))} Not found`); + console.log(` ${fail('config.yaml'.padEnd(22))} Invalid YAML`); results.addCheck( - file.name, + 'config.yaml', 'error', - `${file.name} not found`, - 'Run: npm install -g @kaitranntt/ccs --force', - { status: 'ERROR', info: 'Not found' } + `Invalid YAML: ${(e as Error).message}`, + `Backup and recreate: mv ${configYamlPath} ${configYamlPath}.backup && npm install -g @kaitranntt/ccs --force`, + { status: 'ERROR', info: 'Invalid YAML' } ); + return; + } + } + + // Fallback to config.json (legacy format) + if (jsonExists) { + const spinner = ora('Checking config.json').start(); + try { + const content = fs.readFileSync(configJsonPath, 'utf8'); + JSON.parse(content); + spinner.succeed(); + console.log(` ${ok('config.json'.padEnd(22))} Valid (legacy)`); + results.addCheck('config.json', 'success', undefined, undefined, { + status: 'OK', + info: 'Valid (legacy)', + }); + } catch (e) { + spinner.fail(); + console.log(` ${fail('config.json'.padEnd(22))} Invalid JSON`); + results.addCheck( + 'config.json', + 'error', + `Invalid JSON: ${(e as Error).message}`, + `Backup and recreate: mv ${configJsonPath} ${configJsonPath}.backup && npm install -g @kaitranntt/ccs --force`, + { status: 'ERROR', info: 'Invalid JSON' } + ); + } + return; + } + + // Neither exists - error + const spinner = ora('Checking config').start(); + spinner.fail(); + console.log(` ${fail('config.yaml'.padEnd(22))} Not found`); + results.addCheck( + 'config.yaml', + 'error', + 'No configuration file found (config.yaml or config.json)', + 'Run: npm install -g @kaitranntt/ccs --force', + { status: 'ERROR', info: 'Not found' } + ); + } + + /** + * Check optional settings files (only if corresponding profile is configured) + * These files are NOT required - they're only created when user configures a profile + */ + private checkOptionalSettingsFiles( + results: HealthCheck, + DelegationValidator: { validate: (profile: string) => { valid: boolean; error?: string } } + ): void { + // Settings files to check (only if profile exists) + const settingsFiles = [ + { name: 'glm.settings.json', profile: 'glm', displayName: 'GLM Settings' }, + { name: 'kimi.settings.json', profile: 'kimi', displayName: 'Kimi Settings' }, + ]; + + for (const file of settingsFiles) { + const filePath = path.join(this.ccsDir, file.name); + const exists = fs.existsSync(filePath); + + if (!exists) { + // Not an error - these are optional files + // Only show info if user might expect them continue; } - // Validate JSON + // File exists - validate it + const spinner = ora(`Checking ${file.name}`).start(); try { - const content = fs.readFileSync(file.path, 'utf8'); + const content = fs.readFileSync(filePath, 'utf8'); JSON.parse(content); - // Extract useful info based on file type - let fileInfo = 'Valid'; + // Check if API key is properly configured + const validation = DelegationValidator.validate(file.profile); + + let fileInfo = 'Valid JSON'; let status: 'OK' | 'WARN' = 'OK'; - if (file.profile) { - // For settings files, check if API key is configured - const validation = DelegationValidator.validate(file.profile); - - if (validation.valid) { - fileInfo = 'Key configured'; - status = 'OK'; - } else if (validation.error && validation.error.includes('placeholder')) { - fileInfo = 'Placeholder key'; - status = 'WARN'; - } else { - fileInfo = 'Valid JSON'; - status = 'OK'; - } + if (validation.valid) { + fileInfo = 'Key configured'; + status = 'OK'; + } else if (validation.error && validation.error.includes('placeholder')) { + fileInfo = 'Placeholder key'; + status = 'WARN'; } if (status === 'WARN') { @@ -100,7 +178,7 @@ export class ConfigFilesChecker implements IHealthChecker { file.name, 'error', `Invalid JSON: ${(e as Error).message}`, - `Backup and recreate: mv ${file.path} ${file.path}.backup && npm install -g @kaitranntt/ccs --force`, + `Backup and recreate: mv ${filePath} ${filePath}.backup`, { status: 'ERROR', info: 'Invalid JSON' } ); } From ac745503e2a1644b2cb3542b917dbce5e6109200 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Fri, 26 Dec 2025 12:54:54 -0500 Subject: [PATCH 2/2] fix(doctor): comprehensive health check fixes - profile-check.ts: check config.yaml first, fallback to config.json - recovery-manager.ts: remove dead code (ensureGlm/Glmt/Kimi methods) - 4 files: use os.homedir() for cross-platform compatibility Fixes: - ProfilesChecker now respects unified config format (yaml) - Removed ~100 lines of dead code that contradicted install policy - Windows compatibility for home directory detection Files modified: - src/management/checks/profile-check.ts - src/management/recovery-manager.ts - src/cliproxy/model-config.ts - src/cliproxy/services/variant-service.ts - src/web-server/health/config-checks.ts - src/web-server/routes/settings-routes.ts --- src/cliproxy/model-config.ts | 7 +- src/cliproxy/services/variant-service.ts | 3 +- src/management/checks/profile-check.ts | 119 +++++++++++++++++------ src/management/recovery-manager.ts | 100 ------------------- src/web-server/health/config-checks.ts | 3 +- src/web-server/routes/settings-routes.ts | 3 +- 6 files changed, 97 insertions(+), 138 deletions(-) diff --git a/src/cliproxy/model-config.ts b/src/cliproxy/model-config.ts index ab9e62b5..ddec180b 100644 --- a/src/cliproxy/model-config.ts +++ b/src/cliproxy/model-config.ts @@ -6,6 +6,7 @@ */ import * as fs from 'fs'; +import * as os from 'os'; import * as path from 'path'; import { InteractivePrompt } from '../utils/prompt'; import { getProviderCatalog, supportsModelConfig, ModelEntry } from './model-catalog'; @@ -14,7 +15,7 @@ import { CLIProxyProvider } from './types'; import { initUI, color, bold, dim, ok, info, header } from '../utils/ui'; /** CCS directory */ -const CCS_DIR = path.join(process.env.HOME || process.env.USERPROFILE || '', '.ccs'); +const CCS_DIR = path.join(os.homedir(), '.ccs'); /** * Check if provider has user settings configured @@ -34,7 +35,7 @@ export function getCurrentModel( customSettingsPath?: string ): string | undefined { const settingsPath = customSettingsPath - ? customSettingsPath.replace(/^~/, process.env.HOME || process.env.USERPROFILE || '') + ? customSettingsPath.replace(/^~/, os.homedir()) : getProviderSettingsPath(provider); if (!fs.existsSync(settingsPath)) return undefined; @@ -93,7 +94,7 @@ export async function configureProviderModel( // Use custom settings path for CLIProxy variants, otherwise use default provider path const settingsPath = customSettingsPath - ? customSettingsPath.replace(/^~/, process.env.HOME || process.env.USERPROFILE || '') + ? customSettingsPath.replace(/^~/, os.homedir()) : getProviderSettingsPath(provider); // Skip if already configured (unless --config flag) diff --git a/src/cliproxy/services/variant-service.ts b/src/cliproxy/services/variant-service.ts index 7911ab1e..58e4e05d 100644 --- a/src/cliproxy/services/variant-service.ts +++ b/src/cliproxy/services/variant-service.ts @@ -5,6 +5,7 @@ * Supports both unified config (config.yaml) and legacy JSON format. */ +import * as os from 'os'; import * as path from 'path'; import { CLIProxyProfileName } from '../../auth/profile-detector'; import { CLIProxyProvider } from '../types'; @@ -177,7 +178,7 @@ export function updateVariant(name: string, updates: UpdateVariantOptions): Vari // Update model in settings file if provided if (updates.model !== undefined && existing.settings) { - const settingsPath = existing.settings.replace(/^~/, process.env.HOME || ''); + const settingsPath = existing.settings.replace(/^~/, os.homedir()); updateSettingsModel(settingsPath, updates.model); } diff --git a/src/management/checks/profile-check.ts b/src/management/checks/profile-check.ts index cc449042..7d80a4df 100644 --- a/src/management/checks/profile-check.ts +++ b/src/management/checks/profile-check.ts @@ -11,7 +11,7 @@ import { HealthCheck, IHealthChecker, createSpinner } from './types'; const ora = createSpinner(); /** - * Check profile configurations in config.json + * Check profile configurations in config.yaml (preferred) or config.json (legacy) */ export class ProfilesChecker implements IHealthChecker { name = 'Profiles'; @@ -23,47 +23,102 @@ export class ProfilesChecker implements IHealthChecker { run(results: HealthCheck): void { const spinner = ora('Checking profiles').start(); - const configPath = path.join(this.ccsDir, 'config.json'); + const configYamlPath = path.join(this.ccsDir, 'config.yaml'); + const configJsonPath = path.join(this.ccsDir, 'config.json'); - if (!fs.existsSync(configPath)) { - spinner.info(); - console.log(` ${info('Profiles'.padEnd(22))} config.json not found`); - return; - } + const yamlExists = fs.existsSync(configYamlPath); + const jsonExists = fs.existsSync(configJsonPath); - try { - const config = JSON.parse(fs.readFileSync(configPath, 'utf8')); - - if (!config.profiles || typeof config.profiles !== 'object') { + // Check config.yaml first (preferred format) + if (yamlExists) { + try { + const yaml = require('js-yaml'); + const content = fs.readFileSync(configYamlPath, 'utf8'); + const config = yaml.load(content) as Record; + this.validateProfiles(config, 'config.yaml', spinner, results); + return; + } catch (e) { spinner.fail(); - console.log(` ${fail('Profiles'.padEnd(22))} Missing profiles object`); + console.log( + ` ${fail('Profiles'.padEnd(22))} Invalid config.yaml: ${(e as Error).message}` + ); results.addCheck( 'Profiles', 'error', - 'config.json missing profiles object', - 'Run: npm install -g @kaitranntt/ccs --force', - { status: 'ERROR', info: 'Missing profiles object' } + `Invalid config.yaml: ${(e as Error).message}`, + undefined, + { + status: 'ERROR', + info: (e as Error).message, + } ); return; } - - const profileCount = Object.keys(config.profiles).length; - const profileNames = Object.keys(config.profiles).join(', '); - - spinner.succeed(); - console.log(` ${ok('Profiles'.padEnd(22))} ${profileCount} configured (${profileNames})`); - results.addCheck('Profiles', 'success', `${profileCount} profiles configured`, undefined, { - status: 'OK', - info: `${profileCount} configured (${profileNames.length > 30 ? profileNames.substring(0, 27) + '...' : profileNames})`, - }); - } catch (e) { - spinner.fail(); - console.log(` ${fail('Profiles'.padEnd(22))} ${(e as Error).message}`); - results.addCheck('Profiles', 'error', (e as Error).message, undefined, { - status: 'ERROR', - info: (e as Error).message, - }); } + + // Fallback to config.json (legacy format) + if (jsonExists) { + try { + const config = JSON.parse(fs.readFileSync(configJsonPath, 'utf8')); + this.validateProfiles(config, 'config.json', spinner, results); + return; + } catch (e) { + spinner.fail(); + console.log( + ` ${fail('Profiles'.padEnd(22))} Invalid config.json: ${(e as Error).message}` + ); + results.addCheck( + 'Profiles', + 'error', + `Invalid config.json: ${(e as Error).message}`, + undefined, + { + status: 'ERROR', + info: (e as Error).message, + } + ); + return; + } + } + + // Neither exists + spinner.info(); + console.log( + ` ${info('Profiles'.padEnd(22))} No config file found (config.yaml or config.json)` + ); + } + + /** + * Validate profiles object from parsed config + */ + private validateProfiles( + config: Record, + configFileName: string, + spinner: ReturnType['start']>, + results: HealthCheck + ): void { + if (!config.profiles || typeof config.profiles !== 'object') { + spinner.fail(); + console.log(` ${fail('Profiles'.padEnd(22))} Missing profiles object in ${configFileName}`); + results.addCheck( + 'Profiles', + 'error', + `${configFileName} missing profiles object`, + 'Run: npm install -g @kaitranntt/ccs --force', + { status: 'ERROR', info: 'Missing profiles object' } + ); + return; + } + + const profileCount = Object.keys(config.profiles as object).length; + const profileNames = Object.keys(config.profiles as object).join(', '); + + spinner.succeed(); + console.log(` ${ok('Profiles'.padEnd(22))} ${profileCount} configured (${profileNames})`); + results.addCheck('Profiles', 'success', `${profileCount} profiles configured`, undefined, { + status: 'OK', + info: `${profileCount} configured (${profileNames.length > 30 ? profileNames.substring(0, 27) + '...' : profileNames})`, + }); } } diff --git a/src/management/recovery-manager.ts b/src/management/recovery-manager.ts index ec2169a2..8977870c 100644 --- a/src/management/recovery-manager.ts +++ b/src/management/recovery-manager.ts @@ -153,89 +153,6 @@ class RecoveryManager { return created; } - /** - * Ensure GLM settings file exists - */ - ensureGlmSettings(): boolean { - const settingsPath = path.join(this.ccsDir, 'glm.settings.json'); - if (fs.existsSync(settingsPath)) return false; - - const settings = { - env: { - ANTHROPIC_BASE_URL: 'https://api.z.ai/api/anthropic', - ANTHROPIC_AUTH_TOKEN: 'YOUR_GLM_API_KEY_HERE', - ANTHROPIC_MODEL: 'glm-4.6', - ANTHROPIC_DEFAULT_OPUS_MODEL: 'glm-4.6', - ANTHROPIC_DEFAULT_SONNET_MODEL: 'glm-4.6', - ANTHROPIC_DEFAULT_HAIKU_MODEL: 'glm-4.6', - }, - }; - - const tmpPath = `${settingsPath}.tmp`; - fs.writeFileSync(tmpPath, JSON.stringify(settings, null, 2) + '\n', 'utf8'); - fs.renameSync(tmpPath, settingsPath); - this.recovered.push('Created ~/.ccs/glm.settings.json'); - return true; - } - - /** - * Ensure GLMT settings file exists - */ - ensureGlmtSettings(): boolean { - const settingsPath = path.join(this.ccsDir, 'glmt.settings.json'); - if (fs.existsSync(settingsPath)) return false; - - const settings = { - env: { - ANTHROPIC_BASE_URL: 'https://api.z.ai/api/coding/paas/v4/chat/completions', - ANTHROPIC_AUTH_TOKEN: 'YOUR_GLM_API_KEY_HERE', - ANTHROPIC_MODEL: 'glm-4.6', - ANTHROPIC_DEFAULT_OPUS_MODEL: 'glm-4.6', - ANTHROPIC_DEFAULT_SONNET_MODEL: 'glm-4.6', - ANTHROPIC_DEFAULT_HAIKU_MODEL: 'glm-4.6', - ANTHROPIC_TEMPERATURE: '0.2', - ANTHROPIC_MAX_TOKENS: '65536', - MAX_THINKING_TOKENS: '32768', - ENABLE_STREAMING: 'true', - ANTHROPIC_SAFE_MODE: 'false', - API_TIMEOUT_MS: '3000000', - }, - alwaysThinkingEnabled: true, - }; - - const tmpPath = `${settingsPath}.tmp`; - fs.writeFileSync(tmpPath, JSON.stringify(settings, null, 2) + '\n', 'utf8'); - fs.renameSync(tmpPath, settingsPath); - this.recovered.push('Created ~/.ccs/glmt.settings.json'); - return true; - } - - /** - * Ensure Kimi settings file exists - */ - ensureKimiSettings(): boolean { - const settingsPath = path.join(this.ccsDir, 'kimi.settings.json'); - if (fs.existsSync(settingsPath)) return false; - - const settings = { - env: { - ANTHROPIC_BASE_URL: 'https://api.kimi.com/coding/', - ANTHROPIC_AUTH_TOKEN: 'YOUR_KIMI_API_KEY_HERE', - ANTHROPIC_MODEL: 'kimi-k2-thinking-turbo', - ANTHROPIC_DEFAULT_OPUS_MODEL: 'kimi-k2-thinking-turbo', - ANTHROPIC_DEFAULT_SONNET_MODEL: 'kimi-k2-thinking-turbo', - ANTHROPIC_DEFAULT_HAIKU_MODEL: 'kimi-k2-thinking-turbo', - }, - alwaysThinkingEnabled: true, - }; - - const tmpPath = `${settingsPath}.tmp`; - fs.writeFileSync(tmpPath, JSON.stringify(settings, null, 2) + '\n', 'utf8'); - fs.renameSync(tmpPath, settingsPath); - this.recovered.push('Created ~/.ccs/kimi.settings.json'); - return true; - } - /** * Install shell completion files */ @@ -327,23 +244,6 @@ class RecoveryManager { console.log(info('Auto-recovery completed:')); this.recovered.forEach((msg) => console.log(` - ${msg}`)); - // Show API key hints if created profile settings - const createdGlm = this.recovered.some((msg) => msg.includes('glm.settings.json')); - const createdKimi = this.recovered.some((msg) => msg.includes('kimi.settings.json')); - - if (createdGlm || createdKimi) { - console.log(''); - console.log(info('Configure API keys:')); - if (createdGlm) { - console.log(' GLM: Edit ~/.ccs/glm.settings.json'); - console.log(' Get key from: https://api.z.ai'); - } - if (createdKimi) { - console.log(' Kimi: Edit ~/.ccs/kimi.settings.json'); - console.log(' Get key from: https://www.kimi.com/coding'); - } - } - // Show login hint if created Claude settings if (this.recovered.some((msg) => msg.includes('~/.claude/settings.json'))) { console.log(''); diff --git a/src/web-server/health/config-checks.ts b/src/web-server/health/config-checks.ts index 45c0c2c8..5f7ca34a 100644 --- a/src/web-server/health/config-checks.ts +++ b/src/web-server/health/config-checks.ts @@ -6,6 +6,7 @@ */ import * as fs from 'fs'; +import * as os from 'os'; import * as path from 'path'; import { getConfigPath } from '../../utils/config-manager'; import { isUnifiedMode, hasUnifiedConfig } from '../../config/unified-config-loader'; @@ -17,7 +18,7 @@ import type { HealthCheck } from './types'; export function checkConfigFile(): HealthCheck { // In unified mode, check config.yaml if (isUnifiedMode() || hasUnifiedConfig()) { - const ccsDir = path.join(process.env.HOME || '', '.ccs'); + const ccsDir = path.join(os.homedir(), '.ccs'); const yamlPath = path.join(ccsDir, 'config.yaml'); if (!fs.existsSync(yamlPath)) { diff --git a/src/web-server/routes/settings-routes.ts b/src/web-server/routes/settings-routes.ts index aadfaed2..12e175aa 100644 --- a/src/web-server/routes/settings-routes.ts +++ b/src/web-server/routes/settings-routes.ts @@ -4,6 +4,7 @@ import { Router, Request, Response } from 'express'; import * as fs from 'fs'; +import * as os from 'os'; import * as path from 'path'; import { getCcsDir, loadSettings } from '../../utils/config-manager'; import { isSensitiveKey, maskSensitiveValue } from '../../utils/sensitive-keys'; @@ -33,7 +34,7 @@ function resolveSettingsPath(profileOrVariant: string): string { const variant = variants[profileOrVariant]; if (variant?.settings) { // Variant settings path (e.g., ~/.ccs/agy-g3.settings.json) - return variant.settings.replace(/^~/, process.env.HOME || ''); + return variant.settings.replace(/^~/, os.homedir()); } // Regular profile settings