diff --git a/internal/cmd/mcp_doctor.go b/internal/cmd/mcp_doctor.go index 60c7ba5..31dba09 100644 --- a/internal/cmd/mcp_doctor.go +++ b/internal/cmd/mcp_doctor.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "io" + "sort" "strings" "time" @@ -23,6 +24,7 @@ var ( mcpDoctorFix bool mcpDoctorBundle bool mcpDoctorBundlePath string + mcpDoctorVerbose bool ) var mcpDoctorCmd = &cobra.Command{ @@ -45,6 +47,9 @@ VSCodium, per-profile and workspace configs, duplicate entries, settings and Windows Group Policy that disable MCP or Agent mode, and VS Code's own MCP log for the server. +By default only a summary and the checks that need attention are shown; +--verbose lists every check. + Every failing check prints how to fix it. --fix repairs what the CLI can (stale or missing registrations, a broken venv, a system proxy the server isn't using) and re-runs the checks. @@ -55,6 +60,9 @@ Exits non-zero if any check fails.`, Example: ` # Full diagnostic, including live handshake armis-cli mcp doctor + # Show every check, not just the summary + armis-cli mcp doctor --verbose + # Diagnose and repair what can be repaired automatically armis-cli mcp doctor --fix @@ -78,6 +86,7 @@ func init() { mcpDoctorCmd.Flags().BoolVar(&mcpDoctorFix, "fix", false, "Repair fixable problems (re-register editors, rebuild the venv), then re-check") mcpDoctorCmd.Flags().BoolVar(&mcpDoctorBundle, "bundle", false, "Write a support bundle zip (credentials removed) for Armis support") mcpDoctorCmd.Flags().StringVar(&mcpDoctorBundlePath, "bundle-path", "", "Where to write the support bundle (implies --bundle; default: ./armis-mcp-doctor-.zip)") + mcpDoctorCmd.Flags().BoolVarP(&mcpDoctorVerbose, "verbose", "v", false, "List every check, including passing ones and informational notes") } func runMCPDoctor(cmd *cobra.Command, _ []string) error { @@ -96,7 +105,7 @@ func runMCPDoctor(cmd *cobra.Command, _ []string) error { report := install.RunDoctor(opts) if mcpDoctorFormat == agentFormatPlain { - printMCPDoctorPlain(stderr, report, !mcpDoctorFix) + printMCPDoctorPlain(stderr, report, !mcpDoctorFix, mcpDoctorVerbose) } if mcpDoctorFix { @@ -108,7 +117,7 @@ func runMCPDoctor(cmd *cobra.Command, _ []string) error { _, _ = fmt.Fprintln(stderr, "\nRe-running checks...") report = install.RunDoctor(opts) if mcpDoctorFormat == agentFormatPlain { - printMCPDoctorPlain(stderr, report, false) + printMCPDoctorPlain(stderr, report, false, mcpDoctorVerbose) } } } @@ -201,28 +210,22 @@ func printMCPDoctorJSON(cmd *cobra.Command, report *install.DoctorReport) error return enc.Encode(report) } -func printMCPDoctorPlain(out io.Writer, report *install.DoctorReport, suggestFix bool) { +func printMCPDoctorPlain(out io.Writer, report *install.DoctorReport, suggestFix, verbose bool) { if len(report.Checks) == 0 { _, _ = fmt.Fprintln(out, "No checks produced any output.") return } accessible := !cli.ColorsEnabled() - width := 20 - for _, c := range report.Checks { - width = max(width, len(c.Name)) + var hiddenNotes []string + if verbose { + printDoctorChecksVerbose(out, report.Checks, accessible) + } else { + hiddenNotes = printDoctorChecksCompact(out, report.Checks, accessible) } - var lastComponent string + var passed, warned, failed int for _, c := range report.Checks { - if c.Component != lastComponent { - _, _ = fmt.Fprintf(out, "%s:\n", c.Component) - lastComponent = c.Component - } - _, _ = fmt.Fprintf(out, " %s %-*s %s\n", statusSymbol(c.Status, accessible), width, c.Name, c.Detail) - if c.Remediation != "" && c.Status != install.StatusOK { - printRemediation(out, c.Remediation) - } switch c.Status { case install.StatusOK: passed++ @@ -232,7 +235,6 @@ func printMCPDoctorPlain(out io.Writer, report *install.DoctorReport, suggestFix failed++ } } - _, _ = fmt.Fprintf(out, "\n%d passed, %d warnings, %d failed\n", passed, warned, failed) if suggestFix && len(report.Fixes()) > 0 { _, _ = fmt.Fprintln(out, "Some of these can be repaired automatically: armis-cli mcp doctor --fix") @@ -240,6 +242,151 @@ func printMCPDoctorPlain(out io.Writer, report *install.DoctorReport, suggestFix if warned+failed > 0 { _, _ = fmt.Fprintln(out, "Still stuck? Run 'armis-cli mcp doctor --bundle' and send the zip to Armis support.") } + if !verbose { + if len(hiddenNotes) > 0 { + _, _ = fmt.Fprintf(out, "Every check, plus notes on %s: armis-cli mcp doctor --verbose\n", strings.Join(hiddenNotes, ", ")) + } else { + _, _ = fmt.Fprintln(out, "Every check: armis-cli mcp doctor --verbose") + } + } +} + +// printDoctorChecksVerbose prints every check, grouped by component. +func printDoctorChecksVerbose(out io.Writer, checks []install.DoctorCheck, accessible bool) { + width := 20 + for _, c := range checks { + width = max(width, len(c.Name)) + } + var lastComponent string + for _, c := range checks { + if c.Component != lastComponent { + _, _ = fmt.Fprintf(out, "%s:\n", c.Component) + lastComponent = c.Component + } + _, _ = fmt.Fprintf(out, " %s %-*s %s\n", statusSymbol(c.Status, accessible), width, c.Name, c.Detail) + if c.Remediation != "" && c.Status != install.StatusOK { + printRemediation(out, c.Remediation) + } + } +} + +// doctorRow is one line of the compact view: a passing summary, or a single +// problem check with its hint. +type doctorRow struct { + status install.CheckStatus + label string + text string + check *install.DoctorCheck // set for problem rows +} + +// printDoctorChecksCompact prints one line per component and one for its +// editors when everything passes, and only the failing or warning checks +// otherwise. It returns the names of the informational checks it left out. +func printDoctorChecksCompact(out io.Writer, checks []install.DoctorCheck, accessible bool) []string { + var components []string + byComponent := make(map[string][]install.DoctorCheck) + for _, c := range checks { + if _, ok := byComponent[c.Component]; !ok { + components = append(components, c.Component) + } + byComponent[c.Component] = append(byComponent[c.Component], c) + } + + var rows []doctorRow + var notes []string + for _, comp := range components { + var server, editors []install.DoctorCheck + for _, c := range byComponent[comp] { + switch { + case c.Status == install.StatusInfo: + notes = append(notes, c.Name) + case c.Editor != "": + editors = append(editors, c) + default: + server = append(server, c) + } + } + rows = append(rows, compactServerRows(comp, server)...) + label := "editors" + if comp != "scanner" { + label = comp + " editors" + } + rows = append(rows, compactEditorRows(label, editors)...) + } + + width := 0 + for _, r := range rows { + width = max(width, len(r.label)) + } + for _, r := range rows { + _, _ = fmt.Fprintf(out, " %s %-*s %s\n", statusSymbol(r.status, accessible), width, r.label, r.text) + if r.check != nil && r.check.Remediation != "" { + printRemediation(out, r.check.Remediation) + } + } + return notes +} + +func compactServerRows(label string, checks []install.DoctorCheck) []doctorRow { + if len(checks) == 0 { + return nil + } + if problems := problemRows(label, checks); len(problems) > 0 { + return problems + } + var parts []string + for _, c := range checks { + if c.Summary != "" { + parts = append(parts, c.Summary) + } + } + text := strings.Join(parts, " · ") + switch { + case text != "": + case len(checks) == 1: + text = checks[0].Detail + default: + text = fmt.Sprintf("%d checks passed", len(checks)) + } + return []doctorRow{{status: install.StatusOK, label: label, text: text}} +} + +// compactEditorRows lists every editor whose checks all pass on one line, +// followed by the problem checks of the rest. +func compactEditorRows(label string, checks []install.DoctorCheck) []doctorRow { + failing := make(map[string]bool) + for _, c := range checks { + if c.Status != install.StatusOK { + failing[c.Editor] = true + } + } + var healthy []string + seen := make(map[string]bool) + for _, c := range checks { + if !failing[c.Editor] && !seen[c.Editor] { + seen[c.Editor] = true + healthy = append(healthy, c.Editor) + } + } + sort.Strings(healthy) + + var rows []doctorRow + if len(healthy) > 0 { + rows = append(rows, doctorRow{status: install.StatusOK, label: label, text: strings.Join(healthy, ", ")}) + } + return append(rows, problemRows(label, checks)...) +} + +func problemRows(label string, checks []install.DoctorCheck) []doctorRow { + var rows []doctorRow + for i := range checks { + c := &checks[i] + if c.Status == install.StatusOK { + continue + } + rows = append(rows, doctorRow{status: c.Status, label: label, text: c.Name + ": " + c.Detail, check: c}) + } + return rows } // printRemediation prints a check's hint under it, indenting continuation diff --git a/internal/cmd/mcp_doctor_test.go b/internal/cmd/mcp_doctor_test.go index 0730ca9..3e137cf 100644 --- a/internal/cmd/mcp_doctor_test.go +++ b/internal/cmd/mcp_doctor_test.go @@ -20,7 +20,7 @@ func TestPrintMCPDoctorPlain(t *testing.T) { }} var out bytes.Buffer - printMCPDoctorPlain(&out, report, true) + printMCPDoctorPlain(&out, report, true, true) got := out.String() for _, want := range []string{ @@ -43,13 +43,77 @@ func TestPrintMCPDoctorPlain(t *testing.T) { t.Errorf("columns not aligned to the longest name:\n%s", got) } + if strings.Contains(got, "--verbose") { + t.Error("--verbose suggested in verbose output") + } + out.Reset() - printMCPDoctorPlain(&out, report, false) + printMCPDoctorPlain(&out, report, false, true) if strings.Contains(out.String(), "--fix") { t.Error("--fix suggested after --fix already ran") } } +func TestPrintMCPDoctorPlainCompact(t *testing.T) { + healthy := []install.DoctorCheck{ + {Component: "scanner", Name: "plugin version", Status: install.StatusOK, Detail: "v1.2.2", Summary: "v1.2.2"}, + {Component: "scanner", Name: "python venv", Status: install.StatusOK, Detail: "/venv/python"}, + {Component: "scanner", Name: "tools", Status: install.StatusOK, Detail: "5 tools: a, b", Summary: "5 tools"}, + {Component: "scanner", Name: "Cursor", Editor: "Cursor", Status: install.StatusOK, Detail: "/cursor/mcp.json"}, + {Component: "scanner", Name: "Cursor launch", Editor: "Cursor", Status: install.StatusOK, Detail: "same launch command"}, + {Component: "scanner", Name: "Antigravity", Editor: "Antigravity", Status: install.StatusOK, Detail: "/ag/mcp_config.json"}, + {Component: "vscode", Name: "VS Code settings", Status: install.StatusOK, Detail: "MCP and Agent mode not disabled"}, + {Component: "vscode", Name: "Copilot", Status: install.StatusInfo, Detail: "can't be verified", Remediation: "1. Switch to Agent mode"}, + } + + t.Run("all passing", func(t *testing.T) { + var out bytes.Buffer + printMCPDoctorPlain(&out, &install.DoctorReport{Checks: healthy}, true, false) + got := out.String() + for _, want := range []string{ + "scanner v1.2.2 · 5 tools\n", + "editors Antigravity, Cursor\n", + "vscode MCP and Agent mode not disabled\n", + "7 passed, 0 warnings, 0 failed", + "notes on Copilot: armis-cli mcp doctor --verbose", + } { + if !strings.Contains(got, want) { + t.Errorf("output missing %q:\n%s", want, got) + } + } + for _, hidden := range []string{"/venv/python", "same launch command", "Agent mode\n", "--bundle"} { + if strings.Contains(got, hidden) { + t.Errorf("compact output shows %q:\n%s", hidden, got) + } + } + }) + + t.Run("problems expand", func(t *testing.T) { + checks := append([]install.DoctorCheck(nil), healthy...) + checks[1] = install.DoctorCheck{Component: "scanner", Name: "python venv", Status: install.StatusFail, + Detail: "missing", Remediation: "Rebuild the venv"} + checks[4] = install.DoctorCheck{Component: "scanner", Name: "Cursor launch", Editor: "Cursor", Status: install.StatusFail, + Detail: "exited", Remediation: "Check Cursor"} + var out bytes.Buffer + printMCPDoctorPlain(&out, &install.DoctorReport{Checks: checks}, true, false) + got := out.String() + for _, want := range []string{ + "scanner python venv: missing\n → Rebuild the venv\n", + "editors Antigravity\n", + "editors Cursor launch: exited\n → Check Cursor\n", + "--bundle", + } { + if !strings.Contains(got, want) { + t.Errorf("output missing %q:\n%s", want, got) + } + } + // A failing component shows its problems, not its summary. + if strings.Contains(got, "v1.2.2 · 5 tools") { + t.Errorf("summary shown for a failing component:\n%s", got) + } + }) +} + func stubDoctorUpdate(t *testing.T) *[]bool { t.Helper() var calls []bool @@ -163,7 +227,7 @@ func TestRunMCPDoctorWritesBundle(t *testing.T) { } func TestMCPDoctorFlags(t *testing.T) { - for name, def := range map[string]string{"fix": "false", "bundle": "false", "bundle-path": "", "no-handshake": "false"} { + for name, def := range map[string]string{"fix": "false", "bundle": "false", "bundle-path": "", "no-handshake": "false", "verbose": "false"} { f := mcpDoctorCmd.Flags().Lookup(name) if f == nil { t.Errorf("mcp doctor is missing --%s", name) diff --git a/internal/install/doctor.go b/internal/install/doctor.go index 87d6840..5608576 100644 --- a/internal/install/doctor.go +++ b/internal/install/doctor.go @@ -84,6 +84,12 @@ type DoctorCheck struct { Detail string `json:"detail"` Remediation string `json:"remediation,omitempty"` Fix FixAction `json:"fix,omitempty"` + // Editor names the editor this check is about ("" for checks on the + // server itself), so the compact view can fold editors into one line. + Editor string `json:"editor,omitempty"` + // Summary is a short phrase for the compact view ("5 tools"). Checks + // without one are only counted there. + Summary string `json:"-"` } // hint attaches remediation text a user can act on without support. @@ -92,6 +98,18 @@ func (c *DoctorCheck) hint(remediation string) *DoctorCheck { return c } +// short sets the check's phrase for the compact view. +func (c *DoctorCheck) short(summary string) *DoctorCheck { + c.Summary = summary + return c +} + +// forEditor tags the check as being about the named editor. +func (c *DoctorCheck) forEditor(name string) *DoctorCheck { + c.Editor = name + return c +} + // fix marks the check as repairable by `mcp doctor --fix`. func (c *DoctorCheck) fix(action FixAction, remediation string) *DoctorCheck { c.Fix = action @@ -120,6 +138,12 @@ func (r *DoctorReport) add(component, name string, status CheckStatus, detail st return &r.Checks[len(r.Checks)-1] } +// addEditor appends a check about an editor's registration, named after the +// editor. +func (r *DoctorReport) addEditor(component, editor string, status CheckStatus, detail string) *DoctorCheck { + return r.add(component, editor, status, detail).forEditor(editor) +} + func (r *DoctorReport) artifact(name, content string) { if r.Artifacts == nil { r.Artifacts = make(map[string]string) @@ -257,8 +281,8 @@ func (d *doctorRun) workspaceDir() string { } type probeOutcome struct { - label string // component/name of the check that ran it - ok bool + component, name string // the check that ran it + ok bool } func newDoctorRun(opts DoctorOptions) *doctorRun { @@ -328,7 +352,7 @@ func checkScannerPlugin(d *doctorRun, ei *EditorInstaller) { report.add(component, "plugin version", StatusWarn, "no installed version recorded"). fix(FixReinstall, "Reinstall the plugin: armis-cli mcp doctor --fix") } else { - report.add(component, "plugin version", StatusOK, "v"+v) + report.add(component, "plugin version", StatusOK, "v"+v).short("v" + v) } if !isExecutableFile(pythonPath) { @@ -402,7 +426,7 @@ func checkKnowledgePlugin(d *doctorRun, k *ManifestKnowledge) { report := d.report if k.SHA != "" { - report.add(component, "bridge commit", StatusOK, k.SHA) + report.add(component, "bridge commit", StatusOK, k.SHA).short("commit " + k.SHA[:min(len(k.SHA), 7)]) } found := false @@ -522,7 +546,8 @@ func checkAuth(d *doctorRun, component string, env map[string]string) { } return } - d.report.add(component, "authentication", StatusOK, "client credentials accepted") + d.report.add(component, "authentication", StatusOK, "the client ID and secret in .env were accepted by the Armis API"). + short("credentials accepted") } // checkServerNetwork runs the network probe with the server's own Python @@ -546,7 +571,14 @@ func checkServerNetwork(d *doctorRun, component, python string, env map[string]s } return } - d.report.add(component, "server network", StatusOK, fmt.Sprintf("%s reachable from the server's Python runtime (%s)", url, out)) + // Any HTTP status proves reachability (the probe is unauthenticated, so + // it's usually 401); showing it next to a pass reads like an error. The + // status stays in the bundle's network-probe.txt. + detail := url + " reachable from the server's Python runtime" + if i := strings.Index(out, "(CA: "); i >= 0 { + detail += " " + strings.TrimSpace(out[i:]) + } + d.report.add(component, "server network", StatusOK, detail).short("API reachable") } // tryProxyFix handles the most common corporate-network failure: Python @@ -584,13 +616,22 @@ func tryProxyFix(d *doctorRun, c *DoctorCheck, python string, env map[string]str // launch identical to one already probed is reported by reference instead of // being spawned again. func (d *doctorRun) probe(component, label string, launch serverLaunch) { - report := d.report named := func(n string) string { if label == "" { return n } return label + " " + n } + add := func(name string, status CheckStatus, detail string) *DoctorCheck { + return d.report.add(component, named(name), status, detail).forEditor(label) + } + // Only the server's own launch contributes to the compact summary; an + // editor's probe passing is already implied by the editor's ✓. + short := func(c *DoctorCheck, summary string) { + if label == "" { + c.short(summary) + } + } key := launch.key() if prev, ok := d.probes[key]; ok { @@ -598,15 +639,20 @@ func (d *doctorRun) probe(component, label string, launch serverLaunch) { if !prev.ok { status = StatusFail } - report.add(component, named("launch"), status, "same launch command as "+prev.label+" (see above)") + ref := prev.name + if prev.component != component { + ref = prev.component + " " + ref + } + add("launch", status, fmt.Sprintf("same launch command as the %q check above", ref)) return } - outcome := &probeOutcome{label: component + " / " + named("live handshake")} + outcome := &probeOutcome{component: component, name: named("live handshake")} d.probes[key] = outcome start := time.Now() res, stderr, err := mcpHandshake(launch.Command, launch.Args, launch.Env, d.opts.Timeout) elapsed := time.Since(start).Round(100 * time.Millisecond) + report := d.report if stderr != "" { report.artifact("stderr/"+sanitizeArtifactName(component+" "+named("live handshake"))+".txt", "$ "+launch.commandLine()+"\n\n"+stderr+"\n") @@ -617,7 +663,7 @@ func (d *doctorRun) probe(component, label string, launch serverLaunch) { detail += " — stderr: " + tail(stderr, 300) } hint, action := launchHint(err, stderr, launch) - report.add(component, named("live handshake"), StatusFail, detail).fix(action, hint) + add("live handshake", StatusFail, detail).fix(action, hint) return } outcome.ok = true @@ -631,33 +677,34 @@ func (d *doctorRun) probe(component, label string, launch serverLaunch) { } detail += " in " + elapsed.String() if elapsed > slowStartThreshold { - report.add(component, named("live handshake"), StatusWarn, detail+" (slow start)"). + add("live handshake", StatusWarn, detail+" (slow start)"). hint("Slow starts are usually antivirus scanning the venv. If your editor gives up before the server is ready, ask IT to exclude " + filepath.Dir(filepath.Dir(filepath.Dir(launch.Command))) + " from real-time scanning.") } else { - report.add(component, named("live handshake"), StatusOK, detail) + short(add("live handshake", StatusOK, detail), "starts in "+elapsed.String()) } switch { case res.ToolsErr != nil: outcome.ok = false - report.add(component, named("tools"), StatusFail, "tools/list failed: "+res.ToolsErr.Error()). + add("tools", StatusFail, "tools/list failed: "+res.ToolsErr.Error()). hint(launchHintText(res.ToolsErr, stderr, launch)) case len(res.Tools) == 0: outcome.ok = false - report.add(component, named("tools"), StatusFail, "server started but exposes no tools"). + add("tools", StatusFail, "server started but exposes no tools"). fix(FixReinstall, "Reinstall the plugin: armis-cli mcp doctor --fix") default: - report.add(component, named("tools"), StatusOK, fmt.Sprintf("%d tools: %s", len(res.Tools), strings.Join(res.Tools, ", "))) + short(add("tools", StatusOK, fmt.Sprintf("%d tools: %s", len(res.Tools), strings.Join(res.Tools, ", "))), + fmt.Sprintf("%d tools", len(res.Tools))) } switch { case res.DebugErr != nil: outcome.ok = false - report.add(component, named("tool call"), StatusFail, debugConfigTool+" failed: "+truncate(res.DebugErr.Error(), 300)). + add("tool call", StatusFail, debugConfigTool+" failed: "+truncate(res.DebugErr.Error(), 300)). hint(launchHintText(res.DebugErr, stderr, launch)) case res.DebugConfig != "": report.artifact(sanitizeArtifactName(component)+"/debug_config.txt", res.DebugConfig+"\n") - report.add(component, named("tool call"), StatusOK, debugConfigTool+" succeeded — "+summarizeDebugConfig(res.DebugConfig)) + add("tool call", StatusOK, debugConfigTool+" succeeded — "+summarizeDebugConfig(res.DebugConfig)) } } @@ -715,7 +762,7 @@ func checkManifestEditors(d *doctorRun, component, identifier string, editors ma // lookupEntry below and misreported as "entry not found". content, err := readBoundedConfigFile(entry.ConfigFile) if err != nil { - report.add(component, name, StatusFail, fmt.Sprintf("config file %s: %v", entry.ConfigFile, err)). + report.addEditor(component, name, StatusFail, fmt.Sprintf("config file %s: %v", entry.ConfigFile, err)). fix(FixReregister, "Re-register the server: armis-cli mcp doctor --fix") continue } @@ -741,14 +788,14 @@ func checkManifestEditors(d *doctorRun, component, identifier string, editors ma if parseErr != nil { // Not auto-fixable: re-registering would start from an empty map // and drop every other server the user configured in this file. - report.add(component, name, StatusFail, fmt.Sprintf("config file %s is not valid: %v", entry.ConfigFile, parseErr)). + report.addEditor(component, name, StatusFail, fmt.Sprintf("config file %s is not valid: %v", entry.ConfigFile, parseErr)). fix(FixBlocked, name+" ignores the whole file when it can't be parsed, so no servers in it load. Fix the syntax (often a missing or extra comma), then re-run this doctor.") continue } launch, found := lookupEntry(entry.ConfigFile, entry.Format, identifier, d.workspaceDir()) if !found { - report.add(component, name, StatusWarn, + report.addEditor(component, name, StatusWarn, fmt.Sprintf("registered at %s but entry not found — was it edited or removed?", entry.ConfigFile)). fix(FixReregister, "Re-register the server: armis-cli mcp doctor --fix") continue @@ -757,26 +804,26 @@ func checkManifestEditors(d *doctorRun, component, identifier string, editors ma // lookupEntry understands every format the installer writes, so an // entry with no command here can't be started by the editor. if launch.Command == "" { - report.add(component, name, StatusFail, + report.addEditor(component, name, StatusFail, fmt.Sprintf("entry found in %s but it has no command", entry.ConfigFile)). fix(FixReregister, "Re-register the server: armis-cli mcp doctor --fix") continue } if !isExecutableFile(launch.Command) { - report.add(component, name, StatusFail, + report.addEditor(component, name, StatusFail, fmt.Sprintf("entry found in %s but its command does not exist: %s — likely stale after a reinstall or profile/home directory change", entry.ConfigFile, launch.Command)). fix(FixReregister, "Point the entry at the current install: armis-cli mcp doctor --fix") continue } if launch.EnvFile != "" { if _, err := os.Stat(launch.EnvFile); err != nil { - report.add(component, name, StatusFail, + report.addEditor(component, name, StatusFail, fmt.Sprintf("entry in %s references an envFile that does not exist: %s", entry.ConfigFile, launch.EnvFile)). fix(FixReregister, "Point the entry at the current install: armis-cli mcp doctor --fix") continue } } - report.add(component, name, StatusOK, entry.ConfigFile) + report.addEditor(component, name, StatusOK, entry.ConfigFile) if d.opts.Handshake { d.probe(component, name, launch) @@ -789,7 +836,7 @@ func checkClaudeSection(report *DoctorReport, component string, claude *Manifest return } if _, err := os.Stat(claude.CacheDir); err != nil { - report.add(component, "Claude Code", StatusFail, fmt.Sprintf("cache dir missing: %s", claude.CacheDir)). + report.addEditor(component, "Claude Code", StatusFail, fmt.Sprintf("cache dir missing: %s", claude.CacheDir)). fix(FixReregister, "Reinstall the Claude Code plugin: armis-cli mcp doctor --fix") return } @@ -797,13 +844,13 @@ func checkClaudeSection(report *DoctorReport, component string, claude *Manifest installed, enabled := claudeRegistryStatus(homeDir(".claude"), pluginKeyPrefix) switch { case !installed: - report.add(component, "Claude Code", StatusWarn, "not found in installed_plugins.json"). + report.addEditor(component, "Claude Code", StatusWarn, "not found in installed_plugins.json"). fix(FixReregister, "Reinstall the Claude Code plugin: armis-cli mcp doctor --fix") case !enabled: - report.add(component, "Claude Code", StatusWarn, "installed but not enabled in settings.json"). + report.addEditor(component, "Claude Code", StatusWarn, "installed but not enabled in settings.json"). hint("Enable it in Claude Code with /plugin, or re-run: armis-cli mcp doctor --fix") default: - report.add(component, "Claude Code", StatusOK, claude.CacheDir) + report.addEditor(component, "Claude Code", StatusOK, claude.CacheDir) } } @@ -875,17 +922,17 @@ func checkCodexSection(report *DoctorReport, component string, codex *ManifestCo } content, err := readBoundedConfigFile(codex.ConfigFile) if err != nil { - report.add(component, "Codex CLI", StatusFail, fmt.Sprintf("config file %s: %v", codex.ConfigFile, err)). + report.addEditor(component, "Codex CLI", StatusFail, fmt.Sprintf("config file %s: %v", codex.ConfigFile, err)). fix(FixReregister, "Re-register the server: armis-cli mcp doctor --fix") return } if !strings.Contains(strings.ToLower(string(content)), strings.ToLower(identifier)) { - report.add(component, "Codex CLI", StatusWarn, + report.addEditor(component, "Codex CLI", StatusWarn, fmt.Sprintf("registered at %s but entry not found — was it edited or removed?", codex.ConfigFile)). fix(FixReregister, "Re-register the server: armis-cli mcp doctor --fix") return } - report.add(component, "Codex CLI", StatusOK, codex.ConfigFile) + report.addEditor(component, "Codex CLI", StatusOK, codex.ConfigFile) } // lookupEntryCommand finds the server entry matching identifier in configFile diff --git a/internal/install/doctor_checks_test.go b/internal/install/doctor_checks_test.go index 75b22b0..b070c34 100644 --- a/internal/install/doctor_checks_test.go +++ b/internal/install/doctor_checks_test.go @@ -187,9 +187,15 @@ func TestProbeReportsHandshakeToolsAndToolCall(t *testing.T) { } // The identical second launch is reported by reference, not re-spawned. dup := wantStatus(t, checks, "scanner/Cursor launch", StatusOK) - if !strings.Contains(dup.Detail, "same launch command") { + if !strings.Contains(dup.Detail, `same launch command as the "live handshake" check`) { t.Errorf("dedup detail = %q", dup.Detail) } + if dup.Editor != "Cursor" || tools.Editor != "" { + t.Errorf("editor tags = %q/%q, want Cursor/empty", dup.Editor, tools.Editor) + } + if !strings.HasSuffix(tools.Summary, " tools") { + t.Errorf("tools summary = %q", tools.Summary) + } if _, ok := checks["scanner/Cursor live handshake"]; ok { t.Error("identical launch was probed twice") } @@ -730,6 +736,12 @@ func TestCheckServerNetworkProxyFix(t *testing.T) { d := newDoctorRun(DoctorOptions{}) checkServerNetwork(d, "scanner", "python", map[string]string{}, "/p/.env") wantStatus(t, checkMap(d.report), "scanner/server network", StatusOK) + // The unauthenticated probe's 401 proves reachability; it must not + // appear next to a pass. + c := checkMap(d.report)["scanner/server network"] + if strings.Contains(c.Detail, "401") || !strings.Contains(c.Detail, "(CA: system store)") { + t.Errorf("detail = %q, want the CA source and no HTTP status", c.Detail) + } }) } diff --git a/internal/install/doctor_vscode.go b/internal/install/doctor_vscode.go index 3ebac0a..88f824b 100644 --- a/internal/install/doctor_vscode.go +++ b/internal/install/doctor_vscode.go @@ -175,17 +175,17 @@ func checkVSCodeVariant(d *doctorRun, v vscodeVariant, workspace, pluginDir, sni } name := v.Name + " (" + f.Source.Label + ")" if f.Launch.Command == "" { - report.add(componentVSCode, name, StatusFail, f.Source.Path+": entry has no command"). + report.addEditor(componentVSCode, name, StatusFail, f.Source.Path+": entry has no command"). hint(v.Name + " can't start an entry with no command. Delete it from " + f.Source.Path + ", or replace it with:\n" + snippet) continue } if !isExecutableFile(f.Launch.Command) { - report.add(componentVSCode, name, StatusFail, + report.addEditor(componentVSCode, name, StatusFail, fmt.Sprintf("%s: command does not exist: %s", f.Source.Path, f.Launch.Command)). hint("This entry is stale. Delete it from " + f.Source.Path + ", or replace it with:\n" + snippet) continue } - report.add(componentVSCode, name, StatusOK, f.Source.Path) + report.addEditor(componentVSCode, name, StatusOK, f.Source.Path) if d.opts.Handshake { d.probe(componentVSCode, name, f.Launch) }