the-grimoire-cli 0.3.2

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 (103) hide show
  1. package/.agents/AGENTS.md +112 -0
  2. package/.agents/NAVIGATOR.md +168 -0
  3. package/.agents/VERSION +4 -0
  4. package/.agents/agents/INDEX.md +7 -0
  5. package/.agents/agents/verifier.md +50 -0
  6. package/.agents/commands/INDEX.md +11 -0
  7. package/.agents/commands/checkpoint.md +15 -0
  8. package/.agents/commands/grimoire.md +14 -0
  9. package/.agents/commands/onboard.md +56 -0
  10. package/.agents/commands/present.md +23 -0
  11. package/.agents/commands/verify.md +20 -0
  12. package/.agents/grimoire.manifest +18 -0
  13. package/.agents/rules/00-always.md +42 -0
  14. package/.agents/rules/05-code-quality.md +28 -0
  15. package/.agents/rules/10-working-process.md +31 -0
  16. package/.agents/rules/15-skills.md +27 -0
  17. package/.agents/rules/20-modes.md +41 -0
  18. package/.agents/rules/25-surgical-changes.md +29 -0
  19. package/.agents/rules/30-verification.md +36 -0
  20. package/.agents/rules/35-context-economy.md +41 -0
  21. package/.agents/rules/40-handoff.md +25 -0
  22. package/.agents/rules/45-presentation.md +35 -0
  23. package/.agents/rules/50-security.md +30 -0
  24. package/.agents/rules/60-commit-style.md +14 -0
  25. package/.agents/rules/INDEX.md +18 -0
  26. package/.agents/skills/INDEX.md +8 -0
  27. package/.agents/skills/README.md +6 -0
  28. package/.agents/skills/catalog.md +106 -0
  29. package/.agents/skills/find-skills/SKILL.md +142 -0
  30. package/.agents/stack/INDEX.md +9 -0
  31. package/.agents/stack/README.md +66 -0
  32. package/.agents/stack/desktop.md +36 -0
  33. package/.agents/stack/library.md +16 -0
  34. package/.agents/stack/web-app.md +32 -0
  35. package/.agents/standards/INDEX.md +23 -0
  36. package/.agents/standards/accessibility.md +50 -0
  37. package/.agents/standards/architecture.md +39 -0
  38. package/.agents/standards/attribution.md +39 -0
  39. package/.agents/standards/clean-code.md +121 -0
  40. package/.agents/standards/codex.md +69 -0
  41. package/.agents/standards/error-codes.md +41 -0
  42. package/.agents/standards/general.md +46 -0
  43. package/.agents/standards/guardrail-tests.md +40 -0
  44. package/.agents/standards/knowledge-management.md +35 -0
  45. package/.agents/standards/launch-security-checklist.md +45 -0
  46. package/.agents/standards/observability.md +35 -0
  47. package/.agents/standards/release-versioning.md +53 -0
  48. package/.agents/standards/requirements.md +75 -0
  49. package/.agents/standards/security-scanners.md +42 -0
  50. package/.agents/standards/testing-strategy.md +61 -0
  51. package/.agents/standards/typescript.md +19 -0
  52. package/.agents/standards/writing.md +58 -0
  53. package/.agents/tooling.json +19 -0
  54. package/LICENSE +21 -0
  55. package/README.md +139 -0
  56. package/bin/grimoire.mjs +598 -0
  57. package/package.json +32 -0
  58. package/templates/CLAUDE.md +7 -0
  59. package/templates/ci/ci.yml +49 -0
  60. package/templates/ci/sast.yml +44 -0
  61. package/templates/codex/INDEX.md +18 -0
  62. package/templates/codex/README.md +28 -0
  63. package/templates/codex/decisions/0000-template.md +36 -0
  64. package/templates/codex/decisions/INDEX.md +11 -0
  65. package/templates/codex/decisions/README.md +25 -0
  66. package/templates/codex/domain/INDEX.md +14 -0
  67. package/templates/codex/domain/README.md +10 -0
  68. package/templates/codex/evidence/0000-extraction-template.md +36 -0
  69. package/templates/codex/evidence/INDEX.md +11 -0
  70. package/templates/codex/evidence/README.md +15 -0
  71. package/templates/codex/reference/INDEX.md +11 -0
  72. package/templates/codex/reference/README.md +15 -0
  73. package/templates/codex/reference/confirmed-values.md +18 -0
  74. package/templates/codex/requirements/INDEX.md +11 -0
  75. package/templates/codex/requirements/README.md +22 -0
  76. package/templates/codex/requirements/addons/0000-template.md +35 -0
  77. package/templates/codex/requirements/base.md +36 -0
  78. package/templates/codex/requirements/changes/0000-template.md +39 -0
  79. package/templates/codex/resources/INDEX.md +11 -0
  80. package/templates/codex/resources/README.md +17 -0
  81. package/templates/codex/resources/manifest.md +11 -0
  82. package/templates/codex/runbooks/INDEX.md +9 -0
  83. package/templates/codex/runbooks/README.md +8 -0
  84. package/templates/codex/runbooks/incident-runbook-template.md +58 -0
  85. package/templates/gitignore-snippet.txt +12 -0
  86. package/templates/journal/backlog/README.md +18 -0
  87. package/templates/journal/backlog/done/.gitkeep +0 -0
  88. package/templates/journal/memory/MEMORY.md +15 -0
  89. package/templates/journal/session/.gitkeep +0 -0
  90. package/templates/journal/session/archive/.gitkeep +1 -0
  91. package/templates/journal/session/artifacts/.gitkeep +1 -0
  92. package/templates/journal/session/current.md +12 -0
  93. package/templates/lint/README.md +25 -0
  94. package/templates/lint/eslint.config.mjs +33 -0
  95. package/templates/lint/tsconfig.base.json +11 -0
  96. package/templates/local/AGENTS.local.md +33 -0
  97. package/templates/local/README.md +55 -0
  98. package/templates/local/commands/.gitkeep +0 -0
  99. package/templates/local/rules/.gitkeep +0 -0
  100. package/templates/local/skills/.gitkeep +0 -0
  101. package/templates/local/stack/.gitkeep +0 -0
  102. package/templates/local/standards/.gitkeep +0 -0
  103. package/templates/tests/guardrail.invariants.test.ts +59 -0
