When gt doctor --fix detects stale Claude settings at town root, it was
automatically killing ALL Gas Town sessions (gt-* and hq-*). This is too
disruptive because:
1. Deacon runs gt doctor automatically, creating a restart loop
2. Active crew/polecat work could be lost mid-task
3. Settings are only read at startup, so running agents already have
the config loaded in memory
Instead, warn the user and tell them to restart agents manually:
"Town-root settings were moved. Restart agents to pick up new config:
gt up --restart"
Addresses PR #239 feedback.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1085 lines
30 KiB
Go
1085 lines
30 KiB
Go
package doctor
|
|
|
|
import (
|
|
"encoding/json"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func TestNewClaudeSettingsCheck(t *testing.T) {
|
|
check := NewClaudeSettingsCheck()
|
|
|
|
if check.Name() != "claude-settings" {
|
|
t.Errorf("expected name 'claude-settings', got %q", check.Name())
|
|
}
|
|
|
|
if !check.CanFix() {
|
|
t.Error("expected CanFix to return true")
|
|
}
|
|
}
|
|
|
|
func TestClaudeSettingsCheck_NoSettingsFiles(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
|
|
check := NewClaudeSettingsCheck()
|
|
ctx := &CheckContext{TownRoot: tmpDir}
|
|
|
|
result := check.Run(ctx)
|
|
|
|
if result.Status != StatusOK {
|
|
t.Errorf("expected StatusOK when no settings files, got %v", result.Status)
|
|
}
|
|
}
|
|
|
|
// createValidSettings creates a valid settings.json with all required elements.
|
|
func createValidSettings(t *testing.T, path string) {
|
|
t.Helper()
|
|
|
|
settings := map[string]any{
|
|
"enabledPlugins": []string{"plugin1"},
|
|
"hooks": map[string]any{
|
|
"SessionStart": []any{
|
|
map[string]any{
|
|
"matcher": "**",
|
|
"hooks": []any{
|
|
map[string]any{
|
|
"type": "command",
|
|
"command": "export PATH=/usr/local/bin:$PATH",
|
|
},
|
|
map[string]any{
|
|
"type": "command",
|
|
"command": "gt nudge deacon session-started",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
"Stop": []any{
|
|
map[string]any{
|
|
"matcher": "**",
|
|
"hooks": []any{
|
|
map[string]any{
|
|
"type": "command",
|
|
"command": "gt costs record --session $CLAUDE_SESSION_ID",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
data, err := json.MarshalIndent(settings, "", " ")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if err := os.WriteFile(path, data, 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
// createStaleSettings creates a settings.json missing required elements.
|
|
func createStaleSettings(t *testing.T, path string, missingElements ...string) {
|
|
t.Helper()
|
|
|
|
settings := map[string]any{
|
|
"enabledPlugins": []string{"plugin1"},
|
|
"hooks": map[string]any{
|
|
"SessionStart": []any{
|
|
map[string]any{
|
|
"matcher": "**",
|
|
"hooks": []any{
|
|
map[string]any{
|
|
"type": "command",
|
|
"command": "export PATH=/usr/local/bin:$PATH",
|
|
},
|
|
map[string]any{
|
|
"type": "command",
|
|
"command": "gt nudge deacon session-started",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
"Stop": []any{
|
|
map[string]any{
|
|
"matcher": "**",
|
|
"hooks": []any{
|
|
map[string]any{
|
|
"type": "command",
|
|
"command": "gt costs record --session $CLAUDE_SESSION_ID",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, missing := range missingElements {
|
|
switch missing {
|
|
case "enabledPlugins":
|
|
delete(settings, "enabledPlugins")
|
|
case "hooks":
|
|
delete(settings, "hooks")
|
|
case "PATH":
|
|
// Remove PATH from SessionStart hooks
|
|
hooks := settings["hooks"].(map[string]any)
|
|
sessionStart := hooks["SessionStart"].([]any)
|
|
hookObj := sessionStart[0].(map[string]any)
|
|
innerHooks := hookObj["hooks"].([]any)
|
|
// Filter out PATH command
|
|
var filtered []any
|
|
for _, h := range innerHooks {
|
|
hMap := h.(map[string]any)
|
|
if cmd, ok := hMap["command"].(string); ok && !strings.Contains(cmd, "PATH=") {
|
|
filtered = append(filtered, h)
|
|
}
|
|
}
|
|
hookObj["hooks"] = filtered
|
|
case "deacon-nudge":
|
|
// Remove deacon nudge from SessionStart hooks
|
|
hooks := settings["hooks"].(map[string]any)
|
|
sessionStart := hooks["SessionStart"].([]any)
|
|
hookObj := sessionStart[0].(map[string]any)
|
|
innerHooks := hookObj["hooks"].([]any)
|
|
// Filter out deacon nudge
|
|
var filtered []any
|
|
for _, h := range innerHooks {
|
|
hMap := h.(map[string]any)
|
|
if cmd, ok := hMap["command"].(string); ok && !strings.Contains(cmd, "gt nudge deacon") {
|
|
filtered = append(filtered, h)
|
|
}
|
|
}
|
|
hookObj["hooks"] = filtered
|
|
case "Stop":
|
|
hooks := settings["hooks"].(map[string]any)
|
|
delete(hooks, "Stop")
|
|
}
|
|
}
|
|
|
|
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
data, err := json.MarshalIndent(settings, "", " ")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if err := os.WriteFile(path, data, 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func TestClaudeSettingsCheck_ValidMayorSettings(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
|
|
// Create valid mayor settings at correct location (mayor/.claude/settings.json)
|
|
// NOT at town root (.claude/settings.json) which is wrong location
|
|
mayorSettings := filepath.Join(tmpDir, "mayor", ".claude", "settings.json")
|
|
createValidSettings(t, mayorSettings)
|
|
|
|
check := NewClaudeSettingsCheck()
|
|
ctx := &CheckContext{TownRoot: tmpDir}
|
|
|
|
result := check.Run(ctx)
|
|
|
|
if result.Status != StatusOK {
|
|
t.Errorf("expected StatusOK for valid settings, got %v: %s", result.Status, result.Message)
|
|
}
|
|
}
|
|
|
|
func TestClaudeSettingsCheck_ValidDeaconSettings(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
|
|
// Create valid deacon settings
|
|
deaconSettings := filepath.Join(tmpDir, "deacon", ".claude", "settings.json")
|
|
createValidSettings(t, deaconSettings)
|
|
|
|
check := NewClaudeSettingsCheck()
|
|
ctx := &CheckContext{TownRoot: tmpDir}
|
|
|
|
result := check.Run(ctx)
|
|
|
|
if result.Status != StatusOK {
|
|
t.Errorf("expected StatusOK for valid deacon settings, got %v: %s", result.Status, result.Message)
|
|
}
|
|
}
|
|
|
|
func TestClaudeSettingsCheck_ValidWitnessSettings(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
rigName := "testrig"
|
|
|
|
// Create valid witness settings in correct location (witness/.claude/, outside git repo)
|
|
witnessSettings := filepath.Join(tmpDir, rigName, "witness", ".claude", "settings.json")
|
|
createValidSettings(t, witnessSettings)
|
|
|
|
check := NewClaudeSettingsCheck()
|
|
ctx := &CheckContext{TownRoot: tmpDir}
|
|
|
|
result := check.Run(ctx)
|
|
|
|
if result.Status != StatusOK {
|
|
t.Errorf("expected StatusOK for valid witness settings, got %v: %s", result.Status, result.Message)
|
|
}
|
|
}
|
|
|
|
func TestClaudeSettingsCheck_ValidRefinerySettings(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
rigName := "testrig"
|
|
|
|
// Create valid refinery settings in correct location (refinery/.claude/, outside git repo)
|
|
refinerySettings := filepath.Join(tmpDir, rigName, "refinery", ".claude", "settings.json")
|
|
createValidSettings(t, refinerySettings)
|
|
|
|
check := NewClaudeSettingsCheck()
|
|
ctx := &CheckContext{TownRoot: tmpDir}
|
|
|
|
result := check.Run(ctx)
|
|
|
|
if result.Status != StatusOK {
|
|
t.Errorf("expected StatusOK for valid refinery settings, got %v: %s", result.Status, result.Message)
|
|
}
|
|
}
|
|
|
|
func TestClaudeSettingsCheck_ValidCrewSettings(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
rigName := "testrig"
|
|
|
|
// Create valid crew settings in correct location (crew/.claude/, shared by all crew)
|
|
crewSettings := filepath.Join(tmpDir, rigName, "crew", ".claude", "settings.json")
|
|
createValidSettings(t, crewSettings)
|
|
|
|
check := NewClaudeSettingsCheck()
|
|
ctx := &CheckContext{TownRoot: tmpDir}
|
|
|
|
result := check.Run(ctx)
|
|
|
|
if result.Status != StatusOK {
|
|
t.Errorf("expected StatusOK for valid crew settings, got %v: %s", result.Status, result.Message)
|
|
}
|
|
}
|
|
|
|
func TestClaudeSettingsCheck_ValidPolecatSettings(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
rigName := "testrig"
|
|
|
|
// Create valid polecat settings in correct location (polecats/.claude/, shared by all polecats)
|
|
pcSettings := filepath.Join(tmpDir, rigName, "polecats", ".claude", "settings.json")
|
|
createValidSettings(t, pcSettings)
|
|
|
|
check := NewClaudeSettingsCheck()
|
|
ctx := &CheckContext{TownRoot: tmpDir}
|
|
|
|
result := check.Run(ctx)
|
|
|
|
if result.Status != StatusOK {
|
|
t.Errorf("expected StatusOK for valid polecat settings, got %v: %s", result.Status, result.Message)
|
|
}
|
|
}
|
|
|
|
func TestClaudeSettingsCheck_MissingEnabledPlugins(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
|
|
// Create stale mayor settings missing enabledPlugins (at correct location)
|
|
mayorSettings := filepath.Join(tmpDir, "mayor", ".claude", "settings.json")
|
|
createStaleSettings(t, mayorSettings, "enabledPlugins")
|
|
|
|
check := NewClaudeSettingsCheck()
|
|
ctx := &CheckContext{TownRoot: tmpDir}
|
|
|
|
result := check.Run(ctx)
|
|
|
|
if result.Status != StatusError {
|
|
t.Errorf("expected StatusError for missing enabledPlugins, got %v", result.Status)
|
|
}
|
|
if !strings.Contains(result.Message, "1 stale") {
|
|
t.Errorf("expected message about stale settings, got %q", result.Message)
|
|
}
|
|
}
|
|
|
|
func TestClaudeSettingsCheck_MissingHooks(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
|
|
// Create stale settings missing hooks entirely (at correct location)
|
|
mayorSettings := filepath.Join(tmpDir, "mayor", ".claude", "settings.json")
|
|
createStaleSettings(t, mayorSettings, "hooks")
|
|
|
|
check := NewClaudeSettingsCheck()
|
|
ctx := &CheckContext{TownRoot: tmpDir}
|
|
|
|
result := check.Run(ctx)
|
|
|
|
if result.Status != StatusError {
|
|
t.Errorf("expected StatusError for missing hooks, got %v", result.Status)
|
|
}
|
|
}
|
|
|
|
func TestClaudeSettingsCheck_MissingPATH(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
|
|
// Create stale settings missing PATH export (at correct location)
|
|
mayorSettings := filepath.Join(tmpDir, "mayor", ".claude", "settings.json")
|
|
createStaleSettings(t, mayorSettings, "PATH")
|
|
|
|
check := NewClaudeSettingsCheck()
|
|
ctx := &CheckContext{TownRoot: tmpDir}
|
|
|
|
result := check.Run(ctx)
|
|
|
|
if result.Status != StatusError {
|
|
t.Errorf("expected StatusError for missing PATH, got %v", result.Status)
|
|
}
|
|
found := false
|
|
for _, d := range result.Details {
|
|
if strings.Contains(d, "PATH export") {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
t.Errorf("expected details to mention PATH export, got %v", result.Details)
|
|
}
|
|
}
|
|
|
|
func TestClaudeSettingsCheck_MissingDeaconNudge(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
|
|
// Create stale settings missing deacon nudge (at correct location)
|
|
mayorSettings := filepath.Join(tmpDir, "mayor", ".claude", "settings.json")
|
|
createStaleSettings(t, mayorSettings, "deacon-nudge")
|
|
|
|
check := NewClaudeSettingsCheck()
|
|
ctx := &CheckContext{TownRoot: tmpDir}
|
|
|
|
result := check.Run(ctx)
|
|
|
|
if result.Status != StatusError {
|
|
t.Errorf("expected StatusError for missing deacon nudge, got %v", result.Status)
|
|
}
|
|
found := false
|
|
for _, d := range result.Details {
|
|
if strings.Contains(d, "deacon nudge") {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
t.Errorf("expected details to mention deacon nudge, got %v", result.Details)
|
|
}
|
|
}
|
|
|
|
func TestClaudeSettingsCheck_MissingStopHook(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
|
|
// Create stale settings missing Stop hook (at correct location)
|
|
mayorSettings := filepath.Join(tmpDir, "mayor", ".claude", "settings.json")
|
|
createStaleSettings(t, mayorSettings, "Stop")
|
|
|
|
check := NewClaudeSettingsCheck()
|
|
ctx := &CheckContext{TownRoot: tmpDir}
|
|
|
|
result := check.Run(ctx)
|
|
|
|
if result.Status != StatusError {
|
|
t.Errorf("expected StatusError for missing Stop hook, got %v", result.Status)
|
|
}
|
|
found := false
|
|
for _, d := range result.Details {
|
|
if strings.Contains(d, "Stop hook") {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
t.Errorf("expected details to mention Stop hook, got %v", result.Details)
|
|
}
|
|
}
|
|
|
|
func TestClaudeSettingsCheck_WrongLocationWitness(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
rigName := "testrig"
|
|
|
|
// Create settings in wrong location (witness/rig/.claude/ instead of witness/.claude/)
|
|
// Settings inside git repos should be flagged as wrong location
|
|
wrongSettings := filepath.Join(tmpDir, rigName, "witness", "rig", ".claude", "settings.json")
|
|
createValidSettings(t, wrongSettings)
|
|
|
|
check := NewClaudeSettingsCheck()
|
|
ctx := &CheckContext{TownRoot: tmpDir}
|
|
|
|
result := check.Run(ctx)
|
|
|
|
if result.Status != StatusError {
|
|
t.Errorf("expected StatusError for wrong location, got %v", result.Status)
|
|
}
|
|
found := false
|
|
for _, d := range result.Details {
|
|
if strings.Contains(d, "wrong location") {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
t.Errorf("expected details to mention wrong location, got %v", result.Details)
|
|
}
|
|
}
|
|
|
|
func TestClaudeSettingsCheck_WrongLocationRefinery(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
rigName := "testrig"
|
|
|
|
// Create settings in wrong location (refinery/rig/.claude/ instead of refinery/.claude/)
|
|
// Settings inside git repos should be flagged as wrong location
|
|
wrongSettings := filepath.Join(tmpDir, rigName, "refinery", "rig", ".claude", "settings.json")
|
|
createValidSettings(t, wrongSettings)
|
|
|
|
check := NewClaudeSettingsCheck()
|
|
ctx := &CheckContext{TownRoot: tmpDir}
|
|
|
|
result := check.Run(ctx)
|
|
|
|
if result.Status != StatusError {
|
|
t.Errorf("expected StatusError for wrong location, got %v", result.Status)
|
|
}
|
|
found := false
|
|
for _, d := range result.Details {
|
|
if strings.Contains(d, "wrong location") {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
t.Errorf("expected details to mention wrong location, got %v", result.Details)
|
|
}
|
|
}
|
|
|
|
func TestClaudeSettingsCheck_MultipleStaleFiles(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
rigName := "testrig"
|
|
|
|
// Create multiple stale settings files (all at correct locations)
|
|
mayorSettings := filepath.Join(tmpDir, "mayor", ".claude", "settings.json")
|
|
createStaleSettings(t, mayorSettings, "PATH")
|
|
|
|
deaconSettings := filepath.Join(tmpDir, "deacon", ".claude", "settings.json")
|
|
createStaleSettings(t, deaconSettings, "Stop")
|
|
|
|
// Settings inside git repo (witness/rig/.claude/) are wrong location
|
|
witnessWrong := filepath.Join(tmpDir, rigName, "witness", "rig", ".claude", "settings.json")
|
|
createValidSettings(t, witnessWrong) // Valid content but wrong location
|
|
|
|
check := NewClaudeSettingsCheck()
|
|
ctx := &CheckContext{TownRoot: tmpDir}
|
|
|
|
result := check.Run(ctx)
|
|
|
|
if result.Status != StatusError {
|
|
t.Errorf("expected StatusError for multiple stale files, got %v", result.Status)
|
|
}
|
|
if !strings.Contains(result.Message, "3 stale") {
|
|
t.Errorf("expected message about 3 stale files, got %q", result.Message)
|
|
}
|
|
}
|
|
|
|
func TestClaudeSettingsCheck_InvalidJSON(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
|
|
// Create invalid JSON file (at correct location)
|
|
mayorSettings := filepath.Join(tmpDir, "mayor", ".claude", "settings.json")
|
|
if err := os.MkdirAll(filepath.Dir(mayorSettings), 0755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(mayorSettings, []byte("not valid json {"), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
check := NewClaudeSettingsCheck()
|
|
ctx := &CheckContext{TownRoot: tmpDir}
|
|
|
|
result := check.Run(ctx)
|
|
|
|
if result.Status != StatusError {
|
|
t.Errorf("expected StatusError for invalid JSON, got %v", result.Status)
|
|
}
|
|
found := false
|
|
for _, d := range result.Details {
|
|
if strings.Contains(d, "invalid JSON") {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
t.Errorf("expected details to mention invalid JSON, got %v", result.Details)
|
|
}
|
|
}
|
|
|
|
func TestClaudeSettingsCheck_FixDeletesStaleFile(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
|
|
// Create stale settings in wrong location (inside git repo - easy to test - just delete, no recreate)
|
|
rigName := "testrig"
|
|
wrongSettings := filepath.Join(tmpDir, rigName, "witness", "rig", ".claude", "settings.json")
|
|
createValidSettings(t, wrongSettings)
|
|
|
|
check := NewClaudeSettingsCheck()
|
|
ctx := &CheckContext{TownRoot: tmpDir}
|
|
|
|
// Run to detect
|
|
result := check.Run(ctx)
|
|
if result.Status != StatusError {
|
|
t.Fatalf("expected StatusError before fix, got %v", result.Status)
|
|
}
|
|
|
|
// Apply fix
|
|
if err := check.Fix(ctx); err != nil {
|
|
t.Fatalf("Fix failed: %v", err)
|
|
}
|
|
|
|
// Verify file was deleted
|
|
if _, err := os.Stat(wrongSettings); !os.IsNotExist(err) {
|
|
t.Error("expected wrong location settings to be deleted")
|
|
}
|
|
|
|
// Verify check passes (no settings files means OK)
|
|
result = check.Run(ctx)
|
|
if result.Status != StatusOK {
|
|
t.Errorf("expected StatusOK after fix, got %v", result.Status)
|
|
}
|
|
}
|
|
|
|
func TestClaudeSettingsCheck_SkipsNonRigDirectories(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
|
|
// Create directories that should be skipped
|
|
for _, skipDir := range []string{"mayor", "deacon", "daemon", ".git", "docs", ".hidden"} {
|
|
dir := filepath.Join(tmpDir, skipDir, "witness", "rig", ".claude")
|
|
if err := os.MkdirAll(dir, 0755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// These should NOT be detected as rig witness settings
|
|
settingsPath := filepath.Join(dir, "settings.json")
|
|
createStaleSettings(t, settingsPath, "PATH")
|
|
}
|
|
|
|
check := NewClaudeSettingsCheck()
|
|
ctx := &CheckContext{TownRoot: tmpDir}
|
|
|
|
_ = check.Run(ctx)
|
|
|
|
// Should only find mayor and deacon settings in their specific locations
|
|
// The witness settings in these dirs should be ignored
|
|
// Since we didn't create valid mayor/deacon settings, those will be stale
|
|
// But the ones in "mayor/witness/rig/.claude" should be ignored
|
|
|
|
// Count how many stale files were found - should be 0 since none of the
|
|
// skipped directories have their settings detected
|
|
if len(check.staleSettings) != 0 {
|
|
t.Errorf("expected 0 stale files (skipped dirs), got %d", len(check.staleSettings))
|
|
}
|
|
}
|
|
|
|
func TestClaudeSettingsCheck_MixedValidAndStale(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
rigName := "testrig"
|
|
|
|
// Create valid mayor settings (at correct location)
|
|
mayorSettings := filepath.Join(tmpDir, "mayor", ".claude", "settings.json")
|
|
createValidSettings(t, mayorSettings)
|
|
|
|
// Create stale witness settings in correct location (missing PATH)
|
|
witnessSettings := filepath.Join(tmpDir, rigName, "witness", ".claude", "settings.json")
|
|
createStaleSettings(t, witnessSettings, "PATH")
|
|
|
|
// Create valid refinery settings in correct location
|
|
refinerySettings := filepath.Join(tmpDir, rigName, "refinery", ".claude", "settings.json")
|
|
createValidSettings(t, refinerySettings)
|
|
|
|
check := NewClaudeSettingsCheck()
|
|
ctx := &CheckContext{TownRoot: tmpDir}
|
|
|
|
result := check.Run(ctx)
|
|
|
|
if result.Status != StatusError {
|
|
t.Errorf("expected StatusError for mixed valid/stale, got %v", result.Status)
|
|
}
|
|
if !strings.Contains(result.Message, "1 stale") {
|
|
t.Errorf("expected message about 1 stale file, got %q", result.Message)
|
|
}
|
|
// Should only report the witness settings as stale
|
|
if len(result.Details) != 1 {
|
|
t.Errorf("expected 1 detail, got %d: %v", len(result.Details), result.Details)
|
|
}
|
|
}
|
|
|
|
func TestClaudeSettingsCheck_WrongLocationCrew(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
rigName := "testrig"
|
|
|
|
// Create settings in wrong location (crew/<name>/.claude/ instead of crew/.claude/)
|
|
// Settings inside git repos should be flagged as wrong location
|
|
wrongSettings := filepath.Join(tmpDir, rigName, "crew", "agent1", ".claude", "settings.json")
|
|
createValidSettings(t, wrongSettings)
|
|
|
|
check := NewClaudeSettingsCheck()
|
|
ctx := &CheckContext{TownRoot: tmpDir}
|
|
|
|
result := check.Run(ctx)
|
|
|
|
if result.Status != StatusError {
|
|
t.Errorf("expected StatusError for wrong location, got %v", result.Status)
|
|
}
|
|
found := false
|
|
for _, d := range result.Details {
|
|
if strings.Contains(d, "wrong location") {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
t.Errorf("expected details to mention wrong location, got %v", result.Details)
|
|
}
|
|
}
|
|
|
|
func TestClaudeSettingsCheck_WrongLocationPolecat(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
rigName := "testrig"
|
|
|
|
// Create settings in wrong location (polecats/<name>/.claude/ instead of polecats/.claude/)
|
|
// Settings inside git repos should be flagged as wrong location
|
|
wrongSettings := filepath.Join(tmpDir, rigName, "polecats", "pc1", ".claude", "settings.json")
|
|
createValidSettings(t, wrongSettings)
|
|
|
|
check := NewClaudeSettingsCheck()
|
|
ctx := &CheckContext{TownRoot: tmpDir}
|
|
|
|
result := check.Run(ctx)
|
|
|
|
if result.Status != StatusError {
|
|
t.Errorf("expected StatusError for wrong location, got %v", result.Status)
|
|
}
|
|
found := false
|
|
for _, d := range result.Details {
|
|
if strings.Contains(d, "wrong location") {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
t.Errorf("expected details to mention wrong location, got %v", result.Details)
|
|
}
|
|
}
|
|
|
|
// initTestGitRepo initializes a git repo in the given directory for settings tests.
|
|
func initTestGitRepo(t *testing.T, dir string) {
|
|
t.Helper()
|
|
cmds := [][]string{
|
|
{"git", "init"},
|
|
{"git", "config", "user.email", "test@test.com"},
|
|
{"git", "config", "user.name", "Test User"},
|
|
}
|
|
for _, args := range cmds {
|
|
cmd := exec.Command(args[0], args[1:]...)
|
|
cmd.Dir = dir
|
|
if out, err := cmd.CombinedOutput(); err != nil {
|
|
t.Fatalf("git command %v failed: %v\n%s", args, err, out)
|
|
}
|
|
}
|
|
}
|
|
|
|
// gitAddAndCommit adds and commits a file.
|
|
func gitAddAndCommit(t *testing.T, repoDir, filePath string) {
|
|
t.Helper()
|
|
// Get relative path from repo root
|
|
relPath, err := filepath.Rel(repoDir, filePath)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
cmds := [][]string{
|
|
{"git", "add", relPath},
|
|
{"git", "commit", "-m", "Add file"},
|
|
}
|
|
for _, args := range cmds {
|
|
cmd := exec.Command(args[0], args[1:]...)
|
|
cmd.Dir = repoDir
|
|
if out, err := cmd.CombinedOutput(); err != nil {
|
|
t.Fatalf("git command %v failed: %v\n%s", args, err, out)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestClaudeSettingsCheck_GitStatusUntracked(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
rigName := "testrig"
|
|
|
|
// Create a git repo to simulate a source repo
|
|
rigDir := filepath.Join(tmpDir, rigName, "witness", "rig")
|
|
if err := os.MkdirAll(rigDir, 0755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
initTestGitRepo(t, rigDir)
|
|
|
|
// Create an untracked settings file (not git added)
|
|
wrongSettings := filepath.Join(rigDir, ".claude", "settings.json")
|
|
createValidSettings(t, wrongSettings)
|
|
|
|
check := NewClaudeSettingsCheck()
|
|
ctx := &CheckContext{TownRoot: tmpDir}
|
|
|
|
result := check.Run(ctx)
|
|
|
|
if result.Status != StatusError {
|
|
t.Errorf("expected StatusError for wrong location, got %v", result.Status)
|
|
}
|
|
// Should mention "untracked"
|
|
found := false
|
|
for _, d := range result.Details {
|
|
if strings.Contains(d, "untracked") {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
t.Errorf("expected details to mention untracked, got %v", result.Details)
|
|
}
|
|
}
|
|
|
|
func TestClaudeSettingsCheck_GitStatusTrackedClean(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
rigName := "testrig"
|
|
|
|
// Create a git repo to simulate a source repo
|
|
rigDir := filepath.Join(tmpDir, rigName, "witness", "rig")
|
|
if err := os.MkdirAll(rigDir, 0755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
initTestGitRepo(t, rigDir)
|
|
|
|
// Create settings and commit it (tracked, clean)
|
|
wrongSettings := filepath.Join(rigDir, ".claude", "settings.json")
|
|
createValidSettings(t, wrongSettings)
|
|
gitAddAndCommit(t, rigDir, wrongSettings)
|
|
|
|
check := NewClaudeSettingsCheck()
|
|
ctx := &CheckContext{TownRoot: tmpDir}
|
|
|
|
result := check.Run(ctx)
|
|
|
|
if result.Status != StatusError {
|
|
t.Errorf("expected StatusError for wrong location, got %v", result.Status)
|
|
}
|
|
// Should mention "tracked but unmodified"
|
|
found := false
|
|
for _, d := range result.Details {
|
|
if strings.Contains(d, "tracked but unmodified") {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
t.Errorf("expected details to mention tracked but unmodified, got %v", result.Details)
|
|
}
|
|
}
|
|
|
|
func TestClaudeSettingsCheck_GitStatusTrackedModified(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
rigName := "testrig"
|
|
|
|
// Create a git repo to simulate a source repo
|
|
rigDir := filepath.Join(tmpDir, rigName, "witness", "rig")
|
|
if err := os.MkdirAll(rigDir, 0755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
initTestGitRepo(t, rigDir)
|
|
|
|
// Create settings and commit it
|
|
wrongSettings := filepath.Join(rigDir, ".claude", "settings.json")
|
|
createValidSettings(t, wrongSettings)
|
|
gitAddAndCommit(t, rigDir, wrongSettings)
|
|
|
|
// Modify the file after commit
|
|
if err := os.WriteFile(wrongSettings, []byte(`{"modified": true}`), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
check := NewClaudeSettingsCheck()
|
|
ctx := &CheckContext{TownRoot: tmpDir}
|
|
|
|
result := check.Run(ctx)
|
|
|
|
if result.Status != StatusError {
|
|
t.Errorf("expected StatusError for wrong location, got %v", result.Status)
|
|
}
|
|
// Should mention "local modifications"
|
|
found := false
|
|
for _, d := range result.Details {
|
|
if strings.Contains(d, "local modifications") {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
t.Errorf("expected details to mention local modifications, got %v", result.Details)
|
|
}
|
|
// Should also mention manual review
|
|
if !strings.Contains(result.FixHint, "manual review") {
|
|
t.Errorf("expected fix hint to mention manual review, got %q", result.FixHint)
|
|
}
|
|
}
|
|
|
|
func TestClaudeSettingsCheck_FixSkipsModifiedFiles(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
rigName := "testrig"
|
|
|
|
// Create a git repo to simulate a source repo
|
|
rigDir := filepath.Join(tmpDir, rigName, "witness", "rig")
|
|
if err := os.MkdirAll(rigDir, 0755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
initTestGitRepo(t, rigDir)
|
|
|
|
// Create settings and commit it
|
|
wrongSettings := filepath.Join(rigDir, ".claude", "settings.json")
|
|
createValidSettings(t, wrongSettings)
|
|
gitAddAndCommit(t, rigDir, wrongSettings)
|
|
|
|
// Modify the file after commit
|
|
if err := os.WriteFile(wrongSettings, []byte(`{"modified": true}`), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
check := NewClaudeSettingsCheck()
|
|
ctx := &CheckContext{TownRoot: tmpDir}
|
|
|
|
// Run to detect
|
|
result := check.Run(ctx)
|
|
if result.Status != StatusError {
|
|
t.Fatalf("expected StatusError before fix, got %v", result.Status)
|
|
}
|
|
|
|
// Apply fix - should NOT delete the modified file
|
|
if err := check.Fix(ctx); err != nil {
|
|
t.Fatalf("Fix failed: %v", err)
|
|
}
|
|
|
|
// Verify file still exists (was skipped)
|
|
if _, err := os.Stat(wrongSettings); os.IsNotExist(err) {
|
|
t.Error("expected modified file to be preserved, but it was deleted")
|
|
}
|
|
}
|
|
|
|
func TestClaudeSettingsCheck_FixDeletesUntrackedFiles(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
rigName := "testrig"
|
|
|
|
// Create a git repo to simulate a source repo
|
|
rigDir := filepath.Join(tmpDir, rigName, "witness", "rig")
|
|
if err := os.MkdirAll(rigDir, 0755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
initTestGitRepo(t, rigDir)
|
|
|
|
// Create an untracked settings file (not git added)
|
|
wrongSettings := filepath.Join(rigDir, ".claude", "settings.json")
|
|
createValidSettings(t, wrongSettings)
|
|
|
|
check := NewClaudeSettingsCheck()
|
|
ctx := &CheckContext{TownRoot: tmpDir}
|
|
|
|
// Run to detect
|
|
result := check.Run(ctx)
|
|
if result.Status != StatusError {
|
|
t.Fatalf("expected StatusError before fix, got %v", result.Status)
|
|
}
|
|
|
|
// Apply fix - should delete the untracked file
|
|
if err := check.Fix(ctx); err != nil {
|
|
t.Fatalf("Fix failed: %v", err)
|
|
}
|
|
|
|
// Verify file was deleted
|
|
if _, err := os.Stat(wrongSettings); !os.IsNotExist(err) {
|
|
t.Error("expected untracked file to be deleted")
|
|
}
|
|
}
|
|
|
|
func TestClaudeSettingsCheck_FixDeletesTrackedCleanFiles(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
rigName := "testrig"
|
|
|
|
// Create a git repo to simulate a source repo
|
|
rigDir := filepath.Join(tmpDir, rigName, "witness", "rig")
|
|
if err := os.MkdirAll(rigDir, 0755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
initTestGitRepo(t, rigDir)
|
|
|
|
// Create settings and commit it (tracked, clean)
|
|
wrongSettings := filepath.Join(rigDir, ".claude", "settings.json")
|
|
createValidSettings(t, wrongSettings)
|
|
gitAddAndCommit(t, rigDir, wrongSettings)
|
|
|
|
check := NewClaudeSettingsCheck()
|
|
ctx := &CheckContext{TownRoot: tmpDir}
|
|
|
|
// Run to detect
|
|
result := check.Run(ctx)
|
|
if result.Status != StatusError {
|
|
t.Fatalf("expected StatusError before fix, got %v", result.Status)
|
|
}
|
|
|
|
// Apply fix - should delete the tracked clean file
|
|
if err := check.Fix(ctx); err != nil {
|
|
t.Fatalf("Fix failed: %v", err)
|
|
}
|
|
|
|
// Verify file was deleted
|
|
if _, err := os.Stat(wrongSettings); !os.IsNotExist(err) {
|
|
t.Error("expected tracked clean file to be deleted")
|
|
}
|
|
}
|
|
|
|
func TestClaudeSettingsCheck_DetectsStaleCLAUDEmdAtTownRoot(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
|
|
// Create CLAUDE.md at town root (wrong location)
|
|
staleCLAUDEmd := filepath.Join(tmpDir, "CLAUDE.md")
|
|
if err := os.WriteFile(staleCLAUDEmd, []byte("# Mayor Context\n"), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
check := NewClaudeSettingsCheck()
|
|
ctx := &CheckContext{TownRoot: tmpDir}
|
|
|
|
result := check.Run(ctx)
|
|
|
|
if result.Status != StatusError {
|
|
t.Errorf("expected StatusError for stale CLAUDE.md at town root, got %v", result.Status)
|
|
}
|
|
|
|
// Should mention wrong location
|
|
found := false
|
|
for _, d := range result.Details {
|
|
if strings.Contains(d, "CLAUDE.md") && strings.Contains(d, "wrong location") {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
t.Errorf("expected details to mention CLAUDE.md wrong location, got %v", result.Details)
|
|
}
|
|
}
|
|
|
|
func TestClaudeSettingsCheck_FixMovesCLAUDEmdToMayor(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
|
|
// Create mayor directory (needed for fix to create CLAUDE.md there)
|
|
mayorDir := filepath.Join(tmpDir, "mayor")
|
|
if err := os.MkdirAll(mayorDir, 0755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Create CLAUDE.md at town root (wrong location)
|
|
staleCLAUDEmd := filepath.Join(tmpDir, "CLAUDE.md")
|
|
if err := os.WriteFile(staleCLAUDEmd, []byte("# Mayor Context\n"), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
check := NewClaudeSettingsCheck()
|
|
ctx := &CheckContext{TownRoot: tmpDir}
|
|
|
|
// Run to detect
|
|
result := check.Run(ctx)
|
|
if result.Status != StatusError {
|
|
t.Fatalf("expected StatusError before fix, got %v", result.Status)
|
|
}
|
|
|
|
// Apply fix
|
|
if err := check.Fix(ctx); err != nil {
|
|
t.Fatalf("Fix failed: %v", err)
|
|
}
|
|
|
|
// Verify old file was deleted
|
|
if _, err := os.Stat(staleCLAUDEmd); !os.IsNotExist(err) {
|
|
t.Error("expected CLAUDE.md at town root to be deleted")
|
|
}
|
|
|
|
// Verify new file was created at mayor/
|
|
correctCLAUDEmd := filepath.Join(mayorDir, "CLAUDE.md")
|
|
if _, err := os.Stat(correctCLAUDEmd); os.IsNotExist(err) {
|
|
t.Error("expected CLAUDE.md to be created at mayor/")
|
|
}
|
|
}
|
|
|
|
func TestClaudeSettingsCheck_TownRootSettingsWarnsInsteadOfKilling(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
|
|
// Create mayor directory (needed for fix to recreate settings there)
|
|
mayorDir := filepath.Join(tmpDir, "mayor")
|
|
if err := os.MkdirAll(mayorDir, 0755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Create settings.json at town root (wrong location - pollutes all agents)
|
|
staleTownRootDir := filepath.Join(tmpDir, ".claude")
|
|
if err := os.MkdirAll(staleTownRootDir, 0755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
staleTownRootSettings := filepath.Join(staleTownRootDir, "settings.json")
|
|
// Create valid settings content
|
|
settingsContent := `{
|
|
"env": {"PATH": "/usr/bin"},
|
|
"enabledPlugins": ["claude-code-expert"],
|
|
"hooks": {
|
|
"SessionStart": [{"matcher": "", "hooks": [{"type": "command", "command": "gt prime"}]}],
|
|
"Stop": [{"matcher": "", "hooks": [{"type": "command", "command": "gt handoff"}]}]
|
|
}
|
|
}`
|
|
if err := os.WriteFile(staleTownRootSettings, []byte(settingsContent), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
check := NewClaudeSettingsCheck()
|
|
ctx := &CheckContext{TownRoot: tmpDir}
|
|
|
|
// Run to detect
|
|
result := check.Run(ctx)
|
|
if result.Status != StatusError {
|
|
t.Fatalf("expected StatusError for town root settings, got %v", result.Status)
|
|
}
|
|
|
|
// Verify it's flagged as wrong location
|
|
foundWrongLocation := false
|
|
for _, d := range result.Details {
|
|
if strings.Contains(d, "wrong location") {
|
|
foundWrongLocation = true
|
|
break
|
|
}
|
|
}
|
|
if !foundWrongLocation {
|
|
t.Errorf("expected details to mention wrong location, got %v", result.Details)
|
|
}
|
|
|
|
// Apply fix - should NOT return error and should NOT kill sessions
|
|
// (session killing would require tmux which isn't available in tests)
|
|
if err := check.Fix(ctx); err != nil {
|
|
t.Fatalf("Fix failed: %v", err)
|
|
}
|
|
|
|
// Verify stale file was deleted
|
|
if _, err := os.Stat(staleTownRootSettings); !os.IsNotExist(err) {
|
|
t.Error("expected settings.json at town root to be deleted")
|
|
}
|
|
|
|
// Verify .claude directory was cleaned up (best-effort)
|
|
if _, err := os.Stat(staleTownRootDir); !os.IsNotExist(err) {
|
|
t.Error("expected .claude directory at town root to be deleted")
|
|
}
|
|
}
|