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
This commit is contained in:
Tam Nhu Tran
2026-02-12 04:24:09 +07:00
parent 20d60791ff
commit 43e73f335f
5 changed files with 302 additions and 1 deletions
+2
View File
@@ -10,10 +10,12 @@ export {
listVariants,
createVariant,
createCompositeVariant,
updateCompositeVariant,
removeVariant,
type VariantConfig,
type VariantOperationResult,
type CreateCompositeVariantOptions,
type UpdateCompositeVariantOptions,
} from './variant-service';
// Proxy lifecycle
+87
View File
@@ -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<Record<'opus' | 'sonnet' | 'haiku', CompositeTierConfig>>;
}
/**
* 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 };
}
}
+1
View File
@@ -27,6 +27,7 @@ export async function showHelp(): Promise<void> {
[
['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 <name>', 'Remove a CLIProxy variant profile'],
],
+6 -1
View File
@@ -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<void> {
return;
}
if (command === 'edit') {
await handleEdit(remainingArgs.slice(1), effectiveBackend);
return;
}
if (command === 'list' || command === 'ls') {
await handleList();
return;
+206
View File
@@ -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<void> {
console.log(ok(`Variant removed: ${name}`));
console.log('');
}
export async function handleEdit(
args: string[],
backend: CLIProxyBackend = DEFAULT_BACKEND
): Promise<void> {
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<Record<'opus' | 'sonnet' | 'haiku', CompositeTierConfig>> = {};
// 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('');
}