diff --git a/scripts/completion/README.md b/scripts/completion/README.md index d70103e0..037ebe83 100644 --- a/scripts/completion/README.md +++ b/scripts/completion/README.md @@ -2,14 +2,16 @@ Tab completion for CCS commands, subcommands, profiles, and flags. +The completion scripts are thin adapters over the hidden `ccs __complete` backend so +all supported shells stay aligned with the same command graph. + **Supported Shells:** Bash, Zsh, Fish, PowerShell ## Features - Complete profile names (both settings-based and account-based) -- Complete `ccs auth` subcommands (create, list, show, remove, default) -- Complete flags (`--help`, `--version`, `--json`, `--verbose`, `--yes`) -- Complete profile names for auth subcommands +- Complete root commands, help topics, provider shortcuts, and command flags +- Complete `ccs auth` and `ccs api` lifecycle subcommands - Context-aware: suggests relevant options based on current command ## Quick Install (Recommended) @@ -32,6 +34,12 @@ ccs --shell-completion --fish # Force fish ccs --shell-completion --powershell # Force PowerShell ``` +**Help and verification:** +```bash +ccs help completion +ccs --shell-completion --force +``` + ## Manual Installation Completion files are installed to `~/.ccs/completions/` during `npm install`. @@ -116,188 +124,104 @@ sudo cp scripts/completion/ccs.fish /usr/share/fish/vendor_completions.d/ ```bash $ ccs -auth doctor glm kimi work personal --help --version +auth api cliproxy config doctor docker help -$ ccs auth -create list show remove default --help +$ ccs help +profiles providers completion targets ``` -### Profile Completion +### Context Completion ```bash $ ccs auth show work personal team --json -$ ccs auth remove -work personal team --yes -y +$ ccs api +create discover copy export import remove ``` -### Flag Completion +### Backend Contract ```bash -$ ccs auth list ---verbose --json - -$ ccs auth show work ---json +$ ccs __complete --shell bash --current do +doctor +docker ``` -## Completion Behavior - -### Top-level (after `ccs`) -- Built-in commands: `auth`, `doctor` -- Flags: `--help`, `--version`, `-h`, `-v` -- Settings-based profiles: from `~/.ccs/config.json` -- Account-based profiles: from `~/.ccs/profiles.json` - -### After `ccs auth` -- Subcommands: `create`, `list`, `show`, `remove`, `default` -- Flags: `--help`, `-h` - -### After `ccs auth ` -- **create**: No completion (user enters new profile name) - - Flags: `--force` -- **list**: No profile completion - - Flags: `--verbose`, `--json` -- **show**: Account profiles only - - Flags: `--json` -- **remove**: Account profiles only - - Flags: `--yes`, `-y` -- **default**: Account profiles only - -### After `ccs ` -- No completion (Claude CLI arguments are free-form) +Shell adapters now call the shared CCS completion backend instead of maintaining their own +copy of the command graph. That means: +- top-level commands, help topics, and provider shortcuts come from CCS itself +- dynamic profiles and CLIProxy variants resolve through the real config loaders +- bash, zsh, fish, and PowerShell stay aligned with the same completion logic ## Troubleshooting -### Bash: Completion not working +### Bash -1. Check if bash-completion is installed: - ```bash - # macOS - brew install bash-completion - - # Ubuntu/Debian - sudo apt install bash-completion - ``` - -2. Verify jq is installed (required for profile completion): - ```bash - command -v jq - ``` - -3. Check if completion is loaded: +1. Check if completion is loaded: ```bash complete -p ccs ``` - - Should output: - ``` - complete -F _ccs_completion ccs +2. Verify the backend directly: + ```bash + ccs __complete --shell bash --current "" -- help ``` -### Zsh: Completion not working +### Zsh -1. Verify completion system is enabled in `~/.zshrc`: +1. Verify completion system is enabled: ```zsh autoload -Uz compinit && compinit ``` - -2. Check if completion is loaded: - ```zsh - which _ccs - ``` - -3. Rebuild completion cache: +2. Rebuild the cache if needed: ```zsh rm ~/.zcompdump && compinit ``` - -### PowerShell: Completion not working - -1. Check PowerShell version (5.1+ required): - ```powershell - $PSVersionTable.PSVersion +3. Verify the backend directly: + ```zsh + ccs __complete --shell zsh --current "" -- help ``` -2. Verify profile is loaded: +### PowerShell + +1. Check that the profile exists: ```powershell Test-Path $PROFILE ``` - -3. Check if completion is registered: +2. Verify the backend directly: ```powershell - (Get-ArgumentCompleter).CommandName | Select-String ccs + ccs __complete --shell powershell --current "" -- help ``` -### Fish: Completion not working +### Fish -1. Check Fish version (3.0+ required): - ```fish - fish --version - ``` - -2. Verify completion file is in the right location: +1. Verify completion file location: ```fish ls ~/.config/fish/completions/ccs.fish ``` - -3. Verify jq is installed (required for profile completion): - ```fish - which jq - ``` - -4. Test completion manually: +2. Test completion manually: ```fish complete -C'ccs ' ``` - -5. If needed, rebuild completions: +3. Verify the backend directly: ```fish - fish_update_completions + ccs __complete --shell fish --current "" -- help ``` ## Technical Details -### Bash Implementation -- Uses `complete -F` for programmable completion -- Compatible with bash 3.2+ (macOS default) -- Reads profiles dynamically using `jq` -- Context-aware based on `COMP_CWORD` and `COMP_WORDS` - -### Zsh Implementation -- Uses `_arguments` and `_describe` for rich completion -- Compatible with zsh 5.0+ -- Supports completion descriptions -- Context-aware using `$state` and `$words` - -### PowerShell Implementation -- Uses `Register-ArgumentCompleter` -- Compatible with PowerShell 5.1+ -- Reads profiles dynamically using `ConvertFrom-Json` -- Provides `CompletionResult` objects - -### Fish Implementation -- Uses declarative `complete` command -- Compatible with Fish 3.0+ -- Automatic loading from `~/.config/fish/completions/` -- Helper functions for dynamic profile loading -- Context-aware using `__fish_seen_subcommand_from` -- No manual sourcing required - -## Dependencies - -- **jq**: Required for reading profiles from JSON files - - Install: `brew install jq` (macOS) or `apt install jq` (Ubuntu) - - Already required by CCS core functionality +- Bash uses `complete -F` +- Zsh uses a custom `_ccs` completion function +- Fish uses `complete -a` with backend command substitution +- PowerShell uses `Register-ArgumentCompleter` +- All four shells now delegate suggestion logic to `ccs __complete` ## Contributing -When adding new commands or flags: -1. Update all four completion scripts (bash, zsh, fish, PowerShell) -2. Test on each shell -3. Update this README with new completion examples -4. Maintain cross-shell parity +When adding or changing command surfaces: +1. Update the shared TypeScript command/completion catalog +2. Run `bun run validate` +3. Smoke-check at least one installed shell adapter plus the backend directly ## See Also diff --git a/scripts/completion/ccs.bash b/scripts/completion/ccs.bash index 985e8806..f7d19e10 100644 --- a/scripts/completion/ccs.bash +++ b/scripts/completion/ccs.bash @@ -1,210 +1,42 @@ # Bash completion for CCS (Claude Code Switch) # Compatible with bash 3.2+ -# -# Installation: -# Add to ~/.bashrc or ~/.bash_profile: -# source /path/to/ccs/scripts/completion/ccs.bash -# -# Or install system-wide (requires sudo): -# sudo cp scripts/completion/ccs.bash /etc/bash_completion.d/ccs _ccs_completion() { - local cur prev words cword + local cur COMPREPLY=() - - # Get current word and previous word cur="${COMP_WORDS[COMP_CWORD]}" - prev="${COMP_WORDS[COMP_CWORD-1]}" - # Top-level completion (first argument) - if [[ ${COMP_CWORD} -eq 1 ]]; then - local commands="auth api cliproxy config doctor env persist sync update" - local flags="--help --version --shell-completion -h -v -sc" - local cliproxy_profiles="gemini codex agy qwen" - local profiles="" - - # Add profiles from config.json (settings-based profiles) - if [[ -f ~/.ccs/config.json ]]; then - profiles="$profiles $(jq -r '.profiles | keys[]' ~/.ccs/config.json 2>/dev/null || true)" - fi - - # Add profiles from profiles.json (account-based profiles) - if [[ -f ~/.ccs/profiles.json ]]; then - profiles="$profiles $(jq -r '.profiles | keys[]' ~/.ccs/profiles.json 2>/dev/null || true)" - fi - - # Add cliproxy variants from config.json - if [[ -f ~/.ccs/config.json ]]; then - profiles="$profiles $(jq -r '.cliproxy | keys[]' ~/.ccs/config.json 2>/dev/null || true)" - fi - - # Combine all options - local opts="$commands $flags $cliproxy_profiles $profiles" - COMPREPLY=( $(compgen -W "${opts}" -- ${cur}) ) - return 0 + local tokens_before_current=() + if [[ ${COMP_CWORD} -gt 1 ]]; then + tokens_before_current=("${COMP_WORDS[@]:1:COMP_CWORD-1}") fi - # CLIProxy provider flags (gemini, codex, agy, qwen) - if [[ ${COMP_WORDS[1]} =~ ^(gemini|codex|agy|qwen)$ ]]; then - local provider_flags="--auth --config --logout --headless --help -h" - COMPREPLY=( $(compgen -W "${provider_flags}" -- ${cur}) ) - return 0 - fi - - # auth subcommands - if [[ ${prev} == "auth" ]]; then - local auth_commands="create list show remove default --help -h" - COMPREPLY=( $(compgen -W "${auth_commands}" -- ${cur}) ) - return 0 - fi - - # api subcommands - if [[ ${prev} == "api" ]]; then - local api_commands="create list remove --help -h" - COMPREPLY=( $(compgen -W "${api_commands}" -- ${cur}) ) - return 0 - fi - - # cliproxy subcommands - if [[ ${prev} == "cliproxy" ]]; then - local cliproxy_commands="create list remove --install --latest --help -h" - COMPREPLY=( $(compgen -W "${cliproxy_commands}" -- ${cur}) ) - return 0 - fi - - # Completion for cliproxy subcommands - if [[ ${COMP_WORDS[1]} == "cliproxy" ]]; then - case "${prev}" in - remove|delete|rm) - # Complete with cliproxy variant names - if [[ -f ~/.ccs/config.json ]]; then - local variants=$(jq -r '.cliproxy | keys[]' ~/.ccs/config.json 2>/dev/null || true) - COMPREPLY=( $(compgen -W "${variants}" -- ${cur}) ) - fi - return 0 - ;; - create) - # Complete with create flags - COMPREPLY=( $(compgen -W "--provider --model --force --yes -y" -- ${cur}) ) - return 0 - ;; - --provider) - # Complete with provider names - COMPREPLY=( $(compgen -W "gemini codex agy qwen" -- ${cur}) ) - return 0 - ;; - list|ls) - # No flags for list - return 0 - ;; - --install) - # User enters version number - return 0 - ;; - esac - fi - - # Completion for api subcommands - if [[ ${COMP_WORDS[1]} == "api" ]]; then - case "${prev}" in - remove|delete|rm) - # Complete with settings profile names - if [[ -f ~/.ccs/config.json ]]; then - local profiles=$(jq -r '.profiles | keys[]' ~/.ccs/config.json 2>/dev/null || true) - COMPREPLY=( $(compgen -W "${profiles}" -- ${cur}) ) - fi - return 0 - ;; - create) - # Complete with create flags - COMPREPLY=( $(compgen -W "--base-url --api-key --model --force --yes -y" -- ${cur}) ) - return 0 - ;; - list) - # No flags for list - return 0 - ;; - esac - fi - - # Completion for auth subcommands that need profile names - if [[ ${COMP_WORDS[1]} == "auth" ]]; then - case "${prev}" in - show|remove|default) - # Complete with account profile names only - if [[ -f ~/.ccs/profiles.json ]]; then - local profiles=$(jq -r '.profiles | keys[]' ~/.ccs/profiles.json 2>/dev/null || true) - COMPREPLY=( $(compgen -W "${profiles}" -- ${cur}) ) - fi - return 0 - ;; - create) - # Complete with create flags - COMPREPLY=( $(compgen -W "--force" -- ${cur}) ) - return 0 - ;; - list) - # Complete with list flags - COMPREPLY=( $(compgen -W "--verbose --json" -- ${cur}) ) - return 0 - ;; - 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 --ide --help -h gemini codex agy qwen iflow kiro ghcp claude default" - if [[ -f ~/.ccs/config.json ]]; then - env_opts="$env_opts $(jq -r '.profiles | keys[]' ~/.ccs/config.json 2>/dev/null || true)" - fi - if [[ -f ~/.ccs/profiles.json ]]; then - env_opts="$env_opts $(jq -r '.profiles | keys[]' ~/.ccs/profiles.json 2>/dev/null || true)" - fi - COMPREPLY=( $(compgen -W "${env_opts}" -- ${cur}) ) - return 0 - ;; - --format) - COMPREPLY=( $(compgen -W "openai anthropic raw claude-extension" -- ${cur}) ) - return 0 - ;; - --shell) - COMPREPLY=( $(compgen -W "auto bash zsh fish powershell" -- ${cur}) ) - return 0 - ;; - --ide) - COMPREPLY=( $(compgen -W "vscode cursor windsurf" -- ${cur}) ) - return 0 - ;; - *) - COMPREPLY=( $(compgen -W "--format --shell --ide --help -h" -- ${cur}) ) - return 0 - ;; - esac - fi - - # Flags for doctor command - if [[ ${COMP_WORDS[1]} == "doctor" ]]; then - COMPREPLY=( $(compgen -W "--help -h" -- ${cur}) ) - return 0 - fi - - # Flags for update command - if [[ ${COMP_WORDS[1]} == "update" ]]; then - COMPREPLY=( $(compgen -W "--force --beta --dev --help -h" -- ${cur}) ) - return 0 - fi - - # Flags for shell-completion command - if [[ ${prev} == "--shell-completion" || ${prev} == "-sc" ]]; then - COMPREPLY=( $(compgen -W "--bash --zsh --fish --powershell" -- ${cur}) ) - return 0 - fi + while IFS= read -r line; do + [[ -n "${line}" ]] && COMPREPLY+=("${line}") + done < <(__ccs_completion_run "${cur}" "${tokens_before_current[@]}") return 0 } -# Register completion function +__ccs_completion_run() { + local current="$1" + shift || true + + local script_dir repo_root repo_cli + script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + repo_root="$(cd "${script_dir}/../.." && pwd)" + repo_cli="${repo_root}/dist/ccs.js" + if [[ ! -f "${repo_cli}" ]]; then + repo_cli="${repo_root}/bin/ccs.js" + fi + if [[ -f "${repo_cli}" ]]; then + node "${repo_cli}" __complete --shell bash --current "${current}" -- "$@" 2>/dev/null + return 0 + fi + + if command -v ccs >/dev/null 2>&1; then + ccs __complete --shell bash --current "${current}" -- "$@" 2>/dev/null + fi +} + complete -F _ccs_completion ccs diff --git a/scripts/completion/ccs.fish b/scripts/completion/ccs.fish index 107607b9..e033b08a 100644 --- a/scripts/completion/ccs.fish +++ b/scripts/completion/ccs.fish @@ -1,256 +1,27 @@ # Fish completion for CCS (Claude Code Switch) -# Compatible with fish 3.0+ -# -# Features: -# - Categorized completions with [cmd], [proxy], [model], and [account] prefixes -# - Dynamic profile loading from config.json and profiles.json -# - Context-aware subcommand completion -# -# Installation: -# Copy to ~/.config/fish/completions/: -# mkdir -p ~/.config/fish/completions -# cp scripts/completion/ccs.fish ~/.config/fish/completions/ -# -# Fish will automatically load completions from this directory. -# No need to source or reload - completions are loaded on demand. -# Helper function to get settings profiles -function __fish_ccs_get_settings_profiles - set -l config_path ~/.ccs/config.json +function __fish_ccs_complete + set -l tokens_before_current (commandline -opc) + if test (count $tokens_before_current) -gt 0 + set -e tokens_before_current[1] + end - if test -f $config_path - jq -r '.profiles | keys[]' $config_path 2>/dev/null + set -l current (commandline -ct) + + set -l script_file (status filename) + set -l repo_root (realpath (dirname $script_file)/../.. 2>/dev/null) + set -l repo_cli "$repo_root/dist/ccs.js" + if not test -f "$repo_cli" + set repo_cli "$repo_root/bin/ccs.js" + end + if test -f "$repo_cli" + node "$repo_cli" __complete --shell fish --current "$current" -- $tokens_before_current 2>/dev/null + return + end + + if command -sq ccs + ccs __complete --shell fish --current "$current" -- $tokens_before_current 2>/dev/null end end -# Helper function to get custom/unknown settings profiles -function __fish_ccs_get_custom_settings_profiles - set -l config_path ~/.ccs/config.json - set -l known_profiles default glm kimi - - if test -f $config_path - set -l all_profiles (jq -r '.profiles | keys[]' $config_path 2>/dev/null) - - for profile in $all_profiles - if not contains $profile $known_profiles - echo $profile - end - end - end -end - -# Helper function to get cliproxy variants -function __fish_ccs_get_cliproxy_variants - set -l config_path ~/.ccs/config.json - - if test -f $config_path - jq -r '.cliproxy | keys[]' $config_path 2>/dev/null - end -end - -# Helper function to get profiles with all types -function __fish_ccs_get_profiles - __fish_ccs_get_settings_profiles - __fish_ccs_get_account_profiles -end - -# Helper function to get account profiles only -function __fish_ccs_get_account_profiles - set -l profiles_path ~/.ccs/profiles.json - - if test -f $profiles_path - jq -r '.profiles | keys[]' $profiles_path 2>/dev/null - end -end - -# Helper function to check if we're in auth context -function __fish_ccs_using_auth - __fish_seen_subcommand_from auth -end - -# Helper function to check specific auth subcommand -function __fish_ccs_using_auth_subcommand - set -l subcommand $argv[1] - __fish_ccs_using_auth; and __fish_seen_subcommand_from $subcommand -end - -# Helper function to check if we're in api context -function __fish_ccs_using_api - __fish_seen_subcommand_from api -end - -# Helper function to check specific api subcommand -function __fish_ccs_using_api_subcommand - set -l subcommand $argv[1] - __fish_ccs_using_api; and __fish_seen_subcommand_from $subcommand -end - -# Helper function to check if we're in cliproxy context -function __fish_ccs_using_cliproxy - __fish_seen_subcommand_from cliproxy -end - -# Helper function to check specific cliproxy subcommand -function __fish_ccs_using_cliproxy_subcommand - set -l subcommand $argv[1] - __fish_ccs_using_cliproxy; and __fish_seen_subcommand_from $subcommand -end - -# Helper function to check if we're in profile context -function __fish_ccs_using_profile - __fish_seen_subcommand_from profile -end - -# Helper function to check specific profile subcommand -function __fish_ccs_using_profile_subcommand - set -l subcommand $argv[1] - __fish_ccs_using_profile; and __fish_seen_subcommand_from $subcommand -end - -# Helper function to check if we're using a CLIProxy provider -function __fish_ccs_using_provider - __fish_seen_subcommand_from gemini codex agy qwen -end - -# Disable file completion for ccs -complete -c ccs -f - -# Top-level flags -complete -c ccs -s h -l help -d 'Show help message' -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 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 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 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 '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 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 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 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' -complete -c ccs -n '__fish_seen_argument -l shell-completion; or __fish_seen_argument -s sc' -l zsh -d 'Install for zsh' -complete -c ccs -n '__fish_seen_argument -l shell-completion; or __fish_seen_argument -s sc' -l fish -d 'Install for fish' -complete -c ccs -n '__fish_seen_argument -l shell-completion; or __fish_seen_argument -s sc' -l powershell -d 'Install for PowerShell' - -# CLIProxy provider flags (gemini, codex, agy, qwen) -complete -c ccs -n '__fish_ccs_using_provider' -l auth -d 'Authenticate only' -complete -c ccs -n '__fish_ccs_using_provider' -l config -d 'Change model configuration' -complete -c ccs -n '__fish_ccs_using_provider' -l logout -d 'Clear authentication' -complete -c ccs -n '__fish_ccs_using_provider' -l headless -d 'Headless auth (for SSH)' -complete -c ccs -n '__fish_ccs_using_provider' -s h -l help -d 'Show help' - -# update command flags -complete -c ccs -n '__fish_seen_subcommand_from update' -l force -d 'Force reinstall current version' -complete -c ccs -n '__fish_seen_subcommand_from update' -l beta -d 'Install from dev channel' -complete -c ccs -n '__fish_seen_subcommand_from update' -l dev -d 'Install from dev channel' -complete -c ccs -n '__fish_seen_subcommand_from update' -s h -l help -d 'Show help' - -# 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 -# ============================================================================ -complete -c ccs -n '__fish_ccs_using_auth; and not __fish_seen_subcommand_from create list show remove default' -a 'create' -d 'Create new profile and login' -complete -c ccs -n '__fish_ccs_using_auth; and not __fish_seen_subcommand_from create list show remove default' -a 'list' -d 'List all saved profiles' -complete -c ccs -n '__fish_ccs_using_auth; and not __fish_seen_subcommand_from create list show remove default' -a 'show' -d 'Show profile details' -complete -c ccs -n '__fish_ccs_using_auth; and not __fish_seen_subcommand_from create list show remove default' -a 'remove' -d 'Remove saved profile' -complete -c ccs -n '__fish_ccs_using_auth; and not __fish_seen_subcommand_from create list show remove default' -a 'default' -d 'Set default profile' - -complete -c ccs -n '__fish_ccs_using_auth' -s h -l help -d 'Show help for auth commands' -complete -c ccs -n '__fish_ccs_using_auth_subcommand create' -l force -d 'Allow overwriting existing profile' -complete -c ccs -n '__fish_ccs_using_auth_subcommand list' -l verbose -d 'Show additional details' -complete -c ccs -n '__fish_ccs_using_auth_subcommand list' -l json -d 'Output in JSON format' -complete -c ccs -n '__fish_ccs_using_auth_subcommand show' -a '(__fish_ccs_get_account_profiles)' -d 'Account profile' -complete -c ccs -n '__fish_ccs_using_auth_subcommand show' -l json -d 'Output in JSON format' -complete -c ccs -n '__fish_ccs_using_auth_subcommand remove' -a '(__fish_ccs_get_account_profiles)' -d 'Account profile' -complete -c ccs -n '__fish_ccs_using_auth_subcommand remove' -l yes -d 'Skip confirmation prompts' -complete -c ccs -n '__fish_ccs_using_auth_subcommand remove' -s y -d 'Skip confirmation prompts' -complete -c ccs -n '__fish_ccs_using_auth_subcommand default' -a '(__fish_ccs_get_account_profiles)' -d 'Account profile' - -# ============================================================================ -# api subcommands -# ============================================================================ -complete -c ccs -n '__fish_ccs_using_api; and not __fish_seen_subcommand_from create list remove' -a 'create' -d 'Create new API profile' -complete -c ccs -n '__fish_ccs_using_api; and not __fish_seen_subcommand_from create list remove' -a 'list' -d 'List all API profiles' -complete -c ccs -n '__fish_ccs_using_api; and not __fish_seen_subcommand_from create list remove' -a 'remove' -d 'Remove an API profile' - -complete -c ccs -n '__fish_ccs_using_api' -s h -l help -d 'Show help for api commands' -complete -c ccs -n '__fish_ccs_using_api_subcommand create' -l base-url -d 'API base URL' -complete -c ccs -n '__fish_ccs_using_api_subcommand create' -l api-key -d 'API key' -complete -c ccs -n '__fish_ccs_using_api_subcommand create' -l model -d 'Default model' -complete -c ccs -n '__fish_ccs_using_api_subcommand create' -l force -d 'Overwrite existing profile' -complete -c ccs -n '__fish_ccs_using_api_subcommand create' -l yes -d 'Skip prompts' -complete -c ccs -n '__fish_ccs_using_api_subcommand create' -s y -d 'Skip prompts' -complete -c ccs -n '__fish_ccs_using_api_subcommand remove' -a '(__fish_ccs_get_settings_profiles)' -d 'Settings profile' -complete -c ccs -n '__fish_ccs_using_api_subcommand remove' -l yes -d 'Skip confirmation' -complete -c ccs -n '__fish_ccs_using_api_subcommand remove' -s y -d 'Skip confirmation' - -# ============================================================================ -# cliproxy subcommands -# ============================================================================ -complete -c ccs -n '__fish_ccs_using_cliproxy; and not __fish_seen_subcommand_from create list remove' -a 'create' -d 'Create new CLIProxy variant' -complete -c ccs -n '__fish_ccs_using_cliproxy; and not __fish_seen_subcommand_from create list remove' -a 'list' -d 'List all CLIProxy variants' -complete -c ccs -n '__fish_ccs_using_cliproxy; and not __fish_seen_subcommand_from create list remove' -a 'remove' -d 'Remove a CLIProxy variant' - -complete -c ccs -n '__fish_ccs_using_cliproxy' -s h -l help -d 'Show help for cliproxy commands' -complete -c ccs -n '__fish_ccs_using_cliproxy' -l install -d 'Install specific version' -complete -c ccs -n '__fish_ccs_using_cliproxy' -l latest -d 'Install latest version' -complete -c ccs -n '__fish_ccs_using_cliproxy_subcommand create' -l provider -d 'Provider name' -complete -c ccs -n '__fish_ccs_using_cliproxy_subcommand create; and __fish_seen_argument -l provider' -a 'gemini codex agy qwen' -d 'Provider' -complete -c ccs -n '__fish_ccs_using_cliproxy_subcommand create' -l model -d 'Model name' -complete -c ccs -n '__fish_ccs_using_cliproxy_subcommand create' -l force -d 'Overwrite existing variant' -complete -c ccs -n '__fish_ccs_using_cliproxy_subcommand create' -l yes -d 'Skip prompts' -complete -c ccs -n '__fish_ccs_using_cliproxy_subcommand create' -s y -d 'Skip prompts' -complete -c ccs -n '__fish_ccs_using_cliproxy_subcommand remove' -a '(__fish_ccs_get_cliproxy_variants)' -d 'CLIProxy variant' -complete -c ccs -n '__fish_ccs_using_cliproxy_subcommand remove' -l yes -d 'Skip confirmation' -complete -c ccs -n '__fish_ccs_using_cliproxy_subcommand remove' -s y -d 'Skip confirmation' - -# ============================================================================ -# profile subcommands (legacy - redirects to api) -# ============================================================================ -complete -c ccs -n '__fish_ccs_using_profile; and not __fish_seen_subcommand_from create list remove' -a 'create' -d 'Create new API profile' -complete -c ccs -n '__fish_ccs_using_profile; and not __fish_seen_subcommand_from create list remove' -a 'list' -d 'List all profiles' -complete -c ccs -n '__fish_ccs_using_profile; and not __fish_seen_subcommand_from create list remove' -a 'remove' -d 'Remove a profile' - -complete -c ccs -n '__fish_ccs_using_profile' -s h -l help -d 'Show help for profile commands' -complete -c ccs -n '__fish_ccs_using_profile_subcommand create' -l base-url -d 'API base URL' -complete -c ccs -n '__fish_ccs_using_profile_subcommand create' -l api-key -d 'API key' -complete -c ccs -n '__fish_ccs_using_profile_subcommand create' -l model -d 'Default model' -complete -c ccs -n '__fish_ccs_using_profile_subcommand create' -l force -d 'Overwrite existing profile' -complete -c ccs -n '__fish_ccs_using_profile_subcommand create' -l yes -d 'Skip prompts' -complete -c ccs -n '__fish_ccs_using_profile_subcommand create' -s y -d 'Skip prompts' -complete -c ccs -n '__fish_ccs_using_profile_subcommand remove' -a '(__fish_ccs_get_settings_profiles)' -d 'Settings profile' -complete -c ccs -n '__fish_ccs_using_profile_subcommand remove' -l yes -d 'Skip confirmation' -complete -c ccs -n '__fish_ccs_using_profile_subcommand remove' -s y -d 'Skip confirmation' +complete -c ccs -f -a "(__fish_ccs_complete)" diff --git a/scripts/completion/ccs.ps1 b/scripts/completion/ccs.ps1 index 500cf988..0ec00e94 100644 --- a/scripts/completion/ccs.ps1 +++ b/scripts/completion/ccs.ps1 @@ -1,446 +1,56 @@ # PowerShell completion for CCS (Claude Code Switch) -# Compatible with PowerShell 5.1+ -# -# Installation: -# Add to your PowerShell profile ($PROFILE): -# . /path/to/ccs/scripts/completion/ccs.ps1 -# -# Or install for current user: -# Copy-Item scripts/completion/ccs.ps1 ~\Documents\PowerShell\Scripts\ -# Add to profile: . ~\Documents\PowerShell\Scripts\ccs.ps1 + +function Invoke-CcsCompletionBackend { + param( + [string]$CurrentWord, + [string[]]$TokensBeforeCurrent + ) + + $repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path + $repoCli = Join-Path $repoRoot 'dist\ccs.js' + if (-not (Test-Path $repoCli)) { + $repoCli = Join-Path $repoRoot 'bin\ccs.js' + } + if (Test-Path $repoCli) { + & node $repoCli __complete --shell powershell --current $CurrentWord -- @TokensBeforeCurrent 2>$null + return + } + + if (Get-Command ccs -ErrorAction SilentlyContinue) { + & ccs __complete --shell powershell --current $CurrentWord -- @TokensBeforeCurrent 2>$null + } +} Register-ArgumentCompleter -CommandName ccs -ScriptBlock { param($commandName, $wordToComplete, $commandAst, $fakeBoundParameters) - $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') - $apiCreateFlags = @('--base-url', '--api-key', '--model', '--force', '--yes', '-y') - $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') - $showFlags = @('--json') - $providers = @('gemini', 'codex', 'agy', 'qwen') - - # Get current position in command - $words = $commandAst.ToString() -split '\s+' | Where-Object { $_ -ne '' } - $position = $words.Count - - # Helper function to get profiles - function Get-CcsProfiles { - param([string]$Type = 'all') - - $profiles = @() - - # Settings-based profiles - if ($Type -in @('all', 'settings')) { - $configPath = "$env:USERPROFILE\.ccs\config.json" - if (Test-Path $configPath) { - try { - $config = Get-Content $configPath -Raw | ConvertFrom-Json - $profiles += $config.profiles.PSObject.Properties.Name - } catch {} + $commandElements = @($commandAst.CommandElements | ForEach-Object { $_.Extent.Text }) + $tokensBeforeCurrent = @() + if ($commandElements.Count -gt 1) { + $tokensBeforeCurrent = $commandElements[1..($commandElements.Count - 1)] + if ($tokensBeforeCurrent.Count -gt 0 -and $tokensBeforeCurrent[-1] -eq $wordToComplete) { + $tokensBeforeCurrent = if ($tokensBeforeCurrent.Count -gt 1) { + $tokensBeforeCurrent[0..($tokensBeforeCurrent.Count - 2)] + } else { + @() } } - - # Account-based profiles - if ($Type -in @('all', 'account')) { - $profilesPath = "$env:USERPROFILE\.ccs\profiles.json" - if (Test-Path $profilesPath) { - try { - $data = Get-Content $profilesPath -Raw | ConvertFrom-Json - $profiles += $data.profiles.PSObject.Properties.Name - } catch {} - } - } - - # CLIProxy variants - if ($Type -in @('all', 'cliproxy')) { - $configPath = "$env:USERPROFILE\.ccs\config.json" - if (Test-Path $configPath) { - try { - $config = Get-Content $configPath -Raw | ConvertFrom-Json - if ($config.cliproxy) { - $profiles += $config.cliproxy.PSObject.Properties.Name - } - } catch {} - } - } - - return $profiles | Sort-Object -Unique } - # Top-level completion - if ($position -eq 2) { - $allOptions = $commands + $cliproxyProfiles + (Get-CcsProfiles) - $allOptions | Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object { - [System.Management.Automation.CompletionResult]::new( - $_, - $_, - 'ParameterValue', - $_ - ) + foreach ($line in Invoke-CcsCompletionBackend -CurrentWord $wordToComplete -TokensBeforeCurrent $tokensBeforeCurrent) { + if ([string]::IsNullOrWhiteSpace($line)) { + continue } - return - } - # shell-completion flag completion - if ($words[1] -eq '--shell-completion' -or $words[1] -eq '-sc') { - if ($position -eq 3) { - $shellCompletionFlags | Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object { - [System.Management.Automation.CompletionResult]::new( - $_, - $_, - 'ParameterValue', - $_ - ) - } - } - return - } + $parts = $line -split "`t", 2 + $value = $parts[0] + $description = if ($parts.Count -gt 1) { $parts[1] } else { $parts[0] } - # CLIProxy provider flags (gemini, codex, agy, qwen) - if ($words[1] -in $cliproxyProfiles) { - $providerFlags | Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object { - [System.Management.Automation.CompletionResult]::new( - $_, - $_, - 'ParameterValue', - $_ - ) - } - return - } - - # update command completion - if ($words[1] -eq 'update') { - $updateFlags | Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object { - [System.Management.Automation.CompletionResult]::new( - $_, - $_, - 'ParameterValue', - $_ - ) - } - 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) { - $authCommands | Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object { - [System.Management.Automation.CompletionResult]::new( - $_, - $_, - 'ParameterValue', - $_ - ) - } - } elseif ($position -eq 4) { - switch ($words[2]) { - 'show' { - $options = (Get-CcsProfiles -Type account) + $showFlags - $options | Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object { - [System.Management.Automation.CompletionResult]::new( - $_, - $_, - 'ParameterValue', - $_ - ) - } - } - 'remove' { - $options = (Get-CcsProfiles -Type account) + $removeFlags - $options | Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object { - [System.Management.Automation.CompletionResult]::new( - $_, - $_, - 'ParameterValue', - $_ - ) - } - } - 'default' { - Get-CcsProfiles -Type account | Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object { - [System.Management.Automation.CompletionResult]::new( - $_, - $_, - 'ParameterValue', - $_ - ) - } - } - 'list' { - $listFlags | Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object { - [System.Management.Automation.CompletionResult]::new( - $_, - $_, - 'ParameterValue', - $_ - ) - } - } - 'create' { - @('--force') | Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object { - [System.Management.Automation.CompletionResult]::new( - $_, - $_, - 'ParameterValue', - $_ - ) - } - } - } - } elseif ($position -eq 5) { - switch ($words[2]) { - 'show' { - $showFlags | Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object { - [System.Management.Automation.CompletionResult]::new( - $_, - $_, - 'ParameterValue', - $_ - ) - } - } - 'remove' { - $removeFlags | Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object { - [System.Management.Automation.CompletionResult]::new( - $_, - $_, - 'ParameterValue', - $_ - ) - } - } - } - } - return - } - - # api subcommand completion - if ($words[1] -eq 'api') { - if ($position -eq 3) { - $apiCommands | Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object { - [System.Management.Automation.CompletionResult]::new( - $_, - $_, - 'ParameterValue', - $_ - ) - } - } elseif ($position -eq 4) { - switch ($words[2]) { - 'remove' { - $options = (Get-CcsProfiles -Type settings) + $removeFlags - $options | Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object { - [System.Management.Automation.CompletionResult]::new( - $_, - $_, - 'ParameterValue', - $_ - ) - } - } - 'create' { - $apiCreateFlags | Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object { - [System.Management.Automation.CompletionResult]::new( - $_, - $_, - 'ParameterValue', - $_ - ) - } - } - } - } elseif ($position -eq 5) { - switch ($words[2]) { - 'remove' { - $removeFlags | Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object { - [System.Management.Automation.CompletionResult]::new( - $_, - $_, - 'ParameterValue', - $_ - ) - } - } - } - } - return - } - - # cliproxy subcommand completion - if ($words[1] -eq 'cliproxy') { - if ($position -eq 3) { - $cliproxyCommands | Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object { - [System.Management.Automation.CompletionResult]::new( - $_, - $_, - 'ParameterValue', - $_ - ) - } - } elseif ($position -eq 4) { - switch ($words[2]) { - 'remove' { - $options = (Get-CcsProfiles -Type cliproxy) + $removeFlags - $options | Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object { - [System.Management.Automation.CompletionResult]::new( - $_, - $_, - 'ParameterValue', - $_ - ) - } - } - 'create' { - $cliproxyCreateFlags | Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object { - [System.Management.Automation.CompletionResult]::new( - $_, - $_, - 'ParameterValue', - $_ - ) - } - } - } - } elseif ($position -eq 5) { - switch ($words[2]) { - 'remove' { - $removeFlags | Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object { - [System.Management.Automation.CompletionResult]::new( - $_, - $_, - 'ParameterValue', - $_ - ) - } - } - 'create' { - # After --provider, complete with provider names - if ($words[3] -eq '--provider') { - $providers | Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object { - [System.Management.Automation.CompletionResult]::new( - $_, - $_, - 'ParameterValue', - $_ - ) - } - } - } - } - } - return - } - - # profile subcommand completion (legacy) - if ($words[1] -eq 'profile') { - if ($position -eq 3) { - @('create', 'list', 'remove', '--help', '-h') | Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object { - [System.Management.Automation.CompletionResult]::new( - $_, - $_, - 'ParameterValue', - $_ - ) - } - } elseif ($position -eq 4) { - switch ($words[2]) { - 'remove' { - $options = (Get-CcsProfiles -Type settings) + $removeFlags - $options | Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object { - [System.Management.Automation.CompletionResult]::new( - $_, - $_, - 'ParameterValue', - $_ - ) - } - } - 'create' { - $apiCreateFlags | Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object { - [System.Management.Automation.CompletionResult]::new( - $_, - $_, - 'ParameterValue', - $_ - ) - } - } - } - } elseif ($position -eq 5) { - switch ($words[2]) { - 'remove' { - $removeFlags | Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object { - [System.Management.Automation.CompletionResult]::new( - $_, - $_, - 'ParameterValue', - $_ - ) - } - } - } - } - return + [System.Management.Automation.CompletionResult]::new( + $value, + $value, + 'ParameterValue', + $description + ) } } diff --git a/scripts/completion/ccs.zsh b/scripts/completion/ccs.zsh index 9c948b6a..97e59220 100644 --- a/scripts/completion/ccs.zsh +++ b/scripts/completion/ccs.zsh @@ -1,319 +1,41 @@ #compdef ccs # Zsh completion for CCS (Claude Code Switch) -# Compatible with zsh 5.0+ -# -# Installation: -# Add to ~/.zshrc: -# fpath=(~/.zsh/completion $fpath) -# autoload -Uz compinit && compinit -# source /path/to/ccs/scripts/completion/ccs.zsh -# -# Or install system-wide: -# 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|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|kimi|[^[:space:]]##)([[:space:]]#--[[:space:]]#*)==0\;32=2\;37' -zstyle ':completion:*:*:ccs:*:account-profiles' list-colors '=(#b)([^[:space:]]##)([[:space:]]#--[[:space:]]#*)==0\;33=2\;37' -zstyle ':completion:*:*:ccs:*' group-name '' -zstyle ':completion:*:*:ccs:*:descriptions' format $'\n%B%F{yellow}── %d ──%f%b' -zstyle ':completion:*:*:ccs:*' list-separator ' -- ' -zstyle ':completion:*:*:ccs:*' list-rows-first true -zstyle ':completion:*:*:ccs:*' menu select _ccs() { - local -a commands proxy_profiles settings_profiles_described account_profiles_described cliproxy_variants_described - local curcontext="$curcontext" state line - typeset -A opt_args + local current + current="${words[CURRENT]}" - # Define top-level commands - commands=( - 'auth:Manage multiple Claude accounts' - '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' - ) - - # Define CLIProxy hardcoded profiles (OAuth providers) - proxy_profiles=( - 'gemini:Google Gemini (OAuth)' - '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 - local -A profile_descriptions - profile_descriptions=( - 'default' 'Default Claude Sonnet 4.5' - 'glm' 'GLM-4.6 (cost-optimized)' - 'kimi' 'Kimi for Coding (long-context)' - ) - - # Load settings-based profiles from config.json - if [[ -f ~/.ccs/config.json ]]; then - local -a raw_settings_profiles - raw_settings_profiles=(${(f)"$(jq -r '.profiles | keys[]' ~/.ccs/config.json 2>/dev/null)"}) - - for profile in $raw_settings_profiles; do - local desc="${profile_descriptions[$profile]:-Settings-based profile}" - settings_profiles_described+=("${profile}:${desc}") - done + local -a tokens_before_current + if (( CURRENT > 2 )); then + tokens_before_current=("${words[@]:2:$((CURRENT-2))}") + else + tokens_before_current=() fi - # Load account-based profiles from profiles.json - if [[ -f ~/.ccs/profiles.json ]]; then - local -a raw_account_profiles - raw_account_profiles=(${(f)"$(jq -r '.profiles | keys[]' ~/.ccs/profiles.json 2>/dev/null)"}) - - for profile in $raw_account_profiles; do - account_profiles_described+=("${profile}:Account-based profile") - done - fi - - # Load cliproxy variants from config.json - if [[ -f ~/.ccs/config.json ]]; then - local -a raw_cliproxy_variants - raw_cliproxy_variants=(${(f)"$(jq -r '.cliproxy | keys[]' ~/.ccs/config.json 2>/dev/null)"}) - - for variant in $raw_cliproxy_variants; do - cliproxy_variants_described+=("${variant}:CLIProxy variant") - done - fi - - _arguments -C \ - '(- *)'{-h,--help}'[Show help message]' \ - '(- *)'{-v,--version}'[Show version information]' \ - '(- *)'{-sc,--shell-completion}'[Install shell completion]' \ - '1: :->command' \ - '*:: :->args' - - case $state in - command) - _describe -t commands 'commands' commands - _describe -t proxy-profiles 'CLIProxy profiles' proxy_profiles - _describe -t model-profiles 'model profiles' settings_profiles_described - _describe -t account-profiles 'account profiles' account_profiles_described - _describe -t cliproxy-variants 'CLIProxy variants' cliproxy_variants_described - ;; - - args) - case $words[1] in - auth) - _ccs_auth - ;; - api) - _ccs_api - ;; - cliproxy) - _ccs_cliproxy - ;; - update) - _arguments \ - '--force[Force reinstall current version]' \ - '--beta[Install from dev channel]' \ - '--dev[Install from dev channel]' \ - '(- *)'{-h,--help}'[Show help]' - ;; - doctor) - _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]' \ - '--config[Change model configuration]' \ - '--logout[Clear authentication]' \ - '--headless[Headless auth (for SSH)]' \ - '(- *)'{-h,--help}'[Show help]' - ;; - --shell-completion|-sc) - _arguments \ - '--bash[Install for bash]' \ - '--zsh[Install for zsh]' \ - '--fish[Install for fish]' \ - '--powershell[Install for PowerShell]' - ;; - *) - _message 'Claude CLI arguments' - ;; - esac - ;; - esac + local -a suggestions + suggestions=("${(@f)$(__ccs_completion_run "${current}" "${tokens_before_current[@]}")}") + compadd -- "${suggestions[@]}" } -_ccs_api() { - local curcontext="$curcontext" state line - typeset -A opt_args +__ccs_completion_run() { + local current="$1" + shift || true - local -a api_commands settings_profiles - - api_commands=( - 'create:Create new API profile (interactive)' - 'list:List all API profiles' - 'remove:Remove an API profile' - ) - - if [[ -f ~/.ccs/config.json ]]; then - settings_profiles=(${(f)"$(jq -r '.profiles | keys[]' ~/.ccs/config.json 2>/dev/null)"}) + local script_path script_dir repo_root repo_cli + script_path="${(%):-%N}" + script_dir="${script_path:A:h}" + repo_root="${script_dir:h:h}" + repo_cli="${repo_root}/dist/ccs.js" + if [[ ! -f "${repo_cli}" ]]; then + repo_cli="${repo_root}/bin/ccs.js" + fi + if [[ -f "${repo_cli}" ]]; then + node "${repo_cli}" __complete --shell zsh --current "${current}" -- "$@" 2>/dev/null + return 0 fi - _arguments -C \ - '(- *)'{-h,--help}'[Show help for api commands]' \ - '1: :->subcommand' \ - '*:: :->subargs' - - case $state in - subcommand) - _describe -t api-commands 'api commands' api_commands - ;; - - subargs) - case $words[1] in - create) - _arguments \ - '1:profile name:' \ - '--base-url[API base URL]:url:' \ - '--api-key[API key]:key:' \ - '--model[Default model]:model:' \ - '--force[Overwrite existing profile]' \ - {--yes,-y}'[Skip prompts]' - ;; - list) - ;; - remove|delete|rm) - _arguments \ - '1:profile:($settings_profiles)' \ - {--yes,-y}'[Skip confirmation]' - ;; - esac - ;; - esac -} - -_ccs_cliproxy() { - local curcontext="$curcontext" state line - typeset -A opt_args - - local -a cliproxy_commands cliproxy_variants providers - - cliproxy_commands=( - 'create:Create new CLIProxy variant profile' - 'list:List all CLIProxy variant profiles' - 'remove:Remove a CLIProxy variant profile' - ) - - providers=(gemini codex agy qwen) - - if [[ -f ~/.ccs/config.json ]]; then - cliproxy_variants=(${(f)"$(jq -r '.cliproxy | keys[]' ~/.ccs/config.json 2>/dev/null)"}) + if (( $+commands[ccs] )); then + ccs __complete --shell zsh --current "${current}" -- "$@" 2>/dev/null fi - - _arguments -C \ - '(- *)'{-h,--help}'[Show help for cliproxy commands]' \ - '--install[Install specific version]:version:' \ - '--latest[Install latest version]' \ - '1: :->subcommand' \ - '*:: :->subargs' - - case $state in - subcommand) - _describe -t cliproxy-commands 'cliproxy commands' cliproxy_commands - ;; - - subargs) - case $words[1] in - create) - _arguments \ - '1:variant name:' \ - '--provider[Provider name]:provider:($providers)' \ - '--model[Model name]:model:' \ - '--force[Overwrite existing variant]' \ - {--yes,-y}'[Skip prompts]' - ;; - list|ls) - ;; - remove|delete|rm) - _arguments \ - '1:variant:($cliproxy_variants)' \ - {--yes,-y}'[Skip confirmation]' - ;; - esac - ;; - esac } - -_ccs_auth() { - local curcontext="$curcontext" state line - typeset -A opt_args - - local -a auth_commands account_profiles - - auth_commands=( - 'create:Create new profile and login' - 'list:List all saved profiles' - 'show:Show profile details' - 'remove:Remove saved profile' - 'default:Set default profile' - ) - - if [[ -f ~/.ccs/profiles.json ]]; then - account_profiles=(${(f)"$(jq -r '.profiles | keys[]' ~/.ccs/profiles.json 2>/dev/null)"}) - fi - - _arguments -C \ - '(- *)'{-h,--help}'[Show help for auth commands]' \ - '1: :->subcommand' \ - '*:: :->subargs' - - case $state in - subcommand) - _describe -t auth-commands 'auth commands' auth_commands - ;; - - subargs) - case $words[1] in - create) - _message 'new profile name' - _arguments '--force[Allow overwriting existing profile]' - ;; - list) - _arguments \ - '--verbose[Show additional details]' \ - '--json[Output in JSON format]' - ;; - show) - _arguments \ - '1:profile:($account_profiles)' \ - '--json[Output in JSON format]' - ;; - remove) - _arguments \ - '1:profile:($account_profiles)' \ - {--yes,-y}'[Skip confirmation prompts]' - ;; - default) - _arguments '1:profile:($account_profiles)' - ;; - esac - ;; - esac -} - -_ccs "$@" diff --git a/src/ccs.ts b/src/ccs.ts index 790f652e..64bfddd6 100644 --- a/src/ccs.ts +++ b/src/ccs.ts @@ -318,10 +318,11 @@ async function main(): Promise { registerTarget(new CodexAdapter()); const args = process.argv.slice(2); + const isCompletionCommand = args[0] === '__complete'; // Initialize UI colors early to ensure consistent colored output // Must happen before any status messages (ok, info, fail, etc.) - if (process.stdout.isTTY && !process.env['CI']) { + if (!isCompletionCommand && process.stdout.isTTY && !process.env['CI']) { const { initUI } = await import('./utils/ui'); await initUI(); } @@ -361,7 +362,7 @@ async function main(): Promise { // Security warning: cloud sync paths expose OAuth tokens const cloudService = detectCloudSyncPath(configDirValue); - if (cloudService) { + if (!isCompletionCommand && 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 ...'); @@ -372,7 +373,7 @@ async function main(): Promise { } 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) { + if (!isCompletionCommand && 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 ...'); @@ -380,13 +381,18 @@ async function main(): Promise { } 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) { + if (!isCompletionCommand && 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 ...'); } } + if (isCompletionCommand) { + await tryHandleRootCommand(args); + return; + } + if (shouldPassthroughNativeCodexFlagCommand(args)) { execNativeCodexFlagCommand(args); return; diff --git a/src/commands/command-catalog.ts b/src/commands/command-catalog.ts new file mode 100644 index 00000000..a04589e7 --- /dev/null +++ b/src/commands/command-catalog.ts @@ -0,0 +1,330 @@ +import { COPILOT_SUBCOMMANDS } from '../copilot/constants'; +import { CLIPROXY_PROVIDER_IDS } from '../cliproxy/provider-capabilities'; +import { CURSOR_SUBCOMMANDS } from './cursor-command'; + +export type HelpTopicName = 'profiles' | 'providers' | 'completion' | 'targets'; + +export interface HelpTopicEntry { + name: HelpTopicName; + summary: string; +} + +export interface RootCommandEntry { + name: string; + summary: string; + group: 'start' | 'manage' | 'runtime' | 'operations'; + visibility: 'public' | 'hidden'; + aliases?: readonly string[]; +} + +export interface ShortcutEntry { + name: string; + summary: string; +} + +export const ROOT_HELP_TOPICS: readonly HelpTopicEntry[] = [ + { name: 'profiles', summary: 'Account profiles, API profiles, and CLIProxy variants' }, + { name: 'providers', summary: 'Built-in OAuth providers and runtime shortcuts' }, + { name: 'completion', summary: 'Shell completion install, refresh, and testing' }, + { name: 'targets', summary: 'Claude, Droid, and Codex target routing' }, +] as const; + +export const ROOT_COMMAND_CATALOG: readonly RootCommandEntry[] = [ + { + name: 'help', + summary: 'Show root help or help for a topic/command', + group: 'start', + aliases: ['--help', '-h'], + visibility: 'public', + }, + { + name: 'version', + summary: 'Show version and install details', + group: 'start', + aliases: ['--version', '-v'], + visibility: 'public', + }, + { + name: 'setup', + summary: 'Run the first-time setup wizard', + group: 'start', + aliases: ['--setup'], + visibility: 'public', + }, + { + name: 'config', + summary: 'Open the dashboard and config subcommands', + group: 'start', + visibility: 'public', + }, + { + name: 'doctor', + summary: 'Run health checks and diagnostics', + group: 'start', + aliases: ['--doctor'], + visibility: 'public', + }, + { + name: 'auth', + summary: 'Manage concurrent Claude accounts', + group: 'manage', + visibility: 'public', + }, + { + name: 'api', + summary: 'Create, discover, copy, export, and import API profiles', + group: 'manage', + visibility: 'public', + }, + { + name: 'cliproxy', + summary: 'Manage CLIProxy variants, quota, and local lifecycle', + group: 'manage', + visibility: 'public', + }, + { + name: 'env', + summary: 'Export profile env for third-party tools', + group: 'manage', + visibility: 'public', + }, + { + name: 'persist', + summary: 'Write profile setup to ~/.claude/settings.json', + group: 'manage', + visibility: 'public', + }, + { name: 'tokens', summary: 'Manage CLIProxy auth tokens', group: 'manage', visibility: 'public' }, + { + name: 'migrate', + summary: 'Move legacy JSON config to unified YAML config', + group: 'manage', + aliases: ['--migrate'], + visibility: 'public', + }, + { + name: 'cursor', + summary: 'Run or manage the Cursor bridge', + group: 'runtime', + visibility: 'public', + }, + { + name: 'copilot', + summary: 'Run or manage the GitHub Copilot bridge', + group: 'runtime', + visibility: 'public', + }, + { + name: 'docker', + summary: 'Deploy or operate the bundled Docker stack', + group: 'operations', + visibility: 'public', + }, + { + name: 'sync', + summary: 'Sync delegation commands and skills', + group: 'operations', + aliases: ['--sync'], + visibility: 'public', + }, + { + name: 'update', + summary: 'Update CCS to the latest version', + group: 'operations', + aliases: ['--update'], + visibility: 'public', + }, + { + name: 'cleanup', + summary: 'Remove old CLIProxy logs', + group: 'operations', + aliases: ['--cleanup'], + visibility: 'public', + }, + { + name: '--shell-completion', + summary: 'Install shell completion', + group: 'operations', + aliases: ['-sc'], + visibility: 'hidden', + }, + { + name: '--install', + summary: 'Post-install bootstrap hook', + group: 'operations', + visibility: 'hidden', + }, + { + name: '--uninstall', + summary: 'Post-uninstall cleanup hook', + group: 'operations', + visibility: 'hidden', + }, + { + name: '__complete', + summary: 'Hidden shell completion backend', + group: 'operations', + visibility: 'hidden', + }, +] as const; + +export const BUILTIN_PROVIDER_SHORTCUTS: readonly ShortcutEntry[] = CLIPROXY_PROVIDER_IDS.map( + (name) => ({ + name, + summary: + { + gemini: 'Google Gemini via CLIProxy OAuth', + codex: 'OpenAI Codex via CLIProxy OAuth', + agy: 'Antigravity via CLIProxy OAuth', + qwen: 'Qwen Code via CLIProxy OAuth', + iflow: 'iFlow via CLIProxy OAuth', + kiro: 'Kiro via CLIProxy OAuth', + ghcp: 'GitHub Copilot via CLIProxy OAuth', + claude: 'Claude via CLIProxy OAuth', + kimi: 'Kimi via CLIProxy OAuth', + }[name] || 'CLIProxy OAuth provider', + }) +); + +export const ROOT_PROFILE_EXAMPLES: readonly ShortcutEntry[] = [ + { name: 'ccs auth create work', summary: 'Create a concurrent Claude account profile' }, + { name: 'ccs api create --preset glm', summary: 'Create a GLM-backed API profile' }, + { + name: 'ccs api create --preset anthropic --1m', + summary: 'Create a Claude API profile with explicit [1m]', + }, + { + name: 'ccs env --format claude-extension --ide vscode', + summary: 'Export IDE extension settings', + }, +] as const; + +export const ROOT_COMPATIBLE_ALIAS_EXAMPLES: readonly ShortcutEntry[] = [ + { name: '--target claude|droid|codex', summary: 'Route a profile to the target runtime' }, + { name: 'ccs-droid / ccsd', summary: 'Explicit Droid runtime aliases' }, + { name: 'ccs-codex / ccsx / ccsxp', summary: 'Explicit Codex runtime aliases' }, +] as const; + +export const ROOT_COMMAND_FLAGS = [ + '--help', + '-h', + '--version', + '-v', + '--shell-completion', + '-sc', +] as const; +export const AUTH_SUBCOMMANDS = [ + 'create', + 'list', + 'show', + 'remove', + 'default', + 'reset-default', +] as const; +export const API_SUBCOMMANDS = [ + 'create', + 'list', + 'discover', + 'copy', + 'export', + 'import', + 'remove', +] as const; +export const CLIPROXY_SUBCOMMANDS = [ + 'create', + 'edit', + 'list', + 'remove', + 'catalog', + 'sync', + 'quota', + 'start', + 'restart', + 'status', + 'stop', + 'doctor', + 'default', + 'pause', + 'resume', +] as const; +export const CONFIG_SUBCOMMANDS = ['auth', 'channels', 'image-analysis', 'thinking'] as const; +export const DOCKER_SUBCOMMANDS = ['up', 'down', 'status', 'update', 'logs', 'config'] as const; +export const TOKENS_FLAGS = [ + '--show', + '--api-key', + '--secret', + '--regenerate-secret', + '--variant', + '--reset', + '--help', + '-h', +] as const; +export const MIGRATE_FLAGS = ['--dry-run', '--rollback', '--list-backups', '--help'] as const; +export const CLEANUP_FLAGS = [ + '--errors', + '--days=', + '--dry-run', + '--force', + '--help', + '-h', +] as const; +export const PROVIDER_FLAGS = [ + '--auth', + '--add', + '--paste-callback', + '--accounts', + '--use', + '--config', + '--thinking', + '--effort', + '--1m', + '--no-1m', + '--logout', + '--headless', + '--port-forward', + '--help', + '-h', +] as const; + +export const COMMAND_FLAG_SUGGESTIONS: Readonly> = { + '--shell-completion': ['--bash', '--zsh', '--fish', '--powershell', '--force', '-f'], + auth: ['--help', '-h'], + api: ['--help', '-h'], + cleanup: CLEANUP_FLAGS, + config: ['--help', '-h', '--port', '-p', '--host', '-H', '--dev'], + cursor: ['--help', '-h'], + doctor: ['--fix', '-f', '--help', '-h'], + docker: ['--help', '-h', '--host'], + env: ['--format', '--shell', '--ide', '--help', '-h'], + migrate: MIGRATE_FLAGS, + tokens: TOKENS_FLAGS, + update: ['--force', '--beta', '--dev', '--help', '-h'], +}; + +export const CURSOR_COMPLETION_SUBCOMMANDS = [...CURSOR_SUBCOMMANDS] as const; +export const COPILOT_COMPLETION_SUBCOMMANDS = [...COPILOT_SUBCOMMANDS, 'help'] as const; + +export function getPublicRootCommands(): readonly RootCommandEntry[] { + return ROOT_COMMAND_CATALOG.filter((entry) => entry.visibility === 'public'); +} + +export function getAllRootCommandTokens(): string[] { + return uniqueStrings( + ROOT_COMMAND_CATALOG.flatMap((entry) => [entry.name, ...(entry.aliases || [])]).concat( + 'copilot' + ) + ); +} + +export function getPublicRootCommandTokens(): string[] { + return uniqueStrings( + ROOT_COMMAND_CATALOG.filter((entry) => entry.visibility === 'public').flatMap((entry) => [ + entry.name, + ...(entry.aliases || []), + ]) + ); +} + +export function uniqueStrings(values: readonly string[]): string[] { + return [...new Set(values.filter(Boolean))].sort((left, right) => left.localeCompare(right)); +} diff --git a/src/commands/completion-backend.ts b/src/commands/completion-backend.ts new file mode 100644 index 00000000..2537d4c5 --- /dev/null +++ b/src/commands/completion-backend.ts @@ -0,0 +1,260 @@ +import ProfileDetector from '../auth/profile-detector'; +import { + API_SUBCOMMANDS, + AUTH_SUBCOMMANDS, + BUILTIN_PROVIDER_SHORTCUTS, + CLEANUP_FLAGS, + COMMAND_FLAG_SUGGESTIONS, + CONFIG_SUBCOMMANDS, + COPILOT_COMPLETION_SUBCOMMANDS, + DOCKER_SUBCOMMANDS, + ROOT_COMMAND_FLAGS, + ROOT_HELP_TOPICS, + TOKENS_FLAGS, + PROVIDER_FLAGS, + uniqueStrings, + getPublicRootCommandTokens, + CLIPROXY_SUBCOMMANDS, + MIGRATE_FLAGS, + CURSOR_COMPLETION_SUBCOMMANDS, +} from './command-catalog'; + +export interface CompletionSuggestion { + value: string; + description?: string; +} + +type CompletionShell = 'bash' | 'zsh' | 'fish' | 'powershell'; + +interface CompletionRequest { + current: string; + shell: CompletionShell; + tokensBeforeCurrent: string[]; +} + +function suggestion(value: string, description?: string): CompletionSuggestion { + return { value, description }; +} + +function filterSuggestions( + suggestions: readonly CompletionSuggestion[], + current: string +): CompletionSuggestion[] { + const needle = current.trim().toLowerCase(); + const deduped = [...new Map(suggestions.map((entry) => [entry.value, entry])).values()]; + if (!needle) return deduped.sort((left, right) => left.value.localeCompare(right.value)); + return deduped + .filter((entry) => entry.value.toLowerCase().startsWith(needle)) + .sort((left, right) => left.value.localeCompare(right.value)); +} + +function getDynamicProfileSuggestions(): CompletionSuggestion[] { + const detector = new ProfileDetector(); + const profiles = detector.getAllProfiles(); + + return uniqueStrings([ + ...profiles.settings, + ...profiles.accounts, + ...profiles.cliproxyVariants, + ]).map((value) => suggestion(value)); +} + +function getTopLevelSuggestions(): CompletionSuggestion[] { + return [ + ...getPublicRootCommandTokens().map((value) => suggestion(value)), + ...ROOT_COMMAND_FLAGS.map((value) => suggestion(value)), + ...BUILTIN_PROVIDER_SHORTCUTS.map((entry) => suggestion(entry.name, entry.summary)), + ...getDynamicProfileSuggestions(), + ]; +} + +function getProfileDetector(): ProfileDetector { + return new ProfileDetector(); +} + +function getProfileNames(type: 'settings' | 'accounts' | 'cliproxyVariants'): string[] { + return getProfileDetector().getAllProfiles()[type]; +} + +function completeSubcommands( + values: readonly string[], + flags: readonly string[] = ['--help', '-h'] +): CompletionSuggestion[] { + return uniqueStrings([...values, ...flags]).map((value) => suggestion(value)); +} + +function getSuggestionsForCommand(tokensBeforeCurrent: string[]): CompletionSuggestion[] { + const [command, subcommand] = tokensBeforeCurrent; + const lastToken = tokensBeforeCurrent[tokensBeforeCurrent.length - 1]; + + switch (command) { + case undefined: + return getTopLevelSuggestions(); + case 'help': + return completeSubcommands(ROOT_HELP_TOPICS.map((topic) => topic.name)); + case 'auth': + if (!subcommand) return completeSubcommands(AUTH_SUBCOMMANDS); + if (subcommand === 'show') + return completeSubcommands(getProfileNames('accounts'), ['--json']); + if (subcommand === 'remove') + return completeSubcommands(getProfileNames('accounts'), ['--yes', '-y']); + if (subcommand === 'default') return completeSubcommands(getProfileNames('accounts')); + if (subcommand === 'create') { + return completeSubcommands( + [], + ['--force', '--share-context', '--context-group', '--deeper-continuity', '--bare'] + ); + } + if (subcommand === 'list') return completeSubcommands([], ['--verbose', '--json']); + return completeSubcommands([], COMMAND_FLAG_SUGGESTIONS.auth); + case 'api': + if (!subcommand) return completeSubcommands(API_SUBCOMMANDS); + if (subcommand === 'create') { + return completeSubcommands( + [], + [ + '--preset', + '--cliproxy-provider', + '--base-url', + '--api-key', + '--model', + '--1m', + '--no-1m', + '--target', + '--force', + '--yes', + '-y', + ] + ); + } + if (subcommand === 'discover') + return completeSubcommands([], ['--register', '--json', '--force']); + if (subcommand === 'copy') + return completeSubcommands(getProfileNames('settings'), [ + '--target', + '--force', + '--yes', + '-y', + ]); + if (subcommand === 'export') + return completeSubcommands(getProfileNames('settings'), ['--out', '--include-secrets']); + if (subcommand === 'import') + return completeSubcommands([], ['--name', '--yes', '-y', '--force']); + if (subcommand === 'remove') + return completeSubcommands(getProfileNames('settings'), ['--yes', '-y']); + return completeSubcommands([], COMMAND_FLAG_SUGGESTIONS.api); + case 'cliproxy': + if (!subcommand) + return completeSubcommands(CLIPROXY_SUBCOMMANDS, [ + '--install', + '--latest', + '--update', + '--backend', + '--verbose', + '-v', + '--help', + '-h', + ]); + if (['remove', 'edit'].includes(subcommand)) { + return completeSubcommands(getProfileNames('cliproxyVariants'), ['--yes', '-y']); + } + if (subcommand === 'create' && lastToken === '--provider') { + return BUILTIN_PROVIDER_SHORTCUTS.map((entry) => suggestion(entry.name, entry.summary)); + } + return completeSubcommands( + [], + ['--provider', '--model', '--target', '--backend', '--force', '--yes', '-y', '--help', '-h'] + ); + case 'config': + if (!subcommand || subcommand.startsWith('-')) { + return completeSubcommands(CONFIG_SUBCOMMANDS, COMMAND_FLAG_SUGGESTIONS.config); + } + return []; + case 'docker': + if (!subcommand || subcommand.startsWith('-')) { + return completeSubcommands(DOCKER_SUBCOMMANDS, COMMAND_FLAG_SUGGESTIONS.docker); + } + if (subcommand === 'up') + return completeSubcommands([], ['--port', '--proxy-port', '--host', '--help', '-h']); + if (subcommand === 'logs') + return completeSubcommands([], ['--follow', '--service', '--host', '--help', '-h']); + return completeSubcommands([], COMMAND_FLAG_SUGGESTIONS.docker); + case 'cursor': + return completeSubcommands(CURSOR_COMPLETION_SUBCOMMANDS); + case 'copilot': + return completeSubcommands(COPILOT_COMPLETION_SUBCOMMANDS); + case 'env': + if (lastToken === '--format') + return completeSubcommands(['openai', 'anthropic', 'raw', 'claude-extension']); + if (lastToken === '--shell') + return completeSubcommands(['auto', 'bash', 'zsh', 'fish', 'powershell']); + if (lastToken === '--ide') return completeSubcommands(['vscode', 'cursor', 'windsurf']); + return completeSubcommands( + uniqueStrings([ + ...getProfileNames('settings'), + ...getProfileNames('accounts'), + ...BUILTIN_PROVIDER_SHORTCUTS.map((entry) => entry.name), + ]), + COMMAND_FLAG_SUGGESTIONS.env + ); + case 'tokens': + if (lastToken === '--variant') + return completeSubcommands(getProfileNames('cliproxyVariants')); + return completeSubcommands([], TOKENS_FLAGS); + case 'migrate': + return completeSubcommands([], MIGRATE_FLAGS); + case 'cleanup': + return completeSubcommands([], CLEANUP_FLAGS); + case '--shell-completion': + case '-sc': + return completeSubcommands( + [], + ['--bash', '--zsh', '--fish', '--powershell', '--force', '-f'] + ); + default: + if (BUILTIN_PROVIDER_SHORTCUTS.some((entry) => entry.name === command)) { + if (command === 'agy') + return completeSubcommands([], [...PROVIDER_FLAGS, '--accept-agr-risk']); + if (command === 'kiro') { + return completeSubcommands( + [], + [...PROVIDER_FLAGS, '--kiro-auth-method', '--import', '--incognito'] + ); + } + return completeSubcommands([], PROVIDER_FLAGS); + } + return completeSubcommands([], COMMAND_FLAG_SUGGESTIONS[command] || []); + } +} + +export function getCompletionSuggestions(request: CompletionRequest): CompletionSuggestion[] { + return filterSuggestions(getSuggestionsForCommand(request.tokensBeforeCurrent), request.current); +} + +function parseCompletionArgs(args: string[]): CompletionRequest { + const shellIndex = args.indexOf('--shell'); + const currentIndex = args.indexOf('--current'); + const separatorIndex = args.indexOf('--'); + const shell = (shellIndex !== -1 ? args[shellIndex + 1] : 'bash') as CompletionShell; + const current = currentIndex !== -1 ? args[currentIndex + 1] || '' : ''; + const tokensBeforeCurrent = separatorIndex === -1 ? [] : args.slice(separatorIndex + 1); + return { shell, current, tokensBeforeCurrent }; +} + +function formatForShell( + shell: CompletionShell, + suggestions: readonly CompletionSuggestion[] +): string[] { + if (shell === 'fish' || shell === 'powershell') { + return suggestions.map((entry) => + entry.description ? `${entry.value}\t${entry.description}` : entry.value + ); + } + return suggestions.map((entry) => entry.value); +} + +export async function handleCompletionCommand(args: string[]): Promise { + const request = parseCompletionArgs(args); + const suggestions = getCompletionSuggestions(request); + process.stdout.write(formatForShell(request.shell, suggestions).join('\n')); +} diff --git a/src/commands/help-command.ts b/src/commands/help-command.ts index a457168a..88efc512 100644 --- a/src/commands/help-command.ts +++ b/src/commands/help-command.ts @@ -1,695 +1,229 @@ -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 { getCcsDirDisplay } from '../utils/config-manager'; -import { CLIPROXY_DEFAULT_PORT } from '../cliproxy/config/port-manager'; -import { getOfficialChannelsSupportMessage } from '../channels/official-channels-runtime'; +import packageJson from '../../package.json'; +import { color, dim, header, initUI, subheader } from '../utils/ui'; +import { + BUILTIN_PROVIDER_SHORTCUTS, + ROOT_COMMAND_CATALOG, + ROOT_COMPATIBLE_ALIAS_EXAMPLES, + ROOT_HELP_TOPICS, + ROOT_PROFILE_EXAMPLES, + getPublicRootCommands, + type HelpTopicName, + type RootCommandEntry, +} from './command-catalog'; type HelpWriter = (line: string) => void; -// Get version from package.json (same as version-command.ts) -const VERSION = JSON.parse( - fs.readFileSync(path.join(__dirname, '../../package.json'), 'utf8') -).version; +function getTopicSummary(name: HelpTopicName): string { + return ROOT_HELP_TOPICS.find((topic) => topic.name === name)?.summary || ''; +} -/** - * Print a major section with ═══ borders (only for 3 main sections) - * Format: - * ═══ TITLE ═══ - * Subtitle line 1 - * Subtitle line 2 - * - * command Description - */ -function printMajorSection( +function writeCommandTable( title: string, - subtitles: string[], - items: [string, string][], - writeLine: HelpWriter = console.log + entries: readonly { name: string; summary: string }[], + writeLine: HelpWriter ): void { - // Section header with ═══ borders - writeLine(sectionHeader(title)); - - // Subtitles on separate lines (dim) - for (const subtitle of subtitles) { - writeLine(` ${dim(subtitle)}`); + writeLine(subheader(title)); + const maxWidth = Math.max(...entries.map((entry) => entry.name.length)); + for (const entry of entries) { + writeLine(` ${color(entry.name.padEnd(maxWidth + 2), 'command')} ${entry.summary}`); } - - // Empty line before items - writeLine(''); - - // Calculate max command length for alignment - const maxCmdLen = Math.max(...items.map(([cmd]) => cmd.length)); - - for (const [cmd, desc] of items) { - const paddedCmd = cmd.padEnd(maxCmdLen + 2); - writeLine(` ${color(paddedCmd, 'command')} ${desc}`); - } - - // Extra spacing after section writeLine(''); } -/** - * Print a sub-section with colored title - * Format: - * Title (context): - * command Description - */ -function printSubSection( - title: string, - items: [string, string][], - writeLine: HelpWriter = console.log -): void { - // Sub-section header (colored, no borders) - writeLine(subheader(`${title}:`)); +function writeGroupedCommands(group: RootCommandEntry['group'], writeLine: HelpWriter): void { + const entries = getPublicRootCommands() + .filter((entry) => entry.group === group) + .map((entry) => ({ name: entry.name, summary: entry.summary })); - // Calculate max command length for alignment - const maxCmdLen = Math.max(...items.map(([cmd]) => cmd.length)); + const titles: Record = { + start: 'Start Here', + manage: 'Profile Management', + operations: 'Operations', + runtime: 'Compatible Runtimes', + }; - for (const [cmd, desc] of items) { - const paddedCmd = cmd.padEnd(maxCmdLen + 2); - writeLine(` ${color(paddedCmd, 'command')} ${desc}`); - } + writeCommandTable(titles[group], entries, writeLine); +} - // Spacing after section +async function showProfilesHelp(writeLine: HelpWriter): Promise { + await initUI(); + writeLine(header('CCS Profiles Help')); + writeLine(''); + writeCommandTable( + 'Profile Types', + [ + { name: 'ccs auth create ', summary: 'Concurrent Claude account profile' }, + { name: 'ccs api create', summary: 'API-backed settings profile' }, + { name: 'ccs cliproxy create ', summary: 'Named CLIProxy variant profile' }, + { name: 'ccs env ', summary: 'Export an existing profile for other tools' }, + ], + writeLine + ); + writeCommandTable('Examples', ROOT_PROFILE_EXAMPLES, writeLine); + writeLine(` ${dim('Deep help: ccs auth --help | ccs api --help | ccs cliproxy --help')}`); writeLine(''); } -/** - * Print a config/paths section - * Format: - * Title: - * Label: path - */ -function printConfigSection( - title: string, - items: [string, string][], - writeLine: HelpWriter = console.log -): void { - writeLine(subheader(`${title}:`)); - - // Calculate max label length for alignment - const maxLabelLen = Math.max(...items.map(([label]) => label.length)); - - for (const [label, path] of items) { - const paddedLabel = label.padEnd(maxLabelLen); - writeLine(` ${paddedLabel} ${color(path, 'path')}`); - } - +async function showProvidersHelp(writeLine: HelpWriter): Promise { + await initUI(); + writeLine(header('CCS Providers Help')); + writeLine(''); + writeCommandTable('Built-in OAuth Providers', BUILTIN_PROVIDER_SHORTCUTS, writeLine); + writeCommandTable( + 'Common Setup Paths', + [ + { + name: 'ccs --auth', + summary: 'Authenticate a provider account without launching', + }, + { name: 'ccs api create --preset ', summary: 'Create an API-backed provider profile' }, + { name: 'ccs config', summary: 'Use the dashboard for provider and model setup' }, + ], + writeLine + ); + writeLine(` ${dim('Deep help: ccs cliproxy --help | ccs api --help')}`); writeLine(''); } -/** - * Display comprehensive help information for CCS (Claude Code Switch) - */ +async function showTargetsHelp(writeLine: HelpWriter): Promise { + await initUI(); + writeLine(header('CCS Targets Help')); + writeLine(''); + writeCommandTable('Target Routing', ROOT_COMPATIBLE_ALIAS_EXAMPLES, writeLine); + writeCommandTable( + 'Examples', + [ + { name: 'ccs glm --target droid', summary: 'Run a profile on Droid instead of Claude' }, + { + name: 'ccs --target codex', + summary: 'Open a native Codex session with your current setup', + }, + { name: 'ccs codex-api --target codex', summary: 'Run a routed API bridge on native Codex' }, + ], + writeLine + ); +} + export async function handleHelpCommand(writeLine: HelpWriter = console.log): Promise { - // Initialize UI (if not already) await initUI(); - // Hero box with ASCII art logo and config hint - // Each letter: C=╔═╗/║ /╚═╝, C=╔═╗/║ /╚═╝, S=╔═╗/╚═╗/╚═╝ - const logo = ` -╔═╗ ╔═╗ ╔═╗ -║ ║ ╚═╗ v${VERSION} -╚═╝ ╚═╝ ╚═╝ - -Claude Code Profile & Model Switcher - -Run ${color('ccs config', 'command')} for web dashboard`.trim(); - - writeLine( - box(logo, { - padding: 1, - borderStyle: 'round', - titleAlignment: 'center', - }) - ); + writeLine(header(`CCS CLI v${packageJson.version}`)); + writeLine(''); + writeLine(' Claude profile switching, provider routing, and compatible runtime bridges.'); writeLine(''); - // Resolve display path for dynamic sections - const dirDisplay = getCcsDirDisplay(); - - // Usage section - writeLine(subheader('Usage:')); - writeLine(` ${color('ccs', 'command')} [profile] [claude-args...]`); - writeLine(` ${color('ccs', 'command')} [flags]`); + writeLine(subheader('Usage')); + writeLine(` ${color('ccs [claude-args...]', 'command')}`); + writeLine(` ${color('ccs [options]', 'command')}`); + writeLine(` ${color('ccs help ', 'command')}`); writeLine(''); - // ═══════════════════════════════════════════════════════════════════════════ - // MAJOR SECTION 1: API Key Profiles - // ═══════════════════════════════════════════════════════════════════════════ - printMajorSection( - 'API Key Profiles', - [`Configure in ${dirDisplay}/*.settings.json`], + writeGroupedCommands('start', writeLine); + writeGroupedCommands('manage', writeLine); + writeGroupedCommands('runtime', writeLine); + writeGroupedCommands('operations', writeLine); + + writeCommandTable( + 'OAuth Provider Shortcuts', + BUILTIN_PROVIDER_SHORTCUTS.map((entry) => ({ + name: `ccs ${entry.name}`, + summary: entry.summary, + })), + writeLine + ); + + writeCommandTable('Examples', ROOT_PROFILE_EXAMPLES, writeLine); + writeCommandTable('Targets and Aliases', ROOT_COMPATIBLE_ALIAS_EXAMPLES, writeLine); + writeCommandTable( + 'More Help', [ - ['ccs', 'Use default Claude account'], - ['ccs glm', 'GLM 5 (API key required)'], - ['ccs km', 'Kimi for Coding (API key)'], - [ - 'ccs api create --preset alibaba-coding-plan', - 'Alibaba Coding Plan (Anthropic-compatible API key)', - ], - ['ccs ollama', 'Local Ollama (http://localhost:11434)'], - ['ccs llamacpp', 'Local llama.cpp (http://127.0.0.1:8080)'], - ['ccs ollama-cloud', 'Ollama Cloud (API key required)'], - ['', ''], // Spacer - ['ccs api create --preset anthropic', 'Anthropic direct API key (sk-ant-...)'], - [ - 'ccs api create --cliproxy-provider gemini', - 'Create routed API profile from CLIProxy Gemini', - ], - ['ccs api create', 'Create custom API profile'], - ['ccs api discover --register', 'Discover/register orphan settings files'], - ['ccs api copy ', 'Duplicate API profile'], - ['ccs api export ', 'Export profile bundle'], - ['ccs api import ', 'Import profile bundle'], - ['ccs api remove', 'Remove an API profile'], - ['ccs api list', 'List all API profiles'], + { name: 'ccs help profiles', summary: getTopicSummary('profiles') }, + { name: 'ccs help providers', summary: getTopicSummary('providers') }, + { name: 'ccs help completion', summary: getTopicSummary('completion') }, + { name: 'ccs help targets', summary: getTopicSummary('targets') }, + { name: 'ccs api --help', summary: 'Deep help for API profile lifecycle commands' }, + { name: 'ccs cliproxy --help', summary: 'Deep help for variants, quota, and lifecycle' }, + { name: 'ccs docker --help', summary: 'Deep help for Docker deployment commands' }, + { name: 'ccs cursor --help', summary: 'Deep help for Cursor runtime/admin commands' }, + { name: 'ccs copilot --help', summary: 'Deep help for GitHub Copilot commands' }, ], writeLine ); - // ═══════════════════════════════════════════════════════════════════════════ - // MAJOR SECTION 2: Account Management - // ═══════════════════════════════════════════════════════════════════════════ - printMajorSection( - 'Account Management', - ['Run multiple Claude accounts concurrently'], - [ - ['ccs auth --help', 'Show account management commands'], - [ - 'ccs auth create ', - 'Create account profile (supports --bare, shared groups, --deeper-continuity)', - ], - ['ccs config', 'Dashboard: Accounts table can edit context mode/group/continuity depth'], - [ - '~/.ccs/config.yaml', - 'Optional: continuity.inherit_from_account maps API/CLIProxy/copilot/default profiles to an account context', - ], - ['ccs auth list', 'List all account profiles'], - ['ccs auth default ', 'Set default profile'], - ['ccs auth reset-default', 'Restore original CCS default'], - ['ccs cliproxy auth claude', 'Alternative: authenticate Claude account pool via CLIProxy'], - ], - writeLine - ); - - // ═══════════════════════════════════════════════════════════════════════════ - // MAJOR SECTION 3: CLI Proxy (OAuth Providers) - // ═══════════════════════════════════════════════════════════════════════════ - printMajorSection( - 'CLI Proxy Plus (OAuth Providers)', - [ - 'Zero-config OAuth authentication via CLIProxy Plus', - 'First run: Browser opens for authentication, then model selection', - 'Settings: ~/.ccs/{provider}.settings.json (created after auth)', - 'Safety: do not reuse one Google account across "ccs gemini" and "ccs agy" (issue #509)', - 'Antigravity requires multi-step responsibility confirmation (issue #509)', - 'If you want to keep Google AI access, do not continue this shared-account setup', - 'CCS is as-is and does not take responsibility for account bans/access loss', - ], - [ - ['ccs gemini', 'Google Gemini (gemini-2.5-pro or 3-pro)'], - ['ccs codex', 'OpenAI Codex (supports -medium/-high/-xhigh model suffixes)'], - ['ccs agy', 'Antigravity (Claude/Gemini models)'], - ['ccs qwen', 'Qwen Code OAuth (CLIProxy)'], - ['ccs kimi', 'Kimi (Moonshot AI K2/K2.5 models)'], - ['ccs kiro', 'Kiro (AWS CodeWhisperer Claude models)'], - ['ccs ghcp', 'GitHub Copilot (OAuth via CLIProxy Plus)'], - ['', ''], // Spacer - ['ccs --auth', 'Authenticate only'], - ['ccs --auth --add', 'Add another account'], - [ - 'ccs --paste-callback', - 'Show auth URL and prompt for callback paste (cross-browser)', - ], - ['ccs --accounts', 'List all accounts'], - ['ccs --use ', 'Switch to account'], - ['ccs --config', 'Change model (agy, gemini)'], - [ - 'ccs agy --accept-agr-risk', - 'Bypass interactive Antigravity confirmation (you accept full responsibility)', - ], - [ - 'ccs --thinking ', - 'Set thinking budget (low/medium/high/xhigh/auto/off or number)', - ], - ['ccs codex --effort ', 'Set codex reasoning effort (minimal/low/medium/high/xhigh)'], - ['ccs --1m', 'Request explicit 1M context when the selected model supports [1m]'], - ['ccs --no-1m', 'Force standard context / clear [1m]'], - ['ccs --logout', 'Clear authentication'], - ['ccs --headless', 'Headless auth (for SSH)'], - ['ccs --port-forward', 'Force port-forwarding mode (skip prompt)'], - ['ccs kiro --auth --kiro-auth-method aws', 'Kiro via AWS Builder ID (device code)'], - ['ccs kiro --auth --kiro-auth-method aws-authcode', 'Kiro via AWS auth code flow'], - ['ccs kiro --auth --kiro-auth-method google', 'Kiro via Google OAuth'], - ['ccs kiro --auth --kiro-auth-method github', 'Kiro via GitHub OAuth (Dashboard flow)'], - ['ccs kiro --import', 'Import token from Kiro IDE'], - ['ccs kiro --incognito', 'Use incognito browser (default: normal)'], - ['ccs codex "explain code"', 'Use with prompt'], - ], - writeLine - ); - - // ═══════════════════════════════════════════════════════════════════════════ - // MAJOR SECTION 4: GitHub Copilot Integration (copilot-api) - // ═══════════════════════════════════════════════════════════════════════════ - printMajorSection( - 'GitHub Copilot Integration (copilot-api)', - [ - 'Use your GitHub Copilot subscription with Claude Code via copilot-api', - 'Requires: npm install -g copilot-api', - 'Note: For OAuth-based access, use ccs ghcp instead', - ], - [ - ['ccs copilot', 'Use Copilot via copilot-api daemon'], - ['ccs copilot auth', 'Authenticate with GitHub'], - ['ccs copilot status', 'Show integration status'], - ['ccs copilot models', 'List available models'], - ['ccs copilot usage', 'Show Copilot quota usage'], - ['ccs copilot start', 'Start copilot-api daemon'], - ['ccs copilot stop', 'Stop copilot-api daemon'], - ['ccs copilot enable', 'Enable integration'], - ['ccs copilot disable', 'Disable integration'], - ], - writeLine - ); - - // ═══════════════════════════════════════════════════════════════════════════ - // MAJOR SECTION 5: Cursor IDE Integration - // ═══════════════════════════════════════════════════════════════════════════ - printMajorSection( - 'Cursor IDE Integration', - [ - 'Use Cursor IDE with Claude Code via cursor proxy daemon', - 'Auto-detects token from Cursor installation', - ], - [ - ['ccs cursor', 'Run Claude via Cursor local proxy'], - ['ccs cursor auth', 'Import Cursor token'], - ['ccs cursor auth --manual --token --machine-id ', 'Manual token import'], - ['ccs cursor status', 'Show connection status'], - ['ccs cursor models', 'List available models'], - ['ccs cursor start', 'Start proxy daemon'], - ['ccs cursor stop', 'Stop proxy daemon'], - ['ccs cursor enable', 'Enable cursor integration'], - ['ccs cursor disable', 'Disable cursor integration'], - ], - writeLine - ); - - // ═══════════════════════════════════════════════════════════════════════════ - // SUB-SECTIONS (simpler styling) - // ═══════════════════════════════════════════════════════════════════════════ - - // Delegation - printSubSection( - 'Delegation (inside Claude Code CLI)', - [ - ['/ccs "task"', 'Delegate task (auto-selects profile)'], - ['/ccs --glm "task"', 'Force GLM-5 for simple tasks'], - ['/ccs --kimi "task"', 'Force Kimi OAuth for long context'], - ['/ccs --km "task"', 'Force Kimi API key for long context'], - ['/ccs:continue "follow-up"', 'Continue last delegation session'], - ], - writeLine - ); - - // Delegation CLI Flags (Claude Code passthrough) - printSubSection( - 'Delegation Flags (Claude Code passthrough)', - [ - ['--max-turns ', 'Limit agentic turns (prevents loops)'], - ['--fallback-model ', 'Auto-fallback on overload (sonnet)'], - ['--agents ', 'Inject dynamic subagents'], - ['--betas ', 'Enable experimental features'], - ['--allowedTools ', 'Restrict available tools'], - ['--disallowedTools ', 'Block specific tools'], - ], - writeLine - ); - - // Diagnostics - printSubSection( - 'Diagnostics', - [ - ['ccs setup', 'First-time setup wizard'], - ['ccs doctor', 'Run health check and diagnostics'], - ['ccs cleanup', 'Remove old CLIProxy logs'], - ['ccs config', 'Open web dashboard (includes Claude IDE Extension setup page)'], - ['ccs config auth setup', 'Configure dashboard login'], - ['ccs config auth show', 'Show dashboard auth status'], - ['ccs config channels', 'Show Official Channels status'], - [ - 'ccs config channels --set telegram,discord', - 'Auto-add Telegram + Discord on supported native Claude runs', - ], - ['ccs config channels --set-token telegram=', 'Save TELEGRAM_BOT_TOKEN'], - ['ccs config image-analysis', 'Show image analysis settings'], - ['ccs config image-analysis --enable', 'Enable image analysis'], - ['ccs config thinking', 'Show thinking/reasoning settings'], - ['ccs config thinking --mode auto', 'Set thinking mode'], - ['ccs config thinking --clear-provider-override codex', 'Clear provider overrides'], - ['ccs config --port 3000', 'Use specific port'], - ['ccs config --host 0.0.0.0', 'Force all-interface binding for remote devices'], - ['ccs persist ', 'Write profile setup to ~/.claude/settings.json'], - ['ccs persist --list-backups', 'List available settings.json backups'], - ['ccs persist --restore', 'Restore settings.json from latest backup'], - ['ccs sync', 'Sync delegation commands and skills'], - ['ccs update', 'Update CCS to latest version'], - ['ccs update --force', 'Force reinstall current version'], - ['ccs update --beta', 'Install from dev channel (unstable)'], - ], - writeLine - ); - - // Environment export - printSubSection( - 'Environment Export', - [ - ['ccs env ', 'Export env vars for third-party tools'], - ['ccs env --format openai', 'OpenAI-compatible vars (OpenCode/Cursor)'], - ['ccs env --format anthropic', 'Anthropic vars (default)'], - ['ccs env --format raw', 'All effective env vars'], - [ - 'ccs env --format claude-extension --ide vscode', - 'VS Code/Cursor Claude extension settings JSON', - ], - [ - 'ccs env --format claude-extension --ide windsurf', - 'Windsurf Claude extension settings JSON', - ], - ['ccs env --shell fish', 'Fish shell syntax'], - ], - writeLine - ); - - // Flags - printSubSection( - 'Flags', - [ - ['--config-dir ', 'Use custom CCS config directory'], - ['--target ', 'Target CLI: claude (default), droid, codex (runtime-only)'], - ['-h, --help', 'Show this help message'], - ['-v, --version', 'Show version and installation info'], - ['-sc, --shell-completion', 'Install shell auto-completion'], - ], - writeLine - ); - - // Aliases - printSubSection( - 'Aliases', - [ - ['ccs-droid [args]', 'Explicit Droid runtime alias'], - ['ccsd [args]', 'Legacy shortcut for: ccs-droid [args]'], - ['ccs-codex [args]', 'Explicit Codex runtime alias'], - ['ccsx [args]', 'Short alias for: ccs-codex [args]'], - ['ccsxp [args]', 'Shortcut for: ccs codex --target codex [args]'], - ], - writeLine - ); - - // Multi-target examples - printSubSection( - 'Multi-Target', - [ - ['ccs glm --target droid', 'Run GLM profile on Droid CLI'], - ['ccs-droid glm', 'Same as above (explicit alias)'], - ['ccsd glm', 'Legacy shortcut for ccs-droid'], - ['ccs --target codex', 'Open a native Codex session with your existing ~/.codex setup'], - ['ccs-codex', 'Same as above (explicit Codex alias)'], - ['ccsx', 'Short alias for ccs-codex'], - ['ccsxp "your prompt"', 'Run built-in CLIProxy Codex on native Codex CLI'], - ['ccs codex --target codex', 'Explicit form of ccsxp'], - [ - 'ccs api create codex-api --cliproxy-provider codex', - 'Create a routed API bridge that can also run on Codex', - ], - ['ccs codex-api --target codex', 'Run a Codex bridge profile on native Codex CLI'], - ['ccs-droid codex', 'Run built-in CLIProxy Codex profile on Droid'], - ['ccs-droid agy', 'Run built-in CLIProxy Antigravity profile on Droid'], - [ - 'ccs-droid codex exec --skip-permissions-unsafe "fix failing tests"', - 'Pass through Droid exec mode', - ], - [ - 'ccs-droid codex -m custom:gpt-5.3-codex "fix failing tests"', - 'Auto-routes short exec flags', - ], - [ - 'ccs-droid codex --skip-permissions-unsafe "fix failing tests"', - 'Auto-routes to Droid exec when exec-only flags are detected', - ], - [ - 'ccs cliproxy create my-codex --provider codex --target droid', - 'Create CLIProxy variant with Droid as default target', - ], - ['ccs glm', 'Run GLM profile on Claude Code (default)'], - ], - writeLine - ); - - printSubSection( - 'Codex + CLIProxy', - [ - ['ccsxp "your prompt"', 'Use the built-in CCS Codex provider shortcut on native Codex'], - ['ccs config', 'Open Compatible -> Codex CLI for native Codex setup and diagnostics'], - [ - 'Default provider', - 'Set to cliproxy if plain codex or a personal cxp alias should use CLIProxy', - ], - ['Model provider auth', 'Save cliproxy with env_key = "CLIPROXY_API_KEY"'], - ], - writeLine - ); - - // Configuration - printConfigSection( - 'Configuration', - [ - ['Config File:', isUnifiedMode() ? `${dirDisplay}/config.yaml` : `${dirDisplay}/config.json`], - ['Profiles:', `${dirDisplay}/profiles.json`], - ['Instances:', `${dirDisplay}/instances/`], - ['Settings:', `${dirDisplay}/*.settings.json`], - ], - writeLine - ); - - // CLI Proxy management - printSubSection( - 'CLI Proxy Plus Management', - [ - ['ccs cliproxy', 'Show CLIProxy Plus status and version'], - ['ccs cliproxy --help', 'Full CLIProxy Plus management help'], - ['ccs cliproxy doctor', 'Quota diagnostics (Antigravity)'], - ['ccs cliproxy --install ', 'Install specific version (e.g., 6.6.6)'], - ['ccs cliproxy --latest', 'Update to latest version'], - ['', ''], // Spacer - ['ccs cliproxy pause

