mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-18 22:16:52 +00:00
Merge pull request #70 from kaitranntt/dev
feat: CLIProxy multi-account support and analytics improvements
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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
@@ -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/",
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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:'));
|
||||
|
||||
@@ -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
@@ -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)
|
||||
*/
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user