feat(cliproxy): add --add flag and nickname support for multi-account auth

- Add `--add` flag to skip confirm prompt when adding accounts
- Add confirm prompt when accounts exist and --add not specified
- Add nickname field to AccountInfo (auto-generated from email prefix)
- Add generateNickname() and validateNickname() utility functions
- Update triggerOAuth() to accept add option
- Update registerAccountFromToken() to pass nickname
- Update help text with --add flag documentation
This commit is contained in:
kaitranntt committed 2025-12-09 16:14:28 -05:00
1 parent 1e11d2e40a
commit 493492fa7e
4 files changed
+79 -7

No files matched your search

+41 -2
View File
@@ -19,6 +19,8 @@ export interface AccountInfo {
id: string; id: string;
/** Email address from OAuth (if available) */ /** Email address from OAuth (if available) */
email?: string; email?: string;
/** User-friendly nickname for quick reference (auto-generated from email prefix) */
nickname?: string;
/** Provider this account belongs to */ /** Provider this account belongs to */
provider: CLIProxyProvider; provider: CLIProxyProvider;
/** Whether this is the default account for the provider */ /** Whether this is the default account for the provider */
@@ -53,6 +55,36 @@ const DEFAULT_REGISTRY: AccountsRegistry = {
providers: {}, 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 * Get path to accounts registry file
*/ */
@@ -140,7 +172,8 @@ export function getAccount(provider: CLIProxyProvider, accountId: string): Accou
export function registerAccount( export function registerAccount(
provider: CLIProxyProvider, provider: CLIProxyProvider,
tokenFile: string, tokenFile: string,
email?: string email?: string,
nickname?: string
): AccountInfo { ): AccountInfo {
const registry = loadAccountsRegistry(); const registry = loadAccountsRegistry();
@@ -161,9 +194,13 @@ export function registerAccount(
const accountId = email || 'default'; const accountId = email || 'default';
const isFirstAccount = Object.keys(providerAccounts.accounts).length === 0; const isFirstAccount = Object.keys(providerAccounts.accounts).length === 0;
// Generate nickname if not provided
const accountNickname = nickname || generateNickname(email);
// Create or update account // Create or update account
providerAccounts.accounts[accountId] = { providerAccounts.accounts[accountId] = {
email, email,
nickname: accountNickname,
tokenFile, tokenFile,
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
lastUsedAt: new Date().toISOString(), lastUsedAt: new Date().toISOString(),
@@ -181,6 +218,7 @@ export function registerAccount(
provider, provider,
isDefault: accountId === providerAccounts.default, isDefault: accountId === providerAccounts.default,
email, email,
nickname: accountNickname,
tokenFile, tokenFile,
createdAt: providerAccounts.accounts[accountId].createdAt, createdAt: providerAccounts.accounts[accountId].createdAt,
lastUsedAt: providerAccounts.accounts[accountId].lastUsedAt, lastUsedAt: providerAccounts.accounts[accountId].lastUsedAt,
@@ -333,9 +371,10 @@ export function discoverExistingAccounts(): void {
// Get file stats for creation time // Get file stats for creation time
const stats = fs.statSync(filePath); const stats = fs.statSync(filePath);
// Register account // Register account with auto-generated nickname
providerAccounts.accounts[accountId] = { providerAccounts.accounts[accountId] = {
email, email,
nickname: generateNickname(email),
tokenFile: file, tokenFile: file,
createdAt: stats.birthtime?.toISOString() || new Date().toISOString(), createdAt: stats.birthtime?.toISOString() || new Date().toISOString(),
lastUsedAt: stats.mtime?.toISOString(), lastUsedAt: stats.mtime?.toISOString(),
+34 -4
View File
@@ -22,6 +22,7 @@ import { CLIProxyProvider } from './types';
import { import {
AccountInfo, AccountInfo,
discoverExistingAccounts, discoverExistingAccounts,
generateNickname,
getDefaultAccount, getDefaultAccount,
getProviderAccounts, getProviderAccounts,
registerAccount, registerAccount,
@@ -429,14 +430,15 @@ export function clearAuth(provider: CLIProxyProvider): boolean {
* Auto-detects headless environment and uses --no-browser flag accordingly * Auto-detects headless environment and uses --no-browser flag accordingly
* @param provider - The CLIProxy provider to authenticate * @param provider - The CLIProxy provider to authenticate
* @param options - OAuth options * @param options - OAuth options
* @param options.add - If true, skip confirm prompt when adding another account
* @returns Account info if successful, null otherwise * @returns Account info if successful, null otherwise
*/ */
export async function triggerOAuth( export async function triggerOAuth(
provider: CLIProxyProvider, provider: CLIProxyProvider,
options: { verbose?: boolean; headless?: boolean; account?: string } = {} options: { verbose?: boolean; headless?: boolean; account?: string; add?: boolean } = {}
): Promise<AccountInfo | null> { ): Promise<AccountInfo | null> {
const oauthConfig = getOAuthConfig(provider); const oauthConfig = getOAuthConfig(provider);
const { verbose = false } = options; const { verbose = false, add = false } = options;
// Auto-detect headless if not explicitly set // Auto-detect headless if not explicitly set
const headless = options.headless ?? isHeadlessEnvironment(); 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<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 // Pre-flight check: verify OAuth callback port is available
const preflight = await preflightOAuthCheck(provider); const preflight = await preflightOAuthCheck(provider);
if (!preflight.ready) { if (!preflight.ready) {
@@ -662,8 +692,8 @@ function registerAccountFromToken(
const data = JSON.parse(content); const data = JSON.parse(content);
const email = data.email || undefined; const email = data.email || undefined;
// Register the account // Register the account with auto-generated nickname
return registerAccount(provider, newestFile, email); return registerAccount(provider, newestFile, email, generateNickname(email));
} catch { } catch {
return null; return null;
} }
+3 -1
View File
@@ -123,6 +123,7 @@ export async function execClaudeWithCLIProxy(
const forceHeadless = args.includes('--headless'); const forceHeadless = args.includes('--headless');
const forceLogout = args.includes('--logout'); const forceLogout = args.includes('--logout');
const forceConfig = args.includes('--config'); const forceConfig = args.includes('--config');
const addAccount = args.includes('--add');
// Handle --config: configure model selection and exit // Handle --config: configure model selection and exit
// Pass customSettingsPath for CLIProxy variants to save to correct file // 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 { triggerOAuth } = await import('./auth-handler');
const authSuccess = await triggerOAuth(provider, { const authSuccess = await triggerOAuth(provider, {
verbose, verbose,
add: addAccount,
...(forceHeadless ? { headless: true } : {}), ...(forceHeadless ? { headless: true } : {}),
}); });
if (!authSuccess) { if (!authSuccess) {
@@ -260,7 +262,7 @@ export async function execClaudeWithCLIProxy(
log(`Claude env: ANTHROPIC_MODEL=${envVars.ANTHROPIC_MODEL}`); log(`Claude env: ANTHROPIC_MODEL=${envVars.ANTHROPIC_MODEL}`);
// Filter out CCS-specific flags before passing to Claude CLI // 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 claudeArgs = args.filter((arg) => !ccsFlags.includes(arg));
const isWindows = process.platform === 'win32'; const isWindows = process.platform === 'win32';
+1
View File
@@ -161,6 +161,7 @@ Claude Code Profile & Model Switcher`.trim();
['ccs qwen', 'Qwen Code (qwen3-coder)'], ['ccs qwen', 'Qwen Code (qwen3-coder)'],
['', ''], // Spacer ['', ''], // Spacer
['ccs <provider> --auth', 'Authenticate only'], ['ccs <provider> --auth', 'Authenticate only'],
['ccs <provider> --auth --add', 'Add another account'],
['ccs <provider> --config', 'Change model (agy, gemini)'], ['ccs <provider> --config', 'Change model (agy, gemini)'],
['ccs <provider> --logout', 'Clear authentication'], ['ccs <provider> --logout', 'Clear authentication'],
['ccs <provider> --headless', 'Headless auth (for SSH)'], ['ccs <provider> --headless', 'Headless auth (for SSH)'],