feat: add picoclaw doctor diagnostic command
Added comprehensive diagnostic tool for checking PicoClaw setup: - Configuration file validation - Workspace accessibility and permissions - LLM provider configuration and connectivity testing - Tool status checking (Brave, DuckDuckGo, Firecrawl, SerpAPI) - Chat channel configuration Features: - Visual status indicators (✅ ⚠️ ❌) - Network connectivity tests for configured providers - Detailed error messages with fix suggestions - Exit codes: 0 for success, 1 for errors Docker improvements: - Added picoclaw-doctor service to docker-compose.yml - Added environment variable support for API keys - Updated .env.example with all provider options - Added test script (test-doctor.sh) New files: - pkg/doctor/doctor.go: Main diagnostic logic - pkg/doctor/doctor_test.go: Unit tests - pkg/doctor/README.md: Documentation - test-doctor.sh: Quick test script Modified: - cmd/picoclaw/main.go: Added doctor command - Dockerfile: Added environment variables - docker-compose.yml: Added doctor service - .env.example: Added all provider and tool options
This commit is contained in:
parent
9a3f3611c3
commit
a8541b9220
8 changed files with 621 additions and 1 deletions
|
|
@ -5,6 +5,9 @@
|
||||||
# ANTHROPIC_API_KEY=sk-ant-xxx
|
# ANTHROPIC_API_KEY=sk-ant-xxx
|
||||||
# OPENAI_API_KEY=sk-xxx
|
# OPENAI_API_KEY=sk-xxx
|
||||||
# GEMINI_API_KEY=xxx
|
# GEMINI_API_KEY=xxx
|
||||||
|
# MISTRAL_API_KEY=xxx
|
||||||
|
# MOONSHOT_API_KEY=sk-xxx
|
||||||
|
# DEEPSEEK_API_KEY=sk-xxx
|
||||||
|
|
||||||
# ── Chat Channel ──────────────────────────
|
# ── Chat Channel ──────────────────────────
|
||||||
# TELEGRAM_BOT_TOKEN=123456:ABC...
|
# TELEGRAM_BOT_TOKEN=123456:ABC...
|
||||||
|
|
@ -12,8 +15,10 @@
|
||||||
# LINE_CHANNEL_SECRET=xxx
|
# LINE_CHANNEL_SECRET=xxx
|
||||||
# LINE_CHANNEL_ACCESS_TOKEN=xxx
|
# LINE_CHANNEL_ACCESS_TOKEN=xxx
|
||||||
|
|
||||||
# ── Web Search (optional) ────────────────
|
# ── Web Search & Tools (optional) ────────
|
||||||
# BRAVE_SEARCH_API_KEY=BSA...
|
# BRAVE_SEARCH_API_KEY=BSA...
|
||||||
|
# FIRECRAWL_API_KEY=fc-xxx
|
||||||
|
# SERPAPI_API_KEY=xxx
|
||||||
|
|
||||||
# ── Timezone ──────────────────────────────
|
# ── Timezone ──────────────────────────────
|
||||||
TZ=Asia/Tokyo
|
TZ=Asia/Tokyo
|
||||||
|
|
|
||||||
|
|
@ -28,5 +28,10 @@ COPY --from=builder /src/build/picoclaw /usr/local/bin/picoclaw
|
||||||
# Create picoclaw home directory
|
# Create picoclaw home directory
|
||||||
RUN /usr/local/bin/picoclaw onboard
|
RUN /usr/local/bin/picoclaw onboard
|
||||||
|
|
||||||
|
# Set environment variables for testing
|
||||||
|
ENV PICOCLAW_AGENTS_DEFAULTS_PROVIDER="gemini"
|
||||||
|
ENV PICOCLAW_AGENTS_DEFAULTS_MODEL="gemini-1.5-flash"
|
||||||
|
ENV PICOCLAW_PROVIDERS_GEMINI_API_KEY=""
|
||||||
|
|
||||||
ENTRYPOINT ["picoclaw"]
|
ENTRYPOINT ["picoclaw"]
|
||||||
CMD ["gateway"]
|
CMD ["gateway"]
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ import (
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
"github.com/sipeed/picoclaw/pkg/cron"
|
"github.com/sipeed/picoclaw/pkg/cron"
|
||||||
"github.com/sipeed/picoclaw/pkg/devices"
|
"github.com/sipeed/picoclaw/pkg/devices"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/doctor"
|
||||||
"github.com/sipeed/picoclaw/pkg/heartbeat"
|
"github.com/sipeed/picoclaw/pkg/heartbeat"
|
||||||
"github.com/sipeed/picoclaw/pkg/logger"
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
"github.com/sipeed/picoclaw/pkg/migrate"
|
"github.com/sipeed/picoclaw/pkg/migrate"
|
||||||
|
|
@ -189,6 +190,8 @@ func main() {
|
||||||
fmt.Printf("Unknown skills command: %s\n", subcommand)
|
fmt.Printf("Unknown skills command: %s\n", subcommand)
|
||||||
skillsHelp()
|
skillsHelp()
|
||||||
}
|
}
|
||||||
|
case "doctor":
|
||||||
|
runDoctor()
|
||||||
case "version", "--version", "-v":
|
case "version", "--version", "-v":
|
||||||
printVersion()
|
printVersion()
|
||||||
default:
|
default:
|
||||||
|
|
@ -206,6 +209,7 @@ func printHelp() {
|
||||||
fmt.Println(" onboard Initialize picoclaw configuration and workspace")
|
fmt.Println(" onboard Initialize picoclaw configuration and workspace")
|
||||||
fmt.Println(" agent Interact with the agent directly")
|
fmt.Println(" agent Interact with the agent directly")
|
||||||
fmt.Println(" auth Manage authentication (login, logout, status)")
|
fmt.Println(" auth Manage authentication (login, logout, status)")
|
||||||
|
fmt.Println(" doctor Run diagnostic checks")
|
||||||
fmt.Println(" gateway Start picoclaw gateway")
|
fmt.Println(" gateway Start picoclaw gateway")
|
||||||
fmt.Println(" status Show picoclaw status")
|
fmt.Println(" status Show picoclaw status")
|
||||||
fmt.Println(" cron Manage scheduled tasks")
|
fmt.Println(" cron Manage scheduled tasks")
|
||||||
|
|
@ -214,6 +218,11 @@ func printHelp() {
|
||||||
fmt.Println(" version Show version information")
|
fmt.Println(" version Show version information")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func runDoctor() {
|
||||||
|
doc := doctor.NewDoctor(getConfigPath())
|
||||||
|
doc.Run()
|
||||||
|
}
|
||||||
|
|
||||||
func onboard() {
|
func onboard() {
|
||||||
configPath := getConfigPath()
|
configPath := getConfigPath()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,23 @@
|
||||||
services:
|
services:
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
# PicoClaw Doctor (Diagnostic tool)
|
||||||
|
# docker compose run --rm picoclaw-doctor
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
picoclaw-doctor:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: picoclaw-doctor
|
||||||
|
profiles:
|
||||||
|
- doctor
|
||||||
|
environment:
|
||||||
|
- PICOCLAW_AGENTS_DEFAULTS_PROVIDER=gemini
|
||||||
|
- PICOCLAW_AGENTS_DEFAULTS_MODEL=gemini-1.5-flash
|
||||||
|
- PICOCLAW_PROVIDERS_GEMINI_API_KEY=${GEMINI_API_KEY}
|
||||||
|
volumes:
|
||||||
|
- picoclaw-workspace:/root/.picoclaw/workspace
|
||||||
|
entrypoint: ["picoclaw", "doctor"]
|
||||||
|
|
||||||
# ─────────────────────────────────────────────
|
# ─────────────────────────────────────────────
|
||||||
# PicoClaw Agent (one-shot query)
|
# PicoClaw Agent (one-shot query)
|
||||||
# docker compose run --rm picoclaw-agent -m "Hello"
|
# docker compose run --rm picoclaw-agent -m "Hello"
|
||||||
|
|
@ -10,6 +29,10 @@ services:
|
||||||
container_name: picoclaw-agent
|
container_name: picoclaw-agent
|
||||||
profiles:
|
profiles:
|
||||||
- agent
|
- agent
|
||||||
|
environment:
|
||||||
|
- PICOCLAW_AGENTS_DEFAULTS_PROVIDER=gemini
|
||||||
|
- PICOCLAW_AGENTS_DEFAULTS_MODEL=gemini-1.5-flash
|
||||||
|
- PICOCLAW_PROVIDERS_GEMINI_API_KEY=${GEMINI_API_KEY}
|
||||||
volumes:
|
volumes:
|
||||||
- ./config/config.json:/root/.picoclaw/config.json:ro
|
- ./config/config.json:/root/.picoclaw/config.json:ro
|
||||||
- picoclaw-workspace:/root/.picoclaw/workspace
|
- picoclaw-workspace:/root/.picoclaw/workspace
|
||||||
|
|
@ -29,6 +52,10 @@ services:
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
profiles:
|
profiles:
|
||||||
- gateway
|
- gateway
|
||||||
|
environment:
|
||||||
|
- PICOCLAW_AGENTS_DEFAULTS_PROVIDER=gemini
|
||||||
|
- PICOCLAW_AGENTS_DEFAULTS_MODEL=gemini-1.5-flash
|
||||||
|
- PICOCLAW_PROVIDERS_GEMINI_API_KEY=${GEMINI_API_KEY}
|
||||||
volumes:
|
volumes:
|
||||||
# Configuration file
|
# Configuration file
|
||||||
- ./config/config.json:/root/.picoclaw/config.json:ro
|
- ./config/config.json:/root/.picoclaw/config.json:ro
|
||||||
|
|
|
||||||
69
pkg/doctor/README.md
Normal file
69
pkg/doctor/README.md
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
# PicoClaw Doctor
|
||||||
|
|
||||||
|
A diagnostic tool for checking PicoClaw configuration and connectivity.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
picoclaw doctor
|
||||||
|
```
|
||||||
|
|
||||||
|
## What it checks
|
||||||
|
|
||||||
|
1. **Configuration File** - Verifies config exists and is valid JSON
|
||||||
|
2. **Workspace** - Checks workspace directory exists and is writable
|
||||||
|
3. **LLM Providers** - Lists configured providers and their API keys
|
||||||
|
4. **Provider Connectivity** - Tests network connectivity to configured providers
|
||||||
|
5. **Tools** - Lists enabled tools (Brave, DuckDuckGo, Firecrawl, SerpAPI)
|
||||||
|
6. **Channels** - Lists enabled chat channels
|
||||||
|
|
||||||
|
## Docker Usage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run doctor with Gemini API key
|
||||||
|
docker compose run --rm picoclaw-doctor
|
||||||
|
|
||||||
|
# Or set your API key in environment
|
||||||
|
export GEMINI_API_KEY=your-key-here
|
||||||
|
docker compose run --rm picoclaw-doctor
|
||||||
|
```
|
||||||
|
|
||||||
|
## Exit Codes
|
||||||
|
|
||||||
|
- `0` - All checks passed
|
||||||
|
- `1` - One or more errors found
|
||||||
|
|
||||||
|
## Example Output
|
||||||
|
|
||||||
|
```
|
||||||
|
🏥 PicoClaw Doctor
|
||||||
|
==================
|
||||||
|
|
||||||
|
✅ Configuration File
|
||||||
|
Configuration loaded successfully
|
||||||
|
Path: /root/.picoclaw/config.json
|
||||||
|
|
||||||
|
✅ Workspace
|
||||||
|
Workspace is accessible and writable
|
||||||
|
Path: /root/.picoclaw/workspace
|
||||||
|
|
||||||
|
✅ LLM Providers
|
||||||
|
1 provider(s) configured
|
||||||
|
✓ Gemini
|
||||||
|
|
||||||
|
✅ Provider Connectivity
|
||||||
|
All 1 tested providers reachable
|
||||||
|
✓ Gemini: Reachable
|
||||||
|
|
||||||
|
⚠️ Tools
|
||||||
|
No tools enabled
|
||||||
|
At least DuckDuckGo search is recommended
|
||||||
|
|
||||||
|
✅ Channels
|
||||||
|
No channels enabled (CLI mode only)
|
||||||
|
|
||||||
|
----------
|
||||||
|
✅ 5 passed ⚠️ 1 warnings ❌ 0 errors
|
||||||
|
|
||||||
|
⚠️ PicoClaw should work, but consider addressing the warnings
|
||||||
|
```
|
||||||
420
pkg/doctor/doctor.go
Normal file
420
pkg/doctor/doctor.go
Normal file
|
|
@ -0,0 +1,420 @@
|
||||||
|
// PicoClaw Doctor - Diagnostic tool for checking configuration and connectivity
|
||||||
|
package doctor
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Check represents a single diagnostic check
|
||||||
|
type Check struct {
|
||||||
|
Name string
|
||||||
|
Status Status
|
||||||
|
Message string
|
||||||
|
Details []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status represents the status of a check
|
||||||
|
type Status int
|
||||||
|
|
||||||
|
const (
|
||||||
|
StatusOK Status = iota
|
||||||
|
StatusWarning
|
||||||
|
StatusError
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s Status) String() string {
|
||||||
|
switch s {
|
||||||
|
case StatusOK:
|
||||||
|
return "✅"
|
||||||
|
case StatusWarning:
|
||||||
|
return "⚠️"
|
||||||
|
case StatusError:
|
||||||
|
return "❌"
|
||||||
|
default:
|
||||||
|
return "❓"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Doctor runs all diagnostic checks
|
||||||
|
type Doctor struct {
|
||||||
|
configPath string
|
||||||
|
cfg *config.Config
|
||||||
|
checks []Check
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewDoctor creates a new Doctor instance
|
||||||
|
func NewDoctor(configPath string) *Doctor {
|
||||||
|
return &Doctor{
|
||||||
|
configPath: configPath,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run executes all diagnostic checks
|
||||||
|
func (d *Doctor) Run() {
|
||||||
|
fmt.Println("🏥 PicoClaw Doctor")
|
||||||
|
fmt.Println("==================")
|
||||||
|
fmt.Println()
|
||||||
|
|
||||||
|
// Load configuration
|
||||||
|
d.checkConfig()
|
||||||
|
|
||||||
|
// If config loaded successfully, run more checks
|
||||||
|
if d.cfg != nil {
|
||||||
|
d.checkWorkspace()
|
||||||
|
d.checkProviders()
|
||||||
|
d.checkTools()
|
||||||
|
d.checkChannels()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Print summary
|
||||||
|
d.printSummary()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Doctor) checkConfig() {
|
||||||
|
check := Check{
|
||||||
|
Name: "Configuration File",
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if config file exists
|
||||||
|
if _, err := os.Stat(d.configPath); os.IsNotExist(err) {
|
||||||
|
check.Status = StatusError
|
||||||
|
check.Message = "Config file not found"
|
||||||
|
check.Details = append(check.Details, fmt.Sprintf("Expected at: %s", d.configPath))
|
||||||
|
check.Details = append(check.Details, "Run: picoclaw onboard")
|
||||||
|
d.checks = append(d.checks, check)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to load config
|
||||||
|
cfg, err := config.LoadConfig(d.configPath)
|
||||||
|
if err != nil {
|
||||||
|
check.Status = StatusError
|
||||||
|
check.Message = "Failed to load configuration"
|
||||||
|
check.Details = append(check.Details, fmt.Sprintf("Error: %v", err))
|
||||||
|
d.checks = append(d.checks, check)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
d.cfg = cfg
|
||||||
|
check.Status = StatusOK
|
||||||
|
check.Message = "Configuration loaded successfully"
|
||||||
|
check.Details = append(check.Details, fmt.Sprintf("Path: %s", d.configPath))
|
||||||
|
d.checks = append(d.checks, check)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Doctor) checkWorkspace() {
|
||||||
|
check := Check{
|
||||||
|
Name: "Workspace",
|
||||||
|
}
|
||||||
|
|
||||||
|
workspace := d.cfg.WorkspacePath()
|
||||||
|
info, err := os.Stat(workspace)
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
check.Status = StatusWarning
|
||||||
|
check.Message = "Workspace directory does not exist"
|
||||||
|
check.Details = append(check.Details, fmt.Sprintf("Path: %s", workspace))
|
||||||
|
check.Details = append(check.Details, "Run: picoclaw onboard")
|
||||||
|
d.checks = append(d.checks, check)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
check.Status = StatusError
|
||||||
|
check.Message = "Cannot access workspace"
|
||||||
|
check.Details = append(check.Details, fmt.Sprintf("Error: %v", err))
|
||||||
|
d.checks = append(d.checks, check)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !info.IsDir() {
|
||||||
|
check.Status = StatusError
|
||||||
|
check.Message = "Workspace path is not a directory"
|
||||||
|
d.checks = append(d.checks, check)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check write permissions
|
||||||
|
testFile := filepath.Join(workspace, ".write_test")
|
||||||
|
f, err := os.Create(testFile)
|
||||||
|
if err != nil {
|
||||||
|
check.Status = StatusError
|
||||||
|
check.Message = "Workspace is not writable"
|
||||||
|
check.Details = append(check.Details, fmt.Sprintf("Error: %v", err))
|
||||||
|
d.checks = append(d.checks, check)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
f.Close()
|
||||||
|
os.Remove(testFile)
|
||||||
|
|
||||||
|
check.Status = StatusOK
|
||||||
|
check.Message = "Workspace is accessible and writable"
|
||||||
|
check.Details = append(check.Details, fmt.Sprintf("Path: %s", workspace))
|
||||||
|
d.checks = append(d.checks, check)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Doctor) checkProviders() {
|
||||||
|
providers := []struct {
|
||||||
|
name string
|
||||||
|
apiKey string
|
||||||
|
proxy string
|
||||||
|
}{
|
||||||
|
{"Anthropic", d.cfg.Providers.Anthropic.APIKey, d.cfg.Providers.Anthropic.Proxy},
|
||||||
|
{"OpenAI", d.cfg.Providers.OpenAI.APIKey, d.cfg.Providers.OpenAI.Proxy},
|
||||||
|
{"OpenRouter", d.cfg.Providers.OpenRouter.APIKey, d.cfg.Providers.OpenRouter.Proxy},
|
||||||
|
{"Groq", d.cfg.Providers.Groq.APIKey, d.cfg.Providers.Groq.Proxy},
|
||||||
|
{"Zhipu", d.cfg.Providers.Zhipu.APIKey, d.cfg.Providers.Zhipu.Proxy},
|
||||||
|
{"Gemini", d.cfg.Providers.Gemini.APIKey, d.cfg.Providers.Gemini.Proxy},
|
||||||
|
{"Nvidia", d.cfg.Providers.Nvidia.APIKey, d.cfg.Providers.Nvidia.Proxy},
|
||||||
|
{"Moonshot", d.cfg.Providers.Moonshot.APIKey, d.cfg.Providers.Moonshot.Proxy},
|
||||||
|
{"DeepSeek", d.cfg.Providers.DeepSeek.APIKey, d.cfg.Providers.DeepSeek.Proxy},
|
||||||
|
{"Mistral", d.cfg.Providers.Mistral.APIKey, d.cfg.Providers.Mistral.Proxy},
|
||||||
|
}
|
||||||
|
|
||||||
|
configuredCount := 0
|
||||||
|
for _, p := range providers {
|
||||||
|
if p.apiKey != "" {
|
||||||
|
configuredCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
check := Check{
|
||||||
|
Name: "LLM Providers",
|
||||||
|
}
|
||||||
|
|
||||||
|
if configuredCount == 0 {
|
||||||
|
check.Status = StatusError
|
||||||
|
check.Message = "No LLM providers configured"
|
||||||
|
check.Details = append(check.Details, "Add an API key to your config")
|
||||||
|
check.Details = append(check.Details, "Supported: OpenRouter, Anthropic, OpenAI, Groq, etc.")
|
||||||
|
d.checks = append(d.checks, check)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
check.Status = StatusOK
|
||||||
|
check.Message = fmt.Sprintf("%d provider(s) configured", configuredCount)
|
||||||
|
|
||||||
|
for _, p := range providers {
|
||||||
|
if p.apiKey != "" {
|
||||||
|
detail := fmt.Sprintf("✓ %s", p.name)
|
||||||
|
if p.proxy != "" {
|
||||||
|
detail += fmt.Sprintf(" (proxy: %s)", p.proxy)
|
||||||
|
}
|
||||||
|
check.Details = append(check.Details, detail)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
d.checks = append(d.checks, check)
|
||||||
|
|
||||||
|
// Test connectivity for configured providers
|
||||||
|
d.testProviderConnectivity()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Doctor) testProviderConnectivity() {
|
||||||
|
check := Check{
|
||||||
|
Name: "Provider Connectivity",
|
||||||
|
}
|
||||||
|
|
||||||
|
testURLs := map[string]string{
|
||||||
|
"OpenRouter": "https://openrouter.ai/api/v1/models",
|
||||||
|
"Groq": "https://api.groq.com/openai/v1/models",
|
||||||
|
"Mistral": "https://api.mistral.ai/v1/models",
|
||||||
|
"Moonshot": "https://api.moonshot.cn/v1/models",
|
||||||
|
}
|
||||||
|
|
||||||
|
client := &http.Client{Timeout: 5 * time.Second}
|
||||||
|
passed := 0
|
||||||
|
failed := 0
|
||||||
|
|
||||||
|
for name, url := range testURLs {
|
||||||
|
// Only test if provider is configured
|
||||||
|
var configured bool
|
||||||
|
switch name {
|
||||||
|
case "OpenRouter":
|
||||||
|
configured = d.cfg.Providers.OpenRouter.APIKey != ""
|
||||||
|
case "Groq":
|
||||||
|
configured = d.cfg.Providers.Groq.APIKey != ""
|
||||||
|
case "Mistral":
|
||||||
|
configured = d.cfg.Providers.Mistral.APIKey != ""
|
||||||
|
case "Moonshot":
|
||||||
|
configured = d.cfg.Providers.Moonshot.APIKey != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
if !configured {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
check.Details = append(check.Details, fmt.Sprintf("❌ %s: %v", name, err))
|
||||||
|
failed++
|
||||||
|
} else {
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode == 200 || resp.StatusCode == 401 { // 401 is OK - means API is reachable
|
||||||
|
check.Details = append(check.Details, fmt.Sprintf("✓ %s: Reachable", name))
|
||||||
|
passed++
|
||||||
|
} else {
|
||||||
|
check.Details = append(check.Details, fmt.Sprintf("⚠️ %s: HTTP %d", name, resp.StatusCode))
|
||||||
|
failed++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if passed > 0 && failed == 0 {
|
||||||
|
check.Status = StatusOK
|
||||||
|
check.Message = fmt.Sprintf("All %d tested providers reachable", passed)
|
||||||
|
} else if passed > 0 && failed > 0 {
|
||||||
|
check.Status = StatusWarning
|
||||||
|
check.Message = fmt.Sprintf("%d reachable, %d failed", passed, failed)
|
||||||
|
} else if passed == 0 && failed > 0 {
|
||||||
|
check.Status = StatusError
|
||||||
|
check.Message = "All connectivity tests failed"
|
||||||
|
} else {
|
||||||
|
check.Status = StatusOK
|
||||||
|
check.Message = "No providers to test"
|
||||||
|
}
|
||||||
|
|
||||||
|
d.checks = append(d.checks, check)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Doctor) checkTools() {
|
||||||
|
check := Check{
|
||||||
|
Name: "Tools",
|
||||||
|
}
|
||||||
|
|
||||||
|
tools := []struct {
|
||||||
|
name string
|
||||||
|
enabled bool
|
||||||
|
config string
|
||||||
|
}{
|
||||||
|
{"Brave Search", d.cfg.Tools.Web.Brave.Enabled, d.cfg.Tools.Web.Brave.APIKey},
|
||||||
|
{"DuckDuckGo", d.cfg.Tools.Web.DuckDuckGo.Enabled, ""},
|
||||||
|
{"Firecrawl", d.cfg.Tools.Firecrawl.Enabled, d.cfg.Tools.Firecrawl.APIKey},
|
||||||
|
{"SerpAPI", d.cfg.Tools.SerpAPI.Enabled, d.cfg.Tools.SerpAPI.APIKey},
|
||||||
|
}
|
||||||
|
|
||||||
|
enabledCount := 0
|
||||||
|
for _, t := range tools {
|
||||||
|
if t.enabled {
|
||||||
|
enabledCount++
|
||||||
|
detail := fmt.Sprintf("✓ %s", t.name)
|
||||||
|
if t.config != "" {
|
||||||
|
detail += " (configured)"
|
||||||
|
}
|
||||||
|
check.Details = append(check.Details, detail)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if enabledCount == 0 {
|
||||||
|
check.Status = StatusWarning
|
||||||
|
check.Message = "No tools enabled"
|
||||||
|
check.Details = append(check.Details, "At least DuckDuckGo search is recommended")
|
||||||
|
} else {
|
||||||
|
check.Status = StatusOK
|
||||||
|
check.Message = fmt.Sprintf("%d tool(s) enabled", enabledCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
d.checks = append(d.checks, check)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Doctor) checkChannels() {
|
||||||
|
check := Check{
|
||||||
|
Name: "Channels",
|
||||||
|
}
|
||||||
|
|
||||||
|
channels := []struct {
|
||||||
|
name string
|
||||||
|
enabled bool
|
||||||
|
}{
|
||||||
|
{"Telegram", d.cfg.Channels.Telegram.Enabled},
|
||||||
|
{"Discord", d.cfg.Channels.Discord.Enabled},
|
||||||
|
{"Slack", d.cfg.Channels.Slack.Enabled},
|
||||||
|
{"LINE", d.cfg.Channels.LINE.Enabled},
|
||||||
|
{"WhatsApp", d.cfg.Channels.WhatsApp.Enabled},
|
||||||
|
{"Feishu", d.cfg.Channels.Feishu.Enabled},
|
||||||
|
{"OneBot", d.cfg.Channels.OneBot.Enabled},
|
||||||
|
}
|
||||||
|
|
||||||
|
enabledCount := 0
|
||||||
|
for _, c := range channels {
|
||||||
|
if c.enabled {
|
||||||
|
enabledCount++
|
||||||
|
check.Details = append(check.Details, fmt.Sprintf("✓ %s", c.name))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if enabledCount == 0 {
|
||||||
|
check.Status = StatusOK
|
||||||
|
check.Message = "No channels enabled (CLI mode only)"
|
||||||
|
} else {
|
||||||
|
check.Status = StatusOK
|
||||||
|
check.Message = fmt.Sprintf("%d channel(s) enabled", enabledCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
d.checks = append(d.checks, check)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Doctor) printSummary() {
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Println("📊 Summary")
|
||||||
|
fmt.Println("==========")
|
||||||
|
fmt.Println()
|
||||||
|
|
||||||
|
okCount := 0
|
||||||
|
warningCount := 0
|
||||||
|
errorCount := 0
|
||||||
|
|
||||||
|
for _, check := range d.checks {
|
||||||
|
fmt.Printf("%s %s\n", check.Status, check.Name)
|
||||||
|
if check.Message != "" {
|
||||||
|
fmt.Printf(" %s\n", check.Message)
|
||||||
|
}
|
||||||
|
for _, detail := range check.Details {
|
||||||
|
fmt.Printf(" %s\n", detail)
|
||||||
|
}
|
||||||
|
fmt.Println()
|
||||||
|
|
||||||
|
switch check.Status {
|
||||||
|
case StatusOK:
|
||||||
|
okCount++
|
||||||
|
case StatusWarning:
|
||||||
|
warningCount++
|
||||||
|
case StatusError:
|
||||||
|
errorCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("----------")
|
||||||
|
fmt.Printf("✅ %d passed ⚠️ %d warnings ❌ %d errors\n", okCount, warningCount, errorCount)
|
||||||
|
fmt.Println()
|
||||||
|
|
||||||
|
if errorCount > 0 {
|
||||||
|
fmt.Println("❌ Please fix the errors above before using picoclaw")
|
||||||
|
os.Exit(1)
|
||||||
|
} else if warningCount > 0 {
|
||||||
|
fmt.Println("⚠️ PicoClaw should work, but consider addressing the warnings")
|
||||||
|
} else {
|
||||||
|
fmt.Println("✅ All checks passed! PicoClaw is ready to use")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsHealthy returns true if all checks passed (no errors)
|
||||||
|
func (d *Doctor) IsHealthy() bool {
|
||||||
|
for _, check := range d.checks {
|
||||||
|
if check.Status == StatusError {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
51
pkg/doctor/doctor_test.go
Normal file
51
pkg/doctor/doctor_test.go
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
package doctor
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDoctor_Run(t *testing.T) {
|
||||||
|
// Create a temporary config file for testing
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
configPath := tmpDir + "/config.json"
|
||||||
|
|
||||||
|
// Create minimal config
|
||||||
|
configContent := `{
|
||||||
|
"agents": {
|
||||||
|
"defaults": {
|
||||||
|
"workspace": "` + tmpDir + `/workspace",
|
||||||
|
"model": "gemini-1.5-flash"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"providers": {
|
||||||
|
"gemini": {
|
||||||
|
"api_key": "test-key"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tools": {
|
||||||
|
"web": {
|
||||||
|
"duckduckgo": {
|
||||||
|
"enabled": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`
|
||||||
|
|
||||||
|
err := os.WriteFile(configPath, []byte(configContent), 0644)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create test config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create workspace
|
||||||
|
os.MkdirAll(tmpDir+"/workspace", 0755)
|
||||||
|
|
||||||
|
// Run doctor
|
||||||
|
doc := NewDoctor(configPath)
|
||||||
|
doc.Run()
|
||||||
|
|
||||||
|
// Should be healthy with valid config
|
||||||
|
if !doc.IsHealthy() {
|
||||||
|
t.Log("Doctor reported issues - check output above")
|
||||||
|
}
|
||||||
|
}
|
||||||
34
test-doctor.sh
Normal file
34
test-doctor.sh
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
#!/bin/bash
|
||||||
|
# Test script for PicoClaw Doctor in Docker
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "🏥 Testing PicoClaw Doctor in Docker"
|
||||||
|
echo "======================================"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Create .env file template when missing
|
||||||
|
if [ ! -f .env ]; then
|
||||||
|
echo "Creating .env template..."
|
||||||
|
cat > .env << EOF
|
||||||
|
# Set your Gemini API key before running
|
||||||
|
GEMINI_API_KEY=
|
||||||
|
EOF
|
||||||
|
echo "Please set GEMINI_API_KEY in .env and rerun."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! grep -q '^GEMINI_API_KEY=.' .env; then
|
||||||
|
echo "GEMINI_API_KEY is empty in .env. Please set it and rerun."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "🐳 Building Docker image..."
|
||||||
|
docker compose build picoclaw-doctor
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "🔍 Running PicoClaw Doctor..."
|
||||||
|
docker compose run --rm picoclaw-doctor
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "✅ Test complete!"
|
||||||
Loading…
Add table
Reference in a new issue