diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index c3a73e41..2c149f88 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -6,7 +6,7 @@ }, "metadata": { "description": "GitHits plugins for Claude Code - code examples from global open source", - "version": "0.3.1" + "version": "0.3.2" }, "plugins": [ { diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 196cd910..10abaa20 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "githits", - "version": "0.3.1", + "version": "0.3.2", "description": "Code examples from global open source for developers and AI assistants", "author": { "name": "GitHits" diff --git a/.plugin/plugin.json b/.plugin/plugin.json index 196cd910..10abaa20 100644 --- a/.plugin/plugin.json +++ b/.plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "githits", - "version": "0.3.1", + "version": "0.3.2", "description": "Code examples from global open source for developers and AI assistants", "author": { "name": "GitHits" diff --git a/docs/implementation/cli-commands.md b/docs/implementation/cli-commands.md index e94b5d22..5b537d75 100644 --- a/docs/implementation/cli-commands.md +++ b/docs/implementation/cli-commands.md @@ -101,7 +101,7 @@ githits languages python # filter by name/alias (top 5) githits languages type --json # JSON output for piping ``` -Without a query, lists all languages. With a query, filters to top 5 matches using the same logic as the `search_language` MCP tool (case-insensitive substring match on name, display_name, and aliases). Default output uses colored terminal formatting. JSON output is `[{ "name": "...", "display_name": "..." }, ...]`. +Without a query, lists all languages. With a query, filters to top 5 matches using the same logic as the `search_language` MCP tool (case-insensitive substring match on name, display_name, and aliases). Default output uses colored terminal formatting. JSON output is `[{ "name": "...", "display_name": "...", "aliases": [...] }, ...]`. ### `githits feedback` diff --git a/docs/implementation/mcp-cli-parity.md b/docs/implementation/mcp-cli-parity.md index f947ce1f..fc1d1854 100644 --- a/docs/implementation/mcp-cli-parity.md +++ b/docs/implementation/mcp-cli-parity.md @@ -3,9 +3,12 @@ ## Purpose Users and agents should be able to cross the surface boundary without -learning a new payload shape — parameter names can differ per surface -convention, but defaults, error behaviour, and the serialised -envelopes do not. +learning a new request or error contract. Parameter names can differ per +surface convention, but request defaults and error behaviour do not. +Human/agent default rendering may differ from JSON envelopes: MCP tools +default to compact `text-v1` where available, while CLI has human +terminal output and `--json`. Structured parity is enforced through CLI +`--json` and MCP `format: "json"`. The dual-surface tools today are unified `search` / `search_status`, the file-exploration bundle (`code_files`, `code_read`, `code_grep`), @@ -59,8 +62,8 @@ test suite anchors the doc. ### `PARITY-JSON-KEYS` -- The CLI `--json` payload and the MCP tool text payload, parsed as - JSON, must `deepEqual` for equivalent inputs. +- The CLI `--json` payload and the MCP `format: "json"` payload, + parsed as JSON, must `deepEqual` for equivalent inputs. - String-equal is explicitly not the contract. Key ordering, whitespace, and trailing newlines are free. - **No leading-underscore keys.** `warning`, `hint`, and all other @@ -86,16 +89,16 @@ test suite anchors the doc. `src/shared/code-navigation-error-map.test.ts`; that test is the enforcement mechanism, not a convention. - MCP error text is always valid JSON. A client that parses - `content[0].text` gets the same envelope whether the result is - success or error. + `content[0].text` on error gets the same envelope as CLI `--json`. -### `PARITY-NO-SHARED-TERMINAL-FORMATTER` +### `PARITY-SHARED-TEXT-FORMATTER` -- Terminal rendering is CLI-local. -- MCP emits JSON text only; there is no equivalent pretty-print. -- Small semantic helpers that happen to be reused (e.g. a zero-result - message template) may move into `src/shared/` once two tools need - them. Default: keep formatting surface-local. +- Terminal rendering and MCP text rendering may share formatter code when + the output is useful to both humans and agents. +- Shared formatters must accept surface-specific hints so MCP never emits + CLI-only instructions like `--verbose` or `--lifecycle all`. +- Default MCP success output should be compact `text-v1`; programmatic + parity tests must pass `format: "json"` explicitly. ## Checklist for adding a new dual-surface tool @@ -120,9 +123,10 @@ When a new tool lands with both MCP and CLI surfaces: ## Non-goals -- **Shared terminal formatter.** CLI terminal output and MCP JSON are - different products. A shared prose-rendering layer is premature - until at least two tools prove the shape is common. +- **Forcing identical default prose.** CLI terminal output and MCP text + are related products, not identical products. Share formatters only + when the shape is useful on both surfaces and hints can be made + surface-native. - **Shared MCP description copy.** Each tool's description targets a different decision the agent is making. Copy is not reusable. @@ -137,13 +141,13 @@ When a new tool lands with both MCP and CLI surfaces: | `src/shared/unified-search-request.ts` | Shared request builder for unified `search`; compiles structured query fields and applies defaulting. | | `src/shared/unified-search-response.ts` | Shared JSON envelope builders for unified `search` and follow-up `search_status`. | | `src/shared/package-summary-request.ts` | Shared request builder for `pkg_info`. | -| `src/shared/package-summary-response.ts` | Lean JSON envelope builder and terminal formatter for `pkg_info`. | +| `src/shared/package-summary-response.ts` | Lean JSON envelope builder and shared text/terminal formatter for `pkg_info`. | | `src/shared/package-vulnerabilities-request.ts` | Shared request builder for `pkg_vulns`. | -| `src/shared/package-vulnerabilities-response.ts` | Lean JSON envelope builder for `pkg_vulns`. | +| `src/shared/package-vulnerabilities-response.ts` | Lean JSON envelope builder and shared text/terminal formatter for `pkg_vulns`. | | `src/shared/package-dependencies-request.ts` | Shared request builder for `pkg_deps`. | -| `src/shared/package-dependencies-response.ts` | Lean JSON envelope builder for `pkg_deps`. | +| `src/shared/package-dependencies-response.ts` | Lean JSON envelope builder and shared text/terminal formatter for `pkg_deps`. | | `src/shared/package-changelog-request.ts` | Shared request builder for `pkg_changelog`. | -| `src/shared/package-changelog-response.ts` | JSON envelope builder for `pkg_changelog`. | +| `src/shared/package-changelog-response.ts` | JSON envelope builder and shared text/terminal formatter for `pkg_changelog`. | | `src/shared/list-files-request.ts` | Shared request builder for `code_files`. | | `src/shared/list-files-response.ts` | JSON envelope builder for `code_files`. | | `src/shared/read-file-request.ts` | Shared request builder for `code_read`. | diff --git a/docs/implementation/tools.md b/docs/implementation/tools.md index b15f0e85..cd77cf0f 100644 --- a/docs/implementation/tools.md +++ b/docs/implementation/tools.md @@ -17,19 +17,19 @@ The CLI mirrors the production MCP tool contract where equivalent tools exist. C | Tool | Parameters | Description | |---|---|---| -| `get_example` | `query`, `language?`, `license_mode?` | Search for canonical code examples. Returns markdown-formatted results. If `language` is omitted, the backend infers it from the query. | -| `search_language` | `query` | Find supported programming language names before searching. | +| `get_example` | `query`, `language?`, `license_mode?`, `format?` | Search for canonical code examples. Defaults to markdown with a trailing `solution_id: ...` line for `feedback`; pass `format: "json"` for `{result, solution_id?}`. If `language` is omitted, the backend infers it from the query. | +| `search_language` | `query`, `format?` | Find supported programming language names before searching. Defaults to one compact line per match (`name (Display Name) aliases: ...`); pass `format: "json"` for structured matches. | | `feedback` | `solution_id`, `accepted`, `feedback_text?` | Submit feedback on a search result to improve quality. | | `search` | `query`, `target?`, `targets?`, `sources?`, `category?`, `kind?`, `path_prefix?`, `file_intent?`, `public_only?`, `name?`, `language?`, `allow_partial_results?`, `limit?`, `offset?`, `wait_timeout_ms?`, `format?` | Unified indexed dependency/repository discovery search across code, docs, and symbols. Omit `file_intent` to search across all intents; set it only when you want to narrow results, and note that some sources may ignore it and report that in `sourceStatus`. Prefer `sources:["symbol"]` for symbol-shaped unified search. Complete-by-default; `limit` defaults to 10 to keep agent output compact. Set `allow_partial_results: true` to receive available partial hits while indexing continues. `format` defaults to `text-v1` for compact agent output; pass `format: "json"` for the structured envelope. | -| `search_status` | `search_ref` | Check progress, fetch partial hits when the original request used `allow_partial_results: true`, or fetch final results for a prior unified search. | -| `docs_list` | `registry`, `package_name`, `version?`, `limit?`, `after?` | List hosted/crawled and repository-backed documentation pages for a package. Entries include stable page IDs for `docs_read`; repo-backed entries include exact source metadata for `code_read` follow-up. | -| `docs_read` | `page_id`, `start_line?`, `end_line?` | Read a documentation page by page ID. Supports bounded line ranges for long pages; repo-backed pages include exact file follow-up metadata. | -| `pkg_info` | `registry`, `package_name` | Package overview: latest version, license, description, repository, downloads, GitHub metadata, install command, and known vulnerabilities. Always returns the latest published version. | -| `pkg_vulns` | `registry`, `package_name`, `version?`, `min_severity?`, `include_withdrawn?` | Known vulnerabilities for a package on npm, PyPI, Hex, or Crates. Count summary, per-advisory OSV ID + severity + affected/fix ranges, and upgrade paths. Malware is surfaced in a disjoint bucket. | -| `pkg_deps` | `registry`, `package_name`, `version?`, `lifecycle?`, `include_transitive?`, `include_importers?`, `max_depth?` | Direct runtime dependency list (each `{name, version, constraint}` — the backend resolves each constraint to a concrete version) plus, when the backend has them, structured groups for dev / peer / build / optional with registry-specific condition metadata (PyPI extras, Crates features). Optional transitive block with aggregate edge counts, the preprocessed install footprint as `{name, version}`, typed conflicts and circular-dependency cycles; opt into per-package importer provenance with `include_importers`. | -| `pkg_changelog` | `registry?`, `package_name?`, `repo_url?`, `from_version?`, `to_version?`, `limit?`, `git_ref?`, `include_bodies?` | Release notes or changelog entries for a package or GitHub repo. Default latest mode returns the ten most recent entries; `from_version` switches to range mode (no count cap). Dual addressing (spec vs repo URL) mutually exclusive. Response includes optional `source` (`"releases"` / `"changelog_file"` / `"hexdocs"`) when a concrete changelog source exists, `mode` (`"latest"` / `"range"`), and `entries: { count, items }` with full markdown bodies by default; set `include_bodies: false` for a lean version / date / URL timeline. Package-version entries without changelog text succeed with `source` omitted. | +| `search_status` | `search_ref`, `format?` | Check progress, fetch partial hits when the original request used `allow_partial_results: true`, or fetch final results for a prior unified search. Defaults to compact `text-v1`; pass `format: "json"` for the structured envelope. | +| `docs_list` | `registry`, `package_name`, `version?`, `limit?`, `after?`, `format?` | List hosted/crawled and repository-backed documentation pages for a package. Defaults to compact `text-v1` with ready-to-call `docs_read` follow-ups; repo-backed entries include exact source metadata for `code_read` follow-up when available. | +| `docs_read` | `page_id`, `start_line?`, `end_line?`, `format?` | Read a documentation page by page ID. Defaults to `text-v1` with a 150-line MCP text cap; explicit line ranges are supported. `format: "json"` preserves full-document default while still honoring explicit ranges. Repo-backed pages include exact file follow-up metadata. | +| `pkg_info` | `registry`, `package_name`, `format?` | Package overview: latest version, license, description, repository, downloads, GitHub metadata, install command, and known vulnerabilities. Defaults to compact text; pass `format: "json"` for the lean structured envelope. Always returns the latest published version. | +| `pkg_vulns` | `registry`, `package_name`, `version?`, `min_severity?`, `include_withdrawn?`, `format?` | Known vulnerabilities for a package on npm, PyPI, Hex, or Crates. Defaults to compact summary text; pass `format: "json"` for per-advisory OSV ID + severity + affected/fix ranges, malware bucket, and upgrade paths. | +| `pkg_deps` | `registry`, `package_name`, `version?`, `lifecycle?`, `include_transitive?`, `include_importers?`, `max_depth?`, `format?` | Direct runtime dependency list by default with resolved versions. Non-runtime groups are hidden with an MCP-native hint (`pass lifecycle="all"`). Use `lifecycle: "runtime"` for explicit runtime-only, a concrete non-runtime lifecycle for runtime plus matching groups, or `lifecycle: "all"` for all available groups. Optional transitive output includes aggregate edge counts, the preprocessed install footprint, typed conflicts and circular-dependency cycles; opt into importer provenance with `include_importers`. Pass `format: "json"` for the lean structured envelope. | +| `pkg_changelog` | `registry?`, `package_name?`, `repo_url?`, `from_version?`, `to_version?`, `limit?`, `git_ref?`, `include_bodies?`, `format?` | Release notes or changelog entries for a package or GitHub repo. Defaults to compact text with newest-first entries and capped body previews; pass `format: "json"` for the structured envelope with full markdown bodies, or set `include_bodies: false` for a lean version / date / URL timeline. `from_version` switches to range mode (no count cap). Dual addressing (spec vs repo URL) is mutually exclusive. | | `code_files` | `target`, `path?`, `path_prefix?`, `globs?`, `extensions?`, `file_types?`, `languages?`, `file_intent?`, `file_intents?`, `exclude_file_intents?`, `exclude_doc_files?`, `exclude_test_files?`, `include_hidden?`, `limit?`, `wait_timeout_ms?`, `format?` | List files in an indexed dependency. Returns `{total, hasMore, files: [{path, name, language, fileType, byteSize}], resolution, indexedVersion}` in JSON mode. Dual addressing via `target.registry + target.package_name` (spec) or `target.repo_url + target.git_ref` (repo). Selectors (`path`, `path_prefix`, `globs`) are OR-ed; the other filters intersect on top. Emits an `INDEXING` error envelope when the dependency is being indexed on-demand — retry with a longer `wait_timeout_ms` or pick a version from `details.availableVersions`. `format` defaults to `text-v1` (paths-only listing); pass `format: "json"` for the structured envelope. | -| `code_read` | `target`, `path`, `start_line?`, `end_line?`, `wait_timeout_ms?` | Read a file from an indexed dependency. **MCP per-call span cap: 150 lines** — broader requests (or no range) are silently truncated to the first 150 lines from the caller's start, with a `hint` field explaining the cap and the original request. Use `start_line` / `end_line` to pick a focused window — typical 80-150 lines around a known symbol from `search` / `code_grep`. Binary files set `isBinary: true` and omit `content`. On `NOT_FOUND` / `FILE_NOT_FOUND` call `code_files` to discover the actual path. The cap is MCP-only; the CLI command `githits code read` honors arbitrary ranges. | +| `code_read` | `target`, `path`, `start_line?`, `end_line?`, `wait_timeout_ms?`, `format?` | Read a file from an indexed dependency. `target` accepts the structured object or compact string (`npm:react@18.2.0`, `https://github.com/facebook/react#HEAD`). **MCP per-call span cap: 150 lines** — broader requests (or no range) are silently truncated to the first 150 lines from the caller's start, with a hint explaining the cap and the original request. Defaults to `text-v1` with line-numbered content; pass `format: "json"` for the structured envelope. Use `start_line` / `end_line` to pick a focused window — typical 80-150 lines around a known symbol from `search` / `code_grep`. Binary files set `isBinary: true` and omit `content`. On `NOT_FOUND` / `FILE_NOT_FOUND` call `code_files` to discover the actual path. The cap is MCP-only; the CLI command `githits code read` honors arbitrary ranges. | | `code_grep` | `target`, `pattern`, `path?`, `path_prefix?`, `globs?`, `extensions?`, `pattern_type?`, `case_sensitive?`, `exclude_doc_files?`, `exclude_test_files?`, `context_lines?`, `context_lines_before?`, `context_lines_after?`, `max_matches?`, `max_matches_per_file?`, `cursor?`, `symbol_fields?`, `wait_timeout_ms?`, `format?` | Deterministic text grep over indexed dependency or repository source. Defaults to literal, ASCII case-insensitive matching across the whole target; non-ASCII letters match case-sensitively. Narrow with `path`, `path_prefix`, `globs`, or `extensions`. `pattern_type: "regex"` uses RE2 syntax; whole-target regexes must include at least one literal substring for index pre-filtering. Returns matches plus pagination and scan counters; `symbol_fields` hydrates enclosing symbol metadata on each match. `format` defaults to `text-v1` (matches grouped by file, grep -A/-B notation for context); pass `format: "json"` for the structured envelope. | `search`, `search_status`, `docs_list`, `docs_read`, `pkg_info`, `pkg_vulns`, `pkg_deps`, `pkg_changelog`, `code_files`, `code_read`, and `code_grep` are registered by default. The package/source service URL defaults to `https://pkgseer.dev` and can be overridden via `GITHITS_CODE_NAV_URL` for local development. @@ -40,13 +40,13 @@ The CLI mirrors the production MCP tool contract where equivalent tools exist. C ### `pkg_info` response shape -**Hand-crafted JSON envelope.** `pkg_info` returns a lean JSON payload designed for agent token efficiency. Fields that do not add caller value are deliberately omitted. Null scalars are omitted; blocks (`github`, `vulnerabilities`, `downloads`, `recentChanges`) are omitted entirely when they carry no actionable data. `vulnerabilities` is omitted when `total === 0` or missing; when present, severity values include a CVSS-banded `severityLabel` (`critical` ≥9, `high` ≥7, `medium` ≥4, else `low`) for agent convenience. +**Default MCP text + JSON opt-in.** `pkg_info` defaults to compact text for agent turns. `format: "json"` returns a lean payload designed for programmatic consumers. Fields that do not add caller value are deliberately omitted. Null scalars are omitted; blocks (`github`, `vulnerabilities`, `downloads`, `recentChanges`) are omitted entirely when they carry no actionable data. `vulnerabilities` is omitted when `total === 0` or missing; when present, severity values include a CVSS-banded `severityLabel` (`critical` ≥9, `high` ≥7, `medium` ≥4, else `low`) for agent convenience. **Validation.** The MCP schema is permissive (`registry: z.string()`, `package_name: z.string()`) — validation happens in-handler via `buildPackageSummaryParams`, producing the same structured `{error, code, retryable}` envelope as CLI. Raw Zod errors are never surfaced to agents. **Always latest.** The query exposes no `version` input because the upstream `packageSummary` resolver always returns the latest published version. The CLI `githits pkg info` rejects `@` with `INVALID_ARGUMENT` rather than silently swapping — a silent-swap would break security-testing workflows that pin to an older vulnerable release. -`pkg_info` shares its envelope builder, terminal formatter, and error classifier with the CLI `githits pkg info` command via `src/shared/package-summary-request.ts`, `src/shared/package-summary-response.ts`, and `src/shared/package-intelligence-error-map.ts`. The parity test (`src/tools/package-summary-parity.test.ts`) asserts `toEqual` between CLI `--json` and MCP `content[0].text` for service-sourced fixtures, and `toMatchObject` for the `INVALID_ARGUMENT` fixture where surface-specific error text is acceptable. +`pkg_info` shares its envelope builder, text formatter, and error classifier with the CLI `githits pkg info` command via `src/shared/package-summary-request.ts`, `src/shared/package-summary-response.ts`, and `src/shared/package-intelligence-error-map.ts`. The parity test (`src/tools/package-summary-parity.test.ts`) passes `format: "json"` and asserts `toEqual` between CLI `--json` and MCP JSON output for service-sourced fixtures, and `toMatchObject` for the `INVALID_ARGUMENT` fixture where surface-specific error text is acceptable. ### `pkg_vulns` response shape @@ -64,7 +64,7 @@ The CLI mirrors the production MCP tool contract where equivalent tools exist. C **Registry coverage.** Only npm, PyPI, Hex, and Crates have vulnerability data. The CLI + MCP reject the other five registries client-side with a tool-specific message (`pkg vulns only supports npm, pypi, hex, and crates. Got: ${registry}.`) — rejection predicate lives in `src/shared/package-vulnerabilities-request.ts` rather than the shared registry module, since it is a tool-specific capability matrix. -`pkg_vulns` shares its envelope builder with the CLI `githits pkg vulns` command via `src/shared/package-vulnerabilities-request.ts` and `src/shared/package-vulnerabilities-response.ts`. The terminal formatter is CLI-only (MCP always emits JSON). The parity test (`src/tools/package-vulnerabilities-parity.test.ts`) asserts `toEqual` across the service-sourced success and typed-error fixtures, and `toMatchObject` for builder-sourced `INVALID_ARGUMENT` fixtures such as unsupported registries and tag-style `v`-prefixed versions. +`pkg_vulns` shares its envelope builder and text formatter with the CLI `githits pkg vulns` command via `src/shared/package-vulnerabilities-request.ts` and `src/shared/package-vulnerabilities-response.ts`. MCP defaults to compact text and uses `format: "json"` for structured output. The parity test (`src/tools/package-vulnerabilities-parity.test.ts`) passes `format: "json"`, asserts `toEqual` across the service-sourced success and typed-error fixtures, and uses `toMatchObject` for builder-sourced `INVALID_ARGUMENT` fixtures such as unsupported registries and tag-style `v`-prefixed versions. ### `pkg_deps` response shape @@ -86,7 +86,7 @@ The CLI mirrors the production MCP tool contract where equivalent tools exist. C **MCP schema notes.** Permissive (`registry: z.string()`, `package_name: z.string()`, …) with validation in-handler via `buildPackageDependenciesParams`. Deliberately no `include_groups` input — with the data-first envelope emitting `groups` unconditionally when the backend returns `dependencyGroups`, the flag would be a silently ignored no-op. Neither the MCP surface nor the CLI applies a depth default: `max_depth` / `--depth` is optional and, when omitted, the backend's full-graph traversal is used. `include_importers` requires `include_transitive: true`; `max_depth` and CLI `--depth` require the transitive view — passing them alone is rejected with `INVALID_ARGUMENT` rather than silently ignored. -`pkg_deps` shares its envelope builder with the CLI `githits pkg deps` command via `src/shared/package-dependencies-request.ts` and `src/shared/package-dependencies-response.ts`. The terminal formatter is CLI-only. The parity test (`src/tools/package-dependencies-parity.test.ts`) asserts `toEqual` across every service-sourced success / error fixture (runtime, zero-dep, full-view, optional-lifecycle, multi-lifecycle, filter-matched-nothing, Crates-target-cfg dedup round-trip, transitive, versioned match / diff, NOT_FOUND, VERSION_NOT_FOUND, BACKEND_ERROR) and `toMatchObject` for builder-sourced `INVALID_ARGUMENT` (unsupported registry, tag-style version, unknown lifecycle). +`pkg_deps` shares its envelope builder and text formatter with the CLI `githits pkg deps` command via `src/shared/package-dependencies-request.ts` and `src/shared/package-dependencies-response.ts`. MCP defaults to compact text and uses MCP-native hints such as `pass lifecycle="all"`; CLI hints remain CLI-native. The parity test (`src/tools/package-dependencies-parity.test.ts`) passes `format: "json"`, asserts `toEqual` across every service-sourced success / error fixture (runtime, zero-dep, full-view, optional-lifecycle, multi-lifecycle, filter-matched-nothing, Crates-target-cfg dedup round-trip, transitive, versioned match / diff, NOT_FOUND, VERSION_NOT_FOUND, BACKEND_ERROR), and uses `toMatchObject` for builder-sourced `INVALID_ARGUMENT` (unsupported registry, tag-style version, unknown lifecycle). ### `pkg_changelog` response shape @@ -98,7 +98,7 @@ The CLI mirrors the production MCP tool contract where equivalent tools exist. C **Mode selection.** `from_version` triggers range mode (returns every entry in `[fromVersion, toVersion]` with no cap). Latest mode is the default, capped by `limit` (1–50, backend default 10). `from_version` + `limit` is rejected client-side with `INVALID_ARGUMENT` rather than silently routed to one mode. -**`include_bodies` lever.** Release bodies on large packages (Kubernetes, Node) can run 10 KB+ per entry; a 100-entry range could produce a multi-hundred-KB envelope. `include_bodies: false` opts out explicitly — not silent truncation. Other fields (version / normalizedVersion / publishedAt / htmlUrl) remain so agents still get the release timeline. CLI's `--no-body` mirrors this flag and affects both terminal rendering and `--json` output. The CLI terminal separately caps each body at 10 lines by default for readability (with a `… (+N more — use --verbose …)` footer); `--verbose` uncaps that preview but does not change `--json` output. +**`include_bodies` lever and body previews.** Release bodies on large packages (Kubernetes, Node) can run 10 KB+ per entry; a 100-entry range could produce a multi-hundred-KB envelope. `include_bodies: false` opts out explicitly in JSON and text — not silent truncation. Other fields (version / normalizedVersion / publishedAt / htmlUrl) remain so agents still get the release timeline. MCP text mode caps each body preview at 10 lines and tells agents to pass `format="json"` for full bodies. CLI terminal output uses the same preview cap but gives the CLI-native `--verbose` hint; `--verbose` uncaps terminal previews but does not change `--json` output. **`filter.*` echo.** `filter` is emitted only when the caller explicitly supplied at least one of `from_version`, `to_version`, `limit`, or `git_ref`. Backend-default `limit: 10` / `toVersion: ` is never echoed. The request builder tracks explicit-vs-defaulted via an `explicitFilterFields` set so defaults don't round-trip as caller intent. @@ -108,7 +108,7 @@ The CLI mirrors the production MCP tool contract where equivalent tools exist. C **Overlap with `pkg_info`.** `pkg_info` already surfaces a short-form `recentChanges` block (from the backend's `latestChangelogs` field on `PackageSummaryResult`). For a quick "what shipped recently" glance embedded in a package overview, use `pkg_info`. For the full range-capable, body-rich, `include_bodies`-toggleable changelog with `--no-body` timeline mode and repo-URL addressing, use `pkg_changelog`. -`pkg_changelog` shares its envelope builder with the CLI `githits pkg changelog` command via `src/shared/package-changelog-request.ts` and `src/shared/package-changelog-response.ts`. The terminal formatter is CLI-only. The parity test (`src/tools/package-changelog-parity.test.ts`) asserts `toEqual` across every service-sourced success / error fixture (happy latest, range mode, repo-URL addressing, no-source package-version entries, `--no-body` / `include_bodies: false`, default bodies, empty entries, NOT_FOUND, PackageIntelligenceTargetNotFoundError, VERSION_NOT_FOUND, BACKEND_ERROR) and `toMatchObject` for builder-sourced `INVALID_ARGUMENT`. +`pkg_changelog` shares its envelope builder and text formatter with the CLI `githits pkg changelog` command via `src/shared/package-changelog-request.ts` and `src/shared/package-changelog-response.ts`. MCP defaults to compact text with MCP-native `format="json"` hints for full bodies. The parity test (`src/tools/package-changelog-parity.test.ts`) passes `format: "json"`, asserts `toEqual` across every service-sourced success / error fixture (happy latest, range mode, repo-URL addressing, no-source package-version entries, `--no-body` / `include_bodies: false`, default bodies, empty entries, NOT_FOUND, PackageIntelligenceTargetNotFoundError, VERSION_NOT_FOUND, BACKEND_ERROR), and uses `toMatchObject` for builder-sourced `INVALID_ARGUMENT`. ### `code_files` / `code_read` / `code_grep` response shapes @@ -149,7 +149,7 @@ The `hint` field is emitted only when the cap *actually truncated* the response ## Text response format (`format: "text-v1"`) -`search`, `code_files`, and `code_grep` accept a `format` parameter on the MCP surface. The default is `"text-v1"` — a compact line-oriented format that drops JSON scaffolding to stay lean in agent context. Programmatic callers (parity tests, scripts that parse responses) pass `format: "json"` explicitly. `"text"` is accepted as an alias for `"text-v1"` to keep agent prompts terse. +`get_example`, `search_language`, `search`, `search_status`, `docs_list`, `docs_read`, `pkg_info`, `pkg_vulns`, `pkg_deps`, `pkg_changelog`, `code_files`, `code_read`, and `code_grep` accept a `format` parameter on the MCP surface. The default is `"text-v1"` — a compact line-oriented format that drops JSON scaffolding to stay lean in agent context. Programmatic callers (parity tests, scripts that parse responses) pass `format: "json"` explicitly. `"text"` is accepted as an alias for `"text-v1"` to keep agent prompts terse. **Why text-v1 default.** A 10-hit `search` JSON envelope runs 5–7 KB after compaction; the same hits in `text-v1` land around 3–4 KB. The savings come from dropped quoting, dropped key repetition, and dropped fields that an agent does not need at the per-call decision point (highlights byte offsets, repeated locator scaffolding). The token budget belongs to the agent's reasoning, not to JSON structure. @@ -157,6 +157,10 @@ The `hint` field is emitted only when the cap *actually truncated* the response **ASCII-only.** Separators are ` | `; ellipsis is `...`; no box-drawing or Latin-1 punctuation. Tokenizer behavior for multi-byte UTF-8 varies across BPE variants, and the format runs into Claude, Codex CLI, OpenCode, Cline, Cursor, etc. — ASCII keeps it predictable. +**Example-search anatomy.** `get_example` text mode returns markdown directly, followed by `solution_id: ` when the REST response includes an app URL. This avoids JSON-wrapped markdown while preserving the `feedback` workflow. `search_language` text mode returns one match per line as `name (Display Name) aliases: a, b`; agents should pass the `name` value to `get_example.language`. + +**Package metadata anatomy.** `pkg_info`, `pkg_vulns`, `pkg_deps`, and `pkg_changelog` text mode reuse the shared no-color terminal formatters but inject MCP-native hints. `pkg_deps` hides non-runtime groups by default and says `pass lifecycle="all"` when groups exist. `pkg_changelog` caps body previews and says `pass format="json" for full bodies` when text omitted lines. Package tools keep JSON errors in all formats because agents can reliably branch on `{error, code, retryable, details?}`. + **Hit anatomy** (`search` text-v1): ``` @@ -173,7 +177,7 @@ search | hits | query="..." More hits available. Pass offset=N for the next page or limit=N to widen. ``` -`` compacts to `code` / `symbol` / `docs` / `repo-docs`. `` is `path:start-end` (optionally followed by ` | qualifiedPath | kind`) for code/symbol hits; `pageId: ` for documentation pages; or `sourceUrl` as a last resort. The locator line is copy-pasteable as `code_read` arguments. +`` compacts to `code` / `symbol` / `docs` / `repo-docs`. `` is a ready-to-call follow-up when possible: `code_read target="npm:pkg@version" path="..." start_line=N end_line=M` for code/symbol hits and `docs_read page_id="..."` for documentation hits. If a code/symbol hit lacks a file path, text mode prints `follow-up unavailable: missing filePath` rather than fabricating a path. **Listing anatomy** (`code_files` text-v1): diff --git a/gemini-extension.json b/gemini-extension.json index dffe9ca6..525b523f 100644 --- a/gemini-extension.json +++ b/gemini-extension.json @@ -1,6 +1,6 @@ { "name": "githits", - "version": "0.3.1", + "version": "0.3.2", "description": "Code examples from global open source for developers and AI assistants.", "mcpServers": { "githits": { diff --git a/package.json b/package.json index ff9f87ab..780bc0fb 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "githits", "description": "CLI companion for GitHits - code examples from global open source for developers and AI assistants", - "version": "0.3.1", + "version": "0.3.2", "type": "module", "files": [ "dist", diff --git a/plugins/claude/.claude-plugin/plugin.json b/plugins/claude/.claude-plugin/plugin.json index 196cd910..10abaa20 100644 --- a/plugins/claude/.claude-plugin/plugin.json +++ b/plugins/claude/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "githits", - "version": "0.3.1", + "version": "0.3.2", "description": "Code examples from global open source for developers and AI assistants", "author": { "name": "GitHits" diff --git a/src/commands/languages.ts b/src/commands/languages.ts index b1eb5df5..9ad93472 100644 --- a/src/commands/languages.ts +++ b/src/commands/languages.ts @@ -33,7 +33,11 @@ export async function languagesAction( const displayList: LanguageMatch[] = query ? filterLanguages(allLanguages, query) - : allLanguages.map(({ name, display_name }) => ({ name, display_name })); + : allLanguages.map(({ name, display_name, aliases }) => ({ + name, + display_name, + aliases, + })); if (options.json) { console.log(JSON.stringify(displayList)); diff --git a/src/commands/mcp-instructions.ts b/src/commands/mcp-instructions.ts index 4a26baac..a62c4f14 100644 --- a/src/commands/mcp-instructions.ts +++ b/src/commands/mcp-instructions.ts @@ -13,16 +13,16 @@ import type { Dependencies } from "../container.js"; * guidance for the same always-on tool surface the server registers. */ -const CORE_BLOCK = `GitHits provides verified, canonical code examples from global open source. Use it when you're stuck, repeated attempts failed, you need up-to-date API usage, the question is comparative across OSS projects (e.g. "how does X vs Y handle Z"), the answer requires reading how a real codebase implements a feature, or the user mentions GitHits. +const CORE_BLOCK = `GitHits provides verified, canonical code examples from global open source. Use it for global solution synthesis and canonical examples when you're stuck, repeated attempts failed, you need up-to-date API usage, the question is comparative across OSS projects (e.g. "how does X vs Y handle Z"), the answer requires reading how a real codebase implements a feature, or the user mentions GitHits. -Example workflow: call \`get_example\` with one focused question; pass \`language\` only when you know the exact language name, otherwise call \`search_language\` first. Send \`feedback\` on the returned solution_id. Reuse prior results before searching again.`; +Example workflow: call \`get_example\` with one focused question; pass \`language\` only when you know the exact language name, otherwise call \`search_language\` first. Default output is markdown with a trailing \`solution_id\`; send \`feedback\` on that solution_id. Reuse prior results before searching again. For dependency-specific grounding, use package-scoped \`search\` before global \`get_example\`.`; const PACKAGE_TOOLS_PREAMBLE = `Indexed package/source tools inspect third-party dependency source, docs, and registry metadata. Use them when a stack trace points into a dependency, you need to verify how a library works, or you're evaluating whether to add or upgrade a package. -Package spec: \`registry:name[@version]\`.`; +Package spec: \`registry:name[@version]\`. Default outputs are compact \`text-v1\` for agent context efficiency; pass \`format: "json"\` only when you need structured fields for programmatic parsing.`; const PKG_INFO_BULLET = - "- `pkg_info` — instant package overview: latest version, license, downloads, quickstart, and active advisory count."; + '- `pkg_info` — compact package overview: latest version, license, downloads, quickstart, and active advisory count. Pass `format: "json"` for the structured envelope.'; const DOCS_LIST_BULLET = "- `docs_list` — browse hosted and repository-backed package docs. Entries include stable pageIds, source URLs, and repo-file follow-up metadata when available."; @@ -31,16 +31,16 @@ const DOCS_READ_BULLET = "- `docs_read` — read a documentation page by pageId. Works for both hosted docs and repo-backed docs."; const PKG_VULNS_BULLET = - "- `pkg_vulns` — known CVE / OSV advisories for npm, PyPI, Hex, or Crates packages, optionally pinned to `@version`. Filter with `min_severity`; include retracted advisories with `include_withdrawn`."; + '- `pkg_vulns` — compact known CVE / OSV advisory summary for npm, PyPI, Hex, or Crates packages, optionally pinned to `version`. Filter with `min_severity`; include retracted advisories with `include_withdrawn`. Pass `format: "json"` for per-advisory structured fields.'; const PKG_DEPS_BULLET = - "- `pkg_deps` — direct runtime deps plus lifecycle/feature groups when available. Use `lifecycle` to filter groups, `include_transitive` for the full graph, and `include_importers` for provenance. Supports npm, PyPI, Hex, Crates, vcpkg, and Zig."; + '- `pkg_deps` — compact direct runtime deps by default. Use `lifecycle: "runtime"` for explicit runtime-only, a concrete lifecycle for runtime plus matching non-runtime deps, or `lifecycle: "all"` for all groups. Use `include_transitive` for the full graph and `include_importers` for provenance. Pass `format: "json"` for the structured envelope.'; const PKG_CHANGELOG_BULLET = - "- `pkg_changelog` — release notes for a package or GitHub repo, newest-first. Default latest mode returns recent entries with markdown bodies; `from_version` switches to range mode. Set `include_bodies: false` for a compact timeline."; + '- `pkg_changelog` — compact release notes for a package or GitHub repo, newest-first. Default latest mode returns recent entries with markdown body previews; `from_version` switches to range mode. Set `include_bodies: false` for a compact timeline or pass `format: "json"` for full bodies.'; const SEARCH_BULLET = - '- `search` — unified search across indexed dependency code, docs, and symbols. Omit `sources` for AUTO. Default output is compact `text-v1`; pass `format: "json"` for locators, highlights, and source status. Complete by default; opt into partial hits with `allow_partial_results: true`. Incomplete responses carry a `searchRef`.'; + '- `search` — unified search across indexed dependency code, docs, and symbols. Omit `sources` for AUTO. Use `sources:["code"]` for implementation/examples/tests text, `sources:["symbol"]` for precise API/entity lookup, and `sources:["docs"]` for guides/reference/changelogs. Default output is compact `text-v1` with ready-to-call follow-up arguments; pass `format: "json"` for structured locators, highlights, and source status. Complete by default; opt into partial hits with `allow_partial_results: true`. Incomplete responses carry a `searchRef`.'; const SEARCH_STATUS_BULLET = "- `search_status` — follow up a prior `search` by `searchRef` to check progress, fetch partial hits, or fetch final results."; @@ -55,7 +55,7 @@ const CODE_GREP_BULLET = "- `code_grep` — deterministic text or regex grep over indexed source. Use it when you know the pattern; use `search` for discovery. Narrow with `path`, `path_prefix`, `globs`, or `extensions`. Each match's `path:line` chains into `code_read`."; const SEARCH_VS_SYMBOLS_TIP = - 'Prefer `get_example` for canonical example retrieval; prefer unified `search` for indexed dependency and repository discovery; use `sources:["symbol"]` when you want symbol-shaped results. Use `code_grep` for deterministic text matching and `code_read` for focused-window inspection of a known file.'; + 'Use code-first grounding for behavioral claims: source, symbols, tests, examples, and call sites beat docs prose. Prefer unified `search` for indexed dependency and repository discovery; use `sources:["symbol"]` when you want symbol-shaped results. Prefer `get_example` for global canonical example retrieval after package-scoped grounding. Use `code_grep` for deterministic text matching and `code_read` for focused-window inspection of a known file.'; const REFERENCE_FIRST_TIP = "Strategy — reference-first, content-second. Locate symbols and lines with `search` / `code_grep` first, then read only the needed window with `code_read` using explicit `start_line` / `end_line` values, typically 80-150 lines around the match. The MCP `code_read` surface caps each call at 150 lines."; diff --git a/src/commands/pkg/deps.test.ts b/src/commands/pkg/deps.test.ts index 7f397356..55e34448 100644 --- a/src/commands/pkg/deps.test.ts +++ b/src/commands/pkg/deps.test.ts @@ -41,7 +41,9 @@ describe("pkgDepsAction", () => { const combined = writes.join(""); expect(combined).toContain("express @ 5.2.1 · npm"); expect(combined).toContain("3 direct runtime dependencies"); - expect(combined).toContain("Hidden groups: development — use --groups."); + expect(combined).toContain( + "Hidden groups: development — use --lifecycle all.", + ); writeSpy.mockRestore(); }); @@ -54,11 +56,11 @@ describe("pkgDepsAction", () => { const payload = JSON.parse(output); expect(payload.registry).toBe("npm"); expect(payload.runtime.count).toBe(3); - expect(payload.groups.items.length).toBe(2); + expect(payload.groups).toBeUndefined(); logSpy.mockRestore(); }); - it("implies --groups when --lifecycle is set (groups block appears beneath direct deps list)", async () => { + it("shows groups when a non-runtime lifecycle is set", async () => { const writes: string[] = []; const writeSpy = spyOn(process.stdout, "write").mockImplementation((( chunk: string | Uint8Array, diff --git a/src/commands/pkg/deps.ts b/src/commands/pkg/deps.ts index 9fd0d4a3..e9306e28 100644 --- a/src/commands/pkg/deps.ts +++ b/src/commands/pkg/deps.ts @@ -18,7 +18,6 @@ import { export interface PkgDepsCommandOptions { lifecycle?: string; - groups?: boolean; transitive?: boolean; depth?: string; verbose?: boolean; @@ -35,7 +34,7 @@ export interface PkgDepsCommandDependencies { /** * Core `pkg deps` action. Accepts `[@]`. The * `--lifecycle` filter is server-side (filters `dependencyGroups` - * only) and implies the groups view. `--groups` alone renders the + * only) and implies the groups view. `--lifecycle all` renders the * structured view without filtering. `--transitive` opts into the * aggregate counts + conflict / circular-dependency signals. No * client-side depth cap by default — matches `npm ls` / `cargo @@ -101,17 +100,14 @@ export async function pkgDepsAction( return; } - // `--lifecycle` implies `--groups`: there is no flat projection - // for non-runtime lifecycles on the wire, and the structured view - // is the only coherent display for filtered lifecycles. - const showGroups = - (options.groups ?? false) || canonicalLifecycles.length > 0; + const showGroups = canonicalLifecycles.some((entry) => entry !== "runtime"); const output = formatPackageDependenciesTerminal(report, { verbose: options.verbose, useColors: shouldUseColors(), requestedVersion: parsed.version, - canonicalLifecycles, + canonicalLifecycles: + canonicalLifecycles.length > 0 ? canonicalLifecycles : undefined, includeTransitive: options.transitive, maxDepth: userDepth, showGroups, @@ -195,9 +191,9 @@ function formatDepsTerminalError(mapped: MappedError): string { } const PKG_DEPS_DESCRIPTION = `Analyze package dependencies. By default shows the flat list of -direct runtime dependencies. Use --groups for the structured view +direct runtime dependencies. Use --lifecycle all for the structured view (dev / peer / build / optional, plus registry-specific feature / TFM -groups). --lifecycle filters groups server-side and implies --groups. +groups). Concrete --lifecycle values include runtime plus matching groups. --transitive opts into aggregate edge / unique-package counts, conflict detection, and circular-dependency flags. @@ -211,13 +207,9 @@ export function registerPkgDepsCommand(pkgCommand: Command): Command { .summary("Analyze dependencies for a package") .description(PKG_DEPS_DESCRIPTION) .argument("", "Package spec, e.g. npm:express or npm:express@4.18.0") - .option( - "-g, --groups", - "Render the structured groups view instead of the flat runtime list", - ) .option( "-l, --lifecycle ", - "Filter groups server-side (runtime, development, build, peer, optional; comma-separated for multi-select). Implies --groups.", + "Dependency lifecycle breadth (runtime, development, build, peer, optional, all; comma-separated for multi-select except all).", ) .option( "-t, --transitive", diff --git a/src/shared/code-navigation-target.ts b/src/shared/code-navigation-target.ts new file mode 100644 index 00000000..278c9abf --- /dev/null +++ b/src/shared/code-navigation-target.ts @@ -0,0 +1,47 @@ +import type { CodeNavigationTarget } from "../services/index.js"; +import { toCodeNavigationRegistry } from "./code-navigation.js"; +import { InvalidArgumentError, parsePackageSpec } from "./package-spec.js"; + +/** + * Parse a compact code-navigation target string. + * + * Package targets use the shared package spec grammar, e.g. + * `npm:react@18.2.0`. Repository targets are full URLs with an optional + * `#gitRef` suffix, e.g. `https://github.com/facebook/react#HEAD`. + */ +export function parseCodeNavigationTargetSpec( + spec: string, +): CodeNavigationTarget { + const trimmed = spec.trim(); + if (trimmed.length === 0) { + throw new InvalidArgumentError("Target spec cannot be empty."); + } + + if (trimmed.startsWith("http://") || trimmed.startsWith("https://")) { + return parseRepoTarget(trimmed); + } + + const parsed = parsePackageSpec(trimmed); + return { + registry: toCodeNavigationRegistry(parsed.registry), + packageName: parsed.name, + version: parsed.version, + }; +} + +function parseRepoTarget(spec: string): CodeNavigationTarget { + const hashIndex = spec.lastIndexOf("#"); + if (hashIndex === -1) { + return { repoUrl: spec, gitRef: "HEAD" }; + } + + const repoUrl = spec.slice(0, hashIndex); + const gitRef = spec.slice(hashIndex + 1); + if (!repoUrl || !gitRef) { + throw new InvalidArgumentError( + "Repository target must be a full URL with optional #gitRef suffix.", + ); + } + + return { repoUrl, gitRef }; +} diff --git a/src/shared/follow-up-command-text.ts b/src/shared/follow-up-command-text.ts new file mode 100644 index 00000000..a30d934c --- /dev/null +++ b/src/shared/follow-up-command-text.ts @@ -0,0 +1,83 @@ +import type { UnifiedSearchHitPayload } from "./unified-search-response.js"; + +interface CodeReadCommandInput { + registry?: string; + packageName?: string; + version?: string; + repoUrl?: string; + gitRef?: string; + filePath?: string; + startLine?: number; + endLine?: number; +} + +export function buildSearchHitFollowUpCommand( + hit: UnifiedSearchHitPayload, +): string { + const loc = hit.locator; + if (loc.pageId) { + return buildDocsReadCommand(loc.pageId, loc.startLine, loc.endLine); + } + if (loc.filePath) { + return buildCodeReadCommand({ + registry: loc.registry, + packageName: loc.packageName, + version: loc.version, + repoUrl: loc.repoUrl, + gitRef: loc.gitRef, + filePath: loc.filePath, + startLine: loc.startLine, + endLine: loc.endLine, + }); + } + if (hit.type === "repository_code" || hit.type === "repository_symbol") { + return "follow-up unavailable: missing filePath"; + } + if (loc.sourceUrl) return loc.sourceUrl; + return ""; +} + +export function buildDocsReadCommand( + pageId: string, + startLine?: number, + endLine?: number, +): string { + const parts = [`docs_read page_id=${quote(pageId)}`]; + appendRange(parts, startLine, endLine); + return parts.join(" "); +} + +export function buildCodeReadCommand(input: CodeReadCommandInput): string { + if (!input.filePath) return "follow-up unavailable: missing filePath"; + const target = buildTargetSpec(input); + if (!target) return "follow-up unavailable: missing target"; + const parts = [ + `code_read target=${quote(target)}`, + `path=${quote(input.filePath)}`, + ]; + appendRange(parts, input.startLine, input.endLine); + return parts.join(" "); +} + +function buildTargetSpec(input: CodeReadCommandInput): string | undefined { + if (input.repoUrl) { + return `${input.repoUrl}#${input.gitRef ?? "HEAD"}`; + } + if (input.registry && input.packageName) { + return `${input.registry}:${input.packageName}${input.version ? `@${input.version}` : ""}`; + } + return undefined; +} + +function appendRange( + parts: string[], + startLine: number | undefined, + endLine: number | undefined, +): void { + if (typeof startLine === "number") parts.push(`start_line=${startLine}`); + if (typeof endLine === "number") parts.push(`end_line=${endLine}`); +} + +function quote(value: string): string { + return JSON.stringify(value); +} diff --git a/src/shared/index.ts b/src/shared/index.ts index 15f96aa6..78b38115 100644 --- a/src/shared/index.ts +++ b/src/shared/index.ts @@ -24,6 +24,7 @@ export { type MappedErrorCode, mapCodeNavigationError, } from "./code-navigation-error-map.js"; +export { parseCodeNavigationTargetSpec } from "./code-navigation-target.js"; export { colorize, colors, @@ -38,6 +39,11 @@ export { export { debugLog } from "./debug-log.js"; export { lowerDocSourceKind } from "./docs-follow-up.js"; export { extractSolutionId } from "./extract-solution-id.js"; +export { + buildCodeReadCommand, + buildDocsReadCommand, + buildSearchHitFollowUpCommand, +} from "./follow-up-command-text.js"; export { buildGrepRepoParams, GREP_REPO_PATTERN_NOTE, @@ -74,6 +80,7 @@ export { type LeanPackageDocListEntry, type LeanPackageDocsEnvelope, } from "./list-package-docs-response.js"; +export { renderListPackageDocsText } from "./list-package-docs-text.js"; export { InvalidKeywordsError, normaliseKeywords, @@ -149,6 +156,7 @@ export { toPkgseerRegistry, toPkgseerRegistryLowercase, } from "./pkgseer-registry.js"; +export { renderReadFileText } from "./read-file-text.js"; export { buildReadPackageDocParams, type ReadPackageDocRequestBuildResult, @@ -159,6 +167,7 @@ export { formatReadPackageDocTerminal, type LeanPackageDocEnvelope, } from "./read-package-doc-response.js"; +export { renderReadPackageDocText } from "./read-package-doc-text.js"; export { AuthRequiredError, requireAuth } from "./require-auth.js"; export { endTelemetrySpan, @@ -190,6 +199,7 @@ export { type UnifiedSearchStatusIncompletePayload, type UnifiedSearchStatusResultPayload, } from "./unified-search-response.js"; +export { renderUnifiedSearchStatusText } from "./unified-search-status-text.js"; export { parseUnifiedSearchTargetSpec } from "./unified-search-target.js"; export { renderUnifiedSearchError, diff --git a/src/shared/language-filter.test.ts b/src/shared/language-filter.test.ts index c9766aa6..3ab6353d 100644 --- a/src/shared/language-filter.test.ts +++ b/src/shared/language-filter.test.ts @@ -69,12 +69,20 @@ describe("filterLanguages", () => { expect(result).toHaveLength(3); }); - it("returns only name and display_name", () => { + it("returns fields needed to choose a get_example language", () => { const result = filterLanguages(testLanguages, "python"); expect(result).toHaveLength(1); const match = result[0]; - expect(match).toEqual({ name: "python", display_name: "Python" }); - expect(match && Object.keys(match)).toEqual(["name", "display_name"]); + expect(match).toEqual({ + name: "python", + display_name: "Python", + aliases: ["py"], + }); + expect(match && Object.keys(match)).toEqual([ + "name", + "display_name", + "aliases", + ]); }); }); diff --git a/src/shared/language-filter.ts b/src/shared/language-filter.ts index e7bd00be..0d79a36f 100644 --- a/src/shared/language-filter.ts +++ b/src/shared/language-filter.ts @@ -3,13 +3,15 @@ import type { Language } from "../services/githits-service.js"; export interface LanguageMatch { name: string; display_name: string; + aliases: string[]; } const DEFAULT_LIMIT = 5; /** * Filter languages by case-insensitive substring match on name, display_name, or aliases. - * Returns up to `limit` matches (default 5) with only name and display_name fields. + * Returns up to `limit` matches (default 5) with fields needed to choose + * the exact `get_example` language input. */ export function filterLanguages( languages: Language[], @@ -26,5 +28,9 @@ export function filterLanguages( lang.aliases.some((a) => a.toLowerCase().includes(lowerQuery)), ) .slice(0, limit) - .map(({ name, display_name }) => ({ name, display_name })); + .map(({ name, display_name, aliases }) => ({ + name, + display_name, + aliases, + })); } diff --git a/src/shared/list-package-docs-response.ts b/src/shared/list-package-docs-response.ts index 3e2eb6bd..4e30bf79 100644 --- a/src/shared/list-package-docs-response.ts +++ b/src/shared/list-package-docs-response.ts @@ -13,7 +13,6 @@ export interface LeanPackageDocListEntry { gitRef?: string; requestedRef?: string; filePath?: string; - linkName?: string; lastUpdatedAt?: string; } @@ -60,7 +59,6 @@ export function buildListPackageDocsSuccessPayload( if (page.gitRef) entry.gitRef = page.gitRef; if (page.requestedRef) entry.requestedRef = page.requestedRef; if (page.filePath) entry.filePath = page.filePath; - if (page.linkName) entry.linkName = page.linkName; if (lastUpdatedAt) entry.lastUpdatedAt = lastUpdatedAt; return entry; }), diff --git a/src/shared/list-package-docs-text.ts b/src/shared/list-package-docs-text.ts new file mode 100644 index 00000000..60199723 --- /dev/null +++ b/src/shared/list-package-docs-text.ts @@ -0,0 +1,62 @@ +import { + buildCodeReadCommand, + buildDocsReadCommand, +} from "./follow-up-command-text.js"; +import type { LeanPackageDocsEnvelope } from "./list-package-docs-response.js"; + +const SEP = " | "; + +export function renderListPackageDocsText( + envelope: LeanPackageDocsEnvelope, +): string { + const lines: string[] = []; + lines.push(buildHeader(envelope)); + lines.push(""); + + if (envelope.pages.length === 0) { + lines.push("No documentation pages found."); + return lines.join("\n"); + } + + for (const page of envelope.pages) { + lines.push( + [ + page.pageId, + page.title ?? "", + page.sourceKind ?? "", + page.sourceUrl ?? "", + ].join(SEP), + ); + lines.push(` ${buildDocsReadCommand(page.pageId)}`); + if (page.sourceKind === "repo" && page.repoUrl && page.filePath) { + lines.push( + ` ${buildCodeReadCommand({ + repoUrl: page.repoUrl, + gitRef: page.requestedRef ?? page.gitRef, + filePath: page.filePath, + startLine: 1, + endLine: 150, + })}`, + ); + } + } + + if (envelope.nextCursor) { + lines.push(""); + lines.push(`More docs available. Pass after=${envelope.nextCursor}.`); + } + if (envelope.stale) { + lines.push(""); + lines.push("Documentation may be stale."); + } + return lines.join("\n"); +} + +function buildHeader(envelope: LeanPackageDocsEnvelope): string { + const target = + envelope.registry && envelope.name + ? `${envelope.registry}:${envelope.name}${envelope.version ? `@${envelope.version}` : ""}` + : "package docs"; + const suffix = envelope.total !== undefined ? `/${envelope.total}` : ""; + return `docs_list${SEP}${target}${SEP}${envelope.pages.length}${suffix} page${envelope.pages.length === 1 ? "" : "s"}`; +} diff --git a/src/shared/package-changelog-response.ts b/src/shared/package-changelog-response.ts index b5067dbb..1c6be48b 100644 --- a/src/shared/package-changelog-response.ts +++ b/src/shared/package-changelog-response.ts @@ -189,6 +189,7 @@ function buildFilterBlock( export interface FormatChangelogTerminalOptions { verbose?: boolean; useColors: boolean; + fullBodyHint?: string; } /** @@ -279,7 +280,7 @@ function appendBodyLines( const hidden = bodyLines.length - visible.length; if (hidden > 0) { lines.push( - ` ${dim(`… (+${hidden} more line${hidden === 1 ? "" : "s"} — use --verbose for the full body)`, options.useColors)}`, + ` ${dim(`… (+${hidden} more line${hidden === 1 ? "" : "s"} — ${options.fullBodyHint ?? "use --verbose for the full body"})`, options.useColors)}`, ); } } diff --git a/src/shared/package-dependencies-request.test.ts b/src/shared/package-dependencies-request.test.ts index f076f174..5cc65fe9 100644 --- a/src/shared/package-dependencies-request.test.ts +++ b/src/shared/package-dependencies-request.test.ts @@ -94,13 +94,15 @@ describe("buildPackageDependenciesParams — version handling", () => { describe("buildPackageDependenciesParams — lifecycle parsing", () => { it("parses a single token", () => { - const { params, canonicalLifecycles } = buildPackageDependenciesParams({ - registry: "npm", - packageName: "x", - lifecycle: "runtime", - }); - expect(params.lifecycle).toEqual(["runtime"]); + const { params, canonicalLifecycles, wireLifecycles } = + buildPackageDependenciesParams({ + registry: "npm", + packageName: "x", + lifecycle: "runtime", + }); + expect(params.lifecycle).toBeUndefined(); expect(canonicalLifecycles).toEqual(["runtime"]); + expect(wireLifecycles).toEqual([]); }); it("parses a CSV list, deduplicates, and sorts canonically", () => { @@ -110,7 +112,29 @@ describe("buildPackageDependenciesParams — lifecycle parsing", () => { lifecycle: "optional,development,runtime,development", }); expect(canonicalLifecycles).toEqual(["runtime", "development", "optional"]); - expect(params.lifecycle).toEqual(["runtime", "development", "optional"]); + expect(params.lifecycle).toEqual(["development", "optional"]); + }); + + it("accepts all as a client-side full-view lifecycle", () => { + const { params, canonicalLifecycles, wireLifecycles } = + buildPackageDependenciesParams({ + registry: "npm", + packageName: "x", + lifecycle: "all", + }); + expect(canonicalLifecycles).toEqual(["all"]); + expect(wireLifecycles).toEqual([]); + expect(params.lifecycle).toBeUndefined(); + }); + + it("rejects all combined with concrete lifecycles", () => { + expect(() => + buildPackageDependenciesParams({ + registry: "npm", + packageName: "x", + lifecycle: "all,development", + }), + ).toThrow("lifecycle=all cannot be combined"); }); it("tolerates uppercase / whitespace / repeats", () => { diff --git a/src/shared/package-dependencies-request.ts b/src/shared/package-dependencies-request.ts index 3468ed2c..103eaab8 100644 --- a/src/shared/package-dependencies-request.ts +++ b/src/shared/package-dependencies-request.ts @@ -51,6 +51,8 @@ export type DependencyLifecycle = | "peer" | "optional"; +export type DependencyLifecycleInput = DependencyLifecycle | "all"; + const LIFECYCLES: readonly DependencyLifecycle[] = [ "runtime", "development", @@ -112,7 +114,8 @@ export interface PackageDependenciesRequestBuildResult { * deduplicated). Surfaces verbatim as the envelope's * `filter.lifecycles` when non-empty. Empty array means "no filter". */ - canonicalLifecycles: DependencyLifecycle[]; + canonicalLifecycles: DependencyLifecycleInput[]; + wireLifecycles: DependencyLifecycle[]; } export function buildPackageDependenciesParams( @@ -142,6 +145,10 @@ export function buildPackageDependenciesParams( const version = normaliseVersion(input.version); const canonicalLifecycles = resolveLifecycles(input.lifecycle); + const wireLifecycles = canonicalLifecycles.filter( + (entry): entry is DependencyLifecycle => + entry !== "runtime" && entry !== "all", + ); const maxDepth = input.maxDepth; if (maxDepth !== undefined) { @@ -154,14 +161,14 @@ export function buildPackageDependenciesParams( return { canonicalLifecycles, + wireLifecycles, params: { registry, packageName: trimmedName, version, includeTransitive: input.includeTransitive, maxDepth, - lifecycle: - canonicalLifecycles.length > 0 ? canonicalLifecycles : undefined, + lifecycle: wireLifecycles.length > 0 ? wireLifecycles : undefined, }, }; } @@ -180,28 +187,44 @@ function normaliseVersion(raw: string | undefined): string | undefined { function resolveLifecycles( raw: string | string[] | undefined, -): DependencyLifecycle[] { +): DependencyLifecycleInput[] { if (raw === undefined) return []; const tokens = Array.isArray(raw) ? raw.flatMap((entry) => entry.split(",")) : raw.split(","); - const seen = new Set(); + const seen = new Set(); for (const token of tokens) { const trimmed = token.trim(); if (trimmed.length === 0) continue; const lower = trimmed.toLowerCase(); - if (!isLifecycle(lower)) { + if (!isLifecycleInput(lower)) { throw new InvalidPackageSpecError( - `Unknown lifecycle '${trimmed}'. Expected one of: ${LIFECYCLES.join(", ")}.`, + `Unknown lifecycle '${trimmed}'. Expected one of: ${LIFECYCLES.join(", ")}, all.`, ); } seen.add(lower); } - return Array.from(seen).sort( - (a, b) => LIFECYCLE_ORDER[a] - LIFECYCLE_ORDER[b], - ); + if (seen.has("all") && seen.size > 1) { + throw new InvalidPackageSpecError( + "lifecycle=all cannot be combined with other lifecycle values.", + ); + } + return Array.from(seen).sort(lifecycleInputSort); } export function isLifecycle(value: string): value is DependencyLifecycle { return (LIFECYCLES as readonly string[]).includes(value); } + +function isLifecycleInput(value: string): value is DependencyLifecycleInput { + return value === "all" || isLifecycle(value); +} + +function lifecycleInputSort( + a: DependencyLifecycleInput, + b: DependencyLifecycleInput, +): number { + if (a === "all") return b === "all" ? 0 : 1; + if (b === "all") return -1; + return LIFECYCLE_ORDER[a] - LIFECYCLE_ORDER[b]; +} diff --git a/src/shared/package-dependencies-response.test.ts b/src/shared/package-dependencies-response.test.ts index f38d263b..9da5dc04 100644 --- a/src/shared/package-dependencies-response.test.ts +++ b/src/shared/package-dependencies-response.test.ts @@ -50,15 +50,33 @@ describe("buildPackageDependenciesSuccessPayload — runtime block", () => { }); describe("buildPackageDependenciesSuccessPayload — groups block", () => { - it("emits groups block with items when backend returned dependencyGroups", () => { + it("omits groups block by default even when backend returned dependencyGroups", () => { const payload = buildPackageDependenciesSuccessPayload( defaultDependencyReport, ); + expect(payload.groups).toBeUndefined(); + }); + + it("emits all groups when lifecycle=all was requested", () => { + const payload = buildPackageDependenciesSuccessPayload( + defaultDependencyReport, + { canonicalLifecycles: ["all"] }, + ); expect(payload.groups?.items.length).toBe(2); expect(payload.groups?.items[0]?.name).toBe("runtime"); expect(payload.groups?.items[1]?.name).toBe("development"); }); + it("emits only matching groups when a concrete lifecycle was requested", () => { + const payload = buildPackageDependenciesSuccessPayload( + defaultDependencyReport, + { canonicalLifecycles: ["development"] }, + ); + expect(payload.groups?.items.map((entry) => entry.lifecycle)).toEqual([ + "development", + ]); + }); + it("omits groups block when dependencyGroups is absent", () => { const payload = buildPackageDependenciesSuccessPayload( zeroDepDependencyReport, @@ -71,7 +89,9 @@ describe("buildPackageDependenciesSuccessPayload — groups block", () => { package: { name: "x", version: "1.0.0", registry: "NPM" }, dependencyGroups: { groups: [] }, }; - const payload = buildPackageDependenciesSuccessPayload(fixture); + const payload = buildPackageDependenciesSuccessPayload(fixture, { + canonicalLifecycles: ["development"], + }); expect(payload.groups).toEqual({ items: [] }); }); @@ -120,15 +140,16 @@ describe("buildPackageDependenciesSuccessPayload — groups block", () => { ], }, }; - const names = buildPackageDependenciesSuccessPayload( - fixture, - ).groups?.items.map((g) => g.name); + const names = buildPackageDependenciesSuccessPayload(fixture, { + canonicalLifecycles: ["all"], + }).groups?.items.map((g) => g.name); expect(names).toEqual(["runtime", "dev", "peer", "alpha", "zeta"]); }); it("preserves duplicate {name, constraint} entries verbatim (dedup is terminal-only)", () => { const payload = buildPackageDependenciesSuccessPayload( cratesFeatureDependencyReport, + { canonicalLifecycles: ["all"] }, ); const netGroup = payload.groups?.items.find((g) => g.name === "net"); expect(netGroup?.items.length).toBe(3); // libc, libc, mio — not deduped @@ -313,7 +334,9 @@ describe("formatPackageDependenciesTerminal — runtime view", () => { expect(output).toContain("3 direct runtime dependencies"); expect(output).toContain("accepts"); expect(output).toContain("^2.0.0"); - expect(output).toContain("Hidden groups: development — use --groups."); + expect(output).toContain( + "Hidden groups: development — use --lifecycle all.", + ); }); it("renders zero-dep hot path under 3 lines", () => { @@ -355,7 +378,7 @@ describe("formatPackageDependenciesTerminal — runtime view", () => { const output = formatPackageDependenciesTerminal(fixture, { useColors: false, }); - expect(output).not.toContain("hidden — use --groups"); + expect(output).not.toContain("hidden — use --lifecycle all"); }); }); @@ -363,7 +386,11 @@ describe("formatPackageDependenciesTerminal — groups view", () => { it("renders groups with lifecycle summary header", () => { const output = formatPackageDependenciesTerminal( cratesFeatureDependencyReport, - { useColors: false, showGroups: true }, + { + useColors: false, + showGroups: true, + canonicalLifecycles: ["all"], + }, ); expect(output).toContain("tokio @ 1.52.1 · crates"); expect(output).toContain("3 groups"); @@ -373,7 +400,11 @@ describe("formatPackageDependenciesTerminal — groups view", () => { it("collapses heading to `name` for always-typed groups", () => { const output = formatPackageDependenciesTerminal( cratesFeatureDependencyReport, - { useColors: false, showGroups: true }, + { + useColors: false, + showGroups: true, + canonicalLifecycles: ["all"], + }, ); expect(output).toMatch(/^\s+runtime\s*$/m); }); @@ -381,7 +412,11 @@ describe("formatPackageDependenciesTerminal — groups view", () => { it("renders `name (lifecycle, conditionType)` when conditionValue === name", () => { const output = formatPackageDependenciesTerminal( cratesFeatureDependencyReport, - { useColors: false, showGroups: true }, + { + useColors: false, + showGroups: true, + canonicalLifecycles: ["all"], + }, ); expect(output).toContain("full (optional, feature)"); expect(output).toContain("net (optional, feature)"); @@ -407,6 +442,7 @@ describe("formatPackageDependenciesTerminal — groups view", () => { const output = formatPackageDependenciesTerminal(fixture, { useColors: false, showGroups: true, + canonicalLifecycles: ["all"], }); expect(output).toContain( "group-alias (optional, feature: the-feature-name)", @@ -416,7 +452,11 @@ describe("formatPackageDependenciesTerminal — groups view", () => { it("dedups duplicate {name, constraint} entries in terminal rendering", () => { const output = formatPackageDependenciesTerminal( cratesFeatureDependencyReport, - { useColors: false, showGroups: true }, + { + useColors: false, + showGroups: true, + canonicalLifecycles: ["all"], + }, ); const libcLines = output.split("\n").filter((l) => /^\s+libc\b/.test(l)); expect(libcLines.length).toBe(1); @@ -425,7 +465,12 @@ describe("formatPackageDependenciesTerminal — groups view", () => { it("shows conditionType/selectionMode under --verbose", () => { const output = formatPackageDependenciesTerminal( cratesFeatureDependencyReport, - { useColors: false, showGroups: true, verbose: true }, + { + useColors: false, + showGroups: true, + verbose: true, + canonicalLifecycles: ["all"], + }, ); expect(output).toContain("selectionMode:"); expect(output).toContain("defaultEnabled:"); @@ -458,6 +503,7 @@ describe("formatPackageDependenciesTerminal — groups view", () => { useColors: false, showGroups: true, verbose: true, + canonicalLifecycles: ["all"], }); expect(verbose).toContain("environmentMarkers (2):"); // Typed rendering — type: value per line, no JSON blob. @@ -469,6 +515,7 @@ describe("formatPackageDependenciesTerminal — groups view", () => { useColors: false, showGroups: true, verbose: false, + canonicalLifecycles: ["all"], }); expect(nonVerbose).not.toContain("environmentMarkers"); }); @@ -713,14 +760,15 @@ describe("formatPackageDependenciesTerminal — transitive view", () => { }); // Both lines in the header block: count line then hidden-groups line. expect(output).toMatch( - /3 direct runtime dependencies\nHidden groups: development — use --groups\./, + /3 direct runtime dependencies\nHidden groups: development — use --lifecycle all\./, ); }); - it("omits hidden-groups line when --groups is active (nothing is hidden)", () => { + it("omits hidden-groups line when lifecycle=all is active (nothing is hidden)", () => { const output = formatPackageDependenciesTerminal(defaultDependencyReport, { useColors: false, showGroups: true, + canonicalLifecycles: ["all"], }); expect(output).not.toContain("Hidden groups"); }); @@ -741,6 +789,7 @@ describe("formatPackageDependenciesTerminal — transitive view", () => { const output = formatPackageDependenciesTerminal(defaultDependencyReport, { useColors: false, showGroups: true, + canonicalLifecycles: ["all"], }); const directIdx = output.indexOf("accepts"); const groupsHeadingIdx = output.indexOf("2 groups"); @@ -748,7 +797,7 @@ describe("formatPackageDependenciesTerminal — transitive view", () => { expect(groupsHeadingIdx).toBeGreaterThan(directIdx); }); - it("groups block composes beneath transitive list under --transitive --groups", () => { + it("groups block composes beneath transitive list under --transitive --lifecycle all", () => { const fixture = clone(defaultDependencyReport); fixture.dependencies = { direct: fixture.dependencies?.direct, @@ -762,6 +811,7 @@ describe("formatPackageDependenciesTerminal — transitive view", () => { useColors: false, showGroups: true, includeTransitive: true, + canonicalLifecycles: ["all"], }); const transitiveIdx = output.indexOf("alpha@1"); const groupsHeadingIdx = output.indexOf("2 groups"); @@ -1013,6 +1063,7 @@ describe("formatPackageDependenciesTerminal — no-color", () => { const output = formatPackageDependenciesTerminal(fixture, { useColors: false, showGroups: true, + canonicalLifecycles: ["all"], }); const depLines = output.split("\n").filter((l) => /^\s{4}[a-z]/.test(l)); expect(depLines.map((l) => l.trim().split(/\s+/)[0])).toEqual([ @@ -1042,6 +1093,7 @@ describe("formatPackageDependenciesTerminal — no-color", () => { const output = formatPackageDependenciesTerminal(fixture, { useColors: false, showGroups: true, + canonicalLifecycles: ["all"], }); expect(output).toContain("argon2 (optional, extra)"); expect(output).not.toContain("(optional, feature)"); @@ -1050,7 +1102,11 @@ describe("formatPackageDependenciesTerminal — no-color", () => { it("keeps `feature` vocabulary for Crates packages (Cargo's native term)", () => { const output = formatPackageDependenciesTerminal( cratesFeatureDependencyReport, - { useColors: false, showGroups: true }, + { + useColors: false, + showGroups: true, + canonicalLifecycles: ["all"], + }, ); expect(output).toContain("(optional, feature)"); expect(output).not.toContain("(optional, extra)"); @@ -1061,6 +1117,7 @@ describe("formatPackageDependenciesTerminal — no-color", () => { useColors: false, showGroups: true, verbose: true, + canonicalLifecycles: ["all"], }); expect(output).not.toContain("selectionMode: required"); }); @@ -1068,7 +1125,12 @@ describe("formatPackageDependenciesTerminal — no-color", () => { it("shows `selectionMode: additive` in verbose mode (load-bearing signal)", () => { const output = formatPackageDependenciesTerminal( cratesFeatureDependencyReport, - { useColors: false, showGroups: true, verbose: true }, + { + useColors: false, + showGroups: true, + verbose: true, + canonicalLifecycles: ["all"], + }, ); expect(output).toContain("selectionMode: additive"); }); @@ -1093,6 +1155,7 @@ describe("formatPackageDependenciesTerminal — no-color", () => { const output = formatPackageDependenciesTerminal(fixture, { useColors: false, showGroups: true, + canonicalLifecycles: ["all"], }); expect(output).toContain("full (optional, feature)"); expect(output).not.toContain("feature: Full"); @@ -1101,7 +1164,12 @@ describe("formatPackageDependenciesTerminal — no-color", () => { it("contains no ANSI escape sequences when useColors is false", () => { const output = formatPackageDependenciesTerminal( cratesFeatureDependencyReport, - { useColors: false, showGroups: true, verbose: true }, + { + useColors: false, + showGroups: true, + verbose: true, + canonicalLifecycles: ["all"], + }, ); expect(output).not.toContain("\u001b["); }); diff --git a/src/shared/package-dependencies-response.ts b/src/shared/package-dependencies-response.ts index cfb9f426..619857bf 100644 --- a/src/shared/package-dependencies-response.ts +++ b/src/shared/package-dependencies-response.ts @@ -6,12 +6,11 @@ * * Key design commitments: * - * - **Data-first envelope.** Whenever the backend returned + * - **Runtime-first envelope.** Whenever the backend returned * `dependencies.direct`, we emit a `runtime` block with - * `{count, items: [{name, version?, constraint?}]}`. Whenever the - * backend returned `dependencyGroups`, we emit a `groups` block - * with every returned group. Agents branch on what's in the - * envelope, not on caller flags. + * `{count, items: [{name, version?, constraint?}]}`. Non-runtime + * dependency groups are emitted only when the caller requested a + * lifecycle view (`lifecycle=` or `lifecycle=all`). * - **Preprocessed transitive.** When the caller sets * `includeTransitive`, the envelope's `transitive.packages[]` lists * every unique install with its resolved version. Adding @@ -25,9 +24,9 @@ * is `{cycle: string[]}[]`. Both map from the backend's typed * `DependencyConflict` / `CircularDependencyCycle` shapes — no * raw-fallback path. - * - **Null vs empty matters.** `dependencyGroups: null` → omit - * `groups` entirely ("backend has no groups concept"). Non-null - * with zero members after filtering → `groups: { items: [] }` + * - **Null vs empty matters.** No lifecycle request or + * `dependencyGroups: null` → omit `groups` entirely. Requested + * lifecycle with zero members after filtering → `groups: { items: [] }` * ("filter matched nothing"). * - **No DAG in the envelope.** The typed `dependencyGraph` sits on * the service-layer result for internal lookups (direct-version @@ -50,7 +49,10 @@ import type { EnvironmentMarker, } from "../services/index.js"; import { colorize, dim } from "./colors.js"; -import type { DependencyLifecycle } from "./package-dependencies-request.js"; +import type { + DependencyLifecycle, + DependencyLifecycleInput, +} from "./package-dependencies-request.js"; import { toPkgseerRegistryLowercase } from "./pkgseer-registry.js"; export interface LeanDirectDependency { @@ -149,7 +151,7 @@ export interface LeanTransitiveBlock { } export interface LeanFilterBlock { - lifecycles: DependencyLifecycle[]; + lifecycles: DependencyLifecycleInput[]; } export interface LeanDependencyReport { @@ -167,7 +169,7 @@ export interface BuildDependenciesPayloadOptions { /** Raw caller-supplied version string (pre-normalisation). */ requestedVersion?: string; /** Lifecycles that went on the wire. Empty → no filter. */ - canonicalLifecycles?: DependencyLifecycle[]; + canonicalLifecycles?: DependencyLifecycleInput[]; /** Whether the caller asked for the transitive block. */ includeTransitive?: boolean; /** @@ -224,10 +226,24 @@ export function buildPackageDependenciesSuccessPayload( } const groupsInfo = report.dependencyGroups; - if (groupsInfo !== undefined) { - const groupItems = sortGroups(groupsInfo.groups.map(buildGroup)); + if ( + groupsInfo !== undefined && + shouldEmitGroups(options.canonicalLifecycles) + ) { + const groupItems = sortGroups( + groupsInfo.groups.map(buildGroup).filter((group) => { + if (!options.canonicalLifecycles) return false; + if (options.canonicalLifecycles.includes("all")) return true; + return options.canonicalLifecycles.includes( + group.lifecycle as DependencyLifecycle, + ); + }), + ); const groupsBlock: LeanGroupsBlock = { items: groupItems }; - if (groupsInfo.primaryGroup) { + if ( + groupsInfo.primaryGroup && + groupItems.some((group) => group.name === groupsInfo.primaryGroup) + ) { groupsBlock.primaryGroup = groupsInfo.primaryGroup; } if ( @@ -290,6 +306,13 @@ export function buildPackageDependenciesSuccessPayload( return payload; } +function shouldEmitGroups( + lifecycles: DependencyLifecycleInput[] | undefined, +): boolean { + if (!lifecycles || lifecycles.length === 0) return false; + return lifecycles.some((entry) => entry !== "runtime"); +} + function projectEnvironmentMarker( marker: EnvironmentMarker, ): LeanEnvironmentMarker { @@ -552,7 +575,7 @@ function lowerRegistry(value: string | undefined): string { * transitive entry gets `(required by @, …)` * derived from the typed dependency graph. * - **Groups is a separate block below the deps list.** Shown when - * `--groups` or `--lifecycle` is set, composes cleanly with either + * `--lifecycle` is a non-runtime value or `all`, and composes cleanly with either * the direct or transitive deps list above. * - **Conflicts / cycles section** surfaces after the transitive list * only (they come from the transitive graph). @@ -562,12 +585,13 @@ export interface FormatDependenciesTerminalOptions { verbose?: boolean; useColors?: boolean; requestedVersion?: string; - canonicalLifecycles?: DependencyLifecycle[]; + canonicalLifecycles?: DependencyLifecycleInput[]; includeTransitive?: boolean; /** Caller-supplied traversal depth; surfaces in the summary row. */ maxDepth?: number; /** If true, render the groups block beneath the deps list. */ showGroups?: boolean; + hiddenGroupsHint?: string; } export function formatPackageDependenciesTerminal( @@ -581,7 +605,7 @@ export function formatPackageDependenciesTerminal( // walks small. const payload = buildPackageDependenciesSuccessPayload(report, { requestedVersion: options.requestedVersion, - canonicalLifecycles: options.canonicalLifecycles, + canonicalLifecycles: options.canonicalLifecycles ?? ["all"], includeTransitive: options.includeTransitive, maxDepth: options.maxDepth, includeImporters: verbose, @@ -592,7 +616,7 @@ export function formatPackageDependenciesTerminal( const blocks: string[] = []; - blocks.push(formatHeaderBlock(payload, useColors, showGroups)); + blocks.push(formatHeaderBlock(payload, useColors, showGroups, options)); if (includeTransitive) { blocks.push(formatTransitiveDepsList(payload, verbose, useColors)); @@ -617,6 +641,7 @@ function formatHeaderBlock( payload: LeanDependencyReport, useColors: boolean, showGroups: boolean, + options: FormatDependenciesTerminalOptions, ): string { const name = colorize(payload.name, "bold", useColors); const lines: string[] = [ @@ -625,20 +650,21 @@ function formatHeaderBlock( if (payload.requestedVersion) { lines.push(dim(`(requested ${payload.requestedVersion})`, useColors)); } - lines.push(formatSummaryRow(payload, useColors, showGroups)); + lines.push(formatSummaryRow(payload, useColors, showGroups, options)); return lines.join("\n"); } /** * Single summary row that always renders. Combines runtime / transitive * counts with a "Hidden: …" mention listing non-runtime groups by - * name. When `--groups` is active the "Hidden: …" section is omitted + * name. When the groups view is active the "Hidden: …" section is omitted * because nothing is hidden. */ function formatSummaryRow( payload: LeanDependencyReport, useColors: boolean, showGroups: boolean, + options: FormatDependenciesTerminalOptions, ): string { const countParts: string[] = []; const runtimeCount = payload.runtime?.count ?? 0; @@ -680,7 +706,7 @@ function formatSummaryRow( const hidden = collectHiddenGroupNames(payload); if (hidden.length === 0) return countLine; const hiddenLine = dim( - `Hidden groups: ${hidden.join(", ")} — use --groups.`, + `Hidden groups: ${hidden.join(", ")} — ${options.hiddenGroupsHint ?? "use --lifecycle all."}`, useColors, ); return `${countLine}\n${hiddenLine}`; @@ -866,7 +892,7 @@ function formatConflictsAndCycles( } // -------------------------------------------------------------------- -// Groups block (separate; shown when --groups or --lifecycle) +// Groups block (separate; shown for non-runtime lifecycle views) // -------------------------------------------------------------------- function formatGroupsBlock( diff --git a/src/shared/read-file-text.ts b/src/shared/read-file-text.ts new file mode 100644 index 00000000..59f1d478 --- /dev/null +++ b/src/shared/read-file-text.ts @@ -0,0 +1,59 @@ +import type { LeanReadFileEnvelope } from "./read-file-response.js"; + +const SEP = " | "; + +export function renderReadFileText(envelope: LeanReadFileEnvelope): string { + const lines: string[] = []; + lines.push(buildHeader(envelope)); + lines.push(""); + + if (envelope.isBinary) { + lines.push("Binary file - cannot display as text."); + } else if (envelope.content) { + appendNumberedContent(lines, envelope.content, envelope.startLine ?? 1); + } else { + lines.push("(no content returned)"); + } + + if (envelope.hint) { + lines.push(""); + lines.push(`hint: ${envelope.hint}`); + } + return lines.join("\n"); +} + +function buildHeader(envelope: LeanReadFileEnvelope): string { + const parts = [`code_read${SEP}${envelope.path}`]; + if (envelope.language) parts.push(envelope.language); + const range = buildRange(envelope); + if (range) parts.push(range); + return parts.join(SEP); +} + +function buildRange(envelope: LeanReadFileEnvelope): string | undefined { + if (envelope.startLine !== undefined && envelope.endLine !== undefined) { + return envelope.totalLines !== undefined + ? `lines ${envelope.startLine}-${envelope.endLine}/${envelope.totalLines}` + : `lines ${envelope.startLine}-${envelope.endLine}`; + } + if (envelope.totalLines !== undefined) return `${envelope.totalLines} lines`; + return undefined; +} + +function appendNumberedContent( + lines: string[], + content: string, + startLine: number, +): void { + const bodyLines = content.split("\n"); + if (bodyLines.length > 0 && bodyLines[bodyLines.length - 1] === "") { + bodyLines.pop(); + } + const endLine = startLine + bodyLines.length - 1; + const width = String(endLine).length; + for (let i = 0; i < bodyLines.length; i += 1) { + lines.push( + `${String(startLine + i).padStart(width, " ")} ${bodyLines[i]}`, + ); + } +} diff --git a/src/shared/read-package-doc-response.ts b/src/shared/read-package-doc-response.ts index 5e4690f9..51ef86f0 100644 --- a/src/shared/read-package-doc-response.ts +++ b/src/shared/read-package-doc-response.ts @@ -20,7 +20,6 @@ export interface LeanPackageDocEnvelope { startLine?: number; endLine?: number; breadcrumbs?: string[]; - linkName?: string; lastUpdatedAt?: string; sourceKind?: "crawled" | "repo"; sourceUrl?: string; @@ -30,6 +29,7 @@ export interface LeanPackageDocEnvelope { requestedRef?: string; filePath?: string; baseUrl?: string; + hint?: string; } export function buildReadPackageDocSuccessPayload( @@ -71,7 +71,6 @@ export function buildReadPackageDocSuccessPayload( 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; } diff --git a/src/shared/read-package-doc-text.ts b/src/shared/read-package-doc-text.ts new file mode 100644 index 00000000..240634e2 --- /dev/null +++ b/src/shared/read-package-doc-text.ts @@ -0,0 +1,40 @@ +import type { LeanPackageDocEnvelope } from "./read-package-doc-response.js"; + +const SEP = " | "; + +export function renderReadPackageDocText( + envelope: LeanPackageDocEnvelope, +): string { + const lines: string[] = []; + lines.push(buildHeader(envelope)); + if (envelope.sourceUrl) lines.push(`source: ${envelope.sourceUrl}`); + if (envelope.filePath) { + const ref = envelope.requestedRef ?? envelope.gitRef; + lines.push(`file: ${envelope.filePath}${ref ? ` @ ${ref}` : ""}`); + } + lines.push(""); + if (envelope.content) lines.push(envelope.content); + if (envelope.hint) { + lines.push(""); + lines.push(`hint: ${envelope.hint}`); + } + return lines.join("\n"); +} + +function buildHeader(envelope: LeanPackageDocEnvelope): string { + const parts = [`docs_read${SEP}${envelope.pageId}`]; + if (envelope.title) parts.push(envelope.title); + const range = buildRange(envelope); + if (range) parts.push(range); + return parts.join(SEP); +} + +function buildRange(envelope: LeanPackageDocEnvelope): string | undefined { + if (envelope.startLine !== undefined && envelope.endLine !== undefined) { + return envelope.totalLines !== undefined + ? `lines ${envelope.startLine}-${envelope.endLine}/${envelope.totalLines}` + : `lines ${envelope.startLine}-${envelope.endLine}`; + } + if (envelope.totalLines !== undefined) return `${envelope.totalLines} lines`; + return undefined; +} diff --git a/src/shared/unified-search-response.test.ts b/src/shared/unified-search-response.test.ts index 850d558d..f701b797 100644 --- a/src/shared/unified-search-response.test.ts +++ b/src/shared/unified-search-response.test.ts @@ -35,7 +35,6 @@ describe("buildUnifiedSearchSuccessPayload", () => { target: "npm:express@4.18.2", title: "router middleware", summary: "function router(req, res, next) { ... }", - score: 0.92, highlights: { title: [[7, 17]], summary: [[9, 15]], @@ -45,6 +44,7 @@ describe("buildUnifiedSearchSuccessPayload", () => { language: "javascript", }), }); + expect(payload.results[0]).not.toHaveProperty("score"); }); it("omits default-valued query echo fields", () => { diff --git a/src/shared/unified-search-response.ts b/src/shared/unified-search-response.ts index 42053136..d3adf160 100644 --- a/src/shared/unified-search-response.ts +++ b/src/shared/unified-search-response.ts @@ -48,7 +48,6 @@ export interface UnifiedSearchHitPayload { target: string; title?: string; summary?: string; - score?: number; highlights?: UnifiedSearchHighlightsPayload; locator: { registry?: string; @@ -346,7 +345,6 @@ function buildHitPayload(hit: UnifiedSearchHit): UnifiedSearchHitPayload { }; if (hit.title) payload.title = hit.title; if (hit.summary) payload.summary = hit.summary; - if (typeof hit.score === "number") payload.score = hit.score; const highlights = buildHighlights(hit.highlights); if (highlights) payload.highlights = highlights; return payload; diff --git a/src/shared/unified-search-status-text.ts b/src/shared/unified-search-status-text.ts new file mode 100644 index 00000000..4efcd46a --- /dev/null +++ b/src/shared/unified-search-status-text.ts @@ -0,0 +1,116 @@ +import type { + UnifiedSearchStatusCompletedPayload, + UnifiedSearchStatusIncompletePayload, + UnifiedSearchStatusResultPayload, +} from "./unified-search-response.js"; +import { appendUnifiedSearchHits } from "./unified-search-text.js"; + +const SEP = " | "; + +type StatusPayload = + | UnifiedSearchStatusCompletedPayload + | UnifiedSearchStatusIncompletePayload; + +export function renderUnifiedSearchStatusText(payload: StatusPayload): string { + const lines: string[] = []; + lines.push(buildHeader(payload)); + + if (!payload.completed && payload.progress) { + lines.push(formatProgress(payload.progress)); + } + + const result = payload.result; + if (result) appendResult(lines, result); + + if (!payload.completed) { + lines.push( + `next: call search_status search_ref=${quote(payload.searchRef)}`, + ); + } + + return lines.join("\n"); +} + +function buildHeader(payload: StatusPayload): string { + const state = payload.completed ? "complete" : "indexing"; + const parts = [`search_status${SEP}${state}`]; + if (payload.searchRef) parts.push(`searchRef=${payload.searchRef}`); + return parts.join(SEP); +} + +function appendResult( + lines: string[], + result: UnifiedSearchStatusResultPayload, +): void { + lines.push(""); + if (result.warnings && result.warnings.length > 0) { + lines.push("warnings:"); + for (const warning of result.warnings) lines.push(` - ${warning}`); + lines.push(""); + } + if (result.results.length === 0) { + lines.push("No hits."); + } else { + appendUnifiedSearchHits(lines, result.results); + } + if (result.hasMore) { + const nextOffsetHint = + typeof result.nextOffset === "number" + ? ` Pass offset=${result.nextOffset} for the next page or limit=N to widen.` + : " Pass limit=N to widen."; + lines.push(""); + lines.push(`More hits available.${nextOffsetHint}`); + } + if (result.sourceStatus && result.sourceStatus.length > 0) { + lines.push(""); + lines.push("source notes:"); + for (const entry of result.sourceStatus) { + lines.push(` - ${formatSourceStatus(entry)}`); + } + } +} + +function formatSourceStatus(entry: { + source: string; + targetLabel: string; + indexingStatus?: string; + codeIndexState?: string; + ignoredFilters?: string[]; + incompatibleFilters?: string[]; + ignoredQueryFeatures?: string[]; + incompatibleQueryFeatures?: string[]; + note?: string; +}): string { + const parts: string[] = [`${entry.source} (${entry.targetLabel})`]; + if (entry.indexingStatus) parts.push(`indexing=${entry.indexingStatus}`); + if (entry.codeIndexState) parts.push(`codeIndex=${entry.codeIndexState}`); + if (entry.ignoredFilters?.length) { + parts.push(`ignored=${entry.ignoredFilters.join(",")}`); + } + if (entry.incompatibleFilters?.length) { + parts.push(`incompatible=${entry.incompatibleFilters.join(",")}`); + } + if (entry.ignoredQueryFeatures?.length) { + parts.push(`ignoredQuery=${entry.ignoredQueryFeatures.join(",")}`); + } + if (entry.incompatibleQueryFeatures?.length) { + parts.push( + `incompatibleQuery=${entry.incompatibleQueryFeatures.join(",")}`, + ); + } + if (entry.note) parts.push(entry.note); + return parts.join(SEP); +} + +function formatProgress(progress: { + status: string; + targetsReady: number; + targetsTotal: number; + elapsedMs: number; +}): string { + return `progress: ${progress.status}, ${progress.targetsReady}/${progress.targetsTotal} targets ready, ${progress.elapsedMs}ms elapsed`; +} + +function quote(value: string): string { + return JSON.stringify(value); +} diff --git a/src/shared/unified-search-target.ts b/src/shared/unified-search-target.ts index e14ae3d2..f2294aa1 100644 --- a/src/shared/unified-search-target.ts +++ b/src/shared/unified-search-target.ts @@ -1,40 +1,8 @@ import type { CodeNavigationTarget } from "../services/index.js"; -import { toCodeNavigationRegistry } from "./code-navigation.js"; -import { InvalidArgumentError, parsePackageSpec } from "./package-spec.js"; +import { parseCodeNavigationTargetSpec } from "./code-navigation-target.js"; export function parseUnifiedSearchTargetSpec( spec: string, ): CodeNavigationTarget { - const trimmed = spec.trim(); - if (trimmed.length === 0) { - throw new InvalidArgumentError("Target spec cannot be empty."); - } - - if (trimmed.startsWith("http://") || trimmed.startsWith("https://")) { - return parseRepoTarget(trimmed); - } - - const parsed = parsePackageSpec(trimmed); - return { - registry: toCodeNavigationRegistry(parsed.registry), - packageName: parsed.name, - version: parsed.version, - }; -} - -function parseRepoTarget(spec: string): CodeNavigationTarget { - const hashIndex = spec.lastIndexOf("#"); - if (hashIndex === -1) { - return { repoUrl: spec, gitRef: "HEAD" }; - } - - const repoUrl = spec.slice(0, hashIndex); - const gitRef = spec.slice(hashIndex + 1); - if (!repoUrl || !gitRef) { - throw new InvalidArgumentError( - "Repository target must be a full URL with optional #gitRef suffix.", - ); - } - - return { repoUrl, gitRef }; + return parseCodeNavigationTargetSpec(spec); } diff --git a/src/shared/unified-search-text.test.ts b/src/shared/unified-search-text.test.ts index b50e65f4..5de71078 100644 --- a/src/shared/unified-search-text.test.ts +++ b/src/shared/unified-search-text.test.ts @@ -19,7 +19,6 @@ function codeHit( title: "applyEdit", summary: "Search/replace block parser with fuzzy fallback when exact match fails.", - score: 0.87, locator: { registry: "npm", packageName: "cline", @@ -39,7 +38,6 @@ function docsHit(): UnifiedSearchHitPayload { target: "aider-AI/aider@v0.55.0", title: "Edit Formats", summary: "Compares whole-file, diff-fenced, udiff, and editblock formats.", - score: 0.83, locator: { pageId: "aider/edit-formats", sourceUrl: "https://aider.chat/docs/more/edit-formats.html", @@ -54,7 +52,6 @@ function symbolHit(): UnifiedSearchHitPayload { target: "continuedev/continue@v0.9.42", title: "diffLines", summary: "Myers diff core; line-level with O(ND) complexity.", - score: 0.91, locator: { filePath: "core/diff/myers.ts", startLine: 48, @@ -90,9 +87,10 @@ describe("renderUnifiedSearchSuccess", () => { it("renders a single code hit with locator, title, and summary", () => { const text = renderUnifiedSearchSuccess(completed([codeHit()])); - expect(text).toContain("[1] cline/cline@v3.4.2 code 0.87"); + expect(text).toContain("[1] cline/cline@v3.4.2 code"); + expect(text).not.toContain("0.87"); expect(text).toContain( - " src/integrations/diff/strategies/multi-search-replace.ts:142-156 function", + ' code_read target="npm:cline@v3.4.2" path="src/integrations/diff/strategies/multi-search-replace.ts" start_line=142 end_line=156 function', ); expect(text).toContain(" applyEdit"); expect(text).toContain( @@ -102,16 +100,16 @@ describe("renderUnifiedSearchSuccess", () => { it("uses pageId for documentation hits", () => { const text = renderUnifiedSearchSuccess(completed([docsHit()])); - expect(text).toContain("[1] aider-AI/aider@v0.55.0 docs 0.83"); - expect(text).toContain(" pageId: aider/edit-formats"); + expect(text).toContain("[1] aider-AI/aider@v0.55.0 docs"); + expect(text).toContain(' docs_read page_id="aider/edit-formats"'); expect(text).toContain(" Edit Formats"); }); it("renders qualifiedPath alongside file location for symbol hits", () => { const text = renderUnifiedSearchSuccess(completed([symbolHit()])); - expect(text).toContain("[1] continuedev/continue@v0.9.42 symbol 0.91"); + expect(text).toContain("[1] continuedev/continue@v0.9.42 symbol"); expect(text).toContain( - " core/diff/myers.ts:48-112 core.diff.myers.diffLines | function", + " follow-up unavailable: missing target core.diff.myers.diffLines | function", ); expect(text).toContain(" diffLines"); }); @@ -173,6 +171,7 @@ describe("renderUnifiedSearchSuccess", () => { ); expect(summaryLines.length).toBeGreaterThanOrEqual(1); for (const line of text.split("\n")) { + if (line.includes("code_read ")) continue; // 4-space indent + content; allow some slack for the wrap target. expect(line.length).toBeLessThanOrEqual(82); } diff --git a/src/shared/unified-search-text.ts b/src/shared/unified-search-text.ts index ac3c4850..207940c1 100644 --- a/src/shared/unified-search-text.ts +++ b/src/shared/unified-search-text.ts @@ -15,6 +15,7 @@ * `docs/implementation/tools.md` when changing the format. */ +import { buildSearchHitFollowUpCommand } from "./follow-up-command-text.js"; import type { UnifiedSearchCompletedPayload, UnifiedSearchErrorPayload, @@ -40,10 +41,7 @@ export function renderUnifiedSearchSuccess( if (payload.results.length === 0) { lines.push(payload.completed ? "No hits." : "No hits yet - indexing."); } else { - payload.results.forEach((hit, idx) => { - if (idx > 0) lines.push(""); - appendHit(lines, idx + 1, hit); - }); + appendUnifiedSearchHits(lines, payload.results); } const trailer = buildTrailer(payload); @@ -89,15 +87,22 @@ function buildHeader(payload: SearchSuccessPayload): string { return parts.join(SEP); } +export function appendUnifiedSearchHits( + lines: string[], + hits: UnifiedSearchHitPayload[], +): void { + hits.forEach((hit, idx) => { + if (idx > 0) lines.push(""); + appendHit(lines, idx + 1, hit); + }); +} + function appendHit( lines: string[], index: number, hit: UnifiedSearchHitPayload, ): void { const headerParts: string[] = [hit.target, shortType(hit.type)]; - if (typeof hit.score === "number") { - headerParts.push(formatScore(hit.score)); - } lines.push(`[${index}] ${headerParts.join(" ")}`); const locator = buildLocatorLine(hit); @@ -141,6 +146,13 @@ function shortType(type: string): string { function buildLocatorLine(hit: UnifiedSearchHitPayload): string { const loc = hit.locator; + const followUp = buildSearchHitFollowUpCommand(hit); + if (followUp) { + const tail: string[] = []; + if (loc.qualifiedPath) tail.push(loc.qualifiedPath); + if (loc.kind) tail.push(loc.kind); + return tail.length > 0 ? `${followUp} ${tail.join(SEP)}` : followUp; + } if (loc.filePath) { let line = `${loc.filePath}${formatLineRange(loc.startLine, loc.endLine)}`; const tail: string[] = []; @@ -160,10 +172,6 @@ function formatLineRange(start?: number, end?: number): string { return `:${start}-${end}`; } -function formatScore(score: number): string { - return score.toFixed(2); -} - function buildTrailer(payload: SearchSuccessPayload): string[] { const lines: string[] = []; diff --git a/src/tools/code-navigation-shared.ts b/src/tools/code-navigation-shared.ts index 4c397ebd..dfd9abb4 100644 --- a/src/tools/code-navigation-shared.ts +++ b/src/tools/code-navigation-shared.ts @@ -4,6 +4,8 @@ import { type CodeNavigationRegistryArg, toCodeNavigationRegistry, } from "../shared/code-navigation.js"; +import { mapCodeNavigationError } from "../shared/code-navigation-error-map.js"; +import { parseCodeNavigationTargetSpec } from "../shared/code-navigation-target.js"; import { errorResult, type ToolResult } from "./types.js"; // Re-export the wait-timeout default so callers already importing this @@ -11,7 +13,7 @@ import { errorResult, type ToolResult } from "./types.js"; // src/shared/code-navigation-defaults.ts per the CLI/MCP parity rules. export { DEFAULT_WAIT_TIMEOUT_MS } from "../shared/code-navigation-defaults.js"; -export const codeTargetSchema = z +export const structuredCodeTargetSchema = z .object({ registry: z .enum([ @@ -58,7 +60,17 @@ export const codeTargetSchema = z "Target: provide registry + package_name (package scope) or repo_url + git_ref (repo scope).", ); -export type CodeTargetArg = { +export const codeTargetSchema = z.union([ + structuredCodeTargetSchema, + z + .string() + .min(1) + .describe( + "Compact target string. Package: `npm:react@18.2.0`. Repository: `https://github.com/facebook/react#HEAD` (git ref suffix optional, defaults to HEAD).", + ), +]); + +export type StructuredCodeTargetArg = { registry?: CodeNavigationRegistryArg; package_name?: string; version?: string; @@ -66,6 +78,8 @@ export type CodeTargetArg = { git_ref?: string; }; +export type CodeTargetArg = StructuredCodeTargetArg | string; + /** * Validates and normalizes a code navigation target. * @@ -76,6 +90,14 @@ export type CodeTargetArg = { export function resolveCodeTarget( target: CodeTargetArg, ): CodeNavigationTarget | ToolResult { + if (typeof target === "string") { + try { + return parseCodeNavigationTargetSpec(target); + } catch (error) { + return mappedInvalidTargetResult(error); + } + } + const hasPackageTarget = Boolean(target.registry || target.package_name); const hasRepoTarget = Boolean(target.repo_url || target.git_ref); @@ -124,6 +146,18 @@ export function resolveCodeTarget( }; } +function mappedInvalidTargetResult(error: unknown): ToolResult { + const mapped = mapCodeNavigationError(error); + return errorResult( + JSON.stringify({ + error: mapped.message, + code: mapped.code, + retryable: mapped.retryable ?? false, + ...(mapped.details ? { details: mapped.details } : {}), + }), + ); +} + function invalidTargetResult(message: string): ToolResult { return errorResult( JSON.stringify({ diff --git a/src/tools/get-example.test.ts b/src/tools/get-example.test.ts index 08ab94d8..6ae9ece7 100644 --- a/src/tools/get-example.test.ts +++ b/src/tools/get-example.test.ts @@ -14,6 +14,21 @@ describe("getExampleTool", () => { expect(result.isError).toBeUndefined(); expect(result.content[0]?.text).toContain("# Example"); + expect(result.content[0]?.text).not.toContain('{"result"'); + }); + + it("returns JSON envelope when format=json", async () => { + const service = createMockGitHitsService(); + const tool = createGetExampleTool(service); + + const result = await tool.handler( + { query: "hello world", language: "javascript", format: "json" }, + {}, + ); + + const payload = JSON.parse(result.content[0]?.text ?? "{}"); + expect(payload.result).toContain("# Example"); + expect(payload.solution_id).toBeUndefined(); }); it("passes license_mode to service", async () => { diff --git a/src/tools/get-example.ts b/src/tools/get-example.ts index 6838e7bc..5265f7ce 100644 --- a/src/tools/get-example.ts +++ b/src/tools/get-example.ts @@ -8,6 +8,7 @@ interface GetExampleArgs { query: string; language?: string; license_mode?: "strict" | "yolo" | "custom"; + format?: "json" | "text" | "text-v1"; } const schema = { @@ -28,11 +29,17 @@ const schema = { .enum(["strict", "yolo", "custom"]) .optional() .describe("License filtering mode: strict (default), yolo, or custom."), + format: z + .enum(["json", "text", "text-v1"]) + .optional() + .describe( + "Response format. Default `text-v1` returns markdown directly with a trailing `solution_id` line when available. Pass `json` for `{result, solution_id?}`.", + ), }; const DESCRIPTION = `Get verified, canonical code examples from global open source. -Returns JSON \`{result, solution_id?}\`. \`result\` is markdown — render or quote it directly. Pass \`solution_id\` to \`feedback\` after using or rejecting the example. For searching indexed dependency and repository code/docs, use the unified \`search\` tool instead.`; +Default output is markdown, with a trailing \`solution_id: ...\` line when available. Pass \`format: "json"\` for \`{result, solution_id?}\`. Pass \`solution_id\` to \`feedback\` after using or rejecting the example. For searching indexed dependency and repository code/docs, use the unified \`search\` tool instead.`; export function createGetExampleTool( service: GitHitsService, @@ -53,8 +60,19 @@ export function createGetExampleTool( const payload = solutionId ? { result: markdown, solution_id: solutionId } : { result: markdown }; + if (isTextFormat(args.format)) { + return textResult( + solutionId + ? `${markdown.trimEnd()}\n\nsolution_id: ${solutionId}` + : markdown, + ); + } return textResult(JSON.stringify(payload)); }); }, }; } + +function isTextFormat(format: GetExampleArgs["format"]): boolean { + return format === undefined || format === "text" || format === "text-v1"; +} diff --git a/src/tools/grep-repo.test.ts b/src/tools/grep-repo.test.ts index 057fb90d..4d250cc5 100644 --- a/src/tools/grep-repo.test.ts +++ b/src/tools/grep-repo.test.ts @@ -91,6 +91,28 @@ describe("createGrepRepoTool — happy path", () => { ]); }); + it("accepts compact package string targets", async () => { + const grepRepo = mock(() => Promise.resolve(defaultGrepRepoResult)); + const service = createMockCodeNavigationService({ grepRepo }); + const tool = createGrepRepoTool(service); + + await tool.handler( + { + target: "npm:express", + pattern: "middleware", + }, + {}, + ); + + const calls = grepRepo.mock.calls as unknown as Array< + [{ target: { registry?: string; packageName?: string } }] + >; + expect(calls[0]?.[0]?.target).toMatchObject({ + registry: "NPM", + packageName: "express", + }); + }); + it("passes symbol field hydration through to grepRepo", async () => { const grepRepo = mock(() => Promise.resolve(defaultGrepRepoResult)); const tool = createGrepRepoTool( diff --git a/src/tools/list-files.test.ts b/src/tools/list-files.test.ts index d18cec5d..76b1a522 100644 --- a/src/tools/list-files.test.ts +++ b/src/tools/list-files.test.ts @@ -60,6 +60,27 @@ describe("createListFilesTool — happy path", () => { expect(calls[0]?.[0]?.target?.packageName).toBe("express"); }); + it("accepts compact repo string targets", async () => { + const listFiles = mock(() => Promise.resolve(defaultListFilesResult)); + const service = createMockCodeNavigationService({ listFiles }); + const tool = createListFilesTool(service); + + await tool.handler( + { + target: "https://github.com/expressjs/express#HEAD", + }, + {}, + ); + + const calls = listFiles.mock.calls as unknown as Array< + [{ target: { repoUrl?: string; gitRef?: string } }] + >; + expect(calls[0]?.[0]?.target).toMatchObject({ + repoUrl: "https://github.com/expressjs/express", + gitRef: "HEAD", + }); + }); + it("forwards advanced list-files filters to the service", async () => { const listFiles = mock(() => Promise.resolve(defaultListFilesResult)); const service = createMockCodeNavigationService({ listFiles }); diff --git a/src/tools/list-package-docs-parity.test.ts b/src/tools/list-package-docs-parity.test.ts index 09342cca..721231b2 100644 --- a/src/tools/list-package-docs-parity.test.ts +++ b/src/tools/list-package-docs-parity.test.ts @@ -58,7 +58,7 @@ async function mcpJson( : {}, ); const tool = createListPackageDocsTool(service); - const result = await tool.handler(args, {}); + const result = await tool.handler({ ...args, format: "json" }, {}); return JSON.parse(result.content[0]?.text ?? ""); } diff --git a/src/tools/list-package-docs.test.ts b/src/tools/list-package-docs.test.ts index a8be7dde..84e6b33e 100644 --- a/src/tools/list-package-docs.test.ts +++ b/src/tools/list-package-docs.test.ts @@ -23,6 +23,7 @@ describe("createListPackageDocsTool", () => { "version", "limit", "after", + "format", ]); }); @@ -47,12 +48,12 @@ describe("createListPackageDocsTool", () => { }); }); - it("returns JSON-stringified lean envelope on success", async () => { + it("returns JSON-stringified lean envelope when format=json", async () => { const tool = createListPackageDocsTool( createMockPackageIntelligenceService(), ); const result = await tool.handler( - { registry: "npm", package_name: "express" }, + { registry: "npm", package_name: "express", format: "json" }, {}, ); const payload = parseText(result) as Record; @@ -60,6 +61,20 @@ describe("createListPackageDocsTool", () => { expect(Array.isArray(payload.pages)).toBe(true); }); + it("defaults to compact text output", async () => { + const tool = createListPackageDocsTool( + createMockPackageIntelligenceService(), + ); + const result = await tool.handler( + { registry: "npm", package_name: "express" }, + {}, + ); + const text = result.content[0]?.text ?? ""; + expect(text).toContain("docs_list | npm:express"); + expect(text).toContain("docs_read page_id="); + expect(() => JSON.parse(text)).toThrow(); + }); + it("omits nullish lastUpdatedAt values from the lean envelope", async () => { const tool = createListPackageDocsTool( createMockPackageIntelligenceService({ @@ -86,7 +101,7 @@ describe("createListPackageDocsTool", () => { ); const result = await tool.handler( - { registry: "npm", package_name: "ms" }, + { registry: "npm", package_name: "ms", format: "json" }, {}, ); const payload = parseText(result) as { diff --git a/src/tools/list-package-docs.ts b/src/tools/list-package-docs.ts index 4d6c9ed0..921ea707 100644 --- a/src/tools/list-package-docs.ts +++ b/src/tools/list-package-docs.ts @@ -2,6 +2,7 @@ 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 { renderListPackageDocsText } from "../shared/list-package-docs-text.js"; import { mapPackageIntelligenceError } from "../shared/package-intelligence-error-map.js"; import { errorResult, type ToolDefinition, textResult } from "./types.js"; @@ -11,6 +12,7 @@ export interface ListPackageDocsArgs { version?: string; limit?: number; after?: string; + format?: "json" | "text" | "text-v1"; } const schema = { @@ -31,6 +33,12 @@ const schema = { .string() .optional() .describe("Pagination cursor from a prior response."), + format: z + .enum(["json", "text", "text-v1"]) + .optional() + .describe( + 'Response format. Default `text-v1` — compact page list with ready-to-call `docs_read` follow-ups. Pass `format: "json"` for the structured envelope.', + ), }; const DESCRIPTION = @@ -62,6 +70,9 @@ export function createListPackageDocsTool( limit: build.params.limit, after: build.params.after, }); + if (isTextFormat(args.format)) { + return textResult(renderListPackageDocsText(payload)); + } return textResult(JSON.stringify(payload)); } catch (error) { const mapped = mapPackageIntelligenceError(error); @@ -77,3 +88,7 @@ export function createListPackageDocsTool( }, }; } + +function isTextFormat(format: ListPackageDocsArgs["format"]): boolean { + return format === undefined || format === "text" || format === "text-v1"; +} diff --git a/src/tools/package-changelog-parity.test.ts b/src/tools/package-changelog-parity.test.ts index 691ac76f..75265b2f 100644 --- a/src/tools/package-changelog-parity.test.ts +++ b/src/tools/package-changelog-parity.test.ts @@ -92,7 +92,7 @@ async function mcpJson( : {}, ); const tool = createPackageChangelogTool(service); - const result = await tool.handler(args, {}); + const result = await tool.handler({ ...args, format: "json" }, {}); const text = result.content[0]?.text ?? ""; return { json: JSON.parse(text), isError: result.isError }; } diff --git a/src/tools/package-changelog.test.ts b/src/tools/package-changelog.test.ts index 5d1878a9..bf80cd29 100644 --- a/src/tools/package-changelog.test.ts +++ b/src/tools/package-changelog.test.ts @@ -21,7 +21,10 @@ describe("createPackageChangelogTool — metadata", () => { expect(tool.name).toBe("pkg_changelog"); expect(tool.description).toContain("latest mode"); expect(tool.description).toContain("range mode"); + expect(tool.description).toContain("markdown body previews"); + expect(tool.description).toContain('format: "json"'); expect(Object.keys(tool.schema).sort()).toEqual([ + "format", "from_version", "git_ref", "include_bodies", @@ -53,7 +56,7 @@ describe("createPackageChangelogTool — happy path", () => { expect(calls[0]?.[0]?.repoUrl).toBeUndefined(); }); - it("emits the envelope with entries.count computed client-side", async () => { + it("emits compact text by default", async () => { const tool = createPackageChangelogTool( createMockPackageIntelligenceService(), ); @@ -62,6 +65,49 @@ describe("createPackageChangelogTool — happy path", () => { {}, ); expect(result.isError).toBeUndefined(); + const text = result.content[0]?.text ?? ""; + expect(text).toContain("express · npm"); + expect(text).toContain("2 entries"); + expect(() => JSON.parse(text)).toThrow(); + }); + + it("uses MCP-native hint when compact changelog text truncates bodies", async () => { + const tool = createPackageChangelogTool( + createMockPackageIntelligenceService({ + packageChangelog: mock(() => + Promise.resolve({ + ...defaultChangelogReport, + entries: [ + { + ...defaultChangelogReport.entries[0]!, + body: Array.from( + { length: 12 }, + (_, i) => `line ${i + 1}`, + ).join("\n"), + }, + ], + }), + ), + }), + ); + + const result = await tool.handler( + { registry: "npm", package_name: "express" }, + {}, + ); + const text = result.content[0]?.text ?? ""; + expect(text).toContain('pass format="json" for full bodies'); + expect(text).not.toContain("--verbose"); + }); + + it("emits the JSON envelope with entries.count computed client-side when format=json", async () => { + const tool = createPackageChangelogTool( + createMockPackageIntelligenceService(), + ); + const result = await tool.handler( + { registry: "npm", package_name: "express", format: "json" }, + {}, + ); const payload = parseText(result) as { registry: string; name: string; @@ -91,7 +137,7 @@ describe("createPackageChangelogTool — happy path", () => { ); const result = await tool.handler( - { registry: "npm", package_name: "express" }, + { registry: "npm", package_name: "express", format: "json" }, {}, ); @@ -109,7 +155,7 @@ describe("createPackageChangelogTool — happy path", () => { createMockPackageIntelligenceService(), ); const result = await tool.handler( - { repo_url: "https://github.com/expressjs/express" }, + { repo_url: "https://github.com/expressjs/express", format: "json" }, {}, ); const payload = parseText(result) as { @@ -131,6 +177,7 @@ describe("createPackageChangelogTool — happy path", () => { registry: "npm", package_name: "express", from_version: "5.0.0", + format: "json", }, {}, ); @@ -151,6 +198,7 @@ describe("createPackageChangelogTool — happy path", () => { registry: "npm", package_name: "express", include_bodies: false, + format: "json", }, {}, ); @@ -167,7 +215,7 @@ describe("createPackageChangelogTool — happy path", () => { createMockPackageIntelligenceService(), ); const result = await tool.handler( - { registry: "npm", package_name: "express" }, + { registry: "npm", package_name: "express", format: "json" }, {}, ); const payload = parseText(result) as { diff --git a/src/tools/package-changelog.ts b/src/tools/package-changelog.ts index 04771e75..e492ab52 100644 --- a/src/tools/package-changelog.ts +++ b/src/tools/package-changelog.ts @@ -1,7 +1,10 @@ import { z } from "zod"; import type { PackageIntelligenceService } from "../services/index.js"; import { buildPackageChangelogParams } from "../shared/package-changelog-request.js"; -import { buildPackageChangelogSuccessPayload } from "../shared/package-changelog-response.js"; +import { + buildPackageChangelogSuccessPayload, + formatPackageChangelogTerminal, +} from "../shared/package-changelog-response.js"; import { mapPackageIntelligenceError } from "../shared/package-intelligence-error-map.js"; import { toPkgseerRegistryLowercase } from "../shared/pkgseer-registry.js"; import { type ToolDefinition, textResult } from "./types.js"; @@ -15,6 +18,7 @@ export interface PackageChangelogArgs { to_version?: string; limit?: number; include_bodies?: boolean; + format?: "json" | "text" | "text-v1"; } /** @@ -81,6 +85,12 @@ const schema = { .describe( "When false, each entry in `entries.items[]` omits its `body` field. Default true. Set false when you only need the version / date / URL timeline — drops 10 KB+ per entry on large release notes.", ), + format: z + .enum(["json", "text", "text-v1"]) + .optional() + .describe( + "Response format. Default `text-v1` is compact for agents. Pass `json` for the structured envelope.", + ), }; const DESCRIPTION = @@ -92,7 +102,8 @@ const DESCRIPTION = 'exclusive). Response includes optional `source` (`"releases"` / ' + '`"changelog_file"` / `"hexdocs"`) when a concrete changelog source ' + 'exists, `mode` (`"latest"` or `"range"`), ' + - "`entries: { count, items }` with full markdown bodies. Set " + + 'and entries with markdown body previews. Pass `format: "json"` ' + + "for the structured envelope with full markdown bodies. Set " + "`include_bodies: false` for a version / date / URL timeline only. " + "Package-version entries without changelog text succeed with `source` " + "omitted; no-source plus no entries returns `NOT_FOUND`. Supports npm, " + @@ -132,6 +143,15 @@ export function createPackageChangelogTool( limit: params.limit, gitRef: params.gitRef, }); + if (isTextFormat(args.format)) { + return textResult( + formatPackageChangelogTerminal(payload, { + useColors: false, + verbose: false, + fullBodyHint: 'pass format="json" for full bodies', + }).trimEnd(), + ); + } return textResult(JSON.stringify(payload)); } catch (error) { const mapped = mapPackageIntelligenceError(error); @@ -153,3 +173,7 @@ export function createPackageChangelogTool( }, }; } + +function isTextFormat(format: PackageChangelogArgs["format"]): boolean { + return format === undefined || format === "text" || format === "text-v1"; +} diff --git a/src/tools/package-dependencies-parity.test.ts b/src/tools/package-dependencies-parity.test.ts index 813b928e..0adef237 100644 --- a/src/tools/package-dependencies-parity.test.ts +++ b/src/tools/package-dependencies-parity.test.ts @@ -90,7 +90,7 @@ async function mcpJson( : {}, ); const tool = createPackageDependenciesTool(service); - const result = await tool.handler(args, {}); + const result = await tool.handler({ ...args, format: "json" }, {}); const text = result.content[0]?.text ?? ""; return { json: JSON.parse(text), isError: result.isError }; } @@ -125,11 +125,11 @@ describe("package_dependencies parity", () => { expect((cli as { groups?: unknown }).groups).toBeUndefined(); }); - it("PARITY-JSON-KEYS: full-view express (no filter) CLI === MCP", async () => { + it("PARITY-JSON-KEYS: lifecycle=all full-view express CLI === MCP", async () => { const fn = mock(() => Promise.resolve(defaultDependencyReport)); const cli = await cliJson( "npm:express", - { groups: true }, + { lifecycle: "all" }, cliDeps({ packageIntelligenceService: createMockPackageIntelligenceService({ packageDependencies: fn as never, @@ -137,7 +137,7 @@ describe("package_dependencies parity", () => { }), ); const { json } = await mcpJson( - { registry: "npm", package_name: "express" }, + { registry: "npm", package_name: "express", lifecycle: "all" }, fn as never, ); expect(cli).toEqual(json); @@ -229,7 +229,7 @@ describe("package_dependencies parity", () => { const fn = mock(() => Promise.resolve(cratesFeatureDependencyReport)); const cli = await cliJson( "crates:tokio", - { groups: true }, + { lifecycle: "all" }, cliDeps({ packageIntelligenceService: createMockPackageIntelligenceService({ packageDependencies: fn as never, @@ -237,7 +237,7 @@ describe("package_dependencies parity", () => { }), ); const { json } = await mcpJson( - { registry: "crates", package_name: "tokio" }, + { registry: "crates", package_name: "tokio", lifecycle: "all" }, fn as never, ); expect(cli).toEqual(json); diff --git a/src/tools/package-dependencies.test.ts b/src/tools/package-dependencies.test.ts index e49b879f..b9d18864 100644 --- a/src/tools/package-dependencies.test.ts +++ b/src/tools/package-dependencies.test.ts @@ -17,7 +17,10 @@ describe("createPackageDependenciesTool — metadata", () => { ); expect(tool.name).toBe("pkg_deps"); expect(tool.description).toContain("npm, PyPI, Hex, Crates"); + expect(tool.description).toContain("Default output is compact"); + expect(tool.description).toContain('format: "json"'); expect(Object.keys(tool.schema).sort()).toEqual([ + "format", "include_importers", "include_transitive", "lifecycle", @@ -29,7 +32,7 @@ describe("createPackageDependenciesTool — metadata", () => { expect(tool.annotations?.readOnlyHint).toBe(true); }); - it("does NOT expose an include_groups input (data-first envelope makes it a no-op)", () => { + it("does NOT expose an include_groups input (lifecycle is the breadth knob)", () => { const tool = createPackageDependenciesTool( createMockPackageIntelligenceService(), ); @@ -74,12 +77,12 @@ describe("createPackageDependenciesTool — happy path", () => { expect(calls[0]?.[0]?.registry).toBe("NPM"); expect(calls[0]?.[0]?.packageName).toBe("express"); expect(calls[0]?.[0]?.version).toBe("5.2.1"); - expect(calls[0]?.[0]?.lifecycle).toEqual(["runtime", "development"]); + expect(calls[0]?.[0]?.lifecycle).toEqual(["development"]); expect(calls[0]?.[0]?.includeTransitive).toBe(true); expect(calls[0]?.[0]?.maxDepth).toBe(3); }); - it("emits the lean JSON envelope with runtime + groups blocks", async () => { + it("emits compact text with runtime block by default", async () => { const tool = createPackageDependenciesTool( createMockPackageIntelligenceService(), ); @@ -88,16 +91,35 @@ describe("createPackageDependenciesTool — happy path", () => { {}, ); expect(result.isError).toBeUndefined(); + const text = result.content[0]?.text ?? ""; + expect(text).toContain("express @ 5.2.1 · npm"); + expect(text).toContain("3 direct runtime dependencies"); + expect(text).toContain( + 'Hidden groups: development — pass lifecycle="all".', + ); + expect(text).not.toContain("--lifecycle"); + expect(text).toContain("accepts"); + expect(() => JSON.parse(text)).toThrow(); + }); + + it("emits the lean JSON envelope when format=json", async () => { + const tool = createPackageDependenciesTool( + createMockPackageIntelligenceService(), + ); + const result = await tool.handler( + { registry: "npm", package_name: "express", format: "json" }, + {}, + ); const payload = parseText(result) as { registry: string; name: string; runtime: { count: number }; - groups: { items: unknown[] }; + groups?: { items: unknown[] }; }; expect(payload.registry).toBe("npm"); expect(payload.name).toBe("express"); expect(payload.runtime.count).toBe(3); - expect(payload.groups.items.length).toBe(2); + expect(payload.groups).toBeUndefined(); }); it("surfaces filter.lifecycles when lifecycle is set", async () => { @@ -109,6 +131,7 @@ describe("createPackageDependenciesTool — happy path", () => { registry: "npm", package_name: "express", lifecycle: "development", + format: "json", }, {}, ); @@ -127,6 +150,7 @@ describe("createPackageDependenciesTool — happy path", () => { registry: "npm", package_name: "express", lifecycle: ["runtime", "development"], + format: "json", }, {}, ); @@ -141,7 +165,10 @@ describe("createPackageDependenciesTool — happy path", () => { createMockPackageIntelligenceService(), ); const withoutTransitive = parseText( - await tool.handler({ registry: "npm", package_name: "express" }, {}), + await tool.handler( + { registry: "npm", package_name: "express", format: "json" }, + {}, + ), ) as { transitive?: unknown }; expect(withoutTransitive.transitive).toBeUndefined(); }); @@ -185,7 +212,7 @@ describe("createPackageDependenciesTool — happy path", () => { const tool = createPackageDependenciesTool(service); const result = await tool.handler( - { registry: "npm", package_name: "express" }, + { registry: "npm", package_name: "express", format: "json" }, {}, ); diff --git a/src/tools/package-dependencies.ts b/src/tools/package-dependencies.ts index 0a932ba6..17540307 100644 --- a/src/tools/package-dependencies.ts +++ b/src/tools/package-dependencies.ts @@ -2,7 +2,10 @@ import { z } from "zod"; import type { PackageIntelligenceService } from "../services/index.js"; import { InvalidPackageSpecError } from "../shared/index.js"; import { buildPackageDependenciesParams } from "../shared/package-dependencies-request.js"; -import { buildPackageDependenciesSuccessPayload } from "../shared/package-dependencies-response.js"; +import { + buildPackageDependenciesSuccessPayload, + formatPackageDependenciesTerminal, +} from "../shared/package-dependencies-response.js"; import { mapPackageIntelligenceError } from "../shared/package-intelligence-error-map.js"; import { type ToolDefinition, textResult } from "./types.js"; @@ -14,6 +17,7 @@ export interface PackageDependenciesArgs { include_transitive?: boolean; include_importers?: boolean; max_depth?: number; + format?: "json" | "text" | "text-v1"; } /** @@ -21,11 +25,9 @@ export interface PackageDependenciesArgs { * `buildPackageDependenciesParams` is the single validation path so * raw Zod errors never surface to agents. * - * No `include_groups` input. The data-first envelope emits the - * `groups` block unconditionally when the backend returned - * `dependencyGroups`, so an `include_groups: true` flag would have no - * observable effect — and a silently ignored flag would confuse - * agents. + * No `include_groups` input. `lifecycle` is the single breadth knob: + * omit it for runtime-only, pass a concrete lifecycle for filtered + * groups, or pass `all` for the full groups view. */ const schema = { registry: z @@ -46,7 +48,7 @@ const schema = { .union([z.string(), z.array(z.string())]) .optional() .describe( - 'Filter the `groups` block server-side by lifecycle phase. Accepts a single value, a comma-separated string (e.g. `"runtime,development"`), or an array of strings. Canonical values: `runtime`, `development`, `build`, `peer`, `optional`. Uppercase is tolerated. When the filter matches nothing the response still includes `groups: { items: [] }` so you can tell an empty-match apart from a registry that has no groups concept.', + "Lifecycle breadth. Omit for runtime-only. Use `runtime` for explicit runtime-only, a concrete non-runtime lifecycle (`development`, `build`, `peer`, `optional`) for runtime plus matching groups, or `all` for runtime plus all available groups. Accepts a single value, a comma-separated string, or an array; `all` cannot be combined with other values. Uppercase is tolerated.", ), include_transitive: z .boolean() @@ -69,18 +71,21 @@ const schema = { .describe( "Cap the transitive traversal at this depth (1–10). Omit to get the backend's full graph. Requires `include_transitive: true` — passing `max_depth` without the transitive flag is rejected with `INVALID_ARGUMENT`.", ), + format: z + .enum(["json", "text", "text-v1"]) + .optional() + .describe( + "Response format. Default `text-v1` is compact for agents. Pass `json` for the structured envelope.", + ), }; const DESCRIPTION = - "Analyze a package's dependency graph. The response always includes " + - "a `runtime` block listing the direct runtime dependencies as " + - "`{name, version, constraint}` records (the backend resolves each " + - "constraint to a concrete version for you). It also always includes " + - "a structured `groups` block whenever the backend returns group " + - "metadata — one group per lifecycle (`runtime`, `development`, " + - "`build`, `peer`, `optional`) plus feature-conditional groups for " + - "registries that have them (PyPI extras, Crates features). Use " + - "`lifecycle` to filter `groups` server-side. Set " + + "Analyze a package's dependency graph. Default output is compact " + + "text listing direct runtime dependencies with resolved versions; " + + 'pass `format: "json"` for the structured envelope. Non-runtime ' + + "groups are omitted by default for token efficiency. Use `lifecycle` " + + "with a concrete value for runtime plus matching groups, or `all` " + + "for runtime plus all available groups. Set " + "`include_transitive: true` to add a `transitive` block with the " + "full install footprint, conflict detection, and circular-" + "dependency flags; layer `include_importers: true` on top when you " + @@ -128,6 +133,25 @@ export function createPackageDependenciesTool( maxDepth: args.max_depth, includeImporters: args.include_importers ?? false, }); + if (isTextFormat(args.format)) { + const textLifecycles = + canonicalLifecycles.length > 0 + ? canonicalLifecycles + : (["all"] satisfies typeof canonicalLifecycles); + return textResult( + formatPackageDependenciesTerminal(report, { + useColors: false, + requestedVersion: args.version, + canonicalLifecycles: textLifecycles, + includeTransitive: args.include_transitive, + maxDepth: args.max_depth, + showGroups: + canonicalLifecycles.length > 0 && + !canonicalLifecycles.every((item) => item === "runtime"), + hiddenGroupsHint: 'pass lifecycle="all".', + }).trimEnd(), + ); + } return textResult(JSON.stringify(payload)); } catch (error) { const mapped = mapPackageIntelligenceError(error); @@ -149,3 +173,7 @@ export function createPackageDependenciesTool( }, }; } + +function isTextFormat(format: PackageDependenciesArgs["format"]): boolean { + return format === undefined || format === "text" || format === "text-v1"; +} diff --git a/src/tools/package-summary-parity.test.ts b/src/tools/package-summary-parity.test.ts index 54c796df..4df81f0a 100644 --- a/src/tools/package-summary-parity.test.ts +++ b/src/tools/package-summary-parity.test.ts @@ -77,7 +77,7 @@ async function mcpJson( packageSummaryMock ? { packageSummary: packageSummaryMock as never } : {}, ); const tool = createPackageSummaryTool(service); - const result = await tool.handler(args, {}); + const result = await tool.handler({ ...args, format: "json" }, {}); const text = result.content[0]?.text ?? ""; return { json: JSON.parse(text), diff --git a/src/tools/package-summary.test.ts b/src/tools/package-summary.test.ts index 9bfd66ff..bcf2cb86 100644 --- a/src/tools/package-summary.test.ts +++ b/src/tools/package-summary.test.ts @@ -17,7 +17,13 @@ describe("createPackageSummaryTool — metadata", () => { ); expect(tool.name).toBe("pkg_info"); expect(tool.description).toContain("package overview"); - expect(Object.keys(tool.schema)).toEqual(["registry", "package_name"]); + expect(tool.description).toContain("Default output is compact text"); + expect(tool.description).toContain('format: "json"'); + expect(Object.keys(tool.schema)).toEqual([ + "registry", + "package_name", + "format", + ]); expect(tool.annotations?.readOnlyHint).toBe(true); }); }); @@ -38,7 +44,7 @@ describe("createPackageSummaryTool — happy path", () => { expect(calls[0]?.[0]?.packageName).toBe("express"); }); - it("returns JSON-stringified lean envelope in content[0].text on success", async () => { + it("returns compact text in content[0].text by default", async () => { const tool = createPackageSummaryTool( createMockPackageIntelligenceService(), ); @@ -47,6 +53,20 @@ describe("createPackageSummaryTool — happy path", () => { {}, ); expect(result.isError).toBeUndefined(); + const text = result.content[0]?.text ?? ""; + expect(text).toContain("express @ 4.18.2"); + expect(text).toContain("Repository"); + expect(() => JSON.parse(text)).toThrow(); + }); + + it("returns JSON-stringified lean envelope when format=json", async () => { + const tool = createPackageSummaryTool( + createMockPackageIntelligenceService(), + ); + const result = await tool.handler( + { registry: "npm", package_name: "express", format: "json" }, + {}, + ); const payload = parseText(result) as Record; expect(payload.registry).toBe("npm"); expect(payload.name).toBe("express"); diff --git a/src/tools/package-summary.ts b/src/tools/package-summary.ts index 5b72ff47..c9c7fcac 100644 --- a/src/tools/package-summary.ts +++ b/src/tools/package-summary.ts @@ -2,12 +2,16 @@ import { z } from "zod"; import type { PackageIntelligenceService } from "../services/index.js"; import { mapPackageIntelligenceError } from "../shared/package-intelligence-error-map.js"; import { buildPackageSummaryParams } from "../shared/package-summary-request.js"; -import { buildPackageSummarySuccessPayload } from "../shared/package-summary-response.js"; +import { + buildPackageSummarySuccessPayload, + formatPackageSummaryTerminal, +} from "../shared/package-summary-response.js"; import { type ToolDefinition, textResult } from "./types.js"; export interface PackageSummaryArgs { registry: string; package_name: string; + format?: "json" | "text" | "text-v1"; } /** @@ -26,6 +30,12 @@ const schema = { package_name: z .string() .describe("Package name (scoped names ok: @types/node)."), + format: z + .enum(["json", "text", "text-v1"]) + .optional() + .describe( + "Response format. Default `text-v1` is compact for agents. Pass `json` for the structured envelope.", + ), }; const DESCRIPTION = @@ -33,6 +43,8 @@ const DESCRIPTION = "repository, downloads, GitHub stars, install command, recent " + "changes, and a count of known vulnerabilities. Use before " + "recommending a package or to orient on what a dependency is. " + + 'Default output is compact text; pass `format: "json"` for the ' + + "structured envelope. " + "Works across npm, PyPI, Hex, Crates, NuGet, Maven, Packagist, " + "vcpkg, and Zig. Always returns data for the latest published " + "version."; @@ -53,6 +65,13 @@ export function createPackageSummaryTool( }); const summary = await service.packageSummary(params); const payload = buildPackageSummarySuccessPayload(summary); + if (isTextFormat(args.format)) { + return textResult( + formatPackageSummaryTerminal(summary, { + useColors: false, + }).trimEnd(), + ); + } return textResult(JSON.stringify(payload)); } catch (error) { const mapped = mapPackageIntelligenceError(error); @@ -74,3 +93,7 @@ export function createPackageSummaryTool( }, }; } + +function isTextFormat(format: PackageSummaryArgs["format"]): boolean { + return format === undefined || format === "text" || format === "text-v1"; +} diff --git a/src/tools/package-vulnerabilities-parity.test.ts b/src/tools/package-vulnerabilities-parity.test.ts index f8074405..5c86fb32 100644 --- a/src/tools/package-vulnerabilities-parity.test.ts +++ b/src/tools/package-vulnerabilities-parity.test.ts @@ -90,7 +90,7 @@ async function mcpJson( : {}, ); const tool = createPackageVulnerabilitiesTool(service); - const result = await tool.handler(args, {}); + const result = await tool.handler({ ...args, format: "json" }, {}); const text = result.content[0]?.text ?? ""; return { json: JSON.parse(text), isError: result.isError }; } diff --git a/src/tools/package-vulnerabilities.test.ts b/src/tools/package-vulnerabilities.test.ts index 8ec79a81..09587d55 100644 --- a/src/tools/package-vulnerabilities.test.ts +++ b/src/tools/package-vulnerabilities.test.ts @@ -17,7 +17,10 @@ describe("createPackageVulnerabilitiesTool — metadata", () => { ); expect(tool.name).toBe("pkg_vulns"); expect(tool.description).toContain("npm, PyPI, Hex, or"); + expect(tool.description).toContain("Default output is compact text"); + expect(tool.description).toContain('format: "json"'); expect(Object.keys(tool.schema).sort()).toEqual([ + "format", "include_withdrawn", "min_severity", "package_name", @@ -67,7 +70,7 @@ describe("createPackageVulnerabilitiesTool — happy path", () => { expect(calls[0]?.[0]?.includeWithdrawn).toBe(true); }); - it("returns JSON-stringified lean envelope on success", async () => { + it("returns compact text on success by default", async () => { const tool = createPackageVulnerabilitiesTool( createMockPackageIntelligenceService(), ); @@ -76,6 +79,20 @@ describe("createPackageVulnerabilitiesTool — happy path", () => { {}, ); expect(result.isError).toBeUndefined(); + const text = result.content[0]?.text ?? ""; + expect(text).toContain("express @ 4.18.0 · npm"); + expect(text).toContain("vulnerabilities affect this version"); + expect(() => JSON.parse(text)).toThrow(); + }); + + it("returns JSON-stringified lean envelope when format=json", async () => { + const tool = createPackageVulnerabilitiesTool( + createMockPackageIntelligenceService(), + ); + const result = await tool.handler( + { registry: "npm", package_name: "express", format: "json" }, + {}, + ); const payload = parseText(result) as Record; expect(payload.registry).toBe("npm"); expect(payload.name).toBe("express"); @@ -88,7 +105,12 @@ describe("createPackageVulnerabilitiesTool — happy path", () => { createMockPackageIntelligenceService(), ); const result = await tool.handler( - { registry: "npm", package_name: "express", version: "4.17" }, + { + registry: "npm", + package_name: "express", + version: "4.17", + format: "json", + }, {}, ); const payload = parseText(result) as { requestedVersion?: string }; diff --git a/src/tools/package-vulnerabilities.ts b/src/tools/package-vulnerabilities.ts index fc9a4f50..75b6e755 100644 --- a/src/tools/package-vulnerabilities.ts +++ b/src/tools/package-vulnerabilities.ts @@ -2,7 +2,10 @@ import { z } from "zod"; import type { PackageIntelligenceService } from "../services/index.js"; import { mapPackageIntelligenceError } from "../shared/package-intelligence-error-map.js"; import { buildPackageVulnerabilitiesParams } from "../shared/package-vulnerabilities-request.js"; -import { buildPackageVulnerabilitiesSuccessPayload } from "../shared/package-vulnerabilities-response.js"; +import { + buildPackageVulnerabilitiesSuccessPayload, + formatPackageVulnerabilitiesTerminal, +} from "../shared/package-vulnerabilities-response.js"; import { type ToolDefinition, textResult } from "./types.js"; export interface PackageVulnerabilitiesArgs { @@ -11,6 +14,7 @@ export interface PackageVulnerabilitiesArgs { version?: string; min_severity?: string; include_withdrawn?: boolean; + format?: "json" | "text" | "text-v1"; } /** @@ -42,6 +46,12 @@ const schema = { .boolean() .optional() .describe("Include retracted advisories (default: false)."), + format: z + .enum(["json", "text", "text-v1"]) + .optional() + .describe( + "Response format. Default `text-v1` is compact for agents. Pass `json` for the structured envelope.", + ), }; const DESCRIPTION = @@ -52,7 +62,8 @@ const DESCRIPTION = "bucket. Pass `version` to inspect a specific release; otherwise " + "the latest is checked. Use `min_severity` to filter to a threshold " + "(`low`, `medium`, `high`, `critical`) and `include_withdrawn` to " + - "also see retracted advisories."; + "also see retracted advisories. Default output is compact text; " + + 'pass `format: "json"` for the structured envelope.'; export function createPackageVulnerabilitiesTool( service: PackageIntelligenceService, @@ -75,6 +86,14 @@ export function createPackageVulnerabilitiesTool( const payload = buildPackageVulnerabilitiesSuccessPayload(report, { requestedVersion: args.version, }); + if (isTextFormat(args.format)) { + return textResult( + formatPackageVulnerabilitiesTerminal(report, { + useColors: false, + requestedVersion: args.version, + }).trimEnd(), + ); + } return textResult(JSON.stringify(payload)); } catch (error) { const mapped = mapPackageIntelligenceError(error); @@ -96,3 +115,7 @@ export function createPackageVulnerabilitiesTool( }, }; } + +function isTextFormat(format: PackageVulnerabilitiesArgs["format"]): boolean { + return format === undefined || format === "text" || format === "text-v1"; +} diff --git a/src/tools/read-file-parity.test.ts b/src/tools/read-file-parity.test.ts index 0f576d55..11bb320e 100644 --- a/src/tools/read-file-parity.test.ts +++ b/src/tools/read-file-parity.test.ts @@ -90,7 +90,7 @@ async function mcpJson( readFileMock ? { readFile: readFileMock as never } : {}, ); const tool = createReadFileTool(service); - const result = await tool.handler(args, {}); + const result = await tool.handler({ ...args, format: "json" }, {}); const parsed = JSON.parse(result.content[0]?.text ?? "") as Record< string, unknown diff --git a/src/tools/read-file.test.ts b/src/tools/read-file.test.ts index e63eef4b..5aba069b 100644 --- a/src/tools/read-file.test.ts +++ b/src/tools/read-file.test.ts @@ -23,6 +23,7 @@ describe("createReadFileTool — metadata", () => { expect(tool.description).toContain("NOT_FOUND"); expect(Object.keys(tool.schema).sort()).toEqual([ "end_line", + "format", "path", "start_line", "target", @@ -53,12 +54,40 @@ describe("createReadFileTool — happy path", () => { expect(calls[0]?.[0]?.filePath).toBe("src/index.js"); }); - it("emits the envelope with content + line range", async () => { + it("accepts compact package string targets", async () => { + const readFile = mock(() => Promise.resolve(defaultReadFileResult)); + const service = createMockCodeNavigationService({ readFile }); + const tool = createReadFileTool(service); + + await tool.handler( + { + target: "npm:express@4.18.2", + path: "src/index.js", + }, + {}, + ); + + const calls = readFile.mock.calls as unknown as Array< + [ + { + target: { registry?: string; packageName?: string; version?: string }; + }, + ] + >; + expect(calls[0]?.[0]?.target).toMatchObject({ + registry: "NPM", + packageName: "express", + version: "4.18.2", + }); + }); + + it("emits the envelope with content + line range when format=json", async () => { const tool = createReadFileTool(createMockCodeNavigationService()); const result = await tool.handler( { target: { registry: "npm", package_name: "express" }, path: "src/index.js", + format: "json", }, {}, ); @@ -79,6 +108,21 @@ describe("createReadFileTool — happy path", () => { expect(payload.isBinary).toBeUndefined(); }); + it("defaults to line-numbered text output", async () => { + const tool = createReadFileTool(createMockCodeNavigationService()); + const result = await tool.handler( + { + target: { registry: "npm", package_name: "express" }, + path: "src/index.js", + }, + {}, + ); + const text = result.content[0]?.text ?? ""; + expect(text).toContain("code_read | src/index.js | javascript"); + expect(text).toContain("1 // Express entry point"); + expect(() => JSON.parse(text)).toThrow(); + }); + it("passes start_line / end_line through to the wire", async () => { const readFile = mock(() => Promise.resolve(defaultReadFileResult)); const service = createMockCodeNavigationService({ readFile }); @@ -116,6 +160,7 @@ describe("createReadFileTool — happy path", () => { { target: { registry: "npm", package_name: "express" }, path: "assets/logo.png", + format: "json", }, {}, ); @@ -352,6 +397,7 @@ describe("createReadFileTool — span cap", () => { path: "src/index.js", start_line: 1, end_line: 600, + format: "json", }, {}, ); @@ -377,6 +423,7 @@ describe("createReadFileTool — span cap", () => { { target: { registry: "npm", package_name: "express" }, path: "src/index.js", + format: "json", }, {}, ); @@ -392,6 +439,7 @@ describe("createReadFileTool — span cap", () => { path: "src/index.js", start_line: 10, end_line: 80, + format: "json", }, {}, ); @@ -409,6 +457,7 @@ describe("createReadFileTool — span cap", () => { { target: { registry: "npm", package_name: "express" }, path: "src/index.js", + format: "json", }, {}, ); @@ -436,6 +485,7 @@ describe("createReadFileTool — span cap", () => { path: "src/index.js", start_line: 100, end_line: 600, + format: "json", }, {}, ); @@ -458,6 +508,7 @@ describe("createReadFileTool — span cap", () => { { target: { registry: "npm", package_name: "express" }, path: "assets/logo.png", + format: "json", }, {}, ); diff --git a/src/tools/read-file.ts b/src/tools/read-file.ts index 969913c8..e1d2911a 100644 --- a/src/tools/read-file.ts +++ b/src/tools/read-file.ts @@ -7,6 +7,7 @@ import { buildReadFileSuccessPayload, type LeanReadFileEnvelope, } from "../shared/read-file-response.js"; +import { renderReadFileText } from "../shared/read-file-text.js"; import { type CodeTargetArg, codeTargetSchema, @@ -32,6 +33,7 @@ export interface ReadFileArgs { start_line?: number; end_line?: number; wait_timeout_ms?: number; + format?: "json" | "text" | "text-v1"; } const schema = { @@ -59,6 +61,12 @@ const schema = { .describe( "Max milliseconds to wait for indexing (0–60000, default 20000). On an `INDEXING` error envelope, retry with a longer timeout or pass a version from `details.availableVersions`.", ), + format: z + .enum(["json", "text", "text-v1"]) + .optional() + .describe( + 'Response format. Default `text-v1` — line-numbered source content. Pass `format: "json"` for the structured envelope.', + ), }; const DESCRIPTION = @@ -164,6 +172,9 @@ export function createReadFileTool( ); } + if (isTextFormat(args.format)) { + return textResult(renderReadFileText(payload)); + } return textResult(JSON.stringify(payload)); } catch (error) { const mapped = mapCodeNavigationError(error); @@ -180,6 +191,10 @@ export function createReadFileTool( }; } +function isTextFormat(format: ReadFileArgs["format"]): boolean { + return format === undefined || format === "text" || format === "text-v1"; +} + /** * Whether to emit the cap hint. * diff --git a/src/tools/read-package-doc-parity.test.ts b/src/tools/read-package-doc-parity.test.ts index 40ff93ca..b25c4cdb 100644 --- a/src/tools/read-package-doc-parity.test.ts +++ b/src/tools/read-package-doc-parity.test.ts @@ -53,7 +53,7 @@ async function mcpJson( readPackageDocMock ? { readPackageDoc: readPackageDocMock as never } : {}, ); const tool = createReadPackageDocTool(service); - const result = await tool.handler(args, {}); + const result = await tool.handler({ ...args, format: "json" }, {}); return JSON.parse(result.content[0]?.text ?? ""); } diff --git a/src/tools/read-package-doc.test.ts b/src/tools/read-package-doc.test.ts index 5c6d3783..ebc1e9d5 100644 --- a/src/tools/read-package-doc.test.ts +++ b/src/tools/read-package-doc.test.ts @@ -18,6 +18,7 @@ describe("createReadPackageDocTool", () => { "page_id", "start_line", "end_line", + "format", ]); }); @@ -32,18 +33,34 @@ describe("createReadPackageDocTool", () => { expect(readPackageDoc).toHaveBeenCalledWith({ pageId: "abc" }); }); - it("returns JSON-stringified lean envelope on success", async () => { + it("returns JSON-stringified lean envelope when format=json", async () => { const tool = createReadPackageDocTool( createMockPackageIntelligenceService(), ); const result = await tool.handler( - { page_id: "github:expressjs/express@abc123/README.md" }, + { page_id: "github:expressjs/express@abc123/README.md", format: "json" }, {}, ); const payload = parseText(result) as Record; expect(payload.pageId).toBe("github:expressjs/express@abc123/README.md"); }); + it("defaults to bounded text output", async () => { + const tool = createReadPackageDocTool( + createMockPackageIntelligenceService(), + ); + const result = await tool.handler( + { page_id: "github:expressjs/express@abc123/README.md" }, + {}, + ); + const text = result.content[0]?.text ?? ""; + expect(text).toContain( + "docs_read | github:expressjs/express@abc123/README.md", + ); + expect(text).toContain("lines 1-"); + expect(() => JSON.parse(text)).toThrow(); + }); + it("returns INVALID_ARGUMENT for empty page ID", async () => { const tool = createReadPackageDocTool( createMockPackageIntelligenceService(), diff --git a/src/tools/read-package-doc.ts b/src/tools/read-package-doc.ts index f772f2f2..7e07caa1 100644 --- a/src/tools/read-package-doc.ts +++ b/src/tools/read-package-doc.ts @@ -3,14 +3,18 @@ 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 { renderReadPackageDocText } from "../shared/read-package-doc-text.js"; import { errorResult, type ToolDefinition, textResult } from "./types.js"; export interface ReadPackageDocArgs { page_id: string; start_line?: number; end_line?: number; + format?: "json" | "text" | "text-v1"; } +const MCP_DOC_READ_MAX_SPAN = 150; + const schema = { page_id: z .string() @@ -29,6 +33,12 @@ const schema = { .describe( "Ending line (inclusive). Omit for end of page. Must be ≥ `start_line` when both are set.", ), + format: z + .enum(["json", "text", "text-v1"]) + .optional() + .describe( + 'Response format. Default `text-v1` — raw markdown content capped to 150 lines by default. Pass `format: "json"` for the structured envelope; explicit ranges still slice JSON content.', + ), }; const DESCRIPTION = @@ -48,15 +58,17 @@ export function createReadPackageDocTool( try { const build = buildReadPackageDocParams({ pageId: args.page_id }); const result = await service.readPackageDoc(build.params); - const range = - args.start_line !== undefined || args.end_line !== undefined - ? { startLine: args.start_line, endLine: args.end_line } - : undefined; + const textMode = isTextFormat(args.format); + const range = buildRange(args, textMode); const payload = buildReadPackageDocSuccessPayload( result, build.params.pageId, - range, + range?.range, ); + if (range?.hint && payload.endLine !== undefined) { + payload.hint = range.hint(payload); + } + if (textMode) return textResult(renderReadPackageDocText(payload)); return textResult(JSON.stringify(payload)); } catch (error) { const mapped = mapPackageIntelligenceError(error); @@ -72,3 +84,41 @@ export function createReadPackageDocTool( }, }; } + +function isTextFormat(format: ReadPackageDocArgs["format"]): boolean { + return format === undefined || format === "text" || format === "text-v1"; +} + +function buildRange( + args: ReadPackageDocArgs, + textMode: boolean, +): + | { + range: { startLine?: number; endLine?: number }; + hint?: (payload: { + startLine?: number; + endLine?: number; + totalLines?: number; + }) => string; + } + | undefined { + if (textMode) { + const startLine = args.start_line ?? 1; + const requestedEnd = args.end_line ?? startLine + MCP_DOC_READ_MAX_SPAN - 1; + const endLine = Math.min( + requestedEnd, + startLine + MCP_DOC_READ_MAX_SPAN - 1, + ); + const wasClamped = requestedEnd > endLine; + return { + range: { startLine, endLine }, + hint: wasClamped + ? (payload) => + `Returned lines ${payload.startLine}-${payload.endLine}${payload.totalLines !== undefined ? `/${payload.totalLines}` : ""} (MCP text cap: ${MCP_DOC_READ_MAX_SPAN} lines per call; you requested lines ${startLine}-${requestedEnd}).` + : undefined, + }; + } + return args.start_line !== undefined || args.end_line !== undefined + ? { range: { startLine: args.start_line, endLine: args.end_line } } + : undefined; +} diff --git a/src/tools/search-language.test.ts b/src/tools/search-language.test.ts index 7739cbfd..2e4df5c2 100644 --- a/src/tools/search-language.test.ts +++ b/src/tools/search-language.test.ts @@ -10,17 +10,27 @@ function getText(result: ToolResult): string { } describe("searchLanguageTool", () => { - it("calls service and returns filtered JSON", async () => { + it("calls service and returns compact text by default", async () => { const service = createMockGitHitsService(); const tool = createSearchLanguageTool(service); const result = await tool.handler({ query: "python" }, {}); - const parsed = JSON.parse(getText(result)); expect(result.isError).toBeUndefined(); + expect(getText(result)).toContain("python (Python) aliases: py"); + expect(() => JSON.parse(getText(result))).toThrow(); + expect(service.getLanguages).toHaveBeenCalled(); + }); + + it("returns filtered JSON when format=json", async () => { + const service = createMockGitHitsService(); + const tool = createSearchLanguageTool(service); + + const result = await tool.handler({ query: "python", format: "json" }, {}); + const parsed = JSON.parse(getText(result)); + expect(parsed).toHaveLength(1); expect(parsed[0].name).toBe("python"); - expect(service.getLanguages).toHaveBeenCalled(); }); it("returns error result on service failure", async () => { diff --git a/src/tools/search-language.ts b/src/tools/search-language.ts index 0162caa5..52a875fa 100644 --- a/src/tools/search-language.ts +++ b/src/tools/search-language.ts @@ -6,6 +6,7 @@ import { type ToolDefinition, textResult } from "./types.js"; interface SearchLanguageArgs { query: string; + format?: "json" | "text" | "text-v1"; } const schema = { @@ -15,9 +16,15 @@ const schema = { .describe( 'Language name or partial name to search for (e.g., "python", "type", "java")', ), + format: z + .enum(["json", "text", "text-v1"]) + .optional() + .describe( + "Response format. Default `text-v1` returns one language per line. Pass `json` for the structured array.", + ), }; -const DESCRIPTION = `Find the correct language name for \`get_example\` when it is uncertain. Returns up to 5 matching languages by name, display name, or alias.`; +const DESCRIPTION = `Find the correct language name for \`get_example\` when it is uncertain. Returns up to 5 matching languages by name, display name, or alias. Default output is one language per line; pass \`format: "json"\` for the structured array.`; export function createSearchLanguageTool( service: GitHitsService, @@ -30,8 +37,32 @@ export function createSearchLanguageTool( return withErrorHandling("search languages", async () => { const allLanguages = await service.getLanguages(); const result = filterLanguages(allLanguages, args.query); + if (isTextFormat(args.format)) { + return textResult(renderLanguageMatches(result)); + } return textResult(JSON.stringify(result)); }); }, }; } + +function isTextFormat(format: SearchLanguageArgs["format"]): boolean { + return format === undefined || format === "text" || format === "text-v1"; +} + +function renderLanguageMatches( + matches: Array<{ name: string; display_name: string; aliases: string[] }>, +): string { + if (matches.length === 0) return "No matching languages."; + return matches + .map((match) => { + const label = match.display_name + ? `${match.name} (${match.display_name})` + : match.name; + const aliases = match.aliases?.length + ? ` aliases: ${match.aliases.join(", ")}` + : ""; + return `${label}${aliases}`; + }) + .join("\n"); +} diff --git a/src/tools/search-status.test.ts b/src/tools/search-status.test.ts index c4e27a0d..ef8cae47 100644 --- a/src/tools/search-status.test.ts +++ b/src/tools/search-status.test.ts @@ -45,7 +45,10 @@ describe("searchStatusTool", () => { }), ); - const result = await tool.handler({ search_ref: "search-ref-123" }, {}); + const result = await tool.handler( + { search_ref: "search-ref-123", format: "json" }, + {}, + ); expect(result.isError).toBeUndefined(); expect(JSON.parse(result.content[0]?.text ?? "{}")).toEqual({ @@ -65,7 +68,10 @@ describe("searchStatusTool", () => { it("returns completed payload without fabricating initial query echo", async () => { const tool = createSearchStatusTool(createMockCodeNavigationService()); - const result = await tool.handler({ search_ref: "search-ref-123" }, {}); + const result = await tool.handler( + { search_ref: "search-ref-123", format: "json" }, + {}, + ); const payload = JSON.parse(result.content[0]?.text ?? "{}"); expect(result.isError).toBeUndefined(); @@ -88,11 +94,33 @@ describe("searchStatusTool", () => { }), ); - const result = await tool.handler({ search_ref: "search-ref-timeout" }, {}); + const result = await tool.handler( + { search_ref: "search-ref-timeout", format: "json" }, + {}, + ); const payload = JSON.parse(result.content[0]?.text ?? "{}"); expect(result.isError).toBeUndefined(); expect(payload.completed).toBe(false); expect(payload.progress.status).toBe("TIMEOUT"); }); + + it("defaults to compact text output", async () => { + const tool = createSearchStatusTool( + createMockCodeNavigationService({ + searchStatus: mock(() => + Promise.resolve(createIncompleteOutcome("SEARCHING", "ref-text")), + ), + }), + ); + + const result = await tool.handler({ search_ref: "ref-text" }, {}); + const text = result.content[0]?.text ?? ""; + + expect(result.isError).toBeUndefined(); + expect(text).toContain("search_status | indexing | searchRef=ref-text"); + expect(text).toContain("progress: SEARCHING, 0/1 targets ready"); + expect(text).toContain('next: call search_status search_ref="ref-text"'); + expect(() => JSON.parse(text)).toThrow(); + }); }); diff --git a/src/tools/search-status.ts b/src/tools/search-status.ts index d964700a..7e1b26ee 100644 --- a/src/tools/search-status.ts +++ b/src/tools/search-status.ts @@ -3,11 +3,13 @@ import type { CodeNavigationService } from "../services/index.js"; import { buildUnifiedSearchErrorPayload, buildUnifiedSearchStatusPayload, + renderUnifiedSearchStatusText, } from "../shared/index.js"; import { errorResult, type ToolDefinition, textResult } from "./types.js"; export interface SearchStatusArgs { search_ref: string; + format?: "json" | "text" | "text-v1"; } const schema = { @@ -15,6 +17,12 @@ const schema = { .string() .min(1) .describe("Search reference returned by search."), + format: z + .enum(["json", "text", "text-v1"]) + .optional() + .describe( + 'Response format. Default `text-v1` — compact line-oriented output matching `search`. Pass `format: "json"` for the structured envelope.', + ), }; const DESCRIPTION = @@ -33,6 +41,9 @@ export function createSearchStatusTool( try { const outcome = await service.searchStatus(args.search_ref); const payload = buildUnifiedSearchStatusPayload(outcome); + if (isTextFormat(args.format)) { + return textResult(renderUnifiedSearchStatusText(payload)); + } return textResult(JSON.stringify(payload)); } catch (error) { return errorResult( @@ -42,3 +53,7 @@ export function createSearchStatusTool( }, }; } + +function isTextFormat(format: SearchStatusArgs["format"]): boolean { + return format === undefined || format === "text" || format === "text-v1"; +} diff --git a/src/tools/search.test.ts b/src/tools/search.test.ts index 430d6108..ea62cbb2 100644 --- a/src/tools/search.test.ts +++ b/src/tools/search.test.ts @@ -50,6 +50,55 @@ describe("searchTool", () => { ); }); + it("accepts compact package string targets", async () => { + const search = mock(() => Promise.resolve(defaultUnifiedSearchOutcome)); + const tool = createSearchTool(createMockCodeNavigationService({ search })); + + await tool.handler( + { + query: "handler", + target: "npm:express@4.18.2", + }, + {}, + ); + + expect(search).toHaveBeenCalledWith( + expect.objectContaining({ + targets: [ + expect.objectContaining({ + registry: "NPM", + packageName: "express", + version: "4.18.2", + }), + ], + }), + ); + }); + + it("accepts compact repo string targets inside targets arrays", async () => { + const search = mock(() => Promise.resolve(defaultUnifiedSearchOutcome)); + const tool = createSearchTool(createMockCodeNavigationService({ search })); + + await tool.handler( + { + query: "handler", + targets: ["https://github.com/expressjs/express#v5.0.0"], + }, + {}, + ); + + expect(search).toHaveBeenCalledWith( + expect.objectContaining({ + targets: [ + expect.objectContaining({ + repoUrl: "https://github.com/expressjs/express", + gitRef: "v5.0.0", + }), + ], + }), + ); + }); + it("returns invalid-argument error when target is missing", async () => { const tool = createSearchTool(createMockCodeNavigationService());