feat: add picoclaw doctor command for diagnosing common problems

Checks workspace structure, config validation, session integrity
(orphan tool_calls, missing tool_results), and auth credential health.
Supports --fix flag for auto-repair (delete corrupt sessions, fix
permissions, refresh expired tokens).
This commit is contained in:
Rahul Bansal 2026-02-21 02:18:40 +05:30
parent 9453606f9c
commit 8c87c73e7d
4 changed files with 725 additions and 0 deletions

112
cmd/picoclaw/cmd_doctor.go Normal file
View file

@ -0,0 +1,112 @@
package main
import (
"fmt"
"os"
"github.com/sipeed/picoclaw/pkg/doctor"
)
func doctorCmd() {
fix := false
for _, arg := range os.Args[2:] {
switch arg {
case "--fix", "-fix":
fix = true
case "--help", "-h":
fmt.Println("Usage: picoclaw doctor [--fix]")
fmt.Println()
fmt.Println("Checks for common problems:")
fmt.Println(" - Workspace structure (config dir, workspace dir, permissions)")
fmt.Println(" - Config validation (model_list, default model, provider prefixes)")
fmt.Println(" - Session integrity (orphan tool_calls, missing tool_results)")
fmt.Println(" - Auth credentials (expired tokens, reachability)")
fmt.Println()
fmt.Println("Flags:")
fmt.Println(" --fix Attempt to automatically fix problems")
return
}
}
fmt.Printf("%s picoclaw doctor\n\n", logo)
opts := doctor.Options{
Fix: fix,
}
findings := doctor.Run(opts)
// Group findings by check
errors := 0
warns := 0
fixed := 0
for _, f := range findings {
icon := f.Severity.Icon()
switch f.Severity {
case doctor.SeverityInfo:
fmt.Printf(" [%s] %s\n", icon, f.Message)
case doctor.SeverityWarn:
fmt.Printf(" [%s] %s\n", icon, f.Message)
warns++
case doctor.SeverityError:
fmt.Printf(" [%s] %s\n", icon, f.Message)
errors++
}
// Auto-fix if requested and available
if fix && f.FixFunc != nil {
fmt.Printf(" -> fixing: %s ... ", f.Fix)
if err := f.FixFunc(); err != nil {
fmt.Printf("FAILED: %v\n", err)
} else {
fmt.Printf("OK\n")
fixed++
// Downgrade the counts since we fixed it
if f.Severity == doctor.SeverityError {
errors--
} else if f.Severity == doctor.SeverityWarn {
warns--
}
}
}
}
fmt.Println()
if errors == 0 && warns == 0 {
fmt.Printf("%s All checks passed!\n", logo)
} else {
summary := fmt.Sprintf("%s Found", logo)
if errors > 0 {
summary += fmt.Sprintf(" %d error(s)", errors)
}
if warns > 0 {
if errors > 0 {
summary += " and"
}
summary += fmt.Sprintf(" %d warning(s)", warns)
}
if fixed > 0 {
summary += fmt.Sprintf(" (%d fixed)", fixed)
}
fmt.Println(summary)
// Hint about --fix if there were fixable problems and --fix wasn't used
if !fix {
hasFixable := false
for _, f := range findings {
if f.FixFunc != nil {
hasFixable = true
break
}
}
if hasFixable {
fmt.Println(" Run 'picoclaw doctor --fix' to attempt automatic fixes")
}
}
}
if errors > 0 {
os.Exit(1)
}
}

View file

