From 36e4c85621d09ed24585f108f7c81b7b92bef488 Mon Sep 17 00:00:00 2001 From: Juha Litola Date: Thu, 7 May 2026 11:18:44 +0300 Subject: [PATCH] fix: sharpen pkg info output Remove unverified quickstart fields and make package triage output focus on license, popularity, downloads, and explicit vulnerability status across CLI and MCP. --- README.md | 2 +- docs/implementation/cli-commands.md | 4 +- docs/implementation/tools.md | 6 +- scripts/cli-smoke.ts | 16 +++ scripts/mcp-smoke.ts | 16 +++ src/commands/mcp-instructions.ts | 2 +- src/commands/pkg/info.test.ts | 8 +- src/commands/pkg/info.ts | 14 +- src/services/index.ts | 1 - .../package-intelligence-service.test.ts | 14 +- src/services/package-intelligence-service.ts | 27 ---- src/services/test-helpers.ts | 4 - src/shared/package-summary-response.test.ts | 116 ++++++++++------ src/shared/package-summary-response.ts | 127 +++++++++--------- src/tools/package-summary.test.ts | 29 +++- src/tools/package-summary.ts | 23 +++- 16 files changed, 242 insertions(+), 167 deletions(-) diff --git a/README.md b/README.md index bd9f27dd..ac76a336 100644 --- a/README.md +++ b/README.md @@ -106,7 +106,7 @@ GitHits also exposes indexed package/source tools: | `search_status` | Follow up a prior indexed `search` by `searchRef` | | `docs_list` | Browse mixed package documentation pages | | `docs_read` | Read a documentation page by page ID | -| `pkg_info` | Quick package overview: version, license, downloads, quickstart, advisories | +| `pkg_info` | Quick package triage: version, license, repository popularity, downloads, vulnerabilities | | `pkg_vulns` | CVE / OSV advisories for a package or specific version | | `pkg_deps` | Direct dependencies, dependency groups, and optional transitive graph | | `pkg_changelog` | Release notes / changelog entries for a package or GitHub repo | diff --git a/docs/implementation/cli-commands.md b/docs/implementation/cli-commands.md index e37c92f8..08bbca15 100644 --- a/docs/implementation/cli-commands.md +++ b/docs/implementation/cli-commands.md @@ -122,7 +122,7 @@ githits pkg info crates:serde --json githits pkg info npm:@types/node ``` -Shows a concise overview for a single package: latest version, license, description, repository + homepage, publication date, download count, GitHub metadata, install command, and known vulnerabilities. Default output is a compact terminal block. `--verbose` adds usage snippet, recent advisories, topics, and a recent-changes list. `--json` emits the lean hand-crafted envelope — every null field is omitted, every block that carries no actionable data is omitted entirely. +Shows a concise latest-version overview for dependency triage: license, description, repository popularity (stars/forks/issues and `[ARCHIVED]` when applicable), homepage, publication date, download count, and explicit vulnerability status. Default output is compact. `--verbose` adds GitHub language/topics/last-pushed, recent advisories, and a recent-changes list. `--json` emits the lean hand-crafted envelope — null scalars are omitted, and vulnerability data is emitted whenever the backend reports a numeric count, including zero. **Package spec.** `:`. Registries: `npm`, `pypi`, `hex`, `crates`, `nuget`, `maven`, `zig`, `vcpkg`, `packagist`, `rubygems`, `go`. Scoped npm names (`npm:@types/node`) are supported. @@ -130,7 +130,7 @@ Shows a concise overview for a single package: latest version, license, descript **`--verbose` + `--json`.** `--verbose` has no effect under `--json` — the JSON envelope always carries every field the verbose terminal view exposes (and more). The flag only affects human-readable output. -**Output envelope.** Success payload is hand-crafted for agent token efficiency: `{registry, name, version, description?, license?, homepage?, repository?, publishedAt?, downloads?, github?, install?, usage?, vulnerabilities?, recentChanges?}`. Omitted fields reflect backend nulls, not dropped data. Error envelope: `{error, code, retryable, details?}` — shared classifier family. Under `--json` the error envelope is written to **stderr** so stdout stays clean for `jq`. +**Output envelope.** Success payload is hand-crafted for agent token efficiency: `{registry, name, version, description?, license?, homepage?, repository?, publishedAt?, downloads?, github?, vulnerabilities?, recentChanges?}`. Omitted fields reflect backend nulls, not dropped data. Error envelope: `{error, code, retryable, details?}` — shared classifier family. Under `--json` the error envelope is written to **stderr** so stdout stays clean for `jq`. **Troubleshooting.** `GITHITS_DEBUG=pkg-intel` emits PII-safe classified-error diagnostics (area, event, code, error class, detail keys). Use `GITHITS_DEBUG=*` to enable all non-sensitive package/source diagnostics. diff --git a/docs/implementation/tools.md b/docs/implementation/tools.md index b82001b8..778781ed 100644 --- a/docs/implementation/tools.md +++ b/docs/implementation/tools.md @@ -24,7 +24,7 @@ The CLI mirrors the production MCP tool contract where equivalent tools exist. C | `search_status` | `search_ref`, `format?` | Check progress, fetch partial hits when the original request used `allow_partial_results: true`, or fetch final results for a prior unified search. Defaults to compact `text-v1`; pass `format: "json"` for the structured envelope. | | `docs_list` | `registry`, `package_name`, `version?`, `limit?`, `after?`, `format?` | List hosted/crawled and repository-backed documentation pages for a package. Defaults to compact `text-v1` with ready-to-call `docs_read` follow-ups; repo-backed entries include exact source metadata for `code_read` follow-up when available. | | `docs_read` | `page_id`, `start_line?`, `end_line?`, `format?` | Read a documentation page by page ID. Defaults to `text-v1` with a 150-line MCP text cap; explicit line ranges are supported. `format: "json"` preserves full-document default while still honoring explicit ranges. Repo-backed pages include exact file follow-up metadata. | -| `pkg_info` | `registry`, `package_name`, `format?` | Package overview: latest version, license, description, repository, downloads, GitHub metadata, install command, and known vulnerabilities. Defaults to compact text; pass `format: "json"` for the lean structured envelope. Always returns the latest published version. | +| `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?`, `format?` | Known vulnerabilities for a package on npm, PyPI, Hex, or Crates. Defaults to compact summary text; pass `format: "json"` for per-advisory OSV ID + severity + affected/fix ranges, malware bucket, and upgrade paths. | | `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. | @@ -40,7 +40,9 @@ The CLI mirrors the production MCP tool contract where equivalent tools exist. C ### `pkg_info` response shape -**Default MCP text + JSON opt-in.** `pkg_info` defaults to compact text for agent turns. `format: "json"` returns a lean payload designed for programmatic consumers. Fields that do not add caller value are deliberately omitted. Null scalars are omitted; blocks (`github`, `vulnerabilities`, `downloads`, `recentChanges`) are omitted entirely when they carry no actionable data. `vulnerabilities` is omitted when `total === 0` or missing; when present, severity values include a CVSS-banded `severityLabel` (`critical` ≥9, `high` ≥7, `medium` ≥4, else `low`) for agent convenience. +**Default MCP text + JSON opt-in.** `pkg_info` defaults to compact triage text for agent turns: identity/license, description, repository popularity (stars/forks/issues and `[ARCHIVED]` when available), publish age, downloads, and explicit vulnerability status. `verbose: true` adds GitHub language/topics/last-pushed, recent advisories, and recent changes. `format: "json"` returns a lean payload designed for programmatic consumers. Fields that do not add caller value are deliberately omitted. Null scalars are omitted; blocks (`github`, `downloads`, `recentChanges`) are omitted entirely when they carry no actionable data. `vulnerabilities` is emitted whenever the backend reports a numeric vulnerability count, including `total: 0`, so callers can distinguish "no active vulnerabilities in latest" from unavailable data; when present, recent advisory severity values include a CVSS-banded `severityLabel` (`critical` ≥9, `high` ≥7, `medium` ≥4, else `low`) for agent convenience. + +**No quickstart.** `pkg_info` intentionally does not expose install commands or usage snippets. Those values are package-manager-specific and not verified enough for dependency evaluation. Use `docs_*`, `search`, or `get_example` when usage guidance is needed. **Validation.** The MCP schema is permissive (`registry: z.string()`, `package_name: z.string()`) — validation happens in-handler via `buildPackageSummaryParams`, producing the same structured `{error, code, retryable}` envelope as CLI. Raw Zod errors are never surfaced to agents. diff --git a/scripts/cli-smoke.ts b/scripts/cli-smoke.ts index 399eddd0..f37d2e54 100644 --- a/scripts/cli-smoke.ts +++ b/scripts/cli-smoke.ts @@ -402,6 +402,18 @@ async function runLiveSmoke(): Promise { pkgInfoText.includes("express"), "pkg info terminal missing package name", ); + assert( + pkgInfoText.includes("Repository") && pkgInfoText.includes("stars"), + "pkg info terminal missing repository popularity", + ); + assert( + pkgInfoText.includes("Vulnerabilities"), + "pkg info terminal missing vulnerability status", + ); + assert( + !pkgInfoText.includes("Install") && !pkgInfoText.includes("Usage"), + "pkg info terminal should not include quickstart fields", + ); const pkgInfoJson = assertJsonOutput( await runCli(["pkg", "info", "npm:express", "--json"]), @@ -414,6 +426,10 @@ async function runLiveSmoke(): Promise { typeof pkgInfoJson.version === "string", "pkg info json missing version", ); + assert( + !("install" in pkgInfoJson) && !("usage" in pkgInfoJson), + "pkg info json should not include quickstart fields", + ); const depsText = assertTerminalOutput( await runCli(["pkg", "deps", "npm:express"]), diff --git a/scripts/mcp-smoke.ts b/scripts/mcp-smoke.ts index e53c68bb..6079c318 100644 --- a/scripts/mcp-smoke.ts +++ b/scripts/mcp-smoke.ts @@ -281,6 +281,18 @@ async function runLiveSmoke(client: Client): Promise { pkgInfoText.includes("express"), "pkg_info default missing package name", ); + assert( + pkgInfoText.includes("Repository") && pkgInfoText.includes("stars"), + "pkg_info default missing repository popularity", + ); + assert( + pkgInfoText.includes("Vulnerabilities"), + "pkg_info default missing vulnerability status", + ); + assert( + !pkgInfoText.includes("Install") && !pkgInfoText.includes("Usage"), + "pkg_info default should not include quickstart fields", + ); const pkgInfoJson = assertJsonResult( await callTool(client, "pkg_info", { @@ -297,6 +309,10 @@ async function runLiveSmoke(client: Client): Promise { typeof pkgInfoJson.version === "string", "pkg_info json missing version", ); + assert( + !("install" in pkgInfoJson) && !("usage" in pkgInfoJson), + "pkg_info json should not include quickstart fields", + ); const depsText = assertDefaultText( await callTool(client, "pkg_deps", { diff --git a/src/commands/mcp-instructions.ts b/src/commands/mcp-instructions.ts index 0c02ed7d..84e5253c 100644 --- a/src/commands/mcp-instructions.ts +++ b/src/commands/mcp-instructions.ts @@ -51,7 +51,7 @@ const DOCS_READ_BULLET = "- `docs_read` — read a documentation page by pageId. Works for both hosted docs and repo-backed docs."; const PKG_INFO_BULLET = - '- `pkg_info` — compact package overview: latest version, license, downloads, quickstart, and active advisory count. Pass `format: "json"` for the structured envelope.'; + '- `pkg_info` — latest-version package triage by `registry` + `package_name` (e.g. `npm` + `express`): license, repository popularity, downloads, publish age, and vulnerability status. Set `verbose: true` for GitHub language/topics/last-pushed, recent advisories, and recent changes; pass `format: "json"` for structured fields.'; const PKG_VULNS_BULLET = '- `pkg_vulns` — compact known CVE / OSV advisory summary for npm, PyPI, Hex, or Crates packages, optionally pinned to `version`. Filter with `min_severity`; include retracted advisories with `include_withdrawn`. Pass `format: "json"` for per-advisory structured fields.'; diff --git a/src/commands/pkg/info.test.ts b/src/commands/pkg/info.test.ts index 7e00dfa2..ea89c3cf 100644 --- a/src/commands/pkg/info.test.ts +++ b/src/commands/pkg/info.test.ts @@ -36,9 +36,11 @@ describe("pkgInfoAction", () => { await pkgInfoAction("npm:express", {}, createDeps()); const combined = writes.join(""); - expect(combined).toContain("express @ 4.18.2 · MIT"); + expect(combined).toContain("express @ 4.18.2 | MIT"); expect(combined).toContain("Repository"); - expect(combined).toContain("Install"); + expect(combined).toContain("63k stars, 14k forks, 123 issues"); + expect(combined).toContain("Vulnerabilities"); + expect(combined).not.toContain("Install"); writeSpy.mockRestore(); }); @@ -52,6 +54,8 @@ describe("pkgInfoAction", () => { expect(payload.name).toBe("express"); expect(payload.registry).toBe("npm"); expect(payload.version).toBe("4.18.2"); + expect("install" in payload).toBe(false); + expect("usage" in payload).toBe(false); logSpy.mockRestore(); }); diff --git a/src/commands/pkg/info.ts b/src/commands/pkg/info.ts index 63d49a7b..0b13d6dc 100644 --- a/src/commands/pkg/info.ts +++ b/src/commands/pkg/info.ts @@ -99,13 +99,17 @@ function handlePkgInfoCommandError(error: unknown, json: boolean): never { process.exit(1); } -const PKG_INFO_DESCRIPTION = `Get a package overview — latest version, license, description, -repository, downloads, GitHub stars, install command, and known -vulnerabilities. Use before picking a dependency or to orient on what -a package is. +const PKG_INFO_DESCRIPTION = `Latest-version package overview for dependency triage. + +Default output shows license, description, repository popularity +(stars/forks/issues and [ARCHIVED] when applicable), downloads, +publish age, and vulnerability status. --verbose adds GitHub +language/topics/last-pushed, recent advisories, and recent changes. Package spec: :. Supported registries: ${PKGSEER_REGISTRY_LIST}. +Example: githits pkg info npm:express + Always returns data for the latest published version.`; export function registerPkgInfoCommand(pkgCommand: Command): Command { @@ -116,7 +120,7 @@ export function registerPkgInfoCommand(pkgCommand: Command): Command { .argument("", "Package spec, e.g. npm:express or pypi:requests") .option( "-v, --verbose", - "Show advisories, install usage, topics, and recent changes", + "Show GitHub language/topics/last-pushed, recent advisories, and recent changes", ) .option("--json", "Emit the lean JSON envelope") .action(async (spec: string, options: PkgInfoCommandOptions) => { diff --git a/src/services/index.ts b/src/services/index.ts index 981a9c5e..5b04b1a5 100644 --- a/src/services/index.ts +++ b/src/services/index.ts @@ -161,7 +161,6 @@ export type { PackageSummaryParams, PackageVersionIdentity, PackageVulnerabilitiesParams, - QuickstartInfo, ReadPackageDocParams, TransitiveDependencySummary, UntypedGenericJSON, diff --git a/src/services/package-intelligence-service.test.ts b/src/services/package-intelligence-service.test.ts index fbaa8397..c45e43ce 100644 --- a/src/services/package-intelligence-service.test.ts +++ b/src/services/package-intelligence-service.test.ts @@ -71,10 +71,6 @@ const HAPPY_BODY = { }, ], }, - quickstart: { - installCommand: "npm install express", - usageExample: "const express = require('express')", - }, latestChangelogs: [ { version: "4.18.2", @@ -120,11 +116,10 @@ describe("PackageIntelligenceServiceImpl", () => { expect(result.package.downloadsLastMonth).toBe(86_000_000); expect(result.package.githubRepository?.stargazersCount).toBe(63_400); expect(result.security?.vulnerabilityCount).toBe(5); - expect(result.quickstart?.installCommand).toBe("npm install express"); expect(result.latestChangelogs?.[0]?.version).toBe("4.18.2"); }); - it("preserves null blocks (security / quickstart / github absent)", async () => { + it("preserves null blocks (security / github absent)", async () => { const body = { data: { packageSummary: { @@ -142,7 +137,6 @@ describe("PackageIntelligenceServiceImpl", () => { githubRepository: null, }, security: null, - quickstart: null, latestChangelogs: null, }, }, @@ -163,7 +157,6 @@ describe("PackageIntelligenceServiceImpl", () => { expect(result.package.name).toBe("obscure"); expect(result.package.githubRepository).toBeUndefined(); expect(result.security).toBeUndefined(); - expect(result.quickstart).toBeUndefined(); expect(result.latestChangelogs).toBeUndefined(); }); @@ -173,7 +166,6 @@ describe("PackageIntelligenceServiceImpl", () => { packageSummary: { package: { name: null, latestVersion: "1.0.0" }, security: null, - quickstart: null, latestChangelogs: null, }, }, @@ -196,7 +188,6 @@ describe("PackageIntelligenceServiceImpl", () => { packageSummary: { package: { name: "x", latestVersion: null }, security: null, - quickstart: null, latestChangelogs: null, }, }, @@ -231,6 +222,9 @@ describe("PackageIntelligenceServiceImpl", () => { const parsed = JSON.parse(capturedBody ?? "{}"); expect(parsed.query).toContain("packageSummary(registry: $registry"); expect(parsed.query).toContain("latestChangelogs(limit: 3)"); + expect(parsed.query).not.toContain("quickstart"); + expect(parsed.query).not.toContain("installCommand"); + expect(parsed.query).not.toContain("usageExample"); expect(parsed.variables).toEqual({ registry: "NPM", name: "express" }); }); diff --git a/src/services/package-intelligence-service.ts b/src/services/package-intelligence-service.ts index 0c510c1b..aacc9568 100644 --- a/src/services/package-intelligence-service.ts +++ b/src/services/package-intelligence-service.ts @@ -77,11 +77,6 @@ export interface PackageSecurityOverview { recentVulnerabilities?: VulnerabilityOverview[]; } -export interface QuickstartInfo { - installCommand?: string; - usageExample?: string; -} - export interface ChangelogEntry { version?: string; publishedAt?: string; @@ -91,7 +86,6 @@ export interface ChangelogEntry { export interface PackageSummary { package: PackageIdentity; security?: PackageSecurityOverview; - quickstart?: QuickstartInfo; latestChangelogs?: ChangelogEntry[]; } @@ -615,14 +609,6 @@ const packageSecurityOverviewSchema = z .nullable() .optional(); -const quickstartInfoSchema = z - .object({ - installCommand: z.string().nullable().optional(), - usageExample: z.string().nullable().optional(), - }) - .nullable() - .optional(); - const changelogEntrySchema = z.object({ version: z.string().nullable().optional(), publishedAt: z.string().nullable().optional(), @@ -632,7 +618,6 @@ const changelogEntrySchema = z.object({ const packageSummaryResponseSchema = z.object({ package: packageIdentitySchema.nullable().optional(), security: packageSecurityOverviewSchema, - quickstart: quickstartInfoSchema, latestChangelogs: z.array(changelogEntrySchema).nullable().optional(), }); @@ -685,10 +670,6 @@ query PackageSummary($registry: Registry!, $name: String!) { publishedAt } } - quickstart { - installCommand - usageExample - } latestChangelogs(limit: 3) { version publishedAt @@ -1536,13 +1517,6 @@ export class PackageIntelligenceServiceImpl } : undefined; - const quickstart: QuickstartInfo | undefined = data.quickstart - ? { - installCommand: data.quickstart.installCommand ?? undefined, - usageExample: data.quickstart.usageExample ?? undefined, - } - : undefined; - const latestChangelogs: ChangelogEntry[] | undefined = data.latestChangelogs?.map((entry) => ({ version: entry.version ?? undefined, @@ -1553,7 +1527,6 @@ export class PackageIntelligenceServiceImpl return { package: identity, security, - quickstart, latestChangelogs, }; } diff --git a/src/services/test-helpers.ts b/src/services/test-helpers.ts index 4bedadb2..6dd3a421 100644 --- a/src/services/test-helpers.ts +++ b/src/services/test-helpers.ts @@ -429,10 +429,6 @@ export const defaultPackageSummary: PackageSummary = { }, ], }, - quickstart: { - installCommand: "npm install express", - usageExample: "const express = require('express')\nconst app = express()", - }, latestChangelogs: [ { version: "4.18.2", diff --git a/src/shared/package-summary-response.test.ts b/src/shared/package-summary-response.test.ts index bd50728d..4d40a100 100644 --- a/src/shared/package-summary-response.test.ts +++ b/src/shared/package-summary-response.test.ts @@ -26,7 +26,8 @@ describe("buildPackageSummarySuccessPayload — happy path", () => { expect(payload.publishedAt).toBe("2023-05-28"); expect(payload.downloads?.lastMonth).toBe(86_000_000); expect(payload.github?.stars).toBe(63_400); - expect(payload.install).toBe("npm install express"); + expect("install" in payload).toBe(false); + expect("usage" in payload).toBe(false); expect(payload.vulnerabilities?.total).toBe(5); expect(payload.vulnerabilities?.affectsLatest).toBe(true); expect(payload.vulnerabilities?.recent?.[0]?.severityLabel).toBe("high"); @@ -87,7 +88,7 @@ describe("buildPackageSummarySuccessPayload — omission rules", () => { expect(payload.github?.topics).toBeUndefined(); }); - it("omits vulnerabilities block when total is 0", () => { + it("keeps vulnerabilities block when total is 0", () => { const fixture = happyFixture(); fixture.security = { vulnerabilityCount: 0, @@ -95,7 +96,10 @@ describe("buildPackageSummarySuccessPayload — omission rules", () => { recentVulnerabilities: [], }; const payload = buildPackageSummarySuccessPayload(fixture); - expect(payload.vulnerabilities).toBeUndefined(); + expect(payload.vulnerabilities).toEqual({ + total: 0, + affectsLatest: false, + }); }); it("omits vulnerabilities block when vulnerabilityCount is null/missing", () => { @@ -128,14 +132,6 @@ describe("buildPackageSummarySuccessPayload — omission rules", () => { expect(payload.vulnerabilities?.recent).toBeUndefined(); }); - it("omits install/usage individually when null, and drops quickstart block when empty", () => { - const fixture = happyFixture(); - fixture.quickstart = { installCommand: undefined, usageExample: undefined }; - const payload = buildPackageSummarySuccessPayload(fixture); - expect(payload.install).toBeUndefined(); - expect(payload.usage).toBeUndefined(); - }); - it("drops recentChanges entries with null version but preserves backend order", () => { const fixture = happyFixture(); fixture.latestChangelogs = [ @@ -173,8 +169,8 @@ describe("buildPackageSummarySuccessPayload — omission rules", () => { const summary = buildPackageSummarySuccessPayload(fixture).recentChanges?.[0]?.summary; expect(summary).toBeDefined(); - expect(summary?.length).toBeLessThanOrEqual(121); // 120 + trailing ellipsis - expect(summary?.endsWith("…")).toBe(true); + expect(summary?.length).toBeLessThanOrEqual(120); + expect(summary?.endsWith("...")).toBe(true); }); }); @@ -187,15 +183,6 @@ describe("buildPackageSummarySuccessPayload — data transformations", () => { expect(payload.publishedAt).toBe("2024-05-10"); }); - it("normalises \\r\\n in usage to \\n", () => { - const fixture = happyFixture(); - if (fixture.quickstart) { - fixture.quickstart.usageExample = "line 1\r\nline 2\r\nline 3"; - } - const payload = buildPackageSummarySuccessPayload(fixture); - expect(payload.usage).toBe("line 1\nline 2\nline 3"); - }); - it("omits severity/severityLabel when the score is null or non-positive", () => { const fixture = happyFixture(); fixture.security = { @@ -238,31 +225,53 @@ describe("severityLabel — CVSS banding boundaries", () => { }); describe("formatPackageSummaryTerminal", () => { - it("renders the default block with header, description, fields, and vuln footer", () => { + it("renders the default triage block with repository popularity and vulnerability status", () => { const output = formatPackageSummaryTerminal(defaultPackageSummary, { useColors: false, now: FIXED_NOW, }); - expect(output).toContain("express @ 4.18.2 · MIT"); + expect(output).toContain("express @ 4.18.2 | MIT"); expect(output).toContain("Fast, unopinionated"); - expect(output).toContain("Repository"); - expect(output).toContain("5 known vulnerabilities · latest affected"); + expect(output).toContain( + "Repository https://github.com/expressjs/express (63k stars, 14k forks, 123 issues)", + ); + expect(output).toContain( + "Vulnerabilities 5 active vulnerabilities; latest affected", + ); + expect(output).not.toContain("Install"); + expect(output).not.toContain("Usage"); }); - it("verbose mode adds Usage, Recent advisories, Topics, and Recent changes", () => { + it("verbose mode adds GitHub details, Recent advisories, and Recent changes", () => { const output = formatPackageSummaryTerminal(defaultPackageSummary, { verbose: true, useColors: false, now: FIXED_NOW, }); - expect(output).toContain("Usage"); - expect(output).toContain("const express"); + expect(output).toContain("GitHub"); + expect(output).toContain(" Language JavaScript"); + expect(output).toContain( + " Topics framework, http, middleware, nodejs, web", + ); expect(output).toContain("Recent advisories"); expect(output).toContain("GHSA-xxxx-xxxx-xxxx"); - expect(output).toContain("Topics"); expect(output).toContain("Recent changes"); }); + it("text output stays printable ASCII", () => { + const output = formatPackageSummaryTerminal(defaultPackageSummary, { + verbose: true, + useColors: false, + now: FIXED_NOW, + }); + for (const char of output) { + const code = char.charCodeAt(0); + expect( + code === 9 || code === 10 || code === 13 || (code >= 32 && code <= 126), + ).toBe(true); + } + }); + it("no-color output contains no ANSI escape sequences", () => { const output = formatPackageSummaryTerminal(defaultPackageSummary, { useColors: false, @@ -272,7 +281,7 @@ describe("formatPackageSummaryTerminal", () => { expect(output).not.toContain("\x1b["); }); - it("vuln footer uses singular form for total = 1", () => { + it("vulnerability status uses singular form for total = 1", () => { const fixture = happyFixture(); fixture.security = { vulnerabilityCount: 1, @@ -283,10 +292,10 @@ describe("formatPackageSummaryTerminal", () => { useColors: false, now: FIXED_NOW, }); - expect(output).toContain("1 known vulnerability"); + expect(output).toContain("1 active vulnerability; latest affected"); }); - it("omits the vuln footer when there are zero vulnerabilities", () => { + it("renders explicit zero-vulnerability status", () => { const fixture = happyFixture(); fixture.security = { vulnerabilityCount: 0, @@ -297,8 +306,9 @@ describe("formatPackageSummaryTerminal", () => { useColors: false, now: FIXED_NOW, }); - expect(output).not.toContain("known vulnerabilities"); - expect(output).not.toContain("known vulnerability"); + expect(output).toContain( + "Vulnerabilities No active vulnerabilities in latest published version", + ); }); it("omits license separator when license is null", () => { @@ -319,7 +329,33 @@ describe("formatPackageSummaryTerminal", () => { now: FIXED_NOW, }); expect(output).not.toContain("GitHub"); - expect(output).not.toContain("★"); + expect(output).not.toContain("stars"); + }); + + it("renders GitHub popularity when repository URL is missing", () => { + const fixture = happyFixture(); + fixture.package.repositoryUrl = undefined; + const output = formatPackageSummaryTerminal(fixture, { + useColors: false, + now: FIXED_NOW, + }); + expect(output).toContain( + "GitHub 63k stars, 14k forks, 123 issues", + ); + }); + + it("marks archived repositories in default popularity text", () => { + const fixture = happyFixture(); + if (fixture.package.githubRepository) { + fixture.package.githubRepository.archived = true; + } + const output = formatPackageSummaryTerminal(fixture, { + useColors: false, + now: FIXED_NOW, + }); + expect(output).toContain( + "Repository https://github.com/expressjs/express ([ARCHIVED], 63k stars, 14k forks, 123 issues)", + ); }); it("wraps description at 80 cols and uses 80-col fallback when stdout.columns is undefined", () => { @@ -338,13 +374,15 @@ describe("formatPackageSummaryTerminal", () => { } }); - it("renders topics in verbose mode without colorizing the label gutter", () => { + it("renders topics in verbose GitHub block", () => { const output = formatPackageSummaryTerminal(defaultPackageSummary, { verbose: true, useColors: false, now: FIXED_NOW, }); - // The label "Topics" starts at column 0 with a fixed-width gutter - expect(output).toMatch(/\nTopics\s{4}\s{2}framework/); + expect(output).toContain( + " Topics framework, http, middleware, nodejs, web", + ); + expect(output.match(/Topics/g)?.length).toBe(1); }); }); diff --git a/src/shared/package-summary-response.ts b/src/shared/package-summary-response.ts index 9108851d..ad34416e 100644 --- a/src/shared/package-summary-response.ts +++ b/src/shared/package-summary-response.ts @@ -78,8 +78,6 @@ export interface LeanPackageSummary { publishedAt?: string; downloads?: LeanDownloads; github?: LeanGithub; - install?: string; - usage?: string; vulnerabilities?: LeanVulnerabilities; recentChanges?: LeanRecentChange[]; } @@ -115,12 +113,6 @@ export function buildPackageSummarySuccessPayload( const github = buildGithub(pkg.githubRepository); if (github) payload.github = github; - if (summary.quickstart?.installCommand) { - payload.install = summary.quickstart.installCommand; - } - const usage = normaliseUsage(summary.quickstart?.usageExample); - if (usage) payload.usage = usage; - const vulns = buildVulnerabilities(summary.security); if (vulns) payload.vulnerabilities = vulns; @@ -191,20 +183,12 @@ function buildGithub( return Object.keys(result).length > 0 ? result : undefined; } -function normaliseUsage(usage: string | undefined): string | undefined { - if (!usage) return undefined; - // Guard against \r\n artefacts on Windows-authored backends so the - // output renders consistently and the MCP JSON stays clean. - const cleaned = usage.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); - return cleaned.length > 0 ? cleaned : undefined; -} - function buildVulnerabilities( security: PackageSecurityOverview | undefined, ): LeanVulnerabilities | undefined { if (!security) return undefined; - const total = security.vulnerabilityCount ?? 0; - if (total === 0) return undefined; + if (typeof security.vulnerabilityCount !== "number") return undefined; + const total = security.vulnerabilityCount; const result: LeanVulnerabilities = { total, @@ -264,7 +248,7 @@ function pickChangelogSummary(entry: ChangelogEntry): string | undefined { // we derive one from the first non-empty line of the body. Many // changelogs lead with markdown headers (`## What's Changed`, // `### Bug fixes`) — strip the `#` markers so the summary reads as - // prose. Trimmed to 120 chars with trailing ellipsis. + // prose. Trimmed to 120 chars with trailing ASCII ellipsis. if (!entry.body) return undefined; const firstLine = entry.body .split(/\r?\n/) @@ -272,7 +256,7 @@ function pickChangelogSummary(entry: ChangelogEntry): string | undefined { .find((line) => line.length > 0); if (!firstLine) return undefined; return firstLine.length > 120 - ? `${firstLine.slice(0, 120).trimEnd()}…` + ? `${firstLine.slice(0, 117).trimEnd()}...` : firstLine; } @@ -311,9 +295,9 @@ export function formatPackageSummaryTerminal( const sections: string[] = []; - // Header — `name @ version · license` + // Header — `name @ version | license` const header = lean.license - ? `${colorize(lean.name, "bold", useColors)} @ ${lean.version} · ${lean.license}` + ? `${colorize(lean.name, "bold", useColors)} @ ${lean.version} | ${lean.license}` : `${colorize(lean.name, "bold", useColors)} @ ${lean.version}`; sections.push(header); @@ -328,11 +312,6 @@ export function formatPackageSummaryTerminal( sections.push(fields.join("\n")); } - // Vulnerabilities footer. - if (lean.vulnerabilities) { - sections.push(formatVulnFooter(lean.vulnerabilities, useColors)); - } - if (options.verbose) { const verbose = buildVerboseSections(lean, useColors); if (verbose.length > 0) sections.push(verbose); @@ -386,10 +365,18 @@ function buildFieldList( const fields: LabelledField[] = []; if (lean.repository) { + const repositoryParts = [dim(lean.repository, useColors)]; + const githubPopularity = formatGithubPopularity(lean.github); + if (githubPopularity) repositoryParts.push(`(${githubPopularity})`); fields.push({ label: "Repository", - value: dim(lean.repository, useColors), + value: repositoryParts.join(" "), }); + } else { + const githubPopularity = formatGithubPopularity(lean.github); + if (githubPopularity) { + fields.push({ label: "GitHub", value: githubPopularity }); + } } if (lean.homepage) { fields.push({ label: "Homepage", value: dim(lean.homepage, useColors) }); @@ -415,12 +402,11 @@ function buildFieldList( }); } - if (lean.github) { - fields.push({ label: "GitHub", value: formatGithubLine(lean.github) }); - } - - if (lean.install) { - fields.push({ label: "Install", value: lean.install }); + if (lean.vulnerabilities) { + fields.push({ + label: "Vulnerabilities", + value: formatVulnerabilityStatus(lean.vulnerabilities), + }); } // Label column sized to the widest *raw* label (no ANSI, matching @@ -432,31 +418,35 @@ function buildFieldList( ); } -function formatGithubLine(github: LeanGithub): string { +function formatGithubPopularity( + github: LeanGithub | undefined, +): string | undefined { + if (!github) return undefined; const parts: string[] = []; + if (github.archived) { + parts.push("[ARCHIVED]"); + } if (github.stars !== undefined) { - parts.push(`★ ${formatCompactNumber(github.stars)}`); + parts.push(`${formatCompactNumber(github.stars)} stars`); } if (github.forks !== undefined) { parts.push(`${formatCompactNumber(github.forks)} forks`); } if (github.openIssues !== undefined) { - parts.push(`${formatCompactNumber(github.openIssues)} open issues`); - } - if (github.archived) { - parts.push("archived"); + parts.push(`${formatCompactNumber(github.openIssues)} issues`); } - return parts.join(" · "); + return parts.length > 0 ? parts.join(", ") : undefined; } -function formatVulnFooter( - vulns: LeanVulnerabilities, - useColors: boolean, -): string { +function formatVulnerabilityStatus(vulns: LeanVulnerabilities): string { + if (vulns.total === 0) { + return "No active vulnerabilities in latest published version"; + } const noun = vulns.total === 1 ? "vulnerability" : "vulnerabilities"; - const base = `${vulns.total} known ${noun}`; - const full = vulns.affectsLatest ? `${base} · latest affected` : base; - return colorize(full, "yellow", useColors); + if (vulns.affectsLatest) { + return `${vulns.total} active ${noun}; latest affected`; + } + return `${vulns.total} known ${noun}; latest not affected`; } function buildVerboseSections( @@ -465,16 +455,9 @@ function buildVerboseSections( ): string { const blocks: string[] = []; - if (lean.usage) { - blocks.push( - [ - highlight("Usage", useColors), - lean.usage - .split("\n") - .map((line) => ` ${line}`) - .join("\n"), - ].join("\n"), - ); + if (lean.github) { + const github = formatVerboseGithub(lean.github, useColors); + if (github) blocks.push(github); } if (lean.vulnerabilities?.recent && lean.vulnerabilities.recent.length > 0) { @@ -483,10 +466,6 @@ function buildVerboseSections( ); } - if (lean.github?.topics && lean.github.topics.length > 0) { - blocks.push(`${"Topics".padEnd(10)} ${lean.github.topics.join(", ")}`); - } - if (lean.recentChanges && lean.recentChanges.length > 0) { blocks.push(formatVerboseChanges(lean.recentChanges, useColors)); } @@ -494,6 +473,30 @@ function buildVerboseSections( return blocks.join("\n\n"); } +function formatVerboseGithub( + github: LeanGithub, + useColors: boolean, +): string | undefined { + const fields: LabelledField[] = []; + if (github.language) + fields.push({ label: "Language", value: github.language }); + if (github.lastPushedAt) { + fields.push({ label: "Last pushed", value: github.lastPushedAt }); + } + if (github.topics && github.topics.length > 0) { + fields.push({ label: "Topics", value: github.topics.join(", ") }); + } + const labelWidth = Math.max(10, ...fields.map((field) => field.label.length)); + return fields.length > 0 + ? [ + highlight("GitHub", useColors), + ...fields.map( + (field) => ` ${field.label.padEnd(labelWidth)} ${field.value}`, + ), + ].join("\n") + : undefined; +} + function formatVerboseAdvisories( advisories: LeanVulnerability[], useColors: boolean, diff --git a/src/tools/package-summary.test.ts b/src/tools/package-summary.test.ts index 71c82696..8b7e33d4 100644 --- a/src/tools/package-summary.test.ts +++ b/src/tools/package-summary.test.ts @@ -17,13 +17,14 @@ describe("createPackageSummaryTool — metadata", () => { ); expect(tool.name).toBe("pkg_info"); expect(tool.description).toContain("package overview"); - // Canonical registry order from PKGSEER_REGISTRY_ARGS - expect(tool.description).toContain( - "npm, PyPI, Hex, Crates, NuGet, Maven, Zig, vcpkg, Packagist, RubyGems, and Go", - ); + expect(tool.description).toContain("for example `npm` + `express`"); + expect(tool.description).toContain("[ARCHIVED]"); + expect(tool.description).toContain("verbose: true"); + expect(tool.description).toContain("pkg_vulns"); expect(Object.keys(tool.schema)).toEqual([ "registry", "package_name", + "verbose", "format", ]); expect(tool.annotations?.readOnlyHint).toBe(true); @@ -58,9 +59,27 @@ describe("createPackageSummaryTool — happy path", () => { const text = result.content[0]?.text ?? ""; expect(text).toContain("express @ 4.18.2"); expect(text).toContain("Repository"); + expect(text).toContain("63k stars, 14k forks, 123 issues"); + expect(text).toContain("Vulnerabilities"); + expect(text).not.toContain("Install"); expect(() => JSON.parse(text)).toThrow(); }); + it("returns verbose text when verbose=true", async () => { + const tool = createPackageSummaryTool( + createMockPackageIntelligenceService(), + ); + const result = await tool.handler( + { registry: "npm", package_name: "express", verbose: true }, + {}, + ); + const text = result.content[0]?.text ?? ""; + expect(text).toContain("GitHub"); + expect(text).toContain("Recent advisories"); + expect(text).toContain("Recent changes"); + expect(text).not.toContain("Usage"); + }); + it("returns JSON-stringified lean envelope when format=json", async () => { const tool = createPackageSummaryTool( createMockPackageIntelligenceService(), @@ -73,6 +92,8 @@ describe("createPackageSummaryTool — happy path", () => { expect(payload.registry).toBe("npm"); expect(payload.name).toBe("express"); expect(payload.version).toBe("4.18.2"); + expect("install" in payload).toBe(false); + expect("usage" in payload).toBe(false); }); }); diff --git a/src/tools/package-summary.ts b/src/tools/package-summary.ts index 1c2a384b..b1db3c93 100644 --- a/src/tools/package-summary.ts +++ b/src/tools/package-summary.ts @@ -12,6 +12,7 @@ import { type ToolDefinition, textResult } from "./types.js"; export interface PackageSummaryArgs { registry: string; package_name: string; + verbose?: boolean; format?: "json" | "text" | "text-v1"; } @@ -29,6 +30,12 @@ const schema = { package_name: z .string() .describe("Package name (scoped names ok: @types/node)."), + verbose: z + .boolean() + .optional() + .describe( + "Text only. Adds GitHub language/topics/last-pushed, recent advisories, and recent changes. Ignored for format=json.", + ), format: z .enum(["json", "text", "text-v1"]) .optional() @@ -38,13 +45,14 @@ const schema = { }; const DESCRIPTION = - "Get a package overview — latest version, license, description, " + - "repository, downloads, GitHub stars, install command, recent " + - "changes, and a count of known vulnerabilities. Use before " + - "recommending a package or to orient on what a dependency is. " + - "Works across npm, PyPI, Hex, Crates, NuGet, Maven, Zig, vcpkg, " + - "Packagist, RubyGems, and Go. Always returns data for the latest " + - "published version."; + "Latest-version package overview for dependency triage. Provide " + + "`registry` and `package_name` (for example `npm` + `express`). " + + "Default text returns license, description, repository popularity " + + "(stars/forks/issues and [ARCHIVED] when applicable), downloads, " + + "publish age, and vulnerability status. Set `verbose: true` for " + + "GitHub language/topics/last-pushed, recent advisories, and recent " + + 'changes. Pass `format: "json"` for structured fields. Use ' + + "`pkg_vulns` for version-specific vulnerability details."; export function createPackageSummaryTool( service: PackageIntelligenceService, @@ -65,6 +73,7 @@ export function createPackageSummaryTool( if (isTextFormat(args.format)) { return textResult( formatPackageSummaryTerminal(summary, { + verbose: args.verbose, useColors: false, }).trimEnd(), );