From 4110e1bfcba12b23c66fc664a8bc71e08ff6daf4 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Tue, 4 Nov 2025 21:24:37 -0500 Subject: [PATCH] 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 --- CLAUDE.md | 51 +++- README.md | 30 ++ VERSION | 2 +- bin/claude-detector.js | 156 +++++++++++ bin/config-manager.js | 129 +++++++++ bin/helpers.js | 65 +++++ installers/install.ps1 | 14 +- installers/install.sh | 18 +- lib/ccs | 532 +++++++++++++++++++++++++++++++++++ lib/ccs.ps1 | 608 ++++++++++++++++++++++++++++++++++++++++ package.json | 52 ++++ scripts/bump-version.sh | 37 ++- 12 files changed, 1665 insertions(+), 29 deletions(-) create mode 100644 bin/claude-detector.js create mode 100644 bin/config-manager.js create mode 100644 bin/helpers.js create mode 100755 lib/ccs create mode 100644 lib/ccs.ps1 create mode 100644 package.json diff --git a/CLAUDE.md b/CLAUDE.md index b85e0844..2f624cb7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -7,6 +7,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co CCS (Claude Code Switch) is a lightweight CLI wrapper enabling instant profile switching between Claude Sonnet 4.5 and GLM 4.6 models. The tool delegates to the official Claude CLI via the `--settings` flag, supporting both Unix-like systems (bash) and Windows (PowerShell). **Primary Installation Methods** (highest priority): +- **npm Package** (recommended): `npm install -g @kai/ccs` (cross-platform) - macOS/Linux: `curl -fsSL ccs.kaitran.ca/install | bash` - Windows: `irm ccs.kaitran.ca/install | iex` @@ -46,12 +47,31 @@ exec claude --settings [args] ``` **Key Files**: -- `ccs` (bash) / `ccs.ps1` (PowerShell): Main executable wrapper -- `installers/install.sh` / `installers/install.ps1`: Installation scripts +- `package.json`: npm package manifest with bin field configuration +- `bin/ccs.js`: Cross-platform Node.js entry point (npm package) +- `lib/ccs` (bash) / `lib/ccs.ps1` (PowerShell): Platform-specific executable wrappers +- `installers/install.sh` / `installers/install.ps1`: Traditional installation scripts - `installers/uninstall.sh` / `installers/uninstall.ps1`: Removal scripts - `VERSION`: Single source of truth for version (format: MAJOR.MINOR.PATCH) - `.claude/`: Commands and skills for Claude Code integration +**npm Package Architecture**: +``` +User: ccs [profile] [claude-args] + ↓ +npm creates bin/ccs.js symlink/wrapper + ↓ +bin/ccs.js detects platform (Unix vs Windows) + ├─ Unix: spawn bash lib/ccs [args] + └─ Windows: spawn pwsh/powershell lib/ccs.ps1 [args] + ↓ +Read ~/.ccs/config.json + ↓ +Lookup profile → settings file path + ↓ +exec claude --settings [args] +``` + **Installation Creates**: **Executable Locations**: @@ -103,10 +123,37 @@ cat VERSION ./ccs --version ./ccs glm --help +# Test npm package locally +npm pack # Creates kai-ccs-X.Y.Z.tgz +npm install -g kai-ccs-X.Y.Z.tgz # Test installation +ccs --version # Verify it works +npm uninstall -g @kai/ccs # Cleanup +rm kai-ccs-X.Y.Z.tgz # Remove tarball + # Clean test environment rm -rf ~/.ccs ``` +### npm Package Publishing +```bash +# First-time setup (one-time) +npm login # Login to npm account +npm token create --type=granular --scope=publish # Create token +# Add NPM_TOKEN to GitHub Secrets + +# Publishing workflow +./scripts/bump-version.sh patch # Bump version +git add VERSION package.json lib/ccs lib/ccs.ps1 installers/install.sh installers/install.ps1 +git commit -m "chore: bump version to X.Y.Z" +git tag vX.Y.Z +git push origin main +git push origin vX.Y.Z # Triggers GitHub Actions publish + +# Manual publish (if needed) +npm publish --dry-run # Test before publishing +npm publish --access public # Publish to npm registry +``` + ## Code Standards ### Bash (Unix Systems) diff --git a/README.md b/README.md index 1560700f..794f2d3c 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ Switch between Claude Sonnet 4.5 and GLM 4.6 instantly. Stop hitting rate limits [![License](https://img.shields.io/badge/license-MIT-C15F3C?style=for-the-badge)](LICENSE) [![Platform](https://img.shields.io/badge/platform-macOS%20%7C%20Linux%20%7C%20Windows-lightgrey?style=for-the-badge)]() +[![npm](https://img.shields.io/npm/v/@kai/ccs?style=for-the-badge&logo=npm)](https://www.npmjs.com/package/@kai/ccs) [![PoweredBy](https://img.shields.io/badge/PoweredBy-ClaudeKit-C15F3C?style=for-the-badge)](https://claudekit.cc?ref=HMNKXOHN) **Languages**: [English](README.md) | [Tiếng Việt](README.vi.md) @@ -30,6 +31,17 @@ claude /login ### Primary Installation Methods +#### Option 1: npm Package (Recommended) + +**macOS / Linux / Windows** +```bash +npm install -g @kai/ccs +``` + +Compatible with npm, yarn, pnpm, and bun package managers. + +#### Option 2: Direct Install (Traditional) + **macOS / Linux** ```bash curl -fsSL ccs.kaitran.ca/install | bash @@ -55,6 +67,24 @@ ccs "Debug this issue" ccs "Write unit tests" ``` +#### Package Manager Options + +All major package managers are supported: + +```bash +# npm (default) +npm install -g @kai/ccs + +# yarn +yarn global add @kai/ccs + +# pnpm (70% less disk space) +pnpm add -g @kai/ccs + +# bun (30x faster) +bun add -g @kai/ccs +``` + ### Configuration (Auto-created) **~/.ccs/config.json**: diff --git a/VERSION b/VERSION index 276cbf9e..197c4d5c 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.3.0 +2.4.0 diff --git a/bin/claude-detector.js b/bin/claude-detector.js new file mode 100644 index 00000000..cb0a69b2 --- /dev/null +++ b/bin/claude-detector.js @@ -0,0 +1,156 @@ +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const { execSync } = require('child_process'); +const { showError, expandPath, isPathSafe } = require('./helpers'); + +// Detect Claude CLI executable +function detectClaudeCli() { + // Priority 1: CCS_CLAUDE_PATH environment variable + if (process.env.CCS_CLAUDE_PATH) { + const ccsPath = expandPath(process.env.CCS_CLAUDE_PATH); + if (fs.existsSync(ccsPath) && isExecutable(ccsPath)) { + return ccsPath; + } + // Invalid CCS_CLAUDE_PATH - continue to fallbacks + } + + // Priority 2: Check if claude in PATH + try { + const claudePath = execSync( + process.platform === 'win32' ? 'where claude' : 'which claude', + { encoding: 'utf8', stdio: ['pipe', 'pipe', 'ignore'] } + ).trim().split('\n')[0]; + + if (claudePath && fs.existsSync(claudePath)) { + return claudePath; + } + } catch (e) { + // Not in PATH, continue to common locations + } + + // Priority 3: Check common installation locations + const commonLocations = getCommonLocations(); + + for (const location of commonLocations) { + const expandedPath = expandPath(location); + if (fs.existsSync(expandedPath) && isExecutable(expandedPath)) { + return expandedPath; + } + } + + // Not found + return null; +} + +// Get platform-specific common locations +function getCommonLocations() { + const home = require('os').homedir(); + + if (process.platform === 'win32') { + return [ + path.join(process.env.LOCALAPPDATA || '', 'Claude', 'claude.exe'), + path.join(process.env.PROGRAMFILES || '', 'Claude', 'claude.exe'), + 'C:\\Program Files\\Claude\\claude.exe', + 'D:\\Program Files\\Claude\\claude.exe', + path.join(home, '.local', 'bin', 'claude.exe') + ]; + } else if (process.platform === 'darwin') { + return [ + '/usr/local/bin/claude', + path.join(home, '.local/bin/claude'), + '/opt/homebrew/bin/claude' + ]; + } else { + return [ + '/usr/local/bin/claude', + path.join(home, '.local/bin/claude'), + '/usr/bin/claude' + ]; + } +} + +// Check if file is executable +function isExecutable(filePath) { + try { + fs.accessSync(filePath, fs.constants.X_OK); + return true; + } catch (e) { + return false; + } +} + +// Validate Claude CLI path +function validateClaudeCli(claudePath) { + // Check 1: Empty path + if (!claudePath) { + throw new Error('No path provided'); + } + + // Check 2: File exists + if (!fs.existsSync(claudePath)) { + throw new Error(`File not found: ${claudePath}`); + } + + // Check 3: Is regular file (not directory) + const stats = fs.statSync(claudePath); + if (!stats.isFile()) { + throw new Error(`Path is a directory: ${claudePath}`); + } + + // Check 4: Is executable + if (!isExecutable(claudePath)) { + throw new Error(`File is not executable: ${claudePath}\n\nTry: chmod +x ${claudePath}`); + } + + // Check 5: Path safety (prevent injection) + if (!isPathSafe(claudePath)) { + throw new Error(`Path contains unsafe characters: ${claudePath}\n\nAllowed: alphanumeric, path separators, spaces, hyphens, underscores, dots`); + } + + return true; +} + +// Show Claude not found error +function showClaudeNotFoundError() { + const envVarStatus = process.env.CCS_CLAUDE_PATH || '(not set)'; + const isWindows = process.platform === 'win32'; + + const 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: + + ${isWindows + ? '# Find where Claude is installed\n Get-ChildItem -Path C:\\,D:\\ -Filter claude.exe -Recurse\n\n # Add to PATH\n $env:Path += \';D:\\path\\to\\claude\\directory\'\n [Environment]::SetEnvironmentVariable(\'Path\', $env:Path, \'User\')' + : '# Find where Claude is installed\n sudo find / -name claude 2>/dev/null\n\n # Add to PATH\n export PATH="/path/to/claude/bin:$PATH"\n echo \'export PATH="/path/to/claude/bin:$PATH"\' >> ~/.bashrc\n source ~/.bashrc' + } + + 2. Or set custom path: + + ${isWindows + ? '$env:CCS_CLAUDE_PATH = \'D:\\full\\path\\to\\claude.exe\'\n [Environment]::SetEnvironmentVariable(\'CCS_CLAUDE_PATH\', \'D:\\full\\path\\to\\claude.exe\', \'User\')' + : 'export CCS_CLAUDE_PATH="/full/path/to/claude"\n echo \'export CCS_CLAUDE_PATH="/full/path/to/claude"\' >> ~/.bashrc\n source ~/.bashrc' + } + + 3. Or install Claude CLI: + + https://docs.claude.com/en/docs/claude-code/installation + +Verify installation: + ccs --version`; + + showError(errorMsg); +} + +module.exports = { + detectClaudeCli, + validateClaudeCli, + showClaudeNotFoundError +}; \ No newline at end of file diff --git a/bin/config-manager.js b/bin/config-manager.js new file mode 100644 index 00000000..8fd62d9d --- /dev/null +++ b/bin/config-manager.js @@ -0,0 +1,129 @@ +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const os = require('os'); +const { showError, expandPath, validateProfileName } = require('./helpers'); + +// Get config file path +function getConfigPath() { + return process.env.CCS_CONFIG || path.join(os.homedir(), '.ccs', 'config.json'); +} + +// Read and parse config +function readConfig() { + const configPath = getConfigPath(); + + // Check config exists + if (!fs.existsSync(configPath)) { + const isWindows = process.platform === 'win32'; + showError(`Config file not found: ${configPath} + +Solutions: + 1. Reinstall CCS: + ${isWindows ? 'irm ccs.kaitran.ca/install | iex' : '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`); + process.exit(1); + } + + // Read and parse JSON + let config; + try { + const configContent = fs.readFileSync(configPath, 'utf8'); + config = JSON.parse(configContent); + } catch (e) { + const isWindows = process.platform === 'win32'; + showError(`Invalid JSON in ${configPath} + +Fix the JSON syntax or reinstall: + ${isWindows ? 'irm ccs.kaitran.ca/install | iex' : 'curl -fsSL ccs.kaitran.ca/install | bash'}`); + process.exit(1); + } + + // Validate config has profiles object + if (!config.profiles || typeof config.profiles !== 'object') { + const isWindows = process.platform === 'win32'; + showError(`Config must have 'profiles' object + +See config.example.json for correct format +Or reinstall: + ${isWindows ? 'irm ccs.kaitran.ca/install | iex' : 'curl -fsSL ccs.kaitran.ca/install | bash'}`); + process.exit(1); + } + + return config; +} + +// Get settings path for profile +function getSettingsPath(profile) { + const config = readConfig(); + + // Validate profile name + if (!validateProfileName(profile)) { + showError(`Invalid profile name: ${profile} + +Use only alphanumeric characters, dash, or underscore.`); + process.exit(1); + } + + // Get settings path + const settingsPath = config.profiles[profile]; + + if (!settingsPath) { + const availableProfiles = Object.keys(config.profiles).map(p => ` - ${p}`).join('\n'); + showError(`Profile '${profile}' not found in ${getConfigPath()} + +Available profiles: +${availableProfiles}`); + process.exit(1); + } + + // Expand path + const expandedPath = expandPath(settingsPath); + + // Validate settings file exists + if (!fs.existsSync(expandedPath)) { + const isWindows = process.platform === 'win32'; + showError(`Settings file not found: ${expandedPath} + +Solutions: + 1. Create the settings file for profile '${profile}' + 2. Update the path in ${getConfigPath()} + 3. Or reinstall: ${isWindows ? 'irm ccs.kaitran.ca/install | iex' : 'curl -fsSL ccs.kaitran.ca/install | bash'}`); + process.exit(1); + } + + // Validate settings file is valid JSON + try { + const settingsContent = fs.readFileSync(expandedPath, 'utf8'); + JSON.parse(settingsContent); + } catch (e) { + showError(`Invalid JSON in ${expandedPath} + +Details: ${e.message} + +Solutions: + 1. Validate JSON at https://jsonlint.com + 2. Or reset to template: echo '{"env":{}}' > ${expandedPath} + 3. Or reinstall CCS`); + process.exit(1); + } + + return expandedPath; +} + +module.exports = { + getConfigPath, + readConfig, + getSettingsPath +}; \ No newline at end of file diff --git a/bin/helpers.js b/bin/helpers.js new file mode 100644 index 00000000..b53e1dcb --- /dev/null +++ b/bin/helpers.js @@ -0,0 +1,65 @@ +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const os = require('os'); + +// Color formatting (TTY-aware) +const useColors = process.stderr.isTTY && !process.env.NO_COLOR; +const colors = useColors ? { + red: '\x1b[0;31m', + yellow: '\x1b[1;33m', + cyan: '\x1b[0;36m', + green: '\x1b[0;32m', + bold: '\x1b[1m', + reset: '\x1b[0m' +} : { red: '', yellow: '', cyan: '', green: '', bold: '', reset: '' }; + +// Error formatting +function showError(message) { + console.error(''); + console.error(colors.red + colors.bold + '╔═════════════════════════════════════════════╗' + colors.reset); + console.error(colors.red + colors.bold + '║ ERROR ║' + colors.reset); + console.error(colors.red + colors.bold + '╚═════════════════════════════════════════════╝' + colors.reset); + console.error(''); + console.error(colors.red + message + colors.reset); + console.error(''); +} + +// Path expansion (~ and env vars) +function expandPath(pathStr) { + // Handle tilde expansion + if (pathStr.startsWith('~/') || pathStr.startsWith('~\\')) { + pathStr = path.join(os.homedir(), pathStr.slice(2)); + } + + // Expand environment variables (Windows and Unix) + pathStr = pathStr.replace(/\$\{([^}]+)\}/g, (_, name) => process.env[name] || ''); + pathStr = pathStr.replace(/\$([A-Z_][A-Z0-9_]*)/gi, (_, name) => process.env[name] || ''); + + // Windows %VAR% style + if (process.platform === 'win32') { + pathStr = pathStr.replace(/%([^%]+)%/g, (_, name) => process.env[name] || ''); + } + + return path.normalize(pathStr); +} + +// Validate profile name (alphanumeric, dash, underscore only) +function validateProfileName(profile) { + return /^[a-zA-Z0-9_-]+$/.test(profile); +} + +// Validate path safety (prevent injection) +function isPathSafe(pathStr) { + // Allow: alphanumeric, path separators, space, dash, underscore, dot, colon, tilde + return !/[;|&<>`$*?\[\]'"()]/.test(pathStr); +} + +module.exports = { + colors, + showError, + expandPath, + validateProfileName, + isPathSafe +}; \ No newline at end of file diff --git a/installers/install.ps1 b/installers/install.ps1 index e2b51604..5c9c616f 100644 --- a/installers/install.ps1 +++ b/installers/install.ps1 @@ -20,7 +20,7 @@ $ScriptDir = if ($MyInvocation.MyCommand.Path) { $null } -$InstallMethod = if ($ScriptDir -and ((Test-Path "$ScriptDir\ccs.ps1") -or (Test-Path "$ScriptDir\..\ccs.ps1"))) { +$InstallMethod = if ($ScriptDir -and ((Test-Path "$ScriptDir\lib\ccs.ps1") -or (Test-Path "$ScriptDir\..\lib\ccs.ps1"))) { "git" } else { "standalone" @@ -30,7 +30,7 @@ $InstallMethod = if ($ScriptDir -and ((Test-Path "$ScriptDir\ccs.ps1") -or (Test # IMPORTANT: Update this version when releasing new versions! # This hardcoded version is used for standalone installations (irm | iex) # For git installations, VERSION file is read if available -$CcsVersion = "2.3.0" +$CcsVersion = "2.4.0" # Try to read VERSION file for git installations if ($ScriptDir) { @@ -230,12 +230,12 @@ if ($InstallMethod -eq "standalone") { } } else { # Git install - copy local file - $CcsPs1Path = if (Test-Path "$ScriptDir\ccs.ps1") { - "$ScriptDir\ccs.ps1" - } elseif (Test-Path "$ScriptDir\..\ccs.ps1") { - "$ScriptDir\..\ccs.ps1" + $CcsPs1Path = if (Test-Path "$ScriptDir\lib\ccs.ps1") { + "$ScriptDir\lib\ccs.ps1" + } elseif (Test-Path "$ScriptDir\..\lib\ccs.ps1") { + "$ScriptDir\..\lib\ccs.ps1" } else { - throw "ccs.ps1 not found" + throw "lib\ccs.ps1 not found" } Copy-Item $CcsPs1Path "$CcsDir\ccs.ps1" -Force Write-Host "| [OK] Installed ccs.ps1" diff --git a/installers/install.sh b/installers/install.sh index f6c2c35c..c8807736 100755 --- a/installers/install.sh +++ b/installers/install.sh @@ -21,7 +21,7 @@ fi # Detect installation method (git vs standalone) # Check if ccs executable exists in SCRIPT_DIR or parent (real git install) # Don't just check .git (user might run curl | bash inside their own git repo) -if [[ -f "$SCRIPT_DIR/ccs" ]] || [[ -f "$SCRIPT_DIR/../ccs" ]]; then +if [[ -f "$SCRIPT_DIR/lib/ccs" ]] || [[ -f "$SCRIPT_DIR/../lib/ccs" ]]; then INSTALL_METHOD="git" else INSTALL_METHOD="standalone" @@ -31,7 +31,7 @@ fi # IMPORTANT: Update this version when releasing new versions! # This hardcoded version is used for standalone installations (curl | bash) # For git installations, VERSION file is read if available -CCS_VERSION="2.3.0" +CCS_VERSION="2.4.0" # Try to read VERSION file for git installations if [[ -f "$SCRIPT_DIR/VERSION" ]]; then @@ -410,15 +410,15 @@ if [[ "$INSTALL_METHOD" == "standalone" ]]; then else # Git install - use local ccs file # Handle both running from root or from installers/ subdirectory - if [[ -f "$SCRIPT_DIR/ccs" ]]; then - chmod +x "$SCRIPT_DIR/ccs" - ln -sf "$SCRIPT_DIR/ccs" "$INSTALL_DIR/ccs" - elif [[ -f "$SCRIPT_DIR/../ccs" ]]; then - chmod +x "$SCRIPT_DIR/../ccs" - ln -sf "$SCRIPT_DIR/../ccs" "$INSTALL_DIR/ccs" + if [[ -f "$SCRIPT_DIR/lib/ccs" ]]; then + chmod +x "$SCRIPT_DIR/lib/ccs" + ln -sf "$SCRIPT_DIR/lib/ccs" "$INSTALL_DIR/ccs" + elif [[ -f "$SCRIPT_DIR/../lib/ccs" ]]; then + chmod +x "$SCRIPT_DIR/../lib/ccs" + ln -sf "$SCRIPT_DIR/../lib/ccs" "$INSTALL_DIR/ccs" else echo "|" - echo "[X] Error: ccs executable not found" + echo "[X] Error: lib/ccs executable not found" exit 1 fi echo "| [OK] Installed executable" diff --git a/lib/ccs b/lib/ccs new file mode 100755 index 00000000..fd9c0b62 --- /dev/null +++ b/lib/ccs @@ -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" "$@" diff --git a/lib/ccs.ps1 b/lib/ccs.ps1 new file mode 100644 index 00000000..a1e97cc6 --- /dev/null +++ b/lib/ccs.ps1 @@ -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 +} diff --git a/package.json b/package.json new file mode 100644 index 00000000..1241a002 --- /dev/null +++ b/package.json @@ -0,0 +1,52 @@ +{ + "name": "@kai/ccs", + "version": "2.4.0", + "description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6", + "keywords": [ + "cli", + "claude", + "glm", + "ai", + "profile", + "switch" + ], + "homepage": "https://github.com/kaitranntt/ccs", + "bugs": { + "url": "https://github.com/kaitranntt/ccs/issues" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/kaitranntt/ccs.git" + }, + "license": "MIT", + "author": { + "name": "Tam Nhu Tran (Kai)", + "email": "kaitranntt@users.noreply.github.com" + }, + "bin": { + "ccs": "bin/ccs.js" + }, + "files": [ + "bin/", + "lib/", + "config/", + "VERSION", + "README.md", + "LICENSE" + ], + "engines": { + "node": ">=14.0.0" + }, + "os": [ + "darwin", + "linux", + "win32" + ], + "preferGlobal": true, + "scripts": { + "test": "bash tests/edge-cases.sh", + "prepublishOnly": "node scripts/sync-version.js", + "prepack": "node scripts/sync-version.js", + "prepare": "node scripts/check-executables.js" + } +} diff --git a/scripts/bump-version.sh b/scripts/bump-version.sh index 15184e64..692d9def 100755 --- a/scripts/bump-version.sh +++ b/scripts/bump-version.sh @@ -58,10 +58,11 @@ echo "New version: $NEW_VERSION" echo "" echo "This will update hardcoded versions in:" echo " 1. VERSION file" -echo " 2. ccs (bash executable)" -echo " 3. ccs.ps1 (PowerShell executable)" -echo " 4. installers/install.sh" -echo " 5. installers/install.ps1" +echo " 2. lib/ccs (bash executable)" +echo " 3. lib/ccs.ps1 (PowerShell executable)" +echo " 4. package.json (via sync-version.js)" +echo " 5. installers/install.sh" +echo " 6. installers/install.ps1" echo "" read -p "Continue? (y/N) " -n 1 -r echo @@ -76,23 +77,23 @@ echo "$NEW_VERSION" > "$VERSION_FILE" echo "✓ Updated VERSION file to $NEW_VERSION" # Update ccs (bash executable) -CCS_BASH="$CCS_DIR/ccs" +CCS_BASH="$CCS_DIR/lib/ccs" if [[ -f "$CCS_BASH" ]]; then sed -i.bak "s/^CCS_VERSION=\".*\"/CCS_VERSION=\"$NEW_VERSION\"/" "$CCS_BASH" rm -f "$CCS_BASH.bak" - echo "✓ Updated ccs (bash executable)" + echo "✓ Updated lib/ccs (bash executable)" else - echo "⚠ ccs not found, skipping" + echo "⚠ lib/ccs not found, skipping" fi # Update ccs.ps1 (PowerShell executable) -CCS_PS1="$CCS_DIR/ccs.ps1" +CCS_PS1="$CCS_DIR/lib/ccs.ps1" if [[ -f "$CCS_PS1" ]]; then sed -i.bak "s/^\$CcsVersion = \".*\"/\$CcsVersion = \"$NEW_VERSION\"/" "$CCS_PS1" rm -f "$CCS_PS1.bak" - echo "✓ Updated ccs.ps1 (PowerShell executable)" + echo "✓ Updated lib/ccs.ps1 (PowerShell executable)" else - echo "⚠ ccs.ps1 not found, skipping" + echo "⚠ lib/ccs.ps1 not found, skipping" fi # Update installers/install.sh @@ -115,5 +116,21 @@ else echo "⚠ installers/install.ps1 not found, skipping" fi +# Sync version to package.json +echo "Syncing version to package.json..." +if node "$SCRIPT_DIR/sync-version.js"; then + echo "✓ Synced version to package.json" +else + echo "✗ Error: Failed to sync version to package.json" + exit 1 +fi + echo "" echo "✓ Version bumped to $NEW_VERSION" +echo "" +echo "Next steps:" +echo " 1. Review changes: git diff" +echo " 2. Commit: git add VERSION package.json lib/ccs lib/ccs.ps1 installers/install.sh installers/install.ps1" +echo " 3. Commit: git commit -m \"chore: bump version to $NEW_VERSION\"" +echo " 4. Tag: git tag v$NEW_VERSION" +echo " 5. Push: git push origin main && git push origin v$NEW_VERSION"