diff --git a/README.md b/README.md index 290bea66..c6515a99 100644 --- a/README.md +++ b/README.md @@ -106,6 +106,29 @@ ccs agy --headless # Displays URL, paste in browser elsewhere ccs gemini --logout ``` +### Multi-Account for OAuth Providers + +Use multiple accounts per provider (work + personal): + +```bash +# First account (default) +ccs gemini --auth + +# Add another account +ccs gemini --auth --add + +# Add with nickname for easy identification +ccs gemini --auth --add --nickname work + +# List all accounts +ccs agy --accounts + +# Switch to a different account +ccs agy --use work +``` + +Accounts are stored in `~/.ccs/cliproxy/accounts.json` and can be managed via web dashboard (`ccs config`). + ### OAuth vs API Key Models | Feature | OAuth Providers
(gemini, codex, agy) | API Key Models
(glm, kimi) | diff --git a/src/cliproxy/account-manager.ts b/src/cliproxy/account-manager.ts index 5ba923bd..39413cef 100644 --- a/src/cliproxy/account-manager.ts +++ b/src/cliproxy/account-manager.ts @@ -165,6 +165,32 @@ export function getAccount(provider: CLIProxyProvider, accountId: string): Accou return accounts.find((a) => a.id === accountId) || null; } +/** + * Find account by query (nickname, email, or id) + * Supports partial matching for convenience + */ +export function findAccountByQuery(provider: CLIProxyProvider, query: string): AccountInfo | null { + const accounts = getProviderAccounts(provider); + const lowerQuery = query.toLowerCase(); + + // Exact match first (id, email, nickname) + const exactMatch = accounts.find( + (a) => + a.id === query || + a.email?.toLowerCase() === lowerQuery || + a.nickname?.toLowerCase() === lowerQuery + ); + if (exactMatch) return exactMatch; + + // Partial match on nickname or email prefix + const partialMatch = accounts.find( + (a) => + a.nickname?.toLowerCase().startsWith(lowerQuery) || + a.email?.toLowerCase().startsWith(lowerQuery) + ); + return partialMatch || null; +} + /** * Register a new account * Called after successful OAuth to record the account diff --git a/src/cliproxy/cliproxy-executor.ts b/src/cliproxy/cliproxy-executor.ts index 1f565c8e..af8536c3 100644 --- a/src/cliproxy/cliproxy-executor.ts +++ b/src/cliproxy/cliproxy-executor.ts @@ -28,6 +28,12 @@ import { isAuthenticated } from './auth-handler'; import { CLIProxyProvider, ExecutorConfig } from './types'; import { configureProviderModel, getCurrentModel } from './model-config'; import { supportsModelConfig, isModelBroken, getModelIssueUrl, findModel } from './model-catalog'; +import { + findAccountByQuery, + getProviderAccounts, + setDefaultAccount, + touchAccount, +} from './account-manager'; /** Default executor configuration */ const DEFAULT_CONFIG: ExecutorConfig = { @@ -124,6 +130,52 @@ export async function execClaudeWithCLIProxy( const forceLogout = args.includes('--logout'); const forceConfig = args.includes('--config'); const addAccount = args.includes('--add'); + const showAccounts = args.includes('--accounts'); + + // Parse --use flag + let useAccount: string | undefined; + const useIdx = args.indexOf('--use'); + if (useIdx !== -1 && args[useIdx + 1] && !args[useIdx + 1].startsWith('-')) { + useAccount = args[useIdx + 1]; + } + + // Handle --accounts: list accounts and exit + if (showAccounts) { + const accounts = getProviderAccounts(provider); + if (accounts.length === 0) { + console.log(`[i] No accounts registered for ${providerConfig.displayName}`); + console.log(` Run "ccs ${provider} --auth" to add an account`); + } else { + console.log(`\n${providerConfig.displayName} Accounts:\n`); + for (const acct of accounts) { + const defaultMark = acct.isDefault ? ' (default)' : ''; + const nickname = acct.nickname ? `[${acct.nickname}]` : ''; + console.log(` ${nickname.padEnd(12)} ${acct.email || acct.id}${defaultMark}`); + } + console.log(`\n Use "ccs ${provider} --use " to switch accounts`); + } + process.exit(0); + } + + // Handle --use: switch to specified account + if (useAccount) { + const account = findAccountByQuery(provider, useAccount); + if (!account) { + console.error(`[X] Account not found: "${useAccount}"`); + const accounts = getProviderAccounts(provider); + if (accounts.length > 0) { + console.error(` Available accounts:`); + for (const acct of accounts) { + console.error(` - ${acct.nickname || acct.id} (${acct.email || 'no email'})`); + } + } + process.exit(1); + } + // Set as default for this and future sessions + setDefaultAccount(provider, account.id); + touchAccount(provider, account.id); + console.log(`[OK] Switched to account: ${account.nickname || account.email || account.id}`); + } // Handle --config: configure model selection and exit // Pass customSettingsPath for CLIProxy variants to save to correct file @@ -262,8 +314,14 @@ 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', '--add']; - const claudeArgs = args.filter((arg) => !ccsFlags.includes(arg)); + const ccsFlags = ['--auth', '--headless', '--logout', '--config', '--add', '--accounts', '--use']; + const claudeArgs = args.filter((arg, idx) => { + // Filter out CCS flags + if (ccsFlags.includes(arg)) return false; + // Filter out value after --use + if (args[idx - 1] === '--use') return false; + return true; + }); const isWindows = process.platform === 'win32'; const needsShell = isWindows && /\.(cmd|bat|ps1)$/i.test(claudeCli); diff --git a/src/commands/cliproxy-command.ts b/src/commands/cliproxy-command.ts index 31845543..76f2d2b7 100644 --- a/src/commands/cliproxy-command.ts +++ b/src/commands/cliproxy-command.ts @@ -656,6 +656,21 @@ async function showHelp(): Promise { } console.log(''); + // Multi-Account Commands + console.log(subheader('Multi-Account Commands:')); + const multiAcctCmds: [string, string][] = [ + ['--auth', 'Authenticate with a provider (first account)'], + ['--auth --add', 'Add another account to a provider'], + ['--nickname ', 'Set friendly name for account'], + ['--accounts', 'List all accounts for a provider'], + ['--use ', 'Switch to account by nickname/email'], + ]; + const maxMultiLen = Math.max(...multiAcctCmds.map(([cmd]) => cmd.length)); + for (const [cmd, desc] of multiAcctCmds) { + console.log(` ${color(cmd.padEnd(maxMultiLen + 2), 'command')} ${desc}`); + } + console.log(''); + // Create Options console.log(subheader('Create Options:')); const createOpts: [string, string][] = [ @@ -690,6 +705,23 @@ async function showHelp(): Promise { ` $ ${color('ccs cliproxy --latest', 'command')} ${dim('# Update binary')}` ); console.log(''); + console.log(subheader('Multi-Account Examples:')); + console.log( + ` $ ${color('ccs gemini --auth', 'command')} ${dim('# First account')}` + ); + console.log( + ` $ ${color('ccs gemini --auth --add', 'command')} ${dim('# Add second account')}` + ); + console.log( + ` $ ${color('ccs gemini --auth --add --nickname work', 'command')} ${dim('# With nickname')}` + ); + console.log( + ` $ ${color('ccs agy --accounts', 'command')} ${dim('# List accounts')}` + ); + console.log( + ` $ ${color('ccs agy --use work', 'command')} ${dim('# Switch account')}` + ); + console.log(''); // Notes console.log(subheader('Notes:')); diff --git a/src/commands/help-command.ts b/src/commands/help-command.ts index 9a266347..23c2af18 100644 --- a/src/commands/help-command.ts +++ b/src/commands/help-command.ts @@ -162,6 +162,8 @@ Claude Code Profile & Model Switcher`.trim(); ['', ''], // Spacer ['ccs --auth', 'Authenticate only'], ['ccs --auth --add', 'Add another account'], + ['ccs --accounts', 'List all accounts'], + ['ccs --use ', 'Switch to account'], ['ccs --config', 'Change model (agy, gemini)'], ['ccs --logout', 'Clear authentication'], ['ccs --headless', 'Headless auth (for SSH)'],