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 (
+
{/* 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 (
+
+ );
+}
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",