', 'Pause account from rotation'], - ['ccs cliproxy resume

', 'Resume paused account'], - ['ccs cliproxy status', 'Show CLIProxy process status'], - ['ccs cliproxy quota', 'Show quota/tier/pause status for all providers'], - ['ccs cliproxy quota --provider ', 'Show quota/tier/pause status for one provider'], - ], - writeLine - ); - - printSubSection( - 'Docker Deployment', - [ - ['ccs docker --help', 'Manage the integrated CCS + CLIProxy Docker stack'], - ['ccs docker up', 'Build and start the stack locally'], - ['ccs docker up --host ', 'Stage assets to ~/.ccs/docker and deploy remotely'], - ['ccs docker status', 'Show docker compose and supervisor status'], - ['ccs docker logs --follow', 'Tail combined CCS + CLIProxy logs'], - ['ccs docker update', 'Update CCS and CLIProxy inside the running container'], - ['ccs docker config', 'Show bundled asset paths and deployment defaults'], - ], - writeLine - ); - - // CLI Proxy configuration flags (new) - printSubSection( - 'CLI Proxy Configuration', - [ - ['--proxy-host ', 'Remote proxy hostname/IP'], - ['--proxy-port ', `Proxy port (default: ${CLIPROXY_DEFAULT_PORT})`], - ['--proxy-protocol ', 'Protocol: http or https (default: http)'], - ['--proxy-auth-token ', 'Auth token for remote proxy'], - ['--proxy-timeout ', 'Connection timeout in ms (default: 2000)'], - ['--local-proxy', 'Force local mode, ignore remote config'], - ['--remote-only', 'Fail if remote unreachable (no fallback)'], - ['--allow-self-signed', 'Allow self-signed certs (for dev proxies)'], - ], - writeLine - ); - - // W3: Thinking Budget explanation - printSubSection( - 'Extended Thinking / Reasoning', - [ - ['--thinking off', 'Disable extended thinking'], - ['--thinking auto', 'Let model decide dynamically'], - ['--thinking low', '1K tokens - Quick responses'], - ['--thinking medium', '8K tokens - Standard analysis'], - ['--thinking high', '24K tokens - Deep reasoning'], - ['--thinking xhigh', '32K tokens - Maximum depth'], - ['--thinking ', 'Custom token budget (512-100000)'], - ['', ''], - ['--effort ', 'Codex alias for reasoning effort (minimal/low/medium/high/xhigh)'], - ['--effort xhigh', 'Pin Codex effort to xhigh for this run'], - ['', ''], - ['Droid exec:', 'Use native Droid flag: --reasoning-effort '], - ['', 'CCS auto-maps --thinking/--effort to --reasoning-effort in droid exec mode.'], - ['', 'For interactive droid sessions, CCS applies reasoning via Droid BYOK model config.'], - ['', 'When multiple reasoning flags are provided, the first flag wins.'], - ['', ''], - ['Note:', 'Extended thinking allocates compute for step-by-step reasoning'], - ['', 'before responding.'], - ['', 'Providers: agy/gemini use --thinking, codex uses --effort (or --thinking alias).'], - ['', 'Codex model suffixes also pin effort: -medium / -high / -xhigh.'], - ], - writeLine - ); - - // Extended Context (1M) - printSubSection( - 'Extended Context (--1m)', - [ - ['--1m', 'Request 1M token context when the selected model supports [1m]'], - ['--no-1m', 'Force standard context (Claude default stays plain)'], - ['', ''], - ['Auto behavior:', 'Gemini models: CCS auto-adds [1m] when supported'], - ['', 'Claude models: plain by default, opt-in with --1m or saved [1m]'], - ['', ''], - ['Note:', 'CCS only controls the saved [1m] suffix.'], - ['', 'Provider pricing and entitlement stay upstream.'], - [ - '', - 'Some accounts/providers can still return 429 extra-usage errors for long-context requests.', - ], - ], - writeLine - ); - - // Image Analysis - printSubSection( - 'Image Analysis (first-class local tool)', - [ - ['ccs config image-analysis', 'Show current settings'], - ['ccs config image-analysis --enable', 'Enable for CLIProxy providers'], - ['ccs config image-analysis --disable', 'Disable (use native Read)'], - ['ccs config image-analysis --timeout 120', 'Set analysis timeout'], - ['ccs config image-analysis --set-model

