feat(doctor): add Catalog Sources section showing every resolution layer#32
feat(doctor): add Catalog Sources section showing every resolution layer#32kevinelliott merged 1 commit intomainfrom
Conversation
Lists each catalog source the manager consults so users can see at a glance which layer will serve their catalog: Catalog Sources --------------- ✓ SQLite Catalog Cache: v1.0.11, cached 81h ago ℹ User override (dotdir): not present: ~/.agentmgr/catalog.json ℹ User override (XDG): not present: ~/.config/agentmgr/catalog.json ℹ System share: not present: /usr/local/share/agentmgr/catalog.json ℹ System etc: not present: /etc/agentmgr/catalog.json ✓ Embedded baseline: v1.0.29 (100264 bytes) Each present source reports its version (parsed from the file / cache row). Missing sources render as Skipped, not Warning — absence is the expected state for most paths on a typical machine. Exposes catalog.EmbeddedJSON() to let callers (doctor, potential future admin tools) inspect the baked-in baseline without constructing a Manager. The check flags a Fatal if the embedded bytes are empty — that's a build misconfiguration (build ran without `make sync-catalog`). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Adds a new “Catalog Sources” section to agentmgr doctor that reports catalog resolution layers (SQLite cache, user overrides, system paths, embedded baseline) so users can see which source will serve the catalog.
Changes:
- Add “Catalog Sources” output section to the doctor command.
- Implement
runCatalogSourceChecksto probe cache, file overrides, and embedded catalog, including version/age reporting. - Surface embedded-catalog “empty bytes” as a fatal doctor error.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // 4. Embedded baseline (//go:embed'd into the binary) | ||
| // | ||
| // The CWD is intentionally never probed. | ||
| func runCatalogSourceChecks(ctx context.Context, cfg *config.Config, _ bool) []CheckResult { |
There was a problem hiding this comment.
runCatalogSourceChecks takes cfg *config.Config but never uses it, which will fail compilation in Go (unused parameter). Rename the parameter to _ or use cfg (e.g., for platform/config-derived paths) so the file builds.
| func runCatalogSourceChecks(ctx context.Context, cfg *config.Config, _ bool) []CheckResult { | |
| func runCatalogSourceChecks(ctx context.Context, _ *config.Config, _ bool) []CheckResult { |
| case data == nil: | ||
| results = append(results, CheckResult{ | ||
| Name: "SQLite Catalog Cache", | ||
| Status: CheckOK, |
There was a problem hiding this comment.
When the SQLite catalog cache is empty (data == nil), the check is marked CheckOK. This is effectively a missing source (common on first run) and per the section semantics should be CheckSkipped/info rather than a success checkmark so users don’t read it as “cache is healthy and in use”.
| Status: CheckOK, | |
| Status: CheckSkipped, |
| msg := fmt.Sprintf("cached %s ago", time.Since(cachedAt).Round(time.Second)) | ||
| if err := json.Unmarshal(data, &c); err == nil { | ||
| msg = fmt.Sprintf("v%s, cached %s ago", c.Version, time.Since(cachedAt).Round(time.Second)) | ||
| } | ||
| results = append(results, CheckResult{ | ||
| Name: "SQLite Catalog Cache", | ||
| Status: CheckOK, | ||
| Message: msg, | ||
| }) |
There was a problem hiding this comment.
If the cache row exists but can’t be unmarshaled as a catalog.Catalog, the code still reports CheckOK and just omits the version. That hides a corrupt/invalid cache entry (which catalog.Manager.loadFromCache will reject). Consider reporting CheckWarning (or CheckError) on unmarshal failure and include a remediation hint (e.g., clear the DB/cache).
| msg := fmt.Sprintf("cached %s ago", time.Since(cachedAt).Round(time.Second)) | |
| if err := json.Unmarshal(data, &c); err == nil { | |
| msg = fmt.Sprintf("v%s, cached %s ago", c.Version, time.Since(cachedAt).Round(time.Second)) | |
| } | |
| results = append(results, CheckResult{ | |
| Name: "SQLite Catalog Cache", | |
| Status: CheckOK, | |
| Message: msg, | |
| }) | |
| age := time.Since(cachedAt).Round(time.Second) | |
| if err := json.Unmarshal(data, &c); err != nil { | |
| results = append(results, CheckResult{ | |
| Name: "SQLite Catalog Cache", | |
| Status: CheckWarning, | |
| Message: fmt.Sprintf("cache entry is invalid: %v (cached %s ago)", err, age), | |
| Fix: "Clear the catalog cache/DB and let it be rebuilt.", | |
| }) | |
| } else { | |
| results = append(results, CheckResult{ | |
| Name: "SQLite Catalog Cache", | |
| Status: CheckOK, | |
| Message: fmt.Sprintf("v%s, cached %s ago", c.Version, age), | |
| }) | |
| } |
| paths = append(paths, | ||
| struct{ label, path string }{"User override (dotdir)", filepath.Join(home, ".agentmgr", "catalog.json")}, | ||
| struct{ label, path string }{"User override (XDG)", filepath.Join(home, ".config", "agentmgr", "catalog.json")}, | ||
| ) |
There was a problem hiding this comment.
If os.UserHomeDir() fails, the user-override layers are silently omitted from the report, which can mislead users about what was checked. Consider appending explicit CheckWarning/CheckSkipped results for the user-override layers indicating that $HOME could not be determined (and therefore the paths couldn’t be evaluated).
| ) | |
| ) | |
| } else { | |
| results = append(results, | |
| CheckResult{ | |
| Name: "User override (dotdir)", | |
| Status: CheckWarning, | |
| Message: fmt.Sprintf("skipped: could not determine $HOME, so %s could not be evaluated", filepath.Join("$HOME", ".agentmgr", "catalog.json")), | |
| }, | |
| CheckResult{ | |
| Name: "User override (XDG)", | |
| Status: CheckWarning, | |
| Message: fmt.Sprintf("skipped: could not determine $HOME, so %s could not be evaluated", filepath.Join("$HOME", ".config", "agentmgr", "catalog.json")), | |
| }, | |
| ) |
| ) | ||
| } | ||
| paths = append(paths, | ||
| struct{ label, path string }{"System share", "/usr/local/share/agentmgr/catalog.json"}, |
There was a problem hiding this comment.
The system catalog path checked here is /usr/local/share/agentmgr/catalog.json, but packaging installs the catalog to /usr/share/agentmgr/catalog.json (see .goreleaser.yml nfpms contents). As written, doctor will incorrectly report “not present” on package installs. Add /usr/share/agentmgr/catalog.json to the checked paths (and ideally align catalog.Manager.loadEmbedded to consult the same location).
| struct{ label, path string }{"System share", "/usr/local/share/agentmgr/catalog.json"}, | |
| struct{ label, path string }{"System share", "/usr/local/share/agentmgr/catalog.json"}, | |
| struct{ label, path string }{"System share (packaged)", "/usr/share/agentmgr/catalog.json"}, |
| var msg string | ||
| if err := json.Unmarshal(data, &c); err == nil { | ||
| msg = fmt.Sprintf("%s (v%s)", p.path, c.Version) | ||
| } else { | ||
| msg = fmt.Sprintf("%s (invalid JSON)", p.path) | ||
| } | ||
| results = append(results, CheckResult{ | ||
| Name: p.label, | ||
| Status: CheckOK, | ||
| Message: msg, | ||
| }) |
There was a problem hiding this comment.
When a file-based catalog source exists but contains invalid JSON, the code still marks it CheckOK. Since this layer will be ignored by catalog.Manager.loadEmbedded, it should be reported as CheckWarning (with an actionable fix like “repair or delete the file”) so users understand why it isn’t being used.
| var msg string | |
| if err := json.Unmarshal(data, &c); err == nil { | |
| msg = fmt.Sprintf("%s (v%s)", p.path, c.Version) | |
| } else { | |
| msg = fmt.Sprintf("%s (invalid JSON)", p.path) | |
| } | |
| results = append(results, CheckResult{ | |
| Name: p.label, | |
| Status: CheckOK, | |
| Message: msg, | |
| }) | |
| if err := json.Unmarshal(data, &c); err == nil { | |
| results = append(results, CheckResult{ | |
| Name: p.label, | |
| Status: CheckOK, | |
| Message: fmt.Sprintf("%s (v%s)", p.path, c.Version), | |
| }) | |
| } else { | |
| results = append(results, CheckResult{ | |
| Name: p.label, | |
| Status: CheckWarning, | |
| Message: fmt.Sprintf("%s (invalid JSON: %v)", p.path, err), | |
| Fix: "repair the JSON in this file or delete it so it is not treated as a catalog override", | |
| }) | |
| } |
| msg := fmt.Sprintf("%d bytes", len(embeddedJSON)) | ||
| if err := json.Unmarshal(embeddedJSON, &c); err == nil { | ||
| msg = fmt.Sprintf("v%s (%d bytes)", c.Version, len(embeddedJSON)) | ||
| } | ||
| results = append(results, CheckResult{ | ||
| Name: "Embedded baseline", | ||
| Status: CheckOK, | ||
| Message: msg, | ||
| }) |
There was a problem hiding this comment.
For the embedded baseline, JSON unmarshal failure currently still reports CheckOK with a byte count. If the embedded bytes are present but invalid, catalog loading will fail at runtime (invalid embedded catalog). This should be surfaced as CheckError (or at least CheckWarning) so doctor accurately flags the build/runtime misconfiguration.
| msg := fmt.Sprintf("%d bytes", len(embeddedJSON)) | |
| if err := json.Unmarshal(embeddedJSON, &c); err == nil { | |
| msg = fmt.Sprintf("v%s (%d bytes)", c.Version, len(embeddedJSON)) | |
| } | |
| results = append(results, CheckResult{ | |
| Name: "Embedded baseline", | |
| Status: CheckOK, | |
| Message: msg, | |
| }) | |
| if err := json.Unmarshal(embeddedJSON, &c); err != nil { | |
| results = append(results, CheckResult{ | |
| Name: "Embedded baseline", | |
| Status: CheckError, | |
| Message: fmt.Sprintf("invalid embedded catalog JSON (%d bytes): %v", len(embeddedJSON), err), | |
| Fix: "run `make sync-catalog` before `go build` (or use `make build`)", | |
| }) | |
| } else { | |
| results = append(results, CheckResult{ | |
| Name: "Embedded baseline", | |
| Status: CheckOK, | |
| Message: fmt.Sprintf("v%s (%d bytes)", c.Version, len(embeddedJSON)), | |
| }) | |
| } |
Toolchain + dependency refresh. Minimum Go bumped to 1.25 (forced by x/sync v0.20.0), golangci-lint migrated to v2.11.4, 5 deps bumped plus 3 patch-level ones from v1.2.0-era. Also captures post-v1.2.0 polish: TUI storage reuse perf win (#33), doctor catalog-source breakdown (#32), catalog ctx-scoped logger (#31), README refresh (#30). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Summary
`agentmgr doctor` now reports the status of every catalog source the manager consults, in resolution order, so users can tell at a glance which layer will serve their catalog.
Output
```
Catalog Sources
✓ SQLite Catalog Cache: v1.0.11, cached 81h ago
ℹ User override (dotdir): not present: ~/.agentmgr/catalog.json
ℹ User override (XDG): not present: ~/.config/agentmgr/catalog.json
ℹ System share: not present: /usr/local/share/agentmgr/catalog.json
ℹ System etc: not present: /etc/agentmgr/catalog.json
✓ Embedded baseline: v1.0.29 (100264 bytes)
```
Each present source reports its version (parsed from the file / cache row). Missing sources render as Skipped (`ℹ`) rather than Warning — absence is the expected state for most paths on a typical machine.
The check flags a Fatal if the embedded bytes are empty, since that's a build misconfiguration (build ran without `make sync-catalog`).
Exposes
`catalog.EmbeddedJSON() []byte` was added in #28; this PR uses it from `doctor` and leaves it available for future admin tooling that wants to inspect the baseline without constructing a Manager.
Test plan
🤖 Generated with Claude Code