From 1361c054ff861c5dd493d87e24f0ab978a3e4198 Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Thu, 4 Dec 2025 06:06:12 +0000 Subject: [PATCH 01/24] chore(release): 5.5.0-dev.1 [skip ci] # [5.5.0-dev.1](https://github.com/kaitranntt/ccs/compare/v5.4.4-dev.2...v5.5.0-dev.1) (2025-12-04) ### Features * **kimi:** update default model to kimi-k2-thinking-turbo ([134511c](https://github.com/kaitranntt/ccs/commit/134511c38b581a720da6b9d7e6608ca6b3c63fb1)) --- CHANGELOG.md | 7 +++++++ VERSION | 2 +- installers/install.ps1 | 2 +- installers/install.sh | 2 +- package.json | 2 +- 5 files changed, 11 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 44ce9b98..05e4f393 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +# [5.5.0-dev.1](https://github.com/kaitranntt/ccs/compare/v5.4.4-dev.2...v5.5.0-dev.1) (2025-12-04) + + +### Features + +* **kimi:** update default model to kimi-k2-thinking-turbo ([134511c](https://github.com/kaitranntt/ccs/commit/134511c38b581a720da6b9d7e6608ca6b3c63fb1)) + ## [5.4.4-dev.2](https://github.com/kaitranntt/ccs/compare/v5.4.4-dev.1...v5.4.4-dev.2) (2025-12-04) diff --git a/VERSION b/VERSION index 13fc2320..ae3eabd3 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -5.4.4-dev.2 +5.5.0-dev.1 diff --git a/installers/install.ps1 b/installers/install.ps1 index 2f1f712d..b8a25597 100644 --- a/installers/install.ps1 +++ b/installers/install.ps1 @@ -83,7 +83,7 @@ $InstallMethod = if ($ScriptDir -and ((Test-Path "$ScriptDir\lib\ccs.ps1") -or ( # IMPORTANT: Update this version when releasing new versions! # This hardcoded version is used for standalone installations (irm | iex) # For git installations, VERSION file is read if available -$CcsVersion = "5.4.4-dev.2" +$CcsVersion = "5.5.0-dev.1" # Try to read VERSION file for git installations if ($ScriptDir) { diff --git a/installers/install.sh b/installers/install.sh index a2dd1187..7f346bbc 100755 --- a/installers/install.sh +++ b/installers/install.sh @@ -84,7 +84,7 @@ fi # IMPORTANT: Update this version when releasing new versions! # This hardcoded version is used for standalone installations (curl | bash) # For git installations, VERSION file is read if available -CCS_VERSION="5.4.4-dev.2" +CCS_VERSION="5.5.0-dev.1" # Try to read VERSION file for git installations if [[ -f "$SCRIPT_DIR/VERSION" ]]; then diff --git a/package.json b/package.json index 69130146..202f7d13 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "5.4.4-dev.2", + "version": "5.5.0-dev.1", "description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6", "keywords": [ "cli", From 808a890db377bbeabd923ff3ebfc456778d47234 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Thu, 4 Dec 2025 01:15:08 -0500 Subject: [PATCH 02/24] ci(workflow): add auto-sync dev after main release Automatically merges main into dev after Release workflow completes. Keeps dev ahead with next version number after stable releases. --- .github/workflows/sync-dev-after-release.yml | 35 ++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 .github/workflows/sync-dev-after-release.yml diff --git a/.github/workflows/sync-dev-after-release.yml b/.github/workflows/sync-dev-after-release.yml new file mode 100644 index 00000000..1e1fdbc2 --- /dev/null +++ b/.github/workflows/sync-dev-after-release.yml @@ -0,0 +1,35 @@ +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 + git merge origin/main --no-edit -m "chore(sync): merge main into dev after release [skip ci]" + git push origin dev From 942b4b92cfce054c0886d8508f1c15ad18fd4400 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Thu, 4 Dec 2025 01:23:46 -0500 Subject: [PATCH 03/24] feat(release): simplify dev versioning with stable base - Remove dev branch from semantic-release (main only) - Add dev-release.yml: simple X.Y.Z-dev.N bump workflow - Update sync workflow: handle merge conflicts for version files - Update release.yml: trigger only on main branch Dev versions now stay at {stable}-dev.N until merged to main. PR merge commit type (feat/fix) determines actual version bump. --- .github/workflows/dev-release.yml | 88 ++++++++++++++++++++ .github/workflows/release.yml | 4 +- .github/workflows/sync-dev-after-release.yml | 18 +++- .releaserc.json | 5 +- 4 files changed, 107 insertions(+), 8 deletions(-) create mode 100644 .github/workflows/dev-release.yml diff --git a/.github/workflows/dev-release.yml b/.github/workflows/dev-release.yml new file mode 100644 index 00000000..e0f08d78 --- /dev/null +++ b/.github/workflows/dev-release.yml @@ -0,0 +1,88 @@ +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) + + # Extract base version and dev number + if [[ "$CURRENT" =~ ^([0-9]+\.[0-9]+\.[0-9]+)(-dev\.([0-9]+))?$ ]]; then + BASE="${BASH_REMATCH[1]}" + DEV_NUM="${BASH_REMATCH[3]:-0}" + NEW_DEV=$((DEV_NUM + 1)) + NEW_VERSION="${BASE}-dev.${NEW_DEV}" + else + echo "Invalid version format: $CURRENT" + exit 1 + fi + + 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 index 1e1fdbc2..48495fe6 100644 --- a/.github/workflows/sync-dev-after-release.yml +++ b/.github/workflows/sync-dev-after-release.yml @@ -31,5 +31,21 @@ jobs: run: | git fetch origin main dev git checkout dev - git merge origin/main --no-edit -m "chore(sync): merge main into dev after release [skip ci]" + + # 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", From 482f3a7fc66f1b93a1b7e24e00a87c9858574ebd Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Thu, 4 Dec 2025 01:26:05 -0500 Subject: [PATCH 04/24] fix(dev-release): find next available dev version from npm --- .github/workflows/dev-release.yml | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/.github/workflows/dev-release.yml b/.github/workflows/dev-release.yml index e0f08d78..8282d7cb 100644 --- a/.github/workflows/dev-release.yml +++ b/.github/workflows/dev-release.yml @@ -43,18 +43,32 @@ jobs: id: bump run: | CURRENT=$(cat VERSION) + PKG_NAME=$(jq -r '.name' package.json) - # Extract base version and dev number + # Extract base version if [[ "$CURRENT" =~ ^([0-9]+\.[0-9]+\.[0-9]+)(-dev\.([0-9]+))?$ ]]; then BASE="${BASH_REMATCH[1]}" - DEV_NUM="${BASH_REMATCH[3]:-0}" - NEW_DEV=$((DEV_NUM + 1)) - NEW_VERSION="${BASE}-dev.${NEW_DEV}" 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" From 5d5d62fac9408fefcba2ba25474999cf9605aabe Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 4 Dec 2025 06:27:13 +0000 Subject: [PATCH 05/24] chore(release): 5.5.0-dev.2 [skip ci] --- VERSION | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/VERSION b/VERSION index d50359de..59b9ef41 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -5.5.0 +5.5.0-dev.2 diff --git a/package.json b/package.json index b539b40d..ba3dbe35 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "5.5.0", + "version": "5.5.0-dev.2", "description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6", "keywords": [ "cli", From e3edcf613e28a48fb7cb5c2c90ffed3c80cb0c62 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Thu, 4 Dec 2025 03:19:01 -0500 Subject: [PATCH 06/24] fix(doctor): use actual installed clipproxy version instead of hardcoded --- src/cliproxy/binary-manager.ts | 16 ++++++++++++++++ src/cliproxy/index.ts | 1 + src/management/doctor.ts | 7 ++++--- 3 files changed, 21 insertions(+), 3 deletions(-) diff --git a/src/cliproxy/binary-manager.ts b/src/cliproxy/binary-manager.ts index 9f685314..b2d26bb1 100644 --- a/src/cliproxy/binary-manager.ts +++ b/src/cliproxy/binary-manager.ts @@ -900,4 +900,20 @@ 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; +} + export default BinaryManager; diff --git a/src/cliproxy/index.ts b/src/cliproxy/index.ts index 8673067d..27837e32 100644 --- a/src/cliproxy/index.ts +++ b/src/cliproxy/index.ts @@ -40,6 +40,7 @@ export { ensureCLIProxyBinary, isCLIProxyInstalled, getCLIProxyPath, + getInstalledCliproxyVersion, } from './binary-manager'; // Config generation 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(); From 2126e83da6649df9250d97b1399cc2b779efbc5a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 4 Dec 2025 08:20:32 +0000 Subject: [PATCH 07/24] chore(release): 5.5.0-dev.3 [skip ci] --- VERSION | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/VERSION b/VERSION index 59b9ef41..92a6c8b7 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -5.5.0-dev.2 +5.5.0-dev.3 diff --git a/package.json b/package.json index ba3dbe35..11e05bdc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "5.5.0-dev.2", + "version": "5.5.0-dev.3", "description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6", "keywords": [ "cli", From b18163c57b59cabbd7d18165b28933155d94d74a Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Thu, 4 Dec 2025 03:23:43 -0500 Subject: [PATCH 08/24] fix(update): add --help support and --dev alias for update command --- src/ccs.ts | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/src/ccs.ts b/src/ccs.ts index 23d439d8..3846d23b 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; } From c06e7ab871b767c608b21302c86a97ec05ef6199 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 4 Dec 2025 08:25:37 +0000 Subject: [PATCH 09/24] chore(release): 5.5.0-dev.4 [skip ci] --- VERSION | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/VERSION b/VERSION index 92a6c8b7..093b5622 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -5.5.0-dev.3 +5.5.0-dev.4 diff --git a/package.json b/package.json index 11e05bdc..4c0163e9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "5.5.0-dev.3", + "version": "5.5.0-dev.4", "description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6", "keywords": [ "cli", From 4654c15577307457f8eb86ca9718b527460c7c40 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Thu, 4 Dec 2025 04:45:11 -0500 Subject: [PATCH 10/24] feat(cliproxy): add model catalog with configuration management Introduces new model-catalog and model-config modules to manage LLM provider configurations and aliases. Enables dynamic model selection with validated configuration per provider. --- src/cliproxy/cliproxy-executor.ts | 23 ++- src/cliproxy/index.ts | 10 + src/cliproxy/model-catalog.ts | 81 ++++++++ src/cliproxy/model-config.ts | 168 +++++++++++++++++ tests/unit/cliproxy/model-catalog.test.js | 216 ++++++++++++++++++++++ tests/unit/cliproxy/model-config.test.js | 162 ++++++++++++++++ 6 files changed, 656 insertions(+), 4 deletions(-) create mode 100644 src/cliproxy/model-catalog.ts create mode 100644 src/cliproxy/model-config.ts create mode 100644 tests/unit/cliproxy/model-catalog.test.js create mode 100644 tests/unit/cliproxy/model-config.test.js diff --git a/src/cliproxy/cliproxy-executor.ts b/src/cliproxy/cliproxy-executor.ts index 695adace..df768b0d 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 } from './model-config'; +import { supportsModelConfig } 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,16 @@ 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. 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 +241,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 27837e32..eeffe239 100644 --- a/src/cliproxy/index.ts +++ b/src/cliproxy/index.ts @@ -70,6 +70,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..557cf828 --- /dev/null +++ b/src/cliproxy/model-catalog.ts @@ -0,0 +1,81 @@ +/** + * 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 */ + tier?: 'free' | 'paid'; +} + +/** + * 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' }, + { id: 'gemini-claude-sonnet-4-5-thinking', name: 'Claude Sonnet 4.5 Thinking' }, + { id: 'gemini-claude-sonnet-4-5', name: 'Claude Sonnet 4.5' }, + { id: 'gemini-3-pro-preview', name: 'Gemini 3 Pro', tier: 'paid' }, + ], + }, + gemini: { + provider: 'gemini', + displayName: 'Gemini', + defaultModel: 'gemini-2.5-pro', + models: [ + { id: 'gemini-3-pro-preview', name: 'Gemini 3 Pro', tier: 'paid' }, + { id: 'gemini-2.5-pro', name: 'Gemini 2.5 Pro' }, + ], + }, +}; + +/** + * 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); +} diff --git a/src/cliproxy/model-config.ts b/src/cliproxy/model-config.ts new file mode 100644 index 00000000..1ff67b97 --- /dev/null +++ b/src/cliproxy/model-config.ts @@ -0,0 +1,168 @@ +/** + * 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'; + +/** 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 + */ +function formatModelOption(model: ModelEntry): string { + const tierLabel = model.tier === 'paid' ? ' [paid]' : ''; + return `${model.name} (${model.id})${tierLabel}`; +} + +/** + * 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; + } + + // Build options list + const options = catalog.models.map((m) => ({ + id: m.id, + label: formatModelOption(m), + })); + + // Find default index + const defaultIdx = catalog.models.findIndex((m) => m.id === catalog.defaultModel); + const safeDefaultIdx = defaultIdx >= 0 ? defaultIdx : 0; + + // Show header + console.error(''); + console.error(`[i] Configure ${catalog.displayName} model`); + + // 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: ${displayName} (${selectedModel})`); + console.error(`[i] Saved to: ${settingsPath}`); + console.error(''); + + return true; +} + +/** + * Show current model configuration + */ +export function showCurrentConfig(provider: CLIProxyProvider): void { + if (!supportsModelConfig(provider)) { + console.error(`[i] Provider ${provider} does not support model configuration`); + return; + } + + const catalog = getProviderCatalog(provider); + if (!catalog) return; + + const currentModel = getCurrentModel(provider); + const settingsPath = getProviderSettingsPath(provider); + + console.error(''); + console.error(`[i] ${catalog.displayName} Model Configuration`); + console.error(''); + + if (currentModel) { + const entry = catalog.models.find((m) => m.id === currentModel); + const displayName = entry?.name || 'Unknown'; + console.error(` Current: ${displayName} (${currentModel})`); + console.error(` Config: ${settingsPath}`); + } else { + console.error(` Current: (using defaults)`); + console.error(` Default: ${catalog.defaultModel}`); + } + + console.error(''); + console.error('Available models:'); + catalog.models.forEach((m) => { + const isCurrent = m.id === currentModel; + const marker = isCurrent ? '>' : ' '; + console.error(` ${marker} ${formatModelOption(m)}`); + }); + + console.error(''); + console.error(`Run "ccs ${provider} --config" to change`); + console.error(''); +} 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..24118bd7 --- /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', () => { + const { showCurrentConfig } = modelConfig; + // Just verify it doesn't throw + assert.doesNotThrow(() => showCurrentConfig('agy')); + }); + + it('does not throw for gemini provider', () => { + const { showCurrentConfig } = modelConfig; + assert.doesNotThrow(() => showCurrentConfig('gemini')); + }); + + it('does not throw for unsupported provider', () => { + const { showCurrentConfig } = modelConfig; + assert.doesNotThrow(() => 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})`, + }), +}; From 3bdbff9345c2eb21d861621e56430de5bac61fc4 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Thu, 4 Dec 2025 04:45:26 -0500 Subject: [PATCH 11/24] feat(prompt): add password input utility with masking Adds new password prompt utility with customizable masking for secure credential collection. Integrates with help command for improved UX. --- src/commands/help-command.ts | 7 +- src/utils/prompt.ts | 94 +++++++++++++++++ tests/unit/utils/prompt.test.js | 181 ++++++++++++++++++++++++++++++++ 3 files changed, 279 insertions(+), 3 deletions(-) create mode 100644 tests/unit/utils/prompt.test.js diff --git a/src/commands/help-command.ts b/src/commands/help-command.ts index 5635e90f..7f17601c 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'], diff --git a/src/utils/prompt.ts b/src/utils/prompt.ts index 36ae0b80..08330081 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 @@ -205,4 +214,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/utils/prompt.test.js b/tests/unit/utils/prompt.test.js new file mode 100644 index 00000000..9157e91a --- /dev/null +++ b/tests/unit/utils/prompt.test.js @@ -0,0 +1,181 @@ +/** + * 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('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); + }); + }); +}); From 0d4d2daf0689d0d6f851eb4b013d2b4085334835 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 4 Dec 2025 09:46:50 +0000 Subject: [PATCH 12/24] chore(release): 5.5.0-dev.5 [skip ci] --- VERSION | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/VERSION b/VERSION index 093b5622..d1076ce6 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -5.5.0-dev.4 +5.5.0-dev.5 diff --git a/package.json b/package.json index 4c0163e9..38ae9dba 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "5.5.0-dev.4", + "version": "5.5.0-dev.5", "description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6", "keywords": [ "cli", From df31ffcee7872b8d263451807818b368a9ba1eb4 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Thu, 4 Dec 2025 05:05:45 -0500 Subject: [PATCH 13/24] fix(prompt): strip bracketed paste escape sequences from password input Terminals using bracketed paste mode wrap pasted content with ESC[200~ (start) and ESC[201~ (end) sequences. These were incorrectly passed through to API keys, causing "[200~API_KEY[201~" instead of "API_KEY". Now buffers ESC sequences and discards recognized paste markers. --- src/utils/prompt.ts | 32 ++++++++++++ tests/unit/utils/prompt.test.js | 87 +++++++++++++++++++++++++++++++++ 2 files changed, 119 insertions(+) diff --git a/src/utils/prompt.ts b/src/utils/prompt.ts index 08330081..ae15b382 100644 --- a/src/utils/prompt.ts +++ b/src/utils/prompt.ts @@ -143,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; @@ -162,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) { @@ -177,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(); diff --git a/tests/unit/utils/prompt.test.js b/tests/unit/utils/prompt.test.js index 9157e91a..d32a8975 100644 --- a/tests/unit/utils/prompt.test.js +++ b/tests/unit/utils/prompt.test.js @@ -152,6 +152,93 @@ describe('InteractivePrompt', () => { }); }); + 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'; From e3a18358e9e41b8a42bd4f40364362fbf02fb879 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 4 Dec 2025 10:07:17 +0000 Subject: [PATCH 14/24] chore(release): 5.5.0-dev.6 [skip ci] --- VERSION | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/VERSION b/VERSION index d1076ce6..735dd313 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -5.5.0-dev.5 +5.5.0-dev.6 diff --git a/package.json b/package.json index 38ae9dba..23c72adf 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "5.5.0-dev.5", + "version": "5.5.0-dev.6", "description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6", "keywords": [ "cli", From 20b7bae046ecbee1b7a5d0e6ec373035e4ab7786 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Thu, 4 Dec 2025 05:29:55 -0500 Subject: [PATCH 15/24] refactor(cliproxy): use centralized ui system for model config display Replace custom formatting with ui.ts imports (color, bold, dim, ok, info, header, initUI) to maintain consistent styling with ccs doctor. Add model descriptions to catalog and clarify [Paid API] tier labeling. Update showCurrentConfig to async for UI initialization. Improves code reuse and visual consistency across CLI commands. --- src/cliproxy/model-catalog.ts | 42 +++++++++++++--- src/cliproxy/model-config.ts | 62 +++++++++++++++++------- tests/unit/cliproxy/model-config.test.js | 14 +++--- 3 files changed, 86 insertions(+), 32 deletions(-) diff --git a/src/cliproxy/model-catalog.ts b/src/cliproxy/model-catalog.ts index 557cf828..0fccc017 100644 --- a/src/cliproxy/model-catalog.ts +++ b/src/cliproxy/model-catalog.ts @@ -15,8 +15,10 @@ export interface ModelEntry { id: string; /** Human-readable name for display */ name: string; - /** Access tier indicator */ + /** Access tier indicator - 'paid' means requires paid API tier (not model pricing) */ tier?: 'free' | 'paid'; + /** Optional description for the model */ + description?: string; } /** @@ -40,10 +42,27 @@ export const MODEL_CATALOG: Partial> = displayName: 'Antigravity', defaultModel: 'gemini-3-pro-preview', models: [ - { id: 'gemini-claude-opus-4-5-thinking', name: 'Claude Opus 4.5 Thinking' }, - { id: 'gemini-claude-sonnet-4-5-thinking', name: 'Claude Sonnet 4.5 Thinking' }, - { id: 'gemini-claude-sonnet-4-5', name: 'Claude Sonnet 4.5' }, - { id: 'gemini-3-pro-preview', name: 'Gemini 3 Pro', tier: 'paid' }, + { + id: 'gemini-claude-opus-4-5-thinking', + name: 'Claude Opus 4.5 Thinking', + description: 'Most capable, extended thinking', + }, + { + id: 'gemini-claude-sonnet-4-5-thinking', + name: 'Claude Sonnet 4.5 Thinking', + description: 'Balanced with extended thinking', + }, + { + id: 'gemini-claude-sonnet-4-5', + name: 'Claude Sonnet 4.5', + description: 'Fast and capable', + }, + { + id: 'gemini-3-pro-preview', + name: 'Gemini 3 Pro', + tier: 'paid', + description: 'Google latest, requires Paid API tier', + }, ], }, gemini: { @@ -51,8 +70,17 @@ export const MODEL_CATALOG: Partial> = displayName: 'Gemini', defaultModel: 'gemini-2.5-pro', models: [ - { id: 'gemini-3-pro-preview', name: 'Gemini 3 Pro', tier: 'paid' }, - { id: 'gemini-2.5-pro', name: 'Gemini 2.5 Pro' }, + { + id: 'gemini-3-pro-preview', + name: 'Gemini 3 Pro', + tier: 'paid', + description: 'Latest model, requires Paid API tier', + }, + { + id: 'gemini-2.5-pro', + name: 'Gemini 2.5 Pro', + description: 'Stable release, works with free tier', + }, ], }, }; diff --git a/src/cliproxy/model-config.ts b/src/cliproxy/model-config.ts index 1ff67b97..d58c6c11 100644 --- a/src/cliproxy/model-config.ts +++ b/src/cliproxy/model-config.ts @@ -11,6 +11,7 @@ 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'); @@ -39,11 +40,23 @@ export function getCurrentModel(provider: CLIProxyProvider): string | undefined } /** - * Format model entry for display + * Format model entry for display in selection list */ function formatModelOption(model: ModelEntry): string { - const tierLabel = model.tier === 'paid' ? ' [paid]' : ''; - return `${model.name} (${model.id})${tierLabel}`; + // Tier badge: clarify that "paid" means API tier, not model pricing + const tierBadge = model.tier === 'paid' ? color(' [Paid API]', 'warning') : ''; + return `${model.name}${tierBadge}`; +} + +/** + * 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 API]', 'warning') : ''; + const desc = model.description ? dim(` - ${model.description}`) : ''; + return ` ${marker} ${name}${tierBadge}${desc}`; } /** @@ -72,6 +85,9 @@ export async function configureProviderModel( return false; } + // Initialize UI for colors/gradient + await initUI(); + // Build options list const options = catalog.models.map((m) => ({ id: m.id, @@ -82,9 +98,13 @@ export async function configureProviderModel( const defaultIdx = catalog.models.findIndex((m) => m.id === catalog.defaultModel); const safeDefaultIdx = defaultIdx >= 0 ? defaultIdx : 0; - // Show header + // 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 API] require a paid Google AI Studio API key.')); console.error(''); - console.error(`[i] Configure ${catalog.displayName} model`); // Interactive selection const selectedModel = await InteractivePrompt.selectFromList('Select model:', options, { @@ -118,8 +138,8 @@ export async function configureProviderModel( const displayName = selectedEntry?.name || selectedModel; console.error(''); - console.error(`[OK] Model set to: ${displayName} (${selectedModel})`); - console.error(`[i] Saved to: ${settingsPath}`); + console.error(ok(`Model set to: ${bold(displayName)}`)); + console.error(dim(` Config saved: ${settingsPath}`)); console.error(''); return true; @@ -128,41 +148,47 @@ export async function configureProviderModel( /** * Show current model configuration */ -export function showCurrentConfig(provider: CLIProxyProvider): void { +export async function showCurrentConfig(provider: CLIProxyProvider): Promise { if (!supportsModelConfig(provider)) { - console.error(`[i] Provider ${provider} does not support model configuration`); + 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(`[i] ${catalog.displayName} Model Configuration`); + 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(` Current: ${displayName} (${currentModel})`); - console.error(` Config: ${settingsPath}`); + console.error( + ` ${bold('Current:')} ${color(displayName, 'success')} ${dim(`(${currentModel})`)}` + ); + console.error(` ${bold('Config:')} ${dim(settingsPath)}`); } else { - console.error(` Current: (using defaults)`); - console.error(` Default: ${catalog.defaultModel}`); + console.error(` ${bold('Current:')} ${dim('(using defaults)')}`); + console.error(` ${bold('Default:')} ${catalog.defaultModel}`); } console.error(''); - console.error('Available models:'); + console.error(bold('Available models:')); + console.error(dim(' [Paid API] = Requires paid Google AI Studio API key')); + console.error(''); catalog.models.forEach((m) => { const isCurrent = m.id === currentModel; - const marker = isCurrent ? '>' : ' '; - console.error(` ${marker} ${formatModelOption(m)}`); + console.error(formatModelDetailed(m, isCurrent)); }); console.error(''); - console.error(`Run "ccs ${provider} --config" to change`); + console.error(dim(`Run "ccs ${provider} --config" to change`)); console.error(''); } diff --git a/tests/unit/cliproxy/model-config.test.js b/tests/unit/cliproxy/model-config.test.js index 24118bd7..807dad9b 100644 --- a/tests/unit/cliproxy/model-config.test.js +++ b/tests/unit/cliproxy/model-config.test.js @@ -55,20 +55,20 @@ describe('Model Config', () => { }); describe('showCurrentConfig', () => { - it('does not throw for agy provider', () => { + it('does not throw for agy provider', async () => { const { showCurrentConfig } = modelConfig; - // Just verify it doesn't throw - assert.doesNotThrow(() => showCurrentConfig('agy')); + // Just verify it doesn't throw (now async) + await assert.doesNotReject(async () => showCurrentConfig('agy')); }); - it('does not throw for gemini provider', () => { + it('does not throw for gemini provider', async () => { const { showCurrentConfig } = modelConfig; - assert.doesNotThrow(() => showCurrentConfig('gemini')); + await assert.doesNotReject(async () => showCurrentConfig('gemini')); }); - it('does not throw for unsupported provider', () => { + it('does not throw for unsupported provider', async () => { const { showCurrentConfig } = modelConfig; - assert.doesNotThrow(() => showCurrentConfig('codex')); + await assert.doesNotReject(async () => showCurrentConfig('codex')); }); }); From 7937009665ba96fa3f173a17c778dce2e747c40e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 4 Dec 2025 10:31:30 +0000 Subject: [PATCH 16/24] chore(release): 5.5.0-dev.7 [skip ci] --- VERSION | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/VERSION b/VERSION index 735dd313..0f36985a 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -5.5.0-dev.6 +5.5.0-dev.7 diff --git a/package.json b/package.json index 23c72adf..e3020a44 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "5.5.0-dev.6", + "version": "5.5.0-dev.7", "description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6", "keywords": [ "cli", From 848fbf4686b49305c26ef85da339b12dffa51b5b Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Thu, 4 Dec 2025 05:41:10 -0500 Subject: [PATCH 17/24] fix(cliproxy): clarify paid tier messaging to reference google account tier Replace misleading "[Paid API]" and "API key" references with "[Paid Tier]" and clarify that paid tier refers to a paid Google account (not free tier), not an API key purchase. Updates model descriptions in both catalog and configuration to accurately reflect the tier requirement. --- src/cliproxy/model-catalog.ts | 8 ++++---- src/cliproxy/model-config.ts | 12 +++++++----- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/src/cliproxy/model-catalog.ts b/src/cliproxy/model-catalog.ts index 0fccc017..5c609330 100644 --- a/src/cliproxy/model-catalog.ts +++ b/src/cliproxy/model-catalog.ts @@ -15,7 +15,7 @@ export interface ModelEntry { id: string; /** Human-readable name for display */ name: string; - /** Access tier indicator - 'paid' means requires paid API tier (not model pricing) */ + /** Access tier indicator - 'paid' means requires paid Google account (not free tier) */ tier?: 'free' | 'paid'; /** Optional description for the model */ description?: string; @@ -61,7 +61,7 @@ export const MODEL_CATALOG: Partial> = id: 'gemini-3-pro-preview', name: 'Gemini 3 Pro', tier: 'paid', - description: 'Google latest, requires Paid API tier', + description: 'Google latest, requires paid Google account', }, ], }, @@ -74,12 +74,12 @@ export const MODEL_CATALOG: Partial> = id: 'gemini-3-pro-preview', name: 'Gemini 3 Pro', tier: 'paid', - description: 'Latest model, requires Paid API tier', + description: 'Latest model, requires paid Google account', }, { id: 'gemini-2.5-pro', name: 'Gemini 2.5 Pro', - description: 'Stable release, works with free tier', + description: 'Stable, works with free Google account', }, ], }, diff --git a/src/cliproxy/model-config.ts b/src/cliproxy/model-config.ts index d58c6c11..cc5bcfa4 100644 --- a/src/cliproxy/model-config.ts +++ b/src/cliproxy/model-config.ts @@ -43,8 +43,8 @@ export function getCurrentModel(provider: CLIProxyProvider): string | undefined * Format model entry for display in selection list */ function formatModelOption(model: ModelEntry): string { - // Tier badge: clarify that "paid" means API tier, not model pricing - const tierBadge = model.tier === 'paid' ? color(' [Paid API]', 'warning') : ''; + // Tier badge: clarify that "paid" means paid Google account (not free tier) + const tierBadge = model.tier === 'paid' ? color(' [Paid Tier]', 'warning') : ''; return `${model.name}${tierBadge}`; } @@ -54,7 +54,7 @@ function formatModelOption(model: ModelEntry): string { 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 API]', 'warning') : ''; + const tierBadge = model.tier === 'paid' ? color(' [Paid Tier]', 'warning') : ''; const desc = model.description ? dim(` - ${model.description}`) : ''; return ` ${marker} ${name}${tierBadge}${desc}`; } @@ -103,7 +103,9 @@ export async function configureProviderModel( 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 API] require a paid Google AI Studio API key.')); + console.error( + dim(' Models marked [Paid Tier] require a paid Google account (not free tier).') + ); console.error(''); // Interactive selection @@ -181,7 +183,7 @@ export async function showCurrentConfig(provider: CLIProxyProvider): Promise { const isCurrent = m.id === currentModel; From fcfa7ce35f0aa225ae1b64fc1ae18f20f4679f65 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 4 Dec 2025 10:42:35 +0000 Subject: [PATCH 18/24] chore(release): 5.5.0-dev.8 [skip ci] --- VERSION | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/VERSION b/VERSION index 0f36985a..739045da 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -5.5.0-dev.7 +5.5.0-dev.8 diff --git a/package.json b/package.json index e3020a44..f3f8db3a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "5.5.0-dev.7", + "version": "5.5.0-dev.8", "description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6", "keywords": [ "cli", From fdb8761cfac416831a8c3ae64f5718179517e3d0 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Thu, 4 Dec 2025 06:14:14 -0500 Subject: [PATCH 19/24] fix(cliproxy): correct model selection default and update fallback version - Fix --config showing wrong default star position by using current user model instead of catalog default when reconfiguring - Update CLIPROXY_FALLBACK_VERSION from 6.5.31 to 6.5.40 --- src/cliproxy/model-config.ts | 6 ++++-- src/cliproxy/platform-detector.ts | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/cliproxy/model-config.ts b/src/cliproxy/model-config.ts index cc5bcfa4..b5b4311c 100644 --- a/src/cliproxy/model-config.ts +++ b/src/cliproxy/model-config.ts @@ -94,8 +94,10 @@ export async function configureProviderModel( label: formatModelOption(m), })); - // Find default index - const defaultIdx = catalog.models.findIndex((m) => m.id === catalog.defaultModel); + // 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) 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; From 43e45c157c0e4d2d99960e10b00b98cac9ecaa86 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 4 Dec 2025 11:15:43 +0000 Subject: [PATCH 20/24] chore(release): 5.5.0-dev.9 [skip ci] --- VERSION | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/VERSION b/VERSION index 739045da..2f34054d 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -5.5.0-dev.8 +5.5.0-dev.9 diff --git a/package.json b/package.json index f3f8db3a..d1deeae6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "5.5.0-dev.8", + "version": "5.5.0-dev.9", "description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6", "keywords": [ "cli", From 0e11426daa8896ba58aa9d53889818ab3577e250 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Thu, 4 Dec 2025 06:22:11 -0500 Subject: [PATCH 21/24] feat(cliproxy): add warning for broken claude proxy models on agy - add broken flag to model catalog for models with known issues - display [BROKEN] badge in model selection and config display - show warning message when starting with a broken model - link to tracking issue: CLIProxyAPI#415 Claude proxy models (gemini-claude-*) currently fail with tool calls due to upstream CLIProxyAPI bug. Warning helps users know to switch to gemini-3-pro-preview until fixed. --- src/cliproxy/cliproxy-executor.ts | 23 ++++++++++++++++++++--- src/cliproxy/model-catalog.ts | 26 ++++++++++++++++++++++++++ src/cliproxy/model-config.ts | 6 ++++-- 3 files changed, 50 insertions(+), 5 deletions(-) diff --git a/src/cliproxy/cliproxy-executor.ts b/src/cliproxy/cliproxy-executor.ts index df768b0d..e0c245a8 100644 --- a/src/cliproxy/cliproxy-executor.ts +++ b/src/cliproxy/cliproxy-executor.ts @@ -26,8 +26,8 @@ import { } from './config-generator'; import { isAuthenticated } from './auth-handler'; import { CLIProxyProvider, ExecutorConfig } from './types'; -import { configureProviderModel } from './model-config'; -import { supportsModelConfig } from './model-catalog'; +import { configureProviderModel, getCurrentModel } from './model-config'; +import { supportsModelConfig, isModelBroken, getModelIssueUrl, findModel } from './model-catalog'; /** Default executor configuration */ const DEFAULT_CONFIG: ExecutorConfig = { @@ -170,7 +170,24 @@ export async function execClaudeWithCLIProxy( await configureProviderModel(provider, false); // false = only if not configured } - // 5. Ensure user settings file exists (creates from defaults if not) + // 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); // 6. Generate config file diff --git a/src/cliproxy/model-catalog.ts b/src/cliproxy/model-catalog.ts index 5c609330..767fdfaf 100644 --- a/src/cliproxy/model-catalog.ts +++ b/src/cliproxy/model-catalog.ts @@ -19,6 +19,10 @@ export interface ModelEntry { 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; } /** @@ -46,16 +50,22 @@ export const MODEL_CATALOG: Partial> = 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', @@ -107,3 +117,19 @@ export function findModel(provider: CLIProxyProvider, modelId: string): ModelEnt 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 index b5b4311c..0e9c9190 100644 --- a/src/cliproxy/model-config.ts +++ b/src/cliproxy/model-config.ts @@ -45,7 +45,8 @@ export function getCurrentModel(provider: CLIProxyProvider): string | undefined 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') : ''; - return `${model.name}${tierBadge}`; + const brokenBadge = model.broken ? color(' [BROKEN]', 'error') : ''; + return `${model.name}${tierBadge}${brokenBadge}`; } /** @@ -55,8 +56,9 @@ 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}${desc}`; + return ` ${marker} ${name}${tierBadge}${brokenBadge}${desc}`; } /** From 8cadf4d7fab3901d360fea7a923fe23f3b6ea41f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 4 Dec 2025 11:23:45 +0000 Subject: [PATCH 22/24] chore(release): 5.5.0-dev.10 [skip ci] --- VERSION | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/VERSION b/VERSION index 2f34054d..8a85e0c0 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -5.5.0-dev.9 +5.5.0-dev.10 diff --git a/package.json b/package.json index d1deeae6..4de0a706 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "5.5.0-dev.9", + "version": "5.5.0-dev.10", "description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6", "keywords": [ "cli", From 7e07615eedb7263aa359651abef8660ff0dcd95a Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Thu, 4 Dec 2025 06:35:42 -0500 Subject: [PATCH 23/24] feat(cliproxy): add version management command Allow users to install specific versions or update to latest CLIProxy via 'ccs cliproxy' command with install/update subcommands. --- src/ccs.ts | 7 ++ src/cliproxy/binary-manager.ts | 64 ++++++++-- src/cliproxy/index.ts | 2 + src/cliproxy/types.ts | 2 + src/commands/cliproxy-command.ts | 198 +++++++++++++++++++++++++++++++ src/commands/help-command.ts | 7 ++ 6 files changed, 271 insertions(+), 9 deletions(-) create mode 100644 src/commands/cliproxy-command.ts diff --git a/src/ccs.ts b/src/ccs.ts index 3846d23b..eb6f2d11 100644 --- a/src/ccs.ts +++ b/src/ccs.ts @@ -282,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 b2d26bb1..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(); @@ -916,4 +928,38 @@ export function getInstalledCliproxyVersion(): string { 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/index.ts b/src/cliproxy/index.ts index eeffe239..f7f855e5 100644 --- a/src/cliproxy/index.ts +++ b/src/cliproxy/index.ts @@ -41,6 +41,8 @@ export { isCLIProxyInstalled, getCLIProxyPath, getInstalledCliproxyVersion, + installCliproxyVersion, + fetchLatestCliproxyVersion, } from './binary-manager'; // Config generation 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 7f17601c..70b23fd2 100644 --- a/src/commands/help-command.ts +++ b/src/commands/help-command.ts @@ -204,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')}`); From ebb4e77b6db106390bfd7164a9ebe8bd36fb57df Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 4 Dec 2025 11:37:12 +0000 Subject: [PATCH 24/24] chore(release): 5.5.0-dev.11 [skip ci] --- VERSION | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/VERSION b/VERSION index 8a85e0c0..c7553f13 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -5.5.0-dev.10 +5.5.0-dev.11 diff --git a/package.json b/package.json index 4de0a706..3c660a3f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "5.5.0-dev.10", + "version": "5.5.0-dev.11", "description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6", "keywords": [ "cli",