From d868dc4c32948db27e4b6073e9a7d28966a54971 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Tue, 9 Dec 2025 16:27:31 -0500 Subject: [PATCH] feat(cliproxy): add multi-account support phases 02-03 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 02 - CLI Account Selector in Variant Wizard: - Add --account flag to cliproxy create command - Implement inline OAuth flow when no accounts exist - Add account selector for multiple accounts - Auto-select single account Phase 03 - Dashboard Quick Setup Wizard: - Create quick-setup-wizard.tsx component - Add step-by-step wizard: Provider → Auth → Account → Variant → Success - Add Quick Setup button to CLIProxy page --- src/commands/cliproxy-command.ts | 123 +++++++- ui/src/components/quick-setup-wizard.tsx | 359 +++++++++++++++++++++++ ui/src/pages/cliproxy.tsx | 19 +- 3 files changed, 490 insertions(+), 11 deletions(-) create mode 100644 ui/src/components/quick-setup-wizard.tsx diff --git a/src/commands/cliproxy-command.ts b/src/commands/cliproxy-command.ts index 0332b5a1..31845543 100644 --- a/src/commands/cliproxy-command.ts +++ b/src/commands/cliproxy-command.ts @@ -23,7 +23,8 @@ import { isCLIProxyInstalled, getCLIProxyPath, } from '../cliproxy'; -import { getAllAuthStatus, getOAuthConfig } from '../cliproxy/auth-handler'; +import { getAllAuthStatus, getOAuthConfig, triggerOAuth } from '../cliproxy/auth-handler'; +import { getProviderAccounts } from '../cliproxy/account-manager'; import { CLIPROXY_FALLBACK_VERSION } from '../cliproxy/platform-detector'; import { CLIPROXY_PROFILES, CLIProxyProfileName } from '../auth/profile-detector'; import { getCcsDir, getConfigPath, loadConfig } from '../utils/config-manager'; @@ -53,6 +54,7 @@ interface CliproxyProfileArgs { name?: string; provider?: CLIProxyProfileName; model?: string; + account?: string; force?: boolean; yes?: boolean; } @@ -70,6 +72,8 @@ function parseProfileArgs(args: string[]): CliproxyProfileArgs { result.provider = args[++i] as CLIProxyProfileName; } else if (arg === '--model' && args[i + 1]) { result.model = args[++i]; + } else if (arg === '--account' && args[i + 1]) { + result.account = args[++i]; } else if (arg === '--force') { result.force = true; } else if (arg === '--yes' || arg === '-y') { @@ -136,7 +140,8 @@ function cliproxyVariantExists(name: string): boolean { function createCliproxySettingsFile( name: string, provider: CLIProxyProfileName, - model: string + model: string, + _account?: string ): string { const ccsDir = getCcsDir(); const settingsPath = path.join(ccsDir, `${provider}-${name}.settings.json`); @@ -170,7 +175,8 @@ function createCliproxySettingsFile( function addCliproxyVariant( name: string, provider: CLIProxyProfileName, - settingsPath: string + settingsPath: string, + account?: string ): void { const configPath = getConfigPath(); @@ -189,10 +195,16 @@ function addCliproxyVariant( // Use relative path with ~ for portability const relativePath = `~/.ccs/${path.basename(settingsPath)}`; - config.cliproxy[name] = { + + // Build variant config with optional account + const variantConfig: { provider: string; settings: string; account?: string } = { provider, settings: relativePath, }; + if (account) { + variantConfig.account = account; + } + config.cliproxy[name] = variantConfig; // Write config atomically const tempPath = configPath + '.tmp'; @@ -290,6 +302,103 @@ async function handleCreate(args: string[]): Promise { process.exit(1); } + // Step 2.5: Account selection + let account = parsedArgs.account; + const providerAccounts = getProviderAccounts(provider as CLIProxyProvider); + + if (!account) { + if (providerAccounts.length === 0) { + // No accounts - prompt to authenticate first + console.log(''); + console.log(warn(`No accounts authenticated for ${provider}`)); + console.log(''); + + const shouldAuth = await InteractivePrompt.confirm(`Authenticate with ${provider} now?`, { + default: true, + }); + + if (!shouldAuth) { + console.log(''); + console.log(info('Run authentication first:')); + console.log(` ${color(`ccs ${provider} --auth`, 'command')}`); + process.exit(0); + } + + // Trigger OAuth inline + console.log(''); + const newAccount = await triggerOAuth(provider as CLIProxyProvider, { + add: true, + verbose: args.includes('--verbose'), + }); + + if (!newAccount) { + console.log(fail('Authentication failed')); + process.exit(1); + } + + account = newAccount.id; + console.log(''); + console.log(ok(`Authenticated as ${newAccount.email || newAccount.id}`)); + } else if (providerAccounts.length === 1) { + // Single account - auto-select + account = providerAccounts[0].id; + } else { + // Multiple accounts - show selector with "Add new" option + const ADD_NEW_ID = '__add_new__'; + + const accountOptions = [ + ...providerAccounts.map((acc) => ({ + id: acc.id, + label: `${acc.email || acc.id}${acc.isDefault ? ' (default)' : ''}`, + })), + { + id: ADD_NEW_ID, + label: color('[+ Add new account...]', 'info'), + }, + ]; + + const defaultIdx = providerAccounts.findIndex((a) => a.isDefault); + + const selectedAccount = await InteractivePrompt.selectFromList( + 'Select account:', + accountOptions, + { defaultIndex: defaultIdx >= 0 ? defaultIdx : 0 } + ); + + if (selectedAccount === ADD_NEW_ID) { + // Add new account inline + console.log(''); + const newAccount = await triggerOAuth(provider as CLIProxyProvider, { + add: true, + verbose: args.includes('--verbose'), + }); + + if (!newAccount) { + console.log(fail('Authentication failed')); + process.exit(1); + } + + account = newAccount.id; + console.log(''); + console.log(ok(`Authenticated as ${newAccount.email || newAccount.id}`)); + } else { + account = selectedAccount; + } + } + } else { + // Validate provided account exists + const exists = providerAccounts.find((a) => a.id === account); + if (!exists) { + console.log(fail(`Account '${account}' not found for ${provider}`)); + console.log(''); + console.log('Available accounts:'); + providerAccounts.forEach((a) => { + console.log(` - ${a.email || a.id}${a.isDefault ? ' (default)' : ''}`); + }); + process.exit(1); + } + } + // Step 3: Model selection let model = parsedArgs.model; if (!model) { @@ -323,8 +432,8 @@ async function handleCreate(args: string[]): Promise { console.log(info('Creating CLIProxy variant...')); try { - const settingsPath = createCliproxySettingsFile(name, provider, model); - addCliproxyVariant(name, provider, settingsPath); + const settingsPath = createCliproxySettingsFile(name, provider, model, account); + addCliproxyVariant(name, provider, settingsPath, account); console.log(''); console.log( @@ -332,6 +441,7 @@ async function handleCreate(args: string[]): Promise { `Variant: ${name}\n` + `Provider: ${provider}\n` + `Model: ${model}\n` + + (account ? `Account: ${account}\n` : '') + `Settings: ~/.ccs/${path.basename(settingsPath)}`, 'CLIProxy Variant Created' ) @@ -551,6 +661,7 @@ async function showHelp(): Promise { const createOpts: [string, string][] = [ ['--provider ', 'Provider (gemini, codex, agy, qwen)'], ['--model ', 'Model name'], + ['--account ', 'Account ID (email or default)'], ['--force', 'Overwrite existing variant'], ['--yes, -y', 'Skip confirmation prompts'], ]; diff --git a/ui/src/components/quick-setup-wizard.tsx b/ui/src/components/quick-setup-wizard.tsx new file mode 100644 index 00000000..573c09d6 --- /dev/null +++ b/ui/src/components/quick-setup-wizard.tsx @@ -0,0 +1,359 @@ +/** + * Quick Setup Wizard Component + * Phase 03: Multi-account dashboard support + * + * Step-by-step wizard: Provider -> Auth -> Account -> Variant -> Success + */ + +import { useState, useEffect } from 'react'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Card, CardContent } from '@/components/ui/card'; +import { + Copy, + Check, + RefreshCw, + ChevronRight, + ArrowLeft, + Terminal, + User, + Sparkles, +} from 'lucide-react'; +import { useCliproxyAuth, useCreateVariant } from '@/hooks/use-cliproxy'; +import type { AuthStatus, OAuthAccount } from '@/lib/api-client'; + +interface QuickSetupWizardProps { + open: boolean; + onClose: () => void; +} + +type WizardStep = 'provider' | 'auth' | 'account' | 'variant' | 'success'; + +const providers = [ + { id: 'gemini', name: 'Google Gemini', description: 'Gemini Pro/Flash models' }, + { id: 'codex', name: 'OpenAI Codex', description: 'GPT-4 and codex models' }, + { id: 'agy', name: 'Antigravity', description: 'Antigravity AI models' }, + { id: 'qwen', name: 'Alibaba Qwen', description: 'Qwen Code models' }, + { id: 'iflow', name: 'iFlow', description: 'iFlow AI models' }, +]; + +export function QuickSetupWizard({ open, onClose }: QuickSetupWizardProps) { + const [step, setStep] = useState('provider'); + const [selectedProvider, setSelectedProvider] = useState(''); + const [selectedAccount, setSelectedAccount] = useState(null); + const [variantName, setVariantName] = useState(''); + const [modelName, setModelName] = useState(''); + const [copied, setCopied] = useState(false); + const [isRefreshing, setIsRefreshing] = useState(false); + + const { data: authData, refetch } = useCliproxyAuth(); + const createMutation = useCreateVariant(); + + // Get auth status for selected provider + const providerAuth = authData?.authStatus.find( + (s: AuthStatus) => s.provider === selectedProvider + ); + const accounts = providerAuth?.accounts || []; + + // Reset on close - use timeout to avoid synchronous setState in effect + useEffect(() => { + if (!open) { + const timer = setTimeout(() => { + setStep('provider'); + setSelectedProvider(''); + setSelectedAccount(null); + setVariantName(''); + setModelName(''); + setCopied(false); + }, 0); + return () => clearTimeout(timer); + } + }, [open]); + + // Auto-advance from auth step when account detected + // Use timeout to avoid synchronous setState in effect (React lint rule) + useEffect(() => { + if (step === 'auth' && accounts.length > 0) { + const timer = setTimeout(() => { + if (accounts.length === 1) { + setSelectedAccount(accounts[0]); + setStep('variant'); + } else { + setStep('account'); + } + }, 0); + return () => clearTimeout(timer); + } + }, [step, accounts]); + + const copyCommand = async (cmd: string) => { + await navigator.clipboard.writeText(cmd); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + + const handleRefresh = async () => { + setIsRefreshing(true); + await refetch(); + setIsRefreshing(false); + }; + + const handleProviderSelect = (providerId: string) => { + setSelectedProvider(providerId); + const auth = authData?.authStatus.find((s: AuthStatus) => s.provider === providerId); + const provAccounts = auth?.accounts || []; + + if (provAccounts.length === 0) { + setStep('auth'); + } else if (provAccounts.length === 1) { + setSelectedAccount(provAccounts[0]); + setStep('variant'); + } else { + setStep('account'); + } + }; + + const handleAccountSelect = (account: OAuthAccount) => { + setSelectedAccount(account); + setStep('variant'); + }; + + const handleCreateVariant = async () => { + if (!variantName || !selectedProvider) return; + + try { + await createMutation.mutateAsync({ + name: variantName, + provider: selectedProvider as 'gemini' | 'codex' | 'agy' | 'qwen' | 'iflow', + model: modelName || undefined, + account: selectedAccount?.id, + }); + setStep('success'); + } catch (error) { + console.error('Failed to create variant:', error); + } + }; + + const authCommand = `ccs ${selectedProvider} --auth --add`; + + // Progress steps for indicator + const allSteps = ['provider', 'auth', 'variant', 'success']; + const getStepProgress = (s: WizardStep) => { + if (s === 'account') return 1; // Same as auth + return allSteps.indexOf(s); + }; + const currentProgress = getStepProgress(step); + + return ( + + + + + + Quick Setup Wizard + + + {step === 'provider' && 'Select a provider to get started'} + {step === 'auth' && 'Authenticate with your provider'} + {step === 'account' && 'Select which account to use'} + {step === 'variant' && 'Create your custom variant'} + {step === 'success' && 'Setup complete!'} + + + +
+ {/* Step: Provider Selection */} + {step === 'provider' && ( +
+ {providers.map((p) => ( + + ))} +
+ )} + + {/* Step: Authentication */} + {step === 'auth' && ( +
+ + +
+ + Run this command in your terminal: +
+
+ + {authCommand} + + +
+
+ This will open your browser to authenticate with{' '} + {providers.find((p) => p.id === selectedProvider)?.name} +
+
+
+ +
+ + +
+
+ )} + + {/* Step: Account Selection */} + {step === 'account' && ( +
+
+ {accounts.map((acc: OAuthAccount) => ( + + ))} +
+
+ +
+
+ )} + + {/* Step: Create Variant */} + {step === 'variant' && ( +
+ {selectedAccount && ( +
+ + Using: {selectedAccount.email || selectedAccount.id} +
+ )} + +
+ + setVariantName(e.target.value)} + placeholder="e.g., my-gemini, g3, flash" + /> +
+ Use this name to invoke: ccs {variantName || ''} "prompt" +
+
+ +
+ + setModelName(e.target.value)} + placeholder="e.g., gemini-2.5-pro" + /> +
+ +
+ + +
+
+ )} + + {/* Step: Success */} + {step === 'success' && ( +
+
+
+ +
+
+
+
Variant Created!
+
+ Your custom variant is ready to use +
+
+ + +
Usage:
+ + ccs {variantName} "your prompt here" + +
+
+ +
+ )} +
+ + {/* Progress indicator */} +
+ {allSteps.map((s, i) => ( +
= i ? 'bg-primary' : 'bg-muted' + }`} + /> + ))} +
+ +
+ ); +} diff --git a/ui/src/pages/cliproxy.tsx b/ui/src/pages/cliproxy.tsx index a70e5de9..808d82cf 100644 --- a/ui/src/pages/cliproxy.tsx +++ b/ui/src/pages/cliproxy.tsx @@ -14,9 +14,10 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; -import { Plus, Check, X, User, ChevronDown, Star, Trash2 } from 'lucide-react'; +import { Plus, Check, X, User, ChevronDown, Star, Trash2, Sparkles } from 'lucide-react'; import { CliproxyTable } from '@/components/cliproxy-table'; import { CliproxyDialog } from '@/components/cliproxy-dialog'; +import { QuickSetupWizard } from '@/components/quick-setup-wizard'; import { useCliproxy, useCliproxyAuth, @@ -175,6 +176,7 @@ function ProviderRow({ export function CliproxyPage() { const [dialogOpen, setDialogOpen] = useState(false); + const [wizardOpen, setWizardOpen] = useState(false); const { data, isLoading } = useCliproxy(); const { data: authData, isLoading: authLoading } = useCliproxyAuth(); const setDefaultMutation = useSetDefaultAccount(); @@ -189,10 +191,16 @@ export function CliproxyPage() { Manage OAuth-based provider variants and multi-account configurations

- +
+ + +
{/* Built-in Profiles with Account Management */} @@ -245,6 +253,7 @@ export function CliproxyPage() { setDialogOpen(false)} /> + setWizardOpen(false)} /> ); }