From 6b3f93a80a0232e8c964d73e51aa0afb0768b00f Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Tue, 2 Dec 2025 22:01:50 -0500 Subject: [PATCH 1/3] fix(sync): implement copy fallback for windows when symlinks unavailable When symlink creation fails on Windows (due to Developer Mode being disabled or insufficient permissions), fall back to copying files and directories instead. Changes: - Add copyFallback() method for Windows symlink failures - Add copyDirRecursive() helper for directory copying - Update checkHealth() to recognize copied files as valid on Windows - Add isCopiedItem() helper to validate copied content - Update uninstall() to handle both symlinks and copied files Fixes #45 --- src/utils/claude-symlink-manager.ts | 119 +++++++++++++++++++++++++--- 1 file changed, 107 insertions(+), 12 deletions(-) diff --git a/src/utils/claude-symlink-manager.ts b/src/utils/claude-symlink-manager.ts index 4011d007..29c98260 100644 --- a/src/utils/claude-symlink-manager.ts +++ b/src/utils/claude-symlink-manager.ts @@ -164,12 +164,9 @@ export class ClaudeSymlinkManager { if (!silent) console.log(`[OK] Symlinked ${item.target}`); return true; } catch (err) { - // Windows fallback: stub for now, full implementation in v4.2 + // Windows fallback: copy instead of symlink when symlinks unavailable if (process.platform === 'win32') { - if (!silent) { - console.log(`[!] Symlink failed for ${item.target} (Windows fallback deferred to v4.2)`); - console.log(`[i] Enable Developer Mode or wait for next update`); - } + return this.copyFallback(sourcePath, targetPath, item, silent); } else { const error = err as Error; if (!silent) console.log(`[!] Failed to symlink ${item.target}: ${error.message}`); @@ -178,6 +175,61 @@ export class ClaudeSymlinkManager { } } + /** + * Windows fallback: copy files/directories when symlinks unavailable + * Note: Changes won't auto-sync; user must run 'ccs sync' after updates + */ + private copyFallback( + sourcePath: string, + targetPath: string, + item: CcsItem, + silent = false + ): boolean { + try { + if (item.type === 'directory') { + // Copy directory recursively + this.copyDirRecursive(sourcePath, targetPath); + } else { + // Copy single file + fs.copyFileSync(sourcePath, targetPath); + } + if (!silent) { + console.log(`[OK] Copied ${item.target} (symlink unavailable)`); + console.log(`[i] Run 'ccs sync' after CCS updates to refresh`); + } + return true; + } catch (copyErr) { + const error = copyErr as Error; + if (!silent) { + console.log(`[!] Failed to copy ${item.target}: ${error.message}`); + console.log(`[i] Enable Developer Mode for symlinks, or check permissions`); + } + return false; + } + } + + /** + * Recursively copy directory (for Windows fallback) + */ + private copyDirRecursive(src: string, dest: string): void { + if (!fs.existsSync(dest)) { + fs.mkdirSync(dest, { recursive: true }); + } + + const entries = fs.readdirSync(src, { withFileTypes: true }); + + for (const entry of entries) { + const srcPath = path.join(src, entry.name); + const destPath = path.join(dest, entry.name); + + if (entry.isDirectory()) { + this.copyDirRecursive(srcPath, destPath); + } else { + fs.copyFileSync(srcPath, destPath); + } + } + } + /** * Check if target is already the correct symlink pointing to source */ @@ -224,8 +276,8 @@ export class ClaudeSymlinkManager { } /** - * Uninstall CCS items from ~/.claude/ (remove symlinks only) - * Safe: only removes items that are CCS symlinks + * Uninstall CCS items from ~/.claude/ (remove symlinks or copied files) + * Safe: only removes items that are CCS symlinks or valid copies */ uninstall(): void { let removed = 0; @@ -234,10 +286,20 @@ export class ClaudeSymlinkManager { const targetPath = path.join(this.userClaudeDir, item.target); const sourcePath = path.join(this.ccsClaudeDir, item.source); - // Only remove if it's our symlink - if (fs.existsSync(targetPath) && this.isOurSymlink(targetPath, sourcePath)) { + // Check if it's our symlink or a valid copy (Windows fallback) + const isSymlink = this.isOurSymlink(targetPath, sourcePath); + const isCopy = + process.platform === 'win32' && this.isCopiedItem(targetPath, sourcePath, item.type); + + if (fs.existsSync(targetPath) && (isSymlink || isCopy)) { try { - fs.unlinkSync(targetPath); + if (item.type === 'directory' && !isSymlink) { + // Remove copied directory recursively + fs.rmSync(targetPath, { recursive: true, force: true }); + } else { + // Remove symlink or file + fs.unlinkSync(targetPath); + } console.log(`[OK] Removed ${item.target}`); removed++; } catch (err) { @@ -286,14 +348,47 @@ export class ClaudeSymlinkManager { issues.push(`Not installed: ${item.target} (run 'ccs sync' to install)`); healthy = false; } else if (!this.isOurSymlink(targetPath, sourcePath)) { - issues.push(`Not a CCS symlink: ${item.target} (run 'ccs sync' to fix)`); - healthy = false; + // On Windows, copied files are valid (symlink fallback) + if (process.platform === 'win32' && this.isCopiedItem(targetPath, sourcePath, item.type)) { + // Copied file is valid on Windows, but note it's not a symlink + issues.push(`${item.target} is a copy (not symlink) - run 'ccs sync' after updates`); + // Still healthy, just a warning + } else { + issues.push(`Not a CCS symlink: ${item.target} (run 'ccs sync' to fix)`); + healthy = false; + } } } return { healthy, issues }; } + /** + * Check if target is a valid copy of source (Windows fallback check) + */ + private isCopiedItem( + targetPath: string, + sourcePath: string, + type: 'file' | 'directory' + ): boolean { + try { + const targetStats = fs.statSync(targetPath); + const sourceStats = fs.statSync(sourcePath); + + if (type === 'directory') { + // For directories, just check both exist and are directories + return targetStats.isDirectory() && sourceStats.isDirectory(); + } else { + // For files, compare size as basic validation + return ( + targetStats.isFile() && sourceStats.isFile() && targetStats.size === sourceStats.size + ); + } + } catch { + return false; + } + } + /** * Sync delegation commands and skills to ~/.claude/ (used by 'ccs sync' command) * Same as install() but with explicit sync message From c45fc3e19f1807cb266b1fa18d7ab00a07d181ce Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Wed, 3 Dec 2025 03:16:27 +0000 Subject: [PATCH 2/3] chore(release): 5.4.1-dev.2 [skip ci] ## [5.4.1-dev.2](https://github.com/kaitranntt/ccs/compare/v5.4.1-dev.1...v5.4.1-dev.2) (2025-12-03) ### Bug Fixes * **sync:** implement copy fallback for windows when symlinks unavailable ([6b3f93a](https://github.com/kaitranntt/ccs/commit/6b3f93a80a0232e8c964d73e51aa0afb0768b00f)), closes [#45](https://github.com/kaitranntt/ccs/issues/45) --- CHANGELOG.md | 7 +++++++ VERSION | 2 +- installers/install.ps1 | 2 +- installers/install.sh | 2 +- package.json | 2 +- 5 files changed, 11 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f925bfb..e0bf05d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +## [5.4.1-dev.2](https://github.com/kaitranntt/ccs/compare/v5.4.1-dev.1...v5.4.1-dev.2) (2025-12-03) + + +### Bug Fixes + +* **sync:** implement copy fallback for windows when symlinks unavailable ([6b3f93a](https://github.com/kaitranntt/ccs/commit/6b3f93a80a0232e8c964d73e51aa0afb0768b00f)), closes [#45](https://github.com/kaitranntt/ccs/issues/45) + ## [5.4.1-dev.1](https://github.com/kaitranntt/ccs/compare/v5.4.0...v5.4.1-dev.1) (2025-12-03) diff --git a/VERSION b/VERSION index ef39ed80..f0e21525 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -5.4.1-dev.1 +5.4.1-dev.2 diff --git a/installers/install.ps1 b/installers/install.ps1 index a24b7d9e..9cad1d77 100644 --- a/installers/install.ps1 +++ b/installers/install.ps1 @@ -83,7 +83,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 = "5.4.1-dev.1" +$CcsVersion = "5.4.1-dev.2" # Try to read VERSION file for git installations if ($ScriptDir) { diff --git a/installers/install.sh b/installers/install.sh index 5de1b2b8..67144893 100755 --- a/installers/install.sh +++ b/installers/install.sh @@ -84,7 +84,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="5.4.1-dev.1" +CCS_VERSION="5.4.1-dev.2" # Try to read VERSION file for git installations if [[ -f "$SCRIPT_DIR/VERSION" ]]; then diff --git a/package.json b/package.json index 584e8de2..4be95d2f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "5.4.1-dev.1", + "version": "5.4.1-dev.2", "description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6", "keywords": [ "cli", From 691758ff47c129a8af021ae7a0f9501bbb52b31c Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Wed, 3 Dec 2025 04:44:35 +0000 Subject: [PATCH 3/3] chore(release): 5.4.2-dev.1 [skip ci] ## [5.4.2-dev.1](https://github.com/kaitranntt/ccs/compare/v5.4.1...v5.4.2-dev.1) (2025-12-03) ### Bug Fixes * **merge:** resolve conflicts between dev and main ([8347ea6](https://github.com/kaitranntt/ccs/commit/8347ea64c6b919a79f5ab63c398b6c36f012ca2d)) * **sync:** implement copy fallback for windows when symlinks unavailable ([6b3f93a](https://github.com/kaitranntt/ccs/commit/6b3f93a80a0232e8c964d73e51aa0afb0768b00f)), closes [#45](https://github.com/kaitranntt/ccs/issues/45) --- CHANGELOG.md | 8 ++++++++ VERSION | 2 +- installers/install.ps1 | 2 +- installers/install.sh | 2 +- package.json | 2 +- 5 files changed, 12 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c204e63..97548c74 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,11 @@ +## [5.4.2-dev.1](https://github.com/kaitranntt/ccs/compare/v5.4.1...v5.4.2-dev.1) (2025-12-03) + + +### Bug Fixes + +* **merge:** resolve conflicts between dev and main ([8347ea6](https://github.com/kaitranntt/ccs/commit/8347ea64c6b919a79f5ab63c398b6c36f012ca2d)) +* **sync:** implement copy fallback for windows when symlinks unavailable ([6b3f93a](https://github.com/kaitranntt/ccs/commit/6b3f93a80a0232e8c964d73e51aa0afb0768b00f)), closes [#45](https://github.com/kaitranntt/ccs/issues/45) + ## [5.4.1](https://github.com/kaitranntt/ccs/compare/v5.4.0...v5.4.1) (2025-12-03) diff --git a/VERSION b/VERSION index ade65226..ae273b6d 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -5.4.1 +5.4.2-dev.1 diff --git a/installers/install.ps1 b/installers/install.ps1 index 9df8ae3a..8d09b784 100644 --- a/installers/install.ps1 +++ b/installers/install.ps1 @@ -83,7 +83,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 = "5.4.1" +$CcsVersion = "5.4.2-dev.1" # Try to read VERSION file for git installations if ($ScriptDir) { diff --git a/installers/install.sh b/installers/install.sh index 0d7e874d..2e981f0a 100755 --- a/installers/install.sh +++ b/installers/install.sh @@ -84,7 +84,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="5.4.1" +CCS_VERSION="5.4.2-dev.1" # Try to read VERSION file for git installations if [[ -f "$SCRIPT_DIR/VERSION" ]]; then diff --git a/package.json b/package.json index dcca315f..0dff8516 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "5.4.1", + "version": "5.4.2-dev.1", "description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6", "keywords": [ "cli",