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