mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 02:11:28 +00:00
* fix(shell-completion): resolve ENOTDIR error when parent path is a file
Fixes issue where `--shell-completion` fails with ENOTDIR error when
a file exists where a directory should be created (e.g., ~/.zsh exists
as a file instead of a directory).
Changes:
- Added ensureDirectory() helper that safely creates directories
- Validates parent paths are directories, not files
- Provides clear error message when file conflicts occur
- Applied to all shell installers (bash, zsh, fish, powershell)
- Maintains idempotent behavior (safe to call multiple times)
Before: mkdir with recursive:true fails silently with ENOTDIR
After: Clear error message guides user to resolve file conflict
* chore: bump version to 4.1.4
* feat(completion): improve UI/UX with descriptions and grouping
Improves shell completion UI/UX across all shells (bash, zsh, fish,
PowerShell) with better organization and clearer descriptions.
Changes:
- Zsh: Added descriptions for all profiles and grouped by category
- Commands: "auth", "doctor" with descriptions
- Model profiles: glm, glmt, kimi, etc. with descriptions
- Account profiles: Dynamic with "Account-based profile" label
- Used _alternative for visual grouping
- Fish: Added explicit completions with descriptions for known profiles
- Replaced generic profile listing with specific entries
- Each profile now shows clear description (e.g., "GLM-4.6 (cost-optimized)")
- Bash: Added --shell-completion flag and subflags completion
- PowerShell: Added --shell-completion flag and subflags completion
- All shells: Added completion for --shell-completion subflags
Before (zsh):
ccs
auth -- Manage multiple Claude accounts
doctor -- Run health check and diagnostics
default glm glmt kimi max
After (zsh):
commands
auth -- Manage multiple Claude accounts
doctor -- Run health check and diagnostics
model profiles
default -- Default Claude Sonnet 4.5
glm -- GLM-4.6 (cost-optimized)
glmt -- GLM-4.6 with thinking mode
kimi -- Kimi for Coding (long-context)
max -- Claude Opus (maximum capability)
account profiles
work -- Account-based profile
personal -- Account-based profile
Consistent, organized, and informative completion across all shells.
* fix(completion): handle custom profiles and fix zsh syntax errors
Fixes two issues:
1. Zsh syntax errors with _describe -t flag in _alternative
2. Adds general handling for custom settings profiles (e.g., m2)
Changes:
- Zsh: Fixed _alternative syntax (removed -t tag from _describe calls)
- Error was: "_describe:21: bad option: -M"
- Cause: Tag is auto-derived in _alternative, don't specify with -t
- Fish: Added __fish_ccs_get_custom_settings_profiles function
- Dynamically loads non-hardcoded profiles from config.json
- Shows "Settings-based profile" description for custom profiles
- Removed 'max' from hardcoded known profiles
- 'max' is user's account-based profile, not a default setting
Now supports any custom settings profile (e.g., m2.settings.json for
Minimax M2) without hardcoding. Custom profiles show with generic
"Settings-based profile" description.
Installation properly overwrites:
- fs.copyFileSync overwrites completion files by default
- RC files only modified if marker not already present
* fix(zsh): simplify completion to avoid _alternative syntax issues
Replaced _alternative with multiple _describe calls to fix persistent
zsh completion errors.
The _alternative approach was causing:
- '_describe:21: bad option: -M'
- '(eval):1: bad substitution'
- Unwanted variable expansion in completion menu
New approach uses sequential _describe calls which zsh handles correctly.
Simpler, more reliable, and still shows grouped completion with
descriptions.
Before: _alternative with complex nested _describe calls (broken)
After: Three simple _describe calls (works correctly)
* feat(zsh): add colors and improved formatting to completion
Enhances zsh completion UI/UX with colors and better spacing:
Colors:
- Blue: commands (auth, doctor)
- Green: model profiles (default, glm, glmt, kimi, custom)
- Yellow: account profiles
- Gray: descriptions
Formatting:
- Wider separator ( -- ) for better readability
- Group headers in bold cyan
- Menu selection enabled for navigation
- list-rows-first for better column layout
Before:
auth -- Manage multiple Claude accounts
default -- Default Claude Sonnet 4.5
doctor -- Run health check and diagnostics
glm -- GLM-4.6 (cost-optimized)
After:
commands (cyan header)
auth -- Manage multiple Claude accounts (blue)
doctor -- Run health check and diagnostics (blue)
model profiles (cyan header)
default -- Default Claude Sonnet 4.5 (green)
glm -- GLM-4.6 (cost-optimized) (green)
glmt -- GLM-4.6 with thinking mode (green)
kimi -- Kimi for Coding (long-context) (green)
account profiles (cyan header)
max -- Account-based profile (yellow)
Table-like appearance with colors matching --help output style.
* feat(fish): add colors to completion descriptions
Adds color-coded descriptions to Fish completion matching zsh style:
Colors:
- Blue: commands (auth, doctor)
- Green: model profiles (default, glm, glmt, kimi, custom)
- Yellow: account profiles
Fish has excellent native color support via set_color, making this
straightforward to implement.
Before:
auth Manage multiple Claude accounts
default Default Claude Sonnet 4.5
glm GLM-4.6 (cost-optimized)
max Account profile
After:
auth Manage multiple Claude accounts (blue)
default Default Claude Sonnet 4.5 (green)
glm GLM-4.6 (cost-optimized) (green)
max Account profile (yellow)
Consistent color scheme across zsh and fish shells.
* fix(zsh): correct color application for commands vs descriptions
Fixed color codes being applied backwards - commands were gray while
descriptions were colorful.
Issue:
- Used '1;34' format which zsh misinterpreted
- Commands appeared gray (unreadable)
- Descriptions appeared in color
Fix:
- Simplified color codes from '1;34' to '34' (just the color, no style prefix)
- Pattern now: =(#b)(command)(-- description)=34=90
- First group (command): color 34 (blue/green/yellow)
- Second group (description): color 90 (gray)
Before:
auth -- Manage multiple Claude accounts (gray) (blue)
After:
auth -- Manage multiple Claude accounts (blue) (gray)
Commands now properly colored, descriptions muted.
* feat(zsh): improve section header visibility with separators
Makes section headers more distinct and recognizable as dividers:
Before:
commands
auth -- ...
After:
── commands ──
auth -- ...
Changes:
- Added blank line before headers ($'\n')
- Bold yellow with decorative separators (── text ──)
- Clearly distinguishes section headers from actual commands
Now it's obvious that 'commands', 'model profiles', and 'account
profiles' are just section labels, not completable items.
* fix(zsh): use full ANSI color codes to fix reversed colors
The issue was using shorthand color codes (34, 90) instead of full
ANSI format (0;34, 2;37). Zsh was misinterpreting the shorthand,
causing colors to be applied to wrong groups.
Fixed:
- Commands: 0;34 (normal blue) - was appearing gray
- Model profiles: 0;32 (normal green) - was appearing gray
- Account profiles: 0;33 (normal yellow) - was appearing gray
- Descriptions: 2;37 (dim white) - was appearing colorful
Color codes must be escaped as 0\;34 in zsh strings.
Before fix:
auth (gray) -- description (blue)
After fix:
auth (blue) -- description (dim gray)
* fix(zsh): swap color order for completion groups
Zsh may apply colors in reverse order to capture groups on some systems.
Swapped color order: group 2 first, then group 1.
Pattern: (command)(separator + description)
Was: =blue=dim_white (applied as: blue to cmd, dim to desc)
Now: =dim_white=blue (applied as: dim to desc, blue to cmd)
Testing if this resolves the reversed color issue.
* fix(zsh): add empty leading color to fix reversed coloring
ROOT CAUSE: Zsh list-colors apply first color to WHOLE match, then
override with subsequent colors for each group. Without an empty
leading color, the whole match color leaks to groups without overrides.
Pattern behavior:
=(#b)(cmd)(desc)=BLUE=DIM
→ Whole: BLUE, Group1: DIM (override), Group2: BLUE (no override)
→ Result: cmd=dim, desc=BLUE (REVERSED!)
=(#b)(cmd)(desc)==BLUE=DIM
→ Whole: none, Group1: BLUE, Group2: DIM
→ Result: cmd=BLUE, desc=dim (CORRECT!)
The '==' at start means 'no whole-match color', preventing color bleed.
Now commands will be colorful (blue/green/yellow) and descriptions dim.
* refactor(help): remove specific account examples and generalize description
Removed deterministic account examples (work, personal, team) to make
the help text more generic and less prescriptive.
Changes:
- Removed 'ccs work' and 'ccs personal' example lines
- Removed 'Multi-account workflow' examples section
- Updated description from 'multiple Claude accounts (work, personal, team)'
to 'multiple Claude accounts and alternative models'
- Changed to 'Run different Claude CLI sessions concurrently'
- Applied consistently across Node.js (bin/ccs.js), bash (lib/ccs),
and PowerShell (lib/ccs.ps1)
This makes the help text more flexible and doesn't imply specific
use cases or account naming conventions.
* refactor(help): clarify delegation section and remove non-existent command
Updated delegation section in help text across all implementations:
Changes:
- Renamed section from 'Delegation (Token Optimization)' to
'Delegation (inside Claude Code CLI)' to clarify context
- Removed non-existent '/ccs:create m2' command
- Simplified description to focus on cost savings
- Updated command descriptions:
- '/ccs:glm' now 'for simple tasks' (clearer use case)
- '/ccs:kimi' unchanged (already clear)
- Added delegation section to PowerShell help (was missing)
- Consistent messaging across Node.js, bash, and PowerShell
The new section makes it immediately clear that delegation commands
are used within Claude Code CLI sessions, not as standalone commands.
---------
1468 lines
45 KiB
PowerShell
1468 lines
45 KiB
PowerShell
# CCS - Claude Code Switch (Windows PowerShell)
|
|
# Cross-platform Claude CLI profile switcher
|
|
# https://github.com/kaitranntt/ccs
|
|
|
|
param(
|
|
[switch]$Help,
|
|
[switch]$Version,
|
|
[Parameter(ValueFromRemainingArguments=$true)]
|
|
[string[]]$RemainingArgs
|
|
)
|
|
|
|
$ErrorActionPreference = "Stop"
|
|
|
|
# Version (updated by scripts/bump-version.sh)
|
|
$CcsVersion = "4.1.4"
|
|
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
|
$ConfigFile = if ($env:CCS_CONFIG) { $env:CCS_CONFIG } else { "$env:USERPROFILE\.ccs\config.json" }
|
|
$ProfilesJson = "$env:USERPROFILE\.ccs\profiles.json"
|
|
$InstancesDir = "$env:USERPROFILE\.ccs\instances"
|
|
|
|
# Determine dependency location (git vs installed)
|
|
# Git: lib/ccs.ps1 and dependencies are in same dir (lib/)
|
|
# Installed: lib/ccs.ps1 is in ~/.ccs/, dependencies in ~/.ccs/lib/
|
|
$DepDir = if (Test-Path "$ScriptDir\error-codes.ps1") {
|
|
# Git install - files in same directory
|
|
$ScriptDir
|
|
} else {
|
|
# Standalone install - files in ~/.ccs/lib/
|
|
"$env:USERPROFILE\.ccs\lib"
|
|
}
|
|
|
|
# Source error codes
|
|
. "$DepDir\error-codes.ps1"
|
|
|
|
# Source progress indicators
|
|
. "$DepDir\progress-indicator.ps1"
|
|
|
|
# Source interactive prompts
|
|
. "$DepDir\prompt.ps1"
|
|
|
|
# --- Color/Format Functions ---
|
|
function Write-ErrorMsg {
|
|
param([string]$Message)
|
|
Write-Host ""
|
|
Write-Host "=============================================" -ForegroundColor Red
|
|
Write-Host " ERROR" -ForegroundColor Red
|
|
Write-Host "=============================================" -ForegroundColor Red
|
|
Write-Host ""
|
|
Write-Host $Message -ForegroundColor Red
|
|
Write-Host ""
|
|
}
|
|
|
|
# Calculate Levenshtein distance between two strings
|
|
function Get-LevenshteinDistance {
|
|
param(
|
|
[string]$a,
|
|
[string]$b
|
|
)
|
|
|
|
$lenA = $a.Length
|
|
$lenB = $b.Length
|
|
|
|
if ($lenA -eq 0) { return $lenB }
|
|
if ($lenB -eq 0) { return $lenA }
|
|
|
|
# Initialize matrix
|
|
$matrix = New-Object 'int[,]' ($lenB + 1), ($lenA + 1)
|
|
|
|
# Initialize first row and column
|
|
for ($i = 0; $i -le $lenB; $i++) {
|
|
$matrix[$i, 0] = $i
|
|
}
|
|
for ($j = 0; $j -le $lenA; $j++) {
|
|
$matrix[0, $j] = $j
|
|
}
|
|
|
|
# Fill matrix
|
|
for ($i = 1; $i -le $lenB; $i++) {
|
|
for ($j = 1; $j -le $lenA; $j++) {
|
|
if ($a[$j - 1] -eq $b[$i - 1]) {
|
|
$matrix[$i, $j] = $matrix[$i - 1, $j - 1]
|
|
} else {
|
|
$sub = $matrix[$i - 1, $j - 1]
|
|
$ins = $matrix[$i, $j - 1]
|
|
$del = $matrix[$i - 1, $j]
|
|
$min = [Math]::Min([Math]::Min($sub, $ins), $del)
|
|
$matrix[$i, $j] = $min + 1
|
|
}
|
|
}
|
|
}
|
|
|
|
return $matrix[$lenB, $lenA]
|
|
}
|
|
|
|
# Find similar strings using fuzzy matching
|
|
function Find-SimilarStrings {
|
|
param(
|
|
[string]$Target,
|
|
[string[]]$Candidates,
|
|
[int]$MaxDistance = 2
|
|
)
|
|
|
|
$targetLower = $Target.ToLower()
|
|
$matches = @()
|
|
|
|
foreach ($candidate in $Candidates) {
|
|
$candidateLower = $candidate.ToLower()
|
|
$distance = Get-LevenshteinDistance $targetLower $candidateLower
|
|
|
|
if ($distance -le $MaxDistance -and $distance -gt 0) {
|
|
$matches += [PSCustomObject]@{
|
|
Name = $candidate
|
|
Distance = $distance
|
|
}
|
|
}
|
|
}
|
|
|
|
# Sort by distance and return top 3
|
|
return $matches | Sort-Object Distance | Select-Object -First 3 | ForEach-Object { $_.Name }
|
|
}
|
|
|
|
# Enhanced error message with error codes
|
|
function Show-EnhancedError {
|
|
param(
|
|
[string]$ErrorCode,
|
|
[string]$ShortMsg,
|
|
[string]$Context = "",
|
|
[string]$Suggestions = ""
|
|
)
|
|
|
|
Write-Host ""
|
|
Write-Host "[X] $ShortMsg" -ForegroundColor Red
|
|
Write-Host ""
|
|
|
|
if ($Context) {
|
|
Write-Host $Context
|
|
Write-Host ""
|
|
}
|
|
|
|
if ($Suggestions) {
|
|
Write-Host "Solutions:" -ForegroundColor Yellow
|
|
Write-Host $Suggestions
|
|
Write-Host ""
|
|
}
|
|
|
|
Write-Host "Error: $ErrorCode" -ForegroundColor Yellow
|
|
Write-Host (Get-ErrorDocUrl $ErrorCode) -ForegroundColor Yellow
|
|
Write-Host ""
|
|
}
|
|
|
|
function Write-ColoredText {
|
|
param(
|
|
[string]$Text,
|
|
[string]$Color = "White",
|
|
[switch]$NoNewline
|
|
)
|
|
|
|
$UseColors = $env:FORCE_COLOR -or ([Console]::IsOutputRedirected -eq $false -and -not $env:NO_COLOR)
|
|
|
|
if ($UseColors -and $Color) {
|
|
if ($NoNewline) {
|
|
Write-Host $Text -ForegroundColor $Color -NoNewline
|
|
} else {
|
|
Write-Host $Text -ForegroundColor $Color
|
|
}
|
|
} else {
|
|
if ($NoNewline) {
|
|
Write-Host $Text -NoNewline
|
|
} else {
|
|
Write-Host $Text
|
|
}
|
|
}
|
|
}
|
|
|
|
# --- Claude CLI Detection Logic ---
|
|
|
|
function Find-ClaudeCli {
|
|
if ($env:CCS_CLAUDE_PATH) {
|
|
return $env:CCS_CLAUDE_PATH
|
|
} else {
|
|
return "claude"
|
|
}
|
|
}
|
|
|
|
function Show-ClaudeNotFoundError {
|
|
$Message = "Claude CLI not found in PATH" + "`n`n" +
|
|
"CCS requires Claude CLI to be installed and available in your PATH." + "`n`n" +
|
|
"Solutions:" + "`n" +
|
|
" 1. Install Claude CLI:" + "`n" +
|
|
" https://docs.claude.com/en/docs/claude-code/installation" + "`n`n" +
|
|
" 2. Verify installation:" + "`n" +
|
|
" Get-Command claude" + "`n`n" +
|
|
" 3. If installed but not in PATH, add it:" + "`n" +
|
|
" # Find Claude installation" + "`n" +
|
|
" where.exe claude" + "`n`n" +
|
|
" # Or set custom path" + "`n" +
|
|
" `$env:CCS_CLAUDE_PATH = 'C:\path\to\claude.exe'" + "`n`n" +
|
|
"Restart your terminal after installation."
|
|
|
|
Write-ErrorMsg $Message
|
|
}
|
|
|
|
function Show-Help {
|
|
$UseColors = $env:FORCE_COLOR -or ([Console]::IsOutputRedirected -eq $false -and -not $env:NO_COLOR)
|
|
|
|
# Helper for colored output
|
|
function Write-ColorLine {
|
|
param([string]$Text, [string]$Color = "White")
|
|
if ($UseColors) { Write-Host $Text -ForegroundColor $Color }
|
|
else { Write-Host $Text }
|
|
}
|
|
|
|
Write-ColorLine "CCS (Claude Code Switch) - Instant profile switching for Claude CLI" "White"
|
|
Write-Host ""
|
|
Write-ColorLine "Usage:" "Cyan"
|
|
Write-ColorLine " ccs [profile] [claude-args...]" "Yellow"
|
|
Write-ColorLine " ccs auth <command> [options]" "Yellow"
|
|
Write-ColorLine " ccs [flags]" "Yellow"
|
|
Write-Host ""
|
|
Write-ColorLine "Description:" "Cyan"
|
|
Write-Host " Switch between multiple Claude accounts and alternative models"
|
|
Write-Host " (GLM, Kimi) instantly. Run different Claude CLI sessions concurrently"
|
|
Write-Host " with auto-recovery. Zero downtime."
|
|
Write-Host ""
|
|
Write-ColorLine "Model Switching:" "Cyan"
|
|
Write-ColorLine " ccs Use default Claude account" "Yellow"
|
|
Write-ColorLine " ccs glm Switch to GLM 4.6 model" "Yellow"
|
|
Write-ColorLine " ccs glmt Switch to GLM with thinking mode" "Yellow"
|
|
Write-ColorLine " ccs kimi Switch to Kimi for Coding" "Yellow"
|
|
Write-ColorLine " ccs glm 'debug this code' Use GLM and run command" "Yellow"
|
|
Write-Host ""
|
|
Write-ColorLine "Examples:" "Cyan"
|
|
Write-ColorLine " `$ ccs" "Yellow" -NoNewline
|
|
Write-Host " # Use default account"
|
|
Write-ColorLine " `$ ccs glm `"implement API`"" "Yellow" -NoNewline
|
|
Write-Host " # Cost-optimized model"
|
|
Write-Host ""
|
|
Write-ColorLine "Account Management:" "Cyan"
|
|
Write-ColorLine " ccs auth --help Manage multiple Claude accounts" "Yellow"
|
|
Write-Host ""
|
|
Write-ColorLine "Delegation (inside Claude Code CLI):" "Cyan"
|
|
Write-ColorLine " /ccs:glm `"task`" Delegate to GLM-4.6 for simple tasks" "Yellow"
|
|
Write-ColorLine " /ccs:kimi `"task`" Delegate to Kimi for long context" "Yellow"
|
|
Write-Host " Save tokens by delegating simple tasks to cost-optimized models"
|
|
Write-Host ""
|
|
Write-ColorLine "Diagnostics:" "Cyan"
|
|
Write-ColorLine " ccs doctor Run health check and diagnostics" "Yellow"
|
|
Write-Host ""
|
|
Write-ColorLine "Flags:" "Cyan"
|
|
Write-ColorLine " -h, --help Show this help message" "Yellow"
|
|
Write-ColorLine " -v, --version Show version and installation info" "Yellow"
|
|
Write-ColorLine " --shell-completion Install shell auto-completion" "Yellow"
|
|
Write-Host ""
|
|
Write-ColorLine "Configuration:" "Cyan"
|
|
Write-Host " Config: ~/.ccs/config.json"
|
|
Write-Host " Profiles: ~/.ccs/profiles.json"
|
|
Write-Host " Instances: ~/.ccs/instances/"
|
|
Write-Host " Settings: ~/.ccs/*.settings.json"
|
|
Write-Host ""
|
|
Write-ColorLine "Shared Data:" "Cyan"
|
|
Write-Host " Commands: ~/.ccs/shared/commands/"
|
|
Write-Host " Skills: ~/.ccs/shared/skills/"
|
|
Write-Host " Note: Commands, skills, and agents are symlinked across all profiles"
|
|
Write-Host ""
|
|
Write-ColorLine "Documentation:" "Cyan"
|
|
Write-Host " GitHub: https://github.com/kaitranntt/ccs"
|
|
Write-Host " Docs: https://github.com/kaitranntt/ccs/blob/main/README.md"
|
|
Write-Host ""
|
|
Write-ColorLine "License: MIT" "Cyan"
|
|
}
|
|
|
|
function Show-Version {
|
|
$UseColors = $env:FORCE_COLOR -or ([Console]::IsOutputRedirected -eq $false -and -not $env:NO_COLOR)
|
|
|
|
# Title
|
|
if ($UseColors) {
|
|
Write-Host "CCS (Claude Code Switch) v$CcsVersion" -ForegroundColor White
|
|
} else {
|
|
Write-Host "CCS (Claude Code Switch) v$CcsVersion"
|
|
}
|
|
Write-Host ""
|
|
|
|
# Installation
|
|
if ($UseColors) { Write-Host "Installation:" -ForegroundColor Cyan }
|
|
else { Write-Host "Installation:" }
|
|
|
|
# Location
|
|
$InstallLocation = (Get-Command ccs -ErrorAction SilentlyContinue).Source
|
|
if ($InstallLocation) {
|
|
if ($UseColors) {
|
|
Write-Host " Location: " -ForegroundColor Cyan -NoNewline
|
|
Write-Host $InstallLocation
|
|
} else {
|
|
Write-Host " Location: $InstallLocation"
|
|
}
|
|
} else {
|
|
if ($UseColors) {
|
|
Write-Host " Location: " -ForegroundColor Cyan -NoNewline
|
|
Write-Host "(not found - run from current directory)" -ForegroundColor Gray
|
|
} else {
|
|
Write-Host " Location: (not found - run from current directory)"
|
|
}
|
|
}
|
|
|
|
# Config
|
|
if ($UseColors) {
|
|
Write-Host " Config: " -ForegroundColor Cyan -NoNewline
|
|
Write-Host $ConfigFile
|
|
} else {
|
|
Write-Host " Config: $ConfigFile"
|
|
}
|
|
|
|
Write-Host ""
|
|
|
|
# Documentation
|
|
if ($UseColors) {
|
|
Write-Host "Documentation: https://github.com/kaitranntt/ccs" -ForegroundColor Cyan
|
|
Write-Host "License: MIT" -ForegroundColor Cyan
|
|
} else {
|
|
Write-Host "Documentation: https://github.com/kaitranntt/ccs"
|
|
Write-Host "License: MIT"
|
|
}
|
|
Write-Host ""
|
|
|
|
if ($UseColors) {
|
|
Write-Host "Run 'ccs --help' for usage information" -ForegroundColor Yellow
|
|
} else {
|
|
Write-Host "Run 'ccs --help' for usage information"
|
|
}
|
|
}
|
|
|
|
# --- Auto-Recovery Functions ---
|
|
|
|
function Ensure-CcsDirectory {
|
|
if (Test-Path "$env:USERPROFILE\.ccs") { return $true }
|
|
|
|
try {
|
|
New-Item -ItemType Directory -Path "$env:USERPROFILE\.ccs" -Force | Out-Null
|
|
Write-Host "[i] Auto-recovery: Created ~/.ccs/ directory"
|
|
return $true
|
|
} catch {
|
|
Write-ErrorMsg "Cannot create ~/.ccs/ directory. Check permissions."
|
|
return $false
|
|
}
|
|
}
|
|
|
|
function Ensure-ConfigJson {
|
|
$ConfigFile = "$env:USERPROFILE\.ccs\config.json"
|
|
|
|
# Check if exists and valid
|
|
if (Test-Path $ConfigFile) {
|
|
try {
|
|
Get-Content $ConfigFile -Raw | ConvertFrom-Json | Out-Null
|
|
return $true
|
|
} catch {
|
|
# Corrupted - backup and recreate
|
|
$BackupFile = "$ConfigFile.backup.$(Get-Date -Format 'yyyyMMddHHmmss')"
|
|
Move-Item $ConfigFile $BackupFile -Force
|
|
Write-Host "[i] Auto-recovery: Backed up corrupted config.json"
|
|
}
|
|
}
|
|
|
|
# Create default config
|
|
$DefaultConfig = @{
|
|
profiles = @{
|
|
glm = "~/.ccs/glm.settings.json"
|
|
kimi = "~/.ccs/kimi.settings.json"
|
|
default = "~/.claude/settings.json"
|
|
}
|
|
}
|
|
|
|
$DefaultConfig | ConvertTo-Json -Depth 10 | Set-Content $ConfigFile
|
|
Write-Host "[i] Auto-recovery: Created ~/.ccs/config.json"
|
|
return $true
|
|
}
|
|
|
|
function Ensure-ClaudeSettings {
|
|
$ClaudeDir = "$env:USERPROFILE\.claude"
|
|
$SettingsFile = "$ClaudeDir\settings.json"
|
|
|
|
# Create ~/.claude/ if missing
|
|
if (-not (Test-Path $ClaudeDir)) {
|
|
New-Item -ItemType Directory -Path $ClaudeDir -Force | Out-Null
|
|
Write-Host "[i] Auto-recovery: Created ~/.claude/ directory"
|
|
}
|
|
|
|
# Create settings.json if missing
|
|
if (-not (Test-Path $SettingsFile)) {
|
|
'{}' | Set-Content $SettingsFile
|
|
Write-Host "[i] Auto-recovery: Created ~/.claude/settings.json"
|
|
Write-Host "[i] Next step: Run 'claude /login' to authenticate"
|
|
return $true
|
|
}
|
|
|
|
return $false
|
|
}
|
|
|
|
function Invoke-AutoRecovery {
|
|
if (-not (Ensure-CcsDirectory)) { return $false }
|
|
if (-not (Ensure-ConfigJson)) { return $false }
|
|
Ensure-ClaudeSettings | Out-Null
|
|
return $true
|
|
}
|
|
|
|
# --- Profile Registry Functions (Phase 4) ---
|
|
|
|
function Initialize-ProfilesJson {
|
|
if (Test-Path $ProfilesJson) { return }
|
|
|
|
$InitData = @{
|
|
version = "2.0.0"
|
|
profiles = @{}
|
|
default = $null
|
|
}
|
|
|
|
$InitData | ConvertTo-Json -Depth 10 | Set-Content $ProfilesJson
|
|
|
|
# Set file permissions (user-only)
|
|
$Acl = Get-Acl $ProfilesJson
|
|
$Acl.SetAccessRuleProtection($true, $false)
|
|
$Acl.Access | ForEach-Object { $Acl.RemoveAccessRule($_) | Out-Null }
|
|
$CurrentUser = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name
|
|
$Rule = New-Object System.Security.AccessControl.FileSystemAccessRule(
|
|
$CurrentUser, "FullControl", "Allow"
|
|
)
|
|
$Acl.AddAccessRule($Rule)
|
|
Set-Acl -Path $ProfilesJson -AclObject $Acl
|
|
}
|
|
|
|
function Read-ProfilesJson {
|
|
Initialize-ProfilesJson
|
|
Get-Content $ProfilesJson -Raw | ConvertFrom-Json
|
|
}
|
|
|
|
function Write-ProfilesJson {
|
|
param([PSCustomObject]$Data)
|
|
|
|
$TempFile = "$ProfilesJson.tmp"
|
|
|
|
try {
|
|
$Data | ConvertTo-Json -Depth 10 | Set-Content $TempFile
|
|
Move-Item $TempFile $ProfilesJson -Force
|
|
} catch {
|
|
if (Test-Path $TempFile) { Remove-Item $TempFile -Force }
|
|
throw "Failed to write profiles registry: $_"
|
|
}
|
|
}
|
|
|
|
function Test-ProfileExists {
|
|
param([string]$ProfileName)
|
|
|
|
Initialize-ProfilesJson
|
|
$Data = Read-ProfilesJson
|
|
return $Data.profiles.PSObject.Properties.Name -contains $ProfileName
|
|
}
|
|
|
|
function Register-Profile {
|
|
param([string]$ProfileName)
|
|
|
|
if (Test-ProfileExists $ProfileName) {
|
|
throw "Profile already exists: $ProfileName"
|
|
}
|
|
|
|
$Data = Read-ProfilesJson
|
|
$Timestamp = (Get-Date).ToUniversalTime().ToString("o")
|
|
|
|
$Data.profiles | Add-Member -NotePropertyName $ProfileName -NotePropertyValue ([PSCustomObject]@{
|
|
type = "account"
|
|
created = $Timestamp
|
|
last_used = $null
|
|
})
|
|
|
|
# Note: No longer auto-set as default
|
|
# Users must explicitly run: ccs auth default <profile>
|
|
# Default always stays on implicit 'default' profile (uses ~/.claude/)
|
|
|
|
Write-ProfilesJson $Data
|
|
}
|
|
|
|
function Unregister-Profile {
|
|
param([string]$ProfileName)
|
|
|
|
if (-not (Test-ProfileExists $ProfileName)) { return } # Idempotent
|
|
|
|
$Data = Read-ProfilesJson
|
|
|
|
# Remove profile
|
|
$Data.profiles.PSObject.Properties.Remove($ProfileName)
|
|
|
|
# Update default if it was the deleted profile
|
|
if ($Data.default -eq $ProfileName) {
|
|
$Remaining = $Data.profiles.PSObject.Properties.Name
|
|
$Data.default = if ($Remaining.Count -gt 0) { $Remaining[0] } else { $null }
|
|
}
|
|
|
|
Write-ProfilesJson $Data
|
|
}
|
|
|
|
function Update-ProfileTimestamp {
|
|
param([string]$ProfileName)
|
|
|
|
if (-not (Test-ProfileExists $ProfileName)) { return } # Silent fail
|
|
|
|
$Data = Read-ProfilesJson
|
|
$Timestamp = (Get-Date).ToUniversalTime().ToString("o")
|
|
|
|
$Data.profiles.$ProfileName.last_used = $Timestamp
|
|
|
|
Write-ProfilesJson $Data
|
|
}
|
|
|
|
function Get-DefaultProfile {
|
|
Initialize-ProfilesJson
|
|
$Data = Read-ProfilesJson
|
|
return $Data.default
|
|
}
|
|
|
|
function Set-DefaultProfile {
|
|
param([string]$ProfileName)
|
|
|
|
if (-not (Test-ProfileExists $ProfileName)) {
|
|
throw "Profile not found: $ProfileName"
|
|
}
|
|
|
|
$Data = Read-ProfilesJson
|
|
$Data.default = $ProfileName
|
|
|
|
Write-ProfilesJson $Data
|
|
}
|
|
|
|
# --- Instance Management Functions (Phase 2) ---
|
|
|
|
function Get-SanitizedProfileName {
|
|
param([string]$Name)
|
|
|
|
# Replace unsafe chars, lowercase
|
|
return $Name.ToLower() -replace '[^a-z0-9_-]', '-'
|
|
}
|
|
|
|
function Set-InstancePermissions {
|
|
param([string]$Path)
|
|
|
|
# Get current user
|
|
$CurrentUser = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name
|
|
|
|
# Create new ACL with inheritance disabled
|
|
$Acl = Get-Acl $Path
|
|
$Acl.SetAccessRuleProtection($true, $false) # Disable inheritance
|
|
$Acl.Access | ForEach-Object { $Acl.RemoveAccessRule($_) | Out-Null }
|
|
|
|
# Grant full control to current user only
|
|
$Rule = New-Object System.Security.AccessControl.FileSystemAccessRule(
|
|
$CurrentUser, "FullControl", "ContainerInherit,ObjectInherit", "None", "Allow"
|
|
)
|
|
$Acl.AddAccessRule($Rule)
|
|
|
|
Set-Acl -Path $Path -AclObject $Acl
|
|
}
|
|
|
|
function Link-SharedDirectories {
|
|
param([string]$InstancePath)
|
|
|
|
$SharedDir = "$env:USERPROFILE\.ccs\shared"
|
|
|
|
# Ensure shared directories exist
|
|
@('commands', 'skills', 'agents') | ForEach-Object {
|
|
$Dir = Join-Path $SharedDir $_
|
|
if (-not (Test-Path $Dir)) {
|
|
New-Item -ItemType Directory -Path $Dir -Force | Out-Null
|
|
}
|
|
}
|
|
|
|
# Create symlinks (requires Windows Developer Mode or admin)
|
|
@('commands', 'skills', 'agents') | ForEach-Object {
|
|
$LinkPath = Join-Path $InstancePath $_
|
|
$TargetPath = Join-Path $SharedDir $_
|
|
|
|
# Remove existing directory/link
|
|
if (Test-Path $LinkPath) {
|
|
Remove-Item $LinkPath -Recurse -Force
|
|
}
|
|
|
|
# Try creating symlink (requires privileges)
|
|
try {
|
|
New-Item -ItemType SymbolicLink -Path $LinkPath -Target $TargetPath -Force | Out-Null
|
|
} catch {
|
|
# Fallback: Copy directory instead (suboptimal but functional)
|
|
Copy-Item $TargetPath -Destination $LinkPath -Recurse -Force
|
|
Write-Host "[!] Symlink failed for $_, copied instead (enable Developer Mode)" -ForegroundColor Yellow
|
|
}
|
|
}
|
|
}
|
|
|
|
function Migrate-SharedStructure {
|
|
$SharedDir = "$env:USERPROFILE\.ccs\shared"
|
|
|
|
# Check if migration is needed (shared dirs exist but are empty)
|
|
if (Test-Path $SharedDir) {
|
|
$NeedsMigration = $false
|
|
foreach ($Dir in @('commands', 'skills', 'agents')) {
|
|
$DirPath = Join-Path $SharedDir $Dir
|
|
if (-not (Test-Path $DirPath) -or (Get-ChildItem $DirPath -ErrorAction SilentlyContinue).Count -eq 0) {
|
|
$NeedsMigration = $true
|
|
break
|
|
}
|
|
}
|
|
|
|
if (-not $NeedsMigration) { return }
|
|
}
|
|
|
|
# Create shared directory
|
|
@('commands', 'skills', 'agents') | ForEach-Object {
|
|
$Dir = Join-Path $SharedDir $_
|
|
New-Item -ItemType Directory -Path $Dir -Force | Out-Null
|
|
}
|
|
|
|
# Copy from ~/.claude/ (actual Claude CLI directory)
|
|
$ClaudeDir = "$env:USERPROFILE\.claude"
|
|
|
|
if (Test-Path $ClaudeDir) {
|
|
# Copy commands to shared (if exists)
|
|
$CommandsPath = Join-Path $ClaudeDir "commands"
|
|
if (Test-Path $CommandsPath) {
|
|
Copy-Item "$CommandsPath\*" -Destination "$SharedDir\commands\" -Recurse -ErrorAction SilentlyContinue
|
|
}
|
|
|
|
# Copy skills to shared (if exists)
|
|
$SkillsPath = Join-Path $ClaudeDir "skills"
|
|
if (Test-Path $SkillsPath) {
|
|
Copy-Item "$SkillsPath\*" -Destination "$SharedDir\skills\" -Recurse -ErrorAction SilentlyContinue
|
|
}
|
|
|
|
# Copy agents to shared (if exists)
|
|
$AgentsPath = Join-Path $ClaudeDir "agents"
|
|
if (Test-Path $AgentsPath) {
|
|
Copy-Item "$AgentsPath\*" -Destination "$SharedDir\agents\" -Recurse -ErrorAction SilentlyContinue
|
|
}
|
|
}
|
|
|
|
# Update all instances to use symlinks
|
|
if (Test-Path $InstancesDir) {
|
|
Get-ChildItem $InstancesDir -Directory | ForEach-Object {
|
|
Link-SharedDirectories $_.FullName
|
|
}
|
|
}
|
|
|
|
Write-Host "[OK] Migrated to shared structure"
|
|
}
|
|
|
|
function Copy-GlobalConfigs {
|
|
param([string]$InstancePath)
|
|
|
|
$GlobalClaude = "$env:USERPROFILE\.claude"
|
|
|
|
# Copy settings.json only (commands/skills are now symlinked to shared/)
|
|
$GlobalSettings = Join-Path $GlobalClaude "settings.json"
|
|
if (Test-Path $GlobalSettings) {
|
|
Copy-Item $GlobalSettings -Destination (Join-Path $InstancePath "settings.json") -ErrorAction SilentlyContinue
|
|
}
|
|
}
|
|
|
|
function Initialize-Instance {
|
|
param([string]$InstancePath)
|
|
|
|
# Create base directory with user-only ACL
|
|
New-Item -ItemType Directory -Path $InstancePath -Force | Out-Null
|
|
Set-InstancePermissions $InstancePath
|
|
|
|
# Create subdirectories (profile-specific only)
|
|
$Subdirs = @('session-env', 'todos', 'logs', 'file-history',
|
|
'shell-snapshots', 'debug', '.anthropic')
|
|
|
|
foreach ($Dir in $Subdirs) {
|
|
$DirPath = Join-Path $InstancePath $Dir
|
|
New-Item -ItemType Directory -Path $DirPath -Force | Out-Null
|
|
}
|
|
|
|
# Symlink shared directories
|
|
Link-SharedDirectories $InstancePath
|
|
|
|
# Copy global configs
|
|
Copy-GlobalConfigs $InstancePath
|
|
}
|
|
|
|
function Validate-Instance {
|
|
param([string]$InstancePath)
|
|
|
|
$RequiredDirs = @('session-env', 'todos', 'logs', 'file-history',
|
|
'shell-snapshots', 'debug', '.anthropic')
|
|
|
|
foreach ($Dir in $RequiredDirs) {
|
|
$DirPath = Join-Path $InstancePath $Dir
|
|
if (-not (Test-Path $DirPath)) {
|
|
New-Item -ItemType Directory -Path $DirPath -Force | Out-Null
|
|
}
|
|
}
|
|
}
|
|
|
|
function Ensure-Instance {
|
|
param([string]$ProfileName)
|
|
|
|
$SafeName = Get-SanitizedProfileName $ProfileName
|
|
$InstancePath = "$InstancesDir\$SafeName"
|
|
|
|
if (-not (Test-Path $InstancePath)) {
|
|
Initialize-Instance $InstancePath
|
|
}
|
|
|
|
Validate-Instance $InstancePath
|
|
|
|
return $InstancePath
|
|
}
|
|
|
|
# --- Profile Detection Logic (Phase 1) ---
|
|
|
|
function Get-AllProfileNames {
|
|
$names = @()
|
|
|
|
# Settings-based profiles
|
|
if (Test-Path $ConfigFile) {
|
|
try {
|
|
$Config = Get-Content $ConfigFile -Raw | ConvertFrom-Json
|
|
$names += $Config.profiles.PSObject.Properties.Name
|
|
} catch {}
|
|
}
|
|
|
|
# Account-based profiles
|
|
if (Test-Path $ProfilesJson) {
|
|
try {
|
|
$Profiles = Read-ProfilesJson
|
|
$names += $Profiles.profiles.PSObject.Properties.Name
|
|
} catch {}
|
|
}
|
|
|
|
return $names
|
|
}
|
|
|
|
function Get-AvailableProfiles {
|
|
$lines = @()
|
|
|
|
# Settings-based profiles
|
|
if (Test-Path $ConfigFile) {
|
|
try {
|
|
$Config = Get-Content $ConfigFile -Raw | ConvertFrom-Json
|
|
$SettingsProfiles = $Config.profiles.PSObject.Properties.Name
|
|
|
|
if ($SettingsProfiles.Count -gt 0) {
|
|
$lines += "Settings-based profiles (GLM, Kimi, etc.):"
|
|
foreach ($name in $SettingsProfiles) {
|
|
$lines += " - $name"
|
|
}
|
|
}
|
|
} catch {}
|
|
}
|
|
|
|
# Account-based profiles
|
|
if (Test-Path $ProfilesJson) {
|
|
try {
|
|
$Profiles = Read-ProfilesJson
|
|
$AccountProfiles = $Profiles.profiles.PSObject.Properties.Name
|
|
|
|
if ($AccountProfiles.Count -gt 0) {
|
|
$lines += "Account-based profiles:"
|
|
foreach ($name in $AccountProfiles) {
|
|
$IsDefault = if ($name -eq $Profiles.default) { " [DEFAULT]" } else { "" }
|
|
$lines += " - $name$IsDefault"
|
|
}
|
|
}
|
|
} catch {}
|
|
}
|
|
|
|
if ($lines.Count -eq 0) {
|
|
return " (no profiles configured)`n Run `"ccs auth create <profile>`" to create your first account profile."
|
|
} else {
|
|
return $lines -join "`n"
|
|
}
|
|
}
|
|
|
|
function Get-ProfileType {
|
|
param([string]$ProfileName)
|
|
|
|
# Special case: 'default' resolves to default profile
|
|
if ($ProfileName -eq "default") {
|
|
# Check account-based default first
|
|
if (Test-Path $ProfilesJson) {
|
|
$Profiles = Read-ProfilesJson
|
|
$DefaultAccount = $Profiles.default
|
|
|
|
if ($DefaultAccount -and (Test-ProfileExists $DefaultAccount)) {
|
|
return @{
|
|
Type = "account"
|
|
Name = $DefaultAccount
|
|
}
|
|
}
|
|
}
|
|
|
|
# Check settings-based default
|
|
if (Test-Path $ConfigFile) {
|
|
$Config = Get-Content $ConfigFile -Raw | ConvertFrom-Json
|
|
$DefaultSettings = $Config.profiles.default
|
|
|
|
if ($DefaultSettings) {
|
|
return @{
|
|
Type = "settings"
|
|
Path = $DefaultSettings
|
|
Name = "default"
|
|
}
|
|
}
|
|
}
|
|
|
|
# No default configured, use Claude's defaults
|
|
return @{
|
|
Type = "default"
|
|
Name = "default"
|
|
}
|
|
}
|
|
|
|
# Priority 1: Check settings-based profiles (backward compatibility)
|
|
if (Test-Path $ConfigFile) {
|
|
try {
|
|
$Config = Get-Content $ConfigFile -Raw | ConvertFrom-Json
|
|
$SettingsPath = $Config.profiles.$ProfileName
|
|
|
|
if ($SettingsPath) {
|
|
return @{
|
|
Type = "settings"
|
|
Path = $SettingsPath
|
|
Name = $ProfileName
|
|
}
|
|
}
|
|
} catch {}
|
|
}
|
|
|
|
# Priority 2: Check account-based profiles
|
|
if ((Test-Path $ProfilesJson) -and (Test-ProfileExists $ProfileName)) {
|
|
return @{
|
|
Type = "account"
|
|
Name = $ProfileName
|
|
}
|
|
}
|
|
|
|
# Not found
|
|
return @{
|
|
Type = "error"
|
|
}
|
|
}
|
|
|
|
# --- Auth Commands (Phase 3) ---
|
|
|
|
function Show-AuthHelp {
|
|
Write-Host ""
|
|
Write-Host "CCS Account Management" -ForegroundColor White
|
|
Write-Host ""
|
|
Write-Host "Usage:" -ForegroundColor Cyan
|
|
Write-Host " ccs auth <command> [options]" -ForegroundColor Yellow
|
|
Write-Host ""
|
|
Write-Host "Commands:" -ForegroundColor Cyan
|
|
Write-Host " create <profile> Create new profile and login" -ForegroundColor Yellow
|
|
Write-Host " list List all saved profiles" -ForegroundColor Yellow
|
|
Write-Host " show <profile> Show profile details" -ForegroundColor Yellow
|
|
Write-Host " remove <profile> Remove saved profile" -ForegroundColor Yellow
|
|
Write-Host " default <profile> Set default profile" -ForegroundColor Yellow
|
|
Write-Host ""
|
|
Write-Host "Examples:" -ForegroundColor Cyan
|
|
Write-Host " ccs auth create work # Create & login to work profile" -ForegroundColor Yellow
|
|
Write-Host " ccs auth default work # Set work as default" -ForegroundColor Yellow
|
|
Write-Host " ccs auth list # List all profiles" -ForegroundColor Yellow
|
|
Write-Host ' ccs work "review code" # Use work profile' -ForegroundColor Yellow
|
|
Write-Host ' ccs "review code" # Use default profile' -ForegroundColor Yellow
|
|
Write-Host ""
|
|
Write-Host "Options:" -ForegroundColor Cyan
|
|
Write-Host " --force Allow overwriting existing profile (create)" -ForegroundColor Yellow
|
|
Write-Host " --yes, -y Skip confirmation prompts (remove)" -ForegroundColor Yellow
|
|
Write-Host " --json Output in JSON format (list, show)" -ForegroundColor Yellow
|
|
Write-Host " --verbose Show additional details (list)" -ForegroundColor Yellow
|
|
Write-Host ""
|
|
Write-Host "Note:" -ForegroundColor Cyan
|
|
Write-Host " By default, " -NoNewline
|
|
Write-Host "ccs" -ForegroundColor Yellow -NoNewline
|
|
Write-Host " uses Claude CLI defaults from ~/.claude/"
|
|
Write-Host " Use " -NoNewline
|
|
Write-Host "ccs auth default <profile>" -ForegroundColor Yellow -NoNewline
|
|
Write-Host " to change the default profile."
|
|
Write-Host ""
|
|
}
|
|
|
|
function Invoke-AuthCreate {
|
|
param([string[]]$Args)
|
|
|
|
$ProfileName = ""
|
|
$Force = $false
|
|
|
|
foreach ($arg in $Args) {
|
|
if ($arg -eq "--force") {
|
|
$Force = $true
|
|
} elseif ($arg -match '^-') {
|
|
Write-ErrorMsg "Unknown option: $arg"
|
|
return 1
|
|
} else {
|
|
$ProfileName = $arg
|
|
}
|
|
}
|
|
|
|
if (-not $ProfileName) {
|
|
Write-ErrorMsg "Profile name is required`n`nUsage: ccs auth create <profile> [--force]"
|
|
return 1
|
|
}
|
|
|
|
if (-not $Force -and (Test-ProfileExists $ProfileName)) {
|
|
Write-ErrorMsg "Profile already exists: $ProfileName`nUse --force to overwrite"
|
|
return 1
|
|
}
|
|
|
|
# Create instance
|
|
Write-Host "[i] Creating profile: $ProfileName"
|
|
$InstancePath = Ensure-Instance $ProfileName
|
|
Write-Host "[i] Instance directory: $InstancePath"
|
|
Write-Host ""
|
|
|
|
# Register profile
|
|
Register-Profile $ProfileName
|
|
|
|
# Launch Claude for login
|
|
Write-Host "[i] Starting Claude in isolated instance..." -ForegroundColor Yellow
|
|
Write-Host "[i] You will be prompted to login with your account." -ForegroundColor Yellow
|
|
Write-Host ""
|
|
|
|
$env:CLAUDE_CONFIG_DIR = $InstancePath
|
|
$ClaudeCli = Find-ClaudeCli
|
|
& $ClaudeCli
|
|
|
|
if ($LASTEXITCODE -ne 0) {
|
|
Write-ErrorMsg "Login failed or cancelled`nTo retry: ccs auth create $ProfileName --force"
|
|
return 1
|
|
}
|
|
|
|
Write-Host ""
|
|
Write-Host "[OK] Profile created successfully" -ForegroundColor Green
|
|
Write-Host ""
|
|
Write-Host " Profile: $ProfileName"
|
|
Write-Host " Instance: $InstancePath"
|
|
Write-Host ""
|
|
Write-Host "Usage:"
|
|
Write-Host " ccs $ProfileName `"your prompt here`" # Use this specific profile" -ForegroundColor Yellow
|
|
Write-Host ""
|
|
Write-Host 'To set as default (so you can use just "ccs"):'
|
|
Write-Host " ccs auth default $ProfileName" -ForegroundColor Yellow
|
|
Write-Host ""
|
|
}
|
|
|
|
function Invoke-AuthList {
|
|
param([string[]]$Args)
|
|
|
|
$Verbose = $Args -contains "--verbose"
|
|
$Json = $Args -contains "--json"
|
|
|
|
if (-not (Test-Path $ProfilesJson)) {
|
|
if ($Json) {
|
|
Write-Output "{`"version`":`"$CcsVersion`",`"profiles`":[]}"
|
|
return
|
|
}
|
|
Write-Host "No account profiles found" -ForegroundColor Yellow
|
|
Write-Host ""
|
|
Write-Host "To create your first profile:"
|
|
Write-Host " ccs auth create <profile>" -ForegroundColor Yellow
|
|
return
|
|
}
|
|
|
|
$Data = Read-ProfilesJson
|
|
$Profiles = $Data.profiles.PSObject.Properties.Name
|
|
|
|
if ($Profiles.Count -eq 0) {
|
|
if ($Json) {
|
|
Write-Output "{`"version`":`"$CcsVersion`",`"profiles`":[]}"
|
|
return
|
|
}
|
|
Write-Host "No account profiles found" -ForegroundColor Yellow
|
|
return
|
|
}
|
|
|
|
# JSON output mode
|
|
if ($Json) {
|
|
$ProfilesList = @()
|
|
foreach ($profile in $Profiles) {
|
|
$IsDefault = $profile -eq $Data.default
|
|
$Type = $Data.profiles.$profile.type
|
|
if (-not $Type) { $Type = "account" }
|
|
$Created = $Data.profiles.$profile.created
|
|
$LastUsed = $Data.profiles.$profile.last_used
|
|
$InstancePath = "$InstancesDir\$(Get-SanitizedProfileName $profile)"
|
|
|
|
$ProfilesList += @{
|
|
name = $profile
|
|
type = $Type
|
|
is_default = $IsDefault
|
|
created = $Created
|
|
last_used = $LastUsed
|
|
instance_path = $InstancePath
|
|
}
|
|
}
|
|
|
|
$Output = @{
|
|
version = $CcsVersion
|
|
profiles = $ProfilesList
|
|
}
|
|
|
|
Write-Output ($Output | ConvertTo-Json -Depth 10)
|
|
return
|
|
}
|
|
|
|
# Human-readable output
|
|
Write-Host "Saved Account Profiles:" -ForegroundColor White
|
|
Write-Host ""
|
|
|
|
foreach ($profile in $Profiles) {
|
|
$IsDefault = $profile -eq $Data.default
|
|
|
|
if ($IsDefault) {
|
|
Write-Host "[*] " -ForegroundColor Green -NoNewline
|
|
Write-Host $profile -ForegroundColor Cyan -NoNewline
|
|
Write-Host " (default)" -ForegroundColor Green
|
|
} else {
|
|
Write-Host "[ ] " -NoNewline
|
|
Write-Host $profile -ForegroundColor Cyan
|
|
}
|
|
|
|
$Type = $Data.profiles.$profile.type
|
|
Write-Host " Type: $Type"
|
|
|
|
if ($Verbose) {
|
|
$Created = $Data.profiles.$profile.created
|
|
$LastUsed = $Data.profiles.$profile.last_used
|
|
if (-not $LastUsed) { $LastUsed = "Never" }
|
|
Write-Host " Created: $Created"
|
|
Write-Host " Last used: $LastUsed"
|
|
}
|
|
|
|
Write-Host ""
|
|
}
|
|
}
|
|
|
|
function Invoke-AuthShow {
|
|
param([string[]]$Args)
|
|
|
|
$ProfileName = ""
|
|
$Json = $false
|
|
|
|
# Parse arguments
|
|
foreach ($arg in $Args) {
|
|
if ($arg -eq "--json") {
|
|
$Json = $true
|
|
} elseif ($arg -like "-*") {
|
|
Write-ErrorMsg "Unknown option: $arg"
|
|
return 1
|
|
} else {
|
|
$ProfileName = $arg
|
|
}
|
|
}
|
|
|
|
if (-not $ProfileName) {
|
|
Write-ErrorMsg "Profile name is required"
|
|
Write-Host ""
|
|
Write-Host "Usage: " -NoNewline
|
|
Write-Host "ccs auth show <profile> [--json]" -ForegroundColor Yellow
|
|
return 1
|
|
}
|
|
|
|
if (-not (Test-ProfileExists $ProfileName)) {
|
|
Write-ErrorMsg "Profile not found: $ProfileName"
|
|
return 1
|
|
}
|
|
|
|
$Data = Read-ProfilesJson
|
|
$IsDefault = $ProfileName -eq $Data.default
|
|
|
|
$Type = $Data.profiles.$ProfileName.type
|
|
if (-not $Type) { $Type = "account" }
|
|
$Created = $Data.profiles.$ProfileName.created
|
|
$LastUsed = $Data.profiles.$ProfileName.last_used
|
|
$InstancePath = "$InstancesDir\$(Get-SanitizedProfileName $ProfileName)"
|
|
|
|
# Count sessions
|
|
$SessionCount = 0
|
|
if (Test-Path "$InstancePath\session-env") {
|
|
$SessionFiles = Get-ChildItem "$InstancePath\session-env" -Filter "*.json" -ErrorAction SilentlyContinue
|
|
$SessionCount = $SessionFiles.Count
|
|
}
|
|
|
|
# JSON output mode
|
|
if ($Json) {
|
|
$Output = @{
|
|
name = $ProfileName
|
|
type = $Type
|
|
is_default = $IsDefault
|
|
created = $Created
|
|
last_used = $LastUsed
|
|
instance_path = $InstancePath
|
|
session_count = $SessionCount
|
|
}
|
|
|
|
Write-Output ($Output | ConvertTo-Json -Depth 10)
|
|
return
|
|
}
|
|
|
|
# Human-readable output
|
|
Write-Host "Profile: $ProfileName" -ForegroundColor White
|
|
Write-Host ""
|
|
|
|
Write-Host " Type: $Type"
|
|
Write-Host " Default: $(if ($IsDefault) { 'Yes' } else { 'No' })"
|
|
Write-Host " Instance: $InstancePath"
|
|
Write-Host " Created: $Created"
|
|
if ($LastUsed) {
|
|
Write-Host " Last used: $LastUsed"
|
|
} else {
|
|
Write-Host " Last used: Never"
|
|
}
|
|
Write-Host ""
|
|
}
|
|
|
|
function Invoke-AuthRemove {
|
|
param([string[]]$Args)
|
|
|
|
$ProfileName = ""
|
|
|
|
# Parse arguments
|
|
foreach ($arg in $Args) {
|
|
if ($arg -eq "--yes" -or $arg -eq "-y") {
|
|
$env:CCS_YES = "1" # Auto-confirm
|
|
} elseif ($arg -like "-*") {
|
|
Write-ErrorMsg "Unknown option: $arg"
|
|
return 1
|
|
} else {
|
|
$ProfileName = $arg
|
|
}
|
|
}
|
|
|
|
if (-not $ProfileName) {
|
|
Write-ErrorMsg "Profile name is required"
|
|
Write-Host ""
|
|
Write-Host "Usage: " -NoNewline
|
|
Write-Host "ccs auth remove <profile> [--yes]" -ForegroundColor Yellow
|
|
return 1
|
|
}
|
|
|
|
if (-not (Test-ProfileExists $ProfileName)) {
|
|
Write-ErrorMsg "Profile not found: $ProfileName"
|
|
return 1
|
|
}
|
|
|
|
# Get instance path and session count for impact display
|
|
$InstancePath = "$InstancesDir\$(Get-SanitizedProfileName $ProfileName)"
|
|
$SessionCount = 0
|
|
|
|
if (Test-Path "$InstancePath\session-env") {
|
|
$SessionFiles = Get-ChildItem "$InstancePath\session-env" -Filter "*.json" -ErrorAction SilentlyContinue
|
|
$SessionCount = $SessionFiles.Count
|
|
}
|
|
|
|
# Display impact
|
|
Write-Host ""
|
|
Write-Host "Profile '" -NoNewline
|
|
Write-Host $ProfileName -ForegroundColor Cyan -NoNewline
|
|
Write-Host "' will be permanently deleted."
|
|
Write-Host " Instance path: $InstancePath"
|
|
Write-Host " Sessions: $SessionCount conversation$(if ($SessionCount -ne 1) { 's' } else { '' })"
|
|
Write-Host ""
|
|
|
|
# Interactive confirmation (or --yes flag)
|
|
$Confirmed = Confirm-Action "Delete this profile?" "No"
|
|
|
|
if (-not $Confirmed) {
|
|
Write-Host "[i] Cancelled"
|
|
return 0
|
|
}
|
|
|
|
# Delete instance directory
|
|
if (Test-Path $InstancePath) {
|
|
Remove-Item $InstancePath -Recurse -Force
|
|
}
|
|
|
|
# Remove from registry
|
|
Unregister-Profile $ProfileName
|
|
|
|
Write-Host "[OK] Profile removed successfully" -ForegroundColor Green
|
|
Write-Host " Profile: $ProfileName"
|
|
Write-Host ""
|
|
}
|
|
|
|
function Invoke-AuthDefault {
|
|
param([string[]]$Args)
|
|
|
|
$ProfileName = $Args[0]
|
|
|
|
if (-not $ProfileName) {
|
|
Write-ErrorMsg "Profile name is required`nUsage: ccs auth default <profile>"
|
|
return 1
|
|
}
|
|
|
|
if (-not (Test-ProfileExists $ProfileName)) {
|
|
Write-ErrorMsg "Profile not found: $ProfileName"
|
|
return 1
|
|
}
|
|
|
|
Set-DefaultProfile $ProfileName
|
|
|
|
Write-Host "[OK] Default profile set" -ForegroundColor Green
|
|
Write-Host " Profile: $ProfileName"
|
|
Write-Host ""
|
|
Write-Host "Now you can use:"
|
|
Write-Host " ccs `"your prompt`" # Uses $ProfileName profile" -ForegroundColor Yellow
|
|
Write-Host ""
|
|
}
|
|
|
|
function Invoke-AuthCommands {
|
|
param([string[]]$Args)
|
|
|
|
$Subcommand = $Args[0]
|
|
$SubArgs = if ($Args.Count -gt 1) { $Args[1..($Args.Count-1)] } else { @() }
|
|
|
|
switch ($Subcommand) {
|
|
"create" { Invoke-AuthCreate $SubArgs }
|
|
"list" { Invoke-AuthList $SubArgs }
|
|
"show" { Invoke-AuthShow $SubArgs }
|
|
"remove" { Invoke-AuthRemove $SubArgs }
|
|
"default" { Invoke-AuthDefault $SubArgs }
|
|
default { Show-AuthHelp }
|
|
}
|
|
}
|
|
|
|
function Install-ShellCompletion {
|
|
param([string[]]$Args)
|
|
|
|
Write-Host ""
|
|
Write-Host "Shell Completion Installer" -ForegroundColor Yellow
|
|
Write-Host ""
|
|
|
|
# Ensure completion directory exists
|
|
$CompletionsDir = Join-Path $env:USERPROFILE ".ccs\completions"
|
|
if (-not (Test-Path $CompletionsDir)) {
|
|
New-Item -ItemType Directory -Path $CompletionsDir -Force | Out-Null
|
|
}
|
|
|
|
# Ensure completion file exists
|
|
$CompletionFile = Join-Path $CompletionsDir "ccs.ps1"
|
|
if (-not (Test-Path $CompletionFile)) {
|
|
Write-Host "[X] Completion file not found. Please reinstall CCS." -ForegroundColor Red
|
|
Write-Host ""
|
|
exit 1
|
|
}
|
|
|
|
# Get PowerShell profile path
|
|
$ProfilePath = $PROFILE
|
|
$ProfileDir = Split-Path $ProfilePath -Parent
|
|
|
|
# Create profile directory if it doesn't exist
|
|
if (-not (Test-Path $ProfileDir)) {
|
|
New-Item -ItemType Directory -Path $ProfileDir -Force | Out-Null
|
|
}
|
|
|
|
# Comment marker for easy identification
|
|
$Marker = "# CCS shell completion"
|
|
$SourceCmd = ". `"$CompletionFile`""
|
|
|
|
# Check if already installed
|
|
if (Test-Path $ProfilePath) {
|
|
$Content = Get-Content $ProfilePath -Raw -ErrorAction SilentlyContinue
|
|
if ($Content -and $Content.Contains($Marker)) {
|
|
Write-Host "[OK] Shell completion already installed" -ForegroundColor Green
|
|
Write-Host ""
|
|
return 0
|
|
}
|
|
}
|
|
|
|
# Append to PowerShell profile
|
|
$Block = "`n$Marker`n$SourceCmd`n"
|
|
Add-Content -Path $ProfilePath -Value $Block -NoNewline
|
|
|
|
Write-Host "[OK] Shell completion installed successfully!" -ForegroundColor Green
|
|
Write-Host ""
|
|
Write-Host "Added to $ProfilePath"
|
|
Write-Host ""
|
|
Write-Host "To activate:" -ForegroundColor Cyan
|
|
Write-Host " . `$PROFILE"
|
|
Write-Host ""
|
|
|
|
return 0
|
|
}
|
|
|
|
# --- Main Execution Logic ---
|
|
|
|
# Special case: version command (check BEFORE profile detection)
|
|
if ($Version) {
|
|
Show-Version
|
|
exit 0
|
|
} elseif ($RemainingArgs.Count -gt 0) {
|
|
$FirstArg = $RemainingArgs[0]
|
|
if ($FirstArg -eq "version" -or $FirstArg -eq "--version" -or $FirstArg -eq "-v") {
|
|
Show-Version
|
|
exit 0
|
|
}
|
|
}
|
|
|
|
# Special case: help command (check BEFORE profile detection)
|
|
if ($Help) {
|
|
Show-Help
|
|
exit 0
|
|
} elseif ($RemainingArgs.Count -gt 0) {
|
|
$FirstArg = $RemainingArgs[0]
|
|
if ($FirstArg -eq "--help" -or $FirstArg -eq "-h" -or $FirstArg -eq "help") {
|
|
Show-Help
|
|
exit 0
|
|
}
|
|
}
|
|
|
|
# Special case: shell completion installer
|
|
if ($RemainingArgs.Count -gt 0 -and $RemainingArgs[0] -eq "--shell-completion") {
|
|
$CompletionArgs = if ($RemainingArgs.Count -gt 1) { $RemainingArgs[1..($RemainingArgs.Count-1)] } else { @() }
|
|
$Result = Install-ShellCompletion $CompletionArgs
|
|
exit $Result
|
|
}
|
|
|
|
# Special case: auth commands
|
|
if ($RemainingArgs.Count -gt 0 -and $RemainingArgs[0] -eq "auth") {
|
|
$AuthArgs = if ($RemainingArgs.Count -gt 1) { $RemainingArgs[1..($RemainingArgs.Count-1)] } else { @() }
|
|
Invoke-AuthCommands $AuthArgs
|
|
exit $LASTEXITCODE
|
|
}
|
|
|
|
# Run auto-recovery before main logic
|
|
if (-not (Invoke-AutoRecovery)) {
|
|
Write-ErrorMsg "Auto-recovery failed. Check permissions."
|
|
exit 1
|
|
}
|
|
|
|
# Smart profile detection: if first arg starts with '-', it's a flag not a profile
|
|
if ($RemainingArgs.Count -eq 0 -or $RemainingArgs[0] -match '^-') {
|
|
# No args or first arg is a flag → use default profile
|
|
$Profile = "default"
|
|
# $RemainingArgs already contains the remaining args correctly
|
|
} else {
|
|
# First arg is a profile name
|
|
$Profile = $RemainingArgs[0]
|
|
$RemainingArgs = if ($RemainingArgs.Count -gt 1) { $RemainingArgs | Select-Object -Skip 1 } else { @() }
|
|
}
|
|
|
|
# Validate profile name (alphanumeric, dash, underscore only)
|
|
if ($Profile -notmatch '^[a-zA-Z0-9_-]+$') {
|
|
$ErrorMessage = "Invalid profile name: $Profile" + "`n`n" +
|
|
"Use only alphanumeric characters, dash, or underscore."
|
|
|
|
Write-ErrorMsg $ErrorMessage
|
|
exit 1
|
|
}
|
|
|
|
# Detect profile type
|
|
$ProfileInfo = Get-ProfileType $Profile
|
|
|
|
if ($ProfileInfo.Type -eq "error") {
|
|
# Get suggestions using fuzzy matching
|
|
$AllProfiles = Get-AllProfileNames
|
|
$Suggestions = Find-SimilarStrings -Target $Profile -Candidates $AllProfiles
|
|
|
|
Write-Host ""
|
|
Write-Host "[X] Profile '$Profile' not found" -ForegroundColor Red
|
|
Write-Host ""
|
|
|
|
# Show suggestions if any
|
|
if ($Suggestions -and $Suggestions.Count -gt 0) {
|
|
Write-Host "Did you mean:" -ForegroundColor Yellow
|
|
foreach ($suggestion in $Suggestions) {
|
|
Write-Host " $suggestion"
|
|
}
|
|
Write-Host ""
|
|
}
|
|
|
|
Write-Host "Available profiles:" -ForegroundColor Cyan
|
|
Get-AvailableProfiles | ForEach-Object { Write-Host $_ }
|
|
Write-Host ""
|
|
Write-Host "Solutions:" -ForegroundColor Yellow
|
|
Write-Host " # Use existing profile"
|
|
Write-Host " ccs <profile> `"your prompt`""
|
|
Write-Host ""
|
|
Write-Host " # Create new account profile"
|
|
Write-Host " ccs auth create <name>"
|
|
Write-Host ""
|
|
Write-Host "Error: $script:E_PROFILE_NOT_FOUND" -ForegroundColor Yellow
|
|
Write-Host (Get-ErrorDocUrl $script:E_PROFILE_NOT_FOUND) -ForegroundColor Yellow
|
|
Write-Host ""
|
|
exit 1
|
|
}
|
|
|
|
# Detect Claude CLI executable
|
|
$ClaudeCli = Find-ClaudeCli
|
|
|
|
# Execute based on profile type (Phase 5)
|
|
switch ($ProfileInfo.Type) {
|
|
"account" {
|
|
# Account-based profile: use CLAUDE_CONFIG_DIR
|
|
$InstancePath = Ensure-Instance $ProfileInfo.Name
|
|
Update-ProfileTimestamp $ProfileInfo.Name # Update last_used
|
|
|
|
# Execute Claude with isolated config
|
|
$env:CLAUDE_CONFIG_DIR = $InstancePath
|
|
|
|
try {
|
|
if ($RemainingArgs) {
|
|
& $ClaudeCli @RemainingArgs
|
|
} else {
|
|
& $ClaudeCli
|
|
}
|
|
exit $LASTEXITCODE
|
|
} catch {
|
|
Show-ClaudeNotFoundError
|
|
exit 1
|
|
}
|
|
}
|
|
|
|
"settings" {
|
|
# Settings-based profile: use --settings flag
|
|
$SettingsPath = $ProfileInfo.Path
|
|
|
|
# Path expansion and normalization
|
|
if ($SettingsPath -match '^~[/\\]') {
|
|
$SettingsPath = $SettingsPath -replace '^~', $env:USERPROFILE
|
|
}
|
|
$SettingsPath = [System.Environment]::ExpandEnvironmentVariables($SettingsPath)
|
|
$SettingsPath = $SettingsPath -replace '/', '\'
|
|
|
|
if (-not (Test-Path $SettingsPath)) {
|
|
Write-ErrorMsg "Settings file not found: $SettingsPath"
|
|
exit 1
|
|
}
|
|
|
|
try {
|
|
if ($RemainingArgs) {
|
|
& $ClaudeCli --settings $SettingsPath @RemainingArgs
|
|
} else {
|
|
& $ClaudeCli --settings $SettingsPath
|
|
}
|
|
exit $LASTEXITCODE
|
|
} catch {
|
|
Show-ClaudeNotFoundError
|
|
exit 1
|
|
}
|
|
}
|
|
|
|
"default" {
|
|
# Default: no special handling
|
|
try {
|
|
if ($RemainingArgs) {
|
|
& $ClaudeCli @RemainingArgs
|
|
} else {
|
|
& $ClaudeCli
|
|
}
|
|
exit $LASTEXITCODE
|
|
} catch {
|
|
Show-ClaudeNotFoundError
|
|
exit 1
|
|
}
|
|
}
|
|
|
|
default {
|
|
Write-ErrorMsg "Unknown profile type: $($ProfileInfo.Type)"
|
|
exit 1
|
|
}
|
|
}
|