初始化仓库
This commit is contained in:
+508
@@ -0,0 +1,508 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type DoctorStatus int
|
||||
|
||||
const (
|
||||
DoctorPass DoctorStatus = iota
|
||||
DoctorWarn
|
||||
DoctorFail
|
||||
)
|
||||
|
||||
func (s DoctorStatus) Icon() string {
|
||||
switch s {
|
||||
case DoctorPass:
|
||||
return "✅"
|
||||
case DoctorWarn:
|
||||
return "⚠️"
|
||||
default:
|
||||
return "❌"
|
||||
}
|
||||
}
|
||||
|
||||
type DoctorCheckResult struct {
|
||||
Name string
|
||||
Status DoctorStatus
|
||||
Detail string
|
||||
Latency time.Duration
|
||||
}
|
||||
|
||||
// DoctorChecker is an optional interface for agents to provide specific health checks.
|
||||
type DoctorChecker interface {
|
||||
DoctorChecks(ctx context.Context) []DoctorCheckResult
|
||||
}
|
||||
|
||||
// AgentDoctorInfo is an optional interface agents can implement to provide
|
||||
// CLI binary name and display label for doctor checks, avoiding hardcoded
|
||||
// agent-specific knowledge in core.
|
||||
type AgentDoctorInfo interface {
|
||||
CLIBinaryName() string // e.g. "claude", "codex"
|
||||
CLIDisplayName() string // e.g. "Claude", "Codex" (for display in doctor output)
|
||||
}
|
||||
|
||||
// RunDoctorChecks performs all diagnostic checks.
|
||||
func RunDoctorChecks(ctx context.Context, agent Agent, platforms []Platform) []DoctorCheckResult {
|
||||
var results []DoctorCheckResult
|
||||
|
||||
results = append(results, checkAgentBinary(ctx, agent)...)
|
||||
results = append(results, checkAgentAuth(ctx, agent)...)
|
||||
results = append(results, checkPlatforms(platforms)...)
|
||||
results = append(results, checkSystem(ctx)...)
|
||||
results = append(results, checkDependencies()...)
|
||||
results = append(results, checkNetwork(ctx)...)
|
||||
|
||||
if dc, ok := agent.(DoctorChecker); ok {
|
||||
results = append(results, dc.DoctorChecks(ctx)...)
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
func agentCLIInfo(agent Agent) (bin, label string) {
|
||||
if info, ok := agent.(AgentDoctorInfo); ok {
|
||||
bin = info.CLIBinaryName()
|
||||
label = info.CLIDisplayName()
|
||||
}
|
||||
if bin == "" {
|
||||
bin = agent.Name()
|
||||
}
|
||||
if label == "" {
|
||||
label = bin
|
||||
}
|
||||
return bin, label
|
||||
}
|
||||
|
||||
func checkAgentBinary(ctx context.Context, agent Agent) []DoctorCheckResult {
|
||||
bin, _ := agentCLIInfo(agent)
|
||||
|
||||
path, err := exec.LookPath(bin)
|
||||
if err != nil {
|
||||
return []DoctorCheckResult{{
|
||||
Name: fmt.Sprintf("Agent CLI (%s)", bin),
|
||||
Status: DoctorFail,
|
||||
Detail: "not found in PATH",
|
||||
}}
|
||||
}
|
||||
|
||||
detail := path
|
||||
tctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer cancel()
|
||||
if out, err := exec.CommandContext(tctx, bin, "--version").Output(); err == nil {
|
||||
ver := strings.TrimSpace(string(out))
|
||||
if len(ver) > 80 {
|
||||
ver = ver[:80]
|
||||
}
|
||||
detail = ver
|
||||
}
|
||||
return []DoctorCheckResult{{
|
||||
Name: fmt.Sprintf("Agent CLI (%s)", bin),
|
||||
Status: DoctorPass,
|
||||
Detail: detail,
|
||||
}}
|
||||
}
|
||||
|
||||
func checkAgentAuth(ctx context.Context, agent Agent) []DoctorCheckResult {
|
||||
bin, label := agentCLIInfo(agent)
|
||||
return checkCLIAuth(ctx, bin, []string{"--version"}, label)
|
||||
}
|
||||
|
||||
func checkCLIAuth(ctx context.Context, bin string, args []string, label string) []DoctorCheckResult {
|
||||
tctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
start := time.Now()
|
||||
cmd := exec.CommandContext(tctx, bin, args...)
|
||||
out, err := cmd.CombinedOutput()
|
||||
latency := time.Since(start)
|
||||
|
||||
if err != nil {
|
||||
detail := strings.TrimSpace(string(out))
|
||||
if detail == "" {
|
||||
detail = err.Error()
|
||||
}
|
||||
if len(detail) > 120 {
|
||||
detail = detail[:120] + "..."
|
||||
}
|
||||
return []DoctorCheckResult{{
|
||||
Name: fmt.Sprintf("%s Auth", label),
|
||||
Status: DoctorWarn,
|
||||
Detail: detail,
|
||||
Latency: latency,
|
||||
}}
|
||||
}
|
||||
return []DoctorCheckResult{{
|
||||
Name: fmt.Sprintf("%s Auth", label),
|
||||
Status: DoctorPass,
|
||||
Detail: "OK",
|
||||
Latency: latency,
|
||||
}}
|
||||
}
|
||||
|
||||
func checkPlatforms(platforms []Platform) []DoctorCheckResult {
|
||||
var results []DoctorCheckResult
|
||||
for _, p := range platforms {
|
||||
results = append(results, DoctorCheckResult{
|
||||
Name: fmt.Sprintf("Platform (%s)", p.Name()),
|
||||
Status: DoctorPass,
|
||||
Detail: "connected",
|
||||
})
|
||||
}
|
||||
if len(results) == 0 {
|
||||
results = append(results, DoctorCheckResult{
|
||||
Name: "Platforms",
|
||||
Status: DoctorWarn,
|
||||
Detail: "no platforms configured",
|
||||
})
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
func checkSystem(ctx context.Context) []DoctorCheckResult {
|
||||
var results []DoctorCheckResult
|
||||
|
||||
// Memory
|
||||
var memStats runtime.MemStats
|
||||
runtime.ReadMemStats(&memStats)
|
||||
allocMB := memStats.Alloc / 1024 / 1024
|
||||
sysMB := memStats.Sys / 1024 / 1024
|
||||
results = append(results, DoctorCheckResult{
|
||||
Name: "Memory (Go runtime)",
|
||||
Status: DoctorPass,
|
||||
Detail: fmt.Sprintf("alloc %d MB / sys %d MB", allocMB, sysMB),
|
||||
})
|
||||
|
||||
// System memory (Linux)
|
||||
if runtime.GOOS == "linux" {
|
||||
if data, err := os.ReadFile("/proc/meminfo"); err == nil {
|
||||
var totalKB, availKB uint64
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
if strings.HasPrefix(line, "MemTotal:") {
|
||||
_, _ = fmt.Sscanf(line, "MemTotal: %d kB", &totalKB)
|
||||
} else if strings.HasPrefix(line, "MemAvailable:") {
|
||||
_, _ = fmt.Sscanf(line, "MemAvailable: %d kB", &availKB)
|
||||
}
|
||||
}
|
||||
if totalKB > 0 {
|
||||
totalMB := totalKB / 1024
|
||||
availMB := availKB / 1024
|
||||
usedPct := 100 - (availKB*100)/totalKB
|
||||
status := DoctorPass
|
||||
if usedPct > 90 {
|
||||
status = DoctorFail
|
||||
} else if usedPct > 75 {
|
||||
status = DoctorWarn
|
||||
}
|
||||
results = append(results, DoctorCheckResult{
|
||||
Name: "System Memory",
|
||||
Status: status,
|
||||
Detail: fmt.Sprintf("%d MB available / %d MB total (%d%% used)", availMB, totalMB, usedPct),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// CPU
|
||||
results = append(results, DoctorCheckResult{
|
||||
Name: "CPU",
|
||||
Status: DoctorPass,
|
||||
Detail: fmt.Sprintf("%d cores, %s/%s", runtime.NumCPU(), runtime.GOOS, runtime.GOARCH),
|
||||
})
|
||||
|
||||
// Load average (Linux/macOS)
|
||||
if runtime.GOOS == "linux" {
|
||||
if data, err := os.ReadFile("/proc/loadavg"); err == nil {
|
||||
parts := strings.Fields(string(data))
|
||||
if len(parts) >= 3 {
|
||||
status := DoctorPass
|
||||
detail := fmt.Sprintf("load avg: %s %s %s", parts[0], parts[1], parts[2])
|
||||
// Rough check: if 1-min load > 2x CPU count, warn
|
||||
var load1 float64
|
||||
_, _ = fmt.Sscanf(parts[0], "%f", &load1)
|
||||
if load1 > float64(runtime.NumCPU()*2) {
|
||||
status = DoctorWarn
|
||||
}
|
||||
results = append(results, DoctorCheckResult{
|
||||
Name: "CPU Load",
|
||||
Status: status,
|
||||
Detail: detail,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Disk space
|
||||
if wd, err := os.Getwd(); err == nil {
|
||||
tctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer cancel()
|
||||
if out, err := exec.CommandContext(tctx, "df", "-h", wd).Output(); err == nil {
|
||||
lines := strings.Split(strings.TrimSpace(string(out)), "\n")
|
||||
if len(lines) >= 2 {
|
||||
fields := strings.Fields(lines[len(lines)-1])
|
||||
if len(fields) >= 5 {
|
||||
status := DoctorPass
|
||||
usePct := strings.TrimSuffix(fields[4], "%")
|
||||
var pct int
|
||||
_, _ = fmt.Sscanf(usePct, "%d", &pct)
|
||||
if pct > 95 {
|
||||
status = DoctorFail
|
||||
} else if pct > 85 {
|
||||
status = DoctorWarn
|
||||
}
|
||||
results = append(results, DoctorCheckResult{
|
||||
Name: "Disk Space",
|
||||
Status: status,
|
||||
Detail: fmt.Sprintf("%s available / %s total (%s used)", fields[3], fields[1], fields[4]),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
func checkDependencies() []DoctorCheckResult {
|
||||
deps := []struct {
|
||||
bin string
|
||||
label string
|
||||
required bool
|
||||
}{
|
||||
{"git", "Git", true},
|
||||
{"sqlite3", "SQLite3", false},
|
||||
{"ffmpeg", "FFmpeg (voice)", false},
|
||||
}
|
||||
|
||||
var results []DoctorCheckResult
|
||||
for _, d := range deps {
|
||||
path, err := exec.LookPath(d.bin)
|
||||
if err != nil {
|
||||
status := DoctorWarn
|
||||
if d.required {
|
||||
status = DoctorFail
|
||||
}
|
||||
results = append(results, DoctorCheckResult{
|
||||
Name: d.label,
|
||||
Status: status,
|
||||
Detail: "not found",
|
||||
})
|
||||
} else {
|
||||
results = append(results, DoctorCheckResult{
|
||||
Name: d.label,
|
||||
Status: DoctorPass,
|
||||
Detail: path,
|
||||
})
|
||||
}
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
func checkNetwork(ctx context.Context) []DoctorCheckResult {
|
||||
endpoints := []struct {
|
||||
label string
|
||||
host string
|
||||
url string
|
||||
}{
|
||||
{"API (Anthropic)", "api.anthropic.com:443", "https://api.anthropic.com"},
|
||||
{"API (OpenAI)", "api.openai.com:443", "https://api.openai.com"},
|
||||
}
|
||||
|
||||
var results []DoctorCheckResult
|
||||
for _, ep := range endpoints {
|
||||
tctx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
start := time.Now()
|
||||
conn, err := (&net.Dialer{}).DialContext(tctx, "tcp", ep.host)
|
||||
latency := time.Since(start)
|
||||
cancel()
|
||||
|
||||
if err != nil {
|
||||
results = append(results, DoctorCheckResult{
|
||||
Name: ep.label,
|
||||
Status: DoctorWarn,
|
||||
Detail: fmt.Sprintf("connect failed: %v", err),
|
||||
Latency: latency,
|
||||
})
|
||||
continue
|
||||
}
|
||||
conn.Close()
|
||||
|
||||
status := DoctorPass
|
||||
if latency > 3*time.Second {
|
||||
status = DoctorWarn
|
||||
}
|
||||
results = append(results, DoctorCheckResult{
|
||||
Name: ep.label,
|
||||
Status: status,
|
||||
Detail: "TCP connect OK",
|
||||
Latency: latency,
|
||||
})
|
||||
}
|
||||
|
||||
// HTTP check to verify proxy/firewall
|
||||
tctx, cancel := context.WithTimeout(ctx, 8*time.Second)
|
||||
defer cancel()
|
||||
start := time.Now()
|
||||
client := &http.Client{Timeout: 8 * time.Second}
|
||||
req, _ := http.NewRequestWithContext(tctx, "HEAD", "https://api.anthropic.com", nil)
|
||||
resp, err := client.Do(req)
|
||||
latency := time.Since(start)
|
||||
if err != nil {
|
||||
results = append(results, DoctorCheckResult{
|
||||
Name: "HTTPS (Anthropic)",
|
||||
Status: DoctorWarn,
|
||||
Detail: "HTTPS request failed: " + err.Error(),
|
||||
Latency: latency,
|
||||
})
|
||||
} else {
|
||||
resp.Body.Close()
|
||||
status := DoctorPass
|
||||
if latency > 5*time.Second {
|
||||
status = DoctorWarn
|
||||
}
|
||||
results = append(results, DoctorCheckResult{
|
||||
Name: "HTTPS (Anthropic)",
|
||||
Status: status,
|
||||
Detail: fmt.Sprintf("HTTP %d", resp.StatusCode),
|
||||
Latency: latency,
|
||||
})
|
||||
}
|
||||
|
||||
// Check config file
|
||||
if cfgPath := os.Getenv("CC_CONFIG_PATH"); cfgPath != "" {
|
||||
if _, err := os.Stat(cfgPath); err != nil {
|
||||
results = append(results, DoctorCheckResult{
|
||||
Name: "Config File",
|
||||
Status: DoctorFail,
|
||||
Detail: cfgPath + ": " + err.Error(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Check data directory
|
||||
if home, err := os.UserHomeDir(); err == nil {
|
||||
dataDir := filepath.Join(home, ".cc-connect")
|
||||
if info, err := os.Stat(dataDir); err != nil {
|
||||
results = append(results, DoctorCheckResult{
|
||||
Name: "Data Directory",
|
||||
Status: DoctorWarn,
|
||||
Detail: dataDir + " does not exist",
|
||||
})
|
||||
} else if !info.IsDir() {
|
||||
results = append(results, DoctorCheckResult{
|
||||
Name: "Data Directory",
|
||||
Status: DoctorFail,
|
||||
Detail: dataDir + " is not a directory",
|
||||
})
|
||||
} else {
|
||||
results = append(results, DoctorCheckResult{
|
||||
Name: "Data Directory",
|
||||
Status: DoctorPass,
|
||||
Detail: dataDir,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
// checkNameZh provides Chinese translations for common check names.
|
||||
var checkNameZh = map[string]string{
|
||||
"Memory (Go runtime)": "内存 (Go runtime)",
|
||||
"System Memory": "系统内存",
|
||||
"CPU": "CPU",
|
||||
"CPU Load": "CPU 负载",
|
||||
"Disk Space": "磁盘空间",
|
||||
"Git": "Git",
|
||||
"SQLite3": "SQLite3",
|
||||
"FFmpeg (voice)": "FFmpeg (语音)",
|
||||
"HTTPS (Anthropic)": "HTTPS (Anthropic)",
|
||||
"Data Directory": "数据目录",
|
||||
"Config File": "配置文件",
|
||||
"Platforms": "平台",
|
||||
}
|
||||
|
||||
// checkNameJa provides Japanese translations for common check names.
|
||||
var checkNameJa = map[string]string{
|
||||
"Memory (Go runtime)": "メモリ (Go runtime)",
|
||||
"System Memory": "システムメモリ",
|
||||
"CPU Load": "CPU 負荷",
|
||||
"Disk Space": "ディスク容量",
|
||||
"FFmpeg (voice)": "FFmpeg (音声)",
|
||||
"Data Directory": "データディレクトリ",
|
||||
"Config File": "設定ファイル",
|
||||
"Platforms": "プラットフォーム",
|
||||
}
|
||||
|
||||
func localizeCheckName(name string, lang Language) string {
|
||||
switch lang {
|
||||
case LangChinese, LangTraditionalChinese:
|
||||
// Translate known names; parametric names (e.g. "Agent CLI (claude)") need prefix matching
|
||||
if zh, ok := checkNameZh[name]; ok {
|
||||
return zh
|
||||
}
|
||||
if strings.HasPrefix(name, "Agent CLI") {
|
||||
return strings.Replace(name, "Agent CLI", "Agent 命令行", 1)
|
||||
}
|
||||
if strings.HasPrefix(name, "Platform (") {
|
||||
return strings.Replace(name, "Platform", "平台", 1)
|
||||
}
|
||||
if strings.Contains(name, "Auth") {
|
||||
return strings.Replace(name, "Auth", "认证", 1)
|
||||
}
|
||||
case LangJapanese:
|
||||
if ja, ok := checkNameJa[name]; ok {
|
||||
return ja
|
||||
}
|
||||
if strings.HasPrefix(name, "Agent CLI") {
|
||||
return strings.Replace(name, "Agent CLI", "Agent CLI", 1)
|
||||
}
|
||||
if strings.HasPrefix(name, "Platform (") {
|
||||
return strings.Replace(name, "Platform", "プラットフォーム", 1)
|
||||
}
|
||||
if strings.Contains(name, "Auth") {
|
||||
return strings.Replace(name, "Auth", "認証", 1)
|
||||
}
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
// FormatDoctorResults formats check results using the i18n system.
|
||||
func FormatDoctorResults(results []DoctorCheckResult, i18n *I18n) string {
|
||||
lang := i18n.currentLang()
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString(i18n.T(MsgDoctorTitle))
|
||||
|
||||
passCount, warnCount, failCount := 0, 0, 0
|
||||
for _, r := range results {
|
||||
switch r.Status {
|
||||
case DoctorPass:
|
||||
passCount++
|
||||
case DoctorWarn:
|
||||
warnCount++
|
||||
case DoctorFail:
|
||||
failCount++
|
||||
}
|
||||
|
||||
displayName := localizeCheckName(r.Name, lang)
|
||||
latStr := ""
|
||||
if r.Latency > 0 {
|
||||
latStr = fmt.Sprintf(" (%s)", r.Latency.Round(time.Millisecond))
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("%s %s%s\n %s\n\n", r.Status.Icon(), displayName, latStr, r.Detail))
|
||||
}
|
||||
|
||||
sb.WriteString(i18n.Tf(MsgDoctorSummary, passCount, warnCount, failCount))
|
||||
return sb.String()
|
||||
}
|
||||
Reference in New Issue
Block a user