From 1dbd7229a569604ecacec4a67f51b5732643c5a3 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Wed, 10 Dec 2025 17:53:00 -0500 Subject: [PATCH] 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 --- src/auth/profile-detector.ts | 22 ++++- src/commands/api-command.ts | 55 ++++++++---- src/commands/cliproxy-command.ts | 70 ++++++++++----- src/config/migration-manager.ts | 133 ++++++++-------------------- src/config/unified-config-loader.ts | 22 ++--- src/config/unified-config-types.ts | 16 ++-- 6 files changed, 169 insertions(+), 149 deletions(-) diff --git a/src/auth/profile-detector.ts b/src/auth/profile-detector.ts index 225f83bc..aa537df7 100644 --- a/src/auth/profile-detector.ts +++ b/src/auth/profile-detector.ts @@ -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 { + 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 }; + 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 }, }; } diff --git a/src/commands/api-command.ts b/src/commands/api-command.ts index 0f581194..4193711f 100644 --- a/src/commands/api-command.ts +++ b/src/commands/api-command.ts @@ -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 { 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(''); diff --git a/src/commands/cliproxy-command.ts b/src/commands/cliproxy-command.ts index a381e021..ff4b65ba 100644 --- a/src/commands/cliproxy-command.ts +++ b/src/commands/cliproxy-command.ts @@ -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 { 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 { // 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 { 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 { 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 = diff --git a/src/config/migration-manager.ts b/src/config/migration-manager.ts index 1c259ddf..39622fd3 100644 --- a/src/config/migration-manager.ts +++ b/src/config/migration-manager.ts @@ -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 { // 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 { 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 | 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 { 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 = {}; - const envSecrets: Record = {}; - - 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 | undefined; - if (env) { - // Extract user-configurable values (skip CCS-internal values) - const userEnv: Record = {}; - 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 { } } - // 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 { diff --git a/src/config/unified-config-loader.ts b/src/config/unified-config-loader.ts index 458ed993..505928f3 100644 --- a/src/config/unified-config-loader.ts +++ b/src/config/unified-config-loader.ts @@ -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 , ccs api list, ccs api remove '); - 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() diff --git a/src/config/unified-config-types.ts b/src/config/unified-config-types.ts index e62c4ffb..e41cc4a1 100644 --- a/src/config/unified-config-types.ts +++ b/src/config/unified-config-types.ts @@ -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; + /** Path to settings file (e.g., "~/.ccs/glm.settings.json") */ + settings: string; } /** @@ -46,16 +49,17 @@ export type OAuthAccounts = Record; /** * 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; + /** Path to settings file (e.g., "~/.ccs/gemini-custom.settings.json") */ + settings?: string; } /**