package cmd import ( "encoding/json" "fmt" "os" "os/exec" "path/filepath" "runtime" "strings" "github.com/spf13/cobra" "github.com/warrenronsiek/ctask/internal/config" "github.com/warrenronsiek/ctask/internal/shell" "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 // Canonical location: %LOCALAPPDATA%\ctask\bin on Windows, ~/.local/bin on Unix. // Also checks GOPATH/bin as a fallback for older installs. statusLineFound := false statusLinePath := "" 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" { localAppData := os.Getenv("LOCALAPPDATA") if localAppData != "" { ctaskBin := filepath.Join(localAppData, "ctask", "bin") searchPaths = append(searchPaths, filepath.Join(ctaskBin, "ctask-statusline.sh")) searchPaths = append(searchPaths, filepath.Join(ctaskBin, "ctask-statusline.ps1")) } searchPaths = append(searchPaths, filepath.Join(gopathBin, "ctask-statusline.sh")) searchPaths = append(searchPaths, 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 statusLineFound { // Derive the .sh path from whatever was found (could be .ps1 fallback). // Claude Code always needs the bash variant. shPath := strings.TrimSuffix(statusLinePath, filepath.Ext(statusLinePath)) + ".sh" bashPath := filepath.ToSlash(shPath) // Convert Windows drive letter for Git Bash: C:\... -> /c/... if len(bashPath) >= 2 && bashPath[1] == ':' { bashPath = "/" + strings.ToLower(string(bashPath[0])) + bashPath[2:] } fmt.Printf(" \"statusLine\": {\"type\": \"command\", \"command\": \"bash %s\"}\n", bashPath) } else if runtime.GOOS == "windows" { fmt.Printf(" \"statusLine\": {\"type\": \"command\", \"command\": \"bash /c/Users//AppData/Local/ctask/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(config.SearchRoots(), 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++ } // Checks 6 + 7: Seed directory configuration. // Three states per variable: // - env not set -> informational (built-in defaults will be used) // - env set, path exists -> pass // - env set, path missing -> fail checkSeedDir( "General seed directory", os.Getenv("CTASK_SEED_DIR"), config.ResolveSeedDir(), "CTASK_SEED_DIR", &passed, &failed, ) checkSeedDir( "Project seed directory", os.Getenv("CTASK_SEED_PROJECT_DIR"), config.ResolveProjectSeedDir(), "CTASK_SEED_PROJECT_DIR", &passed, &failed, ) // Check 8: CTASK_PROJECT_ROOT (v0.5). checkProjectRoot(&passed, &failed) // Check 9: tmux availability for persistent session mode (v0.5.3). checkTmux(&passed, &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 } // checkProjectRoot prints the three-state CTASK_PROJECT_ROOT doctor line: // - unset -> INFO pointing at the default location under CTASK_ROOT // - set and exists -> INFO with custom-root advisory // - set and missing -> FAIL (increments failed counter) func checkProjectRoot(passed, failed *int) { _ = passed // reserved for future symmetry with checkSeedDir envValue := os.Getenv("CTASK_PROJECT_ROOT") if envValue == "" { defaultProj := filepath.Join(config.ResolveRoot(), "projects") fmt.Printf(" [INFO] CTASK_PROJECT_ROOT: not set (projects discovered under %s)\n", defaultProj) return } resolved := config.ResolveProjectRoot() if info, err := os.Stat(resolved); err == nil && info.IsDir() { fmt.Printf(" [INFO] CTASK_PROJECT_ROOT: %s (custom -- recommended: set at user scope so all terminals can discover these workspaces)\n", resolved) return } fmt.Printf(" [FAIL] CTASK_PROJECT_ROOT configured but not found: %s\n", resolved) fmt.Printf(" Fix: create the directory or unset CTASK_PROJECT_ROOT.\n") *failed++ } // checkSeedDir prints one of the three seed-directory doctor states. // envValue is the raw value of the CTASK_SEED_* variable (empty means unset). // resolved is the absolute path returned by the config resolver (used only // when envValue is non-empty; for the "not configured" state the built-in // default path is not displayed). func checkSeedDir(label, envValue, resolved, envName string, passed, failed *int) { if envValue == "" { fmt.Printf(" [INFO] %s: not configured (using built-in defaults)\n", label) return } if info, err := os.Stat(resolved); err == nil && info.IsDir() { fmt.Printf(" [PASS] %s: %s\n", label, resolved) *passed++ return } fmt.Printf(" [FAIL] %s configured but not found: %s\n", label, resolved) fmt.Printf(" Fix: create the directory or unset %s to use built-in defaults.\n", envName) *failed++ } // checkTmux reports the three-state tmux check (v0.5.3): // - CTASK_SESSION_MODE != "persistent" -> INFO (direct mode, tmux optional) // - persistent + tmux on PATH + version OK -> two INFO lines // - persistent + tmux missing or too old -> FAIL with install/update hint func checkTmux(passed, failed *int) { _ = passed mode := config.ResolveSessionMode() if mode != "persistent" { fmt.Printf(" [INFO] Session mode: direct (tmux not required)\n") return } fmt.Printf(" [INFO] Session mode: persistent\n") tmuxPath, ver, err := shell.LookupTmux() if err != nil { fmt.Printf(" [FAIL] tmux not found on PATH or unsupported version: %v\n", err) fmt.Printf(" Fix: install tmux 3.0+ (apt/brew/pacman/dnf), or unset CTASK_SESSION_MODE\n") *failed++ return } rawVer := ver.Raw if rawVer == "" { rawVer = "unknown version" } fmt.Printf(" [INFO] tmux found: %s (%s)\n", rawVer, tmuxPath) }