mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-13 20:14:34 +00:00
Merge pull request #510 from kaitranntt/dev
feat: v7.40.0 — env command, config dir override, Gemini schema fix, OAuth hang fix
This commit is contained in:
52 files changed
+1722
-449
No files matched your search
@@ -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()` |
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 0,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "@kaitranntt/ccs",
|
||||
|
||||
@@ -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 |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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 |
|
||||
+------------------+
|
||||
| | |
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
@@ -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}) )
|
||||
|
||||
+26
-17
@@ -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
|
||||
# ============================================================================
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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]' \
|
||||
|
||||
@@ -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(),
|
||||
|
||||
+80
-5
@@ -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<boolean> {
|
||||
|
||||
async function main(): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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');
|
||||
|
||||
@@ -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<Accou
|
||||
// H8: Also clear stdinKeepalive interval to prevent memory leak
|
||||
const cleanup = () => {
|
||||
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<Accou
|
||||
|
||||
// Listen for external cancel signal
|
||||
const handleCancel = (cancelledSessionId: string) => {
|
||||
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<Accou
|
||||
}, 2000);
|
||||
|
||||
// Timeout handling
|
||||
const timeoutMs = headless ? 300000 : 120000;
|
||||
// Device code flows need longer timeout to match CLIProxy binary's polling window (60 attempts × 5s = 300s)
|
||||
const timeoutMs = headless || isDeviceCodeFlow ? 300000 : 120000;
|
||||
const timeout = setTimeout(() => {
|
||||
// H7: Clear stdin keepalive interval
|
||||
if (stdinKeepalive) clearInterval(stdinKeepalive);
|
||||
@@ -435,9 +437,9 @@ export function executeOAuthProcess(options: OAuthProcessOptions): Promise<Accou
|
||||
authSessionEvents.removeListener('session:cancelled', handleCancel);
|
||||
unregisterAuthSession(state.sessionId);
|
||||
cancelProjectSelection(state.sessionId);
|
||||
authProcess.kill();
|
||||
killWithEscalation(authProcess);
|
||||
console.log('');
|
||||
console.log(fail(`OAuth timed out after ${headless ? 5 : 2} minutes`));
|
||||
console.log(fail(`OAuth timed out after ${timeoutMs / 60000} minutes`));
|
||||
for (const line of getTimeoutTroubleshooting(provider, callbackPort ?? null)) {
|
||||
console.log(line);
|
||||
}
|
||||
|
||||
@@ -177,13 +177,15 @@ export function isRetryableError(error: Error): boolean {
|
||||
/**
|
||||
* Download file from URL with progress tracking
|
||||
* @param timeout Timeout in ms (default 120000 for large files)
|
||||
* @param maxRedirects Maximum number of redirects to follow (default 10)
|
||||
*/
|
||||
export function downloadFile(
|
||||
url: string,
|
||||
destPath: string,
|
||||
onProgress?: ProgressCallback,
|
||||
verbose = false,
|
||||
timeout = 120000
|
||||
timeout = 120000,
|
||||
maxRedirects = 10
|
||||
): Promise<void> {
|
||||
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<string> {
|
||||
function fetchTextOnce(
|
||||
url: string,
|
||||
verbose = false,
|
||||
timeout = 30000,
|
||||
maxRedirects = 10
|
||||
): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let resolved = false;
|
||||
|
||||
@@ -352,7 +363,13 @@ function fetchTextOnce(url: string, verbose = false, timeout = 30000): Promise<s
|
||||
reject(new Error('Redirect without location header'));
|
||||
return;
|
||||
}
|
||||
fetchTextOnce(redirectUrl, verbose, timeout).then(resolve).catch(reject);
|
||||
if (maxRedirects <= 0) {
|
||||
reject(new Error('Too many redirects'));
|
||||
return;
|
||||
}
|
||||
fetchTextOnce(redirectUrl, verbose, timeout, maxRedirects - 1)
|
||||
.then(resolve)
|
||||
.catch(reject);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -436,7 +453,8 @@ export async function fetchText(url: string, verbose = false, maxRetries = 3): P
|
||||
function fetchJsonOnce(
|
||||
url: string,
|
||||
verbose = false,
|
||||
timeout = 15000
|
||||
timeout = 15000,
|
||||
maxRedirects = 10
|
||||
): Promise<Record<string, unknown>> {
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<T, R>(
|
||||
items: T[],
|
||||
fn: (item: T) => Promise<R>,
|
||||
concurrency = 10
|
||||
): Promise<R[]> {
|
||||
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
|
||||
|
||||
@@ -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<string, unknown> = {};
|
||||
for (const [defName, defSchema] of Object.entries(value as Record<string, unknown>)) {
|
||||
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<string, unknown> = {};
|
||||
for (const [pattern, patternSchema] of Object.entries(value as Record<string, unknown>)) {
|
||||
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<string, unknown> = {};
|
||||
for (const [depName, depValue] of Object.entries(value as Record<string, unknown>)) {
|
||||
// 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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<string, string>): Record<string, string> {
|
||||
const baseUrl = envVars['ANTHROPIC_BASE_URL'] || '';
|
||||
const apiKey = envVars['ANTHROPIC_AUTH_TOKEN'] || '';
|
||||
const model = envVars['ANTHROPIC_MODEL'] || '';
|
||||
const result: Record<string, string> = {};
|
||||
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<string, string> | 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')} <profile> [options]`);
|
||||
console.log('');
|
||||
|
||||
console.log(subheader('Options:'));
|
||||
console.log(
|
||||
` ${color('--format', 'command')} <fmt> Output format: openai, anthropic, raw ${dim('(default: anthropic)')}`
|
||||
);
|
||||
console.log(
|
||||
` ${color('--shell', 'command')} <sh> 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<void> {
|
||||
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 <profile> [--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<string, string> = {};
|
||||
|
||||
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<string, string>
|
||||
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<string, string>;
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 <profile>', 'Export env vars for third-party tools'],
|
||||
['ccs env <profile> --format openai', 'OpenAI-compatible vars (OpenCode/Cursor)'],
|
||||
['ccs env <profile> --format anthropic', 'Anthropic vars (default)'],
|
||||
['ccs env <profile> --format raw', 'All effective env vars'],
|
||||
['ccs env <profile> --shell fish', 'Fish shell syntax'],
|
||||
]);
|
||||
|
||||
// Flags
|
||||
printSubSection('Flags', [
|
||||
['--config-dir <path>', '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('');
|
||||
|
||||
|
||||
@@ -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<void> {
|
||||
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'));
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(data as Record<string, unknown>)) {
|
||||
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}`);
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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<void> {
|
||||
const homedir = os.homedir();
|
||||
|
||||
console.log('');
|
||||
console.log(header('AUTO-FIX MODE'));
|
||||
console.log('');
|
||||
@@ -111,7 +109,7 @@ export async function runAutoRepair(): Promise<void> {
|
||||
|
||||
// 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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -89,7 +89,7 @@ export async function runHealthChecks(): Promise<HealthReport> {
|
||||
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
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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`;
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()', () => {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 0,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "ui",
|
||||
|
||||
Reference in new issue
Block a user