Skip to content

Commit 725e06f

Browse files
FelixIsaacclaude
andcommitted
feat: cross-platform plugin sync with path normalization
- Add path normalization for plugin configs (installed_plugins.json, known_marketplaces.json) - Push: convert absolute paths to $CLAUDE_DIR placeholder - Pull: expand placeholder to local path, convert backslashes to forward slashes on Unix - Remove plugins/cache and plugins/marketplaces from exclude patterns - Enables "sync once and forget" - plugins work across Windows/macOS/Linux 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 9c13cd4 commit 725e06f

4 files changed

Lines changed: 147 additions & 3 deletions

File tree

internal/cmd/pull.go

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,12 +175,58 @@ func runPull(cmd *cobra.Command, args []string) error {
175175
if pullDryRun {
176176
logInfo(fmt.Sprintf("[DRY RUN] Would restore %d files", count))
177177
} else {
178+
// Expand cross-platform path placeholders to local paths
179+
if err := expandPluginPaths(paths.ClaudeDir); err != nil {
180+
logWarn(fmt.Sprintf("Failed to expand plugin paths: %v", err))
181+
}
182+
178183
logSuccess(fmt.Sprintf("Pull complete! Restored %d files.", count))
179184
}
180185

181186
return nil
182187
}
183188

189+
// expandPluginPaths converts cross-platform placeholders to local platform paths
190+
// in plugin configuration files after pulling from the repo.
191+
func expandPluginPaths(claudeDir string) error {
192+
// Find all JSON files in plugins directory that may contain path placeholders
193+
pluginsDir := filepath.Join(claudeDir, "plugins")
194+
if !sync.FileExists(pluginsDir) {
195+
return nil
196+
}
197+
198+
files, err := sync.WalkFiles(pluginsDir)
199+
if err != nil {
200+
return err
201+
}
202+
203+
for _, file := range files {
204+
if !strings.HasSuffix(file, ".json") {
205+
continue
206+
}
207+
208+
data, err := os.ReadFile(file)
209+
if err != nil {
210+
continue
211+
}
212+
213+
// Only process if file contains the placeholder
214+
if !strings.Contains(string(data), sync.ClaudeDirPlaceholder) {
215+
continue
216+
}
217+
218+
expanded := sync.ExpandPathsInJSON(data, claudeDir)
219+
if err := os.WriteFile(file, expanded, 0644); err != nil {
220+
return fmt.Errorf("failed to write expanded %s: %w", file, err)
221+
}
222+
223+
relPath := sync.RelPath(claudeDir, file)
224+
logInfo(fmt.Sprintf("Expanded paths: %s", relPath))
225+
}
226+
227+
return nil
228+
}
229+
184230
// createBackupZip creates a zip backup of the claude directory
185231
func createBackupZip(claudeDir, claudeJSON, dest string) error {
186232
if err := sync.EnsureDir(filepath.Dir(dest)); err != nil {

internal/cmd/push.go

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@ package cmd
22

33
import (
44
"fmt"
5+
"os"
56
"path/filepath"
7+
"strings"
68

79
"github.com/felixisaac/claude-code-sync/internal/config"
810
"github.com/felixisaac/claude-code-sync/internal/crypto"
@@ -116,6 +118,11 @@ func runPush(cmd *cobra.Command, args []string) error {
116118
return nil
117119
}
118120

121+
// Normalize paths in plugin config files for cross-platform compatibility
122+
if err := normalizePluginPaths(paths.RepoDir, paths.ClaudeDir); err != nil {
123+
logWarn(fmt.Sprintf("Failed to normalize plugin paths: %v", err))
124+
}
125+
119126
// Generate manifest
120127
logInfo("Generating manifest...")
121128
entries, err := sync.GenerateManifest(paths.RepoDir)
@@ -162,3 +169,46 @@ func runPush(cmd *cobra.Command, args []string) error {
162169
logSuccess("Push complete!")
163170
return nil
164171
}
172+
173+
// normalizePluginPaths converts platform-specific paths to cross-platform placeholders
174+
// in plugin configuration files for seamless syncing across Windows/macOS/Linux.
175+
func normalizePluginPaths(repoDir, claudeDir string) error {
176+
// Find all JSON files in plugins directory that may contain paths
177+
pluginsDir := filepath.Join(repoDir, "plugins")
178+
if !sync.FileExists(pluginsDir) {
179+
return nil
180+
}
181+
182+
files, err := sync.WalkFiles(pluginsDir)
183+
if err != nil {
184+
return err
185+
}
186+
187+
for _, file := range files {
188+
if !strings.HasSuffix(file, ".json") {
189+
continue
190+
}
191+
192+
data, err := os.ReadFile(file)
193+
if err != nil {
194+
continue
195+
}
196+
197+
// Only process if file contains the claude dir path
198+
if !strings.Contains(string(data), claudeDir) &&
199+
!strings.Contains(string(data), filepath.ToSlash(claudeDir)) &&
200+
!strings.Contains(string(data), strings.ReplaceAll(claudeDir, `\`, `\\`)) {
201+
continue
202+
}
203+
204+
normalized := sync.NormalizePathsInJSON(data, claudeDir)
205+
if err := os.WriteFile(file, normalized, 0644); err != nil {
206+
return fmt.Errorf("failed to write normalized %s: %w", file, err)
207+
}
208+
209+
relPath := sync.RelPath(repoDir, file)
210+
logInfo(fmt.Sprintf("Normalized paths: %s", relPath))
211+
}
212+
213+
return nil
214+
}

internal/config/config.go

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -70,9 +70,6 @@ var DefaultExcludePatterns = []string{
7070
"shell-snapshots",
7171
"telemetry",
7272
"sessionStorage",
73-
// Plugins subdirs (keep installed_plugins.json, known_marketplaces.json)
74-
"plugins/cache",
75-
"plugins/marketplaces",
7673
// Files
7774
"history.jsonl",
7875
"stats-cache.json",

internal/sync/paths.go

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
package sync
2+
3+
import (
4+
"path/filepath"
5+
"strings"
6+
)
7+
8+
// ClaudeDirPlaceholder is used to replace platform-specific paths in synced files
9+
const ClaudeDirPlaceholder = "$CLAUDE_DIR"
10+
11+
// NormalizePathsInJSON replaces absolute ClaudeDir paths with a cross-platform placeholder.
12+
// This allows plugin configs to be synced across Windows/macOS/Linux.
13+
func NormalizePathsInJSON(data []byte, claudeDir string) []byte {
14+
content := string(data)
15+
16+
// Handle escaped backslashes in JSON (Windows paths like C:\\Users\\...)
17+
escapedClaudeDir := strings.ReplaceAll(claudeDir, `\`, `\\`)
18+
content = strings.ReplaceAll(content, escapedClaudeDir, ClaudeDirPlaceholder)
19+
20+
// Handle forward slash version (normalized paths)
21+
forwardSlashDir := filepath.ToSlash(claudeDir)
22+
content = strings.ReplaceAll(content, forwardSlashDir, ClaudeDirPlaceholder)
23+
24+
// Handle raw backslash version (shouldn't normally appear in JSON, but just in case)
25+
content = strings.ReplaceAll(content, claudeDir, ClaudeDirPlaceholder)
26+
27+
return []byte(content)
28+
}
29+
30+
// ExpandPathsInJSON replaces the cross-platform placeholder with the local ClaudeDir path.
31+
// The expanded path uses the native format for the current platform.
32+
func ExpandPathsInJSON(data []byte, claudeDir string) []byte {
33+
content := string(data)
34+
35+
// For JSON files, we need to use escaped backslashes on Windows
36+
// Check if we're on Windows by looking for backslashes in claudeDir
37+
if strings.Contains(claudeDir, `\`) {
38+
// Windows: use escaped backslashes for JSON
39+
escapedClaudeDir := strings.ReplaceAll(claudeDir, `\`, `\\`)
40+
content = strings.ReplaceAll(content, ClaudeDirPlaceholder, escapedClaudeDir)
41+
} else {
42+
// Unix: replace placeholder and convert any remaining backslashes to forward slashes
43+
content = strings.ReplaceAll(content, ClaudeDirPlaceholder, claudeDir)
44+
// Convert escaped backslashes (\\) to forward slashes
45+
content = strings.ReplaceAll(content, `\\`, `/`)
46+
// Convert single backslashes to forward slashes (shouldn't happen in JSON but just in case)
47+
content = strings.ReplaceAll(content, `\`, `/`)
48+
}
49+
50+
return []byte(content)
51+
}

0 commit comments

Comments
 (0)