mirror of
https://github.com/tiennm99/ccs.git
synced 2026-09-02 12:19:35 +00:00
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
This commit is contained in:
@@ -411,6 +411,24 @@ CCS resolves profiles in priority order:
|
|||||||
|
|
||||||
Usage: `ccs flash "quick task"` or `ccs pro "complex analysis"`
|
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
|
**Settings-based**: GLM, GLMT, Kimi, default
|
||||||
- Uses `--settings` flag pointing to config files
|
- Uses `--settings` flag pointing to config files
|
||||||
- GLMT: Embedded proxy for thinking mode support
|
- GLMT: Embedded proxy for thinking mode support
|
||||||
|
|||||||
@@ -125,8 +125,9 @@ export async function execClaudeWithCLIProxy(
|
|||||||
const forceConfig = args.includes('--config');
|
const forceConfig = args.includes('--config');
|
||||||
|
|
||||||
// Handle --config: configure model selection and exit
|
// Handle --config: configure model selection and exit
|
||||||
|
// Pass customSettingsPath for CLIProxy variants to save to correct file
|
||||||
if (forceConfig && supportsModelConfig(provider)) {
|
if (forceConfig && supportsModelConfig(provider)) {
|
||||||
await configureProviderModel(provider, true);
|
await configureProviderModel(provider, true, cfg.customSettingsPath);
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -166,12 +167,13 @@ export async function execClaudeWithCLIProxy(
|
|||||||
|
|
||||||
// 4. First-run model configuration (interactive)
|
// 4. First-run model configuration (interactive)
|
||||||
// For supported providers, prompt user to select model on first run
|
// For supported providers, prompt user to select model on first run
|
||||||
|
// Pass customSettingsPath for CLIProxy variants
|
||||||
if (supportsModelConfig(provider)) {
|
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
|
// 5. Check for known broken models and warn user
|
||||||
const currentModel = getCurrentModel(provider);
|
const currentModel = getCurrentModel(provider, cfg.customSettingsPath);
|
||||||
if (currentModel && isModelBroken(provider, currentModel)) {
|
if (currentModel && isModelBroken(provider, currentModel)) {
|
||||||
const modelEntry = findModel(provider, currentModel);
|
const modelEntry = findModel(provider, currentModel);
|
||||||
const issueUrl = getModelIssueUrl(provider, currentModel);
|
const issueUrl = getModelIssueUrl(provider, currentModel);
|
||||||
|
|||||||
@@ -64,8 +64,7 @@ export const MODEL_CATALOG: Partial<Record<CLIProxyProvider, ProviderCatalog>> =
|
|||||||
{
|
{
|
||||||
id: 'gemini-3-pro-preview',
|
id: 'gemini-3-pro-preview',
|
||||||
name: 'Gemini 3 Pro',
|
name: 'Gemini 3 Pro',
|
||||||
tier: 'paid',
|
description: 'Google latest model via Antigravity',
|
||||||
description: 'Google latest, requires paid Google account',
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -40,9 +40,16 @@ export function hasUserSettings(provider: CLIProxyProvider): boolean {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Get current model from user settings
|
* 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 {
|
export function getCurrentModel(
|
||||||
const settingsPath = getProviderSettingsPath(provider);
|
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;
|
if (!fs.existsSync(settingsPath)) return undefined;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -80,11 +87,13 @@ function formatModelDetailed(model: ModelEntry, isCurrent: boolean): string {
|
|||||||
*
|
*
|
||||||
* @param provider CLIProxy provider (agy, gemini)
|
* @param provider CLIProxy provider (agy, gemini)
|
||||||
* @param force Force reconfiguration even if settings exist
|
* @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
|
* @returns true if configuration was performed, false if skipped
|
||||||
*/
|
*/
|
||||||
export async function configureProviderModel(
|
export async function configureProviderModel(
|
||||||
provider: CLIProxyProvider,
|
provider: CLIProxyProvider,
|
||||||
force: boolean = false
|
force: boolean = false,
|
||||||
|
customSettingsPath?: string
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
// Check if provider supports model configuration
|
// Check if provider supports model configuration
|
||||||
if (!supportsModelConfig(provider)) {
|
if (!supportsModelConfig(provider)) {
|
||||||
@@ -94,7 +103,10 @@ export async function configureProviderModel(
|
|||||||
const catalog = getProviderCatalog(provider);
|
const catalog = getProviderCatalog(provider);
|
||||||
if (!catalog) return false;
|
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)
|
// Skip if already configured (unless --config flag)
|
||||||
if (!force && fs.existsSync(settingsPath)) {
|
if (!force && fs.existsSync(settingsPath)) {
|
||||||
@@ -111,7 +123,7 @@ export async function configureProviderModel(
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
// Find default index - use current model if configured, otherwise catalog default
|
// 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 targetModel = currentModel || catalog.defaultModel;
|
||||||
const defaultIdx = catalog.models.findIndex((m) => m.id === targetModel);
|
const defaultIdx = catalog.models.findIndex((m) => m.id === targetModel);
|
||||||
const safeDefaultIdx = defaultIdx >= 0 ? defaultIdx : 0;
|
const safeDefaultIdx = defaultIdx >= 0 ? defaultIdx : 0;
|
||||||
|
|||||||
@@ -109,6 +109,7 @@ function apiExists(name: string): boolean {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Create settings.json file for API profile
|
* 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 {
|
function createSettingsFile(name: string, baseUrl: string, apiKey: string, model: string): string {
|
||||||
const ccsDir = getCcsDir();
|
const ccsDir = getCcsDir();
|
||||||
@@ -119,6 +120,9 @@ function createSettingsFile(name: string, baseUrl: string, apiKey: string, model
|
|||||||
ANTHROPIC_BASE_URL: baseUrl,
|
ANTHROPIC_BASE_URL: baseUrl,
|
||||||
ANTHROPIC_AUTH_TOKEN: apiKey,
|
ANTHROPIC_AUTH_TOKEN: apiKey,
|
||||||
ANTHROPIC_MODEL: model,
|
ANTHROPIC_MODEL: model,
|
||||||
|
ANTHROPIC_DEFAULT_OPUS_MODEL: model,
|
||||||
|
ANTHROPIC_DEFAULT_SONNET_MODEL: model,
|
||||||
|
ANTHROPIC_DEFAULT_HAIKU_MODEL: model,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +1,21 @@
|
|||||||
/**
|
/**
|
||||||
* CLIProxy Command Handler
|
* CLIProxy Command Handler
|
||||||
*
|
*
|
||||||
* Manages CLIProxyAPI binary installation and version control.
|
* Manages CLIProxyAPI binary installation and profile variants.
|
||||||
* Allows users to install specific versions or update to latest.
|
|
||||||
*
|
*
|
||||||
* Usage:
|
* Binary commands:
|
||||||
* ccs cliproxy Show current version
|
* ccs cliproxy Show current version
|
||||||
* ccs cliproxy --install <ver> Install specific version
|
* ccs cliproxy --install <ver> Install specific version
|
||||||
* ccs cliproxy --latest Install latest version
|
* ccs cliproxy --latest Install latest version
|
||||||
* ccs cliproxy --help Show help
|
*
|
||||||
|
* Profile commands:
|
||||||
|
* ccs cliproxy create <name> Create CLIProxy variant profile
|
||||||
|
* ccs cliproxy list List all CLIProxy variant profiles
|
||||||
|
* ccs cliproxy remove <name> Remove CLIProxy variant profile
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
import {
|
import {
|
||||||
getInstalledCliproxyVersion,
|
getInstalledCliproxyVersion,
|
||||||
installCliproxyVersion,
|
installCliproxyVersion,
|
||||||
@@ -19,27 +24,505 @@ import {
|
|||||||
getCLIProxyPath,
|
getCLIProxyPath,
|
||||||
} from '../cliproxy';
|
} from '../cliproxy';
|
||||||
import { CLIPROXY_FALLBACK_VERSION } from '../cliproxy/platform-detector';
|
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<string, string>; cliproxy?: Record<string, unknown> };
|
||||||
|
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<string, string>; cliproxy?: Record<string, unknown> };
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
await initUI();
|
||||||
|
const parsedArgs = parseProfileArgs(args);
|
||||||
|
|
||||||
|
// Load config to get available variants
|
||||||
|
let config: { profiles: Record<string, string>; cliproxy?: Record<string, unknown> };
|
||||||
|
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
|
* Show cliproxy command help
|
||||||
*/
|
*/
|
||||||
function showHelp(): void {
|
function showHelp(): void {
|
||||||
console.log('');
|
console.log('');
|
||||||
console.log('Usage: ccs cliproxy [options]');
|
console.log('Usage: ccs cliproxy <command> [options]');
|
||||||
console.log('');
|
console.log('');
|
||||||
console.log('Manage CLIProxyAPI binary installation.');
|
console.log('Manage CLIProxy variants and binary installation.');
|
||||||
console.log('');
|
console.log('');
|
||||||
console.log('Options:');
|
console.log('Profile Commands:');
|
||||||
console.log(' --install <version> Install a specific version (e.g., 6.5.40)');
|
console.log(' create [name] Create new CLIProxy variant profile');
|
||||||
console.log(' --latest Install the latest version from GitHub');
|
console.log(' list List all CLIProxy variant profiles');
|
||||||
console.log(' --verbose, -v Enable verbose output');
|
console.log(' remove <name> Remove a CLIProxy variant profile');
|
||||||
console.log(' --help, -h Show this help message');
|
console.log('');
|
||||||
|
console.log('Binary Commands:');
|
||||||
|
console.log(' --install <version> Install a specific binary version');
|
||||||
|
console.log(' --latest Install the latest binary version');
|
||||||
|
console.log('');
|
||||||
|
console.log('Create Options:');
|
||||||
|
console.log(' --provider <name> Provider (gemini, codex, agy, qwen)');
|
||||||
|
console.log(' --model <model> Model name');
|
||||||
|
console.log(' --force Overwrite existing variant');
|
||||||
|
console.log(' --yes, -y Skip confirmation prompts');
|
||||||
console.log('');
|
console.log('');
|
||||||
console.log('Examples:');
|
console.log('Examples:');
|
||||||
console.log(' ccs cliproxy Show current installed version');
|
console.log(' ccs cliproxy create Interactive wizard');
|
||||||
console.log(' ccs cliproxy --install 6.5.38 Install version 6.5.38');
|
console.log(' ccs cliproxy create g3 --provider gemini --model gemini-3-pro-preview');
|
||||||
console.log(' ccs cliproxy --latest Update to latest version');
|
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('');
|
||||||
console.log('Notes:');
|
console.log('Notes:');
|
||||||
console.log(` Default fallback version: ${CLIPROXY_FALLBACK_VERSION}`);
|
console.log(` Default fallback version: ${CLIPROXY_FALLBACK_VERSION}`);
|
||||||
@@ -161,11 +644,16 @@ async function installLatest(verbose: boolean): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// MAIN ROUTER
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Main cliproxy command handler
|
* Main cliproxy command handler
|
||||||
*/
|
*/
|
||||||
export async function handleCliproxyCommand(args: string[]): Promise<void> {
|
export async function handleCliproxyCommand(args: string[]): Promise<void> {
|
||||||
const verbose = args.includes('--verbose') || args.includes('-v');
|
const verbose = args.includes('--verbose') || args.includes('-v');
|
||||||
|
const command = args[0];
|
||||||
|
|
||||||
// Handle --help
|
// Handle --help
|
||||||
if (args.includes('--help') || args.includes('-h')) {
|
if (args.includes('--help') || args.includes('-h')) {
|
||||||
@@ -173,6 +661,22 @@ export async function handleCliproxyCommand(args: string[]): Promise<void> {
|
|||||||
return;
|
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 <version>
|
// Handle --install <version>
|
||||||
const installIdx = args.indexOf('--install');
|
const installIdx = args.indexOf('--install');
|
||||||
if (installIdx !== -1) {
|
if (installIdx !== -1) {
|
||||||
|
|||||||
@@ -61,12 +61,13 @@ describe('Model Catalog', () => {
|
|||||||
assert.strictEqual(sonnet.name, 'Claude Sonnet 4.5');
|
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 { MODEL_CATALOG } = modelCatalog;
|
||||||
const gem3 = MODEL_CATALOG.agy.models.find((m) => m.id === 'gemini-3-pro-preview');
|
const gem3 = MODEL_CATALOG.agy.models.find((m) => m.id === 'gemini-3-pro-preview');
|
||||||
assert(gem3, 'Should include Gemini 3 Pro');
|
assert(gem3, 'Should include Gemini 3 Pro');
|
||||||
assert.strictEqual(gem3.name, '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', () => {
|
it('has 4 models total', () => {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user