diff --git a/CHANGELOG.md b/CHANGELOG.md index 8165dbd5..6da01f9e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,24 @@ Format: [Keep a Changelog](https://keepachangelog.com/) +## [4.4.0] - 2025-11-23 + +### Changed +- **BREAKING**: settings.json now shared across profiles via symlinks + - Each profile previously had isolated settings.json + - Now all profiles share ~/.claude/settings.json + - Migration automatic on install (uses ~/.claude/settings.json) + - Backups created: `/settings.json.pre-shared-migration` + - Rollback: restore backup manually if needed + +### Added +- Doctor validates settings.json symlink integrity +- Sync repairs broken settings.json symlinks +- Migration from isolated to shared settings (automatic) + +### Fixed +- Consistent shared data architecture across all .claude/ items + ## [4.3.10] - 2025-11-23 ### Fixed diff --git a/README.md b/README.md index 1285a577..a87bdd69 100644 --- a/README.md +++ b/README.md @@ -313,43 +313,21 @@ graph LR - Uses `CLAUDE_CONFIG_DIR` for isolated instances - Create with `ccs auth create ` -### Shared Data (v3.1+) +### Shared Data (v4.4+) -**CCS items (v4.1)**: Commands and skills symlinked from `~/.ccs/.claude/` to `~/.claude/` - **single source of truth with auto-propagation**. +**Shared across instances** (`~/.ccs/shared/` symlinked to `~/.claude/`): +- commands/ - Slash commands +- skills/ - Agent skills +- agents/ - Agent configs +- **settings.json** - Claude CLI settings (v4.4+) -**Profile access**: `~/.ccs/shared/` symlinks to `~/.claude/` - **no duplication across profiles**. +**Profile-specific**: +- sessions/ - Conversation history +- todolists/ - Todo lists +- logs/ - Execution logs -```plaintext -~/.ccs/ -├── .claude/ # CCS items (ships with package, v4.1) -│ ├── commands/ccs/ # Delegation commands (/ccs, /ccs:continue) -│ └── skills/ccs-delegation/ # AI decision framework (replaces deprecated agents) -├── shared/ # Symlinks to ~/.claude/ (for profiles) -│ ├── agents@ → ~/.claude/agents/ -│ ├── commands@ → ~/.claude/commands/ -│ └── skills@ → ~/.claude/skills/ -├── instances/ # Profile-specific data -│ └── work/ -│ ├── agents@ → shared/agents/ -│ ├── commands@ → shared/commands/ -│ ├── skills@ → shared/skills/ -│ ├── settings.json # API keys, credentials -│ ├── sessions/ # Conversation history -│ └── ... - -~/.claude/ # User's Claude directory -├── commands/ccs@ → ~/.ccs/.claude/commands/ccs/ # Selective symlink -├── skills/ccs-delegation@ → ~/.ccs/.claude/skills/ccs-delegation/ -# agents/ccs-delegator.md@ → ~/.ccs/.claude/agents/ccs-delegator.md # Deprecated in v4.3.2 -``` - -**Symlink Chain**: `work profile → ~/.ccs/shared/ → ~/.claude/ → ~/.ccs/.claude/` (CCS items) - -| Type | Files | -|:-----|:------| -| **CCS items** | `~/.ccs/.claude/` (ships with package, selective symlinks to `~/.claude/`) | -| **Shared** | `~/.ccs/shared/` (symlinks to `~/.claude/`) | -| **Profile-specific** | `settings.json`, `sessions/`, `todolists/`, `logs/` | +> [!NOTE] +> **v4.4 Breaking Change**: settings.json now shared across profiles. Previously each profile had isolated settings. Migration is automatic on install using ~/.claude/settings.json as the authoritative source. Backups created: `/settings.json.pre-shared-migration` > [!NOTE] > **Windows**: Symlink support requires Developer Mode (v4.2 will add copy fallback) diff --git a/VERSION b/VERSION index 5d72fe47..fdc66988 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -4.3.10 +4.4.0 diff --git a/bin/management/doctor.js b/bin/management/doctor.js index 1f1647b0..bec8d057 100644 --- a/bin/management/doctor.js +++ b/bin/management/doctor.js @@ -117,6 +117,7 @@ class Doctor { console.log(colored('System Health:', 'bold')); this.checkPermissions(); this.checkCcsSymlinks(); + this.checkSettingsSymlinks(); console.log(''); this.showReport(); @@ -502,6 +503,127 @@ class Doctor { } } + /** + * Check 10: settings.json symlinks + */ + checkSettingsSymlinks() { + const spinner = ora('Checking settings.json symlinks').start(); + + try { + const sharedDir = path.join(this.homedir, '.ccs', 'shared'); + const sharedSettings = path.join(sharedDir, 'settings.json'); + const claudeSettings = path.join(this.claudeDir, 'settings.json'); + + // Check shared settings exists and points to ~/.claude/ + if (!fs.existsSync(sharedSettings)) { + spinner.warn(` ${'settings.json (shared)'.padEnd(26)}${colored('[!]', 'yellow')} Not found`); + this.results.addCheck( + 'Settings Symlinks', + 'warning', + 'Shared settings.json not found', + 'Run: ccs sync' + ); + return; + } + + const sharedStats = fs.lstatSync(sharedSettings); + if (!sharedStats.isSymbolicLink()) { + spinner.warn(` ${'settings.json (shared)'.padEnd(26)}${colored('[!]', 'yellow')} Not a symlink`); + this.results.addCheck( + 'Settings Symlinks', + 'warning', + 'Shared settings.json is not a symlink', + 'Run: ccs sync' + ); + return; + } + + const sharedTarget = fs.readlinkSync(sharedSettings); + const resolvedShared = path.resolve(path.dirname(sharedSettings), sharedTarget); + + if (resolvedShared !== claudeSettings) { + spinner.warn(` ${'settings.json (shared)'.padEnd(26)}${colored('[!]', 'yellow')} Wrong target`); + this.results.addCheck( + 'Settings Symlinks', + 'warning', + `Points to ${resolvedShared} instead of ${claudeSettings}`, + 'Run: ccs sync' + ); + return; + } + + // Check each instance + const instancesDir = path.join(this.ccsDir, 'instances'); + if (!fs.existsSync(instancesDir)) { + spinner.succeed(` ${'settings.json'.padEnd(26)}${colored('[OK]', 'green')} Shared symlink valid`); + this.results.addCheck('Settings Symlinks', 'success', 'Shared symlink valid', null, { + status: 'OK', + info: 'Shared symlink valid' + }); + return; + } + + const instances = fs.readdirSync(instancesDir).filter(name => { + return fs.statSync(path.join(instancesDir, name)).isDirectory(); + }); + + let broken = 0; + for (const instance of instances) { + const instancePath = path.join(instancesDir, instance); + const instanceSettings = path.join(instancePath, 'settings.json'); + + if (!fs.existsSync(instanceSettings)) { + broken++; + continue; + } + + try { + const stats = fs.lstatSync(instanceSettings); + if (!stats.isSymbolicLink()) { + broken++; + continue; + } + + const target = fs.readlinkSync(instanceSettings); + const resolved = path.resolve(path.dirname(instanceSettings), target); + + if (resolved !== sharedSettings) { + broken++; + } + } catch (err) { + broken++; + } + } + + if (broken > 0) { + spinner.warn(` ${'settings.json'.padEnd(26)}${colored('[!]', 'yellow')} ${broken} broken instance(s)`); + this.results.addCheck( + 'Settings Symlinks', + 'warning', + `${broken} instance(s) have broken symlinks`, + 'Run: ccs sync', + { status: 'WARN', info: `${broken} broken instance(s)` } + ); + } else { + spinner.succeed(` ${'settings.json'.padEnd(26)}${colored('[OK]', 'green')} ${instances.length} instance(s) valid`); + this.results.addCheck('Settings Symlinks', 'success', 'All instance symlinks valid', null, { + status: 'OK', + info: `${instances.length} instance(s) valid` + }); + } + + } catch (err) { + spinner.warn(` ${'settings.json'.padEnd(26)}${colored('[!]', 'yellow')} Check failed`); + this.results.addCheck( + 'Settings Symlinks', + 'warning', + `Failed to check: ${err.message}`, + 'Run: ccs sync', + { status: 'WARN', info: 'Check failed' } + ); + } + } + /** * Show health check report */ diff --git a/bin/management/instance-manager.js b/bin/management/instance-manager.js index d317b0c3..f2b57d58 100644 --- a/bin/management/instance-manager.js +++ b/bin/management/instance-manager.js @@ -159,14 +159,9 @@ class InstanceManager { * @param {string} instancePath - Instance path */ _copyGlobalConfigs(instancePath) { - const globalConfigDir = path.join(os.homedir(), '.claude'); - - // Copy settings.json only (commands/skills are now symlinked to shared/) - const globalSettings = path.join(globalConfigDir, 'settings.json'); - if (fs.existsSync(globalSettings)) { - const instanceSettings = path.join(instancePath, 'settings.json'); - fs.copyFileSync(globalSettings, instanceSettings); - } + // No longer needed - settings.json now symlinked via SharedManager + // Keeping method for backward compatibility (empty implementation) + // Can be removed in future major version } /** diff --git a/bin/management/shared-manager.js b/bin/management/shared-manager.js index 2e5a26d3..b5481cb3 100644 --- a/bin/management/shared-manager.js +++ b/bin/management/shared-manager.js @@ -17,7 +17,13 @@ class SharedManager { this.sharedDir = path.join(this.homeDir, '.ccs', 'shared'); this.claudeDir = path.join(this.homeDir, '.claude'); this.instancesDir = path.join(this.homeDir, '.ccs', 'instances'); - this.sharedDirs = ['commands', 'skills', 'agents', 'plugins']; + this.sharedItems = [ + { name: 'commands', type: 'directory' }, + { name: 'skills', type: 'directory' }, + { name: 'agents', type: 'directory' }, + { name: 'plugins', type: 'directory' }, + { name: 'settings.json', type: 'file' } + ]; } /** @@ -74,18 +80,23 @@ class SharedManager { } // Create symlinks ~/.ccs/shared/* → ~/.claude/* - for (const dir of this.sharedDirs) { - const claudePath = path.join(this.claudeDir, dir); - const sharedPath = path.join(this.sharedDir, dir); + for (const item of this.sharedItems) { + const claudePath = path.join(this.claudeDir, item.name); + const sharedPath = path.join(this.sharedDir, item.name); - // Create directory in ~/.claude/ if missing + // Create in ~/.claude/ if missing if (!fs.existsSync(claudePath)) { - fs.mkdirSync(claudePath, { recursive: true, mode: 0o700 }); + if (item.type === 'directory') { + fs.mkdirSync(claudePath, { recursive: true, mode: 0o700 }); + } else if (item.type === 'file') { + // Create empty settings.json if missing + fs.writeFileSync(claudePath, JSON.stringify({}, null, 2), 'utf8'); + } } // Check for circular symlink if (this._detectCircularSymlink(claudePath, sharedPath)) { - console.log(`[!] Skipping ${dir}: circular symlink detected`); + console.log(`[!] Skipping ${item.name}: circular symlink detected`); continue; } @@ -104,18 +115,27 @@ class SharedManager { // Continue to recreate } - // Remove existing directory/link - fs.rmSync(sharedPath, { recursive: true, force: true }); + // Remove existing file/directory/link + if (item.type === 'directory') { + fs.rmSync(sharedPath, { recursive: true, force: true }); + } else { + fs.unlinkSync(sharedPath); + } } // Create symlink try { - fs.symlinkSync(claudePath, sharedPath, 'dir'); + const symlinkType = item.type === 'directory' ? 'dir' : 'file'; + fs.symlinkSync(claudePath, sharedPath, symlinkType); } catch (err) { - // Windows fallback: copy directory + // Windows fallback: copy if (process.platform === 'win32') { - this._copyDirectoryFallback(claudePath, sharedPath); - console.log(`[!] Symlink failed for ${dir}, copied instead (enable Developer Mode)`); + if (item.type === 'directory') { + this._copyDirectoryFallback(claudePath, sharedPath); + } else if (item.type === 'file') { + fs.copyFileSync(claudePath, sharedPath); + } + console.log(`[!] Symlink failed for ${item.name}, copied instead (enable Developer Mode)`); } else { throw err; } @@ -130,23 +150,32 @@ class SharedManager { linkSharedDirectories(instancePath) { this.ensureSharedDirectories(); - for (const dir of this.sharedDirs) { - const linkPath = path.join(instancePath, dir); - const targetPath = path.join(this.sharedDir, dir); + for (const item of this.sharedItems) { + const linkPath = path.join(instancePath, item.name); + const targetPath = path.join(this.sharedDir, item.name); - // Remove existing directory/link + // Remove existing file/directory/link if (fs.existsSync(linkPath)) { - fs.rmSync(linkPath, { recursive: true, force: true }); + if (item.type === 'directory') { + fs.rmSync(linkPath, { recursive: true, force: true }); + } else { + fs.unlinkSync(linkPath); + } } // Create symlink try { - fs.symlinkSync(targetPath, linkPath, 'dir'); + const symlinkType = item.type === 'directory' ? 'dir' : 'file'; + fs.symlinkSync(targetPath, linkPath, symlinkType); } catch (err) { // Windows fallback if (process.platform === 'win32') { - this._copyDirectoryFallback(targetPath, linkPath); - console.log(`[!] Symlink failed for ${dir}, copied instead (enable Developer Mode)`); + if (item.type === 'directory') { + this._copyDirectoryFallback(targetPath, linkPath); + } else if (item.type === 'file') { + fs.copyFileSync(targetPath, linkPath); + } + console.log(`[!] Symlink failed for ${item.name}, copied instead (enable Developer Mode)`); } else { throw err; } @@ -179,49 +208,56 @@ class SharedManager { } // Copy user modifications from ~/.ccs/shared/ to ~/.claude/ - for (const dir of this.sharedDirs) { - const sharedPath = path.join(this.sharedDir, dir); - const claudePath = path.join(this.claudeDir, dir); + for (const item of this.sharedItems) { + const sharedPath = path.join(this.sharedDir, item.name); + const claudePath = path.join(this.claudeDir, item.name); if (!fs.existsSync(sharedPath)) continue; try { const stats = fs.lstatSync(sharedPath); - if (!stats.isDirectory()) continue; - } catch (err) { - continue; - } - // Create claude dir if missing - if (!fs.existsSync(claudePath)) { - fs.mkdirSync(claudePath, { recursive: true, mode: 0o700 }); - } + // Handle directories + if (item.type === 'directory' && stats.isDirectory()) { + // Create claude dir if missing + if (!fs.existsSync(claudePath)) { + fs.mkdirSync(claudePath, { recursive: true, mode: 0o700 }); + } - // Copy files from shared to claude (preserve user modifications) - try { - const entries = fs.readdirSync(sharedPath, { withFileTypes: true }); - let copied = 0; + // Copy files from shared to claude (preserve user modifications) + const entries = fs.readdirSync(sharedPath, { withFileTypes: true }); + let copied = 0; - for (const entry of entries) { - const src = path.join(sharedPath, entry.name); - const dest = path.join(claudePath, entry.name); + for (const entry of entries) { + const src = path.join(sharedPath, entry.name); + const dest = path.join(claudePath, entry.name); - // Skip if already exists in claude - if (fs.existsSync(dest)) continue; + // Skip if already exists in claude + if (fs.existsSync(dest)) continue; - if (entry.isDirectory()) { - fs.cpSync(src, dest, { recursive: true }); - } else { - fs.copyFileSync(src, dest); + if (entry.isDirectory()) { + fs.cpSync(src, dest, { recursive: true }); + } else { + fs.copyFileSync(src, dest); + } + copied++; + } + + if (copied > 0) { + console.log(`[OK] Migrated ${copied} ${item.name} to ~/.claude/${item.name}`); } - copied++; } - if (copied > 0) { - console.log(`[OK] Migrated ${copied} ${dir} to ~/.claude/${dir}`); + // Handle files (settings.json) + else if (item.type === 'file' && stats.isFile()) { + // Only copy if ~/.claude/ version doesn't exist + if (!fs.existsSync(claudePath)) { + fs.copyFileSync(sharedPath, claudePath); + console.log(`[OK] Migrated ${item.name} to ~/.claude/${item.name}`); + } } } catch (err) { - console.log(`[!] Failed to migrate ${dir}: ${err.message}`); + console.log(`[!] Failed to migrate ${item.name}: ${err.message}`); } } @@ -251,6 +287,87 @@ class SharedManager { console.log('[OK] Migration to v3.2.0 complete'); } + /** + * Migrate existing instances from isolated to shared settings.json (v4.4+) + * Runs once on upgrade + */ + migrateToSharedSettings() { + console.log('[i] Migrating instances to shared settings.json...'); + + // Ensure ~/.claude/settings.json exists (authoritative source) + const claudeSettings = path.join(this.claudeDir, 'settings.json'); + if (!fs.existsSync(claudeSettings)) { + // Create empty settings if missing + fs.writeFileSync(claudeSettings, JSON.stringify({}, null, 2), 'utf8'); + console.log('[i] Created ~/.claude/settings.json'); + } + + // Ensure shared settings.json symlink exists + this.ensureSharedDirectories(); + + // Migrate each instance + if (!fs.existsSync(this.instancesDir)) { + console.log('[i] No instances to migrate'); + return; + } + + const instances = fs.readdirSync(this.instancesDir).filter(name => { + const instancePath = path.join(this.instancesDir, name); + return fs.statSync(instancePath).isDirectory(); + }); + + let migrated = 0; + let skipped = 0; + + for (const instance of instances) { + const instancePath = path.join(this.instancesDir, instance); + const instanceSettings = path.join(instancePath, 'settings.json'); + + try { + // Check if already symlink + if (fs.existsSync(instanceSettings)) { + const stats = fs.lstatSync(instanceSettings); + if (stats.isSymbolicLink()) { + skipped++; + continue; // Already migrated + } + + // Backup existing settings + const backup = instanceSettings + '.pre-shared-migration'; + if (!fs.existsSync(backup)) { + fs.copyFileSync(instanceSettings, backup); + console.log(`[i] Backed up ${instance}/settings.json`); + } + + // Remove old settings.json + fs.unlinkSync(instanceSettings); + } + + // Create symlink via SharedManager + const sharedSettings = path.join(this.sharedDir, 'settings.json'); + + try { + fs.symlinkSync(sharedSettings, instanceSettings, 'file'); + migrated++; + } catch (err) { + // Windows fallback + if (process.platform === 'win32') { + fs.copyFileSync(sharedSettings, instanceSettings); + console.log(`[!] Symlink failed for ${instance}, copied instead`); + migrated++; + } else { + throw err; + } + } + + } catch (err) { + console.log(`[!] Failed to migrate ${instance}: ${err.message}`); + } + } + + console.log(`[OK] Migrated ${migrated} instance(s), skipped ${skipped}`); + } + /** * Copy directory as fallback (Windows without Developer Mode) * @param {string} src - Source directory diff --git a/installers/install.ps1 b/installers/install.ps1 index 53754de4..0d0c9fb6 100644 --- a/installers/install.ps1 +++ b/installers/install.ps1 @@ -31,7 +31,7 @@ $InstallMethod = if ($ScriptDir -and ((Test-Path "$ScriptDir\lib\ccs.ps1") -or ( # IMPORTANT: Update this version when releasing new versions! # This hardcoded version is used for standalone installations (irm | iex) # For git installations, VERSION file is read if available -$CcsVersion = "4.3.10" +$CcsVersion = "4.4.0" # Try to read VERSION file for git installations if ($ScriptDir) { diff --git a/installers/install.sh b/installers/install.sh index 49154b5f..e6c79d01 100755 --- a/installers/install.sh +++ b/installers/install.sh @@ -32,7 +32,7 @@ fi # IMPORTANT: Update this version when releasing new versions! # This hardcoded version is used for standalone installations (curl | bash) # For git installations, VERSION file is read if available -CCS_VERSION="4.3.10" +CCS_VERSION="4.4.0" # Try to read VERSION file for git installations if [[ -f "$SCRIPT_DIR/VERSION" ]]; then diff --git a/lib/ccs b/lib/ccs index 89688c87..09344335 100755 --- a/lib/ccs +++ b/lib/ccs @@ -2,7 +2,7 @@ set -euo pipefail # Version (updated by scripts/bump-version.sh) -CCS_VERSION="4.3.10" +CCS_VERSION="4.4.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" diff --git a/lib/ccs.ps1 b/lib/ccs.ps1 index 1bcbc19b..90eee1fe 100644 --- a/lib/ccs.ps1 +++ b/lib/ccs.ps1 @@ -12,7 +12,7 @@ param( $ErrorActionPreference = "Stop" # Version (updated by scripts/bump-version.sh) -$CcsVersion = "4.3.10" +$CcsVersion = "4.4.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" diff --git a/package.json b/package.json index e363f88f..07079c75 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "4.3.10", + "version": "4.4.0", "description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6", "keywords": [ "cli", diff --git a/scripts/postinstall.js b/scripts/postinstall.js index 7d907252..f68571d5 100755 --- a/scripts/postinstall.js +++ b/scripts/postinstall.js @@ -97,6 +97,9 @@ function createConfigFiles() { const sharedManager = new SharedManager(); sharedManager.migrateFromV311(); sharedManager.ensureSharedDirectories(); + + // Run v4.4 migration: Migrate instances to shared settings.json + sharedManager.migrateToSharedSettings(); } catch (err) { console.warn('[!] Migration warning:', err.message); console.warn(' Migration will retry on next run');