create-religion 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -86,16 +86,33 @@ async function runInstall(options) {
86
86
  create: plan.filter((p) => p.action === "create").length,
87
87
  update: plan.filter((p) => p.action === "update").length,
88
88
  conflict: plan.filter((p) => p.action === "conflict").length,
89
- kept: plan.filter((p) => p.action === "seed-skip").length
89
+ kept: plan.filter((p) => p.action === "seed-skip").length,
90
+ merge: plan.filter((p) => p.action === "merge").length,
91
+ remerge: plan.filter((p) => p.action === "remerge").length
90
92
  };
91
93
  console.log(`\nAdapters: ${adapters.map((a) => ADAPTERS[a].label).join(", ")}`);
92
94
  console.log(` create ${counts.create}`);
93
95
  console.log(` update ${counts.update}`);
94
96
  console.log(` keep ${counts.kept} (your files, never overwritten)`);
95
97
  console.log(` conflict ${counts.conflict}`);
98
+ if (counts.merge > 0)
99
+ console.log(` merge ${counts.merge} (your file, awaiting a decision)`);
100
+ if (counts.remerge > 0)
101
+ console.log(` merged ${counts.remerge} (your sections kept)`);
96
102
  for (const entry of plan.filter((p) => p.action === "conflict")) {
97
103
  console.log(` ${entry.relative}`);
98
104
  }
