@kaitranntt/ccs 3.1.0 → 3.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/VERSION CHANGED
@@ -1 +1 @@
1
- 3.1.0
1
+ 3.2.0
package/bin/ccs.js CHANGED
@@ -262,11 +262,6 @@ async function main() {
262
262
  recovery.showRecoveryHints();
263
263
  }
264
264
 
265
- // Run migration to shared structure (Phase 1: idempotent)
266
- const SharedManager = require('./shared-manager');
267
- const sharedManager = new SharedManager();
268
- sharedManager.migrateToSharedStructure();
269
-
270
265
  // Detect profile
271
266
  const { profile, remainingArgs } = detectProfile(args);
272
267
 
@@ -6,33 +6,119 @@ const os = require('os');
6
6
 
7
7
  /**
8
8
  * SharedManager - Manages symlinked shared directories for CCS
9
- * Phase 1: Shared Global Data via Symlinks
9
+ * v3.2.0: Symlink-based architecture
10
10
  *
11
- * Purpose: Eliminates duplication of commands/skills across profile instances
12
- * by symlinking to a single ~/.ccs/shared/ directory.
11
+ * Purpose: Eliminates duplication by symlinking:
12
+ * ~/.claude/ ~/.ccs/shared/ ← instance/
13
13
  */
14
14
  class SharedManager {
15
15
  constructor() {
16
16
  this.homeDir = os.homedir();
17
17
  this.sharedDir = path.join(this.homeDir, '.ccs', 'shared');
18
+ this.claudeDir = path.join(this.homeDir, '.claude');
18
19
  this.instancesDir = path.join(this.homeDir, '.ccs', 'instances');
19
20
  this.sharedDirs = ['commands', 'skills', 'agents'];
20
21
  }
21
22
 
22
23
  /**
23
- * Ensure shared directories exist
24
+ * Detect circular symlink before creation
25
+ * @param {string} target - Target path to link to
26
+ * @param {string} linkPath - Path where symlink will be created
27
+ * @returns {boolean} True if circular
28
+ * @private
29
+ */
30
+ _detectCircularSymlink(target, linkPath) {
31
+ // Check if target exists and is symlink
32
+ if (!fs.existsSync(target)) {
33
+ return false;
34
+ }
35
+
36
+ try {
37
+ const stats = fs.lstatSync(target);
38
+ if (!stats.isSymbolicLink()) {
39
+ return false;
40
+ }
41
+
42
+ // Resolve target's link
43
+ const targetLink = fs.readlinkSync(target);
44
+ const resolvedTarget = path.resolve(path.dirname(target), targetLink);
45
+
46
+ // Check if target points back to our shared dir or link path
47
+ const sharedDir = path.join(this.homeDir, '.ccs', 'shared');
48
+ if (resolvedTarget.startsWith(sharedDir) || resolvedTarget === linkPath) {
49
+ console.log(`[!] Circular symlink detected: ${target} → ${resolvedTarget}`);
50
+ return true;
51
+ }
52
+ } catch (err) {
53
+ // If can't read, assume not circular
54
+ return false;
55
+ }
56
+
57
+ return false;
58
+ }
59
+
60
+ /**
61
+ * Ensure shared directories exist as symlinks to ~/.claude/
62
+ * Creates ~/.claude/ structure if missing
24
63
  */
25
64
  ensureSharedDirectories() {
65
+ // Create ~/.claude/ if missing
66
+ if (!fs.existsSync(this.claudeDir)) {
67
+ console.log('[i] Creating ~/.claude/ directory structure');
68
+ fs.mkdirSync(this.claudeDir, { recursive: true, mode: 0o700 });
69
+ }
70
+
26
71
  // Create shared directory
27
72
  if (!fs.existsSync(this.sharedDir)) {
28
73
  fs.mkdirSync(this.sharedDir, { recursive: true, mode: 0o700 });
29
74
  }
30
75
 
31
- // Create shared subdirectories
76
+ // Create symlinks ~/.ccs/shared/* → ~/.claude/*
32
77
  for (const dir of this.sharedDirs) {
33
- const dirPath = path.join(this.sharedDir, dir);
34
- if (!fs.existsSync(dirPath)) {
35
- fs.mkdirSync(dirPath, { recursive: true, mode: 0o700 });
78
+ const claudePath = path.join(this.claudeDir, dir);
79
+ const sharedPath = path.join(this.sharedDir, dir);
80
+
81
+ // Create directory in ~/.claude/ if missing
82
+ if (!fs.existsSync(claudePath)) {
83
+ fs.mkdirSync(claudePath, { recursive: true, mode: 0o700 });
84
+ }
85
+
86
+ // Check for circular symlink
87
+ if (this._detectCircularSymlink(claudePath, sharedPath)) {
88
+ console.log(`[!] Skipping ${dir}: circular symlink detected`);
89
+ continue;
90
+ }
91
+
92
+ // If already a symlink pointing to correct target, skip
93
+ if (fs.existsSync(sharedPath)) {
94
+ try {
95
+ const stats = fs.lstatSync(sharedPath);
96
+ if (stats.isSymbolicLink()) {
97
+ const currentTarget = fs.readlinkSync(sharedPath);
98
+ const resolvedTarget = path.resolve(path.dirname(sharedPath), currentTarget);
99
+ if (resolvedTarget === claudePath) {
100
+ continue; // Already correct
101
+ }
102
+ }
103
+ } catch (err) {
104
+ // Continue to recreate
105
+ }
106
+
107
+ // Remove existing directory/link
108
+ fs.rmSync(sharedPath, { recursive: true, force: true });
109
+ }
110
+
111
+ // Create symlink
112
+ try {
113
+ fs.symlinkSync(claudePath, sharedPath, 'dir');
114
+ } catch (err) {
115
+ // Windows fallback: copy directory
116
+ if (process.platform === 'win32') {
117
+ this._copyDirectoryFallback(claudePath, sharedPath);
118
+ console.log(`[!] Symlink failed for ${dir}, copied instead (enable Developer Mode)`);
119
+ } else {
120
+ throw err;
121
+ }
36
122
  }
37
123
  }
38
124
  }
@@ -57,9 +143,9 @@ class SharedManager {
57
143
  try {
58
144
  fs.symlinkSync(targetPath, linkPath, 'dir');
59
145
  } catch (err) {
60
- // Windows fallback: copy directory if symlink fails
146
+ // Windows fallback
61
147
  if (process.platform === 'win32') {
62
- this._copyDirectory(targetPath, linkPath);
148
+ this._copyDirectoryFallback(targetPath, linkPath);
63
149
  console.log(`[!] Symlink failed for ${dir}, copied instead (enable Developer Mode)`);
64
150
  } else {
65
151
  throw err;
@@ -69,81 +155,116 @@ class SharedManager {
69
155
  }
70
156
 
71
157
  /**
72
- * Migrate existing instances to shared structure
73
- * Idempotent: Safe to run multiple times
158
+ * Migrate from v3.1.1 (copied data in ~/.ccs/shared/) to v3.2.0 (symlinks to ~/.claude/)
159
+ * Runs once on upgrade
74
160
  */
75
- migrateToSharedStructure() {
76
- // Check if migration is needed (shared dirs exist but are empty)
77
- const needsMigration = !fs.existsSync(this.sharedDir) ||
78
- this.sharedDirs.every(dir => {
79
- const dirPath = path.join(this.sharedDir, dir);
80
- if (!fs.existsSync(dirPath)) return true;
81
- try {
82
- const files = fs.readdirSync(dirPath);
83
- return files.length === 0; // Empty directory needs migration
84
- } catch (err) {
85
- return true; // If we can't read it, assume it needs migration
161
+ migrateFromV311() {
162
+ // Check if migration already done (shared dirs are symlinks)
163
+ const commandsPath = path.join(this.sharedDir, 'commands');
164
+ if (fs.existsSync(commandsPath)) {
165
+ try {
166
+ if (fs.lstatSync(commandsPath).isSymbolicLink()) {
167
+ return; // Already migrated
86
168
  }
87
- });
169
+ } catch (err) {
170
+ // Continue with migration
171
+ }
172
+ }
88
173
 
89
- if (!needsMigration) {
90
- return; // Already migrated with content
174
+ console.log('[i] Migrating from v3.1.1 to v3.2.0...');
175
+
176
+ // Ensure ~/.claude/ exists
177
+ if (!fs.existsSync(this.claudeDir)) {
178
+ fs.mkdirSync(this.claudeDir, { recursive: true, mode: 0o700 });
91
179
  }
92
180
 
93
- // Create shared directories
94
- this.ensureSharedDirectories();
181
+ // Copy user modifications from ~/.ccs/shared/ to ~/.claude/
182
+ for (const dir of this.sharedDirs) {
183
+ const sharedPath = path.join(this.sharedDir, dir);
184
+ const claudePath = path.join(this.claudeDir, dir);
95
185
 
96
- // Copy from ~/.claude/ (actual Claude CLI directory)
97
- const claudeDir = path.join(this.homeDir, '.claude');
186
+ if (!fs.existsSync(sharedPath)) continue;
98
187
 
99
- if (fs.existsSync(claudeDir)) {
100
- // Copy commands to shared (if exists)
101
- const commandsPath = path.join(claudeDir, 'commands');
102
- if (fs.existsSync(commandsPath)) {
103
- this._copyDirectory(commandsPath, path.join(this.sharedDir, 'commands'));
188
+ try {
189
+ const stats = fs.lstatSync(sharedPath);
190
+ if (!stats.isDirectory()) continue;
191
+ } catch (err) {
192
+ continue;
104
193
  }
105
194
 
106
- // Copy skills to shared (if exists)
107
- const skillsPath = path.join(claudeDir, 'skills');
108
- if (fs.existsSync(skillsPath)) {
109
- this._copyDirectory(skillsPath, path.join(this.sharedDir, 'skills'));
195
+ // Create claude dir if missing
196
+ if (!fs.existsSync(claudePath)) {
197
+ fs.mkdirSync(claudePath, { recursive: true, mode: 0o700 });
110
198
  }
111
199
 
112
- // Copy agents to shared (if exists)
113
- const agentsPath = path.join(claudeDir, 'agents');
114
- if (fs.existsSync(agentsPath)) {
115
- this._copyDirectory(agentsPath, path.join(this.sharedDir, 'agents'));
200
+ // Copy files from shared to claude (preserve user modifications)
201
+ try {
202
+ const entries = fs.readdirSync(sharedPath, { withFileTypes: true });
203
+ let copied = 0;
204
+
205
+ for (const entry of entries) {
206
+ const src = path.join(sharedPath, entry.name);
207
+ const dest = path.join(claudePath, entry.name);
208
+
209
+ // Skip if already exists in claude
210
+ if (fs.existsSync(dest)) continue;
211
+
212
+ if (entry.isDirectory()) {
213
+ fs.cpSync(src, dest, { recursive: true });
214
+ } else {
215
+ fs.copyFileSync(src, dest);
216
+ }
217
+ copied++;
218
+ }
219
+
220
+ if (copied > 0) {
221
+ console.log(`[OK] Migrated ${copied} ${dir} to ~/.claude/${dir}`);
222
+ }
223
+ } catch (err) {
224
+ console.log(`[!] Failed to migrate ${dir}: ${err.message}`);
116
225
  }
117
226
  }
118
227
 
119
- // Update all instances to use symlinks
228
+ // Now run ensureSharedDirectories to create symlinks
229
+ this.ensureSharedDirectories();
230
+
231
+ // Update all instances to use new symlinks
120
232
  if (fs.existsSync(this.instancesDir)) {
121
- const instances = fs.readdirSync(this.instancesDir);
233
+ try {
234
+ const instances = fs.readdirSync(this.instancesDir);
122
235
 
123
- for (const instance of instances) {
124
- const instancePath = path.join(this.instancesDir, instance);
125
- if (fs.statSync(instancePath).isDirectory()) {
126
- this.linkSharedDirectories(instancePath);
236
+ for (const instance of instances) {
237
+ const instancePath = path.join(this.instancesDir, instance);
238
+ try {
239
+ if (fs.statSync(instancePath).isDirectory()) {
240
+ this.linkSharedDirectories(instancePath);
241
+ }
242
+ } catch (err) {
243
+ console.log(`[!] Failed to update instance ${instance}: ${err.message}`);
244
+ }
127
245
  }
246
+ } catch (err) {
247
+ // No instances to update
128
248
  }
129
249
  }
130
250
 
131
- console.log('[OK] Migrated to shared structure');
251
+ console.log('[OK] Migration to v3.2.0 complete');
132
252
  }
133
253
 
134
254
  /**
135
- * Copy directory recursively (fallback for Windows)
255
+ * Copy directory as fallback (Windows without Developer Mode)
136
256
  * @param {string} src - Source directory
137
257
  * @param {string} dest - Destination directory
138
258
  * @private
139
259
  */
140
- _copyDirectory(src, dest) {
260
+ _copyDirectoryFallback(src, dest) {
141
261
  if (!fs.existsSync(src)) {
262
+ fs.mkdirSync(src, { recursive: true, mode: 0o700 });
142
263
  return;
143
264
  }
144
265
 
145
266
  if (!fs.existsSync(dest)) {
146
- fs.mkdirSync(dest, { recursive: true });
267
+ fs.mkdirSync(dest, { recursive: true, mode: 0o700 });
147
268
  }
148
269
 
149
270
  const entries = fs.readdirSync(src, { withFileTypes: true });
@@ -153,7 +274,7 @@ class SharedManager {
153
274
  const destPath = path.join(dest, entry.name);
154
275
 
155
276
  if (entry.isDirectory()) {
156
- this._copyDirectory(srcPath, destPath);
277
+ this._copyDirectoryFallback(srcPath, destPath);
157
278
  } else {
158
279
  fs.copyFileSync(srcPath, destPath);
159
280
  }
package/lib/ccs CHANGED
@@ -2,7 +2,7 @@
2
2
  set -euo pipefail
3
3
 
4
4
  # Version (updated by scripts/bump-version.sh)
5
- CCS_VERSION="3.1.0"
5
+ CCS_VERSION="3.2.0"
6
6
  SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
7
7
  readonly CONFIG_FILE="${CCS_CONFIG:-$HOME/.ccs/config.json}"
8
8
  readonly PROFILES_JSON="$HOME/.ccs/profiles.json"
@@ -1010,9 +1010,6 @@ auto_recover || {
1010
1010
  exit 1
1011
1011
  }
1012
1012
 
1013
- # Run migration to shared structure (Phase 1: idempotent)
1014
- migrate_to_shared_structure
1015
-
1016
1013
  # Smart profile detection: if first arg starts with '-', it's a flag not a profile
1017
1014
  if [[ $# -eq 0 ]] || [[ "${1}" =~ ^- ]]; then
1018
1015
  # No args or first arg is a flag → use default profile
package/lib/ccs.ps1 CHANGED
@@ -12,7 +12,7 @@ param(
12
12
  $ErrorActionPreference = "Stop"
13
13
 
14
14
  # Version (updated by scripts/bump-version.sh)
15
- $CcsVersion = "3.1.0"
15
+ $CcsVersion = "3.2.0"
16
16
  $ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
17
17
  $ConfigFile = if ($env:CCS_CONFIG) { $env:CCS_CONFIG } else { "$env:USERPROFILE\.ccs\config.json" }
18
18
  $ProfilesJson = "$env:USERPROFILE\.ccs\profiles.json"
@@ -1000,9 +1000,6 @@ if (-not (Invoke-AutoRecovery)) {
1000
1000
  exit 1
1001
1001
  }
1002
1002
 
1003
- # Run migration to shared structure (Phase 1: idempotent)
1004
- Migrate-SharedStructure
1005
-
1006
1003
  # Smart profile detection: if first arg starts with '-', it's a flag not a profile
1007
1004
  if ($RemainingArgs.Count -eq 0 -or $RemainingArgs[0] -match '^-') {
1008
1005
  # No args or first arg is a flag → use default profile
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kaitranntt/ccs",
3
- "version": "3.1.0",
3
+ "version": "3.2.0",
4
4
  "description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6",
5
5
  "keywords": [
6
6
  "cli",
@@ -89,6 +89,19 @@ function createConfigFiles() {
89
89
  }
90
90
  }
91
91
 
92
+ // Migrate from v3.1.1 to v3.2.0 (symlink architecture)
93
+ console.log('');
94
+ try {
95
+ const SharedManager = require('../bin/shared-manager');
96
+ const sharedManager = new SharedManager();
97
+ sharedManager.migrateFromV311();
98
+ sharedManager.ensureSharedDirectories();
99
+ } catch (err) {
100
+ console.warn('[!] Migration warning:', err.message);
101
+ console.warn(' Migration will retry on next run');
102
+ }
103
+ console.log('');
104
+
92
105
  // Create config.json if missing
93
106
  const configPath = path.join(ccsDir, 'config.json');
94
107
  if (!fs.existsSync(configPath)) {