refactor(config): use settings file references instead of inline env

Changes config.yaml to store references to *.settings.json files
instead of inlining env vars. This matches Claude's ~/.claude/settings.json
pattern and gives users familiar editing experience.

Changes:
- ProfileConfig now stores 'settings' path instead of 'env' object
- CLIProxyVariantConfig uses 'settings' instead of 'model'/'env'
- Migration preserves existing *.settings.json files, only stores references
- Updated config.yaml comments to explain settings file pattern
- Create/remove commands now manage settings files alongside config.yaml

Benefits:
- Users edit familiar *.settings.json format
- config.yaml stays clean (just references)
- No confusion about where to make changes
This commit is contained in:
kaitranntt
2025-12-10 17:53:00 -05:00
parent b621b8e47b
commit 1dbd7229a5
6 changed files with 169 additions and 149 deletions
+20 -2
View File
@@ -52,6 +52,22 @@ export interface ProfileNotFoundError extends Error {
/**
* Profile Detector Class
*/
/**
* Load env vars from a settings file (*.settings.json).
* Expands ~ to home directory. Returns empty object on error.
*/
function loadSettingsFromFile(settingsPath: string): Record<string, string> {
const expandedPath = settingsPath.replace(/^~/, os.homedir());
try {
if (!fs.existsSync(expandedPath)) return {};
const content = fs.readFileSync(expandedPath, 'utf8');
const settings = JSON.parse(content) as { env?: Record<string, string> };
return settings.env || {};
} catch {
return {};
}
}
class ProfileDetector {
private readonly configPath: string;
private readonly profilesPath: string;
@@ -99,12 +115,14 @@ class ProfileDetector {
// Check API profiles
if (config.profiles?.[profileName]) {
const profile = config.profiles[profileName];
// Merge with secrets
// Load env from settings file
const settingsEnv = loadSettingsFromFile(profile.settings);
// Merge with secrets (for backward compat with any extracted secrets)
const secrets = getProfileSecrets(profileName);
return {
type: 'settings',
name: profileName,
env: { ...profile.env, ...secrets },
env: { ...settingsEnv, ...secrets },
};
}
+39 -16
View File
@@ -10,6 +10,7 @@
*/
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import {
initUI,
@@ -32,7 +33,7 @@ import {
loadOrCreateUnifiedConfig,
saveUnifiedConfig,
} from '../config/unified-config-loader';
import { setProfileSecrets, deleteAllProfileSecrets } from '../config/secrets-manager';
import { deleteAllProfileSecrets } from '../config/secrets-manager';
import { isUnifiedConfigEnabled } from '../config/feature-flags';
interface ApiCommandArgs {
@@ -183,7 +184,9 @@ function updateConfig(name: string, _settingsPath: string): void {
}
/**
* Create API profile in unified config (config.yaml + secrets.yaml)
* Create API profile in unified config
* Creates *.settings.json file and stores reference in config.yaml
* (matching Claude's ~/.claude/settings.json pattern)
*/
function createApiProfileUnified(
name: string,
@@ -191,13 +194,15 @@ function createApiProfileUnified(
apiKey: string,
model: string
): void {
const config = loadOrCreateUnifiedConfig();
const ccsDir = path.join(os.homedir(), '.ccs');
const settingsFile = `${name}.settings.json`;
const settingsPath = path.join(ccsDir, settingsFile);
// Add profile to config.yaml (non-sensitive data only)
config.profiles[name] = {
type: 'api',
// Create settings file with all env vars (matching Claude's pattern)
const settings = {
env: {
ANTHROPIC_BASE_URL: baseUrl,
ANTHROPIC_AUTH_TOKEN: apiKey,
ANTHROPIC_MODEL: model,
ANTHROPIC_DEFAULT_OPUS_MODEL: model,
ANTHROPIC_DEFAULT_SONNET_MODEL: model,
@@ -205,12 +210,21 @@ function createApiProfileUnified(
},
};
saveUnifiedConfig(config);
// Ensure directory exists
if (!fs.existsSync(ccsDir)) {
fs.mkdirSync(ccsDir, { recursive: true });
}
// Store API key in secrets.yaml (sensitive data)
setProfileSecrets(name, {
ANTHROPIC_AUTH_TOKEN: apiKey,
});
// Write settings file
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf8');
// Store reference in config.yaml
const config = loadOrCreateUnifiedConfig();
config.profiles[name] = {
type: 'api',
settings: `~/.ccs/${settingsFile}`,
};
saveUnifiedConfig(config);
}
/**
@@ -218,11 +232,20 @@ function createApiProfileUnified(
*/
function removeApiProfileUnified(name: string): void {
const config = loadOrCreateUnifiedConfig();
const profile = config.profiles[name];
if (!config.profiles[name]) {
if (!profile) {
throw new Error(`API profile not found: ${name}`);
}
// Delete the settings file if it exists
if (profile.settings) {
const settingsPath = profile.settings.replace(/^~/, os.homedir());
if (fs.existsSync(settingsPath)) {
fs.unlinkSync(settingsPath);
}
}
delete config.profiles[name];
// Clear default if it was the deleted profile
@@ -232,7 +255,7 @@ function removeApiProfileUnified(name: string): void {
saveUnifiedConfig(config);
// Remove secrets
// Remove any legacy secrets (backward compat)
deleteAllProfileSecrets(name);
}
@@ -416,13 +439,13 @@ async function handleList(): Promise<void> {
console.log(subheader('CLIProxy Variants'));
const cliproxyRows = variants.map((name) => {
const variant = unifiedConfig.cliproxy?.variants[name];
return [name, variant?.provider || 'unknown', variant?.model || '-'];
return [name, variant?.provider || 'unknown', variant?.settings || '-'];
});
console.log(
table(cliproxyRows, {
head: ['Variant', 'Provider', 'Model'],
colWidths: [15, 15, 20],
head: ['Variant', 'Provider', 'Settings'],
colWidths: [15, 15, 30],
})
);
console.log('');
+50 -20
View File
@@ -254,6 +254,7 @@ function removeCliproxyVariant(name: string): { provider: string; settings: stri
/**
* Add CLIProxy variant to unified config (config.yaml)
* Creates *.settings.json file and stores reference in config.yaml
*/
function addCliproxyVariantUnified(
name: string,
@@ -261,9 +262,37 @@ function addCliproxyVariantUnified(
model: string,
account?: string
): void {
const ccsDir = path.join(require('os').homedir(), '.ccs');
const settingsFile = `${provider}-${name}.settings.json`;
const settingsPath = path.join(ccsDir, settingsFile);
// Get base env vars from provider config
const baseEnv = getClaudeEnvVars(provider as CLIProxyProvider, CLIPROXY_DEFAULT_PORT);
// Create settings file with model override
const settings = {
env: {
ANTHROPIC_BASE_URL: baseEnv.ANTHROPIC_BASE_URL || '',
ANTHROPIC_AUTH_TOKEN: baseEnv.ANTHROPIC_AUTH_TOKEN || '',
ANTHROPIC_MODEL: model,
ANTHROPIC_DEFAULT_OPUS_MODEL: model,
ANTHROPIC_DEFAULT_SONNET_MODEL: model,
ANTHROPIC_DEFAULT_HAIKU_MODEL: baseEnv.ANTHROPIC_DEFAULT_HAIKU_MODEL || model,
},
};
// Ensure directory exists
if (!fs.existsSync(ccsDir)) {
fs.mkdirSync(ccsDir, { recursive: true });
}
// Write settings file
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf8');
// Update config.yaml with reference
const config = loadOrCreateUnifiedConfig();
// Ensure cliproxy.variants section exists (preserving other fields)
// Ensure cliproxy.variants section exists
if (!config.cliproxy) {
config.cliproxy = {
oauth_accounts: {},
@@ -275,11 +304,11 @@ function addCliproxyVariantUnified(
config.cliproxy.variants = {};
}
// Add variant
// Add variant with settings file reference
config.cliproxy.variants[name] = {
provider: provider as CLIProxyProvider,
model,
account,
settings: `~/.ccs/${settingsFile}`,
};
saveUnifiedConfig(config);
@@ -288,7 +317,9 @@ function addCliproxyVariantUnified(
/**
* Remove CLIProxy variant from unified config
*/
function removeCliproxyVariantUnified(name: string): { provider: string; model?: string } | null {
function removeCliproxyVariantUnified(
name: string
): { provider: string; settings?: string } | null {
const config = loadOrCreateUnifiedConfig();
if (!config.cliproxy?.variants || !(name in config.cliproxy.variants)) {
@@ -296,11 +327,19 @@ function removeCliproxyVariantUnified(name: string): { provider: string; model?:
}
const variant = config.cliproxy.variants[name];
delete config.cliproxy.variants[name];
// Delete the settings file if it exists
if (variant.settings) {
const settingsPath = variant.settings.replace(/^~/, require('os').homedir());
if (fs.existsSync(settingsPath)) {
fs.unlinkSync(settingsPath);
}
}
delete config.cliproxy.variants[name];
saveUnifiedConfig(config);
return { provider: variant.provider, model: variant.model };
return { provider: variant.provider, settings: variant.settings };
}
/**
@@ -577,7 +616,7 @@ async function handleList(): Promise<void> {
variantData = {};
for (const name of variantNames) {
const v = variants[name];
variantData[name] = { provider: v.provider, model: v.model };
variantData[name] = { provider: v.provider, settings: v.settings };
}
} else {
const config = loadConfig();
@@ -596,17 +635,13 @@ async function handleList(): Promise<void> {
// Build table data
const rows: string[][] = variantNames.map((name) => {
const variant = variantData[name];
const thirdCol = variant.model || variant.settings || '-';
return [name, variant.provider, thirdCol];
return [name, variant.provider, variant.settings || '-'];
});
// Print table
const headLabels = isUnifiedMode()
? ['Variant', 'Provider', 'Model']
: ['Variant', 'Provider', 'Settings'];
console.log(
table(rows, {
head: headLabels,
head: ['Variant', 'Provider', 'Settings'],
colWidths: [15, 12, 35],
})
);
@@ -642,7 +677,7 @@ async function handleRemove(args: string[]): Promise<void> {
variantData = {};
for (const name of variantNames) {
const v = variants[name];
variantData[name] = { provider: v.provider, model: v.model };
variantData[name] = { provider: v.provider, settings: v.settings };
}
} else {
const config = loadConfig();
@@ -695,12 +730,7 @@ async function handleRemove(args: string[]): Promise<void> {
console.log('');
console.log(`Variant '${color(name, 'command')}' will be removed.`);
console.log(` Provider: ${variant.provider}`);
if (isUnifiedMode()) {
console.log(` Model: ${variant.model || '-'}`);
console.log(' Config: ~/.ccs/config.yaml');
} else {
console.log(` Settings: ${variant.settings}`);
}
console.log(` Settings: ${variant.settings || '-'}`);
console.log('');
const confirmed =
+38 -95
View File
@@ -5,17 +5,19 @@
* Features:
* - Automatic backup before migration
* - Rollback support
* - Secret extraction and separation
* - Settings file reference preservation (*.settings.json)
* - Cache file restructuring
*
* Design: Settings remain in *.settings.json files (matching Claude's pattern)
* while config.yaml stores references to these files.
*/
import * as fs from 'fs';
import * as path from 'path';
import { getCcsDir } from '../utils/config-manager';
import type { ProfileConfig, AccountConfig, CLIProxyVariantConfig } from './unified-config-types';
import { createEmptyUnifiedConfig, createEmptySecretsConfig } from './unified-config-types';
import { createEmptyUnifiedConfig } from './unified-config-types';
import { saveUnifiedConfig, hasUnifiedConfig } from './unified-config-loader';
import { saveSecrets, isSecretKey } from './secrets-manager';
const BACKUP_DIR_PREFIX = 'backup-v1-';
@@ -79,7 +81,6 @@ export async function migrate(dryRun = false): Promise<MigrationResult> {
// 3. Build unified config
const unifiedConfig = createEmptyUnifiedConfig();
const secrets = createEmptySecretsConfig();
// Set default if exists
if (oldProfiles?.default && typeof oldProfiles.default === 'string') {
@@ -111,14 +112,9 @@ export async function migrate(dryRun = false): Promise<MigrationResult> {
variant.account = oldVariant.account as string;
}
// Extract model from settings file if exists
// Keep reference to existing settings file
if (oldVariant.settings) {
const settingsPath = expandPath(oldVariant.settings as string);
const settings = readJsonSafe(settingsPath);
const env = settings?.env as Record<string, string> | undefined;
if (env?.ANTHROPIC_MODEL) {
variant.model = env.ANTHROPIC_MODEL;
}
variant.settings = oldVariant.settings as string;
}
unifiedConfig.cliproxy.variants[name] = variant;
@@ -126,96 +122,47 @@ export async function migrate(dryRun = false): Promise<MigrationResult> {
migratedFiles.push('config.json.cliproxy → config.yaml.cliproxy.variants');
}
// 6. Migrate API profiles from config.json + settings files
// 6. Migrate API profiles from config.json
// Keep settings in *.settings.json files (matching Claude's ~/.claude/settings.json pattern)
// config.yaml only stores reference to the settings file
if (oldConfig?.profiles) {
for (const [name, settingsPath] of Object.entries(oldConfig.profiles)) {
const expandedPath = expandPath(settingsPath as string);
const settings = readJsonSafe(expandedPath);
const pathStr = settingsPath as string;
const expandedPath = expandPath(pathStr);
if (settings?.env) {
// Split env into config (non-secret) and secrets
const envConfig: Record<string, string> = {};
const envSecrets: Record<string, string> = {};
for (const [key, value] of Object.entries(settings.env)) {
if (isSecretKey(key)) {
envSecrets[key] = value as string;
} else {
envConfig[key] = value as string;
}
}
const profile: ProfileConfig = {
type: 'api',
env: envConfig,
};
unifiedConfig.profiles[name] = profile;
if (Object.keys(envSecrets).length > 0) {
secrets.profiles[name] = envSecrets;
}
migratedFiles.push(`${name}.settings.json → config.yaml.profiles.${name}`);
} else {
warnings.push(`Skipped ${name}: no env vars found in ${expandedPath}`);
// Verify settings file exists
if (!fs.existsSync(expandedPath)) {
warnings.push(`Skipped ${name}: settings file not found at ${pathStr}`);
continue;
}
// Store reference to settings file (keep using ~ for portability)
const profile: ProfileConfig = {
type: 'api',
settings: pathStr,
};
unifiedConfig.profiles[name] = profile;
migratedFiles.push(`config.json.profiles.${name} → config.yaml (settings: ${pathStr})`);
}
}
// 6b. Migrate built-in CLIProxy OAuth profile settings (gemini, codex, agy, qwen, iflow)
// These files contain user overrides like custom models or env vars (ANTHROPIC_MAX_TOKENS, etc.)
// Keep settings in *.settings.json files - only record reference in config.yaml
// This matches Claude's ~/.claude/settings.json pattern for user familiarity
const builtInProviders = ['gemini', 'codex', 'agy', 'qwen', 'iflow'];
for (const provider of builtInProviders) {
const settingsPath = path.join(ccsDir, `${provider}.settings.json`);
const settingsFile = `${provider}.settings.json`;
const settingsPath = path.join(ccsDir, settingsFile);
if (fs.existsSync(settingsPath)) {
const settings = readJsonSafe(settingsPath);
const env = settings?.env as Record<string, string> | undefined;
if (env) {
// Extract user-configurable values (skip CCS-internal values)
const userEnv: Record<string, string> = {};
let userModel: string | undefined;
// Create variant with reference to settings file
const variant: CLIProxyVariantConfig = {
provider: provider as CLIProxyVariantConfig['provider'],
settings: `~/.ccs/${settingsFile}`,
};
for (const [key, value] of Object.entries(env)) {
// Skip internal CCS-managed values (these are auto-generated at runtime)
if (key === 'ANTHROPIC_AUTH_TOKEN' && value === 'ccs-internal-managed') continue;
if (key === 'ANTHROPIC_BASE_URL' && value.includes('127.0.0.1')) continue;
// Skip model tier settings that are derived from primary model
if (key === 'ANTHROPIC_DEFAULT_OPUS_MODEL') continue;
if (key === 'ANTHROPIC_DEFAULT_SONNET_MODEL') continue;
if (key === 'ANTHROPIC_DEFAULT_HAIKU_MODEL') continue;
// Skip secrets (should not be in CLIProxy settings anyway)
if (isSecretKey(key)) continue;
// Extract model separately
if (key === 'ANTHROPIC_MODEL') {
userModel = value;
continue;
}
// Keep other user-configurable env vars
userEnv[key] = value;
}
// Only create variant if there's user customization
if (userModel || Object.keys(userEnv).length > 0) {
const variant: CLIProxyVariantConfig = {
provider: provider as CLIProxyVariantConfig['provider'],
};
if (userModel) {
variant.model = userModel;
}
if (Object.keys(userEnv).length > 0) {
variant.env = userEnv;
}
unifiedConfig.cliproxy.variants[provider] = variant;
migratedFiles.push(
`${provider}.settings.json → config.yaml.cliproxy.variants.${provider}`
);
}
}
unifiedConfig.cliproxy.variants[provider] = variant;
migratedFiles.push(`${settingsFile} → config.yaml.cliproxy.variants.${provider}`);
}
}
@@ -240,14 +187,10 @@ export async function migrate(dryRun = false): Promise<MigrationResult> {
}
}
// 8. Write new configs (unless dry run)
// 8. Write new config (unless dry run)
// Note: Settings remain in *.settings.json files, config.yaml only stores references
if (!dryRun) {
saveUnifiedConfig(unifiedConfig);
if (Object.keys(secrets.profiles).length > 0) {
saveSecrets(secrets);
migratedFiles.push('secrets extracted → secrets.yaml');
}
}
return {
+12 -10
View File
@@ -112,16 +112,18 @@ function generateYamlHeader(): string {
# Generated by: ccs migrate
# Documentation: https://github.com/kaitranntt/ccs
#
# This file consolidates all CCS configuration in a single, human-readable format.
# Secrets (API keys, tokens) are stored separately in secrets.yaml (chmod 600).
# This file references your settings - actual env vars are in *.settings.json
# files (matching Claude's ~/.claude/settings.json pattern).
#
# To customize a profile:
# 1. Edit the *.settings.json file directly (e.g., ~/.ccs/glm.settings.json)
# 2. The file format matches Claude's settings.json: { "env": { ... } }
#
# Structure:
# ┌─────────────────────────────────────────────────────────────────────────────┐
# │ version - Config format version (do not modify)
# │ default - Default profile name when running 'ccs' without args
# │ profiles - References to *.settings.json files for API providers
# │ cliproxy - References to *.settings.json files for OAuth providers
# │ accounts - Isolated Claude instances (managed by 'ccs auth') │
# │ profiles - API providers like GLM, GLMT, Kimi (managed by 'ccs api') │
# │ cliproxy - OAuth providers: gemini, codex, agy (managed by 'ccs') │
# │ preferences - User preferences (theme, telemetry, auto-update) │
# └─────────────────────────────────────────────────────────────────────────────┘
#
@@ -164,8 +166,8 @@ function generateYamlWithComments(config: UnifiedConfig): string {
// Profiles section
lines.push('# ----------------------------------------------------------------------------');
lines.push('# Profiles: API-based providers (GLM, GLMT, Kimi, custom endpoints)');
lines.push('# Manage with: ccs api add <name>, ccs api list, ccs api remove <name>');
lines.push('# Note: env vars like ANTHROPIC_MAX_TOKENS go here, secrets in secrets.yaml');
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()
@@ -175,8 +177,8 @@ function generateYamlWithComments(config: UnifiedConfig): string {
// CLIProxy section
lines.push('# ----------------------------------------------------------------------------');
lines.push('# CLIProxy: OAuth-based providers (gemini, codex, agy, qwen, iflow)');
lines.push('# Manage with: ccs cliproxy create, ccs cliproxy list, ccs cliproxy remove');
lines.push('# Built-in providers require no config - just run: ccs gemini --auth');
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()
+10 -6
View File
@@ -29,12 +29,15 @@ export interface AccountConfig {
/**
* API-based profile configuration.
* Injects environment variables for alternative providers (GLM, Kimi, etc.).
*
* Settings are stored in separate *.settings.json files (matching Claude's pattern)
* to allow users to edit them directly without touching config.yaml.
*/
export interface ProfileConfig {
/** Profile type - currently only 'api' */
type: 'api';
/** Environment variables (non-secret values only) */
env: Record<string, string>;
/** Path to settings file (e.g., "~/.ccs/glm.settings.json") */
settings: string;
}
/**
@@ -46,16 +49,17 @@ export type OAuthAccounts = Record<string, string>;
/**
* CLIProxy variant configuration.
* User-defined variants of built-in OAuth providers.
*
* Settings are stored in separate *.settings.json files (matching Claude's pattern)
* to allow users to edit them directly without touching config.yaml.
*/
export interface CLIProxyVariantConfig {
/** Base provider to use */
provider: 'gemini' | 'codex' | 'agy' | 'qwen' | 'iflow';
/** Account nickname (references oauth_accounts) */
account?: string;
/** Model override (inline, no separate settings file) */
model?: string;
/** Additional env var overrides (ANTHROPIC_MAX_TOKENS, MAX_THINKING_TOKENS, etc.) */
env?: Record<string, string>;
/** Path to settings file (e.g., "~/.ccs/gemini-custom.settings.json") */
settings?: string;
}
/**