diff --git a/.claude/hooks/protect-generated-dirs.js b/.claude/hooks/protect-generated-dirs.js index 070c9a8..3792061 100644 --- a/.claude/hooks/protect-generated-dirs.js +++ b/.claude/hooks/protect-generated-dirs.js @@ -5,14 +5,18 @@ // wiped and rewritten on every sync." This hook enforces that rule at the tool // layer: it blocks Write/Edit-class tool calls whose target path lives inside a // generated directory (.claude/agents|skills|commands|rules|hooks/, -// .claude-plugin/, .cursor/, .codex/, .opencode/agents|skills|commands/), and -// points the author back at the source file under agents/, skills/, commands/, -// or rules/. +// .claude-plugin/, .cursor/agents|rules/, .codex/agents/skills/, +// .opencode/agents|skills|commands/), and points the author back at the source +// file under agents/, skills/, commands/, or rules/. // -// It deliberately does NOT block `.mcp.json` or `opencode.json`. Those sit -// outside every wiped namespace and may hold the user's own configuration — -// sync writes them when MCP sources exist but never deletes them (D20), so a -// hand-edit there is legitimate and must not be refused. +// The list names the wiped subdirectories exactly, never a parent harness +// directory, so the guard and the sync engine agree on what "generated" means. +// It therefore does NOT block what each harness keeps as per-user state at that +// top level — `.claude/settings.local.json`, `.cursor/environment.json`, +// `.codex/config.toml` — nor the three MCP config files (`.mcp.json`, +// `.cursor/mcp.json`, `opencode.json`), which sync writes when MCP sources exist +// but never deletes (D20). Sync preserves all of these, so a hand-edit is +// legitimate and must not be refused. // // This is agentry's pattern-proof hook — it demonstrates the hooks pipeline the // same way rules/typescript/strict-mode.md demonstrates the rules pipeline. @@ -45,8 +49,9 @@ const GENERATED = [ ".claude/rules/", ".claude/hooks/", ".claude-plugin/", - ".cursor/", - ".codex/", + ".cursor/agents/", + ".cursor/rules/", + ".codex/agents/skills/", ".opencode/agents/", ".opencode/skills/", ".opencode/commands/", diff --git a/README.md b/README.md index d73dbfe..64fff72 100644 --- a/README.md +++ b/README.md @@ -75,7 +75,7 @@ Two decisions do the load-bearing work. **Regeneration over editing.** `npm run sync` is idempotent: run it twice and you get a byte-identical tree. You never hand-edit the generated directories — the source directories are the only thing you touch. CI enforces this. After it runs sync, `git status --porcelain` must come back empty, so a pull request that edits a source file without committing the regenerated output fails the build. A `protect-generated-dirs` hook catches the mistake even earlier, at the moment you try to write the file. -**Wipe only what you own.** An adapter deletes only the subdirectories it generates — `.claude/agents/`, `.claude/skills/`, and so on — never the parent `.claude/`. The harness keeps per-user state at that top level (`settings.local.json` for Claude Code, `config.toml` for Codex, `opencode.json` for OpenCode), and clobbering it would be destructive. Each adapter cleans its own output and leaves the user's data alone. +**Wipe only what you own.** An adapter deletes only the subdirectories it generates — `.claude/agents/`, `.cursor/rules/`, `.codex/agents/skills/`, and so on — never the parent harness directory. Each harness keeps per-user state at that top level (`settings.local.json` for Claude Code, `environment.json` for Cursor, `config.toml` for Codex, `opencode.json` for OpenCode), and clobbering it would be destructive. The `protect-generated-dirs` hook is held to the same line: it blocks exactly the wiped subdirectories and nothing else, so the guard and the engine cannot disagree about what "generated" means. ``` agents/ skills/ commands/ rules/ hooks/ mcp/ ← source of truth (edit these) @@ -303,7 +303,7 @@ Other scripts: npm test ``` -239 tests run on Node's built-in test runner (`node:test`) with no external framework. They cover the transform layer — the part with real logic rather than file copying — and all seven hooks, whose contract is a process exit code rather than a return value: +243 tests run on Node's built-in test runner (`node:test`) with no external framework. They cover the transform layer — the part with real logic rather than file copying — and all seven hooks, whose contract is a process exit code rather than a return value: - `tests/frontmatter.test.js` — the shared frontmatter parser and validators: CRLF endings, an empty body, a missing block, array-shaped values like `tools: [Read, Grep]`, a description that contains a colon, and the required-field and description-length checks. - `tests/cursor-transform.test.js` — `toCursorRule` across the with-frontmatter, without-frontmatter, and already-declares-`alwaysApply` cases, body-spacing normalization, and `globs` injection / `globsForLanguage` mapping. diff --git a/hooks/protect-generated-dirs.js b/hooks/protect-generated-dirs.js index 070c9a8..3792061 100644 --- a/hooks/protect-generated-dirs.js +++ b/hooks/protect-generated-dirs.js @@ -5,14 +5,18 @@ // wiped and rewritten on every sync." This hook enforces that rule at the tool // layer: it blocks Write/Edit-class tool calls whose target path lives inside a // generated directory (.claude/agents|skills|commands|rules|hooks/, -// .claude-plugin/, .cursor/, .codex/, .opencode/agents|skills|commands/), and -// points the author back at the source file under agents/, skills/, commands/, -// or rules/. +// .claude-plugin/, .cursor/agents|rules/, .codex/agents/skills/, +// .opencode/agents|skills|commands/), and points the author back at the source +// file under agents/, skills/, commands/, or rules/. // -// It deliberately does NOT block `.mcp.json` or `opencode.json`. Those sit -// outside every wiped namespace and may hold the user's own configuration — -// sync writes them when MCP sources exist but never deletes them (D20), so a -// hand-edit there is legitimate and must not be refused. +// The list names the wiped subdirectories exactly, never a parent harness +// directory, so the guard and the sync engine agree on what "generated" means. +// It therefore does NOT block what each harness keeps as per-user state at that +// top level — `.claude/settings.local.json`, `.cursor/environment.json`, +// `.codex/config.toml` — nor the three MCP config files (`.mcp.json`, +// `.cursor/mcp.json`, `opencode.json`), which sync writes when MCP sources exist +// but never deletes (D20). Sync preserves all of these, so a hand-edit is +// legitimate and must not be refused. // // This is agentry's pattern-proof hook — it demonstrates the hooks pipeline the // same way rules/typescript/strict-mode.md demonstrates the rules pipeline. @@ -45,8 +49,9 @@ const GENERATED = [ ".claude/rules/", ".claude/hooks/", ".claude-plugin/", - ".cursor/", - ".codex/", + ".cursor/agents/", + ".cursor/rules/", + ".codex/agents/skills/", ".opencode/agents/", ".opencode/skills/", ".opencode/commands/", diff --git a/scripts/sync-harnesses.js b/scripts/sync-harnesses.js index 29c658f..ab3a4ba 100644 --- a/scripts/sync-harnesses.js +++ b/scripts/sync-harnesses.js @@ -277,11 +277,20 @@ async function syncClaude() { * Sync into .cursor/. Agents are copied verbatim with an `agentry-` prefix to * avoid collisions with the user's own Cursor agents. Skills are translated to * .mdc rules with `alwaysApply: false` (see toCursorRule). + * + * Only the generated subdirectories are wiped — the same "wipe what you own" + * discipline as syncClaude, syncCodex, and syncOpenCode. This adapter used to + * remove the whole .cursor/ tree, which deleted the user's own Cursor project + * state (`.cursor/environment.json` and anything else they keep there) on every + * sync, and made the `agentry-` prefix pointless: there was nothing left to + * collide with. `.cursor/mcp.json` is rewritten below when MCP sources exist + * and, like `.mcp.json` and `opencode.json`, is never deleted (D20). */ async function syncCursor() { console.log("\n[cursor]"); const cursorDir = path.join(REPO_ROOT, ".cursor"); - await rmGenerated(cursorDir); + await rmGenerated(path.join(cursorDir, "agents")); + await rmGenerated(path.join(cursorDir, "rules")); for (const entry of await readDirSafe(SOURCES.agents)) { if (entry.isFile() && entry.name.endsWith(".md")) { @@ -332,8 +341,9 @@ async function syncCursor() { // mcp/.json -> .cursor/mcp.json { "mcpServers": { ... } } // Cursor reads project MCP from .cursor/mcp.json, the same `mcpServers` shape - // as Claude. The whole .cursor/ tree was wiped at the top of this adapter, so - // any stale file is already gone — we simply (re)write when sources exist. + // as Claude. It sits outside the wiped agents/ and rules/ namespaces and may + // carry the user's own servers, so it is written when sources exist and never + // deleted — the same contract as .mcp.json and opencode.json (D20). const cursorServers = await loadMcpServers(); if (cursorServers.length) { await writeFile(path.join(cursorDir, "mcp.json"), toMcpServersJson(cursorServers)); diff --git a/tests/protect-generated-dirs.test.js b/tests/protect-generated-dirs.test.js index 9a5efa8..8d16cf1 100644 --- a/tests/protect-generated-dirs.test.js +++ b/tests/protect-generated-dirs.test.js @@ -83,8 +83,14 @@ describe("protect-generated-dirs: allows what is not generated", () => { // they may carry the user's own configuration. A hand-edit is legitimate. ".mcp.json", "opencode.json", - // The parent .opencode/ is not wiped — only its three generated subdirs are. + ".cursor/mcp.json", + // Only the generated subdirectories are wiped, never the parent harness + // directory — so whatever the user keeps at its top level is theirs. + // .cursor/environment.json is Cursor's own background-agent config. ".opencode/some-user-state.json", + ".cursor/environment.json", + ".claude/settings.local.json", + ".codex/config.toml", ]; for (const file of allowed) {