From fa6ad68a282faa8e2054b6ab0972837c2eb058fb Mon Sep 17 00:00:00 2001 From: jimjawn <53662003+jimjawn@users.noreply.github.com> Date: Fri, 12 Jun 2026 17:55:09 +0000 Subject: [PATCH] feat: implement claude-sync auto enable/disable/status hooks Add `claude-sync auto enable/disable/status` subcommands that install and remove claude-sync pull/push hooks in ~/.claude/settings.json, merging with existing hooks without clobbering them. Backed by new internal/claudesettings package that round-trips the full settings file via a raw JSON map. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- cmd/claude-sync/main.go | 112 ++++++++++++++++++++++ internal/claudesettings/settings.go | 141 ++++++++++++++++++++++++++++ 2 files changed, 253 insertions(+) create mode 100644 internal/claudesettings/settings.go diff --git a/cmd/claude-sync/main.go b/cmd/claude-sync/main.go index af810cf..b4850f5 100644 --- a/cmd/claude-sync/main.go +++ b/cmd/claude-sync/main.go @@ -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" @@ -70,6 +71,7 @@ func main() { updateCmd(), changelogCmd(), mcpCmd(), + autoCmd(), ) if err := rootCmd.Execute(); err != nil { @@ -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 + }, + } +} diff --git a/internal/claudesettings/settings.go b/internal/claudesettings/settings.go new file mode 100644 index 0000000..f5c8b80 --- /dev/null +++ b/internal/claudesettings/settings.go @@ -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 +}