From 09bf9fabc39dc87407a3740115d1ae3617f68c67 Mon Sep 17 00:00:00 2001 From: Juha Litola Date: Sat, 25 Apr 2026 16:26:31 +0300 Subject: [PATCH 1/2] feat: add mixed package docs access Expose hosted and repo-backed package docs across CLI, MCP, and unified search so agents can browse docs, read pages by ID, and fall back to exact file reads for repo-backed pages. This also makes docs discoverable from root help and search output while failing loudly on broken backend doc-follow-up contracts. --- src/cli.ts | 24 +- src/commands/code/index.ts | 36 +- src/commands/docs/index.test.ts | 38 ++ src/commands/docs/index.ts | 32 ++ src/commands/docs/list.test.ts | 103 +++++ src/commands/docs/list.ts | 135 ++++++ src/commands/docs/read.test.ts | 107 +++++ src/commands/docs/read.ts | 108 +++++ src/commands/gated-command-group.ts | 49 ++ src/commands/index.ts | 16 +- src/commands/mcp-instructions.test.ts | 26 +- src/commands/mcp-instructions.ts | 15 +- src/commands/mcp.test.ts | 16 + src/commands/mcp.ts | 4 + src/commands/pkg/index.ts | 36 +- src/commands/search.test.ts | 9 +- src/commands/search.ts | 82 +++- src/services/code-navigation-service.ts | 15 + src/services/index.ts | 8 + src/services/package-intelligence-service.ts | 445 +++++++++++++++++++ src/services/test-helpers.ts | 64 +++ src/shared/docs-follow-up.ts | 54 +++ src/shared/index.ts | 28 ++ src/shared/list-package-docs-request.ts | 70 +++ src/shared/list-package-docs-response.ts | 196 ++++++++ src/shared/read-package-doc-request.ts | 23 + src/shared/read-package-doc-response.ts | 135 ++++++ src/shared/unified-search-response.ts | 74 +++ src/tools/index.ts | 2 + src/tools/list-package-docs-parity.test.ts | 120 +++++ src/tools/list-package-docs.test.ts | 129 ++++++ src/tools/list-package-docs.ts | 79 ++++ src/tools/read-package-doc-parity.test.ts | 91 ++++ src/tools/read-package-doc.test.ts | 69 +++ src/tools/read-package-doc.ts | 54 +++ src/tools/search.test.ts | 60 +++ 36 files changed, 2470 insertions(+), 82 deletions(-) create mode 100644 src/commands/docs/index.test.ts create mode 100644 src/commands/docs/index.ts create mode 100644 src/commands/docs/list.test.ts create mode 100644 src/commands/docs/list.ts create mode 100644 src/commands/docs/read.test.ts create mode 100644 src/commands/docs/read.ts create mode 100644 src/commands/gated-command-group.ts create mode 100644 src/shared/docs-follow-up.ts create mode 100644 src/shared/list-package-docs-request.ts create mode 100644 src/shared/list-package-docs-response.ts create mode 100644 src/shared/read-package-doc-request.ts create mode 100644 src/shared/read-package-doc-response.ts create mode 100644 src/tools/list-package-docs-parity.test.ts create mode 100644 src/tools/list-package-docs.test.ts create mode 100644 src/tools/list-package-docs.ts create mode 100644 src/tools/read-package-doc-parity.test.ts create mode 100644 src/tools/read-package-doc.test.ts create mode 100644 src/tools/read-package-doc.ts diff --git a/src/cli.ts b/src/cli.ts index 83b93772..22a4c2d4 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -4,6 +4,7 @@ import { version } from "../package.json"; import { registerAuthStatusCommand, registerCodeCommandGroup, + registerDocsCommandGroup, registerExampleCommand, registerFeedbackCommand, registerInitCommand, @@ -103,6 +104,11 @@ if (shouldEagerLoadGatedCommandGroup(argv, "pkg")) { registerPkgCommandGroup(program, helpRegistrationOptions), ); } +if (shouldEagerLoadGatedCommandGroup(argv, "docs")) { + await withTelemetrySpan("cli.register.docs-group", () => + registerDocsCommandGroup(program, helpRegistrationOptions), + ); +} // Auth status as subcommand of `auth` const authCommand = program @@ -127,7 +133,11 @@ function shouldEagerLoadGatedCommandGroup( ): boolean { const [firstArg] = args; return ( - firstArg === groupName || (firstArg === "help" && args[1] === groupName) + args.length === 0 || + firstArg === groupName || + (firstArg === "help" && (!args[1] || args[1] === groupName)) || + firstArg === "--help" || + firstArg === "-h" ); } @@ -159,12 +169,16 @@ function needsGatedHelpRegistration(args: string[]): boolean { return ( isSearchHelpTarget(secondArg) || secondArg === "code" || - secondArg === "pkg" + secondArg === "pkg" || + secondArg === "docs" ); } return ( - isSearchHelpTarget(firstArg) || firstArg === "code" || firstArg === "pkg" + isSearchHelpTarget(firstArg) || + firstArg === "code" || + firstArg === "pkg" || + firstArg === "docs" ); } @@ -193,10 +207,12 @@ function shouldUseExpiredStoredAuthFallbackForHelp(args: string[]): boolean { firstArg === "search-status" || firstArg === "code" || firstArg === "pkg" || + firstArg === "docs" || (firstArg === "help" && (isSearchHelpTarget(secondArg) || secondArg === "code" || - secondArg === "pkg")) + secondArg === "pkg" || + secondArg === "docs")) ); } diff --git a/src/commands/code/index.ts b/src/commands/code/index.ts index 7d06c602..00398140 100644 --- a/src/commands/code/index.ts +++ b/src/commands/code/index.ts @@ -1,20 +1,16 @@ import type { Command } from "commander"; -import { resolveStartupCodeNavigationRegistrationState } from "../../container.js"; +import type { CodeNavigationCapability } from "../../services/index.js"; import { - type CodeNavigationCapability, - getCodeNavigationUrl, - isCodeNavigationCliOverrideEnabled, -} from "../../services/index.js"; + type GatedCommandGroupOptions, + resolveGatedCommandGroupRegistrationState, +} from "../gated-command-group.js"; import { registerCodeFilesCommand } from "./files.js"; import { registerCodeGrepCommand } from "./grep.js"; import { registerCodeReadCommand } from "./read.js"; import { registerCodeSearchSymbolsCommand } from "./search-symbols.js"; -export interface CodeCommandGroupOptions { - codeNavigationUrl?: string; - overrideEnabled?: boolean; +export interface CodeCommandGroupOptions extends GatedCommandGroupOptions { capability?: CodeNavigationCapability; - expiredStoredAuth?: boolean; } /** @@ -28,26 +24,8 @@ export async function registerCodeCommandGroup( program: Command, options: CodeCommandGroupOptions = {}, ): Promise { - const codeNavigationUrl = options.codeNavigationUrl ?? getCodeNavigationUrl(); - if (!codeNavigationUrl) { - return; - } - - const overrideEnabled = - options.overrideEnabled ?? isCodeNavigationCliOverrideEnabled(); - const registrationState = - options.capability !== undefined || options.expiredStoredAuth !== undefined - ? { - capability: options.capability ?? "unknown", - expiredStoredAuth: options.expiredStoredAuth ?? false, - } - : await resolveStartupCodeNavigationRegistrationState(); - - if ( - !overrideEnabled && - registrationState.capability !== "enabled" && - !registrationState.expiredStoredAuth - ) { + const registration = await resolveGatedCommandGroupRegistrationState(options); + if (!registration.shouldRegister) { return; } diff --git a/src/commands/docs/index.test.ts b/src/commands/docs/index.test.ts new file mode 100644 index 00000000..666e75c0 --- /dev/null +++ b/src/commands/docs/index.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "bun:test"; +import { Command } from "commander"; +import { registerDocsCommandGroup } from "./index.js"; + +describe("registerDocsCommandGroup", () => { + it("does not register docs group without override or capability", async () => { + const program = new Command(); + await registerDocsCommandGroup(program, { + codeNavigationUrl: "https://pkgseer.dev", + overrideEnabled: false, + capability: "disabled", + }); + + expect(program.commands.some((command) => command.name() === "docs")).toBe( + false, + ); + }); + + it("registers docs group when capability is enabled", async () => { + const program = new Command(); + await registerDocsCommandGroup(program, { + codeNavigationUrl: "https://pkgseer.dev", + overrideEnabled: false, + capability: "enabled", + }); + + const docsCommand = program.commands.find( + (command) => command.name() === "docs", + ); + expect(docsCommand).toBeDefined(); + expect( + docsCommand?.commands.some((command) => command.name() === "list"), + ).toBe(true); + expect( + docsCommand?.commands.some((command) => command.name() === "read"), + ).toBe(true); + }); +}); diff --git a/src/commands/docs/index.ts b/src/commands/docs/index.ts new file mode 100644 index 00000000..19474c97 --- /dev/null +++ b/src/commands/docs/index.ts @@ -0,0 +1,32 @@ +import type { Command } from "commander"; +import type { CodeNavigationCapability } from "../../services/index.js"; +import { + type GatedCommandGroupOptions, + resolveGatedCommandGroupRegistrationState, +} from "../gated-command-group.js"; +import { registerDocsListCommand } from "./list.js"; +import { registerDocsReadCommand } from "./read.js"; + +export interface DocsCommandGroupOptions extends GatedCommandGroupOptions { + capability?: CodeNavigationCapability; +} + +export async function registerDocsCommandGroup( + program: Command, + options: DocsCommandGroupOptions = {}, +): Promise { + const registration = await resolveGatedCommandGroupRegistrationState(options); + if (!registration.shouldRegister) { + return; + } + + const docsCommand = program + .command("docs") + .summary("Browse and read mixed package documentation") + .description( + "Browse and read package documentation across hosted docs and repository-backed docs. Docs are mixed by default; entries are source-badged and repo-backed pages also expose exact file follow-up metadata.", + ); + + registerDocsListCommand(docsCommand); + registerDocsReadCommand(docsCommand); +} diff --git a/src/commands/docs/list.test.ts b/src/commands/docs/list.test.ts new file mode 100644 index 00000000..b1988c63 --- /dev/null +++ b/src/commands/docs/list.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it, mock, spyOn } from "bun:test"; +import { PackageIntelligenceTargetNotFoundError } from "../../services/index.js"; +import { + createMockPackageIntelligenceService, + defaultPackageDocsList, +} from "../../services/test-helpers.js"; +import { AuthRequiredError } from "../../shared/require-auth.js"; +import { type DocsListCommandDependencies, docsListAction } from "./list.js"; + +describe("docsListAction", () => { + function createDeps( + overrides: Partial = {}, + ): DocsListCommandDependencies { + return { + packageIntelligenceService: createMockPackageIntelligenceService(), + codeNavigationUrl: "https://pkgseer.dev", + hasValidToken: true, + mcpUrl: "https://mcp.githits.com", + ...overrides, + }; + } + + it("renders source badges and page IDs in terminal output", async () => { + const writes: string[] = []; + const writeSpy = spyOn(process.stdout, "write").mockImplementation((( + chunk: string | Uint8Array, + ) => { + writes.push( + typeof chunk === "string" ? chunk : new TextDecoder().decode(chunk), + ); + return true; + }) as typeof process.stdout.write); + + await docsListAction("npm:express@5.2.1", {}, createDeps()); + + const output = writes.join(""); + expect(output).toContain("123-getting-started"); + expect(output).toContain("[crawled]"); + expect(output).toContain("[repo]"); + writeSpy.mockRestore(); + }); + + it("prints the lean JSON envelope when --json is provided", async () => { + const logSpy = spyOn(console, "log").mockImplementation(() => {}); + + await docsListAction("npm:express@5.2.1", { json: true }, createDeps()); + + const payload = JSON.parse(String(logSpy.mock.calls[0]?.[0])); + expect(payload.name).toBe("express"); + expect(payload.pages[0].followUp.pageId).toBe("123-getting-started"); + expect(payload.pages[1].readFile.path).toBe("README.md"); + logSpy.mockRestore(); + }); + + it("routes service classification through --json error envelope", async () => { + const errorSpy = spyOn(console, "error").mockImplementation(() => {}); + const exitSpy = spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit"); + }); + + const service = createMockPackageIntelligenceService({ + listPackageDocs: mock(() => + Promise.reject( + new PackageIntelligenceTargetNotFoundError("Package not found"), + ), + ), + }); + + try { + await docsListAction( + "npm:ghost", + { json: true }, + createDeps({ packageIntelligenceService: service }), + ); + } catch { + // expected + } + + const payload = JSON.parse(String(errorSpy.mock.calls[0]?.[0])); + expect(payload.code).toBe("NOT_FOUND"); + expect(payload.error).toBe("Package not found"); + errorSpy.mockRestore(); + exitSpy.mockRestore(); + }); + + it("throws AuthRequiredError before calling the service when unauthenticated", async () => { + const listPackageDocs = mock(() => Promise.resolve(defaultPackageDocsList)); + const service = createMockPackageIntelligenceService({ listPackageDocs }); + + await expect( + docsListAction( + "npm:express", + {}, + createDeps({ + packageIntelligenceService: service, + hasValidToken: false, + }), + ), + ).rejects.toThrow(AuthRequiredError); + + expect(listPackageDocs).not.toHaveBeenCalled(); + }); +}); diff --git a/src/commands/docs/list.ts b/src/commands/docs/list.ts new file mode 100644 index 00000000..8fb5c71f --- /dev/null +++ b/src/commands/docs/list.ts @@ -0,0 +1,135 @@ +import type { Command } from "commander"; +import { createContainer } from "../../container.js"; +import type { PackageIntelligenceService } from "../../services/index.js"; +import { shouldUseColors } from "../../shared/colors.js"; +import { + buildListPackageDocsParams, + buildListPackageDocsSuccessPayload, + formatListPackageDocsTerminal, + InvalidPackageSpecError, + mapPackageIntelligenceError, + parsePackageSpec, + requireAuth, +} from "../../shared/index.js"; + +export interface DocsListCommandOptions { + limit?: string; + after?: string; + verbose?: boolean; + json?: boolean; +} + +export interface DocsListCommandDependencies { + packageIntelligenceService: PackageIntelligenceService | undefined; + codeNavigationUrl: string | undefined; + hasValidToken: boolean; + mcpUrl: string; +} + +export async function docsListAction( + spec: string, + options: DocsListCommandOptions, + deps: DocsListCommandDependencies, +): Promise { + requireAuth(deps); + + try { + if (!deps.codeNavigationUrl || !deps.packageIntelligenceService) { + throw new InvalidPackageSpecError( + "Package intelligence is not configured for this environment.", + ); + } + + const parsed = parsePackageSpec(spec); + const limit = parseLimitOption(options.limit); + const build = buildListPackageDocsParams({ + registry: parsed.registry, + packageName: parsed.name, + version: parsed.version, + limit, + after: options.after, + }); + const result = await deps.packageIntelligenceService.listPackageDocs( + build.params, + ); + const payload = buildListPackageDocsSuccessPayload(result, { + limitExplicit: build.limitExplicit, + afterExplicit: build.afterExplicit, + limit: build.params.limit, + after: build.params.after, + }); + + if (options.json) { + console.log(JSON.stringify(payload)); + return; + } + + process.stdout.write( + formatListPackageDocsTerminal(payload, { + verbose: options.verbose ?? false, + useColors: shouldUseColors(), + }), + ); + } catch (error) { + handleDocsListError(error, options.json ?? false); + } +} + +function parseLimitOption(value: string | undefined): number | undefined { + if (value === undefined) return undefined; + const parsed = Number(value); + if (!Number.isInteger(parsed) || parsed < 1 || parsed > 500) { + throw new InvalidPackageSpecError( + "--limit must be an integer between 1 and 500.", + ); + } + return parsed; +} + +function handleDocsListError(error: unknown, json: boolean): never { + const mapped = mapPackageIntelligenceError(error); + + if (json) { + console.error( + JSON.stringify({ + error: mapped.message, + code: mapped.code, + retryable: mapped.retryable ?? false, + ...(mapped.details ? { details: mapped.details } : {}), + }), + ); + } else { + console.error(mapped.message); + } + + process.exit(1); +} + +const DOCS_LIST_DESCRIPTION = `List package documentation pages from mixed sources. + +Docs are mixed by default: hosted/crawled docs and repository-backed docs +appear together. Every entry shows its page ID, source badge, and source +location. Repo-backed docs also carry exact file follow-up metadata in JSON. + +Package spec: :[@version].`; + +export function registerDocsListCommand(docsCommand: Command): Command { + return docsCommand + .command("list") + .summary("List documentation pages for a package") + .description(DOCS_LIST_DESCRIPTION) + .argument("", "Package spec, e.g. npm:express@5.2.1") + .option("--limit ", "Max pages (1-500, default 100)") + .option("--after ", "Pagination cursor from a prior response") + .option("-v, --verbose", "Show updated timestamps when available") + .option("--json", "Emit the JSON envelope") + .action(async (spec: string, options: DocsListCommandOptions) => { + const deps = await createContainer(); + await docsListAction(spec, options, { + packageIntelligenceService: deps.packageIntelligenceService, + codeNavigationUrl: deps.codeNavigationUrl, + hasValidToken: deps.hasValidToken, + mcpUrl: deps.mcpUrl, + }); + }); +} diff --git a/src/commands/docs/read.test.ts b/src/commands/docs/read.test.ts new file mode 100644 index 00000000..ac2b3109 --- /dev/null +++ b/src/commands/docs/read.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it, mock, spyOn } from "bun:test"; +import { PackageIntelligenceTargetNotFoundError } from "../../services/index.js"; +import { + createMockPackageIntelligenceService, + defaultPackageDocResult, +} from "../../services/test-helpers.js"; +import { AuthRequiredError } from "../../shared/require-auth.js"; +import { type DocsReadCommandDependencies, docsReadAction } from "./read.js"; + +describe("docsReadAction", () => { + function createDeps( + overrides: Partial = {}, + ): DocsReadCommandDependencies { + return { + packageIntelligenceService: createMockPackageIntelligenceService(), + codeNavigationUrl: "https://pkgseer.dev", + hasValidToken: true, + mcpUrl: "https://mcp.githits.com", + ...overrides, + }; + } + + it("renders raw content by default", async () => { + const writes: string[] = []; + const writeSpy = spyOn(process.stdout, "write").mockImplementation((( + chunk: string | Uint8Array, + ) => { + writes.push( + typeof chunk === "string" ? chunk : new TextDecoder().decode(chunk), + ); + return true; + }) as typeof process.stdout.write); + + await docsReadAction( + "github:expressjs/express@abc123/README.md", + {}, + createDeps(), + ); + + expect(writes.join("")).toContain("# Express"); + writeSpy.mockRestore(); + }); + + it("prints the lean JSON envelope when --json is provided", async () => { + const logSpy = spyOn(console, "log").mockImplementation(() => {}); + + await docsReadAction( + "github:expressjs/express@abc123/README.md", + { json: true }, + createDeps(), + ); + + const payload = JSON.parse(String(logSpy.mock.calls[0]?.[0])); + expect(payload.pageId).toBe("github:expressjs/express@abc123/README.md"); + expect(payload.readFile.path).toBe("README.md"); + logSpy.mockRestore(); + }); + + it("routes service classification through --json error envelope", async () => { + const errorSpy = spyOn(console, "error").mockImplementation(() => {}); + const exitSpy = spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit"); + }); + + const service = createMockPackageIntelligenceService({ + readPackageDoc: mock(() => + Promise.reject( + new PackageIntelligenceTargetNotFoundError("Doc page not found"), + ), + ), + }); + + try { + await docsReadAction( + "missing-page", + { json: true }, + createDeps({ packageIntelligenceService: service }), + ); + } catch { + // expected + } + + const payload = JSON.parse(String(errorSpy.mock.calls[0]?.[0])); + expect(payload.code).toBe("NOT_FOUND"); + expect(payload.error).toBe("Doc page not found"); + errorSpy.mockRestore(); + exitSpy.mockRestore(); + }); + + it("throws AuthRequiredError before calling the service when unauthenticated", async () => { + const readPackageDoc = mock(() => Promise.resolve(defaultPackageDocResult)); + const service = createMockPackageIntelligenceService({ readPackageDoc }); + + await expect( + docsReadAction( + "github:expressjs/express@abc123/README.md", + {}, + createDeps({ + packageIntelligenceService: service, + hasValidToken: false, + }), + ), + ).rejects.toThrow(AuthRequiredError); + + expect(readPackageDoc).not.toHaveBeenCalled(); + }); +}); diff --git a/src/commands/docs/read.ts b/src/commands/docs/read.ts new file mode 100644 index 00000000..459d0f9f --- /dev/null +++ b/src/commands/docs/read.ts @@ -0,0 +1,108 @@ +import type { Command } from "commander"; +import { createContainer } from "../../container.js"; +import type { PackageIntelligenceService } from "../../services/index.js"; +import { + buildReadPackageDocParams, + buildReadPackageDocSuccessPayload, + formatReadPackageDocTerminal, + InvalidPackageSpecError, + mapPackageIntelligenceError, + requireAuth, + shouldUseColors, +} from "../../shared/index.js"; + +export interface DocsReadCommandOptions { + verbose?: boolean; + json?: boolean; +} + +export interface DocsReadCommandDependencies { + packageIntelligenceService: PackageIntelligenceService | undefined; + codeNavigationUrl: string | undefined; + hasValidToken: boolean; + mcpUrl: string; +} + +export async function docsReadAction( + pageId: string, + options: DocsReadCommandOptions, + deps: DocsReadCommandDependencies, +): Promise { + requireAuth(deps); + + try { + if (!deps.codeNavigationUrl || !deps.packageIntelligenceService) { + throw new InvalidPackageSpecError( + "Package intelligence is not configured for this environment.", + ); + } + + const build = buildReadPackageDocParams({ pageId }); + const result = await deps.packageIntelligenceService.readPackageDoc( + build.params, + ); + const payload = buildReadPackageDocSuccessPayload( + result, + build.params.pageId, + ); + + if (options.json) { + console.log(JSON.stringify(payload)); + return; + } + + process.stdout.write( + formatReadPackageDocTerminal(payload, { + verbose: options.verbose ?? false, + useColors: shouldUseColors(), + }), + ); + } catch (error) { + handleDocsReadError(error, options.json ?? false); + } +} + +function handleDocsReadError(error: unknown, json: boolean): never { + const mapped = mapPackageIntelligenceError(error); + + if (json) { + console.error( + JSON.stringify({ + error: mapped.message, + code: mapped.code, + retryable: mapped.retryable ?? false, + ...(mapped.details ? { details: mapped.details } : {}), + }), + ); + } else { + console.error(mapped.message); + } + + process.exit(1); +} + +const DOCS_READ_DESCRIPTION = `Read a documentation page by page ID. + +Use page IDs from githits docs list, githits search --json, or MCP doc/search +results. Default output is content-only for easy piping; pass --verbose for a +metadata header. Repo-backed pages also expose exact file follow-up metadata in +JSON.`; + +export function registerDocsReadCommand(docsCommand: Command): Command { + return docsCommand + .command("read") + .summary("Read a documentation page by page ID") + .description(DOCS_READ_DESCRIPTION) + .argument("", "Documentation page ID from docs/search results") + .option("-v, --verbose", "Show metadata header before content") + .option("--json", "Emit the JSON envelope") + .action(async (pageId: string, options: DocsReadCommandOptions) => { + const deps = await createContainer(); + await docsReadAction(pageId, options, { + packageIntelligenceService: deps.packageIntelligenceService, + codeNavigationUrl: deps.codeNavigationUrl, + hasValidToken: deps.hasValidToken, + mcpUrl: deps.mcpUrl, + }); + }); +} diff --git a/src/commands/gated-command-group.ts b/src/commands/gated-command-group.ts new file mode 100644 index 00000000..870404e4 --- /dev/null +++ b/src/commands/gated-command-group.ts @@ -0,0 +1,49 @@ +import { resolveStartupCodeNavigationRegistrationState } from "../container.js"; +import { + type CodeNavigationCapability, + getCodeNavigationUrl, + getEnvApiToken, + isCodeNavigationCliOverrideEnabled, +} from "../services/index.js"; + +export interface GatedCommandGroupOptions { + codeNavigationUrl?: string; + overrideEnabled?: boolean; + capability?: CodeNavigationCapability; + envTokenPresent?: boolean; + expiredStoredAuth?: boolean; +} + +export interface GatedCommandGroupRegistrationState { + codeNavigationUrl: string | undefined; + shouldRegister: boolean; +} + +export async function resolveGatedCommandGroupRegistrationState( + options: GatedCommandGroupOptions = {}, +): Promise { + const codeNavigationUrl = options.codeNavigationUrl ?? getCodeNavigationUrl(); + if (!codeNavigationUrl) { + return { codeNavigationUrl: undefined, shouldRegister: false }; + } + + const overrideEnabled = + options.overrideEnabled ?? isCodeNavigationCliOverrideEnabled(); + const registrationState = + options.capability !== undefined || options.expiredStoredAuth !== undefined + ? { + capability: options.capability ?? "unknown", + expiredStoredAuth: options.expiredStoredAuth ?? false, + } + : await resolveStartupCodeNavigationRegistrationState(); + const envTokenPresent = options.envTokenPresent ?? Boolean(getEnvApiToken()); + + return { + codeNavigationUrl, + shouldRegister: + overrideEnabled || + registrationState.capability === "enabled" || + envTokenPresent || + registrationState.expiredStoredAuth, + }; +} diff --git a/src/commands/index.ts b/src/commands/index.ts index bce0fc9a..d59f5c91 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -4,6 +4,13 @@ export { registerAuthStatusCommand, } from "./auth-status.js"; export { registerCodeCommandGroup } from "./code/index.js"; +export { registerDocsCommandGroup } from "./docs/index.js"; +export { + type ExampleDependencies, + type ExampleOptions, + exampleAction, + registerExampleCommand, +} from "./example.js"; export { type FeedbackDependencies, type FeedbackOptions, @@ -33,17 +40,8 @@ export { logoutAction, registerLogoutCommand, } from "./logout.js"; - export { createMcpServer, registerMcpCommand } from "./mcp.js"; - export { registerPkgCommandGroup } from "./pkg/index.js"; - -export { - type ExampleDependencies, - type ExampleOptions, - exampleAction, - registerExampleCommand, -} from "./example.js"; export { registerSearchCommand, registerUnifiedSearchCommands, diff --git a/src/commands/mcp-instructions.test.ts b/src/commands/mcp-instructions.test.ts index e22fe9b1..dce157d5 100644 --- a/src/commands/mcp-instructions.test.ts +++ b/src/commands/mcp-instructions.test.ts @@ -51,6 +51,8 @@ const KNOWN_TOOLS = [ "list_files", "read_file", "grep_repo", + "list_package_docs", + "read_package_doc", "package_summary", "package_vulnerabilities", "package_dependencies", @@ -82,12 +84,20 @@ describe("isPackageToolsCapabilityOpen", () => { expect(isPackageToolsCapabilityOpen(deps)).toBe(false); }); - it("is false when capability unknown even if an env token is present", () => { + it("is true when local override is enabled", () => { + const deps = createTestDeps({ + codeNavigationCapability: "disabled", + codeNavigationCliOverrideEnabled: true, + }); + expect(isPackageToolsCapabilityOpen(deps)).toBe(true); + }); + + it("is true when capability unknown but env token provides opaque grant", () => { const deps = createTestDeps({ codeNavigationCapability: "unknown", envApiToken: "ghi-opaque-token", }); - expect(isPackageToolsCapabilityOpen(deps)).toBe(false); + expect(isPackageToolsCapabilityOpen(deps)).toBe(true); }); it("is false when capability unknown and no env token", () => { @@ -128,6 +138,8 @@ describe("buildMcpInstructions", () => { expect(instructions).toContain("GitHits surfaces verified"); expect(instructions).toContain("Package tools"); expect(instructions).toContain("`package_summary`"); + expect(instructions).toContain("`list_package_docs`"); + expect(instructions).toContain("`read_package_doc`"); expect(instructions).toContain("`package_vulnerabilities`"); expect(instructions).toContain("`package_dependencies`"); expect(instructions).toContain("`package_changelog`"); @@ -187,6 +199,8 @@ describe("buildMcpInstructions", () => { expect(instructions).toContain("Package tools"); expect(instructions).toContain("`package_summary`"); + expect(instructions).toContain("`list_package_docs`"); + expect(instructions).toContain("`read_package_doc`"); expect(instructions).toContain("`package_vulnerabilities`"); expect(instructions).toContain("`package_dependencies`"); expect(instructions).toContain("`package_changelog`"); @@ -210,6 +224,14 @@ describe("buildMcpInstructions", () => { codeNavigationService: createMockCodeNavigationService(), }, }, + { + label: "override enabled, both services wired", + overrides: { + codeNavigationCliOverrideEnabled: true, + codeNavigationService: createMockCodeNavigationService(), + packageIntelligenceService: createMockPackageIntelligenceService(), + }, + }, { label: "gate open, only package intelligence service", overrides: { diff --git a/src/commands/mcp-instructions.ts b/src/commands/mcp-instructions.ts index 0395da49..66863f43 100644 --- a/src/commands/mcp-instructions.ts +++ b/src/commands/mcp-instructions.ts @@ -26,6 +26,12 @@ Package spec: \`registry:name[@version]\`.`; const PACKAGE_SUMMARY_BULLET = "- `package_summary` — instant package overview: latest version, license, downloads, quickstart, and active advisory count."; +const LIST_PACKAGE_DOCS_BULLET = + "- `list_package_docs` — browse mixed package documentation pages from hosted docs and repository-backed docs. Each entry includes a stable pageId, source kind, source URL, and for repo docs exact file follow-up metadata."; + +const READ_PACKAGE_DOC_BULLET = + "- `read_package_doc` — read a documentation page by pageId. Works for both hosted docs and repo-backed docs. Repo-backed results additionally expose exact file follow-up metadata."; + const PACKAGE_VULNERABILITIES_BULLET = "- `package_vulnerabilities` — known CVE / OSV advisories for npm, PyPI, Hex, or Crates packages (optionally pinned to `@version`). Malicious-package advisories surface in a disjoint `malware` bucket; filter with `min_severity` or include retracted advisories with `include_withdrawn`."; @@ -65,7 +71,12 @@ const SEARCH_VS_SYMBOLS_TIP = * documented. */ export function isPackageToolsCapabilityOpen(deps: Dependencies): boolean { - return deps.codeNavigationCapability === "enabled"; + return ( + deps.codeNavigationCliOverrideEnabled || + deps.codeNavigationCapability === "enabled" || + (deps.codeNavigationCapability === "unknown" && + deps.envApiToken !== undefined) + ); } /** @@ -86,6 +97,8 @@ export function buildMcpInstructions(deps: Dependencies): string { const bullets: string[] = []; if (deps.packageIntelligenceService) { + bullets.push(LIST_PACKAGE_DOCS_BULLET); + bullets.push(READ_PACKAGE_DOC_BULLET); bullets.push(PACKAGE_SUMMARY_BULLET); bullets.push(PACKAGE_VULNERABILITIES_BULLET); bullets.push(PACKAGE_DEPENDENCIES_BULLET); diff --git a/src/commands/mcp.test.ts b/src/commands/mcp.test.ts index 8049630d..8822bc8c 100644 --- a/src/commands/mcp.test.ts +++ b/src/commands/mcp.test.ts @@ -107,6 +107,8 @@ describe("createMcpServer", () => { }); const tools = getMcpToolDefinitions(deps); + expect(tools.map((tool) => tool.name)).toContain("list_package_docs"); + expect(tools.map((tool) => tool.name)).toContain("read_package_doc"); expect(tools.map((tool) => tool.name)).toContain("package_summary"); }); @@ -144,6 +146,20 @@ describe("createMcpServer", () => { expect(tools.some((tool) => tool.name === "package_summary")).toBe(false); }); + it("adds package and code-nav tools when local override is enabled", () => { + const deps = createTestDeps({ + codeNavigationCliOverrideEnabled: true, + codeNavigationUrl: "https://pkgseer.dev", + codeNavigationService: createMockCodeNavigationService(), + packageIntelligenceService: createMockPackageIntelligenceService(), + }); + + const names = getMcpToolDefinitions(deps).map((tool) => tool.name); + expect(names).toContain("search"); + expect(names).toContain("list_package_docs"); + expect(names).toContain("read_package_doc"); + }); + it("preserves half-open invariant: whenever package_summary is advertised, unified search is too (enabled path)", () => { const deps = createTestDeps({ codeNavigationCapability: "enabled", diff --git a/src/commands/mcp.ts b/src/commands/mcp.ts index 7d5ac4ac..28bc67a6 100644 --- a/src/commands/mcp.ts +++ b/src/commands/mcp.ts @@ -13,11 +13,13 @@ import { createGetExampleTool, createGrepRepoTool, createListFilesTool, + createListPackageDocsTool, createPackageChangelogTool, createPackageDependenciesTool, createPackageSummaryTool, createPackageVulnerabilitiesTool, createReadFileTool, + createReadPackageDocTool, createSearchLanguageTool, createSearchStatusTool, createSearchTool, @@ -51,6 +53,8 @@ export function getMcpToolDefinitions( } if (gateOpen && deps.packageIntelligenceService) { + tools.push(createListPackageDocsTool(deps.packageIntelligenceService)); + tools.push(createReadPackageDocTool(deps.packageIntelligenceService)); tools.push(createPackageSummaryTool(deps.packageIntelligenceService)); tools.push( createPackageVulnerabilitiesTool(deps.packageIntelligenceService), diff --git a/src/commands/pkg/index.ts b/src/commands/pkg/index.ts index f6fee498..0a4a1ace 100644 --- a/src/commands/pkg/index.ts +++ b/src/commands/pkg/index.ts @@ -1,20 +1,16 @@ import type { Command } from "commander"; -import { resolveStartupCodeNavigationRegistrationState } from "../../container.js"; +import type { CodeNavigationCapability } from "../../services/index.js"; import { - type CodeNavigationCapability, - getCodeNavigationUrl, - isCodeNavigationCliOverrideEnabled, -} from "../../services/index.js"; + type GatedCommandGroupOptions, + resolveGatedCommandGroupRegistrationState, +} from "../gated-command-group.js"; import { registerPkgChangelogCommand } from "./changelog.js"; import { registerPkgDepsCommand } from "./deps.js"; import { registerPkgInfoCommand } from "./info.js"; import { registerPkgVulnsCommand } from "./vulns.js"; -export interface PkgCommandGroupOptions { - codeNavigationUrl?: string; - overrideEnabled?: boolean; +export interface PkgCommandGroupOptions extends GatedCommandGroupOptions { capability?: CodeNavigationCapability; - expiredStoredAuth?: boolean; } /** @@ -38,26 +34,8 @@ export async function registerPkgCommandGroup( program: Command, options: PkgCommandGroupOptions = {}, ): Promise { - const codeNavigationUrl = options.codeNavigationUrl ?? getCodeNavigationUrl(); - if (!codeNavigationUrl) { - return; - } - - const overrideEnabled = - options.overrideEnabled ?? isCodeNavigationCliOverrideEnabled(); - const registrationState = - options.capability !== undefined || options.expiredStoredAuth !== undefined - ? { - capability: options.capability ?? "unknown", - expiredStoredAuth: options.expiredStoredAuth ?? false, - } - : await resolveStartupCodeNavigationRegistrationState(); - - if ( - !overrideEnabled && - registrationState.capability !== "enabled" && - !registrationState.expiredStoredAuth - ) { + const registration = await resolveGatedCommandGroupRegistrationState(options); + if (!registration.shouldRegister) { return; } diff --git a/src/commands/search.test.ts b/src/commands/search.test.ts index 6ca3132e..8f41fd6d 100644 --- a/src/commands/search.test.ts +++ b/src/commands/search.test.ts @@ -449,7 +449,7 @@ describe("searchAction", () => { } }); - it("omits doc-fetch placeholder line for documentation pages", async () => { + it("shows pageId and source info for documentation pages", async () => { const consoleSpy = spyOn(console, "log").mockImplementation(() => {}); if (defaultUnifiedSearchOutcome.state !== "completed") { @@ -471,6 +471,8 @@ describe("searchAction", () => { packageName: "express", version: "5.2.1", pageId: "docs-123", + sourceKind: "CRAWLED", + sourceUrl: "https://hexdocs.pm/express/getting-started.html", }, }, ], @@ -491,8 +493,9 @@ describe("searchAction", () => { expect(output).toContain( "npm:express@4.18.2 [docs page] - Using Express middleware", ); - expect(output).not.toContain("Full doc fetch"); - expect(output).not.toContain("pageId="); + expect(output).toContain("pageId:"); + expect(output).toContain("docs-123"); + expect(output).toContain("[crawled]"); consoleSpy.mockRestore(); }); }); diff --git a/src/commands/search.ts b/src/commands/search.ts index 2a856aa7..d111c9b6 100644 --- a/src/commands/search.ts +++ b/src/commands/search.ts @@ -442,7 +442,10 @@ function formatUnifiedSearchTerminal(payload: { version?: string; repoUrl?: string; gitRef?: string; + requestedRef?: string; pageId?: string; + sourceKind?: string; + sourceUrl?: string; filePath?: string; startLine?: number; endLine?: number; @@ -510,6 +513,10 @@ function formatUnifiedSearchTerminal(payload: { const location = formatUnifiedSearchLocation(entry.locator); const header = formatUnifiedSearchHeader(entry, useColors, location); lines.push(header); + const metadata = formatUnifiedSearchMetadata(entry, useColors); + if (metadata.length > 0) { + lines.push(...metadata); + } if (entry.summary) { lines.push( ...formatUnifiedSearchSummary( @@ -604,7 +611,16 @@ function formatSearchStatusCompletedTerminal(payload: { title?: Array; summary?: Array; }; - locator: { filePath?: string; startLine?: number; endLine?: number }; + locator: { + filePath?: string; + gitRef?: string; + startLine?: number; + endLine?: number; + pageId?: string; + sourceKind?: string; + sourceUrl?: string; + requestedRef?: string; + }; }>; }; }): string { @@ -719,6 +735,7 @@ function dedupeSearchResultsForDisplay< target: string; title?: string; summary?: string; + locator: { pageId?: string; filePath?: string }; }, >(results: T[]): { display: T[]; duplicatesFolded: number } { const seen = new Set(); @@ -731,11 +748,12 @@ function dedupeSearchResultsForDisplay< entry.title ?? "", (entry.summary ?? "").slice(0, 120), ].join(""); - if (seen.has(key)) { + const dedupeKey = `${key}\u0001${entry.locator.pageId ?? entry.locator.filePath ?? ""}`; + if (seen.has(dedupeKey)) { duplicatesFolded += 1; continue; } - seen.add(key); + seen.add(dedupeKey); display.push(entry); } return { display, duplicatesFolded }; @@ -814,9 +832,10 @@ function formatUnifiedSearchLocation(locator: { filePath?: string; startLine?: number; endLine?: number; + sourceUrl?: string; }): string | undefined { if (!locator.filePath) { - return undefined; + return locator.sourceUrl; } if (!locator.startLine) { @@ -836,16 +855,69 @@ function formatUnifiedSearchHeader( startLine?: number; endLine?: number; pageId?: string; + sourceKind?: string; + sourceUrl?: string; + requestedRef?: string; }; title?: string; }, useColors: boolean, location: string | undefined, ): string { - const primary = location ? `${entry.target} ${location}` : entry.target; + const primary = + entry.type === "documentation_page" + ? entry.target + : location + ? `${entry.target} ${location}` + : entry.target; const badge = `[${formatUnifiedSearchResultLabel(entry.type)}]`; const title = entry.title ? highlightRanges(entry.title, entry.highlights?.title, useColors) : undefined; return `${highlight(primary, useColors)} ${dim(badge, useColors)}${title ? ` - ${title}` : ""}`; } + +function formatUnifiedSearchMetadata( + entry: { + type: string; + locator: { + pageId?: string; + sourceKind?: string; + sourceUrl?: string; + filePath?: string; + gitRef?: string; + requestedRef?: string; + }; + }, + useColors: boolean, +): string[] { + if (entry.type !== "documentation_page" && entry.type !== "repository_doc") { + return []; + } + + const lines: string[] = []; + if (entry.locator.pageId) { + lines.push(` ${dim("pageId:", useColors)} ${entry.locator.pageId}`); + } + + const sourceBadge = + entry.locator.sourceKind?.toLowerCase() === "repository" + ? "[repo]" + : entry.locator.sourceKind?.toLowerCase() === "crawled" + ? "[crawled]" + : undefined; + if (entry.locator.sourceUrl) { + lines.push( + ` ${dim("source:", useColors)} ${sourceBadge ? `${sourceBadge} ` : ""}${entry.locator.sourceUrl}`, + ); + } + + if (entry.type === "repository_doc" && entry.locator.filePath) { + const ref = entry.locator.requestedRef ?? entry.locator.gitRef; + lines.push( + ` ${dim("file:", useColors)} ${entry.locator.filePath}${ref ? ` @ ${ref}` : ""}`, + ); + } + + return lines; +} diff --git a/src/services/code-navigation-service.ts b/src/services/code-navigation-service.ts index 6c7977af..88bd2917 100644 --- a/src/services/code-navigation-service.ts +++ b/src/services/code-navigation-service.ts @@ -205,8 +205,11 @@ export interface UnifiedSearchLocator { packageName?: string; version?: string; pageId?: string; + sourceKind?: string; + sourceUrl?: string; repoUrl?: string; gitRef?: string; + requestedRef?: string; filePath?: string; startLine?: number; endLine?: number; @@ -712,8 +715,11 @@ query UnifiedSearch( packageName version pageId + sourceKind + sourceUrl repoUrl gitRef + requestedRef filePath startLine endLine @@ -793,8 +799,11 @@ query UnifiedSearchStatus($searchRef: String!, $includeResults: Boolean!) { packageName version pageId + sourceKind + sourceUrl repoUrl gitRef + requestedRef filePath startLine endLine @@ -1014,8 +1023,11 @@ const unifiedSearchLocatorSchema = z.object({ packageName: z.string().nullable().optional(), version: z.string().nullable().optional(), pageId: z.string().nullable().optional(), + sourceKind: z.string().nullable().optional(), + sourceUrl: z.string().nullable().optional(), repoUrl: z.string().nullable().optional(), gitRef: z.string().nullable().optional(), + requestedRef: z.string().nullable().optional(), filePath: z.string().nullable().optional(), startLine: z.number().int().nullable().optional(), endLine: z.number().int().nullable().optional(), @@ -2057,8 +2069,11 @@ export class CodeNavigationServiceImpl implements CodeNavigationService { packageName: entry.locator.packageName ?? undefined, version: entry.locator.version ?? undefined, pageId: entry.locator.pageId ?? undefined, + sourceKind: entry.locator.sourceKind ?? undefined, + sourceUrl: entry.locator.sourceUrl ?? undefined, repoUrl: entry.locator.repoUrl ?? undefined, gitRef: entry.locator.gitRef ?? undefined, + requestedRef: entry.locator.requestedRef ?? undefined, filePath: entry.locator.filePath ?? undefined, startLine: entry.locator.startLine ?? undefined, endLine: entry.locator.endLine ?? undefined, diff --git a/src/services/index.ts b/src/services/index.ts index 19d39878..1cb2fcf3 100644 --- a/src/services/index.ts +++ b/src/services/index.ts @@ -128,8 +128,15 @@ export type { EnvironmentMarker, GithubRepository, GroupDependency, + ListPackageDocsParams, PackageChangelogParams, PackageDependenciesParams, + PackageDocPage, + PackageDocPageSummary, + PackageDocResult, + PackageDocSource, + PackageDocSourceKind, + PackageDocsList, PackageIdentity, PackageIntelligenceService, PackageSecurityOverview, @@ -138,6 +145,7 @@ export type { PackageVersionIdentity, PackageVulnerabilitiesParams, QuickstartInfo, + ReadPackageDocParams, TransitiveDependencySummary, UntypedGenericJSON, VulnerabilityDetail, diff --git a/src/services/package-intelligence-service.ts b/src/services/package-intelligence-service.ts index 0b613bcf..38f92b60 100644 --- a/src/services/package-intelligence-service.ts +++ b/src/services/package-intelligence-service.ts @@ -353,6 +353,80 @@ export interface ChangelogReport { entries: ChangelogEntryDetail[]; } +export type PackageDocSourceKind = "CRAWLED" | "REPOSITORY"; + +export interface ListPackageDocsParams { + registry: PkgseerRegistry; + packageName: string; + version?: string; + limit?: number; + after?: string; +} + +export interface ReadPackageDocParams { + pageId: string; +} + +export interface PackageDocPageSummary { + id?: string; + title?: string; + slug?: string; + order?: number; + linkName?: string; + lastUpdatedAt?: string; + sourceKind?: PackageDocSourceKind; + sourceUrl?: string; + repoUrl?: string; + gitRef?: string; + requestedRef?: string; + filePath?: string; +} + +export interface PackageDocsPageInfo { + hasNextPage: boolean; + endCursor?: string; + totalCount?: number; +} + +export interface PackageDocsList { + registry?: string; + packageName?: string; + version?: string; + stale?: boolean; + pages: PackageDocPageSummary[]; + pageInfo?: PackageDocsPageInfo; +} + +export interface PackageDocSource { + url?: string; + label?: string; +} + +export interface PackageDocPage { + id?: string; + title?: string; + content?: string; + contentFormat?: string; + breadcrumbs?: string[]; + linkName?: string; + lastUpdatedAt?: string; + sourceKind?: PackageDocSourceKind; + source?: PackageDocSource; + repoUrl?: string; + gitRef?: string; + requestedRef?: string; + filePath?: string; + baseUrl?: string; +} + +export interface PackageDocResult { + registry?: string; + packageName?: string; + version?: string; + sourceKind?: PackageDocSourceKind; + page?: PackageDocPage; +} + export interface PackageIntelligenceService { packageSummary(params: PackageSummaryParams): Promise; packageVulnerabilities( @@ -362,6 +436,8 @@ export interface PackageIntelligenceService { params: PackageDependenciesParams, ): Promise; packageChangelog(params: PackageChangelogParams): Promise; + listPackageDocs(params: ListPackageDocsParams): Promise; + readPackageDoc(params: ReadPackageDocParams): Promise; } // -------------------------------------------------------------------- @@ -1000,6 +1076,171 @@ query PackageChangelog( } }`; +// -------------------------------------------------------------------- +// Zod schema + queries for package docs +// -------------------------------------------------------------------- + +const packageDocSourceKindSchema = z.enum(["CRAWLED", "REPOSITORY"]); + +const packageDocPageSummarySchema = z.object({ + id: z.string().nullable().optional(), + title: z.string().nullable().optional(), + slug: z.string().nullable().optional(), + order: z.number().int().nullable().optional(), + linkName: z.string().nullable().optional(), + lastUpdatedAt: z.string().nullable().optional(), + sourceKind: packageDocSourceKindSchema.nullable().optional(), + sourceUrl: z.string().nullable().optional(), + repoUrl: z.string().nullable().optional(), + gitRef: z.string().nullable().optional(), + requestedRef: z.string().nullable().optional(), + filePath: z.string().nullable().optional(), +}); + +const packageDocsPageInfoSchema = z + .object({ + hasNextPage: z.boolean(), + endCursor: z.string().nullable().optional(), + totalCount: z.number().int().nullable().optional(), + }) + .nullable() + .optional(); + +const packageDocsListResponseSchema = z.object({ + registry: z.string().nullable().optional(), + packageName: z.string().nullable().optional(), + version: z.string().nullable().optional(), + stale: z.boolean().nullable().optional(), + pages: z.array(packageDocPageSummarySchema).nullable().optional(), + pageInfo: packageDocsPageInfoSchema, +}); + +const packageDocSourceSchema = z + .object({ + url: z.string().nullable().optional(), + label: z.string().nullable().optional(), + }) + .nullable() + .optional(); + +const packageDocPageSchema = z + .object({ + id: z.string().nullable().optional(), + title: z.string().nullable().optional(), + content: z.string().nullable().optional(), + contentFormat: z.string().nullable().optional(), + breadcrumbs: z.array(z.string()).nullable().optional(), + linkName: z.string().nullable().optional(), + lastUpdatedAt: z.string().nullable().optional(), + sourceKind: packageDocSourceKindSchema.nullable().optional(), + source: packageDocSourceSchema, + repoUrl: z.string().nullable().optional(), + gitRef: z.string().nullable().optional(), + requestedRef: z.string().nullable().optional(), + filePath: z.string().nullable().optional(), + baseUrl: z.string().nullable().optional(), + }) + .nullable() + .optional(); + +const packageDocResultResponseSchema = z.object({ + registry: z.string().nullable().optional(), + packageName: z.string().nullable().optional(), + version: z.string().nullable().optional(), + sourceKind: packageDocSourceKindSchema.nullable().optional(), + page: packageDocPageSchema, +}); + +const packageDocsListGraphQLResponseSchema = z.object({ + data: z + .object({ + listPackageDocs: packageDocsListResponseSchema.nullable().optional(), + }) + .nullable() + .optional(), + errors: z.array(graphQLErrorSchema).optional(), +}); + +const packageDocReadGraphQLResponseSchema = z.object({ + data: z + .object({ + getDocPage: packageDocResultResponseSchema.nullable().optional(), + }) + .nullable() + .optional(), + errors: z.array(graphQLErrorSchema).optional(), +}); + +const LIST_PACKAGE_DOCS_QUERY = ` +query ListPackageDocs( + $registry: Registry! + $packageName: String! + $version: String + $limit: Int + $after: String +) { + listPackageDocs( + registry: $registry + packageName: $packageName + version: $version + limit: $limit + after: $after + ) { + registry + packageName + version + stale + pages { + id + title + slug + order + linkName + lastUpdatedAt + sourceKind + sourceUrl + repoUrl + gitRef + requestedRef + filePath + } + pageInfo { + hasNextPage + endCursor + totalCount + } + } +}`; + +const READ_PACKAGE_DOC_QUERY = ` +query ReadPackageDoc($pageId: String!) { + getDocPage(pageId: $pageId) { + registry + packageName + version + sourceKind + page { + id + title + content + contentFormat + breadcrumbs + linkName + lastUpdatedAt + sourceKind + source { + url + label + } + repoUrl + gitRef + requestedRef + filePath + baseUrl + } + } +}`; + // -------------------------------------------------------------------- // Service implementation // -------------------------------------------------------------------- @@ -1694,6 +1935,210 @@ export class PackageIntelligenceServiceImpl entries, }; } + + async listPackageDocs( + params: ListPackageDocsParams, + ): Promise { + return withTelemetrySpan("pkg-intel.docs.list", () => + executeWithTokenRefresh({ + getToken: () => this.tokenProvider.getToken(), + forceRefresh: () => this.tokenProvider.forceRefresh(), + shouldRefresh: (error) => error instanceof AuthenticationError, + executeWithToken: (token) => this.executeListPackageDocs(token, params), + }), + ); + } + + private async executeListPackageDocs( + token: string, + params: ListPackageDocsParams, + ): Promise { + let response: PkgseerGraphqlResponse; + try { + response = await postPkgseerGraphql({ + endpointUrl: this.endpointUrl, + token, + query: LIST_PACKAGE_DOCS_QUERY, + variables: { + registry: params.registry, + packageName: params.packageName, + version: params.version, + limit: params.limit, + after: params.after, + }, + fetchFn: this.fetchFn, + }); + } catch (cause) { + if (cause instanceof PkgseerTransportError) { + throw new PackageIntelligenceNetworkError( + "Could not reach the package intelligence service. Check your connection or set GITHITS_CODE_NAV_URL.", + { cause }, + ); + } + throw cause; + } + + if (response.status < 200 || response.status >= 300) { + throw this.createHttpError(response); + } + + const parsed = packageDocsListGraphQLResponseSchema.safeParse( + response.parsedBody, + ); + if (!parsed.success) { + throw new MalformedPackageIntelligenceResponseError( + "Malformed response from the package-intelligence service.", + ); + } + + if (parsed.data.errors && parsed.data.errors.length > 0) { + throw promoteGenericVersionNotFound( + this.createGraphQLError(parsed.data.errors), + params, + ); + } + + const data = parsed.data.data?.listPackageDocs; + if (!data) { + throw new MalformedPackageIntelligenceResponseError( + "Empty response from the package-intelligence service.", + ); + } + + return this.normalisePackageDocsList(data); + } + + private normalisePackageDocsList( + data: z.infer, + ): PackageDocsList { + return { + registry: data.registry ?? undefined, + packageName: data.packageName ?? undefined, + version: data.version ?? undefined, + stale: data.stale ?? undefined, + pages: + data.pages?.map((page) => ({ + id: page.id ?? undefined, + title: page.title ?? undefined, + slug: page.slug ?? undefined, + order: page.order ?? undefined, + linkName: page.linkName ?? undefined, + lastUpdatedAt: page.lastUpdatedAt ?? undefined, + sourceKind: page.sourceKind ?? undefined, + sourceUrl: page.sourceUrl ?? undefined, + repoUrl: page.repoUrl ?? undefined, + gitRef: page.gitRef ?? undefined, + requestedRef: page.requestedRef ?? undefined, + filePath: page.filePath ?? undefined, + })) ?? [], + pageInfo: data.pageInfo + ? { + hasNextPage: data.pageInfo.hasNextPage, + endCursor: data.pageInfo.endCursor ?? undefined, + totalCount: data.pageInfo.totalCount ?? undefined, + } + : undefined, + }; + } + + async readPackageDoc( + params: ReadPackageDocParams, + ): Promise { + return withTelemetrySpan("pkg-intel.docs.read", () => + executeWithTokenRefresh({ + getToken: () => this.tokenProvider.getToken(), + forceRefresh: () => this.tokenProvider.forceRefresh(), + shouldRefresh: (error) => error instanceof AuthenticationError, + executeWithToken: (token) => this.executeReadPackageDoc(token, params), + }), + ); + } + + private async executeReadPackageDoc( + token: string, + params: ReadPackageDocParams, + ): Promise { + let response: PkgseerGraphqlResponse; + try { + response = await postPkgseerGraphql({ + endpointUrl: this.endpointUrl, + token, + query: READ_PACKAGE_DOC_QUERY, + variables: { + pageId: params.pageId, + }, + fetchFn: this.fetchFn, + }); + } catch (cause) { + if (cause instanceof PkgseerTransportError) { + throw new PackageIntelligenceNetworkError( + "Could not reach the package intelligence service. Check your connection or set GITHITS_CODE_NAV_URL.", + { cause }, + ); + } + throw cause; + } + + if (response.status < 200 || response.status >= 300) { + throw this.createHttpError(response); + } + + const parsed = packageDocReadGraphQLResponseSchema.safeParse( + response.parsedBody, + ); + if (!parsed.success) { + throw new MalformedPackageIntelligenceResponseError( + "Malformed response from the package-intelligence service.", + ); + } + + if (parsed.data.errors && parsed.data.errors.length > 0) { + throw this.createGraphQLError(parsed.data.errors); + } + + const data = parsed.data.data?.getDocPage; + if (!data) { + throw new MalformedPackageIntelligenceResponseError( + "Empty response from the package-intelligence service.", + ); + } + + return this.normalisePackageDocResult(data); + } + + private normalisePackageDocResult( + data: z.infer, + ): PackageDocResult { + return { + registry: data.registry ?? undefined, + packageName: data.packageName ?? undefined, + version: data.version ?? undefined, + sourceKind: data.sourceKind ?? undefined, + page: data.page + ? { + id: data.page.id ?? undefined, + title: data.page.title ?? undefined, + content: data.page.content ?? undefined, + contentFormat: data.page.contentFormat ?? undefined, + breadcrumbs: data.page.breadcrumbs ?? undefined, + linkName: data.page.linkName ?? undefined, + lastUpdatedAt: data.page.lastUpdatedAt ?? undefined, + sourceKind: data.page.sourceKind ?? undefined, + source: data.page.source + ? { + url: data.page.source.url ?? undefined, + label: data.page.source.label ?? undefined, + } + : undefined, + repoUrl: data.page.repoUrl ?? undefined, + gitRef: data.page.gitRef ?? undefined, + requestedRef: data.page.requestedRef ?? undefined, + filePath: data.page.filePath ?? undefined, + baseUrl: data.page.baseUrl ?? undefined, + } + : undefined, + }; + } } function parseDetail(body: string): string | undefined { diff --git a/src/services/test-helpers.ts b/src/services/test-helpers.ts index c0b61e74..704f0deb 100644 --- a/src/services/test-helpers.ts +++ b/src/services/test-helpers.ts @@ -25,6 +25,8 @@ import type { KeyringService } from "./keyring-service.js"; import type { ChangelogReport, DependencyReport, + PackageDocResult, + PackageDocsList, PackageIntelligenceService, PackageSummary, VulnerabilityReport, @@ -679,6 +681,66 @@ export const defaultChangelogReport: ChangelogReport = { ], }; +export const defaultPackageDocsList: PackageDocsList = { + registry: "npm", + packageName: "express", + version: "5.2.1", + stale: false, + pages: [ + { + id: "123-getting-started", + title: "Getting Started", + slug: "getting-started", + order: 0, + linkName: "getting-started", + lastUpdatedAt: "2026-02-01T12:00:00Z", + sourceKind: "CRAWLED", + sourceUrl: "https://hexdocs.pm/express/getting-started.html", + }, + { + id: "github:expressjs/express@abc123/README.md", + title: "README.md", + slug: "github:expressjs/express@abc123/README.md", + order: 1, + sourceKind: "REPOSITORY", + sourceUrl: "https://github.com/expressjs/express/blob/abc123/README.md", + repoUrl: "https://github.com/expressjs/express", + gitRef: "abc123", + requestedRef: "v5.2.1", + filePath: "README.md", + }, + ], + pageInfo: { + hasNextPage: false, + totalCount: 2, + }, +}; + +export const defaultPackageDocResult: PackageDocResult = { + registry: "npm", + packageName: "express", + version: "5.2.1", + sourceKind: "REPOSITORY", + page: { + id: "github:expressjs/express@abc123/README.md", + title: "README.md", + content: "# Express\n\nFast, unopinionated web framework.", + contentFormat: "markdown", + breadcrumbs: ["README"], + lastUpdatedAt: "2026-02-01T12:00:00Z", + sourceKind: "REPOSITORY", + source: { + url: "https://github.com/expressjs/express/blob/abc123/README.md", + label: "README.md", + }, + repoUrl: "https://github.com/expressjs/express", + gitRef: "abc123", + requestedRef: "v5.2.1", + filePath: "README.md", + baseUrl: "https://github.com/expressjs/express/blob/abc123/README.md", + }, +}; + /** * Creates a mock PackageIntelligenceService. Defaults resolve to the * fully-populated fixtures; override per-test as needed. @@ -693,6 +755,8 @@ export function createMockPackageIntelligenceService( ), packageDependencies: mock(() => Promise.resolve(defaultDependencyReport)), packageChangelog: mock(() => Promise.resolve(defaultChangelogReport)), + listPackageDocs: mock(() => Promise.resolve(defaultPackageDocsList)), + readPackageDoc: mock(() => Promise.resolve(defaultPackageDocResult)), ...impl, }; } diff --git a/src/shared/docs-follow-up.ts b/src/shared/docs-follow-up.ts new file mode 100644 index 00000000..df38112d --- /dev/null +++ b/src/shared/docs-follow-up.ts @@ -0,0 +1,54 @@ +import type { + PackageDocPage, + PackageDocPageSummary, + PackageDocSourceKind, +} from "../services/index.js"; + +export type DocReadFollowUp = { + type: "read_doc"; + pageId: string; +}; + +export type FileReadFollowUp = { + type: "read_file"; + repoUrl: string; + gitRef: string; + path: string; +}; + +export function lowerDocSourceKind( + value: PackageDocSourceKind | undefined, +): "crawled" | "repo" | undefined { + switch (value) { + case "CRAWLED": + return "crawled"; + case "REPOSITORY": + return "repo"; + default: + return undefined; + } +} + +export function buildDocReadFollowUp( + pageId: string | undefined, +): DocReadFollowUp | undefined { + if (!pageId) return undefined; + return { type: "read_doc", pageId }; +} + +export function buildFileReadFollowUp( + entry: + | Pick + | Pick, +): FileReadFollowUp | undefined { + if (!entry.repoUrl || !entry.gitRef || !entry.filePath) { + return undefined; + } + + return { + type: "read_file", + repoUrl: entry.repoUrl, + gitRef: entry.gitRef, + path: entry.filePath, + }; +} diff --git a/src/shared/index.ts b/src/shared/index.ts index 3a6c4d40..f0877080 100644 --- a/src/shared/index.ts +++ b/src/shared/index.ts @@ -33,6 +33,13 @@ export { warning, } from "./colors.js"; export { debugLog } from "./debug-log.js"; +export { + buildDocReadFollowUp, + buildFileReadFollowUp, + type DocReadFollowUp, + type FileReadFollowUp, + lowerDocSourceKind, +} from "./docs-follow-up.js"; export { buildGrepRepoParams, GREP_REPO_PATTERN_NOTE, @@ -56,6 +63,17 @@ export { filterLanguages, type LanguageMatch, } from "./language-filter.js"; +export { + buildListPackageDocsParams, + type ListPackageDocsRequestBuildResult, + type ListPackageDocsRequestInput, +} from "./list-package-docs-request.js"; +export { + buildListPackageDocsSuccessPayload, + formatListPackageDocsTerminal, + type LeanPackageDocListEntry, + type LeanPackageDocsEnvelope, +} from "./list-package-docs-response.js"; export { InvalidKeywordsError, normaliseKeywords, @@ -130,6 +148,16 @@ export { toPkgseerRegistry, toPkgseerRegistryLowercase, } from "./pkgseer-registry.js"; +export { + buildReadPackageDocParams, + type ReadPackageDocRequestBuildResult, + type ReadPackageDocRequestInput, +} from "./read-package-doc-request.js"; +export { + buildReadPackageDocSuccessPayload, + formatReadPackageDocTerminal, + type LeanPackageDocEnvelope, +} from "./read-package-doc-response.js"; export { AuthRequiredError, requireAuth } from "./require-auth.js"; export { buildSearchSymbolsParams, diff --git a/src/shared/list-package-docs-request.ts b/src/shared/list-package-docs-request.ts new file mode 100644 index 00000000..78df4871 --- /dev/null +++ b/src/shared/list-package-docs-request.ts @@ -0,0 +1,70 @@ +import type { ListPackageDocsParams } from "../services/index.js"; +import { + InvalidPackageSpecError, + UnsupportedRegistryError, +} from "./package-spec.js"; +import { + isKnownPkgseerRegistryArg, + type PkgseerRegistryArg, + toPkgseerRegistry, +} from "./pkgseer-registry.js"; + +export interface ListPackageDocsRequestInput { + registry: string; + packageName: string; + version?: string; + limit?: number; + after?: string; +} + +export interface ListPackageDocsRequestBuildResult { + params: ListPackageDocsParams; + limitExplicit: boolean; + afterExplicit: boolean; +} + +export function buildListPackageDocsParams( + input: ListPackageDocsRequestInput, +): ListPackageDocsRequestBuildResult { + const packageName = input.packageName?.trim() ?? ""; + if (!packageName) { + throw new InvalidPackageSpecError("Package name is required."); + } + + const registry = input.registry?.trim().toLowerCase() ?? ""; + if (!isKnownPkgseerRegistryArg(registry)) { + throw new UnsupportedRegistryError( + `Unsupported registry '${input.registry}'. Supported: npm, pypi, hex, crates, nuget, maven, zig, vcpkg, packagist.`, + ); + } + + const params: ListPackageDocsParams = { + registry: toPkgseerRegistry(registry as PkgseerRegistryArg), + packageName, + }; + + const version = input.version?.trim(); + if (version) params.version = version; + + const after = input.after?.trim(); + if (after) params.after = after; + + if (input.limit !== undefined) { + if ( + !Number.isInteger(input.limit) || + input.limit < 1 || + input.limit > 500 + ) { + throw new InvalidPackageSpecError( + "Limit must be an integer between 1 and 500.", + ); + } + params.limit = input.limit; + } + + return { + params, + limitExplicit: input.limit !== undefined, + afterExplicit: Boolean(after), + }; +} diff --git a/src/shared/list-package-docs-response.ts b/src/shared/list-package-docs-response.ts new file mode 100644 index 00000000..82d2382f --- /dev/null +++ b/src/shared/list-package-docs-response.ts @@ -0,0 +1,196 @@ +import type { PackageDocsList } from "../services/index.js"; +import { MalformedPackageIntelligenceResponseError } from "../services/index.js"; +import { colorize, dim } from "./colors.js"; +import { + buildDocReadFollowUp, + buildFileReadFollowUp, + type DocReadFollowUp, + type FileReadFollowUp, + lowerDocSourceKind, +} from "./docs-follow-up.js"; +import { toIsoDate } from "./format-date.js"; + +export interface LeanPackageDocListEntry { + pageId: string; + title?: string; + sourceKind?: "crawled" | "repo"; + sourceUrl?: string; + repoUrl?: string; + gitRef?: string; + requestedRef?: string; + filePath?: string; + linkName?: string; + lastUpdatedAt?: string; + followUp: DocReadFollowUp; + readFile?: FileReadFollowUp; +} + +export interface LeanPackageDocListFilter { + limit?: number; + after?: string; +} + +export interface LeanPackageDocsEnvelope { + registry?: string; + name?: string; + version?: string; + stale?: boolean; + total?: number; + hasMore: boolean; + nextCursor?: string; + pages: LeanPackageDocListEntry[]; + filter?: LeanPackageDocListFilter; +} + +export interface BuildListPackageDocsPayloadOptions { + limitExplicit: boolean; + afterExplicit: boolean; + limit?: number; + after?: string; +} + +export function buildListPackageDocsSuccessPayload( + result: PackageDocsList, + options: BuildListPackageDocsPayloadOptions, +): LeanPackageDocsEnvelope { + const envelope: LeanPackageDocsEnvelope = { + hasMore: result.pageInfo?.hasNextPage ?? false, + pages: result.pages.map((page) => { + assertDocListEntry(page); + const pageId = page.id as string; + const lastUpdatedAt = toIsoDate(page.lastUpdatedAt); + return { + pageId, + title: page.title ?? undefined, + sourceKind: lowerDocSourceKind(page.sourceKind), + sourceUrl: page.sourceUrl ?? undefined, + repoUrl: page.repoUrl ?? undefined, + gitRef: page.gitRef ?? undefined, + requestedRef: page.requestedRef ?? undefined, + filePath: page.filePath ?? undefined, + linkName: page.linkName ?? undefined, + lastUpdatedAt: lastUpdatedAt ?? undefined, + followUp: buildDocReadFollowUp(pageId) as DocReadFollowUp, + readFile: buildFileReadFollowUp(page), + }; + }), + }; + + if (result.registry) envelope.registry = result.registry.toLowerCase(); + if (result.packageName) envelope.name = result.packageName; + if (result.version) envelope.version = result.version; + if (typeof result.stale === "boolean") envelope.stale = result.stale; + if (result.pageInfo?.totalCount !== undefined) + envelope.total = result.pageInfo.totalCount; + if (result.pageInfo?.endCursor) + envelope.nextCursor = result.pageInfo.endCursor; + + const filter: LeanPackageDocListFilter = {}; + if (options.limitExplicit && options.limit !== undefined) + filter.limit = options.limit; + if (options.afterExplicit && options.after) filter.after = options.after; + if (Object.keys(filter).length > 0) envelope.filter = filter; + + return envelope; +} + +function assertDocListEntry(page: PackageDocsList["pages"][number]): void { + if (!page.id) { + throw new MalformedPackageIntelligenceResponseError( + "Documentation page list entry missing required id.", + ); + } + + if ( + page.sourceKind === "REPOSITORY" && + (!page.repoUrl || !page.gitRef || !page.filePath) + ) { + throw new MalformedPackageIntelligenceResponseError( + "Repository-backed documentation list entry missing repo locator fields.", + ); + } +} + +export interface FormatListPackageDocsTerminalOptions { + useColors: boolean; + verbose?: boolean; +} + +export function formatListPackageDocsTerminal( + envelope: LeanPackageDocsEnvelope, + options: FormatListPackageDocsTerminalOptions, +): string { + const lines: string[] = []; + lines.push(buildSummaryHeader(envelope, options.useColors)); + lines.push(""); + + if (envelope.pages.length === 0) { + lines.push(dim("No documentation pages found.", options.useColors)); + lines.push(""); + return lines.join("\n"); + } + + for (const page of envelope.pages) { + lines.push(formatPageHeader(page, options.useColors)); + const meta = formatPageMeta( + page, + options.useColors, + options.verbose ?? false, + ); + if (meta.length > 0) lines.push(...meta); + lines.push(""); + } + + if (envelope.nextCursor) { + lines.push(dim(`Next cursor: ${envelope.nextCursor}`, options.useColors)); + } + if (envelope.stale) { + lines.push(dim("Documentation may be stale.", options.useColors)); + } + if (envelope.nextCursor || envelope.stale) lines.push(""); + + return lines.join("\n"); +} + +function buildSummaryHeader( + envelope: LeanPackageDocsEnvelope, + useColors: boolean, +): string { + const target = + envelope.registry && envelope.name + ? `${envelope.registry}:${envelope.name}${envelope.version ? `@${envelope.version}` : ""}` + : "package docs"; + const summary = `${target} · ${envelope.pages.length} page${envelope.pages.length === 1 ? "" : "s"}`; + const suffix = envelope.total !== undefined ? ` of ${envelope.total}` : ""; + return `${colorize(summary, "bold", useColors)}${dim(suffix, useColors)}`; +} + +function formatPageHeader( + page: LeanPackageDocListEntry, + useColors: boolean, +): string { + const badge = page.sourceKind === "repo" ? "[repo]" : "[crawled]"; + const title = page.title ?? page.pageId; + return `${colorize(page.pageId, "bold", useColors)} ${dim(badge, useColors)} - ${title}`; +} + +function formatPageMeta( + page: LeanPackageDocListEntry, + useColors: boolean, + verbose: boolean, +): string[] { + const lines: string[] = []; + if (page.sourceUrl) { + lines.push(` ${dim("source:", useColors)} ${page.sourceUrl}`); + } + if (page.filePath) { + const ref = page.requestedRef ?? page.gitRef; + lines.push( + ` ${dim("file:", useColors)} ${page.filePath}${ref ? ` @ ${ref}` : ""}`, + ); + } + if (verbose && page.lastUpdatedAt) { + lines.push(` ${dim("updated:", useColors)} ${page.lastUpdatedAt}`); + } + return lines; +} diff --git a/src/shared/read-package-doc-request.ts b/src/shared/read-package-doc-request.ts new file mode 100644 index 00000000..044add6a --- /dev/null +++ b/src/shared/read-package-doc-request.ts @@ -0,0 +1,23 @@ +import type { ReadPackageDocParams } from "../services/index.js"; +import { InvalidPackageSpecError } from "./package-spec.js"; + +export interface ReadPackageDocRequestInput { + pageId: string; +} + +export interface ReadPackageDocRequestBuildResult { + params: ReadPackageDocParams; +} + +export function buildReadPackageDocParams( + input: ReadPackageDocRequestInput, +): ReadPackageDocRequestBuildResult { + const pageId = input.pageId?.trim() ?? ""; + if (!pageId) { + throw new InvalidPackageSpecError("Page ID is required."); + } + + return { + params: { pageId }, + }; +} diff --git a/src/shared/read-package-doc-response.ts b/src/shared/read-package-doc-response.ts new file mode 100644 index 00000000..3e0edc18 --- /dev/null +++ b/src/shared/read-package-doc-response.ts @@ -0,0 +1,135 @@ +import type { PackageDocResult } from "../services/index.js"; +import { MalformedPackageIntelligenceResponseError } from "../services/index.js"; +import { colorize } from "./colors.js"; +import { + buildDocReadFollowUp, + buildFileReadFollowUp, + type DocReadFollowUp, + type FileReadFollowUp, + lowerDocSourceKind, +} from "./docs-follow-up.js"; +import { toIsoDate } from "./format-date.js"; + +export interface LeanPackageDocEnvelope { + registry?: string; + name?: string; + version?: string; + pageId: string; + title?: string; + format?: string; + content?: string; + breadcrumbs?: string[]; + linkName?: string; + lastUpdatedAt?: string; + sourceKind?: "crawled" | "repo"; + sourceUrl?: string; + sourceLabel?: string; + repoUrl?: string; + gitRef?: string; + requestedRef?: string; + filePath?: string; + baseUrl?: string; + followUp: DocReadFollowUp; + readFile?: FileReadFollowUp; +} + +export function buildReadPackageDocSuccessPayload( + result: PackageDocResult, + requestedPageId: string, +): LeanPackageDocEnvelope { + const pageId = result.page?.id; + if (!pageId) { + throw new MalformedPackageIntelligenceResponseError( + `Documentation page '${requestedPageId}' missing required id in response.`, + ); + } + + if ( + (result.page?.sourceKind ?? result.sourceKind) === "REPOSITORY" && + (!result.page?.repoUrl || !result.page?.gitRef || !result.page?.filePath) + ) { + throw new MalformedPackageIntelligenceResponseError( + `Repository-backed documentation page '${pageId}' missing repo locator fields.`, + ); + } + + const envelope: LeanPackageDocEnvelope = { + pageId, + followUp: buildDocReadFollowUp(pageId) as DocReadFollowUp, + }; + + if (result.registry) envelope.registry = result.registry.toLowerCase(); + if (result.packageName) envelope.name = result.packageName; + if (result.version) envelope.version = result.version; + if (result.page?.title) envelope.title = result.page.title; + if (result.page?.contentFormat) envelope.format = result.page.contentFormat; + if (result.page?.content !== undefined) + envelope.content = result.page.content; + if (result.page?.breadcrumbs && result.page.breadcrumbs.length > 0) { + envelope.breadcrumbs = result.page.breadcrumbs; + } + if (result.page?.linkName) envelope.linkName = result.page.linkName; + if (result.page?.lastUpdatedAt) { + envelope.lastUpdatedAt = toIsoDate(result.page.lastUpdatedAt) ?? undefined; + } + envelope.sourceKind = lowerDocSourceKind( + result.page?.sourceKind ?? result.sourceKind, + ); + if (result.page?.source?.url) envelope.sourceUrl = result.page.source.url; + if (result.page?.source?.label) + envelope.sourceLabel = result.page.source.label; + if (result.page?.repoUrl) envelope.repoUrl = result.page.repoUrl; + if (result.page?.gitRef) envelope.gitRef = result.page.gitRef; + if (result.page?.requestedRef) + envelope.requestedRef = result.page.requestedRef; + if (result.page?.filePath) envelope.filePath = result.page.filePath; + if (result.page?.baseUrl) envelope.baseUrl = result.page.baseUrl; + envelope.readFile = result.page + ? buildFileReadFollowUp(result.page) + : undefined; + + return envelope; +} + +export interface FormatReadPackageDocTerminalOptions { + useColors: boolean; + verbose?: boolean; +} + +export function formatReadPackageDocTerminal( + envelope: LeanPackageDocEnvelope, + options: FormatReadPackageDocTerminalOptions, +): string { + if (!(options.verbose ?? false)) { + return envelope.content ?? ""; + } + + const lines: string[] = []; + lines.push(buildHeader(envelope, options.useColors)); + lines.push(`pageId: ${envelope.pageId}`); + if (envelope.sourceUrl) lines.push(`source: ${envelope.sourceUrl}`); + if (envelope.filePath) { + const ref = envelope.requestedRef ?? envelope.gitRef; + lines.push(`file: ${envelope.filePath}${ref ? ` @ ${ref}` : ""}`); + } + if (envelope.lastUpdatedAt) lines.push(`updated: ${envelope.lastUpdatedAt}`); + if (envelope.breadcrumbs && envelope.breadcrumbs.length > 0) { + lines.push(`breadcrumbs: ${envelope.breadcrumbs.join(" > ")}`); + } + lines.push(""); + if (envelope.content) lines.push(envelope.content); + return `${lines.join("\n")}\n`; +} + +function buildHeader( + envelope: LeanPackageDocEnvelope, + useColors: boolean, +): string { + const badge = envelope.sourceKind === "repo" ? "[repo]" : "[crawled]"; + const title = envelope.title ?? envelope.pageId; + const prefix = + envelope.registry && envelope.name + ? `${envelope.registry}:${envelope.name}${envelope.version ? `@${envelope.version}` : ""}` + : "documentation"; + return `${colorize(`${prefix} ${badge}`, "bold", useColors)}${title ? ` - ${title}` : ""}`; +} diff --git a/src/shared/unified-search-response.ts b/src/shared/unified-search-response.ts index ee07389c..75da7323 100644 --- a/src/shared/unified-search-response.ts +++ b/src/shared/unified-search-response.ts @@ -5,7 +5,24 @@ import type { UnifiedSearchOutcome, UnifiedSearchParams, } from "../services/index.js"; +import { MalformedCodeNavigationResponseError } from "../services/index.js"; import { mapCodeNavigationError } from "./code-navigation-error-map.js"; +import { + buildDocReadFollowUp, + buildFileReadFollowUp, +} from "./docs-follow-up.js"; + +export type UnifiedSearchFollowUpPayload = + | { + type: "read_doc"; + pageId: string; + } + | { + type: "read_file"; + repoUrl: string; + gitRef: string; + path: string; + }; export interface UnifiedSearchQueryEcho { raw: string; @@ -42,8 +59,11 @@ export interface UnifiedSearchHitPayload { packageName?: string; version?: string; pageId?: string; + sourceKind?: string; + sourceUrl?: string; repoUrl?: string; gitRef?: string; + requestedRef?: string; filePath?: string; startLine?: number; endLine?: number; @@ -54,6 +74,8 @@ export interface UnifiedSearchHitPayload { category?: string; language?: string; }; + followUp?: UnifiedSearchFollowUpPayload; + alternateFollowUps?: UnifiedSearchFollowUpPayload[]; } export interface UnifiedSearchCompletedPayload { @@ -256,6 +278,7 @@ function buildQueryEcho( } function buildHitPayload(hit: UnifiedSearchHit): UnifiedSearchHitPayload { + assertSearchFollowUpInvariant(hit); return { type: hit.resultType.toLowerCase(), target: hit.targetLabel, @@ -268,8 +291,11 @@ function buildHitPayload(hit: UnifiedSearchHit): UnifiedSearchHitPayload { packageName: hit.locator.packageName, version: hit.locator.version, pageId: hit.locator.pageId, + sourceKind: hit.locator.sourceKind, + sourceUrl: hit.locator.sourceUrl, repoUrl: hit.locator.repoUrl, gitRef: hit.locator.gitRef, + requestedRef: hit.locator.requestedRef, filePath: hit.locator.filePath, startLine: hit.locator.startLine, endLine: hit.locator.endLine, @@ -280,5 +306,53 @@ function buildHitPayload(hit: UnifiedSearchHit): UnifiedSearchHitPayload { category: hit.locator.category, language: hit.locator.language, }, + followUp: buildPrimaryFollowUp(hit), + alternateFollowUps: buildAlternateFollowUps(hit), }; } + +function assertSearchFollowUpInvariant(hit: UnifiedSearchHit): void { + if ( + (hit.resultType === "DOCUMENTATION_PAGE" || + hit.resultType === "REPOSITORY_DOC") && + !hit.locator.pageId + ) { + throw new MalformedCodeNavigationResponseError( + `${hit.resultType} search hit missing required pageId.`, + ); + } + + if ( + hit.resultType === "REPOSITORY_DOC" && + (!hit.locator.repoUrl || !hit.locator.gitRef || !hit.locator.filePath) + ) { + throw new MalformedCodeNavigationResponseError( + "REPOSITORY_DOC search hit missing repo locator fields.", + ); + } +} + +function buildPrimaryFollowUp( + hit: UnifiedSearchHit, +): UnifiedSearchFollowUpPayload | undefined { + switch (hit.resultType) { + case "DOCUMENTATION_PAGE": + case "REPOSITORY_DOC": + return buildDocReadFollowUp(hit.locator.pageId); + case "REPOSITORY_CODE": + return buildFileReadFollowUp(hit.locator); + default: + return undefined; + } +} + +function buildAlternateFollowUps( + hit: UnifiedSearchHit, +): UnifiedSearchFollowUpPayload[] | undefined { + if (hit.resultType !== "REPOSITORY_DOC") { + return undefined; + } + + const readFile = buildFileReadFollowUp(hit.locator); + return readFile ? [readFile] : undefined; +} diff --git a/src/tools/index.ts b/src/tools/index.ts index ed2419c5..f5c6c0ea 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -2,11 +2,13 @@ export { createFeedbackTool } from "./feedback.js"; export { createGetExampleTool } from "./get-example.js"; export { createGrepRepoTool } from "./grep-repo.js"; export { createListFilesTool } from "./list-files.js"; +export { createListPackageDocsTool } from "./list-package-docs.js"; export { createPackageChangelogTool } from "./package-changelog.js"; export { createPackageDependenciesTool } from "./package-dependencies.js"; export { createPackageSummaryTool } from "./package-summary.js"; export { createPackageVulnerabilitiesTool } from "./package-vulnerabilities.js"; export { createReadFileTool } from "./read-file.js"; +export { createReadPackageDocTool } from "./read-package-doc.js"; export { createSearchTool } from "./search.js"; export { createSearchLanguageTool } from "./search-language.js"; export { createSearchStatusTool } from "./search-status.js"; diff --git a/src/tools/list-package-docs-parity.test.ts b/src/tools/list-package-docs-parity.test.ts new file mode 100644 index 00000000..09342cca --- /dev/null +++ b/src/tools/list-package-docs-parity.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it, mock, spyOn } from "bun:test"; +import { + type DocsListCommandDependencies, + docsListAction, +} from "../commands/docs/list.js"; +import { PackageIntelligenceTargetNotFoundError } from "../services/index.js"; +import { + createMockPackageIntelligenceService, + defaultPackageDocsList, +} from "../services/test-helpers.js"; +import { createListPackageDocsTool } from "./list-package-docs.js"; + +function cliDeps( + overrides: Partial = {}, +): DocsListCommandDependencies { + return { + packageIntelligenceService: createMockPackageIntelligenceService(), + codeNavigationUrl: "https://pkgseer.dev", + hasValidToken: true, + mcpUrl: "https://mcp.example.com", + ...overrides, + }; +} + +async function cliJson( + spec: string, + deps: DocsListCommandDependencies = cliDeps(), +): Promise { + const logSpy = spyOn(console, "log").mockImplementation(() => {}); + const errSpy = spyOn(console, "error").mockImplementation(() => {}); + const exitSpy = spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit"); + }); + try { + try { + await docsListAction(spec, { json: true }, deps); + } catch { + // expected on error paths + } + const raw = + (logSpy.mock.calls[0]?.[0] as string | undefined) ?? + (errSpy.mock.calls[0]?.[0] as string | undefined); + return raw ? JSON.parse(raw) : undefined; + } finally { + logSpy.mockRestore(); + errSpy.mockRestore(); + exitSpy.mockRestore(); + } +} + +async function mcpJson( + args: { registry: string; package_name: string }, + listPackageDocsMock?: () => Promise, +): Promise { + const service = createMockPackageIntelligenceService( + listPackageDocsMock + ? { listPackageDocs: listPackageDocsMock as never } + : {}, + ); + const tool = createListPackageDocsTool(service); + const result = await tool.handler(args, {}); + return JSON.parse(result.content[0]?.text ?? ""); +} + +describe("list_package_docs parity", () => { + it("PARITY-JSON-KEYS: happy path CLI === MCP", async () => { + const cli = await cliJson("npm:express@5.2.1"); + const mcp = await mcpJson({ registry: "npm", package_name: "express" }); + expect(cli).toEqual(mcp); + }); + + it("PARITY-ERROR-ENVELOPE: NOT_FOUND CLI === MCP", async () => { + const fn = mock(() => + Promise.reject( + new PackageIntelligenceTargetNotFoundError("Package not found"), + ), + ); + const cli = await cliJson( + "npm:ghost", + cliDeps({ + packageIntelligenceService: createMockPackageIntelligenceService({ + listPackageDocs: fn as never, + }), + }), + ); + const mcp = await mcpJson( + { registry: "npm", package_name: "ghost" }, + fn as never, + ); + expect(cli).toEqual(mcp); + expect(cli).toEqual({ + error: "Package not found", + code: "NOT_FOUND", + retryable: false, + }); + }); + + it("PARITY-JSON-KEYS: empty list CLI === MCP", async () => { + const fn = mock(() => + Promise.resolve({ + ...defaultPackageDocsList, + pages: [], + pageInfo: { hasNextPage: false, totalCount: 0 }, + }), + ); + const cli = await cliJson( + "npm:express", + cliDeps({ + packageIntelligenceService: createMockPackageIntelligenceService({ + listPackageDocs: fn as never, + }), + }), + ); + const mcp = await mcpJson( + { registry: "npm", package_name: "express" }, + fn as never, + ); + expect(cli).toEqual(mcp); + }); +}); diff --git a/src/tools/list-package-docs.test.ts b/src/tools/list-package-docs.test.ts new file mode 100644 index 00000000..f9cb29b9 --- /dev/null +++ b/src/tools/list-package-docs.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, it, mock } from "bun:test"; +import { + type PackageDocsList, + PackageIntelligenceTargetNotFoundError, +} from "../services/index.js"; +import { createMockPackageIntelligenceService } from "../services/test-helpers.js"; +import { createListPackageDocsTool } from "./list-package-docs.js"; + +function parseText(result: { content: Array<{ text: string }> }): unknown { + return JSON.parse(result.content[0]?.text ?? ""); +} + +describe("createListPackageDocsTool", () => { + it("registers the correct tool metadata", () => { + const tool = createListPackageDocsTool( + createMockPackageIntelligenceService(), + ); + expect(tool.name).toBe("list_package_docs"); + expect(tool.annotations?.readOnlyHint).toBe(true); + expect(Object.keys(tool.schema)).toEqual([ + "registry", + "package_name", + "version", + "limit", + "after", + ]); + }); + + it("calls service.listPackageDocs with normalised params", async () => { + const listPackageDocs = mock(() => + Promise.resolve({ pages: [], pageInfo: { hasNextPage: false } }), + ); + const tool = createListPackageDocsTool( + createMockPackageIntelligenceService({ listPackageDocs }), + ); + + await tool.handler( + { registry: "npm", package_name: "express", version: "5.2.1", limit: 3 }, + {}, + ); + + expect(listPackageDocs).toHaveBeenCalledWith({ + registry: "NPM", + packageName: "express", + version: "5.2.1", + limit: 3, + }); + }); + + it("returns JSON-stringified lean envelope on success", async () => { + const tool = createListPackageDocsTool( + createMockPackageIntelligenceService(), + ); + const result = await tool.handler( + { registry: "npm", package_name: "express" }, + {}, + ); + const payload = parseText(result) as Record; + expect(payload.name).toBe("express"); + expect(Array.isArray(payload.pages)).toBe(true); + }); + + it("omits nullish lastUpdatedAt values from the lean envelope", async () => { + const tool = createListPackageDocsTool( + createMockPackageIntelligenceService({ + listPackageDocs: mock(() => + Promise.resolve({ + registry: "npm", + packageName: "ms", + version: "2.1.3", + pages: [ + { + id: "github:vercel/ms@sha/readme.md", + title: "readme.md", + sourceKind: "REPOSITORY", + sourceUrl: "https://github.com/vercel/ms/blob/sha/readme.md", + repoUrl: "https://github.com/vercel/ms", + gitRef: "sha", + filePath: "readme.md", + }, + ], + pageInfo: { hasNextPage: false }, + } satisfies PackageDocsList), + ), + }), + ); + + const result = await tool.handler( + { registry: "npm", package_name: "ms" }, + {}, + ); + const payload = parseText(result) as { + pages: Array<{ lastUpdatedAt?: string }>; + }; + expect(payload.pages[0]?.lastUpdatedAt).toBeUndefined(); + }); + + it("returns INVALID_ARGUMENT for unknown registry", async () => { + const tool = createListPackageDocsTool( + createMockPackageIntelligenceService(), + ); + const result = await tool.handler( + { registry: "cargo", package_name: "serde" }, + {}, + ); + const payload = parseText(result) as { code: string }; + expect(result.isError).toBe(true); + expect(payload.code).toBe("INVALID_ARGUMENT"); + }); + + it("classifies target-not-found errors as NOT_FOUND", async () => { + const tool = createListPackageDocsTool( + createMockPackageIntelligenceService({ + listPackageDocs: mock(() => + Promise.reject( + new PackageIntelligenceTargetNotFoundError("Package not found"), + ), + ), + }), + ); + const result = await tool.handler( + { registry: "npm", package_name: "ghost" }, + {}, + ); + const payload = parseText(result) as { code: string }; + expect(result.isError).toBe(true); + expect(payload.code).toBe("NOT_FOUND"); + }); +}); diff --git a/src/tools/list-package-docs.ts b/src/tools/list-package-docs.ts new file mode 100644 index 00000000..cfb56b99 --- /dev/null +++ b/src/tools/list-package-docs.ts @@ -0,0 +1,79 @@ +import { z } from "zod"; +import type { PackageIntelligenceService } from "../services/index.js"; +import { buildListPackageDocsParams } from "../shared/list-package-docs-request.js"; +import { buildListPackageDocsSuccessPayload } from "../shared/list-package-docs-response.js"; +import { mapPackageIntelligenceError } from "../shared/package-intelligence-error-map.js"; +import { errorResult, type ToolDefinition, textResult } from "./types.js"; + +export interface ListPackageDocsArgs { + registry: string; + package_name: string; + version?: string; + limit?: number; + after?: string; +} + +const schema = { + registry: z + .string() + .describe( + "Package registry. One of: npm, pypi, hex, crates, nuget, maven, zig, vcpkg, packagist.", + ), + package_name: z + .string() + .describe("Package name (scoped names ok: @types/node)."), + version: z.string().optional().describe("Optional package version."), + limit: z + .number() + .optional() + .describe("Max pages to return (1-500, default 100)."), + after: z + .string() + .optional() + .describe("Pagination cursor from a prior response."), +}; + +const DESCRIPTION = + "List mixed package documentation pages from hosted docs and repository-backed docs. " + + "Every entry includes a stable pageId, source kind, source URL, and for repo docs exact file follow-up metadata. " + + "Use this when you need to browse what docs exist before reading a full page."; + +export function createListPackageDocsTool( + service: PackageIntelligenceService, +): ToolDefinition { + return { + name: "list_package_docs", + description: DESCRIPTION, + schema, + annotations: { readOnlyHint: true }, + handler: async (args) => { + try { + const build = buildListPackageDocsParams({ + registry: args.registry, + packageName: args.package_name, + version: args.version, + limit: args.limit, + after: args.after, + }); + const result = await service.listPackageDocs(build.params); + const payload = buildListPackageDocsSuccessPayload(result, { + limitExplicit: build.limitExplicit, + afterExplicit: build.afterExplicit, + limit: build.params.limit, + after: build.params.after, + }); + return textResult(JSON.stringify(payload)); + } catch (error) { + const mapped = mapPackageIntelligenceError(error); + return errorResult( + JSON.stringify({ + error: mapped.message, + code: mapped.code, + retryable: mapped.retryable ?? false, + ...(mapped.details ? { details: mapped.details } : {}), + }), + ); + } + }, + }; +} diff --git a/src/tools/read-package-doc-parity.test.ts b/src/tools/read-package-doc-parity.test.ts new file mode 100644 index 00000000..40ff93ca --- /dev/null +++ b/src/tools/read-package-doc-parity.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it, mock, spyOn } from "bun:test"; +import { + type DocsReadCommandDependencies, + docsReadAction, +} from "../commands/docs/read.js"; +import { PackageIntelligenceTargetNotFoundError } from "../services/index.js"; +import { createMockPackageIntelligenceService } from "../services/test-helpers.js"; +import { createReadPackageDocTool } from "./read-package-doc.js"; + +function cliDeps( + overrides: Partial = {}, +): DocsReadCommandDependencies { + return { + packageIntelligenceService: createMockPackageIntelligenceService(), + codeNavigationUrl: "https://pkgseer.dev", + hasValidToken: true, + mcpUrl: "https://mcp.example.com", + ...overrides, + }; +} + +async function cliJson( + pageId: string, + deps: DocsReadCommandDependencies = cliDeps(), +): Promise { + const logSpy = spyOn(console, "log").mockImplementation(() => {}); + const errSpy = spyOn(console, "error").mockImplementation(() => {}); + const exitSpy = spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit"); + }); + try { + try { + await docsReadAction(pageId, { json: true }, deps); + } catch { + // expected on error paths + } + const raw = + (logSpy.mock.calls[0]?.[0] as string | undefined) ?? + (errSpy.mock.calls[0]?.[0] as string | undefined); + return raw ? JSON.parse(raw) : undefined; + } finally { + logSpy.mockRestore(); + errSpy.mockRestore(); + exitSpy.mockRestore(); + } +} + +async function mcpJson( + args: { page_id: string }, + readPackageDocMock?: () => Promise, +): Promise { + const service = createMockPackageIntelligenceService( + readPackageDocMock ? { readPackageDoc: readPackageDocMock as never } : {}, + ); + const tool = createReadPackageDocTool(service); + const result = await tool.handler(args, {}); + return JSON.parse(result.content[0]?.text ?? ""); +} + +describe("read_package_doc parity", () => { + it("PARITY-JSON-KEYS: happy path CLI === MCP", async () => { + const cli = await cliJson("github:expressjs/express@abc123/README.md"); + const mcp = await mcpJson({ + page_id: "github:expressjs/express@abc123/README.md", + }); + expect(cli).toEqual(mcp); + }); + + it("PARITY-ERROR-ENVELOPE: NOT_FOUND CLI === MCP", async () => { + const fn = mock(() => + Promise.reject( + new PackageIntelligenceTargetNotFoundError("Doc page not found"), + ), + ); + const cli = await cliJson( + "missing-page", + cliDeps({ + packageIntelligenceService: createMockPackageIntelligenceService({ + readPackageDoc: fn as never, + }), + }), + ); + const mcp = await mcpJson({ page_id: "missing-page" }, fn as never); + expect(cli).toEqual(mcp); + expect(cli).toEqual({ + error: "Doc page not found", + code: "NOT_FOUND", + retryable: false, + }); + }); +}); diff --git a/src/tools/read-package-doc.test.ts b/src/tools/read-package-doc.test.ts new file mode 100644 index 00000000..bebc5106 --- /dev/null +++ b/src/tools/read-package-doc.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it, mock } from "bun:test"; +import { PackageIntelligenceTargetNotFoundError } from "../services/index.js"; +import { createMockPackageIntelligenceService } from "../services/test-helpers.js"; +import { createReadPackageDocTool } from "./read-package-doc.js"; + +function parseText(result: { content: Array<{ text: string }> }): unknown { + return JSON.parse(result.content[0]?.text ?? ""); +} + +describe("createReadPackageDocTool", () => { + it("registers the correct tool metadata", () => { + const tool = createReadPackageDocTool( + createMockPackageIntelligenceService(), + ); + expect(tool.name).toBe("read_package_doc"); + expect(tool.annotations?.readOnlyHint).toBe(true); + expect(Object.keys(tool.schema)).toEqual(["page_id"]); + }); + + it("calls service.readPackageDoc with the page ID", async () => { + const readPackageDoc = mock(() => Promise.resolve({ page: { id: "abc" } })); + const tool = createReadPackageDocTool( + createMockPackageIntelligenceService({ readPackageDoc }), + ); + + await tool.handler({ page_id: "abc" }, {}); + + expect(readPackageDoc).toHaveBeenCalledWith({ pageId: "abc" }); + }); + + it("returns JSON-stringified lean envelope on success", async () => { + const tool = createReadPackageDocTool( + createMockPackageIntelligenceService(), + ); + const result = await tool.handler( + { page_id: "github:expressjs/express@abc123/README.md" }, + {}, + ); + const payload = parseText(result) as Record; + expect(payload.pageId).toBe("github:expressjs/express@abc123/README.md"); + expect(payload.followUp).toBeDefined(); + }); + + it("returns INVALID_ARGUMENT for empty page ID", async () => { + const tool = createReadPackageDocTool( + createMockPackageIntelligenceService(), + ); + const result = await tool.handler({ page_id: " " }, {}); + const payload = parseText(result) as { code: string }; + expect(result.isError).toBe(true); + expect(payload.code).toBe("INVALID_ARGUMENT"); + }); + + it("classifies target-not-found errors as NOT_FOUND", async () => { + const tool = createReadPackageDocTool( + createMockPackageIntelligenceService({ + readPackageDoc: mock(() => + Promise.reject( + new PackageIntelligenceTargetNotFoundError("Doc page not found"), + ), + ), + }), + ); + const result = await tool.handler({ page_id: "missing" }, {}); + const payload = parseText(result) as { code: string }; + expect(result.isError).toBe(true); + expect(payload.code).toBe("NOT_FOUND"); + }); +}); diff --git a/src/tools/read-package-doc.ts b/src/tools/read-package-doc.ts new file mode 100644 index 00000000..9e8b92e6 --- /dev/null +++ b/src/tools/read-package-doc.ts @@ -0,0 +1,54 @@ +import { z } from "zod"; +import type { PackageIntelligenceService } from "../services/index.js"; +import { mapPackageIntelligenceError } from "../shared/package-intelligence-error-map.js"; +import { buildReadPackageDocParams } from "../shared/read-package-doc-request.js"; +import { buildReadPackageDocSuccessPayload } from "../shared/read-package-doc-response.js"; +import { errorResult, type ToolDefinition, textResult } from "./types.js"; + +export interface ReadPackageDocArgs { + page_id: string; +} + +const schema = { + page_id: z + .string() + .describe( + "Documentation page ID from list_package_docs or search results. Pass through unchanged; repo-backed IDs are snapshot-pinned.", + ), +}; + +const DESCRIPTION = + "Read a documentation page by page ID. Works for both hosted/crawled docs and repository-backed docs. " + + "Repo-backed results additionally include exact file follow-up metadata for read_file."; + +export function createReadPackageDocTool( + service: PackageIntelligenceService, +): ToolDefinition { + return { + name: "read_package_doc", + description: DESCRIPTION, + schema, + annotations: { readOnlyHint: true }, + handler: async (args) => { + try { + const build = buildReadPackageDocParams({ pageId: args.page_id }); + const result = await service.readPackageDoc(build.params); + const payload = buildReadPackageDocSuccessPayload( + result, + build.params.pageId, + ); + return textResult(JSON.stringify(payload)); + } catch (error) { + const mapped = mapPackageIntelligenceError(error); + return errorResult( + JSON.stringify({ + error: mapped.message, + code: mapped.code, + retryable: mapped.retryable ?? false, + ...(mapped.details ? { details: mapped.details } : {}), + }), + ); + } + }, + }; +} diff --git a/src/tools/search.test.ts b/src/tools/search.test.ts index 13d411c4..12a28b0a 100644 --- a/src/tools/search.test.ts +++ b/src/tools/search.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it, mock } from "bun:test"; +import type { UnifiedSearchOutcome } from "../services/index.js"; import { createMockCodeNavigationService, defaultUnifiedSearchOutcome, @@ -81,4 +82,63 @@ describe("searchTool", () => { }), ); }); + + it("includes alternate read_file follow-up for repository docs", async () => { + if (defaultUnifiedSearchOutcome.state !== "completed") { + throw new Error("expected completed outcome fixture"); + } + + const baseHit = defaultUnifiedSearchOutcome.result.results[0]!; + + const outcome: UnifiedSearchOutcome = { + ...defaultUnifiedSearchOutcome, + result: { + ...defaultUnifiedSearchOutcome.result, + results: [ + { + ...baseHit, + resultType: "REPOSITORY_DOC" as const, + locator: { + ...baseHit.locator, + pageId: "github:expressjs/express@abc123/README.md", + sourceKind: "REPOSITORY", + sourceUrl: + "https://github.com/expressjs/express/blob/abc123/README.md", + repoUrl: "https://github.com/expressjs/express", + gitRef: "abc123", + requestedRef: "v5.2.1", + filePath: "README.md", + }, + }, + ], + }, + }; + const tool = createSearchTool( + createMockCodeNavigationService({ + search: mock(() => Promise.resolve(outcome)), + }), + ); + + const result = await tool.handler( + { + query: "middleware", + target: { registry: "npm", package_name: "express" }, + }, + {}, + ); + + const payload = JSON.parse(result.content[0]?.text ?? "{}"); + expect(payload.results[0].followUp).toEqual({ + type: "read_doc", + pageId: "github:expressjs/express@abc123/README.md", + }); + expect(payload.results[0].alternateFollowUps).toEqual([ + { + type: "read_file", + repoUrl: "https://github.com/expressjs/express", + gitRef: "abc123", + path: "README.md", + }, + ]); + }); }); From d729c707e3eb2a46c4ea32e45ee90b440f882ce8 Mon Sep 17 00:00:00 2001 From: Juha Litola Date: Mon, 27 Apr 2026 10:20:44 +0300 Subject: [PATCH 2/2] test: align mcp opaque token expectations Update MCP registration tests to match the override and opaque-env-token gating predicate used by tool registration and instructions after the rebase onto main. --- src/commands/mcp.test.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/commands/mcp.test.ts b/src/commands/mcp.test.ts index 8822bc8c..c5786300 100644 --- a/src/commands/mcp.test.ts +++ b/src/commands/mcp.test.ts @@ -85,7 +85,7 @@ describe("createMcpServer", () => { ]); }); - it("omits unified search tools for opaque env tokens without an explicit capability claim", () => { + it("adds unified search tools for opaque env tokens when the gate is otherwise open", () => { const deps = createTestDeps({ envApiToken: "ghi-opaque-token", codeNavigationCapability: "unknown", @@ -94,8 +94,8 @@ describe("createMcpServer", () => { }); const tools = getMcpToolDefinitions(deps); - expect(tools.some((tool) => tool.name === "search")).toBe(false); - expect(tools.some((tool) => tool.name === "search_status")).toBe(false); + expect(tools.some((tool) => tool.name === "search")).toBe(true); + expect(tools.some((tool) => tool.name === "search_status")).toBe(true); }); it("adds package_summary when capability is enabled and service wired", () => { @@ -134,7 +134,7 @@ describe("createMcpServer", () => { expect(tools.map((tool) => tool.name)).not.toContain("package_summary"); }); - it("omits package_summary for opaque env tokens without an explicit capability claim", () => { + it("adds package_summary for opaque env tokens when the gate is otherwise open", () => { const deps = createTestDeps({ envApiToken: "ghi-opaque-token", codeNavigationCapability: "unknown", @@ -143,7 +143,7 @@ describe("createMcpServer", () => { }); const tools = getMcpToolDefinitions(deps); - expect(tools.some((tool) => tool.name === "package_summary")).toBe(false); + expect(tools.some((tool) => tool.name === "package_summary")).toBe(true); }); it("adds package and code-nav tools when local override is enabled", () => {