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:
@@ -2,6 +2,7 @@ package doctor
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
@@ -275,16 +276,19 @@ func (c *SettingsCheck) findRigs(townRoot string) []string {
|
|||||||
// Valid options: session-start.sh wrapper OR 'gt prime --hook'.
|
// Valid options: session-start.sh wrapper OR 'gt prime --hook'.
|
||||||
// Without proper config, gt seance cannot discover sessions.
|
// Without proper config, gt seance cannot discover sessions.
|
||||||
type SessionHookCheck struct {
|
type SessionHookCheck struct {
|
||||||
BaseCheck
|
FixableCheck
|
||||||
|
filesToFix []string // Cached during Run for use in Fix
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewSessionHookCheck creates a new session hook check.
|
// NewSessionHookCheck creates a new session hook check.
|
||||||
func NewSessionHookCheck() *SessionHookCheck {
|
func NewSessionHookCheck() *SessionHookCheck {
|
||||||
return &SessionHookCheck{
|
return &SessionHookCheck{
|
||||||
BaseCheck: BaseCheck{
|
FixableCheck: FixableCheck{
|
||||||
CheckName: "session-hooks",
|
BaseCheck: BaseCheck{
|
||||||
CheckDescription: "Check that settings.json hooks use session-start.sh or --hook flag",
|
CheckName: "session-hooks",
|
||||||
CheckCategory: CategoryConfig,
|
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 issues []string
|
||||||
var checked int
|
var checked int
|
||||||
|
|
||||||
|
// Reset cache
|
||||||
|
c.filesToFix = nil
|
||||||
|
|
||||||
// Find all settings.json files in the town
|
// Find all settings.json files in the town
|
||||||
settingsFiles := c.findSettingsFiles(ctx.TownRoot)
|
settingsFiles := c.findSettingsFiles(ctx.TownRoot)
|
||||||
|
|
||||||
@@ -305,6 +312,8 @@ func (c *SessionHookCheck) Run(ctx *CheckContext) *CheckResult {
|
|||||||
for _, problem := range problems {
|
for _, problem := range problems {
|
||||||
issues = append(issues, fmt.Sprintf("%s: %s", relPath, problem))
|
issues = append(issues, fmt.Sprintf("%s: %s", relPath, problem))
|
||||||
}
|
}
|
||||||
|
// Cache file for Fix
|
||||||
|
c.filesToFix = append(c.filesToFix, settingsPath)
|
||||||
}
|
}
|
||||||
checked++
|
checked++
|
||||||
}
|
}
|
||||||
@@ -322,10 +331,105 @@ func (c *SessionHookCheck) Run(ctx *CheckContext) *CheckResult {
|
|||||||
Status: StatusWarning,
|
Status: StatusWarning,
|
||||||
Message: fmt.Sprintf("%d hook issue(s) found across settings.json files", len(issues)),
|
Message: fmt.Sprintf("%d hook issue(s) found across settings.json files", len(issues)),
|
||||||
Details: 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.
|
// checkSettingsFile checks a single settings.json file for hook issues.
|
||||||
func (c *SessionHookCheck) checkSettingsFile(path string) []string {
|
func (c *SessionHookCheck) checkSettingsFile(path string) []string {
|
||||||
var problems []string
|
var problems []string
|
||||||
|
|||||||
@@ -228,11 +228,137 @@ func TestSessionHookCheck_Run(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSessionHookCheck_Fix(t *testing.T) {
|
||||||
|
t.Run("fixes bare gt prime to gt prime --hook", func(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
claudeDir := filepath.Join(tmpDir, ".claude")
|
||||||
|
if err := os.MkdirAll(claudeDir, 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create settings with bare gt prime
|
||||||
|
settings := `{"hooks": {"SessionStart": [{"hooks": [{"type": "command", "command": "gt prime"}]}]}}`
|
||||||
|
settingsPath := filepath.Join(claudeDir, "settings.json")
|
||||||
|
if err := os.WriteFile(settingsPath, []byte(settings), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
check := NewSessionHookCheck()
|
||||||
|
ctx := &CheckContext{TownRoot: tmpDir}
|
||||||
|
|
||||||
|
// Run to detect issue and cache file
|
||||||
|
result := check.Run(ctx)
|
||||||
|
if result.Status != StatusWarning {
|
||||||
|
t.Errorf("expected StatusWarning before fix, got %v", result.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply fix
|
||||||
|
if err := check.Fix(ctx); err != nil {
|
||||||
|
t.Fatalf("Fix failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-run to verify fix
|
||||||
|
result = check.Run(ctx)
|
||||||
|
if result.Status != StatusOK {
|
||||||
|
t.Errorf("expected StatusOK after fix, got %v: %v", result.Status, result.Details)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify file content
|
||||||
|
data, err := os.ReadFile(settingsPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
content := string(data)
|
||||||
|
if !strings.Contains(content, "gt prime --hook") {
|
||||||
|
t.Errorf("expected 'gt prime --hook' in fixed file, got: %s", content)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("fixes multiple hooks in same file", func(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
claudeDir := filepath.Join(tmpDir, ".claude")
|
||||||
|
if err := os.MkdirAll(claudeDir, 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
settings := `{"hooks": {"SessionStart": [{"hooks": [{"type": "command", "command": "gt prime && echo done"}]}], "PreCompact": [{"hooks": [{"type": "command", "command": "gt prime"}]}]}}`
|
||||||
|
settingsPath := filepath.Join(claudeDir, "settings.json")
|
||||||
|
if err := os.WriteFile(settingsPath, []byte(settings), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
check := NewSessionHookCheck()
|
||||||
|
ctx := &CheckContext{TownRoot: tmpDir}
|
||||||
|
|
||||||
|
result := check.Run(ctx)
|
||||||
|
if result.Status != StatusWarning {
|
||||||
|
t.Errorf("expected StatusWarning before fix, got %v", result.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := check.Fix(ctx); err != nil {
|
||||||
|
t.Fatalf("Fix failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
result = check.Run(ctx)
|
||||||
|
if result.Status != StatusOK {
|
||||||
|
t.Errorf("expected StatusOK after fix, got %v: %v", result.Status, result.Details)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := os.ReadFile(settingsPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
content := string(data)
|
||||||
|
// Both hooks should now have --hook
|
||||||
|
if strings.Count(content, "gt prime --hook") != 2 {
|
||||||
|
t.Errorf("expected 2 occurrences of 'gt prime --hook', got content: %s", content)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("does not double-add --hook", func(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
claudeDir := filepath.Join(tmpDir, ".claude")
|
||||||
|
if err := os.MkdirAll(claudeDir, 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
settings := `{"hooks": {"SessionStart": [{"hooks": [{"type": "command", "command": "gt prime --hook"}]}]}}`
|
||||||
|
settingsPath := filepath.Join(claudeDir, "settings.json")
|
||||||
|
if err := os.WriteFile(settingsPath, []byte(settings), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
check := NewSessionHookCheck()
|
||||||
|
ctx := &CheckContext{TownRoot: tmpDir}
|
||||||
|
|
||||||
|
// Should already be OK
|
||||||
|
result := check.Run(ctx)
|
||||||
|
if result.Status != StatusOK {
|
||||||
|
t.Errorf("expected StatusOK for already-fixed file, got %v", result.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fix should be no-op (no files cached)
|
||||||
|
if err := check.Fix(ctx); err != nil {
|
||||||
|
t.Fatalf("Fix failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := os.ReadFile(settingsPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
content := string(data)
|
||||||
|
// Should not have --hook --hook
|
||||||
|
if strings.Contains(content, "--hook --hook") {
|
||||||
|
t.Errorf("fix doubled --hook flag: %s", content)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func TestParseConfigOutput(t *testing.T) {
|
func TestParseConfigOutput(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
input string
|
input string
|
||||||
want string
|
want string
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "simple value",
|
name: "simple value",
|
||||||
|
|||||||
Reference in New Issue
Block a user