mirror of
https://github.com/tiennm99/ccs.git
synced 2026-08-09 16:23:58 +00:00
Merge pull request #146 from kaitranntt/kai/feat/remote-cliproxyapi-config
feat(cliproxy): add remote proxy configuration support
This commit is contained in:
@@ -243,6 +243,7 @@ See [docs/websearch.md](./docs/websearch.md) for detailed configuration and trou
|
||||
| OAuth Providers | [docs.ccs.kaitran.ca/providers/oauth-providers](https://docs.ccs.kaitran.ca/providers/oauth-providers) |
|
||||
| Multi-Account Claude | [docs.ccs.kaitran.ca/providers/claude-accounts](https://docs.ccs.kaitran.ca/providers/claude-accounts) |
|
||||
| API Profiles | [docs.ccs.kaitran.ca/providers/api-profiles](https://docs.ccs.kaitran.ca/providers/api-profiles) |
|
||||
| Remote Proxy | [docs.ccs.kaitran.ca/features/remote-proxy](https://docs.ccs.kaitran.ca/features/remote-proxy) |
|
||||
| CLI Reference | [docs.ccs.kaitran.ca/reference/cli-commands](https://docs.ccs.kaitran.ca/reference/cli-commands) |
|
||||
| Architecture | [docs.ccs.kaitran.ca/reference/architecture](https://docs.ccs.kaitran.ca/reference/architecture) |
|
||||
| Troubleshooting | [docs.ccs.kaitran.ca/reference/troubleshooting](https://docs.ccs.kaitran.ca/reference/troubleshooting) |
|
||||
|
||||
+244
-139
@@ -21,14 +21,17 @@ import { ensureCLIProxyBinary } from './binary-manager';
|
||||
import {
|
||||
generateConfig,
|
||||
getEffectiveEnvVars,
|
||||
getRemoteEnvVars,
|
||||
getProviderConfig,
|
||||
ensureProviderSettings,
|
||||
CLIPROXY_DEFAULT_PORT,
|
||||
getCliproxyWritablePath,
|
||||
} from './config-generator';
|
||||
import { checkRemoteProxy } from './remote-proxy-client';
|
||||
import { isAuthenticated } from './auth-handler';
|
||||
import { CLIProxyProvider, ExecutorConfig } from './types';
|
||||
import { configureProviderModel, getCurrentModel } from './model-config';
|
||||
import { resolveProxyConfig, PROXY_CLI_FLAGS } from './proxy-config-resolver';
|
||||
import { getWebSearchHookEnv } from '../utils/websearch-manager';
|
||||
import { supportsModelConfig, isModelBroken, getModelIssueUrl, findModel } from './model-catalog';
|
||||
import {
|
||||
@@ -126,6 +129,22 @@ export async function execClaudeWithCLIProxy(
|
||||
}
|
||||
};
|
||||
|
||||
// 0. Resolve proxy configuration (CLI > ENV > config.yaml > defaults)
|
||||
// This filters proxy flags from args and returns resolved config
|
||||
const { config: proxyConfig, remainingArgs: argsWithoutProxy } = resolveProxyConfig(args);
|
||||
|
||||
// Use resolved port from proxy config (overrides ExecutorConfig)
|
||||
if (proxyConfig.port !== CLIPROXY_DEFAULT_PORT) {
|
||||
cfg.port = proxyConfig.port;
|
||||
}
|
||||
|
||||
log(`Proxy mode: ${proxyConfig.mode}`);
|
||||
if (proxyConfig.mode === 'remote') {
|
||||
log(`Remote host: ${proxyConfig.host}:${proxyConfig.port} (${proxyConfig.protocol})`);
|
||||
}
|
||||
|
||||
// Note: proxyConfig is available for Phase 4 (remote mode integration)
|
||||
|
||||
// Ensure MCP web-search is configured for third-party profiles
|
||||
// WebSearch is a server-side tool executed by Anthropic's API
|
||||
// Third-party providers don't have access, so we use MCP fallback
|
||||
@@ -142,39 +161,102 @@ export async function execClaudeWithCLIProxy(
|
||||
const providerConfig = getProviderConfig(provider);
|
||||
log(`Provider: ${providerConfig.displayName}`);
|
||||
|
||||
// 1. Ensure binary exists (downloads if needed)
|
||||
const spinner = new ProgressIndicator('Preparing CLIProxy');
|
||||
spinner.start();
|
||||
// Check remote proxy if configured (before binary download)
|
||||
let useRemoteProxy = false;
|
||||
if (proxyConfig.mode === 'remote' && proxyConfig.host) {
|
||||
const status = await checkRemoteProxy({
|
||||
host: proxyConfig.host,
|
||||
port: proxyConfig.port,
|
||||
protocol: proxyConfig.protocol,
|
||||
authToken: proxyConfig.authToken,
|
||||
timeout: 2000,
|
||||
allowSelfSigned: proxyConfig.protocol === 'https',
|
||||
});
|
||||
|
||||
let binaryPath: string;
|
||||
try {
|
||||
binaryPath = await ensureCLIProxyBinary(verbose);
|
||||
spinner.succeed('CLIProxy binary ready');
|
||||
} catch (error) {
|
||||
spinner.fail('Failed to prepare CLIProxy');
|
||||
throw error;
|
||||
if (status.reachable) {
|
||||
useRemoteProxy = true;
|
||||
console.log(
|
||||
ok(
|
||||
`Connected to remote proxy at ${proxyConfig.host}:${proxyConfig.port} (${status.latencyMs}ms)`
|
||||
)
|
||||
);
|
||||
} else {
|
||||
console.error(warn(`Remote proxy unreachable: ${status.error}`));
|
||||
|
||||
if (proxyConfig.remoteOnly) {
|
||||
throw new Error('Remote proxy unreachable and --remote-only specified');
|
||||
}
|
||||
|
||||
if (proxyConfig.fallbackEnabled) {
|
||||
if (proxyConfig.autoStartLocal) {
|
||||
console.log(info('Falling back to local proxy...'));
|
||||
} else {
|
||||
// Prompt user for fallback (only in TTY)
|
||||
if (process.stdin.isTTY) {
|
||||
const readline = await import('readline');
|
||||
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
||||
const answer = await new Promise<string>((resolve) => {
|
||||
rl.question('Start local proxy instead? [Y/n] ', resolve);
|
||||
});
|
||||
rl.close();
|
||||
if (answer.toLowerCase() === 'n') {
|
||||
throw new Error('Remote proxy unreachable and user declined fallback');
|
||||
}
|
||||
}
|
||||
console.log(info('Starting local proxy...'));
|
||||
}
|
||||
} else {
|
||||
throw new Error('Remote proxy unreachable and fallback disabled');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Handle special flags
|
||||
const forceAuth = args.includes('--auth');
|
||||
const forceHeadless = args.includes('--headless');
|
||||
const forceLogout = args.includes('--logout');
|
||||
const forceConfig = args.includes('--config');
|
||||
const addAccount = args.includes('--add');
|
||||
const showAccounts = args.includes('--accounts');
|
||||
// Variables for local proxy mode
|
||||
let binaryPath: string | undefined;
|
||||
let sessionId: string | undefined;
|
||||
|
||||
// 1. Ensure binary exists (downloads if needed) - SKIP for remote mode
|
||||
if (!useRemoteProxy) {
|
||||
const spinner = new ProgressIndicator('Preparing CLIProxy');
|
||||
spinner.start();
|
||||
|
||||
try {
|
||||
binaryPath = await ensureCLIProxyBinary(verbose);
|
||||
spinner.succeed('CLIProxy binary ready');
|
||||
} catch (error) {
|
||||
spinner.fail('Failed to prepare CLIProxy');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Handle special flags (use argsWithoutProxy - proxy flags already stripped)
|
||||
const forceAuth = argsWithoutProxy.includes('--auth');
|
||||
const forceHeadless = argsWithoutProxy.includes('--headless');
|
||||
const forceLogout = argsWithoutProxy.includes('--logout');
|
||||
const forceConfig = argsWithoutProxy.includes('--config');
|
||||
const addAccount = argsWithoutProxy.includes('--add');
|
||||
const showAccounts = argsWithoutProxy.includes('--accounts');
|
||||
|
||||
// Parse --use <account> flag
|
||||
let useAccount: string | undefined;
|
||||
const useIdx = args.indexOf('--use');
|
||||
if (useIdx !== -1 && args[useIdx + 1] && !args[useIdx + 1].startsWith('-')) {
|
||||
useAccount = args[useIdx + 1];
|
||||
const useIdx = argsWithoutProxy.indexOf('--use');
|
||||
if (
|
||||
useIdx !== -1 &&
|
||||
argsWithoutProxy[useIdx + 1] &&
|
||||
!argsWithoutProxy[useIdx + 1].startsWith('-')
|
||||
) {
|
||||
useAccount = argsWithoutProxy[useIdx + 1];
|
||||
}
|
||||
|
||||
// Parse --nickname <name> flag
|
||||
let setNickname: string | undefined;
|
||||
const nicknameIdx = args.indexOf('--nickname');
|
||||
if (nicknameIdx !== -1 && args[nicknameIdx + 1] && !args[nicknameIdx + 1].startsWith('-')) {
|
||||
setNickname = args[nicknameIdx + 1];
|
||||
const nicknameIdx = argsWithoutProxy.indexOf('--nickname');
|
||||
if (
|
||||
nicknameIdx !== -1 &&
|
||||
argsWithoutProxy[nicknameIdx + 1] &&
|
||||
!argsWithoutProxy[nicknameIdx + 1].startsWith('-')
|
||||
) {
|
||||
setNickname = argsWithoutProxy[nicknameIdx + 1];
|
||||
}
|
||||
|
||||
// Handle --accounts: list accounts and exit
|
||||
@@ -306,122 +388,135 @@ export async function execClaudeWithCLIProxy(
|
||||
// 6. Ensure user settings file exists (creates from defaults if not)
|
||||
ensureProviderSettings(provider);
|
||||
|
||||
// 6. Generate config file
|
||||
log(`Generating config for ${provider}`);
|
||||
const configPath = generateConfig(provider, cfg.port);
|
||||
log(`Config written: ${configPath}`);
|
||||
|
||||
// 6a. Pre-flight check: handle existing proxy or port conflicts
|
||||
// Clean up orphaned sessions first (from crashed proxies)
|
||||
cleanupOrphanedSessions(cfg.port);
|
||||
|
||||
// Check if there's an existing healthy proxy we can reuse
|
||||
const existingProxy = getExistingProxy(cfg.port);
|
||||
// Local proxy mode: generate config, spawn proxy, track session
|
||||
let proxy: ChildProcess | null = null;
|
||||
let sessionId: string;
|
||||
|
||||
if (existingProxy) {
|
||||
// Reuse existing proxy - another CCS session started it
|
||||
log(`Reusing existing CLIProxy on port ${cfg.port} (PID ${existingProxy.pid})`);
|
||||
sessionId = registerSession(cfg.port, existingProxy.pid);
|
||||
console.log(
|
||||
info(`Joined existing CLIProxy (${existingProxy.sessions.length + 1} sessions active)`)
|
||||
);
|
||||
} else {
|
||||
// No existing proxy - check if port is free
|
||||
const portProcess = await getPortProcess(cfg.port);
|
||||
if (portProcess) {
|
||||
if (isCLIProxyProcess(portProcess)) {
|
||||
// CLIProxy on port but no session lock - likely orphaned/zombie
|
||||
// Only kill if no active sessions registered
|
||||
if (!hasActiveSessions()) {
|
||||
log(`Found zombie CLIProxy on port ${cfg.port} (PID ${portProcess.pid}), killing...`);
|
||||
const killed = killProcessOnPort(cfg.port, verbose);
|
||||
if (killed) {
|
||||
console.log(info(`Cleaned up zombie CLIProxy process`));
|
||||
// Wait a bit for port to be released
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
if (!useRemoteProxy) {
|
||||
// 6. Generate config file
|
||||
log(`Generating config for ${provider}`);
|
||||
const configPath = generateConfig(provider, cfg.port);
|
||||
log(`Config written: ${configPath}`);
|
||||
|
||||
// 6a. Pre-flight check: handle existing proxy or port conflicts
|
||||
// Clean up orphaned sessions first (from crashed proxies)
|
||||
cleanupOrphanedSessions(cfg.port);
|
||||
|
||||
// Check if there's an existing healthy proxy we can reuse
|
||||
const existingProxy = getExistingProxy(cfg.port);
|
||||
|
||||
if (existingProxy) {
|
||||
// Reuse existing proxy - another CCS session started it
|
||||
log(`Reusing existing CLIProxy on port ${cfg.port} (PID ${existingProxy.pid})`);
|
||||
sessionId = registerSession(cfg.port, existingProxy.pid);
|
||||
console.log(
|
||||
info(`Joined existing CLIProxy (${existingProxy.sessions.length + 1} sessions active)`)
|
||||
);
|
||||
} else {
|
||||
// No existing proxy - check if port is free
|
||||
const portProcess = await getPortProcess(cfg.port);
|
||||
if (portProcess) {
|
||||
if (isCLIProxyProcess(portProcess)) {
|
||||
// CLIProxy on port but no session lock - likely orphaned/zombie
|
||||
// Only kill if no active sessions registered
|
||||
if (!hasActiveSessions()) {
|
||||
log(`Found zombie CLIProxy on port ${cfg.port} (PID ${portProcess.pid}), killing...`);
|
||||
const killed = killProcessOnPort(cfg.port, verbose);
|
||||
if (killed) {
|
||||
console.log(info(`Cleaned up zombie CLIProxy process`));
|
||||
// Wait a bit for port to be released
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
}
|
||||
} else {
|
||||
// Active sessions exist but getExistingProxy returned null - something's wrong
|
||||
// Try to connect anyway
|
||||
log(`CLIProxy on port ${cfg.port} has active sessions, attempting to join...`);
|
||||
}
|
||||
} else {
|
||||
// Active sessions exist but getExistingProxy returned null - something's wrong
|
||||
// Try to connect anyway
|
||||
log(`CLIProxy on port ${cfg.port} has active sessions, attempting to join...`);
|
||||
// Non-CLIProxy process blocking the port - warn user
|
||||
console.error('');
|
||||
console.error(
|
||||
warn(
|
||||
`Port ${cfg.port} is blocked by ${portProcess.processName} (PID ${portProcess.pid})`
|
||||
)
|
||||
);
|
||||
console.error('');
|
||||
console.error('To fix this, close the blocking application or run:');
|
||||
console.error(` ${getPortCheckCommand(cfg.port)}`);
|
||||
console.error('');
|
||||
throw new Error(`Port ${cfg.port} is in use by another application`);
|
||||
}
|
||||
} else {
|
||||
// Non-CLIProxy process blocking the port - warn user
|
||||
console.error('');
|
||||
console.error(
|
||||
warn(`Port ${cfg.port} is blocked by ${portProcess.processName} (PID ${portProcess.pid})`)
|
||||
);
|
||||
console.error('');
|
||||
console.error('To fix this, close the blocking application or run:');
|
||||
console.error(` ${getPortCheckCommand(cfg.port)}`);
|
||||
console.error('');
|
||||
throw new Error(`Port ${cfg.port} is in use by another application`);
|
||||
}
|
||||
|
||||
// 6b. Spawn CLIProxyAPI binary (only if not reusing existing proxy)
|
||||
// Use detached mode so proxy persists after terminal closes
|
||||
const configPath = generateConfig(provider, cfg.port);
|
||||
const proxyArgs = ['--config', configPath];
|
||||
|
||||
log(`Spawning: ${binaryPath} ${proxyArgs.join(' ')}`);
|
||||
|
||||
proxy = spawn(binaryPath as string, proxyArgs, {
|
||||
stdio: ['ignore', 'ignore', 'ignore'],
|
||||
detached: true, // Persist after parent terminal closes
|
||||
env: {
|
||||
...process.env,
|
||||
WRITABLE_PATH: getCliproxyWritablePath(), // Logs stored in ~/.ccs/cliproxy/logs/
|
||||
},
|
||||
});
|
||||
|
||||
// Unref so parent process can exit independently
|
||||
proxy.unref();
|
||||
|
||||
// Handle proxy errors (only fires if spawn itself fails)
|
||||
proxy.on('error', (error) => {
|
||||
console.error(fail(`CLIProxy spawn error: ${error.message}`));
|
||||
});
|
||||
|
||||
// 7. Wait for proxy readiness via TCP polling
|
||||
const readySpinner = new ProgressIndicator(`Waiting for CLIProxy on port ${cfg.port}`);
|
||||
readySpinner.start();
|
||||
|
||||
try {
|
||||
await waitForProxyReady(cfg.port, cfg.timeout, cfg.pollInterval);
|
||||
readySpinner.succeed(`CLIProxy ready on port ${cfg.port}`);
|
||||
} catch (error) {
|
||||
readySpinner.fail('CLIProxy startup failed');
|
||||
proxy.kill('SIGTERM');
|
||||
|
||||
const err = error as Error;
|
||||
console.error('');
|
||||
console.error(fail('CLIProxy failed to start'));
|
||||
console.error('');
|
||||
console.error('Possible causes:');
|
||||
console.error(` 1. Port ${cfg.port} already in use`);
|
||||
console.error(' 2. Binary crashed on startup');
|
||||
console.error(' 3. Invalid configuration');
|
||||
console.error('');
|
||||
console.error('Troubleshooting:');
|
||||
console.error(` - Check port: ${getPortCheckCommand(cfg.port)}`);
|
||||
console.error(' - Run with --verbose for detailed logs');
|
||||
console.error(` - View config: ${getCatCommand(configPath)}`);
|
||||
console.error(' - Try: ccs doctor --fix');
|
||||
console.error('');
|
||||
|
||||
throw new Error(`CLIProxy startup failed: ${err.message}`);
|
||||
}
|
||||
|
||||
// Register this session with the new proxy
|
||||
sessionId = registerSession(cfg.port, proxy.pid as number);
|
||||
log(`Registered session ${sessionId} with new proxy (PID ${proxy.pid})`);
|
||||
}
|
||||
|
||||
// 6b. Spawn CLIProxyAPI binary (only if not reusing existing proxy)
|
||||
// Use detached mode so proxy persists after terminal closes
|
||||
const proxyArgs = ['--config', configPath];
|
||||
|
||||
log(`Spawning: ${binaryPath} ${proxyArgs.join(' ')}`);
|
||||
|
||||
proxy = spawn(binaryPath, proxyArgs, {
|
||||
stdio: ['ignore', 'ignore', 'ignore'],
|
||||
detached: true, // Persist after parent terminal closes
|
||||
env: {
|
||||
...process.env,
|
||||
WRITABLE_PATH: getCliproxyWritablePath(), // Logs stored in ~/.ccs/cliproxy/logs/
|
||||
},
|
||||
});
|
||||
|
||||
// Unref so parent process can exit independently
|
||||
proxy.unref();
|
||||
|
||||
// Handle proxy errors (only fires if spawn itself fails)
|
||||
proxy.on('error', (error) => {
|
||||
console.error(fail(`CLIProxy spawn error: ${error.message}`));
|
||||
});
|
||||
|
||||
// 7. Wait for proxy readiness via TCP polling
|
||||
const readySpinner = new ProgressIndicator(`Waiting for CLIProxy on port ${cfg.port}`);
|
||||
readySpinner.start();
|
||||
|
||||
try {
|
||||
await waitForProxyReady(cfg.port, cfg.timeout, cfg.pollInterval);
|
||||
readySpinner.succeed(`CLIProxy ready on port ${cfg.port}`);
|
||||
} catch (error) {
|
||||
readySpinner.fail('CLIProxy startup failed');
|
||||
proxy.kill('SIGTERM');
|
||||
|
||||
const err = error as Error;
|
||||
console.error('');
|
||||
console.error(fail('CLIProxy failed to start'));
|
||||
console.error('');
|
||||
console.error('Possible causes:');
|
||||
console.error(` 1. Port ${cfg.port} already in use`);
|
||||
console.error(' 2. Binary crashed on startup');
|
||||
console.error(' 3. Invalid configuration');
|
||||
console.error('');
|
||||
console.error('Troubleshooting:');
|
||||
console.error(` - Check port: ${getPortCheckCommand(cfg.port)}`);
|
||||
console.error(' - Run with --verbose for detailed logs');
|
||||
console.error(` - View config: ${getCatCommand(configPath)}`);
|
||||
console.error(' - Try: ccs doctor --fix');
|
||||
console.error('');
|
||||
|
||||
throw new Error(`CLIProxy startup failed: ${err.message}`);
|
||||
}
|
||||
|
||||
// Register this session with the new proxy
|
||||
sessionId = registerSession(cfg.port, proxy.pid as number);
|
||||
log(`Registered session ${sessionId} with new proxy (PID ${proxy.pid})`);
|
||||
}
|
||||
|
||||
// 7. Execute Claude CLI with proxied environment
|
||||
// Uses custom settings path (for variants), user settings, or bundled defaults
|
||||
const envVars = getEffectiveEnvVars(provider, cfg.port, cfg.customSettingsPath);
|
||||
// Use remote or local env vars based on mode
|
||||
const envVars = useRemoteProxy
|
||||
? getRemoteEnvVars(provider, {
|
||||
host: proxyConfig.host ?? 'localhost',
|
||||
port: proxyConfig.port,
|
||||
protocol: proxyConfig.protocol,
|
||||
authToken: proxyConfig.authToken,
|
||||
})
|
||||
: getEffectiveEnvVars(provider, cfg.port, cfg.customSettingsPath);
|
||||
const webSearchEnv = getWebSearchHookEnv();
|
||||
const env = {
|
||||
...process.env,
|
||||
@@ -437,6 +532,7 @@ export async function execClaudeWithCLIProxy(
|
||||
}
|
||||
|
||||
// Filter out CCS-specific flags before passing to Claude CLI
|
||||
// Note: Proxy flags (--proxy-host, etc.) already stripped by resolveProxyConfig()
|
||||
const ccsFlags = [
|
||||
'--auth',
|
||||
'--headless',
|
||||
@@ -446,12 +542,15 @@ export async function execClaudeWithCLIProxy(
|
||||
'--accounts',
|
||||
'--use',
|
||||
'--nickname',
|
||||
// Proxy flags are handled by resolveProxyConfig, but list for documentation
|
||||
...PROXY_CLI_FLAGS,
|
||||
];
|
||||
const claudeArgs = args.filter((arg, idx) => {
|
||||
const claudeArgs = argsWithoutProxy.filter((arg, idx) => {
|
||||
// Filter out CCS flags
|
||||
if (ccsFlags.includes(arg)) return false;
|
||||
// Filter out value after --use or --nickname
|
||||
if (args[idx - 1] === '--use' || args[idx - 1] === '--nickname') return false;
|
||||
if (argsWithoutProxy[idx - 1] === '--use' || argsWithoutProxy[idx - 1] === '--nickname')
|
||||
return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
@@ -475,14 +574,16 @@ export async function execClaudeWithCLIProxy(
|
||||
});
|
||||
}
|
||||
|
||||
// 8. Cleanup: unregister session when Claude exits
|
||||
// 8. Cleanup: unregister session when Claude exits (local mode only)
|
||||
// Proxy persists by default - use 'ccs cliproxy stop' to kill manually
|
||||
claude.on('exit', (code, signal) => {
|
||||
log(`Claude exited: code=${code}, signal=${signal}`);
|
||||
|
||||
// Unregister this session (proxy keeps running for persistence)
|
||||
unregisterSession(sessionId);
|
||||
log(`Session ${sessionId} unregistered, proxy persists for other sessions or future use`);
|
||||
// Unregister this session (proxy keeps running for persistence) - only for local mode
|
||||
if (sessionId) {
|
||||
unregisterSession(sessionId);
|
||||
log(`Session ${sessionId} unregistered, proxy persists for other sessions or future use`);
|
||||
}
|
||||
|
||||
if (signal) {
|
||||
process.kill(process.pid, signal as NodeJS.Signals);
|
||||
@@ -494,8 +595,10 @@ export async function execClaudeWithCLIProxy(
|
||||
claude.on('error', (error) => {
|
||||
console.error(fail(`Claude CLI error: ${error}`));
|
||||
|
||||
// Unregister session, proxy keeps running
|
||||
unregisterSession(sessionId);
|
||||
// Unregister session, proxy keeps running (local mode only)
|
||||
if (sessionId) {
|
||||
unregisterSession(sessionId);
|
||||
}
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
@@ -503,8 +606,10 @@ export async function execClaudeWithCLIProxy(
|
||||
const cleanup = () => {
|
||||
log('Parent signal received, cleaning up');
|
||||
|
||||
// Unregister session, proxy keeps running
|
||||
unregisterSession(sessionId);
|
||||
// Unregister session, proxy keeps running (local mode only)
|
||||
if (sessionId) {
|
||||
unregisterSession(sessionId);
|
||||
}
|
||||
claude.kill('SIGTERM');
|
||||
};
|
||||
|
||||
|
||||
@@ -479,3 +479,49 @@ export function ensureProviderSettings(provider: CLIProxyProvider): void {
|
||||
mode: 0o600,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get environment variables for remote proxy mode.
|
||||
* Uses the remote proxy's provider endpoint as the base URL.
|
||||
*
|
||||
* @param provider CLIProxy provider (gemini, codex, agy, qwen, iflow)
|
||||
* @param remoteConfig Remote proxy connection details
|
||||
* @returns Environment variables for Claude CLI
|
||||
*/
|
||||
export function getRemoteEnvVars(
|
||||
provider: CLIProxyProvider,
|
||||
remoteConfig: { host: string; port: number; protocol: 'http' | 'https'; authToken?: string }
|
||||
): Record<string, string> {
|
||||
const baseUrl = `${remoteConfig.protocol}://${remoteConfig.host}:${remoteConfig.port}/api/provider/${provider}`;
|
||||
const models = getModelMapping(provider);
|
||||
|
||||
// Get global env vars (DISABLE_TELEMETRY, etc.)
|
||||
const globalEnv = getGlobalEnvVars();
|
||||
|
||||
// Get additional env vars from base config (ANTHROPIC_MAX_TOKENS, etc.)
|
||||
const baseEnvVars = getEnvVarsFromConfig(provider);
|
||||
|
||||
// Filter out core env vars from base config to avoid conflicts
|
||||
const {
|
||||
ANTHROPIC_BASE_URL: _baseUrl,
|
||||
ANTHROPIC_AUTH_TOKEN: _authToken,
|
||||
ANTHROPIC_MODEL: _model,
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: _opusModel,
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: _sonnetModel,
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: _haikuModel,
|
||||
...additionalEnvVars
|
||||
} = baseEnvVars;
|
||||
|
||||
const env: Record<string, string> = {
|
||||
...globalEnv,
|
||||
...additionalEnvVars,
|
||||
ANTHROPIC_BASE_URL: baseUrl,
|
||||
ANTHROPIC_AUTH_TOKEN: remoteConfig.authToken || CCS_INTERNAL_API_KEY,
|
||||
ANTHROPIC_MODEL: models.claudeModel,
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: models.opusModel || models.claudeModel,
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: models.sonnetModel || models.claudeModel,
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: models.haikuModel || models.claudeModel,
|
||||
};
|
||||
|
||||
return env;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
/**
|
||||
* Proxy Config Resolver
|
||||
*
|
||||
* Resolves proxy configuration from multiple sources with priority:
|
||||
* CLI flags > Environment variables > config.yaml > defaults
|
||||
*
|
||||
* Supports both local (spawn CLIProxyAPI) and remote (connect to external) modes.
|
||||
*/
|
||||
|
||||
import { ResolvedProxyConfig } from './types';
|
||||
import { CLIPROXY_DEFAULT_PORT } from './config-generator';
|
||||
|
||||
/** CLI flags for proxy configuration */
|
||||
export const PROXY_CLI_FLAGS = [
|
||||
'--proxy-host',
|
||||
'--proxy-port',
|
||||
'--proxy-protocol',
|
||||
'--proxy-auth-token',
|
||||
'--local-proxy',
|
||||
'--remote-only',
|
||||
] as const;
|
||||
|
||||
/** Environment variable names for proxy configuration */
|
||||
export const PROXY_ENV_VARS = {
|
||||
host: 'CCS_PROXY_HOST',
|
||||
port: 'CCS_PROXY_PORT',
|
||||
protocol: 'CCS_PROXY_PROTOCOL',
|
||||
authToken: 'CCS_PROXY_AUTH_TOKEN',
|
||||
fallbackEnabled: 'CCS_PROXY_FALLBACK_ENABLED',
|
||||
} as const;
|
||||
|
||||
/** Parsed CLI proxy flags */
|
||||
interface ParsedProxyFlags {
|
||||
host?: string;
|
||||
port?: number;
|
||||
protocol?: 'http' | 'https';
|
||||
authToken?: string;
|
||||
localProxy: boolean;
|
||||
remoteOnly: boolean;
|
||||
}
|
||||
|
||||
/** Proxy config from environment variables */
|
||||
interface EnvProxyConfig {
|
||||
host?: string;
|
||||
port?: number;
|
||||
protocol?: 'http' | 'https';
|
||||
authToken?: string;
|
||||
fallbackEnabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse proxy-related CLI flags from argv.
|
||||
* Returns parsed flags and remaining args (with proxy flags removed).
|
||||
*/
|
||||
export function parseProxyFlags(args: string[]): {
|
||||
flags: ParsedProxyFlags;
|
||||
remainingArgs: string[];
|
||||
} {
|
||||
const flags: ParsedProxyFlags = {
|
||||
localProxy: false,
|
||||
remoteOnly: false,
|
||||
};
|
||||
const remainingArgs: string[] = [];
|
||||
|
||||
let i = 0;
|
||||
while (i < args.length) {
|
||||
const arg = args[i];
|
||||
|
||||
if (arg === '--proxy-host' && args[i + 1] && !args[i + 1].startsWith('-')) {
|
||||
flags.host = args[i + 1];
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg === '--proxy-port' && args[i + 1] && !args[i + 1].startsWith('-')) {
|
||||
const port = parseInt(args[i + 1], 10);
|
||||
if (!isNaN(port) && port > 0 && port <= 65535) {
|
||||
flags.port = port;
|
||||
}
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg === '--proxy-protocol' && args[i + 1] && !args[i + 1].startsWith('-')) {
|
||||
const proto = args[i + 1].toLowerCase();
|
||||
if (proto === 'http' || proto === 'https') {
|
||||
flags.protocol = proto;
|
||||
}
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg === '--proxy-auth-token' && args[i + 1] && !args[i + 1].startsWith('-')) {
|
||||
flags.authToken = args[i + 1];
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg === '--local-proxy') {
|
||||
flags.localProxy = true;
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg === '--remote-only') {
|
||||
flags.remoteOnly = true;
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Not a proxy flag - keep in remaining args
|
||||
remainingArgs.push(arg);
|
||||
i += 1;
|
||||
}
|
||||
|
||||
return { flags, remainingArgs };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get proxy configuration from environment variables.
|
||||
*/
|
||||
export function getProxyEnvVars(): EnvProxyConfig {
|
||||
const config: EnvProxyConfig = {};
|
||||
|
||||
const host = process.env[PROXY_ENV_VARS.host];
|
||||
if (host) {
|
||||
config.host = host;
|
||||
}
|
||||
|
||||
const port = process.env[PROXY_ENV_VARS.port];
|
||||
if (port) {
|
||||
const portNum = parseInt(port, 10);
|
||||
if (!isNaN(portNum) && portNum > 0 && portNum <= 65535) {
|
||||
config.port = portNum;
|
||||
}
|
||||
}
|
||||
|
||||
const protocol = process.env[PROXY_ENV_VARS.protocol];
|
||||
if (protocol) {
|
||||
const proto = protocol.toLowerCase();
|
||||
if (proto === 'http' || proto === 'https') {
|
||||
config.protocol = proto;
|
||||
}
|
||||
}
|
||||
|
||||
const authToken = process.env[PROXY_ENV_VARS.authToken];
|
||||
if (authToken) {
|
||||
config.authToken = authToken;
|
||||
}
|
||||
|
||||
const fallback = process.env[PROXY_ENV_VARS.fallbackEnabled];
|
||||
if (fallback !== undefined) {
|
||||
// Accept: '1', 'true', 'yes' as enabled; '0', 'false', 'no' as disabled
|
||||
const lower = fallback.toLowerCase();
|
||||
if (lower === '1' || lower === 'true' || lower === 'yes') {
|
||||
config.fallbackEnabled = true;
|
||||
} else if (lower === '0' || lower === 'false' || lower === 'no') {
|
||||
config.fallbackEnabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default proxy configuration values.
|
||||
*/
|
||||
const DEFAULT_PROXY_CONFIG: ResolvedProxyConfig = {
|
||||
mode: 'local',
|
||||
port: CLIPROXY_DEFAULT_PORT,
|
||||
protocol: 'http',
|
||||
fallbackEnabled: true,
|
||||
autoStartLocal: true,
|
||||
remoteOnly: false,
|
||||
forceLocal: false,
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve proxy configuration with priority: CLI > ENV > config.yaml > defaults.
|
||||
*
|
||||
* @param cliArgs - Raw CLI arguments
|
||||
* @param configYamlProxy - Proxy section from config.yaml (optional, Phase 1)
|
||||
* @returns Resolved configuration and remaining args (without proxy flags)
|
||||
*/
|
||||
export function resolveProxyConfig(
|
||||
cliArgs: string[],
|
||||
|
||||
_configYamlProxy?: {
|
||||
remote?: {
|
||||
enabled?: boolean;
|
||||
host?: string;
|
||||
port?: number;
|
||||
protocol?: 'http' | 'https';
|
||||
auth_token?: string;
|
||||
fallback_enabled?: boolean;
|
||||
};
|
||||
local?: {
|
||||
port?: number;
|
||||
auto_start?: boolean;
|
||||
};
|
||||
}
|
||||
): { config: ResolvedProxyConfig; remainingArgs: string[] } {
|
||||
// 1. Parse CLI flags (highest priority)
|
||||
const { flags: cliFlags, remainingArgs } = parseProxyFlags(cliArgs);
|
||||
|
||||
// 2. Get environment variables
|
||||
const envConfig = getProxyEnvVars();
|
||||
|
||||
// 3. config.yaml proxy section (passed as parameter - Phase 1 provides this)
|
||||
// For now, we use empty object if not provided; Phase 1 integrates unified config loading
|
||||
const yamlConfig = _configYamlProxy || {};
|
||||
|
||||
// 4. Build resolved config with priority merge
|
||||
const resolved: ResolvedProxyConfig = {
|
||||
...DEFAULT_PROXY_CONFIG,
|
||||
};
|
||||
|
||||
// Determine mode: remote if host is specified anywhere (unless --local-proxy)
|
||||
const hasRemoteHost =
|
||||
cliFlags.host || envConfig.host || yamlConfig.remote?.host || yamlConfig.remote?.enabled;
|
||||
|
||||
// --local-proxy forces local mode regardless of remote config
|
||||
if (cliFlags.localProxy) {
|
||||
resolved.mode = 'local';
|
||||
resolved.forceLocal = true;
|
||||
} else if (hasRemoteHost) {
|
||||
resolved.mode = 'remote';
|
||||
}
|
||||
|
||||
// Merge host: CLI > ENV > config.yaml
|
||||
resolved.host = cliFlags.host ?? envConfig.host ?? yamlConfig.remote?.host;
|
||||
|
||||
// Merge port: CLI > ENV > config.yaml (remote or local) > default
|
||||
resolved.port =
|
||||
cliFlags.port ??
|
||||
envConfig.port ??
|
||||
(resolved.mode === 'remote' ? yamlConfig.remote?.port : yamlConfig.local?.port) ??
|
||||
DEFAULT_PROXY_CONFIG.port;
|
||||
|
||||
// Merge protocol: CLI > ENV > config.yaml > default
|
||||
resolved.protocol =
|
||||
cliFlags.protocol ?? envConfig.protocol ?? yamlConfig.remote?.protocol ?? 'http';
|
||||
|
||||
// Merge auth token: CLI > ENV > config.yaml
|
||||
resolved.authToken = cliFlags.authToken ?? envConfig.authToken ?? yamlConfig.remote?.auth_token;
|
||||
|
||||
// Merge fallback enabled: ENV > config.yaml > default
|
||||
resolved.fallbackEnabled =
|
||||
envConfig.fallbackEnabled ?? yamlConfig.remote?.fallback_enabled ?? true;
|
||||
|
||||
// --remote-only from CLI
|
||||
resolved.remoteOnly = cliFlags.remoteOnly;
|
||||
|
||||
// If --remote-only, disable fallback
|
||||
if (resolved.remoteOnly) {
|
||||
resolved.fallbackEnabled = false;
|
||||
}
|
||||
|
||||
// Auto-start local from config.yaml > default
|
||||
resolved.autoStartLocal = yamlConfig.local?.auto_start ?? true;
|
||||
|
||||
return { config: resolved, remainingArgs };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if args contain any proxy flags.
|
||||
* Used for quick filtering before full parse.
|
||||
*/
|
||||
export function hasProxyFlags(args: string[]): boolean {
|
||||
return args.some(
|
||||
(arg) =>
|
||||
arg === '--proxy-host' ||
|
||||
arg === '--proxy-port' ||
|
||||
arg === '--proxy-protocol' ||
|
||||
arg === '--proxy-auth-token' ||
|
||||
arg === '--local-proxy' ||
|
||||
arg === '--remote-only'
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
/**
|
||||
* Remote Proxy Client for CLIProxyAPI
|
||||
*
|
||||
* HTTP client for health checks and connection testing against remote CLIProxyAPI instances.
|
||||
* Uses native fetch API with aggressive timeout for CLI responsiveness.
|
||||
*/
|
||||
|
||||
import * as https from 'https';
|
||||
|
||||
/** Error codes for remote proxy status */
|
||||
export type RemoteProxyErrorCode = 'CONNECTION_REFUSED' | 'TIMEOUT' | 'AUTH_FAILED' | 'UNKNOWN';
|
||||
|
||||
/** Status returned from remote proxy health check */
|
||||
export interface RemoteProxyStatus {
|
||||
/** Whether the remote proxy is reachable */
|
||||
reachable: boolean;
|
||||
/** Latency in milliseconds (only set if reachable) */
|
||||
latencyMs?: number;
|
||||
/** Error message (only set if not reachable) */
|
||||
error?: string;
|
||||
/** Error code for programmatic handling */
|
||||
errorCode?: RemoteProxyErrorCode;
|
||||
}
|
||||
|
||||
/** Configuration for remote proxy client */
|
||||
export interface RemoteProxyClientConfig {
|
||||
/** Remote proxy host (IP or hostname) */
|
||||
host: string;
|
||||
/** Remote proxy port */
|
||||
port: number;
|
||||
/** Protocol to use (http or https) */
|
||||
protocol: 'http' | 'https';
|
||||
/** Optional auth token for Authorization header */
|
||||
authToken?: string;
|
||||
/** Request timeout in ms (default: 2000) */
|
||||
timeout?: number;
|
||||
/** Allow self-signed certificates (default: false) */
|
||||
allowSelfSigned?: boolean;
|
||||
}
|
||||
|
||||
/** Default timeout for remote proxy requests (aggressive for CLI UX) */
|
||||
const DEFAULT_TIMEOUT_MS = 2000;
|
||||
|
||||
/**
|
||||
* Map error to RemoteProxyErrorCode
|
||||
*/
|
||||
function mapErrorToCode(error: Error, statusCode?: number): RemoteProxyErrorCode {
|
||||
const message = error.message.toLowerCase();
|
||||
const code = (error as NodeJS.ErrnoException).code?.toLowerCase();
|
||||
|
||||
// Connection refused
|
||||
if (code === 'econnrefused' || message.includes('connection refused')) {
|
||||
return 'CONNECTION_REFUSED';
|
||||
}
|
||||
|
||||
// Timeout
|
||||
if (
|
||||
code === 'etimedout' ||
|
||||
code === 'timeout' ||
|
||||
message.includes('timeout') ||
|
||||
message.includes('aborted')
|
||||
) {
|
||||
return 'TIMEOUT';
|
||||
}
|
||||
|
||||
// Auth failed (401/403)
|
||||
if (statusCode === 401 || statusCode === 403) {
|
||||
return 'AUTH_FAILED';
|
||||
}
|
||||
|
||||
return 'UNKNOWN';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get human-readable error message from error code
|
||||
*/
|
||||
function getErrorMessage(errorCode: RemoteProxyErrorCode, rawError?: string): string {
|
||||
switch (errorCode) {
|
||||
case 'CONNECTION_REFUSED':
|
||||
return 'Connection refused - is the proxy running?';
|
||||
case 'TIMEOUT':
|
||||
return 'Connection timed out';
|
||||
case 'AUTH_FAILED':
|
||||
return 'Authentication failed - check auth token';
|
||||
default:
|
||||
return rawError || 'Unknown error';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a custom HTTPS agent for self-signed certificate support
|
||||
*/
|
||||
function createHttpsAgent(allowSelfSigned: boolean): https.Agent | undefined {
|
||||
if (!allowSelfSigned) return undefined;
|
||||
|
||||
return new https.Agent({
|
||||
rejectUnauthorized: false,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check health of remote CLIProxyAPI instance
|
||||
*
|
||||
* @param config Remote proxy client configuration
|
||||
* @returns RemoteProxyStatus with reachability and latency
|
||||
*/
|
||||
export async function checkRemoteProxy(
|
||||
config: RemoteProxyClientConfig
|
||||
): Promise<RemoteProxyStatus> {
|
||||
const { host, port, protocol, authToken, allowSelfSigned = false } = config;
|
||||
const timeout = config.timeout ?? DEFAULT_TIMEOUT_MS;
|
||||
|
||||
const url = `${protocol}://${host}:${port}/health`;
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
||||
|
||||
// Build request options
|
||||
const headers: Record<string, string> = {
|
||||
Accept: 'application/json',
|
||||
};
|
||||
|
||||
if (authToken) {
|
||||
headers['Authorization'] = `Bearer ${authToken}`;
|
||||
}
|
||||
|
||||
// For HTTPS with self-signed certs, we need to use native https module
|
||||
// Bun's fetch doesn't support custom agents
|
||||
let response: Response;
|
||||
|
||||
if (protocol === 'https' && allowSelfSigned) {
|
||||
// Warn about security implications
|
||||
console.error('[!] Allowing self-signed certificate - not recommended for production');
|
||||
|
||||
// Use native https module for self-signed cert support
|
||||
response = await new Promise<Response>((resolve, reject) => {
|
||||
const agent = createHttpsAgent(true);
|
||||
const reqTimeout = setTimeout(() => {
|
||||
reject(new Error('Request timeout'));
|
||||
}, timeout);
|
||||
|
||||
const req = https.request(
|
||||
url,
|
||||
{
|
||||
method: 'GET',
|
||||
headers,
|
||||
agent,
|
||||
timeout,
|
||||
},
|
||||
(res) => {
|
||||
clearTimeout(reqTimeout);
|
||||
let data = '';
|
||||
res.on('data', (chunk) => (data += chunk));
|
||||
res.on('end', () => {
|
||||
resolve(
|
||||
new Response(data, {
|
||||
status: res.statusCode || 500,
|
||||
statusText: res.statusMessage,
|
||||
})
|
||||
);
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
req.on('error', (err) => {
|
||||
clearTimeout(reqTimeout);
|
||||
reject(err);
|
||||
});
|
||||
|
||||
req.on('timeout', () => {
|
||||
req.destroy();
|
||||
reject(new Error('Request timeout'));
|
||||
});
|
||||
|
||||
req.end();
|
||||
});
|
||||
} else {
|
||||
// Standard fetch for HTTP or HTTPS without self-signed
|
||||
response = await fetch(url, {
|
||||
signal: controller.signal,
|
||||
headers,
|
||||
});
|
||||
}
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
const latencyMs = Date.now() - startTime;
|
||||
|
||||
// Check for auth failure
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
return {
|
||||
reachable: false,
|
||||
error: getErrorMessage('AUTH_FAILED'),
|
||||
errorCode: 'AUTH_FAILED',
|
||||
};
|
||||
}
|
||||
|
||||
// 200 OK = healthy
|
||||
if (response.ok) {
|
||||
return {
|
||||
reachable: true,
|
||||
latencyMs,
|
||||
};
|
||||
}
|
||||
|
||||
// Non-200 but connected
|
||||
return {
|
||||
reachable: false,
|
||||
error: `Unexpected status: ${response.status}`,
|
||||
errorCode: 'UNKNOWN',
|
||||
};
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
const errorCode = mapErrorToCode(err);
|
||||
|
||||
return {
|
||||
reachable: false,
|
||||
error: getErrorMessage(errorCode, err.message),
|
||||
errorCode,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test connection to remote CLIProxyAPI (alias for dashboard use)
|
||||
*
|
||||
* This is an alias for checkRemoteProxy() for semantic clarity in UI contexts.
|
||||
*
|
||||
* @param config Remote proxy client configuration
|
||||
* @returns RemoteProxyStatus with reachability and latency
|
||||
*/
|
||||
export async function testConnection(config: RemoteProxyClientConfig): Promise<RemoteProxyStatus> {
|
||||
return checkRemoteProxy(config);
|
||||
}
|
||||
@@ -187,3 +187,28 @@ export interface ProviderConfig {
|
||||
/** Whether OAuth is required */
|
||||
requiresOAuth: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolved proxy configuration after merging CLI > ENV > config.yaml > defaults.
|
||||
* Used by executor to determine local vs remote proxy mode.
|
||||
*/
|
||||
export interface ResolvedProxyConfig {
|
||||
/** Proxy mode: 'local' spawns CLIProxyAPI locally, 'remote' connects to external server */
|
||||
mode: 'local' | 'remote';
|
||||
/** Remote proxy hostname/IP (only for remote mode) */
|
||||
host?: string;
|
||||
/** Proxy port (default: 8317) */
|
||||
port: number;
|
||||
/** Protocol for remote connection (default: http) */
|
||||
protocol: 'http' | 'https';
|
||||
/** Auth token for remote proxy authentication */
|
||||
authToken?: string;
|
||||
/** Enable fallback to local when remote unreachable (default: true) */
|
||||
fallbackEnabled: boolean;
|
||||
/** Auto-start local proxy if not running (default: true) */
|
||||
autoStartLocal: boolean;
|
||||
/** --remote-only flag: fail if remote unreachable, no fallback */
|
||||
remoteOnly: boolean;
|
||||
/** --local-proxy flag: force local mode, ignore remote config */
|
||||
forceLocal: boolean;
|
||||
}
|
||||
|
||||
@@ -241,6 +241,25 @@ Claude Code Profile & Model Switcher`.trim();
|
||||
['ccs cliproxy --latest', 'Update to latest version'],
|
||||
]);
|
||||
|
||||
// CLI Proxy configuration flags (new)
|
||||
printSubSection('CLI Proxy Configuration', [
|
||||
['--proxy-host <host>', 'Remote proxy hostname/IP'],
|
||||
['--proxy-port <port>', 'Proxy port (default: 8317)'],
|
||||
['--proxy-protocol <proto>', 'Protocol: http or https (default: http)'],
|
||||
['--proxy-auth-token <token>', 'Auth token for remote proxy'],
|
||||
['--local-proxy', 'Force local mode, ignore remote config'],
|
||||
['--remote-only', 'Fail if remote unreachable (no fallback)'],
|
||||
]);
|
||||
|
||||
// CLI Proxy env vars
|
||||
printSubSection('CLI Proxy Environment Variables', [
|
||||
['CCS_PROXY_HOST', 'Remote proxy hostname'],
|
||||
['CCS_PROXY_PORT', 'Proxy port'],
|
||||
['CCS_PROXY_PROTOCOL', 'Protocol (http/https)'],
|
||||
['CCS_PROXY_AUTH_TOKEN', 'Auth token'],
|
||||
['CCS_PROXY_FALLBACK_ENABLED', 'Enable local fallback (1/0)'],
|
||||
]);
|
||||
|
||||
// CLI Proxy paths
|
||||
console.log(subheader('CLI Proxy:'));
|
||||
console.log(` Binary: ${color('~/.ccs/cliproxy/bin/cli-proxy-api', 'path')}`);
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
UNIFIED_CONFIG_VERSION,
|
||||
DEFAULT_COPILOT_CONFIG,
|
||||
DEFAULT_GLOBAL_ENV,
|
||||
DEFAULT_CLIPROXY_SERVER_CONFIG,
|
||||
GlobalEnvConfig,
|
||||
} from './unified-config-types';
|
||||
import { isUnifiedConfigEnabled } from './feature-flags';
|
||||
@@ -177,6 +178,35 @@ function mergeWithDefaults(partial: Partial<UnifiedConfig>): UnifiedConfig {
|
||||
enabled: partial.global_env?.enabled ?? true,
|
||||
env: partial.global_env?.env ?? { ...DEFAULT_GLOBAL_ENV },
|
||||
},
|
||||
// CLIProxy server config - remote/local CLIProxyAPI settings
|
||||
cliproxy_server: {
|
||||
remote: {
|
||||
enabled:
|
||||
partial.cliproxy_server?.remote?.enabled ?? DEFAULT_CLIPROXY_SERVER_CONFIG.remote.enabled,
|
||||
host: partial.cliproxy_server?.remote?.host ?? DEFAULT_CLIPROXY_SERVER_CONFIG.remote.host,
|
||||
port: partial.cliproxy_server?.remote?.port ?? DEFAULT_CLIPROXY_SERVER_CONFIG.remote.port,
|
||||
protocol:
|
||||
partial.cliproxy_server?.remote?.protocol ??
|
||||
DEFAULT_CLIPROXY_SERVER_CONFIG.remote.protocol,
|
||||
auth_token:
|
||||
partial.cliproxy_server?.remote?.auth_token ??
|
||||
DEFAULT_CLIPROXY_SERVER_CONFIG.remote.auth_token,
|
||||
},
|
||||
fallback: {
|
||||
enabled:
|
||||
partial.cliproxy_server?.fallback?.enabled ??
|
||||
DEFAULT_CLIPROXY_SERVER_CONFIG.fallback.enabled,
|
||||
auto_start:
|
||||
partial.cliproxy_server?.fallback?.auto_start ??
|
||||
DEFAULT_CLIPROXY_SERVER_CONFIG.fallback.auto_start,
|
||||
},
|
||||
local: {
|
||||
port: partial.cliproxy_server?.local?.port ?? DEFAULT_CLIPROXY_SERVER_CONFIG.local.port,
|
||||
auto_start:
|
||||
partial.cliproxy_server?.local?.auto_start ??
|
||||
DEFAULT_CLIPROXY_SERVER_CONFIG.local.auto_start,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -14,8 +14,9 @@
|
||||
* Version 2 = YAML unified format
|
||||
* Version 3 = WebSearch config with model configuration for Gemini/OpenCode
|
||||
* Version 4 = Copilot API integration (GitHub Copilot proxy)
|
||||
* Version 5 = Remote proxy configuration (connect to remote CLIProxyAPI)
|
||||
*/
|
||||
export const UNIFIED_CONFIG_VERSION = 4;
|
||||
export const UNIFIED_CONFIG_VERSION = 5;
|
||||
|
||||
/**
|
||||
* Account configuration (formerly in profiles.json).
|
||||
@@ -185,6 +186,56 @@ export interface CopilotConfig {
|
||||
haiku_model?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remote proxy configuration.
|
||||
* Connect to a remote CLIProxyAPI instance instead of spawning local binary.
|
||||
*/
|
||||
export interface ProxyRemoteConfig {
|
||||
/** Enable remote proxy mode (default: false = local mode) */
|
||||
enabled: boolean;
|
||||
/** Remote proxy hostname or IP (empty = not configured) */
|
||||
host: string;
|
||||
/** Remote proxy port (default: 8317) */
|
||||
port: number;
|
||||
/** Protocol for remote connection */
|
||||
protocol: 'http' | 'https';
|
||||
/** Auth token for remote proxy (optional, sent as header) */
|
||||
auth_token: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback configuration when remote proxy is unreachable.
|
||||
*/
|
||||
export interface ProxyFallbackConfig {
|
||||
/** Enable fallback to local proxy (default: true) */
|
||||
enabled: boolean;
|
||||
/** Auto-start local proxy without prompting (default: false = prompt user) */
|
||||
auto_start: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Local proxy configuration.
|
||||
*/
|
||||
export interface ProxyLocalConfig {
|
||||
/** Local proxy port (default: 8317) */
|
||||
port: number;
|
||||
/** Auto-start local binary (default: true) */
|
||||
auto_start: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* CLIProxy server configuration section.
|
||||
* Controls whether CCS uses local or remote CLIProxyAPI instance.
|
||||
*/
|
||||
export interface CliproxyServerConfig {
|
||||
/** Remote proxy settings */
|
||||
remote: ProxyRemoteConfig;
|
||||
/** Fallback behavior when remote is unreachable */
|
||||
fallback: ProxyFallbackConfig;
|
||||
/** Local proxy settings */
|
||||
local: ProxyLocalConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Global environment variables configuration.
|
||||
* These env vars are injected into ALL non-Claude subscription profiles.
|
||||
@@ -242,7 +293,7 @@ export interface WebSearchConfig {
|
||||
* Stored in ~/.ccs/config.yaml
|
||||
*/
|
||||
export interface UnifiedConfig {
|
||||
/** Config version (4 for copilot support) */
|
||||
/** Config version (5 for remote proxy support) */
|
||||
version: number;
|
||||
/** Default profile name to use when none specified */
|
||||
default?: string;
|
||||
@@ -260,6 +311,8 @@ export interface UnifiedConfig {
|
||||
global_env?: GlobalEnvConfig;
|
||||
/** Copilot API configuration (GitHub Copilot proxy) */
|
||||
copilot?: CopilotConfig;
|
||||
/** CLIProxy server configuration for remote/local mode */
|
||||
cliproxy_server?: CliproxyServerConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -289,6 +342,28 @@ export const DEFAULT_COPILOT_CONFIG: CopilotConfig = {
|
||||
model: 'gpt-4.1', // Free tier compatible
|
||||
};
|
||||
|
||||
/**
|
||||
* Default CLIProxy server configuration.
|
||||
* Local mode by default - remote must be explicitly enabled.
|
||||
*/
|
||||
export const DEFAULT_CLIPROXY_SERVER_CONFIG: CliproxyServerConfig = {
|
||||
remote: {
|
||||
enabled: false,
|
||||
host: '',
|
||||
port: 8317,
|
||||
protocol: 'http',
|
||||
auth_token: '',
|
||||
},
|
||||
fallback: {
|
||||
enabled: true,
|
||||
auto_start: false,
|
||||
},
|
||||
local: {
|
||||
port: 8317,
|
||||
auto_start: true,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Create an empty unified config with defaults.
|
||||
*/
|
||||
@@ -336,6 +411,7 @@ export function createEmptyUnifiedConfig(): UnifiedConfig {
|
||||
env: { ...DEFAULT_GLOBAL_ENV },
|
||||
},
|
||||
copilot: { ...DEFAULT_COPILOT_CONFIG },
|
||||
cliproxy_server: { ...DEFAULT_CLIPROXY_SERVER_CONFIG },
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -51,6 +51,10 @@ export async function startServer(options: ServerOptions): Promise<ServerInstanc
|
||||
const { usageRoutes } = await import('./usage-routes');
|
||||
app.use('/api/usage', usageRoutes);
|
||||
|
||||
// CLIProxy server settings routes (Phase 5)
|
||||
const cliproxyServerRoutes = (await import('./routes/proxy-routes')).default;
|
||||
app.use('/api/cliproxy-server', cliproxyServerRoutes);
|
||||
|
||||
// Dev mode: use Vite middleware for HMR
|
||||
if (options.dev) {
|
||||
const { createServer: createViteServer } = await import('vite');
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* CLIProxy Server Routes - API endpoints for proxy configuration
|
||||
*
|
||||
* Provides REST endpoints for managing CLIProxyAPI connection settings:
|
||||
* - GET /api/cliproxy-server - Get proxy configuration
|
||||
* - PUT /api/cliproxy-server - Update proxy configuration
|
||||
* - POST /api/cliproxy-server/test - Test remote connection
|
||||
*/
|
||||
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { loadOrCreateUnifiedConfig, saveUnifiedConfig } from '../../config/unified-config-loader';
|
||||
import { testConnection } from '../../cliproxy/remote-proxy-client';
|
||||
import {
|
||||
DEFAULT_CLIPROXY_SERVER_CONFIG,
|
||||
CliproxyServerConfig,
|
||||
} from '../../config/unified-config-types';
|
||||
|
||||
const router = Router();
|
||||
|
||||
/**
|
||||
* GET /api/cliproxy-server - Get proxy configuration
|
||||
*/
|
||||
router.get('/', async (_req: Request, res: Response) => {
|
||||
try {
|
||||
const config = await loadOrCreateUnifiedConfig();
|
||||
res.json(config.cliproxy_server || DEFAULT_CLIPROXY_SERVER_CONFIG);
|
||||
} catch (error) {
|
||||
console.error('[cliproxy-server-routes] Failed to load proxy config:', error);
|
||||
res.status(500).json({ error: 'Failed to load proxy config' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PUT /api/cliproxy-server - Update proxy configuration
|
||||
*/
|
||||
router.put('/', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const config = await loadOrCreateUnifiedConfig();
|
||||
const updates = req.body as Partial<CliproxyServerConfig>;
|
||||
|
||||
// Deep merge with defaults and current config
|
||||
config.cliproxy_server = {
|
||||
remote: {
|
||||
...DEFAULT_CLIPROXY_SERVER_CONFIG.remote,
|
||||
...config.cliproxy_server?.remote,
|
||||
...updates.remote,
|
||||
},
|
||||
fallback: {
|
||||
...DEFAULT_CLIPROXY_SERVER_CONFIG.fallback,
|
||||
...config.cliproxy_server?.fallback,
|
||||
...updates.fallback,
|
||||
},
|
||||
local: {
|
||||
...DEFAULT_CLIPROXY_SERVER_CONFIG.local,
|
||||
...config.cliproxy_server?.local,
|
||||
...updates.local,
|
||||
},
|
||||
};
|
||||
|
||||
await saveUnifiedConfig(config);
|
||||
res.json(config.cliproxy_server);
|
||||
} catch (error) {
|
||||
console.error('[cliproxy-server-routes] Failed to save proxy config:', error);
|
||||
res.status(500).json({ error: 'Failed to save proxy config' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/cliproxy-server/test - Test remote proxy connection
|
||||
*/
|
||||
router.post('/test', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { host, port, protocol, authToken, allowSelfSigned } = req.body;
|
||||
|
||||
if (!host || !port) {
|
||||
res.status(400).json({ error: 'Host and port are required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const status = await testConnection({
|
||||
host,
|
||||
port: typeof port === 'number' ? port : parseInt(port, 10),
|
||||
protocol: protocol || 'http',
|
||||
authToken,
|
||||
allowSelfSigned: allowSelfSigned || false,
|
||||
timeout: 5000,
|
||||
});
|
||||
|
||||
res.json(status);
|
||||
} catch (error) {
|
||||
console.error('[cliproxy-server-routes] Failed to test connection:', error);
|
||||
res.status(500).json({ error: 'Failed to test connection' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,274 @@
|
||||
/**
|
||||
* Unit tests for proxy-config-resolver module
|
||||
*/
|
||||
const { describe, it, expect, beforeEach, afterEach } = require('bun:test');
|
||||
|
||||
// Import from compiled dist
|
||||
const {
|
||||
parseProxyFlags,
|
||||
getProxyEnvVars,
|
||||
resolveProxyConfig,
|
||||
hasProxyFlags,
|
||||
PROXY_CLI_FLAGS,
|
||||
PROXY_ENV_VARS,
|
||||
} = require('../../../dist/cliproxy/proxy-config-resolver');
|
||||
|
||||
describe('proxy-config-resolver', () => {
|
||||
describe('PROXY_CLI_FLAGS', () => {
|
||||
it('should define all expected proxy flags', () => {
|
||||
expect(PROXY_CLI_FLAGS).toContain('--proxy-host');
|
||||
expect(PROXY_CLI_FLAGS).toContain('--proxy-port');
|
||||
expect(PROXY_CLI_FLAGS).toContain('--proxy-protocol');
|
||||
expect(PROXY_CLI_FLAGS).toContain('--proxy-auth-token');
|
||||
expect(PROXY_CLI_FLAGS).toContain('--local-proxy');
|
||||
expect(PROXY_CLI_FLAGS).toContain('--remote-only');
|
||||
});
|
||||
});
|
||||
|
||||
describe('PROXY_ENV_VARS', () => {
|
||||
it('should define all expected environment variable names', () => {
|
||||
expect(PROXY_ENV_VARS.host).toBe('CCS_PROXY_HOST');
|
||||
expect(PROXY_ENV_VARS.port).toBe('CCS_PROXY_PORT');
|
||||
expect(PROXY_ENV_VARS.protocol).toBe('CCS_PROXY_PROTOCOL');
|
||||
expect(PROXY_ENV_VARS.authToken).toBe('CCS_PROXY_AUTH_TOKEN');
|
||||
expect(PROXY_ENV_VARS.fallbackEnabled).toBe('CCS_PROXY_FALLBACK_ENABLED');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseProxyFlags', () => {
|
||||
it('should parse --proxy-host flag', () => {
|
||||
const { flags, remainingArgs } = parseProxyFlags(['--proxy-host', '192.168.1.100']);
|
||||
expect(flags.host).toBe('192.168.1.100');
|
||||
expect(remainingArgs).toEqual([]);
|
||||
});
|
||||
|
||||
it('should parse --proxy-port flag', () => {
|
||||
const { flags, remainingArgs } = parseProxyFlags(['--proxy-port', '9000']);
|
||||
expect(flags.port).toBe(9000);
|
||||
expect(remainingArgs).toEqual([]);
|
||||
});
|
||||
|
||||
it('should parse --proxy-protocol flag', () => {
|
||||
const { flags } = parseProxyFlags(['--proxy-protocol', 'https']);
|
||||
expect(flags.protocol).toBe('https');
|
||||
});
|
||||
|
||||
it('should parse --proxy-auth-token flag', () => {
|
||||
const { flags } = parseProxyFlags(['--proxy-auth-token', 'secret123']);
|
||||
expect(flags.authToken).toBe('secret123');
|
||||
});
|
||||
|
||||
it('should parse --local-proxy boolean flag', () => {
|
||||
const { flags } = parseProxyFlags(['--local-proxy']);
|
||||
expect(flags.localProxy).toBe(true);
|
||||
});
|
||||
|
||||
it('should parse --remote-only boolean flag', () => {
|
||||
const { flags } = parseProxyFlags(['--remote-only']);
|
||||
expect(flags.remoteOnly).toBe(true);
|
||||
});
|
||||
|
||||
it('should preserve non-proxy args in remainingArgs', () => {
|
||||
const { flags, remainingArgs } = parseProxyFlags([
|
||||
'--verbose',
|
||||
'--proxy-host',
|
||||
'localhost',
|
||||
'--some-other-flag',
|
||||
]);
|
||||
expect(flags.host).toBe('localhost');
|
||||
expect(remainingArgs).toEqual(['--verbose', '--some-other-flag']);
|
||||
});
|
||||
|
||||
it('should handle mixed proxy and non-proxy args', () => {
|
||||
const { flags, remainingArgs } = parseProxyFlags([
|
||||
'arg1',
|
||||
'--proxy-port',
|
||||
'8080',
|
||||
'arg2',
|
||||
'--local-proxy',
|
||||
'arg3',
|
||||
]);
|
||||
expect(flags.port).toBe(8080);
|
||||
expect(flags.localProxy).toBe(true);
|
||||
expect(remainingArgs).toEqual(['arg1', 'arg2', 'arg3']);
|
||||
});
|
||||
|
||||
it('should ignore invalid port values', () => {
|
||||
const { flags } = parseProxyFlags(['--proxy-port', 'invalid']);
|
||||
expect(flags.port).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should ignore out-of-range port values', () => {
|
||||
const { flags: flags1 } = parseProxyFlags(['--proxy-port', '0']);
|
||||
expect(flags1.port).toBeUndefined();
|
||||
|
||||
const { flags: flags2 } = parseProxyFlags(['--proxy-port', '70000']);
|
||||
expect(flags2.port).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should normalize protocol to lowercase', () => {
|
||||
const { flags } = parseProxyFlags(['--proxy-protocol', 'HTTPS']);
|
||||
expect(flags.protocol).toBe('https');
|
||||
});
|
||||
|
||||
it('should ignore invalid protocol values', () => {
|
||||
const { flags } = parseProxyFlags(['--proxy-protocol', 'ftp']);
|
||||
expect(flags.protocol).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getProxyEnvVars', () => {
|
||||
const originalEnv = { ...process.env };
|
||||
|
||||
beforeEach(() => {
|
||||
// Clear proxy env vars
|
||||
delete process.env.CCS_PROXY_HOST;
|
||||
delete process.env.CCS_PROXY_PORT;
|
||||
delete process.env.CCS_PROXY_PROTOCOL;
|
||||
delete process.env.CCS_PROXY_AUTH_TOKEN;
|
||||
delete process.env.CCS_PROXY_FALLBACK_ENABLED;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Restore original env
|
||||
Object.keys(process.env).forEach((key) => {
|
||||
if (key.startsWith('CCS_PROXY_')) {
|
||||
delete process.env[key];
|
||||
}
|
||||
});
|
||||
Object.assign(process.env, originalEnv);
|
||||
});
|
||||
|
||||
it('should return empty config when no env vars set', () => {
|
||||
const config = getProxyEnvVars();
|
||||
expect(config.host).toBeUndefined();
|
||||
expect(config.port).toBeUndefined();
|
||||
expect(config.protocol).toBeUndefined();
|
||||
expect(config.authToken).toBeUndefined();
|
||||
expect(config.fallbackEnabled).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should read CCS_PROXY_HOST', () => {
|
||||
process.env.CCS_PROXY_HOST = 'remote.example.com';
|
||||
const config = getProxyEnvVars();
|
||||
expect(config.host).toBe('remote.example.com');
|
||||
});
|
||||
|
||||
it('should read and parse CCS_PROXY_PORT', () => {
|
||||
process.env.CCS_PROXY_PORT = '9000';
|
||||
const config = getProxyEnvVars();
|
||||
expect(config.port).toBe(9000);
|
||||
});
|
||||
|
||||
it('should read CCS_PROXY_PROTOCOL', () => {
|
||||
process.env.CCS_PROXY_PROTOCOL = 'https';
|
||||
const config = getProxyEnvVars();
|
||||
expect(config.protocol).toBe('https');
|
||||
});
|
||||
|
||||
it('should read CCS_PROXY_AUTH_TOKEN', () => {
|
||||
process.env.CCS_PROXY_AUTH_TOKEN = 'my-secret-token';
|
||||
const config = getProxyEnvVars();
|
||||
expect(config.authToken).toBe('my-secret-token');
|
||||
});
|
||||
|
||||
it('should parse CCS_PROXY_FALLBACK_ENABLED as true', () => {
|
||||
process.env.CCS_PROXY_FALLBACK_ENABLED = '1';
|
||||
expect(getProxyEnvVars().fallbackEnabled).toBe(true);
|
||||
|
||||
process.env.CCS_PROXY_FALLBACK_ENABLED = 'true';
|
||||
expect(getProxyEnvVars().fallbackEnabled).toBe(true);
|
||||
|
||||
process.env.CCS_PROXY_FALLBACK_ENABLED = 'yes';
|
||||
expect(getProxyEnvVars().fallbackEnabled).toBe(true);
|
||||
});
|
||||
|
||||
it('should parse CCS_PROXY_FALLBACK_ENABLED as false', () => {
|
||||
process.env.CCS_PROXY_FALLBACK_ENABLED = '0';
|
||||
expect(getProxyEnvVars().fallbackEnabled).toBe(false);
|
||||
|
||||
process.env.CCS_PROXY_FALLBACK_ENABLED = 'false';
|
||||
expect(getProxyEnvVars().fallbackEnabled).toBe(false);
|
||||
|
||||
process.env.CCS_PROXY_FALLBACK_ENABLED = 'no';
|
||||
expect(getProxyEnvVars().fallbackEnabled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveProxyConfig', () => {
|
||||
const originalEnv = { ...process.env };
|
||||
|
||||
beforeEach(() => {
|
||||
delete process.env.CCS_PROXY_HOST;
|
||||
delete process.env.CCS_PROXY_PORT;
|
||||
delete process.env.CCS_PROXY_PROTOCOL;
|
||||
delete process.env.CCS_PROXY_AUTH_TOKEN;
|
||||
delete process.env.CCS_PROXY_FALLBACK_ENABLED;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
Object.keys(process.env).forEach((key) => {
|
||||
if (key.startsWith('CCS_PROXY_')) {
|
||||
delete process.env[key];
|
||||
}
|
||||
});
|
||||
Object.assign(process.env, originalEnv);
|
||||
});
|
||||
|
||||
it('should return local mode by default', () => {
|
||||
const { config } = resolveProxyConfig([]);
|
||||
expect(config.mode).toBe('local');
|
||||
expect(config.port).toBe(8317); // Default CLIProxy port
|
||||
expect(config.fallbackEnabled).toBe(true);
|
||||
});
|
||||
|
||||
it('should enable remote mode when --proxy-host is provided', () => {
|
||||
const { config } = resolveProxyConfig(['--proxy-host', '192.168.1.100']);
|
||||
expect(config.mode).toBe('remote');
|
||||
expect(config.host).toBe('192.168.1.100');
|
||||
});
|
||||
|
||||
it('should enable remote mode when CCS_PROXY_HOST env is set', () => {
|
||||
process.env.CCS_PROXY_HOST = 'remote.example.com';
|
||||
const { config } = resolveProxyConfig([]);
|
||||
expect(config.mode).toBe('remote');
|
||||
expect(config.host).toBe('remote.example.com');
|
||||
});
|
||||
|
||||
it('should prioritize CLI flags over ENV vars', () => {
|
||||
process.env.CCS_PROXY_HOST = 'env-host';
|
||||
process.env.CCS_PROXY_PORT = '9000';
|
||||
const { config } = resolveProxyConfig(['--proxy-host', 'cli-host', '--proxy-port', '8080']);
|
||||
expect(config.host).toBe('cli-host');
|
||||
expect(config.port).toBe(8080);
|
||||
});
|
||||
|
||||
it('should force local mode with --local-proxy', () => {
|
||||
process.env.CCS_PROXY_HOST = 'remote.example.com';
|
||||
const { config } = resolveProxyConfig(['--local-proxy']);
|
||||
expect(config.mode).toBe('local');
|
||||
expect(config.forceLocal).toBe(true);
|
||||
});
|
||||
|
||||
it('should set remoteOnly and disable fallback with --remote-only', () => {
|
||||
const { config } = resolveProxyConfig(['--proxy-host', 'remote', '--remote-only']);
|
||||
expect(config.remoteOnly).toBe(true);
|
||||
expect(config.fallbackEnabled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasProxyFlags', () => {
|
||||
it('should return true when proxy flags are present', () => {
|
||||
expect(hasProxyFlags(['--proxy-host', 'localhost'])).toBe(true);
|
||||
expect(hasProxyFlags(['--proxy-port', '8080'])).toBe(true);
|
||||
expect(hasProxyFlags(['--local-proxy'])).toBe(true);
|
||||
expect(hasProxyFlags(['--remote-only'])).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when no proxy flags are present', () => {
|
||||
expect(hasProxyFlags([])).toBe(false);
|
||||
expect(hasProxyFlags(['--verbose', '--help'])).toBe(false);
|
||||
expect(hasProxyFlags(['gemini', 'some-task'])).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* Unit tests for remote-proxy-client module
|
||||
*/
|
||||
import { describe, it, expect } from 'bun:test';
|
||||
import type { RemoteProxyClientConfig, RemoteProxyStatus } from '../../../src/cliproxy/remote-proxy-client';
|
||||
|
||||
// We test the module's type exports and error handling logic
|
||||
// Actual HTTP calls are not mocked in this unit test - use integration tests for that
|
||||
|
||||
describe('remote-proxy-client', () => {
|
||||
describe('type exports', () => {
|
||||
it('should export RemoteProxyClientConfig interface', () => {
|
||||
// Type-level test - ensure the interface shape is correct
|
||||
const config: RemoteProxyClientConfig = {
|
||||
host: 'localhost',
|
||||
port: 8317,
|
||||
protocol: 'http',
|
||||
authToken: 'test-token',
|
||||
timeout: 2000,
|
||||
allowSelfSigned: false,
|
||||
};
|
||||
expect(config.host).toBe('localhost');
|
||||
expect(config.port).toBe(8317);
|
||||
expect(config.protocol).toBe('http');
|
||||
});
|
||||
|
||||
it('should export RemoteProxyStatus interface', () => {
|
||||
// Success case
|
||||
const successStatus: RemoteProxyStatus = {
|
||||
reachable: true,
|
||||
latencyMs: 50,
|
||||
};
|
||||
expect(successStatus.reachable).toBe(true);
|
||||
expect(successStatus.latencyMs).toBe(50);
|
||||
|
||||
// Error case
|
||||
const errorStatus: RemoteProxyStatus = {
|
||||
reachable: false,
|
||||
error: 'Connection refused',
|
||||
errorCode: 'CONNECTION_REFUSED',
|
||||
};
|
||||
expect(errorStatus.reachable).toBe(false);
|
||||
expect(errorStatus.error).toBe('Connection refused');
|
||||
expect(errorStatus.errorCode).toBe('CONNECTION_REFUSED');
|
||||
});
|
||||
});
|
||||
|
||||
describe('RemoteProxyErrorCode', () => {
|
||||
it('should define expected error codes', () => {
|
||||
const validCodes = ['CONNECTION_REFUSED', 'TIMEOUT', 'AUTH_FAILED', 'UNKNOWN'];
|
||||
|
||||
// Type-level test - ensure error codes can be used
|
||||
const status1: RemoteProxyStatus = { reachable: false, errorCode: 'CONNECTION_REFUSED' };
|
||||
const status2: RemoteProxyStatus = { reachable: false, errorCode: 'TIMEOUT' };
|
||||
const status3: RemoteProxyStatus = { reachable: false, errorCode: 'AUTH_FAILED' };
|
||||
const status4: RemoteProxyStatus = { reachable: false, errorCode: 'UNKNOWN' };
|
||||
|
||||
expect(validCodes).toContain(status1.errorCode);
|
||||
expect(validCodes).toContain(status2.errorCode);
|
||||
expect(validCodes).toContain(status3.errorCode);
|
||||
expect(validCodes).toContain(status4.errorCode);
|
||||
});
|
||||
});
|
||||
|
||||
describe('config validation', () => {
|
||||
it('should require host and port', () => {
|
||||
const minimalConfig: RemoteProxyClientConfig = {
|
||||
host: '127.0.0.1',
|
||||
port: 8317,
|
||||
protocol: 'http',
|
||||
};
|
||||
expect(minimalConfig.host).toBeDefined();
|
||||
expect(minimalConfig.port).toBeDefined();
|
||||
expect(minimalConfig.protocol).toBeDefined();
|
||||
});
|
||||
|
||||
it('should allow optional fields', () => {
|
||||
const config: RemoteProxyClientConfig = {
|
||||
host: '127.0.0.1',
|
||||
port: 8317,
|
||||
protocol: 'https',
|
||||
authToken: 'secret',
|
||||
timeout: 5000,
|
||||
allowSelfSigned: true,
|
||||
};
|
||||
expect(config.authToken).toBe('secret');
|
||||
expect(config.timeout).toBe(5000);
|
||||
expect(config.allowSelfSigned).toBe(true);
|
||||
});
|
||||
|
||||
it('should accept http and https protocols', () => {
|
||||
const httpConfig: RemoteProxyClientConfig = {
|
||||
host: 'localhost',
|
||||
port: 8317,
|
||||
protocol: 'http',
|
||||
};
|
||||
const httpsConfig: RemoteProxyClientConfig = {
|
||||
host: 'localhost',
|
||||
port: 8317,
|
||||
protocol: 'https',
|
||||
};
|
||||
expect(httpConfig.protocol).toBe('http');
|
||||
expect(httpsConfig.protocol).toBe('https');
|
||||
});
|
||||
});
|
||||
|
||||
describe('health check URL construction', () => {
|
||||
it('should construct correct health check URL pattern', () => {
|
||||
const config: RemoteProxyClientConfig = {
|
||||
host: '192.168.1.100',
|
||||
port: 8317,
|
||||
protocol: 'http',
|
||||
};
|
||||
const expectedUrl = `${config.protocol}://${config.host}:${config.port}/health`;
|
||||
expect(expectedUrl).toBe('http://192.168.1.100:8317/health');
|
||||
});
|
||||
|
||||
it('should construct HTTPS URL when protocol is https', () => {
|
||||
const config: RemoteProxyClientConfig = {
|
||||
host: 'secure.example.com',
|
||||
port: 443,
|
||||
protocol: 'https',
|
||||
};
|
||||
const expectedUrl = `${config.protocol}://${config.host}:${config.port}/health`;
|
||||
expect(expectedUrl).toBe('https://secure.example.com:443/health');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -123,7 +123,8 @@ export function ProxyStatusWidget() {
|
||||
size="sm"
|
||||
className={cn(
|
||||
'h-7 text-xs gap-1 flex-1',
|
||||
hasUpdate && 'bg-amber-600 hover:bg-amber-700 text-white'
|
||||
hasUpdate &&
|
||||
'bg-sidebar-accent hover:bg-sidebar-accent/90 text-sidebar-accent-foreground'
|
||||
)}
|
||||
onClick={handleRestart}
|
||||
disabled={isActioning}
|
||||
|
||||
@@ -154,6 +154,42 @@ export interface CreatePreset {
|
||||
haiku?: string;
|
||||
}
|
||||
|
||||
/** Remote proxy status from health check */
|
||||
export interface RemoteProxyStatus {
|
||||
reachable: boolean;
|
||||
latencyMs?: number;
|
||||
error?: string;
|
||||
errorCode?: 'CONNECTION_REFUSED' | 'TIMEOUT' | 'AUTH_FAILED' | 'UNKNOWN';
|
||||
}
|
||||
|
||||
/** Remote proxy configuration */
|
||||
export interface ProxyRemoteConfig {
|
||||
enabled: boolean;
|
||||
host: string;
|
||||
port: number;
|
||||
protocol: 'http' | 'https';
|
||||
auth_token: string;
|
||||
}
|
||||
|
||||
/** Fallback configuration */
|
||||
export interface ProxyFallbackConfig {
|
||||
enabled: boolean;
|
||||
auto_start: boolean;
|
||||
}
|
||||
|
||||
/** Local proxy configuration */
|
||||
export interface ProxyLocalConfig {
|
||||
port: number;
|
||||
auto_start: boolean;
|
||||
}
|
||||
|
||||
/** CLIProxy server configuration */
|
||||
export interface CliproxyServerConfig {
|
||||
remote: ProxyRemoteConfig;
|
||||
fallback: ProxyFallbackConfig;
|
||||
local: ProxyLocalConfig;
|
||||
}
|
||||
|
||||
/** CLIProxy process status from session tracker */
|
||||
export interface ProxyProcessStatus {
|
||||
running: boolean;
|
||||
@@ -353,4 +389,27 @@ export const api = {
|
||||
method: 'DELETE',
|
||||
}),
|
||||
},
|
||||
/** CLIProxy server configuration API */
|
||||
cliproxyServer: {
|
||||
/** Get cliproxy server configuration */
|
||||
get: () => request<CliproxyServerConfig>('/cliproxy-server'),
|
||||
/** Update cliproxy server configuration */
|
||||
update: (config: Partial<CliproxyServerConfig>) =>
|
||||
request<CliproxyServerConfig>('/cliproxy-server', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(config),
|
||||
}),
|
||||
/** Test remote proxy connection */
|
||||
test: (params: {
|
||||
host: string;
|
||||
port: number;
|
||||
protocol: 'http' | 'https';
|
||||
authToken?: string;
|
||||
allowSelfSigned?: boolean;
|
||||
}) =>
|
||||
request<RemoteProxyStatus>('/cliproxy-server/test', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(params),
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
+537
-6
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Settings Page - WebSearch & Global Env Configuration
|
||||
* Supports Gemini CLI and Grok CLI providers + Global Environment Variables
|
||||
* Settings Page - WebSearch, Global Env & Proxy Configuration
|
||||
* Supports Gemini CLI and Grok CLI providers + Global Environment Variables + Proxy Settings
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
@@ -12,6 +12,13 @@ import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
Globe,
|
||||
RefreshCw,
|
||||
@@ -28,8 +35,15 @@ import {
|
||||
Settings2,
|
||||
Plus,
|
||||
Trash2,
|
||||
Server,
|
||||
Laptop,
|
||||
Cloud,
|
||||
Wifi,
|
||||
WifiOff,
|
||||
} from 'lucide-react';
|
||||
import { CodeEditor } from '@/components/code-editor';
|
||||
import { api } from '@/lib/api-client';
|
||||
import type { CliproxyServerConfig, RemoteProxyStatus } from '@/lib/api-client';
|
||||
|
||||
interface ProviderConfig {
|
||||
enabled?: boolean;
|
||||
@@ -71,8 +85,10 @@ interface GlobalEnvConfig {
|
||||
|
||||
export function SettingsPage() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const initialTab = searchParams.get('tab') === 'globalenv' ? 'globalenv' : 'websearch';
|
||||
const [activeTab, setActiveTab] = useState<'websearch' | 'globalenv'>(initialTab);
|
||||
const tabParam = searchParams.get('tab');
|
||||
const initialTab =
|
||||
tabParam === 'globalenv' ? 'globalenv' : tabParam === 'proxy' ? 'proxy' : 'websearch';
|
||||
const [activeTab, setActiveTab] = useState<'websearch' | 'globalenv' | 'proxy'>(initialTab);
|
||||
const [config, setConfig] = useState<WebSearchConfig | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
@@ -100,6 +116,14 @@ export function SettingsPage() {
|
||||
// New env var inputs
|
||||
const [newEnvKey, setNewEnvKey] = useState('');
|
||||
const [newEnvValue, setNewEnvValue] = useState('');
|
||||
// Proxy state
|
||||
const [proxyConfig, setCliproxyServerConfig] = useState<CliproxyServerConfig | null>(null);
|
||||
const [proxyLoading, setProxyLoading] = useState(true);
|
||||
const [proxySaving, setProxySaving] = useState(false);
|
||||
const [proxyError, setProxyError] = useState<string | null>(null);
|
||||
const [proxySuccess, setProxySuccess] = useState(false);
|
||||
const [testResult, setTestResult] = useState<RemoteProxyStatus | null>(null);
|
||||
const [testing, setTesting] = useState(false);
|
||||
|
||||
// Load config and status on mount
|
||||
useEffect(() => {
|
||||
@@ -107,6 +131,7 @@ export function SettingsPage() {
|
||||
fetchStatus();
|
||||
fetchRawConfig();
|
||||
fetchGlobalEnvConfig();
|
||||
fetchCliproxyServerConfig();
|
||||
}, []);
|
||||
|
||||
// Sync local model inputs when config changes
|
||||
@@ -179,6 +204,19 @@ export function SettingsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const fetchCliproxyServerConfig = async () => {
|
||||
try {
|
||||
setProxyLoading(true);
|
||||
setProxyError(null);
|
||||
const data = await api.cliproxyServer.get();
|
||||
setCliproxyServerConfig(data);
|
||||
} catch (err) {
|
||||
setProxyError((err as Error).message);
|
||||
} finally {
|
||||
setProxyLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const copyToClipboard = async () => {
|
||||
if (!rawConfig) return;
|
||||
try {
|
||||
@@ -387,6 +425,68 @@ export function SettingsPage() {
|
||||
saveGlobalEnvConfig({ env: newEnv });
|
||||
};
|
||||
|
||||
// Proxy functions
|
||||
const saveCliproxyServerConfig = async (updates: Partial<CliproxyServerConfig>) => {
|
||||
if (!proxyConfig) return;
|
||||
|
||||
// Optimistic update
|
||||
const optimisticConfig = {
|
||||
remote: { ...proxyConfig.remote, ...updates.remote },
|
||||
fallback: { ...proxyConfig.fallback, ...updates.fallback },
|
||||
local: { ...proxyConfig.local, ...updates.local },
|
||||
};
|
||||
setCliproxyServerConfig(optimisticConfig);
|
||||
setTestResult(null); // Clear previous test result on config change
|
||||
|
||||
try {
|
||||
setProxySaving(true);
|
||||
setProxyError(null);
|
||||
|
||||
const data = await api.cliproxyServer.update(updates);
|
||||
setCliproxyServerConfig(data);
|
||||
setProxySuccess(true);
|
||||
setTimeout(() => setProxySuccess(false), 1500);
|
||||
// Silently refresh raw config
|
||||
fetch('/api/config/raw')
|
||||
.then((r) => (r.ok ? r.text() : null))
|
||||
.then((text) => text && setRawConfig(text))
|
||||
.catch(() => {});
|
||||
} catch (err) {
|
||||
setCliproxyServerConfig(proxyConfig);
|
||||
setProxyError((err as Error).message);
|
||||
} finally {
|
||||
setProxySaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTestConnection = async () => {
|
||||
if (!proxyConfig) return;
|
||||
|
||||
const { host, port, protocol, auth_token } = proxyConfig.remote;
|
||||
if (!host || !port) {
|
||||
setProxyError('Host and port are required');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setTesting(true);
|
||||
setProxyError(null);
|
||||
setTestResult(null);
|
||||
|
||||
const result = await api.cliproxyServer.test({
|
||||
host,
|
||||
port,
|
||||
protocol,
|
||||
authToken: auth_token || undefined,
|
||||
});
|
||||
setTestResult(result);
|
||||
} catch (err) {
|
||||
setProxyError((err as Error).message);
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="h-[calc(100vh-100px)] flex items-center justify-center">
|
||||
@@ -408,7 +508,7 @@ export function SettingsPage() {
|
||||
<div className="p-5 border-b bg-background">
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={(v) => setActiveTab(v as 'websearch' | 'globalenv')}
|
||||
onValueChange={(v) => setActiveTab(v as 'websearch' | 'globalenv' | 'proxy')}
|
||||
>
|
||||
<TabsList className="w-full">
|
||||
<TabsTrigger value="websearch" className="flex-1 gap-2">
|
||||
@@ -419,6 +519,10 @@ export function SettingsPage() {
|
||||
<Settings2 className="w-4 h-4" />
|
||||
Global Env
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="proxy" className="flex-1 gap-2">
|
||||
<Server className="w-4 h-4" />
|
||||
Proxy
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
@@ -455,7 +559,7 @@ export function SettingsPage() {
|
||||
fetchRawConfig={fetchRawConfig}
|
||||
loading={loading}
|
||||
/>
|
||||
) : (
|
||||
) : activeTab === 'globalenv' ? (
|
||||
<GlobalEnvContent
|
||||
config={globalEnvConfig}
|
||||
loading={globalEnvLoading}
|
||||
@@ -473,6 +577,20 @@ export function SettingsPage() {
|
||||
fetchGlobalEnvConfig={fetchGlobalEnvConfig}
|
||||
fetchRawConfig={fetchRawConfig}
|
||||
/>
|
||||
) : (
|
||||
<ProxyContent
|
||||
config={proxyConfig}
|
||||
loading={proxyLoading}
|
||||
saving={proxySaving}
|
||||
error={proxyError}
|
||||
success={proxySuccess}
|
||||
testResult={testResult}
|
||||
testing={testing}
|
||||
saveCliproxyServerConfig={saveCliproxyServerConfig}
|
||||
handleTestConnection={handleTestConnection}
|
||||
fetchCliproxyServerConfig={fetchCliproxyServerConfig}
|
||||
fetchRawConfig={fetchRawConfig}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Panel>
|
||||
@@ -1163,3 +1281,416 @@ function GlobalEnvContent({
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Proxy Tab Content Component
|
||||
interface ProxyContentProps {
|
||||
config: CliproxyServerConfig | null;
|
||||
loading: boolean;
|
||||
saving: boolean;
|
||||
error: string | null;
|
||||
success: boolean;
|
||||
testResult: RemoteProxyStatus | null;
|
||||
testing: boolean;
|
||||
saveCliproxyServerConfig: (updates: Partial<CliproxyServerConfig>) => void;
|
||||
handleTestConnection: () => void;
|
||||
fetchCliproxyServerConfig: () => void;
|
||||
fetchRawConfig: () => void;
|
||||
}
|
||||
|
||||
function ProxyContent({
|
||||
config,
|
||||
loading,
|
||||
saving,
|
||||
error,
|
||||
success,
|
||||
testResult,
|
||||
testing,
|
||||
saveCliproxyServerConfig,
|
||||
handleTestConnection,
|
||||
fetchCliproxyServerConfig,
|
||||
fetchRawConfig,
|
||||
}: ProxyContentProps) {
|
||||
// Memoized default config to avoid recreation
|
||||
const defaultRemote = {
|
||||
enabled: false,
|
||||
host: '',
|
||||
port: 8317,
|
||||
protocol: 'http' as const,
|
||||
auth_token: '',
|
||||
};
|
||||
const defaultFallback = { enabled: true, auto_start: true };
|
||||
const defaultLocal = { port: 8317, auto_start: true };
|
||||
|
||||
// Sync local state with config (using refs to avoid lint warnings)
|
||||
const hostInput = config?.remote.host ?? '';
|
||||
const portInput = (config?.remote.port ?? 8317).toString();
|
||||
const authTokenInput = config?.remote.auth_token ?? '';
|
||||
const localPortInput = (config?.local.port ?? 8317).toString();
|
||||
|
||||
// Track edited values separately
|
||||
const [editedHost, setEditedHost] = useState<string | null>(null);
|
||||
const [editedPort, setEditedPort] = useState<string | null>(null);
|
||||
const [editedAuthToken, setEditedAuthToken] = useState<string | null>(null);
|
||||
const [editedLocalPort, setEditedLocalPort] = useState<string | null>(null);
|
||||
|
||||
// Get display values (edited or from config)
|
||||
const displayHost = editedHost ?? hostInput;
|
||||
const displayPort = editedPort ?? portInput;
|
||||
const displayAuthToken = editedAuthToken ?? authTokenInput;
|
||||
const displayLocalPort = editedLocalPort ?? localPortInput;
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<div className="flex items-center gap-3 text-muted-foreground">
|
||||
<RefreshCw className="w-5 h-5 animate-spin" />
|
||||
<span>Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isRemoteMode = config?.remote.enabled ?? false;
|
||||
const remoteConfig = config?.remote ?? defaultRemote;
|
||||
const fallbackConfig = config?.fallback ?? defaultFallback;
|
||||
const localConfig = config?.local ?? defaultLocal;
|
||||
|
||||
// Save functions for blur events
|
||||
const saveHost = () => {
|
||||
const value = editedHost ?? displayHost;
|
||||
if (value !== config?.remote.host) {
|
||||
saveCliproxyServerConfig({ remote: { ...remoteConfig, host: value } });
|
||||
}
|
||||
setEditedHost(null);
|
||||
};
|
||||
|
||||
const savePort = () => {
|
||||
const port = parseInt(editedPort ?? displayPort, 10);
|
||||
if (!isNaN(port) && port !== config?.remote.port) {
|
||||
saveCliproxyServerConfig({ remote: { ...remoteConfig, port } });
|
||||
}
|
||||
setEditedPort(null);
|
||||
};
|
||||
|
||||
const saveAuthToken = () => {
|
||||
const value = editedAuthToken ?? displayAuthToken;
|
||||
if (value !== config?.remote.auth_token) {
|
||||
saveCliproxyServerConfig({ remote: { ...remoteConfig, auth_token: value } });
|
||||
}
|
||||
setEditedAuthToken(null);
|
||||
};
|
||||
|
||||
const saveLocalPort = () => {
|
||||
const port = parseInt(editedLocalPort ?? displayLocalPort, 10);
|
||||
if (!isNaN(port) && port !== config?.local.port) {
|
||||
saveCliproxyServerConfig({ local: { ...localConfig, port } });
|
||||
}
|
||||
setEditedLocalPort(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Toast-style alerts */}
|
||||
<div
|
||||
className={`absolute left-5 right-5 top-20 z-10 transition-all duration-200 ease-out ${
|
||||
error || success
|
||||
? 'opacity-100 translate-y-0'
|
||||
: 'opacity-0 -translate-y-2 pointer-events-none'
|
||||
}`}
|
||||
>
|
||||
{error && (
|
||||
<Alert variant="destructive" className="py-2 shadow-lg">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{success && (
|
||||
<div className="flex items-center gap-2 px-3 py-2 rounded-md border border-green-200 bg-green-50 text-green-700 shadow-lg dark:border-green-900/50 dark:bg-green-900/90 dark:text-green-300">
|
||||
<CheckCircle2 className="h-4 w-4 shrink-0" />
|
||||
<span className="text-sm font-medium">Saved</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Scrollable Content */}
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="p-5 space-y-6">
|
||||
{/* Description */}
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Configure local or remote CLIProxyAPI connection for proxy-based profiles
|
||||
</p>
|
||||
|
||||
{/* Mode Toggle - Card based selection */}
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-base font-medium">Connection Mode</h3>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{/* Local Mode Card */}
|
||||
<button
|
||||
onClick={() =>
|
||||
saveCliproxyServerConfig({ remote: { ...remoteConfig, enabled: false } })
|
||||
}
|
||||
disabled={saving}
|
||||
className={`p-4 rounded-lg border-2 text-left transition-all ${
|
||||
!isRemoteMode
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'border-border hover:border-muted-foreground/50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<Laptop
|
||||
className={`w-5 h-5 ${!isRemoteMode ? 'text-primary' : 'text-muted-foreground'}`}
|
||||
/>
|
||||
<span className="font-medium">Local</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Run CLIProxyAPI binary on this machine
|
||||
</p>
|
||||
</button>
|
||||
|
||||
{/* Remote Mode Card */}
|
||||
<button
|
||||
onClick={() =>
|
||||
saveCliproxyServerConfig({ remote: { ...remoteConfig, enabled: true } })
|
||||
}
|
||||
disabled={saving}
|
||||
className={`p-4 rounded-lg border-2 text-left transition-all ${
|
||||
isRemoteMode
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'border-border hover:border-muted-foreground/50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<Cloud
|
||||
className={`w-5 h-5 ${isRemoteMode ? 'text-primary' : 'text-muted-foreground'}`}
|
||||
/>
|
||||
<span className="font-medium">Remote</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Connect to a remote CLIProxyAPI server
|
||||
</p>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Remote Settings - Show when remote mode is enabled */}
|
||||
{isRemoteMode && (
|
||||
<div className="space-y-4 p-4 rounded-lg border bg-muted/30">
|
||||
<h4 className="text-sm font-medium flex items-center gap-2">
|
||||
<Cloud className="w-4 h-4" />
|
||||
Remote Server Configuration
|
||||
</h4>
|
||||
|
||||
{/* Host */}
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm text-muted-foreground">Host</label>
|
||||
<Input
|
||||
value={displayHost}
|
||||
onChange={(e) => setEditedHost(e.target.value)}
|
||||
onBlur={saveHost}
|
||||
placeholder="192.168.1.100 or proxy.example.com"
|
||||
className="font-mono"
|
||||
disabled={saving}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Port and Protocol */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm text-muted-foreground">Port</label>
|
||||
<Input
|
||||
type="number"
|
||||
value={displayPort}
|
||||
onChange={(e) => setEditedPort(e.target.value)}
|
||||
onBlur={savePort}
|
||||
placeholder="8317"
|
||||
className="font-mono"
|
||||
disabled={saving}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm text-muted-foreground">Protocol</label>
|
||||
<Select
|
||||
value={config?.remote.protocol || 'http'}
|
||||
onValueChange={(value: 'http' | 'https') =>
|
||||
saveCliproxyServerConfig({ remote: { ...remoteConfig, protocol: value } })
|
||||
}
|
||||
disabled={saving}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="http">HTTP</SelectItem>
|
||||
<SelectItem value="https">HTTPS</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Auth Token */}
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm text-muted-foreground">Auth Token (optional)</label>
|
||||
<Input
|
||||
type="password"
|
||||
value={displayAuthToken}
|
||||
onChange={(e) => setEditedAuthToken(e.target.value)}
|
||||
onBlur={saveAuthToken}
|
||||
placeholder="Bearer token for authentication"
|
||||
className="font-mono"
|
||||
disabled={saving}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Test Connection */}
|
||||
<div className="space-y-3 pt-2">
|
||||
<Button
|
||||
onClick={handleTestConnection}
|
||||
disabled={testing || !displayHost || !displayPort}
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
>
|
||||
{testing ? (
|
||||
<>
|
||||
<RefreshCw className="w-4 h-4 mr-2 animate-spin" />
|
||||
Testing...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Wifi className="w-4 h-4 mr-2" />
|
||||
Test Connection
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{/* Test Result */}
|
||||
{testResult && (
|
||||
<div
|
||||
className={`p-3 rounded-md ${
|
||||
testResult.reachable
|
||||
? 'bg-green-50 border border-green-200 dark:bg-green-900/20 dark:border-green-900/50'
|
||||
: 'bg-red-50 border border-red-200 dark:bg-red-900/20 dark:border-red-900/50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{testResult.reachable ? (
|
||||
<>
|
||||
<CheckCircle2 className="w-4 h-4 text-green-600 dark:text-green-400" />
|
||||
<span className="text-sm font-medium text-green-700 dark:text-green-300">
|
||||
Connected ({testResult.latencyMs}ms)
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<WifiOff className="w-4 h-4 text-red-600 dark:text-red-400" />
|
||||
<span className="text-sm font-medium text-red-700 dark:text-red-300">
|
||||
{testResult.error || 'Connection failed'}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Fallback Settings */}
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-base font-medium">Fallback Settings</h3>
|
||||
<div className="space-y-3 p-4 rounded-lg border bg-muted/30">
|
||||
{/* Enable Fallback */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-medium text-sm">Enable fallback to local</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Use local proxy if remote is unreachable
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={config?.fallback.enabled ?? true}
|
||||
onCheckedChange={(checked) =>
|
||||
saveCliproxyServerConfig({ fallback: { ...fallbackConfig, enabled: checked } })
|
||||
}
|
||||
disabled={saving || !isRemoteMode}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Auto-start on fallback */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-medium text-sm">Auto-start local proxy</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Automatically start local proxy on fallback
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={config?.fallback.auto_start ?? false}
|
||||
onCheckedChange={(checked) =>
|
||||
saveCliproxyServerConfig({
|
||||
fallback: { ...fallbackConfig, auto_start: checked },
|
||||
})
|
||||
}
|
||||
disabled={saving || !isRemoteMode || !config?.fallback.enabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Local Proxy Settings */}
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-base font-medium">Local Proxy</h3>
|
||||
<div className="space-y-3 p-4 rounded-lg border bg-muted/30">
|
||||
{/* Port */}
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm text-muted-foreground">Port</label>
|
||||
<Input
|
||||
type="number"
|
||||
value={displayLocalPort}
|
||||
onChange={(e) => setEditedLocalPort(e.target.value)}
|
||||
onBlur={saveLocalPort}
|
||||
placeholder="8317"
|
||||
className="font-mono max-w-32"
|
||||
disabled={saving}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Auto-start */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-medium text-sm">Auto-start</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Start local proxy automatically when needed
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={config?.local.auto_start ?? true}
|
||||
onCheckedChange={(checked) =>
|
||||
saveCliproxyServerConfig({ local: { ...localConfig, auto_start: checked } })
|
||||
}
|
||||
disabled={saving}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="p-4 border-t bg-background">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
fetchCliproxyServerConfig();
|
||||
fetchRawConfig();
|
||||
}}
|
||||
disabled={loading || saving}
|
||||
className="w-full"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user