Merge pull request #70 from kaitranntt/dev

feat: CLIProxy multi-account support and analytics improvements
This commit is contained in:
Kai (Tam Nhu) Tran
2025-12-09 18:01:34 -05:00
committed by GitHub
22 changed files with 1870 additions and 113 deletions
+4 -2
View File
@@ -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
+5 -4
View File
@@ -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
+23
View File
@@ -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<br>(gemini, codex, agy) | API Key Models<br>(glm, kimi) |
+1 -1
View File
@@ -1 +1 @@
5.12.1
5.12.1-dev.4
+2 -1
View File
@@ -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
+3 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@kaitranntt/ccs",
"version": "5.12.1",
"version": "5.12.1-dev.4",
"description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6",
"keywords": [
"cli",
@@ -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/",
+67 -2
View File
@@ -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
*/
@@ -133,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
@@ -140,7 +198,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 +220,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 +244,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 +397,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(),
+79 -11
View File
@@ -22,11 +22,13 @@ import { CLIProxyProvider } from './types';
import {
AccountInfo,
discoverExistingAccounts,
generateNickname,
getDefaultAccount,
getProviderAccounts,
registerAccount,
touchAccount,
} from './account-manager';
import { preflightOAuthCheck } from '../management/oauth-port-diagnostics';
/**
* OAuth callback ports used by CLIProxyAPI (hardcoded in binary)
@@ -90,26 +92,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;
}
/**
@@ -404,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<AccountInfo | null> {
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();
@@ -422,6 +449,47 @@ 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<boolean>((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) {
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 {
@@ -624,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;
}
+62 -2
View File
@@ -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 = {
@@ -123,6 +129,53 @@ export async function execClaudeWithCLIProxy(
const forceHeadless = args.includes('--headless');
const forceLogout = args.includes('--logout');
const forceConfig = args.includes('--config');
const addAccount = args.includes('--add');
const showAccounts = args.includes('--accounts');
// Parse --use <account> 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 <nickname>" 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
@@ -151,6 +204,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,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'];
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);
+149 -6
View File
@@ -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<void> {
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<void> {
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<void> {
`Variant: ${name}\n` +
`Provider: ${provider}\n` +
`Model: ${model}\n` +
(account ? `Account: ${account}\n` : '') +
`Settings: ~/.ccs/${path.basename(settingsPath)}`,
'CLIProxy Variant Created'
)
@@ -546,11 +656,27 @@ async function showHelp(): Promise<void> {
}
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 <name>', 'Set friendly name for account'],
['--accounts', 'List all accounts for a provider'],
['--use <name>', '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][] = [
['--provider <name>', 'Provider (gemini, codex, agy, qwen)'],
['--model <model>', 'Model name'],
['--account <id>', 'Account ID (email or default)'],
['--force', 'Overwrite existing variant'],
['--yes, -y', 'Skip confirmation prompts'],
];
@@ -579,6 +705,23 @@ async function showHelp(): Promise<void> {
` $ ${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:'));
+3
View File
@@ -161,6 +161,9 @@ Claude Code Profile & Model Switcher`.trim();
['ccs qwen', 'Qwen Code (qwen3-coder)'],
['', ''], // Spacer
['ccs <provider> --auth', 'Authenticate only'],
['ccs <provider> --auth --add', 'Add another account'],
['ccs <provider> --accounts', 'List all accounts'],
['ccs <provider> --use <name>', 'Switch to account'],
['ccs <provider> --config', 'Change model (agy, gemini)'],
['ccs <provider> --logout', 'Clear authentication'],
['ccs <provider> --headless', 'Headless auth (for SSH)'],
+127 -4
View File
@@ -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<void> {
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)
*/
+310
View File
@@ -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<string, string> = {
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,
};
+236
View File
@@ -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<CLIProxyProvider, number | null> = {
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<CLIProxyProvider, OAuthFlowType> = {
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<OAuthPortDiagnostic> {
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<OAuthPortDiagnostic[]> {
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<OAuthPortDiagnostic[]> {
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<OAuthPortDiagnostic[]> {
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,
};
+74 -5
View File
@@ -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<HealthReport> {
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<HealthReport> {
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<HealthReport> {
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<HealthReport> {
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<HealthReport> {
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<HealthCheck> {
};
}
// 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<HealthCheck[]> {
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
*/
+208 -2
View File
@@ -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<DailyUsage[]>,
loadMonthlyUsageData() as Promise<MonthlyUsage[]>,
loadSessionData() as Promise<SessionUsage[]>,
]);
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<string, DailyUsage>();
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<string, MonthlyUsage>();
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<string, SessionUsage>();
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<DailyUsage[]>,
loadMonthlyUsageData() as Promise<MonthlyUsage[]>,
loadSessionData() as Promise<SessionUsage[]>,
]);
]).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();
+96
View File
@@ -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 (
<Dialog open={open} onOpenChange={onClose}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Add {displayName} Account</DialogTitle>
<DialogDescription>
Run the command below in your terminal to authenticate a new account
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<Card>
<CardContent className="p-4 space-y-3">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Terminal className="w-4 h-4" />
Run this command:
</div>
<div className="flex items-center gap-2">
<code className="flex-1 px-3 py-2 bg-muted rounded-md font-mono text-sm">
{authCommand}
</code>
<Button variant="outline" size="icon" onClick={copyCommand}>
{copied ? (
<Check className="w-4 h-4 text-green-500" />
) : (
<Copy className="w-4 h-4" />
)}
</Button>
</div>
<div className="text-xs text-muted-foreground">
This will open your browser to authenticate with {displayName}
</div>
</CardContent>
</Card>
<div className="flex items-center justify-end gap-2">
<Button variant="ghost" onClick={onClose}>
Cancel
</Button>
<Button onClick={handleRefresh} disabled={isRefreshing}>
<RefreshCw className={`w-4 h-4 mr-2 ${isRefreshing ? 'animate-spin' : ''}`} />
{isRefreshing ? 'Checking...' : 'I ran the command'}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}
@@ -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]);
+359
View File
@@ -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<WizardStep>('provider');
const [selectedProvider, setSelectedProvider] = useState<string>('');
const [selectedAccount, setSelectedAccount] = useState<OAuthAccount | null>(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 (
<Dialog open={open} onOpenChange={onClose}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Sparkles className="w-5 h-5 text-primary" />
Quick Setup Wizard
</DialogTitle>
<DialogDescription>
{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!'}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
{/* Step: Provider Selection */}
{step === 'provider' && (
<div className="grid gap-2">
{providers.map((p) => (
<button
key={p.id}
onClick={() => handleProviderSelect(p.id)}
className="flex items-center justify-between p-3 border rounded-lg hover:bg-muted/50 transition-colors text-left"
>
<div>
<div className="font-medium">{p.name}</div>
<div className="text-xs text-muted-foreground">{p.description}</div>
</div>
<ChevronRight className="w-4 h-4 text-muted-foreground" />
</button>
))}
</div>
)}
{/* Step: Authentication */}
{step === 'auth' && (
<div className="space-y-4">
<Card>
<CardContent className="p-4 space-y-3">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Terminal className="w-4 h-4" />
Run this command in your terminal:
</div>
<div className="flex items-center gap-2">
<code className="flex-1 px-3 py-2 bg-muted rounded-md font-mono text-sm">
{authCommand}
</code>
<Button variant="outline" size="icon" onClick={() => copyCommand(authCommand)}>
{copied ? (
<Check className="w-4 h-4 text-green-500" />
) : (
<Copy className="w-4 h-4" />
)}
</Button>
</div>
<div className="text-xs text-muted-foreground">
This will open your browser to authenticate with{' '}
{providers.find((p) => p.id === selectedProvider)?.name}
</div>
</CardContent>
</Card>
<div className="flex items-center justify-between">
<Button variant="ghost" onClick={() => setStep('provider')}>
<ArrowLeft className="w-4 h-4 mr-2" />
Back
</Button>
<Button onClick={handleRefresh} disabled={isRefreshing}>
<RefreshCw className={`w-4 h-4 mr-2 ${isRefreshing ? 'animate-spin' : ''}`} />
{isRefreshing ? 'Checking...' : 'I ran the command'}
</Button>
</div>
</div>
)}
{/* Step: Account Selection */}
{step === 'account' && (
<div className="space-y-4">
<div className="grid gap-2">
{accounts.map((acc: OAuthAccount) => (
<button
key={acc.id}
onClick={() => handleAccountSelect(acc)}
className="flex items-center justify-between p-3 border rounded-lg hover:bg-muted/50 transition-colors text-left"
>
<div className="flex items-center gap-3">
<User className="w-5 h-5 text-muted-foreground" />
<div>
<div className="font-medium">{acc.email || acc.id}</div>
{acc.isDefault && (
<div className="text-xs text-muted-foreground">Default account</div>
)}
</div>
</div>
<ChevronRight className="w-4 h-4 text-muted-foreground" />
</button>
))}
</div>
<div className="flex items-center justify-between">
<Button variant="ghost" onClick={() => setStep('auth')}>
<ArrowLeft className="w-4 h-4 mr-2" />
Add different account
</Button>
</div>
</div>
)}
{/* Step: Create Variant */}
{step === 'variant' && (
<div className="space-y-4">
{selectedAccount && (
<div className="flex items-center gap-2 p-2 bg-muted/50 rounded-md text-sm">
<User className="w-4 h-4" />
<span>Using: {selectedAccount.email || selectedAccount.id}</span>
</div>
)}
<div className="space-y-2">
<Label htmlFor="variant-name">Variant Name *</Label>
<Input
id="variant-name"
value={variantName}
onChange={(e) => setVariantName(e.target.value)}
placeholder="e.g., my-gemini, g3, flash"
/>
<div className="text-xs text-muted-foreground">
Use this name to invoke: ccs {variantName || '<name>'} "prompt"
</div>
</div>
<div className="space-y-2">
<Label htmlFor="model-name">Model (optional)</Label>
<Input
id="model-name"
value={modelName}
onChange={(e) => setModelName(e.target.value)}
placeholder="e.g., gemini-2.5-pro"
/>
</div>
<div className="flex items-center justify-between pt-2">
<Button
variant="ghost"
onClick={() => (accounts.length > 1 ? setStep('account') : setStep('auth'))}
>
<ArrowLeft className="w-4 h-4 mr-2" />
Back
</Button>
<Button
onClick={handleCreateVariant}
disabled={!variantName || createMutation.isPending}
>
{createMutation.isPending ? 'Creating...' : 'Create Variant'}
</Button>
</div>
</div>
)}
{/* Step: Success */}
{step === 'success' && (
<div className="space-y-4 text-center">
<div className="flex justify-center">
<div className="w-16 h-16 bg-green-100 dark:bg-green-900/30 rounded-full flex items-center justify-center">
<Check className="w-8 h-8 text-green-600 dark:text-green-400" />
</div>
</div>
<div>
<div className="font-semibold text-lg">Variant Created!</div>
<div className="text-sm text-muted-foreground">
Your custom variant is ready to use
</div>
</div>
<Card>
<CardContent className="p-4 space-y-2">
<div className="text-sm text-muted-foreground">Usage:</div>
<code className="block px-3 py-2 bg-muted rounded-md font-mono text-sm">
ccs {variantName} "your prompt here"
</code>
</CardContent>
</Card>
<Button onClick={onClose} className="w-full">
Done
</Button>
</div>
)}
</div>
{/* Progress indicator */}
<div className="flex justify-center gap-1 pt-2">
{allSteps.map((s, i) => (
<div
key={s}
className={`w-2 h-2 rounded-full transition-colors ${
currentProgress >= i ? 'bg-primary' : 'bg-muted'
}`}
/>
))}
</div>
</DialogContent>
</Dialog>
);
}
+26
View File
@@ -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];
}
+2 -27
View File
@@ -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) }}
/>
<span
className="font-medium truncate max-w-[150px]"
title={model.model}
>
<span className="font-medium" title={model.model}>
{model.model}
</span>
</div>
@@ -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 (
<div className="space-y-4 h-full overflow-hidden">
+31 -28
View File
@@ -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 { AddAccountDialog } from '@/components/add-account-dialog';
import {
useCliproxy,
useCliproxyAuth,
@@ -84,10 +85,12 @@ function ProviderRow({
status,
setDefaultMutation,
removeMutation,
onAddAccount,
}: {
status: AuthStatus;
setDefaultMutation: ReturnType<typeof useSetDefaultAccount>;
removeMutation: ReturnType<typeof useRemoveAccount>;
onAddAccount: () => void;
}) {
const accounts = status.accounts || [];
@@ -147,34 +150,22 @@ function ProviderRow({
</div>
<div className="flex items-center gap-2">
{!status.authenticated && (
<div className="text-xs font-mono bg-muted px-2 py-1 rounded text-muted-foreground select-all">
ccs {status.provider} --auth
</div>
)}
{status.authenticated && (
<Button
variant="outline"
size="sm"
className="h-7 text-xs gap-1"
onClick={() => {
// This is a placeholder since we can't actually run the auth command from UI easily without a terminal
// But we can show the command to run
navigator.clipboard.writeText(`ccs ${status.provider} --auth`);
}}
title="Copy auth command"
>
<Plus className="w-3 h-3" />
Add Account
</Button>
)}
{/* Show Add Account button for all - opens dialog with instructions */}
<Button variant="outline" size="sm" className="h-7 text-xs gap-1" onClick={onAddAccount}>
<Plus className="w-3 h-3" />
Add Account
</Button>
</div>
</div>
);
}
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();
@@ -189,9 +180,9 @@ export function CliproxyPage() {
Manage OAuth-based provider variants and multi-account configurations
</p>
</div>
<Button onClick={() => setDialogOpen(true)}>
<Plus className="w-4 h-4 mr-2" />
Create Variant
<Button onClick={() => setWizardOpen(true)}>
<Sparkles className="w-4 h-4 mr-2" />
Quick Setup
</Button>
</div>
@@ -218,6 +209,12 @@ export function CliproxyPage() {
status={status}
setDefaultMutation={setDefaultMutation}
removeMutation={removeMutation}
onAddAccount={() =>
setAddAccountProvider({
provider: status.provider,
displayName: status.displayName,
})
}
/>
))}
</div>
@@ -244,7 +241,13 @@ export function CliproxyPage() {
)}
</div>
<CliproxyDialog open={dialogOpen} onClose={() => setDialogOpen(false)} />
<QuickSetupWizard open={wizardOpen} onClose={() => setWizardOpen(false)} />
<AddAccountDialog
open={addAccountProvider !== null}
onClose={() => setAddAccountProvider(null)}
provider={addAccountProvider?.provider || ''}
displayName={addAccountProvider?.displayName || ''}
/>
</div>
);
}