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
2025-12-09 16:14:28 -05:00
parent 1e11d2e40a
commit 493492fa7e
4 changed files with 79 additions and 7 deletions
+41 -2
View File
@@ -19,6 +19,8 @@ export interface AccountInfo {
id: string;
/** Email address from OAuth (if available) */
email?: string;
/** User-friendly nickname for quick reference (auto-generated from email prefix) */
nickname?: string;
/** Provider this account belongs to */
provider: CLIProxyProvider;
/** Whether this is the default account for the provider */
@@ -53,6 +55,36 @@ const DEFAULT_REGISTRY: AccountsRegistry = {
providers: {},
};
/**
* Generate nickname from email
* Takes prefix before @ symbol, sanitizes whitespace
* Validation: 1-50 chars, any non-whitespace (permissive per user preference)
*/
export function generateNickname(email?: string): string {
if (!email) return 'default';
const prefix = email.split('@')[0];
// Sanitize: remove whitespace, limit to 50 chars
return prefix.replace(/\s+/g, '').slice(0, 50) || 'default';
}
/**
* Validate nickname
* Rules: 1-50 chars, any non-whitespace allowed (permissive)
* @returns null if valid, error message if invalid
*/
export function validateNickname(nickname: string): string | null {
if (!nickname || nickname.length === 0) {
return 'Nickname is required';
}
if (nickname.length > 50) {
return 'Nickname must be 50 characters or less';
}
if (/\s/.test(nickname)) {
return 'Nickname cannot contain whitespace';
}
return null;
}
/**
* Get path to accounts registry file
*/
@@ -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(),
+34 -4
View File
@@ -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<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();
@@ -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
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;
}
+3 -1
View File
@@ -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';
+1
View File
@@ -161,6 +161,7 @@ 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> --config', 'Change model (agy, gemini)'],
['ccs <provider> --logout', 'Clear authentication'],
['ccs <provider> --headless', 'Headless auth (for SSH)'],