Skip to content

Commit 5f611e4

Browse files
jongioCopilot
andauthored
feat: add machine-readable doctor --json output (#206)
Add a --json flag to the doctor command that emits diagnostics as a single JSON object. Diagnostics are collected once into a doctorReport struct that both the text and JSON renderers consume, so the two outputs cannot drift apart. The default text output is unchanged. Closes #199 Co-authored-by: Copilot App <[email protected]>
1 parent 64503fa commit 5f611e4

5 files changed

Lines changed: 188 additions & 27 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
88

99
### Added
1010
- **Configurable auto-refresh** — set `auto_refresh_seconds` (also in the settings panel) to tune the session-list poll interval, or set it to `0` to turn polling off and refresh only with `r` or reindex
11+
- **Machine-readable doctor output**`dispatch doctor --json` prints diagnostics as a single JSON object so scripts and CI can parse them instead of scraping text
1112

1213
## [v0.13.0] — 2026-06-30
1314

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,8 @@ dispatch completion powershell
143143

144144
Run `dispatch doctor` to print setup checks for the config file, session store, session-state directory, and Copilot CLI binary.
145145

146+
Add `--json` (`dispatch doctor --json`) to print the same checks as a single JSON object for scripts and CI.
147+
146148
### Key Bindings
147149

148150
#### Navigation

cmd/dispatch/cli.go

Lines changed: 115 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,14 @@ package main
22

33
import (
44
"context"
5+
"encoding/json"
56
"errors"
67
"fmt"
78
"io"
89
"os"
910
"path/filepath"
1011
"runtime"
12+
"slices"
1113
"strings"
1214

1315
"github.com/jongio/dispatch/internal/config"
@@ -65,7 +67,14 @@ func handleArgs(args []string, origStderr io.Writer, updateCh <-chan *update.Upd
6567
return true, cleanup, nil
6668

6769
case "doctor":
68-
runDoctor(os.Stdout)
70+
if slices.Contains(args, "--json") {
71+
if jErr := runDoctorJSON(os.Stdout); jErr != nil {
72+
fmt.Fprintf(os.Stderr, "doctor: %v\n", jErr)
73+
return true, cleanup, jErr
74+
}
75+
} else {
76+
runDoctor(os.Stdout)
77+
}
6978
showUpdateNotification(origStderr, updateCh)
7079
return true, cleanup, nil
7180

@@ -207,56 +216,136 @@ Register-ArgumentCompleter -Native -CommandName dispatch, disp -ScriptBlock {
207216
}
208217
`
209218

210-
func runDoctor(w io.Writer) {
211-
if w == nil {
212-
w = io.Discard
213-
}
219+
// doctorStatus values describe the result of a single diagnostic path check.
220+
const (
221+
statusFound = "found"
222+
statusMissing = "missing"
223+
statusWrongType = "wrong_type"
224+
statusError = "error"
225+
)
214226

215-
fmt.Fprintf(w, "Dispatch doctor\n")
216-
fmt.Fprintf(w, "Version: %s\n", version.Version)
217-
fmt.Fprintf(w, "OS: %s/%s\n", runtime.GOOS, runtime.GOARCH)
218-
fmt.Fprintf(w, "\n")
227+
// doctorEntry is the diagnostic result for one path. The err field is used
228+
// only by the text renderer and is not serialized to JSON.
229+
type doctorEntry struct {
230+
Path string `json:"path"`
231+
Status string `json:"status"`
232+
err error
233+
}
234+
235+
// doctorReport is the full set of diagnostics gathered by the doctor command.
236+
// Both the text and JSON renderers consume this struct so their outputs stay
237+
// in sync.
238+
type doctorReport struct {
239+
Version string `json:"version"`
240+
OS string `json:"os"`
241+
Config doctorEntry `json:"config"`
242+
SessionStore doctorEntry `json:"session_store"`
243+
SessionState doctorEntry `json:"session_state"`
244+
CopilotCLI doctorEntry `json:"copilot_cli"`
245+
}
246+
247+
// collectDoctorReport gathers the environment diagnostics once so they can be
248+
// rendered as text or JSON without drifting apart.
249+
func collectDoctorReport() doctorReport {
250+
r := doctorReport{
251+
Version: version.Version,
252+
OS: runtime.GOOS + "/" + runtime.GOARCH,
253+
}
219254

220255
if p, err := config.ConfigPath(); err != nil {
221-
fmt.Fprintf(w, "Config: error: %v\n", err)
256+
r.Config = doctorEntry{Status: statusError, err: err}
222257
} else {
223-
printPathStatus(w, "Config", p, false)
258+
r.Config = doctorEntry{Path: p, Status: pathStatus(p, false)}
224259
}
225260

226261
if p, err := platform.SessionStorePath(); err != nil {
227-
fmt.Fprintf(w, "Session store: error: %v\n", err)
262+
r.SessionStore = doctorEntry{Status: statusError, err: err}
228263
} else {
229-
printPathStatus(w, "Session store", p, false)
264+
r.SessionStore = doctorEntry{Path: p, Status: pathStatus(p, false)}
230265
}
231266

232267
if p := data.SessionStatePath(); p == "" {
233-
fmt.Fprintf(w, "Session state: missing\n")
268+
r.SessionState = doctorEntry{Status: statusMissing}
234269
} else {
235-
printPathStatus(w, "Session state", p, true)
270+
r.SessionState = doctorEntry{Path: p, Status: pathStatus(p, true)}
236271
}
237272

238273
if p := platform.FindCLIBinary(); p == "" {
239-
fmt.Fprintf(w, "Copilot CLI: missing\n")
274+
r.CopilotCLI = doctorEntry{Status: statusMissing}
240275
} else {
241-
fmt.Fprintf(w, "Copilot CLI: found (%s)\n", p)
276+
r.CopilotCLI = doctorEntry{Path: p, Status: statusFound}
242277
}
278+
279+
return r
243280
}
244281

245-
func printPathStatus(w io.Writer, label, path string, wantDir bool) {
282+
// pathStatus stats a path and reports whether it is found, missing, or the
283+
// wrong type (a file where a directory is expected, or vice versa).
284+
func pathStatus(path string, wantDir bool) string {
246285
info, err := os.Stat(path)
247286
if err != nil {
248-
fmt.Fprintf(w, "%s: missing (%s)\n", label, path)
249-
return
287+
return statusMissing
250288
}
251-
if wantDir && !info.IsDir() {
252-
fmt.Fprintf(w, "%s: wrong type, expected directory (%s)\n", label, path)
253-
return
289+
if wantDir != info.IsDir() {
290+
return statusWrongType
291+
}
292+
return statusFound
293+
}
294+
295+
func runDoctor(w io.Writer) {
296+
if w == nil {
297+
w = io.Discard
254298
}
255-
if !wantDir && info.IsDir() {
256-
fmt.Fprintf(w, "%s: wrong type, expected file (%s)\n", label, path)
299+
300+
r := collectDoctorReport()
301+
302+
fmt.Fprintf(w, "Dispatch doctor\n")
303+
fmt.Fprintf(w, "Version: %s\n", r.Version)
304+
fmt.Fprintf(w, "OS: %s\n", r.OS)
305+
fmt.Fprintf(w, "\n")
306+
307+
writeDoctorLine(w, "Config", r.Config, false)
308+
writeDoctorLine(w, "Session store", r.SessionStore, false)
309+
writeDoctorLine(w, "Session state", r.SessionState, true)
310+
writeDoctorLine(w, "Copilot CLI", r.CopilotCLI, false)
311+
}
312+
313+
// runDoctorJSON writes the diagnostics as a single JSON object followed by a
314+
// newline.
315+
func runDoctorJSON(w io.Writer) error {
316+
if w == nil {
317+
w = io.Discard
318+
}
319+
b, err := json.MarshalIndent(collectDoctorReport(), "", " ")
320+
if err != nil {
321+
return err
322+
}
323+
fmt.Fprintf(w, "%s\n", b)
324+
return nil
325+
}
326+
327+
// writeDoctorLine renders one diagnostic entry as human-readable text.
328+
func writeDoctorLine(w io.Writer, label string, e doctorEntry, wantDir bool) {
329+
if e.err != nil {
330+
fmt.Fprintf(w, "%s: error: %v\n", label, e.err)
257331
return
258332
}
259-
fmt.Fprintf(w, "%s: found (%s)\n", label, path)
333+
switch e.Status {
334+
case statusMissing:
335+
if e.Path == "" {
336+
fmt.Fprintf(w, "%s: missing\n", label)
337+
} else {
338+
fmt.Fprintf(w, "%s: missing (%s)\n", label, e.Path)
339+
}
340+
case statusWrongType:
341+
if wantDir {
342+
fmt.Fprintf(w, "%s: wrong type, expected directory (%s)\n", label, e.Path)
343+
} else {
344+
fmt.Fprintf(w, "%s: wrong type, expected file (%s)\n", label, e.Path)
345+
}
346+
default:
347+
fmt.Fprintf(w, "%s: found (%s)\n", label, e.Path)
348+
}
260349
}
261350

262351
// setupLogRedirect opens the log file (if configured via DISPATCH_LOG) and

cmd/dispatch/cli_test.go

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package main
33
import (
44
"bytes"
55
"context"
6+
"encoding/json"
67
"fmt"
78
"io"
89
"os"
@@ -171,6 +172,74 @@ func TestRunDoctor_PrintsDiagnostics(t *testing.T) {
171172
}
172173
}
173174

175+
func TestRunDoctorJSON_Shape(t *testing.T) {
176+
db := filepath.Join(t.TempDir(), "session-store.db")
177+
if err := os.WriteFile(db, []byte("sqlite"), 0o600); err != nil {
178+
t.Fatal(err)
179+
}
180+
stateDir := t.TempDir()
181+
t.Setenv("DISPATCH_DB", db)
182+
t.Setenv("DISPATCH_SESSION_STATE", stateDir)
183+
184+
var buf bytes.Buffer
185+
if err := runDoctorJSON(&buf); err != nil {
186+
t.Fatalf("runDoctorJSON: %v", err)
187+
}
188+
189+
var r doctorReport
190+
if err := json.Unmarshal(buf.Bytes(), &r); err != nil {
191+
t.Fatalf("output is not valid JSON: %v\n%s", err, buf.String())
192+
}
193+
if r.Version == "" {
194+
t.Error("version should be set")
195+
}
196+
if r.OS == "" {
197+
t.Error("os should be set")
198+
}
199+
if r.SessionStore.Status != statusFound {
200+
t.Errorf("session_store status = %q, want %q", r.SessionStore.Status, statusFound)
201+
}
202+
if r.SessionState.Status != statusFound {
203+
t.Errorf("session_state status = %q, want %q", r.SessionState.Status, statusFound)
204+
}
205+
if !strings.HasSuffix(buf.String(), "}\n") {
206+
t.Errorf("JSON output should end with a single newline, got:\n%q", buf.String())
207+
}
208+
}
209+
210+
func TestPathStatus_Cases(t *testing.T) {
211+
dir := t.TempDir()
212+
file := filepath.Join(dir, "f.txt")
213+
if err := os.WriteFile(file, []byte("x"), 0o600); err != nil {
214+
t.Fatal(err)
215+
}
216+
if got := pathStatus(file, false); got != statusFound {
217+
t.Errorf("file wantDir=false: got %q, want %q", got, statusFound)
218+
}
219+
if got := pathStatus(dir, true); got != statusFound {
220+
t.Errorf("dir wantDir=true: got %q, want %q", got, statusFound)
221+
}
222+
if got := pathStatus(filepath.Join(dir, "nope"), false); got != statusMissing {
223+
t.Errorf("missing: got %q, want %q", got, statusMissing)
224+
}
225+
if got := pathStatus(file, true); got != statusWrongType {
226+
t.Errorf("file wantDir=true: got %q, want %q", got, statusWrongType)
227+
}
228+
if got := pathStatus(dir, false); got != statusWrongType {
229+
t.Errorf("dir wantDir=false: got %q, want %q", got, statusWrongType)
230+
}
231+
}
232+
233+
func TestHandleArgs_DoctorJSON(t *testing.T) {
234+
ch := make(chan *update.UpdateInfo, 1)
235+
ch <- nil
236+
237+
done, _, err := handleArgs([]string{"doctor", "--json"}, io.Discard, ch)
238+
if err != nil || !done {
239+
t.Errorf("expected done=true, no error for doctor --json; got done=%v, err=%v", done, err)
240+
}
241+
}
242+
174243
func TestHandleArgs_Doctor(t *testing.T) {
175244
ch := make(chan *update.UpdateInfo, 1)
176245
ch <- nil

cmd/dispatch/main.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@ Commands:
9292
version Print the version
9393
open <id> [--mode M] Resume a session by ID (M: inplace, tab, window, pane)
9494
completion <shell> Print shell completion (bash, zsh, powershell)
95-
doctor Print environment diagnostics
95+
doctor [--json] Print environment diagnostics (--json for machine-readable output)
9696
stats [flags] Print session totals and breakdowns
9797
update Update dispatch to the latest release
9898

0 commit comments

Comments
 (0)