From 29f19308e627f46a5144521f8f2c75e9ff746f6a Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Fri, 26 Dec 2025 13:31:27 -0500 Subject: [PATCH 01/16] fix(cliproxy): ensure version sync after binary update - Add SIGKILL escalation after 3s SIGTERM timeout in session-tracker - Auto-stop running proxy before binary update in binary-manager - Add waitForPortFree utility to port-utils - Detect version mismatch on startup and auto-restart outdated proxy - Store and expose version in session lock for detection Fixes issue where old proxy version continues running after update, causing UI to show different version than actually running. --- src/cliproxy/binary-manager.ts | 26 ++++++++++- src/cliproxy/cliproxy-executor.ts | 44 ++++++++++++++++--- src/cliproxy/proxy-detector.ts | 18 ++++++-- src/cliproxy/session-tracker.ts | 72 ++++++++++++++++++++++++++++++- src/utils/port-utils.ts | 26 +++++++++++ 5 files changed, 174 insertions(+), 12 deletions(-) diff --git a/src/cliproxy/binary-manager.ts b/src/cliproxy/binary-manager.ts index 4e23f880..51a33d45 100644 --- a/src/cliproxy/binary-manager.ts +++ b/src/cliproxy/binary-manager.ts @@ -5,10 +5,12 @@ * Pattern: Mirrors npm install behavior (fast check, download only when needed) */ -import { info } from '../utils/ui'; -import { getBinDir } from './config-generator'; +import { info, warn } from '../utils/ui'; +import { getBinDir, CLIPROXY_DEFAULT_PORT } from './config-generator'; import { BinaryInfo, BinaryManagerConfig } from './types'; import { CLIPROXY_FALLBACK_VERSION } from './platform-detector'; +import { isProxyRunning, stopProxy } from './services/proxy-lifecycle-service'; +import { waitForPortFree } from '../utils/port-utils'; import { UpdateCheckResult, checkForUpdates, @@ -108,12 +110,32 @@ export function getInstalledCliproxyVersion(): string { /** Install a specific version of CLIProxyAPI */ export async function installCliproxyVersion(version: string, verbose = false): Promise { const manager = new BinaryManager({ version, verbose, forceVersion: true }); + + // Check if proxy is running and stop it first + if (isProxyRunning()) { + if (verbose) console.log(info('Stopping running CLIProxy before update...')); + const result = await stopProxy(); + if (result.stopped) { + // Wait for port to be fully released + const portFree = await waitForPortFree(CLIPROXY_DEFAULT_PORT, 5000); + if (!portFree && verbose) { + console.log(warn('Port did not free up in time, proceeding anyway...')); + } + } else if (verbose && result.error) { + console.log(warn(`Could not stop proxy: ${result.error}`)); + } + } + if (manager.isBinaryInstalled()) { if (verbose) console.log(info(`Removing existing CLIProxy Plus v${getInstalledCliproxyVersion()}`)); manager.deleteBinary(); } await manager.ensureBinary(); + + if (verbose) { + console.log(info('New version will be active on next CLIProxy command')); + } } /** Fetch the latest CLIProxyAPI version from GitHub API */ diff --git a/src/cliproxy/cliproxy-executor.ts b/src/cliproxy/cliproxy-executor.ts index 17ce150f..c2813dce 100644 --- a/src/cliproxy/cliproxy-executor.ts +++ b/src/cliproxy/cliproxy-executor.ts @@ -17,7 +17,7 @@ import * as net from 'net'; import { ProgressIndicator } from '../utils/progress-indicator'; import { ok, fail, info, warn } from '../utils/ui'; import { escapeShellArg } from '../utils/shell-executor'; -import { ensureCLIProxyBinary } from './binary-manager'; +import { ensureCLIProxyBinary, getInstalledCliproxyVersion } from './binary-manager'; import { generateConfig, getEffectiveEnvVars, @@ -48,7 +48,12 @@ import { installWebSearchHook, displayWebSearchStatus, } from '../utils/websearch-manager'; -import { registerSession, unregisterSession, cleanupOrphanedSessions } from './session-tracker'; +import { + registerSession, + unregisterSession, + cleanupOrphanedSessions, + stopProxy, +} from './session-tracker'; import { detectRunningProxy, waitForProxyHealthy, reclaimOrphanedProxy } from './proxy-detector'; import { withStartupLock } from './startup-lock'; import { loadOrCreateUnifiedConfig } from '../config/unified-config-loader'; @@ -429,9 +434,33 @@ export async function execClaudeWithCLIProxy( // Use startup lock to coordinate with other CCS processes await withStartupLock(async () => { // Detect running proxy using multiple methods (HTTP, session-lock, port-process) - const proxyStatus = await detectRunningProxy(cfg.port); + let proxyStatus = await detectRunningProxy(cfg.port); log(`Proxy detection: ${JSON.stringify(proxyStatus)}`); + // Check for version mismatch - restart proxy if installed version differs from running + if (proxyStatus.running && proxyStatus.verified && proxyStatus.version) { + const installedVersion = getInstalledCliproxyVersion(); + if (installedVersion !== proxyStatus.version) { + console.log( + warn( + `Version mismatch: running v${proxyStatus.version}, installed v${installedVersion}. Restarting proxy...` + ) + ); + log(`Stopping outdated proxy (PID: ${proxyStatus.pid ?? 'unknown'})...`); + const stopResult = await stopProxy(cfg.port); + if (stopResult.stopped) { + log(`Stopped outdated proxy successfully`); + } else { + log(`Stop proxy result: ${stopResult.error ?? 'unknown error'}`); + } + // Wait for port to be released + await new Promise((r) => setTimeout(r, 500)); + // Re-detect proxy status (should now be not running) + proxyStatus = await detectRunningProxy(cfg.port); + log(`Re-detection after version mismatch restart: ${JSON.stringify(proxyStatus)}`); + } + } + if (proxyStatus.running && proxyStatus.verified) { // Healthy proxy found - join it if (proxyStatus.pid) { @@ -542,9 +571,12 @@ export async function execClaudeWithCLIProxy( throw new Error(`CLIProxy startup failed: ${err.message}`); } - // Register this session with the new proxy - sessionId = registerSession(cfg.port, proxy.pid as number); - log(`Registered session ${sessionId} with new proxy (PID ${proxy.pid})`); + // Register this session with the new proxy, including the installed version + const installedVersion = getInstalledCliproxyVersion(); + sessionId = registerSession(cfg.port, proxy.pid as number, installedVersion); + log( + `Registered session ${sessionId} with new proxy (PID ${proxy.pid}, version ${installedVersion})` + ); }); } diff --git a/src/cliproxy/proxy-detector.ts b/src/cliproxy/proxy-detector.ts index 1a7bc812..d1d6afe6 100644 --- a/src/cliproxy/proxy-detector.ts +++ b/src/cliproxy/proxy-detector.ts @@ -15,7 +15,7 @@ * Solves race conditions between cliproxy-executor.ts and service-manager.ts */ -import { getExistingProxy, registerSession } from './session-tracker'; +import { getExistingProxy, registerSession, getRunningProxyVersion } from './session-tracker'; import { isCliproxyRunning } from './stats-fetcher'; import { getPortProcess, isCLIProxyProcess, PortProcess } from '../utils/port-utils'; @@ -38,6 +38,8 @@ export interface ProxyStatus { blocker?: PortProcess; /** Number of active sessions (if session-lock found) */ sessionCount?: number; + /** Version of the running proxy (from session lock) */ + version?: string; } /** Optional logger function for verbose output */ @@ -86,13 +88,19 @@ export async function detectRunningProxy( } } - log(`HTTP check passed, proxy healthy (PID: ${pid ?? 'unknown'})`); + // Get version from session lock + const runningVersion = getRunningProxyVersion(port); + + log( + `HTTP check passed, proxy healthy (PID: ${pid ?? 'unknown'}, version: ${runningVersion ?? 'unknown'})` + ); return { running: true, verified: true, method: 'http', pid, sessionCount: lock?.sessions?.length, + version: runningVersion ?? undefined, }; } log('HTTP check failed, proxy not responding'); @@ -103,13 +111,17 @@ export async function detectRunningProxy( if (lock) { // Session lock exists - proxy might be starting up // The lock validates PID is running, so proxy exists but not ready - log(`Session lock found: PID ${lock.pid}, ${lock.sessions.length} sessions`); + const lockVersion = getRunningProxyVersion(port); + log( + `Session lock found: PID ${lock.pid}, ${lock.sessions.length} sessions, version: ${lockVersion ?? 'unknown'}` + ); return { running: true, verified: false, method: 'session-lock', pid: lock.pid, sessionCount: lock.sessions.length, + version: lockVersion ?? undefined, }; } log('No session lock found'); diff --git a/src/cliproxy/session-tracker.ts b/src/cliproxy/session-tracker.ts index 05d5eee8..2279eeba 100644 --- a/src/cliproxy/session-tracker.ts +++ b/src/cliproxy/session-tracker.ts @@ -27,6 +27,8 @@ interface SessionLock { pid: number; sessions: string[]; startedAt: string; + /** CLIProxy version running (added for version mismatch detection) */ + version?: string; } /** Generate unique session ID */ @@ -125,6 +127,23 @@ function isProcessRunning(pid: number): boolean { } } +/** + * Wait for a process to exit within a timeout. + * @param pid Process ID to wait for + * @param timeoutMs Maximum time to wait in milliseconds + * @returns true if process exited, false if timeout + */ +async function waitForProcessExit(pid: number, timeoutMs: number): Promise { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + if (!isProcessRunning(pid)) { + return true; // Process exited + } + await new Promise((resolve) => setTimeout(resolve, 100)); + } + return false; // Timeout +} + /** * Check if there's an existing proxy running that we can reuse. * Returns the existing lock if proxy is healthy, null otherwise. @@ -153,9 +172,12 @@ export function getExistingProxy(port: number): SessionLock | null { /** * Register a new session with the proxy. * Call this when starting a new CCS session that will use an existing proxy. + * @param port Port the proxy is running on + * @param proxyPid PID of the proxy process + * @param version Optional CLIProxy version (stored when spawning new proxy) * @returns Session ID for this session */ -export function registerSession(port: number, proxyPid: number): string { +export function registerSession(port: number, proxyPid: number, version?: string): string { const sessionId = generateSessionId(); const existingLock = readSessionLockForPort(port); @@ -170,6 +192,7 @@ export function registerSession(port: number, proxyPid: number): string { pid: proxyPid, sessions: [sessionId], startedAt: new Date().toISOString(), + version, }; writeSessionLockForPort(newLock); } @@ -315,6 +338,19 @@ export async function stopProxy(port: number = CLIPROXY_DEFAULT_PORT): Promise<{ // Found CLIProxy running without session lock - kill it try { process.kill(portProcess.pid, 'SIGTERM'); + + // Wait for graceful shutdown + const exited = await waitForProcessExit(portProcess.pid, 3000); + if (!exited) { + // Escalate to SIGKILL + try { + process.kill(portProcess.pid, 'SIGKILL'); + await waitForProcessExit(portProcess.pid, 1000); + } catch { + // Process may have exited between check and kill + } + } + return { stopped: true, pid: portProcess.pid, sessionCount: 0 }; } catch (err) { const error = err as NodeJS.ErrnoException; @@ -338,6 +374,18 @@ export async function stopProxy(port: number = CLIPROXY_DEFAULT_PORT): Promise<{ // Kill the proxy process process.kill(pid, 'SIGTERM'); + // Wait for graceful shutdown + const exited = await waitForProcessExit(pid, 3000); + if (!exited) { + // Escalate to SIGKILL + try { + process.kill(pid, 'SIGKILL'); + await waitForProcessExit(pid, 1000); + } catch { + // Process may have exited between check and kill + } + } + // Clean up session lock deleteSessionLockForPort(port); @@ -362,6 +410,7 @@ export function getProxyStatus(port: number = CLIPROXY_DEFAULT_PORT): { pid?: number; sessionCount?: number; startedAt?: string; + version?: string; } { const lock = readSessionLockForPort(port); @@ -381,5 +430,26 @@ export function getProxyStatus(port: number = CLIPROXY_DEFAULT_PORT): { pid: lock.pid, sessionCount: lock.sessions.length, startedAt: lock.startedAt, + version: lock.version, }; } + +/** + * Get the version of the running proxy from session lock. + * @param port Port to check (defaults to CLIPROXY_DEFAULT_PORT) + * @returns Version string if available, null otherwise + */ +export function getRunningProxyVersion(port: number = CLIPROXY_DEFAULT_PORT): string | null { + const lock = readSessionLockForPort(port); + if (!lock) { + return null; + } + + // Verify proxy is still running + if (!isProcessRunning(lock.pid)) { + deleteSessionLockForPort(port); + return null; + } + + return lock.version ?? null; +} diff --git a/src/utils/port-utils.ts b/src/utils/port-utils.ts index 2359f1ab..31f87208 100644 --- a/src/utils/port-utils.ts +++ b/src/utils/port-utils.ts @@ -181,6 +181,32 @@ export interface BindingTestResult { message: string; } +/** + * Wait for a port to become free (no process listening) + * @param port Port number to wait for + * @param timeoutMs Maximum time to wait in milliseconds (default: 5000) + * @param pollIntervalMs Interval between checks in milliseconds (default: 200) + * @returns True if port became free, false if timeout + */ +export async function waitForPortFree( + port: number, + timeoutMs = 5000, + pollIntervalMs = 200 +): Promise { + const startTime = Date.now(); + + while (Date.now() - startTime < timeoutMs) { + const process = await getPortProcess(port); + if (!process) { + return true; // Port is free + } + // Wait before next check + await new Promise((resolve) => setTimeout(resolve, pollIntervalMs)); + } + + return false; // Timeout reached +} + /** * Test if we can bind to localhost on a specific port * This verifies network stack is working and port is actually available From 0c697406947ef37f194db26e31d5822cc7e12463 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Fri, 26 Dec 2025 13:37:54 -0500 Subject: [PATCH 02/16] fix(dashboard): support unified config.yaml in web server routes Add loadConfigSafe() function that handles both unified (config.yaml) and legacy (config.json) config formats. Uses throwable errors instead of process.exit() so try/catch blocks work properly in web server routes. Updated files to use loadConfigSafe(): - overview-routes.ts: Dashboard overview API - route-helpers.ts: readConfigSafe() helper - profile-reader.ts: API profile reading - profile-writer.ts: API profile writing - variant-config-adapter.ts: CLIProxy variant config Fixes #206 (Problem 2: config.json not found when user has config.yaml) --- src/api/services/profile-reader.ts | 8 +-- src/api/services/profile-writer.ts | 4 +- .../services/variant-config-adapter.ts | 6 +- src/types/index.ts | 1 + src/utils/config-manager.ts | 59 ++++++++++++++++++- src/web-server/overview-routes.ts | 4 +- src/web-server/routes/route-helpers.ts | 7 ++- 7 files changed, 74 insertions(+), 15 deletions(-) diff --git a/src/api/services/profile-reader.ts b/src/api/services/profile-reader.ts index 7e6dd129..2773e0ad 100644 --- a/src/api/services/profile-reader.ts +++ b/src/api/services/profile-reader.ts @@ -7,7 +7,7 @@ import * as fs from 'fs'; import * as path from 'path'; -import { getCcsDir, loadConfig } from '../../utils/config-manager'; +import { getCcsDir, loadConfigSafe } from '../../utils/config-manager'; import { loadOrCreateUnifiedConfig, isUnifiedMode } from '../../config/unified-config-loader'; import type { ApiProfileInfo, CliproxyVariantInfo, ApiListResult } from './profile-types'; @@ -20,7 +20,7 @@ export function apiProfileExists(name: string): boolean { const config = loadOrCreateUnifiedConfig(); return name in config.profiles; } - const config = loadConfig(); + const config = loadConfigSafe(); return name in config.profiles; } catch { return false; @@ -79,7 +79,7 @@ export function listApiProfiles(): ApiListResult { }); } } else { - const config = loadConfig(); + const config = loadConfigSafe(); for (const [name, settingsPath] of Object.entries(config.profiles)) { // Skip 'default' profile - it's the user's native Claude settings if (name === 'default' && (settingsPath as string).includes('.claude/settings.json')) { @@ -116,7 +116,7 @@ export function getApiProfileNames(): string[] { const config = loadOrCreateUnifiedConfig(); return Object.keys(config.profiles); } - const config = loadConfig(); + const config = loadConfigSafe(); return Object.keys(config.profiles); } diff --git a/src/api/services/profile-writer.ts b/src/api/services/profile-writer.ts index fbc42a71..48c34ff1 100644 --- a/src/api/services/profile-writer.ts +++ b/src/api/services/profile-writer.ts @@ -5,7 +5,7 @@ import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; -import { getCcsDir, getConfigPath, loadConfig } from '../../utils/config-manager'; +import { getCcsDir, getConfigPath, loadConfigSafe } from '../../utils/config-manager'; import { expandPath } from '../../utils/helpers'; import { loadOrCreateUnifiedConfig, @@ -166,7 +166,7 @@ function removeApiProfileUnified(name: string): void { /** Remove API profile from legacy config */ function removeApiProfileLegacy(name: string): void { - const config = loadConfig(); + const config = loadConfigSafe(); delete config.profiles[name]; const configPath = getConfigPath(); diff --git a/src/cliproxy/services/variant-config-adapter.ts b/src/cliproxy/services/variant-config-adapter.ts index d4b8331e..4294b956 100644 --- a/src/cliproxy/services/variant-config-adapter.ts +++ b/src/cliproxy/services/variant-config-adapter.ts @@ -5,7 +5,7 @@ */ import * as fs from 'fs'; -import { getConfigPath, loadConfig } from '../../utils/config-manager'; +import { getConfigPath, loadConfigSafe } from '../../utils/config-manager'; import { CLIProxyProvider } from '../types'; import { loadOrCreateUnifiedConfig, @@ -38,7 +38,7 @@ export function variantExistsInConfig(name: string): boolean { const config = loadOrCreateUnifiedConfig(); return !!(config.cliproxy?.variants && name in config.cliproxy.variants); } - const config = loadConfig(); + const config = loadConfigSafe(); return !!(config.cliproxy && name in config.cliproxy); } catch { return false; @@ -94,7 +94,7 @@ export function listVariantsFromConfig(): Record { return result; } - const config = loadConfig(); + const config = loadConfigSafe(); const variants = config.cliproxy || {}; const result: Record = {}; for (const name of Object.keys(variants)) { diff --git a/src/types/index.ts b/src/types/index.ts index 648ae97f..5ed78646 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -12,6 +12,7 @@ export type { EnvValue, ProfileMetadata, ProfilesRegistry, + CLIProxyVariantsConfig, } from './config'; export { isConfig, isSettings } from './config'; diff --git a/src/utils/config-manager.ts b/src/utils/config-manager.ts index 034a47ec..02b4e6db 100644 --- a/src/utils/config-manager.ts +++ b/src/utils/config-manager.ts @@ -1,7 +1,7 @@ import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; -import { Config, isConfig, Settings, isSettings } from '../types'; +import { Config, isConfig, Settings, isSettings, CLIProxyVariantsConfig } from '../types'; import { expandPath, error } from './helpers'; import { info } from './ui'; import { isUnifiedMode, loadOrCreateUnifiedConfig } from '../config/unified-config-loader'; @@ -82,6 +82,63 @@ export function readConfig(): Config { return loadConfig(); } +/** + * Load config safely with unified mode support. + * Returns Config with profiles from unified config.yaml or legacy config.json. + * Throws on error (catchable) instead of process.exit. + * Use this in web server routes where try/catch is needed. + */ +export function loadConfigSafe(): Config { + // Unified mode: extract profiles from config.yaml + if (isUnifiedMode()) { + const unifiedConfig = loadOrCreateUnifiedConfig(); + + // Convert unified profiles to legacy format for compatibility + const profiles: Record = {}; + for (const [name, profile] of Object.entries(unifiedConfig.profiles)) { + if (profile.settings) { + profiles[name] = profile.settings; + } + } + + // Convert unified cliproxy variants to legacy format + let cliproxy: CLIProxyVariantsConfig | undefined; + if (unifiedConfig.cliproxy?.variants) { + cliproxy = {}; + for (const [name, variant] of Object.entries(unifiedConfig.cliproxy.variants)) { + cliproxy[name] = { + // Cast provider - unified has more providers than legacy type + provider: variant.provider as 'gemini' | 'codex' | 'agy' | 'qwen', + settings: variant.settings || '', + account: variant.account, + port: variant.port, + }; + } + } + + return { + profiles, + cliproxy, + }; + } + + // Legacy mode: read config.json + const configPath = getConfigPath(); + + if (!fs.existsSync(configPath)) { + throw new Error(`Config not found: ${configPath}`); + } + + const raw = fs.readFileSync(configPath, 'utf8'); + const parsed: unknown = JSON.parse(raw); + + if (!isConfig(parsed)) { + throw new Error(`Invalid config format: ${configPath}`); + } + + return parsed; +} + /** * Get settings path for profile. * In unified mode (config.yaml exists), reads from config.yaml first, diff --git a/src/web-server/overview-routes.ts b/src/web-server/overview-routes.ts index b2cd6525..2b064ce8 100644 --- a/src/web-server/overview-routes.ts +++ b/src/web-server/overview-routes.ts @@ -5,7 +5,7 @@ */ import { Router, Request, Response } from 'express'; -import { loadConfig } from '../utils/config-manager'; +import { loadConfigSafe } from '../utils/config-manager'; import { runHealthChecks } from './health-service'; import { getAllAuthStatus, initializeAccounts } from '../cliproxy/auth-handler'; import { getVersion } from '../utils/version'; @@ -18,7 +18,7 @@ export const overviewRoutes = Router(); */ overviewRoutes.get('/', async (_req: Request, res: Response) => { try { - const config = loadConfig(); + const config = loadConfigSafe(); const profileCount = Object.keys(config.profiles).length; const cliproxyVariantCount = Object.keys(config.cliproxy || {}).length; diff --git a/src/web-server/routes/route-helpers.ts b/src/web-server/routes/route-helpers.ts index 4478059a..186ba8bc 100644 --- a/src/web-server/routes/route-helpers.ts +++ b/src/web-server/routes/route-helpers.ts @@ -4,7 +4,7 @@ import * as fs from 'fs'; import * as path from 'path'; -import { getCcsDir, getConfigPath, loadConfig, loadSettings } from '../../utils/config-manager'; +import { getCcsDir, getConfigPath, loadConfigSafe, loadSettings } from '../../utils/config-manager'; import { expandPath } from '../../utils/helpers'; import type { Config, Settings } from '../../types/config'; @@ -17,11 +17,12 @@ export interface ModelMapping { } /** - * Read config safely with fallback + * Read config safely with fallback. + * Uses loadConfigSafe which supports both unified (config.yaml) and legacy (config.json). */ export function readConfigSafe(): Config { try { - return loadConfig(); + return loadConfigSafe(); } catch { return { profiles: {} }; } From a4a473ac93a3adf9b51a0e9371bbd48fa7363157 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Fri, 26 Dec 2025 13:47:17 -0500 Subject: [PATCH 03/16] fix(config): use safe inline logic in getSettingsPath() legacy fallback The try/catch around loadConfig() in getSettingsPath() was ineffective because process.exit() cannot be caught by try/catch - it terminates the process immediately. Replace with inline safe logic that: 1. Checks if config.json exists with fs.existsSync() 2. Reads and parses JSON manually 3. Uses isConfig() type guard for validation 4. Properly catches JSON parse errors This ensures unified mode users don't crash when falling back to check legacy config.json for profiles not found in config.yaml. --- src/utils/config-manager.ts | 25 +++++++++++++++---------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/src/utils/config-manager.ts b/src/utils/config-manager.ts index 02b4e6db..a28938e5 100644 --- a/src/utils/config-manager.ts +++ b/src/utils/config-manager.ts @@ -163,19 +163,24 @@ export function getSettingsPath(profile: string): string { // If not found in unified config, try legacy config.json as fallback if (!settingsPath) { - try { - const legacyConfig = loadConfig(); - if (legacyConfig.profiles[profile]) { - settingsPath = legacyConfig.profiles[profile]; - // Merge legacy profiles into available list (avoid duplicates) - for (const p of Object.keys(legacyConfig.profiles)) { - if (!availableProfiles.includes(p)) { - availableProfiles.push(p); + // Use inline safe logic - loadConfig() calls process.exit() which cannot be caught + const configPath = getConfigPath(); + if (fs.existsSync(configPath)) { + try { + const raw = fs.readFileSync(configPath, 'utf8'); + const legacyConfig: unknown = JSON.parse(raw); + if (isConfig(legacyConfig) && legacyConfig.profiles[profile]) { + settingsPath = legacyConfig.profiles[profile]; + // Merge legacy profiles into available list (avoid duplicates) + for (const p of Object.keys(legacyConfig.profiles)) { + if (!availableProfiles.includes(p)) { + availableProfiles.push(p); + } } } + } catch { + // Legacy config is invalid JSON - that's OK in unified mode } - } catch { - // Legacy config doesn't exist or is invalid - that's OK in unified mode } } } else { From add4aa55c752be54164b42b0c108f54c24944570 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Fri, 26 Dec 2025 23:49:35 -0500 Subject: [PATCH 04/16] fix(kiro): add fallback import from Kiro IDE when OAuth callback redirects When Kiro OAuth callback redirects to Kiro IDE instead of CLI, this fix: - Auto-attempts token import from Kiro IDE storage - Adds --import flag for manual import (ccs kiro --import) - Shows clear instructions if import fails Fixes #212 --- src/cliproxy/auth/auth-types.ts | 2 + src/cliproxy/auth/kiro-import.ts | 159 +++++++++++++++++++++++++++++ src/cliproxy/auth/oauth-handler.ts | 14 ++- src/cliproxy/auth/oauth-process.ts | 47 ++++++++- src/cliproxy/cliproxy-executor.ts | 35 +++++++ src/commands/help-command.ts | 1 + 6 files changed, 252 insertions(+), 6 deletions(-) create mode 100644 src/cliproxy/auth/kiro-import.ts diff --git a/src/cliproxy/auth/auth-types.ts b/src/cliproxy/auth/auth-types.ts index 4bcc476c..927c2e2a 100644 --- a/src/cliproxy/auth/auth-types.ts +++ b/src/cliproxy/auth/auth-types.ts @@ -173,4 +173,6 @@ export interface OAuthOptions { fromUI?: boolean; /** If true, use --no-incognito flag (Kiro only - use normal browser instead of incognito) */ noIncognito?: boolean; + /** If true, skip OAuth and import token from Kiro IDE directly (Kiro only) */ + import?: boolean; } diff --git a/src/cliproxy/auth/kiro-import.ts b/src/cliproxy/auth/kiro-import.ts new file mode 100644 index 00000000..cc507110 --- /dev/null +++ b/src/cliproxy/auth/kiro-import.ts @@ -0,0 +1,159 @@ +/** + * Kiro Import Helper + * + * Imports Kiro token from Kiro IDE when OAuth callback redirects to IDE instead of CLI. + * Spawns cli-proxy-api-plus --kiro-import to import token from Kiro IDE's storage. + */ + +import { spawn } from 'child_process'; +import { info, ok, fail } from '../../utils/ui'; +import { ensureCLIProxyBinary } from '../binary-manager'; +import { generateConfig } from '../config-generator'; +import { getProviderTokenDir } from './token-manager'; + +export interface KiroImportResult { + success: boolean; + provider?: string; + email?: string; + error?: string; +} + +/** + * Try to import Kiro token from Kiro IDE + * Uses cli-proxy-api-plus --kiro-import flag + */ +export async function tryKiroImport(tokenDir: string, verbose = false): Promise { + const log = (msg: string) => { + if (verbose) console.error(`[kiro-import] ${msg}`); + }; + + try { + log('Ensuring CLIProxy binary is available...'); + const binaryPath = await ensureCLIProxyBinary(verbose); + const configPath = generateConfig('kiro'); + + log(`Binary: ${binaryPath}`); + log(`Config: ${configPath}`); + log(`Token dir: ${tokenDir}`); + + return new Promise((resolve) => { + const args = ['--config', configPath, '--kiro-import']; + + log(`Running: ${binaryPath} ${args.join(' ')}`); + + const proc = spawn(binaryPath, args, { + stdio: ['ignore', 'pipe', 'pipe'], + env: { ...process.env, CLI_PROXY_AUTH_DIR: tokenDir }, + }); + + let stdout = ''; + let stderr = ''; + let resolved = false; + + const safeResolve = (result: KiroImportResult) => { + if (resolved) return; + resolved = true; + clearTimeout(timeoutId); + resolve(result); + }; + + proc.stdout?.on('data', (data: Buffer) => { + const output = data.toString(); + stdout += output; + log(`stdout: ${output.trim()}`); + }); + + proc.stderr?.on('data', (data: Buffer) => { + const output = data.toString(); + stderr += output; + log(`stderr: ${output.trim()}`); + }); + + proc.on('exit', (code) => { + log(`Exit code: ${code}`); + + if (code === 0) { + // Parse output for provider info + const providerMatch = stdout.match(/Provider:\s*(\w+)/i); + const emailMatch = stdout.match(/email[:\s]+([^\s,)]+)/i); + const successMatch = + stdout.includes('Kiro token import successful') || + stdout.includes('Imported Kiro token') || + stdout.includes('Authentication saved'); + + if (successMatch) { + safeResolve({ + success: true, + provider: providerMatch?.[1], + email: emailMatch?.[1], + }); + } else { + safeResolve({ + success: false, + error: 'Import completed but token not confirmed', + }); + } + } else { + const errorLine = stderr.trim().split('\n')[0] || stdout.trim().split('\n')[0]; + safeResolve({ + success: false, + error: errorLine || `Exit code ${code}`, + }); + } + }); + + proc.on('error', (error) => { + log(`Process error: ${error.message}`); + safeResolve({ + success: false, + error: error.message, + }); + }); + + // Timeout after 30 seconds + const timeoutId = setTimeout(() => { + if (!resolved && !proc.killed) { + proc.kill(); + safeResolve({ + success: false, + error: 'Import timed out after 30 seconds', + }); + } + }, 30000); + }); + } catch (error) { + log(`Error: ${(error as Error).message}`); + return { + success: false, + error: (error as Error).message, + }; + } +} + +/** + * Import Kiro token with user-facing output + * Shows progress and result to user + */ +export async function importKiroToken(verbose = false): Promise { + const tokenDir = getProviderTokenDir('kiro'); + + console.log(''); + console.log(info('Importing token from Kiro IDE...')); + + const result = await tryKiroImport(tokenDir, verbose); + + if (result.success) { + const providerInfo = result.provider ? ` (Provider: ${result.provider})` : ''; + console.log(ok(`Imported Kiro token from IDE${providerInfo}`)); + return true; + } + + console.log(fail(`Import failed: ${result.error}`)); + console.log(''); + console.log('Make sure you are logged into Kiro IDE first:'); + console.log(' 1. Open Kiro IDE'); + console.log(' 2. Sign in with your AWS/Google account'); + console.log(' 3. Run: ccs kiro --import'); + + return false; +} diff --git a/src/cliproxy/auth/oauth-handler.ts b/src/cliproxy/auth/oauth-handler.ts index caba977c..34ecabbc 100644 --- a/src/cliproxy/auth/oauth-handler.ts +++ b/src/cliproxy/auth/oauth-handler.ts @@ -27,8 +27,9 @@ import { } from '../../management/oauth-port-diagnostics'; import { OAuthOptions, OAUTH_CALLBACK_PORTS, getOAuthConfig } from './auth-types'; import { isHeadlessEnvironment, killProcessOnPort, showStep } from './environment-detector'; -import { getProviderTokenDir, isAuthenticated } from './token-manager'; +import { getProviderTokenDir, isAuthenticated, registerAccountFromToken } from './token-manager'; import { executeOAuthProcess } from './oauth-process'; +import { importKiroToken } from './kiro-import'; /** * Prompt user to add another account @@ -127,6 +128,17 @@ export async function triggerOAuth( ): Promise { const oauthConfig = getOAuthConfig(provider); const { verbose = false, add = false, nickname, fromUI = false, noIncognito = true } = options; + + // Handle --import flag: skip OAuth and import from Kiro IDE directly + if (options.import && provider === 'kiro') { + const tokenDir = getProviderTokenDir(provider); + const success = await importKiroToken(verbose); + if (success) { + return registerAccountFromToken(provider, tokenDir, nickname); + } + return null; + } + const callbackPort = OAUTH_PORTS[provider]; const isCLI = !fromUI; const headless = options.headless ?? isHeadlessEnvironment(); diff --git a/src/cliproxy/auth/oauth-process.ts b/src/cliproxy/auth/oauth-process.ts index 71d58eab..309df84d 100644 --- a/src/cliproxy/auth/oauth-process.ts +++ b/src/cliproxy/auth/oauth-process.ts @@ -6,7 +6,8 @@ */ import { spawn, ChildProcess } from 'child_process'; -import { ok, fail, info } from '../../utils/ui'; +import { ok, fail, info, warn } from '../../utils/ui'; +import { tryKiroImport } from './kiro-import'; import { CLIProxyProvider } from '../types'; import { AccountInfo } from '../account-manager'; import { @@ -205,7 +206,35 @@ function displayUrlFromStderr( } /** Handle token not found after successful process exit */ -function handleTokenNotFound(provider: CLIProxyProvider, callbackPort: number | null): void { +async function handleTokenNotFound( + provider: CLIProxyProvider, + callbackPort: number | null, + tokenDir: string, + nickname: string | undefined, + verbose: boolean +): Promise { + // Kiro-specific: Try auto-import from Kiro IDE + if (provider === 'kiro') { + console.log(''); + console.log(warn('Callback redirected to Kiro IDE. Attempting to import token...')); + + const result = await tryKiroImport(tokenDir, verbose); + + if (result.success) { + const providerInfo = result.provider ? ` (Provider: ${result.provider})` : ''; + console.log(ok(`Imported Kiro token from IDE${providerInfo}`)); + return registerAccountFromToken(provider, tokenDir, nickname); + } + + console.log(fail(`Auto-import failed: ${result.error}`)); + console.log(''); + console.log('To manually import from Kiro IDE:'); + console.log(' 1. Ensure you are logged into Kiro IDE'); + console.log(' 2. Run: ccs kiro --import'); + return null; + } + + // Default behavior for other providers console.log(''); console.log(fail('Token not found after authentication')); console.log(''); @@ -225,6 +254,7 @@ function handleTokenNotFound(provider: CLIProxyProvider, callbackPort: number | console.log(''); console.log(`Try: ccs ${provider} --auth --verbose`); + return null; } /** Handle process exit with error */ @@ -356,7 +386,7 @@ export function executeOAuthProcess(options: OAuthProcessOptions): Promise { + authProcess.on('exit', async (code) => { clearTimeout(timeout); // H5: Remove signal handlers to prevent memory leaks process.removeListener('SIGINT', cleanup); @@ -383,8 +413,15 @@ export function executeOAuthProcess(options: OAuthProcessOptions): Promise --config', 'Change model (agy, gemini)'], ['ccs --logout', 'Clear authentication'], ['ccs --headless', 'Headless auth (for SSH)'], + ['ccs kiro --import', 'Import token from Kiro IDE'], ['ccs kiro --incognito', 'Use incognito browser (default: normal)'], ['ccs codex "explain code"', 'Use with prompt'], ] From 0be397784525275d0bbcc94877942f8963ca3d33 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Sat, 27 Dec 2025 00:05:31 -0500 Subject: [PATCH 05/16] fix: run RecoveryManager before early-exit commands and improve config handling Fixes #214 - Fresh install fails with 'Config not found' when running ccs config before RecoveryManager runs. Changes: - Move RecoveryManager.recoverAll() before all early-exit commands in ccs.ts - Fix loadConfigSafe() to return empty config instead of throwing in legacy mode - Add getActiveConfigPath() for mode-aware config path resolution - Rename cliproxy getConfigPath() to getCliproxyConfigPath() to avoid confusion - Update help-command.ts to show correct config path based on mode This ensures all commands benefit from auto-recovery on fresh installs, and gracefully handles missing config files in all code paths. --- src/ccs.ts | 22 ++++++++++--------- src/cliproxy/config-generator.ts | 7 +++--- src/cliproxy/index.ts | 2 +- src/cliproxy/openai-compat-manager.ts | 6 ++--- src/commands/help-command.ts | 3 ++- src/management/checks/cliproxy-check.ts | 4 ++-- src/utils/config-manager.ts | 19 ++++++++++++++-- src/web-server/health/cliproxy-checks.ts | 2 +- .../routes/cliproxy-stats-routes.ts | 6 ++--- 9 files changed, 45 insertions(+), 26 deletions(-) diff --git a/src/ccs.ts b/src/ccs.ts index 572d1a60..02550848 100644 --- a/src/ccs.ts +++ b/src/ccs.ts @@ -291,6 +291,18 @@ async function main(): Promise { await autoMigrate(); } + // Auto-recovery for missing configuration (BEFORE any early-exit commands) + // This ensures ALL commands benefit from auto-recovery, not just profile-switching flow + // Recovery is safe to run early - it only creates missing files with safe defaults + const RecoveryManagerModule = await import('./management/recovery-manager'); + const RecoveryManager = RecoveryManagerModule.default; + const recovery = new RecoveryManager(); + const recovered = recovery.recoverAll(); + + if (recovered) { + recovery.showRecoveryHints(); + } + // Special case: version command (check BEFORE profile detection) if (firstArg === 'version' || firstArg === '--version' || firstArg === '-v') { handleVersionCommand(); @@ -455,16 +467,6 @@ async function main(): Promise { return; } - // Auto-recovery for missing configuration - const RecoveryManagerModule = await import('./management/recovery-manager'); - const RecoveryManager = RecoveryManagerModule.default; - const recovery = new RecoveryManager(); - const recovered = recovery.recoverAll(); - - if (recovered) { - recovery.showRecoveryHints(); - } - // First-time install: offer setup wizard for interactive users // Check independently of recovery status (user may have empty config.yaml) // Skip if headless, CI, or non-TTY environment diff --git a/src/cliproxy/config-generator.ts b/src/cliproxy/config-generator.ts index 81d42c37..c2d4b83d 100644 --- a/src/cliproxy/config-generator.ts +++ b/src/cliproxy/config-generator.ts @@ -129,9 +129,10 @@ export function getConfigPathForPort(port: number): string { } /** - * Get config file path (default port) + * Get CLIProxy config file path (default port) + * Named distinctly from config-manager's getConfigPath to avoid confusion. */ -export function getConfigPath(): string { +export function getCliproxyConfigPath(): string { return getConfigPathForPort(CLIPROXY_DEFAULT_PORT); } @@ -377,7 +378,7 @@ export function regenerateConfig(port: number = CLIPROXY_DEFAULT_PORT): string { * @returns true if config should be regenerated */ export function configNeedsRegeneration(): boolean { - const configPath = getConfigPath(); + const configPath = getCliproxyConfigPath(); if (!fs.existsSync(configPath)) { return false; // Will be created on first use } diff --git a/src/cliproxy/index.ts b/src/cliproxy/index.ts index edbf9fd0..08bc50a2 100644 --- a/src/cliproxy/index.ts +++ b/src/cliproxy/index.ts @@ -65,7 +65,7 @@ export { getCliproxyDir, getProviderAuthDir, getAuthDir, - getConfigPath, + getCliproxyConfigPath, getBinDir, configExists, deleteConfig, diff --git a/src/cliproxy/openai-compat-manager.ts b/src/cliproxy/openai-compat-manager.ts index 5254a156..9bb8dfd4 100644 --- a/src/cliproxy/openai-compat-manager.ts +++ b/src/cliproxy/openai-compat-manager.ts @@ -7,7 +7,7 @@ import * as fs from 'fs'; import * as yaml from 'js-yaml'; -import { getConfigPath } from './config-generator'; +import { getCliproxyConfigPath } from './config-generator'; /** Model alias configuration */ export interface OpenAICompatModel { @@ -48,7 +48,7 @@ interface ConfigYaml { * Load current config.yaml */ function loadConfig(): ConfigYaml { - const configPath = getConfigPath(); + const configPath = getCliproxyConfigPath(); if (!fs.existsSync(configPath)) { return {}; } @@ -65,7 +65,7 @@ function loadConfig(): ConfigYaml { * Save config.yaml with proper formatting */ function saveConfig(config: ConfigYaml): void { - const configPath = getConfigPath(); + const configPath = getCliproxyConfigPath(); const content = yaml.dump(config, { lineWidth: -1, // Disable line wrapping quotingType: '"', diff --git a/src/commands/help-command.ts b/src/commands/help-command.ts index 82f70a86..4115a622 100644 --- a/src/commands/help-command.ts +++ b/src/commands/help-command.ts @@ -1,6 +1,7 @@ import * as fs from 'fs'; import * as path from 'path'; import { initUI, box, color, dim, sectionHeader, subheader } from '../utils/ui'; +import { isUnifiedMode } from '../config/unified-config-loader'; // Get version from package.json (same as version-command.ts) const VERSION = JSON.parse( @@ -234,7 +235,7 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim(); // Configuration printConfigSection('Configuration', [ - ['Config File:', '~/.ccs/config.json'], + ['Config File:', isUnifiedMode() ? '~/.ccs/config.yaml' : '~/.ccs/config.json'], ['Profiles:', '~/.ccs/profiles.json'], ['Instances:', '~/.ccs/instances/'], ['Settings:', '~/.ccs/*.settings.json'], diff --git a/src/management/checks/cliproxy-check.ts b/src/management/checks/cliproxy-check.ts index a4424f30..4a4998b6 100644 --- a/src/management/checks/cliproxy-check.ts +++ b/src/management/checks/cliproxy-check.ts @@ -8,7 +8,7 @@ import { isCLIProxyInstalled, getCLIProxyPath, getAllAuthStatus, - getConfigPath, + getCliproxyConfigPath, getInstalledCliproxyVersion, CLIPROXY_DEFAULT_PORT, configNeedsRegeneration, @@ -60,7 +60,7 @@ export class CLIProxyConfigChecker implements IHealthChecker { run(results: HealthCheck): void { const spinner = ora('Checking CLIProxy config').start(); - const configPath = getConfigPath(); + const configPath = getCliproxyConfigPath(); if (fs.existsSync(configPath)) { // Check if config needs regeneration (version mismatch or missing features) diff --git a/src/utils/config-manager.ts b/src/utils/config-manager.ts index a28938e5..08368f45 100644 --- a/src/utils/config-manager.ts +++ b/src/utils/config-manager.ts @@ -27,12 +27,26 @@ export function getCcsDir(): string { } /** - * Get config file path + * Get config file path (legacy JSON path) + * @deprecated Use getActiveConfigPath() for mode-aware config path */ export function getConfigPath(): string { return process.env.CCS_CONFIG || path.join(getCcsHome(), '.ccs', 'config.json'); } +/** + * Get the active config file path based on current mode. + * Returns config.yaml in unified mode, config.json in legacy mode. + * @returns Path to the active config file + */ +export function getActiveConfigPath(): string { + const ccsDir = getCcsDir(); + if (isUnifiedMode()) { + return path.join(ccsDir, 'config.yaml'); + } + return path.join(ccsDir, 'config.json'); +} + /** * Load and validate config.json */ @@ -126,7 +140,8 @@ export function loadConfigSafe(): Config { const configPath = getConfigPath(); if (!fs.existsSync(configPath)) { - throw new Error(`Config not found: ${configPath}`); + // Return empty config for graceful degradation (matches unified mode behavior) + return { profiles: {} }; } const raw = fs.readFileSync(configPath, 'utf8'); diff --git a/src/web-server/health/cliproxy-checks.ts b/src/web-server/health/cliproxy-checks.ts index 80f33e8e..5c1ee2f1 100644 --- a/src/web-server/health/cliproxy-checks.ts +++ b/src/web-server/health/cliproxy-checks.ts @@ -9,7 +9,7 @@ import { isCLIProxyInstalled, getInstalledCliproxyVersion, getCLIProxyPath, - getConfigPath as getCliproxyConfigPath, + getCliproxyConfigPath, getAllAuthStatus, CLIPROXY_DEFAULT_PORT, } from '../../cliproxy'; diff --git a/src/web-server/routes/cliproxy-stats-routes.ts b/src/web-server/routes/cliproxy-stats-routes.ts index a4ac196c..53e32c9a 100644 --- a/src/web-server/routes/cliproxy-stats-routes.ts +++ b/src/web-server/routes/cliproxy-stats-routes.ts @@ -14,7 +14,7 @@ import { } from '../../cliproxy/stats-fetcher'; import { getCliproxyWritablePath, - getConfigPath, + getCliproxyConfigPath, getAuthDir, } from '../../cliproxy/config-generator'; import { getProxyStatus as getProxyProcessStatus, stopProxy } from '../../cliproxy/session-tracker'; @@ -272,7 +272,7 @@ router.get('/error-logs/:name', async (req: Request, res: Response): Promise => { try { - const configPath = getConfigPath(); + const configPath = getCliproxyConfigPath(); if (!fs.existsSync(configPath)) { res.status(404).json({ error: 'Config file not found' }); return; @@ -299,7 +299,7 @@ router.put('/config.yaml', async (req: Request, res: Response): Promise => return; } - const configPath = getConfigPath(); + const configPath = getCliproxyConfigPath(); // Ensure parent directory exists const configDir = path.dirname(configPath); From ec2ee0a36d8498fb596d2e3ef793ce89a9f254f8 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Sat, 27 Dec 2025 01:14:54 -0500 Subject: [PATCH 06/16] fix(tests): update test files for renamed getCliproxyConfigPath function Update tests to use renamed function after refactoring getConfigPath to getCliproxyConfigPath in cliproxy/config-generator.ts Files updated: - tests/unit/cliproxy/config-generator-port.test.js - tests/unit/cliproxy/config-generator.test.js --- tests/unit/cliproxy/config-generator-port.test.js | 6 +++--- tests/unit/cliproxy/config-generator.test.js | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/unit/cliproxy/config-generator-port.test.js b/tests/unit/cliproxy/config-generator-port.test.js index 2e4c6f0a..dfbb76d5 100644 --- a/tests/unit/cliproxy/config-generator-port.test.js +++ b/tests/unit/cliproxy/config-generator-port.test.js @@ -19,7 +19,7 @@ process.env.CCS_HOME = testHome; const { getConfigPathForPort, - getConfigPath, + getCliproxyConfigPath, generateConfig, regenerateConfig, configExists, @@ -98,9 +98,9 @@ describe('Config Generator Port', function () { }); }); - describe('getConfigPath', function () { + describe('getCliproxyConfigPath', function () { it('returns path for default port', function () { - const configPath = getConfigPath(); + const configPath = getCliproxyConfigPath(); const defaultPath = getConfigPathForPort(CLIPROXY_DEFAULT_PORT); assert.strictEqual(configPath, defaultPath); }); diff --git a/tests/unit/cliproxy/config-generator.test.js b/tests/unit/cliproxy/config-generator.test.js index eab0743d..703b5fe6 100644 --- a/tests/unit/cliproxy/config-generator.test.js +++ b/tests/unit/cliproxy/config-generator.test.js @@ -362,7 +362,7 @@ auth-dir: "/test" let testDir; let originalCcsHome; let regenerateConfig; - let getConfigPath; + let getCliproxyConfigPath; beforeEach(() => { // Create a temporary test directory @@ -375,7 +375,7 @@ auth-dir: "/test" delete require.cache[require.resolve('../../../dist/utils/config-manager')]; const configGenerator = require('../../../dist/cliproxy/config-generator'); regenerateConfig = configGenerator.regenerateConfig; - getConfigPath = configGenerator.getConfigPath; + getCliproxyConfigPath = configGenerator.getCliproxyConfigPath; }); afterEach(() => { From fa8830e1ce97b6f0bb5f89c93325414a15412369 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Sat, 27 Dec 2025 11:20:10 -0500 Subject: [PATCH 07/16] feat(ui): add auth profile management to Dashboard - Add create profile dialog with CLI command copy-paste - Add delete profile with confirmation dialog - Add reset to CCS default button - Add DELETE endpoints for /accounts/:name and /accounts/reset-default - Achieve CLI/Dashboard parity per design philosophy Refs: plans/251227-0114-ccs-cli-dashboard-parity-audit --- src/web-server/routes/account-routes.ts | 46 +++++ ui/src/components/account/accounts-table.tsx | 185 +++++++++++++----- .../account/create-auth-profile-dialog.tsx | 124 ++++++++++++ ui/src/components/account/index.ts | 1 + ui/src/hooks/use-accounts.ts | 32 ++- ui/src/lib/api-client.ts | 2 + ui/src/pages/accounts.tsx | 28 +-- 7 files changed, 353 insertions(+), 65 deletions(-) create mode 100644 ui/src/components/account/create-auth-profile-dialog.tsx diff --git a/src/web-server/routes/account-routes.ts b/src/web-server/routes/account-routes.ts index 6b512b3f..686c604f 100644 --- a/src/web-server/routes/account-routes.ts +++ b/src/web-server/routes/account-routes.ts @@ -82,4 +82,50 @@ router.post('/default', (req: Request, res: Response): void => { } }); +/** + * DELETE /api/accounts/reset-default - Reset to CCS default + */ +router.delete('/reset-default', (_req: Request, res: Response): void => { + try { + if (isUnifiedMode()) { + registry.clearDefaultUnified(); + } else { + registry.clearDefaultProfile(); + } + res.json({ success: true, default: null }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + +/** + * DELETE /api/accounts/:name - Delete an account + */ +router.delete('/:name', (req: Request, res: Response): void => { + try { + const { name } = req.params; + + if (!name) { + res.status(400).json({ error: 'Missing account name' }); + return; + } + + // Check if trying to delete default + const currentDefault = registry.getDefaultUnified() ?? registry.getDefaultProfile(); + if (name === currentDefault) { + res + .status(400) + .json({ error: 'Cannot delete the default account. Set a different default first.' }); + return; + } + + // Delete the profile + registry.deleteProfile(name); + + res.json({ success: true, deleted: name }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + export default router; diff --git a/ui/src/components/account/accounts-table.tsx b/ui/src/components/account/accounts-table.tsx index 65865f03..d90969c9 100644 --- a/ui/src/components/account/accounts-table.tsx +++ b/ui/src/components/account/accounts-table.tsx @@ -1,8 +1,9 @@ /** * Accounts Table Component - * Phase 03: REST API Routes & CRUD + * Dashboard parity: Full CRUD for auth profiles */ +import { useState } from 'react'; import { useReactTable, getCoreRowModel, flexRender, type ColumnDef } from '@tanstack/react-table'; import { Table, @@ -13,17 +14,35 @@ import { TableRow, } from '@/components/ui/table'; import { Button } from '@/components/ui/button'; -import { Check } from 'lucide-react'; -import { useSetDefaultAccount } from '@/hooks/use-accounts'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog'; +import { Check, Trash2, RotateCcw } from 'lucide-react'; +import { + useSetDefaultAccount, + useDeleteAccount, + useResetDefaultAccount, +} from '@/hooks/use-accounts'; import type { Account } from '@/lib/api-client'; interface AccountsTableProps { data: Account[]; defaultAccount: string | null; + onRefresh?: () => void; } export function AccountsTable({ data, defaultAccount }: AccountsTableProps) { const setDefaultMutation = useSetDefaultAccount(); + const deleteMutation = useDeleteAccount(); + const resetDefaultMutation = useResetDefaultAccount(); + const [deleteTarget, setDeleteTarget] = useState(null); const columns: ColumnDef[] = [ { @@ -71,20 +90,34 @@ export function AccountsTable({ data, defaultAccount }: AccountsTableProps) { { id: 'actions', header: 'Actions', - size: 100, + size: 180, cell: ({ row }) => { const isDefault = row.original.name === defaultAccount; + const isPending = setDefaultMutation.isPending || deleteMutation.isPending; + return ( - +
+ + +
); }, }, @@ -100,51 +133,97 @@ export function AccountsTable({ data, defaultAccount }: AccountsTableProps) { if (data.length === 0) { return (
- No accounts found. Use ccs login to - add accounts. + No accounts found. Use{' '} + ccs auth create to add accounts.
); } return ( -
- - - {table.getHeaderGroups().map((headerGroup) => ( - - {headerGroup.headers.map((header) => { - const widthClass = - { - name: 'w-[200px]', - type: 'w-[100px]', - created: 'w-[150px]', - last_used: 'w-[150px]', - actions: 'w-[100px]', - }[header.id] || 'w-auto'; + <> +
+
+
+ + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + const widthClass = + { + name: 'w-[200px]', + type: 'w-[100px]', + created: 'w-[150px]', + last_used: 'w-[150px]', + actions: 'w-[180px]', + }[header.id] || 'w-auto'; - return ( - - {header.isPlaceholder - ? null - : flexRender(header.column.columnDef.header, header.getContext())} - - ); - })} - - ))} - - - {table.getRowModel().rows.map((row) => ( - - {row.getVisibleCells().map((cell) => ( - - {flexRender(cell.column.columnDef.cell, cell.getContext())} - + return ( + + {header.isPlaceholder + ? null + : flexRender(header.column.columnDef.header, header.getContext())} + + ); + })} + ))} - - ))} - -
-
+ + + {table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + ))} + + + + + {/* Reset default button */} + {defaultAccount && ( +
+ +
+ )} + + + {/* Delete confirmation dialog */} + !open && setDeleteTarget(null)}> + + + Delete Account + + Are you sure you want to delete the account "{deleteTarget}"? This will + remove the profile and all its session data. This action cannot be undone. + + + + Cancel + { + if (deleteTarget) { + deleteMutation.mutate(deleteTarget); + setDeleteTarget(null); + } + }} + > + Delete + + + + + ); } diff --git a/ui/src/components/account/create-auth-profile-dialog.tsx b/ui/src/components/account/create-auth-profile-dialog.tsx new file mode 100644 index 00000000..2da65f82 --- /dev/null +++ b/ui/src/components/account/create-auth-profile-dialog.tsx @@ -0,0 +1,124 @@ +/** + * Create Auth Profile Dialog + * Shows CLI command for creating auth profiles (Dashboard cannot spawn Claude login directly) + */ + +import { useState } from 'react'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Copy, Check, Terminal } from 'lucide-react'; + +interface CreateAuthProfileDialogProps { + open: boolean; + onClose: () => void; +} + +export function CreateAuthProfileDialog({ open, onClose }: CreateAuthProfileDialogProps) { + const [profileName, setProfileName] = useState(''); + const [copied, setCopied] = useState(false); + + // Validate profile name: alphanumeric, dash, underscore only + const isValidName = /^[a-zA-Z][a-zA-Z0-9_-]*$/.test(profileName); + const command = profileName ? `ccs auth create ${profileName}` : 'ccs auth create '; + + const handleCopy = async () => { + if (!isValidName) return; + await navigator.clipboard.writeText(command); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + + const handleClose = () => { + setProfileName(''); + setCopied(false); + onClose(); + }; + + return ( + !isOpen && handleClose()}> + + + Create New Account + + Auth profiles require Claude CLI login. Run the command below in your terminal. + + + +
+
+ + setProfileName(e.target.value)} + placeholder="e.g., work, personal, client" + autoComplete="off" + /> + {profileName && !isValidName && ( +

+ Name must start with a letter and contain only letters, numbers, dashes, or + underscores. +

+ )} +
+ +
+ +
+ + {command} + +
+
+ +
+

