From b621b8e47bc63f2939b45a243173ce6b414a3ec2 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Wed, 10 Dec 2025 17:42:40 -0500 Subject: [PATCH 01/17] feat(config): add unified YAML config with migration support Implements unified config.yaml format consolidating: - config.json (API profiles) - profiles.json (account metadata) - *.settings.json (env vars) Features: - Auto-migration from v1 JSON to v2 YAML with backup - Secrets separation into secrets.yaml (chmod 600) - Enhanced YAML output with section comments - CLIProxy OAuth settings migration with env var support - Feature flag for gradual rollout (CCS_UNIFIED_CONFIG=1) - Rollback support via ccs migrate --rollback New commands: - ccs migrate - Run migration - ccs migrate --dry-run - Preview changes - ccs migrate --rollback - Restore from backup - ccs migrate --list-backups - List available backups Closes #75 --- bun.lock | 4 + package.json | 2 + src/auth/auth-commands.ts | 67 ++++- src/auth/profile-detector.ts | 154 +++++++++- src/auth/profile-registry.ts | 102 +++++++ src/ccs.ts | 18 ++ src/commands/api-command.ts | 251 +++++++++++++--- src/commands/cliproxy-command.ts | 254 ++++++++++++----- src/commands/migrate-command.ts | 160 +++++++++++ src/config/feature-flags.ts | 27 ++ src/config/index.ts | 21 ++ src/config/migration-manager.ts | 424 ++++++++++++++++++++++++++++ src/config/reserved-names.ts | 40 +++ src/config/secrets-manager.ts | 187 ++++++++++++ src/config/unified-config-loader.ts | 260 +++++++++++++++++ src/config/unified-config-types.ts | 170 +++++++++++ src/utils/sensitive-keys.ts | 64 +++++ src/web-server/routes.ts | 138 ++++++++- tests/unit/unified-config.test.ts | 201 +++++++++++++ ui/src/hooks/use-unified-config.ts | 127 +++++++++ ui/src/lib/api-client.ts | 45 +++ 21 files changed, 2575 insertions(+), 141 deletions(-) create mode 100644 src/commands/migrate-command.ts create mode 100644 src/config/feature-flags.ts create mode 100644 src/config/index.ts create mode 100644 src/config/migration-manager.ts create mode 100644 src/config/reserved-names.ts create mode 100644 src/config/secrets-manager.ts create mode 100644 src/config/unified-config-loader.ts create mode 100644 src/config/unified-config-types.ts create mode 100644 src/utils/sensitive-keys.ts create mode 100644 tests/unit/unified-config.test.ts create mode 100644 ui/src/hooks/use-unified-config.ts diff --git a/bun.lock b/bun.lock index 3e11076c..32d31864 100644 --- a/bun.lock +++ b/bun.lock @@ -11,6 +11,7 @@ "express": "^4.18.2", "get-port": "^7.0.0", "gradient-string": "^3.0.0", + "js-yaml": "^4.1.1", "listr2": "^9.0.5", "open": "^10.1.0", "ora": "^9.0.0", @@ -24,6 +25,7 @@ "@tailwindcss/vite": "^4.1.17", "@types/chokidar": "^2.1.7", "@types/express": "^4.17.21", + "@types/js-yaml": "^4.0.9", "@types/node": "^20.19.25", "@types/ws": "^8.5.10", "@typescript-eslint/eslint-plugin": "^8.48.0", @@ -365,6 +367,8 @@ "@types/http-errors": ["@types/http-errors@2.0.5", "", {}, "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg=="], + "@types/js-yaml": ["@types/js-yaml@4.0.9", "", {}, "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg=="], + "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], "@types/mime": ["@types/mime@1.3.5", "", {}, "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w=="], diff --git a/package.json b/package.json index 31aac806..e3e74e1a 100644 --- a/package.json +++ b/package.json @@ -87,6 +87,7 @@ "express": "^4.18.2", "get-port": "^7.0.0", "gradient-string": "^3.0.0", + "js-yaml": "^4.1.1", "listr2": "^9.0.5", "open": "^10.1.0", "ora": "^9.0.0", @@ -100,6 +101,7 @@ "@tailwindcss/vite": "^4.1.17", "@types/chokidar": "^2.1.7", "@types/express": "^4.17.21", + "@types/js-yaml": "^4.0.9", "@types/node": "^20.19.25", "@types/ws": "^8.5.10", "@typescript-eslint/eslint-plugin": "^8.48.0", diff --git a/src/auth/auth-commands.ts b/src/auth/auth-commands.ts index 2a1d4168..184ace50 100644 --- a/src/auth/auth-commands.ts +++ b/src/auth/auth-commands.ts @@ -6,6 +6,10 @@ * * Login-per-profile model: Each profile is an isolated Claude instance. * Users login directly in each instance (no credential copying). + * + * Supports dual-mode configuration: + * - Unified YAML format (config.yaml) when CCS_UNIFIED_CONFIG=1 or config.yaml exists + * - Legacy JSON format (profiles.json) as fallback */ import { spawn, ChildProcess } from 'child_process'; @@ -29,6 +33,8 @@ import { import { detectClaudeCli } from '../utils/claude-detector'; import { InteractivePrompt } from '../utils/prompt'; import packageJson from '../../package.json'; +import { hasUnifiedConfig } from '../config/unified-config-loader'; +import { isUnifiedConfigEnabled } from '../config/feature-flags'; interface AuthCommandArgs { profileName?: string; @@ -66,6 +72,13 @@ class AuthCommands { this.instanceMgr = new InstanceManager(); } + /** + * Check if unified config mode is active + */ + private isUnifiedMode(): boolean { + return hasUnifiedConfig() || isUnifiedConfigEnabled(); + } + /** * Show help for auth commands */ @@ -152,8 +165,10 @@ class AuthCommands { process.exit(1); } - // Check if profile already exists - if (!force && this.registry.hasProfile(profileName)) { + // Check if profile already exists (check both legacy and unified) + const existsLegacy = this.registry.hasProfile(profileName); + const existsUnified = this.registry.hasAccountUnified(profileName); + if (!force && (existsLegacy || existsUnified)) { console.log(fail(`Profile already exists: ${profileName}`)); console.log(` Use ${color('--force', 'command')} to overwrite`); process.exit(1); @@ -164,15 +179,25 @@ class AuthCommands { console.log(info(`Creating profile: ${profileName}`)); const instancePath = this.instanceMgr.ensureInstance(profileName); - // Create/update profile entry - if (this.registry.hasProfile(profileName)) { - this.registry.updateProfile(profileName, { - type: 'account', - }); + // Create/update profile entry based on config mode + if (this.isUnifiedMode()) { + // Use unified config (config.yaml) + if (existsUnified) { + this.registry.touchAccountUnified(profileName); + } else { + this.registry.createAccountUnified(profileName); + } } else { - this.registry.createProfile(profileName, { - type: 'account', - }); + // Use legacy profiles.json + if (existsLegacy) { + this.registry.updateProfile(profileName, { + type: 'account', + }); + } else { + this.registry.createProfile(profileName, { + type: 'account', + }); + } } console.log(info(`Instance directory: ${instancePath}`)); @@ -458,7 +483,11 @@ class AuthCommands { process.exit(1); } - if (!this.registry.hasProfile(profileName)) { + // Check existence in both legacy and unified + const existsLegacy = this.registry.hasProfile(profileName); + const existsUnified = this.registry.hasAccountUnified(profileName); + + if (!existsLegacy && !existsUnified) { console.log(fail(`Profile not found: ${profileName}`)); process.exit(1); } @@ -501,8 +530,13 @@ class AuthCommands { // Delete instance this.instanceMgr.deleteInstance(profileName); - // Delete profile - this.registry.deleteProfile(profileName); + // Delete profile from appropriate config + if (this.isUnifiedMode() && existsUnified) { + this.registry.removeAccountUnified(profileName); + } + if (existsLegacy) { + this.registry.deleteProfile(profileName); + } console.log(ok(`Profile removed: ${profileName}`)); console.log(''); @@ -527,7 +561,12 @@ class AuthCommands { } try { - this.registry.setDefaultProfile(profileName); + // Use unified or legacy based on config mode + if (this.isUnifiedMode()) { + this.registry.setDefaultUnified(profileName); + } else { + this.registry.setDefaultProfile(profileName); + } console.log(ok(`Default profile set: ${profileName}`)); console.log(''); diff --git a/src/auth/profile-detector.ts b/src/auth/profile-detector.ts index eab38008..225f83bc 100644 --- a/src/auth/profile-detector.ts +++ b/src/auth/profile-detector.ts @@ -3,6 +3,10 @@ * * Determines profile type (settings-based vs account-based) for routing. * Priority: settings-based profiles (glm/kimi) checked FIRST for backward compatibility. + * + * Supports dual-mode configuration: + * - Unified YAML format (config.yaml) when CCS_UNIFIED_CONFIG=1 or config.yaml exists + * - Legacy JSON format (config.json, profiles.json) as fallback */ import * as fs from 'fs'; @@ -10,6 +14,10 @@ import * as path from 'path'; import * as os from 'os'; import { findSimilarStrings } from '../utils/helpers'; import { Config, Settings, ProfileMetadata } from '../types'; +import { UnifiedConfig } from '../config/unified-config-types'; +import { hasUnifiedConfig, loadUnifiedConfig } from '../config/unified-config-loader'; +import { getProfileSecrets } from '../config/secrets-manager'; +import { isUnifiedConfigEnabled } from '../config/feature-flags'; export type ProfileType = 'settings' | 'account' | 'cliproxy' | 'default'; @@ -25,6 +33,8 @@ export interface ProfileDetectionResult { message?: string; /** For cliproxy variants: the underlying provider (gemini, codex, agy, qwen) */ provider?: CLIProxyProfileName; + /** For unified config profiles: merged env vars (config + secrets) */ + env?: Record; } export interface AllProfiles { @@ -51,6 +61,70 @@ class ProfileDetector { this.profilesPath = path.join(os.homedir(), '.ccs', 'profiles.json'); } + /** + * Check if unified config mode is active. + * Returns true if config.yaml exists or CCS_UNIFIED_CONFIG=1. + */ + private isUnifiedMode(): boolean { + return hasUnifiedConfig() || isUnifiedConfigEnabled(); + } + + /** + * Load unified config if available. + * Returns null if not in unified mode or load fails. + */ + private readUnifiedConfig(): UnifiedConfig | null { + if (!this.isUnifiedMode()) return null; + return loadUnifiedConfig(); + } + + /** + * Resolve profile from unified config format. + * Returns null if profile not found in unified config. + */ + private resolveFromUnifiedConfig( + profileName: string, + config: UnifiedConfig + ): ProfileDetectionResult | null { + // Check CLIProxy variants first + if (config.cliproxy?.variants?.[profileName]) { + const variant = config.cliproxy.variants[profileName]; + return { + type: 'cliproxy', + name: profileName, + provider: variant.provider as CLIProxyProfileName, + }; + } + + // Check API profiles + if (config.profiles?.[profileName]) { + const profile = config.profiles[profileName]; + // Merge with secrets + const secrets = getProfileSecrets(profileName); + return { + type: 'settings', + name: profileName, + env: { ...profile.env, ...secrets }, + }; + } + + // Check accounts + if (config.accounts?.[profileName]) { + const account = config.accounts[profileName]; + return { + type: 'account', + name: profileName, + profile: { + type: 'account', + created: account.created, + last_used: account.last_used, + }, + }; + } + + return null; + } + /** * Read settings-based config (config.json) */ @@ -90,9 +164,10 @@ class ProfileDetector { * * Priority order: * 0. Hardcoded CLIProxy profiles (gemini, codex, agy, qwen) - * 1. User-defined CLIProxy variants (config.cliproxy section) - * 2. Settings-based profiles (config.profiles section) - * 3. Account-based profiles (profiles.json) + * 1. Unified config profiles (if config.yaml exists or CCS_UNIFIED_CONFIG=1) + * 2. User-defined CLIProxy variants (config.cliproxy section) [legacy] + * 3. Settings-based profiles (config.profiles section) [legacy] + * 4. Account-based profiles (profiles.json) [legacy] */ detectProfileType(profileName: string | null | undefined): ProfileDetectionResult { // Special case: 'default' means use default profile @@ -109,7 +184,15 @@ class ProfileDetector { }; } - // Priority 1: Check user-defined CLIProxy variants (config.cliproxy section) + // Priority 1: Try unified config if available + const unifiedConfig = this.readUnifiedConfig(); + if (unifiedConfig) { + const result = this.resolveFromUnifiedConfig(profileName, unifiedConfig); + if (result) return result; + // Fall through to legacy if not found in unified config + } + + // Priority 2: Check user-defined CLIProxy variants (config.cliproxy section) const config = this.readConfig(); if (config.cliproxy && config.cliproxy[profileName]) { @@ -122,7 +205,7 @@ class ProfileDetector { }; } - // Priority 2: Check settings-based profiles (glm, kimi) - BACKWARD COMPATIBILITY + // Priority 3: Check settings-based profiles (glm, kimi) - LEGACY FALLBACK if (config.profiles && config.profiles[profileName]) { return { type: 'settings', @@ -131,7 +214,7 @@ class ProfileDetector { }; } - // Priority 3: Check account-based profiles (work, personal) + // Priority 4: Check account-based profiles (work, personal) - LEGACY FALLBACK const profiles = this.readProfiles(); if (profiles.profiles && profiles.profiles[profileName]) { @@ -163,7 +246,14 @@ class ProfileDetector { * Resolve default profile */ private resolveDefaultProfile(): ProfileDetectionResult { - // Check if account-based default exists + // Check unified config first + const unifiedConfig = this.readUnifiedConfig(); + if (unifiedConfig?.default) { + const result = this.resolveFromUnifiedConfig(unifiedConfig.default, unifiedConfig); + if (result) return result; + } + + // Check if account-based default exists (legacy) const profiles = this.readProfiles(); if (profiles.default && profiles.profiles[profiles.default]) { @@ -216,6 +306,43 @@ class ProfileDetector { lines.push(` - ${name}`); }); + // Check unified config first + const unifiedConfig = this.readUnifiedConfig(); + if (unifiedConfig) { + // CLIProxy variants from unified config + const variants = Object.keys(unifiedConfig.cliproxy?.variants || {}); + if (variants.length > 0) { + lines.push('CLIProxy variants (unified config):'); + variants.forEach((name) => { + const variant = unifiedConfig.cliproxy?.variants[name]; + lines.push(` - ${name} (${variant?.provider || 'unknown'})`); + }); + } + + // API profiles from unified config + const apiProfiles = Object.keys(unifiedConfig.profiles || {}); + if (apiProfiles.length > 0) { + lines.push('API profiles (unified config):'); + apiProfiles.forEach((name) => { + const isDefault = name === unifiedConfig.default; + lines.push(` - ${name}${isDefault ? ' [DEFAULT]' : ''}`); + }); + } + + // Account profiles from unified config + const accounts = Object.keys(unifiedConfig.accounts || {}); + if (accounts.length > 0) { + lines.push('Account profiles (unified config):'); + accounts.forEach((name) => { + const isDefault = name === unifiedConfig.default; + lines.push(` - ${name}${isDefault ? ' [DEFAULT]' : ''}`); + }); + } + + return lines.join('\n'); + } + + // Fall back to legacy config display // CLIProxy variants (user-defined) const config = this.readConfig(); const cliproxyVariants = Object.keys(config.cliproxy || {}); @@ -270,6 +397,19 @@ class ProfileDetector { * Get all available profile names */ getAllProfiles(): AllProfiles & { cliproxy: string[]; cliproxyVariants: string[] } { + // Check unified config first + const unifiedConfig = this.readUnifiedConfig(); + if (unifiedConfig) { + return { + settings: Object.keys(unifiedConfig.profiles || {}), + accounts: Object.keys(unifiedConfig.accounts || {}), + cliproxy: [...CLIPROXY_PROFILES], + cliproxyVariants: Object.keys(unifiedConfig.cliproxy?.variants || {}), + default: unifiedConfig.default, + }; + } + + // Fall back to legacy config const config = this.readConfig(); const profiles = this.readProfiles(); diff --git a/src/auth/profile-registry.ts b/src/auth/profile-registry.ts index df7dd3ec..6d05ce2a 100644 --- a/src/auth/profile-registry.ts +++ b/src/auth/profile-registry.ts @@ -2,6 +2,12 @@ import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; import { ProfileMetadata } from '../types'; +import { + hasUnifiedConfig, + loadOrCreateUnifiedConfig, + saveUnifiedConfig, +} from '../config/unified-config-loader'; +import { isUnifiedConfigEnabled } from '../config/feature-flags'; /** * Profile Registry (Simplified) @@ -41,6 +47,13 @@ export class ProfileRegistry { this.profilesPath = path.join(os.homedir(), '.ccs', 'profiles.json'); } + /** + * Check if unified config mode is active + */ + private isUnifiedMode(): boolean { + return hasUnifiedConfig() || isUnifiedConfigEnabled(); + } + /** * Read profiles from disk */ @@ -220,6 +233,95 @@ export class ProfileRegistry { last_used: new Date().toISOString(), }); } + + // ========================================== + // Unified Config Methods + // ========================================== + + /** + * Create account in unified config (config.yaml) + */ + createAccountUnified(name: string): void { + const config = loadOrCreateUnifiedConfig(); + if (config.accounts[name]) { + throw new Error(`Account already exists: ${name}`); + } + config.accounts[name] = { + created: new Date().toISOString(), + last_used: null, + }; + saveUnifiedConfig(config); + } + + /** + * Remove account from unified config + */ + removeAccountUnified(name: string): void { + const config = loadOrCreateUnifiedConfig(); + if (!config.accounts[name]) { + throw new Error(`Account not found: ${name}`); + } + delete config.accounts[name]; + // Clear default if it was the deleted account + if (config.default === name) { + config.default = undefined; + } + saveUnifiedConfig(config); + } + + /** + * Set default profile in unified config + */ + setDefaultUnified(name: string): void { + const config = loadOrCreateUnifiedConfig(); + // Check if exists in accounts, profiles, or cliproxy variants + const exists = + config.accounts[name] || config.profiles[name] || config.cliproxy?.variants?.[name]; + if (!exists) { + throw new Error(`Profile not found: ${name}`); + } + config.default = name; + saveUnifiedConfig(config); + } + + /** + * Check if account exists in unified config + */ + hasAccountUnified(name: string): boolean { + if (!this.isUnifiedMode()) return false; + const config = loadOrCreateUnifiedConfig(); + return !!config.accounts[name]; + } + + /** + * Get all accounts from unified config + */ + getAllAccountsUnified(): Record { + if (!this.isUnifiedMode()) return {}; + const config = loadOrCreateUnifiedConfig(); + return config.accounts; + } + + /** + * Get default from unified config + */ + getDefaultUnified(): string | undefined { + if (!this.isUnifiedMode()) return undefined; + const config = loadOrCreateUnifiedConfig(); + return config.default; + } + + /** + * Update account last_used in unified config + */ + touchAccountUnified(name: string): void { + const config = loadOrCreateUnifiedConfig(); + if (!config.accounts[name]) { + throw new Error(`Account not found: ${name}`); + } + config.accounts[name].last_used = new Date().toISOString(); + saveUnifiedConfig(config); + } } export default ProfileRegistry; diff --git a/src/ccs.ts b/src/ccs.ts index 9fa92ef4..c5ad3405 100644 --- a/src/ccs.ts +++ b/src/ccs.ts @@ -202,6 +202,10 @@ interface ProfileError extends Error { async function main(): Promise { const args = process.argv.slice(2); + // Auto-migrate to unified config format (silent if already migrated) + const { autoMigrate } = await import('./config/migration-manager'); + await autoMigrate(); + // Special case: version command (check BEFORE profile detection) const firstArg = args[0]; if (firstArg === 'version' || firstArg === '--version' || firstArg === '-v') { @@ -245,6 +249,20 @@ async function main(): Promise { return; } + // Special case: migrate command + if (firstArg === 'migrate' || firstArg === '--migrate') { + const { handleMigrateCommand, printMigrateHelp } = await import('./commands/migrate-command'); + const migrateArgs = args.slice(1); + + if (migrateArgs.includes('--help') || migrateArgs.includes('-h')) { + printMigrateHelp(); + return; + } + + await handleMigrateCommand(migrateArgs); + return; + } + // Special case: update command if (firstArg === 'update' || firstArg === '--update') { const updateArgs = args.slice(1); diff --git a/src/commands/api-command.ts b/src/commands/api-command.ts index a0c605b8..0f581194 100644 --- a/src/commands/api-command.ts +++ b/src/commands/api-command.ts @@ -3,6 +3,10 @@ * * Manages CCS API profiles for custom API providers. * Commands: create, list, remove + * + * Supports dual-mode configuration: + * - Unified YAML format (config.yaml) when CCS_UNIFIED_CONFIG=1 or config.yaml exists + * - Legacy JSON format (config.json, *.settings.json) as fallback */ import * as fs from 'fs'; @@ -22,6 +26,14 @@ import { } from '../utils/ui'; import { InteractivePrompt } from '../utils/prompt'; import { getCcsDir, getConfigPath, loadConfig } from '../utils/config-manager'; +import { isReservedName } from '../config/reserved-names'; +import { + hasUnifiedConfig, + loadOrCreateUnifiedConfig, + saveUnifiedConfig, +} from '../config/unified-config-loader'; +import { setProfileSecrets, deleteAllProfileSecrets } from '../config/secrets-manager'; +import { isUnifiedConfigEnabled } from '../config/feature-flags'; interface ApiCommandArgs { name?: string; @@ -72,9 +84,8 @@ function validateApiName(name: string): string | null { if (name.length > 32) { return 'API name must be 32 characters or less'; } - // Reserved names - const reserved = ['default', 'auth', 'api', 'doctor', 'sync', 'update', 'help', 'version']; - if (reserved.includes(name.toLowerCase())) { + // Use centralized reserved names list + if (isReservedName(name)) { return `'${name}' is a reserved name`; } return null; @@ -96,10 +107,21 @@ function validateUrl(url: string): string | null { } /** - * Check if API profile already exists in config.json + * Check if unified config mode is active + */ +function isUnifiedMode(): boolean { + return hasUnifiedConfig() || isUnifiedConfigEnabled(); +} + +/** + * Check if API profile already exists in config */ function apiExists(name: string): boolean { try { + if (isUnifiedMode()) { + const config = loadOrCreateUnifiedConfig(); + return name in config.profiles; + } const config = loadConfig(); return name in config.profiles; } catch { @@ -160,6 +182,60 @@ function updateConfig(name: string, _settingsPath: string): void { fs.renameSync(tempPath, configPath); } +/** + * Create API profile in unified config (config.yaml + secrets.yaml) + */ +function createApiProfileUnified( + name: string, + baseUrl: string, + apiKey: string, + model: string +): void { + const config = loadOrCreateUnifiedConfig(); + + // Add profile to config.yaml (non-sensitive data only) + config.profiles[name] = { + type: 'api', + env: { + ANTHROPIC_BASE_URL: baseUrl, + ANTHROPIC_MODEL: model, + ANTHROPIC_DEFAULT_OPUS_MODEL: model, + ANTHROPIC_DEFAULT_SONNET_MODEL: model, + ANTHROPIC_DEFAULT_HAIKU_MODEL: model, + }, + }; + + saveUnifiedConfig(config); + + // Store API key in secrets.yaml (sensitive data) + setProfileSecrets(name, { + ANTHROPIC_AUTH_TOKEN: apiKey, + }); +} + +/** + * Remove API profile from unified config + */ +function removeApiProfileUnified(name: string): void { + const config = loadOrCreateUnifiedConfig(); + + if (!config.profiles[name]) { + throw new Error(`API profile not found: ${name}`); + } + + delete config.profiles[name]; + + // Clear default if it was the deleted profile + if (config.default === name) { + config.default = undefined; + } + + saveUnifiedConfig(config); + + // Remove secrets + deleteAllProfileSecrets(name); +} + /** * Handle 'ccs api create' command */ @@ -230,19 +306,35 @@ async function handleCreate(args: string[]): Promise { console.log(info('Creating API profile...')); try { - const settingsPath = createSettingsFile(name, baseUrl, apiKey, model); - updateConfig(name, settingsPath); - - console.log(''); - console.log( - infoBox( - `API: ${name}\n` + - `Settings: ~/.ccs/${name}.settings.json\n` + - `Base URL: ${baseUrl}\n` + - `Model: ${model}`, - 'API Profile Created' - ) - ); + if (isUnifiedMode()) { + // Use unified config format + createApiProfileUnified(name, baseUrl, apiKey, model); + console.log(''); + console.log( + infoBox( + `API: ${name}\n` + + `Config: ~/.ccs/config.yaml\n` + + `Secrets: ~/.ccs/secrets.yaml\n` + + `Base URL: ${baseUrl}\n` + + `Model: ${model}`, + 'API Profile Created (Unified Config)' + ) + ); + } else { + // Use legacy JSON format + const settingsPath = createSettingsFile(name, baseUrl, apiKey, model); + updateConfig(name, settingsPath); + console.log(''); + console.log( + infoBox( + `API: ${name}\n` + + `Settings: ~/.ccs/${name}.settings.json\n` + + `Base URL: ${baseUrl}\n` + + `Model: ${model}`, + 'API Profile Created' + ) + ); + } console.log(''); console.log(header('Usage')); console.log(` ${color(`ccs ${name} "your prompt"`, 'command')}`); @@ -258,6 +350,14 @@ async function handleCreate(args: string[]): Promise { */ function isApiConfigured(apiName: string): boolean { try { + if (isUnifiedMode()) { + // Check secrets.yaml for unified config + const { getProfileSecrets } = require('../config/secrets-manager'); + const secrets = getProfileSecrets(apiName); + const token = secrets?.ANTHROPIC_AUTH_TOKEN || ''; + return token.length > 0 && !token.includes('YOUR_') && !token.includes('your-'); + } + // Legacy: check settings.json file const ccsDir = getCcsDir(); const settingsPath = path.join(ccsDir, `${apiName}.settings.json`); if (!fs.existsSync(settingsPath)) return false; @@ -281,6 +381,59 @@ async function handleList(): Promise { console.log(''); try { + if (isUnifiedMode()) { + // List from unified config + const unifiedConfig = loadOrCreateUnifiedConfig(); + const apis = Object.keys(unifiedConfig.profiles); + + if (apis.length === 0) { + console.log(warn('No API profiles configured')); + console.log(''); + console.log('To create an API profile:'); + console.log(` ${color('ccs api create', 'command')}`); + console.log(''); + return; + } + + // Build table data with status indicators + const rows: string[][] = apis.map((name) => { + const status = isApiConfigured(name) ? color('[OK]', 'success') : color('[!]', 'warning'); + return [name, 'config.yaml', status]; + }); + + // Print table + console.log( + table(rows, { + head: ['API', 'Config', 'Status'], + colWidths: [15, 20, 10], + }) + ); + console.log(''); + + // Show CLIProxy variants if any + const variants = Object.keys(unifiedConfig.cliproxy?.variants || {}); + if (variants.length > 0) { + console.log(subheader('CLIProxy Variants')); + const cliproxyRows = variants.map((name) => { + const variant = unifiedConfig.cliproxy?.variants[name]; + return [name, variant?.provider || 'unknown', variant?.model || '-']; + }); + + console.log( + table(cliproxyRows, { + head: ['Variant', 'Provider', 'Model'], + colWidths: [15, 15, 20], + }) + ); + console.log(''); + } + + console.log(dim(`Total: ${apis.length} API profile(s)`)); + console.log(''); + return; + } + + // Legacy: list from config.json const config = loadConfig(); const apis = Object.keys(config.profiles); @@ -342,16 +495,16 @@ async function handleRemove(args: string[]): Promise { await initUI(); const parsedArgs = parseArgs(args); - // Load config first to get available APIs - let config: { profiles: Record; cliproxy?: Record }; - try { - config = loadConfig(); - } catch { - console.log(fail('Failed to load config')); - process.exit(1); + // Get available APIs based on config mode + let apis: string[]; + if (isUnifiedMode()) { + const unifiedConfig = loadOrCreateUnifiedConfig(); + apis = Object.keys(unifiedConfig.profiles); + } else { + const config = loadConfig(); + apis = Object.keys(config.profiles); } - const apis = Object.keys(config.profiles); if (apis.length === 0) { console.log(warn('No API profiles to remove')); process.exit(0); @@ -375,7 +528,7 @@ async function handleRemove(args: string[]): Promise { }); } - if (!(name in config.profiles)) { + if (!apis.includes(name)) { console.log(fail(`API '${name}' not found`)); console.log(''); console.log('Available APIs:'); @@ -383,13 +536,15 @@ async function handleRemove(args: string[]): Promise { process.exit(1); } - const settingsPath = config.profiles[name]; - const expandedPath = path.join(getCcsDir(), `${name}.settings.json`); - // Confirm deletion console.log(''); console.log(`API '${color(name, 'command')}' will be removed.`); - console.log(` Settings: ${settingsPath}`); + if (isUnifiedMode()) { + console.log(' Config: ~/.ccs/config.yaml'); + console.log(' Secrets: ~/.ccs/secrets.yaml'); + } else { + console.log(` Settings: ~/.ccs/${name}.settings.json`); + } console.log(''); const confirmed = @@ -401,20 +556,32 @@ async function handleRemove(args: string[]): Promise { process.exit(0); } - // Remove from config.json - delete config.profiles[name]; - const configPath = getConfigPath(); - const tempPath = configPath + '.tmp'; - fs.writeFileSync(tempPath, JSON.stringify(config, null, 2) + '\n', 'utf8'); - fs.renameSync(tempPath, configPath); + try { + if (isUnifiedMode()) { + // Remove from unified config + removeApiProfileUnified(name); + } else { + // Remove from legacy config.json + const config = loadConfig(); + delete config.profiles[name]; + const configPath = getConfigPath(); + const tempPath = configPath + '.tmp'; + fs.writeFileSync(tempPath, JSON.stringify(config, null, 2) + '\n', 'utf8'); + fs.renameSync(tempPath, configPath); - // Remove settings file if it exists - if (fs.existsSync(expandedPath)) { - fs.unlinkSync(expandedPath); + // Remove settings file if it exists + const expandedPath = path.join(getCcsDir(), `${name}.settings.json`); + if (fs.existsSync(expandedPath)) { + fs.unlinkSync(expandedPath); + } + } + + console.log(ok(`API profile removed: ${name}`)); + console.log(''); + } catch (error) { + console.log(fail(`Failed to remove API profile: ${(error as Error).message}`)); + process.exit(1); } - - console.log(ok(`API profile removed: ${name}`)); - console.log(''); } /** diff --git a/src/commands/cliproxy-command.ts b/src/commands/cliproxy-command.ts index 76f2d2b7..a381e021 100644 --- a/src/commands/cliproxy-command.ts +++ b/src/commands/cliproxy-command.ts @@ -12,6 +12,10 @@ * ccs cliproxy create Create CLIProxy variant profile * ccs cliproxy list List all CLIProxy variant profiles * ccs cliproxy remove Remove CLIProxy variant profile + * + * Supports dual-mode configuration: + * - Unified YAML format (config.yaml) when CCS_UNIFIED_CONFIG=1 or config.yaml exists + * - Legacy JSON format (config.json) as fallback */ import * as fs from 'fs'; @@ -45,6 +49,13 @@ import { infoBox, } from '../utils/ui'; import { InteractivePrompt } from '../utils/prompt'; +import { isReservedName } from '../config/reserved-names'; +import { + hasUnifiedConfig, + loadOrCreateUnifiedConfig, + saveUnifiedConfig, +} from '../config/unified-config-loader'; +import { isUnifiedConfigEnabled } from '../config/feature-flags'; // ============================================================================ // PROFILE MANAGEMENT @@ -99,33 +110,29 @@ function validateProfileName(name: string): string | null { if (name.length > 32) { return 'Name must be 32 characters or less'; } - // Reserved names - includes built-in cliproxy profiles - const reserved = [ - 'default', - 'auth', - 'api', - 'doctor', - 'sync', - 'update', - 'help', - 'version', - 'cliproxy', - 'create', - 'list', - 'remove', - ...CLIPROXY_PROFILES, - ]; - if (reserved.includes(name.toLowerCase())) { + // Use centralized reserved names list (includes built-in cliproxy profiles) + if (isReservedName(name)) { return `'${name}' is a reserved name`; } return null; } +/** + * Check if unified config mode is active + */ +function isUnifiedMode(): boolean { + return hasUnifiedConfig() || isUnifiedConfigEnabled(); +} + /** * Check if CLIProxy variant profile exists */ function cliproxyVariantExists(name: string): boolean { try { + if (isUnifiedMode()) { + const config = loadOrCreateUnifiedConfig(); + return !!(config.cliproxy?.variants && name in config.cliproxy.variants); + } const config = loadConfig(); return !!(config.cliproxy && name in config.cliproxy); } catch { @@ -245,6 +252,57 @@ function removeCliproxyVariant(name: string): { provider: string; settings: stri return variant; } +/** + * Add CLIProxy variant to unified config (config.yaml) + */ +function addCliproxyVariantUnified( + name: string, + provider: CLIProxyProfileName, + model: string, + account?: string +): void { + const config = loadOrCreateUnifiedConfig(); + + // Ensure cliproxy.variants section exists (preserving other fields) + if (!config.cliproxy) { + config.cliproxy = { + oauth_accounts: {}, + providers: ['gemini', 'codex', 'agy', 'qwen', 'iflow'], + variants: {}, + }; + } + if (!config.cliproxy.variants) { + config.cliproxy.variants = {}; + } + + // Add variant + config.cliproxy.variants[name] = { + provider: provider as CLIProxyProvider, + model, + account, + }; + + saveUnifiedConfig(config); +} + +/** + * Remove CLIProxy variant from unified config + */ +function removeCliproxyVariantUnified(name: string): { provider: string; model?: string } | null { + const config = loadOrCreateUnifiedConfig(); + + if (!config.cliproxy?.variants || !(name in config.cliproxy.variants)) { + return null; + } + + const variant = config.cliproxy.variants[name]; + delete config.cliproxy.variants[name]; + + saveUnifiedConfig(config); + + return { provider: variant.provider, model: variant.model }; +} + /** * Format model entry for display */ @@ -432,20 +490,38 @@ async function handleCreate(args: string[]): Promise { console.log(info('Creating CLIProxy variant...')); try { - const settingsPath = createCliproxySettingsFile(name, provider, model, account); - addCliproxyVariant(name, provider, settingsPath, account); + if (isUnifiedMode()) { + // Use unified config format (no settings file needed - env vars derived at runtime) + addCliproxyVariantUnified(name, provider, model, account); - console.log(''); - console.log( - infoBox( - `Variant: ${name}\n` + - `Provider: ${provider}\n` + - `Model: ${model}\n` + - (account ? `Account: ${account}\n` : '') + - `Settings: ~/.ccs/${path.basename(settingsPath)}`, - 'CLIProxy Variant Created' - ) - ); + console.log(''); + console.log( + infoBox( + `Variant: ${name}\n` + + `Provider: ${provider}\n` + + `Model: ${model}\n` + + (account ? `Account: ${account}\n` : '') + + `Config: ~/.ccs/config.yaml`, + 'CLIProxy Variant Created (Unified Config)' + ) + ); + } else { + // Legacy: create settings.json file + const settingsPath = createCliproxySettingsFile(name, provider, model, account); + addCliproxyVariant(name, provider, settingsPath, account); + + console.log(''); + console.log( + infoBox( + `Variant: ${name}\n` + + `Provider: ${provider}\n` + + `Model: ${model}\n` + + (account ? `Account: ${account}\n` : '') + + `Settings: ~/.ccs/${path.basename(settingsPath)}`, + 'CLIProxy Variant Created' + ) + ); + } console.log(''); console.log(header('Usage')); console.log(` ${color(`ccs ${name} "your prompt"`, 'command')}`); @@ -490,24 +566,47 @@ async function handleList(): Promise { console.log(dim(' To logout: ccs --logout')); console.log(''); - // Show custom variants if any - const config = loadConfig(); - const variants = config.cliproxy || {}; - const variantNames = Object.keys(variants); + // Show custom variants if any (from unified or legacy config) + let variantNames: string[]; + let variantData: Record; + + if (isUnifiedMode()) { + const unifiedConfig = loadOrCreateUnifiedConfig(); + const variants = unifiedConfig.cliproxy?.variants || {}; + variantNames = Object.keys(variants); + variantData = {}; + for (const name of variantNames) { + const v = variants[name]; + variantData[name] = { provider: v.provider, model: v.model }; + } + } else { + const config = loadConfig(); + const variants = config.cliproxy || {}; + variantNames = Object.keys(variants); + variantData = {}; + for (const name of variantNames) { + const v = variants[name] as { provider: string; settings: string }; + variantData[name] = { provider: v.provider, settings: v.settings }; + } + } if (variantNames.length > 0) { console.log(subheader('Custom Variants')); // Build table data const rows: string[][] = variantNames.map((name) => { - const variant = variants[name] as { provider: string; settings: string }; - return [name, variant.provider, variant.settings]; + const variant = variantData[name]; + const thirdCol = variant.model || variant.settings || '-'; + return [name, variant.provider, thirdCol]; }); // Print table + const headLabels = isUnifiedMode() + ? ['Variant', 'Provider', 'Model'] + : ['Variant', 'Provider', 'Settings']; console.log( table(rows, { - head: ['Variant', 'Provider', 'Settings'], + head: headLabels, colWidths: [15, 12, 35], }) ); @@ -532,17 +631,29 @@ async function handleRemove(args: string[]): Promise { await initUI(); const parsedArgs = parseProfileArgs(args); - // Load config to get available variants - let config: { profiles: Record; cliproxy?: Record }; - try { - config = loadConfig(); - } catch { - console.log(fail('Failed to load config')); - process.exit(1); - } + // Get available variants based on config mode + let variantNames: string[]; + let variantData: Record; - const variants = config.cliproxy || {}; - const variantNames = Object.keys(variants); + if (isUnifiedMode()) { + const unifiedConfig = loadOrCreateUnifiedConfig(); + const variants = unifiedConfig.cliproxy?.variants || {}; + variantNames = Object.keys(variants); + variantData = {}; + for (const name of variantNames) { + const v = variants[name]; + variantData[name] = { provider: v.provider, model: v.model }; + } + } else { + const config = loadConfig(); + const variants = config.cliproxy || {}; + variantNames = Object.keys(variants); + variantData = {}; + for (const name of variantNames) { + const v = variants[name] as { provider: string; settings: string }; + variantData[name] = { provider: v.provider, settings: v.settings }; + } + } if (variantNames.length === 0) { console.log(warn('No CLIProxy variants to remove')); @@ -556,7 +667,7 @@ async function handleRemove(args: string[]): Promise { console.log(''); console.log('Available variants:'); variantNames.forEach((n, i) => { - const v = variants[n] as { provider: string }; + const v = variantData[n]; console.log(` ${i + 1}. ${n} (${v.provider})`); }); console.log(''); @@ -570,7 +681,7 @@ async function handleRemove(args: string[]): Promise { }); } - if (!(name in variants)) { + if (!variantNames.includes(name)) { console.log(fail(`Variant '${name}' not found`)); console.log(''); console.log('Available variants:'); @@ -578,13 +689,18 @@ async function handleRemove(args: string[]): Promise { process.exit(1); } - const variant = variants[name] as { provider: string; settings: string }; + const variant = variantData[name]; // Confirm deletion console.log(''); console.log(`Variant '${color(name, 'command')}' will be removed.`); console.log(` Provider: ${variant.provider}`); - console.log(` Settings: ${variant.settings}`); + if (isUnifiedMode()) { + console.log(` Model: ${variant.model || '-'}`); + console.log(' Config: ~/.ccs/config.yaml'); + } else { + console.log(` Settings: ${variant.settings}`); + } console.log(''); const confirmed = @@ -595,21 +711,31 @@ async function handleRemove(args: string[]): Promise { process.exit(0); } - // Remove from config - const removed = removeCliproxyVariant(name); - if (!removed) { - console.log(fail('Failed to remove variant from config')); + try { + if (isUnifiedMode()) { + // Remove from unified config + removeCliproxyVariantUnified(name); + } else { + // Remove from legacy config + const removed = removeCliproxyVariant(name); + if (!removed) { + console.log(fail('Failed to remove variant from config')); + process.exit(1); + } + + // Remove settings file if it exists + const settingsFile = removed.settings.replace(/^~/, process.env.HOME || ''); + if (fs.existsSync(settingsFile)) { + fs.unlinkSync(settingsFile); + } + } + + console.log(ok(`Variant removed: ${name}`)); + console.log(''); + } catch (error) { + console.log(fail(`Failed to remove variant: ${(error as Error).message}`)); process.exit(1); } - - // Remove settings file if it exists - const settingsFile = removed.settings.replace(/^~/, process.env.HOME || ''); - if (fs.existsSync(settingsFile)) { - fs.unlinkSync(settingsFile); - } - - console.log(ok(`Variant removed: ${name}`)); - console.log(''); } // ============================================================================ diff --git a/src/commands/migrate-command.ts b/src/commands/migrate-command.ts new file mode 100644 index 00000000..016a1b8e --- /dev/null +++ b/src/commands/migrate-command.ts @@ -0,0 +1,160 @@ +/** + * Migrate Command + * + * CLI command to migrate from v1 (JSON) to v2 (YAML) config format. + * + * Usage: + * ccs migrate - Run migration + * ccs migrate --dry-run - Preview migration without changes + * ccs migrate --rollback - Restore from backup + * ccs migrate --list-backups - List available backups + */ + +import { + migrate, + rollback, + needsMigration, + getBackupDirectories, +} from '../config/migration-manager'; +import { hasUnifiedConfig } from '../config/unified-config-loader'; + +export async function handleMigrateCommand(args: string[]): Promise { + // Handle --list-backups + if (args.includes('--list-backups')) { + listBackups(); + return; + } + + // Handle --rollback + if (args.includes('--rollback')) { + const rollbackIndex = args.indexOf('--rollback'); + const backupPath = args[rollbackIndex + 1]; + + if (!backupPath) { + console.error('[X] Error: --rollback requires backup path'); + console.log('[i] Usage: ccs migrate --rollback '); + console.log('[i] Use --list-backups to see available backups'); + process.exit(1); + } + + await handleRollback(backupPath); + return; + } + + // Check if already migrated + if (hasUnifiedConfig() && !needsMigration()) { + console.log('[i] Already using unified config format (config.yaml)'); + return; + } + + // Check if migration is needed + if (!needsMigration()) { + console.log('[i] No migration needed - no legacy config found'); + return; + } + + // Handle --dry-run + const dryRun = args.includes('--dry-run'); + + if (dryRun) { + console.log('[i] Dry run - no changes will be made'); + console.log(''); + } + + console.log('[i] Migrating to unified config format (v2)...'); + console.log(''); + + const result = await migrate(dryRun); + + if (result.success) { + console.log('[OK] Migration complete'); + console.log(''); + + if (result.backupPath) { + console.log(` Backup created: ${result.backupPath}`); + } + + if (result.migratedFiles.length > 0) { + console.log(''); + console.log(' Migrated:'); + result.migratedFiles.forEach((f) => console.log(` - ${f}`)); + } + + if (result.warnings.length > 0) { + console.log(''); + console.log(' Warnings:'); + result.warnings.forEach((w) => console.log(` [!] ${w}`)); + } + + if (dryRun) { + console.log(''); + console.log('[i] This was a dry run. Run without --dry-run to apply changes.'); + } else { + console.log(''); + console.log('[i] To rollback: ccs migrate --rollback ' + result.backupPath); + } + } else { + console.error(`[X] Migration failed: ${result.error}`); + + if (result.migratedFiles.length > 0) { + console.log(''); + console.log(' Partially migrated:'); + result.migratedFiles.forEach((f) => console.log(` - ${f}`)); + } + + process.exit(1); + } +} + +async function handleRollback(backupPath: string): Promise { + console.log(`[i] Rolling back from: ${backupPath}`); + console.log(''); + + const success = await rollback(backupPath); + + if (success) { + console.log('[OK] Rollback complete'); + console.log('[i] Legacy config restored'); + } else { + console.error('[X] Rollback failed'); + process.exit(1); + } +} + +function listBackups(): void { + const backups = getBackupDirectories(); + + if (backups.length === 0) { + console.log('[i] No backup directories found'); + return; + } + + console.log('[i] Available backups (most recent first):'); + console.log(''); + backups.forEach((backup, index) => { + console.log(` ${index + 1}. ${backup}`); + }); + console.log(''); + console.log('[i] To rollback: ccs migrate --rollback '); +} + +/** + * Print help for migrate command. + */ +export function printMigrateHelp(): void { + console.log('Usage: ccs migrate [options]'); + console.log(''); + console.log('Migrate from legacy JSON config to unified YAML format.'); + console.log(''); + console.log('Options:'); + console.log(' --dry-run Preview migration without making changes'); + console.log(' --rollback PATH Restore from backup directory'); + console.log(' --list-backups List available backup directories'); + console.log(' --help Show this help message'); + console.log(''); + console.log('Examples:'); + console.log(' ccs migrate # Run migration'); + console.log(' ccs migrate --dry-run # Preview changes'); + console.log(' ccs migrate --list-backups # List backups'); + console.log(' ccs migrate --rollback ~/.ccs/backup-v1-2025-01-15'); +} diff --git a/src/config/feature-flags.ts b/src/config/feature-flags.ts new file mode 100644 index 00000000..6e806f32 --- /dev/null +++ b/src/config/feature-flags.ts @@ -0,0 +1,27 @@ +/** + * Feature flags for gradual rollout of new functionality. + */ + +/** + * Check if unified config (YAML) is enabled. + * Set CCS_UNIFIED_CONFIG=1 to enable. + */ +export function isUnifiedConfigEnabled(): boolean { + return process.env.CCS_UNIFIED_CONFIG === '1'; +} + +/** + * Check if migration mode is active. + * Set CCS_MIGRATE=1 to trigger automatic migration. + */ +export function isMigrationEnabled(): boolean { + return process.env.CCS_MIGRATE === '1'; +} + +/** + * Check if debug mode is enabled. + * Set CCS_DEBUG=1 for verbose logging. + */ +export function isDebugEnabled(): boolean { + return process.env.CCS_DEBUG === '1'; +} diff --git a/src/config/index.ts b/src/config/index.ts new file mode 100644 index 00000000..ca503a7c --- /dev/null +++ b/src/config/index.ts @@ -0,0 +1,21 @@ +/** + * Config Module Exports + * + * Central export point for unified config functionality. + */ + +// Types +export * from './unified-config-types'; + +// Feature Flags +export * from './feature-flags'; + +// Reserved Names +export * from './reserved-names'; + +// Loaders +export * from './unified-config-loader'; +export * from './secrets-manager'; + +// Migration +export * from './migration-manager'; diff --git a/src/config/migration-manager.ts b/src/config/migration-manager.ts new file mode 100644 index 00000000..1c259ddf --- /dev/null +++ b/src/config/migration-manager.ts @@ -0,0 +1,424 @@ +/** + * Migration Manager + * + * Handles migration from legacy JSON config (v1) to unified YAML config (v2). + * Features: + * - Automatic backup before migration + * - Rollback support + * - Secret extraction and separation + * - Cache file restructuring + */ + +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 { saveUnifiedConfig, hasUnifiedConfig } from './unified-config-loader'; +import { saveSecrets, isSecretKey } from './secrets-manager'; + +const BACKUP_DIR_PREFIX = 'backup-v1-'; + +/** + * Migration result with details about what was migrated. + */ +export interface MigrationResult { + success: boolean; + backupPath?: string; + error?: string; + migratedFiles: string[]; + warnings: string[]; +} + +/** + * Check if migration from v1 to v2 is needed. + */ +export function needsMigration(): boolean { + const ccsDir = getCcsDir(); + const hasOldConfig = fs.existsSync(path.join(ccsDir, 'config.json')); + const hasNewConfig = hasUnifiedConfig(); + + return hasOldConfig && !hasNewConfig; +} + +/** + * Get list of backup directories. + */ +export function getBackupDirectories(): string[] { + const ccsDir = getCcsDir(); + if (!fs.existsSync(ccsDir)) return []; + + return fs + .readdirSync(ccsDir) + .filter((name) => name.startsWith(BACKUP_DIR_PREFIX)) + .map((name) => path.join(ccsDir, name)) + .sort() + .reverse(); // Most recent first +} + +/** + * Perform migration from v1 to v2 format. + */ +export async function migrate(dryRun = false): Promise { + const ccsDir = getCcsDir(); + const migratedFiles: string[] = []; + const warnings: string[] = []; + + try { + // 1. Create backup + const timestamp = new Date().toISOString().split('T')[0]; + const backupDir = path.join(ccsDir, `${BACKUP_DIR_PREFIX}${timestamp}`); + + if (!dryRun) { + await createBackup(ccsDir, backupDir); + } + + // 2. Read old configs + const oldConfig = readJsonSafe(path.join(ccsDir, 'config.json')); + const oldProfiles = readJsonSafe(path.join(ccsDir, 'profiles.json')); + + // 3. Build unified config + const unifiedConfig = createEmptyUnifiedConfig(); + const secrets = createEmptySecretsConfig(); + + // Set default if exists + if (oldProfiles?.default && typeof oldProfiles.default === 'string') { + unifiedConfig.default = oldProfiles.default; + } + + // 4. Migrate accounts from profiles.json + if (oldProfiles?.profiles) { + for (const [name, meta] of Object.entries(oldProfiles.profiles)) { + const metadata = meta as Record; + const account: AccountConfig = { + created: (metadata.created as string) || new Date().toISOString(), + last_used: (metadata.last_used as string) || null, + }; + unifiedConfig.accounts[name] = account; + } + migratedFiles.push('profiles.json → config.yaml.accounts'); + } + + // 5. Migrate CLIProxy variants from config.json + if (oldConfig?.cliproxy) { + for (const [name, variantData] of Object.entries(oldConfig.cliproxy)) { + const oldVariant = variantData as Record; + const variant: CLIProxyVariantConfig = { + provider: oldVariant.provider as CLIProxyVariantConfig['provider'], + }; + + if (oldVariant.account) { + variant.account = oldVariant.account as string; + } + + // Extract model from settings file if exists + 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; + } + } + + unifiedConfig.cliproxy.variants[name] = variant; + } + migratedFiles.push('config.json.cliproxy → config.yaml.cliproxy.variants'); + } + + // 6. Migrate API profiles from config.json + settings files + if (oldConfig?.profiles) { + for (const [name, settingsPath] of Object.entries(oldConfig.profiles)) { + const expandedPath = expandPath(settingsPath as string); + const settings = readJsonSafe(expandedPath); + + 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}`); + } + } + } + + // 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.) + const builtInProviders = ['gemini', 'codex', 'agy', 'qwen', 'iflow']; + for (const provider of builtInProviders) { + const settingsPath = path.join(ccsDir, `${provider}.settings.json`); + 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; + + 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}` + ); + } + } + } + } + + // 7. Migrate cache files + if (!dryRun) { + const cacheDir = path.join(ccsDir, 'cache'); + if (!fs.existsSync(cacheDir)) { + fs.mkdirSync(cacheDir, { recursive: true }); + } + + if (moveIfExists(path.join(ccsDir, 'usage-cache.json'), path.join(cacheDir, 'usage.json'))) { + migratedFiles.push('usage-cache.json → cache/usage.json'); + } + + if ( + moveIfExists( + path.join(ccsDir, 'update-check.json'), + path.join(cacheDir, 'update-check.json') + ) + ) { + migratedFiles.push('update-check.json → cache/update-check.json'); + } + } + + // 8. Write new configs (unless dry run) + if (!dryRun) { + saveUnifiedConfig(unifiedConfig); + + if (Object.keys(secrets.profiles).length > 0) { + saveSecrets(secrets); + migratedFiles.push('secrets extracted → secrets.yaml'); + } + } + + return { + success: true, + backupPath: dryRun ? undefined : backupDir, + migratedFiles, + warnings, + }; + } catch (err) { + return { + success: false, + error: err instanceof Error ? err.message : 'Unknown error', + migratedFiles, + warnings, + }; + } +} + +/** + * Rollback migration by restoring from backup. + */ +export async function rollback(backupPath: string): Promise { + const ccsDir = getCcsDir(); + + if (!fs.existsSync(backupPath)) { + console.error(`[X] Backup not found: ${backupPath}`); + return false; + } + + try { + // Remove new config files + const configYaml = path.join(ccsDir, 'config.yaml'); + const secretsYaml = path.join(ccsDir, 'secrets.yaml'); + const cacheDir = path.join(ccsDir, 'cache'); + + if (fs.existsSync(configYaml)) fs.unlinkSync(configYaml); + if (fs.existsSync(secretsYaml)) fs.unlinkSync(secretsYaml); + + // Restore cache files to original locations + if (fs.existsSync(cacheDir)) { + moveIfExists(path.join(cacheDir, 'usage.json'), path.join(ccsDir, 'usage-cache.json')); + moveIfExists( + path.join(cacheDir, 'update-check.json'), + path.join(ccsDir, 'update-check.json') + ); + + // Remove cache dir if empty + const remaining = fs.readdirSync(cacheDir); + if (remaining.length === 0) { + fs.rmdirSync(cacheDir); + } + } + + // Restore files from backup + const files = fs.readdirSync(backupPath); + for (const file of files) { + fs.copyFileSync(path.join(backupPath, file), path.join(ccsDir, file)); + } + + return true; + } catch (err) { + console.error(`[X] Rollback failed: ${err instanceof Error ? err.message : 'Unknown error'}`); + return false; + } +} + +// --- Helper Functions --- + +/** + * Read JSON file safely, returning null on error or if file doesn't exist. + */ +function readJsonSafe(filePath: string): Record | null { + if (!fs.existsSync(filePath)) return null; + try { + return JSON.parse(fs.readFileSync(filePath, 'utf8')) as Record; + } catch { + return null; + } +} + +/** + * Expand ~ to home directory in path. + */ +function expandPath(p: string): string { + return p.replace(/^~/, process.env.HOME || ''); +} + +/** + * Move file if it exists. Returns true if moved, false if source didn't exist. + */ +function moveIfExists(from: string, to: string): boolean { + if (fs.existsSync(from)) { + fs.renameSync(from, to); + return true; + } + return false; +} + +/** + * Create backup of all v1 config files. + */ +async function createBackup(srcDir: string, backupDir: string): Promise { + // Check if backup already exists (prevent overwriting) + if (fs.existsSync(backupDir)) { + // Add timestamp suffix to make unique + const suffix = Date.now().toString(36); + const uniqueBackupDir = `${backupDir}-${suffix}`; + fs.mkdirSync(uniqueBackupDir, { recursive: true }); + await performBackup(srcDir, uniqueBackupDir); + return; + } + + fs.mkdirSync(backupDir, { recursive: true }); + await performBackup(srcDir, backupDir); +} + +async function performBackup(srcDir: string, backupDir: string): Promise { + const filesToBackup = ['config.json', 'profiles.json', 'usage-cache.json', 'update-check.json']; + + // Also backup *.settings.json files + const allFiles = fs.readdirSync(srcDir); + const settingsFiles = allFiles.filter((f) => f.endsWith('.settings.json')); + filesToBackup.push(...settingsFiles); + + for (const file of filesToBackup) { + const src = path.join(srcDir, file); + if (fs.existsSync(src)) { + fs.copyFileSync(src, path.join(backupDir, file)); + } + } +} + +/** + * Auto-migrate on first run after update. + * Silent if already migrated or no config exists. + * Shows friendly message with backup location on success. + */ +export async function autoMigrate(): Promise { + // Skip in test environment + if (process.env.NODE_ENV === 'test' || process.env.CCS_SKIP_MIGRATION === '1') { + return; + } + + // Skip if no migration needed + if (!needsMigration()) { + return; + } + + const result = await migrate(false); + + if (result.success) { + console.log(''); + console.log('╭─────────────────────────────────────────────────────────╮'); + console.log('│ [OK] Migrated to unified config (config.yaml) │'); + console.log('╰─────────────────────────────────────────────────────────╯'); + console.log(` Backup: ${result.backupPath}`); + console.log(` Items: ${result.migratedFiles.length} migrated`); + if (result.warnings.length > 0) { + for (const warning of result.warnings) { + console.log(` [!] ${warning}`); + } + } + console.log(' Rollback: ccs migrate --rollback'); + console.log(''); + } else { + console.log(''); + console.log('╭─────────────────────────────────────────────────────────╮'); + console.log('│ [!] Migration failed - using legacy config │'); + console.log('╰─────────────────────────────────────────────────────────╯'); + console.log(` Error: ${result.error}`); + console.log(' Retry: ccs migrate'); + console.log(''); + } +} diff --git a/src/config/reserved-names.ts b/src/config/reserved-names.ts new file mode 100644 index 00000000..657e5d78 --- /dev/null +++ b/src/config/reserved-names.ts @@ -0,0 +1,40 @@ +/** + * Reserved profile names that cannot be used for user-defined profiles. + * These names are reserved for CLIProxy providers and CLI commands. + */ +export const RESERVED_PROFILE_NAMES = [ + // CLIProxy providers (built-in OAuth) + 'gemini', + 'codex', + 'agy', + 'qwen', + 'iflow', + // CLI commands and special names + 'default', + 'config', + 'cliproxy', +] as const; + +export type ReservedProfileName = (typeof RESERVED_PROFILE_NAMES)[number]; + +/** + * Check if a name is reserved and cannot be used for user profiles. + * @param name - The profile name to check + * @returns true if the name is reserved + */ +export function isReservedName(name: string): boolean { + return RESERVED_PROFILE_NAMES.includes(name.toLowerCase() as ReservedProfileName); +} + +/** + * Validate a profile name and throw if reserved. + * @param name - The profile name to validate + * @throws Error if the name is reserved + */ +export function validateProfileName(name: string): void { + if (isReservedName(name)) { + throw new Error( + `Profile name '${name}' is reserved. Reserved names: ${RESERVED_PROFILE_NAMES.join(', ')}` + ); + } +} diff --git a/src/config/secrets-manager.ts b/src/config/secrets-manager.ts new file mode 100644 index 00000000..c8c3d26e --- /dev/null +++ b/src/config/secrets-manager.ts @@ -0,0 +1,187 @@ +/** + * Secrets Manager + * + * Handles loading and saving secrets (API keys, tokens) in a separate file + * with restricted permissions (chmod 600). + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import * as yaml from 'js-yaml'; +import { getCcsDir } from '../utils/config-manager'; +import { SecretsConfig, isSecretsConfig, createEmptySecretsConfig } from './unified-config-types'; + +// Re-export from shared utility for backward compatibility +export { isSensitiveKey as isSecretKey } from '../utils/sensitive-keys'; + +const SECRETS_FILE = 'secrets.yaml'; +const SECRETS_FILE_MODE = 0o600; // Owner read/write only + +/** + * Get path to secrets.yaml + */ +export function getSecretsPath(): string { + return path.join(getCcsDir(), SECRETS_FILE); +} + +/** + * Check if secrets.yaml exists + */ +export function hasSecrets(): boolean { + return fs.existsSync(getSecretsPath()); +} + +/** + * Load secrets from YAML file. + * Returns empty secrets config if file doesn't exist. + */ +export function loadSecrets(): SecretsConfig { + const secretsPath = getSecretsPath(); + + if (!fs.existsSync(secretsPath)) { + return createEmptySecretsConfig(); + } + + try { + const content = fs.readFileSync(secretsPath, 'utf8'); + const parsed = yaml.load(content); + + if (!isSecretsConfig(parsed)) { + console.error(`[!] Invalid secrets format in ${secretsPath}`); + return createEmptySecretsConfig(); + } + + return parsed; + } catch (err) { + const error = err instanceof Error ? err.message : 'Unknown error'; + console.error(`[X] Failed to load secrets: ${error}`); + return createEmptySecretsConfig(); + } +} + +/** + * Save secrets to YAML file with restricted permissions. + * Uses atomic write (temp file + rename) to prevent corruption. + */ +export function saveSecrets(secrets: SecretsConfig): void { + const secretsPath = getSecretsPath(); + const dir = path.dirname(secretsPath); + + // Ensure directory exists + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); + } + + // Convert to YAML + const content = yaml.dump(secrets, { + indent: 2, + lineWidth: -1, + quotingType: '"', + noRefs: true, + }); + + // Atomic write: write to temp file, then rename + const tempPath = `${secretsPath}.tmp.${process.pid}`; + + try { + fs.writeFileSync(tempPath, content, { mode: SECRETS_FILE_MODE }); + fs.renameSync(tempPath, secretsPath); + + // Ensure correct permissions after rename (some systems may not preserve) + fs.chmodSync(secretsPath, SECRETS_FILE_MODE); + } catch (err) { + // Clean up temp file on error + if (fs.existsSync(tempPath)) { + try { + fs.unlinkSync(tempPath); + } catch { + // Ignore cleanup errors + } + } + throw err; + } +} + +/** + * Get a secret value for a specific profile. + */ +export function getProfileSecret(profileName: string, key: string): string | undefined { + const secrets = loadSecrets(); + return secrets.profiles[profileName]?.[key]; +} + +/** + * Set a secret value for a specific profile. + */ +export function setProfileSecret(profileName: string, key: string, value: string): void { + const secrets = loadSecrets(); + + if (!secrets.profiles[profileName]) { + secrets.profiles[profileName] = {}; + } + + secrets.profiles[profileName][key] = value; + saveSecrets(secrets); +} + +/** + * Delete a secret value for a specific profile. + */ +export function deleteProfileSecret(profileName: string, key: string): boolean { + const secrets = loadSecrets(); + + if (!secrets.profiles[profileName]?.[key]) { + return false; + } + + delete secrets.profiles[profileName][key]; + + // Clean up empty profile object + if (Object.keys(secrets.profiles[profileName]).length === 0) { + delete secrets.profiles[profileName]; + } + + saveSecrets(secrets); + return true; +} + +/** + * Get all secrets for a profile. + */ +export function getProfileSecrets(profileName: string): Record { + const secrets = loadSecrets(); + return secrets.profiles[profileName] || {}; +} + +/** + * Set all secrets for a profile (replaces existing). + */ +export function setProfileSecrets( + profileName: string, + profileSecrets: Record +): void { + const secrets = loadSecrets(); + + if (Object.keys(profileSecrets).length === 0) { + delete secrets.profiles[profileName]; + } else { + secrets.profiles[profileName] = profileSecrets; + } + + saveSecrets(secrets); +} + +/** + * Delete all secrets for a profile. + */ +export function deleteAllProfileSecrets(profileName: string): boolean { + const secrets = loadSecrets(); + + if (!secrets.profiles[profileName]) { + return false; + } + + delete secrets.profiles[profileName]; + saveSecrets(secrets); + return true; +} diff --git a/src/config/unified-config-loader.ts b/src/config/unified-config-loader.ts new file mode 100644 index 00000000..458ed993 --- /dev/null +++ b/src/config/unified-config-loader.ts @@ -0,0 +1,260 @@ +/** + * Unified Config Loader + * + * Loads and saves the unified YAML configuration. + * Provides fallback to legacy JSON format for backward compatibility. + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import * as yaml from 'js-yaml'; +import { getCcsDir } from '../utils/config-manager'; +import { + UnifiedConfig, + isUnifiedConfig, + createEmptyUnifiedConfig, + UNIFIED_CONFIG_VERSION, +} from './unified-config-types'; +import { isUnifiedConfigEnabled } from './feature-flags'; + +const CONFIG_YAML = 'config.yaml'; +const CONFIG_JSON = 'config.json'; + +/** + * Get path to unified config.yaml + */ +export function getConfigYamlPath(): string { + return path.join(getCcsDir(), CONFIG_YAML); +} + +/** + * Get path to legacy config.json + */ +export function getConfigJsonPath(): string { + return path.join(getCcsDir(), CONFIG_JSON); +} + +/** + * Check if unified config.yaml exists + */ +export function hasUnifiedConfig(): boolean { + return fs.existsSync(getConfigYamlPath()); +} + +/** + * Check if legacy config.json exists + */ +export function hasLegacyConfig(): boolean { + return fs.existsSync(getConfigJsonPath()); +} + +/** + * Determine which config format is active. + * Returns 'yaml' if unified config exists or is enabled, + * 'json' if only legacy config exists, + * 'none' if no config exists. + */ +export function getConfigFormat(): 'yaml' | 'json' | 'none' { + if (hasUnifiedConfig()) return 'yaml'; + if (isUnifiedConfigEnabled()) return 'yaml'; + if (hasLegacyConfig()) return 'json'; + return 'none'; +} + +/** + * Load unified config from YAML file. + * Returns null if file doesn't exist or format check fails. + */ +export function loadUnifiedConfig(): UnifiedConfig | null { + const yamlPath = getConfigYamlPath(); + + // If file doesn't exist, return null + if (!fs.existsSync(yamlPath)) { + return null; + } + + try { + const content = fs.readFileSync(yamlPath, 'utf8'); + const parsed = yaml.load(content); + + if (!isUnifiedConfig(parsed)) { + console.error(`[!] Invalid config format in ${yamlPath}`); + return null; + } + + return parsed; + } catch (err) { + const error = err instanceof Error ? err.message : 'Unknown error'; + console.error(`[X] Failed to load config: ${error}`); + return null; + } +} + +/** + * Load config, preferring YAML if available, falling back to creating empty config. + */ +export function loadOrCreateUnifiedConfig(): UnifiedConfig { + const existing = loadUnifiedConfig(); + if (existing) return existing; + + // Create empty config + const config = createEmptyUnifiedConfig(); + return config; +} + +/** + * Generate YAML header with helpful comments. + */ +function generateYamlHeader(): string { + return `# ============================================================================ +# CCS Unified Configuration (config.yaml) +# ============================================================================ +# 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). +# +# Structure: +# ┌─────────────────────────────────────────────────────────────────────────────┐ +# │ version - Config format version (do not modify) │ +# │ default - Default profile name when running 'ccs' without args │ +# │ 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) │ +# └─────────────────────────────────────────────────────────────────────────────┘ +# +# Usage: +# ccs Switch to profile +# ccs api add Add new API profile +# ccs cliproxy create Create CLIProxy variant +# ccs migrate --rollback Restore from backup +# +`; +} + +/** + * Generate YAML content with section comments for better readability. + */ +function generateYamlWithComments(config: UnifiedConfig): string { + const lines: string[] = []; + + // Version + lines.push(`version: ${config.version}`); + lines.push(''); + + // Default + if (config.default) { + lines.push(`# Default profile used when running 'ccs' without arguments`); + lines.push(`default: "${config.default}"`); + lines.push(''); + } + + // Accounts section + lines.push('# ----------------------------------------------------------------------------'); + lines.push('# Accounts: Isolated Claude instances (each with separate auth/sessions)'); + lines.push('# Manage with: ccs auth add , ccs auth list, ccs auth remove '); + lines.push('# ----------------------------------------------------------------------------'); + lines.push( + yaml.dump({ accounts: config.accounts }, { indent: 2, lineWidth: -1, quotingType: '"' }).trim() + ); + lines.push(''); + + // 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('# ----------------------------------------------------------------------------'); + lines.push( + yaml.dump({ profiles: config.profiles }, { indent: 2, lineWidth: -1, quotingType: '"' }).trim() + ); + lines.push(''); + + // 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('# ----------------------------------------------------------------------------'); + lines.push( + yaml.dump({ cliproxy: config.cliproxy }, { indent: 2, lineWidth: -1, quotingType: '"' }).trim() + ); + lines.push(''); + + // Preferences section + lines.push('# ----------------------------------------------------------------------------'); + lines.push('# Preferences: User settings'); + lines.push('# ----------------------------------------------------------------------------'); + lines.push( + yaml + .dump({ preferences: config.preferences }, { indent: 2, lineWidth: -1, quotingType: '"' }) + .trim() + ); + lines.push(''); + + return lines.join('\n'); +} + +/** + * Save unified config to YAML file. + * Uses atomic write (temp file + rename) to prevent corruption. + */ +export function saveUnifiedConfig(config: UnifiedConfig): void { + const yamlPath = getConfigYamlPath(); + const dir = path.dirname(yamlPath); + + // Ensure directory exists + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); + } + + // Ensure version is set + config.version = UNIFIED_CONFIG_VERSION; + + // Generate YAML with section comments + const yamlContent = generateYamlWithComments(config); + const content = generateYamlHeader() + yamlContent; + + // Atomic write: write to temp file, then rename + const tempPath = `${yamlPath}.tmp.${process.pid}`; + + try { + fs.writeFileSync(tempPath, content, { mode: 0o600 }); + fs.renameSync(tempPath, yamlPath); + } catch (err) { + // Clean up temp file on error + if (fs.existsSync(tempPath)) { + try { + fs.unlinkSync(tempPath); + } catch { + // Ignore cleanup errors + } + } + throw err; + } +} + +/** + * Update unified config with partial data. + * Loads existing config, merges changes, and saves. + */ +export function updateUnifiedConfig(updates: Partial): UnifiedConfig { + const config = loadOrCreateUnifiedConfig(); + const updated = { ...config, ...updates }; + saveUnifiedConfig(updated); + return updated; +} + +/** + * Get or set default profile name. + */ +export function getDefaultProfile(): string | undefined { + const config = loadUnifiedConfig(); + return config?.default; +} + +export function setDefaultProfile(name: string): void { + updateUnifiedConfig({ default: name }); +} diff --git a/src/config/unified-config-types.ts b/src/config/unified-config-types.ts new file mode 100644 index 00000000..e62c4ffb --- /dev/null +++ b/src/config/unified-config-types.ts @@ -0,0 +1,170 @@ +/** + * Unified Config Types for CCS v2 + * + * This file defines the new unified YAML configuration format that consolidates: + * - config.json (API profiles) + * - profiles.json (account metadata) + * - *.settings.json (env vars) + * + * Into a single config.yaml + secrets.yaml structure. + */ + +/** + * Unified config version. + * Version 2 = YAML unified format + */ +export const UNIFIED_CONFIG_VERSION = 2; + +/** + * Account configuration (formerly in profiles.json). + * Represents an isolated Claude instance via CLAUDE_CONFIG_DIR. + */ +export interface AccountConfig { + /** ISO timestamp when account was created */ + created: string; + /** ISO timestamp of last usage, null if never used */ + last_used: string | null; +} + +/** + * API-based profile configuration. + * Injects environment variables for alternative providers (GLM, Kimi, etc.). + */ +export interface ProfileConfig { + /** Profile type - currently only 'api' */ + type: 'api'; + /** Environment variables (non-secret values only) */ + env: Record; +} + +/** + * CLIProxy OAuth account nickname mapping. + * Maps user-friendly nicknames to email addresses. + */ +export type OAuthAccounts = Record; + +/** + * CLIProxy variant configuration. + * User-defined variants of built-in OAuth providers. + */ +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; +} + +/** + * CLIProxy configuration section. + */ +export interface CLIProxyConfig { + /** Nickname to email mapping for OAuth accounts */ + oauth_accounts: OAuthAccounts; + /** Built-in providers (read-only, for reference) */ + providers: readonly string[]; + /** User-defined provider variants */ + variants: Record; +} + +/** + * User preferences. + */ +export interface PreferencesConfig { + /** UI theme preference */ + theme?: 'light' | 'dark' | 'system'; + /** Enable anonymous telemetry */ + telemetry?: boolean; + /** Enable automatic update checks */ + auto_update?: boolean; +} + +/** + * Main unified configuration structure. + * Stored in ~/.ccs/config.yaml + */ +export interface UnifiedConfig { + /** Config version (2 for unified format) */ + version: number; + /** Default profile name to use when none specified */ + default?: string; + /** Account-based profiles (isolated Claude instances) */ + accounts: Record; + /** API-based profiles (env var injection) */ + profiles: Record; + /** CLIProxy configuration */ + cliproxy: CLIProxyConfig; + /** User preferences */ + preferences: PreferencesConfig; +} + +/** + * Secrets configuration structure. + * Stored in ~/.ccs/secrets.yaml with chmod 600. + * Contains sensitive values like API keys. + */ +export interface SecretsConfig { + /** Secrets version */ + version: number; + /** Profile secrets mapping: profile_name -> { key: value } */ + profiles: Record>; +} + +/** + * Create an empty unified config with defaults. + */ +export function createEmptyUnifiedConfig(): UnifiedConfig { + return { + version: UNIFIED_CONFIG_VERSION, + default: undefined, + accounts: {}, + profiles: {}, + cliproxy: { + oauth_accounts: {}, + providers: ['gemini', 'codex', 'agy', 'qwen', 'iflow'], + variants: {}, + }, + preferences: { + theme: 'system', + telemetry: false, + auto_update: true, + }, + }; +} + +/** + * Create an empty secrets config. + */ +export function createEmptySecretsConfig(): SecretsConfig { + return { + version: 1, + profiles: {}, + }; +} + +/** + * Type guard for UnifiedConfig. + */ +export function isUnifiedConfig(obj: unknown): obj is UnifiedConfig { + if (typeof obj !== 'object' || obj === null) return false; + const config = obj as Record; + return ( + typeof config.version === 'number' && + config.version === UNIFIED_CONFIG_VERSION && + typeof config.accounts === 'object' && + typeof config.profiles === 'object' && + typeof config.cliproxy === 'object' + ); +} + +/** + * Type guard for SecretsConfig. + */ +export function isSecretsConfig(obj: unknown): obj is SecretsConfig { + if (typeof obj !== 'object' || obj === null) return false; + const config = obj as Record; + return typeof config.version === 'number' && typeof config.profiles === 'object'; +} diff --git a/src/utils/sensitive-keys.ts b/src/utils/sensitive-keys.ts new file mode 100644 index 00000000..d163f90d --- /dev/null +++ b/src/utils/sensitive-keys.ts @@ -0,0 +1,64 @@ +/** + * Sensitive Key Detection Utilities + * + * Shared patterns for detecting secret/sensitive environment variable keys. + * Used by migration (secrets separation) and UI (masking). + */ + +/** + * Patterns that match sensitive keys (API keys, tokens, passwords). + * More specific than substring matching to avoid false positives. + */ +export const SENSITIVE_KEY_PATTERNS = [ + /^ANTHROPIC_AUTH_TOKEN$/, // Exact match for Anthropic auth token + /_API_KEY$/, // Keys ending with _API_KEY + /_AUTH_TOKEN$/, // Keys ending with _AUTH_TOKEN + /_SECRET$/, // Keys ending with _SECRET + /_SECRET_KEY$/, // Keys ending with _SECRET_KEY + /^API_KEY$/, // Exact match for API_KEY + /^AUTH_TOKEN$/, // Exact match for AUTH_TOKEN + /^SECRET$/, // Exact match for SECRET + /_PASSWORD$/, // Keys ending with _PASSWORD + /^PASSWORD$/, // Exact match for PASSWORD + /_CREDENTIAL$/, // Keys ending with _CREDENTIAL + /_PRIVATE_KEY$/, // Keys ending with _PRIVATE_KEY +]; + +/** + * Check if a key name contains a secret/sensitive value. + * Used during migration to separate secrets from config. + * + * @param key - Environment variable key name + * @returns true if the key likely contains sensitive data + */ +export function isSensitiveKey(key: string): boolean { + return SENSITIVE_KEY_PATTERNS.some((pattern) => pattern.test(key)); +} + +/** + * Mask a sensitive value for display. + * Shows first 4 and last 4 characters, replaces middle with asterisks. + * + * @param value - The sensitive value to mask + * @returns Masked value like "sk-a****xyz1" + */ +export function maskSensitiveValue(value: string): string { + if (!value || value.length <= 8) { + return '*'.repeat(value?.length || 0); + } + return value.slice(0, 4) + '*'.repeat(Math.max(0, value.length - 8)) + value.slice(-4); +} + +/** + * Mask all sensitive keys in an env object. + * + * @param env - Environment variables object + * @returns New object with sensitive values masked + */ +export function maskSensitiveEnv(env: Record): Record { + const masked: Record = {}; + for (const [key, value] of Object.entries(env)) { + masked[key] = isSensitiveKey(key) ? maskSensitiveValue(value) : value; + } + return masked; +} diff --git a/src/web-server/routes.ts b/src/web-server/routes.ts index 5b48c420..25007940 100644 --- a/src/web-server/routes.ts +++ b/src/web-server/routes.ts @@ -19,6 +19,22 @@ import { removeAccount as removeAccountFn, } from '../cliproxy/account-manager'; import type { CLIProxyProvider } from '../cliproxy/types'; +// Unified config imports +import { + hasUnifiedConfig, + loadUnifiedConfig, + saveUnifiedConfig, + getConfigFormat, +} from '../config/unified-config-loader'; +import { + needsMigration, + migrate, + rollback, + getBackupDirectories, +} from '../config/migration-manager'; +import { getProfileSecrets, setProfileSecrets } from '../config/secrets-manager'; +import { isUnifiedConfig } from '../config/unified-config-types'; +import { isSensitiveKey, maskSensitiveValue } from '../utils/sensitive-keys'; export const apiRoutes = Router(); @@ -478,22 +494,10 @@ function maskApiKeys(settings: Settings): Settings { if (!settings.env) return settings; const masked = { ...settings, env: { ...settings.env } }; - // Pattern-based matching for sensitive keys - const sensitivePatterns = [ - /^ANTHROPIC_AUTH_TOKEN$/, // Exact match for Anthropic auth token - /_API_KEY$/, // Keys ending with _API_KEY - /_AUTH_TOKEN$/, // Keys ending with _AUTH_TOKEN - /^API_KEY$/, // Exact match for API_KEY - /^AUTH_TOKEN$/, // Exact match for AUTH_TOKEN - ]; for (const key of Object.keys(masked.env)) { - if (sensitivePatterns.some((pattern) => pattern.test(key))) { - const value = masked.env[key]; - if (value && value.length > 8) { - masked.env[key] = - value.slice(0, 4) + '*'.repeat(Math.max(0, value.length - 8)) + value.slice(-4); - } + if (isSensitiveKey(key)) { + masked.env[key] = maskSensitiveValue(masked.env[key]); } } @@ -669,3 +673,109 @@ apiRoutes.post('/health/fix/:checkId', (req: Request, res: Response): void => { res.status(400).json({ success: false, message: result.message }); } }); + +// ==================== Unified Config (Phase 5) ==================== + +/** + * GET /api/config/format - Return current config format and migration status + */ +apiRoutes.get('/config/format', (_req: Request, res: Response) => { + res.json({ + format: getConfigFormat(), + migrationNeeded: needsMigration(), + backups: getBackupDirectories(), + }); +}); + +/** + * GET /api/config - Return unified config (excludes secrets) + */ +apiRoutes.get('/config', (_req: Request, res: Response): void => { + if (!hasUnifiedConfig()) { + res.status(400).json({ error: 'Unified config not enabled' }); + return; + } + + const config = loadUnifiedConfig(); + if (!config) { + res.status(500).json({ error: 'Failed to load config' }); + return; + } + + res.json(config); +}); + +/** + * PUT /api/config - Update unified config + */ +apiRoutes.put('/config', (req: Request, res: Response): void => { + const config = req.body; + + if (!isUnifiedConfig(config)) { + res.status(400).json({ error: 'Invalid config format' }); + return; + } + + try { + saveUnifiedConfig(config); + res.json({ success: true }); + } catch (err) { + res.status(500).json({ error: (err as Error).message }); + } +}); + +/** + * POST /api/config/migrate - Trigger migration from JSON to YAML + */ +apiRoutes.post('/config/migrate', async (req: Request, res: Response) => { + const dryRun = req.query.dryRun === 'true'; + const result = await migrate(dryRun); + res.json(result); +}); + +/** + * POST /api/config/rollback - Rollback migration to JSON format + */ +apiRoutes.post('/config/rollback', async (req: Request, res: Response): Promise => { + const { backupPath } = req.body; + + if (!backupPath || typeof backupPath !== 'string') { + res.status(400).json({ error: 'Missing required field: backupPath' }); + return; + } + + const success = await rollback(backupPath); + res.json({ success }); +}); + +/** + * PUT /api/secrets/:profile - Update profile secrets (write-only) + */ +apiRoutes.put('/secrets/:profile', (req: Request, res: Response): void => { + const { profile } = req.params; + const secrets = req.body; + + if (!secrets || typeof secrets !== 'object') { + res.status(400).json({ error: 'Invalid secrets format' }); + return; + } + + try { + setProfileSecrets(profile, secrets as Record); + res.json({ success: true }); + } catch (err) { + res.status(500).json({ error: (err as Error).message }); + } +}); + +/** + * GET /api/secrets/:profile/exists - Check if secrets exist (no values returned) + */ +apiRoutes.get('/secrets/:profile/exists', (req: Request, res: Response) => { + const { profile } = req.params; + const secrets = getProfileSecrets(profile); + res.json({ + exists: Object.keys(secrets).length > 0, + keys: Object.keys(secrets), // Only key names, not values + }); +}); diff --git a/tests/unit/unified-config.test.ts b/tests/unit/unified-config.test.ts new file mode 100644 index 00000000..3ad80b22 --- /dev/null +++ b/tests/unit/unified-config.test.ts @@ -0,0 +1,201 @@ +/** + * Unit tests for unified config module + */ +import { describe, it, expect } from 'bun:test'; + +// Import only modules without transitive dependencies on config-manager +import { + isReservedName, + validateProfileName, + RESERVED_PROFILE_NAMES, +} from '../../src/config/reserved-names'; +import { + createEmptyUnifiedConfig, + createEmptySecretsConfig, + isUnifiedConfig, + isSecretsConfig, + UNIFIED_CONFIG_VERSION, +} from '../../src/config/unified-config-types'; +import { isUnifiedConfigEnabled } from '../../src/config/feature-flags'; + +// Inline helper to test secret key detection (copied from secrets-manager to avoid import chain) +function isSecretKey(key: string): boolean { + const upper = key.toUpperCase(); + const secretPatterns = ['TOKEN', 'SECRET', 'API_KEY', 'APIKEY', 'PASSWORD', 'CREDENTIAL', 'AUTH', 'PRIVATE']; + return secretPatterns.some((pattern) => upper.includes(pattern)); +} + +describe('reserved-names', () => { + describe('RESERVED_PROFILE_NAMES', () => { + it('should include CLIProxy providers', () => { + expect(RESERVED_PROFILE_NAMES).toContain('gemini'); + expect(RESERVED_PROFILE_NAMES).toContain('codex'); + expect(RESERVED_PROFILE_NAMES).toContain('agy'); + expect(RESERVED_PROFILE_NAMES).toContain('qwen'); + expect(RESERVED_PROFILE_NAMES).toContain('iflow'); + }); + + it('should include CLI commands', () => { + expect(RESERVED_PROFILE_NAMES).toContain('default'); + expect(RESERVED_PROFILE_NAMES).toContain('config'); + expect(RESERVED_PROFILE_NAMES).toContain('cliproxy'); + }); + }); + + describe('isReservedName', () => { + it('should return true for reserved names', () => { + expect(isReservedName('gemini')).toBe(true); + expect(isReservedName('codex')).toBe(true); + expect(isReservedName('default')).toBe(true); + }); + + it('should be case-insensitive', () => { + expect(isReservedName('GEMINI')).toBe(true); + expect(isReservedName('Codex')).toBe(true); + expect(isReservedName('DEFAULT')).toBe(true); + }); + + it('should return false for non-reserved names', () => { + expect(isReservedName('myprofile')).toBe(false); + expect(isReservedName('work')).toBe(false); + expect(isReservedName('personal')).toBe(false); + }); + }); + + describe('validateProfileName', () => { + it('should throw for reserved names', () => { + expect(() => validateProfileName('gemini')).toThrow(/reserved/i); + expect(() => validateProfileName('default')).toThrow(/reserved/i); + }); + + it('should not throw for valid names', () => { + expect(() => validateProfileName('myprofile')).not.toThrow(); + expect(() => validateProfileName('work')).not.toThrow(); + }); + }); +}); + +describe('unified-config-types', () => { + describe('createEmptyUnifiedConfig', () => { + it('should create config with correct version', () => { + const config = createEmptyUnifiedConfig(); + expect(config.version).toBe(UNIFIED_CONFIG_VERSION); + }); + + it('should have empty accounts and profiles', () => { + const config = createEmptyUnifiedConfig(); + expect(Object.keys(config.accounts)).toHaveLength(0); + expect(Object.keys(config.profiles)).toHaveLength(0); + }); + + it('should have default preferences', () => { + const config = createEmptyUnifiedConfig(); + expect(config.preferences.theme).toBe('system'); + expect(config.preferences.telemetry).toBe(false); + expect(config.preferences.auto_update).toBe(true); + }); + + it('should have CLIProxy providers list', () => { + const config = createEmptyUnifiedConfig(); + expect(config.cliproxy.providers).toContain('gemini'); + expect(config.cliproxy.providers).toContain('codex'); + }); + }); + + describe('createEmptySecretsConfig', () => { + it('should create secrets with version 1', () => { + const secrets = createEmptySecretsConfig(); + expect(secrets.version).toBe(1); + }); + + it('should have empty profiles', () => { + const secrets = createEmptySecretsConfig(); + expect(Object.keys(secrets.profiles)).toHaveLength(0); + }); + }); + + describe('isUnifiedConfig', () => { + it('should return true for valid config', () => { + const config = createEmptyUnifiedConfig(); + expect(isUnifiedConfig(config)).toBe(true); + }); + + it('should return false for null', () => { + expect(isUnifiedConfig(null)).toBe(false); + }); + + it('should return false for wrong version', () => { + const config = { ...createEmptyUnifiedConfig(), version: 1 }; + expect(isUnifiedConfig(config)).toBe(false); + }); + + it('should return false for missing fields', () => { + expect(isUnifiedConfig({ version: 2 })).toBe(false); + expect(isUnifiedConfig({ version: 2, accounts: {} })).toBe(false); + }); + }); + + describe('isSecretsConfig', () => { + it('should return true for valid secrets', () => { + const secrets = createEmptySecretsConfig(); + expect(isSecretsConfig(secrets)).toBe(true); + }); + + it('should return false for null', () => { + expect(isSecretsConfig(null)).toBe(false); + }); + + it('should return false for missing fields', () => { + expect(isSecretsConfig({ version: 1 })).toBe(false); + }); + }); +}); + +describe('secrets-manager', () => { + describe('isSecretKey', () => { + it('should identify token keys as secrets', () => { + expect(isSecretKey('ANTHROPIC_AUTH_TOKEN')).toBe(true); + expect(isSecretKey('ACCESS_TOKEN')).toBe(true); + expect(isSecretKey('REFRESH_TOKEN')).toBe(true); + }); + + it('should identify API keys as secrets', () => { + expect(isSecretKey('API_KEY')).toBe(true); + expect(isSecretKey('OPENAI_API_KEY')).toBe(true); + expect(isSecretKey('APIKEY')).toBe(true); + }); + + it('should identify password keys as secrets', () => { + expect(isSecretKey('PASSWORD')).toBe(true); + expect(isSecretKey('DB_PASSWORD')).toBe(true); + }); + + it('should identify secret/credential keys', () => { + expect(isSecretKey('CLIENT_SECRET')).toBe(true); + expect(isSecretKey('AWS_CREDENTIAL')).toBe(true); + }); + + it('should not identify non-secret keys', () => { + expect(isSecretKey('ANTHROPIC_MODEL')).toBe(false); + expect(isSecretKey('ANTHROPIC_BASE_URL')).toBe(false); + expect(isSecretKey('DEBUG')).toBe(false); + expect(isSecretKey('NODE_ENV')).toBe(false); + }); + + it('should be case-insensitive', () => { + expect(isSecretKey('api_key')).toBe(true); + expect(isSecretKey('Api_Key')).toBe(true); + }); + }); +}); + +describe('feature-flags', () => { + describe('isUnifiedConfigEnabled', () => { + it('should read from CCS_UNIFIED_CONFIG env var', () => { + // This test runs with the current environment + // In CI, CCS_UNIFIED_CONFIG may or may not be set + const result = isUnifiedConfigEnabled(); + expect(typeof result).toBe('boolean'); + }); + }); +}); diff --git a/ui/src/hooks/use-unified-config.ts b/ui/src/hooks/use-unified-config.ts new file mode 100644 index 00000000..0e27ce00 --- /dev/null +++ b/ui/src/hooks/use-unified-config.ts @@ -0,0 +1,127 @@ +/** + * React Query hooks for unified config + * Phase 05: Dashboard Updates + */ + +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { api } from '@/lib/api-client'; +import { toast } from 'sonner'; + +/** + * Get current config format and migration status + */ +export function useConfigFormat() { + return useQuery({ + queryKey: ['config-format'], + queryFn: () => api.config.format(), + }); +} + +/** + * Get unified config (only available when format is 'yaml') + */ +export function useUnifiedConfig() { + return useQuery({ + queryKey: ['unified-config'], + queryFn: () => api.config.get(), + enabled: false, // Only fetch when explicitly enabled + }); +} + +/** + * Update unified config + */ +export function useUpdateConfig() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (config: Record) => api.config.update(config), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['unified-config'] }); + queryClient.invalidateQueries({ queryKey: ['config-format'] }); + toast.success('Configuration updated successfully'); + }, + onError: (error: Error) => { + toast.error(error.message); + }, + }); +} + +/** + * Run migration from JSON to YAML format + */ +export function useMigration() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (dryRun: boolean) => api.config.migrate(dryRun), + onSuccess: (result, dryRun) => { + if (dryRun) { + toast.info('Migration preview completed'); + } else if (result.success) { + queryClient.invalidateQueries({ queryKey: ['config-format'] }); + queryClient.invalidateQueries({ queryKey: ['unified-config'] }); + queryClient.invalidateQueries({ queryKey: ['profiles'] }); + queryClient.invalidateQueries({ queryKey: ['accounts'] }); + toast.success('Migration completed successfully'); + } else { + toast.error(result.error ?? 'Migration failed'); + } + }, + onError: (error: Error) => { + toast.error(error.message); + }, + }); +} + +/** + * Rollback migration to JSON format + */ +export function useRollback() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (backupPath: string) => api.config.rollback(backupPath), + onSuccess: (result) => { + if (result.success) { + queryClient.invalidateQueries({ queryKey: ['config-format'] }); + queryClient.invalidateQueries({ queryKey: ['unified-config'] }); + queryClient.invalidateQueries({ queryKey: ['profiles'] }); + queryClient.invalidateQueries({ queryKey: ['accounts'] }); + toast.success('Rollback completed successfully'); + } else { + toast.error('Rollback failed'); + } + }, + onError: (error: Error) => { + toast.error(error.message); + }, + }); +} + +/** + * Update profile secrets + */ +export function useUpdateSecrets() { + return useMutation({ + mutationFn: ({ profile, secrets }: { profile: string; secrets: Record }) => + api.secrets.update(profile, secrets), + onSuccess: () => { + toast.success('Secrets updated successfully'); + }, + onError: (error: Error) => { + toast.error(error.message); + }, + }); +} + +/** + * Check if profile has secrets (doesn't return values) + */ +export function useSecretsExists(profile: string) { + return useQuery({ + queryKey: ['secrets-exists', profile], + queryFn: () => api.secrets.exists(profile), + enabled: !!profile, + }); +} diff --git a/ui/src/lib/api-client.ts b/ui/src/lib/api-client.ts index 62d63d3c..4a8932a0 100644 --- a/ui/src/lib/api-client.ts +++ b/ui/src/lib/api-client.ts @@ -90,6 +90,26 @@ export interface Account { last_used?: string | null; } +// Unified config types +export interface ConfigFormat { + format: 'yaml' | 'json' | 'none'; + migrationNeeded: boolean; + backups: string[]; +} + +export interface MigrationResult { + success: boolean; + backupPath?: string; + error?: string; + migratedFiles: string[]; + warnings: string[]; +} + +export interface SecretsExists { + exists: boolean; + keys: string[]; +} + // API export const api = { profiles: { @@ -142,4 +162,29 @@ export const api = { body: JSON.stringify({ name }), }), }, + // Unified config API + config: { + format: () => request('/config/format'), + get: () => request>('/config'), + update: (config: Record) => + request<{ success: boolean }>('/config', { + method: 'PUT', + body: JSON.stringify(config), + }), + migrate: (dryRun = false) => + request(`/config/migrate?dryRun=${dryRun}`, { method: 'POST' }), + rollback: (backupPath: string) => + request<{ success: boolean }>('/config/rollback', { + method: 'POST', + body: JSON.stringify({ backupPath }), + }), + }, + secrets: { + update: (profile: string, secrets: Record) => + request<{ success: boolean }>(`/secrets/${profile}`, { + method: 'PUT', + body: JSON.stringify(secrets), + }), + exists: (profile: string) => request(`/secrets/${profile}/exists`), + }, }; From 1dbd7229a569604ecacec4a67f51b5732643c5a3 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Wed, 10 Dec 2025 17:53:00 -0500 Subject: [PATCH 02/17] 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; } /** From 0d70708658efb4b7e431f95d69c742a28d254ca6 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Wed, 10 Dec 2025 18:13:17 -0500 Subject: [PATCH 03/17] feat(cliproxy): implement --nickname flag for account management - Add renameAccount function to account-manager.ts with validation - Support --nickname to rename current default account - Support --nickname with --auth --add to set nickname during OAuth - Add getDefaultAccount import for nickname operations - Filter --nickname flag from Claude CLI args --- src/cliproxy/account-manager.ts | 32 +++++++++++++++++++++ src/cliproxy/auth-handler.ts | 22 ++++++++++---- src/cliproxy/cliproxy-executor.ts | 48 +++++++++++++++++++++++++++++-- 3 files changed, 93 insertions(+), 9 deletions(-) diff --git a/src/cliproxy/account-manager.ts b/src/cliproxy/account-manager.ts index 39413cef..f1b53d71 100644 --- a/src/cliproxy/account-manager.ts +++ b/src/cliproxy/account-manager.ts @@ -304,6 +304,38 @@ export function removeAccount(provider: CLIProxyProvider, accountId: string): bo return true; } +/** + * Rename an account's nickname + */ +export function renameAccount( + provider: CLIProxyProvider, + accountId: string, + newNickname: string +): boolean { + const validationError = validateNickname(newNickname); + if (validationError) { + throw new Error(validationError); + } + + const registry = loadAccountsRegistry(); + const providerAccounts = registry.providers[provider]; + + if (!providerAccounts?.accounts[accountId]) { + return false; + } + + // Check if nickname is already used by another account + for (const [id, account] of Object.entries(providerAccounts.accounts)) { + if (id !== accountId && account.nickname?.toLowerCase() === newNickname.toLowerCase()) { + throw new Error(`Nickname "${newNickname}" is already used by another account`); + } + } + + providerAccounts.accounts[accountId].nickname = newNickname; + saveAccountsRegistry(registry); + return true; +} + /** * Update last used timestamp for an account */ diff --git a/src/cliproxy/auth-handler.ts b/src/cliproxy/auth-handler.ts index de355dd0..1f8b057f 100644 --- a/src/cliproxy/auth-handler.ts +++ b/src/cliproxy/auth-handler.ts @@ -435,10 +435,16 @@ export function clearAuth(provider: CLIProxyProvider): boolean { */ export async function triggerOAuth( provider: CLIProxyProvider, - options: { verbose?: boolean; headless?: boolean; account?: string; add?: boolean } = {} + options: { + verbose?: boolean; + headless?: boolean; + account?: string; + add?: boolean; + nickname?: string; + } = {} ): Promise { const oauthConfig = getOAuthConfig(provider); - const { verbose = false, add = false } = options; + const { verbose = false, add = false, nickname } = options; // Auto-detect headless if not explicitly set const headless = options.headless ?? isHeadlessEnvironment(); @@ -615,7 +621,7 @@ export async function triggerOAuth( console.log('[OK] Authentication successful'); // Register the account in accounts registry - const account = registerAccountFromToken(provider, tokenDir); + const account = registerAccountFromToken(provider, tokenDir, nickname); resolve(account); } else { if (!headless) spinner.fail('Authentication incomplete'); @@ -658,10 +664,14 @@ export async function triggerOAuth( /** * Register account from newly created token file * Scans auth directory for new token and extracts email + * @param provider - The CLIProxy provider + * @param tokenDir - Directory containing token files + * @param nickname - Optional nickname (uses auto-generated from email if not provided) */ function registerAccountFromToken( provider: CLIProxyProvider, - tokenDir: string + tokenDir: string, + nickname?: string ): AccountInfo | null { try { const files = fs.readdirSync(tokenDir); @@ -692,8 +702,8 @@ function registerAccountFromToken( const data = JSON.parse(content); const email = data.email || undefined; - // Register the account with auto-generated nickname - return registerAccount(provider, newestFile, email, generateNickname(email)); + // Register the account (use provided nickname or auto-generate from email) + return registerAccount(provider, newestFile, email, nickname || generateNickname(email)); } catch { return null; } diff --git a/src/cliproxy/cliproxy-executor.ts b/src/cliproxy/cliproxy-executor.ts index af8536c3..e21d1a27 100644 --- a/src/cliproxy/cliproxy-executor.ts +++ b/src/cliproxy/cliproxy-executor.ts @@ -33,6 +33,8 @@ import { getProviderAccounts, setDefaultAccount, touchAccount, + renameAccount, + getDefaultAccount, } from './account-manager'; /** Default executor configuration */ @@ -139,6 +141,13 @@ export async function execClaudeWithCLIProxy( useAccount = args[useIdx + 1]; } + // Parse --nickname flag + let setNickname: string | undefined; + const nicknameIdx = args.indexOf('--nickname'); + if (nicknameIdx !== -1 && args[nicknameIdx + 1] && !args[nicknameIdx + 1].startsWith('-')) { + setNickname = args[nicknameIdx + 1]; + } + // Handle --accounts: list accounts and exit if (showAccounts) { const accounts = getProviderAccounts(provider); @@ -177,6 +186,29 @@ export async function execClaudeWithCLIProxy( console.log(`[OK] Switched to account: ${account.nickname || account.email || account.id}`); } + // Handle --nickname: rename account and exit (unless used with --auth --add) + if (setNickname && !addAccount) { + const defaultAccount = getDefaultAccount(provider); + if (!defaultAccount) { + console.error(`[X] No account found for ${providerConfig.displayName}`); + console.error(` Run "ccs ${provider} --auth" to add an account first`); + process.exit(1); + } + try { + const success = renameAccount(provider, defaultAccount.id, setNickname); + if (success) { + console.log(`[OK] Renamed account to: ${setNickname}`); + } else { + console.error(`[X] Failed to rename account`); + process.exit(1); + } + } catch (err) { + console.error(`[X] ${err instanceof Error ? err.message : 'Failed to rename account'}`); + process.exit(1); + } + process.exit(0); + } + // Handle --config: configure model selection and exit // Pass customSettingsPath for CLIProxy variants to save to correct file if (forceConfig && supportsModelConfig(provider)) { @@ -206,6 +238,7 @@ export async function execClaudeWithCLIProxy( verbose, add: addAccount, ...(forceHeadless ? { headless: true } : {}), + ...(setNickname ? { nickname: setNickname } : {}), }); if (!authSuccess) { throw new Error(`Authentication required for ${providerConfig.displayName}`); @@ -314,12 +347,21 @@ export async function execClaudeWithCLIProxy( log(`Claude env: ANTHROPIC_MODEL=${envVars.ANTHROPIC_MODEL}`); // Filter out CCS-specific flags before passing to Claude CLI - const ccsFlags = ['--auth', '--headless', '--logout', '--config', '--add', '--accounts', '--use']; + const ccsFlags = [ + '--auth', + '--headless', + '--logout', + '--config', + '--add', + '--accounts', + '--use', + '--nickname', + ]; const claudeArgs = args.filter((arg, idx) => { // Filter out CCS flags if (ccsFlags.includes(arg)) return false; - // Filter out value after --use - if (args[idx - 1] === '--use') return false; + // Filter out value after --use or --nickname + if (args[idx - 1] === '--use' || args[idx - 1] === '--nickname') return false; return true; }); From 05a6199d839c26ebff6278448825174466fc6518 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Wed, 10 Dec 2025 18:19:37 -0500 Subject: [PATCH 04/17] fix(migrate): skip autoMigrate when running migrate command Prevents double migration message when user explicitly runs 'ccs migrate --dry-run' - autoMigrate was triggering real migration before the migrate command handler ran. --- src/ccs.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/ccs.ts b/src/ccs.ts index c5ad3405..3d8ae6b2 100644 --- a/src/ccs.ts +++ b/src/ccs.ts @@ -203,8 +203,11 @@ async function main(): Promise { const args = process.argv.slice(2); // Auto-migrate to unified config format (silent if already migrated) - const { autoMigrate } = await import('./config/migration-manager'); - await autoMigrate(); + // Skip if user is explicitly running migrate command + if (args[0] !== 'migrate') { + const { autoMigrate } = await import('./config/migration-manager'); + await autoMigrate(); + } // Special case: version command (check BEFORE profile detection) const firstArg = args[0]; From c09cc35352e6b0a1a0c3596f911908a7d8c8b902 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Wed, 10 Dec 2025 18:21:45 -0500 Subject: [PATCH 05/17] style(migrate): use boxed UI for migration success message --- src/commands/migrate-command.ts | 33 +++++++++++++++------------------ 1 file changed, 15 insertions(+), 18 deletions(-) diff --git a/src/commands/migrate-command.ts b/src/commands/migrate-command.ts index 016a1b8e..ff5e8515 100644 --- a/src/commands/migrate-command.ts +++ b/src/commands/migrate-command.ts @@ -61,38 +61,35 @@ export async function handleMigrateCommand(args: string[]): Promise { console.log(''); } - console.log('[i] Migrating to unified config format (v2)...'); - console.log(''); - const result = await migrate(dryRun); if (result.success) { - console.log('[OK] Migration complete'); console.log(''); - - if (result.backupPath) { - console.log(` Backup created: ${result.backupPath}`); + console.log('╭─────────────────────────────────────────────────────────╮'); + if (dryRun) { + console.log('│ [i] Dry run - migration preview (no changes made) │'); + } else { + console.log('│ [OK] Migrated to unified config (config.yaml) │'); } + console.log('╰─────────────────────────────────────────────────────────╯'); - if (result.migratedFiles.length > 0) { - console.log(''); - console.log(' Migrated:'); - result.migratedFiles.forEach((f) => console.log(` - ${f}`)); + if (result.backupPath && !dryRun) { + console.log(` Backup: ${result.backupPath}`); } + console.log(` Items: ${result.migratedFiles.length} migrated`); if (result.warnings.length > 0) { - console.log(''); - console.log(' Warnings:'); - result.warnings.forEach((w) => console.log(` [!] ${w}`)); + for (const warning of result.warnings) { + console.log(` [!] ${warning}`); + } } if (dryRun) { - console.log(''); - console.log('[i] This was a dry run. Run without --dry-run to apply changes.'); + console.log(' Run without --dry-run to apply changes'); } else { - console.log(''); - console.log('[i] To rollback: ccs migrate --rollback ' + result.backupPath); + console.log(' Rollback: ccs migrate --rollback'); } + console.log(''); } else { console.error(`[X] Migration failed: ${result.error}`); From 0aa913164211b6cd0ad65b3b546e49edaa0bcc30 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Wed, 10 Dec 2025 18:24:26 -0500 Subject: [PATCH 06/17] fix(migrate): include backup path in rollback command --- src/commands/migrate-command.ts | 2 +- src/config/migration-manager.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/commands/migrate-command.ts b/src/commands/migrate-command.ts index ff5e8515..aac190fc 100644 --- a/src/commands/migrate-command.ts +++ b/src/commands/migrate-command.ts @@ -87,7 +87,7 @@ export async function handleMigrateCommand(args: string[]): Promise { if (dryRun) { console.log(' Run without --dry-run to apply changes'); } else { - console.log(' Rollback: ccs migrate --rollback'); + console.log(` Rollback: ccs migrate --rollback ${result.backupPath}`); } console.log(''); } else { diff --git a/src/config/migration-manager.ts b/src/config/migration-manager.ts index 39622fd3..88344006 100644 --- a/src/config/migration-manager.ts +++ b/src/config/migration-manager.ts @@ -353,7 +353,7 @@ export async function autoMigrate(): Promise { console.log(` [!] ${warning}`); } } - console.log(' Rollback: ccs migrate --rollback'); + console.log(` Rollback: ccs migrate --rollback ${result.backupPath}`); console.log(''); } else { console.log(''); From 790ac3c862c81539a048db5b3f67ed8d86a86cfb Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Wed, 10 Dec 2025 18:32:35 -0500 Subject: [PATCH 07/17] fix(cache): use ~/.ccs/cache/ for usage and update-check files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - usage-disk-cache.ts: usage-cache.json → cache/usage.json - update-checker.ts: update-check.json → cache/update-check.json - Both ensure cache directory exists before writing - Update tests to use new cache paths --- src/utils/update-checker.ts | 8 ++++---- src/web-server/usage-disk-cache.ts | 15 ++++++++------- .../utils/update-checker-beta-channel.test.js | 12 ++++++------ 3 files changed, 18 insertions(+), 17 deletions(-) diff --git a/src/utils/update-checker.ts b/src/utils/update-checker.ts index dc3f152e..95d1076b 100644 --- a/src/utils/update-checker.ts +++ b/src/utils/update-checker.ts @@ -8,7 +8,8 @@ import * as os from 'os'; import * as https from 'https'; import { colored } from './helpers'; -const UPDATE_CHECK_FILE = path.join(os.homedir(), '.ccs', 'update-check.json'); +const CACHE_DIR = path.join(os.homedir(), '.ccs', 'cache'); +const UPDATE_CHECK_FILE = path.join(CACHE_DIR, 'update-check.json'); const CHECK_INTERVAL = 24 * 60 * 60 * 1000; // 24 hours const GITHUB_API_URL = 'https://api.github.com/repos/kaitranntt/ccs/releases/latest'; const NPM_REGISTRY_BASE = 'https://registry.npmjs.org/@kaitranntt/ccs'; @@ -208,9 +209,8 @@ export function readCache(): UpdateCache { */ export function writeCache(cache: UpdateCache): void { try { - const ccsDir = path.join(os.homedir(), '.ccs'); - if (!fs.existsSync(ccsDir)) { - fs.mkdirSync(ccsDir, { recursive: true, mode: 0o700 }); + if (!fs.existsSync(CACHE_DIR)) { + fs.mkdirSync(CACHE_DIR, { recursive: true, mode: 0o700 }); } fs.writeFileSync(UPDATE_CHECK_FILE, JSON.stringify(cache, null, 2), 'utf8'); diff --git a/src/web-server/usage-disk-cache.ts b/src/web-server/usage-disk-cache.ts index 2396c7be..c1ba8ee6 100644 --- a/src/web-server/usage-disk-cache.ts +++ b/src/web-server/usage-disk-cache.ts @@ -4,7 +4,7 @@ * Caches aggregated usage data to disk to avoid re-parsing 6000+ JSONL files * on every dashboard startup. Uses TTL-based invalidation with stale-while-revalidate. * - * Cache location: ~/.ccs/usage-cache.json + * Cache location: ~/.ccs/cache/usage.json * Default TTL: 5 minutes (configurable) */ @@ -15,7 +15,8 @@ import type { DailyUsage, MonthlyUsage, SessionUsage } from './usage-types'; // Cache configuration const CCS_DIR = path.join(os.homedir(), '.ccs'); -const CACHE_FILE = path.join(CCS_DIR, 'usage-cache.json'); +const CACHE_DIR = path.join(CCS_DIR, 'cache'); +const CACHE_FILE = path.join(CACHE_DIR, 'usage.json'); const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes const STALE_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days (max age for stale data) @@ -33,11 +34,11 @@ export interface UsageDiskCache { const CACHE_VERSION = 2; /** - * Ensure ~/.ccs directory exists + * Ensure ~/.ccs/cache directory exists */ -function ensureCcsDir(): void { - if (!fs.existsSync(CCS_DIR)) { - fs.mkdirSync(CCS_DIR, { recursive: true }); +function ensureCacheDir(): void { + if (!fs.existsSync(CACHE_DIR)) { + fs.mkdirSync(CACHE_DIR, { recursive: true }); } } @@ -97,7 +98,7 @@ export function writeDiskCache( session: SessionUsage[] ): void { try { - ensureCcsDir(); + ensureCacheDir(); const cache: UsageDiskCache = { version: CACHE_VERSION, diff --git a/tests/unit/utils/update-checker-beta-channel.test.js b/tests/unit/utils/update-checker-beta-channel.test.js index c3ca8c15..f25520a9 100644 --- a/tests/unit/utils/update-checker-beta-channel.test.js +++ b/tests/unit/utils/update-checker-beta-channel.test.js @@ -275,7 +275,7 @@ describe('Beta Channel Implementation (Phase 3)', function () { latest_version: null, dismissed_version: null }; - mockFileSystem[path.join(os.homedir(), '.ccs', 'update-check.json')] = JSON.stringify(cacheData); + mockFileSystem[path.join(os.homedir(), '.ccs', 'cache', 'update-check.json')] = JSON.stringify(cacheData); }); it('should use latest tag when targetTag is "latest"', async function () { @@ -362,7 +362,7 @@ describe('Beta Channel Implementation (Phase 3)', function () { await updateCheckerModule.checkForUpdates('5.4.0', true, 'npm', 'dev'); // Check cache was updated with dev version - const cachePath = path.join(os.homedir(), '.ccs', 'update-check.json'); + const cachePath = path.join(os.homedir(), '.ccs', 'cache', 'update-check.json'); const cacheData = JSON.parse(mockFileSystem[cachePath]); assert.strictEqual(cacheData.latest_version, '5.5.0'); @@ -376,7 +376,7 @@ describe('Beta Channel Implementation (Phase 3)', function () { latest_version: '5.5.0', dismissed_version: null }; - mockFileSystem[path.join(os.homedir(), '.ccs', 'update-check.json')] = JSON.stringify(cacheData); + mockFileSystem[path.join(os.homedir(), '.ccs', 'cache', 'update-check.json')] = JSON.stringify(cacheData); // Call with force=false to use cache const result = await updateCheckerModule.checkForUpdates('5.4.0', false, 'npm', 'dev'); @@ -465,7 +465,7 @@ describe('Beta Channel Implementation (Phase 3)', function () { describe('Cache functionality', function () { it('should create cache directory if not exists', async function () { // Ensure no cache exists - const cacheDir = path.join(os.homedir(), '.ccs'); + const cacheDir = path.join(os.homedir(), '.ccs', 'cache'); delete mockFileSystem[cacheDir]; await updateCheckerModule.checkForUpdates('5.4.0', true, 'npm', 'latest'); @@ -481,7 +481,7 @@ describe('Beta Channel Implementation (Phase 3)', function () { latest_version: '5.5.0', dismissed_version: '5.5.0' }; - mockFileSystem[path.join(os.homedir(), '.ccs', 'update-check.json')] = JSON.stringify(cacheData); + mockFileSystem[path.join(os.homedir(), '.ccs', 'cache', 'update-check.json')] = JSON.stringify(cacheData); const result = await updateCheckerModule.checkForUpdates('5.4.1', false, 'npm', 'dev'); @@ -492,7 +492,7 @@ describe('Beta Channel Implementation (Phase 3)', function () { it('should handle corrupted cache gracefully', async function () { // Set up corrupted cache - mockFileSystem[path.join(os.homedir(), '.ccs', 'update-check.json')] = 'invalid json'; + mockFileSystem[path.join(os.homedir(), '.ccs', 'cache', 'update-check.json')] = 'invalid json'; // Should not throw error const result = await updateCheckerModule.checkForUpdates('5.4.0', true, 'npm', 'latest'); From cbeb28ff1f77405de8e3df69d3c5bc1c7a64cab3 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 11 Dec 2025 01:31:46 +0000 Subject: [PATCH 08/17] chore(release): 5.14.0-dev.1 [skip ci] --- VERSION | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/VERSION b/VERSION index 0a70cf03..976e616c 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -5.14.0 +5.14.0-dev.1 diff --git a/package.json b/package.json index e3e74e1a..574069ce 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "5.14.0", + "version": "5.14.0-dev.1", "description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6", "keywords": [ "cli", From f83051be40514a2084ceb06007eea37b31dd3062 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Wed, 10 Dec 2025 20:56:32 -0500 Subject: [PATCH 09/17] feat(api): improve create UX with URL validation and model mapping - Add URL warning for common mistakes (e.g., /chat/completions endpoint) - Add optional model mapping prompt for Opus/Sonnet/Haiku backends - Show edit hint after profile creation for modifying settings - Support custom model configurations in unified config mode Closes #72 --- src/commands/api-command.ts | 178 +++++++++++++++++++++++++++++------- 1 file changed, 143 insertions(+), 35 deletions(-) diff --git a/src/commands/api-command.ts b/src/commands/api-command.ts index 4193711f..71a5468a 100644 --- a/src/commands/api-command.ts +++ b/src/commands/api-command.ts @@ -93,7 +93,7 @@ function validateApiName(name: string): string | null { } /** - * Validate URL format + * Validate URL format and warn about common mistakes */ function validateUrl(url: string): string | null { if (!url) { @@ -107,6 +107,25 @@ function validateUrl(url: string): string | null { } } +/** + * Check if URL looks like it includes endpoint path (common mistake) + * Returns warning message if problematic, null if OK + */ +function getUrlWarning(url: string): string | null { + const problematicPaths = ['/chat/completions', '/v1/messages', '/messages', '/completions']; + const lowerUrl = url.toLowerCase(); + + for (const path of problematicPaths) { + if (lowerUrl.endsWith(path)) { + return ( + `URL ends with "${path}" - Claude appends this automatically.\n` + + ` You likely want: ${url.replace(new RegExp(path + '$', 'i'), '')}` + ); + } + } + return null; +} + /** * Check if unified config mode is active */ @@ -130,11 +149,24 @@ function apiExists(name: string): boolean { } } +/** Model mapping for API profiles */ +interface ModelMapping { + default: string; + opus: string; + sonnet: string; + haiku: string; +} + /** * Create settings.json file for API profile * Includes all 4 model fields for proper Claude CLI integration */ -function createSettingsFile(name: string, baseUrl: string, apiKey: string, model: string): string { +function createSettingsFile( + name: string, + baseUrl: string, + apiKey: string, + models: ModelMapping +): string { const ccsDir = getCcsDir(); const settingsPath = path.join(ccsDir, `${name}.settings.json`); @@ -142,10 +174,10 @@ function createSettingsFile(name: string, baseUrl: string, apiKey: string, model env: { ANTHROPIC_BASE_URL: baseUrl, ANTHROPIC_AUTH_TOKEN: apiKey, - ANTHROPIC_MODEL: model, - ANTHROPIC_DEFAULT_OPUS_MODEL: model, - ANTHROPIC_DEFAULT_SONNET_MODEL: model, - ANTHROPIC_DEFAULT_HAIKU_MODEL: model, + ANTHROPIC_MODEL: models.default, + ANTHROPIC_DEFAULT_OPUS_MODEL: models.opus, + ANTHROPIC_DEFAULT_SONNET_MODEL: models.sonnet, + ANTHROPIC_DEFAULT_HAIKU_MODEL: models.haiku, }, }; @@ -192,7 +224,7 @@ function createApiProfileUnified( name: string, baseUrl: string, apiKey: string, - model: string + models: ModelMapping ): void { const ccsDir = path.join(os.homedir(), '.ccs'); const settingsFile = `${name}.settings.json`; @@ -203,10 +235,10 @@ function createApiProfileUnified( env: { ANTHROPIC_BASE_URL: baseUrl, ANTHROPIC_AUTH_TOKEN: apiKey, - ANTHROPIC_MODEL: model, - ANTHROPIC_DEFAULT_OPUS_MODEL: model, - ANTHROPIC_DEFAULT_SONNET_MODEL: model, - ANTHROPIC_DEFAULT_HAIKU_MODEL: model, + ANTHROPIC_MODEL: models.default, + ANTHROPIC_DEFAULT_OPUS_MODEL: models.opus, + ANTHROPIC_DEFAULT_SONNET_MODEL: models.sonnet, + ANTHROPIC_DEFAULT_HAIKU_MODEL: models.haiku, }, }; @@ -293,9 +325,12 @@ async function handleCreate(args: string[]): Promise { // Step 2: Base URL let baseUrl = parsedArgs.baseUrl; if (!baseUrl) { - baseUrl = await InteractivePrompt.input('API Base URL (e.g., https://api.example.com)', { - validate: validateUrl, - }); + baseUrl = await InteractivePrompt.input( + 'API Base URL (e.g., https://api.example.com/v1 - without /chat/completions)', + { + validate: validateUrl, + } + ); } else { const error = validateUrl(baseUrl); if (error) { @@ -304,6 +339,23 @@ async function handleCreate(args: string[]): Promise { } } + // Check for common URL mistakes and warn + const urlWarning = getUrlWarning(baseUrl); + if (urlWarning) { + console.log(''); + console.log(warn(urlWarning)); + const continueAnyway = await InteractivePrompt.confirm('Continue with this URL anyway?', { + default: false, + }); + if (!continueAnyway) { + // Let user re-enter URL + baseUrl = await InteractivePrompt.input('API Base URL', { + validate: validateUrl, + default: baseUrl.replace(/\/(chat\/completions|v1\/messages|messages|completions)$/i, ''), + }); + } + } + // Step 3: API Key let apiKey = parsedArgs.apiKey; if (!apiKey) { @@ -324,44 +376,100 @@ async function handleCreate(args: string[]): Promise { } model = model || defaultModel; + // Step 5: Optional model mapping for Opus/Sonnet/Haiku + // Ask user if they want different models for each type + let opusModel = model; + let sonnetModel = model; + let haikuModel = model; + + if (!parsedArgs.yes) { + console.log(''); + console.log(dim('Some API proxies route different model types to different backends.')); + const wantCustomMapping = await InteractivePrompt.confirm( + 'Configure different models for Opus/Sonnet/Haiku?', + { default: false } + ); + + if (wantCustomMapping) { + console.log(''); + console.log(dim('Leave blank to use the default model for each.')); + opusModel = (await InteractivePrompt.input('Opus model', { default: model })) || model; + sonnetModel = (await InteractivePrompt.input('Sonnet model', { default: model })) || model; + haikuModel = (await InteractivePrompt.input('Haiku model', { default: model })) || model; + } + } + + // Build model mapping + const models: ModelMapping = { + default: model, + opus: opusModel, + sonnet: sonnetModel, + haiku: haikuModel, + }; + + // Check if custom model mapping is configured + const hasCustomMapping = opusModel !== model || sonnetModel !== model || haikuModel !== model; + // Create files console.log(''); console.log(info('Creating API profile...')); try { + const settingsFile = `~/.ccs/${name}.settings.json`; + if (isUnifiedMode()) { // Use unified config format - createApiProfileUnified(name, baseUrl, apiKey, model); + createApiProfileUnified(name, baseUrl, apiKey, models); console.log(''); - console.log( - infoBox( - `API: ${name}\n` + - `Config: ~/.ccs/config.yaml\n` + - `Secrets: ~/.ccs/secrets.yaml\n` + - `Base URL: ${baseUrl}\n` + - `Model: ${model}`, - 'API Profile Created (Unified Config)' - ) - ); + + // Build info message + let infoMsg = + `API: ${name}\n` + + `Config: ~/.ccs/config.yaml\n` + + `Settings: ${settingsFile}\n` + + `Base URL: ${baseUrl}\n` + + `Model: ${model}`; + + if (hasCustomMapping) { + infoMsg += + `\n\nModel Mapping:\n` + + ` Opus: ${opusModel}\n` + + ` Sonnet: ${sonnetModel}\n` + + ` Haiku: ${haikuModel}`; + } + + console.log(infoBox(infoMsg, 'API Profile Created')); } else { // Use legacy JSON format - const settingsPath = createSettingsFile(name, baseUrl, apiKey, model); + const settingsPath = createSettingsFile(name, baseUrl, apiKey, models); updateConfig(name, settingsPath); console.log(''); - console.log( - infoBox( - `API: ${name}\n` + - `Settings: ~/.ccs/${name}.settings.json\n` + - `Base URL: ${baseUrl}\n` + - `Model: ${model}`, - 'API Profile Created' - ) - ); + + let infoMsg = + `API: ${name}\n` + + `Settings: ${settingsFile}\n` + + `Base URL: ${baseUrl}\n` + + `Model: ${model}`; + + if (hasCustomMapping) { + infoMsg += + `\n\nModel Mapping:\n` + + ` Opus: ${opusModel}\n` + + ` Sonnet: ${sonnetModel}\n` + + ` Haiku: ${haikuModel}`; + } + + console.log(infoBox(infoMsg, 'API Profile Created')); } + console.log(''); console.log(header('Usage')); console.log(` ${color(`ccs ${name} "your prompt"`, 'command')}`); console.log(''); + console.log(header('Edit Settings')); + console.log(` ${dim('To modify env vars later:')}`); + console.log(` ${color(`nano ${settingsFile.replace('~', '$HOME')}`, 'command')}`); + console.log(''); } catch (error) { console.log(fail(`Failed to create API profile: ${(error as Error).message}`)); process.exit(1); From 2b1a3b48799eae30b5d0493e5af65edab204f4d8 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Wed, 10 Dec 2025 21:09:38 -0500 Subject: [PATCH 10/17] feat(dashboard): add code editor for raw JSON settings editing - Add GET/PUT /api/file endpoints for generic file access with security validation - Add GET /api/files endpoint to list editable JSON files in ~/.ccs/ - Create CodeEditor component with JSON syntax highlighting (prism-react-renderer) - Add "Raw JSON" tab to SettingsDialog for direct JSON editing - Support conflict detection, atomic writes, and automatic backups - Lazy load editor to minimize initial bundle impact (~31KB gzipped) Closes #73 --- src/web-server/routes.ts | 187 ++++++++++++++++++++++++++ ui/bun.lock | 8 ++ ui/package.json | 2 + ui/src/components/code-editor.tsx | 164 ++++++++++++++++++++++ ui/src/components/settings-dialog.tsx | 103 ++++++++++++-- 5 files changed, 452 insertions(+), 12 deletions(-) create mode 100644 ui/src/components/code-editor.tsx diff --git a/src/web-server/routes.ts b/src/web-server/routes.ts index 25007940..956cae88 100644 --- a/src/web-server/routes.ts +++ b/src/web-server/routes.ts @@ -779,3 +779,190 @@ apiRoutes.get('/secrets/:profile/exists', (req: Request, res: Response) => { keys: Object.keys(secrets), // Only key names, not values }); }); + +// ==================== Generic File API (Issue #73) ==================== + +/** + * Security: Validate file path is within allowed directories + * - ~/.ccs/ directory: read/write allowed + * - ~/.claude/settings.json: read-only + */ +function validateFilePath(filePath: string): { valid: boolean; readonly: boolean; error?: string } { + const expandedPath = expandPath(filePath); + const normalizedPath = path.normalize(expandedPath); + const ccsDir = getCcsDir(); + const claudeSettingsPath = expandPath('~/.claude/settings.json'); + + // Check if path is within ~/.ccs/ + if (normalizedPath.startsWith(ccsDir)) { + // Block access to sensitive subdirectories + const relativePath = normalizedPath.slice(ccsDir.length); + if (relativePath.includes('/.git/') || relativePath.includes('/node_modules/')) { + return { valid: false, readonly: false, error: 'Access to this path is not allowed' }; + } + return { valid: true, readonly: false }; + } + + // Allow read-only access to ~/.claude/settings.json + if (normalizedPath === claudeSettingsPath) { + return { valid: true, readonly: true }; + } + + return { valid: false, readonly: false, error: 'Access to this path is not allowed' }; +} + +/** + * GET /api/file - Read a file with path validation + * Query params: path (required) + * Returns: { content: string, mtime: number, readonly: boolean, path: string } + */ +apiRoutes.get('/file', (req: Request, res: Response): void => { + const filePath = req.query.path as string; + + if (!filePath) { + res.status(400).json({ error: 'Missing required query parameter: path' }); + return; + } + + const validation = validateFilePath(filePath); + if (!validation.valid) { + res.status(403).json({ error: validation.error }); + return; + } + + const expandedPath = expandPath(filePath); + + if (!fs.existsSync(expandedPath)) { + res.status(404).json({ error: 'File not found' }); + return; + } + + try { + const stat = fs.statSync(expandedPath); + const content = fs.readFileSync(expandedPath, 'utf8'); + + res.json({ + content, + mtime: stat.mtime.getTime(), + readonly: validation.readonly, + path: expandedPath, + }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + +/** + * PUT /api/file - Write a file with conflict detection and backup + * Query params: path (required) + * Body: { content: string, expectedMtime?: number } + * Returns: { success: true, mtime: number, backupPath?: string } + */ +apiRoutes.put('/file', (req: Request, res: Response): void => { + const filePath = req.query.path as string; + const { content, expectedMtime } = req.body; + + if (!filePath) { + res.status(400).json({ error: 'Missing required query parameter: path' }); + return; + } + + if (typeof content !== 'string') { + res.status(400).json({ error: 'Missing required field: content' }); + return; + } + + const validation = validateFilePath(filePath); + if (!validation.valid) { + res.status(403).json({ error: validation.error }); + return; + } + + if (validation.readonly) { + res.status(403).json({ error: 'File is read-only' }); + return; + } + + const expandedPath = expandPath(filePath); + const ccsDir = getCcsDir(); + + // Conflict detection (if file exists and expectedMtime provided) + if (fs.existsSync(expandedPath) && expectedMtime !== undefined) { + const stat = fs.statSync(expandedPath); + if (stat.mtime.getTime() !== expectedMtime) { + res.status(409).json({ + error: 'File modified externally', + currentMtime: stat.mtime.getTime(), + }); + return; + } + } + + try { + // Create backup if file exists + let backupPath: string | undefined; + if (fs.existsSync(expandedPath)) { + const backupDir = path.join(ccsDir, 'backups'); + if (!fs.existsSync(backupDir)) { + fs.mkdirSync(backupDir, { recursive: true }); + } + const filename = path.basename(expandedPath); + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + backupPath = path.join(backupDir, `${filename}.${timestamp}.bak`); + fs.copyFileSync(expandedPath, backupPath); + } + + // Ensure parent directory exists + const parentDir = path.dirname(expandedPath); + if (!fs.existsSync(parentDir)) { + fs.mkdirSync(parentDir, { recursive: true }); + } + + // Write atomically + const tempPath = expandedPath + '.tmp'; + fs.writeFileSync(tempPath, content); + fs.renameSync(tempPath, expandedPath); + + const newStat = fs.statSync(expandedPath); + res.json({ + success: true, + mtime: newStat.mtime.getTime(), + backupPath, + }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + +/** + * GET /api/files - List editable files in ~/.ccs/ + * Returns: { files: Array<{ name: string, path: string, mtime: number }> } + */ +apiRoutes.get('/files', (_req: Request, res: Response): void => { + const ccsDir = getCcsDir(); + + if (!fs.existsSync(ccsDir)) { + res.json({ files: [] }); + return; + } + + try { + const entries = fs.readdirSync(ccsDir, { withFileTypes: true }); + const files = entries + .filter((entry) => entry.isFile() && entry.name.endsWith('.json')) + .map((entry) => { + const filePath = path.join(ccsDir, entry.name); + const stat = fs.statSync(filePath); + return { + name: entry.name, + path: `~/.ccs/${entry.name}`, + mtime: stat.mtime.getTime(), + }; + }) + .sort((a, b) => a.name.localeCompare(b.name)); + + res.json({ files }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); diff --git a/ui/bun.lock b/ui/bun.lock index 14e0041b..f2f7ca2e 100644 --- a/ui/bun.lock +++ b/ui/bun.lock @@ -23,11 +23,13 @@ "clsx": "^2.1.1", "date-fns": "^4.1.0", "lucide-react": "^0.556.0", + "prism-react-renderer": "^2.4.1", "react": "^19.2.0", "react-day-picker": "^9.12.0", "react-dom": "^19.2.0", "react-hook-form": "^7.68.0", "react-router-dom": "^7.10.1", + "react-simple-code-editor": "^0.14.1", "recharts": "^2.12.0", "sonner": "^2.0.7", "tailwind-merge": "^3.4.0", @@ -387,6 +389,8 @@ "@types/node": ["@types/node@24.10.1", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ=="], + "@types/prismjs": ["@types/prismjs@1.26.5", "", {}, "sha512-AUZTa7hQ2KY5L7AmtSiqxlhWxb4ina0yd8hNbl4TWuqnv/pFP0nDMb3YrfSBf4hJVGLh2YEIBfKaBW/9UEl6IQ=="], + "@types/react": ["@types/react@19.2.7", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg=="], "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], @@ -677,6 +681,8 @@ "prettier": ["prettier@3.7.4", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA=="], + "prism-react-renderer": ["prism-react-renderer@2.4.1", "", { "dependencies": { "@types/prismjs": "^1.26.0", "clsx": "^2.0.0" }, "peerDependencies": { "react": ">=16.0.0" } }, "sha512-ey8Ls/+Di31eqzUxC46h8MksNuGx/n0AAC8uKpwFau4RPDYLuE3EXTp8N8G2vX2N7UC/+IXeNUnlWBGGcAG+Ig=="], + "prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="], "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], @@ -701,6 +707,8 @@ "react-router-dom": ["react-router-dom@7.10.1", "", { "dependencies": { "react-router": "7.10.1" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" } }, "sha512-JNBANI6ChGVjA5bwsUIwJk7LHKmqB4JYnYfzFwyp2t12Izva11elds2jx7Yfoup2zssedntwU0oZ5DEmk5Sdaw=="], + "react-simple-code-editor": ["react-simple-code-editor@0.14.1", "", { "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-BR5DtNRy+AswWJECyA17qhUDvrrCZ6zXOCfkQY5zSmb96BVUbpVAv03WpcjcwtCwiLbIANx3gebHOcXYn1EHow=="], + "react-smooth": ["react-smooth@4.0.4", "", { "dependencies": { "fast-equals": "^5.0.1", "prop-types": "^15.8.1", "react-transition-group": "^4.4.5" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q=="], "react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="], diff --git a/ui/package.json b/ui/package.json index acd632e1..7e02cbdb 100644 --- a/ui/package.json +++ b/ui/package.json @@ -34,11 +34,13 @@ "clsx": "^2.1.1", "date-fns": "^4.1.0", "lucide-react": "^0.556.0", + "prism-react-renderer": "^2.4.1", "react": "^19.2.0", "react-day-picker": "^9.12.0", "react-dom": "^19.2.0", "react-hook-form": "^7.68.0", "react-router-dom": "^7.10.1", + "react-simple-code-editor": "^0.14.1", "recharts": "^2.12.0", "sonner": "^2.0.7", "tailwind-merge": "^3.4.0", diff --git a/ui/src/components/code-editor.tsx b/ui/src/components/code-editor.tsx new file mode 100644 index 00000000..68a8bc8c --- /dev/null +++ b/ui/src/components/code-editor.tsx @@ -0,0 +1,164 @@ +/** + * Code Editor Component + * Lightweight JSON editor with syntax highlighting, line numbers, and validation + * Uses react-simple-code-editor + prism-react-renderer for minimal bundle size (~18KB) + */ + +import { useState, useCallback, useMemo } from 'react'; +import Editor from 'react-simple-code-editor'; +import { Highlight, themes } from 'prism-react-renderer'; +import { useTheme } from '@/hooks/use-theme'; +import { cn } from '@/lib/utils'; +import { AlertCircle, CheckCircle2 } from 'lucide-react'; + +interface CodeEditorProps { + value: string; + onChange: (value: string) => void; + language?: 'json' | 'yaml'; + readonly?: boolean; + className?: string; + minHeight?: string; +} + +interface ValidationResult { + valid: boolean; + error?: string; + line?: number; +} + +/** + * Validate JSON and extract error location + */ +function validateJson(code: string): ValidationResult { + if (!code.trim()) { + return { valid: true }; + } + + try { + JSON.parse(code); + return { valid: true }; + } catch (e) { + const error = e as SyntaxError; + const message = error.message; + + // Try to extract line number from error message + // Format: "... at position X" or "... at line Y column Z" + const posMatch = message.match(/position (\d+)/); + if (posMatch) { + const pos = parseInt(posMatch[1], 10); + const lines = code.substring(0, pos).split('\n'); + return { + valid: false, + error: message, + line: lines.length, + }; + } + + return { + valid: false, + error: message, + }; + } +} + +export function CodeEditor({ + value, + onChange, + language = 'json', + readonly = false, + className, + minHeight = '300px', +}: CodeEditorProps) { + const { isDark } = useTheme(); + const [isFocused, setIsFocused] = useState(false); + + // Validate on every change for JSON + const validation = useMemo(() => { + if (language === 'json') { + return validateJson(value); + } + return { valid: true }; + }, [value, language]); + + // Highlight function using prism-react-renderer + const highlightCode = useCallback( + (code: string) => ( + + {({ tokens, getLineProps, getTokenProps }) => ( + <> + {tokens.map((line, i) => ( +
+ + {i + 1} + + + {line.map((token, key) => ( + + ))} + +
+ ))} + + )} +
+ ), + [isDark, language, validation.line] + ); + + return ( +
+ {/* Editor container */} +
+ {} : onChange} + highlight={highlightCode} + padding={12} + disabled={readonly} + onFocus={() => setIsFocused(true)} + onBlur={() => setIsFocused(false)} + textareaClassName={cn( + 'focus:outline-none font-mono text-sm', + readonly && 'cursor-not-allowed' + )} + preClassName="font-mono text-sm" + style={{ + fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace', + fontSize: '0.875rem', + minHeight, + }} + /> +
+ + {/* Validation status */} +
+ {validation.valid ? ( + + + Valid {language.toUpperCase()} + + ) : ( + + + {validation.error} + {validation.line && ` (line ${validation.line})`} + + )} + {readonly && (Read-only)} +
+
+ ); +} diff --git a/ui/src/components/settings-dialog.tsx b/ui/src/components/settings-dialog.tsx index d4b0f3b9..624ce907 100644 --- a/ui/src/components/settings-dialog.tsx +++ b/ui/src/components/settings-dialog.tsx @@ -1,10 +1,10 @@ /** * Settings Dialog Component * Reusable dialog for editing profile environment variables - * Features: masked inputs for sensitive keys, conflict detection, save/cancel + * Features: masked inputs for sensitive keys, conflict detection, save/cancel, raw JSON editor */ -import { useState, useMemo, useCallback } from 'react'; +import { useState, useMemo, useCallback, lazy, Suspense } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { Dialog, @@ -18,9 +18,14 @@ import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { MaskedInput } from '@/components/ui/masked-input'; import { ConfirmDialog } from '@/components/confirm-dialog'; -import { Save, X, Loader2 } from 'lucide-react'; +import { Save, X, Loader2, Code2 } from 'lucide-react'; import { toast } from 'sonner'; +// Lazy load CodeEditor to reduce initial bundle size +const CodeEditor = lazy(() => + import('@/components/code-editor').then((m) => ({ default: m.CodeEditor })) +); + interface Settings { env?: Record; } @@ -55,6 +60,8 @@ function SettingsDialogContent({ }) { const [localEdits, setLocalEdits] = useState>({}); const [conflictDialog, setConflictDialog] = useState(false); + const [rawJsonEdits, setRawJsonEdits] = useState(null); + const [activeTab, setActiveTab] = useState('env'); const queryClient = useQueryClient(); // Fetch settings for selected profile @@ -63,6 +70,23 @@ function SettingsDialogContent({ queryFn: () => fetch(`/api/settings/${profileName}/raw`).then((r) => r.json()), }); + // Derive raw JSON content: use edits if available, otherwise serialize from data + const settings = data?.settings; + const rawJsonContent = useMemo(() => { + if (rawJsonEdits !== null) { + return rawJsonEdits; + } + if (settings) { + return JSON.stringify(settings, null, 2); + } + return ''; + }, [rawJsonEdits, settings]); + + // Update raw JSON when user edits + const handleRawJsonChange = useCallback((value: string) => { + setRawJsonEdits(value); + }, []); + // Derive current settings by merging original data with local edits const currentSettings = useMemo((): Settings | undefined => { const settings = data?.settings; @@ -76,16 +100,39 @@ function SettingsDialogContent({ }; }, [data?.settings, localEdits]); + // Check if raw JSON is valid + const isRawJsonValid = useMemo(() => { + try { + JSON.parse(rawJsonContent); + return true; + } catch { + return false; + } + }, [rawJsonContent]); + // Save mutation const saveMutation = useMutation({ mutationFn: async () => { - const settingsToSave: Settings = { - ...data?.settings, - env: { - ...data?.settings?.env, - ...localEdits, - }, - }; + let settingsToSave: Settings; + + // Determine what to save based on active tab + if (activeTab === 'raw') { + // Parse raw JSON content + try { + settingsToSave = JSON.parse(rawJsonContent); + } catch { + throw new Error('Invalid JSON'); + } + } else { + // Use form-based edits + settingsToSave = { + ...data?.settings, + env: { + ...data?.settings?.env, + ...localEdits, + }, + }; + } const res = await fetch(`/api/settings/${profileName}`, { method: 'PUT', @@ -174,7 +221,11 @@ function SettingsDialogContent({ ) : (
- + Environment + + + Raw JSON + + + + + Loading editor... +
+ } + > + + + + @@ -252,7 +328,10 @@ function SettingsDialogContent({ - +
+

Create API Profile

+

Configure a new custom API endpoint

+
+ + + +
+
+ {/* Basic Info Card */} + + + Basic Information + Profile name and API connection details + + + {/* Name */} +
+ + + {errors.name ? ( +

{errors.name.message}

+ ) : ( +

+ Used as: ccs my-api "prompt" +

+ )} +
+ + {/* Base URL */} +
+ + + {errors.baseUrl ? ( +

{errors.baseUrl.message}

+ ) : urlWarning ? ( +
+ + {urlWarning} +
+ ) : ( +

+ Base URL without /chat/completions (Claude adds this) +

+ )} +
+ + {/* API Key */} +
+ + + {errors.apiKey && ( +

{errors.apiKey.message}

+ )} +
+
+
+ + {/* Model Configuration Card */} + + + Model Configuration + Configure which models to use with this API + + + {/* Default Model */} +
+ + +

+ Leave blank to use: {DEFAULT_MODEL} +

+
+ + {/* Model Mapping Expander */} +
+ + + {showModelMapping && ( +
+
+ + + Configure different model IDs for each tier. Useful for API proxies that + route Opus/Sonnet/Haiku to different backends. + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ )} +
+
+
+ + {/* Actions */} +
+ + +
+
+
+
+ + ); +} diff --git a/ui/src/components/profile-dialog.tsx b/ui/src/components/profile-dialog.tsx index 3734c5ce..183f2f24 100644 --- a/ui/src/components/profile-dialog.tsx +++ b/ui/src/components/profile-dialog.tsx @@ -1,8 +1,10 @@ /** * Profile Dialog Component * Phase 03: REST API Routes & CRUD + * Updated: Added model mapping fields for Opus/Sonnet/Haiku */ +import { useState, useEffect } from 'react'; import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import * as z from 'zod'; @@ -12,6 +14,9 @@ import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { useCreateProfile, useUpdateProfile } from '@/hooks/use-profiles'; import type { Profile } from '@/lib/api-client'; +import { ChevronDown, ChevronRight } from 'lucide-react'; + +const DEFAULT_MODEL = 'claude-sonnet-4-5-20250929'; const schema = z.object({ name: z @@ -21,6 +26,9 @@ const schema = z.object({ baseUrl: z.string().url('Invalid URL'), apiKey: z.string().min(10, 'API key must be at least 10 characters'), model: z.string().optional(), + opusModel: z.string().optional(), + sonnetModel: z.string().optional(), + haikuModel: z.string().optional(), }); type FormData = z.infer; @@ -34,12 +42,14 @@ interface ProfileDialogProps { export function ProfileDialog({ open, onClose, profile }: ProfileDialogProps) { const createMutation = useCreateProfile(); const updateMutation = useUpdateProfile(); + const [showModelMapping, setShowModelMapping] = useState(false); const { register, handleSubmit, formState: { errors }, reset, + watch, } = useForm({ resolver: zodResolver(schema), defaultValues: profile @@ -48,10 +58,30 @@ export function ProfileDialog({ open, onClose, profile }: ProfileDialogProps) { baseUrl: '', apiKey: '', model: '', + opusModel: '', + sonnetModel: '', + haikuModel: '', } : undefined, }); + // Watch model field to auto-expand model mapping when custom model is entered + const modelValue = watch('model'); + + useEffect(() => { + // Auto-show model mapping if user enters a custom model (not default) + if (modelValue && modelValue !== DEFAULT_MODEL && modelValue.trim() !== '') { + setShowModelMapping(true); + } + }, [modelValue]); + + // Reset state when dialog opens/closes + useEffect(() => { + if (!open) { + setShowModelMapping(false); + } + }, [open]); + const onSubmit = async (data: FormData) => { try { if (profile) { @@ -62,6 +92,9 @@ export function ProfileDialog({ open, onClose, profile }: ProfileDialogProps) { baseUrl: data.baseUrl, apiKey: data.apiKey, model: data.model, + opusModel: data.opusModel, + sonnetModel: data.sonnetModel, + haikuModel: data.haikuModel, }, }); } else { @@ -78,7 +111,7 @@ export function ProfileDialog({ open, onClose, profile }: ProfileDialogProps) { return ( - + {profile ? 'Edit Profile' : 'Create API Profile'} @@ -104,8 +137,72 @@ export function ProfileDialog({ open, onClose, profile }: ProfileDialogProps) {
- - + + +

+ Leave blank to use: {DEFAULT_MODEL} +

+
+ + {/* Model Mapping Section */} +
+ + + {showModelMapping && ( +
+

+ Configure different model IDs for each tier. Useful for API proxies that route + different model types to different backends. +

+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ )}
diff --git a/ui/src/components/profile-editor.tsx b/ui/src/components/profile-editor.tsx new file mode 100644 index 00000000..11190a95 --- /dev/null +++ b/ui/src/components/profile-editor.tsx @@ -0,0 +1,528 @@ +/** + * Profile Editor Component + * Inline editor for API profile settings with tabs for Environment/Raw JSON/Info + */ + +import { useState, useMemo, useCallback, lazy, Suspense } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { MaskedInput } from '@/components/ui/masked-input'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { ConfirmDialog } from '@/components/confirm-dialog'; +import { + Save, + Loader2, + Code2, + Settings, + Info, + Terminal, + Trash2, + RefreshCw, + Plus, + X, +} from 'lucide-react'; +import { toast } from 'sonner'; + +// Lazy load CodeEditor to reduce initial bundle size +const CodeEditor = lazy(() => + import('@/components/code-editor').then((m) => ({ default: m.CodeEditor })) +); + +interface Settings { + env?: Record; +} + +interface SettingsResponse { + profile: string; + settings: Settings; + mtime: number; + path: string; +} + +interface ProfileEditorProps { + profileName: string; + onDelete?: () => void; +} + +export function ProfileEditor({ profileName, onDelete }: ProfileEditorProps) { + const [localEdits, setLocalEdits] = useState>({}); + const [conflictDialog, setConflictDialog] = useState(false); + const [rawJsonEdits, setRawJsonEdits] = useState(null); + const [activeTab, setActiveTab] = useState('env'); + const [newEnvKey, setNewEnvKey] = useState(''); + const queryClient = useQueryClient(); + + // Fetch settings for selected profile + const { data, isLoading, refetch } = useQuery({ + queryKey: ['settings', profileName], + queryFn: () => fetch(`/api/settings/${profileName}/raw`).then((r) => r.json()), + }); + + // Derive raw JSON content + const settings = data?.settings; + const rawJsonContent = useMemo(() => { + if (rawJsonEdits !== null) { + return rawJsonEdits; + } + if (settings) { + return JSON.stringify(settings, null, 2); + } + return ''; + }, [rawJsonEdits, settings]); + + const handleRawJsonChange = useCallback((value: string) => { + setRawJsonEdits(value); + }, []); + + // Derive current settings by merging original data with local edits + const currentSettings = useMemo((): Settings | undefined => { + if (!settings) return undefined; + return { + ...settings, + env: { + ...settings.env, + ...localEdits, + }, + }; + }, [settings, localEdits]); + + // Check if raw JSON is valid + const isRawJsonValid = useMemo(() => { + try { + JSON.parse(rawJsonContent); + return true; + } catch { + return false; + } + }, [rawJsonContent]); + + // Check if there are unsaved changes + const hasChanges = useMemo(() => { + if (activeTab === 'raw') { + return rawJsonEdits !== null; + } + return Object.keys(localEdits).length > 0; + }, [activeTab, rawJsonEdits, localEdits]); + + // Save mutation + const saveMutation = useMutation({ + mutationFn: async () => { + let settingsToSave: Settings; + + if (activeTab === 'raw') { + try { + settingsToSave = JSON.parse(rawJsonContent); + } catch { + throw new Error('Invalid JSON'); + } + } else { + settingsToSave = { + ...data?.settings, + env: { + ...data?.settings?.env, + ...localEdits, + }, + }; + } + + const res = await fetch(`/api/settings/${profileName}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + settings: settingsToSave, + expectedMtime: data?.mtime, + }), + }); + + if (res.status === 409) { + throw new Error('CONFLICT'); + } + + if (!res.ok) { + throw new Error('Failed to save'); + } + + return res.json(); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['settings', profileName] }); + queryClient.invalidateQueries({ queryKey: ['profiles'] }); + setLocalEdits({}); + setRawJsonEdits(null); + toast.success('Settings saved'); + }, + onError: (error: Error) => { + if (error.message === 'CONFLICT') { + setConflictDialog(true); + } else { + toast.error(error.message); + } + }, + }); + + const handleSave = () => { + saveMutation.mutate(); + }; + + const handleConflictResolve = async (overwrite: boolean) => { + setConflictDialog(false); + if (overwrite) { + await refetch(); + saveMutation.mutate(); + } else { + setLocalEdits({}); + setRawJsonEdits(null); + } + }; + + const updateEnvValue = (key: string, value: string) => { + setLocalEdits((prev) => ({ + ...prev, + [key]: value, + })); + }; + + const addNewEnvVar = () => { + if (!newEnvKey.trim()) return; + setLocalEdits((prev) => ({ + ...prev, + [newEnvKey.trim()]: '', + })); + setNewEnvKey(''); + }; + + const isSensitiveKey = (key: string): boolean => { + const sensitivePatterns = [ + /^ANTHROPIC_AUTH_TOKEN$/, + /_API_KEY$/, + /_AUTH_TOKEN$/, + /^API_KEY$/, + /^AUTH_TOKEN$/, + /_SECRET$/, + /^SECRET$/, + ]; + return sensitivePatterns.some((pattern) => pattern.test(key)); + }; + + // Reset state when profile changes + const profileKey = profileName; + + return ( +
+ {/* Header */} +
+
+
+

{profileName}

+ {data && ( + + {data.path.replace(/^.*\//, '')} + + )} +
+ {data && ( +

+ Last modified: {new Date(data.mtime).toLocaleString()} +

+ )} +
+
+ + {onDelete && ( + + )} + +
+
+ + {isLoading ? ( +
+ + Loading settings... +
+ ) : ( + +
+ + + + Environment + + + + Raw JSON + + + + Usage + + + + Info + + +
+ + {/* Environment Tab */} + + +
+ {currentSettings?.env && Object.keys(currentSettings.env).length > 0 ? ( + <> + {Object.entries(currentSettings.env).map(([key, value]) => ( +
+ + {isSensitiveKey(key) ? ( + updateEnvValue(key, e.target.value)} + className="font-mono text-sm" + /> + ) : ( + updateEnvValue(key, e.target.value)} + className="font-mono text-sm" + /> + )} +
+ ))} + + {/* Add new env var */} +
+ +
+ setNewEnvKey(e.target.value.toUpperCase())} + className="font-mono text-sm" + onKeyDown={(e) => e.key === 'Enter' && addNewEnvVar()} + /> + +
+
+ + ) : ( +
+

No environment variables configured.

+

+ Add variables using the Raw JSON tab or the form below. +

+
+ setNewEnvKey(e.target.value.toUpperCase())} + className="font-mono text-sm max-w-xs" + onKeyDown={(e) => e.key === 'Enter' && addNewEnvVar()} + /> + +
+
+ )} +
+
+
+ + {/* Raw JSON Tab */} + + + + Loading editor... +
+ } + > +
+ {!isRawJsonValid && rawJsonEdits !== null && ( +
+ + Invalid JSON syntax +
+ )} +
+ +
+
+ + + + {/* Usage Tab */} + + +
+ + + CLI Usage + Use this profile from the command line + + +
+ + + ccs {profileName} "your prompt here" + +
+
+ + + ccs {profileName} + +
+
+ + + ccs default {profileName} + +
+
+
+ + + + Environment Variables + Variables set when using this profile + + +
+ {currentSettings?.env && Object.keys(currentSettings.env).length > 0 ? ( + Object.entries(currentSettings.env).map(([key, value]) => ( +
+ + {key} + + + {isSensitiveKey(key) ? '••••••••' : value} + +
+ )) + ) : ( +

+ No environment variables set +

+ )} +
+
+
+
+
+
+ + {/* Info Tab */} + + +
+ + + Profile Information + Details about this configuration file + + + {data && ( + <> +
+ Profile Name + {data.profile} +
+
+ File Path + + {data.path} + +
+
+ Last Modified + {new Date(data.mtime).toLocaleString()} +
+
+ Variables + {Object.keys(currentSettings?.env || {}).length} configured +
+ + )} +
+
+
+
+
+ + )} + + handleConflictResolve(true)} + onCancel={() => handleConflictResolve(false)} + /> +
+ ); +} diff --git a/ui/src/lib/api-client.ts b/ui/src/lib/api-client.ts index 4a8932a0..40a6a0c7 100644 --- a/ui/src/lib/api-client.ts +++ b/ui/src/lib/api-client.ts @@ -31,12 +31,18 @@ export interface CreateProfile { baseUrl: string; apiKey: string; model?: string; + opusModel?: string; + sonnetModel?: string; + haikuModel?: string; } export interface UpdateProfile { baseUrl?: string; apiKey?: string; model?: string; + opusModel?: string; + sonnetModel?: string; + haikuModel?: string; } export interface Variant { diff --git a/ui/src/pages/api.tsx b/ui/src/pages/api.tsx index 74c0e72b..d0229df3 100644 --- a/ui/src/pages/api.tsx +++ b/ui/src/pages/api.tsx @@ -1,66 +1,331 @@ /** - * API Profiles Page - * Phase 03: REST API Routes & CRUD + * API Profiles Page - Master-Detail Layout + * Comprehensive profile management with inline editing */ -import { useState } from 'react'; +import { useState, useMemo } from 'react'; import { Button } from '@/components/ui/button'; -import { Plus } from 'lucide-react'; -import { ProfilesTable } from '@/components/profiles-table'; -import { ProfileDialog } from '@/components/profile-dialog'; -import { SettingsDialog } from '@/components/settings-dialog'; -import { useProfiles } from '@/hooks/use-profiles'; +import { Input } from '@/components/ui/input'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { Badge } from '@/components/ui/badge'; +import { Separator } from '@/components/ui/separator'; +import { + Plus, + Search, + Settings2, + Trash2, + CheckCircle2, + AlertCircle, + Server, + ExternalLink, + FileJson, +} from 'lucide-react'; +import { ProfileEditor } from '@/components/profile-editor'; +import { ProfileCreateForm } from '@/components/profile-create-form'; +import { useProfiles, useDeleteProfile } from '@/hooks/use-profiles'; +import { ConfirmDialog } from '@/components/confirm-dialog'; import type { Profile } from '@/lib/api-client'; +import { cn } from '@/lib/utils'; export function ApiPage() { - const [dialogOpen, setDialogOpen] = useState(false); - const [editingProfile, setEditingProfile] = useState(null); - const [settingsDialogOpen, setSettingsDialogOpen] = useState(false); - const [settingsProfileName, setSettingsProfileName] = useState(null); const { data, isLoading } = useProfiles(); + const deleteMutation = useDeleteProfile(); + const [selectedProfile, setSelectedProfile] = useState(null); + const [searchQuery, setSearchQuery] = useState(''); + const [showCreateForm, setShowCreateForm] = useState(false); + const [deleteConfirm, setDeleteConfirm] = useState(null); - const handleEditSettings = (profile: Profile) => { - setSettingsProfileName(profile.name); - setSettingsDialogOpen(true); + // Memoize profiles to maintain stable reference + const profiles = useMemo(() => data?.profiles || [], [data?.profiles]); + + // Filter profiles by search + const filteredProfiles = useMemo( + () => profiles.filter((p) => p.name.toLowerCase().includes(searchQuery.toLowerCase())), + [profiles, searchQuery] + ); + + // Compute effective selected profile (auto-select first if none selected) + const effectiveSelectedProfile = useMemo(() => { + if (showCreateForm) return null; + if (selectedProfile && profiles.some((p) => p.name === selectedProfile)) { + return selectedProfile; + } + return profiles.length > 0 ? profiles[0].name : null; + }, [selectedProfile, profiles, showCreateForm]); + + // Handle profile deletion + const handleDelete = (name: string) => { + deleteMutation.mutate(name, { + onSuccess: () => { + if (selectedProfile === name) { + setSelectedProfile(null); + } + setDeleteConfirm(null); + }, + }); }; - const handleCloseDialog = () => { - setDialogOpen(false); - setEditingProfile(null); + // Handle create success + const handleCreateSuccess = (name: string) => { + setShowCreateForm(false); + setSelectedProfile(name); }; - const handleCloseSettingsDialog = () => { - setSettingsDialogOpen(false); - setSettingsProfileName(null); - }; + const selectedProfileData = profiles.find((p) => p.name === effectiveSelectedProfile); return ( -
-
-
-

API Profiles

-

- Manage custom API profiles for Claude CLI -

+
+ {/* Left Panel - Profiles List */} +
+ {/* Header */} +
+
+
+ +

API Profiles

+
+ +
+ + {/* Search */} +
+ + setSearchQuery(e.target.value)} + /> +
- + + {/* Profile List */} + + {isLoading ? ( +
Loading profiles...
+ ) : filteredProfiles.length === 0 ? ( +
+ {profiles.length === 0 ? ( +
+ +
+

No API profiles yet

+

+ Create your first profile to connect to custom API endpoints +

+
+ +
+ ) : ( +

+ No profiles match "{searchQuery}" +

+ )} +
+ ) : ( +
+ {filteredProfiles.map((profile) => ( + { + setSelectedProfile(profile.name); + setShowCreateForm(false); + }} + onDelete={() => setDeleteConfirm(profile.name)} + /> + ))} +
+ )} +
+ + {/* Footer Stats */} + {profiles.length > 0 && ( +
+
+ + {profiles.length} profile{profiles.length !== 1 ? 's' : ''} + + + + {profiles.filter((p) => p.configured).length} configured + +
+
+ )}
- {isLoading ? ( -
Loading profiles...
- ) : ( - - )} + {/* Right Panel - Editor */} +
+ {showCreateForm ? ( + { + setShowCreateForm(false); + if (profiles.length > 0) { + setSelectedProfile(profiles[0].name); + } + }} + /> + ) : selectedProfileData ? ( + setDeleteConfirm(selectedProfileData.name)} + /> + ) : ( + { + setShowCreateForm(true); + setSelectedProfile(null); + }} + /> + )} +
- - deleteConfirm && handleDelete(deleteConfirm)} + onCancel={() => setDeleteConfirm(null)} />
); } + +/** Profile list item component */ +function ProfileListItem({ + profile, + isSelected, + onSelect, + onDelete, +}: { + profile: Profile; + isSelected: boolean; + onSelect: () => void; + onDelete: () => void; +}) { + return ( +
+ {/* Status indicator */} + {profile.configured ? ( + + ) : ( + + )} + + {/* Profile info */} +
+
{profile.name}
+
{profile.settingsPath}
+
+ + {/* Actions */} + +
+ ); +} + +/** Empty state when no profile is selected */ +function EmptyState({ onCreateClick }: { onCreateClick: () => void }) { + return ( +
+
+ +

API Profile Manager

+

+ Configure custom API endpoints for Claude CLI. Connect to proxy services like copilot-api, + OpenRouter, or your own API backend. +

+ +
+ + + + +
+

+ What you can configure: +

+
    +
  • + + URL + + Custom API base URL endpoint +
  • +
  • + + Auth + + API key or authentication token +
  • +
  • + + Models + + Model mapping for Opus/Sonnet/Haiku +
  • +
+
+ + +
+
+
+ ); +} From 8c9d669ccec6d2c56c37f4421e5ca6d4c95703e3 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Wed, 10 Dec 2025 22:14:01 -0500 Subject: [PATCH 12/17] feat(api-profile-ux): implement tabbed profile editor and fix disclaimer visibility --- ui/src/App.tsx | 12 +- ui/src/components/localhost-disclaimer.tsx | 19 +- ui/src/components/profile-editor.tsx | 558 ++++++++++----------- ui/src/components/ui/copy-button.tsx | 60 +++ ui/src/pages/api.tsx | 14 +- 5 files changed, 348 insertions(+), 315 deletions(-) create mode 100644 ui/src/components/ui/copy-button.tsx diff --git a/ui/src/App.tsx b/ui/src/App.tsx index c9ab07be..8ba2fe22 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -43,16 +43,18 @@ function Layout() { return ( -
-
+
+
- }> - - +
+ }> + + +
diff --git a/ui/src/components/localhost-disclaimer.tsx b/ui/src/components/localhost-disclaimer.tsx index 23a1c778..c7947163 100644 --- a/ui/src/components/localhost-disclaimer.tsx +++ b/ui/src/components/localhost-disclaimer.tsx @@ -1,30 +1,13 @@ import { Shield, X } from 'lucide-react'; import { useState } from 'react'; -import { useSidebar } from '@/hooks/use-sidebar'; export function LocalhostDisclaimer() { const [dismissed, setDismissed] = useState(false); - const { state, isMobile } = useSidebar(); if (dismissed) return null; - // Calculate the left margin based on sidebar state - // When expanded: sidebar width is 16rem - // When collapsed: sidebar width is 3rem - // On mobile: sidebar is overlay, no margin needed - const getLeftMargin = () => { - if (isMobile) return '0'; - return state === 'expanded' ? '16rem' : '3rem'; - }; - return ( -
+
diff --git a/ui/src/components/profile-editor.tsx b/ui/src/components/profile-editor.tsx index 11190a95..8808ee00 100644 --- a/ui/src/components/profile-editor.tsx +++ b/ui/src/components/profile-editor.tsx @@ -1,6 +1,6 @@ /** * Profile Editor Component - * Inline editor for API profile settings with tabs for Environment/Raw JSON/Info + * Inline editor for API profile settings with 2-column layout (Friendly UI + Raw JSON) */ import { useState, useMemo, useCallback, lazy, Suspense } from 'react'; @@ -9,24 +9,13 @@ import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { MaskedInput } from '@/components/ui/masked-input'; -import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { ScrollArea } from '@/components/ui/scroll-area'; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; import { ConfirmDialog } from '@/components/confirm-dialog'; -import { - Save, - Loader2, - Code2, - Settings, - Info, - Terminal, - Trash2, - RefreshCw, - Plus, - X, -} from 'lucide-react'; +import { Save, Loader2, Code2, Trash2, RefreshCw, Plus, X, Info } from 'lucide-react'; import { toast } from 'sonner'; +import { CopyButton } from '@/components/ui/copy-button'; +import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'; // Lazy load CodeEditor to reduce initial bundle size const CodeEditor = lazy(() => @@ -53,7 +42,6 @@ export function ProfileEditor({ profileName, onDelete }: ProfileEditorProps) { const [localEdits, setLocalEdits] = useState>({}); const [conflictDialog, setConflictDialog] = useState(false); const [rawJsonEdits, setRawJsonEdits] = useState(null); - const [activeTab, setActiveTab] = useState('env'); const [newEnvKey, setNewEnvKey] = useState(''); const queryClient = useQueryClient(); @@ -80,7 +68,18 @@ export function ProfileEditor({ profileName, onDelete }: ProfileEditorProps) { }, []); // Derive current settings by merging original data with local edits + // Prioritize rawJsonEdits if available const currentSettings = useMemo((): Settings | undefined => { + if (rawJsonEdits !== null) { + try { + return JSON.parse(rawJsonEdits); + } catch { + // If invalid JSON, fall back to undefined or partial state + // The UI will likely show empty or potentially broken state if JSON is invalid, + // but the Raw Editor will show the error. + } + } + if (!settings) return undefined; return { ...settings, @@ -89,7 +88,38 @@ export function ProfileEditor({ profileName, onDelete }: ProfileEditorProps) { ...localEdits, }, }; - }, [settings, localEdits]); + }, [settings, localEdits, rawJsonEdits]); + + // Sync Visual Editor changes to Raw JSON + const updateEnvValue = (key: string, value: string) => { + const newEnv = { ...(currentSettings?.env || {}), [key]: value }; + + // Update local edits + setLocalEdits((prev) => ({ + ...prev, + [key]: value, + })); + + // Update rawJsonEdits to keep sync + const newSettings = { ...currentSettings, env: newEnv }; + setRawJsonEdits(JSON.stringify(newSettings, null, 2)); + }; + + const addNewEnvVar = () => { + if (!newEnvKey.trim()) return; + const key = newEnvKey.trim(); + const newEnv = { ...(currentSettings?.env || {}), [key]: '' }; + + setLocalEdits((prev) => ({ + ...prev, + [key]: '', + })); + + const newSettings = { ...currentSettings, env: newEnv }; + setRawJsonEdits(JSON.stringify(newSettings, null, 2)); + + setNewEnvKey(''); + }; // Check if raw JSON is valid const isRawJsonValid = useMemo(() => { @@ -103,24 +133,22 @@ export function ProfileEditor({ profileName, onDelete }: ProfileEditorProps) { // Check if there are unsaved changes const hasChanges = useMemo(() => { - if (activeTab === 'raw') { - return rawJsonEdits !== null; + if (rawJsonEdits !== null) { + return rawJsonEdits !== JSON.stringify(settings, null, 2); } return Object.keys(localEdits).length > 0; - }, [activeTab, rawJsonEdits, localEdits]); + }, [rawJsonEdits, localEdits, settings]); // Save mutation const saveMutation = useMutation({ mutationFn: async () => { let settingsToSave: Settings; - if (activeTab === 'raw') { - try { - settingsToSave = JSON.parse(rawJsonContent); - } catch { - throw new Error('Invalid JSON'); - } - } else { + try { + // Always save from rawJsonContent as it's the source of truth + settingsToSave = JSON.parse(rawJsonContent); + } catch { + // Fallback (should typically not happen if validation is correct) settingsToSave = { ...data?.settings, env: { @@ -180,22 +208,6 @@ export function ProfileEditor({ profileName, onDelete }: ProfileEditorProps) { } }; - const updateEnvValue = (key: string, value: string) => { - setLocalEdits((prev) => ({ - ...prev, - [key]: value, - })); - }; - - const addNewEnvVar = () => { - if (!newEnvKey.trim()) return; - setLocalEdits((prev) => ({ - ...prev, - [newEnvKey.trim()]: '', - })); - setNewEnvKey(''); - }; - const isSensitiveKey = (key: string): boolean => { const sensitivePatterns = [ /^ANTHROPIC_AUTH_TOKEN$/, @@ -212,10 +224,206 @@ export function ProfileEditor({ profileName, onDelete }: ProfileEditorProps) { // Reset state when profile changes const profileKey = profileName; + // Render Left Column Content (Environment + Info + Usage) + const renderFriendlyUI = () => ( +
+ +
+ + + Environment Variables + + + Info & Usage + + +
+ +
+ + {/* Scrollable Environment Variables List */} + +
+ {currentSettings?.env && Object.keys(currentSettings.env).length > 0 ? ( + <> + {Object.entries(currentSettings.env).map(([key, value]) => ( +
+ + {isSensitiveKey(key) ? ( + updateEnvValue(key, e.target.value)} + className="font-mono text-sm h-8" + /> + ) : ( + updateEnvValue(key, e.target.value)} + className="font-mono text-sm h-8" + /> + )} +
+ ))} + + ) : ( +
+

No environment variables configured.

+

+ Add variables using the input below or edit the JSON directly. +

+
+ )} +
+
+ + {/* Fixed Add Input at Bottom */} +
+ +
+ setNewEnvKey(e.target.value.toUpperCase())} + className="font-mono text-sm h-8" + onKeyDown={(e) => e.key === 'Enter' && addNewEnvVar()} + /> + +
+
+
+ + + +
+ {/* Profile Information */} +
+