105
+ const mergeable = plan.filter((p) => p.action === "merge").map((p) => p.relative);
106
+ let merge = false;
107
+ if (mergeable.length > 0) {
108
+ console.log(`\nThese already exist and were written by you:`);
109
+ for (const relative of mergeable)
110
+ console.log(` ${relative}`);
111
+ console.log("\nReligion can append its own sections to them inside markers, keeping everything" +
112
+ "\nyou wrote exactly where it is. Later updates then replace only what is between" +
113
+ "\nthose markers. The originals are backed up either way.");
114
+ merge = await confirmMerge(options.yes);
115
+ }
99
116
  if (options.dryRun) {
100
117
  console.log("\nDry run. Nothing written.");
101
118
  return;
@@ -103,9 +120,12 @@ async function runInstall(options) {
103
120
  if (plan.length === 0) {
104
121
  throw new Error(`No template files found under ${templateRoot}. Nothing would be installed, which is never correct.`);
105
122
  }
106
- const result = await applyInstall(templateRoot, target, plan, { force: options.force });
123
+ const result = await applyInstall(templateRoot, target, plan, { force: options.force, merge });
107
124
  await writeManifest(target, await version(), adapters, templateRoot, previous, result.conflicts);
108
125
  console.log(`\nWrote ${result.written.length} file(s).`);
126
+ if (result.merged.length > 0) {
127
+ console.log(`Merged ${result.merged.length} entry file(s), keeping what you wrote.`);
128
+ }
109
129
  if (result.backups.length > 0)
110
130
  console.log(`Backed up ${result.backups.length} conflicting file(s).`);
111
131
  if (result.conflicts.length > 0) {
@@ -126,6 +146,21 @@ async function runInstall(options) {
126
146
  console.log("\nNext: run the setup skill in your AI tool, then fill in the two plans.");
127
147
  }
128
148
  }
149
+ /**
150
+ * Ask before rewriting a file the user wrote.
151
+ *
152
+ * Defaults to yes because the merge is additive and reversible: nothing of theirs moves and
153
+ * the original is backed up first. Declining leaves the file untouched and reported as a
154
+ * conflict, which is the old behaviour.
155
+ */
156
+ async function confirmMerge(assumeYes) {
157
+ if (assumeYes || !process.stdin.isTTY)
158
+ return assumeYes;
159
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
160
+ const answer = (await rl.question("\nMerge them? [Y/n] ")).trim().toLowerCase();
161
+ rl.close();
162
+ return answer === "" || answer === "y" || answer === "yes";
163
+ }
129
164
  async function chooseAdapters(assumeYes) {
130
165
  const all = Object.keys(ADAPTERS);
131
166
  if (assumeYes || !process.stdin.isTTY)
@@ -10,6 +10,7 @@ import crypto from "node:crypto";
10
10
  import fs from "node:fs/promises";
11
11
  import path from "node:path";
12
12
  import { exists, STATE_DIR } from "./paths.js";
13
+ import { hasDamagedMarkers, hasMarkers, replaceManagedBlock, spliceEntry } from "./merge.js";
13
14
  export const ADAPTERS = {
14
15
  claude: { label: "Claude Code", trees: [".claude/skills", ".claude/hooks"], entry: ["CLAUDE.md"] },
15
16
  codex: { label: "Codex", trees: [".agents/skills"], entry: ["AGENTS.md"] },
@@ -24,8 +25,13 @@ export function hash(contents) {
24
25
  export function isManaged(relative) {
25
26
  return !relative.startsWith(`${STATE_DIR}/`);
26
27
  }
28
+ /** The entry files for the chosen adapters, which are the only files merging applies to. */
29
+ function entryFiles(adapters) {
30
+ return new Set(adapters.flatMap((a) => ADAPTERS[a].entry));
31
+ }
27
32
  export async function planInstall(templateRoot, target, adapters, previous) {
28
33
  const wanted = wantedPaths(adapters);
34
+ const entries = entryFiles(adapters);
29
35
  const plan = [];
30
36
  for (const relative of await walk(templateRoot)) {
31
37
  if (!wanted(relative))
@@ -50,7 +56,22 @@ export async function planInstall(templateRoot, target, adapters, previous) {
50
56
  // installed. Anything else is a local edit, and overwriting it silently is the failure
51
57
  // this manifest exists to prevent.
52
58
  const recorded = previous?.managed[relative];
53
- plan.push({ relative, action: recorded && recorded === hash(current) ? "update" : "conflict" });
59
+ if (recorded && recorded === hash(current)) {
60
+ plan.push({ relative, action: "update" });
61
+ continue;
62
+ }
63
+ // An entry file is the one place where "differs from the template" is the normal case
64
+ // rather than a problem: a repository that already tells agents how to work has one, and
65
+ // it is usually the most carefully written file in the project.
66
+ if (entries.has(relative)) {
67
+ const text = current.toString("utf8");
68
+ // A damaged marker pair is a conflict, never a fresh merge: splicing a second block
69
+ // into a file that already has one is worse than the damage.
70
+ const action = hasMarkers(text) ? "remerge" : hasDamagedMarkers(text) ? "conflict" : "merge";
71
+ plan.push({ relative, action });
72
+ continue;
73
+ }
74
+ plan.push({ relative, action: "conflict" });
54
75
  }
55
76
  return plan;
56
77
  }
@@ -58,6 +79,7 @@ export async function applyInstall(templateRoot, target, plan, options) {
58
79
  const written = [];
59
80
  const conflicts = [];
60
81
  const backups = [];
82
+ const merged = [];
61
83
  for (const entry of plan) {
62
84
  if (entry.action === "unchanged" || entry.action === "seed-skip")
63
85
  continue;
@@ -65,6 +87,33 @@ export async function applyInstall(templateRoot, target, plan, options) {
65
87
  conflicts.push(entry.relative);
66
88
  continue;
67
89
  }
90
+ // Replacing only what is between the markers is the whole point of having merged: the
91
+ // user's own prose sits outside them and is never read, let alone rewritten.
92
+ if (entry.action === "remerge" || (entry.action === "merge" && options.merge)) {
93
+ const destination = path.join(target, entry.relative);
94
+ const template = await fs.readFile(path.join(templateRoot, entry.relative), "utf8");
95
+ const current = await fs.readFile(destination, "utf8");
96
+ if (entry.action === "merge") {
97
+ const backup = path.join(target, STATE_DIR, ".state", "backups", entry.relative);
98
+ await fs.mkdir(path.dirname(backup), { recursive: true });
99
+ await fs.copyFile(destination, backup);
100
+ backups.push(entry.relative);
101
+ }
102
+ const next = entry.action === "merge" ? spliceEntry(current, template) : replaceManagedBlock(current, template);
103
+ if (next === null) {
104
+ // Markers went missing between planning and writing, or were edited into an order
105
+ // this cannot reason about. Leaving it alone is the only safe answer.
106
+ conflicts.push(entry.relative);
107
+ continue;
108
+ }
109
+ await fs.writeFile(destination, next, "utf8");
110
+ merged.push(entry.relative);
111
+ continue;
112
+ }
113
+ if (entry.action === "merge") {
114
+ conflicts.push(entry.relative);
115
+ continue;
116
+ }
68
117
  const destination = path.join(target, entry.relative);
69
118
  if (entry.action === "conflict") {
70
119
  const backup = path.join(target, STATE_DIR, ".state", "backups", entry.relative);
@@ -76,7 +125,7 @@ export async function applyInstall(templateRoot, target, plan, options) {
76
125
  await fs.copyFile(path.join(templateRoot, entry.relative), destination);
77
126
  written.push(entry.relative);
78
127
  }
79
- return { written, conflicts, backups };
128
+ return { written, conflicts, backups, merged };
80
129
  }
81
130
  /**
82
131
  * Records what was *installed*, not what is currently on disk.
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Merging Religion's entry-file sections into a file the project already had.
3
+ *
4
+ * A repository that already carries agent instructions is the normal case, not the odd one,
5
+ * and the previous behaviour left those installs with the workflow on disk but nothing
6
+ * telling the agent it existed. Overwriting was never an option: those files are often the
7
+ * most carefully written thing in the repository.
8
+ *
9
+ * So the user's file stays exactly where it is and Religion's sections are appended inside
10
+ * markers. The markers are what make the result maintainable rather than a one-time paste:
11
+ * a later update replaces what is between them and never reads what is outside.
12
+ *
13
+ * Two sections are deliberately left outside the block. "What this is" is what the user's own
14
+ * file already says, and Commands is filled in by the setup skill with the project's real
15
+ * commands. Both would be destroyed by an update that replaced them.
16
+ */
17
+ export const MANAGED_START = "<!-- religion:managed:start -->";
18
+ export const MANAGED_END = "<!-- religion:managed:end -->";
19
+ /** Sections the user owns. They never enter the managed block. */
20
+ const USER_OWNED = new Set(["What this is", "Commands"]);
21
+ const BLOCK_NOTE = "Everything between these markers is generated by Religion and replaced on update.\n" +
22
+ "Edit the files it points at, or the sections outside this block, rather than editing here.";
23
+ /** Split a rendered entry file into its leading lines and its `## ` sections. */
24
+ function parse(entry) {
25
+ const lines = entry.split(/\r?\n/);
26
+ const lead = [];
27
+ const sections = [];
28
+ let current = null;
29
+ for (const line of lines) {
30
+ const heading = /^## (.+)$/.exec(line);
31
+ if (heading?.[1]) {
32
+ current = { heading: heading[1].trim(), body: "" };
33
+ sections.push(current);
34
+ continue;
35
+ }
36
+ if (current)
37
+ current.body += line + "\n";
38
+ else
39
+ lead.push(line);
40
+ }
41
+ return { lead, sections };
42
+ }
43
+ function render(sections) {
44
+ return sections.map((s) => `## ${s.heading}\n${s.body.replace(/\n+$/, "")}\n`).join("\n");
45
+ }
46
+ /**
47
+ * The part of a rendered entry file that Religion owns.
48
+ *
49
+ * Includes the import lines, which is what makes a merged install keep receiving new context
50
+ * files: leaving them outside the block would freeze the set of loaded files at whatever
51
+ * shipped on the day of the merge.
52
+ */
53
+ export function managedBlock(templateEntry) {
54
+ const { lead, sections } = parse(templateEntry);
55
+ const imports = lead.filter((line) => line.startsWith("@")).join("\n");
56
+ const owned = sections.filter((s) => !USER_OWNED.has(s.heading));
57
+ return [
58
+ MANAGED_START,
59
+ "",
60
+ BLOCK_NOTE,
61
+ "",
62
+ ...(imports ? [imports, ""] : []),
63
+ render(owned).replace(/\n+$/, ""),
64
+ "",
65
+ MANAGED_END
66
+ ].join("\n");
67
+ }
68
+ /** The sections the user owns, taken from the template as a starting point. */
69
+ export function userOwnedSections(templateEntry) {
70
+ const { sections } = parse(templateEntry);
71
+ const owned = sections.filter((s) => USER_OWNED.has(s.heading) && s.heading !== "What this is");
72
+ return render(owned).replace(/\n+$/, "");
73
+ }
74
+ export function hasMarkers(contents) {
75
+ return contents.includes(MANAGED_START) && contents.includes(MANAGED_END);
76
+ }
77
+ /**
78
+ * A file carrying one marker but not both, or them in the wrong order.
79
+ *
80
+ * This must be told apart from a file that was never merged. Treating it as unmerged splices
81
+ * a second block into a file that already had one, which is a worse state than the damage it
82
+ * was trying to repair.
83
+ */
84
+ export function hasDamagedMarkers(contents) {
85
+ const start = contents.indexOf(MANAGED_START);
86
+ const end = contents.indexOf(MANAGED_END);
87
+ if (start < 0 && end < 0)
88
+ return false;
89
+ return start < 0 || end < 0 || end < start;
90
+ }
91
+ /**
92
+ * Build the merged file: their content, then Religion's block, then the sections they own.
93
+ *
94
+ * Nothing of theirs moves. That is the property that makes this safe to accept without
95
+ * reading the diff, and the reason the block is appended rather than woven in.
96
+ */
97
+ export function spliceEntry(existing, templateEntry) {
98
+ const body = existing.replace(/\n+$/, "");
99
+ const commands = userOwnedSections(templateEntry);
100
+ const parts = [body, "", managedBlock(templateEntry)];
101
+ // Only add Commands when their file has no section by that name; theirs is more accurate
102
+ // than the placeholder, and setup will find either one.
103
+ if (commands && !/^## Commands\s*$/m.test(existing))
104
+ parts.push("", commands);
105
+ return parts.join("\n") + "\n";
106
+ }
107
+ /**
108
+ * Replace the managed block in a merged file, leaving everything outside it untouched.
109
+ *
110
+ * Returns null when the markers are missing or out of order, which is treated as a conflict
111
+ * rather than repaired: a file whose markers were edited is one whose owner had a reason.
112
+ */
113
+ export function replaceManagedBlock(existing, templateEntry) {
114
+ const start = existing.indexOf(MANAGED_START);
115
+ const end = existing.indexOf(MANAGED_END);
116
+ if (start < 0 || end < 0 || end < start)
117
+ return null;
118
+ return existing.slice(0, start) + managedBlock(templateEntry) + existing.slice(end + MANAGED_END.length);
119
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-religion",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "A file-backed, spec-driven AI development workflow. Spec before code, small reviewed diffs, and project state in markdown so a cleared context costs nothing.",
5
5
  "license": "MIT",
6
6
  "author": "Nate",