9e23277094
Adds two [INFO] lines after the existing pass/fail checks reporting whether the resolved general and project seed directories exist. These are read-only and do not contribute to the pass/fail counters, so users with no seed directories still see "5 checks passed, 0 failed".
208 lines
6.4 KiB
Go
208 lines
6.4 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(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++
|
|
}
|
|
|
|
// Informational checks (do not affect pass/fail counters).
|
|
fmt.Println()
|
|
fmt.Println("Seed directories (informational):")
|
|
|
|
seedDir := config.ResolveSeedDir()
|
|
if info, err := os.Stat(seedDir); err == nil && info.IsDir() {
|
|
fmt.Printf(" [INFO] General seed directory: %s (present)\n", seedDir)
|
|
} else {
|
|
fmt.Printf(" [INFO] General seed directory: %s (not present)\n", seedDir)
|
|
}
|
|
|
|
projectSeedDir := config.ResolveProjectSeedDir()
|
|
if info, err := os.Stat(projectSeedDir); err == nil && info.IsDir() {
|
|
fmt.Printf(" [INFO] Project seed directory: %s (present)\n", projectSeedDir)
|
|
} else {
|
|
fmt.Printf(" [INFO] Project seed directory: %s (not present)\n", projectSeedDir)
|
|
}
|
|
|
|
// 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
|
|
}
|