Files
ccs/lib/ccs.ps1
T
kaitranntt daf075dc69 feat(core)!: refactor to symlink-based shared data architecture (v3.2.0)
BREAKING CHANGE: Shared data architecture refactored from copy-based to symlink-based.

Architecture Change:

- OLD: ~/.claude/ → [COPY 500ms] → ~/.ccs/shared/ → [SYMLINK] → instance/

- NEW: ~/.claude/ → [SYMLINK <1ms] → ~/.ccs/shared/ → [SYMLINK] → instance/

Performance Improvements:

- Install time: 500ms → 100ms (60% faster)

- Symlink creation: <1ms per directory (500x faster than copy)

- Zero data duplication between profiles

- Live updates: instant propagation across all profiles

Implementation Changes:

- bin/shared-manager.js: Added circular symlink detection, v3.1.1→v3.2.0 migration

- installers/install.sh: Replaced copy logic with symlink creation

- installers/install.ps1: Replaced copy logic with symlink creation + Developer Mode check

- scripts/postinstall.js: Updated to call migrateFromV311()

- tests/: Added symlink chain validation tests (bash + PowerShell)

Migration:

- Automatic upgrade from v3.1.1

- User customizations preserved in ~/.claude/

- No manual action required

Testing:

- All integration tests passed (6/6)

- Symlink chain validated on Linux

- Migration with data preservation validated

- Windows fallback logic preserved for non-Developer Mode
2025-11-10 21:57:44 -05:00

1110 lines
34 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 = "3.2.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)
Write-Host ""
Write-Host "=============================================" -ForegroundColor Red
Write-Host " ERROR" -ForegroundColor Red
Write-Host "=============================================" -ForegroundColor Red
Write-Host ""
Write-Host $Message -ForegroundColor Red
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 (work, personal, team) and"
Write-Host " alternative models (GLM, Kimi) instantly. Concurrent sessions with"
Write-Host " 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 kimi Switch to Kimi for Coding" "Yellow"
Write-ColorLine " ccs glm 'debug this code' Use GLM and run command" "Yellow"
Write-Host ""
Write-ColorLine "Account Management:" "Cyan"
Write-ColorLine " ccs auth --help Manage multiple Claude accounts" "Yellow"
Write-ColorLine " ccs work Switch to work account" "Yellow"
Write-ColorLine " ccs personal Switch to personal account" "Yellow"
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-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-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 "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"
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 <profile>" -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 <profile>"
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 <profile> --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 <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 }
}
}
# --- 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: 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") {
$ErrorMessage = "Profile '$Profile' not found" + "`n`n" +
"Available profiles:" + "`n" +
(Get-AvailableProfiles)
Write-ErrorMsg $ErrorMessage
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
}
}