chore: bump version to 2.4.0

- PowerShell 7+ syntax fixes (ampersand, pipe chars, regex)
- Node.js standalone implementation (no shell spawning)
- Add modular architecture (helpers, claude-detector, config-manager)
- Comprehensive test suite with 95% coverage
- Enhanced cross-platform compatibility and error handling
This commit is contained in:
kaitranntt committed 2025-11-04 21:24:37 -05:00
1 parent f21b3a6de1
commit 4110e1bfcb
12 files changed
+1665 -29

No files matched your search

Executable
+532
View File
@@ -0,0 +1,532 @@
#!/usr/bin/env bash
set -euo pipefail
# Version (updated by scripts/bump-version.sh)
CCS_VERSION="2.4.0"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# --- Color/Format Functions ---
setup_colors() {
if [[ -t 2 ]] && [[ -z "${NO_COLOR:-}" ]]; then
RED='\033[0;31m'
YELLOW='\033[1;33m'
BOLD='\033[1m'
RESET='\033[0m'
else
RED='' YELLOW='' 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
}
setup_colors
# --- Claude CLI Detection Logic ---
detect_claude_cli() {
local claude_path=""
# Priority 1: CCS_CLAUDE_PATH environment variable
if [[ -n "${CCS_CLAUDE_PATH:-}" ]]; then
if [[ -f "$CCS_CLAUDE_PATH" ]] && [[ -x "$CCS_CLAUDE_PATH" ]]; then
echo "$CCS_CLAUDE_PATH"
return 0
fi
# Invalid CCS_CLAUDE_PATH - continue to fallbacks
# Warning will be shown later in validation phase
fi
# Priority 2: Check if claude in PATH
claude_path=$(command -v claude 2>/dev/null || true)
if [[ -n "$claude_path" ]]; then
echo "$claude_path"
return 0
fi
# Priority 3: Check common installation locations
local common_locations=()
# Platform-specific common locations
if [[ "$OSTYPE" == darwin* ]]; then
# macOS
common_locations=(
"/usr/local/bin/claude"
"$HOME/.local/bin/claude"
"/opt/homebrew/bin/claude"
)
else
# Linux
common_locations=(
"/usr/local/bin/claude"
"$HOME/.local/bin/claude"
"/usr/bin/claude"
)
fi
# Check each common location
for location in "${common_locations[@]}"; do
if [[ -f "$location" ]] && [[ -x "$location" ]]; then
echo "$location"
return 0
fi
done
# Not found
echo ""
return 1
}
# Global variable for validation error message
VALIDATION_ERROR=""
validate_claude_cli() {
local path="$1"
VALIDATION_ERROR=""
# Check 1: Empty path
if [[ -z "$path" ]]; then
VALIDATION_ERROR="No path provided"
return 1
fi
# Check 2: File exists
if [[ ! -e "$path" ]]; then
VALIDATION_ERROR="File not found: $path"
return 1
fi
# Check 3: Is regular file (not directory)
if [[ -d "$path" ]]; then
VALIDATION_ERROR="Path is a directory: $path"
return 1
fi
# Check 4: Is executable
if [[ ! -x "$path" ]]; then
VALIDATION_ERROR="File is not executable: $path
Try: chmod +x $path"
return 1
fi
# Check 5: Path safety (prevent injection)
# Allow: alphanumeric, /, \, :, space, -, _, ., ~
if [[ "$path" =~ [^\;a-zA-Z0-9/\\:\ \._~-] ]]; then
VALIDATION_ERROR="Path contains unsafe characters: $path
Allowed: alphanumeric, path separators, spaces, hyphens, underscores, dots"
return 1
fi
# All checks passed
return 0
}
show_claude_not_found_error() {
local env_var_status="${CCS_CLAUDE_PATH:-(not set)}"
msg_error "Claude CLI not found
Searched:
- CCS_CLAUDE_PATH: $env_var_status
- System PATH: not found
- Common locations: not found
Solutions:
1. Add Claude CLI to PATH:
# Find where Claude is installed
sudo find / -name claude 2>/dev/null
# Then add to PATH (replace /path/to with actual path)
export PATH=\"/path/to/claude/bin:\$PATH\"
echo 'export PATH=\"/path/to/claude/bin:\$PATH\"' >> ~/.bashrc
source ~/.bashrc
2. Or set custom path:
export CCS_CLAUDE_PATH=\"/full/path/to/claude\"
echo 'export CCS_CLAUDE_PATH=\"/full/path/to/claude\"' >> ~/.bashrc
source ~/.bashrc
Example (D drive on Windows/WSL):
export CCS_CLAUDE_PATH=\"/mnt/d/Tools/Claude/claude.exe\"
3. Or install Claude CLI:
https://docs.claude.com/en/docs/claude-code/installation
Verify installation:
ccs --version
Debugging:
# Check if claude command exists
command -v claude
# Check CCS_CLAUDE_PATH
echo \$CCS_CLAUDE_PATH"
}
CONFIG_FILE="${CCS_CONFIG:-$HOME/.ccs/config.json}"
# Installation function for commands and skills
install_commands_and_skills() {
# Try both possible locations for .claude directory
local source_dir=""
local possible_dirs=(
"$SCRIPT_DIR/.claude" # Development: tools/ccs/.claude
"$HOME/.ccs/.claude" # Installed: ~/.ccs/.claude
)
for dir in "${possible_dirs[@]}"; do
if [[ -d "$dir" ]]; then
source_dir="$dir"
break
fi
done
local target_dir="$HOME/.claude"
echo "┌─ Installing CCS Commands & Skills"
echo "│ Source: $source_dir"
echo "│ Target: $target_dir"
echo "│"
# Check if source directory exists
if [[ ! -d "$source_dir" ]]; then
echo "|"
msg_error "Source directory not found.
Checked locations:
- $SCRIPT_DIR/.claude (development)
- $HOME/.ccs/.claude (installed)
Solution:
1. If developing: Ensure you're in the CCS repository
2. If installed: Reinstall CCS with: curl -fsSL ccs.kaitran.ca/install | bash"
return 1
fi
# Create target directories if they don't exist
mkdir -p "$target_dir/commands"
mkdir -p "$target_dir/skills"
local installed_count=0
local skipped_count=0
# Install commands
if [[ -d "$source_dir/commands" ]]; then
echo "│ Installing commands..."
for cmd_file in "$source_dir/commands"/*.md; do
if [[ -f "$cmd_file" ]]; then
local cmd_name=$(basename "$cmd_file" .md)
local target_file="$target_dir/commands/$cmd_name.md"
if [[ -f "$target_file" ]]; then
echo "| | [i] Skipping existing command: $cmd_name.md"
skipped_count=$((skipped_count + 1))
else
if cp "$cmd_file" "$target_file"; then
echo "| | [OK] Installed command: $cmd_name.md"
installed_count=$((installed_count + 1))
else
echo "| | [X] Failed to install command: $cmd_name.md"
fi
fi
fi
done
else
echo "| [i] No commands directory found"
fi
echo "|"
# Install skills
if [[ -d "$source_dir/skills" ]]; then
echo "| Installing skills..."
for skill_dir in "$source_dir/skills"/*; do
if [[ -d "$skill_dir" ]]; then
local skill_name=$(basename "$skill_dir")
local target_skill_dir="$target_dir/skills/$skill_name"
if [[ -d "$target_skill_dir" ]]; then
echo "| | [i] Skipping existing skill: $skill_name"
skipped_count=$((skipped_count + 1))
else
if cp -r "$skill_dir" "$target_skill_dir"; then
echo "| | [OK] Installed skill: $skill_name"
installed_count=$((installed_count + 1))
else
echo "| | [X] Failed to install skill: $skill_name"
fi
fi
fi
done
else
echo "| [i] No skills directory found"
fi
echo "└─"
echo ""
echo "[OK] Installation complete!"
echo " Installed: $installed_count items"
echo " Skipped: $skipped_count items (already exist)"
echo ""
echo "You can now use the /ccs command in Claude CLI for task delegation."
echo "Example: /ccs glm /plan 'add user authentication'"
}
# Uninstallation function for commands and skills
uninstall_commands_and_skills() {
local target_dir="$HOME/.claude"
local removed_count=0
local not_found_count=0
echo "┌─ Uninstalling CCS Commands & Skills"
echo "│ Target: $target_dir"
echo "│"
# Check if target directory exists
if [[ ! -d "$target_dir" ]]; then
echo "|"
echo "│ [i] Claude directory not found: $target_dir"
echo "│ Nothing to uninstall."
echo "└─"
echo ""
echo "[OK] Uninstall complete!"
echo " Removed: 0 items (nothing was installed)"
return 0
fi
# Remove commands
local commands_dir="$target_dir/commands"
if [[ -d "$commands_dir" ]]; then
echo "│ Removing commands..."
for cmd_file in "$commands_dir"/ccs.md; do
if [[ -f "$cmd_file" ]]; then
local cmd_name=$(basename "$cmd_file" .md)
if rm "$cmd_file"; then
echo "| | [OK] Removed command: $cmd_name.md"
removed_count=$((removed_count + 1))
else
echo "| | [X] Failed to remove command: $cmd_name.md"
fi
else
echo "| | [i] CCS command not found"
not_found_count=$((not_found_count + 1))
fi
done
else
echo "│ [i] Commands directory not found"
not_found_count=$((not_found_count + 1))
fi
echo "|"
# Remove skills
local skills_dir="$target_dir/skills"
if [[ -d "$skills_dir" ]]; then
echo "| Removing skills..."
for skill_dir in "$skills_dir"/ccs-delegation; do
if [[ -d "$skill_dir" ]]; then
local skill_name=$(basename "$skill_dir")
if rm -rf "$skill_dir"; then
echo "| | [OK] Removed skill: $skill_name"
removed_count=$((removed_count + 1))
else
echo "| | [X] Failed to remove skill: $skill_name"
fi
else
echo "| | [i] CCS skill not found"
not_found_count=$((not_found_count + 1))
fi
done
else
echo "│ [i] Skills directory not found"
not_found_count=$((not_found_count + 1))
fi
echo "└─"
echo ""
echo "[OK] Uninstall complete!"
echo " Removed: $removed_count items"
echo " Not found: $not_found_count items (already removed)"
echo ""
echo "The /ccs command is no longer available in Claude CLI."
echo "To reinstall: ccs --install"
}
# Special case: version command (check BEFORE profile detection)
if [[ $# -gt 0 ]] && [[ "${1}" == "version" || "${1}" == "--version" || "${1}" == "-v" ]]; then
echo "CCS (Claude Code Switch) version $CCS_VERSION"
# Show install location if we can determine it
INSTALL_LOCATION=$(command -v ccs 2>/dev/null || echo "unknown")
if [[ "$INSTALL_LOCATION" != "unknown" ]]; then
# Resolve symlink to actual file
if [[ -L "$INSTALL_LOCATION" ]]; then
ACTUAL_LOCATION=$(readlink "$INSTALL_LOCATION" 2>/dev/null || echo "$INSTALL_LOCATION")
echo "Installed at: $INSTALL_LOCATION -> $ACTUAL_LOCATION"
else
echo "Installed at: $INSTALL_LOCATION"
fi
fi
echo "https://github.com/kaitranntt/ccs"
exit 0
fi
# Special case: help command (check BEFORE profile detection)
if [[ $# -gt 0 ]] && [[ "${1}" == "--help" || "${1}" == "-h" || "${1}" == "help" ]]; then
shift # Remove the help argument
# Detect and validate Claude CLI for help command
CLAUDE_CLI=$(detect_claude_cli)
if [[ -z "$CLAUDE_CLI" ]]; then
show_claude_not_found_error
exit 1
fi
if ! validate_claude_cli "$CLAUDE_CLI"; then
msg_error "$VALIDATION_ERROR"
exit 1
fi
exec "$CLAUDE_CLI" --help "$@"
fi
# Special case: install command (check BEFORE profile detection)
if [[ $# -gt 0 ]] && [[ "${1}" == "--install" ]]; then
install_commands_and_skills
exit $?
fi
# Special case: uninstall command (check BEFORE profile detection)
if [[ $# -gt 0 ]] && [[ "${1}" == "--uninstall" ]]; then
uninstall_commands_and_skills
exit $?
fi
# 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
# Check config exists
if [[ ! -f "$CONFIG_FILE" ]]; then
msg_error "Config file not found: $CONFIG_FILE
Solutions:
1. Reinstall CCS:
curl -fsSL ccs.kaitran.ca/install | bash
2. Or create config manually:
mkdir -p ~/.ccs
cat > ~/.ccs/config.json << 'EOF'
{
\"profiles\": {
\"glm\": \"~/.ccs/glm.settings.json\",
\"default\": \"~/.claude/settings.json\"
}
}
EOF"
exit 1
fi
# Check jq installed
if ! command -v jq &> /dev/null; then
msg_error "jq is required but not installed
Install jq:
macOS: brew install jq
Ubuntu: sudo apt install jq
Fedora: sudo dnf install jq"
exit 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
# Validate JSON syntax
if ! jq -e . "$CONFIG_FILE" &>/dev/null; then
msg_error "Invalid JSON in $CONFIG_FILE
Fix the JSON syntax or reinstall:
curl -fsSL ccs.kaitran.ca/install | bash"
exit 1
fi
# Validate config has profiles object
if ! jq -e '.profiles' "$CONFIG_FILE" &>/dev/null; then
msg_error "Config must have 'profiles' object
See config/config.example.json for correct format
Or reinstall:
curl -fsSL ccs.kaitran.ca/install | bash"
exit 1
fi
# Get settings path for profile (using --arg to prevent injection)
SETTINGS_PATH=$(jq -r --arg profile "$PROFILE" '.profiles[$profile] // empty' "$CONFIG_FILE")
if [[ -z "$SETTINGS_PATH" ]]; then
AVAILABLE_PROFILES=$(jq -r '.profiles | keys[]' "$CONFIG_FILE" 2>/dev/null | sed 's/^/ - /')
msg_error "Profile '$PROFILE' not found in $CONFIG_FILE
Available profiles:
$AVAILABLE_PROFILES"
exit 1
fi
# Expand ~ in path
SETTINGS_PATH="${SETTINGS_PATH/#\~/$HOME}"
# Validate settings file exists
if [[ ! -f "$SETTINGS_PATH" ]]; then
msg_error "Settings file not found: $SETTINGS_PATH
Solutions:
1. Create the settings file for profile '$PROFILE'
2. Update the path in $CONFIG_FILE
3. Or reinstall: curl -fsSL ccs.kaitran.ca/install | bash"
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)
if [[ -z "$CLAUDE_CLI" ]]; then
show_claude_not_found_error
exit 1
fi
# Validate detected path
if ! validate_claude_cli "$CLAUDE_CLI"; then
msg_error "$VALIDATION_ERROR"
exit 1
fi
# Execute with validated path
exec "$CLAUDE_CLI" --settings "$SETTINGS_PATH" "$@"
+608
View File
@@ -0,0 +1,608 @@
# CCS - Claude Code Switch (Windows PowerShell)
# Cross-platform Claude CLI profile switcher
# https://github.com/kaitranntt/ccs
param(
[Parameter(Position=0)]
[string]$ProfileOrFlag = "default",
[Parameter(ValueFromRemainingArguments=$true)]
[string[]]$RemainingArgs
)
$ErrorActionPreference = "Stop"
# --- 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 ""
}
# --- Claude CLI Detection Logic ---
function Find-ClaudeCli {
[OutputType([string])]
param()
# Priority 1: CCS_CLAUDE_PATH environment variable
$CcsClaudePath = $env:CCS_CLAUDE_PATH
if ($CcsClaudePath) {
if ((Test-Path $CcsClaudePath -PathType Leaf) -and
(Get-Command $CcsClaudePath -ErrorAction SilentlyContinue)) {
return $CcsClaudePath
}
# Invalid CCS_CLAUDE_PATH - continue to fallbacks
# Warning will be shown later in validation phase
}
# Priority 2: Check if claude in PATH
$ClaudeInPath = Get-Command claude -ErrorAction SilentlyContinue
if ($ClaudeInPath) {
return $ClaudeInPath.Source
}
# Priority 3: Check common installation locations
$CommonLocations = @(
"$env:LOCALAPPDATA\Claude\claude.exe",
"$env:PROGRAMFILES\Claude\claude.exe",
"C:\Program Files\Claude\claude.exe",
"D:\Program Files\Claude\claude.exe",
"$env:USERPROFILE\.local\bin\claude.exe"
)
foreach ($Location in $CommonLocations) {
$ExpandedPath = [System.Environment]::ExpandEnvironmentVariables($Location)
if ((Test-Path $ExpandedPath -PathType Leaf) -and
(Get-Command $ExpandedPath -ErrorAction SilentlyContinue)) {
return $ExpandedPath
}
}
# Not found
return ""
}
function Test-ClaudeCli {
[OutputType([bool])]
param(
[Parameter(Mandatory=$true)]
[AllowEmptyString()]
[string]$Path
)
# Check 1: Empty path
if ([string]::IsNullOrWhiteSpace($Path)) {
throw "No path provided"
}
# Check 2: File exists
if (-not (Test-Path $Path)) {
throw "File not found: $Path"
}
# Check 3: Is regular file (not directory)
if (Test-Path $Path -PathType Container) {
throw "Path is a directory: $Path"
}
# Check 4: Is executable (Get-Command can load it)
try {
$null = Get-Command $Path -ErrorAction Stop
} catch {
throw "File is not executable: $Path`n`nCheck file permissions and file type"
}
# Check 5: Path safety (prevent injection)
# Allow: alphanumeric, \, /, :, space, -, _, ., ~
if ($Path -match '[;\|&<>``\$\*\?\[\]''\"()]') {
throw "Path contains unsafe characters: $Path`n`nAllowed: alphanumeric, path separators, spaces, hyphens, underscores, dots"
}
# All checks passed
return $true
}
function Show-ClaudeNotFoundError {
$EnvVarStatus = if ($env:CCS_CLAUDE_PATH) { $env:CCS_CLAUDE_PATH } else { "(not set)" }
Write-ErrorMsg @"
Claude CLI not found
Searched:
- CCS_CLAUDE_PATH: $EnvVarStatus
- System PATH: not found
- Common locations: not found
Solutions:
1. Add Claude CLI to PATH:
# Find where Claude is installed
Get-ChildItem -Path C:\,D:\ -Filter claude.exe -Recurse -ErrorAction SilentlyContinue | Select-Object FullName
# Then add to PATH (replace with actual path)
`$env:Path += ';D:\path\to\claude\directory'
[Environment]::SetEnvironmentVariable('Path', `$env:Path, 'User')
# Restart terminal for changes to take effect
2. Or set custom path:
`$env:CCS_CLAUDE_PATH = 'D:\full\path\to\claude.exe'
[Environment]::SetEnvironmentVariable('CCS_CLAUDE_PATH', 'D:\full\path\to\claude.exe', 'User')
Example (D drive installation):
`$env:CCS_CLAUDE_PATH = 'D:\Tools\Claude\claude.exe'
[Environment]::SetEnvironmentVariable('CCS_CLAUDE_PATH', 'D:\Tools\Claude\claude.exe', 'User')
# Restart terminal for changes to take effect
3. Or install Claude CLI:
https://docs.claude.com/en/docs/claude-code/installation
Verify installation:
ccs --version
Debugging:
# Check if claude command exists
Get-Command claude -ErrorAction SilentlyContinue
# Check CCS_CLAUDE_PATH
`$env:CCS_CLAUDE_PATH
"@
}
# Version (updated by scripts/bump-version.sh)
$CcsVersion = "2.4.0"
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
# Installation function for commands and skills
function Install-CommandsAndSkills {
# Try both possible locations for .claude directory
$SourceDir = $null
$PossibleDirs = @(
(Join-Path $ScriptDir ".claude"), # Development: tools/ccs/.claude
(Join-Path $env:USERPROFILE ".ccs\.claude") # Installed: ~/.ccs/.claude
)
foreach ($Dir in $PossibleDirs) {
if (Test-Path $Dir) {
$SourceDir = $Dir
break
}
}
$HomeDir = if ($env:HOME) { $env:HOME } else { $env:USERPROFILE }
$TargetDir = Join-Path $HomeDir ".claude"
Write-Host "[Installing CCS Commands and Skills]" -ForegroundColor Cyan
Write-Host "│ Source: $SourceDir"
Write-Host "│ Target: $TargetDir"
Write-Host "│"
# Check if source directory exists
if (-not $SourceDir) {
Write-Host "│"
$DevelopmentPath = Join-Path $ScriptDir ".claude"
$InstalledPath = Join-Path $env:USERPROFILE ".ccs\.claude"
Write-ErrorMsg @"
Source directory not found.
Checked locations:
- $DevelopmentPath (development)
- $InstalledPath (installed)
Solution:
1. If developing: Ensure you're in the CCS repository
2. If installed: Reinstall CCS with: irm ccs.kaitran.ca/install | iex
"@
exit 1
}
# Create target directories if they don't exist
$CommandsDir = Join-Path $TargetDir "commands"
$SkillsDir = Join-Path $TargetDir "skills"
if (-not (Test-Path $CommandsDir)) {
New-Item -ItemType Directory -Path $CommandsDir -Force | Out-Null
}
if (-not (Test-Path $SkillsDir)) {
New-Item -ItemType Directory -Path $SkillsDir -Force | Out-Null
}
$InstalledCount = 0
$SkippedCount = 0
# Install commands
$SourceCommandsDir = Join-Path $SourceDir "commands"
if (Test-Path $SourceCommandsDir) {
Write-Host "│ Installing commands..." -ForegroundColor Yellow
Get-ChildItem $SourceCommandsDir -Filter "*.md" | ForEach-Object {
$CmdName = $_.BaseName
$TargetFile = Join-Path $CommandsDir "$CmdName.md"
if (Test-Path $TargetFile) {
Write-Host "│ | [i] Skipping existing command: $CmdName.md" -ForegroundColor Yellow
$SkippedCount++
} else {
try {
Copy-Item $_.FullName $TargetFile -ErrorAction Stop
Write-Host "│ | [OK] Installed command: $CmdName.md" -ForegroundColor Green
$InstalledCount++
} catch {
Write-Host "│ | [!] Failed to install command: $CmdName.md" -ForegroundColor Red
Write-Host "│ Error: $($_.Exception.Message)" -ForegroundColor Red
}
}
}
} else {
Write-Host "│ [i] No commands directory found" -ForegroundColor Gray
}
Write-Host "│"
# Install skills
$SourceSkillsDir = Join-Path $SourceDir "skills"
if (Test-Path $SourceSkillsDir) {
Write-Host "│ Installing skills..." -ForegroundColor Yellow
Get-ChildItem $SourceSkillsDir -Directory | ForEach-Object {
$SkillName = $_.Name
$TargetSkillDir = Join-Path $SkillsDir $SkillName
if (Test-Path $TargetSkillDir) {
Write-Host "│ | [i] Skipping existing skill: $SkillName" -ForegroundColor Yellow
$SkippedCount++
} else {
try {
Copy-Item $_.FullName $TargetSkillDir -Recurse -ErrorAction Stop
Write-Host "│ | [OK] Installed skill: $SkillName" -ForegroundColor Green
$InstalledCount++
} catch {
Write-Host "│ | [!] Failed to install skill: $SkillName" -ForegroundColor Red
Write-Host "│ Error: $($_.Exception.Message)" -ForegroundColor Red
}
}
}
} else {
Write-Host "│ [i] No skills directory found" -ForegroundColor Gray
}
Write-Host "[DONE]"
Write-Host ""
Write-Host "[OK] Installation complete!" -ForegroundColor Green
Write-Host " Installed: $InstalledCount items"
Write-Host " Skipped: $SkippedCount items (already exist)"
Write-Host ""
Write-Host "You can now use the /ccs command in Claude CLI for task delegation." -ForegroundColor Cyan
Write-Host "Example: /ccs glm /plan 'add user authentication'" -ForegroundColor Cyan
}
# Uninstallation function for commands and skills
function Uninstall-CommandsAndSkills {
$HomeDir = if ($env:HOME) { $env:HOME } else { $env:USERPROFILE }
$TargetDir = Join-Path $HomeDir ".claude"
$RemovedCount = 0
$NotFoundCount = 0
Write-Host "[Uninstalling CCS Commands and Skills]" -ForegroundColor Cyan
Write-Host "│ Target: $TargetDir"
Write-Host "│"
# Check if target directory exists
if (-not (Test-Path $TargetDir)) {
Write-Host "│"
Write-Host "│ [i] Claude directory not found: $TargetDir" -ForegroundColor Gray
Write-Host "│ Nothing to uninstall."
Write-Host "[DONE]"
Write-Host ""
Write-Host "[OK] Uninstall complete!" -ForegroundColor Green
Write-Host " Removed: 0 items (nothing was installed)"
return
}
# Remove commands
$CommandsDir = Join-Path $TargetDir "commands"
if (Test-Path $CommandsDir) {
Write-Host "│ Removing commands..." -ForegroundColor Yellow
$CmdFile = Join-Path $CommandsDir "ccs.md"
if (Test-Path $CmdFile) {
try {
Remove-Item $CmdFile -Force -ErrorAction Stop
Write-Host "│ | [OK] Removed command: ccs.md" -ForegroundColor Green
$RemovedCount++
} catch {
Write-Host "│ | [!] Failed to remove command: ccs.md" -ForegroundColor Red
Write-Host "│ Error: $($_.Exception.Message)" -ForegroundColor Red
}
} else {
Write-Host "│ | [i] CCS command not found" -ForegroundColor Gray
$NotFoundCount++
}
} else {
Write-Host "│ [i] Commands directory not found" -ForegroundColor Gray
$NotFoundCount++
}
Write-Host "│"
# Remove skills
$SkillsDir = Join-Path $TargetDir "skills"
if (Test-Path $SkillsDir) {
Write-Host "│ Removing skills..." -ForegroundColor Yellow
$SkillDir = Join-Path $SkillsDir "ccs-delegation"
if (Test-Path $SkillDir) {
try {
Remove-Item $SkillDir -Recurse -Force -ErrorAction Stop
Write-Host "│ | [OK] Removed skill: ccs-delegation" -ForegroundColor Green
$RemovedCount++
} catch {
Write-Host "│ | [!] Failed to remove skill: ccs-delegation" -ForegroundColor Red
Write-Host "│ Error: $($_.Exception.Message)" -ForegroundColor Red
}
} else {
Write-Host "│ | [i] CCS skill not found" -ForegroundColor Gray
$NotFoundCount++
}
} else {
Write-Host "│ [i] Skills directory not found" -ForegroundColor Gray
$NotFoundCount++
}
Write-Host "[DONE]"
Write-Host ""
Write-Host "[OK] Uninstall complete!" -ForegroundColor Green
Write-Host " Removed: $RemovedCount items"
Write-Host " Not found: $NotFoundCount items (already removed)"
Write-Host ""
Write-Host "The /ccs command is no longer available in Claude CLI." -ForegroundColor Cyan
Write-Host "To reinstall: ccs --install" -ForegroundColor Cyan
}
# Special case: version command (check BEFORE profile detection)
# Check both $ProfileOrFlag and first element of $RemainingArgs
$FirstArg = if ($ProfileOrFlag -ne "default") { $ProfileOrFlag } elseif ($RemainingArgs.Count -gt 0) { $RemainingArgs[0] } else { $null }
if ($FirstArg -eq "version" -or $FirstArg -eq "--version" -or $FirstArg -eq "-v") {
Write-Host "CCS (Claude Code Switch) version $CcsVersion"
# Show install location
$InstallLocation = (Get-Command ccs -ErrorAction SilentlyContinue).Source
if ($InstallLocation) {
Write-Host "Installed at: $InstallLocation"
}
Write-Host "https://github.com/kaitranntt/ccs"
exit 0
}
# Special case: help command (check BEFORE profile detection)
if ($FirstArg -eq "--help" -or $FirstArg -eq "-h" -or $FirstArg -eq "help") {
# Detect and validate Claude CLI for help command
$ClaudeCli = Find-ClaudeCli
if ([string]::IsNullOrEmpty($ClaudeCli)) {
Show-ClaudeNotFoundError
exit 1
}
try {
$null = Test-ClaudeCli -Path $ClaudeCli
} catch {
Write-ErrorMsg $_.Exception.Message
exit 1
}
try {
if ($RemainingArgs) {
& $ClaudeCli --help @RemainingArgs
} else {
& $ClaudeCli --help
}
exit $LASTEXITCODE
} catch {
Write-Host "Error: Failed to execute claude --help" -ForegroundColor Red
Write-Host $_.Exception.Message
exit 1
}
}
# Special case: install command (check BEFORE profile detection)
if ($FirstArg -eq "--install") {
Install-CommandsAndSkills
exit $LASTEXITCODE
}
# Special case: uninstall command (check BEFORE profile detection)
if ($FirstArg -eq "--uninstall") {
Uninstall-CommandsAndSkills
exit $LASTEXITCODE
}
# Smart profile detection: if first arg starts with '-', it's a flag not a profile
if ($ProfileOrFlag -match '^-') {
# First arg is a flag → use default profile, keep all args
$Profile = "default"
# Prepend $ProfileOrFlag to $RemainingArgs (it's actually a flag, not a profile)
if ($RemainingArgs) {
$RemainingArgs = @($ProfileOrFlag) + $RemainingArgs
} else {
$RemainingArgs = @($ProfileOrFlag)
}
} else {
# First arg is a profile name
$Profile = $ProfileOrFlag
# $RemainingArgs already contains correct args (PowerShell handles this)
}
# Special case: "default" profile just runs claude directly (no profile switching)
if ($Profile -eq "default") {
try {
if ($RemainingArgs) {
& claude @RemainingArgs
} else {
& claude
}
exit $LASTEXITCODE
} catch {
Write-Host "Error: Failed to execute claude" -ForegroundColor Red
Write-Host $_.Exception.Message
exit 1
}
}
# Config file location (supports environment variable override)
$ConfigFile = if ($env:CCS_CONFIG) {
$env:CCS_CONFIG
} else {
"$env:USERPROFILE\.ccs\config.json"
}
# Check config exists
if (-not (Test-Path $ConfigFile)) {
Write-ErrorMsg @"
Config file not found: $ConfigFile
Solutions:
1. Reinstall CCS:
irm ccs.kaitran.ca/install | iex
2. Or create config manually:
New-Item -ItemType Directory -Force -Path '$env:USERPROFILE\.ccs'
Set-Content -Path '$env:USERPROFILE\.ccs\config.json' -Value '{
"profiles": {
"glm": "~/.ccs/glm.settings.json",
"default": "~/.claude/settings.json"
}
}'
"@
exit 1
}
# Validate profile name (alphanumeric, dash, underscore only)
if ($Profile -notmatch '^[a-zA-Z0-9_-]+$') {
Write-ErrorMsg @"
Invalid profile name: $Profile
Use only alphanumeric characters, dash, or underscore.
"@
exit 1
}
# Read and parse JSON config
try {
$ConfigContent = Get-Content $ConfigFile -Raw -ErrorAction Stop
$Config = $ConfigContent | ConvertFrom-Json -ErrorAction Stop
} catch {
Write-ErrorMsg @"
Invalid JSON in $ConfigFile
Fix the JSON syntax or reinstall:
irm ccs.kaitran.ca/install | iex
"@
exit 1
}
# Validate config has profiles object
if (-not $Config.profiles) {
Write-ErrorMsg @"
Config must have 'profiles' object
See .ccs.example.json for correct format
Or reinstall:
irm ccs.kaitran.ca/install | iex
"@
exit 1
}
# Get settings path for profile
$SettingsPath = $Config.profiles.$Profile
if (-not $SettingsPath) {
$AvailableProfiles = ($Config.profiles.PSObject.Properties.Name | ForEach-Object { " - $_" }) -join "`n"
Write-ErrorMsg @"
Profile '$Profile' not found in $ConfigFile
Available profiles:
$AvailableProfiles
"@
exit 1
}
# Path expansion and normalization
# 1. Handle Unix-style tilde expansion (~/path -> %USERPROFILE%\path)
if ($SettingsPath -match '^~[/\\]') {
$SettingsPath = $SettingsPath -replace '^~', $env:USERPROFILE
}
# 2. Expand Windows environment variables (%USERPROFILE%, etc.)
$SettingsPath = [System.Environment]::ExpandEnvironmentVariables($SettingsPath)
# 3. Convert forward slashes to backslashes (Unix path compatibility)
$SettingsPath = $SettingsPath -replace '/', '\'
# Validate settings file exists
if (-not (Test-Path $SettingsPath)) {
Write-ErrorMsg @"
Settings file not found: $SettingsPath
Solutions:
1. Create the settings file for profile '$Profile'
2. Update the path in $ConfigFile
3. Or reinstall: irm ccs.kaitran.ca/install | iex
"@
exit 1
}
# Validate settings file is valid JSON (basic check)
try {
$SettingsContent = Get-Content $SettingsPath -Raw -ErrorAction Stop
$Settings = $SettingsContent | ConvertFrom-Json -ErrorAction Stop
} catch {
Write-ErrorMsg @"
Invalid JSON in $SettingsPath
Details: $_
Solutions:
1. Validate JSON at https://jsonlint.com
2. Or reset to template:
Set-Content -Path '$SettingsPath' -Value '{`"env`":{}}`'
3. Or reinstall: irm ccs.kaitran.ca/install | iex
"@
exit 1
}
# Detect Claude CLI executable
$ClaudeCli = Find-ClaudeCli
if ([string]::IsNullOrEmpty($ClaudeCli)) {
Show-ClaudeNotFoundError
exit 1
}
# Validate detected path
try {
$null = Test-ClaudeCli -Path $ClaudeCli
} catch {
Write-ErrorMsg $_.Exception.Message
exit 1
}
# Execute with validated path
try {
if ($RemainingArgs) {
& $ClaudeCli --settings $SettingsPath @RemainingArgs
} else {
& $ClaudeCli --settings $SettingsPath
}
exit $LASTEXITCODE
} catch {
Write-Host "Error: Failed to execute claude" -ForegroundColor Red
Write-Host $_.Exception.Message
exit 1
}