diff --git a/.agents/rules/codemap.md b/.agents/rules/codemap.md index b4873580..077dc764 100644 --- a/.agents/rules/codemap.md +++ b/.agents/rules/codemap.md @@ -2,6 +2,8 @@ alwaysApply: true --- + + # Codemap This project is indexed by **Codemap** — a local SQLite index of structure (symbols, imports, exports, components, dependencies, markers, scopes, references, bindings, call graphs, CSS variables, coverage). diff --git a/.agents/skills/codemap/SKILL.md b/.agents/skills/codemap/SKILL.md index 01f52961..73b51b26 100644 --- a/.agents/skills/codemap/SKILL.md +++ b/.agents/skills/codemap/SKILL.md @@ -3,6 +3,8 @@ name: codemap description: Query codebase structure via SQLite instead of scanning files. Use when exploring code, finding where symbols are defined, tracing who imports what, listing components / hooks / CSS variables / deprecated symbols, walking dependency or call graphs, or auditing structural changes on a PR. --- + + # Codemap skill Full content is served live by the installed `codemap` CLI, so version bumps carry today's reference automatically — no `agents init` re-run needed. diff --git a/.changeset/agents-init-safety.md b/.changeset/agents-init-safety.md new file mode 100644 index 00000000..5e0c85b8 --- /dev/null +++ b/.changeset/agents-init-safety.md @@ -0,0 +1,5 @@ +--- +"@stainless-code/codemap": patch +--- + +`codemap agents init` is safer on re-run: IDE mirrors sync bundled template paths only; `--force` overwrites IDE mirrors only when they carry `codemap-init:managed` or match the legacy mirror heuristic (pre-marker bundled copies); invalid MCP JSON shapes are rejected instead of reset (even with `--force`). diff --git a/.gitignore b/.gitignore index fcf9f083..2b1ccf99 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,4 @@ fixtures/golden/scenarios.external.json fixtures/qa/*.local.md fixtures/benchmark/*.local.json .agent-eval/ +.tmp/ diff --git a/docs/agents.md b/docs/agents.md index d298b384..e8c3c9e1 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -32,8 +32,8 @@ codemap agents init --git-hooks # opt-in background index on git events codemap agents init --no-git-hooks # remove codemap hook blocks ``` -- **`--force`** — if **`.agents/`** already exists, delete only the **same file paths** that ship in **`templates/agents`** (under **`rules/`** and **`skills/`**), then copy those files from the template. Any **other** files next to them (your custom rules, extra skill dirs, notes at **`.agents/`** root, etc.) are **not** removed. Use **`--interactive`**, not a bare **`interactive`** argument (unknown tokens are rejected). -- **`--interactive`** — multiselect which tools to wire (see below); choose **symlink** vs **copy** for integrations that mirror **`.agents/rules`** (and Cursor also **`.agents/skills`**). Uses [**@clack/prompts**](https://github.com/bombshell-dev/clack); **non-TTY** runs exit with an error. +- **`--force`** — if **`.agents/`** already exists, delete only the **same file paths** that ship in **`templates/agents`** (under **`rules/`** and **`skills/`**), then copy those files from the template. Any **other** files next to them (your custom rules, extra skill dirs, notes at **`.agents/`** root, etc.) are **not** removed. IDE mirrors (`.cursor/rules`, …) sync **only bundled template paths** (today `rules/codemap.md` and `skills/codemap/SKILL.md`) — not your whole **`.agents/`** tree. **`--force`** overwrites an existing IDE mirror **only** when it has **``** or matches the **legacy mirror heuristic** (see [§ IDE mirror provenance](#ide-mirror-provenance-codemap-initmanaged)). Pointer files (`CLAUDE.md`, …): **`--force`** refreshes the `codemap-pointer` section only; your prose outside the markers is kept. Use **`--interactive`**, not a bare **`interactive`** argument (unknown tokens are rejected). +- **`--interactive`** — multiselect which tools to wire (see below); choose **symlink** vs **copy** for integrations that mirror **bundled** **`.agents/rules`** paths (and Cursor also bundled **`.agents/skills`**). Uses [**@clack/prompts**](https://github.com/bombshell-dev/clack); **non-TTY** runs exit with an error. ## Git and `.gitignore` @@ -45,17 +45,17 @@ The user's root **`.gitignore`** is no longer touched by `codemap agents init`. All integrations reuse the **same** bundled content under **`.agents/`**. Symlink-style rows use one **link mode** for the whole run (**symlink** or **copy**) when any of them is selected. -| Integration | What gets created | Notes | -| ------------------------------------- | ---------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | -| **Cursor** | **`.cursor/rules`**, **`.cursor/skills`** → **`.agents/`** | Per-file symlink or copy (each rule/skill file, not a directory link). | -| **Windsurf** | **`.windsurf/rules`** → **`.agents/rules`** | Rules only. | -| **Continue** | **`.continue/rules`** → **`.agents/rules`** | [Continue rules](https://docs.continue.dev/customize/rules). | -| **Cline** | **`.clinerules`** → **`.agents/rules`** | Per-file symlink or copy. | -| **Amazon Q** | **`.amazonq/rules`** → **`.agents/rules`** | [AWS rules](https://aws.amazon.com/blogs/devops/mastering-amazon-q-developer-with-rules/). | -| **GitHub Copilot** | **`.github/copilot-instructions.md`** | Pointer + link to [GitHub Docs](https://docs.github.com/copilot/customizing-copilot/adding-custom-instructions-for-github-copilot). | -| **Claude Code** | **`CLAUDE.md`** | Root onboarding pointer. | -| **Zed / JetBrains / Aider (generic)** | **`AGENTS.md`** | Many tools read root **`AGENTS.md`**; JetBrains/Aider have no single mandated path — this file is the shared hook. | -| **Gemini** | **`GEMINI.md`** | For integrations that load **`GEMINI.md`**. | +| Integration | What gets created | Notes | +| ------------------------------------- | ------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------- | +| **Cursor** | **`.cursor/rules`**, **`.cursor/skills`** → bundled **`.agents/`** paths | Per-file symlink or copy of **bundled** rule/skill paths only (not your whole **`.agents/`** tree). | +| **Windsurf** | **`.windsurf/rules`** → bundled **`.agents/rules`** paths | Bundled rules only. | +| **Continue** | **`.continue/rules`** → bundled **`.agents/rules`** paths | [Continue rules](https://docs.continue.dev/customize/rules). | +| **Cline** | **`.clinerules`** → bundled **`.agents/rules`** paths | Per-file symlink or copy (bundled paths only). | +| **Amazon Q** | **`.amazonq/rules`** → bundled **`.agents/rules`** paths | [AWS rules](https://aws.amazon.com/blogs/devops/mastering-amazon-q-developer-with-rules/). | +| **GitHub Copilot** | **`.github/copilot-instructions.md`** | Pointer + link to [GitHub Docs](https://docs.github.com/copilot/customizing-copilot/adding-custom-instructions-for-github-copilot). | +| **Claude Code** | **`CLAUDE.md`** | Root onboarding pointer. | +| **Zed / JetBrains / Aider (generic)** | **`AGENTS.md`** | Many tools read root **`AGENTS.md`**; JetBrains/Aider have no single mandated path — this file is the shared hook. | +| **Gemini** | **`GEMINI.md`** | For integrations that load **`GEMINI.md`**. | ## Git hooks (opt-in freshness) @@ -73,10 +73,23 @@ Root / Copilot **pointer** files (**`CLAUDE.md`**, **`AGENTS.md`**, **`GEMINI.md | File exists, section present | **Replace only** that section — idempotent re-runs, no duplicate blocks; template updates fix **stale** text. | | File exists, no section, but content looks like an **old** Codemap-only file | **Replace whole file** with the managed section (one-time migration). | | File exists with other content (e.g. your team intro) | **Append** the managed section **once**. | -| **`--force`** | Replace the **entire file** with the latest managed section. | +| **`--force`** | Refresh the **managed pointer section** only; user content outside markers is preserved. | Append alone would duplicate on every run — markers + replace are what prevent duplicates and staleness. +## IDE mirror provenance (`codemap-init:managed`) + +Bundled templates ship **``**. **Copy mode** writes that marker into IDE mirror files; **symlink mode** inherits it from the **`.agents/`** target (init checks marker content through the link). + +| Surface | `--force` overwrite policy | +| ------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| **`.agents/`** bundled paths (`rules/codemap.md`, `skills/codemap/SKILL.md`, …) | Always refreshed — path whitelist only; marker not required. | +| **IDE mirrors** (`.cursor/`, `.windsurf/`, …) | Only when the dest file has **`codemap-init:managed`**, or content matches a **legacy Codemap mirror** (pre-marker copy from bundled templates: `codemap query` plus `codemap-pointer-version`, `codemap://rule`, or `stainless-code/codemap`). User-owned files at those paths are never overwritten. | + +**Legacy heuristic caveat:** Long user-authored prose at a bundled mirror path that quotes official Codemap docs could match once — delete that file manually if **`--force`** refreshes something you did not intend. + +**Upgrading from pre-marker init:** Re-run **`codemap agents init --force`** with your IDE targets selected (or **`--interactive`**). Copy-mode mirrors from older inits are migrated once via the legacy heuristic; symlink mode needs no mirror migration (init reads markers through the link into **`.agents/`**). If **`--force`** still refuses a mirror path, delete that single file manually and re-run init. + ## Live fetch surface (CLI + MCP + HTTP) Once `agents init` has written the pointer templates, the consumer's disk holds ~16-line SKILL + ~23-line rule. The actual content is served live: @@ -140,7 +153,7 @@ With **`--mcp`** and no `--target` filter, all **project-local** rows above are 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` (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. +**Side-effect-only re-runs:** When `.agents/` already exists, `codemap agents init --mcp`, **`--interactive`** target wiring (or any explicit integration targets), or `--git-hooks` still apply 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 and invalid `mcpServers` / `servers` **shape** are **rejected** (fix the file manually — init never wipes or resets those maps, even with `--force`). Foreign MCP servers in a valid map are always preserved on merge. ## Section assembler and `*.gen.md` diff --git a/scripts/agents-init-dummy-corpus.test.mjs b/scripts/agents-init-dummy-corpus.test.mjs new file mode 100644 index 00000000..5ae4197a --- /dev/null +++ b/scripts/agents-init-dummy-corpus.test.mjs @@ -0,0 +1,474 @@ +/** + * End-to-end agents init on a copy of fixtures/minimal (dummy corpus). + * Exercises template copy, IDE mirrors, pointers, MCP safety, and side-effect re-runs. + */ + +import { describe, expect, it } from "bun:test"; +import { spawnSync } from "node:child_process"; +import { + cpSync, + existsSync, + lstatSync, + mkdirSync, + mkdtempSync, + readFileSync, + readlinkSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, relative } from "node:path"; + +import { + applyAgentsInitMcp, + CODEMAP_MCP_SERVER_KEY, +} from "../src/agents-init-mcp.ts"; +import { + CODMAP_INIT_MANAGED, + CODMAP_POINTER_BEGIN, + runAgentsInit, +} from "../src/agents-init.ts"; +import { isCodemapHookInstalled } from "../src/application/git-hooks.ts"; + +const REPO_ROOT = join(import.meta.dirname, ".."); +const FIXTURE = join(REPO_ROOT, "fixtures/minimal"); +const CLI = join(REPO_ROOT, "src/index.ts"); +const E2E_TMP = join(tmpdir(), "codemap-agents-init-e2e"); + +function copyDummyCorpus() { + mkdirSync(E2E_TMP, { recursive: true }); + const dir = mkdtempSync(join(E2E_TMP, "corpus-")); + cpSync(FIXTURE, dir, { recursive: true }); + for (const name of ["index.db", "index.db-wal", "index.db-shm"]) { + const db = join(dir, ".codemap", name); + if (existsSync(db)) { + rmSync(db, { force: true }); + } + } + return dir; +} + +function runCli(root, args) { + const result = spawnSync("bun", [CLI, "--root", root, ...args], { + encoding: "utf8", + cwd: REPO_ROOT, + }); + return { + exitCode: result.status ?? 1, + out: `${result.stdout ?? ""}${result.stderr ?? ""}`, + }; +} + +function isSymlinkTo(destFile, srcFile) { + try { + if (!lstatSync(destFile).isSymbolicLink()) { + return false; + } + return readlinkSync(destFile) === relative(dirname(destFile), srcFile); + } catch { + return false; + } +} + +describe("agents init on fixtures/minimal dummy corpus", () => { + it("indexes the corpus and runs a golden recipe", () => { + const dir = copyDummyCorpus(); + try { + const index = runCli(dir, ["--full"]); + expect(index.exitCode).toBe(0); + const query = runCli(dir, [ + "query", + "--recipe", + "index-summary", + "--json", + ]); + expect(query.exitCode).toBe(0); + expect(query.out).toMatch(/files|symbols/i); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("fresh init wires bundled templates, pointers, MCP, and git hooks (copy mode)", async () => { + const dir = copyDummyCorpus(); + try { + mkdirSync(join(dir, ".git", "hooks"), { recursive: true }); + expect( + await runAgentsInit({ + projectRoot: dir, + force: true, + targets: [ + "cursor", + "windsurf", + "claude-md", + "copilot", + "agents-md", + "gemini-md", + ], + linkMode: "copy", + gitHooks: "install", + }), + ).toBe(true); + await applyAgentsInitMcp({ projectRoot: dir, targets: ["cursor"] }); + + expect( + readFileSync(join(dir, ".agents", "rules", "codemap.md"), "utf-8"), + ).toContain(CODMAP_INIT_MANAGED); + expect( + readFileSync(join(dir, ".cursor", "rules", "codemap.mdc"), "utf-8"), + ).toContain(CODMAP_INIT_MANAGED); + expect( + readFileSync(join(dir, ".windsurf", "rules", "codemap.md"), "utf-8"), + ).toContain(CODMAP_INIT_MANAGED); + expect(readFileSync(join(dir, "CLAUDE.md"), "utf-8")).toContain( + CODMAP_POINTER_BEGIN, + ); + expect( + readFileSync(join(dir, ".github", "copilot-instructions.md"), "utf-8"), + ).toContain(CODMAP_POINTER_BEGIN); + expect(readFileSync(join(dir, "AGENTS.md"), "utf-8")).toContain( + CODMAP_POINTER_BEGIN, + ); + expect(existsSync(join(dir, ".cursor", "mcp.json"))).toBe(true); + expect( + isCodemapHookInstalled(join(dir, ".git", "hooks", "post-commit")), + ).toBe(true); + + const mcp = JSON.parse( + readFileSync(join(dir, ".cursor", "mcp.json"), "utf-8"), + ); + expect(mcp.mcpServers?.[CODEMAP_MCP_SERVER_KEY]?.command).toBeDefined(); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("--force refreshes bundled paths only; custom .agents files stay; custom rules are not mirrored", async () => { + const dir = copyDummyCorpus(); + try { + await runAgentsInit({ + projectRoot: dir, + force: true, + targets: ["cursor"], + linkMode: "copy", + }); + writeFileSync( + join(dir, ".agents", "rules", "team-custom.md"), + "# Team\n", + "utf-8", + ); + writeFileSync(join(dir, ".agents", "TEAM-NOTES.md"), "notes", "utf-8"); + writeFileSync( + join(dir, ".agents", "rules", "codemap.md"), + `${CODMAP_INIT_MANAGED}\nstale bundled rule`, + "utf-8", + ); + + expect( + await runAgentsInit({ + projectRoot: dir, + force: true, + targets: ["cursor"], + linkMode: "copy", + }), + ).toBe(true); + + expect( + readFileSync(join(dir, ".agents", "rules", "team-custom.md"), "utf-8"), + ).toBe("# Team\n"); + expect(readFileSync(join(dir, ".agents", "TEAM-NOTES.md"), "utf-8")).toBe( + "notes", + ); + expect( + readFileSync(join(dir, ".agents", "rules", "codemap.md"), "utf-8"), + ).not.toContain("stale bundled rule"); + expect(existsSync(join(dir, ".cursor", "rules", "team-custom.mdc"))).toBe( + false, + ); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("preserves user IDE rules; refreshes managed mirrors; refuses non-managed overwrite", async () => { + const dir = copyDummyCorpus(); + try { + await runAgentsInit({ + projectRoot: dir, + force: true, + targets: ["cursor"], + linkMode: "copy", + }); + writeFileSync( + join(dir, ".cursor", "rules", "team-local.mdc"), + "# Local team rule\n", + "utf-8", + ); + writeFileSync( + join(dir, ".cursor", "rules", "codemap.mdc"), + `${CODMAP_INIT_MANAGED}\nstale mirror`, + "utf-8", + ); + + expect( + await runAgentsInit({ + projectRoot: dir, + force: true, + targets: ["cursor"], + linkMode: "copy", + }), + ).toBe(true); + expect( + readFileSync(join(dir, ".cursor", "rules", "team-local.mdc"), "utf-8"), + ).toBe("# Local team rule\n"); + expect( + readFileSync(join(dir, ".cursor", "rules", "codemap.mdc"), "utf-8"), + ).not.toContain("stale mirror"); + + writeFileSync( + join(dir, ".cursor", "rules", "codemap.mdc"), + "user-owned codemap slot", + "utf-8", + ); + await expect( + runAgentsInit({ + projectRoot: dir, + force: true, + targets: ["cursor"], + linkMode: "copy", + }), + ).rejects.toThrow(/not codemap-managed/); + expect( + readFileSync(join(dir, ".cursor", "rules", "codemap.mdc"), "utf-8"), + ).toBe("user-owned codemap slot"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("symlink mode links only bundled paths under .cursor", async () => { + const dir = copyDummyCorpus(); + try { + await runAgentsInit({ + projectRoot: dir, + force: true, + targets: ["cursor"], + linkMode: "symlink", + }); + writeFileSync( + join(dir, ".agents", "rules", "extra-team.md"), + "# Extra\n", + "utf-8", + ); + expect( + await runAgentsInit({ + projectRoot: dir, + force: false, + targets: ["cursor"], + linkMode: "symlink", + }), + ).toBe(true); + const codemapMdc = join(dir, ".cursor", "rules", "codemap.mdc"); + expect( + isSymlinkTo(codemapMdc, join(dir, ".agents", "rules", "codemap.md")), + ).toBe(true); + expect(existsSync(join(dir, ".cursor", "rules", "extra-team.mdc"))).toBe( + false, + ); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("--force migrates legacy copy-mode mirror on dummy corpus", async () => { + const dir = copyDummyCorpus(); + try { + await runAgentsInit({ + projectRoot: dir, + force: true, + targets: ["cursor"], + linkMode: "copy", + }); + writeFileSync( + join(dir, ".cursor", "rules", "codemap.mdc"), + `${readFileSync(join(dir, ".agents", "rules", "codemap.md"), "utf-8") + .replace(CODMAP_INIT_MANAGED, "") + .trim()}\nstale mirror line`, + "utf-8", + ); + expect( + await runAgentsInit({ + projectRoot: dir, + force: true, + targets: ["cursor"], + linkMode: "copy", + }), + ).toBe(true); + expect( + readFileSync(join(dir, ".cursor", "rules", "codemap.mdc"), "utf-8"), + ).toContain(CODMAP_INIT_MANAGED); + expect( + readFileSync(join(dir, ".cursor", "rules", "codemap.mdc"), "utf-8"), + ).not.toContain("stale mirror line"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("--force refuses symlink refresh for non-managed mirror on dummy corpus", async () => { + const dir = copyDummyCorpus(); + try { + await runAgentsInit({ + projectRoot: dir, + force: true, + targets: ["cursor"], + linkMode: "symlink", + }); + rmSync(join(dir, ".cursor", "rules", "codemap.mdc")); + writeFileSync( + join(dir, ".cursor", "rules", "codemap.mdc"), + "# My team codemap override\n", + "utf-8", + ); + await expect( + runAgentsInit({ + projectRoot: dir, + force: true, + targets: ["cursor"], + linkMode: "symlink", + }), + ).rejects.toThrow(/not codemap-managed/); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("pointer upsert keeps existing AGENTS.md prose on --force", async () => { + const dir = copyDummyCorpus(); + try { + writeFileSync( + join(dir, "AGENTS.md"), + "# Fixture team\n\nCustom onboarding.\n", + "utf-8", + ); + await runAgentsInit({ + projectRoot: dir, + force: true, + targets: ["agents-md"], + }); + const md = readFileSync(join(dir, "AGENTS.md"), "utf-8"); + expect(md).toContain("# Fixture team"); + expect(md).toContain("Custom onboarding."); + expect(md).toContain(CODMAP_POINTER_BEGIN); + + await runAgentsInit({ + projectRoot: dir, + force: true, + targets: ["agents-md"], + }); + const again = readFileSync(join(dir, "AGENTS.md"), "utf-8"); + expect(again).toContain("Custom onboarding."); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("MCP merge preserves foreign servers; invalid shape rejected even with --force", async () => { + const dir = copyDummyCorpus(); + try { + await runAgentsInit({ projectRoot: dir, force: true, mcp: true }); + writeFileSync( + join(dir, ".cursor", "mcp.json"), + `${JSON.stringify( + { + mcpServers: { + foreign: { command: "node", args: ["other.js"] }, + }, + }, + null, + 2, + )}\n`, + "utf-8", + ); + await applyAgentsInitMcp({ projectRoot: dir, targets: ["cursor"] }); + let parsed = JSON.parse( + readFileSync(join(dir, ".cursor", "mcp.json"), "utf-8"), + ); + expect(parsed.mcpServers.foreign).toEqual({ + command: "node", + args: ["other.js"], + }); + expect(parsed.mcpServers[CODEMAP_MCP_SERVER_KEY]).toBeDefined(); + + writeFileSync( + join(dir, ".cursor", "mcp.json"), + `${JSON.stringify({ mcpServers: "bad", editor: "cursor" }, null, 2)}\n`, + "utf-8", + ); + await expect( + applyAgentsInitMcp({ + projectRoot: dir, + targets: ["cursor"], + force: true, + }), + ).rejects.toThrow(/mcpServers must be a JSON object/); + parsed = JSON.parse( + readFileSync(join(dir, ".cursor", "mcp.json"), "utf-8"), + ); + expect(parsed.editor).toBe("cursor"); + expect(parsed.mcpServers).toBe("bad"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("CLI side-effect re-runs on existing .agents/ without --force", () => { + const dir = copyDummyCorpus(); + try { + mkdirSync(join(dir, ".agents"), { recursive: true }); + mkdirSync(join(dir, ".git", "hooks"), { recursive: true }); + writeFileSync(join(dir, ".agents", "KEEP.md"), "fixture note", "utf-8"); + + const mcpOnly = runCli(dir, ["agents", "init", "--mcp"]); + expect(mcpOnly.exitCode).toBe(0); + expect(readFileSync(join(dir, ".agents", "KEEP.md"), "utf-8")).toBe( + "fixture note", + ); + expect(existsSync(join(dir, ".cursor", "mcp.json"))).toBe(true); + + const hooksOnly = runCli(dir, ["agents", "init", "--git-hooks"]); + expect(hooksOnly.exitCode).toBe(0); + expect( + isCodemapHookInstalled(join(dir, ".git", "hooks", "post-commit")), + ).toBe(true); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("indexed dummy corpus still queries after agents init", async () => { + const dir = copyDummyCorpus(); + try { + const index = runCli(dir, ["--full"]); + expect(index.exitCode).toBe(0); + await runAgentsInit({ + projectRoot: dir, + force: true, + targets: ["cursor"], + mcp: true, + }); + const query = runCli(dir, [ + "query", + "--recipe", + "find-symbol-definitions", + "--params", + "name=ShopButton", + "--json", + ]); + expect(query.exitCode).toBe(0); + expect(query.out).toMatch(/ShopButton/); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/src/agents-init-mcp.test.ts b/src/agents-init-mcp.test.ts index 45f798e3..b82af33c 100644 --- a/src/agents-init-mcp.test.ts +++ b/src/agents-init-mcp.test.ts @@ -129,39 +129,22 @@ describe("mergeCodemapMcpServer", () => { }); describe("normalizeExistingMcpServersFile", () => { - it("rejects non-object mcpServers without force", () => { + it("rejects non-object mcpServers", () => { expect(() => normalizeExistingMcpServersFile( { mcpServers: "not-an-object" }, - { label: ".cursor/mcp.json", force: false }, + { label: ".cursor/mcp.json" }, ), ).toThrow(/mcpServers must be a JSON object/); }); - it("replaces non-object mcpServers with force", () => { - expect( + it("rejects non-object mcpServers even with force callers", () => { + expect(() => normalizeExistingMcpServersFile( { mcpServers: ["a", "b"] }, - { label: ".mcp.json", force: true }, - ), - ).toEqual({ - existing: {}, - replacedInvalid: true, - invalidReason: "shape", - }); - }); - - it("preserves non-mcpServers keys when force-replacing invalid mcpServers", () => { - expect( - normalizeExistingMcpServersFile( - { mcpServers: "bad", editor: "cursor" }, - { label: ".cursor/mcp.json", force: true }, + { label: ".mcp.json" }, ), - ).toEqual({ - existing: { editor: "cursor" }, - replacedInvalid: true, - invalidReason: "shape", - }); + ).toThrow(/mcpServers must be a JSON object/); }); }); @@ -208,11 +191,11 @@ describe("mergeCodemapVsCodeServer", () => { }); describe("normalizeExistingVsCodeMcpFile", () => { - it("rejects non-object servers without force", () => { + it("rejects non-object servers", () => { expect(() => normalizeExistingVsCodeMcpFile( { servers: "bad" }, - { label: ".vscode/mcp.json", force: false }, + { label: ".vscode/mcp.json" }, ), ).toThrow(/servers must be a JSON object/); }); @@ -544,16 +527,8 @@ describe("applyAgentsInitMcp", () => { } }); - it("force-replaces invalid mcpServers shape and preserves other keys", async () => { + it("rejects invalid mcpServers shape even with --force", async () => { const dir = mkdtempSync(join(tmpdir(), "codemap-agents-mcp-")); - 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, ".cursor"), { recursive: true }); writeFileSync( @@ -561,38 +536,25 @@ describe("applyAgentsInitMcp", () => { `${JSON.stringify({ mcpServers: "bad", editor: "cursor" }, null, 2)}\n`, "utf-8", ); - await applyAgentsInitMcp({ - projectRoot: dir, - targets: ["cursor"], - force: true, - }); + await expect( + applyAgentsInitMcp({ + projectRoot: dir, + targets: ["cursor"], + force: true, + }), + ).rejects.toThrow(/mcpServers must be a JSON object/); const parsed = JSON.parse( readFileSync(join(dir, ".cursor", "mcp.json"), "utf-8"), - ) as { - editor: string; - mcpServers: Record; - }; + ) as { editor: string; mcpServers: unknown }; expect(parsed.editor).toBe("cursor"); - expect(parsed.mcpServers[CODEMAP_MCP_SERVER_KEY]?.command).toBe("npx"); - expect( - stderr.some((line) => line.includes("invalid mcpServers shape")), - ).toBe(true); + expect(parsed.mcpServers).toBe("bad"); } finally { - console.error = prevError; rmSync(dir, { recursive: true, force: true }); } }); - it("force-replaces invalid VS Code servers shape and preserves other keys", async () => { + it("rejects invalid VS Code servers shape even with --force", 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( @@ -600,102 +562,61 @@ describe("applyAgentsInitMcp", () => { `${JSON.stringify({ servers: "bad", editor: "vscode" }, null, 2)}\n`, "utf-8", ); - await applyAgentsInitMcp({ - projectRoot: dir, - targets: ["vscode"], - force: true, - }); + await expect( + applyAgentsInitMcp({ + projectRoot: dir, + targets: ["vscode"], + force: true, + }), + ).rejects.toThrow(/servers must be a JSON object/); const parsed = JSON.parse( readFileSync(join(dir, ".vscode", "mcp.json"), "utf-8"), - ) as { - editor: string; - servers: Record; - }; + ) as { editor: string; servers: unknown }; 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); + expect(parsed.servers).toBe("bad"); } finally { - console.error = prevError; rmSync(dir, { recursive: true, force: true }); } }); - it("replaces invalid JSON with --force", async () => { + it("rejects unparseable MCP JSON even with --force", async () => { const dir = mkdtempSync(join(tmpdir(), "codemap-agents-mcp-")); - 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, ".cursor"), { recursive: true }); writeFileSync(join(dir, ".cursor", "mcp.json"), "{ not json", "utf-8"); - await applyAgentsInitMcp({ - projectRoot: dir, - targets: ["cursor"], - force: true, - }); - const parsed = JSON.parse( - readFileSync(join(dir, ".cursor", "mcp.json"), "utf-8"), - ) as { mcpServers: Record }; - expect(parsed.mcpServers[CODEMAP_MCP_SERVER_KEY]?.command).toBe("npx"); - expect(stderr.some((line) => line.includes("unparseable JSON"))).toBe( - true, + await expect( + applyAgentsInitMcp({ + projectRoot: dir, + targets: ["cursor"], + force: true, + }), + ).rejects.toThrow(/fix JSON manually/); + expect(readFileSync(join(dir, ".cursor", "mcp.json"), "utf-8")).toBe( + "{ not json", ); } finally { - console.error = prevError; rmSync(dir, { recursive: true, force: true }); } }); - it("replaces invalid Claude .mcp.json with --force", async () => { + it("rejects unparseable Claude .mcp.json even with --force", async () => { const dir = mkdtempSync(join(tmpdir(), "codemap-agents-mcp-")); - 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 { writeFileSync(join(dir, ".mcp.json"), "{ not json", "utf-8"); - await applyAgentsInitMcp({ - projectRoot: dir, - targets: ["claude-code"], - force: true, - }); - const parsed = JSON.parse( - readFileSync(join(dir, ".mcp.json"), "utf-8"), - ) as { - mcpServers: Record; - }; - expect(parsed.mcpServers[CODEMAP_MCP_SERVER_KEY]?.command).toBe("npx"); - expect( - stderr.some((line) => line.includes(".mcp.json (Claude Code)")), - ).toBe(true); + await expect( + applyAgentsInitMcp({ + projectRoot: dir, + targets: ["claude-code"], + force: true, + }), + ).rejects.toThrow(/fix JSON manually/); } finally { - console.error = prevError; rmSync(dir, { recursive: true, force: true }); } }); - it("replaces invalid .claude/settings.json with --force", async () => { + it("rejects unparseable .claude/settings.json even with --force", async () => { const dir = mkdtempSync(join(tmpdir(), "codemap-agents-mcp-")); - 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, ".claude"), { recursive: true }); writeFileSync( @@ -703,6 +624,35 @@ describe("applyAgentsInitMcp", () => { "{ not json", "utf-8", ); + await expect( + applyAgentsInitMcp({ + projectRoot: dir, + targets: ["claude-code"], + force: true, + }), + ).rejects.toThrow(/fix JSON manually/); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("coerces malformed permissions.allow with --force without dropping other keys", 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", deny: ["WebFetch"] }, + editor: "claude", + }, + null, + 2, + )}\n`, + "utf-8", + ); await applyAgentsInitMcp({ projectRoot: dir, targets: ["claude-code"], @@ -710,15 +660,16 @@ describe("applyAgentsInitMcp", () => { }); const settings = JSON.parse( readFileSync(join(dir, ".claude", "settings.json"), "utf-8"), - ) as { permissions: { allow: string[] } }; + ) as { + editor: string; + permissions: { allow: string[]; deny: string[] }; + }; + expect(settings.editor).toBe("claude"); + expect(settings.permissions.deny).toEqual(["WebFetch"]); expect(settings.permissions.allow).toContain( CODEMAP_MCP_PERMISSION_ALLOW, ); - expect( - stderr.some((line) => line.includes(".claude/settings.json")), - ).toBe(true); } finally { - console.error = prevError; rmSync(dir, { recursive: true, force: true }); } }); diff --git a/src/agents-init-mcp.ts b/src/agents-init-mcp.ts index b1914399..ee5b46e8 100644 --- a/src/agents-init-mcp.ts +++ b/src/agents-init-mcp.ts @@ -80,19 +80,12 @@ function entriesEqual(a: McpServerEntry, b: McpServerEntry): boolean { /** Validate top-level file + optional `mcpServers` map before merge. */ export function normalizeExistingMcpServersFile( parsed: unknown, - opts: { label: string; force: boolean }, -): { - existing: McpServersFile; - replacedInvalid: boolean; - invalidReason?: "shape" | undefined; -} { + opts: { label: string }, +): { existing: McpServersFile } { if (!isPlainObject(parsed)) { - if (!opts.force) { - throw new Error( - `Codemap: ${opts.label} is not a JSON object — use --force to replace.`, - ); - } - return { existing: {}, replacedInvalid: true }; + throw new Error( + `Codemap: ${opts.label} is not a JSON object — fix JSON manually, then re-run init --mcp.`, + ); } const file = parsed as McpServersFile & Record; const ms = file.mcpServers; @@ -100,37 +93,22 @@ export function normalizeExistingMcpServersFile( ms !== undefined && (ms === null || typeof ms !== "object" || Array.isArray(ms)) ) { - if (!opts.force) { - throw new Error( - `Codemap: ${opts.label} mcpServers must be a JSON object — use --force to replace.`, - ); - } - const { mcpServers: _drop, ...rest } = file; - return { - existing: rest as McpServersFile, - replacedInvalid: true, - invalidReason: "shape", - }; + throw new Error( + `Codemap: ${opts.label} mcpServers must be a JSON object — fix manually, then re-run init --mcp.`, + ); } - return { existing: file, replacedInvalid: false }; + return { existing: file }; } /** Validate top-level file + optional VS Code `servers` map before merge. */ export function normalizeExistingVsCodeMcpFile( parsed: unknown, - opts: { label: string; force: boolean }, -): { - existing: VsCodeMcpFile; - replacedInvalid: boolean; - invalidReason?: "shape" | undefined; -} { + opts: { label: string }, +): { existing: VsCodeMcpFile } { if (!isPlainObject(parsed)) { - if (!opts.force) { - throw new Error( - `Codemap: ${opts.label} is not a JSON object — use --force to replace.`, - ); - } - return { existing: {}, replacedInvalid: true }; + throw new Error( + `Codemap: ${opts.label} is not a JSON object — fix JSON manually, then re-run init --mcp.`, + ); } const file = parsed as VsCodeMcpFile & Record; const servers = file.servers; @@ -138,19 +116,11 @@ export function normalizeExistingVsCodeMcpFile( servers !== undefined && (servers === null || typeof servers !== "object" || Array.isArray(servers)) ) { - if (!opts.force) { - throw new Error( - `Codemap: ${opts.label} servers must be a JSON object — use --force to replace.`, - ); - } - const { servers: _drop, ...rest } = file; - return { - existing: rest as VsCodeMcpFile, - replacedInvalid: true, - invalidReason: "shape", - }; + throw new Error( + `Codemap: ${opts.label} servers must be a JSON object — fix manually, then re-run init --mcp.`, + ); } - return { existing: file, replacedInvalid: false }; + return { existing: file }; } export function mergeCodemapMcpServer( @@ -199,16 +169,6 @@ function writeJsonIfChanged(path: string, value: unknown, label: string): void { console.log(` Wrote ${label}`); } -function formatMcpReplaceWarning( - label: string, - reason: "unparseable" | "invalid-shape", - key: "mcpServers" | "servers" = "mcpServers", -): string { - const detail = - reason === "invalid-shape" ? `invalid ${key} shape` : "unparseable JSON"; - return ` Warning: replacing ${detail} in ${label} (--force); foreign MCP entries in that file are dropped.`; -} - /** Post-write check — mirrors TanStack Intent's verify-before-success pattern. */ export function verifyCodemapMcpServersFile(opts: { path: string; @@ -229,7 +189,6 @@ export function verifyCodemapMcpServersFile(opts: { } const normalized = normalizeExistingMcpServersFile(parsed, { label: opts.label, - force: true, }); const written = normalized.existing.mcpServers?.[CODEMAP_MCP_SERVER_KEY]; if (written === undefined) { @@ -263,7 +222,6 @@ export function verifyCodemapVsCodeMcpFile(opts: { } const normalized = normalizeExistingVsCodeMcpFile(parsed, { label: opts.label, - force: true, }); const written = normalized.existing.servers?.[CODEMAP_MCP_SERVER_KEY]; const expected = { type: "stdio" as const, ...opts.expectedEntry }; @@ -324,39 +282,24 @@ export function upsertMcpServersFile(opts: { path: string; label: string; entry: McpServerEntry; - force: boolean; }): void { mkdirSync(dirname(opts.path), { recursive: true }); let existing: McpServersFile = {}; if (existsSync(opts.path)) { - let replaceReason: "unparseable" | "invalid-shape" | undefined; try { const parsed = readJsonFile(opts.path); const normalized = normalizeExistingMcpServersFile(parsed, { label: opts.label, - force: opts.force, }); existing = normalized.existing; - if (normalized.replacedInvalid) { - replaceReason = - normalized.invalidReason === "shape" - ? "invalid-shape" - : "unparseable"; - } } catch (err) { if (err instanceof Error && err.message.startsWith("Codemap:")) { throw err; } - if (!opts.force) { - throw new Error( - `Codemap: could not parse ${opts.label} — fix JSON or use --force to replace (${String(err)})`, - { cause: err }, - ); - } - replaceReason = "unparseable"; - } - if (replaceReason !== undefined) { - console.error(formatMcpReplaceWarning(opts.label, replaceReason)); + throw new Error( + `Codemap: could not parse ${opts.label} — fix JSON manually, then re-run init --mcp (${String(err)})`, + { cause: err }, + ); } } const merged = mergeCodemapMcpServer(existing, opts.entry); @@ -373,40 +316,23 @@ export function upsertVsCodeMcpFile(opts: { path: string; label: string; entry: McpServerEntry; - force: boolean; }): void { mkdirSync(dirname(opts.path), { recursive: true }); let existing: VsCodeMcpFile = {}; if (existsSync(opts.path)) { - let replaceReason: "unparseable" | "invalid-shape" | undefined; try { const parsed = readJsonFile(opts.path); const normalized = normalizeExistingVsCodeMcpFile(parsed, { label: opts.label, - force: opts.force, }); existing = normalized.existing; - if (normalized.replacedInvalid) { - replaceReason = - normalized.invalidReason === "shape" - ? "invalid-shape" - : "unparseable"; - } } catch (err) { if (err instanceof Error && err.message.startsWith("Codemap:")) { throw err; } - if (!opts.force) { - throw new Error( - `Codemap: could not parse ${opts.label} — fix JSON or use --force to replace (${String(err)})`, - { cause: err }, - ); - } - replaceReason = "unparseable"; - } - if (replaceReason !== undefined) { - console.error( - formatMcpReplaceWarning(opts.label, replaceReason, "servers"), + throw new Error( + `Codemap: could not parse ${opts.label} — fix JSON manually, then re-run init --mcp (${String(err)})`, + { cause: err }, ); } } @@ -450,7 +376,6 @@ export function upsertClaudeSettingsPermissions(opts: { mkdirSync(dirname(path), { recursive: true }); let existing: ClaudeSettingsFile = {}; if (existsSync(path)) { - let replacedUnparseable = false; try { const parsed = readJsonFile(path); if ( @@ -466,35 +391,33 @@ export function upsertClaudeSettingsPermissions(opts: { ) { if (!opts.force) { throw new Error( - "Codemap: .claude/settings.json permissions.allow must be a string[] — use --force to replace.", + "Codemap: .claude/settings.json permissions.allow must be a string[] — use --force to coerce invalid entries.", ); } - replacedUnparseable = true; + existing = { + ...candidate, + permissions: { + ...candidate.permissions, + allow: Array.isArray(allow) + ? allow.filter((x): x is string => typeof x === "string") + : [], + }, + }; } else { existing = candidate; } - } else if (!opts.force) { + } else { throw new Error( - "Codemap: .claude/settings.json is not a JSON object — use --force to replace.", + "Codemap: .claude/settings.json is not a JSON object — fix JSON manually, then re-run init --mcp.", ); - } else { - replacedUnparseable = true; } } catch (err) { if (err instanceof Error && err.message.startsWith("Codemap:")) { throw err; } - if (!opts.force) { - throw new Error( - `Codemap: could not parse .claude/settings.json — fix JSON or use --force (${String(err)})`, - { cause: err }, - ); - } - replacedUnparseable = true; - } - if (replacedUnparseable) { - console.error( - " Warning: replacing unparseable JSON in .claude/settings.json (--force); prior keys in that file are dropped.", + throw new Error( + `Codemap: could not parse .claude/settings.json — fix JSON manually, then re-run init --mcp (${String(err)})`, + { cause: err }, ); } } @@ -546,14 +469,12 @@ export async function applyAgentsInitMcp( path, label: def.label, entry, - force, }); } else { upsertMcpServersFile({ path, label: def.label, entry, - force, }); } diff --git a/src/agents-init.test.ts b/src/agents-init.test.ts index d231ac5b..823c0c30 100644 --- a/src/agents-init.test.ts +++ b/src/agents-init.test.ts @@ -12,11 +12,13 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { + CODMAP_INIT_MANAGED, CODMAP_POINTER_BEGIN, CODMAP_POINTER_END, ensureGitignoreCodemapPattern, listRegularFilesRecursive, relPathToAbsSegments, + resolveBundledAgentMirrorPaths, runAgentsInit, targetsNeedLinkMode, upsertCodemapPointerFile, @@ -348,9 +350,7 @@ describe("runAgentsInit", () => { expect(lstatSync(skillsDir).isSymbolicLink()).toBe(false); expect(lstatSync(rulesDir).isDirectory()).toBe(true); expect(lstatSync(skillsDir).isDirectory()).toBe(true); - for (const rel of listRegularFilesRecursive( - join(dir, ".agents", "rules"), - )) { + for (const rel of resolveBundledAgentMirrorPaths().ruleFiles) { const cursorRel = rel.endsWith(".md") ? rel.slice(0, -3) + ".mdc" : rel; expect( lstatSync( @@ -358,9 +358,7 @@ describe("runAgentsInit", () => { ).isSymbolicLink(), ).toBe(true); } - for (const rel of listRegularFilesRecursive( - join(dir, ".agents", "skills"), - )) { + for (const rel of resolveBundledAgentMirrorPaths().skillFiles) { expect( lstatSync( join(dir, ".cursor", "skills", ...rel.split("/")), @@ -441,19 +439,220 @@ describe("runAgentsInit", () => { } }); - it("runAgentsInit refuses Cursor wiring when .cursor/rules exists without force", async () => { + it("runAgentsInit does not mirror custom .agents rules to .cursor", async () => { const dir = mkdtempSync(join(tmpdir(), "codemap-agents-")); try { + await runAgentsInit({ projectRoot: dir, force: true }); + writeFileSync( + join(dir, ".agents", "rules", "user-custom.md"), + "# User agents rule\n", + "utf-8", + ); + expect( + await runAgentsInit({ + projectRoot: dir, + force: false, + targets: ["cursor"], + linkMode: "copy", + }), + ).toBe(true); + expect(existsSync(join(dir, ".cursor", "rules", "user-custom.mdc"))).toBe( + false, + ); + expect( + readFileSync(join(dir, ".cursor", "rules", "codemap.mdc"), "utf-8"), + ).toContain(CODMAP_INIT_MANAGED); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("runAgentsInit wires Cursor alongside existing user rules", async () => { + const dir = mkdtempSync(join(tmpdir(), "codemap-agents-")); + try { + await runAgentsInit({ projectRoot: dir, force: true }); + mkdirSync(join(dir, ".cursor", "rules"), { recursive: true }); + writeFileSync( + join(dir, ".cursor", "rules", "user-custom.mdc"), + "# User rule\n", + "utf-8", + ); + expect( + await runAgentsInit({ + projectRoot: dir, + force: false, + targets: ["cursor"], + linkMode: "copy", + }), + ).toBe(true); + expect( + readFileSync(join(dir, ".cursor", "rules", "user-custom.mdc"), "utf-8"), + ).toBe("# User rule\n"); + expect( + readFileSync(join(dir, ".cursor", "rules", "codemap.mdc"), "utf-8"), + ).toContain("codemap"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("runAgentsInit with --force replaces only codemap-managed mirror files under .cursor/rules", async () => { + const dir = mkdtempSync(join(tmpdir(), "codemap-agents-")); + try { + await runAgentsInit({ projectRoot: dir, force: true }); mkdirSync(join(dir, ".cursor", "rules"), { recursive: true }); - writeFileSync(join(dir, ".cursor", "rules", "x.mdc"), "", "utf-8"); + writeFileSync( + join(dir, ".cursor", "rules", "user-custom.mdc"), + "# User rule\n", + "utf-8", + ); + writeFileSync( + join(dir, ".cursor", "rules", "codemap.mdc"), + `${CODMAP_INIT_MANAGED}\nstale codemap mirror`, + "utf-8", + ); + expect( + await runAgentsInit({ + projectRoot: dir, + force: true, + targets: ["cursor"], + linkMode: "copy", + }), + ).toBe(true); + expect( + readFileSync(join(dir, ".cursor", "rules", "user-custom.mdc"), "utf-8"), + ).toBe("# User rule\n"); + expect( + readFileSync(join(dir, ".cursor", "rules", "codemap.mdc"), "utf-8"), + ).toContain("codemap"); + expect( + readFileSync(join(dir, ".cursor", "rules", "codemap.mdc"), "utf-8"), + ).not.toContain("stale codemap mirror"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("runAgentsInit with --force refuses to overwrite mirror files without codemap-init marker", async () => { + const dir = mkdtempSync(join(tmpdir(), "codemap-agents-")); + try { + await runAgentsInit({ projectRoot: dir, force: true }); + mkdirSync(join(dir, ".cursor", "rules"), { recursive: true }); + writeFileSync( + join(dir, ".cursor", "rules", "codemap.mdc"), + "# My team codemap override\n", + "utf-8", + ); await expect( runAgentsInit({ projectRoot: dir, - force: false, + force: true, targets: ["cursor"], linkMode: "copy", }), - ).rejects.toThrow(/\.cursor\/rules already exists/); + ).rejects.toThrow(/not codemap-managed/); + expect( + readFileSync(join(dir, ".cursor", "rules", "codemap.mdc"), "utf-8"), + ).toBe("# My team codemap override\n"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("runAgentsInit with --force migrates legacy copy-mode mirrors without codemap-init marker", async () => { + const dir = mkdtempSync(join(tmpdir(), "codemap-agents-")); + try { + await runAgentsInit({ projectRoot: dir, force: true }); + mkdirSync(join(dir, ".cursor", "rules"), { recursive: true }); + writeFileSync( + join(dir, ".cursor", "rules", "codemap.mdc"), + `${readFileSync(join(dir, ".agents", "rules", "codemap.md"), "utf-8") + .replace(CODMAP_INIT_MANAGED, "") + .trim()}\nstale mirror line`, + "utf-8", + ); + expect( + await runAgentsInit({ + projectRoot: dir, + force: true, + targets: ["cursor"], + linkMode: "copy", + }), + ).toBe(true); + expect( + readFileSync(join(dir, ".cursor", "rules", "codemap.mdc"), "utf-8"), + ).toContain(CODMAP_INIT_MANAGED); + expect( + readFileSync(join(dir, ".cursor", "rules", "codemap.mdc"), "utf-8"), + ).not.toContain("stale mirror line"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("runAgentsInit with --force migrates legacy mirror via symlink refresh", async () => { + const dir = mkdtempSync(join(tmpdir(), "codemap-agents-")); + try { + await runAgentsInit({ + projectRoot: dir, + force: true, + targets: ["cursor"], + linkMode: "copy", + }); + writeFileSync( + join(dir, ".cursor", "rules", "codemap.mdc"), + `${readFileSync(join(dir, ".agents", "rules", "codemap.md"), "utf-8") + .replace(CODMAP_INIT_MANAGED, "") + .trim()}\nstale mirror line`, + "utf-8", + ); + expect( + await runAgentsInit({ + projectRoot: dir, + force: true, + targets: ["cursor"], + linkMode: "symlink", + }), + ).toBe(true); + expect( + lstatSync( + join(dir, ".cursor", "rules", "codemap.mdc"), + ).isSymbolicLink(), + ).toBe(true); + expect( + readFileSync(join(dir, ".cursor", "rules", "codemap.mdc"), "utf-8"), + ).toContain(CODMAP_INIT_MANAGED); + expect( + readFileSync(join(dir, ".cursor", "rules", "codemap.mdc"), "utf-8"), + ).not.toContain("stale mirror line"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("runAgentsInit with --force refuses symlink refresh when dest is a non-managed file", async () => { + const dir = mkdtempSync(join(tmpdir(), "codemap-agents-")); + try { + await runAgentsInit({ + projectRoot: dir, + force: true, + targets: ["cursor"], + linkMode: "symlink", + }); + rmSync(join(dir, ".cursor", "rules", "codemap.mdc")); + writeFileSync( + join(dir, ".cursor", "rules", "codemap.mdc"), + "# My team codemap override\n", + "utf-8", + ); + await expect( + runAgentsInit({ + projectRoot: dir, + force: true, + targets: ["cursor"], + linkMode: "symlink", + }), + ).rejects.toThrow(/not codemap-managed/); } finally { rmSync(dir, { recursive: true, force: true }); } @@ -558,15 +757,17 @@ describe("upsertCodemapPointerFile", () => { } }); - it("--force replaces entire file with managed section", async () => { + it("--force refreshes pointer section without dropping user content", async () => { const dir = mkdtempSync(join(tmpdir(), "codemap-pointer-")); const p = join(dir, "AGENTS.md"); try { writeFileSync(p, "# Keep me\n\nLots of custom content.\n", "utf-8"); upsertCodemapPointerFile(p, POINTER_INNER_TEST, "AGENTS.md", true); - expect(readFileSync(p, "utf-8")).toBe( - wrapPointerTest(POINTER_INNER_TEST), - ); + const out = readFileSync(p, "utf-8"); + expect(out).toContain("# Keep me"); + expect(out).toContain("Lots of custom content."); + expect(out).toContain(CODMAP_POINTER_BEGIN); + expect(out).toContain("stainless-code/codemap"); } finally { rmSync(dir, { recursive: true, force: true }); } diff --git a/src/agents-init.ts b/src/agents-init.ts index 187dc232..999d64d4 100644 --- a/src/agents-init.ts +++ b/src/agents-init.ts @@ -1,9 +1,11 @@ import { copyFileSync, existsSync, + lstatSync, mkdirSync, readdirSync, readFileSync, + readlinkSync, rmSync, statSync, symlinkSync, @@ -64,28 +66,136 @@ export function relPathToAbsSegments(rel: string): string[] { return segments; } -/** Copy only listed relative paths from `srcRoot` into `destRoot` (mkdir parents per file). */ +function removeManagedFileIfExists(abs: string, label: string): void { + if (!existsSync(abs)) { + return; + } + const st = statSync(abs); + if (st.isDirectory()) { + throw new Error( + `Codemap: ${label} is a directory — remove it manually; init only replaces codemap-managed files.`, + ); + } + rmSync(abs, { force: true }); +} + +function removeBundledPathsIfExist(destBase: string, relPaths: string[]): void { + for (const rel of relPaths) { + const abs = join(destBase, ...relPathToAbsSegments(rel)); + removeManagedFileIfExists(abs, abs); + } +} + +function isSymlinkTo(destFile: string, srcFile: string): boolean { + try { + if (!lstatSync(destFile).isSymbolicLink()) { + return false; + } + return readlinkSync(destFile) === relative(dirname(destFile), srcFile); + } catch { + return false; + } +} + +function filesContentEqual(a: string, b: string): boolean { + return readFileSync(a).equals(readFileSync(b)); +} + +/** HTML comment — marks files init wrote or mirrors from bundled templates; `--force` overwrites only when present. */ +export const CODMAP_INIT_MANAGED = ""; + +function fileHasCodemapInitMarker(path: string): boolean { + if (!existsSync(path)) { + return false; + } + try { + return readFileSync(path, "utf-8").includes(CODMAP_INIT_MANAGED); + } catch { + return false; + } +} + +/** One-time upgrade for copy-mode mirrors written before {@link CODMAP_INIT_MANAGED} shipped. */ +function looksLikeLegacyCodemapMirror(content: string): boolean { + const t = content.trim(); + if (t.length < 80) { + return false; + } + return ( + t.includes("codemap query") && + (t.includes("codemap-pointer-version") || + t.includes("codemap://rule") || + t.includes("stainless-code/codemap")) + ); +} + +function mirrorMayForceOverwrite(path: string): boolean { + if (fileHasCodemapInitMarker(path)) { + return true; + } + try { + return looksLikeLegacyCodemapMirror(readFileSync(path, "utf-8")); + } catch { + return false; + } +} + +function refuseOverwriteNonManagedMirror(path: string): void { + throw new Error( + `Codemap: ${path} exists but is not codemap-managed (missing ${CODMAP_INIT_MANAGED}) — remove or edit manually; init will not overwrite.`, + ); +} + +/** Bundled template paths under `rules/` and `skills/` (IDE mirrors sync these only). */ +export function resolveBundledAgentMirrorPaths(templateRoot?: string): { + ruleFiles: string[]; + skillFiles: string[]; +} { + const root = templateRoot ?? resolveAgentsTemplateDir(); + return { + ruleFiles: listRegularFilesRecursive(join(root, "rules")), + skillFiles: listRegularFilesRecursive(join(root, "skills")), + }; +} + +/** Copy listed paths; never deletes paths outside `relPaths`. */ function copyFilesGranular( srcRoot: string, destRoot: string, relPaths: string[], + force: boolean, renameFn?: (rel: string) => string, ): void { for (const rel of relPaths) { const destRel = renameFn ? renameFn(rel) : rel; const from = join(srcRoot, ...relPathToAbsSegments(rel)); const to = join(destRoot, ...relPathToAbsSegments(destRel)); + if (existsSync(to)) { + if (!force && filesContentEqual(from, to)) { + continue; + } + if (!force) { + throw new Error( + `Codemap: ${to} already exists — use --force to replace codemap-managed mirror files only.`, + ); + } + if (!mirrorMayForceOverwrite(to)) { + refuseOverwriteNonManagedMirror(to); + } + removeManagedFileIfExists(to, to); + } mkdirSync(dirname(to), { recursive: true }); copyFileSync(from, to); } } -/** Symlink each file: `destRoot/` → relative path to `srcRoot/` (mkdir parents per file). */ +/** Symlink listed paths; never deletes paths outside `relPaths`. */ function symlinkFilesGranular( srcRoot: string, destRoot: string, relPaths: string[], labelForErrors: string, + force: boolean, renameFn?: (rel: string) => string, ): void { mkdirSync(destRoot, { recursive: true }); @@ -93,6 +203,20 @@ function symlinkFilesGranular( const destRel = renameFn ? renameFn(rel) : rel; const srcFile = join(srcRoot, ...relPathToAbsSegments(rel)); const destFile = join(destRoot, ...relPathToAbsSegments(destRel)); + if (existsSync(destFile)) { + if (!force && isSymlinkTo(destFile, srcFile)) { + continue; + } + if (!force) { + throw new Error( + `Codemap: ${destFile} already exists — use --force to replace codemap-managed mirror files only.`, + ); + } + if (!mirrorMayForceOverwrite(destFile)) { + refuseOverwriteNonManagedMirror(destFile); + } + removeManagedFileIfExists(destFile, destFile); + } mkdirSync(dirname(destFile), { recursive: true }); const target = relative(dirname(destFile), srcFile); try { @@ -106,16 +230,6 @@ function symlinkFilesGranular( } } -function removeBundledPathsIfExist(destBase: string, relPaths: string[]): void { - for (const rel of relPaths) { - const abs = join(destBase, ...relPathToAbsSegments(rel)); - if (!existsSync(abs)) { - continue; - } - rmSync(abs, { recursive: true, force: true }); - } -} - export type { AgentsInitTarget } from "./agents-init-targets"; export { AGENTS_INIT_SYMLINK_TARGETS, @@ -195,7 +309,7 @@ function looksLikeLegacyCodemapPointer(content: string): boolean { * - **Existing + markers:** replace inner section (updates stale template text). * - **Existing, no markers, legacy Codemap content:** replace whole file with managed block. * - **Existing, other content:** append managed block once. - * - **`force`:** replace entire file with the latest managed block (same as a fresh write). + * - **`force`:** refresh the managed section only (never drops non-pointer content). */ export function upsertCodemapPointerFile( path: string, @@ -211,12 +325,6 @@ export function upsertCodemapPointerFile( return; } - if (force) { - writeFileSync(path, wrapped, "utf-8"); - console.log(` Replaced ${label} (--force)`); - return; - } - const content = readFileSync(path, "utf-8"); if (content.match(codemapPointerBlockRegex())) { @@ -232,7 +340,11 @@ export function upsertCodemapPointerFile( return; } writeFileSync(path, next, "utf-8"); - console.log(` Updated Codemap section in ${label}`); + console.log( + force + ? ` Refreshed Codemap section in ${label} (--force)` + : ` Updated Codemap section in ${label}`, + ); return; } @@ -280,46 +392,27 @@ export function ensureGitignoreCodemapPattern(projectRoot: string): void { } } -function removePathForRewrite( - path: string, - force: boolean, - label: string, -): void { - if (!existsSync(path)) { - return; - } - if (!force) { - throw new Error( - `Codemap: ${label} already exists — use --force to replace, or remove it manually.`, - ); - } - rmSync(path, { recursive: true, force: true }); -} - -/** - * Map `.agents/rules` into a destination directory (symlink or copy). - */ function wireAgentsRulesTo( projectRoot: string, destPath: string, label: string, + ruleRelPaths: string[], linkMode: AgentsInitLinkMode, force: boolean, ): void { const agentsRules = join(projectRoot, ".agents", "rules"); mkdirSync(dirname(destPath), { recursive: true }); - removePathForRewrite(destPath, force, label); if (linkMode === "symlink") { - const ruleFiles = listRegularFilesRecursive(agentsRules); - symlinkFilesGranular(agentsRules, destPath, ruleFiles, label); + symlinkFilesGranular(agentsRules, destPath, ruleRelPaths, label, force); console.log( - ` Linked each file under ${label} → .agents/rules (${ruleFiles.length} files)`, + ` Linked ${ruleRelPaths.length} bundled rule file(s) under ${label} → .agents/rules`, ); return; } - const ruleFiles = listRegularFilesRecursive(agentsRules); - copyFilesGranular(agentsRules, destPath, ruleFiles); - console.log(` Copied .agents/rules → ${label}`); + copyFilesGranular(agentsRules, destPath, ruleRelPaths, force); + console.log( + ` Copied ${ruleRelPaths.length} bundled rule file(s) → ${label}`, + ); } /** @@ -339,10 +432,19 @@ export function applyAgentsInitTargets( ); } + const { ruleFiles: bundledRuleFiles, skillFiles: bundledSkillFiles } = + resolveBundledAgentMirrorPaths(); + for (const t of targets) { switch (t) { case "cursor": { - applyCursorIntegration(projectRoot, linkMode, force); + applyCursorIntegration( + projectRoot, + bundledRuleFiles, + bundledSkillFiles, + linkMode, + force, + ); break; } case "windsurf": { @@ -350,6 +452,7 @@ export function applyAgentsInitTargets( projectRoot, join(projectRoot, ".windsurf", "rules"), ".windsurf/rules", + bundledRuleFiles, linkMode, force, ); @@ -360,6 +463,7 @@ export function applyAgentsInitTargets( projectRoot, join(projectRoot, ".continue", "rules"), ".continue/rules", + bundledRuleFiles, linkMode, force, ); @@ -370,6 +474,7 @@ export function applyAgentsInitTargets( projectRoot, join(projectRoot, ".clinerules"), ".clinerules", + bundledRuleFiles, linkMode, force, ); @@ -380,6 +485,7 @@ export function applyAgentsInitTargets( projectRoot, join(projectRoot, ".amazonq", "rules"), ".amazonq/rules", + bundledRuleFiles, linkMode, force, ); @@ -433,6 +539,8 @@ function mdToMdc(rel: string): string { function applyCursorIntegration( projectRoot: string, + ruleRelPaths: string[], + skillRelPaths: string[], linkMode: AgentsInitLinkMode, force: boolean, ): void { @@ -444,44 +552,31 @@ function applyCursorIntegration( mkdirSync(join(projectRoot, ".cursor"), { recursive: true }); if (linkMode === "symlink") { - removePathForRewrite(cursorRules, force, ".cursor/rules"); - removePathForRewrite(cursorSkills, force, ".cursor/skills"); - const ruleFiles = listRegularFilesRecursive(agentsRules); - const skillFiles = listRegularFilesRecursive(agentsSkills); symlinkFilesGranular( agentsRules, cursorRules, - ruleFiles, + ruleRelPaths, ".cursor/rules", + force, mdToMdc, ); symlinkFilesGranular( agentsSkills, cursorSkills, - skillFiles, + skillRelPaths, ".cursor/skills", + force, ); console.log( - ` Linked ${ruleFiles.length} rule file(s) and ${skillFiles.length} skill file(s) under .cursor/ → .agents/`, + ` Linked ${ruleRelPaths.length} bundled rule file(s) and ${skillRelPaths.length} bundled skill file(s) under .cursor/ → .agents/`, ); return; } - removePathForRewrite(cursorRules, force, ".cursor/rules"); - removePathForRewrite(cursorSkills, force, ".cursor/skills"); - copyFilesGranular( - agentsRules, - cursorRules, - listRegularFilesRecursive(agentsRules), - mdToMdc, - ); - copyFilesGranular( - agentsSkills, - cursorSkills, - listRegularFilesRecursive(agentsSkills), - ); + copyFilesGranular(agentsRules, cursorRules, ruleRelPaths, force, mdToMdc); + copyFilesGranular(agentsSkills, cursorSkills, skillRelPaths, force); console.log( - " Copied rules and skills into .cursor/rules and .cursor/skills", + " Copied bundled rules and skills into .cursor/rules and .cursor/skills", ); } @@ -547,6 +642,18 @@ export async function runAgentsInit( await maybeApplyAgentsInitMcp(options); return true; } + const targets = options.targets ?? []; + if (targets.length > 0) { + applyAgentsInitTargets( + options.projectRoot, + targets, + options.linkMode ?? "symlink", + false, + ); + ensureGitignoreCodemapPattern(options.projectRoot); + await maybeApplyAgentsInitMcp(options); + return true; + } console.error( ` .agents/ already exists at ${destRoot}. Re-run with --force to refresh bundled template files under rules/ and skills/, or remove the directory.`, ); @@ -558,8 +665,18 @@ export async function runAgentsInit( mkdirSync(destRoot, { recursive: true }); } - copyFilesGranular(templateRules, destRules, bundledRuleFiles); - copyFilesGranular(templateSkills, destSkills, bundledSkillFiles); + copyFilesGranular( + templateRules, + destRules, + bundledRuleFiles, + !!options.force, + ); + copyFilesGranular( + templateSkills, + destSkills, + bundledSkillFiles, + !!options.force, + ); console.log(` Wrote agent templates to ${destRoot}`); diff --git a/templates/agents/rules/codemap.md b/templates/agents/rules/codemap.md index b4873580..077dc764 100644 --- a/templates/agents/rules/codemap.md +++ b/templates/agents/rules/codemap.md @@ -2,6 +2,8 @@ alwaysApply: true --- + + # Codemap This project is indexed by **Codemap** — a local SQLite index of structure (symbols, imports, exports, components, dependencies, markers, scopes, references, bindings, call graphs, CSS variables, coverage). diff --git a/templates/agents/skills/codemap/SKILL.md b/templates/agents/skills/codemap/SKILL.md index 01f52961..73b51b26 100644 --- a/templates/agents/skills/codemap/SKILL.md +++ b/templates/agents/skills/codemap/SKILL.md @@ -3,6 +3,8 @@ name: codemap description: Query codebase structure via SQLite instead of scanning files. Use when exploring code, finding where symbols are defined, tracing who imports what, listing components / hooks / CSS variables / deprecated symbols, walking dependency or call graphs, or auditing structural changes on a PR. --- + + # Codemap skill Full content is served live by the installed `codemap` CLI, so version bumps carry today's reference automatically — no `agents init` re-run needed.