diff --git a/src/cliproxy/sync/auto-sync-watcher.ts b/src/cliproxy/sync/auto-sync-watcher.ts new file mode 100644 index 00000000..91046296 --- /dev/null +++ b/src/cliproxy/sync/auto-sync-watcher.ts @@ -0,0 +1,185 @@ +/** + * Auto-Sync Watcher + * + * Watches for profile settings changes and automatically syncs to local CLIProxy config. + * Uses debouncing to prevent sync storms during rapid edits. + */ + +import * as chokidar from 'chokidar'; +import * as path from 'path'; +import { getCcsDir } from '../../utils/config-manager'; +import { loadOrCreateUnifiedConfig } from '../../config/unified-config-loader'; +import { syncToLocalConfig } from './local-config-sync'; + +/** Debounce delay in milliseconds */ +const DEBOUNCE_MS = 3000; + +/** Singleton watcher instance */ +let watcherInstance: chokidar.FSWatcher | null = null; +let syncTimeout: NodeJS.Timeout | null = null; +let isSyncing = false; + +/** + * Check if auto-sync is enabled in config. + */ +export function isAutoSyncEnabled(): boolean { + try { + const config = loadOrCreateUnifiedConfig(); + // For local sync, check cliproxy.auto_sync (simpler config location) + return config.cliproxy?.auto_sync === true; + } catch { + return false; + } +} + +/** + * Log auto-sync message. + */ +function log(message: string): void { + console.log(`[auto-sync] ${message}`); +} + +/** + * Execute sync to local CLIProxy config. + */ +async function triggerSync(): Promise { + if (isSyncing) { + log('Sync already in progress, skipping'); + return; + } + + if (!isAutoSyncEnabled()) { + log('Auto-sync disabled, skipping'); + return; + } + + isSyncing = true; + + try { + const result = syncToLocalConfig(); + + if (!result.success) { + log(`Sync failed: ${result.error}`); + return; + } + + if (result.syncedCount === 0) { + log('No profiles to sync'); + return; + } + + log(`Success: ${result.syncedCount} profile(s) synced to ${result.configPath}`); + } catch (error) { + log(`Sync error: ${(error as Error).message}`); + } finally { + isSyncing = false; + } +} + +/** + * Handle file change event with debouncing. + */ +function onFileChange(filePath: string): void { + const fileName = path.basename(filePath); + log(`Profile change detected: ${fileName}`); + + // Clear existing timeout + if (syncTimeout) { + clearTimeout(syncTimeout); + } + + log(`Waiting ${DEBOUNCE_MS / 1000}s for additional changes...`); + + // Set new debounced timeout + syncTimeout = setTimeout(() => { + syncTimeout = null; + triggerSync().catch((err) => { + log(`Sync error: ${err.message}`); + }); + }, DEBOUNCE_MS); +} + +/** + * Start the auto-sync watcher. + * Watches ~/.ccs/*.settings.json for changes. + */ +export function startAutoSyncWatcher(): void { + if (watcherInstance) { + log('Watcher already running'); + return; + } + + if (!isAutoSyncEnabled()) { + // Don't start if disabled, but log nothing (called at startup) + return; + } + + const ccsDir = getCcsDir(); + const watchPattern = path.join(ccsDir, '*.settings.json'); + + log(`Starting watcher on ${watchPattern}`); + + watcherInstance = chokidar.watch(watchPattern, { + ignoreInitial: true, // Don't trigger on initial scan + persistent: true, + awaitWriteFinish: { + stabilityThreshold: 500, + pollInterval: 100, + }, + }); + + watcherInstance.on('change', onFileChange); + watcherInstance.on('add', onFileChange); + + watcherInstance.on('error', (error) => { + log(`Watcher error: ${error.message}`); + }); + + log('Watcher started'); +} + +/** + * Stop the auto-sync watcher. + */ +export async function stopAutoSyncWatcher(): Promise { + if (syncTimeout) { + clearTimeout(syncTimeout); + syncTimeout = null; + } + + if (watcherInstance) { + await watcherInstance.close(); + watcherInstance = null; + log('Watcher stopped'); + } +} + +/** + * Restart the watcher (after config change). + */ +export async function restartAutoSyncWatcher(): Promise { + // Wait for any active sync to complete (max 10s) + const maxWait = 10000; + const start = Date.now(); + while (isSyncing && Date.now() - start < maxWait) { + await new Promise((resolve) => setTimeout(resolve, 100)); + } + + await stopAutoSyncWatcher(); + startAutoSyncWatcher(); +} + +/** + * Get watcher status. + */ +export function getAutoSyncStatus(): { + enabled: boolean; + watching: boolean; + syncing: boolean; +} { + return { + enabled: isAutoSyncEnabled(), + watching: watcherInstance !== null, + syncing: isSyncing, + }; +} diff --git a/src/cliproxy/sync/index.ts b/src/cliproxy/sync/index.ts new file mode 100644 index 00000000..cafcad26 --- /dev/null +++ b/src/cliproxy/sync/index.ts @@ -0,0 +1,41 @@ +/** + * CLIProxy Sync Module + * + * Profile sync functionality for syncing CCS API profiles to CLIProxy config. + */ + +// Profile mapper +export type { SyncableProfile, SyncPreviewItem } from './profile-mapper'; +export { + loadSyncableProfiles, + mapProfileToClaudeKey, + generateSyncPayload, + generateSyncPreview, + getSyncableProfileCount, + isProfileSyncable, +} from './profile-mapper'; + +// Model alias config +export type { ModelAlias, ModelAliasConfig } from './model-alias-config'; +export { + getModelAliasesPath, + loadModelAliases, + saveModelAliases, + getProfileAliases, + addProfileAlias, + removeProfileAlias, + listAllAliases, + DEFAULT_MODEL_ALIASES, +} from './model-alias-config'; + +// Local config sync +export { syncToLocalConfig, getLocalSyncStatus } from './local-config-sync'; + +// Auto-sync watcher +export { + startAutoSyncWatcher, + stopAutoSyncWatcher, + restartAutoSyncWatcher, + isAutoSyncEnabled, + getAutoSyncStatus, +} from './auto-sync-watcher'; diff --git a/src/cliproxy/sync/local-config-sync.ts b/src/cliproxy/sync/local-config-sync.ts new file mode 100644 index 00000000..ca19a04e --- /dev/null +++ b/src/cliproxy/sync/local-config-sync.ts @@ -0,0 +1,170 @@ +/** + * Local Config Sync + * + * Syncs CCS API profiles to the local CLIProxy config.yaml. + * Updates only the claude-api-key section, preserving other config. + */ + +import * as fs from 'fs'; +import * as yaml from 'js-yaml'; +import { getCliproxyConfigPath } from '../config-generator'; +import { generateSyncPayload } from './profile-mapper'; +import type { ClaudeKey } from '../management-api-types'; + +/** + * Sync profiles to local CLIProxy config.yaml. + * Merges/replaces the claude-api-key section. + * + * @returns Object with success status and synced count + */ +export function syncToLocalConfig(): { + success: boolean; + syncedCount: number; + configPath: string; + error?: string; +} { + const configPath = getCliproxyConfigPath(); + + try { + // Generate payload from CCS profiles + const payload = generateSyncPayload(); + + if (payload.length === 0) { + return { + success: true, + syncedCount: 0, + configPath, + }; + } + + // Read existing config + if (!fs.existsSync(configPath)) { + return { + success: false, + syncedCount: 0, + configPath, + error: 'CLIProxy config not found. Run ccs doctor to generate.', + }; + } + + const configContent = fs.readFileSync(configPath, 'utf8'); + const parsedConfig = yaml.load(configContent); + + // Validate config is an object + if (!parsedConfig || typeof parsedConfig !== 'object' || Array.isArray(parsedConfig)) { + return { + success: false, + syncedCount: 0, + configPath, + error: 'Invalid config.yaml format. Expected object, got ' + typeof parsedConfig, + }; + } + + const config = parsedConfig as Record; + + // Transform payload to config format + const claudeApiKeys = payload.map(transformToConfigFormat); + + // Update only claude-api-key section + config['claude-api-key'] = claudeApiKeys; + + // Write back with preserved formatting + const newContent = yaml.dump(config, { + indent: 2, + lineWidth: -1, // No wrapping + }); + + // Atomic write with cleanup on failure + const tempPath = configPath + '.tmp'; + try { + fs.writeFileSync(tempPath, newContent, { mode: 0o600 }); + fs.renameSync(tempPath, configPath); + } catch (writeError) { + // Clean up temp file if it exists + if (fs.existsSync(tempPath)) { + try { + fs.unlinkSync(tempPath); + } catch { + // Ignore cleanup errors + } + } + throw writeError; + } + + return { + success: true, + syncedCount: payload.length, + configPath, + }; + } catch (error) { + return { + success: false, + syncedCount: 0, + configPath, + error: (error as Error).message, + }; + } +} + +/** + * Transform ClaudeKey to config.yaml format. + * The config format uses slightly different field names. + */ +function transformToConfigFormat(key: ClaudeKey): Record { + const entry: Record = { + 'api-key': key['api-key'], + }; + + if (key['base-url']) { + entry['base-url'] = key['base-url']; + } + + // Add empty proxy-url (required by CLIProxyAPI) + entry['proxy-url'] = ''; + + if (key.models && key.models.length > 0) { + entry.models = key.models.map((m) => ({ + name: m.name, + alias: m.alias || '', + })); + } + + // Note: prefix is not used in local config - it's for remote routing only + + return entry; +} + +/** + * Get local sync status. + */ +export function getLocalSyncStatus(): { + configExists: boolean; + configPath: string; + currentKeyCount: number; + syncableProfileCount: number; +} { + const configPath = getCliproxyConfigPath(); + let currentKeyCount = 0; + + if (fs.existsSync(configPath)) { + try { + const content = fs.readFileSync(configPath, 'utf8'); + const config = yaml.load(content) as Record; + const keys = config['claude-api-key']; + if (Array.isArray(keys)) { + currentKeyCount = keys.length; + } + } catch { + // Ignore parse errors + } + } + + const payload = generateSyncPayload(); + + return { + configExists: fs.existsSync(configPath), + configPath, + currentKeyCount, + syncableProfileCount: payload.length, + }; +} diff --git a/src/cliproxy/sync/model-alias-config.ts b/src/cliproxy/sync/model-alias-config.ts new file mode 100644 index 00000000..4cccc7f1 --- /dev/null +++ b/src/cliproxy/sync/model-alias-config.ts @@ -0,0 +1,176 @@ +/** + * Model Alias Configuration + * + * Manages model alias mappings for CLIProxy sync. + * Aliases map Claude model names to provider-specific models. + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import { getCcsDir } from '../../utils/config-manager'; + +/** Model alias mapping */ +export interface ModelAlias { + /** Claude model name (e.g., "claude-3-5-sonnet") */ + from: string; + /** Target model (e.g., "glm-4.7-airx-thinking") */ + to: string; +} + +/** Model alias configuration file structure */ +export interface ModelAliasConfig { + /** Version for future schema changes */ + version: number; + /** Aliases for each profile name */ + aliases: Record; +} + +/** Default model aliases (common mappings) */ +export const DEFAULT_MODEL_ALIASES: Record = { + glm: [ + { from: 'claude-sonnet-4-20250514', to: 'glm-4.7-thinking' }, + { from: 'claude-3-5-sonnet-20241022', to: 'glm-4.7' }, + { from: 'claude-3-5-haiku-20241022', to: 'glm-4.7-flash' }, + ], + kimi: [ + { from: 'claude-sonnet-4-20250514', to: 'moonshot-v1-auto' }, + { from: 'claude-3-5-sonnet-20241022', to: 'moonshot-v1-128k' }, + { from: 'claude-3-5-haiku-20241022', to: 'moonshot-v1-32k' }, + ], + qwen: [ + { from: 'claude-sonnet-4-20250514', to: 'qwen-coder-plus' }, + { from: 'claude-3-5-sonnet-20241022', to: 'qwen-plus' }, + { from: 'claude-3-5-haiku-20241022', to: 'qwen-turbo' }, + ], +}; + +/** + * Get path to model aliases config file. + */ +export function getModelAliasesPath(): string { + return path.join(getCcsDir(), 'cliproxy', 'model-aliases.json'); +} + +/** + * Load model alias configuration. + * Returns defaults if file doesn't exist. + */ +export function loadModelAliases(): ModelAliasConfig { + const aliasPath = getModelAliasesPath(); + + try { + if (fs.existsSync(aliasPath)) { + const content = fs.readFileSync(aliasPath, 'utf8'); + const config = JSON.parse(content) as ModelAliasConfig; + return config; + } + } catch { + // Fall through to defaults + } + + // Return defaults + return { + version: 1, + aliases: { ...DEFAULT_MODEL_ALIASES }, + }; +} + +/** + * Save model alias configuration. + * @returns Success status with optional error message + */ +export function saveModelAliases(config: ModelAliasConfig): { success: boolean; error?: string } { + try { + const aliasPath = getModelAliasesPath(); + const dir = path.dirname(aliasPath); + + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + + fs.writeFileSync(aliasPath, JSON.stringify(config, null, 2), 'utf8'); + return { success: true }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : String(error), + }; + } +} + +/** + * Get aliases for a specific profile. + */ +export function getProfileAliases(profileName: string): ModelAlias[] { + const config = loadModelAliases(); + return config.aliases[profileName] ?? []; +} + +/** + * Add an alias for a profile. + */ +export function addProfileAlias(profileName: string, from: string, to: string): void { + // Validate inputs + if (!from || from.trim() === '') { + throw new Error('Model alias "from" cannot be empty'); + } + if (!to || to.trim() === '') { + throw new Error('Model alias "to" cannot be empty'); + } + + const config = loadModelAliases(); + + if (!config.aliases[profileName]) { + config.aliases[profileName] = []; + } + + // Check if alias already exists (update if so) + const existingIdx = config.aliases[profileName].findIndex((a) => a.from === from); + if (existingIdx >= 0) { + config.aliases[profileName][existingIdx].to = to; + } else { + config.aliases[profileName].push({ from, to }); + } + + const result = saveModelAliases(config); + if (!result.success) { + throw new Error(`Failed to save model aliases: ${result.error}`); + } +} + +/** + * Remove an alias for a profile. + */ +export function removeProfileAlias(profileName: string, from: string): boolean { + const config = loadModelAliases(); + + if (!config.aliases[profileName]) { + return false; + } + + const initialLen = config.aliases[profileName].length; + config.aliases[profileName] = config.aliases[profileName].filter((a) => a.from !== from); + + if (config.aliases[profileName].length === initialLen) { + return false; + } + + // Remove empty profile entry + if (config.aliases[profileName].length === 0) { + delete config.aliases[profileName]; + } + + const result = saveModelAliases(config); + if (!result.success) { + throw new Error(`Failed to save model aliases: ${result.error}`); + } + return true; +} + +/** + * List all aliases. + */ +export function listAllAliases(): Record { + const config = loadModelAliases(); + return config.aliases; +} diff --git a/src/cliproxy/sync/profile-mapper.ts b/src/cliproxy/sync/profile-mapper.ts new file mode 100644 index 00000000..6929fec3 --- /dev/null +++ b/src/cliproxy/sync/profile-mapper.ts @@ -0,0 +1,199 @@ +/** + * Profile Mapper for CLIProxy Sync + * + * Transforms CCS settings-based profiles into CLIProxy ClaudeKey format. + * Handles model alias mapping and settings normalization. + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import { getCcsDir } from '../../utils/config-manager'; +import { listApiProfiles, isApiProfileConfigured } from '../../api/services/profile-reader'; +import type { ClaudeKey, ClaudeModel } from '../management-api-types'; +import { getProfileAliases, type ModelAlias } from './model-alias-config'; + +/** + * Profile info with settings for sync. + */ +export interface SyncableProfile { + /** Profile name (e.g., "glm", "kimi") */ + name: string; + /** Path to settings.json file */ + settingsPath: string; + /** Whether profile has valid API key */ + isConfigured: boolean; + /** Environment variables from settings.json */ + env?: Record; +} + +/** + * Settings.json file structure (Claude compatible). + */ +interface SettingsJson { + env?: Record; +} + +/** + * Load syncable API profiles from CCS config. + * Filters to only configured profiles (with real API keys). + */ +export function loadSyncableProfiles(): SyncableProfile[] { + const { profiles } = listApiProfiles(); + const syncable: SyncableProfile[] = []; + + for (const profile of profiles) { + // Skip unconfigured profiles + if (!profile.isConfigured) { + continue; + } + + // Load settings.json for env vars + const ccsDir = getCcsDir(); + const settingsPath = path.join(ccsDir, `${profile.name}.settings.json`); + + let env: Record | undefined; + try { + if (fs.existsSync(settingsPath)) { + const content = fs.readFileSync(settingsPath, 'utf8'); + const settings = JSON.parse(content) as SettingsJson; + env = settings.env; + } + } catch { + // Skip profiles with unreadable settings + continue; + } + + // Must have ANTHROPIC_AUTH_TOKEN + const token = env?.ANTHROPIC_AUTH_TOKEN; + if (!token || token.includes('YOUR_') || token.includes('your-')) { + continue; + } + + syncable.push({ + name: profile.name, + settingsPath, + isConfigured: true, + env, + }); + } + + return syncable; +} + +/** + * Map model aliases to ClaudeModel format. + */ +function mapAliasesToClaudeModels(aliases: ModelAlias[]): ClaudeModel[] { + return aliases.map((alias) => ({ + name: alias.from, + alias: alias.to, + })); +} + +/** + * Sanitize profile name for YAML safety. + * Replaces non-alphanumeric chars (except - and _) with hyphens. + */ +function sanitizeProfileName(name: string): string { + return name.replace(/[^a-zA-Z0-9-_]/g, '-'); +} + +/** + * Map a single profile to ClaudeKey format. + */ +export function mapProfileToClaudeKey(profile: SyncableProfile): ClaudeKey | null { + const env = profile.env; + if (!env) return null; + + const apiKey = env.ANTHROPIC_AUTH_TOKEN; + if (!apiKey) return null; + + const baseUrl = env.ANTHROPIC_BASE_URL; + + // Generate prefix from profile name (e.g., "glm" -> "glm-") + const prefix = `${sanitizeProfileName(profile.name)}-`; + + // Load model aliases for this profile + const aliases = getProfileAliases(profile.name); + const models = aliases.length > 0 ? mapAliasesToClaudeModels(aliases) : undefined; + + const claudeKey: ClaudeKey = { + 'api-key': apiKey, + prefix, + }; + + if (baseUrl) { + claudeKey['base-url'] = baseUrl; + } + + if (models && models.length > 0) { + claudeKey.models = models; + } + + return claudeKey; +} + +/** + * Generate sync payload from all configured profiles. + * Returns array of ClaudeKey ready to push to remote CLIProxy. + */ +export function generateSyncPayload(): ClaudeKey[] { + const profiles = loadSyncableProfiles(); + const keys: ClaudeKey[] = []; + + for (const profile of profiles) { + const key = mapProfileToClaudeKey(profile); + if (key) { + keys.push(key); + } + } + + return keys; +} + +/** + * Generate sync preview with profile details. + * Used for dry-run mode to show what would be synced. + */ +export interface SyncPreviewItem { + /** Profile name */ + name: string; + /** Base URL (masked) */ + baseUrl?: string; + /** Whether profile has model aliases */ + hasAliases: boolean; + /** Number of model aliases */ + aliasCount: number; +} + +export function generateSyncPreview(): SyncPreviewItem[] { + const profiles = loadSyncableProfiles(); + const preview: SyncPreviewItem[] = []; + + for (const profile of profiles) { + const aliases = getProfileAliases(profile.name); + + preview.push({ + name: profile.name, + baseUrl: profile.env?.ANTHROPIC_BASE_URL, + hasAliases: aliases.length > 0, + aliasCount: aliases.length, + }); + } + + return preview; +} + +/** + * Get count of syncable profiles. + */ +export function getSyncableProfileCount(): number { + return loadSyncableProfiles().length; +} + +/** + * Check if profile is syncable (configured with valid API key). + */ +export function isProfileSyncable(profileName: string): boolean { + return isApiProfileConfigured(profileName); +}