vssh 4.2.0__tar.gz → 4.2.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. {vssh-4.2.0 → vssh-4.2.1}/CHANGELOG.md +17 -0
  2. {vssh-4.2.0 → vssh-4.2.1}/HELP.md +2 -0
  3. {vssh-4.2.0 → vssh-4.2.1}/Makefile +1 -1
  4. {vssh-4.2.0 → vssh-4.2.1}/PKG-INFO +13 -9
  5. {vssh-4.2.0 → vssh-4.2.1}/README.md +12 -8
  6. vssh-4.2.1/cmd/vssh/doctor.go +297 -0
  7. {vssh-4.2.0 → vssh-4.2.1}/cmd/vssh/main.go +4 -1
  8. {vssh-4.2.0 → vssh-4.2.1}/cmd/vssh/mcp.go +112 -63
  9. {vssh-4.2.0 → vssh-4.2.1}/cmd/vssh/mcp_test.go +40 -2
  10. {vssh-4.2.0 → vssh-4.2.1}/internal/server/auth.go +18 -2
  11. {vssh-4.2.0 → vssh-4.2.1}/pyproject.toml +1 -1
  12. {vssh-4.2.0 → vssh-4.2.1}/src/vssh/client.py +6 -0
  13. {vssh-4.2.0 → vssh-4.2.1}/tests/test_python_sdk.py +9 -0
  14. {vssh-4.2.0 → vssh-4.2.1}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  15. {vssh-4.2.0 → vssh-4.2.1}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  16. {vssh-4.2.0 → vssh-4.2.1}/.github/workflows/ci.yml +0 -0
  17. {vssh-4.2.0 → vssh-4.2.1}/.github/workflows/release.yml +0 -0
  18. {vssh-4.2.0 → vssh-4.2.1}/.gitignore +0 -0
  19. {vssh-4.2.0 → vssh-4.2.1}/CONTRIBUTING.md +0 -0
  20. {vssh-4.2.0 → vssh-4.2.1}/README.ko.md +0 -0
  21. {vssh-4.2.0 → vssh-4.2.1}/SECURITY.md +0 -0
  22. {vssh-4.2.0 → vssh-4.2.1}/cmd/vssh/fanout_test.go +0 -0
  23. {vssh-4.2.0 → vssh-4.2.1}/docs/AI_NATIVE_CAPABILITIES.ko.md +0 -0
  24. {vssh-4.2.0 → vssh-4.2.1}/docs/CODEX_ORCHESTRATION.ko.md +0 -0
  25. {vssh-4.2.0 → vssh-4.2.1}/docs/CODEX_ORCHESTRATION.md +0 -0
  26. {vssh-4.2.0 → vssh-4.2.1}/docs/DIRECTION.md +0 -0
  27. {vssh-4.2.0 → vssh-4.2.1}/docs/DISTRIBUTION.ko.md +0 -0
  28. {vssh-4.2.0 → vssh-4.2.1}/docs/NETWORK_TRAVERSAL_AUDIT.ko.md +0 -0
  29. {vssh-4.2.0 → vssh-4.2.1}/docs/PERFORMANCE.ko.md +0 -0
  30. {vssh-4.2.0 → vssh-4.2.1}/docs/PUBLISHING_AUDIT.ko.md +0 -0
  31. {vssh-4.2.0 → vssh-4.2.1}/docs/PUBLISHING_AUDIT.md +0 -0
  32. {vssh-4.2.0 → vssh-4.2.1}/docs/PYTHON_SDK.ko.md +0 -0
  33. {vssh-4.2.0 → vssh-4.2.1}/docs/WHY_VSSH.ko.md +0 -0
  34. {vssh-4.2.0 → vssh-4.2.1}/docs/WHY_VSSH.md +0 -0
  35. {vssh-4.2.0 → vssh-4.2.1}/go.mod +0 -0
  36. {vssh-4.2.0 → vssh-4.2.1}/go.sum +0 -0
  37. {vssh-4.2.0 → vssh-4.2.1}/install.sh +0 -0
  38. {vssh-4.2.0 → vssh-4.2.1}/internal/adapter/vssh.go +0 -0
  39. {vssh-4.2.0 → vssh-4.2.1}/internal/agent/agent.go +0 -0
  40. {vssh-4.2.0 → vssh-4.2.1}/internal/agent/api.go +0 -0
  41. {vssh-4.2.0 → vssh-4.2.1}/internal/config/config.go +0 -0
  42. {vssh-4.2.0 → vssh-4.2.1}/internal/event/event.go +0 -0
  43. {vssh-4.2.0 → vssh-4.2.1}/internal/server/artifact_test.go +0 -0
  44. {vssh-4.2.0 → vssh-4.2.1}/internal/server/auth_test.go +0 -0
  45. {vssh-4.2.0 → vssh-4.2.1}/internal/server/client.go +0 -0
  46. {vssh-4.2.0 → vssh-4.2.1}/internal/server/exec_test.go +0 -0
  47. {vssh-4.2.0 → vssh-4.2.1}/internal/server/jobs.go +0 -0
  48. {vssh-4.2.0 → vssh-4.2.1}/internal/server/jobs_test.go +0 -0
  49. {vssh-4.2.0 → vssh-4.2.1}/internal/server/pty_darwin.go +0 -0
  50. {vssh-4.2.0 → vssh-4.2.1}/internal/server/pty_linux.go +0 -0
  51. {vssh-4.2.0 → vssh-4.2.1}/internal/server/relay.go +0 -0
  52. {vssh-4.2.0 → vssh-4.2.1}/internal/server/rpc.go +0 -0
  53. {vssh-4.2.0 → vssh-4.2.1}/internal/server/server.go +0 -0
  54. {vssh-4.2.0 → vssh-4.2.1}/internal/server/sync.go +0 -0
  55. {vssh-4.2.0 → vssh-4.2.1}/internal/server/transfer.go +0 -0
  56. {vssh-4.2.0 → vssh-4.2.1}/internal/server/transfer_advanced.go +0 -0
  57. {vssh-4.2.0 → vssh-4.2.1}/internal/server/transfer_test.go +0 -0
  58. {vssh-4.2.0 → vssh-4.2.1}/internal/ssh/ssh.go +0 -0
  59. {vssh-4.2.0 → vssh-4.2.1}/internal/ssh/ssh_test.go +0 -0
  60. {vssh-4.2.0 → vssh-4.2.1}/src/vssh/__init__.py +0 -0
