Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/cli-changelog.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@buildinternet/releases": minor
---

Add `releases changelog` to print recent product updates from releases.sh (the self-published `releases-sh` feed) and a link to https://releases.sh/updates. Also available as the local MCP `changelog` tool.
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ bun test # bun test (not part of check)
- **`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.
- **`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`, `changelog`) that proxy to `api.releases.sh`. Mirrors the canonical tool names served by the hosted server at `mcp.releases.sh`, plus `changelog` for recent releases.sh product updates (same as `releases changelog`: optional `limit` 1–50, default 5; JSON `{ url, feed, entries }`; GET-only, no writes). `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`.
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ releases list --category ai
releases get vercel # org, product, or source
releases org overview vercel # full AI-generated org overview
releases stats
releases changelog # recent product updates from releases.sh
releases submit https://acme.dev/changelog # suggest a source for the registry
releases feedback "great tool — here's an idea…" # message the maintainers
```
Expand Down
1 change: 1 addition & 0 deletions skills/releases-cli/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ releases lookup domain vercel.com # resolve a domain/URL to its registry en
releases collection list # browse curated cross-org playlists
releases collection releases frontier-ai-labs # the feed for one collection
releases stats # registry overview
releases changelog # recent product updates from releases.sh
releases categories # valid --category values (fixed taxonomy)
```

Expand Down
12 changes: 12 additions & 0 deletions skills/releases-cli/references/reader.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,18 @@ releases stats --days 7 # adjust the activity window
releases stats --json
```

## Product changelog

Recent updates to releases.sh itself — the same self-published feed the website renders at [releases.sh/updates](https://releases.sh/updates). Keyless; not the per-source `admin source changelog` wrapper.

```bash
releases changelog # latest 5 entries, then a link to /updates
releases changelog --limit 10
releases changelog --json
```

`--limit` is 1–50 (default 5). `--json` is `{ url, feed, entries }` where each entry has `id`, `kind` (`platform` | `cli`), `title`, `date`, `url`, `tags`, `summary`, and `body`.

## Categories

```bash
Expand Down
52 changes: 52 additions & 0 deletions src/cli/commands/product-changelog.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { Command } from "commander";
import { writeJson } from "../../lib/output.js";
import { InvalidInputError } from "../../lib/errors.js";
import {
DEFAULT_CHANGELOG_LIMIT,
MAX_CHANGELOG_LIMIT,
fetchProductChangelog,
formatChangelogHuman,
} from "../../lib/product-changelog.js";

function parseChangelogLimit(raw: string | undefined): number {
if (raw === undefined) return DEFAULT_CHANGELOG_LIMIT;
if (!/^-?\d+$/.test(raw)) {
throw new InvalidInputError("limit", `must be a positive integer (got ${raw})`);
}
const n = Number.parseInt(raw, 10);
if (n < 1) {
throw new InvalidInputError("limit", `must be a positive integer (got ${raw})`);
}
if (n > MAX_CHANGELOG_LIMIT) {
throw new InvalidInputError("limit", `must be ${MAX_CHANGELOG_LIMIT} or less (got ${n})`);
}
return n;
}

export function registerProductChangelogCommand(program: Command): void {
program
.command("changelog")
.description("Print recent product updates from releases.sh")
.option(
"--limit <n>",
`Number of entries to show (default: ${DEFAULT_CHANGELOG_LIMIT}, max: ${MAX_CHANGELOG_LIMIT})`,
)
.option("--json", "Output as JSON")
.addHelpText(
"after",
`
Examples:
releases changelog
releases changelog --limit 10
releases changelog --json`,
)
.action(async (opts: { limit?: string; json?: boolean }) => {
const limit = parseChangelogLimit(opts.limit);
const doc = await fetchProductChangelog({ limit });
if (opts.json) {
await writeJson(doc);
return;
}
process.stdout.write(formatChangelogHuman(doc));
});
}
4 changes: 4 additions & 0 deletions src/cli/program.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import { registerBlockCommand } from "./commands/block.js";
import { registerImportCommand } from "./commands/import.js";
import { registerTaskCommand } from "./commands/task.js";
import { registerChangelogCommand } from "./commands/changelog.js";
import { registerProductChangelogCommand } from "./commands/product-changelog.js";
import { registerGetCommand } from "./commands/get.js";
import { registerShowCommand } from "./commands/show.js";
import { registerEmbedCommand } from "./commands/admin/embed.js";
Expand Down Expand Up @@ -152,13 +153,15 @@ function printStyledHelp(): string {
` - releases tail : ${chalk.dim("Show the most recent releases (add -f to follow)")}`,
);
lines.push(` - releases list : ${chalk.dim("List and inspect sources")}`);
lines.push(` - releases changelog : ${chalk.dim("Recent product updates from releases.sh")}`);
lines.push("");

lines.push(chalk.cyan("Commands:"));
lines.push(row("search <query>", "Full-text search across releases"));
lines.push(row("tail [slug]", "Show the most recent releases (add -f to follow)"));
lines.push(row("list [slug]", "List sources or inspect one"));
lines.push(row("get <id|slug>", "Get any entity by ID or slug"));
lines.push(row("changelog", "Recent product updates from releases.sh"));
lines.push(row("stats", "Show database statistics"));
lines.push(row("categories", "List valid category values"));
lines.push(row("admin", "Operator workflows"));
Expand Down Expand Up @@ -251,6 +254,7 @@ program.addHelpText("after", () => {
registerSearchCommand(program);
registerLookupCommand(program);
registerTailCommand(program);
registerProductChangelogCommand(program);
registerStatsCommand(program);
registerListCommand(program, { alias: "sources" });
// Canonical verb: get. Deprecated alias: show (emits a warning).
Expand Down
113 changes: 113 additions & 0 deletions src/lib/product-changelog.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
/**
* Fetch and format the public releases.sh product changelog for
* `releases changelog` and the MCP `changelog` tool.
*
* The feed is the self-published `releases-sh` org in the registry — the
* same source the website renders at /updates. No extra website JSON twin
* is required; the CLI already speaks that API.
*/
import { getLatestReleases } from "../api/releases.js";
import type { LatestRelease } from "../api/types.js";
import { humanDate } from "./release-display.js";

export const CHANGELOG_ORG_SLUG = "releases-sh";
export const CHANGELOG_PAGE_URL = "https://releases.sh/updates";
export const CHANGELOG_FEED_URL = "https://releases.sh/releases-sh.atom";
export const DEFAULT_CHANGELOG_LIMIT = 5;
export const MAX_CHANGELOG_LIMIT = 50;

export type ChangelogKind = "platform" | "cli";

export type ChangelogEntry = {
id: string;
kind: ChangelogKind;
title: string;
date: string;
url: string;
tags: string[];
summary: string;
body: string;
};

export type ChangelogDocument = {
url: string;
feed: string;
entries: ChangelogEntry[];
};

export type FetchProductChangelogOptions = {
limit?: number;
fetchLatest?: (opts: { org: string; count: number }) => Promise<LatestRelease[]>;
};

/** Platform rollups live on `product-changelog`; everything else is a CLI/package cut. */
export function changelogKindFromSource(sourceSlug: string | null | undefined): ChangelogKind {
return sourceSlug === "product-changelog" ? "platform" : "cli";
}

export function releasePermalink(id: string): string {
return `https://releases.sh/release/${id}`;
}

