the-grimoire-cli 0.3.2 → 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.
Files changed (95) hide show
  1. package/.agents/AGENTS.md +112 -112
  2. package/.agents/NAVIGATOR.md +188 -168
  3. package/.agents/VERSION +4 -4
  4. package/.agents/agents/INDEX.md +7 -7
  5. package/.agents/agents/verifier.md +50 -50
  6. package/.agents/commands/INDEX.md +11 -11
  7. package/.agents/commands/checkpoint.md +15 -15
  8. package/.agents/commands/grimoire.md +14 -14
  9. package/.agents/commands/onboard.md +56 -56
  10. package/.agents/commands/present.md +23 -23
  11. package/.agents/commands/verify.md +20 -20
  12. package/.agents/grimoire.manifest +18 -18
  13. package/.agents/rules/00-always.md +42 -42
  14. package/.agents/rules/05-code-quality.md +28 -28
  15. package/.agents/rules/10-working-process.md +31 -31
  16. package/.agents/rules/15-skills.md +27 -27
  17. package/.agents/rules/20-modes.md +41 -41
  18. package/.agents/rules/25-surgical-changes.md +29 -29
  19. package/.agents/rules/30-verification.md +36 -36
  20. package/.agents/rules/35-context-economy.md +41 -41
  21. package/.agents/rules/40-handoff.md +25 -25
  22. package/.agents/rules/45-presentation.md +35 -35
  23. package/.agents/rules/50-security.md +30 -30
  24. package/.agents/rules/60-commit-style.md +14 -14
  25. package/.agents/rules/INDEX.md +18 -18
  26. package/.agents/skills/INDEX.md +8 -8
  27. package/.agents/skills/README.md +1 -1
  28. package/.agents/skills/catalog.md +106 -106
  29. package/.agents/skills/find-skills/SKILL.md +142 -142
  30. package/.agents/stack/README.md +69 -66
  31. package/.agents/stack/desktop.md +36 -36
  32. package/.agents/stack/library.md +1 -1
  33. package/.agents/stack/web-app.md +32 -32
  34. package/.agents/standards/INDEX.md +23 -23
  35. package/.agents/standards/accessibility.md +50 -50
  36. package/.agents/standards/architecture.md +39 -39
  37. package/.agents/standards/attribution.md +39 -39
  38. package/.agents/standards/clean-code.md +121 -121
  39. package/.agents/standards/codex.md +69 -69
  40. package/.agents/standards/error-codes.md +41 -41
  41. package/.agents/standards/general.md +46 -46
  42. package/.agents/standards/guardrail-tests.md +40 -40
  43. package/.agents/standards/knowledge-management.md +35 -35
  44. package/.agents/standards/launch-security-checklist.md +45 -45
  45. package/.agents/standards/observability.md +35 -35
  46. package/.agents/standards/release-versioning.md +53 -53
  47. package/.agents/standards/requirements.md +75 -75
  48. package/.agents/standards/security-scanners.md +42 -42
  49. package/.agents/standards/testing-strategy.md +61 -61
  50. package/.agents/standards/typescript.md +19 -19
  51. package/.agents/standards/writing.md +58 -58
  52. package/.agents/tooling.json +19 -19
  53. package/LICENSE +1 -1
  54. package/README.md +139 -139
  55. package/bin/grimoire.mjs +630 -598
  56. package/package.json +32 -32
  57. package/templates/CLAUDE.md +7 -7
  58. package/templates/ci/ci.yml +49 -49
  59. package/templates/ci/sast.yml +44 -44
  60. package/templates/codex/INDEX.md +18 -18
  61. package/templates/codex/README.md +28 -28
  62. package/templates/codex/decisions/0000-template.md +36 -36
  63. package/templates/codex/decisions/INDEX.md +11 -11
  64. package/templates/codex/decisions/README.md +25 -25
  65. package/templates/codex/domain/INDEX.md +14 -14
  66. package/templates/codex/domain/README.md +10 -10
  67. package/templates/codex/evidence/0000-extraction-template.md +36 -36
  68. package/templates/codex/evidence/INDEX.md +11 -11
  69. package/templates/codex/evidence/README.md +15 -15
  70. package/templates/codex/reference/INDEX.md +11 -11
  71. package/templates/codex/reference/README.md +15 -15
  72. package/templates/codex/reference/confirmed-values.md +18 -18
  73. package/templates/codex/requirements/INDEX.md +11 -11
  74. package/templates/codex/requirements/README.md +22 -22
  75. package/templates/codex/requirements/addons/0000-template.md +35 -35
  76. package/templates/codex/requirements/base.md +36 -36
  77. package/templates/codex/requirements/changes/0000-template.md +39 -39
  78. package/templates/codex/resources/INDEX.md +11 -11
  79. package/templates/codex/resources/README.md +17 -17
  80. package/templates/codex/resources/manifest.md +11 -11
  81. package/templates/codex/runbooks/INDEX.md +9 -9
  82. package/templates/codex/runbooks/README.md +8 -8
  83. package/templates/codex/runbooks/incident-runbook-template.md +58 -58
  84. package/templates/gitignore-snippet.txt +10 -12
  85. package/templates/journal/backlog/README.md +18 -18
  86. package/templates/journal/memory/MEMORY.md +15 -15
  87. package/templates/journal/session/archive/.gitkeep +1 -1
  88. package/templates/journal/session/artifacts/.gitkeep +1 -1
  89. package/templates/journal/session/current.md +12 -12
  90. package/templates/lint/README.md +25 -25
  91. package/templates/lint/eslint.config.mjs +33 -33
  92. package/templates/lint/tsconfig.base.json +11 -11
  93. package/templates/local/AGENTS.local.md +33 -33
  94. package/templates/local/README.md +55 -55
  95. package/templates/tests/guardrail.invariants.test.ts +59 -59
