diff --git a/CLAUDE.md b/CLAUDE.md index f8a9d07b..a8aaa564 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -131,6 +131,7 @@ bun run validate # Step 3: Final check (must pass) | `ccs copilot --help` | `src/commands/copilot-command.ts` → `handleHelp()` | | `ccs doctor --help` | `src/commands/doctor-command.ts` → `showHelp()` | | `ccs migrate --help` | `src/commands/migrate-command.ts` → `printMigrateHelp()` | +| `ccs env --help` | `src/commands/env-command.ts` → `showHelp()` | | `ccs persist --help` | `src/commands/persist-command.ts` → `showHelp()` | | `ccs setup --help` | `src/commands/setup-command.ts` → `showHelp()` | diff --git a/bun.lock b/bun.lock index e9396b8b..fd68ef78 100644 --- a/bun.lock +++ b/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "@kaitranntt/ccs", diff --git a/docs/codebase-summary.md b/docs/codebase-summary.md index 4ef7fc7b..43fcb828 100644 --- a/docs/codebase-summary.md +++ b/docs/codebase-summary.md @@ -48,6 +48,7 @@ src/ │ ├── config-command.ts # Config management commands │ ├── config-image-analysis-command.ts # Image analysis hook config (NEW v7.34) │ ├── doctor-command.ts # Health diagnostics +│ ├── env-command.ts # Export shell env vars for third-party tools (v7.39) │ ├── help-command.ts # Help text generation │ ├── install-command.ts # Install/uninstall logic │ ├── shell-completion-command.ts @@ -465,10 +466,12 @@ export type { ProviderEditorProps } from './provider-editor'; ``` tests/ -├── unit/ # Unit tests (6 core test files) +├── unit/ # Unit tests (7 core test files) │ ├── data-aggregator.test.ts │ ├── cliproxy/ │ │ └── remote-proxy-client.test.ts +│ ├── commands/ +│ │ └── env-command.test.ts │ ├── jsonl-parser.test.ts │ ├── model-pricing.test.ts │ ├── unified-config.test.ts @@ -487,12 +490,12 @@ tests/ | Metric | Value | |--------|-------| -| Total Tests | 1407 | -| Passing | 1407 | +| Total Tests | 1440 | +| Passing | 1440 | | Skipped | 6 | | Failed | 0 | | Coverage Threshold | 90% | -| Test Files | 40+ | +| Test Files | 41 | --- diff --git a/docs/project-overview-pdr.md b/docs/project-overview-pdr.md index 2fd44000..f6384588 100644 --- a/docs/project-overview-pdr.md +++ b/docs/project-overview-pdr.md @@ -115,6 +115,13 @@ CCS provides: - Entrypoint with privilege dropping and usage help - Environment variable configuration support +### FR-011: Third-Party Tool Integration +- Export shell-evaluable env vars via `ccs env` command +- Support OpenAI, Anthropic, raw output formats +- Auto-detect shell (bash/zsh, fish, PowerShell) from $SHELL +- Security: single-quoted output, key sanitization, shell-specific escaping +- Cross-platform compatibility (macOS, Linux, Windows) + --- ## Non-Functional Requirements @@ -192,7 +199,7 @@ CCS provides: | Startup time | < 100ms | Achieved | | Dashboard load | < 2s | Achieved | | Error rate | < 1% | Achieved | -| Test coverage | > 90% | 90% (1407 tests, 6 skipped) | +| Test coverage | > 90% | 90% (1440 tests, 6 skipped) | | File size compliance | 100% < 200 lines | 95% | --- @@ -261,6 +268,16 @@ CCS provides: - [x] Quota 429 rate limit handling improvements - [x] WebSocket maxPayload limit (DoS prevention) +### v7.39 Release (Complete) +- [x] `ccs env` command for third-party tool integration (OpenCode, Cursor, Continue) +- [x] Multi-format output: openai, anthropic, raw +- [x] Multi-shell support: bash/zsh, fish, PowerShell (auto-detected) +- [x] CLIProxy profile support (gemini, codex, agy, qwen) +- [x] Settings profile support (glm, kimi, custom API) +- [x] Security: single-quoted output, key sanitization, shell-specific escaping +- [x] Shell completion updated (bash, zsh, fish, PowerShell) +- [x] 34 unit tests for env command + ### v8.0 Release (Planned - Q1 2026) - [ ] Multiple CLIProxyAPI instances (load balancing, failover) - [ ] Native git worktree support diff --git a/docs/project-roadmap.md b/docs/project-roadmap.md index 2aa857fc..7f257d11 100644 --- a/docs/project-roadmap.md +++ b/docs/project-roadmap.md @@ -26,13 +26,14 @@ All major modularization work is complete. The codebase evolved from monolithic | 12 | Hybrid Quota Management | `quota-manager.ts`, `quota-fetcher.ts` (v7.14) | | 13 | Docker Support | `docker/` directory with Dockerfile, Compose, entrypoint | | 14 | Image Analysis Hook | Vision proxying via CLIProxy transformers (v7.34) | +| 15 | Third-Party Tool Integration | `ccs env` command with multi-format export (v7.39) | **Metrics Achieved**: - Files >500 lines: 12 -> 5 (-58%) - UI files >200 lines: 28 -> 8 (-71%) - Barrel exports: 5 -> 39 (+680%) - Test coverage: 0% -> 90% -- Total tests: 1407 (6 skipped) +- Total tests: 1440 (6 skipped) --- @@ -170,6 +171,7 @@ worktrees: | Hybrid Quota Management | COMPLETE | v7.14 | | Docker Support (PR #345) | COMPLETE | v7.23 | | Image Analysis Hook | COMPLETE | v7.34 | +| Third-Party Tool Integration | COMPLETE | v7.39 | | Critical Bug Fixes (#158, #155, #124) | PLANNED | Q1 2026 | | Multiple CLIProxyAPI Instances | PLANNED | Q1 2026 | | Git Worktree Support | PLANNED | Q2 2026 | diff --git a/docs/system-architecture.md b/docs/system-architecture.md index cca04f5b..10b55a2a 100644 --- a/docs/system-architecture.md +++ b/docs/system-architecture.md @@ -116,9 +116,10 @@ CCS v7.34 adds Image Analysis Hook for vision model proxying through CLIProxy wi | commands/ | | auth/ | | config/ | |------------------| |------------------| |------------------| | doctor-command | | account-switcher | | unified-config- | - | help-command | | profile-detector | | loader | - | install-command | | commands/ | | migration-manager| - | sync-command | +------------------+ +------------------+ + | env-command | | profile-detector | | loader | + | help-command | | commands/ | | migration-manager| + | install-command | +------------------+ +------------------+ + | sync-command | | update-command | +------------------+ | | | diff --git a/package.json b/package.json index 8b777e2c..0a844cda 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.40.0", + "version": "7.40.0-dev.6", "description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6", "keywords": [ "cli", diff --git a/scripts/completion/ccs.bash b/scripts/completion/ccs.bash index d0d13d33..b9eb965e 100644 --- a/scripts/completion/ccs.bash +++ b/scripts/completion/ccs.bash @@ -18,7 +18,7 @@ _ccs_completion() { # Top-level completion (first argument) if [[ ${COMP_CWORD} -eq 1 ]]; then - local commands="auth api cliproxy doctor sync update" + local commands="auth api cliproxy doctor env sync update" local flags="--help --version --shell-completion -h -v -sc" local cliproxy_profiles="gemini codex agy qwen" local profiles="" @@ -151,6 +151,33 @@ _ccs_completion() { esac fi + # env subcommands + if [[ ${COMP_WORDS[1]} == "env" ]]; then + case "${prev}" in + env) + # Complete with profile names and flags (inline profiles since $cliproxy_profiles is out of scope) + local env_opts="--format --shell --help -h gemini codex agy qwen iflow kiro ghcp claude" + if [[ -f ~/.ccs/config.json ]]; then + env_opts="$env_opts $(jq -r '.profiles | keys[]' ~/.ccs/config.json 2>/dev/null || true)" + fi + COMPREPLY=( $(compgen -W "${env_opts}" -- ${cur}) ) + return 0 + ;; + --format) + COMPREPLY=( $(compgen -W "openai anthropic raw" -- ${cur}) ) + return 0 + ;; + --shell) + COMPREPLY=( $(compgen -W "auto bash zsh fish powershell" -- ${cur}) ) + return 0 + ;; + *) + COMPREPLY=( $(compgen -W "--format --shell --help -h" -- ${cur}) ) + return 0 + ;; + esac + fi + # Flags for doctor command if [[ ${COMP_WORDS[1]} == "doctor" ]]; then COMPREPLY=( $(compgen -W "--help -h" -- ${cur}) ) diff --git a/scripts/completion/ccs.fish b/scripts/completion/ccs.fish index cb058cc8..ea58c2d7 100644 --- a/scripts/completion/ccs.fish +++ b/scripts/completion/ccs.fish @@ -121,33 +121,34 @@ complete -c ccs -s v -l version -d 'Show version information' complete -c ccs -s sc -l shell-completion -d 'Install shell completion' # Commands - grouped with [cmd] prefix for visual distinction -complete -c ccs -n 'not __fish_seen_subcommand_from auth api cliproxy doctor sync update gemini codex agy qwen' -a 'auth' -d '[cmd] Manage multiple Claude accounts' -complete -c ccs -n 'not __fish_seen_subcommand_from auth api cliproxy doctor sync update gemini codex agy qwen' -a 'api' -d '[cmd] Manage API profiles (create/remove)' -complete -c ccs -n 'not __fish_seen_subcommand_from auth api cliproxy doctor sync update gemini codex agy qwen' -a 'cliproxy' -d '[cmd] Manage CLIProxy variants and binary' -complete -c ccs -n 'not __fish_seen_subcommand_from auth api cliproxy doctor sync update gemini codex agy qwen' -a 'doctor' -d '[cmd] Run health check and diagnostics' -complete -c ccs -n 'not __fish_seen_subcommand_from auth api cliproxy doctor sync update gemini codex agy qwen' -a 'sync' -d '[cmd] Sync delegation commands and skills' -complete -c ccs -n 'not __fish_seen_subcommand_from auth api cliproxy doctor sync update gemini codex agy qwen' -a 'update' -d '[cmd] Update CCS to latest version' +complete -c ccs -n 'not __fish_seen_subcommand_from auth api cliproxy doctor env sync update gemini codex agy qwen' -a 'auth' -d '[cmd] Manage multiple Claude accounts' +complete -c ccs -n 'not __fish_seen_subcommand_from auth api cliproxy doctor env sync update gemini codex agy qwen' -a 'api' -d '[cmd] Manage API profiles (create/remove)' +complete -c ccs -n 'not __fish_seen_subcommand_from auth api cliproxy doctor env sync update gemini codex agy qwen' -a 'cliproxy' -d '[cmd] Manage CLIProxy variants and binary' +complete -c ccs -n 'not __fish_seen_subcommand_from auth api cliproxy doctor env sync update gemini codex agy qwen' -a 'doctor' -d '[cmd] Run health check and diagnostics' +complete -c ccs -n 'not __fish_seen_subcommand_from auth api cliproxy doctor env sync update gemini codex agy qwen' -a 'env' -d '[cmd] Export env vars for third-party tools' +complete -c ccs -n 'not __fish_seen_subcommand_from auth api cliproxy doctor env sync update gemini codex agy qwen' -a 'sync' -d '[cmd] Sync delegation commands and skills' +complete -c ccs -n 'not __fish_seen_subcommand_from auth api cliproxy doctor env sync update gemini codex agy qwen' -a 'update' -d '[cmd] Update CCS to latest version' # CLIProxy profiles - grouped with [proxy] prefix for OAuth providers -complete -c ccs -n 'not __fish_seen_subcommand_from auth api cliproxy doctor sync update gemini codex agy qwen' -a 'gemini' -d '[proxy] Google Gemini (OAuth)' -complete -c ccs -n 'not __fish_seen_subcommand_from auth api cliproxy doctor sync update gemini codex agy qwen' -a 'codex' -d '[proxy] OpenAI Codex (OAuth)' -complete -c ccs -n 'not __fish_seen_subcommand_from auth api cliproxy doctor sync update gemini codex agy qwen' -a 'agy' -d '[proxy] Antigravity (OAuth)' -complete -c ccs -n 'not __fish_seen_subcommand_from auth api cliproxy doctor sync update gemini codex agy qwen' -a 'qwen' -d '[proxy] Qwen Code (OAuth)' +complete -c ccs -n 'not __fish_seen_subcommand_from auth api cliproxy doctor env sync update gemini codex agy qwen' -a 'gemini' -d '[proxy] Google Gemini (OAuth)' +complete -c ccs -n 'not __fish_seen_subcommand_from auth api cliproxy doctor env sync update gemini codex agy qwen' -a 'codex' -d '[proxy] OpenAI Codex (OAuth)' +complete -c ccs -n 'not __fish_seen_subcommand_from auth api cliproxy doctor env sync update gemini codex agy qwen' -a 'agy' -d '[proxy] Antigravity (OAuth)' +complete -c ccs -n 'not __fish_seen_subcommand_from auth api cliproxy doctor env sync update gemini codex agy qwen' -a 'qwen' -d '[proxy] Qwen Code (OAuth)' # Model profiles - grouped with [model] prefix for visual distinction -complete -c ccs -n 'not __fish_seen_subcommand_from auth api cliproxy doctor sync update gemini codex agy qwen' -a 'default' -d '[model] Default Claude Sonnet 4.5' -complete -c ccs -n 'not __fish_seen_subcommand_from auth api cliproxy doctor sync update gemini codex agy qwen' -a 'glm' -d '[model] GLM-4.6 (cost-optimized)' -complete -c ccs -n 'not __fish_seen_subcommand_from auth api cliproxy doctor sync update gemini codex agy qwen' -a 'glmt' -d '[model] GLM-4.6 with thinking mode' -complete -c ccs -n 'not __fish_seen_subcommand_from auth api cliproxy doctor sync update gemini codex agy qwen' -a 'kimi' -d '[model] Kimi for Coding (long-context)' +complete -c ccs -n 'not __fish_seen_subcommand_from auth api cliproxy doctor env sync update gemini codex agy qwen' -a 'default' -d '[model] Default Claude Sonnet 4.5' +complete -c ccs -n 'not __fish_seen_subcommand_from auth api cliproxy doctor env sync update gemini codex agy qwen' -a 'glm' -d '[model] GLM-4.6 (cost-optimized)' +complete -c ccs -n 'not __fish_seen_subcommand_from auth api cliproxy doctor env sync update gemini codex agy qwen' -a 'glmt' -d '[model] GLM-4.6 with thinking mode' +complete -c ccs -n 'not __fish_seen_subcommand_from auth api cliproxy doctor env sync update gemini codex agy qwen' -a 'kimi' -d '[model] Kimi for Coding (long-context)' # Custom model profiles - dynamic with [model] prefix -complete -c ccs -n 'not __fish_seen_subcommand_from auth api cliproxy doctor sync update gemini codex agy qwen' -a '(__fish_ccs_get_custom_settings_profiles)' -d '[model] Settings-based profile' +complete -c ccs -n 'not __fish_seen_subcommand_from auth api cliproxy doctor env sync update gemini codex agy qwen' -a '(__fish_ccs_get_custom_settings_profiles)' -d '[model] Settings-based profile' # CLIProxy variants - dynamic with [variant] prefix -complete -c ccs -n 'not __fish_seen_subcommand_from auth api cliproxy doctor sync update gemini codex agy qwen' -a '(__fish_ccs_get_cliproxy_variants)' -d '[variant] CLIProxy variant' +complete -c ccs -n 'not __fish_seen_subcommand_from auth api cliproxy doctor env sync update gemini codex agy qwen' -a '(__fish_ccs_get_cliproxy_variants)' -d '[variant] CLIProxy variant' # Account profiles - dynamic with [account] prefix -complete -c ccs -n 'not __fish_seen_subcommand_from auth api cliproxy doctor sync update gemini codex agy qwen' -a '(__fish_ccs_get_account_profiles)' -d '[account] Account-based profile' +complete -c ccs -n 'not __fish_seen_subcommand_from auth api cliproxy doctor env sync update gemini codex agy qwen' -a '(__fish_ccs_get_account_profiles)' -d '[account] Account-based profile' # shell-completion subflags complete -c ccs -n '__fish_seen_argument -l shell-completion; or __fish_seen_argument -s sc' -l bash -d 'Install for bash' @@ -171,6 +172,14 @@ complete -c ccs -n '__fish_seen_subcommand_from update' -s h -l help -d 'Show he # doctor command flags complete -c ccs -n '__fish_seen_subcommand_from doctor' -s h -l help -d 'Show help for doctor command' +# env command completions +complete -c ccs -n '__fish_seen_subcommand_from env; and not __fish_seen_argument -l format -l shell' -a 'gemini codex agy qwen iflow kiro ghcp claude' -d '[proxy] CLIProxy profile' +complete -c ccs -n '__fish_seen_subcommand_from env' -l format -d 'Output format' +complete -c ccs -n '__fish_seen_subcommand_from env; and __fish_seen_argument -l format' -a 'openai anthropic raw' -d 'Format' +complete -c ccs -n '__fish_seen_subcommand_from env' -l shell -d 'Shell syntax' +complete -c ccs -n '__fish_seen_subcommand_from env; and __fish_seen_argument -l shell' -a 'auto bash zsh fish powershell' -d 'Shell' +complete -c ccs -n '__fish_seen_subcommand_from env' -s h -l help -d 'Show help for env command' + # ============================================================================ # auth subcommands # ============================================================================ diff --git a/scripts/completion/ccs.ps1 b/scripts/completion/ccs.ps1 index eb640cbf..500cf988 100644 --- a/scripts/completion/ccs.ps1 +++ b/scripts/completion/ccs.ps1 @@ -12,8 +12,8 @@ Register-ArgumentCompleter -CommandName ccs -ScriptBlock { param($commandName, $wordToComplete, $commandAst, $fakeBoundParameters) - $commands = @('auth', 'api', 'cliproxy', 'doctor', 'sync', 'update', '--help', '--version', '--shell-completion', '-h', '-v', '-sc') - $cliproxyProfiles = @('gemini', 'codex', 'agy', 'qwen') + $commands = @('auth', 'api', 'cliproxy', 'doctor', 'env', 'sync', 'update', '--help', '--version', '--shell-completion', '-h', '-v', '-sc') + $cliproxyProfiles = @('gemini', 'codex', 'agy', 'qwen', 'iflow', 'kiro', 'ghcp', 'claude') $authCommands = @('create', 'list', 'show', 'remove', 'default', '--help', '-h') $apiCommands = @('create', 'list', 'remove', '--help', '-h') $cliproxyCommands = @('create', 'list', 'remove', '--install', '--latest', '--help', '-h') @@ -21,6 +21,9 @@ Register-ArgumentCompleter -CommandName ccs -ScriptBlock { $cliproxyCreateFlags = @('--provider', '--model', '--force', '--yes', '-y') $providerFlags = @('--auth', '--config', '--logout', '--headless', '--help', '-h') $updateFlags = @('--force', '--beta', '--dev', '--help', '-h') + $envFlags = @('--format', '--shell', '--help', '-h') + $envFormats = @('openai', 'anthropic', 'raw') + $envShells = @('auto', 'bash', 'zsh', 'fish', 'powershell') $shellCompletionFlags = @('--bash', '--zsh', '--fish', '--powershell') $listFlags = @('--verbose', '--json') $removeFlags = @('--yes', '-y') @@ -130,6 +133,55 @@ Register-ArgumentCompleter -CommandName ccs -ScriptBlock { return } + # env command completion + if ($words[1] -eq 'env') { + if ($position -eq 3) { + $options = $cliproxyProfiles + (Get-CcsProfiles -Type settings) + $envFlags + $options | Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object { + [System.Management.Automation.CompletionResult]::new( + $_, + $_, + 'ParameterValue', + $_ + ) + } + } elseif ($position -ge 4) { + switch ($words[$position - 2]) { + '--format' { + $envFormats | Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object { + [System.Management.Automation.CompletionResult]::new( + $_, + $_, + 'ParameterValue', + $_ + ) + } + } + '--shell' { + $envShells | Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object { + [System.Management.Automation.CompletionResult]::new( + $_, + $_, + 'ParameterValue', + $_ + ) + } + } + default { + $envFlags | Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object { + [System.Management.Automation.CompletionResult]::new( + $_, + $_, + 'ParameterValue', + $_ + ) + } + } + } + } + return + } + # auth subcommand completion if ($words[1] -eq 'auth') { if ($position -eq 3) { diff --git a/scripts/completion/ccs.zsh b/scripts/completion/ccs.zsh index cb0ef587..fb4f9f36 100644 --- a/scripts/completion/ccs.zsh +++ b/scripts/completion/ccs.zsh @@ -13,7 +13,7 @@ # sudo cp scripts/completion/ccs.zsh /usr/local/share/zsh/site-functions/_ccs # Set up completion styles for better formatting and colors -zstyle ':completion:*:*:ccs:*:commands' list-colors '=(#b)(auth|api|cliproxy|doctor|sync|update)([[:space:]]#--[[:space:]]#*)==0\;34=2\;37' +zstyle ':completion:*:*:ccs:*:commands' list-colors '=(#b)(auth|api|cliproxy|doctor|env|sync|update)([[:space:]]#--[[:space:]]#*)==0\;34=2\;37' zstyle ':completion:*:*:ccs:*:proxy-profiles' list-colors '=(#b)(gemini|codex|agy|qwen)([[:space:]]#--[[:space:]]#*)==0\;35=2\;37' zstyle ':completion:*:*:ccs:*:model-profiles' list-colors '=(#b)(default|glm|glmt|kimi|[^[:space:]]##)([[:space:]]#--[[:space:]]#*)==0\;32=2\;37' zstyle ':completion:*:*:ccs:*:account-profiles' list-colors '=(#b)([^[:space:]]##)([[:space:]]#--[[:space:]]#*)==0\;33=2\;37' @@ -34,6 +34,7 @@ _ccs() { 'api:Manage API profiles (create/remove)' 'cliproxy:Manage CLIProxy variants and binary' 'doctor:Run health check and diagnostics' + 'env:Export env vars for third-party tools' 'sync:Sync delegation commands and skills' 'update:Update CCS to latest version' ) @@ -44,6 +45,10 @@ _ccs() { 'codex:OpenAI Codex (OAuth)' 'agy:Antigravity (OAuth)' 'qwen:Qwen Code (OAuth)' + 'iflow:iFlow (OAuth)' + 'kiro:Kiro (OAuth)' + 'ghcp:GitHub Copilot (OAuth)' + 'claude:Claude Direct (OAuth)' ) # Define known settings profiles with descriptions @@ -124,6 +129,13 @@ _ccs() { _arguments \ '(- *)'{-h,--help}'[Show help for doctor command]' ;; + env) + _arguments \ + '--format[Output format]:format:(openai anthropic raw)' \ + '--shell[Shell syntax]:shell:(auto bash zsh fish powershell)' \ + '(- *)'{-h,--help}'[Show help]' \ + '1:profile:($proxy_profiles ${(k)settings_profiles_described})' + ;; gemini|codex|agy|qwen) _arguments \ '--auth[Authenticate only]' \ diff --git a/src/api/services/openrouter-catalog.ts b/src/api/services/openrouter-catalog.ts index b6b2d6ff..bfcb784e 100644 --- a/src/api/services/openrouter-catalog.ts +++ b/src/api/services/openrouter-catalog.ts @@ -5,10 +5,12 @@ import * as fs from 'fs'; import * as path from 'path'; -import * as os from 'os'; +import { getCcsDir } from '../../utils/config-manager'; const OPENROUTER_API_URL = 'https://openrouter.ai/api/v1/models'; -const CACHE_FILE = path.join(os.homedir(), '.ccs', 'openrouter-models-cache.json'); +function getCacheFile() { + return path.join(getCcsDir(), 'openrouter-models-cache.json'); +} const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours export interface OpenRouterModel { @@ -30,8 +32,9 @@ interface CacheData { /** Check if cached data is valid */ function getCachedModels(): OpenRouterModel[] | null { try { - if (!fs.existsSync(CACHE_FILE)) return null; - const data = JSON.parse(fs.readFileSync(CACHE_FILE, 'utf8')) as CacheData; + const cacheFile = getCacheFile(); + if (!fs.existsSync(cacheFile)) return null; + const data = JSON.parse(fs.readFileSync(cacheFile, 'utf8')) as CacheData; if (Date.now() - data.fetchedAt > CACHE_TTL_MS) return null; return data.models; } catch { @@ -42,10 +45,11 @@ function getCachedModels(): OpenRouterModel[] | null { /** Save models to cache */ function setCachedModels(models: OpenRouterModel[]): void { try { - const dir = path.dirname(CACHE_FILE); + const cacheFile = getCacheFile(); + const dir = path.dirname(cacheFile); if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); fs.writeFileSync( - CACHE_FILE, + cacheFile, JSON.stringify({ models, fetchedAt: Date.now(), diff --git a/src/ccs.ts b/src/ccs.ts index 5150ff00..851650bd 100644 --- a/src/ccs.ts +++ b/src/ccs.ts @@ -2,7 +2,13 @@ import { spawn, ChildProcess } from 'child_process'; import * as path from 'path'; import * as fs from 'fs'; import { detectClaudeCli } from './utils/claude-detector'; -import { getSettingsPath, loadSettings } from './utils/config-manager'; +import { + getSettingsPath, + loadSettings, + getCcsDir, + setGlobalConfigDir, + detectCloudSyncPath, +} from './utils/config-manager'; import { validateGlmKey, validateMiniMaxKey } from './utils/api-key-validator'; import { ErrorManager } from './utils/error-manager'; import { execClaudeWithCLIProxy, CLIProxyProvider } from './cliproxy'; @@ -15,7 +21,7 @@ import { import { getGlobalEnvConfig } from './config/unified-config-loader'; import { ensureProfileHooks as ensureImageAnalyzerHooks } from './utils/hooks/image-analyzer-profile-hook-injector'; import { getImageAnalysisHookEnv } from './utils/hooks'; -import { fail, info } from './utils/ui'; +import { fail, info, warn } from './utils/ui'; // Import centralized error handling import { handleError, runCleanup } from './errors'; @@ -80,7 +86,7 @@ async function execClaudeWithProxy( if (!apiKey || apiKey === 'YOUR_GLM_API_KEY_HERE') { console.error(fail('GLMT profile requires Z.AI API key')); - console.error(' Edit ~/.ccs/glmt.settings.json and set ANTHROPIC_AUTH_TOKEN'); + console.error(` Edit ${getCcsDir()}/glmt.settings.json and set ANTHROPIC_AUTH_TOKEN`); process.exit(1); } @@ -148,7 +154,7 @@ async function execClaudeWithProxy( console.error('Workarounds:'); console.error(' - Use non-thinking mode: ccs glm "prompt"'); console.error(' - Enable verbose logging: ccs glmt --verbose "prompt"'); - console.error(' - Check proxy logs in ~/.ccs/logs/ (if debug enabled)'); + console.error(` - Check proxy logs in ${getCcsDir()}/logs/ (if debug enabled)`); console.error(''); proxy.kill(); process.exit(1); @@ -259,7 +265,6 @@ async function showCachedUpdateNotification(): Promise { async function main(): Promise { const args = process.argv.slice(2); - const firstArg = args[0]; // Initialize UI colors early to ensure consistent colored output // Must happen before any status messages (ok, info, fail, etc.) @@ -268,6 +273,69 @@ async function main(): Promise { await initUI(); } + // Parse --config-dir flag (must happen before any config loading) + const configDirIdx = args.findIndex((a) => a === '--config-dir' || a.startsWith('--config-dir=')); + if (configDirIdx !== -1) { + const arg = args[configDirIdx]; + let configDirValue: string | undefined; + let spliceCount = 1; + + if (arg.startsWith('--config-dir=')) { + configDirValue = arg.split('=').slice(1).join('='); + } else { + configDirValue = args[configDirIdx + 1]; + spliceCount = 2; + } + + if (!configDirValue || configDirValue.startsWith('-')) { + console.error(fail('--config-dir requires a path argument')); + process.exit(1); + } + + try { + const stat = fs.statSync(configDirValue); + if (!stat.isDirectory()) { + console.error(fail(`Not a directory: ${configDirValue}`)); + process.exit(1); + } + } catch { + console.error(fail(`Config directory not found: ${configDirValue}`)); + console.error(info('Create the directory first, then copy your config files into it.')); + process.exit(1); + } + + setGlobalConfigDir(configDirValue); + + // Security warning: cloud sync paths expose OAuth tokens + const cloudService = detectCloudSyncPath(configDirValue); + if (cloudService) { + console.error(warn(`CCS directory is under ${cloudService}.`)); + console.error(' OAuth tokens in cliproxy/auth/ will be synced to cloud.'); + console.error(' Consider: CCS_DIR=/path/outside/cloud ccs ...'); + } + + // Remove consumed args so they don't leak to Claude CLI + args.splice(configDirIdx, spliceCount); + } else if (process.env.CCS_DIR) { + // Also warn for CCS_DIR env var pointing to cloud sync + const cloudService = detectCloudSyncPath(process.env.CCS_DIR); + if (cloudService) { + console.error(warn(`CCS directory is under ${cloudService}.`)); + console.error(' OAuth tokens in cliproxy/auth/ will be synced to cloud.'); + console.error(' Consider: CCS_DIR=/path/outside/cloud ccs ...'); + } + } else if (process.env.CCS_HOME) { + // Also warn for CCS_HOME env var pointing to cloud sync + const cloudService = detectCloudSyncPath(process.env.CCS_HOME); + if (cloudService) { + console.error(warn(`CCS directory is under ${cloudService}.`)); + console.error(' OAuth tokens in cliproxy/auth/ will be synced to cloud.'); + console.error(' Consider: CCS_DIR=/path/outside/cloud ccs ...'); + } + } + + const firstArg = args[0]; + // Trigger update check early for ALL commands except version/help/update // Only if TTY and not CI to avoid noise in automated environments const skipUpdateCheck = [ @@ -450,6 +518,13 @@ async function main(): Promise { return; } + // Special case: env command (export env vars for third-party tools) + if (firstArg === 'env') { + const { handleEnvCommand } = await import('./commands/env-command'); + await handleEnvCommand(args.slice(1)); + return; + } + // Special case: setup command (first-time wizard) if (firstArg === 'setup' || firstArg === '--setup') { const { handleSetupCommand } = await import('./commands/setup-command'); diff --git a/src/cliproxy/auth/oauth-process.ts b/src/cliproxy/auth/oauth-process.ts index 9f6fb39d..ba157784 100644 --- a/src/cliproxy/auth/oauth-process.ts +++ b/src/cliproxy/auth/oauth-process.ts @@ -7,6 +7,7 @@ import { spawn, ChildProcess } from 'child_process'; import { ok, fail, info, warn } from '../../utils/ui'; +import { killWithEscalation } from '../../utils/process-utils'; import { tryKiroImport } from './kiro-import'; import { CLIProxyProvider } from '../types'; import { AccountInfo } from '../account-manager'; @@ -333,8 +334,8 @@ export function executeOAuthProcess(options: OAuthProcessOptions): Promise { if (stdinKeepalive) clearInterval(stdinKeepalive); - if (authProcess && !authProcess.killed) { - authProcess.kill('SIGTERM'); + if (authProcess && authProcess.exitCode === null) { + killWithEscalation(authProcess); } }; process.on('SIGINT', cleanup); @@ -358,9 +359,9 @@ export function executeOAuthProcess(options: OAuthProcessOptions): Promise { - if (cancelledSessionId === state.sessionId && authProcess && !authProcess.killed) { + if (cancelledSessionId === state.sessionId && authProcess && authProcess.exitCode === null) { log('Session cancelled externally'); - authProcess.kill('SIGTERM'); + killWithEscalation(authProcess); } }; authSessionEvents.on('session:cancelled', handleCancel); @@ -425,7 +426,8 @@ export function executeOAuthProcess(options: OAuthProcessOptions): Promise { // H7: Clear stdin keepalive interval if (stdinKeepalive) clearInterval(stdinKeepalive); @@ -435,9 +437,9 @@ export function executeOAuthProcess(options: OAuthProcessOptions): Promise { return new Promise((resolve, reject) => { let resolved = false; @@ -203,10 +205,14 @@ export function downloadFile( cleanup(new Error('Redirect without location header')); return; } + if (maxRedirects <= 0) { + cleanup(new Error('Too many redirects')); + return; + } if (verbose) { console.error(`[cliproxy] Following redirect: ${redirectUrl}`); } - downloadFile(redirectUrl, destPath, onProgress, verbose, timeout) + downloadFile(redirectUrl, destPath, onProgress, verbose, timeout, maxRedirects - 1) .then(resolve) .catch(reject); return; @@ -340,7 +346,12 @@ export async function downloadWithRetry( /** * Fetch text content from URL (single attempt) */ -function fetchTextOnce(url: string, verbose = false, timeout = 30000): Promise { +function fetchTextOnce( + url: string, + verbose = false, + timeout = 30000, + maxRedirects = 10 +): Promise { return new Promise((resolve, reject) => { let resolved = false; @@ -352,7 +363,13 @@ function fetchTextOnce(url: string, verbose = false, timeout = 30000): Promise> { return new Promise((resolve, reject) => { let resolved = false; @@ -456,7 +474,13 @@ function fetchJsonOnce( reject(new Error('Redirect without location header')); return; } - fetchJsonOnce(redirectUrl, verbose, timeout).then(resolve).catch(reject); + if (maxRedirects <= 0) { + reject(new Error('Too many redirects')); + return; + } + fetchJsonOnce(redirectUrl, verbose, timeout, maxRedirects - 1) + .then(resolve) + .catch(reject); return; } diff --git a/src/cliproxy/executor/index.ts b/src/cliproxy/executor/index.ts index 595bc07a..986868fb 100644 --- a/src/cliproxy/executor/index.ts +++ b/src/cliproxy/executor/index.ts @@ -13,8 +13,10 @@ import { spawn, ChildProcess } from 'child_process'; import * as fs from 'fs'; import * as os from 'os'; +import * as path from 'path'; import { ProgressIndicator } from '../../utils/progress-indicator'; import { ok, fail, info, warn } from '../../utils/ui'; +import { getCcsDir } from '../../utils/config-manager'; import { escapeShellArg } from '../../utils/shell-executor'; import { ensureCLIProxyBinary } from '../binary-manager'; import { @@ -655,7 +657,7 @@ export async function execClaudeWithCLIProxy( upstreamBaseUrl: postSanitizationBaseUrl, verbose, defaultEffort: 'medium', - traceFilePath: traceEnabled ? `${os.homedir()}/.ccs/codex-reasoning-proxy.log` : '', + traceFilePath: traceEnabled ? path.join(getCcsDir(), 'codex-reasoning-proxy.log') : '', modelMap: { defaultModel: initialEnvVars.ANTHROPIC_MODEL, opusModel: initialEnvVars.ANTHROPIC_DEFAULT_OPUS_MODEL, diff --git a/src/cliproxy/model-config.ts b/src/cliproxy/model-config.ts index 2da1cc63..1442a93a 100644 --- a/src/cliproxy/model-config.ts +++ b/src/cliproxy/model-config.ts @@ -7,15 +7,12 @@ import * as fs from 'fs'; import * as os from 'os'; -import * as path from 'path'; import { InteractivePrompt } from '../utils/prompt'; import { getProviderCatalog, supportsModelConfig, ModelEntry } from './model-catalog'; import { getProviderSettingsPath, getClaudeEnvVars } from './config-generator'; import { CLIProxyProvider } from './types'; import { initUI, color, bold, dim, ok, info, header } from '../utils/ui'; - -/** CCS directory */ -const CCS_DIR = path.join(os.homedir(), '.ccs'); +import { getCcsDir } from '../utils/config-manager'; /** * Check if provider has user settings configured @@ -185,8 +182,9 @@ export async function configureProviderModel( }; // Ensure CCS directory exists - if (!fs.existsSync(CCS_DIR)) { - fs.mkdirSync(CCS_DIR, { recursive: true }); + const ccsDir = getCcsDir(); + if (!fs.existsSync(ccsDir)) { + fs.mkdirSync(ccsDir, { recursive: true }); } // Write settings file diff --git a/src/cliproxy/quota-manager.ts b/src/cliproxy/quota-manager.ts index a5943c6a..0aea4a1b 100644 --- a/src/cliproxy/quota-manager.ts +++ b/src/cliproxy/quota-manager.ts @@ -164,6 +164,26 @@ export function clearCooldown(provider: CLIProxyProvider, accountId: string): vo // PRE-FLIGHT CHECK // ============================================================================ +/** + * Process items with limited concurrency to prevent connection burst + * @param items - Items to process + * @param fn - Async function to apply to each item + * @param concurrency - Number of concurrent operations (default: 10) + */ +async function batchedMap( + items: T[], + fn: (item: T) => Promise, + concurrency = 10 +): Promise { + const results: R[] = []; + for (let i = 0; i < items.length; i += concurrency) { + const batch = items.slice(i, i + concurrency); + const batchResults = await Promise.all(batch.map(fn)); + results.push(...batchResults); + } + return results; +} + /** * Result of pre-flight quota check */ @@ -213,9 +233,10 @@ export async function findHealthyAccount( if (available.length === 0) return null; - // Fetch quota for each available account (with caching and deduplication) - const withQuotas = await Promise.all( - available.map(async (account) => { + // Fetch quota for each available account (batched to prevent connection burst) + const withQuotas = await batchedMap( + available, + async (account) => { let quota = getCachedQuota(provider, account.id); if (!quota) { quota = await fetchQuotaWithDedup(provider, account.id); @@ -228,7 +249,8 @@ export async function findHealthyAccount( tier: account.tier || 'unknown', lastQuota: avgQuota, }; - }) + }, + 10 ); // Filter by threshold diff --git a/src/cliproxy/schema-sanitizer.ts b/src/cliproxy/schema-sanitizer.ts index a63c2d6f..3c1203ac 100644 --- a/src/cliproxy/schema-sanitizer.ts +++ b/src/cliproxy/schema-sanitizer.ts @@ -1,78 +1,60 @@ /** * Schema Sanitizer * - * Sanitizes MCP tool input_schema to remove non-standard JSON Schema properties - * that Gemini/Vertex APIs reject. + * Sanitizes tool input_schema to only include fields supported by Google's + * Gemini/Vertex AI function_declarations Schema object. * - * MCP servers (especially design tools) include UI-specific metadata in schemas: - * - cornerRadius, fillColor, fontFamily, fontSize, fontWeight, gap, padding, etc. + * This sanitizer runs exclusively in the CLIProxy execution path (Gemini, Codex, + * Antigravity, etc.), never for direct Anthropic API requests. * - * These are valid as MCP hints but invalid for strict JSON Schema validation. + * Gemini supports a subset of OpenAPI 3.0 schema — fields outside this subset + * (like "examples", "$ref", "oneOf", etc.) cause 400 errors. + * + * Reference: https://ai.google.dev/api/rest/v1beta/cachedContents#Schema */ -/** Valid JSON Schema Draft-07 keywords (used by Anthropic/Gemini APIs) */ -const VALID_JSON_SCHEMA_KEYWORDS = new Set([ +/** + * Fields supported by Gemini's function_declarations Schema object. + * Source: https://ai.google.dev/api/rest/v1beta/cachedContents#Schema + */ +const GEMINI_SUPPORTED_SCHEMA_FIELDS = new Set([ // Core 'type', + 'format', + 'title', + 'description', + 'nullable', + 'example', + 'default', + + // Enum + 'enum', + + // Object 'properties', 'required', + 'minProperties', + 'maxProperties', + + // Array 'items', - 'enum', - 'const', - 'default', + 'minItems', + 'maxItems', // String validation 'minLength', 'maxLength', 'pattern', - 'format', // Number validation 'minimum', 'maximum', - 'exclusiveMinimum', - 'exclusiveMaximum', - 'multipleOf', - // Array validation - 'minItems', - 'maxItems', - 'uniqueItems', - 'contains', - 'additionalItems', - - // Object validation - 'additionalProperties', - 'patternProperties', - 'minProperties', - 'maxProperties', - 'propertyNames', - 'dependencies', - - // Composition - 'oneOf', + // Composition (only anyOf supported, NOT oneOf/allOf/not) 'anyOf', - 'allOf', - 'not', - 'if', - 'then', - 'else', - // Metadata (allowed by most APIs) - 'title', - 'description', - '$id', - '$schema', - '$ref', - '$defs', - 'definitions', - '$comment', - 'examples', - 'readOnly', - 'writeOnly', - 'deprecated', - 'contentMediaType', - 'contentEncoding', + // Gemini-specific (non-standard OpenAPI) + 'propertyOrdering', ]); /** Maximum recursion depth to prevent stack overflow */ @@ -88,15 +70,15 @@ export interface SchemaSanitizationResult { } /** - * Check if a key is a valid JSON Schema keyword. + * Check if a key is supported by Gemini's Schema object. */ function isValidSchemaKey(key: string): boolean { - return VALID_JSON_SCHEMA_KEYWORDS.has(key); + return GEMINI_SUPPORTED_SCHEMA_FIELDS.has(key); } /** * Recursively sanitize a JSON Schema object. - * Removes non-standard properties while preserving valid schema structure. + * Removes fields unsupported by Gemini while preserving valid schema structure. * * @param schema The schema object to sanitize * @param path Current path for logging (e.g., "properties.foo.items") @@ -167,115 +149,27 @@ function sanitizeSchemaRecursive( continue; } - if (key === 'additionalProperties' && typeof value === 'object') { - // Can be boolean or schema object - result[key] = sanitizeSchemaRecursive(value, keyPath, removedPaths, visited, depth + 1); - continue; - } - - if (key === 'additionalItems' && typeof value === 'object' && value !== null) { - // Can be boolean or schema object (tuple validation) - result[key] = sanitizeSchemaRecursive(value, keyPath, removedPaths, visited, depth + 1); - continue; - } - - // Composition keywords contain schema arrays - if (['oneOf', 'anyOf', 'allOf'].includes(key) && Array.isArray(value)) { + // anyOf is the only composition keyword Gemini supports + if (key === 'anyOf' && Array.isArray(value)) { result[key] = value.map((item, index) => sanitizeSchemaRecursive(item, `${keyPath}[${index}]`, removedPaths, visited, depth + 1) ); continue; } - if (key === 'not' && typeof value === 'object') { - result[key] = sanitizeSchemaRecursive(value, keyPath, removedPaths, visited, depth + 1); - continue; - } - - if (key === 'propertyNames' && typeof value === 'object') { - result[key] = sanitizeSchemaRecursive(value, keyPath, removedPaths, visited, depth + 1); - continue; - } - - // Conditional keywords - if (['if', 'then', 'else'].includes(key) && typeof value === 'object') { - result[key] = sanitizeSchemaRecursive(value, keyPath, removedPaths, visited, depth + 1); - continue; - } - - if (key === 'contains' && typeof value === 'object') { - result[key] = sanitizeSchemaRecursive(value, keyPath, removedPaths, visited, depth + 1); - continue; - } - - if (key === '$defs' || key === 'definitions') { - // Definition containers - if (typeof value === 'object' && value !== null) { - const sanitizedDefs: Record = {}; - for (const [defName, defSchema] of Object.entries(value as Record)) { - sanitizedDefs[defName] = sanitizeSchemaRecursive( - defSchema, - `${keyPath}.${defName}`, - removedPaths, - visited, - depth + 1 - ); - } - result[key] = sanitizedDefs; - continue; - } - } - - if (key === 'patternProperties') { - // Pattern property containers - preserve pattern keys and sanitize schema values - if (typeof value === 'object' && value !== null) { - const sanitizedPatterns: Record = {}; - for (const [pattern, patternSchema] of Object.entries(value as Record)) { - sanitizedPatterns[pattern] = sanitizeSchemaRecursive( - patternSchema, - `${keyPath}.${pattern}`, - removedPaths, - visited, - depth + 1 - ); - } - result[key] = sanitizedPatterns; - continue; - } - } - - if (key === 'dependencies') { - if (typeof value === 'object' && value !== null) { - const sanitizedDeps: Record = {}; - for (const [depName, depValue] of Object.entries(value as Record)) { - // Schema dependencies need recursion, property dependencies (arrays) pass through - if (typeof depValue === 'object' && depValue !== null && !Array.isArray(depValue)) { - sanitizedDeps[depName] = sanitizeSchemaRecursive( - depValue, - `${keyPath}.${depName}`, - removedPaths, - visited, - depth + 1 - ); - } else { - sanitizedDeps[depName] = depValue; - } - } - result[key] = sanitizedDeps; - continue; - } - } - - // Check if this is a valid JSON Schema keyword + // Check if this is a Gemini-supported field if (isValidSchemaKey(key)) { - // Recurse for nested objects that might contain schemas - if (typeof value === 'object' && value !== null && !Array.isArray(value)) { + // "example" and "default" hold arbitrary values, not nested schemas — pass through as-is + if (key === 'example' || key === 'default') { + result[key] = value; + } else if (typeof value === 'object' && value !== null && !Array.isArray(value)) { + // Recurse for nested objects that might contain schemas result[key] = sanitizeSchemaRecursive(value, keyPath, removedPaths, visited, depth + 1); } else { result[key] = value; } } else { - // Non-standard property - remove it + // Unsupported field — remove it removedPaths.push(keyPath); } } @@ -284,7 +178,7 @@ function sanitizeSchemaRecursive( } /** - * Sanitize an input_schema object, removing non-standard JSON Schema properties. + * Sanitize an input_schema object, removing fields unsupported by Gemini. * * @param inputSchema The tool's input_schema object * @returns Sanitization result with cleaned schema and metadata diff --git a/src/cliproxy/tool-sanitization-proxy.ts b/src/cliproxy/tool-sanitization-proxy.ts index 87c3b811..36c7c40f 100644 --- a/src/cliproxy/tool-sanitization-proxy.ts +++ b/src/cliproxy/tool-sanitization-proxy.ts @@ -218,7 +218,7 @@ export class ToolSanitizationProxy { for (const entry of schemaResult.removedByTool) { this.writeLog( 'warn', - `[tool-sanitization-proxy] Schema sanitized for "${entry.name}": removed ${entry.removed.length} non-standard properties` + `[tool-sanitization-proxy] Schema sanitized for "${entry.name}": removed ${entry.removed.length} Gemini-unsupported properties` ); } this.log( diff --git a/src/commands/env-command.ts b/src/commands/env-command.ts new file mode 100644 index 00000000..6ef1f48a --- /dev/null +++ b/src/commands/env-command.ts @@ -0,0 +1,302 @@ +/** + * Env Command Handler + * + * Export environment variables for third-party tool integration. + * Outputs shell-evaluable exports for OpenCode, Cursor, Continue, etc. + */ + +import { initUI, header, dim, color, subheader, fail, warn } from '../utils/ui'; +import { CLIProxyProvider } from '../cliproxy/types'; +import { CLIPROXY_PROFILES, loadSettingsFromFile } from '../auth/profile-detector'; +import { getEffectiveEnvVars } from '../cliproxy/config/env-builder'; +import { CLIPROXY_DEFAULT_PORT } from '../cliproxy/config/port-manager'; +import { isUnifiedMode, loadUnifiedConfig } from '../config/unified-config-loader'; +import { expandPath } from '../utils/helpers'; +import { getCcsDir } from '../utils/config-manager'; +import { ProfileRegistry } from '../auth/profile-registry'; + +type ShellType = 'bash' | 'fish' | 'powershell'; +type OutputFormat = 'openai' | 'anthropic' | 'raw'; + +const VALID_FORMATS: OutputFormat[] = ['openai', 'anthropic', 'raw']; +const VALID_SHELLS: ShellType[] = ['bash', 'fish', 'powershell']; +const VALID_SHELL_INPUTS = ['auto', 'bash', 'zsh', 'fish', 'powershell'] as const; +const VALID_ENV_KEY = /^[A-Za-z_][A-Za-z0-9_]*$/; + +/** Auto-detect shell from environment */ +export function detectShell(flag?: string): ShellType { + if (flag && flag !== 'auto' && VALID_SHELLS.includes(flag as ShellType)) { + return flag as ShellType; + } + const shell = process.env['SHELL'] || ''; + if (shell.includes('fish')) return 'fish'; + if (shell.includes('pwsh') || process.platform === 'win32') return 'powershell'; + return 'bash'; +} + +/** Format a single env var export for the target shell (single-quoted to prevent injection) */ +export function formatExportLine(shell: ShellType, key: string, value: string): string { + switch (shell) { + case 'fish': + // Fish: single quotes prevent expansion; escape embedded single quotes with '\'' + return `set -gx ${key} '${value.replace(/'/g, "'\\''")}'`; + case 'powershell': + // PowerShell: single quotes prevent expansion; escape embedded single quotes with '' + return `$env:${key} = '${value.replace(/'/g, "''")}'`; + default: + // Bash/zsh: single quotes prevent all expansion; handle embedded single quotes + return `export ${key}='${value.replace(/'/g, "'\\''")}'`; + } +} + +/** Map Anthropic env vars to OpenAI-compatible format. + * OPENAI_MODEL is included so tools that need it (e.g. OpenCode local provider) + * can discover the model without additional configuration. */ +export function transformToOpenAI(envVars: Record): Record { + const baseUrl = envVars['ANTHROPIC_BASE_URL'] || ''; + const apiKey = envVars['ANTHROPIC_AUTH_TOKEN'] || ''; + const model = envVars['ANTHROPIC_MODEL'] || ''; + const result: Record = {}; + if (apiKey) result['OPENAI_API_KEY'] = apiKey; + if (baseUrl) { + result['OPENAI_BASE_URL'] = baseUrl; + result['LOCAL_ENDPOINT'] = baseUrl; + } + if (model) result['OPENAI_MODEL'] = model; + return result; +} + +/** Parse --key=value or --key value style args */ +export function parseFlag(args: string[], flag: string): string | undefined { + // --flag=value style + const eqMatch = args.find((a) => a.startsWith(`--${flag}=`)); + if (eqMatch) return eqMatch.split('=').slice(1).join('='); + // --flag value style + const idx = args.indexOf(`--${flag}`); + if (idx >= 0 && idx + 1 < args.length && !args[idx + 1].startsWith('-')) { + return args[idx + 1]; + } + return undefined; +} + +/** Find the first positional argument, skipping flags and their values */ +export function findProfile(args: string[], flagsWithValues: string[]): string | undefined { + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + if (arg.startsWith('-')) { + // Skip flag values: --flag=value (single token) or --flag value (two tokens) + const flagName = arg.replace(/^--/, '').split('=')[0]; + if (!arg.includes('=') && flagsWithValues.includes(flagName) && i + 1 < args.length) { + i++; // skip next arg (the value) + } + continue; + } + return arg; + } + return undefined; +} + +/** Check if a profile is a CLIProxy profile */ +function isCLIProxyProfile(name: string): boolean { + return (CLIPROXY_PROFILES as readonly string[]).includes(name); +} + +/** Resolve env vars for settings-based profiles (glm, kimi, custom API profiles) */ +function resolveSettingsProfile(profileName: string): Record | null { + if (!isUnifiedMode()) return null; + + const config = loadUnifiedConfig(); + if (!config) return null; + + // Check unified config profiles section + const profileConfig = config.profiles?.[profileName]; + if (!profileConfig) return null; + + if (profileConfig.type !== 'api') { + console.error( + fail( + `Profile '${profileName}' is type '${profileConfig.type}', not a settings-based API profile.` + ) + ); + process.exit(1); + } + + if (profileConfig.settings) { + const settingsPath = expandPath(profileConfig.settings); + return loadSettingsFromFile(settingsPath); + } + + return {}; +} + +/** Show help for env command */ +function showHelp(): void { + console.log(''); + console.log(header('ccs env')); + console.log(''); + console.log(' Export environment variables for third-party tool integration.'); + console.log(''); + + console.log(subheader('Usage:')); + console.log(` ${color('ccs env', 'command')} [options]`); + console.log(''); + + console.log(subheader('Options:')); + console.log( + ` ${color('--format', 'command')} Output format: openai, anthropic, raw ${dim('(default: anthropic)')}` + ); + console.log( + ` ${color('--shell', 'command')} Shell syntax: auto, bash/zsh, fish, powershell ${dim('(default: auto)')}` + ); + console.log(` ${color('--help, -h', 'command')} Show this help message`); + console.log(''); + + console.log(subheader('Formats:')); + console.log( + ` ${color('openai', 'command')} OPENAI_API_KEY, OPENAI_BASE_URL, LOCAL_ENDPOINT` + ); + console.log( + ` ${color('anthropic', 'command')} ANTHROPIC_BASE_URL, ANTHROPIC_AUTH_TOKEN, ANTHROPIC_MODEL` + ); + console.log(` ${color('raw', 'command')} All effective env vars as-is`); + console.log(''); + + console.log(subheader('Examples:')); + console.log( + ` $ ${color('eval $(ccs env gemini --format openai)', 'command')} ${dim('# For OpenCode/Cursor')}` + ); + console.log( + ` $ ${color('ccs env codex --format anthropic', 'command')} ${dim('# Anthropic vars')}` + ); + console.log( + ` $ ${color('ccs env glm --format raw', 'command')} ${dim('# All vars from settings')}` + ); + console.log( + ` $ ${color('ccs env agy --format openai --shell fish', 'command')} ${dim('# Fish shell syntax')}` + ); + console.log(''); +} + +/** + * Handle env command + * @param args - Command line arguments (after 'env') + */ +export async function handleEnvCommand(args: string[]): Promise { + await initUI(); + + if (args.includes('--help') || args.includes('-h')) { + showHelp(); + return; + } + + // Parse profile (first positional argument, skipping flag values) + const flagsWithValues = ['format', 'shell']; + const profile = findProfile(args, flagsWithValues); + if (!profile) { + console.error(fail('Usage: ccs env [--format openai|anthropic|raw]')); + process.exit(1); + } + + // Parse flags + const formatStr = parseFlag(args, 'format') || 'anthropic'; + if (!VALID_FORMATS.includes(formatStr as OutputFormat)) { + console.error(fail(`Invalid format: ${formatStr}. Use: ${VALID_FORMATS.join(', ')}`)); + process.exit(1); + } + const format = formatStr as OutputFormat; + + const shellStr = parseFlag(args, 'shell') || 'auto'; + if (!VALID_SHELL_INPUTS.includes(shellStr as (typeof VALID_SHELL_INPUTS)[number])) { + console.error(fail(`Invalid shell: ${shellStr}. Use: ${VALID_SHELL_INPUTS.join(', ')}`)); + process.exit(1); + } + // zsh uses the same syntax as bash + const shell = detectShell(shellStr === 'zsh' ? 'bash' : shellStr); + + // Resolve env vars based on profile type + let envVars: Record = {}; + + if (isCLIProxyProfile(profile)) { + // CLIProxy profile (gemini, codex, agy, etc.) + const provider = profile as CLIProxyProvider; + const resolved = getEffectiveEnvVars(provider, CLIPROXY_DEFAULT_PORT); + // Convert NodeJS.ProcessEnv to Record + for (const [k, v] of Object.entries(resolved)) { + if (v !== undefined) envVars[k] = v; + } + } else { + // Settings-based profile (glm, kimi, custom API) + const resolved = resolveSettingsProfile(profile); + if (!resolved) { + // Check if it's an account-based profile + const registry = new ProfileRegistry(); + const allProfiles = registry.getAllProfiles(); + if (allProfiles[profile]) { + console.error( + fail( + `'${profile}' is an account-based profile. ` + + '`ccs env` only supports CLIProxy and settings profiles.' + ) + ); + process.exit(1); + } + + console.error(fail(`Profile '${profile}' not found.`)); + console.error(dim(' Available CLIProxy profiles: ' + CLIPROXY_PROFILES.join(', '))); + if (!isUnifiedMode()) { + console.error( + dim(' Settings profiles require unified config. Run `ccs migrate` to upgrade.') + ); + } else { + console.error(dim(` Check ${getCcsDir()}/config.yaml for custom profiles.`)); + } + process.exit(1); + } + envVars = resolved; + } + + if (Object.keys(envVars).length === 0) { + console.error(warn(`No env vars resolved for profile '${profile}'.`)); + process.exit(1); + } + + // Transform to requested format + let output: Record; + switch (format) { + case 'openai': + output = transformToOpenAI(envVars); + break; + case 'anthropic': { + // Filter to only Anthropic-relevant vars + output = {}; + for (const [k, v] of Object.entries(envVars)) { + if (k.startsWith('ANTHROPIC_')) { + output[k] = v; + } + } + break; + } + case 'raw': + output = envVars; + break; + } + + // Guard: format transformation may filter out all vars + if (Object.keys(output).filter((k) => output[k]).length === 0) { + console.error( + warn(`No ${format}-format vars found for profile '${profile}'. Try --format raw`) + ); + process.exit(1); + } + + // Output shell-formatted exports to stdout + for (const [key, value] of Object.entries(output)) { + if (!VALID_ENV_KEY.test(key)) { + console.error(dim(` Skipping invalid key: ${key}`)); + continue; + } + if (value) { + console.log(formatExportLine(shell, key, value)); + } + } +} diff --git a/src/commands/help-command.ts b/src/commands/help-command.ts index 2097453f..6c51f385 100644 --- a/src/commands/help-command.ts +++ b/src/commands/help-command.ts @@ -2,6 +2,7 @@ import * as fs from 'fs'; import * as path from 'path'; import { initUI, box, color, dim, sectionHeader, subheader } from '../utils/ui'; import { isUnifiedMode } from '../config/unified-config-loader'; +import { getCcsDir, getCcsDirSource } from '../utils/config-manager'; // Get version from package.json (same as version-command.ts) const VERSION = JSON.parse( @@ -110,6 +111,10 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim(); ); console.log(''); + // Resolve display path for dynamic sections + const [dirSource] = getCcsDirSource(); + const dirDisplay = dirSource === 'default' ? '~/.ccs' : getCcsDir(); + // Usage section console.log(subheader('Usage:')); console.log(` ${color('ccs', 'command')} [profile] [claude-args...]`); @@ -121,7 +126,7 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim(); // ═══════════════════════════════════════════════════════════════════════════ printMajorSection( 'API Key Profiles', - ['Configure in ~/.ccs/*.settings.json'], + [`Configure in ${dirDisplay}/*.settings.json`], [ ['ccs', 'Use default Claude account'], ['ccs glm', 'GLM 4.6 (API key required)'], @@ -257,8 +262,18 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim(); ['ccs update --beta', 'Install from dev channel (unstable)'], ]); + // Environment export + printSubSection('Environment Export', [ + ['ccs env ', 'Export env vars for third-party tools'], + ['ccs env --format openai', 'OpenAI-compatible vars (OpenCode/Cursor)'], + ['ccs env --format anthropic', 'Anthropic vars (default)'], + ['ccs env --format raw', 'All effective env vars'], + ['ccs env --shell fish', 'Fish shell syntax'], + ]); + // Flags printSubSection('Flags', [ + ['--config-dir ', 'Use custom CCS config directory'], ['-h, --help', 'Show this help message'], ['-v, --version', 'Show version and installation info'], ['-sc, --shell-completion', 'Install shell auto-completion'], @@ -266,10 +281,10 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim(); // Configuration printConfigSection('Configuration', [ - ['Config File:', isUnifiedMode() ? '~/.ccs/config.yaml' : '~/.ccs/config.json'], - ['Profiles:', '~/.ccs/profiles.json'], - ['Instances:', '~/.ccs/instances/'], - ['Settings:', '~/.ccs/*.settings.json'], + ['Config File:', isUnifiedMode() ? `${dirDisplay}/config.yaml` : `${dirDisplay}/config.json`], + ['Profiles:', `${dirDisplay}/profiles.json`], + ['Instances:', `${dirDisplay}/instances/`], + ['Settings:', `${dirDisplay}/*.settings.json`], ]); // CLI Proxy management @@ -336,6 +351,13 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim(); ['', 'providers (agy, gemini, codex, kiro, ghcp).'], ]); + // CCS Environment Variables + printSubSection('Environment Variables', [ + ['CCS_DIR', 'Override CCS config directory (default: ~/.ccs)'], + ['CCS_HOME', 'Override home directory (legacy, appends .ccs)'], + ['CCS_DEBUG', 'Enable debug logging'], + ]); + // CLI Proxy env vars printSubSection('CLI Proxy Environment Variables', [ ['CCS_PROXY_HOST', 'Remote proxy hostname'], @@ -349,17 +371,17 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim(); // CLI Proxy paths console.log(subheader('CLI Proxy:')); - console.log(` Binary: ${color('~/.ccs/cliproxy/bin/cli-proxy-api-plus', 'path')}`); - console.log(` Config: ${color('~/.ccs/cliproxy/config.yaml', 'path')}`); - console.log(` Auth: ${color('~/.ccs/cliproxy/auth/', 'path')}`); + console.log(` Binary: ${color(`${dirDisplay}/cliproxy/bin/cli-proxy-api-plus`, 'path')}`); + console.log(` Config: ${color(`${dirDisplay}/cliproxy/config.yaml`, 'path')}`); + console.log(` Auth: ${color(`${dirDisplay}/cliproxy/auth/`, 'path')}`); console.log(` ${dim('Port: 8317 (default)')}`); console.log(''); // Shared Data console.log(subheader('Shared Data:')); - console.log(` Commands: ${color('~/.ccs/shared/commands/', 'path')}`); - console.log(` Skills: ${color('~/.ccs/shared/skills/', 'path')}`); - console.log(` Agents: ${color('~/.ccs/shared/agents/', 'path')}`); + console.log(` Commands: ${color(`${dirDisplay}/shared/commands/`, 'path')}`); + console.log(` Skills: ${color(`${dirDisplay}/shared/skills/`, 'path')}`); + console.log(` Agents: ${color(`${dirDisplay}/shared/agents/`, 'path')}`); console.log(` ${dim('Note: Symlinked across all profiles')}`); console.log(''); diff --git a/src/commands/version-command.ts b/src/commands/version-command.ts index 02304b46..c83cd9d1 100644 --- a/src/commands/version-command.ts +++ b/src/commands/version-command.ts @@ -6,9 +6,8 @@ import * as path from 'path'; import * as fs from 'fs'; -import * as os from 'os'; import { initUI, header, subheader, color, warn } from '../utils/ui'; -import { getActiveConfigPath } from '../utils/config-manager'; +import { getActiveConfigPath, getCcsDir } from '../utils/config-manager'; import { getVersion } from '../utils/version'; /** @@ -23,24 +22,24 @@ export async function handleVersionCommand(): Promise { const installLocation = process.argv[1] || '(not found)'; console.log(` ${color('Location:'.padEnd(17), 'info')} ${installLocation}`); - const ccsDir = path.join(os.homedir(), '.ccs'); + const ccsDir = getCcsDir(); console.log(` ${color('CCS Directory:'.padEnd(17), 'info')} ${ccsDir}`); const configPath = getActiveConfigPath(); console.log(` ${color('Config:'.padEnd(17), 'info')} ${configPath}`); - const profilesJson = path.join(os.homedir(), '.ccs', 'profiles.json'); + const profilesJson = path.join(ccsDir, 'profiles.json'); console.log(` ${color('Profiles:'.padEnd(17), 'info')} ${profilesJson}`); // Delegation status - const delegationSessionsPath = path.join(os.homedir(), '.ccs', 'delegation-sessions.json'); + const delegationSessionsPath = path.join(ccsDir, 'delegation-sessions.json'); const delegationConfigured = fs.existsSync(delegationSessionsPath); const readyProfiles: string[] = []; // Check for profiles with valid API keys for (const profile of ['glm', 'kimi']) { - const settingsPath = path.join(os.homedir(), '.ccs', `${profile}.settings.json`); + const settingsPath = path.join(ccsDir, `${profile}.settings.json`); if (fs.existsSync(settingsPath)) { try { const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); diff --git a/src/config/unified-config-loader.ts b/src/config/unified-config-loader.ts index a8e336de..76401431 100644 --- a/src/config/unified-config-loader.ts +++ b/src/config/unified-config-loader.ts @@ -89,10 +89,13 @@ function acquireLock(): boolean { } // Acquire lock - fs.writeFileSync(lockPath, lockData, { mode: 0o600 }); + fs.writeFileSync(lockPath, lockData, { flag: 'wx', mode: 0o600 }); return true; - } catch { - // Lock acquisition failed + } catch (error) { + // EEXIST means another process acquired the lock between our check and write + if ((error as NodeJS.ErrnoException).code === 'EEXIST') { + return false; + } return false; } } @@ -645,7 +648,15 @@ export function saveUnifiedConfig(config: UnifiedConfig): void { // Synchronous sleep without CPU-intensive busy-wait // Uses Atomics.wait which properly sleeps the thread // Note: saveUnifiedConfig is sync API with 19+ callers, converting to async not feasible - Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, retryDelayMs); + try { + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, retryDelayMs); + } catch { + // Fallback for environments without SharedArrayBuffer/Atomics support + const end = Date.now() + retryDelayMs; + while (Date.now() < end) { + /* busy-wait */ + } + } } if (!lockAcquired) { diff --git a/src/delegation/delegation-handler.ts b/src/delegation/delegation-handler.ts index 75c75f6f..cfe85138 100644 --- a/src/delegation/delegation-handler.ts +++ b/src/delegation/delegation-handler.ts @@ -6,6 +6,7 @@ import { ResultFormatter } from './result-formatter'; import { DelegationValidator } from '../utils/delegation-validator'; import { SettingsParser } from './settings-parser'; import { fail, warn } from '../utils/ui'; +import { getCcsDir } from '../utils/config-manager'; /** * Parse and validate a string flag value @@ -330,7 +331,7 @@ export class DelegationHandler { console.error(` ${validation.error}`); console.error(''); console.error(' Run: ccs doctor'); - console.error(` Or configure: ~/.ccs/${profile}.settings.json`); + console.error(` Or configure: ${getCcsDir()}/${profile}.settings.json`); process.exit(1); } } diff --git a/src/delegation/headless-executor.ts b/src/delegation/headless-executor.ts index 1a8be770..da95e746 100644 --- a/src/delegation/headless-executor.ts +++ b/src/delegation/headless-executor.ts @@ -7,6 +7,7 @@ import { spawn } from 'child_process'; import * as path from 'path'; +import { killWithEscalation } from '../utils/process-utils'; import * as fs from 'fs'; import { SessionManager } from './session-manager'; import { SettingsParser } from './settings-parser'; @@ -214,11 +215,8 @@ export class HeadlessExecutor { // Setup signal handlers for cleanup const cleanupHandler = () => { - if (!proc.killed) { - proc.kill('SIGTERM'); - setTimeout(() => { - if (!proc.killed) proc.kill('SIGKILL'); - }, 2000); + if (proc.exitCode === null) { + killWithEscalation(proc, 2000); } }; process.once('SIGINT', cleanupHandler); @@ -326,16 +324,14 @@ export class HeadlessExecutor { // Handle timeout if (timeout > 0) { const timeoutHandle = setTimeout(() => { - if (!proc.killed) { + if (proc.exitCode === null) { timedOut = true; if (progressInterval) { clearInterval(progressInterval); process.stderr.write('\r\x1b[K'); } - proc.kill('SIGTERM'); - setTimeout(() => { - if (!proc.killed) proc.kill('SIGKILL'); - }, 10000); + // Longer grace period for timeout (vs 2s for SIGINT) since delegated sessions may need time to flush output + killWithEscalation(proc, 10000); } }, timeout); proc.on('close', () => clearTimeout(timeoutHandle)); diff --git a/src/glmt/glmt-proxy.ts b/src/glmt/glmt-proxy.ts index f52d55a9..2d7be16c 100644 --- a/src/glmt/glmt-proxy.ts +++ b/src/glmt/glmt-proxy.ts @@ -382,15 +382,15 @@ export class GlmtProxy { * Honors Retry-After header if provided */ private calculateRetryDelay(attempt: number, retryAfterHeader?: string): number { + const MAX_DELAY_MS = 30000; // Honor Retry-After header if present and valid if (retryAfterHeader) { const retryAfter = parseInt(retryAfterHeader, 10); if (!isNaN(retryAfter) && retryAfter > 0) { - return retryAfter * 1000; // Convert seconds to ms + return Math.min(retryAfter * 1000, MAX_DELAY_MS); // Cap at 30s } } // Exponential backoff: 2^attempt * baseDelay + random jitter (0-500ms), capped at 30s - const MAX_DELAY_MS = 30000; const exponentialDelay = Math.min( Math.pow(2, attempt) * this.retryConfig.baseDelay, MAX_DELAY_MS diff --git a/src/glmt/glmt-transformer.ts b/src/glmt/glmt-transformer.ts index 5c4a41e7..a1854210 100644 --- a/src/glmt/glmt-transformer.ts +++ b/src/glmt/glmt-transformer.ts @@ -148,13 +148,32 @@ export class GlmtTransformer { return this.streamParser.finalizeDelta(accumulator); } + private redactSensitiveData(data: unknown): unknown { + if (data === null || data === undefined) return data; + if (typeof data !== 'object') return data; + if (Array.isArray(data)) return data.map((item) => this.redactSensitiveData(item)); + + const SENSITIVE_KEYS = + /^(authorization|auth[_-]?token|api[_-]?key|apikey|token|secret|password|credential|x-api-key|anthropic-api-key|cookie)$/i; + const result: Record = {}; + for (const [key, value] of Object.entries(data as Record)) { + if (SENSITIVE_KEYS.test(key)) { + result[key] = '[REDACTED]'; + } else { + result[key] = this.redactSensitiveData(value); + } + } + return result; + } + private writeDebugLog(type: string, data: unknown): void { if (!this.debugLog) return; try { const timestamp = new Date().toISOString().replace(/[:.]/g, '-').split('.')[0]; const filepath = path.join(this.debugLogDir, `${timestamp}-${type}.json`); fs.mkdirSync(this.debugLogDir, { recursive: true }); - fs.writeFileSync(filepath, JSON.stringify(data, null, 2) + '\n', 'utf8'); + const redacted = this.redactSensitiveData(data); + fs.writeFileSync(filepath, JSON.stringify(redacted, null, 2) + '\n', 'utf8'); } catch (error) { console.error(`[glmt-transformer] Debug log error: ${(error as Error).message}`); } diff --git a/src/management/checks/env-check.ts b/src/management/checks/env-check.ts index c586b07a..95a34a61 100644 --- a/src/management/checks/env-check.ts +++ b/src/management/checks/env-check.ts @@ -4,6 +4,7 @@ import { getEnvironmentDiagnostics } from '../environment-diagnostics'; import { ok, warn } from '../../utils/ui'; +import { getCcsDir, getCcsDirSource } from '../../utils/config-manager'; import { HealthCheck, IHealthChecker, createSpinner } from './types'; const ora = createSpinner(); @@ -54,6 +55,12 @@ export class EnvironmentChecker implements IHealthChecker { } console.log(` ${''.padEnd(24)} Browser: ${diag.browserReason}`); + // Show CCS directory and source + const ccsDir = getCcsDir(); + const [dirSource] = getCcsDirSource(); + const sourceLabel = dirSource === 'default' ? '' : ` (via ${dirSource})`; + console.log(` ${''.padEnd(24)} CCS Dir: ${ccsDir}${sourceLabel}`); + results.addCheck( 'Environment', envStatus === 'OK' ? 'success' : 'warning', diff --git a/src/management/checks/index.ts b/src/management/checks/index.ts index 680c8f0c..7edcd737 100644 --- a/src/management/checks/index.ts +++ b/src/management/checks/index.ts @@ -3,16 +3,16 @@ */ // Types and utilities -export { - HealthCheck, +export type { HealthCheckDetails, HealthCheckItem, HealthIssue, - IHealthChecker, Spinner, - createSpinner, + IHealthChecker, } from './types'; +export { HealthCheck, createSpinner } from './types'; + // System checks export { ClaudeCliChecker, CcsDirectoryChecker, runSystemChecks } from './system-check'; diff --git a/src/management/recovery-manager.ts b/src/management/recovery-manager.ts index 8977870c..ec7e3710 100644 --- a/src/management/recovery-manager.ts +++ b/src/management/recovery-manager.ts @@ -6,8 +6,8 @@ import * as fs from 'fs'; import * as path from 'path'; -import * as os from 'os'; import { info } from '../utils/ui'; +import { getCcsHome, getCcsDir } from '../utils/config-manager'; import { createEmptyUnifiedConfig, UNIFIED_CONFIG_VERSION } from '../config/unified-config-types'; import { saveUnifiedConfig, @@ -15,13 +15,6 @@ import { loadUnifiedConfig, } from '../config/unified-config-loader'; -/** - * Get CCS home directory (respects CCS_HOME env for test isolation) - */ -function getCcsHome(): string { - return process.env.CCS_HOME || os.homedir(); -} - /** * Recovery Manager Class */ @@ -35,7 +28,7 @@ class RecoveryManager { constructor() { this.homedir = getCcsHome(); - this.ccsDir = path.join(this.homedir, '.ccs'); + this.ccsDir = getCcsDir(); this.claudeDir = path.join(this.homedir, '.claude'); this.sharedDir = path.join(this.ccsDir, 'shared'); this.completionsDir = path.join(this.ccsDir, 'completions'); @@ -48,7 +41,7 @@ class RecoveryManager { ensureCcsDirectory(): boolean { if (!fs.existsSync(this.ccsDir)) { fs.mkdirSync(this.ccsDir, { recursive: true, mode: 0o755 }); - this.recovered.push('Created ~/.ccs/ directory'); + this.recovered.push(`Created ${this.ccsDir} directory`); return true; } return false; @@ -67,7 +60,7 @@ class RecoveryManager { return false; // Config exists and is valid } // Config exists but is corrupted - will be recreated below - this.recovered.push('Detected corrupted ~/.ccs/config.yaml'); + this.recovered.push(`Detected corrupted ${this.ccsDir}/config.yaml`); } // Check for legacy config.json - if exists, let autoMigrate handle it @@ -83,7 +76,7 @@ class RecoveryManager { try { saveUnifiedConfig(config); - this.recovered.push('Created ~/.ccs/config.yaml'); + this.recovered.push(`Created ${this.ccsDir}/config.yaml`); return true; } catch (_saveErr) { // Fallback: create minimal config.json for backward compat @@ -92,7 +85,7 @@ class RecoveryManager { const tmpPath = `${legacyConfigPath}.tmp`; fs.writeFileSync(tmpPath, JSON.stringify(fallbackConfig, null, 2) + '\n', 'utf8'); fs.renameSync(tmpPath, legacyConfigPath); - this.recovered.push('Created ~/.ccs/config.json (fallback)'); + this.recovered.push(`Created ${this.ccsDir}/config.json (fallback)`); return true; } catch (_fallbackErr) { // Both writes failed - log but don't crash @@ -111,7 +104,7 @@ class RecoveryManager { // Create ~/.claude/ if missing if (!fs.existsSync(this.claudeDir)) { fs.mkdirSync(this.claudeDir, { recursive: true, mode: 0o755 }); - this.recovered.push('Created ~/.claude/ directory'); + this.recovered.push(`Created ${this.claudeDir} directory`); } // Create settings.json if missing @@ -120,7 +113,7 @@ class RecoveryManager { fs.writeFileSync(tmpPath, '{}\n', 'utf8'); fs.renameSync(tmpPath, claudeSettingsPath); - this.recovered.push('Created ~/.claude/settings.json'); + this.recovered.push(`Created ${claudeSettingsPath}`); return true; } @@ -136,7 +129,7 @@ class RecoveryManager { // Create shared directory if (!fs.existsSync(this.sharedDir)) { fs.mkdirSync(this.sharedDir, { recursive: true, mode: 0o755 }); - this.recovered.push('Created ~/.ccs/shared/'); + this.recovered.push(`Created ${this.sharedDir}`); created = true; } @@ -197,7 +190,7 @@ class RecoveryManager { } if (installed) { - this.recovered.push('Installed shell completions to ~/.ccs/completions/'); + this.recovered.push(`Installed shell completions to ${this.completionsDir}`); } return installed; @@ -245,7 +238,7 @@ class RecoveryManager { this.recovered.forEach((msg) => console.log(` - ${msg}`)); // Show login hint if created Claude settings - if (this.recovered.some((msg) => msg.includes('~/.claude/settings.json'))) { + if (this.recovered.some((msg) => msg.includes('settings.json') && msg.includes('.claude'))) { console.log(''); console.log(info('Next step: Login to Claude CLI')); console.log(' Run: claude /login'); diff --git a/src/management/repair/auto-repair.ts b/src/management/repair/auto-repair.ts index b769bbc7..861a30b6 100644 --- a/src/management/repair/auto-repair.ts +++ b/src/management/repair/auto-repair.ts @@ -4,8 +4,8 @@ import * as fs from 'fs'; import * as path from 'path'; -import * as os from 'os'; import { ok, warn, fail, info, header, color } from '../../utils/ui'; +import { getCcsDir } from '../../utils/config-manager'; import { CLIPROXY_DEFAULT_PORT, configNeedsRegeneration, @@ -28,8 +28,6 @@ const ora = createSpinner(); * 4. OAuth callback ports blocked by CLIProxy */ export async function runAutoRepair(): Promise { - const homedir = os.homedir(); - console.log(''); console.log(header('AUTO-FIX MODE')); console.log(''); @@ -111,7 +109,7 @@ export async function runAutoRepair(): Promise { // Fix 4: Fix shared symlinks (settings.json broken by Claude CLI toggle thinking, etc.) const symlinkSpinner = ora('Checking shared settings.json symlink').start(); - const sharedSettings = path.join(homedir, '.ccs', 'shared', 'settings.json'); + const sharedSettings = path.join(getCcsDir(), 'shared', 'settings.json'); try { if (fs.existsSync(sharedSettings)) { const stats = fs.lstatSync(sharedSettings); diff --git a/src/management/shared-manager.ts b/src/management/shared-manager.ts index ca6d29be..754e42e7 100644 --- a/src/management/shared-manager.ts +++ b/src/management/shared-manager.ts @@ -62,7 +62,7 @@ class SharedManager { const resolvedTarget = path.resolve(path.dirname(target), targetLink); // Check if target points back to our shared dir or link path - const sharedDir = path.join(this.homeDir, '.ccs', 'shared'); + const sharedDir = path.join(getCcsDir(), 'shared'); if (resolvedTarget.startsWith(sharedDir) || resolvedTarget === linkPath) { console.log(warn(`Circular symlink detected: ${target} → ${resolvedTarget}`)); return true; diff --git a/src/utils/claude-dir-installer.ts b/src/utils/claude-dir-installer.ts index 789e66aa..38ed9690 100644 --- a/src/utils/claude-dir-installer.ts +++ b/src/utils/claude-dir-installer.ts @@ -197,12 +197,7 @@ export class ClaudeDirInstaller { cleanupDeprecated(silent = false): CleanupResult { const deprecatedFile = path.join(this.ccsClaudeDir, 'agents', 'ccs-delegator.md'); const userSymlinkFile = path.join(this.homeDir, '.claude', 'agents', 'ccs-delegator.md'); - const migrationMarker = path.join( - this.homeDir, - '.ccs', - '.migrations', - 'v435-delegator-cleanup' - ); + const migrationMarker = path.join(getCcsDir(), '.migrations', 'v435-delegator-cleanup'); const cleanedFiles: string[] = []; diff --git a/src/utils/config-manager.ts b/src/utils/config-manager.ts index 655f5b30..c00d9319 100644 --- a/src/utils/config-manager.ts +++ b/src/utils/config-manager.ts @@ -10,6 +10,18 @@ import { isUnifiedMode, loadOrCreateUnifiedConfig } from '../config/unified-conf // const { ErrorManager } = require('./error-manager'); // const RecoveryManager = require('./recovery-manager'); +// Module-level state for --config-dir CLI flag override +let _globalConfigDir: string | undefined; + +/** + * Set global config directory from --config-dir CLI flag. + * Must be called before any config loading. + * Pass undefined to clear (useful for tests). + */ +export function setGlobalConfigDir(dir: string | undefined): void { + _globalConfigDir = dir ? path.resolve(dir) : undefined; +} + /** * Get the CCS home directory (respects CCS_HOME env var for test isolation) * @returns Home directory path @@ -19,11 +31,62 @@ export function getCcsHome(): string { } /** - * Get the CCS directory path (~/.ccs) - * @returns Path to .ccs directory + * Resolve the CCS directory with source information. + * Single source of truth for precedence logic. + */ +function _resolveCcsDir(): { source: string; dir: string } { + if (_globalConfigDir) return { source: '--config-dir', dir: _globalConfigDir }; + if (process.env.CCS_DIR) return { source: 'CCS_DIR', dir: path.resolve(process.env.CCS_DIR) }; + if (process.env.CCS_HOME) + return { source: 'CCS_HOME', dir: path.join(path.resolve(process.env.CCS_HOME), '.ccs') }; + return { source: 'default', dir: path.join(os.homedir(), '.ccs') }; +} + +/** + * Get the CCS directory path. + * Precedence: --config-dir flag > CCS_DIR env > CCS_HOME env (legacy, appends .ccs) > ~/.ccs default + * @returns Path to CCS config directory */ export function getCcsDir(): string { - return path.join(getCcsHome(), '.ccs'); + return _resolveCcsDir().dir; +} + +/** + * Get which source determined the CCS directory (for diagnostics). + * @returns [source_label, resolved_path] tuple + */ +export function getCcsDirSource(): [string, string] { + const r = _resolveCcsDir(); + return [r.source, r.dir]; +} + +/** + * Cloud sync folder patterns for security warning. + */ +const CLOUD_SYNC_PATTERNS = [ + 'Dropbox', + 'OneDrive', + 'Google Drive', + 'iCloud Drive', + 'iCloudDrive', + 'pCloud', + 'MEGA', + 'Box Sync', +]; + +/** + * Check if a path is under a cloud-synced folder. + * @returns Detected service name or null + */ +export function detectCloudSyncPath(dir: string): string | null { + const normalized = dir.replace(/\\/g, '/').toLowerCase(); + const segments = normalized.split('/'); + for (const pattern of CLOUD_SYNC_PATTERNS) { + const patternLower = pattern.toLowerCase(); + // Exact path segment match to avoid false positives (e.g., "megauser" != "MEGA") + if (segments.some((s) => s === patternLower)) return pattern; + } + return null; } /** @@ -39,7 +102,7 @@ export function getCcsHooksDir(): string { * @deprecated Use getActiveConfigPath() for mode-aware config path */ export function getConfigPath(): string { - return process.env.CCS_CONFIG || path.join(getCcsHome(), '.ccs', 'config.json'); + return process.env.CCS_CONFIG || path.join(getCcsDir(), 'config.json'); } /** diff --git a/src/utils/delegation-validator.ts b/src/utils/delegation-validator.ts index 76be898b..93d669dd 100644 --- a/src/utils/delegation-validator.ts +++ b/src/utils/delegation-validator.ts @@ -2,9 +2,9 @@ import * as fs from 'fs'; import * as path from 'path'; -import * as os from 'os'; import { Settings } from '../types'; import { ValidationResult } from '../types/utils'; +import { getCcsDir } from './config-manager'; /** * Extended validation result for delegation profiles @@ -28,8 +28,7 @@ export class DelegationValidator { * @returns Validation result { valid: boolean, error?: string, settingsPath?: string } */ static validate(profileName: string): DelegationValidationResult { - const homeDir = os.homedir(); - const settingsPath = path.join(homeDir, '.ccs', `${profileName}.settings.json`); + const settingsPath = path.join(getCcsDir(), `${profileName}.settings.json`); // Check if profile directory exists if (!fs.existsSync(settingsPath)) { @@ -144,8 +143,7 @@ export class DelegationValidator { * @returns List of profile names ready for delegation */ static getReadyProfiles(): string[] { - const homeDir = os.homedir(); - const ccsDir = path.join(homeDir, '.ccs'); + const ccsDir = getCcsDir(); const configPath = path.join(ccsDir, 'config.yaml'); if (!fs.existsSync(ccsDir)) { diff --git a/src/utils/process-utils.ts b/src/utils/process-utils.ts new file mode 100644 index 00000000..571ce743 --- /dev/null +++ b/src/utils/process-utils.ts @@ -0,0 +1,21 @@ +/** + * Process management utilities + */ + +import { ChildProcess } from 'child_process'; + +/** + * Kill process with SIGTERM, escalating to SIGKILL if it doesn't exit. + * Uses exitCode === null (not proc.killed) to check if process is still running, + * since proc.killed only indicates a signal was sent, not that the process exited. + */ +export function killWithEscalation(proc: ChildProcess, gracePeriodMs = 3000): void { + proc.kill('SIGTERM'); + const timer = setTimeout(() => { + if (proc.exitCode === null) { + proc.kill('SIGKILL'); + } + }, gracePeriodMs); + timer.unref(); // Don't keep event loop alive just for escalation + proc.once('exit', () => clearTimeout(timer)); +} diff --git a/src/utils/shell-completion.ts b/src/utils/shell-completion.ts index 68419571..ae2479eb 100644 --- a/src/utils/shell-completion.ts +++ b/src/utils/shell-completion.ts @@ -1,6 +1,7 @@ import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; +import { getCcsDir } from './config-manager'; // import { execSync } from 'child_process'; // Currently unused type ShellType = 'bash' | 'zsh' | 'fish' | 'powershell' | null; @@ -24,7 +25,7 @@ export class ShellCompletionInstaller { constructor() { this.homeDir = os.homedir(); - this.ccsDir = path.join(this.homeDir, '.ccs'); + this.ccsDir = getCcsDir(); this.completionDir = path.join(this.ccsDir, 'completions'); this.scriptsDir = path.join(__dirname, '../../scripts/completion'); } diff --git a/src/web-server/health-service.ts b/src/web-server/health-service.ts index 21a8723d..69703484 100644 --- a/src/web-server/health-service.ts +++ b/src/web-server/health-service.ts @@ -89,7 +89,7 @@ export async function runHealthChecks(): Promise { const healthChecks: HealthCheck[] = []; healthChecks.push(checkPermissions(ccsDir)); healthChecks.push(checkCcsSymlinks()); - healthChecks.push(checkSettingsSymlinks(homedir, ccsDir, claudeDir)); + healthChecks.push(checkSettingsSymlinks(ccsDir, claudeDir)); groups.push({ id: 'system-health', name: 'System Health', icon: 'Shield', checks: healthChecks }); // Group 6: CLIProxy @@ -139,7 +139,7 @@ export function fixHealthIssue(checkId: string): { success: boolean; message: st switch (checkId) { case 'ccs-dir': fs.mkdirSync(ccsDir, { recursive: true }); - return { success: true, message: 'Created ~/.ccs directory' }; + return { success: true, message: `Created ${ccsDir} directory` }; case 'config-file': { // Use appropriate config based on unified mode diff --git a/src/web-server/health/config-checks.ts b/src/web-server/health/config-checks.ts index 5f7ca34a..17fb04ac 100644 --- a/src/web-server/health/config-checks.ts +++ b/src/web-server/health/config-checks.ts @@ -6,9 +6,8 @@ */ import * as fs from 'fs'; -import * as os from 'os'; import * as path from 'path'; -import { getConfigPath } from '../../utils/config-manager'; +import { getConfigPath, getCcsDir } from '../../utils/config-manager'; import { isUnifiedMode, hasUnifiedConfig } from '../../config/unified-config-loader'; import type { HealthCheck } from './types'; @@ -18,7 +17,7 @@ import type { HealthCheck } from './types'; export function checkConfigFile(): HealthCheck { // In unified mode, check config.yaml if (isUnifiedMode() || hasUnifiedConfig()) { - const ccsDir = path.join(os.homedir(), '.ccs'); + const ccsDir = getCcsDir(); const yamlPath = path.join(ccsDir, 'config.yaml'); if (!fs.existsSync(yamlPath)) { diff --git a/src/web-server/health/symlink-checks.ts b/src/web-server/health/symlink-checks.ts index a5e4e0aa..40b90b5f 100644 --- a/src/web-server/health/symlink-checks.ts +++ b/src/web-server/health/symlink-checks.ts @@ -49,13 +49,9 @@ export function checkCcsSymlinks(): HealthCheck { /** * Check settings symlinks */ -export function checkSettingsSymlinks( - homedir: string, - ccsDir: string, - claudeDir: string -): HealthCheck { +export function checkSettingsSymlinks(ccsDir: string, claudeDir: string): HealthCheck { try { - const sharedDir = `${homedir}/.ccs/shared`; + const sharedDir = `${ccsDir}/shared`; const sharedSettings = `${sharedDir}/settings.json`; const claudeSettings = `${claudeDir}/settings.json`; diff --git a/src/web-server/middleware/auth-middleware.ts b/src/web-server/middleware/auth-middleware.ts index 248e303c..044963d7 100644 --- a/src/web-server/middleware/auth-middleware.ts +++ b/src/web-server/middleware/auth-middleware.ts @@ -10,7 +10,7 @@ import { getDashboardAuthConfig } from '../../config/unified-config-loader'; import crypto from 'crypto'; import fs from 'fs'; import path from 'path'; -import os from 'os'; +import { getCcsDir } from '../../utils/config-manager'; // Extend Express Request with session declare module 'express-session' { @@ -24,7 +24,9 @@ declare module 'express-session' { const PUBLIC_PATHS = ['/api/auth/login', '/api/auth/check', '/api/auth/setup', '/api/health']; /** Path to persistent session secret file */ -const SESSION_SECRET_PATH = path.join(os.homedir(), '.ccs', '.session-secret'); +function getSessionSecretPath() { + return path.join(getCcsDir(), '.session-secret'); +} /** * Generate or retrieve persistent session secret. @@ -36,10 +38,12 @@ function getSessionSecret(): string { return process.env.CCS_SESSION_SECRET; } + const secretPath = getSessionSecretPath(); + // 2. Try to read persisted secret try { - if (fs.existsSync(SESSION_SECRET_PATH)) { - const secret = fs.readFileSync(SESSION_SECRET_PATH, 'utf-8').trim(); + if (fs.existsSync(secretPath)) { + const secret = fs.readFileSync(secretPath, 'utf-8').trim(); if (secret.length >= 32) { return secret; } @@ -51,11 +55,11 @@ function getSessionSecret(): string { // 3. Generate and persist new random secret const newSecret = crypto.randomBytes(32).toString('hex'); try { - const dir = path.dirname(SESSION_SECRET_PATH); + const dir = path.dirname(secretPath); if (!fs.existsSync(dir)) { fs.mkdirSync(dir, { recursive: true }); } - fs.writeFileSync(SESSION_SECRET_PATH, newSecret, { mode: 0o600 }); + fs.writeFileSync(secretPath, newSecret, { mode: 0o600 }); } catch (err) { // Log warning - sessions won't persist across restarts console.warn('[!] Failed to persist session secret:', (err as Error).message); diff --git a/src/web-server/routes/provider-routes.ts b/src/web-server/routes/provider-routes.ts index e5b05659..4b91125c 100644 --- a/src/web-server/routes/provider-routes.ts +++ b/src/web-server/routes/provider-routes.ts @@ -25,7 +25,7 @@ router.get('/', (_req: Request, res: Response): void => { // Mask API keys for security const masked = providers.map((p) => ({ ...p, - apiKey: p.apiKey ? `...${p.apiKey.slice(-4)}` : '', + apiKey: p.apiKey ? (p.apiKey.length > 8 ? `...${p.apiKey.slice(-4)}` : '***') : '', })); res.json({ providers: masked }); } catch (error) { @@ -58,7 +58,11 @@ router.get('/:name', (req: Request, res: Response): void => { // Mask API key res.json({ ...provider, - apiKey: provider.apiKey ? `...${provider.apiKey.slice(-4)}` : '', + apiKey: provider.apiKey + ? provider.apiKey.length > 8 + ? `...${provider.apiKey.slice(-4)}` + : '***' + : '', }); } catch (error) { res.status(500).json({ error: (error as Error).message }); @@ -94,6 +98,10 @@ router.post('/', (req: Request, res: Response): void => { res.status(400).json({ error: 'apiKey is required' }); return; } + if (models && !Array.isArray(models)) { + res.status(400).json({ error: 'models must be an array' }); + return; + } addOpenAICompatProvider({ name, diff --git a/src/web-server/usage/aggregator.ts b/src/web-server/usage/aggregator.ts index ce59ba07..5d30676e 100644 --- a/src/web-server/usage/aggregator.ts +++ b/src/web-server/usage/aggregator.ts @@ -7,7 +7,6 @@ import * as fs from 'fs'; import * as path from 'path'; -import * as os from 'os'; import { loadDailyUsageData, loadMonthlyUsageData, @@ -25,28 +24,32 @@ import { getCacheAge, } from './disk-cache'; import { ok, info, fail } from '../../utils/ui'; +import { getCcsDir } from '../../utils/config-manager'; // ============================================================================ // Multi-Instance Support - Aggregate usage from CCS profiles // ============================================================================ /** Path to CCS instances directory */ -const CCS_INSTANCES_DIR = path.join(os.homedir(), '.ccs', 'instances'); +function getCcsInstancesDir() { + return path.join(getCcsDir(), 'instances'); +} /** * Get list of CCS instance paths that have usage data * Only returns instances with existing projects/ directory */ function getInstancePaths(): string[] { - if (!fs.existsSync(CCS_INSTANCES_DIR)) { + const instancesDir = getCcsInstancesDir(); + if (!fs.existsSync(instancesDir)) { return []; } try { - const entries = fs.readdirSync(CCS_INSTANCES_DIR, { withFileTypes: true }); + const entries = fs.readdirSync(instancesDir, { withFileTypes: true }); return entries .filter((entry) => entry.isDirectory()) - .map((entry) => path.join(CCS_INSTANCES_DIR, entry.name)) + .map((entry) => path.join(instancesDir, entry.name)) .filter((instancePath) => { // Only include instances that have a projects directory const projectsPath = path.join(instancePath, 'projects'); diff --git a/src/web-server/usage/disk-cache.ts b/src/web-server/usage/disk-cache.ts index fd834da2..523169c9 100644 --- a/src/web-server/usage/disk-cache.ts +++ b/src/web-server/usage/disk-cache.ts @@ -10,14 +10,17 @@ import * as fs from 'fs'; import * as path from 'path'; -import * as os from 'os'; import type { DailyUsage, HourlyUsage, MonthlyUsage, SessionUsage } from './types'; import { ok, info, warn } from '../../utils/ui'; +import { getCcsDir } from '../../utils/config-manager'; // Cache configuration -const CCS_DIR = path.join(os.homedir(), '.ccs'); -const CACHE_DIR = path.join(CCS_DIR, 'cache'); -const CACHE_FILE = path.join(CACHE_DIR, 'usage.json'); +function getCacheDir() { + return path.join(getCcsDir(), 'cache'); +} +function getCacheFile() { + return path.join(getCacheDir(), 'usage.json'); +} const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes const STALE_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days (max age for stale data) @@ -39,8 +42,9 @@ const CACHE_VERSION = 3; * Ensure ~/.ccs/cache directory exists */ function ensureCacheDir(): void { - if (!fs.existsSync(CACHE_DIR)) { - fs.mkdirSync(CACHE_DIR, { recursive: true }); + const dir = getCacheDir(); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); } } @@ -51,11 +55,12 @@ function ensureCacheDir(): void { */ export function readDiskCache(): UsageDiskCache | null { try { - if (!fs.existsSync(CACHE_FILE)) { + const cacheFile = getCacheFile(); + if (!fs.existsSync(cacheFile)) { return null; } - const data = fs.readFileSync(CACHE_FILE, 'utf-8'); + const data = fs.readFileSync(cacheFile, 'utf-8'); const cache: UsageDiskCache = JSON.parse(data); // Version check - invalidate if schema changed @@ -113,9 +118,10 @@ export function writeDiskCache( }; // Write atomically using temp file + rename - const tempFile = CACHE_FILE + '.tmp'; + const cacheFile = getCacheFile(); + const tempFile = cacheFile + '.tmp'; fs.writeFileSync(tempFile, JSON.stringify(cache), 'utf-8'); - fs.renameSync(tempFile, CACHE_FILE); + fs.renameSync(tempFile, cacheFile); console.log(ok('Disk cache updated')); } catch (err) { @@ -145,8 +151,9 @@ export function getCacheAge(cache: UsageDiskCache | null): string { */ export function clearDiskCache(): void { try { - if (fs.existsSync(CACHE_FILE)) { - fs.unlinkSync(CACHE_FILE); + const cacheFile = getCacheFile(); + if (fs.existsSync(cacheFile)) { + fs.unlinkSync(cacheFile); console.log(ok('Disk cache cleared')); } } catch (err) { diff --git a/tests/unit/cliproxy/port-validation.test.ts b/tests/unit/cliproxy/port-validation.test.ts index ac9831c8..71d7301a 100644 --- a/tests/unit/cliproxy/port-validation.test.ts +++ b/tests/unit/cliproxy/port-validation.test.ts @@ -15,8 +15,8 @@ import { describe, it, expect } from 'bun:test'; import { validatePort, CLIPROXY_DEFAULT_PORT, -} from '../../../dist/cliproxy/config-generator'; -import { resolveProxyConfig } from '../../../dist/cliproxy/proxy-config-resolver'; +} from '../../../src/cliproxy/config-generator'; +import { resolveProxyConfig } from '../../../src/cliproxy/proxy-config-resolver'; describe('Port Validation', () => { describe('validatePort()', () => { diff --git a/tests/unit/cliproxy/proxy-detector-port.test.ts b/tests/unit/cliproxy/proxy-detector-port.test.ts index 6006b328..1ed10431 100644 --- a/tests/unit/cliproxy/proxy-detector-port.test.ts +++ b/tests/unit/cliproxy/proxy-detector-port.test.ts @@ -6,8 +6,8 @@ */ import { describe, it, expect } from 'bun:test'; -import { detectRunningProxy } from '../../../dist/cliproxy/proxy-detector'; -import { CLIPROXY_DEFAULT_PORT } from '../../../dist/cliproxy/config-generator'; +import { detectRunningProxy } from '../../../src/cliproxy/proxy-detector'; +import { CLIPROXY_DEFAULT_PORT } from '../../../src/cliproxy/config-generator'; describe('Proxy Detector Port Validation', () => { describe('detectRunningProxy with invalid ports', () => { diff --git a/tests/unit/cliproxy/schema-sanitizer.test.ts b/tests/unit/cliproxy/schema-sanitizer.test.ts index c9a5cabb..b5e64f38 100644 --- a/tests/unit/cliproxy/schema-sanitizer.test.ts +++ b/tests/unit/cliproxy/schema-sanitizer.test.ts @@ -1,14 +1,15 @@ /** * Schema Sanitizer Unit Tests * - * Tests for MCP tool input_schema sanitization. + * Tests for Gemini-compatible tool input_schema sanitization. + * Verifies that only Gemini-supported fields are preserved. */ import { describe, expect, test } from 'bun:test'; -import { sanitizeInputSchema, sanitizeToolSchemas } from '../../../dist/cliproxy/schema-sanitizer.js'; +import { sanitizeInputSchema, sanitizeToolSchemas } from '../../../src/cliproxy/schema-sanitizer'; describe('sanitizeInputSchema', () => { - test('preserves valid JSON Schema properties', () => { + test('preserves Gemini-supported properties', () => { const schema = { type: 'object', properties: { @@ -110,10 +111,10 @@ describe('sanitizeInputSchema', () => { }); }); - test('handles oneOf/anyOf/allOf with nested schemas', () => { + test('preserves anyOf and sanitizes nested schemas', () => { const schema = { type: 'object', - oneOf: [ + anyOf: [ { type: 'string', customProp: 'remove' }, { type: 'number', anotherCustom: 123 }, ], @@ -124,10 +125,25 @@ describe('sanitizeInputSchema', () => { expect(result.removedCount).toBe(2); expect(result.schema).toEqual({ type: 'object', - oneOf: [{ type: 'string' }, { type: 'number' }], + anyOf: [{ type: 'string' }, { type: 'number' }], }); }); + test('removes oneOf and allOf (not supported by Gemini)', () => { + const schema = { + type: 'object', + oneOf: [{ type: 'string' }, { type: 'number' }], + allOf: [{ required: ['name'] }], + }; + + const result = sanitizeInputSchema(schema); + + expect(result.removedCount).toBe(2); + expect(result.removedPaths).toContain('oneOf'); + expect(result.removedPaths).toContain('allOf'); + expect(result.schema).toEqual({ type: 'object' }); + }); + test('handles deeply nested structures', () => { const schema = { type: 'object', @@ -153,51 +169,52 @@ describe('sanitizeInputSchema', () => { expect(result.removedPaths).toContain('properties.level1.properties.level2.uiHint'); }); - test('preserves $defs and definitions', () => { + test('strips "examples" field (issue #155)', () => { const schema = { type: 'object', - $defs: { - address: { - type: 'object', - customUI: 'remove', - properties: { - street: { type: 'string' }, - }, - }, - }, properties: { - home: { $ref: '#/$defs/address' }, + command: { + type: 'string', + description: 'The command to execute', + examples: ['ls -la', 'git status'], + }, + timeout: { + type: 'number', + description: 'Timeout in ms', + examples: [5000, 10000], + }, }, }; const result = sanitizeInputSchema(schema); - expect(result.removedCount).toBe(1); - expect(result.removedPaths).toContain('$defs.address.customUI'); + expect(result.removedCount).toBe(2); + expect(result.removedPaths).toContain('properties.command.examples'); + expect(result.removedPaths).toContain('properties.timeout.examples'); expect(result.schema).toEqual({ type: 'object', - $defs: { - address: { - type: 'object', - properties: { - street: { type: 'string' }, - }, - }, - }, properties: { - home: { $ref: '#/$defs/address' }, + command: { type: 'string', description: 'The command to execute' }, + timeout: { type: 'number', description: 'Timeout in ms' }, }, }); }); - test('handles empty schema', () => { - const result = sanitizeInputSchema({}); + test('keeps "example" (singular) but strips "examples" (plural)', () => { + const schema = { + type: 'string', + example: 'hello', + examples: ['hello', 'world'], + }; - expect(result.removedCount).toBe(0); - expect(result.schema).toEqual({}); + const result = sanitizeInputSchema(schema); + + expect(result.removedCount).toBe(1); + expect(result.removedPaths).toContain('examples'); + expect(result.schema).toEqual({ type: 'string', example: 'hello' }); }); - test('preserves all standard metadata keywords', () => { + test('strips Gemini-unsupported JSON Schema metadata fields', () => { const schema = { $id: 'https://example.com/schema', $schema: 'https://json-schema.org/draft-07/schema#', @@ -212,95 +229,187 @@ describe('sanitizeInputSchema', () => { const result = sanitizeInputSchema(schema); - expect(result.removedCount).toBe(0); - expect(result.schema).toEqual(schema); + // $id, $schema, examples, deprecated, readOnly, $comment = 6 removed + expect(result.removedCount).toBe(6); + expect(result.removedPaths).toContain('$id'); + expect(result.removedPaths).toContain('$schema'); + expect(result.removedPaths).toContain('examples'); + expect(result.removedPaths).toContain('deprecated'); + expect(result.removedPaths).toContain('readOnly'); + expect(result.removedPaths).toContain('$comment'); + expect(result.schema).toEqual({ + title: 'Test Schema', + description: 'A test schema', + type: 'string', + }); }); - test('preserves additionalItems keyword', () => { - const schema = { - type: 'array', - items: [{ type: 'string' }], - additionalItems: { type: 'boolean' }, - }; - const result = sanitizeInputSchema(schema); - expect(result.removedCount).toBe(0); - expect(result.schema.additionalItems).toEqual({ type: 'boolean' }); - }); - - test('sanitizes nested schemas in additionalItems', () => { - const schema = { - type: 'array', - items: [{ type: 'string' }], - additionalItems: { type: 'boolean', customProp: 'remove' }, - }; - const result = sanitizeInputSchema(schema); - expect(result.removedCount).toBe(1); - expect(result.removedPaths).toContain('additionalItems.customProp'); - expect(result.schema.additionalItems).toEqual({ type: 'boolean' }); - }); - - test('preserves if/then/else and sanitizes nested schemas', () => { - const schema = { - if: { properties: { type: { const: 'foo' } }, uiHint: 'remove' }, - then: { required: ['foo'], badProp: 123 }, - else: { required: ['bar'] }, - }; - const result = sanitizeInputSchema(schema); - expect(result.removedCount).toBe(2); - expect(result.schema.if).toEqual({ properties: { type: { const: 'foo' } } }); - expect(result.schema.then).toEqual({ required: ['foo'] }); - }); - - test('preserves patternProperties keyword and sanitizes nested schemas', () => { + test('strips $defs, $ref, definitions (not supported by Gemini)', () => { const schema = { type: 'object', - patternProperties: { '^S_': { type: 'string', customProp: 'remove' } }, - }; - const result = sanitizeInputSchema(schema); - expect(result.removedCount).toBe(1); - expect(result.removedPaths).toContain('patternProperties.^S_.customProp'); - expect(result.schema.patternProperties).toEqual({ '^S_': { type: 'string' } }); - }); - - test('preserves contains keyword and sanitizes nested schema', () => { - const schema = { - type: 'array', - contains: { type: 'number', minimum: 5, customProp: 'remove' }, - }; - const result = sanitizeInputSchema(schema); - expect(result.removedCount).toBe(1); - expect(result.removedPaths).toContain('contains.customProp'); - expect(result.schema.contains).toEqual({ type: 'number', minimum: 5 }); - }); - - test('preserves propertyNames keyword and sanitizes nested schema', () => { - const schema = { - type: 'object', - propertyNames: { type: 'string', pattern: '^[a-z]+$', customProp: 'remove' }, - }; - const result = sanitizeInputSchema(schema); - expect(result.removedCount).toBe(1); - expect(result.removedPaths).toContain('propertyNames.customProp'); - expect(result.schema.propertyNames).toEqual({ type: 'string', pattern: '^[a-z]+$' }); - }); - - test('handles dependencies with both property and schema dependencies', () => { - const schema = { - type: 'object', - dependencies: { - bar: ['foo'], // property dependency (array) - pass through - baz: { properties: { qux: { type: 'string' } }, customProp: 'remove' }, // schema dependency + $defs: { + address: { + type: 'object', + properties: { street: { type: 'string' } }, + }, + }, + properties: { + home: { $ref: '#/$defs/address' }, }, }; + const result = sanitizeInputSchema(schema); - expect(result.removedCount).toBe(1); - expect(result.removedPaths).toContain('dependencies.baz.customProp'); - expect(result.schema.dependencies).toEqual({ - bar: ['foo'], - baz: { properties: { qux: { type: 'string' } } }, + + expect(result.removedCount).toBe(2); + expect(result.removedPaths).toContain('$defs'); + expect(result.removedPaths).toContain('properties.home.$ref'); + expect(result.schema).toEqual({ + type: 'object', + properties: { + home: {}, + }, }); }); + test('strips additionalProperties (not supported by Gemini)', () => { + const schema = { + type: 'object', + properties: { name: { type: 'string' } }, + additionalProperties: false, + }; + + const result = sanitizeInputSchema(schema); + + expect(result.removedCount).toBe(1); + expect(result.removedPaths).toContain('additionalProperties'); + }); + + test('strips const, not, if/then/else (not supported by Gemini)', () => { + const schema = { + type: 'object', + const: 'fixed', + not: { type: 'number' }, + if: { properties: { type: { const: 'foo' } } }, + then: { required: ['foo'] }, + else: { required: ['bar'] }, + }; + + const result = sanitizeInputSchema(schema); + + expect(result.removedCount).toBe(5); + expect(result.removedPaths).toContain('const'); + expect(result.removedPaths).toContain('not'); + expect(result.removedPaths).toContain('if'); + expect(result.removedPaths).toContain('then'); + expect(result.removedPaths).toContain('else'); + expect(result.schema).toEqual({ type: 'object' }); + }); + + test('strips number/array/object validation fields unsupported by Gemini', () => { + const schema = { + type: 'object', + properties: { + score: { + type: 'number', + exclusiveMinimum: 0, + exclusiveMaximum: 100, + multipleOf: 5, + }, + tags: { + type: 'array', + items: { type: 'string' }, + uniqueItems: true, + contains: { type: 'string' }, + additionalItems: false, + }, + meta: { + type: 'object', + writeOnly: true, + definitions: { addr: { type: 'string' } }, + }, + }, + }; + + const result = sanitizeInputSchema(schema); + + expect(result.removedPaths).toContain('properties.score.exclusiveMinimum'); + expect(result.removedPaths).toContain('properties.score.exclusiveMaximum'); + expect(result.removedPaths).toContain('properties.score.multipleOf'); + expect(result.removedPaths).toContain('properties.tags.uniqueItems'); + expect(result.removedPaths).toContain('properties.tags.contains'); + expect(result.removedPaths).toContain('properties.tags.additionalItems'); + expect(result.removedPaths).toContain('properties.meta.writeOnly'); + expect(result.removedPaths).toContain('properties.meta.definitions'); + expect(result.removedCount).toBe(8); + }); + + test('preserves example/default with complex values without recursion', () => { + const schema = { + type: 'object', + example: ['value1', 'value2'], + default: { nested: { deep: { unsupportedKey: true } } }, + }; + + const result = sanitizeInputSchema(schema); + + expect(result.removedCount).toBe(0); + expect(result.schema.example).toEqual(['value1', 'value2']); + expect(result.schema.default).toEqual({ nested: { deep: { unsupportedKey: true } } }); + }); + + test('handles empty properties and anyOf edge cases', () => { + const schema = { + type: 'object', + properties: {}, + anyOf: [], + }; + + const result = sanitizeInputSchema(schema); + + expect(result.removedCount).toBe(0); + expect(result.schema.properties).toEqual({}); + expect(result.schema.anyOf).toEqual([]); + }); + + test('preserves all Gemini-supported fields', () => { + const schema = { + type: 'object', + format: 'date', + title: 'Test', + description: 'A test', + nullable: true, + example: { name: 'test' }, + default: {}, + enum: ['a', 'b'], + properties: { name: { type: 'string' } }, + required: ['name'], + minProperties: 1, + maxProperties: 10, + items: { type: 'string' }, + minItems: 0, + maxItems: 100, + minLength: 1, + maxLength: 255, + pattern: '^[a-z]+$', + minimum: 0, + maximum: 100, + anyOf: [{ type: 'string' }], + propertyOrdering: ['name'], + }; + + const result = sanitizeInputSchema(schema); + + expect(result.removedCount).toBe(0); + expect(result.removedPaths).toEqual([]); + }); + + test('handles empty schema', () => { + const result = sanitizeInputSchema({}); + + expect(result.removedCount).toBe(0); + expect(result.schema).toEqual({}); + }); + test('handles null input gracefully', () => { const result = sanitizeInputSchema(null as any); expect(result.schema).toBeNull(); @@ -361,6 +470,70 @@ describe('sanitizeToolSchemas', () => { }); }); + test('strips examples from Claude Code tool schemas (issue #155)', () => { + const tools = [ + { + name: 'Bash', + input_schema: { + type: 'object', + properties: { + command: { + type: 'string', + description: 'The command to execute', + examples: ['ls -la', 'git status', 'npm install'], + }, + timeout: { + type: 'number', + description: 'Timeout in ms', + examples: [5000, 30000], + }, + }, + required: ['command'], + }, + }, + { + name: 'Read', + input_schema: { + type: 'object', + properties: { + file_path: { + type: 'string', + description: 'File path to read', + examples: ['/src/index.ts', '/package.json'], + }, + }, + required: ['file_path'], + }, + }, + ]; + + const result = sanitizeToolSchemas(tools); + + expect(result.totalRemoved).toBe(3); + expect(result.removedByTool).toHaveLength(2); + + // Verify examples stripped from Bash tool + const bashTool = result.tools.find((t) => t.name === 'Bash'); + expect(bashTool?.input_schema).toEqual({ + type: 'object', + properties: { + command: { type: 'string', description: 'The command to execute' }, + timeout: { type: 'number', description: 'Timeout in ms' }, + }, + required: ['command'], + }); + + // Verify examples stripped from Read tool + const readTool = result.tools.find((t) => t.name === 'Read'); + expect(readTool?.input_schema).toEqual({ + type: 'object', + properties: { + file_path: { type: 'string', description: 'File path to read' }, + }, + required: ['file_path'], + }); + }); + test('handles tools without input_schema', () => { const tools = [ { name: 'simple_tool', description: 'No schema' }, diff --git a/tests/unit/commands/env-command.test.ts b/tests/unit/commands/env-command.test.ts new file mode 100644 index 00000000..b6da6236 --- /dev/null +++ b/tests/unit/commands/env-command.test.ts @@ -0,0 +1,215 @@ +/** + * Unit tests for env-command.ts + * + * Tests pure utility functions: detectShell, formatExportLine, transformToOpenAI + */ +import { describe, it, expect, afterEach } from 'bun:test'; +import { + detectShell, + formatExportLine, + transformToOpenAI, + parseFlag, + findProfile, +} from '../../../src/commands/env-command'; + +describe('env-command', () => { + describe('detectShell', () => { + const originalShell = process.env['SHELL']; + + afterEach(() => { + if (originalShell !== undefined) { + process.env['SHELL'] = originalShell; + } else { + delete process.env['SHELL']; + } + }); + + it('returns explicit bash flag', () => { + expect(detectShell('bash')).toBe('bash'); + }); + + it('returns explicit fish flag', () => { + expect(detectShell('fish')).toBe('fish'); + }); + + it('returns explicit powershell flag', () => { + expect(detectShell('powershell')).toBe('powershell'); + }); + + it('auto-detects bash from SHELL=/bin/zsh', () => { + process.env['SHELL'] = '/bin/zsh'; + expect(detectShell('auto')).toBe('bash'); + }); + + it('auto-detects bash from SHELL=/bin/bash', () => { + process.env['SHELL'] = '/bin/bash'; + expect(detectShell()).toBe('bash'); + }); + + it('auto-detects fish from SHELL=/usr/bin/fish', () => { + process.env['SHELL'] = '/usr/bin/fish'; + expect(detectShell('auto')).toBe('fish'); + }); + + it('defaults to bash when SHELL is empty', () => { + process.env['SHELL'] = ''; + expect(detectShell()).toBe('bash'); + }); + + it('ignores invalid flag and auto-detects', () => { + process.env['SHELL'] = '/bin/bash'; + expect(detectShell('invalid')).toBe('bash'); + }); + + it('auto-detects powershell from SHELL containing pwsh', () => { + process.env['SHELL'] = '/usr/local/bin/pwsh'; + expect(detectShell('auto')).toBe('powershell'); + }); + }); + + describe('formatExportLine', () => { + it('formats bash export', () => { + expect(formatExportLine('bash', 'API_KEY', 'sk-123')).toBe("export API_KEY='sk-123'"); + }); + + it('formats fish export', () => { + expect(formatExportLine('fish', 'API_KEY', 'sk-123')).toBe("set -gx API_KEY 'sk-123'"); + }); + + it('formats powershell export', () => { + expect(formatExportLine('powershell', 'API_KEY', 'sk-123')).toBe( + "$env:API_KEY = 'sk-123'" + ); + }); + + it('escapes single quotes in values', () => { + expect(formatExportLine('bash', 'VAL', "it's here")).toBe( + "export VAL='it'\\''s here'" + ); + }); + + it('handles empty values', () => { + expect(formatExportLine('bash', 'EMPTY', '')).toBe("export EMPTY=''"); + }); + + it('handles URLs with special characters', () => { + const url = 'http://127.0.0.1:8317/api/provider/gemini'; + expect(formatExportLine('bash', 'BASE_URL', url)).toBe(`export BASE_URL='${url}'`); + }); + + it('prevents shell injection with $() in values', () => { + expect(formatExportLine('bash', 'TOKEN', 'safe$(whoami)')).toBe( + "export TOKEN='safe$(whoami)'" + ); + }); + + it('prevents backtick injection in values', () => { + expect(formatExportLine('bash', 'TOKEN', 'safe`whoami`')).toBe( + "export TOKEN='safe`whoami`'" + ); + }); + + it('escapes single quotes in fish values', () => { + expect(formatExportLine('fish', 'VAL', "it's here")).toBe("set -gx VAL 'it'\\''s here'"); + }); + + it('escapes single quotes in powershell values', () => { + expect(formatExportLine('powershell', 'VAL', "it's here")).toBe( + "$env:VAL = 'it''s here'" + ); + }); + }); + + describe('transformToOpenAI', () => { + it('maps Anthropic vars to OpenAI format', () => { + const result = transformToOpenAI({ + ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/gemini', + ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed', + ANTHROPIC_MODEL: 'gemini-claude-sonnet-4-5', + }); + + expect(result).toEqual({ + OPENAI_API_KEY: 'ccs-internal-managed', + OPENAI_BASE_URL: 'http://127.0.0.1:8317/api/provider/gemini', + LOCAL_ENDPOINT: 'http://127.0.0.1:8317/api/provider/gemini', + OPENAI_MODEL: 'gemini-claude-sonnet-4-5', + }); + }); + + it('handles missing source vars gracefully', () => { + const result = transformToOpenAI({}); + + expect(result).toEqual({}); + }); + + it('only extracts relevant vars', () => { + const result = transformToOpenAI({ + ANTHROPIC_BASE_URL: 'http://localhost:8317', + ANTHROPIC_AUTH_TOKEN: 'key', + ANTHROPIC_MAX_TOKENS: '8096', + DISABLE_TELEMETRY: '1', + }); + + // OPENAI_API_KEY + OPENAI_BASE_URL + LOCAL_ENDPOINT (no OPENAI_MODEL when ANTHROPIC_MODEL absent) + expect(Object.keys(result)).toHaveLength(3); + expect(result['ANTHROPIC_MAX_TOKENS']).toBeUndefined(); + }); + + it('omits OPENAI_MODEL when ANTHROPIC_MODEL absent', () => { + const result = transformToOpenAI({ + ANTHROPIC_BASE_URL: 'http://localhost:8317', + ANTHROPIC_AUTH_TOKEN: 'key', + }); + + expect(result['OPENAI_MODEL']).toBeUndefined(); + }); + }); + + describe('parseFlag', () => { + it('parses --flag=value style', () => { + expect(parseFlag(['--format=openai'], 'format')).toBe('openai'); + }); + + it('parses --flag value style', () => { + expect(parseFlag(['--format', 'openai'], 'format')).toBe('openai'); + }); + + it('handles values containing =', () => { + expect(parseFlag(['--format=key=val=ue'], 'format')).toBe('key=val=ue'); + }); + + it('returns undefined for missing flag', () => { + expect(parseFlag(['--shell', 'bash'], 'format')).toBeUndefined(); + }); + + it('does not consume next flag as value', () => { + expect(parseFlag(['--format', '--shell'], 'format')).toBeUndefined(); + }); + }); + + describe('findProfile', () => { + it('finds profile as first positional arg', () => { + expect(findProfile(['gemini'], ['format', 'shell'])).toBe('gemini'); + }); + + it('skips flags before profile', () => { + expect(findProfile(['--format', 'openai', 'gemini'], ['format', 'shell'])).toBe('gemini'); + }); + + it('skips --flag=value style flags', () => { + expect(findProfile(['--format=openai', 'gemini'], ['format', 'shell'])).toBe('gemini'); + }); + + it('handles profile before flags', () => { + expect(findProfile(['gemini', '--format', 'openai'], ['format', 'shell'])).toBe('gemini'); + }); + + it('returns undefined when no positional args', () => { + expect(findProfile(['--format', 'openai', '--shell', 'fish'], ['format', 'shell'])).toBeUndefined(); + }); + + it('skips multiple flag-value pairs', () => { + expect(findProfile(['--format', 'openai', '--shell', 'fish', 'codex'], ['format', 'shell'])).toBe('codex'); + }); + }); +}); diff --git a/tests/unit/config-dir-override.test.js b/tests/unit/config-dir-override.test.js new file mode 100644 index 00000000..29ca2435 --- /dev/null +++ b/tests/unit/config-dir-override.test.js @@ -0,0 +1,163 @@ +/** + * Config Directory Override Unit Tests + * + * Tests CCS_DIR env var and --config-dir flag precedence, + * getCcsDirSource() diagnostic, and cloud sync path detection. + */ + +const assert = require('assert'); +const path = require('path'); +const os = require('os'); + +describe('Config Directory Override', function () { + let originalCcsDir; + let originalCcsHome; + + beforeEach(function () { + originalCcsDir = process.env.CCS_DIR; + originalCcsHome = process.env.CCS_HOME; + delete process.env.CCS_DIR; + delete process.env.CCS_HOME; + // Reset module-level state + const { setGlobalConfigDir } = require('../../dist/utils/config-manager'); + setGlobalConfigDir(undefined); + }); + + afterEach(function () { + if (originalCcsDir !== undefined) process.env.CCS_DIR = originalCcsDir; + else delete process.env.CCS_DIR; + if (originalCcsHome !== undefined) process.env.CCS_HOME = originalCcsHome; + else delete process.env.CCS_HOME; + // Reset module-level state + const { setGlobalConfigDir } = require('../../dist/utils/config-manager'); + setGlobalConfigDir(undefined); + }); + + describe('getCcsDir() precedence', function () { + it('should return ~/.ccs by default', function () { + const { getCcsDir } = require('../../dist/utils/config-manager'); + const expected = path.join(os.homedir(), '.ccs'); + assert.strictEqual(getCcsDir(), expected); + }); + + it('should use CCS_HOME with .ccs appended (legacy behavior)', function () { + process.env.CCS_HOME = '/tmp/test-home'; + const { getCcsDir } = require('../../dist/utils/config-manager'); + assert.strictEqual(getCcsDir(), '/tmp/test-home/.ccs'); + }); + + it('should use CCS_DIR directly (no .ccs append)', function () { + process.env.CCS_DIR = '/tmp/my-ccs-config'; + const { getCcsDir } = require('../../dist/utils/config-manager'); + assert.strictEqual(getCcsDir(), path.resolve('/tmp/my-ccs-config')); + }); + + it('should give CCS_DIR precedence over CCS_HOME', function () { + process.env.CCS_DIR = '/tmp/ccs-dir'; + process.env.CCS_HOME = '/tmp/ccs-home'; + const { getCcsDir } = require('../../dist/utils/config-manager'); + assert.strictEqual(getCcsDir(), path.resolve('/tmp/ccs-dir')); + }); + + it('should give setGlobalConfigDir() highest precedence', function () { + process.env.CCS_DIR = '/tmp/env-dir'; + process.env.CCS_HOME = '/tmp/env-home'; + const { getCcsDir, setGlobalConfigDir } = require('../../dist/utils/config-manager'); + setGlobalConfigDir('/tmp/flag-dir'); + assert.strictEqual(getCcsDir(), path.resolve('/tmp/flag-dir')); + }); + + it('should resolve relative paths in setGlobalConfigDir()', function () { + const { getCcsDir, setGlobalConfigDir } = require('../../dist/utils/config-manager'); + setGlobalConfigDir('relative/path'); + assert.strictEqual(getCcsDir(), path.resolve('relative/path')); + }); + + it('should clear override when setGlobalConfigDir(undefined) is called', function () { + const { getCcsDir, setGlobalConfigDir } = require('../../dist/utils/config-manager'); + setGlobalConfigDir('/tmp/override'); + assert.strictEqual(getCcsDir(), path.resolve('/tmp/override')); + setGlobalConfigDir(undefined); + const expected = path.join(os.homedir(), '.ccs'); + assert.strictEqual(getCcsDir(), expected); + }); + }); + + describe('getCcsDirSource()', function () { + it('should return "default" when no overrides set', function () { + const { getCcsDirSource } = require('../../dist/utils/config-manager'); + const [source] = getCcsDirSource(); + assert.strictEqual(source, 'default'); + }); + + it('should return "CCS_DIR" when CCS_DIR is set', function () { + process.env.CCS_DIR = '/tmp/test'; + const { getCcsDirSource } = require('../../dist/utils/config-manager'); + const [source] = getCcsDirSource(); + assert.strictEqual(source, 'CCS_DIR'); + }); + + it('should return "CCS_HOME" when only CCS_HOME is set', function () { + process.env.CCS_HOME = '/tmp/home'; + const { getCcsDirSource } = require('../../dist/utils/config-manager'); + const [source] = getCcsDirSource(); + assert.strictEqual(source, 'CCS_HOME'); + }); + + it('should return "--config-dir" when setGlobalConfigDir() is set', function () { + const { getCcsDirSource, setGlobalConfigDir } = require('../../dist/utils/config-manager'); + setGlobalConfigDir('/tmp/flag-test'); + const [source, dir] = getCcsDirSource(); + assert.strictEqual(source, '--config-dir'); + assert.strictEqual(dir, path.resolve('/tmp/flag-test')); + }); + }); + + describe('detectCloudSyncPath()', function () { + it('should detect Dropbox', function () { + const { detectCloudSyncPath } = require('../../dist/utils/config-manager'); + assert.strictEqual(detectCloudSyncPath('/Users/kai/Dropbox/ccs'), 'Dropbox'); + }); + + it('should detect OneDrive on Windows paths', function () { + const { detectCloudSyncPath } = require('../../dist/utils/config-manager'); + assert.strictEqual(detectCloudSyncPath('C:\\Users\\kai\\OneDrive\\ccs'), 'OneDrive'); + }); + + it('should detect iCloud Drive', function () { + const { detectCloudSyncPath } = require('../../dist/utils/config-manager'); + assert.strictEqual( + detectCloudSyncPath('/Users/kai/Library/Mobile Documents/iCloud Drive/ccs'), + 'iCloud Drive' + ); + }); + + it('should detect Google Drive', function () { + const { detectCloudSyncPath } = require('../../dist/utils/config-manager'); + assert.strictEqual(detectCloudSyncPath('/Users/kai/Google Drive/ccs'), 'Google Drive'); + }); + + it('should detect cloud paths case-insensitively', function () { + const { detectCloudSyncPath } = require('../../dist/utils/config-manager'); + assert.strictEqual(detectCloudSyncPath('/Users/kai/dropbox/ccs'), 'Dropbox'); + assert.strictEqual(detectCloudSyncPath('C:\\Users\\kai\\onedrive\\ccs'), 'OneDrive'); + assert.strictEqual(detectCloudSyncPath('/Users/kai/google drive/ccs'), 'Google Drive'); + }); + + it('should return null for regular paths', function () { + const { detectCloudSyncPath } = require('../../dist/utils/config-manager'); + assert.strictEqual(detectCloudSyncPath('/home/kai/.ccs'), null); + }); + + it('should return null for default ~/.ccs path', function () { + const { detectCloudSyncPath } = require('../../dist/utils/config-manager'); + assert.strictEqual(detectCloudSyncPath(path.join(os.homedir(), '.ccs')), null); + }); + + it('should not false-positive on substrings (e.g., megauser, Dropbox-api)', function () { + const { detectCloudSyncPath } = require('../../dist/utils/config-manager'); + assert.strictEqual(detectCloudSyncPath('/home/megauser/.ccs'), null); + assert.strictEqual(detectCloudSyncPath('/home/kai/Dropbox-api-client/ccs'), null); + }); + }); +}); diff --git a/tests/unit/utils/process-utils.test.ts b/tests/unit/utils/process-utils.test.ts new file mode 100644 index 00000000..cfae232a --- /dev/null +++ b/tests/unit/utils/process-utils.test.ts @@ -0,0 +1,132 @@ +/** + * Unit tests for process-utils.ts + */ +import { describe, it, expect, beforeEach, afterEach, jest } from 'bun:test'; +import { EventEmitter } from 'events'; +import { killWithEscalation } from '../../../src/utils/process-utils'; +import type { ChildProcess } from 'child_process'; + +// Mock ChildProcess using EventEmitter +function createMockProcess(exitCode: number | null = null): ChildProcess { + const proc = new EventEmitter() as any; + proc.killed = false; + proc.exitCode = exitCode; + proc.kill = jest.fn((signal?: string) => { + if (signal === 'SIGTERM' || signal === 'SIGKILL') { + proc.killed = true; + } + return true; + }); + return proc as ChildProcess; +} + +describe('killWithEscalation', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('should send SIGTERM immediately', () => { + const proc = createMockProcess(); + killWithEscalation(proc); + + expect(proc.kill).toHaveBeenCalledWith('SIGTERM'); + expect(proc.kill).toHaveBeenCalledTimes(1); + }); + + it('should send SIGKILL after grace period if process still running', () => { + const proc = createMockProcess(null); // exitCode null = still running + killWithEscalation(proc, 3000); + + // SIGTERM sent immediately + expect(proc.kill).toHaveBeenCalledWith('SIGTERM'); + expect(proc.kill).toHaveBeenCalledTimes(1); + + // Advance time by grace period + jest.advanceTimersByTime(3000); + + // SIGKILL sent after grace period + expect(proc.kill).toHaveBeenCalledWith('SIGKILL'); + expect(proc.kill).toHaveBeenCalledTimes(2); + }); + + it('should NOT send SIGKILL if process exits before grace period', () => { + const proc = createMockProcess(null); + killWithEscalation(proc, 3000); + + expect(proc.kill).toHaveBeenCalledWith('SIGTERM'); + expect(proc.kill).toHaveBeenCalledTimes(1); + + // Simulate process exit after 1 second + jest.advanceTimersByTime(1000); + proc.exitCode = 0; // Process exited + proc.emit('exit', 0); + + // Advance remaining time + jest.advanceTimersByTime(2000); + + // SIGKILL should NOT have been sent + expect(proc.kill).toHaveBeenCalledTimes(1); + expect(proc.kill).not.toHaveBeenCalledWith('SIGKILL'); + }); + + it('should use default grace period of 3000ms', () => { + const proc = createMockProcess(null); + killWithEscalation(proc); // No grace period argument + + expect(proc.kill).toHaveBeenCalledWith('SIGTERM'); + + // Advance by default 3000ms + jest.advanceTimersByTime(3000); + + expect(proc.kill).toHaveBeenCalledWith('SIGKILL'); + }); + + it('should respect custom grace period', () => { + const proc = createMockProcess(null); + killWithEscalation(proc, 5000); // Custom 5 second grace period + + expect(proc.kill).toHaveBeenCalledWith('SIGTERM'); + + // Advance by less than grace period + jest.advanceTimersByTime(4999); + expect(proc.kill).toHaveBeenCalledTimes(1); // Still only SIGTERM + + // Advance to grace period + jest.advanceTimersByTime(1); + expect(proc.kill).toHaveBeenCalledWith('SIGKILL'); + }); + + it('should clear timer when process exits', () => { + const proc = createMockProcess(null); + killWithEscalation(proc, 3000); + + // Simulate immediate exit + proc.exitCode = 0; + proc.emit('exit', 0); + + // Advance way past grace period + jest.advanceTimersByTime(10000); + + // Should only have SIGTERM, timer was cleared + expect(proc.kill).toHaveBeenCalledTimes(1); + expect(proc.kill).toHaveBeenCalledWith('SIGTERM'); + }); + + it('should handle process that already exited', () => { + const proc = createMockProcess(0); // Already exited + killWithEscalation(proc, 3000); + + expect(proc.kill).toHaveBeenCalledWith('SIGTERM'); + + // Even though exitCode is not null, timer still fires + // (because we check exitCode at timer callback time) + jest.advanceTimersByTime(3000); + + // SIGKILL should NOT be sent because exitCode is not null + expect(proc.kill).toHaveBeenCalledTimes(1); + }); +}); diff --git a/ui/bun.lock b/ui/bun.lock index 2756da98..c2e29de1 100644 --- a/ui/bun.lock +++ b/ui/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "ui",