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
112 changes: 112 additions & 0 deletions cmd/claude-sync/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import (
"github.com/AlecAivazis/survey/v2"
"github.com/spf13/cobra"

"github.com/tawanorg/claude-sync/internal/claudesettings"
"github.com/tawanorg/claude-sync/internal/config"
"github.com/tawanorg/claude-sync/internal/crypto"
"github.com/tawanorg/claude-sync/internal/storage"
Expand Down Expand Up @@ -70,6 +71,7 @@ func main() {
updateCmd(),
changelogCmd(),
mcpCmd(),
autoCmd(),
)

if err := rootCmd.Execute(); err != nil {
Expand Down Expand Up @@ -2816,3 +2818,113 @@ func runMCPPull(ctx context.Context, syncer *sync.Syncer) error {
}
return nil
}

// auto subcommand — install/remove/show claude-sync hooks in ~/.claude/settings.json

func autoCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "auto",
Short: "Manage auto-sync hooks for Claude Code",
Long: `Install or remove claude-sync hooks that automatically push/pull when Claude Code sessions start and stop.`,
}
cmd.AddCommand(autoEnableCmd(), autoDisableCmd(), autoStatusCmd())
return cmd
}

func autoEnableCmd() *cobra.Command {
return &cobra.Command{
Use: "enable",
Short: "Install auto-sync hooks into Claude Code settings",
RunE: func(cmd *cobra.Command, args []string) error {
s, raw, err := claudesettings.Load()
if err != nil {
return fmt.Errorf("failed to load Claude settings: %w", err)
}

changed := false
if !claudesettings.HasClaudeSyncHook(s.Hooks["SessionStart"]) {
s.Hooks["SessionStart"] = claudesettings.AddHook(s.Hooks["SessionStart"], "claude-sync pull -q")
changed = true
}
if !claudesettings.HasClaudeSyncHook(s.Hooks["Stop"]) {
s.Hooks["Stop"] = claudesettings.AddHook(s.Hooks["Stop"], "claude-sync push -q")
changed = true
}

if !changed {
fmt.Println("Auto-sync hooks already installed.")
return nil
}

if err := claudesettings.Save(s, raw); err != nil {
return fmt.Errorf("failed to save Claude settings: %w", err)
}

fmt.Println("Auto-sync hooks installed:")
fmt.Println(" SessionStart → claude-sync pull -q")
fmt.Println(" Stop → claude-sync push -q")
return nil
},
}
}

func autoDisableCmd() *cobra.Command {
return &cobra.Command{
Use: "disable",
Short: "Remove auto-sync hooks from Claude Code settings",
RunE: func(cmd *cobra.Command, args []string) error {
s, raw, err := claudesettings.Load()
if err != nil {
return fmt.Errorf("failed to load Claude settings: %w", err)
}

before := claudesettings.HasClaudeSyncHook(s.Hooks["SessionStart"]) ||
claudesettings.HasClaudeSyncHook(s.Hooks["Stop"])

s.Hooks["SessionStart"] = claudesettings.RemoveClaudeSyncHooks(s.Hooks["SessionStart"])
s.Hooks["Stop"] = claudesettings.RemoveClaudeSyncHooks(s.Hooks["Stop"])

if !before {
fmt.Println("Auto-sync hooks were not installed.")
return nil
}

if err := claudesettings.Save(s, raw); err != nil {
return fmt.Errorf("failed to save Claude settings: %w", err)
}

fmt.Println("Auto-sync hooks removed.")
return nil
},
}
}

func autoStatusCmd() *cobra.Command {
return &cobra.Command{
Use: "status",
Short: "Show auto-sync hook status",
RunE: func(cmd *cobra.Command, args []string) error {
s, _, err := claudesettings.Load()
if err != nil {
return fmt.Errorf("failed to load Claude settings: %w", err)
}

hasStart := claudesettings.HasClaudeSyncHook(s.Hooks["SessionStart"])
hasStop := claudesettings.HasClaudeSyncHook(s.Hooks["Stop"])

if hasStart || hasStop {
fmt.Println("Auto-sync hooks: enabled")
if hasStart {
fmt.Println(" SessionStart → claude-sync pull -q")
}
if hasStop {
fmt.Println(" Stop → claude-sync push -q")
}
} else {
fmt.Println("Auto-sync hooks: not installed")
fmt.Println("Run 'claude-sync auto enable' to install.")
}
return nil
},
}
}
141 changes: 141 additions & 0 deletions internal/claudesettings/settings.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
package claudesettings

