Merge pull request #54 from kaitranntt/dev

fix(cliproxy): model config improvements and fallback version update
This commit is contained in:
Kai (Tam Nhu) Tran
2025-12-04 06:37:45 -05:00
committed by GitHub
21 changed files with 1627 additions and 30 deletions
+102
View File
@@ -0,0 +1,102 @@
name: Dev Release
on:
push:
branches: [dev]
jobs:
release:
# Skip if commit message contains [skip ci] or is a release commit
if: "!contains(github.event.head_commit.message, '[skip ci]')"
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
token: ${{ secrets.PAT_TOKEN }}
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
registry-url: 'https://registry.npmjs.org'
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Build and validate
run: |
bun run build
bun run validate
- name: Bump dev version
id: bump
run: |
CURRENT=$(cat VERSION)
PKG_NAME=$(jq -r '.name' package.json)
# Extract base version
if [[ "$CURRENT" =~ ^([0-9]+\.[0-9]+\.[0-9]+)(-dev\.([0-9]+))?$ ]]; then
BASE="${BASH_REMATCH[1]}"
else
echo "Invalid version format: $CURRENT"
exit 1
fi
# Find highest published dev version for this base
LATEST_DEV=$(npm view "${PKG_NAME}" versions --json 2>/dev/null | \
jq -r '.[]' | \
grep "^${BASE}-dev\." | \
sed "s/${BASE}-dev\.//" | \
sort -n | \
tail -1)
if [[ -z "$LATEST_DEV" ]]; then
NEW_DEV=1
else
NEW_DEV=$((LATEST_DEV + 1))
fi
NEW_VERSION="${BASE}-dev.${NEW_DEV}"
echo "current=$CURRENT" >> $GITHUB_OUTPUT
echo "new=$NEW_VERSION" >> $GITHUB_OUTPUT
echo "Bumping: $CURRENT -> $NEW_VERSION"
- name: Update version files
run: |
NEW_VERSION="${{ steps.bump.outputs.new }}"
# Update VERSION file
echo "$NEW_VERSION" > VERSION
# Update package.json
jq --arg v "$NEW_VERSION" '.version = $v' package.json > package.json.tmp
mv package.json.tmp package.json
# Update installers
sed -i "s/^VERSION=.*/VERSION=\"$NEW_VERSION\"/" installers/install.sh
sed -i "s/^\$Version = .*/\$Version = \"$NEW_VERSION\"/" installers/install.ps1
- name: Publish to npm
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: npm publish --tag dev
- name: Commit version bump
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add VERSION package.json installers/install.sh installers/install.ps1
git commit -m "chore(release): ${{ steps.bump.outputs.new }} [skip ci]"
git push origin dev
+1 -3
View File
@@ -2,9 +2,7 @@ name: Release
on:
push:
branches:
- main
- dev
branches: [main]
workflow_dispatch:
jobs:
@@ -0,0 +1,51 @@
name: Sync Dev After Main Release
on:
workflow_run:
workflows: ["Release"]
types: [completed]
branches: [main]
jobs:
sync-dev:
# Only run if Release workflow succeeded on main branch
if: ${{ github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.head_branch == 'main' }}
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
token: ${{ secrets.PAT_TOKEN }}
- name: Configure Git
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
- name: Sync dev with main
run: |
git fetch origin main dev
git checkout dev
# Attempt merge, handle version conflicts by taking main's versions
if ! git merge origin/main --no-edit -m "chore(sync): merge main into dev after release [skip ci]"; then
echo "Merge conflicts detected, resolving version files..."
# For version files, take main's version (new stable base)
for file in VERSION package.json installers/install.sh installers/install.ps1 CHANGELOG.md; do
if git diff --name-only --diff-filter=U | grep -q "^${file}$"; then
git checkout --theirs "$file"
git add "$file"
fi
done
# Complete the merge
git commit --no-edit
fi
git push origin dev
+1 -4
View File
@@ -1,8 +1,5 @@
{
"branches": [
"main",
{ "name": "dev", "prerelease": true }
],
"branches": ["main"],
"plugins": [
"@semantic-release/commit-analyzer",
"@semantic-release/release-notes-generator",
+1 -1
View File
@@ -1 +1 @@
5.5.0
5.5.0-dev.11
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@kaitranntt/ccs",
"version": "5.5.0",
"version": "5.5.0-dev.11",
"description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6",
"keywords": [
"cli",
+27 -1
View File
@@ -241,8 +241,27 @@ async function main(): Promise<void> {
// Special case: update command
if (firstArg === 'update' || firstArg === '--update') {
const updateArgs = args.slice(1);
// Handle --help for update command
if (updateArgs.includes('--help') || updateArgs.includes('-h')) {
console.log('');
console.log('Usage: ccs update [options]');
console.log('');
console.log('Options:');
console.log(' --force Force reinstall current version');
console.log(' --beta, --dev Install from dev channel (unstable)');
console.log(' --help, -h Show this help message');
console.log('');
console.log('Examples:');
console.log(' ccs update Update to latest stable');
console.log(' ccs update --force Force reinstall');
console.log(' ccs update --beta Install dev channel');
console.log('');
return;
}
const forceFlag = updateArgs.includes('--force');
const betaFlag = updateArgs.includes('--beta');
const betaFlag = updateArgs.includes('--beta') || updateArgs.includes('--dev');
await handleUpdateCommand({ force: forceFlag, beta: betaFlag });
return;
}
@@ -263,6 +282,13 @@ async function main(): Promise<void> {
return;
}
// Special case: cliproxy command (manages CLIProxyAPI binary)
if (firstArg === 'cliproxy') {
const { handleCliproxyCommand } = await import('./commands/cliproxy-command');
await handleCliproxyCommand(args.slice(1));
return;
}
// Special case: headless delegation (-p flag)
if (args.includes('-p') || args.includes('--prompt')) {
const { DelegationHandler } = await import('./delegation/delegation-handler');
+71 -9
View File
@@ -63,6 +63,7 @@ const DEFAULT_CONFIG: BinaryManagerConfig = {
binPath: getBinDir(),
maxRetries: 3,
verbose: false,
forceVersion: false,
};
/**
@@ -88,6 +89,12 @@ export class BinaryManager {
if (fs.existsSync(binaryPath)) {
this.log(`Binary exists: ${binaryPath}`);
// Skip auto-update if forceVersion is set (user requested specific version)
if (this.config.forceVersion) {
this.log(`Force version mode: skipping auto-update`);
return this.getBinaryPath();
}
// Check for updates in background (non-blocking for UX)
try {
const updateResult = await this.checkForUpdates();
@@ -114,16 +121,21 @@ export class BinaryManager {
// Download, verify, extract
this.log('Binary not found, downloading...');
// Check latest version before first download
try {
const latestVersion = await this.fetchLatestVersion();
if (latestVersion && this.isNewerVersion(latestVersion, this.config.version)) {
this.log(`Using latest version: ${latestVersion} (instead of ${this.config.version})`);
this.config.version = latestVersion;
// Skip auto-upgrade to latest if forceVersion is set
if (!this.config.forceVersion) {
// Check latest version before first download
try {
const latestVersion = await this.fetchLatestVersion();
if (latestVersion && this.isNewerVersion(latestVersion, this.config.version)) {
this.log(`Using latest version: ${latestVersion} (instead of ${this.config.version})`);
this.config.version = latestVersion;
}
} catch {
// Use pinned version if API fails
this.log(`Using pinned version: ${this.config.version}`);
}
} catch {
// Use pinned version if API fails
this.log(`Using pinned version: ${this.config.version}`);
} else {
this.log(`Force version mode: using specified version ${this.config.version}`);
}
await this.downloadAndInstall();
@@ -900,4 +912,54 @@ export function getCLIProxyPath(): string {
return manager.getBinaryPath();
}
/**
* Get installed CLIProxyAPI version from .version file
* Returns the fallback version if not installed or version file missing
*/
export function getInstalledCliproxyVersion(): string {
const versionFile = path.join(getBinDir(), '.version');
if (fs.existsSync(versionFile)) {
try {
return fs.readFileSync(versionFile, 'utf8').trim();
} catch {
return CLIPROXY_FALLBACK_VERSION;
}
}
return CLIPROXY_FALLBACK_VERSION;
}
/**
* Install a specific version of CLIProxyAPI
* Deletes existing binary and downloads the specified version
*
* @param version Version to install (e.g., "6.5.40")
* @param verbose Enable verbose logging
*/
export async function installCliproxyVersion(version: string, verbose = false): Promise<void> {
// Use forceVersion to prevent auto-upgrade to latest
const manager = new BinaryManager({ version, verbose, forceVersion: true });
// Delete existing binary if present
if (manager.isBinaryInstalled()) {
const currentVersion = getInstalledCliproxyVersion();
if (verbose) {
console.log(`[i] Removing existing CLIProxyAPI v${currentVersion}`);
}
manager.deleteBinary();
}
// Install specified version (forceVersion prevents auto-upgrade)
await manager.ensureBinary();
}
/**
* Fetch the latest CLIProxyAPI version from GitHub API
* @returns Latest version string (e.g., "6.5.40")
*/
export async function fetchLatestCliproxyVersion(): Promise<string> {
const manager = new BinaryManager();
const result = await manager.checkForUpdates();
return result.latestVersion;
}
export default BinaryManager;
+36 -4
View File
@@ -26,6 +26,8 @@ import {
} from './config-generator';
import { isAuthenticated } from './auth-handler';
import { CLIProxyProvider, ExecutorConfig } from './types';
import { configureProviderModel, getCurrentModel } from './model-config';
import { supportsModelConfig, isModelBroken, getModelIssueUrl, findModel } from './model-catalog';
/** Default executor configuration */
const DEFAULT_CONFIG: ExecutorConfig = {
@@ -116,10 +118,17 @@ export async function execClaudeWithCLIProxy(
throw error;
}
// 2. Handle authentication flags
// 2. Handle special flags
const forceAuth = args.includes('--auth');
const forceHeadless = args.includes('--headless');
const forceLogout = args.includes('--logout');
const forceConfig = args.includes('--config');
// Handle --config: configure model selection and exit
if (forceConfig && supportsModelConfig(provider)) {
await configureProviderModel(provider, true);
process.exit(0);
}
// Handle --logout: clear auth and exit
if (forceLogout) {
@@ -155,10 +164,33 @@ export async function execClaudeWithCLIProxy(
}
}
// 4. Ensure user settings file exists (creates from defaults if not)
// 4. First-run model configuration (interactive)
// For supported providers, prompt user to select model on first run
if (supportsModelConfig(provider)) {
await configureProviderModel(provider, false); // false = only if not configured
}
// 5. Check for known broken models and warn user
const currentModel = getCurrentModel(provider);
if (currentModel && isModelBroken(provider, currentModel)) {
const modelEntry = findModel(provider, currentModel);
const issueUrl = getModelIssueUrl(provider, currentModel);
console.error('');
console.error(
`[!] Warning: ${modelEntry?.name || currentModel} has known issues with Claude Code`
);
console.error(' Tool calls will fail. Use "gemini-3-pro-preview" instead.');
if (issueUrl) {
console.error(` Tracking: ${issueUrl}`);
}
console.error(` Run "ccs ${provider} --config" to change model.`);
console.error('');
}
// 6. Ensure user settings file exists (creates from defaults if not)
ensureProviderSettings(provider);
// 5. Generate config file
// 6. Generate config file
log(`Generating config for ${provider}`);
const configPath = generateConfig(provider, cfg.port);
log(`Config written: ${configPath}`);
@@ -226,7 +258,7 @@ export async function execClaudeWithCLIProxy(
log(`Claude env: ANTHROPIC_MODEL=${envVars.ANTHROPIC_MODEL}`);
// Filter out CCS-specific flags before passing to Claude CLI
const ccsFlags = ['--auth', '--headless', '--logout'];
const ccsFlags = ['--auth', '--headless', '--logout', '--config'];
const claudeArgs = args.filter((arg) => !ccsFlags.includes(arg));
const isWindows = process.platform === 'win32';
+13
View File
@@ -40,6 +40,9 @@ export {
ensureCLIProxyBinary,
isCLIProxyInstalled,
getCLIProxyPath,
getInstalledCliproxyVersion,
installCliproxyVersion,
fetchLatestCliproxyVersion,
} from './binary-manager';
// Config generation
@@ -69,6 +72,16 @@ export {
clearConfigCache,
} from './base-config-loader';
// Model catalog and configuration
export type { ModelEntry, ProviderCatalog } from './model-catalog';
export { MODEL_CATALOG, supportsModelConfig, getProviderCatalog, findModel } from './model-catalog';
export {
hasUserSettings,
getCurrentModel,
configureProviderModel,
showCurrentConfig,
} from './model-config';
// Executor
export { execClaudeWithCLIProxy, isPortAvailable, findAvailablePort } from './cliproxy-executor';
+135
View File
@@ -0,0 +1,135 @@
/**
* Model Catalog - Available models for CLI Proxy providers
*
* Ships with CCS to provide users with interactive model selection.
* Models are mapped to their internal names used by the proxy backend.
*/
import { CLIProxyProvider } from './types';
/**
* Model entry definition
*/
export interface ModelEntry {
/** Literal model name to put in settings.json */
id: string;
/** Human-readable name for display */
name: string;
/** Access tier indicator - 'paid' means requires paid Google account (not free tier) */
tier?: 'free' | 'paid';
/** Optional description for the model */
description?: string;
/** Model has known issues - show warning when selected */
broken?: boolean;
/** Issue URL for broken models */
issueUrl?: string;
}
/**
* Provider catalog definition
*/
export interface ProviderCatalog {
provider: CLIProxyProvider;
displayName: string;
models: ModelEntry[];
defaultModel: string;
}
/**
* Model catalog for providers that support interactive configuration
*
* Models listed in order of recommendation (top = best)
*/
export const MODEL_CATALOG: Partial<Record<CLIProxyProvider, ProviderCatalog>> = {
agy: {
provider: 'agy',
displayName: 'Antigravity',
defaultModel: 'gemini-3-pro-preview',
models: [
{
id: 'gemini-claude-opus-4-5-thinking',
name: 'Claude Opus 4.5 Thinking',
description: 'Most capable, extended thinking',
broken: true,
issueUrl: 'https://github.com/router-for-me/CLIProxyAPI/issues/415',
},
{
id: 'gemini-claude-sonnet-4-5-thinking',
name: 'Claude Sonnet 4.5 Thinking',
description: 'Balanced with extended thinking',
broken: true,
issueUrl: 'https://github.com/router-for-me/CLIProxyAPI/issues/415',
},
{
id: 'gemini-claude-sonnet-4-5',
name: 'Claude Sonnet 4.5',
description: 'Fast and capable',
broken: true,
issueUrl: 'https://github.com/router-for-me/CLIProxyAPI/issues/415',
},
{
id: 'gemini-3-pro-preview',
name: 'Gemini 3 Pro',
tier: 'paid',
description: 'Google latest, requires paid Google account',
},
],
},
gemini: {
provider: 'gemini',
displayName: 'Gemini',
defaultModel: 'gemini-2.5-pro',
models: [
{
id: 'gemini-3-pro-preview',
name: 'Gemini 3 Pro',
tier: 'paid',
description: 'Latest model, requires paid Google account',
},
{
id: 'gemini-2.5-pro',
name: 'Gemini 2.5 Pro',
description: 'Stable, works with free Google account',
},
],
},
};
/**
* Check if provider supports interactive model configuration
*/
export function supportsModelConfig(provider: CLIProxyProvider): boolean {
return provider in MODEL_CATALOG;
}
/**
* Get catalog for provider
*/
export function getProviderCatalog(provider: CLIProxyProvider): ProviderCatalog | undefined {
return MODEL_CATALOG[provider];
}
/**
* Find model entry by ID
*/
export function findModel(provider: CLIProxyProvider, modelId: string): ModelEntry | undefined {
const catalog = MODEL_CATALOG[provider];
if (!catalog) return undefined;
return catalog.models.find((m) => m.id === modelId);
}
/**
* Check if model has known issues
*/
export function isModelBroken(provider: CLIProxyProvider, modelId: string): boolean {
const model = findModel(provider, modelId);
return model?.broken === true;
}
/**
* Get issue URL for broken model
*/
export function getModelIssueUrl(provider: CLIProxyProvider, modelId: string): string | undefined {
const model = findModel(provider, modelId);
return model?.issueUrl;
}
+200
View File
@@ -0,0 +1,200 @@
/**
* Model Configuration - Interactive model selection for CLI Proxy providers
*
* Handles first-run configuration and explicit --config flag.
* Persists user selection to ~/.ccs/{provider}.settings.json
*/
import * as fs from 'fs';
import * as path from 'path';
import { InteractivePrompt } from '../utils/prompt';
import { getProviderCatalog, supportsModelConfig, ModelEntry } from './model-catalog';
import { getProviderSettingsPath, getClaudeEnvVars } from './config-generator';
import { CLIProxyProvider } from './types';
import { initUI, color, bold, dim, ok, info, header } from '../utils/ui';
/** CCS directory */
const CCS_DIR = path.join(process.env.HOME || process.env.USERPROFILE || '', '.ccs');
/**
* Check if provider has user settings configured
*/
export function hasUserSettings(provider: CLIProxyProvider): boolean {
const settingsPath = getProviderSettingsPath(provider);
return fs.existsSync(settingsPath);
}
/**
* Get current model from user settings
*/
export function getCurrentModel(provider: CLIProxyProvider): string | undefined {
const settingsPath = getProviderSettingsPath(provider);
if (!fs.existsSync(settingsPath)) return undefined;
try {
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
return settings.env?.ANTHROPIC_MODEL;
} catch {
return undefined;
}
}
/**
* Format model entry for display in selection list
*/
function formatModelOption(model: ModelEntry): string {
// Tier badge: clarify that "paid" means paid Google account (not free tier)
const tierBadge = model.tier === 'paid' ? color(' [Paid Tier]', 'warning') : '';
const brokenBadge = model.broken ? color(' [BROKEN]', 'error') : '';
return `${model.name}${tierBadge}${brokenBadge}`;
}
/**
* Format model entry for detailed display (with description)
*/
function formatModelDetailed(model: ModelEntry, isCurrent: boolean): string {
const marker = isCurrent ? color('>', 'success') : ' ';
const name = isCurrent ? bold(model.name) : model.name;
const tierBadge = model.tier === 'paid' ? color(' [Paid Tier]', 'warning') : '';
const brokenBadge = model.broken ? color(' [BROKEN]', 'error') : '';
const desc = model.description ? dim(` - ${model.description}`) : '';
return ` ${marker} ${name}${tierBadge}${brokenBadge}${desc}`;
}
/**
* Configure model for provider (interactive)
*
* @param provider CLIProxy provider (agy, gemini)
* @param force Force reconfiguration even if settings exist
* @returns true if configuration was performed, false if skipped
*/
export async function configureProviderModel(
provider: CLIProxyProvider,
force: boolean = false
): Promise<boolean> {
// Check if provider supports model configuration
if (!supportsModelConfig(provider)) {
return false;
}
const catalog = getProviderCatalog(provider);
if (!catalog) return false;
const settingsPath = getProviderSettingsPath(provider);
// Skip if already configured (unless --config flag)
if (!force && fs.existsSync(settingsPath)) {
return false;
}
// Initialize UI for colors/gradient
await initUI();
// Build options list
const options = catalog.models.map((m) => ({
id: m.id,
label: formatModelOption(m),
}));
// Find default index - use current model if configured, otherwise catalog default
const currentModel = getCurrentModel(provider);
const targetModel = currentModel || catalog.defaultModel;
const defaultIdx = catalog.models.findIndex((m) => m.id === targetModel);
const safeDefaultIdx = defaultIdx >= 0 ? defaultIdx : 0;
// Show header with context (gradient like ccs doctor)
console.error('');
console.error(header(`Configure ${catalog.displayName} Model`));
console.error('');
console.error(dim(' Select which model to use for this provider.'));
console.error(
dim(' Models marked [Paid Tier] require a paid Google account (not free tier).')
);
console.error('');
// Interactive selection
const selectedModel = await InteractivePrompt.selectFromList('Select model:', options, {
defaultIndex: safeDefaultIdx,
});
// Get base env vars to preserve haiku model and base URL
const baseEnv = getClaudeEnvVars(provider);
// Build settings with selected model
const settings = {
env: {
...baseEnv,
ANTHROPIC_MODEL: selectedModel,
ANTHROPIC_DEFAULT_OPUS_MODEL: selectedModel,
ANTHROPIC_DEFAULT_SONNET_MODEL: selectedModel,
// Keep haiku as-is from base config (usually flash model)
},
};
// Ensure CCS directory exists
if (!fs.existsSync(CCS_DIR)) {
fs.mkdirSync(CCS_DIR, { recursive: true });
}
// Write settings file
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
// Find display name
const selectedEntry = catalog.models.find((m) => m.id === selectedModel);
const displayName = selectedEntry?.name || selectedModel;
console.error('');
console.error(ok(`Model set to: ${bold(displayName)}`));
console.error(dim(` Config saved: ${settingsPath}`));
console.error('');
return true;
}
/**
* Show current model configuration
*/
export async function showCurrentConfig(provider: CLIProxyProvider): Promise<void> {
if (!supportsModelConfig(provider)) {
console.error(info(`Provider ${provider} does not support model configuration`));
return;
}
const catalog = getProviderCatalog(provider);
if (!catalog) return;
// Initialize UI for colors/gradient
await initUI();
const currentModel = getCurrentModel(provider);
const settingsPath = getProviderSettingsPath(provider);
console.error('');
console.error(header(`${catalog.displayName} Model Configuration`));
console.error('');
if (currentModel) {
const entry = catalog.models.find((m) => m.id === currentModel);
const displayName = entry?.name || 'Unknown';
console.error(
` ${bold('Current:')} ${color(displayName, 'success')} ${dim(`(${currentModel})`)}`
);
console.error(` ${bold('Config:')} ${dim(settingsPath)}`);
} else {
console.error(` ${bold('Current:')} ${dim('(using defaults)')}`);
console.error(` ${bold('Default:')} ${catalog.defaultModel}`);
}
console.error('');
console.error(bold('Available models:'));
console.error(dim(' [Paid Tier] = Requires paid Google account (not free tier)'));
console.error('');
catalog.models.forEach((m) => {
const isCurrent = m.id === currentModel;
console.error(formatModelDetailed(m, isCurrent));
});
console.error('');
console.error(dim(`Run "ccs ${provider} --config" to change`));
console.error('');
}
+1 -1
View File
@@ -11,7 +11,7 @@ import { PlatformInfo, SupportedOS, SupportedArch, ArchiveExtension } from './ty
* CLIProxyAPI fallback version (used when GitHub API unavailable)
* Auto-update fetches latest from GitHub; this is only a safety net
*/
export const CLIPROXY_FALLBACK_VERSION = '6.5.31';
export const CLIPROXY_FALLBACK_VERSION = '6.5.40';
/** @deprecated Use CLIPROXY_FALLBACK_VERSION instead */
export const CLIPROXY_VERSION = CLIPROXY_FALLBACK_VERSION;
+2
View File
@@ -46,6 +46,8 @@ export interface BinaryManagerConfig {
maxRetries: number;
/** Enable verbose logging */
verbose: boolean;
/** Force specific version (skip auto-upgrade to latest) */
forceVersion?: boolean;
}
/**
+198
View File
@@ -0,0 +1,198 @@
/**
* CLIProxy Command Handler
*
* Manages CLIProxyAPI binary installation and version control.
* Allows users to install specific versions or update to latest.
*
* Usage:
* ccs cliproxy Show current version
* ccs cliproxy --install <ver> Install specific version
* ccs cliproxy --latest Install latest version
* ccs cliproxy --help Show help
*/
import {
getInstalledCliproxyVersion,
installCliproxyVersion,
fetchLatestCliproxyVersion,
isCLIProxyInstalled,
getCLIProxyPath,
} from '../cliproxy';
import { CLIPROXY_FALLBACK_VERSION } from '../cliproxy/platform-detector';
import { color, dim, initUI } from '../utils/ui';
/**
* Show cliproxy command help
*/
function showHelp(): void {
console.log('');
console.log('Usage: ccs cliproxy [options]');
console.log('');
console.log('Manage CLIProxyAPI binary installation.');
console.log('');
console.log('Options:');
console.log(' --install <version> 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('');
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('');
console.log('Notes:');
console.log(` Default fallback version: ${CLIPROXY_FALLBACK_VERSION}`);
console.log(' Releases: https://github.com/router-for-me/CLIProxyAPI/releases');
console.log('');
}
/**
* Show current cliproxy status
*/
async function showStatus(verbose: boolean): Promise<void> {
await initUI();
const installed = isCLIProxyInstalled();
const currentVersion = getInstalledCliproxyVersion();
const binaryPath = getCLIProxyPath();
console.log('');
console.log(color('CLIProxyAPI Status', 'primary'));
console.log('');
if (installed) {
console.log(` Installed: ${color('Yes', 'success')}`);
console.log(` Version: ${color(`v${currentVersion}`, 'info')}`);
console.log(` Binary: ${dim(binaryPath)}`);
} else {
console.log(` Installed: ${color('No', 'error')}`);
console.log(` Fallback: ${color(`v${CLIPROXY_FALLBACK_VERSION}`, 'info')}`);
console.log(` ${dim('Run "ccs gemini" or any provider to auto-install')}`);
}
// Try to fetch latest version
try {
console.log('');
console.log(` ${dim('Checking for updates...')}`);
const latestVersion = await fetchLatestCliproxyVersion();
if (latestVersion !== currentVersion) {
console.log(
` Latest: ${color(`v${latestVersion}`, 'success')} ${dim('(update available)')}`
);
console.log('');
console.log(` ${dim(`Run "ccs cliproxy --latest" to update`)}`);
} else {
console.log(` Latest: ${color(`v${latestVersion}`, 'success')} ${dim('(up to date)')}`);
}
} catch (error) {
if (verbose) {
const err = error as Error;
console.log(` Latest: ${dim(`Could not fetch (${err.message})`)}`);
}
}
console.log('');
}
/**
* Install a specific version
*/
async function installVersion(version: string, verbose: boolean): Promise<void> {
// Validate version format (basic semver check)
if (!/^\d+\.\d+\.\d+$/.test(version)) {
console.error('[X] Invalid version format. Expected format: X.Y.Z (e.g., 6.5.40)');
process.exit(1);
}
console.log(`[i] Installing CLIProxyAPI v${version}...`);
console.log('');
try {
await installCliproxyVersion(version, verbose);
console.log('');
console.log(`[OK] CLIProxyAPI v${version} installed successfully`);
} catch (error) {
const err = error as Error;
console.error('');
console.error(`[X] Failed to install CLIProxyAPI v${version}`);
console.error(` ${err.message}`);
console.error('');
console.error('Possible causes:');
console.error(' 1. Version does not exist on GitHub');
console.error(' 2. Network connectivity issues');
console.error(' 3. GitHub API rate limiting');
console.error('');
console.error('Check available versions at:');
console.error(' https://github.com/router-for-me/CLIProxyAPI/releases');
process.exit(1);
}
}
/**
* Install latest version
*/
async function installLatest(verbose: boolean): Promise<void> {
console.log('[i] Fetching latest CLIProxyAPI version...');
try {
const latestVersion = await fetchLatestCliproxyVersion();
const currentVersion = getInstalledCliproxyVersion();
if (isCLIProxyInstalled() && latestVersion === currentVersion) {
console.log(`[OK] Already running latest version: v${latestVersion}`);
return;
}
console.log(`[i] Latest version: v${latestVersion}`);
if (isCLIProxyInstalled()) {
console.log(`[i] Current version: v${currentVersion}`);
}
console.log('');
await installCliproxyVersion(latestVersion, verbose);
console.log('');
console.log(`[OK] CLIProxyAPI updated to v${latestVersion}`);
} catch (error) {
const err = error as Error;
console.error(`[X] Failed to install latest version: ${err.message}`);
process.exit(1);
}
}
/**
* Main cliproxy command handler
*/
export async function handleCliproxyCommand(args: string[]): Promise<void> {
const verbose = args.includes('--verbose') || args.includes('-v');
// Handle --help
if (args.includes('--help') || args.includes('-h')) {
showHelp();
return;
}
// Handle --install <version>
const installIdx = args.indexOf('--install');
if (installIdx !== -1) {
const version = args[installIdx + 1];
if (!version || version.startsWith('-')) {
console.error('[X] Missing version argument for --install');
console.error(' Usage: ccs cliproxy --install <version>');
console.error(' Example: ccs cliproxy --install 6.5.40');
process.exit(1);
}
await installVersion(version, verbose);
return;
}
// Handle --latest
if (args.includes('--latest')) {
await installLatest(verbose);
return;
}
// Default: show status
await showStatus(verbose);
}
+11 -3
View File
@@ -151,16 +151,17 @@ Claude Code Profile & Model Switcher`.trim();
'CLI Proxy (OAuth Providers)',
[
'Zero-config OAuth authentication via CLIProxyAPI',
'First run: Browser opens for authentication',
'First run: Browser opens for authentication, then model selection',
'Settings: ~/.ccs/{provider}.settings.json (created after auth)',
],
[
['ccs gemini', 'Google Gemini (gemini-2.5-pro)'],
['ccs gemini', 'Google Gemini (gemini-2.5-pro or 3-pro)'],
['ccs codex', 'OpenAI Codex (gpt-5.1-codex-max)'],
['ccs agy', 'Antigravity (gemini-3-pro-preview)'],
['ccs agy', 'Antigravity (Claude/Gemini models)'],
['ccs qwen', 'Qwen Code (qwen3-coder)'],
['', ''], // Spacer
['ccs <provider> --auth', 'Authenticate only'],
['ccs <provider> --config', 'Change model (agy, gemini)'],
['ccs <provider> --logout', 'Clear authentication'],
['ccs <provider> --headless', 'Headless auth (for SSH)'],
['ccs codex "explain code"', 'Use with prompt'],
@@ -203,6 +204,13 @@ Claude Code Profile & Model Switcher`.trim();
['Settings:', '~/.ccs/*.settings.json'],
]);
// CLI Proxy management
printSubSection('CLI Proxy Management', [
['ccs cliproxy', 'Show CLIProxyAPI status and version'],
['ccs cliproxy --install <ver>', 'Install specific version (e.g., 6.5.40)'],
['ccs cliproxy --latest', 'Update to latest version'],
]);
// CLI Proxy paths
console.log(subheader('CLI Proxy:'));
console.log(` Binary: ${color('~/.ccs/cliproxy/bin/cli-proxy-api', 'path')}`);
+4 -3
View File
@@ -16,7 +16,7 @@ import {
isPortAvailable,
getAllAuthStatus,
getConfigPath,
CLIPROXY_VERSION,
getInstalledCliproxyVersion,
CLIPROXY_DEFAULT_PORT,
} from '../cliproxy';
@@ -775,11 +775,12 @@ class Doctor {
if (isCLIProxyInstalled()) {
const binaryPath = getCLIProxyPath();
const installedVersion = getInstalledCliproxyVersion();
binarySpinner.succeed();
console.log(` ${ok('CLIProxy Binary'.padEnd(22))} v${CLIPROXY_VERSION}`);
console.log(` ${ok('CLIProxy Binary'.padEnd(22))} v${installedVersion}`);
this.results.addCheck('CLIProxy Binary', 'success', undefined, undefined, {
status: 'OK',
info: `v${CLIPROXY_VERSION} (${binaryPath})`,
info: `v${installedVersion} (${binaryPath})`,
});
} else {
binarySpinner.info();
+126
View File
@@ -24,6 +24,15 @@ interface PasswordOptions {
mask?: string; // Character to show (default: '*')
}
interface SelectOption {
id: string;
label: string;
}
interface SelectOptions {
defaultIndex?: number;
}
export class InteractivePrompt {
/**
* Ask for confirmation
@@ -134,6 +143,11 @@ export class InteractivePrompt {
/**
* Get password/secret input (masked)
*
* Handles bracketed paste mode escape sequences that terminals send:
* - Start paste: ESC[200~
* - End paste: ESC[201~
* These are stripped automatically so pasted API keys work correctly.
*/
static async password(message: string, options: PasswordOptions = {}): Promise<string> {
const { mask = '*' } = options;
@@ -153,6 +167,7 @@ export class InteractivePrompt {
return new Promise((resolve) => {
let input = '';
let escapeBuffer = ''; // Buffer for escape sequence detection
const cleanup = (): void => {
if (process.stdin.setRawMode) {
@@ -168,6 +183,32 @@ export class InteractivePrompt {
for (const char of str) {
const charCode = char.charCodeAt(0);
// ESC character (start of escape sequence)
if (charCode === 27) {
escapeBuffer = '\x1b';
continue;
}
// If we're in an escape sequence, buffer chars until we detect the pattern
if (escapeBuffer) {
escapeBuffer += char;
// Check for bracketed paste sequences: ESC[200~ (start) or ESC[201~ (end)
if (escapeBuffer === '\x1b[200~' || escapeBuffer === '\x1b[201~') {
// Discard bracketed paste markers
escapeBuffer = '';
continue;
}
// If buffer is getting too long without match, it's not a paste sequence
// Flush buffer as regular input (shouldn't happen with API keys)
if (escapeBuffer.length > 6) {
// Not a recognized sequence - skip it entirely (likely other escape seq)
escapeBuffer = '';
}
continue;
}
// Enter key (CR or LF)
if (charCode === 13 || charCode === 10) {
cleanup();
@@ -205,4 +246,89 @@ export class InteractivePrompt {
process.stdin.resume();
});
}
/**
* Select from a numbered list
*
* Displays options with numbers and waits for user selection.
* Shows default with asterisk (*) prefix.
*/
static async selectFromList(
prompt: string,
options: SelectOption[],
selectOptions: SelectOptions = {}
): Promise<string> {
const { defaultIndex = 0 } = selectOptions;
// Check for --yes flag (automation) - use default
if (
process.env.CCS_YES === '1' ||
process.argv.includes('--yes') ||
process.argv.includes('-y')
) {
console.error(`[i] Using default: ${options[defaultIndex].label}`);
return options[defaultIndex].id;
}
// Check for --no-input flag (CI)
if (process.env.CCS_NO_INPUT === '1' || process.argv.includes('--no-input')) {
console.error(`[i] Using default: ${options[defaultIndex].label}`);
return options[defaultIndex].id;
}
// Non-TTY: use default
if (!process.stdin.isTTY) {
console.error(`[i] Using default: ${options[defaultIndex].label}`);
return options[defaultIndex].id;
}
// Display prompt and options
console.error(`${prompt}`);
console.error('');
options.forEach((opt, i) => {
const marker = i === defaultIndex ? '*' : ' ';
const num = String(i + 1).padStart(2);
console.error(` ${marker}${num}. ${opt.label}`);
});
console.error('');
const rl = readline.createInterface({
input: process.stdin,
output: process.stderr,
terminal: true,
});
const defaultNum = String(defaultIndex + 1);
const promptText = `Enter choice [1-${options.length}] (default: ${defaultNum}): `;
return new Promise((resolve) => {
rl.question(promptText, (answer: string) => {
rl.close();
const normalized = answer.trim();
// Empty answer: use default
if (normalized === '') {
console.error(`[i] Using default: ${options[defaultIndex].label}`);
resolve(options[defaultIndex].id);
return;
}
// Parse number
const num = parseInt(normalized, 10);
// Validate range
if (isNaN(num) || num < 1 || num > options.length) {
console.error(`[!] Invalid choice. Please enter 1-${options.length}`);
resolve(InteractivePrompt.selectFromList(prompt, options, selectOptions));
return;
}
const selected = options[num - 1];
resolve(selected.id);
});
});
}
}
+216
View File
@@ -0,0 +1,216 @@
/**
* Tests for CLIProxy Model Catalog
* Verifies model database structure and lookup functions
*/
const assert = require('assert');
describe('Model Catalog', () => {
const modelCatalog = require('../../../dist/cliproxy/model-catalog');
describe('MODEL_CATALOG structure', () => {
it('contains AGY provider catalog', () => {
const { MODEL_CATALOG } = modelCatalog;
assert(MODEL_CATALOG.agy, 'Should have agy provider');
assert.strictEqual(MODEL_CATALOG.agy.provider, 'agy');
assert.strictEqual(MODEL_CATALOG.agy.displayName, 'Antigravity');
});
it('contains Gemini provider catalog', () => {
const { MODEL_CATALOG } = modelCatalog;
assert(MODEL_CATALOG.gemini, 'Should have gemini provider');
assert.strictEqual(MODEL_CATALOG.gemini.provider, 'gemini');
assert.strictEqual(MODEL_CATALOG.gemini.displayName, 'Gemini');
});
it('does not contain codex or qwen (not configurable)', () => {
const { MODEL_CATALOG } = modelCatalog;
assert.strictEqual(MODEL_CATALOG.codex, undefined);
assert.strictEqual(MODEL_CATALOG.qwen, undefined);
});
});
describe('AGY models', () => {
it('has correct default model', () => {
const { MODEL_CATALOG } = modelCatalog;
assert.strictEqual(MODEL_CATALOG.agy.defaultModel, 'gemini-3-pro-preview');
});
it('includes Claude Opus 4.5 Thinking', () => {
const { MODEL_CATALOG } = modelCatalog;
const opus = MODEL_CATALOG.agy.models.find(
(m) => m.id === 'gemini-claude-opus-4-5-thinking'
);
assert(opus, 'Should include Claude Opus 4.5 Thinking');
assert.strictEqual(opus.name, 'Claude Opus 4.5 Thinking');
});
it('includes Claude Sonnet 4.5 Thinking', () => {
const { MODEL_CATALOG } = modelCatalog;
const sonnetThinking = MODEL_CATALOG.agy.models.find(
(m) => m.id === 'gemini-claude-sonnet-4-5-thinking'
);
assert(sonnetThinking, 'Should include Claude Sonnet 4.5 Thinking');
assert.strictEqual(sonnetThinking.name, 'Claude Sonnet 4.5 Thinking');
});
it('includes Claude Sonnet 4.5', () => {
const { MODEL_CATALOG } = modelCatalog;
const sonnet = MODEL_CATALOG.agy.models.find((m) => m.id === 'gemini-claude-sonnet-4-5');
assert(sonnet, 'Should include Claude Sonnet 4.5');
assert.strictEqual(sonnet.name, 'Claude Sonnet 4.5');
});
it('includes Gemini 3 Pro with paid tier', () => {
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');
});
it('has 4 models total', () => {
const { MODEL_CATALOG } = modelCatalog;
assert.strictEqual(MODEL_CATALOG.agy.models.length, 4);
});
});
describe('Gemini models', () => {
it('has correct default model', () => {
const { MODEL_CATALOG } = modelCatalog;
assert.strictEqual(MODEL_CATALOG.gemini.defaultModel, 'gemini-2.5-pro');
});
it('includes Gemini 3 Pro with paid tier', () => {
const { MODEL_CATALOG } = modelCatalog;
const gem3 = MODEL_CATALOG.gemini.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');
});
it('includes Gemini 2.5 Pro without tier (free)', () => {
const { MODEL_CATALOG } = modelCatalog;
const gem25 = MODEL_CATALOG.gemini.models.find((m) => m.id === 'gemini-2.5-pro');
assert(gem25, 'Should include Gemini 2.5 Pro');
assert.strictEqual(gem25.name, 'Gemini 2.5 Pro');
assert.strictEqual(gem25.tier, undefined);
});
it('has 2 models total', () => {
const { MODEL_CATALOG } = modelCatalog;
assert.strictEqual(MODEL_CATALOG.gemini.models.length, 2);
});
});
describe('supportsModelConfig', () => {
it('returns true for agy', () => {
const { supportsModelConfig } = modelCatalog;
assert.strictEqual(supportsModelConfig('agy'), true);
});
it('returns true for gemini', () => {
const { supportsModelConfig } = modelCatalog;
assert.strictEqual(supportsModelConfig('gemini'), true);
});
it('returns false for codex', () => {
const { supportsModelConfig } = modelCatalog;
assert.strictEqual(supportsModelConfig('codex'), false);
});
it('returns false for qwen', () => {
const { supportsModelConfig } = modelCatalog;
assert.strictEqual(supportsModelConfig('qwen'), false);
});
});
describe('getProviderCatalog', () => {
it('returns catalog for agy', () => {
const { getProviderCatalog } = modelCatalog;
const catalog = getProviderCatalog('agy');
assert(catalog, 'Should return catalog');
assert.strictEqual(catalog.provider, 'agy');
assert(Array.isArray(catalog.models));
});
it('returns catalog for gemini', () => {
const { getProviderCatalog } = modelCatalog;
const catalog = getProviderCatalog('gemini');
assert(catalog, 'Should return catalog');
assert.strictEqual(catalog.provider, 'gemini');
});
it('returns undefined for codex', () => {
const { getProviderCatalog } = modelCatalog;
const catalog = getProviderCatalog('codex');
assert.strictEqual(catalog, undefined);
});
});
describe('findModel', () => {
it('finds Claude Opus 4.5 Thinking in agy', () => {
const { findModel } = modelCatalog;
const model = findModel('agy', 'gemini-claude-opus-4-5-thinking');
assert(model, 'Should find model');
assert.strictEqual(model.name, 'Claude Opus 4.5 Thinking');
});
it('finds Gemini 2.5 Pro in gemini', () => {
const { findModel } = modelCatalog;
const model = findModel('gemini', 'gemini-2.5-pro');
assert(model, 'Should find model');
assert.strictEqual(model.name, 'Gemini 2.5 Pro');
});
it('returns undefined for unknown model', () => {
const { findModel } = modelCatalog;
const model = findModel('agy', 'unknown-model');
assert.strictEqual(model, undefined);
});
it('returns undefined for unsupported provider', () => {
const { findModel } = modelCatalog;
const model = findModel('codex', 'any-model');
assert.strictEqual(model, undefined);
});
});
describe('Model entry structure', () => {
it('all models have required fields', () => {
const { MODEL_CATALOG } = modelCatalog;
for (const [provider, catalog] of Object.entries(MODEL_CATALOG)) {
for (const model of catalog.models) {
assert(model.id, `Model in ${provider} should have id`);
assert(typeof model.id === 'string', `Model id should be string`);
assert(model.name, `Model ${model.id} should have name`);
assert(typeof model.name === 'string', `Model name should be string`);
// tier is optional
if (model.tier !== undefined) {
assert(['free', 'paid'].includes(model.tier), `Invalid tier: ${model.tier}`);
}
}
}
});
it('all model IDs are unique within provider', () => {
const { MODEL_CATALOG } = modelCatalog;
for (const [provider, catalog] of Object.entries(MODEL_CATALOG)) {
const ids = catalog.models.map((m) => m.id);
const uniqueIds = new Set(ids);
assert.strictEqual(ids.length, uniqueIds.size, `Duplicate model IDs in ${provider}`);
}
});
it('default model exists in models array', () => {
const { MODEL_CATALOG } = modelCatalog;
for (const [provider, catalog] of Object.entries(MODEL_CATALOG)) {
const defaultExists = catalog.models.some((m) => m.id === catalog.defaultModel);
assert(defaultExists, `Default model ${catalog.defaultModel} not found in ${provider}`);
}
});
});
});
+162
View File
@@ -0,0 +1,162 @@
/**
* Tests for CLIProxy Model Configuration
* Verifies model configuration logic and settings management
*/
const assert = require('assert');
const fs = require('fs');
const path = require('path');
const os = require('os');
describe('Model Config', () => {
const modelConfig = require('../../../dist/cliproxy/model-config');
const modelCatalog = require('../../../dist/cliproxy/model-catalog');
describe('hasUserSettings', () => {
it('returns false when settings file does not exist', () => {
// Ensure we're checking a non-existent path
const { hasUserSettings } = modelConfig;
// Since we can't easily mock getCcsDir, we test the function logic
// by checking it doesn't throw
const result = hasUserSettings('agy');
assert(typeof result === 'boolean', 'Should return boolean');
});
});
describe('getCurrentModel', () => {
it('returns undefined when settings file does not exist', () => {
const { getCurrentModel } = modelConfig;
// Test with a provider that likely has no settings in test env
const result = getCurrentModel('agy');
// Result depends on whether ~/.ccs/agy.settings.json exists
// Just verify it returns string or undefined
assert(
result === undefined || typeof result === 'string',
'Should return string or undefined'
);
});
});
describe('configureProviderModel', () => {
it('returns false for unsupported provider (codex)', async () => {
const { configureProviderModel } = modelConfig;
const result = await configureProviderModel('codex', true);
assert.strictEqual(result, false);
});
it('returns false for unsupported provider (qwen)', async () => {
const { configureProviderModel } = modelConfig;
const result = await configureProviderModel('qwen', true);
assert.strictEqual(result, false);
});
// Note: Full interactive tests require mocking stdin
// These are smoke tests to verify basic logic
});
describe('showCurrentConfig', () => {
it('does not throw for agy provider', async () => {
const { showCurrentConfig } = modelConfig;
// Just verify it doesn't throw (now async)
await assert.doesNotReject(async () => showCurrentConfig('agy'));
});
it('does not throw for gemini provider', async () => {
const { showCurrentConfig } = modelConfig;
await assert.doesNotReject(async () => showCurrentConfig('gemini'));
});
it('does not throw for unsupported provider', async () => {
const { showCurrentConfig } = modelConfig;
await assert.doesNotReject(async () => showCurrentConfig('codex'));
});
});
describe('Model catalog integration', () => {
it('configureProviderModel uses correct catalog for agy', async () => {
const { getProviderCatalog } = modelCatalog;
const catalog = getProviderCatalog('agy');
// Verify catalog structure is what configureProviderModel expects
assert(catalog.models, 'Should have models array');
assert(catalog.defaultModel, 'Should have defaultModel');
assert(catalog.displayName, 'Should have displayName');
});
it('configureProviderModel uses correct catalog for gemini', async () => {
const { getProviderCatalog } = modelCatalog;
const catalog = getProviderCatalog('gemini');
assert(catalog.models, 'Should have models array');
assert(catalog.defaultModel, 'Should have defaultModel');
assert(catalog.displayName, 'Should have displayName');
});
});
describe('Settings file format', () => {
it('should generate correct settings structure', () => {
// Test the expected settings structure
const expectedStructure = {
env: {
ANTHROPIC_BASE_URL: expect.any(String),
ANTHROPIC_AUTH_TOKEN: expect.any(String),
ANTHROPIC_MODEL: 'gemini-claude-opus-4-5-thinking',
ANTHROPIC_DEFAULT_OPUS_MODEL: 'gemini-claude-opus-4-5-thinking',
ANTHROPIC_DEFAULT_SONNET_MODEL: 'gemini-claude-opus-4-5-thinking',
ANTHROPIC_DEFAULT_HAIKU_MODEL: expect.any(String),
},
};
// Verify the structure is valid JSON
const testSettings = {
env: {
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/agy',
ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed',
ANTHROPIC_MODEL: 'gemini-claude-opus-4-5-thinking',
ANTHROPIC_DEFAULT_OPUS_MODEL: 'gemini-claude-opus-4-5-thinking',
ANTHROPIC_DEFAULT_SONNET_MODEL: 'gemini-claude-opus-4-5-thinking',
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'gemini-2.5-flash',
},
};
const json = JSON.stringify(testSettings, null, 2);
const parsed = JSON.parse(json);
assert(parsed.env, 'Should have env object');
assert(parsed.env.ANTHROPIC_MODEL, 'Should have ANTHROPIC_MODEL');
assert.strictEqual(
parsed.env.ANTHROPIC_MODEL,
'gemini-claude-opus-4-5-thinking'
);
});
it('all env values should be strings (PowerShell safety)', () => {
const testSettings = {
env: {
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/agy',
ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed',
ANTHROPIC_MODEL: 'gemini-claude-opus-4-5-thinking',
ANTHROPIC_DEFAULT_OPUS_MODEL: 'gemini-claude-opus-4-5-thinking',
ANTHROPIC_DEFAULT_SONNET_MODEL: 'gemini-claude-opus-4-5-thinking',
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'gemini-2.5-flash',
},
};
for (const [key, value] of Object.entries(testSettings.env)) {
assert.strictEqual(
typeof value,
'string',
`env.${key} should be string, got ${typeof value}`
);
}
});
});
});
// Helper for expect-like assertions
const expect = {
any: (type) => ({
_type: type,
toString: () => `expect.any(${type.name})`,
}),
};
+268
View File
@@ -0,0 +1,268 @@
/**
* Tests for Interactive Prompt Utilities
* Verifies prompt functions including selectFromList
*/
const assert = require('assert');
describe('InteractivePrompt', () => {
const { InteractivePrompt } = require('../../../dist/utils/prompt');
let originalArgv;
let originalEnv;
beforeEach(() => {
originalArgv = [...process.argv];
originalEnv = { ...process.env };
});
afterEach(() => {
process.argv = originalArgv;
process.env = originalEnv;
});
describe('selectFromList', () => {
describe('automation flags', () => {
it('uses default when CCS_YES=1', async () => {
process.env.CCS_YES = '1';
const options = [
{ id: 'opt1', label: 'Option 1' },
{ id: 'opt2', label: 'Option 2' },
];
try {
const result = await InteractivePrompt.selectFromList('Select:', options, {
defaultIndex: 0,
});
assert.strictEqual(result, 'opt1');
} finally {
delete process.env.CCS_YES;
}
});
it('uses default when --yes flag present', async () => {
process.argv = [...process.argv, '--yes'];
const options = [
{ id: 'first', label: 'First' },
{ id: 'second', label: 'Second' },
];
const result = await InteractivePrompt.selectFromList('Pick:', options, {
defaultIndex: 1,
});
assert.strictEqual(result, 'second');
});
it('uses default when -y flag present', async () => {
process.argv = [...process.argv, '-y'];
const options = [
{ id: 'a', label: 'A' },
{ id: 'b', label: 'B' },
];
const result = await InteractivePrompt.selectFromList('Choose:', options);
assert.strictEqual(result, 'a'); // default is 0
});
it('uses default when CCS_NO_INPUT=1', async () => {
process.env.CCS_NO_INPUT = '1';
const options = [
{ id: 'model1', label: 'Model 1' },
{ id: 'model2', label: 'Model 2' },
{ id: 'model3', label: 'Model 3' },
];
try {
const result = await InteractivePrompt.selectFromList('Select model:', options, {
defaultIndex: 2,
});
assert.strictEqual(result, 'model3');
} finally {
delete process.env.CCS_NO_INPUT;
}
});
it('uses default when --no-input flag present', async () => {
process.argv = [...process.argv, '--no-input'];
const options = [
{ id: 'x', label: 'X' },
{ id: 'y', label: 'Y' },
];
const result = await InteractivePrompt.selectFromList('Pick:', options);
assert.strictEqual(result, 'x');
});
});
describe('options structure', () => {
it('accepts options with id and label', async () => {
process.env.CCS_YES = '1';
const options = [
{ id: 'gemini-claude-opus-4-5-thinking', label: 'Claude Opus 4.5 Thinking' },
{ id: 'gemini-claude-sonnet-4-5', label: 'Claude Sonnet 4.5' },
];
try {
const result = await InteractivePrompt.selectFromList('Select:', options);
assert.strictEqual(result, 'gemini-claude-opus-4-5-thinking');
} finally {
delete process.env.CCS_YES;
}
});
it('respects custom defaultIndex', async () => {
process.env.CCS_YES = '1';
const options = [
{ id: 'first', label: 'First' },
{ id: 'second', label: 'Second' },
{ id: 'third', label: 'Third' },
];
try {
const result = await InteractivePrompt.selectFromList('Select:', options, {
defaultIndex: 2,
});
assert.strictEqual(result, 'third');
} finally {
delete process.env.CCS_YES;
}
});
it('defaults to index 0 when no defaultIndex provided', async () => {
process.env.CCS_YES = '1';
const options = [
{ id: 'a', label: 'A' },
{ id: 'b', label: 'B' },
];
try {
const result = await InteractivePrompt.selectFromList('Select:', options);
assert.strictEqual(result, 'a');
} finally {
delete process.env.CCS_YES;
}
});
});
});
describe('password - bracketed paste handling', () => {
/**
* Test helper: Simulates the escape sequence filtering logic from password()
* This mirrors the implementation to verify bracketed paste sequences are stripped
*/
function stripBracketedPaste(input) {
let result = '';
let escapeBuffer = '';
for (const char of input) {
const charCode = char.charCodeAt(0);
// ESC character (start of escape sequence)
if (charCode === 27) {
escapeBuffer = '\x1b';
continue;
}
// If we're in an escape sequence, buffer chars until we detect the pattern
if (escapeBuffer) {
escapeBuffer += char;
// Check for bracketed paste sequences: ESC[200~ (start) or ESC[201~ (end)
if (escapeBuffer === '\x1b[200~' || escapeBuffer === '\x1b[201~') {
escapeBuffer = '';
continue;
}
// If buffer is getting too long without match, it's not a paste sequence
if (escapeBuffer.length > 6) {
escapeBuffer = '';
}
continue;
}
// Regular printable character
if (charCode >= 32) {
result += char;
}
}
return result;
}
it('strips ESC[200~ (start paste) sequence', () => {
const input = '\x1b[200~sk-ant-api-key\x1b[201~';
const result = stripBracketedPaste(input);
assert.strictEqual(result, 'sk-ant-api-key');
});
it('handles API key pasted with bracketed paste mode', () => {
const pastedKey = '\x1b[200~sk-ant-api03-abcdefghijklmnop\x1b[201~';
const result = stripBracketedPaste(pastedKey);
assert.strictEqual(result, 'sk-ant-api03-abcdefghijklmnop');
});
it('passes through normal typed input without escape sequences', () => {
const typedKey = 'sk-ant-api03-normal-typing';
const result = stripBracketedPaste(typedKey);
assert.strictEqual(result, 'sk-ant-api03-normal-typing');
});
it('handles only start paste sequence', () => {
const input = '\x1b[200~my-api-key';
const result = stripBracketedPaste(input);
assert.strictEqual(result, 'my-api-key');
});
it('handles only end paste sequence', () => {
const input = 'my-api-key\x1b[201~';
const result = stripBracketedPaste(input);
assert.strictEqual(result, 'my-api-key');
});
it('handles multiple paste sequences', () => {
const input = '\x1b[200~first\x1b[201~\x1b[200~second\x1b[201~';
const result = stripBracketedPaste(input);
assert.strictEqual(result, 'firstsecond');
});
it('handles empty paste', () => {
const input = '\x1b[200~\x1b[201~';
const result = stripBracketedPaste(input);
assert.strictEqual(result, '');
});
});
describe('confirm', () => {
it('returns true when CCS_YES=1', async () => {
process.env.CCS_YES = '1';
try {
const result = await InteractivePrompt.confirm('Proceed?');
assert.strictEqual(result, true);
} finally {
delete process.env.CCS_YES;
}
});
it('returns true when --yes flag present', async () => {
process.argv = [...process.argv, '--yes'];
const result = await InteractivePrompt.confirm('Continue?');
assert.strictEqual(result, true);
});
it('returns true when -y flag present', async () => {
process.argv = [...process.argv, '-y'];
const result = await InteractivePrompt.confirm('Continue?');
assert.strictEqual(result, true);
});
});
});