Skip to content

Commit e8ea640

Browse files
feat(agents): add --targets and --link-mode for non-interactive init (#158)
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.
1 parent 33cd88c commit e8ea640

13 files changed

Lines changed: 737 additions & 53 deletions

.changeset/agents-init-targets.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@stainless-code/codemap": patch
3+
---
4+
5+
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`.

docs/agents.md

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,12 +28,17 @@ codemap agents init
2828
codemap agents init --force
2929
codemap agents init --interactive # or -i; requires a TTY
3030
codemap agents init --mcp # project MCP config (default project-local targets; Windsurf opt-in via -i)
31+
codemap agents init --targets cursor,copilot --mcp # non-interactive: subset of integrations + MCP
32+
codemap agents init --targets copilot # Copilot instructions only (no MCP)
33+
codemap agents init --targets windsurf --link-mode copy # rule mirrors as copies (sandboxes / Windows)
3134
codemap agents init --git-hooks # opt-in background index on git events
3235
codemap agents init --no-git-hooks # remove codemap hook blocks
3336
```
3437

3538
- **`--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 **`<!-- codemap-init:managed -->`** 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).
36-
- **`--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.
39+
- **`--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`**.
40+
- **`--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.
41+
- **`--link-mode`**`symlink` or `copy`; only valid when **`--targets`** includes a rule-mirror integration (`cursor`, `windsurf`, `continue`, `cline`, `amazon-q`).
3742

3843
## Git and `.gitignore`
3944

@@ -149,7 +154,7 @@ Example: `CODEMAP_MCP_TOOLS=query,context,show codemap mcp --no-watch`
149154
| 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`) |
150155
| 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) |
151156

152-
With **`--mcp`** and no `--target` filter, all **project-local** rows above are written except **Windsurf**, which has no documented workspace MCP path.
157+
With **`--mcp`** and no **`--targets`** filter, all **project-local** rows above are written except **Windsurf**, which has no documented workspace MCP path.
153158

154159
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: …`).
155160

docs/roadmap.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ Long-running MCP / HTTP sessions dominate agent workflows; one-shot CLI keeps th
8383
- [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.
8484
- [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).
8585
- [ ] **`--mcp-invocation global|auto` flag** — explicit override to force global `codemap` on PATH vs PM-aware auto-resolve. Effort: S.
86+
- [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).
8687
- [ ] **`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.
8788
- [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).
8889

src/agents-init-targets.test.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { describe, expect, it } from "bun:test";
2+
3+
import {
4+
formatAgentsInitTargetIdsForError,
5+
parseAgentsInitTargets,
6+
} from "./agents-init-targets";
7+
8+
describe("parseAgentsInitTargets", () => {
9+
it("parses comma-separated and repeated segments", () => {
10+
expect(parseAgentsInitTargets(["cursor,copilot", "cursor"])).toEqual([
11+
"cursor",
12+
"copilot",
13+
]);
14+
});
15+
16+
it("rejects unknown ids with valid list", () => {
17+
expect(() => parseAgentsInitTargets(["nope"])).toThrow(
18+
/unknown integration/,
19+
);
20+
expect(() => parseAgentsInitTargets(["nope"])).toThrow(
21+
formatAgentsInitTargetIdsForError(),
22+
);
23+
});
24+
25+
it("rejects empty segments", () => {
26+
expect(() => parseAgentsInitTargets([","])).toThrow(
27+
/requires at least one integration id/,
28+
);
29+
});
30+
});

src/agents-init-targets.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,21 @@ export type AgentsInitTarget =
1313
| "agents-md"
1414
| "gemini-md";
1515

