Merge pull request #175 from kaitranntt/dev

feat(cliproxy): add remote CLIProxy routing support
This commit is contained in:
Kai (Tam Nhu) Tran
2025-12-21 20:25:04 -05:00
committed by GitHub
30 changed files with 1629 additions and 483 deletions
+24 -12
View File
@@ -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
+24
View File
@@ -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
+31
View File
@@ -255,6 +255,37 @@ See [docs/websearch.md](./docs/websearch.md) for detailed configuration and trou
<br>
## 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.
<br>
## Documentation
| Topic | Link |
+20
View File
@@ -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
+12 -6
View File
@@ -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 |
---
+21 -4
View File
@@ -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)
+12 -9
View File
@@ -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,78120 files) |
| 7 | Analytics Page | `pages/analytics/` (4208 files) |
| 8 | Auth Monitor | `monitoring/auth-monitor/` (4658 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 |
+65 -4
View File
@@ -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 <host> Remote hostname/IP
--proxy-port <port> Port (default: 8317 HTTP, 443 HTTPS)
--proxy-protocol <proto> http or https
--proxy-auth-token <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
+1 -1
View File
@@ -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",
+42 -8
View File
@@ -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,
+120 -22
View File
@@ -392,6 +392,48 @@ function getGlobalEnvVars(): Record<string, string> {
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<string, string> {
*
* 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<string, string> {
// 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<string, string> = {};
// 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<string, string>;
}
} 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<string, string>;
}
} 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<string, string> = {
...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;
+100
View File
@@ -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<string, string> = {}
): Record<string, string> {
const headers: Record<string, string> = {
Accept: 'application/json',
...additionalHeaders,
};
// Only add auth header if token is configured
if (target.authToken) {
headers['Authorization'] = `Bearer ${target.authToken}`;
}
return headers;
}
+159
View File
@@ -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<string, string> = {
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<string, string> = {
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<RemoteAuthStatus[]> {
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<string, RemoteAuthFile[]>();
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;
}
+81 -38
View File
@@ -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<CliproxyStats | null> {
export async function fetchCliproxyStats(port?: number): Promise<CliproxyStats | null> {
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<CliproxyModelsResponse | null> {
export async function fetchCliproxyModels(port?: number): Promise<CliproxyModelsResponse | null> {
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<CliproxyErrorLog[] | null> {
export async function fetchCliproxyErrorLogs(port?: number): Promise<CliproxyErrorLog[] | null> {
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<string | null> {
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<boolean> {
export async function isCliproxyRunning(port?: number): Promise<boolean> {
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,
});
+68
View File
@@ -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<string, unknown>;
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;
+149 -59
View File
@@ -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<void> => {
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<string, CLIProxyProvider> = {
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<string, CLIProxyProvider> = {
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<void> => {
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 });
}
});
+186 -5
View File
@@ -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<void> => {
const handleStatsRequest = async (_req: Request, res: Response): Promise<void> => {
try {
// Check if proxy is running first
const running = await isCliproxyRunning();
@@ -48,7 +52,18 @@ router.get('/stats', async (_req: Request, res: Response): Promise<void> => {
} 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<voi
}
});
// ==================== Config File ====================
/**
* GET /api/cliproxy/config.yaml - Get CLIProxy YAML config content
* Returns: plain text YAML content
*/
router.get('/config.yaml', async (_req: Request, res: Response): Promise<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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;
+29 -17
View File
@@ -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<void> => {
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<void> => {
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;
+17 -9
View File
@@ -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<void> => {
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 });
}
});
+2 -1
View File
@@ -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
+15 -61
View File
@@ -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<string, unknown>;
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;
+160 -136
View File
@@ -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;
+67 -59
View File
@@ -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;
@@ -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<CliproxyServerConfig>({
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 (
<div className="flex-1 flex flex-col relative">
{/* Login hint banner */}
{/* Remote indicator and login hint banner */}
{showLoginHint && !isLoading && (
<div className="absolute top-2 left-1/2 -translate-x-1/2 z-20">
<div className="flex items-center gap-2 px-3 py-1.5 bg-blue-50 dark:bg-blue-950 border border-blue-200 dark:border-blue-800 rounded-md text-sm">
{isRemote && (
<>
<Globe className="h-3.5 w-3.5 text-green-600" />
<span className="text-green-600 font-medium">Remote</span>
<span className="text-blue-300 dark:text-blue-700">|</span>
</>
)}
<Key className="h-3.5 w-3.5 text-blue-600" />
<span>
Key:{' '}
<code className="bg-blue-100 dark:bg-blue-900 px-1 rounded font-mono font-semibold">
ccs
{authToken && authToken.length > 4
? `***${authToken.slice(-4)}`
: authToken || 'ccs'}
</code>
</span>
<button
@@ -145,7 +234,11 @@ export function ControlPanelEmbed({ port = CLIPROXY_DEFAULT_PORT }: ControlPanel
<div className="absolute inset-0 flex items-center justify-center bg-background/80 z-10">
<div className="text-center">
<RefreshCw className="w-8 h-8 animate-spin text-primary mx-auto mb-2" />
<p className="text-sm text-muted-foreground">Loading Control Panel...</p>
<p className="text-sm text-muted-foreground">
{isRemote
? `Loading Control Panel from ${displayHost}...`
: 'Loading Control Panel...'}
</p>
</div>
</div>
)}
@@ -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()}
/>
@@ -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({
<div>
<div className="flex items-center gap-2">
<h2 className="text-lg font-semibold">{displayName}</h2>
{data?.path && (
{isRemoteMode && (
<Badge
variant="secondary"
className="text-xs gap-1 bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400"
>
<Globe className="w-3 h-3" />
Remote
</Badge>
)}
{!isRemoteMode && data?.path && (
<Badge variant="outline" className="text-xs">
{data.path.replace(/^.*\//, '')}
{data.path.replace(/^.*[\\/]/, '')}
</Badge>
)}
</div>
{data && (
<p className="text-xs text-muted-foreground mt-0.5">
Last modified: {new Date(data.mtime).toLocaleString()}
{isRemoteMode ? (
<p className="text-xs text-blue-600 dark:text-blue-400 mt-0.5">
Traffic auto-routed to remote server
</p>
) : (
data && (
<p className="text-xs text-muted-foreground mt-0.5">
Last modified: {new Date(data.mtime).toLocaleString()}
</p>
)
)}
</div>
</div>
@@ -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;
@@ -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<CliproxyServerConfig>({
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 (
<div
className={cn(
'rounded-lg border p-3 transition-colors',
'border-blue-500/30 bg-blue-500/5'
)}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Globe className="w-4 h-4 text-blue-500" />
<span className="text-sm font-medium">Remote Proxy</span>
<Badge
variant="secondary"
className="text-[10px] h-4 px-1.5 bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400"
>
Active
</Badge>
</div>
<Activity className="w-3 h-3 text-blue-600" />
</div>
<div className="mt-2 text-xs text-muted-foreground">
<div className="flex items-center gap-1 mb-1">
<span className="font-mono">{remoteDisplayHost}</span>
</div>
<p className="text-[10px] text-muted-foreground/70 leading-tight">
Traffic auto-routed to remote server
</p>
</div>
</div>
);
}
// Local mode: show original controls
return (
<div
className={cn(
+8 -5
View File
@@ -249,7 +249,8 @@ export const api = {
},
cliproxy: {
list: () => 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: {
+3
View File
@@ -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,