feat(catalog): embed baseline catalog into binary via go:embed#28
feat(catalog): embed baseline catalog into binary via go:embed#28kevinelliott merged 1 commit intomainfrom
Conversation
Fixes the UX hole where a fresh `go install github.com/.../agentmgr`
has no catalog on first run — no package-managed
/usr/share/agentmgr/catalog.json exists, the SQLite cache is empty,
and a remote refresh requires network. Now every build ships a
baseline catalog compiled in; first run works offline.
Approach
- pkg/catalog/catalog.json: build-time copy of the repo-root
canonical catalog.json (kept in sync via Makefile target below).
- pkg/catalog/embed.go: declares `//go:embed catalog.json` into
embeddedCatalogJSON; EmbeddedJSON() exposes the raw bytes.
- pkg/catalog/manager.go (loadEmbedded): new resolution order —
1. user overrides (~/.agentmgr/catalog.json,
~/.config/agentmgr/catalog.json) let power users pin a custom
catalog without rebuilding,
2. system-wide install share (/usr/local/share/agentmgr,
/etc/agentmgr) populated by goreleaser,
3. the embedded bytes as the always-present baseline.
- The CWD probe stays removed — a stray catalog.json in whatever
directory the user ran agentmgr from should never win.
Sync discipline
- Makefile `sync-catalog` target: `cp catalog.json pkg/catalog/`.
`make build` depends on it so the default dev loop Just Works.
- Makefile `check-catalog-sync` target: `diff -q` the two copies,
exit non-zero with a remediation message if they drift.
- CI Lint job runs `make check-catalog-sync` before golangci-lint.
- CONTRIBUTING.md documents the sync requirement for contributors
who run `go build` directly.
We sync rather than symlink because //go:embed does not follow
symlinks that point outside the package directory.
Tests
- Replaces TestManagerLoadEmbedded with three scoped tests:
_UsesEmbeddedBaseline (the new baseline path wins when no
cache/override exists), _UserOverrideWins (power-user $HOME file
takes precedence over embedded bytes), and _NoCWDShadow (stray
./catalog.json is still ignored).
- Adds withEmbeddedJSON test helper that swaps the package-level
bytes with t.Cleanup restoration — used in the Refresh tests so
they exercise remote-vs-cache version logic without the embedded
baseline interfering.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR embeds a baseline agent catalog into the agentmgr binary (via go:embed) so first-run catalog operations work offline, while preserving on-disk user/system overrides and the existing remote refresh flow.
Changes:
- Adds an embedded baseline catalog (
pkg/catalog/catalog.json) and exposes raw embedded bytes viapkg/catalog/embed.go. - Updates catalog loading fallback order to prefer user overrides, then system paths, then embedded bytes.
- Adds Makefile + CI guardrails to keep the embedded catalog copy synced with the repo-root
catalog.json, and updates/extends tests for the new resolution behavior.
Reviewed changes
Copilot reviewed 6 out of 7 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| pkg/catalog/embed.go | Introduces go:embed baseline catalog bytes + EmbeddedJSON() accessor |
| pkg/catalog/manager.go | Updates loadEmbedded() resolution order and adds embedded fallback |
| pkg/catalog/manager_test.go | Reworks/extends tests around embedded fallback, overrides, and refresh behavior |
| pkg/catalog/catalog.json | Adds the embedded baseline catalog copy (synced from repo-root catalog.json) |
| Makefile | Adds sync-catalog + check-catalog-sync; makes build depend on syncing |
| .github/workflows/ci.yml | Runs make check-catalog-sync before lint to prevent drift |
| CONTRIBUTING.md | Documents the catalog sync requirement for contributors |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // loadEmbedded returns the baseline catalog that ships with the binary, | ||
| // allowing user-scoped on-disk overrides to win when present. | ||
| // | ||
| // Resolution order: | ||
| // 1. User-override paths ($HOME/.agentmgr/catalog.json, | ||
| // $HOME/.config/agentmgr/catalog.json) — lets power users pin a | ||
| // modified catalog without rebuilding. | ||
| // 2. System-wide install share (/usr/local/share/agentmgr/catalog.json, | ||
| // /etc/agentmgr/catalog.json) — populated by goreleaser packaging. | ||
| // 3. The go:embed'd catalog compiled into the binary (see embed.go). | ||
| // | ||
| // The current working directory is intentionally NOT probed. A stray | ||
| // catalog.json in whatever directory the user happened to invoke agentmgr | ||
| // from would silently shadow the real catalog. | ||
| func (m *Manager) loadEmbedded() (*Catalog, error) { | ||
| // Try to read from file in current directory or known locations | ||
| paths := []string{ | ||
| "catalog.json", | ||
| "/usr/local/share/agentmgr/catalog.json", | ||
| "/etc/agentmgr/catalog.json", | ||
| } | ||
| paths := make([]string, 0, 4) | ||
|
|
||
| // Add user home directory paths | ||
| // 1. User-scoped overrides. | ||
| if home, err := os.UserHomeDir(); err == nil { | ||
| paths = append(paths, | ||
| home+"/.agentmgr/catalog.json", | ||
| home+"/.config/agentmgr/catalog.json", | ||
| ) | ||
| } | ||
|
|
||
| // 2. System-wide install share. | ||
| paths = append(paths, | ||
| "/usr/local/share/agentmgr/catalog.json", | ||
| "/etc/agentmgr/catalog.json", | ||
| ) |
There was a problem hiding this comment.
loadEmbedded() claims the system-wide catalog is populated by goreleaser packaging, but the probed paths don’t include /usr/share/agentmgr/catalog.json (goreleaser nfpms installs the catalog there). As a result, packaged installs won’t ever pick up the system catalog and will always skip straight to the embedded baseline. Add /usr/share/agentmgr/catalog.json to the system path list (and update the doc comment accordingly; optionally keep /usr/local/share/... for manual installs).
| // EmbeddedJSON returns the raw JSON bytes of the embedded catalog. | ||
| // Callers that want a parsed *Catalog should use Manager.Get instead. | ||
| func EmbeddedJSON() []byte { | ||
| return embeddedCatalogJSON |
There was a problem hiding this comment.
EmbeddedJSON() returns the package-level []byte directly. Because []byte is mutable, external callers can accidentally (or concurrently) modify the embedded baseline, potentially corrupting catalog loads (and for go:embed data, writes may even fault on some platforms). Safer API: return a defensive copy (e.g., append([]byte(nil), embeddedCatalogJSON...)) and document the returned bytes as immutable if you want to avoid extra allocations in hot paths.
| // EmbeddedJSON returns the raw JSON bytes of the embedded catalog. | |
| // Callers that want a parsed *Catalog should use Manager.Get instead. | |
| func EmbeddedJSON() []byte { | |
| return embeddedCatalogJSON | |
| // EmbeddedJSON returns a copy of the raw JSON bytes of the embedded catalog. | |
| // Callers that want a parsed *Catalog should use Manager.Get instead. | |
| func EmbeddedJSON() []byte { | |
| return append([]byte(nil), embeddedCatalogJSON...) |
Promote the Unreleased section to 1.2.0 and capture the UX / operability work landed since v1.1.0: - #20 install/update progress streaming with -v - #21 gRPC server wire-up in helper - #22, #26 test coverage (cli/output 0→82%, systray 0.2→3.2%) - #24 RefreshOnStart field removal - #27 bubbles v0.21 -> v1.0 - #28 go:embed baseline catalog (fresh `go install` works offline) - #29 pkg/logging slog wrapper wired into main and grpc recovery Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: address Copilot review feedback — correctness fixes Seven real-bug fixes called out across review comments on merged PRs: - pkg/platform/platform.go: resetLookPathCache() no longer reassigns the package-level sync.Map (unsafe vs concurrent readers). Uses Range+Delete instead. - pkg/installer/providers/brew.go: GetLatestVersion no longer caches context-canceled / deadline-exceeded errors. A caller with a short ctx would have poisoned the 5m TTL for every later caller. - pkg/installer/providers/progress.go: the progress writer is now wrapped in a locking writer so concurrent Write calls from cmd.Stdout and cmd.Stderr (os/exec writes those from separate goroutines) don't interleave bytes mid-call. - pkg/catalog/embed.go: EmbeddedJSON() now returns a copy of the backing bytes. Previously callers could mutate the package-level []byte, corrupting the baseline for every future Get(). - pkg/catalog/manager.go: loadEmbedded now also probes /usr/share/agentmgr/catalog.json — where the goreleaser nfpm config installs the catalog for .deb/.rpm users. Previously that path was silently ignored at runtime even though goreleaser wrote the file there at install time. - internal/cli/doctor.go: Catalog Sources section updated with the matching /usr/share/agentmgr row so `doctor` output mirrors the loader's probe order. - internal/cli/agent.go: removed the --all (-a) and --check-updates flags from `agent list`. Both bound to local vars that were never read anywhere, so the flags did nothing; users setting them got a silent no-op instead of a useful behavior. Supersedes the corresponding Copilot annotations on PRs #19, #28, #29, #33, plus the dead-flag note on the agent list command. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore: address remaining Copilot review nits (docs + tests) Batch of small review-comment fixes — documentation accuracy, test hardening, and gosec G104 on grpc teardown: - pkg/api/grpc/server.go: explicit \`_ = s.listener.Close()\` in Stop() and ForceStop() with a comment noting the close error is not actionable during teardown (silences gosec G104). - pkg/catalog/manager.go: Refresh doc now explicitly notes that singleflight does not merge contexts and the first caller's ctx governs the shared fetch — a caller with a short deadline can cancel the in-flight work for all shared callers. - pkg/installer/providers/progress.go: WithProgressWriter doc clarifies why passing nil is a no-op (asymmetry lets middleware attach a writer safely without downstream code accidentally clobbering it). - internal/systray/cli_uninstall_darwin.go: file is darwin-only via //go:build tag, so platform.ID() is always Darwin at runtime. Removed the dead Linux and Windows branches (the filepath/os/platform imports they needed are gone too). - CHANGELOG.md: "(Removed in [Unreleased].)" was a broken shortcut reference link — promoted to "(Removed in v1.2.0.)". - README.md: feature bullet uses the full "agentmgr agent list" command prefix, consistent with everywhere else in the doc. - internal/cli/output/spinner_test.go: non-TTY test replaced the 30ms sleep with a direct assertion on s.isTTY. Start() is a no-op when isTTY is false, so sleeping was measuring nothing. - internal/cli/output/table_test.go: ANSI-alignment loop now actually asserts inter-column padding instead of discarding each line. - internal/systray/handlers_test.go: mustParseVersion panics on parse error so test-setup bugs surface at the fixture, not downstream. TestDialogTracking uses bare *exec.Cmd values (trackDialog only checks pointer identity) to avoid the PATH lookup for \`true\` on Windows CI. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(cli): drop "all" flag assertion from list; assert removal The --all / -a and --check-updates flags were removed from \`agent list\` in the parent commit because both bound to unused local vars. Drop the existence assertion for "all" and add an anti-regression check that asserts both flags are ABSENT. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Five catalog entries (block-goose, openclaw, droid, claude-squad, amazon-q-cli at lines 468/706/2149/2212/2630) declare cask-ness via metadata.cask: "true" rather than the canonical metadata.type: "cask". parseBrewPackage only checked the canonical key, so "brew install" ran without --cask for those agents and Homebrew rejected them as unknown formulae. Now accepts either form: - "type": "cask" (canonical) - "cask": "true" (legacy, common in user-customized catalogs too) Provider behavior change is the entire fix — no catalog edits needed. brew install/upgrade/uninstall already build their own arg lists from isCask, so the catalog command/update_cmd strings (which lack --cask for these legacy entries) are documentation only. Closes the corresponding Copilot review comment from PR #28. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Summary
Fixes the UX hole where a fresh `go install github.com/.../agentmgr` has no catalog on first run — no package-managed `/usr/share/agentmgr/catalog.json` exists, the SQLite cache is empty, and a remote refresh requires network. Now every build ships a baseline catalog compiled in; first run works offline.
Approach
/.agentmgr/catalog.json`, `/.config/agentmgr/catalog.json`) — power users can pin a custom catalog without rebuilding.The CWD probe stays removed (no `./catalog.json`).
Sync discipline
We sync rather than symlink because `//go:embed` does not follow symlinks that point outside the package directory.
Tests
Replaces `TestManagerLoadEmbedded` with three scoped tests:
Adds `withEmbeddedJSON` helper (t.Cleanup-restoring swap) and uses it in the existing Refresh tests so they exercise remote-vs-cache version logic without the embedded baseline interfering.
Supersedes
Addresses the concern that closed #23 was trying to fix, without breaking CI's reliance on a baseline catalog being present at runtime.
Test plan
🤖 Generated with Claude Code