export function toChangelogEntry(row: LatestRelease): ChangelogEntry {
const summary = row.summary?.trim() ?? "";
const sourceSlug = row.sourceSlug?.trim() || "";
return {
id: row.id,
kind: changelogKindFromSource(sourceSlug),
title: row.title,
date: row.publishedAt ?? "",
url: releasePermalink(row.id),
tags: sourceSlug ? [sourceSlug] : [],
summary,
body: summary,
};
}

export function clampChangelogLimit(limit: number | undefined): number {
const n = limit ?? DEFAULT_CHANGELOG_LIMIT;
if (n > MAX_CHANGELOG_LIMIT) return MAX_CHANGELOG_LIMIT;
if (n < 1) return DEFAULT_CHANGELOG_LIMIT;
return n;
}

function indent(text: string, prefix = " "): string {
return text
.split("\n")
.map((line) => (line.length === 0 ? prefix.trimEnd() : `${prefix}${line}`))
.join("\n");
}

/** Human terminal output: recent titles + summaries, then a link to /updates. */
export function formatChangelogHuman(doc: ChangelogDocument): string {
const lines: string[] = ["What's new", ""];
if (doc.entries.length === 0) {
lines.push("No changelog entries.");
} else {
for (const entry of doc.entries) {
lines.push(entry.title);
const when = humanDate(entry.date) || entry.date || "undated";
lines.push(`${when} · ${entry.url}`);
lines.push("");
if (entry.summary) {
lines.push(indent(entry.summary));
lines.push("");
}
}
}
lines.push(`See all updates: ${doc.url}`);
return `${lines.join("\n")}\n`;
}

export async function fetchProductChangelog(
opts: FetchProductChangelogOptions = {},
): Promise<ChangelogDocument> {
const limit = clampChangelogLimit(opts.limit);
const fetchLatest = opts.fetchLatest ?? getLatestReleases;
const releases = await fetchLatest({ org: CHANGELOG_ORG_SLUG, count: limit });
return {
url: CHANGELOG_PAGE_URL,
feed: CHANGELOG_FEED_URL,
entries: releases.slice(0, limit).map(toChangelogEntry),
};
}
29 changes: 29 additions & 0 deletions src/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,11 @@ import { logger } from "@releases/lib/logger";
import { recordEvent } from "../lib/telemetry.js";
import { describeAmbiguousSource } from "../cli/suggest.js";
import { VERSION } from "../cli/version.js";
import {
DEFAULT_CHANGELOG_LIMIT,
MAX_CHANGELOG_LIMIT,
fetchProductChangelog,
} from "../lib/product-changelog.js";

function textResult(text: string) {
return { content: [{ type: "text" as const, text }] };
Expand Down Expand Up @@ -586,6 +591,30 @@ server.registerTool(
},
);

// ── changelog ────────────────────────────────────────────────────────
server.registerTool(
"changelog",
{
description:
"Read recent releases.sh product updates (platform rollups and CLI cuts). Returns the latest entries with titles, dates, summaries, and a link to the full changelog at https://releases.sh/updates. Same as `releases changelog`. Use this to discover new features before recommending releases.sh workflows.",
inputSchema: {
limit: z
.number()
.int()
.min(1)
.max(MAX_CHANGELOG_LIMIT)
.optional()
.describe(
`How many entries to return (default ${DEFAULT_CHANGELOG_LIMIT}, max ${MAX_CHANGELOG_LIMIT}).`,
),
},
},
async ({ limit }) => {
const doc = await fetchProductChangelog({ limit });
return textResult(JSON.stringify(doc, null, 2));
},
);

// ── Start function ───────────────────────────────────────────────────
export async function startMcpServer() {
const transport = new StdioServerTransport();
Expand Down
7 changes: 7 additions & 0 deletions tests/cli/agent-ergonomics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,13 @@ describe("Examples in --help output (#2)", () => {
expect(stdout).toContain("--kind sdk");
});

it("releases changelog --help contains an Examples block", () => {
const { stdout, exitCode } = runCli(["changelog", "--help"]);
expect(exitCode).toBe(0);
expect(stdout).toContain("Examples:");
expect(stdout).toContain("releases changelog --json");
});

it("releases search --help contains an Examples block", () => {
const { stdout, exitCode } = runCli(["search", "--help"]);
expect(exitCode).toBe(0);
Expand Down
1 change: 1 addition & 0 deletions tests/cli/help.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ describe("CLI command gating (public mode)", () => {
expect(stdout).toContain("list");
expect(stdout).toContain("stats");
expect(stdout).toContain("categories");
expect(stdout).toContain("changelog");
expect(stdout).toContain("admin");
// `summary` and `compare` are local-only AI tools — not shipped in OSS.
});
Expand Down
52 changes: 52 additions & 0 deletions tests/cli/product-changelog.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { describe, expect, it } from "bun:test";
import { runCli } from "../utils.js";

const publicEnv = {
RELEASED_API_URL: "",
RELEASED_API_KEY: "",
RELEASES_API_URL: "",
RELEASES_API_KEY: "",
};

describe("releases changelog", () => {
it("is a public command in --help", () => {
const { stdout, exitCode } = runCli(["--help"], { env: publicEnv });
expect(exitCode).toBe(0);
expect(stdout).toContain("changelog");
});

it("prints help with examples", () => {
const { stdout, exitCode } = runCli(["changelog", "--help"], { env: publicEnv });
expect(exitCode).toBe(0);
expect(stdout).toContain("releases changelog");
expect(stdout).toContain("Examples:");
expect(stdout).toContain("releases changelog --json");
expect(stdout).toContain("--limit");
});

it("rejects a non-positive --limit without hitting the API", () => {
const { stderr, stdout, exitCode } = runCli(["changelog", "--limit", "0"], { env: publicEnv });
expect(exitCode).not.toBe(0);
expect(stderr).toContain("Invalid limit");
expect(stdout).not.toContain("What's new");
});

it("rejects a limit above the cap", () => {
const { stderr, exitCode } = runCli(["changelog", "--limit", "999"], { env: publicEnv });
expect(exitCode).not.toBe(0);
expect(stderr).toContain("50 or less");
});

it("emits a structured JSON error for a bad --limit", () => {
const { stdout, exitCode } = runCli(["changelog", "--json", "--limit", "999"], {
env: publicEnv,
});
expect(exitCode).not.toBe(0);
const parsed = JSON.parse(stdout) as {
error: { kind: string; field?: string; message: string };
};
expect(parsed.error.kind).toBe("invalid_input");
expect(parsed.error.field).toBe("limit");
expect(parsed.error.message).toContain("50 or less");
});
});
Loading
Loading