@@ -2,6 +2,23 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [v0.7.5] - 2026-05-22
6
+
7
+ ### Runtime
8
+
9
+ - Add `vssh doctor` / `vssh setup-check` for AI-operator setup diagnostics:
10
+ effective binary, stale binary conflicts, secret source, Wire config, and peer
11
+ counts.
12
+ - Add MCP `vssh_doctor` so Codex, Claude, Cursor, and other MCP clients can
13
+ diagnose VSSH before attempting execution or facts calls.
14
+ - Prefer MCP-safe underscore tool names and deduplicate exposed tools.
15
+ - Read native secrets from `/etc/vssh/secret` and `~/.vssh/secret` before
16
+ falling back to Wire-derived secrets.
17
+
18
+ ### Python SDK
19
+
20
+ - Add `VSSH.doctor()` to call `vssh doctor --json`.
21
+
5
22
  ## [v0.7.4] - 2026-05-16
6
23
 
7
24
  ### Runtime
@@ -27,6 +27,7 @@ vssh server
27
27
  export VSSH_SECRET=your-secret
28
28
  vssh shell hostname # Interactive shell
29
29
  vssh run hostname "cmd" # Execute command
30
+ vssh doctor --json # Diagnose setup before MCP/AI use
30
31
  ```
31
32
 
32
33
  ## CLI Commands
@@ -47,6 +48,7 @@ vssh get <host:path> <local> # Download
47
48
  ```bash
