diff --git a/.github/workflows/dev-release.yml b/.github/workflows/dev-release.yml index ea10d23d..33168072 100644 --- a/.github/workflows/dev-release.yml +++ b/.github/workflows/dev-release.yml @@ -82,22 +82,31 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - # Get version from package.json (updated by semantic-release) + # Get version from package.json VERSION=$(jq -r '.version' package.json) - # Find commits since last release - LAST_RELEASE=$(git log --oneline --grep="chore(release):" -n 2 | tail -1 | cut -d' ' -f1) - RANGE="${LAST_RELEASE:-HEAD~20}..HEAD" + # Get commits ONLY since last dev tag (not all release commits) + # This prevents re-tagging issues from older releases + LAST_DEV_TAG=$(git tag -l "v*-dev.*" --sort=-v:refname | head -1 || echo "") + + if [ -n "$LAST_DEV_TAG" ]; then + # Commits between last dev tag and current (excluding release commit) + RANGE="${LAST_DEV_TAG}..HEAD~1" + else + # First dev release - check commits since last stable tag + STABLE_TAG=$(git tag -l "v[0-9]*.[0-9]*.[0-9]" --merged origin/main --sort=-v:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | head -1 || echo "") + RANGE="${STABLE_TAG:-HEAD~10}..HEAD~1" + fi echo "Checking commits in range: $RANGE" - # Extract issue numbers - ISSUES=$(git log $RANGE --pretty=format:"%s %b" | \ + # Extract issue numbers from commit messages + ISSUES=$(git log $RANGE --pretty=format:"%s %b" 2>/dev/null | \ grep -oE "(Fixes|Closes|Resolves|Refs?) #[0-9]+" | \ grep -oE "#[0-9]+" | sort -u || true) if [[ -z "$ISSUES" ]]; then - echo "No linked issues found" + echo "No linked issues found in range" exit 0 fi @@ -110,13 +119,16 @@ jobs: for ISSUE in $ISSUES; do NUM=${ISSUE#\#} - # Skip if already tagged - if gh issue view "$NUM" --repo "${{ github.repository }}" --json labels --jq '.labels[].name' | grep -q "released-dev"; then - echo "Issue #$NUM already tagged, skipping" + # Skip if already has ANY release label (prevents duplicate comments) + RELEASE_LABELS=$(gh issue view "$NUM" --repo "${{ github.repository }}" --json labels --jq '[.labels[].name | select(startswith("released"))] | length' 2>/dev/null || echo "0") + if [[ "$RELEASE_LABELS" -gt 0 ]]; then + echo "Issue #$NUM already released, skipping" continue fi echo "Tagging issue #$NUM" - gh issue comment "$NUM" --repo "${{ github.repository }}" --body "[i] Available in \`$VERSION\`. Update: \`ccs update --dev\`" || true - gh issue edit "$NUM" --add-label "released-dev" --repo "${{ github.repository }}" || true + # Remove pending-release (transition), add released-dev + gh issue edit "$NUM" --remove-label "pending-release" --add-label "released-dev" --repo "${{ github.repository }}" 2>/dev/null || \ + gh issue edit "$NUM" --add-label "released-dev" --repo "${{ github.repository }}" || true + gh issue comment "$NUM" --repo "${{ github.repository }}" --body "[bot] Available in \`$VERSION\`. Install: \`ccs update --dev\`" || true done diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 09249950..0a31aa98 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -73,3 +73,27 @@ jobs: exit 0 fi node scripts/send-discord-release.cjs production "$DISCORD_WEBHOOK_URL" + + - name: Cleanup stale labels on released issues + if: success() && steps.release.outputs.released == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + # semantic-release already adds "released" label via .releaserc.cjs + # This step removes transitional labels from issues now in stable + + # Find issues with both "released" and "released-dev" labels + ISSUES=$(gh issue list --label "released" --label "released-dev" --state all --json number --jq '.[].number' 2>/dev/null || echo "") + + for NUM in $ISSUES; do + echo "Cleaning up labels on issue #$NUM" + gh issue edit "$NUM" --remove-label "released-dev" --remove-label "pending-release" --repo "${{ github.repository }}" 2>/dev/null || true + done + + # Also clean pending-release from any issues with released label + PENDING=$(gh issue list --label "released" --label "pending-release" --state all --json number --jq '.[].number' 2>/dev/null || echo "") + + for NUM in $PENDING; do + echo "Removing pending-release from issue #$NUM" + gh issue edit "$NUM" --remove-label "pending-release" --repo "${{ github.repository }}" 2>/dev/null || true + done diff --git a/README.md b/README.md index 17ad794c..c1902b03 100644 --- a/README.md +++ b/README.md @@ -255,6 +255,37 @@ See [docs/websearch.md](./docs/websearch.md) for detailed configuration and trou
+## Remote CLIProxy + +CCS v7.x supports connecting to remote CLIProxyAPI instances, enabling: +- **Team sharing**: One CLIProxyAPI server for multiple developers +- **Cost optimization**: Centralized API key management +- **Network isolation**: Keep API credentials on a secure server + +### Quick Setup + +Configure via dashboard (**Settings > CLIProxy Server**) or CLI flags: + +```bash +ccs gemini --proxy-host 192.168.1.100 --proxy-port 8317 +ccs codex --proxy-host proxy.example.com --proxy-protocol https +``` + +### CLI Flags + +| Flag | Description | +|------|-------------| +| `--proxy-host` | Remote proxy hostname or IP | +| `--proxy-port` | Remote proxy port (default: 8317 for HTTP, 443 for HTTPS) | +| `--proxy-protocol` | `http` or `https` (default: http) | +| `--proxy-auth-token` | Bearer token for authentication | +| `--local-proxy` | Force local mode, ignore remote config | +| `--remote-only` | Fail if remote unreachable (no fallback) | + +See [Remote Proxy documentation](https://docs.ccs.kaitran.ca/features/remote-proxy) for detailed setup. + +
+ ## Documentation | Topic | Link | diff --git a/docs/code-standards.md b/docs/code-standards.md index 66a0bb51..b4389cbc 100644 --- a/docs/code-standards.md +++ b/docs/code-standards.md @@ -580,6 +580,26 @@ interface Config { ... } export interface Config { ... } ``` +### 5. Config Priority Pattern + +When resolving configuration from multiple sources, follow this priority order: + +```typescript +// proxy-config-resolver.ts pattern +// Priority: CLI flags > Environment variables > config.yaml > defaults + +const resolved = { + ...DEFAULT_CONFIG, // 4. Defaults (lowest) + ...yamlConfig, // 3. config.yaml + ...envConfig, // 2. Environment variables + ...cliFlags, // 1. CLI flags (highest) +}; +``` + +This pattern is used in: +- `src/cliproxy/proxy-config-resolver.ts` - Remote proxy config +- `src/config/unified-config-loader.ts` - Main config loading + --- ## Related Documentation diff --git a/docs/codebase-summary.md b/docs/codebase-summary.md index f71092d1..1f07e61d 100644 --- a/docs/codebase-summary.md +++ b/docs/codebase-summary.md @@ -2,7 +2,7 @@ Last Updated: 2025-12-21 -Comprehensive overview of the modularized CCS codebase structure following the Phase 9 modularization effort (Settings, Analytics, Auth Monitor splits + Test Infrastructure). +Comprehensive overview of the modularized CCS codebase structure following the Phase 9 modularization effort (Settings, Analytics, Auth Monitor splits + Test Infrastructure) and v7.1 Remote CLIProxy feature. ## Repository Structure @@ -79,6 +79,9 @@ src/ │ ├── service-manager.ts # Background service │ ├── proxy-detector.ts # Running proxy detection │ ├── startup-lock.ts # Race condition prevention +│ ├── remote-proxy-client.ts # Remote proxy health checks (NEW v7.1) +│ ├── proxy-config-resolver.ts # CLI/env/config merging (NEW v7.1) +│ ├── types.ts # ResolvedProxyConfig for local/remote modes │ └── [more files...] │ ├── copilot/ # GitHub Copilot integration @@ -161,6 +164,7 @@ src/ | Auth | `auth/`, `cliproxy/auth/` | Authentication across providers | | Config | `config/`, `types/` | Configuration & type definitions | | Providers | `cliproxy/`, `copilot/`, `glmt/` | Provider integrations | +| Remote Proxy | `cliproxy/remote-*.ts`, `proxy-config-resolver.ts` | Remote CLIProxy support (v7.1) | | Services | `web-server/`, `api/` | HTTP server, API services | | Utilities | `utils/`, `management/` | Helpers, diagnostics | @@ -462,12 +466,14 @@ tests/ | Metric | Value | |--------|-------| -| Total Tests | 497 | -| Passing | 497 | -| Skipped | 2 | -| Failed | 0 | +| CLI Tests | 539 | +| UI Tests | 99 | +| Total Tests | 638 | +| Passing | 612 | +| Skipped | 6 | +| Failed | 0 (CLI), 26 (UI - jsdom setup) | | Coverage Threshold | 90% | -| Test Files | 29 | +| Test Files | 38 | --- diff --git a/docs/project-overview-pdr.md b/docs/project-overview-pdr.md index b022cf3c..86e36719 100644 --- a/docs/project-overview-pdr.md +++ b/docs/project-overview-pdr.md @@ -8,9 +8,9 @@ Last Updated: 2025-12-21 **Tagline**: The universal AI profile manager for Claude Code -**Description**: CLI wrapper enabling seamless switching between multiple Claude accounts and alternative AI providers (GLM, Gemini, Codex, OpenRouter) with a React-based dashboard for configuration management. +**Description**: CLI wrapper enabling seamless switching between multiple Claude accounts and alternative AI providers (GLM, Gemini, Codex, OpenRouter) with a React-based dashboard for configuration management. Supports both local and remote CLIProxyAPI instances. -**Current Version**: v7.x (OpenRouter integration added) +**Current Version**: v7.1.x (Remote CLIProxy routing, OpenRouter integration) --- @@ -90,6 +90,14 @@ CCS provides: - Support Gemini CLI, OpenCode, Grok providers - Graceful fallback chain +### FR-008: Remote CLIProxy Support +- Connect to remote CLIProxyAPI instances +- CLI flags for proxy configuration (--proxy-host, --proxy-port, etc.) +- Environment variable configuration (CCS_PROXY_HOST, etc.) +- Fallback to local proxy when remote unreachable +- Protocol-based default ports (443 for HTTPS, 8317 for HTTP) +- Dashboard UI for remote server configuration and testing + --- ## Non-Functional Requirements @@ -167,7 +175,7 @@ CCS provides: | Startup time | < 100ms | Achieved | | Dashboard load | < 2s | Achieved | | Error rate | < 1% | Achieved | -| Test coverage | > 90% | 90% (497 tests) | +| Test coverage | > 90% | 90% (539 CLI + 99 UI tests) | | File size compliance | 100% < 200 lines | 95% | --- @@ -191,7 +199,16 @@ CCS provides: - [x] Settings page modularization (20 files) - [x] Analytics page modularization (8 files) - [x] Auth monitor modularization (8 files) -- [x] Comprehensive test infrastructure (497 tests) +- [x] Comprehensive test infrastructure (539 CLI + 99 UI tests) + +### v7.1 Release (Complete) +- [x] Remote CLIProxy routing support +- [x] CLI flags for remote proxy (--proxy-host, --proxy-port, etc.) +- [x] Environment variables for proxy config (CCS_PROXY_*) +- [x] Dashboard remote proxy configuration UI +- [x] Connection testing with latency display +- [x] Fallback to local when remote unreachable +- [x] Protocol-based default ports (HTTPS:443, HTTP:8317) ### v8.0 Release (Planned - Q1 2026) - [ ] Multiple CLIProxyAPI instances (load balancing, failover) diff --git a/docs/project-roadmap.md b/docs/project-roadmap.md index eee5d08f..72f095eb 100644 --- a/docs/project-roadmap.md +++ b/docs/project-roadmap.md @@ -17,16 +17,18 @@ All major modularization work is complete. The codebase evolved from monolithic | 3 | CLIProxy | `src/cliproxy/` with auth/, binary/, services/ subdirs | | 4 | Utils/Errors | `src/utils/ui/`, `src/errors/`, `src/management/` | | 5 | UI Components | 5 monster files split into modular dirs (54+ modules) | -| 6 | Settings Page | `pages/settings/` (1,781→20 files) | -| 7 | Analytics Page | `pages/analytics/` (420→8 files) | -| 8 | Auth Monitor | `monitoring/auth-monitor/` (465→8 files) | -| 9 | Test Infrastructure | 99 UI tests + 497 CLI tests, 90% coverage | +| 6 | Settings Page | `pages/settings/` (1,781->20 files) | +| 7 | Analytics Page | `pages/analytics/` (420->8 files) | +| 8 | Auth Monitor | `monitoring/auth-monitor/` (465->8 files) | +| 9 | Test Infrastructure | 99 UI tests + 539 CLI tests, 90% coverage | +| 10 | Remote CLIProxy | `proxy-config-resolver.ts`, `remote-proxy-client.ts` | **Metrics Achieved**: -- Files >500 lines: 12 → 5 (-58%) -- UI files >200 lines: 28 → 8 (-71%) -- Barrel exports: 5 → 39 (+680%) -- Test coverage: 0% → 90% +- Files >500 lines: 12 -> 5 (-58%) +- UI files >200 lines: 28 -> 8 (-71%) +- Barrel exports: 5 -> 39 (+680%) +- Test coverage: 0% -> 90% +- Total tests: 638 (539 CLI + 99 UI) --- @@ -59,7 +61,7 @@ All major modularization work is complete. The codebase evolved from monolithic | Issue | Title | Type | Status | |-------|-------|------|--------| -| #142 | Configure with available CLIProxyAPI | enhancement | **IN PROGRESS** | +| #142 | Configure with available CLIProxyAPI | enhancement | **COMPLETE** (v7.1) | | #157 | Support for Kiro auth from CLIProxyAPIPlus | enhancement | - | | #123 | Add More Models | enhancement | Ongoing | | #114 | OpenCode Zen Free model + Auto Rotation API Key | enhancement | - | @@ -157,6 +159,7 @@ worktrees: | Milestone | Status | Target | |-----------|--------|--------| | Modularization (Phases 1-9) | COMPLETE | - | +| Remote CLIProxy Support (#142) | COMPLETE | v7.1 | | Critical Bug Fixes (#158, #155, #124) | PLANNED | Q1 2026 | | Multiple CLIProxyAPI Instances | PLANNED | Q1 2026 | | Git Worktree Support | PLANNED | Q1 2026 | diff --git a/docs/system-architecture.md b/docs/system-architecture.md index 632efb6d..91589e57 100644 --- a/docs/system-architecture.md +++ b/docs/system-architecture.md @@ -13,6 +13,8 @@ CCS is a CLI wrapper that enables seamless switching between multiple Claude acc 1. **CLI Application** (`src/`) - Node.js TypeScript CLI 2. **Dashboard UI** (`ui/`) - React web application served by Express +CCS v7.1 adds support for both **local** and **remote** CLIProxyAPI instances. + ``` +===========================================================================+ | CCS System | @@ -30,10 +32,10 @@ CCS is a CLI wrapper that enables seamless switching between multiple Claude acc | +------------------+ +-----------------+ | Gemini/etc) | | | | +----------------+ | | v | -| +-----------------+ | -| | CLIProxyAPI | | -| | (Binary) | | -| +-----------------+ | +| +---------------------+ | +| | CLIProxyAPI | | +| | (Local or Remote) | | +| +---------------------+ | | | +===========================================================================+ ``` @@ -350,6 +352,65 @@ CCS is a CLI wrapper that enables seamless switching between multiple Claude acc +------------------+ ``` +### Remote CLIProxy Flow (v7.1) + +``` ++===========================================================================+ +| Remote CLIProxy Architecture | ++===========================================================================+ + + Config Resolution (proxy-config-resolver.ts) + | + +---> Priority: CLI flags > ENV vars > config.yaml > defaults + | + v + +------------------+ + | ResolvedProxyConfig | + | mode: local|remote | + +------------------+ + | + +---> [mode = local] ---> Spawn local CLIProxyAPI binary + | | + | v + | localhost:8317 + | + +---> [mode = remote] ---> Connect to remote server + | + v + +------------------+ + | Health Check | remote-proxy-client.ts + | /v1/models | 2s timeout + +------------------+ + | + +---> [reachable] ---> Use remote + | | + | v + | protocol://host:port + | + +---> [unreachable] ---> Fallback decision + | + +-----------------------------+ + | + +---> [fallbackEnabled] ---> Start local + | + +---> [remoteOnly] ---> Fail with error + + CLI Flags: + --proxy-host Remote hostname/IP + --proxy-port Port (default: 8317 HTTP, 443 HTTPS) + --proxy-protocol http or https + --proxy-auth-token Bearer authentication + --local-proxy Force local mode + --remote-only Fail if remote unreachable + + Environment Variables: + CCS_PROXY_HOST Remote hostname + CCS_PROXY_PORT Remote port + CCS_PROXY_PROTOCOL Protocol (http/https) + CCS_PROXY_AUTH_TOKEN Auth token + CCS_PROXY_FALLBACK_ENABLED Enable fallback (true/false) +``` + --- ## Configuration Architecture diff --git a/package.json b/package.json index 53885ead..5e5b1d88 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.1.1", + "version": "7.1.1-dev.4", "description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6", "keywords": [ "cli", diff --git a/src/cliproxy/cliproxy-executor.ts b/src/cliproxy/cliproxy-executor.ts index dd72b261..e895d251 100644 --- a/src/cliproxy/cliproxy-executor.ts +++ b/src/cliproxy/cliproxy-executor.ts @@ -51,6 +51,7 @@ import { import { registerSession, unregisterSession, cleanupOrphanedSessions } from './session-tracker'; import { detectRunningProxy, waitForProxyHealthy, reclaimOrphanedProxy } from './proxy-detector'; import { withStartupLock } from './startup-lock'; +import { loadOrCreateUnifiedConfig } from '../config/unified-config-loader'; /** Default executor configuration */ const DEFAULT_CONFIG: ExecutorConfig = { @@ -126,7 +127,25 @@ 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); + const unifiedConfig = loadOrCreateUnifiedConfig(); + const cliproxyServerConfig = unifiedConfig.cliproxy_server; + const { config: proxyConfig, remainingArgs: argsWithoutProxy } = resolveProxyConfig(args, { + remote: cliproxyServerConfig?.remote + ? { + enabled: cliproxyServerConfig.remote.enabled, + host: cliproxyServerConfig.remote.host, + port: cliproxyServerConfig.remote.port, + protocol: cliproxyServerConfig.remote.protocol, + auth_token: cliproxyServerConfig.remote.auth_token, + } + : undefined, + local: cliproxyServerConfig?.local + ? { + port: cliproxyServerConfig.local.port, + auto_start: cliproxyServerConfig.local.auto_start, + } + : undefined, + }); // Use resolved port from proxy config (overrides ExecutorConfig) if (proxyConfig.port !== CLIPROXY_DEFAULT_PORT) { @@ -516,14 +535,29 @@ export async function execClaudeWithCLIProxy( // 7. Execute Claude CLI with proxied environment // Use remote or local env vars based on mode + // When remote is configured (even if using local), pass config for URL rewriting + const remoteRewriteConfig = + proxyConfig.mode === 'remote' && proxyConfig.host + ? { + host: proxyConfig.host, + port: proxyConfig.port, + protocol: proxyConfig.protocol, + authToken: proxyConfig.authToken, + } + : undefined; + const envVars = useRemoteProxy - ? getRemoteEnvVars(provider, { - host: proxyConfig.host ?? 'localhost', - port: proxyConfig.port, - protocol: proxyConfig.protocol, - authToken: proxyConfig.authToken, - }) - : getEffectiveEnvVars(provider, cfg.port, cfg.customSettingsPath); + ? getRemoteEnvVars( + provider, + { + host: proxyConfig.host ?? 'localhost', + port: proxyConfig.port, + protocol: proxyConfig.protocol, + authToken: proxyConfig.authToken, + }, + cfg.customSettingsPath + ) + : getEffectiveEnvVars(provider, cfg.port, cfg.customSettingsPath, remoteRewriteConfig); const webSearchEnv = getWebSearchHookEnv(); const env = { ...process.env, diff --git a/src/cliproxy/config-generator.ts b/src/cliproxy/config-generator.ts index d0ca4b95..57022dcd 100644 --- a/src/cliproxy/config-generator.ts +++ b/src/cliproxy/config-generator.ts @@ -392,6 +392,48 @@ function getGlobalEnvVars(): Record { return globalEnvConfig.env; } +/** Remote proxy configuration for URL rewriting */ +interface RemoteProxyRewriteConfig { + host: string; + port?: number; + protocol: 'http' | 'https'; + authToken?: string; +} + +/** + * Rewrite localhost URLs to remote server URLs. + * Handles various localhost patterns: 127.0.0.1, localhost, 0.0.0.0 + */ +function rewriteLocalhostUrls( + envVars: NodeJS.ProcessEnv, + provider: CLIProxyProvider, + remoteConfig: RemoteProxyRewriteConfig +): NodeJS.ProcessEnv { + const result = { ...envVars }; + const baseUrl = result.ANTHROPIC_BASE_URL; + + if (!baseUrl) return result; + + // Check if URL points to localhost (127.0.0.1, localhost, 0.0.0.0) + const localhostPattern = /^https?:\/\/(127\.0\.0\.1|localhost|0\.0\.0\.0)(:\d+)?/i; + if (!localhostPattern.test(baseUrl)) return result; + + // Build remote URL with smart port handling + const defaultPort = remoteConfig.protocol === 'https' ? 443 : 80; + const effectivePort = remoteConfig.port ?? defaultPort; + const portSuffix = effectivePort === defaultPort ? '' : `:${effectivePort}`; + const remoteBaseUrl = `${remoteConfig.protocol}://${remoteConfig.host}${portSuffix}/api/provider/${provider}`; + + result.ANTHROPIC_BASE_URL = remoteBaseUrl; + + // Update auth token if provided + if (remoteConfig.authToken) { + result.ANTHROPIC_AUTH_TOKEN = remoteConfig.authToken; + } + + return result; +} + /** * Get effective environment variables for provider * @@ -402,15 +444,20 @@ function getGlobalEnvVars(): Record { * * All results are merged with global_env vars (telemetry/reporting disables). * User takes full responsibility for custom settings. + * + * If remoteRewriteConfig is provided, localhost URLs are rewritten to remote server. */ export function getEffectiveEnvVars( provider: CLIProxyProvider, port: number = CLIPROXY_DEFAULT_PORT, - customSettingsPath?: string + customSettingsPath?: string, + remoteRewriteConfig?: RemoteProxyRewriteConfig ): NodeJS.ProcessEnv { // Get global env vars (DISABLE_TELEMETRY, etc.) const globalEnv = getGlobalEnvVars(); + let envVars: NodeJS.ProcessEnv; + // Priority 1: Custom settings path (for user-defined variants) if (customSettingsPath) { const expandedPath = customSettingsPath.replace(/^~/, require('os').homedir()); @@ -421,7 +468,12 @@ export function getEffectiveEnvVars( if (settings.env && typeof settings.env === 'object') { // Custom variant settings found - merge with global env - return { ...globalEnv, ...settings.env }; + envVars = { ...globalEnv, ...settings.env }; + // Apply remote rewrite if configured + if (remoteRewriteConfig) { + envVars = rewriteLocalhostUrls(envVars, provider, remoteRewriteConfig); + } + return envVars; } } catch { // Invalid JSON - fall through to provider defaults @@ -443,7 +495,12 @@ export function getEffectiveEnvVars( if (settings.env && typeof settings.env === 'object') { // User override found - merge with global env - return { ...globalEnv, ...settings.env }; + envVars = { ...globalEnv, ...settings.env }; + // Apply remote rewrite if configured + if (remoteRewriteConfig) { + envVars = rewriteLocalhostUrls(envVars, provider, remoteRewriteConfig); + } + return envVars; } } catch { // Invalid JSON or structure - fall through to defaults @@ -483,48 +540,89 @@ export function ensureProviderSettings(provider: CLIProxyProvider): void { /** * Get environment variables for remote proxy mode. * Uses the remote proxy's provider endpoint as the base URL. + * Respects user model settings from custom settings path or provider settings file. * * @param provider CLIProxy provider (gemini, codex, agy, qwen, iflow) * @param remoteConfig Remote proxy connection details + * @param customSettingsPath Optional path to user's custom settings file * @returns Environment variables for Claude CLI */ export function getRemoteEnvVars( provider: CLIProxyProvider, - remoteConfig: { host: string; port?: number; protocol: 'http' | 'https'; authToken?: string } + remoteConfig: { host: string; port?: number; protocol: 'http' | 'https'; authToken?: string }, + customSettingsPath?: string ): Record { // Build URL with smart port handling - omit if using protocol default const defaultPort = remoteConfig.protocol === 'https' ? 443 : 80; const effectivePort = remoteConfig.port ?? defaultPort; const portSuffix = effectivePort === defaultPort ? '' : `:${effectivePort}`; const baseUrl = `${remoteConfig.protocol}://${remoteConfig.host}${portSuffix}/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); + // Load user settings with priority: custom path > user settings file > base config + let userEnvVars: Record = {}; - // 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; + // Priority 1: Custom settings path (for user-defined variants) + if (customSettingsPath) { + const expandedPath = customSettingsPath.replace(/^~/, require('os').homedir()); + if (fs.existsSync(expandedPath)) { + try { + const content = fs.readFileSync(expandedPath, 'utf-8'); + const settings: ProviderSettings = JSON.parse(content); + if (settings.env && typeof settings.env === 'object') { + userEnvVars = settings.env as Record; + } + } catch { + // Invalid JSON - fall through to provider defaults + console.warn(warn(`Invalid settings file: ${customSettingsPath}`)); + } + } + } + // Priority 2: Default provider settings file (~/.ccs/{provider}.settings.json) + if (Object.keys(userEnvVars).length === 0) { + const settingsPath = getProviderSettingsPath(provider); + if (fs.existsSync(settingsPath)) { + try { + const content = fs.readFileSync(settingsPath, 'utf-8'); + const settings: ProviderSettings = JSON.parse(content); + if (settings.env && typeof settings.env === 'object') { + userEnvVars = settings.env as Record; + } + } catch { + // Invalid JSON - fall through to base config + } + } + } + + // Priority 3: Base config defaults + if (Object.keys(userEnvVars).length === 0) { + const models = getModelMapping(provider); + const baseEnvVars = getEnvVarsFromConfig(provider); + // Filter out URL/auth from base config (we'll set those from remote config) + const { + ANTHROPIC_BASE_URL: _baseUrl, + ANTHROPIC_AUTH_TOKEN: _authToken, + ...additionalEnvVars + } = baseEnvVars; + userEnvVars = { + ...additionalEnvVars, + 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, + }; + } + + // Build final env: global + user settings + remote URL/auth override const env: Record = { ...globalEnv, - ...additionalEnvVars, + ...userEnvVars, + // Always override URL and auth token with remote config 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; diff --git a/src/cliproxy/proxy-target-resolver.ts b/src/cliproxy/proxy-target-resolver.ts new file mode 100644 index 00000000..64778f44 --- /dev/null +++ b/src/cliproxy/proxy-target-resolver.ts @@ -0,0 +1,100 @@ +/** + * Proxy Target Resolver + * + * Determines whether CLIProxyAPI requests should go to local or remote + * based on unified config. Used by stats-fetcher, auth-routes, and UI. + */ + +import { loadOrCreateUnifiedConfig } from '../config/unified-config-loader'; +import type { CliproxyServerConfig } from '../config/unified-config-types'; + +/** Default CLIProxyAPI port */ +const DEFAULT_CLIPROXY_PORT = 8317; + +/** Resolved proxy target for making requests */ +export interface ProxyTarget { + /** Target hostname or IP */ + host: string; + /** Target port */ + port: number; + /** Protocol (http/https) */ + protocol: 'http' | 'https'; + /** Optional auth token - only send header if defined and non-empty */ + authToken?: string; + /** True if targeting remote server, false if local */ + isRemote: boolean; +} + +/** + * Load cliproxy_server configuration from unified config. + * Returns undefined if not configured. + */ +function loadCliproxyServerConfig(): CliproxyServerConfig | undefined { + const config = loadOrCreateUnifiedConfig(); + return config.cliproxy_server; +} + +/** + * Get the current CLIProxyAPI target based on unified config. + * Returns remote server config if enabled, otherwise localhost. + */ +export function getProxyTarget(): ProxyTarget { + const config = loadCliproxyServerConfig(); + + if (config?.remote?.enabled && config.remote?.host) { + const protocol = config.remote.protocol ?? 'http'; + // Default port based on protocol if not specified + const defaultPort = protocol === 'https' ? 443 : 80; + const port = config.remote.port ?? defaultPort; + + return { + host: config.remote.host, + port, + protocol, + authToken: config.remote.auth_token || undefined, // Empty string -> undefined + isRemote: true, + }; + } + + return { + host: '127.0.0.1', + port: config?.local?.port ?? DEFAULT_CLIPROXY_PORT, + protocol: 'http', + isRemote: false, + }; +} + +/** + * Build URL for proxy endpoint + * @param target Resolved proxy target + * @param path Endpoint path (e.g., '/v0/management/usage') + */ +export function buildProxyUrl(target: ProxyTarget, path: string): string { + // Normalize path to ensure leading slash + const normalizedPath = path.startsWith('/') ? path : `/${path}`; + return `${target.protocol}://${target.host}:${target.port}${normalizedPath}`; +} + +/** + * Build request headers for proxy requests + * Handles optional auth token - only adds Authorization header if token is set. + * + * @param target Resolved proxy target + * @param additionalHeaders Extra headers to merge + */ +export function buildProxyHeaders( + target: ProxyTarget, + additionalHeaders: Record = {} +): Record { + const headers: Record = { + Accept: 'application/json', + ...additionalHeaders, + }; + + // Only add auth header if token is configured + if (target.authToken) { + headers['Authorization'] = `Bearer ${target.authToken}`; + } + + return headers; +} diff --git a/src/cliproxy/remote-auth-fetcher.ts b/src/cliproxy/remote-auth-fetcher.ts new file mode 100644 index 00000000..5acaba86 --- /dev/null +++ b/src/cliproxy/remote-auth-fetcher.ts @@ -0,0 +1,159 @@ +/** + * Remote Auth Fetcher + * Fetches and transforms auth data from remote CLIProxyAPI. + */ + +import { + getProxyTarget, + buildProxyUrl, + buildProxyHeaders, + ProxyTarget, +} from './proxy-target-resolver'; + +/** Timeout for remote fetch requests (ms) */ +const REMOTE_FETCH_TIMEOUT_MS = 5000; + +/** Remote auth file from CLIProxyAPI /v0/management/auth-files */ +interface RemoteAuthFile { + id: string; + name: string; + type: string; + provider: string; + email?: string; + status: 'active' | 'disabled' | 'unavailable'; + source: 'file' | 'memory'; +} + +/** Account info for UI display */ +export interface RemoteAccountInfo { + id: string; + email: string; + isDefault: boolean; + status: 'active' | 'disabled' | 'unavailable'; +} + +/** Auth status for a provider (UI format) */ +export interface RemoteAuthStatus { + provider: string; + displayName: string; + authenticated: boolean; + tokenFiles: number; + accounts: RemoteAccountInfo[]; + defaultAccount: string | null; + source: 'remote'; +} + +/** Map CLIProxyAPI provider names to CCS internal names */ +const PROVIDER_MAP: Record = { + gemini: 'gemini', + 'gemini-cli': 'gemini', // CLIProxyAPI uses 'gemini-cli' for Gemini CLI auth + antigravity: 'agy', + codex: 'codex', + qwen: 'qwen', + iflow: 'iflow', +}; + +/** Display names for providers */ +const PROVIDER_DISPLAY_NAMES: Record = { + gemini: 'Google Gemini', + agy: 'AntiGravity', + codex: 'Codex', + qwen: 'Qwen', + iflow: 'iFlow', +}; + +/** + * Fetch auth status from remote CLIProxyAPI + * @throws Error if remote is unreachable or returns error + */ +export async function fetchRemoteAuthStatus(target?: ProxyTarget): Promise { + const proxyTarget = target ?? getProxyTarget(); + + if (!proxyTarget.isRemote) { + throw new Error('fetchRemoteAuthStatus called but remote mode not enabled'); + } + + const url = buildProxyUrl(proxyTarget, '/v0/management/auth-files'); + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), REMOTE_FETCH_TIMEOUT_MS); + + try { + const response = await fetch(url, { + signal: controller.signal, + headers: buildProxyHeaders(proxyTarget), + }); + + clearTimeout(timeoutId); + + if (!response.ok) { + if (response.status === 401 || response.status === 403) { + throw new Error('Authentication failed - check auth token in settings'); + } + throw new Error(`Remote returned ${response.status}: ${response.statusText}`); + } + + const data: unknown = await response.json(); + + // Validate response structure + if (!data || typeof data !== 'object' || !('files' in data) || !Array.isArray(data.files)) { + throw new Error('Invalid response format from remote auth endpoint'); + } + + return transformRemoteAuthFiles(data.files as RemoteAuthFile[]); + } catch (error) { + clearTimeout(timeoutId); + + if (error instanceof Error && error.name === 'AbortError') { + throw new Error('Remote proxy connection timed out'); + } + throw error; + } +} + +/** + * Transform CLIProxyAPI auth files to CCS AuthStatus format + * @param files Array of auth files from remote API + */ +function transformRemoteAuthFiles(files: RemoteAuthFile[]): RemoteAuthStatus[] { + const byProvider = new Map(); + + for (const file of files) { + const provider = PROVIDER_MAP[file.provider.toLowerCase()]; + if (!provider) { + // Unknown provider, skip (could add logging in debug mode) + continue; + } + + const existing = byProvider.get(provider); + if (existing) { + existing.push(file); + } else { + byProvider.set(provider, [file]); + } + } + + const result: RemoteAuthStatus[] = []; + + for (const [provider, providerFiles] of byProvider) { + const activeFiles = providerFiles.filter((f) => f.status === 'active'); + const accounts: RemoteAccountInfo[] = providerFiles.map((f, idx) => ({ + id: f.id, + email: f.email || f.name || 'Unknown', + isDefault: idx === 0, + status: f.status, + })); + + result.push({ + provider, + displayName: PROVIDER_DISPLAY_NAMES[provider] || provider, + authenticated: activeFiles.length > 0, + tokenFiles: providerFiles.length, + accounts, + defaultAccount: accounts.find((a) => a.isDefault)?.id || null, + source: 'remote', + }); + } + + return result; +} diff --git a/src/cliproxy/stats-fetcher.ts b/src/cliproxy/stats-fetcher.ts index e7506a74..f66e987d 100644 --- a/src/cliproxy/stats-fetcher.ts +++ b/src/cliproxy/stats-fetcher.ts @@ -5,7 +5,8 @@ * Requires usage-statistics-enabled: true in config.yaml. */ -import { CCS_CONTROL_PANEL_SECRET, CLIPROXY_DEFAULT_PORT } from './config-generator'; +import { CCS_CONTROL_PANEL_SECRET } from './config-generator'; +import { getProxyTarget, buildProxyUrl, buildProxyHeaders } from './proxy-target-resolver'; /** Per-account usage statistics */ export interface AccountUsageStats { @@ -95,19 +96,27 @@ interface UsageApiResponse { * @param port CLIProxyAPI port (default: 8317) * @returns Stats object or null if unavailable */ -export async function fetchCliproxyStats( - port: number = CLIPROXY_DEFAULT_PORT -): Promise { +export async function fetchCliproxyStats(port?: number): Promise { try { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 3000); // 3s timeout - const response = await fetch(`http://127.0.0.1:${port}/v0/management/usage`, { + // Dynamic target resolution + const target = getProxyTarget(); + // Allow port override for local testing only + if (port !== undefined && !target.isRemote) { + target.port = port; + } + const url = buildProxyUrl(target, '/v0/management/usage'); + + // For management endpoints, use CCS control panel secret for local, remote auth for remote + const headers = target.isRemote + ? buildProxyHeaders(target) + : { Accept: 'application/json', Authorization: `Bearer ${CCS_CONTROL_PANEL_SECRET}` }; + + const response = await fetch(url, { signal: controller.signal, - headers: { - Accept: 'application/json', - Authorization: `Bearer ${CCS_CONTROL_PANEL_SECRET}`, - }, + headers, }); clearTimeout(timeoutId); @@ -222,20 +231,27 @@ export interface CliproxyModelsResponse { * @param port CLIProxyAPI port (default: 8317) * @returns Categorized models or null if unavailable */ -export async function fetchCliproxyModels( - port: number = CLIPROXY_DEFAULT_PORT -): Promise { +export async function fetchCliproxyModels(port?: number): Promise { try { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 3000); - const response = await fetch(`http://127.0.0.1:${port}/v1/models`, { + // Dynamic target resolution + const target = getProxyTarget(); + // Allow port override for local testing only + if (port !== undefined && !target.isRemote) { + target.port = port; + } + const url = buildProxyUrl(target, '/v1/models'); + + // For /v1 endpoints: use remote auth token for remote, ccs-internal-managed for local + const headers = target.isRemote + ? buildProxyHeaders(target) + : { Accept: 'application/json', Authorization: 'Bearer ccs-internal-managed' }; + + const response = await fetch(url, { signal: controller.signal, - headers: { - Accept: 'application/json', - // Use the internal API key for /v1 endpoints - Authorization: 'Bearer ccs-internal-managed', - }, + headers, }); clearTimeout(timeoutId); @@ -293,19 +309,27 @@ interface ErrorLogsApiResponse { * @param port CLIProxyAPI port (default: 8317) * @returns Array of error log metadata or null if unavailable */ -export async function fetchCliproxyErrorLogs( - port: number = CLIPROXY_DEFAULT_PORT -): Promise { +export async function fetchCliproxyErrorLogs(port?: number): Promise { try { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 3000); - const response = await fetch(`http://127.0.0.1:${port}/v0/management/request-error-logs`, { + // Dynamic target resolution + const target = getProxyTarget(); + // Allow port override for local testing only + if (port !== undefined && !target.isRemote) { + target.port = port; + } + const url = buildProxyUrl(target, '/v0/management/request-error-logs'); + + // For management endpoints, use CCS control panel secret for local, remote auth for remote + const headers = target.isRemote + ? buildProxyHeaders(target) + : { Accept: 'application/json', Authorization: `Bearer ${CCS_CONTROL_PANEL_SECRET}` }; + + const response = await fetch(url, { signal: controller.signal, - headers: { - Accept: 'application/json', - Authorization: `Bearer ${CCS_CONTROL_PANEL_SECRET}`, - }, + headers, }); clearTimeout(timeoutId); @@ -329,22 +353,33 @@ export async function fetchCliproxyErrorLogs( */ export async function fetchCliproxyErrorLogContent( name: string, - port: number = CLIPROXY_DEFAULT_PORT + port?: number ): Promise { try { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 5000); - const response = await fetch( - `http://127.0.0.1:${port}/v0/management/request-error-logs/${encodeURIComponent(name)}`, - { - signal: controller.signal, - headers: { - Authorization: `Bearer ${CCS_CONTROL_PANEL_SECRET}`, - }, - } + // Dynamic target resolution + const target = getProxyTarget(); + // Allow port override for local testing only + if (port !== undefined && !target.isRemote) { + target.port = port; + } + const url = buildProxyUrl( + target, + `/v0/management/request-error-logs/${encodeURIComponent(name)}` ); + // For management endpoints, use CCS control panel secret for local, remote auth for remote + const headers = target.isRemote + ? buildProxyHeaders(target) + : { Authorization: `Bearer ${CCS_CONTROL_PANEL_SECRET}` }; + + const response = await fetch(url, { + signal: controller.signal, + headers, + }); + clearTimeout(timeoutId); if (!response.ok) { @@ -362,13 +397,21 @@ export async function fetchCliproxyErrorLogContent( * @param port CLIProxyAPI port (default: 8317) * @returns true if proxy is running */ -export async function isCliproxyRunning(port: number = CLIPROXY_DEFAULT_PORT): Promise { +export async function isCliproxyRunning(port?: number): Promise { try { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 1000); // 1s timeout - // Use root endpoint - CLIProxyAPI returns server info at / - const response = await fetch(`http://127.0.0.1:${port}/`, { + // Dynamic target resolution + const target = getProxyTarget(); + // Allow port override for local testing only + if (port !== undefined && !target.isRemote) { + target.port = port; + } + const url = buildProxyUrl(target, '/'); + + // Health check - no auth needed for root endpoint + const response = await fetch(url, { signal: controller.signal, }); diff --git a/src/web-server/routes/account-routes.ts b/src/web-server/routes/account-routes.ts new file mode 100644 index 00000000..848383f5 --- /dev/null +++ b/src/web-server/routes/account-routes.ts @@ -0,0 +1,68 @@ +/** + * Account Routes - CRUD operations for Claude accounts (profiles.json) + * + * Separated from profile-routes.ts to avoid dual-mounting conflicts. + */ + +import { Router, Request, Response } from 'express'; +import * as fs from 'fs'; +import * as path from 'path'; +import { getCcsDir } from '../../utils/config-manager'; + +const router = Router(); + +/** + * GET /api/accounts - List accounts from profiles.json + */ +router.get('/', (_req: Request, res: Response): void => { + try { + const profilesPath = path.join(getCcsDir(), 'profiles.json'); + + if (!fs.existsSync(profilesPath)) { + res.json({ accounts: [], default: null }); + return; + } + + const data = JSON.parse(fs.readFileSync(profilesPath, 'utf8')); + const accounts = Object.entries(data.profiles || {}).map(([name, meta]) => { + const metadata = meta as Record; + return { + name, + ...metadata, + }; + }); + + res.json({ accounts, default: data.default || null }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + +/** + * POST /api/accounts/default - Set default account + */ +router.post('/default', (req: Request, res: Response): void => { + try { + const { name } = req.body; + + if (!name) { + res.status(400).json({ error: 'Missing required field: name' }); + return; + } + + const profilesPath = path.join(getCcsDir(), 'profiles.json'); + + const data = fs.existsSync(profilesPath) + ? JSON.parse(fs.readFileSync(profilesPath, 'utf8')) + : { profiles: {} }; + + data.default = name; + fs.writeFileSync(profilesPath, JSON.stringify(data, null, 2) + '\n'); + + res.json({ default: name }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + +export default router; diff --git a/src/web-server/routes/cliproxy-auth-routes.ts b/src/web-server/routes/cliproxy-auth-routes.ts index 6d5d16b2..feeff110 100644 --- a/src/web-server/routes/cliproxy-auth-routes.ts +++ b/src/web-server/routes/cliproxy-auth-routes.ts @@ -21,6 +21,8 @@ import { removeAccount as removeAccountFn, touchAccount, } from '../../cliproxy/account-manager'; +import { getProxyTarget } from '../../cliproxy/proxy-target-resolver'; +import { fetchRemoteAuthStatus } from '../../cliproxy/remote-auth-fetcher'; import type { CLIProxyProvider } from '../../cliproxy/types'; const router = Router(); @@ -32,57 +34,79 @@ const validProviders: CLIProxyProvider[] = ['gemini', 'codex', 'agy', 'qwen', 'i * GET /api/cliproxy/auth - Get auth status for built-in CLIProxy profiles * Also fetches CLIProxyAPI stats to update lastUsedAt for active providers */ -router.get('/', async (_req: Request, res: Response) => { - // Initialize accounts from existing tokens on first request - initializeAccounts(); +router.get('/', async (_req: Request, res: Response): Promise => { + try { + // Check if remote mode is enabled + const target = getProxyTarget(); + if (target.isRemote) { + const authStatus = await fetchRemoteAuthStatus(target); + res.json({ authStatus, source: 'remote' }); + return; + } - // Fetch CLIProxyAPI usage stats to determine active providers - const stats = await fetchCliproxyStats(); + // Local mode: Initialize accounts from existing tokens on first request + initializeAccounts(); - // Map CLIProxyAPI provider names to our internal provider names - const statsProviderMap: Record = { - gemini: 'gemini', - antigravity: 'agy', - codex: 'codex', - qwen: 'qwen', - iflow: 'iflow', - }; + // Fetch CLIProxyAPI usage stats to determine active providers + const stats = await fetchCliproxyStats(); - // Update lastUsedAt for providers with recent activity - if (stats?.requestsByProvider) { - for (const [statsProvider, requestCount] of Object.entries(stats.requestsByProvider)) { - if (requestCount > 0) { - const provider = statsProviderMap[statsProvider.toLowerCase()]; - if (provider) { - // Touch the default account for this provider (or all accounts) - const accounts = getProviderAccounts(provider); - for (const account of accounts) { - // Only touch if this is the default account (most likely being used) - if (account.isDefault) { - touchAccount(provider, account.id); + // Map CLIProxyAPI provider names to our internal provider names + const statsProviderMap: Record = { + gemini: 'gemini', + antigravity: 'agy', + codex: 'codex', + qwen: 'qwen', + iflow: 'iflow', + }; + + // Update lastUsedAt for providers with recent activity + if (stats?.requestsByProvider) { + for (const [statsProvider, requestCount] of Object.entries(stats.requestsByProvider)) { + if (requestCount > 0) { + const provider = statsProviderMap[statsProvider.toLowerCase()]; + if (provider) { + // Touch the default account for this provider (or all accounts) + const accounts = getProviderAccounts(provider); + for (const account of accounts) { + // Only touch if this is the default account (most likely being used) + if (account.isDefault) { + touchAccount(provider, account.id); + } } } } } } + + const statuses = getAllAuthStatus(); + + const authStatus = statuses.map((status) => { + const oauthConfig = getOAuthConfig(status.provider); + return { + provider: status.provider, + displayName: oauthConfig.displayName, + authenticated: status.authenticated, + lastAuth: status.lastAuth?.toISOString() || null, + tokenFiles: status.tokenFiles.length, + accounts: status.accounts, + defaultAccount: status.defaultAccount, + }; + }); + + res.json({ authStatus }); + } catch (error) { + // Return appropriate error for remote vs local mode + const target = getProxyTarget(); + if (target.isRemote) { + res.status(503).json({ + error: (error as Error).message, + authStatus: [], + source: 'remote', + }); + } else { + res.status(500).json({ error: (error as Error).message }); + } } - - const statuses = getAllAuthStatus(); - - const authStatus = statuses.map((status) => { - const oauthConfig = getOAuthConfig(status.provider); - return { - provider: status.provider, - displayName: oauthConfig.displayName, - authenticated: status.authenticated, - lastAuth: status.lastAuth?.toISOString() || null, - tokenFiles: status.tokenFiles.length, - accounts: status.accounts, - defaultAccount: status.defaultAccount, - }; - }); - - res.json({ authStatus }); }); // ==================== Account Management ==================== @@ -90,12 +114,41 @@ router.get('/', async (_req: Request, res: Response) => { /** * GET /api/cliproxy/accounts - Get all accounts across all providers */ -router.get('/accounts', (_req: Request, res: Response) => { - // Initialize accounts from existing tokens - initializeAccounts(); +router.get('/accounts', async (_req: Request, res: Response): Promise => { + try { + // Check if remote mode is enabled + const target = getProxyTarget(); + if (target.isRemote) { + const authStatus = await fetchRemoteAuthStatus(target); + // Transform RemoteAuthStatus[] to account summary format + const accounts = authStatus.flatMap((status) => + status.accounts.map((acc) => ({ + provider: status.provider, + ...acc, + })) + ); + res.json({ accounts, source: 'remote' }); + return; + } - const accounts = getAllAccountsSummary(); - res.json({ accounts }); + // Local mode: Initialize accounts from existing tokens + initializeAccounts(); + + const accounts = getAllAccountsSummary(); + res.json({ accounts }); + } catch (error) { + const target = getProxyTarget(); + if (target.isRemote) { + res.status(503).json({ + error: (error as Error).message, + accounts: [], + source: 'remote', + }); + } else { + const message = error instanceof Error ? error.message : 'Failed to list accounts'; + res.status(500).json({ error: message }); + } + } }); /** @@ -110,14 +163,28 @@ router.get('/accounts/:provider', (req: Request, res: Response): void => { return; } - const accounts = getProviderAccounts(provider as CLIProxyProvider); - res.json({ provider, accounts }); + try { + const accounts = getProviderAccounts(provider as CLIProxyProvider); + res.json({ provider, accounts }); + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to get provider accounts'; + res.status(500).json({ error: message }); + } }); /** * POST /api/cliproxy/accounts/:provider/default - Set default account for provider */ router.post('/accounts/:provider/default', (req: Request, res: Response): void => { + // Check if remote mode is enabled - account management not available + const target = getProxyTarget(); + if (target.isRemote) { + res.status(501).json({ + error: 'Account management not available in remote mode', + }); + return; + } + const { provider } = req.params; const { accountId } = req.body; @@ -132,12 +199,19 @@ router.post('/accounts/:provider/default', (req: Request, res: Response): void = return; } - const success = setDefaultAccountFn(provider as CLIProxyProvider, accountId); + try { + const success = setDefaultAccountFn(provider as CLIProxyProvider, accountId); - if (success) { - res.json({ provider, defaultAccount: accountId }); - } else { - res.status(404).json({ error: 'Account not found' }); + if (success) { + res.json({ provider, defaultAccount: accountId }); + } else { + res + .status(404) + .json({ error: `Account '${accountId}' not found for provider '${provider}'` }); + } + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to set default account'; + res.status(500).json({ error: message }); } }); @@ -145,6 +219,15 @@ router.post('/accounts/:provider/default', (req: Request, res: Response): void = * DELETE /api/cliproxy/accounts/:provider/:accountId - Remove an account */ router.delete('/accounts/:provider/:accountId', (req: Request, res: Response): void => { + // Check if remote mode is enabled - account management not available + const target = getProxyTarget(); + if (target.isRemote) { + res.status(501).json({ + error: 'Account management not available in remote mode', + }); + return; + } + const { provider, accountId } = req.params; // Validate provider @@ -153,12 +236,19 @@ router.delete('/accounts/:provider/:accountId', (req: Request, res: Response): v return; } - const success = removeAccountFn(provider as CLIProxyProvider, accountId); + try { + const success = removeAccountFn(provider as CLIProxyProvider, accountId); - if (success) { - res.json({ provider, accountId, deleted: true }); - } else { - res.status(404).json({ error: 'Account not found' }); + if (success) { + res.json({ provider, accountId, deleted: true }); + } else { + res + .status(404) + .json({ error: `Account '${accountId}' not found for provider '${provider}'` }); + } + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to remove account'; + res.status(500).json({ error: message }); } }); diff --git a/src/web-server/routes/cliproxy-stats-routes.ts b/src/web-server/routes/cliproxy-stats-routes.ts index c1b4a676..85517dff 100644 --- a/src/web-server/routes/cliproxy-stats-routes.ts +++ b/src/web-server/routes/cliproxy-stats-routes.ts @@ -3,6 +3,7 @@ */ import { Router, Request, Response } from 'express'; +import * as fs from 'fs'; import * as path from 'path'; import { fetchCliproxyStats, @@ -11,7 +12,11 @@ import { fetchCliproxyErrorLogs, fetchCliproxyErrorLogContent, } from '../../cliproxy/stats-fetcher'; -import { getCliproxyWritablePath } from '../../cliproxy/config-generator'; +import { + getCliproxyWritablePath, + getConfigPath, + getAuthDir, +} from '../../cliproxy/config-generator'; import { getProxyStatus as getProxyProcessStatus, stopProxy } from '../../cliproxy/session-tracker'; import { ensureCliproxyService } from '../../cliproxy/service-manager'; import { checkCliproxyUpdate } from '../../cliproxy/binary-manager'; @@ -19,10 +24,9 @@ import { checkCliproxyUpdate } from '../../cliproxy/binary-manager'; const router = Router(); /** - * GET /api/cliproxy/stats - Get CLIProxyAPI usage statistics - * Returns: CliproxyStats or error if proxy not running + * Shared handler for stats/usage endpoint */ -router.get('/stats', async (_req: Request, res: Response): Promise => { +const handleStatsRequest = async (_req: Request, res: Response): Promise => { try { // Check if proxy is running first const running = await isCliproxyRunning(); @@ -48,7 +52,18 @@ router.get('/stats', async (_req: Request, res: Response): Promise => { } catch (error) { res.status(500).json({ error: (error as Error).message }); } -}); +}; + +/** + * GET /api/cliproxy/stats - Get CLIProxyAPI usage statistics + * Returns: CliproxyStats or error if proxy not running + */ +router.get('/stats', handleStatsRequest); + +/** + * GET /api/cliproxy/usage - Alias for /stats (frontend compatibility) + */ +router.get('/usage', handleStatsRequest); /** * GET /api/cliproxy/status - Check CLIProxyAPI running status @@ -249,4 +264,170 @@ router.get('/error-logs/:name', async (req: Request, res: Response): Promise => { + try { + const configPath = getConfigPath(); + if (!fs.existsSync(configPath)) { + res.status(404).json({ error: 'Config file not found' }); + return; + } + + const content = fs.readFileSync(configPath, 'utf8'); + res.type('text/yaml').send(content); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + +/** + * PUT /api/cliproxy/config.yaml - Save CLIProxy YAML config content + * Body: { content: string } + * Returns: { success: true, path: string } + */ +router.put('/config.yaml', async (req: Request, res: Response): Promise => { + try { + const { content } = req.body; + + if (typeof content !== 'string') { + res.status(400).json({ error: 'Missing required field: content' }); + return; + } + + const configPath = getConfigPath(); + + // Ensure parent directory exists + const configDir = path.dirname(configPath); + if (!fs.existsSync(configDir)) { + fs.mkdirSync(configDir, { recursive: true }); + } + + // Write atomically + const tempPath = configPath + '.tmp'; + fs.writeFileSync(tempPath, content); + fs.renameSync(tempPath, configPath); + + res.json({ success: true, path: configPath }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + +// ==================== Auth Files ==================== + +/** + * GET /api/cliproxy/auth-files - List auth files in auth directory + * Returns: { files: Array<{ name, size, mtime }> } + */ +router.get('/auth-files', async (_req: Request, res: Response): Promise => { + try { + const authDir = getAuthDir(); + + if (!fs.existsSync(authDir)) { + res.json({ files: [] }); + return; + } + + const entries = fs.readdirSync(authDir, { withFileTypes: true }); + const files = entries + .filter((entry) => entry.isFile()) + .map((entry) => { + const filePath = path.join(authDir, entry.name); + const stat = fs.statSync(filePath); + return { + name: entry.name, + size: stat.size, + mtime: stat.mtime.getTime(), + }; + }); + + res.json({ files, directory: authDir }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + +/** + * GET /api/cliproxy/auth-files/download - Download auth file content + * Query: ?name=filename + * Returns: file content as octet-stream + */ +router.get('/auth-files/download', async (req: Request, res: Response): Promise => { + try { + const { name } = req.query; + + if (!name || typeof name !== 'string') { + res.status(400).json({ error: 'Missing required query parameter: name' }); + return; + } + + // Validate filename - prevent path traversal + if (name.includes('..') || name.includes('/') || name.includes('\\')) { + res.status(400).json({ error: 'Invalid filename' }); + return; + } + + const authDir = getAuthDir(); + const filePath = path.join(authDir, name); + + if (!fs.existsSync(filePath)) { + res.status(404).json({ error: 'Auth file not found' }); + return; + } + + const content = fs.readFileSync(filePath); + res.setHeader('Content-Disposition', `attachment; filename="${name}"`); + res.type('application/octet-stream').send(content); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + +// ==================== Model Updates ==================== + +/** + * PUT /api/cliproxy/models/:provider - Update model for a provider + * Body: { model: string } + * Returns: { success: true, provider, model } + */ +router.put('/models/:provider', async (req: Request, res: Response): Promise => { + try { + const { provider } = req.params; + const { model } = req.body; + + if (!model || typeof model !== 'string') { + res.status(400).json({ error: 'Missing required field: model' }); + return; + } + + // Get the settings file for this provider + const ccsDir = getCliproxyWritablePath(); + const settingsPath = path.join(ccsDir, `${provider}.settings.json`); + + if (!fs.existsSync(settingsPath)) { + res.status(404).json({ error: `Settings file not found for provider: ${provider}` }); + return; + } + + // Read and update settings + const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); + settings.env = settings.env || {}; + settings.env.ANTHROPIC_MODEL = model; + + // Write atomically + const tempPath = settingsPath + '.tmp'; + fs.writeFileSync(tempPath, JSON.stringify(settings, null, 2) + '\n'); + fs.renameSync(tempPath, settingsPath); + + res.json({ success: true, provider, model }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + export default router; diff --git a/src/web-server/routes/config-routes.ts b/src/web-server/routes/config-routes.ts index 1c1b7f20..d00985ca 100644 --- a/src/web-server/routes/config-routes.ts +++ b/src/web-server/routes/config-routes.ts @@ -24,12 +24,16 @@ const router = Router(); /** * GET /api/config/format - Return current config format and migration status */ -router.get('/format', (_req: Request, res: Response) => { - res.json({ - format: getConfigFormat(), - migrationNeeded: needsMigration(), - backups: getBackupDirectories(), - }); +router.get('/format', (_req: Request, res: Response): void => { + try { + res.json({ + format: getConfigFormat(), + migrationNeeded: needsMigration(), + backups: getBackupDirectories(), + }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } }); /** @@ -89,25 +93,33 @@ router.put('/', (req: Request, res: Response): void => { /** * POST /api/config/migrate - Trigger migration from JSON to YAML */ -router.post('/migrate', async (req: Request, res: Response) => { - const dryRun = req.query.dryRun === 'true'; - const result = await migrate(dryRun); - res.json(result); +router.post('/migrate', async (req: Request, res: Response): Promise => { + try { + const dryRun = req.query.dryRun === 'true'; + const result = await migrate(dryRun); + res.json(result); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } }); /** * POST /api/config/rollback - Rollback migration to JSON format */ router.post('/rollback', async (req: Request, res: Response): Promise => { - const { backupPath } = req.body; + try { + const { backupPath } = req.body; - if (!backupPath || typeof backupPath !== 'string') { - res.status(400).json({ error: 'Missing required field: backupPath' }); - return; + if (!backupPath || typeof backupPath !== 'string') { + res.status(400).json({ error: 'Missing required field: backupPath' }); + return; + } + + const success = await rollback(backupPath); + res.json({ success }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); } - - const success = await rollback(backupPath); - res.json({ success }); }); export default router; diff --git a/src/web-server/routes/health-routes.ts b/src/web-server/routes/health-routes.ts index 5740633a..0331b4f8 100644 --- a/src/web-server/routes/health-routes.ts +++ b/src/web-server/routes/health-routes.ts @@ -10,22 +10,30 @@ const router = Router(); /** * GET /api/health - Run health checks */ -router.get('/', async (_req: Request, res: Response) => { - const report = await runHealthChecks(); - res.json(report); +router.get('/', async (_req: Request, res: Response): Promise => { + try { + const report = await runHealthChecks(); + res.json(report); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } }); /** * POST /api/health/fix/:checkId - Fix a health issue */ router.post('/fix/:checkId', (req: Request, res: Response): void => { - const { checkId } = req.params; - const result = fixHealthIssue(checkId); + try { + const { checkId } = req.params; + const result = fixHealthIssue(checkId); - if (result.success) { - res.json({ success: true, message: result.message }); - } else { - res.status(400).json({ success: false, message: result.message }); + if (result.success) { + res.json({ success: true, message: result.message }); + } else { + res.status(400).json({ success: false, message: result.message }); + } + } catch (error) { + res.status(500).json({ error: (error as Error).message }); } }); diff --git a/src/web-server/routes/index.ts b/src/web-server/routes/index.ts index 819cb717..6b967ac3 100644 --- a/src/web-server/routes/index.ts +++ b/src/web-server/routes/index.ts @@ -9,6 +9,7 @@ import { Router } from 'express'; // Import domain routers import profileRoutes from './profile-routes'; +import accountRoutes from './account-routes'; import configRoutes from './config-routes'; import healthRoutes from './health-routes'; import providerRoutes from './provider-routes'; @@ -28,7 +29,7 @@ export const apiRoutes = Router(); // Profile CRUD, settings management, presets, accounts apiRoutes.use('/profiles', profileRoutes); apiRoutes.use('/settings', settingsRoutes); -apiRoutes.use('/accounts', profileRoutes); +apiRoutes.use('/accounts', accountRoutes); // ==================== Unified Config ==================== // Config format, migration diff --git a/src/web-server/routes/profile-routes.ts b/src/web-server/routes/profile-routes.ts index 311d5426..b7943bea 100644 --- a/src/web-server/routes/profile-routes.ts +++ b/src/web-server/routes/profile-routes.ts @@ -1,13 +1,11 @@ /** - * Profile Routes - CRUD operations for user profiles and accounts + * Profile Routes - CRUD operations for user profiles * * Uses unified config (config.yaml) when available, falls back to legacy (config.json). + * Note: Account routes have been moved to account-routes.ts */ import { Router, Request, Response } from 'express'; -import * as fs from 'fs'; -import * as path from 'path'; -import { getCcsDir } from '../../utils/config-manager'; import { isReservedName, RESERVED_PROFILE_NAMES } from '../../config/reserved-names'; import { createApiProfile, removeApiProfile } from '../../api/services/profile-writer'; import { apiProfileExists, listApiProfiles } from '../../api/services/profile-reader'; @@ -20,15 +18,19 @@ const router = Router(); /** * GET /api/profiles - List all profiles */ -router.get('/', (_req: Request, res: Response) => { - const result = listApiProfiles(); - // Map isConfigured -> configured for UI compatibility - const profiles = result.profiles.map((p) => ({ - name: p.name, - settingsPath: p.settingsPath, - configured: p.isConfigured, - })); - res.json({ profiles }); +router.get('/', (_req: Request, res: Response): void => { + try { + const result = listApiProfiles(); + // Map isConfigured -> configured for UI compatibility + const profiles = result.profiles.map((p) => ({ + name: p.name, + settingsPath: p.settingsPath, + configured: p.isConfigured, + })); + res.json({ profiles }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } }); /** @@ -117,52 +119,4 @@ router.delete('/:name', (req: Request, res: Response): void => { res.json({ name, deleted: true }); }); -// ==================== Accounts ==================== - -/** - * GET /api/accounts - List accounts from profiles.json - */ -router.get('/accounts', (_req: Request, res: Response): void => { - const profilesPath = path.join(getCcsDir(), 'profiles.json'); - - if (!fs.existsSync(profilesPath)) { - res.json({ accounts: [], default: null }); - return; - } - - const data = JSON.parse(fs.readFileSync(profilesPath, 'utf8')); - const accounts = Object.entries(data.profiles || {}).map(([name, meta]) => { - const metadata = meta as Record; - return { - name, - ...metadata, - }; - }); - - res.json({ accounts, default: data.default || null }); -}); - -/** - * POST /api/accounts/default - Set default account - */ -router.post('/accounts/default', (req: Request, res: Response): void => { - const { name } = req.body; - - if (!name) { - res.status(400).json({ error: 'Missing required field: name' }); - return; - } - - const profilesPath = path.join(getCcsDir(), 'profiles.json'); - - const data = fs.existsSync(profilesPath) - ? JSON.parse(fs.readFileSync(profilesPath, 'utf8')) - : { profiles: {} }; - - data.default = name; - fs.writeFileSync(profilesPath, JSON.stringify(data, null, 2) + '\n'); - - res.json({ default: name }); -}); - export default router; diff --git a/src/web-server/routes/settings-routes.ts b/src/web-server/routes/settings-routes.ts index c810c568..02f03874 100644 --- a/src/web-server/routes/settings-routes.ts +++ b/src/web-server/routes/settings-routes.ts @@ -32,103 +32,115 @@ function maskApiKeys(settings: Settings): Settings { * GET /api/settings/:profile - Get settings with masked API keys */ router.get('/:profile', (req: Request, res: Response): void => { - const { profile } = req.params; - const ccsDir = getCcsDir(); - const settingsPath = path.join(ccsDir, `${profile}.settings.json`); + try { + const { profile } = req.params; + const ccsDir = getCcsDir(); + const settingsPath = path.join(ccsDir, `${profile}.settings.json`); - if (!fs.existsSync(settingsPath)) { - res.status(404).json({ error: 'Settings not found' }); - return; + if (!fs.existsSync(settingsPath)) { + res.status(404).json({ error: 'Settings not found' }); + return; + } + + const stat = fs.statSync(settingsPath); + const settings = loadSettings(settingsPath); + const masked = maskApiKeys(settings); + + res.json({ + profile, + settings: masked, + mtime: stat.mtime.getTime(), + path: settingsPath, + }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); } - - const stat = fs.statSync(settingsPath); - const settings = loadSettings(settingsPath); - const masked = maskApiKeys(settings); - - res.json({ - profile, - settings: masked, - mtime: stat.mtime.getTime(), - path: settingsPath, - }); }); /** * GET /api/settings/:profile/raw - Get full settings (for editing) */ router.get('/:profile/raw', (req: Request, res: Response): void => { - const { profile } = req.params; - const ccsDir = getCcsDir(); - const settingsPath = path.join(ccsDir, `${profile}.settings.json`); + try { + const { profile } = req.params; + const ccsDir = getCcsDir(); + const settingsPath = path.join(ccsDir, `${profile}.settings.json`); - if (!fs.existsSync(settingsPath)) { - res.status(404).json({ error: 'Settings not found' }); - return; + if (!fs.existsSync(settingsPath)) { + res.status(404).json({ error: 'Settings not found' }); + return; + } + + const stat = fs.statSync(settingsPath); + const settings = loadSettings(settingsPath); + + res.json({ + profile, + settings, + mtime: stat.mtime.getTime(), + path: settingsPath, + }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); } - - const stat = fs.statSync(settingsPath); - const settings = loadSettings(settingsPath); - - res.json({ - profile, - settings, - mtime: stat.mtime.getTime(), - path: settingsPath, - }); }); /** * PUT /api/settings/:profile - Update settings with conflict detection and backup */ router.put('/:profile', (req: Request, res: Response): void => { - const { profile } = req.params; - const { settings, expectedMtime } = req.body; - const ccsDir = getCcsDir(); - const settingsPath = path.join(ccsDir, `${profile}.settings.json`); + try { + const { profile } = req.params; + const { settings, expectedMtime } = req.body; + const ccsDir = getCcsDir(); + const settingsPath = path.join(ccsDir, `${profile}.settings.json`); - const fileExists = fs.existsSync(settingsPath); + const fileExists = fs.existsSync(settingsPath); - // Only check conflict if file exists and expectedMtime was provided - if (fileExists && expectedMtime) { - const stat = fs.statSync(settingsPath); - if (stat.mtime.getTime() !== expectedMtime) { - res.status(409).json({ - error: 'File modified externally', - currentMtime: stat.mtime.getTime(), - }); - return; + // Only check conflict if file exists and expectedMtime was provided + if (fileExists && expectedMtime) { + const stat = fs.statSync(settingsPath); + if (stat.mtime.getTime() !== expectedMtime) { + res.status(409).json({ + error: 'File modified externally', + currentMtime: stat.mtime.getTime(), + }); + return; + } } - } - // Create backup only if file exists - let backupPath: string | undefined; - if (fileExists) { - const backupDir = path.join(ccsDir, 'backups'); - if (!fs.existsSync(backupDir)) { - fs.mkdirSync(backupDir, { recursive: true }); + // Create backup only if file exists + let backupPath: string | undefined; + if (fileExists) { + const backupDir = path.join(ccsDir, 'backups'); + if (!fs.existsSync(backupDir)) { + fs.mkdirSync(backupDir, { recursive: true }); + } + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + backupPath = path.join(backupDir, `${profile}.${timestamp}.settings.json`); + fs.copyFileSync(settingsPath, backupPath); } - const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); - backupPath = path.join(backupDir, `${profile}.${timestamp}.settings.json`); - fs.copyFileSync(settingsPath, backupPath); + + // Ensure directory exists for new files + if (!fileExists) { + fs.mkdirSync(path.dirname(settingsPath), { recursive: true }); + } + + // Write new settings atomically + const tempPath = settingsPath + '.tmp'; + fs.writeFileSync(tempPath, JSON.stringify(settings, null, 2) + '\n'); + fs.renameSync(tempPath, settingsPath); + + const newStat = fs.statSync(settingsPath); + res.json({ + profile, + mtime: newStat.mtime.getTime(), + backupPath, + created: !fileExists, + }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); } - - // Ensure directory exists for new files - if (!fileExists) { - fs.mkdirSync(path.dirname(settingsPath), { recursive: true }); - } - - // Write new settings atomically - const tempPath = settingsPath + '.tmp'; - fs.writeFileSync(tempPath, JSON.stringify(settings, null, 2) + '\n'); - fs.renameSync(tempPath, settingsPath); - - const newStat = fs.statSync(settingsPath); - res.json({ - profile, - mtime: newStat.mtime.getTime(), - backupPath, - created: !fileExists, - }); }); // ==================== Presets ==================== @@ -137,85 +149,97 @@ router.put('/:profile', (req: Request, res: Response): void => { * GET /api/settings/:profile/presets - Get saved presets for a provider */ router.get('/:profile/presets', (req: Request, res: Response): void => { - const { profile } = req.params; - const ccsDir = getCcsDir(); - const settingsPath = path.join(ccsDir, `${profile}.settings.json`); + try { + const { profile } = req.params; + const ccsDir = getCcsDir(); + const settingsPath = path.join(ccsDir, `${profile}.settings.json`); - if (!fs.existsSync(settingsPath)) { - res.json({ presets: [] }); - return; + if (!fs.existsSync(settingsPath)) { + res.json({ presets: [] }); + return; + } + + const settings = loadSettings(settingsPath); + res.json({ presets: settings.presets || [] }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); } - - const settings = loadSettings(settingsPath); - res.json({ presets: settings.presets || [] }); }); /** * POST /api/settings/:profile/presets - Create a new preset */ router.post('/:profile/presets', (req: Request, res: Response): void => { - const { profile } = req.params; - const { name, default: defaultModel, opus, sonnet, haiku } = req.body; + try { + const { profile } = req.params; + const { name, default: defaultModel, opus, sonnet, haiku } = req.body; - if (!name || !defaultModel) { - res.status(400).json({ error: 'Missing required fields: name, default' }); - return; + if (!name || !defaultModel) { + res.status(400).json({ error: 'Missing required fields: name, default' }); + return; + } + + const ccsDir = getCcsDir(); + const settingsPath = path.join(ccsDir, `${profile}.settings.json`); + + // Create settings file if it doesn't exist + if (!fs.existsSync(settingsPath)) { + fs.writeFileSync(settingsPath, JSON.stringify({ env: {}, presets: [] }, null, 2) + '\n'); + } + + const settings = loadSettings(settingsPath); + settings.presets = settings.presets || []; + + // Check for duplicate name + if (settings.presets.some((p) => p.name === name)) { + res.status(409).json({ error: 'Preset with this name already exists' }); + return; + } + + const preset = { + name, + default: defaultModel, + opus: opus || defaultModel, + sonnet: sonnet || defaultModel, + haiku: haiku || defaultModel, + }; + + settings.presets.push(preset); + fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n'); + + res.status(201).json({ preset }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); } - - const ccsDir = getCcsDir(); - const settingsPath = path.join(ccsDir, `${profile}.settings.json`); - - // Create settings file if it doesn't exist - if (!fs.existsSync(settingsPath)) { - fs.writeFileSync(settingsPath, JSON.stringify({ env: {}, presets: [] }, null, 2) + '\n'); - } - - const settings = loadSettings(settingsPath); - settings.presets = settings.presets || []; - - // Check for duplicate name - if (settings.presets.some((p) => p.name === name)) { - res.status(409).json({ error: 'Preset with this name already exists' }); - return; - } - - const preset = { - name, - default: defaultModel, - opus: opus || defaultModel, - sonnet: sonnet || defaultModel, - haiku: haiku || defaultModel, - }; - - settings.presets.push(preset); - fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n'); - - res.status(201).json({ preset }); }); /** * DELETE /api/settings/:profile/presets/:name - Delete a preset */ router.delete('/:profile/presets/:name', (req: Request, res: Response): void => { - const { profile, name } = req.params; - const ccsDir = getCcsDir(); - const settingsPath = path.join(ccsDir, `${profile}.settings.json`); + try { + const { profile, name } = req.params; + const ccsDir = getCcsDir(); + const settingsPath = path.join(ccsDir, `${profile}.settings.json`); - if (!fs.existsSync(settingsPath)) { - res.status(404).json({ error: 'Settings not found' }); - return; + if (!fs.existsSync(settingsPath)) { + res.status(404).json({ error: 'Settings not found' }); + return; + } + + const settings = loadSettings(settingsPath); + if (!settings.presets || !settings.presets.some((p) => p.name === name)) { + res.status(404).json({ error: 'Preset not found' }); + return; + } + + settings.presets = settings.presets.filter((p) => p.name !== name); + fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n'); + + res.json({ success: true }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); } - - const settings = loadSettings(settingsPath); - if (!settings.presets || !settings.presets.some((p) => p.name === name)) { - res.status(404).json({ error: 'Preset not found' }); - return; - } - - settings.presets = settings.presets.filter((p) => p.name !== name); - fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n'); - - res.json({ success: true }); }); export default router; diff --git a/src/web-server/routes/variant-routes.ts b/src/web-server/routes/variant-routes.ts index 1ce3d601..e402bbaa 100644 --- a/src/web-server/routes/variant-routes.ts +++ b/src/web-server/routes/variant-routes.ts @@ -78,82 +78,90 @@ router.post('/', (req: Request, res: Response): void => { * PUT /api/cliproxy/:name - Update cliproxy variant */ router.put('/:name', (req: Request, res: Response): void => { - const { name } = req.params; - const { provider, account, model } = req.body; + try { + const { name } = req.params; + const { provider, account, model } = req.body; - const config = readConfigSafe(); + const config = readConfigSafe(); - if (!config.cliproxy?.[name]) { - res.status(404).json({ error: 'Variant not found' }); - return; - } - - const variant = config.cliproxy[name]; - - // Update fields if provided - if (provider) { - variant.provider = provider; - } - if (account !== undefined) { - if (account) { - variant.account = account; - } else { - delete variant.account; // Remove account to use default + if (!config.cliproxy?.[name]) { + res.status(404).json({ error: 'Variant not found' }); + return; } - } - // Update model in settings file if provided - if (model !== undefined) { - const settingsPath = path.join(getCcsDir(), `${name}.settings.json`); - if (fs.existsSync(settingsPath)) { - const settings = loadSettings(settingsPath); - if (model) { - settings.env = settings.env || {}; - settings.env.ANTHROPIC_MODEL = model; - } else if (settings.env) { - delete settings.env.ANTHROPIC_MODEL; + const variant = config.cliproxy[name]; + + // Update fields if provided + if (provider) { + variant.provider = provider; + } + if (account !== undefined) { + if (account) { + variant.account = account; + } else { + delete variant.account; // Remove account to use default } - fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n'); } + + // Update model in settings file if provided + if (model !== undefined) { + const settingsPath = path.join(getCcsDir(), `${name}.settings.json`); + if (fs.existsSync(settingsPath)) { + const settings = loadSettings(settingsPath); + if (model) { + settings.env = settings.env || {}; + settings.env.ANTHROPIC_MODEL = model; + } else if (settings.env) { + delete settings.env.ANTHROPIC_MODEL; + } + fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n'); + } + } + + writeConfig(config); + + res.json({ + name, + provider: variant.provider, + account: variant.account || 'default', + settings: variant.settings, + updated: true, + }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); } - - writeConfig(config); - - res.json({ - name, - provider: variant.provider, - account: variant.account || 'default', - settings: variant.settings, - updated: true, - }); }); /** * DELETE /api/cliproxy/:name - Delete cliproxy variant */ router.delete('/:name', (req: Request, res: Response): void => { - const { name } = req.params; + try { + const { name } = req.params; - const config = readConfigSafe(); + const config = readConfigSafe(); - if (!config.cliproxy?.[name]) { - res.status(404).json({ error: 'Variant not found' }); - return; - } - - // Never delete settings files for reserved provider names (safety guard) - if (!isReservedName(name)) { - // Only delete settings file for non-reserved variant names - const settingsPath = path.join(getCcsDir(), `${name}.settings.json`); - if (fs.existsSync(settingsPath)) { - fs.unlinkSync(settingsPath); + if (!config.cliproxy?.[name]) { + res.status(404).json({ error: 'Variant not found' }); + return; } + + // Never delete settings files for reserved provider names (safety guard) + if (!isReservedName(name)) { + // Only delete settings file for non-reserved variant names + const settingsPath = path.join(getCcsDir(), `${name}.settings.json`); + if (fs.existsSync(settingsPath)) { + fs.unlinkSync(settingsPath); + } + } + + delete config.cliproxy[name]; + writeConfig(config); + + res.json({ name, deleted: true }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); } - - delete config.cliproxy[name]; - writeConfig(config); - - res.json({ name, deleted: true }); }); export default router; diff --git a/ui/src/components/cliproxy/control-panel-embed.tsx b/ui/src/components/cliproxy/control-panel-embed.tsx index 393a9e64..2abe97f2 100644 --- a/ui/src/components/cliproxy/control-panel-embed.tsx +++ b/ui/src/components/cliproxy/control-panel-embed.tsx @@ -3,10 +3,14 @@ * * Embeds the CLIProxy management.html with auto-authentication. * Uses postMessage to inject credentials into the iframe. + * Supports both local and remote CLIProxy server connections. */ -import { useState, useEffect, useRef, useCallback } from 'react'; -import { RefreshCw, AlertCircle, Key, X, Gauge } from 'lucide-react'; +import { useState, useEffect, useRef, useCallback, useMemo } from 'react'; +import { RefreshCw, AlertCircle, Key, X, Gauge, Globe } from 'lucide-react'; +import { useQuery } from '@tanstack/react-query'; +import { api } from '@/lib/api-client'; +import type { CliproxyServerConfig } from '@/lib/api-client'; /** CLIProxyAPI default port */ const CLIPROXY_DEFAULT_PORT = 8317; @@ -25,30 +29,94 @@ export function ControlPanelEmbed({ port = CLIPROXY_DEFAULT_PORT }: ControlPanel const [isConnected, setIsConnected] = useState(false); const [showLoginHint, setShowLoginHint] = useState(true); - const managementUrl = `http://localhost:${port}/management.html`; + // Fetch cliproxy_server config for remote/local mode detection + const { data: cliproxyConfig, error: configError } = useQuery({ + queryKey: ['cliproxy-server-config'], + queryFn: () => api.cliproxyServer.get(), + staleTime: 30000, // 30 seconds + }); + + // Log config fetch errors (fallback to local mode on error) + useEffect(() => { + if (configError) { + console.warn('[ControlPanelEmbed] Config fetch failed, using local mode:', configError); + } + }, [configError]); + + // Calculate URLs and settings based on remote or local mode + const { managementUrl, checkUrl, authToken, isRemote, displayHost } = useMemo(() => { + const remote = cliproxyConfig?.remote; + + if (remote?.enabled && remote?.host) { + const protocol = remote.protocol || 'http'; + // Use port from config, or default based on protocol (443 for https, 80 for http) + const remotePort = remote.port || (protocol === 'https' ? 443 : 80); + // Only include port in URL if it's non-standard + const portSuffix = + (protocol === 'https' && remotePort === 443) || (protocol === 'http' && remotePort === 80) + ? '' + : `:${remotePort}`; + const baseUrl = `${protocol}://${remote.host}${portSuffix}`; + + return { + managementUrl: `${baseUrl}/management.html`, + checkUrl: `${baseUrl}/`, + authToken: remote.auth_token || undefined, + isRemote: true, + displayHost: `${remote.host}${portSuffix}`, + }; + } + + // Local mode + return { + managementUrl: `http://localhost:${port}/management.html`, + checkUrl: `http://localhost:${port}/`, + authToken: CCS_CONTROL_PANEL_SECRET, + isRemote: false, + displayHost: `localhost:${port}`, + }; + }, [cliproxyConfig, port]); // Check if CLIProxy is running useEffect(() => { + const controller = new AbortController(); + const checkConnection = async () => { try { - const response = await fetch(`http://localhost:${port}/`, { - signal: AbortSignal.timeout(2000), + const response = await fetch(checkUrl, { + signal: controller.signal, }); if (response.ok) { setIsConnected(true); setError(null); } else { setIsConnected(false); - setError('CLIProxy returned an error'); + setError( + isRemote + ? `Remote CLIProxy at ${displayHost} returned an error` + : 'CLIProxy returned an error' + ); } - } catch { + } catch (e) { + // Ignore abort errors (component unmounting) + if (e instanceof Error && e.name === 'AbortError') return; + setIsConnected(false); - setError('CLIProxy is not running'); + setError( + isRemote + ? `Remote CLIProxy at ${displayHost} is not reachable` + : 'CLIProxy is not running' + ); } }; - checkConnection(); - }, [port]); + // Start connection check with timeout + const timeoutId = setTimeout(() => controller.abort(), 2000); + checkConnection().finally(() => clearTimeout(timeoutId)); + + // Cleanup: abort fetch on unmount + return () => controller.abort(); + }, [checkUrl, isRemote, displayHost]); // Handle iframe load - attempt to auto-login via postMessage const handleIframeLoad = useCallback(() => { @@ -57,26 +125,38 @@ export function ControlPanelEmbed({ port = CLIPROXY_DEFAULT_PORT }: ControlPanel // Try to inject credentials via postMessage // The management.html needs to listen for this message // If it doesn't support it, user will see the login page - if (iframeRef.current?.contentWindow) { + if (iframeRef.current?.contentWindow && authToken) { try { + // Derive apiBase from checkUrl (remove trailing slash) + const apiBase = checkUrl.replace(/\/$/, ''); + + // Security: Validate iframe src matches target origin before sending credentials + const iframeSrc = iframeRef.current.src; + if (!iframeSrc.startsWith(apiBase)) { + console.warn('[ControlPanelEmbed] Iframe origin mismatch, skipping postMessage'); + return; + } + // Send credentials to iframe iframeRef.current.contentWindow.postMessage( { type: 'ccs-auto-login', - apiBase: `http://localhost:${port}`, - managementKey: CCS_CONTROL_PANEL_SECRET, + apiBase, + managementKey: authToken, }, - `http://localhost:${port}` + apiBase ); - } catch { + } catch (e) { // Cross-origin restriction - expected if not same origin - console.debug('[ControlPanelEmbed] postMessage failed - cross-origin'); + console.debug('[ControlPanelEmbed] postMessage failed - cross-origin:', e); } } - }, [port]); + }, [checkUrl, authToken]); const handleRefresh = () => { setIsLoading(true); + setError(null); + setIsConnected(false); if (iframeRef.current) { iframeRef.current.src = managementUrl; } @@ -119,15 +199,24 @@ export function ControlPanelEmbed({ port = CLIPROXY_DEFAULT_PORT }: ControlPanel return (
- {/* Login hint banner */} + {/* Remote indicator and login hint banner */} {showLoginHint && !isLoading && (
+ {isRemote && ( + <> + + Remote + | + + )} Key:{' '} - ccs + {authToken && authToken.length > 4 + ? `***${authToken.slice(-4)}` + : authToken || 'ccs'}
)} diff --git a/ui/src/components/cliproxy/provider-editor/index.tsx b/ui/src/components/cliproxy/provider-editor/index.tsx index 2d9f14ff..a153dbbd 100644 --- a/ui/src/components/cliproxy/provider-editor/index.tsx +++ b/ui/src/components/cliproxy/provider-editor/index.tsx @@ -31,6 +31,7 @@ export function ProviderEditor({ authStatus, catalog, logoProvider, + isRemoteMode, onAddAccount, onSetDefault, onRemoveAccount, @@ -124,6 +125,7 @@ export function ProviderEditor({ hasChanges={hasChanges} isRawJsonValid={isRawJsonValid} isSaving={saveMutation.isPending} + isRemoteMode={isRemoteMode} onRefetch={refetch} onSave={() => saveMutation.mutate()} /> diff --git a/ui/src/components/cliproxy/provider-editor/provider-editor-header.tsx b/ui/src/components/cliproxy/provider-editor/provider-editor-header.tsx index f64f9605..3d662811 100644 --- a/ui/src/components/cliproxy/provider-editor/provider-editor-header.tsx +++ b/ui/src/components/cliproxy/provider-editor/provider-editor-header.tsx @@ -5,7 +5,7 @@ import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; -import { Save, Loader2, RefreshCw } from 'lucide-react'; +import { Save, Loader2, RefreshCw, Globe } from 'lucide-react'; import { ProviderLogo } from '../provider-logo'; import type { SettingsResponse } from './types'; @@ -18,6 +18,7 @@ interface ProviderEditorHeaderProps { hasChanges: boolean; isRawJsonValid: boolean; isSaving: boolean; + isRemoteMode?: boolean; onRefetch: () => void; onSave: () => void; } @@ -31,6 +32,7 @@ export function ProviderEditorHeader({ hasChanges, isRawJsonValid, isSaving, + isRemoteMode, onRefetch, onSave, }: ProviderEditorHeaderProps) { @@ -41,16 +43,31 @@ export function ProviderEditorHeader({

{displayName}

- {data?.path && ( + {isRemoteMode && ( + + + Remote + + )} + {!isRemoteMode && data?.path && ( - {data.path.replace(/^.*\//, '')} + {data.path.replace(/^.*[\\/]/, '')} )}
- {data && ( -

- Last modified: {new Date(data.mtime).toLocaleString()} + {isRemoteMode ? ( +

+ Traffic auto-routed to remote server

+ ) : ( + data && ( +

+ Last modified: {new Date(data.mtime).toLocaleString()} +

+ ) )}
diff --git a/ui/src/components/cliproxy/provider-editor/types.ts b/ui/src/components/cliproxy/provider-editor/types.ts index 4d1d87a6..6bea9288 100644 --- a/ui/src/components/cliproxy/provider-editor/types.ts +++ b/ui/src/components/cliproxy/provider-editor/types.ts @@ -21,6 +21,8 @@ export interface ProviderEditorProps { catalog?: ProviderCatalog; /** Provider type for logo display (defaults to provider) */ logoProvider?: string; + /** True if using remote CLIProxy mode (hides local paths) */ + isRemoteMode?: boolean; onAddAccount: () => void; onSetDefault: (accountId: string) => void; onRemoveAccount: (accountId: string) => void; diff --git a/ui/src/components/monitoring/proxy-status-widget.tsx b/ui/src/components/monitoring/proxy-status-widget.tsx index bfa0a2c3..6c4af3b0 100644 --- a/ui/src/components/monitoring/proxy-status-widget.tsx +++ b/ui/src/components/monitoring/proxy-status-widget.tsx @@ -3,11 +3,24 @@ * * Displays CLIProxy process status with start/stop/restart controls. * Shows: running state, port, session count, uptime, update availability. + * In remote mode: shows remote server info instead of local controls. */ -import { Activity, Power, RefreshCw, Clock, Users, Square, RotateCw, ArrowUp } from 'lucide-react'; +import { + Activity, + Power, + RefreshCw, + Clock, + Users, + Square, + RotateCw, + ArrowUp, + Globe, +} from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; +import { useQuery } from '@tanstack/react-query'; +import { api, type CliproxyServerConfig } from '@/lib/api-client'; import { useProxyStatus, useStartProxy, @@ -48,10 +61,32 @@ export function ProxyStatusWidget() { const startProxy = useStartProxy(); const stopProxy = useStopProxy(); + // Fetch cliproxy_server config for remote mode detection + const { data: cliproxyConfig } = useQuery({ + queryKey: ['cliproxy-server-config'], + queryFn: () => api.cliproxyServer.get(), + staleTime: 30000, // 30 seconds + }); + + // Determine if remote mode is enabled + const remoteConfig = cliproxyConfig?.remote; + const isRemoteMode = remoteConfig?.enabled && remoteConfig?.host; + const isRunning = status?.running ?? false; const isActioning = startProxy.isPending || stopProxy.isPending; const hasUpdate = updateCheck?.hasUpdate ?? false; + // Build remote display info + const remoteDisplayHost = isRemoteMode + ? (() => { + const protocol = remoteConfig.protocol || 'http'; + const port = remoteConfig.port || (protocol === 'https' ? 443 : 80); + const isDefaultPort = + (protocol === 'https' && port === 443) || (protocol === 'http' && port === 80); + return isDefaultPort ? remoteConfig.host : `${remoteConfig.host}:${port}`; + })() + : null; + // Restart = stop then start const handleRestart = async () => { await stopProxy.mutateAsync(); @@ -60,6 +95,43 @@ export function ProxyStatusWidget() { startProxy.mutate(); }; + // Remote mode: show remote server info + if (isRemoteMode) { + return ( +
+
+
+ + Remote Proxy + + Active + +
+ +
+ +
+
+ {remoteDisplayHost} +
+

+ Traffic auto-routed to remote server +

+
+
+ ); + } + + // Local mode: show original controls + return (
request<{ variants: Variant[] }>('/cliproxy'), - getAuthStatus: () => request<{ authStatus: AuthStatus[] }>('/cliproxy/auth'), + getAuthStatus: () => + request<{ authStatus: AuthStatus[]; source?: 'remote' | 'local' }>('/cliproxy/auth'), create: (data: CreateVariant) => request('/cliproxy', { method: 'POST', @@ -307,16 +308,18 @@ export const api = { // Multi-account management accounts: { - list: () => request<{ accounts: ProviderAccountsMap }>('/cliproxy/accounts'), + list: () => request<{ accounts: ProviderAccountsMap }>('/cliproxy/auth/accounts'), listByProvider: (provider: string) => - request<{ provider: string; accounts: OAuthAccount[] }>(`/cliproxy/accounts/${provider}`), + request<{ provider: string; accounts: OAuthAccount[] }>( + `/cliproxy/auth/accounts/${provider}` + ), setDefault: (provider: string, accountId: string) => - request(`/cliproxy/accounts/${provider}/default`, { + request(`/cliproxy/auth/accounts/${provider}/default`, { method: 'POST', body: JSON.stringify({ accountId }), }), remove: (provider: string, accountId: string) => - request(`/cliproxy/accounts/${provider}/${accountId}`, { method: 'DELETE' }), + request(`/cliproxy/auth/accounts/${provider}/${accountId}`, { method: 'DELETE' }), }, // OAuth flow auth: { diff --git a/ui/src/pages/cliproxy.tsx b/ui/src/pages/cliproxy.tsx index 46f668ac..b9fc21a9 100644 --- a/ui/src/pages/cliproxy.tsx +++ b/ui/src/pages/cliproxy.tsx @@ -192,6 +192,7 @@ export function CliproxyPage() { } | null>(null); const providers = useMemo(() => authData?.authStatus || [], [authData?.authStatus]); + const isRemoteMode = authData?.source === 'remote'; const variants = useMemo(() => variantsData?.variants || [], [variantsData?.variants]); // Auto-select first provider if nothing selected @@ -338,6 +339,7 @@ export function CliproxyPage() { authStatus={parentAuthForVariant} catalog={MODEL_CATALOGS[selectedVariantData.provider]} logoProvider={selectedVariantData.provider} + isRemoteMode={isRemoteMode} onAddAccount={() => setAddAccountProvider({ provider: selectedVariantData.provider, @@ -365,6 +367,7 @@ export function CliproxyPage() { displayName={selectedStatus.displayName} authStatus={selectedStatus} catalog={MODEL_CATALOGS[selectedStatus.provider]} + isRemoteMode={isRemoteMode} onAddAccount={() => setAddAccountProvider({ provider: selectedStatus.provider,