Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ toolchain go1.24.12
require (
filippo.io/age v1.3.1
github.com/charmbracelet/huh v0.6.0
github.com/charmbracelet/huh/spinner v0.0.0-20251215014908-6f7d32faaff3
github.com/charmbracelet/lipgloss v1.1.0
github.com/fsnotify/fsnotify v1.9.0
github.com/go-git/go-git/v5 v5.16.4
github.com/spf13/cobra v1.8.0
)
Expand All @@ -23,7 +25,6 @@ require (
github.com/charmbracelet/bubbles v0.21.0 // indirect
github.com/charmbracelet/bubbletea v1.3.10 // indirect
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
github.com/charmbracelet/huh/spinner v0.0.0-20251215014908-6f7d32faaff3 // indirect
github.com/charmbracelet/x/ansi v0.10.1 // indirect
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect
Expand Down Expand Up @@ -56,7 +57,6 @@ require (
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/crypto v0.45.0 // indirect
golang.org/x/net v0.47.0 // indirect
golang.org/x/sync v0.18.0 // indirect
golang.org/x/sys v0.38.0 // indirect
golang.org/x/text v0.31.0 // indirect
gopkg.in/warnings.v0 v0.1.2 // indirect
Expand Down
20 changes: 6 additions & 14 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -21,14 +21,12 @@ github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8=
github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA=
github.com/catppuccin/go v0.2.0 h1:ktBeIrIP42b/8FGiScP9sgrWOss3lw0Z5SktRoithGA=
github.com/catppuccin/go v0.2.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc=
github.com/charmbracelet/bubbles v0.20.0 h1:jSZu6qD8cRQ6k9OMfR1WlM+ruM8fkPWkHvQWD9LIutE=
github.com/charmbracelet/bubbles v0.20.0/go.mod h1:39slydyswPy+uVOHZ5x/GjwVAFkCsV8IIVy+4MhzwwU=
github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs=
github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg=
github.com/charmbracelet/bubbletea v1.1.0 h1:FjAl9eAL3HBCHenhz/ZPjkKdScmaS5SK69JAK2YJK9c=
github.com/charmbracelet/bubbletea v1.1.0/go.mod h1:9Ogk0HrdbHolIKHdjfFpyXJmiCzGwy+FesYkZr7hYU4=
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
Expand All @@ -37,20 +35,16 @@ github.com/charmbracelet/huh v0.6.0 h1:mZM8VvZGuE0hoDXq6XLxRtgfWyTI3b2jZNKh0xWma
github.com/charmbracelet/huh v0.6.0/go.mod h1:GGNKeWCeNzKpEOh/OJD8WBwTQjV3prFAtQPpLv+AVwU=
github.com/charmbracelet/huh/spinner v0.0.0-20251215014908-6f7d32faaff3 h1:KUeWGoKnmyrLaDIa0smE6pK5eFMZWNIxPGweQR12iLg=
github.com/charmbracelet/huh/spinner v0.0.0-20251215014908-6f7d32faaff3/go.mod h1:OMqKat/mm9a/qOnpuNOPyYO9bPzRNnmzLnRZT5KYltg=
github.com/charmbracelet/lipgloss v1.0.0 h1:O7VkGDvqEdGi93X+DeqsQ7PKHDgtQfF8j8/O2qFMQNg=
github.com/charmbracelet/lipgloss v1.0.0/go.mod h1:U5fy9Z+C38obMs+T+tJqst9VGzlOYGj4ri9reL3qUlo=
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
github.com/charmbracelet/x/ansi v0.4.2 h1:0JM6Aj/g/KC154/gOP4vfxun0ff6itogDYk41kof+qk=
github.com/charmbracelet/x/ansi v0.4.2/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw=
github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ=
github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8=
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ=
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4=
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ=
github.com/charmbracelet/x/term v0.2.0 h1:cNB9Ot9q8I711MyZ7myUR5HFWL/lc3OpU8jZ4hwm0x0=
github.com/charmbracelet/x/term v0.2.0/go.mod h1:GVxgxAbjUrmpvIINHIQnJJKpMlHiZ4cktEQCN6GWyF0=
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0=
Expand All @@ -69,6 +63,8 @@ github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc
github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI=
Expand Down Expand Up @@ -110,8 +106,6 @@ github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/termenv v0.15.3-0.20240618155329-98d742f6907a h1:2MaM6YC3mGu54x+RKAA6JiFFHlHDY1UbkxqppT7wYOg=
github.com/muesli/termenv v0.15.3-0.20240618155329-98d742f6907a/go.mod h1:hxSnBBYLK21Vtq/PHd0S2FYCxBXzBua8ov5s1RobyRQ=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k=
Expand Down Expand Up @@ -154,8 +148,6 @@ golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbR
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
Expand Down
156 changes: 156 additions & 0 deletions internal/cli/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -298,6 +298,65 @@ Useful after many sync operations to keep storage optimized.`,
},
}

// watchCmd starts the autosync daemon
var watchCmd = &cobra.Command{
Use: "watch",
Short: "Start autosync daemon (file watch + periodic sync)",
Long: `Run as a background daemon that syncs automatically.

Triggers a sync on:
- Startup
- File changes in OpenCode config directory (debounced)
- Periodic interval (default: 5 minutes)

Typically run via: opencode-sync watch &
Or install as a launchd service: opencode-sync autosync install`,
RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := config.Load()
if err != nil || cfg == nil {
return fmt.Errorf("no configuration found. Run 'opencode-sync setup' first")
}

p, err := paths.Get()
if err != nil {
return fmt.Errorf("failed to get paths: %w", err)
}

watcher := sync.NewWatcher(cfg, runSync, p)
return watcher.Run()
},
}

// autosyncCmd manages the autosync launchd service
var autosyncCmd = &cobra.Command{
Use: "autosync",
Short: "Manage the autosync launchd service (macOS)",
}

var autosyncInstallCmd = &cobra.Command{
Use: "install",
Short: "Install autosync as a launchd service",
RunE: func(cmd *cobra.Command, args []string) error {
return runAutosyncInstall()
},
}

var autosyncUninstallCmd = &cobra.Command{
Use: "uninstall",
Short: "Uninstall the autosync launchd service",
RunE: func(cmd *cobra.Command, args []string) error {
return runAutosyncUninstall()
},
}

var autosyncStatusCmd = &cobra.Command{
Use: "status",
Short: "Show autosync launchd service status",
RunE: func(cmd *cobra.Command, args []string) error {
return runAutosyncStatus()
},
}

func init() {
// Add config subcommands
configCmd.AddCommand(configShowCmd)
Expand All @@ -309,6 +368,11 @@ func init() {
keyCmd.AddCommand(keyExportCmd)
keyCmd.AddCommand(keyImportCmd)
keyCmd.AddCommand(keyRegenCmd)

// Add autosync subcommands
autosyncCmd.AddCommand(autosyncInstallCmd)
autosyncCmd.AddCommand(autosyncUninstallCmd)
autosyncCmd.AddCommand(autosyncStatusCmd)
}

// Command implementations
Expand Down Expand Up @@ -1405,3 +1469,95 @@ func runGC() error {
ui.Success("Repository optimized!")
return nil
}

func runAutosyncInstall() error {
plistName := "com.opencode-sync.autosync"
plistPath := filepath.Join(os.Getenv("HOME"), "Library/LaunchAgents", plistName+".plist")
logDir := filepath.Join(os.Getenv("HOME"), "Library/Logs/opencode-sync")

binaryPath, err := os.Executable()
if err != nil {
binaryPath = filepath.Join(os.Getenv("HOME"), ".local/bin/opencode-sync")
}

if err := os.MkdirAll(logDir, 0755); err != nil {
return fmt.Errorf("failed to create log dir: %w", err)
}

plist := fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>%s</string>
<key>ProgramArguments</key>
<array>
<string>%s</string>
<string>watch</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>StandardOutPath</key>
<string>%s/autosync.log</string>
<key>StandardErrorPath</key>
<string>%s/autosync.err</string>
</dict>
</plist>
`, plistName, binaryPath, logDir, logDir)

if err := os.WriteFile(plistPath, []byte(plist), 0644); err != nil {
return fmt.Errorf("failed to write plist: %w", err)
}

if err := exec.Command("launchctl", "load", plistPath).Run(); err != nil {
return fmt.Errorf("failed to load launchd service: %w", err)
}

ui.Success("Autosync service installed and started!")
fmt.Printf(" Plist: %s\n", plistPath)
fmt.Printf(" Logs: %s/autosync.log\n", logDir)
return nil
}

