Skip to content

Commit 493492f

Browse files
committed
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
1 parent 1e11d2e commit 493492f

File tree

4 files changed

+79
-7
lines changed

4 files changed

+79
-7
lines changed

src/cliproxy/account-manager.ts

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ export interface AccountInfo {
1919
id: string;
2020
/** Email address from OAuth (if available) */
2121
email?: string;
22+
/** User-friendly nickname for quick reference (auto-generated from email prefix) */
23+
nickname?: string;
2224
/** Provider this account belongs to */
2325
provider: CLIProxyProvider;
2426
/** Whether this is the default account for the provider */
@@ -53,6 +55,36 @@ const DEFAULT_REGISTRY: AccountsRegistry = {
5355
providers: {},
5456
};
5557

58+
/**
59+
* Generate nickname from email
60+
* Takes prefix before @ symbol, sanitizes whitespace
61+
* Validation: 1-50 chars, any non-whitespace (permissive per user preference)
62+
*/
63+
export function generateNickname(email?: string): string {
64+
if (!email) return 'default';
65+
const prefix = email.split('@')[0];
66+
// Sanitize: remove whitespace, limit to 50 chars
67+
return prefix.replace(/\s+/g, '').slice(0, 50) || 'default';
68+
}
69+
70+
/**
71+
* Validate nickname
72+
* Rules: 1-50 chars, any non-whitespace allowed (permissive)
73+
* @returns null if valid, error message if invalid
74+
*/
75+
export function validateNickname(nickname: string): string | null {
76+
if (!nickname || nickname.length === 0) {
77+
return 'Nickname is required';
78+
}
79+
if (nickname.length > 50) {
80+
return 'Nickname must be 50 characters or less';
81+
}
82+
if (/\s/.test(nickname)) {
83+
return 'Nickname cannot contain whitespace';
84+
}
85+
return null;
86+
}
87+
5688
/**
5789
* Get path to accounts registry file
5890
*/
@@ -140,7 +172,8 @@ export function getAccount(provider: CLIProxyProvider, accountId: string): Accou
140172
export function registerAccount(
141173
provider: CLIProxyProvider,
142174
tokenFile: string,
143-
email?: string
175+
email?: string,
176+
nickname?: string
144177
): AccountInfo {
145178
const registry = loadAccountsRegistry();
146179

@@ -161,9 +194,13 @@ export function registerAccount(
161194
const accountId = email || 'default';
162195
const isFirstAccount = Object.keys(providerAccounts.accounts).length === 0;
163196

197+
// Generate nickname if not provided
198+
const accountNickname = nickname || generateNickname(email);
199+
164200
// Create or update account
165201
providerAccounts.accounts[accountId] = {
166202
email,
203+
nickname: accountNickname,
167204
tokenFile,
168205
createdAt: new Date().toISOString(),
169206
lastUsedAt: new Date().toISOString(),
@@ -181,6 +218,7 @@ export function registerAccount(
181218
provider,
182219
isDefault: accountId === providerAccounts.default,
183220
email,
221+
nickname: accountNickname,
184222
tokenFile,
185223
createdAt: providerAccounts.accounts[accountId].createdAt,
186224
lastUsedAt: providerAccounts.accounts[accountId].lastUsedAt,
@@ -333,9 +371,10 @@ export function discoverExistingAccounts(): void {
333371
// Get file stats for creation time
334372
const stats = fs.statSync(filePath);
335373

336-
// Register account
374+
// Register account with auto-generated nickname
337375
providerAccounts.accounts[accountId] = {
338376
email,
377+
nickname: generateNickname(email),
339378
tokenFile: file,
340379
createdAt: stats.birthtime?.toISOString() || new Date().toISOString(),
341380
lastUsedAt: stats.mtime?.toISOString(),

src/cliproxy/auth-handler.ts

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import { CLIProxyProvider } from './types';
2222
import {
2323
AccountInfo,
2424
discoverExistingAccounts,
25+
generateNickname,
2526
getDefaultAccount,
2627
getProviderAccounts,
2728
registerAccount,
@@ -429,14 +430,15 @@ export function clearAuth(provider: CLIProxyProvider): boolean {
429430
* Auto-detects headless environment and uses --no-browser flag accordingly
430431
* @param provider - The CLIProxy provider to authenticate
431432
* @param options - OAuth options
433+
* @param options.add - If true, skip confirm prompt when adding another account
432434
* @returns Account info if successful, null otherwise
433435
*/
434436
export async function triggerOAuth(
435437
provider: CLIProxyProvider,
436-
options: { verbose?: boolean; headless?: boolean; account?: string } = {}
438+
options: { verbose?: boolean; headless?: boolean; account?: string; add?: boolean } = {}
437439
): Promise<AccountInfo | null> {
438440
const oauthConfig = getOAuthConfig(provider);
439-
const { verbose = false } = options;
441+
const { verbose = false, add = false } = options;
440442

441443
// Auto-detect headless if not explicitly set
442444
const headless = options.headless ?? isHeadlessEnvironment();
@@ -447,6 +449,34 @@ export async function triggerOAuth(
447449
}
448450
};
449451

452+
// Check for existing accounts and prompt if --add not specified
453+
const existingAccounts = getProviderAccounts(provider);
454+
if (existingAccounts.length > 0 && !add) {
455+
console.log('');
456+
console.log(
457+
`[i] ${existingAccounts.length} account(s) already authenticated for ${oauthConfig.displayName}`
458+
);
459+
460+
// Import readline for confirm prompt
461+
const readline = await import('readline');
462+
const rl = readline.createInterface({
463+
input: process.stdin,
464+
output: process.stdout,
465+
});
466+
467+
const confirmed = await new Promise<boolean>((resolve) => {
468+
rl.question('[?] Add another account? (y/N): ', (answer) => {
469+
rl.close();
470+
resolve(answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes');
471+
});
472+
});
473+
474+
if (!confirmed) {
475+
console.log('[i] Cancelled');
476+
return null;
477+
}
478+
}
479+
450480
// Pre-flight check: verify OAuth callback port is available
451481
const preflight = await preflightOAuthCheck(provider);
452482
if (!preflight.ready) {
@@ -662,8 +692,8 @@ function registerAccountFromToken(
662692
const data = JSON.parse(content);
663693
const email = data.email || undefined;
664694

665-
// Register the account
666-
return registerAccount(provider, newestFile, email);
695+
// Register the account with auto-generated nickname
696+
return registerAccount(provider, newestFile, email, generateNickname(email));
667697
} catch {
668698
return null;
669699
}

src/cliproxy/cliproxy-executor.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,7 @@ export async function execClaudeWithCLIProxy(
123123
const forceHeadless = args.includes('--headless');
124124
const forceLogout = args.includes('--logout');
125125
const forceConfig = args.includes('--config');
126+
const addAccount = args.includes('--add');
126127

127128
// Handle --config: configure model selection and exit
128129
// Pass customSettingsPath for CLIProxy variants to save to correct file
@@ -151,6 +152,7 @@ export async function execClaudeWithCLIProxy(
151152
const { triggerOAuth } = await import('./auth-handler');
152153
const authSuccess = await triggerOAuth(provider, {
153154
verbose,
155+
add: addAccount,
154156
...(forceHeadless ? { headless: true } : {}),
155157
});
156158
if (!authSuccess) {
@@ -260,7 +262,7 @@ export async function execClaudeWithCLIProxy(
260262
log(`Claude env: ANTHROPIC_MODEL=${envVars.ANTHROPIC_MODEL}`);
261263

262264
// Filter out CCS-specific flags before passing to Claude CLI
263-
const ccsFlags = ['--auth', '--headless', '--logout', '--config'];
265+
const ccsFlags = ['--auth', '--headless', '--logout', '--config', '--add'];
264266
const claudeArgs = args.filter((arg) => !ccsFlags.includes(arg));
265267

266268
const isWindows = process.platform === 'win32';

src/commands/help-command.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,7 @@ Claude Code Profile & Model Switcher`.trim();
161161
['ccs qwen', 'Qwen Code (qwen3-coder)'],
162162
['', ''], // Spacer
163163
['ccs <provider> --auth', 'Authenticate only'],
164+
['ccs <provider> --auth --add', 'Add another account'],
164165
['ccs <provider> --config', 'Change model (agy, gemini)'],
165166
['ccs <provider> --logout', 'Clear authentication'],
166167
['ccs <provider> --headless', 'Headless auth (for SSH)'],

0 commit comments

Comments
 (0)