From f034c25a611ed418e333ea128f3ddcd4458be199 Mon Sep 17 00:00:00 2001 From: "Tam Nhu (Kai) Tran" <61256810+kaitranntt@users.noreply.github.com> Date: Sun, 9 Nov 2025 18:26:49 -0500 Subject: [PATCH] feat: implement native multi-account switching with isolated instances (#3) * feat: implement native multi-account switching with isolated instances Add account-based profile management system that enables users to run multiple Claude accounts concurrently with complete isolation. Key features: - Profile registry (~/.ccs/profiles.json) tracks account profiles - Instance isolation (~/.ccs/instances//) for each account - Auth commands: create, list, show, remove, default - Backward compatible with settings-based profiles (GLM, Kimi) - Auto-copies global .claude configs to new instances Implementation: - Phase 1: Profile detection logic (account vs settings-based) - Phase 2: Instance management (initialization, validation) - Phase 3: Auth CLI commands - Phase 4: Profile registry CRUD operations - Phase 5: Execution routing based on profile type Both lib/ccs (bash) and lib/ccs.ps1 (PowerShell) updated for cross-platform support. * fix(ci): add pull_request trigger to satisfy branch protection The branch protection rule requires "Deploy ccs-installer to CloudFlare" status check to pass, but the workflow only ran on push to main branch. This caused PRs to wait indefinitely for a status that never reported. Changes: - Add pull_request trigger with same path filters - Split deployment into conditional steps: - PR mode: dry-run validation only (--dry-run flag) - Production: actual deployment (push to main only) - Keeps same job name to satisfy branch protection requirement Security: - No deployment from PR branches (dry-run only) - Production deploy only when push to main AND ref check - Secrets used safely in both contexts This fixes PR #3 which was stuck waiting for status. * fix(ci): remove path filter from pull_request trigger The path filter prevented workflow from running on PRs that don't modify worker/installer files, causing required status check to never report. This blocked PR #3 indefinitely. Changes: - Remove paths filter from pull_request trigger - Keep paths filter on push to main (optimize deployments) - Workflow now runs on ALL PRs to satisfy branch protection Trade-off: - PRs changing non-worker files will trigger unnecessary dry-run - But this ensures required status check always reports - Small cost for reliability and simpler configuration Alternatives considered: - Path-aware required checks: Not supported by GitHub - Remove required check: Loses CI validation - Add all paths to filter: Makes config brittle --- .github/workflows/deploy-ccs-worker.yml | 17 +- lib/ccs | 952 ++++++++++++++++-------- lib/ccs.ps1 | 830 ++++++++++++++++++--- 3 files changed, 1365 insertions(+), 434 deletions(-) diff --git a/.github/workflows/deploy-ccs-worker.yml b/.github/workflows/deploy-ccs-worker.yml index a2e58204..6391e91f 100644 --- a/.github/workflows/deploy-ccs-worker.yml +++ b/.github/workflows/deploy-ccs-worker.yml @@ -8,6 +8,10 @@ on: - 'scripts/wrangler.toml' - 'installers/**' + # Run on all PRs to satisfy branch protection (no path filter) + pull_request: + branches: [main] + # Allow manual trigger workflow_dispatch: @@ -20,7 +24,18 @@ jobs: - name: Checkout code uses: actions/checkout@v4 - - name: Deploy Worker + - name: Validate Worker (PR Mode) + if: github.event_name == 'pull_request' + uses: cloudflare/wrangler-action@v3 + with: + apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} + accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + workingDirectory: scripts + wranglerVersion: "4.45.3" + command: deploy --dry-run + + - name: Deploy Worker (Production) + if: github.event_name == 'push' && github.ref == 'refs/heads/main' uses: cloudflare/wrangler-action@v3 with: apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} diff --git a/lib/ccs b/lib/ccs index cb6cbf74..dc9c1094 100755 --- a/lib/ccs +++ b/lib/ccs @@ -5,6 +5,8 @@ set -euo pipefail CCS_VERSION="3.0.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" # --- Color/Format Functions --- setup_colors() { @@ -13,12 +15,13 @@ setup_colors() { ([[ -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='' YELLOW='' CYAN='' BOLD='' RESET='' + RED='' GREEN='' YELLOW='' CYAN='' BOLD='' RESET='' fi } @@ -37,53 +40,48 @@ show_help() { echo "" echo -e "${CYAN}Usage:${RESET}" echo -e " ${YELLOW}ccs${RESET} [profile] [claude-args...]" + echo -e " ${YELLOW}ccs auth${RESET} [options]" echo -e " ${YELLOW}ccs${RESET} [flags]" echo "" echo -e "${CYAN}Description:${RESET}" - echo -e " Switch between Claude models instantly. Stop hitting rate limits." - echo -e " Maps profile names to Claude settings files via ~/.ccs/config.json" + echo -e " Switch between Claude accounts and models instantly." + echo -e " Supports concurrent multi-account sessions." echo "" echo -e "${CYAN}Profile Switching:${RESET}" echo -e " ${YELLOW}ccs${RESET} Use default profile" echo -e " ${YELLOW}ccs glm${RESET} Switch to GLM profile" - echo -e " ${YELLOW}ccs kimi${RESET} Switch to Kimi profile" - echo -e " ${YELLOW}ccs glm${RESET} \"debug this code\" Switch to GLM and run command" - echo -e " ${YELLOW}ccs kimi${RESET} \"write tests\" Switch to Kimi and run command" - echo -e " ${YELLOW}ccs glm${RESET} --verbose Switch to GLM with Claude flags" - echo -e " ${YELLOW}ccs kimi${RESET} --verbose Switch to Kimi with Claude flags" + echo -e " ${YELLOW}ccs work${RESET} Switch to work account" + echo -e " ${YELLOW}ccs work${RESET} \"debug code\" Run command with work account" + echo "" + echo -e "${CYAN}Account Management:${RESET}" + echo -e " ${YELLOW}ccs auth create ${RESET} Create new account profile" + echo -e " ${YELLOW}ccs auth list${RESET} List all profiles" + echo -e " ${YELLOW}ccs auth show ${RESET} Show profile details" + echo -e " ${YELLOW}ccs auth remove ${RESET} Remove profile (requires --force)" + echo -e " ${YELLOW}ccs auth default ${RESET} Set default profile" 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 "" + echo "" echo -e "${CYAN}Configuration:${RESET}" - echo -e " Config File: ~/.ccs/config.json" - echo -e " Settings: ~/.ccs/*.settings.json" - echo -e " Environment: CCS_CONFIG (override config path)" + echo -e " Config: ~/.ccs/config.json" + echo -e " Profiles: ~/.ccs/profiles.json" + echo -e " Settings: ~/.ccs/*.settings.json" echo "" echo -e "${CYAN}Examples:${RESET}" - echo -e " # Use default Claude subscription" - echo -e " ${YELLOW}ccs${RESET} \"Review this architecture\"" + echo -e " # Create work account profile" + echo -e " ${YELLOW}ccs auth create work${RESET}" + echo "" + echo -e " # Use work account" + echo -e " ${YELLOW}ccs work${RESET} \"Review architecture\"" echo "" echo -e " # Switch to GLM for cost-effective tasks" echo -e " ${YELLOW}ccs glm${RESET} \"Write unit tests\"" echo "" - echo -e " # Switch to Kimi for alternative option" - echo -e " ${YELLOW}ccs kimi${RESET} \"Write integration tests\"" - echo "" - echo -e " # Use with verbose output" - echo -e " ${YELLOW}ccs glm${RESET} --verbose \"Debug error\"" - echo -e " ${YELLOW}ccs kimi${RESET} --verbose \"Review code\"" - echo "" - echo -e "${YELLOW}Uninstall:${RESET}" - echo -e " macOS/Linux: curl -fsSL ccs.kaitran.ca/uninstall | bash" - echo -e " Windows: irm ccs.kaitran.ca/uninstall | iex" - echo -e " npm: npm uninstall -g @kaitranntt/ccs" - 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" } @@ -124,199 +122,6 @@ Solutions: Restart your terminal after installation." } -# WIP: .claude/ integration testing incomplete -# Feature disabled until testing complete -# install_commands_and_skills() { -: <<'COMMENTED_OUT' - # 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'" -COMMENTED_OUT -# } - -# WIP: .claude/ integration testing incomplete -# Feature disabled until testing complete -# uninstall_commands_and_skills() { -: <<'COMMENTED_OUT' - 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" -COMMENTED_OUT -# } - show_version() { echo -e "${BOLD}CCS (Claude Code Switch) v${CCS_VERSION}${RESET}" echo "" @@ -337,6 +142,573 @@ show_version() { 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 + }') + + # Set as default if none exists + local has_default=$(echo "$data" | jq -r '.default // empty') + if [[ -z "$has_default" ]]; then + data=$(echo "$data" | jq --arg name "$profile_name" '.default = $name') + fi + + 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' +} + +# Initialize new instance directory +initialize_instance() { + local instance_path="$1" + + # Create base directory + mkdir -m 0700 -p "$instance_path" + + # Create subdirectories + local subdirs=(session-env todos logs file-history shell-snapshots debug .anthropic commands skills) + for dir in "${subdirs[@]}"; do + mkdir -m 0700 -p "$instance_path/$dir" + done + + # 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 + [[ -f "$global_claude/settings.json" ]] && \ + cp "$global_claude/settings.json" "$instance_path/settings.json" 2>/dev/null || true + + # Copy commands/ + [[ -d "$global_claude/commands" ]] && \ + cp -r "$global_claude/commands" "$instance_path/" 2>/dev/null || true + + # Copy skills/ + [[ -d "$global_claude/skills" ]] && \ + cp -r "$global_claude/skills" "$instance_path/" 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 +list_available_profiles() { + local lines=() + + # Settings-based profiles + if [[ -f "$CONFIG_FILE" ]]; then + local settings_profiles=$(jq -r '.profiles | keys[]' "$CONFIG_FILE" 2>/dev/null || true) + if [[ -n "$settings_profiles" ]]; then + lines+=("Settings-based profiles (GLM, Kimi, etc.):") + while IFS= read -r name; do + lines+=(" - $name") + done <<< "$settings_profiles" + fi + fi + + # Account-based profiles + if [[ -f "$PROFILES_JSON" ]]; then + local account_profiles=$(jq -r '.profiles | keys[]' "$PROFILES_JSON" 2>/dev/null || true) + local default_profile=$(jq -r '.default // empty' "$PROFILES_JSON" 2>/dev/null || true) + + if [[ -n "$account_profiles" ]]; then + lines+=("Account-based profiles:") + while IFS= read -r name; do + local is_default="" + [[ "$name" == "$default_profile" ]] && is_default=" [DEFAULT]" + lines+=(" - $name$is_default") + done <<< "$account_profiles" + fi + fi + + if [[ ${#lines[@]} -eq 0 ]]; then + echo " (no profiles configured)" + echo " Run \"ccs auth create \" to create your first account profile." + else + printf '%s\n' "${lines[@]}" + fi +} + +# Detect profile type and return info +# Sets global variables: PROFILE_TYPE, PROFILE_PATH/INSTANCE_PATH +detect_profile_type() { + local profile_name="$1" + + # Special case: 'default' resolves to default profile + if [[ "$profile_name" == "default" ]]; then + # Check account-based default first + if [[ -f "$PROFILES_JSON" ]]; then + local default_account=$(jq -r '.default // empty' "$PROFILES_JSON" 2>/dev/null || true) + if [[ -n "$default_account" ]] && profile_exists "$default_account"; then + PROFILE_TYPE="account" + PROFILE_NAME="$default_account" + return 0 + fi + fi + + # Check settings-based default + if [[ -f "$CONFIG_FILE" ]]; then + local default_settings=$(jq -r '.profiles.default // empty' "$CONFIG_FILE" 2>/dev/null || true) + if [[ -n "$default_settings" ]]; then + PROFILE_TYPE="settings" + PROFILE_PATH="$default_settings" + PROFILE_NAME="default" + return 0 + fi + fi + + # No default configured, use Claude's defaults + PROFILE_TYPE="default" + PROFILE_NAME="default" + return 0 + fi + + # Priority 1: Check settings-based profiles (backward compatibility) + if [[ -f "$CONFIG_FILE" ]]; then + local settings_path=$(jq -r ".profiles.\"$profile_name\" // empty" "$CONFIG_FILE" 2>/dev/null || true) + if [[ -n "$settings_path" ]]; then + PROFILE_TYPE="settings" + PROFILE_PATH="$settings_path" + PROFILE_NAME="$profile_name" + return 0 + fi + fi + + # Priority 2: Check account-based profiles + if [[ -f "$PROFILES_JSON" ]] && profile_exists "$profile_name"; then + PROFILE_TYPE="account" + PROFILE_NAME="$profile_name" + return 0 + fi + + # Not found + PROFILE_TYPE="error" + return 1 +} + +# --- Auth Commands (Phase 3) --- + +auth_help() { + echo -e "${BOLD}CCS Account Management${RESET}" + echo "" + echo -e "${CYAN}Usage:${RESET}" + echo -e " ${YELLOW}ccs auth${RESET} [options]" + echo "" + echo -e "${CYAN}Commands:${RESET}" + echo -e " ${YELLOW}create ${RESET} Create new profile and login" + echo -e " ${YELLOW}list${RESET} List all saved profiles" + echo -e " ${YELLOW}show ${RESET} Show profile details" + echo -e " ${YELLOW}remove ${RESET} Remove saved profile" + echo -e " ${YELLOW}default ${RESET} Set default profile" + echo "" + echo -e "${CYAN}Examples:${RESET}" + echo -e " ${YELLOW}ccs auth create work${RESET}" + echo -e " ${YELLOW}ccs auth list${RESET}" + echo -e " ${YELLOW}ccs auth remove work --force${RESET}" + echo "" +} + +auth_create() { + local profile_name="" + local force=false + + # Parse arguments + while [[ $# -gt 0 ]]; do + case "$1" in + --force) force=true ;; + -*) msg_error "Unknown option: $1"; return 1 ;; + *) profile_name="$1" ;; + esac + shift + done + + # Validate profile name + [[ -z "$profile_name" ]] && { + msg_error "Profile name is required" + echo "" + echo "Usage: ${YELLOW}ccs auth create [--force]${RESET}" + return 1 + } + + # Check if exists + if ! $force && profile_exists "$profile_name"; then + msg_error "Profile already exists: $profile_name" + echo "Use ${YELLOW}--force${RESET} to overwrite" + return 1 + fi + + # Create instance + echo "[i] Creating profile: $profile_name" + local instance_path=$(ensure_instance "$profile_name") + echo "[i] Instance directory: $instance_path" + echo "" + + # Register profile + register_profile "$profile_name" + + # Launch Claude for login + echo -e "${YELLOW}[i] Starting Claude in isolated instance...${RESET}" + echo -e "${YELLOW}[i] You will be prompted to login with your account.${RESET}" + echo "" + + CLAUDE_CONFIG_DIR="$instance_path" $(detect_claude_cli) || { + msg_error "Login failed or cancelled" + echo "To retry: ${YELLOW}ccs auth create $profile_name --force${RESET}" + return 1 + } + + echo "" + echo -e "${GREEN}[OK] Profile created successfully${RESET}" + echo "" + echo " Profile: $profile_name" + echo " Instance: $instance_path" + echo "" + echo "Usage:" + echo " ${YELLOW}ccs $profile_name \"your prompt here\"${RESET}" + echo "" +} + +auth_list() { + local verbose=false + [[ "${1:-}" == "--verbose" ]] && verbose=true + + # Read profiles.json + [[ ! -f "$PROFILES_JSON" ]] && { + echo -e "${YELLOW}No account profiles found${RESET}" + echo "" + echo "To create your first profile:" + echo " ${YELLOW}ccs auth create ${RESET}" + return 0 + } + + local profiles=$(jq -r '.profiles | keys[]' "$PROFILES_JSON" 2>/dev/null || true) + local default_profile=$(jq -r '.default // empty' "$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="${1:-}" + + [[ -z "$profile_name" ]] && { + msg_error "Profile name is required" + echo "Usage: ${YELLOW}ccs auth show ${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 + + echo -e "${BOLD}Profile: $profile_name${RESET}" + echo "" + + 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 // \"Never\"" "$PROFILES_JSON" 2>/dev/null || true) + local instance_path="$INSTANCES_DIR/$(sanitize_profile_name "$profile_name")" + + echo " Type: $type" + echo " Default: $($is_default && echo "Yes" || echo "No")" + echo " Instance: $instance_path" + echo " Created: $created" + echo " Last used: $last_used" + echo "" +} + +auth_remove() { + local profile_name="" + local force=false + + while [[ $# -gt 0 ]]; do + case "$1" in + --force) force=true ;; + *) profile_name="$1" ;; + esac + shift + done + + [[ -z "$profile_name" ]] && { + msg_error "Profile name is required" + echo "Usage: ${YELLOW}ccs auth remove --force${RESET}" + return 1 + } + + profile_exists "$profile_name" || { + msg_error "Profile not found: $profile_name" + return 1 + } + + $force || { + msg_error "Removal requires --force flag for safety" + echo "Run: ${YELLOW}ccs auth remove $profile_name --force${RESET}" + return 1 + } + + # Delete instance directory + local instance_path="$INSTANCES_DIR/$(sanitize_profile_name "$profile_name")" + rm -rf "$instance_path" + + # Remove from registry + unregister_profile "$profile_name" + + echo -e "${GREEN}[OK] Profile removed successfully${RESET}" + echo " Profile: $profile_name" + echo "" +} + +auth_default() { + local profile_name="${1:-}" + + [[ -z "$profile_name" ]] && { + msg_error "Profile name is required" + echo "Usage: ${YELLOW}ccs auth default ${RESET}" + return 1 + } + + profile_exists "$profile_name" || { + msg_error "Profile not found: $profile_name" + return 1 + } + + set_default_profile "$profile_name" + + echo -e "${GREEN}[OK] Default profile set${RESET}" + echo " Profile: $profile_name" + echo "" + echo "Now you can use:" + echo " ${YELLOW}ccs \"your prompt\"${RESET} # Uses $profile_name profile" + echo "" +} + +handle_auth_commands() { + shift # Remove 'auth' + + local subcommand="${1:-}" + shift || true + + case "$subcommand" in + create) auth_create "$@" ;; + list) auth_list "$@" ;; + show) auth_show "$@" ;; + remove) auth_remove "$@" ;; + default) auth_default "$@" ;; + *) auth_help ;; + esac +} + +# --- Main Execution Logic --- + # Special case: version command (check BEFORE profile detection) if [[ $# -gt 0 ]] && [[ "${1}" == "version" || "${1}" == "--version" || "${1}" == "-v" ]]; then show_version @@ -345,35 +717,14 @@ fi # Special case: help command (check BEFORE profile detection) if [[ $# -gt 0 ]] && [[ "${1}" == "--help" || "${1}" == "-h" || "${1}" == "help" ]]; then - setup_colors show_help exit 0 fi -# Special case: install command (check BEFORE profile detection) -if [[ $# -gt 0 ]] && [[ "${1}" == "--install" ]]; then - echo "" - echo "Feature not available" - echo "" - echo "The --install flag is currently under development." - echo ".claude/ integration testing is not complete." - echo "" - echo "For updates: https://github.com/kaitranntt/ccs/issues" - echo "" - exit 0 -fi - -# Special case: uninstall command (check BEFORE profile detection) -if [[ $# -gt 0 ]] && [[ "${1}" == "--uninstall" ]]; then - echo "" - echo "Feature not available" - echo "" - echo "The --uninstall flag is currently under development." - echo ".claude/ integration testing is not complete." - echo "" - echo "For updates: https://github.com/kaitranntt/ccs/issues" - echo "" - exit 0 +# Special case: auth commands +if [[ $# -gt 0 ]] && [[ "${1}" == "auth" ]]; then + handle_auth_commands "$@" + exit $? fi # Smart profile detection: if first arg starts with '-', it's a flag not a profile @@ -385,29 +736,6 @@ else 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\", - \"kimi\": \"~/.ccs/kimi.settings.json\", - \"default\": \"~/.claude/settings.json\" - } -} -EOF" - exit 1 -fi - - # Validate profile name (alphanumeric, dash, underscore only) if [[ "$PROFILE" =~ [^a-zA-Z0-9_-] ]]; then msg_error "Invalid profile name: $PROFILE @@ -416,44 +744,12 @@ Use only alphanumeric characters, dash, or underscore." exit 1 fi -# Single check gets profile path, validates JSON + structure in one step -SETTINGS_PATH=$(jq -r --arg profile "$PROFILE" '.profiles[$profile] // empty' "$CONFIG_FILE" 2>/dev/null) - -if [[ -z "$SETTINGS_PATH" ]]; then - # Could be: invalid JSON, no profiles object, or profile not found - # Show helpful error based on what we can detect - 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" - elif ! 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" - else - AVAILABLE_PROFILES=$(jq -r '.profiles | keys[]' "$CONFIG_FILE" 2>/dev/null | sed 's/^/ - /') - msg_error "Profile '$PROFILE' not found in $CONFIG_FILE +# Detect profile type +if ! detect_profile_type "$PROFILE"; then + msg_error "Profile '$PROFILE' not found Available profiles: -$AVAILABLE_PROFILES" - fi - 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" +$(list_available_profiles)" exit 1 fi @@ -465,9 +761,45 @@ fi # Detect Claude CLI executable CLAUDE_CLI=$(detect_claude_cli) -# Execute Claude with the profile settings -# If claude is not found, exec will fail and show an error -if ! exec "$CLAUDE_CLI" --settings "$SETTINGS_PATH" "$@"; then - show_claude_not_found_error - exit 1 -fi +# 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 diff --git a/lib/ccs.ps1 b/lib/ccs.ps1 index 6b82c2d1..26c0e857 100644 --- a/lib/ccs.ps1 +++ b/lib/ccs.ps1 @@ -11,6 +11,13 @@ param( $ErrorActionPreference = "Stop" +# Version (updated by scripts/bump-version.sh) +$CcsVersion = "3.0.0" +$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$ConfigFile = if ($env:CCS_CONFIG) { $env:CCS_CONFIG } else { "$env:USERPROFILE\.ccs\config.json" } +$ProfilesJson = "$env:USERPROFILE\.ccs\profiles.json" +$InstancesDir = "$env:USERPROFILE\.ccs\instances" + # --- Color/Format Functions --- function Write-ErrorMsg { param([string]$Message) @@ -89,62 +96,52 @@ function Show-Help { Write-Host "" Write-ColorLine "Usage:" "Cyan" Write-ColorLine " ccs [profile] [claude-args...]" "Yellow" + Write-ColorLine " ccs auth [options]" "Yellow" Write-ColorLine " ccs [flags]" "Yellow" Write-Host "" Write-ColorLine "Description:" "Cyan" - Write-Host " Switch between Claude models instantly. Stop hitting rate limits." - Write-Host " Maps profile names to Claude settings files via ~/.ccs/config.json" + Write-Host " Switch between Claude accounts and models instantly." + Write-Host " Supports concurrent multi-account sessions." Write-Host "" Write-ColorLine "Profile Switching:" "Cyan" Write-ColorLine " ccs Use default profile" "Yellow" Write-ColorLine " ccs glm Switch to GLM profile" "Yellow" - Write-ColorLine " ccs kimi Switch to Kimi profile" "Yellow" - Write-ColorLine " ccs glm 'debug this code' Switch to GLM and run command" "Yellow" - Write-ColorLine " ccs kimi 'write tests' Switch to Kimi and run command" "Yellow" - Write-ColorLine " ccs glm --verbose Switch to GLM with Claude flags" "Yellow" - Write-ColorLine " ccs kimi --verbose Switch to Kimi with Claude flags" "Yellow" + Write-ColorLine " ccs work Switch to work account" "Yellow" + Write-ColorLine " ccs work 'debug code' Run command with work account" "Yellow" + Write-Host "" + Write-ColorLine "Account Management:" "Cyan" + Write-ColorLine " ccs auth create Create new account profile" "Yellow" + Write-ColorLine " ccs auth list List all profiles" "Yellow" + Write-ColorLine " ccs auth show Show profile details" "Yellow" + Write-ColorLine " ccs auth remove Remove profile (requires --force)" "Yellow" + Write-ColorLine " ccs auth default Set default profile" "Yellow" Write-Host "" Write-ColorLine "Flags:" "Cyan" Write-ColorLine " -h, --help Show this help message" "Yellow" Write-ColorLine " -v, --version Show version and installation info" "Yellow" - Write-Host "" + Write-Host "" Write-ColorLine "Configuration:" "Cyan" - Write-Host " Config File: ~/.ccs/config.json" - Write-Host " Settings: ~/.ccs/*.settings.json" - Write-Host " Environment: CCS_CONFIG (override config path)" + Write-Host " Config: ~/.ccs/config.json" + Write-Host " Profiles: ~/.ccs/profiles.json" + Write-Host " Settings: ~/.ccs/*.settings.json" Write-Host "" Write-ColorLine "Examples:" "Cyan" - Write-Host " # Use default Claude subscription" - Write-ColorLine " ccs 'Review this architecture'" "Yellow" + Write-Host " # Create work account profile" + Write-ColorLine " ccs auth create work" "Yellow" + Write-Host "" + Write-Host " # Use work account" + Write-ColorLine " ccs work 'Review architecture'" "Yellow" Write-Host "" Write-Host " # Switch to GLM for cost-effective tasks" Write-ColorLine " ccs glm 'Write unit tests'" "Yellow" Write-Host "" - Write-Host " # Switch to Kimi for alternative option" - Write-ColorLine " ccs kimi 'Write integration tests'" "Yellow" - Write-Host "" - Write-Host " # Use with verbose output" - Write-ColorLine " ccs glm --verbose 'Debug error'" "Yellow" - Write-ColorLine " ccs kimi --verbose 'Review code'" "Yellow" - Write-Host "" - Write-ColorLine "Uninstall:" "Cyan" - Write-Host " macOS/Linux: curl -fsSL ccs.kaitran.ca/uninstall | bash" - Write-Host " Windows: irm ccs.kaitran.ca/uninstall | iex" - Write-Host " npm: npm uninstall -g @kaitranntt/ccs" - Write-Host "" Write-ColorLine "Documentation:" "Cyan" Write-Host " GitHub: https://github.com/kaitranntt/ccs" Write-Host " Docs: https://github.com/kaitranntt/ccs/blob/main/README.md" - Write-Host " Issues: https://github.com/kaitranntt/ccs/issues" Write-Host "" Write-ColorLine "License: MIT" "Cyan" } -# Version (updated by scripts/bump-version.sh) -$CcsVersion = "3.0.0" -$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path -$ConfigFile = if ($env:CCS_CONFIG) { $env:CCS_CONFIG } else { "$env:USERPROFILE\.ccs\config.json" } - function Show-Version { $UseColors = $env:FORCE_COLOR -or ([Console]::IsOutputRedirected -eq $false -and -not $env:NO_COLOR) @@ -205,8 +202,604 @@ function Show-Version { } } +# --- Profile Registry Functions (Phase 4) --- + +function Initialize-ProfilesJson { + if (Test-Path $ProfilesJson) { return } + + $InitData = @{ + version = "2.0.0" + profiles = @{} + default = $null + } + + $InitData | ConvertTo-Json -Depth 10 | Set-Content $ProfilesJson + + # Set file permissions (user-only) + $Acl = Get-Acl $ProfilesJson + $Acl.SetAccessRuleProtection($true, $false) + $Acl.Access | ForEach-Object { $Acl.RemoveAccessRule($_) | Out-Null } + $CurrentUser = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name + $Rule = New-Object System.Security.AccessControl.FileSystemAccessRule( + $CurrentUser, "FullControl", "Allow" + ) + $Acl.AddAccessRule($Rule) + Set-Acl -Path $ProfilesJson -AclObject $Acl +} + +function Read-ProfilesJson { + Initialize-ProfilesJson + Get-Content $ProfilesJson -Raw | ConvertFrom-Json +} + +function Write-ProfilesJson { + param([PSCustomObject]$Data) + + $TempFile = "$ProfilesJson.tmp" + + try { + $Data | ConvertTo-Json -Depth 10 | Set-Content $TempFile + Move-Item $TempFile $ProfilesJson -Force + } catch { + if (Test-Path $TempFile) { Remove-Item $TempFile -Force } + throw "Failed to write profiles registry: $_" + } +} + +function Test-ProfileExists { + param([string]$ProfileName) + + Initialize-ProfilesJson + $Data = Read-ProfilesJson + return $Data.profiles.PSObject.Properties.Name -contains $ProfileName +} + +function Register-Profile { + param([string]$ProfileName) + + if (Test-ProfileExists $ProfileName) { + throw "Profile already exists: $ProfileName" + } + + $Data = Read-ProfilesJson + $Timestamp = (Get-Date).ToUniversalTime().ToString("o") + + $Data.profiles | Add-Member -NotePropertyName $ProfileName -NotePropertyValue ([PSCustomObject]@{ + type = "account" + created = $Timestamp + last_used = $null + }) + + # Set as default if none exists + if (-not $Data.default) { + $Data.default = $ProfileName + } + + Write-ProfilesJson $Data +} + +function Unregister-Profile { + param([string]$ProfileName) + + if (-not (Test-ProfileExists $ProfileName)) { return } # Idempotent + + $Data = Read-ProfilesJson + + # Remove profile + $Data.profiles.PSObject.Properties.Remove($ProfileName) + + # Update default if it was the deleted profile + if ($Data.default -eq $ProfileName) { + $Remaining = $Data.profiles.PSObject.Properties.Name + $Data.default = if ($Remaining.Count -gt 0) { $Remaining[0] } else { $null } + } + + Write-ProfilesJson $Data +} + +function Update-ProfileTimestamp { + param([string]$ProfileName) + + if (-not (Test-ProfileExists $ProfileName)) { return } # Silent fail + + $Data = Read-ProfilesJson + $Timestamp = (Get-Date).ToUniversalTime().ToString("o") + + $Data.profiles.$ProfileName.last_used = $Timestamp + + Write-ProfilesJson $Data +} + +function Get-DefaultProfile { + Initialize-ProfilesJson + $Data = Read-ProfilesJson + return $Data.default +} + +function Set-DefaultProfile { + param([string]$ProfileName) + + if (-not (Test-ProfileExists $ProfileName)) { + throw "Profile not found: $ProfileName" + } + + $Data = Read-ProfilesJson + $Data.default = $ProfileName + + Write-ProfilesJson $Data +} + +# --- Instance Management Functions (Phase 2) --- + +function Get-SanitizedProfileName { + param([string]$Name) + + # Replace unsafe chars, lowercase + return $Name.ToLower() -replace '[^a-z0-9_-]', '-' +} + +function Set-InstancePermissions { + param([string]$Path) + + # Get current user + $CurrentUser = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name + + # Create new ACL with inheritance disabled + $Acl = Get-Acl $Path + $Acl.SetAccessRuleProtection($true, $false) # Disable inheritance + $Acl.Access | ForEach-Object { $Acl.RemoveAccessRule($_) | Out-Null } + + # Grant full control to current user only + $Rule = New-Object System.Security.AccessControl.FileSystemAccessRule( + $CurrentUser, "FullControl", "ContainerInherit,ObjectInherit", "None", "Allow" + ) + $Acl.AddAccessRule($Rule) + + Set-Acl -Path $Path -AclObject $Acl +} + +function Copy-GlobalConfigs { + param([string]$InstancePath) + + $GlobalClaude = "$env:USERPROFILE\.claude" + + # Copy settings.json + $GlobalSettings = Join-Path $GlobalClaude "settings.json" + if (Test-Path $GlobalSettings) { + Copy-Item $GlobalSettings -Destination (Join-Path $InstancePath "settings.json") -ErrorAction SilentlyContinue + } + + # Copy commands/ + $GlobalCommands = Join-Path $GlobalClaude "commands" + if (Test-Path $GlobalCommands) { + Copy-Item $GlobalCommands -Destination $InstancePath -Recurse -ErrorAction SilentlyContinue + } + + # Copy skills/ + $GlobalSkills = Join-Path $GlobalClaude "skills" + if (Test-Path $GlobalSkills) { + Copy-Item $GlobalSkills -Destination $InstancePath -Recurse -ErrorAction SilentlyContinue + } +} + +function Initialize-Instance { + param([string]$InstancePath) + + # Create base directory with user-only ACL + New-Item -ItemType Directory -Path $InstancePath -Force | Out-Null + Set-InstancePermissions $InstancePath + + # Create subdirectories + $Subdirs = @('session-env', 'todos', 'logs', 'file-history', + 'shell-snapshots', 'debug', '.anthropic', 'commands', 'skills') + + foreach ($Dir in $Subdirs) { + $DirPath = Join-Path $InstancePath $Dir + New-Item -ItemType Directory -Path $DirPath -Force | Out-Null + } + + # Copy global configs + Copy-GlobalConfigs $InstancePath +} + +function Validate-Instance { + param([string]$InstancePath) + + $RequiredDirs = @('session-env', 'todos', 'logs', 'file-history', + 'shell-snapshots', 'debug', '.anthropic') + + foreach ($Dir in $RequiredDirs) { + $DirPath = Join-Path $InstancePath $Dir + if (-not (Test-Path $DirPath)) { + New-Item -ItemType Directory -Path $DirPath -Force | Out-Null + } + } +} + +function Ensure-Instance { + param([string]$ProfileName) + + $SafeName = Get-SanitizedProfileName $ProfileName + $InstancePath = "$InstancesDir\$SafeName" + + if (-not (Test-Path $InstancePath)) { + Initialize-Instance $InstancePath + } + + Validate-Instance $InstancePath + + return $InstancePath +} + +# --- Profile Detection Logic (Phase 1) --- + +function Get-AvailableProfiles { + $lines = @() + + # Settings-based profiles + if (Test-Path $ConfigFile) { + try { + $Config = Get-Content $ConfigFile -Raw | ConvertFrom-Json + $SettingsProfiles = $Config.profiles.PSObject.Properties.Name + + if ($SettingsProfiles.Count -gt 0) { + $lines += "Settings-based profiles (GLM, Kimi, etc.):" + foreach ($name in $SettingsProfiles) { + $lines += " - $name" + } + } + } catch {} + } + + # Account-based profiles + if (Test-Path $ProfilesJson) { + try { + $Profiles = Read-ProfilesJson + $AccountProfiles = $Profiles.profiles.PSObject.Properties.Name + + if ($AccountProfiles.Count -gt 0) { + $lines += "Account-based profiles:" + foreach ($name in $AccountProfiles) { + $IsDefault = if ($name -eq $Profiles.default) { " [DEFAULT]" } else { "" } + $lines += " - $name$IsDefault" + } + } + } catch {} + } + + if ($lines.Count -eq 0) { + return " (no profiles configured)`n Run `"ccs auth create `" to create your first account profile." + } else { + return $lines -join "`n" + } +} + +function Get-ProfileType { + param([string]$ProfileName) + + # Special case: 'default' resolves to default profile + if ($ProfileName -eq "default") { + # Check account-based default first + if (Test-Path $ProfilesJson) { + $Profiles = Read-ProfilesJson + $DefaultAccount = $Profiles.default + + if ($DefaultAccount -and (Test-ProfileExists $DefaultAccount)) { + return @{ + Type = "account" + Name = $DefaultAccount + } + } + } + + # Check settings-based default + if (Test-Path $ConfigFile) { + $Config = Get-Content $ConfigFile -Raw | ConvertFrom-Json + $DefaultSettings = $Config.profiles.default + + if ($DefaultSettings) { + return @{ + Type = "settings" + Path = $DefaultSettings + Name = "default" + } + } + } + + # No default configured, use Claude's defaults + return @{ + Type = "default" + Name = "default" + } + } + + # Priority 1: Check settings-based profiles (backward compatibility) + if (Test-Path $ConfigFile) { + try { + $Config = Get-Content $ConfigFile -Raw | ConvertFrom-Json + $SettingsPath = $Config.profiles.$ProfileName + + if ($SettingsPath) { + return @{ + Type = "settings" + Path = $SettingsPath + Name = $ProfileName + } + } + } catch {} + } + + # Priority 2: Check account-based profiles + if ((Test-Path $ProfilesJson) -and (Test-ProfileExists $ProfileName)) { + return @{ + Type = "account" + Name = $ProfileName + } + } + + # Not found + return @{ + Type = "error" + } +} + +# --- Auth Commands (Phase 3) --- + +function Show-AuthHelp { + Write-Host "" + Write-Host "CCS Account Management" -ForegroundColor White + Write-Host "" + Write-Host "Usage:" -ForegroundColor Cyan + Write-Host " ccs auth [options]" -ForegroundColor Yellow + Write-Host "" + Write-Host "Commands:" -ForegroundColor Cyan + Write-Host " create Create new profile and login" -ForegroundColor Yellow + Write-Host " list List all saved profiles" -ForegroundColor Yellow + Write-Host " show Show profile details" -ForegroundColor Yellow + Write-Host " remove Remove saved profile" -ForegroundColor Yellow + Write-Host " default Set default profile" -ForegroundColor Yellow + Write-Host "" + Write-Host "Examples:" -ForegroundColor Cyan + Write-Host " ccs auth create work" -ForegroundColor Yellow + Write-Host " ccs auth list" -ForegroundColor Yellow + Write-Host " ccs auth remove work --force" -ForegroundColor Yellow + Write-Host "" +} + +function Invoke-AuthCreate { + param([string[]]$Args) + + $ProfileName = "" + $Force = $false + + foreach ($arg in $Args) { + if ($arg -eq "--force") { + $Force = $true + } elseif ($arg -match '^-') { + Write-ErrorMsg "Unknown option: $arg" + return 1 + } else { + $ProfileName = $arg + } + } + + if (-not $ProfileName) { + Write-ErrorMsg "Profile name is required`n`nUsage: ccs auth create [--force]" + return 1 + } + + if (-not $Force -and (Test-ProfileExists $ProfileName)) { + Write-ErrorMsg "Profile already exists: $ProfileName`nUse --force to overwrite" + return 1 + } + + # Create instance + Write-Host "[i] Creating profile: $ProfileName" + $InstancePath = Ensure-Instance $ProfileName + Write-Host "[i] Instance directory: $InstancePath" + Write-Host "" + + # Register profile + Register-Profile $ProfileName + + # Launch Claude for login + Write-Host "[i] Starting Claude in isolated instance..." -ForegroundColor Yellow + Write-Host "[i] You will be prompted to login with your account." -ForegroundColor Yellow + Write-Host "" + + $env:CLAUDE_CONFIG_DIR = $InstancePath + $ClaudeCli = Find-ClaudeCli + & $ClaudeCli + + if ($LASTEXITCODE -ne 0) { + Write-ErrorMsg "Login failed or cancelled`nTo retry: ccs auth create $ProfileName --force" + return 1 + } + + Write-Host "" + Write-Host "[OK] Profile created successfully" -ForegroundColor Green + Write-Host "" + Write-Host " Profile: $ProfileName" + Write-Host " Instance: $InstancePath" + Write-Host "" + Write-Host "Usage:" + Write-Host " ccs $ProfileName `"your prompt here`"" -ForegroundColor Yellow + Write-Host "" +} + +function Invoke-AuthList { + param([string[]]$Args) + + $Verbose = $Args -contains "--verbose" + + if (-not (Test-Path $ProfilesJson)) { + Write-Host "No account profiles found" -ForegroundColor Yellow + Write-Host "" + Write-Host "To create your first profile:" + Write-Host " ccs auth create " -ForegroundColor Yellow + return + } + + $Data = Read-ProfilesJson + $Profiles = $Data.profiles.PSObject.Properties.Name + + if ($Profiles.Count -eq 0) { + Write-Host "No account profiles found" -ForegroundColor Yellow + return + } + + Write-Host "Saved Account Profiles:" -ForegroundColor White + Write-Host "" + + foreach ($profile in $Profiles) { + $IsDefault = $profile -eq $Data.default + + if ($IsDefault) { + Write-Host "[*] " -ForegroundColor Green -NoNewline + Write-Host $profile -ForegroundColor Cyan -NoNewline + Write-Host " (default)" -ForegroundColor Green + } else { + Write-Host "[ ] " -NoNewline + Write-Host $profile -ForegroundColor Cyan + } + + $Type = $Data.profiles.$profile.type + Write-Host " Type: $Type" + + if ($Verbose) { + $Created = $Data.profiles.$profile.created + $LastUsed = $Data.profiles.$profile.last_used + if (-not $LastUsed) { $LastUsed = "Never" } + Write-Host " Created: $Created" + Write-Host " Last used: $LastUsed" + } + + Write-Host "" + } +} + +function Invoke-AuthShow { + param([string[]]$Args) + + $ProfileName = $Args[0] + + if (-not $ProfileName) { + Write-ErrorMsg "Profile name is required`nUsage: ccs auth show " + return 1 + } + + if (-not (Test-ProfileExists $ProfileName)) { + Write-ErrorMsg "Profile not found: $ProfileName" + return 1 + } + + $Data = Read-ProfilesJson + $IsDefault = $ProfileName -eq $Data.default + + Write-Host "Profile: $ProfileName" -ForegroundColor White + Write-Host "" + + $Type = $Data.profiles.$ProfileName.type + $Created = $Data.profiles.$ProfileName.created + $LastUsed = $Data.profiles.$ProfileName.last_used + if (-not $LastUsed) { $LastUsed = "Never" } + $InstancePath = "$InstancesDir\$(Get-SanitizedProfileName $ProfileName)" + + Write-Host " Type: $Type" + Write-Host " Default: $(if ($IsDefault) { 'Yes' } else { 'No' })" + Write-Host " Instance: $InstancePath" + Write-Host " Created: $Created" + Write-Host " Last used: $LastUsed" + Write-Host "" +} + +function Invoke-AuthRemove { + param([string[]]$Args) + + $ProfileName = "" + $Force = $false + + foreach ($arg in $Args) { + if ($arg -eq "--force") { + $Force = $true + } else { + $ProfileName = $arg + } + } + + if (-not $ProfileName) { + Write-ErrorMsg "Profile name is required`nUsage: ccs auth remove --force" + return 1 + } + + if (-not (Test-ProfileExists $ProfileName)) { + Write-ErrorMsg "Profile not found: $ProfileName" + return 1 + } + + if (-not $Force) { + Write-ErrorMsg "Removal requires --force flag for safety`nRun: ccs auth remove $ProfileName --force" + return 1 + } + + # Delete instance directory + $InstancePath = "$InstancesDir\$(Get-SanitizedProfileName $ProfileName)" + if (Test-Path $InstancePath) { + Remove-Item $InstancePath -Recurse -Force + } + + # Remove from registry + Unregister-Profile $ProfileName + + Write-Host "[OK] Profile removed successfully" -ForegroundColor Green + Write-Host " Profile: $ProfileName" + Write-Host "" +} + +function Invoke-AuthDefault { + param([string[]]$Args) + + $ProfileName = $Args[0] + + if (-not $ProfileName) { + Write-ErrorMsg "Profile name is required`nUsage: ccs auth default " + return 1 + } + + if (-not (Test-ProfileExists $ProfileName)) { + Write-ErrorMsg "Profile not found: $ProfileName" + return 1 + } + + Set-DefaultProfile $ProfileName + + Write-Host "[OK] Default profile set" -ForegroundColor Green + Write-Host " Profile: $ProfileName" + Write-Host "" + Write-Host "Now you can use:" + Write-Host " ccs `"your prompt`" # Uses $ProfileName profile" -ForegroundColor Yellow + Write-Host "" +} + +function Invoke-AuthCommands { + param([string[]]$Args) + + $Subcommand = $Args[0] + $SubArgs = if ($Args.Count -gt 1) { $Args[1..($Args.Count-1)] } else { @() } + + switch ($Subcommand) { + "create" { Invoke-AuthCreate $SubArgs } + "list" { Invoke-AuthList $SubArgs } + "show" { Invoke-AuthShow $SubArgs } + "remove" { Invoke-AuthRemove $SubArgs } + "default" { Invoke-AuthDefault $SubArgs } + default { Show-AuthHelp } + } +} + +# --- Main Execution Logic --- + # Special case: version command (check BEFORE profile detection) -# Handle switch parameters and remaining arguments if ($Version) { Show-Version exit 0 @@ -230,30 +823,11 @@ if ($Help) { } } -# Special case: install command (check BEFORE profile detection) -if ($FirstArg -eq "--install") { - Write-Host "" - Write-Host "Feature not available" -ForegroundColor Yellow - Write-Host "" - Write-Host "The --install flag is currently under development." - Write-Host ".claude/ integration testing is not complete." - Write-Host "" - Write-Host "For updates: https://github.com/kaitranntt/ccs/issues" - Write-Host "" - exit 0 -} - -# Special case: uninstall command (check BEFORE profile detection) -if ($FirstArg -eq "--uninstall") { - Write-Host "" - Write-Host "Feature not available" -ForegroundColor Yellow - Write-Host "" - Write-Host "The --uninstall flag is currently under development." - Write-Host ".claude/ integration testing is not complete." - Write-Host "" - Write-Host "For updates: https://github.com/kaitranntt/ccs/issues" - Write-Host "" - exit 0 +# Special case: auth commands +if ($RemainingArgs.Count -gt 0 -and $RemainingArgs[0] -eq "auth") { + $AuthArgs = if ($RemainingArgs.Count -gt 1) { $RemainingArgs[1..($RemainingArgs.Count-1)] } else { @() } + Invoke-AuthCommands $AuthArgs + exit $LASTEXITCODE } # Smart profile detection: if first arg starts with '-', it's a flag not a profile @@ -267,20 +841,6 @@ if ($RemainingArgs.Count -eq 0 -or $RemainingArgs[0] -match '^-') { $RemainingArgs = if ($RemainingArgs.Count -gt 1) { $RemainingArgs | Select-Object -Skip 1 } else { @() } } -# Check config exists -if (-not (Test-Path $ConfigFile)) { - $ErrorMessage = "Config file not found: $ConfigFile" + "`n`n" + - "Solutions:" + "`n" + - " 1. Reinstall CCS:" + "`n" + - " irm ccs.kaitran.ca/install | iex" + "`n`n" + - " 2. Or create config manually:" + "`n" + - " New-Item -ItemType Directory -Force -Path '$env:USERPROFILE\.ccs'" + "`n" + - " Set-Content -Path '$env:USERPROFILE\.ccs\config.json' -Value '{`"profiles`":{`"glm`":`"~/.ccs/glm.settings.json`",`"kimi`":`"~/.ccs/kimi.settings.json`",`"default`":`"~/.claude/settings.json`"}}'" - - Write-ErrorMsg $ErrorMessage - exit 1 -} - # Validate profile name (alphanumeric, dash, underscore only) if ($Profile -notmatch '^[a-zA-Z0-9_-]+$') { $ErrorMessage = "Invalid profile name: $Profile" + "`n`n" + @@ -290,49 +850,13 @@ if ($Profile -notmatch '^[a-zA-Z0-9_-]+$') { exit 1 } -# Read and parse JSON config, get profile path in one step -try { - $ConfigContent = Get-Content $ConfigFile -Raw -ErrorAction Stop - $Config = $ConfigContent | ConvertFrom-Json -ErrorAction Stop - $SettingsPath = $Config.profiles.$Profile +# Detect profile type +$ProfileInfo = Get-ProfileType $Profile - if (-not $SettingsPath) { - $AvailableProfiles = ($Config.profiles.PSObject.Properties.Name | ForEach-Object { " - $_" }) -join "`n" - $ErrorMessage = "Profile '$Profile' not found in $ConfigFile" + "`n`n" + - "Available profiles:" + "`n" + - $AvailableProfiles - - Write-ErrorMsg $ErrorMessage - exit 1 - } -} catch { - $ErrorMessage = "Invalid JSON in $ConfigFile" + "`n`n" + - "Fix the JSON syntax or reinstall:" + "`n" + - " irm ccs.kaitran.ca/install | iex" - - Write-ErrorMsg $ErrorMessage - 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)) { - $ErrorMessage = "Settings file not found: $SettingsPath" + "`n`n" + - "Solutions:" + "`n" + - " 1. Create the settings file for profile '$Profile'" + "`n" + - " 2. Update the path in $ConfigFile" + "`n" + - " 3. Or reinstall: irm ccs.kaitran.ca/install | iex" +if ($ProfileInfo.Type -eq "error") { + $ErrorMessage = "Profile '$Profile' not found" + "`n`n" + + "Available profiles:" + "`n" + + (Get-AvailableProfiles) Write-ErrorMsg $ErrorMessage exit 1 @@ -341,15 +865,75 @@ if (-not (Test-Path $SettingsPath)) { # Detect Claude CLI executable $ClaudeCli = Find-ClaudeCli -# Execute Claude with the profile settings -try { - if ($RemainingArgs) { - & $ClaudeCli --settings $SettingsPath @RemainingArgs - } else { - & $ClaudeCli --settings $SettingsPath +# Execute based on profile type (Phase 5) +switch ($ProfileInfo.Type) { + "account" { + # Account-based profile: use CLAUDE_CONFIG_DIR + $InstancePath = Ensure-Instance $ProfileInfo.Name + Update-ProfileTimestamp $ProfileInfo.Name # Update last_used + + # Execute Claude with isolated config + $env:CLAUDE_CONFIG_DIR = $InstancePath + + try { + if ($RemainingArgs) { + & $ClaudeCli @RemainingArgs + } else { + & $ClaudeCli + } + exit $LASTEXITCODE + } catch { + Show-ClaudeNotFoundError + exit 1 + } } - exit $LASTEXITCODE -} catch { - Show-ClaudeNotFoundError - exit 1 -} \ No newline at end of file + + "settings" { + # Settings-based profile: use --settings flag + $SettingsPath = $ProfileInfo.Path + + # Path expansion and normalization + if ($SettingsPath -match '^~[/\\]') { + $SettingsPath = $SettingsPath -replace '^~', $env:USERPROFILE + } + $SettingsPath = [System.Environment]::ExpandEnvironmentVariables($SettingsPath) + $SettingsPath = $SettingsPath -replace '/', '\' + + if (-not (Test-Path $SettingsPath)) { + Write-ErrorMsg "Settings file not found: $SettingsPath" + exit 1 + } + + try { + if ($RemainingArgs) { + & $ClaudeCli --settings $SettingsPath @RemainingArgs + } else { + & $ClaudeCli --settings $SettingsPath + } + exit $LASTEXITCODE + } catch { + Show-ClaudeNotFoundError + exit 1 + } + } + + "default" { + # Default: no special handling + try { + if ($RemainingArgs) { + & $ClaudeCli @RemainingArgs + } else { + & $ClaudeCli + } + exit $LASTEXITCODE + } catch { + Show-ClaudeNotFoundError + exit 1 + } + } + + default { + Write-ErrorMsg "Unknown profile type: $($ProfileInfo.Type)" + exit 1 + } +}