diff --git a/README.md b/README.md index b9ef7f9c..8ed7e91f 100644 --- a/README.md +++ b/README.md @@ -109,7 +109,7 @@ GitHits also exposes indexed package/source tools: | `pkg_info` | Quick package triage: version, license, repository popularity, downloads, vulnerabilities | | `pkg_vulns` | CVE / OSV advisories for a package or specific version, with filter echo and compact/verbose modes | | `pkg_deps` | Direct dependencies, dependency groups, and optional transitive graph | -| `pkg_changelog` | Release notes / changelog entries for a package or GitHub repo | +| `pkg_changelog` | Release notes / changelog entries for a package or GitHub repo, with compact timeline and body-preview controls | | `code_files` | Discover what files a dependency or repo contains | | `code_read` | Read a dependency file by path | | `code_grep` | Deterministic text grep across indexed dependency or repo files | diff --git a/docs/implementation/cli-commands.md b/docs/implementation/cli-commands.md index 31318df7..ff855193 100644 --- a/docs/implementation/cli-commands.md +++ b/docs/implementation/cli-commands.md @@ -225,7 +225,7 @@ Fetches release notes or changelog entries for a package or GitHub repository. O **Pre-release versions.** Normalised versions flow through unchanged (`5.0.0-rc.1`, `2.32.0.dev0`, `1.7.0-rc.5` round-trip cleanly on `--from` / `--to`). Tag-style `v`-prefixed inputs are rejected on any version flag, consistent with `pkg vulns` / `pkg deps`. -**Default terminal output.** Summary header (`name · registry · source · mode · entry count`) followed by each entry's `version date url` header plus the first 10 lines of its markdown body, indented and dimmed. Bodies longer than the cap show a footer `… (+N more lines — use --verbose for the full body)`. Missing dates render as `—`; missing versions render as `(unversioned)`. The version column is padded to the longest entry in the current response (no fixed width). +**Default terminal output.** Summary header (`name | registry | source | mode | entry count`) followed by each entry's `version date url` header plus the first 10 lines of its markdown body, indented and dimmed. Bodies longer than the cap show a footer `... (+N more lines - use --verbose for the full body)`. Missing dates render as `-`; missing versions render as `(unversioned)`. The version column is padded to the longest entry in the current response (no fixed width). **`--verbose`.** Uncaps the body preview — every entry's full markdown body renders, indented and dimmed, with no truncation footer. Terminal-only — does not change `--json` output. diff --git a/docs/implementation/tools.md b/docs/implementation/tools.md index 90aa90aa..1440a033 100644 --- a/docs/implementation/tools.md +++ b/docs/implementation/tools.md @@ -27,7 +27,7 @@ The CLI mirrors the production MCP tool contract where equivalent tools exist. C | `pkg_info` | `registry`, `package_name`, `verbose?`, `format?` | Latest-version package triage: license, description, repository popularity (stars/forks/issues and `[ARCHIVED]` when applicable), downloads, publish age, and vulnerability status. Example: `{registry:"npm", package_name:"express"}`. Set `verbose: true` for GitHub language/topics/last-pushed, recent advisories, and recent changes. Pass `format: "json"` for structured fields. | | `pkg_vulns` | `registry`, `package_name`, `version?`, `min_severity?`, `include_withdrawn?`, `verbose?`, `format?` | Known vulnerabilities for a package on npm, PyPI, Hex, or Crates. Example: `{registry:"npm", package_name:"lodash", version:"4.17.20", min_severity:"high"}`. Defaults to compact text capped at 5 advisory rows with active filter echo; set `verbose: true` to show all text rows or pass `format: "json"` for the complete per-advisory envelope. | | `pkg_deps` | `registry`, `package_name`, `version?`, `lifecycle?`, `include_transitive?`, `include_importers?`, `max_depth?`, `format?` | Direct runtime dependency list by default with resolved versions. Non-runtime groups are hidden with an MCP-native hint (`pass lifecycle="all"`). Use `lifecycle: "runtime"` for explicit runtime-only, a concrete non-runtime lifecycle for runtime plus matching groups, or `lifecycle: "all"` for all available groups. Optional transitive output includes aggregate edge counts, the preprocessed install footprint, typed conflicts and circular-dependency cycles; opt into importer provenance with `include_importers`. Pass `format: "json"` for the lean structured envelope. | -| `pkg_changelog` | `registry?`, `package_name?`, `repo_url?`, `from_version?`, `to_version?`, `limit?`, `git_ref?`, `include_bodies?`, `format?` | Release notes or changelog entries for a package or GitHub repo. Defaults to compact text with newest-first entries and capped body previews; pass `format: "json"` for the structured envelope with full markdown bodies, or set `include_bodies: false` for a lean version / date / URL timeline. `from_version` switches to range mode (no count cap). Dual addressing (spec vs repo URL) is mutually exclusive. | +| `pkg_changelog` | `registry?`, `package_name?`, `repo_url?`, `from_version?`, `to_version?`, `limit?`, `git_ref?`, `include_bodies?`, `verbose?`, `body_lines?`, `format?` | Release notes or changelog entries for a package or GitHub repo. Example: `{registry:"npm", package_name:"express", limit:2}`. Defaults to compact text with newest-first entries and 10-line body previews; set `body_lines` to tune text previews, `verbose:true` for full text bodies, `include_bodies:false` for a lean timeline, or `format:"json"` for the complete envelope. `from_version` switches to range mode (no count cap). Dual addressing (spec vs repo URL) is mutually exclusive. | | `code_files` | `target`, `path?`, `path_prefix?`, `globs?`, `extensions?`, `file_types?`, `languages?`, `file_intent?`, `file_intents?`, `exclude_file_intents?`, `exclude_doc_files?`, `exclude_test_files?`, `include_hidden?`, `limit?`, `wait_timeout_ms?`, `format?` | List files in an indexed dependency. Returns `{total, hasMore, files: [{path, name, language, fileType, byteSize}], resolution, indexedVersion}` in JSON mode. Dual addressing via `target.registry + target.package_name` (spec) or `target.repo_url + target.git_ref` (repo). Selectors (`path`, `path_prefix`, `globs`) are OR-ed; the other filters intersect on top. Emits an `INDEXING` error envelope when the dependency is being indexed on-demand — retry with a longer `wait_timeout_ms` or pick a version from `details.availableVersions`. `format` defaults to `text-v1` (paths-only listing); pass `format: "json"` for the structured envelope. | | `code_read` | `target`, `path`, `start_line?`, `end_line?`, `wait_timeout_ms?`, `format?` | Read a file from an indexed dependency. `target` accepts the structured object or compact string (`npm:react@18.2.0`, `https://github.com/facebook/react#HEAD`). **MCP per-call span cap: 150 lines** — broader requests (or no range) are silently truncated to the first 150 lines from the caller's start, with a hint explaining the cap and the original request. Defaults to `text-v1` with line-numbered content; pass `format: "json"` for the structured envelope. Use `start_line` / `end_line` to pick a focused window — typical 80-150 lines around a known symbol from `search` / `code_grep`. Binary files set `isBinary: true` and omit `content`. On `NOT_FOUND` / `FILE_NOT_FOUND` call `code_files` to discover the actual path. The cap is MCP-only; the CLI command `githits code read` honors arbitrary ranges. | | `code_grep` | `target`, `pattern`, `path?`, `path_prefix?`, `globs?`, `extensions?`, `pattern_type?`, `case_sensitive?`, `exclude_doc_files?`, `exclude_test_files?`, `context_lines?`, `context_lines_before?`, `context_lines_after?`, `max_matches?`, `max_matches_per_file?`, `cursor?`, `symbol_fields?`, `wait_timeout_ms?`, `format?` | Deterministic text grep over indexed dependency or repository source. Defaults to literal, ASCII case-insensitive matching across the whole target; non-ASCII letters match case-sensitively. Narrow with `path`, `path_prefix`, `globs`, or `extensions`. `pattern_type: "regex"` uses RE2 syntax; whole-target regexes must include at least one literal substring for index pre-filtering. Returns matches plus pagination and scan counters; `symbol_fields` hydrates enclosing symbol metadata on each match. `format` defaults to `text-v1` (matches grouped by file, grep -A/-B notation for context); pass `format: "json"` for the structured envelope. | @@ -102,7 +102,7 @@ The CLI mirrors the production MCP tool contract where equivalent tools exist. C **Mode selection.** `from_version` triggers range mode (returns every entry in `[fromVersion, toVersion]` with no cap). Latest mode is the default, capped by `limit` (1–50, backend default 10). `from_version` + `limit` is rejected client-side with `INVALID_ARGUMENT` rather than silently routed to one mode. -**`include_bodies` lever and body previews.** Release bodies on large packages (Kubernetes, Node) can run 10 KB+ per entry; a 100-entry range could produce a multi-hundred-KB envelope. `include_bodies: false` opts out explicitly in JSON and text — not silent truncation. Other fields (version / normalizedVersion / publishedAt / htmlUrl) remain so agents still get the release timeline. MCP text mode caps each body preview at 10 lines and tells agents to pass `format="json"` for full bodies. CLI terminal output uses the same preview cap but gives the CLI-native `--verbose` hint; `--verbose` uncaps terminal previews but does not change `--json` output. +**`include_bodies` lever and body previews.** Release bodies on large packages (Kubernetes, Node) can run 10 KB+ per entry; a 100-entry range could produce a multi-hundred-KB envelope. `include_bodies: false` opts out explicitly in JSON and text — not silent truncation. Other fields (version / normalizedVersion / publishedAt / htmlUrl) remain so agents still get the release timeline. Text mode caps each body preview at 10 lines by default. MCP adds text-only `body_lines` (1-50) to tune the cap and `verbose:true` to uncap text bodies; both are ignored for JSON. `verbose:true` conflicts with `include_bodies:false` and `body_lines`. CLI terminal output uses the same default preview cap and gives the CLI-native `--verbose` hint; `--verbose` uncaps terminal previews but does not change `--json` output. **`filter.*` echo.** `filter` is emitted only when the caller explicitly supplied at least one of `from_version`, `to_version`, `limit`, or `git_ref`. Backend-default `limit: 10` / `toVersion: ` is never echoed. The request builder tracks explicit-vs-defaulted via an `explicitFilterFields` set so defaults don't round-trip as caller intent. @@ -112,7 +112,7 @@ The CLI mirrors the production MCP tool contract where equivalent tools exist. C **Overlap with `pkg_info`.** `pkg_info` already surfaces a short-form `recentChanges` block (from the backend's `latestChangelogs` field on `PackageSummaryResult`). For a quick "what shipped recently" glance embedded in a package overview, use `pkg_info`. For the full range-capable, body-rich, `include_bodies`-toggleable changelog with `--no-body` timeline mode and repo-URL addressing, use `pkg_changelog`. -`pkg_changelog` shares its envelope builder and text formatter with the CLI `githits pkg changelog` command via `src/shared/package-changelog-request.ts` and `src/shared/package-changelog-response.ts`. MCP defaults to compact text with MCP-native `format="json"` hints for full bodies. The parity test (`src/tools/package-changelog-parity.test.ts`) passes `format: "json"`, asserts `toEqual` across every service-sourced success / error fixture (happy latest, range mode, repo-URL addressing, no-source package-version entries, `--no-body` / `include_bodies: false`, default bodies, empty entries, NOT_FOUND, PackageIntelligenceTargetNotFoundError, VERSION_NOT_FOUND, BACKEND_ERROR), and uses `toMatchObject` for builder-sourced `INVALID_ARGUMENT`. +`pkg_changelog` shares its envelope builder and text formatter with the CLI `githits pkg changelog` command via `src/shared/package-changelog-request.ts` and `src/shared/package-changelog-response.ts`. MCP defaults to compact text with MCP-native `verbose=true`, `body_lines=`, and `format="json"` hints for full bodies. The parity test (`src/tools/package-changelog-parity.test.ts`) passes `format: "json"`, asserts `toEqual` across every service-sourced success / error fixture (happy latest, range mode, repo-URL addressing, no-source package-version entries, `--no-body` / `include_bodies: false`, default bodies, empty entries, NOT_FOUND, PackageIntelligenceTargetNotFoundError, VERSION_NOT_FOUND, BACKEND_ERROR), and uses `toMatchObject` for builder-sourced `INVALID_ARGUMENT`. ### `code_files` / `code_read` / `code_grep` response shapes @@ -163,7 +163,7 @@ The `hint` field is emitted only when the cap *actually truncated* the response **Example-search anatomy.** `get_example` text mode returns markdown directly, followed by `solution_id: ` when the REST response includes an app URL. This avoids JSON-wrapped markdown while preserving the `feedback` workflow. `search_language` text mode returns one match per line as `name (Display Name) aliases: a, b`; agents should pass the `name` value to `get_example.language`. -**Package metadata anatomy.** `pkg_info`, `pkg_vulns`, `pkg_deps`, and `pkg_changelog` text mode reuse the shared no-color terminal formatters but inject MCP-native hints. `pkg_deps` hides non-runtime groups by default and says `pass lifecycle="all"` when groups exist. `pkg_changelog` caps body previews and says `pass format="json" for full bodies` when text omitted lines. Package tools keep JSON errors in all formats because agents can reliably branch on `{error, code, retryable, details?}`. +**Package metadata anatomy.** `pkg_info`, `pkg_vulns`, `pkg_deps`, and `pkg_changelog` text mode reuse the shared no-color terminal formatters but inject MCP-native hints. `pkg_deps` hides non-runtime groups by default and says `pass lifecycle="all"` when groups exist. `pkg_changelog` caps body previews and says `pass verbose=true`, `body_lines=`, or `format="json"` when text omitted lines. Package tools keep JSON errors in all formats because agents can reliably branch on `{error, code, retryable, details?}`. **Hit anatomy** (`search` text-v1): diff --git a/eval/agentic/README.md b/eval/agentic/README.md index 36cbdcce..dfcac004 100644 --- a/eval/agentic/README.md +++ b/eval/agentic/README.md @@ -97,7 +97,7 @@ least one agent for quick iteration. | Unified `search` / `search_status` behavior | `unified-search-investigation.md` | | Package overview or vulnerability UX, `pkg_info`, `pkg_vulns` | `package-overview-vulnerabilities.md` | | Dependency graph UX, `pkg_deps` | `package-dependencies.md` | -| Release notes UX, `pkg_changelog` | `package-changelog.md` | +| Release notes UX, `pkg_changelog` | `package-changelog.md`; use `package-changelog-range.md` for range/body-preview behavior | | Documentation browsing, `docs_list`, `docs_read` | `docs-discovery.md` | | File listing / file read UX, `code_files`, `code_read` | `code-file-navigation.md` | | Deterministic source search UX, `code_grep` | `code-grep-investigation.md` | diff --git a/eval/agentic/workloads/package-changelog-range.md b/eval/agentic/workloads/package-changelog-range.md new file mode 100644 index 00000000..ab06a2c2 --- /dev/null +++ b/eval/agentic/workloads/package-changelog-range.md @@ -0,0 +1,6 @@ +# Workload: Package Changelog Range Summary + +You are evaluating Express upgrade risk from `4.17.0` through `4.18.2` for a +Node.js service. Summarize the notable behavioral, dependency, and security +changes in that version range. Prefer concise evidence over copying raw release +notes, and call out any caveats about the changelog source. diff --git a/scripts/cli-smoke.ts b/scripts/cli-smoke.ts index f3d6d06e..2310f651 100644 --- a/scripts/cli-smoke.ts +++ b/scripts/cli-smoke.ts @@ -512,6 +512,10 @@ async function runLiveSmoke(): Promise { changelogText.includes("express") || changelogText.includes("changelog"), "pkg changelog terminal missing context", ); + assert( + !/[·…—–→]/.test(changelogText), + "pkg changelog terminal contains non-ASCII punctuation", + ); const changelogJson = assertJsonOutput( await runCli(["pkg", "changelog", "npm:express", "--limit", "1", "--json"]), diff --git a/scripts/mcp-smoke.ts b/scripts/mcp-smoke.ts index 9454692a..6867db84 100644 --- a/scripts/mcp-smoke.ts +++ b/scripts/mcp-smoke.ts @@ -429,11 +429,33 @@ async function runLiveSmoke(client: Client): Promise { changelogText.includes("full bodies") ) { assert( - changelogText.includes('pass format="json" for full bodies'), + changelogText.includes( + 'pass verbose=true, body_lines=, or format="json"', + ), "pkg_changelog truncation hint is not MCP-native", ); } + const changelogBodyLinesText = assertDefaultText( + await callTool(client, "pkg_changelog", { + registry: "npm", + package_name: "express", + limit: 2, + body_lines: 3, + }), + "pkg_changelog body_lines", + ); + assert( + changelogBodyLinesText.includes( + 'pass verbose=true, body_lines=, or format="json"', + ), + "pkg_changelog body_lines missing MCP-native truncation hint", + ); + assert( + !changelogBodyLinesText.includes("--verbose"), + "pkg_changelog body_lines leaked CLI verbose flag", + ); + const changelogJson = assertJsonResult( await callTool(client, "pkg_changelog", { registry: "npm", @@ -446,6 +468,20 @@ async function runLiveSmoke(client: Client): Promise { assertRecord(changelogJson, "pkg_changelog json"); assertRecord(changelogJson.entries, "pkg_changelog json entries"); + const changelogTimeline = assertDefaultText( + await callTool(client, "pkg_changelog", { + registry: "npm", + package_name: "express", + limit: 2, + include_bodies: false, + }), + "pkg_changelog timeline", + ); + assert( + !changelogTimeline.includes("What's Changed"), + "pkg_changelog include_bodies=false still emitted bodies", + ); + const docsText = assertDefaultText( await callTool(client, "docs_list", { registry: "npm", diff --git a/src/commands/mcp-instructions.ts b/src/commands/mcp-instructions.ts index e089d5ce..108231e1 100644 --- a/src/commands/mcp-instructions.ts +++ b/src/commands/mcp-instructions.ts @@ -60,7 +60,7 @@ const PKG_DEPS_BULLET = '- `pkg_deps` — compact direct runtime deps by default. Use `lifecycle: "runtime"` for explicit runtime-only, a concrete lifecycle for runtime plus matching non-runtime deps, or `lifecycle: "all"` for all groups. Use `include_transitive` for the full graph and `include_importers` for provenance. Pass `format: "json"` for the structured envelope.'; const PKG_CHANGELOG_BULLET = - '- `pkg_changelog` — compact release notes for a package or GitHub repo, newest-first. Default latest mode returns recent entries with markdown body previews; `from_version` switches to range mode. Set `include_bodies: false` for a compact timeline or pass `format: "json"` for full bodies.'; + '- `pkg_changelog` — compact release notes for a package or GitHub repo, newest-first (e.g. `npm` + `express` + `limit: 2`). Default latest mode returns recent entries with 10-line markdown previews; `from_version` switches to range mode. Set `body_lines` to tune text previews, `verbose: true` for full text bodies, `include_bodies: false` for a compact timeline, or `format: "json"` for the complete envelope.'; /** * Combined strategy tip. Replaces the earlier diff --git a/src/commands/pkg/changelog.test.ts b/src/commands/pkg/changelog.test.ts index 1cc4f1d8..87d1ae99 100644 --- a/src/commands/pkg/changelog.test.ts +++ b/src/commands/pkg/changelog.test.ts @@ -42,13 +42,13 @@ describe("pkgChangelogAction", () => { await pkgChangelogAction("npm:express", {}, createDeps()); const combined = writes.join(""); - expect(combined).toContain("express · npm"); + expect(combined).toContain("express | npm"); expect(combined).toContain("source: GitHub Releases"); expect(combined).toContain("2 entries"); expect(combined).toContain("5.2.1"); // Default now shows bodies (capped at 10 lines). Fixture bodies // are well under the cap, so full content appears without the - // "… more lines" footer. + // "... more lines" footer. expect(combined).toContain("## Patch"); expect(combined).toContain("- fixed a thing"); expect(combined).not.toContain("use --verbose"); @@ -99,7 +99,9 @@ describe("pkgChangelogAction", () => { expect(combined).toContain("line 10"); expect(combined).not.toContain("line 11"); expect(combined).toContain("+15 more lines"); - expect(combined).toContain("use --verbose for the full body"); + expect(combined).toContain( + "... (+15 more lines - use --verbose for the full body)", + ); writeSpy.mockRestore(); }); diff --git a/src/commands/pkg/changelog.ts b/src/commands/pkg/changelog.ts index 38768b36..334c6b2a 100644 --- a/src/commands/pkg/changelog.ts +++ b/src/commands/pkg/changelog.ts @@ -176,7 +176,7 @@ function formatChangelogTerminalError(mapped: MappedError): string { if (available && available.length > 0) { const sample = available.slice(0, 5).join(", "); const more = available.length - 5; - const suffix = more > 0 ? `, … (+${more} more)` : ""; + const suffix = more > 0 ? `, ... (+${more} more)` : ""; lines.push(` available: ${sample}${suffix}`); } return lines.join("\n"); diff --git a/src/shared/package-changelog-response.test.ts b/src/shared/package-changelog-response.test.ts index 9ed5528c..7214eece 100644 --- a/src/shared/package-changelog-response.test.ts +++ b/src/shared/package-changelog-response.test.ts @@ -244,7 +244,7 @@ describe("formatPackageChangelogTerminal", () => { verbose: false, useColors: false, }); - expect(output).toContain("express · npm"); + expect(output).toContain("express | npm"); expect(output).toContain("source: GitHub Releases"); expect(output).toContain("2 entries"); expect(output).toContain("5.2.1"); @@ -292,6 +292,36 @@ describe("formatPackageChangelogTerminal", () => { expect(output).not.toContain("line 11"); expect(output).toContain("+15 more lines"); expect(output).toContain("use --verbose for the full body"); + expect(output).toContain( + "... (+15 more lines - use --verbose for the full body)", + ); + }); + + it("uses caller-supplied body preview line cap", () => { + const longBody = Array.from({ length: 8 }, (_, i) => `line ${i + 1}`).join( + "\n", + ); + const report: ChangelogReport = { + ...baseReport, + entries: [ + { + version: "1.0.0", + publishedAt: "2026-01-01T00:00:00Z", + htmlUrl: "https://example.com", + body: longBody, + }, + ], + }; + const envelope = buildPackageChangelogSuccessPayload(report, baseOptions); + const output = formatPackageChangelogTerminal(envelope, { + verbose: false, + useColors: false, + bodyPreviewLines: 3, + fullBodyHint: "pass verbose=true", + }); + expect(output).toContain("line 3"); + expect(output).not.toContain("line 4"); + expect(output).toContain("... (+5 more lines - pass verbose=true)"); }); it("lifts the body cap under --verbose and omits the truncation footer", () => { @@ -381,7 +411,7 @@ describe("formatPackageChangelogTerminal", () => { verbose: false, useColors: false, }); - expect(output).toContain("—"); + expect(output).toContain("-"); }); it("renders 'No entries in this range.' on zero-entry success", () => { @@ -394,7 +424,7 @@ describe("formatPackageChangelogTerminal", () => { expect(output).toContain("No entries in this range."); }); - it("labels range mode with from → to in the summary", () => { + it("labels range mode with from -> to in the summary", () => { const envelope = buildPackageChangelogSuccessPayload(baseReport, { ...baseOptions, mode: "range", @@ -406,7 +436,36 @@ describe("formatPackageChangelogTerminal", () => { verbose: false, useColors: false, }); - expect(output).toContain("range 4.0.0 → 5.0.0"); + expect(output).toContain("range 4.0.0 -> 5.0.0"); + }); + + it("renders printable ASCII punctuation", () => { + const longBody = Array.from({ length: 12 }, (_, i) => `line ${i + 1}`).join( + "\n", + ); + const report: ChangelogReport = { + ...baseReport, + entries: [ + { + version: "1.0.0", + publishedAt: undefined, + htmlUrl: "https://example.com", + body: longBody, + }, + ], + }; + const envelope = buildPackageChangelogSuccessPayload(report, { + ...baseOptions, + mode: "range", + fromVersion: "1.0.0", + toVersion: "2.0.0", + explicitFilterFields: new Set(["fromVersion", "toVersion"]), + }); + const output = formatPackageChangelogTerminal(envelope, { + verbose: false, + useColors: false, + }); + expect(output).not.toMatch(/[·…—–→]/); }); it("labels latest mode with 'latest up to X' when toVersion is set", () => { diff --git a/src/shared/package-changelog-response.ts b/src/shared/package-changelog-response.ts index 1c6be48b..31567173 100644 --- a/src/shared/package-changelog-response.ts +++ b/src/shared/package-changelog-response.ts @@ -1,8 +1,6 @@ /** * Hand-crafted response envelope for the `package_changelog` tool. - * Shared by CLI `--json` output and MCP `content[0].text`. Terminal - * formatter is CLI-only but reads from the same envelope shape so - * the two surfaces cannot drift. + * Shared by CLI `--json`, CLI terminal output, and MCP `content[0].text`. * * Key design commitments: * @@ -42,7 +40,7 @@ */ import type { ChangelogReport } from "../services/index.js"; -import { colorize, dim } from "./colors.js"; +import { colorize, dim, highlight } from "./colors.js"; import type { ExplicitFilterField } from "./package-changelog-request.js"; /** Two backend-documented modes, kept lowercase. */ @@ -183,13 +181,14 @@ function buildFilterBlock( } // -------------------------------------------------------------------- -// Terminal formatter (CLI-only). +// Shared terminal/text formatter used by both CLI and MCP. // -------------------------------------------------------------------- export interface FormatChangelogTerminalOptions { verbose?: boolean; useColors: boolean; fullBodyHint?: string; + bodyPreviewLines?: number; } /** @@ -215,7 +214,7 @@ const DEFAULT_BODY_PREVIEW_LINES = 10; * * Edge cases: * - Empty entries: summary header + "No entries in this range.". - * - Missing `publishedAt`: `—` in the date column. + * - Missing `publishedAt`: `-` in the date column. * - Missing `version`: `(unversioned)`; backend newest-first order * preserved (we don't re-sort). * - Empty-string body: rendered with a neutral `(empty release @@ -243,14 +242,14 @@ export function formatPackageChangelogTerminal( const version = entry.version ?? "(unversioned)"; const date = entry.publishedAt ? formatDate(entry.publishedAt) - : dim("—", options.useColors); + : dim("-", options.useColors); const url = entry.htmlUrl ? dim(entry.htmlUrl, options.useColors) : dim("(no link)", options.useColors); const padded = padColumn(version, versionWidth); const datePadded = padColumn(date, dateWidth); lines.push( - `${colorize(padded, "bold", options.useColors)} ${datePadded} ${url}`, + `${highlight(`${padded} ${datePadded}`, options.useColors)} ${url}`, ); if (entry.body != null) { appendBodyLines(lines, entry.body, options); @@ -272,15 +271,17 @@ function appendBodyLines( return; } const bodyLines = body.split("\n"); - const cap = options.verbose ? bodyLines.length : DEFAULT_BODY_PREVIEW_LINES; + const cap = options.verbose + ? bodyLines.length + : (options.bodyPreviewLines ?? DEFAULT_BODY_PREVIEW_LINES); const visible = bodyLines.slice(0, cap); for (const bodyLine of visible) { - lines.push(` ${dim(bodyLine, options.useColors)}`); + lines.push(` ${bodyLine}`); } const hidden = bodyLines.length - visible.length; if (hidden > 0) { lines.push( - ` ${dim(`… (+${hidden} more line${hidden === 1 ? "" : "s"} — ${options.fullBodyHint ?? "use --verbose for the full body"})`, options.useColors)}`, + ` ${dim(`... (+${hidden} more line${hidden === 1 ? "" : "s"} - ${options.fullBodyHint ?? "use --verbose for the full body"})`, options.useColors)}`, ); } } @@ -291,7 +292,7 @@ function buildSummaryLine( ): string { const identity = envelope.registry && envelope.name - ? `${envelope.name} · ${envelope.registry}` + ? `${envelope.name} | ${envelope.registry}` : (envelope.repoUrl ?? "(unknown)"); const sourceLabel = envelope.source ? humanizeSource(envelope.source) @@ -300,13 +301,13 @@ function buildSummaryLine( envelope.mode === "range" ? rangeLabel(envelope) : latestLabel(envelope); const countLabel = `${envelope.entries.count} ${plural("entry", "entries", envelope.entries.count)}`; const parts = [identity, `source: ${sourceLabel}`, modeLabel, countLabel]; - return colorize(parts.join(" · "), "bold", options.useColors); + return colorize(parts.join(" | "), "bold", options.useColors); } function rangeLabel(envelope: LeanChangelogEnvelope): string { const from = envelope.filter?.fromVersion ?? "earliest"; const to = envelope.filter?.toVersion ?? "latest"; - return `range ${from} → ${to}`; + return `range ${from} -> ${to}`; } function latestLabel(envelope: LeanChangelogEnvelope): string { diff --git a/src/tools/package-changelog.test.ts b/src/tools/package-changelog.test.ts index bf80cd29..ba6112d9 100644 --- a/src/tools/package-changelog.test.ts +++ b/src/tools/package-changelog.test.ts @@ -22,8 +22,10 @@ describe("createPackageChangelogTool — metadata", () => { expect(tool.description).toContain("latest mode"); expect(tool.description).toContain("range mode"); expect(tool.description).toContain("markdown body previews"); + expect(tool.description).toContain("body_lines"); expect(tool.description).toContain('format: "json"'); expect(Object.keys(tool.schema).sort()).toEqual([ + "body_lines", "format", "from_version", "git_ref", @@ -33,6 +35,7 @@ describe("createPackageChangelogTool — metadata", () => { "registry", "repo_url", "to_version", + "verbose", ]); expect(tool.annotations?.readOnlyHint).toBe(true); }); @@ -66,7 +69,7 @@ describe("createPackageChangelogTool — happy path", () => { ); expect(result.isError).toBeUndefined(); const text = result.content[0]?.text ?? ""; - expect(text).toContain("express · npm"); + expect(text).toContain("express | npm"); expect(text).toContain("2 entries"); expect(() => JSON.parse(text)).toThrow(); }); @@ -96,10 +99,103 @@ describe("createPackageChangelogTool — happy path", () => { {}, ); const text = result.content[0]?.text ?? ""; - expect(text).toContain('pass format="json" for full bodies'); + expect(text).toContain( + 'pass verbose=true, body_lines=, or format="json"', + ); expect(text).not.toContain("--verbose"); }); + it("uses body_lines to cap MCP text previews", async () => { + const tool = createPackageChangelogTool( + createMockPackageIntelligenceService({ + packageChangelog: mock(() => + Promise.resolve({ + ...defaultChangelogReport, + entries: [ + { + ...defaultChangelogReport.entries[0]!, + body: Array.from({ length: 8 }, (_, i) => `line ${i + 1}`).join( + "\n", + ), + }, + ], + }), + ), + }), + ); + + const result = await tool.handler( + { registry: "npm", package_name: "express", body_lines: 3 }, + {}, + ); + const text = result.content[0]?.text ?? ""; + expect(text).toContain("line 3"); + expect(text).not.toContain("line 4"); + expect(text).toContain("... (+5 more lines"); + }); + + it("verbose=true renders full MCP text bodies", async () => { + const tool = createPackageChangelogTool( + createMockPackageIntelligenceService({ + packageChangelog: mock(() => + Promise.resolve({ + ...defaultChangelogReport, + entries: [ + { + ...defaultChangelogReport.entries[0]!, + body: Array.from( + { length: 12 }, + (_, i) => `line ${i + 1}`, + ).join("\n"), + }, + ], + }), + ), + }), + ); + + const result = await tool.handler( + { registry: "npm", package_name: "express", verbose: true }, + {}, + ); + const text = result.content[0]?.text ?? ""; + expect(text).toContain("line 12"); + expect(text).not.toContain("more line"); + }); + + it("returns INVALID_ARGUMENT for conflicting or invalid text controls", async () => { + const packageChangelog = mock(() => + Promise.resolve(defaultChangelogReport), + ); + const tool = createPackageChangelogTool( + createMockPackageIntelligenceService({ packageChangelog }), + ); + + const conflict = await tool.handler( + { + registry: "npm", + package_name: "express", + include_bodies: false, + verbose: true, + }, + {}, + ); + expect(conflict.isError).toBe(true); + expect((parseText(conflict) as { code: string }).code).toBe( + "INVALID_ARGUMENT", + ); + + const invalid = await tool.handler( + { registry: "npm", package_name: "express", body_lines: 0 }, + {}, + ); + expect(invalid.isError).toBe(true); + expect((parseText(invalid) as { code: string }).code).toBe( + "INVALID_ARGUMENT", + ); + expect(packageChangelog).not.toHaveBeenCalled(); + }); + it("emits the JSON envelope with entries.count computed client-side when format=json", async () => { const tool = createPackageChangelogTool( createMockPackageIntelligenceService(), @@ -210,6 +306,27 @@ describe("createPackageChangelogTool — happy path", () => { } }); + it("ignores text-only controls for JSON output shape", async () => { + const tool = createPackageChangelogTool( + createMockPackageIntelligenceService(), + ); + const baseline = await tool.handler( + { registry: "npm", package_name: "express", format: "json" }, + {}, + ); + const withTextControls = await tool.handler( + { + registry: "npm", + package_name: "express", + format: "json", + body_lines: 3, + verbose: true, + }, + {}, + ); + expect(parseText(withTextControls)).toEqual(parseText(baseline)); + }); + it("keeps body fields by default", async () => { const tool = createPackageChangelogTool( createMockPackageIntelligenceService(), diff --git a/src/tools/package-changelog.ts b/src/tools/package-changelog.ts index 76406841..b3b66187 100644 --- a/src/tools/package-changelog.ts +++ b/src/tools/package-changelog.ts @@ -1,5 +1,6 @@ import { z } from "zod"; import type { PackageIntelligenceService } from "../services/index.js"; +import { InvalidPackageSpecError } from "../shared/index.js"; import { buildPackageChangelogParams } from "../shared/package-changelog-request.js"; import { buildPackageChangelogSuccessPayload, @@ -21,6 +22,8 @@ export interface PackageChangelogArgs { to_version?: string; limit?: number; include_bodies?: boolean; + verbose?: boolean; + body_lines?: number; format?: "json" | "text" | "text-v1"; } @@ -88,6 +91,18 @@ const schema = { .describe( "When false, each entry in `entries.items[]` omits its `body` field. Default true. Set false when you only need the version / date / URL timeline — drops 10 KB+ per entry on large release notes.", ), + verbose: z + .boolean() + .optional() + .describe( + "Text output only. Show full body previews. Mutually exclusive with include_bodies:false and body_lines.", + ), + body_lines: z + .number() + .optional() + .describe( + "Text output only. Number of body lines to preview per entry (1-50, default 10). Ignored for format=json and include_bodies:false. Mutually exclusive with verbose:true.", + ), format: z .enum(["json", "text", "text-v1"]) .optional() @@ -105,9 +120,13 @@ const DESCRIPTION = 'exclusive). Response includes optional `source` (`"releases"` / ' + '`"changelog_file"` / `"hexdocs"`) when a concrete changelog source ' + 'exists, `mode` (`"latest"` or `"range"`), and entries with ' + - "markdown body previews. Set `include_bodies: false` for a " + - 'version / date / URL timeline only; pass `format: "json"` for ' + - "full markdown bodies. Package-version entries without changelog " + + "markdown body previews. Example: " + + '`{"registry":"npm","package_name":"express","limit":2}`. ' + + "Text output previews 10 body lines by default; use `body_lines` " + + "to tune the preview or `verbose:true` for full text bodies. Set " + + "`include_bodies: false` for a version / date / URL timeline only; " + + 'pass `format: "json"` for the complete structured envelope. ' + + "Package-version entries without changelog " + "text succeed with `source` omitted; no-source plus no entries " + "returns `NOT_FOUND`. Supports npm, PyPI, Hex, Crates, NuGet, " + "Maven, Zig, vcpkg, Packagist, RubyGems, and Go."; @@ -122,6 +141,10 @@ export function createPackageChangelogTool( annotations: { readOnlyHint: true }, handler: async (args) => { try { + const textFormat = isTextFormat(args.format); + const bodyPreviewLines = textFormat + ? validateTextOptions(args) + : undefined; const { params, explicitFilterFields } = buildPackageChangelogParams({ registry: args.registry, packageName: args.package_name, @@ -146,12 +169,14 @@ export function createPackageChangelogTool( limit: params.limit, gitRef: params.gitRef, }); - if (isTextFormat(args.format)) { + if (textFormat) { return textResult( formatPackageChangelogTerminal(payload, { useColors: false, - verbose: false, - fullBodyHint: 'pass format="json" for full bodies', + verbose: args.verbose ?? false, + bodyPreviewLines, + fullBodyHint: + 'pass verbose=true, body_lines=, or format="json" for full bodies', }).trimEnd(), ); } @@ -177,6 +202,30 @@ export function createPackageChangelogTool( }; } +function validateTextOptions(args: PackageChangelogArgs): number | undefined { + if (args.include_bodies === false && args.verbose === true) { + throw new InvalidPackageSpecError( + "verbose:true conflicts with include_bodies:false because bodies are omitted. Drop one of the two options.", + ); + } + if (args.verbose === true && args.body_lines !== undefined) { + throw new InvalidPackageSpecError( + "body_lines conflicts with verbose:true because verbose already shows full bodies. Drop one of the two options.", + ); + } + if (args.body_lines === undefined) return undefined; + if ( + !Number.isInteger(args.body_lines) || + args.body_lines < 1 || + args.body_lines > 50 + ) { + throw new InvalidPackageSpecError( + `body_lines must be an integer between 1 and 50. Got ${args.body_lines}.`, + ); + } + return args.body_lines; +} + function isTextFormat(format: PackageChangelogArgs["format"]): boolean { return format === undefined || format === "text" || format === "text-v1"; }