+ + Profile Information +

+
+ {data && ( + <> +
+ Profile Name + {data.profile} +
+
+ File Path +
+ + {data.path} + + +
+
+
+ Last Modified + {new Date(data.mtime).toLocaleString()} +
+ + )} +
+
+ + {/* Usage */} +
+

Quick Usage

+
+
+ +
+ + ccs {profileName} "prompt" + + +
+
+
+ +
+ + ccs default {profileName} + + +
+
+
+
+
+
+
+
+
+
+ ); + + // Render Right Column Content (Raw JSON Editor) + const renderRawEditor = () => ( + + + Loading editor... +
+ } + > +
+ {!isRawJsonValid && rawJsonEdits !== null && ( +
+ + Invalid JSON syntax +
+ )} +
+
+ +
+
+
+ + ); + return (
{/* Header */} -
+

{profileName}

@@ -243,9 +451,7 @@ export function ProfileEditor({ profileName, onDelete }: ProfileEditorProps) {
) : ( - -
- - - - Environment - - - - Raw JSON - - - - Usage - - - - Info - - + // Split Layout (40% Left / 60% Right) +
+ {/* Left Column: Friendly UI */} +
{renderFriendlyUI()}
+ + {/* Right Column: Raw Editor */} +
+
+ + + Raw Configuration (JSON) + +
+ {renderRawEditor()}
- - {/* Environment Tab */} - - -
- {currentSettings?.env && Object.keys(currentSettings.env).length > 0 ? ( - <> - {Object.entries(currentSettings.env).map(([key, value]) => ( -
- - {isSensitiveKey(key) ? ( - updateEnvValue(key, e.target.value)} - className="font-mono text-sm" - /> - ) : ( - updateEnvValue(key, e.target.value)} - className="font-mono text-sm" - /> - )} -
- ))} - - {/* Add new env var */} -
- -
- setNewEnvKey(e.target.value.toUpperCase())} - className="font-mono text-sm" - onKeyDown={(e) => e.key === 'Enter' && addNewEnvVar()} - /> - -
-
- - ) : ( -
-

No environment variables configured.

-

- Add variables using the Raw JSON tab or the form below. -

-
- setNewEnvKey(e.target.value.toUpperCase())} - className="font-mono text-sm max-w-xs" - onKeyDown={(e) => e.key === 'Enter' && addNewEnvVar()} - /> - -
-
- )} -
-
-
- - {/* Raw JSON Tab */} - - - - Loading editor... -
- } - > -
- {!isRawJsonValid && rawJsonEdits !== null && ( -
- - Invalid JSON syntax -
- )} -
- -
-
- - - - {/* Usage Tab */} - - -
- - - CLI Usage - Use this profile from the command line - - -
- - - ccs {profileName} "your prompt here" - -
-
- - - ccs {profileName} - -
-
- - - ccs default {profileName} - -
-
-
- - - - Environment Variables - Variables set when using this profile - - -
- {currentSettings?.env && Object.keys(currentSettings.env).length > 0 ? ( - Object.entries(currentSettings.env).map(([key, value]) => ( -
- - {key} - - - {isSensitiveKey(key) ? '••••••••' : value} - -
- )) - ) : ( -

- No environment variables set -

- )} -
-
-
-
-
-
- - {/* Info Tab */} - - -
- - - Profile Information - Details about this configuration file - - - {data && ( - <> -
- Profile Name - {data.profile} -
-
- File Path - - {data.path} - -
-
- Last Modified - {new Date(data.mtime).toLocaleString()} -
-
- Variables - {Object.keys(currentSettings?.env || {}).length} configured -
- - )} -
-
-
-
-
- +
)} { + navigator.clipboard.writeText(value); + setHasCopied(true); + setTimeout(() => setHasCopied(false), 2000); + }; + + return ( + + + + + + +

{hasCopied ? 'Copied!' : label}

+
+
+
+ ); +} diff --git a/ui/src/pages/api.tsx b/ui/src/pages/api.tsx index d0229df3..aa21b1d2 100644 --- a/ui/src/pages/api.tsx +++ b/ui/src/pages/api.tsx @@ -26,6 +26,7 @@ import { useProfiles, useDeleteProfile } from '@/hooks/use-profiles'; import { ConfirmDialog } from '@/components/confirm-dialog'; import type { Profile } from '@/lib/api-client'; import { cn } from '@/lib/utils'; +import { CopyButton } from '@/components/ui/copy-button'; export function ApiPage() { const { data, isLoading } = useProfiles(); @@ -74,7 +75,7 @@ export function ApiPage() { const selectedProfileData = profiles.find((p) => p.name === effectiveSelectedProfile); return ( -
+
{/* Left Panel - Profiles List */}
{/* Header */} @@ -248,7 +249,16 @@ function ProfileListItem({ {/* Profile info */}
{profile.name}
-
{profile.settingsPath}
+
+
+ {profile.settingsPath} +
+ +
{/* Actions */} From 46ee1df0836fac4bb6b4b75413846163ced2fc6f Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Wed, 10 Dec 2025 22:50:01 -0500 Subject: [PATCH 13/17] fix(ui): resolve layout and theme issues in profile editor - Fix overflow issues in main layout by removing pb-12 and adding min-h-0 - Fix code editor theme switching by adding key prop to force re-render - Fix localhost disclaimer positioning by removing fixed positioning and redundant padding - Simplify analytics page layout structure and adjust spacing --- ui/src/App.tsx | 2 +- ui/src/components/code-editor.tsx | 1 + ui/src/components/localhost-disclaimer.tsx | 2 +- ui/src/pages/analytics.tsx | 422 ++++++++++----------- 4 files changed, 213 insertions(+), 214 deletions(-) diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 8ba2fe22..8b4801b3 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -50,7 +50,7 @@ function Layout() {
-
+
}> diff --git a/ui/src/components/code-editor.tsx b/ui/src/components/code-editor.tsx index 68a8bc8c..16243dc6 100644 --- a/ui/src/components/code-editor.tsx +++ b/ui/src/components/code-editor.tsx @@ -126,6 +126,7 @@ export function CodeEditor({ value={value} onValueChange={readonly ? () => {} : onChange} highlight={highlightCode} + key={isDark ? 'dark' : 'light'} padding={12} disabled={readonly} onFocus={() => setIsFocused(true)} diff --git a/ui/src/components/localhost-disclaimer.tsx b/ui/src/components/localhost-disclaimer.tsx index c7947163..78d5c6e1 100644 --- a/ui/src/components/localhost-disclaimer.tsx +++ b/ui/src/components/localhost-disclaimer.tsx @@ -7,7 +7,7 @@ export function LocalhostDisclaimer() { if (dismissed) return null; return ( -
+
diff --git a/ui/src/pages/analytics.tsx b/ui/src/pages/analytics.tsx index f0f03dc1..dc2c313b 100644 --- a/ui/src/pages/analytics.tsx +++ b/ui/src/pages/analytics.tsx @@ -96,230 +96,228 @@ export function AnalyticsPage() { }, []); return ( -
-
- {/* Header */} -
-
-

Analytics

-

Track usage & insights

-
-
- - {lastUpdatedText && ( - - Updated {lastUpdatedText} - - )} - -
+
+ {/* Header */} +
+
+

Analytics

+

Track usage & insights

+
+ + {lastUpdatedText && ( + + Updated {lastUpdatedText} + + )} + +
+
- {/* Summary Cards */} - + {/* Summary Cards */} + - {/* Main Content */} -
- {/* Usage Trend Chart - Full Width */} - + {/* Main Content */} +
+ {/* Usage Trend Chart - Full Width */} + + + + + Usage Trends + + + + + + + + {/* Bottom Row - Cost by Model (4) + Model Usage (2) + Session Stats (4) */} +
+ {/* Cost by Model - 4/10 width with breakdown */} + - - Usage Trends + + Cost by Model - - + + {isModelsLoading ? ( + + ) : ( +
+ {[...(models || [])] + .sort((a, b) => b.cost - a.cost) + .map((model) => ( + + ))} + {/* Legend */} +
+ +
+ Input + + +
+ Output + + +
+ Cache Write + + +
+ Cache Read + +
+
+ )} - {/* Bottom Row - Cost by Model (4) + Model Usage (2) + Session Stats (4) */} -
- {/* Cost by Model - 4/10 width with breakdown */} - - - - - Cost by Model - - - - {isModelsLoading ? ( - - ) : ( -
- {[...(models || [])] - .sort((a, b) => b.cost - a.cost) - .map((model) => ( - - ))} - {/* Legend */} -
- -
- Input - - -
- Output - - -
- Cache Write - - -
- Cache Read - -
-
- )} - - - - {/* Model Distribution - 2/10 width */} - - - - - Model Usage - - - - - - - - {/* Session Stats - 2/10 width */} - - - {/* Usage Insights - 2/10 width */} - -
- - {/* Model Details Popover - positioned at cursor */} - !open && handlePopoverClose()}> - -
+ + + + Model Usage + + + + - - - {selectedModel && } - - + + + + {/* Session Stats - 2/10 width */} + + + {/* Usage Insights - 2/10 width */} +
+ + {/* Model Details Popover - positioned at cursor */} + !open && handlePopoverClose()}> + +
+ + + {selectedModel && } + +
); From 720ff9d7d6eb881a73547daab262030fb619e5ee Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Wed, 10 Dec 2025 23:38:43 -0500 Subject: [PATCH 14/17] feat(profile): refactor create UX with dialog-based interface - Replace ProfileCreateForm with ProfileCreateDialog for better UX - Add Select component for dropdown functionality - Implement sensitive keys detection and masking - Enhance code editor with improved validation - Update dependencies and lock files --- ui/bun.lock | 7 + ui/package.json | 1 + ui/src/components/code-editor.tsx | 120 +++++-- ui/src/components/profile-create-dialog.tsx | 344 ++++++++++++++++++++ ui/src/components/profile-create-form.tsx | 319 ------------------ ui/src/components/ui/select.tsx | 150 +++++++++ ui/src/lib/sensitive-keys.ts | 35 ++ ui/src/pages/api.tsx | 38 +-- 8 files changed, 648 insertions(+), 366 deletions(-) create mode 100644 ui/src/components/profile-create-dialog.tsx delete mode 100644 ui/src/components/profile-create-form.tsx create mode 100644 ui/src/components/ui/select.tsx create mode 100644 ui/src/lib/sensitive-keys.ts diff --git a/ui/bun.lock b/ui/bun.lock index f2f7ca2e..c677c243 100644 --- a/ui/bun.lock +++ b/ui/bun.lock @@ -12,6 +12,7 @@ "@radix-ui/react-label": "^2.1.8", "@radix-ui/react-popover": "^1.1.15", "@radix-ui/react-scroll-area": "^1.2.10", + "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-tabs": "^1.1.13", @@ -245,6 +246,8 @@ "@radix-ui/react-scroll-area": ["@radix-ui/react-scroll-area@1.2.10", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A=="], + "@radix-ui/react-select": ["@radix-ui/react-select@2.2.6", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ=="], + "@radix-ui/react-separator": ["@radix-ui/react-separator@1.1.8", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-sDvqVY4itsKwwSMEe0jtKgfTh+72Sy3gPmQpjqcQneqQ4PFmr/1I0YA+2/puilhggCe2gJcx5EBAYFkWkdpa5g=="], "@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA=="], @@ -263,6 +266,8 @@ "@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ=="], + "@radix-ui/react-use-previous": ["@radix-ui/react-use-previous@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ=="], + "@radix-ui/react-use-rect": ["@radix-ui/react-use-rect@1.1.1", "", { "dependencies": { "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w=="], "@radix-ui/react-use-size": ["@radix-ui/react-use-size@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ=="], @@ -807,6 +812,8 @@ "@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + "@radix-ui/react-select/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + "@radix-ui/react-separator/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.4", "", { "dependencies": { "@radix-ui/react-slot": "1.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg=="], "@radix-ui/react-tooltip/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], diff --git a/ui/package.json b/ui/package.json index 7e02cbdb..9b90a795 100644 --- a/ui/package.json +++ b/ui/package.json @@ -23,6 +23,7 @@ "@radix-ui/react-label": "^2.1.8", "@radix-ui/react-popover": "^1.1.15", "@radix-ui/react-scroll-area": "^1.2.10", + "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-tabs": "^1.1.13", diff --git a/ui/src/components/code-editor.tsx b/ui/src/components/code-editor.tsx index 16243dc6..d379a7c9 100644 --- a/ui/src/components/code-editor.tsx +++ b/ui/src/components/code-editor.tsx @@ -4,12 +4,14 @@ * Uses react-simple-code-editor + prism-react-renderer for minimal bundle size (~18KB) */ -import { useState, useCallback, useMemo } from 'react'; +import { useState, useCallback, useMemo, useEffect, useRef } from 'react'; import Editor from 'react-simple-code-editor'; import { Highlight, themes } from 'prism-react-renderer'; import { useTheme } from '@/hooks/use-theme'; import { cn } from '@/lib/utils'; -import { AlertCircle, CheckCircle2 } from 'lucide-react'; +import { isSensitiveKey } from '@/lib/sensitive-keys'; +import { AlertCircle, CheckCircle2, Eye, EyeOff } from 'lucide-react'; +import { Button } from '@/components/ui/button'; interface CodeEditorProps { value: string; @@ -71,6 +73,19 @@ export function CodeEditor({ }: CodeEditorProps) { const { isDark } = useTheme(); const [isFocused, setIsFocused] = useState(false); + const [isMasked, setIsMasked] = useState(true); + // Force Editor remount when theme changes (works around react-simple-code-editor caching) + const [editorKey, setEditorKey] = useState(0); + const isFirstRender = useRef(true); + + useEffect(() => { + // Skip first render, only trigger on theme changes + if (isFirstRender.current) { + isFirstRender.current = false; + return; + } + setEditorKey((k) => k + 1); + }, [isDark]); // Validate on every change for JSON const validation = useMemo(() => { @@ -81,32 +96,76 @@ export function CodeEditor({ }, [value, language]); // Highlight function using prism-react-renderer + // Note: Line numbers removed - they break textarea/pre alignment in react-simple-code-editor const highlightCode = useCallback( (code: string) => ( - {({ tokens, getLineProps, getTokenProps }) => ( - <> - {tokens.map((line, i) => ( -
- - {i + 1} - - - {line.map((token, key) => ( - - ))} - -
- ))} - - )} + {({ tokens, getLineProps, getTokenProps }) => { + let nextValueIsSensitive = false; + + return ( + <> + {tokens.map((line, i) => ( +
+ {line.map((token, key) => { + let isSensitive = false; + + // Check for sensitive keys + if (token.types.includes('property')) { + const content = token.content.replace(/['"]/g, ''); + // Use shared sensitive key detection utility + if (isSensitiveKey(content)) { + nextValueIsSensitive = true; + } else { + nextValueIsSensitive = false; + } + } + // Apply masking to values following sensitive keys + else if ( + (token.types.includes('string') || + token.types.includes('number') || + token.types.includes('boolean')) && + nextValueIsSensitive + ) { + isSensitive = true; + // Consumes the flag for this value + nextValueIsSensitive = false; + } + // Reset flag on commas or new keys (handled by property check), + // but persist through colons and whitespace + else if (token.types.includes('punctuation')) { + if ( + token.content !== ':' && + token.content !== '[' && + token.content !== '{' + ) { + nextValueIsSensitive = false; + } + } + + const tokenProps = getTokenProps({ token }); + + if (isSensitive && isMasked) { + tokenProps.className = cn( + tokenProps.className, + 'blur-[3px] select-none opacity-70 transition-all duration-200' + ); + } + + return ; + })} +
+ ))} + + ); + }}
), - [isDark, language, validation.line] + [isDark, language, validation.line, isMasked] ); return ( @@ -126,7 +185,7 @@ export function CodeEditor({ value={value} onValueChange={readonly ? () => {} : onChange} highlight={highlightCode} - key={isDark ? 'dark' : 'light'} + key={editorKey} padding={12} disabled={readonly} onFocus={() => setIsFocused(true)} @@ -142,6 +201,19 @@ export function CodeEditor({ minHeight, }} /> + + {/* Secrets Toggle Overlay */} +
+ +
{/* Validation status */} diff --git a/ui/src/components/profile-create-dialog.tsx b/ui/src/components/profile-create-dialog.tsx new file mode 100644 index 00000000..5506bd45 --- /dev/null +++ b/ui/src/components/profile-create-dialog.tsx @@ -0,0 +1,344 @@ +/** + * Profile Create Dialog Component + * Modal dialog with tabbed interface for creating new API profiles + * Includes Quick Start templates and advanced model configuration + */ + +import { useState, useEffect } from 'react'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import * as z from 'zod'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { Badge } from '@/components/ui/badge'; +import { useCreateProfile } from '@/hooks/use-profiles'; +import { Loader2, Plus, AlertTriangle, Info, Eye, EyeOff } from 'lucide-react'; +import { toast } from 'sonner'; +import { cn } from '@/lib/utils'; + +const DEFAULT_MODEL = 'claude-sonnet-4-5-20250929'; + +const schema = z.object({ + name: z + .string() + .min(1, 'Name is required') + .regex(/^[a-zA-Z][a-zA-Z0-9._-]*$/, 'Must start with letter, only letters/numbers/.-_'), + baseUrl: z.string().url('Invalid URL format'), + apiKey: z.string().min(1, 'API key is required'), + model: z.string().optional(), + opusModel: z.string().optional(), + sonnetModel: z.string().optional(), + haikuModel: z.string().optional(), +}); + +type FormData = z.infer; + +interface ProfileCreateDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onSuccess: (name: string) => void; +} + +// Common URL mistakes to warn about +const PROBLEMATIC_PATHS = ['/chat/completions', '/v1/messages', '/messages', '/completions']; + +export function ProfileCreateDialog({ open, onOpenChange, onSuccess }: ProfileCreateDialogProps) { + const createMutation = useCreateProfile(); + const [activeTab, setActiveTab] = useState('basic'); + const [urlWarning, setUrlWarning] = useState(null); + const [showApiKey, setShowApiKey] = useState(false); + + const { + register, + handleSubmit, + formState: { errors }, + watch, + reset, + } = useForm({ + resolver: zodResolver(schema), + defaultValues: { + name: '', + baseUrl: '', + apiKey: '', + model: '', + opusModel: '', + sonnetModel: '', + haikuModel: '', + }, + }); + + const baseUrlValue = watch('baseUrl'); + + // Reset form when dialog opens + useEffect(() => { + if (open) { + reset(); + setActiveTab('basic'); + setUrlWarning(null); + setShowApiKey(false); + } + }, [open, reset]); + + // Check for common URL mistakes + useEffect(() => { + if (baseUrlValue) { + const lowerUrl = baseUrlValue.toLowerCase(); + for (const path of PROBLEMATIC_PATHS) { + if (lowerUrl.endsWith(path)) { + const suggestedUrl = baseUrlValue.replace(new RegExp(path + '$', 'i'), ''); + setUrlWarning( + `URL ends with "${path}" - Claude appends this automatically. You likely want: ${suggestedUrl}` + ); + return; + } + } + } + setUrlWarning(null); + }, [baseUrlValue]); + + const onSubmit = async (data: FormData) => { + try { + await createMutation.mutateAsync(data); + toast.success(`Profile "${data.name}" created`); + onSuccess(data.name); + onOpenChange(false); + } catch (error) { + toast.error((error as Error).message || 'Failed to create profile'); + } + }; + + const hasBasicErrors = !!errors.name || !!errors.baseUrl || !!errors.apiKey; + const hasModelErrors = + !!errors.model || !!errors.opusModel || !!errors.sonnetModel || !!errors.haikuModel; + + return ( + + + + + + Create API Profile + + Configure a custom API endpoint for Claude Code. + + +
+ +
+ + + Basic Information + {hasBasicErrors && ( + + )} + + + Model Configuration + {hasModelErrors && ( + + )} + + +
+ +
+ +
+ {/* Name */} +
+ + + {errors.name ? ( +

{errors.name.message}

+ ) : ( +

+ Used in CLI:{' '} + + ccs my-api "prompt" + +

+ )} +
+ + {/* Base URL */} +
+ + + {errors.baseUrl ? ( +

{errors.baseUrl.message}

+ ) : urlWarning ? ( +
+ + {urlWarning} +
+ ) : ( +

+ The endpoint that accepts OpenAI-compatible and Anthropic requests +

+ )} +
+ + {/* API Key */} +
+ +
+ + +
+ {errors.apiKey && ( +

{errors.apiKey.message}

+ )} +
+
+
+ + +
+ +
+

Model Mapping

+

+ Claude Code requests specific model tiers (Opus/Sonnet/Haiku). Map these tiers + to the specific models supported by your API provider. +

+
+
+ +
+
+ + +

+ Fallback model if no specific tier is requested +

+
+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+
+
+
+
+ + + + + +
+
+
+
+ ); +} diff --git a/ui/src/components/profile-create-form.tsx b/ui/src/components/profile-create-form.tsx deleted file mode 100644 index 8674ecf0..00000000 --- a/ui/src/components/profile-create-form.tsx +++ /dev/null @@ -1,319 +0,0 @@ -/** - * Profile Create Form Component - * Inline form for creating new API profiles with model mapping - */ - -import { useState, useEffect } from 'react'; -import { useForm } from 'react-hook-form'; -import { zodResolver } from '@hookform/resolvers/zod'; -import * as z from 'zod'; -import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; -import { Label } from '@/components/ui/label'; -import { ScrollArea } from '@/components/ui/scroll-area'; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; -import { Badge } from '@/components/ui/badge'; -import { useCreateProfile } from '@/hooks/use-profiles'; -import { - ArrowLeft, - Loader2, - Plus, - ChevronDown, - ChevronRight, - AlertTriangle, - HelpCircle, -} from 'lucide-react'; -import { toast } from 'sonner'; - -const DEFAULT_MODEL = 'claude-sonnet-4-5-20250929'; - -const schema = z.object({ - name: z - .string() - .min(1, 'Name is required') - .regex(/^[a-zA-Z][a-zA-Z0-9._-]*$/, 'Must start with letter, only letters/numbers/.-_'), - baseUrl: z.string().url('Invalid URL format'), - apiKey: z.string().min(10, 'API key must be at least 10 characters'), - model: z.string().optional(), - opusModel: z.string().optional(), - sonnetModel: z.string().optional(), - haikuModel: z.string().optional(), -}); - -type FormData = z.infer; - -interface ProfileCreateFormProps { - onSuccess: (name: string) => void; - onCancel: () => void; -} - -// Common URL mistakes to warn about -const PROBLEMATIC_PATHS = ['/chat/completions', '/v1/messages', '/messages', '/completions']; - -export function ProfileCreateForm({ onSuccess, onCancel }: ProfileCreateFormProps) { - const createMutation = useCreateProfile(); - const [showModelMapping, setShowModelMapping] = useState(false); - const [urlWarning, setUrlWarning] = useState(null); - - const { - register, - handleSubmit, - formState: { errors }, - watch, - } = useForm({ - resolver: zodResolver(schema), - defaultValues: { - model: '', - opusModel: '', - sonnetModel: '', - haikuModel: '', - }, - }); - - const modelValue = watch('model'); - const baseUrlValue = watch('baseUrl'); - - // Auto-expand model mapping when custom model is entered - useEffect(() => { - if (modelValue && modelValue !== DEFAULT_MODEL && modelValue.trim() !== '') { - setShowModelMapping(true); - } - }, [modelValue]); - - // Check for common URL mistakes - useEffect(() => { - if (baseUrlValue) { - const lowerUrl = baseUrlValue.toLowerCase(); - for (const path of PROBLEMATIC_PATHS) { - if (lowerUrl.endsWith(path)) { - const suggestedUrl = baseUrlValue.replace(new RegExp(path + '$', 'i'), ''); - setUrlWarning( - `URL ends with "${path}" - Claude appends this automatically. You likely want: ${suggestedUrl}` - ); - return; - } - } - } - setUrlWarning(null); - }, [baseUrlValue]); - - const onSubmit = async (data: FormData) => { - try { - await createMutation.mutateAsync(data); - toast.success(`Profile "${data.name}" created`); - onSuccess(data.name); - } catch (error) { - toast.error((error as Error).message || 'Failed to create profile'); - } - }; - - return ( -
- {/* Header */} -
- -
-

Create API Profile

-

Configure a new custom API endpoint

-
-
- - -
-
- {/* Basic Info Card */} - - - Basic Information - Profile name and API connection details - - - {/* Name */} -
- - - {errors.name ? ( -

{errors.name.message}

- ) : ( -

- Used as: ccs my-api "prompt" -

- )} -
- - {/* Base URL */} -
- - - {errors.baseUrl ? ( -

{errors.baseUrl.message}

- ) : urlWarning ? ( -
- - {urlWarning} -
- ) : ( -

- Base URL without /chat/completions (Claude adds this) -

- )} -
- - {/* API Key */} -
- - - {errors.apiKey && ( -

{errors.apiKey.message}

- )} -
-
-
- - {/* Model Configuration Card */} - - - Model Configuration - Configure which models to use with this API - - - {/* Default Model */} -
- - -

- Leave blank to use: {DEFAULT_MODEL} -

-
- - {/* Model Mapping Expander */} -
- - - {showModelMapping && ( -
-
- - - Configure different model IDs for each tier. Useful for API proxies that - route Opus/Sonnet/Haiku to different backends. - -
- -
- - -
- -
- - -
- -
- - -
-
- )} -
-
-
- - {/* Actions */} -
- - -
-
-
-
-
- ); -} diff --git a/ui/src/components/ui/select.tsx b/ui/src/components/ui/select.tsx new file mode 100644 index 00000000..fad1c32d --- /dev/null +++ b/ui/src/components/ui/select.tsx @@ -0,0 +1,150 @@ +import * as React from 'react'; +import * as SelectPrimitive from '@radix-ui/react-select'; +import { Check, ChevronDown, ChevronUp } from 'lucide-react'; + +import { cn } from '@/lib/utils'; + +const Select = SelectPrimitive.Root; + +const SelectGroup = SelectPrimitive.Group; + +const SelectValue = SelectPrimitive.Value; + +const SelectTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + span]:line-clamp-1', + className + )} + {...props} + > + {children} + + + + +)); +SelectTrigger.displayName = SelectPrimitive.Trigger.displayName; + +const SelectScrollUpButton = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)); +SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName; + +const SelectScrollDownButton = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)); +SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName; + +const SelectContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, position = 'popper', ...props }, ref) => ( + + + + + {children} + + + + +)); +SelectContent.displayName = SelectPrimitive.Content.displayName; + +const SelectLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +SelectLabel.displayName = SelectPrimitive.Label.displayName; + +const SelectItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + {children} + +)); +SelectItem.displayName = SelectPrimitive.Item.displayName; + +const SelectSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +SelectSeparator.displayName = SelectPrimitive.Separator.displayName; + +export { + Select, + SelectGroup, + SelectValue, + SelectTrigger, + SelectContent, + SelectLabel, + SelectItem, + SelectSeparator, + SelectScrollUpButton, + SelectScrollDownButton, +}; diff --git a/ui/src/lib/sensitive-keys.ts b/ui/src/lib/sensitive-keys.ts new file mode 100644 index 00000000..c4f1cc98 --- /dev/null +++ b/ui/src/lib/sensitive-keys.ts @@ -0,0 +1,35 @@ +/** + * Sensitive Key Detection Utilities (UI) + * + * Re-exports from main package for use in UI components. + * Patterns detect API keys, tokens, passwords, etc. + */ + +/** + * Patterns that match sensitive keys (API keys, tokens, passwords). + * More specific than substring matching to avoid false positives. + */ +export const SENSITIVE_KEY_PATTERNS = [ + /^ANTHROPIC_AUTH_TOKEN$/, // Exact match for Anthropic auth token + /_API_KEY$/, // Keys ending with _API_KEY + /_AUTH_TOKEN$/, // Keys ending with _AUTH_TOKEN + /_SECRET$/, // Keys ending with _SECRET + /_SECRET_KEY$/, // Keys ending with _SECRET_KEY + /^API_KEY$/, // Exact match for API_KEY + /^AUTH_TOKEN$/, // Exact match for AUTH_TOKEN + /^SECRET$/, // Exact match for SECRET + /_PASSWORD$/, // Keys ending with _PASSWORD + /^PASSWORD$/, // Exact match for PASSWORD + /_CREDENTIAL$/, // Keys ending with _CREDENTIAL + /_PRIVATE_KEY$/, // Keys ending with _PRIVATE_KEY +]; + +/** + * Check if a key name contains a secret/sensitive value. + * + * @param key - Environment variable key name + * @returns true if the key likely contains sensitive data + */ +export function isSensitiveKey(key: string): boolean { + return SENSITIVE_KEY_PATTERNS.some((pattern) => pattern.test(key)); +} diff --git a/ui/src/pages/api.tsx b/ui/src/pages/api.tsx index aa21b1d2..cde4a4d6 100644 --- a/ui/src/pages/api.tsx +++ b/ui/src/pages/api.tsx @@ -21,7 +21,7 @@ import { FileJson, } from 'lucide-react'; import { ProfileEditor } from '@/components/profile-editor'; -import { ProfileCreateForm } from '@/components/profile-create-form'; +import { ProfileCreateDialog } from '@/components/profile-create-dialog'; import { useProfiles, useDeleteProfile } from '@/hooks/use-profiles'; import { ConfirmDialog } from '@/components/confirm-dialog'; import type { Profile } from '@/lib/api-client'; @@ -33,7 +33,7 @@ export function ApiPage() { const deleteMutation = useDeleteProfile(); const [selectedProfile, setSelectedProfile] = useState(null); const [searchQuery, setSearchQuery] = useState(''); - const [showCreateForm, setShowCreateForm] = useState(false); + const [isCreateDialogOpen, setCreateDialogOpen] = useState(false); const [deleteConfirm, setDeleteConfirm] = useState(null); // Memoize profiles to maintain stable reference @@ -47,12 +47,11 @@ export function ApiPage() { // Compute effective selected profile (auto-select first if none selected) const effectiveSelectedProfile = useMemo(() => { - if (showCreateForm) return null; if (selectedProfile && profiles.some((p) => p.name === selectedProfile)) { return selectedProfile; } return profiles.length > 0 ? profiles[0].name : null; - }, [selectedProfile, profiles, showCreateForm]); + }, [selectedProfile, profiles]); // Handle profile deletion const handleDelete = (name: string) => { @@ -68,7 +67,7 @@ export function ApiPage() { // Handle create success const handleCreateSuccess = (name: string) => { - setShowCreateForm(false); + setCreateDialogOpen(false); setSelectedProfile(name); }; @@ -88,8 +87,7 @@ export function ApiPage() {