/ internal / tui / doctor.go
doctor.go
  1  package tui
  2  
  3  import (
  4  	"context"
  5  	"fmt"
  6  	"os"
  7  	"path/filepath"
  8  	"strings"
  9  	"time"
 10  
 11  	"github.com/charmbracelet/lipgloss"
 12  
 13  	"github.com/Kocoro-lab/ShanClaw/internal/client"
 14  	"github.com/Kocoro-lab/ShanClaw/internal/mcp"
 15  	"github.com/Kocoro-lab/ShanClaw/internal/permissions"
 16  )
 17  
 18  type doctorCheck struct {
 19  	name   string
 20  	ok     bool
 21  	detail string
 22  }
 23  
 24  type doctorDoneMsg struct {
 25  	checks []doctorCheck
 26  }
 27  
 28  // runDoctorChecks runs all sync diagnostic checks.
 29  func runDoctorChecks(shannonDir, apiKey string, perms *permissions.PermissionsConfig, mcpServers map[string]mcp.MCPServerConfig, toolCount int) []doctorCheck {
 30  	var checks []doctorCheck
 31  
 32  	// 1. Config directory
 33  	if shannonDir != "" {
 34  		cfgPath := filepath.Join(shannonDir, "config.yaml")
 35  		if _, err := os.Stat(cfgPath); err == nil {
 36  			checks = append(checks, doctorCheck{"Config", true, cfgPath})
 37  		} else {
 38  			checks = append(checks, doctorCheck{"Config", false, "config.yaml not found in " + shannonDir})
 39  		}
 40  	} else {
 41  		checks = append(checks, doctorCheck{"Config", false, "shannon directory not set"})
 42  	}
 43  
 44  	// 2. API key
 45  	if apiKey != "" {
 46  		masked := "****"
 47  		if len(apiKey) > 4 {
 48  			masked = "****" + apiKey[len(apiKey)-4:]
 49  		}
 50  		checks = append(checks, doctorCheck{"API key", true, masked})
 51  	} else {
 52  		checks = append(checks, doctorCheck{"API key", false, "not configured"})
 53  	}
 54  
 55  	// 3. Tools
 56  	checks = append(checks, doctorCheck{"Tools", toolCount > 0, fmt.Sprintf("%d registered", toolCount)})
 57  
 58  	// 4. Sessions dir writable
 59  	sessDir := filepath.Join(shannonDir, "sessions")
 60  	if tmpFile, err := os.CreateTemp(sessDir, ".doctor-check-*"); err == nil {
 61  		tmpFile.Close()
 62  		os.Remove(tmpFile.Name())
 63  		checks = append(checks, doctorCheck{"Sessions dir", true, sessDir})
 64  	} else {
 65  		checks = append(checks, doctorCheck{"Sessions dir", false, fmt.Sprintf("not writable: %v", err)})
 66  	}
 67  
 68  	// 5. Permissions summary
 69  	if perms != nil {
 70  		checks = append(checks, doctorCheck{"Permissions", true, fmt.Sprintf("%d allowed, %d denied", len(perms.AllowedCommands), len(perms.DeniedCommands))})
 71  	} else {
 72  		checks = append(checks, doctorCheck{"Permissions", true, "default (no rules)"})
 73  	}
 74  
 75  	// 6. MCP servers
 76  	mcpCount := len(mcpServers)
 77  	if mcpCount > 0 {
 78  		checks = append(checks, doctorCheck{"MCP servers", true, fmt.Sprintf("%d configured", mcpCount)})
 79  	} else {
 80  		checks = append(checks, doctorCheck{"MCP servers", false, "none configured"})
 81  	}
 82  
 83  	return checks
 84  }
 85  
 86  // runDoctorWithHealth runs all checks including the async API health check.
 87  func runDoctorWithHealth(shannonDir, apiKey, endpoint string, gw *client.GatewayClient, perms *permissions.PermissionsConfig, mcpServers map[string]mcp.MCPServerConfig, toolCount int) []doctorCheck {
 88  	checks := runDoctorChecks(shannonDir, apiKey, perms, mcpServers, toolCount)
 89  
 90  	// API health check (with timeout)
 91  	ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
 92  	defer cancel()
 93  	if gw != nil {
 94  		if err := gw.Health(ctx); err == nil {
 95  			checks = append(checks, doctorCheck{"API reachable", true, endpoint})
 96  		} else {
 97  			checks = append(checks, doctorCheck{"API reachable", false, fmt.Sprintf("%s: %v", endpoint, err)})
 98  		}
 99  	} else {
100  		checks = append(checks, doctorCheck{"API reachable", false, "gateway client not initialized"})
101  	}
102  
103  	return checks
104  }
105  
106  func formatDoctorResults(checks []doctorCheck) string {
107  	dimStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("243"))
108  	okStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("42"))
109  	failStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("196"))
110  
111  	var sb strings.Builder
112  	sb.WriteString(dimStyle.Render("  Diagnostics:") + "\n")
113  	for _, c := range checks {
114  		icon := okStyle.Render("[ok]")
115  		if !c.ok {
116  			icon = failStyle.Render("[!!]")
117  		}
118  		sb.WriteString(fmt.Sprintf("  %s %s: %s\n", icon, dimStyle.Render(c.name), dimStyle.Render(c.detail)))
119  	}
120  	return strings.TrimRight(sb.String(), "\n")
121  }