48
49
  vssh status # Show dashboard
49
50
  vssh list # List all peers
51
+ vssh doctor # Diagnose binary, secret, config, and peers
50
52
  vssh version # Show version
51
53
  ```
52
54
 
@@ -1,4 +1,4 @@
1
- VERSION := 0.7.4
1
+ VERSION := 0.7.5
2
2
  BUILD_TIME := $(shell date -u '+%Y-%m-%d_%H:%M:%S')
3
3
  LDFLAGS := -ldflags "-s -w -X main.version=$(VERSION) -X main.buildTime=$(BUILD_TIME)"
4
4
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: vssh
3
- Version: 4.2.0
3
+ Version: 4.2.1
4
4
  Summary: Python SDK for the vssh AI-native remote execution daemon
5
5
  Project-URL: Homepage, https://github.com/meshpop/vssh
6
6
  Project-URL: Repository, https://github.com/meshpop/vssh
@@ -99,6 +99,7 @@ VSSH_SECRET=change-me vssh run web1 "df -h"
99
99
  # Show status
100
100
  vssh # Dashboard
101
101
  vssh list # List all nodes
102
+ vssh doctor --json # Diagnose binary, secret, config, peers, and MCP readiness
102
103
  ```
103
104
 
104
105
  ## Commands
@@ -123,6 +124,7 @@ vssh get <src> <dst> Download through native daemon
123
124
  vssh exec <node> <cmd> Alias for native run
124
125
  vssh list List all nodes
125
126
  vssh status Show dashboard
127
+ vssh doctor [--json] Diagnose local setup
126
128
  vssh version Show version
127
129
  vssh help Show help
128
130
  ```
@@ -269,6 +271,7 @@ facts = client.facts("web1")
269
271
  fleet = client.facts_many(["web1", "db1"])
270
272
  results = client.exec_many(["web1", "db1"], "uptime")
271
273
  job = client.job_start("web1", "long-running-command")
274
+ doctor = client.doctor()
272
275
  ```
273
276
 
274
277
  The Python SDK is a client layer for AI agents and automation. It calls the
@@ -278,13 +281,14 @@ Agent-facing MCP tools:
278
281
 
279
282
  | Tool | Purpose |
280
283
  |------|---------|
281
- | `vssh.hosts.list` | List known hosts for routing |
282
- | `vssh.exec.safe` | Execute read/diagnostic commands with policy blocking |
283
- | `vssh.exec` | Execute with policy checks and optional `allow_dangerous` |
284
- | `vssh.route.select` | Select the best host by capability, tag, and health |
285
- | `vssh.exec.routed` | Route first, then execute with policy/evidence |
286
- | `vssh.policy.check` | Classify a command before execution |
287
- | `vssh_exec`, `vssh_list`, `vssh_status` | Backward-compatible tool names |
284
+ | `vssh_doctor` | Diagnose binary, secret source, wire config, peers, and MCP readiness |
285
+ | `vssh_hosts_list` | List known hosts for routing |
286
+ | `vssh_exec_safe` | Execute read/diagnostic commands with policy blocking |
287
+ | `vssh_exec` | Execute with policy checks and optional `allow_dangerous` |
288
+ | `vssh_route_select` | Select the best host by capability, tag, and health |
289
+ | `vssh_exec_routed` | Route first, then execute with policy/evidence |
290
+ | `vssh_policy_check` | Classify a command before execution |
291
+ | `vssh_status`, `vssh_list` | Status and peer inventory |
288
292
 
289
293
  Commands matching destructive/service-impacting patterns such as `rm -rf`,
290
294
  `shutdown`, `reboot`, `docker rm`, `kubectl delete`, and `systemctl restart`
@@ -292,7 +296,7 @@ are blocked unless the caller sets `allow_dangerous: true` after explicit human
292
296
  approval. Every execution response is an evidence envelope with timestamps,
293
297
  policy decision, target, command, timeout, and the structured execution result.