package/bin/grimoire.mjs CHANGED
@@ -1,598 +1,630 @@
1
- #!/usr/bin/env node
2
- // Grimoire CLI — init a project with the agent template, or sync template updates.
3
- // Self-contained, no deps. Node >=18.
4
-
5
- import { fileURLToPath } from "node:url";
6
- import path from "node:path";
7
- import fs from "node:fs";
8
- import os from "node:os";
9
- import { execFileSync } from "node:child_process";
10
-
11
- const __dirname = path.dirname(fileURLToPath(import.meta.url));
12
- const TEMPLATE_ROOT = path.resolve(__dirname, "..");
13
- const TEMPLATE_AGENTS = path.join(TEMPLATE_ROOT, ".agents");
14
- const TEMPLATES_DIR = path.join(TEMPLATE_ROOT, "templates");
15
-
16
- function log(msg) { process.stdout.write(msg + "\n"); }
17
- function fail(msg) { process.stderr.write("grimoire: " + msg + "\n"); process.exit(1); }
18
-
19
- function parseArgs(argv) {
20
- const out = { cmd: argv[0], dir: process.cwd(), apply: false, check: false };
21
- for (let i = 1; i < argv.length; i++) {
22
- if (argv[i] === "--dir") out.dir = path.resolve(argv[++i]);
23
- else if (argv[i] === "--apply") out.apply = true;
24
- else if (argv[i] === "--dry-run") out.apply = false;
25
- else if (argv[i] === "--check") out.check = true;
26
- }
27
- return out;
28
- }
29
-
30
- function readTooling() {
31
- return JSON.parse(fs.readFileSync(path.join(TEMPLATE_AGENTS, "tooling.json"), "utf8"));
32
- }
33
-
34
- // Optional project-owned tooling at local/tooling.json (same shape as the base:
35
- // { plugins, skills, mcp }). Lets a project declare its own plugins / MCP (Linear, Sentry,
36
- // Supabase, Figma, …) without bloating the base. Returns null if absent/invalid (doctor flags it).
37
- function readLocalTooling(dir) {
38
- const file = path.join(dir, "local", "tooling.json");
39
- if (!fs.existsSync(file)) return null;
40
- try { return JSON.parse(fs.readFileSync(file, "utf8")); } catch { return null; }
41
- }
42
-
43
- // Base ∪ local tooling: base wins on a key conflict; local entries with a new key are added.
44
- function mergedTooling(dir) {
45
- const base = readTooling();
46
- const local = readLocalTooling(dir);
47
- if (!local) return base;
48
- const merge = (a = [], b = [], key) => {
49
- const m = new Map();
50
- for (const x of a) m.set(key(x), x);
51
- for (const x of b) if (!m.has(key(x))) m.set(key(x), x);
52
- return [...m.values()];
53
- };
54
- return {
55
- plugins: merge(base.plugins, local.plugins, pluginKey),
56
- skills: merge(base.skills, local.skills, (s) => s.name),
57
- mcp: merge(base.mcp, local.mcp, (m) => m.name),
58
- };
59
- }
60
-
61
- function claudeSettingsPath() {
62
- return path.join(os.homedir(), ".claude", "settings.json");
63
- }
64
-
65
- function readSettings(p) {
66
- try { return JSON.parse(fs.readFileSync(p, "utf8")); } catch { return {}; }
67
- }
68
-
69
- function pluginKey(pl) { return `${pl.name}@${pl.marketplace}`; }
70
-
71
- function missingPlugins(tooling, settings) {
72
- const enabled = settings.enabledPlugins || {};
73
- return (tooling.plugins || []).filter((pl) => !enabled[pluginKey(pl)]);
74
- }
75
-
76
- function applyPlugins(sp, settings, missing) {
77
- if (missing.length === 0) return;
78
- if (fs.existsSync(sp)) fs.copyFileSync(sp, sp + ".bak");
79
- settings.enabledPlugins = settings.enabledPlugins || {};
80
- for (const pl of missing) settings.enabledPlugins[pluginKey(pl)] = true; // add only
81
- fs.mkdirSync(path.dirname(sp), { recursive: true });
82
- fs.writeFileSync(sp, JSON.stringify(settings, null, 2) + "\n");
83
- }
84
-
85
- // Collect unresolved ${ENV} placeholders anywhere in a server definition (env for stdio servers,
86
- // headers/url for http servers, etc.). Recurses over strings so transport shape does not matter.
87
- function unresolvedEnv(node, out) {
88
- if (typeof node === "string") {
89
- for (const m of node.matchAll(/\$\{(\w+)\}/g)) if (!process.env[m[1]]) out.push(m[1]);
90
- } else if (Array.isArray(node)) {
91
- for (const v of node) unresolvedEnv(v, out);
92
- } else if (node && typeof node === "object") {
93
- for (const v of Object.values(node)) unresolvedEnv(v, out);
94
- }
95
- return out;
96
- }
97
-
98
- // Additively merge MCP servers from tooling.json into the project .mcp.json. Never clobbers
99
- // an existing server definition. Returns the names added + any unresolved ${ENV} placeholders.
100
- function mergeMcp(target, tooling) {
101
- const file = path.join(target, ".mcp.json");
102
- const cur = fs.existsSync(file) ? JSON.parse(fs.readFileSync(file, "utf8")) : {};
103
- cur.mcpServers = cur.mcpServers || {};
104
- const added = [];
105
- const needsEnv = [];
106
- for (const m of tooling.mcp || []) {
107
- if (cur.mcpServers[m.name]) continue;
108
- cur.mcpServers[m.name] = m.server;
109
- added.push(m.name);
110
- unresolvedEnv(m.server, needsEnv);
111
- }
112
- if (added.length) fs.writeFileSync(file, JSON.stringify(cur, null, 2) + "\n");
113
- return { added, needsEnv };
114
- }
115
-
116
- // Safety net for adopting a project that already has an .agents/: copy the whole
117
- // tree to a sibling .agents.bak-<stamp>/ before init overwrites managed paths.
118
- // Returns the backup path, or null if there was nothing to back up.
119
- function backupAgents(destAgents) {
120
- if (!fs.existsSync(destAgents)) return null;
121
- const stamp = new Date().toISOString().replace(/[:.]/g, "-");
122
- const bak = `${destAgents}.bak-${stamp}`;
123
- fs.cpSync(destAgents, bak, { recursive: true });
124
- return bak;
125
- }
126
-
127
- // One-time migration from the old layout (.agents/{memory,backlog,session,local}) to the new root
128
- // layout (journal/{memory,backlog,session}, local/). Backs up the whole .agents/ once, then moves
129
- // each legacy dir only if its new home is absent. On conflict the legacy copy stays in .agents/
130
- // (preserved in the backup) and is reported. Idempotent: a no-op once the legacy dirs are gone.
131
- function migrateLegacyLayout(dir) {
132
- const destAgents = path.join(dir, ".agents");
133
- const moves = [
134
- ["memory", path.join("journal", "memory")],
135
- ["backlog", path.join("journal", "backlog")],
136
- ["session", path.join("journal", "session")],
137
- ["local", "local"],
138
- ];
139
- const legacy = moves.filter(([from]) => fs.existsSync(path.join(destAgents, from)));
140
- if (!legacy.length) return null;
141
- const bak = backupAgents(destAgents);
142
- const moved = [];
143
- const conflicts = [];
144
- for (const [from, to] of legacy) {
145
- const dst = path.join(dir, to);
146
- if (fs.existsSync(dst)) { conflicts.push(from); continue; }
147
- fs.mkdirSync(path.dirname(dst), { recursive: true });
148
- fs.renameSync(path.join(destAgents, from), dst);
149
- moved.push(`${from} -> ${to}`);
150
- }
151
- return { bak, moved, conflicts };
152
- }
153
-
154
- // Wholesale replace of the managed contract: delete .agents/ and copy the entire template .agents/.
155
- // Safe because nothing project-owned lives under .agents/ anymore (migration moved it to root).
156
- function copyAgentsWholesale(destAgents) {
157
- fs.rmSync(destAgents, { recursive: true, force: true });
158
- fs.cpSync(TEMPLATE_AGENTS, destAgents, { recursive: true });
159
- }
160
-
161
- // Mirror project-discoverable skills into .claude/skills/ so Claude Code finds them: the vendored
162
- // base skill (find-skills, under .agents/) plus every project-only skill under root local/skills/.
163
- // Runs for both init and sync.
164
- function mirrorProjectSkills(target) {
165
- const sources = [path.join(target, ".agents", "skills", "find-skills")];
166
- const localSkills = path.join(target, "local", "skills");
167
- if (fs.existsSync(localSkills)) {
168
- for (const e of fs.readdirSync(localSkills, { withFileTypes: true })) {
169
- if (e.isDirectory()) sources.push(path.join(localSkills, e.name));
170
- }
171
- }
172
- for (const src of sources) {
173
- if (!fs.existsSync(src)) continue;
174
- const dest = path.join(target, ".claude", "skills", path.basename(src));
175
- fs.rmSync(dest, { recursive: true, force: true });
176
- fs.mkdirSync(path.dirname(dest), { recursive: true });
177
- fs.cpSync(src, dest, { recursive: true });
178
- }
179
- }
180
-
181
- function templateSha() {
182
- try {
183
- // stdio: ignore stderr so git's "fatal: not a git repository" never leaks to the user when
184
- // grimoire runs from an npx/tarball install (no .git) — the catch already handles the failure.
185
- return execFileSync("git", ["-C", TEMPLATE_ROOT, "rev-parse", "--short", "HEAD"], {
186
- encoding: "utf8",
187
- stdio: ["ignore", "pipe", "ignore"],
188
- }).trim();
189
- } catch {
190
- return "unknown";
191
- }
192
- }
193
-
194
- // The release version: single source of truth is package.json, so it can never drift from the
195
- // published package. `.agents/VERSION` is informational only — it is regenerated on every stamp.
196
- function pkgVersion() {
197
- return JSON.parse(fs.readFileSync(path.join(TEMPLATE_ROOT, "package.json"), "utf8")).version;
198
- }
199
-
200
- function stampVersion(destAgents) {
201
- const stamp = `grimoire v${pkgVersion()}\nsha: ${templateSha()}\n`;
202
- fs.writeFileSync(path.join(destAgents, "VERSION"), stamp);
203
- }
204
-
205
- function ensureGitignore(target) {
206
- const snippet = fs.readFileSync(path.join(TEMPLATES_DIR, "gitignore-snippet.txt"), "utf8").trimEnd();
207
- const file = path.join(target, ".gitignore");
208
- const cur = fs.existsSync(file) ? fs.readFileSync(file, "utf8") : "";
209
- if (cur.includes("journal/session/")) return;
210
- fs.writeFileSync(file, (cur ? cur.replace(/\s*$/, "") + "\n\n" : "") + snippet + "\n");
211
- }
212
-
213
- function writePointer(target) {
214
- const dest = path.join(target, "CLAUDE.md");
215
- if (fs.existsSync(dest)) {
216
- log(" skip CLAUDE.md (exists) — ensure it imports @.agents/AGENTS.md");
217
- return;
218
- }
219
- fs.copyFileSync(path.join(TEMPLATES_DIR, "CLAUDE.md"), dest);
220
- }
221
-
222
- // Seed a project-owned root folder from templates/, filling only ABSENT files (never overwrites
223
- // a file the project already has). Generalizes the old per-file seed; used for journal/ and local/.
224
- // Unlike seedCodex (dir-level seed-once), this fills gaps — e.g. journal/ exists but lacks MEMORY.md.
225
- function seedRoot(name, target) {
226
- const src = path.join(TEMPLATES_DIR, name);
227
- if (!fs.existsSync(src)) return;
228
- const walk = (s, d) => {
229
- fs.mkdirSync(d, { recursive: true });
230
- for (const e of fs.readdirSync(s, { withFileTypes: true })) {
231
- const sp = path.join(s, e.name);
232
- const dp = path.join(d, e.name);
233
- if (e.isDirectory()) walk(sp, dp);
234
- else if (!fs.existsSync(dp)) fs.copyFileSync(sp, dp);
235
- }
236
- };
237
- walk(src, path.join(target, name));
238
- }
239
-
240
- // Seed the project's knowledge base once: copy templates/codex/ → <target>/codex/ (at the repo
241
- // ROOT, not under .agents/) only when the destination is absent. codex/ is project-owned — it holds
242
- // domain, requirements, decisions, evidence, resources, reference, and runbooks — and lives outside
243
- // every managed path, so `grimoire sync` never touches it. Seed-once, like the old doc trees.
244
- function seedCodex(target) {
245
- const dest = path.join(target, "codex");
246
- const src = path.join(TEMPLATES_DIR, "codex");
247
- if (fs.existsSync(dest) || !fs.existsSync(src)) return;
248
- fs.mkdirSync(path.dirname(dest), { recursive: true });
249
- fs.cpSync(src, dest, { recursive: true });
250
- }
251
-
252
- function init({ dir }) {
253
- const destAgents = path.join(dir, ".agents");
254
-
255
- // Auto-migrate an old-layout project before touching .agents/.
256
- const mig = migrateLegacyLayout(dir);
257
-
258
- copyAgentsWholesale(destAgents);
259
-
260
- stampVersion(destAgents);
261
- writePointer(dir);
262
- ensureGitignore(dir);
263
- seedCodex(dir); // codex/ at repo ROOT — project-owned, seed-once
264
- seedRoot("journal", dir); // journal/{memory,backlog,session} — project-owned, per-file seed
265
- seedRoot("local", dir); // local/ override config — project-owned, per-file seed
266
- mirrorProjectSkills(dir);
267
- generateIndexes(dir); // after all mutations, so a freshly-seeded local/ is indexed
268
-
269
- if (mig && mig.bak) log(" migrated old layout; backed up .agents/ -> " + path.basename(mig.bak) + "/");
270
- if (mig && mig.moved.length) log(" moved: " + mig.moved.join(", "));
271
- if (mig && mig.conflicts.length) log(" conflict (kept in backup, not moved): " + mig.conflicts.join(", "));
272
- log("grimoire init: scaffolded .agents/ (read-only contract) + CLAUDE.md + codex/ + journal/ + local/");
273
- log(" contract (managed, wholesale-synced): .agents/");
274
- log(" project-owned (seeded if absent): codex/ journal/ local/");
275
- log(" next: set the active stack profile + testing policy in local/AGENTS.local.md");
276
-
277
- bootstrap({ dir, apply: false });
278
- }
279
-
280
- function sync({ dir }) {
281
- const destAgents = path.join(dir, ".agents");
282
- if (!fs.existsSync(destAgents)) fail("no .agents/ here run `grimoire init` first.");
283
-
284
- const oldVersion = (() => {
285
- try { return fs.readFileSync(path.join(destAgents, "VERSION"), "utf8").trim(); }
286
- catch { return "(none)"; }
287
- })();
288
-
289
- // Migrate an old-layout project first, then wholesale-replace the contract.
290
- const mig = migrateLegacyLayout(dir);
291
- copyAgentsWholesale(destAgents);
292
- stampVersion(destAgents);
293
- // Fill any newly-introduced project-owned scaffolding without clobbering existing files.
294
- seedCodex(dir);
295
- seedRoot("journal", dir);
296
- seedRoot("local", dir);
297
- mirrorProjectSkills(dir);
298
- generateIndexes(dir); // after all mutations, so newly-seeded files are indexed
299
-
300
- if (mig && mig.bak) log(" migrated old layout; backed up .agents/ -> " + path.basename(mig.bak) + "/");
301
- if (mig && mig.moved.length) log(" moved: " + mig.moved.join(", "));
302
- if (mig && mig.conflicts.length) log(" conflict (kept in backup, not moved): " + mig.conflicts.join(", "));
303
- log("grimoire sync: wholesale-replaced the .agents/ contract from template.");
304
- log(" untouched (project-owned): codex/ journal/ local/");
305
- log(" VERSION: " + oldVersion.split(/\r?\n/)[0] + " -> sha " + templateSha());
306
- log(" tooling.json may have changed; run `grimoire bootstrap` to apply plugin/MCP updates.");
307
- }
308
-
309
- function bootstrap({ dir, apply }) {
310
- const tooling = mergedTooling(dir); // base ∪ local/tooling.json
311
- const sp = claudeSettingsPath();
312
- const settings = readSettings(sp);
313
- const missing = missingPlugins(tooling, settings);
314
-
315
- log(`grimoire bootstrap (${apply ? "apply" : "dry-run"})`);
316
-
317
- if (missing.length === 0) {
318
- log(" plugins: all required plugins already enabled.");
319
- } else {
320
- log(" plugins missing:");
321
- for (const pl of missing) log(` - ${pluginKey(pl)}`);
322
- if (apply) {
323
- applyPlugins(sp, settings, missing);
324
- log(` enabled ${missing.length} plugin(s); backup at ${sp}.bak`);
325
- } else {
326
- log(` (dry-run) re-run with --apply to enable them in ${sp}`);
327
- }
328
- }
329
-
330
- // Installer-based skills (e.g. mattpocock) are user-scoped print the hint, never auto-run.
331
- for (const sk of tooling.skills || []) {
332
- if (sk.install) {
333
- log(` skill ${sk.name}: install via \`${sk.install}\`` + (sk.setup ? `, then run ${sk.setup}` : ""));
334
- }
335
- }
336
-
337
- if (apply) {
338
- const { added, needsEnv } = mergeMcp(dir, tooling);
339
- if (added.length) log(` mcp: added ${added.join(", ")} to .mcp.json`);
340
- else log(" mcp: all servers already present.");
341
- for (const e of [...new Set(needsEnv)]) log(` mcp: set ${e} in your environment before use.`);
342
- } else {
343
- const names = (tooling.mcp || []).map((m) => m.name).join(", ");
344
- log(` (dry-run) mcp servers to ensure: ${names}`);
345
- }
346
- }
347
-
348
- // --- index: per-folder INDEX.md (a generated table of contents) ----------------------------------
349
- // Two-level progressive disclosure: AGENTS.md map -> folder INDEX.md (one line per file) -> file.
350
- // Generated, never hand-edited, so it cannot drift; `grimoire index --check` fails CI on staleness.
351
- const INDEX_FOLDERS = ["rules", "standards", "stack", "commands", "agents", "skills"];
352
- // Same INDEX tooling for the project's own customization layer under local/.
353
- const LOCAL_INDEX_FOLDERS = ["rules", "standards", "stack", "commands", "skills", "reference"];
354
-
355
- function firstSentence(s) {
356
- const m = s.match(/^[\s\S]*?[.!?](\s|$)/);
357
- let out = (m ? m[0] : s).replace(/\s+/g, " ").trim();
358
- if (out.length > 140) out = out.slice(0, 139).trimEnd() + "…";
359
- return out;
360
- }
361
-
362
- // Normalize a blurb fragment to clean plain text: drop a leading list marker, emphasis/code
363
- // markers, and a trailing colon. Pure + exported for unit testing.
364
- export function cleanBlurb(s) {
365
- return s
366
- .replace(/^\s*(?:[-*+]|\d+\.)\s+/, "") // drop a leading list marker
367
- .replace(/\*\*|__|[*_`]/g, "") // drop emphasis/code markers
368
- .replace(/\s*:\s*$/, "") // drop a trailing colon only (keep inner ones, e.g. "Modes: NORMAL")
369
- .replace(/\s+/g, " ")
370
- .trim();
371
- }
372
-
373
- // One-line blurb for a file: frontmatter `description:` if present, else H1 title (minus any
374
- // leading "NN " numbering) joined with the first sentence of the first paragraph.
375
- function blurbFor(filePath) {
376
- const text = fs.readFileSync(filePath, "utf8");
377
- const fm = text.match(/^---\r?\n([\s\S]*?)\r?\n---/);
378
- if (fm) {
379
- const d = fm[1].match(/^description:\s*(.+)$/m);
380
- if (d) return cleanBlurb(firstSentence(d[1].trim().replace(/^["']|["']$/g, "")));
381
- }
382
- const lines = text.split(/\r?\n/);
383
- let title = "";
384
- let i = 0;
385
- for (; i < lines.length; i++) {
386
- const h = lines[i].match(/^#\s+(.+)$/);
387
- if (h) { title = cleanBlurb(h[1].replace(/^\d+\s*[—-]\s*/, "").trim()); i++; break; }
388
- }
389
- let para = "";
390
- for (; i < lines.length; i++) {
391
- const l = lines[i].trim();
392
- if (!l) { if (para) break; else continue; }
393
- if (l.startsWith("#")) break;
394
- para += (para ? " " : "") + l;
395
- }
396
- const sent = para ? cleanBlurb(firstSentence(para)) : "";
397
- if (title && sent) return `${title} — ${sent}`;
398
- return title || sent || "(no description)";
399
- }
400
-
401
- function indexEntries(dir) {
402
- const out = [];
403
- const ents = fs.readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name));
404
- for (const e of ents) {
405
- if (e.isFile() && e.name.endsWith(".md") && !/^(INDEX|README)\.md$/i.test(e.name)) {
406
- out.push({ label: e.name, blurb: blurbFor(path.join(dir, e.name)) });
407
- } else if (e.isDirectory()) {
408
- const sub = path.join(dir, e.name);
409
- const mds = fs.readdirSync(sub).filter((f) => f.endsWith(".md"));
410
- if (!mds.length) continue;
411
- const main = mds.find((f) => /^SKILL\.md$/i.test(f)) || mds.find((f) => f === e.name + ".md") || mds.sort()[0];
412
- out.push({ label: e.name + "/", blurb: blurbFor(path.join(sub, main)) });
413
- }
414
- }
415
- return out;
416
- }
417
-
418
- function renderIndex(folder, entries) {
419
- const rows = entries.map((e) => `| \`${e.label}\` | ${e.blurb.replace(/\|/g, "\\|")} |`).join("\n");
420
- return (
421
- `# ${folder} index\n\n` +
422
- "<!-- GENERATED by `grimoire index`; do not edit by hand. Re-run after adding/renaming files here. -->\n\n" +
423
- "| File | What it covers |\n|---|---|\n" +
424
- rows +
425
- "\n"
426
- );
427
- }
428
-
429
- // Write (or, in check mode, just diff) INDEX.md for every indexed folder. Returns stale folders.
430
- function generateIndexes(dir, { check } = {}) {
431
- const stale = [];
432
- // Two groups: the managed base at .agents/<folder>, and the project's own
433
- // customization layer at <root>/local/<folder>. Same renderer + drift rules.
434
- const groups = [
435
- { base: path.join(dir, ".agents"), folders: INDEX_FOLDERS, prefix: "" },
436
- { base: path.join(dir, "local"), folders: LOCAL_INDEX_FOLDERS, prefix: "local/" },
437
- ];
438
- for (const g of groups) {
439
- for (const folder of g.folders) {
440
- const dir = path.join(g.base, folder);
441
- if (!fs.existsSync(dir)) continue;
442
- const entries = indexEntries(dir);
443
- if (!entries.length) continue;
444
- const content = renderIndex(g.prefix + folder, entries);
445
- const file = path.join(dir, "INDEX.md");
446
- // Compare newline-agnostically: a git checkout with core.autocrlf=true rewrites committed LF to
447
- // CRLF on disk, but renderIndex emits LF — a raw compare would report false drift on Windows.
448
- // Strip every CR (not just \r\n) so a doubled \r\r\n never leaves a stray CR behind.
449
- const cur = fs.existsSync(file) ? fs.readFileSync(file, "utf8").replace(/\r/g, "") : "";
450
- if (cur === content) continue;
451
- if (check) stale.push(g.prefix + folder + "/INDEX.md");
452
- else fs.writeFileSync(file, content);
453
- }
454
- }
455
- return stale;
456
- }
457
-
458
- // Drift guard: every MCP server wired in tooling.json must be documented in skills/catalog.md.
459
- function catalogDrift(destAgents) {
460
- const catalogFile = path.join(destAgents, "skills", "catalog.md");
461
- if (!fs.existsSync(catalogFile)) return [];
462
- const tooling = readTooling();
463
- const catalog = fs.readFileSync(catalogFile, "utf8");
464
- return (tooling.mcp || [])
465
- .map((m) => m.name)
466
- .filter((n) => !new RegExp("`" + n.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + "`").test(catalog));
467
- }
468
-
469
- function index({ dir, check }) {
470
- const destAgents = path.join(dir, ".agents");
471
- if (!fs.existsSync(destAgents)) fail("no .agents/ here — run `grimoire init` first.");
472
- const stale = generateIndexes(dir, { check });
473
- const drift = catalogDrift(destAgents);
474
- if (check) {
475
- const probs = [];
476
- if (stale.length) probs.push("stale INDEX.md (run `grimoire index`): " + stale.join(", "));
477
- if (drift.length) probs.push("skills/catalog.md missing tooling MCP: " + drift.join(", "));
478
- if (probs.length) fail(probs.join("; "));
479
- log("grimoire index --check: all INDEX.md current; catalog covers tooling MCP.");
480
- return;
481
- }
482
- log("grimoire index: refreshed INDEX.md under " + INDEX_FOLDERS.join("/ ") + "/");
483
- if (drift.length) log(" warning: skills/catalog.md does not mention tooling MCP: " + drift.join(", "));
484
- }
485
-
486
- // Read-only health check: verify a project is correctly wired. Aggregates
487
- // findings, prints one line each, exits 1 if any error (CI-friendly).
488
- function doctor({ dir }) {
489
- const destAgents = path.join(dir, ".agents");
490
- if (!fs.existsSync(destAgents)) fail("no .agents/ here — run `grimoire init` first.");
491
- const errors = [];
492
- const warnings = [];
493
- const err = (m) => errors.push(m);
494
- const warn = (m) => warnings.push(m);
495
-
496
- // 1. wiring CLAUDE.md imports the contract.
497
- const claude = path.join(dir, "CLAUDE.md");
498
- if (!fs.existsSync(claude)) {
499
- err("CLAUDE.md missing the agent entry point is not wired.");
500
- } else {
501
- const t = fs.readFileSync(claude, "utf8");
502
- if (!t.includes("@.agents/AGENTS.md")) err("CLAUDE.md does not import @.agents/AGENTS.md.");
503
- if (!t.includes("@local/AGENTS.local.md"))
504
- warn("CLAUDE.md does not import @local/AGENTS.local.md (local overrides won't load).");
505
- }
506
-
507
- // 2. skill frontmatter — mirrored skills need name: + description: to be discoverable.
508
- const skillDirs = [
509
- { rel: "skills", base: destAgents },
510
- { rel: path.join("local", "skills"), base: dir },
511
- ];
512
- for (const { rel, base } of skillDirs) {
513
- const sdir = path.join(base, rel);
514
- if (!fs.existsSync(sdir)) continue;
515
- for (const e of fs.readdirSync(sdir, { withFileTypes: true })) {
516
- if (!e.isDirectory()) continue;
517
- const sk = path.join(sdir, e.name, "SKILL.md");
518
- if (!fs.existsSync(sk)) continue;
519
- const fm = fs.readFileSync(sk, "utf8").match(/^---\r?\n([\s\S]*?)\r?\n---/);
520
- const body = fm ? fm[1] : "";
521
- if (!/^name:\s*\S/m.test(body) || !/^description:\s*\S/m.test(body))
522
- err(`${rel.replace(/\\/g, "/")}/${e.name}/SKILL.md needs name: + description: (Claude Code can't discover it otherwise).`);
523
- }
524
- }
525
-
526
- // 3. INDEX + catalog drift (root + local).
527
- for (const s of generateIndexes(dir, { check: true }))
528
- err(`stale INDEX.md (run \`grimoire index\`): ${s}`);
529
- for (const m of catalogDrift(destAgents)) err(`skills/catalog.md missing tooling MCP: ${m}`);
530
-
531
- // 4. AGENTS.local filled — stack profile + testing policy set, not placeholders.
532
- const localEntry = path.join(dir, "local", "AGENTS.local.md");
533
- if (fs.existsSync(localEntry)) {
534
- const t = fs.readFileSync(localEntry, "utf8");
535
- const val = (label) => (t.match(new RegExp(label + ":\\*\\*\\s*(.*)")) || [])[1];
536
- const unset = (v) => !v || v.trim() === "" || v.trim().startsWith("<!--");
537
- if (unset(val("Active stack profile")))
538
- warn("local/AGENTS.local.md: Active stack profile not set (still the seeded placeholder).");
539
- if (unset(val("Testing policy")))
540
- warn("local/AGENTS.local.md: Testing policy not set (still the seeded placeholder).");
541
- }
542
-
543
- // 5. entry-file size ceiling (rules/35-context-economy.md).
544
- for (const rel of ["CLAUDE.md", path.join(".agents", "AGENTS.md"), path.join("local", "AGENTS.local.md")]) {
545
- const f = path.join(dir, rel);
546
- if (!fs.existsSync(f)) continue;
547
- const n = fs.readFileSync(f, "utf8").split(/\r?\n/).length;
548
- if (n > 300) warn(`${rel.replace(/\\/g, "/")} is ${n} lines (>300 — keep entry files lean).`);
549
- }
550
-
551
- // 7. knowledge base scaffolded — codex/INDEX.md is the read-first project knowledge home.
552
- if (!fs.existsSync(path.join(dir, "codex", "INDEX.md")))
553
- warn("codex/INDEX.md missing — the project knowledge base isn't scaffolded (run `grimoire init`).");
554
-
555
- // 8. local/tooling.json (if present) must be valid JSON — bootstrap reads it.
556
- const lt = path.join(dir, "local", "tooling.json");
557
- if (fs.existsSync(lt)) {
558
- try { JSON.parse(fs.readFileSync(lt, "utf8")); }
559
- catch { err("local/tooling.json is not valid JSON (grimoire bootstrap can't read it)."); }
560
- }
561
-
562
- log(`grimoire doctor: ${errors.length} error(s), ${warnings.length} warning(s)`);
563
- for (const e of errors) log(" error: " + e);
564
- for (const w of warnings) log(" warn: " + w);
565
- if (!errors.length && !warnings.length) log(" all checks passed.");
566
- if (errors.length) process.exit(1);
567
- }
568
-
569
- // Report both versions the user tracks: the release semver (package.json) and the build sha (git).
570
- function version() {
571
- log(`grimoire v${pkgVersion()} (sha ${templateSha()})`);
572
- }
573
-
574
- function help() {
575
- log("grimoire <command> [--dir <path>]\n");
576
- log(" init scaffold .agents/ + CLAUDE.md + codex/ journal/ local/ (migrates an old layout; backs up first)");
577
- log(" sync wholesale-replace the .agents/ contract from the template (codex/ journal/ local/ untouched)");
578
- log(" bootstrap enable required plugins / MCP / skills (dry-run; --apply to write)");
579
- log(" index regenerate per-folder INDEX.md (--check fails on drift, for CI)");
580
- log(" doctor health-check the project's wiring (exits non-zero on error, for CI)");
581
- log(" --version print the release version + build sha (-v)");
582
- }
583
-
584
- // Only dispatch the CLI when invoked directly (so importing this module — e.g. from tests —
585
- // does not execute commands or call process.exit).
586
- if (import.meta.url === `file://${process.argv[1]}` || process.argv[1]?.endsWith("grimoire.mjs")) {
587
- const args = parseArgs(process.argv.slice(2));
588
- switch (args.cmd) {
589
- case "init": init(args); break;
590
- case "sync": sync(args); break;
591
- case "bootstrap": bootstrap(args); break;
592
- case "index": index(args); break;
593
- case "doctor": doctor(args); break;
594
- case "--version": case "-v": version(); break;
595
- case "--help": case "-h": case undefined: help(); break;
596
- default: fail(`unknown command "${args.cmd}" (try --help)`);
597
- }
598
- }
1
+ #!/usr/bin/env node
2
+ // Grimoire CLI — init a project with the agent template, or sync template updates.
3
+ // Self-contained, no deps. Node >=18.
4
+
5
+ import { fileURLToPath } from "node:url";
6
+ import path from "node:path";
7
+ import fs from "node:fs";
8
+ import os from "node:os";
9
+ import { execFileSync } from "node:child_process";
10
+
11
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
12
+ const TEMPLATE_ROOT = path.resolve(__dirname, "..");
13
+ const TEMPLATE_AGENTS = path.join(TEMPLATE_ROOT, ".agents");
14
+ const TEMPLATES_DIR = path.join(TEMPLATE_ROOT, "templates");
15
+
16
+ function log(msg) { process.stdout.write(msg + "\n"); }
17
+ function fail(msg) { process.stderr.write("grimoire: " + msg + "\n"); process.exit(1); }
18
+
19
+ function parseArgs(argv) {
20
+ const out = { cmd: argv[0], dir: process.cwd(), apply: false, check: false };
21
+ for (let i = 1; i < argv.length; i++) {
22
+ if (argv[i] === "--dir") out.dir = path.resolve(argv[++i]);
23
+ else if (argv[i] === "--apply") out.apply = true;
24
+ else if (argv[i] === "--dry-run") out.apply = false;
25
+ else if (argv[i] === "--check") out.check = true;
26
+ }
27
+ return out;
28
+ }
29
+
30
+ function readTooling() {
31
+ return JSON.parse(fs.readFileSync(path.join(TEMPLATE_AGENTS, "tooling.json"), "utf8"));
32
+ }
33
+
34
+ // Optional project-owned tooling at local/tooling.json (same shape as the base:
35
+ // { plugins, skills, mcp }). Lets a project declare its own plugins / MCP (Linear, Sentry,
36
+ // Supabase, Figma, …) without bloating the base. Returns null if absent/invalid (doctor flags it).
37
+ function readLocalTooling(dir) {
38
+ const file = path.join(dir, "local", "tooling.json");
39
+ if (!fs.existsSync(file)) return null;
40
+ try { return JSON.parse(fs.readFileSync(file, "utf8")); } catch { return null; }
41
+ }
42
+
43
+ // Base ∪ local tooling: base wins on a key conflict; local entries with a new key are added.
44
+ function mergedTooling(dir) {
45
+ const base = readTooling();
46
+ const local = readLocalTooling(dir);
47
+ if (!local) return base;
48
+ const merge = (a = [], b = [], key) => {
49
+ const m = new Map();
50
+ for (const x of a) m.set(key(x), x);
51
+ for (const x of b) if (!m.has(key(x))) m.set(key(x), x);
52
+ return [...m.values()];
53
+ };
54
+ return {
55
+ plugins: merge(base.plugins, local.plugins, pluginKey),
56
+ skills: merge(base.skills, local.skills, (s) => s.name),
57
+ mcp: merge(base.mcp, local.mcp, (m) => m.name),
58
+ };
59
+ }
60
+
61
+ function claudeSettingsPath() {
62
+ return path.join(os.homedir(), ".claude", "settings.json");
63
+ }
64
+
65
+ function readSettings(p) {
66
+ try { return JSON.parse(fs.readFileSync(p, "utf8")); } catch { return {}; }
67
+ }
68
+
69
+ function pluginKey(pl) { return `${pl.name}@${pl.marketplace}`; }
70
+
71
+ function missingPlugins(tooling, settings) {
72
+ const enabled = settings.enabledPlugins || {};
73
+ return (tooling.plugins || []).filter((pl) => !enabled[pluginKey(pl)]);
74
+ }
75
+
76
+ function applyPlugins(sp, settings, missing) {
77
+ if (missing.length === 0) return;
78
+ if (fs.existsSync(sp)) fs.copyFileSync(sp, sp + ".bak");
79
+ settings.enabledPlugins = settings.enabledPlugins || {};
80
+ for (const pl of missing) settings.enabledPlugins[pluginKey(pl)] = true; // add only
81
+ fs.mkdirSync(path.dirname(sp), { recursive: true });
82
+ fs.writeFileSync(sp, JSON.stringify(settings, null, 2) + "\n");
83
+ }
84
+
85
+ // Collect unresolved ${ENV} placeholders anywhere in a server definition (env for stdio servers,
86
+ // headers/url for http servers, etc.). Recurses over strings so transport shape does not matter.
87
+ function unresolvedEnv(node, out) {
88
+ if (typeof node === "string") {
89
+ for (const m of node.matchAll(/\$\{(\w+)\}/g)) if (!process.env[m[1]]) out.push(m[1]);
90
+ } else if (Array.isArray(node)) {
91
+ for (const v of node) unresolvedEnv(v, out);
92
+ } else if (node && typeof node === "object") {
93
+ for (const v of Object.values(node)) unresolvedEnv(v, out);
94
+ }
95
+ return out;
96
+ }
97
+
98
+ // Additively merge MCP servers from tooling.json into the project .mcp.json. Never clobbers
99
+ // an existing server definition. Returns the names added + any unresolved ${ENV} placeholders.
100
+ function mergeMcp(target, tooling) {
101
+ const file = path.join(target, ".mcp.json");
102
+ const cur = fs.existsSync(file) ? JSON.parse(fs.readFileSync(file, "utf8")) : {};
103
+ cur.mcpServers = cur.mcpServers || {};
104
+ const added = [];
105
+ const needsEnv = [];
106
+ for (const m of tooling.mcp || []) {
107
+ if (cur.mcpServers[m.name]) continue;
108
+ cur.mcpServers[m.name] = m.server;
109
+ added.push(m.name);
110
+ unresolvedEnv(m.server, needsEnv);
111
+ }
112
+ if (added.length) fs.writeFileSync(file, JSON.stringify(cur, null, 2) + "\n");
113
+ return { added, needsEnv };
114
+ }
115
+
116
+ // Safety net for adopting a project that already has an .agents/: copy the whole
117
+ // tree to a sibling .agents.bak-<stamp>/ before init overwrites managed paths.
118
+ // Returns the backup path, or null if there was nothing to back up.
119
+ function backupAgents(destAgents) {
120
+ if (!fs.existsSync(destAgents)) return null;
121
+ const stamp = new Date().toISOString().replace(/[:.]/g, "-");
122
+ const bak = `${destAgents}.bak-${stamp}`;
123
+ fs.cpSync(destAgents, bak, { recursive: true });
124
+ return bak;
125
+ }
126
+
127
+ // One-time migration from the old layout (.agents/{memory,backlog,session,local}) to the new root
128
+ // layout (journal/{memory,backlog,session}, local/). Backs up the whole .agents/ once, then moves
129
+ // each legacy dir only if its new home is absent. On conflict the legacy copy stays in .agents/
130
+ // (preserved in the backup) and is reported. Idempotent: a no-op once the legacy dirs are gone.
131
+ function migrateLegacyLayout(dir) {
132
+ const destAgents = path.join(dir, ".agents");
133
+ const moves = [
134
+ ["memory", path.join("journal", "memory")],
135
+ ["backlog", path.join("journal", "backlog")],
136
+ ["session", path.join("journal", "session")],
137
+ ["local", "local"],
138
+ ];
139
+ const legacy = moves.filter(([from]) => fs.existsSync(path.join(destAgents, from)));
140
+ if (!legacy.length) return null;
141
+ const bak = backupAgents(destAgents);
142
+ const moved = [];
143
+ const conflicts = [];
144
+ for (const [from, to] of legacy) {
145
+ const dst = path.join(dir, to);
146
+ if (fs.existsSync(dst)) { conflicts.push(from); continue; }
147
+ fs.mkdirSync(path.dirname(dst), { recursive: true });
148
+ fs.renameSync(path.join(destAgents, from), dst);
149
+ moved.push(`${from} -> ${to}`);
150
+ }
151
+ return { bak, moved, conflicts };
152
+ }
153
+
154
+ // Wholesale replace of the managed contract: delete .agents/ and copy the entire template .agents/.
155
+ // Safe because nothing project-owned lives under .agents/ anymore (migration moved it to root).
156
+ function copyAgentsWholesale(destAgents) {
157
+ fs.rmSync(destAgents, { recursive: true, force: true });
158
+ fs.cpSync(TEMPLATE_AGENTS, destAgents, { recursive: true });
159
+ }
160
+
161
+ // Mirror project-discoverable skills into .claude/skills/ so Claude Code finds them: the vendored
162
+ // base skill (find-skills, under .agents/) plus every project-only skill under root local/skills/.
163
+ // Runs for both init and sync.
164
+ function mirrorProjectSkills(target) {
165
+ const sources = [path.join(target, ".agents", "skills", "find-skills")];
166
+ const localSkills = path.join(target, "local", "skills");
167
+ if (fs.existsSync(localSkills)) {
168
+ for (const e of fs.readdirSync(localSkills, { withFileTypes: true })) {
169
+ if (e.isDirectory()) sources.push(path.join(localSkills, e.name));
170
+ }
171
+ }
172
+ for (const src of sources) {
173
+ if (!fs.existsSync(src)) continue;
174
+ const dest = path.join(target, ".claude", "skills", path.basename(src));
175
+ fs.rmSync(dest, { recursive: true, force: true });
176
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
177
+ fs.cpSync(src, dest, { recursive: true });
178
+ }
179
+ }
180
+
181
+ function templateSha() {
182
+ try {
183
+ // stdio: ignore stderr so git's "fatal: not a git repository" never leaks to the user when
184
+ // grimoire runs from an npx/tarball install (no .git) — the catch already handles the failure.
185
+ return execFileSync("git", ["-C", TEMPLATE_ROOT, "rev-parse", "--short", "HEAD"], {
186
+ encoding: "utf8",
187
+ stdio: ["ignore", "pipe", "ignore"],
188
+ }).trim();
189
+ } catch {
190
+ return "unknown";
191
+ }
192
+ }
193
+
194
+ // The release version: single source of truth is package.json, so it can never drift from the
195
+ // published package. `.agents/VERSION` is informational only — it is regenerated on every stamp.
196
+ function pkgVersion() {
197
+ return JSON.parse(fs.readFileSync(path.join(TEMPLATE_ROOT, "package.json"), "utf8")).version;
198
+ }
199
+
200
+ function stampVersion(destAgents) {
201
+ const stamp = `grimoire v${pkgVersion()}\nsha: ${templateSha()}\n`;
202
+ fs.writeFileSync(path.join(destAgents, "VERSION"), stamp);
203
+ }
204
+
205
+ function ensureGitignore(target) {
206
+ const snippet = fs.readFileSync(path.join(TEMPLATES_DIR, "gitignore-snippet.txt"), "utf8").trimEnd();
207
+ const file = path.join(target, ".gitignore");
208
+ const before = fs.existsSync(file) ? fs.readFileSync(file, "utf8") : "";
209
+ // Old-layout repos ignored `.agents/session/`; the snippet's `journal/session/`
210
+ // supersedes it. Strip the stale path lines so they don't linger post-migration.
211
+ let cur = before.replace(/^[ \t]*\.agents\/session\/.*\r?\n?/gm, "");
212
+ if (!cur.includes("journal/session/")) {
213
+ cur = (cur.trim() ? cur.replace(/\s*$/, "") + "\n\n" : "") + snippet + "\n";
214
+ }
215
+ if (cur !== before) fs.writeFileSync(file, cur);
216
+ }
217
+
218
+ function writePointer(target) {
219
+ const dest = path.join(target, "CLAUDE.md");
220
+ if (fs.existsSync(dest)) {
221
+ const cur = fs.readFileSync(dest, "utf8");
222
+ if (cur.includes("@.agents/AGENTS.md")) {
223
+ log(" CLAUDE.md already imports the contract left as-is.");
224
+ return;
225
+ }
226
+ // Existing CLAUDE.md (e.g. a bare `@AGENTS.md`) — append the Grimoire imports
227
+ // non-destructively so the project keeps its own content.
228
+ fs.writeFileSync(dest, cur.replace(/\s*$/, "") + "\n\n@.agents/AGENTS.md\n@local/AGENTS.local.md\n");
229
+ log(" appended Grimoire imports to existing CLAUDE.md.");
230
+ return;
231
+ }
232
+ fs.copyFileSync(path.join(TEMPLATES_DIR, "CLAUDE.md"), dest);
233
+ }
234
+
235
+ // After a local-layout migration (.agents/local -> root local/), a project's
236
+ // CLAUDE.md may still import the old path. Rewrite `@.agents/local/...` and inline
237
+ // `.agents/local/...` references to the new root `local/...`. Returns true if it
238
+ // changed the file. (init never reaches this — it writes a fresh pointer.)
239
+ function fixPointerAfterMigration(dir) {
240
+ const dest = path.join(dir, "CLAUDE.md");
241
+ if (!fs.existsSync(dest)) return false;
242
+ const cur = fs.readFileSync(dest, "utf8");
243
+ const next = cur.replace(/@\.agents\/local\//g, "@local/").replace(/\.agents\/local\//g, "local/");
244
+ if (next === cur) return false;
245
+ fs.writeFileSync(dest, next);
246
+ return true;
247
+ }
248
+
249
+ // Seed a project-owned root folder from templates/, filling only ABSENT files (never overwrites
250
+ // a file the project already has). Generalizes the old per-file seed; used for journal/ and local/.
251
+ // Unlike seedCodex (dir-level seed-once), this fills gaps — e.g. journal/ exists but lacks MEMORY.md.
252
+ function seedRoot(name, target) {
253
+ const src = path.join(TEMPLATES_DIR, name);
254
+ if (!fs.existsSync(src)) return;
255
+ const walk = (s, d) => {
256
+ fs.mkdirSync(d, { recursive: true });
257
+ for (const e of fs.readdirSync(s, { withFileTypes: true })) {
258
+ const sp = path.join(s, e.name);
259
+ const dp = path.join(d, e.name);
260
+ if (e.isDirectory()) walk(sp, dp);
261
+ else if (!fs.existsSync(dp)) fs.copyFileSync(sp, dp);
262
+ }
263
+ };
264
+ walk(src, path.join(target, name));
265
+ }
266
+
267
+ // Seed the project's knowledge base once: copy templates/codex/ <target>/codex/ (at the repo
268
+ // ROOT, not under .agents/) only when the destination is absent. codex/ is project-owned — it holds
269
+ // domain, requirements, decisions, evidence, resources, reference, and runbooks and lives outside
270
+ // every managed path, so `grimoire sync` never touches it. Seed-once, like the old doc trees.
271
+ function seedCodex(target) {
272
+ const dest = path.join(target, "codex");
273
+ const src = path.join(TEMPLATES_DIR, "codex");
274
+ if (fs.existsSync(dest) || !fs.existsSync(src)) return;
275
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
276
+ fs.cpSync(src, dest, { recursive: true });
277
+ }
278
+
279
+ function init({ dir }) {
280
+ const destAgents = path.join(dir, ".agents");
281
+
282
+ // Auto-migrate an old-layout project before touching .agents/.
283
+ const mig = migrateLegacyLayout(dir);
284
+
285
+ copyAgentsWholesale(destAgents);
286
+
287
+ stampVersion(destAgents);
288
+ writePointer(dir);
289
+ ensureGitignore(dir);
290
+ seedCodex(dir); // codex/ at repo ROOT — project-owned, seed-once
291
+ seedRoot("journal", dir); // journal/{memory,backlog,session} — project-owned, per-file seed
292
+ seedRoot("local", dir); // local/ override config — project-owned, per-file seed
293
+ mirrorProjectSkills(dir);
294
+ generateIndexes(dir); // after all mutations, so a freshly-seeded local/ is indexed
295
+
296
+ if (mig && mig.bak) log(" migrated old layout; backed up .agents/ -> " + path.basename(mig.bak) + "/");
297
+ if (mig && mig.moved.length) log(" moved: " + mig.moved.join(", "));
298
+ if (mig && mig.conflicts.length) log(" conflict (kept in backup, not moved): " + mig.conflicts.join(", "));
299
+ log("grimoire init: scaffolded .agents/ (read-only contract) + CLAUDE.md + codex/ + journal/ + local/");
300
+ log(" contract (managed, wholesale-synced): .agents/");
301
+ log(" project-owned (seeded if absent): codex/ journal/ local/");
302
+ log(" next: set the active stack profile + testing policy in local/AGENTS.local.md");
303
+
304
+ bootstrap({ dir, apply: false });
305
+ }
306
+
307
+ function sync({ dir }) {
308
+ const destAgents = path.join(dir, ".agents");
309
+ if (!fs.existsSync(destAgents)) fail("no .agents/ here — run `grimoire init` first.");
310
+
311
+ const oldVersion = (() => {
312
+ try { return fs.readFileSync(path.join(destAgents, "VERSION"), "utf8").trim(); }
313
+ catch { return "(none)"; }
314
+ })();
315
+
316
+ // Migrate an old-layout project first, then wholesale-replace the contract.
317
+ const mig = migrateLegacyLayout(dir);
318
+ copyAgentsWholesale(destAgents);
319
+ stampVersion(destAgents);
320
+ ensureGitignore(dir); // keep .gitignore in step with the layout (drops stale .agents/session/, adds journal/session + .agents.bak + graphify)
321
+ const pointerFixed = fixPointerAfterMigration(dir); // repair a CLAUDE.md still importing the pre-migration .agents/local/ path
322
+ // Fill any newly-introduced project-owned scaffolding without clobbering existing files.
323
+ seedCodex(dir);
324
+ seedRoot("journal", dir);
325
+ seedRoot("local", dir);
326
+ mirrorProjectSkills(dir);
327
+ generateIndexes(dir); // after all mutations, so newly-seeded files are indexed
328
+
329
+ if (mig && mig.bak) log(" migrated old layout; backed up .agents/ -> " + path.basename(mig.bak) + "/");
330
+ if (mig && mig.moved.length) log(" moved: " + mig.moved.join(", "));
331
+ if (mig && mig.conflicts.length) log(" conflict (kept in backup, not moved): " + mig.conflicts.join(", "));
332
+ if (pointerFixed) log(" fixed CLAUDE.md imports (.agents/local/ -> local/)");
333
+ log("grimoire sync: wholesale-replaced the .agents/ contract from template.");
334
+ log(" untouched (project-owned): codex/ journal/ local/");
335
+ log(" VERSION: " + oldVersion.split(/\r?\n/)[0] + " -> sha " + templateSha());
336
+ log(" tooling.json may have changed; run `grimoire bootstrap` to apply plugin/MCP updates.");
337
+ }
338
+
339
+ function bootstrap({ dir, apply }) {
340
+ const tooling = mergedTooling(dir); // base local/tooling.json
341
+ const sp = claudeSettingsPath();
342
+ const settings = readSettings(sp);
343
+ const missing = missingPlugins(tooling, settings);
344
+
345
+ log(`grimoire bootstrap (${apply ? "apply" : "dry-run"})`);
346
+
347
+ if (missing.length === 0) {
348
+ log(" plugins: all required plugins already enabled.");
349
+ } else {
350
+ log(" plugins missing:");
351
+ for (const pl of missing) log(` - ${pluginKey(pl)}`);
352
+ if (apply) {
353
+ applyPlugins(sp, settings, missing);
354
+ log(` enabled ${missing.length} plugin(s); backup at ${sp}.bak`);
355
+ } else {
356
+ log(` (dry-run) re-run with --apply to enable them in ${sp}`);
357
+ }
358
+ }
359
+
360
+ // Installer-based skills (e.g. mattpocock) are user-scoped — print the hint, never auto-run.
361
+ for (const sk of tooling.skills || []) {
362
+ if (sk.install) {
363
+ log(` skill ${sk.name}: install via \`${sk.install}\`` + (sk.setup ? `, then run ${sk.setup}` : ""));
364
+ }
365
+ }
366
+
367
+ if (apply) {
368
+ const { added, needsEnv } = mergeMcp(dir, tooling);
369
+ if (added.length) log(` mcp: added ${added.join(", ")} to .mcp.json`);
370
+ else log(" mcp: all servers already present.");
371
+ for (const e of [...new Set(needsEnv)]) log(` mcp: set ${e} in your environment before use.`);
372
+ } else {
373
+ const names = (tooling.mcp || []).map((m) => m.name).join(", ");
374
+ log(` (dry-run) mcp servers to ensure: ${names}`);
375
+ }
376
+ }
377
+
378
+ // --- index: per-folder INDEX.md (a generated table of contents) ----------------------------------
379
+ // Two-level progressive disclosure: AGENTS.md map -> folder INDEX.md (one line per file) -> file.
380
+ // Generated, never hand-edited, so it cannot drift; `grimoire index --check` fails CI on staleness.
381
+ const INDEX_FOLDERS = ["rules", "standards", "stack", "commands", "agents", "skills"];
382
+ // Same INDEX tooling for the project's own customization layer under local/.
383
+ const LOCAL_INDEX_FOLDERS = ["rules", "standards", "stack", "commands", "skills", "reference"];
384
+
385
+ function firstSentence(s) {
386
+ const m = s.match(/^[\s\S]*?[.!?](\s|$)/);
387
+ let out = (m ? m[0] : s).replace(/\s+/g, " ").trim();
388
+ if (out.length > 140) out = out.slice(0, 139).trimEnd() + "…";
389
+ return out;
390
+ }
391
+
392
+ // Normalize a blurb fragment to clean plain text: drop a leading list marker, emphasis/code
393
+ // markers, and a trailing colon. Pure + exported for unit testing.
394
+ export function cleanBlurb(s) {
395
+ return s
396
+ .replace(/^\s*(?:[-*+]|\d+\.)\s+/, "") // drop a leading list marker
397
+ .replace(/\*\*|__|[*_`]/g, "") // drop emphasis/code markers
398
+ .replace(/\s*:\s*$/, "") // drop a trailing colon only (keep inner ones, e.g. "Modes: NORMAL")
399
+ .replace(/\s+/g, " ")
400
+ .trim();
401
+ }
402
+
403
+ // One-line blurb for a file: frontmatter `description:` if present, else H1 title (minus any
404
+ // leading "NN " numbering) joined with the first sentence of the first paragraph.
405
+ function blurbFor(filePath) {
406
+ const text = fs.readFileSync(filePath, "utf8");
407
+ const fm = text.match(/^---\r?\n([\s\S]*?)\r?\n---/);
408
+ if (fm) {
409
+ const d = fm[1].match(/^description:\s*(.+)$/m);
410
+ if (d) return cleanBlurb(firstSentence(d[1].trim().replace(/^["']|["']$/g, "")));
411
+ }
412
+ const lines = text.split(/\r?\n/);
413
+ let title = "";
414
+ let i = 0;
415
+ for (; i < lines.length; i++) {
416
+ const h = lines[i].match(/^#\s+(.+)$/);
417
+ if (h) { title = cleanBlurb(h[1].replace(/^\d+\s*[—-]\s*/, "").trim()); i++; break; }
418
+ }
419
+ let para = "";
420
+ for (; i < lines.length; i++) {
421
+ const l = lines[i].trim();
422
+ if (!l) { if (para) break; else continue; }
423
+ if (l.startsWith("#")) break;
424
+ para += (para ? " " : "") + l;
425
+ }
426
+ const sent = para ? cleanBlurb(firstSentence(para)) : "";
427
+ if (title && sent) return `${title} — ${sent}`;
428
+ return title || sent || "(no description)";
429
+ }
430
+
431
+ function indexEntries(dir) {
432
+ const out = [];
433
+ const ents = fs.readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name));
434
+ for (const e of ents) {
435
+ if (e.isFile() && e.name.endsWith(".md") && !/^(INDEX|README)\.md$/i.test(e.name)) {
436
+ out.push({ label: e.name, blurb: blurbFor(path.join(dir, e.name)) });
437
+ } else if (e.isDirectory()) {
438
+ const sub = path.join(dir, e.name);
439
+ const mds = fs.readdirSync(sub).filter((f) => f.endsWith(".md"));
440
+ if (!mds.length) continue;
441
+ const main = mds.find((f) => /^SKILL\.md$/i.test(f)) || mds.find((f) => f === e.name + ".md") || mds.sort()[0];
442
+ out.push({ label: e.name + "/", blurb: blurbFor(path.join(sub, main)) });
443
+ }
444
+ }
445
+ return out;
446
+ }
447
+
448
+ function renderIndex(folder, entries) {
449
+ const rows = entries.map((e) => `| \`${e.label}\` | ${e.blurb.replace(/\|/g, "\\|")} |`).join("\n");
450
+ return (
451
+ `# ${folder} index\n\n` +
452
+ "<!-- GENERATED by `grimoire index`; do not edit by hand. Re-run after adding/renaming files here. -->\n\n" +
453
+ "| File | What it covers |\n|---|---|\n" +
454
+ rows +
455
+ "\n"
456
+ );
457
+ }
458
+
459
+ // Write (or, in check mode, just diff) INDEX.md for every indexed folder. Returns stale folders.
460
+ function generateIndexes(dir, { check } = {}) {
461
+ const stale = [];
462
+ // Two groups: the managed base at .agents/<folder>, and the project's own
463
+ // customization layer at <root>/local/<folder>. Same renderer + drift rules.
464
+ const groups = [
465
+ { base: path.join(dir, ".agents"), folders: INDEX_FOLDERS, prefix: "" },
466
+ { base: path.join(dir, "local"), folders: LOCAL_INDEX_FOLDERS, prefix: "local/" },
467
+ ];
468
+ for (const g of groups) {
469
+ for (const folder of g.folders) {
470
+ const dir = path.join(g.base, folder);
471
+ if (!fs.existsSync(dir)) continue;
472
+ const entries = indexEntries(dir);
473
+ if (!entries.length) continue;
474
+ const content = renderIndex(g.prefix + folder, entries);
475
+ const file = path.join(dir, "INDEX.md");
476
+ // Compare newline-agnostically: a git checkout with core.autocrlf=true rewrites committed LF to
477
+ // CRLF on disk, but renderIndex emits LF — a raw compare would report false drift on Windows.
478
+ // Strip every CR (not just \r\n) so a doubled \r\r\n never leaves a stray CR behind.
479
+ const cur = fs.existsSync(file) ? fs.readFileSync(file, "utf8").replace(/\r/g, "") : "";
480
+ if (cur === content) continue;
481
+ if (check) stale.push(g.prefix + folder + "/INDEX.md");
482
+ else fs.writeFileSync(file, content);
483
+ }
484
+ }
485
+ return stale;
486
+ }
487
+
488
+ // Drift guard: every MCP server wired in tooling.json must be documented in skills/catalog.md.
489
+ function catalogDrift(destAgents) {
490
+ const catalogFile = path.join(destAgents, "skills", "catalog.md");
491
+ if (!fs.existsSync(catalogFile)) return [];
492
+ const tooling = readTooling();
493
+ const catalog = fs.readFileSync(catalogFile, "utf8");
494
+ return (tooling.mcp || [])
495
+ .map((m) => m.name)
496
+ .filter((n) => !new RegExp("`" + n.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + "`").test(catalog));
497
+ }
498
+
499
+ function index({ dir, check }) {
500
+ const destAgents = path.join(dir, ".agents");
501
+ if (!fs.existsSync(destAgents)) fail("no .agents/ here — run `grimoire init` first.");
502
+ const stale = generateIndexes(dir, { check });
503
+ const drift = catalogDrift(destAgents);
504
+ if (check) {
505
+ const probs = [];
506
+ if (stale.length) probs.push("stale INDEX.md (run `grimoire index`): " + stale.join(", "));
507
+ if (drift.length) probs.push("skills/catalog.md missing tooling MCP: " + drift.join(", "));
508
+ if (probs.length) fail(probs.join("; "));
509
+ log("grimoire index --check: all INDEX.md current; catalog covers tooling MCP.");
510
+ return;
511
+ }
512
+ log("grimoire index: refreshed INDEX.md under " + INDEX_FOLDERS.join("/ ") + "/");
513
+ if (drift.length) log(" warning: skills/catalog.md does not mention tooling MCP: " + drift.join(", "));
514
+ }
515
+
516
+ // Read-only health check: verify a project is correctly wired. Aggregates
517
+ // findings, prints one line each, exits 1 if any error (CI-friendly).
518
+ function doctor({ dir }) {
519
+ const destAgents = path.join(dir, ".agents");
520
+ if (!fs.existsSync(destAgents)) fail("no .agents/ here run `grimoire init` first.");
521
+ const errors = [];
522
+ const warnings = [];
523
+ const err = (m) => errors.push(m);
524
+ const warn = (m) => warnings.push(m);
525
+
526
+ // 1. wiring CLAUDE.md imports the contract.
527
+ const claude = path.join(dir, "CLAUDE.md");
528
+ if (!fs.existsSync(claude)) {
529
+ err("CLAUDE.md missing the agent entry point is not wired.");
530
+ } else {
531
+ const t = fs.readFileSync(claude, "utf8");
532
+ if (!t.includes("@.agents/AGENTS.md")) err("CLAUDE.md does not import @.agents/AGENTS.md.");
533
+ if (!t.includes("@local/AGENTS.local.md"))
534
+ warn("CLAUDE.md does not import @local/AGENTS.local.md (local overrides won't load).");
535
+ if (t.includes("@.agents/local/"))
536
+ warn("CLAUDE.md imports @.agents/local/… (old layout) should be @local/…; run `grimoire sync` to repair.");
537
+ }
538
+
539
+ // 2. skill frontmatter — mirrored skills need name: + description: to be discoverable.
540
+ const skillDirs = [
541
+ { rel: "skills", base: destAgents },
542
+ { rel: path.join("local", "skills"), base: dir },
543
+ ];
544
+ for (const { rel, base } of skillDirs) {
545
+ const sdir = path.join(base, rel);
546
+ if (!fs.existsSync(sdir)) continue;
547
+ for (const e of fs.readdirSync(sdir, { withFileTypes: true })) {
548
+ if (!e.isDirectory()) continue;
549
+ const sk = path.join(sdir, e.name, "SKILL.md");
550
+ if (!fs.existsSync(sk)) continue;
551
+ const fm = fs.readFileSync(sk, "utf8").match(/^---\r?\n([\s\S]*?)\r?\n---/);
552
+ const body = fm ? fm[1] : "";
553
+ if (!/^name:\s*\S/m.test(body) || !/^description:\s*\S/m.test(body))
554
+ err(`${rel.replace(/\\/g, "/")}/${e.name}/SKILL.md needs name: + description: (Claude Code can't discover it otherwise).`);
555
+ }
556
+ }
557
+
558
+ // 3. INDEX + catalog drift (root + local).
559
+ for (const s of generateIndexes(dir, { check: true }))
560
+ err(`stale INDEX.md (run \`grimoire index\`): ${s}`);
561
+ for (const m of catalogDrift(destAgents)) err(`skills/catalog.md missing tooling MCP: ${m}`);
562
+
563
+ // 4. AGENTS.local filled stack profile + testing policy set, not placeholders.
564
+ const localEntry = path.join(dir, "local", "AGENTS.local.md");
565
+ if (fs.existsSync(localEntry)) {
566
+ const t = fs.readFileSync(localEntry, "utf8");
567
+ const val = (label) => (t.match(new RegExp(label + ":\\*\\*\\s*(.*)")) || [])[1];
568
+ const unset = (v) => !v || v.trim() === "" || v.trim().startsWith("<!--");
569
+ if (unset(val("Active stack profile")))
570
+ warn("local/AGENTS.local.md: Active stack profile not set (still the seeded placeholder).");
571
+ if (unset(val("Testing policy")))
572
+ warn("local/AGENTS.local.md: Testing policy not set (still the seeded placeholder).");
573
+ }
574
+
575
+ // 5. entry-file size ceiling (rules/35-context-economy.md).
576
+ for (const rel of ["CLAUDE.md", path.join(".agents", "AGENTS.md"), path.join("local", "AGENTS.local.md")]) {
577
+ const f = path.join(dir, rel);
578
+ if (!fs.existsSync(f)) continue;
579
+ const n = fs.readFileSync(f, "utf8").split(/\r?\n/).length;
580
+ if (n > 300) warn(`${rel.replace(/\\/g, "/")} is ${n} lines (>300 keep entry files lean).`);
581
+ }
582
+
583
+ // 7. knowledge base scaffolded — codex/INDEX.md is the read-first project knowledge home.
584
+ if (!fs.existsSync(path.join(dir, "codex", "INDEX.md")))
585
+ warn("codex/INDEX.md missing the project knowledge base isn't scaffolded (run `grimoire init`).");
586
+
587
+ // 8. local/tooling.json (if present) must be valid JSON — bootstrap reads it.
588
+ const lt = path.join(dir, "local", "tooling.json");
589
+ if (fs.existsSync(lt)) {
590
+ try { JSON.parse(fs.readFileSync(lt, "utf8")); }
591
+ catch { err("local/tooling.json is not valid JSON (grimoire bootstrap can't read it)."); }
592
+ }
593
+
594
+ log(`grimoire doctor: ${errors.length} error(s), ${warnings.length} warning(s)`);
595
+ for (const e of errors) log(" error: " + e);
596
+ for (const w of warnings) log(" warn: " + w);
597
+ if (!errors.length && !warnings.length) log(" all checks passed.");
598
+ if (errors.length) process.exit(1);
599
+ }
600
+
601
+ // Report both versions the user tracks: the release semver (package.json) and the build sha (git).
602
+ function version() {
603
+ log(`grimoire v${pkgVersion()} (sha ${templateSha()})`);
604
+ }
605
+
606
+ function help() {
607
+ log("grimoire <command> [--dir <path>]\n");
608
+ log(" init scaffold .agents/ + CLAUDE.md + codex/ journal/ local/ (migrates an old layout; backs up first)");
609
+ log(" sync wholesale-replace the .agents/ contract from the template (codex/ journal/ local/ untouched)");
610
+ log(" bootstrap enable required plugins / MCP / skills (dry-run; --apply to write)");
611
+ log(" index regenerate per-folder INDEX.md (--check fails on drift, for CI)");
612
+ log(" doctor health-check the project's wiring (exits non-zero on error, for CI)");
613
+ log(" --version print the release version + build sha (-v)");
614
+ }
615
+
616
+ // Only dispatch the CLI when invoked directly (so importing this module — e.g. from tests —
617
+ // does not execute commands or call process.exit).
618
+ if (import.meta.url === `file://${process.argv[1]}` || process.argv[1]?.endsWith("grimoire.mjs")) {
619
+ const args = parseArgs(process.argv.slice(2));
620
+ switch (args.cmd) {
621
+ case "init": init(args); break;
622
+ case "sync": sync(args); break;
623
+ case "bootstrap": bootstrap(args); break;
624
+ case "index": index(args); break;
625
+ case "doctor": doctor(args); break;
626
+ case "--version": case "-v": version(); break;
627
+ case "--help": case "-h": case undefined: help(); break;
628
+ default: fail(`unknown command "${args.cmd}" (try --help)`);
629
+ }
630
+ }