@@ -0,0 +1,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 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
+ }
package/package.json ADDED
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "the-grimoire-cli",
3
+ "version": "0.3.2",
4
+ "description": "A reusable, tool-agnostic AI-agent operating system. Init it into any project; sync template updates without clobbering local customization.",
5
+ "type": "module",
6
+ "bin": {
7
+ "grimoire": "bin/grimoire.mjs"
8
+ },
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/nuttchanon/the-grimoire.git"
12
+ },
13
+ "homepage": "https://github.com/nuttchanon/the-grimoire#readme",
14
+ "bugs": "https://github.com/nuttchanon/the-grimoire/issues",
15
+ "keywords": ["ai", "agent", "claude", "claude-code", "scaffold", "template", "cli", "agents.md"],
16
+ "files": [
17
+ "bin/",
18
+ ".agents/",
19
+ "templates/",
20
+ "README.md",
21
+ "LICENSE"
22
+ ],
23
+ "scripts": {
24
+ "test": "node --test \"test/**/*.test.mjs\"",
25
+ "index:check": "node bin/grimoire.mjs index --dir . --check"
26
+ },
27
+ "engines": {
28
+ "node": ">=18"
29
+ },
30
+ "license": "MIT",
31
+ "author": "Nutt"
32
+ }
@@ -0,0 +1,7 @@
1
+ # CLAUDE.md
2
+
3
+ This project uses **Grimoire**. The canonical agent contract is `.agents/AGENTS.md`.
4
+ Read it first, then follow its load order.
5
+
6
+ @.agents/AGENTS.md
7
+ @local/AGENTS.local.md