From 6427ecf5af4a9be40f39c2a64bf72ac4e861d349 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Fri, 5 Dec 2025 21:44:41 -0500 Subject: [PATCH] feat(cliproxy): add crud commands for variant profiles - Add `ccs cliproxy create/list/remove` commands for variant management - Interactive wizard with provider/model selection from catalog - Settings file auto-generated with all 6 ANTHROPIC_* env fields - Fix `ccs api create` to include all 4 model fields (was missing 3) - Fix `--config` flag to save to correct variant settings file - Remove paid tier badge from AGY models (all free via Antigravity) - Add settings file format example to README for CLIProxy variants - Add 22 unit tests for cliproxy command validation and config handling --- README.md | 18 + src/cliproxy/cliproxy-executor.ts | 8 +- src/cliproxy/model-catalog.ts | 3 +- src/cliproxy/model-config.ts | 22 +- src/commands/api-command.ts | 4 + src/commands/cliproxy-command.ts | 534 ++++++++++++++++++- tests/unit/cliproxy/model-catalog.test.js | 5 +- tests/unit/commands/cliproxy-command.test.js | 359 +++++++++++++ 8 files changed, 926 insertions(+), 27 deletions(-) create mode 100644 tests/unit/commands/cliproxy-command.test.js diff --git a/README.md b/README.md index c21ba43a..72e656b1 100644 --- a/README.md +++ b/README.md @@ -411,6 +411,24 @@ CCS resolves profiles in priority order: Usage: `ccs flash "quick task"` or `ccs pro "complex analysis"` +Settings file format (`~/.ccs/gemini-flash.settings.json`): + +```json +{ + "env": { + "ANTHROPIC_BASE_URL": "http://127.0.0.1:8317/api/provider/gemini", + "ANTHROPIC_AUTH_TOKEN": "ccs-internal-managed", + "ANTHROPIC_MODEL": "gemini-2.5-flash", + "ANTHROPIC_DEFAULT_OPUS_MODEL": "gemini-2.5-flash", + "ANTHROPIC_DEFAULT_SONNET_MODEL": "gemini-2.5-flash", + "ANTHROPIC_DEFAULT_HAIKU_MODEL": "gemini-2.5-flash" + } +} +``` + +> [!TIP] +> Copy from `~/.ccs/gemini.settings.json` (auto-generated on first `ccs gemini` run) and modify `ANTHROPIC_MODEL` to your desired model. + **Settings-based**: GLM, GLMT, Kimi, default - Uses `--settings` flag pointing to config files - GLMT: Embedded proxy for thinking mode support diff --git a/src/cliproxy/cliproxy-executor.ts b/src/cliproxy/cliproxy-executor.ts index e0c245a8..7977f828 100644 --- a/src/cliproxy/cliproxy-executor.ts +++ b/src/cliproxy/cliproxy-executor.ts @@ -125,8 +125,9 @@ export async function execClaudeWithCLIProxy( const forceConfig = args.includes('--config'); // Handle --config: configure model selection and exit + // Pass customSettingsPath for CLIProxy variants to save to correct file if (forceConfig && supportsModelConfig(provider)) { - await configureProviderModel(provider, true); + await configureProviderModel(provider, true, cfg.customSettingsPath); process.exit(0); } @@ -166,12 +167,13 @@ export async function execClaudeWithCLIProxy( // 4. First-run model configuration (interactive) // For supported providers, prompt user to select model on first run + // Pass customSettingsPath for CLIProxy variants if (supportsModelConfig(provider)) { - await configureProviderModel(provider, false); // false = only if not configured + await configureProviderModel(provider, false, cfg.customSettingsPath); // false = only if not configured } // 5. Check for known broken models and warn user - const currentModel = getCurrentModel(provider); + const currentModel = getCurrentModel(provider, cfg.customSettingsPath); if (currentModel && isModelBroken(provider, currentModel)) { const modelEntry = findModel(provider, currentModel); const issueUrl = getModelIssueUrl(provider, currentModel); diff --git a/src/cliproxy/model-catalog.ts b/src/cliproxy/model-catalog.ts index f6b5af1c..f06edf46 100644 --- a/src/cliproxy/model-catalog.ts +++ b/src/cliproxy/model-catalog.ts @@ -64,8 +64,7 @@ export const MODEL_CATALOG: Partial> = { id: 'gemini-3-pro-preview', name: 'Gemini 3 Pro', - tier: 'paid', - description: 'Google latest, requires paid Google account', + description: 'Google latest model via Antigravity', }, ], }, diff --git a/src/cliproxy/model-config.ts b/src/cliproxy/model-config.ts index 9ae139fb..c9d8e648 100644 --- a/src/cliproxy/model-config.ts +++ b/src/cliproxy/model-config.ts @@ -40,9 +40,16 @@ export function hasUserSettings(provider: CLIProxyProvider): boolean { /** * Get current model from user settings + * @param provider CLIProxy provider + * @param customSettingsPath Optional custom settings path for CLIProxy variants */ -export function getCurrentModel(provider: CLIProxyProvider): string | undefined { - const settingsPath = getProviderSettingsPath(provider); +export function getCurrentModel( + provider: CLIProxyProvider, + customSettingsPath?: string +): string | undefined { + const settingsPath = customSettingsPath + ? customSettingsPath.replace(/^~/, process.env.HOME || process.env.USERPROFILE || '') + : getProviderSettingsPath(provider); if (!fs.existsSync(settingsPath)) return undefined; try { @@ -80,11 +87,13 @@ function formatModelDetailed(model: ModelEntry, isCurrent: boolean): string { * * @param provider CLIProxy provider (agy, gemini) * @param force Force reconfiguration even if settings exist + * @param customSettingsPath Optional custom settings path for CLIProxy variants * @returns true if configuration was performed, false if skipped */ export async function configureProviderModel( provider: CLIProxyProvider, - force: boolean = false + force: boolean = false, + customSettingsPath?: string ): Promise { // Check if provider supports model configuration if (!supportsModelConfig(provider)) { @@ -94,7 +103,10 @@ export async function configureProviderModel( const catalog = getProviderCatalog(provider); if (!catalog) return false; - const settingsPath = getProviderSettingsPath(provider); + // Use custom settings path for CLIProxy variants, otherwise use default provider path + const settingsPath = customSettingsPath + ? customSettingsPath.replace(/^~/, process.env.HOME || process.env.USERPROFILE || '') + : getProviderSettingsPath(provider); // Skip if already configured (unless --config flag) if (!force && fs.existsSync(settingsPath)) { @@ -111,7 +123,7 @@ export async function configureProviderModel( })); // Find default index - use current model if configured, otherwise catalog default - const currentModel = getCurrentModel(provider); + const currentModel = getCurrentModel(provider, customSettingsPath); const targetModel = currentModel || catalog.defaultModel; const defaultIdx = catalog.models.findIndex((m) => m.id === targetModel); const safeDefaultIdx = defaultIdx >= 0 ? defaultIdx : 0; diff --git a/src/commands/api-command.ts b/src/commands/api-command.ts index ab7c9fff..a0c605b8 100644 --- a/src/commands/api-command.ts +++ b/src/commands/api-command.ts @@ -109,6 +109,7 @@ function apiExists(name: string): boolean { /** * Create settings.json file for API profile + * Includes all 4 model fields for proper Claude CLI integration */ function createSettingsFile(name: string, baseUrl: string, apiKey: string, model: string): string { const ccsDir = getCcsDir(); @@ -119,6 +120,9 @@ function createSettingsFile(name: string, baseUrl: string, apiKey: string, model ANTHROPIC_BASE_URL: baseUrl, ANTHROPIC_AUTH_TOKEN: apiKey, ANTHROPIC_MODEL: model, + ANTHROPIC_DEFAULT_OPUS_MODEL: model, + ANTHROPIC_DEFAULT_SONNET_MODEL: model, + ANTHROPIC_DEFAULT_HAIKU_MODEL: model, }, }; diff --git a/src/commands/cliproxy-command.ts b/src/commands/cliproxy-command.ts index f0252d9a..0a8c4fe4 100644 --- a/src/commands/cliproxy-command.ts +++ b/src/commands/cliproxy-command.ts @@ -1,16 +1,21 @@ /** * CLIProxy Command Handler * - * Manages CLIProxyAPI binary installation and version control. - * Allows users to install specific versions or update to latest. + * Manages CLIProxyAPI binary installation and profile variants. * - * Usage: + * Binary commands: * ccs cliproxy Show current version * ccs cliproxy --install Install specific version * ccs cliproxy --latest Install latest version - * ccs cliproxy --help Show help + * + * Profile commands: + * ccs cliproxy create Create CLIProxy variant profile + * ccs cliproxy list List all CLIProxy variant profiles + * ccs cliproxy remove Remove CLIProxy variant profile */ +import * as fs from 'fs'; +import * as path from 'path'; import { getInstalledCliproxyVersion, installCliproxyVersion, @@ -19,27 +24,505 @@ import { getCLIProxyPath, } from '../cliproxy'; import { CLIPROXY_FALLBACK_VERSION } from '../cliproxy/platform-detector'; -import { color, dim, initUI } from '../utils/ui'; +import { CLIPROXY_PROFILES, CLIProxyProfileName } from '../auth/profile-detector'; +import { getCcsDir, getConfigPath, loadConfig } from '../utils/config-manager'; +import { getClaudeEnvVars, CLIPROXY_DEFAULT_PORT } from '../cliproxy/config-generator'; +import { getProviderCatalog, supportsModelConfig, ModelEntry } from '../cliproxy/model-catalog'; +import { CLIProxyProvider } from '../cliproxy/types'; +import { + initUI, + header, + subheader, + color, + dim, + ok, + fail, + warn, + info, + table, + infoBox, +} from '../utils/ui'; +import { InteractivePrompt } from '../utils/prompt'; + +// ============================================================================ +// PROFILE MANAGEMENT +// ============================================================================ + +interface CliproxyProfileArgs { + name?: string; + provider?: CLIProxyProfileName; + model?: string; + force?: boolean; + yes?: boolean; +} + +/** + * Parse command line arguments for profile commands + */ +function parseProfileArgs(args: string[]): CliproxyProfileArgs { + const result: CliproxyProfileArgs = {}; + + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + + if (arg === '--provider' && args[i + 1]) { + result.provider = args[++i] as CLIProxyProfileName; + } else if (arg === '--model' && args[i + 1]) { + result.model = args[++i]; + } else if (arg === '--force') { + result.force = true; + } else if (arg === '--yes' || arg === '-y') { + result.yes = true; + } else if (!arg.startsWith('-') && !result.name) { + result.name = arg; + } + } + + return result; +} + +/** + * Validate CLIProxy profile name + */ +function validateProfileName(name: string): string | null { + if (!name) { + return 'Profile name is required'; + } + if (!/^[a-zA-Z][a-zA-Z0-9._-]*$/.test(name)) { + return 'Name must start with letter, contain only letters, numbers, dot, dash, underscore'; + } + if (name.length > 32) { + return 'Name must be 32 characters or less'; + } + // Reserved names - includes built-in cliproxy profiles + const reserved = [ + 'default', + 'auth', + 'api', + 'doctor', + 'sync', + 'update', + 'help', + 'version', + 'cliproxy', + 'create', + 'list', + 'remove', + ...CLIPROXY_PROFILES, + ]; + if (reserved.includes(name.toLowerCase())) { + return `'${name}' is a reserved name`; + } + return null; +} + +/** + * Check if CLIProxy variant profile exists + */ +function cliproxyVariantExists(name: string): boolean { + try { + const config = loadConfig(); + return !!(config.cliproxy && name in config.cliproxy); + } catch { + return false; + } +} + +/** + * Create settings.json file for CLIProxy variant + * Includes all 6 fields for proper Claude CLI integration + */ +function createCliproxySettingsFile( + name: string, + provider: CLIProxyProfileName, + model: string +): string { + const ccsDir = getCcsDir(); + const settingsPath = path.join(ccsDir, `${provider}-${name}.settings.json`); + + // Get base env vars from provider config + const baseEnv = getClaudeEnvVars(provider as CLIProxyProvider, CLIPROXY_DEFAULT_PORT); + + const settings = { + env: { + ANTHROPIC_BASE_URL: baseEnv.ANTHROPIC_BASE_URL || '', + ANTHROPIC_AUTH_TOKEN: baseEnv.ANTHROPIC_AUTH_TOKEN || '', + ANTHROPIC_MODEL: model, + ANTHROPIC_DEFAULT_OPUS_MODEL: model, + ANTHROPIC_DEFAULT_SONNET_MODEL: model, + ANTHROPIC_DEFAULT_HAIKU_MODEL: baseEnv.ANTHROPIC_DEFAULT_HAIKU_MODEL || model, + }, + }; + + // Ensure directory exists + if (!fs.existsSync(ccsDir)) { + fs.mkdirSync(ccsDir, { recursive: true }); + } + + fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf8'); + return settingsPath; +} + +/** + * Update config.json with new CLIProxy variant + */ +function addCliproxyVariant( + name: string, + provider: CLIProxyProfileName, + settingsPath: string +): void { + const configPath = getConfigPath(); + + // Read existing config or create new + let config: { profiles: Record; cliproxy?: Record }; + try { + config = JSON.parse(fs.readFileSync(configPath, 'utf8')); + } catch { + config = { profiles: {} }; + } + + // Ensure cliproxy section exists + if (!config.cliproxy) { + config.cliproxy = {}; + } + + // Use relative path with ~ for portability + const relativePath = `~/.ccs/${path.basename(settingsPath)}`; + config.cliproxy[name] = { + provider, + settings: relativePath, + }; + + // Write config atomically + const tempPath = configPath + '.tmp'; + fs.writeFileSync(tempPath, JSON.stringify(config, null, 2) + '\n', 'utf8'); + fs.renameSync(tempPath, configPath); +} + +/** + * Remove CLIProxy variant from config.json + */ +function removeCliproxyVariant(name: string): { provider: string; settings: string } | null { + const configPath = getConfigPath(); + + let config: { profiles: Record; cliproxy?: Record }; + try { + config = JSON.parse(fs.readFileSync(configPath, 'utf8')); + } catch { + return null; + } + + if (!config.cliproxy || !(name in config.cliproxy)) { + return null; + } + + const variant = config.cliproxy[name] as { provider: string; settings: string }; + delete config.cliproxy[name]; + + // Clean up empty cliproxy section + if (Object.keys(config.cliproxy).length === 0) { + delete config.cliproxy; + } + + // Write config atomically + const tempPath = configPath + '.tmp'; + fs.writeFileSync(tempPath, JSON.stringify(config, null, 2) + '\n', 'utf8'); + fs.renameSync(tempPath, configPath); + + return variant; +} + +/** + * Format model entry for display + */ +function formatModelOption(model: ModelEntry): string { + const tierBadge = model.tier === 'paid' ? color(' [Paid Tier]', 'warning') : ''; + return `${model.name}${tierBadge}`; +} + +/** + * Handle 'ccs cliproxy create' command + */ +async function handleCreate(args: string[]): Promise { + await initUI(); + const parsedArgs = parseProfileArgs(args); + + console.log(header('Create CLIProxy Variant')); + console.log(''); + + // Step 1: Profile name + let name = parsedArgs.name; + if (!name) { + name = await InteractivePrompt.input('Variant name (e.g., g3, flash, pro)', { + validate: validateProfileName, + }); + } else { + const error = validateProfileName(name); + if (error) { + console.log(fail(error)); + process.exit(1); + } + } + + // Check if exists + if (cliproxyVariantExists(name) && !parsedArgs.force) { + console.log(fail(`Variant '${name}' already exists`)); + console.log(` Use ${color('--force', 'command')} to overwrite`); + process.exit(1); + } + + // Step 2: Provider selection + let provider = parsedArgs.provider; + if (!provider) { + const providerOptions = CLIPROXY_PROFILES.map((p) => ({ + id: p, + label: p.charAt(0).toUpperCase() + p.slice(1), + })); + + provider = (await InteractivePrompt.selectFromList( + 'Select provider:', + providerOptions + )) as CLIProxyProfileName; + } else if (!CLIPROXY_PROFILES.includes(provider)) { + console.log(fail(`Invalid provider: ${provider}`)); + console.log(` Available: ${CLIPROXY_PROFILES.join(', ')}`); + process.exit(1); + } + + // Step 3: Model selection + let model = parsedArgs.model; + if (!model) { + // Check if provider has model catalog for interactive selection + 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('Select model:', modelOptions, { + defaultIndex: defaultIdx >= 0 ? defaultIdx : 0, + }); + } + } + + // Fallback to manual input if no catalog + if (!model) { + model = await InteractivePrompt.input('Model name', { + validate: (val) => (val ? null : 'Model is required'), + }); + } + } + + // Create files + console.log(''); + console.log(info('Creating CLIProxy variant...')); + + try { + const settingsPath = createCliproxySettingsFile(name, provider, model); + addCliproxyVariant(name, provider, settingsPath); + + console.log(''); + console.log( + infoBox( + `Variant: ${name}\n` + + `Provider: ${provider}\n` + + `Model: ${model}\n` + + `Settings: ~/.ccs/${path.basename(settingsPath)}`, + 'CLIProxy Variant Created' + ) + ); + console.log(''); + console.log(header('Usage')); + console.log(` ${color(`ccs ${name} "your prompt"`, 'command')}`); + console.log(''); + console.log(dim('To change model later:')); + console.log(` ${color(`ccs ${name} --config`, 'command')}`); + console.log(''); + } catch (error) { + console.log(fail(`Failed to create variant: ${(error as Error).message}`)); + process.exit(1); + } +} + +/** + * Handle 'ccs cliproxy list' command + */ +async function handleList(): Promise { + await initUI(); + + console.log(header('CLIProxy Variants')); + console.log(''); + + try { + const config = loadConfig(); + const variants = config.cliproxy || {}; + const variantNames = Object.keys(variants); + + if (variantNames.length === 0) { + console.log(warn('No CLIProxy variants configured')); + console.log(''); + console.log('Built-in CLIProxy profiles:'); + CLIPROXY_PROFILES.forEach((p) => console.log(` ${color(p, 'command')}`)); + console.log(''); + console.log('To create a custom variant:'); + console.log(` ${color('ccs cliproxy create', 'command')}`); + console.log(''); + return; + } + + // Build table data + const rows: string[][] = variantNames.map((name) => { + const variant = variants[name] as { provider: string; settings: string }; + return [name, variant.provider, variant.settings]; + }); + + // Print table + console.log( + table(rows, { + head: ['Variant', 'Provider', 'Settings'], + colWidths: [15, 12, 35], + }) + ); + console.log(''); + + // Show built-in profiles + console.log(subheader('Built-in Profiles')); + console.log(` ${CLIPROXY_PROFILES.join(', ')}`); + console.log(''); + + console.log(dim(`Total: ${variantNames.length} custom variant(s)`)); + console.log(''); + } catch (error) { + console.log(fail(`Failed to list variants: ${(error as Error).message}`)); + process.exit(1); + } +} + +/** + * Handle 'ccs cliproxy remove' command + */ +async function handleRemove(args: string[]): Promise { + await initUI(); + const parsedArgs = parseProfileArgs(args); + + // Load config to get available variants + let config: { profiles: Record; cliproxy?: Record }; + try { + config = loadConfig(); + } catch { + console.log(fail('Failed to load config')); + process.exit(1); + } + + const variants = config.cliproxy || {}; + const variantNames = Object.keys(variants); + + if (variantNames.length === 0) { + console.log(warn('No CLIProxy variants to remove')); + process.exit(0); + } + + // Interactive selection if not provided + let name = parsedArgs.name; + if (!name) { + console.log(header('Remove CLIProxy Variant')); + console.log(''); + console.log('Available variants:'); + variantNames.forEach((n, i) => { + const v = variants[n] as { provider: string }; + console.log(` ${i + 1}. ${n} (${v.provider})`); + }); + console.log(''); + + name = await InteractivePrompt.input('Variant name to remove', { + validate: (val) => { + if (!val) return 'Variant name is required'; + if (!variantNames.includes(val)) return `Variant '${val}' not found`; + return null; + }, + }); + } + + if (!(name in variants)) { + 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] as { provider: string; settings: string }; + + // Confirm deletion + console.log(''); + console.log(`Variant '${color(name, 'command')}' will be removed.`); + console.log(` Provider: ${variant.provider}`); + console.log(` Settings: ${variant.settings}`); + console.log(''); + + const confirmed = + parsedArgs.yes || (await InteractivePrompt.confirm('Delete this variant?', { default: false })); + + if (!confirmed) { + console.log(info('Cancelled')); + process.exit(0); + } + + // Remove from config + const removed = removeCliproxyVariant(name); + if (!removed) { + console.log(fail('Failed to remove variant from config')); + process.exit(1); + } + + // Remove settings file if it exists + const settingsFile = removed.settings.replace(/^~/, process.env.HOME || ''); + if (fs.existsSync(settingsFile)) { + fs.unlinkSync(settingsFile); + } + + console.log(ok(`Variant removed: ${name}`)); + console.log(''); +} + +// ============================================================================ +// BINARY MANAGEMENT +// ============================================================================ /** * Show cliproxy command help */ function showHelp(): void { console.log(''); - console.log('Usage: ccs cliproxy [options]'); + console.log('Usage: ccs cliproxy [options]'); console.log(''); - console.log('Manage CLIProxyAPI binary installation.'); + console.log('Manage CLIProxy variants and binary installation.'); console.log(''); - console.log('Options:'); - console.log(' --install Install a specific version (e.g., 6.5.40)'); - console.log(' --latest Install the latest version from GitHub'); - console.log(' --verbose, -v Enable verbose output'); - console.log(' --help, -h Show this help message'); + console.log('Profile Commands:'); + console.log(' create [name] Create new CLIProxy variant profile'); + console.log(' list List all CLIProxy variant profiles'); + console.log(' remove Remove a CLIProxy variant profile'); + console.log(''); + console.log('Binary Commands:'); + console.log(' --install Install a specific binary version'); + console.log(' --latest Install the latest binary version'); + console.log(''); + console.log('Create Options:'); + console.log(' --provider Provider (gemini, codex, agy, qwen)'); + console.log(' --model Model name'); + console.log(' --force Overwrite existing variant'); + console.log(' --yes, -y Skip confirmation prompts'); console.log(''); console.log('Examples:'); - console.log(' ccs cliproxy Show current installed version'); - console.log(' ccs cliproxy --install 6.5.38 Install version 6.5.38'); - console.log(' ccs cliproxy --latest Update to latest version'); + console.log(' ccs cliproxy create Interactive wizard'); + console.log(' ccs cliproxy create g3 --provider gemini --model gemini-3-pro-preview'); + console.log(' ccs cliproxy list Show all variants'); + console.log(' ccs cliproxy remove g3 Remove variant'); + console.log(' ccs cliproxy --latest Update binary'); console.log(''); console.log('Notes:'); console.log(` Default fallback version: ${CLIPROXY_FALLBACK_VERSION}`); @@ -161,11 +644,16 @@ async function installLatest(verbose: boolean): Promise { } } +// ============================================================================ +// MAIN ROUTER +// ============================================================================ + /** * Main cliproxy command handler */ export async function handleCliproxyCommand(args: string[]): Promise { const verbose = args.includes('--verbose') || args.includes('-v'); + const command = args[0]; // Handle --help if (args.includes('--help') || args.includes('-h')) { @@ -173,6 +661,22 @@ export async function handleCliproxyCommand(args: string[]): Promise { return; } + // Handle profile commands + if (command === 'create') { + await handleCreate(args.slice(1)); + return; + } + + if (command === 'list' || command === 'ls') { + await handleList(); + return; + } + + if (command === 'remove' || command === 'delete' || command === 'rm') { + await handleRemove(args.slice(1)); + return; + } + // Handle --install const installIdx = args.indexOf('--install'); if (installIdx !== -1) { diff --git a/tests/unit/cliproxy/model-catalog.test.js b/tests/unit/cliproxy/model-catalog.test.js index 2a3c67d5..f90fae31 100644 --- a/tests/unit/cliproxy/model-catalog.test.js +++ b/tests/unit/cliproxy/model-catalog.test.js @@ -61,12 +61,13 @@ describe('Model Catalog', () => { assert.strictEqual(sonnet.name, 'Claude Sonnet 4.5'); }); - it('includes Gemini 3 Pro with paid tier', () => { + it('includes Gemini 3 Pro (free via Antigravity)', () => { const { MODEL_CATALOG } = modelCatalog; const gem3 = MODEL_CATALOG.agy.models.find((m) => m.id === 'gemini-3-pro-preview'); assert(gem3, 'Should include Gemini 3 Pro'); assert.strictEqual(gem3.name, 'Gemini 3 Pro'); - assert.strictEqual(gem3.tier, 'paid'); + // AGY models are all free - no paid tier + assert.strictEqual(gem3.tier, undefined, 'AGY models should not have paid tier'); }); it('has 4 models total', () => { diff --git a/tests/unit/commands/cliproxy-command.test.js b/tests/unit/commands/cliproxy-command.test.js new file mode 100644 index 00000000..a92dea23 --- /dev/null +++ b/tests/unit/commands/cliproxy-command.test.js @@ -0,0 +1,359 @@ +/** + * Tests for CLIProxy Command - Profile Management + * + * Tests the CRUD operations for CLIProxy variant profiles: + * - create: Creates new variant with provider + model + * - list: Lists all custom variants + * - remove: Removes variant and settings file + */ + +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); +const os = require('os'); + +describe('CLIProxy Command - Profile Management', () => { + // Test fixtures directory + let testDir; + let testConfigPath; + let testCcsDir; + + beforeEach(() => { + // Create isolated test directory + testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-cliproxy-test-')); + testCcsDir = path.join(testDir, '.ccs'); + testConfigPath = path.join(testCcsDir, 'config.json'); + fs.mkdirSync(testCcsDir, { recursive: true }); + }); + + afterEach(() => { + // Cleanup test directory + if (testDir && fs.existsSync(testDir)) { + fs.rmSync(testDir, { recursive: true, force: true }); + } + }); + + describe('validateProfileName', () => { + // Import validation logic pattern from source + const validateProfileName = (name) => { + if (!name) return 'Profile name is required'; + if (!/^[a-zA-Z][a-zA-Z0-9._-]*$/.test(name)) { + return 'Name must start with letter, contain only letters, numbers, dot, dash, underscore'; + } + if (name.length > 32) return 'Name must be 32 characters or less'; + const reserved = [ + 'default', 'auth', 'api', 'doctor', 'sync', 'update', 'help', 'version', + 'cliproxy', 'create', 'list', 'remove', 'gemini', 'codex', 'agy', 'qwen' + ]; + if (reserved.includes(name.toLowerCase())) return `'${name}' is a reserved name`; + return null; + }; + + it('rejects empty name', () => { + assert.strictEqual(validateProfileName(''), 'Profile name is required'); + assert.strictEqual(validateProfileName(null), 'Profile name is required'); + assert.strictEqual(validateProfileName(undefined), 'Profile name is required'); + }); + + it('rejects names starting with number', () => { + const result = validateProfileName('3test'); + assert(result !== null, 'Should reject name starting with number'); + assert(result.includes('start with letter')); + }); + + it('rejects names starting with special character', () => { + assert(validateProfileName('-test') !== null); + assert(validateProfileName('_test') !== null); + assert(validateProfileName('.test') !== null); + }); + + it('accepts valid names', () => { + assert.strictEqual(validateProfileName('g3'), null); + assert.strictEqual(validateProfileName('flash'), null); + assert.strictEqual(validateProfileName('pro-v2'), null); + assert.strictEqual(validateProfileName('test_model'), null); + assert.strictEqual(validateProfileName('MyModel.v1'), null); + }); + + it('rejects names over 32 characters', () => { + const longName = 'a'.repeat(33); + const result = validateProfileName(longName); + assert(result !== null); + assert(result.includes('32 characters')); + }); + + it('accepts names exactly 32 characters', () => { + const exactName = 'a'.repeat(32); + assert.strictEqual(validateProfileName(exactName), null); + }); + + it('rejects reserved names (case insensitive)', () => { + const reserved = ['gemini', 'GEMINI', 'Gemini', 'codex', 'agy', 'qwen', 'default', 'cliproxy']; + reserved.forEach((name) => { + const result = validateProfileName(name); + assert(result !== null, `Should reject reserved name: ${name}`); + assert(result.includes('reserved'), `Error should mention reserved: ${name}`); + }); + }); + + it('rejects command names as profile names', () => { + const commands = ['create', 'list', 'remove', 'help', 'version']; + commands.forEach((name) => { + const result = validateProfileName(name); + assert(result !== null, `Should reject command name: ${name}`); + }); + }); + }); + + describe('Settings File Format', () => { + it('creates settings with all 6 required env fields', () => { + // Simulate what createCliproxySettingsFile produces + const model = 'gemini-3-pro-preview'; + const settings = { + env: { + ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/gemini', + ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed', + ANTHROPIC_MODEL: model, + ANTHROPIC_DEFAULT_OPUS_MODEL: model, + ANTHROPIC_DEFAULT_SONNET_MODEL: model, + ANTHROPIC_DEFAULT_HAIKU_MODEL: model, + }, + }; + + // Verify all required fields present + assert(settings.env.ANTHROPIC_BASE_URL, 'Missing ANTHROPIC_BASE_URL'); + assert(settings.env.ANTHROPIC_AUTH_TOKEN, 'Missing ANTHROPIC_AUTH_TOKEN'); + assert(settings.env.ANTHROPIC_MODEL, 'Missing ANTHROPIC_MODEL'); + assert(settings.env.ANTHROPIC_DEFAULT_OPUS_MODEL, 'Missing ANTHROPIC_DEFAULT_OPUS_MODEL'); + assert(settings.env.ANTHROPIC_DEFAULT_SONNET_MODEL, 'Missing ANTHROPIC_DEFAULT_SONNET_MODEL'); + assert(settings.env.ANTHROPIC_DEFAULT_HAIKU_MODEL, 'Missing ANTHROPIC_DEFAULT_HAIKU_MODEL'); + + // Verify model applied to all 4 model fields + assert.strictEqual(settings.env.ANTHROPIC_MODEL, model); + assert.strictEqual(settings.env.ANTHROPIC_DEFAULT_OPUS_MODEL, model); + assert.strictEqual(settings.env.ANTHROPIC_DEFAULT_SONNET_MODEL, model); + assert.strictEqual(settings.env.ANTHROPIC_DEFAULT_HAIKU_MODEL, model); + }); + + it('uses correct provider endpoint in BASE_URL', () => { + const providers = ['gemini', 'codex', 'agy', 'qwen']; + providers.forEach((provider) => { + const expectedUrl = `http://127.0.0.1:8317/api/provider/${provider}`; + assert(expectedUrl.includes(provider), `URL should contain provider: ${provider}`); + assert(expectedUrl.startsWith('http://127.0.0.1:8317'), 'Should use localhost:8317'); + }); + }); + + it('settings file is valid JSON', () => { + const settingsPath = path.join(testCcsDir, 'gemini-test.settings.json'); + const settings = { + env: { + ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/gemini', + ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed', + ANTHROPIC_MODEL: 'gemini-3-pro-preview', + ANTHROPIC_DEFAULT_OPUS_MODEL: 'gemini-3-pro-preview', + ANTHROPIC_DEFAULT_SONNET_MODEL: 'gemini-3-pro-preview', + ANTHROPIC_DEFAULT_HAIKU_MODEL: 'gemini-3-pro-preview', + }, + }; + + fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n'); + + // Verify it can be read back + const readBack = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); + assert.deepStrictEqual(readBack, settings); + }); + }); + + describe('Config.json CLIProxy Section', () => { + it('adds cliproxy section when creating first variant', () => { + const config = { profiles: {} }; + + // Simulate adding variant + if (!config.cliproxy) config.cliproxy = {}; + config.cliproxy['g3'] = { + provider: 'gemini', + settings: '~/.ccs/gemini-g3.settings.json', + }; + + assert(config.cliproxy, 'cliproxy section should exist'); + assert(config.cliproxy.g3, 'g3 variant should exist'); + assert.strictEqual(config.cliproxy.g3.provider, 'gemini'); + }); + + it('preserves existing profiles when adding variant', () => { + const config = { + profiles: { + glm: '~/.ccs/glm.settings.json', + kimi: '~/.ccs/kimi.settings.json', + }, + }; + + // Simulate adding variant + if (!config.cliproxy) config.cliproxy = {}; + config.cliproxy['flash'] = { + provider: 'gemini', + settings: '~/.ccs/gemini-flash.settings.json', + }; + + // Verify profiles preserved + assert.strictEqual(Object.keys(config.profiles).length, 2); + assert(config.profiles.glm); + assert(config.profiles.kimi); + // Verify variant added + assert(config.cliproxy.flash); + }); + + it('removes cliproxy section when last variant removed', () => { + const config = { + profiles: { glm: '~/.ccs/glm.settings.json' }, + cliproxy: { + g3: { provider: 'gemini', settings: '~/.ccs/gemini-g3.settings.json' }, + }, + }; + + // Simulate removing last variant + delete config.cliproxy.g3; + if (Object.keys(config.cliproxy).length === 0) { + delete config.cliproxy; + } + + assert(!config.cliproxy, 'cliproxy section should be removed when empty'); + assert(config.profiles.glm, 'profiles should be preserved'); + }); + + it('keeps cliproxy section when other variants exist', () => { + const config = { + profiles: {}, + cliproxy: { + g3: { provider: 'gemini', settings: '~/.ccs/gemini-g3.settings.json' }, + flash: { provider: 'gemini', settings: '~/.ccs/gemini-flash.settings.json' }, + }, + }; + + // Simulate removing one variant + delete config.cliproxy.flash; + if (Object.keys(config.cliproxy).length === 0) { + delete config.cliproxy; + } + + assert(config.cliproxy, 'cliproxy section should remain'); + assert(config.cliproxy.g3, 'g3 should still exist'); + assert(!config.cliproxy.flash, 'flash should be removed'); + }); + + it('uses tilde (~) path format for portability', () => { + const variant = { + provider: 'gemini', + settings: '~/.ccs/gemini-g3.settings.json', + }; + + assert(variant.settings.startsWith('~/'), 'Path should start with ~/'); + assert(variant.settings.includes('.ccs'), 'Path should include .ccs'); + }); + }); + + describe('Provider Validation', () => { + const validProviders = ['gemini', 'codex', 'agy', 'qwen']; + + it('accepts all valid providers', () => { + validProviders.forEach((provider) => { + assert(validProviders.includes(provider), `${provider} should be valid`); + }); + }); + + it('rejects invalid providers', () => { + const invalidProviders = ['openai', 'anthropic', 'claude', 'gpt', 'invalid']; + invalidProviders.forEach((provider) => { + assert(!validProviders.includes(provider), `${provider} should be invalid`); + }); + }); + }); + + describe('Atomic Config Writes', () => { + it('uses temp file + rename pattern for safety', () => { + const configPath = testConfigPath; + const tempPath = configPath + '.tmp'; + const config = { profiles: {}, cliproxy: { test: { provider: 'gemini', settings: '~/.ccs/test.json' } } }; + + // Write to temp + fs.writeFileSync(tempPath, JSON.stringify(config, null, 2) + '\n'); + assert(fs.existsSync(tempPath), 'Temp file should exist'); + + // Rename (atomic on most filesystems) + fs.renameSync(tempPath, configPath); + assert(fs.existsSync(configPath), 'Config should exist after rename'); + assert(!fs.existsSync(tempPath), 'Temp should not exist after rename'); + + // Verify content + const readBack = JSON.parse(fs.readFileSync(configPath, 'utf8')); + assert.deepStrictEqual(readBack, config); + }); + }); + + describe('Settings File Naming Convention', () => { + it('uses provider-name.settings.json format', () => { + const testCases = [ + { provider: 'gemini', name: 'g3', expected: 'gemini-g3.settings.json' }, + { provider: 'gemini', name: 'flash', expected: 'gemini-flash.settings.json' }, + { provider: 'codex', name: 'turbo', expected: 'codex-turbo.settings.json' }, + { provider: 'agy', name: 'opus', expected: 'agy-opus.settings.json' }, + ]; + + testCases.forEach(({ provider, name, expected }) => { + const filename = `${provider}-${name}.settings.json`; + assert.strictEqual(filename, expected, `Wrong filename for ${provider}/${name}`); + }); + }); + }); +}); + +describe('API Command - Model Fields Fix', () => { + describe('createSettingsFile', () => { + it('includes all 4 model fields', () => { + // Simulate what createSettingsFile now produces + const model = 'claude-sonnet-4-5-20250929'; + const settings = { + env: { + ANTHROPIC_BASE_URL: 'https://api.example.com', + ANTHROPIC_AUTH_TOKEN: 'test-key', + ANTHROPIC_MODEL: model, + ANTHROPIC_DEFAULT_OPUS_MODEL: model, + ANTHROPIC_DEFAULT_SONNET_MODEL: model, + ANTHROPIC_DEFAULT_HAIKU_MODEL: model, + }, + }; + + // Verify all 4 model fields present and equal + const modelFields = [ + 'ANTHROPIC_MODEL', + 'ANTHROPIC_DEFAULT_OPUS_MODEL', + 'ANTHROPIC_DEFAULT_SONNET_MODEL', + 'ANTHROPIC_DEFAULT_HAIKU_MODEL', + ]; + + modelFields.forEach((field) => { + assert(settings.env[field], `Missing ${field}`); + assert.strictEqual(settings.env[field], model, `${field} should equal model`); + }); + }); + + it('applies single model input to all 4 fields', () => { + const inputModel = 'custom-model-v1'; + + // This is the fix: single model applied to all 4 fields + const env = { + ANTHROPIC_MODEL: inputModel, + ANTHROPIC_DEFAULT_OPUS_MODEL: inputModel, + ANTHROPIC_DEFAULT_SONNET_MODEL: inputModel, + ANTHROPIC_DEFAULT_HAIKU_MODEL: inputModel, + }; + + // All should be identical + assert.strictEqual(env.ANTHROPIC_MODEL, inputModel); + assert.strictEqual(env.ANTHROPIC_DEFAULT_OPUS_MODEL, inputModel); + assert.strictEqual(env.ANTHROPIC_DEFAULT_SONNET_MODEL, inputModel); + assert.strictEqual(env.ANTHROPIC_DEFAULT_HAIKU_MODEL, inputModel); + }); + }); +});