294
298
 
295
- `vssh.hosts.list` returns agent-friendly records:
299
+ `vssh_hosts_list` returns agent-friendly records:
296
300
 
297
301
  ```json
298
302
  {
@@ -78,6 +78,7 @@ VSSH_SECRET=change-me vssh run web1 "df -h"
78
78
  # Show status
79
79
  vssh # Dashboard
80
80
  vssh list # List all nodes
81
+ vssh doctor --json # Diagnose binary, secret, config, peers, and MCP readiness
81
82
  ```
82
83
 
83
84
  ## Commands
@@ -102,6 +103,7 @@ vssh get <src> <dst> Download through native daemon
102
103
  vssh exec <node> <cmd> Alias for native run
103
104
  vssh list List all nodes
104
105
  vssh status Show dashboard
106
+ vssh doctor [--json] Diagnose local setup
105
107
  vssh version Show version
106
108
  vssh help Show help
107
109
  ```
@@ -248,6 +250,7 @@ facts = client.facts("web1")
248
250
  fleet = client.facts_many(["web1", "db1"])
249
251
  results = client.exec_many(["web1", "db1"], "uptime")
250
252
  job = client.job_start("web1", "long-running-command")
253
+ doctor = client.doctor()
251
254
  ```
252
255
 
253
256
  The Python SDK is a client layer for AI agents and automation. It calls the
@@ -257,13 +260,14 @@ Agent-facing MCP tools:
257
260
 
258
261
  | Tool | Purpose |
259
262
  |------|---------|
260
- | `vssh.hosts.list` | List known hosts for routing |
261
- | `vssh.exec.safe` | Execute read/diagnostic commands with policy blocking |
262
- | `vssh.exec` | Execute with policy checks and optional `allow_dangerous` |
263
- | `vssh.route.select` | Select the best host by capability, tag, and health |
264
- | `vssh.exec.routed` | Route first, then execute with policy/evidence |
265
- | `vssh.policy.check` | Classify a command before execution |
266
- | `vssh_exec`, `vssh_list`, `vssh_status` | Backward-compatible tool names |
263
+ | `vssh_doctor` | Diagnose binary, secret source, wire config, peers, and MCP readiness |
264
+ | `vssh_hosts_list` | List known hosts for routing |
265
+ | `vssh_exec_safe` | Execute read/diagnostic commands with policy blocking |
266
+ | `vssh_exec` | Execute with policy checks and optional `allow_dangerous` |
267
+ | `vssh_route_select` | Select the best host by capability, tag, and health |
268
+ | `vssh_exec_routed` | Route first, then execute with policy/evidence |
269
+ | `vssh_policy_check` | Classify a command before execution |
270
+ | `vssh_status`, `vssh_list` | Status and peer inventory |
267
271
 
268
272
  Commands matching destructive/service-impacting patterns such as `rm -rf`,
269
273
  `shutdown`, `reboot`, `docker rm`, `kubectl delete`, and `systemctl restart`
@@ -271,7 +275,7 @@ are blocked unless the caller sets `allow_dangerous: true` after explicit human
271
275
  approval. Every execution response is an evidence envelope with timestamps,
272
276
  policy decision, target, command, timeout, and the structured execution result.
273
277
 
274
- `vssh.hosts.list` returns agent-friendly records:
278
+ `vssh_hosts_list` returns agent-friendly records:
275
279
 
