feat(shared): implement shared data architecture (v3.1.0)

Phase 1: Multi-profile shared data via symlinks

Added:

- SharedManager class for symlink orchestration (bin/shared-manager.js)

- Auto-migration from ~/.claude/ to ~/.ccs/shared/ on first run

- Shared directories: commands/, skills/, agents/

- Windows fallback: copies dirs if symlinks fail

Fixed:

- Migration logic now detects empty directories

- Previously skipped migration when postinstall created empty dirs

- Now properly copies from ~/.claude/ when shared dirs are empty

Changed:

- Instance initialization symlinks to shared dirs instead of copying

- Postinstall creates ~/.ccs/shared/ structure automatically

- All implementations (Node.js, bash, PowerShell) updated for consistency

- Help text includes agents/ in shared data section

Technical:

- Profile-specific data remains isolated (settings, sessions, todolists, logs)

- Migration is idempotent: safe to run multiple times

- Cross-platform symlink support with graceful fallback

Closes #4
This commit is contained in:
kaitranntt committed 2025-11-10 20:45:21 -05:00
1 parent df549ddf4b
commit d925dfc169
15 files changed
+929 -708

No files matched your search

+86 -15
View File
@@ -2,7 +2,7 @@
set -euo pipefail
# Version (updated by scripts/bump-version.sh)
CCS_VERSION="3.0.2"
CCS_VERSION="3.1.0"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
readonly CONFIG_FILE="${CCS_CONFIG:-$HOME/.ccs/config.json}"
readonly PROFILES_JSON="$HOME/.ccs/profiles.json"
@@ -67,9 +67,15 @@ show_help() {
echo -e " ${YELLOW}-v, --version${RESET} Show version and installation info"
echo ""
echo -e "${CYAN}Configuration:${RESET}"
echo -e " Config: ~/.ccs/config.json"
echo -e " Profiles: ~/.ccs/profiles.json"
echo -e " Settings: ~/.ccs/*.settings.json"
echo -e " Config: ~/.ccs/config.json"
echo -e " Profiles: ~/.ccs/profiles.json"
echo -e " Instances: ~/.ccs/instances/"
echo -e " Settings: ~/.ccs/*.settings.json"
echo ""
echo -e "${CYAN}Shared Data:${RESET}"
echo -e " Commands: ~/.ccs/shared/commands/"
echo -e " Skills: ~/.ccs/shared/skills/"
echo -e " Note: Commands, skills, and agents are symlinked across all profiles"
echo ""
echo -e "${CYAN}Documentation:${RESET}"
echo -e " GitHub: ${CYAN}https://github.com/kaitranntt/ccs${RESET}"
@@ -493,6 +499,73 @@ sanitize_profile_name() {
echo "$name" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9_-]/-/g'
}
# Link shared directories (Phase 1: Shared Global Data)
link_shared_directories() {
local instance_path="$1"
local shared_dir="$HOME/.ccs/shared"
# Ensure shared directories exist
mkdir -p "$shared_dir"/{commands,skills,agents}
# Create symlinks (remove existing first if present)
for dir in commands skills agents; do
local link_path="$instance_path/$dir"
local target_path="$shared_dir/$dir"
# Remove existing directory/link
[[ -e "$link_path" ]] && rm -rf "$link_path"
# Create symlink
ln -sf "$target_path" "$link_path"
done
}
# Migrate to shared structure (Phase 1: Auto-migration)
migrate_to_shared_structure() {
local shared_dir="$HOME/.ccs/shared"
# Check if migration is needed (shared dirs exist but are empty)
if [[ -d "$shared_dir" ]]; then
local needs_migration=false
for dir in commands skills agents; do
if [[ ! -d "$shared_dir/$dir" ]] || [[ -z "$(ls -A "$shared_dir/$dir" 2>/dev/null)" ]]; then
needs_migration=true
break
fi
done
[[ "$needs_migration" == "false" ]] && return 0
fi
# Create shared directory
mkdir -p "$shared_dir"/{commands,skills,agents}
# Copy from ~/.claude/ (actual Claude CLI directory)
local claude_dir="$HOME/.claude"
if [[ -d "$claude_dir" ]]; then
# Copy commands to shared (if exists)
[[ -d "$claude_dir/commands" ]] && \
cp -r "$claude_dir/commands"/* "$shared_dir/commands/" 2>/dev/null || true
# Copy skills to shared (if exists)
[[ -d "$claude_dir/skills" ]] && \
cp -r "$claude_dir/skills"/* "$shared_dir/skills/" 2>/dev/null || true
# Copy agents to shared (if exists)
[[ -d "$claude_dir/agents" ]] && \
cp -r "$claude_dir/agents"/* "$shared_dir/agents/" 2>/dev/null || true
fi
# Update all instances to use symlinks
for instance_path in "$INSTANCES_DIR"/*; do
[[ -d "$instance_path" ]] || continue
link_shared_directories "$instance_path"
done
echo "[OK] Migrated to shared structure"
}
# Initialize new instance directory
initialize_instance() {
local instance_path="$1"
@@ -500,12 +573,15 @@ initialize_instance() {
# Create base directory
mkdir -m 0700 -p "$instance_path"
# Create subdirectories
local subdirs=(session-env todos logs file-history shell-snapshots debug .anthropic commands skills)
# Create subdirectories (profile-specific only)
local subdirs=(session-env todos logs file-history shell-snapshots debug .anthropic)
for dir in "${subdirs[@]}"; do
mkdir -m 0700 -p "$instance_path/$dir"
done
# Symlink shared directories
link_shared_directories "$instance_path"
# Copy global configs (optional)
copy_global_configs "$instance_path"
}
@@ -527,17 +603,9 @@ copy_global_configs() {
local instance_path="$1"
local global_claude="$HOME/.claude"
# Copy settings.json
# Copy settings.json only (commands/skills are now symlinked to shared/)
[[ -f "$global_claude/settings.json" ]] && \
cp "$global_claude/settings.json" "$instance_path/settings.json" 2>/dev/null || true
# Copy commands/
[[ -d "$global_claude/commands" ]] && \
cp -r "$global_claude/commands" "$instance_path/" 2>/dev/null || true
# Copy skills/
[[ -d "$global_claude/skills" ]] && \
cp -r "$global_claude/skills" "$instance_path/" 2>/dev/null || true
}
# Ensure instance exists (lazy initialization)
@@ -942,6 +1010,9 @@ auto_recover || {
exit 1
}
# Run migration to shared structure (Phase 1: idempotent)
migrate_to_shared_structure
# Smart profile detection: if first arg starts with '-', it's a flag not a profile
if [[ $# -eq 0 ]] || [[ "${1}" =~ ^- ]]; then
# No args or first arg is a flag → use default profile
+109 -19
View File
@@ -12,7 +12,7 @@ param(
$ErrorActionPreference = "Stop"
# Version (updated by scripts/bump-version.sh)
$CcsVersion = "3.0.2"
$CcsVersion = "3.1.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"
@@ -123,9 +123,15 @@ function Show-Help {
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 " Settings: ~/.ccs/*.settings.json"
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"
@@ -422,28 +428,106 @@ function Set-InstancePermissions {
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
# 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
}
# 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 {
@@ -453,15 +537,18 @@ function Initialize-Instance {
New-Item -ItemType Directory -Path $InstancePath -Force | Out-Null
Set-InstancePermissions $InstancePath
# Create subdirectories
# Create subdirectories (profile-specific only)
$Subdirs = @('session-env', 'todos', 'logs', 'file-history',
'shell-snapshots', 'debug', '.anthropic', 'commands', 'skills')
'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
}
@@ -913,6 +1000,9 @@ if (-not (Invoke-AutoRecovery)) {
exit 1
}
# Run migration to shared structure (Phase 1: idempotent)
Migrate-SharedStructure
# 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