diff --git a/.agents/rules/agents-tier-system.md b/.agents/rules/agents-tier-system.md index cb36edc4..5ba0a9b4 100644 --- a/.agents/rules/agents-tier-system.md +++ b/.agents/rules/agents-tier-system.md @@ -33,6 +33,7 @@ Genuinely cross-cutting. Apply to every turn regardless of file: - `pr-comment-fact-check` — fires the fact-check skill on PR-comment intent triggers - `preserve-comments` — never silently delete TODOs / commented-out code - `tracer-bullets` — small end-to-end slices, not horizontal layers +- `consumer-surfaces` — consumer-facing text describes user-visible behavior only - `verify-after-each-step` — run the project's checks per milestone, not at commit time ### Tier 2 — Auto-attached technical rules (this rule's tier) diff --git a/.agents/rules/concise-comments.md b/.agents/rules/concise-comments.md index 50b6c832..39116d8c 100644 --- a/.agents/rules/concise-comments.md +++ b/.agents/rules/concise-comments.md @@ -30,6 +30,11 @@ For every comment you wrote, ask: **"Could a teammate (human or AI) re-derive th - Section headers in short files (`// === Helpers ===`). - Author / date stamps (git tracks this). - Multi-line prose where one clause does the job. +- **Prose inside JSDoc** when `@param` / `@returns` / `@typedef` already carry the meaning — types stay; narrating them does not. + +## Exception: JSDoc as types (`.mjs`, `@ts-check`) + +Untyped JS has no `.ts` surface — **`@typedef`, `@param`, `@returns`, and inline `@type` are the type system; keep them.** Apply the decision test only to **prose** in those blocks (keep non-obvious _why_ like `bunx` vs `bun x`; cut restatements of param names or return shapes). ## Reconcile with `preserve-comments` diff --git a/.agents/rules/consumer-surfaces.md b/.agents/rules/consumer-surfaces.md new file mode 100644 index 00000000..a6e21c24 --- /dev/null +++ b/.agents/rules/consumer-surfaces.md @@ -0,0 +1,40 @@ +--- +description: Consumer-facing surfaces must describe user-visible behavior only — no maintainer internals, CI wiring, or implementation file names. +alwaysApply: true +--- + +# Consumer surfaces + +Consumers install **`@stainless-code/codemap`** and interact through CLI, MCP, HTTP, and bundled agent templates. They must only see **what to run** and **what it does** — never how this repo implements it. + +## Consumer surfaces (write for users) + +| Surface | Audience | +| ---------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | +| `templates/agent-content/**` | Served live via `codemap skill` / `codemap rule`, `codemap://skill` / `codemap://rule`, MCP `instructions`, HTTP resources | +| `templates/agents/**` | Copied into consumer projects by `codemap agents init` | +| **`.changeset/*.md` body** | Release notes → `CHANGELOG.md` on npm | +| **Root `README.md` (install / usage)** | npm landing page | +| **CLI help text and user-facing errors** | Terminal output | + +`docs/agents.md` is maintainer reference linked from bundled README — keep **MCP wiring / init** sections consumer-accurate; implementation tables belong in maintainer sections, not in served agent-content. + +## Maintainer-only (never leak into consumer surfaces) + +- Internal refactors, CI / GitHub Action wiring, dual-file sync (`*.ts` ↔ `*.mjs`) +- `scripts/`, `docs/plans/`, `docs/research/`, module paths under `src/` +- Dogfood paths (`bun src/index.ts`) — maintainer workflow only; consumers use PM-aware spawn from `agents init --mcp` +- “We moved X to Y” unless the **user-visible** command or output changed + +## When editing consumer surfaces + +1. **Behavior, not implementation** — e.g. “PM-aware MCP spawn via `agents init --mcp`”, not “`resolveCodemapCliInvocation` in `codemap-invocation.ts`”. +2. **Changesets** — user-visible outcome only; no Action refactors, detect-pm delegation, or sync comments between source files. +3. **Served skill / rule** — no hardcoded `{command: "codemap"}` unless documenting legacy manual wiring; prefer `codemap agents init --mcp` and PM-specific examples (`npx`, `pnpm exec`, `yarn exec`, `bunx`, dlx). +4. **Cross-ref maintainer detail** — plans, architecture internals, and contributor tables stay in `docs/` or `.agents/` skills; link from consumer text only when the user must follow a public doc (e.g. [agents.md § MCP wiring](https://github.com/stainless-code/codemap/blob/main/docs/agents.md#mcp-wiring-via-agents-init)). + +## Decision test + +Before shipping text on a consumer surface: **“Would a user who only `npm i @stainless-code/codemap` care?”** If no → cut it or move it to a maintainer doc. + +Related: [`write-a-skill`](../skills/write-a-skill/SKILL.md) (maintainer vs shipped templates), [`docs-governance`](./docs-governance.md) Rule 10 (agent-content layers). diff --git a/.agents/rules/docs-governance.md b/.agents/rules/docs-governance.md index 556b1bf0..5d4cbd1a 100644 --- a/.agents/rules/docs-governance.md +++ b/.agents/rules/docs-governance.md @@ -8,7 +8,7 @@ alwaysApply: false Before authoring or editing any doc in this repo, **read the [`docs-governance` skill](../skills/docs-governance/SKILL.md)** for the full reference. This rule is the priming layer. -The canonical Rules (1–10) live in [`docs/README.md`](../../docs/README.md) — cite them by number; never restate them. +The canonical Rules (1–10) live in [`docs/README.md`](../../docs/README.md) — cite them by number; never restate them. Consumer-surface policy: [`.agents/rules/consumer-surfaces.md`](./consumer-surfaces.md) (Rule 10 sub-bullet). ## Surface tiers (which subset of governance applies) diff --git a/.changeset/mcp-invocation-resolve.md b/.changeset/mcp-invocation-resolve.md new file mode 100644 index 00000000..2afb9667 --- /dev/null +++ b/.changeset/mcp-invocation-resolve.md @@ -0,0 +1,5 @@ +--- +"@stainless-code/codemap": patch +--- + +`codemap agents init --mcp` writes PM-aware MCP spawn commands (e.g. `npx codemap`, `pnpm exec codemap`, `yarn exec codemap`, `bunx codemap`, or dlx `@stainless-code/codemap@latest`) instead of assuming global `codemap` on PATH. diff --git a/.cursor/rules/consumer-surfaces.mdc b/.cursor/rules/consumer-surfaces.mdc new file mode 120000 index 00000000..19c6e550 --- /dev/null +++ b/.cursor/rules/consumer-surfaces.mdc @@ -0,0 +1 @@ +../../.agents/rules/consumer-surfaces.md \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 40882dda..18f688fd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -92,6 +92,9 @@ jobs: - name: Run unit tests with coverage run: bun run test:coverage + - name: Run scripts tests (Action helpers + TS↔mjs sync) + run: bun run test:scripts + - name: Golden query regression (fixtures/minimal) run: bun run test:golden diff --git a/README.md b/README.md index 5f56f326..516ec979 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,7 @@ codemap context --compact --for "refactor auth" # JSON envelope + i codemap ingest-coverage coverage/coverage-final.json --json # Istanbul / LCOV (auto-detected) → coverage table; joins with symbols NODE_V8_COVERAGE=.cov bun test && codemap ingest-coverage .cov --runtime --json # V8 protocol (per-process dumps); local-only codemap agents init # scaffold .agents/ rules + skills -codemap agents init --mcp # project MCP config (see docs/agents.md) +codemap agents init --mcp # PM-aware project MCP config (see docs/agents.md) codemap apply rename-preview --params old=foo,new=bar --dry-run # preview recipe-driven edits (substrate executor) ``` @@ -246,7 +246,7 @@ codemap --files src/a.ts src/b.tsx # Scaffold .agents/ from bundled templates — full matrix: docs/agents.md codemap agents init codemap agents init --force -codemap agents init --mcp # project MCP config for supported IDEs +codemap agents init --mcp # PM-aware project MCP config (see docs/agents.md) codemap agents init --interactive # -i; IDE wiring + symlink vs copy ``` diff --git a/action.yml b/action.yml index 705d95df..bd9f4b2e 100644 --- a/action.yml +++ b/action.yml @@ -86,7 +86,7 @@ inputs: outputs: agent: - description: "Resolved package manager (npm / pnpm / yarn / bun)." + description: "Resolved package manager (npm / pnpm / yarn / yarn@berry / bun)." value: ${{ steps.detect-pm.outputs.agent }} exec: description: "Shell-ready command used to invoke codemap." @@ -140,14 +140,18 @@ runs: env: PACKAGE_MANAGER: ${{ inputs.package-manager }} VERSION: ${{ inputs.version }} - WORKING_DIRECTORY: ${{ inputs.working-directory }} run: | + ACTION_STAGE="$RUNNER_TEMP/codemap-action" + WORK_DIR="${{ inputs.working-directory }}" + RESOLVED_WORKDIR="$(bash "${{ github.action_path }}/scripts/action-resolve-working-directory.sh" "$GITHUB_WORKSPACE" "$WORK_DIR")" # Action runs without its own node_modules; install the detector lazily. # Pinned to a known version so consumers get reproducible builds. - npm install --no-save --prefix "$RUNNER_TEMP/codemap-action" package-manager-detector@1.6.0 >/dev/null - cp "${{ github.action_path }}/scripts/detect-pm.mjs" "$RUNNER_TEMP/codemap-action/detect-pm.mjs" - cd "$RUNNER_TEMP/codemap-action" - node detect-pm.mjs + npm install --no-save --prefix "$ACTION_STAGE" package-manager-detector@1.6.0 + cp "${{ github.action_path }}/scripts/detect-pm.mjs" \ + "${{ github.action_path }}/scripts/codemap-invocation.mjs" \ + "$ACTION_STAGE/" + cd "$ACTION_STAGE" + WORKING_DIRECTORY="$RESOLVED_WORKDIR" node detect-pm.mjs - name: Validate inputs (mode + flag interactions) if: steps.gate.outputs.skip != 'true' diff --git a/docs/README.md b/docs/README.md index e44bc04a..67b16b52 100644 --- a/docs/README.md +++ b/docs/README.md @@ -44,6 +44,7 @@ These rules are normative — cite them by number in PR review. Ordered by how o - **Auto-flows (no template edit needed)** — recipe additions (`templates/recipes/*.{sql,md}`), schema additions (`src/db.ts` `createTables()`). Both surfaces via `*.gen.md` renderers in `src/application/agent-content.ts`; `codemap skill` re-assembles every call, while MCP/HTTP memoize the assembled skill/rule/schema/mcp-instructions body per server process. - **Narrative changes** — new CLI flag / output mode / MCP tool / HTTP route / output-shape change → edit the relevant hand-written section in **`templates/agent-content/skill/*.md`** (single source of truth; `codemap skill` (CLI), `codemap://skill` (MCP), and `GET /resources/{encoded-uri}` against `codemap serve` (HTTP) all serve the same assembled body). - **Pointer-shape changes** (frontmatter schema, fetch instructions, marker comments) → edit `templates/agents/{rules/codemap,skills/codemap/SKILL}.md` AND bump `EXPECTED_POINTER_VERSION` in `agent-content.ts` so consumers see the staleness nag and re-run `codemap agents init --force`. + - **Consumer-only surfaces** — [`templates/agent-content/`](../templates/agent-content/), [`templates/agents/`](../templates/agents/), [`.changeset/`](../.changeset/) bodies, and user-facing CLI text describe **user-visible behavior** only. No maintainer internals (Action wiring, `src/` module names, dual-file sync, dogfood spawn paths). Full policy: [`.agents/rules/consumer-surfaces.md`](../.agents/rules/consumer-surfaces.md). This repo's `.agents/{rules/codemap,skills/codemap/SKILL}.md` are thin pointers too (regenerate via `bun src/index.ts agents init --force` if pointer shape drifts) — they used to be the dev-side "second copy" to keep in sync; that obligation is gone. diff --git a/docs/agents.md b/docs/agents.md index 59cac192..d298b384 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -127,7 +127,7 @@ Example: `CODEMAP_MCP_TOOLS=query,context,show codemap mcp --no-watch` | Target | Files written | | ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Cursor | `.cursor/mcp.json` — `codemap mcp --watch --root ${workspaceFolder}` | +| Cursor | `.cursor/mcp.json` — PM-resolved spawn + `mcp --watch --root ${workspaceFolder}` (e.g. `npx codemap`, `pnpm exec codemap`, `yarn exec codemap`, `bunx codemap`) | | Claude Code | `.mcp.json` + `.claude/settings.json` — `permissions.allow` includes `mcp__codemap__*` | | VS Code / Copilot | `.vscode/mcp.json` — `servers.codemap` with `type: stdio` | | Continue | `.continue/mcpServers/codemap-mcp.json` (JSON `mcpServers`; also accepted from Cursor/Cline exports) | @@ -138,9 +138,9 @@ Example: `CODEMAP_MCP_TOOLS=query,context,show codemap mcp --no-watch` With **`--mcp`** and no `--target` filter, all **project-local** rows above are written except **Windsurf**, which has no documented workspace MCP path. -Merge is idempotent: foreign MCP servers and existing settings keys are preserved; only the `codemap` server entry and permission pattern are upserted. Requires `codemap` on `PATH` (global install or dev dependency binary). +Merge is idempotent: foreign MCP servers and existing settings keys are preserved; only the `codemap` server entry and permission pattern are upserted. **`command` / spawn args are resolved from the project** (when `@stainless-code/codemap` is listed in `package.json`, the local PM runner is used — e.g. `pnpm exec codemap`, `yarn exec codemap`, `bunx codemap`; otherwise PM dlx of `@stainless-code/codemap@latest` — e.g. `npx @stainless-code/codemap@latest`, `pnpm dlx @stainless-code/codemap@latest`, `yarn dlx @stainless-code/codemap@latest`; yarn classic may fall back to `npx` per `package-manager-detector`; Bun uses **`bunx`**, not `bun x`). Init logs the chosen invocation (`MCP CLI: …`). -**Side-effect-only re-runs:** When `.agents/` already exists, `codemap agents init --mcp` or `--git-hooks` still applies MCP/hook changes without `--force`. `codemap agents init --no-git-hooks --mcp` uninstalls hook blocks and writes MCP even when `.agents/` is absent. Template refresh still requires `--force`. Unparseable MCP JSON is rejected unless `--force` (which replaces the whole file and drops foreign entries — a warning is printed). +**Side-effect-only re-runs:** When `.agents/` already exists, `codemap agents init --mcp` or `--git-hooks` still applies MCP/hook changes without `--force`. `codemap agents init --no-git-hooks --mcp` uninstalls hook blocks and writes MCP even when `.agents/` is absent. Template refresh still requires `--force`. Unparseable MCP JSON is rejected unless `--force` (full file replace; foreign MCP entries dropped — warning printed). Invalid `mcpServers` / `servers` **shape** with `--force` replaces only that map and preserves other top-level keys. ## Section assembler and `*.gen.md` @@ -188,6 +188,7 @@ Warning goes to stderr only so `codemap skill > file.md` stays clean. | **`src/agents-init-targets.ts`** | **`AgentsInitTarget`** + symlink-style integration ids — shared by init and MCP registry without import cycles. | | **`src/agents-init.ts`** | **`runAgentsInit`**, **`upsertCodemapPointerFile`**, **`listRegularFilesRecursive`**, **`applyAgentsInitTargets`** (per-file **`copyFileSync`** / **`symlinkFilesGranular`**), **`ensureGitignoreCodemapPattern`** (writes `/.gitignore`), **`targetsNeedLinkMode`**. | | **`src/agents-init-mcp-registry.ts`** | **`AGENTS_INIT_MCP_REGISTRY`** — paths, formats, defaults, docs URLs (source of truth for the MCP table below). | +| **`src/codemap-invocation.ts`** | PM-aware codemap CLI spawn resolution (`resolveCodemapCliInvocation`, `buildCodemapMcpSpawn`); shared with `scripts/codemap-invocation.mjs`. | | **`src/agents-init-mcp.ts`** | **`applyAgentsInitMcp`**, JSON merge + post-write verify; **`--mcp`** side effect. | | **`src/agents-init-interactive.ts`** | **`@clack/prompts`** flow; calls **`runAgentsInit`**. | | **`src/cli/cmd-agents.ts`** | Lazy-loaded from **`src/cli/main.ts`**. | diff --git a/docs/architecture.md b/docs/architecture.md index 56af5ce0..c4992d55 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -110,6 +110,7 @@ A local SQLite database (`.codemap/index.db`) indexes the project tree and store | `adapters/` | `LanguageAdapter` types and built-in TS/CSS/text implementations | | `parsed-types.ts` | Shared `ParsedFile` shape for workers and adapters | | `agents-init.ts` / `agents-init-interactive.ts` / `agents-init-mcp.ts` / `agents-init-mcp-registry.ts` / `agents-init-targets.ts` / `agents-template-path.ts` | `codemap agents init` — see [agents.md](./agents.md) (granular template + IDE writes, pointer upsert, **`--interactive`**, **`--mcp`** registry-driven JSON merge + verify-after-write, **`/.gitignore`** reconciler). **`agents-template-path.ts`** is the leaf bundled-template resolver (used by init + `application/agent-content` / `query-recipes` without import cycles). | +| `codemap-invocation.ts` / `scripts/codemap-invocation.mjs` | PM-aware codemap CLI spawn resolution (`resolveCodemapCliInvocation`, `buildCodemapMcpSpawn`); TS for **`agents init --mcp`**, `.mjs` mirror for Action **`detect-pm`** — keep in sync (`scripts/codemap-invocation-sync.test.mjs`). | | `cli/cmd-skill.ts` | `codemap skill` / `codemap rule` verbs — thin wrappers over `assembleAgentContent(kind)` that print the bundled markdown to stdout. See [agents.md § Live fetch surface](./agents.md#live-fetch-surface-cli--mcp--http). | | `application/agent-content.ts` | `assembleAgentContent(kind)`, `RENDERERS` map (`*.gen.md` dispatch), `renderRecipesSection` (live recipe catalog), `renderSchemaSection` (in-memory SQLite + `createTables()` DDL), `checkConsumerPointers` / `maybeWarnStalePointers`, `EXPECTED_POINTER_VERSION`. See [agents.md § Section assembler](./agents.md#section-assembler-and-genmd) and [§ Pointer protocol](./agents.md#pointer-protocol-and-staleness-detection). | | `benchmark.ts` (+ `benchmark-default-scenarios.ts`, `benchmark-config.ts`, `benchmark-common.ts`) | SQL vs traditional timing; optional **`CODEMAP_BENCHMARK_CONFIG`** JSON — [benchmark.md § Custom scenarios](./benchmark.md#custom-scenarios-codemap_benchmark_config) | diff --git a/docs/roadmap.md b/docs/roadmap.md index 0905453a..2ece7e79 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -81,6 +81,8 @@ Long-running MCP / HTTP sessions dominate agent workflows; one-shot CLI keeps th - [x] **Index staleness surfacing** — `index_freshness.pending_sync` on `context`, MCP tool metadata, and HTTP headers when the watcher debounce queue or in-flight reindex is active. Shipped [#149](https://github.com/stainless-code/codemap/pull/149). - [x] **Adaptive output budgets** — scale trace/explore/node snippet char caps (and explore row limits) from indexed file counts via **`resolveOutputBudget(file_count)`** in `output-budget.ts`. Shipped [#152](https://github.com/stainless-code/codemap/pull/152). **`context`** hub/signature caps remain in **`resolveContextBudget()`**. - [x] **MCP session lifecycle hygiene** — stdio disconnect detection (stdin EOF, stdout EPIPE, parent-PID poll, SIGINT/SIGTERM) and refcount-gated watcher stop on MCP client exit; HTTP `serve --watch` starts/stops the watcher per client (5s release grace between stateless requests; `/health` excluded). **Explicitly no MCP idle timeout** — process stays up while the stdio pipe is open even without tool calls (IDE hosts do not respawn mid-session). See [architecture.md § Session lifecycle wiring](./architecture.md#cli-usage). Effort: S–M. +- [x] **PM-aware MCP spawn (`agents init --mcp`)** — resolve PM `execute-local` vs dlx for MCP JSON `command`/`args` when codemap is a devDependency. Shipped [#154](https://github.com/stainless-code/codemap/pull/154). +- [ ] **`--mcp-invocation global|auto` flag** — explicit override to force global `codemap` on PATH vs PM-aware auto-resolve. Effort: S. - [ ] **`agents init` uninstall (teardown)** — symmetric inverse of init for failed pilots, template mistakes, or leaving a repo: remove codemap-managed MCP entries, pointer sections, and IDE symlinks only (same scoped paths as init; never delete user-authored `.agents/` siblings). `--target` filter, `--yes` non-interactive. Not the happy-path docs story — adoption stays `init --mcp --git-hooks` + committed `.agents/`. Effort: S. - [x] **HEAD / index freshness warning** — `index_freshness.commit_drift` + `warning` on `context` / tool metadata; boot stderr on `codemap mcp` / `serve` when concerns remain after prime. Shipped [#149](https://github.com/stainless-code/codemap/pull/149). diff --git a/lint-staged.config.js b/lint-staged.config.js index 8bdf5328..8ea59455 100644 --- a/lint-staged.config.js +++ b/lint-staged.config.js @@ -31,4 +31,5 @@ export default { "*.{css,json,md,mdc,html,yaml,yml}": "bun run format:check", "*.{ts,tsx}": typecheckStagedFiles, "*.test.ts": "bun test", + "scripts/**/*.test.mjs": "bun test", }; diff --git a/package.json b/package.json index 92bd2ace..77ba0178 100644 --- a/package.json +++ b/package.json @@ -46,7 +46,7 @@ "benchmark:query": "bun scripts/benchmark-query-output.ts", "build": "tsdown", "changeset": "changeset", - "check": "bun run build && bun run --parallel format:check lint:ci test typecheck && bun run test:golden && bun run test:agent-eval", + "check": "bun run build && bun run --parallel format:check lint:ci test test:scripts typecheck && bun run test:golden && bun run test:agent-eval", "check-updates": "bun update -i --latest", "check:perf-baseline": "bun scripts/check-perf-baseline.ts", "check:perf-baseline:update": "bun scripts/check-perf-baseline.ts --update", @@ -70,6 +70,7 @@ "test:coverage": "bun test --coverage ./src", "test:golden": "bun scripts/query-golden.ts", "test:golden:external": "bun scripts/query-golden.ts --corpus external", + "test:scripts": "bash -c 'files=$(find scripts -name \"*.test.mjs\"); if [ -z \"$files\" ]; then echo \"no scripts test files found\" >&2; exit 1; fi; exec bun test $files'", "typecheck": "tsgo --noEmit", "version": "changeset version && bun run format CHANGELOG.md" }, diff --git a/scripts/action-resolve-working-directory.sh b/scripts/action-resolve-working-directory.sh new file mode 100755 index 00000000..ae9194e2 --- /dev/null +++ b/scripts/action-resolve-working-directory.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +# Resolve Action working-directory under GITHUB_WORKSPACE (mirrors action.yml detect-pm step). +set -euo pipefail +GITHUB_WORKSPACE="${1:?GITHUB_WORKSPACE required}" +WORK_DIR="${2:-}" +if [[ "$WORK_DIR" == *".."* ]]; then + echo "::error::codemap action: working-directory must not contain .." >&2 + exit 1 +fi +if [ -z "$WORK_DIR" ] || [ "$WORK_DIR" = "." ]; then + echo "$GITHUB_WORKSPACE" +else + echo "$GITHUB_WORKSPACE/$WORK_DIR" +fi diff --git a/scripts/action-resolve-working-directory.test.mjs b/scripts/action-resolve-working-directory.test.mjs new file mode 100644 index 00000000..5c3eb81a --- /dev/null +++ b/scripts/action-resolve-working-directory.test.mjs @@ -0,0 +1,51 @@ +/** + * Tests `scripts/action-resolve-working-directory.sh` — same logic as action.yml + * detect-pm WORKING_DIRECTORY resolution. + */ + +import { describe, expect, it } from "bun:test"; +import { spawnSync } from "node:child_process"; +import { chmodSync } from "node:fs"; +import { join } from "node:path"; + +const SCRIPT = join(import.meta.dirname, "action-resolve-working-directory.sh"); +chmodSync(SCRIPT, 0o755); + +function resolve(workspace, workDir) { + const args = [SCRIPT, workspace]; + if (workDir !== undefined) args.push(workDir); + const result = spawnSync("bash", args, { encoding: "utf8" }); + return result; +} + +describe("action-resolve-working-directory.sh", () => { + it("uses GITHUB_WORKSPACE when working-directory is empty", () => { + const result = resolve("/repo", ""); + expect(result.status).toBe(0); + expect(result.stdout.trim()).toBe("/repo"); + }); + + it("uses GITHUB_WORKSPACE when working-directory is .", () => { + const result = resolve("/repo", "."); + expect(result.status).toBe(0); + expect(result.stdout.trim()).toBe("/repo"); + }); + + it("joins relative subdirectory under GITHUB_WORKSPACE", () => { + const result = resolve("/repo", "apps/web"); + expect(result.status).toBe(0); + expect(result.stdout.trim()).toBe("/repo/apps/web"); + }); + + it("rejects .. in working-directory", () => { + const result = resolve("/repo", "../etc"); + expect(result.status).toBe(1); + expect(result.stderr).toContain("must not contain .."); + }); + + it("does not escape workspace for absolute-looking segments", () => { + const result = resolve("/repo", "/etc"); + expect(result.status).toBe(0); + expect(result.stdout.trim()).toBe("/repo//etc"); + }); +}); diff --git a/scripts/codemap-invocation-sync.test.mjs b/scripts/codemap-invocation-sync.test.mjs new file mode 100644 index 00000000..599c37c4 --- /dev/null +++ b/scripts/codemap-invocation-sync.test.mjs @@ -0,0 +1,180 @@ +/** + * TS ↔ mjs resolver parity — catches drift between `src/codemap-invocation.ts` + * and `scripts/codemap-invocation.mjs`. + */ + +import { describe, expect, it, beforeAll, afterAll } from "bun:test"; +import { mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; + +import { + buildCodemapMcpSpawn as spawnFromTs, + formatCodemapExec as execFromTs, + normalizeSpawnCommand as normalizeFromTs, + resolveCodemapCliInvocation as resolveFromTs, + SAFE_CODEMAP_VERSION_RE as versionReFromTs, +} from "../src/codemap-invocation.ts"; +import { + buildCodemapMcpSpawn as spawnFromMjs, + formatCodemapExec as execFromMjs, + normalizeSpawnCommand as normalizeFromMjs, + resolveCodemapCliInvocation as resolveFromMjs, + SAFE_CODEMAP_VERSION_RE as versionReFromMjs, +} from "./codemap-invocation.mjs"; + +let workRoot; + +beforeAll(() => { + workRoot = join(tmpdir(), `codemap-invocation-sync-${process.pid}`); + rmSync(workRoot, { recursive: true, force: true }); + mkdirSync(workRoot, { recursive: true }); +}); + +afterAll(() => { + rmSync(workRoot, { recursive: true, force: true }); +}); + +function makeFixture(name, files) { + const dir = join(workRoot, name); + for (const [path, contents] of Object.entries(files)) { + const filePath = join(dir, path); + mkdirSync(dirname(filePath), { recursive: true }); + writeFileSync(filePath, contents); + } + return dir; +} + +/** @type {Array<{ label: string; fixture: Record; opts: { projectRoot: string; packageManager?: string; version?: string } }>} */ +const CASES = [ + { + label: "pnpm execute-local", + fixture: { + "package.json": JSON.stringify({ + devDependencies: { "@stainless-code/codemap": "^1.0.0" }, + }), + "pnpm-lock.yaml": "lockfileVersion: 9\n", + }, + opts: { projectRoot: "", packageManager: "pnpm" }, + }, + { + label: "npm dlx-latest", + fixture: { + "package.json": JSON.stringify({ name: "empty" }), + "package-lock.json": "{}", + }, + opts: { projectRoot: "", packageManager: "npm" }, + }, + { + label: "npm dlx-pinned", + fixture: { + "package.json": JSON.stringify({ name: "empty" }), + "package-lock.json": "{}", + }, + opts: { projectRoot: "", packageManager: "npm", version: "1.2.3" }, + }, + { + label: "bun bunx local", + fixture: { + "package.json": JSON.stringify({ + devDependencies: { "@stainless-code/codemap": "^1.0.0" }, + }), + "bun.lock": "", + }, + opts: { projectRoot: "", packageManager: "bun" }, + }, + { + label: "yarn berry exec local", + fixture: { + "package.json": JSON.stringify({ + packageManager: "yarn@berry@4.0.0", + devDependencies: { "@stainless-code/codemap": "^1.0.0" }, + }), + "yarn.lock": "", + }, + opts: { projectRoot: "", packageManager: "yarn@berry" }, + }, + { + label: "yarn classic exec local", + fixture: { + "package.json": JSON.stringify({ + devDependencies: { "@stainless-code/codemap": "^1.0.0" }, + }), + "yarn.lock": "", + }, + opts: { projectRoot: "", packageManager: "yarn" }, + }, + { + label: "pnpm dlx-latest", + fixture: { + "package.json": JSON.stringify({ name: "empty" }), + "pnpm-lock.yaml": "lockfileVersion: 9\n", + }, + opts: { projectRoot: "", packageManager: "pnpm" }, + }, + { + label: "monorepo parent walk-up", + fixture: { + "package.json": JSON.stringify({ + devDependencies: { "@stainless-code/codemap": "^1.0.0" }, + }), + "pnpm-lock.yaml": "lockfileVersion: 9\n", + "apps/web/package.json": JSON.stringify({ name: "web" }), + }, + opts: { projectRoot: "", packageManager: "pnpm" }, + }, + { + label: "yarn berry autodetect dlx", + fixture: { + "package.json": JSON.stringify({ + packageManager: "yarn@berry@4.0.0", + name: "empty", + }), + "yarn.lock": "", + }, + opts: { projectRoot: "" }, + }, +]; + +describe("codemap-invocation TS ↔ mjs sync", () => { + for (const testCase of CASES) { + it(`resolveCodemapCliInvocation: ${testCase.label}`, async () => { + const dir = makeFixture( + testCase.label.replace(/\s+/g, "-"), + testCase.fixture, + ); + const projectRoot = + testCase.label === "monorepo parent walk-up" + ? join(dir, "apps", "web") + : dir; + const opts = { ...testCase.opts, projectRoot }; + const ts = await resolveFromTs(opts); + const mjs = await resolveFromMjs(opts); + expect(mjs).toEqual(ts); + }); + } + + it("normalizeSpawnCommand matches", () => { + expect(normalizeFromMjs("bun", ["x", "codemap"])).toEqual( + normalizeFromTs("bun", ["x", "codemap"]), + ); + expect(normalizeFromMjs("pnpm", ["exec", "codemap"])).toEqual( + normalizeFromTs("pnpm", ["exec", "codemap"]), + ); + }); + + it("buildCodemapMcpSpawn matches", () => { + const prefix = { command: "npx", args: ["codemap"] }; + expect(spawnFromMjs(prefix, true)).toEqual(spawnFromTs(prefix, true)); + expect(spawnFromMjs(prefix, false)).toEqual(spawnFromTs(prefix, false)); + }); + + it("formatCodemapExec matches", () => { + const prefix = { command: "pnpm", args: ["exec", "codemap"] }; + expect(execFromMjs(prefix)).toEqual(execFromTs(prefix)); + }); + + it("SAFE_CODEMAP_VERSION_RE matches", () => { + expect(String(versionReFromMjs)).toBe(String(versionReFromTs)); + }); +}); diff --git a/scripts/codemap-invocation.mjs b/scripts/codemap-invocation.mjs new file mode 100644 index 00000000..653eb3ee --- /dev/null +++ b/scripts/codemap-invocation.mjs @@ -0,0 +1,172 @@ +#!/usr/bin/env node +// @ts-check +/** Keep in sync with `src/codemap-invocation.ts`. */ + +import { existsSync, readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; + +import { resolveCommand } from "package-manager-detector/commands"; +import { detect } from "package-manager-detector/detect"; + +/** @typedef {"project-installed" | "dlx-pinned" | "dlx-latest"} CodemapInstallMethod */ +/** @typedef {{ command: string; args: string[]; installMethod: CodemapInstallMethod; agent: string }} ResolvedCodemapInvocation */ +/** @typedef {Pick} CodemapInvocationPrefix */ + +/** @type {"@stainless-code/codemap"} */ +export const CODEMAP_PUBLISHED_NAME = "@stainless-code/codemap"; + +/** @type {readonly ["@stainless-code/codemap", "codemap"]} */ +export const CODEMAP_DEP_KEYS = ["@stainless-code/codemap", "codemap"]; + +const VALID_AGENTS = new Set(["npm", "pnpm", "yarn", "yarn@berry", "bun"]); + +export const SAFE_CODEMAP_VERSION_RE = /^[A-Za-z0-9][A-Za-z0-9._+^-]*$/; + +/** + * MCP JSON: `bunx`, not `bun` + `x`. + * @param {string} command + * @param {string[]} args + * @returns {{ command: string; args: string[] }} + */ +export function normalizeSpawnCommand(command, args) { + if (command === "bun" && args[0] === "x") { + return { command: "bunx", args: args.slice(1) }; + } + return { command, args }; +} + +/** @param {string} manifestPath @returns {boolean} */ +function manifestHasCodemap(manifestPath) { + if (!existsSync(manifestPath)) return false; + try { + const manifest = JSON.parse(readFileSync(manifestPath, "utf8")); + const buckets = [ + manifest?.dependencies, + manifest?.devDependencies, + manifest?.optionalDependencies, + ]; + return buckets.some( + (b) => + b !== null && + b !== undefined && + CODEMAP_DEP_KEYS.some((k) => b[k] !== undefined), + ); + } catch { + return false; + } +} + +/** @param {string} workingDir @returns {boolean} */ +export function codemapInProjectDependencies(workingDir) { + let dir = workingDir; + for (;;) { + if (manifestHasCodemap(join(dir, "package.json"))) return true; + const parent = dirname(dir); + if (parent === dir) return false; + dir = parent; + } +} + +/** @param {string} version */ +function validateCodemapVersionInput(version) { + if (version === "") return; + if (version.includes("\n") || version.includes("\r")) { + throw new Error("VERSION must not contain line breaks."); + } + if (!SAFE_CODEMAP_VERSION_RE.test(version)) { + throw new Error( + `VERSION "${version}" contains invalid characters. Use a semver pin or dist-tag (e.g. 1.2.3, latest).`, + ); + } +} + +/** `package-manager-detector` reports Berry as `{ agent: "yarn", version: "berry" }`. */ +/** @param {{ agent?: string; version?: string } | null | undefined} detected @returns {string} */ +function normalizeDetectedAgent(detected) { + const agent = detected?.agent ?? "npm"; + if (agent === "yarn" && detected?.version === "berry") { + return "yarn@berry"; + } + return agent; +} + +/** + * @param {{ projectRoot: string; packageManager?: string; version?: string }} opts + * @returns {Promise} + */ +export async function resolveCodemapCliInvocation(opts) { + const projectRoot = opts.projectRoot; + const versionInput = (opts.version ?? "").trim(); + validateCodemapVersionInput(versionInput); + + let agent = (opts.packageManager ?? "").trim(); + if (agent !== "" && !VALID_AGENTS.has(agent)) { + throw new Error( + `package-manager "${agent}" not recognised. Expected one of: ${[...VALID_AGENTS].join(", ")}.`, + ); + } + if (agent === "") { + const detected = await detect({ cwd: projectRoot }); + agent = normalizeDetectedAgent(detected); + } + + /** @type {"execute" | "execute-local"} */ + let intent; + /** @type {string[]} */ + let commandArgs; + /** @type {CodemapInstallMethod} */ + let installMethod; + if (versionInput !== "") { + intent = "execute"; + commandArgs = [`${CODEMAP_PUBLISHED_NAME}@${versionInput}`]; + installMethod = "dlx-pinned"; + } else if (codemapInProjectDependencies(projectRoot)) { + intent = "execute-local"; + commandArgs = ["codemap"]; + installMethod = "project-installed"; + } else { + intent = "execute"; + commandArgs = [`${CODEMAP_PUBLISHED_NAME}@latest`]; + installMethod = "dlx-latest"; + } + + const resolved = resolveCommand(agent, intent, commandArgs); + if (resolved === null) { + throw new Error( + `package-manager-detector returned null for agent="${agent}", intent="${intent}". ` + + `Check that the agent supports this intent (npm/pnpm/yarn/yarn@berry/bun execute-local or dlx).`, + ); + } + const normalized = normalizeSpawnCommand(resolved.command, resolved.args); + return { + ...normalized, + installMethod, + agent, + }; +} + +/** @param {boolean | undefined} includeWorkspaceRoot @returns {string[]} */ +function codemapMcpTailArgs(includeWorkspaceRoot) { + const args = ["mcp", "--watch"]; + if (includeWorkspaceRoot === true) { + args.push("--root", "${workspaceFolder}"); + } + return args; +} + +/** + * @param {CodemapInvocationPrefix} invocation + * @param {boolean | undefined} includeWorkspaceRoot + * @returns {{ command: string; args: string[] }} + */ +export function buildCodemapMcpSpawn(invocation, includeWorkspaceRoot) { + return { + command: invocation.command, + args: [...invocation.args, ...codemapMcpTailArgs(includeWorkspaceRoot)], + }; +} + +/** Action `GITHUB_OUTPUT`. @param {CodemapInvocationPrefix} invocation @returns {string} */ +export function formatCodemapExec(invocation) { + return [invocation.command, ...invocation.args].join(" "); +} diff --git a/scripts/detect-pm.mjs b/scripts/detect-pm.mjs index 34a27ac9..5617db4b 100644 --- a/scripts/detect-pm.mjs +++ b/scripts/detect-pm.mjs @@ -13,93 +13,40 @@ * Q2 + Q3 of docs/plans/github-marketplace-action.md. */ -import { appendFileSync, existsSync, readFileSync } from "node:fs"; -import { join } from "node:path"; +import { appendFileSync } from "node:fs"; import process from "node:process"; -import { resolveCommand } from "package-manager-detector/commands"; -import { detect } from "package-manager-detector/detect"; - -const VALID_AGENTS = new Set(["npm", "pnpm", "yarn", "yarn@berry", "bun"]); - -/** Semver pin or dist-tag for `@stainless-code/codemap@` — no whitespace or shell metacharacters. */ -const SAFE_VERSION_RE = /^[A-Za-z0-9][A-Za-z0-9._+^-]*$/; - -function validateVersionInput(version) { - if (version === "") return; - if (version.includes("\n") || version.includes("\r")) { - fail("VERSION must not contain line breaks."); - } - if (!SAFE_VERSION_RE.test(version)) { - fail( - `VERSION "${version}" contains invalid characters. Use a semver pin or dist-tag (e.g. 1.2.3, latest).`, - ); - } -} +import { + formatCodemapExec, + resolveCodemapCliInvocation, +} from "./codemap-invocation.mjs"; async function main() { const explicitAgent = (process.env["PACKAGE_MANAGER"] ?? "").trim(); const versionInput = (process.env["VERSION"] ?? "").trim(); - validateVersionInput(versionInput); const workingDir = (process.env["WORKING_DIRECTORY"] ?? "").trim() || process.cwd(); - let agent; - if (explicitAgent !== "") { - if (!VALID_AGENTS.has(explicitAgent)) { - fail( - `package-manager input "${explicitAgent}" not recognised. Expected one of: ${[...VALID_AGENTS].join(", ")}.`, - ); - } - agent = explicitAgent; - } else { - const detected = await detect({ cwd: workingDir }); - agent = detected?.agent ?? "npm"; - } - - // Per Q3 (docs/plans/github-marketplace-action.md). `execute-local` resolves - // the `codemap` bin alias; `execute` (dlx) needs the scoped registry name. - const PUBLISHED_NAME = "@stainless-code/codemap"; - let intent; - let commandArgs; - let installMethod; - if (versionInput !== "") { - intent = "execute"; - commandArgs = [`${PUBLISHED_NAME}@${versionInput}`]; - installMethod = "dlx-pinned"; - } else if (codemapInDevDependencies(workingDir)) { - intent = "execute-local"; - commandArgs = ["codemap"]; - installMethod = "project-installed"; - } else { - intent = "execute"; - commandArgs = [`${PUBLISHED_NAME}@latest`]; - installMethod = "dlx-latest"; - } - - const resolved = resolveCommand(agent, intent, commandArgs); - if (resolved === null) { - fail( - `package-manager-detector returned null for agent="${agent}", intent="${intent}". This usually means the agent doesn't support that intent (e.g. deno's execute-local).`, - ); - } - const { command, args } = resolved; - const exec = [command, ...args].join(" "); + const resolved = await resolveCodemapCliInvocation({ + projectRoot: workingDir, + packageManager: explicitAgent !== "" ? explicitAgent : undefined, + version: versionInput !== "" ? versionInput : undefined, + }); + const exec = formatCodemapExec(resolved); const outputFile = process.env["GITHUB_OUTPUT"]; if (outputFile === undefined || outputFile === "") { - // Local / non-Actions invocation: dump to stdout. - console.log(`agent=${agent}`); + console.log(`agent=${resolved.agent}`); console.log(`exec=${exec}`); - console.log(`install_method=${installMethod}`); + console.log(`install_method=${resolved.installMethod}`); return; } appendFileSync( outputFile, formatGithubOutput({ - agent, + agent: resolved.agent, exec, - install_method: installMethod, + install_method: resolved.installMethod, }), ); } @@ -117,30 +64,6 @@ function formatGithubOutput(entries) { return block; } -// Scoped published name + bare bin name (workspace aliases use the latter). -const CODEMAP_DEP_KEYS = ["@stainless-code/codemap", "codemap"]; - -function codemapInDevDependencies(workingDir) { - try { - const manifestPath = join(workingDir, "package.json"); - if (!existsSync(manifestPath)) return false; - const manifest = JSON.parse(readFileSync(manifestPath, "utf8")); - const buckets = [ - manifest?.dependencies, - manifest?.devDependencies, - manifest?.optionalDependencies, - ]; - return buckets.some( - (b) => - b !== null && - b !== undefined && - CODEMAP_DEP_KEYS.some((k) => b[k] !== undefined), - ); - } catch { - return false; - } -} - function fail(message) { console.error(`detect-pm: ${message}`); process.exit(1); diff --git a/scripts/detect-pm.test.mjs b/scripts/detect-pm.test.mjs index 3976d41d..91f8d5bc 100644 --- a/scripts/detect-pm.test.mjs +++ b/scripts/detect-pm.test.mjs @@ -10,11 +10,19 @@ import { describe, expect, it, beforeAll, afterAll } from "bun:test"; import { spawnSync } from "node:child_process"; -import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { + copyFileSync, + mkdirSync, + readFileSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { join, dirname } from "node:path"; const SCRIPT = join(import.meta.dirname, "detect-pm.mjs"); +const REPO_NODE_MODULES = join(import.meta.dirname, "..", "node_modules"); let workRoot; beforeAll(() => { @@ -31,7 +39,9 @@ function makeFixture(name, files) { const dir = join(workRoot, name); mkdirSync(dir, { recursive: true }); for (const [path, contents] of Object.entries(files)) { - writeFileSync(join(dir, path), contents); + const filePath = join(dir, path); + mkdirSync(dirname(filePath), { recursive: true }); + writeFileSync(filePath, contents); } return dir; } @@ -108,6 +118,21 @@ describe("scripts/detect-pm.mjs", () => { expect(out.exec).not.toContain("@stainless-code/codemap@"); }); + it("uses bunx (not bun x) for bun execute-local", () => { + const dir = makeFixture("bun-local-fixture", { + "package.json": JSON.stringify({ + devDependencies: { "@stainless-code/codemap": "^1.0.0" }, + }), + "bun.lock": "", + }); + const out = runDetect({ + WORKING_DIRECTORY: dir, + PACKAGE_MANAGER: "bun", + }); + expect(out.install_method).toBe("project-installed"); + expect(out.exec).toBe("bunx codemap"); + }); + it("uses execute-local when bare `codemap` key is set (workspace alias case)", () => { const dir = makeFixture("bare-dev-dep-fixture", { "package.json": JSON.stringify({ @@ -213,4 +238,69 @@ describe("scripts/detect-pm.mjs", () => { const out = runDetect({ WORKING_DIRECTORY: dir }); expect(out.agent).toBe("pnpm"); }); + + it("autodetects yarn@berry from packageManager field", () => { + const dir = makeFixture("yarn-berry-autodetect", { + "package.json": JSON.stringify({ + packageManager: "yarn@berry@4.0.0", + name: "berry-app", + }), + "yarn.lock": "", + }); + const out = runDetect({ WORKING_DIRECTORY: dir }); + expect(out.agent).toBe("yarn@berry"); + expect(out.exec).toContain("yarn dlx"); + }); + + it("runs from an Action-style isolated stage with both script files", () => { + const stage = join(workRoot, "action-stage"); + mkdirSync(stage, { recursive: true }); + copyFileSync( + join(import.meta.dirname, "detect-pm.mjs"), + join(stage, "detect-pm.mjs"), + ); + copyFileSync( + join(import.meta.dirname, "codemap-invocation.mjs"), + join(stage, "codemap-invocation.mjs"), + ); + symlinkSync(REPO_NODE_MODULES, join(stage, "node_modules"), "dir"); + const dir = makeFixture("action-stage-project", { + "package.json": JSON.stringify({ + devDependencies: { "@stainless-code/codemap": "^1.0.0" }, + }), + "package-lock.json": "{}", + }); + const result = spawnSync("node", ["detect-pm.mjs"], { + cwd: stage, + env: { + ...process.env, + GITHUB_OUTPUT: "", + WORKING_DIRECTORY: dir, + }, + encoding: "utf8", + }); + if (result.status !== 0) { + throw new Error( + `detect-pm action stage exited ${result.status}: ${result.stderr || result.stdout}`, + ); + } + expect(result.stdout).toContain("agent=npm"); + expect(result.stdout).toContain("install_method=project-installed"); + }); + + it("resolve walk-up from monorepo child via absolute WORKING_DIRECTORY", () => { + const root = makeFixture("monorepo-detect-pm", { + "package.json": JSON.stringify({ + devDependencies: { "@stainless-code/codemap": "^1.0.0" }, + }), + "package-lock.json": "{}", + "apps/web/package.json": JSON.stringify({ name: "web" }), + }); + const out = runDetect({ + WORKING_DIRECTORY: join(root, "apps", "web"), + PACKAGE_MANAGER: "npm", + }); + expect(out.install_method).toBe("project-installed"); + expect(out.exec).toContain("codemap"); + }); }); diff --git a/src/agents-init-interactive.ts b/src/agents-init-interactive.ts index f8a57006..ae2f1b28 100644 --- a/src/agents-init-interactive.ts +++ b/src/agents-init-interactive.ts @@ -183,7 +183,7 @@ export async function runAgentsInitInteractive( mcp = offerMcp; } - const success = runAgentsInit({ + const success = await runAgentsInit({ projectRoot: opts.projectRoot, force: opts.force, targets, diff --git a/src/agents-init-mcp.test.ts b/src/agents-init-mcp.test.ts index a9866635..45f798e3 100644 --- a/src/agents-init-mcp.test.ts +++ b/src/agents-init-mcp.test.ts @@ -14,7 +14,6 @@ import { CODEMAP_MCP_PERMISSION_ALLOW, CODEMAP_MCP_SERVER_KEY, applyAgentsInitMcp, - buildCodemapMcpServerEntry, buildMcpServerEntryForDef, mergeClaudeCodemapPermissions, mergeCodemapMcpServer, @@ -24,28 +23,70 @@ import { verifyCodemapMcpServersFile, } from "./agents-init-mcp"; import { getAgentsInitMcpTargetDef } from "./agents-init-mcp-registry"; - -describe("buildCodemapMcpServerEntry", () => { +import { buildCodemapMcpSpawn } from "./codemap-invocation"; +import type { ResolvedCodemapInvocation } from "./codemap-invocation"; + +const NPM_LOCAL_INVOCATION: ResolvedCodemapInvocation = { + command: "npx", + args: ["codemap"], + installMethod: "project-installed", + agent: "npm", +}; + +function seedInstalledCodemapProject(dir: string): void { + writeFileSync( + join(dir, "package.json"), + JSON.stringify({ + devDependencies: { "@stainless-code/codemap": "^1.0.0" }, + }), + ); + writeFileSync(join(dir, "package-lock.json"), "{}"); +} + +function seedPnpmInstalledCodemapProject(dir: string): void { + writeFileSync( + join(dir, "package.json"), + JSON.stringify({ + devDependencies: { "@stainless-code/codemap": "^1.0.0" }, + }), + ); + writeFileSync(join(dir, "pnpm-lock.yaml"), "lockfileVersion: 9\n"); +} + +function seedBunInstalledCodemapProject(dir: string): void { + writeFileSync( + join(dir, "package.json"), + JSON.stringify({ + devDependencies: { "@stainless-code/codemap": "^1.0.0" }, + }), + ); + writeFileSync(join(dir, "bun.lock"), ""); +} + +describe("buildCodemapMcpSpawn", () => { it("includes workspace root for Cursor", () => { - expect(buildCodemapMcpServerEntry({ includeWorkspaceRoot: true })).toEqual({ - command: "codemap", - args: ["mcp", "--watch", "--root", "${workspaceFolder}"], + expect(buildCodemapMcpSpawn(NPM_LOCAL_INVOCATION, true)).toEqual({ + command: "npx", + args: ["codemap", "mcp", "--watch", "--root", "${workspaceFolder}"], }); }); it("omits --root for cwd-based clients", () => { - expect(buildCodemapMcpServerEntry()).toEqual({ - command: "codemap", - args: ["mcp", "--watch"], + expect(buildCodemapMcpSpawn(NPM_LOCAL_INVOCATION, false)).toEqual({ + command: "npx", + args: ["codemap", "mcp", "--watch"], }); }); it("adds Amazon Q IDE transport fields for default.json", () => { expect( - buildMcpServerEntryForDef(getAgentsInitMcpTargetDef("amazon-q-default")), + buildMcpServerEntryForDef( + getAgentsInitMcpTargetDef("amazon-q-default"), + NPM_LOCAL_INVOCATION, + ), ).toEqual({ - command: "codemap", - args: ["mcp", "--watch"], + command: "npx", + args: ["codemap", "mcp", "--watch"], transportType: "stdio", disabled: false, timeout: 60, @@ -61,7 +102,7 @@ describe("mergeCodemapMcpServer", () => { other: { command: "npx", args: ["-y", "other-mcp"] }, }, }, - buildCodemapMcpServerEntry({ includeWorkspaceRoot: true }), + buildCodemapMcpSpawn(NPM_LOCAL_INVOCATION, true), ); expect(Object.keys(merged.mcpServers ?? {})).toEqual(["other", "codemap"]); expect(merged.mcpServers?.other?.command).toBe("npx"); @@ -73,13 +114,16 @@ describe("mergeCodemapMcpServer", () => { it("adds codemap to empty mcpServers", () => { const merged = mergeCodemapMcpServer( { mcpServers: {} }, - buildCodemapMcpServerEntry(), + buildCodemapMcpSpawn(NPM_LOCAL_INVOCATION, false), ); expect(Object.keys(merged.mcpServers ?? {})).toEqual(["codemap"]); }); it("adds codemap when mcpServers key is missing", () => { - const merged = mergeCodemapMcpServer({}, buildCodemapMcpServerEntry()); + const merged = mergeCodemapMcpServer( + {}, + buildCodemapMcpSpawn(NPM_LOCAL_INVOCATION, false), + ); expect(Object.keys(merged.mcpServers ?? {})).toEqual(["codemap"]); }); }); @@ -152,13 +196,13 @@ describe("mergeCodemapVsCodeServer", () => { other: { command: "npx", args: ["-y", "other"] }, }, }, - buildCodemapMcpServerEntry(), + buildCodemapMcpSpawn(NPM_LOCAL_INVOCATION, false), ); expect(merged.servers?.other?.command).toBe("npx"); expect(merged.servers?.[CODEMAP_MCP_SERVER_KEY]).toEqual({ type: "stdio", - command: "codemap", - args: ["mcp", "--watch"], + command: "npx", + args: ["codemap", "mcp", "--watch"], }); }); }); @@ -188,7 +232,7 @@ describe("verifyCodemapMcpServersFile", () => { verifyCodemapMcpServersFile({ path, label: "test mcp.json", - expectedEntry: buildCodemapMcpServerEntry(), + expectedEntry: buildCodemapMcpSpawn(NPM_LOCAL_INVOCATION, false), }), ).toThrow(/missing codemap entry/); } finally { @@ -198,11 +242,59 @@ describe("verifyCodemapMcpServersFile", () => { }); describe("applyAgentsInitMcp", () => { - it("writes all default project MCP files", () => { + it("writes pnpm exec spawn when pnpm-lock is present", async () => { + const dir = mkdtempSync(join(tmpdir(), "codemap-agents-mcp-pnpm-")); + try { + seedPnpmInstalledCodemapProject(dir); + await applyAgentsInitMcp({ projectRoot: dir, targets: ["cursor"] }); + const cursor = JSON.parse( + readFileSync(join(dir, ".cursor", "mcp.json"), "utf-8"), + ) as { + mcpServers: Record; + }; + expect(cursor.mcpServers[CODEMAP_MCP_SERVER_KEY]?.command).toBe("pnpm"); + expect(cursor.mcpServers[CODEMAP_MCP_SERVER_KEY]?.args).toEqual([ + "exec", + "codemap", + "mcp", + "--watch", + "--root", + "${workspaceFolder}", + ]); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("writes bunx spawn when bun.lock is present", async () => { + const dir = mkdtempSync(join(tmpdir(), "codemap-agents-mcp-bun-")); + try { + seedBunInstalledCodemapProject(dir); + await applyAgentsInitMcp({ projectRoot: dir, targets: ["cursor"] }); + const cursor = JSON.parse( + readFileSync(join(dir, ".cursor", "mcp.json"), "utf-8"), + ) as { + mcpServers: Record; + }; + expect(cursor.mcpServers[CODEMAP_MCP_SERVER_KEY]?.command).toBe("bunx"); + expect(cursor.mcpServers[CODEMAP_MCP_SERVER_KEY]?.args).toEqual([ + "codemap", + "mcp", + "--watch", + "--root", + "${workspaceFolder}", + ]); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("writes all default project MCP files", async () => { const dir = mkdtempSync(join(tmpdir(), "codemap-agents-mcp-all-")); const fakeHome = mkdtempSync(join(tmpdir(), "codemap-agents-mcp-home-")); try { - applyAgentsInitMcp({ projectRoot: dir, homeDir: fakeHome }); + seedInstalledCodemapProject(dir); + await applyAgentsInitMcp({ projectRoot: dir, homeDir: fakeHome }); expect(existsSync(join(dir, ".cursor", "mcp.json"))).toBe(true); expect(existsSync(join(dir, ".mcp.json"))).toBe(true); expect(existsSync(join(dir, ".vscode", "mcp.json"))).toBe(true); @@ -217,9 +309,15 @@ describe("applyAgentsInitMcp", () => { const vscode = JSON.parse( readFileSync(join(dir, ".vscode", "mcp.json"), "utf-8"), - ) as { servers: Record }; + ) as { + servers: Record< + string, + { type: string; command: string; args?: string[] } + >; + }; expect(vscode.servers[CODEMAP_MCP_SERVER_KEY]?.type).toBe("stdio"); - expect(vscode.servers[CODEMAP_MCP_SERVER_KEY]?.command).toBe("codemap"); + expect(vscode.servers[CODEMAP_MCP_SERVER_KEY]?.command).toBe("npx"); + expect(vscode.servers[CODEMAP_MCP_SERVER_KEY]?.args?.[0]).toBe("codemap"); const amazonDefault = JSON.parse( readFileSync(join(dir, ".amazonq", "default.json"), "utf-8"), @@ -236,8 +334,8 @@ describe("applyAgentsInitMcp", () => { >; }; expect(amazonDefault.mcpServers[CODEMAP_MCP_SERVER_KEY]).toEqual({ - command: "codemap", - args: ["mcp", "--watch"], + command: "npx", + args: ["codemap", "mcp", "--watch"], transportType: "stdio", disabled: false, timeout: 60, @@ -252,8 +350,8 @@ describe("applyAgentsInitMcp", () => { >; }; expect(amazonLegacy.mcpServers[CODEMAP_MCP_SERVER_KEY]).toEqual({ - command: "codemap", - args: ["mcp", "--watch"], + command: "npx", + args: ["codemap", "mcp", "--watch"], }); expect( amazonLegacy.mcpServers[CODEMAP_MCP_SERVER_KEY]?.transportType, @@ -264,10 +362,11 @@ describe("applyAgentsInitMcp", () => { } }); - it("is idempotent for Amazon Q dual MCP files on re-run", () => { + it("is idempotent for Amazon Q dual MCP files on re-run", async () => { const dir = mkdtempSync(join(tmpdir(), "codemap-agents-mcp-q-idem-")); try { - applyAgentsInitMcp({ + seedInstalledCodemapProject(dir); + await applyAgentsInitMcp({ projectRoot: dir, targets: ["amazon-q", "amazon-q-default"], }); @@ -279,7 +378,7 @@ describe("applyAgentsInitMcp", () => { join(dir, ".amazonq", "default.json"), "utf-8", ); - applyAgentsInitMcp({ + await applyAgentsInitMcp({ projectRoot: dir, targets: ["amazon-q", "amazon-q-default"], }); @@ -294,11 +393,12 @@ describe("applyAgentsInitMcp", () => { } }); - it("writes project .cline/mcp.json when cline target selected", () => { + it("writes project .cline/mcp.json when cline target selected", async () => { const dir = mkdtempSync(join(tmpdir(), "codemap-agents-mcp-cl-")); const fakeHome = mkdtempSync(join(tmpdir(), "codemap-agents-mcp-cl-home-")); try { - applyAgentsInitMcp({ + seedInstalledCodemapProject(dir); + await applyAgentsInitMcp({ projectRoot: dir, homeDir: fakeHome, targets: ["cline"], @@ -312,11 +412,12 @@ describe("applyAgentsInitMcp", () => { } }); - it("writes Windsurf global config only when windsurf target selected", () => { + it("writes Windsurf global config only when windsurf target selected", async () => { const dir = mkdtempSync(join(tmpdir(), "codemap-agents-mcp-ws-")); const fakeHome = mkdtempSync(join(tmpdir(), "codemap-agents-mcp-ws-home-")); try { - applyAgentsInitMcp({ + seedInstalledCodemapProject(dir); + await applyAgentsInitMcp({ projectRoot: dir, homeDir: fakeHome, targets: ["windsurf"], @@ -331,10 +432,11 @@ describe("applyAgentsInitMcp", () => { } }); - it("writes Cursor and Claude project MCP files", () => { + it("writes Cursor and Claude project MCP files", async () => { const dir = mkdtempSync(join(tmpdir(), "codemap-agents-mcp-")); try { - applyAgentsInitMcp({ projectRoot: dir }); + seedInstalledCodemapProject(dir); + await applyAgentsInitMcp({ projectRoot: dir }); const cursor = JSON.parse( readFileSync(join(dir, ".cursor", "mcp.json"), "utf-8"), ) as { mcpServers: Record }; @@ -346,6 +448,7 @@ describe("applyAgentsInitMcp", () => { readFileSync(join(dir, ".mcp.json"), "utf-8"), ) as { mcpServers: Record }; expect(claudeMcp.mcpServers[CODEMAP_MCP_SERVER_KEY]?.args).toEqual([ + "codemap", "mcp", "--watch", ]); @@ -361,9 +464,10 @@ describe("applyAgentsInitMcp", () => { } }); - it("merges into existing .cursor/mcp.json without clobbering other servers", () => { + it("merges into existing .cursor/mcp.json without clobbering other servers", async () => { const dir = mkdtempSync(join(tmpdir(), "codemap-agents-mcp-")); try { + seedInstalledCodemapProject(dir); mkdirSync(join(dir, ".cursor"), { recursive: true }); writeFileSync( join(dir, ".cursor", "mcp.json"), @@ -378,7 +482,7 @@ describe("applyAgentsInitMcp", () => { )}\n`, "utf-8", ); - applyAgentsInitMcp({ projectRoot: dir, targets: ["cursor"] }); + await applyAgentsInitMcp({ projectRoot: dir, targets: ["cursor"] }); const parsed = JSON.parse( readFileSync(join(dir, ".cursor", "mcp.json"), "utf-8"), ) as { mcpServers: Record }; @@ -392,12 +496,13 @@ describe("applyAgentsInitMcp", () => { } }); - it("is idempotent on re-run", () => { + it("is idempotent on re-run", async () => { const dir = mkdtempSync(join(tmpdir(), "codemap-agents-mcp-")); try { - applyAgentsInitMcp({ projectRoot: dir, targets: ["cursor"] }); + seedInstalledCodemapProject(dir); + await applyAgentsInitMcp({ projectRoot: dir, targets: ["cursor"] }); const before = readFileSync(join(dir, ".cursor", "mcp.json"), "utf-8"); - applyAgentsInitMcp({ projectRoot: dir, targets: ["cursor"] }); + await applyAgentsInitMcp({ projectRoot: dir, targets: ["cursor"] }); expect(readFileSync(join(dir, ".cursor", "mcp.json"), "utf-8")).toBe( before, ); @@ -406,38 +511,40 @@ describe("applyAgentsInitMcp", () => { } }); - it("rejects invalid JSON without --force", () => { + it("rejects invalid JSON without --force", async () => { const dir = mkdtempSync(join(tmpdir(), "codemap-agents-mcp-")); try { + seedInstalledCodemapProject(dir); mkdirSync(join(dir, ".cursor"), { recursive: true }); writeFileSync(join(dir, ".cursor", "mcp.json"), "{ not json", "utf-8"); - expect(() => + await expect( applyAgentsInitMcp({ projectRoot: dir, targets: ["cursor"] }), - ).toThrow(/could not parse/); + ).rejects.toThrow(/could not parse/); expect(existsSync(join(dir, ".cursor", "mcp.json"))).toBe(true); } finally { rmSync(dir, { recursive: true, force: true }); } }); - it("rejects non-object mcpServers without --force", () => { + it("rejects non-object mcpServers without --force", async () => { const dir = mkdtempSync(join(tmpdir(), "codemap-agents-mcp-")); try { + seedInstalledCodemapProject(dir); mkdirSync(join(dir, ".cursor"), { recursive: true }); writeFileSync( join(dir, ".cursor", "mcp.json"), `${JSON.stringify({ mcpServers: "bad" }, null, 2)}\n`, "utf-8", ); - expect(() => + await expect( applyAgentsInitMcp({ projectRoot: dir, targets: ["cursor"] }), - ).toThrow(/mcpServers must be a JSON object/); + ).rejects.toThrow(/mcpServers must be a JSON object/); } finally { rmSync(dir, { recursive: true, force: true }); } }); - it("force-replaces invalid mcpServers shape and preserves other keys", () => { + it("force-replaces invalid mcpServers shape and preserves other keys", async () => { const dir = mkdtempSync(join(tmpdir(), "codemap-agents-mcp-")); const stderr: string[] = []; const prevError = console.error; @@ -454,7 +561,7 @@ describe("applyAgentsInitMcp", () => { `${JSON.stringify({ mcpServers: "bad", editor: "cursor" }, null, 2)}\n`, "utf-8", ); - applyAgentsInitMcp({ + await applyAgentsInitMcp({ projectRoot: dir, targets: ["cursor"], force: true, @@ -466,9 +573,7 @@ describe("applyAgentsInitMcp", () => { mcpServers: Record; }; expect(parsed.editor).toBe("cursor"); - expect(parsed.mcpServers[CODEMAP_MCP_SERVER_KEY]?.command).toBe( - "codemap", - ); + expect(parsed.mcpServers[CODEMAP_MCP_SERVER_KEY]?.command).toBe("npx"); expect( stderr.some((line) => line.includes("invalid mcpServers shape")), ).toBe(true); @@ -478,7 +583,47 @@ describe("applyAgentsInitMcp", () => { } }); - it("replaces invalid JSON with --force", () => { + it("force-replaces invalid VS Code servers shape and preserves other keys", async () => { + const dir = mkdtempSync(join(tmpdir(), "codemap-agents-mcp-vscode-")); + const stderr: string[] = []; + const prevError = console.error; + console.error = (...args: unknown[]) => { + stderr.push( + args.map((a) => (typeof a === "string" ? a : String(a))).join(" "), + ); + prevError(...args); + }; + try { + mkdirSync(join(dir, ".vscode"), { recursive: true }); + writeFileSync( + join(dir, ".vscode", "mcp.json"), + `${JSON.stringify({ servers: "bad", editor: "vscode" }, null, 2)}\n`, + "utf-8", + ); + await applyAgentsInitMcp({ + projectRoot: dir, + targets: ["vscode"], + force: true, + }); + const parsed = JSON.parse( + readFileSync(join(dir, ".vscode", "mcp.json"), "utf-8"), + ) as { + editor: string; + servers: Record; + }; + expect(parsed.editor).toBe("vscode"); + expect(parsed.servers[CODEMAP_MCP_SERVER_KEY]?.type).toBe("stdio"); + expect(parsed.servers[CODEMAP_MCP_SERVER_KEY]?.command).toBe("npx"); + expect( + stderr.some((line) => line.includes("invalid servers shape")), + ).toBe(true); + } finally { + console.error = prevError; + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("replaces invalid JSON with --force", async () => { const dir = mkdtempSync(join(tmpdir(), "codemap-agents-mcp-")); const stderr: string[] = []; const prevError = console.error; @@ -491,7 +636,7 @@ describe("applyAgentsInitMcp", () => { try { mkdirSync(join(dir, ".cursor"), { recursive: true }); writeFileSync(join(dir, ".cursor", "mcp.json"), "{ not json", "utf-8"); - applyAgentsInitMcp({ + await applyAgentsInitMcp({ projectRoot: dir, targets: ["cursor"], force: true, @@ -499,9 +644,7 @@ describe("applyAgentsInitMcp", () => { const parsed = JSON.parse( readFileSync(join(dir, ".cursor", "mcp.json"), "utf-8"), ) as { mcpServers: Record }; - expect(parsed.mcpServers[CODEMAP_MCP_SERVER_KEY]?.command).toBe( - "codemap", - ); + expect(parsed.mcpServers[CODEMAP_MCP_SERVER_KEY]?.command).toBe("npx"); expect(stderr.some((line) => line.includes("unparseable JSON"))).toBe( true, ); @@ -511,7 +654,7 @@ describe("applyAgentsInitMcp", () => { } }); - it("replaces invalid Claude .mcp.json with --force", () => { + it("replaces invalid Claude .mcp.json with --force", async () => { const dir = mkdtempSync(join(tmpdir(), "codemap-agents-mcp-")); const stderr: string[] = []; const prevError = console.error; @@ -523,7 +666,7 @@ describe("applyAgentsInitMcp", () => { }; try { writeFileSync(join(dir, ".mcp.json"), "{ not json", "utf-8"); - applyAgentsInitMcp({ + await applyAgentsInitMcp({ projectRoot: dir, targets: ["claude-code"], force: true, @@ -533,9 +676,7 @@ describe("applyAgentsInitMcp", () => { ) as { mcpServers: Record; }; - expect(parsed.mcpServers[CODEMAP_MCP_SERVER_KEY]?.command).toBe( - "codemap", - ); + expect(parsed.mcpServers[CODEMAP_MCP_SERVER_KEY]?.command).toBe("npx"); expect( stderr.some((line) => line.includes(".mcp.json (Claude Code)")), ).toBe(true); @@ -545,7 +686,7 @@ describe("applyAgentsInitMcp", () => { } }); - it("replaces invalid .claude/settings.json with --force", () => { + it("replaces invalid .claude/settings.json with --force", async () => { const dir = mkdtempSync(join(tmpdir(), "codemap-agents-mcp-")); const stderr: string[] = []; const prevError = console.error; @@ -562,7 +703,7 @@ describe("applyAgentsInitMcp", () => { "{ not json", "utf-8", ); - applyAgentsInitMcp({ + await applyAgentsInitMcp({ projectRoot: dir, targets: ["claude-code"], force: true, @@ -582,9 +723,10 @@ describe("applyAgentsInitMcp", () => { } }); - it("merges Claude files without clobbering foreign servers or permissions", () => { + it("merges Claude files without clobbering foreign servers or permissions", async () => { const dir = mkdtempSync(join(tmpdir(), "codemap-agents-mcp-")); try { + seedInstalledCodemapProject(dir); writeFileSync( join(dir, ".mcp.json"), `${JSON.stringify( @@ -613,7 +755,7 @@ describe("applyAgentsInitMcp", () => { )}\n`, "utf-8", ); - applyAgentsInitMcp({ projectRoot: dir, targets: ["claude-code"] }); + await applyAgentsInitMcp({ projectRoot: dir, targets: ["claude-code"] }); const mcp = JSON.parse(readFileSync(join(dir, ".mcp.json"), "utf-8")) as { mcpServers: Record; }; @@ -636,24 +778,25 @@ describe("applyAgentsInitMcp", () => { } }); - it("rejects malformed permissions.allow without --force", () => { + it("rejects malformed permissions.allow without --force", async () => { const dir = mkdtempSync(join(tmpdir(), "codemap-agents-mcp-")); try { + seedInstalledCodemapProject(dir); mkdirSync(join(dir, ".claude"), { recursive: true }); writeFileSync( join(dir, ".claude", "settings.json"), `${JSON.stringify({ permissions: { allow: "bad" } }, null, 2)}\n`, "utf-8", ); - expect(() => + await expect( applyAgentsInitMcp({ projectRoot: dir, targets: ["claude-code"] }), - ).toThrow(/permissions\.allow must be a string\[\]/); + ).rejects.toThrow(/permissions\.allow must be a string\[\]/); } finally { rmSync(dir, { recursive: true, force: true }); } }); - it("merges Amazon Q default.json without clobbering foreign keys", () => { + it("merges Amazon Q default.json without clobbering foreign keys", async () => { const dir = mkdtempSync(join(tmpdir(), "codemap-agents-mcp-q-")); try { mkdirSync(join(dir, ".amazonq"), { recursive: true }); @@ -675,7 +818,7 @@ describe("applyAgentsInitMcp", () => { )}\n`, "utf-8", ); - applyAgentsInitMcp({ + await applyAgentsInitMcp({ projectRoot: dir, targets: ["amazon-q-default"], }); @@ -692,7 +835,7 @@ describe("applyAgentsInitMcp", () => { transportType: "stdio", }); expect(parsed.mcpServers[CODEMAP_MCP_SERVER_KEY]).toMatchObject({ - command: "codemap", + command: "npx", transportType: "stdio", }); } finally { diff --git a/src/agents-init-mcp.ts b/src/agents-init-mcp.ts index c562b3c5..b1914399 100644 --- a/src/agents-init-mcp.ts +++ b/src/agents-init-mcp.ts @@ -11,6 +11,12 @@ import type { AgentsInitMcpTarget, AgentsInitMcpTargetDef, } from "./agents-init-mcp-registry"; +import { + buildCodemapMcpSpawn, + formatCodemapExec, + resolveCodemapCliInvocation, +} from "./codemap-invocation"; +import type { ResolvedCodemapInvocation } from "./codemap-invocation"; /** MCP server key in Cursor / Claude / Windsurf `mcpServers` maps and VS Code `servers`. */ export const CODEMAP_MCP_SERVER_KEY = "codemap"; @@ -46,23 +52,12 @@ export interface ClaudeSettingsFile { }; } -export function buildCodemapMcpServerEntry(opts?: { - includeWorkspaceRoot?: boolean | undefined; -}): McpServerEntry { - const args = ["mcp", "--watch"]; - if (opts?.includeWorkspaceRoot === true) { - args.push("--root", "${workspaceFolder}"); - } - return { command: "codemap", args }; -} - /** Host-specific codemap MCP entry (Cursor root arg, Amazon Q IDE transport fields, …). */ export function buildMcpServerEntryForDef( def: Pick, + invocation: ResolvedCodemapInvocation, ): McpServerEntry { - const base = buildCodemapMcpServerEntry({ - includeWorkspaceRoot: def.workspaceRootArg === true, - }); + const base = buildCodemapMcpSpawn(invocation, def.workspaceRootArg === true); if (def.format === "amazon-q-ide") { return { ...base, @@ -519,7 +514,9 @@ export interface ApplyAgentsInitMcpOptions { * Write MCP config for selected integrations. Cursor uses * `${workspaceFolder}` root injection; most other clients rely on workspace cwd. */ -export function applyAgentsInitMcp(opts: ApplyAgentsInitMcpOptions): void { +export async function applyAgentsInitMcp( + opts: ApplyAgentsInitMcpOptions, +): Promise { const targets = opts.targets ?? [...DEFAULT_AGENTS_INIT_MCP_TARGETS]; if (targets.length === 0) { console.log( @@ -532,17 +529,23 @@ export function applyAgentsInitMcp(opts: ApplyAgentsInitMcpOptions): void { projectRoot: opts.projectRoot, homeDir: opts.homeDir ?? homedir(), }; + const invocation = await resolveCodemapCliInvocation({ + projectRoot: opts.projectRoot, + }); + console.log( + ` MCP CLI: ${formatCodemapExec(invocation)} (${invocation.installMethod})`, + ); for (const id of targets) { const def = getAgentsInitMcpTargetDef(id); - const entry = buildMcpServerEntryForDef(def); + const entry = buildMcpServerEntryForDef(def, invocation); const path = resolveMcpConfigPath(def, roots); if (def.format === "vscode-servers") { upsertVsCodeMcpFile({ path, label: def.label, - entry: buildCodemapMcpServerEntry(), + entry, force, }); } else { diff --git a/src/agents-init.test.ts b/src/agents-init.test.ts index 4ba07614..d231ac5b 100644 --- a/src/agents-init.test.ts +++ b/src/agents-init.test.ts @@ -29,10 +29,10 @@ import { } from "./application/git-hooks"; describe("runAgentsInit", () => { - it("copies templates into .agents/", () => { + it("copies templates into .agents/", async () => { const dir = mkdtempSync(join(tmpdir(), "codemap-agents-")); try { - const ok = runAgentsInit({ projectRoot: dir, force: true }); + const ok = await runAgentsInit({ projectRoot: dir, force: true }); expect(ok).toBe(true); const skill = readFileSync( join(dir, ".agents", "skills", "codemap", "SKILL.md"), @@ -47,7 +47,7 @@ describe("runAgentsInit", () => { } }); - it("runAgentsInit with --force refreshes only template file paths; user files under rules/ and skills/ remain", () => { + it("runAgentsInit with --force refreshes only template file paths; user files under rules/ and skills/ remain", async () => { const dir = mkdtempSync(join(tmpdir(), "codemap-agents-")); try { mkdirSync(join(dir, ".agents", "rules"), { recursive: true }); @@ -63,7 +63,7 @@ describe("runAgentsInit", () => { "user skill", "utf-8", ); - expect(runAgentsInit({ projectRoot: dir, force: true })).toBe(true); + expect(await runAgentsInit({ projectRoot: dir, force: true })).toBe(true); expect(readFileSync(join(dir, ".agents", "USER_NOTES.md"), "utf-8")).toBe( "keep me", ); @@ -84,11 +84,11 @@ describe("runAgentsInit", () => { } }); - it("runAgentsInit with mcp and empty targets skips MCP writes", () => { + it("runAgentsInit with mcp and empty targets skips MCP writes", async () => { const dir = mkdtempSync(join(tmpdir(), "codemap-agents-")); try { expect( - runAgentsInit({ + await runAgentsInit({ projectRoot: dir, force: true, mcp: true, @@ -102,11 +102,11 @@ describe("runAgentsInit", () => { } }); - it("runAgentsInit with amazon-q target writes both Amazon Q MCP files", () => { + it("runAgentsInit with amazon-q target writes both Amazon Q MCP files", async () => { const dir = mkdtempSync(join(tmpdir(), "codemap-agents-")); try { expect( - runAgentsInit({ + await runAgentsInit({ projectRoot: dir, force: true, targets: ["amazon-q"], @@ -121,17 +121,20 @@ describe("runAgentsInit", () => { } }); - it("runAgentsInit with mcp writes default project-local MCP configs", () => { + it("runAgentsInit with mcp writes default project-local MCP configs", async () => { const dir = mkdtempSync(join(tmpdir(), "codemap-agents-")); try { - expect(runAgentsInit({ projectRoot: dir, force: true, mcp: true })).toBe( - true, - ); + expect( + await runAgentsInit({ projectRoot: dir, force: true, mcp: true }), + ).toBe(true); expect(existsSync(join(dir, ".cursor", "mcp.json"))).toBe(true); const cursor = JSON.parse( readFileSync(join(dir, ".cursor", "mcp.json"), "utf-8"), - ) as { mcpServers: Record }; - expect(cursor.mcpServers.codemap?.command).toBe("codemap"); + ) as { mcpServers: Record }; + expect(cursor.mcpServers.codemap?.command).toBe("npx"); + expect(cursor.mcpServers.codemap?.args[0]).toBe( + "@stainless-code/codemap@latest", + ); expect(existsSync(join(dir, ".mcp.json"))).toBe(true); expect(existsSync(join(dir, ".vscode", "mcp.json"))).toBe(true); @@ -149,12 +152,12 @@ describe("runAgentsInit", () => { } }); - it("runAgentsInit --mcp on existing .agents/ without --force writes MCP only", () => { + it("runAgentsInit --mcp on existing .agents/ without --force writes MCP only", async () => { const dir = mkdtempSync(join(tmpdir(), "codemap-agents-")); try { mkdirSync(join(dir, ".agents"), { recursive: true }); writeFileSync(join(dir, ".agents", "USER.md"), "keep", "utf-8"); - expect(runAgentsInit({ projectRoot: dir, mcp: true })).toBe(true); + expect(await runAgentsInit({ projectRoot: dir, mcp: true })).toBe(true); expect(readFileSync(join(dir, ".agents", "USER.md"), "utf-8")).toBe( "keep", ); @@ -164,15 +167,15 @@ describe("runAgentsInit", () => { } }); - it("runAgentsInit --git-hooks on existing .agents/ without --force installs hooks only", () => { + it("runAgentsInit --git-hooks on existing .agents/ without --force installs hooks only", async () => { const dir = mkdtempSync(join(tmpdir(), "codemap-agents-")); try { mkdirSync(join(dir, ".agents"), { recursive: true }); mkdirSync(join(dir, ".git", "hooks"), { recursive: true }); writeFileSync(join(dir, ".agents", "USER.md"), "keep", "utf-8"); - expect(runAgentsInit({ projectRoot: dir, gitHooks: "install" })).toBe( - true, - ); + expect( + await runAgentsInit({ projectRoot: dir, gitHooks: "install" }), + ).toBe(true); expect(readFileSync(join(dir, ".agents", "USER.md"), "utf-8")).toBe( "keep", ); @@ -184,14 +187,14 @@ describe("runAgentsInit", () => { } }); - it("runAgentsInit --git-hooks --mcp on existing .agents/ installs hooks and MCP", () => { + it("runAgentsInit --git-hooks --mcp on existing .agents/ installs hooks and MCP", async () => { const dir = mkdtempSync(join(tmpdir(), "codemap-agents-")); try { mkdirSync(join(dir, ".agents"), { recursive: true }); mkdirSync(join(dir, ".git", "hooks"), { recursive: true }); writeFileSync(join(dir, ".agents", "USER.md"), "keep", "utf-8"); expect( - runAgentsInit({ + await runAgentsInit({ projectRoot: dir, gitHooks: "install", mcp: true, @@ -210,7 +213,7 @@ describe("runAgentsInit", () => { } }); - it("runAgentsInit --no-git-hooks --mcp uninstalls hooks and writes MCP", () => { + it("runAgentsInit --no-git-hooks --mcp uninstalls hooks and writes MCP", async () => { const dir = mkdtempSync(join(tmpdir(), "codemap-agents-")); try { mkdirSync(join(dir, ".git", "hooks"), { recursive: true }); @@ -220,7 +223,7 @@ describe("runAgentsInit", () => { "utf-8", ); expect( - runAgentsInit({ + await runAgentsInit({ projectRoot: dir, gitHooks: "uninstall", mcp: true, @@ -238,7 +241,7 @@ describe("runAgentsInit", () => { } }); - it("listRegularFilesRecursive matches bundled rules and skills files", () => { + it("listRegularFilesRecursive matches bundled rules and skills files", async () => { const root = resolveAgentsTemplateDir(); const rules = listRegularFilesRecursive(join(root, "rules")).sort(); const skills = listRegularFilesRecursive(join(root, "skills")).sort(); @@ -246,22 +249,24 @@ describe("runAgentsInit", () => { expect(skills).toContain("codemap/SKILL.md"); }); - it("returns false when .agents exists without force", () => { + it("returns false when .agents exists without force", async () => { const dir = mkdtempSync(join(tmpdir(), "codemap-agents-")); try { mkdirSync(join(dir, ".agents"), { recursive: true }); - expect(runAgentsInit({ projectRoot: dir, force: false })).toBe(false); + expect(await runAgentsInit({ projectRoot: dir, force: false })).toBe( + false, + ); } finally { rmSync(dir, { recursive: true, force: true }); } }); - it("resolveAgentsTemplateDir points at templates/agents", () => { + it("resolveAgentsTemplateDir points at templates/agents", async () => { const p = resolveAgentsTemplateDir().replace(/\\/g, "/"); expect(p.endsWith("/templates/agents")).toBe(true); }); - it("ensureGitignoreCodemapPattern writes /.gitignore (root untouched)", () => { + it("ensureGitignoreCodemapPattern writes /.gitignore (root untouched)", async () => { const dir = mkdtempSync(join(tmpdir(), "codemap-agents-")); try { const rootGi = join(dir, ".gitignore"); @@ -277,7 +282,7 @@ describe("runAgentsInit", () => { } }); - it("ensureGitignoreCodemapPattern is idempotent (no rewrite on steady state)", () => { + it("ensureGitignoreCodemapPattern is idempotent (no rewrite on steady state)", async () => { const dir = mkdtempSync(join(tmpdir(), "codemap-agents-")); try { ensureGitignoreCodemapPattern(dir); @@ -290,10 +295,10 @@ describe("runAgentsInit", () => { } }); - it("runAgentsInit calls the reconciler — produces /.gitignore", () => { + it("runAgentsInit calls the reconciler — produces /.gitignore", async () => { const dir = mkdtempSync(join(tmpdir(), "codemap-agents-")); try { - expect(runAgentsInit({ projectRoot: dir, force: true })).toBe(true); + expect(await runAgentsInit({ projectRoot: dir, force: true })).toBe(true); const stateGi = join(dir, ".codemap", ".gitignore"); expect(existsSync(stateGi)).toBe(true); } finally { @@ -301,11 +306,11 @@ describe("runAgentsInit", () => { } }); - it("runAgentsInit with Cursor target copies into .cursor/", () => { + it("runAgentsInit with Cursor target copies into .cursor/", async () => { const dir = mkdtempSync(join(tmpdir(), "codemap-agents-")); try { expect( - runAgentsInit({ + await runAgentsInit({ projectRoot: dir, force: true, targets: ["cursor"], @@ -326,11 +331,11 @@ describe("runAgentsInit", () => { } }); - it("runAgentsInit with Cursor symlink creates per-file symlinks, not directory symlinks", () => { + it("runAgentsInit with Cursor symlink creates per-file symlinks, not directory symlinks", async () => { const dir = mkdtempSync(join(tmpdir(), "codemap-agents-")); try { expect( - runAgentsInit({ + await runAgentsInit({ projectRoot: dir, force: true, targets: ["cursor"], @@ -367,11 +372,11 @@ describe("runAgentsInit", () => { } }); - it("runAgentsInit with claude-md writes CLAUDE.md", () => { + it("runAgentsInit with claude-md writes CLAUDE.md", async () => { const dir = mkdtempSync(join(tmpdir(), "codemap-agents-")); try { expect( - runAgentsInit({ + await runAgentsInit({ projectRoot: dir, force: true, targets: ["claude-md"], @@ -386,18 +391,18 @@ describe("runAgentsInit", () => { } }); - it("targetsNeedLinkMode is true only for symlink-style integrations", () => { + it("targetsNeedLinkMode is true only for symlink-style integrations", async () => { expect(targetsNeedLinkMode([])).toBe(false); expect(targetsNeedLinkMode(["claude-md", "copilot"])).toBe(false); expect(targetsNeedLinkMode(["cursor"])).toBe(true); expect(targetsNeedLinkMode(["windsurf", "agents-md"])).toBe(true); }); - it("runAgentsInit writes Copilot and pointer files", () => { + it("runAgentsInit writes Copilot and pointer files", async () => { const dir = mkdtempSync(join(tmpdir(), "codemap-agents-")); try { expect( - runAgentsInit({ + await runAgentsInit({ projectRoot: dir, force: true, targets: ["copilot", "agents-md", "gemini-md"], @@ -417,11 +422,11 @@ describe("runAgentsInit", () => { } }); - it("runAgentsInit with Windsurf copies .windsurf/rules", () => { + it("runAgentsInit with Windsurf copies .windsurf/rules", async () => { const dir = mkdtempSync(join(tmpdir(), "codemap-agents-")); try { expect( - runAgentsInit({ + await runAgentsInit({ projectRoot: dir, force: true, targets: ["windsurf"], @@ -436,19 +441,19 @@ describe("runAgentsInit", () => { } }); - it("runAgentsInit refuses Cursor wiring when .cursor/rules exists without force", () => { + it("runAgentsInit refuses Cursor wiring when .cursor/rules exists without force", async () => { const dir = mkdtempSync(join(tmpdir(), "codemap-agents-")); try { mkdirSync(join(dir, ".cursor", "rules"), { recursive: true }); writeFileSync(join(dir, ".cursor", "rules", "x.mdc"), "", "utf-8"); - expect(() => + await expect( runAgentsInit({ projectRoot: dir, force: false, targets: ["cursor"], linkMode: "copy", }), - ).toThrow(/\.cursor\/rules already exists/); + ).rejects.toThrow(/\.cursor\/rules already exists/); } finally { rmSync(dir, { recursive: true, force: true }); } @@ -470,7 +475,7 @@ function wrapPointerTest(inner: string): string { } describe("upsertCodemapPointerFile", () => { - it("appends managed section to existing non-Codemap file", () => { + it("appends managed section to existing non-Codemap file", async () => { const dir = mkdtempSync(join(tmpdir(), "codemap-pointer-")); const p = join(dir, "AGENTS.md"); try { @@ -488,7 +493,7 @@ describe("upsertCodemapPointerFile", () => { } }); - it("replaces managed section in place on second run (no duplicate blocks)", () => { + it("replaces managed section in place on second run (no duplicate blocks)", async () => { const dir = mkdtempSync(join(tmpdir(), "codemap-pointer-")); const p = join(dir, "NOTE.md"); try { @@ -508,7 +513,7 @@ describe("upsertCodemapPointerFile", () => { } }); - it("migrates legacy unmarked Codemap pointer file to managed section", () => { + it("migrates legacy unmarked Codemap pointer file to managed section", async () => { const dir = mkdtempSync(join(tmpdir(), "codemap-pointer-")); const p = join(dir, "CLAUDE.md"); try { @@ -528,7 +533,7 @@ describe("upsertCodemapPointerFile", () => { } }); - it("replaces ALL managed sections when file has two blocks", () => { + it("replaces ALL managed sections when file has two blocks", async () => { const dir = mkdtempSync(join(tmpdir(), "codemap-pointer-")); const p = join(dir, "NOTE.md"); try { @@ -553,7 +558,7 @@ describe("upsertCodemapPointerFile", () => { } }); - it("--force replaces entire file with managed section", () => { + it("--force replaces entire file with managed section", async () => { const dir = mkdtempSync(join(tmpdir(), "codemap-pointer-")); const p = join(dir, "AGENTS.md"); try { @@ -569,33 +574,33 @@ describe("upsertCodemapPointerFile", () => { }); describe("relPathToAbsSegments — defence-in-depth path safety", () => { - it("returns segments for a normal relative path", () => { + it("returns segments for a normal relative path", async () => { expect(relPathToAbsSegments("rules/codemap.md")).toEqual([ "rules", "codemap.md", ]); }); - it("filters empty segments (leading / trailing / double slashes)", () => { + it("filters empty segments (leading / trailing / double slashes)", async () => { expect(relPathToAbsSegments("/rules//codemap.md/")).toEqual([ "rules", "codemap.md", ]); }); - it("rejects `..` segment", () => { + it("rejects `..` segment", async () => { expect(() => relPathToAbsSegments("../etc/passwd")).toThrow( /refusing path with ".." segment/, ); }); - it("rejects `..` segment in the middle of the path", () => { + it("rejects `..` segment in the middle of the path", async () => { expect(() => relPathToAbsSegments("rules/../../etc/passwd")).toThrow( /refusing path with ".." segment/, ); }); - it("rejects `.` segment", () => { + it("rejects `.` segment", async () => { expect(() => relPathToAbsSegments("rules/./codemap.md")).toThrow( /refusing path with "." segment/, ); diff --git a/src/agents-init.ts b/src/agents-init.ts index df30764b..187dc232 100644 --- a/src/agents-init.ts +++ b/src/agents-init.ts @@ -485,9 +485,11 @@ function applyCursorIntegration( ); } -function maybeApplyAgentsInitMcp(options: AgentsInitOptions): void { +async function maybeApplyAgentsInitMcp( + options: AgentsInitOptions, +): Promise { if (options.mcp === true) { - applyAgentsInitMcp({ + await applyAgentsInitMcp({ projectRoot: options.projectRoot, force: !!options.force, targets: resolveAgentsInitMcpTargets(options.targets), @@ -500,11 +502,13 @@ function maybeApplyAgentsInitMcp(options: AgentsInitOptions): void { * **`--force`** deletes only template-backed files, then writes those files again with per-file copies — your other files under **`.agents/`**, **`rules/`**, or **`skills/`** stay. * @returns `false` when `.agents/` exists and `--force` was not used (unless only side effects like `--git-hooks` / `--mcp`). */ -export function runAgentsInit(options: AgentsInitOptions): boolean { +export async function runAgentsInit( + options: AgentsInitOptions, +): Promise { if (options.gitHooks === "uninstall") { uninstallGitHooks(options.projectRoot); console.log(" Removed codemap blocks from git hooks"); - maybeApplyAgentsInitMcp(options); + await maybeApplyAgentsInitMcp(options); return true; } @@ -536,11 +540,11 @@ export function runAgentsInit(options: AgentsInitOptions): boolean { console.log( " Installed git hooks (post-commit, post-merge, post-checkout) for background codemap sync", ); - maybeApplyAgentsInitMcp(options); + await maybeApplyAgentsInitMcp(options); return true; } if (options.mcp === true) { - maybeApplyAgentsInitMcp(options); + await maybeApplyAgentsInitMcp(options); return true; } console.error( @@ -583,7 +587,7 @@ export function runAgentsInit(options: AgentsInitOptions): boolean { ); } - maybeApplyAgentsInitMcp(options); + await maybeApplyAgentsInitMcp(options); return true; } diff --git a/src/cli.test.ts b/src/cli.test.ts index f00db742..ee41d27b 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -167,7 +167,7 @@ describe("CLI unknown / invalid args", () => { const parsed = JSON.parse( readFileSync(join(dir, ".cursor", "mcp.json"), "utf-8"), ) as { mcpServers: Record }; - expect(parsed.mcpServers.codemap?.command).toBe("codemap"); + expect(parsed.mcpServers.codemap?.command).toBe("npx"); } finally { rmSync(dir, { recursive: true, force: true }); } diff --git a/src/cli/cmd-agents.ts b/src/cli/cmd-agents.ts index 4aeb4d36..1b2c47f8 100644 --- a/src/cli/cmd-agents.ts +++ b/src/cli/cmd-agents.ts @@ -32,7 +32,7 @@ export async function runAgentsInitCmd(opts: { await import("../agents-init-interactive.js"); return await runAgentsInitInteractive(opts); } - return runAgentsInit({ + return await runAgentsInit({ projectRoot: opts.projectRoot, force: opts.force, gitHooks: opts.gitHooks, diff --git a/src/cli/main.ts b/src/cli/main.ts index e7047957..dfccae66 100644 --- a/src/cli/main.ts +++ b/src/cli/main.ts @@ -64,7 +64,7 @@ export async function main(): Promise { Copies bundled agent templates into .agents/ under the project root. --force Refresh only files that ship in templates/agents (merge into rules/ & skills/) --interactive Pick IDEs (Cursor, Copilot, Windsurf, …) and symlink vs copy - --mcp Write MCP config for supported IDEs (see docs/agents.md) + --mcp Write PM-aware MCP config for supported IDEs (see docs/agents.md) --git-hooks Install background incremental index hooks (post-commit, post-merge, post-checkout) --no-git-hooks Remove codemap blocks from git hooks `); diff --git a/src/codemap-invocation.test.ts b/src/codemap-invocation.test.ts new file mode 100644 index 00000000..f3e3f65e --- /dev/null +++ b/src/codemap-invocation.test.ts @@ -0,0 +1,305 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; + +import { + buildCodemapMcpSpawn, + codemapInProjectDependencies, + formatCodemapExec, + normalizeSpawnCommand, + resolveCodemapCliInvocation, + SAFE_CODEMAP_VERSION_RE, +} from "./codemap-invocation"; + +let workRoot: string; + +beforeAll(() => { + workRoot = join(tmpdir(), `codemap-invocation-test-${process.pid}`); + rmSync(workRoot, { recursive: true, force: true }); + mkdirSync(workRoot, { recursive: true }); +}); + +afterAll(() => { + rmSync(workRoot, { recursive: true, force: true }); +}); + +function makeFixture(name: string, files: Record): string { + const dir = join(workRoot, name); + for (const [path, contents] of Object.entries(files)) { + const filePath = join(dir, path); + mkdirSync(dirname(filePath), { recursive: true }); + writeFileSync(filePath, contents); + } + return dir; +} + +describe("normalizeSpawnCommand", () => { + it("rewrites bun x to bunx", () => { + expect(normalizeSpawnCommand("bun", ["x", "codemap"])).toEqual({ + command: "bunx", + args: ["codemap"], + }); + }); + + it("passes through other commands", () => { + expect(normalizeSpawnCommand("pnpm", ["exec", "codemap"])).toEqual({ + command: "pnpm", + args: ["exec", "codemap"], + }); + }); +}); + +describe("codemapInProjectDependencies", () => { + it("finds scoped devDependency in project root", () => { + const dir = makeFixture("scoped-root", { + "package.json": JSON.stringify({ + devDependencies: { "@stainless-code/codemap": "^1.0.0" }, + }), + }); + expect(codemapInProjectDependencies(dir)).toBe(true); + }); + + it("walks up to parent package.json", () => { + const dir = makeFixture("monorepo-child", { + "package.json": JSON.stringify({ + devDependencies: { "@stainless-code/codemap": "^1.0.0" }, + }), + "apps/web/package.json": JSON.stringify({ name: "web" }), + }); + expect(codemapInProjectDependencies(join(dir, "apps", "web"))).toBe(true); + }); + + it("returns false when no manifest lists codemap", () => { + const dir = makeFixture("no-dep", { + "package.json": JSON.stringify({ name: "empty" }), + }); + expect(codemapInProjectDependencies(dir)).toBe(false); + }); +}); + +describe("resolveCodemapCliInvocation", () => { + it("uses execute-local when codemap is a project dependency", async () => { + const dir = makeFixture("pnpm-local", { + "package.json": JSON.stringify({ + devDependencies: { "@stainless-code/codemap": "^1.0.0" }, + }), + "pnpm-lock.yaml": "lockfileVersion: 9\n", + }); + const resolved = await resolveCodemapCliInvocation({ + projectRoot: dir, + packageManager: "pnpm", + }); + expect(resolved.installMethod).toBe("project-installed"); + expect(resolved.command).toBe("pnpm"); + expect(resolved.args).toEqual(["exec", "codemap"]); + }); + + it("uses bunx for bun execute-local", async () => { + const dir = makeFixture("bun-local", { + "package.json": JSON.stringify({ + devDependencies: { "@stainless-code/codemap": "^1.0.0" }, + }), + "bun.lock": "", + }); + const resolved = await resolveCodemapCliInvocation({ + projectRoot: dir, + packageManager: "bun", + }); + expect(resolved.installMethod).toBe("project-installed"); + expect(resolved.command).toBe("bunx"); + expect(resolved.args).toEqual(["codemap"]); + }); + + it("uses yarn exec for yarn execute-local", async () => { + const dir = makeFixture("yarn-local", { + "package.json": JSON.stringify({ + devDependencies: { "@stainless-code/codemap": "^1.0.0" }, + }), + "yarn.lock": "", + }); + const resolved = await resolveCodemapCliInvocation({ + projectRoot: dir, + packageManager: "yarn", + }); + expect(resolved.installMethod).toBe("project-installed"); + expect(resolved.command).toBe("yarn"); + expect(resolved.args).toEqual(["exec", "codemap"]); + }); + + it("uses dlx-latest when codemap is not installed locally", async () => { + const dir = makeFixture("dlx-fallback", { + "package.json": JSON.stringify({ name: "no-codemap" }), + "package-lock.json": "{}", + }); + const resolved = await resolveCodemapCliInvocation({ + projectRoot: dir, + packageManager: "npm", + }); + expect(resolved.installMethod).toBe("dlx-latest"); + expect(resolved.command).toBe("npx"); + expect(resolved.args).toEqual(["@stainless-code/codemap@latest"]); + }); + + it("uses yarn berry exec for execute-local", async () => { + const dir = makeFixture("yarn-berry-local", { + "package.json": JSON.stringify({ + packageManager: "yarn@berry@4.0.0", + devDependencies: { "@stainless-code/codemap": "^1.0.0" }, + }), + "yarn.lock": "", + }); + const resolved = await resolveCodemapCliInvocation({ + projectRoot: dir, + packageManager: "yarn@berry", + }); + expect(resolved.installMethod).toBe("project-installed"); + expect(resolved.command).toBe("yarn"); + expect(resolved.args[0]).toBe("exec"); + expect(resolved.args).toContain("codemap"); + }); + + it("rejects VERSION with shell metacharacters", async () => { + const dir = makeFixture("version-bad-char", { + "package.json": JSON.stringify({ name: "x" }), + }); + await expect( + resolveCodemapCliInvocation({ + projectRoot: dir, + packageManager: "npm", + version: "1.0.0;", + }), + ).rejects.toThrow(/invalid characters/); + }); + + it("rejects VERSION with embedded newline", async () => { + const dir = makeFixture("version-newline", { + "package.json": JSON.stringify({ name: "x" }), + }); + await expect( + resolveCodemapCliInvocation({ + projectRoot: dir, + packageManager: "npm", + version: "1.0.0\nlatest", + }), + ).rejects.toThrow(/line breaks/); + }); + + it("uses dlx-pinned when version is set", async () => { + const dir = makeFixture("dlx-pinned", { + "package.json": JSON.stringify({ name: "no-codemap" }), + "package-lock.json": "{}", + }); + const resolved = await resolveCodemapCliInvocation({ + projectRoot: dir, + packageManager: "npm", + version: "1.2.3", + }); + expect(resolved.installMethod).toBe("dlx-pinned"); + expect(resolved.args).toEqual(["@stainless-code/codemap@1.2.3"]); + }); + + it("uses pnpm dlx when codemap is not installed locally", async () => { + const dir = makeFixture("pnpm-dlx", { + "package.json": JSON.stringify({ name: "no-codemap" }), + "pnpm-lock.yaml": "lockfileVersion: 9\n", + }); + const resolved = await resolveCodemapCliInvocation({ + projectRoot: dir, + packageManager: "pnpm", + }); + expect(resolved.installMethod).toBe("dlx-latest"); + expect(resolved.command).toBe("pnpm"); + expect(resolved.args[0]).toBe("dlx"); + expect(resolved.args).toContain("@stainless-code/codemap@latest"); + }); + + it("uses npx for yarn classic dlx (detector quirk)", async () => { + const dir = makeFixture("yarn-dlx", { + "package.json": JSON.stringify({ name: "no-codemap" }), + "yarn.lock": "", + }); + const resolved = await resolveCodemapCliInvocation({ + projectRoot: dir, + packageManager: "yarn", + }); + expect(resolved.installMethod).toBe("dlx-latest"); + expect(resolved.command).toBe("npx"); + expect(resolved.args).toEqual(["@stainless-code/codemap@latest"]); + }); + + it("autodetects package manager from lockfile", async () => { + const dir = makeFixture("autodetect-pnpm", { + "package.json": JSON.stringify({ name: "no-codemap" }), + "pnpm-lock.yaml": "lockfileVersion: 9\n", + }); + const resolved = await resolveCodemapCliInvocation({ projectRoot: dir }); + expect(resolved.agent).toBe("pnpm"); + }); + + it("autodetects yarn@berry from packageManager field", async () => { + const dir = makeFixture("yarn-berry-autodetect", { + "package.json": JSON.stringify({ + packageManager: "yarn@berry@4.0.0", + name: "no-codemap", + }), + "yarn.lock": "", + }); + const resolved = await resolveCodemapCliInvocation({ projectRoot: dir }); + expect(resolved.agent).toBe("yarn@berry"); + expect(resolved.installMethod).toBe("dlx-latest"); + expect(resolved.args[0]).toBe("dlx"); + }); + + it("rejects unknown package-manager values", async () => { + const dir = makeFixture("bad-pm", { + "package.json": JSON.stringify({ name: "x" }), + }); + await expect( + resolveCodemapCliInvocation({ + projectRoot: dir, + packageManager: "rye", + }), + ).rejects.toThrow(/not recognised/); + }); +}); + +describe("formatCodemapExec", () => { + it("joins command and args", () => { + expect( + formatCodemapExec({ command: "pnpm", args: ["exec", "codemap"] }), + ).toBe("pnpm exec codemap"); + }); +}); + +describe("SAFE_CODEMAP_VERSION_RE", () => { + it("accepts semver pins and dist-tags", () => { + expect(SAFE_CODEMAP_VERSION_RE.test("1.2.3")).toBe(true); + expect(SAFE_CODEMAP_VERSION_RE.test("latest")).toBe(true); + expect(SAFE_CODEMAP_VERSION_RE.test("1.0.0-beta.1")).toBe(true); + }); + + it("rejects shell metacharacters", () => { + expect(SAFE_CODEMAP_VERSION_RE.test("1.0.0;")).toBe(false); + }); +}); + +describe("buildCodemapMcpSpawn", () => { + it("appends MCP tail args after invocation prefix", () => { + expect( + buildCodemapMcpSpawn({ command: "bunx", args: ["codemap"] }, true), + ).toEqual({ + command: "bunx", + args: ["codemap", "mcp", "--watch", "--root", "${workspaceFolder}"], + }); + }); + + it("omits workspace root when includeWorkspaceRoot is false", () => { + expect( + buildCodemapMcpSpawn({ command: "npx", args: ["codemap"] }, false), + ).toEqual({ + command: "npx", + args: ["codemap", "mcp", "--watch"], + }); + }); +}); diff --git a/src/codemap-invocation.ts b/src/codemap-invocation.ts new file mode 100644 index 00000000..39118ca6 --- /dev/null +++ b/src/codemap-invocation.ts @@ -0,0 +1,176 @@ +import { existsSync, readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; + +import { resolveCommand } from "package-manager-detector/commands"; +import { detect } from "package-manager-detector/detect"; + +/** Keep `scripts/codemap-invocation.mjs` in sync. */ + +export const CODEMAP_PUBLISHED_NAME = "@stainless-code/codemap"; + +export const CODEMAP_DEP_KEYS = ["@stainless-code/codemap", "codemap"] as const; + +export type CodemapInstallMethod = + | "project-installed" + | "dlx-pinned" + | "dlx-latest"; + +export interface ResolvedCodemapInvocation { + command: string; + args: string[]; + installMethod: CodemapInstallMethod; + agent: string; +} + +const VALID_AGENTS = new Set(["npm", "pnpm", "yarn", "yarn@berry", "bun"]); + +export const SAFE_CODEMAP_VERSION_RE = /^[A-Za-z0-9][A-Za-z0-9._+^-]*$/; + +/** MCP JSON executables use `bunx`, not `bun` + `x`. */ +export function normalizeSpawnCommand( + command: string, + args: string[], +): { command: string; args: string[] } { + if (command === "bun" && args[0] === "x") { + return { command: "bunx", args: args.slice(1) }; + } + return { command, args }; +} + +function manifestHasCodemap(manifestPath: string): boolean { + if (!existsSync(manifestPath)) return false; + try { + const manifest = JSON.parse(readFileSync(manifestPath, "utf8")) as { + dependencies?: Record; + devDependencies?: Record; + optionalDependencies?: Record; + }; + const buckets = [ + manifest.dependencies, + manifest.devDependencies, + manifest.optionalDependencies, + ]; + return buckets.some( + (b) => + b !== null && + b !== undefined && + CODEMAP_DEP_KEYS.some((k) => b[k] !== undefined), + ); + } catch { + return false; + } +} + +export function codemapInProjectDependencies(workingDir: string): boolean { + let dir = workingDir; + for (;;) { + if (manifestHasCodemap(join(dir, "package.json"))) return true; + const parent = dirname(dir); + if (parent === dir) return false; + dir = parent; + } +} + +function validateCodemapVersionInput(version: string): void { + if (version === "") return; + if (version.includes("\n") || version.includes("\r")) { + throw new Error("VERSION must not contain line breaks."); + } + if (!SAFE_CODEMAP_VERSION_RE.test(version)) { + throw new Error( + `VERSION "${version}" contains invalid characters. Use a semver pin or dist-tag (e.g. 1.2.3, latest).`, + ); + } +} + +/** `package-manager-detector` reports Berry as `{ agent: "yarn", version: "berry" }`. */ +function normalizeDetectedAgent( + detected: { agent?: string; version?: string } | null | undefined, +): string { + const agent = detected?.agent ?? "npm"; + if (agent === "yarn" && detected?.version === "berry") { + return "yarn@berry"; + } + return agent; +} + +export async function resolveCodemapCliInvocation(opts: { + projectRoot: string; + packageManager?: string | undefined; + version?: string | undefined; +}): Promise { + const versionInput = (opts.version ?? "").trim(); + validateCodemapVersionInput(versionInput); + + let agent = (opts.packageManager ?? "").trim(); + if (agent !== "" && !VALID_AGENTS.has(agent)) { + throw new Error( + `package-manager "${agent}" not recognised. Expected one of: ${[...VALID_AGENTS].join(", ")}.`, + ); + } + if (agent === "") { + const detected = await detect({ cwd: opts.projectRoot }); + agent = normalizeDetectedAgent(detected); + } + + let intent: "execute" | "execute-local"; + let commandArgs: string[]; + let installMethod: CodemapInstallMethod; + if (versionInput !== "") { + intent = "execute"; + commandArgs = [`${CODEMAP_PUBLISHED_NAME}@${versionInput}`]; + installMethod = "dlx-pinned"; + } else if (codemapInProjectDependencies(opts.projectRoot)) { + intent = "execute-local"; + commandArgs = ["codemap"]; + installMethod = "project-installed"; + } else { + intent = "execute"; + commandArgs = [`${CODEMAP_PUBLISHED_NAME}@latest`]; + installMethod = "dlx-latest"; + } + + const resolved = resolveCommand( + agent as "npm" | "pnpm" | "yarn" | "yarn@berry" | "bun", + intent, + commandArgs, + ); + if (resolved === null) { + throw new Error( + `package-manager-detector returned null for agent="${agent}", intent="${intent}". ` + + `Check that the agent supports this intent (npm/pnpm/yarn/yarn@berry/bun execute-local or dlx).`, + ); + } + const normalized = normalizeSpawnCommand(resolved.command, resolved.args); + return { + ...normalized, + installMethod, + agent, + }; +} + +function codemapMcpTailArgs( + includeWorkspaceRoot: boolean | undefined, +): string[] { + const args = ["mcp", "--watch"]; + if (includeWorkspaceRoot === true) { + args.push("--root", "${workspaceFolder}"); + } + return args; +} + +export function buildCodemapMcpSpawn( + invocation: Pick, + includeWorkspaceRoot: boolean | undefined, +): { command: string; args: string[] } { + return { + command: invocation.command, + args: [...invocation.args, ...codemapMcpTailArgs(includeWorkspaceRoot)], + }; +} + +export function formatCodemapExec( + invocation: Pick, +): string { + return [invocation.command, ...invocation.args].join(" "); +} diff --git a/templates/agent-content/skill/10-recipes-context.md b/templates/agent-content/skill/10-recipes-context.md index 7f6745f9..c7266c79 100644 --- a/templates/agent-content/skill/10-recipes-context.md +++ b/templates/agent-content/skill/10-recipes-context.md @@ -72,7 +72,7 @@ codemap query --json --recipe affected-tests --params changed_files=src/foo.ts - **`codemap://files/{path}`** — per-file roll-up `{path, language, line_count, symbols, imports, exports, coverage}`; URI-encode path segments (MCP template uses `{+path}`). Live. - **`codemap://symbols/{name}`** — exact-name lookup only → `{matches, disambiguation?}`; optional `?in=` filter. Use **`show`** / **`snippet`** tools (or CLI `--query`) for field-qualified discovery. Live. -**Launching:** point your agent host at `codemap mcp` as the stdio command. Most hosts (Claude Code, Cursor, Codex) accept `{command: "codemap", args: ["mcp"], cwd: "/path/to/project"}`. The server inherits `cwd` as the project root unless `--root` overrides it. +**Launching:** prefer **`codemap agents init --mcp`** — writes PM-aware spawn config (`npx codemap`, `pnpm exec codemap`, `yarn exec codemap`, `bunx codemap`, or dlx `@stainless-code/codemap@latest`) with `mcp --watch`. Manual wiring: stdio command + args `mcp` (add `--watch` to keep the index warm); spawn `cwd` is the project root unless `--root` overrides. Do not assume global `codemap` on PATH. **Determinism:** Bundled recipes use stable secondary **`ORDER BY`** tie-breakers (and ordered inner **`LIMIT`** samples where applicable). Prefer **`--recipe`** over pasting SQL when you need the maintained ordering. **Canonical SQL** is whatever **`codemap query --print-sql `** or **`codemap query --recipes-json`** returns (single source in the CLI).