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
313 lines
10 KiB
Go
313 lines
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)
|
||
}
|
||
}
|