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: %s new \"my first task\"\n", invocationName()) 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) // Check 10: v0.6 — global config file + per-setting source attribution. // Loaded once and reused across the whole settings block so doctor // stays internally consistent (no re-reading the config file mid-render). resolver := config.LoadResolver() checkSettings(resolver, &passed, &failed) // Check 11: v0.6 — agent check when a workspace context is resolvable. // Uses the most-recently-active workspace (mirrors `ctask last`); skips // with an INFO line when no active workspace exists anywhere. checkAgentForDoctor(&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++ } // checkSettings prints the v0.6 ── Settings ── block. The block shows // the effective value of every user-level default tracked by the // resolver, along with the source it was drawn from (built-in default, // config file, env var, or platform override). When an env var // overrides a config value, the override line displays both values so // the user can see what they would inherit if they unset the env. // // The block also reports the config-file lifecycle state: // - not found → INFO line only (no counters bumped) // - found, valid → INFO line listing the path // - found, invalid → FAIL line naming the offending key (failed++) // // Increments failed only for the invalid-config case. Passed is // reserved for future per-setting checks (e.g., "ctask_root exists // and is writable" — currently covered by Check 1). // // All values must come from the resolver, never re-read from env or // disk inside this function — the resolver is the single source of // truth and was already loaded once by runDoctor. func checkSettings(r *config.Resolver, passed, failed *int) { _ = passed // reserved (see doc comment) fmt.Println() fmt.Println("── Settings ──────────────────────────────────") // Config-file lifecycle line. switch { case r.ConfigErr != nil: fmt.Printf("Config file: %s\n", r.ConfigPath) fmt.Printf(" [FAIL] %s\n", r.ConfigErr) fmt.Println(" Config file settings not applied — using env vars and built-in defaults only.") *failed++ case r.ConfigPath != "": if _, err := os.Stat(r.ConfigPath); err == nil { fmt.Printf("Config file: %s\n", r.ConfigPath) } else { fmt.Printf("Config file: not found — using built-in defaults (%s)\n", r.ConfigPath) } default: fmt.Println("Config file: not found — using built-in defaults") } // Per-setting lines. Each line: `: ` then a `source:` // child line. When Override is non-nil the source line names the // overridden source and value, matching the spec example // `CTASK_AGENT env var (overrides config file: claude)`. for _, s := range []config.ResolvedSetting{ r.CtaskRoot(), r.ProjectRoot(), r.SeedDir(), r.DefaultAgent(), r.DefaultCategory(), r.SessionMode(), r.Editor(), } { fmt.Println() printSettingLine(s) } } // printSettingLine renders one ResolvedSetting as a key/value/source // trio, including override-chain context. Extracted so future settings // (e.g., per-workspace launch session mode in info) can reuse it. func printSettingLine(s config.ResolvedSetting) { fmt.Printf("%s: %s\n", s.Key, s.Value) fmt.Printf(" source: %s\n", formatSettingSource(s)) if s.Source == config.PlatformOverride && s.Override != nil { // Spec section 1.8 calls for an extra "configured: " // row so the user sees what they asked for in addition to // what's in effect. fmt.Printf(" configured: %s\n", s.Override.Value) } } // formatSettingSource builds the "source: ..." annotation. The base // label comes from the SettingSource.String(); env-var sources also // surface the actual env-var name; PlatformOverride spells out why // it fired; and when an override chain is present, the next-lower // source's value is appended in parentheses so doctor renders lines // like "CTASK_AGENT env var (overrides config file: claude)". func formatSettingSource(s config.ResolvedSetting) string { var base string switch s.Source { case config.EnvVar: if s.EnvName != "" { base = s.EnvName + " env var" } else { base = "env var" } case config.PlatformOverride: base = "platform override (persistent mode requires tmux; not available on native Windows)" default: base = s.Source.String() } if s.Override != nil && s.Source != config.PlatformOverride { base += fmt.Sprintf(" (overrides %s: %s)", s.Override.Source, s.Override.Value) } return base } // checkAgentForDoctor runs the same validation as `ctask agents check` // against the most-recently-active workspace. When no active workspace // exists, prints a skip line. FAIL outcomes increment the failed counter // (so doctor's overall exit code reflects agent breakage); WARN does not. func checkAgentForDoctor(passed, failed *int) { _ = passed // reserved for future symmetry with the other checks fmt.Println() roots := config.SearchRoots() best, err := workspace.MostRecentActive(roots) if err != nil || best == nil { fmt.Println("Agent check: skipped (no workspace context)") return } if checkErr := runAgentsCheckOnWorkspace(os.Stdout, best); checkErr != nil { // runAgentsCheckOnWorkspace already printed the [FAIL] lines; just // increment the counter so the summary reflects the breakage. *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) }