Merge pull request #208 from kaitranntt/fix/config-check-outdated-file-requirements

fix(doctor): prefer config.yaml and make settings files optional
This commit is contained in:
Kai (Tam Nhu) Tran
2025-12-26 10:05:14 -08:00
committed by GitHub
7 changed files with 220 additions and 183 deletions
+4 -3
View File
@@ -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)
+2 -1
View File
@@ -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);
}
+123 -45
View File
@@ -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' }
);
}
+87 -32
View File
@@ -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<string, unknown>;
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<string, unknown>,
configFileName: string,
spinner: ReturnType<ReturnType<typeof ora>['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})`,
});
}
}
-100
View File
@@ -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('');
+2 -1
View File
@@ -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)) {
+2 -1
View File
@@ -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