', 'Set provider model'], - ['', ''], - ['Note:', 'When ready, third-party launches expose the local ImageAnalysis MCP tool'], - ['', 'and route requests directly to the resolved CCS provider path.'], - ['', 'If runtime/auth/proxy is unavailable, CCS falls back to native Read.'], - ], - writeLine - ); - - printSubSection( - 'Official Channels (official Claude plugins)', - [ - ['ccs config', 'Dashboard -> Settings -> Channels (fastest path)'], - ['ccs config channels', 'Show current status'], - [ - 'ccs config channels --set telegram,discord', - 'Auto-add selected channels on native Claude default/account sessions', - ], - ['ccs config channels --set all', 'Enable Telegram, Discord, and iMessage'], - ['ccs config channels --unattended', 'Also add --dangerously-skip-permissions'], - ['ccs config channels --set-token telegram=', 'Save TELEGRAM_BOT_TOKEN'], - ['ccs config channels --set-token discord=', 'Save DISCORD_BOT_TOKEN'], - ['ccs config channels --clear-token [channel]', 'Remove one or all saved channel tokens'], - ['', ''], - ['', 'Fastest path: turn on the channel, save the token if needed, then run ccs.'], - ['Note:', getOfficialChannelsSupportMessage()], - ['', 'Telegram/Discord tokens live in ~/.claude/channels//.env.'], - ['', 'Current-process TELEGRAM_BOT_TOKEN / DISCORD_BOT_TOKEN also work for that launch.'], - ['', 'iMessage is macOS-only and requires local OS permissions instead of a bot token.'], - ], - writeLine - ); - - // 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'], - ['CCS_THINKING', 'Override thinking level (flag > env > config)'], - ], - writeLine - ); - - // CLI Proxy env vars - printSubSection( - 'CLI Proxy Environment Variables', - [ - ['CCS_PROXY_HOST', 'Remote proxy hostname'], - ['CCS_PROXY_PORT', 'Proxy port'], - ['CCS_PROXY_PROTOCOL', 'Protocol (http/https)'], - ['CCS_PROXY_AUTH_TOKEN', 'Auth token'], - ['CCS_PROXY_TIMEOUT', 'Connection timeout in ms'], - ['CCS_PROXY_FALLBACK_ENABLED', 'Enable local fallback (1/0)'], - ['CCS_ALLOW_SELF_SIGNED', 'Allow self-signed certs (1/0)'], - ], - writeLine - ); - - // CLI Proxy paths - writeLine(subheader('CLI Proxy:')); - writeLine(` Binary: ${color(`${dirDisplay}/cliproxy/bin/cli-proxy-api-plus`, 'path')}`); - writeLine(` Config: ${color(`${dirDisplay}/cliproxy/config.yaml`, 'path')}`); - writeLine(` Auth: ${color(`${dirDisplay}/cliproxy/auth/`, 'path')}`); - writeLine(` ${dim(`Port: ${CLIPROXY_DEFAULT_PORT} (default)`)}`); - writeLine(''); - - // Shared Data - writeLine(subheader('Shared Data:')); - writeLine(` Commands: ${color(`${dirDisplay}/shared/commands/`, 'path')}`); - writeLine(` Skills: ${color(`${dirDisplay}/shared/skills/`, 'path')}`); - writeLine(` Agents: ${color(`${dirDisplay}/shared/agents/`, 'path')}`); - writeLine(` ${dim('Note: Symlinked across all profiles')}`); - writeLine(''); - - // Examples (aligned with consistent spacing) - writeLine(subheader('Examples:')); - writeLine(` $ ${color('ccs', 'command')} ${dim('# Use default account')}`); - writeLine( - ` $ ${color('ccs gemini', 'command')} ${dim('# OAuth (browser opens first time)')}` - ); - writeLine(` $ ${color('ccs glm "implement API"', 'command')} ${dim('# API key model')}`); - writeLine(` $ ${color('ccs config', 'command')} ${dim('# Open web dashboard')}`); - writeLine(''); - - // Update examples - writeLine(subheader('Update:')); - writeLine( - ` $ ${color('ccs update', 'command')} ${dim('# Update to latest stable')}` - ); - writeLine( - ` $ ${color('ccs update --force', 'command')} ${dim('# Force reinstall current')}` - ); - writeLine(` $ ${color('ccs update --beta', 'command')} ${dim('# Install dev channel')}`); - writeLine(''); - - // Docs link - writeLine(` ${dim('Docs: https://github.com/kaitranntt/ccs')}`); - writeLine(''); - - // Uninstall - writeLine(subheader('Uninstall:')); - writeLine(` ${color('npm uninstall -g @kaitranntt/ccs', 'command')}`); - writeLine(''); - - // License - writeLine(dim('License: MIT')); + writeLine(` ${dim('Flags: -h/--help, -v/--version, -sc/--shell-completion, --target ')}`); writeLine(''); } + +function listHelpTargets(): string { + return ROOT_HELP_TOPICS.map((topic) => topic.name).join(', '); +} + +export async function handleHelpRoute( + args: string[], + writeLine: HelpWriter = console.log +): Promise { + const topic = args[0] as HelpTopicName | undefined; + if (!topic) { + await handleHelpCommand(writeLine); + return; + } + + if (topic === 'profiles') { + await showProfilesHelp(writeLine); + return; + } + if (topic === 'providers') { + await showProvidersHelp(writeLine); + return; + } + if (topic === 'targets') { + await showTargetsHelp(writeLine); + return; + } + if (topic === 'completion') { + const { showShellCompletionHelp } = await import('./shell-completion-command'); + showShellCompletionHelp(writeLine); + return; + } + + const commandHandlers: Partial Promise>> = { + api: async () => (await import('./api-command/help')).showApiCommandHelp(writeLine), + auth: async () => { + const authModule = await import('../auth/auth-commands'); + const AuthCommands = authModule.default; + await new AuthCommands().showHelp(); + }, + cleanup: async () => (await import('./cleanup-command')).handleCleanupCommand(['--help']), + cliproxy: async () => (await import('./cliproxy/help-subcommand')).showHelp(), + copilot: async () => + process.exit(await (await import('./copilot-command')).handleCopilotCommand(['--help'])), + cursor: async () => + process.exit(await (await import('./cursor-command')).handleCursorCommand(['--help'])), + docker: async () => (await import('./docker/help-subcommand')).showHelp(), + migrate: async () => (await import('./migrate-command')).printMigrateHelp(), + setup: async () => (await import('./setup-command')).handleSetupCommand(['--help']), + tokens: async () => + process.exit(await (await import('./tokens-command')).handleTokensCommand(['--help'])), + }; + + const handler = commandHandlers[topic]; + if (handler) { + await handler(); + return; + } + + await initUI(); + writeLine(color(`Unknown help topic or command: ${topic}`, 'error')); + writeLine(''); + writeLine(` ${dim(`Available help topics: ${listHelpTargets()}`)}`); + writeLine(''); + process.exitCode = 1; +} + +export function getRootHelpVisibleCommands(): string[] { + return getPublicRootCommands().map((entry) => entry.name); +} + +export function getRootHelpCatalogEntries(): readonly RootCommandEntry[] { + return ROOT_COMMAND_CATALOG; +} diff --git a/src/commands/root-command-router.ts b/src/commands/root-command-router.ts index 450dec5b..be45acc7 100644 --- a/src/commands/root-command-router.ts +++ b/src/commands/root-command-router.ts @@ -16,7 +16,7 @@ async function printUpdateCommandHelp(): Promise { console.log(''); } -const ROOT_COMMAND_ROUTES: readonly NamedCommandRoute[] = [ +export const ROOT_COMMAND_ROUTES: readonly NamedCommandRoute[] = [ { name: 'migrate', aliases: ['--migrate'], @@ -55,9 +55,9 @@ const ROOT_COMMAND_ROUTES: readonly NamedCommandRoute[] = [ { name: 'help', aliases: ['--help', '-h'], - handle: async () => { - const { handleHelpCommand } = await import('./help-command'); - await handleHelpCommand(); + handle: async (args) => { + const { handleHelpRoute } = await import('./help-command'); + await handleHelpRoute(args); }, }, { @@ -82,6 +82,13 @@ const ROOT_COMMAND_ROUTES: readonly NamedCommandRoute[] = [ await handleShellCompletionCommand(args); }, }, + { + name: '__complete', + handle: async (args) => { + const { handleCompletionCommand } = await import('./completion-backend'); + await handleCompletionCommand(args); + }, + }, { name: 'doctor', aliases: ['--doctor'], diff --git a/src/commands/shell-completion-command.ts b/src/commands/shell-completion-command.ts index 65fe4c58..310789f0 100644 --- a/src/commands/shell-completion-command.ts +++ b/src/commands/shell-completion-command.ts @@ -28,6 +28,24 @@ interface ShellCompletionInstallerLike { install(shell: ShellTarget, options: { force: boolean }): ShellCompletionInstallResult; } +export function showShellCompletionHelp(writeLine: (line: string) => void = console.log): void { + writeLine(header('Shell Completion')); + writeLine(''); + writeLine(color('Usage:', 'info')); + writeLine(' ccs --shell-completion # Auto-detect shell and install'); + writeLine(' ccs --shell-completion --bash # Install for bash'); + writeLine(' ccs --shell-completion --zsh # Install for zsh'); + writeLine(' ccs --shell-completion --fish # Install for fish'); + writeLine(' ccs --shell-completion --powershell # Install for PowerShell'); + writeLine(' ccs --shell-completion --force # Reinstall/refresh the active shell setup'); + writeLine(''); + writeLine(color('Test:', 'info')); + writeLine(' ccs '); + writeLine(' ccs help '); + writeLine(' ccs auth '); + writeLine(''); +} + export function parseShellCompletionArgs(args: string[]): ShellCompletionParsedArgs { let targetShell: ShellTarget = null; const force = args.includes('--force') || args.includes('-f'); @@ -77,12 +95,15 @@ export function createShellCompletionCommandContract( */ export async function handleShellCompletionCommand(args: string[]): Promise { await initUI(); - const { ShellCompletionInstaller } = await import('../utils/shell-completion'); - - console.log(header('Shell Completion Installer')); - console.log(''); + if (args.includes('--help') || args.includes('-h')) { + showShellCompletionHelp(); + return; + } try { + const { ShellCompletionInstaller } = await import('../utils/shell-completion'); + console.log(header('Shell Completion Installer')); + console.log(''); const installer = new ShellCompletionInstaller(); const contract = createShellCompletionCommandContract(installer); await runCommandWithContract(args, contract); @@ -90,13 +111,7 @@ export async function handleShellCompletionCommand(args: string[]): Promise { + test('covers every routed root command and alias', () => { + const catalogTokens = new Set(getAllRootCommandTokens()); + + for (const route of ROOT_COMMAND_ROUTES) { + expect(catalogTokens.has(route.name)).toBe(true); + for (const alias of route.aliases || []) { + expect(catalogTokens.has(alias)).toBe(true); + } + } + }); + + test('keeps hidden operational hooks out of the public help surface', () => { + const hiddenCommands = ROOT_COMMAND_CATALOG.filter((entry) => entry.visibility === 'hidden').map( + (entry) => entry.name + ); + + expect(hiddenCommands).toContain('--install'); + expect(hiddenCommands).toContain('--uninstall'); + expect(hiddenCommands).toContain('__complete'); + }); +}); diff --git a/tests/unit/commands/completion-backend.test.ts b/tests/unit/commands/completion-backend.test.ts new file mode 100644 index 00000000..a864c290 --- /dev/null +++ b/tests/unit/commands/completion-backend.test.ts @@ -0,0 +1,132 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +import { getCompletionSuggestions } from '../../../src/commands/completion-backend'; + +const originalHome = process.env.HOME; +const originalUserProfile = process.env.USERPROFILE; +const originalCcsHome = process.env.CCS_HOME; +let tempHome = ''; + +function suggestionValues( + tokensBeforeCurrent: string[], + current = '', + shell: 'bash' | 'fish' | 'powershell' | 'zsh' = 'bash' +): string[] { + return getCompletionSuggestions({ tokensBeforeCurrent, current, shell }).map((entry) => entry.value); +} + +beforeEach(() => { + tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-completion-')); + fs.mkdirSync(path.join(tempHome, '.ccs'), { recursive: true }); + fs.writeFileSync( + path.join(tempHome, '.ccs', 'config.json'), + JSON.stringify( + { + profiles: { + localglm: '~/.ccs/localglm.settings.json', + }, + cliproxy: { + 'my-codex': { + provider: 'codex', + settings: '~/.ccs/my-codex.settings.json', + }, + }, + }, + null, + 2 + ) + ); + fs.writeFileSync( + path.join(tempHome, '.ccs', 'profiles.json'), + JSON.stringify( + { + profiles: { + work: { type: 'account', created: '2026-04-02T00:00:00.000Z' }, + }, + default: 'work', + }, + null, + 2 + ) + ); + + process.env.HOME = tempHome; + process.env.USERPROFILE = tempHome; + process.env.CCS_HOME = tempHome; +}); + +afterEach(() => { + if (originalHome === undefined) delete process.env.HOME; + else process.env.HOME = originalHome; + if (originalUserProfile === undefined) delete process.env.USERPROFILE; + else process.env.USERPROFILE = originalUserProfile; + if (originalCcsHome === undefined) delete process.env.CCS_HOME; + else process.env.CCS_HOME = originalCcsHome; + + fs.rmSync(tempHome, { recursive: true, force: true }); +}); + +describe('completion backend', () => { + test('includes current root commands and dynamic profiles at the top level', () => { + const values = suggestionValues([]); + + expect(values).toContain('config'); + expect(values).toContain('docker'); + expect(values).toContain('tokens'); + expect(values).toContain('migrate'); + expect(values).toContain('cursor'); + expect(values).toContain('copilot'); + expect(values).toContain('gemini'); + expect(values).toContain('localglm'); + expect(values).toContain('work'); + expect(values).toContain('my-codex'); + expect(values).not.toContain('__complete'); + expect(values).not.toContain('--install'); + expect(values).not.toContain('--uninstall'); + }); + + test('suggests help topics from the help command', () => { + const values = suggestionValues(['help']); + expect(values).toContain('profiles'); + expect(values).toContain('providers'); + expect(values).toContain('completion'); + expect(values).toContain('targets'); + expect(values).not.toContain('api'); + expect(values).not.toContain('__complete'); + }); + + test('suggests lifecycle subcommands for api', () => { + const values = suggestionValues(['api']); + expect(values).toEqual(expect.arrayContaining(['create', 'discover', 'copy', 'export', 'import', 'remove'])); + }); + + test('suggests account profiles for auth show', () => { + const values = suggestionValues(['auth', 'show']); + expect(values).toContain('work'); + expect(values).toContain('--json'); + }); + + test('suggests cliproxy variants for variant-scoped commands', () => { + const values = suggestionValues(['cliproxy', 'edit']); + expect(values).toContain('my-codex'); + }); + + test('suggests env format values after the format flag', () => { + const values = suggestionValues(['env', '--format']); + expect(values).toEqual(expect.arrayContaining(['openai', 'anthropic', 'raw', 'claude-extension'])); + }); + + test('includes live doctor and cliproxy flags from the shared catalog', () => { + expect(suggestionValues(['doctor'])).toEqual(expect.arrayContaining(['--fix', '-f'])); + expect(suggestionValues(['cliproxy'])).toEqual(expect.arrayContaining(['remove', '--backend'])); + }); + + test('filters suggestions by the current token prefix', () => { + const values = suggestionValues([], 'do'); + expect(values).toEqual(expect.arrayContaining(['docker', 'doctor'])); + expect(values).not.toContain('tokens'); + }); +}); diff --git a/tests/unit/commands/help-command-parity.test.ts b/tests/unit/commands/help-command-parity.test.ts index 7ad072ae..bf830826 100644 --- a/tests/unit/commands/help-command-parity.test.ts +++ b/tests/unit/commands/help-command-parity.test.ts @@ -1,152 +1,77 @@ import { describe, expect, test } from 'bun:test'; -import { showApiCommandHelp } from '../../../src/commands/api-command/help'; -import { handleHelpCommand } from '../../../src/commands/help-command'; +import { + handleHelpCommand, + handleHelpRoute, + getRootHelpVisibleCommands, +} from '../../../src/commands/help-command'; function stripAnsi(input: string): string { return input.replace(/\u001b\[[0-9;]*m/g, ''); } +async function renderLines( + render: (writeLine: (line: string) => void) => Promise +): Promise { + const lines: string[] = []; + await render((line) => lines.push(line)); + return stripAnsi(lines.join('\n')); +} + describe('help command parity', () => { - test('root help documents cliproxy provider filter under quota command', async () => { - const lines: string[] = []; - await handleHelpCommand((line) => lines.push(line)); + test('root help stays within the compact line budget', async () => { + const rendered = await renderLines((writeLine) => handleHelpCommand(writeLine)); + const visibleLines = rendered.split('\n').filter((line) => line.trim().length > 0); - const rendered = stripAnsi(lines.join('\n')); - expect(rendered.includes('ccs cliproxy status [provider]')).toBe(false); - expect(rendered.includes('ccs cliproxy status')).toBe(true); - expect(rendered.includes('ccs cliproxy quota --provider ')).toBe(true); - expect(rendered.includes('ccs llamacpp')).toBe(true); + expect(visibleLines.length).toBeLessThanOrEqual(90); + expect(rendered.includes('ccs help ')).toBe(true); + expect(rendered.includes('ccs help completion')).toBe(true); }); - test('root help documents llama.cpp as a local API profile', async () => { - const lines: string[] = []; - await handleHelpCommand((line) => lines.push(line)); + test('root help covers every public root command once the catalog is updated', async () => { + const rendered = await renderLines((writeLine) => handleHelpCommand(writeLine)); - const rendered = stripAnsi(lines.join('\n')); - expect(rendered.includes('ccs llamacpp')).toBe(true); - expect(rendered.includes('http://127.0.0.1:8080')).toBe(true); + for (const command of getRootHelpVisibleCommands()) { + expect(rendered.includes(command)).toBe(true); + } }); - test('root help no longer markets glmt as a supported profile', async () => { - const lines: string[] = []; - await handleHelpCommand((line) => lines.push(line)); - - const rendered = stripAnsi(lines.join('\n')); + test('root help no longer markets deprecated glmt directly', async () => { + const rendered = await renderLines((writeLine) => handleHelpCommand(writeLine)); expect(rendered.includes('ccs glmt')).toBe(false); - expect(rendered.includes('ccs glm')).toBe(true); }); - test('root help documents Claude IDE extension setup surfaces', async () => { - const lines: string[] = []; - await handleHelpCommand((line) => lines.push(line)); + test('providers topic lists built-in OAuth provider shortcuts', async () => { + const rendered = await renderLines((writeLine) => handleHelpRoute(['providers'], writeLine)); - const rendered = stripAnsi(lines.join('\n')); - expect(rendered.includes('Claude IDE Extension setup page')).toBe(true); - expect(rendered.includes('ccs env --format claude-extension --ide vscode')).toBe( - true - ); - expect(rendered.includes('ccs env --format claude-extension --ide windsurf')).toBe( - true - ); + expect(rendered.includes('Built-in OAuth Providers')).toBe(true); + expect(rendered.includes('ccs cliproxy --help')).toBe(true); + expect(rendered.includes('gemini')).toBe(true); + expect(rendered.includes('codex')).toBe(true); + expect(rendered.includes('ghcp')).toBe(true); }); - test('root help documents dashboard host binding example', async () => { - const lines: string[] = []; - await handleHelpCommand((line) => lines.push(line)); + test('completion topic documents install and verification paths', async () => { + const rendered = await renderLines((writeLine) => handleHelpRoute(['completion'], writeLine)); - const rendered = stripAnsi(lines.join('\n')); - expect(rendered.includes('ccs config --host 0.0.0.0')).toBe(true); - expect(rendered.includes('Force all-interface binding for remote devices')).toBe(true); + expect(rendered.includes('ccs --shell-completion')).toBe(true); + expect(rendered.includes('ccs help ')).toBe(true); + expect(rendered.includes('--force')).toBe(true); }); - test('root help documents docker deployment commands', async () => { - const lines: string[] = []; - await handleHelpCommand((line) => lines.push(line)); + test('api topic delegates to command-specific help', async () => { + const rendered = await renderLines((writeLine) => handleHelpRoute(['api'], writeLine)); - const rendered = stripAnsi(lines.join('\n')); - expect(rendered.includes('ccs docker --help')).toBe(true); - expect(rendered.includes('ccs docker up --host ')).toBe(true); - expect(rendered.includes('ccs docker update')).toBe(true); - }); - - test('root help documents official channels native-only scope and process-env tokens', async () => { - const lines: string[] = []; - await handleHelpCommand((line) => lines.push(line)); - - const rendered = stripAnsi(lines.join('\n')); - expect(rendered.includes('Dashboard -> Settings -> Channels (fastest path)')).toBe(true); - expect( - rendered.includes( - 'Fastest path: turn on the channel, save the token if needed, then run ccs.' - ) - ).toBe(true); - expect(rendered.includes('ccs codex')).toBe(true); - expect(rendered.includes('ccs --target codex')).toBe(true); - expect( - rendered.includes('Current-process TELEGRAM_BOT_TOKEN / DISCORD_BOT_TOKEN also work') - ).toBe(true); - }); - - test('root help documents bare cursor as the runtime entrypoint', async () => { - const lines: string[] = []; - await handleHelpCommand((line) => lines.push(line)); - - const rendered = stripAnsi(lines.join('\n')); - expect(rendered.includes('ccs cursor ')).toBe(false); - expect(rendered.includes('Run Claude via Cursor local proxy')).toBe(true); - }); - - test('root help explains Claude [1m] as an explicit CCS suffix with upstream limits', async () => { - const lines: string[] = []; - await handleHelpCommand((line) => lines.push(line)); - - const rendered = stripAnsi(lines.join('\n')); - expect( - rendered.includes('Claude models: plain by default, opt-in with --1m or saved [1m]') - ).toBe(true); - expect(rendered.includes('CCS only controls the saved [1m] suffix.')).toBe(true); - expect(rendered.includes('return 429 extra-usage errors for long-context requests')).toBe(true); - }); - - test('root help documents native Codex runtime alias and runtime-only scope', async () => { - const lines: string[] = []; - await handleHelpCommand((line) => lines.push(line)); - - const rendered = stripAnsi(lines.join('\n')); - expect(rendered.includes('ccs-codex [args]')).toBe(true); - expect(rendered.includes('ccsx [args]')).toBe(true); - expect(rendered.includes('ccsxp [args]')).toBe(true); - expect(rendered.includes('ccs --target codex')).toBe(true); - expect(rendered.includes('ccsxp "your prompt"')).toBe(true); - expect(rendered.includes('ccs codex-api --target codex')).toBe(true); - expect(rendered.includes('Compatible -> Codex CLI')).toBe(true); - expect(rendered.includes('CLIPROXY_API_KEY')).toBe(true); - expect(rendered.includes('codex (runtime-only)')).toBe(true); - }); - - test('api help documents create-time Claude [1m] flags and entitlement warning', async () => { - const lines: string[] = []; - await showApiCommandHelp((line) => lines.push(line)); - - const rendered = stripAnsi(lines.join('\n')); - expect(rendered.includes('--1m / --no-1m')).toBe(true); + expect(rendered.includes('CCS API Management')).toBe(true); expect(rendered.includes('ccs api create --preset anthropic --1m')).toBe(true); - expect(rendered.includes('Plain Claude model IDs stay on standard context by default.')).toBe( - true - ); - expect(rendered.includes('some accounts can still return 429 for long-context requests')).toBe( - true - ); + expect(rendered.includes('ccs api discover --register')).toBe(true); }); - test('api help documents Codex bridge runtime launch separately from persisted targets', async () => { - const lines: string[] = []; - await showApiCommandHelp((line) => lines.push(line)); + test('unknown help target shows an actionable fallback', async () => { + const rendered = await renderLines((writeLine) => handleHelpRoute(['unknown-topic'], writeLine)); - const rendered = stripAnsi(lines.join('\n')); - expect(rendered.includes('ccs api create codex-api --cliproxy-provider codex')).toBe(true); - expect(rendered.includes('ccs codex-api --target codex')).toBe(true); - expect(rendered.includes('Default target: claude or droid (create)')).toBe(true); + expect(rendered.includes('Unknown help topic or command: unknown-topic')).toBe(true); + expect(rendered.includes('Available help topics:')).toBe(true); + process.exitCode = 0; }); }); diff --git a/tests/unit/commands/root-command-router.test.ts b/tests/unit/commands/root-command-router.test.ts index 7e32630e..b5250d39 100644 --- a/tests/unit/commands/root-command-router.test.ts +++ b/tests/unit/commands/root-command-router.test.ts @@ -27,7 +27,7 @@ beforeEach(() => { }, })); - mock.module('../../../src/commands/api-command', () => ({ + mock.module('../../../src/commands/api-command/index', () => ({ handleApiCommand: async (args: string[]) => { calls.push(`api:${args.join(' ')}`); }, @@ -42,6 +42,12 @@ beforeEach(() => { mock.module('../../../src/commands/tokens-command', () => ({ handleTokensCommand: async () => 37, })); + + mock.module('../../../src/commands/completion-backend', () => ({ + handleCompletionCommand: async (args: string[]) => { + calls.push(`complete:${args.join(' ')}`); + }, + })); }); afterEach(() => { @@ -120,4 +126,12 @@ describe('root-command-router', () => { await expect(tryHandleRootCommand(['tokens', 'list'])).rejects.toThrow('process.exit(37)'); }); + + it('routes hidden completion queries through the completion backend', async () => { + const tryHandleRootCommand = await loadTryHandleRootCommand(); + + await expect(tryHandleRootCommand(['__complete', '--shell', 'bash', '--current', 'do'])).resolves.toBe(true); + + expect(calls).toEqual(['complete:--shell bash --current do']); + }); });