feat(config): introduce shared settings.json across profiles

This commit is contained in:
kaitranntt committed 2025-11-23 20:31:39 -05:00
1 parent c4e5bcc40b
commit 739895a29e
12 files changed
+330 -97

No files matched your search

+18
View File
@@ -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: `<instance>/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
+12 -34
View File
@@ -313,43 +313,21 @@ graph LR
- Uses `CLAUDE_CONFIG_DIR` for isolated instances
- Create with `ccs auth create <profile>`
### 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: `<instance>/settings.json.pre-shared-migration`
> [!NOTE]
> **Windows**: Symlink support requires Developer Mode (v4.2 will add copy fallback)
+1 -1
View File
@@ -1 +1 @@
4.3.10
4.4.0
+122
View File
@@ -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
*/
+3 -8
View File
@@ -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
}
/**
+166 -49
View File
@@ -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
+1 -1
View File
@@ -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) {
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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"
+1 -1
View File
@@ -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"
+1 -1
View File
@@ -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",
+3
View File
@@ -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');