fix: critical hooks bug - preserve all Claude Code settings

BREAKING BUG FIX: Hook installation was destroying existing Claude Code settings

- Fix critical data loss in --install-hook and --uninstall-hook commands
- Change ClaudeSettings to preserve ALL existing JSON settings via Data map
- Add automatic timestamped backup creation before any settings modification
- Implement surgical hook installation that adds to existing config structure
- Implement surgical hook removal that only removes shush hooks
- Prevent loss of user settings like includeCoAuthoredBy, themes, etc.
- Add comprehensive error handling for malformed settings files

This was a critical data loss bug that could destroy users' Claude Code configurations.
All existing settings are now preserved during hook operations.

Fixes: Users losing their Claude Code settings during hook installation
Added: Automatic backups with timestamp (settings.json.backup.TIMESTAMP)
Improved: Hook management now respects existing PostToolUse configurations
This commit is contained in:
carraes
2025-07-07 14:18:59 -03:00
parent 3aff5e644a
commit 9109964235
4 changed files with 297 additions and 65 deletions
+27 -4
View File
@@ -17,6 +17,24 @@ Download the binary for your platform from the [releases page](https://github.co
## Usage
### 🤖 Claude Code Integration (Most Popular!)
```bash
# Install automatic comment cleanup after Claude modifies files
shush --install-hook # User-wide (all projects)
shush --install-hook -s project # Project-specific only
# Manage hooks
shush --hook-status # Check installation status
shush --list-hooks # Show all configured hooks
shush --uninstall-hook # Remove hooks
# Once installed, comments are automatically cleaned whenever Claude Code
# uses Write, Edit, or MultiEdit tools! Respects your .shush.toml config.
```
### 📂 Basic File Operations
```bash
# Remove all comments from a file
shush file.py
@@ -44,18 +62,23 @@ shush script.py --preserve-lines
# Verbose output
shush app.go --verbose
```
### 🔀 Git-Aware Processing
```bash
# Git-aware processing (only process changed lines)
shush --staged # Clean comments from staged changes
shush --unstaged # Clean comments from unstaged changes
shush --changes-only # Clean comments from all changes
```
# Configuration and hooks management
### ⚙️ Configuration Management
```bash
# Configuration management
shush --create-config # Create .shush.toml configuration
shush --config # Show current configuration
shush --install-hook # Install Claude Code hooks (user-wide)
shush --install-hook -s project # Install project-specific hooks
shush --hook-status # Check hook installation status
# Combine flags for complex operations
shush src/ --recursive --inline --dry-run --verbose
+1 -1
View File
@@ -12,7 +12,7 @@ import (
"github.com/carlosarraes/shush/internal/types"
)
var version = "0.2.2"
var version = "0.2.3"
func main() {
var cliArgs types.CLI
+52 -7
View File
@@ -62,6 +62,10 @@ func InstallHooks(scope string) error {
return fmt.Errorf("failed to get settings path: %w", err)
}
if err := hooks.CreateBackup(path); err != nil {
return fmt.Errorf("failed to create backup: %w", err)
}
settings, err := hooks.LoadSettings(path)
if err != nil {
return fmt.Errorf("failed to load settings: %w", err)
@@ -109,6 +113,10 @@ func UninstallHooks(scope string) error {
return fmt.Errorf("shush hook not found for %s scope at %s", scopeName, path)
}
if err := hooks.CreateBackup(path); err != nil {
return fmt.Errorf("failed to create backup: %w", err)
}
if _, err := exec.LookPath("jq"); err == nil {
if err := uninstallWithJQ(path, scopeName); err == nil {
return nil
@@ -222,19 +230,56 @@ func listHooksForPath(path string) error {
return err
}
if len(settings.Hooks) == 0 {
hooksData, exists := settings.Data["hooks"]
if !exists {
return fmt.Errorf("no hooks")
}
hooksMap, ok := hooksData.(map[string]interface{})
if !ok {
return fmt.Errorf("invalid hooks format")
}
if len(hooksMap) == 0 {
return fmt.Errorf("no hooks")
}
hasShush := hooks.HasShushHook(settings)
for event, configs := range settings.Hooks {
for _, config := range configs {
for _, hook := range config.Hooks {
if hook.Command == "shush --changes-only" {
fmt.Printf(" ✓ shush --changes-only (%s: %s)\n", event, config.Matcher)
for event, eventData := range hooksMap {
configs, ok := eventData.([]interface{})
if !ok {
continue
}
for _, configData := range configs {
config, ok := configData.(map[string]interface{})
if !ok {
continue
}
matcher, _ := config["matcher"].(string)
hooksData, exists := config["hooks"]
if !exists {
continue
}
hooks, ok := hooksData.([]interface{})
if !ok {
continue
}
for _, hookData := range hooks {
hook, ok := hookData.(map[string]interface{})
if !ok {
continue
}
command, _ := hook["command"].(string)
if command == "shush --changes-only" {
fmt.Printf(" ✓ shush --changes-only (%s: %s)\n", event, matcher)
} else {
fmt.Printf(" ✓ %s (%s: %s)\n", hook.Command, event, config.Matcher)
fmt.Printf(" ✓ %s (%s: %s)\n", command, event, matcher)
}
}
}
+217 -53
View File
@@ -7,6 +7,7 @@ import (
"os"
"path/filepath"
"strings"
"time"
)
type HookScope int
@@ -17,7 +18,7 @@ const (
)
type ClaudeSettings struct {
Hooks map[string][]EventConfig `json:"hooks,omitempty"`
Data map[string]interface{} `json:"-"`
}
type EventConfig struct {
@@ -58,28 +59,50 @@ func EnsureSettingsDirectory(settingsPath string) error {
return nil
}
func CreateBackup(settingsPath string) error {
if _, err := os.Stat(settingsPath); os.IsNotExist(err) {
return nil
}
timestamp := time.Now().Unix()
backupPath := fmt.Sprintf("%s.backup.%d", settingsPath, timestamp)
data, err := os.ReadFile(settingsPath)
if err != nil {
return fmt.Errorf("failed to read original settings for backup: %w", err)
}
if err := os.WriteFile(backupPath, data, 0644); err != nil {
return fmt.Errorf("failed to create backup at %s: %w", backupPath, err)
}
fmt.Printf("✓ Backup created: %s\n", backupPath)
return nil
}
func LoadSettings(path string) (*ClaudeSettings, error) {
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return &ClaudeSettings{
Hooks: make(map[string][]EventConfig),
Data: make(map[string]interface{}),
}, nil
}
return nil, fmt.Errorf("failed to read settings file %s: %w", path, err)
}
var settings ClaudeSettings
if err := json.Unmarshal(data, &settings); err != nil {
var allSettings map[string]interface{}
if err := json.Unmarshal(data, &allSettings); err != nil {
return nil, fmt.Errorf("failed to parse settings file %s: %w", path, err)
}
if settings.Hooks == nil {
settings.Hooks = make(map[string][]EventConfig)
if allSettings == nil {
allSettings = make(map[string]interface{})
}
return &settings, nil
return &ClaudeSettings{
Data: allSettings,
}, nil
}
func SaveSettings(path string, settings *ClaudeSettings) error {
@@ -87,7 +110,7 @@ func SaveSettings(path string, settings *ClaudeSettings) error {
return err
}
data, err := json.MarshalIndent(settings, "", " ")
data, err := json.MarshalIndent(settings.Data, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal settings: %w", err)
}
@@ -115,18 +138,55 @@ func CreateShushEventConfig() EventConfig {
}
func HasShushHook(settings *ClaudeSettings) bool {
if settings.Hooks == nil {
return false
}
postToolUseConfigs, exists := settings.Hooks["PostToolUse"]
hooksData, exists := settings.Data["hooks"]
if !exists {
return false
}
for _, config := range postToolUseConfigs {
for _, hook := range config.Hooks {
if strings.Contains(hook.Command, "shush --changes-only") {
hooksMap, ok := hooksData.(map[string]interface{})
if !ok {
return false
}
postToolUseData, exists := hooksMap["PostToolUse"]
if !exists {
return false
}
postToolUseConfigs, ok := postToolUseData.([]interface{})
if !ok {
return false
}
for _, configData := range postToolUseConfigs {
config, ok := configData.(map[string]interface{})
if !ok {
continue
}
hooksData, exists := config["hooks"]
if !exists {
continue
}
hooks, ok := hooksData.([]interface{})
if !ok {
continue
}
for _, hookData := range hooks {
hook, ok := hookData.(map[string]interface{})
if !ok {
continue
}
command, exists := hook["command"]
if !exists {
continue
}
commandStr, ok := command.(string)
if ok && strings.Contains(commandStr, "shush --changes-only") {
return true
}
}
@@ -136,71 +196,175 @@ func HasShushHook(settings *ClaudeSettings) bool {
}
func AddShushHook(settings *ClaudeSettings) error {
if settings.Hooks == nil {
settings.Hooks = make(map[string][]EventConfig)
}
if HasShushHook(settings) {
return errors.New("shush hook already installed")
}
postToolUseConfigs, exists := settings.Hooks["PostToolUse"]
if !exists {
if _, exists := settings.Data["hooks"]; !exists {
settings.Data["hooks"] = make(map[string]interface{})
}
settings.Hooks["PostToolUse"] = []EventConfig{CreateShushEventConfig()}
hooksMap := settings.Data["hooks"].(map[string]interface{})
shushHook := map[string]interface{}{
"type": "command",
"command": "shush --changes-only",
"timeout": 30,
}
if postToolUseData, exists := hooksMap["PostToolUse"]; exists {
postToolUseConfigs, ok := postToolUseData.([]interface{})
if !ok {
return errors.New("invalid PostToolUse format in existing settings")
}
for _, configData := range postToolUseConfigs {
config, ok := configData.(map[string]interface{})
if !ok {
continue
}
matcher, exists := config["matcher"]
if !exists {
continue
}
matcherStr, ok := matcher.(string)
if !ok {
continue
}
if matcherStr == "Write|Edit|MultiEdit" {
hooksData, exists := config["hooks"]
if !exists {
config["hooks"] = []interface{}{shushHook}
return nil
}
hooks, ok := hooksData.([]interface{})
if !ok {
return errors.New("invalid hooks format in existing settings")
}
config["hooks"] = append(hooks, shushHook)
return nil
}
}
newConfig := map[string]interface{}{
"matcher": "Write|Edit|MultiEdit",
"hooks": []interface{}{shushHook},
}
hooksMap["PostToolUse"] = append(postToolUseConfigs, newConfig)
return nil
}
for i, config := range postToolUseConfigs {
if config.Matcher == "Write|Edit|MultiEdit" || config.Matcher == "" {
postToolUseConfigs[i].Hooks = append(config.Hooks, CreateShushHookEntry())
return nil
}
newConfig := map[string]interface{}{
"matcher": "Write|Edit|MultiEdit",
"hooks": []interface{}{shushHook},
}
settings.Hooks["PostToolUse"] = append(postToolUseConfigs, CreateShushEventConfig())
hooksMap["PostToolUse"] = []interface{}{newConfig}
return nil
}
func RemoveShushHook(settings *ClaudeSettings) error {
if settings.Hooks == nil {
return errors.New("no hooks configuration found")
}
postToolUseConfigs, exists := settings.Hooks["PostToolUse"]
if !exists {
if !HasShushHook(settings) {
return errors.New("shush hook not found")
}
hooksData, exists := settings.Data["hooks"]
if !exists {
return errors.New("no hooks configuration found")
}
hooksMap, ok := hooksData.(map[string]interface{})
if !ok {
return errors.New("invalid hooks format")
}
postToolUseData, exists := hooksMap["PostToolUse"]
if !exists {
return errors.New("no PostToolUse hooks found")
}
postToolUseConfigs, ok := postToolUseData.([]interface{})
if !ok {
return errors.New("invalid PostToolUse format")
}
found := false
for i, config := range postToolUseConfigs {
newHooks := make([]HookEntry, 0, len(config.Hooks))
for _, hook := range config.Hooks {
if !strings.Contains(hook.Command, "shush --changes-only") {
newHooks = append(newHooks, hook)
} else {
var newConfigs []interface{}
for _, configData := range postToolUseConfigs {
config, ok := configData.(map[string]interface{})
if !ok {
newConfigs = append(newConfigs, configData)
continue
}
hooksData, exists := config["hooks"]
if !exists {
newConfigs = append(newConfigs, configData)
continue
}
hooks, ok := hooksData.([]interface{})
if !ok {
newConfigs = append(newConfigs, configData)
continue
}
var newHooks []interface{}
for _, hookData := range hooks {
hook, ok := hookData.(map[string]interface{})
if !ok {
newHooks = append(newHooks, hookData)
continue
}
command, exists := hook["command"]
if !exists {
newHooks = append(newHooks, hookData)
continue
}
commandStr, ok := command.(string)
if !ok {
newHooks = append(newHooks, hookData)
continue
}
if strings.Contains(commandStr, "shush --changes-only") {
found = true
} else {
newHooks = append(newHooks, hookData)
}
}
postToolUseConfigs[i].Hooks = newHooks
if len(newHooks) > 0 {
newConfig := make(map[string]interface{})
for k, v := range config {
newConfig[k] = v
}
newConfig["hooks"] = newHooks
newConfigs = append(newConfigs, newConfig)
}
}
if !found {
return errors.New("shush hook not found")
}
filteredConfigs := make([]EventConfig, 0, len(postToolUseConfigs))
for _, config := range postToolUseConfigs {
if len(config.Hooks) > 0 {
filteredConfigs = append(filteredConfigs, config)
}
if len(newConfigs) == 0 {
delete(hooksMap, "PostToolUse")
} else {
hooksMap["PostToolUse"] = newConfigs
}
if len(filteredConfigs) == 0 {
delete(settings.Hooks, "PostToolUse")
} else {
settings.Hooks["PostToolUse"] = filteredConfigs
if len(hooksMap) == 0 {
delete(settings.Data, "hooks")
}
return nil