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 diff --git a/package.json b/package.json index c82df7dd..051f175b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.8.0", + "version": "7.8.0-dev.4", "description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6", "keywords": [ "cli", 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/ccs.ts b/src/ccs.ts index 572d1a60..dc2df697 100644 --- a/src/ccs.ts +++ b/src/ccs.ts @@ -291,6 +291,24 @@ 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 + // 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(); + } + } 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) if (firstArg === 'version' || firstArg === '--version' || firstArg === '-v') { handleVersionCommand(); @@ -455,16 +473,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/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 { 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..99d31f97 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'; @@ -250,6 +255,8 @@ export async function execClaudeWithCLIProxy( const forceConfig = argsWithoutProxy.includes('--config'); const addAccount = argsWithoutProxy.includes('--add'); const showAccounts = argsWithoutProxy.includes('--accounts'); + // Kiro-specific: --import to import token from Kiro IDE directly + const forceImport = argsWithoutProxy.includes('--import'); // Kiro-specific: browser mode for OAuth // Default to normal browser (noIncognito=true) for reliability - incognito often fails on Linux // --incognito flag opts into incognito mode, --no-incognito is legacy (now default) @@ -362,6 +369,38 @@ export async function execClaudeWithCLIProxy( process.exit(0); } + // Handle --import: import token from Kiro IDE directly (Kiro only) + if (forceImport) { + if (provider !== 'kiro') { + console.error(fail('--import is only available for Kiro')); + console.error(` Run "ccs ${provider} --auth" to authenticate`); + process.exit(1); + } + // Validate flag conflicts + if (forceAuth) { + console.error(fail('Cannot use --import with --auth')); + console.error(' --import: Import existing token from Kiro IDE'); + console.error(' --auth: Trigger new OAuth flow in browser'); + process.exit(1); + } + if (forceLogout) { + console.error(fail('Cannot use --import with --logout')); + process.exit(1); + } + const { triggerOAuth } = await import('./auth-handler'); + const authSuccess = await triggerOAuth(provider, { + verbose, + import: true, + ...(setNickname ? { nickname: setNickname } : {}), + }); + if (!authSuccess) { + console.error(fail('Failed to import Kiro token from IDE')); + console.error(' Make sure you are logged into Kiro IDE first'); + process.exit(1); + } + process.exit(0); + } + // 3. Ensure OAuth completed (if provider requires it) if (providerConfig.requiresOAuth) { log(`Checking authentication for ${provider}`); @@ -429,9 +468,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 +605,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})` + ); }); } @@ -604,6 +670,7 @@ export async function execClaudeWithCLIProxy( '--nickname', '--incognito', '--no-incognito', + '--import', // Proxy flags are handled by resolveProxyConfig, but list for documentation ...PROXY_CLI_FLAGS, ]; 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/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/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/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/commands/help-command.ts b/src/commands/help-command.ts index 82f70a86..abb4ee01 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( @@ -173,6 +174,7 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim(); ['ccs --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'], ] @@ -234,7 +236,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/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/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..e1169137 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'; @@ -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 */ @@ -82,6 +96,69 @@ 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] = { + provider: variant.provider, + settings: variant.settings, + account: variant.account, + port: variant.port, + }; + } + } + + return { + profiles, + cliproxy, + }; + } + + // Legacy mode: read config.json + const configPath = getConfigPath(); + + if (!fs.existsSync(configPath)) { + // Return empty config for graceful degradation (matches unified mode behavior) + return { profiles: {} }; + } + + const raw = fs.readFileSync(configPath, 'utf8'); + + 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}`); + } + + return parsed; +} + /** * Get settings path for profile. * In unified mode (config.yaml exists), reads from config.yaml first, @@ -106,19 +183,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 { 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 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/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/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/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/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); 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: {} }; } 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(() => { 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'); }); 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/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/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/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/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..8145b054 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: { @@ -351,6 +357,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)} /> ); }