70bd1674b3
Three-state check matching the seed-dir pattern: INFO when unset (points at the default discovery location), INFO with user-scope advisory when set and present, FAIL when set but the directory doesn't exist. Advisory wording is recommendatory, not prescriptive (per spec amendment). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
257 lines
8.3 KiB
Go
257 lines
8.3 KiB
Go
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/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/<you>/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)
|
|
|
|
// 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++
|
|
}
|