mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 10:16:49 +00:00
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
1110 lines
34 KiB
PowerShell
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
|
|
}
|
|
}
|