func runAutosyncUninstall() error {
plistName := "com.opencode-sync.autosync"
plistPath := filepath.Join(os.Getenv("HOME"), "Library/LaunchAgents", plistName+".plist")

exec.Command("launchctl", "unload", plistPath).Run()
os.Remove(plistPath)

ui.Success("Autosync service uninstalled")
return nil
}

func runAutosyncStatus() error {
plistName := "com.opencode-sync.autosync"
plistPath := filepath.Join(os.Getenv("HOME"), "Library/LaunchAgents", plistName+".plist")
logPath := filepath.Join(os.Getenv("HOME"), "Library/Logs/opencode-sync/autosync.log")

if _, err := os.Stat(plistPath); os.IsNotExist(err) {
ui.Warn("Autosync service is not installed")
return nil
}

fmt.Printf("Plist: %s\n", plistPath)

if out, err := exec.Command("launchctl", "list", plistName).CombinedOutput(); err == nil {
fmt.Printf("Status: running\n%s\n", string(out))
} else {
fmt.Printf("Status: not running\n")
}

if data, err := os.ReadFile(logPath); err == nil && len(data) > 0 {
lines := string(data)
if idx := len(lines) - 500; idx > 0 {
lines = lines[idx:]
}
fmt.Println("\nRecent logs:")
fmt.Println(lines)
}

return nil
}
2 changes: 2 additions & 0 deletions internal/cli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,8 @@ func init() {
rootCmd.AddCommand(rebindCmd)
rootCmd.AddCommand(gcCmd)
rootCmd.AddCommand(uninstallCmd)
rootCmd.AddCommand(watchCmd)
rootCmd.AddCommand(autosyncCmd)
}

