mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-12 18:15:48 +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:
1 parent
1e11d2e40a
commit
493492fa7e
4 files changed
+79
-7
No files matched your search
@@ -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(),
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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)'],
|
||||||
|
|||||||
Reference in new issue
Block a user