mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 10:16:49 +00:00
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:
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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)'],
|
||||
|
||||
Reference in New Issue
Block a user