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.
- package/dist/bin/religion.js +37 -2
- package/dist/lib/install.js +51 -2
- package/dist/lib/merge.js +119 -0
- package/package.json +1 -1
package/dist/bin/religion.js
CHANGED
|
@@ -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)
|
package/dist/lib/install.js
CHANGED
|
@@ -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
|
-
|
|
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
|
+
"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",
|