mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-17 04:17:11 +00:00
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:
@@ -10,10 +10,12 @@ export {
|
||||
listVariants,
|
||||
createVariant,
|
||||
createCompositeVariant,
|
||||
updateCompositeVariant,
|
||||
removeVariant,
|
||||
type VariantConfig,
|
||||
type VariantOperationResult,
|
||||
type CreateCompositeVariantOptions,
|
||||
type UpdateCompositeVariantOptions,
|
||||
} from './variant-service';
|
||||
|
||||
// Proxy lifecycle
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'],
|
||||
],
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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('');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user