From 92007d7c0468db969bd481c6517f0b3a851d8433 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Tue, 9 Dec 2025 12:50:38 -0500 Subject: [PATCH 01/13] feat(doctor): add OAuth diagnostics for Windows headless false positives Add environment and OAuth port diagnostics to help troubleshoot authentication issues reported by Windows users: - Add ENVIRONMENT section showing platform, SSH, TTY, browser capability - Add OAUTH READINESS section with pre-flight port availability checks - Fix Windows headless false positive (isTTY undefined on npm wrappers) - Add pre-flight OAuth check before auth attempt to fail fast New files: - src/management/environment-diagnostics.ts - src/management/oauth-port-diagnostics.ts Case study: Vietnamese Windows users reported "command hangs" because Windows terminal wrappers don't set isTTY correctly, triggering headless mode when browser should be available. --- src/cliproxy/auth-handler.ts | 52 +++- src/management/doctor.ts | 131 ++++++++- src/management/environment-diagnostics.ts | 310 ++++++++++++++++++++++ src/management/oauth-port-diagnostics.ts | 236 ++++++++++++++++ 4 files changed, 718 insertions(+), 11 deletions(-) create mode 100644 src/management/environment-diagnostics.ts create mode 100644 src/management/oauth-port-diagnostics.ts diff --git a/src/cliproxy/auth-handler.ts b/src/cliproxy/auth-handler.ts index b854312f..23b92fd2 100644 --- a/src/cliproxy/auth-handler.ts +++ b/src/cliproxy/auth-handler.ts @@ -27,6 +27,7 @@ import { registerAccount, touchAccount, } from './account-manager'; +import { preflightOAuthCheck } from '../management/oauth-port-diagnostics'; /** * OAuth callback ports used by CLIProxyAPI (hardcoded in binary) @@ -90,26 +91,50 @@ function killProcessOnPort(port: number, verbose: boolean): boolean { /** * Detect if running in a headless environment (no browser available) + * + * IMPROVED: Avoids false positives on Windows desktop environments + * where isTTY may be undefined due to terminal wrapper behavior. + * + * Case study: Vietnamese Windows users reported "command hangs" because + * their terminal (PowerShell via npm) didn't set isTTY correctly. */ function isHeadlessEnvironment(): boolean { - // SSH session + // SSH session - always headless if (process.env.SSH_TTY || process.env.SSH_CLIENT || process.env.SSH_CONNECTION) { return true; } - // No display (Linux/X11) + // No display on Linux (X11/Wayland) - headless if (process.platform === 'linux' && !process.env.DISPLAY && !process.env.WAYLAND_DISPLAY) { return true; } - // Non-interactive (piped stdin) - skip on Windows - // Windows npm wrappers don't set isTTY correctly (returns undefined, not true) + // Windows desktop - NEVER headless unless SSH (already checked above) + // This fixes false positive where Windows npm wrappers don't set isTTY correctly // Windows desktop environments always have browser capability - if (process.platform !== 'win32' && !process.stdin.isTTY) { - return true; + if (process.platform === 'win32') { + return false; } - return false; + // macOS - check for proper terminal + if (process.platform === 'darwin') { + // Non-interactive stdin on macOS means likely piped/scripted + if (!process.stdin.isTTY) { + return true; + } + return false; + } + + // Linux with display - check TTY + if (process.platform === 'linux') { + if (!process.stdin.isTTY) { + return true; + } + return false; + } + + // Default fallback for unknown platforms + return !process.stdin.isTTY; } /** @@ -422,6 +447,19 @@ export async function triggerOAuth( } }; + // Pre-flight check: verify OAuth callback port is available + const preflight = await preflightOAuthCheck(provider); + if (!preflight.ready) { + console.log(''); + console.log('[!] OAuth pre-flight check failed:'); + for (const issue of preflight.issues) { + console.log(` ${issue}`); + } + console.log(''); + console.log('[i] Resolve the port conflict and try again.'); + return null; + } + // Ensure binary exists let binaryPath: string; try { diff --git a/src/management/doctor.ts b/src/management/doctor.ts index 6f4058a3..cfb101f9 100644 --- a/src/management/doctor.ts +++ b/src/management/doctor.ts @@ -19,6 +19,8 @@ import { CLIPROXY_DEFAULT_PORT, } from '../cliproxy'; import { getPortProcess, isCLIProxyProcess } from '../utils/port-utils'; +import { getEnvironmentDiagnostics } from './environment-diagnostics'; +import { checkAuthCodePorts } from './oauth-port-diagnostics'; // Make ora optional (might not be available during npm install postinstall) // ora v9+ is an ES module, need to use .default for CommonJS @@ -146,31 +148,41 @@ class Doctor { this.checkCcsDirectory(); console.log(''); - // Group 2: Configuration + // Group 2: Environment (NEW - OAuth readiness diagnostics) + console.log(header('ENVIRONMENT')); + this.checkEnvironment(); + console.log(''); + + // Group 3: Configuration console.log(header('CONFIGURATION')); this.checkConfigFiles(); this.checkClaudeSettings(); console.log(''); - // Group 3: Profiles & Delegation + // Group 4: Profiles & Delegation console.log(header('PROFILES & DELEGATION')); this.checkProfiles(); this.checkInstances(); this.checkDelegation(); console.log(''); - // Group 4: System Health + // Group 5: System Health console.log(header('SYSTEM HEALTH')); this.checkPermissions(); this.checkCcsSymlinks(); this.checkSettingsSymlinks(); console.log(''); - // Group 5: CLIProxy (OAuth profiles) + // Group 6: CLIProxy (OAuth profiles) console.log(header('CLIPROXY (OAUTH PROFILES)')); await this.checkCLIProxy(); console.log(''); + // Group 7: OAuth Readiness (NEW - port availability) + console.log(header('OAUTH READINESS')); + await this.checkOAuthPorts(); + console.log(''); + this.showReport(); return this.results; } @@ -766,6 +778,117 @@ class Doctor { } } + /** + * Check 10.1: Environment detection (OAuth readiness) + * Helps diagnose Windows headless false positives + */ + private checkEnvironment(): void { + const spinner = ora('Checking environment').start(); + const diag = getEnvironmentDiagnostics(); + + // Determine overall environment health + let envStatus: 'OK' | 'WARN' = 'OK'; + let envMessage = 'Browser available'; + + // Check for potential issues + if (diag.detectedHeadless) { + if (diag.platform === 'win32' && diag.ttyStatus === 'undefined') { + // Windows false positive - this is actually a warning + envStatus = 'WARN'; + envMessage = 'Headless detected (may be false positive on Windows)'; + } else if (diag.sshSession) { + envMessage = 'SSH session (headless mode)'; + } else { + envMessage = 'Headless environment'; + } + } + + if (envStatus === 'WARN') { + spinner.warn(); + console.log(` ${warn('Environment'.padEnd(22))} ${envMessage}`); + } else { + spinner.succeed(); + console.log(` ${ok('Environment'.padEnd(22))} ${envMessage}`); + } + + // Show key environment details + console.log(` ${''.padEnd(24)} Platform: ${diag.platformName}`); + if (diag.sshSession) { + console.log(` ${''.padEnd(24)} SSH: Yes (${diag.sshReason})`); + } + if (diag.ttyStatus === 'undefined') { + console.log(` ${''.padEnd(24)} TTY: undefined [!]`); + } + console.log(` ${''.padEnd(24)} Browser: ${diag.browserReason}`); + + this.results.addCheck( + 'Environment', + envStatus === 'OK' ? 'success' : 'warning', + envMessage, + envStatus === 'WARN' ? 'If browser opens correctly, this warning can be ignored' : undefined, + { + status: envStatus, + info: envMessage, + } + ); + } + + /** + * Check 10.2: OAuth callback ports availability + * Pre-flight check for OAuth authentication + */ + private async checkOAuthPorts(): Promise { + const spinner = ora('Checking OAuth callback ports').start(); + const portDiagnostics = await checkAuthCodePorts(); + + // Count issues + const conflicts = portDiagnostics.filter((d) => d.status === 'occupied'); + + if (conflicts.length === 0) { + spinner.succeed(); + console.log(` ${ok('OAuth Ports'.padEnd(22))} All callback ports available`); + this.results.addCheck('OAuth Ports', 'success', undefined, undefined, { + status: 'OK', + info: 'All callback ports available', + }); + } else { + spinner.warn(); + console.log(` ${warn('OAuth Ports'.padEnd(22))} ${conflicts.length} port conflict(s)`); + this.results.addCheck( + 'OAuth Ports', + 'warning', + `${conflicts.length} port conflict(s)`, + 'Close conflicting applications before OAuth', + { status: 'WARN', info: `${conflicts.length} conflict(s)` } + ); + } + + // Show individual port status + for (const diag of portDiagnostics) { + const providerName = diag.provider.charAt(0).toUpperCase() + diag.provider.slice(1); + const portStr = diag.port !== null ? `(${diag.port})` : ''; + + let statusIcon: string; + switch (diag.status) { + case 'free': + case 'cliproxy': + statusIcon = ok(`${providerName} ${portStr}`.padEnd(20)); + break; + case 'occupied': + statusIcon = warn(`${providerName} ${portStr}`.padEnd(20)); + break; + default: + statusIcon = info(`${providerName} ${portStr}`.padEnd(20)); + } + + console.log(` ${statusIcon} ${diag.message}`); + + if (diag.recommendation && diag.status === 'occupied') { + console.log(` ${''.padEnd(24)} Fix: ${diag.recommendation}`); + } + } + } + /** * Check 11: CLIProxy health (OAuth profiles: gemini, codex, agy, qwen) */ diff --git a/src/management/environment-diagnostics.ts b/src/management/environment-diagnostics.ts new file mode 100644 index 00000000..7083256a --- /dev/null +++ b/src/management/environment-diagnostics.ts @@ -0,0 +1,310 @@ +/** + * Environment Diagnostics Module + * + * Provides detailed environment detection diagnostics for troubleshooting + * OAuth authentication issues, particularly for Windows users experiencing + * false headless detection. + * + * Case study: Vietnamese Windows users reported "command hangs" because + * Windows terminal wrappers don't set isTTY correctly, triggering + * headless mode when browser should be available. + */ + +/** + * Detailed environment diagnostic information + */ +export interface EnvironmentDiagnostics { + /** Operating system platform */ + platform: NodeJS.Platform; + /** Platform display name */ + platformName: string; + /** Current shell (from SHELL env or detected) */ + shell: string; + /** Terminal program (TERM_PROGRAM env) */ + termProgram: string | null; + /** Whether running in SSH session */ + sshSession: boolean; + /** SSH detection reason */ + sshReason: string | null; + /** stdin TTY status */ + ttyStatus: boolean | 'undefined'; + /** stdout TTY status */ + stdoutTty: boolean | 'undefined'; + /** Display environment (Linux X11/Wayland) */ + display: string | null; + /** Windows Terminal detected (WT_SESSION env) */ + windowsTerminal: boolean; + /** VS Code integrated terminal detected */ + vsCodeTerminal: boolean; + /** CI environment detected */ + ciEnvironment: boolean; + /** Final headless detection result */ + detectedHeadless: boolean; + /** Reasons why headless was detected (or not) */ + headlessReasons: string[]; + /** Browser capability assessment */ + browserCapability: 'available' | 'unlikely' | 'unknown'; + /** Browser capability reason */ + browserReason: string; +} + +/** + * Detect current shell from environment + */ +function detectShell(): string { + if (process.env.SHELL) { + return process.env.SHELL; + } + if (process.platform === 'win32') { + if (process.env.PSModulePath) { + return 'PowerShell'; + } + if (process.env.COMSPEC) { + return process.env.COMSPEC; + } + return 'cmd.exe'; + } + return 'unknown'; +} + +/** + * Check if running in SSH session + */ +function checkSshSession(): { isSsh: boolean; reason: string | null } { + if (process.env.SSH_TTY) { + return { isSsh: true, reason: 'SSH_TTY set' }; + } + if (process.env.SSH_CLIENT) { + return { isSsh: true, reason: 'SSH_CLIENT set' }; + } + if (process.env.SSH_CONNECTION) { + return { isSsh: true, reason: 'SSH_CONNECTION set' }; + } + return { isSsh: false, reason: null }; +} + +/** + * Check browser capability based on environment + */ +function assessBrowserCapability( + platform: NodeJS.Platform, + sshSession: boolean, + display: string | null, + windowsTerminal: boolean, + vsCodeTerminal: boolean +): { capability: 'available' | 'unlikely' | 'unknown'; reason: string } { + // SSH session - no browser + if (sshSession) { + return { capability: 'unlikely', reason: 'SSH session detected' }; + } + + // Windows desktop - should always have browser + if (platform === 'win32') { + if (windowsTerminal || vsCodeTerminal) { + return { capability: 'available', reason: 'Windows desktop terminal' }; + } + // Even plain cmd.exe on Windows desktop should have browser access + return { capability: 'available', reason: 'Windows desktop environment' }; + } + + // macOS - always has browser unless SSH + if (platform === 'darwin') { + return { capability: 'available', reason: 'macOS desktop environment' }; + } + + // Linux - depends on DISPLAY/WAYLAND + if (platform === 'linux') { + if (display) { + return { capability: 'available', reason: `Display available: ${display}` }; + } + return { capability: 'unlikely', reason: 'No DISPLAY or WAYLAND_DISPLAY' }; + } + + return { capability: 'unknown', reason: 'Unknown platform' }; +} + +/** + * Determine if environment is headless (improved detection) + * Returns both result and reasons for transparency + */ +function detectHeadless(): { isHeadless: boolean; reasons: string[] } { + const reasons: string[] = []; + + // SSH session + if (process.env.SSH_TTY || process.env.SSH_CLIENT || process.env.SSH_CONNECTION) { + reasons.push('SSH session detected'); + return { isHeadless: true, reasons }; + } + + // Linux without display + if (process.platform === 'linux' && !process.env.DISPLAY && !process.env.WAYLAND_DISPLAY) { + reasons.push('Linux without DISPLAY/WAYLAND_DISPLAY'); + return { isHeadless: true, reasons }; + } + + // Windows desktop - NEVER headless unless SSH (already checked above) + // This fixes false positive on Windows where isTTY is undefined + if (process.platform === 'win32') { + reasons.push('Windows desktop - browser available'); + return { isHeadless: false, reasons }; + } + + // macOS - check for proper terminal + if (process.platform === 'darwin') { + if (!process.stdin.isTTY) { + reasons.push('Non-interactive stdin on macOS'); + return { isHeadless: true, reasons }; + } + reasons.push('macOS with TTY - browser available'); + return { isHeadless: false, reasons }; + } + + // Linux with display - check TTY + if (process.platform === 'linux') { + if (!process.stdin.isTTY) { + reasons.push('Non-interactive stdin on Linux'); + return { isHeadless: true, reasons }; + } + reasons.push('Linux with display and TTY'); + return { isHeadless: false, reasons }; + } + + // Default fallback + reasons.push('Default: assuming headless'); + return { isHeadless: true, reasons }; +} + +/** + * Get comprehensive environment diagnostics + */ +export function getEnvironmentDiagnostics(): EnvironmentDiagnostics { + const platform = process.platform; + const ssh = checkSshSession(); + const display = + process.platform === 'linux' + ? process.env.DISPLAY || process.env.WAYLAND_DISPLAY || null + : null; + const windowsTerminal = !!process.env.WT_SESSION; + const vsCodeTerminal = !!( + process.env.VSCODE_GIT_IPC_HANDLE || + process.env.VSCODE_IPC_HOOK_CLI || + process.env.TERM_PROGRAM === 'vscode' + ); + const ciEnvironment = !!( + process.env.CI || + process.env.GITHUB_ACTIONS || + process.env.GITLAB_CI || + process.env.JENKINS_URL + ); + + const browser = assessBrowserCapability( + platform, + ssh.isSsh, + display, + windowsTerminal, + vsCodeTerminal + ); + const headless = detectHeadless(); + + // Platform display name + const platformNames: Record = { + win32: 'Windows', + darwin: 'macOS', + linux: 'Linux', + freebsd: 'FreeBSD', + openbsd: 'OpenBSD', + }; + + return { + platform, + platformName: platformNames[platform] || platform, + shell: detectShell(), + termProgram: process.env.TERM_PROGRAM || null, + sshSession: ssh.isSsh, + sshReason: ssh.reason, + ttyStatus: process.stdin.isTTY === undefined ? 'undefined' : process.stdin.isTTY, + stdoutTty: process.stdout.isTTY === undefined ? 'undefined' : process.stdout.isTTY, + display, + windowsTerminal, + vsCodeTerminal, + ciEnvironment, + detectedHeadless: headless.isHeadless, + headlessReasons: headless.reasons, + browserCapability: browser.capability, + browserReason: browser.reason, + }; +} + +/** + * Check if environment should use headless OAuth flow + * Uses improved detection that avoids Windows false positives + */ +export function shouldUseHeadlessAuth(): boolean { + return detectHeadless().isHeadless; +} + +/** + * Format diagnostics for display + */ +export function formatEnvironmentDiagnostics(diag: EnvironmentDiagnostics): string[] { + const lines: string[] = []; + + // Platform + lines.push(`Platform ${diag.platformName} (${diag.platform})`); + + // Shell + lines.push(`Shell ${diag.shell}`); + + // Terminal + if (diag.termProgram) { + lines.push(`Terminal Program ${diag.termProgram}`); + } + if (diag.windowsTerminal) { + lines.push(`Windows Terminal Yes`); + } + if (diag.vsCodeTerminal) { + lines.push(`VS Code Terminal Yes`); + } + + // SSH + lines.push(`SSH Session ${diag.sshSession ? `Yes (${diag.sshReason})` : 'No'}`); + + // TTY + const ttyDisplay = + diag.ttyStatus === 'undefined' ? 'undefined [!]' : diag.ttyStatus ? 'Yes' : 'No'; + lines.push(`stdin TTY ${ttyDisplay}`); + + // Display (Linux) + if (diag.platform === 'linux') { + lines.push(`Display ${diag.display || 'Not set'}`); + } + + // CI + if (diag.ciEnvironment) { + lines.push(`CI Environment Yes`); + } + + // Browser capability + const browserIcon = + diag.browserCapability === 'available' + ? '[OK]' + : diag.browserCapability === 'unlikely' + ? '[!]' + : '[?]'; + lines.push(`Browser Capability ${browserIcon} ${diag.browserReason}`); + + // Headless detection result + const headlessIcon = diag.detectedHeadless ? '[!]' : '[OK]'; + lines.push(`Headless Mode ${headlessIcon} ${diag.detectedHeadless ? 'Yes' : 'No'}`); + for (const reason of diag.headlessReasons) { + lines.push(` → ${reason}`); + } + + return lines; +} + +export default { + getEnvironmentDiagnostics, + shouldUseHeadlessAuth, + formatEnvironmentDiagnostics, +}; diff --git a/src/management/oauth-port-diagnostics.ts b/src/management/oauth-port-diagnostics.ts new file mode 100644 index 00000000..ccc255a0 --- /dev/null +++ b/src/management/oauth-port-diagnostics.ts @@ -0,0 +1,236 @@ +/** + * OAuth Port Diagnostics Module + * + * Pre-flight checks for OAuth callback ports to detect conflicts + * before users attempt authentication. + * + * OAuth flows require specific localhost ports for callbacks: + * - Gemini: 8085 + * - Codex: 1455 + * - Agy: 51121 + * - Qwen: Device Code Flow (no port needed) + */ + +import { getPortProcess, PortProcess, isCLIProxyProcess } from '../utils/port-utils'; +import { CLIProxyProvider } from '../cliproxy/types'; + +/** + * OAuth callback ports for each provider + * Extracted from CLIProxyAPI source + */ +export const OAUTH_CALLBACK_PORTS: Record = { + gemini: 8085, + codex: 1455, + agy: 51121, + qwen: null, // Device Code Flow - no callback port + iflow: null, // Device Code Flow - no callback port +}; + +/** + * OAuth flow types + */ +export type OAuthFlowType = 'authorization_code' | 'device_code'; + +/** + * OAuth flow type per provider + */ +export const OAUTH_FLOW_TYPES: Record = { + gemini: 'authorization_code', + codex: 'authorization_code', + agy: 'authorization_code', + qwen: 'device_code', + iflow: 'device_code', +}; + +/** + * Port diagnostic result + */ +export interface OAuthPortDiagnostic { + /** Provider name */ + provider: CLIProxyProvider; + /** OAuth flow type */ + flowType: OAuthFlowType; + /** Callback port (null for device code flow) */ + port: number | null; + /** Port status */ + status: 'free' | 'occupied' | 'cliproxy' | 'not_applicable'; + /** Process occupying the port (if any) */ + process: PortProcess | null; + /** Human-readable status message */ + message: string; + /** Recommendation for fixing (if issue detected) */ + recommendation: string | null; +} + +/** + * Check OAuth port availability for a single provider + */ +export async function checkOAuthPort(provider: CLIProxyProvider): Promise { + const port = OAUTH_CALLBACK_PORTS[provider]; + const flowType = OAUTH_FLOW_TYPES[provider]; + + // Device code flow doesn't need callback port + if (port === null) { + return { + provider, + flowType, + port: null, + status: 'not_applicable', + process: null, + message: 'Uses Device Code Flow (no callback port needed)', + recommendation: null, + }; + } + + // Check if port is in use + const portProcess = await getPortProcess(port); + + if (!portProcess) { + return { + provider, + flowType, + port, + status: 'free', + process: null, + message: `Port ${port} is available`, + recommendation: null, + }; + } + + // Check if it's CLIProxy (expected if proxy is running) + if (isCLIProxyProcess(portProcess)) { + return { + provider, + flowType, + port, + status: 'cliproxy', + process: portProcess, + message: `Port ${port} in use by CLIProxy (expected)`, + recommendation: null, + }; + } + + // Port is occupied by another process + return { + provider, + flowType, + port, + status: 'occupied', + process: portProcess, + message: `Port ${port} occupied by ${portProcess.processName}`, + recommendation: `Kill process: kill ${portProcess.pid} (or close ${portProcess.processName})`, + }; +} + +/** + * Check OAuth ports for all providers + */ +export async function checkAllOAuthPorts(): Promise { + const providers: CLIProxyProvider[] = ['gemini', 'codex', 'agy', 'qwen', 'iflow']; + const results: OAuthPortDiagnostic[] = []; + + for (const provider of providers) { + const diagnostic = await checkOAuthPort(provider); + results.push(diagnostic); + } + + return results; +} + +/** + * Check OAuth ports for providers that use Authorization Code flow only + */ +export async function checkAuthCodePorts(): Promise { + const providers: CLIProxyProvider[] = ['gemini', 'codex', 'agy']; + const results: OAuthPortDiagnostic[] = []; + + for (const provider of providers) { + const diagnostic = await checkOAuthPort(provider); + results.push(diagnostic); + } + + return results; +} + +/** + * Get providers with port conflicts + */ +export async function getPortConflicts(): Promise { + const allPorts = await checkAllOAuthPorts(); + return allPorts.filter((d) => d.status === 'occupied'); +} + +/** + * Format OAuth port diagnostics for display + */ +export function formatOAuthPortDiagnostics(diagnostics: OAuthPortDiagnostic[]): string[] { + const lines: string[] = []; + + for (const diag of diagnostics) { + const providerName = diag.provider.charAt(0).toUpperCase() + diag.provider.slice(1); + const portStr = diag.port !== null ? `(${diag.port})` : ''; + + let statusIcon: string; + switch (diag.status) { + case 'free': + statusIcon = '[OK]'; + break; + case 'cliproxy': + statusIcon = '[OK]'; + break; + case 'occupied': + statusIcon = '[!]'; + break; + case 'not_applicable': + statusIcon = '[i]'; + break; + default: + statusIcon = '[?]'; + } + + const label = `${providerName} ${portStr}`.padEnd(20); + lines.push(`${statusIcon} ${label} ${diag.message}`); + + if (diag.recommendation) { + lines.push(` → ${diag.recommendation}`); + } + } + + return lines; +} + +/** + * Pre-flight check before OAuth - returns issues or empty array if OK + */ +export async function preflightOAuthCheck(provider: CLIProxyProvider): Promise<{ + ready: boolean; + issues: string[]; +}> { + const diagnostic = await checkOAuthPort(provider); + const issues: string[] = []; + + if (diagnostic.status === 'occupied' && diagnostic.process) { + issues.push( + `OAuth callback port ${diagnostic.port} is blocked by ${diagnostic.process.processName} (PID ${diagnostic.process.pid})` + ); + if (diagnostic.recommendation) { + issues.push(`Fix: ${diagnostic.recommendation}`); + } + } + + return { + ready: issues.length === 0, + issues, + }; +} + +export default { + OAUTH_CALLBACK_PORTS, + OAUTH_FLOW_TYPES, + checkOAuthPort, + checkAllOAuthPorts, + checkAuthCodePorts, + getPortConflicts, + formatOAuthPortDiagnostics, + preflightOAuthCheck, +}; From d576e5e1f2ce924603f95c1eba200cf5a22a7ef9 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 9 Dec 2025 17:53:13 +0000 Subject: [PATCH 02/13] chore(release): 5.12.1-dev.1 [skip ci] --- VERSION | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/VERSION b/VERSION index 61ef7496..bd534c66 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -5.12.1 +5.12.1-dev.1 diff --git a/package.json b/package.json index 74e4562d..df3fc549 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "5.12.1", + "version": "5.12.1-dev.1", "description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6", "keywords": [ "cli", From 96d9fc68e9cf2af8b0b0d237d1ef094269cebb38 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Tue, 9 Dec 2025 13:00:04 -0500 Subject: [PATCH 03/13] feat(dashboard): add Environment and OAuth Readiness groups to health page Bring OAuth diagnostics from ccs doctor to web dashboard: - Add Environment group showing platform, SSH, TTY, browser capability - Add OAuth Readiness group showing port availability (8085, 1455, 51121) - Reuse existing environment-diagnostics and oauth-port-diagnostics modules The dashboard health page now has 7 groups matching ccs doctor output. --- src/web-server/health-service.ts | 79 ++++++++++++++++++++++++++++++-- 1 file changed, 74 insertions(+), 5 deletions(-) diff --git a/src/web-server/health-service.ts b/src/web-server/health-service.ts index 469d0539..5219f904 100644 --- a/src/web-server/health-service.ts +++ b/src/web-server/health-service.ts @@ -2,7 +2,7 @@ * Health Check Service (Phase 06) * * Runs comprehensive health checks for CCS dashboard matching `ccs doctor` output. - * Groups: System, Configuration, Profiles & Delegation, System Health, CLIProxy + * Groups: System, Environment, Configuration, Profiles & Delegation, System Health, CLIProxy, OAuth Readiness */ import * as fs from 'fs'; @@ -21,6 +21,8 @@ import { import { getClaudeCliInfo } from '../utils/claude-detector'; import { getPortProcess, isCLIProxyProcess } from '../utils/port-utils'; import packageJson from '../../package.json'; +import { getEnvironmentDiagnostics } from '../management/environment-diagnostics'; +import { checkAuthCodePorts } from '../management/oauth-port-diagnostics'; export interface HealthCheck { id: string; @@ -70,7 +72,12 @@ export async function runHealthChecks(): Promise { systemChecks.push(checkCcsDirectory(ccsDir)); groups.push({ id: 'system', name: 'System', icon: 'Monitor', checks: systemChecks }); - // Group 2: Configuration + // Group 2: Environment (OAuth readiness diagnostics) + const envChecks: HealthCheck[] = []; + envChecks.push(checkEnvironment()); + groups.push({ id: 'environment', name: 'Environment', icon: 'Laptop', checks: envChecks }); + + // Group 3: Configuration const configChecks: HealthCheck[] = []; configChecks.push(checkConfigFile()); configChecks.push(...checkSettingsFiles(ccsDir)); @@ -82,7 +89,7 @@ export async function runHealthChecks(): Promise { checks: configChecks, }); - // Group 3: Profiles & Delegation + // Group 4: Profiles & Delegation const profileChecks: HealthCheck[] = []; profileChecks.push(checkProfiles(ccsDir)); profileChecks.push(checkInstances(ccsDir)); @@ -94,7 +101,7 @@ export async function runHealthChecks(): Promise { checks: profileChecks, }); - // Group 4: System Health + // Group 5: System Health const healthChecks: HealthCheck[] = []; healthChecks.push(checkPermissions(ccsDir)); healthChecks.push(checkCcsSymlinks()); @@ -106,7 +113,7 @@ export async function runHealthChecks(): Promise { checks: healthChecks, }); - // Group 5: CLIProxy + // Group 6: CLIProxy const cliproxyChecks: HealthCheck[] = []; cliproxyChecks.push(checkCliproxyBinary()); cliproxyChecks.push(checkCliproxyConfig()); @@ -119,6 +126,16 @@ export async function runHealthChecks(): Promise { checks: cliproxyChecks, }); + // Group 7: OAuth Readiness (port availability) + const oauthReadinessChecks: HealthCheck[] = []; + oauthReadinessChecks.push(...(await checkOAuthPortsForDashboard())); + groups.push({ + id: 'oauth-readiness', + name: 'OAuth Readiness', + icon: 'Key', + checks: oauthReadinessChecks, + }); + // Flatten all checks for backward compatibility const allChecks = groups.flatMap((g) => g.checks); @@ -747,6 +764,58 @@ async function checkCliproxyPort(): Promise { }; } +// Check 16: Environment (platform, SSH, TTY, browser capability) +function checkEnvironment(): HealthCheck { + const diag = getEnvironmentDiagnostics(); + + let status: 'ok' | 'warning' | 'info' = 'ok'; + let message = 'Browser available'; + + if (diag.detectedHeadless) { + if (diag.platform === 'win32' && diag.ttyStatus === 'undefined') { + status = 'warning'; + message = 'Possible headless false positive (Windows)'; + } else if (diag.sshSession) { + status = 'info'; + message = 'SSH session (headless mode)'; + } else { + status = 'info'; + message = 'Headless environment'; + } + } + + return { + id: 'environment', + name: 'Environment', + status, + message, + details: `${diag.platformName} | SSH: ${diag.sshSession ? 'Yes' : 'No'} | Browser: ${diag.browserReason}`, + }; +} + +// Check 17: OAuth Ports (port availability for Gemini, Codex, Agy) +async function checkOAuthPortsForDashboard(): Promise { + const portDiagnostics = await checkAuthCodePorts(); + + return portDiagnostics.map((diag) => { + const providerName = diag.provider.charAt(0).toUpperCase() + diag.provider.slice(1); + const portStr = diag.port ? ` (${diag.port})` : ''; + + let status: 'ok' | 'warning' | 'info' = 'ok'; + if (diag.status === 'occupied') status = 'warning'; + if (diag.status === 'not_applicable') status = 'info'; + + return { + id: `oauth-port-${diag.provider}`, + name: `${providerName}${portStr}`, + status, + message: diag.message, + details: diag.process ? `PID ${diag.process.pid}` : undefined, + fix: diag.recommendation || undefined, + }; + }); +} + /** * Fix a health issue by its check ID */ From 00cf5c20dd744c752e84cbc964e18b3b3eb66ad0 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 9 Dec 2025 18:02:34 +0000 Subject: [PATCH 04/13] chore(release): 5.12.1-dev.2 [skip ci] --- VERSION | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/VERSION b/VERSION index bd534c66..987c7d8b 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -5.12.1-dev.1 +5.12.1-dev.2 diff --git a/package.json b/package.json index df3fc549..8c7e73f8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "5.12.1-dev.1", + "version": "5.12.1-dev.2", "description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6", "keywords": [ "cli", From f255a20a931babc45e8a4c9e34d733f8a9eed83f Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Tue, 9 Dec 2025 14:44:56 -0500 Subject: [PATCH 05/13] feat(analytics): refactor model color management and fix UI display issues - Extract getModelColor utility to lib/utils.ts with improved FNV-1a hash algorithm - Replace hardcoded color palette with vibrant tones palette - Remove color constants from model-breakdown-chart.tsx - Fix truncated model names in analytics display - Update project roadmap with analytics enhancements --- docs/project-roadmap.md | 3 +- .../analytics/model-breakdown-chart.tsx | 19 ++---------- ui/src/lib/utils.ts | 26 +++++++++++++++++ ui/src/pages/analytics.tsx | 29 ++----------------- 4 files changed, 33 insertions(+), 44 deletions(-) diff --git a/docs/project-roadmap.md b/docs/project-roadmap.md index 8611f590..3df08fb0 100644 --- a/docs/project-roadmap.md +++ b/docs/project-roadmap.md @@ -589,6 +589,7 @@ src/types/ **Release Date**: 2025-12-08 #### UI Fixes & Improvements +- ✅ **Analytics UI Enhancements**: Standardized colors, fixed truncated model names, and ensured color consistency in the analytics dashboard. - ✅ **Auto-formatting**: 31 UI files auto-formatted for consistent styling. - ✅ **Fast Refresh Exports**: Resolved `react-refresh/only-export-components` by extracting `buttonVariants`, `useSidebar`, and `useWebSocketContext` to separate files. - ✅ **React Hooks Issues**: Fixed `react-hooks/purity` (`Math.random()` in `useMemo` for `sidebar.tsx`) and `react-hooks/set-state-in-effect` (`use-theme.ts`, `settings.tsx`). @@ -821,6 +822,6 @@ src/types/ --- **Document Status**: Living document, updated with each major release -**Last Updated**: 2025-12-08 (UI Layout Improvements) +**Last Updated**: 2025-12-09 (Analytics UI Enhancements) **Next Update**: v4.6.0 UI Enhancements Planning **Maintainer**: CCS Development Team \ No newline at end of file diff --git a/ui/src/components/analytics/model-breakdown-chart.tsx b/ui/src/components/analytics/model-breakdown-chart.tsx index cfc02294..e96045fe 100644 --- a/ui/src/components/analytics/model-breakdown-chart.tsx +++ b/ui/src/components/analytics/model-breakdown-chart.tsx @@ -9,7 +9,7 @@ import { useMemo } from 'react'; import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip } from 'recharts'; import { Skeleton } from '@/components/ui/skeleton'; import type { ModelUsage } from '@/hooks/use-usage'; -import { cn } from '@/lib/utils'; +import { cn, getModelColor } from '@/lib/utils'; interface ModelBreakdownChartProps { data: ModelUsage[]; @@ -17,30 +17,17 @@ interface ModelBreakdownChartProps { className?: string; } -const COLORS = [ - '#0080FF', - '#00C49F', - '#FFBB28', - '#FF8042', - '#8884D8', - '#82CA9D', - '#FFC658', - '#8DD1E1', - '#D084D0', - '#87D068', -]; - export function ModelBreakdownChart({ data, isLoading, className }: ModelBreakdownChartProps) { const chartData = useMemo(() => { if (!data || data.length === 0) return []; - return data.map((item, index) => ({ + return data.map((item) => ({ name: item.model, value: item.tokens, cost: item.cost, requests: item.requests, percentage: item.percentage, - fill: COLORS[index % COLORS.length], + fill: getModelColor(item.model), })); }, [data]); diff --git a/ui/src/lib/utils.ts b/ui/src/lib/utils.ts index 2819a830..d683130e 100644 --- a/ui/src/lib/utils.ts +++ b/ui/src/lib/utils.ts @@ -4,3 +4,29 @@ import { twMerge } from 'tailwind-merge'; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); } + +export function getModelColor(model: string): string { + // Vibrant Tones Palette + const colors = [ + '#f94144', // Strawberry Red + '#f3722c', // Pumpkin Spice + '#f8961e', // Carrot Orange + '#f9844a', // Atomic Tangerine + '#f9c74f', // Tuscan Sun + '#90be6d', // Willow Green + '#43aa8b', // Seaweed + '#4d908e', // Dark Cyan + '#577590', // Blue Slate + '#277da1', // Cerulean + ]; + + // FNV-1a hash algorithm + let hash = 0x811c9dc5; + for (let i = 0; i < model.length; i++) { + hash ^= model.charCodeAt(i); + hash = Math.imul(hash, 0x01000193); + } + + // Ensure positive index + return colors[(hash >>> 0) % colors.length]; +} diff --git a/ui/src/pages/analytics.tsx b/ui/src/pages/analytics.tsx index 410ae7fa..d000c405 100644 --- a/ui/src/pages/analytics.tsx +++ b/ui/src/pages/analytics.tsx @@ -26,6 +26,7 @@ import { useRefreshUsage, useUsageStatus, } from '@/hooks/use-usage'; +import { getModelColor } from '@/lib/utils'; type ViewMode = 'daily' | 'monthly' | 'sessions'; @@ -214,10 +215,7 @@ export function AnalyticsPage() { className="w-2.5 h-2.5 rounded-full shrink-0" style={{ backgroundColor: getModelColor(model.model) }} /> - + {model.model} @@ -276,29 +274,6 @@ export function AnalyticsPage() { ); } -// Helper function to generate consistent colors for models -function getModelColor(model: string): string { - const colors = [ - '#0080FF', - '#00C49F', - '#FFBB28', - '#FF8042', - '#8884D8', - '#82CA9D', - '#FFC658', - '#8DD1E1', - '#D084D0', - '#87D068', - ]; - - let hash = 0; - for (let i = 0; i < model.length; i++) { - hash = model.charCodeAt(i) + ((hash << 5) - hash); - } - - return colors[Math.abs(hash) % colors.length]; -} - export function AnalyticsSkeleton() { return (
From 1e11d2e40af20386e5e26677021440f35a7e7217 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Tue, 9 Dec 2025 15:15:55 -0500 Subject: [PATCH 06/13] feat(analytics): aggregate usage from all CCS auth profiles Add multi-instance support to usage analytics. The dashboard now aggregates usage data from ~/.claude/ AND all CCS instances in ~/.ccs/instances//. - Add getInstancePaths() to discover CCS instances with usage data - Add loadInstanceData() to load data from specific instance - Add merge functions for daily/monthly/session data with deduplication - Modify refreshFromSource() to aggregate from all instances sequentially - Merge by date/sessionId to avoid double-counting overlapping usage --- src/web-server/usage-routes.ts | 210 ++++++++++++++++++++++++++++++++- 1 file changed, 208 insertions(+), 2 deletions(-) diff --git a/src/web-server/usage-routes.ts b/src/web-server/usage-routes.ts index 61329b6e..03e4e882 100644 --- a/src/web-server/usage-routes.ts +++ b/src/web-server/usage-routes.ts @@ -12,6 +12,9 @@ */ import { Router, Request, Response } from 'express'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; import { loadDailyUsageData, loadMonthlyUsageData, @@ -29,6 +32,169 @@ import { getCacheAge, } from './usage-disk-cache'; +// ============================================================================ +// Multi-Instance Support - Aggregate usage from CCS profiles +// ============================================================================ + +/** Path to CCS instances directory */ +const CCS_INSTANCES_DIR = path.join(os.homedir(), '.ccs', 'instances'); + +/** + * Get list of CCS instance paths that have usage data + * Only returns instances with existing projects/ directory + */ +function getInstancePaths(): string[] { + if (!fs.existsSync(CCS_INSTANCES_DIR)) { + return []; + } + + try { + const entries = fs.readdirSync(CCS_INSTANCES_DIR, { withFileTypes: true }); + return entries + .filter((entry) => entry.isDirectory()) + .map((entry) => path.join(CCS_INSTANCES_DIR, entry.name)) + .filter((instancePath) => { + // Only include instances that have a projects directory + const projectsPath = path.join(instancePath, 'projects'); + return fs.existsSync(projectsPath); + }); + } catch { + console.error('[!] Failed to read CCS instances directory'); + return []; + } +} + +/** + * Load usage data from a specific instance by temporarily setting CLAUDE_CONFIG_DIR + * Returns empty arrays if instance has no usage data + */ +async function loadInstanceData(instancePath: string): Promise<{ + daily: DailyUsage[]; + monthly: MonthlyUsage[]; + session: SessionUsage[]; +}> { + const originalConfigDir = process.env.CLAUDE_CONFIG_DIR; + + try { + // Set CLAUDE_CONFIG_DIR to instance path for better-ccusage to read from + process.env.CLAUDE_CONFIG_DIR = instancePath; + + const [daily, monthly, session] = await Promise.all([ + loadDailyUsageData() as Promise, + loadMonthlyUsageData() as Promise, + loadSessionData() as Promise, + ]); + + return { daily, monthly, session }; + } catch (_err) { + // Instance may have no usage data - that's OK + const instanceName = path.basename(instancePath); + console.log(`[i] No usage data in instance: ${instanceName}`); + return { daily: [], monthly: [], session: [] }; + } finally { + // Restore original env var + if (originalConfigDir === undefined) { + delete process.env.CLAUDE_CONFIG_DIR; + } else { + process.env.CLAUDE_CONFIG_DIR = originalConfigDir; + } + } +} + +/** + * Merge daily usage data from multiple sources + * Combines entries with same date by aggregating tokens + */ +function mergeDailyData(sources: DailyUsage[][]): DailyUsage[] { + const dateMap = new Map(); + + for (const source of sources) { + for (const day of source) { + const existing = dateMap.get(day.date); + if (existing) { + // Aggregate tokens for same date + existing.inputTokens += day.inputTokens; + existing.outputTokens += day.outputTokens; + existing.cacheCreationTokens += day.cacheCreationTokens; + existing.cacheReadTokens += day.cacheReadTokens; + existing.totalCost += day.totalCost; + // Merge unique models + const modelSet = new Set([...existing.modelsUsed, ...day.modelsUsed]); + existing.modelsUsed = Array.from(modelSet); + // Merge model breakdowns by aggregating same modelName + for (const breakdown of day.modelBreakdowns) { + const existingBreakdown = existing.modelBreakdowns.find( + (b) => b.modelName === breakdown.modelName + ); + if (existingBreakdown) { + existingBreakdown.inputTokens += breakdown.inputTokens; + existingBreakdown.outputTokens += breakdown.outputTokens; + existingBreakdown.cacheCreationTokens += breakdown.cacheCreationTokens; + existingBreakdown.cacheReadTokens += breakdown.cacheReadTokens; + existingBreakdown.cost += breakdown.cost; + } else { + existing.modelBreakdowns.push({ ...breakdown }); + } + } + } else { + // Clone to avoid mutating original + dateMap.set(day.date, { + ...day, + modelsUsed: [...day.modelsUsed], + modelBreakdowns: day.modelBreakdowns.map((b) => ({ ...b })), + }); + } + } + } + + return Array.from(dateMap.values()).sort((a, b) => a.date.localeCompare(b.date)); +} + +/** + * Merge monthly usage data from multiple sources + */ +function mergeMonthlyData(sources: MonthlyUsage[][]): MonthlyUsage[] { + const monthMap = new Map(); + + for (const source of sources) { + for (const month of source) { + const existing = monthMap.get(month.month); + if (existing) { + existing.inputTokens += month.inputTokens; + existing.outputTokens += month.outputTokens; + existing.cacheCreationTokens += month.cacheCreationTokens; + existing.cacheReadTokens += month.cacheReadTokens; + existing.totalCost += month.totalCost; + const modelSet = new Set([...existing.modelsUsed, ...month.modelsUsed]); + existing.modelsUsed = Array.from(modelSet); + } else { + monthMap.set(month.month, { ...month, modelsUsed: [...month.modelsUsed] }); + } + } + } + + return Array.from(monthMap.values()).sort((a, b) => a.month.localeCompare(b.month)); +} + +/** + * Merge session data from multiple sources + * Deduplicates by sessionId (same session shouldn't appear in multiple instances) + */ +function mergeSessionData(sources: SessionUsage[][]): SessionUsage[] { + const sessionMap = new Map(); + + for (const source of sources) { + for (const session of source) { + // Use sessionId as unique key - later entries overwrite earlier ones + sessionMap.set(session.sessionId, session); + } + } + + return Array.from(sessionMap.values()).sort( + (a, b) => new Date(b.lastActivity).getTime() - new Date(a.lastActivity).getTime() + ); +} + export const usageRoutes = Router(); /** Query parameters for usage endpoints */ @@ -195,17 +361,57 @@ let isRefreshing = false; /** * Load fresh data from better-ccusage and update both memory and disk caches + * Aggregates data from default ~/.claude/ AND all CCS instances */ async function refreshFromSource(): Promise<{ daily: DailyUsage[]; monthly: MonthlyUsage[]; session: SessionUsage[]; }> { - const [daily, monthly, session] = await Promise.all([ + // Load default data (from ~/.claude/ or current CLAUDE_CONFIG_DIR) + const defaultData = await Promise.all([ loadDailyUsageData() as Promise, loadMonthlyUsageData() as Promise, loadSessionData() as Promise, - ]); + ]).then(([daily, monthly, session]) => ({ daily, monthly, session })); + + // Load data from all CCS instances sequentially (to avoid env var race condition) + const instancePaths = getInstancePaths(); + const instanceDataResults: Array<{ + daily: DailyUsage[]; + monthly: MonthlyUsage[]; + session: SessionUsage[]; + }> = []; + + for (const instancePath of instancePaths) { + try { + const data = await loadInstanceData(instancePath); + instanceDataResults.push(data); + } catch (err) { + const instanceName = path.basename(instancePath); + console.error(`[!] Failed to load instance ${instanceName}:`, err); + } + } + + // Collect successful instance data + const allDailySources: DailyUsage[][] = [defaultData.daily]; + const allMonthlySources: MonthlyUsage[][] = [defaultData.monthly]; + const allSessionSources: SessionUsage[][] = [defaultData.session]; + + for (const result of instanceDataResults) { + allDailySources.push(result.daily); + allMonthlySources.push(result.monthly); + allSessionSources.push(result.session); + } + + if (instanceDataResults.length > 0) { + console.log(`[i] Aggregated usage data from ${instanceDataResults.length} CCS instance(s)`); + } + + // Merge all data sources + const daily = mergeDailyData(allDailySources); + const monthly = mergeMonthlyData(allMonthlySources); + const session = mergeSessionData(allSessionSources); // Update in-memory cache const now = Date.now(); From 493492fa7e88746f47240026ac16fae0575ff223 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Tue, 9 Dec 2025 16:14:28 -0500 Subject: [PATCH 07/13] feat(cliproxy): add --add flag and nickname support for multi-account auth - Add `--add` flag to skip confirm prompt when adding accounts - Add confirm prompt when accounts exist and --add not specified - Add nickname field to AccountInfo (auto-generated from email prefix) - Add generateNickname() and validateNickname() utility functions - Update triggerOAuth() to accept add option - Update registerAccountFromToken() to pass nickname - Update help text with --add flag documentation --- src/cliproxy/account-manager.ts | 43 +++++++++++++++++++++++++++++-- src/cliproxy/auth-handler.ts | 38 ++++++++++++++++++++++++--- src/cliproxy/cliproxy-executor.ts | 4 ++- src/commands/help-command.ts | 1 + 4 files changed, 79 insertions(+), 7 deletions(-) diff --git a/src/cliproxy/account-manager.ts b/src/cliproxy/account-manager.ts index e2ceffa9..5ba923bd 100644 --- a/src/cliproxy/account-manager.ts +++ b/src/cliproxy/account-manager.ts @@ -19,6 +19,8 @@ export interface AccountInfo { id: string; /** Email address from OAuth (if available) */ email?: string; + /** User-friendly nickname for quick reference (auto-generated from email prefix) */ + nickname?: string; /** Provider this account belongs to */ provider: CLIProxyProvider; /** Whether this is the default account for the provider */ @@ -53,6 +55,36 @@ const DEFAULT_REGISTRY: AccountsRegistry = { providers: {}, }; +/** + * Generate nickname from email + * Takes prefix before @ symbol, sanitizes whitespace + * Validation: 1-50 chars, any non-whitespace (permissive per user preference) + */ +export function generateNickname(email?: string): string { + if (!email) return 'default'; + const prefix = email.split('@')[0]; + // Sanitize: remove whitespace, limit to 50 chars + return prefix.replace(/\s+/g, '').slice(0, 50) || 'default'; +} + +/** + * Validate nickname + * Rules: 1-50 chars, any non-whitespace allowed (permissive) + * @returns null if valid, error message if invalid + */ +export function validateNickname(nickname: string): string | null { + if (!nickname || nickname.length === 0) { + return 'Nickname is required'; + } + if (nickname.length > 50) { + return 'Nickname must be 50 characters or less'; + } + if (/\s/.test(nickname)) { + return 'Nickname cannot contain whitespace'; + } + return null; +} + /** * Get path to accounts registry file */ @@ -140,7 +172,8 @@ export function getAccount(provider: CLIProxyProvider, accountId: string): Accou export function registerAccount( provider: CLIProxyProvider, tokenFile: string, - email?: string + email?: string, + nickname?: string ): AccountInfo { const registry = loadAccountsRegistry(); @@ -161,9 +194,13 @@ export function registerAccount( const accountId = email || 'default'; const isFirstAccount = Object.keys(providerAccounts.accounts).length === 0; + // Generate nickname if not provided + const accountNickname = nickname || generateNickname(email); + // Create or update account providerAccounts.accounts[accountId] = { email, + nickname: accountNickname, tokenFile, createdAt: new Date().toISOString(), lastUsedAt: new Date().toISOString(), @@ -181,6 +218,7 @@ export function registerAccount( provider, isDefault: accountId === providerAccounts.default, email, + nickname: accountNickname, tokenFile, createdAt: providerAccounts.accounts[accountId].createdAt, lastUsedAt: providerAccounts.accounts[accountId].lastUsedAt, @@ -333,9 +371,10 @@ export function discoverExistingAccounts(): void { // Get file stats for creation time const stats = fs.statSync(filePath); - // Register account + // Register account with auto-generated nickname providerAccounts.accounts[accountId] = { email, + nickname: generateNickname(email), tokenFile: file, createdAt: stats.birthtime?.toISOString() || new Date().toISOString(), lastUsedAt: stats.mtime?.toISOString(), diff --git a/src/cliproxy/auth-handler.ts b/src/cliproxy/auth-handler.ts index 23b92fd2..de355dd0 100644 --- a/src/cliproxy/auth-handler.ts +++ b/src/cliproxy/auth-handler.ts @@ -22,6 +22,7 @@ import { CLIProxyProvider } from './types'; import { AccountInfo, discoverExistingAccounts, + generateNickname, getDefaultAccount, getProviderAccounts, registerAccount, @@ -429,14 +430,15 @@ export function clearAuth(provider: CLIProxyProvider): boolean { * Auto-detects headless environment and uses --no-browser flag accordingly * @param provider - The CLIProxy provider to authenticate * @param options - OAuth options + * @param options.add - If true, skip confirm prompt when adding another account * @returns Account info if successful, null otherwise */ export async function triggerOAuth( provider: CLIProxyProvider, - options: { verbose?: boolean; headless?: boolean; account?: string } = {} + options: { verbose?: boolean; headless?: boolean; account?: string; add?: boolean } = {} ): Promise { const oauthConfig = getOAuthConfig(provider); - const { verbose = false } = options; + const { verbose = false, add = false } = options; // Auto-detect headless if not explicitly set const headless = options.headless ?? isHeadlessEnvironment(); @@ -447,6 +449,34 @@ export async function triggerOAuth( } }; + // Check for existing accounts and prompt if --add not specified + const existingAccounts = getProviderAccounts(provider); + if (existingAccounts.length > 0 && !add) { + console.log(''); + console.log( + `[i] ${existingAccounts.length} account(s) already authenticated for ${oauthConfig.displayName}` + ); + + // Import readline for confirm prompt + const readline = await import('readline'); + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + + const confirmed = await new Promise((resolve) => { + rl.question('[?] Add another account? (y/N): ', (answer) => { + rl.close(); + resolve(answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes'); + }); + }); + + if (!confirmed) { + console.log('[i] Cancelled'); + return null; + } + } + // Pre-flight check: verify OAuth callback port is available const preflight = await preflightOAuthCheck(provider); if (!preflight.ready) { @@ -662,8 +692,8 @@ function registerAccountFromToken( const data = JSON.parse(content); const email = data.email || undefined; - // Register the account - return registerAccount(provider, newestFile, email); + // Register the account with auto-generated nickname + return registerAccount(provider, newestFile, email, generateNickname(email)); } catch { return null; } diff --git a/src/cliproxy/cliproxy-executor.ts b/src/cliproxy/cliproxy-executor.ts index 7977f828..1f565c8e 100644 --- a/src/cliproxy/cliproxy-executor.ts +++ b/src/cliproxy/cliproxy-executor.ts @@ -123,6 +123,7 @@ export async function execClaudeWithCLIProxy( const forceHeadless = args.includes('--headless'); const forceLogout = args.includes('--logout'); const forceConfig = args.includes('--config'); + const addAccount = args.includes('--add'); // Handle --config: configure model selection and exit // Pass customSettingsPath for CLIProxy variants to save to correct file @@ -151,6 +152,7 @@ export async function execClaudeWithCLIProxy( const { triggerOAuth } = await import('./auth-handler'); const authSuccess = await triggerOAuth(provider, { verbose, + add: addAccount, ...(forceHeadless ? { headless: true } : {}), }); if (!authSuccess) { @@ -260,7 +262,7 @@ export async function execClaudeWithCLIProxy( log(`Claude env: ANTHROPIC_MODEL=${envVars.ANTHROPIC_MODEL}`); // Filter out CCS-specific flags before passing to Claude CLI - const ccsFlags = ['--auth', '--headless', '--logout', '--config']; + const ccsFlags = ['--auth', '--headless', '--logout', '--config', '--add']; const claudeArgs = args.filter((arg) => !ccsFlags.includes(arg)); const isWindows = process.platform === 'win32'; diff --git a/src/commands/help-command.ts b/src/commands/help-command.ts index d2663ffc..9a266347 100644 --- a/src/commands/help-command.ts +++ b/src/commands/help-command.ts @@ -161,6 +161,7 @@ Claude Code Profile & Model Switcher`.trim(); ['ccs qwen', 'Qwen Code (qwen3-coder)'], ['', ''], // Spacer ['ccs --auth', 'Authenticate only'], + ['ccs --auth --add', 'Add another account'], ['ccs --config', 'Change model (agy, gemini)'], ['ccs --logout', 'Clear authentication'], ['ccs --headless', 'Headless auth (for SSH)'], From d868dc4c32948db27e4b6073e9a7d28966a54971 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Tue, 9 Dec 2025 16:27:31 -0500 Subject: [PATCH 08/13] feat(cliproxy): add multi-account support phases 02-03 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 02 - CLI Account Selector in Variant Wizard: - Add --account flag to cliproxy create command - Implement inline OAuth flow when no accounts exist - Add account selector for multiple accounts - Auto-select single account Phase 03 - Dashboard Quick Setup Wizard: - Create quick-setup-wizard.tsx component - Add step-by-step wizard: Provider → Auth → Account → Variant → Success - Add Quick Setup button to CLIProxy page --- src/commands/cliproxy-command.ts | 123 +++++++- ui/src/components/quick-setup-wizard.tsx | 359 +++++++++++++++++++++++ ui/src/pages/cliproxy.tsx | 19 +- 3 files changed, 490 insertions(+), 11 deletions(-) create mode 100644 ui/src/components/quick-setup-wizard.tsx diff --git a/src/commands/cliproxy-command.ts b/src/commands/cliproxy-command.ts index 0332b5a1..31845543 100644 --- a/src/commands/cliproxy-command.ts +++ b/src/commands/cliproxy-command.ts @@ -23,7 +23,8 @@ import { isCLIProxyInstalled, getCLIProxyPath, } from '../cliproxy'; -import { getAllAuthStatus, getOAuthConfig } from '../cliproxy/auth-handler'; +import { getAllAuthStatus, getOAuthConfig, triggerOAuth } from '../cliproxy/auth-handler'; +import { getProviderAccounts } from '../cliproxy/account-manager'; import { CLIPROXY_FALLBACK_VERSION } from '../cliproxy/platform-detector'; import { CLIPROXY_PROFILES, CLIProxyProfileName } from '../auth/profile-detector'; import { getCcsDir, getConfigPath, loadConfig } from '../utils/config-manager'; @@ -53,6 +54,7 @@ interface CliproxyProfileArgs { name?: string; provider?: CLIProxyProfileName; model?: string; + account?: string; force?: boolean; yes?: boolean; } @@ -70,6 +72,8 @@ function parseProfileArgs(args: string[]): CliproxyProfileArgs { result.provider = args[++i] as CLIProxyProfileName; } else if (arg === '--model' && args[i + 1]) { result.model = args[++i]; + } else if (arg === '--account' && args[i + 1]) { + result.account = args[++i]; } else if (arg === '--force') { result.force = true; } else if (arg === '--yes' || arg === '-y') { @@ -136,7 +140,8 @@ function cliproxyVariantExists(name: string): boolean { function createCliproxySettingsFile( name: string, provider: CLIProxyProfileName, - model: string + model: string, + _account?: string ): string { const ccsDir = getCcsDir(); const settingsPath = path.join(ccsDir, `${provider}-${name}.settings.json`); @@ -170,7 +175,8 @@ function createCliproxySettingsFile( function addCliproxyVariant( name: string, provider: CLIProxyProfileName, - settingsPath: string + settingsPath: string, + account?: string ): void { const configPath = getConfigPath(); @@ -189,10 +195,16 @@ function addCliproxyVariant( // Use relative path with ~ for portability const relativePath = `~/.ccs/${path.basename(settingsPath)}`; - config.cliproxy[name] = { + + // Build variant config with optional account + const variantConfig: { provider: string; settings: string; account?: string } = { provider, settings: relativePath, }; + if (account) { + variantConfig.account = account; + } + config.cliproxy[name] = variantConfig; // Write config atomically const tempPath = configPath + '.tmp'; @@ -290,6 +302,103 @@ async function handleCreate(args: string[]): Promise { process.exit(1); } + // Step 2.5: Account selection + let account = parsedArgs.account; + const providerAccounts = getProviderAccounts(provider as CLIProxyProvider); + + if (!account) { + if (providerAccounts.length === 0) { + // No accounts - prompt to authenticate first + console.log(''); + console.log(warn(`No accounts authenticated for ${provider}`)); + console.log(''); + + const shouldAuth = await InteractivePrompt.confirm(`Authenticate with ${provider} now?`, { + default: true, + }); + + if (!shouldAuth) { + console.log(''); + console.log(info('Run authentication first:')); + console.log(` ${color(`ccs ${provider} --auth`, 'command')}`); + process.exit(0); + } + + // Trigger OAuth inline + console.log(''); + const newAccount = await triggerOAuth(provider as CLIProxyProvider, { + add: true, + verbose: args.includes('--verbose'), + }); + + if (!newAccount) { + console.log(fail('Authentication failed')); + process.exit(1); + } + + account = newAccount.id; + console.log(''); + console.log(ok(`Authenticated as ${newAccount.email || newAccount.id}`)); + } else if (providerAccounts.length === 1) { + // Single account - auto-select + account = providerAccounts[0].id; + } else { + // Multiple accounts - show selector with "Add new" option + const ADD_NEW_ID = '__add_new__'; + + const accountOptions = [ + ...providerAccounts.map((acc) => ({ + id: acc.id, + label: `${acc.email || acc.id}${acc.isDefault ? ' (default)' : ''}`, + })), + { + id: ADD_NEW_ID, + label: color('[+ Add new account...]', 'info'), + }, + ]; + + const defaultIdx = providerAccounts.findIndex((a) => a.isDefault); + + const selectedAccount = await InteractivePrompt.selectFromList( + 'Select account:', + accountOptions, + { defaultIndex: defaultIdx >= 0 ? defaultIdx : 0 } + ); + + if (selectedAccount === ADD_NEW_ID) { + // Add new account inline + console.log(''); + const newAccount = await triggerOAuth(provider as CLIProxyProvider, { + add: true, + verbose: args.includes('--verbose'), + }); + + if (!newAccount) { + console.log(fail('Authentication failed')); + process.exit(1); + } + + account = newAccount.id; + console.log(''); + console.log(ok(`Authenticated as ${newAccount.email || newAccount.id}`)); + } else { + account = selectedAccount; + } + } + } else { + // Validate provided account exists + const exists = providerAccounts.find((a) => a.id === account); + if (!exists) { + console.log(fail(`Account '${account}' not found for ${provider}`)); + console.log(''); + console.log('Available accounts:'); + providerAccounts.forEach((a) => { + console.log(` - ${a.email || a.id}${a.isDefault ? ' (default)' : ''}`); + }); + process.exit(1); + } + } + // Step 3: Model selection let model = parsedArgs.model; if (!model) { @@ -323,8 +432,8 @@ async function handleCreate(args: string[]): Promise { console.log(info('Creating CLIProxy variant...')); try { - const settingsPath = createCliproxySettingsFile(name, provider, model); - addCliproxyVariant(name, provider, settingsPath); + const settingsPath = createCliproxySettingsFile(name, provider, model, account); + addCliproxyVariant(name, provider, settingsPath, account); console.log(''); console.log( @@ -332,6 +441,7 @@ async function handleCreate(args: string[]): Promise { `Variant: ${name}\n` + `Provider: ${provider}\n` + `Model: ${model}\n` + + (account ? `Account: ${account}\n` : '') + `Settings: ~/.ccs/${path.basename(settingsPath)}`, 'CLIProxy Variant Created' ) @@ -551,6 +661,7 @@ async function showHelp(): Promise { const createOpts: [string, string][] = [ ['--provider ', 'Provider (gemini, codex, agy, qwen)'], ['--model ', 'Model name'], + ['--account ', 'Account ID (email or default)'], ['--force', 'Overwrite existing variant'], ['--yes, -y', 'Skip confirmation prompts'], ]; diff --git a/ui/src/components/quick-setup-wizard.tsx b/ui/src/components/quick-setup-wizard.tsx new file mode 100644 index 00000000..573c09d6 --- /dev/null +++ b/ui/src/components/quick-setup-wizard.tsx @@ -0,0 +1,359 @@ +/** + * Quick Setup Wizard Component + * Phase 03: Multi-account dashboard support + * + * Step-by-step wizard: Provider -> Auth -> Account -> Variant -> Success + */ + +import { useState, useEffect } 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 { Card, CardContent } from '@/components/ui/card'; +import { + Copy, + Check, + RefreshCw, + ChevronRight, + ArrowLeft, + Terminal, + User, + Sparkles, +} from 'lucide-react'; +import { useCliproxyAuth, useCreateVariant } from '@/hooks/use-cliproxy'; +import type { AuthStatus, OAuthAccount } from '@/lib/api-client'; + +interface QuickSetupWizardProps { + open: boolean; + onClose: () => void; +} + +type WizardStep = 'provider' | 'auth' | 'account' | 'variant' | 'success'; + +const providers = [ + { id: 'gemini', name: 'Google Gemini', description: 'Gemini Pro/Flash models' }, + { id: 'codex', name: 'OpenAI Codex', description: 'GPT-4 and codex models' }, + { id: 'agy', name: 'Antigravity', description: 'Antigravity AI models' }, + { id: 'qwen', name: 'Alibaba Qwen', description: 'Qwen Code models' }, + { id: 'iflow', name: 'iFlow', description: 'iFlow AI models' }, +]; + +export function QuickSetupWizard({ open, onClose }: QuickSetupWizardProps) { + const [step, setStep] = useState('provider'); + const [selectedProvider, setSelectedProvider] = useState(''); + const [selectedAccount, setSelectedAccount] = useState(null); + const [variantName, setVariantName] = useState(''); + const [modelName, setModelName] = useState(''); + const [copied, setCopied] = useState(false); + const [isRefreshing, setIsRefreshing] = useState(false); + + const { data: authData, refetch } = useCliproxyAuth(); + const createMutation = useCreateVariant(); + + // Get auth status for selected provider + const providerAuth = authData?.authStatus.find( + (s: AuthStatus) => s.provider === selectedProvider + ); + const accounts = providerAuth?.accounts || []; + + // Reset on close - use timeout to avoid synchronous setState in effect + useEffect(() => { + if (!open) { + const timer = setTimeout(() => { + setStep('provider'); + setSelectedProvider(''); + setSelectedAccount(null); + setVariantName(''); + setModelName(''); + setCopied(false); + }, 0); + return () => clearTimeout(timer); + } + }, [open]); + + // Auto-advance from auth step when account detected + // Use timeout to avoid synchronous setState in effect (React lint rule) + useEffect(() => { + if (step === 'auth' && accounts.length > 0) { + const timer = setTimeout(() => { + if (accounts.length === 1) { + setSelectedAccount(accounts[0]); + setStep('variant'); + } else { + setStep('account'); + } + }, 0); + return () => clearTimeout(timer); + } + }, [step, accounts]); + + const copyCommand = async (cmd: string) => { + await navigator.clipboard.writeText(cmd); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + + const handleRefresh = async () => { + setIsRefreshing(true); + await refetch(); + setIsRefreshing(false); + }; + + const handleProviderSelect = (providerId: string) => { + setSelectedProvider(providerId); + const auth = authData?.authStatus.find((s: AuthStatus) => s.provider === providerId); + const provAccounts = auth?.accounts || []; + + if (provAccounts.length === 0) { + setStep('auth'); + } else if (provAccounts.length === 1) { + setSelectedAccount(provAccounts[0]); + setStep('variant'); + } else { + setStep('account'); + } + }; + + const handleAccountSelect = (account: OAuthAccount) => { + setSelectedAccount(account); + setStep('variant'); + }; + + const handleCreateVariant = async () => { + if (!variantName || !selectedProvider) return; + + try { + await createMutation.mutateAsync({ + name: variantName, + provider: selectedProvider as 'gemini' | 'codex' | 'agy' | 'qwen' | 'iflow', + model: modelName || undefined, + account: selectedAccount?.id, + }); + setStep('success'); + } catch (error) { + console.error('Failed to create variant:', error); + } + }; + + const authCommand = `ccs ${selectedProvider} --auth --add`; + + // Progress steps for indicator + const allSteps = ['provider', 'auth', 'variant', 'success']; + const getStepProgress = (s: WizardStep) => { + if (s === 'account') return 1; // Same as auth + return allSteps.indexOf(s); + }; + const currentProgress = getStepProgress(step); + + return ( + + + + + + Quick Setup Wizard + + + {step === 'provider' && 'Select a provider to get started'} + {step === 'auth' && 'Authenticate with your provider'} + {step === 'account' && 'Select which account to use'} + {step === 'variant' && 'Create your custom variant'} + {step === 'success' && 'Setup complete!'} + + + +
+ {/* Step: Provider Selection */} + {step === 'provider' && ( +
+ {providers.map((p) => ( + + ))} +
+ )} + + {/* Step: Authentication */} + {step === 'auth' && ( +
+ + +
+ + Run this command in your terminal: +
+
+ + {authCommand} + + +
+
+ This will open your browser to authenticate with{' '} + {providers.find((p) => p.id === selectedProvider)?.name} +
+
+
+ +
+ + +
+
+ )} + + {/* Step: Account Selection */} + {step === 'account' && ( +
+
+ {accounts.map((acc: OAuthAccount) => ( + + ))} +
+
+ +
+
+ )} + + {/* Step: Create Variant */} + {step === 'variant' && ( +
+ {selectedAccount && ( +
+ + Using: {selectedAccount.email || selectedAccount.id} +
+ )} + +
+ + setVariantName(e.target.value)} + placeholder="e.g., my-gemini, g3, flash" + /> +
+ Use this name to invoke: ccs {variantName || ''} "prompt" +
+
+ +
+ + setModelName(e.target.value)} + placeholder="e.g., gemini-2.5-pro" + /> +
+ +
+ + +
+
+ )} + + {/* Step: Success */} + {step === 'success' && ( +
+
+
+ +
+
+
+
Variant Created!
+
+ Your custom variant is ready to use +
+
+ + +
Usage:
+ + ccs {variantName} "your prompt here" + +
+
+ +
+ )} +
+ + {/* Progress indicator */} +
+ {allSteps.map((s, i) => ( +
= i ? 'bg-primary' : 'bg-muted' + }`} + /> + ))} +
+ +
+ ); +} diff --git a/ui/src/pages/cliproxy.tsx b/ui/src/pages/cliproxy.tsx index a70e5de9..808d82cf 100644 --- a/ui/src/pages/cliproxy.tsx +++ b/ui/src/pages/cliproxy.tsx @@ -14,9 +14,10 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; -import { Plus, Check, X, User, ChevronDown, Star, Trash2 } from 'lucide-react'; +import { Plus, Check, X, User, ChevronDown, Star, Trash2, Sparkles } from 'lucide-react'; import { CliproxyTable } from '@/components/cliproxy-table'; import { CliproxyDialog } from '@/components/cliproxy-dialog'; +import { QuickSetupWizard } from '@/components/quick-setup-wizard'; import { useCliproxy, useCliproxyAuth, @@ -175,6 +176,7 @@ function ProviderRow({ export function CliproxyPage() { const [dialogOpen, setDialogOpen] = useState(false); + const [wizardOpen, setWizardOpen] = useState(false); const { data, isLoading } = useCliproxy(); const { data: authData, isLoading: authLoading } = useCliproxyAuth(); const setDefaultMutation = useSetDefaultAccount(); @@ -189,10 +191,16 @@ export function CliproxyPage() { Manage OAuth-based provider variants and multi-account configurations

- +
+ + +
{/* Built-in Profiles with Account Management */} @@ -245,6 +253,7 @@ export function CliproxyPage() { setDialogOpen(false)} /> + setWizardOpen(false)} /> ); } From 8f5c006f07f0ad93a7c7009df377b292076af55a Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Tue, 9 Dec 2025 16:51:24 -0500 Subject: [PATCH 09/13] feat(ui): simplify CLIProxy page UX with dedicated Add Account dialog - Remove redundant "Create Variant" button (Quick Setup is more comprehensive) - Add AddAccountDialog component for per-provider account addition - Keep "Quick Setup" for full variant creation wizard - Keep "Add Account" per-row for adding accounts only --- ui/src/components/add-account-dialog.tsx | 96 ++++++++++++++++++++++++ ui/src/pages/cliproxy.tsx | 62 +++++++-------- 2 files changed, 124 insertions(+), 34 deletions(-) create mode 100644 ui/src/components/add-account-dialog.tsx diff --git a/ui/src/components/add-account-dialog.tsx b/ui/src/components/add-account-dialog.tsx new file mode 100644 index 00000000..0fac8fdc --- /dev/null +++ b/ui/src/components/add-account-dialog.tsx @@ -0,0 +1,96 @@ +/** + * Add Account Dialog Component + * Simple dialog to add another OAuth account to a provider + * + * Shows auth command + refresh button (no variant creation) + */ + +import { useState } from 'react'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent } from '@/components/ui/card'; +import { Copy, Check, RefreshCw, Terminal } from 'lucide-react'; +import { useCliproxyAuth } from '@/hooks/use-cliproxy'; + +interface AddAccountDialogProps { + open: boolean; + onClose: () => void; + provider: string; + displayName: string; +} + +export function AddAccountDialog({ open, onClose, provider, displayName }: AddAccountDialogProps) { + const [copied, setCopied] = useState(false); + const [isRefreshing, setIsRefreshing] = useState(false); + const { refetch } = useCliproxyAuth(); + + const authCommand = `ccs ${provider} --auth --add`; + + const copyCommand = async () => { + await navigator.clipboard.writeText(authCommand); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + + const handleRefresh = async () => { + setIsRefreshing(true); + await refetch(); + setIsRefreshing(false); + onClose(); + }; + + return ( + + + + Add {displayName} Account + + Run the command below in your terminal to authenticate a new account + + + +
+ + +
+ + Run this command: +
+
+ + {authCommand} + + +
+
+ This will open your browser to authenticate with {displayName} +
+
+
+ +
+ + +
+
+
+
+ ); +} diff --git a/ui/src/pages/cliproxy.tsx b/ui/src/pages/cliproxy.tsx index 808d82cf..7fc098de 100644 --- a/ui/src/pages/cliproxy.tsx +++ b/ui/src/pages/cliproxy.tsx @@ -16,8 +16,8 @@ import { } from '@/components/ui/dropdown-menu'; import { Plus, Check, X, User, ChevronDown, Star, Trash2, Sparkles } from 'lucide-react'; import { CliproxyTable } from '@/components/cliproxy-table'; -import { CliproxyDialog } from '@/components/cliproxy-dialog'; import { QuickSetupWizard } from '@/components/quick-setup-wizard'; +import { AddAccountDialog } from '@/components/add-account-dialog'; import { useCliproxy, useCliproxyAuth, @@ -85,10 +85,12 @@ function ProviderRow({ status, setDefaultMutation, removeMutation, + onAddAccount, }: { status: AuthStatus; setDefaultMutation: ReturnType; removeMutation: ReturnType; + onAddAccount: () => void; }) { const accounts = status.accounts || []; @@ -148,35 +150,22 @@ function ProviderRow({
- {!status.authenticated && ( -
- ccs {status.provider} --auth -
- )} - {status.authenticated && ( - - )} + {/* Show Add Account button for all - opens dialog with instructions */} +
); } export function CliproxyPage() { - const [dialogOpen, setDialogOpen] = useState(false); const [wizardOpen, setWizardOpen] = useState(false); + const [addAccountProvider, setAddAccountProvider] = useState<{ + provider: string; + displayName: string; + } | null>(null); const { data, isLoading } = useCliproxy(); const { data: authData, isLoading: authLoading } = useCliproxyAuth(); const setDefaultMutation = useSetDefaultAccount(); @@ -191,16 +180,10 @@ export function CliproxyPage() { Manage OAuth-based provider variants and multi-account configurations

-
- - -
+ {/* Built-in Profiles with Account Management */} @@ -226,6 +209,12 @@ export function CliproxyPage() { status={status} setDefaultMutation={setDefaultMutation} removeMutation={removeMutation} + onAddAccount={() => + setAddAccountProvider({ + provider: status.provider, + displayName: status.displayName, + }) + } /> ))} @@ -252,8 +241,13 @@ export function CliproxyPage() { )} - setDialogOpen(false)} /> setWizardOpen(false)} /> + setAddAccountProvider(null)} + provider={addAccountProvider?.provider || ''} + displayName={addAccountProvider?.displayName || ''} + /> ); } From 8f6684f948b0905d0dd7b558c3d0d4023e042970 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Tue, 9 Dec 2025 17:28:41 -0500 Subject: [PATCH 10/13] feat(cliproxy): add --use and --accounts flags for multi-account switching - Add findAccountByQuery() to search accounts by nickname/email/id - Add --accounts flag to list all accounts for a provider - Add --use flag to switch between accounts - Filter CCS-specific flags from Claude CLI args - Update help documentation with new multi-account commands --- README.md | 23 ++++++++++++ src/cliproxy/account-manager.ts | 26 +++++++++++++ src/cliproxy/cliproxy-executor.ts | 62 ++++++++++++++++++++++++++++++- src/commands/cliproxy-command.ts | 32 ++++++++++++++++ src/commands/help-command.ts | 2 + 5 files changed, 143 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 290bea66..c6515a99 100644 --- a/README.md +++ b/README.md @@ -106,6 +106,29 @@ ccs agy --headless # Displays URL, paste in browser elsewhere ccs gemini --logout ``` +### Multi-Account for OAuth Providers + +Use multiple accounts per provider (work + personal): + +```bash +# First account (default) +ccs gemini --auth + +# Add another account +ccs gemini --auth --add + +# Add with nickname for easy identification +ccs gemini --auth --add --nickname work + +# List all accounts +ccs agy --accounts + +# Switch to a different account +ccs agy --use work +``` + +Accounts are stored in `~/.ccs/cliproxy/accounts.json` and can be managed via web dashboard (`ccs config`). + ### OAuth vs API Key Models | Feature | OAuth Providers
(gemini, codex, agy) | API Key Models
(glm, kimi) | diff --git a/src/cliproxy/account-manager.ts b/src/cliproxy/account-manager.ts index 5ba923bd..39413cef 100644 --- a/src/cliproxy/account-manager.ts +++ b/src/cliproxy/account-manager.ts @@ -165,6 +165,32 @@ export function getAccount(provider: CLIProxyProvider, accountId: string): Accou return accounts.find((a) => a.id === accountId) || null; } +/** + * Find account by query (nickname, email, or id) + * Supports partial matching for convenience + */ +export function findAccountByQuery(provider: CLIProxyProvider, query: string): AccountInfo | null { + const accounts = getProviderAccounts(provider); + const lowerQuery = query.toLowerCase(); + + // Exact match first (id, email, nickname) + const exactMatch = accounts.find( + (a) => + a.id === query || + a.email?.toLowerCase() === lowerQuery || + a.nickname?.toLowerCase() === lowerQuery + ); + if (exactMatch) return exactMatch; + + // Partial match on nickname or email prefix + const partialMatch = accounts.find( + (a) => + a.nickname?.toLowerCase().startsWith(lowerQuery) || + a.email?.toLowerCase().startsWith(lowerQuery) + ); + return partialMatch || null; +} + /** * Register a new account * Called after successful OAuth to record the account diff --git a/src/cliproxy/cliproxy-executor.ts b/src/cliproxy/cliproxy-executor.ts index 1f565c8e..af8536c3 100644 --- a/src/cliproxy/cliproxy-executor.ts +++ b/src/cliproxy/cliproxy-executor.ts @@ -28,6 +28,12 @@ import { isAuthenticated } from './auth-handler'; import { CLIProxyProvider, ExecutorConfig } from './types'; import { configureProviderModel, getCurrentModel } from './model-config'; import { supportsModelConfig, isModelBroken, getModelIssueUrl, findModel } from './model-catalog'; +import { + findAccountByQuery, + getProviderAccounts, + setDefaultAccount, + touchAccount, +} from './account-manager'; /** Default executor configuration */ const DEFAULT_CONFIG: ExecutorConfig = { @@ -124,6 +130,52 @@ export async function execClaudeWithCLIProxy( const forceLogout = args.includes('--logout'); const forceConfig = args.includes('--config'); const addAccount = args.includes('--add'); + const showAccounts = args.includes('--accounts'); + + // Parse --use flag + let useAccount: string | undefined; + const useIdx = args.indexOf('--use'); + if (useIdx !== -1 && args[useIdx + 1] && !args[useIdx + 1].startsWith('-')) { + useAccount = args[useIdx + 1]; + } + + // Handle --accounts: list accounts and exit + if (showAccounts) { + const accounts = getProviderAccounts(provider); + if (accounts.length === 0) { + console.log(`[i] No accounts registered for ${providerConfig.displayName}`); + console.log(` Run "ccs ${provider} --auth" to add an account`); + } else { + console.log(`\n${providerConfig.displayName} Accounts:\n`); + for (const acct of accounts) { + const defaultMark = acct.isDefault ? ' (default)' : ''; + const nickname = acct.nickname ? `[${acct.nickname}]` : ''; + console.log(` ${nickname.padEnd(12)} ${acct.email || acct.id}${defaultMark}`); + } + console.log(`\n Use "ccs ${provider} --use " to switch accounts`); + } + process.exit(0); + } + + // Handle --use: switch to specified account + if (useAccount) { + const account = findAccountByQuery(provider, useAccount); + if (!account) { + console.error(`[X] Account not found: "${useAccount}"`); + const accounts = getProviderAccounts(provider); + if (accounts.length > 0) { + console.error(` Available accounts:`); + for (const acct of accounts) { + console.error(` - ${acct.nickname || acct.id} (${acct.email || 'no email'})`); + } + } + process.exit(1); + } + // Set as default for this and future sessions + setDefaultAccount(provider, account.id); + touchAccount(provider, account.id); + console.log(`[OK] Switched to account: ${account.nickname || account.email || account.id}`); + } // Handle --config: configure model selection and exit // Pass customSettingsPath for CLIProxy variants to save to correct file @@ -262,8 +314,14 @@ export async function execClaudeWithCLIProxy( log(`Claude env: ANTHROPIC_MODEL=${envVars.ANTHROPIC_MODEL}`); // Filter out CCS-specific flags before passing to Claude CLI - const ccsFlags = ['--auth', '--headless', '--logout', '--config', '--add']; - const claudeArgs = args.filter((arg) => !ccsFlags.includes(arg)); + const ccsFlags = ['--auth', '--headless', '--logout', '--config', '--add', '--accounts', '--use']; + const claudeArgs = args.filter((arg, idx) => { + // Filter out CCS flags + if (ccsFlags.includes(arg)) return false; + // Filter out value after --use + if (args[idx - 1] === '--use') return false; + return true; + }); const isWindows = process.platform === 'win32'; const needsShell = isWindows && /\.(cmd|bat|ps1)$/i.test(claudeCli); diff --git a/src/commands/cliproxy-command.ts b/src/commands/cliproxy-command.ts index 31845543..76f2d2b7 100644 --- a/src/commands/cliproxy-command.ts +++ b/src/commands/cliproxy-command.ts @@ -656,6 +656,21 @@ async function showHelp(): Promise { } console.log(''); + // Multi-Account Commands + console.log(subheader('Multi-Account Commands:')); + const multiAcctCmds: [string, string][] = [ + ['--auth', 'Authenticate with a provider (first account)'], + ['--auth --add', 'Add another account to a provider'], + ['--nickname ', 'Set friendly name for account'], + ['--accounts', 'List all accounts for a provider'], + ['--use ', 'Switch to account by nickname/email'], + ]; + const maxMultiLen = Math.max(...multiAcctCmds.map(([cmd]) => cmd.length)); + for (const [cmd, desc] of multiAcctCmds) { + console.log(` ${color(cmd.padEnd(maxMultiLen + 2), 'command')} ${desc}`); + } + console.log(''); + // Create Options console.log(subheader('Create Options:')); const createOpts: [string, string][] = [ @@ -690,6 +705,23 @@ async function showHelp(): Promise { ` $ ${color('ccs cliproxy --latest', 'command')} ${dim('# Update binary')}` ); console.log(''); + console.log(subheader('Multi-Account Examples:')); + console.log( + ` $ ${color('ccs gemini --auth', 'command')} ${dim('# First account')}` + ); + console.log( + ` $ ${color('ccs gemini --auth --add', 'command')} ${dim('# Add second account')}` + ); + console.log( + ` $ ${color('ccs gemini --auth --add --nickname work', 'command')} ${dim('# With nickname')}` + ); + console.log( + ` $ ${color('ccs agy --accounts', 'command')} ${dim('# List accounts')}` + ); + console.log( + ` $ ${color('ccs agy --use work', 'command')} ${dim('# Switch account')}` + ); + console.log(''); // Notes console.log(subheader('Notes:')); diff --git a/src/commands/help-command.ts b/src/commands/help-command.ts index 9a266347..23c2af18 100644 --- a/src/commands/help-command.ts +++ b/src/commands/help-command.ts @@ -162,6 +162,8 @@ Claude Code Profile & Model Switcher`.trim(); ['', ''], // Spacer ['ccs --auth', 'Authenticate only'], ['ccs --auth --add', 'Add another account'], + ['ccs --accounts', 'List all accounts'], + ['ccs --use ', 'Switch to account'], ['ccs --config', 'Change model (agy, gemini)'], ['ccs --logout', 'Clear authentication'], ['ccs --headless', 'Headless auth (for SSH)'], From 70d54b9fe1d3087ee84d3494985cd30edaa37d54 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 9 Dec 2025 22:42:34 +0000 Subject: [PATCH 11/13] chore(release): 5.12.1-dev.3 [skip ci] --- VERSION | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/VERSION b/VERSION index 987c7d8b..71ca79ae 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -5.12.1-dev.2 +5.12.1-dev.3 diff --git a/package.json b/package.json index 8c7e73f8..59586e50 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "5.12.1-dev.2", + "version": "5.12.1-dev.3", "description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6", "keywords": [ "cli", From 8095a4bc07c4019877cdd68e5a375521ec9112de Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Tue, 9 Dec 2025 17:47:33 -0500 Subject: [PATCH 12/13] ci: optimize build pipeline to eliminate redundant builds - validate now uses test:all directly (build already done) - Added test:ci script for explicit CI usage - ci.yml now builds UI deps and uses build:all - dev-release.yml separates build and validate steps - Reduces build count from 3-4 to 1 per release cycle --- .github/workflows/ci.yml | 6 ++++-- .github/workflows/dev-release.yml | 9 +++++---- package.json | 3 ++- 3 files changed, 11 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eb61958a..3b8ae6ae 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,10 +23,12 @@ jobs: node-version: '22' - name: Install dependencies - run: bun install --frozen-lockfile + run: | + bun install --frozen-lockfile + cd ui && bun install --frozen-lockfile - name: Build package - run: bun run build + run: bun run build:all - name: Validate (typecheck + lint + tests) run: bun run validate diff --git a/.github/workflows/dev-release.yml b/.github/workflows/dev-release.yml index ac177753..b1967ad1 100644 --- a/.github/workflows/dev-release.yml +++ b/.github/workflows/dev-release.yml @@ -36,10 +36,11 @@ jobs: bun install --frozen-lockfile cd ui && bun install --frozen-lockfile - - name: Build and validate - run: | - bun run build:all - bun run validate + - name: Build + run: bun run build:all + + - name: Validate (typecheck + lint + tests) + run: bun run validate - name: Bump dev version id: bump diff --git a/package.json b/package.json index 59586e50..6930b94c 100644 --- a/package.json +++ b/package.json @@ -62,9 +62,10 @@ "lint:fix": "eslint src/ --fix", "format": "prettier --write src/", "format:check": "prettier --check src/", - "validate": "bun run typecheck && bun run lint:fix && bun run format:check && bun run test", + "validate": "bun run typecheck && bun run lint:fix && bun run format:check && bun run test:all", "verify:bundle": "node scripts/verify-bundle.js", "test": "bun run build && bun run test:all", + "test:ci": "bun run test:all", "test:all": "bun test", "test:unit": "bun test tests/unit/", "test:npm": "bun test tests/npm/", From 277836fd4df9e2d0ec97c5e2852e8b93247038f2 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 9 Dec 2025 22:49:54 +0000 Subject: [PATCH 13/13] chore(release): 5.12.1-dev.4 [skip ci] --- VERSION | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/VERSION b/VERSION index 71ca79ae..02007089 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -5.12.1-dev.3 +5.12.1-dev.4 diff --git a/package.json b/package.json index 6930b94c..3615376b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "5.12.1-dev.3", + "version": "5.12.1-dev.4", "description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6", "keywords": [ "cli",