From 30d3e64d7ef446aef56a1087bb4e9e2058ffbfbd Mon Sep 17 00:00:00 2001 From: warren Date: Mon, 6 Apr 2026 09:57:58 -0400 Subject: [PATCH] feat: ctask doctor command with 5 health checks Checks: workspace root, agent on PATH, status line helper, Claude settings, existing workspaces. Actionable fix guidance for each failure. Co-Authored-By: Claude Opus 4.6 (1M context) --- cmd/doctor.go | 175 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 175 insertions(+) create mode 100644 cmd/doctor.go diff --git a/cmd/doctor.go b/cmd/doctor.go new file mode 100644 index 0000000..4b52f3a --- /dev/null +++ b/cmd/doctor.go @@ -0,0 +1,175 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + + "github.com/spf13/cobra" + "github.com/warrenronsiek/ctask/internal/config" + "github.com/warrenronsiek/ctask/internal/workspace" +) + +var doctorCmd = &cobra.Command{ + Use: "doctor", + Short: "Verify ctask setup and diagnose configuration problems", + Args: cobra.NoArgs, + SilenceUsage: true, + RunE: runDoctor, +} + +func init() { + rootCmd.AddCommand(doctorCmd) +} + +func runDoctor(cmd *cobra.Command, args []string) error { + passed := 0 + failed := 0 + + // Check 1: Workspace root exists and is writable + root := config.ResolveRoot() + if info, err := os.Stat(root); err == nil && info.IsDir() { + // Test writability by creating a temp file + testFile := filepath.Join(root, ".ctask-doctor-test") + if err := os.WriteFile(testFile, []byte("test"), 0644); err == nil { + os.Remove(testFile) + fmt.Printf(" [PASS] Workspace root exists: %s\n", root) + passed++ + } else { + fmt.Printf(" [FAIL] Workspace root not writable: %s\n", root) + fmt.Printf(" Fix: check permissions on %s\n", root) + failed++ + } + } else { + fmt.Printf(" [FAIL] Workspace root not found: %s\n", root) + fmt.Printf(" Fix: create it with: mkdir %s\n", root) + failed++ + } + + // Check 2: Default agent on PATH + agent := config.ResolveAgent() + if _, err := exec.LookPath(agent); err == nil { + fmt.Printf(" [PASS] Default agent found: %s\n", agent) + passed++ + } else { + fmt.Printf(" [FAIL] Agent command not found: %s\n", agent) + fmt.Printf(" Fix: install it or set CTASK_AGENT to a different command\n") + failed++ + } + + // Check 3: Status line helper exists + // The canonical location is next to the ctask binary in GOPATH/bin, + // or on Unix in ~/.local/bin. We check both the bash and powershell variants. + statusLineFound := false + statusLinePath := "" + + // Determine expected locations based on platform + var searchPaths []string + home, _ := os.UserHomeDir() + gopath := os.Getenv("GOPATH") + if gopath == "" { + gopath = filepath.Join(home, "go") + } + gopathBin := filepath.Join(gopath, "bin") + + if runtime.GOOS == "windows" { + searchPaths = []string{ + filepath.Join(gopathBin, "ctask-statusline.sh"), + filepath.Join(gopathBin, "ctask-statusline.ps1"), + } + } else { + searchPaths = []string{ + filepath.Join(home, ".local", "bin", "ctask-statusline.sh"), + filepath.Join(gopathBin, "ctask-statusline.sh"), + "/usr/local/bin/ctask-statusline.sh", + } + } + + for _, p := range searchPaths { + if _, err := os.Stat(p); err == nil { + statusLineFound = true + statusLinePath = p + break + } + } + + if statusLineFound { + fmt.Printf(" [PASS] Status line helper found: %s\n", statusLinePath) + passed++ + } else { + fmt.Printf(" [FAIL] Status line helper not found\n") + fmt.Printf(" Fix: copy ctask-statusline.sh to %s\n", searchPaths[0]) + failed++ + } + + // Check 4: Claude Code status line configured + claudeSettingsPath := filepath.Join(home, ".claude", "settings.json") + statusLineConfigured := false + + if data, err := os.ReadFile(claudeSettingsPath); err == nil { + var settings map[string]interface{} + if err := json.Unmarshal(data, &settings); err == nil { + if sl, ok := settings["statusLine"].(map[string]interface{}); ok { + slType, _ := sl["type"].(string) + slCmd, _ := sl["command"].(string) + if slType == "command" && slCmd != "" { + // Check if the referenced file in the command exists + // Extract the file path from the command (last argument typically) + statusLineConfigured = true + fmt.Printf(" [PASS] Claude Code status line configured\n") + passed++ + } + } + } + } + + if !statusLineConfigured { + fmt.Printf(" [FAIL] Claude Code status line not configured or misconfigured\n") + fmt.Printf(" Fix: add to %s:\n", claudeSettingsPath) + if runtime.GOOS == "windows" { + fmt.Printf(" \"statusLine\": {\"type\": \"command\", \"command\": \"bash /c/Users//go/bin/ctask-statusline.sh\"}\n") + } else { + fmt.Printf(" \"statusLine\": {\"type\": \"command\", \"command\": \"bash ~/.local/bin/ctask-statusline.sh\"}\n") + } + failed++ + } + + // Check 5: Workspace root has workspaces + results, err := workspace.ListWorkspaces(root, workspace.ListOpts{ + IncludeArchived: true, + Limit: 0, // no limit + }) + if err == nil && len(results) > 0 { + activeCount := 0 + archivedCount := 0 + for _, r := range results { + if r.Meta.Status == "archived" { + archivedCount++ + } else { + activeCount++ + } + } + fmt.Printf(" [PASS] Workspaces found: %d tasks", len(results)) + if archivedCount > 0 { + fmt.Printf(" (%d archived)", archivedCount) + } + fmt.Println() + passed++ + } else { + fmt.Printf(" [FAIL] No workspaces found\n") + fmt.Printf(" Fix: create one with: ctask new \"my first task\"\n") + failed++ + } + + // Summary + fmt.Println() + fmt.Printf("%d checks passed, %d failed\n", passed, failed) + + if failed > 0 { + return fmt.Errorf("%d checks failed", failed) + } + return nil +}