276
280
  ```json
277
281
  {
@@ -0,0 +1,297 @@
1
+ package main
2
+
3
+ import (
4
+ "encoding/json"
5
+ "fmt"
6
+ "os"
7
+ "os/exec"
8
+ "path/filepath"
9
+ "sort"
10
+ "strings"
11
+ "time"
12
+
13
+ "github.com/meshpop/vssh/internal/config"
14
+ "github.com/meshpop/vssh/internal/ssh"
15
+ )
16
+
17
+ type DoctorReport struct {
18
+ Kind string `json:"kind"`
19
+ Status string `json:"status"`
20
+ Checks []DoctorCheck `json:"checks"`
21
+ NextActions []string `json:"next_actions"`
22
+ }
23
+
24
+ type DoctorCheck struct {
25
+ Name string `json:"name"`
26
+ Status string `json:"status"`
27
+ Detail string `json:"detail,omitempty"`
28
+ Next string `json:"next,omitempty"`
29
+ }
30
+
31
+ func cmdDoctor(args []string) {
32
+ jsonOut := false
33
+ filtered := make([]string, 0, len(args))
34
+ for _, arg := range args {
35
+ if arg == "--json" {
36
+ jsonOut = true
37
+ continue
38
+ }
39
+ filtered = append(filtered, arg)
40
+ }
41
+ if len(filtered) != 0 {
42
+ fmt.Fprintln(os.Stderr, "Usage: vssh doctor [--json]")
43
+ os.Exit(1)
44
+ }
45
+ report := runDoctor()
46
+ if jsonOut {
47
+ writeJSON(report)
48
+ return
49
+ }
50
+ fmt.Print(formatDoctor(report))
51
+ if report.Status == "fail" {
52
+ os.Exit(1)
53
+ }
54
+ }
55
+
56
+ func runDoctor() DoctorReport {
57
+ report := DoctorReport{Kind: "vssh_doctor", Status: "ok"}
58
+ add := func(name, status, detail, next string) {
59
+ report.Checks = append(report.Checks, DoctorCheck{Name: name, Status: status, Detail: detail, Next: next})
60
+ if status == "fail" {
61
+ report.Status = "fail"
62
+ } else if status == "warn" && report.Status == "ok" {
63
+ report.Status = "warn"
64
+ }
65
+ }
66
+
67
+ if exe, err := os.Executable(); err == nil {
68
+ add("vssh_binary", "ok", exe, "")
69
+ } else {
70
+ add("vssh_binary", "fail", err.Error(), "reinstall vssh or run the expected binary explicitly")
71
+ }
72
+ add("vssh_version", "ok", fmt.Sprintf("vssh %s", version), "")
73
+ checkBinaryConflicts(add)
74
+ checkSecretSource(add)
75
+ checkWireConfig(add)
76
+ checkPeers(add)
77
+
78
+ switch report.Status {
79
+ case "ok":
80
+ report.NextActions = []string{
81
+ "Run vssh status.",
82
+ "Run vssh facts-many <hosts> before connecting AI operators.",
83
+ "Connect MCP clients to vssh mcp only when direct transport tools are needed.",
84
+ }
85
+ case "warn":
86
+ report.NextActions = []string{
87
+ "Review warning checks before relying on native execution.",
88
+ "Pin MCP clients to the intended vssh binary and shared secret.",
89
+ }
90
+ default:
91
+ report.NextActions = []string{
92
+ "Fix failed checks before starting vsshd or exposing vssh MCP.",
93
+ }
94
+ }
95
+ return report
96
+ }
97
+
98
+ func checkBinaryConflicts(add func(name, status, detail, next string)) {
99
+ current, _ := os.Executable()
100
+ candidates := vsshBinaryCandidates(current)
101
+ versions := map[string]string{}
102
+ for _, candidate := range candidates {
103
+ versions[candidate] = vsshBinaryVersion(candidate)
104
+ }
105
+ if len(versions) == 0 {
106
+ add("vssh_binary_conflict", "warn", "no executable vssh candidates found", "install vssh or add it to PATH")
107
+ return
108
+ }
109
+ if hasVersionConflict(versions) {
110
+ add("vssh_binary_conflict", "warn", formatVersions(versions), "remove stale vssh binaries or pin MCP clients to the intended binary")
111
+ return
112
+ }
113
+ add("vssh_binary_conflict", "ok", formatVersions(versions), "")
114
+ }
115
+
116
+ func checkSecretSource(add func(name, status, detail, next string)) {
117
+ source := secretSource()
118
+ if source == "" {
119
+ add("vssh_secret", "fail", "no VSSH_SECRET, secret file, or wire-derived secret", "set VSSH_SECRET or create ~/.vssh/secret on client and daemon")
120
+ return
121
+ }
122
+ secret := getSecret()
123
+ if strings.TrimSpace(secret) == "" {
124
+ add("vssh_secret", "fail", "secret source exists but no effective secret was produced", "verify secret file contents or wire config")
125
+ return
126
+ }
127
+ add("vssh_secret", "ok", source+"; value not displayed", "")
128
+ }
129
+
130
+ func checkWireConfig(add func(name, status, detail, next string)) {
131
+ cfg, err := config.LoadWireConfig()
132
+ if err != nil {
133
+ add("wire_config", "warn", "wire config not found", "standalone vssh can still work; configure peers or use explicit host:port")
134
+ return
135
+ }
136
+ detail := "node=" + cfg.NodeName
137
+ if cfg.ServerURL != "" {
138
+ detail += " server_url=set"
139
+ }
140
+ add("wire_config", "ok", detail, "")
141
+ }
142
+
143
+ func checkPeers(add func(name, status, detail, next string)) {
144
+ connector, err := ssh.NewConnector("default")
145
+ if err != nil {
146
+ add("peers", "warn", err.Error(), "verify wire/tailscale peer configuration or use explicit host:port targets")
147
+ return
148
+ }
149
+ peers := connector.ListPeers()
150
+ online := 0
151
+ now := time.Now().Unix()
152
+ for _, peer := range peers {
153
+ if peer.NodeName == "" {
154
+ continue
155
+ }
156
+ if peerOnline(peer, now) {
157
+ online++
158
+ }
159
+ }
160
+ if len(peers) == 0 {
161
+ add("peers", "warn", "peers=0", "configure peers before using routing, facts-many, or MCP host selection")
162
+ return
163
+ }
164
+ add("peers", "ok", fmt.Sprintf("peers=%d online=%d", len(peers), online), "")
165
+ }
166
+
167
+ func peerOnline(peer config.Peer, now int64) bool {
168
+ if peer.Online != nil {
169
+ return *peer.Online
170
+ }
171
+ if peer.LastSeen == nil {
172
+ return false
173
+ }
174
+ switch v := peer.LastSeen.(type) {
175
+ case float64:
176
+ return now-int64(v) < 60
177
+ case int64:
178
+ return now-v < 60
179
+ case string:
180
+ if t, err := time.Parse(time.RFC3339, v); err == nil {
181
+ return time.Since(t) < 60*time.Second
182
+ }
183
+ }
184
+ return false
185
+ }
186
+
187
+ func secretSource() string {
188
+ if strings.TrimSpace(os.Getenv("VSSH_SECRET")) != "" {
189
+ return "env:VSSH_SECRET"
190
+ }
191
+ for _, path := range []string{"/etc/vssh/secret", filepath.Join(homeDir(), ".vssh", "secret")} {
192
+ if st, err := os.Stat(path); err == nil && !st.IsDir() {
193
+ return path
194
+ }
195
+ }
196
+ if strings.TrimSpace(os.Getenv("WIRE_SERVER_URL")) != "" {
197
+ return "env:WIRE_SERVER_URL derived"
198
+ }
199
+ if _, err := config.LoadWireConfig(); err == nil {
200
+ return "wire config derived"
201
+ }
202
+ return ""
203
+ }
204
+
205
+ func homeDir() string {
206
+ home, _ := os.UserHomeDir()
207
+ return home
208
+ }
209
+
210
+ func vsshBinaryCandidates(current string) []string {
211
+ seen := map[string]bool{}
212
+ add := func(path string) {
213
+ if path == "" || seen[path] {
214
+ return
215
+ }
216
+ if st, err := os.Stat(path); err == nil && !st.IsDir() && st.Mode()&0111 != 0 {
217
+ seen[path] = true
218
+ }
219
+ }
220
+ add(current)
221
+ if path, err := exec.LookPath("vssh"); err == nil {
222
+ add(path)
223
+ }
224
+ add(filepath.Join(homeDir(), "bin", "vssh"))
225
+ add("/usr/local/bin/vssh")
226
+ add("/opt/homebrew/bin/vssh")
227
+ for _, dir := range filepath.SplitList(os.Getenv("PATH")) {
228
+ add(filepath.Join(dir, "vssh"))
229
+ }
230
+ out := make([]string, 0, len(seen))
231
+ for path := range seen {
232
+ out = append(out, path)
233
+ }
234
+ sort.Strings(out)
235
+ return out
236
+ }
237
+
238
+ func vsshBinaryVersion(path string) string {
239
+ out, err := exec.Command(path, "--version").CombinedOutput()
240
+ if err != nil {
241
+ return "unknown"
242
+ }
243
+ return strings.TrimSpace(string(out))
244
+ }
245
+
246
+ func hasVersionConflict(versions map[string]string) bool {
247
+ seen := map[string]bool{}
248
+ for _, version := range versions {
249
+ if version == "" || version == "unknown" {
250
+ continue
251
+ }
252
+ seen[version] = true
253
+ }
254
+ return len(seen) > 1
255
+ }
256
+
257
+ func formatVersions(versions map[string]string) string {
258
+ paths := make([]string, 0, len(versions))
259
+ for path := range versions {
260
+ paths = append(paths, path)
261
+ }
262
+ sort.Strings(paths)
263
+ parts := make([]string, 0, len(paths))
264
+ for _, path := range paths {
265
+ parts = append(parts, path+"="+versions[path])
266
+ }
267
+ return strings.Join(parts, "; ")
268
+ }
269
+
270
+ func formatDoctor(report DoctorReport) string {
271
+ var b strings.Builder
272
+ fmt.Fprintf(&b, "vssh doctor status=%s\n", report.Status)
273
+ for _, check := range report.Checks {
274
+ fmt.Fprintf(&b, "- %s: %s", check.Name, check.Status)
275
+ if check.Detail != "" {
276
+ fmt.Fprintf(&b, " | %s", check.Detail)
277
+ }
278
+ if check.Next != "" {
279
+ fmt.Fprintf(&b, " | next: %s", check.Next)
280
+ }
281
+ b.WriteByte('\n')
282
+ }
283
+ if len(report.NextActions) > 0 {
284
+ b.WriteString("next_actions:\n")
285
+ for _, action := range report.NextActions {
286
+ fmt.Fprintf(&b, "- %s\n", action)
287
+ }
288
+ }
289
+ return b.String()
290
+ }
291
+
292
+ func doctorJSON(report DoctorReport) map[string]interface{} {
293
+ payload, _ := json.Marshal(report)
294
+ out := map[string]interface{}{}
295
+ json.Unmarshal(payload, &out)
296
+ return out
297
+ }
@@ -19,7 +19,7 @@ import (
19
19
  )
20
20
 
21
21
  var (
22
- version = "0.7.4"
22
+ version = "0.7.5"
23
23
  buildTime = ""
24
24
  )
25
25
 
@@ -65,6 +65,8 @@ func main() {
65
65
  cmdArtifactCollect(os.Args[2:])
66
66
  case "setup":
67
67
  cmdSetup()
68
+ case "doctor", "setup-check", "install-check":
69
+ cmdDoctor(os.Args[2:])
68
70
  case "status":
69
71
  cmdStatus()
70
72
  case "list", "ls":
@@ -115,6 +117,7 @@ Usage:
115
117
  vssh bench <host> [count] Measure native exec latency
116
118
 
117
119
  vssh status Show connection status
120
+ vssh doctor [--json] Diagnose local binary, secret, peers, and MCP readiness
118
121
  vssh list List all peers
119
122
  vssh agent Run monitoring agent
120
123
  vssh mcp Run MCP JSON-RPC server