From 43e73f335f85f051b7294b0d9f4bdae405780a26 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Thu, 12 Feb 2026 04:24:09 +0700 Subject: [PATCH] feat(cliproxy): add CLI edit command for composite variants - Add handleEdit() interactive wizard for modifying existing variants - Add updateCompositeVariant() to variant-service for partial tier updates - Route 'edit' subcommand in cliproxy index - Update help text with edit command documentation - Export updateCompositeVariant from services index --- src/cliproxy/services/index.ts | 2 + src/cliproxy/services/variant-service.ts | 87 +++++++++ src/commands/cliproxy/help-subcommand.ts | 1 + src/commands/cliproxy/index.ts | 7 +- src/commands/cliproxy/variant-subcommand.ts | 206 ++++++++++++++++++++ 5 files changed, 302 insertions(+), 1 deletion(-) diff --git a/src/cliproxy/services/index.ts b/src/cliproxy/services/index.ts index 0672fe0f..b8fa43de 100644 --- a/src/cliproxy/services/index.ts +++ b/src/cliproxy/services/index.ts @@ -10,10 +10,12 @@ export { listVariants, createVariant, createCompositeVariant, + updateCompositeVariant, removeVariant, type VariantConfig, type VariantOperationResult, type CreateCompositeVariantOptions, + type UpdateCompositeVariantOptions, } from './variant-service'; // Proxy lifecycle diff --git a/src/cliproxy/services/variant-service.ts b/src/cliproxy/services/variant-service.ts index 05d974f4..2863d2e5 100644 --- a/src/cliproxy/services/variant-service.ts +++ b/src/cliproxy/services/variant-service.ts @@ -337,3 +337,90 @@ export function createCompositeVariant( return { success: false, error: (error as Error).message }; } } + +/** Update options for composite variant */ +export interface UpdateCompositeVariantOptions { + defaultTier?: 'opus' | 'sonnet' | 'haiku'; + tiers?: Partial>; +} + +/** + * Update an existing composite CLIProxy variant. + * Merges changes with existing config and regenerates settings file. + */ +export function updateCompositeVariant( + name: string, + updates: UpdateCompositeVariantOptions +): VariantOperationResult { + if (!isUnifiedMode()) { + throw new Error('Composite variants require unified config (config.yaml).'); + } + + try { + const variants = listVariantsFromConfig(); + const existing = variants[name]; + + if (!existing) { + return { success: false, error: `Variant '${name}' not found` }; + } + + if (existing.type !== 'composite' || !existing.tiers) { + return { success: false, error: `Variant '${name}' is not a composite variant` }; + } + + // Merge tiers (keep unchanged tiers from existing) + const mergedTiers = { + opus: updates.tiers?.opus ?? existing.tiers.opus, + sonnet: updates.tiers?.sonnet ?? existing.tiers.sonnet, + haiku: updates.tiers?.haiku ?? existing.tiers.haiku, + }; + + // Validate all tier providers against backend compatibility + const tierNames: Array<'opus' | 'sonnet' | 'haiku'> = ['opus', 'sonnet', 'haiku']; + for (const tier of tierNames) { + const backendError = validateProviderBackend(mergedTiers[tier].provider); + if (backendError) { + return { success: false, error: `${tier} tier: ${backendError}` }; + } + } + + const newDefaultTier = updates.defaultTier ?? existing.default_tier ?? 'sonnet'; + + // Delete old settings file + if (existing.settings) { + deleteSettingsFile(existing.settings); + } + + // Create new settings file with updated config + const settingsPath = createCompositeSettingsFile( + name, + mergedTiers, + newDefaultTier, + existing.port + ); + + // Save updated composite config to unified config + const compositeConfig: CompositeVariantConfig = { + type: 'composite', + default_tier: newDefaultTier, + tiers: mergedTiers, + settings: getCompositeRelativeSettingsPath(name), + port: existing.port, + }; + saveCompositeVariantUnified(name, compositeConfig); + + return { + success: true, + settingsPath, + variant: { + provider: mergedTiers[newDefaultTier].provider, + type: 'composite', + default_tier: newDefaultTier, + tiers: mergedTiers, + port: existing.port, + }, + }; + } catch (error) { + return { success: false, error: (error as Error).message }; + } +} diff --git a/src/commands/cliproxy/help-subcommand.ts b/src/commands/cliproxy/help-subcommand.ts index 84268553..fe136927 100644 --- a/src/commands/cliproxy/help-subcommand.ts +++ b/src/commands/cliproxy/help-subcommand.ts @@ -27,6 +27,7 @@ export async function showHelp(): Promise { [ ['create [name]', 'Create new CLIProxy variant profile'], ['create --composite', 'Create composite variant (mix providers per tier)'], + ['edit [name]', 'Edit an existing CLIProxy variant profile'], ['list', 'List all CLIProxy variant profiles'], ['remove ', 'Remove a CLIProxy variant profile'], ], diff --git a/src/commands/cliproxy/index.ts b/src/commands/cliproxy/index.ts index dc87d32d..bdefb355 100644 --- a/src/commands/cliproxy/index.ts +++ b/src/commands/cliproxy/index.ts @@ -19,7 +19,7 @@ import { handlePauseAccount, handleResumeAccount, } from './quota-subcommand'; -import { handleCreate, handleRemove } from './variant-subcommand'; +import { handleCreate, handleRemove, handleEdit } from './variant-subcommand'; import { handleProxyStatus, handleStop } from './proxy-lifecycle-subcommand'; import { showStatus, handleInstallVersion, handleInstallLatest } from './install-subcommand'; import { showHelp } from './help-subcommand'; @@ -161,6 +161,11 @@ export async function handleCliproxyCommand(args: string[]): Promise { return; } + if (command === 'edit') { + await handleEdit(remainingArgs.slice(1), effectiveBackend); + return; + } + if (command === 'list' || command === 'ls') { await handleList(); return; diff --git a/src/commands/cliproxy/variant-subcommand.ts b/src/commands/cliproxy/variant-subcommand.ts index aae47644..ff52fa86 100644 --- a/src/commands/cliproxy/variant-subcommand.ts +++ b/src/commands/cliproxy/variant-subcommand.ts @@ -21,6 +21,7 @@ import { listVariants, createVariant, createCompositeVariant, + updateCompositeVariant, removeVariant, } from '../../cliproxy/services'; import { DEFAULT_BACKEND } from '../../cliproxy/platform-detector'; @@ -458,3 +459,208 @@ export async function handleRemove(args: string[]): Promise { console.log(ok(`Variant removed: ${name}`)); console.log(''); } + +export async function handleEdit( + args: string[], + backend: CLIProxyBackend = DEFAULT_BACKEND +): Promise { + await initUI(); + const parsedArgs = parseProfileArgs(args); + const variants = listVariants(); + const variantNames = Object.keys(variants); + + if (variantNames.length === 0) { + console.log(warn('No CLIProxy variants to edit')); + process.exit(0); + } + + let name = parsedArgs.name; + if (!name) { + console.log(header(`Edit ${getBackendLabel(backend)} Variant`)); + console.log(''); + console.log('Available variants:'); + 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 edit', { + validate: (val) => { + if (!val) return 'Variant name is required'; + if (!variantNames.includes(val)) return `Variant '${val}' not found`; + return null; + }, + }); + } + + if (!variantNames.includes(name)) { + console.log(fail(`Variant '${name}' not found`)); + console.log(''); + console.log('Available variants:'); + variantNames.forEach((n) => console.log(` - ${n}`)); + process.exit(1); + } + + const variant = variants[name]; + + // If not composite, use existing updateVariant() flow (interactive prompts) + if (variant.type !== 'composite') { + console.log(header(`Edit Variant: ${name}`)); + console.log(''); + console.log(`Current provider: ${variant.provider}`); + if (variant.model) { + console.log(`Current model: ${variant.model}`); + } + console.log(''); + + const changeProvider = await InteractivePrompt.confirm('Change provider?', { default: false }); + let newProvider: CLIProxyProfileName | undefined = undefined; + if (changeProvider) { + const providerOptions = CLIPROXY_PROFILES.map((p) => ({ + id: p, + label: p.charAt(0).toUpperCase() + p.slice(1), + })); + newProvider = (await InteractivePrompt.selectFromList( + 'Select new provider:', + providerOptions + )) as CLIProxyProfileName; + } + + const changeModel = await InteractivePrompt.confirm('Change model?', { default: false }); + let newModel = variant.model || ''; + if (changeModel) { + const providerForModel = newProvider || (variant.provider as CLIProxyProfileName); + if (supportsModelConfig(providerForModel as CLIProxyProvider)) { + const catalog = getProviderCatalog(providerForModel 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); + newModel = await InteractivePrompt.selectFromList('Select new model:', modelOptions, { + defaultIndex: defaultIdx >= 0 ? defaultIdx : 0, + }); + } + } else { + newModel = await InteractivePrompt.input('New model name', { + validate: (val) => (val ? null : 'Model is required'), + }); + } + } + + console.log(''); + console.log(info(`Updating ${getBackendLabel(backend)} variant...`)); + // Use existing updateVariant from variant-service for single-provider variants + const { updateVariant } = await import('../../cliproxy/services/variant-service'); + const result = updateVariant(name, { + provider: newProvider, + model: changeModel ? newModel : undefined, + }); + + if (!result.success) { + console.log(fail(`Failed to update variant: ${result.error}`)); + process.exit(1); + } + + console.log(''); + console.log(ok(`Variant updated: ${name}`)); + console.log(''); + return; + } + + // Composite variant edit flow + console.log(header(`Edit Composite Variant: ${name}`)); + console.log(''); + if (!variant.tiers) { + console.log(fail('Invalid composite variant: missing tier configuration')); + process.exit(1); + } + + console.log(info('Current tier configuration:')); + 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}`); + console.log(` Default: ${variant.default_tier}`); + console.log(''); + + const verbose = args.includes('--verbose'); + const updatedTiers: Partial> = {}; + + // Ask per-tier edits + for (const tierName of ['opus', 'sonnet', 'haiku'] as const) { + const shouldEdit = await InteractivePrompt.confirm(`Edit ${tierName} tier?`, { + default: false, + }); + if (shouldEdit) { + const newConfig = await selectTierConfig(tierName, verbose); + if (!newConfig) { + console.log(fail('Edit cancelled')); + process.exit(0); + } + updatedTiers[tierName] = newConfig; + } + } + + // Ask for default tier change + let newDefaultTier = variant.default_tier; + const changeDefault = await InteractivePrompt.confirm('Change default tier?', { default: false }); + if (changeDefault) { + const finalTiers = { + opus: updatedTiers.opus ?? variant.tiers.opus, + sonnet: updatedTiers.sonnet ?? variant.tiers.sonnet, + haiku: updatedTiers.haiku ?? variant.tiers.haiku, + }; + const tierOptions = [ + { + id: 'opus' as const, + label: `Opus (${finalTiers.opus.provider}: ${finalTiers.opus.model})`, + }, + { + id: 'sonnet' as const, + label: `Sonnet (${finalTiers.sonnet.provider}: ${finalTiers.sonnet.model})`, + }, + { + id: 'haiku' as const, + label: `Haiku (${finalTiers.haiku.provider}: ${finalTiers.haiku.model})`, + }, + ]; + newDefaultTier = (await InteractivePrompt.selectFromList( + 'Default tier (ANTHROPIC_MODEL):', + tierOptions + )) as 'opus' | 'sonnet' | 'haiku'; + } + + console.log(''); + console.log(info(`Updating composite ${getBackendLabel(backend)} variant...`)); + const result = updateCompositeVariant(name, { + tiers: updatedTiers, + defaultTier: changeDefault ? newDefaultTier : undefined, + }); + + if (!result.success) { + console.log(fail(`Failed to update composite variant: ${result.error}`)); + process.exit(1); + } + + console.log(''); + const finalVariant = result.variant; + if (finalVariant && finalVariant.tiers) { + const tierSummary = + `Opus: ${finalVariant.tiers.opus.provider} / ${finalVariant.tiers.opus.model}\n` + + `Sonnet: ${finalVariant.tiers.sonnet.provider} / ${finalVariant.tiers.sonnet.model}\n` + + `Haiku: ${finalVariant.tiers.haiku.provider} / ${finalVariant.tiers.haiku.model}\n` + + `Default: ${finalVariant.default_tier}`; + const portInfo = finalVariant.port ? `\nPort: ${finalVariant.port}` : ''; + console.log( + infoBox( + `Variant: ${name} (composite)\n${tierSummary}${portInfo}\nConfig: ~/.ccs/config.yaml`, + 'Composite Variant Updated' + ) + ); + } else { + console.log(ok(`Composite variant updated: ${name}`)); + } + console.log(''); +}