Skip to content

feat(catalog): embed baseline catalog into binary via go:embed#28

Merged
kevinelliott merged 1 commit intomainfrom
feat/embed-catalog
Apr 21, 2026
Merged

feat(catalog): embed baseline catalog into binary via go:embed#28
kevinelliott merged 1 commit intomainfrom
feat/embed-catalog

Conversation

@kevinelliott
Copy link
Copy Markdown
Owner

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

  • `pkg/catalog/catalog.json` — build-time copy of the repo-root canonical `catalog.json`, kept in sync via a Makefile target.
  • `pkg/catalog/embed.go` — declares `//go:embed catalog.json` into `embeddedCatalogJSON`; exports `EmbeddedJSON()`.
  • `pkg/catalog/manager.go` `loadEmbedded` — new resolution order:
    1. User overrides (`/.agentmgr/catalog.json`, `/.config/agentmgr/catalog.json`) — power users can pin a custom catalog without rebuilding.
    2. System-wide install share (`/usr/local/share/agentmgr`, `/etc/agentmgr`) — populated by goreleaser.
    3. The embedded bytes — the always-present baseline.

The CWD probe stays removed (no `./catalog.json`).

Sync discipline

  • `make sync-catalog` → `cp catalog.json pkg/catalog/`. `make build` depends on it.
  • `make check-catalog-sync` → `diff -q` the two copies, non-zero with remediation message if drift.
  • CI Lint job runs `make check-catalog-sync` before golangci-lint.
  • `CONTRIBUTING.md` documents the sync requirement.

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`: new baseline wins when no cache/override exists
  • `_UserOverrideWins`: `$HOME` file takes precedence over embedded bytes
  • `_NoCWDShadow`: stray `./catalog.json` still ignored

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

  • `make build` clean (sync happens automatically)
  • `make check-catalog-sync` green
  • `go test ./... -race -short` green
  • `make lint` clean
  • Manual: `./bin/agentmgr catalog list` on a fresh install (empty cache, no `~/.agentmgr/` override) returns the embedded catalog

🤖 Generated with Claude Code

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>
Copilot AI review requested due to automatic review settings April 21, 2026 11:30
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 via pkg/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.

Comment thread pkg/catalog/manager.go
Comment on lines +292 to +321
// 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",
)
Copy link

Copilot AI Apr 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.
Comment thread pkg/catalog/embed.go
Comment on lines +18 to +21
// 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
Copy link

Copilot AI Apr 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
// 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...)

Copilot uses AI. Check for mistakes.
@kevinelliott kevinelliott merged commit a6eb11d into main Apr 21, 2026
19 checks passed
@kevinelliott kevinelliott deleted the feat/embed-catalog branch April 21, 2026 11:39
kevinelliott added a commit that referenced this pull request Apr 21, 2026
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>
kevinelliott added a commit that referenced this pull request Apr 25, 2026
* 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>
kevinelliott added a commit that referenced this pull request Apr 25, 2026
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants