mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 12:15:57 +00:00
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
This commit is contained in:
@@ -43,8 +43,9 @@ export async function handleList(): Promise<void> {
|
||||
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] })
|
||||
|
||||
@@ -26,6 +26,7 @@ export async function showHelp(): Promise<void> {
|
||||
'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 <name>', 'Remove a CLIProxy variant profile'],
|
||||
],
|
||||
|
||||
@@ -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<CompositeTierConfig | null> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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}`);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user