diff --git a/.github/workflows/dev-release.yml b/.github/workflows/dev-release.yml new file mode 100644 index 00000000..8282d7cb --- /dev/null +++ b/.github/workflows/dev-release.yml @@ -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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 06da872f..8dc0e40f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2,9 +2,7 @@ name: Release on: push: - branches: - - main - - dev + branches: [main] workflow_dispatch: jobs: diff --git a/.github/workflows/sync-dev-after-release.yml b/.github/workflows/sync-dev-after-release.yml new file mode 100644 index 00000000..48495fe6 --- /dev/null +++ b/.github/workflows/sync-dev-after-release.yml @@ -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 diff --git a/.releaserc.json b/.releaserc.json index 8bde2f39..1809e16b 100644 --- a/.releaserc.json +++ b/.releaserc.json @@ -1,8 +1,5 @@ { - "branches": [ - "main", - { "name": "dev", "prerelease": true } - ], + "branches": ["main"], "plugins": [ "@semantic-release/commit-analyzer", "@semantic-release/release-notes-generator", diff --git a/VERSION b/VERSION index d50359de..c7553f13 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -5.5.0 +5.5.0-dev.11 diff --git a/package.json b/package.json index b539b40d..3c660a3f 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/ccs.ts b/src/ccs.ts index 23d439d8..eb6f2d11 100644 --- a/src/ccs.ts +++ b/src/ccs.ts @@ -241,8 +241,27 @@ async function main(): Promise { // 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 { 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'); diff --git a/src/cliproxy/binary-manager.ts b/src/cliproxy/binary-manager.ts index 9f685314..81a5c8f6 100644 --- a/src/cliproxy/binary-manager.ts +++ b/src/cliproxy/binary-manager.ts @@ -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 { + // 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 { + const manager = new BinaryManager(); + const result = await manager.checkForUpdates(); + return result.latestVersion; +} + export default BinaryManager; diff --git a/src/cliproxy/cliproxy-executor.ts b/src/cliproxy/cliproxy-executor.ts index 695adace..e0c245a8 100644 --- a/src/cliproxy/cliproxy-executor.ts +++ b/src/cliproxy/cliproxy-executor.ts @@ -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'; diff --git a/src/cliproxy/index.ts b/src/cliproxy/index.ts index 8673067d..f7f855e5 100644 --- a/src/cliproxy/index.ts +++ b/src/cliproxy/index.ts @@ -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'; diff --git a/src/cliproxy/model-catalog.ts b/src/cliproxy/model-catalog.ts new file mode 100644 index 00000000..767fdfaf --- /dev/null +++ b/src/cliproxy/model-catalog.ts @@ -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> = { + 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; +} diff --git a/src/cliproxy/model-config.ts b/src/cliproxy/model-config.ts new file mode 100644 index 00000000..0e9c9190 --- /dev/null +++ b/src/cliproxy/model-config.ts @@ -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 { + // 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 { + 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(''); +} diff --git a/src/cliproxy/platform-detector.ts b/src/cliproxy/platform-detector.ts index 28b5ad3b..13ef8f9b 100644 --- a/src/cliproxy/platform-detector.ts +++ b/src/cliproxy/platform-detector.ts @@ -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; diff --git a/src/cliproxy/types.ts b/src/cliproxy/types.ts index 86c20be7..a9ae85a7 100644 --- a/src/cliproxy/types.ts +++ b/src/cliproxy/types.ts @@ -46,6 +46,8 @@ export interface BinaryManagerConfig { maxRetries: number; /** Enable verbose logging */ verbose: boolean; + /** Force specific version (skip auto-upgrade to latest) */ + forceVersion?: boolean; } /** diff --git a/src/commands/cliproxy-command.ts b/src/commands/cliproxy-command.ts new file mode 100644 index 00000000..f0252d9a --- /dev/null +++ b/src/commands/cliproxy-command.ts @@ -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 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 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 { + 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 { + // 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 { + 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 { + const verbose = args.includes('--verbose') || args.includes('-v'); + + // Handle --help + if (args.includes('--help') || args.includes('-h')) { + showHelp(); + return; + } + + // Handle --install + 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 '); + 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); +} diff --git a/src/commands/help-command.ts b/src/commands/help-command.ts index 5635e90f..70b23fd2 100644 --- a/src/commands/help-command.ts +++ b/src/commands/help-command.ts @@ -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 --auth', 'Authenticate only'], + ['ccs --config', 'Change model (agy, gemini)'], ['ccs --logout', 'Clear authentication'], ['ccs --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 ', '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')}`); diff --git a/src/management/doctor.ts b/src/management/doctor.ts index 51e9f5c8..af25862a 100644 --- a/src/management/doctor.ts +++ b/src/management/doctor.ts @@ -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(); diff --git a/src/utils/prompt.ts b/src/utils/prompt.ts index 36ae0b80..ae15b382 100644 --- a/src/utils/prompt.ts +++ b/src/utils/prompt.ts @@ -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 { 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 { + 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); + }); + }); + } } diff --git a/tests/unit/cliproxy/model-catalog.test.js b/tests/unit/cliproxy/model-catalog.test.js new file mode 100644 index 00000000..2a3c67d5 --- /dev/null +++ b/tests/unit/cliproxy/model-catalog.test.js @@ -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}`); + } + }); + }); +}); diff --git a/tests/unit/cliproxy/model-config.test.js b/tests/unit/cliproxy/model-config.test.js new file mode 100644 index 00000000..807dad9b --- /dev/null +++ b/tests/unit/cliproxy/model-config.test.js @@ -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})`, + }), +}; diff --git a/tests/unit/utils/prompt.test.js b/tests/unit/utils/prompt.test.js new file mode 100644 index 00000000..d32a8975 --- /dev/null +++ b/tests/unit/utils/prompt.test.js @@ -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); + }); + }); +});