Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 14 additions & 9 deletions .claude/hooks/protect-generated-dirs.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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/",
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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.
Expand Down
23 changes: 14 additions & 9 deletions hooks/protect-generated-dirs.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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/",
Expand Down
16 changes: 13 additions & 3 deletions scripts/sync-harnesses.js
Original file line number Diff line number Diff line change
Expand Up @@ -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")) {
Expand Down Expand Up @@ -332,8 +341,9 @@ async function syncCursor() {

// mcp/<name>.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));
Expand Down
8 changes: 7 additions & 1 deletion tests/protect-generated-dirs.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down