From 051826ea204ae337bfa5f10d6a5a70f71ae50165 Mon Sep 17 00:00:00 2001 From: Sutu Sebastian Date: Wed, 27 May 2026 19:49:11 +0300 Subject: [PATCH] feat(agents): add --targets and --link-mode for non-interactive init Expose --targets and --link-mode on codemap agents init for CI and sandboxes. Combine with --mcp to write MCP configs only for selected integrations. Side-effect paths compose targets with --git-hooks or --mcp on existing .agents/ without --force. --- .changeset/agents-init-targets.md | 5 + docs/agents.md | 9 +- docs/roadmap.md | 1 + src/agents-init-targets.test.ts | 30 ++ src/agents-init-targets.ts | 55 +++ src/agents-init.test.ts | 70 ++++ src/agents-init.ts | 28 +- src/cli.test.ts | 330 ++++++++++++++++++ src/cli/bootstrap.ts | 4 +- src/cli/cmd-agents.test.ts | 32 ++ src/cli/cmd-agents.ts | 169 ++++++++- src/cli/main.ts | 55 +-- .../agent-content/skill/10-recipes-context.md | 2 +- 13 files changed, 737 insertions(+), 53 deletions(-) create mode 100644 .changeset/agents-init-targets.md create mode 100644 src/agents-init-targets.test.ts create mode 100644 src/cli/cmd-agents.test.ts diff --git a/.changeset/agents-init-targets.md b/.changeset/agents-init-targets.md new file mode 100644 index 00000000..5b52b2b5 --- /dev/null +++ b/.changeset/agents-init-targets.md @@ -0,0 +1,5 @@ +--- +"@stainless-code/codemap": patch +--- + +Add `codemap agents init --targets` and `--link-mode` for non-interactive IDE wiring. Combine with `--mcp` to write MCP config only for selected integrations (e.g. Cursor + Copilot without Continue/Cline). Mutually exclusive with `--interactive`. diff --git a/docs/agents.md b/docs/agents.md index 488d4c8a..2654a9f7 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -28,12 +28,17 @@ codemap agents init codemap agents init --force codemap agents init --interactive # or -i; requires a TTY codemap agents init --mcp # project MCP config (default project-local targets; Windsurf opt-in via -i) +codemap agents init --targets cursor,copilot --mcp # non-interactive: subset of integrations + MCP +codemap agents init --targets copilot # Copilot instructions only (no MCP) +codemap agents init --targets windsurf --link-mode copy # rule mirrors as copies (sandboxes / Windows) 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. 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. +- **`--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. Mutually exclusive with **`--targets`**. +- **`--targets`** — comma-separated integration ids (`cursor`, `copilot`, `claude-md`, `windsurf`, `continue`, `cline`, `amazon-q`, `agents-md`, `gemini-md`) or repeated `--targets` flags. Wires IDE mirrors without a TTY. With **`--mcp`**, only MCP configs for the selected integrations are written (e.g. `cursor` alone → `.cursor/mcp.json` only, not root `.mcp.json`). Default **`--link-mode`** is **symlink** when omitted. Unknown ids exit 1 with the valid list. +- **`--link-mode`** — `symlink` or `copy`; only valid when **`--targets`** includes a rule-mirror integration (`cursor`, `windsurf`, `continue`, `cline`, `amazon-q`). ## Git and `.gitignore` @@ -149,7 +154,7 @@ Example: `CODEMAP_MCP_TOOLS=query,context,show codemap mcp --no-watch` | Cline | `.cline/mcp.json` ([Cline CLI reference](https://docs.cline.bot/cli/cli-reference); global IDE settings may also use `~/.cline/data/settings/cline_mcp_settings.json`) | | Windsurf (Cascade) | `~/.codeium/windsurf/mcp_config.json` ([Windsurf docs](https://docs.windsurf.com/windsurf/cascade/mcp) — user-global only; written when Windsurf integration is selected) | -With **`--mcp`** and no `--target` filter, all **project-local** rows above are written except **Windsurf**, which has no documented workspace MCP path. +With **`--mcp`** and no **`--targets`** 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. **`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: …`). diff --git a/docs/roadmap.md b/docs/roadmap.md index 2ece7e79..f0408f75 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -83,6 +83,7 @@ Long-running MCP / HTTP sessions dominate agent workflows; one-shot CLI keeps th - [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. +- [x] **`agents init --targets` (non-interactive IDE wiring)** — `--targets` + `--link-mode` for CI/sandboxes; MCP subset when combined with `--mcp`. Shipped [#158](https://github.com/stainless-code/codemap/pull/158); see [agents.md](./agents.md). - [ ] **`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/src/agents-init-targets.test.ts b/src/agents-init-targets.test.ts new file mode 100644 index 00000000..c6a3912d --- /dev/null +++ b/src/agents-init-targets.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "bun:test"; + +import { + formatAgentsInitTargetIdsForError, + parseAgentsInitTargets, +} from "./agents-init-targets"; + +describe("parseAgentsInitTargets", () => { + it("parses comma-separated and repeated segments", () => { + expect(parseAgentsInitTargets(["cursor,copilot", "cursor"])).toEqual([ + "cursor", + "copilot", + ]); + }); + + it("rejects unknown ids with valid list", () => { + expect(() => parseAgentsInitTargets(["nope"])).toThrow( + /unknown integration/, + ); + expect(() => parseAgentsInitTargets(["nope"])).toThrow( + formatAgentsInitTargetIdsForError(), + ); + }); + + it("rejects empty segments", () => { + expect(() => parseAgentsInitTargets([","])).toThrow( + /requires at least one integration id/, + ); + }); +}); diff --git a/src/agents-init-targets.ts b/src/agents-init-targets.ts index 76a01a5d..c6c42367 100644 --- a/src/agents-init-targets.ts +++ b/src/agents-init-targets.ts @@ -13,6 +13,21 @@ export type AgentsInitTarget = | "agents-md" | "gemini-md"; +/** Same order as interactive multiselect in `agents-init-interactive.ts`. */ +export const AGENTS_INIT_TARGET_IDS: readonly AgentsInitTarget[] = [ + "cursor", + "claude-md", + "copilot", + "windsurf", + "continue", + "cline", + "amazon-q", + "agents-md", + "gemini-md", +] as const; + +const AGENTS_INIT_TARGET_ID_SET = new Set(AGENTS_INIT_TARGET_IDS); + /** Targets that mirror `.agents/rules` (and Cursor also `.agents/skills`) via per-file symlink or copy. */ export const AGENTS_INIT_SYMLINK_TARGETS: readonly AgentsInitTarget[] = [ "cursor", @@ -25,3 +40,43 @@ export const AGENTS_INIT_SYMLINK_TARGETS: readonly AgentsInitTarget[] = [ export function targetsNeedLinkMode(targets: AgentsInitTarget[]): boolean { return targets.some((t) => AGENTS_INIT_SYMLINK_TARGETS.includes(t)); } + +export function isAgentsInitTarget(id: string): id is AgentsInitTarget { + return AGENTS_INIT_TARGET_ID_SET.has(id); +} + +export function formatAgentsInitTargetIdsForError(): string { + return AGENTS_INIT_TARGET_IDS.join(", "); +} + +/** + * Parse `--targets` argv segments (`cursor,copilot` or repeated flags). + * Dedupes while preserving first-seen order. + */ +export function parseAgentsInitTargets(raw: string[]): AgentsInitTarget[] { + const out: AgentsInitTarget[] = []; + const seen = new Set(); + for (const segment of raw) { + for (const part of segment.split(",")) { + const id = part.trim(); + if (id.length === 0) { + throw new Error( + "codemap: --targets requires at least one integration id", + ); + } + if (!isAgentsInitTarget(id)) { + throw new Error( + `codemap: unknown integration ${JSON.stringify(id)}. Valid ids: ${formatAgentsInitTargetIdsForError()}`, + ); + } + if (!seen.has(id)) { + seen.add(id); + out.push(id); + } + } + } + if (out.length === 0) { + throw new Error("codemap: --targets requires at least one integration id"); + } + return out; +} diff --git a/src/agents-init.test.ts b/src/agents-init.test.ts index 823c0c30..ea3d97f0 100644 --- a/src/agents-init.test.ts +++ b/src/agents-init.test.ts @@ -215,6 +215,76 @@ describe("runAgentsInit", () => { } }); + it("runAgentsInit --git-hooks --targets cursor on existing .agents/ composes hooks and wiring", async () => { + const dir = mkdtempSync(join(tmpdir(), "codemap-agents-")); + try { + mkdirSync(join(dir, ".agents", "rules"), { recursive: true }); + mkdirSync(join(dir, ".agents", "skills", "codemap"), { + recursive: true, + }); + mkdirSync(join(dir, ".git", "hooks"), { recursive: true }); + writeFileSync( + join(dir, ".agents", "rules", "codemap.md"), + `${CODMAP_INIT_MANAGED}\n`, + "utf-8", + ); + writeFileSync( + join(dir, ".agents", "skills", "codemap", "SKILL.md"), + `${CODMAP_INIT_MANAGED}\n`, + "utf-8", + ); + expect( + await runAgentsInit({ + projectRoot: dir, + gitHooks: "install", + targets: ["cursor"], + }), + ).toBe(true); + expect( + isCodemapHookInstalled(join(dir, ".git", "hooks", "post-commit")), + ).toBe(true); + expect(existsSync(join(dir, ".cursor", "rules", "codemap.mdc"))).toBe( + true, + ); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("runAgentsInit --mcp --targets cursor on existing .agents/ composes MCP and wiring", async () => { + const dir = mkdtempSync(join(tmpdir(), "codemap-agents-")); + try { + mkdirSync(join(dir, ".agents", "rules"), { recursive: true }); + mkdirSync(join(dir, ".agents", "skills", "codemap"), { + recursive: true, + }); + writeFileSync( + join(dir, ".agents", "rules", "codemap.md"), + `${CODMAP_INIT_MANAGED}\n`, + "utf-8", + ); + writeFileSync( + join(dir, ".agents", "skills", "codemap", "SKILL.md"), + `${CODMAP_INIT_MANAGED}\n`, + "utf-8", + ); + expect( + await runAgentsInit({ + projectRoot: dir, + mcp: true, + targets: ["cursor"], + }), + ).toBe(true); + expect(existsSync(join(dir, ".cursor", "mcp.json"))).toBe(true); + expect(existsSync(join(dir, ".cursor", "rules", "codemap.mdc"))).toBe( + true, + ); + expect(existsSync(join(dir, ".mcp.json"))).toBe(false); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + it("runAgentsInit --no-git-hooks --mcp uninstalls hooks and writes MCP", async () => { const dir = mkdtempSync(join(tmpdir(), "codemap-agents-")); try { diff --git a/src/agents-init.ts b/src/agents-init.ts index 999d64d4..32851836 100644 --- a/src/agents-init.ts +++ b/src/agents-init.ts @@ -592,6 +592,23 @@ async function maybeApplyAgentsInitMcp( } } +/** Side-effect-only path when `.agents/` exists and `--force` is off. */ +function applyMaybeAgentsInitTargetsOnExisting( + options: AgentsInitOptions, +): void { + const targets = options.targets ?? []; + if (targets.length === 0) { + return; + } + applyAgentsInitTargets( + options.projectRoot, + targets, + options.linkMode ?? "symlink", + false, + ); + ensureGitignoreCodemapPattern(options.projectRoot); +} + /** * Copy bundled `rules/` and `skills/` into `/.agents/`, optional integrations, `.gitignore` hint. * **`--force`** deletes only template-backed files, then writes those files again with per-file copies — your other files under **`.agents/`**, **`rules/`**, or **`skills/`** stay. @@ -603,6 +620,7 @@ export async function runAgentsInit( if (options.gitHooks === "uninstall") { uninstallGitHooks(options.projectRoot); console.log(" Removed codemap blocks from git hooks"); + applyMaybeAgentsInitTargetsOnExisting(options); await maybeApplyAgentsInitMcp(options); return true; } @@ -635,22 +653,18 @@ export async function runAgentsInit( console.log( " Installed git hooks (post-commit, post-merge, post-checkout) for background codemap sync", ); + applyMaybeAgentsInitTargetsOnExisting(options); await maybeApplyAgentsInitMcp(options); return true; } if (options.mcp === true) { + applyMaybeAgentsInitTargetsOnExisting(options); await maybeApplyAgentsInitMcp(options); return true; } const targets = options.targets ?? []; if (targets.length > 0) { - applyAgentsInitTargets( - options.projectRoot, - targets, - options.linkMode ?? "symlink", - false, - ); - ensureGitignoreCodemapPattern(options.projectRoot); + applyMaybeAgentsInitTargetsOnExisting(options); await maybeApplyAgentsInitMcp(options); return true; } diff --git a/src/cli.test.ts b/src/cli.test.ts index 0b3443f1..6b69bb8a 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "bun:test"; import { existsSync, + lstatSync, mkdirSync, mkdtempSync, readFileSync, @@ -250,6 +251,335 @@ describe("CLI unknown / invalid args", () => { } }); + test("agents init --targets cursor --mcp writes Cursor MCP and rules only", async () => { + const dir = mkdtempSync( + join(tmpdir(), "codemap-cli-agents-targets-cursor-"), + ); + try { + const { exitCode, err } = await runCli([ + "--root", + dir, + "agents", + "init", + "--force", + "--targets", + "cursor", + "--mcp", + ]); + expect(exitCode).toBe(0); + expect(err).toBe(""); + expect(existsSync(join(dir, ".cursor", "mcp.json"))).toBe(true); + expect(existsSync(join(dir, ".cursor", "rules"))).toBe(true); + expect(existsSync(join(dir, ".mcp.json"))).toBe(false); + expect(existsSync(join(dir, ".continue"))).toBe(false); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test("agents init --targets cursor,copilot --mcp writes Cursor and VS Code MCP only", async () => { + const dir = mkdtempSync(join(tmpdir(), "codemap-cli-agents-targets-cc-")); + try { + const { exitCode, err } = await runCli([ + "--root", + dir, + "agents", + "init", + "--force", + "--targets", + "cursor,copilot", + "--mcp", + ]); + expect(exitCode).toBe(0); + expect(err).toBe(""); + expect(existsSync(join(dir, ".cursor", "mcp.json"))).toBe(true); + expect(existsSync(join(dir, ".vscode", "mcp.json"))).toBe(true); + expect(existsSync(join(dir, ".continue"))).toBe(false); + expect(existsSync(join(dir, ".mcp.json"))).toBe(false); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test("agents init --targets cursor --interactive exits 1", async () => { + const dir = mkdtempSync(join(tmpdir(), "codemap-cli-agents-targets-i-")); + try { + const { exitCode, err } = await runCli([ + "--root", + dir, + "agents", + "init", + "--targets", + "cursor", + "--interactive", + ]); + expect(exitCode).toBe(1); + expect(err).toContain("cannot be combined with --interactive"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test("agents init --targets nope exits 1 with valid ids", async () => { + const dir = mkdtempSync(join(tmpdir(), "codemap-cli-agents-targets-bad-")); + try { + const { exitCode, err } = await runCli([ + "--root", + dir, + "agents", + "init", + "--targets", + "nope", + ]); + expect(exitCode).toBe(1); + expect(err).toContain("unknown integration"); + expect(err).toContain("cursor"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test("agents init --targets windsurf --link-mode copy uses copies not symlinks", async () => { + const dir = mkdtempSync(join(tmpdir(), "codemap-cli-agents-targets-ws-")); + try { + const { exitCode, err } = await runCli([ + "--root", + dir, + "agents", + "init", + "--force", + "--targets", + "windsurf", + "--link-mode", + "copy", + ]); + expect(exitCode).toBe(0); + expect(err).toBe(""); + const rulePath = join(dir, ".windsurf", "rules", "codemap.md"); + expect(existsSync(rulePath)).toBe(true); + expect(lstatSync(rulePath).isSymbolicLink()).toBe(false); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test("agents init --targets copilot --link-mode copy exits 1", async () => { + const dir = mkdtempSync( + join(tmpdir(), "codemap-cli-agents-targets-lm-copilot-"), + ); + try { + const { exitCode, err } = await runCli([ + "--root", + dir, + "agents", + "init", + "--targets", + "copilot", + "--link-mode", + "copy", + ]); + expect(exitCode).toBe(1); + expect(err).toContain("--link-mode is only valid"); + expect(err).toContain("copilot"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test("agents init --link-mode symlink without --targets exits 1", async () => { + const dir = mkdtempSync( + join(tmpdir(), "codemap-cli-agents-targets-lm-alone-"), + ); + try { + const { exitCode, err } = await runCli([ + "--root", + dir, + "agents", + "init", + "--link-mode", + "symlink", + ]); + expect(exitCode).toBe(1); + expect(err).toContain("--link-mode is only valid"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test("agents init --targets without value exits 1", async () => { + const dir = mkdtempSync( + join(tmpdir(), "codemap-cli-agents-targets-empty-"), + ); + try { + const { exitCode, err } = await runCli([ + "--root", + dir, + "agents", + "init", + "--targets", + ]); + expect(exitCode).toBe(1); + expect(err).toContain("--targets requires"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test("agents init --targets cursor without --mcp wires rules only", async () => { + const dir = mkdtempSync( + join(tmpdir(), "codemap-cli-agents-targets-no-mcp-"), + ); + try { + const { exitCode, err } = await runCli([ + "--root", + dir, + "agents", + "init", + "--force", + "--targets", + "cursor", + ]); + expect(exitCode).toBe(0); + expect(err).toBe(""); + expect(existsSync(join(dir, ".cursor", "rules"))).toBe(true); + expect(existsSync(join(dir, ".cursor", "mcp.json"))).toBe(false); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test("agents init --targets claude-md --mcp writes root .mcp.json only", async () => { + const dir = mkdtempSync( + join(tmpdir(), "codemap-cli-agents-targets-claude-"), + ); + try { + const { exitCode, err } = await runCli([ + "--root", + dir, + "agents", + "init", + "--force", + "--targets", + "claude-md", + "--mcp", + ]); + expect(exitCode).toBe(0); + expect(err).toBe(""); + expect(existsSync(join(dir, ".mcp.json"))).toBe(true); + expect(existsSync(join(dir, ".cursor", "mcp.json"))).toBe(false); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test("agents init --force --mcp still writes full default MCP set", async () => { + const dir = mkdtempSync(join(tmpdir(), "codemap-cli-agents-mcp-defaults-")); + try { + const { exitCode, err } = await runCli([ + "--root", + dir, + "agents", + "init", + "--force", + "--mcp", + ]); + expect(exitCode).toBe(0); + expect(err).toBe(""); + expect(existsSync(join(dir, ".cursor", "mcp.json"))).toBe(true); + expect(existsSync(join(dir, ".vscode", "mcp.json"))).toBe(true); + expect(existsSync(join(dir, ".mcp.json"))).toBe(true); + expect( + existsSync(join(dir, ".continue", "mcpServers", "codemap-mcp.json")), + ).toBe(true); + expect(existsSync(join(dir, ".cline", "mcp.json"))).toBe(true); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test("agents init --targets copilot on existing .agents/ writes copilot instructions only", async () => { + const dir = mkdtempSync( + join(tmpdir(), "codemap-cli-agents-targets-copilot-"), + ); + try { + mkdirSync(join(dir, ".agents", "rules"), { recursive: true }); + mkdirSync(join(dir, ".agents", "skills", "codemap"), { + recursive: true, + }); + writeFileSync(join(dir, ".agents", "USER.md"), "keep", "utf-8"); + writeFileSync( + join(dir, ".agents", "rules", "codemap.md"), + "\n", + "utf-8", + ); + writeFileSync( + join(dir, ".agents", "skills", "codemap", "SKILL.md"), + "\n", + "utf-8", + ); + const { exitCode, err } = await runCli([ + "--root", + dir, + "agents", + "init", + "--targets", + "copilot", + ]); + expect(exitCode).toBe(0); + expect(err).toBe(""); + expect(readFileSync(join(dir, ".agents", "USER.md"), "utf-8")).toBe( + "keep", + ); + expect( + readFileSync(join(dir, ".github", "copilot-instructions.md"), "utf-8"), + ).toContain("Codemap"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test("agents init --git-hooks --targets cursor on existing .agents/ composes", async () => { + const dir = mkdtempSync( + join(tmpdir(), "codemap-cli-agents-hooks-targets-"), + ); + try { + mkdirSync(join(dir, ".agents", "rules"), { recursive: true }); + mkdirSync(join(dir, ".agents", "skills", "codemap"), { + recursive: true, + }); + mkdirSync(join(dir, ".git", "hooks"), { recursive: true }); + writeFileSync( + join(dir, ".agents", "rules", "codemap.md"), + "\n", + "utf-8", + ); + writeFileSync( + join(dir, ".agents", "skills", "codemap", "SKILL.md"), + "\n", + "utf-8", + ); + const { exitCode, err } = await runCli([ + "--root", + dir, + "agents", + "init", + "--git-hooks", + "--targets", + "cursor", + ]); + expect(exitCode).toBe(0); + expect(err).toBe(""); + expect( + isCodemapHookInstalled(join(dir, ".git", "hooks", "post-commit")), + ).toBe(true); + expect(existsSync(join(dir, ".cursor", "rules", "codemap.mdc"))).toBe( + true, + ); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + test("agents init --git-hooks --mcp on existing .agents/ composes side effects", async () => { const dir = mkdtempSync(join(tmpdir(), "codemap-cli-agents-combo-")); try { diff --git a/src/cli/bootstrap.ts b/src/cli/bootstrap.ts index e5986c0a..6d3304e8 100644 --- a/src/cli/bootstrap.ts +++ b/src/cli/bootstrap.ts @@ -33,7 +33,7 @@ Audit (structural drift — baseline snapshots or git ref): codemap audit [--baseline ] [--base ] [--json] [--ci] ... Agents: - codemap agents init [--force] [--interactive|-i] [--mcp] [--git-hooks] [--no-git-hooks] + codemap agents init [--force] [--interactive|-i] [--mcp] [--targets ] [--link-mode symlink|copy] [--git-hooks] [--no-git-hooks] codemap skill · codemap rule # live full markdown (pointer protocol) PR comment renderer (audit/SARIF → markdown summary): @@ -139,7 +139,7 @@ export function validateIndexModeArgs(rest: string[]): void { if (rest[0] === "agents") { if (rest[1] === "init") return; console.error( - `codemap: unknown agents command "${rest[1] ?? "(missing)"}". Expected: codemap agents init [--force] [--interactive|-i] [--mcp] [--git-hooks] [--no-git-hooks]`, + `codemap: unknown agents command "${rest[1] ?? "(missing)"}". Expected: codemap agents init [--force] [--interactive|-i] [--mcp] [--targets ] [--link-mode symlink|copy] [--git-hooks] [--no-git-hooks]`, ); process.exit(1); } diff --git a/src/cli/cmd-agents.test.ts b/src/cli/cmd-agents.test.ts new file mode 100644 index 00000000..8d703968 --- /dev/null +++ b/src/cli/cmd-agents.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "bun:test"; + +import { parseAgentsInitRest } from "./cmd-agents"; + +describe("parseAgentsInitRest", () => { + it("parses flags and targets", () => { + const r = parseAgentsInitRest([ + "--force", + "--targets", + "cursor,copilot", + "--mcp", + ]); + expect(r.kind).toBe("run"); + if (r.kind !== "run") return; + expect(r.force).toBe(true); + expect(r.targets).toEqual(["cursor", "copilot"]); + expect(r.mcp).toBe(true); + }); + + it("rejects --targets with empty value", () => { + const r = parseAgentsInitRest(["--targets"]); + expect(r.kind).toBe("error"); + if (r.kind === "error") { + expect(r.message).toContain("--targets requires a value"); + } + }); + + it("rejects --targets= with empty string", () => { + const r = parseAgentsInitRest(["--targets="]); + expect(r.kind).toBe("error"); + }); +}); diff --git a/src/cli/cmd-agents.ts b/src/cli/cmd-agents.ts index 1b2c47f8..8ef65c59 100644 --- a/src/cli/cmd-agents.ts +++ b/src/cli/cmd-agents.ts @@ -1,4 +1,10 @@ +import type { AgentsInitLinkMode } from "../agents-init"; import { runAgentsInit } from "../agents-init"; +import type { AgentsInitTarget } from "../agents-init-targets"; +import { + parseAgentsInitTargets, + targetsNeedLinkMode, +} from "../agents-init-targets"; function reportAgentsInitError(err: unknown): boolean { const message = @@ -8,17 +14,176 @@ function reportAgentsInitError(err: unknown): boolean { ? err : String(err); console.error( - message.startsWith("Codemap:") ? message : `Codemap: ${message}`, + message.startsWith("codemap:") ? message : `Codemap: ${message}`, ); return false; } +export type ParseAgentsInitRestResult = + | { + kind: "run"; + force: boolean; + interactive: boolean; + gitHooks?: "install" | "uninstall"; + mcp?: boolean; + targets?: AgentsInitTarget[]; + linkMode?: AgentsInitLinkMode; + } + | { kind: "error"; message: string }; + +const INIT_FLAGS_NO_VALUE = new Set([ + "--force", + "--interactive", + "-i", + "--mcp", + "--git-hooks", + "--no-git-hooks", + "--help", + "-h", +]); + +function consumeInitFlagValue( + rest: string[], + i: number, + flagName: string, +): string | { error: string } { + const next = rest[i + 1]; + if (next === undefined || next.startsWith("-")) { + return { error: `codemap: ${flagName} requires a value` }; + } + return next; +} + +/** + * Parse `codemap agents init` argv after `agents` and `init`. + * Does not handle `--help` (caller should short-circuit first). + */ +export function parseAgentsInitRest( + initRest: string[], +): ParseAgentsInitRestResult { + const targetsRaw: string[] = []; + let linkMode: AgentsInitLinkMode | undefined; + let force = false; + let interactive = false; + let mcp: boolean | undefined; + let gitHooks: "install" | "uninstall" | undefined; + + for (let i = 0; i < initRest.length; i++) { + const a = initRest[i]; + if (a === "--targets") { + const value = consumeInitFlagValue(initRest, i, "--targets"); + if (typeof value === "object" && "error" in value) { + return { kind: "error", message: value.error }; + } + targetsRaw.push(value); + i++; + continue; + } + if (a.startsWith("--targets=")) { + const value = a.slice("--targets=".length).trim(); + if (value.length === 0) { + return { + kind: "error", + message: "codemap: --targets requires at least one integration id", + }; + } + targetsRaw.push(value); + continue; + } + if (a === "--link-mode") { + const value = consumeInitFlagValue(initRest, i, "--link-mode"); + if (typeof value === "object" && "error" in value) { + return { kind: "error", message: value.error }; + } + if (value !== "symlink" && value !== "copy") { + return { + kind: "error", + message: `codemap: --link-mode must be symlink or copy (got ${JSON.stringify(value)})`, + }; + } + linkMode = value; + i++; + continue; + } + if (INIT_FLAGS_NO_VALUE.has(a)) { + if (a === "--force") force = true; + else if (a === "--interactive" || a === "-i") interactive = true; + else if (a === "--mcp") mcp = true; + else if (a === "--git-hooks") gitHooks = "install"; + else if (a === "--no-git-hooks") gitHooks = "uninstall"; + continue; + } + if (a.startsWith("-")) { + return { kind: "error", message: `codemap: unknown option "${a}"` }; + } + return { kind: "error", message: `codemap: unexpected argument "${a}"` }; + } + + if (gitHooks !== undefined && interactive) { + return { + kind: "error", + message: + "codemap: --git-hooks / --no-git-hooks cannot be combined with --interactive.", + }; + } + + let targets: AgentsInitTarget[] | undefined; + if (targetsRaw.length > 0) { + try { + targets = parseAgentsInitTargets(targetsRaw); + } catch (err) { + const message = + err instanceof Error + ? err.message + : typeof err === "string" + ? err + : String(err); + return { kind: "error", message }; + } + } + + if (interactive && targets !== undefined && targets.length > 0) { + return { + kind: "error", + message: + "codemap: --targets cannot be combined with --interactive. Use one selection mechanism per run.", + }; + } + + if (linkMode !== undefined) { + const needLink = targets !== undefined && targetsNeedLinkMode(targets); + if (!needLink) { + const mirrorIds = "cursor, windsurf, continue, cline, amazon-q"; + const hint = + targets === undefined || targets.length === 0 + ? `pass --targets with a rule-mirror integration (${mirrorIds})` + : `--targets (${targets.join(", ")}) has no rule-mirror integrations; use ${mirrorIds} with --link-mode`; + return { + kind: "error", + message: `codemap: --link-mode is only valid when ${hint}`, + }; + } + } + + return { + kind: "run", + force, + interactive, + gitHooks, + mcp, + targets, + linkMode, + }; +} + export async function runAgentsInitCmd(opts: { projectRoot: string; force: boolean; interactive: boolean; gitHooks?: "install" | "uninstall"; mcp?: boolean; + targets?: AgentsInitTarget[]; + linkMode?: AgentsInitLinkMode; }): Promise { try { if (opts.interactive) { @@ -37,6 +202,8 @@ export async function runAgentsInitCmd(opts: { force: opts.force, gitHooks: opts.gitHooks, mcp: opts.mcp, + targets: opts.targets, + linkMode: opts.linkMode, }); } catch (err) { return reportAgentsInitError(err); diff --git a/src/cli/main.ts b/src/cli/main.ts index dfccae66..bf4bdcee 100644 --- a/src/cli/main.ts +++ b/src/cli/main.ts @@ -59,61 +59,36 @@ export async function main(): Promise { if (rest[0] === "agents" && rest[1] === "init") { if (rest.includes("--help") || rest.includes("-h")) { - console.log(`Usage: codemap agents init [--force] [--interactive|-i] [--mcp] [--git-hooks] [--no-git-hooks] + console.log(`Usage: codemap agents init [--force] [--interactive|-i] [--mcp] [--targets ] [--link-mode symlink|copy] [--git-hooks] [--no-git-hooks] 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 PM-aware MCP config for supported IDEs (see docs/agents.md) + --mcp Write PM-aware MCP config (all defaults, or subset when --targets is set) + --targets Comma-separated integrations (cursor, copilot, claude-md, …); wires IDE mirrors + --link-mode symlink | copy — when --targets includes rule mirrors (default: symlink) --git-hooks Install background incremental index hooks (post-commit, post-merge, post-checkout) --no-git-hooks Remove codemap blocks from git hooks `); return; } const initRest = rest.slice(2); - const knownInit = new Set([ - "--force", - "--interactive", - "-i", - "--mcp", - "--git-hooks", - "--no-git-hooks", - "--help", - "-h", - ]); - for (const a of initRest) { - if (knownInit.has(a)) { - continue; - } - if (a.startsWith("-")) { - console.error(`codemap: unknown option "${a}"`); - } else { - console.error(`codemap: unexpected argument "${a}"`); - } + const { parseAgentsInitRest, runAgentsInitCmd } = + await import("./cmd-agents.js"); + const parsed = parseAgentsInitRest(initRest); + if (parsed.kind === "error") { + console.error(parsed.message); console.error("Run codemap agents init --help for usage."); process.exit(1); } - const { runAgentsInitCmd } = await import("./cmd-agents.js"); - const gitHooks = rest.includes("--no-git-hooks") - ? "uninstall" - : rest.includes("--git-hooks") - ? "install" - : undefined; - if ( - gitHooks !== undefined && - (rest.includes("--interactive") || rest.includes("-i")) - ) { - console.error( - "codemap: --git-hooks / --no-git-hooks cannot be combined with --interactive.", - ); - process.exit(1); - } const ok = await runAgentsInitCmd({ projectRoot: root, - force: rest.includes("--force"), - interactive: rest.includes("--interactive") || rest.includes("-i"), - gitHooks, - mcp: rest.includes("--mcp") ? true : undefined, + force: parsed.force, + interactive: parsed.interactive, + gitHooks: parsed.gitHooks, + mcp: parsed.mcp, + targets: parsed.targets, + linkMode: parsed.linkMode, }); if (!ok) process.exit(1); return; diff --git a/templates/agent-content/skill/10-recipes-context.md b/templates/agent-content/skill/10-recipes-context.md index c7266c79..53da516f 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:** 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. +**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`. In CI or sandboxes, add **`--targets cursor,copilot`** (comma-separated integration ids) to write only those IDE configs; combine with **`--link-mode copy`** when rule mirrors must be copies. 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).