From 4926c200f2601655e0a93f73c9dd8d03c44e12da Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Thu, 12 Feb 2026 03:27:29 +0700 Subject: [PATCH] feat(cliproxy): add composite variant CLI wizard - Add --composite flag to 'ccs cliproxy variant create' - Implement selectTierConfig() interactive prompt per tier - Add default tier selection after all tiers configured - Display composite label in auth list and variant remove - Show per-tier details in remove confirmation - Update help text with --composite usage Refs #506 --- src/commands/cliproxy/auth-subcommand.ts | 3 +- src/commands/cliproxy/help-subcommand.ts | 1 + src/commands/cliproxy/variant-subcommand.ts | 157 +++++++++++++++++++- 3 files changed, 158 insertions(+), 3 deletions(-) diff --git a/src/commands/cliproxy/auth-subcommand.ts b/src/commands/cliproxy/auth-subcommand.ts index fbdaa353..76ab6730 100644 --- a/src/commands/cliproxy/auth-subcommand.ts +++ b/src/commands/cliproxy/auth-subcommand.ts @@ -43,8 +43,9 @@ export async function handleList(): Promise { console.log(subheader('Custom Variants')); const rows = variantNames.map((name) => { const variant = variants[name]; + const providerDisplay = variant.type === 'composite' ? 'composite' : variant.provider; const portStr = variant.port ? String(variant.port) : '-'; - return [name, variant.provider, portStr, variant.settings || '-']; + return [name, providerDisplay, portStr, variant.settings || '-']; }); console.log( table(rows, { head: ['Variant', 'Provider', 'Port', 'Settings'], colWidths: [15, 12, 8, 30] }) diff --git a/src/commands/cliproxy/help-subcommand.ts b/src/commands/cliproxy/help-subcommand.ts index 1c970232..84268553 100644 --- a/src/commands/cliproxy/help-subcommand.ts +++ b/src/commands/cliproxy/help-subcommand.ts @@ -26,6 +26,7 @@ export async function showHelp(): Promise { 'Profile Commands:', [ ['create [name]', 'Create new CLIProxy variant profile'], + ['create --composite', 'Create composite variant (mix providers per tier)'], ['list', 'List all CLIProxy variant profiles'], ['remove ', 'Remove a CLIProxy variant profile'], ], diff --git a/src/commands/cliproxy/variant-subcommand.ts b/src/commands/cliproxy/variant-subcommand.ts index 24d7ba0c..aae47644 100644 --- a/src/commands/cliproxy/variant-subcommand.ts +++ b/src/commands/cliproxy/variant-subcommand.ts @@ -20,9 +20,11 @@ import { variantExists, listVariants, createVariant, + createCompositeVariant, removeVariant, } from '../../cliproxy/services'; import { DEFAULT_BACKEND } from '../../cliproxy/platform-detector'; +import { CompositeTierConfig } from '../../config/unified-config-types'; interface CliproxyProfileArgs { name?: string; @@ -31,6 +33,7 @@ interface CliproxyProfileArgs { account?: string; force?: boolean; yes?: boolean; + composite?: boolean; } function parseProfileArgs(args: string[]): CliproxyProfileArgs { @@ -47,6 +50,8 @@ function parseProfileArgs(args: string[]): CliproxyProfileArgs { result.force = true; } else if (arg === '--yes' || arg === '-y') { result.yes = true; + } else if (arg === '--composite') { + result.composite = true; } else if (!arg.startsWith('-') && !result.name) { result.name = arg; } @@ -68,6 +73,71 @@ function getBackendLabel(backend: CLIProxyBackend): string { return backend === 'plus' ? 'CLIProxy Plus' : 'CLIProxy'; } +/** + * Interactive prompt to select provider + model for a single tier. + * Returns a CompositeTierConfig, or null if user cancelled auth. + */ +async function selectTierConfig( + tierName: string, + verbose: boolean +): Promise { + console.log(header(`${tierName.charAt(0).toUpperCase() + tierName.slice(1)} Tier`)); + + // Select provider + const providerOptions = CLIPROXY_PROFILES.map((p) => ({ + id: p, + label: p.charAt(0).toUpperCase() + p.slice(1), + })); + const provider = (await InteractivePrompt.selectFromList( + `Provider for ${tierName}:`, + providerOptions + )) as CLIProxyProfileName; + + // Check auth + const providerAccounts = getProviderAccounts(provider as CLIProxyProvider); + if (providerAccounts.length === 0) { + console.log(''); + console.log(warn(`No accounts authenticated for ${provider}`)); + const shouldAuth = await InteractivePrompt.confirm(`Authenticate with ${provider} now?`, { + default: true, + }); + if (!shouldAuth) { + console.log(info(`Skipping auth. Run: ${color(`ccs ${provider} --auth`, 'command')}`)); + return null; + } + const newAccount = await triggerOAuth(provider as CLIProxyProvider, { + add: true, + verbose, + }); + if (!newAccount) { + console.log(fail('Authentication failed')); + process.exit(1); + } + console.log(ok(`Authenticated as ${newAccount.email || newAccount.id}`)); + } + + // Select model + let model: string | undefined; + if (supportsModelConfig(provider as CLIProxyProvider)) { + const catalog = getProviderCatalog(provider as CLIProxyProvider); + if (catalog) { + const modelOptions = catalog.models.map((m) => ({ id: m.id, label: formatModelOption(m) })); + const defaultIdx = catalog.models.findIndex((m) => m.id === catalog.defaultModel); + model = await InteractivePrompt.selectFromList(`Model for ${tierName}:`, modelOptions, { + defaultIndex: defaultIdx >= 0 ? defaultIdx : 0, + }); + } + } + if (!model) { + model = await InteractivePrompt.input(`Model name for ${tierName}`, { + validate: (val) => (val ? null : 'Model is required'), + }); + } + + console.log(''); + return { provider, model }; +} + export async function handleCreate( args: string[], backend: CLIProxyBackend = DEFAULT_BACKEND @@ -97,6 +167,76 @@ export async function handleCreate( process.exit(1); } + // Clean up old variant if --force is set + if (variantExists(name) && parsedArgs.force) { + removeVariant(name); + } + + // Composite mode: select provider+model per tier + if (parsedArgs.composite) { + console.log(info('Composite variant — select provider and model for each tier')); + console.log(''); + + const verbose = args.includes('--verbose'); + const opus = await selectTierConfig('opus', verbose); + if (!opus) { + return; // User cancelled auth + } + const sonnet = await selectTierConfig('sonnet', verbose); + if (!sonnet) { + return; // User cancelled auth + } + const haiku = await selectTierConfig('haiku', verbose); + if (!haiku) { + return; // User cancelled auth + } + + // Select default tier + const tierOptions = [ + { id: 'opus' as const, label: `Opus (${opus.provider}: ${opus.model})` }, + { id: 'sonnet' as const, label: `Sonnet (${sonnet.provider}: ${sonnet.model})` }, + { id: 'haiku' as const, label: `Haiku (${haiku.provider}: ${haiku.model})` }, + ]; + const defaultTier = (await InteractivePrompt.selectFromList( + 'Default tier (ANTHROPIC_MODEL):', + tierOptions + )) as 'opus' | 'sonnet' | 'haiku'; + + console.log(''); + console.log(info(`Creating composite ${getBackendLabel(backend)} variant...`)); + const result = createCompositeVariant({ + name, + defaultTier, + tiers: { opus, sonnet, haiku }, + }); + + if (!result.success) { + console.log(fail(`Failed to create composite variant: ${result.error}`)); + process.exit(1); + } + + console.log(''); + const tiers = result.variant?.tiers; + const tierSummary = tiers + ? `Opus: ${tiers.opus.provider} / ${tiers.opus.model}\n` + + `Sonnet: ${tiers.sonnet.provider} / ${tiers.sonnet.model}\n` + + `Haiku: ${tiers.haiku.provider} / ${tiers.haiku.model}\n` + + `Default: ${defaultTier}` + : ''; + const portInfo = result.variant?.port ? `\nPort: ${result.variant.port}` : ''; + console.log( + infoBox( + `Variant: ${name} (composite)\n${tierSummary}${portInfo}\nConfig: ~/.ccs/config.yaml`, + 'Composite Variant Created' + ) + ); + console.log(''); + console.log(header('Usage')); + console.log(` ${color(`ccs ${name} "your prompt"`, 'command')}`); + console.log(''); + return; + } + // Step 2: Provider selection let provider = parsedArgs.provider; if (!provider) { @@ -260,7 +400,11 @@ export async function handleRemove(args: string[]): Promise { console.log(header('Remove CLIProxy Variant')); console.log(''); console.log('Available variants:'); - variantNames.forEach((n, i) => console.log(` ${i + 1}. ${n} (${variants[n].provider})`)); + variantNames.forEach((n, i) => { + const v = variants[n]; + const label = v.type === 'composite' ? 'composite' : v.provider; + console.log(` ${i + 1}. ${n} (${label})`); + }); console.log(''); name = await InteractivePrompt.input('Variant name to remove', { validate: (val) => { @@ -282,7 +426,16 @@ export async function handleRemove(args: string[]): Promise { const variant = variants[name]; console.log(''); console.log(`Variant '${color(name, 'command')}' will be removed.`); - console.log(` Provider: ${variant.provider}`); + if (variant.type === 'composite') { + console.log(` Type: composite`); + if (variant.tiers) { + console.log(` Opus: ${variant.tiers.opus.provider} / ${variant.tiers.opus.model}`); + console.log(` Sonnet: ${variant.tiers.sonnet.provider} / ${variant.tiers.sonnet.model}`); + console.log(` Haiku: ${variant.tiers.haiku.provider} / ${variant.tiers.haiku.model}`); + } + } else { + console.log(` Provider: ${variant.provider}`); + } if (variant.port) { console.log(` Port: ${variant.port}`); }