diff --git a/src/cliproxy/account-manager.ts b/src/cliproxy/account-manager.ts index e2ceffa9..5ba923bd 100644 --- a/src/cliproxy/account-manager.ts +++ b/src/cliproxy/account-manager.ts @@ -19,6 +19,8 @@ export interface AccountInfo { id: string; /** Email address from OAuth (if available) */ email?: string; + /** User-friendly nickname for quick reference (auto-generated from email prefix) */ + nickname?: string; /** Provider this account belongs to */ provider: CLIProxyProvider; /** Whether this is the default account for the provider */ @@ -53,6 +55,36 @@ const DEFAULT_REGISTRY: AccountsRegistry = { providers: {}, }; +/** + * Generate nickname from email + * Takes prefix before @ symbol, sanitizes whitespace + * Validation: 1-50 chars, any non-whitespace (permissive per user preference) + */ +export function generateNickname(email?: string): string { + if (!email) return 'default'; + const prefix = email.split('@')[0]; + // Sanitize: remove whitespace, limit to 50 chars + return prefix.replace(/\s+/g, '').slice(0, 50) || 'default'; +} + +/** + * Validate nickname + * Rules: 1-50 chars, any non-whitespace allowed (permissive) + * @returns null if valid, error message if invalid + */ +export function validateNickname(nickname: string): string | null { + if (!nickname || nickname.length === 0) { + return 'Nickname is required'; + } + if (nickname.length > 50) { + return 'Nickname must be 50 characters or less'; + } + if (/\s/.test(nickname)) { + return 'Nickname cannot contain whitespace'; + } + return null; +} + /** * Get path to accounts registry file */ @@ -140,7 +172,8 @@ export function getAccount(provider: CLIProxyProvider, accountId: string): Accou export function registerAccount( provider: CLIProxyProvider, tokenFile: string, - email?: string + email?: string, + nickname?: string ): AccountInfo { const registry = loadAccountsRegistry(); @@ -161,9 +194,13 @@ export function registerAccount( const accountId = email || 'default'; const isFirstAccount = Object.keys(providerAccounts.accounts).length === 0; + // Generate nickname if not provided + const accountNickname = nickname || generateNickname(email); + // Create or update account providerAccounts.accounts[accountId] = { email, + nickname: accountNickname, tokenFile, createdAt: new Date().toISOString(), lastUsedAt: new Date().toISOString(), @@ -181,6 +218,7 @@ export function registerAccount( provider, isDefault: accountId === providerAccounts.default, email, + nickname: accountNickname, tokenFile, createdAt: providerAccounts.accounts[accountId].createdAt, lastUsedAt: providerAccounts.accounts[accountId].lastUsedAt, @@ -333,9 +371,10 @@ export function discoverExistingAccounts(): void { // Get file stats for creation time const stats = fs.statSync(filePath); - // Register account + // Register account with auto-generated nickname providerAccounts.accounts[accountId] = { email, + nickname: generateNickname(email), tokenFile: file, createdAt: stats.birthtime?.toISOString() || new Date().toISOString(), lastUsedAt: stats.mtime?.toISOString(), diff --git a/src/cliproxy/auth-handler.ts b/src/cliproxy/auth-handler.ts index 23b92fd2..de355dd0 100644 --- a/src/cliproxy/auth-handler.ts +++ b/src/cliproxy/auth-handler.ts @@ -22,6 +22,7 @@ import { CLIProxyProvider } from './types'; import { AccountInfo, discoverExistingAccounts, + generateNickname, getDefaultAccount, getProviderAccounts, registerAccount, @@ -429,14 +430,15 @@ export function clearAuth(provider: CLIProxyProvider): boolean { * Auto-detects headless environment and uses --no-browser flag accordingly * @param provider - The CLIProxy provider to authenticate * @param options - OAuth options + * @param options.add - If true, skip confirm prompt when adding another account * @returns Account info if successful, null otherwise */ export async function triggerOAuth( provider: CLIProxyProvider, - options: { verbose?: boolean; headless?: boolean; account?: string } = {} + options: { verbose?: boolean; headless?: boolean; account?: string; add?: boolean } = {} ): Promise { const oauthConfig = getOAuthConfig(provider); - const { verbose = false } = options; + const { verbose = false, add = false } = options; // Auto-detect headless if not explicitly set const headless = options.headless ?? isHeadlessEnvironment(); @@ -447,6 +449,34 @@ export async function triggerOAuth( } }; + // Check for existing accounts and prompt if --add not specified + const existingAccounts = getProviderAccounts(provider); + if (existingAccounts.length > 0 && !add) { + console.log(''); + console.log( + `[i] ${existingAccounts.length} account(s) already authenticated for ${oauthConfig.displayName}` + ); + + // Import readline for confirm prompt + const readline = await import('readline'); + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + + const confirmed = await new Promise((resolve) => { + rl.question('[?] Add another account? (y/N): ', (answer) => { + rl.close(); + resolve(answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes'); + }); + }); + + if (!confirmed) { + console.log('[i] Cancelled'); + return null; + } + } + // Pre-flight check: verify OAuth callback port is available const preflight = await preflightOAuthCheck(provider); if (!preflight.ready) { @@ -662,8 +692,8 @@ function registerAccountFromToken( const data = JSON.parse(content); const email = data.email || undefined; - // Register the account - return registerAccount(provider, newestFile, email); + // Register the account with auto-generated nickname + return registerAccount(provider, newestFile, email, generateNickname(email)); } catch { return null; } diff --git a/src/cliproxy/cliproxy-executor.ts b/src/cliproxy/cliproxy-executor.ts index 7977f828..1f565c8e 100644 --- a/src/cliproxy/cliproxy-executor.ts +++ b/src/cliproxy/cliproxy-executor.ts @@ -123,6 +123,7 @@ export async function execClaudeWithCLIProxy( const forceHeadless = args.includes('--headless'); const forceLogout = args.includes('--logout'); const forceConfig = args.includes('--config'); + const addAccount = args.includes('--add'); // Handle --config: configure model selection and exit // Pass customSettingsPath for CLIProxy variants to save to correct file @@ -151,6 +152,7 @@ export async function execClaudeWithCLIProxy( const { triggerOAuth } = await import('./auth-handler'); const authSuccess = await triggerOAuth(provider, { verbose, + add: addAccount, ...(forceHeadless ? { headless: true } : {}), }); if (!authSuccess) { @@ -260,7 +262,7 @@ export async function execClaudeWithCLIProxy( log(`Claude env: ANTHROPIC_MODEL=${envVars.ANTHROPIC_MODEL}`); // Filter out CCS-specific flags before passing to Claude CLI - const ccsFlags = ['--auth', '--headless', '--logout', '--config']; + const ccsFlags = ['--auth', '--headless', '--logout', '--config', '--add']; const claudeArgs = args.filter((arg) => !ccsFlags.includes(arg)); const isWindows = process.platform === 'win32'; diff --git a/src/commands/help-command.ts b/src/commands/help-command.ts index d2663ffc..9a266347 100644 --- a/src/commands/help-command.ts +++ b/src/commands/help-command.ts @@ -161,6 +161,7 @@ Claude Code Profile & Model Switcher`.trim(); ['ccs qwen', 'Qwen Code (qwen3-coder)'], ['', ''], // Spacer ['ccs --auth', 'Authenticate only'], + ['ccs --auth --add', 'Add another account'], ['ccs --config', 'Change model (agy, gemini)'], ['ccs --logout', 'Clear authentication'], ['ccs --headless', 'Headless auth (for SSH)'],