import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
)

// HookEntry represents a single hook command entry.
type HookEntry struct {
Type string `json:"type"`
Command string `json:"command"`
}

// HookGroup represents a matcher + list of hooks.
type HookGroup struct {
Matcher string `json:"matcher"`
Hooks []HookEntry `json:"hooks"`
}

// Settings holds the parsed hooks section of ~/.claude/settings.json.
type Settings struct {
Hooks map[string][]HookGroup
}

// SettingsPath returns the path to ~/.claude/settings.json.
func SettingsPath() (string, error) {
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
return filepath.Join(home, ".claude", "settings.json"), nil
}

// Load reads ~/.claude/settings.json, returning the parsed hooks and the full
// raw map so that unknown fields are preserved on Save.
func Load() (*Settings, map[string]json.RawMessage, error) {
path, err := SettingsPath()
if err != nil {
return nil, nil, err
}

data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return &Settings{Hooks: make(map[string][]HookGroup)}, make(map[string]json.RawMessage), nil
}
return nil, nil, fmt.Errorf("reading settings: %w", err)
}

// Decode all fields as raw JSON to preserve unknown fields.
var raw map[string]json.RawMessage
if err := json.Unmarshal(data, &raw); err != nil {
return nil, nil, fmt.Errorf("parsing settings: %w", err)
}
if raw == nil {
raw = make(map[string]json.RawMessage)
}

s := &Settings{Hooks: make(map[string][]HookGroup)}
if hooksRaw, ok := raw["hooks"]; ok {
if err := json.Unmarshal(hooksRaw, &s.Hooks); err != nil {
return nil, nil, fmt.Errorf("parsing hooks: %w", err)
}
}

return s, raw, nil
}

// Save writes the settings back to ~/.claude/settings.json, preserving all
// fields that were in the original file.
func Save(s *Settings, raw map[string]json.RawMessage) error {
path, err := SettingsPath()
if err != nil {
return err
}

// Ensure the directory exists.
if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
return fmt.Errorf("creating .claude directory: %w", err)
}

hooksData, err := json.Marshal(s.Hooks)
if err != nil {
return fmt.Errorf("marshalling hooks: %w", err)
}
if raw == nil {
raw = make(map[string]json.RawMessage)
}
raw["hooks"] = json.RawMessage(hooksData)

data, err := json.MarshalIndent(raw, "", " ")
if err != nil {
return fmt.Errorf("marshalling settings: %w", err)
}

return os.WriteFile(path, append(data, '\n'), 0600)
}

// HasClaudeSyncHook returns true if any hook entry in groups has a command
// containing "claude-sync".
func HasClaudeSyncHook(groups []HookGroup) bool {
for _, g := range groups {
for _, h := range g.Hooks {
if strings.Contains(h.Command, "claude-sync") {
return true
}
}
}
return false
}

// AddHook appends a new hook group with a single command entry.
func AddHook(groups []HookGroup, command string) []HookGroup {
return append(groups, HookGroup{
Matcher: "",
Hooks: []HookEntry{{Type: "command", Command: command}},
})
}

// RemoveClaudeSyncHooks removes any hook entries whose command contains
// "claude-sync". Groups that become empty are dropped entirely.
func RemoveClaudeSyncHooks(groups []HookGroup) []HookGroup {
var result []HookGroup
for _, g := range groups {
var kept []HookEntry
for _, h := range g.Hooks {
if !strings.Contains(h.Command, "claude-sync") {
kept = append(kept, h)
}
}
if len(kept) > 0 {
g.Hooks = kept
result = append(result, g)
}
// Drop the group entirely if all hooks were claude-sync.
}
return result
}