After running the command:

+
    +
  1. Complete the Claude login in your browser
  2. +
  3. Return here and refresh to see the new account
  4. +
+
+ +
+ + +
+
+
+
+ ); +} diff --git a/ui/src/components/account/index.ts b/ui/src/components/account/index.ts index ba0cf9b4..0b75ccb8 100644 --- a/ui/src/components/account/index.ts +++ b/ui/src/components/account/index.ts @@ -5,6 +5,7 @@ // Main components export { AccountsTable } from './accounts-table'; export { AddAccountDialog } from './add-account-dialog'; +export { CreateAuthProfileDialog } from './create-auth-profile-dialog'; // Flow visualization (from subdirectory) export { AccountFlowViz } from './flow-viz'; diff --git a/ui/src/hooks/use-accounts.ts b/ui/src/hooks/use-accounts.ts index a74422f2..b46691af 100644 --- a/ui/src/hooks/use-accounts.ts +++ b/ui/src/hooks/use-accounts.ts @@ -1,6 +1,6 @@ /** * React Query hooks for accounts (profiles.json) - * Phase 03: REST API Routes & CRUD + * Dashboard parity: Full CRUD operations for auth profiles */ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; @@ -28,3 +28,33 @@ export function useSetDefaultAccount() { }, }); } + +export function useResetDefaultAccount() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: () => api.accounts.resetDefault(), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['accounts'] }); + toast.success('Default account reset to CCS'); + }, + onError: (error: Error) => { + toast.error(error.message); + }, + }); +} + +export function useDeleteAccount() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (name: string) => api.accounts.delete(name), + onSuccess: (_data, name) => { + queryClient.invalidateQueries({ queryKey: ['accounts'] }); + toast.success(`Account "${name}" deleted`); + }, + onError: (error: Error) => { + toast.error(error.message); + }, + }); +} diff --git a/ui/src/lib/api-client.ts b/ui/src/lib/api-client.ts index 57cdb9bd..10b5ba44 100644 --- a/ui/src/lib/api-client.ts +++ b/ui/src/lib/api-client.ts @@ -351,6 +351,8 @@ export const api = { method: 'POST', body: JSON.stringify({ name }), }), + resetDefault: () => request('/accounts/reset-default', { method: 'DELETE' }), + delete: (name: string) => request(`/accounts/${name}`, { method: 'DELETE' }), }, // Unified config API config: { diff --git a/ui/src/pages/accounts.tsx b/ui/src/pages/accounts.tsx index 231dd545..bbed9c89 100644 --- a/ui/src/pages/accounts.tsx +++ b/ui/src/pages/accounts.tsx @@ -1,13 +1,18 @@ /** * Accounts Page - * Phase 03: REST API Routes & CRUD + * Dashboard parity: Auth profile CRUD operations */ +import { useState } from 'react'; +import { Plus } from 'lucide-react'; import { AccountsTable } from '@/components/account/accounts-table'; +import { CreateAuthProfileDialog } from '@/components/account/create-auth-profile-dialog'; +import { Button } from '@/components/ui/button'; import { useAccounts } from '@/hooks/use-accounts'; export function AccountsPage() { - const { data, isLoading } = useAccounts(); + const { data, isLoading, refetch } = useAccounts(); + const [createDialogOpen, setCreateDialogOpen] = useState(false); return (
@@ -18,22 +23,23 @@ export function AccountsPage() { Manage multi-account Claude sessions (profiles.json)

+ {isLoading ? (
Loading accounts...
) : ( - + )} -
-

- Accounts are isolated Claude instances with separate sessions. -
- Use ccs auth create <name> to add new - accounts via CLI. -

-
+ setCreateDialogOpen(false)} /> ); } From c20033473b150689ff4c581d5b4b2a6e12adb758 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Sat, 27 Dec 2025 11:54:08 -0500 Subject: [PATCH 08/16] docs: update design principles and add feature interface requirements --- CLAUDE.md | 30 ++++++++++++++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index e08ed1df..e85547cf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,10 +8,22 @@ CLI wrapper for instant switching between multiple Claude accounts and alternati ## Design Principles (ENFORCE STRICTLY) +### Technical Excellence - **YAGNI**: No features "just in case" - **KISS**: Simple bash/PowerShell/Node.js only -- **DRY**: One source of truth (config.json) -- **CLI-First**: All features must have CLI interface +- **DRY**: One source of truth (config.yaml) + +### User Experience (EQUALLY IMPORTANT) +- **CLI-Complete**: All features MUST have CLI interface +- **Dashboard-Parity**: Configuration features MUST also have Dashboard interface +- **Execution is CLI**: Running profiles happens via terminal, not dashboard buttons +- **UX > Brevity**: Error messages and help text prioritize user success over terseness +- **Progressive Disclosure**: Simple by default, power features accessible but not overwhelming + +### When Principles Conflict +- **UX > YAGNI** for user-facing features (if users need it, it's not "just in case") +- **KISS applies to BOTH** code AND user experience (simple journey, not just simple code) +- **DRY applies to BOTH** code AND interface patterns (consistent behavior across CLI/Dashboard) ## Common Mistakes (AVOID) @@ -89,6 +101,20 @@ bun run validate # Step 3: Final check (must pass) 4. **Cross-platform parity** - bash/PowerShell/Node.js must behave identically 5. **CLI documentation** - ALL changes MUST update `--help` in src/ccs.ts, lib/ccs, lib/ccs.ps1 6. **Idempotent** - All install operations safe to run multiple times +7. **Dashboard parity** - Configuration features MUST work in both CLI and Dashboard + +## Feature Interface Requirements + +| Feature Type | CLI | Dashboard | Example | +|--------------|-----|-----------|---------| +| Profile creation | ✓ | ✓ | `ccs auth create`, Dashboard "Add Account" | +| Profile switching | ✓ | ✓ | `ccs ` (execution is CLI-only) | +| API key config | ✓ | ✓ | `ccs api create`, Dashboard API Profiles | +| Health check | ✓ | ✓ | `ccs doctor`, Dashboard Live Monitor | +| OAuth auth flow | ✓ | ✓ | Browser opens from CLI or Dashboard | +| Analytics/monitoring | ✗ | ✓ | Dashboard Analytics (visual by nature) | +| WebSearch config | ✓ | ✓ | CLI flags, Dashboard Settings | +| Remote proxy config | ✓ | ✓ | CLI flags, Dashboard Settings | ## File Structure From 716122f35bce827d5093cd83d659604615224860 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 27 Dec 2025 16:59:31 +0000 Subject: [PATCH 09/16] chore(release): 7.8.0-dev.1 [skip ci] --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index c82df7dd..30bcac90 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.8.0", + "version": "7.8.0-dev.1", "description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6", "keywords": [ "cli", From 5f59d710a687aa23b22f470114fc763bf1412fbd Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Sat, 27 Dec 2025 12:04:52 -0500 Subject: [PATCH 10/16] feat(dashboard): add Import from Kiro IDE button Add "Import from IDE" button to Dashboard AddAccountDialog for Kiro provider: - POST /api/cliproxy/auth/kiro/import endpoint using tryKiroImport() - useKiroImport() React Query hook with cache invalidation - UI button shown alongside OAuth authenticate for Kiro only - Applies default preset when importing first account - Fix UI typecheck script (remove incompatible --build flag) --- src/web-server/routes/cliproxy-auth-routes.ts | 50 ++++++++++++++++ ui/package.json | 2 +- .../components/account/add-account-dialog.tsx | 58 ++++++++++++++++--- ui/src/hooks/use-cliproxy.ts | 21 +++++++ ui/src/lib/api-client.ts | 6 ++ 5 files changed, 128 insertions(+), 9 deletions(-) diff --git a/src/web-server/routes/cliproxy-auth-routes.ts b/src/web-server/routes/cliproxy-auth-routes.ts index 637cf2d3..a4871e38 100644 --- a/src/web-server/routes/cliproxy-auth-routes.ts +++ b/src/web-server/routes/cliproxy-auth-routes.ts @@ -24,6 +24,8 @@ import { import { getProxyTarget } from '../../cliproxy/proxy-target-resolver'; import { fetchRemoteAuthStatus } from '../../cliproxy/remote-auth-fetcher'; import { loadOrCreateUnifiedConfig } from '../../config/unified-config-loader'; +import { tryKiroImport } from '../../cliproxy/auth/kiro-import'; +import { getProviderTokenDir } from '../../cliproxy/auth/token-manager'; import type { CLIProxyProvider } from '../../cliproxy/types'; const router = Router(); @@ -350,4 +352,52 @@ router.post('/project-selection/:sessionId', (req: Request, res: Response): void } }); +/** + * POST /api/cliproxy/auth/kiro/import - Import Kiro token from Kiro IDE + * Alternative auth path when OAuth callback fails to redirect properly + */ +router.post('/kiro/import', async (_req: Request, res: Response): Promise => { + // Check if remote mode is enabled - import not available remotely + const target = getProxyTarget(); + if (target.isRemote) { + res.status(501).json({ + error: 'Kiro import not available in remote mode', + }); + return; + } + + try { + const tokenDir = getProviderTokenDir('kiro'); + const result = await tryKiroImport(tokenDir, false); + + if (result.success) { + // Re-initialize accounts to pick up new token + initializeAccounts(); + + // Get the newly added account + const accounts = getProviderAccounts('kiro'); + const newAccount = accounts.find((a) => a.isDefault) || accounts[0]; + + res.json({ + success: true, + account: newAccount + ? { + id: newAccount.id, + email: newAccount.email, + provider: 'kiro', + isDefault: newAccount.isDefault, + } + : null, + }); + } else { + res.status(400).json({ + success: false, + error: result.error || 'Failed to import Kiro token', + }); + } + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + export default router; diff --git a/ui/package.json b/ui/package.json index 5fbb4629..23c50f38 100644 --- a/ui/package.json +++ b/ui/package.json @@ -6,7 +6,7 @@ "scripts": { "dev": "vite", "build": "tsc -b && vite build", - "typecheck": "tsc -b --noEmit", + "typecheck": "tsc --noEmit", "lint": "eslint .", "lint:fix": "eslint . --fix", "format": "prettier --write src/ tests/", diff --git a/ui/src/components/account/add-account-dialog.tsx b/ui/src/components/account/add-account-dialog.tsx index fa01fa4e..9d83e9c9 100644 --- a/ui/src/components/account/add-account-dialog.tsx +++ b/ui/src/components/account/add-account-dialog.tsx @@ -2,6 +2,7 @@ * Add Account Dialog Component * Triggers OAuth flow server-side to add another account to a provider * Applies default preset when adding first account + * For Kiro: Also shows "Import from IDE" option as fallback */ import { useState } from 'react'; @@ -15,8 +16,8 @@ import { import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; -import { Loader2, ExternalLink, User } from 'lucide-react'; -import { useStartAuth } from '@/hooks/use-cliproxy'; +import { Loader2, ExternalLink, User, Download } from 'lucide-react'; +import { useStartAuth, useKiroImport } from '@/hooks/use-cliproxy'; import { applyDefaultPreset } from '@/lib/preset-utils'; import { toast } from 'sonner'; @@ -38,6 +39,10 @@ export function AddAccountDialog({ }: AddAccountDialogProps) { const [nickname, setNickname] = useState(''); const startAuthMutation = useStartAuth(); + const kiroImportMutation = useKiroImport(); + + const isKiro = provider === 'kiro'; + const isPending = startAuthMutation.isPending || kiroImportMutation.isPending; const handleStartAuth = () => { startAuthMutation.mutate( @@ -60,8 +65,24 @@ export function AddAccountDialog({ ); }; + const handleKiroImport = () => { + kiroImportMutation.mutate(undefined, { + onSuccess: async () => { + // Apply default preset if this is the first account + if (isFirstAccount) { + const result = await applyDefaultPreset('kiro'); + if (result.success && result.presetName) { + toast.success(`Applied "${result.presetName}" preset`); + } + } + setNickname(''); + onClose(); + }, + }); + }; + const handleOpenChange = (isOpen: boolean) => { - if (!isOpen && !startAuthMutation.isPending) { + if (!isOpen && !isPending) { setNickname(''); onClose(); } @@ -73,8 +94,9 @@ export function AddAccountDialog({ Add {displayName} Account - Click the button below to authenticate a new account. A browser window will open for - OAuth. + {isKiro + ? 'Authenticate via browser or import an existing token from Kiro IDE.' + : 'Click the button below to authenticate a new account. A browser window will open for OAuth.'} @@ -88,7 +110,7 @@ export function AddAccountDialog({ value={nickname} onChange={(e) => setNickname(e.target.value)} placeholder="e.g., work, personal" - disabled={startAuthMutation.isPending} + disabled={isPending} className="flex-1" /> @@ -98,10 +120,25 @@ export function AddAccountDialog({
- - + )} +
diff --git a/ui/src/hooks/use-cliproxy.ts b/ui/src/hooks/use-cliproxy.ts index fa93c06f..7dd7811b 100644 --- a/ui/src/hooks/use-cliproxy.ts +++ b/ui/src/hooks/use-cliproxy.ts @@ -136,6 +136,27 @@ export function useStartAuth() { }); } +// Kiro IDE import hook (alternative auth path when OAuth callback fails) +export function useKiroImport() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: () => api.cliproxy.auth.kiroImport(), + onSuccess: (data) => { + queryClient.invalidateQueries({ queryKey: ['cliproxy-accounts'] }); + queryClient.invalidateQueries({ queryKey: ['cliproxy-auth'] }); + if (data.account) { + toast.success(`Imported Kiro account: ${data.account.email || data.account.id}`); + } else { + toast.success('Kiro token imported'); + } + }, + onError: (error: Error) => { + toast.error(error.message); + }, + }); +} + // Stats and models hooks for Overview tab export function useCliproxyStats() { return useQuery({ diff --git a/ui/src/lib/api-client.ts b/ui/src/lib/api-client.ts index 57cdb9bd..8ececebd 100644 --- a/ui/src/lib/api-client.ts +++ b/ui/src/lib/api-client.ts @@ -331,6 +331,12 @@ export const api = { method: 'POST', body: JSON.stringify({ nickname }), }), + /** Import Kiro token from Kiro IDE (Kiro only) */ + kiroImport: () => + request<{ success: boolean; account: OAuthAccount | null; error?: string }>( + '/cliproxy/auth/kiro/import', + { method: 'POST' } + ), }, // Error logs errorLogs: { From 2fff770b6bc67616e855cc8dc940751bd1267a67 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Sat, 27 Dec 2025 12:25:59 -0500 Subject: [PATCH 11/16] fix: wrap RecoveryManager in try-catch to prevent blocking CLI commands Recovery failures (permission errors, disk full) no longer block --version/--help commands. Recovery is best-effort - warns on failure but allows basic CLI functionality to continue. Fixes edge case identified in PR #215 code review. --- src/ccs.ts | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/src/ccs.ts b/src/ccs.ts index 02550848..dc2df697 100644 --- a/src/ccs.ts +++ b/src/ccs.ts @@ -294,13 +294,19 @@ async function main(): Promise { // Auto-recovery for missing configuration (BEFORE any early-exit commands) // This ensures ALL commands benefit from auto-recovery, not just profile-switching flow // Recovery is safe to run early - it only creates missing files with safe defaults - const RecoveryManagerModule = await import('./management/recovery-manager'); - const RecoveryManager = RecoveryManagerModule.default; - const recovery = new RecoveryManager(); - const recovered = recovery.recoverAll(); + // Wrapped in try-catch to prevent blocking --version/--help on permission errors + try { + const RecoveryManagerModule = await import('./management/recovery-manager'); + const RecoveryManager = RecoveryManagerModule.default; + const recovery = new RecoveryManager(); + const recovered = recovery.recoverAll(); - if (recovered) { - recovery.showRecoveryHints(); + if (recovered) { + recovery.showRecoveryHints(); + } + } catch (err) { + // Recovery is best-effort - don't block basic CLI functionality + console.warn('[!] Recovery failed:', (err as Error).message); } // Special case: version command (check BEFORE profile detection) From 8cea17d5f32f3a5a44c5fff06726995870868842 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 27 Dec 2025 17:26:14 +0000 Subject: [PATCH 12/16] chore(release): 7.8.0-dev.2 [skip ci] --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 30bcac90..1302c1d8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.8.0-dev.1", + "version": "7.8.0-dev.2", "description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6", "keywords": [ "cli", From 8a3c5a446beb197148a132900a88f09043cbab55 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Sat, 27 Dec 2025 12:28:59 -0500 Subject: [PATCH 13/16] fix: improve type safety and error handling in config-manager - Wrap JSON.parse in try-catch with clear error message for malformed JSON - Add iflow, kiro, ghcp providers to CLIProxyVariantConfig type - Make settings optional in CLIProxyVariantConfig (was required with empty string fallback) - Remove unsafe type cast in loadConfigSafe() Addresses additional edge cases from PR #215 code review. --- src/types/config.ts | 8 ++++---- src/utils/config-manager.ts | 13 +++++++++---- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/src/types/config.ts b/src/types/config.ts index fe3d661c..a00195a3 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -17,10 +17,10 @@ export interface ProfilesConfig { * Example: "flash" → gemini provider with gemini-2.5-flash model */ export interface CLIProxyVariantConfig { - /** CLIProxy provider to use (gemini, codex, agy, qwen) */ - provider: 'gemini' | 'codex' | 'agy' | 'qwen'; - /** Path to settings.json with custom model configuration */ - settings: string; + /** CLIProxy provider to use */ + provider: 'gemini' | 'codex' | 'agy' | 'qwen' | 'iflow' | 'kiro' | 'ghcp'; + /** Path to settings.json with custom model configuration (optional) */ + settings?: string; /** Account identifier for multi-account support (optional, defaults to 'default') */ account?: string; /** Unique port for variant isolation (8318-8417) */ diff --git a/src/utils/config-manager.ts b/src/utils/config-manager.ts index 08368f45..e1169137 100644 --- a/src/utils/config-manager.ts +++ b/src/utils/config-manager.ts @@ -121,9 +121,8 @@ export function loadConfigSafe(): Config { cliproxy = {}; for (const [name, variant] of Object.entries(unifiedConfig.cliproxy.variants)) { cliproxy[name] = { - // Cast provider - unified has more providers than legacy type - provider: variant.provider as 'gemini' | 'codex' | 'agy' | 'qwen', - settings: variant.settings || '', + provider: variant.provider, + settings: variant.settings, account: variant.account, port: variant.port, }; @@ -145,7 +144,13 @@ export function loadConfigSafe(): Config { } const raw = fs.readFileSync(configPath, 'utf8'); - const parsed: unknown = JSON.parse(raw); + + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch (e) { + throw new Error(`Malformed JSON in config: ${configPath} - ${(e as Error).message}`); + } if (!isConfig(parsed)) { throw new Error(`Invalid config format: ${configPath}`); From 67a48a8305125959ecab468f117cc9de0badddd5 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Sat, 27 Dec 2025 16:30:25 -0500 Subject: [PATCH 14/16] fix(test): remove redundant build from beforeAll hook CI already runs bun run build:all before validate step. Duplicate build in test hook was causing timeout in CI. --- tests/unit/utils/update-checker-beta-channel.test.js | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/tests/unit/utils/update-checker-beta-channel.test.js b/tests/unit/utils/update-checker-beta-channel.test.js index 4cca9820..964cfc75 100644 --- a/tests/unit/utils/update-checker-beta-channel.test.js +++ b/tests/unit/utils/update-checker-beta-channel.test.js @@ -27,14 +27,7 @@ describe('Beta Channel Implementation (Phase 3)', function () { let httpsRequests = []; beforeAll(async function () { - // Build the project first - const { execSync } = require('child_process'); - try { - execSync('bun run build', { cwd: path.resolve(__dirname, '../../..'), stdio: 'pipe' }); - } catch (error) { - console.warn('Build failed, tests may not work:', error.message); - } - + // Note: Build is handled by CI before tests run (bun run build:all) // Import the built module updateCheckerModule = await import('../../../dist/utils/update-checker.js'); }); From d515c49f5da255b0b2efba1c6ea90005cffcb465 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 27 Dec 2025 21:32:45 +0000 Subject: [PATCH 15/16] chore(release): 7.8.0-dev.3 [skip ci] --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 1302c1d8..1fd6977d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.8.0-dev.2", + "version": "7.8.0-dev.3", "description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6", "keywords": [ "cli", From 886c8e4397e8b805474bf2228c0c3060b3f23af4 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 27 Dec 2025 21:40:35 +0000 Subject: [PATCH 16/16] chore(release): 7.8.0-dev.4 [skip ci] --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 1fd6977d..051f175b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.8.0-dev.3", + "version": "7.8.0-dev.4", "description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6", "keywords": [ "cli",