mirror of
https://github.com/tiennm99/ccs.git
synced 2026-09-02 14:19:56 +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,
|
listVariants,
|
||||||
createVariant,
|
createVariant,
|
||||||
createCompositeVariant,
|
createCompositeVariant,
|
||||||
|
updateCompositeVariant,
|
||||||
removeVariant,
|
removeVariant,
|
||||||
type VariantConfig,
|
type VariantConfig,
|
||||||
type VariantOperationResult,
|
type VariantOperationResult,
|
||||||
type CreateCompositeVariantOptions,
|
type CreateCompositeVariantOptions,
|
||||||
|
type UpdateCompositeVariantOptions,
|
||||||
} from './variant-service';
|
} from './variant-service';
|
||||||
|
|
||||||
// Proxy lifecycle
|
// Proxy lifecycle
|
||||||
|
|||||||
@@ -337,3 +337,90 @@ export function createCompositeVariant(
|
|||||||
return { success: false, error: (error as Error).message };
|
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 [name]', 'Create new CLIProxy variant profile'],
|
||||||
['create --composite', 'Create composite variant (mix providers per tier)'],
|
['create --composite', 'Create composite variant (mix providers per tier)'],
|
||||||
|
['edit [name]', 'Edit an existing CLIProxy variant profile'],
|
||||||
['list', 'List all CLIProxy variant profiles'],
|
['list', 'List all CLIProxy variant profiles'],
|
||||||
['remove <name>', 'Remove a CLIProxy variant profile'],
|
['remove <name>', 'Remove a CLIProxy variant profile'],
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import {
|
|||||||
handlePauseAccount,
|
handlePauseAccount,
|
||||||
handleResumeAccount,
|
handleResumeAccount,
|
||||||
} from './quota-subcommand';
|
} from './quota-subcommand';
|
||||||
import { handleCreate, handleRemove } from './variant-subcommand';
|
import { handleCreate, handleRemove, handleEdit } from './variant-subcommand';
|
||||||
import { handleProxyStatus, handleStop } from './proxy-lifecycle-subcommand';
|
import { handleProxyStatus, handleStop } from './proxy-lifecycle-subcommand';
|
||||||
import { showStatus, handleInstallVersion, handleInstallLatest } from './install-subcommand';
|
import { showStatus, handleInstallVersion, handleInstallLatest } from './install-subcommand';
|
||||||
import { showHelp } from './help-subcommand';
|
import { showHelp } from './help-subcommand';
|
||||||
@@ -161,6 +161,11 @@ export async function handleCliproxyCommand(args: string[]): Promise<void> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (command === 'edit') {
|
||||||
|
await handleEdit(remainingArgs.slice(1), effectiveBackend);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (command === 'list' || command === 'ls') {
|
if (command === 'list' || command === 'ls') {
|
||||||
await handleList();
|
await handleList();
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
listVariants,
|
listVariants,
|
||||||
createVariant,
|
createVariant,
|
||||||
createCompositeVariant,
|
createCompositeVariant,
|
||||||
|
updateCompositeVariant,
|
||||||
removeVariant,
|
removeVariant,
|
||||||
} from '../../cliproxy/services';
|
} from '../../cliproxy/services';
|
||||||
import { DEFAULT_BACKEND } from '../../cliproxy/platform-detector';
|
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(ok(`Variant removed: ${name}`));
|
||||||
console.log('');
|
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