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>
443 lines
13 KiB
Go
443 lines
13 KiB
Go
package doctor
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/steveyegge/gastown/internal/constants"
|
|
)
|
|
|
|
func TestSessionHookCheck_UsesSessionStartScript(t *testing.T) {
|
|
check := NewSessionHookCheck()
|
|
|
|
tests := []struct {
|
|
name string
|
|
content string
|
|
hookType string
|
|
want bool
|
|
}{
|
|
{
|
|
name: "bare gt prime fails",
|
|
content: `{"hooks": {"SessionStart": [{"hooks": [{"type": "command", "command": "gt prime"}]}]}}`,
|
|
hookType: "SessionStart",
|
|
want: false,
|
|
},
|
|
{
|
|
name: "gt prime --hook passes",
|
|
content: `{"hooks": {"SessionStart": [{"hooks": [{"type": "command", "command": "gt prime --hook"}]}]}}`,
|
|
hookType: "SessionStart",
|
|
want: true,
|
|
},
|
|
{
|
|
name: "session-start.sh passes",
|
|
content: `{"hooks": {"SessionStart": [{"hooks": [{"type": "command", "command": "bash ~/.claude/hooks/session-start.sh"}]}]}}`,
|
|
hookType: "SessionStart",
|
|
want: true,
|
|
},
|
|
{
|
|
name: "no SessionStart hook passes",
|
|
content: `{"hooks": {"Stop": [{"hooks": [{"type": "command", "command": "gt handoff"}]}]}}`,
|
|
hookType: "SessionStart",
|
|
want: true,
|
|
},
|
|
{
|
|
name: "PreCompact with --hook passes",
|
|
content: `{"hooks": {"PreCompact": [{"hooks": [{"type": "command", "command": "gt prime --hook"}]}]}}`,
|
|
hookType: "PreCompact",
|
|
want: true,
|
|
},
|
|
{
|
|
name: "PreCompact bare gt prime fails",
|
|
content: `{"hooks": {"PreCompact": [{"hooks": [{"type": "command", "command": "gt prime"}]}]}}`,
|
|
hookType: "PreCompact",
|
|
want: false,
|
|
},
|
|
{
|
|
name: "gt prime --hook with extra flags passes",
|
|
content: `{"hooks": {"SessionStart": [{"hooks": [{"type": "command", "command": "gt prime --hook --verbose"}]}]}}`,
|
|
hookType: "SessionStart",
|
|
want: true,
|
|
},
|
|
{
|
|
name: "gt prime with --hook not first still passes",
|
|
content: `{"hooks": {"SessionStart": [{"hooks": [{"type": "command", "command": "gt prime --verbose --hook"}]}]}}`,
|
|
hookType: "SessionStart",
|
|
want: true,
|
|
},
|
|
{
|
|
name: "gt prime with other flags but no --hook fails",
|
|
content: `{"hooks": {"SessionStart": [{"hooks": [{"type": "command", "command": "gt prime --verbose"}]}]}}`,
|
|
hookType: "SessionStart",
|
|
want: false,
|
|
},
|
|
{
|
|
name: "both session-start.sh and gt prime passes (session-start.sh wins)",
|
|
content: `{"hooks": {"SessionStart": [{"hooks": [{"type": "command", "command": "bash session-start.sh && gt prime"}]}]}}`,
|
|
hookType: "SessionStart",
|
|
want: true,
|
|
},
|
|
{
|
|
name: "gt prime --hookup is NOT valid (false positive check)",
|
|
content: `{"hooks": {"SessionStart": [{"hooks": [{"type": "command", "command": "gt prime --hookup"}]}]}}`,
|
|
hookType: "SessionStart",
|
|
want: false,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := check.usesSessionStartScript(tt.content, tt.hookType)
|
|
if got != tt.want {
|
|
t.Errorf("usesSessionStartScript() = %v, want %v", got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestSessionHookCheck_Run(t *testing.T) {
|
|
t.Run("bare gt prime warns", 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"}]}]}}`
|
|
if err := os.WriteFile(filepath.Join(claudeDir, "settings.json"), []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, got %v", result.Status)
|
|
}
|
|
})
|
|
|
|
t.Run("gt prime --hook passes", 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"}]}]}}`
|
|
if err := os.WriteFile(filepath.Join(claudeDir, "settings.json"), []byte(settings), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
check := NewSessionHookCheck()
|
|
ctx := &CheckContext{TownRoot: tmpDir}
|
|
result := check.Run(ctx)
|
|
|
|
if result.Status != StatusOK {
|
|
t.Errorf("expected StatusOK, got %v: %v", result.Status, result.Details)
|
|
}
|
|
})
|
|
|
|
t.Run("rig-level settings with --hook passes", func(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
|
|
rigDir := filepath.Join(tmpDir, "myrig")
|
|
if err := os.MkdirAll(filepath.Join(rigDir, "crew"), 0755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
claudeDir := filepath.Join(rigDir, ".claude")
|
|
if err := os.MkdirAll(claudeDir, 0755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
settings := `{"hooks": {"SessionStart": [{"hooks": [{"type": "command", "command": "gt prime --hook"}]}]}}`
|
|
if err := os.WriteFile(filepath.Join(claudeDir, "settings.json"), []byte(settings), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
check := NewSessionHookCheck()
|
|
ctx := &CheckContext{TownRoot: tmpDir}
|
|
result := check.Run(ctx)
|
|
|
|
if result.Status != StatusOK {
|
|
t.Errorf("expected StatusOK for rig-level settings, got %v: %v", result.Status, result.Details)
|
|
}
|
|
})
|
|
|
|
t.Run("rig-level bare gt prime warns", func(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
|
|
rigDir := filepath.Join(tmpDir, "myrig")
|
|
if err := os.MkdirAll(filepath.Join(rigDir, "polecats"), 0755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
claudeDir := filepath.Join(rigDir, ".claude")
|
|
if err := os.MkdirAll(claudeDir, 0755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
settings := `{"hooks": {"SessionStart": [{"hooks": [{"type": "command", "command": "gt prime"}]}]}}`
|
|
if err := os.WriteFile(filepath.Join(claudeDir, "settings.json"), []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 for rig-level bare gt prime, got %v", result.Status)
|
|
}
|
|
})
|
|
|
|
t.Run("mixed valid and invalid hooks warns", 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"}]}], "PreCompact": [{"hooks": [{"type": "command", "command": "gt prime"}]}]}}`
|
|
if err := os.WriteFile(filepath.Join(claudeDir, "settings.json"), []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 when PreCompact is invalid, got %v", result.Status)
|
|
}
|
|
if len(result.Details) != 1 {
|
|
t.Errorf("expected 1 issue (PreCompact), got %d: %v", len(result.Details), result.Details)
|
|
}
|
|
})
|
|
|
|
t.Run("no settings files returns OK", func(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
|
|
check := NewSessionHookCheck()
|
|
ctx := &CheckContext{TownRoot: tmpDir}
|
|
result := check.Run(ctx)
|
|
|
|
if result.Status != StatusOK {
|
|
t.Errorf("expected StatusOK when no settings files, got %v", result.Status)
|
|
}
|
|
})
|
|
}
|
|
|
|
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) {
|
|
tests := []struct {
|
|
name string
|
|
input string
|
|
want string
|
|
}{
|
|
{
|
|
name: "simple value",
|
|
input: "agent,role,rig,convoy,slot\n",
|
|
want: "agent,role,rig,convoy,slot",
|
|
},
|
|
{
|
|
name: "value with trailing newlines",
|
|
input: "agent,role,rig,convoy,slot\n\n",
|
|
want: "agent,role,rig,convoy,slot",
|
|
},
|
|
{
|
|
name: "Note prefix filtered",
|
|
input: "Note: No git repository initialized - running without background sync\nagent,role,rig,convoy,slot\n",
|
|
want: "agent,role,rig,convoy,slot",
|
|
},
|
|
{
|
|
name: "multiple Note prefixes filtered",
|
|
input: "Note: First note\nNote: Second note\nagent,role,rig,convoy,slot\n",
|
|
want: "agent,role,rig,convoy,slot",
|
|
},
|
|
{
|
|
name: "empty output",
|
|
input: "",
|
|
want: "",
|
|
},
|
|
{
|
|
name: "only whitespace",
|
|
input: " \n \n",
|
|
want: "",
|
|
},
|
|
{
|
|
name: "Note with different casing is not filtered",
|
|
input: "note: lowercase should not match\n",
|
|
want: "note: lowercase should not match",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := parseConfigOutput([]byte(tt.input))
|
|
if got != tt.want {
|
|
t.Errorf("parseConfigOutput() = %q, want %q", got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCustomTypesCheck_ParsesOutputWithNotePrefix(t *testing.T) {
|
|
// This test verifies that CustomTypesCheck correctly parses bd output
|
|
// that contains "Note:" informational messages before the actual config value.
|
|
// Without proper filtering, the check would see "Note: ..." as the config value
|
|
// and incorrectly report all custom types as missing.
|
|
|
|
// Test the parsing logic directly - this simulates bd outputting:
|
|
// "Note: No git repository initialized - running without background sync"
|
|
// followed by the actual config value
|
|
output := "Note: No git repository initialized - running without background sync\n" + constants.BeadsCustomTypes + "\n"
|
|
parsed := parseConfigOutput([]byte(output))
|
|
|
|
if parsed != constants.BeadsCustomTypes {
|
|
t.Errorf("parseConfigOutput failed to filter Note: prefix\ngot: %q\nwant: %q", parsed, constants.BeadsCustomTypes)
|
|
}
|
|
|
|
// Verify that all required types are found in the parsed output
|
|
configuredSet := make(map[string]bool)
|
|
for _, typ := range strings.Split(parsed, ",") {
|
|
configuredSet[strings.TrimSpace(typ)] = true
|
|
}
|
|
|
|
var missing []string
|
|
for _, required := range constants.BeadsCustomTypesList() {
|
|
if !configuredSet[required] {
|
|
missing = append(missing, required)
|
|
}
|
|
}
|
|
|
|
if len(missing) > 0 {
|
|
t.Errorf("After parsing, missing types: %v", missing)
|
|
}
|
|
}
|