@ -115,6 +115,8 @@ func main() {
authCmd() authCmd()
case "cron": case "cron":
cronCmd() cronCmd()
case "doctor":
doctorCmd()
case "skills": case "skills":
if len(os.Args) < 3 { if len(os.Args) < 3 {
skillsHelp() skillsHelp()
@ -186,6 +188,7 @@ func printHelp() {
fmt.Println(" cron Manage scheduled tasks") fmt.Println(" cron Manage scheduled tasks")
fmt.Println(" migrate Migrate from OpenClaw to PicoClaw") fmt.Println(" migrate Migrate from OpenClaw to PicoClaw")
fmt.Println(" skills Manage skills (install, list, remove)") fmt.Println(" skills Manage skills (install, list, remove)")
fmt.Println(" doctor Check for common problems (--fix to auto-repair)")
fmt.Println(" version Show version information") fmt.Println(" version Show version information")
} }

505
pkg/doctor/doctor.go Normal file
View file

@ -0,0 +1,505 @@
package doctor
import (
"encoding/json"
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"github.com/sipeed/picoclaw/pkg/auth"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/providers"
)
// Severity classifies how bad a problem is.
type Severity int
const (
SeverityInfo Severity = iota
SeverityWarn
SeverityError
)
func (s Severity) String() string {
switch s {
case SeverityInfo:
return "info"
case SeverityWarn:
return "warn"
case SeverityError:
return "ERROR"
default:
return "?"
}
}
func (s Severity) Icon() string {
switch s {
case SeverityInfo:
return "i"
case SeverityWarn:
return "!"
case SeverityError:
return "x"
default:
return "?"
}
}
// Finding is a single problem or observation.
type Finding struct {
Check string
Severity Severity
Message string
Fix string // non-empty if auto-fixable
FixFunc func() error
}
// Result is what a check function returns.
type Result struct {
Findings []Finding
}
func (r *Result) Add(check string, sev Severity, msg string) {
r.Findings = append(r.Findings, Finding{Check: check, Severity: sev, Message: msg})
}
func (r *Result) AddFixable(check string, sev Severity, msg, fix string, fn func() error) {
r.Findings = append(r.Findings, Finding{Check: check, Severity: sev, Message: msg, Fix: fix, FixFunc: fn})
}
func (r *Result) OK(check, msg string) {
r.Add(check, SeverityInfo, msg)
}
func (r *Result) Warn(check, msg string) {
r.Add(check, SeverityWarn, msg)
}
func (r *Result) Error(check, msg string) {
r.Add(check, SeverityError, msg)
}
// Options controls doctor behavior.
type Options struct {
Fix bool // attempt auto-fixes
ConfigDir string // ~/.picoclaw
}
// Run executes all checks and returns findings.
func Run(opts Options) []Finding {
if opts.ConfigDir == "" {
home, _ := os.UserHomeDir()
opts.ConfigDir = filepath.Join(home, ".picoclaw")
}
var all []Finding
checks := []func(Options) Result{
checkWorkspace,
checkConfig,
checkSessions,
checkAuth,
}
for _, check := range checks {
r := check(opts)
all = append(all, r.Findings...)
}
return all
}
// ---------------------------------------------------------------------------
// Check: workspace structure
// ---------------------------------------------------------------------------
func checkWorkspace(opts Options) Result {
var r Result
check := "workspace"
configDir := opts.ConfigDir
if _, err := os.Stat(configDir); os.IsNotExist(err) {
r.Error(check, fmt.Sprintf("config directory missing: %s", configDir))
return r
}
r.OK(check, fmt.Sprintf("config directory exists: %s", configDir))
configFile := filepath.Join(configDir, "config.json")
if _, err := os.Stat(configFile); os.IsNotExist(err) {
r.Error(check, "config.json missing — run 'picoclaw onboard'")
return r
}
r.OK(check, "config.json exists")
// Load config to find workspace path
cfg, err := config.LoadConfig(configFile)
if err != nil {
r.Error(check, fmt.Sprintf("config.json parse error: %v", err))
return r
}
ws := cfg.WorkspacePath()
if _, err := os.Stat(ws); os.IsNotExist(err) {
r.Warn(check, fmt.Sprintf("workspace directory missing: %s", ws))
} else {
r.OK(check, fmt.Sprintf("workspace directory exists: %s", ws))
}
sessionsDir := filepath.Join(ws, "sessions")
if _, err := os.Stat(sessionsDir); os.IsNotExist(err) {
r.OK(check, "sessions directory does not exist yet (will be created on first use)")
} else {
r.OK(check, fmt.Sprintf("sessions directory exists: %s", sessionsDir))
}
// Check auth.json permissions
authFile := filepath.Join(configDir, "auth.json")
if info, err := os.Stat(authFile); err == nil {
perm := info.Mode().Perm()
if perm&0077 != 0 {
r.AddFixable(check, SeverityWarn,
fmt.Sprintf("auth.json has loose permissions: %o (should be 600)", perm),
"chmod 600 auth.json",
func() error { return os.Chmod(authFile, 0600) },
)
} else {
r.OK(check, "auth.json permissions OK (600)")
}
}
return r
}
// ---------------------------------------------------------------------------
// Check: config validation
// ---------------------------------------------------------------------------
func checkConfig(opts Options) Result {
var r Result
check := "config"
configFile := filepath.Join(opts.ConfigDir, "config.json")
cfg, err := config.LoadConfig(configFile)
if err != nil {
r.Error(check, fmt.Sprintf("cannot load config: %v", err))
return r
}
defaultModel := cfg.Agents.Defaults.Model
if defaultModel == "" {
r.Error(check, "no default model configured")
return r
}
r.OK(check, fmt.Sprintf("default model: %s", defaultModel))
if len(cfg.ModelList) == 0 {
r.Error(check, "model_list is empty — no models configured")
return r
}
r.OK(check, fmt.Sprintf("%d model(s) in model_list", len(cfg.ModelList)))
// Check each model entry
foundDefault := false
for i, m := range cfg.ModelList {
if err := m.Validate(); err != nil {
r.Error(check, fmt.Sprintf("model_list[%d] (%s): %v", i, m.ModelName, err))
continue
}
// Check provider prefix
parts := strings.SplitN(m.Model, "/", 2)
if len(parts) < 2 {
r.Warn(check, fmt.Sprintf("model_list[%d] (%s): model identifier %q missing provider/ prefix", i, m.ModelName, m.Model))
}
// Check auth: needs either api_key or auth_method
if m.APIKey == "" && m.AuthMethod == "" {
r.Warn(check, fmt.Sprintf("model_list[%d] (%s): no api_key or auth_method set", i, m.ModelName))
}
if m.ModelName == defaultModel {
foundDefault = true
}
}
if !foundDefault {
r.Error(check, fmt.Sprintf("default model %q not found in model_list — agent will fail to start", defaultModel))
} else {
r.OK(check, fmt.Sprintf("default model %q found in model_list", defaultModel))
}
return r
}
// ---------------------------------------------------------------------------
// Check: session integrity
// ---------------------------------------------------------------------------
// sessionFile is the raw JSON structure we load for inspection.
type sessionFile struct {
Key string `json:"key"`
Messages []providers.Message `json:"messages"`
Summary string `json:"summary,omitempty"`
Created time.Time `json:"created"`
Updated time.Time `json:"updated"`
}
func checkSessions(opts Options) Result {
var r Result
check := "sessions"
configFile := filepath.Join(opts.ConfigDir, "config.json")
cfg, err := config.LoadConfig(configFile)
if err != nil {
r.Error(check, fmt.Sprintf("cannot load config to find workspace: %v", err))
return r
}
sessionsDir := filepath.Join(cfg.WorkspacePath(), "sessions")
entries, err := os.ReadDir(sessionsDir)
if err != nil {
if os.IsNotExist(err) {
r.OK(check, "no sessions directory — nothing to check")
return r
}
r.Error(check, fmt.Sprintf("cannot read sessions directory: %v", err))
return r
}
sessionCount := 0
for _, entry := range entries {
if entry.IsDir() || filepath.Ext(entry.Name()) != ".json" {
continue
}
sessionCount++
filePath := filepath.Join(sessionsDir, entry.Name())
data, err := os.ReadFile(filePath)
if err != nil {
r.Error(check, fmt.Sprintf("%s: cannot read: %v", entry.Name(), err))
continue
}
var sess sessionFile
if err := json.Unmarshal(data, &sess); err != nil {
r.Error(check, fmt.Sprintf("%s: invalid JSON: %v", entry.Name(), err))
continue
}
problems := checkSessionMessages(sess.Messages)
if len(problems) == 0 {
r.OK(check, fmt.Sprintf("%s: %d messages, OK", entry.Name(), len(sess.Messages)))
} else {
for _, p := range problems {
r.AddFixable(check, SeverityError,
fmt.Sprintf("%s: %s", entry.Name(), p),
"remove corrupt session file",
makeSessionDeleteFunc(filePath),
)
}
}
}
if sessionCount == 0 {
r.OK(check, "no session files found")
}
return r
}
// checkSessionMessages inspects a message array for common corruption patterns.
func checkSessionMessages(msgs []providers.Message) []string {
var problems []string
// Build a set of tool_call IDs that have a corresponding tool result.
toolResultIDs := map[string]bool{}
for _, m := range msgs {
if m.Role == "tool" && m.ToolCallID != "" {
toolResultIDs[m.ToolCallID] = true
}
}
for i, m := range msgs {
// Check: assistant message with tool_calls must be followed by tool results
if m.Role == "assistant" && len(m.ToolCalls) > 0 {
for _, tc := range m.ToolCalls {
id := tc.ID
if id == "" {
// Use Function.Name for identification
name := tc.Name
if tc.Function != nil {
name = tc.Function.Name
}
problems = append(problems, fmt.Sprintf("message[%d]: tool_call has empty ID (tool: %s)", i, name))
continue
}
if !toolResultIDs[id] {
name := tc.Name
if tc.Function != nil {
name = tc.Function.Name
}
problems = append(problems, fmt.Sprintf("message[%d]: orphan tool_call %q (tool: %s) — no matching tool result", i, id, name))
}
}
}
// Check: tool result must have a corresponding tool_call
if m.Role == "tool" && m.ToolCallID != "" {
found := false
for _, prev := range msgs[:i] {
if prev.Role != "assistant" {
continue
}
for _, tc := range prev.ToolCalls {
if tc.ID == m.ToolCallID {
found = true
break
}
}
if found {
break
}
}
if !found {
problems = append(problems, fmt.Sprintf("message[%d]: orphan tool_result %q — no matching tool_call", i, m.ToolCallID))
}
}
// Check: empty content on non-tool messages is suspicious (not always an error)
if m.Role == "assistant" && m.Content == "" && len(m.ToolCalls) == 0 {
problems = append(problems, fmt.Sprintf("message[%d]: assistant message with empty content and no tool_calls", i))
}
// Check: consecutive same-role messages (user, user) — some providers reject this
if i > 0 && m.Role == msgs[i-1].Role && m.Role == "user" {
problems = append(problems, fmt.Sprintf("message[%d]: consecutive user messages (some providers reject this)", i))
}
}
return problems
}
func makeSessionDeleteFunc(path string) func() error {
return func() error {
return os.Remove(path)
}
}
// ---------------------------------------------------------------------------
// Check: auth credential health
// ---------------------------------------------------------------------------
func checkAuth(opts Options) Result {
var r Result
check := "auth"
store, err := auth.LoadStore()
if err != nil {
r.Error(check, fmt.Sprintf("cannot load auth store: %v", err))
return r
}
if len(store.Credentials) == 0 {
r.Warn(check, "no credentials stored — run 'picoclaw auth login'")
return r
}
r.OK(check, fmt.Sprintf("%d credential(s) found", len(store.Credentials)))
for provider, cred := range store.Credentials {
prefix := fmt.Sprintf("[%s]", provider)
if cred.AccessToken == "" {
r.Error(check, fmt.Sprintf("%s access_token is empty", prefix))
continue
}
switch cred.AuthMethod {
case "oauth":
if cred.ExpiresAt.IsZero() {
r.Warn(check, fmt.Sprintf("%s OAuth token has no expiry set", prefix))
} else if cred.IsExpired() {
if cred.RefreshToken != "" {
r.Warn(check, fmt.Sprintf("%s OAuth token expired at %s (refresh token available)", prefix, cred.ExpiresAt.Format(time.RFC3339)))
// Try a refresh
if provider == "anthropic" {
r.AddFixable(check, SeverityWarn,
fmt.Sprintf("%s token expired — can attempt refresh", prefix),
"refresh Anthropic OAuth token",
func() error { return tryRefreshAnthropic(cred) },
)
}
} else {
r.Error(check, fmt.Sprintf("%s OAuth token expired at %s (no refresh token)", prefix, cred.ExpiresAt.Format(time.RFC3339)))
}
} else {
remaining := time.Until(cred.ExpiresAt).Truncate(time.Minute)
r.OK(check, fmt.Sprintf("%s OAuth token valid (expires in %s)", prefix, remaining))
if cred.NeedsRefresh() {
r.Warn(check, fmt.Sprintf("%s token expires within 5 minutes — will need refresh soon", prefix))
}
}
if cred.Email != "" {
r.OK(check, fmt.Sprintf("%s email: %s", prefix, cred.Email))
}
if cred.SubscriptionType != "" {
r.OK(check, fmt.Sprintf("%s plan: %s", prefix, cred.SubscriptionType))
}
case "token", "":
// Paste token — just check it looks non-empty
r.OK(check, fmt.Sprintf("%s API key/token present (length %d)", prefix, len(cred.AccessToken)))
default:
r.Warn(check, fmt.Sprintf("%s unknown auth_method: %s", prefix, cred.AuthMethod))
}
// Check: can we actually reach the provider's API?
if provider == "anthropic" {
checkAnthropicReachable(&r, check, prefix)
} else if provider == "openai" {
checkOpenAIReachable(&r, check, prefix)
}
}
return r
}
// tryRefreshAnthropic attempts to refresh an expired Anthropic OAuth token.
func tryRefreshAnthropic(cred *auth.AuthCredential) error {
return auth.RefreshAnthropicCredential(cred)
}
func checkAnthropicReachable(r *Result, check, prefix string) {
client := &http.Client{Timeout: 5 * time.Second}
resp, err := client.Get("https://api.anthropic.com/v1/models")
if err != nil {
r.Warn(check, fmt.Sprintf("%s cannot reach api.anthropic.com: %v", prefix, err))
return
}
resp.Body.Close()
// 401 is expected without auth — it means the endpoint is reachable
if resp.StatusCode == 401 || resp.StatusCode == 200 || resp.StatusCode == 403 {
r.OK(check, fmt.Sprintf("%s api.anthropic.com reachable", prefix))
} else {
r.Warn(check, fmt.Sprintf("%s api.anthropic.com returned unexpected status: %d", prefix, resp.StatusCode))
}
}
func checkOpenAIReachable(r *Result, check, prefix string) {
client := &http.Client{Timeout: 5 * time.Second}
resp, err := client.Get("https://api.openai.com/v1/models")
if err != nil {
r.Warn(check, fmt.Sprintf("%s cannot reach api.openai.com: %v", prefix, err))
return
}
resp.Body.Close()
if resp.StatusCode == 401 || resp.StatusCode == 200 || resp.StatusCode == 403 {
r.OK(check, fmt.Sprintf("%s api.openai.com reachable", prefix))
} else {
r.Warn(check, fmt.Sprintf("%s api.openai.com returned unexpected status: %d", prefix, resp.StatusCode))
}
}

105
pkg/doctor/doctor_test.go Normal file
View file

@ -0,0 +1,105 @@
package doctor
import (
"testing"
"github.com/sipeed/picoclaw/pkg/providers"
)
func TestCheckSessionMessages_Clean(t *testing.T) {
msgs := []providers.Message{
{Role: "user", Content: "hello"},
{Role: "assistant", Content: "hi there"},
}
problems := checkSessionMessages(msgs)
if len(problems) != 0 {
t.Errorf("expected no problems, got %v", problems)
}
}
func TestCheckSessionMessages_OrphanToolCall(t *testing.T) {
msgs := []providers.Message{
{
Role: "assistant",
Content: "let me check",
ToolCalls: []providers.ToolCall{
{ID: "call_123", Name: "exec"},
},
},
{Role: "user", Content: "hi"},
}
problems := checkSessionMessages(msgs)
if len(problems) != 1 {
t.Fatalf("expected 1 problem, got %d: %v", len(problems), problems)
}
if problems[0] == "" {
t.Error("problem message should not be empty")
}
}
func TestCheckSessionMessages_MatchedToolCall(t *testing.T) {
msgs := []providers.Message{
{
Role: "assistant",
Content: "let me check",
ToolCalls: []providers.ToolCall{
{ID: "call_123", Name: "exec"},
},
},
{Role: "tool", Content: "output", ToolCallID: "call_123"},
{Role: "assistant", Content: "done"},
}
problems := checkSessionMessages(msgs)
if len(problems) != 0 {
t.Errorf("expected no problems, got %v", problems)
}
}
func TestCheckSessionMessages_OrphanToolResult(t *testing.T) {
msgs := []providers.Message{
{Role: "user", Content: "hello"},
{Role: "tool", Content: "output", ToolCallID: "call_orphan"},
}
problems := checkSessionMessages(msgs)
if len(problems) != 1 {
t.Fatalf("expected 1 problem, got %d: %v", len(problems), problems)
}
}
func TestCheckSessionMessages_EmptyAssistant(t *testing.T) {
msgs := []providers.Message{
{Role: "user", Content: "hello"},
{Role: "assistant", Content: ""},
}
problems := checkSessionMessages(msgs)
if len(problems) != 1 {
t.Fatalf("expected 1 problem, got %d: %v", len(problems), problems)
}
}
func TestCheckSessionMessages_ConsecutiveUserMessages(t *testing.T) {
msgs := []providers.Message{
{Role: "user", Content: "hello"},
{Role: "user", Content: "hello again"},
}
problems := checkSessionMessages(msgs)
if len(problems) != 1 {
t.Fatalf("expected 1 problem, got %d: %v", len(problems), problems)
}
}
func TestSeverityString(t *testing.T) {
tests := []struct {
sev Severity
want string
}{
{SeverityInfo, "info"},
{SeverityWarn, "warn"},
{SeverityError, "ERROR"},
}
for _, tt := range tests {
if got := tt.sev.String(); got != tt.want {
t.Errorf("Severity(%d).String() = %q, want %q", tt.sev, got, tt.want)
}
}
}