detached 99d5537

feat(ext): add extension doctor diagnostics

commit
99d5537a9ae4d35a3bcceb1976cd888c984386de
author
Jia Wei · · 2026-07-02 09:07 UTC
parent
5c27086
signature
unsigned
7 files · +507 -6
M README.md +7 -0
@@ -676,12 +676,19 @@ zot can be extended in any language via a subprocess + JSON-RPC protocol. Extens
676676 ```bash
677677 zot ext install <path|git-url> # copy / clone into $ZOT_HOME/extensions/
678678 zot ext list # show installed extensions
679+zot ext doctor # diagnose load, registration, and conflict issues
679680 zot ext logs <name> [-f] # cat or tail the extension's stderr log
680681 zot ext enable <name> # re-enable a disabled extension
681682 zot ext disable <name> # disable without removing
682683 zot ext remove <name> # delete an extension directory
683684 ```
684685
686+`zot ext doctor` keeps normal extension startup fail-soft, but gives
687+you an explicit troubleshooting view: manifest errors, disabled or
688+shadowed extensions, subprocess load errors, ready/auto-ready status,
689+registered commands/tools, registration conflicts, warnings, and the
690+stderr log path.
691+
685692 For development, point `zot --ext <path>` at a working directory and skip the install step entirely. Repeatable; takes precedence over installed extensions of the same name.
686693
687694 ### Updating extensions
@@ -676,12 +676,19 @@ zot can be extended in any language via a subprocess + JSON-RPC protocol. Extens
676 ```bash676 ```bash
677 zot ext install <path|git-url> # copy / clone into $ZOT_HOME/extensions/677 zot ext install <path|git-url> # copy / clone into $ZOT_HOME/extensions/
678 zot ext list # show installed extensions678 zot ext list # show installed extensions
679+zot ext doctor # diagnose load, registration, and conflict issues
679 zot ext logs <name> [-f] # cat or tail the extension's stderr log680 zot ext logs <name> [-f] # cat or tail the extension's stderr log
680 zot ext enable <name> # re-enable a disabled extension681 zot ext enable <name> # re-enable a disabled extension
681 zot ext disable <name> # disable without removing682 zot ext disable <name> # disable without removing
682 zot ext remove <name> # delete an extension directory683 zot ext remove <name> # delete an extension directory
683 ```684 ```
684 685
686+`zot ext doctor` keeps normal extension startup fail-soft, but gives
687+you an explicit troubleshooting view: manifest errors, disabled or
688+shadowed extensions, subprocess load errors, ready/auto-ready status,
689+registered commands/tools, registration conflicts, warnings, and the
690+stderr log path.
691+
685 For development, point `zot --ext <path>` at a working directory and skip the install step entirely. Repeatable; takes precedence over installed extensions of the same name.692 For development, point `zot --ext <path>` at a working directory and skip the install step entirely. Repeatable; takes precedence over installed extensions of the same name.
686 693
687 ### Updating extensions694 ### Updating extensions
M docs/extensions.md +7 -0
@@ -468,6 +468,7 @@ Reply with `shutdown_ack` and then exit.
468468
469469 ```
470470 zot ext list list installed extensions and their state
471+zot ext doctor diagnose load, registration, and conflict issues
471472 zot ext install <path|git-url> copy / clone into $ZOT_HOME/extensions/
472473 zot ext remove <name> delete an extension directory
473474 zot ext enable <name> re-enable a disabled extension
@@ -475,6 +476,12 @@ zot ext disable <name> disable without removing
475476 zot ext logs <name> [-f] cat / tail the extension's stderr
476477 ```
477478
479+`zot ext doctor` runs the same discovery path as zot startup, but reports
480+what happened instead of changing the fail-soft runtime behavior. It shows
481+manifest errors, disabled or shadowed extensions, subprocess load errors,
482+ready/auto-ready status, registered commands/tools, registration conflicts,
483+warnings, and each extension's stderr log path.
484+
478485 `zot ext install <path>` does a recursive copy; `<git-url>` does a
479486 shallow clone. Both validate that the destination contains an
480487 `extension.json` and roll back if not.
@@ -468,6 +468,7 @@ Reply with `shutdown_ack` and then exit.
468 468
469 ```469 ```
470 zot ext list list installed extensions and their state470 zot ext list list installed extensions and their state
471+zot ext doctor diagnose load, registration, and conflict issues
471 zot ext install <path|git-url> copy / clone into $ZOT_HOME/extensions/472 zot ext install <path|git-url> copy / clone into $ZOT_HOME/extensions/
472 zot ext remove <name> delete an extension directory473 zot ext remove <name> delete an extension directory
473 zot ext enable <name> re-enable a disabled extension474 zot ext enable <name> re-enable a disabled extension
@@ -475,6 +476,12 @@ zot ext disable <name> disable without removing
475 zot ext logs <name> [-f] cat / tail the extension's stderr476 zot ext logs <name> [-f] cat / tail the extension's stderr
476 ```477 ```
477 478
479+`zot ext doctor` runs the same discovery path as zot startup, but reports
480+what happened instead of changing the fail-soft runtime behavior. It shows
481+manifest errors, disabled or shadowed extensions, subprocess load errors,
482+ready/auto-ready status, registered commands/tools, registration conflicts,
483+warnings, and each extension's stderr log path.
484+
478 `zot ext install <path>` does a recursive copy; `<git-url>` does a485 `zot ext install <path>` does a recursive copy; `<git-url>` does a
479 shallow clone. Both validate that the destination contains an486 shallow clone. Both validate that the destination contains an
480 `extension.json` and roll back if not.487 `extension.json` and roll back if not.
M packages/agent/cli.go +1 -1
@@ -183,7 +183,7 @@ func Run(rawArgs []string, version string) error {
183183 if handled, err := runBotCommand(rawArgs, version); handled {
184184 return err
185185 }
186- if handled, err := runExtCommand(rawArgs); handled {
186+ if handled, err := runExtCommand(rawArgs, version); handled {
187187 return err
188188 }
189189 if handled, err := runUpdateCommand(rawArgs, version); handled {
@@ -183,7 +183,7 @@ func Run(rawArgs []string, version string) error {
183 if handled, err := runBotCommand(rawArgs, version); handled {183 if handled, err := runBotCommand(rawArgs, version); handled {
184 return err184 return err
185 }185 }
186- if handled, err := runExtCommand(rawArgs); handled {186+ if handled, err := runExtCommand(rawArgs, version); handled {
187 return err187 return err
188 }188 }
189 if handled, err := runUpdateCommand(rawArgs, version); handled {189 if handled, err := runUpdateCommand(rawArgs, version); handled {
M packages/agent/extcmd.go +234 -1
@@ -1,14 +1,19 @@
11 package agent
22
33 import (
4+ "context"
45 "encoding/json"
56 "fmt"
67 "io"
78 "os"
89 "os/exec"
910 "path/filepath"
11+ "sort"
1012 "strings"
13+ "time"
1114
15+ "github.com/patriceckhart/zot/packages/agent/extensions"
16+ "github.com/patriceckhart/zot/packages/agent/extproto"
1217 "github.com/patriceckhart/zot/packages/ignore"
1318 )
1419
@@ -16,7 +21,7 @@ import (
1621 // (handled=true, err) if rawArgs starts with "ext"; otherwise
1722 // (handled=false, nil) so the main router falls through to the
1823 // regular flag parser.
19-func runExtCommand(rawArgs []string) (handled bool, err error) {
24+func runExtCommand(rawArgs []string, version string) (handled bool, err error) {
2025 if len(rawArgs) == 0 || rawArgs[0] != "ext" {
2126 return false, nil
2227 }
@@ -27,6 +32,8 @@ func runExtCommand(rawArgs []string) (handled bool, err error) {
2732 switch rawArgs[1] {
2833 case "list":
2934 return true, extList()
35+ case "doctor":
36+ return true, extDoctor(version)
3037 case "logs":
3138 return true, extLogs(rawArgs[2:])
3239 case "enable":
@@ -51,6 +58,7 @@ func printExtHelp() {
5158
5259 usage:
5360 zot ext list list installed extensions and their state
61+ zot ext doctor diagnose installed extensions
5462 zot ext logs <name> [-f] cat / tail an extension's stderr log
5563 zot ext enable <name> re-enable a disabled extension
5664 zot ext disable <name> disable without removing
@@ -122,6 +130,231 @@ func extList() error {
122130 return nil
123131 }
124132
133+type extDoctorHooks struct{}
134+
135+func (extDoctorHooks) Notify(string, string, string) {}
136+func (extDoctorHooks) Submit(string) {}
137+func (extDoctorHooks) SubmitSlash(string) {}
138+func (extDoctorHooks) Insert(string) {}
139+func (extDoctorHooks) Display(string, string) {}
140+func (extDoctorHooks) ClearNotes(string) {}
141+func (extDoctorHooks) OpenPanel(string, extproto.PanelSpec) {}
142+func (extDoctorHooks) UpdatePanel(string, string, string, []string, string) {}
143+func (extDoctorHooks) ClosePanel(string, string) {}
144+
145+type extDoctorStaticRow struct {
146+ Scope string
147+ Name string
148+ Version string
149+ Enabled bool
150+ Dir string
151+ Exec string
152+ Theme bool
153+ Manifest string
154+ Error string
155+ Shadowed bool
156+}
157+
158+// extDoctor diagnoses extension discovery and registration without changing
159+// normal fail-soft extension behavior.
160+func extDoctor(version string) error {
161+ rows := scanExtDoctorStatic()
162+ if len(rows) == 0 {
163+ fmt.Fprintln(os.Stdout, "no extensions installed")
164+ fmt.Fprintln(os.Stdout, "see docs/extensions.md to write your own, or `zot ext install <path|url>`")
165+ return nil
166+ }
167+
168+ cwd, _ := os.Getwd()
169+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
170+ defer cancel()
171+ mgr := extensions.New(ZotHome(), cwd, version, "", "", extDoctorHooks{})
172+ errs := mgr.Discover(ctx)
173+ mgr.WaitForReady(3 * time.Second)
174+ diags := mgr.Diagnostics()
175+ mgr.Stop(500 * time.Millisecond)
176+
177+ diagByDir := map[string]extensions.ExtensionDiagnostic{}
178+ for _, d := range diags {
179+ diagByDir[d.Dir] = d
180+ }
181+
182+ fmt.Fprintln(os.Stdout, "zot extension doctor")
183+ fmt.Fprintln(os.Stdout)
184+ for _, row := range rows {
185+ printExtDoctorRow(os.Stdout, row, diagByDir[row.Dir])
186+ }
187+ if len(errs) > 0 {
188+ fmt.Fprintln(os.Stdout, "load errors:")
189+ for _, err := range errs {
190+ fmt.Fprintf(os.Stdout, " ! %v\n", err)
191+ }
192+ }
193+ return nil
194+}
195+
196+func scanExtDoctorStatic() []extDoctorStaticRow {
197+ type scanDir struct {
198+ scope string
199+ dir string
200+ }
201+ var dirs []scanDir
202+ if cwd, err := os.Getwd(); err == nil {
203+ dirs = append(dirs, scanDir{scope: "project", dir: filepath.Join(cwd, ".zot", "extensions")})
204+ }
205+ if h := ZotHome(); h != "" {
206+ dirs = append(dirs, scanDir{scope: "global", dir: filepath.Join(h, "extensions")})
207+ }
208+
209+ seen := map[string]bool{}
210+ var rows []extDoctorStaticRow
211+ for _, sd := range dirs {
212+ entries, err := os.ReadDir(sd.dir)
213+ if err != nil {
214+ continue
215+ }
216+ sort.Slice(entries, func(i, j int) bool { return entries[i].Name() < entries[j].Name() })
217+ for _, e := range entries {
218+ if !e.IsDir() {
219+ continue
220+ }
221+ extDir := filepath.Join(sd.dir, e.Name())
222+ row := extDoctorStaticRow{
223+ Scope: sd.scope,
224+ Name: e.Name(),
225+ Enabled: true,
226+ Dir: extDir,
227+ Manifest: filepath.Join(extDir, "extension.json"),
228+ Theme: extensions.HasExtensionTheme(extDir),
229+ }
230+ if seen[e.Name()] {
231+ row.Shadowed = true
232+ } else {
233+ seen[e.Name()] = true
234+ }
235+ raw, err := os.ReadFile(row.Manifest)
236+ if err != nil {
237+ row.Error = "missing extension.json"
238+ rows = append(rows, row)
239+ continue
240+ }
241+ var m struct {
242+ Name string `json:"name"`
243+ Version string `json:"version"`
244+ Exec string `json:"exec"`
245+ Enabled *bool `json:"enabled"`
246+ }
247+ if err := json.Unmarshal(raw, &m); err != nil {
248+ row.Error = "parse manifest: " + err.Error()
249+ rows = append(rows, row)
250+ continue
251+ }
252+ if m.Name != "" {
253+ row.Name = m.Name
254+ } else {
255+ row.Error = "manifest: name is required"
256+ }
257+ row.Version = m.Version
258+ row.Exec = m.Exec
259+ if m.Enabled != nil {
260+ row.Enabled = *m.Enabled
261+ }
262+ if row.Exec == "" && !row.Theme && row.Error == "" {
263+ row.Error = "manifest: exec is required"
264+ }
265+ rows = append(rows, row)
266+ }
267+ }
268+ return rows
269+}
270+
271+func printExtDoctorRow(w io.Writer, row extDoctorStaticRow, diag extensions.ExtensionDiagnostic) {
272+ status := "ok"
273+ switch {
274+ case row.Error != "":
275+ status = "error"
276+ case row.Shadowed:
277+ status = "shadowed"
278+ case !row.Enabled:
279+ status = "disabled"
280+ case row.Exec == "" && row.Theme:
281+ status = "theme-only"
282+ case diag.Name == "":
283+ status = "not loaded"
284+ case diag.ReadyTimedOut:
285+ status = "ready-timeout"
286+ case diag.AutoReady:
287+ status = "auto-ready"
288+ case !diag.Ready:
289+ status = "not ready"
290+ case len(diag.Messages) > 0:
291+ status = "warning"
292+ }
293+ fmt.Fprintf(w, "%s [%s] %s (%s)\n", row.Name, row.Scope, status, row.Dir)
294+ if row.Version != "" {
295+ fmt.Fprintf(w, " version: %s\n", row.Version)
296+ }
297+ if row.Error != "" {
298+ if row.Exec != "" {
299+ fmt.Fprintf(w, " log: %s\n", extDoctorLogPath(row.Name))
300+ }
301+ fmt.Fprintf(w, " error: %s\n", row.Error)
302+ return
303+ }
304+ if row.Shadowed {
305+ fmt.Fprintln(w, " note: skipped because a higher-priority extension directory with this name wins")
306+ return
307+ }
308+ if !row.Enabled {
309+ fmt.Fprintln(w, " note: disabled in extension.json")
310+ return
311+ }
312+ if row.Exec == "" && row.Theme {
313+ fmt.Fprintln(w, " type: theme-only")
314+ return
315+ }
316+ if row.Exec != "" {
317+ fmt.Fprintf(w, " exec: %s\n", row.Exec)
318+ }
319+ logPath := diag.LogPath
320+ if logPath == "" && row.Exec != "" {
321+ logPath = extDoctorLogPath(row.Name)
322+ }
323+ if logPath != "" {
324+ fmt.Fprintf(w, " log: %s\n", logPath)
325+ }
326+ if len(diag.Commands) > 0 {
327+ fmt.Fprintln(w, " commands:")
328+ for _, c := range diag.Commands {
329+ state := "active"
330+ if !c.Active {
331+ state = "shadowed"
332+ }
333+ fmt.Fprintf(w, " /%s (%s)\n", c.Name, state)
334+ }
335+ }
336+ if len(diag.Tools) > 0 {
337+ fmt.Fprintln(w, " tools:")
338+ for _, t := range diag.Tools {
339+ state := "active"
340+ if !t.Active {
341+ state = "shadowed"
342+ }
343+ fmt.Fprintf(w, " %s (%s)\n", t.Name, state)
344+ }
345+ }
346+ for _, msg := range diag.Messages {
347+ fmt.Fprintf(w, " warning: %s\n", msg)
348+ }
349+}
350+
351+func extDoctorLogPath(name string) string {
352+ if name == "" || ZotHome() == "" {
353+ return ""
354+ }
355+ return filepath.Join(ZotHome(), "logs", "ext-"+name+".log")
356+}
357+
125358 // extLogs locates the named extension's log file and either cats or
126359 // tails it (-f).
127360 func extLogs(args []string) error {
@@ -1,14 +1,19 @@
1 package agent1 package agent
2 2
3 import (3 import (
4+ "context"
4 "encoding/json"5 "encoding/json"
5 "fmt"6 "fmt"
6 "io"7 "io"
7 "os"8 "os"
8 "os/exec"9 "os/exec"
9 "path/filepath"10 "path/filepath"
11+ "sort"
10 "strings"12 "strings"
13+ "time"
11 14
15+ "github.com/patriceckhart/zot/packages/agent/extensions"
16+ "github.com/patriceckhart/zot/packages/agent/extproto"
12 "github.com/patriceckhart/zot/packages/ignore"17 "github.com/patriceckhart/zot/packages/ignore"
13 )18 )
14 19
@@ -16,7 +21,7 @@ import (
16 // (handled=true, err) if rawArgs starts with "ext"; otherwise21 // (handled=true, err) if rawArgs starts with "ext"; otherwise
17 // (handled=false, nil) so the main router falls through to the22 // (handled=false, nil) so the main router falls through to the
18 // regular flag parser.23 // regular flag parser.
19-func runExtCommand(rawArgs []string) (handled bool, err error) {24+func runExtCommand(rawArgs []string, version string) (handled bool, err error) {
20 if len(rawArgs) == 0 || rawArgs[0] != "ext" {25 if len(rawArgs) == 0 || rawArgs[0] != "ext" {
21 return false, nil26 return false, nil
22 }27 }
@@ -27,6 +32,8 @@ func runExtCommand(rawArgs []string) (handled bool, err error) {
27 switch rawArgs[1] {32 switch rawArgs[1] {
28 case "list":33 case "list":
29 return true, extList()34 return true, extList()
35+ case "doctor":
36+ return true, extDoctor(version)
30 case "logs":37 case "logs":
31 return true, extLogs(rawArgs[2:])38 return true, extLogs(rawArgs[2:])
32 case "enable":39 case "enable":
@@ -51,6 +58,7 @@ func printExtHelp() {
51 58
52 usage:59 usage:
53 zot ext list list installed extensions and their state60 zot ext list list installed extensions and their state
61+ zot ext doctor diagnose installed extensions
54 zot ext logs <name> [-f] cat / tail an extension's stderr log62 zot ext logs <name> [-f] cat / tail an extension's stderr log
55 zot ext enable <name> re-enable a disabled extension63 zot ext enable <name> re-enable a disabled extension
56 zot ext disable <name> disable without removing64 zot ext disable <name> disable without removing
@@ -122,6 +130,231 @@ func extList() error {
122 return nil130 return nil
123 }131 }
124 132
133+type extDoctorHooks struct{}
134+
135+func (extDoctorHooks) Notify(string, string, string) {}
136+func (extDoctorHooks) Submit(string) {}
137+func (extDoctorHooks) SubmitSlash(string) {}
138+func (extDoctorHooks) Insert(string) {}
139+func (extDoctorHooks) Display(string, string) {}
140+func (extDoctorHooks) ClearNotes(string) {}
141+func (extDoctorHooks) OpenPanel(string, extproto.PanelSpec) {}
142+func (extDoctorHooks) UpdatePanel(string, string, string, []string, string) {}
143+func (extDoctorHooks) ClosePanel(string, string) {}
144+
145+type extDoctorStaticRow struct {
146+ Scope string
147+ Name string
148+ Version string
149+ Enabled bool
150+ Dir string
151+ Exec string
152+ Theme bool
153+ Manifest string
154+ Error string
155+ Shadowed bool
156+}
157+
158+// extDoctor diagnoses extension discovery and registration without changing
159+// normal fail-soft extension behavior.
160+func extDoctor(version string) error {
161+ rows := scanExtDoctorStatic()
162+ if len(rows) == 0 {
163+ fmt.Fprintln(os.Stdout, "no extensions installed")
164+ fmt.Fprintln(os.Stdout, "see docs/extensions.md to write your own, or `zot ext install <path|url>`")
165+ return nil
166+ }
167+
168+ cwd, _ := os.Getwd()
169+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
170+ defer cancel()
171+ mgr := extensions.New(ZotHome(), cwd, version, "", "", extDoctorHooks{})
172+ errs := mgr.Discover(ctx)
173+ mgr.WaitForReady(3 * time.Second)
174+ diags := mgr.Diagnostics()
175+ mgr.Stop(500 * time.Millisecond)
176+
177+ diagByDir := map[string]extensions.ExtensionDiagnostic{}
178+ for _, d := range diags {
179+ diagByDir[d.Dir] = d
180+ }
181+
182+ fmt.Fprintln(os.Stdout, "zot extension doctor")
183+ fmt.Fprintln(os.Stdout)
184+ for _, row := range rows {
185+ printExtDoctorRow(os.Stdout, row, diagByDir[row.Dir])
186+ }
187+ if len(errs) > 0 {
188+ fmt.Fprintln(os.Stdout, "load errors:")
189+ for _, err := range errs {
190+ fmt.Fprintf(os.Stdout, " ! %v\n", err)
191+ }
192+ }
193+ return nil
194+}
195+
196+func scanExtDoctorStatic() []extDoctorStaticRow {
197+ type scanDir struct {
198+ scope string
199+ dir string
200+ }
201+ var dirs []scanDir
202+ if cwd, err := os.Getwd(); err == nil {
203+ dirs = append(dirs, scanDir{scope: "project", dir: filepath.Join(cwd, ".zot", "extensions")})
204+ }
205+ if h := ZotHome(); h != "" {
206+ dirs = append(dirs, scanDir{scope: "global", dir: filepath.Join(h, "extensions")})
207+ }
208+
209+ seen := map[string]bool{}
210+ var rows []extDoctorStaticRow
211+ for _, sd := range dirs {
212+ entries, err := os.ReadDir(sd.dir)
213+ if err != nil {
214+ continue
215+ }
216+ sort.Slice(entries, func(i, j int) bool { return entries[i].Name() < entries[j].Name() })
217+ for _, e := range entries {
218+ if !e.IsDir() {
219+ continue
220+ }
221+ extDir := filepath.Join(sd.dir, e.Name())
222+ row := extDoctorStaticRow{
223+ Scope: sd.scope,
224+ Name: e.Name(),
225+ Enabled: true,
226+ Dir: extDir,
227+ Manifest: filepath.Join(extDir, "extension.json"),
228+ Theme: extensions.HasExtensionTheme(extDir),
229+ }
230+ if seen[e.Name()] {
231+ row.Shadowed = true
232+ } else {
233+ seen[e.Name()] = true
234+ }
235+ raw, err := os.ReadFile(row.Manifest)
236+ if err != nil {
237+ row.Error = "missing extension.json"
238+ rows = append(rows, row)
239+ continue
240+ }
241+ var m struct {
242+ Name string `json:"name"`
243+ Version string `json:"version"`
244+ Exec string `json:"exec"`
245+ Enabled *bool `json:"enabled"`
246+ }
247+ if err := json.Unmarshal(raw, &m); err != nil {
248+ row.Error = "parse manifest: " + err.Error()
249+ rows = append(rows, row)
250+ continue
251+ }
252+ if m.Name != "" {
253+ row.Name = m.Name
254+ } else {
255+ row.Error = "manifest: name is required"
256+ }
257+ row.Version = m.Version
258+ row.Exec = m.Exec
259+ if m.Enabled != nil {
260+ row.Enabled = *m.Enabled
261+ }
262+ if row.Exec == "" && !row.Theme && row.Error == "" {
263+ row.Error = "manifest: exec is required"
264+ }
265+ rows = append(rows, row)
266+ }
267+ }
268+ return rows
269+}
270+
271+func printExtDoctorRow(w io.Writer, row extDoctorStaticRow, diag extensions.ExtensionDiagnostic) {
272+ status := "ok"
273+ switch {
274+ case row.Error != "":
275+ status = "error"
276+ case row.Shadowed:
277+ status = "shadowed"
278+ case !row.Enabled:
279+ status = "disabled"
280+ case row.Exec == "" && row.Theme:
281+ status = "theme-only"
282+ case diag.Name == "":
283+ status = "not loaded"
284+ case diag.ReadyTimedOut:
285+ status = "ready-timeout"
286+ case diag.AutoReady:
287+ status = "auto-ready"
288+ case !diag.Ready:
289+ status = "not ready"
290+ case len(diag.Messages) > 0:
291+ status = "warning"
292+ }
293+ fmt.Fprintf(w, "%s [%s] %s (%s)\n", row.Name, row.Scope, status, row.Dir)
294+ if row.Version != "" {
295+ fmt.Fprintf(w, " version: %s\n", row.Version)
296+ }
297+ if row.Error != "" {
298+ if row.Exec != "" {
299+ fmt.Fprintf(w, " log: %s\n", extDoctorLogPath(row.Name))
300+ }
301+ fmt.Fprintf(w, " error: %s\n", row.Error)
302+ return
303+ }
304+ if row.Shadowed {
305+ fmt.Fprintln(w, " note: skipped because a higher-priority extension directory with this name wins")
306+ return
307+ }
308+ if !row.Enabled {
309+ fmt.Fprintln(w, " note: disabled in extension.json")
310+ return
311+ }
312+ if row.Exec == "" && row.Theme {
313+ fmt.Fprintln(w, " type: theme-only")
314+ return
315+ }
316+ if row.Exec != "" {
317+ fmt.Fprintf(w, " exec: %s\n", row.Exec)
318+ }
319+ logPath := diag.LogPath
320+ if logPath == "" && row.Exec != "" {
321+ logPath = extDoctorLogPath(row.Name)
322+ }
323+ if logPath != "" {
324+ fmt.Fprintf(w, " log: %s\n", logPath)
325+ }
326+ if len(diag.Commands) > 0 {
327+ fmt.Fprintln(w, " commands:")
328+ for _, c := range diag.Commands {
329+ state := "active"
330+ if !c.Active {
331+ state = "shadowed"
332+ }
333+ fmt.Fprintf(w, " /%s (%s)\n", c.Name, state)
334+ }
335+ }
336+ if len(diag.Tools) > 0 {
337+ fmt.Fprintln(w, " tools:")
338+ for _, t := range diag.Tools {
339+ state := "active"
340+ if !t.Active {
341+ state = "shadowed"
342+ }
343+ fmt.Fprintf(w, " %s (%s)\n", t.Name, state)
344+ }
345+ }
346+ for _, msg := range diag.Messages {
347+ fmt.Fprintf(w, " warning: %s\n", msg)
348+ }
349+}
350+
351+func extDoctorLogPath(name string) string {
352+ if name == "" || ZotHome() == "" {
353+ return ""
354+ }
355+ return filepath.Join(ZotHome(), "logs", "ext-"+name+".log")
356+}
357+
125 // extLogs locates the named extension's log file and either cats or358 // extLogs locates the named extension's log file and either cats or
126 // tails it (-f).359 // tails it (-f).
127 func extLogs(args []string) error {360 func extLogs(args []string) error {
M packages/agent/extcmd_test.go +57 -0
@@ -1,9 +1,13 @@
11 package agent
22
33 import (
4+ "bytes"
45 "os"
56 "path/filepath"
7+ "strings"
68 "testing"
9+
10+ "github.com/patriceckhart/zot/packages/agent/extensions"
711 )
812
913 // TestExtInstallDotSource verifies that `zot ext install .` derives the
@@ -124,3 +128,56 @@ func TestGitignoreNegation(t *testing.T) {
124128 t.Fatal("expected build/keep.txt to be re-included by negation")
125129 }
126130 }
131+
132+func TestExtDoctorStaticScanAndRender(t *testing.T) {
133+ home := t.TempDir()
134+ t.Setenv("ZOT_HOME", home)
135+ cwd := t.TempDir()
136+ oldCWD, err := os.Getwd()
137+ if err != nil {
138+ t.Fatal(err)
139+ }
140+ defer os.Chdir(oldCWD)
141+ if err := os.Chdir(cwd); err != nil {
142+ t.Fatal(err)
143+ }
144+
145+ mustWrite := func(path, body string) {
146+ t.Helper()
147+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
148+ t.Fatal(err)
149+ }
150+ if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
151+ t.Fatal(err)
152+ }
153+ }
154+
155+ mustWrite(filepath.Join(cwd, ".zot", "extensions", "disabled", "extension.json"), `{"name":"disabled","exec":"./run.sh","enabled":false}`)
156+ mustWrite(filepath.Join(cwd, ".zot", "extensions", "theme", "extension.json"), `{"name":"theme"}`)
157+ mustWrite(filepath.Join(cwd, ".zot", "extensions", "theme", "theme.json"), `{"name":"Theme"}`)
158+ mustWrite(filepath.Join(cwd, ".zot", "extensions", "bad", "extension.json"), `{bad json`)
159+ mustWrite(filepath.Join(cwd, ".zot", "extensions", "dup", "extension.json"), `{"name":"dup","exec":"./project.sh"}`)
160+ mustWrite(filepath.Join(home, "extensions", "dup", "extension.json"), `{"name":"dup","exec":"./global.sh"}`)
161+
162+ rows := scanExtDoctorStatic()
163+ if len(rows) != 5 {
164+ t.Fatalf("rows = %d, want 5: %#v", len(rows), rows)
165+ }
166+
167+ var out bytes.Buffer
168+ for _, row := range rows {
169+ printExtDoctorRow(&out, row, extensions.ExtensionDiagnostic{})
170+ }
171+ got := out.String()
172+ for _, want := range []string{
173+ "disabled [project] disabled",
174+ "theme [project] theme-only",
175+ "bad [project] error",
176+ "dup [global] shadowed",
177+ "parse manifest:",
178+ } {
179+ if !strings.Contains(got, want) {
180+ t.Fatalf("doctor output missing %q:\n%s", want, got)
181+ }
182+ }
183+}
@@ -1,9 +1,13 @@
1 package agent1 package agent
2 2
3 import (3 import (
4+ "bytes"
4 "os"5 "os"
5 "path/filepath"6 "path/filepath"
7+ "strings"
6 "testing"8 "testing"
9+
10+ "github.com/patriceckhart/zot/packages/agent/extensions"
7 )11 )
8 12
9 // TestExtInstallDotSource verifies that `zot ext install .` derives the13 // TestExtInstallDotSource verifies that `zot ext install .` derives the
@@ -124,3 +128,56 @@ func TestGitignoreNegation(t *testing.T) {
124 t.Fatal("expected build/keep.txt to be re-included by negation")128 t.Fatal("expected build/keep.txt to be re-included by negation")
125 }129 }
126 }130 }
131+
132+func TestExtDoctorStaticScanAndRender(t *testing.T) {
133+ home := t.TempDir()
134+ t.Setenv("ZOT_HOME", home)
135+ cwd := t.TempDir()
136+ oldCWD, err := os.Getwd()
137+ if err != nil {
138+ t.Fatal(err)
139+ }
140+ defer os.Chdir(oldCWD)
141+ if err := os.Chdir(cwd); err != nil {
142+ t.Fatal(err)
143+ }
144+
145+ mustWrite := func(path, body string) {
146+ t.Helper()
147+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
148+ t.Fatal(err)
149+ }
150+ if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
151+ t.Fatal(err)
152+ }
153+ }
154+
155+ mustWrite(filepath.Join(cwd, ".zot", "extensions", "disabled", "extension.json"), `{"name":"disabled","exec":"./run.sh","enabled":false}`)
156+ mustWrite(filepath.Join(cwd, ".zot", "extensions", "theme", "extension.json"), `{"name":"theme"}`)
157+ mustWrite(filepath.Join(cwd, ".zot", "extensions", "theme", "theme.json"), `{"name":"Theme"}`)
158+ mustWrite(filepath.Join(cwd, ".zot", "extensions", "bad", "extension.json"), `{bad json`)
159+ mustWrite(filepath.Join(cwd, ".zot", "extensions", "dup", "extension.json"), `{"name":"dup","exec":"./project.sh"}`)
160+ mustWrite(filepath.Join(home, "extensions", "dup", "extension.json"), `{"name":"dup","exec":"./global.sh"}`)
161+
162+ rows := scanExtDoctorStatic()
163+ if len(rows) != 5 {
164+ t.Fatalf("rows = %d, want 5: %#v", len(rows), rows)
165+ }
166+
167+ var out bytes.Buffer
168+ for _, row := range rows {
169+ printExtDoctorRow(&out, row, extensions.ExtensionDiagnostic{})
170+ }
171+ got := out.String()
172+ for _, want := range []string{
173+ "disabled [project] disabled",
174+ "theme [project] theme-only",
175+ "bad [project] error",
176+ "dup [global] shadowed",
177+ "parse manifest:",
178+ } {
179+ if !strings.Contains(got, want) {
180+ t.Fatalf("doctor output missing %q:\n%s", want, got)
181+ }
182+ }
183+}
M packages/agent/extensions/manager.go +119 -4
@@ -92,6 +92,10 @@ type Extension struct {
9292 // EmitEvent / InterceptToolCall to filter recipients.
9393 eventSubs map[string]struct{}
9494 interceptSubs map[string]struct{}
95+
96+ autoReady bool
97+ readyTimedOut bool
98+ diagnostics []string
9599 }
96100
97101 // HostHooks is the small interface the manager calls back into the
@@ -255,7 +259,7 @@ func (m *Manager) loadOne(ctx context.Context, dir string) error {
255259 if mf.Name == "" {
256260 return errors.New("manifest: name is required")
257261 }
258- hasTheme := hasExtensionTheme(dir)
262+ hasTheme := HasExtensionTheme(dir)
259263 if mf.Exec == "" && !hasTheme {
260264 return errors.New("manifest: exec is required")
261265 }
@@ -307,7 +311,9 @@ func (m *Manager) loadOne(ctx context.Context, dir string) error {
307311 // Loaded BEFORE Discover so explicit paths win on name conflicts
308312 // against installed extensions. Spawns happen in parallel like the
309313 // regular discovery path; errors are returned per path.
310-func hasExtensionTheme(dir string) bool {
314+// HasExtensionTheme reports whether an extension directory contains a theme
315+// file in one of the supported extension-owned locations.
316+func HasExtensionTheme(dir string) bool {
311317 for _, file := range []string{"theme.json", filepath.Join("themes", "theme.json")} {
312318 if _, err := os.Stat(filepath.Join(dir, file)); err == nil {
313319 return true
@@ -490,6 +496,10 @@ func (m *Manager) WaitForReady(grace time.Duration) {
490496 select {
491497 case <-ext.readyCh:
492498 case <-deadline:
499+ ext.mu.Lock()
500+ ext.readyTimedOut = true
501+ ext.diagnostics = append(ext.diagnostics, "timed out waiting for ready frame")
502+ ext.mu.Unlock()
493503 fmt.Fprintf(ext.logFile, "[zot] timed out waiting for ready frame; proceeding\n")
494504 ext.readyOnce.Do(func() { close(ext.readyCh) })
495505 }
@@ -623,6 +633,10 @@ func (m *Manager) assumeReadyAfterIdle(ext *Extension) {
623633 if current.Equal(last) {
624634 // No new frame in the idle window. Treat as ready.
625635 ext.readyOnce.Do(func() {
636+ ext.mu.Lock()
637+ ext.autoReady = true
638+ ext.diagnostics = append(ext.diagnostics, "no ready frame; auto-ready after idle")
639+ ext.mu.Unlock()
626640 fmt.Fprintf(ext.logFile, "[zot] no ready frame; auto-readying after idle (legacy SDK?)\n")
627641 close(ext.readyCh)
628642 })
@@ -662,6 +676,7 @@ func (m *Manager) readLoop(ext *Extension, scanner *bufio.Scanner) {
662676 ext.mu.Unlock()
663677 var frame extproto.Frame
664678 if err := json.Unmarshal(line, &frame); err != nil {
679+ ext.recordDiagnostic(fmt.Sprintf("malformed json frame: %v", err))
665680 fmt.Fprintf(ext.logFile, "[zot] malformed json from extension: %v\n", err)
666681 continue
667682 }
@@ -675,10 +690,14 @@ func (m *Manager) readLoop(ext *Extension, scanner *bufio.Scanner) {
675690 // keep the race detector happy.
676691 m.mu.Lock()
677692 ext.commands = append(ext.commands, rc)
678- if _, exists := m.commandIndex[rc.Name]; !exists {
693+ _, shadowed := m.commandIndex[rc.Name]
694+ if !shadowed {
679695 m.commandIndex[rc.Name] = ext
680696 }
681697 m.mu.Unlock()
698+ if shadowed {
699+ ext.recordDiagnostic(fmt.Sprintf("command /%s conflicts with another extension registering the same name", rc.Name))
700+ }
682701 }
683702 case "register_tool":
684703 var rt extproto.RegisterToolFromExt
@@ -691,16 +710,21 @@ func (m *Manager) readLoop(ext *Extension, scanner *bufio.Scanner) {
691710 if len(rt.Schema) > 0 {
692711 var tmp any
693712 if err := json.Unmarshal(rt.Schema, &tmp); err != nil {
713+ ext.recordDiagnostic(fmt.Sprintf("tool %q schema is not valid json: %v", rt.Name, err))
694714 fmt.Fprintf(ext.logFile, "[zot] tool %q: schema is not valid json (%v); skipped\n", rt.Name, err)
695715 continue
696716 }
697717 }
698718 m.mu.Lock()
699719 ext.tools = append(ext.tools, rt)
700- if _, exists := m.toolIndex[rt.Name]; !exists {
720+ _, shadowed := m.toolIndex[rt.Name]
721+ if !shadowed {
701722 m.toolIndex[rt.Name] = ext
702723 }
703724 m.mu.Unlock()
725+ if shadowed {
726+ ext.recordDiagnostic(fmt.Sprintf("tool %s conflicts with another extension registering the same name", rt.Name))
727+ }
704728 case "ready":
705729 ext.readyOnce.Do(func() { close(ext.readyCh) })
706730 case "subscribe":
@@ -805,11 +829,102 @@ func (m *Manager) readLoop(ext *Extension, scanner *bufio.Scanner) {
805829 case "shutdown_ack":
806830 // Caller of Stop is waiting on the process exit, not this frame.
807831 default:
832+ ext.recordDiagnostic(fmt.Sprintf("unknown frame type %q", frame.Type))
808833 fmt.Fprintf(ext.logFile, "[zot] unknown frame type %q\n", frame.Type)
809834 }
810835 }
811836 }
812837
838+func (ext *Extension) recordDiagnostic(msg string) {
839+ ext.mu.Lock()
840+ defer ext.mu.Unlock()
841+ ext.diagnostics = append(ext.diagnostics, msg)
842+}
843+
844+// RegisteredCommandDiagnostic describes one command an extension attempted
845+// to register and whether it owns the active dispatch slot.
846+type RegisteredCommandDiagnostic struct {
847+ Name string
848+ Description string
849+ Active bool
850+}
851+
852+// RegisteredToolDiagnostic describes one tool an extension attempted to
853+// register and whether it owns the active dispatch slot.
854+type RegisteredToolDiagnostic struct {
855+ Name string
856+ Description string
857+ Active bool
858+}
859+
860+// ExtensionDiagnostic is a stable snapshot for diagnostic commands.
861+type ExtensionDiagnostic struct {
862+ Name string
863+ Version string
864+ Dir string
865+ LogPath string
866+ ThemeOnly bool
867+ Ready bool
868+ AutoReady bool
869+ ReadyTimedOut bool
870+ Commands []RegisteredCommandDiagnostic
871+ Tools []RegisteredToolDiagnostic
872+ Messages []string
873+}
874+
875+// Diagnostics returns a snapshot of loaded extension state for commands such
876+// as `zot ext doctor`. It is read-only and does not change manager behavior.
877+func (m *Manager) Diagnostics() []ExtensionDiagnostic {
878+ m.mu.RLock()
879+ defer m.mu.RUnlock()
880+ names := make([]string, 0, len(m.ext))
881+ for name := range m.ext {
882+ names = append(names, name)
883+ }
884+ sort.Strings(names)
885+ out := make([]ExtensionDiagnostic, 0, len(names))
886+ for _, name := range names {
887+ ext := m.ext[name]
888+ ext.mu.Lock()
889+ msgs := append([]string(nil), ext.diagnostics...)
890+ autoReady := ext.autoReady
891+ readyTimedOut := ext.readyTimedOut
892+ ext.mu.Unlock()
893+
894+ diag := ExtensionDiagnostic{
895+ Name: ext.Manifest.Name,
896+ Version: ext.Manifest.Version,
897+ Dir: ext.Dir,
898+ LogPath: ext.LogPath,
899+ ThemeOnly: ext.Manifest.Exec == "",
900+ AutoReady: autoReady,
901+ ReadyTimedOut: readyTimedOut,
902+ Messages: msgs,
903+ }
904+ select {
905+ case <-ext.readyCh:
906+ diag.Ready = true
907+ default:
908+ }
909+ for _, c := range ext.commands {
910+ diag.Commands = append(diag.Commands, RegisteredCommandDiagnostic{
911+ Name: c.Name,
912+ Description: c.Description,
913+ Active: m.commandIndex[c.Name] == ext,
914+ })
915+ }
916+ for _, t := range ext.tools {
917+ diag.Tools = append(diag.Tools, RegisteredToolDiagnostic{
918+ Name: t.Name,
919+ Description: t.Description,
920+ Active: m.toolIndex[t.Name] == ext,
921+ })
922+ }
923+ out = append(out, diag)
924+ }
925+ return out
926+}
927+
813928 // Commands returns a snapshot of every (extension, command) pair
814929 // currently registered. Used by the slash autocomplete + /help.
815930 func (m *Manager) Commands() []CommandInfo {
@@ -92,6 +92,10 @@ type Extension struct {
92 // EmitEvent / InterceptToolCall to filter recipients.92 // EmitEvent / InterceptToolCall to filter recipients.
93 eventSubs map[string]struct{}93 eventSubs map[string]struct{}
94 interceptSubs map[string]struct{}94 interceptSubs map[string]struct{}
95+
96+ autoReady bool
97+ readyTimedOut bool
98+ diagnostics []string
95 }99 }
96 100
97 // HostHooks is the small interface the manager calls back into the101 // HostHooks is the small interface the manager calls back into the
@@ -255,7 +259,7 @@ func (m *Manager) loadOne(ctx context.Context, dir string) error {
255 if mf.Name == "" {259 if mf.Name == "" {
256 return errors.New("manifest: name is required")260 return errors.New("manifest: name is required")
257 }261 }
258- hasTheme := hasExtensionTheme(dir)262+ hasTheme := HasExtensionTheme(dir)
259 if mf.Exec == "" && !hasTheme {263 if mf.Exec == "" && !hasTheme {
260 return errors.New("manifest: exec is required")264 return errors.New("manifest: exec is required")
261 }265 }
@@ -307,7 +311,9 @@ func (m *Manager) loadOne(ctx context.Context, dir string) error {
307 // Loaded BEFORE Discover so explicit paths win on name conflicts311 // Loaded BEFORE Discover so explicit paths win on name conflicts
308 // against installed extensions. Spawns happen in parallel like the312 // against installed extensions. Spawns happen in parallel like the
309 // regular discovery path; errors are returned per path.313 // regular discovery path; errors are returned per path.
310-func hasExtensionTheme(dir string) bool {314+// HasExtensionTheme reports whether an extension directory contains a theme
315+// file in one of the supported extension-owned locations.
316+func HasExtensionTheme(dir string) bool {
311 for _, file := range []string{"theme.json", filepath.Join("themes", "theme.json")} {317 for _, file := range []string{"theme.json", filepath.Join("themes", "theme.json")} {
312 if _, err := os.Stat(filepath.Join(dir, file)); err == nil {318 if _, err := os.Stat(filepath.Join(dir, file)); err == nil {
313 return true319 return true
@@ -490,6 +496,10 @@ func (m *Manager) WaitForReady(grace time.Duration) {
490 select {496 select {
491 case <-ext.readyCh:497 case <-ext.readyCh:
492 case <-deadline:498 case <-deadline:
499+ ext.mu.Lock()
500+ ext.readyTimedOut = true
501+ ext.diagnostics = append(ext.diagnostics, "timed out waiting for ready frame")
502+ ext.mu.Unlock()
493 fmt.Fprintf(ext.logFile, "[zot] timed out waiting for ready frame; proceeding\n")503 fmt.Fprintf(ext.logFile, "[zot] timed out waiting for ready frame; proceeding\n")
494 ext.readyOnce.Do(func() { close(ext.readyCh) })504 ext.readyOnce.Do(func() { close(ext.readyCh) })
495 }505 }
@@ -623,6 +633,10 @@ func (m *Manager) assumeReadyAfterIdle(ext *Extension) {
623 if current.Equal(last) {633 if current.Equal(last) {
624 // No new frame in the idle window. Treat as ready.634 // No new frame in the idle window. Treat as ready.
625 ext.readyOnce.Do(func() {635 ext.readyOnce.Do(func() {
636+ ext.mu.Lock()
637+ ext.autoReady = true
638+ ext.diagnostics = append(ext.diagnostics, "no ready frame; auto-ready after idle")
639+ ext.mu.Unlock()
626 fmt.Fprintf(ext.logFile, "[zot] no ready frame; auto-readying after idle (legacy SDK?)\n")640 fmt.Fprintf(ext.logFile, "[zot] no ready frame; auto-readying after idle (legacy SDK?)\n")
627 close(ext.readyCh)641 close(ext.readyCh)
628 })642 })
@@ -662,6 +676,7 @@ func (m *Manager) readLoop(ext *Extension, scanner *bufio.Scanner) {
662 ext.mu.Unlock()676 ext.mu.Unlock()
663 var frame extproto.Frame677 var frame extproto.Frame
664 if err := json.Unmarshal(line, &frame); err != nil {678 if err := json.Unmarshal(line, &frame); err != nil {
679+ ext.recordDiagnostic(fmt.Sprintf("malformed json frame: %v", err))
665 fmt.Fprintf(ext.logFile, "[zot] malformed json from extension: %v\n", err)680 fmt.Fprintf(ext.logFile, "[zot] malformed json from extension: %v\n", err)
666 continue681 continue
667 }682 }
@@ -675,10 +690,14 @@ func (m *Manager) readLoop(ext *Extension, scanner *bufio.Scanner) {
675 // keep the race detector happy.690 // keep the race detector happy.
676 m.mu.Lock()691 m.mu.Lock()
677 ext.commands = append(ext.commands, rc)692 ext.commands = append(ext.commands, rc)
678- if _, exists := m.commandIndex[rc.Name]; !exists {693+ _, shadowed := m.commandIndex[rc.Name]
694+ if !shadowed {
679 m.commandIndex[rc.Name] = ext695 m.commandIndex[rc.Name] = ext
680 }696 }
681 m.mu.Unlock()697 m.mu.Unlock()
698+ if shadowed {
699+ ext.recordDiagnostic(fmt.Sprintf("command /%s conflicts with another extension registering the same name", rc.Name))
700+ }
682 }701 }
683 case "register_tool":702 case "register_tool":
684 var rt extproto.RegisterToolFromExt703 var rt extproto.RegisterToolFromExt
@@ -691,16 +710,21 @@ func (m *Manager) readLoop(ext *Extension, scanner *bufio.Scanner) {
691 if len(rt.Schema) > 0 {710 if len(rt.Schema) > 0 {
692 var tmp any711 var tmp any
693 if err := json.Unmarshal(rt.Schema, &tmp); err != nil {712 if err := json.Unmarshal(rt.Schema, &tmp); err != nil {
713+ ext.recordDiagnostic(fmt.Sprintf("tool %q schema is not valid json: %v", rt.Name, err))
694 fmt.Fprintf(ext.logFile, "[zot] tool %q: schema is not valid json (%v); skipped\n", rt.Name, err)714 fmt.Fprintf(ext.logFile, "[zot] tool %q: schema is not valid json (%v); skipped\n", rt.Name, err)
695 continue715 continue
696 }716 }
697 }717 }
698 m.mu.Lock()718 m.mu.Lock()
699 ext.tools = append(ext.tools, rt)719 ext.tools = append(ext.tools, rt)
700- if _, exists := m.toolIndex[rt.Name]; !exists {720+ _, shadowed := m.toolIndex[rt.Name]
721+ if !shadowed {
701 m.toolIndex[rt.Name] = ext722 m.toolIndex[rt.Name] = ext
702 }723 }
703 m.mu.Unlock()724 m.mu.Unlock()
725+ if shadowed {
726+ ext.recordDiagnostic(fmt.Sprintf("tool %s conflicts with another extension registering the same name", rt.Name))
727+ }
704 case "ready":728 case "ready":
705 ext.readyOnce.Do(func() { close(ext.readyCh) })729 ext.readyOnce.Do(func() { close(ext.readyCh) })
706 case "subscribe":730 case "subscribe":
@@ -805,11 +829,102 @@ func (m *Manager) readLoop(ext *Extension, scanner *bufio.Scanner) {
805 case "shutdown_ack":829 case "shutdown_ack":
806 // Caller of Stop is waiting on the process exit, not this frame.830 // Caller of Stop is waiting on the process exit, not this frame.
807 default:831 default:
832+ ext.recordDiagnostic(fmt.Sprintf("unknown frame type %q", frame.Type))
808 fmt.Fprintf(ext.logFile, "[zot] unknown frame type %q\n", frame.Type)833 fmt.Fprintf(ext.logFile, "[zot] unknown frame type %q\n", frame.Type)
809 }834 }
810 }835 }
811 }836 }
812 837
838+func (ext *Extension) recordDiagnostic(msg string) {
839+ ext.mu.Lock()
840+ defer ext.mu.Unlock()
841+ ext.diagnostics = append(ext.diagnostics, msg)
842+}
843+
844+// RegisteredCommandDiagnostic describes one command an extension attempted
845+// to register and whether it owns the active dispatch slot.
846+type RegisteredCommandDiagnostic struct {
847+ Name string
848+ Description string
849+ Active bool
850+}
851+
852+// RegisteredToolDiagnostic describes one tool an extension attempted to
853+// register and whether it owns the active dispatch slot.
854+type RegisteredToolDiagnostic struct {
855+ Name string
856+ Description string
857+ Active bool
858+}
859+
860+// ExtensionDiagnostic is a stable snapshot for diagnostic commands.
861+type ExtensionDiagnostic struct {
862+ Name string
863+ Version string
864+ Dir string
865+ LogPath string
866+ ThemeOnly bool
867+ Ready bool
868+ AutoReady bool
869+ ReadyTimedOut bool
870+ Commands []RegisteredCommandDiagnostic
871+ Tools []RegisteredToolDiagnostic
872+ Messages []string
873+}
874+
875+// Diagnostics returns a snapshot of loaded extension state for commands such
876+// as `zot ext doctor`. It is read-only and does not change manager behavior.
877+func (m *Manager) Diagnostics() []ExtensionDiagnostic {
878+ m.mu.RLock()
879+ defer m.mu.RUnlock()
880+ names := make([]string, 0, len(m.ext))
881+ for name := range m.ext {
882+ names = append(names, name)
883+ }
884+ sort.Strings(names)
885+ out := make([]ExtensionDiagnostic, 0, len(names))
886+ for _, name := range names {
887+ ext := m.ext[name]
888+ ext.mu.Lock()
889+ msgs := append([]string(nil), ext.diagnostics...)
890+ autoReady := ext.autoReady
891+ readyTimedOut := ext.readyTimedOut
892+ ext.mu.Unlock()
893+
894+ diag := ExtensionDiagnostic{
895+ Name: ext.Manifest.Name,
896+ Version: ext.Manifest.Version,
897+ Dir: ext.Dir,
898+ LogPath: ext.LogPath,
899+ ThemeOnly: ext.Manifest.Exec == "",
900+ AutoReady: autoReady,
901+ ReadyTimedOut: readyTimedOut,
902+ Messages: msgs,
903+ }
904+ select {
905+ case <-ext.readyCh:
906+ diag.Ready = true
907+ default:
908+ }
909+ for _, c := range ext.commands {
910+ diag.Commands = append(diag.Commands, RegisteredCommandDiagnostic{
911+ Name: c.Name,
912+ Description: c.Description,
913+ Active: m.commandIndex[c.Name] == ext,
914+ })
915+ }
916+ for _, t := range ext.tools {
917+ diag.Tools = append(diag.Tools, RegisteredToolDiagnostic{
918+ Name: t.Name,
919+ Description: t.Description,
920+ Active: m.toolIndex[t.Name] == ext,
921+ })
922+ }
923+ out = append(out, diag)
924+ }
925+ return out
926+}
927+
813 // Commands returns a snapshot of every (extension, command) pair928 // Commands returns a snapshot of every (extension, command) pair
814 // currently registered. Used by the slash autocomplete + /help.929 // currently registered. Used by the slash autocomplete + /help.
815 func (m *Manager) Commands() []CommandInfo {930 func (m *Manager) Commands() []CommandInfo {
M packages/agent/extensions/manager_test.go +82 -0
@@ -182,6 +182,88 @@ func TestManagerSpawnAndInvoke(t *testing.T) {
182182 }
183183 }
184184
185+func TestDiagnosticsReportMalformedFramesAndConflicts(t *testing.T) {
186+ if runtime.GOOS == "windows" {
187+ t.Skip("mock extension uses /bin/sh; skip on windows")
188+ }
189+
190+ tmp := t.TempDir()
191+ extRoot := filepath.Join(tmp, "extensions")
192+ writeDiagExtension := func(name, script string) {
193+ t.Helper()
194+ dir := filepath.Join(extRoot, name)
195+ if err := os.MkdirAll(dir, 0o755); err != nil {
196+ t.Fatal(err)
197+ }
198+ if err := os.WriteFile(filepath.Join(dir, "run.sh"), []byte(script), 0o755); err != nil {
199+ t.Fatal(err)
200+ }
201+ mfb, _ := json.Marshal(map[string]any{"name": name, "exec": "./run.sh"})
202+ if err := os.WriteFile(filepath.Join(dir, "extension.json"), mfb, 0o644); err != nil {
203+ t.Fatal(err)
204+ }
205+ }
206+
207+ writeDiagExtension("a-first", `#!/bin/sh
208+printf '%s\n' '{"type":"hello","name":"a-first","version":"0.1","capabilities":["tools"]}'
209+printf '%s\n' '{"type":"register_tool","name":"shared","description":"first","schema":{"type":"object"}}'
210+printf '%s\n' '{"type":"register_tool","name":"broken","description":"bad","schema":'
211+printf '%s\n' '{"type":"ready"}'
212+while IFS= read -r line; do
213+ case "$line" in
214+ *'"type":"shutdown"'*) exit 0 ;;
215+ esac
216+done
217+`)
218+ writeDiagExtension("b-second", `#!/bin/sh
219+printf '%s\n' '{"type":"hello","name":"b-second","version":"0.1","capabilities":["tools"]}'
220+printf '%s\n' '{"type":"register_tool","name":"shared","description":"second","schema":{"type":"object"}}'
221+printf '%s\n' '{"type":"ready"}'
222+while IFS= read -r line; do
223+ case "$line" in
224+ *'"type":"shutdown"'*) exit 0 ;;
225+ esac
226+done
227+`)
228+
229+ mgr := New(tmp, "", "0.0.0-test", "anthropic", "claude-opus-4-7", &stubHooks{})
230+ if errs := mgr.Discover(context.Background()); len(errs) > 0 {
231+ t.Fatalf("discover errors: %v", errs)
232+ }
233+ defer mgr.Stop(2 * time.Second)
234+ mgr.WaitForReady(2 * time.Second)
235+
236+ diags := mgr.Diagnostics()
237+ byName := map[string]ExtensionDiagnostic{}
238+ for _, d := range diags {
239+ byName[d.Name] = d
240+ }
241+
242+ first := byName["a-first"]
243+ if len(first.Messages) == 0 || !strings.Contains(strings.Join(first.Messages, "\n"), "malformed json frame") {
244+ t.Fatalf("expected malformed-frame diagnostic, got %#v", first.Messages)
245+ }
246+
247+ var shadowedTool bool
248+ var conflictMessage bool
249+ for _, d := range diags {
250+ if strings.Contains(strings.Join(d.Messages, "\n"), "conflicts with another extension") {
251+ conflictMessage = true
252+ }
253+ for _, tool := range d.Tools {
254+ if tool.Name == "shared" && !tool.Active {
255+ shadowedTool = true
256+ }
257+ }
258+ }
259+ if !shadowedTool {
260+ t.Fatalf("expected one shared tool registration to be inactive, got %#v", diags)
261+ }
262+ if !conflictMessage {
263+ t.Fatalf("expected conflict diagnostic, got %#v", diags)
264+ }
265+}
266+
185267 // TestSpontaneousOpenPanel verifies that an extension sending an
186268 // open_panel frame outside of any command response causes the manager
187269 // to call hooks.OpenPanel with the correct PanelSpec fields.
@@ -182,6 +182,88 @@ func TestManagerSpawnAndInvoke(t *testing.T) {
182 }182 }
183 }183 }
184 184
185+func TestDiagnosticsReportMalformedFramesAndConflicts(t *testing.T) {
186+ if runtime.GOOS == "windows" {
187+ t.Skip("mock extension uses /bin/sh; skip on windows")
188+ }
189+
190+ tmp := t.TempDir()
191+ extRoot := filepath.Join(tmp, "extensions")
192+ writeDiagExtension := func(name, script string) {
193+ t.Helper()
194+ dir := filepath.Join(extRoot, name)
195+ if err := os.MkdirAll(dir, 0o755); err != nil {
196+ t.Fatal(err)
197+ }
198+ if err := os.WriteFile(filepath.Join(dir, "run.sh"), []byte(script), 0o755); err != nil {
199+ t.Fatal(err)
200+ }
201+ mfb, _ := json.Marshal(map[string]any{"name": name, "exec": "./run.sh"})
202+ if err := os.WriteFile(filepath.Join(dir, "extension.json"), mfb, 0o644); err != nil {
203+ t.Fatal(err)
204+ }
205+ }
206+
207+ writeDiagExtension("a-first", `#!/bin/sh
208+printf '%s\n' '{"type":"hello","name":"a-first","version":"0.1","capabilities":["tools"]}'
209+printf '%s\n' '{"type":"register_tool","name":"shared","description":"first","schema":{"type":"object"}}'
210+printf '%s\n' '{"type":"register_tool","name":"broken","description":"bad","schema":'
211+printf '%s\n' '{"type":"ready"}'
212+while IFS= read -r line; do
213+ case "$line" in
214+ *'"type":"shutdown"'*) exit 0 ;;
215+ esac
216+done
217+`)
218+ writeDiagExtension("b-second", `#!/bin/sh
219+printf '%s\n' '{"type":"hello","name":"b-second","version":"0.1","capabilities":["tools"]}'
220+printf '%s\n' '{"type":"register_tool","name":"shared","description":"second","schema":{"type":"object"}}'
221+printf '%s\n' '{"type":"ready"}'
222+while IFS= read -r line; do
223+ case "$line" in
224+ *'"type":"shutdown"'*) exit 0 ;;
225+ esac
226+done
227+`)
228+
229+ mgr := New(tmp, "", "0.0.0-test", "anthropic", "claude-opus-4-7", &stubHooks{})
230+ if errs := mgr.Discover(context.Background()); len(errs) > 0 {
231+ t.Fatalf("discover errors: %v", errs)
232+ }
233+ defer mgr.Stop(2 * time.Second)
234+ mgr.WaitForReady(2 * time.Second)
235+
236+ diags := mgr.Diagnostics()
237+ byName := map[string]ExtensionDiagnostic{}
238+ for _, d := range diags {
239+ byName[d.Name] = d
240+ }
241+
242+ first := byName["a-first"]
243+ if len(first.Messages) == 0 || !strings.Contains(strings.Join(first.Messages, "\n"), "malformed json frame") {
244+ t.Fatalf("expected malformed-frame diagnostic, got %#v", first.Messages)
245+ }
246+
247+ var shadowedTool bool
248+ var conflictMessage bool
249+ for _, d := range diags {
250+ if strings.Contains(strings.Join(d.Messages, "\n"), "conflicts with another extension") {
251+ conflictMessage = true
252+ }
253+ for _, tool := range d.Tools {
254+ if tool.Name == "shared" && !tool.Active {
255+ shadowedTool = true
256+ }
257+ }
258+ }
259+ if !shadowedTool {
260+ t.Fatalf("expected one shared tool registration to be inactive, got %#v", diags)
261+ }
262+ if !conflictMessage {
263+ t.Fatalf("expected conflict diagnostic, got %#v", diags)
264+ }
265+}
266+
185 // TestSpontaneousOpenPanel verifies that an extension sending an267 // TestSpontaneousOpenPanel verifies that an extension sending an
186 // open_panel frame outside of any command response causes the manager268 // open_panel frame outside of any command response causes the manager
187 // to call hooks.OpenPanel with the correct PanelSpec fields.269 // to call hooks.OpenPanel with the correct PanelSpec fields.

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close