// runSetupWizard runs the first-time setup wizard
Expand Down
15 changes: 15 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,15 @@ type Config struct {
Repo RepoConfig `json:"repo"`
Encryption EncryptionConfig `json:"encryption"`
Sync SyncConfig `json:"sync"`
Autosync AutosyncConfig `json:"autosync,omitempty"`
}

// AutosyncConfig holds autosync daemon settings
type AutosyncConfig struct {
Enabled bool `json:"enabled"`
PollInterval string `json:"pollInterval,omitempty"`
DebounceDelay string `json:"debounceDelay,omitempty"`
WatchFiles bool `json:"watchFiles,omitempty"`
}

// RepoConfig holds Git repository configuration
Expand Down Expand Up @@ -56,6 +65,12 @@ func Default() *Config {
IncludeMcpAuth: false,
Exclude: []string{"node_modules", "*.log", "bun.lock"},
},
Autosync: AutosyncConfig{
Enabled: false,
PollInterval: "15m",
DebounceDelay: "3s",
WatchFiles: true,
},
}
}

Expand Down
6 changes: 6 additions & 0 deletions internal/paths/paths.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ type Paths struct {
// OpenCodeDataDir is where OpenCode stores its data (auth.json, etc.)
OpenCodeDataDir string

// OpenCodeSkillsDir is where OpenCode CLI stores skills (~/.opencode/skills/)
OpenCodeSkillsDir string

// ClaudeSkillsDir is where Claude Code stores skills (~/.claude/skills/)
ClaudeSkillsDir string
}
Expand Down Expand Up @@ -94,8 +97,11 @@ func (p *Paths) SyncableOpenCodePaths() []string {
filepath.Join(p.OpenCodeConfigDir, "mode"),
filepath.Join(p.OpenCodeConfigDir, "themes"),
filepath.Join(p.OpenCodeConfigDir, "plugin"),
filepath.Join(p.OpenCodeConfigDir, "tools"),
filepath.Join(p.OpenCodeConfigDir, "package.json"),
}

paths = append(paths, p.OpenCodeSkillsDir)
paths = append(paths, p.ClaudeSkillsDir)

return paths
Expand Down
1 change: 1 addition & 0 deletions internal/paths/paths_unix.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ func getPlatformPaths() (*Paths, error) {
DataDir: filepath.Join(dataHome, "opencode-sync"),
OpenCodeConfigDir: filepath.Join(configHome, "opencode"),
OpenCodeDataDir: filepath.Join(dataHome, "opencode"),
OpenCodeSkillsDir: filepath.Join(home, ".opencode", "skills"),
ClaudeSkillsDir: filepath.Join(home, ".claude", "skills"),
}, nil
}
8 changes: 8 additions & 0 deletions internal/sync/sync.go
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,8 @@ func (s *Syncer) CopyToRepo() error {

if srcPath == s.paths.ClaudeSkillsDir {
dstPath = filepath.Join(s.paths.SyncRepoDir(), "claude-skills")
} else if srcPath == s.paths.OpenCodeSkillsDir {
dstPath = filepath.Join(s.paths.SyncRepoDir(), "opencode-skills")
} else {
relPath, err = filepath.Rel(s.paths.OpenCodeConfigDir, srcPath)
if err != nil {
Expand Down Expand Up @@ -204,6 +206,12 @@ func (s *Syncer) CopyFromRepo() error {
return nil
}
dstPath = filepath.Join(s.paths.ClaudeSkillsDir, relToClaudeSkills)
} else if strings.HasPrefix(relPath, "opencode-skills"+string(filepath.Separator)) || relPath == "opencode-skills" {
relToOpenCodeSkills, _ := filepath.Rel("opencode-skills", relPath)
if relToOpenCodeSkills == "." {
return nil
}
dstPath = filepath.Join(s.paths.OpenCodeSkillsDir, relToOpenCodeSkills)
} else {
dstPath = filepath.Join(s.paths.OpenCodeConfigDir, relPath)
}
Expand Down
Loading