diff --git a/docs/code-standards.md b/docs/code-standards.md index 9872e1c0..f2de6704 100644 --- a/docs/code-standards.md +++ b/docs/code-standards.md @@ -740,4 +740,10 @@ These code standards ensure the CCS codebase remains: - **Subsystem organization**: Clear separation (auth, delegation, glmt, management, utils) - **Naming conventions**: handler, executor, manager, validator, formatter, parser suffixes +**v4.5.0 Installation Standards**: +- **Bootstrap-based installers**: Native shell wrappers delegate to Node.js via npx +- **Node.js requirement**: 14+ required (checked during install) +- **No shell dependencies**: error-codes.sh, progress-indicator.sh, prompt.sh removed +- **First-run bootstrap**: Auto-installs @kaitranntt/ccs npm package globally + Following these standards helps maintain the quality, modularity, and extensibility of the v4.x architecture while enabling future development with AI delegation, shared data management, and comprehensive diagnostics. \ No newline at end of file diff --git a/docs/codebase-summary.md b/docs/codebase-summary.md index 975c43fc..f731fd04 100644 --- a/docs/codebase-summary.md +++ b/docs/codebase-summary.md @@ -1,21 +1,23 @@ -# CCS Codebase Summary (v4.3.2) +# CCS Codebase Summary (v4.5.0) ## Overview -CCS (Claude Code Switch) v4.3.2 is a lightweight CLI wrapper enabling instant profile switching between Claude Sonnet 4.5, GLM 4.6, GLMT (GLM with Thinking), and Kimi for Coding models. Version 4.x introduces AI-powered delegation, selective .claude/ directory symlinking, stream-JSON output, and enhanced shell completion. +CCS (Claude Code Switch) v4.5.0 is a lightweight CLI wrapper enabling instant profile switching between Claude Sonnet 4.5, GLM 4.6, GLMT (GLM with Thinking), and Kimi for Coding models. Version 4.x introduces AI-powered delegation, selective .claude/ directory symlinking, stream-JSON output, and enhanced shell completion. v4.5.0 completes transition to Node.js-first architecture with bootstrap-based installers. ## Version Evolution -### v4.3.2 Architecture (Current) +### v4.5.0 Architecture (Current) - **Total LOC**: ~8,477 lines (JavaScript only) - **Key Features**: AI delegation, stream-JSON output, shell completion, doctor diagnostics, sync command - **New Components**: delegation/, utils/claude-symlink-manager.js, utils/delegation-validator.js, utils/update-checker.js - **Architecture**: Modular design with clear separation: auth/, delegation/, glmt/, management/, utils/ +- **Installation**: Bootstrap-based native installers (requires Node.js 14+, no shell dependencies) ### Evolution Summary - **v2.x**: Vault-based credential encryption (~1,700 LOC) - **v3.0**: Vault removal, login-per-profile (~1,100 LOC, 40% reduction) -- **v4.0-4.3.2**: Delegation system, .claude/ sharing, stream-JSON (~8,477 LOC including tests/utils) +- **v4.0-4.4.x**: Delegation system, .claude/ sharing, stream-JSON (~8,477 LOC including tests/utils) +- **v4.5.0**: Bootstrap-based installers, TypeScript npm package with quality gates ## Core Components (v4.3.2) diff --git a/docs/system-architecture.md b/docs/system-architecture.md index 8af40f00..435ea958 100644 --- a/docs/system-architecture.md +++ b/docs/system-architecture.md @@ -2,7 +2,7 @@ ## Overview -CCS (Claude Code Switch) is a lightweight CLI wrapper that provides instant profile switching between Claude Sonnet 4.5, GLM 4.6, GLMT (GLM with Thinking), and Kimi for Coding models. Current version **v4.3.2** features AI-powered delegation system, selective .claude/ directory symlinking, stream-JSON output, shell completion (4 shells), and comprehensive diagnostics (doctor, sync, update commands). +CCS (Claude Code Switch) is a lightweight CLI wrapper that provides instant profile switching between Claude Sonnet 4.5, GLM 4.6, GLMT (GLM with Thinking), and Kimi for Coding models. Current version **v4.5.0** features AI-powered delegation system, selective .claude/ directory symlinking, stream-JSON output, shell completion (4 shells), and comprehensive diagnostics (doctor, sync, update commands). v4.5.0 completes transition to Node.js-first architecture with bootstrap-based installers. ## Core Architecture Principles @@ -997,6 +997,9 @@ graph LR ### Installation Process +**Two installation methods available:** + +**Method 1: npm Package (Recommended)** 1. **Package Download**: User installs via npm/yarn/pnpm/bun 2. **Post-install Script** (`scripts/postinstall.js`): - Creates `~/.ccs/` directory structure @@ -1013,6 +1016,19 @@ graph LR 3. **Path Configuration**: npm automatically adds to PATH 4. **Ready State**: System ready for profile switching +**Method 2: Native Installers (v4.5.0)** +1. **Requirements Check**: Validates Node.js 14+ available +2. **Bootstrap Installation**: + - Installs lightweight shell wrappers (lib/ccs, lib/ccs.ps1) + - Shell wrappers delegate to Node.js via npx on first run + - No shell dependencies (error-codes, progress-indicator, prompt removed) +3. **First Run**: Bootstrap auto-installs @kaitranntt/ccs npm package globally +4. **Completion Message**: Shows requirements section with Node.js version + +**Requirements (v4.5.0+)**: +- Node.js 14+ (checked during install, enforced by bootstrap) +- npm 5.2+ (for npx, comes with Node.js 8.2+) + **Idempotency**: Safe to run multiple times, preserves existing configs **Migration Logic** (v3.3.0): diff --git a/installers/install.ps1 b/installers/install.ps1 index 7362b703..5e030924 100644 --- a/installers/install.ps1 +++ b/installers/install.ps1 @@ -1,4 +1,6 @@ -# CCS Installation Script (Windows PowerShell) +# CCS Installation Script (v4.5.0) - Windows PowerShell +# Bootstrap-based: Installs lightweight shell wrappers that delegate to Node.js +# Requires: Node.js 14+ (checked during install, enforced by bootstrap) # https://github.com/kaitranntt/ccs param( @@ -85,6 +87,42 @@ function Write-Section { Write-Host "" } +# --- Node.js Detection (v4.5) --- +function Test-NodeJs { + $MIN_VERSION = 14 + + if (-not (Get-Command node -ErrorAction SilentlyContinue)) { + Write-WarningMsg @" +Node.js not found + +CCS v4.5+ requires Node.js 14+ to run. +The bootstrap scripts will check and install the npm package on first use. + +Install Node.js: https://nodejs.org (LTS recommended) + +Installation will continue, but 'ccs' will not work until Node.js is installed. +"@ + return $false + } + + $nodeVersion = (node -v) -replace 'v', '' + $nodeMajor = [int]($nodeVersion -split '\.')[0] + if ($nodeMajor -lt $MIN_VERSION) { + Write-WarningMsg @" +Node.js 14+ required (found: $(node -v)) + +CCS v4.5+ requires Node.js 14 or newer. +Upgrade from: https://nodejs.org + +Installation will continue, but 'ccs' may not work correctly. +"@ + return $false + } + + Write-Success "Node.js $(node -v) detected" + return $true +} + # Helper Functions function Detect-CurrentProvider { @@ -261,6 +299,9 @@ function Install-ClaudeFolder { # Main Installation +# Check Node.js requirement (warn if missing, continue anyway) +$null = Test-NodeJs + Write-Host '===== Installing CCS (Windows) =====' # Create directories @@ -276,32 +317,8 @@ if ($InstallMethod -eq "standalone") { Invoke-WebRequest -Uri "$BaseUrl/lib/ccs.ps1" -OutFile "$CcsDir\ccs.ps1" -UseBasicParsing Write-Host "| [OK] Downloaded ccs.ps1" - # Download required dependencies - $LibDir = "$CcsDir\lib" - if (-not (Test-Path $LibDir)) { - New-Item -ItemType Directory -Path $LibDir -Force | Out-Null - } - - try { - Invoke-WebRequest -Uri "$BaseUrl/lib/error-codes.ps1" -OutFile "$LibDir\error-codes.ps1" -UseBasicParsing - Write-Host "| [OK] Downloaded error-codes.ps1" - } catch { - Write-Host "| [!] Warning: Failed to download error-codes.ps1" - } - - try { - Invoke-WebRequest -Uri "$BaseUrl/lib/progress-indicator.ps1" -OutFile "$LibDir\progress-indicator.ps1" -UseBasicParsing - Write-Host "| [OK] Downloaded progress-indicator.ps1" - } catch { - Write-Host "| [!] Warning: Failed to download progress-indicator.ps1" - } - - try { - Invoke-WebRequest -Uri "$BaseUrl/lib/prompt.ps1" -OutFile "$LibDir\prompt.ps1" -UseBasicParsing - Write-Host "| [OK] Downloaded prompt.ps1" - } catch { - Write-Host "| [!] Warning: Failed to download prompt.ps1" - } + # Note: Shell dependencies (error-codes.ps1, progress-indicator.ps1, prompt.ps1) no longer needed + # Bootstrap delegates all functionality to Node.js via npx # Download shell completion files $CompletionsDir = "$CcsDir\completions" @@ -333,36 +350,8 @@ if ($InstallMethod -eq "standalone") { Copy-Item $CcsPs1Path "$CcsDir\ccs.ps1" -Force Write-Host "| [OK] Installed ccs.ps1" - # Copy required dependencies - $LibDir = "$CcsDir\lib" - if (-not (Test-Path $LibDir)) { - New-Item -ItemType Directory -Path $LibDir -Force | Out-Null - } - - $SourceLibDir = if (Test-Path "$ScriptDir\lib") { - "$ScriptDir\lib" - } elseif (Test-Path "$ScriptDir\..\lib") { - "$ScriptDir\..\lib" - } else { - $null - } - - if ($SourceLibDir) { - if (Test-Path "$SourceLibDir\error-codes.ps1") { - Copy-Item "$SourceLibDir\error-codes.ps1" "$LibDir\error-codes.ps1" -Force - Write-Host "| [OK] Copied error-codes.ps1" - } - - if (Test-Path "$SourceLibDir\progress-indicator.ps1") { - Copy-Item "$SourceLibDir\progress-indicator.ps1" "$LibDir\progress-indicator.ps1" -Force - Write-Host "| [OK] Copied progress-indicator.ps1" - } - - if (Test-Path "$SourceLibDir\prompt.ps1") { - Copy-Item "$SourceLibDir\prompt.ps1" "$LibDir\prompt.ps1" -Force - Write-Host "| [OK] Copied prompt.ps1" - } - } + # Note: Shell dependencies (error-codes.ps1, progress-indicator.ps1, prompt.ps1) no longer needed + # Bootstrap delegates all functionality to Node.js via npx # Copy shell completion files $CompletionsDir = "$CcsDir\completions" @@ -751,11 +740,19 @@ Write-Host " * glm profile -> $CcsDir\glm.settings.json" Write-Host " * kimi profile -> $CcsDir\kimi.settings.json" Write-Host " * .claude/ folder -> $CcsDir\.claude\" Write-Host "" +Write-Host " Requirements:" +$nodeVer = if (Get-Command node -ErrorAction SilentlyContinue) { node -v } else { "NOT FOUND" } +Write-Host " * Node.js 14+ (detected: $nodeVer)" +Write-Host " * npm 5.2+ (for npx, comes with Node.js 8.2+)" +Write-Host "" +Write-Host " First Run:" +Write-Host " The first time you run 'ccs', it will automatically install" +Write-Host " the @kaitranntt/ccs npm package globally via npx." +Write-Host "" Write-Host " Quick start:" Write-Host " ccs # Use Claude subscription (default)" Write-Host " ccs glm # Use GLM fallback" Write-Host " ccs kimi # Use Kimi for Coding" Write-Host "" -Write-Host "" Write-Host " To uninstall: ccs-uninstall" Write-Host "" diff --git a/installers/install.sh b/installers/install.sh index 86362fbb..5c73513d 100755 --- a/installers/install.sh +++ b/installers/install.sh @@ -2,7 +2,9 @@ set -euo pipefail # ============================================================================ -# CCS Installation Script +# CCS Installation Script (v4.5.0) +# Bootstrap-based: Installs lightweight shell wrappers that delegate to Node.js +# Requires: Node.js 14+ (checked during install, enforced by bootstrap) # ============================================================================ # --- Configuration --- @@ -139,6 +141,36 @@ msg_section() { setup_colors +# --- Node.js Detection (v4.5) --- +check_nodejs() { + if ! command -v node &> /dev/null; then + msg_warning "Node.js not found + + CCS v4.5+ requires Node.js 14+ to run. + The bootstrap scripts will check and install the npm package on first use. + + Install Node.js: https://nodejs.org (LTS recommended) + + Installation will continue, but 'ccs' will not work until Node.js is installed." + return 1 + fi + + local node_major + node_major=$(node -v | cut -d'v' -f2 | cut -d'.' -f1) + if [[ $node_major -lt 14 ]]; then + msg_warning "Node.js 14+ required (found: $(node -v)) + + CCS v4.5+ requires Node.js 14 or newer. + Upgrade from: https://nodejs.org + + Installation will continue, but 'ccs' may not work correctly." + return 1 + fi + + msg_success "Node.js $(node -v) detected" + return 0 +} + # --- Shell Profile Management --- detect_shell_profile() { @@ -456,6 +488,9 @@ create_kimi_profile() { # --- Main Installation --- +# Check Node.js requirement (warn if missing, continue anyway) +check_nodejs || true + echo "┌─ Installing CCS" # Create directories @@ -482,25 +517,8 @@ if [[ "$INSTALL_METHOD" == "standalone" ]]; then exit 1 fi - # Download required dependencies - mkdir -p "$CCS_DIR/lib" - if curl -fsSL "$BASE_URL/lib/error-codes.sh" -o "$CCS_DIR/lib/error-codes.sh" 2>/dev/null; then - echo "| [OK] Downloaded error-codes.sh" - else - echo "| [!] Warning: Failed to download error-codes.sh" - fi - - if curl -fsSL "$BASE_URL/lib/progress-indicator.sh" -o "$CCS_DIR/lib/progress-indicator.sh" 2>/dev/null; then - echo "| [OK] Downloaded progress-indicator.sh" - else - echo "| [!] Warning: Failed to download progress-indicator.sh" - fi - - if curl -fsSL "$BASE_URL/lib/prompt.sh" -o "$CCS_DIR/lib/prompt.sh" 2>/dev/null; then - echo "| [OK] Downloaded prompt.sh" - else - echo "| [!] Warning: Failed to download prompt.sh" - fi + # Note: Shell dependencies (error-codes.sh, progress-indicator.sh, prompt.sh) no longer needed + # Bootstrap delegates all functionality to Node.js via npx # Download shell completion files mkdir -p "$CCS_DIR/completions" @@ -528,22 +546,8 @@ else fi echo "| [OK] Installed executable" - # Copy required dependencies - mkdir -p "$CCS_DIR/lib" - if [[ -f "$LIB_DIR/error-codes.sh" ]]; then - cp "$LIB_DIR/error-codes.sh" "$CCS_DIR/lib/error-codes.sh" - echo "| [OK] Copied error-codes.sh" - fi - - if [[ -f "$LIB_DIR/progress-indicator.sh" ]]; then - cp "$LIB_DIR/progress-indicator.sh" "$CCS_DIR/lib/progress-indicator.sh" - echo "| [OK] Copied progress-indicator.sh" - fi - - if [[ -f "$LIB_DIR/prompt.sh" ]]; then - cp "$LIB_DIR/prompt.sh" "$CCS_DIR/lib/prompt.sh" - echo "| [OK] Copied prompt.sh" - fi + # Note: Shell dependencies (error-codes.sh, progress-indicator.sh, prompt.sh) no longer needed + # Bootstrap delegates all functionality to Node.js via npx # Copy shell completion files mkdir -p "$CCS_DIR/completions" @@ -856,11 +860,18 @@ echo " * glm profile -> ~/.ccs/glm.settings.json" echo " * kimi profile -> ~/.ccs/kimi.settings.json" echo " * .claude/ folder -> ~/.ccs/.claude/" echo "" +echo " Requirements:" +echo " * Node.js 14+ (detected: $(node -v 2>/dev/null || echo 'NOT FOUND'))" +echo " * npm 5.2+ (for npx, comes with Node.js 8.2+)" +echo "" +echo " First Run:" +echo " The first time you run 'ccs', it will automatically install" +echo " the @kaitranntt/ccs npm package globally via npx." +echo "" echo " Quick start:" echo " ccs # Use Claude subscription (default)" echo " ccs glm # Use GLM fallback" echo " ccs kimi # Use Kimi for Coding" echo "" -echo "" echo " To uninstall: ccs-uninstall" echo "" diff --git a/lib/ccs b/lib/ccs index b5b45152..b5c2351a 100755 --- a/lib/ccs +++ b/lib/ccs @@ -1,1918 +1,32 @@ #!/usr/bin/env bash +# CCS - Claude Code Switch (Bootstrap) +# Delegates to Node.js implementation via npx +# https://github.com/kaitranntt/ccs set -euo pipefail -# Version (updated by scripts/bump-version.sh) -CCS_VERSION="4.5.0" -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -readonly CONFIG_FILE="${CCS_CONFIG:-$HOME/.ccs/config.json}" -readonly PROFILES_JSON="$HOME/.ccs/profiles.json" -readonly INSTANCES_DIR="$HOME/.ccs/instances" +readonly PACKAGE="@kaitranntt/ccs" +readonly MIN_NODE_VERSION=14 -# Determine dependency location (git vs installed) -# Git: lib/ccs and dependencies are in same dir (lib/) -# Installed: lib/ccs is symlinked from ~/.local/bin/ccs, dependencies in ~/.ccs/lib/ -if [[ -f "$SCRIPT_DIR/error-codes.sh" ]]; then - # Git install - files in same directory - DEP_DIR="$SCRIPT_DIR" -else - # Standalone install - files in ~/.ccs/lib/ - DEP_DIR="$HOME/.ccs/lib" +# Check Node.js installed +if ! command -v node &>/dev/null; then + echo "[X] Node.js not found" >&2 + echo " Install: https://nodejs.org (LTS recommended)" >&2 + exit 127 fi -# Source error codes -source "$DEP_DIR/error-codes.sh" - -# Source progress indicators -source "$DEP_DIR/progress-indicator.sh" - -# Source interactive prompts -source "$DEP_DIR/prompt.sh" - -# --- Color/Format Functions --- -setup_colors() { - # Enable colors if: FORCE_COLOR set OR (TTY detected AND NO_COLOR not set) OR (TERM supports colors AND NO_COLOR not set) - if [[ -n "${FORCE_COLOR:-}" ]] || \ - ([[ -t 1 || -t 2 ]] && [[ -z "${NO_COLOR:-}" ]]) || \ - ([[ -n "${TERM:-}" && "${TERM}" != "dumb" ]] && [[ -z "${NO_COLOR:-}" ]]); then - RED='\033[0;31m' - GREEN='\033[0;32m' - YELLOW='\033[1;33m' - CYAN='\033[0;36m' - BOLD='\033[1m' - RESET='\033[0m' - else - RED='' GREEN='' YELLOW='' CYAN='' BOLD='' RESET='' - fi -} - -msg_error() { - echo "" >&2 - echo -e "${RED}${BOLD}=============================================${RESET}" >&2 - echo -e "${RED}${BOLD} ERROR${RESET}" >&2 - echo -e "${RED}${BOLD}=============================================${RESET}" >&2 - echo "" >&2 - echo -e "${RED}$1${RESET}" >&2 - echo "" >&2 -} - -# Enhanced error message with error codes -show_enhanced_error() { - local error_code="$1" - local short_msg="$2" - local context="${3:-}" - local suggestions="${4:-}" - - echo "" >&2 - echo -e "${RED}[X] $short_msg${RESET}" >&2 - echo "" >&2 - - [[ -n "$context" ]] && { - echo -e "$context" >&2 - echo "" >&2 - } - - [[ -n "$suggestions" ]] && { - echo -e "${YELLOW}Solutions:${RESET}" >&2 - echo -e "$suggestions" >&2 - echo "" >&2 - } - - echo -e "${YELLOW}Error: $error_code${RESET}" >&2 - echo -e "${YELLOW}$(get_error_doc_url "$error_code")${RESET}" >&2 - echo "" >&2 -} - -# Calculate Levenshtein distance between two strings -levenshtein_distance() { - local a="$1" - local b="$2" - local len_a=${#a} - local len_b=${#b} - - # Return early for empty strings - [[ $len_a -eq 0 ]] && { echo "$len_b"; return; } - [[ $len_b -eq 0 ]] && { echo "$len_a"; return; } - - # Initialize matrix using associative array - declare -A matrix - - # Initialize first row and column - for ((j=0; j<=len_a; j++)); do - matrix[0,$j]=$j - done - for ((i=0; i<=len_b; i++)); do - matrix[$i,0]=$i - done - - # Fill matrix - for ((i=1; i<=len_b; i++)); do - for ((j=1; j<=len_a; j++)); do - if [[ "${a:j-1:1}" == "${b:i-1:1}" ]]; then - matrix[$i,$j]=${matrix[$((i-1)),$((j-1))]} - else - local sub=${matrix[$((i-1)),$((j-1))]} - local ins=${matrix[$i,$((j-1))]} - local del=${matrix[$((i-1)),$j]} - local min=$sub - [[ $ins -lt $min ]] && min=$ins - [[ $del -lt $min ]] && min=$del - matrix[$i,$j]=$((min + 1)) - fi - done - done - - echo "${matrix[$len_b,$len_a]}" -} - -# Find similar strings using fuzzy matching -find_similar_strings() { - local target="$1" - shift - local candidates=("$@") - local max_distance=2 - local target_lower="${target,,}" - - declare -A distances - local matches=() - - # Calculate distances - for candidate in "${candidates[@]}"; do - local candidate_lower="${candidate,,}" - local dist=$(levenshtein_distance "$target_lower" "$candidate_lower") - if [[ $dist -le $max_distance && $dist -gt 0 ]]; then - distances["$candidate"]=$dist - matches+=("$candidate") - fi - done - - # Sort by distance (simple bubble sort for small arrays) - for ((i=0; i<${#matches[@]}; i++)); do - for ((j=i+1; j<${#matches[@]}; j++)); do - if [[ ${distances[${matches[i]}]} -gt ${distances[${matches[j]}]} ]]; then - local temp="${matches[i]}" - matches[i]="${matches[j]}" - matches[j]="$temp" - fi - done - done - - # Return first 3 matches - for ((i=0; i<${#matches[@]} && i<3; i++)); do - echo "${matches[i]}" - done -} - -show_help() { - echo -e "${BOLD}CCS (Claude Code Switch) - Instant profile switching for Claude CLI${RESET}" - echo "" - - echo -e "${CYAN}Usage:${RESET}" - echo -e " ${YELLOW}ccs${RESET} [profile] [claude-args...]" - echo -e " ${YELLOW}ccs${RESET} [flags]" - echo "" - - echo -e "${CYAN}Description:${RESET}" - echo -e " Switch between multiple Claude accounts and alternative models" - echo -e " (GLM, Kimi) instantly. Run different Claude CLI sessions concurrently" - echo -e " with auto-recovery. Zero downtime." - echo "" - - echo -e "${CYAN}Model Switching:${RESET}" - echo -e " ${YELLOW}ccs${RESET} Use default Claude account" - echo -e " ${YELLOW}ccs glm${RESET} Switch to GLM 4.6 model" - echo -e " ${YELLOW}ccs glmt${RESET} Switch to GLM with thinking mode" - echo -e " ${YELLOW}ccs glmt --verbose${RESET} Enable debug logging" - echo -e " ${YELLOW}ccs kimi${RESET} Switch to Kimi for Coding" - echo -e " ${YELLOW}ccs glm${RESET} \"debug this code\" Use GLM and run command" - echo "" - - echo -e "${CYAN}Account Management:${RESET}" - echo -e " ${YELLOW}ccs auth --help${RESET} Run multiple Claude accounts concurrently" - echo "" - - echo -e "${CYAN}Delegation (inside Claude Code CLI):${RESET}" - echo -e " ${YELLOW}/ccs \"task\"${RESET} Delegate task (auto-selects best profile)" - echo -e " ${YELLOW}/ccs --glm \"task\"${RESET} Force GLM-4.6 for simple tasks" - echo -e " ${YELLOW}/ccs --kimi \"task\"${RESET} Force Kimi for long context" - echo -e " ${YELLOW}/ccs:continue \"follow-up\"${RESET} Continue last delegation session" - echo -e " Save tokens by delegating simple tasks to cost-optimized models" - echo "" - - echo -e "${CYAN}Diagnostics:${RESET}" - echo -e " ${YELLOW}ccs doctor${RESET} Run health check and diagnostics" - echo -e " ${YELLOW}ccs sync${RESET} Sync delegation commands and skills" - echo -e " ${YELLOW}ccs update${RESET} Update CCS to latest version" - echo "" - - echo -e "${CYAN}Flags:${RESET}" - echo -e " ${YELLOW}-h, --help${RESET} Show this help message" - echo -e " ${YELLOW}-v, --version${RESET} Show version and installation info" - echo -e " ${YELLOW}-sc, --shell-completion${RESET} Install shell auto-completion" - echo "" - - echo -e "${CYAN}Configuration:${RESET}" - echo -e " Config File: ~/.ccs/config.json" - echo -e " Profiles: ~/.ccs/profiles.json" - echo -e " Instances: ~/.ccs/instances/" - echo -e " Settings: ~/.ccs/*.settings.json" - echo -e " Environment: CCS_CONFIG (override config path)" - echo "" - - echo -e "${CYAN}Shared Data:${RESET}" - echo -e " Commands: ~/.ccs/shared/commands/" - echo -e " Skills: ~/.ccs/shared/skills/" - echo -e " Agents: ~/.ccs/shared/agents/" - echo -e " Plugins: ~/.ccs/shared/plugins/" - echo -e " Note: Commands, skills, agents, and plugins are symlinked across all profiles" - echo "" - - echo -e "${CYAN}Examples:${RESET}" - echo -e " ${YELLOW}\$ ccs${RESET} # Use default account" - echo -e " ${YELLOW}\$ ccs glm \"implement API\"${RESET} # Cost-optimized model" - echo "" - echo -e " For more: ${CYAN}https://github.com/kaitranntt/ccs/blob/main/README.md${RESET}" - echo "" - - echo -e "${YELLOW}Uninstall:${RESET}" - echo -e " npm: npm uninstall -g @kaitranntt/ccs" - echo -e " macOS/Linux: curl -fsSL ccs.kaitran.ca/uninstall | bash" - echo -e " Windows: irm ccs.kaitran.ca/uninstall | iex" - echo "" - - echo -e "${CYAN}Documentation:${RESET}" - echo -e " GitHub: ${CYAN}https://github.com/kaitranntt/ccs${RESET}" - echo -e " Docs: https://github.com/kaitranntt/ccs/blob/main/README.md" - echo -e " Issues: https://github.com/kaitranntt/ccs/issues" - echo "" - - echo -e "${CYAN}License:${RESET} MIT" -} - -setup_colors - -# Check dependencies early -command -v jq &>/dev/null || { - msg_error "jq required but not installed. Install: brew install jq (macOS) or apt install jq (Ubuntu)" - exit 1 -} - -# --- Auto-Recovery Functions --- - -ensure_ccs_directory() { - [[ -d "$HOME/.ccs" ]] && return 0 - - mkdir -p "$HOME/.ccs" 2>/dev/null || { - msg_error "Cannot create ~/.ccs/ directory. Check permissions." - return 1 - } - - echo "[i] Auto-recovery: Created ~/.ccs/ directory" - return 0 -} - -ensure_config_json() { - local config_file="$HOME/.ccs/config.json" - - # Check if exists and valid - if [[ -f "$config_file" ]]; then - jq empty "$config_file" 2>/dev/null && return 0 - - # Corrupted - backup and recreate - local backup_file="${config_file}.backup.$(date +%s)" - mv "$config_file" "$backup_file" 2>/dev/null - echo "[i] Auto-recovery: Backed up corrupted config.json" - fi - - # Create default config - cat > "$config_file" <<'EOF' -{ - "profiles": { - "glm": "~/.ccs/glm.settings.json", - "kimi": "~/.ccs/kimi.settings.json", - "default": "~/.claude/settings.json" - } -} -EOF - - echo "[i] Auto-recovery: Created ~/.ccs/config.json" - return 0 -} - -ensure_claude_settings() { - local claude_dir="$HOME/.claude" - local settings_file="$claude_dir/settings.json" - - # Create ~/.claude/ if missing - if [[ ! -d "$claude_dir" ]]; then - mkdir -p "$claude_dir" 2>/dev/null || return 1 - echo "[i] Auto-recovery: Created ~/.claude/ directory" - fi - - # Create settings.json if missing - if [[ ! -f "$settings_file" ]]; then - echo '{}' > "$settings_file" 2>/dev/null || return 1 - echo "[i] Auto-recovery: Created ~/.claude/settings.json" - echo "[i] Next step: Run 'claude /login' to authenticate" - return 0 - fi - - return 0 -} - -# Run auto-recovery -auto_recover() { - ensure_ccs_directory || return 1 - ensure_config_json || return 1 - ensure_claude_settings || return 1 - return 0 -} - -# --- Doctor Command --- - -doctor_check() { - local check_name="$1" - local status="$2" # success, warning, error - local message="${3:-}" - - case "$status" in - success) - echo -e "${GREEN}[OK]${RESET} $check_name" - ;; - warning) - echo -e "${YELLOW}[!]${RESET} $check_name${message:+: $message}" - ;; - error) - echo -e "${RED}[X]${RESET} $check_name: $message" - ;; - esac -} - -doctor_run() { - echo -e "${CYAN}Running CCS Health Check...${RESET}" - echo "" - - local has_errors=false - local total_checks=9 - local current_check=0 - - # Check Claude CLI - current_check=$((current_check + 1)) - show_progress_step $current_check $total_checks "Checking Claude CLI" - if command -v "$(detect_claude_cli)" &>/dev/null; then - clear_progress - doctor_check "Claude CLI" "success" - else - clear_progress - doctor_check "Claude CLI" "error" "Not found in PATH" - has_errors=true - fi - - # Check ~/.ccs/ - current_check=$((current_check + 1)) - show_progress_step $current_check $total_checks "Checking CCS directory" - if [[ -d "$HOME/.ccs" ]]; then - clear_progress - doctor_check "CCS Directory" "success" - else - clear_progress - doctor_check "CCS Directory" "error" "~/.ccs/ not found" - has_errors=true - fi - - # Check config.json - current_check=$((current_check + 1)) - show_progress_step $current_check $total_checks "Checking config.json" - if [[ -f "$CONFIG_FILE" ]]; then - if jq empty "$CONFIG_FILE" 2>/dev/null; then - clear_progress - doctor_check "config.json" "success" - else - clear_progress - doctor_check "config.json" "error" "Invalid JSON" - has_errors=true - fi - else - clear_progress - doctor_check "config.json" "error" "Not found" - has_errors=true - fi - - # Check glm.settings.json - current_check=$((current_check + 1)) - show_progress_step $current_check $total_checks "Checking glm.settings.json" - local glm_file="$HOME/.ccs/glm.settings.json" - if [[ -f "$glm_file" ]]; then - if jq empty "$glm_file" 2>/dev/null; then - clear_progress - doctor_check "glm.settings.json" "success" - else - clear_progress - doctor_check "glm.settings.json" "error" "Invalid JSON" - has_errors=true - fi - else - clear_progress - doctor_check "glm.settings.json" "error" "Not found" - has_errors=true - fi - - # Check kimi.settings.json - current_check=$((current_check + 1)) - show_progress_step $current_check $total_checks "Checking kimi.settings.json" - local kimi_file="$HOME/.ccs/kimi.settings.json" - if [[ -f "$kimi_file" ]]; then - if jq empty "$kimi_file" 2>/dev/null; then - clear_progress - doctor_check "kimi.settings.json" "success" - else - clear_progress - doctor_check "kimi.settings.json" "error" "Invalid JSON" - has_errors=true - fi - else - clear_progress - doctor_check "kimi.settings.json" "error" "Not found" - has_errors=true - fi - - # Check ~/.claude/settings.json - current_check=$((current_check + 1)) - show_progress_step $current_check $total_checks "Checking Claude settings" - if [[ -f "$HOME/.claude/settings.json" ]]; then - if jq empty "$HOME/.claude/settings.json" 2>/dev/null; then - clear_progress - doctor_check "Claude Settings" "success" - else - clear_progress - doctor_check "Claude Settings" "warning" "Invalid JSON" - fi - else - clear_progress - doctor_check "Claude Settings" "warning" "Not found - run 'claude /login'" - fi - - # Check profiles - current_check=$((current_check + 1)) - show_progress_step $current_check $total_checks "Checking profiles" - if [[ -f "$CONFIG_FILE" ]]; then - local profile_count=$(jq -r '.profiles | length' "$CONFIG_FILE" 2>/dev/null || echo "0") - clear_progress - doctor_check "Profiles" "success" "($profile_count configured)" - fi - - # Check instances - current_check=$((current_check + 1)) - show_progress_step $current_check $total_checks "Checking instances" - if [[ -d "$INSTANCES_DIR" ]]; then - local instance_count=$(find "$INSTANCES_DIR" -maxdepth 1 -type d 2>/dev/null | wc -l) - instance_count=$((instance_count - 1)) # Exclude parent dir - clear_progress - doctor_check "Instances" "success" "($instance_count account profiles)" - else - clear_progress - doctor_check "Instances" "success" "(no account profiles)" - fi - - # Check permissions - current_check=$((current_check + 1)) - show_progress_step $current_check $total_checks "Checking permissions" - local test_file="$HOME/.ccs/.permission-test" - if echo "test" > "$test_file" 2>/dev/null; then - rm -f "$test_file" 2>/dev/null - clear_progress - doctor_check "Permissions" "success" - else - clear_progress - doctor_check "Permissions" "error" "Cannot write to ~/.ccs/" - has_errors=true - fi - - # Summary - echo "" - echo -e "${CYAN}═══════════════════════════════════════════${RESET}" - if $has_errors; then - echo -e "${RED}Status: Installation has errors${RESET}" - echo "Run: npm install -g @kaitranntt/ccs --force" - else - echo -e "${GREEN}✓ All checks passed!${RESET}" - fi - echo "" - - $has_errors && exit 1 || exit 0 -} - -# --- Sync Command --- - -sync_run() { - local ccs_claude_dir="$HOME/.ccs/.claude" - local user_claude_dir="$HOME/.claude" - - echo -e "${CYAN}Syncing delegation commands and skills to ~/.claude/...${RESET}" - echo "" - - # Check if source directory exists - if [[ ! -d "$ccs_claude_dir" ]]; then - msg_error "CCS .claude/ directory not found at $ccs_claude_dir" - echo "Reinstall CCS: npm install -g @kaitranntt/ccs --force" +# Check Node.js version (major only) +node_version=$(node -v | cut -d'v' -f2 | cut -d'.' -f1) +if [[ "$node_version" -lt "$MIN_NODE_VERSION" ]]; then + echo "[X] Node.js $MIN_NODE_VERSION+ required (found: $(node -v))" >&2 + echo " Update: https://nodejs.org" >&2 exit 1 - fi - - # Create ~/.claude/ if missing - if [[ ! -d "$user_claude_dir" ]]; then - echo -e "${CYAN}[i]${RESET} Creating ~/.claude/ directory" - mkdir -p "$user_claude_dir" - chmod 700 "$user_claude_dir" - fi - - # Items to symlink (source:target:type) - local items=( - "commands/ccs:commands/ccs:dir" - "skills/ccs-delegation:skills/ccs-delegation:dir" - "agents/ccs-delegator.md:agents/ccs-delegator.md:file" - ) - - local installed=0 - local skipped=0 - - for item in "${items[@]}"; do - IFS=':' read -r source target type <<< "$item" - local source_path="$ccs_claude_dir/$source" - local target_path="$user_claude_dir/$target" - local target_dir="$(dirname "$target_path")" - - # Check source exists - if [[ ! -e "$source_path" ]]; then - echo -e "${YELLOW}[!]${RESET} Source not found: $source, skipping" - continue - fi - - # Create parent directory if needed - if [[ ! -d "$target_dir" ]]; then - mkdir -p "$target_dir" - chmod 700 "$target_dir" - fi - - # Check if already correct symlink - if [[ -L "$target_path" ]]; then - local link_target="$(readlink "$target_path")" - local resolved_target="$(cd "$(dirname "$target_path")" && cd "$(dirname "$link_target")" && pwd)/$(basename "$link_target")" - - if [[ "$resolved_target" == "$source_path" ]]; then - ((skipped++)) - continue - fi - fi - - # Backup existing file/directory - if [[ -e "$target_path" ]]; then - local timestamp="$(date +%Y-%m-%d)" - local backup_path="${target_path}.backup-${timestamp}" - local counter=1 - - while [[ -e "$backup_path" ]]; do - backup_path="${target_path}.backup-${timestamp}-${counter}" - ((counter++)) - done - - mv "$target_path" "$backup_path" - echo -e "${CYAN}[i]${RESET} Backed up existing to $(basename "$backup_path")" - fi - - # Create symlink - ln -s "$source_path" "$target_path" 2>/dev/null - if [[ $? -eq 0 ]]; then - echo -e "${GREEN}[OK]${RESET} Installed $target" - ((installed++)) - else - echo -e "${RED}[X]${RESET} Failed to install $target" - fi - done - - echo "" - echo -e "${GREEN}✓ Update complete${RESET}" - echo " Installed: $installed" - echo " Already up-to-date: $skipped" - echo "" -} - -# --- Update Command --- - -update_run() { - echo "" - echo -e "${CYAN}Checking for updates...${RESET}" - echo "" - - # Detect installation method - local install_method="direct" - if command -v npm &>/dev/null && npm list -g @kaitranntt/ccs &>/dev/null 2>&1; then - install_method="npm" - fi - - # Fetch latest version from appropriate source - local latest_version="" - if command -v curl &>/dev/null; then - if [[ "$install_method" == "npm" ]]; then - # Check npm registry for npm installations - latest_version=$(curl -fsSL https://registry.npmjs.org/@kaitranntt/ccs/latest 2>/dev/null | \ - grep '"version"' | head -1 | sed -E 's/.*"version"[[:space:]]*:[[:space:]]*"([0-9.]+)".*/\1/') - else - # Check GitHub releases for direct installations - latest_version=$(curl -fsSL https://api.github.com/repos/kaitranntt/ccs/releases/latest 2>/dev/null | \ - grep '"tag_name"' | sed -E 's/.*"v?([0-9.]+)".*/\1/') - fi - fi - - if [[ -z "$latest_version" ]]; then - echo -e "${YELLOW}[!] Unable to check for updates${RESET}" - echo "" - echo "Try manually:" - if [[ "$install_method" == "npm" ]]; then - echo -e " ${YELLOW}npm install -g @kaitranntt/ccs@latest${RESET}" - else - echo -e " ${YELLOW}curl -fsSL ccs.kaitran.ca/install | bash${RESET}" - fi - echo "" - exit 1 - fi - - # Compare versions - if [[ "$latest_version" == "$CCS_VERSION" ]]; then - echo -e "${GREEN}[OK] You are already on the latest version (${CCS_VERSION})${RESET}" - echo "" - exit 0 - fi - - # Check if update available - local current_major=$(echo "$CCS_VERSION" | cut -d. -f1) - local current_minor=$(echo "$CCS_VERSION" | cut -d. -f2) - local current_patch=$(echo "$CCS_VERSION" | cut -d. -f3) - - local latest_major=$(echo "$latest_version" | cut -d. -f1) - local latest_minor=$(echo "$latest_version" | cut -d. -f2) - local latest_patch=$(echo "$latest_version" | cut -d. -f3) - - local is_newer=0 - if [[ $latest_major -gt $current_major ]]; then - is_newer=1 - elif [[ $latest_major -eq $current_major ]] && [[ $latest_minor -gt $current_minor ]]; then - is_newer=1 - elif [[ $latest_major -eq $current_major ]] && [[ $latest_minor -eq $current_minor ]] && [[ $latest_patch -gt $current_patch ]]; then - is_newer=1 - fi - - if [[ $is_newer -eq 0 ]]; then - echo -e "${GREEN}[OK] You are on version ${CCS_VERSION} (latest is ${latest_version})${RESET}" - echo "" - exit 0 - fi - - echo -e "${YELLOW}[i] Update available: ${CCS_VERSION} → ${latest_version}${RESET}" - echo "" - - # Perform update based on installation method - if [[ "$install_method" == "npm" ]]; then - echo -e "${CYAN}Updating via npm...${RESET}" - echo "" - - # Clear npm cache to ensure fresh download - echo -e "${CYAN}Clearing package cache...${RESET}" - if ! npm cache clean --force 2>/dev/null; then - echo -e "${YELLOW}[!] Cache clearing failed, proceeding anyway...${RESET}" - fi - echo "" - - if npm install -g @kaitranntt/ccs@latest; then - echo "" - echo -e "${GREEN}[OK] Update successful!${RESET}" - echo "" - echo -e "Run ${YELLOW}ccs --version${RESET} to verify" - echo "" - exit 0 - else - echo "" - echo -e "${RED}[X] Update failed${RESET}" - echo "" - echo "Try manually:" - echo -e " ${YELLOW}npm cache clean --force && npm install -g @kaitranntt/ccs@latest${RESET}" - echo "" - exit 1 - fi - else - echo -e "${CYAN}Updating via installer...${RESET}" - echo "" - - if curl -fsSL ccs.kaitran.ca/install | bash; then - echo "" - echo -e "${GREEN}[OK] Update successful!${RESET}" - echo "" - echo -e "Run ${YELLOW}ccs --version${RESET} to verify" - echo "" - exit 0 - else - echo "" - echo -e "${RED}[X] Update failed${RESET}" - echo "" - echo "Try manually:" - echo -e " ${YELLOW}curl -fsSL ccs.kaitran.ca/install | bash${RESET}" - echo "" - exit 1 - fi - fi -} - -# --- Claude CLI Detection Logic --- - -detect_claude_cli() { - echo "${CCS_CLAUDE_PATH:-claude}" -} - -show_claude_not_found_error() { - msg_error "Claude CLI not found in PATH - -CCS requires Claude CLI to be installed and available in your PATH. - -Solutions: - 1. Install Claude CLI: - https://docs.claude.com/en/docs/claude-code/installation - - 2. Verify installation: - command -v claude - - 3. If installed but not in PATH, add it: - # Find Claude installation - which claude - - # Or set custom path - export CCS_CLAUDE_PATH='/path/to/claude' - -Restart your terminal after installation." -} - -show_version() { - # Title - echo -e "${BOLD}CCS (Claude Code Switch) v${CCS_VERSION}${RESET}" - echo "" - - # Installation section with table-like formatting - echo -e "${CYAN}Installation:${RESET}" - - # Location - prioritize script location over command location - local script_location="$(readlink -f "${BASH_SOURCE[0]}")" - local command_location=$(command -v ccs 2>/dev/null || echo "(not found)") - - # Show script location if running from source - if [[ "$script_location" == *"ccs"* ]]; then - printf " ${CYAN}%-16s${RESET} %s\n" "Location:" "${script_location}" - else - printf " ${CYAN}%-16s${RESET} %s\n" "Location:" "${command_location}" - fi - - # .ccs/ directory location - printf " ${CYAN}%-16s${RESET} %s\n" "CCS Directory:" "$HOME/.ccs/" - - # Config path - printf " ${CYAN}%-16s${RESET} %s\n" "Config:" "${CONFIG_FILE}" - - # Profiles.json location - printf " ${CYAN}%-16s${RESET} %s\n" "Profiles:" "${PROFILES_JSON}" - - # Delegation status - check multiple indicators - local delegation_configured=false - local ready_profiles=() - - # Check for delegation-sessions.json (primary indicator) - local delegation_sessions="$HOME/.ccs/delegation-sessions.json" - if [[ -f "$delegation_sessions" ]]; then - delegation_configured=true - fi - - # Check for profiles with valid API keys (secondary indicator) - for profile in glm kimi; do - local settings_file="$HOME/.ccs/$profile.settings.json" - if [[ -f "$settings_file" ]]; then - # Check if API key is configured (not a placeholder) - local api_key=$(jq -r '.env.ANTHROPIC_AUTH_TOKEN // empty' "$settings_file" 2>/dev/null) - if [[ -n "$api_key" ]] && [[ ! "$api_key" =~ YOUR_.*_API_KEY_HERE ]] && [[ ! "$api_key" =~ sk-test.* ]]; then - ready_profiles+=("$profile") - delegation_configured=true - fi - fi - done - - if $delegation_configured; then - printf " ${CYAN}%-16s${RESET} %s\n" "Delegation:" "Enabled" - else - printf " ${CYAN}%-16s${RESET} %s\n" "Delegation:" "Not configured" - fi - - echo "" - - # Ready Profiles section - make it more prominent - if [[ ${#ready_profiles[@]} -gt 0 ]]; then - echo -e "${CYAN}Delegation Ready:${RESET}" - echo " ${YELLOW}✓${RESET} ${ready_profiles[*]} profiles are ready for delegation" - echo "" - elif $delegation_configured; then - echo -e "${CYAN}Delegation Ready:${RESET}" - echo " ${YELLOW}!${RESET} Delegation configured but no valid API keys found" - echo "" - fi - - # Documentation - echo -e "${CYAN}Documentation:${RESET} https://github.com/kaitranntt/ccs" - echo -e "${CYAN}License:${RESET} MIT" - echo "" - - # Help hint - echo -e "${YELLOW}Run 'ccs --help' for usage information${RESET}" -} - -# --- Profile Registry Functions (Phase 4) --- - -# Initialize empty registry if missing -init_profiles_json() { - [[ -f "$PROFILES_JSON" ]] && return 0 - - local init_data='{ - "version": "2.0.0", - "profiles": {}, - "default": null -}' - - echo "$init_data" > "$PROFILES_JSON" - chmod 0600 "$PROFILES_JSON" -} - -# Read entire profiles.json -read_profiles_json() { - init_profiles_json - cat "$PROFILES_JSON" -} - -# Write entire profiles.json (atomic) -write_profiles_json() { - local content="$1" - local temp_file="$PROFILES_JSON.tmp" - - echo "$content" > "$temp_file" || { - msg_error "Failed to write profiles registry" - return 1 - } - - chmod 0600 "$temp_file" - mv "$temp_file" "$PROFILES_JSON" || { - rm -f "$temp_file" - msg_error "Failed to update profiles registry" - return 1 - } -} - -# Check if profile exists -profile_exists() { - local profile_name="$1" - init_profiles_json - - local exists=$(jq -r ".profiles.\"$profile_name\" // empty" "$PROFILES_JSON") - [[ -n "$exists" ]] -} - -# Create new profile -register_profile() { - local profile_name="$1" - local timestamp=$(date -u +"%Y-%m-%dT%H:%M:%S.000Z") - - init_profiles_json - - # Check if exists - profile_exists "$profile_name" && { - msg_error "Profile already exists: $profile_name" - return 1 - } - - # Read current data - local data=$(read_profiles_json) - - # Add new profile - data=$(echo "$data" | jq \ - --arg name "$profile_name" \ - --arg timestamp "$timestamp" \ - '.profiles[$name] = { - "type": "account", - "created": $timestamp, - "last_used": null - }') - - # Note: No longer auto-set as default - # Users must explicitly run: ccs auth default - # Default always stays on implicit 'default' profile (uses ~/.claude/) - - write_profiles_json "$data" -} - -# Delete profile -unregister_profile() { - local profile_name="$1" - - init_profiles_json - - profile_exists "$profile_name" || return 0 # Idempotent - - local data=$(read_profiles_json) - - # Remove profile - data=$(echo "$data" | jq --arg name "$profile_name" 'del(.profiles[$name])') - - # Update default if it was the deleted profile - local current_default=$(echo "$data" | jq -r '.default // empty') - if [[ "$current_default" == "$profile_name" ]]; then - # Set to first remaining profile or null - local first_profile=$(echo "$data" | jq -r '.profiles | keys[0] // empty') - data=$(echo "$data" | jq --arg first "$first_profile" ' - .default = if $first != "" then $first else null end - ') - fi - - write_profiles_json "$data" -} - -# Update last_used timestamp -touch_profile() { - local profile_name="$1" - local timestamp=$(date -u +"%Y-%m-%dT%H:%M:%S.000Z") - - profile_exists "$profile_name" || return 0 # Silent fail if not exists - - local data=$(read_profiles_json) - - data=$(echo "$data" | jq \ - --arg name "$profile_name" \ - --arg timestamp "$timestamp" \ - '.profiles[$name].last_used = $timestamp') - - write_profiles_json "$data" -} - -# Get default profile -get_default_profile() { - init_profiles_json - jq -r '.default // empty' "$PROFILES_JSON" -} - -# Set default profile -set_default_profile() { - local profile_name="$1" - - profile_exists "$profile_name" || { - msg_error "Profile not found: $profile_name" - return 1 - } - - local data=$(read_profiles_json) - data=$(echo "$data" | jq --arg name "$profile_name" '.default = $name') - - write_profiles_json "$data" -} - -# --- Instance Management Functions (Phase 2) --- - -# Sanitize profile name for filesystem -sanitize_profile_name() { - local name="$1" - # Replace unsafe chars with dash, lowercase - echo "$name" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9_-]/-/g' -} - -# Link shared directories (Phase 1: Shared Global Data) -link_shared_directories() { - local instance_path="$1" - local shared_dir="$HOME/.ccs/shared" - - # Ensure shared directories exist - mkdir -p "$shared_dir"/{commands,skills,agents,plugins} - - # Create symlinks (remove existing first if present) - for dir in commands skills agents plugins; do - local link_path="$instance_path/$dir" - local target_path="$shared_dir/$dir" - - # Remove existing directory/link - [[ -e "$link_path" ]] && rm -rf "$link_path" - - # Create symlink - ln -sf "$target_path" "$link_path" - done -} - -# Migrate to shared structure (Phase 1: Auto-migration) -migrate_to_shared_structure() { - local shared_dir="$HOME/.ccs/shared" - - # Check if migration is needed (shared dirs exist but are empty) - if [[ -d "$shared_dir" ]]; then - local needs_migration=false - for dir in commands skills agents plugins; do - if [[ ! -d "$shared_dir/$dir" ]] || [[ -z "$(ls -A "$shared_dir/$dir" 2>/dev/null)" ]]; then - needs_migration=true - break - fi - done - - [[ "$needs_migration" == "false" ]] && return 0 - fi - - # Create shared directory - mkdir -p "$shared_dir"/{commands,skills,agents,plugins} - - # Copy from ~/.claude/ (actual Claude CLI directory) - local claude_dir="$HOME/.claude" - - if [[ -d "$claude_dir" ]]; then - # Copy commands to shared (if exists) - [[ -d "$claude_dir/commands" ]] && \ - cp -r "$claude_dir/commands"/* "$shared_dir/commands/" 2>/dev/null || true - - # Copy skills to shared (if exists) - [[ -d "$claude_dir/skills" ]] && \ - cp -r "$claude_dir/skills"/* "$shared_dir/skills/" 2>/dev/null || true - - # Copy agents to shared (if exists) - [[ -d "$claude_dir/agents" ]] && \ - cp -r "$claude_dir/agents"/* "$shared_dir/agents/" 2>/dev/null || true - - # Copy plugins to shared (if exists) - [[ -d "$claude_dir/plugins" ]] && \ - cp -r "$claude_dir/plugins"/* "$shared_dir/plugins/" 2>/dev/null || true - fi - - # Update all instances to use symlinks - for instance_path in "$INSTANCES_DIR"/*; do - [[ -d "$instance_path" ]] || continue - link_shared_directories "$instance_path" - done - - echo "[OK] Migrated to shared structure" -} - -# Initialize new instance directory -initialize_instance() { - local instance_path="$1" - - # Create base directory - mkdir -m 0700 -p "$instance_path" - - # Create subdirectories (profile-specific only) - local subdirs=(session-env todos logs file-history shell-snapshots debug .anthropic) - for dir in "${subdirs[@]}"; do - mkdir -m 0700 -p "$instance_path/$dir" - done - - # Symlink shared directories - link_shared_directories "$instance_path" - - # Copy global configs (optional) - copy_global_configs "$instance_path" -} - -# Validate instance structure (auto-repair) -validate_instance() { - local instance_path="$1" - local required_dirs=(session-env todos logs file-history shell-snapshots debug .anthropic) - - for dir in "${required_dirs[@]}"; do - if [[ ! -d "$instance_path/$dir" ]]; then - mkdir -m 0700 -p "$instance_path/$dir" - fi - done -} - -# Copy global Claude configs to instance -copy_global_configs() { - local instance_path="$1" - local global_claude="$HOME/.claude" - - # Copy settings.json only (commands/skills are now symlinked to shared/) - [[ -f "$global_claude/settings.json" ]] && \ - cp "$global_claude/settings.json" "$instance_path/settings.json" 2>/dev/null || true -} - -# Ensure instance exists (lazy initialization) -ensure_instance() { - local profile_name="$1" - local safe_name=$(sanitize_profile_name "$profile_name") - local instance_path="$INSTANCES_DIR/$safe_name" - - # Create if missing - if [[ ! -d "$instance_path" ]]; then - initialize_instance "$instance_path" - fi - - # Validate structure - validate_instance "$instance_path" - - echo "$instance_path" -} - -# --- Profile Detection Logic (Phase 1) --- - -# List available profiles for error messages -# Get all profile names (for fuzzy matching) -get_all_profile_names() { - local names=() - - # Settings-based profiles - if [[ -f "$CONFIG_FILE" ]]; then - while IFS= read -r name; do - names+=("$name") - done < <(jq -r '.profiles | keys[]' "$CONFIG_FILE" 2>/dev/null || true) - fi - - # Account-based profiles - if [[ -f "$PROFILES_JSON" ]]; then - while IFS= read -r name; do - names+=("$name") - done < <(jq -r '.profiles | keys[]' "$PROFILES_JSON" 2>/dev/null || true) - fi - - printf '%s\n' "${names[@]}" -} - -list_available_profiles() { - local lines=() - - # Settings-based profiles - if [[ -f "$CONFIG_FILE" ]]; then - local settings_profiles=$(jq -r '.profiles | keys[]' "$CONFIG_FILE" 2>/dev/null || true) - if [[ -n "$settings_profiles" ]]; then - lines+=("Settings-based profiles (GLM, Kimi, etc.):") - while IFS= read -r name; do - lines+=(" - $name") - done <<< "$settings_profiles" - fi - fi - - # Account-based profiles - if [[ -f "$PROFILES_JSON" ]]; then - local account_profiles=$(jq -r '.profiles | keys[]' "$PROFILES_JSON" 2>/dev/null || true) - local default_profile=$(jq -r '.default // empty' "$PROFILES_JSON" 2>/dev/null || true) - - if [[ -n "$account_profiles" ]]; then - lines+=("Account-based profiles:") - while IFS= read -r name; do - local is_default="" - [[ "$name" == "$default_profile" ]] && is_default=" [DEFAULT]" - lines+=(" - $name$is_default") - done <<< "$account_profiles" - fi - fi - - if [[ ${#lines[@]} -eq 0 ]]; then - echo " (no profiles configured)" - echo " Run \"ccs auth create \" to create your first account profile." - else - printf '%s\n' "${lines[@]}" - fi -} - -# Detect profile type and return info -# Sets global variables: PROFILE_TYPE, PROFILE_PATH/INSTANCE_PATH -detect_profile_type() { - local profile_name="$1" - - # Special case: 'default' resolves to default profile - if [[ "$profile_name" == "default" ]]; then - # Check account-based default first - if [[ -f "$PROFILES_JSON" ]]; then - local default_account=$(jq -r '.default // empty' "$PROFILES_JSON" 2>/dev/null || true) - if [[ -n "$default_account" ]] && profile_exists "$default_account"; then - PROFILE_TYPE="account" - PROFILE_NAME="$default_account" - return 0 - fi - fi - - # Check settings-based default - if [[ -f "$CONFIG_FILE" ]]; then - local default_settings=$(jq -r '.profiles.default // empty' "$CONFIG_FILE" 2>/dev/null || true) - if [[ -n "$default_settings" ]]; then - PROFILE_TYPE="settings" - PROFILE_PATH="$default_settings" - PROFILE_NAME="default" - return 0 - fi - fi - - # No default configured, use Claude's defaults - PROFILE_TYPE="default" - PROFILE_NAME="default" - return 0 - fi - - # Priority 1: Check settings-based profiles (backward compatibility) - if [[ -f "$CONFIG_FILE" ]]; then - local settings_path=$(jq -r ".profiles.\"$profile_name\" // empty" "$CONFIG_FILE" 2>/dev/null || true) - if [[ -n "$settings_path" ]]; then - PROFILE_TYPE="settings" - PROFILE_PATH="$settings_path" - PROFILE_NAME="$profile_name" - return 0 - fi - fi - - # Priority 2: Check account-based profiles - if [[ -f "$PROFILES_JSON" ]] && profile_exists "$profile_name"; then - PROFILE_TYPE="account" - PROFILE_NAME="$profile_name" - return 0 - fi - - # Not found - PROFILE_TYPE="error" - return 1 -} - -# --- Auth Commands (Phase 3) --- - -auth_help() { - echo -e "${BOLD}CCS Concurrent Account Management${RESET}" - echo "" - echo -e "${CYAN}Usage:${RESET}" - echo -e " ${YELLOW}ccs auth${RESET} [options]" - echo "" - echo -e "${CYAN}Commands:${RESET}" - echo -e " ${YELLOW}create ${RESET} Create new profile and login" - echo -e " ${YELLOW}list${RESET} List all saved profiles" - echo -e " ${YELLOW}show ${RESET} Show profile details" - echo -e " ${YELLOW}remove ${RESET} Remove saved profile" - echo -e " ${YELLOW}default ${RESET} Set default profile" - echo "" - echo -e "${CYAN}Examples:${RESET}" - echo -e " ${YELLOW}ccs auth create work${RESET} # Create & login to work profile" - echo -e " ${YELLOW}ccs auth default work${RESET} # Set work as default" - echo -e " ${YELLOW}ccs auth list${RESET} # List all profiles" - echo -e " ${YELLOW}ccs work \"review code\"${RESET} # Use work profile" - echo -e " ${YELLOW}ccs \"review code\"${RESET} # Use default profile" - echo "" - echo -e "${CYAN}Options:${RESET}" - echo -e " ${YELLOW}--force${RESET} Allow overwriting existing profile (create)" - echo -e " ${YELLOW}--yes, -y${RESET} Skip confirmation prompts (remove)" - echo -e " ${YELLOW}--json${RESET} Output in JSON format (list, show)" - echo -e " ${YELLOW}--verbose${RESET} Show additional details (list)" - echo "" - echo -e "${CYAN}Note:${RESET}" - echo -e " By default, ${YELLOW}ccs${RESET} uses Claude CLI defaults from ~/.claude/" - echo -e " Use ${YELLOW}ccs auth default ${RESET} to change the default profile." - echo "" -} - -auth_create() { - local profile_name="" - local force=false - - # Parse arguments - while [[ $# -gt 0 ]]; do - case "$1" in - --force) force=true ;; - -*) msg_error "Unknown option: $1"; return 1 ;; - *) profile_name="$1" ;; - esac - shift - done - - # Validate profile name - [[ -z "$profile_name" ]] && { - msg_error "Profile name is required" - echo "" - echo "Usage: ${YELLOW}ccs auth create [--force]${RESET}" - return 1 - } - - # Check if exists - if ! $force && profile_exists "$profile_name"; then - msg_error "Profile already exists: $profile_name" - echo "Use ${YELLOW}--force${RESET} to overwrite" - return 1 - fi - - # Create instance - echo "[i] Creating profile: $profile_name" - local instance_path=$(ensure_instance "$profile_name") - echo "[i] Instance directory: $instance_path" - echo "" - - # Register profile - register_profile "$profile_name" - - # Launch Claude for login - echo -e "${YELLOW}[i] Starting Claude in isolated instance...${RESET}" - echo -e "${YELLOW}[i] You will be prompted to login with your account.${RESET}" - echo "" - - CLAUDE_CONFIG_DIR="$instance_path" $(detect_claude_cli) || { - msg_error "Login failed or cancelled" - echo "To retry: ${YELLOW}ccs auth create $profile_name --force${RESET}" - return 1 - } - - echo "" - echo -e "${GREEN}[OK] Profile created successfully${RESET}" - echo "" - echo " Profile: $profile_name" - echo " Instance: $instance_path" - echo "" - echo "Usage:" - echo " ${YELLOW}ccs $profile_name \"your prompt here\"${RESET} # Use this specific profile" - echo "" - echo "To set as default (so you can use just \"ccs\"):" - echo " ${YELLOW}ccs auth default $profile_name${RESET}" - echo "" -} - -auth_list() { - local verbose=false - local json=false - - # Parse arguments - while [[ $# -gt 0 ]]; do - case "$1" in - --verbose) verbose=true ;; - --json) json=true ;; - *) ;; - esac - shift - done - - # Read profiles.json - [[ ! -f "$PROFILES_JSON" ]] && { - if $json; then - echo "{\"version\":\"$CCS_VERSION\",\"profiles\":[]}" - return 0 - fi - echo -e "${YELLOW}No account profiles found${RESET}" - echo "" - echo "To create your first profile:" - echo " ${YELLOW}ccs auth create ${RESET}" - return 0 - } - - local default_profile=$(jq -r '.default // empty' "$PROFILES_JSON" 2>/dev/null || true) - - # JSON output mode - if $json; then - jq -n \ - --arg version "$CCS_VERSION" \ - --arg default "$default_profile" \ - --argjson data "$(cat "$PROFILES_JSON")" \ - '{ - version: $version, - profiles: [ - $data.profiles | to_entries[] | { - name: .key, - type: (.value.type // "account"), - is_default: (.key == $default), - created: .value.created, - last_used: (.value.last_used // null), - instance_path: ($ENV.INSTANCES_DIR + "/" + .key) - } - ] - }' - return 0 - fi - - # Human-readable output - local profiles=$(jq -r '.profiles | keys[]' "$PROFILES_JSON" 2>/dev/null || true) - - [[ -z "$profiles" ]] && { - echo -e "${YELLOW}No account profiles found${RESET}" - return 0 - } - - echo -e "${BOLD}Saved Account Profiles:${RESET}" - echo "" - - # Display profiles - while IFS= read -r profile; do - local is_default=false - [[ "$profile" == "$default_profile" ]] && is_default=true - - if $is_default; then - echo -e "${GREEN}[*] ${CYAN}$profile${GREEN} (default)${RESET}" - else - echo -e "[ ] ${CYAN}$profile${RESET}" - fi - - local type=$(jq -r ".profiles.\"$profile\".type // \"account\"" "$PROFILES_JSON" 2>/dev/null || true) - echo " Type: $type" - - if $verbose; then - local created=$(jq -r ".profiles.\"$profile\".created" "$PROFILES_JSON" 2>/dev/null || true) - local last_used=$(jq -r ".profiles.\"$profile\".last_used // \"Never\"" "$PROFILES_JSON" 2>/dev/null || true) - echo " Created: $created" - echo " Last used: $last_used" - fi - - echo "" - done <<< "$profiles" -} - -auth_show() { - local profile_name="" - local json=false - - # Parse arguments - while [[ $# -gt 0 ]]; do - case "$1" in - --json) json=true ;; - -*) msg_error "Unknown option: $1"; return 1 ;; - *) profile_name="$1" ;; - esac - shift - done - - [[ -z "$profile_name" ]] && { - msg_error "Profile name is required" - echo "Usage: ${YELLOW}ccs auth show [--json]${RESET}" - return 1 - } - - # Check if exists - profile_exists "$profile_name" || { - msg_error "Profile not found: $profile_name" - return 1 - } - - local default_profile=$(jq -r '.default // empty' "$PROFILES_JSON" 2>/dev/null || true) - local is_default=false - [[ "$profile_name" == "$default_profile" ]] && is_default=true - - local type=$(jq -r ".profiles.\"$profile_name\".type // \"account\"" "$PROFILES_JSON" 2>/dev/null || true) - local created=$(jq -r ".profiles.\"$profile_name\".created" "$PROFILES_JSON" 2>/dev/null || true) - local last_used=$(jq -r ".profiles.\"$profile_name\".last_used // null" "$PROFILES_JSON" 2>/dev/null || true) - local instance_path="$INSTANCES_DIR/$(sanitize_profile_name "$profile_name")" - - # Count sessions - local session_count=0 - if [[ -d "$instance_path/session-env" ]]; then - session_count=$(find "$instance_path/session-env" -name "*.json" 2>/dev/null | wc -l | tr -d ' ') - fi - - # JSON output mode - if $json; then - jq -n \ - --arg name "$profile_name" \ - --arg type "$type" \ - --argjson is_default "$is_default" \ - --arg created "$created" \ - --arg last_used "$last_used" \ - --arg instance_path "$instance_path" \ - --argjson session_count "$session_count" \ - '{ - name: $name, - type: $type, - is_default: $is_default, - created: $created, - last_used: $last_used, - instance_path: $instance_path, - session_count: $session_count - }' - return 0 - fi - - # Human-readable output - echo -e "${BOLD}Profile: $profile_name${RESET}" - echo "" - - echo " Type: $type" - echo " Default: $($is_default && echo "Yes" || echo "No")" - echo " Instance: $instance_path" - echo " Created: $created" - [[ "$last_used" != "null" ]] && echo " Last used: $last_used" || echo " Last used: Never" - echo "" -} - -auth_remove() { - local profile_name="" - - # Parse arguments - while [[ $# -gt 0 ]]; do - case "$1" in - --yes|-y) export CCS_YES=1 ;; # Auto-confirm (export for confirm_action) - -*) msg_error "Unknown option: $1"; return 1 ;; - *) profile_name="$1" ;; - esac - shift - done - - [[ -z "$profile_name" ]] && { - msg_error "Profile name is required" - echo "" - echo "Usage: ${YELLOW}ccs auth remove [--yes]${RESET}" - return 1 - } - - profile_exists "$profile_name" || { - msg_error "Profile not found: $profile_name" - return 1 - } - - # Get instance path and session count for impact display - local instance_path="$INSTANCES_DIR/$(sanitize_profile_name "$profile_name")" - local session_count=0 - - if [[ -d "$instance_path/session-env" ]]; then - session_count=$(find "$instance_path/session-env" -name "*.json" 2>/dev/null | wc -l | tr -d ' ') - fi - - # Display impact - echo "" - echo "Profile '${CYAN}$profile_name${RESET}' will be permanently deleted." - echo " Instance path: $instance_path" - echo " Sessions: $session_count conversation$([ "$session_count" -ne 1 ] && echo "s" || echo "")" - echo "" - - # Interactive confirmation (or --yes flag) - if ! confirm_action "Delete this profile?" "no"; then - echo "[i] Cancelled" - return 0 - fi - - # Delete instance directory - rm -rf "$instance_path" - - # Remove from registry - unregister_profile "$profile_name" - - echo -e "${GREEN}[OK] Profile removed successfully${RESET}" - echo " Profile: $profile_name" - echo "" -} - -auth_default() { - local profile_name="${1:-}" - - [[ -z "$profile_name" ]] && { - msg_error "Profile name is required" - echo "Usage: ${YELLOW}ccs auth default ${RESET}" - return 1 - } - - profile_exists "$profile_name" || { - msg_error "Profile not found: $profile_name" - return 1 - } - - set_default_profile "$profile_name" - - echo -e "${GREEN}[OK] Default profile set${RESET}" - echo " Profile: $profile_name" - echo "" - echo "Now you can use:" - echo " ${YELLOW}ccs \"your prompt\"${RESET} # Uses $profile_name profile" - echo "" -} - -handle_auth_commands() { - shift # Remove 'auth' - - local subcommand="${1:-}" - shift || true - - case "$subcommand" in - create) auth_create "$@" ;; - list) auth_list "$@" ;; - show) auth_show "$@" ;; - remove) auth_remove "$@" ;; - default) auth_default "$@" ;; - *) auth_help ;; - esac -} - -# --- Shell Completion Installer --- - -install_shell_completion() { - shift # Remove --shell-completion - - echo -e "${BOLD}Shell Completion Installer${RESET}" - echo "" - - # Parse flags for manual shell selection - local target_shell="" - for arg in "$@"; do - case "$arg" in - --bash) target_shell="bash" ;; - --zsh) target_shell="zsh" ;; - --fish) target_shell="fish" ;; - *) ;; - esac - done - - # Auto-detect shell if not specified - if [[ -z "$target_shell" ]]; then - if [[ -n "$BASH_VERSION" ]]; then - target_shell="bash" - elif [[ -n "$ZSH_VERSION" ]]; then - target_shell="zsh" - elif [[ -n "$FISH_VERSION" ]]; then - target_shell="fish" - else - echo -e "${RED}[X] Could not detect shell${RESET}" >&2 - echo "" >&2 - echo -e "${YELLOW}Usage:${RESET}" >&2 - echo " ccs --shell-completion # Auto-detect shell" >&2 - echo " ccs --shell-completion --bash # Install for bash" >&2 - echo " ccs --shell-completion --zsh # Install for zsh" >&2 - echo " ccs --shell-completion --fish # Install for fish" >&2 - echo "" >&2 - return 1 - fi - fi - - # Ensure completion files exist in ~/.ccs/completions/ - local completions_dir="$HOME/.ccs/completions" - if [[ ! -d "$completions_dir" ]]; then - mkdir -p "$completions_dir" - fi - - # Copy from scripts if not present - local script_dir="$(dirname "$0")" - if [[ -f "$script_dir/../scripts/completion/ccs.bash" ]]; then - cp "$script_dir/../scripts/completion/ccs.bash" "$completions_dir/" 2>/dev/null || true - cp "$script_dir/../scripts/completion/ccs.zsh" "$completions_dir/" 2>/dev/null || true - cp "$script_dir/../scripts/completion/ccs.fish" "$completions_dir/" 2>/dev/null || true - fi - - # Install based on target shell - case "$target_shell" in - bash) - local rc_file="$HOME/.bashrc" - local completion_file="$completions_dir/ccs.bash" - local marker="# CCS shell completion" - - if [[ ! -f "$completion_file" ]]; then - echo -e "${RED}[X] Completion file not found: $completion_file${RESET}" >&2 - echo " Please reinstall CCS." >&2 - return 1 - fi - - # Check if already installed - if grep -q "$marker" "$rc_file" 2>/dev/null; then - echo -e "${GREEN}[OK] Shell completion already installed${RESET}" - echo "" - return 0 - fi - - # Append to .bashrc - { - echo "" - echo "$marker" - echo "source \"$completion_file\"" - } >> "$rc_file" - - echo -e "${GREEN}[OK] Shell completion installed successfully!${RESET}" - echo "" - echo "Added to $rc_file" - echo "" - echo -e "${CYAN}To activate:${RESET}" - echo " source ~/.bashrc" - echo "" - echo -e "${CYAN}Then test:${RESET}" - echo " ccs # See available profiles" - echo " ccs auth # See auth subcommands" - echo "" - ;; - - zsh) - local rc_file="$HOME/.zshrc" - local completion_dir="$HOME/.zsh/completion" - local completion_file="$completions_dir/ccs.zsh" - local marker="# CCS shell completion" - - if [[ ! -f "$completion_file" ]]; then - echo -e "${RED}[X] Completion file not found: $completion_file${RESET}" >&2 - echo " Please reinstall CCS." >&2 - return 1 - fi - - # Create zsh completion directory - mkdir -p "$completion_dir" - - # Copy to zsh completion directory - cp "$completion_file" "$completion_dir/_ccs" - - # Check if already installed - if grep -q "$marker" "$rc_file" 2>/dev/null; then - echo -e "${GREEN}[OK] Shell completion already installed${RESET}" - echo "" - return 0 - fi - - # Append to .zshrc - { - echo "" - echo "$marker" - echo "fpath=(~/.zsh/completion \$fpath)" - echo "autoload -Uz compinit && compinit" - } >> "$rc_file" - - echo -e "${GREEN}[OK] Shell completion installed successfully!${RESET}" - echo "" - echo "Added to $rc_file" - echo "" - echo -e "${CYAN}To activate:${RESET}" - echo " source ~/.zshrc" - echo "" - echo -e "${CYAN}Then test:${RESET}" - echo " ccs # See available profiles" - echo " ccs auth # See auth subcommands" - echo "" - ;; - - fish) - local fish_dir="$HOME/.config/fish/completions" - local completion_file="$completions_dir/ccs.fish" - - if [[ ! -f "$completion_file" ]]; then - echo -e "${RED}[X] Completion file not found: $completion_file${RESET}" >&2 - echo " Please reinstall CCS." >&2 - return 1 - fi - - # Create fish completion directory - mkdir -p "$fish_dir" - - # Copy to fish completion directory (fish auto-loads from here) - cp "$completion_file" "$fish_dir/ccs.fish" - - echo -e "${GREEN}[OK] Shell completion installed successfully!${RESET}" - echo "" - echo "Installed to $fish_dir/ccs.fish" - echo "" - echo -e "${CYAN}To activate:${RESET}" - echo " Fish auto-loads completions (no reload needed)" - echo "" - echo -e "${CYAN}Then test:${RESET}" - echo " ccs # See available profiles" - echo " ccs auth # See auth subcommands" - echo "" - ;; - - *) - echo -e "${RED}[X] Unsupported shell: $target_shell${RESET}" >&2 - return 1 - ;; - esac - - return 0 -} - -# --- Main Execution Logic --- - -# Special case: version command (check BEFORE profile detection) -if [[ $# -gt 0 ]] && [[ "${1}" == "version" || "${1}" == "--version" || "${1}" == "-v" ]]; then - show_version - exit 0 fi -# Special case: help command (check BEFORE profile detection) -if [[ $# -gt 0 ]] && [[ "${1}" == "--help" || "${1}" == "-h" || "${1}" == "help" ]]; then - show_help - exit 0 +# Check npm/npx available +if ! command -v npx &>/dev/null; then + echo "[X] npx not found (requires npm 5.2+)" >&2 + exit 127 fi -# Special case: auth commands -if [[ $# -gt 0 ]] && [[ "${1}" == "auth" ]]; then - handle_auth_commands "$@" - exit $? -fi - -# Special case: shell completion installer -if [[ $# -gt 0 ]] && [[ "${1}" == "--shell-completion" || "${1}" == "-sc" ]]; then - install_shell_completion "$@" - exit $? -fi - -# Special case: doctor command -if [[ $# -gt 0 ]] && [[ "${1}" == "doctor" || "${1}" == "--doctor" ]]; then - doctor_run - exit $? -fi - -# Special case: sync command -if [[ $# -gt 0 ]] && [[ "${1}" == "sync" || "${1}" == "--sync" ]]; then - sync_run - exit $? -fi - -# Special case: update command -if [[ $# -gt 0 ]] && [[ "${1}" == "update" || "${1}" == "--update" ]]; then - update_run - exit $? -fi - -# Run auto-recovery before main logic -auto_recover || { - msg_error "Auto-recovery failed. Check permissions." - exit 1 -} - -# Smart profile detection: if first arg starts with '-', it's a flag not a profile -if [[ $# -eq 0 ]] || [[ "${1}" =~ ^- ]]; then - # No args or first arg is a flag → use default profile - PROFILE="default" -else - # First arg doesn't start with '-' → treat as profile name - PROFILE="${1}" -fi - -# Validate profile name (alphanumeric, dash, underscore only) -if [[ "$PROFILE" =~ [^a-zA-Z0-9_-] ]]; then - msg_error "Invalid profile name: $PROFILE - -Use only alphanumeric characters, dash, or underscore." - exit 1 -fi - -# Detect profile type -if ! detect_profile_type "$PROFILE"; then - # Get suggestions using fuzzy matching - mapfile -t all_profiles < <(get_all_profile_names) - mapfile -t suggestions < <(find_similar_strings "$PROFILE" "${all_profiles[@]}") - - echo "" >&2 - echo -e "${RED}[X] Profile '$PROFILE' not found${RESET}" >&2 - echo "" >&2 - - # Show suggestions if any - if [[ ${#suggestions[@]} -gt 0 ]]; then - echo -e "${YELLOW}Did you mean:${RESET}" >&2 - for suggestion in "${suggestions[@]}"; do - echo " $suggestion" >&2 - done - echo "" >&2 - fi - - echo -e "${CYAN}Available profiles:${RESET}" >&2 - list_available_profiles >&2 - echo "" >&2 - echo -e "${YELLOW}Solutions:${RESET}" >&2 - echo " # Use existing profile" >&2 - echo " ccs \"your prompt\"" >&2 - echo "" >&2 - echo " # Create new account profile" >&2 - echo " ccs auth create " >&2 - echo "" >&2 - echo -e "${YELLOW}Error: $E_PROFILE_NOT_FOUND${RESET}" >&2 - echo -e "${YELLOW}$(get_error_doc_url "$E_PROFILE_NOT_FOUND")${RESET}" >&2 - echo "" >&2 - exit 1 -fi - -# Shift profile arg only if first arg was NOT a flag -if [[ $# -gt 0 ]] && [[ ! "${1}" =~ ^- ]]; then - shift -fi - -# Detect Claude CLI executable -CLAUDE_CLI=$(detect_claude_cli) - -# Execute based on profile type (Phase 5) -case "$PROFILE_TYPE" in - account) - # Account-based profile: use CLAUDE_CONFIG_DIR - INSTANCE_PATH=$(ensure_instance "$PROFILE_NAME") - touch_profile "$PROFILE_NAME" # Update last_used - - # Execute Claude with isolated config - CLAUDE_CONFIG_DIR="$INSTANCE_PATH" exec "$CLAUDE_CLI" "$@" || { - show_claude_not_found_error - exit 1 - } - ;; - - settings) - # Settings-based profile: use --settings flag - SETTINGS_PATH="${PROFILE_PATH/#\~/$HOME}" - - [[ ! -f "$SETTINGS_PATH" ]] && { - msg_error "Settings file not found: $SETTINGS_PATH" - exit 1 - } - - exec "$CLAUDE_CLI" --settings "$SETTINGS_PATH" "$@" || { - show_claude_not_found_error - exit 1 - } - ;; - - default) - # Default: no special handling - exec "$CLAUDE_CLI" "$@" || { - show_claude_not_found_error - exit 1 - } - ;; - - *) - msg_error "Unknown profile type: $PROFILE_TYPE" - exit 1 - ;; -esac +# Execute via npx (auto-installs if needed) +exec npx "$PACKAGE" "$@" diff --git a/lib/ccs.ps1 b/lib/ccs.ps1 index 2cf2f67f..8203a2d6 100644 --- a/lib/ccs.ps1 +++ b/lib/ccs.ps1 @@ -1,1825 +1,39 @@ -# CCS - Claude Code Switch (Windows PowerShell) -# Cross-platform Claude CLI profile switcher +# CCS - Claude Code Switch (Bootstrap) +# Delegates to Node.js implementation via npx # https://github.com/kaitranntt/ccs param( - [switch]$Help, - [switch]$Version, [Parameter(ValueFromRemainingArguments=$true)] [string[]]$RemainingArgs ) $ErrorActionPreference = "Stop" +$PACKAGE = "@kaitranntt/ccs" +$MIN_NODE_VERSION = 14 -# Version (updated by scripts/bump-version.sh) -$CcsVersion = "4.5.0" -$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path -$ConfigFile = if ($env:CCS_CONFIG) { $env:CCS_CONFIG } else { "$env:USERPROFILE\.ccs\config.json" } -$ProfilesJson = "$env:USERPROFILE\.ccs\profiles.json" -$InstancesDir = "$env:USERPROFILE\.ccs\instances" - -# Determine dependency location (git vs installed) -# Git: lib/ccs.ps1 and dependencies are in same dir (lib/) -# Installed: lib/ccs.ps1 is in ~/.ccs/, dependencies in ~/.ccs/lib/ -$DepDir = if (Test-Path "$ScriptDir\error-codes.ps1") { - # Git install - files in same directory - $ScriptDir -} else { - # Standalone install - files in ~/.ccs/lib/ - "$env:USERPROFILE\.ccs\lib" +# Check Node.js installed +$NodeCmd = Get-Command node -ErrorAction SilentlyContinue +if (-not $NodeCmd) { + Write-Host "[X] Node.js not found" + Write-Host " Install: https://nodejs.org (LTS recommended)" + exit 127 } -# Source error codes -. "$DepDir\error-codes.ps1" - -# Source progress indicators -. "$DepDir\progress-indicator.ps1" - -# Source interactive prompts -. "$DepDir\prompt.ps1" - -# --- Color/Format Functions --- -function Write-ErrorMsg { - param([string]$Message) - Write-Host "" - Write-Host "=============================================" -ForegroundColor Red - Write-Host " ERROR" -ForegroundColor Red - Write-Host "=============================================" -ForegroundColor Red - Write-Host "" - Write-Host $Message -ForegroundColor Red - Write-Host "" -} - -# Calculate Levenshtein distance between two strings -function Get-LevenshteinDistance { - param( - [string]$a, - [string]$b - ) - - $lenA = $a.Length - $lenB = $b.Length - - if ($lenA -eq 0) { return $lenB } - if ($lenB -eq 0) { return $lenA } - - # Initialize matrix - $matrix = New-Object 'int[,]' ($lenB + 1), ($lenA + 1) - - # Initialize first row and column - for ($i = 0; $i -le $lenB; $i++) { - $matrix[$i, 0] = $i - } - for ($j = 0; $j -le $lenA; $j++) { - $matrix[0, $j] = $j - } - - # Fill matrix - for ($i = 1; $i -le $lenB; $i++) { - for ($j = 1; $j -le $lenA; $j++) { - if ($a[$j - 1] -eq $b[$i - 1]) { - $matrix[$i, $j] = $matrix[$i - 1, $j - 1] - } else { - $sub = $matrix[$i - 1, $j - 1] - $ins = $matrix[$i, $j - 1] - $del = $matrix[$i - 1, $j] - $min = [Math]::Min([Math]::Min($sub, $ins), $del) - $matrix[$i, $j] = $min + 1 - } - } - } - - return $matrix[$lenB, $lenA] -} - -# Find similar strings using fuzzy matching -function Find-SimilarStrings { - param( - [string]$Target, - [string[]]$Candidates, - [int]$MaxDistance = 2 - ) - - $targetLower = $Target.ToLower() - $matches = @() - - foreach ($candidate in $Candidates) { - $candidateLower = $candidate.ToLower() - $distance = Get-LevenshteinDistance $targetLower $candidateLower - - if ($distance -le $MaxDistance -and $distance -gt 0) { - $matches += [PSCustomObject]@{ - Name = $candidate - Distance = $distance - } - } - } - - # Sort by distance and return top 3 - return $matches | Sort-Object Distance | Select-Object -First 3 | ForEach-Object { $_.Name } -} - -# Enhanced error message with error codes -function Show-EnhancedError { - param( - [string]$ErrorCode, - [string]$ShortMsg, - [string]$Context = "", - [string]$Suggestions = "" - ) - - Write-Host "" - Write-Host "[X] $ShortMsg" -ForegroundColor Red - Write-Host "" - - if ($Context) { - Write-Host $Context - Write-Host "" - } - - if ($Suggestions) { - Write-Host "Solutions:" -ForegroundColor Yellow - Write-Host $Suggestions - Write-Host "" - } - - Write-Host "Error: $ErrorCode" -ForegroundColor Yellow - Write-Host (Get-ErrorDocUrl $ErrorCode) -ForegroundColor Yellow - Write-Host "" -} - -function Write-ColoredText { - param( - [string]$Text, - [string]$Color = "White", - [switch]$NoNewline - ) - - $UseColors = $env:FORCE_COLOR -or ([Console]::IsOutputRedirected -eq $false -and -not $env:NO_COLOR) - - if ($UseColors -and $Color) { - if ($NoNewline) { - Write-Host $Text -ForegroundColor $Color -NoNewline - } else { - Write-Host $Text -ForegroundColor $Color - } - } else { - if ($NoNewline) { - Write-Host $Text -NoNewline - } else { - Write-Host $Text - } - } -} - -# --- Claude CLI Detection Logic --- - -function Find-ClaudeCli { - if ($env:CCS_CLAUDE_PATH) { - return $env:CCS_CLAUDE_PATH - } else { - return "claude" - } -} - -function Show-ClaudeNotFoundError { - $Message = "Claude CLI not found in PATH" + "`n`n" + - "CCS requires Claude CLI to be installed and available in your PATH." + "`n`n" + - "Solutions:" + "`n" + - " 1. Install Claude CLI:" + "`n" + - " https://docs.claude.com/en/docs/claude-code/installation" + "`n`n" + - " 2. Verify installation:" + "`n" + - " Get-Command claude" + "`n`n" + - " 3. If installed but not in PATH, add it:" + "`n" + - " # Find Claude installation" + "`n" + - " where.exe claude" + "`n`n" + - " # Or set custom path" + "`n" + - " `$env:CCS_CLAUDE_PATH = 'C:\path\to\claude.exe'" + "`n`n" + - "Restart your terminal after installation." - - Write-ErrorMsg $Message -} - -function Show-Help { - $UseColors = $env:FORCE_COLOR -or ([Console]::IsOutputRedirected -eq $false -and -not $env:NO_COLOR) - - # Helper for colored output - function Write-ColorLine { - param([string]$Text, [string]$Color = "White") - if ($UseColors) { Write-Host $Text -ForegroundColor $Color } - else { Write-Host $Text } - } - - Write-ColorLine "CCS (Claude Code Switch) - Instant profile switching for Claude CLI" "White" - Write-Host "" - - Write-ColorLine "Usage:" "Cyan" - Write-ColorLine " ccs [profile] [claude-args...]" "Yellow" - Write-ColorLine " ccs [flags]" "Yellow" - Write-Host "" - - Write-ColorLine "Description:" "Cyan" - Write-Host " Switch between multiple Claude accounts and alternative models" - Write-Host " (GLM, Kimi) instantly. Run different Claude CLI sessions concurrently" - Write-Host " with auto-recovery. Zero downtime." - Write-Host "" - - Write-ColorLine "Model Switching:" "Cyan" - Write-ColorLine " ccs Use default Claude account" "Yellow" - Write-ColorLine " ccs glm Switch to GLM 4.6 model" "Yellow" - Write-ColorLine " ccs glmt Switch to GLM with thinking mode" "Yellow" - Write-ColorLine " ccs glmt --verbose Enable debug logging" "Yellow" - Write-ColorLine " ccs kimi Switch to Kimi for Coding" "Yellow" - Write-ColorLine " ccs glm 'debug this code' Use GLM and run command" "Yellow" - Write-Host "" - - Write-ColorLine "Account Management:" "Cyan" - Write-ColorLine " ccs auth --help Run multiple Claude accounts concurrently" "Yellow" - Write-Host "" - - Write-ColorLine "Delegation (inside Claude Code CLI):" "Cyan" - Write-ColorLine " /ccs `"task`" Delegate task (auto-selects best profile)" "Yellow" - Write-ColorLine " /ccs --glm `"task`" Force GLM-4.6 for simple tasks" "Yellow" - Write-ColorLine " /ccs --kimi `"task`" Force Kimi for long context" "Yellow" - Write-ColorLine " /ccs:continue `"follow-up`" Continue last delegation session" "Yellow" - Write-Host " Save tokens by delegating simple tasks to cost-optimized models" - Write-Host "" - - Write-ColorLine "Diagnostics:" "Cyan" - Write-ColorLine " ccs doctor Run health check and diagnostics" "Yellow" - Write-ColorLine " ccs sync Sync delegation commands and skills" "Yellow" - Write-ColorLine " ccs update Update CCS to latest version" "Yellow" - Write-Host "" - - Write-ColorLine "Flags:" "Cyan" - Write-ColorLine " -h, --help Show this help message" "Yellow" - Write-ColorLine " -v, --version Show version and installation info" "Yellow" - Write-ColorLine " -sc, --shell-completion Install shell auto-completion" "Yellow" - Write-Host "" - - Write-ColorLine "Configuration:" "Cyan" - Write-Host " Config File: ~/.ccs/config.json" - Write-Host " Profiles: ~/.ccs/profiles.json" - Write-Host " Instances: ~/.ccs/instances/" - Write-Host " Settings: ~/.ccs/*.settings.json" - Write-Host " Environment: CCS_CONFIG (override config path)" - Write-Host "" - - Write-ColorLine "Shared Data:" "Cyan" - Write-Host " Commands: ~/.ccs/shared/commands/" - Write-Host " Skills: ~/.ccs/shared/skills/" - Write-Host " Agents: ~/.ccs/shared/agents/" - Write-Host " Plugins: ~/.ccs/shared/plugins/" - Write-Host " Note: Commands, skills, agents, and plugins are symlinked across all profiles" - Write-Host "" - - Write-ColorLine "Examples:" "Cyan" - Write-ColorLine " `$ ccs # Use default account" "Yellow" - Write-ColorLine " `$ ccs glm `"implement API`" # Cost-optimized model" "Yellow" - Write-Host "" - Write-ColorLine " For more: https://github.com/kaitranntt/ccs/blob/main/README.md" "Cyan" - Write-Host "" - - Write-ColorLine "Uninstall:" "Yellow" - Write-Host " npm: npm uninstall -g @kaitranntt/ccs" - Write-Host " macOS/Linux: curl -fsSL ccs.kaitran.ca/uninstall | bash" - Write-Host " Windows: irm ccs.kaitran.ca/uninstall | iex" - Write-Host "" - - Write-ColorLine "Documentation:" "Cyan" - Write-Host " GitHub: https://github.com/kaitranntt/ccs" - Write-Host " Docs: https://github.com/kaitranntt/ccs/blob/main/README.md" - Write-Host " Issues: https://github.com/kaitranntt/ccs/issues" - Write-Host "" - - Write-ColorLine "License: MIT" "Cyan" -} - -function Show-Version { - $UseColors = $env:FORCE_COLOR -or ([Console]::IsOutputRedirected -eq $false -and -not $env:NO_COLOR) - - # Helper for aligned output - function Write-TableLine { - param( - [string]$Label, - [string]$Value, - [string]$Color = "Cyan" - ) - if ($UseColors) { - $PaddedLabel = $Label.PadRight(17) - Write-Host " $PaddedLabel " -ForegroundColor $Color -NoNewline - Write-Host $Value - } else { - $PaddedLabel = $Label.PadRight(17) - Write-Host " $PaddedLabel $Value" - } - } - - # Title - if ($UseColors) { - Write-Host "CCS (Claude Code Switch) v$CcsVersion" -ForegroundColor White - } else { - Write-Host "CCS (Claude Code Switch) v$CcsVersion" - } - Write-Host "" - - # Installation section with table-like formatting - if ($UseColors) { Write-Host "Installation:" -ForegroundColor Cyan } - else { Write-Host "Installation:" } - - # Location - prioritize script location over command location - $ScriptLocation = $MyInvocation.MyCommand.Path - $InstallLocation = (Get-Command ccs -ErrorAction SilentlyContinue).Source - - # Show script location if running from source - if ($ScriptLocation -and (Test-Path $ScriptLocation)) { - Write-TableLine "Location:" $ScriptLocation - } elseif ($InstallLocation) { - Write-TableLine "Location:" $InstallLocation - } else { - Write-TableLine "Location:" "(not found - run from current directory)" - } - - # .ccs/ directory location - Write-TableLine "CCS Directory:" "$env:USERPROFILE\.ccs\" - - # Config path - Write-TableLine "Config:" $ConfigFile - - # Profiles.json location - Write-TableLine "Profiles:" $ProfilesJson - - # Delegation status - check multiple indicators - $DelegationConfigured = $false - $ReadyProfiles = @() - - # Check for delegation-sessions.json (primary indicator) - $DelegationSessions = "$env:USERPROFILE\.ccs\delegation-sessions.json" - if (Test-Path $DelegationSessions) { - $DelegationConfigured = $true - } - - # Check for profiles with valid API keys (secondary indicator) - foreach ($profile in @("glm", "kimi")) { - $SettingsFile = "$env:USERPROFILE\.ccs\$profile.settings.json" - if (Test-Path $SettingsFile) { - try { - $Settings = Get-Content $SettingsFile -Raw | ConvertFrom-Json - $ApiKey = $Settings.env.ANTHROPIC_AUTH_TOKEN - if ($ApiKey -and $ApiKey -notmatch "YOUR_.*_API_KEY_HERE" -and $ApiKey -notmatch "sk-test.*") { - $ReadyProfiles += $profile - $DelegationConfigured = $true - } - } catch { } - } - } - - if ($DelegationConfigured) { - Write-TableLine "Delegation:" "Enabled" - } else { - Write-TableLine "Delegation:" "Not configured" - } - - Write-Host "" - - # Ready Profiles section - make it more prominent - if ($ReadyProfiles.Count -gt 0) { - if ($UseColors) { Write-Host "Delegation Ready:" -ForegroundColor Cyan } - else { Write-Host "Delegation Ready:" } - - $ReadyProfilesStr = $ReadyProfiles -join ", " - if ($UseColors) { - Write-Host " ✓ " -ForegroundColor Yellow -NoNewline - Write-Host "$ReadyProfilesStr profiles are ready for delegation" - } else { - Write-Host " ! $ReadyProfilesStr profiles are ready for delegation" - } - Write-Host "" - } elseif ($DelegationConfigured) { - if ($UseColors) { Write-Host "Delegation Ready:" -ForegroundColor Cyan } - else { Write-Host "Delegation Ready:" } - - if ($UseColors) { - Write-Host " ! " -ForegroundColor Yellow -NoNewline - Write-Host "Delegation configured but no valid API keys found" - } else { - Write-Host " ! Delegation configured but no valid API keys found" - } - Write-Host "" - } - - # Documentation - if ($UseColors) { - Write-Host "Documentation: https://github.com/kaitranntt/ccs" -ForegroundColor Cyan - Write-Host "License: MIT" -ForegroundColor Cyan - } else { - Write-Host "Documentation: https://github.com/kaitranntt/ccs" - Write-Host "License: MIT" - } - Write-Host "" - - if ($UseColors) { - Write-Host "Run 'ccs --help' for usage information" -ForegroundColor Yellow - } else { - Write-Host "Run 'ccs --help' for usage information" - } -} - -# --- Auto-Recovery Functions --- - -function Ensure-CcsDirectory { - if (Test-Path "$env:USERPROFILE\.ccs") { return $true } - - try { - New-Item -ItemType Directory -Path "$env:USERPROFILE\.ccs" -Force | Out-Null - Write-Host "[i] Auto-recovery: Created ~/.ccs/ directory" - return $true - } catch { - Write-ErrorMsg "Cannot create ~/.ccs/ directory. Check permissions." - return $false - } -} - -function Ensure-ConfigJson { - $ConfigFile = "$env:USERPROFILE\.ccs\config.json" - - # Check if exists and valid - if (Test-Path $ConfigFile) { - try { - Get-Content $ConfigFile -Raw | ConvertFrom-Json | Out-Null - return $true - } catch { - # Corrupted - backup and recreate - $BackupFile = "$ConfigFile.backup.$(Get-Date -Format 'yyyyMMddHHmmss')" - Move-Item $ConfigFile $BackupFile -Force - Write-Host "[i] Auto-recovery: Backed up corrupted config.json" - } - } - - # Create default config - $DefaultConfig = @{ - profiles = @{ - glm = "~/.ccs/glm.settings.json" - kimi = "~/.ccs/kimi.settings.json" - default = "~/.claude/settings.json" - } - } - - $DefaultConfig | ConvertTo-Json -Depth 10 | Set-Content $ConfigFile - Write-Host "[i] Auto-recovery: Created ~/.ccs/config.json" - return $true -} - -function Ensure-ClaudeSettings { - $ClaudeDir = "$env:USERPROFILE\.claude" - $SettingsFile = "$ClaudeDir\settings.json" - - # Create ~/.claude/ if missing - if (-not (Test-Path $ClaudeDir)) { - New-Item -ItemType Directory -Path $ClaudeDir -Force | Out-Null - Write-Host "[i] Auto-recovery: Created ~/.claude/ directory" - } - - # Create settings.json if missing - if (-not (Test-Path $SettingsFile)) { - '{}' | Set-Content $SettingsFile - Write-Host "[i] Auto-recovery: Created ~/.claude/settings.json" - Write-Host "[i] Next step: Run 'claude /login' to authenticate" - return $true - } - - return $false -} - -function Invoke-AutoRecovery { - if (-not (Ensure-CcsDirectory)) { return $false } - if (-not (Ensure-ConfigJson)) { return $false } - Ensure-ClaudeSettings | Out-Null - return $true -} - -# --- Profile Registry Functions (Phase 4) --- - -function Initialize-ProfilesJson { - if (Test-Path $ProfilesJson) { return } - - $InitData = @{ - version = "2.0.0" - profiles = @{} - default = $null - } - - $InitData | ConvertTo-Json -Depth 10 | Set-Content $ProfilesJson - - # Set file permissions (user-only) - $Acl = Get-Acl $ProfilesJson - $Acl.SetAccessRuleProtection($true, $false) - $Acl.Access | ForEach-Object { $Acl.RemoveAccessRule($_) | Out-Null } - $CurrentUser = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name - $Rule = New-Object System.Security.AccessControl.FileSystemAccessRule( - $CurrentUser, "FullControl", "Allow" - ) - $Acl.AddAccessRule($Rule) - Set-Acl -Path $ProfilesJson -AclObject $Acl -} - -function Read-ProfilesJson { - Initialize-ProfilesJson - Get-Content $ProfilesJson -Raw | ConvertFrom-Json -} - -function Write-ProfilesJson { - param([PSCustomObject]$Data) - - $TempFile = "$ProfilesJson.tmp" - - try { - $Data | ConvertTo-Json -Depth 10 | Set-Content $TempFile - Move-Item $TempFile $ProfilesJson -Force - } catch { - if (Test-Path $TempFile) { Remove-Item $TempFile -Force } - throw "Failed to write profiles registry: $_" - } -} - -function Test-ProfileExists { - param([string]$ProfileName) - - Initialize-ProfilesJson - $Data = Read-ProfilesJson - return $Data.profiles.PSObject.Properties.Name -contains $ProfileName -} - -function Register-Profile { - param([string]$ProfileName) - - if (Test-ProfileExists $ProfileName) { - throw "Profile already exists: $ProfileName" - } - - $Data = Read-ProfilesJson - $Timestamp = (Get-Date).ToUniversalTime().ToString("o") - - $Data.profiles | Add-Member -NotePropertyName $ProfileName -NotePropertyValue ([PSCustomObject]@{ - type = "account" - created = $Timestamp - last_used = $null - }) - - # Note: No longer auto-set as default - # Users must explicitly run: ccs auth default [profile] - # Default always stays on implicit 'default' profile (uses ~/.claude/) - - Write-ProfilesJson $Data -} - -function Unregister-Profile { - param([string]$ProfileName) - - if (-not (Test-ProfileExists $ProfileName)) { return } # Idempotent - - $Data = Read-ProfilesJson - - # Remove profile - $Data.profiles.PSObject.Properties.Remove($ProfileName) - - # Update default if it was the deleted profile - if ($Data.default -eq $ProfileName) { - $Remaining = $Data.profiles.PSObject.Properties.Name - $Data.default = if ($Remaining.Count -gt 0) { $Remaining[0] } else { $null } - } - - Write-ProfilesJson $Data -} - -function Update-ProfileTimestamp { - param([string]$ProfileName) - - if (-not (Test-ProfileExists $ProfileName)) { return } # Silent fail - - $Data = Read-ProfilesJson - $Timestamp = (Get-Date).ToUniversalTime().ToString("o") - - $Data.profiles.$ProfileName.last_used = $Timestamp - - Write-ProfilesJson $Data -} - -function Get-DefaultProfile { - Initialize-ProfilesJson - $Data = Read-ProfilesJson - return $Data.default -} - -function Set-DefaultProfile { - param([string]$ProfileName) - - if (-not (Test-ProfileExists $ProfileName)) { - throw "Profile not found: $ProfileName" - } - - $Data = Read-ProfilesJson - $Data.default = $ProfileName - - Write-ProfilesJson $Data -} - -# --- Instance Management Functions (Phase 2) --- - -function Get-SanitizedProfileName { - param([string]$Name) - - # Replace unsafe chars, lowercase - return $Name.ToLower() -replace '[^a-z0-9_-]', '-' -} - -function Set-InstancePermissions { - param([string]$Path) - - # Get current user - $CurrentUser = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name - - # Create new ACL with inheritance disabled - $Acl = Get-Acl $Path - $Acl.SetAccessRuleProtection($true, $false) # Disable inheritance - $Acl.Access | ForEach-Object { $Acl.RemoveAccessRule($_) | Out-Null } - - # Grant full control to current user only - $Rule = New-Object System.Security.AccessControl.FileSystemAccessRule( - $CurrentUser, "FullControl", "ContainerInherit,ObjectInherit", "None", "Allow" - ) - $Acl.AddAccessRule($Rule) - - Set-Acl -Path $Path -AclObject $Acl -} - -function Link-SharedDirectories { - param([string]$InstancePath) - - $SharedDir = "$env:USERPROFILE\.ccs\shared" - - # Ensure shared directories exist - @('commands', 'skills', 'agents', 'plugins') | ForEach-Object { - $Dir = Join-Path $SharedDir $_ - if (-not (Test-Path $Dir)) { - New-Item -ItemType Directory -Path $Dir -Force | Out-Null - } - } - - # Create symlinks (requires Windows Developer Mode or admin) - @('commands', 'skills', 'agents', 'plugins') | ForEach-Object { - $LinkPath = Join-Path $InstancePath $_ - $TargetPath = Join-Path $SharedDir $_ - - # Remove existing directory/link - if (Test-Path $LinkPath) { - Remove-Item $LinkPath -Recurse -Force - } - - # Try creating symlink (requires privileges) - try { - New-Item -ItemType SymbolicLink -Path $LinkPath -Target $TargetPath -Force | Out-Null - } catch { - # Fallback: Copy directory instead (suboptimal but functional) - Copy-Item $TargetPath -Destination $LinkPath -Recurse -Force - Write-Host "[!] Symlink failed for $_, copied instead (enable Developer Mode)" -ForegroundColor Yellow - } - } -} - -function Migrate-SharedStructure { - $SharedDir = "$env:USERPROFILE\.ccs\shared" - - # Check if migration is needed (shared dirs exist but are empty) - if (Test-Path $SharedDir) { - $NeedsMigration = $false - foreach ($Dir in @('commands', 'skills', 'agents', 'plugins')) { - $DirPath = Join-Path $SharedDir $Dir - if (-not (Test-Path $DirPath) -or (Get-ChildItem $DirPath -ErrorAction SilentlyContinue).Count -eq 0) { - $NeedsMigration = $true - break - } - } - - if (-not $NeedsMigration) { return } - } - - # Create shared directory - @('commands', 'skills', 'agents', 'plugins') | ForEach-Object { - $Dir = Join-Path $SharedDir $_ - New-Item -ItemType Directory -Path $Dir -Force | Out-Null - } - - # Copy from ~/.claude/ (actual Claude CLI directory) - $ClaudeDir = "$env:USERPROFILE\.claude" - - if (Test-Path $ClaudeDir) { - # Copy commands to shared (if exists) - $CommandsPath = Join-Path $ClaudeDir "commands" - if (Test-Path $CommandsPath) { - Copy-Item "$CommandsPath\*" -Destination "$SharedDir\commands\" -Recurse -ErrorAction SilentlyContinue - } - - # Copy skills to shared (if exists) - $SkillsPath = Join-Path $ClaudeDir "skills" - if (Test-Path $SkillsPath) { - Copy-Item "$SkillsPath\*" -Destination "$SharedDir\skills\" -Recurse -ErrorAction SilentlyContinue - } - - # Copy agents to shared (if exists) - $AgentsPath = Join-Path $ClaudeDir "agents" - if (Test-Path $AgentsPath) { - Copy-Item "$AgentsPath\*" -Destination "$SharedDir\agents\" -Recurse -ErrorAction SilentlyContinue - } - - # Copy plugins to shared (if exists) - $PluginsPath = Join-Path $ClaudeDir "plugins" - if (Test-Path $PluginsPath) { - Copy-Item "$PluginsPath\*" -Destination "$SharedDir\plugins\" -Recurse -ErrorAction SilentlyContinue - } - } - - # Update all instances to use symlinks - if (Test-Path $InstancesDir) { - Get-ChildItem $InstancesDir -Directory | ForEach-Object { - Link-SharedDirectories $_.FullName - } - } - - Write-Host "[OK] Migrated to shared structure" -} - -function Copy-GlobalConfigs { - param([string]$InstancePath) - - $GlobalClaude = "$env:USERPROFILE\.claude" - - # Copy settings.json only (commands/skills are now symlinked to shared/) - $GlobalSettings = Join-Path $GlobalClaude "settings.json" - if (Test-Path $GlobalSettings) { - Copy-Item $GlobalSettings -Destination (Join-Path $InstancePath "settings.json") -ErrorAction SilentlyContinue - } -} - -function Initialize-Instance { - param([string]$InstancePath) - - # Create base directory with user-only ACL - New-Item -ItemType Directory -Path $InstancePath -Force | Out-Null - Set-InstancePermissions $InstancePath - - # Create subdirectories (profile-specific only) - $Subdirs = @('session-env', 'todos', 'logs', 'file-history', - 'shell-snapshots', 'debug', '.anthropic') - - foreach ($Dir in $Subdirs) { - $DirPath = Join-Path $InstancePath $Dir - New-Item -ItemType Directory -Path $DirPath -Force | Out-Null - } - - # Symlink shared directories - Link-SharedDirectories $InstancePath - - # Copy global configs - Copy-GlobalConfigs $InstancePath -} - -function Validate-Instance { - param([string]$InstancePath) - - $RequiredDirs = @('session-env', 'todos', 'logs', 'file-history', - 'shell-snapshots', 'debug', '.anthropic') - - foreach ($Dir in $RequiredDirs) { - $DirPath = Join-Path $InstancePath $Dir - if (-not (Test-Path $DirPath)) { - New-Item -ItemType Directory -Path $DirPath -Force | Out-Null - } - } -} - -function Ensure-Instance { - param([string]$ProfileName) - - $SafeName = Get-SanitizedProfileName $ProfileName - $InstancePath = "$InstancesDir\$SafeName" - - if (-not (Test-Path $InstancePath)) { - Initialize-Instance $InstancePath - } - - Validate-Instance $InstancePath - - return $InstancePath -} - -# --- Profile Detection Logic (Phase 1) --- - -function Get-AllProfileNames { - $names = [System.Collections.ArrayList]::new() - - # Settings-based profiles - if (Test-Path $ConfigFile) { - try { - $Config = Get-Content $ConfigFile -Raw | ConvertFrom-Json - $Config.profiles.PSObject.Properties.Name | ForEach-Object { - [void]$names.Add($_) - } - } catch {} - } - - # Account-based profiles - if (Test-Path $ProfilesJson) { - try { - $Profiles = Read-ProfilesJson - $Profiles.profiles.PSObject.Properties.Name | ForEach-Object { - [void]$names.Add($_) - } - } catch {} - } - - return $names.ToArray() -} - -function Get-AvailableProfiles { - $lines = @() - - # Settings-based profiles - if (Test-Path $ConfigFile) { - try { - $Config = Get-Content $ConfigFile -Raw | ConvertFrom-Json - $SettingsProfiles = $Config.profiles.PSObject.Properties.Name - - if ($SettingsProfiles.Count -gt 0) { - $lines += "Settings-based profiles (GLM, Kimi, etc):" - foreach ($name in $SettingsProfiles) { - $lines += " * $name" - } - } - } catch {} - } - - # Account-based profiles - if (Test-Path $ProfilesJson) { - try { - $Profiles = Read-ProfilesJson - $AccountProfiles = $Profiles.profiles.PSObject.Properties.Name - - if ($AccountProfiles.Count -gt 0) { - $lines += "Account-based profiles:" - foreach ($name in $AccountProfiles) { - $IsDefault = if ($name -eq $Profiles.default) { " [DEFAULT]" } else { "" } - $lines += " * $name$IsDefault" - } - } - } catch {} - } - - if ($lines.Count -eq 0) { - return @(" (no profiles configured)", " Run 'ccs auth create [profile]' to create your first account profile.") - } else { - return $lines - } -} - -function Get-ProfileType { - param([string]$ProfileName) - - # Special case: 'default' resolves to default profile - if ($ProfileName -eq "default") { - # Check account-based default first - if (Test-Path $ProfilesJson) { - $Profiles = Read-ProfilesJson - $DefaultAccount = $Profiles.default - - if ($DefaultAccount -and (Test-ProfileExists $DefaultAccount)) { - return @{ - Type = "account" - Name = $DefaultAccount - } - } - } - - # Check settings-based default - if (Test-Path $ConfigFile) { - $Config = Get-Content $ConfigFile -Raw | ConvertFrom-Json - $DefaultSettings = $Config.profiles.default - - if ($DefaultSettings) { - return @{ - Type = "settings" - Path = $DefaultSettings - Name = "default" - } - } - } - - # No default configured, use Claude's defaults - return @{ - Type = "default" - Name = "default" - } - } - - # Priority 1: Check settings-based profiles (backward compatibility) - if (Test-Path $ConfigFile) { - try { - $Config = Get-Content $ConfigFile -Raw | ConvertFrom-Json - $SettingsPath = $Config.profiles.$ProfileName - - if ($SettingsPath) { - return @{ - Type = "settings" - Path = $SettingsPath - Name = $ProfileName - } - } - } catch {} - } - - # Priority 2: Check account-based profiles - if ((Test-Path $ProfilesJson) -and (Test-ProfileExists $ProfileName)) { - return @{ - Type = "account" - Name = $ProfileName - } - } - - # Not found - return @{ - Type = "error" - } -} - -# --- Sync Command --- - -function Sync-Run { - $CcsClaudeDir = "$env:USERPROFILE\.ccs\.claude" - $UserClaudeDir = "$env:USERPROFILE\.claude" - - Write-Host "Syncing delegation commands and skills to ~/.claude/..." -ForegroundColor Cyan - Write-Host "" - - # Check if source directory exists - if (-not (Test-Path $CcsClaudeDir)) { - Write-Host "[X] CCS .claude/ directory not found at $CcsClaudeDir" -ForegroundColor Red - Write-Host "Reinstall CCS: npm install -g @kaitranntt/ccs --force" - exit 1 - } - - # Create ~/.claude/ if missing - if (-not (Test-Path $UserClaudeDir)) { - Write-Host "[i] Creating ~/.claude/ directory" -ForegroundColor Cyan - New-Item -ItemType Directory -Path $UserClaudeDir -Force | Out-Null - } - - # Items to symlink - $Items = @( - @{ Source = "commands\ccs"; Target = "commands\ccs"; Type = "Directory" } - @{ Source = "skills\ccs-delegation"; Target = "skills\ccs-delegation"; Type = "Directory" } - @{ Source = "agents\ccs-delegator.md"; Target = "agents\ccs-delegator.md"; Type = "File" } - ) - - $Installed = 0 - $Skipped = 0 - - foreach ($Item in $Items) { - $SourcePath = Join-Path $CcsClaudeDir $Item.Source - $TargetPath = Join-Path $UserClaudeDir $Item.Target - $TargetDir = Split-Path -Parent $TargetPath - - # Check source exists - if (-not (Test-Path $SourcePath)) { - Write-Host "[!] Source not found: $($Item.Source), skipping" -ForegroundColor Yellow - continue - } - - # Create parent directory if needed - if (-not (Test-Path $TargetDir)) { - New-Item -ItemType Directory -Path $TargetDir -Force | Out-Null - } - - # Check if already correct symlink - if (Test-Path $TargetPath) { - $ItemInfo = Get-Item $TargetPath -Force - if ($ItemInfo.LinkType -eq "SymbolicLink") { - $LinkTarget = $ItemInfo.Target - if ($LinkTarget -eq $SourcePath) { - $Skipped++ - continue - } - } - - # Backup existing file/directory - $Timestamp = Get-Date -Format "yyyy-MM-dd" - $BackupPath = "$TargetPath.backup-$Timestamp" - $Counter = 1 - - while (Test-Path $BackupPath) { - $BackupPath = "$TargetPath.backup-$Timestamp-$Counter" - $Counter++ - } - - Move-Item -Path $TargetPath -Destination $BackupPath -Force - Write-Host "[i] Backed up existing to $(Split-Path -Leaf $BackupPath)" -ForegroundColor Cyan - } - - # Create symlink - try { - $SymlinkType = if ($Item.Type -eq "Directory") { "Junction" } else { "SymbolicLink" } - New-Item -ItemType $SymlinkType -Path $TargetPath -Target $SourcePath -Force -ErrorAction Stop | Out-Null - Write-Host "[OK] Installed $($Item.Target)" -ForegroundColor Green - $Installed++ - } catch { - $ErrorMsg = "[X] Failed to install $($Item.Target): $($_.Exception.Message)" - Write-Host $ErrorMsg -ForegroundColor Red - if ($_.Exception.Message -match "privilege") { - Write-Host "[i] Run PowerShell as Administrator or enable Developer Mode" -ForegroundColor Yellow - } - } - } - - Write-Host "" - Write-Host "✓ Update complete" -ForegroundColor Green - Write-Host " Installed: $Installed" - Write-Host " Already up-to-date: $Skipped" - Write-Host "" -} - -# --- Update Command --- - -function Update-Run { - Write-Host "" - Write-Host "Checking for updates..." -ForegroundColor Cyan - Write-Host "" - - # Detect installation method - $InstallMethod = "direct" - try { - $NpmList = npm list -g @kaitranntt/ccs 2>&1 - if ($LASTEXITCODE -eq 0) { - $InstallMethod = "npm" - } - } catch { - # npm not available or not installed via npm - } - - # Fetch latest version from appropriate source - $LatestVersion = "" - try { - if ($InstallMethod -eq "npm") { - # Check npm registry for npm installations - $Response = Invoke-RestMethod -Uri "https://registry.npmjs.org/@kaitranntt/ccs/latest" -TimeoutSec 5 - $LatestVersion = $Response.version - } else { - # Check GitHub releases for direct installations - $Response = Invoke-RestMethod -Uri "https://api.github.com/repos/kaitranntt/ccs/releases/latest" -TimeoutSec 5 - $LatestVersion = $Response.tag_name -replace '^v', '' - } - } catch { - Write-Host "[!] Unable to check for updates" -ForegroundColor Yellow - Write-Host "" - Write-Host "Try manually:" - if ($InstallMethod -eq "npm") { - Write-Host " npm install -g @kaitranntt/ccs@latest" -ForegroundColor Yellow - } else { - Write-Host " irm ccs.kaitran.ca/install | iex" -ForegroundColor Yellow - } - Write-Host "" - exit 1 - } - - # Compare versions - if ($LatestVersion -eq $CcsVersion) { - Write-Host "[OK] You are already on the latest version ($CcsVersion)" -ForegroundColor Green - Write-Host "" - exit 0 - } - - # Check if update available - $CurrentParts = $CcsVersion.Split('.') - $LatestParts = $LatestVersion.Split('.') - - $IsNewer = $false - for ($i = 0; $i -lt 3; $i++) { - $Current = [int]$CurrentParts[$i] - $Latest = [int]$LatestParts[$i] - - if ($Latest -gt $Current) { - $IsNewer = $true - break - } elseif ($Latest -lt $Current) { - break - } - } - - if (-not $IsNewer) { - Write-Host "[OK] You are on version $CcsVersion (latest is $LatestVersion)" -ForegroundColor Green - Write-Host "" - exit 0 - } - - Write-Host "[i] Update available: $CcsVersion → $LatestVersion" -ForegroundColor Yellow - Write-Host "" - - # Perform update based on installation method - if ($InstallMethod -eq "npm") { - Write-Host "Updating via npm..." -ForegroundColor Cyan - Write-Host "" - - # Clear npm cache to ensure fresh download - Write-Host "Clearing package cache..." -ForegroundColor Cyan - try { - npm cache clean --force 2>$null - if ($LASTEXITCODE -ne 0) { - Write-Host "[!] Cache clearing failed, proceeding anyway..." -ForegroundColor Yellow - } - } catch { - Write-Host "[!] Cache clearing failed, proceeding anyway..." -ForegroundColor Yellow - } - Write-Host "" - - try { - npm install -g @kaitranntt/ccs@latest - if ($LASTEXITCODE -eq 0) { - Write-Host "" - Write-Host "[OK] Update successful!" -ForegroundColor Green - Write-Host "" - Write-Host "Run ccs --version to verify" -ForegroundColor Yellow - Write-Host "" - exit 0 - } else { - throw "npm install failed" - } - } catch { - Write-Host "" - Write-Host "[X] Update failed" -ForegroundColor Red - Write-Host "" - Write-Host "Try manually:" - Write-Host " npm cache clean --force; npm install -g @kaitranntt/ccs@latest" -ForegroundColor Yellow - Write-Host "" - exit 1 - } - } else { - Write-Host "Updating via installer..." -ForegroundColor Cyan - Write-Host "" - - try { - irm ccs.kaitran.ca/install | iex - Write-Host "" - Write-Host "[OK] Update successful!" -ForegroundColor Green - Write-Host "" - Write-Host "Run ccs --version to verify" -ForegroundColor Yellow - Write-Host "" - exit 0 - } catch { - Write-Host "" - Write-Host "[X] Update failed" -ForegroundColor Red - Write-Host "" - Write-Host "Try manually:" - Write-Host " irm ccs.kaitran.ca/install | iex" -ForegroundColor Yellow - Write-Host "" - exit 1 - } - } -} - -# --- Auth Commands (Phase 3) --- - -function Show-AuthHelp { - Write-Host "" - Write-Host "CCS Concurrent Account Management" -ForegroundColor White - Write-Host "" - Write-Host "Usage:" -ForegroundColor Cyan - Write-Host " ccs auth [command] [options]" -ForegroundColor Yellow - Write-Host "" - Write-Host "Commands:" -ForegroundColor Cyan - Write-Host " create [profile] Create new profile and login" -ForegroundColor Yellow - Write-Host " list List all saved profiles" -ForegroundColor Yellow - Write-Host " show [profile] Show profile details" -ForegroundColor Yellow - Write-Host " remove [profile] Remove saved profile" -ForegroundColor Yellow - Write-Host " default [profile] Set default profile" -ForegroundColor Yellow - Write-Host "" - Write-Host "Examples:" -ForegroundColor Cyan - Write-Host " ccs auth create work # Create & login to work profile" -ForegroundColor Yellow - Write-Host " ccs auth default work # Set work as default" -ForegroundColor Yellow - Write-Host " ccs auth list # List all profiles" -ForegroundColor Yellow - Write-Host ' ccs work "review code" # Use work profile' -ForegroundColor Yellow - Write-Host ' ccs "review code" # Use default profile' -ForegroundColor Yellow - Write-Host "" - Write-Host "Options:" -ForegroundColor Cyan - Write-Host " --force Allow overwriting existing profile (create)" -ForegroundColor Yellow - Write-Host " --yes, -y Skip confirmation prompts (remove)" -ForegroundColor Yellow - Write-Host " --json Output in JSON format (list, show)" -ForegroundColor Yellow - Write-Host " --verbose Show additional details (list)" -ForegroundColor Yellow - Write-Host "" - Write-Host "Note:" -ForegroundColor Cyan - Write-Host " By default, " -NoNewline - Write-Host "ccs" -ForegroundColor Yellow -NoNewline - Write-Host " uses Claude CLI defaults from ~/.claude/" - Write-Host " Use " -NoNewline - Write-Host "ccs auth default [profile]" -ForegroundColor Yellow -NoNewline - Write-Host " to change the default profile." - Write-Host "" -} - -function Invoke-AuthCreate { - param([string[]]$Args) - - $ProfileName = "" - $Force = $false - - foreach ($arg in $Args) { - if ($arg -eq "--force") { - $Force = $true - } elseif ($arg -match '^-') { - Write-ErrorMsg "Unknown option: $arg" - return 1 - } else { - $ProfileName = $arg - } - } - - if (-not $ProfileName) { - Write-ErrorMsg "Profile name is required`n`nUsage: ccs auth create [profile] [--force]" - return 1 - } - - if (-not $Force -and (Test-ProfileExists $ProfileName)) { - Write-ErrorMsg "Profile already exists: $ProfileName`nUse --force to overwrite" - return 1 - } - - # Create instance - Write-Host "[i] Creating profile: $ProfileName" - $InstancePath = Ensure-Instance $ProfileName - Write-Host "[i] Instance directory: $InstancePath" - Write-Host "" - - # Register profile - Register-Profile $ProfileName - - # Launch Claude for login - Write-Host "[i] Starting Claude in isolated instance..." -ForegroundColor Yellow - Write-Host "[i] You will be prompted to login with your account." -ForegroundColor Yellow - Write-Host "" - - $env:CLAUDE_CONFIG_DIR = $InstancePath - $ClaudeCli = Find-ClaudeCli - & $ClaudeCli - - if ($LASTEXITCODE -ne 0) { - Write-ErrorMsg "Login failed or cancelled`nTo retry: ccs auth create $ProfileName --force" - return 1 - } - - Write-Host "" - Write-Host "[OK] Profile created successfully" -ForegroundColor Green - Write-Host "" - Write-Host " Profile: $ProfileName" - Write-Host " Instance: $InstancePath" - Write-Host "" - Write-Host "Usage:" - Write-Host " ccs $ProfileName `"your prompt here`" # Use this specific profile" -ForegroundColor Yellow - Write-Host "" - Write-Host 'To set as default (so you can use just "ccs"):' - Write-Host " ccs auth default $ProfileName" -ForegroundColor Yellow - Write-Host "" -} - -function Invoke-AuthList { - param([string[]]$Args) - - $Verbose = $Args -contains "--verbose" - $Json = $Args -contains "--json" - - if (-not (Test-Path $ProfilesJson)) { - if ($Json) { - Write-Output "{`"version`":`"$CcsVersion`",`"profiles`":[]}" - return - } - Write-Host "No account profiles found" -ForegroundColor Yellow - Write-Host "" - Write-Host "To create your first profile:" - Write-Host " ccs auth create [profile]" -ForegroundColor Yellow - return - } - - $Data = Read-ProfilesJson - $Profiles = $Data.profiles.PSObject.Properties.Name - - if ($Profiles.Count -eq 0) { - if ($Json) { - Write-Output "{`"version`":`"$CcsVersion`",`"profiles`":[]}" - return - } - Write-Host "No account profiles found" -ForegroundColor Yellow - return - } - - # JSON output mode - if ($Json) { - $ProfilesList = @() - foreach ($profile in $Profiles) { - $IsDefault = $profile -eq $Data.default - $Type = $Data.profiles.$profile.type - if (-not $Type) { $Type = "account" } - $Created = $Data.profiles.$profile.created - $LastUsed = $Data.profiles.$profile.last_used - $InstancePath = "$InstancesDir\$(Get-SanitizedProfileName $profile)" - - $ProfilesList += @{ - name = $profile - type = $Type - is_default = $IsDefault - created = $Created - last_used = $LastUsed - instance_path = $InstancePath - } - } - - $Output = @{ - version = $CcsVersion - profiles = $ProfilesList - } - - Write-Output ($Output | ConvertTo-Json -Depth 10) - return - } - - # Human-readable output - Write-Host "Saved Account Profiles:" -ForegroundColor White - Write-Host "" - - foreach ($profile in $Profiles) { - $IsDefault = $profile -eq $Data.default - - if ($IsDefault) { - Write-Host "[*] " -ForegroundColor Green -NoNewline - Write-Host $profile -ForegroundColor Cyan -NoNewline - Write-Host " (default)" -ForegroundColor Green - } else { - Write-Host "[ ] " -NoNewline - Write-Host $profile -ForegroundColor Cyan - } - - $Type = $Data.profiles.$profile.type - Write-Host " Type: $Type" - - if ($Verbose) { - $Created = $Data.profiles.$profile.created - $LastUsed = $Data.profiles.$profile.last_used - if (-not $LastUsed) { $LastUsed = "Never" } - Write-Host " Created: $Created" - Write-Host " Last used: $LastUsed" - } - - Write-Host "" - } -} - -function Invoke-AuthShow { - param([string[]]$Args) - - $ProfileName = "" - $Json = $false - - # Parse arguments - foreach ($arg in $Args) { - if ($arg -eq "--json") { - $Json = $true - } elseif ($arg -like "-*") { - Write-ErrorMsg "Unknown option: $arg" - return 1 - } else { - $ProfileName = $arg - } - } - - if (-not $ProfileName) { - Write-ErrorMsg "Profile name is required" - Write-Host "" - Write-Host "Usage: " -NoNewline - Write-Host "ccs auth show [profile] [--json]" -ForegroundColor Yellow - return 1 - } - - if (-not (Test-ProfileExists $ProfileName)) { - Write-ErrorMsg "Profile not found: $ProfileName" - return 1 - } - - $Data = Read-ProfilesJson - $IsDefault = $ProfileName -eq $Data.default - - $Type = $Data.profiles.$ProfileName.type - if (-not $Type) { $Type = "account" } - $Created = $Data.profiles.$ProfileName.created - $LastUsed = $Data.profiles.$ProfileName.last_used - $InstancePath = "$InstancesDir\$(Get-SanitizedProfileName $ProfileName)" - - # Count sessions - $SessionCount = 0 - if (Test-Path "$InstancePath\session-env") { - $SessionFiles = Get-ChildItem "$InstancePath\session-env" -Filter "*.json" -ErrorAction SilentlyContinue - $SessionCount = $SessionFiles.Count - } - - # JSON output mode - if ($Json) { - $Output = @{ - name = $ProfileName - type = $Type - is_default = $IsDefault - created = $Created - last_used = $LastUsed - instance_path = $InstancePath - session_count = $SessionCount - } - - Write-Output ($Output | ConvertTo-Json -Depth 10) - return - } - - # Human-readable output - Write-Host "Profile: $ProfileName" -ForegroundColor White - Write-Host "" - - Write-Host " Type: $Type" - Write-Host " Default: $(if ($IsDefault) { 'Yes' } else { 'No' })" - Write-Host " Instance: $InstancePath" - Write-Host " Created: $Created" - if ($LastUsed) { - Write-Host " Last used: $LastUsed" - } else { - Write-Host " Last used: Never" - } - Write-Host "" -} - -function Invoke-AuthRemove { - param([string[]]$Args) - - $ProfileName = "" - - # Parse arguments - foreach ($arg in $Args) { - if ($arg -eq "--yes" -or $arg -eq "-y") { - $env:CCS_YES = "1" # Auto-confirm - } elseif ($arg -like "-*") { - Write-ErrorMsg "Unknown option: $arg" - return 1 - } else { - $ProfileName = $arg - } - } - - if (-not $ProfileName) { - Write-ErrorMsg "Profile name is required" - Write-Host "" - Write-Host "Usage: " -NoNewline - Write-Host "ccs auth remove [profile] [--yes]" -ForegroundColor Yellow - return 1 - } - - if (-not (Test-ProfileExists $ProfileName)) { - Write-ErrorMsg "Profile not found: $ProfileName" - return 1 - } - - # Get instance path and session count for impact display - $InstancePath = "$InstancesDir\$(Get-SanitizedProfileName $ProfileName)" - $SessionCount = 0 - - if (Test-Path "$InstancePath\session-env") { - $SessionFiles = Get-ChildItem "$InstancePath\session-env" -Filter "*.json" -ErrorAction SilentlyContinue - $SessionCount = $SessionFiles.Count - } - - # Display impact - Write-Host "" - Write-Host "Profile '" -NoNewline - Write-Host $ProfileName -ForegroundColor Cyan -NoNewline - Write-Host "' will be permanently deleted." - Write-Host " Instance path: $InstancePath" - Write-Host " Sessions: $SessionCount conversation$(if ($SessionCount -ne 1) { 's' } else { '' })" - Write-Host "" - - # Interactive confirmation (or --yes flag) - $Confirmed = Confirm-Action "Delete this profile?" "No" - - if (-not $Confirmed) { - Write-Host "[i] Cancelled" - return 0 - } - - # Delete instance directory - if (Test-Path $InstancePath) { - Remove-Item $InstancePath -Recurse -Force - } - - # Remove from registry - Unregister-Profile $ProfileName - - Write-Host "[OK] Profile removed successfully" -ForegroundColor Green - Write-Host " Profile: $ProfileName" - Write-Host "" -} - -function Invoke-AuthDefault { - param([string[]]$Args) - - $ProfileName = $Args[0] - - if (-not $ProfileName) { - Write-ErrorMsg "Profile name is required`nUsage: ccs auth default [profile]" - return 1 - } - - if (-not (Test-ProfileExists $ProfileName)) { - Write-ErrorMsg "Profile not found: $ProfileName" - return 1 - } - - Set-DefaultProfile $ProfileName - - Write-Host "[OK] Default profile set" -ForegroundColor Green - Write-Host " Profile: $ProfileName" - Write-Host "" - Write-Host "Now you can use:" - Write-Host " ccs `"your prompt`" # Uses $ProfileName profile" -ForegroundColor Yellow - Write-Host "" -} - -function Invoke-AuthCommands { - param([string[]]$Args) - - $Subcommand = $Args[0] - $SubArgs = if ($Args.Count -gt 1) { $Args[1..($Args.Count-1)] } else { @() } - - switch ($Subcommand) { - "create" { Invoke-AuthCreate $SubArgs } - "list" { Invoke-AuthList $SubArgs } - "show" { Invoke-AuthShow $SubArgs } - "remove" { Invoke-AuthRemove $SubArgs } - "default" { Invoke-AuthDefault $SubArgs } - default { Show-AuthHelp } - } -} - -function Install-ShellCompletion { - param([string[]]$Args) - - Write-Host "" - Write-Host "Shell Completion Installer" -ForegroundColor Yellow - Write-Host "" - - # Ensure completion directory exists - $CompletionsDir = Join-Path $env:USERPROFILE ".ccs\completions" - if (-not (Test-Path $CompletionsDir)) { - New-Item -ItemType Directory -Path $CompletionsDir -Force | Out-Null - } - - # Ensure completion file exists - $CompletionFile = Join-Path $CompletionsDir "ccs.ps1" - if (-not (Test-Path $CompletionFile)) { - Write-Host "[X] Completion file not found. Please reinstall CCS." -ForegroundColor Red - Write-Host "" - exit 1 - } - - # Get PowerShell profile path - $ProfilePath = $PROFILE - $ProfileDir = Split-Path $ProfilePath -Parent - - # Create profile directory if it doesn't exist - if (-not (Test-Path $ProfileDir)) { - New-Item -ItemType Directory -Path $ProfileDir -Force | Out-Null - } - - # Comment marker for easy identification - $Marker = "# CCS shell completion" - $SourceCmd = ". `"$CompletionFile`"" - - # Check if already installed - if (Test-Path $ProfilePath) { - $Content = Get-Content $ProfilePath -Raw -ErrorAction SilentlyContinue - if ($Content -and $Content.Contains($Marker)) { - Write-Host "[OK] Shell completion already installed" -ForegroundColor Green - Write-Host "" - return 0 - } - } - - # Append to PowerShell profile - $Block = "`n$Marker`n$SourceCmd`n" - Add-Content -Path $ProfilePath -Value $Block -NoNewline - - Write-Host "[OK] Shell completion installed successfully!" -ForegroundColor Green - Write-Host "" - Write-Host "Added to $ProfilePath" - Write-Host "" - Write-Host "To activate:" -ForegroundColor Cyan - Write-Host " . `$PROFILE" - Write-Host "" - - return 0 -} - -# --- Main Execution Logic --- - -# Special case: version command (check BEFORE profile detection) -if ($Version) { - Show-Version - exit 0 -} elseif ($RemainingArgs.Count -gt 0) { - $FirstArg = $RemainingArgs[0] - if ($FirstArg -eq "version" -or $FirstArg -eq "--version" -or $FirstArg -eq "-v") { - Show-Version - exit 0 - } -} - -# Special case: help command (check BEFORE profile detection) -if ($Help) { - Show-Help - exit 0 -} elseif ($RemainingArgs.Count -gt 0) { - $FirstArg = $RemainingArgs[0] - if ($FirstArg -eq "--help" -or $FirstArg -eq "-h" -or $FirstArg -eq "help") { - Show-Help - exit 0 - } -} - -# Special case: shell completion installer -if ($RemainingArgs.Count -gt 0 -and ($RemainingArgs[0] -eq "--shell-completion" -or $RemainingArgs[0] -eq "-sc")) { - $CompletionArgs = if ($RemainingArgs.Count -gt 1) { $RemainingArgs[1..($RemainingArgs.Count-1)] } else { @() } - $Result = Install-ShellCompletion $CompletionArgs - exit $Result -} - -# Special case: auth commands -if ($RemainingArgs.Count -gt 0 -and $RemainingArgs[0] -eq "auth") { - $AuthArgs = if ($RemainingArgs.Count -gt 1) { $RemainingArgs[1..($RemainingArgs.Count-1)] } else { @() } - Invoke-AuthCommands $AuthArgs - exit $LASTEXITCODE -} - -# Special case: sync command -if ($RemainingArgs.Count -gt 0 -and ($RemainingArgs[0] -eq "sync" -or $RemainingArgs[0] -eq "--sync")) { - Sync-Run - exit 0 -} - -# Special case: update command -if ($RemainingArgs.Count -gt 0 -and ($RemainingArgs[0] -eq "update" -or $RemainingArgs[0] -eq "--update")) { - Update-Run - exit 0 -} - -# Run auto-recovery before main logic -if (-not (Invoke-AutoRecovery)) { - Write-ErrorMsg "Auto-recovery failed. Check permissions." +# Check Node.js version (major only) +$NodeVersion = (node -v) -replace '^v', '' -split '\.' | Select-Object -First 1 +if ([int]$NodeVersion -lt $MIN_NODE_VERSION) { + Write-Host "[X] Node.js $MIN_NODE_VERSION+ required (found: $(node -v))" + Write-Host " Update: https://nodejs.org" exit 1 } -# Smart profile detection: if first arg starts with '-', it's a flag not a profile -if ($RemainingArgs.Count -eq 0 -or $RemainingArgs[0] -match '^-') { - # No args or first arg is a flag → use default profile - $Profile = "default" - # $RemainingArgs already contains the remaining args correctly -} else { - # First arg is a profile name - $Profile = $RemainingArgs[0] - $RemainingArgs = if ($RemainingArgs.Count -gt 1) { $RemainingArgs | Select-Object -Skip 1 } else { @() } +# Check npm/npx available +$NpxCmd = Get-Command npx -ErrorAction SilentlyContinue +if (-not $NpxCmd) { + Write-Host "[X] npx not found (requires npm 5.2+)" + exit 127 } -# Validate profile name (alphanumeric, dash, underscore only) -if ($Profile -notmatch '^[a-zA-Z0-9_-]+$') { - $ErrorMessage = "Invalid profile name: $Profile" + "`n`n" + - "Use only alphanumeric characters, dash, or underscore." - - Write-ErrorMsg $ErrorMessage - exit 1 -} - -# Detect profile type -$ProfileInfo = Get-ProfileType $Profile - -if ($ProfileInfo.Type -eq "error") { - Write-Host "" - Write-Host "[X] Profile '$Profile' not found" -ForegroundColor Red - Write-Host "" - - # Get suggestions using fuzzy matching (only if profiles exist) - $AllProfiles = Get-AllProfileNames - if ($AllProfiles -and $AllProfiles.Count -gt 0) { - $Suggestions = Find-SimilarStrings -Target $Profile -Candidates $AllProfiles - - # Show suggestions if any - if ($Suggestions -and $Suggestions.Count -gt 0) { - Write-Host "Did you mean:" -ForegroundColor Yellow - foreach ($suggestion in $Suggestions) { - Write-Host " $suggestion" - } - Write-Host "" - } - } - - Write-Host "Available profiles:" -ForegroundColor Cyan - $AvailableProfiles = Get-AvailableProfiles - foreach ($line in $AvailableProfiles) { - Write-Host $line - } - Write-Host "" - Write-Host "Solutions:" -ForegroundColor Yellow - Write-Host " # Use existing profile" - Write-Host " ccs [profile] 'your prompt'" - Write-Host "" - Write-Host " # Create new account profile" - Write-Host " ccs auth create [name]" - Write-Host "" - $ErrorCode = $script:E_PROFILE_NOT_FOUND - Write-Host "Error: $ErrorCode" -ForegroundColor Yellow - $ErrorUrl = Get-ErrorDocUrl $ErrorCode - Write-Host $ErrorUrl -ForegroundColor Yellow - Write-Host "" - exit 1 -} - -# Detect Claude CLI executable -$ClaudeCli = Find-ClaudeCli - -# Execute based on profile type (Phase 5) -switch ($ProfileInfo.Type) { - "account" { - # Account-based profile: use CLAUDE_CONFIG_DIR - $InstancePath = Ensure-Instance $ProfileInfo.Name - Update-ProfileTimestamp $ProfileInfo.Name # Update last_used - - # Execute Claude with isolated config - $env:CLAUDE_CONFIG_DIR = $InstancePath - - try { - if ($RemainingArgs) { - & $ClaudeCli @RemainingArgs - } else { - & $ClaudeCli - } - exit $LASTEXITCODE - } catch { - Show-ClaudeNotFoundError - exit 1 - } - } - - "settings" { - # Settings-based profile: use --settings flag - $SettingsPath = $ProfileInfo.Path - - # Path expansion and normalization - if ($SettingsPath -match '^~[/\\]') { - $SettingsPath = $SettingsPath -replace '^~', $env:USERPROFILE - } - $SettingsPath = [System.Environment]::ExpandEnvironmentVariables($SettingsPath) - $SettingsPath = $SettingsPath -replace '/', '\' - - if (-not (Test-Path $SettingsPath)) { - Write-ErrorMsg "Settings file not found: $SettingsPath" - exit 1 - } - - try { - if ($RemainingArgs) { - & $ClaudeCli --settings $SettingsPath @RemainingArgs - } else { - & $ClaudeCli --settings $SettingsPath - } - exit $LASTEXITCODE - } catch { - Show-ClaudeNotFoundError - exit 1 - } - } - - "default" { - # Default: no special handling - try { - if ($RemainingArgs) { - & $ClaudeCli @RemainingArgs - } else { - & $ClaudeCli - } - exit $LASTEXITCODE - } catch { - Show-ClaudeNotFoundError - exit 1 - } - } - - default { - Write-ErrorMsg "Unknown profile type: $($ProfileInfo.Type)" - exit 1 - } -} +# Execute via npx (auto-installs if needed) +& npx $PACKAGE @RemainingArgs +exit $LASTEXITCODE diff --git a/src/ccs.ts b/src/ccs.ts index 709b6ccc..686bb582 100644 --- a/src/ccs.ts +++ b/src/ccs.ts @@ -169,6 +169,11 @@ function handleHelpCommand(): void { console.log(' with auto-recovery. Zero downtime.'); console.log(''); + console.log(colored('Requirements:', 'cyan')); + console.log(' Node.js 14+ (detected automatically by bootstrap)'); + console.log(' npm 5.2+ (for npx, comes with Node.js 8.2+)'); + console.log(''); + console.log(colored('Model Switching:', 'cyan')); console.log(` ${colored('ccs', 'yellow')} Use default Claude account`); console.log(` ${colored('ccs glm', 'yellow')} Switch to GLM 4.6 model`);