项目文件夹

文件
wehub-resource-sync a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:33:42 +08:00

313 行
10 KiB
Go

package main
import (
"encoding/json"
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"github.com/spf13/cobra"
"github.com/zzet/gortex/internal/agents"
"github.com/zzet/gortex/internal/daemon"
)
// initDoctorCmd implements `gortex init doctor`. It re-runs every
// adapter's Detect + Plan against the current environment and
// reports drift — files we'd like to write, files that already
// exist, files whose contents look wrong. Zero writes: doctor
// never modifies disk.
//
// This is the zero-op form of the installer and the primary
// support-channel tool. Output defaults to a human-readable table;
// --json emits a machine-readable report for CI consumers.
var initDoctorCmd = &cobra.Command{
Use: "doctor",
Short: "Report the configured state of every Gortex integration without writing anything",
Args: cobra.NoArgs,
RunE: runInitDoctor,
}
var initDoctorJSON bool
func init() {
initDoctorCmd.Flags().BoolVar(&initDoctorJSON, "json", false, "emit a structured JSON report on stdout")
initCmd.AddCommand(initDoctorCmd)
}
// DoctorEnvironment captures the live wiring doctor can verify because
// Gortex owns the binary and the daemon — something a config-file-only
// integration cannot check: that the `gortex` the editor will launch is the
// one on PATH, and that the daemon it proxies to actually answers a handshake.
type DoctorEnvironment struct {
BinaryOnPath bool `json:"binary_on_path"`
BinaryPath string `json:"binary_path,omitempty"`
BinaryError string `json:"binary_error,omitempty"`
DaemonRunning bool `json:"daemon_running"`
DaemonSocket string `json:"daemon_socket,omitempty"`
DaemonVersion string `json:"daemon_version,omitempty"`
DaemonError string `json:"daemon_error,omitempty"`
}
// DoctorAgentReport is one agent's slice of the doctor output.
// Each planned file shows up in Files along with its observed
// status on disk (present, missing, …).
type DoctorAgentReport struct {
Name string `json:"name"`
DocsURL string `json:"docs_url,omitempty"`
Detected bool `json:"detected"`
Configured bool `json:"configured"`
Files []DoctorFileStatus `json:"files,omitempty"`
}
// DoctorFileStatus is the observed state of one file the adapter
// would touch. Status is the union of "present", "missing",
// "unreadable", "would-create", "would-merge". The first three
// describe disk observation; the last two carry the adapter's
// planned action so reviewers see both "what's there" and "what
// init would do" side by side.
type DoctorFileStatus struct {
Path string `json:"path"`
Status string `json:"status"`
Planned agents.ActionKind `json:"planned_action,omitempty"`
Keys []string `json:"keys,omitempty"`
ByteSize int64 `json:"byte_size,omitempty"`
Reason string `json:"reason,omitempty"`
// StanzaStatus is set for a present mcpServers file carrying a
// Gortex-authored entry: "current" when it matches the canonical stanza,
// "stale" when Gortex wrote it but the shape has since changed. Empty for
// files that are not Gortex MCP configs. StanzaHint carries the fix.
StanzaStatus string `json:"stanza_status,omitempty"`
StanzaHint string `json:"stanza_hint,omitempty"`
}
func runInitDoctor(cmd *cobra.Command, _ []string) error {
home, _ := os.UserHomeDir()
root, err := filepath.Abs(".")
if err != nil {
return err
}
env := agents.Env{
Root: root,
Home: home,
HookCommand: "gortex hook",
Mode: agents.ModeProject,
InstallHooks: true,
Stderr: nil, // suppress progress lines; doctor is read-only
}
doctorEnv := doctorEnvironment()
registry := buildRegistry()
reports := make([]DoctorAgentReport, 0, len(registry.All()))
for _, a := range registry.All() {
reports = append(reports, inspectAdapter(a, env))
}
if initDoctorJSON {
enc := json.NewEncoder(cmd.OutOrStdout())
enc.SetIndent("", " ")
return enc.Encode(map[string]any{"environment": doctorEnv, "agents": reports})
}
printDoctorEnvironment(cmd.OutOrStdout(), doctorEnv)
printDoctorHuman(cmd.OutOrStdout(), reports)
return nil
}
// doctorEnvironment probes the live wiring: whether `gortex` resolves on PATH
// and whether the daemon answers a handshake on its socket. Both are
// best-effort and never fail the command — doctor is a read-only diagnostic.
func doctorEnvironment() DoctorEnvironment {
out := DoctorEnvironment{DaemonSocket: daemon.SocketPath()}
if p, err := exec.LookPath("gortex"); err == nil {
out.BinaryOnPath = true
out.BinaryPath = p
} else {
out.BinaryError = err.Error()
}
// A real handshake (not just a socket stat) proves the daemon is alive and
// speaking the protocol this binary expects.
c, err := daemon.Dial(daemon.Handshake{Mode: daemon.ModeControl, ClientName: "init-doctor"})
if err != nil {
if errors.Is(err, daemon.ErrDaemonUnavailable) {
out.DaemonError = "no daemon running (start it with `gortex daemon start`)"
} else {
out.DaemonError = err.Error()
}
return out
}
defer c.Close()
out.DaemonRunning = true
out.DaemonVersion = c.Ack.DaemonVersion
return out
}
// inspectMCPStanza reads a present file, looks for a Gortex-authored entry
// under mcpServers, and reports whether it matches the canonical stanza
// ("current") or has drifted ("stale"). ok is false when the file is not a
// Gortex MCP config (not JSON, no mcpServers, or the entry is user-authored),
// so non-MCP planned files are left unannotated.
func inspectMCPStanza(path string) (status string, ok bool) {
raw, err := os.ReadFile(path)
if err != nil {
return "", false
}
var root map[string]any
if json.Unmarshal(raw, &root) != nil {
return "", false
}
servers, isMap := root["mcpServers"].(map[string]any)
if !isMap {
return "", false
}
entry, found := servers["gortex"]
if !found {
// Some configs key the server differently; accept any single
// Gortex-authored entry.
for _, v := range servers {
if agents.IsGortexAuthoredMCPEntry(v) {
entry, found = v, true
break
}
}
}
if !found || !agents.IsGortexAuthoredMCPEntry(entry) {
return "", false
}
if agents.MCPEntriesEqual(entry, agents.DefaultGortexMCPEntry()) {
return "current", true
}
return "stale", true
}
// inspectAdapter runs Detect + Plan for one adapter, then stats
// each planned file to fill in the observed status. Never calls
// Apply, so disk is untouched.
func inspectAdapter(a agents.Adapter, env agents.Env) DoctorAgentReport {
rep := DoctorAgentReport{Name: a.Name(), DocsURL: a.DocsURL()}
detected, _ := a.Detect(env)
rep.Detected = detected
plan, err := a.Plan(env)
if err != nil || plan == nil {
return rep
}
anyPresent := false
for _, pf := range plan.Files {
status := DoctorFileStatus{Path: pf.Path, Planned: pf.Action, Keys: pf.Keys}
info, err := os.Stat(pf.Path)
switch {
case err == nil:
anyPresent = true
status.Status = "present"
status.ByteSize = info.Size()
// For a present mcpServers config, verify the Gortex stanza is
// the current canonical shape — Gortex owns the migration logic,
// so doctor can flag an authored-but-outdated stanza and name the
// one-command fix instead of silently leaving a broken wiring.
if st, isStanza := inspectMCPStanza(pf.Path); isStanza {
status.StanzaStatus = st
if st == "stale" {
status.StanzaHint = "Gortex-authored but outdated — run `gortex install` to migrate"
}
}
case os.IsNotExist(err):
status.Status = "missing"
default:
status.Status = "unreadable"
status.Reason = err.Error()
}
rep.Files = append(rep.Files, status)
}
rep.Configured = anyPresent
return rep
}
// printDoctorEnvironment renders the live-wiring preamble: the binary on PATH
// and the daemon handshake. These two lines answer "is the integration
// actually live", not just "is the config file present".
func printDoctorEnvironment(w io.Writer, env DoctorEnvironment) {
fmt.Fprintln(w, "Gortex init doctor — environment:")
if env.BinaryOnPath {
fmt.Fprintf(w, " %s gortex on PATH: %s\n", glyphCheck, env.BinaryPath)
} else {
fmt.Fprintf(w, " %s gortex not found on PATH (%s)\n", glyphCross, env.BinaryError)
}
if env.DaemonRunning {
ver := env.DaemonVersion
if ver == "" {
ver = "ok"
}
fmt.Fprintf(w, " %s daemon handshake: %s (%s)\n", glyphCheck, ver, env.DaemonSocket)
} else {
fmt.Fprintf(w, " %s daemon handshake: %s\n", glyphCross, env.DaemonError)
}
fmt.Fprintln(w)
}
// glyphCheck / glyphCross are the doctor's status markers.
const (
glyphCheck = "✓"
glyphCross = "✗"
)
// printDoctorHuman renders the human-readable summary. One row per
// agent, with a nested file list. Columns line up for copy-paste
// into issue reports.
func printDoctorHuman(w io.Writer, reports []DoctorAgentReport) {
fmt.Fprintln(w, "Gortex init doctor — observed state of every adapter's planned files:")
fmt.Fprintln(w)
for _, r := range reports {
detMark := "–"
if r.Detected {
detMark = "✓"
}
cfgMark := "–"
if r.Configured {
cfgMark = "✓"
}
fmt.Fprintf(w, " [%s detected] [%s any-file-present] %s\n", detMark, cfgMark, r.Name)
for _, f := range r.Files {
statusSym := "?"
switch f.Status {
case "present":
statusSym = "✓"
case "missing":
statusSym = "✗"
case "unreadable":
statusSym = "!"
}
extra := ""
if f.Status == "present" && f.ByteSize > 0 {
extra = fmt.Sprintf(" (%d bytes)", f.ByteSize)
}
switch f.StanzaStatus {
case "stale":
extra += " [stale stanza: run `gortex install` to migrate]"
case "current":
extra += " [stanza current]"
}
if f.Status == "missing" && f.Planned != "" {
// Strip the "would-" prefix so we don't print
// "init would would-create".
verb := string(f.Planned)
if len(verb) > len("would-") && verb[:len("would-")] == "would-" {
verb = verb[len("would-"):]
}
extra = fmt.Sprintf(" (init would %s)", verb)
}
fmt.Fprintf(w, " %s %s%s\n", statusSym, f.Path, extra)
}
if r.DocsURL != "" {
fmt.Fprintf(w, " docs: %s\n", r.DocsURL)
}
fmt.Fprintln(w)
}
}