From 5b3d56548a8dfb2e6bb22e14b13f0fb038f2d1fb Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Thu, 18 Dec 2025 02:15:35 -0500 Subject: [PATCH 01/40] feat(dashboard): add error log viewer for CLIProxy diagnostics Add ErrorLogsMonitor component to Home page that displays CLIProxyAPI error logs when requests fail. Users can now diagnose why success rates drop by viewing detailed error log contents. Backend: - Add fetchCliproxyErrorLogs/fetchCliproxyErrorLogContent in stats-fetcher - Add GET /api/cliproxy/error-logs and /api/cliproxy/error-logs/:name routes - Include path traversal protection for filename validation Frontend: - Add CliproxyErrorLog type and errorLogs API methods - Add useCliproxyErrorLogs/useCliproxyErrorLogContent hooks - Create ErrorLogsMonitor component with expandable log viewer - Integrate into Home page below AuthMonitor Closes #132 --- src/cliproxy/stats-fetcher.ts | 84 +++++++++ src/web-server/routes.ts | 73 ++++++++ ui/src/components/error-logs-monitor.tsx | 212 +++++++++++++++++++++++ ui/src/hooks/use-cliproxy-stats.ts | 57 ++++++ ui/src/lib/api-client.ts | 21 +++ ui/src/pages/home.tsx | 4 + 6 files changed, 451 insertions(+) create mode 100644 ui/src/components/error-logs-monitor.tsx diff --git a/src/cliproxy/stats-fetcher.ts b/src/cliproxy/stats-fetcher.ts index aaf00058..cafbc526 100644 --- a/src/cliproxy/stats-fetcher.ts +++ b/src/cliproxy/stats-fetcher.ts @@ -271,6 +271,90 @@ export async function fetchCliproxyModels( } } +/** Error log file metadata from CLIProxyAPI */ +export interface CliproxyErrorLog { + /** Filename (e.g., "error-v1-chat-completions-2025-01-15T10-30-00.log") */ + name: string; + /** File size in bytes */ + size: number; + /** Last modified timestamp (Unix seconds) */ + modified: number; +} + +/** Response from /v0/management/request-error-logs endpoint */ +interface ErrorLogsApiResponse { + files: CliproxyErrorLog[]; +} + +/** + * Fetch error log file list from CLIProxyAPI management API + * @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 { + 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`, { + signal: controller.signal, + headers: { + Accept: 'application/json', + Authorization: `Bearer ${CCS_CONTROL_PANEL_SECRET}`, + }, + }); + + clearTimeout(timeoutId); + + if (!response.ok) { + return null; + } + + const data = (await response.json()) as ErrorLogsApiResponse; + return data.files ?? []; + } catch { + return null; + } +} + +/** + * Fetch error log file content from CLIProxyAPI management API + * @param name Error log filename + * @param port CLIProxyAPI port (default: 8317) + * @returns Log file content as string or null if unavailable + */ +export async function fetchCliproxyErrorLogContent( + name: string, + port: number = CLIPROXY_DEFAULT_PORT +): 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}`, + }, + } + ); + + clearTimeout(timeoutId); + + if (!response.ok) { + return null; + } + + return await response.text(); + } catch { + return null; + } +} + /** * Check if CLIProxyAPI is running and responsive * @param port CLIProxyAPI port (default: 8317) diff --git a/src/web-server/routes.ts b/src/web-server/routes.ts index 9a4486fd..7e9a44a5 100644 --- a/src/web-server/routes.ts +++ b/src/web-server/routes.ts @@ -21,6 +21,8 @@ import { fetchCliproxyStats, fetchCliproxyModels, isCliproxyRunning, + fetchCliproxyErrorLogs, + fetchCliproxyErrorLogContent, } from '../cliproxy/stats-fetcher'; import { listOpenAICompatProviders, @@ -1374,6 +1376,77 @@ apiRoutes.get('/cliproxy/models', async (_req: Request, res: Response): Promise< } }); +// ==================== Error Logs ==================== + +/** + * GET /api/cliproxy/error-logs - Get list of error log files + * Returns: { files: CliproxyErrorLog[] } or error if proxy not running + */ +apiRoutes.get('/cliproxy/error-logs', async (_req: Request, res: Response): Promise => { + try { + const running = await isCliproxyRunning(); + if (!running) { + res.status(503).json({ + error: 'CLIProxyAPI not running', + message: 'Start a CLIProxy session to view error logs', + }); + return; + } + + const files = await fetchCliproxyErrorLogs(); + if (files === null) { + res.status(503).json({ + error: 'Error logs unavailable', + message: 'CLIProxyAPI is running but error logs endpoint not responding', + }); + return; + } + + res.json({ files }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + +/** + * GET /api/cliproxy/error-logs/:name - Get content of a specific error log + * Returns: plain text log content + */ +apiRoutes.get('/cliproxy/error-logs/:name', async (req: Request, res: Response): Promise => { + const { name } = req.params; + + // Validate filename format and prevent path traversal + if ( + !name || + !name.startsWith('error-') || + !name.endsWith('.log') || + name.includes('..') || + name.includes('/') || + name.includes('\\') + ) { + res.status(400).json({ error: 'Invalid error log filename' }); + return; + } + + try { + const running = await isCliproxyRunning(); + if (!running) { + res.status(503).json({ error: 'CLIProxyAPI not running' }); + return; + } + + const content = await fetchCliproxyErrorLogContent(name); + if (content === null) { + res.status(404).json({ error: 'Error log not found' }); + return; + } + + res.type('text/plain').send(content); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + // ============================================ // OpenAI Compatibility Layer Routes // ============================================ diff --git a/ui/src/components/error-logs-monitor.tsx b/ui/src/components/error-logs-monitor.tsx new file mode 100644 index 00000000..4aeaf45a --- /dev/null +++ b/ui/src/components/error-logs-monitor.tsx @@ -0,0 +1,212 @@ +/** + * Error Logs Monitor Component + * + * Displays CLIProxyAPI error logs with expandable details. + * Designed to complement the AuthMonitor on the Home page. + */ + +import { useState } from 'react'; +import { useCliproxyErrorLogs, useCliproxyErrorLogContent } from '@/hooks/use-cliproxy-stats'; +import { useCliproxyStatus } from '@/hooks/use-cliproxy-stats'; +import { cn, STATUS_COLORS } from '@/lib/utils'; +import { Skeleton } from '@/components/ui/skeleton'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { + AlertTriangle, + ChevronDown, + ChevronRight, + FileWarning, + Clock, + FileText, + XCircle, +} from 'lucide-react'; + +/** Format file size in human-readable format */ +function formatSize(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + +/** Format timestamp to relative time */ +function formatRelativeTime(unixSeconds: number): string { + const diff = Math.floor(Date.now() / 1000 - unixSeconds); + if (diff < 60) return 'just now'; + if (diff < 3600) return `${Math.floor(diff / 60)}m ago`; + if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`; + return `${Math.floor(diff / 86400)}d ago`; +} + +/** Parse error log filename to extract endpoint and timestamp */ +function parseErrorLogName(name: string): { endpoint: string; timestamp: string } { + // Format: error-v1-chat-completions-2025-01-15T10-30-00.log + const match = name.match(/^error-(.+)-(\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2})\.log$/); + if (match) { + const endpoint = match[1].replace(/-/g, '/'); + const timestamp = match[2].replace(/T/, ' ').replace(/-/g, ':'); + return { endpoint: `/${endpoint}`, timestamp }; + } + return { endpoint: name, timestamp: '' }; +} + +/** Error log content viewer with syntax highlighting */ +function ErrorLogContent({ name }: { name: string }) { + const { data: content, isLoading, error } = useCliproxyErrorLogContent(name); + + if (isLoading) { + return ( +
+ + + +
+ ); + } + + if (error || !content) { + return ( +
Failed to load error log content
+ ); + } + + return ( + +
+        {content}
+      
+
+ ); +} + +export function ErrorLogsMonitor() { + const { data: status } = useCliproxyStatus(); + const { data: logs, isLoading, error } = useCliproxyErrorLogs(status?.running); + const [expandedLog, setExpandedLog] = useState(null); + + // Don't show if proxy not running or loading + if (!status?.running) { + return null; + } + + if (isLoading) { + return ( +
+
+ + +
+
+ {[1, 2, 3].map((i) => ( + + ))} +
+
+ ); + } + + // Don't show if no errors (good state) + if (!logs || logs.length === 0) { + return null; + } + + const errorCount = logs.length; + + return ( +
+ {/* Header with warning styling */} +
+
+
+ +
+ Error Logs + + {errorCount} failed request{errorCount !== 1 ? 's' : ''} + +
+
+ + CLIProxy Diagnostics +
+
+ + {/* Error logs list */} + +
+ {logs.slice(0, 10).map((log) => { + const isExpanded = expandedLog === log.name; + const { endpoint, timestamp } = parseErrorLogName(log.name); + + return ( +
+ + + {/* Expandable content */} + {isExpanded && ( +
+ +
+ )} +
+ ); + })} +
+ + {/* Show more indicator */} + {logs.length > 10 && ( +
+ Showing 10 of {logs.length} error logs +
+ )} +
+ + {/* Footer hint */} + {error && ( +
+ {error.message} +
+ )} +
+ ); +} diff --git a/ui/src/hooks/use-cliproxy-stats.ts b/ui/src/hooks/use-cliproxy-stats.ts index 3d27c7a9..3bea8b29 100644 --- a/ui/src/hooks/use-cliproxy-stats.ts +++ b/ui/src/hooks/use-cliproxy-stats.ts @@ -130,3 +130,60 @@ export function useCliproxyModels(enabled = true) { retry: 1, }); } + +/** Error log file metadata from CLIProxyAPI */ +export interface CliproxyErrorLog { + name: string; + size: number; + modified: number; +} + +/** + * Fetch CLIProxy error logs from API + */ +async function fetchCliproxyErrorLogs(): Promise { + const response = await fetch('/api/cliproxy/error-logs'); + if (!response.ok) { + const error = await response.json(); + throw new Error(error.message || 'Failed to fetch error logs'); + } + const data = await response.json(); + return data.files ?? []; +} + +/** + * Fetch specific error log content + */ +async function fetchCliproxyErrorLogContent(name: string): Promise { + const response = await fetch(`/api/cliproxy/error-logs/${encodeURIComponent(name)}`); + if (!response.ok) { + throw new Error('Failed to fetch error log content'); + } + return response.text(); +} + +/** + * Hook to get CLIProxy error logs list + */ +export function useCliproxyErrorLogs(enabled = true) { + return useQuery({ + queryKey: ['cliproxy-error-logs'], + queryFn: fetchCliproxyErrorLogs, + enabled, + refetchInterval: 30000, // Refresh every 30 seconds + retry: 1, + staleTime: 10000, + }); +} + +/** + * Hook to get specific error log content + */ +export function useCliproxyErrorLogContent(name: string | null) { + return useQuery({ + queryKey: ['cliproxy-error-log-content', name], + queryFn: () => (name ? fetchCliproxyErrorLogContent(name) : Promise.resolve('')), + enabled: !!name, + staleTime: 60000, // Cache log content for 1 minute + }); +} diff --git a/ui/src/lib/api-client.ts b/ui/src/lib/api-client.ts index 7ca955b4..9732272b 100644 --- a/ui/src/lib/api-client.ts +++ b/ui/src/lib/api-client.ts @@ -163,6 +163,16 @@ export interface ProxyProcessStatus { startedAt?: string; } +/** Error log file metadata from CLIProxyAPI */ +export interface CliproxyErrorLog { + /** Filename (e.g., "error-v1-chat-completions-2025-01-15T10-30-00.log") */ + name: string; + /** File size in bytes */ + size: number; + /** Last modified timestamp (Unix seconds) */ + modified: number; +} + /** Result from starting proxy service */ export interface ProxyStartResult { started: boolean; @@ -266,6 +276,17 @@ export const api = { body: JSON.stringify({ nickname }), }), }, + // Error logs + errorLogs: { + /** List error log files */ + list: () => request<{ files: CliproxyErrorLog[] }>('/cliproxy/error-logs'), + /** Get content of a specific error log */ + getContent: async (name: string): Promise => { + const res = await fetch(`${BASE_URL}/cliproxy/error-logs/${encodeURIComponent(name)}`); + if (!res.ok) throw new Error('Failed to load error log'); + return res.text(); + }, + }, }, accounts: { list: () => request<{ accounts: Account[]; default: string | null }>('/accounts'), diff --git a/ui/src/pages/home.tsx b/ui/src/pages/home.tsx index ccc5bf92..e864b87a 100644 --- a/ui/src/pages/home.tsx +++ b/ui/src/pages/home.tsx @@ -1,6 +1,7 @@ import { useNavigate } from 'react-router-dom'; import { HeroSection } from '@/components/hero-section'; import { AuthMonitor } from '@/components/auth-monitor'; +import { ErrorLogsMonitor } from '@/components/error-logs-monitor'; import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; import { Skeleton } from '@/components/ui/skeleton'; import { Key, Zap, Users, Activity, AlertTriangle } from 'lucide-react'; @@ -175,6 +176,9 @@ export function HomePage() { {/* Auth Monitor */} + + {/* Error Logs Monitor - shows only when there are errors */} + ); } From 1ef625ee863c517a5fbba21f16cf991bb77be7d7 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Thu, 18 Dec 2025 02:45:58 -0500 Subject: [PATCH 02/40] fix(error-logs-monitor): properly handle status loading state - Add isStatusLoading check to prevent false-negative rendering - Use nullish coalescing for enabled param to ensure boolean value - Component now waits for status before deciding visibility Fixes initial render returning null due to undefined status --- ui/src/components/error-logs-monitor.tsx | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/ui/src/components/error-logs-monitor.tsx b/ui/src/components/error-logs-monitor.tsx index 4aeaf45a..74418e3a 100644 --- a/ui/src/components/error-logs-monitor.tsx +++ b/ui/src/components/error-logs-monitor.tsx @@ -79,11 +79,15 @@ function ErrorLogContent({ name }: { name: string }) { } export function ErrorLogsMonitor() { - const { data: status } = useCliproxyStatus(); - const { data: logs, isLoading, error } = useCliproxyErrorLogs(status?.running); + const { data: status, isLoading: isStatusLoading } = useCliproxyStatus(); + const { data: logs, isLoading, error } = useCliproxyErrorLogs(status?.running ?? false); const [expandedLog, setExpandedLog] = useState(null); - // Don't show if proxy not running or loading + // Don't show while status is loading or if proxy not running + if (isStatusLoading) { + return null; + } + if (!status?.running) { return null; } From 6b9396fbc6d464bc3e3d6d3bb639e70fe5306074 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Thu, 18 Dec 2025 02:54:46 -0500 Subject: [PATCH 03/40] feat(cliproxy): set WRITABLE_PATH for log storage in ~/.ccs/cliproxy/ - Add getCliproxyWritablePath() helper function - Set WRITABLE_PATH env var when spawning CLIProxy in both executors - Logs will now be stored in ~/.ccs/cliproxy/logs/ instead of CWD - Enables error log viewer to find logs in predictable location Note: CLIProxyAPI still has hardcoded MaxBackups=0 (unlimited). Log rotation should be addressed in CLIProxyAPI upstream. --- src/cliproxy/cliproxy-executor.ts | 5 +++++ src/cliproxy/config-generator.ts | 9 +++++++++ src/cliproxy/service-manager.ts | 5 +++++ 3 files changed, 19 insertions(+) diff --git a/src/cliproxy/cliproxy-executor.ts b/src/cliproxy/cliproxy-executor.ts index bef6a2fa..1bad1d16 100644 --- a/src/cliproxy/cliproxy-executor.ts +++ b/src/cliproxy/cliproxy-executor.ts @@ -24,6 +24,7 @@ import { getProviderConfig, ensureProviderSettings, CLIPROXY_DEFAULT_PORT, + getCliproxyWritablePath, } from './config-generator'; import { isAuthenticated } from './auth-handler'; import { CLIProxyProvider, ExecutorConfig } from './types'; @@ -369,6 +370,10 @@ export async function execClaudeWithCLIProxy( proxy = spawn(binaryPath, proxyArgs, { stdio: ['ignore', 'ignore', 'ignore'], detached: true, // Persist after parent terminal closes + env: { + ...process.env, + WRITABLE_PATH: getCliproxyWritablePath(), // Logs stored in ~/.ccs/cliproxy/logs/ + }, }); // Unref so parent process can exit independently diff --git a/src/cliproxy/config-generator.ts b/src/cliproxy/config-generator.ts index 3383d313..7f572dba 100644 --- a/src/cliproxy/config-generator.ts +++ b/src/cliproxy/config-generator.ts @@ -30,6 +30,15 @@ export const CCS_INTERNAL_API_KEY = 'ccs-internal-managed'; /** Simple secret key for Control Panel login (user-facing) */ export const CCS_CONTROL_PANEL_SECRET = 'ccs'; +/** + * Get CLIProxy writable directory for logs and runtime files. + * This directory is set as WRITABLE_PATH env var when spawning CLIProxy. + * Logs will be stored in ~/.ccs/cliproxy/logs/ + */ +export function getCliproxyWritablePath(): string { + return path.join(getCcsDir(), 'cliproxy'); +} + /** * Config version - bump when config format changes to trigger regeneration * v1: Initial config (port, auth-dir, api-keys only) diff --git a/src/cliproxy/service-manager.ts b/src/cliproxy/service-manager.ts index a515dcc0..f6977a07 100644 --- a/src/cliproxy/service-manager.ts +++ b/src/cliproxy/service-manager.ts @@ -19,6 +19,7 @@ import { regenerateConfig, configNeedsRegeneration, CLIPROXY_DEFAULT_PORT, + getCliproxyWritablePath, } from './config-generator'; import { isCliproxyRunning } from './stats-fetcher'; @@ -171,6 +172,10 @@ export async function ensureCliproxyService( proxyProcess = spawn(binaryPath, proxyArgs, { stdio: ['ignore', verbose ? 'pipe' : 'ignore', verbose ? 'pipe' : 'ignore'], detached: true, // Allow process to run independently + env: { + ...process.env, + WRITABLE_PATH: getCliproxyWritablePath(), // Logs stored in ~/.ccs/cliproxy/logs/ + }, }); // Forward output in verbose mode From 126cffc6dcf434abeee883a4109d3705cdb92a67 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Thu, 18 Dec 2025 16:52:57 -0500 Subject: [PATCH 04/40] refactor: remove deprecated native shell installers - Remove installers/ directory (install.sh, install.ps1, uninstall.sh, uninstall.ps1) - Update CloudFlare worker to 301 redirect /install* to npm docs - Remove detectInstallationMethod() - npm is now only install method - Simplify update-command.ts to npm-only updates - Clean up tests to remove direct install references BREAKING CHANGE: Native shell installers (curl/irm) no longer work. Use `npm install -g @kaitranntt/ccs` instead. --- installers/install.ps1 | 808 --------------- installers/install.sh | 927 ------------------ installers/uninstall.ps1 | 99 -- installers/uninstall.sh | 95 -- scripts/worker.js | 56 +- src/ccs.ts | 7 +- src/commands/update-command.ts | 165 +--- src/utils/package-manager-detector.ts | 66 +- .../update-command-beta-channel.test.js | 114 +-- .../update-command-force-reinstall.test.js | 99 +- 10 files changed, 51 insertions(+), 2385 deletions(-) delete mode 100644 installers/install.ps1 delete mode 100755 installers/install.sh delete mode 100644 installers/uninstall.ps1 delete mode 100755 installers/uninstall.sh diff --git a/installers/install.ps1 b/installers/install.ps1 deleted file mode 100644 index 27a4a0f1..00000000 --- a/installers/install.ps1 +++ /dev/null @@ -1,808 +0,0 @@ -# CCS Installation Script (v4.5.0) - Windows PowerShell - DEPRECATED -# DEPRECATED: This installer is deprecated. Use npm instead. -# Bootstrap-based: Installs lightweight shell wrappers (LEGACY) -# Requires: Node.js 14+ (npm recommended) -# https://github.com/kaitranntt/ccs - -param( - [string]$InstallDir = "$env:USERPROFILE\.ccs" -) - -$ErrorActionPreference = "Stop" - -# --- Deprecation Notice --- -Write-Host "" -Write-Host "=======================================================================" -ForegroundColor Yellow -Write-Host " " -ForegroundColor Yellow -Write-Host " [!] DEPRECATION NOTICE " -ForegroundColor Yellow -Write-Host " " -ForegroundColor Yellow -Write-Host " Native shell installers are deprecated and will be removed " -ForegroundColor Yellow -Write-Host " in a future version. Please use npm installation instead: " -ForegroundColor Yellow -Write-Host " " -ForegroundColor Yellow -Write-Host " npm install -g @kaitranntt/ccs " -ForegroundColor Yellow -Write-Host " " -ForegroundColor Yellow -Write-Host " Proceeding with legacy install (auto-runs npm if available)... " -ForegroundColor Yellow -Write-Host " " -ForegroundColor Yellow -Write-Host "=======================================================================" -ForegroundColor Yellow -Write-Host "" -Start-Sleep -Seconds 3 - -# --- Auto-redirect to npm installation --- -if (Get-Command npm -ErrorAction SilentlyContinue) { - Write-Host "[i] Node.js detected, using npm installation (recommended)..." -ForegroundColor Cyan - Write-Host "" - - npm install -g "@kaitranntt/ccs" - - if ($LASTEXITCODE -eq 0) { - Write-Host "" - Write-Host "[OK] CCS installed via npm successfully!" -ForegroundColor Green - Write-Host "" - Write-Host "Quick start:" - Write-Host " ccs # Use Claude (default)" - Write-Host " ccs glm # Use GLM" - Write-Host " ccs --help # Show all commands" - Write-Host "" - exit 0 - } else { - Write-Host "" - Write-Host "[!] npm installation failed. Falling back to legacy install..." -ForegroundColor Yellow - Write-Host "" - Start-Sleep -Seconds 2 - } -} else { - Write-Host "[!] npm not found. Falling back to legacy install..." -ForegroundColor Yellow - Write-Host "[!] Install Node.js from https://nodejs.org for the recommended method." -ForegroundColor Yellow - Write-Host "" - Start-Sleep -Seconds 2 -} - -# Continue with legacy PowerShell installation... - -# Configuration -$CcsDir = "$env:USERPROFILE\.ccs" -$ClaudeDir = "$env:USERPROFILE\.claude" -$GlmModel = "glm-4.6" -$KimiModel = "kimi-for-coding" - -# Detect if running from git repository or standalone -$ScriptDir = if ($MyInvocation.MyCommand.Path) { - Split-Path -Parent $MyInvocation.MyCommand.Path -} else { - # Running via irm | iex (in-memory, no file path) - $null -} - -$InstallMethod = if ($ScriptDir -and ((Test-Path "$ScriptDir\lib\ccs.ps1") -or (Test-Path "$ScriptDir\..\lib\ccs.ps1"))) { - "git" -} else { - "standalone" -} - -# Version configuration -# IMPORTANT: Update this version when releasing new versions! -# This hardcoded version is used for standalone installations (irm | iex) -# For git installations, VERSION file is read if available -$CcsVersion = "6.5.0" - -# Try to read VERSION file for git installations -if ($ScriptDir) { - $VersionFile = if (Test-Path "$ScriptDir\VERSION") { - "$ScriptDir\VERSION" - } elseif (Test-Path "$ScriptDir\..\VERSION") { - "$ScriptDir\..\VERSION" - } else { - $null - } - - if ($VersionFile -and (Test-Path $VersionFile)) { - $CcsVersion = (Get-Content $VersionFile -Raw).Trim() - } -} - -# --- Color/Format Functions --- -function Write-Critical { - param([string]$Message) - Write-Host "" - Write-Host "╔═════════════════════════════════════════════╗" -ForegroundColor Red - Write-Host "║ ACTION REQUIRED ║" -ForegroundColor Red - Write-Host "╚═════════════════════════════════════════════╝" -ForegroundColor Red - Write-Host "" - Write-Host $Message -ForegroundColor Red - Write-Host "" -} - -function Write-WarningMsg { - param([string]$Message) - Write-Host "" - Write-Host "[!] WARNING" -ForegroundColor Yellow - Write-Host $Message -ForegroundColor Yellow - Write-Host "" -} - -function Write-Success { - param([string]$Message) - Write-Host "[OK] $Message" -ForegroundColor Green -} - -function Write-Info { - param([string]$Message) - Write-Host "[i] $Message" -} - -function Write-Section { - param([string]$Title) - Write-Host "" - Write-Host "===== $Title =====" -ForegroundColor Cyan - Write-Host "" -} - -# --- Node.js Detection (v4.5) --- -function Test-NodeJs { - $MIN_VERSION = 14 - - if (-not (Get-Command node -ErrorAction SilentlyContinue)) { - Write-WarningMsg @" -Node.js not found - -CCS v4.5+ requires Node.js 14+ to run. -The bootstrap scripts will check and install the npm package on first use. - -Install Node.js: https://nodejs.org (LTS recommended) - -Installation will continue, but 'ccs' will not work until Node.js is installed. -"@ - return $false - } - - $nodeVersion = (node -v) -replace 'v', '' - $nodeMajor = [int]($nodeVersion -split '\.')[0] - if ($nodeMajor -lt $MIN_VERSION) { - Write-WarningMsg @" -Node.js 14+ required (found: $(node -v)) - -CCS v4.5+ requires Node.js 14 or newer. -Upgrade from: https://nodejs.org - -Installation will continue, but 'ccs' may not work correctly. -"@ - return $false - } - - Write-Success "Node.js $(node -v) detected" - return $true -} - -# Helper Functions - -function Detect-CurrentProvider { - $SettingsFile = "$ClaudeDir\settings.json" - if (-not (Test-Path $SettingsFile)) { - return "unknown" - } - - try { - $Content = Get-Content $SettingsFile -Raw - if ($Content -match "api\.kimi\.com|kimi-for-coding") { - return "kimi" - } elseif ($Content -match "api\.z\.ai|glm-4") { - return "glm" - } elseif ($Content -match "ANTHROPIC_BASE_URL" -and $Content -notmatch "api\.z\.ai|api\.kimi\.com") { - return "custom" - } else { - return "claude" - } - } catch { - return "unknown" - } -} - -function New-GlmTemplate { - $Template = @{ - env = @{ - ANTHROPIC_BASE_URL = "https://api.z.ai/api/anthropic" - ANTHROPIC_AUTH_TOKEN = "YOUR_GLM_API_KEY_HERE" - ANTHROPIC_MODEL = $GlmModel - ANTHROPIC_DEFAULT_OPUS_MODEL = $GlmModel - ANTHROPIC_DEFAULT_SONNET_MODEL = $GlmModel - ANTHROPIC_DEFAULT_HAIKU_MODEL = $GlmModel - } - } - return $Template | ConvertTo-Json -Depth 10 -} - -function New-GlmProfile { - param([string]$Provider) - - $CurrentSettings = "$ClaudeDir\settings.json" - $GlmSettings = "$CcsDir\glm.settings.json" - - if ($Provider -eq "glm" -and (Test-Path $CurrentSettings)) { - Write-Host "[OK] Copying current GLM config to profile..." - - try { - $Config = Get-Content $CurrentSettings -Raw | ConvertFrom-Json - if (-not $Config.env) { - $Config | Add-Member -NotePropertyName env -NotePropertyValue @{} -Force - } - $Config.env | Add-Member -NotePropertyName ANTHROPIC_DEFAULT_OPUS_MODEL -NotePropertyValue $GlmModel -Force - $Config.env | Add-Member -NotePropertyName ANTHROPIC_DEFAULT_SONNET_MODEL -NotePropertyValue $GlmModel -Force - $Config.env | Add-Member -NotePropertyName ANTHROPIC_DEFAULT_HAIKU_MODEL -NotePropertyValue $GlmModel -Force - - $Config | ConvertTo-Json -Depth 10 | Set-Content $GlmSettings - Write-Host " Created: $GlmSettings with your existing API key + enhanced settings" - } catch { - Write-Host " [i] Copying current settings failed, using template" - New-GlmTemplate | Set-Content $GlmSettings - } - } else { - Write-Host "Creating GLM profile template at $GlmSettings" - New-GlmTemplate | Set-Content $GlmSettings - Write-Host " Created: $GlmSettings" - Write-Host " [!] Edit this file and replace YOUR_GLM_API_KEY_HERE with your actual GLM API key" - } -} - -function New-KimiTemplate { - $Template = @{ - env = @{ - ANTHROPIC_BASE_URL = "https://api.kimi.com/coding/" - ANTHROPIC_AUTH_TOKEN = "YOUR_KIMI_API_KEY_HERE" - ANTHROPIC_MODEL = $KimiModel - ANTHROPIC_SMALL_FAST_MODEL = $KimiModel - ANTHROPIC_DEFAULT_OPUS_MODEL = $KimiModel - ANTHROPIC_DEFAULT_SONNET_MODEL = $KimiModel - ANTHROPIC_DEFAULT_HAIKU_MODEL = $KimiModel - } - alwaysThinkingEnabled = $true - } - return $Template | ConvertTo-Json -Depth 10 -} - -function New-KimiProfile { - param([string]$Provider) - - $CurrentSettings = "$ClaudeDir\settings.json" - $KimiSettings = "$CcsDir\kimi.settings.json" - - if ($Provider -eq "kimi" -and (Test-Path $CurrentSettings)) { - Write-Host "[OK] Copying current Kimi config to profile..." - - try { - $Config = Get-Content $CurrentSettings -Raw | ConvertFrom-Json - if (-not $Config.env) { - $Config | Add-Member -NotePropertyName env -NotePropertyValue @{} -Force - } - $Config.env | Add-Member -NotePropertyName ANTHROPIC_SMALL_FAST_MODEL -NotePropertyValue $KimiModel -Force - $Config.env | Add-Member -NotePropertyName ANTHROPIC_DEFAULT_OPUS_MODEL -NotePropertyValue $KimiModel -Force - $Config.env | Add-Member -NotePropertyName ANTHROPIC_DEFAULT_SONNET_MODEL -NotePropertyValue $KimiModel -Force - $Config.env | Add-Member -NotePropertyName ANTHROPIC_DEFAULT_HAIKU_MODEL -NotePropertyValue $KimiModel -Force - $Config | Add-Member -NotePropertyName alwaysThinkingEnabled -NotePropertyValue $true -Force - - $Config | ConvertTo-Json -Depth 10 | Set-Content $KimiSettings - Write-Host " Created: $KimiSettings with your existing API key + enhanced settings" - } catch { - Write-Host " [i] Copying current settings failed, using template" - New-KimiTemplate | Set-Content $KimiSettings - } - } else { - Write-Host "Creating Kimi profile template at $KimiSettings" - New-KimiTemplate | Set-Content $KimiSettings - Write-Host " Created: $KimiSettings" - Write-Host " [!] Edit this file and replace YOUR_KIMI_API_KEY_HERE with your actual Kimi API key" - } -} - -function Install-ClaudeFolder { - param( - [string]$SourceDir - ) - - $TargetDir = "$CcsDir\.claude" - - # Check if already exists - if (Test-Path $TargetDir) { - Write-Host "| [i] .claude/ folder already exists, skipping" - return $true - } - - # Create directory structure - $null = New-Item -ItemType Directory -Force -Path "$TargetDir\commands" - $null = New-Item -ItemType Directory -Force -Path "$TargetDir\skills\ccs-delegation\references" - - if ($InstallMethod -eq "git" -and $SourceDir) { - # Copy from local git repo - $SourceClaudeDir = Join-Path $SourceDir ".claude" - if (Test-Path $SourceClaudeDir) { - try { - Copy-Item -Path "$SourceClaudeDir\*" -Destination $TargetDir -Recurse -Force - Write-Host "| [OK] Installed .claude/ folder" - return $true - } catch { - Write-Host "| [!] Failed to copy .claude/ folder" - return $false - } - } else { - Write-Host "| [!] .claude/ folder not found in source" - return $false - } - } else { - # Standalone: download from GitHub - try { - $BaseUrl = "https://raw.githubusercontent.com/kaitranntt/ccs/main/.claude" - - Invoke-WebRequest -Uri "$BaseUrl/commands/ccs.md" ` - -OutFile "$TargetDir\commands\ccs.md" -UseBasicParsing - Invoke-WebRequest -Uri "$BaseUrl/skills/ccs-delegation/SKILL.md" ` - -OutFile "$TargetDir\skills\ccs-delegation\SKILL.md" -UseBasicParsing - Invoke-WebRequest -Uri "$BaseUrl/skills/ccs-delegation/references/delegation-patterns.md" ` - -OutFile "$TargetDir\skills\ccs-delegation\references\delegation-patterns.md" -UseBasicParsing - - Write-Host "| [OK] Downloaded .claude/ folder" - return $true - } catch { - Write-Host "| [!] Failed to download .claude/ folder" - return $false - } - } -} - -# Main Installation - -# Check Node.js requirement (warn if missing, continue anyway) -$null = Test-NodeJs - -Write-Host '===== Installing CCS (Windows) =====' - -# Create directories -New-Item -ItemType Directory -Force -Path $CcsDir | Out-Null - -# Install main executable -if ($InstallMethod -eq "standalone") { - # Standalone install - download from GitHub - Write-Host "| Downloading CCS from GitHub..." - - try { - $BaseUrl = "https://raw.githubusercontent.com/kaitranntt/ccs/main" - Invoke-WebRequest -Uri "$BaseUrl/lib/ccs.ps1" -OutFile "$CcsDir\ccs.ps1" -UseBasicParsing - Write-Host "| [OK] Downloaded ccs.ps1" - - # Note: Shell dependencies (error-codes.ps1, progress-indicator.ps1, prompt.ps1) no longer needed - # Bootstrap delegates all functionality to Node.js via npx - - # Download shell completion files - $CompletionsDir = "$CcsDir\completions" - if (-not (Test-Path $CompletionsDir)) { - New-Item -ItemType Directory -Path $CompletionsDir -Force | Out-Null - } - - try { - Invoke-WebRequest -Uri "$BaseUrl/scripts/completion/ccs.ps1" -OutFile "$CompletionsDir\ccs.ps1" -UseBasicParsing - Write-Host "| [OK] Downloaded completion files" - } catch { - Write-Host "| [!] Warning: Failed to download completion files" - } - } catch { - Write-Host "|" - Write-Host "[X] Error: Failed to download ccs.ps1 from GitHub" -ForegroundColor Red - Write-Host " $_" - return - } -} else { - # Git install - copy local file - $CcsPs1Path = if (Test-Path "$ScriptDir\lib\ccs.ps1") { - "$ScriptDir\lib\ccs.ps1" - } elseif (Test-Path "$ScriptDir\..\lib\ccs.ps1") { - "$ScriptDir\..\lib\ccs.ps1" - } else { - throw "lib\ccs.ps1 not found" - } - Copy-Item $CcsPs1Path "$CcsDir\ccs.ps1" -Force - Write-Host "| [OK] Installed ccs.ps1" - - # Note: Shell dependencies (error-codes.ps1, progress-indicator.ps1, prompt.ps1) no longer needed - # Bootstrap delegates all functionality to Node.js via npx - - # Copy shell completion files - $CompletionsDir = "$CcsDir\completions" - if (-not (Test-Path $CompletionsDir)) { - New-Item -ItemType Directory -Path $CompletionsDir -Force | Out-Null - } - - $SourceCompletionDir = if (Test-Path "$ScriptDir\scripts\completion") { - "$ScriptDir\scripts\completion" - } elseif (Test-Path "$ScriptDir\..\scripts\completion") { - "$ScriptDir\..\scripts\completion" - } else { - $null - } - - if ($SourceCompletionDir -and (Test-Path "$SourceCompletionDir\ccs.ps1")) { - Copy-Item "$SourceCompletionDir\ccs.ps1" "$CompletionsDir\ccs.ps1" -Force -ErrorAction SilentlyContinue - Write-Host "| [OK] Copied completion files" - } -} - -# Install uninstall script as ccs-uninstall.ps1 -if ($ScriptDir -and (Test-Path "$ScriptDir\uninstall.ps1")) { - # Copy uninstall.ps1 as ccs-uninstall.ps1 (similar to Linux symlink approach) - if ($ScriptDir -ne $CcsDir) { - Copy-Item "$ScriptDir\uninstall.ps1" "$CcsDir\ccs-uninstall.ps1" -Force - } - # Clean up old uninstall.ps1 from previous installations - if (Test-Path "$CcsDir\uninstall.ps1") { - Remove-Item "$CcsDir\uninstall.ps1" -Force -ErrorAction SilentlyContinue - } - Write-Host "| [OK] Installed uninstaller" -} elseif ($InstallMethod -eq "standalone") { - try { - $BaseUrl = "https://raw.githubusercontent.com/kaitranntt/ccs/main" - # Download uninstall.ps1 as ccs-uninstall.ps1 - Invoke-WebRequest -Uri "$BaseUrl/installers/uninstall.ps1" -OutFile "$CcsDir\ccs-uninstall.ps1" -UseBasicParsing - # Clean up old uninstall.ps1 from previous installations - if (Test-Path "$CcsDir\uninstall.ps1") { - Remove-Item "$CcsDir\uninstall.ps1" -Force -ErrorAction SilentlyContinue - } - Write-Host "| [OK] Installed uninstaller" - } catch { - Write-Host "| [!] Could not download uninstaller (optional)" - } -} - -Write-Host "| [OK] Created directories" - -# Install .claude/ folder -if ($InstallMethod -eq "git" -and $ScriptDir) { - $ParentDir = Split-Path -Parent $ScriptDir - $null = Install-ClaudeFolder -SourceDir $ParentDir -} else { - $null = Install-ClaudeFolder -SourceDir "" -} - -Write-Host "=========================================" -Write-Host "" - -# Profile Setup - -$CurrentProvider = Detect-CurrentProvider - -$ProviderLabel = switch ($CurrentProvider) { - "glm" { ' (detected: GLM)' } - "kimi" { ' (detected: Kimi)' } - "claude" { ' (detected: Claude)' } - "custom" { ' (detected: custom)' } - default { "" } -} - -Write-Host "===== Configuring Profiles v$CcsVersion$ProviderLabel" - -# Backup existing config (single backup, no timestamp) -$ConfigFile = "$CcsDir\config.json" -$BackupFile = "$CcsDir\config.json.backup" -if (Test-Path $ConfigFile) { - Copy-Item $ConfigFile $BackupFile -Force -} - -$NeedsGlmKey = $false -$GlmSettings = "$CcsDir\glm.settings.json" - -# Create GLM profile if missing -if (-not (Test-Path $GlmSettings)) { - New-GlmProfile -Provider $CurrentProvider - if ($CurrentProvider -ne "glm") { - $NeedsGlmKey = $true - } -} else { - Write-Host '| [OK] GLM profile exists' -} - -$NeedsKimiKey = $false -$KimiSettings = "$CcsDir\kimi.settings.json" - -# Create Kimi profile if missing -if (-not (Test-Path $KimiSettings)) { - New-KimiProfile -Provider $CurrentProvider - if ($CurrentProvider -ne "kimi") { - $NeedsKimiKey = $true - } -} else { - Write-Host '| [OK] Kimi profile exists' -} - -# Create config if missing -if (-not (Test-Path $ConfigFile)) { - $ConfigContent = @{ - profiles = @{ - glm = "~/.ccs/glm.settings.json" - kimi = "~/.ccs/kimi.settings.json" - default = "~/.claude/settings.json" - } - } - $ConfigContent | ConvertTo-Json -Depth 10 | Set-Content $ConfigFile - Write-Host ('| OK: Config created at {0}\.ccs\config.json' -f $env:USERPROFILE) -} - -# Validate config JSON -if (Test-Path $ConfigFile) { - try { - $null = Get-Content $ConfigFile -Raw | ConvertFrom-Json - } catch { - Write-Host '| [!] Warning: Invalid JSON in config.json' -ForegroundColor Yellow - if (Test-Path $BackupFile) { - Write-Host ('| Restore from: {0}' -f $BackupFile) - } - } -} - -# Validate GLM settings JSON -if (Test-Path $GlmSettings) { - try { - $null = Get-Content $GlmSettings -Raw | ConvertFrom-Json - } catch { - Write-Host '| [!] Warning: Invalid JSON in glm.settings.json' -ForegroundColor Yellow - } -} - -Write-Host "=========================================" -Write-Host "" - -# Detect circular symlink -function Test-CircularSymlink { - param( - [string]$Target, - [string]$LinkPath - ) - - # Check if target exists and is symlink - if (-not (Test-Path $Target)) { - return $false - } - - try { - $Item = Get-Item $Target -ErrorAction Stop - if ($Item.LinkType -ne "SymbolicLink") { - return $false - } - - # Resolve target's link - $TargetLink = $Item.Target - $SharedDir = "$env:USERPROFILE\.ccs\shared" - - # Check if target points back to our shared dir - if ($TargetLink -like "$SharedDir*" -or $TargetLink -eq $LinkPath) { - Write-Host "[!] Circular symlink detected: $Target → $TargetLink" - return $true - } - } catch { - return $false - } - - return $false -} - -# Setup shared directories as symlinks to ~/.claude/ (v3.2.0) -function Initialize-SharedSymlinks { - $SharedDir = "$CcsDir\shared" - $ClaudeDir = "$env:USERPROFILE\.claude" - - # Create ~/.claude/ if missing - if (-not (Test-Path $ClaudeDir)) { - Write-Host "[i] Creating ~/.claude/ directory structure" - New-Item -ItemType Directory -Path $ClaudeDir -Force | Out-Null - @('commands', 'skills', 'agents') | ForEach-Object { - New-Item -ItemType Directory -Path "$ClaudeDir\$_" -Force | Out-Null - } - } - - # Create shared directory - if (-not (Test-Path $SharedDir)) { - New-Item -ItemType Directory -Path $SharedDir -Force | Out-Null - } - - # Create symlinks ~/.ccs/shared/* → ~/.claude/* - foreach ($Dir in @('commands', 'skills', 'agents')) { - $ClaudePath = "$ClaudeDir\$Dir" - $SharedPath = "$SharedDir\$Dir" - - # Create directory in ~/.claude/ if missing - if (-not (Test-Path $ClaudePath)) { - New-Item -ItemType Directory -Path $ClaudePath -Force | Out-Null - } - - # Check for circular symlink - if (Test-CircularSymlink -Target $ClaudePath -LinkPath $SharedPath) { - Write-Host "[!] Skipping $Dir`: circular symlink detected" - continue - } - - # If already correct symlink, skip - if (Test-Path $SharedPath) { - try { - $Item = Get-Item $SharedPath -ErrorAction Stop - if ($Item.LinkType -eq "SymbolicLink") { - $CurrentTarget = $Item.Target - if ($CurrentTarget -eq $ClaudePath) { - continue # Already correct - } - } - # Backup existing data before replacing - if ((Get-ChildItem $SharedPath -ErrorAction SilentlyContinue).Count -gt 0) { - Write-Host "[i] Migrating existing $Dir to ~/.claude/$Dir" - Get-ChildItem $SharedPath -ErrorAction SilentlyContinue | ForEach-Object { - $DestPath = Join-Path $ClaudePath $_.Name - if (-not (Test-Path $DestPath)) { - Copy-Item $_.FullName $DestPath -Recurse -ErrorAction SilentlyContinue - } - } - } - Remove-Item $SharedPath -Recurse -Force -ErrorAction SilentlyContinue - } catch { - # Continue to recreate - } - } - - # Create symlink (requires Developer Mode or admin) - try { - New-Item -ItemType SymbolicLink -Path $SharedPath -Target $ClaudePath -Force -ErrorAction Stop | Out-Null - } catch { - Write-Host "[!] Symlink failed for $Dir, copying instead (enable Developer Mode)" - if (-not (Test-Path $SharedPath)) { - New-Item -ItemType Directory -Path $SharedPath -Force | Out-Null - } - if (Test-Path $ClaudePath) { - Copy-Item "$ClaudePath\*" $SharedPath -Recurse -ErrorAction SilentlyContinue - } - } - } -} - -Write-Host "[i] Setting up shared directories..." -Initialize-SharedSymlinks -Write-Host "" - -# Install CCS items to ~/.claude/ via symlinks (v4.1.0) -Write-Host "[i] Installing CCS items to ~/.claude/..." -if (Get-Command node -ErrorAction SilentlyContinue) { - # Check if .claude/ was successfully installed - if (Test-Path "$CcsDir\.claude") { - # Download or copy claude-symlink-manager.js - $UtilsDir = "$CcsDir\bin\utils" - if (-not (Test-Path $UtilsDir)) { - New-Item -ItemType Directory -Path $UtilsDir -Force | Out-Null - } - - if ($InstallMethod -eq "git" -and $ScriptDir) { - # Git install - copy from local repo - $SourcePath = $null - if (Test-Path "$ScriptDir\..\bin\utils\claude-symlink-manager.js") { - $SourcePath = "$ScriptDir\..\bin\utils\claude-symlink-manager.js" - } elseif (Test-Path "$ScriptDir\bin\utils\claude-symlink-manager.js") { - $SourcePath = "$ScriptDir\bin\utils\claude-symlink-manager.js" - } - - if ($SourcePath) { - Copy-Item $SourcePath "$UtilsDir\claude-symlink-manager.js" -Force - } - } else { - # Standalone install - download from GitHub - try { - Invoke-WebRequest -Uri "https://raw.githubusercontent.com/kaitranntt/ccs/main/bin/utils/claude-symlink-manager.js" ` - -OutFile "$UtilsDir\claude-symlink-manager.js" -UseBasicParsing - } catch { - Write-Host "[!] Failed to download claude-symlink-manager.js" - } - } - - # Call ClaudeSymlinkManager if available - if (Test-Path "$UtilsDir\claude-symlink-manager.js") { - try { - $scriptBlock = @" - try { - const ClaudeSymlinkManager = require('$($UtilsDir -replace '\\', '/')/claude-symlink-manager.js'); - const manager = new ClaudeSymlinkManager(); - manager.install(); - } catch (err) { - console.log('[!] CCS item installation warning: ' + err.message); - console.log(' Run "ccs sync" to retry'); - } -"@ - node -e $scriptBlock 2>$null - if (-not $?) { - Write-Host "[!] CCS item installation skipped (run 'ccs sync' later)" - } - } catch { - Write-Host "[!] CCS item installation failed: $($_.Exception.Message)" - Write-Host " Run 'ccs sync' after installation to complete setup" - } - } else { - Write-Host "[!] claude-symlink-manager.js not found, skipping" - Write-Host " Run 'ccs sync' after installation to complete setup" - } - } else { - Write-Host "[!] .claude/ folder not found, skipping CCS item installation" - } -} else { - Write-Host "[!] Node.js not found, skipping CCS item installation" - Write-Host " Install Node.js and run 'ccs sync' to complete setup" -} -Write-Host "" -Write-Host "[i] Note: Windows symlink support requires Developer Mode (v4.2 will add fallback)" -Write-Host "" - -# Check and update PATH -$UserPath = [Environment]::GetEnvironmentVariable("Path", [System.EnvironmentVariableTarget]::User) -if ($UserPath -notlike "*$CcsDir*") { - Write-Host "[!] PATH Configuration Required" - Write-Host "" - Write-Host " Adding $CcsDir to your PATH..." - - try { - $NewPath = if ($UserPath) { "$UserPath;$CcsDir" } else { $CcsDir } - [Environment]::SetEnvironmentVariable("Path", $NewPath, [System.EnvironmentVariableTarget]::User) - - Write-Host " [OK] PATH updated. Restart your terminal for changes to take effect." - Write-Host "" - } catch { - Write-Host " [X] Could not update PATH automatically." -ForegroundColor Yellow - Write-Host " Please add manually: $CcsDir" - Write-Host "" - } -} - -# Show API key warning if needed -if ($NeedsGlmKey) { - Write-Critical @" -Configure GLM API Key: - - 1. Get API key from: https://api.z.ai - - 2. Edit: $env:USERPROFILE\.ccs\glm.settings.json - - 3. Replace: YOUR_GLM_API_KEY_HERE - With your actual API key - - 4. Test: ccs glm --version -"@ -} - -# Show API key warning for Kimi if needed -if ($NeedsKimiKey) { - Write-Critical @" -Configure Kimi API Key: - - 1. Get API key from: https://www.kimi.com/coding - - 2. Edit: $env:USERPROFILE\.ccs\kimi.settings.json - - 3. Replace: YOUR_KIMI_API_KEY_HERE - With your actual API key - - 4. Test: ccs kimi --version -"@ -} - -Write-Success "CCS installed successfully!" -Write-Host "" -Write-Host " Installed components:" -Write-Host " * ccs command -> $CcsDir\ccs.ps1" -Write-Host " * config -> $CcsDir\config.json" -Write-Host " * glm profile -> $CcsDir\glm.settings.json" -Write-Host " * kimi profile -> $CcsDir\kimi.settings.json" -Write-Host " * .claude/ folder -> $CcsDir\.claude\" -Write-Host "" -Write-Host " Requirements:" -$nodeVer = if (Get-Command node -ErrorAction SilentlyContinue) { node -v } else { "NOT FOUND" } -Write-Host " * Node.js 14+ (detected: $nodeVer)" -Write-Host " * npm 5.2+ (for npx, comes with Node.js 8.2+)" -Write-Host "" -Write-Host " First Run:" -Write-Host " The first time you run 'ccs', it will automatically install" -Write-Host " the @kaitranntt/ccs npm package globally via npx." -Write-Host "" -Write-Host " Quick start:" -Write-Host " ccs # Use Claude subscription (default)" -Write-Host " ccs glm # Use GLM fallback" -Write-Host " ccs kimi # Use Kimi for Coding" -Write-Host "" -Write-Host " To uninstall: ccs-uninstall" -Write-Host "" diff --git a/installers/install.sh b/installers/install.sh deleted file mode 100755 index 0b56b411..00000000 --- a/installers/install.sh +++ /dev/null @@ -1,927 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# ============================================================================ -# CCS Installation Script (v4.5.0) - DEPRECATED -# DEPRECATED: This installer is deprecated. Use npm instead. -# Bootstrap-based: Installs lightweight shell wrappers (LEGACY) -# Requires: Node.js 14+ (npm recommended) -# ============================================================================ - -# --- Deprecation Notice --- -echo "" -echo "=======================================================================" -echo " " -echo " [!] DEPRECATION NOTICE " -echo " " -echo " Native shell installers are deprecated and will be removed " -echo " in a future version. Please use npm installation instead: " -echo " " -echo " npm install -g @kaitranntt/ccs " -echo " " -echo " Proceeding with legacy install (auto-runs npm if available)... " -echo " " -echo "=======================================================================" -echo "" -sleep 3 # Give users time to read - -# --- Auto-redirect to npm installation --- -if command -v npm &> /dev/null; then - echo "[i] Node.js detected, using npm installation (recommended)..." - echo "" - npm install -g @kaitranntt/ccs - exit_code=$? - - if [ $exit_code -eq 0 ]; then - echo "" - echo "[OK] CCS installed via npm successfully!" - echo "" - echo "Quick start:" - echo " ccs # Use Claude (default)" - echo " ccs glm # Use GLM" - echo " ccs --help # Show all commands" - echo "" - exit 0 - else - echo "" - echo "[!] npm installation failed. Falling back to legacy install..." - echo "" - sleep 2 - fi -else - echo "[!] npm not found. Falling back to legacy install..." - echo "[!] Install Node.js from https://nodejs.org for the recommended method." - echo "" - sleep 2 -fi - -# Continue with legacy bash installation... - -# --- Configuration --- -INSTALL_DIR="${INSTALL_DIR:-$HOME/.local/bin}" -CCS_DIR="$HOME/.ccs" -CLAUDE_DIR="$HOME/.claude" -GLM_MODEL="glm-4.6" -KIMI_MODEL="kimi-k2-thinking-turbo" - -# Resolve script directory (handles both file-based and piped execution) -if [[ -n "${BASH_SOURCE[0]:-}" ]]; then - SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -else - SCRIPT_DIR="$(cd "$(dirname "${0:-$PWD}")" && pwd)" -fi - -# Detect installation method (git vs standalone) -# Check if ccs executable exists in SCRIPT_DIR or parent (real git install) -# Don't just check .git (user might run curl | bash inside their own git repo) -if [[ -f "$SCRIPT_DIR/lib/ccs" ]] || [[ -f "$SCRIPT_DIR/../lib/ccs" ]]; then - INSTALL_METHOD="git" -else - INSTALL_METHOD="standalone" -fi - -# Version configuration -# IMPORTANT: Update this version when releasing new versions! -# This hardcoded version is used for standalone installations (curl | bash) -# For git installations, VERSION file is read if available -CCS_VERSION="6.5.0" - -# Try to read VERSION file for git installations -if [[ -f "$SCRIPT_DIR/VERSION" ]]; then - CCS_VERSION="$(cat "$SCRIPT_DIR/VERSION" | tr -d '\n' | tr -d '\r')" -elif [[ -f "$SCRIPT_DIR/../VERSION" ]]; then - CCS_VERSION="$(cat "$SCRIPT_DIR/../VERSION" | tr -d '\n' | tr -d '\r')" -fi - -# --- Platform Detection --- -# Detect platform and redirect to Windows installer if needed -detect_platform() { - case "$OSTYPE" in - msys*|mingw*|cygwin*|win32*) - echo "windows" - ;; - *) - echo "unix" - ;; - esac -} - -PLATFORM=$(detect_platform) - -if [[ "$PLATFORM" == "windows" ]]; then - echo "Windows detected. Using PowerShell installer..." - - if [[ -f "$SCRIPT_DIR/install.ps1" ]]; then - powershell.exe -ExecutionPolicy Bypass -File "$SCRIPT_DIR/install.ps1" - exit $? - else - echo "Error: install.ps1 not found." - echo "Please download the full CCS package from:" - echo " https://github.com/kaitranntt/ccs" - exit 1 - fi -fi - -# Continue with Unix installation... - -# --- Helper Functions --- - -detect_current_provider() { - local settings="$CLAUDE_DIR/settings.json" - if [[ ! -f "$settings" ]]; then - echo "unknown" - return - fi - - if grep -q "api.kimi.com\|kimi-for-coding" "$settings" 2>/dev/null; then - echo "kimi" - elif grep -q "api.z.ai\|glm-4" "$settings" 2>/dev/null; then - echo "glm" - elif grep -q "ANTHROPIC_BASE_URL" "$settings" 2>/dev/null && ! grep -q "api.z.ai\|api.kimi.com" "$settings" 2>/dev/null; then - echo "custom" - else - echo "claude" - fi -} - -# --- Color/Format Functions (ANSI) --- -setup_colors() { - if [[ -t 1 ]] && [[ -z "${NO_COLOR:-}" ]]; then - RED='\033[0;31m' - GREEN='\033[0;32m' - YELLOW='\033[1;33m' - CYAN='\033[0;36m' - BOLD='\033[1m' - RESET='\033[0m' - else - RED='' GREEN='' YELLOW='' CYAN='' BOLD='' RESET='' - fi -} - -msg_critical() { - echo "" >&2 - echo -e "${RED}${BOLD}╔═════════════════════════════════════════════╗${RESET}" >&2 - echo -e "${RED}${BOLD}║ ACTION REQUIRED ║${RESET}" >&2 - echo -e "${RED}${BOLD}╚═════════════════════════════════════════════╝${RESET}" >&2 - echo "" >&2 - echo -e "${RED}$1${RESET}" >&2 - echo "" >&2 -} - -msg_warning() { - echo "" >&2 - echo -e "${YELLOW}${BOLD}[!] WARNING${RESET}" >&2 - echo -e "${YELLOW}$1${RESET}" >&2 - echo "" >&2 -} - -msg_success() { - echo -e "${GREEN}[OK] $1${RESET}" -} - -msg_info() { - echo -e "[i] $1" -} - -msg_section() { - echo "" - echo -e "${BOLD}===== $1 =====${RESET}" - echo "" -} - -setup_colors - -# --- Node.js Detection (v4.5) --- -check_nodejs() { - if ! command -v node &> /dev/null; then - msg_warning "Node.js not found - - CCS v4.5+ requires Node.js 14+ to run. - The bootstrap scripts will check and install the npm package on first use. - - Install Node.js: https://nodejs.org (LTS recommended) - - Installation will continue, but 'ccs' will not work until Node.js is installed." - return 1 - fi - - local node_major - node_major=$(node -v | cut -d'v' -f2 | cut -d'.' -f1) - if [[ $node_major -lt 14 ]]; then - msg_warning "Node.js 14+ required (found: $(node -v)) - - CCS v4.5+ requires Node.js 14 or newer. - Upgrade from: https://nodejs.org - - Installation will continue, but 'ccs' may not work correctly." - return 1 - fi - - msg_success "Node.js $(node -v) detected" - return 0 -} - -# --- Shell Profile Management --- - -detect_shell_profile() { - # Safe extraction of shell name (no command substitution) - local shell_path="${SHELL:-/bin/bash}" - local shell_name="${shell_path##*/}" - - # Validate shell_name is alphanumeric (defense in depth) - if [[ ! "$shell_name" =~ ^[a-zA-Z0-9_-]+$ ]]; then - shell_name="bash" - fi - - case "$shell_name" in - zsh) - echo "$HOME/.zshrc" - ;; - bash) - if [[ "$OSTYPE" == darwin* ]]; then - # macOS prefers bash_profile - [[ -f "$HOME/.bash_profile" ]] && echo "$HOME/.bash_profile" || echo "$HOME/.bashrc" - else - echo "$HOME/.bashrc" - fi - ;; - fish) - echo "$HOME/.config/fish/config.fish" - ;; - *) - # Default to bashrc - echo "$HOME/.bashrc" - ;; - esac -} - -check_path_configured() { - [[ ":$PATH:" == *":$HOME/.local/bin:"* ]] -} - -add_to_path() { - local profile_file="$1" - local dir_to_add="$HOME/.local/bin" - - # Create profile file if doesn't exist - if [[ ! -f "$profile_file" ]]; then - local profile_dir="$(dirname "$profile_file")" - - if ! mkdir -p "$profile_dir" 2>/dev/null; then - echo "[!] Failed to create directory: $profile_dir" >&2 - return 1 - fi - - if ! touch "$profile_file" 2>/dev/null; then - echo "[!] Failed to create profile file: $profile_file" >&2 - return 1 - fi - fi - - # Check if already in profile (avoid duplicates) - if grep -q "# CCS: Added by Claude Code Switch installer" "$profile_file" 2>/dev/null; then - return 0 # Already added - fi - - # Check for fish shell (different syntax) - if [[ "$profile_file" == *"config.fish" ]]; then - cat >> "$profile_file" << 'EOF' - -# CCS: Added by Claude Code Switch installer -set -gx PATH $HOME/.local/bin $PATH -EOF - else - # Bash/Zsh syntax - cat >> "$profile_file" << 'EOF' - -# CCS: Added by Claude Code Switch installer -export PATH="$HOME/.local/bin:$PATH" -EOF - fi - - return 0 -} - -configure_shell_path() { - if check_path_configured; then - msg_info "PATH already configured for ~/.local/bin" - return 0 - fi - - local profile_file=$(detect_shell_profile) - - echo "" - msg_section "Configuring Shell PATH" - msg_info "Detected shell profile: $profile_file" - - if add_to_path "$profile_file"; then - msg_success "Added ~/.local/bin to PATH in $profile_file" - echo "" - - # Show reload instructions - msg_critical "Reload your shell to use 'ccs' command: - - Option 1 (current session): - source $profile_file - - Option 2 (new session): - Open a new terminal window - - Then verify: - ccs --version" - - return 0 - else - msg_warning "Could not auto-configure PATH - - Manually add this line to $profile_file: - export PATH=\"\$HOME/.local/bin:\$PATH\" - - Then reload: - source $profile_file" - return 1 - fi -} - -create_glm_template() { - cat << EOF -{ - "env": { - "ANTHROPIC_BASE_URL": "https://api.z.ai/api/anthropic", - "ANTHROPIC_AUTH_TOKEN": "YOUR_GLM_API_KEY_HERE", - "ANTHROPIC_MODEL": "$GLM_MODEL", - "ANTHROPIC_DEFAULT_OPUS_MODEL": "$GLM_MODEL", - "ANTHROPIC_DEFAULT_SONNET_MODEL": "$GLM_MODEL", - "ANTHROPIC_DEFAULT_HAIKU_MODEL": "$GLM_MODEL" - } -} -EOF -} - -create_kimi_template() { - cat << EOF -{ - "env": { - "ANTHROPIC_BASE_URL": "https://api.kimi.com/coding/", - "ANTHROPIC_AUTH_TOKEN": "YOUR_KIMI_API_KEY_HERE", - "ANTHROPIC_MODEL": "$KIMI_MODEL", - "ANTHROPIC_SMALL_FAST_MODEL": "$KIMI_MODEL", - "ANTHROPIC_DEFAULT_OPUS_MODEL": "$KIMI_MODEL", - "ANTHROPIC_DEFAULT_SONNET_MODEL": "$KIMI_MODEL", - "ANTHROPIC_DEFAULT_HAIKU_MODEL": "$KIMI_MODEL" - }, - "alwaysThinkingEnabled": true -} -EOF -} - -atomic_mv() { - local src="$1" - local dest="$2" - if mv "$src" "$dest" 2>/dev/null; then - return 0 - else - rm -f "$src" - echo " [X] Error: Failed to create $dest (check permissions)" - exit 1 - fi -} - -download_file() { - local url="$1" - local dest="$2" - - if ! curl -fsSL "$url" -o "$dest"; then - echo " [!] Failed to download: $(basename "$dest")" - return 1 - fi - return 0 -} - -install_claude_folder() { - local source_dir="$1" - local target_dir="$CCS_DIR/.claude" - - # Check if already exists - if [[ -d "$target_dir" ]]; then - echo "| [i] .claude/ folder already exists, skipping" - return 0 - fi - - mkdir -p "$target_dir/commands" "$target_dir/skills/ccs-delegation/references" - - if [[ "$INSTALL_METHOD" == "git" ]]; then - # Copy from local git repo - if [[ -d "$source_dir/.claude" ]]; then - cp -r "$source_dir/.claude"/* "$target_dir/" 2>/dev/null || { - echo "| [!] Failed to copy .claude/ folder" - return 1 - } - echo "| [OK] Installed .claude/ folder" - else - echo "| [!] .claude/ folder not found in source" - return 1 - fi - else - # Standalone: download from GitHub - local base_url="https://raw.githubusercontent.com/kaitranntt/ccs/main/.claude" - - download_file "$base_url/commands/ccs.md" "$target_dir/commands/ccs.md" || return 1 - download_file "$base_url/skills/ccs-delegation/SKILL.md" "$target_dir/skills/ccs-delegation/SKILL.md" || return 1 - download_file "$base_url/skills/ccs-delegation/references/delegation-patterns.md" "$target_dir/skills/ccs-delegation/references/delegation-patterns.md" || return 1 - - echo "| [OK] Downloaded .claude/ folder" - fi - - return 0 -} - -create_glm_profile() { - local current_settings="$CLAUDE_DIR/settings.json" - local glm_settings="$CCS_DIR/glm.settings.json" - local provider="$1" - - if [[ "$provider" == "glm" ]]; then - echo "[OK] Copying current GLM config to profile..." - if command -v jq &> /dev/null; then - if jq '.env |= (. // {}) + { - "ANTHROPIC_DEFAULT_OPUS_MODEL": "'"$GLM_MODEL"'", - "ANTHROPIC_DEFAULT_SONNET_MODEL": "'"$GLM_MODEL"'", - "ANTHROPIC_DEFAULT_HAIKU_MODEL": "'"$GLM_MODEL"'" - }' "$current_settings" > "$glm_settings.tmp" 2>/dev/null; then - atomic_mv "$glm_settings.tmp" "$glm_settings" - echo " Created: $glm_settings (with your existing API key + enhanced settings)" - else - rm -f "$glm_settings.tmp" - cp "$current_settings" "$glm_settings" - echo " Created: $glm_settings (copied as-is, jq enhancement failed)" - fi - else - cp "$current_settings" "$glm_settings" - echo " Created: $glm_settings (copied as-is, jq not available)" - fi - else - echo "Creating GLM profile template at $glm_settings" - if [[ -f "$current_settings" ]] && command -v jq &> /dev/null; then - if jq '.env |= (. // {}) + { - "ANTHROPIC_BASE_URL": "https://api.z.ai/api/anthropic", - "ANTHROPIC_AUTH_TOKEN": "YOUR_GLM_API_KEY_HERE", - "ANTHROPIC_MODEL": "'"$GLM_MODEL"'", - "ANTHROPIC_DEFAULT_OPUS_MODEL": "'"$GLM_MODEL"'", - "ANTHROPIC_DEFAULT_SONNET_MODEL": "'"$GLM_MODEL"'", - "ANTHROPIC_DEFAULT_HAIKU_MODEL": "'"$GLM_MODEL"'" - }' "$current_settings" > "$glm_settings.tmp" 2>/dev/null; then - atomic_mv "$glm_settings.tmp" "$glm_settings" - else - rm -f "$glm_settings.tmp" - echo " [i] jq failed, using basic template" - create_glm_template > "$glm_settings" - fi - else - create_glm_template > "$glm_settings" - fi - echo " Created: $glm_settings" - echo " [!] Edit this file and replace YOUR_GLM_API_KEY_HERE with your actual GLM API key" - fi -} - -create_kimi_profile() { - local current_settings="$CLAUDE_DIR/settings.json" - local kimi_settings="$CCS_DIR/kimi.settings.json" - local provider="$1" - - if [[ "$provider" == "kimi" ]]; then - echo "[OK] Copying current Kimi config to profile..." - if command -v jq &> /dev/null; then - if jq '.env |= (. // {}) + { - "ANTHROPIC_SMALL_FAST_MODEL": "'"$KIMI_MODEL"'", - "ANTHROPIC_DEFAULT_OPUS_MODEL": "'"$KIMI_MODEL"'", - "ANTHROPIC_DEFAULT_SONNET_MODEL": "'"$KIMI_MODEL"'", - "ANTHROPIC_DEFAULT_HAIKU_MODEL": "'"$KIMI_MODEL"'" - }' "$current_settings" > "$kimi_settings.tmp" 2>/dev/null; then - atomic_mv "$kimi_settings.tmp" "$kimi_settings" - echo " Created: $kimi_settings (with your existing API key + enhanced settings)" - else - rm -f "$kimi_settings.tmp" - cp "$current_settings" "$kimi_settings" - echo " Created: $kimi_settings (copied as-is, jq enhancement failed)" - fi - else - cp "$current_settings" "$kimi_settings" - echo " Created: $kimi_settings (copied as-is, jq not available)" - fi - else - echo "Creating Kimi profile template at $kimi_settings" - if [[ -f "$current_settings" ]] && command -v jq &> /dev/null; then - if jq '.env |= (. // {}) + { - "ANTHROPIC_BASE_URL": "https://api.kimi.com/coding/", - "ANTHROPIC_AUTH_TOKEN": "YOUR_KIMI_API_KEY_HERE", - "ANTHROPIC_MODEL": "'"$KIMI_MODEL"'", - "ANTHROPIC_SMALL_FAST_MODEL": "'"$KIMI_MODEL"'", - "ANTHROPIC_DEFAULT_OPUS_MODEL": "'"$KIMI_MODEL"'", - "ANTHROPIC_DEFAULT_SONNET_MODEL": "'"$KIMI_MODEL"'", - "ANTHROPIC_DEFAULT_HAIKU_MODEL": "'"$KIMI_MODEL"'" - } | . + {"alwaysThinkingEnabled": true}' "$current_settings" > "$kimi_settings.tmp" 2>/dev/null; then - atomic_mv "$kimi_settings.tmp" "$kimi_settings" - else - rm -f "$kimi_settings.tmp" - echo " [i] jq failed, using basic template" - create_kimi_template > "$kimi_settings" - fi - else - create_kimi_template > "$kimi_settings" - fi - echo " Created: $kimi_settings" - echo " [!] Edit this file and replace YOUR_KIMI_API_KEY_HERE with your actual Kimi API key" - fi -} - -# --- Main Installation --- - -# Check Node.js requirement (warn if missing, continue anyway) -check_nodejs || true - -echo "┌─ Installing CCS" - -# Create directories -mkdir -p "$INSTALL_DIR" "$CCS_DIR" - -# Install main executable -if [[ "$INSTALL_METHOD" == "standalone" ]]; then - # Standalone install - download ccs from GitHub - if ! command -v curl &> /dev/null; then - echo "[X] Error: curl is required for standalone installation" - exit 1 - fi - - BASE_URL="https://raw.githubusercontent.com/kaitranntt/ccs/main" - - # Download main executable - if curl -fsSL "$BASE_URL/lib/ccs" -o "$CCS_DIR/ccs"; then - chmod +x "$CCS_DIR/ccs" - ln -sf "$CCS_DIR/ccs" "$INSTALL_DIR/ccs" - echo "| [OK] Downloaded executable" - else - echo "|" - echo "[X] Error: Failed to download ccs from GitHub" - exit 1 - fi - - # Note: Shell dependencies (error-codes.sh, progress-indicator.sh, prompt.sh) no longer needed - # Bootstrap delegates all functionality to Node.js via npx - - # Download shell completion files - mkdir -p "$CCS_DIR/completions" - if curl -fsSL "$BASE_URL/scripts/completion/ccs.bash" -o "$CCS_DIR/completions/ccs.bash" 2>/dev/null; then - echo "| [OK] Downloaded completion files" - fi - curl -fsSL "$BASE_URL/scripts/completion/ccs.zsh" -o "$CCS_DIR/completions/ccs.zsh" 2>/dev/null || true - curl -fsSL "$BASE_URL/scripts/completion/ccs.fish" -o "$CCS_DIR/completions/ccs.fish" 2>/dev/null || true -else - # Git install - use local ccs file - # Handle both running from root or from installers/ subdirectory - local LIB_DIR="" - if [[ -f "$SCRIPT_DIR/lib/ccs" ]]; then - chmod +x "$SCRIPT_DIR/lib/ccs" - ln -sf "$SCRIPT_DIR/lib/ccs" "$INSTALL_DIR/ccs" - LIB_DIR="$SCRIPT_DIR/lib" - elif [[ -f "$SCRIPT_DIR/../lib/ccs" ]]; then - chmod +x "$SCRIPT_DIR/../lib/ccs" - ln -sf "$SCRIPT_DIR/../lib/ccs" "$INSTALL_DIR/ccs" - LIB_DIR="$SCRIPT_DIR/../lib" - else - echo "|" - echo "[X] Error: lib/ccs executable not found" - exit 1 - fi - echo "| [OK] Installed executable" - - # Note: Shell dependencies (error-codes.sh, progress-indicator.sh, prompt.sh) no longer needed - # Bootstrap delegates all functionality to Node.js via npx - - # Copy shell completion files - mkdir -p "$CCS_DIR/completions" - local COMPLETION_DIR="" - if [[ -d "$SCRIPT_DIR/scripts/completion" ]]; then - COMPLETION_DIR="$SCRIPT_DIR/scripts/completion" - elif [[ -d "$SCRIPT_DIR/../scripts/completion" ]]; then - COMPLETION_DIR="$SCRIPT_DIR/../scripts/completion" - fi - - if [[ -n "$COMPLETION_DIR" ]]; then - cp "$COMPLETION_DIR/ccs.bash" "$CCS_DIR/completions/ccs.bash" 2>/dev/null || true - cp "$COMPLETION_DIR/ccs.zsh" "$CCS_DIR/completions/ccs.zsh" 2>/dev/null || true - cp "$COMPLETION_DIR/ccs.fish" "$CCS_DIR/completions/ccs.fish" 2>/dev/null || true - echo "| [OK] Copied completion files" - fi -fi - -if [[ ! -L "$INSTALL_DIR/ccs" ]]; then - echo "|" - echo "[X] Error: Failed to create symlink at $INSTALL_DIR/ccs" - echo " Check directory permissions and try again." - exit 1 -fi - -# Install uninstall script (with idempotency check) -if [[ -f "$SCRIPT_DIR/uninstall.sh" ]]; then - # Only copy if source and destination are different - if [[ "$SCRIPT_DIR/uninstall.sh" != "$CCS_DIR/uninstall.sh" ]]; then - cp "$SCRIPT_DIR/uninstall.sh" "$CCS_DIR/uninstall.sh" - fi - chmod +x "$CCS_DIR/uninstall.sh" - ln -sf "$CCS_DIR/uninstall.sh" "$INSTALL_DIR/ccs-uninstall" - echo "| [OK] Installed uninstaller" -elif [[ "$INSTALL_METHOD" == "standalone" ]] && command -v curl &> /dev/null; then - if curl -fsSL https://raw.githubusercontent.com/kaitranntt/ccs/main/installers/uninstall.sh -o "$CCS_DIR/uninstall.sh"; then - chmod +x "$CCS_DIR/uninstall.sh" - ln -sf "$CCS_DIR/uninstall.sh" "$INSTALL_DIR/ccs-uninstall" - echo "| [OK] Installed uninstaller" - fi -fi - -echo "| [OK] Created directories" - -# Install .claude/ folder -if [[ "$INSTALL_METHOD" == "git" ]]; then - install_claude_folder "$SCRIPT_DIR/.." || echo "| [!] Optional .claude/ installation skipped" -else - install_claude_folder "" || echo "| [!] Optional .claude/ installation skipped" -fi - -echo "└─" -echo "" - -# --- Profile Setup --- - -CURRENT_PROVIDER=$(detect_current_provider) -GLM_SETTINGS="$CCS_DIR/glm.settings.json" -KIMI_SETTINGS="$CCS_DIR/kimi.settings.json" - -# Build provider label -PROVIDER_LABEL="" -[[ "$CURRENT_PROVIDER" == "glm" ]] && PROVIDER_LABEL=" (detected: GLM)" -[[ "$CURRENT_PROVIDER" == "kimi" ]] && PROVIDER_LABEL=" (detected: Kimi)" -[[ "$CURRENT_PROVIDER" == "claude" ]] && PROVIDER_LABEL=" (detected: Claude)" -[[ "$CURRENT_PROVIDER" == "custom" ]] && PROVIDER_LABEL=" (detected: custom)" - -echo "┌─ Configuring Profiles (v${CCS_VERSION})${PROVIDER_LABEL}" - -# Backup existing config if present (single backup, no timestamp) -BACKUP_FILE="$CCS_DIR/config.json.backup" -if [[ -f "$CCS_DIR/config.json" ]]; then - cp "$CCS_DIR/config.json" "$BACKUP_FILE" -fi - -# Track if GLM needs API key -NEEDS_GLM_KEY=false - -# Create GLM profile if missing -if [[ ! -f "$GLM_SETTINGS" ]]; then - create_glm_profile "$CURRENT_PROVIDER" >/dev/null 2>&1 - echo "| [OK] GLM profile -> ~/.ccs/glm.settings.json" - [[ "$CURRENT_PROVIDER" != "glm" ]] && NEEDS_GLM_KEY=true -fi - -# Track if Kimi needs API key -NEEDS_KIMI_KEY=false - -# Create Kimi profile if missing -if [[ ! -f "$KIMI_SETTINGS" ]]; then - create_kimi_profile "$CURRENT_PROVIDER" >/dev/null 2>&1 - echo "| [OK] Kimi profile -> ~/.ccs/kimi.settings.json" - [[ "$CURRENT_PROVIDER" != "kimi" ]] && NEEDS_KIMI_KEY=true -fi - -# Create config if missing -if [[ ! -f "$CCS_DIR/config.json" ]]; then - cat > "$CCS_DIR/config.json.tmp" << 'EOF' -{ - "profiles": { - "glm": "~/.ccs/glm.settings.json", - "kimi": "~/.ccs/kimi.settings.json", - "default": "~/.claude/settings.json" - } -} -EOF - atomic_mv "$CCS_DIR/config.json.tmp" "$CCS_DIR/config.json" - echo "| [OK] Config -> ~/.ccs/config.json" -fi - -# Validate config JSON -if [[ -f "$CCS_DIR/config.json" ]]; then - if command -v jq &> /dev/null; then - if ! jq -e . "$CCS_DIR/config.json" &>/dev/null; then - echo "| [!] Warning: Invalid JSON in config.json" - if [[ -f "$BACKUP_FILE" ]]; then - echo "| Restore from: $BACKUP_FILE" - fi - fi - fi -fi - -# Validate GLM settings JSON -if [[ -f "$GLM_SETTINGS" ]]; then - if command -v jq &> /dev/null; then - if ! jq -e . "$GLM_SETTINGS" &>/dev/null; then - echo "| [!] Warning: Invalid JSON in glm.settings.json" - fi - fi -fi - -echo "└─" -echo "" - -# Detect circular symlink -detect_circular_symlink() { - local target="$1" - local link_path="$2" - - # Check if target exists and is symlink - if [[ ! -L "$target" ]]; then - return 1 # Not circular - fi - - # Resolve target's link - local target_link=$(readlink "$target" 2>/dev/null || echo "") - local shared_dir="$HOME/.ccs/shared" - - # Check if target points back to our shared dir - if [[ "$target_link" == "$shared_dir"* ]] || [[ "$target_link" == "$link_path" ]]; then - echo "[!] Circular symlink detected: $target → $target_link" - return 0 # Circular - fi - - return 1 # Not circular -} - -# Setup shared directories as symlinks to ~/.claude/ (v3.2.0) -setup_shared_symlinks() { - local shared_dir="$CCS_DIR/shared" - local claude_dir="$HOME/.claude" - - # Create ~/.claude/ if missing - if [[ ! -d "$claude_dir" ]]; then - echo "[i] Creating ~/.claude/ directory structure" - mkdir -p "$claude_dir"/{commands,skills,agents} - fi - - # Create shared directory - mkdir -p "$shared_dir" - - # Create symlinks ~/.ccs/shared/* → ~/.claude/* - for dir in commands skills agents; do - local claude_path="$claude_dir/$dir" - local shared_path="$shared_dir/$dir" - - # Create directory in ~/.claude/ if missing - if [[ ! -d "$claude_path" ]]; then - mkdir -p "$claude_path" - fi - - # Check for circular symlink - if detect_circular_symlink "$claude_path" "$shared_path"; then - echo "[!] Skipping $dir: circular symlink detected" - continue - fi - - # If already correct symlink, skip - if [[ -L "$shared_path" ]]; then - local current_target=$(readlink "$shared_path" 2>/dev/null || echo "") - if [[ "$current_target" == "$claude_path" ]]; then - continue # Already correct - fi - rm -rf "$shared_path" - elif [[ -e "$shared_path" ]]; then - # Backup existing data before replacing - if [[ -d "$shared_path" ]] && [[ -n "$(ls -A "$shared_path" 2>/dev/null)" ]]; then - echo "[i] Migrating existing $dir to ~/.claude/$dir" - # Copy to claude dir (preserve user modifications) - for item in "$shared_path"/*; do - [[ -e "$item" ]] || continue - local basename=$(basename "$item") - if [[ ! -e "$claude_path/$basename" ]]; then - cp -r "$item" "$claude_path/" 2>/dev/null - fi - done - fi - rm -rf "$shared_path" - fi - - # Create symlink - ln -s "$claude_path" "$shared_path" 2>/dev/null || { - echo "[!] Failed to create symlink for $dir, copying instead" - mkdir -p "$shared_path" - if [[ -d "$claude_path" ]]; then - cp -r "$claude_path"/* "$shared_path/" 2>/dev/null || true - fi - } - done -} - -echo "[i] Setting up shared directories..." -setup_shared_symlinks -echo "" - -# Install CCS items to ~/.claude/ via symlinks (v4.1.0) -echo "[i] Installing CCS items to ~/.claude/..." -if command -v node &> /dev/null; then - # Check if .claude/ was successfully installed - if [[ -d "$CCS_DIR/.claude" ]]; then - # Download or copy claude-symlink-manager.js - mkdir -p "$CCS_DIR/bin/utils" - - if [[ "$INSTALL_METHOD" == "git" ]]; then - # Git install - copy from local repo - if [[ -f "$SCRIPT_DIR/../bin/utils/claude-symlink-manager.js" ]]; then - cp "$SCRIPT_DIR/../bin/utils/claude-symlink-manager.js" "$CCS_DIR/bin/utils/claude-symlink-manager.js" - elif [[ -f "$SCRIPT_DIR/bin/utils/claude-symlink-manager.js" ]]; then - cp "$SCRIPT_DIR/bin/utils/claude-symlink-manager.js" "$CCS_DIR/bin/utils/claude-symlink-manager.js" - fi - else - # Standalone install - download from GitHub - if ! curl -fsSL "https://raw.githubusercontent.com/kaitranntt/ccs/main/bin/utils/claude-symlink-manager.js" -o "$CCS_DIR/bin/utils/claude-symlink-manager.js" 2>/dev/null; then - echo "[!] Failed to download claude-symlink-manager.js" - fi - fi - - # Call ClaudeSymlinkManager if available - if [[ -f "$CCS_DIR/bin/utils/claude-symlink-manager.js" ]]; then - node -e " - try { - const ClaudeSymlinkManager = require('$CCS_DIR/bin/utils/claude-symlink-manager.js'); - const manager = new ClaudeSymlinkManager(); - manager.install(); - } catch (err) { - console.log('[!] CCS item installation warning: ' + err.message); - console.log(' Run \"ccs sync\" to retry'); - } - " 2>/dev/null || echo "[!] CCS item installation skipped (run 'ccs sync' later)" - else - echo "[!] claude-symlink-manager.js not found, skipping" - echo " Run 'ccs sync' after installation to complete setup" - fi - else - echo "[!] .claude/ folder not found, skipping CCS item installation" - fi -else - echo "[!] Node.js not found, skipping CCS item installation" - echo " Install Node.js and run 'ccs sync' to complete setup" -fi -echo "" - -# Auto-configure PATH if needed (all Unix platforms) -configure_shell_path - -# Show API key warning if needed -if [[ "$NEEDS_GLM_KEY" == "true" ]]; then - msg_critical "Configure GLM API Key: - - 1. Get API key from: https://api.z.ai - - 2. Edit: ~/.ccs/glm.settings.json - - 3. Replace: YOUR_GLM_API_KEY_HERE - With your actual API key - - 4. Test: ccs glm --version" -fi - -# Show API key warning for Kimi if needed -if [[ "$NEEDS_KIMI_KEY" == "true" ]]; then - msg_critical "Configure Kimi API Key: - - 1. Get API key from: https://www.kimi.com/coding - - 2. Edit: ~/.ccs/kimi.settings.json - - 3. Replace: YOUR_KIMI_API_KEY_HERE - With your actual API key - - 4. Test: ccs kimi --version" -fi - -msg_success "CCS installed successfully!" -echo "" -echo " Installed components:" -echo " * ccs command -> ~/.local/bin/ccs" -echo " * config -> ~/.ccs/config.json" -echo " * glm profile -> ~/.ccs/glm.settings.json" -echo " * kimi profile -> ~/.ccs/kimi.settings.json" -echo " * .claude/ folder -> ~/.ccs/.claude/" -echo "" -echo " Requirements:" -echo " * Node.js 14+ (detected: $(node -v 2>/dev/null || echo 'NOT FOUND'))" -echo " * npm 5.2+ (for npx, comes with Node.js 8.2+)" -echo "" -echo " First Run:" -echo " The first time you run 'ccs', it will automatically install" -echo " the @kaitranntt/ccs npm package globally via npx." -echo "" -echo " Quick start:" -echo " ccs # Use Claude subscription (default)" -echo " ccs glm # Use GLM fallback" -echo " ccs kimi # Use Kimi for Coding" -echo "" -echo " To uninstall: ccs-uninstall" -echo "" diff --git a/installers/uninstall.ps1 b/installers/uninstall.ps1 deleted file mode 100644 index fca77726..00000000 --- a/installers/uninstall.ps1 +++ /dev/null @@ -1,99 +0,0 @@ -# CCS Uninstallation Script (Windows PowerShell) -# https://github.com/kaitranntt/ccs - -$ErrorActionPreference = "Stop" - -# --- Color/Format Functions --- -function Write-Success { - param([string]$Message) - Write-Host "[OK] $Message" -ForegroundColor Green -} - -function Write-Info { - param([string]$Message) - Write-Host "[i] $Message" -ForegroundColor Cyan -} - -# --- Selective Cleanup Function --- -function Invoke-SelectiveCleanup { - param([string]$CcsDir) - - $Removed = @() - $Kept = @() - - # Remove executables and version metadata - $FilesToRemove = @("ccs.ps1", "VERSION") - - # Also remove the uninstall script itself - $UninstallScript = $PSCommandPath - if ($UninstallScript -and (Test-Path $UninstallScript)) { - $FilesToRemove += $UninstallScript - } - - foreach ($File in $FilesToRemove) { - $FilePath = if ([System.IO.Path]::IsPathRooted($File)) { $File } else { Join-Path $CcsDir $File } - if (Test-Path $FilePath) { - Remove-Item $FilePath -Force - $Removed += Split-Path $FilePath -Leaf - } - } - - # Remove .claude folder - if (Test-Path "$CcsDir\.claude") { - Remove-Item "$CcsDir\.claude" -Recurse -Force - $Removed += ".claude/" - } - - # Track kept files - if (Test-Path "$CcsDir\config.json") { $Kept += "config.json" } - if (Test-Path "$CcsDir\config.json.backup") { $Kept += "config.json.backup" } - Get-ChildItem "$CcsDir\*.settings.json" -ErrorAction SilentlyContinue | ForEach-Object { - $Kept += $_.Name - } - - # Report results - if ($Removed.Count -gt 0) { - Write-Info "Cleaned up: $($Removed -join ', ')" - } - - if ($Kept.Count -gt 0) { - Write-Info "Kept config files: $($Kept -join ', ')" - } -} - -Write-Host "Uninstalling ccs..." -Write-Host "" - -$CcsDir = "$env:USERPROFILE\.ccs" - -# Remove from PATH -$UserPath = [Environment]::GetEnvironmentVariable("Path", [System.EnvironmentVariableTarget]::User) -if ($UserPath -like "*$CcsDir*") { - try { - $NewPath = ($UserPath -split ';' | Where-Object { $_ -ne $CcsDir }) -join ';' - [Environment]::SetEnvironmentVariable("Path", $NewPath, [System.EnvironmentVariableTarget]::User) - Write-Success "Removed from PATH: $CcsDir" - Write-Host " Restart your terminal for changes to take effect." - } catch { - Write-Host "[!] Could not remove from PATH automatically. Please remove manually: $CcsDir" -ForegroundColor Yellow - } -} - -# Ask about ~/.ccs directory -if (Test-Path $CcsDir) { - Write-Host "" - $Response = Read-Host "Remove CCS directory $CcsDir`? This includes config and profiles. (y/N)" - if ($Response -match '^[Yy]$') { - Remove-Item $CcsDir -Recurse -Force - Write-Success "Removed: $CcsDir" - } else { - Write-Host "" - Invoke-SelectiveCleanup -CcsDir $CcsDir - } -} else { - Write-Info "No CCS directory found at $CcsDir" -} - -Write-Host "" -Write-Success "Uninstall complete!" -Write-Host "" diff --git a/installers/uninstall.sh b/installers/uninstall.sh deleted file mode 100755 index e0a407d8..00000000 --- a/installers/uninstall.sh +++ /dev/null @@ -1,95 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# --- Color/Format Functions --- -setup_colors() { - if [[ -t 1 ]] && [[ -z "${NO_COLOR:-}" ]]; then - GREEN='\033[0;32m' - CYAN='\033[0;36m' - RESET='\033[0m' - else - GREEN='' CYAN='' RESET='' - fi -} - -msg_success() { - echo -e "${GREEN}[OK] $1${RESET}" -} - -msg_info() { - echo -e "${CYAN}[i] $1${RESET}" -} - -# --- Selective Cleanup Function --- -selective_cleanup() { - local ccs_dir="$1" - local removed=() - local kept=() - - # Remove executables, version metadata, and .claude folder - for file in "ccs" "uninstall.sh" "VERSION"; do - if [[ -f "$ccs_dir/$file" ]]; then - rm "$ccs_dir/$file" - removed+=("$file") - fi - done - - # Remove .claude folder - if [[ -d "$ccs_dir/.claude" ]]; then - rm -rf "$ccs_dir/.claude" - removed+=(".claude/") - fi - - # Track kept files - [[ -f "$ccs_dir/config.json" ]] && kept+=("config.json") - [[ -f "$ccs_dir/config.json.backup" ]] && kept+=("config.json.backup") - for settings in "$ccs_dir"/*.settings.json; do - [[ -f "$settings" ]] && kept+=("$(basename "$settings")") - done - - # Report results - if [[ ${#removed[@]} -gt 0 ]]; then - msg_info "Cleaned up: ${removed[*]}" - fi - - if [[ ${#kept[@]} -gt 0 ]]; then - msg_info "Kept config files: ${kept[*]}" - fi -} - -setup_colors - -echo "Uninstalling ccs..." -echo "" - -# Remove from ~/.local/bin (standard location) -if [[ -L "$HOME/.local/bin/ccs" ]]; then - rm "$HOME/.local/bin/ccs" - msg_success "Removed: $HOME/.local/bin/ccs" -elif [[ -f "$HOME/.local/bin/ccs" ]]; then - rm "$HOME/.local/bin/ccs" - msg_success "Removed: $HOME/.local/bin/ccs" -fi - -if [[ -L "$HOME/.local/bin/ccs-uninstall" ]]; then - rm "$HOME/.local/bin/ccs-uninstall" - msg_success "Removed: $HOME/.local/bin/ccs-uninstall" -fi - -# Ask about ~/.ccs directory -if [[ -d "$HOME/.ccs" ]]; then - read -p "Remove CCS directory ~/.ccs? This includes config and profiles. (y/N) " -n 1 -r - echo - if [[ $REPLY =~ ^[Yy]$ ]]; then - rm -rf "$HOME/.ccs" - msg_success "Removed: $HOME/.ccs" - else - echo "" - selective_cleanup "$HOME/.ccs" - fi -else - msg_info "No CCS directory found at $HOME/.ccs" -fi - -echo "" -msg_success "Uninstall complete!" diff --git a/scripts/worker.js b/scripts/worker.js index ca0d7fdb..2e74b4f1 100644 --- a/scripts/worker.js +++ b/scripts/worker.js @@ -1,46 +1,26 @@ +/** + * CCS CloudFlare Worker - Redirect to npm Installation + * + * Legacy shell installers are deprecated. This worker now redirects + * all /install* and /uninstall* requests to the npm installation docs. + */ export default { async fetch(request) { const url = new URL(request.url); + const docsUrl = 'https://docs.ccs.kaitran.ca/getting-started/installation'; - // Detect platform from User-Agent header - const userAgent = request.headers.get('user-agent') || ''; - const isWindows = userAgent.includes('Windows') || userAgent.includes('Win32'); - const isPowerShell = userAgent.includes('PowerShell') || userAgent.includes('pwsh'); - - // Smart routing with platform detection - let filePath; - if (url.pathname === '/install' || url.pathname === '/install.sh') { - filePath = (isWindows && isPowerShell) ? 'installers/install.ps1' : 'installers/install.sh'; - } else if (url.pathname === '/install.ps1') { - filePath = 'installers/install.ps1'; - } else if (url.pathname === '/uninstall' || url.pathname === '/uninstall.sh') { - filePath = (isWindows && isPowerShell) ? 'installers/uninstall.ps1' : 'installers/uninstall.sh'; - } else if (url.pathname === '/uninstall.ps1') { - filePath = 'installers/uninstall.ps1'; - } else { - return new Response('Not Found', { status: 404 }); + // Redirect all install/uninstall paths to npm installation docs + if ( + url.pathname === '/install' || + url.pathname === '/install.sh' || + url.pathname === '/install.ps1' || + url.pathname === '/uninstall' || + url.pathname === '/uninstall.sh' || + url.pathname === '/uninstall.ps1' + ) { + return Response.redirect(docsUrl, 301); } - try { - const githubUrl = `https://raw.githubusercontent.com/kaitranntt/ccs/main/${filePath}`; - const response = await fetch(githubUrl); - - if (!response.ok) { - return new Response('File not found on GitHub', { status: 404 }); - } - - const contentType = filePath.endsWith('.ps1') - ? 'text/plain; charset=utf-8' - : 'text/x-shellscript; charset=utf-8'; - - return new Response(response.body, { - headers: { - 'Content-Type': contentType, - 'Cache-Control': 'public, max-age=300' - } - }); - } catch (error) { - return new Response('Server Error', { status: 500 }); - } + return new Response('Not Found', { status: 404 }); } }; \ No newline at end of file diff --git a/src/ccs.ts b/src/ccs.ts index 2477cfb4..bc48ea45 100644 --- a/src/ccs.ts +++ b/src/ccs.ts @@ -32,7 +32,7 @@ import { checkCachedUpdate, isCacheStale, } from './utils/update-checker'; -import { detectInstallationMethod } from './utils/package-manager-detector'; +// Note: npm is now the only supported installation method // ========== Profile Detection ========== @@ -219,9 +219,8 @@ interface ProfileError extends Error { async function refreshUpdateCache(): Promise { try { const currentVersion = getVersion(); - const installMethod = detectInstallationMethod(); - // Force=true to always fetch fresh data - await checkForUpdates(currentVersion, true, installMethod); + // npm is now the only supported installation method + await checkForUpdates(currentVersion, true, 'npm'); } catch (_e) { // Silently fail - update check shouldn't crash main CLI } diff --git a/src/commands/update-command.ts b/src/commands/update-command.ts index 5cef805a..a172e4da 100644 --- a/src/commands/update-command.ts +++ b/src/commands/update-command.ts @@ -2,12 +2,12 @@ * Update Command Handler * * Handles `ccs update` command - checks for updates and installs latest version. - * Supports both npm and direct installation methods. + * Uses npm/yarn/pnpm/bun package managers exclusively. */ import { spawn } from 'child_process'; import { initUI, header, ok, fail, warn, info, color } from '../utils/ui'; -import { detectInstallationMethod, detectPackageManager } from '../utils/package-manager-detector'; +import { detectPackageManager } from '../utils/package-manager-detector'; import { compareVersionsWithPrerelease } from '../utils/update-checker'; import { getVersion } from '../utils/version'; @@ -35,33 +35,20 @@ export async function handleUpdateCommand(options: UpdateOptions = {}): Promise< console.log(header('Checking for updates...')); console.log(''); - const installMethod = detectInstallationMethod(); - const isNpmInstall = installMethod === 'npm'; - // Force reinstall - skip update check if (force) { console.log(info(`Force reinstall from @${targetTag} channel...`)); console.log(''); - - if (isNpmInstall) { - await performNpmUpdate(targetTag, true); - } else { - // Direct install doesn't support --beta - if (beta) { - handleDirectBetaNotSupported(); - return; - } - await performDirectUpdate(); - } + await performNpmUpdate(targetTag, true); return; } const { checkForUpdates } = await import('../utils/update-checker'); - const updateResult = await checkForUpdates(CCS_VERSION, true, installMethod, targetTag); + const updateResult = await checkForUpdates(CCS_VERSION, true, 'npm', targetTag); if (updateResult.status === 'check_failed') { - handleCheckFailed(updateResult.message ?? 'Update check failed', isNpmInstall, targetTag); + handleCheckFailed(updateResult.message ?? 'Update check failed', targetTag); return; } @@ -102,21 +89,13 @@ export async function handleUpdateCommand(options: UpdateOptions = {}): Promise< console.log(''); } - if (isNpmInstall) { - await performNpmUpdate(targetTag); - } else { - await performDirectUpdate(); - } + await performNpmUpdate(targetTag); } /** * Handle failed update check */ -function handleCheckFailed( - message: string, - isNpmInstall: boolean, - targetTag: string = 'latest' -): void { +function handleCheckFailed(message: string, targetTag: string = 'latest'): void { console.log(fail(message)); console.log(''); console.log(warn('Possible causes:')); @@ -126,36 +105,27 @@ function handleCheckFailed( console.log(''); console.log('Try again later or update manually:'); - if (isNpmInstall) { - const packageManager = detectPackageManager(); - let manualCommand: string; + const packageManager = detectPackageManager(); + let manualCommand: string; - switch (packageManager) { - case 'npm': - manualCommand = `npm install -g @kaitranntt/ccs@${targetTag}`; - break; - case 'yarn': - manualCommand = `yarn global add @kaitranntt/ccs@${targetTag}`; - break; - case 'pnpm': - manualCommand = `pnpm add -g @kaitranntt/ccs@${targetTag}`; - break; - case 'bun': - manualCommand = `bun add -g @kaitranntt/ccs@${targetTag}`; - break; - default: - manualCommand = `npm install -g @kaitranntt/ccs@${targetTag}`; - } - - console.log(color(` ${manualCommand}`, 'command')); - } else { - const isWindows = process.platform === 'win32'; - if (isWindows) { - console.log(color(' irm ccs.kaitran.ca/install | iex', 'command')); - } else { - console.log(color(' curl -fsSL ccs.kaitran.ca/install | bash', 'command')); - } + switch (packageManager) { + case 'npm': + manualCommand = `npm install -g @kaitranntt/ccs@${targetTag}`; + break; + case 'yarn': + manualCommand = `yarn global add @kaitranntt/ccs@${targetTag}`; + break; + case 'pnpm': + manualCommand = `pnpm add -g @kaitranntt/ccs@${targetTag}`; + break; + case 'bun': + manualCommand = `bun add -g @kaitranntt/ccs@${targetTag}`; + break; + default: + manualCommand = `npm install -g @kaitranntt/ccs@${targetTag}`; } + + console.log(color(` ${manualCommand}`, 'command')); console.log(''); process.exit(1); } @@ -300,86 +270,3 @@ async function performNpmUpdate( performUpdate(); } } - -/** - * Handle direct install beta not supported error - */ -function handleDirectBetaNotSupported(): void { - console.log(fail('--beta flag requires npm installation')); - console.log(''); - console.log('Current installation method: direct installer'); - console.log('To use beta releases, install via npm:'); - console.log(''); - console.log(color(' npm install -g @kaitranntt/ccs', 'command')); - console.log(color(' ccs update --beta', 'command')); - console.log(''); - console.log('Or continue using stable releases via direct installer.'); - console.log(''); - process.exit(1); -} - -/** - * Perform update via direct installer (curl/irm) - */ -async function performDirectUpdate(): Promise { - console.log(info('Updating via installer...')); - console.log(''); - - const isWindows = process.platform === 'win32'; - let command: string; - let args: string[]; - - if (isWindows) { - command = 'powershell.exe'; - args = [ - '-NoProfile', - '-ExecutionPolicy', - 'Bypass', - '-Command', - 'irm ccs.kaitran.ca/install | iex', - ]; - } else { - command = '/bin/bash'; - args = ['-c', 'curl -fsSL ccs.kaitran.ca/install | bash']; - } - - const child = spawn(command, args, { - stdio: 'inherit', - }); - - child.on('exit', (code) => { - if (code === 0) { - console.log(''); - console.log(ok('Update successful!')); - console.log(''); - console.log(`Run ${color('ccs --version', 'command')} to verify`); - console.log(''); - } else { - console.log(''); - console.log(fail('Update failed')); - console.log(''); - console.log('Try manually:'); - if (isWindows) { - console.log(color(' irm ccs.kaitran.ca/install | iex', 'command')); - } else { - console.log(color(' curl -fsSL ccs.kaitran.ca/install | bash', 'command')); - } - console.log(''); - } - process.exit(code || 0); - }); - - child.on('error', () => { - console.log(''); - console.log(fail('Failed to run installer')); - console.log(''); - console.log('Try manually:'); - if (isWindows) { - console.log(color(' irm ccs.kaitran.ca/install | iex', 'command')); - } else { - console.log(color(' curl -fsSL ccs.kaitran.ca/install | bash', 'command')); - } - console.log(''); - process.exit(1); - }); -} diff --git a/src/utils/package-manager-detector.ts b/src/utils/package-manager-detector.ts index a30fb39b..ac5840a0 100644 --- a/src/utils/package-manager-detector.ts +++ b/src/utils/package-manager-detector.ts @@ -2,77 +2,13 @@ * Package Manager Detector Utilities * * Cross-platform package manager detection utilities for CCS. + * Now only supports npm-based installation (npm/yarn/pnpm/bun). */ import * as path from 'path'; import * as fs from 'fs'; import { spawnSync } from 'child_process'; -/** - * Detect installation method - */ -export function detectInstallationMethod(): 'npm' | 'direct' { - const scriptPath = process.argv[1]; - - // Method 1: Check if script is inside node_modules - if (scriptPath.includes('node_modules')) { - return 'npm'; - } - - // Method 2: Check if script is in npm global bin directory - const npmGlobalBinPatterns = [ - /\.npm\/global\/bin\//, - /\/\.nvm\/versions\/node\/[^/]+\/bin\//, - /\/usr\/local\/bin\//, - /\/usr\/bin\//, - ]; - - for (const pattern of npmGlobalBinPatterns) { - if (pattern.test(scriptPath)) { - try { - const binDir = path.dirname(scriptPath); - const nodeModulesDir = path.join(binDir, '..', 'lib', 'node_modules', '@kaitranntt', 'ccs'); - const globalModulesDir = path.join(binDir, '..', 'node_modules', '@kaitranntt', 'ccs'); - - if (fs.existsSync(nodeModulesDir) || fs.existsSync(globalModulesDir)) { - return 'npm'; - } - } catch (_err) { - // Continue checking other patterns - } - } - } - - // Method 3: Check if package.json exists in parent directory - const packageJsonPath = path.join(__dirname, '..', 'package.json'); - - if (fs.existsSync(packageJsonPath)) { - try { - const pkg = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); - if (pkg.name === '@kaitranntt/ccs') { - return 'npm'; - } - } catch (_err) { - // Ignore parse errors - } - } - - // Method 4: Check if script is a symlink pointing to node_modules - try { - const stats = fs.lstatSync(scriptPath); - if (stats.isSymbolicLink()) { - const targetPath = fs.readlinkSync(scriptPath); - if (targetPath.includes('node_modules') || targetPath.includes('@kaitranntt/ccs')) { - return 'npm'; - } - } - } catch (_err) { - // Continue to default - } - - return 'direct'; -} - /** * Detect which package manager was used for installation */ diff --git a/tests/unit/commands/update-command-beta-channel.test.js b/tests/unit/commands/update-command-beta-channel.test.js index 52a84a6b..3f9ccbd1 100644 --- a/tests/unit/commands/update-command-beta-channel.test.js +++ b/tests/unit/commands/update-command-beta-channel.test.js @@ -118,9 +118,7 @@ describe.skip('Update Command Beta Channel Implementation (Phase 3)', function ( describe('Beta stability warning display', function () { it('should show beta warning when installing from dev channel', function () { // Mock package manager detection - const originalDetectInstallationMethod = packageManagerDetectorModule.detectInstallationMethod; const originalDetectPackageManager = packageManagerDetectorModule.detectPackageManager; - packageManagerDetectorModule.detectInstallationMethod = () => 'npm'; packageManagerDetectorModule.detectPackageManager = () => 'npm'; // Mock update checker to return update available @@ -166,7 +164,6 @@ describe.skip('Update Command Beta Channel Implementation (Phase 3)', function ( assert(returnStable, 'should show return to stable instruction'); } finally { // Restore original functions - packageManagerDetectorModule.detectInstallationMethod = originalDetectInstallationMethod; packageManagerDetectorModule.detectPackageManager = originalDetectPackageManager; updateCheckerModule.checkForUpdates = originalCheckForUpdates; require('child_process').spawn = originalSpawn; @@ -175,9 +172,7 @@ describe.skip('Update Command Beta Channel Implementation (Phase 3)', function ( it('should NOT show beta warning for stable channel', function () { // Mock package manager detection - const originalDetectInstallationMethod = packageManagerDetectorModule.detectInstallationMethod; const originalDetectPackageManager = packageManagerDetectorModule.detectPackageManager; - packageManagerDetectorModule.detectInstallationMethod = () => 'npm'; packageManagerDetectorModule.detectPackageManager = () => 'npm'; // Mock update checker to return update available @@ -204,7 +199,6 @@ describe.skip('Update Command Beta Channel Implementation (Phase 3)', function ( assert(!unstableWarning, 'should not show production warning for stable channel'); } finally { // Restore original functions - packageManagerDetectorModule.detectInstallationMethod = originalDetectInstallationMethod; packageManagerDetectorModule.detectPackageManager = originalDetectPackageManager; updateCheckerModule.checkForUpdates = originalCheckForUpdates; } @@ -212,9 +206,7 @@ describe.skip('Update Command Beta Channel Implementation (Phase 3)', function ( it('should show beta warning even with force flag', function () { // Mock package manager detection - const originalDetectInstallationMethod = packageManagerDetectorModule.detectInstallationMethod; const originalDetectPackageManager = packageManagerDetectorModule.detectPackageManager; - packageManagerDetectorModule.detectInstallationMethod = () => 'npm'; packageManagerDetectorModule.detectPackageManager = () => 'npm'; try { @@ -228,7 +220,6 @@ describe.skip('Update Command Beta Channel Implementation (Phase 3)', function ( assert(betaWarning, 'should show beta warning even with force'); } finally { // Restore original functions - packageManagerDetectorModule.detectInstallationMethod = originalDetectInstallationMethod; packageManagerDetectorModule.detectPackageManager = originalDetectPackageManager; } }); @@ -237,9 +228,7 @@ describe.skip('Update Command Beta Channel Implementation (Phase 3)', function ( describe('handleCheckFailed with targetTag parameter', function () { it('should show manual update command with dev tag for npm install', function () { // Mock package manager detection - const originalDetectInstallationMethod = packageManagerDetectorModule.detectInstallationMethod; const originalDetectPackageManager = packageManagerDetectorModule.detectPackageManager; - packageManagerDetectorModule.detectInstallationMethod = () => 'npm'; packageManagerDetectorModule.detectPackageManager = () => 'npm'; try { @@ -265,9 +254,7 @@ describe.skip('Update Command Beta Channel Implementation (Phase 3)', function ( it('should show manual update command with latest tag for stable', function () { // Mock package manager detection - const originalDetectInstallationMethod = packageManagerDetectorModule.detectInstallationMethod; const originalDetectPackageManager = packageManagerDetectorModule.detectPackageManager; - packageManagerDetectorModule.detectInstallationMethod = () => 'npm'; packageManagerDetectorModule.detectPackageManager = () => 'npm'; try { @@ -304,9 +291,7 @@ describe.skip('Update Command Beta Channel Implementation (Phase 3)', function ( consoleOutput = []; // Mock package manager detection - const originalDetectInstallationMethod = packageManagerDetectorModule.detectInstallationMethod; const originalDetectPackageManager = packageManagerDetectorModule.detectPackageManager; - packageManagerDetectorModule.detectInstallationMethod = () => 'npm'; packageManagerDetectorModule.detectPackageManager = () => name; try { @@ -330,96 +315,13 @@ describe.skip('Update Command Beta Channel Implementation (Phase 3)', function ( assert(manualCommand, `should show manual ${name} command with dev tag`); // Restore functions - packageManagerDetectorModule.detectInstallationMethod = originalDetectInstallationMethod; packageManagerDetectorModule.detectPackageManager = originalDetectPackageManager; }); }); - - it('should show direct install commands when npm detection fails', function () { - // Mock package manager detection - const originalDetectInstallationMethod = packageManagerDetectorModule.detectInstallationMethod; - const originalDetectPackageManager = packageManagerDetectorModule.detectPackageManager; - packageManagerDetectorModule.detectInstallationMethod = () => 'direct'; - packageManagerDetectorModule.detectPackageManager = () => 'npm'; - - try { - // Mock checkForUpdates to return failed - const originalCheckForUpdates = updateCheckerModule.checkForUpdates; - updateCheckerModule.checkForUpdates = async () => ({ - status: 'check_failed', - message: 'Failed to check for updates' - }); - - // Call with beta: false (beta not supported for direct) - updateCommandModule.handleUpdateCommand({ beta: false }); - } catch (e) { - // Expected to exit - } - - // Should show direct install commands - if (process.platform === 'win32') { - const powershellCmd = consoleOutput.find(output => - output[0] && output[0].includes('irm ccs.kaitran.ca/install | iex') - ); - assert(powershellCmd, 'should show PowerShell command for Windows'); - } else { - const curlCmd = consoleOutput.find(output => - output[0] && output[0].includes('curl -fsSL ccs.kaitran.ca/install | bash') - ); - assert(curlCmd, 'should show curl command for Unix'); - } - }); - - it('should show beta not supported message for direct install with beta', function () { - // Mock package manager detection - const originalDetectInstallationMethod = packageManagerDetectorModule.detectInstallationMethod; - packageManagerDetectorModule.detectInstallationMethod = () => 'direct'; - - try { - // Mock checkForUpdates to return beta not supported - const originalCheckForUpdates = updateCheckerModule.checkForUpdates; - updateCheckerModule.checkForUpdates = async () => ({ - status: 'check_failed', - reason: 'beta_not_supported', - message: '--beta requires npm installation method' - }); - - // Call with beta: true - updateCommandModule.handleUpdateCommand({ beta: true }); - } catch (e) { - // Expected to exit - } - - // Should show beta not supported message - const betaError = consoleOutput.find(output => - output[0] && output[0].includes('[X] --beta requires npm installation') - ); - assert(betaError, 'should show beta not supported error'); - - const currentMethod = consoleOutput.find(output => - output[0] && output[0].includes('Current installation method: direct installer') - ); - assert(currentMethod, 'should show current installation method'); - - // Should show npm install instructions - const npmInstall = consoleOutput.find(output => - output[0] && output[0].includes('npm install -g @kaitranntt/ccs') - ); - assert(npmInstall, 'should show npm install instructions'); - - const ccsUpdateBeta = consoleOutput.find(output => - output[0] && output[0].includes('ccs update --beta') - ); - assert(ccsUpdateBeta, 'should show ccs update --beta instruction'); - }); }); describe('Error handling', function () { it('should handle checkForUpdates throwing error', function () { - // Mock package manager detection - const originalDetectInstallationMethod = packageManagerDetectorModule.detectInstallationMethod; - packageManagerDetectorModule.detectInstallationMethod = () => 'npm'; - try { // Mock checkForUpdates to throw error const originalCheckForUpdates = updateCheckerModule.checkForUpdates; @@ -435,10 +337,6 @@ describe.skip('Update Command Beta Channel Implementation (Phase 3)', function ( }); it('should exit with error code 1 when check fails', function () { - // Mock package manager detection - const originalDetectInstallationMethod = packageManagerDetectorModule.detectInstallationMethod; - packageManagerDetectorModule.detectInstallationMethod = () => 'npm'; - try { // Mock checkForUpdates to return failed const originalCheckForUpdates = updateCheckerModule.checkForUpdates; @@ -458,10 +356,6 @@ describe.skip('Update Command Beta Channel Implementation (Phase 3)', function ( describe('Integration with update checker', function () { it('should pass correct targetTag to checkForUpdates', function () { - // Mock package manager detection - const originalDetectInstallationMethod = packageManagerDetectorModule.detectInstallationMethod; - packageManagerDetectorModule.detectInstallationMethod = () => 'npm'; - // Track calls to checkForUpdates let checkForUpdatesCalls = []; const originalCheckForUpdates = updateCheckerModule.checkForUpdates; @@ -480,16 +374,11 @@ describe.skip('Update Command Beta Channel Implementation (Phase 3)', function ( assert.strictEqual(devCall.installMethod, 'npm'); } finally { // Restore function - packageManagerDetectorModule.detectInstallationMethod = originalDetectInstallationMethod; updateCheckerModule.checkForUpdates = originalCheckForUpdates; } }); it('should pass force parameter correctly', function () { - // Mock package manager detection - const originalDetectInstallationMethod = packageManagerDetectorModule.detectInstallationMethod; - packageManagerDetectorModule.detectInstallationMethod = () => 'npm'; - // Track calls to checkForUpdates let checkForUpdatesCalls = []; const originalCheckForUpdates = updateCheckerModule.checkForUpdates; @@ -507,9 +396,8 @@ describe.skip('Update Command Beta Channel Implementation (Phase 3)', function ( assert.strictEqual(checkForUpdatesCalls[0].force, true, 'should pass force parameter'); } finally { // Restore function - packageManagerDetectorModule.detectInstallationMethod = originalDetectInstallationMethod; updateCheckerModule.checkForUpdates = originalCheckForUpdates; } }); }); -}); \ No newline at end of file +}); diff --git a/tests/unit/commands/update-command-force-reinstall.test.js b/tests/unit/commands/update-command-force-reinstall.test.js index d12a9513..aa85a57d 100644 --- a/tests/unit/commands/update-command-force-reinstall.test.js +++ b/tests/unit/commands/update-command-force-reinstall.test.js @@ -6,7 +6,6 @@ * - Skip update check when force is true * - Target tag calculation (latest vs dev) based on beta flag * - performNpmUpdate function with targetTag parameter - * - handleDirectBetaNotSupported function for direct installs * - Success messages showing "Reinstall" vs "Update" * * NOTE: These tests are currently skipped because they require proper mocking @@ -113,9 +112,7 @@ describe.skip('Update Command - Force Reinstall Implementation (Phase 2)', funct describe('Target tag calculation based on beta flag', function () { it('should set targetTag to "latest" when beta flag is false', function () { // Mock package manager detection - const originalDetectInstallationMethod = packageManagerDetectorModule.detectInstallationMethod; const originalDetectPackageManager = packageManagerDetectorModule.detectPackageManager; - packageManagerDetectorModule.detectInstallationMethod = () => 'npm'; packageManagerDetectorModule.detectPackageManager = () => 'npm'; try { @@ -130,16 +127,13 @@ describe.skip('Update Command - Force Reinstall Implementation (Phase 2)', funct assert(latestCall, 'should install latest tag when beta is false'); } finally { // Restore original functions - packageManagerDetectorModule.detectInstallationMethod = originalDetectInstallationMethod; packageManagerDetectorModule.detectPackageManager = originalDetectPackageManager; } }); it('should set targetTag to "dev" when beta flag is true', function () { // Mock package manager detection - const originalDetectInstallationMethod = packageManagerDetectorModule.detectInstallationMethod; const originalDetectPackageManager = packageManagerDetectorModule.detectPackageManager; - packageManagerDetectorModule.detectInstallationMethod = () => 'npm'; packageManagerDetectorModule.detectPackageManager = () => 'npm'; try { @@ -154,7 +148,6 @@ describe.skip('Update Command - Force Reinstall Implementation (Phase 2)', funct assert(devCall, 'should install dev tag when beta is true'); } finally { // Restore original functions - packageManagerDetectorModule.detectInstallationMethod = originalDetectInstallationMethod; packageManagerDetectorModule.detectPackageManager = originalDetectPackageManager; } }); @@ -162,10 +155,6 @@ describe.skip('Update Command - Force Reinstall Implementation (Phase 2)', funct describe('Force flag behavior', function () { it('should show force reinstall message when force is true', function () { - // Mock package manager detection - const originalDetectInstallationMethod = packageManagerDetectorModule.detectInstallationMethod; - packageManagerDetectorModule.detectInstallationMethod = () => 'npm'; - try { // Call with force: true updateCommandModule.handleUpdateCommand({ force: true, beta: false }); @@ -176,16 +165,13 @@ describe.skip('Update Command - Force Reinstall Implementation (Phase 2)', funct ); assert(forceMessage, 'should show force reinstall message'); } finally { - // Restore original function - packageManagerDetectorModule.detectInstallationMethod = originalDetectInstallationMethod; + // No cleanup needed } }); it('should bypass update check when force is true', function () { // Mock package manager detection - const originalDetectInstallationMethod = packageManagerDetectorModule.detectInstallationMethod; const originalDetectPackageManager = packageManagerDetectorModule.detectPackageManager; - packageManagerDetectorModule.detectInstallationMethod = () => 'npm'; packageManagerDetectorModule.detectPackageManager = () => 'npm'; try { @@ -201,7 +187,6 @@ describe.skip('Update Command - Force Reinstall Implementation (Phase 2)', funct assert(npmCall.args.includes('install'), 'should call install command'); } finally { // Restore original functions - packageManagerDetectorModule.detectInstallationMethod = originalDetectInstallationMethod; packageManagerDetectorModule.detectPackageManager = originalDetectPackageManager; } }); @@ -210,9 +195,7 @@ describe.skip('Update Command - Force Reinstall Implementation (Phase 2)', funct describe('Package manager tag syntax', function () { it('should use correct tag syntax for npm', function () { // Mock package manager detection - const originalDetectInstallationMethod = packageManagerDetectorModule.detectInstallationMethod; const originalDetectPackageManager = packageManagerDetectorModule.detectPackageManager; - packageManagerDetectorModule.detectInstallationMethod = () => 'npm'; packageManagerDetectorModule.detectPackageManager = () => 'npm'; try { @@ -226,16 +209,13 @@ describe.skip('Update Command - Force Reinstall Implementation (Phase 2)', funct assert(npmCall.args.includes('-g'), 'should use global flag for npm'); } finally { // Restore original functions - packageManagerDetectorModule.detectInstallationMethod = originalDetectInstallationMethod; packageManagerDetectorModule.detectPackageManager = originalDetectPackageManager; } }); it('should use correct tag syntax for yarn', function () { // Mock package manager detection - const originalDetectInstallationMethod = packageManagerDetectorModule.detectInstallationMethod; const originalDetectPackageManager = packageManagerDetectorModule.detectPackageManager; - packageManagerDetectorModule.detectInstallationMethod = () => 'npm'; packageManagerDetectorModule.detectPackageManager = () => 'yarn'; try { @@ -249,16 +229,13 @@ describe.skip('Update Command - Force Reinstall Implementation (Phase 2)', funct assert(yarnCall.args.includes('global'), 'should use global flag for yarn'); } finally { // Restore original functions - packageManagerDetectorModule.detectInstallationMethod = originalDetectInstallationMethod; packageManagerDetectorModule.detectPackageManager = originalDetectPackageManager; } }); it('should use correct tag syntax for pnpm', function () { // Mock package manager detection - const originalDetectInstallationMethod = packageManagerDetectorModule.detectInstallationMethod; const originalDetectPackageManager = packageManagerDetectorModule.detectPackageManager; - packageManagerDetectorModule.detectInstallationMethod = () => 'npm'; packageManagerDetectorModule.detectPackageManager = () => 'pnpm'; try { @@ -272,16 +249,13 @@ describe.skip('Update Command - Force Reinstall Implementation (Phase 2)', funct assert(pnpmCall.args.includes('-g'), 'should use global flag for pnpm'); } finally { // Restore original functions - packageManagerDetectorModule.detectInstallationMethod = originalDetectInstallationMethod; packageManagerDetectorModule.detectPackageManager = originalDetectPackageManager; } }); it('should use correct tag syntax for bun', function () { // Mock package manager detection - const originalDetectInstallationMethod = packageManagerDetectorModule.detectInstallationMethod; const originalDetectPackageManager = packageManagerDetectorModule.detectPackageManager; - packageManagerDetectorModule.detectInstallationMethod = () => 'npm'; packageManagerDetectorModule.detectPackageManager = () => 'bun'; try { @@ -295,80 +269,15 @@ describe.skip('Update Command - Force Reinstall Implementation (Phase 2)', funct assert(bunCall.args.includes('-g'), 'should use global flag for bun'); } finally { // Restore original functions - packageManagerDetectorModule.detectInstallationMethod = originalDetectInstallationMethod; packageManagerDetectorModule.detectPackageManager = originalDetectPackageManager; } }); }); - describe('Direct install beta not supported', function () { - it('should show error for direct install with --beta', function () { - // Mock installation method detection as direct - const originalDetectInstallationMethod = packageManagerDetectorModule.detectInstallationMethod; - packageManagerDetectorModule.detectInstallationMethod = () => 'direct'; - - try { - // Call with force: true, beta: true - updateCommandModule.handleUpdateCommand({ force: true, beta: true }); - - // Should show beta not supported error - const betaError = consoleOutput.find(output => - output[0] && output[0].includes('--beta flag requires npm installation') - ); - assert(betaError, 'should show beta not supported error'); - - const directInstallMsg = consoleOutput.find(output => - output[0] && output[0].includes('Current installation method: direct installer') - ); - assert(directInstallMsg, 'should show direct installer message'); - - // Should exit with error code - assert(processExitCalls.length > 0, 'should call process.exit'); - assert(processExitCalls[0] === 1, 'should exit with error code 1'); - } finally { - // Restore original function - packageManagerDetectorModule.detectInstallationMethod = originalDetectInstallationMethod; - } - }); - - it('should allow force reinstall with direct install when beta is false', function () { - // Mock installation method detection as direct - const originalDetectInstallationMethod = packageManagerDetectorModule.detectInstallationMethod; - packageManagerDetectorModule.detectInstallationMethod = () => 'direct'; - - try { - // Call with force: true, beta: false - updateCommandModule.handleUpdateCommand({ force: true, beta: false }); - - // Should NOT show beta error - const betaError = consoleOutput.find(output => - output[0] && output[0].includes('--beta flag requires npm installation') - ); - assert(!betaError, 'should not show beta error when beta is false'); - - // Should call spawn for direct update - assert(spawnCalls.length > 0, 'should call spawn for direct update'); - - // Should call curl or powershell - const directUpdateCall = spawnCalls[0]; - if (process.platform === 'win32') { - assert(directUpdateCall.command === 'powershell.exe', 'should call powershell on Windows'); - } else { - assert(directUpdateCall.command === '/bin/bash', 'should call bash on Unix'); - } - } finally { - // Restore original function - packageManagerDetectorModule.detectInstallationMethod = originalDetectInstallationMethod; - } - }); - }); - describe('Success messages', function () { it('should show "Reinstalling" message when force is true', function () { // Mock package manager detection - const originalDetectInstallationMethod = packageManagerDetectorModule.detectInstallationMethod; const originalDetectPackageManager = packageManagerDetectorModule.detectPackageManager; - packageManagerDetectorModule.detectInstallationMethod = () => 'npm'; packageManagerDetectorModule.detectPackageManager = () => 'npm'; try { @@ -382,7 +291,6 @@ describe.skip('Update Command - Force Reinstall Implementation (Phase 2)', funct assert(reinstallingMsg, 'should show reinstalling message'); } finally { // Restore original functions - packageManagerDetectorModule.detectInstallationMethod = originalDetectInstallationMethod; packageManagerDetectorModule.detectPackageManager = originalDetectPackageManager; } }); @@ -391,9 +299,7 @@ describe.skip('Update Command - Force Reinstall Implementation (Phase 2)', funct describe('Combined force and beta behavior', function () { it('should handle force with beta for npm install', function () { // Mock package manager detection - const originalDetectInstallationMethod = packageManagerDetectorModule.detectInstallationMethod; const originalDetectPackageManager = packageManagerDetectorModule.detectPackageManager; - packageManagerDetectorModule.detectInstallationMethod = () => 'npm'; packageManagerDetectorModule.detectPackageManager = () => 'npm'; try { @@ -412,9 +318,8 @@ describe.skip('Update Command - Force Reinstall Implementation (Phase 2)', funct assert(forceMessage, 'should show force reinstall from dev channel message'); } finally { // Restore original functions - packageManagerDetectorModule.detectInstallationMethod = originalDetectInstallationMethod; packageManagerDetectorModule.detectPackageManager = originalDetectPackageManager; } }); }); -}); \ No newline at end of file +}); From 4b969b6870aae6b5859b9a1be0cf98b9d537ce00 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Thu, 18 Dec 2025 18:16:39 -0500 Subject: [PATCH 05/40] fix(ci): remove deprecated installer references from dev-release workflow The native shell installers (install.sh, install.ps1) were removed in the recent refactor, but the dev-release workflow still tried to update and commit them, causing CI failures. --- .github/workflows/dev-release.yml | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/.github/workflows/dev-release.yml b/.github/workflows/dev-release.yml index 80ae031a..05d90f92 100644 --- a/.github/workflows/dev-release.yml +++ b/.github/workflows/dev-release.yml @@ -88,10 +88,6 @@ jobs: jq --arg v "$NEW_VERSION" '.version = $v' package.json > package.json.tmp mv package.json.tmp package.json - # Update installers - sed -i "s/^VERSION=.*/VERSION=\"$NEW_VERSION\"/" installers/install.sh - sed -i "s/^\$Version = .*/\$Version = \"$NEW_VERSION\"/" installers/install.ps1 - - name: Publish to npm env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} @@ -154,6 +150,6 @@ jobs: run: | git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" - git add VERSION package.json installers/install.sh installers/install.ps1 + git add VERSION package.json git commit -m "chore(release): ${{ steps.bump.outputs.new }} [skip ci]" git push origin dev From 7eba223d8dd152dec91ad21713c2035f3ec9b262 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 18 Dec 2025 23:17:54 +0000 Subject: [PATCH 06/40] chore(release): 6.5.0-dev.1 [skip ci] --- VERSION | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/VERSION b/VERSION index f22d756d..83f5f342 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -6.5.0 +6.5.0-dev.1 diff --git a/package.json b/package.json index 19100fe2..96e15b53 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "6.5.0", + "version": "6.5.0-dev.1", "description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6", "keywords": [ "cli", From 8f47b8775f2c2493c05ee2be861ca3f8667cfc0e Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Thu, 18 Dec 2025 18:23:38 -0500 Subject: [PATCH 07/40] feat(ui): redesign error logs monitor with split view layout - Replace dropdown accordion with master-detail split view - Add log list panel (240px) on left with selection state - Add content viewer panel on right for better readability - Add demo mode prop for UI testing - Auto-select first log via useMemo/derived state --- ui/src/components/error-logs-monitor.tsx | 361 +++++++++++++++-------- 1 file changed, 243 insertions(+), 118 deletions(-) diff --git a/ui/src/components/error-logs-monitor.tsx b/ui/src/components/error-logs-monitor.tsx index 74418e3a..b399a67b 100644 --- a/ui/src/components/error-logs-monitor.tsx +++ b/ui/src/components/error-logs-monitor.tsx @@ -1,11 +1,11 @@ /** * Error Logs Monitor Component * - * Displays CLIProxyAPI error logs with expandable details. - * Designed to complement the AuthMonitor on the Home page. + * Displays CLIProxyAPI error logs with master-detail split view. + * Log list on left, content panel on right for better readability. */ -import { useState } from 'react'; +import { useState, useMemo } from 'react'; import { useCliproxyErrorLogs, useCliproxyErrorLogContent } from '@/hooks/use-cliproxy-stats'; import { useCliproxyStatus } from '@/hooks/use-cliproxy-stats'; import { cn, STATUS_COLORS } from '@/lib/utils'; @@ -13,14 +13,85 @@ import { Skeleton } from '@/components/ui/skeleton'; import { ScrollArea } from '@/components/ui/scroll-area'; import { AlertTriangle, - ChevronDown, - ChevronRight, FileWarning, Clock, FileText, XCircle, + FlaskConical, + Terminal, } from 'lucide-react'; +/** Demo mode mock data */ +const DEMO_ERROR_LOGS = [ + { + name: 'error-v1-chat-completions-2025-12-18T17-45-23.log', + size: 4523, + modified: Math.floor(Date.now() / 1000) - 120, + }, + { + name: 'error-v1-messages-2025-12-18T17-30-15.log', + size: 8912, + modified: Math.floor(Date.now() / 1000) - 900, + }, + { + name: 'error-v1-chat-completions-2025-12-18T16-22-08.log', + size: 2341, + modified: Math.floor(Date.now() / 1000) - 5400, + }, + { + name: 'error-v1-models-2025-12-18T14-10-55.log', + size: 1024, + modified: Math.floor(Date.now() / 1000) - 12600, + }, +]; + +const DEMO_LOG_CONTENT = `================================================================================ +REQUEST ERROR LOG +================================================================================ +Timestamp: 2025-12-18T17:45:23Z +Endpoint: /v1/chat/completions +Method: POST +Status: 429 Too Many Requests + +-------------------------------------------------------------------------------- +REQUEST HEADERS +-------------------------------------------------------------------------------- +Content-Type: application/json +Authorization: Bearer ccs-internal-managed +User-Agent: claude-code/1.0 + +-------------------------------------------------------------------------------- +REQUEST BODY +-------------------------------------------------------------------------------- +{ + "model": "gemini-claude-opus-4-5-thinking", + "messages": [ + {"role": "user", "content": "Explain quantum computing"} + ], + "max_tokens": 4096, + "stream": true +} + +-------------------------------------------------------------------------------- +RESPONSE +-------------------------------------------------------------------------------- +{ + "error": { + "message": "Rate limit exceeded. Please retry after 60 seconds.", + "type": "rate_limit_error", + "code": "rate_limit_exceeded" + } +} + +-------------------------------------------------------------------------------- +UPSTREAM API ERROR +-------------------------------------------------------------------------------- +Provider: gemini +Account: user@gmail.com +Quota: Daily limit reached (1500/1500 requests) +Retry-After: 60 +================================================================================`; + /** Format file size in human-readable format */ function formatSize(bytes: number): string { if (bytes < 1024) return `${bytes} B`; @@ -39,7 +110,6 @@ function formatRelativeTime(unixSeconds: number): string { /** Parse error log filename to extract endpoint and timestamp */ function parseErrorLogName(name: string): { endpoint: string; timestamp: string } { - // Format: error-v1-chat-completions-2025-01-15T10-30-00.log const match = name.match(/^error-(.+)-(\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2})\.log$/); if (match) { const endpoint = match[1].replace(/-/g, '/'); @@ -49,84 +119,181 @@ function parseErrorLogName(name: string): { endpoint: string; timestamp: string return { endpoint: name, timestamp: '' }; } -/** Error log content viewer with syntax highlighting */ -function ErrorLogContent({ name }: { name: string }) { - const { data: content, isLoading, error } = useCliproxyErrorLogContent(name); +/** Log content panel component */ +function LogContentPanel({ name, demo = false }: { name: string | null; demo?: boolean }) { + const { data: content, isLoading, error } = useCliproxyErrorLogContent(demo ? null : name); + // No log selected + if (!name) { + return ( +
+
+ +

Select a log to view details

+
+
+ ); + } + + // Demo mode + if (demo) { + const { endpoint } = parseErrorLogName(name); + return ( +
+
+ + {endpoint} +
+ +
+            {DEMO_LOG_CONTENT}
+          
+
+
+ ); + } + + // Loading state if (isLoading) { return ( -
+
+
); } + // Error or no content if (error || !content) { return ( -
Failed to load error log content
+
+

Failed to load log content

+
); } + // Show content + const { endpoint } = parseErrorLogName(name); return ( - -
-        {content}
-      
-
+
+
+ + {endpoint} +
+ +
+          {content}
+        
+
+
); } -export function ErrorLogsMonitor() { - const { data: status, isLoading: isStatusLoading } = useCliproxyStatus(); - const { data: logs, isLoading, error } = useCliproxyErrorLogs(status?.running ?? false); - const [expandedLog, setExpandedLog] = useState(null); +/** Error log item in the list */ +interface ErrorLogItemProps { + name: string; + size: number; + modified: number; + isSelected: boolean; + onClick: () => void; +} - // Don't show while status is loading or if proxy not running - if (isStatusLoading) { - return null; - } +function ErrorLogItem({ name, size, modified, isSelected, onClick }: ErrorLogItemProps) { + const { endpoint, timestamp } = parseErrorLogName(name); - if (!status?.running) { - return null; - } - - if (isLoading) { - return ( -
-
- - + return ( + + ); +} + +export function ErrorLogsMonitor({ demo = false }: { demo?: boolean } = {}) { + const { data: status, isLoading: isStatusLoading } = useCliproxyStatus(); + const { + data: logs, + isLoading, + error, + } = useCliproxyErrorLogs(demo ? false : (status?.running ?? false)); + // Use demo data or real data + const displayLogs = demo ? DEMO_ERROR_LOGS : logs; + + // Compute default selection (first log name or null) + const defaultLogName = useMemo(() => displayLogs?.[0]?.name ?? null, [displayLogs]); + + // Use controlled selection that defaults to first log + const [selectedLog, setSelectedLog] = useState(null); + + // Effective selection: use user selection if available, otherwise default + const effectiveSelection = selectedLog ?? defaultLogName; + + // Non-demo mode guards + if (!demo) { + if (isStatusLoading) return null; + if (!status?.running) return null; + if (isLoading) { + return ( +
+
+ + +
+
+ {[1, 2, 3].map((i) => ( + + ))} +
+
+ ); + } + if (!logs || logs.length === 0) return null; } - // Don't show if no errors (good state) - if (!logs || logs.length === 0) { - return null; - } - - const errorCount = logs.length; + const errorCount = displayLogs?.length ?? 0; return (
- {/* Header with warning styling */} + {/* Header */}
-
- -
- Error Logs + + Error Logs {errorCount} failed request{errorCount !== 1 ? 's' : ''} + {demo && ( + + + DEMO + + )}
@@ -134,78 +301,36 @@ export function ErrorLogsMonitor() {
- {/* Error logs list */} - -
- {logs.slice(0, 10).map((log) => { - const isExpanded = expandedLog === log.name; - const { endpoint, timestamp } = parseErrorLogName(log.name); - - return ( -
- - - {/* Expandable content */} - {isExpanded && ( -
- -
- )} + {/* Split View: List (left) + Content (right) */} +
+ {/* Left Panel: Log List */} +
+ +
+ {displayLogs?.slice(0, 10).map((log) => ( + setSelectedLog(log.name)} + /> + ))} +
+ {(displayLogs?.length ?? 0) > 10 && ( +
+ Showing 10 of {displayLogs?.length} logs
- ); - })} + )} +
- {/* Show more indicator */} - {logs.length > 10 && ( -
- Showing 10 of {logs.length} error logs -
- )} - + {/* Right Panel: Log Content */} + +
- {/* Footer hint */} + {/* Footer error */} {error && (
{error.message} From 78a14e873d2c7ce8dab302683b01bd90faa9664c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 18 Dec 2025 23:32:18 +0000 Subject: [PATCH 08/40] chore(release): 6.5.0-dev.2 [skip ci] --- VERSION | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/VERSION b/VERSION index 83f5f342..755d9faa 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -6.5.0-dev.1 +6.5.0-dev.2 diff --git a/package.json b/package.json index 96e15b53..8df2cea4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "6.5.0-dev.1", + "version": "6.5.0-dev.2", "description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6", "keywords": [ "cli", From 5d4f49e4bb6f9748efa89e96c342dfae3e35d02b Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Thu, 18 Dec 2025 21:06:20 -0500 Subject: [PATCH 09/40] feat(ui): add absolute path copy for error logs - add absolutePath field to CliproxyErrorLog interface - inject absolute path in routes.ts from getCliproxyWritablePath() - update copy button to use absolute path with fallback to filename - refactor error-logs-monitor to remove demo mode - add error-log-parser lib for structured log parsing --- src/cliproxy/stats-fetcher.ts | 2 + src/web-server/routes.ts | 10 +- ui/src/components/error-logs-monitor.tsx | 726 ++++++++++++++++------- ui/src/components/ui/copy-button.tsx | 17 +- ui/src/hooks/use-cliproxy-stats.ts | 2 + ui/src/lib/error-log-parser.ts | 328 ++++++++++ 6 files changed, 850 insertions(+), 235 deletions(-) create mode 100644 ui/src/lib/error-log-parser.ts diff --git a/src/cliproxy/stats-fetcher.ts b/src/cliproxy/stats-fetcher.ts index cafbc526..e7506a74 100644 --- a/src/cliproxy/stats-fetcher.ts +++ b/src/cliproxy/stats-fetcher.ts @@ -279,6 +279,8 @@ export interface CliproxyErrorLog { size: number; /** Last modified timestamp (Unix seconds) */ modified: number; + /** Absolute path to the log file (injected by backend) */ + absolutePath?: string; } /** Response from /v0/management/request-error-logs endpoint */ diff --git a/src/web-server/routes.ts b/src/web-server/routes.ts index f6b4f4b7..7ccd973d 100644 --- a/src/web-server/routes.ts +++ b/src/web-server/routes.ts @@ -25,6 +25,7 @@ import { fetchCliproxyErrorLogs, fetchCliproxyErrorLogContent, } from '../cliproxy/stats-fetcher'; +import { getCliproxyWritablePath } from '../cliproxy/config-generator'; import { listOpenAICompatProviders, getOpenAICompatProvider, @@ -1446,7 +1447,14 @@ apiRoutes.get('/cliproxy/error-logs', async (_req: Request, res: Response): Prom return; } - res.json({ files }); + // Inject absolute paths into each file entry + const logsDir = path.join(getCliproxyWritablePath(), 'logs'); + const filesWithPaths = files.map((file) => ({ + ...file, + absolutePath: path.join(logsDir, file.name), + })); + + res.json({ files: filesWithPaths }); } catch (error) { res.status(500).json({ error: (error as Error).message }); } diff --git a/ui/src/components/error-logs-monitor.tsx b/ui/src/components/error-logs-monitor.tsx index b399a67b..eb704397 100644 --- a/ui/src/components/error-logs-monitor.tsx +++ b/ui/src/components/error-logs-monitor.tsx @@ -2,191 +2,351 @@ * Error Logs Monitor Component * * Displays CLIProxyAPI error logs with master-detail split view. - * Log list on left, content panel on right for better readability. + * ETL: Parses raw logs into structured data for rich display. + * - Left panel: Log list with status code, provider, endpoint, relative time + * - Right panel: Tabbed view (Overview, Headers, Request, Response, Raw) */ -import { useState, useMemo } from 'react'; +import { useState, useMemo, useRef, useEffect } from 'react'; +import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels'; import { useCliproxyErrorLogs, useCliproxyErrorLogContent } from '@/hooks/use-cliproxy-stats'; import { useCliproxyStatus } from '@/hooks/use-cliproxy-stats'; -import { cn, STATUS_COLORS } from '@/lib/utils'; +import { cn } from '@/lib/utils'; import { Skeleton } from '@/components/ui/skeleton'; import { ScrollArea } from '@/components/ui/scroll-area'; +import { ProviderIcon } from '@/components/provider-icon'; +import { CopyButton } from '@/components/ui/copy-button'; import { AlertTriangle, FileWarning, Clock, FileText, - XCircle, - FlaskConical, Terminal, + Info, + Code, + ArrowUpRight, + ArrowDownLeft, + GripVertical, + GripHorizontal, } from 'lucide-react'; +import { + parseErrorLog, + parseFilename, + formatRelativeTime, + formatBytes, + getStatusColor, + getErrorTypeLabel, + type ParsedErrorLog, +} from '@/lib/error-log-parser'; -/** Demo mode mock data */ -const DEMO_ERROR_LOGS = [ - { - name: 'error-v1-chat-completions-2025-12-18T17-45-23.log', - size: 4523, - modified: Math.floor(Date.now() / 1000) - 120, - }, - { - name: 'error-v1-messages-2025-12-18T17-30-15.log', - size: 8912, - modified: Math.floor(Date.now() / 1000) - 900, - }, - { - name: 'error-v1-chat-completions-2025-12-18T16-22-08.log', - size: 2341, - modified: Math.floor(Date.now() / 1000) - 5400, - }, - { - name: 'error-v1-models-2025-12-18T14-10-55.log', - size: 1024, - modified: Math.floor(Date.now() / 1000) - 12600, - }, -]; +type TabType = 'overview' | 'headers' | 'request' | 'response' | 'raw'; -const DEMO_LOG_CONTENT = `================================================================================ -REQUEST ERROR LOG -================================================================================ -Timestamp: 2025-12-18T17:45:23Z -Endpoint: /v1/chat/completions -Method: POST -Status: 429 Too Many Requests - --------------------------------------------------------------------------------- -REQUEST HEADERS --------------------------------------------------------------------------------- -Content-Type: application/json -Authorization: Bearer ccs-internal-managed -User-Agent: claude-code/1.0 - --------------------------------------------------------------------------------- -REQUEST BODY --------------------------------------------------------------------------------- -{ - "model": "gemini-claude-opus-4-5-thinking", - "messages": [ - {"role": "user", "content": "Explain quantum computing"} - ], - "max_tokens": 4096, - "stream": true +/** Tab button component */ +function TabButton({ + active, + onClick, + children, + icon: Icon, +}: { + active: boolean; + onClick: () => void; + children: React.ReactNode; + icon?: React.ComponentType<{ className?: string }>; +}) { + return ( + + ); } --------------------------------------------------------------------------------- -RESPONSE --------------------------------------------------------------------------------- -{ - "error": { - "message": "Rate limit exceeded. Please retry after 60 seconds.", - "type": "rate_limit_error", - "code": "rate_limit_exceeded" +/** Status badge component */ +function StatusBadge({ code }: { code: number }) { + const colorClass = getStatusColor(code); + return ( + + {code} + + ); +} + +/** Overview tab content */ +function OverviewTab({ parsed }: { parsed: ParsedErrorLog }) { + return ( +
+ {/* Status row */} +
+ + {parsed.statusText} + + {getErrorTypeLabel(parsed.errorType)} + +
+ + {/* Key metrics grid */} +
+
+
Method
+
{parsed.method || 'N/A'}
+
+
+
Provider
+
{parsed.provider || 'N/A'}
+
+
+
Version
+
{parsed.version || 'N/A'}
+
+
+
Endpoint
+
+ {parsed.endpoint || 'N/A'} +
+
+
+ + {/* URL */} +
+
URL
+
+ {parsed.url || 'N/A'} +
+
+ + {/* Timestamp */} +
+
Timestamp
+
{parsed.timestamp || 'N/A'}
+
+ + {/* Suggestion based on error type */} + {parsed.errorType !== 'unknown' && ( +
+ +
+ {parsed.errorType === 'rate_limit' && + 'Rate limited. Consider using multiple accounts or reducing request frequency.'} + {parsed.errorType === 'auth' && + 'Authentication failed. Check credentials or re-authenticate with the provider.'} + {parsed.errorType === 'not_found' && + 'Endpoint not found. This endpoint may not exist on this provider.'} + {parsed.errorType === 'server' && + 'Server error from upstream. Retry or check provider status.'} + {parsed.errorType === 'timeout' && + 'Request timed out. Check network or increase timeout settings.'} +
+
+ )} +
+ ); +} + +/** Headers tab content */ +function HeadersTab({ headers }: { headers: Record }) { + const entries = Object.entries(headers); + if (entries.length === 0) { + return
No headers available
; } + + return ( + +
+ {entries.map(([key, value]) => ( +
+ {key}: + {value} +
+ ))} +
+
+ ); } --------------------------------------------------------------------------------- -UPSTREAM API ERROR --------------------------------------------------------------------------------- -Provider: gemini -Account: user@gmail.com -Quota: Daily limit reached (1500/1500 requests) -Retry-After: 60 -================================================================================`; - -/** Format file size in human-readable format */ -function formatSize(bytes: number): string { - if (bytes < 1024) return `${bytes} B`; - if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; - return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; -} - -/** Format timestamp to relative time */ -function formatRelativeTime(unixSeconds: number): string { - const diff = Math.floor(Date.now() / 1000 - unixSeconds); - if (diff < 60) return 'just now'; - if (diff < 3600) return `${Math.floor(diff / 60)}m ago`; - if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`; - return `${Math.floor(diff / 86400)}d ago`; -} - -/** Parse error log filename to extract endpoint and timestamp */ -function parseErrorLogName(name: string): { endpoint: string; timestamp: string } { - const match = name.match(/^error-(.+)-(\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2})\.log$/); - if (match) { - const endpoint = match[1].replace(/-/g, '/'); - const timestamp = match[2].replace(/T/, ' ').replace(/-/g, ':'); - return { endpoint: `/${endpoint}`, timestamp }; +/** JSON/Body tab content */ +function BodyTab({ content, label }: { content: string; label: string }) { + if (!content || content.trim() === '') { + return
No {label.toLowerCase()} body
; } - return { endpoint: name, timestamp: '' }; + + // Try to format as JSON + let formatted = content; + let isJson = false; + try { + const parsed = JSON.parse(content); + formatted = JSON.stringify(parsed, null, 2); + isJson = true; + } catch { + // Not JSON, use as-is + } + + return ( + +
+        {formatted}
+      
+
+ ); } -/** Log content panel component */ -function LogContentPanel({ name, demo = false }: { name: string | null; demo?: boolean }) { - const { data: content, isLoading, error } = useCliproxyErrorLogContent(demo ? null : name); +/** Raw tab content */ +function RawTab({ content }: { content: string }) { + return ( + +
+        {content}
+      
+
+ ); +} + +/** Log content panel with tabs */ +function LogContentPanel({ name, absolutePath }: { name: string | null; absolutePath?: string }) { + const [activeTab, setActiveTab] = useState('overview'); + const { data: content, isLoading, error } = useCliproxyErrorLogContent(name); + + // Parse log content + const parsed = useMemo(() => { + if (!content) return null; + return parseErrorLog(content); + }, [content]); // No log selected if (!name) { return (
-
- -

Select a log to view details

+
+ +

Select a log to view details

); } - // Demo mode - if (demo) { - const { endpoint } = parseErrorLogName(name); - return ( -
-
- - {endpoint} -
- -
-            {DEMO_LOG_CONTENT}
-          
-
-
- ); - } - // Loading state if (isLoading) { return ( -
- - - - +
+ + + +
); } // Error or no content - if (error || !content) { + if (error || !content || !parsed) { return (
-

Failed to load log content

+

Failed to load log content

); } - // Show content - const { endpoint } = parseErrorLogName(name); return ( -
-
- - {endpoint} +
+ {/* Header with status */} +
+
+ + + {parsed.provider}/{parsed.endpoint || 'unknown'} + + {/* Copy Absolute Path Button */} + {name && ( + + )} +
+
+ {/* Copy Raw Content Button */} + {content && ( + + )} + + {parsed.method} + +
+
+ + {/* Tabs */} +
+ setActiveTab('overview')} + icon={Info} + > + Overview + + setActiveTab('headers')} + icon={Code} + > + Headers + + setActiveTab('request')} + icon={ArrowUpRight} + > + Request + + setActiveTab('response')} + icon={ArrowDownLeft} + > + Response + + setActiveTab('raw')} icon={FileText}> + Raw + +
+ + {/* Tab content */} +
+ {activeTab === 'overview' && } + {activeTab === 'headers' && } + {activeTab === 'request' && } + {activeTab === 'response' && } + {activeTab === 'raw' && }
- -
-          {content}
-        
-
); } @@ -201,52 +361,146 @@ interface ErrorLogItemProps { } function ErrorLogItem({ name, size, modified, isSelected, onClick }: ErrorLogItemProps) { - const { endpoint, timestamp } = parseErrorLogName(name); + const parsed = useMemo(() => parseFilename(name), [name]); return ( ); } -export function ErrorLogsMonitor({ demo = false }: { demo?: boolean } = {}) { +export function ErrorLogsMonitor() { const { data: status, isLoading: isStatusLoading } = useCliproxyStatus(); - const { - data: logs, - isLoading, - error, - } = useCliproxyErrorLogs(demo ? false : (status?.running ?? false)); - // Use demo data or real data - const displayLogs = demo ? DEMO_ERROR_LOGS : logs; + const { data: logs, isLoading, error } = useCliproxyErrorLogs(status?.running ?? false); + + // Vertical resize state + const [height, setHeight] = useState(500); + const [isResizing, setIsResizing] = useState(false); + const containerRef = useRef(null); + const scrollIntervalRef = useRef(null); + + // Auto-scroll handler + const stopAutoScroll = () => { + if (scrollIntervalRef.current) { + clearInterval(scrollIntervalRef.current); + scrollIntervalRef.current = null; + } + }; + + // Resize handlers + useEffect(() => { + if (!isResizing) return; + + const handleMouseMove = (e: MouseEvent) => { + const container = containerRef.current; + if (!container) return; + + const rect = container.getBoundingClientRect(); + const containerTopDoc = rect.top + window.scrollY; + const newHeight = e.pageY - containerTopDoc; + + // Constrain height (min 300, no max) + setHeight(Math.max(300, newHeight)); + + // Auto-scroll logic + const viewportHeight = window.innerHeight; + const distFromBottom = viewportHeight - e.clientY; + const scrollSpeed = 15; + + stopAutoScroll(); + + if (distFromBottom < 50) { + scrollIntervalRef.current = setInterval(() => { + window.scrollBy(0, scrollSpeed); + }, 16); + } else if (e.clientY < 50) { + scrollIntervalRef.current = setInterval(() => { + window.scrollBy(0, -scrollSpeed); + }, 16); + } + }; + + const handleMouseUp = () => { + setIsResizing(false); + stopAutoScroll(); + document.body.style.cursor = 'default'; + document.body.style.userSelect = 'auto'; + }; + + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + document.body.style.cursor = 'row-resize'; + document.body.style.userSelect = 'none'; + + return () => { + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + document.body.style.cursor = 'default'; + document.body.style.userSelect = 'auto'; + stopAutoScroll(); + }; + }, [isResizing]); + + const startResizing = (e: React.MouseEvent) => { + e.preventDefault(); + setIsResizing(true); + }; // Compute default selection (first log name or null) - const defaultLogName = useMemo(() => displayLogs?.[0]?.name ?? null, [displayLogs]); + const defaultLogName = useMemo(() => logs?.[0]?.name ?? null, [logs]); // Use controlled selection that defaults to first log const [selectedLog, setSelectedLog] = useState(null); @@ -254,87 +508,109 @@ export function ErrorLogsMonitor({ demo = false }: { demo?: boolean } = {}) { // Effective selection: use user selection if available, otherwise default const effectiveSelection = selectedLog ?? defaultLogName; - // Non-demo mode guards - if (!demo) { - if (isStatusLoading) return null; - if (!status?.running) return null; - if (isLoading) { - return ( -
-
- - -
-
- {[1, 2, 3].map((i) => ( - - ))} -
-
- ); - } - if (!logs || logs.length === 0) return null; - } + // Get absolute path for the selected log + const selectedAbsolutePath = useMemo(() => { + if (!effectiveSelection || !logs) return undefined; + const log = logs.find((l) => l.name === effectiveSelection); + return log?.absolutePath; + }, [effectiveSelection, logs]); - const errorCount = displayLogs?.length ?? 0; + // Guards + if (isStatusLoading) return null; + if (!status?.running) return null; + if (isLoading) { + return ( +
+
+ + +
+
+ {[1, 2, 3].map((i) => ( + + ))} +
+
+ ); + } + if (!logs || logs.length === 0) return null; + + const errorCount = logs.length; return ( -
+
{/* Header */} -
-
- - Error Logs - +
+
+ + Error Logs + {errorCount} failed request{errorCount !== 1 ? 's' : ''} - {demo && ( - - - DEMO - - )}
-
- +
+ CLIProxy Diagnostics
- {/* Split View: List (left) + Content (right) */} -
- {/* Left Panel: Log List */} -
- -
- {displayLogs?.slice(0, 10).map((log) => ( - setSelectedLog(log.name)} - /> - ))} -
- {(displayLogs?.length ?? 0) > 10 && ( -
- Showing 10 of {displayLogs?.length} logs + {/* Resizable Panel Layout */} +
+ + {/* Left Panel: Log List */} + + +
+ {logs.slice(0, 50).map((log) => ( + setSelectedLog(log.name)} + /> + ))}
- )} -
-
+ {logs.length > 50 && ( +
+ Showing 50 of {logs.length} logs +
+ )} + + - {/* Right Panel: Log Content */} - + {/* Resize Handle */} + +
+
+ +
+ + + {/* Right Panel: Log Content */} + + + +
- {/* Footer error */} - {error && ( -
+ {/* Use standard footer if error, otherwise show resize handle */} + {error ? ( +
{error.message}
+ ) : ( +
+ +
)}
); diff --git a/ui/src/components/ui/copy-button.tsx b/ui/src/components/ui/copy-button.tsx index 5453f328..3f296151 100644 --- a/ui/src/components/ui/copy-button.tsx +++ b/ui/src/components/ui/copy-button.tsx @@ -3,19 +3,21 @@ import { useState } from 'react'; import { cn } from '@/lib/utils'; import { Button } from '@/components/ui/button'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; +import { type VariantProps } from 'class-variance-authority'; +import { type buttonVariants } from '@/components/ui/button-variants'; interface CopyButtonProps { value: string; className?: string; - variant?: 'default' | 'outline' | 'ghost' | 'secondary'; - size?: 'default' | 'sm' | 'lg' | 'icon'; + variant?: VariantProps['variant']; + size?: VariantProps['size']; label?: string; } export function CopyButton({ value, className, - variant = 'ghost', + variant = 'outline', size = 'icon', label = 'Copy to clipboard', }: CopyButtonProps) { @@ -34,19 +36,16 @@ export function CopyButton({ diff --git a/ui/src/hooks/use-cliproxy-stats.ts b/ui/src/hooks/use-cliproxy-stats.ts index 3bea8b29..2e825e01 100644 --- a/ui/src/hooks/use-cliproxy-stats.ts +++ b/ui/src/hooks/use-cliproxy-stats.ts @@ -136,6 +136,8 @@ export interface CliproxyErrorLog { name: string; size: number; modified: number; + /** Absolute path to the log file (injected by backend) */ + absolutePath?: string; } /** diff --git a/ui/src/lib/error-log-parser.ts b/ui/src/lib/error-log-parser.ts new file mode 100644 index 00000000..3afa030c --- /dev/null +++ b/ui/src/lib/error-log-parser.ts @@ -0,0 +1,328 @@ +/** + * Error Log Parser Utility + * + * Parses CLIProxy error log content into structured data for display. + * Extracts request info, headers, body, and response sections. + */ + +/** Parsed error log structure */ +export interface ParsedErrorLog { + // Request Info + version: string; + url: string; + method: string; + timestamp: string; + + // Response + statusCode: number; + statusText: string; + + // Sections (raw strings) + requestHeaders: Record; + requestBody: string; + responseHeaders: Record; + responseBody: string; + + // Computed metadata + provider: string; + endpoint: string; + isClientError: boolean; + isServerError: boolean; + errorType: 'rate_limit' | 'auth' | 'not_found' | 'server' | 'timeout' | 'unknown'; +} + +/** Parsed filename metadata */ +export interface ParsedFilename { + provider: string; + endpoint: string; + timestamp: Date; + raw: string; +} + +/** + * Parse error log filename to extract provider, endpoint, and timestamp + * Format: error-api-provider-{provider}-api-{endpoint}-{timestamp}-{id}.log + */ +export function parseFilename(name: string): ParsedFilename { + const result: ParsedFilename = { + provider: 'unknown', + endpoint: 'unknown', + timestamp: new Date(), + raw: name, + }; + + // Extract provider: error-api-provider-{PROVIDER}-api-... + const providerMatch = name.match(/error-api-provider-([^-]+)-/); + if (providerMatch) { + result.provider = providerMatch[1]; + } + + // Extract endpoint from after provider: ...-api-{ENDPOINT}-{timestamp} + // Example: error-api-provider-agy-api-event_logging-batch-2025-12-18T185041-... + const endpointMatch = name.match(/-api-([a-z_]+(?:-[a-z_]+)*)-\d{4}-\d{2}-\d{2}T/i); + if (endpointMatch) { + result.endpoint = endpointMatch[1].replace(/-/g, '/'); + } + + // Extract timestamp: 2025-12-18T185041 + const tsMatch = name.match(/(\d{4}-\d{2}-\d{2}T\d{6})/); + if (tsMatch) { + const ts = tsMatch[1]; + // Parse: 2025-12-18T185041 → 2025-12-18T18:50:41 + const formatted = `${ts.slice(0, 10)}T${ts.slice(11, 13)}:${ts.slice(13, 15)}:${ts.slice(15, 17)}`; + result.timestamp = new Date(formatted); + } + + return result; +} + +/** + * Parse raw error log content into structured data + */ +export function parseErrorLog(content: string): ParsedErrorLog { + const result: ParsedErrorLog = { + version: '', + url: '', + method: '', + timestamp: '', + statusCode: 0, + statusText: '', + requestHeaders: {}, + requestBody: '', + responseHeaders: {}, + responseBody: '', + provider: '', + endpoint: '', + isClientError: false, + isServerError: false, + errorType: 'unknown', + }; + + // Split into sections + const sections = content.split(/^===\s*(.+?)\s*===$/m); + + let currentSection = ''; + for (let i = 0; i < sections.length; i++) { + const part = sections[i].trim(); + + if (part === 'REQUEST INFO') { + currentSection = 'request_info'; + continue; + } else if (part === 'HEADERS') { + currentSection = 'headers'; + continue; + } else if (part === 'REQUEST BODY') { + currentSection = 'request_body'; + continue; + } else if (part === 'RESPONSE') { + currentSection = 'response'; + continue; + } + + // Parse section content + switch (currentSection) { + case 'request_info': + parseRequestInfo(part, result); + break; + case 'headers': + result.requestHeaders = parseHeaders(part); + break; + case 'request_body': + result.requestBody = part; + break; + case 'response': + parseResponse(part, result); + break; + } + } + + // Compute derived fields + computeDerivedFields(result); + + return result; +} + +/** Parse REQUEST INFO section */ +function parseRequestInfo(content: string, result: ParsedErrorLog): void { + const lines = content.split('\n'); + for (const line of lines) { + const [key, ...valueParts] = line.split(':'); + const value = valueParts.join(':').trim(); + + switch (key?.trim()?.toLowerCase()) { + case 'version': + result.version = value; + break; + case 'url': + result.url = value; + break; + case 'method': + result.method = value; + break; + case 'timestamp': + result.timestamp = value; + break; + } + } +} + +/** Parse headers into key-value object */ +function parseHeaders(content: string): Record { + const headers: Record = {}; + const lines = content.split('\n'); + + for (const line of lines) { + const colonIndex = line.indexOf(':'); + if (colonIndex > 0) { + const key = line.slice(0, colonIndex).trim(); + const value = line.slice(colonIndex + 1).trim(); + if (key) headers[key] = value; + } + } + + return headers; +} + +/** Parse RESPONSE section */ +function parseResponse(content: string, result: ParsedErrorLog): void { + const lines = content.split('\n'); + let headersEnded = false; + const bodyLines: string[] = []; + + for (const line of lines) { + // First line might be "Status: 404" + if (line.startsWith('Status:')) { + const statusStr = line.replace('Status:', '').trim(); + const statusParts = statusStr.split(/\s+/); + result.statusCode = parseInt(statusParts[0], 10) || 0; + result.statusText = statusParts.slice(1).join(' ') || getStatusText(result.statusCode); + continue; + } + + // Check for empty line (separates headers from body) + if (line.trim() === '' && !headersEnded) { + headersEnded = true; + continue; + } + + // Parse response headers + if (!headersEnded) { + const colonIndex = line.indexOf(':'); + if (colonIndex > 0) { + const key = line.slice(0, colonIndex).trim(); + const value = line.slice(colonIndex + 1).trim(); + if (key) result.responseHeaders[key] = value; + } + } else { + bodyLines.push(line); + } + } + + result.responseBody = bodyLines.join('\n').trim(); +} + +/** Compute derived fields from parsed data */ +function computeDerivedFields(result: ParsedErrorLog): void { + // Extract provider from URL: /api/provider/{PROVIDER}/... + const providerMatch = result.url.match(/\/api\/provider\/([^/]+)/); + if (providerMatch) { + result.provider = providerMatch[1]; + } + + // Extract endpoint from URL + const endpointMatch = result.url.match(/\/api\/provider\/[^/]+\/api\/(.+)/); + if (endpointMatch) { + result.endpoint = endpointMatch[1]; + } + + // Status code classification + result.isClientError = result.statusCode >= 400 && result.statusCode < 500; + result.isServerError = result.statusCode >= 500; + + // Error type classification + if (result.statusCode === 429) { + result.errorType = 'rate_limit'; + } else if (result.statusCode === 401 || result.statusCode === 403) { + result.errorType = 'auth'; + } else if (result.statusCode === 404) { + result.errorType = 'not_found'; + } else if (result.statusCode >= 500) { + result.errorType = 'server'; + } else if (result.statusCode === 408 || result.statusCode === 504) { + result.errorType = 'timeout'; + } +} + +/** Get status text for common codes */ +function getStatusText(code: number): string { + const statusTexts: Record = { + 400: 'Bad Request', + 401: 'Unauthorized', + 403: 'Forbidden', + 404: 'Not Found', + 408: 'Request Timeout', + 429: 'Too Many Requests', + 500: 'Internal Server Error', + 502: 'Bad Gateway', + 503: 'Service Unavailable', + 504: 'Gateway Timeout', + }; + return statusTexts[code] || ''; +} + +/** + * Format Unix timestamp (seconds) to relative time string + */ +export function formatRelativeTime(modifiedSeconds: number): string { + const now = Date.now(); + const modified = modifiedSeconds * 1000; // Convert to milliseconds + const diff = now - modified; + + const seconds = Math.floor(diff / 1000); + const minutes = Math.floor(seconds / 60); + const hours = Math.floor(minutes / 60); + const days = Math.floor(hours / 24); + + if (seconds < 60) return 'just now'; + if (minutes < 60) return `${minutes}m ago`; + if (hours < 24) return `${hours}h ago`; + if (days < 7) return `${days}d ago`; + + // Format as date for older logs + const date = new Date(modified); + return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); +} + +/** + * Format bytes to human readable size + */ +export function formatBytes(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + +/** + * Get status code badge color class + */ +export function getStatusColor(code: number): string { + if (code >= 500) return 'text-red-500'; + if (code === 429) return 'text-orange-500'; + if (code >= 400) return 'text-yellow-500'; + return 'text-gray-500'; +} + +/** + * Get error type label + */ +export function getErrorTypeLabel(type: ParsedErrorLog['errorType']): string { + const labels: Record = { + rate_limit: 'Rate Limited', + auth: 'Auth Error', + not_found: 'Not Found', + server: 'Server Error', + timeout: 'Timeout', + unknown: 'Error', + }; + return labels[type] || 'Error'; +} From 25cfb5b65e2d53d2a6038b2684cfc4f2c5a145c6 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 19 Dec 2025 02:07:47 +0000 Subject: [PATCH 10/40] chore(release): 6.5.0-dev.3 [skip ci] --- VERSION | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/VERSION b/VERSION index 755d9faa..aa6b2bc9 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -6.5.0-dev.2 +6.5.0-dev.3 diff --git a/package.json b/package.json index 8df2cea4..9a2a17e0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "6.5.0-dev.2", + "version": "6.5.0-dev.3", "description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6", "keywords": [ "cli", From 5d343260c7307c2d7ac8da92eb5f94c7f764d08c Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Thu, 18 Dec 2025 22:38:25 -0500 Subject: [PATCH 11/40] feat(global-env): add global environment variables injection for third-party profiles - add global_env config section to unified config types and loader - inject global env vars at runtime for cliproxy and copilot profiles - add GET/PUT /api/global-env endpoints for UI management - add Global Env tab to Settings page with enable toggle and var management - add GlobalEnvIndicator component showing injected vars in profile editors - support ?tab=globalenv query param for direct navigation from indicators --- src/ccs.ts | 5 + src/cliproxy/config-generator.ts | 32 +- src/config/unified-config-loader.ts | 41 + src/config/unified-config-types.ts | 28 + src/copilot/copilot-executor.ts | 8 +- src/web-server/routes.ts | 49 +- .../components/cliproxy/provider-editor.tsx | 9 +- .../copilot/copilot-config-form.tsx | 9 +- ui/src/components/global-env-indicator.tsx | 132 ++ ui/src/components/profile-editor.tsx | 9 +- ui/src/pages/settings.tsx | 1122 ++++++++++++----- 11 files changed, 1091 insertions(+), 353 deletions(-) create mode 100644 ui/src/components/global-env-indicator.tsx diff --git a/src/ccs.ts b/src/ccs.ts index bc48ea45..d5c3cc68 100644 --- a/src/ccs.ts +++ b/src/ccs.ts @@ -11,6 +11,7 @@ import { displayWebSearchStatus, getWebSearchHookEnv, } from './utils/websearch-manager'; +import { getGlobalEnvConfig } from './config/unified-config-loader'; // Import extracted command handlers import { handleVersionCommand } from './commands/version-command'; @@ -494,7 +495,11 @@ async function main(): Promise { // Use --settings flag (backward compatible) const expandedSettingsPath = getSettingsPath(profileInfo.name); const webSearchEnv = getWebSearchHookEnv(); + // Get global env vars (DISABLE_TELEMETRY, etc.) for third-party profiles + const globalEnvConfig = getGlobalEnvConfig(); + const globalEnv = globalEnvConfig.enabled ? globalEnvConfig.env : {}; const envVars: NodeJS.ProcessEnv = { + ...globalEnv, ...webSearchEnv, CCS_PROFILE_TYPE: 'settings', // Signal to WebSearch hook this is a third-party provider }; diff --git a/src/cliproxy/config-generator.ts b/src/cliproxy/config-generator.ts index 7f572dba..df134a36 100644 --- a/src/cliproxy/config-generator.ts +++ b/src/cliproxy/config-generator.ts @@ -14,7 +14,7 @@ import { getCcsDir } from '../utils/config-manager'; import { warn } from '../utils/ui'; import { CLIProxyProvider, ProviderConfig, ProviderModelMapping } from './types'; import { getModelMappingFromConfig, getEnvVarsFromConfig } from './base-config-loader'; -import { loadOrCreateUnifiedConfig } from '../config/unified-config-loader'; +import { loadOrCreateUnifiedConfig, getGlobalEnvConfig } from '../config/unified-config-loader'; /** Settings file structure for user overrides */ interface ProviderSettings { @@ -380,6 +380,18 @@ export function getClaudeEnvVars( }; } +/** + * Get global env vars to inject into all third-party profiles. + * Returns empty object if disabled. + */ +function getGlobalEnvVars(): Record { + const globalEnvConfig = getGlobalEnvConfig(); + if (!globalEnvConfig.enabled) { + return {}; + } + return globalEnvConfig.env; +} + /** * Get effective environment variables for provider * @@ -388,7 +400,7 @@ export function getClaudeEnvVars( * 2. User settings file (~/.ccs/{provider}.settings.json) if exists * 3. Bundled defaults from PROVIDER_CONFIGS * - * This allows users to customize model mappings without code changes. + * All results are merged with global_env vars (telemetry/reporting disables). * User takes full responsibility for custom settings. */ export function getEffectiveEnvVars( @@ -396,6 +408,9 @@ export function getEffectiveEnvVars( port: number = CLIPROXY_DEFAULT_PORT, customSettingsPath?: string ): NodeJS.ProcessEnv { + // Get global env vars (DISABLE_TELEMETRY, etc.) + const globalEnv = getGlobalEnvVars(); + // Priority 1: Custom settings path (for user-defined variants) if (customSettingsPath) { const expandedPath = customSettingsPath.replace(/^~/, require('os').homedir()); @@ -405,8 +420,8 @@ export function getEffectiveEnvVars( const settings: ProviderSettings = JSON.parse(content); if (settings.env && typeof settings.env === 'object') { - // Custom variant settings found - use them - return settings.env; + // Custom variant settings found - merge with global env + return { ...globalEnv, ...settings.env }; } } catch { // Invalid JSON - fall through to provider defaults @@ -427,9 +442,8 @@ export function getEffectiveEnvVars( const settings: ProviderSettings = JSON.parse(content); if (settings.env && typeof settings.env === 'object') { - // User override found - use their settings - // Note: User is responsible for correctness - return settings.env; + // User override found - merge with global env + return { ...globalEnv, ...settings.env }; } } catch { // Invalid JSON or structure - fall through to defaults @@ -437,8 +451,8 @@ export function getEffectiveEnvVars( } } - // No override or invalid - use bundled defaults - return getClaudeEnvVars(provider, port); + // No override or invalid - use bundled defaults merged with global env + return { ...globalEnv, ...getClaudeEnvVars(provider, port) }; } /** diff --git a/src/config/unified-config-loader.ts b/src/config/unified-config-loader.ts index de6efa48..80e0ad64 100644 --- a/src/config/unified-config-loader.ts +++ b/src/config/unified-config-loader.ts @@ -15,6 +15,8 @@ import { createEmptyUnifiedConfig, UNIFIED_CONFIG_VERSION, DEFAULT_COPILOT_CONFIG, + DEFAULT_GLOBAL_ENV, + GlobalEnvConfig, } from './unified-config-types'; import { isUnifiedConfigEnabled } from './feature-flags'; @@ -170,6 +172,11 @@ function mergeWithDefaults(partial: Partial): UnifiedConfig { wait_on_limit: partial.copilot?.wait_on_limit ?? DEFAULT_COPILOT_CONFIG.wait_on_limit, model: partial.copilot?.model ?? DEFAULT_COPILOT_CONFIG.model, }, + // Global env - injected into all non-Claude subscription profiles + global_env: { + enabled: partial.global_env?.enabled ?? true, + env: partial.global_env?.env ?? { ...DEFAULT_GLOBAL_ENV }, + }, }; } @@ -336,6 +343,28 @@ function generateYamlWithComments(config: UnifiedConfig): string { lines.push(''); } + // Global env section + if (config.global_env) { + lines.push('# ----------------------------------------------------------------------------'); + lines.push( + '# Global Environment Variables: Injected into all non-Claude subscription profiles' + ); + lines.push('# These env vars disable telemetry/reporting for third-party providers.'); + lines.push('# Configure via Dashboard (`ccs config`) > Global Env tab.'); + lines.push('#'); + lines.push('# Default variables:'); + lines.push('# DISABLE_BUG_COMMAND: Disables /bug command (not supported by proxy)'); + lines.push('# DISABLE_ERROR_REPORTING: Disables error reporting to Anthropic'); + lines.push('# DISABLE_TELEMETRY: Disables usage telemetry'); + lines.push('# ----------------------------------------------------------------------------'); + lines.push( + yaml + .dump({ global_env: config.global_env }, { indent: 2, lineWidth: -1, quotingType: '"' }) + .trim() + ); + lines.push(''); + } + return lines.join('\n'); } @@ -462,3 +491,15 @@ export function getWebSearchConfig(): { gemini: config.websearch?.gemini, }; } + +/** + * Get global_env configuration. + * Returns defaults if not configured. + */ +export function getGlobalEnvConfig(): GlobalEnvConfig { + const config = loadOrCreateUnifiedConfig(); + return { + enabled: config.global_env?.enabled ?? true, + env: config.global_env?.env ?? { ...DEFAULT_GLOBAL_ENV }, + }; +} diff --git a/src/config/unified-config-types.ts b/src/config/unified-config-types.ts index a692173c..0379c9ea 100644 --- a/src/config/unified-config-types.ts +++ b/src/config/unified-config-types.ts @@ -185,6 +185,28 @@ export interface CopilotConfig { haiku_model?: string; } +/** + * Global environment variables configuration. + * These env vars are injected into ALL non-Claude subscription profiles. + * Useful for disabling telemetry, bug commands, error reporting, etc. + */ +export interface GlobalEnvConfig { + /** Enable global env injection (default: true) */ + enabled: boolean; + /** Environment variables to inject */ + env: Record; +} + +/** + * Default global env vars for third-party profiles. + * These disable Claude Code telemetry/reporting since we're using proxy. + */ +export const DEFAULT_GLOBAL_ENV: Record = { + DISABLE_BUG_COMMAND: '1', + DISABLE_ERROR_REPORTING: '1', + DISABLE_TELEMETRY: '1', +}; + /** * WebSearch configuration. * Uses CLI tools (Gemini CLI, Grok CLI, OpenCode) for third-party profiles. @@ -234,6 +256,8 @@ export interface UnifiedConfig { preferences: PreferencesConfig; /** WebSearch configuration */ websearch?: WebSearchConfig; + /** Global environment variables for all non-Claude subscription profiles */ + global_env?: GlobalEnvConfig; /** Copilot API configuration (GitHub Copilot proxy) */ copilot?: CopilotConfig; } @@ -307,6 +331,10 @@ export function createEmptyUnifiedConfig(): UnifiedConfig { }, }, }, + global_env: { + enabled: true, + env: { ...DEFAULT_GLOBAL_ENV }, + }, copilot: { ...DEFAULT_COPILOT_CONFIG }, }; } diff --git a/src/copilot/copilot-executor.ts b/src/copilot/copilot-executor.ts index 012fb385..794cf8ff 100644 --- a/src/copilot/copilot-executor.ts +++ b/src/copilot/copilot-executor.ts @@ -7,6 +7,7 @@ import { spawn } from 'child_process'; import { CopilotConfig } from '../config/unified-config-types'; +import { getGlobalEnvConfig } from '../config/unified-config-loader'; import { checkAuthStatus, isCopilotApiInstalled } from './copilot-auth'; import { isDaemonRunning, startDaemon } from './copilot-daemon'; import { ensureCopilotApi } from './copilot-package-manager'; @@ -127,9 +128,14 @@ export async function executeCopilotProfile( // Generate environment for Claude const copilotEnv = generateCopilotEnv(config); - // Merge with current environment + // Get global env vars (DISABLE_TELEMETRY, etc.) for third-party profiles + const globalEnvConfig = getGlobalEnvConfig(); + const globalEnv = globalEnvConfig.enabled ? globalEnvConfig.env : {}; + + // Merge with current environment (global env first, copilot overrides) const env = { ...process.env, + ...globalEnv, ...copilotEnv, }; diff --git a/src/web-server/routes.ts b/src/web-server/routes.ts index 7ccd973d..9e1cd207 100644 --- a/src/web-server/routes.ts +++ b/src/web-server/routes.ts @@ -1789,7 +1789,7 @@ import { getInstalledVersion as getCopilotInstalledVersion, } from '../copilot'; import { DEFAULT_COPILOT_CONFIG } from '../config/unified-config-types'; -import { loadOrCreateUnifiedConfig } from '../config/unified-config-loader'; +import { loadOrCreateUnifiedConfig, getGlobalEnvConfig } from '../config/unified-config-loader'; /** * GET /api/copilot/status - Get Copilot status (auth + daemon + install info) @@ -2081,3 +2081,50 @@ apiRoutes.put('/copilot/settings/raw', (req: Request, res: Response): void => { res.status(500).json({ error: (error as Error).message }); } }); + +// ==================== Global Environment Variables ==================== + +/** + * GET /api/global-env - Get global environment variables configuration + * Returns the global_env section from config.yaml + */ +apiRoutes.get('/global-env', (_req: Request, res: Response): void => { + try { + const config = getGlobalEnvConfig(); + res.json(config); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + +/** + * PUT /api/global-env - Update global environment variables configuration + * Updates the global_env section in config.yaml + */ +apiRoutes.put('/global-env', (req: Request, res: Response): void => { + try { + const { enabled, env } = req.body; + const config = loadOrCreateUnifiedConfig(); + + // Validate env is an object with string values + if (env !== undefined && typeof env === 'object' && env !== null) { + for (const [key, value] of Object.entries(env)) { + if (typeof value !== 'string') { + res.status(400).json({ error: `Invalid value for ${key}: must be a string` }); + return; + } + } + } + + // Update global_env section + config.global_env = { + enabled: enabled ?? config.global_env?.enabled ?? true, + env: env ?? config.global_env?.env ?? {}, + }; + + saveUnifiedConfig(config); + res.json({ success: true, config: config.global_env }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); diff --git a/ui/src/components/cliproxy/provider-editor.tsx b/ui/src/components/cliproxy/provider-editor.tsx index 98b2f5bf..0fd9632e 100644 --- a/ui/src/components/cliproxy/provider-editor.tsx +++ b/ui/src/components/cliproxy/provider-editor.tsx @@ -57,6 +57,7 @@ import { useDeletePreset, } from '@/hooks/use-cliproxy'; import { cn } from '@/lib/utils'; +import { GlobalEnvIndicator } from '@/components/global-env-indicator'; import { usePrivacy, PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context'; // Lazy load CodeEditor @@ -543,7 +544,7 @@ export function ProviderEditor({ Invalid JSON syntax
)} -
+
+ {/* Global Env Indicator */} +
+
+ +
+
); diff --git a/ui/src/components/copilot/copilot-config-form.tsx b/ui/src/components/copilot/copilot-config-form.tsx index 80cce9db..2311c6b2 100644 --- a/ui/src/components/copilot/copilot-config-form.tsx +++ b/ui/src/components/copilot/copilot-config-form.tsx @@ -31,6 +31,7 @@ import { useCopilot, type CopilotModel, type CopilotPlanTier } from '@/hooks/use import { Loader2, Save, Code2, X, Info, RefreshCw, Sparkles, Zap, Check } from 'lucide-react'; import { toast } from 'sonner'; import { ConfirmDialog } from '@/components/confirm-dialog'; +import { GlobalEnvIndicator } from '@/components/global-env-indicator'; // Lazy load CodeEditor const CodeEditor = lazy(() => @@ -726,7 +727,7 @@ export function CopilotConfigForm() { Invalid JSON syntax
)} -
+
+ {/* Global Env Indicator */} +
+
+ +
+
); diff --git a/ui/src/components/global-env-indicator.tsx b/ui/src/components/global-env-indicator.tsx new file mode 100644 index 00000000..b6cc0cf3 --- /dev/null +++ b/ui/src/components/global-env-indicator.tsx @@ -0,0 +1,132 @@ +/** + * Global Environment Variables Indicator + * + * Shows which env vars from global_env will be injected at runtime. + * Displayed below the Raw Configuration (JSON) section in profile editors. + */ + +import { useState, useEffect } from 'react'; +import { Link } from 'react-router-dom'; +import { Settings2, ChevronDown, ChevronUp, ExternalLink, Info } from 'lucide-react'; +import { Button } from '@/components/ui/button'; + +interface GlobalEnvConfig { + enabled: boolean; + env: Record; +} + +interface GlobalEnvIndicatorProps { + /** Current profile's env vars (to show which are overridden) */ + profileEnv?: Record; +} + +export function GlobalEnvIndicator({ profileEnv = {} }: GlobalEnvIndicatorProps) { + const [config, setConfig] = useState(null); + const [loading, setLoading] = useState(true); + const [expanded, setExpanded] = useState(false); + + useEffect(() => { + fetchConfig(); + }, []); + + const fetchConfig = async () => { + try { + setLoading(true); + const res = await fetch('/api/global-env'); + if (!res.ok) throw new Error('Failed to load'); + const data = await res.json(); + setConfig(data); + } catch { + setConfig(null); + } finally { + setLoading(false); + } + }; + + // Don't render if loading or disabled or no vars + if (loading) return null; + if (!config?.enabled) return null; + + const envVars = config.env || {}; + const envKeys = Object.keys(envVars); + if (envKeys.length === 0) return null; + + // Check which keys are already in profile (won't be overridden) + const injectedKeys = envKeys.filter((key) => !(key in profileEnv)); + const overriddenKeys = envKeys.filter((key) => key in profileEnv); + + return ( +
+ {/* Header - clickable to expand */} + + + {/* Expanded content */} + {expanded && ( +
+ {/* Injected vars */} + {injectedKeys.length > 0 && ( +
+ {injectedKeys.map((key) => ( +
+ + + + {key}={envVars[key]} + +
+ ))} +
+ )} + + {/* Overridden vars (profile takes precedence) */} + {overriddenKeys.length > 0 && ( +
+

Skipped (profile already defines):

+ {overriddenKeys.map((key) => ( +
+ ~ + {key} +
+ ))} +
+ )} + + {/* Link to settings */} +
+ +
+
+ )} +
+ ); +} diff --git a/ui/src/components/profile-editor.tsx b/ui/src/components/profile-editor.tsx index 5a763587..b582c00d 100644 --- a/ui/src/components/profile-editor.tsx +++ b/ui/src/components/profile-editor.tsx @@ -16,6 +16,7 @@ import { Save, Loader2, Code2, Trash2, RefreshCw, Plus, X, Info } from 'lucide-r import { toast } from 'sonner'; import { CopyButton } from '@/components/ui/copy-button'; import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'; +import { GlobalEnvIndicator } from '@/components/global-env-indicator'; // Lazy load CodeEditor to reduce initial bundle size const CodeEditor = lazy(() => @@ -412,7 +413,7 @@ export function ProfileEditor({ profileName, onDelete }: ProfileEditorProps) { Invalid JSON syntax
)} -
+
+ {/* Global Env Indicator */} +
+
+ +
+
); diff --git a/ui/src/pages/settings.tsx b/ui/src/pages/settings.tsx index d14175db..00f39dee 100644 --- a/ui/src/pages/settings.tsx +++ b/ui/src/pages/settings.tsx @@ -1,15 +1,17 @@ /** - * Settings Page - WebSearch Configuration - * Supports Gemini CLI and Grok CLI providers + * Settings Page - WebSearch & Global Env Configuration + * Supports Gemini CLI and Grok CLI providers + Global Environment Variables */ import { useState, useEffect } from 'react'; +import { useSearchParams } from 'react-router-dom'; import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels'; import { Button } from '@/components/ui/button'; import { Alert, AlertDescription } from '@/components/ui/alert'; import { ScrollArea } from '@/components/ui/scroll-area'; import { Switch } from '@/components/ui/switch'; import { Input } from '@/components/ui/input'; +import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Globe, RefreshCw, @@ -23,6 +25,9 @@ import { ExternalLink, ChevronDown, ChevronUp, + Settings2, + Plus, + Trash2, } from 'lucide-react'; import { CodeEditor } from '@/components/code-editor'; @@ -59,7 +64,15 @@ interface WebSearchStatus { }; } +interface GlobalEnvConfig { + enabled: boolean; + env: Record; +} + export function SettingsPage() { + const [searchParams] = useSearchParams(); + const initialTab = searchParams.get('tab') === 'globalenv' ? 'globalenv' : 'websearch'; + const [activeTab, setActiveTab] = useState<'websearch' | 'globalenv'>(initialTab); const [config, setConfig] = useState(null); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); @@ -78,12 +91,22 @@ export function SettingsPage() { const [showGeminiHint, setShowGeminiHint] = useState(false); const [showOpencodeHint, setShowOpencodeHint] = useState(false); const [showGrokHint, setShowGrokHint] = useState(false); + // Global Env state + const [globalEnvConfig, setGlobalEnvConfig] = useState(null); + const [globalEnvLoading, setGlobalEnvLoading] = useState(true); + const [globalEnvSaving, setGlobalEnvSaving] = useState(false); + const [globalEnvError, setGlobalEnvError] = useState(null); + const [globalEnvSuccess, setGlobalEnvSuccess] = useState(false); + // New env var inputs + const [newEnvKey, setNewEnvKey] = useState(''); + const [newEnvValue, setNewEnvValue] = useState(''); // Load config and status on mount useEffect(() => { fetchConfig(); fetchStatus(); fetchRawConfig(); + fetchGlobalEnvConfig(); }, []); // Sync local model inputs when config changes @@ -141,6 +164,21 @@ export function SettingsPage() { } }; + const fetchGlobalEnvConfig = async () => { + try { + setGlobalEnvLoading(true); + setGlobalEnvError(null); + const res = await fetch('/api/global-env'); + if (!res.ok) throw new Error('Failed to load Global Env config'); + const data = await res.json(); + setGlobalEnvConfig(data); + } catch (err) { + setGlobalEnvError((err as Error).message); + } finally { + setGlobalEnvLoading(false); + } + }; + const copyToClipboard = async () => { if (!rawConfig) return; try { @@ -284,6 +322,71 @@ export function SettingsPage() { } }; + // Global Env functions + const saveGlobalEnvConfig = async (updates: Partial) => { + if (!globalEnvConfig) return; + + // Optimistic update + const optimisticConfig = { ...globalEnvConfig, ...updates }; + setGlobalEnvConfig(optimisticConfig); + + try { + setGlobalEnvSaving(true); + setGlobalEnvError(null); + + const res = await fetch('/api/global-env', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(optimisticConfig), + }); + + if (!res.ok) { + const data = await res.json(); + throw new Error(data.error || 'Failed to save'); + } + + const data = await res.json(); + setGlobalEnvConfig(data.config); + setGlobalEnvSuccess(true); + setTimeout(() => setGlobalEnvSuccess(false), 1500); + // Silently refresh raw config + fetch('/api/config/raw') + .then((r) => (r.ok ? r.text() : null)) + .then((text) => text && setRawConfig(text)) + .catch(() => {}); + } catch (err) { + setGlobalEnvConfig(globalEnvConfig); + setGlobalEnvError((err as Error).message); + } finally { + setGlobalEnvSaving(false); + } + }; + + const toggleGlobalEnv = () => { + saveGlobalEnvConfig({ enabled: !globalEnvConfig?.enabled }); + }; + + const addEnvVar = () => { + if (!newEnvKey.trim() || !globalEnvConfig) return; + const newEnv = { ...globalEnvConfig.env, [newEnvKey.trim()]: newEnvValue }; + saveGlobalEnvConfig({ env: newEnv }); + setNewEnvKey(''); + setNewEnvValue(''); + }; + + const removeEnvVar = (key: string) => { + if (!globalEnvConfig) return; + const newEnv = { ...globalEnvConfig.env }; + delete newEnv[key]; + saveGlobalEnvConfig({ env: newEnv }); + }; + + const updateEnvValue = (key: string, value: string) => { + if (!globalEnvConfig) return; + const newEnv = { ...globalEnvConfig.env, [key]: value }; + saveGlobalEnvConfig({ env: newEnv }); + }; + if (loading) { return (
@@ -298,348 +401,79 @@ export function SettingsPage() { return (
- {/* Left Panel - WebSearch Controls */} + {/* Left Panel - Settings Controls */}
- {/* Header */} + {/* Header with Tabs */}
-
- -
-

WebSearch

-

- CLI-based web search for third-party profiles -

-
-
-
- - {/* Toast-style alerts - absolute positioned, no layout shift */} -
- {error && ( - - - {error} - - )} - {success && ( -
- - Saved -
- )} -
- - {/* Scrollable Content */} - -
- {/* Status Summary */} -
-
-

- {isGeminiEnabled ? 'WebSearch enabled' : 'WebSearch disabled'} -

- {statusLoading ? ( -

Checking status...

- ) : status?.readiness ? ( -

{status.readiness.message}

- ) : null} -
- -
- - {/* CLI Providers */} -
-

Providers

- - {/* Gemini CLI Provider */} -
-
-
- -
-
-

gemini

- - FREE - - {status?.geminiCli?.installed ? ( - - installed - - ) : ( - - not installed - - )} -
-

- Google Gemini CLI (1000 req/day free) -

-
-
- -
- {/* Model input when enabled */} - {isGeminiEnabled && ( -
-
- - setGeminiModelInput(e.target.value)} - onBlur={saveGeminiModel} - placeholder="gemini-2.5-flash" - className="h-8 text-sm font-mono" - disabled={saving} - /> -
-
- )} - {/* Installation hint when not installed - inside card */} - {!status?.geminiCli?.installed && !statusLoading && ( -
- - {showGeminiHint && ( -
-

- Install globally (FREE tier available): -

- - npm install -g @google/gemini-cli - - - - View documentation - -
- )} -
- )} -
- - {/* OpenCode CLI Provider */} -
-
-
- -
-
-

opencode

- - FREE - - {status?.opencodeCli?.installed ? ( - - installed - - ) : ( - - not installed - - )} -
-

- OpenCode (web search via Zen) -

-
-
- -
- {/* Model input when enabled */} - {isOpenCodeEnabled && ( -
-
- - setOpencodeModelInput(e.target.value)} - onBlur={saveOpencodeModel} - placeholder="opencode/grok-code" - className="h-8 text-sm font-mono" - disabled={saving} - /> -
-
- )} - {/* Installation hint when not installed - inside card */} - {!status?.opencodeCli?.installed && !statusLoading && ( -
- - {showOpencodeHint && ( -
-

- Install globally (FREE tier available): -

- - curl -fsSL https://opencode.ai/install | bash - - - - View documentation - -
- )} -
- )} -
- - {/* Grok CLI Provider */} -
-
-
- -
-
-

grok

- - GROK_API_KEY - - {status?.grokCli?.installed ? ( - - installed - - ) : ( - - not installed - - )} -
-

- xAI Grok CLI (web + X search) -

-
-
- -
- {/* Installation hint when not installed - inside card */} - {!status?.grokCli?.installed && !statusLoading && ( -
- - {showGrokHint && ( -
-

- Install globally (requires xAI API key): -

- - npm install -g @vibe-kit/grok-cli - - - - View documentation - -
- )} -
- )} -
-
-
-
- - {/* Footer */} -
- + + + + WebSearch + + + + Global Env + + +
+ + {/* Tab Content */} + {activeTab === 'websearch' ? ( + + ) : ( + + )}
@@ -719,3 +553,613 @@ export function SettingsPage() {
); } + +// WebSearch Tab Content Component +interface WebSearchContentProps { + config: WebSearchConfig | null; + status: WebSearchStatus | null; + statusLoading: boolean; + saving: boolean; + error: string | null; + success: boolean; + isGeminiEnabled: boolean; + isGrokEnabled: boolean; + isOpenCodeEnabled: boolean; + geminiModelInput: string; + opencodeModelInput: string; + showGeminiHint: boolean; + showOpencodeHint: boolean; + showGrokHint: boolean; + setGeminiModelInput: (v: string) => void; + setOpencodeModelInput: (v: string) => void; + setShowGeminiHint: (v: boolean) => void; + setShowOpencodeHint: (v: boolean) => void; + setShowGrokHint: (v: boolean) => void; + toggleGemini: () => void; + toggleGrok: () => void; + toggleOpenCode: () => void; + saveGeminiModel: () => void; + saveOpencodeModel: () => void; + fetchStatus: () => void; + fetchConfig: () => void; + fetchRawConfig: () => void; + loading: boolean; +} + +function WebSearchContent({ + status, + statusLoading, + saving, + error, + success, + isGeminiEnabled, + isGrokEnabled, + isOpenCodeEnabled, + geminiModelInput, + opencodeModelInput, + showGeminiHint, + showOpencodeHint, + showGrokHint, + setGeminiModelInput, + setOpencodeModelInput, + setShowGeminiHint, + setShowOpencodeHint, + setShowGrokHint, + toggleGemini, + toggleGrok, + toggleOpenCode, + saveGeminiModel, + saveOpencodeModel, + fetchStatus, + fetchConfig, + fetchRawConfig, + loading, +}: WebSearchContentProps) { + return ( + <> + {/* Toast-style alerts - absolute positioned, no layout shift */} +
+ {error && ( + + + {error} + + )} + {success && ( +
+ + Saved +
+ )} +
+ + {/* Scrollable Content */} + +
+ {/* Description */} +

+ CLI-based web search for third-party profiles (gemini, codex, agy, etc.) +

+ + {/* Status Summary */} +
+
+

+ {isGeminiEnabled ? 'WebSearch enabled' : 'WebSearch disabled'} +

+ {statusLoading ? ( +

Checking status...

+ ) : status?.readiness ? ( +

{status.readiness.message}

+ ) : null} +
+ +
+ + {/* CLI Providers */} +
+

Providers

+ + {/* Gemini CLI Provider */} +
+
+
+ +
+
+

gemini

+ + FREE + + {status?.geminiCli?.installed ? ( + + installed + + ) : ( + + not installed + + )} +
+

+ Google Gemini CLI (1000 req/day free) +

+
+
+ +
+ {/* Model input when enabled */} + {isGeminiEnabled && ( +
+
+ + setGeminiModelInput(e.target.value)} + onBlur={saveGeminiModel} + placeholder="gemini-2.5-flash" + className="h-8 text-sm font-mono" + disabled={saving} + /> +
+
+ )} + {/* Installation hint when not installed - inside card */} + {!status?.geminiCli?.installed && !statusLoading && ( +
+ + {showGeminiHint && ( +
+

+ Install globally (FREE tier available): +

+ + npm install -g @google/gemini-cli + + + + View documentation + +
+ )} +
+ )} +
+ + {/* OpenCode CLI Provider */} +
+
+
+ +
+
+

opencode

+ + FREE + + {status?.opencodeCli?.installed ? ( + + installed + + ) : ( + + not installed + + )} +
+

OpenCode (web search via Zen)

+
+
+ +
+ {/* Model input when enabled */} + {isOpenCodeEnabled && ( +
+
+ + setOpencodeModelInput(e.target.value)} + onBlur={saveOpencodeModel} + placeholder="opencode/grok-code" + className="h-8 text-sm font-mono" + disabled={saving} + /> +
+
+ )} + {/* Installation hint when not installed - inside card */} + {!status?.opencodeCli?.installed && !statusLoading && ( +
+ + {showOpencodeHint && ( +
+

+ Install globally (FREE tier available): +

+ + curl -fsSL https://opencode.ai/install | bash + + + + View documentation + +
+ )} +
+ )} +
+ + {/* Grok CLI Provider */} +
+
+
+ +
+
+

grok

+ + GROK_API_KEY + + {status?.grokCli?.installed ? ( + + installed + + ) : ( + + not installed + + )} +
+

xAI Grok CLI (web + X search)

+
+
+ +
+ {/* Installation hint when not installed - inside card */} + {!status?.grokCli?.installed && !statusLoading && ( +
+ + {showGrokHint && ( +
+

+ Install globally (requires xAI API key): +

+ + npm install -g @vibe-kit/grok-cli + + + + View documentation + +
+ )} +
+ )} +
+
+
+
+ + {/* Footer */} +
+ +
+ + ); +} + +// Global Env Tab Content Component +interface GlobalEnvContentProps { + config: GlobalEnvConfig | null; + loading: boolean; + saving: boolean; + error: string | null; + success: boolean; + newEnvKey: string; + newEnvValue: string; + setNewEnvKey: (v: string) => void; + setNewEnvValue: (v: string) => void; + toggleGlobalEnv: () => void; + addEnvVar: () => void; + removeEnvVar: (key: string) => void; + updateEnvValue: (key: string, value: string) => void; + fetchGlobalEnvConfig: () => void; + fetchRawConfig: () => void; +} + +function GlobalEnvContent({ + config, + loading, + saving, + error, + success, + newEnvKey, + newEnvValue, + setNewEnvKey, + setNewEnvValue, + toggleGlobalEnv, + addEnvVar, + removeEnvVar, + fetchGlobalEnvConfig, + fetchRawConfig, +}: GlobalEnvContentProps) { + if (loading) { + return ( +
+
+ + Loading... +
+
+ ); + } + + return ( + <> + {/* Toast-style alerts */} +
+ {error && ( + + + {error} + + )} + {success && ( +
+ + Saved +
+ )} +
+ + {/* Scrollable Content */} + +
+ {/* Description */} +

+ Environment variables injected into all non-Claude subscription profiles (gemini, codex, + agy, copilot, etc.) +

+ + {/* Enable/Disable Toggle */} +
+
+

+ {config?.enabled ? 'Global Env enabled' : 'Global Env disabled'} +

+

+ {config?.enabled + ? 'Env vars will be injected into third-party profiles' + : 'Env vars will not be injected'} +

+
+ +
+ + {/* Current Environment Variables */} +
+

Environment Variables

+ + {config?.env && Object.keys(config.env).length > 0 ? ( +
+ {Object.entries(config.env).map(([key, value]) => ( +
+ {key} + = + {value} + +
+ ))} +
+ ) : ( +
+

No environment variables configured

+
+ )} + + {/* Add New Variable */} +
+

Add New Variable

+
+ setNewEnvKey(e.target.value.toUpperCase())} + placeholder="KEY_NAME" + className="flex-1 font-mono text-sm h-9" + disabled={saving} + /> + = + setNewEnvValue(e.target.value)} + placeholder="value" + className="flex-1 font-mono text-sm h-9" + disabled={saving} + /> + +
+
+ + {/* Common Variables Quick Add */} +
+

Quick Add Common Variables

+
+ {[ + { key: 'DISABLE_BUG_COMMAND', value: '1' }, + { key: 'DISABLE_ERROR_REPORTING', value: '1' }, + { key: 'DISABLE_TELEMETRY', value: '1' }, + ].map( + ({ key, value }) => + !config?.env?.[key] && ( + + ) + )} + {config?.env && + ['DISABLE_BUG_COMMAND', 'DISABLE_ERROR_REPORTING', 'DISABLE_TELEMETRY'].every( + (k) => config.env[k] + ) && ( + + All common variables are configured + + )} +
+
+
+
+
+ + {/* Footer */} +
+ +
+ + ); +} From 248d970cba8671b7c20dc99f8d1a70e4fe113605 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Thu, 18 Dec 2025 22:44:07 -0500 Subject: [PATCH 12/40] style(ui): widen cliproxy sidebar from w-64 to w-80 --- ui/src/pages/cliproxy.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/src/pages/cliproxy.tsx b/ui/src/pages/cliproxy.tsx index b296c7fe..80d013fb 100644 --- a/ui/src/pages/cliproxy.tsx +++ b/ui/src/pages/cliproxy.tsx @@ -227,7 +227,7 @@ export function CliproxyPage() { return (
{/* Left Sidebar */} -
+
{/* Header */}
From 2adc272f278b1d80d160ad4d6e1f35e3b61cb156 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Thu, 18 Dec 2025 22:52:12 -0500 Subject: [PATCH 13/40] fix(cliproxy): prevent misleading update message when proxy is running When CLIProxyAPI is already running as a background process, the update cannot be applied because the old process is still in memory. Previously, the CLI would download the new binary and show "Updating CLIProxyAPI..." even though the running process remained on the old version. Now checks if proxy is running before attempting update: - If running: shows update available message with instruction to stop first - If not running: proceeds with update as before Fixes #143 --- src/cliproxy/binary-manager.ts | 37 ++++++++++++++++++++++++---------- 1 file changed, 26 insertions(+), 11 deletions(-) diff --git a/src/cliproxy/binary-manager.ts b/src/cliproxy/binary-manager.ts index 8137c108..db9e38bf 100644 --- a/src/cliproxy/binary-manager.ts +++ b/src/cliproxy/binary-manager.ts @@ -19,7 +19,8 @@ import * as crypto from 'crypto'; import * as zlib from 'zlib'; import { ProgressIndicator } from '../utils/progress-indicator'; import { ok, info } from '../utils/ui'; -import { getBinDir, getCliproxyDir } from './config-generator'; +import { getBinDir, getCliproxyDir, CLIPROXY_DEFAULT_PORT } from './config-generator'; +import { isCliproxyRunning } from './stats-fetcher'; import { BinaryInfo, BinaryManagerConfig, @@ -103,17 +104,31 @@ export class BinaryManager { try { const updateResult = await this.checkForUpdates(); if (updateResult.hasUpdate) { - console.log( - info( - `CLIProxyAPI update available: v${updateResult.currentVersion} -> v${updateResult.latestVersion}` - ) - ); - console.log(info('Updating CLIProxyAPI...')); + // Check if CLIProxyAPI is currently running - can't update while running + const proxyRunning = await isCliproxyRunning(CLIPROXY_DEFAULT_PORT); + if (proxyRunning) { + // Proxy is running - can't update, just notify user + console.log( + info( + `CLIProxyAPI update available: v${updateResult.currentVersion} -> v${updateResult.latestVersion}` + ) + ); + console.log(info('Run "ccs cliproxy stop" then restart to apply update')); + this.log('Skipping update: CLIProxyAPI is currently running'); + } else { + // Proxy not running - safe to update + console.log( + info( + `CLIProxyAPI update available: v${updateResult.currentVersion} -> v${updateResult.latestVersion}` + ) + ); + console.log(info('Updating CLIProxyAPI...')); - // Delete old binary and download new version - this.deleteBinary(); - this.config.version = updateResult.latestVersion; - await this.downloadAndInstall(); + // Delete old binary and download new version + this.deleteBinary(); + this.config.version = updateResult.latestVersion; + await this.downloadAndInstall(); + } } } catch (error) { // Silent fail - don't block startup if update check fails From 6d206e6e9c8cae63c34aa9e17c72155c5d71c402 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 19 Dec 2025 03:56:06 +0000 Subject: [PATCH 14/40] chore(release): 6.5.0-dev.4 [skip ci] --- VERSION | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/VERSION b/VERSION index aa6b2bc9..fe665b4a 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -6.5.0-dev.3 +6.5.0-dev.4 diff --git a/package.json b/package.json index 9a2a17e0..fa54fa02 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "6.5.0-dev.3", + "version": "6.5.0-dev.4", "description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6", "keywords": [ "cli", From c9ad0b077934ae8418d4e97b9b02a09044ff898b Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Thu, 18 Dec 2025 23:03:45 -0500 Subject: [PATCH 15/40] feat(ui): add Stop and Restart buttons to ProxyStatusWidget - Add POST /api/cliproxy/proxy-stop API endpoint - Add proxyStop method in api-client and useStopProxy hook - Update ProxyStatusWidget with Stop and Restart controls when running - Restart = stop + delay + start (for applying CLIProxyAPI updates) Complements the fix in binary-manager.ts which now tells users to stop proxy before updates can be applied. --- src/web-server/routes.ts | 15 ++++- ui/src/components/proxy-status-widget.tsx | 81 ++++++++++++++++++----- ui/src/hooks/use-cliproxy.ts | 21 ++++++ ui/src/lib/api-client.ts | 9 +++ 4 files changed, 107 insertions(+), 19 deletions(-) diff --git a/src/web-server/routes.ts b/src/web-server/routes.ts index 9e1cd207..4f2797f7 100644 --- a/src/web-server/routes.ts +++ b/src/web-server/routes.ts @@ -44,7 +44,7 @@ import { } from '../cliproxy/account-manager'; import type { CLIProxyProvider } from '../cliproxy/types'; import { getClaudeEnvVars } from '../cliproxy/config-generator'; -import { getProxyStatus as getProxyProcessStatus } from '../cliproxy/session-tracker'; +import { getProxyStatus as getProxyProcessStatus, stopProxy } from '../cliproxy/session-tracker'; import { ensureCliproxyService } from '../cliproxy/service-manager'; // Unified config imports import { @@ -1389,6 +1389,19 @@ apiRoutes.post('/cliproxy/proxy-start', async (_req: Request, res: Response): Pr } }); +/** + * POST /api/cliproxy/proxy-stop - Stop the CLIProxy service + * Returns: { stopped, pid?, sessionCount?, error? } + */ +apiRoutes.post('/cliproxy/proxy-stop', (_req: Request, res: Response): void => { + try { + const result = stopProxy(); + res.json(result); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + /** * GET /api/cliproxy/models - Get available models from CLIProxyAPI * Returns: { models: CliproxyModel[], byCategory: Record, totalCount: number } diff --git a/ui/src/components/proxy-status-widget.tsx b/ui/src/components/proxy-status-widget.tsx index a85981c2..946d1cff 100644 --- a/ui/src/components/proxy-status-widget.tsx +++ b/ui/src/components/proxy-status-widget.tsx @@ -1,13 +1,13 @@ /** * Proxy Status Widget * - * Displays CLIProxy process status with start button for recovery. + * Displays CLIProxy process status with start/stop/restart controls. * Shows: running state, port, session count, uptime. */ -import { Activity, Power, RefreshCw, Clock, Users } from 'lucide-react'; +import { Activity, Power, RefreshCw, Clock, Users, Square, RotateCw } from 'lucide-react'; import { Button } from '@/components/ui/button'; -import { useProxyStatus, useStartProxy } from '@/hooks/use-cliproxy'; +import { useProxyStatus, useStartProxy, useStopProxy } from '@/hooks/use-cliproxy'; import { cn } from '@/lib/utils'; function formatUptime(startedAt?: string): string { @@ -28,8 +28,18 @@ function formatUptime(startedAt?: string): string { export function ProxyStatusWidget() { const { data: status, isLoading } = useProxyStatus(); const startProxy = useStartProxy(); + const stopProxy = useStopProxy(); const isRunning = status?.running ?? false; + const isActioning = startProxy.isPending || stopProxy.isPending; + + // Restart = stop then start + const handleRestart = async () => { + await stopProxy.mutateAsync(); + // Small delay to ensure port is released + await new Promise((r) => setTimeout(r, 500)); + startProxy.mutate(); + }; return (
{isRunning && status ? ( -
- Port {status.port} - {status.sessionCount !== undefined && status.sessionCount > 0 && ( - - - {status.sessionCount} session{status.sessionCount !== 1 ? 's' : ''} - - )} - {status.startedAt && ( - - - {formatUptime(status.startedAt)} - - )} -
+ <> +
+ Port {status.port} + {status.sessionCount !== undefined && status.sessionCount > 0 && ( + + + {status.sessionCount} session{status.sessionCount !== 1 ? 's' : ''} + + )} + {status.startedAt && ( + + + {formatUptime(status.startedAt)} + + )} +
+ {/* Control buttons when running */} +
+ + +
+ ) : (
Not running diff --git a/ui/src/hooks/use-cliproxy.ts b/ui/src/hooks/use-cliproxy.ts index 9f23842e..65a5be2f 100644 --- a/ui/src/hooks/use-cliproxy.ts +++ b/ui/src/hooks/use-cliproxy.ts @@ -240,3 +240,24 @@ export function useStartProxy() { }, }); } + +export function useStopProxy() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: () => api.cliproxy.proxyStop(), + onSuccess: (data) => { + queryClient.invalidateQueries({ queryKey: ['proxy-status'] }); + if (data.stopped) { + toast.success( + `CLIProxy stopped${data.sessionCount ? ` (${data.sessionCount} session(s) disconnected)` : ''}` + ); + } else { + toast.error(data.error || 'Failed to stop CLIProxy'); + } + }, + onError: (error: Error) => { + toast.error(error.message); + }, + }); +} diff --git a/ui/src/lib/api-client.ts b/ui/src/lib/api-client.ts index 9732272b..5d5096a5 100644 --- a/ui/src/lib/api-client.ts +++ b/ui/src/lib/api-client.ts @@ -182,6 +182,14 @@ export interface ProxyStartResult { error?: string; } +/** Result from stopping proxy service */ +export interface ProxyStopResult { + stopped: boolean; + pid?: number; + sessionCount?: number; + error?: string; +} + // API export const api = { profiles: { @@ -216,6 +224,7 @@ export const api = { // Proxy process status and control proxyStatus: () => request('/cliproxy/proxy-status'), proxyStart: () => request('/cliproxy/proxy-start', { method: 'POST' }), + proxyStop: () => request('/cliproxy/proxy-stop', { method: 'POST' }), // Stats and models for Overview tab stats: () => request<{ usage: Record }>('/cliproxy/usage'), From 96762a9f6ee096570b2fe6136a4431e6ce1d1a47 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Thu, 18 Dec 2025 23:17:05 -0500 Subject: [PATCH 16/40] feat(ui): show CLIProxyAPI update availability in dashboard - Add GET /api/cliproxy/update-check endpoint - Export checkCliproxyUpdate() function from binary-manager - Add useCliproxyUpdateCheck hook with 1-hour cache - Update ProxyStatusWidget with amber "Update" badge when available - Highlight Restart button as "Update" with amber styling when update pending --- src/cliproxy/binary-manager.ts | 17 ++++++++++ src/web-server/routes.ts | 14 ++++++++ ui/src/components/proxy-status-widget.tsx | 41 +++++++++++++++++++---- ui/src/hooks/use-cliproxy.ts | 12 +++++++ ui/src/lib/api-client.ts | 9 +++++ 5 files changed, 86 insertions(+), 7 deletions(-) diff --git a/src/cliproxy/binary-manager.ts b/src/cliproxy/binary-manager.ts index db9e38bf..be2e533a 100644 --- a/src/cliproxy/binary-manager.ts +++ b/src/cliproxy/binary-manager.ts @@ -995,6 +995,23 @@ export async function fetchLatestCliproxyVersion(): Promise { return result.latestVersion; } +/** Update check result for API response */ +export interface CliproxyUpdateCheckResult { + hasUpdate: boolean; + currentVersion: string; + latestVersion: string; + fromCache: boolean; +} + +/** + * Check for CLIProxyAPI binary updates + * @returns Update check result with version info + */ +export async function checkCliproxyUpdate(): Promise { + const manager = new BinaryManager(); + return manager.checkForUpdates(); +} + /** * Get path to version pin file * @returns Absolute path to .version-pin file diff --git a/src/web-server/routes.ts b/src/web-server/routes.ts index 4f2797f7..6f68fc27 100644 --- a/src/web-server/routes.ts +++ b/src/web-server/routes.ts @@ -46,6 +46,7 @@ import type { CLIProxyProvider } from '../cliproxy/types'; import { getClaudeEnvVars } 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'; // Unified config imports import { hasUnifiedConfig, @@ -1402,6 +1403,19 @@ apiRoutes.post('/cliproxy/proxy-stop', (_req: Request, res: Response): void => { } }); +/** + * GET /api/cliproxy/update-check - Check for CLIProxyAPI binary updates + * Returns: { hasUpdate, currentVersion, latestVersion, fromCache } + */ +apiRoutes.get('/cliproxy/update-check', async (_req: Request, res: Response): Promise => { + try { + const result = await checkCliproxyUpdate(); + res.json(result); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + /** * GET /api/cliproxy/models - Get available models from CLIProxyAPI * Returns: { models: CliproxyModel[], byCategory: Record, totalCount: number } diff --git a/ui/src/components/proxy-status-widget.tsx b/ui/src/components/proxy-status-widget.tsx index 946d1cff..161be85a 100644 --- a/ui/src/components/proxy-status-widget.tsx +++ b/ui/src/components/proxy-status-widget.tsx @@ -2,12 +2,18 @@ * Proxy Status Widget * * Displays CLIProxy process status with start/stop/restart controls. - * Shows: running state, port, session count, uptime. + * Shows: running state, port, session count, uptime, update availability. */ -import { Activity, Power, RefreshCw, Clock, Users, Square, RotateCw } from 'lucide-react'; +import { Activity, Power, RefreshCw, Clock, Users, Square, RotateCw, ArrowUp } from 'lucide-react'; import { Button } from '@/components/ui/button'; -import { useProxyStatus, useStartProxy, useStopProxy } from '@/hooks/use-cliproxy'; +import { Badge } from '@/components/ui/badge'; +import { + useProxyStatus, + useStartProxy, + useStopProxy, + useCliproxyUpdateCheck, +} from '@/hooks/use-cliproxy'; import { cn } from '@/lib/utils'; function formatUptime(startedAt?: string): string { @@ -27,11 +33,13 @@ function formatUptime(startedAt?: string): string { export function ProxyStatusWidget() { const { data: status, isLoading } = useProxyStatus(); + const { data: updateCheck } = useCliproxyUpdateCheck(); const startProxy = useStartProxy(); const stopProxy = useStopProxy(); const isRunning = status?.running ?? false; const isActioning = startProxy.isPending || stopProxy.isPending; + const hasUpdate = updateCheck?.hasUpdate ?? false; // Restart = stop then start const handleRestart = async () => { @@ -57,6 +65,16 @@ export function ProxyStatusWidget() { )} /> CLIProxy Service + {hasUpdate && ( + v${updateCheck?.latestVersion}`} + > + + Update + + )}
@@ -90,19 +108,28 @@ export function ProxyStatusWidget() { {/* Control buttons when running */}
)} + + {/* Version sync indicator */} + {updateCheck?.currentVersion && ( +
+ v{updateCheck.currentVersion} + {updateCheck.checkedAt && ( + + Synced {formatTimeAgo(updateCheck.checkedAt)} + + )} +
+ )}
); } diff --git a/ui/src/lib/api-client.ts b/ui/src/lib/api-client.ts index f45837dd..73815560 100644 --- a/ui/src/lib/api-client.ts +++ b/ui/src/lib/api-client.ts @@ -196,6 +196,7 @@ export interface CliproxyUpdateCheckResult { currentVersion: string; latestVersion: string; fromCache: boolean; + checkedAt: number; // Unix timestamp of last check } // API From 584b31e3b6ea3cb91fb51c002bb13652dd8bd9d1 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 19 Dec 2025 04:27:31 +0000 Subject: [PATCH 18/40] chore(release): 6.5.0-dev.5 [skip ci] --- VERSION | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/VERSION b/VERSION index fe665b4a..4ac491f7 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -6.5.0-dev.4 +6.5.0-dev.5 diff --git a/package.json b/package.json index fa54fa02..06d58c39 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "6.5.0-dev.4", + "version": "6.5.0-dev.5", "description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6", "keywords": [ "cli", From 45207b4e7f92c09d7464dd5c954718254ddfd43a Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Thu, 18 Dec 2025 23:45:44 -0500 Subject: [PATCH 19/40] feat(cleanup): add age-based error log cleanup - add --errors flag to target error-*.log files - add --days=N for age filtering (default: 7 days) - show preview of files to delete before confirmation - keep recent logs for debugging, only delete old ones --- src/commands/cleanup-command.ts | 208 ++++++++++++++++++++++++++++++-- 1 file changed, 199 insertions(+), 9 deletions(-) diff --git a/src/commands/cleanup-command.ts b/src/commands/cleanup-command.ts index bae4e192..20c23cfa 100644 --- a/src/commands/cleanup-command.ts +++ b/src/commands/cleanup-command.ts @@ -2,6 +2,7 @@ * Cleanup Command Handler * * Removes old CLIProxy logs to free up disk space. + * Supports both main logs and error request logs with age-based filtering. * Logs can accumulate to several GB without user awareness. */ @@ -10,6 +11,9 @@ import * as path from 'path'; import { getCliproxyDir } from '../cliproxy/config-generator'; import { info, ok, warn } from '../utils/ui'; +/** Default age in days for error log cleanup */ +const DEFAULT_ERROR_LOG_AGE_DAYS = 7; + /** Get the CLIProxy logs directory */ function getLogsDir(): string { return path.join(getCliproxyDir(), 'logs'); @@ -95,6 +99,76 @@ function cleanDirectory(dirPath: string): { deleted: number; freedBytes: number return { deleted, freedBytes }; } +/** Error log file info */ +interface ErrorLogInfo { + name: string; + path: string; + size: number; + mtime: Date; + ageInDays: number; +} + +/** Get error log files with metadata */ +function getErrorLogFiles(logsDir: string): ErrorLogInfo[] { + if (!fs.existsSync(logsDir)) return []; + + const now = Date.now(); + const files: ErrorLogInfo[] = []; + const entries = fs.readdirSync(logsDir); + + for (const entry of entries) { + // Only process error-*.log files + if (!entry.startsWith('error-') || !entry.endsWith('.log')) continue; + + const filePath = path.join(logsDir, entry); + try { + const stats = fs.lstatSync(filePath); + if (stats.isFile() && !stats.isSymbolicLink()) { + const ageMs = now - stats.mtime.getTime(); + files.push({ + name: entry, + path: filePath, + size: stats.size, + mtime: stats.mtime, + ageInDays: Math.floor(ageMs / (1000 * 60 * 60 * 24)), + }); + } + } catch { + // File may have been deleted - skip + } + } + + // Sort by age, oldest first + return files.sort((a, b) => b.ageInDays - a.ageInDays); +} + +/** Delete error logs older than specified days */ +function cleanErrorLogs( + logsDir: string, + maxAgeDays: number +): { deleted: number; freedBytes: number; kept: number } { + const files = getErrorLogFiles(logsDir); + let deleted = 0; + let freedBytes = 0; + let kept = 0; + + for (const file of files) { + if (file.ageInDays >= maxAgeDays) { + try { + fs.unlinkSync(file.path); + deleted++; + freedBytes += file.size; + } catch { + // File may be locked or already deleted + } + } else { + kept++; + } + } + + return { deleted, freedBytes, kept }; +} + /** Print help for cleanup command */ function printHelp(): void { console.log(''); @@ -103,20 +177,19 @@ function printHelp(): void { console.log('Remove old CLIProxy logs to free up disk space.'); console.log(''); console.log('Options:'); + console.log(' --errors Clean error request logs (error-*.log files)'); + console.log(' --days=N Delete error logs older than N days (default: 7)'); console.log(' --dry-run Show what would be deleted without deleting'); console.log(' --force Skip confirmation prompt'); console.log(' --help, -h Show this help message'); console.log(''); console.log('Examples:'); - console.log(' ccs cleanup Interactive cleanup with confirmation'); - console.log(' ccs cleanup --dry-run Preview cleanup without deleting'); - console.log(' ccs cleanup --force Clean without confirmation'); - console.log(''); - console.log('Note: CLIProxy logging is disabled by default.'); - console.log('To enable logging, edit ~/.ccs/config.yaml:'); - console.log(' cliproxy:'); - console.log(' logging:'); - console.log(' enabled: true'); + console.log(' ccs cleanup Interactive main log cleanup'); + console.log(' ccs cleanup --errors Clean error logs older than 7 days'); + console.log(' ccs cleanup --errors --days=3 Clean error logs older than 3 days'); + console.log(' ccs cleanup --errors --dry-run Preview error log cleanup'); + console.log(' ccs cleanup --dry-run Preview main log cleanup'); + console.log(' ccs cleanup --force Clean main logs without confirmation'); console.log(''); } @@ -132,8 +205,125 @@ export async function handleCleanupCommand(args: string[]): Promise { const dryRun = args.includes('--dry-run'); const force = args.includes('--force'); + const cleanErrors = args.includes('--errors'); const logsDir = getLogsDir(); + // Parse --days=N option + let maxAgeDays = DEFAULT_ERROR_LOG_AGE_DAYS; + const daysArg = args.find((arg) => arg.startsWith('--days=')); + if (daysArg) { + const parsed = parseInt(daysArg.split('=')[1], 10); + if (isNaN(parsed) || parsed < 1) { + console.log(warn('Invalid --days value. Must be a positive integer.')); + return; + } + maxAgeDays = parsed; + } + + // Route to error log cleanup or main log cleanup + if (cleanErrors) { + await handleErrorLogCleanup(logsDir, maxAgeDays, dryRun, force); + } else { + await handleMainLogCleanup(logsDir, dryRun, force); + } +} + +/** + * Handle error log cleanup (error-*.log files) + */ +async function handleErrorLogCleanup( + logsDir: string, + maxAgeDays: number, + dryRun: boolean, + force: boolean +): Promise { + // Check if logs directory exists + if (!fs.existsSync(logsDir)) { + console.log(info('No CLIProxy logs directory found.')); + return; + } + + // Get error log files + const errorLogs = getErrorLogFiles(logsDir); + if (errorLogs.length === 0) { + console.log(info('No error logs found.')); + return; + } + + // Calculate what would be deleted + const toDelete = errorLogs.filter((f) => f.ageInDays >= maxAgeDays); + const toKeep = errorLogs.filter((f) => f.ageInDays < maxAgeDays); + const totalDeleteSize = toDelete.reduce((sum, f) => sum + f.size, 0); + + console.log(''); + console.log(`Error Logs: ${logsDir}`); + console.log(` Total: ${errorLogs.length} files`); + console.log( + ` To delete: ${toDelete.length} files older than ${maxAgeDays} days (${formatBytes(totalDeleteSize)})` + ); + console.log(` To keep: ${toKeep.length} files newer than ${maxAgeDays} days`); + console.log(''); + + if (toDelete.length === 0) { + console.log(info(`No error logs older than ${maxAgeDays} days.`)); + return; + } + + // Show oldest files in dry-run or verbose mode + if (dryRun || toDelete.length <= 5) { + console.log('Files to delete:'); + for (const file of toDelete.slice(0, 10)) { + console.log(` ${file.name} (${file.ageInDays}d old, ${formatBytes(file.size)})`); + } + if (toDelete.length > 10) { + console.log(` ... and ${toDelete.length - 10} more`); + } + console.log(''); + } + + if (dryRun) { + console.log(info('Dry run - no files deleted.')); + return; + } + + // Confirm unless --force + if (!force) { + const readline = await import('readline'); + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + + const answer = await new Promise((resolve) => { + rl.question( + `Delete ${toDelete.length} error logs older than ${maxAgeDays} days (${formatBytes(totalDeleteSize)})? [y/N] `, + resolve + ); + }); + rl.close(); + + if (answer.toLowerCase() !== 'y') { + console.log('Cancelled.'); + return; + } + } + + // Perform cleanup + const { deleted, freedBytes, kept } = cleanErrorLogs(logsDir, maxAgeDays); + console.log(ok(`Deleted ${deleted} error logs, freed ${formatBytes(freedBytes)}`)); + if (kept > 0) { + console.log(info(`Kept ${kept} recent error logs (less than ${maxAgeDays} days old)`)); + } +} + +/** + * Handle main log cleanup (main.log and rotated files) + */ +async function handleMainLogCleanup( + logsDir: string, + dryRun: boolean, + force: boolean +): Promise { // Check if logs directory exists if (!fs.existsSync(logsDir)) { console.log(info('No CLIProxy logs found.')); From 2db8f3bf1a27984a4b80c6b7615a2e1627cc742f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 19 Dec 2025 04:46:57 +0000 Subject: [PATCH 20/40] chore(release): 6.5.0-dev.6 [skip ci] --- VERSION | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/VERSION b/VERSION index 4ac491f7..fa6cb17c 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -6.5.0-dev.5 +6.5.0-dev.6 diff --git a/package.json b/package.json index 06d58c39..bdbaefca 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "6.5.0-dev.5", + "version": "6.5.0-dev.6", "description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6", "keywords": [ "cli", From ee76d663aec59a86a236156dbc163d0d291c0446 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Fri, 19 Dec 2025 00:26:22 -0500 Subject: [PATCH 21/40] feat(ci): add Discord notifications for releases - stable releases: fetch GH release, post green embed with changelog - dev releases: post orange embed with version info - graceful skip if webhook not configured --- .github/workflows/dev-release.yml | 14 +++++++++++ .github/workflows/release.yml | 39 +++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+) diff --git a/.github/workflows/dev-release.yml b/.github/workflows/dev-release.yml index 05d90f92..3e19a84a 100644 --- a/.github/workflows/dev-release.yml +++ b/.github/workflows/dev-release.yml @@ -153,3 +153,17 @@ jobs: git add VERSION package.json git commit -m "chore(release): ${{ steps.bump.outputs.new }} [skip ci]" git push origin dev + + - name: Notify Discord + if: success() + env: + DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }} + VERSION: ${{ steps.bump.outputs.new }} + run: | + if [ -z "$DISCORD_WEBHOOK_URL" ]; then + echo "DISCORD_WEBHOOK_URL not set, skipping notification" + exit 0 + fi + curl -s -X POST "$DISCORD_WEBHOOK_URL" \ + -H "Content-Type: application/json" \ + -d "{\"username\":\"CCS Dev Release\",\"embeds\":[{\"title\":\"Dev Release $VERSION\",\"url\":\"https://www.npmjs.com/package/@kaitranntt/ccs/v/$VERSION\",\"color\":15638323,\"description\":\"Pre-release version available for testing.\",\"footer\":{\"text\":\"npm i @kaitranntt/ccs@dev\"}}]}" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9ca53cf5..467f8907 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -52,3 +52,42 @@ jobs: NPM_TOKEN: ${{ secrets.NPM_TOKEN }} NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} run: bunx semantic-release + + - name: Notify Discord + if: success() + uses: actions/github-script@v7 + env: + DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }} + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + if (!process.env.DISCORD_WEBHOOK_URL) { + core.warning('DISCORD_WEBHOOK_URL not set, skipping notification') + return + } + const { data: releases } = await github.rest.repos.listReleases({ + owner: context.repo.owner, + repo: context.repo.repo, + per_page: 1 + }) + if (!releases.length) { + core.warning('No releases found, skipping notification') + return + } + const r = releases[0] + const desc = (r.body || 'No changelog available').slice(0, 2000) + await fetch(process.env.DISCORD_WEBHOOK_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + username: 'CCS Release', + embeds: [{ + title: `Release ${r.name || r.tag_name}`, + url: r.html_url, + color: 0x10B981, + description: desc, + timestamp: r.created_at, + footer: { text: 'npm i @kaitranntt/ccs@latest' } + }] + }) + }) From 338528777bef0261f94b67e50b5ada3ab411eba1 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 19 Dec 2025 05:27:40 +0000 Subject: [PATCH 22/40] chore(release): 6.5.0-dev.7 [skip ci] --- VERSION | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/VERSION b/VERSION index fa6cb17c..1d36282b 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -6.5.0-dev.6 +6.5.0-dev.7 diff --git a/package.json b/package.json index bdbaefca..e18b9c4b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "6.5.0-dev.6", + "version": "6.5.0-dev.7", "description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6", "keywords": [ "cli", From 0f590c80d689c39cea7c94937ed398941dddb533 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Fri, 19 Dec 2025 00:57:05 -0500 Subject: [PATCH 23/40] feat(ci): add semantic-release for dev branch with rich Discord notifications - add .releaserc.cjs with branch-aware config (dev=prerelease, main=stable) - add scripts/send-discord-release.cjs that parses CHANGELOG.md - replace custom VERSION bumping with semantic-release - add Discord webhook URL validation - add release detection to prevent false notifications --- .github/workflows/dev-release.yml | 133 ++++++------------- .github/workflows/release.yml | 52 +++----- .releaserc.cjs | 120 +++++++++++++++++ .releaserc.json | 23 ---- VERSION | 1 - bun.lock | 95 +++++++------ package.json | 5 + scripts/send-discord-release.cjs | 214 ++++++++++++++++++++++++++++++ scripts/sync-version-plugin.cjs | 41 ------ 9 files changed, 452 insertions(+), 232 deletions(-) create mode 100644 .releaserc.cjs delete mode 100644 .releaserc.json delete mode 100644 VERSION create mode 100644 scripts/send-discord-release.cjs delete mode 100644 scripts/sync-version-plugin.cjs diff --git a/.github/workflows/dev-release.yml b/.github/workflows/dev-release.yml index 3e19a84a..48fd91aa 100644 --- a/.github/workflows/dev-release.yml +++ b/.github/workflows/dev-release.yml @@ -6,13 +6,15 @@ on: jobs: release: - # Skip if commit message contains [skip ci] or is a release commit + # Skip if commit message contains [skip ci] if: "!contains(github.event.head_commit.message, '[skip ci]')" runs-on: ubuntu-latest permissions: contents: write issues: write + pull-requests: write + id-token: write steps: - name: Checkout @@ -43,84 +45,59 @@ jobs: - name: Validate (typecheck + lint + tests) run: bun run validate - - name: Bump dev version - id: bump - run: | - CURRENT=$(cat VERSION) - PKG_NAME=$(jq -r '.name' package.json) - - # Extract base version - if [[ "$CURRENT" =~ ^([0-9]+\.[0-9]+\.[0-9]+)(-dev\.([0-9]+))?$ ]]; then - BASE="${BASH_REMATCH[1]}" - else - echo "Invalid version format: $CURRENT" - exit 1 - fi - - # Find highest published dev version for this base - LATEST_DEV=$(npm view "${PKG_NAME}" versions --json 2>/dev/null | \ - jq -r '.[]' | \ - grep "^${BASE}-dev\." | \ - sed "s/${BASE}-dev\.//" | \ - sort -n | \ - tail -1) - - if [[ -z "$LATEST_DEV" ]]; then - NEW_DEV=1 - else - NEW_DEV=$((LATEST_DEV + 1)) - fi - - NEW_VERSION="${BASE}-dev.${NEW_DEV}" - - echo "current=$CURRENT" >> $GITHUB_OUTPUT - echo "new=$NEW_VERSION" >> $GITHUB_OUTPUT - echo "Bumping: $CURRENT -> $NEW_VERSION" - - - name: Update version files - run: | - NEW_VERSION="${{ steps.bump.outputs.new }}" - - # Update VERSION file - echo "$NEW_VERSION" > VERSION - - # Update package.json - jq --arg v "$NEW_VERSION" '.version = $v' package.json > package.json.tmp - mv package.json.tmp package.json - - - name: Publish to npm + - name: Release + id: release env: + HUSKY: 0 + GITHUB_TOKEN: ${{ secrets.PAT_TOKEN }} + GH_TOKEN: ${{ secrets.PAT_TOKEN }} + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - run: npm publish --tag dev + run: | + OUTPUT=$(bunx semantic-release 2>&1) || true + echo "$OUTPUT" + if echo "$OUTPUT" | grep -q "Published release"; then + echo "released=true" >> $GITHUB_OUTPUT + else + echo "released=false" >> $GITHUB_OUTPUT + fi + + - name: Notify Discord + if: success() && steps.release.outputs.released == 'true' + env: + DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }} + run: | + if [ -z "$DISCORD_WEBHOOK_URL" ]; then + echo "DISCORD_WEBHOOK_URL not set, skipping" + exit 0 + fi + node scripts/send-discord-release.cjs dev "$DISCORD_WEBHOOK_URL" - name: Tag resolved issues + if: success() && steps.release.outputs.released == 'true' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - NEW_VERSION="${{ steps.bump.outputs.new }}" + # Get version from package.json (updated by semantic-release) + VERSION=$(jq -r '.version' package.json) - # Find commits since last dev release (look for version bump commits) - LAST_RELEASE_COMMIT=$(git log --oneline --grep="chore(release):" -n 2 | tail -1 | cut -d' ' -f1) - - if [[ -n "$LAST_RELEASE_COMMIT" ]]; then - RANGE="${LAST_RELEASE_COMMIT}..HEAD" - else - RANGE="HEAD~20..HEAD" - fi + # 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" echo "Checking commits in range: $RANGE" - # Extract issue numbers from commits + # Extract issue numbers ISSUES=$(git log $RANGE --pretty=format:"%s %b" | \ grep -oE "(Fixes|Closes|Resolves|Refs?) #[0-9]+" | \ grep -oE "#[0-9]+" | sort -u || true) if [[ -z "$ISSUES" ]]; then - echo "No linked issues found in commits" + echo "No linked issues found" exit 0 fi - # Create labels if they don't exist + # Create label if needed gh label create "released-dev" \ --color "1d76db" \ --description "Fix available in @dev npm channel" \ @@ -129,41 +106,13 @@ jobs: for ISSUE in $ISSUES; do NUM=${ISSUE#\#} - # Skip if already tagged (avoid spam) + # 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 has released-dev label, skipping" + echo "Issue #$NUM already tagged, skipping" continue fi - echo "Tagging issue #$NUM as released-dev" - - # Add comment - gh issue comment "$NUM" --repo "${{ github.repository }}" --body ":test_tube: Available in \`$NEW_VERSION\`. Install: \`npm i @kaitranntt/ccs@dev\`" || true - - # Add released-dev label + echo "Tagging issue #$NUM" + gh issue comment "$NUM" --repo "${{ github.repository }}" --body "[i] Available in \`$VERSION\`. Install: \`npm i @kaitranntt/ccs@dev\`" || true gh issue edit "$NUM" --add-label "released-dev" --repo "${{ github.repository }}" || true done - - - name: Commit version bump - env: - HUSKY: 0 - run: | - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - git add VERSION package.json - git commit -m "chore(release): ${{ steps.bump.outputs.new }} [skip ci]" - git push origin dev - - - name: Notify Discord - if: success() - env: - DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }} - VERSION: ${{ steps.bump.outputs.new }} - run: | - if [ -z "$DISCORD_WEBHOOK_URL" ]; then - echo "DISCORD_WEBHOOK_URL not set, skipping notification" - exit 0 - fi - curl -s -X POST "$DISCORD_WEBHOOK_URL" \ - -H "Content-Type: application/json" \ - -d "{\"username\":\"CCS Dev Release\",\"embeds\":[{\"title\":\"Dev Release $VERSION\",\"url\":\"https://www.npmjs.com/package/@kaitranntt/ccs/v/$VERSION\",\"color\":15638323,\"description\":\"Pre-release version available for testing.\",\"footer\":{\"text\":\"npm i @kaitranntt/ccs@dev\"}}]}" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 467f8907..0e1f2719 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -45,49 +45,29 @@ jobs: run: bun run validate - name: Release + id: release env: HUSKY: 0 GITHUB_TOKEN: ${{ secrets.PAT_TOKEN }} GH_TOKEN: ${{ secrets.PAT_TOKEN }} NPM_TOKEN: ${{ secrets.NPM_TOKEN }} NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - run: bunx semantic-release + run: | + OUTPUT=$(bunx semantic-release 2>&1) || true + echo "$OUTPUT" + if echo "$OUTPUT" | grep -q "Published release"; then + echo "released=true" >> $GITHUB_OUTPUT + else + echo "released=false" >> $GITHUB_OUTPUT + fi - name: Notify Discord - if: success() - uses: actions/github-script@v7 + if: success() && steps.release.outputs.released == 'true' env: DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }} - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - script: | - if (!process.env.DISCORD_WEBHOOK_URL) { - core.warning('DISCORD_WEBHOOK_URL not set, skipping notification') - return - } - const { data: releases } = await github.rest.repos.listReleases({ - owner: context.repo.owner, - repo: context.repo.repo, - per_page: 1 - }) - if (!releases.length) { - core.warning('No releases found, skipping notification') - return - } - const r = releases[0] - const desc = (r.body || 'No changelog available').slice(0, 2000) - await fetch(process.env.DISCORD_WEBHOOK_URL, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - username: 'CCS Release', - embeds: [{ - title: `Release ${r.name || r.tag_name}`, - url: r.html_url, - color: 0x10B981, - description: desc, - timestamp: r.created_at, - footer: { text: 'npm i @kaitranntt/ccs@latest' } - }] - }) - }) + run: | + if [ -z "$DISCORD_WEBHOOK_URL" ]; then + echo "DISCORD_WEBHOOK_URL not set, skipping" + exit 0 + fi + node scripts/send-discord-release.cjs production "$DISCORD_WEBHOOK_URL" diff --git a/.releaserc.cjs b/.releaserc.cjs new file mode 100644 index 00000000..eff9aeab --- /dev/null +++ b/.releaserc.cjs @@ -0,0 +1,120 @@ +/** + * Semantic Release Configuration + * + * Branch-aware config: + * - dev branch: Uses dev release configuration (prerelease) + * - main branch: Uses production release configuration + */ + +const currentBranch = + process.env.GITHUB_REF_NAME || + process.env.GIT_BRANCH || + (process.env.GITHUB_REF && process.env.GITHUB_REF.replace('refs/heads/', '')) || + require('child_process').execSync('git rev-parse --abbrev-ref HEAD').toString().trim(); + +console.error(`[semantic-release config] Branch: ${currentBranch}`); + +// Shared plugin config +const commitAnalyzer = [ + '@semantic-release/commit-analyzer', + { + preset: 'conventionalcommits', + releaseRules: [ + { type: 'docs', scope: 'README', release: 'patch' }, + { type: 'refactor', release: 'patch' }, + { type: 'style', release: 'patch' }, + ], + }, +]; + +const releaseNotesGenerator = [ + '@semantic-release/release-notes-generator', + { + preset: 'conventionalcommits', + presetConfig: { + types: [ + { type: 'feat', section: 'Features' }, + { type: 'fix', section: 'Bug Fixes' }, + { type: 'docs', section: 'Documentation' }, + { type: 'style', section: 'Styles' }, + { type: 'refactor', section: 'Code Refactoring' }, + { type: 'perf', section: 'Performance Improvements' }, + { type: 'test', section: 'Tests' }, + { type: 'build', section: 'Build System' }, + { type: 'ci', section: 'CI' }, + ], + }, + }, +]; + +// Dev release configuration +const devConfig = { + branches: [ + 'main', // Required even in dev config + { + name: 'dev', + prerelease: 'dev', + }, + ], + plugins: [ + commitAnalyzer, + releaseNotesGenerator, + [ + '@semantic-release/changelog', + { + changelogFile: 'CHANGELOG.md', + }, + ], + '@semantic-release/npm', + [ + '@semantic-release/github', + { + prerelease: true, + }, + ], + [ + '@semantic-release/git', + { + assets: ['CHANGELOG.md', 'package.json'], + message: 'chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}', + }, + ], + ], +}; + +// Production release configuration +const productionConfig = { + branches: ['main'], + plugins: [ + commitAnalyzer, + releaseNotesGenerator, + [ + '@semantic-release/changelog', + { + changelogFile: 'CHANGELOG.md', + }, + ], + '@semantic-release/npm', + [ + '@semantic-release/github', + { + successComment: + ':tada: This issue has been resolved in version ${nextRelease.version} :tada:\n\nThe release is available on:\n- [npm package (@latest)](https://www.npmjs.com/package/@kaitranntt/ccs)\n- [GitHub release](${releases[0].url})', + releasedLabels: ['released'], + }, + ], + [ + '@semantic-release/git', + { + assets: ['CHANGELOG.md', 'package.json'], + message: 'chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}', + }, + ], + ], +}; + +const config = currentBranch === 'dev' ? devConfig : productionConfig; + +console.error(`[semantic-release config] Using ${currentBranch === 'dev' ? 'DEV' : 'PRODUCTION'} config`); + +module.exports = config; diff --git a/.releaserc.json b/.releaserc.json deleted file mode 100644 index 1809e16b..00000000 --- a/.releaserc.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "branches": ["main"], - "plugins": [ - "@semantic-release/commit-analyzer", - "@semantic-release/release-notes-generator", - ["@semantic-release/changelog", { - "changelogFile": "CHANGELOG.md" - }], - "./scripts/sync-version-plugin.cjs", - ["@semantic-release/npm", { - "npmPublish": true - }], - ["@semantic-release/git", { - "assets": ["CHANGELOG.md", "package.json", "VERSION", "installers/install.sh", "installers/install.ps1"], - "message": "chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}" - }], - ["@semantic-release/github", { - "successComment": ":tada: This issue has been resolved in version ${nextRelease.version} :tada:\n\nThe release is available on:\n- [npm package (@${nextRelease.channel || 'latest'})](https://www.npmjs.com/package/ccs-claude-code-switcher)\n- [GitHub release](${releases.filter(r => r.name === 'GitHub release').map(r => r.url)[0] || url})", - "failComment": false, - "releasedLabels": ["released"] - }] - ] -} diff --git a/VERSION b/VERSION deleted file mode 100644 index 1d36282b..00000000 --- a/VERSION +++ /dev/null @@ -1 +0,0 @@ -6.5.0-dev.7 diff --git a/bun.lock b/bun.lock index c147f881..a697e9ab 100644 --- a/bun.lock +++ b/bun.lock @@ -21,7 +21,11 @@ "@commitlint/cli": "^20.1.0", "@commitlint/config-conventional": "^20.0.0", "@semantic-release/changelog": "^6.0.3", + "@semantic-release/commit-analyzer": "^13.0.1", "@semantic-release/git": "^10.0.1", + "@semantic-release/github": "^12.0.2", + "@semantic-release/npm": "^13.1.3", + "@semantic-release/release-notes-generator": "^14.1.0", "@tailwindcss/vite": "^4.1.17", "@types/chokidar": "^2.1.7", "@types/express": "^4.17.21", @@ -31,6 +35,7 @@ "@typescript-eslint/eslint-plugin": "^8.48.0", "@typescript-eslint/parser": "^8.48.0", "@vitejs/plugin-react": "^5.1.1", + "conventional-changelog-conventionalcommits": "^9.1.0", "eslint": "^9.39.1", "eslint-config-prettier": "^10.1.8", "husky": "^9.1.7", @@ -43,13 +48,13 @@ }, }, "packages": { - "@actions/core": ["@actions/core@1.11.1", "", { "dependencies": { "@actions/exec": "^1.1.1", "@actions/http-client": "^2.0.1" } }, "sha512-hXJCSrkwfA46Vd9Z3q4cpEpHB1rL5NG04+/rbqW9d3+CSvtB1tYe8UTpAlixa1vj0m/ULglfEK2UKxMGxCxv5A=="], + "@actions/core": ["@actions/core@2.0.1", "", { "dependencies": { "@actions/exec": "^2.0.0", "@actions/http-client": "^3.0.0" } }, "sha512-oBfqT3GwkvLlo1fjvhQLQxuwZCGTarTE5OuZ2Wg10hvhBj7LRIlF611WT4aZS6fDhO5ZKlY7lCAZTlpmyaHaeg=="], - "@actions/exec": ["@actions/exec@1.1.1", "", { "dependencies": { "@actions/io": "^1.0.1" } }, "sha512-+sCcHHbVdk93a0XT19ECtO/gIXoxvdsgQLzb2fE2/5sIZmWQuluYyjPQtrtTHdU1YzTZ7bAPN4sITq2xi1679w=="], + "@actions/exec": ["@actions/exec@2.0.0", "", { "dependencies": { "@actions/io": "^2.0.0" } }, "sha512-k8ngrX2voJ/RIN6r9xB82NVqKpnMRtxDoiO+g3olkIUpQNqjArXrCQceduQZCQj3P3xm32pChRLqRrtXTlqhIw=="], - "@actions/http-client": ["@actions/http-client@2.2.3", "", { "dependencies": { "tunnel": "^0.0.6", "undici": "^5.25.4" } }, "sha512-mx8hyJi/hjFvbPokCg4uRd4ZX78t+YyRPtnKWwIl+RzNaVuFpQHfmlGVfsKEJN8LwTCvL+DfVgAM04XaHkm6bA=="], + "@actions/http-client": ["@actions/http-client@3.0.0", "", { "dependencies": { "tunnel": "^0.0.6", "undici": "^5.28.5" } }, "sha512-1s3tXAfVMSz9a4ZEBkXXRQD4QhY3+GAsWSbaYpeknPOKEeyRiU3lH+bHiLMZdo2x/fIeQ/hscL1wCkDLVM2DZQ=="], - "@actions/io": ["@actions/io@1.1.3", "", {}, "sha512-wi9JjgKLYS7U/z8PPbco+PvTb/nRWjeoFlJ1Qer83k/3C5PHQi28hiVdeE2kHXmIL99mQFawx8qt/JPjZilJ8Q=="], + "@actions/io": ["@actions/io@2.0.0", "", {}, "sha512-Jv33IN09XLO+0HS79aaODsvIRyduiF7NY/F6LYeK5oeUmrsz7aFdRphQjFoESF4jS7lMauDOttKALcpapVDIAg=="], "@babel/code-frame": ["@babel/code-frame@7.27.1", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg=="], @@ -305,7 +310,7 @@ "@semantic-release/github": ["@semantic-release/github@12.0.2", "", { "dependencies": { "@octokit/core": "^7.0.0", "@octokit/plugin-paginate-rest": "^14.0.0", "@octokit/plugin-retry": "^8.0.0", "@octokit/plugin-throttling": "^11.0.0", "@semantic-release/error": "^4.0.0", "aggregate-error": "^5.0.0", "debug": "^4.3.4", "dir-glob": "^3.0.1", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.0", "issue-parser": "^7.0.0", "lodash-es": "^4.17.21", "mime": "^4.0.0", "p-filter": "^4.0.0", "tinyglobby": "^0.2.14", "undici": "^7.0.0", "url-join": "^5.0.0" }, "peerDependencies": { "semantic-release": ">=24.1.0" } }, "sha512-qyqLS+aSGH1SfXIooBKjs7mvrv0deg8v+jemegfJg1kq6ji+GJV8CO08VJDEsvjp3O8XJmTTIAjjZbMzagzsdw=="], - "@semantic-release/npm": ["@semantic-release/npm@13.1.2", "", { "dependencies": { "@actions/core": "^1.11.1", "@semantic-release/error": "^4.0.0", "aggregate-error": "^5.0.0", "env-ci": "^11.2.0", "execa": "^9.0.0", "fs-extra": "^11.0.0", "lodash-es": "^4.17.21", "nerf-dart": "^1.0.0", "normalize-url": "^8.0.0", "npm": "^11.6.2", "rc": "^1.2.8", "read-pkg": "^10.0.0", "registry-auth-token": "^5.0.0", "semver": "^7.1.2", "tempy": "^3.0.0" }, "peerDependencies": { "semantic-release": ">=20.1.0" } }, "sha512-9rtshDTNlzYrC7uSBtB1vHqFzFZaNHigqkkCH5Ls4N/BSlVOenN5vtwHYxjAR4jf1hNvWSVwL4eIFTHONYckkw=="], + "@semantic-release/npm": ["@semantic-release/npm@13.1.3", "", { "dependencies": { "@actions/core": "^2.0.0", "@semantic-release/error": "^4.0.0", "aggregate-error": "^5.0.0", "env-ci": "^11.2.0", "execa": "^9.0.0", "fs-extra": "^11.0.0", "lodash-es": "^4.17.21", "nerf-dart": "^1.0.0", "normalize-url": "^8.0.0", "npm": "^11.6.2", "rc": "^1.2.8", "read-pkg": "^10.0.0", "registry-auth-token": "^5.0.0", "semver": "^7.1.2", "tempy": "^3.0.0" }, "peerDependencies": { "semantic-release": ">=20.1.0" } }, "sha512-q7zreY8n9V0FIP1Cbu63D+lXtRAVAIWb30MH5U3TdrfXt6r2MIrWCY0whAImN53qNvSGp0Zt07U95K+Qp9GpEg=="], "@semantic-release/release-notes-generator": ["@semantic-release/release-notes-generator@14.1.0", "", { "dependencies": { "conventional-changelog-angular": "^8.0.0", "conventional-changelog-writer": "^8.0.0", "conventional-commits-filter": "^5.0.0", "conventional-commits-parser": "^6.0.0", "debug": "^4.0.0", "get-stream": "^7.0.0", "import-from-esm": "^2.0.0", "into-stream": "^7.0.0", "lodash-es": "^4.17.21", "read-package-up": "^11.0.0" }, "peerDependencies": { "semantic-release": ">=20.1.0" } }, "sha512-CcyDRk7xq+ON/20YNR+1I/jP7BYKICr1uKd1HHpROSnnTdGqOTburi4jcRiTYz0cpfhxSloQO3cGhnoot7IEkA=="], @@ -529,7 +534,7 @@ "conventional-changelog-angular": ["conventional-changelog-angular@8.1.0", "", { "dependencies": { "compare-func": "^2.0.0" } }, "sha512-GGf2Nipn1RUCAktxuVauVr1e3r8QrLP/B0lEUsFktmGqc3ddbQkhoJZHJctVU829U1c6mTSWftrVOCHaL85Q3w=="], - "conventional-changelog-conventionalcommits": ["conventional-changelog-conventionalcommits@7.0.2", "", { "dependencies": { "compare-func": "^2.0.0" } }, "sha512-NKXYmMR/Hr1DevQegFB4MwfM5Vv0m4UIxKZTTYuD98lpTknaZlSRrDOG4X7wIXpGkfsYxZTghUN+Qq+T0YQI7w=="], + "conventional-changelog-conventionalcommits": ["conventional-changelog-conventionalcommits@9.1.0", "", { "dependencies": { "compare-func": "^2.0.0" } }, "sha512-MnbEysR8wWa8dAEvbj5xcBgJKQlX/m0lhS8DsyAAWDHdfs2faDJxTgzRYlRYpXSe7UiKrIIlB4TrBKU9q9DgkA=="], "conventional-changelog-writer": ["conventional-changelog-writer@8.2.0", "", { "dependencies": { "conventional-commits-filter": "^5.0.0", "handlebars": "^4.7.7", "meow": "^13.0.0", "semver": "^7.5.2" }, "bin": { "conventional-changelog-writer": "dist/cli/index.js" } }, "sha512-Y2aW4596l9AEvFJRwFGJGiQjt2sBYTjPD18DdvxX9Vpz0Z7HQ+g1Z+6iYDAm1vR3QOJrDBkRHixHK/+FhkR6Pw=="], @@ -705,7 +710,7 @@ "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], - "get-stream": ["get-stream@6.0.1", "", {}, "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg=="], + "get-stream": ["get-stream@7.0.1", "", {}, "sha512-3M8C1EOFN6r8AMUhwUAACIoXZJEOufDU5+0gFFN5uNs6XYOralD2Pqkl7m046va6x77FwposWXbAhPPIOus7mQ=="], "git-log-parser": ["git-log-parser@1.2.1", "", { "dependencies": { "argv-formatter": "~1.0.0", "spawn-error-forwarder": "~1.0.0", "split2": "~1.0.0", "stream-combiner2": "~1.1.1", "through2": "~2.0.0", "traverse": "0.6.8" } }, "sha512-PI+sPDvHXNPl5WNOErAK05s3j0lgwUzMN6o8cyQrDaKfT3qd7TmNJKeXX+SknI5I0QhG5fVPAEwSY4tRGDtYoQ=="], @@ -799,7 +804,7 @@ "is-path-inside": ["is-path-inside@3.0.3", "", {}, "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ=="], - "is-plain-obj": ["is-plain-obj@2.1.0", "", {}, "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA=="], + "is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="], "is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="], @@ -927,7 +932,7 @@ "media-typer": ["media-typer@0.3.0", "", {}, "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ=="], - "meow": ["meow@12.1.1", "", {}, "sha512-BhXM0Au22RwUneMPwSCnyhTOizdWoIEPU9sp0Aqa1PnDMR5Wv2FGXYDjuzJEIX+Eo2Rb8xuYe5jrnm5QowQFkw=="], + "meow": ["meow@13.2.0", "", {}, "sha512-pxQJQzB6djGPXh08dacEloMFopsOqGVRKFPYvPOt9XDZ1HasbgDZA74CJGreSU4G3Ak7EFJGoiH2auq+yXISgA=="], "merge-descriptors": ["merge-descriptors@1.0.3", "", {}, "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ=="], @@ -1019,7 +1024,7 @@ "parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="], - "parse-json": ["parse-json@5.2.0", "", { "dependencies": { "@babel/code-frame": "^7.0.0", "error-ex": "^1.3.1", "json-parse-even-better-errors": "^2.3.0", "lines-and-columns": "^1.1.6" } }, "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg=="], + "parse-json": ["parse-json@8.3.0", "", { "dependencies": { "@babel/code-frame": "^7.26.2", "index-to-position": "^1.1.0", "type-fest": "^4.39.1" } }, "sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ=="], "parse-ms": ["parse-ms@4.0.0", "", {}, "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw=="], @@ -1075,7 +1080,7 @@ "react-refresh": ["react-refresh@0.18.0", "", {}, "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw=="], - "read-package-up": ["read-package-up@12.0.0", "", { "dependencies": { "find-up-simple": "^1.0.1", "read-pkg": "^10.0.0", "type-fest": "^5.2.0" } }, "sha512-Q5hMVBYur/eQNWDdbF4/Wqqr9Bjvtrw2kjGxxBbKLbx8bVCL8gcArjTy8zDUuLGQicftpMuU0riQNcAsbtOVsw=="], + "read-package-up": ["read-package-up@11.0.0", "", { "dependencies": { "find-up-simple": "^1.0.0", "read-pkg": "^9.0.0", "type-fest": "^4.6.0" } }, "sha512-MbgfoNPANMdb4oRBNg5eqLbB2t2r+o5Ua1pNt8BqGp4I0FJZhuVSOj3PaBPni4azWuSzEdNn2evevzVmEk1ohQ=="], "read-pkg": ["read-pkg@10.0.0", "", { "dependencies": { "@types/normalize-package-data": "^2.4.4", "normalize-package-data": "^8.0.0", "parse-json": "^8.3.0", "type-fest": "^5.2.0", "unicorn-magic": "^0.3.0" } }, "sha512-A70UlgfNdKI5NSvTTfHzLQj7NJRpJ4mT5tGafkllJ4wh71oYuGm/pzphHcmW4s35iox56KSK721AihodoXSc/A=="], @@ -1309,6 +1314,8 @@ "@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + "@commitlint/config-conventional/conventional-changelog-conventionalcommits": ["conventional-changelog-conventionalcommits@7.0.2", "", { "dependencies": { "compare-func": "^2.0.0" } }, "sha512-NKXYmMR/Hr1DevQegFB4MwfM5Vv0m4UIxKZTTYuD98lpTknaZlSRrDOG4X7wIXpGkfsYxZTghUN+Qq+T0YQI7w=="], + "@commitlint/config-validator/ajv": ["ajv@8.17.1", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g=="], "@commitlint/format/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], @@ -1345,10 +1352,6 @@ "@semantic-release/npm/execa": ["execa@9.6.1", "", { "dependencies": { "@sindresorhus/merge-streams": "^4.0.0", "cross-spawn": "^7.0.6", "figures": "^6.1.0", "get-stream": "^9.0.0", "human-signals": "^8.0.1", "is-plain-obj": "^4.1.0", "is-stream": "^4.0.1", "npm-run-path": "^6.0.0", "pretty-ms": "^9.2.0", "signal-exit": "^4.1.0", "strip-final-newline": "^4.0.0", "yoctocolors": "^2.1.1" } }, "sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA=="], - "@semantic-release/release-notes-generator/get-stream": ["get-stream@7.0.1", "", {}, "sha512-3M8C1EOFN6r8AMUhwUAACIoXZJEOufDU5+0gFFN5uNs6XYOralD2Pqkl7m046va6x77FwposWXbAhPPIOus7mQ=="], - - "@semantic-release/release-notes-generator/read-package-up": ["read-package-up@11.0.0", "", { "dependencies": { "find-up-simple": "^1.0.0", "read-pkg": "^9.0.0", "type-fest": "^4.6.0" } }, "sha512-MbgfoNPANMdb4oRBNg5eqLbB2t2r+o5Ua1pNt8BqGp4I0FJZhuVSOj3PaBPni4azWuSzEdNn2evevzVmEk1ohQ=="], - "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.7.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-o1uhUASyo921r2XtHYOHy7gdkGLge8ghBEQHMWmyJFoXlpU58kIrhhN3w26lpQb6dspetweapMn2CSNwQ8I4wg=="], "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.7.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA=="], @@ -1377,9 +1380,7 @@ "cli-highlight/yargs": ["yargs@16.2.0", "", { "dependencies": { "cliui": "^7.0.2", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.0", "y18n": "^5.0.5", "yargs-parser": "^20.2.2" } }, "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw=="], - "conventional-changelog-writer/meow": ["meow@13.2.0", "", {}, "sha512-pxQJQzB6djGPXh08dacEloMFopsOqGVRKFPYvPOt9XDZ1HasbgDZA74CJGreSU4G3Ak7EFJGoiH2auq+yXISgA=="], - - "conventional-commits-parser/meow": ["meow@13.2.0", "", {}, "sha512-pxQJQzB6djGPXh08dacEloMFopsOqGVRKFPYvPOt9XDZ1HasbgDZA74CJGreSU4G3Ak7EFJGoiH2auq+yXISgA=="], + "cosmiconfig/parse-json": ["parse-json@5.2.0", "", { "dependencies": { "@babel/code-frame": "^7.0.0", "error-ex": "^1.3.1", "json-parse-even-better-errors": "^2.3.0", "lines-and-columns": "^1.1.6" } }, "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg=="], "crypto-random-string/type-fest": ["type-fest@1.4.0", "", {}, "sha512-yGSza74xk0UG8k+pLh5oeoYirvIiWo5t0/o3zHHAO2tRDiZcxWP7fywNlXhqb6/r6sWvwi+RsyQMWhVLe4BVuA=="], @@ -1389,6 +1390,8 @@ "eslint/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], + "execa/get-stream": ["get-stream@6.0.1", "", {}, "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg=="], + "express/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], "figures/is-unicode-supported": ["is-unicode-supported@2.1.0", "", {}, "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ=="], @@ -1399,6 +1402,8 @@ "from2/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="], + "git-raw-commits/meow": ["meow@12.1.1", "", {}, "sha512-BhXM0Au22RwUneMPwSCnyhTOizdWoIEPU9sp0Aqa1PnDMR5Wv2FGXYDjuzJEIX+Eo2Rb8xuYe5jrnm5QowQFkw=="], + "git-raw-commits/split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="], "glob/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], @@ -1741,6 +1746,8 @@ "p-filter/p-map": ["p-map@7.0.4", "", {}, "sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ=="], + "parse-json/type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="], + "parse5-htmlparser2-tree-adapter/parse5": ["parse5@6.0.1", "", {}, "sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw=="], "path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], @@ -1749,9 +1756,9 @@ "rc/strip-json-comments": ["strip-json-comments@2.0.1", "", {}, "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ=="], - "read-package-up/type-fest": ["type-fest@5.2.0", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-xxCJm+Bckc6kQBknN7i9fnP/xobQRsRQxR01CztFkp/h++yfVxUUcmMgfR2HttJx/dpWjS9ubVuyspJv24Q9DA=="], + "read-package-up/read-pkg": ["read-pkg@9.0.1", "", { "dependencies": { "@types/normalize-package-data": "^2.4.3", "normalize-package-data": "^6.0.0", "parse-json": "^8.0.0", "type-fest": "^4.6.0", "unicorn-magic": "^0.1.0" } }, "sha512-9viLL4/n1BJUCT1NXVTdS1jtm80yDEgR5T4yCelII49Mbj0v1rZdKqj7zCiYdbB0CuCgdrvHcNogAKTFPBocFA=="], - "read-pkg/parse-json": ["parse-json@8.3.0", "", { "dependencies": { "@babel/code-frame": "^7.26.2", "index-to-position": "^1.1.0", "type-fest": "^4.39.1" } }, "sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ=="], + "read-package-up/type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="], "read-pkg/type-fest": ["type-fest@5.2.0", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-xxCJm+Bckc6kQBknN7i9fnP/xobQRsRQxR01CztFkp/h++yfVxUUcmMgfR2HttJx/dpWjS9ubVuyspJv24Q9DA=="], @@ -1759,12 +1766,18 @@ "semantic-release/@semantic-release/error": ["@semantic-release/error@4.0.0", "", {}, "sha512-mgdxrHTLOjOddRVYIYDo0fR3/v61GNN1YGkfbrjuIKg/uMgCd+Qzo3UAXJ+woLQQpos4pl5Esuw5A7AoNlzjUQ=="], + "semantic-release/@semantic-release/npm": ["@semantic-release/npm@13.1.2", "", { "dependencies": { "@actions/core": "^1.11.1", "@semantic-release/error": "^4.0.0", "aggregate-error": "^5.0.0", "env-ci": "^11.2.0", "execa": "^9.0.0", "fs-extra": "^11.0.0", "lodash-es": "^4.17.21", "nerf-dart": "^1.0.0", "normalize-url": "^8.0.0", "npm": "^11.6.2", "rc": "^1.2.8", "read-pkg": "^10.0.0", "registry-auth-token": "^5.0.0", "semver": "^7.1.2", "tempy": "^3.0.0" }, "peerDependencies": { "semantic-release": ">=20.1.0" } }, "sha512-9rtshDTNlzYrC7uSBtB1vHqFzFZaNHigqkkCH5Ls4N/BSlVOenN5vtwHYxjAR4jf1hNvWSVwL4eIFTHONYckkw=="], + "semantic-release/aggregate-error": ["aggregate-error@5.0.0", "", { "dependencies": { "clean-stack": "^5.2.0", "indent-string": "^5.0.0" } }, "sha512-gOsf2YwSlleG6IjRYG2A7k0HmBMEo6qVNk9Bp/EaLgAJT5ngH6PXbqa4ItvnEwCm/velL5jAnQgsHsWnjhGmvw=="], "semantic-release/execa": ["execa@9.6.1", "", { "dependencies": { "@sindresorhus/merge-streams": "^4.0.0", "cross-spawn": "^7.0.6", "figures": "^6.1.0", "get-stream": "^9.0.0", "human-signals": "^8.0.1", "is-plain-obj": "^4.1.0", "is-stream": "^4.0.1", "npm-run-path": "^6.0.0", "pretty-ms": "^9.2.0", "signal-exit": "^4.1.0", "strip-final-newline": "^4.0.0", "yoctocolors": "^2.1.1" } }, "sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA=="], + "semantic-release/get-stream": ["get-stream@6.0.1", "", {}, "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg=="], + "semantic-release/p-reduce": ["p-reduce@3.0.0", "", {}, "sha512-xsrIUgI0Kn6iyDYm9StOpOeK29XM1aboGji26+QEortiFST1hGZaUQOLhtEbqHErPpGW/aSz6allwK2qcptp0Q=="], + "semantic-release/read-package-up": ["read-package-up@12.0.0", "", { "dependencies": { "find-up-simple": "^1.0.1", "read-pkg": "^10.0.0", "type-fest": "^5.2.0" } }, "sha512-Q5hMVBYur/eQNWDdbF4/Wqqr9Bjvtrw2kjGxxBbKLbx8bVCL8gcArjTy8zDUuLGQicftpMuU0riQNcAsbtOVsw=="], + "semantic-release/yargs": ["yargs@18.0.0", "", { "dependencies": { "cliui": "^9.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "string-width": "^7.2.0", "y18n": "^5.0.5", "yargs-parser": "^22.0.0" } }, "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg=="], "send/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], @@ -1797,8 +1810,12 @@ "through2/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="], + "yargs-unparser/is-plain-obj": ["is-plain-obj@2.1.0", "", {}, "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA=="], + "@commitlint/config-validator/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + "@commitlint/parse/conventional-commits-parser/meow": ["meow@12.1.1", "", {}, "sha512-BhXM0Au22RwUneMPwSCnyhTOizdWoIEPU9sp0Aqa1PnDMR5Wv2FGXYDjuzJEIX+Eo2Rb8xuYe5jrnm5QowQFkw=="], + "@commitlint/parse/conventional-commits-parser/split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="], "@commitlint/top-level/find-up/locate-path": ["locate-path@7.2.0", "", { "dependencies": { "p-locate": "^6.0.0" } }, "sha512-gvVijfZvn7R+2qyPX8mAuKcFGDf6Nc61GdvGafQsHL0sBIxfKzA+usWn4GFC/bk+QdwPUD4kWFJLhElipq+0VA=="], @@ -1823,8 +1840,6 @@ "@semantic-release/npm/execa/human-signals": ["human-signals@8.0.1", "", {}, "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ=="], - "@semantic-release/npm/execa/is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="], - "@semantic-release/npm/execa/is-stream": ["is-stream@4.0.1", "", {}, "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A=="], "@semantic-release/npm/execa/npm-run-path": ["npm-run-path@6.0.0", "", { "dependencies": { "path-key": "^4.0.0", "unicorn-magic": "^0.3.0" } }, "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA=="], @@ -1833,10 +1848,6 @@ "@semantic-release/npm/execa/strip-final-newline": ["strip-final-newline@4.0.0", "", {}, "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw=="], - "@semantic-release/release-notes-generator/read-package-up/read-pkg": ["read-pkg@9.0.1", "", { "dependencies": { "@types/normalize-package-data": "^2.4.3", "normalize-package-data": "^6.0.0", "parse-json": "^8.0.0", "type-fest": "^4.6.0", "unicorn-magic": "^0.1.0" } }, "sha512-9viLL4/n1BJUCT1NXVTdS1jtm80yDEgR5T4yCelII49Mbj0v1rZdKqj7zCiYdbB0CuCgdrvHcNogAKTFPBocFA=="], - - "@semantic-release/release-notes-generator/read-package-up/type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="], - "@types/chokidar/chokidar/readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="], "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], @@ -1893,7 +1904,11 @@ "pkg-conf/find-up/locate-path": ["locate-path@2.0.0", "", { "dependencies": { "p-locate": "^2.0.0", "path-exists": "^3.0.0" } }, "sha512-NCI2kiDkyR7VeEKm27Kda/iQHyKJe1Bu0FlTbYp3CqJu+9IFe9bLyAjMxf5ZDDbEg+iMPzB5zYyUTSm8wVTKmA=="], - "read-pkg/parse-json/type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="], + "read-package-up/read-pkg/normalize-package-data": ["normalize-package-data@6.0.2", "", { "dependencies": { "hosted-git-info": "^7.0.0", "semver": "^7.3.5", "validate-npm-package-license": "^3.0.4" } }, "sha512-V6gygoYb/5EmNI+MEGrWkC+e6+Rr7mTmfHrxDbLzxQogBkgzo76rkok0Am6thgSF7Mv2nLOajAJj5vDJZEFn7g=="], + + "read-package-up/read-pkg/unicorn-magic": ["unicorn-magic@0.1.0", "", {}, "sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ=="], + + "semantic-release/@semantic-release/npm/@actions/core": ["@actions/core@1.11.1", "", { "dependencies": { "@actions/exec": "^1.1.1", "@actions/http-client": "^2.0.1" } }, "sha512-hXJCSrkwfA46Vd9Z3q4cpEpHB1rL5NG04+/rbqW9d3+CSvtB1tYe8UTpAlixa1vj0m/ULglfEK2UKxMGxCxv5A=="], "semantic-release/aggregate-error/clean-stack": ["clean-stack@5.3.0", "", { "dependencies": { "escape-string-regexp": "5.0.0" } }, "sha512-9ngPTOhYGQqNVSfeJkYXHmF7AGWp4/nN5D/QqNQs3Dvxd1Kk/WpjHfNujKHYUQ/5CoGyOyFNoWSPk5afzP0QVg=="], @@ -1903,8 +1918,6 @@ "semantic-release/execa/human-signals": ["human-signals@8.0.1", "", {}, "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ=="], - "semantic-release/execa/is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="], - "semantic-release/execa/is-stream": ["is-stream@4.0.1", "", {}, "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A=="], "semantic-release/execa/npm-run-path": ["npm-run-path@6.0.0", "", { "dependencies": { "path-key": "^4.0.0", "unicorn-magic": "^0.3.0" } }, "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA=="], @@ -1913,6 +1926,8 @@ "semantic-release/execa/strip-final-newline": ["strip-final-newline@4.0.0", "", {}, "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw=="], + "semantic-release/read-package-up/type-fest": ["type-fest@5.2.0", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-xxCJm+Bckc6kQBknN7i9fnP/xobQRsRQxR01CztFkp/h++yfVxUUcmMgfR2HttJx/dpWjS9ubVuyspJv24Q9DA=="], + "semantic-release/yargs/cliui": ["cliui@9.0.1", "", { "dependencies": { "string-width": "^7.2.0", "strip-ansi": "^7.1.0", "wrap-ansi": "^9.0.0" } }, "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w=="], "semantic-release/yargs/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], @@ -1951,12 +1966,6 @@ "@semantic-release/npm/execa/npm-run-path/path-key": ["path-key@4.0.0", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="], - "@semantic-release/release-notes-generator/read-package-up/read-pkg/normalize-package-data": ["normalize-package-data@6.0.2", "", { "dependencies": { "hosted-git-info": "^7.0.0", "semver": "^7.3.5", "validate-npm-package-license": "^3.0.4" } }, "sha512-V6gygoYb/5EmNI+MEGrWkC+e6+Rr7mTmfHrxDbLzxQogBkgzo76rkok0Am6thgSF7Mv2nLOajAJj5vDJZEFn7g=="], - - "@semantic-release/release-notes-generator/read-package-up/read-pkg/parse-json": ["parse-json@8.3.0", "", { "dependencies": { "@babel/code-frame": "^7.26.2", "index-to-position": "^1.1.0", "type-fest": "^4.39.1" } }, "sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ=="], - - "@semantic-release/release-notes-generator/read-package-up/read-pkg/unicorn-magic": ["unicorn-magic@0.1.0", "", {}, "sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ=="], - "env-ci/execa/npm-run-path/path-key": ["path-key@4.0.0", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="], "env-ci/execa/onetime/mimic-fn": ["mimic-fn@4.0.0", "", {}, "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw=="], @@ -1971,6 +1980,12 @@ "pkg-conf/find-up/locate-path/path-exists": ["path-exists@3.0.0", "", {}, "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ=="], + "read-package-up/read-pkg/normalize-package-data/hosted-git-info": ["hosted-git-info@7.0.2", "", { "dependencies": { "lru-cache": "^10.0.1" } }, "sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w=="], + + "semantic-release/@semantic-release/npm/@actions/core/@actions/exec": ["@actions/exec@1.1.1", "", { "dependencies": { "@actions/io": "^1.0.1" } }, "sha512-+sCcHHbVdk93a0XT19ECtO/gIXoxvdsgQLzb2fE2/5sIZmWQuluYyjPQtrtTHdU1YzTZ7bAPN4sITq2xi1679w=="], + + "semantic-release/@semantic-release/npm/@actions/core/@actions/http-client": ["@actions/http-client@2.2.3", "", { "dependencies": { "tunnel": "^0.0.6", "undici": "^5.25.4" } }, "sha512-mx8hyJi/hjFvbPokCg4uRd4ZX78t+YyRPtnKWwIl+RzNaVuFpQHfmlGVfsKEJN8LwTCvL+DfVgAM04XaHkm6bA=="], + "semantic-release/aggregate-error/clean-stack/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="], "semantic-release/execa/npm-run-path/path-key": ["path-key@4.0.0", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="], @@ -1991,16 +2006,18 @@ "@commitlint/top-level/find-up/locate-path/p-locate/p-limit": ["p-limit@4.0.0", "", { "dependencies": { "yocto-queue": "^1.0.0" } }, "sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ=="], - "@semantic-release/release-notes-generator/read-package-up/read-pkg/normalize-package-data/hosted-git-info": ["hosted-git-info@7.0.2", "", { "dependencies": { "lru-cache": "^10.0.1" } }, "sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w=="], - "pkg-conf/find-up/locate-path/p-locate/p-limit": ["p-limit@1.3.0", "", { "dependencies": { "p-try": "^1.0.0" } }, "sha512-vvcXsLAJ9Dr5rQOPk7toZQZJApBl2K4J6dANSsEuh6QI41JYcsS/qhTGa9ErIUUgK3WNQoJYvylxvjqmiqEA9Q=="], + "read-package-up/read-pkg/normalize-package-data/hosted-git-info/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], + + "semantic-release/@semantic-release/npm/@actions/core/@actions/exec/@actions/io": ["@actions/io@1.1.3", "", {}, "sha512-wi9JjgKLYS7U/z8PPbco+PvTb/nRWjeoFlJ1Qer83k/3C5PHQi28hiVdeE2kHXmIL99mQFawx8qt/JPjZilJ8Q=="], + + "semantic-release/@semantic-release/npm/@actions/core/@actions/http-client/undici": ["undici@5.29.0", "", { "dependencies": { "@fastify/busboy": "^2.0.0" } }, "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg=="], + "semantic-release/yargs/cliui/wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], "signale/chalk/ansi-styles/color-convert/color-name": ["color-name@1.1.3", "", {}, "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw=="], "@commitlint/top-level/find-up/locate-path/p-locate/p-limit/yocto-queue": ["yocto-queue@1.2.2", "", {}, "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ=="], - - "@semantic-release/release-notes-generator/read-package-up/read-pkg/normalize-package-data/hosted-git-info/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], } } diff --git a/package.json b/package.json index e18b9c4b..4895b3ea 100644 --- a/package.json +++ b/package.json @@ -98,7 +98,11 @@ "@commitlint/cli": "^20.1.0", "@commitlint/config-conventional": "^20.0.0", "@semantic-release/changelog": "^6.0.3", + "@semantic-release/commit-analyzer": "^13.0.1", "@semantic-release/git": "^10.0.1", + "@semantic-release/github": "^12.0.2", + "@semantic-release/npm": "^13.1.3", + "@semantic-release/release-notes-generator": "^14.1.0", "@tailwindcss/vite": "^4.1.17", "@types/chokidar": "^2.1.7", "@types/express": "^4.17.21", @@ -108,6 +112,7 @@ "@typescript-eslint/eslint-plugin": "^8.48.0", "@typescript-eslint/parser": "^8.48.0", "@vitejs/plugin-react": "^5.1.1", + "conventional-changelog-conventionalcommits": "^9.1.0", "eslint": "^9.39.1", "eslint-config-prettier": "^10.1.8", "husky": "^9.1.7", diff --git a/scripts/send-discord-release.cjs b/scripts/send-discord-release.cjs new file mode 100644 index 00000000..b22da048 --- /dev/null +++ b/scripts/send-discord-release.cjs @@ -0,0 +1,214 @@ +/** + * Send Release Notification to Discord using Embeds + * + * Usage: + * node send-discord-release.cjs + * + * Args: + * type: 'production' or 'dev' + * webhook-url: Discord webhook URL + */ + +const fs = require('fs'); +const https = require('https'); +const { URL } = require('url'); + +const releaseType = process.argv[2]; // 'production' or 'dev' +const webhookUrl = process.argv[3]; + +if (!releaseType || !webhookUrl) { + console.error('Usage: node send-discord-release.cjs '); + process.exit(1); +} + +// Validate webhook URL is Discord +try { + const parsed = new URL(webhookUrl); + if (!parsed.hostname.endsWith('discord.com') || !parsed.pathname.startsWith('/api/webhooks/')) { + console.error('[X] Invalid Discord webhook URL'); + process.exit(1); + } +} catch { + console.error('[X] Invalid URL format'); + process.exit(1); +} + +/** + * Extract latest release from CHANGELOG.md + */ +function extractLatestRelease() { + const changelogPath = 'CHANGELOG.md'; + + if (!fs.existsSync(changelogPath)) { + return { + version: 'Unknown', + date: new Date().toISOString().split('T')[0], + sections: {}, + }; + } + + const content = fs.readFileSync(changelogPath, 'utf8'); + const lines = content.split('\n'); + + let version = 'Unknown'; + let date = new Date().toISOString().split('T')[0]; + let collecting = false; + let currentSection = null; + const sections = {}; + + for (const line of lines) { + // Match: ## [1.0.0](url) (2025-01-01) or ## 1.0.0 (2025-01-01) + const versionMatch = line.match(/^## \[?(\d+\.\d+\.\d+(?:-dev\.\d+)?)\]?.*?\((\d{4}-\d{2}-\d{2})\)/); + if (versionMatch) { + if (!collecting) { + version = versionMatch[1]; + date = versionMatch[2]; + collecting = true; + continue; + } else { + break; // Found next version, stop + } + } + + if (!collecting) continue; + + // Match section headers: ### Features, ### Bug Fixes + const sectionMatch = line.match(/^### (.+)/); + if (sectionMatch) { + currentSection = sectionMatch[1]; + sections[currentSection] = []; + continue; + } + + // Collect bullet points + if (currentSection && line.trim().startsWith('*')) { + const item = line.trim().substring(1).trim(); + if (item) { + sections[currentSection].push(item); + } + } + } + + return { version, date, sections }; +} + +/** + * Create Discord embed + */ +function createEmbed(release) { + const isDev = releaseType === 'dev'; + const color = isDev ? 0xf59e0b : 0x10b981; // Orange for dev, Green for production + const title = isDev ? `Dev Release ${release.version}` : `Release ${release.version}`; + const url = `https://github.com/kaitranntt/ccs/releases/tag/v${release.version}`; + + // Section name to indicator mapping (ASCII only per CLAUDE.md) + const sectionIndicators = { + Features: '[+]', + 'Bug Fixes': '[X]', + Documentation: '[i]', + Styles: '[~]', + 'Code Refactoring': '[~]', + 'Performance Improvements': '[!]', + Tests: '[T]', + 'Build System': '[B]', + CI: '[C]', + }; + + const fields = []; + + for (const [sectionName, items] of Object.entries(release.sections)) { + if (items.length === 0) continue; + + const indicator = sectionIndicators[sectionName] || '[*]'; + let fieldValue = items.map((item) => `• ${item}`).join('\n'); + + // Discord field value max is 1024 characters + if (fieldValue.length > 1024) { + const truncateAt = fieldValue.lastIndexOf('\n', 1000); + fieldValue = fieldValue.substring(0, truncateAt > 0 ? truncateAt : 1000) + '\n... *(truncated)*'; + } + + fields.push({ + name: `${indicator} ${sectionName}`, + value: fieldValue, + inline: false, + }); + } + + if (fields.length === 0) { + fields.push({ + name: '[i] Release Notes', + value: 'Release completed. See changelog on GitHub.', + inline: false, + }); + } + + return { + title, + url, + color, + timestamp: new Date().toISOString(), + footer: { + text: isDev ? 'npm i @kaitranntt/ccs@dev' : 'npm i @kaitranntt/ccs@latest', + }, + fields, + }; +} + +/** + * Send to Discord webhook + */ +function sendToDiscord(embed) { + const payload = { + username: releaseType === 'dev' ? 'CCS Dev Release' : 'CCS Release', + embeds: [embed], + }; + + const url = new URL(webhookUrl); + const options = { + hostname: url.hostname, + path: url.pathname + url.search, + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }; + + const req = https.request(options, (res) => { + let data = ''; + + res.on('data', (chunk) => { + data += chunk; + }); + + res.on('end', () => { + if (res.statusCode >= 200 && res.statusCode < 300) { + console.log('[OK] Discord notification sent'); + } else { + console.error(`[X] Discord webhook failed: ${res.statusCode}`); + console.error(data); + process.exit(1); + } + }); + }); + + req.on('error', (error) => { + console.error('[X] Error sending Discord notification:', error); + process.exit(1); + }); + + req.write(JSON.stringify(payload)); + req.end(); +} + +// Main +try { + const release = extractLatestRelease(); + console.log(`[i] Preparing ${releaseType} notification for v${release.version}`); + + const embed = createEmbed(release); + sendToDiscord(embed); +} catch (error) { + console.error('[X] Error:', error); + process.exit(1); +} diff --git a/scripts/sync-version-plugin.cjs b/scripts/sync-version-plugin.cjs deleted file mode 100644 index 90d17ca3..00000000 --- a/scripts/sync-version-plugin.cjs +++ /dev/null @@ -1,41 +0,0 @@ -/** - * semantic-release plugin to sync VERSION file - * - * semantic-release updates package.json but not the VERSION file. - * This plugin keeps VERSION in sync for shell scripts and installers. - */ -const fs = require('fs'); -const path = require('path'); - -module.exports = { - /** - * Called during the prepare step before git commit - */ - prepare(_pluginConfig, context) { - const { nextRelease, logger } = context; - const versionFile = path.join(process.cwd(), 'VERSION'); - - // Write version without 'v' prefix (e.g., "5.1.0" not "v5.1.0") - const version = nextRelease.version; - fs.writeFileSync(versionFile, version + '\n'); - logger.log('[sync-version-plugin] Updated VERSION file to %s', version); - - // Also update installers for standalone installs - const installSh = path.join(process.cwd(), 'installers', 'install.sh'); - const installPs1 = path.join(process.cwd(), 'installers', 'install.ps1'); - - if (fs.existsSync(installSh)) { - let content = fs.readFileSync(installSh, 'utf8'); - content = content.replace(/^CCS_VERSION=".*"/m, `CCS_VERSION="${version}"`); - fs.writeFileSync(installSh, content); - logger.log('[sync-version-plugin] Updated installers/install.sh'); - } - - if (fs.existsSync(installPs1)) { - let content = fs.readFileSync(installPs1, 'utf8'); - content = content.replace(/^\$CcsVersion = ".*"/m, `$CcsVersion = "${version}"`); - fs.writeFileSync(installPs1, content); - logger.log('[sync-version-plugin] Updated installers/install.ps1'); - } - } -}; From 86d41e5e4d0d670ed420c9c76377fcdedbe561f0 Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Fri, 19 Dec 2025 06:05:00 +0000 Subject: [PATCH 24/40] chore(release): 6.6.0-dev.1 [skip ci] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## [6.6.0-dev.1](https://github.com/kaitranntt/ccs/compare/v6.5.0...v6.6.0-dev.1) (2025-12-19) ### ⚠ BREAKING CHANGES * Native shell installers (curl/irm) no longer work. Use `npm install -g @kaitranntt/ccs` instead. ### Features * **ci:** add Discord notifications for releases ([ee76d66](https://github.com/kaitranntt/ccs/commit/ee76d663aec59a86a236156dbc163d0d291c0446)) * **ci:** add semantic-release for dev branch with rich Discord notifications ([0f590c8](https://github.com/kaitranntt/ccs/commit/0f590c80d689c39cea7c94937ed398941dddb533)) * **cleanup:** add age-based error log cleanup ([45207b4](https://github.com/kaitranntt/ccs/commit/45207b4e7f92c09d7464dd5c954718254ddfd43a)) * **cliproxy:** set WRITABLE_PATH for log storage in ~/.ccs/cliproxy/ ([6b9396f](https://github.com/kaitranntt/ccs/commit/6b9396fbc6d464bc3e3d6d3bb639e70fe5306074)) * **dashboard:** add error log viewer for CLIProxy diagnostics ([5b3d565](https://github.com/kaitranntt/ccs/commit/5b3d56548a8dfb2e6bb22e14b13f0fb038f2d1fb)), closes [#132](https://github.com/kaitranntt/ccs/issues/132) * **global-env:** add global environment variables injection for third-party profiles ([5d34326](https://github.com/kaitranntt/ccs/commit/5d343260c7307c2d7ac8da92eb5f94c7f764d08c)) * **ui:** add absolute path copy for error logs ([5d4f49e](https://github.com/kaitranntt/ccs/commit/5d4f49e4bb6f9748efa89e96c342dfae3e35d02b)) * **ui:** add Stop and Restart buttons to ProxyStatusWidget ([c9ad0b0](https://github.com/kaitranntt/ccs/commit/c9ad0b077934ae8418d4e97b9b02a09044ff898b)) * **ui:** add version sync timestamp to ProxyStatusWidget ([d43079b](https://github.com/kaitranntt/ccs/commit/d43079b72414d7b841a35a934ea39a91527f4172)) * **ui:** redesign error logs monitor with split view layout ([8f47b87](https://github.com/kaitranntt/ccs/commit/8f47b8775f2c2493c05ee2be861ca3f8667cfc0e)) * **ui:** show CLIProxyAPI update availability in dashboard ([96762a9](https://github.com/kaitranntt/ccs/commit/96762a9f6ee096570b2fe6136a4431e6ce1d1a47)) ### Bug Fixes * **ci:** remove deprecated installer references from dev-release workflow ([4b969b6](https://github.com/kaitranntt/ccs/commit/4b969b6870aae6b5859b9a1be0cf98b9d537ce00)) * **cliproxy:** prevent misleading update message when proxy is running ([2adc272](https://github.com/kaitranntt/ccs/commit/2adc272f278b1d80d160ad4d6e1f35e3b61cb156)), closes [#143](https://github.com/kaitranntt/ccs/issues/143) * **error-logs-monitor:** properly handle status loading state ([1ef625e](https://github.com/kaitranntt/ccs/commit/1ef625ee863c517a5fbba21f16cf991bb77be7d7)) ### Styles * **ui:** widen cliproxy sidebar from w-64 to w-80 ([248d970](https://github.com/kaitranntt/ccs/commit/248d970cba8671b7c20dc99f8d1a70e4fe113605)) ### Code Refactoring * remove deprecated native shell installers ([126cffc](https://github.com/kaitranntt/ccs/commit/126cffc6dcf434abeee883a4109d3705cdb92a67)) --- CHANGELOG.md | 35 +++++++++++++++++++++++++++++++++++ package.json | 2 +- 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 24c42936..01c7ffa3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,38 @@ +## [6.6.0-dev.1](https://github.com/kaitranntt/ccs/compare/v6.5.0...v6.6.0-dev.1) (2025-12-19) + +### ⚠ BREAKING CHANGES + +* Native shell installers (curl/irm) no longer work. +Use `npm install -g @kaitranntt/ccs` instead. + +### Features + +* **ci:** add Discord notifications for releases ([ee76d66](https://github.com/kaitranntt/ccs/commit/ee76d663aec59a86a236156dbc163d0d291c0446)) +* **ci:** add semantic-release for dev branch with rich Discord notifications ([0f590c8](https://github.com/kaitranntt/ccs/commit/0f590c80d689c39cea7c94937ed398941dddb533)) +* **cleanup:** add age-based error log cleanup ([45207b4](https://github.com/kaitranntt/ccs/commit/45207b4e7f92c09d7464dd5c954718254ddfd43a)) +* **cliproxy:** set WRITABLE_PATH for log storage in ~/.ccs/cliproxy/ ([6b9396f](https://github.com/kaitranntt/ccs/commit/6b9396fbc6d464bc3e3d6d3bb639e70fe5306074)) +* **dashboard:** add error log viewer for CLIProxy diagnostics ([5b3d565](https://github.com/kaitranntt/ccs/commit/5b3d56548a8dfb2e6bb22e14b13f0fb038f2d1fb)), closes [#132](https://github.com/kaitranntt/ccs/issues/132) +* **global-env:** add global environment variables injection for third-party profiles ([5d34326](https://github.com/kaitranntt/ccs/commit/5d343260c7307c2d7ac8da92eb5f94c7f764d08c)) +* **ui:** add absolute path copy for error logs ([5d4f49e](https://github.com/kaitranntt/ccs/commit/5d4f49e4bb6f9748efa89e96c342dfae3e35d02b)) +* **ui:** add Stop and Restart buttons to ProxyStatusWidget ([c9ad0b0](https://github.com/kaitranntt/ccs/commit/c9ad0b077934ae8418d4e97b9b02a09044ff898b)) +* **ui:** add version sync timestamp to ProxyStatusWidget ([d43079b](https://github.com/kaitranntt/ccs/commit/d43079b72414d7b841a35a934ea39a91527f4172)) +* **ui:** redesign error logs monitor with split view layout ([8f47b87](https://github.com/kaitranntt/ccs/commit/8f47b8775f2c2493c05ee2be861ca3f8667cfc0e)) +* **ui:** show CLIProxyAPI update availability in dashboard ([96762a9](https://github.com/kaitranntt/ccs/commit/96762a9f6ee096570b2fe6136a4431e6ce1d1a47)) + +### Bug Fixes + +* **ci:** remove deprecated installer references from dev-release workflow ([4b969b6](https://github.com/kaitranntt/ccs/commit/4b969b6870aae6b5859b9a1be0cf98b9d537ce00)) +* **cliproxy:** prevent misleading update message when proxy is running ([2adc272](https://github.com/kaitranntt/ccs/commit/2adc272f278b1d80d160ad4d6e1f35e3b61cb156)), closes [#143](https://github.com/kaitranntt/ccs/issues/143) +* **error-logs-monitor:** properly handle status loading state ([1ef625e](https://github.com/kaitranntt/ccs/commit/1ef625ee863c517a5fbba21f16cf991bb77be7d7)) + +### Styles + +* **ui:** widen cliproxy sidebar from w-64 to w-80 ([248d970](https://github.com/kaitranntt/ccs/commit/248d970cba8671b7c20dc99f8d1a70e4fe113605)) + +### Code Refactoring + +* remove deprecated native shell installers ([126cffc](https://github.com/kaitranntt/ccs/commit/126cffc6dcf434abeee883a4109d3705cdb92a67)) + # [6.5.0](https://github.com/kaitranntt/ccs/compare/v6.4.0...v6.5.0) (2025-12-18) diff --git a/package.json b/package.json index 4895b3ea..c0274462 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "6.5.0-dev.7", + "version": "6.6.0-dev.1", "description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6", "keywords": [ "cli", From eff2e2d29f3f227c05103c252823fb9e040b6e49 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Fri, 19 Dec 2025 01:11:29 -0500 Subject: [PATCH 25/40] feat(config): add proxy configuration types and schema - add ProxyRemoteConfig, ProxyFallbackConfig, ProxyLocalConfig interfaces - add ProxyConfig composite type with remote/fallback/local sections - add ResolvedProxyConfig interface for runtime config - bump UNIFIED_CONFIG_VERSION to 5 - add DEFAULT_PROXY_CONFIG constant --- src/cliproxy/types.ts | 25 ++++++++++ src/config/unified-config-types.ts | 80 +++++++++++++++++++++++++++++- 2 files changed, 103 insertions(+), 2 deletions(-) diff --git a/src/cliproxy/types.ts b/src/cliproxy/types.ts index 925842d0..9fc1c1a9 100644 --- a/src/cliproxy/types.ts +++ b/src/cliproxy/types.ts @@ -187,3 +187,28 @@ export interface ProviderConfig { /** Whether OAuth is required */ requiresOAuth: boolean; } + +/** + * Resolved proxy configuration after merging CLI > ENV > config.yaml > defaults. + * Used by executor to determine local vs remote proxy mode. + */ +export interface ResolvedProxyConfig { + /** Proxy mode: 'local' spawns CLIProxyAPI locally, 'remote' connects to external server */ + mode: 'local' | 'remote'; + /** Remote proxy hostname/IP (only for remote mode) */ + host?: string; + /** Proxy port (default: 8317) */ + port: number; + /** Protocol for remote connection (default: http) */ + protocol: 'http' | 'https'; + /** Auth token for remote proxy authentication */ + authToken?: string; + /** Enable fallback to local when remote unreachable (default: true) */ + fallbackEnabled: boolean; + /** Auto-start local proxy if not running (default: true) */ + autoStartLocal: boolean; + /** --remote-only flag: fail if remote unreachable, no fallback */ + remoteOnly: boolean; + /** --local-proxy flag: force local mode, ignore remote config */ + forceLocal: boolean; +} diff --git a/src/config/unified-config-types.ts b/src/config/unified-config-types.ts index 0379c9ea..b1bc4252 100644 --- a/src/config/unified-config-types.ts +++ b/src/config/unified-config-types.ts @@ -14,8 +14,9 @@ * Version 2 = YAML unified format * Version 3 = WebSearch config with model configuration for Gemini/OpenCode * Version 4 = Copilot API integration (GitHub Copilot proxy) + * Version 5 = Remote proxy configuration (connect to remote CLIProxyAPI) */ -export const UNIFIED_CONFIG_VERSION = 4; +export const UNIFIED_CONFIG_VERSION = 5; /** * Account configuration (formerly in profiles.json). @@ -185,6 +186,56 @@ export interface CopilotConfig { haiku_model?: string; } +/** + * Remote proxy configuration. + * Connect to a remote CLIProxyAPI instance instead of spawning local binary. + */ +export interface ProxyRemoteConfig { + /** Enable remote proxy mode (default: false = local mode) */ + enabled: boolean; + /** Remote proxy hostname or IP (empty = not configured) */ + host: string; + /** Remote proxy port (default: 8317) */ + port: number; + /** Protocol for remote connection */ + protocol: 'http' | 'https'; + /** Auth token for remote proxy (optional, sent as header) */ + auth_token: string; +} + +/** + * Fallback configuration when remote proxy is unreachable. + */ +export interface ProxyFallbackConfig { + /** Enable fallback to local proxy (default: true) */ + enabled: boolean; + /** Auto-start local proxy without prompting (default: false = prompt user) */ + auto_start: boolean; +} + +/** + * Local proxy configuration. + */ +export interface ProxyLocalConfig { + /** Local proxy port (default: 8317) */ + port: number; + /** Auto-start local binary (default: true) */ + auto_start: boolean; +} + +/** + * Proxy configuration section. + * Controls whether CCS uses local or remote CLIProxyAPI instance. + */ +export interface ProxyConfig { + /** Remote proxy settings */ + remote: ProxyRemoteConfig; + /** Fallback behavior when remote is unreachable */ + fallback: ProxyFallbackConfig; + /** Local proxy settings */ + local: ProxyLocalConfig; +} + /** * Global environment variables configuration. * These env vars are injected into ALL non-Claude subscription profiles. @@ -242,7 +293,7 @@ export interface WebSearchConfig { * Stored in ~/.ccs/config.yaml */ export interface UnifiedConfig { - /** Config version (4 for copilot support) */ + /** Config version (5 for remote proxy support) */ version: number; /** Default profile name to use when none specified */ default?: string; @@ -260,6 +311,8 @@ export interface UnifiedConfig { global_env?: GlobalEnvConfig; /** Copilot API configuration (GitHub Copilot proxy) */ copilot?: CopilotConfig; + /** Proxy configuration for remote/local CLIProxyAPI */ + proxy?: ProxyConfig; } /** @@ -289,6 +342,28 @@ export const DEFAULT_COPILOT_CONFIG: CopilotConfig = { model: 'gpt-4.1', // Free tier compatible }; +/** + * Default proxy configuration. + * Local mode by default - remote must be explicitly enabled. + */ +export const DEFAULT_PROXY_CONFIG: ProxyConfig = { + remote: { + enabled: false, + host: '', + port: 8317, + protocol: 'http', + auth_token: '', + }, + fallback: { + enabled: true, + auto_start: false, + }, + local: { + port: 8317, + auto_start: true, + }, +}; + /** * Create an empty unified config with defaults. */ @@ -336,6 +411,7 @@ export function createEmptyUnifiedConfig(): UnifiedConfig { env: { ...DEFAULT_GLOBAL_ENV }, }, copilot: { ...DEFAULT_COPILOT_CONFIG }, + proxy: { ...DEFAULT_PROXY_CONFIG }, }; } From 197174441f6eeca5e3c98e88af43d91ee081f734 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Fri, 19 Dec 2025 01:11:49 -0500 Subject: [PATCH 26/40] feat(config): add proxy section to unified config loader - merge proxy config with defaults in loadUnifiedConfig - preserve user overrides for remote/fallback/local sections --- src/config/unified-config-loader.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/config/unified-config-loader.ts b/src/config/unified-config-loader.ts index 80e0ad64..2450472a 100644 --- a/src/config/unified-config-loader.ts +++ b/src/config/unified-config-loader.ts @@ -16,6 +16,7 @@ import { UNIFIED_CONFIG_VERSION, DEFAULT_COPILOT_CONFIG, DEFAULT_GLOBAL_ENV, + DEFAULT_PROXY_CONFIG, GlobalEnvConfig, } from './unified-config-types'; import { isUnifiedConfigEnabled } from './feature-flags'; @@ -177,6 +178,24 @@ function mergeWithDefaults(partial: Partial): UnifiedConfig { enabled: partial.global_env?.enabled ?? true, env: partial.global_env?.env ?? { ...DEFAULT_GLOBAL_ENV }, }, + // Proxy config - remote/local CLIProxyAPI settings + proxy: { + remote: { + enabled: partial.proxy?.remote?.enabled ?? DEFAULT_PROXY_CONFIG.remote.enabled, + host: partial.proxy?.remote?.host ?? DEFAULT_PROXY_CONFIG.remote.host, + port: partial.proxy?.remote?.port ?? DEFAULT_PROXY_CONFIG.remote.port, + protocol: partial.proxy?.remote?.protocol ?? DEFAULT_PROXY_CONFIG.remote.protocol, + auth_token: partial.proxy?.remote?.auth_token ?? DEFAULT_PROXY_CONFIG.remote.auth_token, + }, + fallback: { + enabled: partial.proxy?.fallback?.enabled ?? DEFAULT_PROXY_CONFIG.fallback.enabled, + auto_start: partial.proxy?.fallback?.auto_start ?? DEFAULT_PROXY_CONFIG.fallback.auto_start, + }, + local: { + port: partial.proxy?.local?.port ?? DEFAULT_PROXY_CONFIG.local.port, + auto_start: partial.proxy?.local?.auto_start ?? DEFAULT_PROXY_CONFIG.local.auto_start, + }, + }, }; } From 68a93f0500f396ebcc65cc133c1a444ae5a0f220 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Fri, 19 Dec 2025 01:12:46 -0500 Subject: [PATCH 27/40] feat(cliproxy): add proxy config resolver with CLI flag support - parseProxyFlags() extracts --proxy-host, --proxy-port, --proxy-protocol - getProxyEnvVars() reads CCS_PROXY_* environment variables - resolveProxyConfig() merges CLI > ENV > config.yaml > defaults - hasProxyFlags() detects proxy-related CLI arguments - add 29 comprehensive unit tests --- src/cliproxy/proxy-config-resolver.ts | 279 ++++++++++++++++++ .../cliproxy/proxy-config-resolver.test.js | 274 +++++++++++++++++ 2 files changed, 553 insertions(+) create mode 100644 src/cliproxy/proxy-config-resolver.ts create mode 100644 tests/unit/cliproxy/proxy-config-resolver.test.js diff --git a/src/cliproxy/proxy-config-resolver.ts b/src/cliproxy/proxy-config-resolver.ts new file mode 100644 index 00000000..b0af403f --- /dev/null +++ b/src/cliproxy/proxy-config-resolver.ts @@ -0,0 +1,279 @@ +/** + * Proxy Config Resolver + * + * Resolves proxy configuration from multiple sources with priority: + * CLI flags > Environment variables > config.yaml > defaults + * + * Supports both local (spawn CLIProxyAPI) and remote (connect to external) modes. + */ + +import { ResolvedProxyConfig } from './types'; +import { CLIPROXY_DEFAULT_PORT } from './config-generator'; + +/** CLI flags for proxy configuration */ +export const PROXY_CLI_FLAGS = [ + '--proxy-host', + '--proxy-port', + '--proxy-protocol', + '--proxy-auth-token', + '--local-proxy', + '--remote-only', +] as const; + +/** Environment variable names for proxy configuration */ +export const PROXY_ENV_VARS = { + host: 'CCS_PROXY_HOST', + port: 'CCS_PROXY_PORT', + protocol: 'CCS_PROXY_PROTOCOL', + authToken: 'CCS_PROXY_AUTH_TOKEN', + fallbackEnabled: 'CCS_PROXY_FALLBACK_ENABLED', +} as const; + +/** Parsed CLI proxy flags */ +interface ParsedProxyFlags { + host?: string; + port?: number; + protocol?: 'http' | 'https'; + authToken?: string; + localProxy: boolean; + remoteOnly: boolean; +} + +/** Proxy config from environment variables */ +interface EnvProxyConfig { + host?: string; + port?: number; + protocol?: 'http' | 'https'; + authToken?: string; + fallbackEnabled?: boolean; +} + +/** + * Parse proxy-related CLI flags from argv. + * Returns parsed flags and remaining args (with proxy flags removed). + */ +export function parseProxyFlags(args: string[]): { + flags: ParsedProxyFlags; + remainingArgs: string[]; +} { + const flags: ParsedProxyFlags = { + localProxy: false, + remoteOnly: false, + }; + const remainingArgs: string[] = []; + + let i = 0; + while (i < args.length) { + const arg = args[i]; + + if (arg === '--proxy-host' && args[i + 1] && !args[i + 1].startsWith('-')) { + flags.host = args[i + 1]; + i += 2; + continue; + } + + if (arg === '--proxy-port' && args[i + 1] && !args[i + 1].startsWith('-')) { + const port = parseInt(args[i + 1], 10); + if (!isNaN(port) && port > 0 && port <= 65535) { + flags.port = port; + } + i += 2; + continue; + } + + if (arg === '--proxy-protocol' && args[i + 1] && !args[i + 1].startsWith('-')) { + const proto = args[i + 1].toLowerCase(); + if (proto === 'http' || proto === 'https') { + flags.protocol = proto; + } + i += 2; + continue; + } + + if (arg === '--proxy-auth-token' && args[i + 1] && !args[i + 1].startsWith('-')) { + flags.authToken = args[i + 1]; + i += 2; + continue; + } + + if (arg === '--local-proxy') { + flags.localProxy = true; + i += 1; + continue; + } + + if (arg === '--remote-only') { + flags.remoteOnly = true; + i += 1; + continue; + } + + // Not a proxy flag - keep in remaining args + remainingArgs.push(arg); + i += 1; + } + + return { flags, remainingArgs }; +} + +/** + * Get proxy configuration from environment variables. + */ +export function getProxyEnvVars(): EnvProxyConfig { + const config: EnvProxyConfig = {}; + + const host = process.env[PROXY_ENV_VARS.host]; + if (host) { + config.host = host; + } + + const port = process.env[PROXY_ENV_VARS.port]; + if (port) { + const portNum = parseInt(port, 10); + if (!isNaN(portNum) && portNum > 0 && portNum <= 65535) { + config.port = portNum; + } + } + + const protocol = process.env[PROXY_ENV_VARS.protocol]; + if (protocol) { + const proto = protocol.toLowerCase(); + if (proto === 'http' || proto === 'https') { + config.protocol = proto; + } + } + + const authToken = process.env[PROXY_ENV_VARS.authToken]; + if (authToken) { + config.authToken = authToken; + } + + const fallback = process.env[PROXY_ENV_VARS.fallbackEnabled]; + if (fallback !== undefined) { + // Accept: '1', 'true', 'yes' as enabled; '0', 'false', 'no' as disabled + const lower = fallback.toLowerCase(); + if (lower === '1' || lower === 'true' || lower === 'yes') { + config.fallbackEnabled = true; + } else if (lower === '0' || lower === 'false' || lower === 'no') { + config.fallbackEnabled = false; + } + } + + return config; +} + +/** + * Default proxy configuration values. + */ +const DEFAULT_PROXY_CONFIG: ResolvedProxyConfig = { + mode: 'local', + port: CLIPROXY_DEFAULT_PORT, + protocol: 'http', + fallbackEnabled: true, + autoStartLocal: true, + remoteOnly: false, + forceLocal: false, +}; + +/** + * Resolve proxy configuration with priority: CLI > ENV > config.yaml > defaults. + * + * @param cliArgs - Raw CLI arguments + * @param configYamlProxy - Proxy section from config.yaml (optional, Phase 1) + * @returns Resolved configuration and remaining args (without proxy flags) + */ +export function resolveProxyConfig( + cliArgs: string[], + + _configYamlProxy?: { + remote?: { + enabled?: boolean; + host?: string; + port?: number; + protocol?: 'http' | 'https'; + auth_token?: string; + fallback_enabled?: boolean; + }; + local?: { + port?: number; + auto_start?: boolean; + }; + } +): { config: ResolvedProxyConfig; remainingArgs: string[] } { + // 1. Parse CLI flags (highest priority) + const { flags: cliFlags, remainingArgs } = parseProxyFlags(cliArgs); + + // 2. Get environment variables + const envConfig = getProxyEnvVars(); + + // 3. config.yaml proxy section (passed as parameter - Phase 1 provides this) + // For now, we use empty object if not provided; Phase 1 integrates unified config loading + const yamlConfig = _configYamlProxy || {}; + + // 4. Build resolved config with priority merge + const resolved: ResolvedProxyConfig = { + ...DEFAULT_PROXY_CONFIG, + }; + + // Determine mode: remote if host is specified anywhere (unless --local-proxy) + const hasRemoteHost = + cliFlags.host || envConfig.host || yamlConfig.remote?.host || yamlConfig.remote?.enabled; + + // --local-proxy forces local mode regardless of remote config + if (cliFlags.localProxy) { + resolved.mode = 'local'; + resolved.forceLocal = true; + } else if (hasRemoteHost) { + resolved.mode = 'remote'; + } + + // Merge host: CLI > ENV > config.yaml + resolved.host = cliFlags.host ?? envConfig.host ?? yamlConfig.remote?.host; + + // Merge port: CLI > ENV > config.yaml (remote or local) > default + resolved.port = + cliFlags.port ?? + envConfig.port ?? + (resolved.mode === 'remote' ? yamlConfig.remote?.port : yamlConfig.local?.port) ?? + DEFAULT_PROXY_CONFIG.port; + + // Merge protocol: CLI > ENV > config.yaml > default + resolved.protocol = + cliFlags.protocol ?? envConfig.protocol ?? yamlConfig.remote?.protocol ?? 'http'; + + // Merge auth token: CLI > ENV > config.yaml + resolved.authToken = cliFlags.authToken ?? envConfig.authToken ?? yamlConfig.remote?.auth_token; + + // Merge fallback enabled: ENV > config.yaml > default + resolved.fallbackEnabled = + envConfig.fallbackEnabled ?? yamlConfig.remote?.fallback_enabled ?? true; + + // --remote-only from CLI + resolved.remoteOnly = cliFlags.remoteOnly; + + // If --remote-only, disable fallback + if (resolved.remoteOnly) { + resolved.fallbackEnabled = false; + } + + // Auto-start local from config.yaml > default + resolved.autoStartLocal = yamlConfig.local?.auto_start ?? true; + + return { config: resolved, remainingArgs }; +} + +/** + * Check if args contain any proxy flags. + * Used for quick filtering before full parse. + */ +export function hasProxyFlags(args: string[]): boolean { + return args.some( + (arg) => + arg === '--proxy-host' || + arg === '--proxy-port' || + arg === '--proxy-protocol' || + arg === '--proxy-auth-token' || + arg === '--local-proxy' || + arg === '--remote-only' + ); +} diff --git a/tests/unit/cliproxy/proxy-config-resolver.test.js b/tests/unit/cliproxy/proxy-config-resolver.test.js new file mode 100644 index 00000000..2a7d0851 --- /dev/null +++ b/tests/unit/cliproxy/proxy-config-resolver.test.js @@ -0,0 +1,274 @@ +/** + * Unit tests for proxy-config-resolver module + */ +const { describe, it, expect, beforeEach, afterEach } = require('bun:test'); + +// Import from compiled dist +const { + parseProxyFlags, + getProxyEnvVars, + resolveProxyConfig, + hasProxyFlags, + PROXY_CLI_FLAGS, + PROXY_ENV_VARS, +} = require('../../../dist/cliproxy/proxy-config-resolver'); + +describe('proxy-config-resolver', () => { + describe('PROXY_CLI_FLAGS', () => { + it('should define all expected proxy flags', () => { + expect(PROXY_CLI_FLAGS).toContain('--proxy-host'); + expect(PROXY_CLI_FLAGS).toContain('--proxy-port'); + expect(PROXY_CLI_FLAGS).toContain('--proxy-protocol'); + expect(PROXY_CLI_FLAGS).toContain('--proxy-auth-token'); + expect(PROXY_CLI_FLAGS).toContain('--local-proxy'); + expect(PROXY_CLI_FLAGS).toContain('--remote-only'); + }); + }); + + describe('PROXY_ENV_VARS', () => { + it('should define all expected environment variable names', () => { + expect(PROXY_ENV_VARS.host).toBe('CCS_PROXY_HOST'); + expect(PROXY_ENV_VARS.port).toBe('CCS_PROXY_PORT'); + expect(PROXY_ENV_VARS.protocol).toBe('CCS_PROXY_PROTOCOL'); + expect(PROXY_ENV_VARS.authToken).toBe('CCS_PROXY_AUTH_TOKEN'); + expect(PROXY_ENV_VARS.fallbackEnabled).toBe('CCS_PROXY_FALLBACK_ENABLED'); + }); + }); + + describe('parseProxyFlags', () => { + it('should parse --proxy-host flag', () => { + const { flags, remainingArgs } = parseProxyFlags(['--proxy-host', '192.168.1.100']); + expect(flags.host).toBe('192.168.1.100'); + expect(remainingArgs).toEqual([]); + }); + + it('should parse --proxy-port flag', () => { + const { flags, remainingArgs } = parseProxyFlags(['--proxy-port', '9000']); + expect(flags.port).toBe(9000); + expect(remainingArgs).toEqual([]); + }); + + it('should parse --proxy-protocol flag', () => { + const { flags } = parseProxyFlags(['--proxy-protocol', 'https']); + expect(flags.protocol).toBe('https'); + }); + + it('should parse --proxy-auth-token flag', () => { + const { flags } = parseProxyFlags(['--proxy-auth-token', 'secret123']); + expect(flags.authToken).toBe('secret123'); + }); + + it('should parse --local-proxy boolean flag', () => { + const { flags } = parseProxyFlags(['--local-proxy']); + expect(flags.localProxy).toBe(true); + }); + + it('should parse --remote-only boolean flag', () => { + const { flags } = parseProxyFlags(['--remote-only']); + expect(flags.remoteOnly).toBe(true); + }); + + it('should preserve non-proxy args in remainingArgs', () => { + const { flags, remainingArgs } = parseProxyFlags([ + '--verbose', + '--proxy-host', + 'localhost', + '--some-other-flag', + ]); + expect(flags.host).toBe('localhost'); + expect(remainingArgs).toEqual(['--verbose', '--some-other-flag']); + }); + + it('should handle mixed proxy and non-proxy args', () => { + const { flags, remainingArgs } = parseProxyFlags([ + 'arg1', + '--proxy-port', + '8080', + 'arg2', + '--local-proxy', + 'arg3', + ]); + expect(flags.port).toBe(8080); + expect(flags.localProxy).toBe(true); + expect(remainingArgs).toEqual(['arg1', 'arg2', 'arg3']); + }); + + it('should ignore invalid port values', () => { + const { flags } = parseProxyFlags(['--proxy-port', 'invalid']); + expect(flags.port).toBeUndefined(); + }); + + it('should ignore out-of-range port values', () => { + const { flags: flags1 } = parseProxyFlags(['--proxy-port', '0']); + expect(flags1.port).toBeUndefined(); + + const { flags: flags2 } = parseProxyFlags(['--proxy-port', '70000']); + expect(flags2.port).toBeUndefined(); + }); + + it('should normalize protocol to lowercase', () => { + const { flags } = parseProxyFlags(['--proxy-protocol', 'HTTPS']); + expect(flags.protocol).toBe('https'); + }); + + it('should ignore invalid protocol values', () => { + const { flags } = parseProxyFlags(['--proxy-protocol', 'ftp']); + expect(flags.protocol).toBeUndefined(); + }); + }); + + describe('getProxyEnvVars', () => { + const originalEnv = { ...process.env }; + + beforeEach(() => { + // Clear proxy env vars + delete process.env.CCS_PROXY_HOST; + delete process.env.CCS_PROXY_PORT; + delete process.env.CCS_PROXY_PROTOCOL; + delete process.env.CCS_PROXY_AUTH_TOKEN; + delete process.env.CCS_PROXY_FALLBACK_ENABLED; + }); + + afterEach(() => { + // Restore original env + Object.keys(process.env).forEach((key) => { + if (key.startsWith('CCS_PROXY_')) { + delete process.env[key]; + } + }); + Object.assign(process.env, originalEnv); + }); + + it('should return empty config when no env vars set', () => { + const config = getProxyEnvVars(); + expect(config.host).toBeUndefined(); + expect(config.port).toBeUndefined(); + expect(config.protocol).toBeUndefined(); + expect(config.authToken).toBeUndefined(); + expect(config.fallbackEnabled).toBeUndefined(); + }); + + it('should read CCS_PROXY_HOST', () => { + process.env.CCS_PROXY_HOST = 'remote.example.com'; + const config = getProxyEnvVars(); + expect(config.host).toBe('remote.example.com'); + }); + + it('should read and parse CCS_PROXY_PORT', () => { + process.env.CCS_PROXY_PORT = '9000'; + const config = getProxyEnvVars(); + expect(config.port).toBe(9000); + }); + + it('should read CCS_PROXY_PROTOCOL', () => { + process.env.CCS_PROXY_PROTOCOL = 'https'; + const config = getProxyEnvVars(); + expect(config.protocol).toBe('https'); + }); + + it('should read CCS_PROXY_AUTH_TOKEN', () => { + process.env.CCS_PROXY_AUTH_TOKEN = 'my-secret-token'; + const config = getProxyEnvVars(); + expect(config.authToken).toBe('my-secret-token'); + }); + + it('should parse CCS_PROXY_FALLBACK_ENABLED as true', () => { + process.env.CCS_PROXY_FALLBACK_ENABLED = '1'; + expect(getProxyEnvVars().fallbackEnabled).toBe(true); + + process.env.CCS_PROXY_FALLBACK_ENABLED = 'true'; + expect(getProxyEnvVars().fallbackEnabled).toBe(true); + + process.env.CCS_PROXY_FALLBACK_ENABLED = 'yes'; + expect(getProxyEnvVars().fallbackEnabled).toBe(true); + }); + + it('should parse CCS_PROXY_FALLBACK_ENABLED as false', () => { + process.env.CCS_PROXY_FALLBACK_ENABLED = '0'; + expect(getProxyEnvVars().fallbackEnabled).toBe(false); + + process.env.CCS_PROXY_FALLBACK_ENABLED = 'false'; + expect(getProxyEnvVars().fallbackEnabled).toBe(false); + + process.env.CCS_PROXY_FALLBACK_ENABLED = 'no'; + expect(getProxyEnvVars().fallbackEnabled).toBe(false); + }); + }); + + describe('resolveProxyConfig', () => { + const originalEnv = { ...process.env }; + + beforeEach(() => { + delete process.env.CCS_PROXY_HOST; + delete process.env.CCS_PROXY_PORT; + delete process.env.CCS_PROXY_PROTOCOL; + delete process.env.CCS_PROXY_AUTH_TOKEN; + delete process.env.CCS_PROXY_FALLBACK_ENABLED; + }); + + afterEach(() => { + Object.keys(process.env).forEach((key) => { + if (key.startsWith('CCS_PROXY_')) { + delete process.env[key]; + } + }); + Object.assign(process.env, originalEnv); + }); + + it('should return local mode by default', () => { + const { config } = resolveProxyConfig([]); + expect(config.mode).toBe('local'); + expect(config.port).toBe(8317); // Default CLIProxy port + expect(config.fallbackEnabled).toBe(true); + }); + + it('should enable remote mode when --proxy-host is provided', () => { + const { config } = resolveProxyConfig(['--proxy-host', '192.168.1.100']); + expect(config.mode).toBe('remote'); + expect(config.host).toBe('192.168.1.100'); + }); + + it('should enable remote mode when CCS_PROXY_HOST env is set', () => { + process.env.CCS_PROXY_HOST = 'remote.example.com'; + const { config } = resolveProxyConfig([]); + expect(config.mode).toBe('remote'); + expect(config.host).toBe('remote.example.com'); + }); + + it('should prioritize CLI flags over ENV vars', () => { + process.env.CCS_PROXY_HOST = 'env-host'; + process.env.CCS_PROXY_PORT = '9000'; + const { config } = resolveProxyConfig(['--proxy-host', 'cli-host', '--proxy-port', '8080']); + expect(config.host).toBe('cli-host'); + expect(config.port).toBe(8080); + }); + + it('should force local mode with --local-proxy', () => { + process.env.CCS_PROXY_HOST = 'remote.example.com'; + const { config } = resolveProxyConfig(['--local-proxy']); + expect(config.mode).toBe('local'); + expect(config.forceLocal).toBe(true); + }); + + it('should set remoteOnly and disable fallback with --remote-only', () => { + const { config } = resolveProxyConfig(['--proxy-host', 'remote', '--remote-only']); + expect(config.remoteOnly).toBe(true); + expect(config.fallbackEnabled).toBe(false); + }); + }); + + describe('hasProxyFlags', () => { + it('should return true when proxy flags are present', () => { + expect(hasProxyFlags(['--proxy-host', 'localhost'])).toBe(true); + expect(hasProxyFlags(['--proxy-port', '8080'])).toBe(true); + expect(hasProxyFlags(['--local-proxy'])).toBe(true); + expect(hasProxyFlags(['--remote-only'])).toBe(true); + }); + + it('should return false when no proxy flags are present', () => { + expect(hasProxyFlags([])).toBe(false); + expect(hasProxyFlags(['--verbose', '--help'])).toBe(false); + expect(hasProxyFlags(['gemini', 'some-task'])).toBe(false); + }); + }); +}); From 30d564cda66a54c2ac12788559624cb0736cdeb3 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Fri, 19 Dec 2025 01:13:54 -0500 Subject: [PATCH 28/40] feat(cliproxy): add remote proxy client for health checks - checkRemoteProxy() tests remote CLIProxyAPI /health endpoint - testConnection() validates remote proxy connectivity - typed error codes: CONNECTION_REFUSED, TIMEOUT, AUTH_FAILED, UNKNOWN - support for self-signed certificates with allowSelfSigned flag - add type-level tests for interfaces --- src/cliproxy/remote-proxy-client.ts | 236 ++++++++++++++++++ .../unit/cliproxy/remote-proxy-client.test.ts | 128 ++++++++++ 2 files changed, 364 insertions(+) create mode 100644 src/cliproxy/remote-proxy-client.ts create mode 100644 tests/unit/cliproxy/remote-proxy-client.test.ts diff --git a/src/cliproxy/remote-proxy-client.ts b/src/cliproxy/remote-proxy-client.ts new file mode 100644 index 00000000..c4c8a321 --- /dev/null +++ b/src/cliproxy/remote-proxy-client.ts @@ -0,0 +1,236 @@ +/** + * Remote Proxy Client for CLIProxyAPI + * + * HTTP client for health checks and connection testing against remote CLIProxyAPI instances. + * Uses native fetch API with aggressive timeout for CLI responsiveness. + */ + +import * as https from 'https'; + +/** Error codes for remote proxy status */ +export type RemoteProxyErrorCode = 'CONNECTION_REFUSED' | 'TIMEOUT' | 'AUTH_FAILED' | 'UNKNOWN'; + +/** Status returned from remote proxy health check */ +export interface RemoteProxyStatus { + /** Whether the remote proxy is reachable */ + reachable: boolean; + /** Latency in milliseconds (only set if reachable) */ + latencyMs?: number; + /** Error message (only set if not reachable) */ + error?: string; + /** Error code for programmatic handling */ + errorCode?: RemoteProxyErrorCode; +} + +/** Configuration for remote proxy client */ +export interface RemoteProxyClientConfig { + /** Remote proxy host (IP or hostname) */ + host: string; + /** Remote proxy port */ + port: number; + /** Protocol to use (http or https) */ + protocol: 'http' | 'https'; + /** Optional auth token for Authorization header */ + authToken?: string; + /** Request timeout in ms (default: 2000) */ + timeout?: number; + /** Allow self-signed certificates (default: false) */ + allowSelfSigned?: boolean; +} + +/** Default timeout for remote proxy requests (aggressive for CLI UX) */ +const DEFAULT_TIMEOUT_MS = 2000; + +/** + * Map error to RemoteProxyErrorCode + */ +function mapErrorToCode(error: Error, statusCode?: number): RemoteProxyErrorCode { + const message = error.message.toLowerCase(); + const code = (error as NodeJS.ErrnoException).code?.toLowerCase(); + + // Connection refused + if (code === 'econnrefused' || message.includes('connection refused')) { + return 'CONNECTION_REFUSED'; + } + + // Timeout + if ( + code === 'etimedout' || + code === 'timeout' || + message.includes('timeout') || + message.includes('aborted') + ) { + return 'TIMEOUT'; + } + + // Auth failed (401/403) + if (statusCode === 401 || statusCode === 403) { + return 'AUTH_FAILED'; + } + + return 'UNKNOWN'; +} + +/** + * Get human-readable error message from error code + */ +function getErrorMessage(errorCode: RemoteProxyErrorCode, rawError?: string): string { + switch (errorCode) { + case 'CONNECTION_REFUSED': + return 'Connection refused - is the proxy running?'; + case 'TIMEOUT': + return 'Connection timed out'; + case 'AUTH_FAILED': + return 'Authentication failed - check auth token'; + default: + return rawError || 'Unknown error'; + } +} + +/** + * Create a custom HTTPS agent for self-signed certificate support + */ +function createHttpsAgent(allowSelfSigned: boolean): https.Agent | undefined { + if (!allowSelfSigned) return undefined; + + return new https.Agent({ + rejectUnauthorized: false, + }); +} + +/** + * Check health of remote CLIProxyAPI instance + * + * @param config Remote proxy client configuration + * @returns RemoteProxyStatus with reachability and latency + */ +export async function checkRemoteProxy( + config: RemoteProxyClientConfig +): Promise { + const { host, port, protocol, authToken, allowSelfSigned = false } = config; + const timeout = config.timeout ?? DEFAULT_TIMEOUT_MS; + + const url = `${protocol}://${host}:${port}/health`; + const startTime = Date.now(); + + try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeout); + + // Build request options + const headers: Record = { + Accept: 'application/json', + }; + + if (authToken) { + headers['Authorization'] = `Bearer ${authToken}`; + } + + // For HTTPS with self-signed certs, we need to use native https module + // Bun's fetch doesn't support custom agents + let response: Response; + + if (protocol === 'https' && allowSelfSigned) { + // Warn about security implications + console.error('[!] Allowing self-signed certificate - not recommended for production'); + + // Use native https module for self-signed cert support + response = await new Promise((resolve, reject) => { + const agent = createHttpsAgent(true); + const reqTimeout = setTimeout(() => { + reject(new Error('Request timeout')); + }, timeout); + + const req = https.request( + url, + { + method: 'GET', + headers, + agent, + timeout, + }, + (res) => { + clearTimeout(reqTimeout); + let data = ''; + res.on('data', (chunk) => (data += chunk)); + res.on('end', () => { + resolve( + new Response(data, { + status: res.statusCode || 500, + statusText: res.statusMessage, + }) + ); + }); + } + ); + + req.on('error', (err) => { + clearTimeout(reqTimeout); + reject(err); + }); + + req.on('timeout', () => { + req.destroy(); + reject(new Error('Request timeout')); + }); + + req.end(); + }); + } else { + // Standard fetch for HTTP or HTTPS without self-signed + response = await fetch(url, { + signal: controller.signal, + headers, + }); + } + + clearTimeout(timeoutId); + + const latencyMs = Date.now() - startTime; + + // Check for auth failure + if (response.status === 401 || response.status === 403) { + return { + reachable: false, + error: getErrorMessage('AUTH_FAILED'), + errorCode: 'AUTH_FAILED', + }; + } + + // 200 OK = healthy + if (response.ok) { + return { + reachable: true, + latencyMs, + }; + } + + // Non-200 but connected + return { + reachable: false, + error: `Unexpected status: ${response.status}`, + errorCode: 'UNKNOWN', + }; + } catch (error) { + const err = error as Error; + const errorCode = mapErrorToCode(err); + + return { + reachable: false, + error: getErrorMessage(errorCode, err.message), + errorCode, + }; + } +} + +/** + * Test connection to remote CLIProxyAPI (alias for dashboard use) + * + * This is an alias for checkRemoteProxy() for semantic clarity in UI contexts. + * + * @param config Remote proxy client configuration + * @returns RemoteProxyStatus with reachability and latency + */ +export async function testConnection(config: RemoteProxyClientConfig): Promise { + return checkRemoteProxy(config); +} diff --git a/tests/unit/cliproxy/remote-proxy-client.test.ts b/tests/unit/cliproxy/remote-proxy-client.test.ts new file mode 100644 index 00000000..61022c11 --- /dev/null +++ b/tests/unit/cliproxy/remote-proxy-client.test.ts @@ -0,0 +1,128 @@ +/** + * Unit tests for remote-proxy-client module + */ +import { describe, it, expect } from 'bun:test'; +import type { RemoteProxyClientConfig, RemoteProxyStatus } from '../../../src/cliproxy/remote-proxy-client'; + +// We test the module's type exports and error handling logic +// Actual HTTP calls are not mocked in this unit test - use integration tests for that + +describe('remote-proxy-client', () => { + describe('type exports', () => { + it('should export RemoteProxyClientConfig interface', () => { + // Type-level test - ensure the interface shape is correct + const config: RemoteProxyClientConfig = { + host: 'localhost', + port: 8317, + protocol: 'http', + authToken: 'test-token', + timeout: 2000, + allowSelfSigned: false, + }; + expect(config.host).toBe('localhost'); + expect(config.port).toBe(8317); + expect(config.protocol).toBe('http'); + }); + + it('should export RemoteProxyStatus interface', () => { + // Success case + const successStatus: RemoteProxyStatus = { + reachable: true, + latencyMs: 50, + }; + expect(successStatus.reachable).toBe(true); + expect(successStatus.latencyMs).toBe(50); + + // Error case + const errorStatus: RemoteProxyStatus = { + reachable: false, + error: 'Connection refused', + errorCode: 'CONNECTION_REFUSED', + }; + expect(errorStatus.reachable).toBe(false); + expect(errorStatus.error).toBe('Connection refused'); + expect(errorStatus.errorCode).toBe('CONNECTION_REFUSED'); + }); + }); + + describe('RemoteProxyErrorCode', () => { + it('should define expected error codes', () => { + const validCodes = ['CONNECTION_REFUSED', 'TIMEOUT', 'AUTH_FAILED', 'UNKNOWN']; + + // Type-level test - ensure error codes can be used + const status1: RemoteProxyStatus = { reachable: false, errorCode: 'CONNECTION_REFUSED' }; + const status2: RemoteProxyStatus = { reachable: false, errorCode: 'TIMEOUT' }; + const status3: RemoteProxyStatus = { reachable: false, errorCode: 'AUTH_FAILED' }; + const status4: RemoteProxyStatus = { reachable: false, errorCode: 'UNKNOWN' }; + + expect(validCodes).toContain(status1.errorCode); + expect(validCodes).toContain(status2.errorCode); + expect(validCodes).toContain(status3.errorCode); + expect(validCodes).toContain(status4.errorCode); + }); + }); + + describe('config validation', () => { + it('should require host and port', () => { + const minimalConfig: RemoteProxyClientConfig = { + host: '127.0.0.1', + port: 8317, + protocol: 'http', + }; + expect(minimalConfig.host).toBeDefined(); + expect(minimalConfig.port).toBeDefined(); + expect(minimalConfig.protocol).toBeDefined(); + }); + + it('should allow optional fields', () => { + const config: RemoteProxyClientConfig = { + host: '127.0.0.1', + port: 8317, + protocol: 'https', + authToken: 'secret', + timeout: 5000, + allowSelfSigned: true, + }; + expect(config.authToken).toBe('secret'); + expect(config.timeout).toBe(5000); + expect(config.allowSelfSigned).toBe(true); + }); + + it('should accept http and https protocols', () => { + const httpConfig: RemoteProxyClientConfig = { + host: 'localhost', + port: 8317, + protocol: 'http', + }; + const httpsConfig: RemoteProxyClientConfig = { + host: 'localhost', + port: 8317, + protocol: 'https', + }; + expect(httpConfig.protocol).toBe('http'); + expect(httpsConfig.protocol).toBe('https'); + }); + }); + + describe('health check URL construction', () => { + it('should construct correct health check URL pattern', () => { + const config: RemoteProxyClientConfig = { + host: '192.168.1.100', + port: 8317, + protocol: 'http', + }; + const expectedUrl = `${config.protocol}://${config.host}:${config.port}/health`; + expect(expectedUrl).toBe('http://192.168.1.100:8317/health'); + }); + + it('should construct HTTPS URL when protocol is https', () => { + const config: RemoteProxyClientConfig = { + host: 'secure.example.com', + port: 443, + protocol: 'https', + }; + const expectedUrl = `${config.protocol}://${config.host}:${config.port}/health`; + expect(expectedUrl).toBe('https://secure.example.com:443/health'); + }); + }); +}); From 18729c9983ecd1f9d857b0de2753e99c675c624a Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Fri, 19 Dec 2025 01:14:24 -0500 Subject: [PATCH 29/40] fix(ci): remove sync-version.js that depends on deleted VERSION file --- package.json | 2 -- scripts/sync-version.js | 15 --------------- 2 files changed, 17 deletions(-) delete mode 100755 scripts/sync-version.js diff --git a/package.json b/package.json index c0274462..8cc1bdbd 100644 --- a/package.json +++ b/package.json @@ -75,8 +75,6 @@ "ui:build": "cd ui && bun run build", "ui:preview": "cd ui && bun run preview", "ui:validate": "cd ui && bun run validate", - "prepublishOnly": "node scripts/sync-version.js", - "prepack": "node scripts/sync-version.js", "prepare": "husky", "postinstall": "node scripts/postinstall.js" }, diff --git a/scripts/sync-version.js b/scripts/sync-version.js deleted file mode 100755 index e0d4a42a..00000000 --- a/scripts/sync-version.js +++ /dev/null @@ -1,15 +0,0 @@ -#!/usr/bin/env node -const fs = require('fs'); -const path = require('path'); - -// Read VERSION file -const versionFile = path.join(__dirname, '..', 'VERSION'); -const version = fs.readFileSync(versionFile, 'utf8').trim(); - -// Update package.json -const pkgPath = path.join(__dirname, '..', 'package.json'); -const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')); -pkg.version = version; -fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n'); - -console.log(`✓ Synced version ${version} to package.json`); \ No newline at end of file From f4a50d006c1f6bd284fe743f9a322540763e1848 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Fri, 19 Dec 2025 01:15:10 -0500 Subject: [PATCH 30/40] feat(cliproxy): add getRemoteEnvVars for remote proxy mode - generate environment variables for remote CLIProxyAPI - construct ANTHROPIC_BASE_URL from host/port/protocol - include auth token in Authorization header when provided --- src/cliproxy/config-generator.ts | 46 ++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/src/cliproxy/config-generator.ts b/src/cliproxy/config-generator.ts index df134a36..9cf354d6 100644 --- a/src/cliproxy/config-generator.ts +++ b/src/cliproxy/config-generator.ts @@ -479,3 +479,49 @@ export function ensureProviderSettings(provider: CLIProxyProvider): void { mode: 0o600, }); } + +/** + * Get environment variables for remote proxy mode. + * Uses the remote proxy's provider endpoint as the base URL. + * + * @param provider CLIProxy provider (gemini, codex, agy, qwen, iflow) + * @param remoteConfig Remote proxy connection details + * @returns Environment variables for Claude CLI + */ +export function getRemoteEnvVars( + provider: CLIProxyProvider, + remoteConfig: { host: string; port: number; protocol: 'http' | 'https'; authToken?: string } +): Record { + const baseUrl = `${remoteConfig.protocol}://${remoteConfig.host}:${remoteConfig.port}/api/provider/${provider}`; + const models = getModelMapping(provider); + + // Get global env vars (DISABLE_TELEMETRY, etc.) + const globalEnv = getGlobalEnvVars(); + + // Get additional env vars from base config (ANTHROPIC_MAX_TOKENS, etc.) + const baseEnvVars = getEnvVarsFromConfig(provider); + + // Filter out core env vars from base config to avoid conflicts + const { + ANTHROPIC_BASE_URL: _baseUrl, + ANTHROPIC_AUTH_TOKEN: _authToken, + ANTHROPIC_MODEL: _model, + ANTHROPIC_DEFAULT_OPUS_MODEL: _opusModel, + ANTHROPIC_DEFAULT_SONNET_MODEL: _sonnetModel, + ANTHROPIC_DEFAULT_HAIKU_MODEL: _haikuModel, + ...additionalEnvVars + } = baseEnvVars; + + const env: Record = { + ...globalEnv, + ...additionalEnvVars, + ANTHROPIC_BASE_URL: baseUrl, + ANTHROPIC_AUTH_TOKEN: remoteConfig.authToken || CCS_INTERNAL_API_KEY, + ANTHROPIC_MODEL: models.claudeModel, + ANTHROPIC_DEFAULT_OPUS_MODEL: models.opusModel || models.claudeModel, + ANTHROPIC_DEFAULT_SONNET_MODEL: models.sonnetModel || models.claudeModel, + ANTHROPIC_DEFAULT_HAIKU_MODEL: models.haikuModel || models.claudeModel, + }; + + return env; +} From bd1ff2f059d01d4371b2230d4902bc5ab210055e Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Fri, 19 Dec 2025 01:15:33 -0500 Subject: [PATCH 31/40] feat(cliproxy): integrate remote proxy mode in executor - resolve proxy config with CLI flags, ENV vars, and config.yaml - check remote proxy health before skipping local binary spawn - implement TTY-aware fallback prompting when remote unreachable - support --remote-only flag to disable fallback - use remote or local env vars based on mode --- src/cliproxy/cliproxy-executor.ts | 383 +++++++++++++++++++----------- 1 file changed, 244 insertions(+), 139 deletions(-) diff --git a/src/cliproxy/cliproxy-executor.ts b/src/cliproxy/cliproxy-executor.ts index 1bad1d16..9cd4277f 100644 --- a/src/cliproxy/cliproxy-executor.ts +++ b/src/cliproxy/cliproxy-executor.ts @@ -21,14 +21,17 @@ import { ensureCLIProxyBinary } from './binary-manager'; import { generateConfig, getEffectiveEnvVars, + getRemoteEnvVars, getProviderConfig, ensureProviderSettings, CLIPROXY_DEFAULT_PORT, getCliproxyWritablePath, } from './config-generator'; +import { checkRemoteProxy } from './remote-proxy-client'; import { isAuthenticated } from './auth-handler'; import { CLIProxyProvider, ExecutorConfig } from './types'; import { configureProviderModel, getCurrentModel } from './model-config'; +import { resolveProxyConfig, PROXY_CLI_FLAGS } from './proxy-config-resolver'; import { getWebSearchHookEnv } from '../utils/websearch-manager'; import { supportsModelConfig, isModelBroken, getModelIssueUrl, findModel } from './model-catalog'; import { @@ -126,6 +129,22 @@ export async function execClaudeWithCLIProxy( } }; + // 0. Resolve proxy configuration (CLI > ENV > config.yaml > defaults) + // This filters proxy flags from args and returns resolved config + const { config: proxyConfig, remainingArgs: argsWithoutProxy } = resolveProxyConfig(args); + + // Use resolved port from proxy config (overrides ExecutorConfig) + if (proxyConfig.port !== CLIPROXY_DEFAULT_PORT) { + cfg.port = proxyConfig.port; + } + + log(`Proxy mode: ${proxyConfig.mode}`); + if (proxyConfig.mode === 'remote') { + log(`Remote host: ${proxyConfig.host}:${proxyConfig.port} (${proxyConfig.protocol})`); + } + + // Note: proxyConfig is available for Phase 4 (remote mode integration) + // Ensure MCP web-search is configured for third-party profiles // WebSearch is a server-side tool executed by Anthropic's API // Third-party providers don't have access, so we use MCP fallback @@ -142,39 +161,102 @@ export async function execClaudeWithCLIProxy( const providerConfig = getProviderConfig(provider); log(`Provider: ${providerConfig.displayName}`); - // 1. Ensure binary exists (downloads if needed) - const spinner = new ProgressIndicator('Preparing CLIProxy'); - spinner.start(); + // Check remote proxy if configured (before binary download) + let useRemoteProxy = false; + if (proxyConfig.mode === 'remote' && proxyConfig.host) { + const status = await checkRemoteProxy({ + host: proxyConfig.host, + port: proxyConfig.port, + protocol: proxyConfig.protocol, + authToken: proxyConfig.authToken, + timeout: 2000, + allowSelfSigned: proxyConfig.protocol === 'https', + }); - let binaryPath: string; - try { - binaryPath = await ensureCLIProxyBinary(verbose); - spinner.succeed('CLIProxy binary ready'); - } catch (error) { - spinner.fail('Failed to prepare CLIProxy'); - throw error; + if (status.reachable) { + useRemoteProxy = true; + console.log( + ok( + `Connected to remote proxy at ${proxyConfig.host}:${proxyConfig.port} (${status.latencyMs}ms)` + ) + ); + } else { + console.error(warn(`Remote proxy unreachable: ${status.error}`)); + + if (proxyConfig.remoteOnly) { + throw new Error('Remote proxy unreachable and --remote-only specified'); + } + + if (proxyConfig.fallbackEnabled) { + if (proxyConfig.autoStartLocal) { + console.log(info('Falling back to local proxy...')); + } else { + // Prompt user for fallback (only in TTY) + if (process.stdin.isTTY) { + const readline = await import('readline'); + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + const answer = await new Promise((resolve) => { + rl.question('Start local proxy instead? [Y/n] ', resolve); + }); + rl.close(); + if (answer.toLowerCase() === 'n') { + throw new Error('Remote proxy unreachable and user declined fallback'); + } + } + console.log(info('Starting local proxy...')); + } + } else { + throw new Error('Remote proxy unreachable and fallback disabled'); + } + } } - // 2. Handle special flags - const forceAuth = args.includes('--auth'); - const forceHeadless = args.includes('--headless'); - const forceLogout = args.includes('--logout'); - const forceConfig = args.includes('--config'); - const addAccount = args.includes('--add'); - const showAccounts = args.includes('--accounts'); + // Variables for local proxy mode + let binaryPath: string | undefined; + let sessionId: string | undefined; + + // 1. Ensure binary exists (downloads if needed) - SKIP for remote mode + if (!useRemoteProxy) { + const spinner = new ProgressIndicator('Preparing CLIProxy'); + spinner.start(); + + try { + binaryPath = await ensureCLIProxyBinary(verbose); + spinner.succeed('CLIProxy binary ready'); + } catch (error) { + spinner.fail('Failed to prepare CLIProxy'); + throw error; + } + } + + // 2. Handle special flags (use argsWithoutProxy - proxy flags already stripped) + const forceAuth = argsWithoutProxy.includes('--auth'); + const forceHeadless = argsWithoutProxy.includes('--headless'); + const forceLogout = argsWithoutProxy.includes('--logout'); + const forceConfig = argsWithoutProxy.includes('--config'); + const addAccount = argsWithoutProxy.includes('--add'); + const showAccounts = argsWithoutProxy.includes('--accounts'); // Parse --use flag let useAccount: string | undefined; - const useIdx = args.indexOf('--use'); - if (useIdx !== -1 && args[useIdx + 1] && !args[useIdx + 1].startsWith('-')) { - useAccount = args[useIdx + 1]; + const useIdx = argsWithoutProxy.indexOf('--use'); + if ( + useIdx !== -1 && + argsWithoutProxy[useIdx + 1] && + !argsWithoutProxy[useIdx + 1].startsWith('-') + ) { + useAccount = argsWithoutProxy[useIdx + 1]; } // Parse --nickname flag let setNickname: string | undefined; - const nicknameIdx = args.indexOf('--nickname'); - if (nicknameIdx !== -1 && args[nicknameIdx + 1] && !args[nicknameIdx + 1].startsWith('-')) { - setNickname = args[nicknameIdx + 1]; + const nicknameIdx = argsWithoutProxy.indexOf('--nickname'); + if ( + nicknameIdx !== -1 && + argsWithoutProxy[nicknameIdx + 1] && + !argsWithoutProxy[nicknameIdx + 1].startsWith('-') + ) { + setNickname = argsWithoutProxy[nicknameIdx + 1]; } // Handle --accounts: list accounts and exit @@ -306,122 +388,135 @@ export async function execClaudeWithCLIProxy( // 6. Ensure user settings file exists (creates from defaults if not) ensureProviderSettings(provider); - // 6. Generate config file - log(`Generating config for ${provider}`); - const configPath = generateConfig(provider, cfg.port); - log(`Config written: ${configPath}`); - - // 6a. Pre-flight check: handle existing proxy or port conflicts - // Clean up orphaned sessions first (from crashed proxies) - cleanupOrphanedSessions(cfg.port); - - // Check if there's an existing healthy proxy we can reuse - const existingProxy = getExistingProxy(cfg.port); + // Local proxy mode: generate config, spawn proxy, track session let proxy: ChildProcess | null = null; - let sessionId: string; - if (existingProxy) { - // Reuse existing proxy - another CCS session started it - log(`Reusing existing CLIProxy on port ${cfg.port} (PID ${existingProxy.pid})`); - sessionId = registerSession(cfg.port, existingProxy.pid); - console.log( - info(`Joined existing CLIProxy (${existingProxy.sessions.length + 1} sessions active)`) - ); - } else { - // No existing proxy - check if port is free - const portProcess = await getPortProcess(cfg.port); - if (portProcess) { - if (isCLIProxyProcess(portProcess)) { - // CLIProxy on port but no session lock - likely orphaned/zombie - // Only kill if no active sessions registered - if (!hasActiveSessions()) { - log(`Found zombie CLIProxy on port ${cfg.port} (PID ${portProcess.pid}), killing...`); - const killed = killProcessOnPort(cfg.port, verbose); - if (killed) { - console.log(info(`Cleaned up zombie CLIProxy process`)); - // Wait a bit for port to be released - await new Promise((r) => setTimeout(r, 500)); + if (!useRemoteProxy) { + // 6. Generate config file + log(`Generating config for ${provider}`); + const configPath = generateConfig(provider, cfg.port); + log(`Config written: ${configPath}`); + + // 6a. Pre-flight check: handle existing proxy or port conflicts + // Clean up orphaned sessions first (from crashed proxies) + cleanupOrphanedSessions(cfg.port); + + // Check if there's an existing healthy proxy we can reuse + const existingProxy = getExistingProxy(cfg.port); + + if (existingProxy) { + // Reuse existing proxy - another CCS session started it + log(`Reusing existing CLIProxy on port ${cfg.port} (PID ${existingProxy.pid})`); + sessionId = registerSession(cfg.port, existingProxy.pid); + console.log( + info(`Joined existing CLIProxy (${existingProxy.sessions.length + 1} sessions active)`) + ); + } else { + // No existing proxy - check if port is free + const portProcess = await getPortProcess(cfg.port); + if (portProcess) { + if (isCLIProxyProcess(portProcess)) { + // CLIProxy on port but no session lock - likely orphaned/zombie + // Only kill if no active sessions registered + if (!hasActiveSessions()) { + log(`Found zombie CLIProxy on port ${cfg.port} (PID ${portProcess.pid}), killing...`); + const killed = killProcessOnPort(cfg.port, verbose); + if (killed) { + console.log(info(`Cleaned up zombie CLIProxy process`)); + // Wait a bit for port to be released + await new Promise((r) => setTimeout(r, 500)); + } + } else { + // Active sessions exist but getExistingProxy returned null - something's wrong + // Try to connect anyway + log(`CLIProxy on port ${cfg.port} has active sessions, attempting to join...`); } } else { - // Active sessions exist but getExistingProxy returned null - something's wrong - // Try to connect anyway - log(`CLIProxy on port ${cfg.port} has active sessions, attempting to join...`); + // Non-CLIProxy process blocking the port - warn user + console.error(''); + console.error( + warn( + `Port ${cfg.port} is blocked by ${portProcess.processName} (PID ${portProcess.pid})` + ) + ); + console.error(''); + console.error('To fix this, close the blocking application or run:'); + console.error(` ${getPortCheckCommand(cfg.port)}`); + console.error(''); + throw new Error(`Port ${cfg.port} is in use by another application`); } - } else { - // Non-CLIProxy process blocking the port - warn user - console.error(''); - console.error( - warn(`Port ${cfg.port} is blocked by ${portProcess.processName} (PID ${portProcess.pid})`) - ); - console.error(''); - console.error('To fix this, close the blocking application or run:'); - console.error(` ${getPortCheckCommand(cfg.port)}`); - console.error(''); - throw new Error(`Port ${cfg.port} is in use by another application`); } + + // 6b. Spawn CLIProxyAPI binary (only if not reusing existing proxy) + // Use detached mode so proxy persists after terminal closes + const configPath = generateConfig(provider, cfg.port); + const proxyArgs = ['--config', configPath]; + + log(`Spawning: ${binaryPath} ${proxyArgs.join(' ')}`); + + proxy = spawn(binaryPath as string, proxyArgs, { + stdio: ['ignore', 'ignore', 'ignore'], + detached: true, // Persist after parent terminal closes + env: { + ...process.env, + WRITABLE_PATH: getCliproxyWritablePath(), // Logs stored in ~/.ccs/cliproxy/logs/ + }, + }); + + // Unref so parent process can exit independently + proxy.unref(); + + // Handle proxy errors (only fires if spawn itself fails) + proxy.on('error', (error) => { + console.error(fail(`CLIProxy spawn error: ${error.message}`)); + }); + + // 7. Wait for proxy readiness via TCP polling + const readySpinner = new ProgressIndicator(`Waiting for CLIProxy on port ${cfg.port}`); + readySpinner.start(); + + try { + await waitForProxyReady(cfg.port, cfg.timeout, cfg.pollInterval); + readySpinner.succeed(`CLIProxy ready on port ${cfg.port}`); + } catch (error) { + readySpinner.fail('CLIProxy startup failed'); + proxy.kill('SIGTERM'); + + const err = error as Error; + console.error(''); + console.error(fail('CLIProxy failed to start')); + console.error(''); + console.error('Possible causes:'); + console.error(` 1. Port ${cfg.port} already in use`); + console.error(' 2. Binary crashed on startup'); + console.error(' 3. Invalid configuration'); + console.error(''); + console.error('Troubleshooting:'); + console.error(` - Check port: ${getPortCheckCommand(cfg.port)}`); + console.error(' - Run with --verbose for detailed logs'); + console.error(` - View config: ${getCatCommand(configPath)}`); + console.error(' - Try: ccs doctor --fix'); + console.error(''); + + throw new Error(`CLIProxy startup failed: ${err.message}`); + } + + // Register this session with the new proxy + sessionId = registerSession(cfg.port, proxy.pid as number); + log(`Registered session ${sessionId} with new proxy (PID ${proxy.pid})`); } - - // 6b. Spawn CLIProxyAPI binary (only if not reusing existing proxy) - // Use detached mode so proxy persists after terminal closes - const proxyArgs = ['--config', configPath]; - - log(`Spawning: ${binaryPath} ${proxyArgs.join(' ')}`); - - proxy = spawn(binaryPath, proxyArgs, { - stdio: ['ignore', 'ignore', 'ignore'], - detached: true, // Persist after parent terminal closes - env: { - ...process.env, - WRITABLE_PATH: getCliproxyWritablePath(), // Logs stored in ~/.ccs/cliproxy/logs/ - }, - }); - - // Unref so parent process can exit independently - proxy.unref(); - - // Handle proxy errors (only fires if spawn itself fails) - proxy.on('error', (error) => { - console.error(fail(`CLIProxy spawn error: ${error.message}`)); - }); - - // 7. Wait for proxy readiness via TCP polling - const readySpinner = new ProgressIndicator(`Waiting for CLIProxy on port ${cfg.port}`); - readySpinner.start(); - - try { - await waitForProxyReady(cfg.port, cfg.timeout, cfg.pollInterval); - readySpinner.succeed(`CLIProxy ready on port ${cfg.port}`); - } catch (error) { - readySpinner.fail('CLIProxy startup failed'); - proxy.kill('SIGTERM'); - - const err = error as Error; - console.error(''); - console.error(fail('CLIProxy failed to start')); - console.error(''); - console.error('Possible causes:'); - console.error(` 1. Port ${cfg.port} already in use`); - console.error(' 2. Binary crashed on startup'); - console.error(' 3. Invalid configuration'); - console.error(''); - console.error('Troubleshooting:'); - console.error(` - Check port: ${getPortCheckCommand(cfg.port)}`); - console.error(' - Run with --verbose for detailed logs'); - console.error(` - View config: ${getCatCommand(configPath)}`); - console.error(' - Try: ccs doctor --fix'); - console.error(''); - - throw new Error(`CLIProxy startup failed: ${err.message}`); - } - - // Register this session with the new proxy - sessionId = registerSession(cfg.port, proxy.pid as number); - log(`Registered session ${sessionId} with new proxy (PID ${proxy.pid})`); } // 7. Execute Claude CLI with proxied environment - // Uses custom settings path (for variants), user settings, or bundled defaults - const envVars = getEffectiveEnvVars(provider, cfg.port, cfg.customSettingsPath); + // Use remote or local env vars based on mode + const envVars = useRemoteProxy + ? getRemoteEnvVars(provider, { + host: proxyConfig.host ?? 'localhost', + port: proxyConfig.port, + protocol: proxyConfig.protocol, + authToken: proxyConfig.authToken, + }) + : getEffectiveEnvVars(provider, cfg.port, cfg.customSettingsPath); const webSearchEnv = getWebSearchHookEnv(); const env = { ...process.env, @@ -437,6 +532,7 @@ export async function execClaudeWithCLIProxy( } // Filter out CCS-specific flags before passing to Claude CLI + // Note: Proxy flags (--proxy-host, etc.) already stripped by resolveProxyConfig() const ccsFlags = [ '--auth', '--headless', @@ -446,12 +542,15 @@ export async function execClaudeWithCLIProxy( '--accounts', '--use', '--nickname', + // Proxy flags are handled by resolveProxyConfig, but list for documentation + ...PROXY_CLI_FLAGS, ]; - const claudeArgs = args.filter((arg, idx) => { + const claudeArgs = argsWithoutProxy.filter((arg, idx) => { // Filter out CCS flags if (ccsFlags.includes(arg)) return false; // Filter out value after --use or --nickname - if (args[idx - 1] === '--use' || args[idx - 1] === '--nickname') return false; + if (argsWithoutProxy[idx - 1] === '--use' || argsWithoutProxy[idx - 1] === '--nickname') + return false; return true; }); @@ -475,14 +574,16 @@ export async function execClaudeWithCLIProxy( }); } - // 8. Cleanup: unregister session when Claude exits + // 8. Cleanup: unregister session when Claude exits (local mode only) // Proxy persists by default - use 'ccs cliproxy stop' to kill manually claude.on('exit', (code, signal) => { log(`Claude exited: code=${code}, signal=${signal}`); - // Unregister this session (proxy keeps running for persistence) - unregisterSession(sessionId); - log(`Session ${sessionId} unregistered, proxy persists for other sessions or future use`); + // Unregister this session (proxy keeps running for persistence) - only for local mode + if (sessionId) { + unregisterSession(sessionId); + log(`Session ${sessionId} unregistered, proxy persists for other sessions or future use`); + } if (signal) { process.kill(process.pid, signal as NodeJS.Signals); @@ -494,8 +595,10 @@ export async function execClaudeWithCLIProxy( claude.on('error', (error) => { console.error(fail(`Claude CLI error: ${error}`)); - // Unregister session, proxy keeps running - unregisterSession(sessionId); + // Unregister session, proxy keeps running (local mode only) + if (sessionId) { + unregisterSession(sessionId); + } process.exit(1); }); @@ -503,8 +606,10 @@ export async function execClaudeWithCLIProxy( const cleanup = () => { log('Parent signal received, cleaning up'); - // Unregister session, proxy keeps running - unregisterSession(sessionId); + // Unregister session, proxy keeps running (local mode only) + if (sessionId) { + unregisterSession(sessionId); + } claude.kill('SIGTERM'); }; From ebfef34a13f09e1c2b09ac9f0914661d9e911d93 Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Fri, 19 Dec 2025 06:15:45 +0000 Subject: [PATCH 32/40] chore(release): 6.6.0-dev.2 [skip ci] ## [6.6.0-dev.2](https://github.com/kaitranntt/ccs/compare/v6.6.0-dev.1...v6.6.0-dev.2) (2025-12-19) ### Bug Fixes * **ci:** remove sync-version.js that depends on deleted VERSION file ([18729c9](https://github.com/kaitranntt/ccs/commit/18729c9983ecd1f9d857b0de2753e99c675c624a)) --- CHANGELOG.md | 6 ++++++ package.json | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 01c7ffa3..049b5ed9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## [6.6.0-dev.2](https://github.com/kaitranntt/ccs/compare/v6.6.0-dev.1...v6.6.0-dev.2) (2025-12-19) + +### Bug Fixes + +* **ci:** remove sync-version.js that depends on deleted VERSION file ([18729c9](https://github.com/kaitranntt/ccs/commit/18729c9983ecd1f9d857b0de2753e99c675c624a)) + ## [6.6.0-dev.1](https://github.com/kaitranntt/ccs/compare/v6.5.0...v6.6.0-dev.1) (2025-12-19) ### ⚠ BREAKING CHANGES diff --git a/package.json b/package.json index 8cc1bdbd..dec67bae 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "6.6.0-dev.1", + "version": "6.6.0-dev.2", "description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6", "keywords": [ "cli", From 8decdfb515075b772970de7c85b34c31baf93754 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Fri, 19 Dec 2025 01:16:10 -0500 Subject: [PATCH 33/40] feat(web-server): add proxy configuration API routes - GET /api/proxy returns current proxy config - PUT /api/proxy updates proxy settings in config.yaml - POST /api/proxy/test tests remote connection - register routes in web server index --- src/web-server/index.ts | 4 ++ src/web-server/routes/proxy-routes.ts | 93 +++++++++++++++++++++++++++ 2 files changed, 97 insertions(+) create mode 100644 src/web-server/routes/proxy-routes.ts diff --git a/src/web-server/index.ts b/src/web-server/index.ts index e9c4ba3a..4464027e 100644 --- a/src/web-server/index.ts +++ b/src/web-server/index.ts @@ -51,6 +51,10 @@ export async function startServer(options: ServerOptions): Promise { + try { + const config = await loadOrCreateUnifiedConfig(); + res.json(config.proxy || DEFAULT_PROXY_CONFIG); + } catch (error) { + console.error('[proxy-routes] Failed to load proxy config:', error); + res.status(500).json({ error: 'Failed to load proxy config' }); + } +}); + +/** + * PUT /api/proxy - Update proxy configuration + */ +router.put('/', async (req: Request, res: Response) => { + try { + const config = await loadOrCreateUnifiedConfig(); + const updates = req.body as Partial; + + // Deep merge with defaults and current config + config.proxy = { + remote: { + ...DEFAULT_PROXY_CONFIG.remote, + ...config.proxy?.remote, + ...updates.remote, + }, + fallback: { + ...DEFAULT_PROXY_CONFIG.fallback, + ...config.proxy?.fallback, + ...updates.fallback, + }, + local: { + ...DEFAULT_PROXY_CONFIG.local, + ...config.proxy?.local, + ...updates.local, + }, + }; + + await saveUnifiedConfig(config); + res.json(config.proxy); + } catch (error) { + console.error('[proxy-routes] Failed to save proxy config:', error); + res.status(500).json({ error: 'Failed to save proxy config' }); + } +}); + +/** + * POST /api/proxy/test - Test remote proxy connection + */ +router.post('/test', async (req: Request, res: Response) => { + try { + const { host, port, protocol, authToken, allowSelfSigned } = req.body; + + if (!host || !port) { + res.status(400).json({ error: 'Host and port are required' }); + return; + } + + const status = await testConnection({ + host, + port: typeof port === 'number' ? port : parseInt(port, 10), + protocol: protocol || 'http', + authToken, + allowSelfSigned: allowSelfSigned || false, + timeout: 5000, + }); + + res.json(status); + } catch (error) { + console.error('[proxy-routes] Failed to test connection:', error); + res.status(500).json({ error: 'Failed to test connection' }); + } +}); + +export default router; From 9a9ef98542bb766087b711fc39e928e347ad9b86 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Fri, 19 Dec 2025 01:17:20 -0500 Subject: [PATCH 34/40] feat(ui): add Proxy settings tab to dashboard - add Proxy tab with Local/Remote mode toggle cards - add remote server config inputs (host, port, protocol, auth token) - add Test Connection button with reachability status display - add fallback settings (enable fallback, auto-start local) - add local proxy port configuration - add getProxyConfig, updateProxyConfig, testProxyConnection API methods --- ui/src/lib/api-client.ts | 59 +++++ ui/src/pages/settings.tsx | 537 +++++++++++++++++++++++++++++++++++++- 2 files changed, 590 insertions(+), 6 deletions(-) diff --git a/ui/src/lib/api-client.ts b/ui/src/lib/api-client.ts index 73815560..3bb1c87f 100644 --- a/ui/src/lib/api-client.ts +++ b/ui/src/lib/api-client.ts @@ -154,6 +154,42 @@ export interface CreatePreset { haiku?: string; } +/** Remote proxy status from health check */ +export interface RemoteProxyStatus { + reachable: boolean; + latencyMs?: number; + error?: string; + errorCode?: 'CONNECTION_REFUSED' | 'TIMEOUT' | 'AUTH_FAILED' | 'UNKNOWN'; +} + +/** Remote proxy configuration */ +export interface ProxyRemoteConfig { + enabled: boolean; + host: string; + port: number; + protocol: 'http' | 'https'; + auth_token: string; +} + +/** Fallback configuration */ +export interface ProxyFallbackConfig { + enabled: boolean; + auto_start: boolean; +} + +/** Local proxy configuration */ +export interface ProxyLocalConfig { + port: number; + auto_start: boolean; +} + +/** Proxy configuration */ +export interface ProxyConfig { + remote: ProxyRemoteConfig; + fallback: ProxyFallbackConfig; + local: ProxyLocalConfig; +} + /** CLIProxy process status from session tracker */ export interface ProxyProcessStatus { running: boolean; @@ -353,4 +389,27 @@ export const api = { method: 'DELETE', }), }, + /** Proxy configuration API */ + proxy: { + /** Get proxy configuration */ + get: () => request('/proxy'), + /** Update proxy configuration */ + update: (config: Partial) => + request('/proxy', { + method: 'PUT', + body: JSON.stringify(config), + }), + /** Test remote proxy connection */ + test: (params: { + host: string; + port: number; + protocol: 'http' | 'https'; + authToken?: string; + allowSelfSigned?: boolean; + }) => + request('/proxy/test', { + method: 'POST', + body: JSON.stringify(params), + }), + }, }; diff --git a/ui/src/pages/settings.tsx b/ui/src/pages/settings.tsx index 00f39dee..6f143344 100644 --- a/ui/src/pages/settings.tsx +++ b/ui/src/pages/settings.tsx @@ -1,6 +1,6 @@ /** - * Settings Page - WebSearch & Global Env Configuration - * Supports Gemini CLI and Grok CLI providers + Global Environment Variables + * Settings Page - WebSearch, Global Env & Proxy Configuration + * Supports Gemini CLI and Grok CLI providers + Global Environment Variables + Proxy Settings */ import { useState, useEffect } from 'react'; @@ -12,6 +12,13 @@ import { ScrollArea } from '@/components/ui/scroll-area'; import { Switch } from '@/components/ui/switch'; import { Input } from '@/components/ui/input'; import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; import { Globe, RefreshCw, @@ -28,8 +35,15 @@ import { Settings2, Plus, Trash2, + Server, + Laptop, + Cloud, + Wifi, + WifiOff, } from 'lucide-react'; import { CodeEditor } from '@/components/code-editor'; +import { api } from '@/lib/api-client'; +import type { ProxyConfig, RemoteProxyStatus } from '@/lib/api-client'; interface ProviderConfig { enabled?: boolean; @@ -71,8 +85,10 @@ interface GlobalEnvConfig { export function SettingsPage() { const [searchParams] = useSearchParams(); - const initialTab = searchParams.get('tab') === 'globalenv' ? 'globalenv' : 'websearch'; - const [activeTab, setActiveTab] = useState<'websearch' | 'globalenv'>(initialTab); + const tabParam = searchParams.get('tab'); + const initialTab = + tabParam === 'globalenv' ? 'globalenv' : tabParam === 'proxy' ? 'proxy' : 'websearch'; + const [activeTab, setActiveTab] = useState<'websearch' | 'globalenv' | 'proxy'>(initialTab); const [config, setConfig] = useState(null); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); @@ -100,6 +116,14 @@ export function SettingsPage() { // New env var inputs const [newEnvKey, setNewEnvKey] = useState(''); const [newEnvValue, setNewEnvValue] = useState(''); + // Proxy state + const [proxyConfig, setProxyConfig] = useState(null); + const [proxyLoading, setProxyLoading] = useState(true); + const [proxySaving, setProxySaving] = useState(false); + const [proxyError, setProxyError] = useState(null); + const [proxySuccess, setProxySuccess] = useState(false); + const [testResult, setTestResult] = useState(null); + const [testing, setTesting] = useState(false); // Load config and status on mount useEffect(() => { @@ -107,6 +131,7 @@ export function SettingsPage() { fetchStatus(); fetchRawConfig(); fetchGlobalEnvConfig(); + fetchProxyConfig(); }, []); // Sync local model inputs when config changes @@ -179,6 +204,19 @@ export function SettingsPage() { } }; + const fetchProxyConfig = async () => { + try { + setProxyLoading(true); + setProxyError(null); + const data = await api.proxy.get(); + setProxyConfig(data); + } catch (err) { + setProxyError((err as Error).message); + } finally { + setProxyLoading(false); + } + }; + const copyToClipboard = async () => { if (!rawConfig) return; try { @@ -387,6 +425,68 @@ export function SettingsPage() { saveGlobalEnvConfig({ env: newEnv }); }; + // Proxy functions + const saveProxyConfig = async (updates: Partial) => { + if (!proxyConfig) return; + + // Optimistic update + const optimisticConfig = { + remote: { ...proxyConfig.remote, ...updates.remote }, + fallback: { ...proxyConfig.fallback, ...updates.fallback }, + local: { ...proxyConfig.local, ...updates.local }, + }; + setProxyConfig(optimisticConfig); + setTestResult(null); // Clear previous test result on config change + + try { + setProxySaving(true); + setProxyError(null); + + const data = await api.proxy.update(updates); + setProxyConfig(data); + setProxySuccess(true); + setTimeout(() => setProxySuccess(false), 1500); + // Silently refresh raw config + fetch('/api/config/raw') + .then((r) => (r.ok ? r.text() : null)) + .then((text) => text && setRawConfig(text)) + .catch(() => {}); + } catch (err) { + setProxyConfig(proxyConfig); + setProxyError((err as Error).message); + } finally { + setProxySaving(false); + } + }; + + const handleTestConnection = async () => { + if (!proxyConfig) return; + + const { host, port, protocol, auth_token } = proxyConfig.remote; + if (!host || !port) { + setProxyError('Host and port are required'); + return; + } + + try { + setTesting(true); + setProxyError(null); + setTestResult(null); + + const result = await api.proxy.test({ + host, + port, + protocol, + authToken: auth_token || undefined, + }); + setTestResult(result); + } catch (err) { + setProxyError((err as Error).message); + } finally { + setTesting(false); + } + }; + if (loading) { return (
@@ -408,7 +508,7 @@ export function SettingsPage() {
setActiveTab(v as 'websearch' | 'globalenv')} + onValueChange={(v) => setActiveTab(v as 'websearch' | 'globalenv' | 'proxy')} > @@ -419,6 +519,10 @@ export function SettingsPage() { Global Env + + + Proxy +
@@ -455,7 +559,7 @@ export function SettingsPage() { fetchRawConfig={fetchRawConfig} loading={loading} /> - ) : ( + ) : activeTab === 'globalenv' ? ( + ) : ( + )}
@@ -1163,3 +1281,410 @@ function GlobalEnvContent({ ); } + +// Proxy Tab Content Component +interface ProxyContentProps { + config: ProxyConfig | null; + loading: boolean; + saving: boolean; + error: string | null; + success: boolean; + testResult: RemoteProxyStatus | null; + testing: boolean; + saveProxyConfig: (updates: Partial) => void; + handleTestConnection: () => void; + fetchProxyConfig: () => void; + fetchRawConfig: () => void; +} + +function ProxyContent({ + config, + loading, + saving, + error, + success, + testResult, + testing, + saveProxyConfig, + handleTestConnection, + fetchProxyConfig, + fetchRawConfig, +}: ProxyContentProps) { + // Memoized default config to avoid recreation + const defaultRemote = { + enabled: false, + host: '', + port: 8317, + protocol: 'http' as const, + auth_token: '', + }; + const defaultFallback = { enabled: true, auto_start: true }; + const defaultLocal = { port: 8317, auto_start: true }; + + // Sync local state with config (using refs to avoid lint warnings) + const hostInput = config?.remote.host ?? ''; + const portInput = (config?.remote.port ?? 8317).toString(); + const authTokenInput = config?.remote.auth_token ?? ''; + const localPortInput = (config?.local.port ?? 8317).toString(); + + // Track edited values separately + const [editedHost, setEditedHost] = useState(null); + const [editedPort, setEditedPort] = useState(null); + const [editedAuthToken, setEditedAuthToken] = useState(null); + const [editedLocalPort, setEditedLocalPort] = useState(null); + + // Get display values (edited or from config) + const displayHost = editedHost ?? hostInput; + const displayPort = editedPort ?? portInput; + const displayAuthToken = editedAuthToken ?? authTokenInput; + const displayLocalPort = editedLocalPort ?? localPortInput; + + if (loading) { + return ( +
+
+ + Loading... +
+
+ ); + } + + const isRemoteMode = config?.remote.enabled ?? false; + const remoteConfig = config?.remote ?? defaultRemote; + const fallbackConfig = config?.fallback ?? defaultFallback; + const localConfig = config?.local ?? defaultLocal; + + // Save functions for blur events + const saveHost = () => { + const value = editedHost ?? displayHost; + if (value !== config?.remote.host) { + saveProxyConfig({ remote: { ...remoteConfig, host: value } }); + } + setEditedHost(null); + }; + + const savePort = () => { + const port = parseInt(editedPort ?? displayPort, 10); + if (!isNaN(port) && port !== config?.remote.port) { + saveProxyConfig({ remote: { ...remoteConfig, port } }); + } + setEditedPort(null); + }; + + const saveAuthToken = () => { + const value = editedAuthToken ?? displayAuthToken; + if (value !== config?.remote.auth_token) { + saveProxyConfig({ remote: { ...remoteConfig, auth_token: value } }); + } + setEditedAuthToken(null); + }; + + const saveLocalPort = () => { + const port = parseInt(editedLocalPort ?? displayLocalPort, 10); + if (!isNaN(port) && port !== config?.local.port) { + saveProxyConfig({ local: { ...localConfig, port } }); + } + setEditedLocalPort(null); + }; + + return ( + <> + {/* Toast-style alerts */} +
+ {error && ( + + + {error} + + )} + {success && ( +
+ + Saved +
+ )} +
+ + {/* Scrollable Content */} + +
+ {/* Description */} +

+ Configure local or remote CLIProxyAPI connection for proxy-based profiles +

+ + {/* Mode Toggle - Card based selection */} +
+

Connection Mode

+
+ {/* Local Mode Card */} + + + {/* Remote Mode Card */} + +
+
+ + {/* Remote Settings - Show when remote mode is enabled */} + {isRemoteMode && ( +
+

+ + Remote Server Configuration +

+ + {/* Host */} +
+ + setEditedHost(e.target.value)} + onBlur={saveHost} + placeholder="192.168.1.100 or proxy.example.com" + className="font-mono" + disabled={saving} + /> +
+ + {/* Port and Protocol */} +
+
+ + setEditedPort(e.target.value)} + onBlur={savePort} + placeholder="8317" + className="font-mono" + disabled={saving} + /> +
+
+ + +
+
+ + {/* Auth Token */} +
+ + setEditedAuthToken(e.target.value)} + onBlur={saveAuthToken} + placeholder="Bearer token for authentication" + className="font-mono" + disabled={saving} + /> +
+ + {/* Test Connection */} +
+ + + {/* Test Result */} + {testResult && ( +
+
+ {testResult.reachable ? ( + <> + + + Connected ({testResult.latencyMs}ms) + + + ) : ( + <> + + + {testResult.error || 'Connection failed'} + + + )} +
+
+ )} +
+
+ )} + + {/* Fallback Settings */} +
+

Fallback Settings

+
+ {/* Enable Fallback */} +
+
+

Enable fallback to local

+

+ Use local proxy if remote is unreachable +

+
+ + saveProxyConfig({ fallback: { ...fallbackConfig, enabled: checked } }) + } + disabled={saving || !isRemoteMode} + /> +
+ + {/* Auto-start on fallback */} +
+
+

Auto-start local proxy

+

+ Automatically start local proxy on fallback +

+
+ + saveProxyConfig({ fallback: { ...fallbackConfig, auto_start: checked } }) + } + disabled={saving || !isRemoteMode || !config?.fallback.enabled} + /> +
+
+
+ + {/* Local Proxy Settings */} +
+

Local Proxy

+
+ {/* Port */} +
+ + setEditedLocalPort(e.target.value)} + onBlur={saveLocalPort} + placeholder="8317" + className="font-mono max-w-32" + disabled={saving} + /> +
+ + {/* Auto-start */} +
+
+

Auto-start

+

+ Start local proxy automatically when needed +

+
+ + saveProxyConfig({ local: { ...localConfig, auto_start: checked } }) + } + disabled={saving} + /> +
+
+
+
+
+ + {/* Footer */} +
+ +
+ + ); +} From 196422cee1f7410d385581f2a28df3faa87d68e3 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Fri, 19 Dec 2025 01:18:05 -0500 Subject: [PATCH 35/40] docs(cliproxy): add remote proxy documentation - add proxy CLI flags section to help command output - add proxy environment variables section to help - add Remote Proxy section to README with config examples - document CLI flag overrides and priority resolution --- README.md | 78 ++++++++++++++++++++++++++++++++++++ src/commands/help-command.ts | 19 +++++++++ 2 files changed, 97 insertions(+) diff --git a/README.md b/README.md index bb69dc9c..edbc9374 100644 --- a/README.md +++ b/README.md @@ -195,6 +195,84 @@ Without Developer Mode, CCS falls back to copying directories.
+## Remote Proxy + +Connect to a remote CLIProxyAPI server (Docker, Kubernetes, or another machine) instead of using the local binary. + +### Configuration + +Configure via dashboard (**Settings → Proxy** tab) or `~/.ccs/config.yaml`: + +```yaml +proxy: + remote: + enabled: true + host: "192.168.1.100" # Remote server hostname/IP + port: 8317 # Default CLIProxy port + protocol: http # http or https + auth-token: "" # Optional auth token + fallback: + enabled: true # Fallback to local if remote unreachable + auto-start: true # Auto-start local proxy on fallback + local: + port: 8317 + auto-start: true +``` + +### CLI Flags + +Override config for one-time use: + +| Flag | Description | +|------|-------------| +| `--proxy-host ` | Remote proxy hostname/IP | +| `--proxy-port ` | Proxy port (default: 8317) | +| `--proxy-protocol ` | Protocol: `http` or `https` | +| `--proxy-auth-token ` | Auth token for remote proxy | +| `--local-proxy` | Force local mode, ignore remote config | +| `--remote-only` | Fail if remote unreachable (no fallback) | + +```bash +# One-time remote connection +ccs gemini --proxy-host 192.168.1.100 --proxy-port 8317 + +# Force local mode +ccs gemini --local-proxy + +# Strict remote mode (no fallback) +ccs gemini --proxy-host remote.example.com --remote-only +``` + +### Environment Variables + +For CI/CD and automation: + +| Variable | Description | +|----------|-------------| +| `CCS_PROXY_HOST` | Remote proxy hostname | +| `CCS_PROXY_PORT` | Proxy port | +| `CCS_PROXY_PROTOCOL` | Protocol (`http`/`https`) | +| `CCS_PROXY_AUTH_TOKEN` | Auth token | +| `CCS_PROXY_FALLBACK_ENABLED` | Enable local fallback (`1`/`0`) | + +```bash +# Docker example +export CCS_PROXY_HOST="cliproxy-container" +export CCS_PROXY_PORT="8317" +ccs gemini "implement feature" +``` + +### Priority Resolution + +Configuration sources are merged with this priority (highest first): + +1. **CLI flags** — One-time overrides +2. **Environment variables** — CI/CD automation +3. **config.yaml** — Persistent settings +4. **Defaults** — Local mode, port 8317 + +
+ ## WebSearch Third-party profiles (Gemini, Codex, GLM, etc.) cannot use Anthropic's native WebSearch. CCS automatically configures MCP-based web search as a fallback. diff --git a/src/commands/help-command.ts b/src/commands/help-command.ts index 618dfd23..4490126b 100644 --- a/src/commands/help-command.ts +++ b/src/commands/help-command.ts @@ -241,6 +241,25 @@ Claude Code Profile & Model Switcher`.trim(); ['ccs cliproxy --latest', 'Update to latest version'], ]); + // CLI Proxy configuration flags (new) + printSubSection('CLI Proxy Configuration', [ + ['--proxy-host ', 'Remote proxy hostname/IP'], + ['--proxy-port ', 'Proxy port (default: 8317)'], + ['--proxy-protocol ', 'Protocol: http or https (default: http)'], + ['--proxy-auth-token ', 'Auth token for remote proxy'], + ['--local-proxy', 'Force local mode, ignore remote config'], + ['--remote-only', 'Fail if remote unreachable (no fallback)'], + ]); + + // CLI Proxy env vars + printSubSection('CLI Proxy Environment Variables', [ + ['CCS_PROXY_HOST', 'Remote proxy hostname'], + ['CCS_PROXY_PORT', 'Proxy port'], + ['CCS_PROXY_PROTOCOL', 'Protocol (http/https)'], + ['CCS_PROXY_AUTH_TOKEN', 'Auth token'], + ['CCS_PROXY_FALLBACK_ENABLED', 'Enable local fallback (1/0)'], + ]); + // CLI Proxy paths console.log(subheader('CLI Proxy:')); console.log(` Binary: ${color('~/.ccs/cliproxy/bin/cli-proxy-api', 'path')}`); From eeb6913d96fe1a9a0d8721627a07c7f772b67b88 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Fri, 19 Dec 2025 01:41:04 -0500 Subject: [PATCH 36/40] style(ui): use sidebar accent colors for proxy update button - replace hardcoded amber-600 with sidebar-accent theme variable - improve consistency with sidebar theme --- ui/src/components/proxy-status-widget.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/ui/src/components/proxy-status-widget.tsx b/ui/src/components/proxy-status-widget.tsx index d3d4cd1d..bfa0a2c3 100644 --- a/ui/src/components/proxy-status-widget.tsx +++ b/ui/src/components/proxy-status-widget.tsx @@ -123,7 +123,8 @@ export function ProxyStatusWidget() { size="sm" className={cn( 'h-7 text-xs gap-1 flex-1', - hasUpdate && 'bg-amber-600 hover:bg-amber-700 text-white' + hasUpdate && + 'bg-sidebar-accent hover:bg-sidebar-accent/90 text-sidebar-accent-foreground' )} onClick={handleRestart} disabled={isActioning} From 8d8d4c248ad890413d5c4e7e72f9f2a16305f74f Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Fri, 19 Dec 2025 02:14:49 -0500 Subject: [PATCH 37/40] refactor: rename proxy to cliproxy_server and update API routes --- README.md | 79 +-------------------------- src/config/unified-config-loader.ts | 35 ++++++++---- src/config/unified-config-types.ts | 14 ++--- src/web-server/index.ts | 6 +- src/web-server/routes/proxy-routes.ts | 45 ++++++++------- ui/src/lib/api-client.ts | 20 +++---- ui/src/pages/settings.tsx | 66 ++++++++++++---------- 7 files changed, 104 insertions(+), 161 deletions(-) diff --git a/README.md b/README.md index edbc9374..0a6620c7 100644 --- a/README.md +++ b/README.md @@ -195,84 +195,6 @@ Without Developer Mode, CCS falls back to copying directories.
-## Remote Proxy - -Connect to a remote CLIProxyAPI server (Docker, Kubernetes, or another machine) instead of using the local binary. - -### Configuration - -Configure via dashboard (**Settings → Proxy** tab) or `~/.ccs/config.yaml`: - -```yaml -proxy: - remote: - enabled: true - host: "192.168.1.100" # Remote server hostname/IP - port: 8317 # Default CLIProxy port - protocol: http # http or https - auth-token: "" # Optional auth token - fallback: - enabled: true # Fallback to local if remote unreachable - auto-start: true # Auto-start local proxy on fallback - local: - port: 8317 - auto-start: true -``` - -### CLI Flags - -Override config for one-time use: - -| Flag | Description | -|------|-------------| -| `--proxy-host ` | Remote proxy hostname/IP | -| `--proxy-port ` | Proxy port (default: 8317) | -| `--proxy-protocol ` | Protocol: `http` or `https` | -| `--proxy-auth-token ` | Auth token for remote proxy | -| `--local-proxy` | Force local mode, ignore remote config | -| `--remote-only` | Fail if remote unreachable (no fallback) | - -```bash -# One-time remote connection -ccs gemini --proxy-host 192.168.1.100 --proxy-port 8317 - -# Force local mode -ccs gemini --local-proxy - -# Strict remote mode (no fallback) -ccs gemini --proxy-host remote.example.com --remote-only -``` - -### Environment Variables - -For CI/CD and automation: - -| Variable | Description | -|----------|-------------| -| `CCS_PROXY_HOST` | Remote proxy hostname | -| `CCS_PROXY_PORT` | Proxy port | -| `CCS_PROXY_PROTOCOL` | Protocol (`http`/`https`) | -| `CCS_PROXY_AUTH_TOKEN` | Auth token | -| `CCS_PROXY_FALLBACK_ENABLED` | Enable local fallback (`1`/`0`) | - -```bash -# Docker example -export CCS_PROXY_HOST="cliproxy-container" -export CCS_PROXY_PORT="8317" -ccs gemini "implement feature" -``` - -### Priority Resolution - -Configuration sources are merged with this priority (highest first): - -1. **CLI flags** — One-time overrides -2. **Environment variables** — CI/CD automation -3. **config.yaml** — Persistent settings -4. **Defaults** — Local mode, port 8317 - -
- ## WebSearch Third-party profiles (Gemini, Codex, GLM, etc.) cannot use Anthropic's native WebSearch. CCS automatically configures MCP-based web search as a fallback. @@ -321,6 +243,7 @@ See [docs/websearch.md](./docs/websearch.md) for detailed configuration and trou | OAuth Providers | [docs.ccs.kaitran.ca/providers/oauth-providers](https://docs.ccs.kaitran.ca/providers/oauth-providers) | | Multi-Account Claude | [docs.ccs.kaitran.ca/providers/claude-accounts](https://docs.ccs.kaitran.ca/providers/claude-accounts) | | API Profiles | [docs.ccs.kaitran.ca/providers/api-profiles](https://docs.ccs.kaitran.ca/providers/api-profiles) | +| Remote Proxy | [docs.ccs.kaitran.ca/features/remote-proxy](https://docs.ccs.kaitran.ca/features/remote-proxy) | | CLI Reference | [docs.ccs.kaitran.ca/reference/cli-commands](https://docs.ccs.kaitran.ca/reference/cli-commands) | | Architecture | [docs.ccs.kaitran.ca/reference/architecture](https://docs.ccs.kaitran.ca/reference/architecture) | | Troubleshooting | [docs.ccs.kaitran.ca/reference/troubleshooting](https://docs.ccs.kaitran.ca/reference/troubleshooting) | diff --git a/src/config/unified-config-loader.ts b/src/config/unified-config-loader.ts index 2450472a..98c72da1 100644 --- a/src/config/unified-config-loader.ts +++ b/src/config/unified-config-loader.ts @@ -16,7 +16,7 @@ import { UNIFIED_CONFIG_VERSION, DEFAULT_COPILOT_CONFIG, DEFAULT_GLOBAL_ENV, - DEFAULT_PROXY_CONFIG, + DEFAULT_CLIPROXY_SERVER_CONFIG, GlobalEnvConfig, } from './unified-config-types'; import { isUnifiedConfigEnabled } from './feature-flags'; @@ -178,22 +178,33 @@ function mergeWithDefaults(partial: Partial): UnifiedConfig { enabled: partial.global_env?.enabled ?? true, env: partial.global_env?.env ?? { ...DEFAULT_GLOBAL_ENV }, }, - // Proxy config - remote/local CLIProxyAPI settings - proxy: { + // CLIProxy server config - remote/local CLIProxyAPI settings + cliproxy_server: { remote: { - enabled: partial.proxy?.remote?.enabled ?? DEFAULT_PROXY_CONFIG.remote.enabled, - host: partial.proxy?.remote?.host ?? DEFAULT_PROXY_CONFIG.remote.host, - port: partial.proxy?.remote?.port ?? DEFAULT_PROXY_CONFIG.remote.port, - protocol: partial.proxy?.remote?.protocol ?? DEFAULT_PROXY_CONFIG.remote.protocol, - auth_token: partial.proxy?.remote?.auth_token ?? DEFAULT_PROXY_CONFIG.remote.auth_token, + enabled: + partial.cliproxy_server?.remote?.enabled ?? DEFAULT_CLIPROXY_SERVER_CONFIG.remote.enabled, + host: partial.cliproxy_server?.remote?.host ?? DEFAULT_CLIPROXY_SERVER_CONFIG.remote.host, + port: partial.cliproxy_server?.remote?.port ?? DEFAULT_CLIPROXY_SERVER_CONFIG.remote.port, + protocol: + partial.cliproxy_server?.remote?.protocol ?? + DEFAULT_CLIPROXY_SERVER_CONFIG.remote.protocol, + auth_token: + partial.cliproxy_server?.remote?.auth_token ?? + DEFAULT_CLIPROXY_SERVER_CONFIG.remote.auth_token, }, fallback: { - enabled: partial.proxy?.fallback?.enabled ?? DEFAULT_PROXY_CONFIG.fallback.enabled, - auto_start: partial.proxy?.fallback?.auto_start ?? DEFAULT_PROXY_CONFIG.fallback.auto_start, + enabled: + partial.cliproxy_server?.fallback?.enabled ?? + DEFAULT_CLIPROXY_SERVER_CONFIG.fallback.enabled, + auto_start: + partial.cliproxy_server?.fallback?.auto_start ?? + DEFAULT_CLIPROXY_SERVER_CONFIG.fallback.auto_start, }, local: { - port: partial.proxy?.local?.port ?? DEFAULT_PROXY_CONFIG.local.port, - auto_start: partial.proxy?.local?.auto_start ?? DEFAULT_PROXY_CONFIG.local.auto_start, + port: partial.cliproxy_server?.local?.port ?? DEFAULT_CLIPROXY_SERVER_CONFIG.local.port, + auto_start: + partial.cliproxy_server?.local?.auto_start ?? + DEFAULT_CLIPROXY_SERVER_CONFIG.local.auto_start, }, }, }; diff --git a/src/config/unified-config-types.ts b/src/config/unified-config-types.ts index b1bc4252..fcffd26d 100644 --- a/src/config/unified-config-types.ts +++ b/src/config/unified-config-types.ts @@ -224,10 +224,10 @@ export interface ProxyLocalConfig { } /** - * Proxy configuration section. + * CLIProxy server configuration section. * Controls whether CCS uses local or remote CLIProxyAPI instance. */ -export interface ProxyConfig { +export interface CliproxyServerConfig { /** Remote proxy settings */ remote: ProxyRemoteConfig; /** Fallback behavior when remote is unreachable */ @@ -311,8 +311,8 @@ export interface UnifiedConfig { global_env?: GlobalEnvConfig; /** Copilot API configuration (GitHub Copilot proxy) */ copilot?: CopilotConfig; - /** Proxy configuration for remote/local CLIProxyAPI */ - proxy?: ProxyConfig; + /** CLIProxy server configuration for remote/local mode */ + cliproxy_server?: CliproxyServerConfig; } /** @@ -343,10 +343,10 @@ export const DEFAULT_COPILOT_CONFIG: CopilotConfig = { }; /** - * Default proxy configuration. + * Default CLIProxy server configuration. * Local mode by default - remote must be explicitly enabled. */ -export const DEFAULT_PROXY_CONFIG: ProxyConfig = { +export const DEFAULT_CLIPROXY_SERVER_CONFIG: CliproxyServerConfig = { remote: { enabled: false, host: '', @@ -411,7 +411,7 @@ export function createEmptyUnifiedConfig(): UnifiedConfig { env: { ...DEFAULT_GLOBAL_ENV }, }, copilot: { ...DEFAULT_COPILOT_CONFIG }, - proxy: { ...DEFAULT_PROXY_CONFIG }, + cliproxy_server: { ...DEFAULT_CLIPROXY_SERVER_CONFIG }, }; } diff --git a/src/web-server/index.ts b/src/web-server/index.ts index 4464027e..a4de7f80 100644 --- a/src/web-server/index.ts +++ b/src/web-server/index.ts @@ -51,9 +51,9 @@ export async function startServer(options: ServerOptions): Promise { try { const config = await loadOrCreateUnifiedConfig(); - res.json(config.proxy || DEFAULT_PROXY_CONFIG); + res.json(config.cliproxy_server || DEFAULT_CLIPROXY_SERVER_CONFIG); } catch (error) { - console.error('[proxy-routes] Failed to load proxy config:', error); + console.error('[cliproxy-server-routes] Failed to load proxy config:', error); res.status(500).json({ error: 'Failed to load proxy config' }); } }); /** - * PUT /api/proxy - Update proxy configuration + * PUT /api/cliproxy-server - Update proxy configuration */ router.put('/', async (req: Request, res: Response) => { try { const config = await loadOrCreateUnifiedConfig(); - const updates = req.body as Partial; + const updates = req.body as Partial; // Deep merge with defaults and current config - config.proxy = { + config.cliproxy_server = { remote: { - ...DEFAULT_PROXY_CONFIG.remote, - ...config.proxy?.remote, + ...DEFAULT_CLIPROXY_SERVER_CONFIG.remote, + ...config.cliproxy_server?.remote, ...updates.remote, }, fallback: { - ...DEFAULT_PROXY_CONFIG.fallback, - ...config.proxy?.fallback, + ...DEFAULT_CLIPROXY_SERVER_CONFIG.fallback, + ...config.cliproxy_server?.fallback, ...updates.fallback, }, local: { - ...DEFAULT_PROXY_CONFIG.local, - ...config.proxy?.local, + ...DEFAULT_CLIPROXY_SERVER_CONFIG.local, + ...config.cliproxy_server?.local, ...updates.local, }, }; await saveUnifiedConfig(config); - res.json(config.proxy); + res.json(config.cliproxy_server); } catch (error) { - console.error('[proxy-routes] Failed to save proxy config:', error); + console.error('[cliproxy-server-routes] Failed to save proxy config:', error); res.status(500).json({ error: 'Failed to save proxy config' }); } }); /** - * POST /api/proxy/test - Test remote proxy connection + * POST /api/cliproxy-server/test - Test remote proxy connection */ router.post('/test', async (req: Request, res: Response) => { try { @@ -85,7 +88,7 @@ router.post('/test', async (req: Request, res: Response) => { res.json(status); } catch (error) { - console.error('[proxy-routes] Failed to test connection:', error); + console.error('[cliproxy-server-routes] Failed to test connection:', error); res.status(500).json({ error: 'Failed to test connection' }); } }); diff --git a/ui/src/lib/api-client.ts b/ui/src/lib/api-client.ts index 3bb1c87f..4e0f6d21 100644 --- a/ui/src/lib/api-client.ts +++ b/ui/src/lib/api-client.ts @@ -183,8 +183,8 @@ export interface ProxyLocalConfig { auto_start: boolean; } -/** Proxy configuration */ -export interface ProxyConfig { +/** CLIProxy server configuration */ +export interface CliproxyServerConfig { remote: ProxyRemoteConfig; fallback: ProxyFallbackConfig; local: ProxyLocalConfig; @@ -389,13 +389,13 @@ export const api = { method: 'DELETE', }), }, - /** Proxy configuration API */ - proxy: { - /** Get proxy configuration */ - get: () => request('/proxy'), - /** Update proxy configuration */ - update: (config: Partial) => - request('/proxy', { + /** CLIProxy server configuration API */ + cliproxyServer: { + /** Get cliproxy server configuration */ + get: () => request('/cliproxy-server'), + /** Update cliproxy server configuration */ + update: (config: Partial) => + request('/cliproxy-server', { method: 'PUT', body: JSON.stringify(config), }), @@ -407,7 +407,7 @@ export const api = { authToken?: string; allowSelfSigned?: boolean; }) => - request('/proxy/test', { + request('/cliproxy-server/test', { method: 'POST', body: JSON.stringify(params), }), diff --git a/ui/src/pages/settings.tsx b/ui/src/pages/settings.tsx index 6f143344..1fdb9138 100644 --- a/ui/src/pages/settings.tsx +++ b/ui/src/pages/settings.tsx @@ -43,7 +43,7 @@ import { } from 'lucide-react'; import { CodeEditor } from '@/components/code-editor'; import { api } from '@/lib/api-client'; -import type { ProxyConfig, RemoteProxyStatus } from '@/lib/api-client'; +import type { CliproxyServerConfig, RemoteProxyStatus } from '@/lib/api-client'; interface ProviderConfig { enabled?: boolean; @@ -117,7 +117,7 @@ export function SettingsPage() { const [newEnvKey, setNewEnvKey] = useState(''); const [newEnvValue, setNewEnvValue] = useState(''); // Proxy state - const [proxyConfig, setProxyConfig] = useState(null); + const [proxyConfig, setCliproxyServerConfig] = useState(null); const [proxyLoading, setProxyLoading] = useState(true); const [proxySaving, setProxySaving] = useState(false); const [proxyError, setProxyError] = useState(null); @@ -131,7 +131,7 @@ export function SettingsPage() { fetchStatus(); fetchRawConfig(); fetchGlobalEnvConfig(); - fetchProxyConfig(); + fetchCliproxyServerConfig(); }, []); // Sync local model inputs when config changes @@ -204,12 +204,12 @@ export function SettingsPage() { } }; - const fetchProxyConfig = async () => { + const fetchCliproxyServerConfig = async () => { try { setProxyLoading(true); setProxyError(null); - const data = await api.proxy.get(); - setProxyConfig(data); + const data = await api.cliproxyServer.get(); + setCliproxyServerConfig(data); } catch (err) { setProxyError((err as Error).message); } finally { @@ -426,7 +426,7 @@ export function SettingsPage() { }; // Proxy functions - const saveProxyConfig = async (updates: Partial) => { + const saveCliproxyServerConfig = async (updates: Partial) => { if (!proxyConfig) return; // Optimistic update @@ -435,15 +435,15 @@ export function SettingsPage() { fallback: { ...proxyConfig.fallback, ...updates.fallback }, local: { ...proxyConfig.local, ...updates.local }, }; - setProxyConfig(optimisticConfig); + setCliproxyServerConfig(optimisticConfig); setTestResult(null); // Clear previous test result on config change try { setProxySaving(true); setProxyError(null); - const data = await api.proxy.update(updates); - setProxyConfig(data); + const data = await api.cliproxyServer.update(updates); + setCliproxyServerConfig(data); setProxySuccess(true); setTimeout(() => setProxySuccess(false), 1500); // Silently refresh raw config @@ -452,7 +452,7 @@ export function SettingsPage() { .then((text) => text && setRawConfig(text)) .catch(() => {}); } catch (err) { - setProxyConfig(proxyConfig); + setCliproxyServerConfig(proxyConfig); setProxyError((err as Error).message); } finally { setProxySaving(false); @@ -473,7 +473,7 @@ export function SettingsPage() { setProxyError(null); setTestResult(null); - const result = await api.proxy.test({ + const result = await api.cliproxyServer.test({ host, port, protocol, @@ -586,9 +586,9 @@ export function SettingsPage() { success={proxySuccess} testResult={testResult} testing={testing} - saveProxyConfig={saveProxyConfig} + saveCliproxyServerConfig={saveCliproxyServerConfig} handleTestConnection={handleTestConnection} - fetchProxyConfig={fetchProxyConfig} + fetchCliproxyServerConfig={fetchCliproxyServerConfig} fetchRawConfig={fetchRawConfig} /> )} @@ -1284,16 +1284,16 @@ function GlobalEnvContent({ // Proxy Tab Content Component interface ProxyContentProps { - config: ProxyConfig | null; + config: CliproxyServerConfig | null; loading: boolean; saving: boolean; error: string | null; success: boolean; testResult: RemoteProxyStatus | null; testing: boolean; - saveProxyConfig: (updates: Partial) => void; + saveCliproxyServerConfig: (updates: Partial) => void; handleTestConnection: () => void; - fetchProxyConfig: () => void; + fetchCliproxyServerConfig: () => void; fetchRawConfig: () => void; } @@ -1305,9 +1305,9 @@ function ProxyContent({ success, testResult, testing, - saveProxyConfig, + saveCliproxyServerConfig, handleTestConnection, - fetchProxyConfig, + fetchCliproxyServerConfig, fetchRawConfig, }: ProxyContentProps) { // Memoized default config to avoid recreation @@ -1359,7 +1359,7 @@ function ProxyContent({ const saveHost = () => { const value = editedHost ?? displayHost; if (value !== config?.remote.host) { - saveProxyConfig({ remote: { ...remoteConfig, host: value } }); + saveCliproxyServerConfig({ remote: { ...remoteConfig, host: value } }); } setEditedHost(null); }; @@ -1367,7 +1367,7 @@ function ProxyContent({ const savePort = () => { const port = parseInt(editedPort ?? displayPort, 10); if (!isNaN(port) && port !== config?.remote.port) { - saveProxyConfig({ remote: { ...remoteConfig, port } }); + saveCliproxyServerConfig({ remote: { ...remoteConfig, port } }); } setEditedPort(null); }; @@ -1375,7 +1375,7 @@ function ProxyContent({ const saveAuthToken = () => { const value = editedAuthToken ?? displayAuthToken; if (value !== config?.remote.auth_token) { - saveProxyConfig({ remote: { ...remoteConfig, auth_token: value } }); + saveCliproxyServerConfig({ remote: { ...remoteConfig, auth_token: value } }); } setEditedAuthToken(null); }; @@ -1383,7 +1383,7 @@ function ProxyContent({ const saveLocalPort = () => { const port = parseInt(editedLocalPort ?? displayLocalPort, 10); if (!isNaN(port) && port !== config?.local.port) { - saveProxyConfig({ local: { ...localConfig, port } }); + saveCliproxyServerConfig({ local: { ...localConfig, port } }); } setEditedLocalPort(null); }; @@ -1426,7 +1426,9 @@ function ProxyContent({
{/* Local Mode Card */}