Files
ccs/lib/ccs.ps1
T
Kai (Tam Nhu) Tranandkaitranntt 2671b97039 fix(completion): improve shell completion UI/UX and fix mkdir errors (#10)
* fix(shell-completion): resolve ENOTDIR error when parent path is a file

Fixes issue where `--shell-completion` fails with ENOTDIR error when
a file exists where a directory should be created (e.g., ~/.zsh exists
as a file instead of a directory).

Changes:
- Added ensureDirectory() helper that safely creates directories
- Validates parent paths are directories, not files
- Provides clear error message when file conflicts occur
- Applied to all shell installers (bash, zsh, fish, powershell)
- Maintains idempotent behavior (safe to call multiple times)

Before: mkdir with recursive:true fails silently with ENOTDIR
After: Clear error message guides user to resolve file conflict

* chore: bump version to 4.1.4

* feat(completion): improve UI/UX with descriptions and grouping

Improves shell completion UI/UX across all shells (bash, zsh, fish,
PowerShell) with better organization and clearer descriptions.

Changes:
- Zsh: Added descriptions for all profiles and grouped by category
  - Commands: "auth", "doctor" with descriptions
  - Model profiles: glm, glmt, kimi, etc. with descriptions
  - Account profiles: Dynamic with "Account-based profile" label
  - Used _alternative for visual grouping
- Fish: Added explicit completions with descriptions for known profiles
  - Replaced generic profile listing with specific entries
  - Each profile now shows clear description (e.g., "GLM-4.6 (cost-optimized)")
- Bash: Added --shell-completion flag and subflags completion
- PowerShell: Added --shell-completion flag and subflags completion
- All shells: Added completion for --shell-completion subflags

Before (zsh):
  ccs
  auth     -- Manage multiple Claude accounts
  doctor   -- Run health check and diagnostics
  default  glm      glmt     kimi     max

After (zsh):
  commands
    auth    -- Manage multiple Claude accounts
    doctor  -- Run health check and diagnostics
  model profiles
    default -- Default Claude Sonnet 4.5
    glm     -- GLM-4.6 (cost-optimized)
    glmt    -- GLM-4.6 with thinking mode
    kimi    -- Kimi for Coding (long-context)
    max     -- Claude Opus (maximum capability)
  account profiles
    work    -- Account-based profile
    personal -- Account-based profile

Consistent, organized, and informative completion across all shells.

* fix(completion): handle custom profiles and fix zsh syntax errors

Fixes two issues:
1. Zsh syntax errors with _describe -t flag in _alternative
2. Adds general handling for custom settings profiles (e.g., m2)

Changes:
- Zsh: Fixed _alternative syntax (removed -t tag from _describe calls)
  - Error was: "_describe:21: bad option: -M"
  - Cause: Tag is auto-derived in _alternative, don't specify with -t
- Fish: Added __fish_ccs_get_custom_settings_profiles function
  - Dynamically loads non-hardcoded profiles from config.json
  - Shows "Settings-based profile" description for custom profiles
- Removed 'max' from hardcoded known profiles
  - 'max' is user's account-based profile, not a default setting

Now supports any custom settings profile (e.g., m2.settings.json for
Minimax M2) without hardcoding. Custom profiles show with generic
"Settings-based profile" description.

Installation properly overwrites:
- fs.copyFileSync overwrites completion files by default
- RC files only modified if marker not already present

* fix(zsh): simplify completion to avoid _alternative syntax issues

Replaced _alternative with multiple _describe calls to fix persistent
zsh completion errors.

The _alternative approach was causing:
- '_describe:21: bad option: -M'
- '(eval):1: bad substitution'
- Unwanted variable expansion in completion menu

New approach uses sequential _describe calls which zsh handles correctly.
Simpler, more reliable, and still shows grouped completion with
descriptions.

Before: _alternative with complex nested _describe calls (broken)
After: Three simple _describe calls (works correctly)

* feat(zsh): add colors and improved formatting to completion

Enhances zsh completion UI/UX with colors and better spacing:

Colors:
- Blue: commands (auth, doctor)
- Green: model profiles (default, glm, glmt, kimi, custom)
- Yellow: account profiles
- Gray: descriptions

Formatting:
- Wider separator (  --  ) for better readability
- Group headers in bold cyan
- Menu selection enabled for navigation
- list-rows-first for better column layout

Before:
  auth    -- Manage multiple Claude accounts
  default  -- Default Claude Sonnet 4.5
  doctor  -- Run health check and diagnostics
  glm      -- GLM-4.6 (cost-optimized)

After:
  commands (cyan header)
  auth    --  Manage multiple Claude accounts  (blue)
  doctor  --  Run health check and diagnostics (blue)

  model profiles (cyan header)
  default  --  Default Claude Sonnet 4.5       (green)
  glm      --  GLM-4.6 (cost-optimized)        (green)
  glmt     --  GLM-4.6 with thinking mode      (green)
  kimi     --  Kimi for Coding (long-context)  (green)

  account profiles (cyan header)
  max  --  Account-based profile              (yellow)

Table-like appearance with colors matching --help output style.

* feat(fish): add colors to completion descriptions

Adds color-coded descriptions to Fish completion matching zsh style:

Colors:
- Blue: commands (auth, doctor)
- Green: model profiles (default, glm, glmt, kimi, custom)
- Yellow: account profiles

Fish has excellent native color support via set_color, making this
straightforward to implement.

Before:
  auth       Manage multiple Claude accounts
  default    Default Claude Sonnet 4.5
  glm        GLM-4.6 (cost-optimized)
  max        Account profile

After:
  auth       Manage multiple Claude accounts       (blue)
  default    Default Claude Sonnet 4.5            (green)
  glm        GLM-4.6 (cost-optimized)             (green)
  max        Account profile                      (yellow)

Consistent color scheme across zsh and fish shells.

* fix(zsh): correct color application for commands vs descriptions

Fixed color codes being applied backwards - commands were gray while
descriptions were colorful.

Issue:
- Used '1;34' format which zsh misinterpreted
- Commands appeared gray (unreadable)
- Descriptions appeared in color

Fix:
- Simplified color codes from '1;34' to '34' (just the color, no style prefix)
- Pattern now: =(#b)(command)(-- description)=34=90
  - First group (command): color 34 (blue/green/yellow)
  - Second group (description): color 90 (gray)

Before:
  auth    --  Manage multiple Claude accounts  (gray) (blue)

After:
  auth    --  Manage multiple Claude accounts  (blue) (gray)

Commands now properly colored, descriptions muted.

* feat(zsh): improve section header visibility with separators

Makes section headers more distinct and recognizable as dividers:

Before:
  commands
  auth    --  ...

After:

  ── commands ──
  auth    --  ...

Changes:
- Added blank line before headers ($'\n')
- Bold yellow with decorative separators (── text ──)
- Clearly distinguishes section headers from actual commands

Now it's obvious that 'commands', 'model profiles', and 'account
profiles' are just section labels, not completable items.

* fix(zsh): use full ANSI color codes to fix reversed colors

The issue was using shorthand color codes (34, 90) instead of full
ANSI format (0;34, 2;37). Zsh was misinterpreting the shorthand,
causing colors to be applied to wrong groups.

Fixed:
- Commands: 0;34 (normal blue) - was appearing gray
- Model profiles: 0;32 (normal green) - was appearing gray
- Account profiles: 0;33 (normal yellow) - was appearing gray
- Descriptions: 2;37 (dim white) - was appearing colorful

Color codes must be escaped as 0\;34 in zsh strings.

Before fix:
  auth      (gray) --  description (blue)

After fix:
  auth      (blue) --  description (dim gray)

* fix(zsh): swap color order for completion groups

Zsh may apply colors in reverse order to capture groups on some systems.
Swapped color order: group 2 first, then group 1.

Pattern: (command)(separator + description)
Was: =blue=dim_white (applied as: blue to cmd, dim to desc)
Now: =dim_white=blue (applied as: dim to desc, blue to cmd)

Testing if this resolves the reversed color issue.

* fix(zsh): add empty leading color to fix reversed coloring

ROOT CAUSE: Zsh list-colors apply first color to WHOLE match, then
override with subsequent colors for each group. Without an empty
leading color, the whole match color leaks to groups without overrides.

Pattern behavior:
  =(#b)(cmd)(desc)=BLUE=DIM
    → Whole: BLUE, Group1: DIM (override), Group2: BLUE (no override)
    → Result: cmd=dim, desc=BLUE (REVERSED!)

  =(#b)(cmd)(desc)==BLUE=DIM
    → Whole: none, Group1: BLUE, Group2: DIM
    → Result: cmd=BLUE, desc=dim (CORRECT!)

The '==' at start means 'no whole-match color', preventing color bleed.

Now commands will be colorful (blue/green/yellow) and descriptions dim.

* refactor(help): remove specific account examples and generalize description

Removed deterministic account examples (work, personal, team) to make
the help text more generic and less prescriptive.

Changes:
- Removed 'ccs work' and 'ccs personal' example lines
- Removed 'Multi-account workflow' examples section
- Updated description from 'multiple Claude accounts (work, personal, team)'
  to 'multiple Claude accounts and alternative models'
- Changed to 'Run different Claude CLI sessions concurrently'
- Applied consistently across Node.js (bin/ccs.js), bash (lib/ccs),
  and PowerShell (lib/ccs.ps1)

This makes the help text more flexible and doesn't imply specific
use cases or account naming conventions.

* refactor(help): clarify delegation section and remove non-existent command

Updated delegation section in help text across all implementations:

Changes:
- Renamed section from 'Delegation (Token Optimization)' to
  'Delegation (inside Claude Code CLI)' to clarify context
- Removed non-existent '/ccs:create m2' command
- Simplified description to focus on cost savings
- Updated command descriptions:
  - '/ccs:glm' now 'for simple tasks' (clearer use case)
  - '/ccs:kimi' unchanged (already clear)
- Added delegation section to PowerShell help (was missing)
- Consistent messaging across Node.js, bash, and PowerShell

The new section makes it immediately clear that delegation commands
are used within Claude Code CLI sessions, not as standalone commands.

---------
2025-11-18 01:19:28 -05:00

1468 lines
45 KiB
PowerShell

# CCS - Claude Code Switch (Windows PowerShell)
# Cross-platform Claude CLI profile switcher
# https://github.com/kaitranntt/ccs
param(
[switch]$Help,
[switch]$Version,
[Parameter(ValueFromRemainingArguments=$true)]
[string[]]$RemainingArgs
)
$ErrorActionPreference = "Stop"
# Version (updated by scripts/bump-version.sh)
$CcsVersion = "4.1.4"
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$ConfigFile = if ($env:CCS_CONFIG) { $env:CCS_CONFIG } else { "$env:USERPROFILE\.ccs\config.json" }
$ProfilesJson = "$env:USERPROFILE\.ccs\profiles.json"
$InstancesDir = "$env:USERPROFILE\.ccs\instances"
# Determine dependency location (git vs installed)
# Git: lib/ccs.ps1 and dependencies are in same dir (lib/)
# Installed: lib/ccs.ps1 is in ~/.ccs/, dependencies in ~/.ccs/lib/
$DepDir = if (Test-Path "$ScriptDir\error-codes.ps1") {
# Git install - files in same directory
$ScriptDir
} else {
# Standalone install - files in ~/.ccs/lib/
"$env:USERPROFILE\.ccs\lib"
}
# Source error codes
. "$DepDir\error-codes.ps1"
# Source progress indicators
. "$DepDir\progress-indicator.ps1"
# Source interactive prompts
. "$DepDir\prompt.ps1"
# --- Color/Format Functions ---
function Write-ErrorMsg {
param([string]$Message)
Write-Host ""
Write-Host "=============================================" -ForegroundColor Red
Write-Host " ERROR" -ForegroundColor Red
Write-Host "=============================================" -ForegroundColor Red
Write-Host ""
Write-Host $Message -ForegroundColor Red
Write-Host ""
}
# Calculate Levenshtein distance between two strings
function Get-LevenshteinDistance {
param(
[string]$a,
[string]$b
)
$lenA = $a.Length
$lenB = $b.Length
if ($lenA -eq 0) { return $lenB }
if ($lenB -eq 0) { return $lenA }
# Initialize matrix
$matrix = New-Object 'int[,]' ($lenB + 1), ($lenA + 1)
# Initialize first row and column
for ($i = 0; $i -le $lenB; $i++) {
$matrix[$i, 0] = $i
}
for ($j = 0; $j -le $lenA; $j++) {
$matrix[0, $j] = $j
}
# Fill matrix
for ($i = 1; $i -le $lenB; $i++) {
for ($j = 1; $j -le $lenA; $j++) {
if ($a[$j - 1] -eq $b[$i - 1]) {
$matrix[$i, $j] = $matrix[$i - 1, $j - 1]
} else {
$sub = $matrix[$i - 1, $j - 1]
$ins = $matrix[$i, $j - 1]
$del = $matrix[$i - 1, $j]
$min = [Math]::Min([Math]::Min($sub, $ins), $del)
$matrix[$i, $j] = $min + 1
}
}
}
return $matrix[$lenB, $lenA]
}
# Find similar strings using fuzzy matching
function Find-SimilarStrings {
param(
[string]$Target,
[string[]]$Candidates,
[int]$MaxDistance = 2
)
$targetLower = $Target.ToLower()
$matches = @()
foreach ($candidate in $Candidates) {
$candidateLower = $candidate.ToLower()
$distance = Get-LevenshteinDistance $targetLower $candidateLower
if ($distance -le $MaxDistance -and $distance -gt 0) {
$matches += [PSCustomObject]@{
Name = $candidate
Distance = $distance
}
}
}
# Sort by distance and return top 3
return $matches | Sort-Object Distance | Select-Object -First 3 | ForEach-Object { $_.Name }
}
# Enhanced error message with error codes
function Show-EnhancedError {
param(
[string]$ErrorCode,
[string]$ShortMsg,
[string]$Context = "",
[string]$Suggestions = ""
)
Write-Host ""
Write-Host "[X] $ShortMsg" -ForegroundColor Red
Write-Host ""
if ($Context) {
Write-Host $Context
Write-Host ""
}
if ($Suggestions) {
Write-Host "Solutions:" -ForegroundColor Yellow
Write-Host $Suggestions
Write-Host ""
}
Write-Host "Error: $ErrorCode" -ForegroundColor Yellow
Write-Host (Get-ErrorDocUrl $ErrorCode) -ForegroundColor Yellow
Write-Host ""
}
function Write-ColoredText {
param(
[string]$Text,
[string]$Color = "White",
[switch]$NoNewline
)
$UseColors = $env:FORCE_COLOR -or ([Console]::IsOutputRedirected -eq $false -and -not $env:NO_COLOR)
if ($UseColors -and $Color) {
if ($NoNewline) {
Write-Host $Text -ForegroundColor $Color -NoNewline
} else {
Write-Host $Text -ForegroundColor $Color
}
} else {
if ($NoNewline) {
Write-Host $Text -NoNewline
} else {
Write-Host $Text
}
}
}
# --- Claude CLI Detection Logic ---
function Find-ClaudeCli {
if ($env:CCS_CLAUDE_PATH) {
return $env:CCS_CLAUDE_PATH
} else {
return "claude"
}
}
function Show-ClaudeNotFoundError {
$Message = "Claude CLI not found in PATH" + "`n`n" +
"CCS requires Claude CLI to be installed and available in your PATH." + "`n`n" +
"Solutions:" + "`n" +
" 1. Install Claude CLI:" + "`n" +
" https://docs.claude.com/en/docs/claude-code/installation" + "`n`n" +
" 2. Verify installation:" + "`n" +
" Get-Command claude" + "`n`n" +
" 3. If installed but not in PATH, add it:" + "`n" +
" # Find Claude installation" + "`n" +
" where.exe claude" + "`n`n" +
" # Or set custom path" + "`n" +
" `$env:CCS_CLAUDE_PATH = 'C:\path\to\claude.exe'" + "`n`n" +
"Restart your terminal after installation."
Write-ErrorMsg $Message
}
function Show-Help {
$UseColors = $env:FORCE_COLOR -or ([Console]::IsOutputRedirected -eq $false -and -not $env:NO_COLOR)
# Helper for colored output
function Write-ColorLine {
param([string]$Text, [string]$Color = "White")
if ($UseColors) { Write-Host $Text -ForegroundColor $Color }
else { Write-Host $Text }
}
Write-ColorLine "CCS (Claude Code Switch) - Instant profile switching for Claude CLI" "White"
Write-Host ""
Write-ColorLine "Usage:" "Cyan"
Write-ColorLine " ccs [profile] [claude-args...]" "Yellow"
Write-ColorLine " ccs auth <command> [options]" "Yellow"
Write-ColorLine " ccs [flags]" "Yellow"
Write-Host ""
Write-ColorLine "Description:" "Cyan"
Write-Host " Switch between multiple Claude accounts and alternative models"
Write-Host " (GLM, Kimi) instantly. Run different Claude CLI sessions concurrently"
Write-Host " with auto-recovery. Zero downtime."
Write-Host ""
Write-ColorLine "Model Switching:" "Cyan"
Write-ColorLine " ccs Use default Claude account" "Yellow"
Write-ColorLine " ccs glm Switch to GLM 4.6 model" "Yellow"
Write-ColorLine " ccs glmt Switch to GLM with thinking mode" "Yellow"
Write-ColorLine " ccs kimi Switch to Kimi for Coding" "Yellow"
Write-ColorLine " ccs glm 'debug this code' Use GLM and run command" "Yellow"
Write-Host ""
Write-ColorLine "Examples:" "Cyan"
Write-ColorLine " `$ ccs" "Yellow" -NoNewline
Write-Host " # Use default account"
Write-ColorLine " `$ ccs glm `"implement API`"" "Yellow" -NoNewline
Write-Host " # Cost-optimized model"
Write-Host ""
Write-ColorLine "Account Management:" "Cyan"
Write-ColorLine " ccs auth --help Manage multiple Claude accounts" "Yellow"
Write-Host ""
Write-ColorLine "Delegation (inside Claude Code CLI):" "Cyan"
Write-ColorLine " /ccs:glm `"task`" Delegate to GLM-4.6 for simple tasks" "Yellow"
Write-ColorLine " /ccs:kimi `"task`" Delegate to Kimi for long context" "Yellow"
Write-Host " Save tokens by delegating simple tasks to cost-optimized models"
Write-Host ""
Write-ColorLine "Diagnostics:" "Cyan"
Write-ColorLine " ccs doctor Run health check and diagnostics" "Yellow"
Write-Host ""
Write-ColorLine "Flags:" "Cyan"
Write-ColorLine " -h, --help Show this help message" "Yellow"
Write-ColorLine " -v, --version Show version and installation info" "Yellow"
Write-ColorLine " --shell-completion Install shell auto-completion" "Yellow"
Write-Host ""
Write-ColorLine "Configuration:" "Cyan"
Write-Host " Config: ~/.ccs/config.json"
Write-Host " Profiles: ~/.ccs/profiles.json"
Write-Host " Instances: ~/.ccs/instances/"
Write-Host " Settings: ~/.ccs/*.settings.json"
Write-Host ""
Write-ColorLine "Shared Data:" "Cyan"
Write-Host " Commands: ~/.ccs/shared/commands/"
Write-Host " Skills: ~/.ccs/shared/skills/"
Write-Host " Note: Commands, skills, and agents are symlinked across all profiles"
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 ""
Write-ColorLine "License: MIT" "Cyan"
}
function Show-Version {
$UseColors = $env:FORCE_COLOR -or ([Console]::IsOutputRedirected -eq $false -and -not $env:NO_COLOR)
# Title
if ($UseColors) {
Write-Host "CCS (Claude Code Switch) v$CcsVersion" -ForegroundColor White
} else {
Write-Host "CCS (Claude Code Switch) v$CcsVersion"
}
Write-Host ""
# Installation
if ($UseColors) { Write-Host "Installation:" -ForegroundColor Cyan }
else { Write-Host "Installation:" }
# Location
$InstallLocation = (Get-Command ccs -ErrorAction SilentlyContinue).Source
if ($InstallLocation) {
if ($UseColors) {
Write-Host " Location: " -ForegroundColor Cyan -NoNewline
Write-Host $InstallLocation
} else {
Write-Host " Location: $InstallLocation"
}
} else {
if ($UseColors) {
Write-Host " Location: " -ForegroundColor Cyan -NoNewline
Write-Host "(not found - run from current directory)" -ForegroundColor Gray
} else {
Write-Host " Location: (not found - run from current directory)"
}
}
# Config
if ($UseColors) {
Write-Host " Config: " -ForegroundColor Cyan -NoNewline
Write-Host $ConfigFile
} else {
Write-Host " Config: $ConfigFile"
}
Write-Host ""
# Documentation
if ($UseColors) {
Write-Host "Documentation: https://github.com/kaitranntt/ccs" -ForegroundColor Cyan
Write-Host "License: MIT" -ForegroundColor Cyan
} else {
Write-Host "Documentation: https://github.com/kaitranntt/ccs"
Write-Host "License: MIT"
}
Write-Host ""
if ($UseColors) {
Write-Host "Run 'ccs --help' for usage information" -ForegroundColor Yellow
} else {
Write-Host "Run 'ccs --help' for usage information"
}
}
# --- Auto-Recovery Functions ---
function Ensure-CcsDirectory {
if (Test-Path "$env:USERPROFILE\.ccs") { return $true }
try {
New-Item -ItemType Directory -Path "$env:USERPROFILE\.ccs" -Force | Out-Null
Write-Host "[i] Auto-recovery: Created ~/.ccs/ directory"
return $true
} catch {
Write-ErrorMsg "Cannot create ~/.ccs/ directory. Check permissions."
return $false
}
}
function Ensure-ConfigJson {
$ConfigFile = "$env:USERPROFILE\.ccs\config.json"
# Check if exists and valid
if (Test-Path $ConfigFile) {
try {
Get-Content $ConfigFile -Raw | ConvertFrom-Json | Out-Null
return $true
} catch {
# Corrupted - backup and recreate
$BackupFile = "$ConfigFile.backup.$(Get-Date -Format 'yyyyMMddHHmmss')"
Move-Item $ConfigFile $BackupFile -Force
Write-Host "[i] Auto-recovery: Backed up corrupted config.json"
}
}
# Create default config
$DefaultConfig = @{
profiles = @{
glm = "~/.ccs/glm.settings.json"
kimi = "~/.ccs/kimi.settings.json"
default = "~/.claude/settings.json"
}
}
$DefaultConfig | ConvertTo-Json -Depth 10 | Set-Content $ConfigFile
Write-Host "[i] Auto-recovery: Created ~/.ccs/config.json"
return $true
}
function Ensure-ClaudeSettings {
$ClaudeDir = "$env:USERPROFILE\.claude"
$SettingsFile = "$ClaudeDir\settings.json"
# Create ~/.claude/ if missing
if (-not (Test-Path $ClaudeDir)) {
New-Item -ItemType Directory -Path $ClaudeDir -Force | Out-Null
Write-Host "[i] Auto-recovery: Created ~/.claude/ directory"
}
# Create settings.json if missing
if (-not (Test-Path $SettingsFile)) {
'{}' | Set-Content $SettingsFile
Write-Host "[i] Auto-recovery: Created ~/.claude/settings.json"
Write-Host "[i] Next step: Run 'claude /login' to authenticate"
return $true
}
return $false
}
function Invoke-AutoRecovery {
if (-not (Ensure-CcsDirectory)) { return $false }
if (-not (Ensure-ConfigJson)) { return $false }
Ensure-ClaudeSettings | Out-Null
return $true
}
# --- Profile Registry Functions (Phase 4) ---
function Initialize-ProfilesJson {
if (Test-Path $ProfilesJson) { return }
$InitData = @{
version = "2.0.0"
profiles = @{}
default = $null
}
$InitData | ConvertTo-Json -Depth 10 | Set-Content $ProfilesJson
# Set file permissions (user-only)
$Acl = Get-Acl $ProfilesJson
$Acl.SetAccessRuleProtection($true, $false)
$Acl.Access | ForEach-Object { $Acl.RemoveAccessRule($_) | Out-Null }
$CurrentUser = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name
$Rule = New-Object System.Security.AccessControl.FileSystemAccessRule(
$CurrentUser, "FullControl", "Allow"
)
$Acl.AddAccessRule($Rule)
Set-Acl -Path $ProfilesJson -AclObject $Acl
}
function Read-ProfilesJson {
Initialize-ProfilesJson
Get-Content $ProfilesJson -Raw | ConvertFrom-Json
}
function Write-ProfilesJson {
param([PSCustomObject]$Data)
$TempFile = "$ProfilesJson.tmp"
try {
$Data | ConvertTo-Json -Depth 10 | Set-Content $TempFile
Move-Item $TempFile $ProfilesJson -Force
} catch {
if (Test-Path $TempFile) { Remove-Item $TempFile -Force }
throw "Failed to write profiles registry: $_"
}
}
function Test-ProfileExists {
param([string]$ProfileName)
Initialize-ProfilesJson
$Data = Read-ProfilesJson
return $Data.profiles.PSObject.Properties.Name -contains $ProfileName
}
function Register-Profile {
param([string]$ProfileName)
if (Test-ProfileExists $ProfileName) {
throw "Profile already exists: $ProfileName"
}
$Data = Read-ProfilesJson
$Timestamp = (Get-Date).ToUniversalTime().ToString("o")
$Data.profiles | Add-Member -NotePropertyName $ProfileName -NotePropertyValue ([PSCustomObject]@{
type = "account"
created = $Timestamp
last_used = $null
})
# Note: No longer auto-set as default
# Users must explicitly run: ccs auth default <profile>
# Default always stays on implicit 'default' profile (uses ~/.claude/)
Write-ProfilesJson $Data
}
function Unregister-Profile {
param([string]$ProfileName)
if (-not (Test-ProfileExists $ProfileName)) { return } # Idempotent
$Data = Read-ProfilesJson
# Remove profile
$Data.profiles.PSObject.Properties.Remove($ProfileName)
# Update default if it was the deleted profile
if ($Data.default -eq $ProfileName) {
$Remaining = $Data.profiles.PSObject.Properties.Name
$Data.default = if ($Remaining.Count -gt 0) { $Remaining[0] } else { $null }
}
Write-ProfilesJson $Data
}
function Update-ProfileTimestamp {
param([string]$ProfileName)
if (-not (Test-ProfileExists $ProfileName)) { return } # Silent fail
$Data = Read-ProfilesJson
$Timestamp = (Get-Date).ToUniversalTime().ToString("o")
$Data.profiles.$ProfileName.last_used = $Timestamp
Write-ProfilesJson $Data
}
function Get-DefaultProfile {
Initialize-ProfilesJson
$Data = Read-ProfilesJson
return $Data.default
}
function Set-DefaultProfile {
param([string]$ProfileName)
if (-not (Test-ProfileExists $ProfileName)) {
throw "Profile not found: $ProfileName"
}
$Data = Read-ProfilesJson
$Data.default = $ProfileName
Write-ProfilesJson $Data
}
# --- Instance Management Functions (Phase 2) ---
function Get-SanitizedProfileName {
param([string]$Name)
# Replace unsafe chars, lowercase
return $Name.ToLower() -replace '[^a-z0-9_-]', '-'
}
function Set-InstancePermissions {
param([string]$Path)
# Get current user
$CurrentUser = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name
# Create new ACL with inheritance disabled
$Acl = Get-Acl $Path
$Acl.SetAccessRuleProtection($true, $false) # Disable inheritance
$Acl.Access | ForEach-Object { $Acl.RemoveAccessRule($_) | Out-Null }
# Grant full control to current user only
$Rule = New-Object System.Security.AccessControl.FileSystemAccessRule(
$CurrentUser, "FullControl", "ContainerInherit,ObjectInherit", "None", "Allow"
)
$Acl.AddAccessRule($Rule)
Set-Acl -Path $Path -AclObject $Acl
}
function Link-SharedDirectories {
param([string]$InstancePath)
$SharedDir = "$env:USERPROFILE\.ccs\shared"
# Ensure shared directories exist
@('commands', 'skills', 'agents') | ForEach-Object {
$Dir = Join-Path $SharedDir $_
if (-not (Test-Path $Dir)) {
New-Item -ItemType Directory -Path $Dir -Force | Out-Null
}
}
# Create symlinks (requires Windows Developer Mode or admin)
@('commands', 'skills', 'agents') | ForEach-Object {
$LinkPath = Join-Path $InstancePath $_
$TargetPath = Join-Path $SharedDir $_
# Remove existing directory/link
if (Test-Path $LinkPath) {
Remove-Item $LinkPath -Recurse -Force
}
# Try creating symlink (requires privileges)
try {
New-Item -ItemType SymbolicLink -Path $LinkPath -Target $TargetPath -Force | Out-Null
} catch {
# Fallback: Copy directory instead (suboptimal but functional)
Copy-Item $TargetPath -Destination $LinkPath -Recurse -Force
Write-Host "[!] Symlink failed for $_, copied instead (enable Developer Mode)" -ForegroundColor Yellow
}
}
}
function Migrate-SharedStructure {
$SharedDir = "$env:USERPROFILE\.ccs\shared"
# Check if migration is needed (shared dirs exist but are empty)
if (Test-Path $SharedDir) {
$NeedsMigration = $false
foreach ($Dir in @('commands', 'skills', 'agents')) {
$DirPath = Join-Path $SharedDir $Dir
if (-not (Test-Path $DirPath) -or (Get-ChildItem $DirPath -ErrorAction SilentlyContinue).Count -eq 0) {
$NeedsMigration = $true
break
}
}
if (-not $NeedsMigration) { return }
}
# Create shared directory
@('commands', 'skills', 'agents') | ForEach-Object {
$Dir = Join-Path $SharedDir $_
New-Item -ItemType Directory -Path $Dir -Force | Out-Null
}
# Copy from ~/.claude/ (actual Claude CLI directory)
$ClaudeDir = "$env:USERPROFILE\.claude"
if (Test-Path $ClaudeDir) {
# Copy commands to shared (if exists)
$CommandsPath = Join-Path $ClaudeDir "commands"
if (Test-Path $CommandsPath) {
Copy-Item "$CommandsPath\*" -Destination "$SharedDir\commands\" -Recurse -ErrorAction SilentlyContinue
}
# Copy skills to shared (if exists)
$SkillsPath = Join-Path $ClaudeDir "skills"
if (Test-Path $SkillsPath) {
Copy-Item "$SkillsPath\*" -Destination "$SharedDir\skills\" -Recurse -ErrorAction SilentlyContinue
}
# Copy agents to shared (if exists)
$AgentsPath = Join-Path $ClaudeDir "agents"
if (Test-Path $AgentsPath) {
Copy-Item "$AgentsPath\*" -Destination "$SharedDir\agents\" -Recurse -ErrorAction SilentlyContinue
}
}
# Update all instances to use symlinks
if (Test-Path $InstancesDir) {
Get-ChildItem $InstancesDir -Directory | ForEach-Object {
Link-SharedDirectories $_.FullName
}
}
Write-Host "[OK] Migrated to shared structure"
}
function Copy-GlobalConfigs {
param([string]$InstancePath)
$GlobalClaude = "$env:USERPROFILE\.claude"
# Copy settings.json only (commands/skills are now symlinked to shared/)
$GlobalSettings = Join-Path $GlobalClaude "settings.json"
if (Test-Path $GlobalSettings) {
Copy-Item $GlobalSettings -Destination (Join-Path $InstancePath "settings.json") -ErrorAction SilentlyContinue
}
}
function Initialize-Instance {
param([string]$InstancePath)
# Create base directory with user-only ACL
New-Item -ItemType Directory -Path $InstancePath -Force | Out-Null
Set-InstancePermissions $InstancePath
# Create subdirectories (profile-specific only)
$Subdirs = @('session-env', 'todos', 'logs', 'file-history',
'shell-snapshots', 'debug', '.anthropic')
foreach ($Dir in $Subdirs) {
$DirPath = Join-Path $InstancePath $Dir
New-Item -ItemType Directory -Path $DirPath -Force | Out-Null
}
# Symlink shared directories
Link-SharedDirectories $InstancePath
# Copy global configs
Copy-GlobalConfigs $InstancePath
}
function Validate-Instance {
param([string]$InstancePath)
$RequiredDirs = @('session-env', 'todos', 'logs', 'file-history',
'shell-snapshots', 'debug', '.anthropic')
foreach ($Dir in $RequiredDirs) {
$DirPath = Join-Path $InstancePath $Dir
if (-not (Test-Path $DirPath)) {
New-Item -ItemType Directory -Path $DirPath -Force | Out-Null
}
}
}
function Ensure-Instance {
param([string]$ProfileName)
$SafeName = Get-SanitizedProfileName $ProfileName
$InstancePath = "$InstancesDir\$SafeName"
if (-not (Test-Path $InstancePath)) {
Initialize-Instance $InstancePath
}
Validate-Instance $InstancePath
return $InstancePath
}
# --- Profile Detection Logic (Phase 1) ---
function Get-AllProfileNames {
$names = @()
# Settings-based profiles
if (Test-Path $ConfigFile) {
try {
$Config = Get-Content $ConfigFile -Raw | ConvertFrom-Json
$names += $Config.profiles.PSObject.Properties.Name
} catch {}
}
# Account-based profiles
if (Test-Path $ProfilesJson) {
try {
$Profiles = Read-ProfilesJson
$names += $Profiles.profiles.PSObject.Properties.Name
} catch {}
}
return $names
}
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 <profile>`" 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 <command> [options]" -ForegroundColor Yellow
Write-Host ""
Write-Host "Commands:" -ForegroundColor Cyan
Write-Host " create <profile> Create new profile and login" -ForegroundColor Yellow
Write-Host " list List all saved profiles" -ForegroundColor Yellow
Write-Host " show <profile> Show profile details" -ForegroundColor Yellow
Write-Host " remove <profile> Remove saved profile" -ForegroundColor Yellow
Write-Host " default <profile> Set default profile" -ForegroundColor Yellow
Write-Host ""
Write-Host "Examples:" -ForegroundColor Cyan
Write-Host " ccs auth create work # Create & login to work profile" -ForegroundColor Yellow
Write-Host " ccs auth default work # Set work as default" -ForegroundColor Yellow
Write-Host " ccs auth list # List all profiles" -ForegroundColor Yellow
Write-Host ' ccs work "review code" # Use work profile' -ForegroundColor Yellow
Write-Host ' ccs "review code" # Use default profile' -ForegroundColor Yellow
Write-Host ""
Write-Host "Options:" -ForegroundColor Cyan
Write-Host " --force Allow overwriting existing profile (create)" -ForegroundColor Yellow
Write-Host " --yes, -y Skip confirmation prompts (remove)" -ForegroundColor Yellow
Write-Host " --json Output in JSON format (list, show)" -ForegroundColor Yellow
Write-Host " --verbose Show additional details (list)" -ForegroundColor Yellow
Write-Host ""
Write-Host "Note:" -ForegroundColor Cyan
Write-Host " By default, " -NoNewline
Write-Host "ccs" -ForegroundColor Yellow -NoNewline
Write-Host " uses Claude CLI defaults from ~/.claude/"
Write-Host " Use " -NoNewline
Write-Host "ccs auth default <profile>" -ForegroundColor Yellow -NoNewline
Write-Host " to change the default profile."
Write-Host ""
}
function Invoke-AuthCreate {
param([string[]]$Args)
$ProfileName = ""
$Force = $false
foreach ($arg in $Args) {
if ($arg -eq "--force") {
$Force = $true
} elseif ($arg -match '^-') {
Write-ErrorMsg "Unknown option: $arg"
return 1
} else {
$ProfileName = $arg
}
}
if (-not $ProfileName) {
Write-ErrorMsg "Profile name is required`n`nUsage: ccs auth create <profile> [--force]"
return 1
}
if (-not $Force -and (Test-ProfileExists $ProfileName)) {
Write-ErrorMsg "Profile already exists: $ProfileName`nUse --force to overwrite"
return 1
}
# Create instance
Write-Host "[i] Creating profile: $ProfileName"
$InstancePath = Ensure-Instance $ProfileName
Write-Host "[i] Instance directory: $InstancePath"
Write-Host ""
# Register profile
Register-Profile $ProfileName
# Launch Claude for login
Write-Host "[i] Starting Claude in isolated instance..." -ForegroundColor Yellow
Write-Host "[i] You will be prompted to login with your account." -ForegroundColor Yellow
Write-Host ""
$env:CLAUDE_CONFIG_DIR = $InstancePath
$ClaudeCli = Find-ClaudeCli
& $ClaudeCli
if ($LASTEXITCODE -ne 0) {
Write-ErrorMsg "Login failed or cancelled`nTo retry: ccs auth create $ProfileName --force"
return 1
}
Write-Host ""
Write-Host "[OK] Profile created successfully" -ForegroundColor Green
Write-Host ""
Write-Host " Profile: $ProfileName"
Write-Host " Instance: $InstancePath"
Write-Host ""
Write-Host "Usage:"
Write-Host " ccs $ProfileName `"your prompt here`" # Use this specific profile" -ForegroundColor Yellow
Write-Host ""
Write-Host 'To set as default (so you can use just "ccs"):'
Write-Host " ccs auth default $ProfileName" -ForegroundColor Yellow
Write-Host ""
}
function Invoke-AuthList {
param([string[]]$Args)
$Verbose = $Args -contains "--verbose"
$Json = $Args -contains "--json"
if (-not (Test-Path $ProfilesJson)) {
if ($Json) {
Write-Output "{`"version`":`"$CcsVersion`",`"profiles`":[]}"
return
}
Write-Host "No account profiles found" -ForegroundColor Yellow
Write-Host ""
Write-Host "To create your first profile:"
Write-Host " ccs auth create <profile>" -ForegroundColor Yellow
return
}
$Data = Read-ProfilesJson
$Profiles = $Data.profiles.PSObject.Properties.Name
if ($Profiles.Count -eq 0) {
if ($Json) {
Write-Output "{`"version`":`"$CcsVersion`",`"profiles`":[]}"
return
}
Write-Host "No account profiles found" -ForegroundColor Yellow
return
}
# JSON output mode
if ($Json) {
$ProfilesList = @()
foreach ($profile in $Profiles) {
$IsDefault = $profile -eq $Data.default
$Type = $Data.profiles.$profile.type
if (-not $Type) { $Type = "account" }
$Created = $Data.profiles.$profile.created
$LastUsed = $Data.profiles.$profile.last_used
$InstancePath = "$InstancesDir\$(Get-SanitizedProfileName $profile)"
$ProfilesList += @{
name = $profile
type = $Type
is_default = $IsDefault
created = $Created
last_used = $LastUsed
instance_path = $InstancePath
}
}
$Output = @{
version = $CcsVersion
profiles = $ProfilesList
}
Write-Output ($Output | ConvertTo-Json -Depth 10)
return
}
# Human-readable output
Write-Host "Saved Account Profiles:" -ForegroundColor White
Write-Host ""
foreach ($profile in $Profiles) {
$IsDefault = $profile -eq $Data.default
if ($IsDefault) {
Write-Host "[*] " -ForegroundColor Green -NoNewline
Write-Host $profile -ForegroundColor Cyan -NoNewline
Write-Host " (default)" -ForegroundColor Green
} else {
Write-Host "[ ] " -NoNewline
Write-Host $profile -ForegroundColor Cyan
}
$Type = $Data.profiles.$profile.type
Write-Host " Type: $Type"
if ($Verbose) {
$Created = $Data.profiles.$profile.created
$LastUsed = $Data.profiles.$profile.last_used
if (-not $LastUsed) { $LastUsed = "Never" }
Write-Host " Created: $Created"
Write-Host " Last used: $LastUsed"
}
Write-Host ""
}
}
function Invoke-AuthShow {
param([string[]]$Args)
$ProfileName = ""
$Json = $false
# Parse arguments
foreach ($arg in $Args) {
if ($arg -eq "--json") {
$Json = $true
} elseif ($arg -like "-*") {
Write-ErrorMsg "Unknown option: $arg"
return 1
} else {
$ProfileName = $arg
}
}
if (-not $ProfileName) {
Write-ErrorMsg "Profile name is required"
Write-Host ""
Write-Host "Usage: " -NoNewline
Write-Host "ccs auth show <profile> [--json]" -ForegroundColor Yellow
return 1
}
if (-not (Test-ProfileExists $ProfileName)) {
Write-ErrorMsg "Profile not found: $ProfileName"
return 1
}
$Data = Read-ProfilesJson
$IsDefault = $ProfileName -eq $Data.default
$Type = $Data.profiles.$ProfileName.type
if (-not $Type) { $Type = "account" }
$Created = $Data.profiles.$ProfileName.created
$LastUsed = $Data.profiles.$ProfileName.last_used
$InstancePath = "$InstancesDir\$(Get-SanitizedProfileName $ProfileName)"
# Count sessions
$SessionCount = 0
if (Test-Path "$InstancePath\session-env") {
$SessionFiles = Get-ChildItem "$InstancePath\session-env" -Filter "*.json" -ErrorAction SilentlyContinue
$SessionCount = $SessionFiles.Count
}
# JSON output mode
if ($Json) {
$Output = @{
name = $ProfileName
type = $Type
is_default = $IsDefault
created = $Created
last_used = $LastUsed
instance_path = $InstancePath
session_count = $SessionCount
}
Write-Output ($Output | ConvertTo-Json -Depth 10)
return
}
# Human-readable output
Write-Host "Profile: $ProfileName" -ForegroundColor White
Write-Host ""
Write-Host " Type: $Type"
Write-Host " Default: $(if ($IsDefault) { 'Yes' } else { 'No' })"
Write-Host " Instance: $InstancePath"
Write-Host " Created: $Created"
if ($LastUsed) {
Write-Host " Last used: $LastUsed"
} else {
Write-Host " Last used: Never"
}
Write-Host ""
}
function Invoke-AuthRemove {
param([string[]]$Args)
$ProfileName = ""
# Parse arguments
foreach ($arg in $Args) {
if ($arg -eq "--yes" -or $arg -eq "-y") {
$env:CCS_YES = "1" # Auto-confirm
} elseif ($arg -like "-*") {
Write-ErrorMsg "Unknown option: $arg"
return 1
} else {
$ProfileName = $arg
}
}
if (-not $ProfileName) {
Write-ErrorMsg "Profile name is required"
Write-Host ""
Write-Host "Usage: " -NoNewline
Write-Host "ccs auth remove <profile> [--yes]" -ForegroundColor Yellow
return 1
}
if (-not (Test-ProfileExists $ProfileName)) {
Write-ErrorMsg "Profile not found: $ProfileName"
return 1
}
# Get instance path and session count for impact display
$InstancePath = "$InstancesDir\$(Get-SanitizedProfileName $ProfileName)"
$SessionCount = 0
if (Test-Path "$InstancePath\session-env") {
$SessionFiles = Get-ChildItem "$InstancePath\session-env" -Filter "*.json" -ErrorAction SilentlyContinue
$SessionCount = $SessionFiles.Count
}
# Display impact
Write-Host ""
Write-Host "Profile '" -NoNewline
Write-Host $ProfileName -ForegroundColor Cyan -NoNewline
Write-Host "' will be permanently deleted."
Write-Host " Instance path: $InstancePath"
Write-Host " Sessions: $SessionCount conversation$(if ($SessionCount -ne 1) { 's' } else { '' })"
Write-Host ""
# Interactive confirmation (or --yes flag)
$Confirmed = Confirm-Action "Delete this profile?" "No"
if (-not $Confirmed) {
Write-Host "[i] Cancelled"
return 0
}
# Delete instance directory
if (Test-Path $InstancePath) {
Remove-Item $InstancePath -Recurse -Force
}
# Remove from registry
Unregister-Profile $ProfileName
Write-Host "[OK] Profile removed successfully" -ForegroundColor Green
Write-Host " Profile: $ProfileName"
Write-Host ""
}
function Invoke-AuthDefault {
param([string[]]$Args)
$ProfileName = $Args[0]
if (-not $ProfileName) {
Write-ErrorMsg "Profile name is required`nUsage: ccs auth default <profile>"
return 1
}
if (-not (Test-ProfileExists $ProfileName)) {
Write-ErrorMsg "Profile not found: $ProfileName"
return 1
}
Set-DefaultProfile $ProfileName
Write-Host "[OK] Default profile set" -ForegroundColor Green
Write-Host " Profile: $ProfileName"
Write-Host ""
Write-Host "Now you can use:"
Write-Host " ccs `"your prompt`" # Uses $ProfileName profile" -ForegroundColor Yellow
Write-Host ""
}
function Invoke-AuthCommands {
param([string[]]$Args)
$Subcommand = $Args[0]
$SubArgs = if ($Args.Count -gt 1) { $Args[1..($Args.Count-1)] } else { @() }
switch ($Subcommand) {
"create" { Invoke-AuthCreate $SubArgs }
"list" { Invoke-AuthList $SubArgs }
"show" { Invoke-AuthShow $SubArgs }
"remove" { Invoke-AuthRemove $SubArgs }
"default" { Invoke-AuthDefault $SubArgs }
default { Show-AuthHelp }
}
}
function Install-ShellCompletion {
param([string[]]$Args)
Write-Host ""
Write-Host "Shell Completion Installer" -ForegroundColor Yellow
Write-Host ""
# Ensure completion directory exists
$CompletionsDir = Join-Path $env:USERPROFILE ".ccs\completions"
if (-not (Test-Path $CompletionsDir)) {
New-Item -ItemType Directory -Path $CompletionsDir -Force | Out-Null
}
# Ensure completion file exists
$CompletionFile = Join-Path $CompletionsDir "ccs.ps1"
if (-not (Test-Path $CompletionFile)) {
Write-Host "[X] Completion file not found. Please reinstall CCS." -ForegroundColor Red
Write-Host ""
exit 1
}
# Get PowerShell profile path
$ProfilePath = $PROFILE
$ProfileDir = Split-Path $ProfilePath -Parent
# Create profile directory if it doesn't exist
if (-not (Test-Path $ProfileDir)) {
New-Item -ItemType Directory -Path $ProfileDir -Force | Out-Null
}
# Comment marker for easy identification
$Marker = "# CCS shell completion"
$SourceCmd = ". `"$CompletionFile`""
# Check if already installed
if (Test-Path $ProfilePath) {
$Content = Get-Content $ProfilePath -Raw -ErrorAction SilentlyContinue
if ($Content -and $Content.Contains($Marker)) {
Write-Host "[OK] Shell completion already installed" -ForegroundColor Green
Write-Host ""
return 0
}
}
# Append to PowerShell profile
$Block = "`n$Marker`n$SourceCmd`n"
Add-Content -Path $ProfilePath -Value $Block -NoNewline
Write-Host "[OK] Shell completion installed successfully!" -ForegroundColor Green
Write-Host ""
Write-Host "Added to $ProfilePath"
Write-Host ""
Write-Host "To activate:" -ForegroundColor Cyan
Write-Host " . `$PROFILE"
Write-Host ""
return 0
}
# --- Main Execution Logic ---
# Special case: version command (check BEFORE profile detection)
if ($Version) {
Show-Version
exit 0
} elseif ($RemainingArgs.Count -gt 0) {
$FirstArg = $RemainingArgs[0]
if ($FirstArg -eq "version" -or $FirstArg -eq "--version" -or $FirstArg -eq "-v") {
Show-Version
exit 0
}
}
# Special case: help command (check BEFORE profile detection)
if ($Help) {
Show-Help
exit 0
} elseif ($RemainingArgs.Count -gt 0) {
$FirstArg = $RemainingArgs[0]
if ($FirstArg -eq "--help" -or $FirstArg -eq "-h" -or $FirstArg -eq "help") {
Show-Help
exit 0
}
}
# Special case: shell completion installer
if ($RemainingArgs.Count -gt 0 -and $RemainingArgs[0] -eq "--shell-completion") {
$CompletionArgs = if ($RemainingArgs.Count -gt 1) { $RemainingArgs[1..($RemainingArgs.Count-1)] } else { @() }
$Result = Install-ShellCompletion $CompletionArgs
exit $Result
}
# Special case: auth commands
if ($RemainingArgs.Count -gt 0 -and $RemainingArgs[0] -eq "auth") {
$AuthArgs = if ($RemainingArgs.Count -gt 1) { $RemainingArgs[1..($RemainingArgs.Count-1)] } else { @() }
Invoke-AuthCommands $AuthArgs
exit $LASTEXITCODE
}
# Run auto-recovery before main logic
if (-not (Invoke-AutoRecovery)) {
Write-ErrorMsg "Auto-recovery failed. Check permissions."
exit 1
}
# Smart profile detection: if first arg starts with '-', it's a flag not a profile
if ($RemainingArgs.Count -eq 0 -or $RemainingArgs[0] -match '^-') {
# No args or first arg is a flag → use default profile
$Profile = "default"
# $RemainingArgs already contains the remaining args correctly
} else {
# First arg is a profile name
$Profile = $RemainingArgs[0]
$RemainingArgs = if ($RemainingArgs.Count -gt 1) { $RemainingArgs | Select-Object -Skip 1 } else { @() }
}
# Validate profile name (alphanumeric, dash, underscore only)
if ($Profile -notmatch '^[a-zA-Z0-9_-]+$') {
$ErrorMessage = "Invalid profile name: $Profile" + "`n`n" +
"Use only alphanumeric characters, dash, or underscore."
Write-ErrorMsg $ErrorMessage
exit 1
}
# Detect profile type
$ProfileInfo = Get-ProfileType $Profile
if ($ProfileInfo.Type -eq "error") {
# Get suggestions using fuzzy matching
$AllProfiles = Get-AllProfileNames
$Suggestions = Find-SimilarStrings -Target $Profile -Candidates $AllProfiles
Write-Host ""
Write-Host "[X] Profile '$Profile' not found" -ForegroundColor Red
Write-Host ""
# Show suggestions if any
if ($Suggestions -and $Suggestions.Count -gt 0) {
Write-Host "Did you mean:" -ForegroundColor Yellow
foreach ($suggestion in $Suggestions) {
Write-Host " $suggestion"
}
Write-Host ""
}
Write-Host "Available profiles:" -ForegroundColor Cyan
Get-AvailableProfiles | ForEach-Object { Write-Host $_ }
Write-Host ""
Write-Host "Solutions:" -ForegroundColor Yellow
Write-Host " # Use existing profile"
Write-Host " ccs <profile> `"your prompt`""
Write-Host ""
Write-Host " # Create new account profile"
Write-Host " ccs auth create <name>"
Write-Host ""
Write-Host "Error: $script:E_PROFILE_NOT_FOUND" -ForegroundColor Yellow
Write-Host (Get-ErrorDocUrl $script:E_PROFILE_NOT_FOUND) -ForegroundColor Yellow
Write-Host ""
exit 1
}
# Detect Claude CLI executable
$ClaudeCli = Find-ClaudeCli
# Execute based on profile type (Phase 5)
switch ($ProfileInfo.Type) {
"account" {
# Account-based profile: use CLAUDE_CONFIG_DIR
$InstancePath = Ensure-Instance $ProfileInfo.Name
Update-ProfileTimestamp $ProfileInfo.Name # Update last_used
# Execute Claude with isolated config
$env:CLAUDE_CONFIG_DIR = $InstancePath
try {
if ($RemainingArgs) {
& $ClaudeCli @RemainingArgs
} else {
& $ClaudeCli
}
exit $LASTEXITCODE
} catch {
Show-ClaudeNotFoundError
exit 1
}
}
"settings" {
# Settings-based profile: use --settings flag
$SettingsPath = $ProfileInfo.Path
# Path expansion and normalization
if ($SettingsPath -match '^~[/\\]') {
$SettingsPath = $SettingsPath -replace '^~', $env:USERPROFILE
}
$SettingsPath = [System.Environment]::ExpandEnvironmentVariables($SettingsPath)
$SettingsPath = $SettingsPath -replace '/', '\'
if (-not (Test-Path $SettingsPath)) {
Write-ErrorMsg "Settings file not found: $SettingsPath"
exit 1
}
try {
if ($RemainingArgs) {
& $ClaudeCli --settings $SettingsPath @RemainingArgs
} else {
& $ClaudeCli --settings $SettingsPath
}
exit $LASTEXITCODE
} catch {
Show-ClaudeNotFoundError
exit 1
}
}
"default" {
# Default: no special handling
try {
if ($RemainingArgs) {
& $ClaudeCli @RemainingArgs
} else {
& $ClaudeCli
}
exit $LASTEXITCODE
} catch {
Show-ClaudeNotFoundError
exit 1
}
}
default {
Write-ErrorMsg "Unknown profile type: $($ProfileInfo.Type)"
exit 1
}
}