feat(cliproxy): add multi-account support for CLIProxy providers

- Implement account-manager.ts for managing multiple OAuth accounts per provider
- Add account selector in UI dialog with dropdown for authenticated accounts
- Extend auth-handler.ts to support account registration and multi-account authentication
- Update web-server routes with account management endpoints (GET/POST/DELETE)
- Add account badges and management UI in cliproxy page
- Support account field in CLIProxy variant configuration
- Add Badge component from shadcn/ui for UI styling
- Enable default account selection and account removal functionality
This commit is contained in:
kaitranntt
2025-12-08 00:16:49 -05:00
parent 2a5292bd77
commit 4dc17fac4f
10 changed files with 994 additions and 63 deletions
+356
View File
@@ -0,0 +1,356 @@
/**
* Account Manager for CLIProxyAPI Multi-Account Support
*
* Manages multiple OAuth accounts per provider (Gemini, Codex, etc.).
* Each provider can have multiple accounts, with one designated as default.
*
* Account storage: ~/.ccs/cliproxy/accounts.json
* Token storage: ~/.ccs/cliproxy/auth/ (flat structure, CLIProxyAPI discovers by type field)
*/
import * as fs from 'fs';
import * as path from 'path';
import { CLIProxyProvider } from './types';
import { getCliproxyDir, getAuthDir } from './config-generator';
/** Account information */
export interface AccountInfo {
/** Account identifier (email or custom name) */
id: string;
/** Email address from OAuth (if available) */
email?: string;
/** Provider this account belongs to */
provider: CLIProxyProvider;
/** Whether this is the default account for the provider */
isDefault: boolean;
/** Token file name in auth directory */
tokenFile: string;
/** When account was added */
createdAt: string;
/** Last usage time */
lastUsedAt?: string;
}
/** Provider accounts configuration */
interface ProviderAccounts {
/** Default account ID for this provider */
default: string;
/** Map of account ID to account metadata */
accounts: Record<string, Omit<AccountInfo, 'id' | 'provider' | 'isDefault'>>;
}
/** Accounts registry structure */
interface AccountsRegistry {
/** Version for future migrations */
version: number;
/** Accounts organized by provider */
providers: Partial<Record<CLIProxyProvider, ProviderAccounts>>;
}
/** Default registry structure */
const DEFAULT_REGISTRY: AccountsRegistry = {
version: 1,
providers: {},
};
/**
* Get path to accounts registry file
*/
export function getAccountsRegistryPath(): string {
return path.join(getCliproxyDir(), 'accounts.json');
}
/**
* Load accounts registry
*/
export function loadAccountsRegistry(): AccountsRegistry {
const registryPath = getAccountsRegistryPath();
if (!fs.existsSync(registryPath)) {
return { ...DEFAULT_REGISTRY };
}
try {
const content = fs.readFileSync(registryPath, 'utf-8');
const data = JSON.parse(content);
return {
version: data.version || 1,
providers: data.providers || {},
};
} catch {
return { ...DEFAULT_REGISTRY };
}
}
/**
* Save accounts registry
*/
export function saveAccountsRegistry(registry: AccountsRegistry): void {
const registryPath = getAccountsRegistryPath();
const dir = path.dirname(registryPath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
}
fs.writeFileSync(registryPath, JSON.stringify(registry, null, 2) + '\n', {
mode: 0o600,
});
}
/**
* Get all accounts for a provider
*/
export function getProviderAccounts(provider: CLIProxyProvider): AccountInfo[] {
const registry = loadAccountsRegistry();
const providerAccounts = registry.providers[provider];
if (!providerAccounts) {
return [];
}
return Object.entries(providerAccounts.accounts).map(([id, meta]) => ({
id,
provider,
isDefault: id === providerAccounts.default,
...meta,
}));
}
/**
* Get default account for a provider
*/
export function getDefaultAccount(provider: CLIProxyProvider): AccountInfo | null {
const accounts = getProviderAccounts(provider);
return accounts.find((a) => a.isDefault) || accounts[0] || null;
}
/**
* Get specific account by ID
*/
export function getAccount(provider: CLIProxyProvider, accountId: string): AccountInfo | null {
const accounts = getProviderAccounts(provider);
return accounts.find((a) => a.id === accountId) || null;
}
/**
* Register a new account
* Called after successful OAuth to record the account
*/
export function registerAccount(
provider: CLIProxyProvider,
tokenFile: string,
email?: string
): AccountInfo {
const registry = loadAccountsRegistry();
// Initialize provider section if needed
if (!registry.providers[provider]) {
registry.providers[provider] = {
default: 'default',
accounts: {},
};
}
const providerAccounts = registry.providers[provider];
if (!providerAccounts) {
throw new Error('Failed to initialize provider accounts');
}
// Determine account ID
const accountId = email || 'default';
const isFirstAccount = Object.keys(providerAccounts.accounts).length === 0;
// Create or update account
providerAccounts.accounts[accountId] = {
email,
tokenFile,
createdAt: new Date().toISOString(),
lastUsedAt: new Date().toISOString(),
};
// Set as default if first account
if (isFirstAccount) {
providerAccounts.default = accountId;
}
saveAccountsRegistry(registry);
return {
id: accountId,
provider,
isDefault: accountId === providerAccounts.default,
email,
tokenFile,
createdAt: providerAccounts.accounts[accountId].createdAt,
lastUsedAt: providerAccounts.accounts[accountId].lastUsedAt,
};
}
/**
* Set default account for a provider
*/
export function setDefaultAccount(provider: CLIProxyProvider, accountId: string): boolean {
const registry = loadAccountsRegistry();
const providerAccounts = registry.providers[provider];
if (!providerAccounts || !providerAccounts.accounts[accountId]) {
return false;
}
providerAccounts.default = accountId;
saveAccountsRegistry(registry);
return true;
}
/**
* Remove an account
*/
export function removeAccount(provider: CLIProxyProvider, accountId: string): boolean {
const registry = loadAccountsRegistry();
const providerAccounts = registry.providers[provider];
if (!providerAccounts || !providerAccounts.accounts[accountId]) {
return false;
}
// Get token file to delete
const tokenFile = providerAccounts.accounts[accountId].tokenFile;
const tokenPath = path.join(getAuthDir(), tokenFile);
// Delete token file
if (fs.existsSync(tokenPath)) {
try {
fs.unlinkSync(tokenPath);
} catch {
// Ignore deletion errors
}
}
// Remove from registry
delete providerAccounts.accounts[accountId];
// Update default if needed
const remainingAccounts = Object.keys(providerAccounts.accounts);
if (providerAccounts.default === accountId && remainingAccounts.length > 0) {
providerAccounts.default = remainingAccounts[0];
}
saveAccountsRegistry(registry);
return true;
}
/**
* Update last used timestamp for an account
*/
export function touchAccount(provider: CLIProxyProvider, accountId: string): void {
const registry = loadAccountsRegistry();
const providerAccounts = registry.providers[provider];
if (providerAccounts?.accounts[accountId]) {
providerAccounts.accounts[accountId].lastUsedAt = new Date().toISOString();
saveAccountsRegistry(registry);
}
}
/**
* Get token file path for an account
*/
export function getAccountTokenPath(provider: CLIProxyProvider, accountId?: string): string | null {
const account = accountId ? getAccount(provider, accountId) : getDefaultAccount(provider);
if (!account) {
return null;
}
return path.join(getAuthDir(), account.tokenFile);
}
/**
* Auto-discover accounts from existing token files
* Called during migration or first run to populate accounts registry
*/
export function discoverExistingAccounts(): void {
const authDir = getAuthDir();
if (!fs.existsSync(authDir)) {
return;
}
const registry = loadAccountsRegistry();
const files = fs.readdirSync(authDir);
for (const file of files) {
if (!file.endsWith('.json')) continue;
const filePath = path.join(authDir, file);
try {
const content = fs.readFileSync(filePath, 'utf-8');
const data = JSON.parse(content);
// Skip if no type field
if (!data.type) continue;
const provider = data.type.toLowerCase() as CLIProxyProvider;
// Validate provider
if (!['gemini', 'codex', 'agy', 'qwen', 'iflow'].includes(provider)) {
continue;
}
// Extract email if available
const email = data.email || undefined;
const accountId = email || 'default';
// Initialize provider section if needed
if (!registry.providers[provider]) {
registry.providers[provider] = {
default: accountId,
accounts: {},
};
}
const providerAccounts = registry.providers[provider];
if (!providerAccounts) continue;
// Skip if account already registered
if (providerAccounts.accounts[accountId]) {
continue;
}
// Get file stats for creation time
const stats = fs.statSync(filePath);
// Register account
providerAccounts.accounts[accountId] = {
email,
tokenFile: file,
createdAt: stats.birthtime?.toISOString() || new Date().toISOString(),
lastUsedAt: stats.mtime?.toISOString(),
};
} catch {
// Skip invalid files
continue;
}
}
saveAccountsRegistry(registry);
}
/**
* Get summary of all accounts across providers
*/
export function getAllAccountsSummary(): Record<CLIProxyProvider, AccountInfo[]> {
const providers: CLIProxyProvider[] = ['gemini', 'codex', 'agy', 'qwen', 'iflow'];
const summary: Record<CLIProxyProvider, AccountInfo[]> = {} as Record<
CLIProxyProvider,
AccountInfo[]
>;
for (const provider of providers) {
summary[provider] = getProviderAccounts(provider);
}
return summary;
}
+95 -10
View File
@@ -19,6 +19,14 @@ import { ProgressIndicator } from '../utils/progress-indicator';
import { ensureCLIProxyBinary } from './binary-manager';
import { generateConfig, getProviderAuthDir } from './config-generator';
import { CLIProxyProvider } from './types';
import {
AccountInfo,
discoverExistingAccounts,
getDefaultAccount,
getProviderAccounts,
registerAccount,
touchAccount,
} from './account-manager';
/**
* OAuth callback ports used by CLIProxyAPI (hardcoded in binary)
@@ -118,6 +126,10 @@ export interface AuthStatus {
tokenFiles: string[];
/** When last authenticated (if known) */
lastAuth?: Date;
/** Accounts registered for this provider (multi-account support) */
accounts: AccountInfo[];
/** Default account ID */
defaultAccount?: string;
}
/**
@@ -323,12 +335,18 @@ export function getAuthStatus(provider: CLIProxyProvider): AuthStatus {
}
}
// Get registered accounts for multi-account support
const accounts = getProviderAccounts(provider);
const defaultAccount = getDefaultAccount(provider);
return {
provider,
authenticated: tokenFiles.length > 0,
tokenDir,
tokenFiles,
lastAuth,
accounts,
defaultAccount: defaultAccount?.id,
};
}
@@ -384,11 +402,14 @@ export function clearAuth(provider: CLIProxyProvider): boolean {
/**
* Trigger OAuth flow for provider
* Auto-detects headless environment and uses --no-browser flag accordingly
* @param provider - The CLIProxy provider to authenticate
* @param options - OAuth options
* @returns Account info if successful, null otherwise
*/
export async function triggerOAuth(
provider: CLIProxyProvider,
options: { verbose?: boolean; headless?: boolean } = {}
): Promise<boolean> {
options: { verbose?: boolean; headless?: boolean; account?: string } = {}
): Promise<AccountInfo | null> {
const oauthConfig = getOAuthConfig(provider);
const { verbose = false } = options;
@@ -451,7 +472,7 @@ export async function triggerOAuth(
spinner.start();
}
return new Promise<boolean>((resolve) => {
return new Promise<AccountInfo | null>((resolve) => {
// Spawn CLIProxyAPI with auth flag (and --no-browser if headless)
const authProcess = spawn(binaryPath, args, {
stdio: ['ignore', 'pipe', 'pipe'],
@@ -513,7 +534,7 @@ export async function triggerOAuth(
console.error(' - Make sure a browser is available');
console.error(' - Try running with --verbose for details');
}
resolve(false);
resolve(null);
}, timeoutMs);
authProcess.on('exit', (code) => {
@@ -524,7 +545,10 @@ export async function triggerOAuth(
if (isAuthenticated(provider)) {
if (!headless) spinner.succeed(`Authenticated with ${oauthConfig.displayName}`);
console.log('[OK] Authentication successful');
resolve(true);
// Register the account in accounts registry
const account = registerAccountFromToken(provider, tokenDir);
resolve(account);
} else {
if (!headless) spinner.fail('Authentication incomplete');
console.error('[X] Token not found after authentication');
@@ -537,7 +561,7 @@ export async function triggerOAuth(
console.error(' The OAuth flow may have been cancelled or callback port was in use');
console.error(` Try: pkill -f cli-proxy-api && ccs ${provider} --auth`);
}
resolve(false);
resolve(null);
}
} else {
if (!headless) spinner.fail('Authentication failed');
@@ -550,7 +574,7 @@ export async function triggerOAuth(
console.error('');
console.error('[i] No OAuth URL was displayed. Try with --verbose for details.');
}
resolve(false);
resolve(null);
}
});
@@ -558,24 +582,76 @@ export async function triggerOAuth(
clearTimeout(timeout);
if (!headless) spinner.fail('Authentication error');
console.error(`[X] Failed to start auth process: ${error.message}`);
resolve(false);
resolve(null);
});
});
}
/**
* Register account from newly created token file
* Scans auth directory for new token and extracts email
*/
function registerAccountFromToken(
provider: CLIProxyProvider,
tokenDir: string
): AccountInfo | null {
try {
const files = fs.readdirSync(tokenDir);
const jsonFiles = files.filter((f) => f.endsWith('.json'));
// Find newest token file for this provider
let newestFile: string | null = null;
let newestMtime = 0;
for (const file of jsonFiles) {
const filePath = path.join(tokenDir, file);
if (!isTokenFileForProvider(filePath, provider)) continue;
const stats = fs.statSync(filePath);
if (stats.mtimeMs > newestMtime) {
newestMtime = stats.mtimeMs;
newestFile = file;
}
}
if (!newestFile) {
return null;
}
// Read token to extract email
const tokenPath = path.join(tokenDir, newestFile);
const content = fs.readFileSync(tokenPath, 'utf-8');
const data = JSON.parse(content);
const email = data.email || undefined;
// Register the account
return registerAccount(provider, newestFile, email);
} catch {
return null;
}
}
/**
* Ensure provider is authenticated
* Triggers OAuth flow if not authenticated
* @param provider - The CLIProxy provider
* @param options - Auth options including optional account
* @returns true if authenticated, false otherwise
*/
export async function ensureAuth(
provider: CLIProxyProvider,
options: { verbose?: boolean; headless?: boolean } = {}
options: { verbose?: boolean; headless?: boolean; account?: string } = {}
): Promise<boolean> {
// Check if already authenticated
if (isAuthenticated(provider)) {
if (options.verbose) {
console.error(`[auth] ${provider} already authenticated`);
}
// Touch the account to update last used time
const defaultAccount = getDefaultAccount(provider);
if (defaultAccount) {
touchAccount(provider, options.account || defaultAccount.id);
}
return true;
}
@@ -583,7 +659,16 @@ export async function ensureAuth(
const oauthConfig = getOAuthConfig(provider);
console.log(`[i] ${oauthConfig.displayName} authentication required`);
return triggerOAuth(provider, options);
const account = await triggerOAuth(provider, options);
return account !== null;
}
/**
* Initialize accounts registry from existing tokens
* Should be called on startup to populate accounts from existing token files
*/
export function initializeAccounts(): void {
discoverExistingAccounts();
}
/**
+2
View File
@@ -21,6 +21,8 @@ export interface CLIProxyVariantConfig {
provider: 'gemini' | 'codex' | 'agy' | 'qwen';
/** Path to settings.json with custom model configuration */
settings: string;
/** Account identifier for multi-account support (optional, defaults to 'default') */
account?: string;
}
/**
+159 -4
View File
@@ -11,7 +11,14 @@ import { getCcsDir, getConfigPath, loadConfig, loadSettings } from '../utils/con
import { Config, Settings } from '../types/config';
import { expandPath } from '../utils/helpers';
import { runHealthChecks, fixHealthIssue } from './health-service';
import { getAllAuthStatus, getOAuthConfig } from '../cliproxy/auth-handler';
import { getAllAuthStatus, getOAuthConfig, initializeAccounts } from '../cliproxy/auth-handler';
import {
getAllAccountsSummary,
getProviderAccounts,
setDefaultAccount as setDefaultAccountFn,
removeAccount as removeAccountFn,
} from '../cliproxy/account-manager';
import type { CLIProxyProvider } from '../cliproxy/types';
export const apiRoutes = Router();
@@ -231,6 +238,7 @@ apiRoutes.get('/cliproxy', (_req: Request, res: Response) => {
name,
provider: variant.provider,
settings: variant.settings,
account: variant.account || 'default', // Include account field
}));
res.json({ variants });
@@ -240,7 +248,7 @@ apiRoutes.get('/cliproxy', (_req: Request, res: Response) => {
* POST /api/cliproxy - Create cliproxy variant
*/
apiRoutes.post('/cliproxy', (req: Request, res: Response): void => {
const { name, provider, model } = req.body;
const { name, provider, model, account } = req.body;
if (!name || !provider) {
res.status(400).json({ error: 'Missing required fields: name, provider' });
@@ -263,10 +271,69 @@ apiRoutes.post('/cliproxy', (req: Request, res: Response): void => {
// Create settings file for variant
const settingsPath = createCliproxySettings(name, model);
config.cliproxy[name] = { provider, settings: settingsPath };
// Include account if specified (defaults to 'default' if not provided)
config.cliproxy[name] = {
provider,
settings: settingsPath,
...(account && { account }),
};
writeConfig(config);
res.status(201).json({ name, provider, settings: settingsPath });
res.status(201).json({ name, provider, settings: settingsPath, account: account || 'default' });
});
/**
* PUT /api/cliproxy/:name - Update cliproxy variant
*/
apiRoutes.put('/cliproxy/:name', (req: Request, res: Response): void => {
const { name } = req.params;
const { provider, account, model } = req.body;
const config = readConfigSafe();
if (!config.cliproxy?.[name]) {
res.status(404).json({ error: 'Variant not found' });
return;
}
const variant = config.cliproxy[name];
// Update fields if provided
if (provider) {
variant.provider = provider;
}
if (account !== undefined) {
if (account) {
variant.account = account;
} else {
delete variant.account; // Remove account to use default
}
}
// Update model in settings file if provided
if (model !== undefined) {
const settingsPath = path.join(getCcsDir(), `${name}.settings.json`);
if (fs.existsSync(settingsPath)) {
const settings = loadSettings(settingsPath);
if (model) {
settings.env = settings.env || {};
settings.env.ANTHROPIC_MODEL = model;
} else if (settings.env) {
delete settings.env.ANTHROPIC_MODEL;
}
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
}
}
writeConfig(config);
res.json({
name,
provider: variant.provider,
account: variant.account || 'default',
settings: variant.settings,
updated: true,
});
});
/**
@@ -298,6 +365,9 @@ apiRoutes.delete('/cliproxy/:name', (req: Request, res: Response): void => {
* GET /api/cliproxy/auth - Get auth status for built-in CLIProxy profiles
*/
apiRoutes.get('/cliproxy/auth', (_req: Request, res: Response) => {
// Initialize accounts from existing tokens on first request
initializeAccounts();
const statuses = getAllAuthStatus();
const authStatus = statuses.map((status) => {
@@ -308,12 +378,97 @@ apiRoutes.get('/cliproxy/auth', (_req: Request, res: Response) => {
authenticated: status.authenticated,
lastAuth: status.lastAuth?.toISOString() || null,
tokenFiles: status.tokenFiles.length,
accounts: status.accounts,
defaultAccount: status.defaultAccount,
};
});
res.json({ authStatus });
});
// ==================== Account Management (Multi-Account Support) ====================
/**
* GET /api/cliproxy/accounts - Get all accounts across all providers
*/
apiRoutes.get('/cliproxy/accounts', (_req: Request, res: Response) => {
// Initialize accounts from existing tokens
initializeAccounts();
const accounts = getAllAccountsSummary();
res.json({ accounts });
});
/**
* GET /api/cliproxy/accounts/:provider - Get accounts for a specific provider
*/
apiRoutes.get('/cliproxy/accounts/:provider', (req: Request, res: Response): void => {
const { provider } = req.params;
// Validate provider
const validProviders: CLIProxyProvider[] = ['gemini', 'codex', 'agy', 'qwen', 'iflow'];
if (!validProviders.includes(provider as CLIProxyProvider)) {
res.status(400).json({ error: `Invalid provider: ${provider}` });
return;
}
const accounts = getProviderAccounts(provider as CLIProxyProvider);
res.json({ provider, accounts });
});
/**
* POST /api/cliproxy/accounts/:provider/default - Set default account for provider
*/
apiRoutes.post('/cliproxy/accounts/:provider/default', (req: Request, res: Response): void => {
const { provider } = req.params;
const { accountId } = req.body;
if (!accountId) {
res.status(400).json({ error: 'Missing required field: accountId' });
return;
}
// Validate provider
const validProviders: CLIProxyProvider[] = ['gemini', 'codex', 'agy', 'qwen', 'iflow'];
if (!validProviders.includes(provider as CLIProxyProvider)) {
res.status(400).json({ error: `Invalid provider: ${provider}` });
return;
}
const success = setDefaultAccountFn(provider as CLIProxyProvider, accountId);
if (success) {
res.json({ provider, defaultAccount: accountId });
} else {
res.status(404).json({ error: 'Account not found' });
}
});
/**
* DELETE /api/cliproxy/accounts/:provider/:accountId - Remove an account
*/
apiRoutes.delete(
'/api/cliproxy/accounts/:provider/:accountId',
(req: Request, res: Response): void => {
const { provider, accountId } = req.params;
// Validate provider
const validProviders: CLIProxyProvider[] = ['gemini', 'codex', 'agy', 'qwen', 'iflow'];
if (!validProviders.includes(provider as CLIProxyProvider)) {
res.status(400).json({ error: `Invalid provider: ${provider}` });
return;
}
const success = removeAccountFn(provider as CLIProxyProvider, accountId);
if (success) {
res.json({ provider, accountId, deleted: true });
} else {
res.status(404).json({ error: 'Account not found' });
}
}
);
// ==================== Settings (Phase 05) ====================
/**
+47 -3
View File
@@ -1,18 +1,19 @@
/**
* CLIProxy Variant Dialog Component
* Phase 03: REST API Routes & CRUD
* Phase 06: Multi-Account Support
*/
import { useForm } from 'react-hook-form';
import { useForm, useWatch } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import * as z from 'zod';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { useCreateVariant } from '@/hooks/use-cliproxy';
import { useCreateVariant, useCliproxyAuth } from '@/hooks/use-cliproxy';
const providers = ['gemini', 'codex', 'agy', 'qwen'] as const;
const providers = ['gemini', 'codex', 'agy', 'qwen', 'iflow'] as const;
const schema = z.object({
name: z
@@ -21,6 +22,7 @@ const schema = z.object({
.regex(/^[a-zA-Z][a-zA-Z0-9._-]*$/, 'Invalid variant name'),
provider: z.enum(providers, { message: 'Provider is required' }),
model: z.string().optional(),
account: z.string().optional(),
});
type FormData = z.infer<typeof schema>;
@@ -35,20 +37,30 @@ const providerOptions = [
{ value: 'codex', label: 'OpenAI Codex' },
{ value: 'agy', label: 'Antigravity' },
{ value: 'qwen', label: 'Alibaba Qwen' },
{ value: 'iflow', label: 'iFlow' },
];
export function CliproxyDialog({ open, onClose }: CliproxyDialogProps) {
const createMutation = useCreateVariant();
const { data: authData } = useCliproxyAuth();
const {
register,
handleSubmit,
control,
formState: { errors },
reset,
} = useForm<FormData>({
resolver: zodResolver(schema),
});
// Watch provider to show relevant accounts
const selectedProvider = useWatch({ control, name: 'provider' });
// Get accounts for selected provider
const providerAuth = authData?.authStatus.find((s) => s.provider === selectedProvider);
const providerAccounts = providerAuth?.accounts || [];
const onSubmit = async (data: FormData) => {
try {
await createMutation.mutateAsync(data);
@@ -91,6 +103,38 @@ export function CliproxyDialog({ open, onClose }: CliproxyDialogProps) {
)}
</div>
{/* Account selector - only show if provider has accounts */}
{selectedProvider && providerAccounts.length > 0 && (
<div>
<Label htmlFor="account">Account</Label>
<select
id="account"
{...register('account')}
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
>
<option value="">Use default account</option>
{providerAccounts.map((acc) => (
<option key={acc.id} value={acc.id}>
{acc.email || acc.id}
{acc.isDefault ? ' (default)' : ''}
</option>
))}
</select>
<span className="text-xs text-muted-foreground mt-1 block">
Select which OAuth account this variant should use
</span>
</div>
)}
{/* Show message if provider selected but no accounts */}
{selectedProvider && providerAccounts.length === 0 && providerAuth && (
<div className="text-sm text-amber-600 bg-amber-50 dark:bg-amber-950/20 p-3 rounded-md">
No accounts authenticated for {providerAuth.displayName}.
<br />
<code className="text-xs bg-muted px-1 rounded">ccs {selectedProvider} --auth</code>
</div>
)}
<div>
<Label htmlFor="model">Model (optional)</Label>
<Input id="model" {...register('model')} placeholder="gemini-2.5-pro" />
+20 -1
View File
@@ -1,6 +1,7 @@
/**
* CLIProxy Variants Table Component
* Phase 03: REST API Routes & CRUD
* Phase 06: Multi-Account Support
*/
import { useReactTable, getCoreRowModel, flexRender, type ColumnDef } from '@tanstack/react-table';
@@ -13,13 +14,14 @@ import {
TableRow,
} from '@/components/ui/table';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { MoreHorizontal, Trash2 } from 'lucide-react';
import { MoreHorizontal, Trash2, User } from 'lucide-react';
import { useDeleteVariant } from '@/hooks/use-cliproxy';
import type { Variant } from '@/lib/api-client';
@@ -32,6 +34,7 @@ const providerLabels: Record<string, string> = {
codex: 'OpenAI Codex',
agy: 'Antigravity',
qwen: 'Alibaba Qwen',
iflow: 'iFlow',
};
export function CliproxyTable({ data }: CliproxyTableProps) {
@@ -47,6 +50,22 @@ export function CliproxyTable({ data }: CliproxyTableProps) {
header: 'Provider',
cell: ({ row }) => providerLabels[row.original.provider] || row.original.provider,
},
{
accessorKey: 'account',
header: 'Account',
cell: ({ row }) => {
const account = row.original.account;
if (!account) {
return <span className="text-muted-foreground text-xs">default</span>;
}
return (
<Badge variant="secondary" className="text-xs font-normal">
<User className="w-3 h-3 mr-1" />
{account}
</Badge>
);
},
},
{
accessorKey: 'settings',
header: 'Settings Path',
+32
View File
@@ -0,0 +1,32 @@
import type * as React from 'react';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const badgeVariants = cva(
'inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
{
variants: {
variant: {
default: 'border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80',
secondary:
'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',
destructive:
'border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80',
outline: 'text-foreground',
},
},
defaultVariants: {
variant: 'default',
},
}
);
interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>, VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
}
export { Badge };
+69 -2
View File
@@ -1,10 +1,11 @@
/**
* React Query hooks for CLIProxy variants
* React Query hooks for CLIProxy variants and accounts
* Phase 03: REST API Routes & CRUD
* Phase 06: Multi-Account Management
*/
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { api, type CreateVariant } from '@/lib/api-client';
import { api, type CreateVariant, type UpdateVariant } from '@/lib/api-client';
import { toast } from 'sonner';
export function useCliproxy() {
@@ -36,6 +37,22 @@ export function useCreateVariant() {
});
}
export function useUpdateVariant() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ name, data }: { name: string; data: UpdateVariant }) =>
api.cliproxy.update(name, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['cliproxy'] });
toast.success('Variant updated successfully');
},
onError: (error: Error) => {
toast.error(error.message);
},
});
}
export function useDeleteVariant() {
const queryClient = useQueryClient();
@@ -50,3 +67,53 @@ export function useDeleteVariant() {
},
});
}
// Multi-account management hooks
export function useCliproxyAccounts() {
return useQuery({
queryKey: ['cliproxy-accounts'],
queryFn: () => api.cliproxy.accounts.list(),
});
}
export function useProviderAccounts(provider: string) {
return useQuery({
queryKey: ['cliproxy-accounts', provider],
queryFn: () => api.cliproxy.accounts.listByProvider(provider),
enabled: !!provider,
});
}
export function useSetDefaultAccount() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ provider, accountId }: { provider: string; accountId: string }) =>
api.cliproxy.accounts.setDefault(provider, accountId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['cliproxy-accounts'] });
queryClient.invalidateQueries({ queryKey: ['cliproxy-auth'] });
toast.success('Default account updated');
},
onError: (error: Error) => {
toast.error(error.message);
},
});
}
export function useRemoveAccount() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ provider, accountId }: { provider: string; accountId: string }) =>
api.cliproxy.accounts.remove(provider, accountId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['cliproxy-accounts'] });
queryClient.invalidateQueries({ queryKey: ['cliproxy-auth'] });
toast.success('Account removed');
},
onError: (error: Error) => {
toast.error(error.message);
},
});
}
+44 -2
View File
@@ -41,14 +41,33 @@ export interface UpdateProfile {
export interface Variant {
name: string;
provider: 'gemini' | 'codex' | 'agy' | 'qwen';
provider: 'gemini' | 'codex' | 'agy' | 'qwen' | 'iflow';
settings: string;
account?: string;
}
export interface CreateVariant {
name: string;
provider: 'gemini' | 'codex' | 'agy' | 'qwen';
provider: 'gemini' | 'codex' | 'agy' | 'qwen' | 'iflow';
model?: string;
account?: string;
}
export interface UpdateVariant {
provider?: 'gemini' | 'codex' | 'agy' | 'qwen' | 'iflow';
model?: string;
account?: string;
}
/** OAuth account info for multi-account support */
export interface OAuthAccount {
id: string;
email?: string;
provider: 'gemini' | 'codex' | 'agy' | 'qwen' | 'iflow';
isDefault: boolean;
tokenFile: string;
createdAt: string;
lastUsedAt?: string;
}
export interface AuthStatus {
@@ -57,8 +76,13 @@ export interface AuthStatus {
authenticated: boolean;
lastAuth: string | null;
tokenFiles: number;
accounts: OAuthAccount[];
defaultAccount?: string;
}
/** Provider accounts summary */
export type ProviderAccountsMap = Record<string, OAuthAccount[]>;
export interface Account {
name: string;
type?: string;
@@ -90,7 +114,25 @@ export const api = {
method: 'POST',
body: JSON.stringify(data),
}),
update: (name: string, data: UpdateVariant) =>
request(`/cliproxy/${name}`, {
method: 'PUT',
body: JSON.stringify(data),
}),
delete: (name: string) => request(`/cliproxy/${name}`, { method: 'DELETE' }),
// Multi-account management
accounts: {
list: () => request<{ accounts: ProviderAccountsMap }>('/cliproxy/accounts'),
listByProvider: (provider: string) =>
request<{ provider: string; accounts: OAuthAccount[] }>(`/cliproxy/accounts/${provider}`),
setDefault: (provider: string, accountId: string) =>
request(`/cliproxy/accounts/${provider}/default`, {
method: 'POST',
body: JSON.stringify({ accountId }),
}),
remove: (provider: string, accountId: string) =>
request(`/cliproxy/accounts/${provider}/${accountId}`, { method: 'DELETE' }),
},
},
accounts: {
list: () => request<{ accounts: Account[]; default: string | null }>('/accounts'),
+170 -41
View File
@@ -1,19 +1,179 @@
/**
* CLIProxy Page
* Phase 03: REST API Routes & CRUD
* Phase 06: Multi-Account Management
*/
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Plus, Check, X } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
DropdownMenuSeparator,
} from '@/components/ui/dropdown-menu';
import { Plus, Check, X, User, ChevronDown, Star, Trash2 } from 'lucide-react';
import { CliproxyTable } from '@/components/cliproxy-table';
import { CliproxyDialog } from '@/components/cliproxy-dialog';
import { useCliproxy, useCliproxyAuth } from '@/hooks/use-cliproxy';
import {
useCliproxy,
useCliproxyAuth,
useSetDefaultAccount,
useRemoveAccount,
} from '@/hooks/use-cliproxy';
import type { OAuthAccount, AuthStatus } from '@/lib/api-client';
function AccountBadge({
account,
onSetDefault,
onRemove,
isRemoving,
}: {
account: OAuthAccount;
onSetDefault: () => void;
onRemove: () => void;
isRemoving: boolean;
}) {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
className={`inline-flex items-center gap-1.5 px-2 py-1 text-xs rounded-md border transition-colors hover:bg-muted/50 ${
account.isDefault
? 'border-primary/30 bg-primary/5 text-primary'
: 'border-muted bg-muted/20'
}`}
>
<User className="w-3 h-3" />
<span className="max-w-[120px] truncate">{account.email || account.id}</span>
{account.isDefault && <Star className="w-3 h-3 fill-current" />}
<ChevronDown className="w-3 h-3 opacity-50" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-48">
<div className="px-2 py-1.5 text-xs text-muted-foreground">
{account.email || account.id}
{account.lastUsedAt && (
<div className="mt-0.5">
Last used: {new Date(account.lastUsedAt).toLocaleDateString()}
</div>
)}
</div>
<DropdownMenuSeparator />
{!account.isDefault && (
<DropdownMenuItem onClick={onSetDefault}>
<Star className="w-4 h-4 mr-2" />
Set as default
</DropdownMenuItem>
)}
<DropdownMenuItem
className="text-red-600 focus:text-red-600"
onClick={onRemove}
disabled={isRemoving}
>
<Trash2 className="w-4 h-4 mr-2" />
{isRemoving ? 'Removing...' : 'Remove account'}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
function ProviderCard({
status,
setDefaultMutation,
removeMutation,
}: {
status: AuthStatus;
setDefaultMutation: ReturnType<typeof useSetDefaultAccount>;
removeMutation: ReturnType<typeof useRemoveAccount>;
}) {
const accounts = status.accounts || [];
const hasMultipleAccounts = accounts.length > 1;
return (
<div
className={`p-4 rounded-lg border ${
status.authenticated ? 'border-green-500/30 bg-green-500/5' : 'border-muted bg-muted/5'
}`}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{status.authenticated ? (
<Check className="w-4 h-4 text-green-500" />
) : (
<X className="w-4 h-4 text-muted-foreground" />
)}
<span className="font-medium">{status.displayName}</span>
</div>
{accounts.length > 0 && (
<Badge variant="secondary" className="text-xs">
{accounts.length} account{accounts.length !== 1 ? 's' : ''}
</Badge>
)}
</div>
{status.authenticated ? (
<div className="mt-2">
{accounts.length > 0 ? (
<div className="flex flex-wrap gap-1.5">
{accounts.map((account) => (
<AccountBadge
key={account.id}
account={account}
onSetDefault={() =>
setDefaultMutation.mutate({
provider: status.provider,
accountId: account.id,
})
}
onRemove={() =>
removeMutation.mutate({
provider: status.provider,
accountId: account.id,
})
}
isRemoving={removeMutation.isPending}
/>
))}
</div>
) : (
<div className="text-sm text-muted-foreground">
Authenticated
{status.lastAuth && (
<span className="ml-1">({new Date(status.lastAuth).toLocaleDateString()})</span>
)}
</div>
)}
{hasMultipleAccounts && (
<div className="mt-2 text-xs text-muted-foreground">
Click account to manage. Star = default.
</div>
)}
<div className="mt-2 text-xs text-muted-foreground">
Add account: <code className="bg-muted px-1 rounded">ccs {status.provider} --auth</code>
</div>
</div>
) : (
<div className="mt-1 text-sm text-muted-foreground">
Not authenticated
<span className="block text-xs mt-1">
Run: <code className="bg-muted px-1 rounded">ccs {status.provider} --auth</code>
</span>
</div>
)}
</div>
);
}
export function CliproxyPage() {
const [dialogOpen, setDialogOpen] = useState(false);
const { data, isLoading } = useCliproxy();
const { data: authData, isLoading: authLoading } = useCliproxyAuth();
const setDefaultMutation = useSetDefaultAccount();
const removeMutation = useRemoveAccount();
return (
<div className="p-6 space-y-6">
@@ -21,7 +181,7 @@ export function CliproxyPage() {
<div>
<h1 className="text-2xl font-bold">CLIProxy</h1>
<p className="text-sm text-muted-foreground mt-1">
Manage OAuth-based provider variants (Gemini, Codex, Antigravity, Qwen)
Manage OAuth-based provider variants with multi-account support
</p>
</div>
<Button onClick={() => setDialogOpen(true)}>
@@ -30,51 +190,20 @@ export function CliproxyPage() {
</Button>
</div>
{/* Built-in Profiles Auth Status */}
{/* Built-in Profiles with Account Management */}
<div>
<h2 className="text-lg font-semibold mb-3">Built-in Profiles</h2>
<h2 className="text-lg font-semibold mb-3">Built-in Profiles & Accounts</h2>
{authLoading ? (
<div className="text-muted-foreground">Loading auth status...</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
{authData?.authStatus.map((status) => (
<div
<ProviderCard
key={status.provider}
className={`p-4 rounded-lg border ${
status.authenticated
? 'border-green-500/30 bg-green-500/5'
: 'border-muted bg-muted/5'
}`}
>
<div className="flex items-center gap-2">
{status.authenticated ? (
<Check className="w-4 h-4 text-green-500" />
) : (
<X className="w-4 h-4 text-muted-foreground" />
)}
<span className="font-medium">{status.displayName}</span>
</div>
<div className="mt-1 text-sm text-muted-foreground">
{status.authenticated ? (
<>
Authenticated
{status.lastAuth && (
<span className="ml-1">
({new Date(status.lastAuth).toLocaleDateString()})
</span>
)}
</>
) : (
<>
Not authenticated
<span className="block text-xs mt-1">
Run:{' '}
<code className="bg-muted px-1 rounded">ccs {status.provider} --auth</code>
</span>
</>
)}
</div>
</div>
status={status}
setDefaultMutation={setDefaultMutation}
removeMutation={removeMutation}
/>
))}
</div>
)}