mirror of
https://github.com/tiennm99/ccs.git
synced 2026-09-20 05:11:48 +00:00
feat(cliproxy): unified config for concurrent gemini/codex usage
- Add unified CLIProxy config supporting all providers concurrently
- Move CLIProxy files to ~/.ccs/cliproxy/ subdirectory
- Use flat auth directory structure for OAuth tokens
- Add provider-specific URL routing via /api/provider/{provider}
- Add base settings templates for gemini and codex
- Fix model registration with proper auth file discovery
Enables users to run `ccs gemini` and `ccs codex` concurrently
without config conflicts.
This commit is contained in:
1 parent
5f71eba6f6
commit
efb966c96d
13 files changed
+303
-160
No files matched your search
+4
-4
@@ -7,7 +7,7 @@ Format: [Keep a Changelog](https://keepachangelog.com/)
|
||||
### Added
|
||||
- **CLIProxy OAuth Profiles**: Three new zero-config profiles powered by CLIProxyAPI
|
||||
- `ccs gemini` - Google Gemini via OAuth (zero config)
|
||||
- `ccs chatgpt` - ChatGPT/OpenAI Codex via OAuth (zero config)
|
||||
- `ccs codex` - OpenAI Codex via OAuth (zero config)
|
||||
- `ccs qwen` - Alibaba Qwen via OAuth (zero config)
|
||||
|
||||
- **Download-on-Demand Binary**: CLIProxyAPI binary (~15MB) downloads automatically on first use
|
||||
@@ -25,7 +25,7 @@ Format: [Keep a Changelog](https://keepachangelog.com/)
|
||||
- **CLIProxy Diagnostics** in `ccs doctor`:
|
||||
- Binary installation status + version
|
||||
- Config file validation
|
||||
- OAuth status per provider (gemini/chatgpt/qwen)
|
||||
- OAuth status per provider (gemini/codex/qwen)
|
||||
- Port 8317 availability check
|
||||
|
||||
- **Enhanced Error Messages** (`src/utils/error-manager.ts`):
|
||||
@@ -44,7 +44,7 @@ Format: [Keep a Changelog](https://keepachangelog.com/)
|
||||
|
||||
### Changed
|
||||
- **Profile Detection**: New priority order
|
||||
1. CLIProxy profiles (gemini, chatgpt, qwen)
|
||||
1. CLIProxy profiles (gemini, codex, qwen)
|
||||
2. Settings-based profiles (glm, glmt, kimi)
|
||||
3. Account-based profiles (work, personal)
|
||||
4. Default Claude CLI
|
||||
@@ -56,7 +56,7 @@ Format: [Keep a Changelog](https://keepachangelog.com/)
|
||||
- **Default Port**: 8317 (TCP polling for readiness, no PROXY_READY signal)
|
||||
- **Model Mappings**:
|
||||
- Gemini: gemini-2.0-flash (opus: thinking-exp, haiku: flash-lite)
|
||||
- ChatGPT: gpt-4o (opus: o1, haiku: gpt-4o-mini)
|
||||
- Codex: gpt-4o (opus: o1, haiku: gpt-4o-mini)
|
||||
- Qwen: qwen-max (sonnet: qwen-plus, haiku: qwen-turbo)
|
||||
- **Storage**:
|
||||
- Binary: `~/.ccs/bin/cliproxyapi`
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "http://127.0.0.1:8317/api/provider/codex",
|
||||
"ANTHROPIC_AUTH_TOKEN": "ccs-internal-managed",
|
||||
"ANTHROPIC_MODEL": "gpt-5.1-codex-max",
|
||||
"ANTHROPIC_DEFAULT_OPUS_MODEL": "gpt-5.1-codex-max-high",
|
||||
"ANTHROPIC_DEFAULT_SONNET_MODEL": "gpt-5.1-codex-max",
|
||||
"ANTHROPIC_DEFAULT_HAIKU_MODEL": "gpt-5.1-codex-mini-high"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "http://127.0.0.1:8317/api/provider/gemini",
|
||||
"ANTHROPIC_AUTH_TOKEN": "ccs-internal-managed",
|
||||
"ANTHROPIC_MODEL": "gemini-2.5-pro",
|
||||
"ANTHROPIC_DEFAULT_OPUS_MODEL": "gemini-3-pro-preview",
|
||||
"ANTHROPIC_DEFAULT_SONNET_MODEL": "gemini-2.5-pro",
|
||||
"ANTHROPIC_DEFAULT_HAIKU_MODEL": "gemini-2.5-flash"
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,7 @@ import { Config, Settings, ProfileMetadata } from '../types';
|
||||
export type ProfileType = 'settings' | 'account' | 'cliproxy' | 'default';
|
||||
|
||||
/** CLIProxy profile names (OAuth-based, zero config) */
|
||||
export const CLIPROXY_PROFILES = ['gemini', 'chatgpt', 'qwen'] as const;
|
||||
export const CLIPROXY_PROFILES = ['gemini', 'codex', 'qwen'] as const;
|
||||
export type CLIProxyProfileName = (typeof CLIPROXY_PROFILES)[number];
|
||||
|
||||
export interface ProfileDetectionResult {
|
||||
|
||||
+136
-71
@@ -1,23 +1,47 @@
|
||||
/**
|
||||
* Auth Handler for CLIProxyAPI
|
||||
*
|
||||
* Manages OAuth authentication for CLIProxy providers (Gemini, ChatGPT, Qwen).
|
||||
* Manages OAuth authentication for CLIProxy providers (Gemini, Codex, Qwen).
|
||||
* CLIProxyAPI handles OAuth internally - we just need to:
|
||||
* 1. Check if auth exists (token files in auth-dir)
|
||||
* 1. Check if auth exists (token files in CCS auth directory)
|
||||
* 2. Trigger OAuth flow by spawning binary with auth flag
|
||||
* 3. Provide headless fallback (display URL for manual auth)
|
||||
* 3. Auto-detect headless environments (SSH, no DISPLAY)
|
||||
* 4. Use -no-browser flag for headless, display OAuth URL for manual auth
|
||||
*
|
||||
* Token storage: ~/.ccs/cliproxy-auth/<provider>/
|
||||
* Token storage: ~/.ccs/cliproxy/auth/<provider>/
|
||||
* Each provider has its own directory to avoid conflicts.
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { spawn } from 'child_process';
|
||||
import { ProgressIndicator } from '../utils/progress-indicator';
|
||||
import { getAuthDir } from './config-generator';
|
||||
import { getProviderAuthDir, generateConfig } from './config-generator';
|
||||
import { ensureCLIProxyBinary } from './binary-manager';
|
||||
import { CLIProxyProvider } from './types';
|
||||
|
||||
/**
|
||||
* Detect if running in a headless environment (no browser available)
|
||||
*/
|
||||
function isHeadlessEnvironment(): boolean {
|
||||
// SSH session
|
||||
if (process.env.SSH_TTY || process.env.SSH_CLIENT || process.env.SSH_CONNECTION) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// No display (Linux/X11)
|
||||
if (process.platform === 'linux' && !process.env.DISPLAY && !process.env.WAYLAND_DISPLAY) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Non-interactive (piped stdin)
|
||||
if (!process.stdin.isTTY) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Auth status for a provider
|
||||
*/
|
||||
@@ -60,21 +84,21 @@ const OAUTH_CONFIGS: Record<CLIProxyProvider, ProviderOAuthConfig> = {
|
||||
displayName: 'Google Gemini',
|
||||
authUrl: 'https://accounts.google.com/o/oauth2/v2/auth',
|
||||
scopes: ['https://www.googleapis.com/auth/generative-language'],
|
||||
authFlag: '--auth-gemini',
|
||||
authFlag: '-login',
|
||||
},
|
||||
chatgpt: {
|
||||
provider: 'chatgpt',
|
||||
displayName: 'ChatGPT/OpenAI',
|
||||
codex: {
|
||||
provider: 'codex',
|
||||
displayName: 'Codex',
|
||||
authUrl: 'https://auth.openai.com/authorize',
|
||||
scopes: ['openid', 'profile'],
|
||||
authFlag: '--auth-codex',
|
||||
authFlag: '-codex-login',
|
||||
},
|
||||
qwen: {
|
||||
provider: 'qwen',
|
||||
displayName: 'Alibaba Qwen',
|
||||
authUrl: 'https://auth.aliyun.com/oauth2/authorize',
|
||||
scopes: ['dashscope'],
|
||||
authFlag: '--auth-qwen',
|
||||
authFlag: '-qwen-login',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -93,11 +117,13 @@ export function getOAuthConfig(provider: CLIProxyProvider): ProviderOAuthConfig
|
||||
* Get token directory for provider
|
||||
*/
|
||||
export function getProviderTokenDir(provider: CLIProxyProvider): string {
|
||||
return path.join(getAuthDir(), provider);
|
||||
return getProviderAuthDir(provider);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if provider has valid authentication
|
||||
* CLIProxyAPI stores OAuth tokens as JSON files in the auth directory.
|
||||
* We check for any .json files that could contain tokens.
|
||||
*/
|
||||
export function isAuthenticated(provider: CLIProxyProvider): boolean {
|
||||
const tokenDir = getProviderTokenDir(provider);
|
||||
@@ -106,13 +132,16 @@ export function isAuthenticated(provider: CLIProxyProvider): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check for any token files (CLIProxyAPI stores tokens with various names)
|
||||
const files = fs.readdirSync(tokenDir);
|
||||
const tokenFiles = files.filter(
|
||||
(f) => f.endsWith('.json') || f.endsWith('.token') || f === 'credentials'
|
||||
);
|
||||
|
||||
return tokenFiles.length > 0;
|
||||
// Check for any token files created by CLIProxyAPI
|
||||
try {
|
||||
const files = fs.readdirSync(tokenDir);
|
||||
const tokenFiles = files.filter(
|
||||
(f) => f.endsWith('.json') || f.endsWith('.token') || f === 'credentials'
|
||||
);
|
||||
return tokenFiles.length > 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -152,7 +181,7 @@ export function getAuthStatus(provider: CLIProxyProvider): AuthStatus {
|
||||
* Get auth status for all providers
|
||||
*/
|
||||
export function getAllAuthStatus(): AuthStatus[] {
|
||||
const providers: CLIProxyProvider[] = ['gemini', 'chatgpt', 'qwen'];
|
||||
const providers: CLIProxyProvider[] = ['gemini', 'codex', 'qwen'];
|
||||
return providers.map(getAuthStatus);
|
||||
}
|
||||
|
||||
@@ -180,14 +209,17 @@ export function clearAuth(provider: CLIProxyProvider): boolean {
|
||||
|
||||
/**
|
||||
* Trigger OAuth flow for provider
|
||||
* Opens browser for user authentication
|
||||
* Auto-detects headless environment and uses -no-browser flag accordingly
|
||||
*/
|
||||
export async function triggerOAuth(
|
||||
provider: CLIProxyProvider,
|
||||
options: { verbose?: boolean; headless?: boolean } = {}
|
||||
): Promise<boolean> {
|
||||
const oauthConfig = getOAuthConfig(provider);
|
||||
const { verbose = false, headless = false } = options;
|
||||
const { verbose = false } = options;
|
||||
|
||||
// Auto-detect headless if not explicitly set
|
||||
const headless = options.headless ?? isHeadlessEnvironment();
|
||||
|
||||
const log = (msg: string) => {
|
||||
if (verbose) {
|
||||
@@ -208,91 +240,124 @@ export async function triggerOAuth(
|
||||
const tokenDir = getProviderTokenDir(provider);
|
||||
fs.mkdirSync(tokenDir, { recursive: true, mode: 0o700 });
|
||||
|
||||
// Headless mode: display manual instructions
|
||||
// Generate config file (CLIProxyAPI requires it even for auth)
|
||||
const configPath = generateConfig(provider);
|
||||
log(`Config generated: ${configPath}`);
|
||||
|
||||
// Build args: config + auth flag + optional -no-browser for headless
|
||||
const args = ['-config', configPath, oauthConfig.authFlag];
|
||||
if (headless) {
|
||||
console.log('');
|
||||
console.log(`[i] Headless mode: Manual authentication required for ${oauthConfig.displayName}`);
|
||||
console.log('');
|
||||
console.log('Instructions:');
|
||||
console.log(` 1. Run CLIProxyAPI binary with auth flag on a machine with browser:`);
|
||||
console.log(` ${binaryPath} ${oauthConfig.authFlag}`);
|
||||
console.log('');
|
||||
console.log(` 2. Complete OAuth in browser`);
|
||||
console.log('');
|
||||
console.log(` 3. Copy token files from CLIProxyAPI auth directory to:`);
|
||||
console.log(` ${tokenDir}`);
|
||||
console.log('');
|
||||
return false;
|
||||
args.push('-no-browser');
|
||||
}
|
||||
|
||||
// Standard mode: spawn binary with auth flag
|
||||
const spinner = new ProgressIndicator(`Authenticating with ${oauthConfig.displayName}`);
|
||||
spinner.start();
|
||||
// Show appropriate message
|
||||
console.log('');
|
||||
if (headless) {
|
||||
console.log(`[i] Headless mode detected - manual authentication required`);
|
||||
console.log(`[i] ${oauthConfig.displayName} will display an OAuth URL below`);
|
||||
console.log('');
|
||||
} else {
|
||||
console.log(`[i] Opening browser for ${oauthConfig.displayName} authentication...`);
|
||||
console.log('[i] Complete the login in your browser.');
|
||||
console.log('');
|
||||
}
|
||||
|
||||
console.log('');
|
||||
console.log(`[i] Opening browser for ${oauthConfig.displayName} authentication...`);
|
||||
console.log('[i] Complete the login in your browser.');
|
||||
console.log('');
|
||||
const spinner = new ProgressIndicator(`Authenticating with ${oauthConfig.displayName}`);
|
||||
if (!headless) {
|
||||
spinner.start();
|
||||
}
|
||||
|
||||
return new Promise<boolean>((resolve) => {
|
||||
// Spawn CLIProxyAPI with auth flag
|
||||
// Note: CLIProxyAPI handles the OAuth flow internally
|
||||
const authProcess = spawn(binaryPath, [oauthConfig.authFlag], {
|
||||
stdio: verbose ? 'inherit' : ['ignore', 'pipe', 'pipe'],
|
||||
// Spawn CLIProxyAPI with auth flag (and -no-browser if headless)
|
||||
const authProcess = spawn(binaryPath, args, {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
env: {
|
||||
...process.env,
|
||||
// Set auth directory for CLIProxyAPI
|
||||
CLI_PROXY_AUTH_DIR: tokenDir,
|
||||
},
|
||||
});
|
||||
|
||||
let stderrData = '';
|
||||
let urlDisplayed = false;
|
||||
|
||||
if (!verbose) {
|
||||
authProcess.stdout?.on('data', (data: Buffer) => {
|
||||
log(`stdout: ${data.toString().trim()}`);
|
||||
});
|
||||
authProcess.stdout?.on('data', (data: Buffer) => {
|
||||
const output = data.toString();
|
||||
log(`stdout: ${output.trim()}`);
|
||||
|
||||
authProcess.stderr?.on('data', (data: Buffer) => {
|
||||
stderrData += data.toString();
|
||||
log(`stderr: ${data.toString().trim()}`);
|
||||
});
|
||||
}
|
||||
// In headless mode, display OAuth URLs prominently
|
||||
if (headless) {
|
||||
const lines = output.split('\n');
|
||||
for (const line of lines) {
|
||||
// Look for URLs in the output
|
||||
const urlMatch = line.match(/https?:\/\/[^\s]+/);
|
||||
if (urlMatch && !urlDisplayed) {
|
||||
console.log(` ${urlMatch[0]}`);
|
||||
console.log('');
|
||||
console.log('[i] Waiting for authentication... (press Ctrl+C to cancel)');
|
||||
urlDisplayed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Timeout after 2 minutes
|
||||
authProcess.stderr?.on('data', (data: Buffer) => {
|
||||
const output = data.toString();
|
||||
stderrData += output;
|
||||
log(`stderr: ${output.trim()}`);
|
||||
|
||||
// Also check stderr for URLs (some tools output there)
|
||||
if (headless && !urlDisplayed) {
|
||||
const urlMatch = output.match(/https?:\/\/[^\s]+/);
|
||||
if (urlMatch) {
|
||||
console.log(` ${urlMatch[0]}`);
|
||||
console.log('');
|
||||
console.log('[i] Waiting for authentication... (press Ctrl+C to cancel)');
|
||||
urlDisplayed = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Timeout after 5 minutes for headless (user needs time to copy URL)
|
||||
const timeoutMs = headless ? 300000 : 120000;
|
||||
const timeout = setTimeout(() => {
|
||||
spinner.fail('Authentication timeout');
|
||||
if (!headless) spinner.fail('Authentication timeout');
|
||||
authProcess.kill();
|
||||
console.error('[X] OAuth timed out after 2 minutes');
|
||||
console.error(`[X] OAuth timed out after ${headless ? 5 : 2} minutes`);
|
||||
console.error('');
|
||||
console.error('Troubleshooting:');
|
||||
console.error(' - Make sure a browser is available');
|
||||
console.error(' - Try running with --verbose for details');
|
||||
console.error(` - For headless systems, use: ccs ${provider} --auth --headless`);
|
||||
if (!headless) {
|
||||
console.error('Troubleshooting:');
|
||||
console.error(' - Make sure a browser is available');
|
||||
console.error(' - Try running with --verbose for details');
|
||||
}
|
||||
resolve(false);
|
||||
}, 120000);
|
||||
}, timeoutMs);
|
||||
|
||||
authProcess.on('exit', (code) => {
|
||||
clearTimeout(timeout);
|
||||
|
||||
if (code === 0) {
|
||||
spinner.succeed(`Authenticated with ${oauthConfig.displayName}`);
|
||||
if (!headless) spinner.succeed(`Authenticated with ${oauthConfig.displayName}`);
|
||||
|
||||
// Verify token was created
|
||||
if (isAuthenticated(provider)) {
|
||||
console.log('[OK] Authentication successful');
|
||||
resolve(true);
|
||||
} else {
|
||||
spinner.fail('Authentication incomplete');
|
||||
if (!headless) spinner.fail('Authentication incomplete');
|
||||
console.error('[X] Token not found after authentication');
|
||||
console.error(' The OAuth flow may have been cancelled');
|
||||
resolve(false);
|
||||
}
|
||||
} else {
|
||||
spinner.fail('Authentication failed');
|
||||
if (!headless) spinner.fail('Authentication failed');
|
||||
console.error(`[X] CLIProxyAPI auth exited with code ${code}`);
|
||||
if (stderrData) {
|
||||
console.error(` ${stderrData.trim()}`);
|
||||
if (stderrData && !urlDisplayed) {
|
||||
console.error(` ${stderrData.trim().split('\n')[0]}`);
|
||||
}
|
||||
// Show headless hint if we detected headless environment
|
||||
if (headless && !urlDisplayed) {
|
||||
console.error('');
|
||||
console.error('[i] No OAuth URL was displayed. Try with --verbose for details.');
|
||||
}
|
||||
resolve(false);
|
||||
}
|
||||
@@ -300,7 +365,7 @@ export async function triggerOAuth(
|
||||
|
||||
authProcess.on('error', (error) => {
|
||||
clearTimeout(timeout);
|
||||
spinner.fail('Authentication error');
|
||||
if (!headless) spinner.fail('Authentication error');
|
||||
console.error(`[X] Failed to start auth process: ${error.message}`);
|
||||
resolve(false);
|
||||
});
|
||||
|
||||
@@ -16,8 +16,8 @@ import * as https from 'https';
|
||||
import * as http from 'http';
|
||||
import * as crypto from 'crypto';
|
||||
import * as zlib from 'zlib';
|
||||
import { getCcsDir } from '../utils/config-manager';
|
||||
import { ProgressIndicator } from '../utils/progress-indicator';
|
||||
import { getBinDir } from './config-generator';
|
||||
import {
|
||||
BinaryInfo,
|
||||
BinaryManagerConfig,
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
getDownloadUrl,
|
||||
getChecksumsUrl,
|
||||
getExecutableName,
|
||||
getArchiveBinaryName,
|
||||
CLIPROXY_VERSION,
|
||||
} from './platform-detector';
|
||||
|
||||
@@ -37,7 +38,7 @@ import {
|
||||
const DEFAULT_CONFIG: BinaryManagerConfig = {
|
||||
version: CLIPROXY_VERSION,
|
||||
releaseUrl: 'https://github.com/router-for-me/CLIProxyAPI/releases/download',
|
||||
binPath: path.join(getCcsDir(), 'bin'),
|
||||
binPath: getBinDir(),
|
||||
maxRetries: 3,
|
||||
verbose: false,
|
||||
};
|
||||
@@ -400,6 +401,7 @@ export class BinaryManager {
|
||||
return new Promise((resolve, reject) => {
|
||||
const destDir = this.config.binPath;
|
||||
const execName = getExecutableName();
|
||||
const archiveBinaryName = getArchiveBinaryName();
|
||||
|
||||
// Read and decompress
|
||||
const gunzip = zlib.createGunzip();
|
||||
@@ -440,7 +442,11 @@ export class BinaryManager {
|
||||
if (name && size > 0) {
|
||||
// Extract just the filename (handle directories)
|
||||
const baseName = path.basename(name);
|
||||
if (baseName === execName || baseName === 'CLIProxyAPI') {
|
||||
if (
|
||||
baseName === execName ||
|
||||
baseName === archiveBinaryName ||
|
||||
baseName === 'cli-proxy-api'
|
||||
) {
|
||||
currentFile = { name: baseName, size };
|
||||
fileBuffer = Buffer.alloc(0);
|
||||
bytesRead = 0;
|
||||
@@ -504,6 +510,7 @@ export class BinaryManager {
|
||||
return new Promise((resolve, reject) => {
|
||||
const destDir = this.config.binPath;
|
||||
const execName = getExecutableName();
|
||||
const archiveBinaryName = getArchiveBinaryName();
|
||||
const buffer = fs.readFileSync(archivePath);
|
||||
|
||||
// Find End of Central Directory record (EOCD)
|
||||
@@ -540,7 +547,11 @@ export class BinaryManager {
|
||||
const baseName = path.basename(fileName);
|
||||
|
||||
// Check if this is the executable we want
|
||||
if (baseName === execName || baseName === 'CLIProxyAPI.exe') {
|
||||
if (
|
||||
baseName === execName ||
|
||||
baseName === archiveBinaryName ||
|
||||
baseName === 'cli-proxy-api.exe'
|
||||
) {
|
||||
// Read from local file header
|
||||
const localOffset = localHeaderOffset;
|
||||
const localSig = buffer.readUInt32LE(localOffset);
|
||||
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
getProviderConfig,
|
||||
CLIPROXY_DEFAULT_PORT,
|
||||
} from './config-generator';
|
||||
import { ensureAuth, isAuthenticated } from './auth-handler';
|
||||
import { isAuthenticated } from './auth-handler';
|
||||
import { CLIProxyProvider, ExecutorConfig } from './types';
|
||||
|
||||
/** Default executor configuration */
|
||||
@@ -115,30 +115,51 @@ export async function execClaudeWithCLIProxy(
|
||||
throw error;
|
||||
}
|
||||
|
||||
// 2. Ensure OAuth completed (if provider requires it)
|
||||
// 2. Handle authentication flags
|
||||
const forceAuth = args.includes('--auth');
|
||||
const forceHeadless = args.includes('--headless');
|
||||
const forceLogout = args.includes('--logout');
|
||||
|
||||
// Handle --logout: clear auth and exit
|
||||
if (forceLogout) {
|
||||
const { clearAuth } = await import('./auth-handler');
|
||||
if (clearAuth(provider)) {
|
||||
console.log(`[OK] Logged out from ${providerConfig.displayName}`);
|
||||
} else {
|
||||
console.log(`[i] No authentication found for ${providerConfig.displayName}`);
|
||||
}
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// 3. Ensure OAuth completed (if provider requires it)
|
||||
if (providerConfig.requiresOAuth) {
|
||||
log(`Checking authentication for ${provider}`);
|
||||
|
||||
// Check for --auth flag to force re-auth
|
||||
const forceAuth = args.includes('--auth');
|
||||
const headless = args.includes('--headless');
|
||||
|
||||
if (forceAuth || !isAuthenticated(provider)) {
|
||||
const authSuccess = await ensureAuth(provider, { verbose, headless });
|
||||
// Pass headless only if explicitly set; otherwise let auth-handler auto-detect
|
||||
const { triggerOAuth } = await import('./auth-handler');
|
||||
const authSuccess = await triggerOAuth(provider, {
|
||||
verbose,
|
||||
...(forceHeadless ? { headless: true } : {}),
|
||||
});
|
||||
if (!authSuccess) {
|
||||
throw new Error(`Authentication required for ${providerConfig.displayName}`);
|
||||
}
|
||||
// If --auth was explicitly passed, exit after auth (don't start Claude)
|
||||
if (forceAuth) {
|
||||
process.exit(0);
|
||||
}
|
||||
} else {
|
||||
log(`${provider} already authenticated`);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Generate config file
|
||||
// 4. Generate config file
|
||||
log(`Generating config for ${provider}`);
|
||||
const configPath = generateConfig(provider, cfg.port);
|
||||
log(`Config written: ${configPath}`);
|
||||
|
||||
// 4. Spawn CLIProxyAPI binary
|
||||
// 5. Spawn CLIProxyAPI binary
|
||||
const proxyArgs = ['--config', configPath];
|
||||
|
||||
log(`Spawning: ${binaryPath} ${proxyArgs.join(' ')}`);
|
||||
@@ -199,12 +220,16 @@ export async function execClaudeWithCLIProxy(
|
||||
log(`Claude env: ANTHROPIC_BASE_URL=${envVars.ANTHROPIC_BASE_URL}`);
|
||||
log(`Claude env: ANTHROPIC_MODEL=${envVars.ANTHROPIC_MODEL}`);
|
||||
|
||||
// Filter out CCS-specific flags before passing to Claude CLI
|
||||
const ccsFlags = ['--auth', '--headless', '--logout'];
|
||||
const claudeArgs = args.filter((arg) => !ccsFlags.includes(arg));
|
||||
|
||||
const isWindows = process.platform === 'win32';
|
||||
const needsShell = isWindows && /\.(cmd|bat|ps1)$/i.test(claudeCli);
|
||||
|
||||
let claude: ChildProcess;
|
||||
if (needsShell) {
|
||||
const cmdString = [claudeCli, ...args].map(escapeShellArg).join(' ');
|
||||
const cmdString = [claudeCli, ...claudeArgs].map(escapeShellArg).join(' ');
|
||||
claude = spawn(cmdString, {
|
||||
stdio: 'inherit',
|
||||
windowsHide: true,
|
||||
@@ -212,7 +237,7 @@ export async function execClaudeWithCLIProxy(
|
||||
env,
|
||||
});
|
||||
} else {
|
||||
claude = spawn(claudeCli, args, {
|
||||
claude = spawn(claudeCli, claudeArgs, {
|
||||
stdio: 'inherit',
|
||||
windowsHide: true,
|
||||
env,
|
||||
|
||||
@@ -24,23 +24,23 @@ export const PROVIDER_CONFIGS: Record<CLIProxyProvider, ProviderConfig> = {
|
||||
name: 'gemini',
|
||||
displayName: 'Gemini',
|
||||
models: {
|
||||
defaultModel: 'gemini-2.0-flash',
|
||||
claudeModel: 'gemini-2.0-flash',
|
||||
opusModel: 'gemini-2.0-flash-thinking-exp',
|
||||
sonnetModel: 'gemini-2.0-flash',
|
||||
haikuModel: 'gemini-2.0-flash-lite',
|
||||
defaultModel: 'gemini-2.5-pro',
|
||||
claudeModel: 'gemini-2.5-pro',
|
||||
opusModel: 'gemini-3-pro-preview',
|
||||
sonnetModel: 'gemini-2.5-pro',
|
||||
haikuModel: 'gemini-2.5-flash',
|
||||
},
|
||||
requiresOAuth: true,
|
||||
},
|
||||
chatgpt: {
|
||||
name: 'chatgpt',
|
||||
displayName: 'ChatGPT',
|
||||
codex: {
|
||||
name: 'codex',
|
||||
displayName: 'Codex',
|
||||
models: {
|
||||
defaultModel: 'gpt-4o',
|
||||
claudeModel: 'gpt-4o',
|
||||
opusModel: 'o1',
|
||||
sonnetModel: 'gpt-4o',
|
||||
haikuModel: 'gpt-4o-mini',
|
||||
defaultModel: 'gpt-5.1-codex-max',
|
||||
claudeModel: 'gpt-5.1-codex-max',
|
||||
opusModel: 'gpt-5.1-codex-max-high',
|
||||
sonnetModel: 'gpt-5.1-codex-max',
|
||||
haikuModel: 'gpt-5.1-codex-mini-high',
|
||||
},
|
||||
requiresOAuth: true,
|
||||
},
|
||||
@@ -77,31 +77,57 @@ export function getModelMapping(provider: CLIProxyProvider): ProviderModelMappin
|
||||
}
|
||||
|
||||
/**
|
||||
* Get auth directory for CLIProxyAPI
|
||||
* Get CLIProxy base directory
|
||||
* All CLIProxy-related files are stored under ~/.ccs/cliproxy/
|
||||
*/
|
||||
export function getCliproxyDir(): string {
|
||||
return path.join(getCcsDir(), 'cliproxy');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get auth directory for provider
|
||||
* All providers use a FLAT auth directory structure for unified config.
|
||||
* CLIProxyAPI stores OAuth tokens directly in auth/ (not subdirectories).
|
||||
* This enables all providers to be discovered and used concurrently.
|
||||
*/
|
||||
export function getProviderAuthDir(_provider: CLIProxyProvider): string {
|
||||
// Use flat structure - all auth files in same directory for unified discovery
|
||||
// Provider param kept for API compatibility (CLIProxyAPI handles via auth file type field)
|
||||
return path.join(getCliproxyDir(), 'auth');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get base auth directory for CLIProxyAPI
|
||||
*/
|
||||
export function getAuthDir(): string {
|
||||
return path.join(getCcsDir(), 'cliproxy-auth');
|
||||
return path.join(getCliproxyDir(), 'auth');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get config file path
|
||||
*/
|
||||
export function getConfigPath(): string {
|
||||
return path.join(getCcsDir(), 'cliproxy.config.yaml');
|
||||
return path.join(getCliproxyDir(), 'config.yaml');
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate config.yaml content for provider
|
||||
* Get binary directory path
|
||||
*/
|
||||
function generateConfigContent(
|
||||
provider: CLIProxyProvider,
|
||||
port: number = CLIPROXY_DEFAULT_PORT
|
||||
): string {
|
||||
const authDir = getAuthDir();
|
||||
export function getBinDir(): string {
|
||||
return path.join(getCliproxyDir(), 'bin');
|
||||
}
|
||||
|
||||
// Base config (always present)
|
||||
let config = `# CLIProxyAPI config generated by CCS
|
||||
# Provider: ${provider}
|
||||
/**
|
||||
* Generate UNIFIED config.yaml content for ALL providers
|
||||
* This enables concurrent usage of gemini/codex/qwen without config conflicts.
|
||||
* CLIProxyAPI routes requests by model name to the appropriate provider.
|
||||
*/
|
||||
function generateUnifiedConfigContent(port: number = CLIPROXY_DEFAULT_PORT): string {
|
||||
const authDir = getAuthDir(); // Base auth dir - CLIProxyAPI scans subdirectories
|
||||
|
||||
// Unified config with all providers
|
||||
const config = `# CLIProxyAPI unified config generated by CCS
|
||||
# Supports: gemini, codex, qwen (concurrent usage)
|
||||
# Generated: ${new Date().toISOString()}
|
||||
|
||||
port: ${port}
|
||||
@@ -113,60 +139,38 @@ usage-statistics-enabled: false
|
||||
api-keys:
|
||||
- "${CCS_INTERNAL_API_KEY}"
|
||||
|
||||
# OAuth tokens stored here
|
||||
# OAuth tokens stored in subdirectories (gemini/, codex/, qwen/)
|
||||
# CLIProxyAPI auto-discovers auth files in subdirectories
|
||||
auth-dir: "${authDir}"
|
||||
`;
|
||||
|
||||
// Provider-specific config
|
||||
switch (provider) {
|
||||
case 'gemini':
|
||||
config += `
|
||||
# Gemini configuration (OAuth-managed)
|
||||
# API keys will be loaded from auth-dir after OAuth
|
||||
gemini-api-key: []
|
||||
# All providers configured - routes by model name
|
||||
# No provider-specific sections needed - OAuth auth files provide credentials
|
||||
`;
|
||||
break;
|
||||
|
||||
case 'chatgpt':
|
||||
config += `
|
||||
# ChatGPT/Codex configuration (OAuth-managed)
|
||||
# API keys will be loaded from auth-dir after OAuth
|
||||
codex-api-key: []
|
||||
`;
|
||||
break;
|
||||
|
||||
case 'qwen':
|
||||
config += `
|
||||
# Qwen configuration (API key required)
|
||||
openai-compatibility:
|
||||
- name: "qwen"
|
||||
base-url: "https://dashscope.aliyuncs.com/compatible-mode/v1"
|
||||
api-key-entries: []
|
||||
`;
|
||||
break;
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate config.yaml file for provider
|
||||
* @returns Path to generated config file
|
||||
* Generate unified config.yaml file (supports all providers concurrently)
|
||||
* Only regenerates if config doesn't exist.
|
||||
* @returns Path to config file
|
||||
*/
|
||||
export function generateConfig(
|
||||
provider: CLIProxyProvider,
|
||||
port: number = CLIPROXY_DEFAULT_PORT
|
||||
): string {
|
||||
const configPath = getConfigPath();
|
||||
const configContent = generateConfigContent(provider, port);
|
||||
|
||||
// Ensure directories exist
|
||||
const authDir = getAuthDir();
|
||||
// Ensure provider auth directory exists
|
||||
const authDir = getProviderAuthDir(provider);
|
||||
fs.mkdirSync(path.dirname(configPath), { recursive: true });
|
||||
fs.mkdirSync(authDir, { recursive: true });
|
||||
fs.mkdirSync(authDir, { recursive: true, mode: 0o700 });
|
||||
|
||||
// Write config with secure permissions (0600)
|
||||
fs.writeFileSync(configPath, configContent, { mode: 0o600 });
|
||||
// Only generate config if it doesn't exist (unified config serves all providers)
|
||||
if (!fs.existsSync(configPath)) {
|
||||
const configContent = generateUnifiedConfigContent(port);
|
||||
fs.writeFileSync(configPath, configContent, { mode: 0o600 });
|
||||
}
|
||||
|
||||
return configPath;
|
||||
}
|
||||
@@ -190,6 +194,8 @@ export function deleteConfig(): void {
|
||||
|
||||
/**
|
||||
* Get environment variables for Claude CLI
|
||||
* Uses provider-specific endpoint (e.g., /api/provider/gemini) for explicit routing.
|
||||
* This enables concurrent gemini/codex usage - each session routes to its provider via URL path.
|
||||
*/
|
||||
export function getClaudeEnvVars(
|
||||
provider: CLIProxyProvider,
|
||||
@@ -198,7 +204,8 @@ export function getClaudeEnvVars(
|
||||
const models = getModelMapping(provider);
|
||||
|
||||
return {
|
||||
ANTHROPIC_BASE_URL: `http://127.0.0.1:${port}`,
|
||||
// Provider-specific endpoint - routes to correct provider via URL path
|
||||
ANTHROPIC_BASE_URL: `http://127.0.0.1:${port}/api/provider/${provider}`,
|
||||
ANTHROPIC_AUTH_TOKEN: CCS_INTERNAL_API_KEY,
|
||||
ANTHROPIC_MODEL: models.claudeModel,
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: models.opusModel || models.claudeModel,
|
||||
|
||||
@@ -28,6 +28,7 @@ export {
|
||||
getDownloadUrl,
|
||||
getChecksumsUrl,
|
||||
getExecutableName,
|
||||
getArchiveBinaryName,
|
||||
isPlatformSupported,
|
||||
getPlatformDescription,
|
||||
CLIPROXY_VERSION,
|
||||
@@ -47,8 +48,11 @@ export {
|
||||
getClaudeEnvVars,
|
||||
getProviderConfig,
|
||||
getModelMapping,
|
||||
getCliproxyDir,
|
||||
getProviderAuthDir,
|
||||
getAuthDir,
|
||||
getConfigPath,
|
||||
getBinDir,
|
||||
configExists,
|
||||
deleteConfig,
|
||||
PROVIDER_CONFIGS,
|
||||
|
||||
@@ -62,10 +62,20 @@ export function detectPlatform(): PlatformInfo {
|
||||
/**
|
||||
* Get executable name based on platform
|
||||
* @returns Binary executable name (with .exe on Windows)
|
||||
* Note: The actual binary inside the archive is named 'cli-proxy-api'
|
||||
*/
|
||||
export function getExecutableName(): string {
|
||||
const platform = detectPlatform();
|
||||
return platform.os === 'windows' ? 'CLIProxyAPI.exe' : 'CLIProxyAPI';
|
||||
return platform.os === 'windows' ? 'cli-proxy-api.exe' : 'cli-proxy-api';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the name of the binary inside the archive
|
||||
* @returns Binary name as it appears in the tar.gz/zip
|
||||
*/
|
||||
export function getArchiveBinaryName(): string {
|
||||
const platform = detectPlatform();
|
||||
return platform.os === 'windows' ? 'cli-proxy-api.exe' : 'cli-proxy-api';
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -108,7 +108,7 @@ export interface DownloadResult {
|
||||
/**
|
||||
* Supported CLIProxy providers
|
||||
*/
|
||||
export type CLIProxyProvider = 'gemini' | 'chatgpt' | 'qwen';
|
||||
export type CLIProxyProvider = 'gemini' | 'codex' | 'qwen';
|
||||
|
||||
/**
|
||||
* CLIProxy config.yaml structure (minimal)
|
||||
|
||||
@@ -28,7 +28,7 @@ export function handleHelpCommand(): void {
|
||||
console.log(colored('Model Switching:', 'cyan'));
|
||||
console.log(` ${colored('ccs', 'yellow')} Use default Claude account`);
|
||||
console.log(
|
||||
` ${colored('ccs chatgpt', 'yellow')} ChatGPT via OAuth (zero config)`
|
||||
` ${colored('ccs codex', 'yellow')} Codex via OAuth (zero config)`
|
||||
);
|
||||
console.log(
|
||||
` ${colored('ccs gemini', 'yellow')} Gemini via OAuth (zero config)`
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
getCLIProxyPath,
|
||||
isPortAvailable,
|
||||
getAllAuthStatus,
|
||||
getConfigPath,
|
||||
CLIPROXY_VERSION,
|
||||
CLIPROXY_DEFAULT_PORT,
|
||||
} from '../cliproxy';
|
||||
@@ -783,15 +784,15 @@ class Doctor {
|
||||
|
||||
// 2. Config file exists?
|
||||
const configSpinner = ora('Checking CLIProxy config').start();
|
||||
const configPath = path.join(this.ccsDir, 'cliproxy.config.yaml');
|
||||
const configPath = getConfigPath();
|
||||
|
||||
if (fs.existsSync(configPath)) {
|
||||
configSpinner.succeed(
|
||||
` ${'CLIProxy Config'.padEnd(26)}${colored('[OK]', 'green')} cliproxy.config.yaml`
|
||||
` ${'CLIProxy Config'.padEnd(26)}${colored('[OK]', 'green')} cliproxy/config.yaml`
|
||||
);
|
||||
this.results.addCheck('CLIProxy Config', 'success', undefined, undefined, {
|
||||
status: 'OK',
|
||||
info: 'cliproxy.config.yaml',
|
||||
info: 'cliproxy/config.yaml',
|
||||
});
|
||||
} else {
|
||||
configSpinner.info(
|
||||
|
||||
Reference in new issue
Block a user