diff --git a/CHANGELOG.md b/CHANGELOG.md index dd97878..2c5b080 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,14 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Changed + +- **`forge sync` now adopts an existing project `CLAUDE.md` instead of skipping it.** Previously a + repo with its own `CLAUDE.md` was left untouched — which meant Forge's shared rules never + reached Claude Code there. Sync now prepends the one-line `@AGENTS.md` import (idempotent, + every original line preserved) and reports `adopted`. `AGENTS.md` keeps its back-up-then-write + behaviour; your skills and other tool files are untouched. + ### Fixed - **Secret-refusal no longer guts auth-related work.** `SECRET_RE` matched the bare words diff --git a/src/emit/claude.js b/src/emit/claude.js index 762f980..e00893d 100644 --- a/src/emit/claude.js +++ b/src/emit/claude.js @@ -1,19 +1,33 @@ // Claude Code reads CLAUDE.md, not AGENTS.md. Emit a thin CLAUDE.md that imports -// the shared source via @AGENTS.md (first line, Windows-safe). Never clobber a -// user's own unmanaged CLAUDE.md. +// the shared source via @AGENTS.md (first line, Windows-safe). If the repo already has its OWN +// CLAUDE.md, ADOPT it rather than skip: prepend the one-line @AGENTS.md import (idempotent) so +// Forge's shared rules actually reach Claude Code, while preserving every line the user wrote. +import { writeFileSync } from "node:fs"; import { join } from "node:path"; +const IMPORTS_AGENTS = /^\s*@AGENTS\.md\b/m; + export default { tool: "Claude Code", emit(ctx) { const path = join(ctx.targetRoot, "CLAUDE.md"); const existing = ctx.shared.readIfExists(path); + // A user's own (unmanaged) CLAUDE.md: adopt it by wiring in the shared import, non-destructively. if (existing !== null && !ctx.shared.isManaged(existing)) { + if (IMPORTS_AGENTS.test(existing)) { + return { + tool: this.tool, + target: "CLAUDE.md", + action: "unchanged", + note: "your CLAUDE.md already imports @AGENTS.md", + }; + } + writeFileSync(path, `@AGENTS.md\n\n${existing.replace(/^/, "")}`); return { tool: this.tool, target: "CLAUDE.md", - action: "skipped", - note: "existing unmanaged CLAUDE.md left as-is", + action: "adopted", + note: "prepended @AGENTS.md import to your existing CLAUDE.md (content preserved)", }; } const content = [ diff --git a/test/sync.test.js b/test/sync.test.js index c6c0f85..40b4db5 100644 --- a/test/sync.test.js +++ b/test/sync.test.js @@ -36,13 +36,20 @@ test("re-running is idempotent (nothing rewritten)", () => { assert.equal(written.length, 0, `second sync wrote ${written.map((r) => r.target)}`); }); -test("does not clobber an existing unmanaged CLAUDE.md", () => { +test("adopts an existing unmanaged CLAUDE.md: prepends @AGENTS.md, preserves content, idempotent", () => { const root = fixture(); - writeFileSync(join(root, "CLAUDE.md"), "# my own claude file\n"); + writeFileSync(join(root, "CLAUDE.md"), "# my own claude file\nkeep me\n"); const res = sync({ targetRoot: root }); const row = res.report.find((r) => r.target === "CLAUDE.md"); - assert.equal(row.action, "skipped"); - assert.match(readFileSync(join(root, "CLAUDE.md"), "utf8"), /my own claude file/); + assert.equal(row.action, "adopted", "existing CLAUDE.md is adopted, not skipped or clobbered"); + const after = readFileSync(join(root, "CLAUDE.md"), "utf8"); + assert.match(after, /^@AGENTS\.md/m, "shared import wired in"); + assert.match(after, /my own claude file/, "original content preserved"); + assert.match(after, /keep me/); + // Re-running does not re-prepend or rewrite. + const again = sync({ targetRoot: root }); + assert.equal(again.report.find((r) => r.target === "CLAUDE.md").action, "unchanged"); + assert.equal((after.match(/@AGENTS\.md/g) || []).length, 1, "import appears exactly once"); }); test("warns when a legacy .cursorrules would shadow AGENTS.md", () => {