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:
Tam Nhu Tran
2026-02-12 04:09:19 +07:00
parent 44b30b8753
commit 4926c200f2
3 changed files with 158 additions and 3 deletions
+2 -1
View File
@@ -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] })
+1
View File
@@ -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'],
],
+155 -2
View File
@@ -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}`);
}