16+
/** Same order as interactive multiselect in `agents-init-interactive.ts`. */
17+
export const AGENTS_INIT_TARGET_IDS: readonly AgentsInitTarget[] = [
18+
"cursor",
19+
"claude-md",
20+
"copilot",
21+
"windsurf",
22+
"continue",
23+
"cline",
24+
"amazon-q",
25+
"agents-md",
26+
"gemini-md",
27+
] as const;
28+
29+
const AGENTS_INIT_TARGET_ID_SET = new Set<string>(AGENTS_INIT_TARGET_IDS);
30+
1631
/** Targets that mirror `.agents/rules` (and Cursor also `.agents/skills`) via per-file symlink or copy. */
1732
export const AGENTS_INIT_SYMLINK_TARGETS: readonly AgentsInitTarget[] = [
1833
"cursor",
@@ -25,3 +40,43 @@ export const AGENTS_INIT_SYMLINK_TARGETS: readonly AgentsInitTarget[] = [
2540
export function targetsNeedLinkMode(targets: AgentsInitTarget[]): boolean {
2641
return targets.some((t) => AGENTS_INIT_SYMLINK_TARGETS.includes(t));
2742
}
43+
44+
export function isAgentsInitTarget(id: string): id is AgentsInitTarget {
45+
return AGENTS_INIT_TARGET_ID_SET.has(id);
46+
}
47+
48+
export function formatAgentsInitTargetIdsForError(): string {
49+
return AGENTS_INIT_TARGET_IDS.join(", ");
50+
}
51+
52+
/**
53+
* Parse `--targets` argv segments (`cursor,copilot` or repeated flags).
54+
* Dedupes while preserving first-seen order.
55+
*/
56+
export function parseAgentsInitTargets(raw: string[]): AgentsInitTarget[] {
57+
const out: AgentsInitTarget[] = [];
58+
const seen = new Set<AgentsInitTarget>();
59+
for (const segment of raw) {
60+
for (const part of segment.split(",")) {
61+
const id = part.trim();
62+
if (id.length === 0) {
63+
throw new Error(
64+
"codemap: --targets requires at least one integration id",
65+
);
66+
}
67+
if (!isAgentsInitTarget(id)) {
68+
throw new Error(
69+
`codemap: unknown integration ${JSON.stringify(id)}. Valid ids: ${formatAgentsInitTargetIdsForError()}`,
70+
);
71+
}
72+
if (!seen.has(id)) {
73+
seen.add(id);
74+
out.push(id);
75+
}
76+
}
77+
}
78+
if (out.length === 0) {
79+
throw new Error("codemap: --targets requires at least one integration id");
80+
}
81+
return out;
82+
}

src/agents-init.test.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,76 @@ describe("runAgentsInit", () => {
215215
}
216216
});
217217

218+
it("runAgentsInit --git-hooks --targets cursor on existing .agents/ composes hooks and wiring", async () => {
219+
const dir = mkdtempSync(join(tmpdir(), "codemap-agents-"));
220+
try {
221+
mkdirSync(join(dir, ".agents", "rules"), { recursive: true });
222+
mkdirSync(join(dir, ".agents", "skills", "codemap"), {
223+
recursive: true,
224+
});
225+
mkdirSync(join(dir, ".git", "hooks"), { recursive: true });
226+
writeFileSync(
227+
join(dir, ".agents", "rules", "codemap.md"),
228+
`${CODMAP_INIT_MANAGED}\n`,
229+
"utf-8",
230+
);
231+
writeFileSync(
232+
join(dir, ".agents", "skills", "codemap", "SKILL.md"),
233+
`${CODMAP_INIT_MANAGED}\n`,
234+
"utf-8",
235+
);
236+
expect(
237+
await runAgentsInit({
238+
projectRoot: dir,
239+
gitHooks: "install",
240+
targets: ["cursor"],
241+
}),
242+
).toBe(true);
243+
expect(
244+
isCodemapHookInstalled(join(dir, ".git", "hooks", "post-commit")),
245+
).toBe(true);
246+
expect(existsSync(join(dir, ".cursor", "rules", "codemap.mdc"))).toBe(
247+
true,
248+
);
249+
} finally {
250+
rmSync(dir, { recursive: true, force: true });
251+
}
252+
});
253+
254+
it("runAgentsInit --mcp --targets cursor on existing .agents/ composes MCP and wiring", async () => {
255+
const dir = mkdtempSync(join(tmpdir(), "codemap-agents-"));
256+
try {
257+
mkdirSync(join(dir, ".agents", "rules"), { recursive: true });
258+
mkdirSync(join(dir, ".agents", "skills", "codemap"), {
259+
recursive: true,
260+
});
261+
writeFileSync(
262+
join(dir, ".agents", "rules", "codemap.md"),
263+
`${CODMAP_INIT_MANAGED}\n`,
264+
"utf-8",
265+
);
266+
writeFileSync(
267+
join(dir, ".agents", "skills", "codemap", "SKILL.md"),
268+
`${CODMAP_INIT_MANAGED}\n`,
269+
"utf-8",
270+
);
271+
expect(
272+
await runAgentsInit({
273+
projectRoot: dir,
274+
mcp: true,
275+
targets: ["cursor"],
276+
}),
277+
).toBe(true);
278+
expect(existsSync(join(dir, ".cursor", "mcp.json"))).toBe(true);
279+
expect(existsSync(join(dir, ".cursor", "rules", "codemap.mdc"))).toBe(
280+
true,
281+
);
282+
expect(existsSync(join(dir, ".mcp.json"))).toBe(false);
283+
} finally {
284+
rmSync(dir, { recursive: true, force: true });
285+
}
286+
});
287+
218288
it("runAgentsInit --no-git-hooks --mcp uninstalls hooks and writes MCP", async () => {
219289
const dir = mkdtempSync(join(tmpdir(), "codemap-agents-"));
220290
try {

src/agents-init.ts

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -592,6 +592,23 @@ async function maybeApplyAgentsInitMcp(
592592
}
593593
}
594594

595+
/** Side-effect-only path when `.agents/` exists and `--force` is off. */
596+
function applyMaybeAgentsInitTargetsOnExisting(
597+
options: AgentsInitOptions,
598+
): void {
599+
const targets = options.targets ?? [];
600+
if (targets.length === 0) {
601+
return;
602+
}
603+
applyAgentsInitTargets(
604+
options.projectRoot,
605+
targets,
606+
options.linkMode ?? "symlink",
607+
false,
608+
);
609+
ensureGitignoreCodemapPattern(options.projectRoot);
610+
}
611+
595612
/**
596613
* Copy bundled `rules/` and `skills/` into `<projectRoot>/.agents/`, optional integrations, `.gitignore` hint.
597614
* **`--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(
603620
if (options.gitHooks === "uninstall") {
604621
uninstallGitHooks(options.projectRoot);
605622
console.log(" Removed codemap blocks from git hooks");
623+
applyMaybeAgentsInitTargetsOnExisting(options);
606624
await maybeApplyAgentsInitMcp(options);
607625
return true;
608626
}
@@ -635,22 +653,18 @@ export async function runAgentsInit(
635653
console.log(
636654
" Installed git hooks (post-commit, post-merge, post-checkout) for background codemap sync",
637655
);
656+
applyMaybeAgentsInitTargetsOnExisting(options);
638657
await maybeApplyAgentsInitMcp(options);
639658
return true;
640659
}
641660
if (options.mcp === true) {
661+
applyMaybeAgentsInitTargetsOnExisting(options);
642662
await maybeApplyAgentsInitMcp(options);
643663
return true;
644664
}
645665
const targets = options.targets ?? [];
646666
if (targets.length > 0) {
647-
applyAgentsInitTargets(
648-
options.projectRoot,
649-
targets,
650-
options.linkMode ?? "symlink",
651-
false,
652-
);
653-
ensureGitignoreCodemapPattern(options.projectRoot);
667+
applyMaybeAgentsInitTargetsOnExisting(options);
654668
await maybeApplyAgentsInitMcp(options);
655669
return true;
656670
}

0 commit comments

Comments
 (0)