Files
ccs/lib/ccs
T

1919 lines
55 KiB
Bash
Executable File

#!/usr/bin/env bash
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"
# 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"
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"
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 <profile>
# 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 <profile>\" 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} <command> [options]"
echo ""
echo -e "${CYAN}Commands:${RESET}"
echo -e " ${YELLOW}create <profile>${RESET} Create new profile and login"
echo -e " ${YELLOW}list${RESET} List all saved profiles"
echo -e " ${YELLOW}show <profile>${RESET} Show profile details"
echo -e " ${YELLOW}remove <profile>${RESET} Remove saved profile"
echo -e " ${YELLOW}default <profile>${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 <profile>${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 <profile> [--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 <profile>${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 <profile> [--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 <profile> [--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 <profile>${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 <TAB> # See available profiles"
echo " ccs auth <TAB> # 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 <TAB> # See available profiles"
echo " ccs auth <TAB> # 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 <TAB> # See available profiles"
echo " ccs auth <TAB> # 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
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 <profile> \"your prompt\"" >&2
echo "" >&2
echo " # Create new account profile" >&2
echo " ccs auth create <name>" >&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