AGENTS.md · git:20260713.88999c9 · 2026-07-13 · sha256 d63a00f616b60fa9
AGENTS.md git:20260713.88999c9A
Immutable. This exact content is served forever at /api/v1/blob/d63a00f616b60fa9.
# Agents guide — releases-cli
This repo is the public, client-only CLI for the Releases registry. It talks to `api.releases.sh` over HTTP. Ingest, database access, and AI pipelines live in a separate open-source backend monorepo, [buildinternet/releases](https://github.com/buildinternet/releases).
## Stack
- **Runtime:** Bun (required for `bun --compile`)
- **Language:** TypeScript (strict mode)
- **CLI:** Commander
- **MCP:** `@modelcontextprotocol/sdk` on stdio
- **HTTP client:** `src/api/client.ts` is the only data-access layer. No Drizzle, no SQLite, no local DB.
## Commands
```bash
bun src/index.ts <command> # run from source
bun run build # compile binary to dist/releases
bun run check # oxlint (lint + type-check) + oxfmt check — CI gate
bun run lint # oxlint only (`typecheck` is an alias)
bun test # bun test (not part of check)
```
## Architecture
- **`src/index.ts`** — entry point. Validates config, sets up telemetry, rewrites legacy aliases, hands off to Commander.
- **`src/cli/program.ts`** — Commander program wiring. Public reader commands at the top level; operator workflows under `admin`.
- **`src/cli/commands/`** — one file per command. Every command goes through `src/api/client.ts` for data access.
- **`src/api/client.ts`** — single HTTP boundary. `apiFetch()` auto-attaches `Authorization: Bearer ${RELEASES_API_KEY}` when admin mode is active, and (via `src/lib/mutation-log.ts`) records mutating requests when `RELEASES_RUN_DIR` is set.
- **`src/lib/mode.ts`** — `getApiUrl()` / `getApiKey()` / `isAdminMode()` / `validateConfig()`. Always remote.
- **`src/lib/mutation-log.ts`** — admin-mutation audit log for the `~/.releases/work/` maintenance workspace. When `RELEASES_RUN_DIR` is set, each non-GET request (minus telemetry/heartbeat plumbing) appends a `{timestamp, command, target, result}` JSONL line to `$RELEASES_RUN_DIR/mutations.jsonl`. Unset → no-op; fully fail-open.
- **`src/lib/trace.ts`** — managed-session traces. Writes `<dir>/<id>/{trace.json,summary.md}` for terminal sessions/workflows; dir precedence is explicit flag > `RELEASES_RUN_DIR` > `~/.releases/work/runs`. Used by `--trace-dir` (onboard, `source fetch --wait`, `overview batch --wait`) and `task get --save`. `summary.md` mirrors the monorepo's `docs/architecture/maintenance-workspace.md` run-summary template.
- **`src/lib/telemetry.ts`** — anonymous usage pings to `api.releases.sh/v1/telemetry`. First-run notice shown once. Opt out via `RELEASES_TELEMETRY_DISABLED=1` or `DO_NOT_TRACK=1`.
- **`src/lib/update-check.ts`** — npm-registry poll for newer CLI versions (24h cache in `~/.releases/update-check.json`). Prints a one-line stderr nag after command output when stale. **`src/lib/skills-update-check.ts`** mirrors it for the bundled skills: GitHub `git/trees/main` poll for the `skills/` subtree SHA, cached in `~/.releases/skills-check.json`. Baseline is written on successful `releases skills install`; the nag fires only if a baseline exists and diverges. Defense in depth: when the `skills` CLI's lock file (`$XDG_STATE_HOME/skills/.skill-lock.json` or `~/.agents/.skill-lock.json`) is present and parses cleanly with zero `buildinternet/releases-cli` entries, the nag is suppressed (user uninstalled via `skills`). A missing/unreadable lock file falls through to the baseline check so manual installers aren't penalized. Opt out via `RELEASES_DISABLE_SKILL_UPDATE_CHECK=1`. Both checks skip non-TTY callers and `--help`/`--version`.
- **`src/mcp/server.ts`** — local stdio MCP bridge. Exposes read-only tools (`search`, `get_latest_releases`, `list_catalog`, `get_catalog_entry`, `get_source`, `list_organizations`, `get_organization`) that proxy to `api.releases.sh`. Mirrors the canonical tool names served by the hosted server at `mcp.releases.sh`. `get_catalog_entry` inlines a CHANGELOG slice for source entries via `include_changelog` / `changelog_path` / `changelog_offset` / `changelog_limit` / `changelog_tokens` (products have none — degrades with a clear message). The former `get_source_changelog` tool was removed in 0.72.0 — use `get_catalog_entry` with the `changelog_*` params instead.
- **`@buildinternet/releases-core`** — runtime-neutral helpers (schema, categories, slicing, IDs, slugs, tokens, CLI contracts). Published from the private [`buildinternet/releases`](https://github.com/buildinternet/releases) monorepo (canonical source in `packages/core/`), consumed here as a regular npm dependency. Bump the pin in `package.json` when adopting a new schema.
- **`packages/lib/`** (`@buildinternet/releases-lib`) — logger, errors, trimmed config.
- **`skills/`** — single source of truth for the three user-facing agent skills (`releases-mcp`, `releases-cli`, `analyzing-releases`); there is no generated copy and no npm wrapper (`@buildinternet/releases-skills` is retired/deprecated). Operator skills live in the backend monorepo's `.claude/skills/` tree — do not re-add copies here. The Claude plugin references these folders directly through `.claude-plugin/marketplace.json` (the plugin's `skills` array lists `./skills/<name>` paths, resolved against the repo root via `source: "./"`), so editing a skill is the whole change — nothing to re-sync. Cross-agent install runs through `releases skills install`, which shells out to `npx skills add buildinternet/releases-cli` (the `vercel-labs/skills` ecosystem). Wiring is in `src/cli/commands/skills.ts`; pure argv construction in `src/cli/skills/build-args.ts`.
- **`plugins/claude/releases/`** — Claude Code plugin. Bundles the hosted MCP connection + the skills referenced from `skills/` via `marketplace.json`.
- **`npm/`** — meta package (`@buildinternet/releases`) + five platform binary packages. CI writes the compiled binary into each platform package before publishing.
## Conventions
- All logging to **stderr** via `@releases/lib/logger`. stdout is reserved for MCP JSON-RPC in `admin mcp serve` mode and for `--json` command output.
- Reader commands (top-level `search`, `latest`, `list`, `show`, `stats`, `categories`) are unauthenticated GETs — safe to run without credentials. `summary` and `compare` are intentionally not in this CLI; they require AI provider calls and live in the backend monorepo.
- Admin commands under `releases admin` are gated at CLI startup: missing `RELEASES_API_KEY` errors out before Commander dispatch.
- IDs over slugs everywhere. Every `<identifier>` arg accepts a typed ID (`org_…`, `src_…`, `prod_…`, `rel_…`), a bare slug, or — for sources and products — an `org/slug` coordinate (e.g. `vercel/vercel-ai-sdk`). `findSource(identifier)` / `findProduct(identifier)` in `src/api/client.ts` branch on shape: typed IDs hit the bare API path (still safe — IDs stay globally unique), `org/slug` is split locally and routed to `/v1/orgs/{org}/sources/{slug}`, bare slugs round-trip through `GET /v1/lookups/{source,product}-by-slug` to resolve a canonical home before fetching (#698). Bare slugs cost one extra round-trip per command; coordinate and typed-ID forms skip the resolver. Mutation helpers take a typed-ID-bearing entity object (`{ id }`) and POST/PATCH/DELETE against the bare path with the ID — see existing call sites in `cli/commands/{edit,product,release,remove}.ts`.
- `--json` supported on every reader command. Admin commands support it where it makes sense.
- The release readers (`get`, `search`, `tail`/`latest`) emit a **slim** JSON shape by default and accept `--full` for the complete payload — see `src/cli/render/release-json.ts` (`slimReleaseDetail` / `slimSearchHit` / `slimLatest`). The slim shape keeps `id`, `version`, `title`, `summary`, a markdown-stripped `excerpt`, `url`, `publishedAt`, nested `source`/`org`, and `contentChars`/`contentTokens`; it also keeps `media[]` (carrying the R2-mirrored `r2Url`) when the release has media and stamps `contentTruncated: true` when a full `content` body was projected to `excerpt` — both are signals callers explicitly want (verify R2 mirroring; know `--full` has more) and both are omitted when not applicable. It drops storage/pipeline internals (`contentHash`, `sourceId`, `versionSort`, `fetchedAt`, `embeddedAt`, `prerelease`, `composition`) and the redundant `title*` variants. Slim-by-default is deliberate — it's the agent token win. (`list` is the inverse: verbose default, opt-in `--compact`; its table carries a per-source `Releases` count column, `releaseCount` in `--json`.) `--full` only affects `--json`; passing it without `--json` warns and is ignored. `slimReleaseDetail`/`slimLatest`/`slimSearchHit` also carry the AI-scored `importance` (1–5) verbatim, normalized to `null` (never omitted, never coerced to `0`) when unscored — `null` means "unknown," not "unimportant."
- **`--fields <list>` projection mask.** On the readers (`get` across release/source/org/product, `search`, `tail`/`latest`), `--fields id,version,source.slug` post-filters the `--json` output to a comma-separated mask, dot-notation for nested keys. It's a **post-projection** over whatever shape the reader produced, so it composes with `--full` (mask the full payload) and reuses the slim vocabulary by default — it does not invent field names. Generic backend in `src/lib/fields.ts` (`projectFields` / `applyFieldMask`); dot-notation walks plain objects only (request an array-valued field like `media` whole). A field that resolves nowhere is dropped with one stderr warning (typos visible, stdout JSON intact); `--fields` without `--json` warns and is ignored (same as `--full`). `search` applies the mask to each entity array (`releases`/`catalog`/`collections`), preserving the wrapper metadata. To extend to another reader, wrap its `writeJson` object/array with `applyFieldMask(value, opts.fields)`.
- `--json` list responses return `{ items, pagination }` via the shared `ListResponse<T>` contract in `@buildinternet/releases-core/cli-contracts`. Pagination carries `{ page, pageSize, returned, hasMore }` plus `totalItems`/`totalPages` once the tail has been seen. When a default call returns a full page and more exists, the CLI also emits a stderr truncation warning so scripts don't silently miss rows. `metadata` fields are parsed into nested objects — don't call `JSON.parse` again. Use `parseMetadataField()` from the same module when adding new commands that surface metadata.
- **`--page-all` NDJSON streaming.** The page-based list readers (`list`, `org list`, `admin product list`) take `--page-all`: instead of returning one `{ items, pagination }` page, the CLI walks every page itself and streams the result as newline-delimited JSON — one item per line via `writeJsonLine` — so an agent consumes a whole result set in one command with no `--page`/`--limit` bookkeeping and no truncation warning to react to. It's `--json`-only (like `--full`/`--fields`): without `--json` it warns and falls through to the table; combined with `--page` it's rejected (they contradict); `--limit` still sets the per-request page size (a round-trip tuning knob). Generic backend in `src/lib/paginate.ts` (`streamAllPages(fetchPage, project?)`): `fetchPage(page)` returns `{ items, hasMore }`, iteration stops on `!hasMore` or an empty page (defensive), with a `MAX_PAGES` backstop. To extend to another page-based reader, gate on `opts.pageAll` before its single-page fetch and hand `streamAllPages` a closure that fetches page `p`.
- **Structured `--json` errors.** When the invocation requested `--json`, a thrown error is emitted as a parseable `{ error: { kind, message, status?, method?, path?, field? } }` payload on **stdout** (not an unstructured stderr dump), with a non-zero exit code. `kind` is `"api"` (carries `status`/`method`/`path`), `"invalid_input"` (carries `field`), or `"error"`. Lives in `src/lib/errors.ts` (`ApiError`, `InvalidInputError`, `toErrorPayload`); the top-level handler in `src/index.ts` renders it. Without `--json`, those two known error types print a clean one-line stderr message (no stack); unexpected errors still re-throw with a stack.
- **`--dry-run` + the uniform marker.** Every mutating command takes `--dry-run` (resolve + validate, print the planned write, exit without performing it). Each command keeps its own preview fields (`status: "would-add"`, `wouldUpdate: <slug>`, `wouldRemove`, `wouldPost`, …) — those shapes are intentionally not unified — but every `--json` dry-run payload also carries a uniform `dryRun: true` marker via `markDryRun()` (`src/lib/dry-run.ts`), so an agent can detect "this was a preview, not a write" without per-command knowledge. When adding a new mutation: add `--dry-run`, branch BEFORE any write (network-free where possible — resolution reads are fine), and wrap the `--json` preview with `markDryRun(...)` (map it over array previews). Commands with no `--json` (e.g. `keys revoke`, `policy ignore add`) still get a `[dry-run] Would …` stderr line.
- **Input hardening.** User-supplied identifiers are validated at the entity resolvers (`findOrg`/`findProduct`/`findSource`/`getRelease`) via `assertCleanIdentifier` (`src/lib/validate-input.ts`) before any network call — rejecting control characters, whitespace, `..` traversal, `%` percent-encoding, embedded `?`/`#`, and backslashes (the agent-hallucination patterns). `apiFetch` has a control-character backstop (`assertSafePath`) on the assembled path; file-reading flags (`--batch`/`--file`/`--content-file`) sandbox via `assertSafeReadPath` (rejects `..` traversal). The trust model is "the agent is not a trusted operator." When wiring a new command that takes a raw identifier on a path that _isn't_ already routed through these resolvers, call `assertCleanIdentifier` yourself.
- **Raw-payload `--input` (mutations).** `admin source create`/`update` accept a `--input <json>` body so an agent can send the request shape directly instead of reverse-mapping it onto a dozen bespoke flags. The body maps to the **CLI input shape** (not the raw API), so the existing dedup / org-resolution / metadata-packing / validation all still run: `create --input` is one `--batch` element (`name`/`url`/…/`metadataSet`), `update --input` maps to the update fields plus a convenience `metadata` object (each key set directly; a JSON `null` deletes it). The reader is `readJsonInputArg` (`src/lib/input.ts`) — literal JSON, `@<path>` (hardened via `readContentArg`), or `-` for stdin; parse/shape errors throw `CliError` so they serialize under `--json`. `--strict`/`--dry-run`/`--json` stay execution modifiers from the flags (the body never sets them); `--input` is mutually exclusive with `--batch`. When extending this to another mutation, reuse `readJsonInputArg` and keep the body mapping onto the CLI input shape.
- Table rendering goes through `renderTable()` in `src/cli/render/table.ts` — borderless, two-space delimited, headers uppercased + cyan. In TTY mode it fits to `process.stdout.columns` (or the `COLUMNS` env override) using the gh-style three-pass column-width allocator: short flex columns get their natural width, long ones split the remainder, and any leftover redistributes back. Per-column `noTruncate: true` locks a column to its full natural width (use for IDs, dates, fixed-format fields); `alignRight: true` right-aligns numeric counts. In non-TTY mode (piped) output drops to bare TSV — no headers, no colors, no truncation — so `cut`/`awk` work cleanly. Don't reach for `cli-table3`; it was removed and the renderer covers the same surface without the broken-grid failure mode at narrow widths. `renderTable` also supports `showHeader: false` and per-row `subRows` (TTY-only continuation lines indented under column 1) — both used by the shared release renderer below.
- Release rows (`search` + `tail`/`latest`) render through `renderReleaseRows()` in `src/cli/render/releases-table.ts`, built on `renderTable`. It's a single aligned grid — identity (package-qualified version, else source name) · description · relative age · dimmed `rel_…` — with no header. `feed` mode uses the `releaseDescription` fallback chain (summary → titleShort → titleGenerated → content excerpt → title); `search` mode puts the title in the description column and adds a cleaned, markdown-stripped excerpt as an aligned `subRow` (dropped when it just repeats the title). Non-TTY stays bare TSV (`id`-first, ISO dates, version column) for pipelines. Pure helpers (`relativeDate`, `cleanExcerpt`, `releaseIdentity`, `releaseDescription`) live in `src/lib/release-display.ts`. A TTY-only row with AI-scored `importance` ≥ 4 gets a quiet glyph prefix on the description (`◇` outline at 4, `◆` solid at 5) via `importanceMarker()` — mirrors the web's ≥4 render threshold, no emoji, and never fires for `importance <= 3` or unscored (`null`)/absent rows. The non-TTY TSV never carries the marker. `tail`/`latest` also takes `--min-importance <1-5>` (validated client-side against `IMPORTANCE_MIN`/`IMPORTANCE_MAX` from `@buildinternet/releases-core/importance` via `parseImportanceFlag`), forwarded as `?minImportance=` to `/v1/releases/latest` and the `--product` feed.
- `daysAgoIso()` from `@buildinternet/releases-core/dates` for cutoff math. Don't roll your own.
- Org overviews: `releases org get <identifier>` includes a short overview preview; `releases org overview <identifier>` is the unauthenticated public reader for the full body. Both accept the same identifier shapes as the rest of the CLI (typed `org_…` ID, slug, domain, name, or account handle). Both surfaces add a `⚠ older than 30 days` warning past `OVERVIEW_STALE_DAYS` (from `@buildinternet/releases-core/overview`).
## Telemetry
The CLI sends anonymous pings (command name, duration, exit code, CLI version, OS, arch) to `api.releases.sh/v1/telemetry`. No arguments, paths, slugs, or content are included. The code lives at `src/lib/telemetry.ts`. First run prints a one-line notice and persists a marker file at `~/.releases/telemetry-notice-shown`.
## Releasing
**Every PR with user-visible changes MUST ship a `.changeset/*.md` file.** Run `bun changeset` (interactive) or write the file directly in `.changeset/`. Bump level: `patch` for bug fixes, `minor` for additive features, `major` for breaking changes. The header needs only the single meta package — `"@buildinternet/releases": patch` — on its own line; the `fixed` group in `.changeset/config.json` automatically bumps the other seven packages to match, so do **not** list them. (Every changeset in git history follows this one-line form.)
**Never hand-edit a `version` field.** Not in the root `package.json`, not in `npm/*/package.json`, not in `packages/*/package.json`, and not in `src/cli/version.ts`. The release pipeline owns all of them — `changeset version` updates the package files, and `scripts/sync-version.ts` mirrors the result into `src/cli/version.ts`. The MCP server re-exports that constant (`src/mcp/server.ts` imports `VERSION` from `../cli/version.js`), so there's no separate string to sync.
Changesets versions eight `@buildinternet/releases*` packages together (fixed group):
- `@buildinternet/releases` — meta package
- `@buildinternet/releases-{darwin-arm64,darwin-x64,linux-arm64,linux-x64,windows-x64}` — platform binaries
- `@buildinternet/releases-{lib,skills}` — shared libraries
`@buildinternet/releases-core` is published independently from the monorepo and consumed here as a regular npm dependency — bump its pin in `package.json` when adopting a new schema. It is **not** part of the fixed group.
On merge to `main`, `.github/workflows/release.yml` opens or updates a `chore: version packages` PR. Merging that PR re-runs the workflow, publishes to npm, and cuts a GitHub release with the platform binaries attached.
## What's NOT in this repo
Anything that touches a database, AI provider, or crawl infrastructure stays in the backend monorepo ([buildinternet/releases](https://github.com/buildinternet/releases)):
- `src/db/`, `src/ai/`, `src/adapters/` — ingest engine and DB queries
- `workers/` — Cloudflare API, MCP, and discovery workers
- `web/` — the public catalog
- Managed agent config and deploy scripts
The OSS CLI is a pure HTTP client. If a feature requires local Anthropic/Cloudflare calls, it lives in the backend monorepo.