mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-15 18:21:09 +00:00
feat: implement native multi-account switching with isolated instances (#3)
* feat: implement native multi-account switching with isolated instances Add account-based profile management system that enables users to run multiple Claude accounts concurrently with complete isolation. Key features: - Profile registry (~/.ccs/profiles.json) tracks account profiles - Instance isolation (~/.ccs/instances/<profile>/) for each account - Auth commands: create, list, show, remove, default - Backward compatible with settings-based profiles (GLM, Kimi) - Auto-copies global .claude configs to new instances Implementation: - Phase 1: Profile detection logic (account vs settings-based) - Phase 2: Instance management (initialization, validation) - Phase 3: Auth CLI commands - Phase 4: Profile registry CRUD operations - Phase 5: Execution routing based on profile type Both lib/ccs (bash) and lib/ccs.ps1 (PowerShell) updated for cross-platform support. * fix(ci): add pull_request trigger to satisfy branch protection The branch protection rule requires "Deploy ccs-installer to CloudFlare" status check to pass, but the workflow only ran on push to main branch. This caused PRs to wait indefinitely for a status that never reported. Changes: - Add pull_request trigger with same path filters - Split deployment into conditional steps: - PR mode: dry-run validation only (--dry-run flag) - Production: actual deployment (push to main only) - Keeps same job name to satisfy branch protection requirement Security: - No deployment from PR branches (dry-run only) - Production deploy only when push to main AND ref check - Secrets used safely in both contexts This fixes PR #3 which was stuck waiting for status. * fix(ci): remove path filter from pull_request trigger The path filter prevented workflow from running on PRs that don't modify worker/installer files, causing required status check to never report. This blocked PR #3 indefinitely. Changes: - Remove paths filter from pull_request trigger - Keep paths filter on push to main (optimize deployments) - Workflow now runs on ALL PRs to satisfy branch protection Trade-off: - PRs changing non-worker files will trigger unnecessary dry-run - But this ensures required status check always reports - Small cost for reliability and simpler configuration Alternatives considered: - Path-aware required checks: Not supported by GitHub - Remove required check: Loses CI validation - Add all paths to filter: Makes config brittle
This commit is contained in:
@@ -8,6 +8,10 @@ on:
|
||||
- 'scripts/wrangler.toml'
|
||||
- 'installers/**'
|
||||
|
||||
# Run on all PRs to satisfy branch protection (no path filter)
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
# Allow manual trigger
|
||||
workflow_dispatch:
|
||||
|
||||
@@ -20,7 +24,18 @@ jobs:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Deploy Worker
|
||||
- name: Validate Worker (PR Mode)
|
||||
if: github.event_name == 'pull_request'
|
||||
uses: cloudflare/wrangler-action@v3
|
||||
with:
|
||||
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
workingDirectory: scripts
|
||||
wranglerVersion: "4.45.3"
|
||||
command: deploy --dry-run
|
||||
|
||||
- name: Deploy Worker (Production)
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
uses: cloudflare/wrangler-action@v3
|
||||
with:
|
||||
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
|
||||
+707
-123
@@ -11,6 +11,13 @@ param(
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
# Version (updated by scripts/bump-version.sh)
|
||||
$CcsVersion = "3.0.0"
|
||||
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
$ConfigFile = if ($env:CCS_CONFIG) { $env:CCS_CONFIG } else { "$env:USERPROFILE\.ccs\config.json" }
|
||||
$ProfilesJson = "$env:USERPROFILE\.ccs\profiles.json"
|
||||
$InstancesDir = "$env:USERPROFILE\.ccs\instances"
|
||||
|
||||
# --- Color/Format Functions ---
|
||||
function Write-ErrorMsg {
|
||||
param([string]$Message)
|
||||
@@ -89,62 +96,52 @@ function Show-Help {
|
||||
Write-Host ""
|
||||
Write-ColorLine "Usage:" "Cyan"
|
||||
Write-ColorLine " ccs [profile] [claude-args...]" "Yellow"
|
||||
Write-ColorLine " ccs auth <command> [options]" "Yellow"
|
||||
Write-ColorLine " ccs [flags]" "Yellow"
|
||||
Write-Host ""
|
||||
Write-ColorLine "Description:" "Cyan"
|
||||
Write-Host " Switch between Claude models instantly. Stop hitting rate limits."
|
||||
Write-Host " Maps profile names to Claude settings files via ~/.ccs/config.json"
|
||||
Write-Host " Switch between Claude accounts and models instantly."
|
||||
Write-Host " Supports concurrent multi-account sessions."
|
||||
Write-Host ""
|
||||
Write-ColorLine "Profile Switching:" "Cyan"
|
||||
Write-ColorLine " ccs Use default profile" "Yellow"
|
||||
Write-ColorLine " ccs glm Switch to GLM profile" "Yellow"
|
||||
Write-ColorLine " ccs kimi Switch to Kimi profile" "Yellow"
|
||||
Write-ColorLine " ccs glm 'debug this code' Switch to GLM and run command" "Yellow"
|
||||
Write-ColorLine " ccs kimi 'write tests' Switch to Kimi and run command" "Yellow"
|
||||
Write-ColorLine " ccs glm --verbose Switch to GLM with Claude flags" "Yellow"
|
||||
Write-ColorLine " ccs kimi --verbose Switch to Kimi with Claude flags" "Yellow"
|
||||
Write-ColorLine " ccs work Switch to work account" "Yellow"
|
||||
Write-ColorLine " ccs work 'debug code' Run command with work account" "Yellow"
|
||||
Write-Host ""
|
||||
Write-ColorLine "Account Management:" "Cyan"
|
||||
Write-ColorLine " ccs auth create <profile> Create new account profile" "Yellow"
|
||||
Write-ColorLine " ccs auth list List all profiles" "Yellow"
|
||||
Write-ColorLine " ccs auth show <profile> Show profile details" "Yellow"
|
||||
Write-ColorLine " ccs auth remove <profile> Remove profile (requires --force)" "Yellow"
|
||||
Write-ColorLine " ccs auth default <profile> Set default profile" "Yellow"
|
||||
Write-Host ""
|
||||
Write-ColorLine "Flags:" "Cyan"
|
||||
Write-ColorLine " -h, --help Show this help message" "Yellow"
|
||||
Write-ColorLine " -v, --version Show version and installation info" "Yellow"
|
||||
Write-Host ""
|
||||
Write-Host ""
|
||||
Write-ColorLine "Configuration:" "Cyan"
|
||||
Write-Host " Config File: ~/.ccs/config.json"
|
||||
Write-Host " Settings: ~/.ccs/*.settings.json"
|
||||
Write-Host " Environment: CCS_CONFIG (override config path)"
|
||||
Write-Host " Config: ~/.ccs/config.json"
|
||||
Write-Host " Profiles: ~/.ccs/profiles.json"
|
||||
Write-Host " Settings: ~/.ccs/*.settings.json"
|
||||
Write-Host ""
|
||||
Write-ColorLine "Examples:" "Cyan"
|
||||
Write-Host " # Use default Claude subscription"
|
||||
Write-ColorLine " ccs 'Review this architecture'" "Yellow"
|
||||
Write-Host " # Create work account profile"
|
||||
Write-ColorLine " ccs auth create work" "Yellow"
|
||||
Write-Host ""
|
||||
Write-Host " # Use work account"
|
||||
Write-ColorLine " ccs work 'Review architecture'" "Yellow"
|
||||
Write-Host ""
|
||||
Write-Host " # Switch to GLM for cost-effective tasks"
|
||||
Write-ColorLine " ccs glm 'Write unit tests'" "Yellow"
|
||||
Write-Host ""
|
||||
Write-Host " # Switch to Kimi for alternative option"
|
||||
Write-ColorLine " ccs kimi 'Write integration tests'" "Yellow"
|
||||
Write-Host ""
|
||||
Write-Host " # Use with verbose output"
|
||||
Write-ColorLine " ccs glm --verbose 'Debug error'" "Yellow"
|
||||
Write-ColorLine " ccs kimi --verbose 'Review code'" "Yellow"
|
||||
Write-Host ""
|
||||
Write-ColorLine "Uninstall:" "Cyan"
|
||||
Write-Host " macOS/Linux: curl -fsSL ccs.kaitran.ca/uninstall | bash"
|
||||
Write-Host " Windows: irm ccs.kaitran.ca/uninstall | iex"
|
||||
Write-Host " npm: npm uninstall -g @kaitranntt/ccs"
|
||||
Write-Host ""
|
||||
Write-ColorLine "Documentation:" "Cyan"
|
||||
Write-Host " GitHub: https://github.com/kaitranntt/ccs"
|
||||
Write-Host " Docs: https://github.com/kaitranntt/ccs/blob/main/README.md"
|
||||
Write-Host " Issues: https://github.com/kaitranntt/ccs/issues"
|
||||
Write-Host ""
|
||||
Write-ColorLine "License: MIT" "Cyan"
|
||||
}
|
||||
|
||||
# Version (updated by scripts/bump-version.sh)
|
||||
$CcsVersion = "3.0.0"
|
||||
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
$ConfigFile = if ($env:CCS_CONFIG) { $env:CCS_CONFIG } else { "$env:USERPROFILE\.ccs\config.json" }
|
||||
|
||||
function Show-Version {
|
||||
$UseColors = $env:FORCE_COLOR -or ([Console]::IsOutputRedirected -eq $false -and -not $env:NO_COLOR)
|
||||
|
||||
@@ -205,8 +202,604 @@ function Show-Version {
|
||||
}
|
||||
}
|
||||
|
||||
# --- Profile Registry Functions (Phase 4) ---
|
||||
|
||||
function Initialize-ProfilesJson {
|
||||
if (Test-Path $ProfilesJson) { return }
|
||||
|
||||
$InitData = @{
|
||||
version = "2.0.0"
|
||||
profiles = @{}
|
||||
default = $null
|
||||
}
|
||||
|
||||
$InitData | ConvertTo-Json -Depth 10 | Set-Content $ProfilesJson
|
||||
|
||||
# Set file permissions (user-only)
|
||||
$Acl = Get-Acl $ProfilesJson
|
||||
$Acl.SetAccessRuleProtection($true, $false)
|
||||
$Acl.Access | ForEach-Object { $Acl.RemoveAccessRule($_) | Out-Null }
|
||||
$CurrentUser = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name
|
||||
$Rule = New-Object System.Security.AccessControl.FileSystemAccessRule(
|
||||
$CurrentUser, "FullControl", "Allow"
|
||||
)
|
||||
$Acl.AddAccessRule($Rule)
|
||||
Set-Acl -Path $ProfilesJson -AclObject $Acl
|
||||
}
|
||||
|
||||
function Read-ProfilesJson {
|
||||
Initialize-ProfilesJson
|
||||
Get-Content $ProfilesJson -Raw | ConvertFrom-Json
|
||||
}
|
||||
|
||||
function Write-ProfilesJson {
|
||||
param([PSCustomObject]$Data)
|
||||
|
||||
$TempFile = "$ProfilesJson.tmp"
|
||||
|
||||
try {
|
||||
$Data | ConvertTo-Json -Depth 10 | Set-Content $TempFile
|
||||
Move-Item $TempFile $ProfilesJson -Force
|
||||
} catch {
|
||||
if (Test-Path $TempFile) { Remove-Item $TempFile -Force }
|
||||
throw "Failed to write profiles registry: $_"
|
||||
}
|
||||
}
|
||||
|
||||
function Test-ProfileExists {
|
||||
param([string]$ProfileName)
|
||||
|
||||
Initialize-ProfilesJson
|
||||
$Data = Read-ProfilesJson
|
||||
return $Data.profiles.PSObject.Properties.Name -contains $ProfileName
|
||||
}
|
||||
|
||||
function Register-Profile {
|
||||
param([string]$ProfileName)
|
||||
|
||||
if (Test-ProfileExists $ProfileName) {
|
||||
throw "Profile already exists: $ProfileName"
|
||||
}
|
||||
|
||||
$Data = Read-ProfilesJson
|
||||
$Timestamp = (Get-Date).ToUniversalTime().ToString("o")
|
||||
|
||||
$Data.profiles | Add-Member -NotePropertyName $ProfileName -NotePropertyValue ([PSCustomObject]@{
|
||||
type = "account"
|
||||
created = $Timestamp
|
||||
last_used = $null
|
||||
})
|
||||
|
||||
# Set as default if none exists
|
||||
if (-not $Data.default) {
|
||||
$Data.default = $ProfileName
|
||||
}
|
||||
|
||||
Write-ProfilesJson $Data
|
||||
}
|
||||
|
||||
function Unregister-Profile {
|
||||
param([string]$ProfileName)
|
||||
|
||||
if (-not (Test-ProfileExists $ProfileName)) { return } # Idempotent
|
||||
|
||||
$Data = Read-ProfilesJson
|
||||
|
||||
# Remove profile
|
||||
$Data.profiles.PSObject.Properties.Remove($ProfileName)
|
||||
|
||||
# Update default if it was the deleted profile
|
||||
if ($Data.default -eq $ProfileName) {
|
||||
$Remaining = $Data.profiles.PSObject.Properties.Name
|
||||
$Data.default = if ($Remaining.Count -gt 0) { $Remaining[0] } else { $null }
|
||||
}
|
||||
|
||||
Write-ProfilesJson $Data
|
||||
}
|
||||
|
||||
function Update-ProfileTimestamp {
|
||||
param([string]$ProfileName)
|
||||
|
||||
if (-not (Test-ProfileExists $ProfileName)) { return } # Silent fail
|
||||
|
||||
$Data = Read-ProfilesJson
|
||||
$Timestamp = (Get-Date).ToUniversalTime().ToString("o")
|
||||
|
||||
$Data.profiles.$ProfileName.last_used = $Timestamp
|
||||
|
||||
Write-ProfilesJson $Data
|
||||
}
|
||||
|
||||
function Get-DefaultProfile {
|
||||
Initialize-ProfilesJson
|
||||
$Data = Read-ProfilesJson
|
||||
return $Data.default
|
||||
}
|
||||
|
||||
function Set-DefaultProfile {
|
||||
param([string]$ProfileName)
|
||||
|
||||
if (-not (Test-ProfileExists $ProfileName)) {
|
||||
throw "Profile not found: $ProfileName"
|
||||
}
|
||||
|
||||
$Data = Read-ProfilesJson
|
||||
$Data.default = $ProfileName
|
||||
|
||||
Write-ProfilesJson $Data
|
||||
}
|
||||
|
||||
# --- Instance Management Functions (Phase 2) ---
|
||||
|
||||
function Get-SanitizedProfileName {
|
||||
param([string]$Name)
|
||||
|
||||
# Replace unsafe chars, lowercase
|
||||
return $Name.ToLower() -replace '[^a-z0-9_-]', '-'
|
||||
}
|
||||
|
||||
function Set-InstancePermissions {
|
||||
param([string]$Path)
|
||||
|
||||
# Get current user
|
||||
$CurrentUser = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name
|
||||
|
||||
# Create new ACL with inheritance disabled
|
||||
$Acl = Get-Acl $Path
|
||||
$Acl.SetAccessRuleProtection($true, $false) # Disable inheritance
|
||||
$Acl.Access | ForEach-Object { $Acl.RemoveAccessRule($_) | Out-Null }
|
||||
|
||||
# Grant full control to current user only
|
||||
$Rule = New-Object System.Security.AccessControl.FileSystemAccessRule(
|
||||
$CurrentUser, "FullControl", "ContainerInherit,ObjectInherit", "None", "Allow"
|
||||
)
|
||||
$Acl.AddAccessRule($Rule)
|
||||
|
||||
Set-Acl -Path $Path -AclObject $Acl
|
||||
}
|
||||
|
||||
function Copy-GlobalConfigs {
|
||||
param([string]$InstancePath)
|
||||
|
||||
$GlobalClaude = "$env:USERPROFILE\.claude"
|
||||
|
||||
# Copy settings.json
|
||||
$GlobalSettings = Join-Path $GlobalClaude "settings.json"
|
||||
if (Test-Path $GlobalSettings) {
|
||||
Copy-Item $GlobalSettings -Destination (Join-Path $InstancePath "settings.json") -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
# Copy commands/
|
||||
$GlobalCommands = Join-Path $GlobalClaude "commands"
|
||||
if (Test-Path $GlobalCommands) {
|
||||
Copy-Item $GlobalCommands -Destination $InstancePath -Recurse -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
# Copy skills/
|
||||
$GlobalSkills = Join-Path $GlobalClaude "skills"
|
||||
if (Test-Path $GlobalSkills) {
|
||||
Copy-Item $GlobalSkills -Destination $InstancePath -Recurse -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
|
||||
function Initialize-Instance {
|
||||
param([string]$InstancePath)
|
||||
|
||||
# Create base directory with user-only ACL
|
||||
New-Item -ItemType Directory -Path $InstancePath -Force | Out-Null
|
||||
Set-InstancePermissions $InstancePath
|
||||
|
||||
# Create subdirectories
|
||||
$Subdirs = @('session-env', 'todos', 'logs', 'file-history',
|
||||
'shell-snapshots', 'debug', '.anthropic', 'commands', 'skills')
|
||||
|
||||
foreach ($Dir in $Subdirs) {
|
||||
$DirPath = Join-Path $InstancePath $Dir
|
||||
New-Item -ItemType Directory -Path $DirPath -Force | Out-Null
|
||||
}
|
||||
|
||||
# Copy global configs
|
||||
Copy-GlobalConfigs $InstancePath
|
||||
}
|
||||
|
||||
function Validate-Instance {
|
||||
param([string]$InstancePath)
|
||||
|
||||
$RequiredDirs = @('session-env', 'todos', 'logs', 'file-history',
|
||||
'shell-snapshots', 'debug', '.anthropic')
|
||||
|
||||
foreach ($Dir in $RequiredDirs) {
|
||||
$DirPath = Join-Path $InstancePath $Dir
|
||||
if (-not (Test-Path $DirPath)) {
|
||||
New-Item -ItemType Directory -Path $DirPath -Force | Out-Null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Ensure-Instance {
|
||||
param([string]$ProfileName)
|
||||
|
||||
$SafeName = Get-SanitizedProfileName $ProfileName
|
||||
$InstancePath = "$InstancesDir\$SafeName"
|
||||
|
||||
if (-not (Test-Path $InstancePath)) {
|
||||
Initialize-Instance $InstancePath
|
||||
}
|
||||
|
||||
Validate-Instance $InstancePath
|
||||
|
||||
return $InstancePath
|
||||
}
|
||||
|
||||
# --- Profile Detection Logic (Phase 1) ---
|
||||
|
||||
function Get-AvailableProfiles {
|
||||
$lines = @()
|
||||
|
||||
# Settings-based profiles
|
||||
if (Test-Path $ConfigFile) {
|
||||
try {
|
||||
$Config = Get-Content $ConfigFile -Raw | ConvertFrom-Json
|
||||
$SettingsProfiles = $Config.profiles.PSObject.Properties.Name
|
||||
|
||||
if ($SettingsProfiles.Count -gt 0) {
|
||||
$lines += "Settings-based profiles (GLM, Kimi, etc.):"
|
||||
foreach ($name in $SettingsProfiles) {
|
||||
$lines += " - $name"
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
# Account-based profiles
|
||||
if (Test-Path $ProfilesJson) {
|
||||
try {
|
||||
$Profiles = Read-ProfilesJson
|
||||
$AccountProfiles = $Profiles.profiles.PSObject.Properties.Name
|
||||
|
||||
if ($AccountProfiles.Count -gt 0) {
|
||||
$lines += "Account-based profiles:"
|
||||
foreach ($name in $AccountProfiles) {
|
||||
$IsDefault = if ($name -eq $Profiles.default) { " [DEFAULT]" } else { "" }
|
||||
$lines += " - $name$IsDefault"
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
if ($lines.Count -eq 0) {
|
||||
return " (no profiles configured)`n Run `"ccs auth create <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" -ForegroundColor Yellow
|
||||
Write-Host " ccs auth list" -ForegroundColor Yellow
|
||||
Write-Host " ccs auth remove work --force" -ForegroundColor Yellow
|
||||
Write-Host ""
|
||||
}
|
||||
|
||||
function Invoke-AuthCreate {
|
||||
param([string[]]$Args)
|
||||
|
||||
$ProfileName = ""
|
||||
$Force = $false
|
||||
|
||||
foreach ($arg in $Args) {
|
||||
if ($arg -eq "--force") {
|
||||
$Force = $true
|
||||
} elseif ($arg -match '^-') {
|
||||
Write-ErrorMsg "Unknown option: $arg"
|
||||
return 1
|
||||
} else {
|
||||
$ProfileName = $arg
|
||||
}
|
||||
}
|
||||
|
||||
if (-not $ProfileName) {
|
||||
Write-ErrorMsg "Profile name is required`n`nUsage: ccs auth create <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`"" -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)
|
||||
# Handle switch parameters and remaining arguments
|
||||
if ($Version) {
|
||||
Show-Version
|
||||
exit 0
|
||||
@@ -230,30 +823,11 @@ if ($Help) {
|
||||
}
|
||||
}
|
||||
|
||||
# Special case: install command (check BEFORE profile detection)
|
||||
if ($FirstArg -eq "--install") {
|
||||
Write-Host ""
|
||||
Write-Host "Feature not available" -ForegroundColor Yellow
|
||||
Write-Host ""
|
||||
Write-Host "The --install flag is currently under development."
|
||||
Write-Host ".claude/ integration testing is not complete."
|
||||
Write-Host ""
|
||||
Write-Host "For updates: https://github.com/kaitranntt/ccs/issues"
|
||||
Write-Host ""
|
||||
exit 0
|
||||
}
|
||||
|
||||
# Special case: uninstall command (check BEFORE profile detection)
|
||||
if ($FirstArg -eq "--uninstall") {
|
||||
Write-Host ""
|
||||
Write-Host "Feature not available" -ForegroundColor Yellow
|
||||
Write-Host ""
|
||||
Write-Host "The --uninstall flag is currently under development."
|
||||
Write-Host ".claude/ integration testing is not complete."
|
||||
Write-Host ""
|
||||
Write-Host "For updates: https://github.com/kaitranntt/ccs/issues"
|
||||
Write-Host ""
|
||||
exit 0
|
||||
# Special case: auth commands
|
||||
if ($RemainingArgs.Count -gt 0 -and $RemainingArgs[0] -eq "auth") {
|
||||
$AuthArgs = if ($RemainingArgs.Count -gt 1) { $RemainingArgs[1..($RemainingArgs.Count-1)] } else { @() }
|
||||
Invoke-AuthCommands $AuthArgs
|
||||
exit $LASTEXITCODE
|
||||
}
|
||||
|
||||
# Smart profile detection: if first arg starts with '-', it's a flag not a profile
|
||||
@@ -267,20 +841,6 @@ if ($RemainingArgs.Count -eq 0 -or $RemainingArgs[0] -match '^-') {
|
||||
$RemainingArgs = if ($RemainingArgs.Count -gt 1) { $RemainingArgs | Select-Object -Skip 1 } else { @() }
|
||||
}
|
||||
|
||||
# Check config exists
|
||||
if (-not (Test-Path $ConfigFile)) {
|
||||
$ErrorMessage = "Config file not found: $ConfigFile" + "`n`n" +
|
||||
"Solutions:" + "`n" +
|
||||
" 1. Reinstall CCS:" + "`n" +
|
||||
" irm ccs.kaitran.ca/install | iex" + "`n`n" +
|
||||
" 2. Or create config manually:" + "`n" +
|
||||
" New-Item -ItemType Directory -Force -Path '$env:USERPROFILE\.ccs'" + "`n" +
|
||||
" Set-Content -Path '$env:USERPROFILE\.ccs\config.json' -Value '{`"profiles`":{`"glm`":`"~/.ccs/glm.settings.json`",`"kimi`":`"~/.ccs/kimi.settings.json`",`"default`":`"~/.claude/settings.json`"}}'"
|
||||
|
||||
Write-ErrorMsg $ErrorMessage
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Validate profile name (alphanumeric, dash, underscore only)
|
||||
if ($Profile -notmatch '^[a-zA-Z0-9_-]+$') {
|
||||
$ErrorMessage = "Invalid profile name: $Profile" + "`n`n" +
|
||||
@@ -290,49 +850,13 @@ if ($Profile -notmatch '^[a-zA-Z0-9_-]+$') {
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Read and parse JSON config, get profile path in one step
|
||||
try {
|
||||
$ConfigContent = Get-Content $ConfigFile -Raw -ErrorAction Stop
|
||||
$Config = $ConfigContent | ConvertFrom-Json -ErrorAction Stop
|
||||
$SettingsPath = $Config.profiles.$Profile
|
||||
# Detect profile type
|
||||
$ProfileInfo = Get-ProfileType $Profile
|
||||
|
||||
if (-not $SettingsPath) {
|
||||
$AvailableProfiles = ($Config.profiles.PSObject.Properties.Name | ForEach-Object { " - $_" }) -join "`n"
|
||||
$ErrorMessage = "Profile '$Profile' not found in $ConfigFile" + "`n`n" +
|
||||
"Available profiles:" + "`n" +
|
||||
$AvailableProfiles
|
||||
|
||||
Write-ErrorMsg $ErrorMessage
|
||||
exit 1
|
||||
}
|
||||
} catch {
|
||||
$ErrorMessage = "Invalid JSON in $ConfigFile" + "`n`n" +
|
||||
"Fix the JSON syntax or reinstall:" + "`n" +
|
||||
" irm ccs.kaitran.ca/install | iex"
|
||||
|
||||
Write-ErrorMsg $ErrorMessage
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Path expansion and normalization
|
||||
# 1. Handle Unix-style tilde expansion (~/path -> %USERPROFILE%\path)
|
||||
if ($SettingsPath -match '^~[/\\]') {
|
||||
$SettingsPath = $SettingsPath -replace '^~', $env:USERPROFILE
|
||||
}
|
||||
|
||||
# 2. Expand Windows environment variables (%USERPROFILE%, etc.)
|
||||
$SettingsPath = [System.Environment]::ExpandEnvironmentVariables($SettingsPath)
|
||||
|
||||
# 3. Convert forward slashes to backslashes (Unix path compatibility)
|
||||
$SettingsPath = $SettingsPath -replace '/', '\'
|
||||
|
||||
# Validate settings file exists
|
||||
if (-not (Test-Path $SettingsPath)) {
|
||||
$ErrorMessage = "Settings file not found: $SettingsPath" + "`n`n" +
|
||||
"Solutions:" + "`n" +
|
||||
" 1. Create the settings file for profile '$Profile'" + "`n" +
|
||||
" 2. Update the path in $ConfigFile" + "`n" +
|
||||
" 3. Or reinstall: irm ccs.kaitran.ca/install | iex"
|
||||
if ($ProfileInfo.Type -eq "error") {
|
||||
$ErrorMessage = "Profile '$Profile' not found" + "`n`n" +
|
||||
"Available profiles:" + "`n" +
|
||||
(Get-AvailableProfiles)
|
||||
|
||||
Write-ErrorMsg $ErrorMessage
|
||||
exit 1
|
||||
@@ -341,15 +865,75 @@ if (-not (Test-Path $SettingsPath)) {
|
||||
# Detect Claude CLI executable
|
||||
$ClaudeCli = Find-ClaudeCli
|
||||
|
||||
# Execute Claude with the profile settings
|
||||
try {
|
||||
if ($RemainingArgs) {
|
||||
& $ClaudeCli --settings $SettingsPath @RemainingArgs
|
||||
} else {
|
||||
& $ClaudeCli --settings $SettingsPath
|
||||
# Execute based on profile type (Phase 5)
|
||||
switch ($ProfileInfo.Type) {
|
||||
"account" {
|
||||
# Account-based profile: use CLAUDE_CONFIG_DIR
|
||||
$InstancePath = Ensure-Instance $ProfileInfo.Name
|
||||
Update-ProfileTimestamp $ProfileInfo.Name # Update last_used
|
||||
|
||||
# Execute Claude with isolated config
|
||||
$env:CLAUDE_CONFIG_DIR = $InstancePath
|
||||
|
||||
try {
|
||||
if ($RemainingArgs) {
|
||||
& $ClaudeCli @RemainingArgs
|
||||
} else {
|
||||
& $ClaudeCli
|
||||
}
|
||||
exit $LASTEXITCODE
|
||||
} catch {
|
||||
Show-ClaudeNotFoundError
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
exit $LASTEXITCODE
|
||||
} catch {
|
||||
Show-ClaudeNotFoundError
|
||||
exit 1
|
||||
}
|
||||
|
||||
"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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user