feat(doctor): add auto-fix capability to SessionHookCheck (#857)

Add Fix() method to SessionHookCheck to automatically update
settings.json files when 'gt prime' is used without '--hook'.
This enables 'gt doctor --fix' to repair existing installations
that use bare 'gt prime' in SessionStart/PreCompact hooks.

Changes:
- Changed SessionHookCheck to embed FixableCheck instead of BaseCheck
- Added filesToFix cache populated during Run()
- Implemented Fix() method that parses JSON and replaces 'gt prime'
  with 'gt prime --hook' in command strings
- Uses json.Encoder with SetEscapeHTML(false) to preserve readable
  ampersands in command strings

Closes: gt-1tj0c

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
aleiby
2026-01-21 19:11:34 -08:00
committed by GitHub
parent 3931d10af3
commit 5791cd7e34
2 changed files with 239 additions and 9 deletions
+110 -6
View File
@@ -2,6 +2,7 @@ package doctor
import (
"bufio"
"encoding/json"
"fmt"
"os"
"os/exec"
@@ -275,16 +276,19 @@ func (c *SettingsCheck) findRigs(townRoot string) []string {
// Valid options: session-start.sh wrapper OR 'gt prime --hook'.
// Without proper config, gt seance cannot discover sessions.
type SessionHookCheck struct {
BaseCheck
FixableCheck
filesToFix []string // Cached during Run for use in Fix
}
// NewSessionHookCheck creates a new session hook check.
func NewSessionHookCheck() *SessionHookCheck {
return &SessionHookCheck{
BaseCheck: BaseCheck{
CheckName: "session-hooks",
CheckDescription: "Check that settings.json hooks use session-start.sh or --hook flag",
CheckCategory: CategoryConfig,
FixableCheck: FixableCheck{
BaseCheck: BaseCheck{
CheckName: "session-hooks",
CheckDescription: "Check that settings.json hooks use session-start.sh or --hook flag",
CheckCategory: CategoryConfig,
},
},
}
}
@@ -294,6 +298,9 @@ func (c *SessionHookCheck) Run(ctx *CheckContext) *CheckResult {
var issues []string
var checked int
// Reset cache
c.filesToFix = nil
// Find all settings.json files in the town
settingsFiles := c.findSettingsFiles(ctx.TownRoot)
@@ -305,6 +312,8 @@ func (c *SessionHookCheck) Run(ctx *CheckContext) *CheckResult {
for _, problem := range problems {
issues = append(issues, fmt.Sprintf("%s: %s", relPath, problem))
}
// Cache file for Fix
c.filesToFix = append(c.filesToFix, settingsPath)
}
checked++
}
@@ -322,10 +331,105 @@ func (c *SessionHookCheck) Run(ctx *CheckContext) *CheckResult {
Status: StatusWarning,
Message: fmt.Sprintf("%d hook issue(s) found across settings.json files", len(issues)),
Details: issues,
FixHint: "Update hooks to use 'gt prime --hook' or 'bash ~/.claude/hooks/session-start.sh' for session_id passthrough",
FixHint: "Run 'gt doctor --fix' to update hooks to use 'gt prime --hook'",
}
}
// Fix updates settings.json files to use 'gt prime --hook' instead of bare 'gt prime'.
func (c *SessionHookCheck) Fix(ctx *CheckContext) error {
for _, path := range c.filesToFix {
if err := c.fixSettingsFile(path); err != nil {
return fmt.Errorf("failed to fix %s: %w", path, err)
}
}
return nil
}
// fixSettingsFile updates a single settings.json file.
func (c *SessionHookCheck) fixSettingsFile(path string) error {
// Read file
data, err := os.ReadFile(path)
if err != nil {
return err
}
// Parse JSON to get structure
var settings map[string]interface{}
if err := json.Unmarshal(data, &settings); err != nil {
return fmt.Errorf("invalid JSON: %w", err)
}
// Get hooks section
hooks, ok := settings["hooks"].(map[string]interface{})
if !ok {
return nil // No hooks section, nothing to fix
}
modified := false
// Fix SessionStart and PreCompact hooks
for _, hookType := range []string{"SessionStart", "PreCompact"} {
hookList, ok := hooks[hookType].([]interface{})
if !ok {
continue
}
for _, hookEntry := range hookList {
entry, ok := hookEntry.(map[string]interface{})
if !ok {
continue
}
hooksList, ok := entry["hooks"].([]interface{})
if !ok {
continue
}
for _, hook := range hooksList {
hookMap, ok := hook.(map[string]interface{})
if !ok {
continue
}
command, ok := hookMap["command"].(string)
if !ok {
continue
}
// Check if command has 'gt prime' without --hook
if strings.Contains(command, "gt prime") && !containsFlag(command, "--hook") {
// Replace 'gt prime' with 'gt prime --hook'
newCommand := strings.Replace(command, "gt prime", "gt prime --hook", -1)
hookMap["command"] = newCommand
modified = true
}
}
}
}
if !modified {
return nil
}
// Marshal back to JSON with indentation, without HTML escaping
// (json.MarshalIndent escapes & as \u0026 which is valid but less readable)
buf := new(strings.Builder)
encoder := json.NewEncoder(buf)
encoder.SetEscapeHTML(false)
encoder.SetIndent("", " ")
if err := encoder.Encode(settings); err != nil {
return fmt.Errorf("failed to marshal JSON: %w", err)
}
newData := []byte(buf.String())
// Write back
if err := os.WriteFile(path, newData, 0644); err != nil {
return fmt.Errorf("failed to write file: %w", err)
}
return nil
}
// checkSettingsFile checks a single settings.json file for hook issues.
func (c *SessionHookCheck) checkSettingsFile(path string) []string {
var problems []string