codealmanac 0.1.5 → 0.1.7

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 (44) hide show
  1. package/dist/chunk-2JJTTN7P.js +539 -0
  2. package/dist/chunk-2JJTTN7P.js.map +1 -0
  3. package/dist/chunk-3C5SY5SE.js +1239 -0
  4. package/dist/chunk-3C5SY5SE.js.map +1 -0
  5. package/dist/chunk-4CODZRHH.js +19 -0
  6. package/dist/chunk-4CODZRHH.js.map +1 -0
  7. package/dist/chunk-7JUX4ADQ.js +38 -0
  8. package/dist/chunk-7JUX4ADQ.js.map +1 -0
  9. package/dist/chunk-A6PUCAVJ.js +145 -0
  10. package/dist/chunk-A6PUCAVJ.js.map +1 -0
  11. package/dist/chunk-AXFPUHBN.js +227 -0
  12. package/dist/chunk-AXFPUHBN.js.map +1 -0
  13. package/dist/chunk-FM3VRDK7.js +20 -0
  14. package/dist/chunk-FM3VRDK7.js.map +1 -0
  15. package/dist/chunk-H6WU6PYH.js +441 -0
  16. package/dist/chunk-H6WU6PYH.js.map +1 -0
  17. package/dist/chunk-P3LDTCLB.js +34 -0
  18. package/dist/chunk-P3LDTCLB.js.map +1 -0
  19. package/dist/chunk-QHQ6YH7U.js +81 -0
  20. package/dist/chunk-QHQ6YH7U.js.map +1 -0
  21. package/dist/chunk-Z4MWLVS2.js +355 -0
  22. package/dist/chunk-Z4MWLVS2.js.map +1 -0
  23. package/dist/chunk-Z6MBJ3D2.js +203 -0
  24. package/dist/chunk-Z6MBJ3D2.js.map +1 -0
  25. package/dist/cli-AIH5QQ5H.js +393 -0
  26. package/dist/cli-AIH5QQ5H.js.map +1 -0
  27. package/dist/codealmanac.js +68 -5954
  28. package/dist/codealmanac.js.map +1 -1
  29. package/dist/doctor-6FN5JO5F.js +15 -0
  30. package/dist/doctor-6FN5JO5F.js.map +1 -0
  31. package/dist/hook-CRJMWSSO.js +12 -0
  32. package/dist/hook-CRJMWSSO.js.map +1 -0
  33. package/dist/register-commands-PZMQNGCH.js +2644 -0
  34. package/dist/register-commands-PZMQNGCH.js.map +1 -0
  35. package/dist/uninstall-NBEZNNKM.js +12 -0
  36. package/dist/uninstall-NBEZNNKM.js.map +1 -0
  37. package/dist/update-IL243I4E.js +10 -0
  38. package/dist/update-IL243I4E.js.map +1 -0
  39. package/dist/wiki-EHZ7LG7R.js +238 -0
  40. package/dist/wiki-EHZ7LG7R.js.map +1 -0
  41. package/guides/processing/claude-code.md +152 -0
  42. package/guides/processing/codex.md +214 -0
  43. package/guides/processing/generic.md +128 -0
  44. package/package.json +2 -2
@@ -0,0 +1,2644 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ addEntry,
4
+ ancestorsInFile,
5
+ descendantsInDb,
6
+ dropEntry,
7
+ ensureFreshIndex,
8
+ ensureGlobalDir,
9
+ ensureTopic,
10
+ findTopic,
11
+ loadTopicsFile,
12
+ looksLikeDir,
13
+ normalizePath,
14
+ openIndex,
15
+ parseDuration,
16
+ parseFrontmatter,
17
+ readRegistry,
18
+ resolveWikiRoot,
19
+ runHealth,
20
+ runIndexer,
21
+ titleCase,
22
+ toKebabCase,
23
+ writeTopicsFile
24
+ } from "./chunk-3C5SY5SE.js";
25
+ import {
26
+ runDoctor
27
+ } from "./chunk-H6WU6PYH.js";
28
+ import "./chunk-4CODZRHH.js";
29
+ import {
30
+ runUninstall
31
+ } from "./chunk-A6PUCAVJ.js";
32
+ import {
33
+ runUpdate
34
+ } from "./chunk-Z6MBJ3D2.js";
35
+ import {
36
+ collectOption,
37
+ emit,
38
+ parsePositiveInt,
39
+ readStdin
40
+ } from "./chunk-P3LDTCLB.js";
41
+ import "./chunk-QHQ6YH7U.js";
42
+ import {
43
+ BLUE,
44
+ BOLD,
45
+ DIM,
46
+ RST
47
+ } from "./chunk-FM3VRDK7.js";
48
+ import {
49
+ assertClaudeAuth,
50
+ runSetup
51
+ } from "./chunk-2JJTTN7P.js";
52
+ import {
53
+ runHookInstall,
54
+ runHookStatus,
55
+ runHookUninstall
56
+ } from "./chunk-Z4MWLVS2.js";
57
+ import "./chunk-AXFPUHBN.js";
58
+ import {
59
+ findNearestAlmanacDir,
60
+ getRepoAlmanacDir
61
+ } from "./chunk-7JUX4ADQ.js";
62
+
63
+ // src/topics/frontmatter-rewrite.ts
64
+ import { readFile, rename, writeFile } from "fs/promises";
65
+ import yaml from "js-yaml";
66
+ async function rewritePageTopics(filePath, transform) {
67
+ const raw = await readFile(filePath, "utf8");
68
+ const { before, after, output, changed } = applyTopicsTransform(
69
+ raw,
70
+ transform
71
+ );
72
+ if (changed) {
73
+ const tmp = `${filePath}.tmp`;
74
+ await writeFile(tmp, output, "utf8");
75
+ await rename(tmp, filePath);
76
+ }
77
+ return { before, after, changed };
78
+ }
79
+ function applyTopicsTransform(raw, transform) {
80
+ const parsed = splitFrontmatter(raw);
81
+ if (parsed === null) {
82
+ const next = dedupeSlugs(transform([]));
83
+ if (next.length === 0) {
84
+ return { before: [], after: [], output: raw, changed: false };
85
+ }
86
+ const fm = `---
87
+ topics: ${flowList(next)}
88
+ ---
89
+
90
+ `;
91
+ return {
92
+ before: [],
93
+ after: next,
94
+ output: `${fm}${raw}`,
95
+ changed: true
96
+ };
97
+ }
98
+ const { opener, fmLines, closer, body, eol } = parsed;
99
+ const { before, existingRange } = readTopicsFromLines(fmLines);
100
+ const beforeDeduped = dedupeSlugs(before);
101
+ const after = dedupeSlugs(transform(beforeDeduped));
102
+ if (arraysEqual(beforeDeduped, after)) {
103
+ return { before: beforeDeduped, after, output: raw, changed: false };
104
+ }
105
+ let nextFmLines;
106
+ if (existingRange === null) {
107
+ if (after.length === 0) {
108
+ return { before: beforeDeduped, after, output: raw, changed: false };
109
+ }
110
+ nextFmLines = [...fmLines, `topics: ${flowList(after)}`];
111
+ } else {
112
+ const replacement = after.length === 0 ? null : `topics: ${flowList(after)}`;
113
+ const preservedTail = replacement === null ? [] : existingRange.preserved;
114
+ nextFmLines = [
115
+ ...fmLines.slice(0, existingRange.start),
116
+ ...replacement === null ? [] : [replacement],
117
+ ...preservedTail,
118
+ ...fmLines.slice(existingRange.end)
119
+ ];
120
+ }
121
+ const fmBlock = nextFmLines.length === 0 ? "" : `${nextFmLines.join(eol)}${eol}`;
122
+ const output = `${opener}${fmBlock}${closer}${body}`;
123
+ return {
124
+ before: beforeDeduped,
125
+ after,
126
+ output,
127
+ changed: true
128
+ };
129
+ }
130
+ function splitFrontmatter(raw) {
131
+ if (!raw.startsWith("---")) return null;
132
+ const openerMatch = raw.match(/^---(\r?\n)/);
133
+ if (openerMatch === null) return null;
134
+ const opener = `---${openerMatch[1] ?? "\n"}`;
135
+ const rest = raw.slice(opener.length);
136
+ let fenceIdx;
137
+ if (rest.startsWith("---")) {
138
+ fenceIdx = 0;
139
+ } else {
140
+ const m = rest.match(/\r?\n---(\r?\n|$)/);
141
+ if (m === null || m.index === void 0) return null;
142
+ const leadingNewlineLen = (m[0] ?? "").startsWith("\r\n") ? 2 : 1;
143
+ fenceIdx = m.index + leadingNewlineLen;
144
+ }
145
+ const fmBlock = rest.slice(0, fenceIdx);
146
+ const afterDashes = rest.slice(fenceIdx + 3);
147
+ let closerTail = "";
148
+ if (afterDashes.startsWith("\r\n")) {
149
+ closerTail = "\r\n";
150
+ } else if (afterDashes.startsWith("\n")) {
151
+ closerTail = "\n";
152
+ }
153
+ const closer = `---${closerTail}`;
154
+ const body = afterDashes.slice(closerTail.length);
155
+ const fmLines = fmBlock.length === 0 ? [] : fmBlock.replace(/\r?\n$/, "").split(/\r?\n/);
156
+ const eol = opener.endsWith("\r\n") || /\r\n/.test(fmBlock) ? "\r\n" : "\n";
157
+ return { opener, fmLines, closer, body, eol };
158
+ }
159
+ function readTopicsFromLines(fmLines) {
160
+ const keyLineIdx = findTopKey(fmLines, "topics");
161
+ if (keyLineIdx === -1) {
162
+ return { before: [], existingRange: null };
163
+ }
164
+ const keyLine = fmLines[keyLineIdx] ?? "";
165
+ const colonIdx = keyLine.indexOf(":");
166
+ const after = keyLine.slice(colonIdx + 1).trim();
167
+ const afterNoComment = stripTrailingComment(after);
168
+ if (afterNoComment.length === 0) {
169
+ const values2 = [];
170
+ const preserved = [];
171
+ let i = keyLineIdx + 1;
172
+ let endIdx = i;
173
+ let pendingNonEntries = [];
174
+ while (i < fmLines.length) {
175
+ const line = fmLines[i] ?? "";
176
+ const trimmed = line.trim();
177
+ if (trimmed.length === 0 || trimmed.startsWith("#")) {
178
+ pendingNonEntries.push(line);
179
+ i += 1;
180
+ continue;
181
+ }
182
+ const m = line.match(/^\s*-\s+(.*)$/);
183
+ if (m === null) break;
184
+ if (pendingNonEntries.length > 0) {
185
+ preserved.push(...pendingNonEntries);
186
+ pendingNonEntries = [];
187
+ }
188
+ const raw = stripTrailingComment((m[1] ?? "").trim());
189
+ const parsed2 = parseScalar(raw);
190
+ if (parsed2.length > 0) values2.push(parsed2);
191
+ i += 1;
192
+ endIdx = i;
193
+ }
194
+ return {
195
+ before: values2,
196
+ existingRange: { start: keyLineIdx, end: endIdx, preserved }
197
+ };
198
+ }
199
+ let parsed;
200
+ try {
201
+ parsed = yaml.load(afterNoComment);
202
+ } catch {
203
+ parsed = null;
204
+ }
205
+ const values = [];
206
+ if (Array.isArray(parsed)) {
207
+ for (const v of parsed) {
208
+ if (typeof v === "string" && v.trim().length > 0) {
209
+ values.push(v.trim());
210
+ }
211
+ }
212
+ } else if (typeof parsed === "string" && parsed.trim().length > 0) {
213
+ values.push(parsed.trim());
214
+ }
215
+ return {
216
+ before: values,
217
+ existingRange: { start: keyLineIdx, end: keyLineIdx + 1, preserved: [] }
218
+ };
219
+ }
220
+ function findTopKey(fmLines, key) {
221
+ const re = new RegExp(`^${escapeRegex(key)}\\s*:`);
222
+ for (let i = 0; i < fmLines.length; i += 1) {
223
+ if (re.test(fmLines[i] ?? "")) return i;
224
+ }
225
+ return -1;
226
+ }
227
+ function escapeRegex(s) {
228
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
229
+ }
230
+ function stripTrailingComment(s) {
231
+ let inSingle = false;
232
+ let inDouble = false;
233
+ for (let i = 0; i < s.length; i += 1) {
234
+ const ch = s[i];
235
+ if (ch === "'" && !inDouble) inSingle = !inSingle;
236
+ else if (ch === '"' && !inSingle) inDouble = !inDouble;
237
+ else if (ch === "#" && !inSingle && !inDouble) {
238
+ return s.slice(0, i).trimEnd();
239
+ }
240
+ }
241
+ return s;
242
+ }
243
+ function parseScalar(s) {
244
+ if (s.length === 0) return s;
245
+ if (s.length >= 2 && s[0] === '"' && s[s.length - 1] === '"') {
246
+ return s.slice(1, -1);
247
+ }
248
+ if (s.length >= 2 && s[0] === "'" && s[s.length - 1] === "'") {
249
+ return s.slice(1, -1);
250
+ }
251
+ return s;
252
+ }
253
+ function flowList(items) {
254
+ return `[${items.map((t) => formatScalar(t)).join(", ")}]`;
255
+ }
256
+ function formatScalar(s) {
257
+ if (/^[a-z0-9][a-z0-9-]*$/.test(s)) return s;
258
+ return yaml.dump(s, { flowLevel: 0, lineWidth: Number.MAX_SAFE_INTEGER }).trimEnd();
259
+ }
260
+ function dedupeSlugs(list) {
261
+ const seen = /* @__PURE__ */ new Set();
262
+ const out = [];
263
+ for (const raw of list) {
264
+ const s = raw.trim();
265
+ if (s.length === 0) continue;
266
+ if (seen.has(s)) continue;
267
+ seen.add(s);
268
+ out.push(s);
269
+ }
270
+ return out;
271
+ }
272
+ function arraysEqual(a, b) {
273
+ if (a.length !== b.length) return false;
274
+ for (let i = 0; i < a.length; i += 1) {
275
+ if (a[i] !== b[i]) return false;
276
+ }
277
+ return true;
278
+ }
279
+
280
+ // src/topics/paths.ts
281
+ import { join } from "path";
282
+ function topicsYamlPath(repoRoot) {
283
+ return join(repoRoot, ".almanac", "topics.yaml");
284
+ }
285
+ function indexDbPath(repoRoot) {
286
+ return join(repoRoot, ".almanac", "index.db");
287
+ }
288
+
289
+ // src/commands/tag.ts
290
+ async function runTag(options) {
291
+ const repoRoot = await resolveWikiRoot({ cwd: options.cwd, wiki: options.wiki });
292
+ const topics = options.topics.map((t) => toKebabCase(t)).filter((t) => t.length > 0);
293
+ if (topics.length === 0) {
294
+ return {
295
+ stdout: "",
296
+ stderr: "almanac: tag requires at least one topic\n",
297
+ exitCode: 1
298
+ };
299
+ }
300
+ const pages = [];
301
+ if (options.stdin === true) {
302
+ if (options.stdinInput === void 0) {
303
+ return {
304
+ stdout: "",
305
+ stderr: "almanac: tag --stdin called without stdin input\n",
306
+ exitCode: 1
307
+ };
308
+ }
309
+ for (const line of options.stdinInput.split(/\r?\n/)) {
310
+ const s = line.trim();
311
+ if (s.length > 0) pages.push(s);
312
+ }
313
+ } else if (options.page !== void 0 && options.page.length > 0) {
314
+ pages.push(options.page);
315
+ } else {
316
+ return {
317
+ stdout: "",
318
+ stderr: "almanac: tag requires a page slug (or --stdin)\n",
319
+ exitCode: 1
320
+ };
321
+ }
322
+ await ensureFreshIndex({ repoRoot });
323
+ const db = openIndex(indexDbPath(repoRoot));
324
+ const stmt = db.prepare(
325
+ "SELECT file_path FROM pages WHERE slug = ?"
326
+ );
327
+ const resolved = [];
328
+ const missing = [];
329
+ try {
330
+ for (const page of pages) {
331
+ const row = stmt.get(toKebabCase(page));
332
+ if (row === void 0) {
333
+ missing.push(page);
334
+ } else {
335
+ resolved.push({ page, filePath: row.file_path });
336
+ }
337
+ }
338
+ } finally {
339
+ db.close();
340
+ }
341
+ if (resolved.length === 0) {
342
+ const stderr2 = missing.map((p) => `almanac: no such page "${p}"
343
+ `).join("");
344
+ return {
345
+ stdout: "",
346
+ stderr: stderr2,
347
+ exitCode: 1
348
+ };
349
+ }
350
+ const yamlPath = topicsYamlPath(repoRoot);
351
+ const file = await loadTopicsFile(yamlPath);
352
+ let fileChanged = false;
353
+ for (const t of topics) {
354
+ const before = file.topics.length;
355
+ ensureTopic(file, t);
356
+ if (file.topics.length > before) fileChanged = true;
357
+ }
358
+ if (fileChanged) {
359
+ await writeTopicsFile(yamlPath, file);
360
+ }
361
+ const summary = [];
362
+ let taggedPages = 0;
363
+ for (const { page, filePath } of resolved) {
364
+ const result = await rewritePageTopics(filePath, (current) => {
365
+ const out = [...current];
366
+ for (const t of topics) if (!current.includes(t)) out.push(t);
367
+ return out;
368
+ });
369
+ if (result.changed) {
370
+ taggedPages += 1;
371
+ const added = result.after.filter((t) => !result.before.includes(t));
372
+ summary.push(`tagged ${page}: ${added.join(", ")}`);
373
+ } else {
374
+ summary.push(
375
+ `no change ${page} (already tagged with ${topics.join(", ")})`
376
+ );
377
+ }
378
+ }
379
+ if (taggedPages > 0 || fileChanged) {
380
+ await runIndexer({ repoRoot });
381
+ }
382
+ const stderr = missing.map((p) => `almanac: no such page "${p}"
383
+ `).join("");
384
+ return {
385
+ stdout: summary.length > 0 ? `${summary.join("\n")}
386
+ ` : "",
387
+ stderr,
388
+ exitCode: missing.length > 0 ? 1 : 0
389
+ };
390
+ }
391
+ async function runUntag(options) {
392
+ const repoRoot = await resolveWikiRoot({ cwd: options.cwd, wiki: options.wiki });
393
+ const page = toKebabCase(options.page);
394
+ const topic = toKebabCase(options.topic);
395
+ if (page.length === 0) {
396
+ return {
397
+ stdout: "",
398
+ stderr: "almanac: untag requires a page slug\n",
399
+ exitCode: 1
400
+ };
401
+ }
402
+ if (topic.length === 0) {
403
+ return {
404
+ stdout: "",
405
+ stderr: "almanac: untag requires a topic\n",
406
+ exitCode: 1
407
+ };
408
+ }
409
+ await ensureFreshIndex({ repoRoot });
410
+ const db = openIndex(indexDbPath(repoRoot));
411
+ let filePath;
412
+ try {
413
+ const row = db.prepare(
414
+ "SELECT file_path FROM pages WHERE slug = ?"
415
+ ).get(page);
416
+ if (row === void 0) {
417
+ return {
418
+ stdout: "",
419
+ stderr: `almanac: no such page "${page}"
420
+ `,
421
+ exitCode: 1
422
+ };
423
+ }
424
+ filePath = row.file_path;
425
+ } finally {
426
+ db.close();
427
+ }
428
+ const result = await rewritePageTopics(
429
+ filePath,
430
+ (current) => current.filter((t) => t !== topic)
431
+ );
432
+ if (result.changed) {
433
+ await runIndexer({ repoRoot });
434
+ }
435
+ return {
436
+ stdout: result.changed ? `untagged ${page}: ${topic}
437
+ ` : `no change ${page} (not tagged with ${topic})
438
+ `,
439
+ stderr: "",
440
+ exitCode: 0
441
+ };
442
+ }
443
+
444
+ // src/commands/topics/workspace.ts
445
+ function resolveTopicsRepo(options) {
446
+ return resolveWikiRoot({ cwd: options.cwd, wiki: options.wiki });
447
+ }
448
+ async function openFreshTopicsWorkspace(repoRoot) {
449
+ await ensureFreshIndex({ repoRoot });
450
+ const yamlPath = topicsYamlPath(repoRoot);
451
+ const file = await loadTopicsFile(yamlPath);
452
+ const db = openIndex(indexDbPath(repoRoot));
453
+ return { repoRoot, yamlPath, file, db };
454
+ }
455
+ function closeWorkspace(workspace) {
456
+ workspace.db.close();
457
+ }
458
+ function topicExists(file, db, slug) {
459
+ if (findTopic(file, slug) !== null) return true;
460
+ const row = db.prepare(
461
+ "SELECT slug FROM topics WHERE slug = ?"
462
+ ).get(slug);
463
+ return row !== void 0;
464
+ }
465
+
466
+ // src/commands/topics/create.ts
467
+ async function runTopicsCreate(options) {
468
+ const repoRoot = await resolveTopicsRepo(options);
469
+ const slug = toKebabCase(options.name);
470
+ if (slug.length === 0) {
471
+ return {
472
+ stdout: "",
473
+ stderr: `almanac: topic name "${options.name}" has no slug-able characters
474
+ `,
475
+ exitCode: 1
476
+ };
477
+ }
478
+ const title = options.name.trim().length > 0 ? options.name.trim() : titleCase(slug);
479
+ const workspace = await openFreshTopicsWorkspace(repoRoot);
480
+ try {
481
+ const { repoRoot: repoRoot2, yamlPath, file, db } = workspace;
482
+ const requestedParents = (options.parents ?? []).map((p) => toKebabCase(p)).filter((p) => p.length > 0);
483
+ for (const p of requestedParents) {
484
+ if (p === slug) {
485
+ return {
486
+ stdout: "",
487
+ stderr: `almanac: topic cannot be its own parent
488
+ `,
489
+ exitCode: 1
490
+ };
491
+ }
492
+ if (!topicExists(file, db, p)) {
493
+ return {
494
+ stdout: "",
495
+ stderr: `almanac: parent topic "${p}" does not exist; create it first with \`almanac topics create ${p}\`
496
+ `,
497
+ exitCode: 1
498
+ };
499
+ }
500
+ if (findTopic(file, p) === null) {
501
+ ensureTopic(file, p);
502
+ }
503
+ }
504
+ const existing = findTopic(file, slug);
505
+ if (existing === null) {
506
+ const entry = {
507
+ slug,
508
+ title,
509
+ description: null,
510
+ parents: requestedParents
511
+ };
512
+ file.topics.push(entry);
513
+ } else {
514
+ for (const p of requestedParents) {
515
+ if (existing.parents.includes(p)) continue;
516
+ const ancestors = ancestorsInFile(file, p);
517
+ if (ancestors.has(slug) || p === slug) {
518
+ return {
519
+ stdout: "",
520
+ stderr: `almanac: adding "${p}" as a parent of "${slug}" would create a cycle
521
+ `,
522
+ exitCode: 1
523
+ };
524
+ }
525
+ existing.parents.push(p);
526
+ }
527
+ if (existing.title === titleCase(existing.slug) && title !== titleCase(slug) && title !== existing.title) {
528
+ existing.title = title;
529
+ }
530
+ }
531
+ await writeTopicsFile(yamlPath, file);
532
+ await runIndexer({ repoRoot: repoRoot2 });
533
+ return {
534
+ stdout: existing === null ? `created topic "${slug}"
535
+ ` : `updated topic "${slug}"
536
+ `,
537
+ stderr: "",
538
+ exitCode: 0
539
+ };
540
+ } finally {
541
+ closeWorkspace(workspace);
542
+ }
543
+ }
544
+
545
+ // src/commands/topics/page-rewrite.ts
546
+ import { readFile as readFile2 } from "fs/promises";
547
+ import { join as join2 } from "path";
548
+ import fg from "fast-glob";
549
+ async function rewriteTopicOnPages(repoRoot, transform) {
550
+ const pagesDir = join2(repoRoot, ".almanac", "pages");
551
+ const files = await fg("**/*.md", {
552
+ cwd: pagesDir,
553
+ absolute: true,
554
+ onlyFiles: true
555
+ });
556
+ let changed = 0;
557
+ for (const filePath of files) {
558
+ const raw = await readFile2(filePath, "utf8");
559
+ const applied = applyTopicsTransform(raw, transform);
560
+ if (!applied.changed) continue;
561
+ await rewritePageTopics(filePath, transform);
562
+ changed += 1;
563
+ }
564
+ return changed;
565
+ }
566
+
567
+ // src/commands/topics/delete.ts
568
+ async function runTopicsDelete(options) {
569
+ const repoRoot = await resolveTopicsRepo(options);
570
+ const slug = toKebabCase(options.slug);
571
+ if (slug.length === 0) {
572
+ return { stdout: "", stderr: `almanac: empty topic slug
573
+ `, exitCode: 1 };
574
+ }
575
+ const workspace = await openFreshTopicsWorkspace(repoRoot);
576
+ let pagesUpdated;
577
+ try {
578
+ const { repoRoot: repoRoot2, yamlPath, file, db } = workspace;
579
+ if (!topicExists(file, db, slug)) {
580
+ return {
581
+ stdout: "",
582
+ stderr: `almanac: no such topic "${slug}"
583
+ `,
584
+ exitCode: 1
585
+ };
586
+ }
587
+ file.topics = file.topics.filter((t) => t.slug !== slug);
588
+ for (const t of file.topics) {
589
+ t.parents = t.parents.filter((p) => p !== slug);
590
+ }
591
+ await writeTopicsFile(yamlPath, file);
592
+ pagesUpdated = await rewriteTopicOnPages(
593
+ repoRoot2,
594
+ (topics) => topics.filter((t) => t !== slug)
595
+ );
596
+ } finally {
597
+ closeWorkspace(workspace);
598
+ }
599
+ await runIndexer({ repoRoot: workspace.repoRoot });
600
+ return {
601
+ stdout: `deleted topic "${slug}" (${pagesUpdated} page${pagesUpdated === 1 ? "" : "s"} untagged)
602
+ `,
603
+ stderr: "",
604
+ exitCode: 0
605
+ };
606
+ }
607
+
608
+ // src/commands/topics/describe.ts
609
+ async function runTopicsDescribe(options) {
610
+ const repoRoot = await resolveTopicsRepo(options);
611
+ const slug = toKebabCase(options.slug);
612
+ if (slug.length === 0) {
613
+ return { stdout: "", stderr: `almanac: empty topic slug
614
+ `, exitCode: 1 };
615
+ }
616
+ const workspace = await openFreshTopicsWorkspace(repoRoot);
617
+ try {
618
+ const { yamlPath, file, db } = workspace;
619
+ if (!topicExists(file, db, slug)) {
620
+ return {
621
+ stdout: "",
622
+ stderr: `almanac: no such topic "${slug}"
623
+ `,
624
+ exitCode: 1
625
+ };
626
+ }
627
+ const entry = ensureTopic(file, slug);
628
+ const text = options.description.trim();
629
+ entry.description = text.length === 0 ? null : text;
630
+ await writeTopicsFile(yamlPath, file);
631
+ } finally {
632
+ closeWorkspace(workspace);
633
+ }
634
+ await runIndexer({ repoRoot: workspace.repoRoot });
635
+ return {
636
+ stdout: `described ${slug}
637
+ `,
638
+ stderr: "",
639
+ exitCode: 0
640
+ };
641
+ }
642
+
643
+ // src/commands/topics/link.ts
644
+ async function runTopicsLink(options) {
645
+ const repoRoot = await resolveTopicsRepo(options);
646
+ const child = toKebabCase(options.child);
647
+ const parent = toKebabCase(options.parent);
648
+ if (child.length === 0 || parent.length === 0) {
649
+ return { stdout: "", stderr: `almanac: empty topic slug
650
+ `, exitCode: 1 };
651
+ }
652
+ if (child === parent) {
653
+ return {
654
+ stdout: "",
655
+ stderr: `almanac: topic cannot be its own parent
656
+ `,
657
+ exitCode: 1
658
+ };
659
+ }
660
+ const workspace = await openFreshTopicsWorkspace(repoRoot);
661
+ try {
662
+ const { repoRoot: repoRoot2, yamlPath, file, db } = workspace;
663
+ for (const slug of [child, parent]) {
664
+ if (!topicExists(file, db, slug)) {
665
+ return {
666
+ stdout: "",
667
+ stderr: `almanac: topic "${slug}" does not exist
668
+ `,
669
+ exitCode: 1
670
+ };
671
+ }
672
+ if (findTopic(file, slug) === null) {
673
+ ensureTopic(file, slug);
674
+ }
675
+ }
676
+ const childEntry = findTopic(file, child);
677
+ if (childEntry === null) {
678
+ return {
679
+ stdout: "",
680
+ stderr: `almanac: topic "${child}" not found
681
+ `,
682
+ exitCode: 1
683
+ };
684
+ }
685
+ if (childEntry.parents.includes(parent)) {
686
+ return {
687
+ stdout: `edge ${child} \u2192 ${parent} already exists
688
+ `,
689
+ stderr: "",
690
+ exitCode: 0
691
+ };
692
+ }
693
+ const parentAncestors = ancestorsInFile(file, parent);
694
+ if (parentAncestors.has(child) || parent === child) {
695
+ return {
696
+ stdout: "",
697
+ stderr: `almanac: adding ${parent} as parent of ${child} would create a cycle
698
+ `,
699
+ exitCode: 1
700
+ };
701
+ }
702
+ childEntry.parents.push(parent);
703
+ await writeTopicsFile(yamlPath, file);
704
+ await runIndexer({ repoRoot: repoRoot2 });
705
+ return {
706
+ stdout: `linked ${child} \u2192 ${parent}
707
+ `,
708
+ stderr: "",
709
+ exitCode: 0
710
+ };
711
+ } finally {
712
+ closeWorkspace(workspace);
713
+ }
714
+ }
715
+
716
+ // src/commands/topics/list.ts
717
+ async function runTopicsList(options) {
718
+ const repoRoot = await resolveWikiRoot({ cwd: options.cwd, wiki: options.wiki });
719
+ await ensureFreshIndex({ repoRoot });
720
+ const db = openIndex(indexDbPath(repoRoot));
721
+ try {
722
+ const rows = db.prepare(
723
+ // page_count excludes archived pages — matches the policy used
724
+ // by `topics show` (see `pagesDirectlyTagged`) and by every
725
+ // page-scoped check in `health`. Pick one rule and apply it
726
+ // everywhere; a topic with "5 pages" in `topics list` and "3
727
+ // pages" in `topics show` is a trust-eroding inconsistency.
728
+ `SELECT t.slug, t.title, t.description,
729
+ (SELECT COUNT(*)
730
+ FROM page_topics pt
731
+ JOIN pages p ON p.slug = pt.page_slug
732
+ WHERE pt.topic_slug = t.slug AND p.archived_at IS NULL
733
+ ) AS page_count
734
+ FROM topics t
735
+ ORDER BY t.slug`
736
+ ).all();
737
+ if (options.json === true) {
738
+ return {
739
+ stdout: `${JSON.stringify(rows, null, 2)}
740
+ `,
741
+ stderr: "",
742
+ exitCode: 0
743
+ };
744
+ }
745
+ if (rows.length === 0) {
746
+ return {
747
+ stdout: "no topics. create one with `almanac topics create <name>` or tag a page.\n",
748
+ stderr: "",
749
+ exitCode: 0
750
+ };
751
+ }
752
+ const slugWidth = rows.reduce((w, r) => Math.max(w, r.slug.length), 0);
753
+ const lines = rows.map((r) => {
754
+ const slug = r.slug.padEnd(slugWidth);
755
+ const count = `(${r.page_count} page${r.page_count === 1 ? "" : "s"})`;
756
+ return `${BLUE}${slug}${RST} ${DIM}${count}${RST}`;
757
+ });
758
+ return { stdout: `${lines.join("\n")}
759
+ `, stderr: "", exitCode: 0 };
760
+ } finally {
761
+ db.close();
762
+ }
763
+ }
764
+
765
+ // src/commands/topics/rename.ts
766
+ async function runTopicsRename(options) {
767
+ const repoRoot = await resolveTopicsRepo(options);
768
+ const oldSlug = toKebabCase(options.oldSlug);
769
+ const newSlug = toKebabCase(options.newSlug);
770
+ if (oldSlug.length === 0 || newSlug.length === 0) {
771
+ return { stdout: "", stderr: `almanac: empty topic slug
772
+ `, exitCode: 1 };
773
+ }
774
+ if (oldSlug === newSlug) {
775
+ return {
776
+ stdout: `topic "${oldSlug}" unchanged
777
+ `,
778
+ stderr: "",
779
+ exitCode: 0
780
+ };
781
+ }
782
+ const workspace = await openFreshTopicsWorkspace(repoRoot);
783
+ let pagesUpdated;
784
+ try {
785
+ const { repoRoot: repoRoot2, yamlPath, file, db } = workspace;
786
+ const oldInYaml = findTopic(file, oldSlug);
787
+ if (!topicExists(file, db, oldSlug)) {
788
+ return {
789
+ stdout: "",
790
+ stderr: `almanac: no such topic "${oldSlug}"
791
+ `,
792
+ exitCode: 1
793
+ };
794
+ }
795
+ if (topicExists(file, db, newSlug)) {
796
+ return {
797
+ stdout: "",
798
+ stderr: `almanac: topic "${newSlug}" already exists; delete it first if you intend to merge
799
+ `,
800
+ exitCode: 1
801
+ };
802
+ }
803
+ if (oldInYaml !== null) {
804
+ oldInYaml.slug = newSlug;
805
+ if (oldInYaml.title === titleCase(oldSlug)) {
806
+ oldInYaml.title = titleCase(newSlug);
807
+ }
808
+ }
809
+ for (const t of file.topics) {
810
+ t.parents = t.parents.map((p) => p === oldSlug ? newSlug : p);
811
+ }
812
+ await writeTopicsFile(yamlPath, file);
813
+ pagesUpdated = await rewriteTopicOnPages(
814
+ repoRoot2,
815
+ (topics) => topics.map((t) => t === oldSlug ? newSlug : t)
816
+ );
817
+ } finally {
818
+ closeWorkspace(workspace);
819
+ }
820
+ await runIndexer({ repoRoot: workspace.repoRoot });
821
+ return {
822
+ stdout: `renamed ${oldSlug} \u2192 ${newSlug} (${pagesUpdated} page${pagesUpdated === 1 ? "" : "s"} updated)
823
+ `,
824
+ stderr: "",
825
+ exitCode: 0
826
+ };
827
+ }
828
+
829
+ // src/commands/topics/read.ts
830
+ function pagesDirectlyTagged(db, slug) {
831
+ return db.prepare(
832
+ `SELECT pt.page_slug
833
+ FROM page_topics pt
834
+ JOIN pages p ON p.slug = pt.page_slug
835
+ WHERE pt.topic_slug = ? AND p.archived_at IS NULL
836
+ ORDER BY pt.page_slug`
837
+ ).all(slug).map((r) => r.page_slug);
838
+ }
839
+ function pagesForSubtree(db, slug) {
840
+ const slugs = [slug, ...descendantsInDb(db, slug)];
841
+ const placeholders = slugs.map(() => "?").join(", ");
842
+ const rows = db.prepare(
843
+ `SELECT DISTINCT pt.page_slug
844
+ FROM page_topics pt
845
+ JOIN pages p ON p.slug = pt.page_slug
846
+ WHERE pt.topic_slug IN (${placeholders}) AND p.archived_at IS NULL
847
+ ORDER BY pt.page_slug`
848
+ ).all(...slugs);
849
+ return rows.map((r) => r.page_slug);
850
+ }
851
+ function formatShow(r) {
852
+ const lines = [];
853
+ lines.push(`${DIM}slug:${RST} ${BLUE}${r.slug}${RST}`);
854
+ lines.push(`${DIM}title:${RST} ${r.title ?? titleCase(r.slug)}`);
855
+ lines.push(`${DIM}description:${RST} ${r.description ?? "\u2014"}`);
856
+ lines.push(
857
+ `${DIM}parents:${RST} ${r.parents.length > 0 ? r.parents.join(", ") : "\u2014"}`
858
+ );
859
+ lines.push(
860
+ `${DIM}children:${RST} ${r.children.length > 0 ? r.children.join(", ") : "\u2014"}`
861
+ );
862
+ const pagesLabel = r.descendants_used === true ? "pages (incl. descendants)" : "pages";
863
+ lines.push(`${DIM}${pagesLabel}:${RST}`);
864
+ if (r.pages.length === 0) {
865
+ lines.push(" \u2014");
866
+ } else {
867
+ for (const p of r.pages) lines.push(` ${BLUE}${p}${RST}`);
868
+ }
869
+ return `${lines.join("\n")}
870
+ `;
871
+ }
872
+
873
+ // src/commands/topics/show.ts
874
+ async function runTopicsShow(options) {
875
+ const repoRoot = await resolveWikiRoot({ cwd: options.cwd, wiki: options.wiki });
876
+ await ensureFreshIndex({ repoRoot });
877
+ const slug = toKebabCase(options.slug);
878
+ if (slug.length === 0) {
879
+ return {
880
+ stdout: "",
881
+ stderr: `almanac: empty topic slug
882
+ `,
883
+ exitCode: 1
884
+ };
885
+ }
886
+ const db = openIndex(indexDbPath(repoRoot));
887
+ try {
888
+ const row = db.prepare("SELECT slug, title, description FROM topics WHERE slug = ?").get(slug);
889
+ if (row === void 0) {
890
+ return {
891
+ stdout: "",
892
+ stderr: `almanac: no such topic "${slug}"
893
+ `,
894
+ exitCode: 1
895
+ };
896
+ }
897
+ const parents = db.prepare(
898
+ "SELECT parent_slug FROM topic_parents WHERE child_slug = ? ORDER BY parent_slug"
899
+ ).all(slug).map((r) => r.parent_slug);
900
+ const children = db.prepare(
901
+ "SELECT child_slug FROM topic_parents WHERE parent_slug = ? ORDER BY child_slug"
902
+ ).all(slug).map((r) => r.child_slug);
903
+ const pageSlugs = options.descendants === true ? pagesForSubtree(db, slug) : pagesDirectlyTagged(db, slug);
904
+ const record = {
905
+ slug: row.slug,
906
+ title: row.title,
907
+ description: row.description,
908
+ parents,
909
+ children,
910
+ pages: pageSlugs,
911
+ descendants_used: options.descendants === true
912
+ };
913
+ if (options.json === true) {
914
+ return {
915
+ stdout: `${JSON.stringify(record, null, 2)}
916
+ `,
917
+ stderr: "",
918
+ exitCode: 0
919
+ };
920
+ }
921
+ return { stdout: formatShow(record), stderr: "", exitCode: 0 };
922
+ } finally {
923
+ db.close();
924
+ }
925
+ }
926
+
927
+ // src/commands/topics/unlink.ts
928
+ async function runTopicsUnlink(options) {
929
+ const repoRoot = await resolveWikiRoot({ cwd: options.cwd, wiki: options.wiki });
930
+ const child = toKebabCase(options.child);
931
+ const parent = toKebabCase(options.parent);
932
+ if (child.length === 0 || parent.length === 0) {
933
+ return { stdout: "", stderr: `almanac: empty topic slug
934
+ `, exitCode: 1 };
935
+ }
936
+ const yamlPath = topicsYamlPath(repoRoot);
937
+ const file = await loadTopicsFile(yamlPath);
938
+ const childEntry = findTopic(file, child);
939
+ if (childEntry === null || !childEntry.parents.includes(parent)) {
940
+ return {
941
+ stdout: `no edge ${child} \u2192 ${parent}
942
+ `,
943
+ stderr: "",
944
+ exitCode: 0
945
+ };
946
+ }
947
+ childEntry.parents = childEntry.parents.filter((p) => p !== parent);
948
+ await writeTopicsFile(yamlPath, file);
949
+ await runIndexer({ repoRoot });
950
+ return {
951
+ stdout: `unlinked ${child} \u2192 ${parent}
952
+ `,
953
+ stderr: "",
954
+ exitCode: 0
955
+ };
956
+ }
957
+
958
+ // src/registry/autoregister.ts
959
+ import { existsSync } from "fs";
960
+ import { basename } from "path";
961
+ async function autoRegisterIfNeeded(cwd) {
962
+ try {
963
+ const repoRoot = findNearestAlmanacDir(cwd);
964
+ if (repoRoot === null) return null;
965
+ if (!existsSync(repoRoot)) return null;
966
+ const entries = await readRegistry();
967
+ const existing = entries.find((e) => samePath(e.path, repoRoot));
968
+ if (existing !== void 0) return existing;
969
+ const name = toKebabCase(basename(repoRoot));
970
+ if (name.length === 0) return null;
971
+ const finalName = resolveNameCollision(entries, name, repoRoot);
972
+ if (finalName === null) return null;
973
+ const entry = {
974
+ name: finalName,
975
+ description: "",
976
+ path: repoRoot,
977
+ registered_at: (/* @__PURE__ */ new Date()).toISOString()
978
+ };
979
+ await addEntry(entry);
980
+ return entry;
981
+ } catch (err) {
982
+ if (err instanceof Error && "code" in err && (err.code === "ENOENT" || err.code === "EACCES" || err.code === "EPERM")) {
983
+ return null;
984
+ }
985
+ throw err;
986
+ }
987
+ }
988
+ function resolveNameCollision(entries, baseName, repoPath) {
989
+ const owner = entries.find((e) => e.name === baseName);
990
+ if (owner === void 0 || samePath(owner.path, repoPath)) {
991
+ return baseName;
992
+ }
993
+ const taken = new Set(entries.map((e) => e.name));
994
+ const MAX_ATTEMPTS = 1e3;
995
+ for (let suffix = 2; suffix < MAX_ATTEMPTS + 2; suffix += 1) {
996
+ const candidate = `${baseName}-${suffix}`;
997
+ if (!taken.has(candidate)) return candidate;
998
+ }
999
+ return null;
1000
+ }
1001
+ function samePath(a, b) {
1002
+ if (process.platform === "darwin" || process.platform === "win32") {
1003
+ return a.toLowerCase() === b.toLowerCase();
1004
+ }
1005
+ return a === b;
1006
+ }
1007
+
1008
+ // src/cli/register-edit-commands.ts
1009
+ function registerEditCommands(program) {
1010
+ program.command("tag [page] [topics...]").description("add topics to a page (auto-creates missing topics)").option("--stdin", "read page slugs from stdin (one per line)").option("--wiki <name>", "target a specific registered wiki").action(
1011
+ async (page, topicsArg, opts) => {
1012
+ await autoRegisterIfNeeded(process.cwd());
1013
+ const resolvedTopics = opts.stdin === true ? [page, ...topicsArg].filter(
1014
+ (t) => typeof t === "string" && t.length > 0
1015
+ ) : topicsArg;
1016
+ const result = await runTag({
1017
+ cwd: process.cwd(),
1018
+ page: opts.stdin === true ? void 0 : page,
1019
+ topics: resolvedTopics,
1020
+ stdin: opts.stdin,
1021
+ stdinInput: opts.stdin === true ? await readStdin() : void 0,
1022
+ wiki: opts.wiki
1023
+ });
1024
+ emit(result);
1025
+ }
1026
+ );
1027
+ program.command("untag <page> <topic>").description("remove a topic from a page's frontmatter").option("--wiki <name>", "target a specific registered wiki").action(
1028
+ async (page, topic, opts) => {
1029
+ await autoRegisterIfNeeded(process.cwd());
1030
+ const result = await runUntag({
1031
+ cwd: process.cwd(),
1032
+ page,
1033
+ topic,
1034
+ wiki: opts.wiki
1035
+ });
1036
+ emit(result);
1037
+ }
1038
+ );
1039
+ const topics = program.command("topics").description("manage the topic DAG");
1040
+ topics.command("list", { isDefault: true }).description("list all topics with page counts").option("--wiki <name>", "target a specific registered wiki").option("--json", "emit structured JSON").action(async (opts) => {
1041
+ await autoRegisterIfNeeded(process.cwd());
1042
+ const result = await runTopicsList({
1043
+ cwd: process.cwd(),
1044
+ wiki: opts.wiki,
1045
+ json: opts.json
1046
+ });
1047
+ emit(result);
1048
+ });
1049
+ topics.command("show <slug>").description("print a topic's metadata, parents, children, and pages").option("--descendants", "include pages tagged with descendant topics").option("--wiki <name>", "target a specific registered wiki").option("--json", "emit structured JSON").action(
1050
+ async (slug, opts) => {
1051
+ await autoRegisterIfNeeded(process.cwd());
1052
+ const result = await runTopicsShow({
1053
+ cwd: process.cwd(),
1054
+ slug,
1055
+ descendants: opts.descendants,
1056
+ wiki: opts.wiki,
1057
+ json: opts.json
1058
+ });
1059
+ emit(result);
1060
+ }
1061
+ );
1062
+ topics.command("create <name>").description("create a topic (rejects if --parent slug does not exist)").option("--parent <slug>", "parent topic slug (repeat for multiple parents)", collectOption, []).option("--wiki <name>", "target a specific registered wiki").action(
1063
+ async (name, opts) => {
1064
+ await autoRegisterIfNeeded(process.cwd());
1065
+ const result = await runTopicsCreate({
1066
+ cwd: process.cwd(),
1067
+ name,
1068
+ parents: opts.parent,
1069
+ wiki: opts.wiki
1070
+ });
1071
+ emit(result);
1072
+ }
1073
+ );
1074
+ topics.command("link <child> <parent>").description("add a DAG edge (cycle-checked)").option("--wiki <name>", "target a specific registered wiki").action(async (child, parent, opts) => {
1075
+ await autoRegisterIfNeeded(process.cwd());
1076
+ const result = await runTopicsLink({
1077
+ cwd: process.cwd(),
1078
+ child,
1079
+ parent,
1080
+ wiki: opts.wiki
1081
+ });
1082
+ emit(result);
1083
+ });
1084
+ topics.command("unlink <child> <parent>").description("remove a DAG edge").option("--wiki <name>", "target a specific registered wiki").action(async (child, parent, opts) => {
1085
+ await autoRegisterIfNeeded(process.cwd());
1086
+ const result = await runTopicsUnlink({
1087
+ cwd: process.cwd(),
1088
+ child,
1089
+ parent,
1090
+ wiki: opts.wiki
1091
+ });
1092
+ emit(result);
1093
+ });
1094
+ topics.command("rename <old> <new>").description("rename a topic; rewrites every affected page's frontmatter").option("--wiki <name>", "target a specific registered wiki").action(async (oldSlug, newSlug, opts) => {
1095
+ await autoRegisterIfNeeded(process.cwd());
1096
+ const result = await runTopicsRename({
1097
+ cwd: process.cwd(),
1098
+ oldSlug,
1099
+ newSlug,
1100
+ wiki: opts.wiki
1101
+ });
1102
+ emit(result);
1103
+ });
1104
+ topics.command("delete <slug>").description("delete a topic; untags every affected page").option("--wiki <name>", "target a specific registered wiki").action(async (slug, opts) => {
1105
+ await autoRegisterIfNeeded(process.cwd());
1106
+ const result = await runTopicsDelete({
1107
+ cwd: process.cwd(),
1108
+ slug,
1109
+ wiki: opts.wiki
1110
+ });
1111
+ emit(result);
1112
+ });
1113
+ topics.command("describe <slug> <text>").description("set a topic's one-line description").option("--wiki <name>", "target a specific registered wiki").action(async (slug, text, opts) => {
1114
+ await autoRegisterIfNeeded(process.cwd());
1115
+ const result = await runTopicsDescribe({
1116
+ cwd: process.cwd(),
1117
+ slug,
1118
+ description: text,
1119
+ wiki: opts.wiki
1120
+ });
1121
+ emit(result);
1122
+ });
1123
+ }
1124
+
1125
+ // src/commands/list.ts
1126
+ import { existsSync as existsSync2 } from "fs";
1127
+ async function listWikis(options) {
1128
+ if (options.drop !== void 0) {
1129
+ return handleDrop(options.drop);
1130
+ }
1131
+ const entries = await readRegistry();
1132
+ const reachable = entries.filter((e) => isReachable(e));
1133
+ if (options.json === true) {
1134
+ return { stdout: `${JSON.stringify(reachable, null, 2)}
1135
+ `, exitCode: 0 };
1136
+ }
1137
+ return { stdout: formatPretty(reachable), exitCode: 0 };
1138
+ }
1139
+ async function handleDrop(name) {
1140
+ const removed = await dropEntry(name);
1141
+ if (removed === null) {
1142
+ return {
1143
+ stdout: `no registry entry named "${name}"
1144
+ `,
1145
+ exitCode: 1
1146
+ };
1147
+ }
1148
+ return {
1149
+ stdout: `removed "${removed.name}" (${removed.path})
1150
+ `,
1151
+ exitCode: 0
1152
+ };
1153
+ }
1154
+ function isReachable(entry) {
1155
+ if (entry.path.length === 0) return false;
1156
+ return existsSync2(entry.path);
1157
+ }
1158
+ function formatPretty(entries) {
1159
+ if (entries.length === 0) {
1160
+ return `${DIM}no wikis registered. run \`almanac bootstrap\` in a repo to create one.${RST}
1161
+ `;
1162
+ }
1163
+ const nameWidth = Math.min(
1164
+ 30,
1165
+ entries.reduce((w, e) => Math.max(w, e.name.length), 0)
1166
+ );
1167
+ const lines = [];
1168
+ for (const entry of entries) {
1169
+ const name = entry.name.padEnd(nameWidth);
1170
+ const desc = entry.description.length > 0 ? entry.description : "\u2014";
1171
+ lines.push(`${BLUE}${BOLD}${name}${RST} ${desc}`);
1172
+ lines.push(`${" ".repeat(nameWidth)} ${DIM}${entry.path}${RST}`);
1173
+ }
1174
+ return `${lines.join("\n")}
1175
+ `;
1176
+ }
1177
+
1178
+ // src/commands/search.ts
1179
+ import { join as join3 } from "path";
1180
+ async function runSearch(options) {
1181
+ const repoRoot = await resolveWikiRoot({
1182
+ cwd: options.cwd,
1183
+ wiki: options.wiki
1184
+ });
1185
+ await ensureFreshIndex({ repoRoot });
1186
+ const dbPath = join3(repoRoot, ".almanac", "index.db");
1187
+ const db = openIndex(dbPath);
1188
+ try {
1189
+ const rows = executeQuery(db, options);
1190
+ const limited = options.limit !== void 0 && options.limit >= 0 ? rows.slice(0, options.limit) : rows;
1191
+ const stdout = formatResults(limited, options);
1192
+ const stderr = buildStderr(limited, options);
1193
+ return { stdout, stderr, exitCode: 0 };
1194
+ } finally {
1195
+ db.close();
1196
+ }
1197
+ }
1198
+ function executeQuery(db, options) {
1199
+ const whereClauses = [];
1200
+ const params = [];
1201
+ if (options.archived === true) {
1202
+ whereClauses.push("p.archived_at IS NOT NULL");
1203
+ } else if (options.includeArchive !== true) {
1204
+ whereClauses.push("p.archived_at IS NULL");
1205
+ }
1206
+ for (const rawTopic of options.topics) {
1207
+ const topicSlug = slugForTopic(rawTopic);
1208
+ if (topicSlug.length === 0) continue;
1209
+ whereClauses.push(
1210
+ "EXISTS (SELECT 1 FROM page_topics pt WHERE pt.page_slug = p.slug AND pt.topic_slug = ?)"
1211
+ );
1212
+ params.push(topicSlug);
1213
+ }
1214
+ if (options.mentions !== void 0 && options.mentions.length > 0) {
1215
+ const isDir = looksLikeDir(options.mentions);
1216
+ const norm = normalizePath(options.mentions, isDir);
1217
+ if (isDir) {
1218
+ const escaped = escapeGlobMeta(norm);
1219
+ whereClauses.push(
1220
+ `EXISTS (
1221
+ SELECT 1 FROM file_refs r
1222
+ WHERE r.page_slug = p.slug
1223
+ AND (r.path = ? OR r.path GLOB ?)
1224
+ )`
1225
+ );
1226
+ params.push(norm, `${escaped}*`);
1227
+ } else {
1228
+ const prefixes = parentFolderPrefixes(norm);
1229
+ if (prefixes.length === 0) {
1230
+ whereClauses.push(
1231
+ `EXISTS (
1232
+ SELECT 1 FROM file_refs r
1233
+ WHERE r.page_slug = p.slug AND r.path = ?
1234
+ )`
1235
+ );
1236
+ params.push(norm);
1237
+ } else {
1238
+ const placeholders = prefixes.map(() => "?").join(", ");
1239
+ whereClauses.push(
1240
+ `EXISTS (
1241
+ SELECT 1 FROM file_refs r
1242
+ WHERE r.page_slug = p.slug
1243
+ AND (
1244
+ r.path = ?
1245
+ OR (r.is_dir = 1 AND r.path IN (${placeholders}))
1246
+ )
1247
+ )`
1248
+ );
1249
+ params.push(norm, ...prefixes);
1250
+ }
1251
+ }
1252
+ }
1253
+ const now = Math.floor(Date.now() / 1e3);
1254
+ if (options.since !== void 0) {
1255
+ const seconds = parseDuration(options.since);
1256
+ whereClauses.push("p.updated_at >= ?");
1257
+ params.push(now - seconds);
1258
+ }
1259
+ if (options.stale !== void 0) {
1260
+ const seconds = parseDuration(options.stale);
1261
+ whereClauses.push("p.updated_at < ?");
1262
+ params.push(now - seconds);
1263
+ }
1264
+ if (options.orphan === true) {
1265
+ whereClauses.push(
1266
+ "NOT EXISTS (SELECT 1 FROM page_topics pt WHERE pt.page_slug = p.slug)"
1267
+ );
1268
+ }
1269
+ let sql;
1270
+ if (options.query !== void 0 && options.query.trim().length > 0) {
1271
+ const ftsExpr = buildFtsQuery(options.query);
1272
+ sql = `
1273
+ SELECT p.slug, p.title, p.updated_at, p.archived_at, p.superseded_by
1274
+ FROM pages p
1275
+ JOIN fts_pages f ON f.slug = p.slug
1276
+ WHERE fts_pages MATCH ?
1277
+ ${whereClauses.length > 0 ? `AND ${whereClauses.join(" AND ")}` : ""}
1278
+ ORDER BY f.rank ASC, p.updated_at DESC, p.slug ASC
1279
+ `;
1280
+ params.unshift(ftsExpr);
1281
+ } else {
1282
+ sql = buildSql(whereClauses);
1283
+ }
1284
+ const rows = db.prepare(sql).all(...params);
1285
+ const topicStmt = db.prepare(
1286
+ "SELECT topic_slug FROM page_topics WHERE page_slug = ? ORDER BY topic_slug"
1287
+ );
1288
+ const out = rows.map((row) => ({
1289
+ slug: row.slug,
1290
+ title: row.title,
1291
+ updated_at: row.updated_at,
1292
+ archived_at: row.archived_at,
1293
+ superseded_by: row.superseded_by,
1294
+ topics: topicStmt.all(row.slug).map((t) => t.topic_slug)
1295
+ }));
1296
+ return out;
1297
+ }
1298
+ function buildSql(whereClauses) {
1299
+ const where = whereClauses.length > 0 ? `WHERE ${whereClauses.join(" AND ")}` : "";
1300
+ return `
1301
+ SELECT p.slug, p.title, p.updated_at, p.archived_at, p.superseded_by
1302
+ FROM pages p
1303
+ ${where}
1304
+ ORDER BY p.updated_at DESC, p.slug ASC
1305
+ `;
1306
+ }
1307
+ function buildFtsQuery(raw) {
1308
+ const trimmed = raw.trim();
1309
+ if (trimmed.length >= 2 && trimmed.startsWith('"') && trimmed.endsWith('"')) {
1310
+ const inner = trimmed.slice(1, -1).toLowerCase().replace(/[^a-z0-9]+/g, " ").trim();
1311
+ if (inner.length === 0) return '""';
1312
+ return `"${inner}"`;
1313
+ }
1314
+ const tokens = trimmed.toLowerCase().split(/[^a-z0-9]+/).filter((t) => t.length > 0);
1315
+ if (tokens.length === 0) return '""';
1316
+ return tokens.map((t) => `${t}*`).join(" AND ");
1317
+ }
1318
+ function slugForTopic(raw) {
1319
+ return raw.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
1320
+ }
1321
+ function parentFolderPrefixes(filePath) {
1322
+ const out = [];
1323
+ let cursor = 0;
1324
+ while (true) {
1325
+ const next = filePath.indexOf("/", cursor);
1326
+ if (next === -1) break;
1327
+ out.push(filePath.slice(0, next + 1));
1328
+ cursor = next + 1;
1329
+ }
1330
+ return out;
1331
+ }
1332
+ function escapeGlobMeta(s) {
1333
+ return s.replace(/[\*\?\[]/g, (ch) => `[${ch}]`);
1334
+ }
1335
+ function formatResults(rows, options) {
1336
+ if (options.json === true) {
1337
+ return `${JSON.stringify(rows, null, 2)}
1338
+ `;
1339
+ }
1340
+ if (rows.length === 0) return "";
1341
+ return `${rows.map((r) => `${BLUE}${r.slug}${RST}`).join("\n")}
1342
+ `;
1343
+ }
1344
+ function buildStderr(rows, options) {
1345
+ if (options.json === true) return "";
1346
+ if (rows.length === 0) {
1347
+ return "# 0 results\n";
1348
+ }
1349
+ if (options.limit !== void 0) return "";
1350
+ if (rows.length > 50) {
1351
+ return `almanac: ${rows.length} results \u2014 consider --limit or a narrower query
1352
+ `;
1353
+ }
1354
+ return "";
1355
+ }
1356
+
1357
+ // src/commands/show.ts
1358
+ import { readFile as readFile3 } from "fs/promises";
1359
+ import { join as join4 } from "path";
1360
+ async function runShow(options) {
1361
+ const repoRoot = await resolveWikiRoot({
1362
+ cwd: options.cwd,
1363
+ wiki: options.wiki
1364
+ });
1365
+ await ensureFreshIndex({ repoRoot });
1366
+ const dbPath = join4(repoRoot, ".almanac", "index.db");
1367
+ const db = openIndex(dbPath);
1368
+ try {
1369
+ const slugs = collectSlugs(options);
1370
+ if (slugs.length === 0) {
1371
+ return {
1372
+ stdout: "",
1373
+ stderr: "almanac: show requires a slug (or --stdin)\n",
1374
+ exitCode: 1
1375
+ };
1376
+ }
1377
+ const records = [];
1378
+ const missing = [];
1379
+ for (const slug of slugs) {
1380
+ const rec = await fetchRecord(db, slug);
1381
+ if (rec === null) {
1382
+ missing.push(slug);
1383
+ continue;
1384
+ }
1385
+ records.push(rec);
1386
+ }
1387
+ const bulk = options.stdin === true;
1388
+ const stdout = bulk ? formatBulk(records) : formatSingle(records, options);
1389
+ const stderr = missing.map((s) => `almanac: no such page "${s}"
1390
+ `).join("");
1391
+ return {
1392
+ stdout,
1393
+ stderr,
1394
+ exitCode: missing.length > 0 ? 1 : 0
1395
+ };
1396
+ } finally {
1397
+ db.close();
1398
+ }
1399
+ }
1400
+ async function fetchRecord(db, slug) {
1401
+ const pageRow = db.prepare(
1402
+ "SELECT slug, title, file_path, updated_at, archived_at, superseded_by FROM pages WHERE slug = ?"
1403
+ ).get(slug);
1404
+ if (pageRow === void 0) return null;
1405
+ const topics = db.prepare(
1406
+ "SELECT topic_slug FROM page_topics WHERE page_slug = ? ORDER BY topic_slug"
1407
+ ).all(slug).map((r) => r.topic_slug);
1408
+ const refs = db.prepare(
1409
+ "SELECT original_path, is_dir FROM file_refs WHERE page_slug = ? ORDER BY original_path"
1410
+ ).all(slug).map((r) => ({ path: r.original_path, is_dir: r.is_dir === 1 }));
1411
+ const linksOut = db.prepare(
1412
+ "SELECT target_slug FROM wikilinks WHERE source_slug = ? ORDER BY target_slug"
1413
+ ).all(slug).map((r) => r.target_slug);
1414
+ const linksIn = db.prepare(
1415
+ "SELECT source_slug FROM wikilinks WHERE target_slug = ? ORDER BY source_slug"
1416
+ ).all(slug).map((r) => r.source_slug);
1417
+ const xwiki = db.prepare(
1418
+ "SELECT target_wiki, target_slug FROM cross_wiki_links WHERE source_slug = ? ORDER BY target_wiki, target_slug"
1419
+ ).all(slug).map((r) => ({ wiki: r.target_wiki, target: r.target_slug }));
1420
+ const supersedesRows = db.prepare(
1421
+ "SELECT slug FROM pages WHERE superseded_by = ? ORDER BY slug"
1422
+ ).all(slug).map((r) => r.slug);
1423
+ let body = "";
1424
+ try {
1425
+ body = stripFrontmatter(await readFile3(pageRow.file_path, "utf8"));
1426
+ } catch {
1427
+ }
1428
+ return {
1429
+ slug: pageRow.slug,
1430
+ title: pageRow.title,
1431
+ file_path: pageRow.file_path,
1432
+ updated_at: pageRow.updated_at,
1433
+ archived_at: pageRow.archived_at,
1434
+ superseded_by: pageRow.superseded_by,
1435
+ supersedes: supersedesRows,
1436
+ topics,
1437
+ file_refs: refs,
1438
+ wikilinks_out: linksOut,
1439
+ wikilinks_in: linksIn,
1440
+ cross_wiki_links: xwiki,
1441
+ body
1442
+ };
1443
+ }
1444
+ function formatBulk(records) {
1445
+ if (records.length === 0) return "";
1446
+ return records.map((r) => JSON.stringify(r)).join("\n") + "\n";
1447
+ }
1448
+ function formatSingle(records, options) {
1449
+ if (options.json === true) {
1450
+ const only = records[0] ?? null;
1451
+ return `${JSON.stringify(only, null, 2)}
1452
+ `;
1453
+ }
1454
+ return records.map((r) => formatRecord(r, options)).join("");
1455
+ }
1456
+ var FIELD_ORDER = [
1457
+ "title",
1458
+ "topics",
1459
+ "files",
1460
+ "links",
1461
+ "backlinks",
1462
+ "xwiki",
1463
+ "lineage",
1464
+ "updated",
1465
+ "path"
1466
+ ];
1467
+ function selectedFields(options) {
1468
+ const selected = [];
1469
+ for (const f of FIELD_ORDER) {
1470
+ if (options[f] === true) selected.push(f);
1471
+ }
1472
+ return selected;
1473
+ }
1474
+ function formatRecord(rec, options) {
1475
+ if (options.raw === true) {
1476
+ if (rec.body.length === 0) return "";
1477
+ return rec.body.endsWith("\n") ? rec.body : `${rec.body}
1478
+ `;
1479
+ }
1480
+ const fields = selectedFields(options);
1481
+ if (fields.length > 0) {
1482
+ if (fields.length === 1) {
1483
+ return bareField(rec, fields[0]);
1484
+ }
1485
+ return labeledFields(rec, fields);
1486
+ }
1487
+ if (options.meta === true) {
1488
+ return metadataHeader(rec) + "\n";
1489
+ }
1490
+ if (options.lead === true) {
1491
+ return firstParagraph(rec.body) + "\n";
1492
+ }
1493
+ const header = metadataHeader(rec);
1494
+ const body = rec.body;
1495
+ const sep = body.length > 0 ? `
1496
+
1497
+ ${DIM}---${RST}
1498
+
1499
+ ` : "\n";
1500
+ return header + sep + body;
1501
+ }
1502
+ function bareField(rec, field) {
1503
+ switch (field) {
1504
+ case "title":
1505
+ return (rec.title ?? "") + "\n";
1506
+ case "topics":
1507
+ return rec.topics.map((t) => `${t}
1508
+ `).join("");
1509
+ case "files":
1510
+ return rec.file_refs.map((r) => `${r.path}
1511
+ `).join("");
1512
+ case "links":
1513
+ return rec.wikilinks_out.map((t) => `${t}
1514
+ `).join("");
1515
+ case "backlinks":
1516
+ return rec.wikilinks_in.map((t) => `${t}
1517
+ `).join("");
1518
+ case "xwiki":
1519
+ return rec.cross_wiki_links.map((x) => `${x.wiki}:${x.target}
1520
+ `).join("");
1521
+ case "lineage": {
1522
+ const lines = [];
1523
+ if (rec.archived_at !== null) {
1524
+ lines.push(
1525
+ `archived_at: ${new Date(rec.archived_at * 1e3).toISOString()}`
1526
+ );
1527
+ }
1528
+ if (rec.superseded_by !== null) {
1529
+ lines.push(`superseded_by: ${rec.superseded_by}`);
1530
+ }
1531
+ if (rec.supersedes.length > 0) {
1532
+ lines.push(`supersedes: ${rec.supersedes.join(", ")}`);
1533
+ }
1534
+ return lines.length > 0 ? `${lines.join("\n")}
1535
+ ` : "";
1536
+ }
1537
+ case "updated":
1538
+ return `${new Date(rec.updated_at * 1e3).toISOString()}
1539
+ `;
1540
+ case "path":
1541
+ return `${rec.file_path}
1542
+ `;
1543
+ }
1544
+ }
1545
+ function labeledFields(rec, fields) {
1546
+ const parts = [];
1547
+ for (const f of fields) {
1548
+ parts.push(labeledSection(rec, f));
1549
+ }
1550
+ return parts.join("\n");
1551
+ }
1552
+ function labeledSection(rec, field) {
1553
+ switch (field) {
1554
+ case "title":
1555
+ return `${DIM}title:${RST} ${rec.title ?? "\u2014"}
1556
+ `;
1557
+ case "topics":
1558
+ return rec.topics.length > 0 ? `${DIM}topics:${RST} ${rec.topics.join(", ")}
1559
+ ` : `${DIM}topics:${RST} \u2014
1560
+ `;
1561
+ case "files":
1562
+ return formatListSection(
1563
+ "files",
1564
+ rec.file_refs.map((r) => `${r.path}`)
1565
+ );
1566
+ case "links":
1567
+ return formatListSection("links", rec.wikilinks_out);
1568
+ case "backlinks":
1569
+ return formatListSection("backlinks", rec.wikilinks_in);
1570
+ case "xwiki":
1571
+ return formatListSection(
1572
+ "xwiki",
1573
+ rec.cross_wiki_links.map((x) => `${x.wiki}:${x.target}`)
1574
+ );
1575
+ case "lineage": {
1576
+ const lines = [`${DIM}lineage:${RST}`];
1577
+ if (rec.archived_at !== null) {
1578
+ lines.push(
1579
+ ` ${DIM}archived_at:${RST} ${new Date(rec.archived_at * 1e3).toISOString()}`
1580
+ );
1581
+ }
1582
+ if (rec.superseded_by !== null) {
1583
+ lines.push(` ${DIM}superseded_by:${RST} ${rec.superseded_by}`);
1584
+ }
1585
+ if (rec.supersedes.length > 0) {
1586
+ lines.push(` ${DIM}supersedes:${RST} ${rec.supersedes.join(", ")}`);
1587
+ }
1588
+ if (lines.length === 1) lines.push(" \u2014");
1589
+ return lines.join("\n") + "\n";
1590
+ }
1591
+ case "updated":
1592
+ return `${DIM}updated:${RST} ${new Date(rec.updated_at * 1e3).toISOString()}
1593
+ `;
1594
+ case "path":
1595
+ return `${DIM}path:${RST} ${rec.file_path}
1596
+ `;
1597
+ }
1598
+ }
1599
+ function formatListSection(label, items) {
1600
+ if (items.length === 0) return `${DIM}${label}:${RST} \u2014
1601
+ `;
1602
+ if (items.length <= 3) return `${DIM}${label}:${RST} ${items.join(", ")}
1603
+ `;
1604
+ return `${DIM}${label}:${RST}
1605
+ ${items.map((i) => ` ${i}`).join("\n")}
1606
+ `;
1607
+ }
1608
+ function metadataHeader(rec) {
1609
+ const lines = [];
1610
+ lines.push(`${DIM}slug:${RST} ${BLUE}${rec.slug}${RST}`);
1611
+ lines.push(`${DIM}title:${RST} ${rec.title ?? "\u2014"}`);
1612
+ lines.push(
1613
+ `${DIM}topics:${RST} ${rec.topics.length > 0 ? rec.topics.join(", ") : "\u2014"}`
1614
+ );
1615
+ if (rec.file_refs.length > 0) {
1616
+ const parts = rec.file_refs.map(
1617
+ (r) => `${r.path}`
1618
+ );
1619
+ lines.push(`${DIM}files:${RST} ${parts.join(", ")}`);
1620
+ }
1621
+ lines.push(
1622
+ `${DIM}updated:${RST} ${new Date(rec.updated_at * 1e3).toISOString()}`
1623
+ );
1624
+ if (rec.wikilinks_out.length > 0) {
1625
+ lines.push(`${DIM}links:${RST} ${rec.wikilinks_out.join(", ")}`);
1626
+ }
1627
+ if (rec.wikilinks_in.length > 0) {
1628
+ lines.push(`${DIM}backlinks:${RST} ${rec.wikilinks_in.join(", ")}`);
1629
+ }
1630
+ if (rec.cross_wiki_links.length > 0) {
1631
+ lines.push(
1632
+ `${DIM}xwiki:${RST} ${rec.cross_wiki_links.map((x) => `${x.wiki}:${x.target}`).join(", ")}`
1633
+ );
1634
+ }
1635
+ if (rec.archived_at !== null) {
1636
+ lines.push(
1637
+ `${DIM}archived:${RST} ${new Date(rec.archived_at * 1e3).toISOString()}`
1638
+ );
1639
+ }
1640
+ if (rec.superseded_by !== null) {
1641
+ lines.push(`${DIM}superseded_by:${RST} ${rec.superseded_by}`);
1642
+ }
1643
+ if (rec.supersedes.length > 0) {
1644
+ lines.push(`${DIM}supersedes:${RST} ${rec.supersedes.join(", ")}`);
1645
+ }
1646
+ return lines.join("\n");
1647
+ }
1648
+ function stripFrontmatter(src) {
1649
+ if (!src.startsWith("---\n") && !src.startsWith("---\r\n")) return src;
1650
+ const afterOpen = src.replace(/^---\r?\n/, "");
1651
+ const endMatch = afterOpen.match(/^---[ \t]*\r?\n/m);
1652
+ if (endMatch === null || endMatch.index === void 0) return src;
1653
+ return afterOpen.slice(endMatch.index + endMatch[0].length);
1654
+ }
1655
+ function firstParagraph(body) {
1656
+ let src = body.trimStart();
1657
+ if (src.startsWith("# ")) {
1658
+ const nl = src.indexOf("\n");
1659
+ src = nl === -1 ? "" : src.slice(nl + 1).trimStart();
1660
+ }
1661
+ const blank = src.search(/\n[ \t]*\n/);
1662
+ if (blank === -1) return src.trimEnd();
1663
+ return src.slice(0, blank).trimEnd();
1664
+ }
1665
+ function collectSlugs(options) {
1666
+ if (options.stdin === true && options.stdinInput !== void 0) {
1667
+ return options.stdinInput.split(/\r?\n/).map((s) => s.trim()).filter((s) => s.length > 0);
1668
+ }
1669
+ if (options.slug !== void 0 && options.slug.length > 0) {
1670
+ return [options.slug];
1671
+ }
1672
+ return [];
1673
+ }
1674
+
1675
+ // src/cli/register-query-commands.ts
1676
+ function registerQueryCommands(program) {
1677
+ program.command("search [query]").description("find pages by text, topic, file mentions, freshness").option(
1678
+ "--topic <name...>",
1679
+ "filter by topic (repeat for intersection)",
1680
+ collectOption,
1681
+ []
1682
+ ).option(
1683
+ "--mentions <path>",
1684
+ "pages referencing this path; matches exact file, trailing-slash folders, and any file under a folder prefix"
1685
+ ).option("--since <duration>", "updated within duration, by file mtime (e.g. 2w, 30d)").option("--stale <duration>", "NOT updated within duration, by file mtime").option("--orphan", "pages with no topics").option("--include-archive", "include archived pages").option("--archived", "archived pages only").option("--wiki <name>", "target a specific registered wiki").option("--json", "emit structured JSON").option("--limit <n>", "cap results", parsePositiveInt).action(
1686
+ async (query2, opts) => {
1687
+ await autoRegisterIfNeeded(process.cwd());
1688
+ const result = await runSearch({
1689
+ cwd: process.cwd(),
1690
+ query: query2,
1691
+ topics: opts.topic ?? [],
1692
+ mentions: opts.mentions,
1693
+ since: opts.since,
1694
+ stale: opts.stale,
1695
+ orphan: opts.orphan,
1696
+ includeArchive: opts.includeArchive,
1697
+ archived: opts.archived,
1698
+ wiki: opts.wiki,
1699
+ json: opts.json,
1700
+ limit: opts.limit
1701
+ });
1702
+ emit(result);
1703
+ }
1704
+ );
1705
+ program.command("show [slug]").description("print a page (metadata + body; flags to narrow)").option("--stdin", "read slugs from stdin (one per line)").option("--wiki <name>", "target a specific registered wiki").option("--json", "structured JSON (overrides other view/field flags)").option("--raw", "body only (alias: --body)").option("--body", "body only (alias: --raw)").option("--meta", "metadata only, no body").option("--lead", "first paragraph of the body only").option("--title", "print title").option("--topics", "print topics").option("--files", "print file refs").option("--links", "print outgoing wikilinks").option("--backlinks", "print incoming wikilinks").option("--xwiki", "print cross-wiki links").option("--lineage", "print archived_at / supersedes / superseded_by").option("--updated", "print updated timestamp").option("--path", "print absolute file path").action(
1706
+ async (slug, opts) => {
1707
+ await autoRegisterIfNeeded(process.cwd());
1708
+ const result = await runShow({
1709
+ cwd: process.cwd(),
1710
+ slug,
1711
+ stdin: opts.stdin,
1712
+ stdinInput: opts.stdin === true ? await readStdin() : void 0,
1713
+ wiki: opts.wiki,
1714
+ json: opts.json,
1715
+ raw: opts.raw === true || opts.body === true,
1716
+ meta: opts.meta,
1717
+ lead: opts.lead,
1718
+ title: opts.title,
1719
+ topics: opts.topics,
1720
+ files: opts.files,
1721
+ links: opts.links,
1722
+ backlinks: opts.backlinks,
1723
+ xwiki: opts.xwiki,
1724
+ lineage: opts.lineage,
1725
+ updated: opts.updated,
1726
+ path: opts.path
1727
+ });
1728
+ emit(result);
1729
+ }
1730
+ );
1731
+ program.command("health").description("report graph integrity problems").option("--topic <name>", "scope to a topic + its descendants").option("--stale <duration>", "stale threshold (default 90d)").option("--stdin", "read page slugs from stdin (limit to these pages)").option("--json", "emit structured JSON").option("--wiki <name>", "target a specific registered wiki").action(
1732
+ async (opts) => {
1733
+ await autoRegisterIfNeeded(process.cwd());
1734
+ const result = await runHealth({
1735
+ cwd: process.cwd(),
1736
+ topic: opts.topic,
1737
+ stale: opts.stale,
1738
+ stdin: opts.stdin,
1739
+ stdinInput: opts.stdin === true ? await readStdin() : void 0,
1740
+ json: opts.json,
1741
+ wiki: opts.wiki
1742
+ });
1743
+ emit(result);
1744
+ }
1745
+ );
1746
+ program.command("list").description("list registered wikis").option("--json", "emit structured JSON").option(
1747
+ "--drop <name>",
1748
+ "remove a wiki from the registry (the only way entries are ever removed)"
1749
+ ).action(async (opts) => {
1750
+ if (opts.drop === void 0) {
1751
+ await autoRegisterIfNeeded(process.cwd());
1752
+ }
1753
+ const result = await listWikis(opts);
1754
+ process.stdout.write(result.stdout);
1755
+ if (result.exitCode !== 0) {
1756
+ process.exitCode = result.exitCode;
1757
+ }
1758
+ });
1759
+ }
1760
+
1761
+ // src/cli/register-setup-commands.ts
1762
+ function registerSetupCommands(program) {
1763
+ program.command("setup").description("install the hook + CLAUDE.md guides (bare codealmanac alias)").option("-y, --yes", "skip prompts; install everything").option("--skip-hook", "opt out of the SessionEnd hook").option("--skip-guides", "opt out of the CLAUDE.md guides").action(
1764
+ async (opts) => {
1765
+ const result = await runSetup({
1766
+ yes: opts.yes,
1767
+ skipHook: opts.skipHook,
1768
+ skipGuides: opts.skipGuides
1769
+ });
1770
+ emit(result);
1771
+ }
1772
+ );
1773
+ program.command("doctor").description("report on the codealmanac install + current wiki health").option("--json", "emit structured JSON").option("--install-only", "report only on the install (skip wiki checks)").option("--wiki-only", "report only on the current wiki (skip install checks)").action(
1774
+ async (opts) => {
1775
+ const result = await runDoctor({
1776
+ cwd: process.cwd(),
1777
+ json: opts.json,
1778
+ installOnly: opts.installOnly,
1779
+ wikiOnly: opts.wikiOnly
1780
+ });
1781
+ emit(result);
1782
+ }
1783
+ );
1784
+ program.command("update").description("install the latest codealmanac (synchronous foreground `npm i -g`)").option(
1785
+ "--dismiss",
1786
+ "silence the update banner for the current `latest_version` without installing"
1787
+ ).option("--check", "force a registry check now (bypasses the 24h cache); no install").option(
1788
+ "--enable-notifier",
1789
+ "re-enable the pre-command update banner (writes ~/.almanac/config.json)"
1790
+ ).option(
1791
+ "--disable-notifier",
1792
+ "silence the pre-command update banner (writes ~/.almanac/config.json)"
1793
+ ).action(
1794
+ async (opts) => {
1795
+ const result = await runUpdate({
1796
+ dismiss: opts.dismiss,
1797
+ check: opts.check,
1798
+ enableNotifier: opts.enableNotifier,
1799
+ disableNotifier: opts.disableNotifier
1800
+ });
1801
+ emit(result);
1802
+ }
1803
+ );
1804
+ program.command("uninstall").description("remove the hook + guides + import line").option("-y, --yes", "skip confirmations; remove everything").option("--keep-hook", "don't remove the SessionEnd hook (guides still prompted unless --yes)").option(
1805
+ "--keep-guides",
1806
+ "don't remove the guides or CLAUDE.md import (hook still prompted unless --yes)"
1807
+ ).action(
1808
+ async (opts) => {
1809
+ const result = await runUninstall({
1810
+ yes: opts.yes,
1811
+ keepHook: opts.keepHook,
1812
+ keepGuides: opts.keepGuides
1813
+ });
1814
+ emit(result);
1815
+ }
1816
+ );
1817
+ }
1818
+
1819
+ // src/commands/bootstrap.ts
1820
+ import { createWriteStream, existsSync as existsSync5 } from "fs";
1821
+ import { readdir } from "fs/promises";
1822
+ import { join as join6, relative } from "path";
1823
+
1824
+ // src/agent/prompts.ts
1825
+ import { existsSync as existsSync3 } from "fs";
1826
+ import { readFile as readFile4 } from "fs/promises";
1827
+ import path from "path";
1828
+ import { fileURLToPath } from "url";
1829
+ var PROMPT_NAMES = [
1830
+ "bootstrap",
1831
+ "writer",
1832
+ "reviewer"
1833
+ ];
1834
+ var overrideDir = null;
1835
+ var resolvedDir = null;
1836
+ function resolvePromptsDir() {
1837
+ if (overrideDir !== null) return overrideDir;
1838
+ if (resolvedDir !== null) return resolvedDir;
1839
+ const here = path.dirname(fileURLToPath(import.meta.url));
1840
+ const candidates = [
1841
+ // Bundled dist layout: `.../<pkg>/dist/codealmanac.js` → `../prompts`
1842
+ path.resolve(here, "..", "prompts"),
1843
+ // Source layout: `.../<pkg>/src/agent/prompts.ts` → `../../prompts`
1844
+ path.resolve(here, "..", "..", "prompts"),
1845
+ // Defensive fallback: if tsup someday emits a nested `dist/src/agent`,
1846
+ // walk up three levels.
1847
+ path.resolve(here, "..", "..", "..", "prompts")
1848
+ ];
1849
+ for (const dir of candidates) {
1850
+ if (isPromptsDir(dir)) {
1851
+ resolvedDir = dir;
1852
+ return dir;
1853
+ }
1854
+ }
1855
+ throw new Error(
1856
+ "could not locate bundled prompts/ directory. Tried:\n" + candidates.map((c) => ` - ${c}`).join("\n")
1857
+ );
1858
+ }
1859
+ function isPromptsDir(dir) {
1860
+ if (!existsSync3(dir)) return false;
1861
+ return PROMPT_NAMES.every(
1862
+ (name) => existsSync3(path.join(dir, `${name}.md`))
1863
+ );
1864
+ }
1865
+ async function loadPrompt(name) {
1866
+ const dir = resolvePromptsDir();
1867
+ return readFile4(path.join(dir, `${name}.md`), "utf8");
1868
+ }
1869
+
1870
+ // src/agent/sdk.ts
1871
+ import { query } from "@anthropic-ai/claude-agent-sdk";
1872
+ async function runAgent(opts) {
1873
+ const q = query({
1874
+ prompt: opts.prompt,
1875
+ options: {
1876
+ systemPrompt: opts.systemPrompt,
1877
+ allowedTools: opts.allowedTools,
1878
+ agents: opts.agents ?? {},
1879
+ cwd: opts.cwd,
1880
+ model: opts.model ?? "claude-sonnet-4-6",
1881
+ maxTurns: opts.maxTurns ?? 100,
1882
+ // REQUIRED for streaming text deltas. Without it, `stream_event`
1883
+ // messages never fire and the CLI has no progress visibility during
1884
+ // long turns. See docs/research/agent-sdk.md §12 pitfall #1.
1885
+ includePartialMessages: true
1886
+ }
1887
+ });
1888
+ let cost = 0;
1889
+ let turns = 0;
1890
+ let result = "";
1891
+ let sessionId;
1892
+ let success = false;
1893
+ let errorMsg;
1894
+ try {
1895
+ for await (const msg of q) {
1896
+ opts.onMessage?.(msg);
1897
+ if (sessionId === void 0 && typeof msg.session_id === "string") {
1898
+ sessionId = msg.session_id;
1899
+ }
1900
+ if (msg.type === "result") {
1901
+ cost = msg.total_cost_usd;
1902
+ turns = msg.num_turns;
1903
+ if (msg.subtype === "success") {
1904
+ success = true;
1905
+ result = msg.result;
1906
+ } else {
1907
+ success = false;
1908
+ errorMsg = // `SDKResultError` variants don't carry a `result` string; the
1909
+ // useful detail lives in `errors` (array of strings) or the
1910
+ // subtype itself (e.g. "error_max_turns").
1911
+ (msg.errors?.join("; ") ?? "") || `agent error: ${msg.subtype}`;
1912
+ }
1913
+ }
1914
+ }
1915
+ } catch (err) {
1916
+ errorMsg = err instanceof Error ? err.message : String(err);
1917
+ success = false;
1918
+ }
1919
+ return { success, cost, turns, result, sessionId, error: errorMsg };
1920
+ }
1921
+
1922
+ // src/commands/init.ts
1923
+ import { existsSync as existsSync4 } from "fs";
1924
+ import { mkdir, readFile as readFile5, writeFile as writeFile2 } from "fs/promises";
1925
+ import { basename as basename2, join as join5 } from "path";
1926
+ async function initWiki(options) {
1927
+ const repoRoot = findNearestAlmanacDir(options.cwd) ?? options.cwd;
1928
+ const almanacDir = getRepoAlmanacDir(repoRoot);
1929
+ const pagesDir = join5(almanacDir, "pages");
1930
+ const readmePath = join5(almanacDir, "README.md");
1931
+ const alreadyExisted = existsSync4(almanacDir);
1932
+ await mkdir(pagesDir, { recursive: true });
1933
+ if (!existsSync4(readmePath)) {
1934
+ await writeFile2(readmePath, starterReadme(), "utf8");
1935
+ }
1936
+ await ensureGitignoreHasIndexDb(repoRoot);
1937
+ const name = toKebabCase(options.name ?? basename2(repoRoot));
1938
+ if (name.length === 0) {
1939
+ throw new Error(
1940
+ "could not derive a wiki name from the current directory; pass --name"
1941
+ );
1942
+ }
1943
+ const description = (options.description ?? "").trim();
1944
+ await ensureGlobalDir();
1945
+ const entry = {
1946
+ name,
1947
+ description,
1948
+ path: repoRoot,
1949
+ registered_at: (/* @__PURE__ */ new Date()).toISOString()
1950
+ };
1951
+ await addEntry(entry);
1952
+ return { entry, almanacDir, created: !alreadyExisted };
1953
+ }
1954
+ async function ensureGitignoreHasIndexDb(cwd) {
1955
+ const path2 = join5(cwd, ".gitignore");
1956
+ const targets = [
1957
+ ".almanac/index.db",
1958
+ ".almanac/index.db-wal",
1959
+ ".almanac/index.db-shm",
1960
+ // Capture/bootstrap/ingest log files written by the AI pipeline.
1961
+ // These can be multi-megabyte JSONL files and should never be
1962
+ // committed. Bug #4 from codealmanac-known-bugs.md: a 1.8MB bootstrap
1963
+ // log was accidentally committed before this line was added.
1964
+ ".almanac/.capture-*",
1965
+ ".almanac/.bootstrap-*",
1966
+ ".almanac/.ingest-*"
1967
+ ];
1968
+ let existing = "";
1969
+ if (existsSync4(path2)) {
1970
+ existing = await readFile5(path2, "utf8");
1971
+ }
1972
+ const lines = existing.split(/\r?\n/).map((l) => l.trim());
1973
+ const missing = targets.filter((t) => !lines.includes(t));
1974
+ if (missing.length === 0) return;
1975
+ const hasHeader = lines.includes("# codealmanac");
1976
+ const block = hasHeader ? missing.join("\n") + "\n" : `# codealmanac
1977
+ ${missing.join("\n")}
1978
+ `;
1979
+ const sep = existing.length === 0 ? "" : existing.endsWith("\n") ? "\n" : "\n\n";
1980
+ await writeFile2(path2, `${existing}${sep}${block}`, "utf8");
1981
+ }
1982
+ function starterReadme() {
1983
+ return `# Wiki
1984
+
1985
+ This is the codealmanac wiki for this repository. It captures the knowledge
1986
+ the code itself can't say \u2014 decisions, flows, invariants, gotchas, incidents.
1987
+
1988
+ The primary reader is an AI coding agent. The secondary reader is a human
1989
+ skimming to understand the shape of the codebase. Write accordingly: dense,
1990
+ factual, linked.
1991
+
1992
+ ## Notability bar
1993
+
1994
+ Write a page when there is **non-obvious knowledge that will help a future
1995
+ agent**. Specifically:
1996
+
1997
+ - A decision that took discussion, research, or trial-and-error
1998
+ - A gotcha discovered through failure
1999
+ - A cross-cutting flow that spans multiple files and isn't obvious from any
2000
+ one of them
2001
+ - A constraint or invariant not visible from the code
2002
+ - An entity (technology, service, system) referenced by multiple pages
2003
+
2004
+ Do not write pages that restate what the code does. Do not write pages of
2005
+ inference \u2014 only of observation. Silence is an acceptable outcome.
2006
+
2007
+ ## Topic taxonomy
2008
+
2009
+ Topics form a DAG; pages can belong to multiple topics. Start with these and
2010
+ grow as the wiki does:
2011
+
2012
+ - \`stack\` \u2014 technologies and services we use (frameworks, databases, APIs)
2013
+ - \`systems\` \u2014 custom systems we built (auth, billing, search)
2014
+ - \`flows\` \u2014 multi-file processes end-to-end (checkout-flow, publish-flow)
2015
+ - \`decisions\` \u2014 "why X over Y"
2016
+ - \`incidents\` \u2014 recorded failures and their fixes
2017
+ - \`concepts\` \u2014 shared vocabulary specific to this codebase
2018
+
2019
+ Domain topics (\`auth\`, \`payments\`, \`frontend\`, \`backend\`) live alongside
2020
+ these. A page about JWT rotation belongs to both \`auth\` and \`decisions\`.
2021
+
2022
+ ## Page shapes
2023
+
2024
+ Four shapes cover most of what gets written. They are suggestions, not a
2025
+ schema \u2014 a page that fits none of them is fine.
2026
+
2027
+ - **Entity** \u2014 a stable named thing (Supabase, Stripe, the search service)
2028
+ - **Decision** \u2014 why we chose X over Y
2029
+ - **Flow** \u2014 how a multi-file process works end-to-end
2030
+ - **Gotcha** \u2014 a specific surprise, failure, or constraint
2031
+
2032
+ ## Writing conventions
2033
+
2034
+ - Every sentence contains a specific fact. If it doesn't, cut it.
2035
+ - Neutral tone. "is", not "serves as". No "plays a pivotal role", no
2036
+ interpretive "-ing" clauses, no vague attribution ("experts argue").
2037
+ - No hedging or knowledge-gap disclaimers. If you don't know, don't write
2038
+ the sentence.
2039
+ - Prose first. Bullets for genuine lists. Tables only for structured
2040
+ comparison.
2041
+ - No formulaic conclusions. End with the last substantive fact.
2042
+
2043
+ ## Linking
2044
+
2045
+ One \`[[...]]\` syntax for everything, disambiguated by content:
2046
+
2047
+ - \`[[checkout-flow]]\` \u2014 page slug
2048
+ - \`[[src/checkout/handler.ts]]\` \u2014 file reference
2049
+ - \`[[src/checkout/]]\` \u2014 folder reference (trailing slash)
2050
+ - \`[[other-wiki:slug]]\` \u2014 cross-wiki reference
2051
+
2052
+ Every page should link to at least one entity when possible. A page with no
2053
+ entity link is suspect.
2054
+
2055
+ ## Pages live in \`.almanac/pages/\`
2056
+
2057
+ One markdown file per page, kebab-case slug. Frontmatter carries \`topics:\`
2058
+ and optional \`files:\`. The rest is prose.
2059
+ `;
2060
+ }
2061
+
2062
+ // src/commands/bootstrap.ts
2063
+ var BOOTSTRAP_TOOLS = ["Read", "Write", "Edit", "Glob", "Grep", "Bash"];
2064
+ async function runBootstrap(options) {
2065
+ try {
2066
+ await assertClaudeAuth(options.spawnCli);
2067
+ } catch (err) {
2068
+ const msg = err instanceof Error ? err.message : String(err);
2069
+ return {
2070
+ stdout: "",
2071
+ stderr: `almanac: ${msg}
2072
+ `,
2073
+ exitCode: 1
2074
+ };
2075
+ }
2076
+ const repoRoot = findNearestAlmanacDir(options.cwd) ?? options.cwd;
2077
+ const almanacDir = getRepoAlmanacDir(repoRoot);
2078
+ const pagesDir = join6(almanacDir, "pages");
2079
+ if (options.force !== true && existsSync5(pagesDir)) {
2080
+ const existing = await countMarkdownPages(pagesDir);
2081
+ if (existing > 0) {
2082
+ return {
2083
+ stdout: "",
2084
+ stderr: `almanac: .almanac/ already initialized with ${existing} page${existing === 1 ? "" : "s"}. Use 'almanac capture' instead, or --force to overwrite.
2085
+ `,
2086
+ exitCode: 1
2087
+ };
2088
+ }
2089
+ }
2090
+ if (!existsSync5(almanacDir)) {
2091
+ try {
2092
+ await initWiki({ cwd: repoRoot });
2093
+ } catch (err) {
2094
+ const msg = err instanceof Error ? err.message : String(err);
2095
+ return {
2096
+ stdout: "",
2097
+ stderr: `almanac: init failed during bootstrap: ${msg}
2098
+ `,
2099
+ exitCode: 1
2100
+ };
2101
+ }
2102
+ }
2103
+ const systemPrompt = await loadPrompt("bootstrap");
2104
+ const now = options.now?.() ?? /* @__PURE__ */ new Date();
2105
+ const logName = `.bootstrap-${formatTimestamp(now)}.log`;
2106
+ const logPath = join6(almanacDir, logName);
2107
+ const logStream = createWriteStream(logPath, { flags: "w" });
2108
+ const out = process.stdout;
2109
+ const formatter = new StreamingFormatter({
2110
+ write: (line) => {
2111
+ if (options.quiet !== true) out.write(line);
2112
+ }
2113
+ });
2114
+ const onMessage = (msg) => {
2115
+ try {
2116
+ logStream.write(`${JSON.stringify(msg)}
2117
+ `);
2118
+ } catch {
2119
+ }
2120
+ formatter.handle(msg);
2121
+ };
2122
+ const runner = options.runAgent ?? runAgent;
2123
+ const userPrompt = `Begin the bootstrap now. Working directory: ${repoRoot}.`;
2124
+ let result;
2125
+ try {
2126
+ result = await runner({
2127
+ systemPrompt,
2128
+ prompt: userPrompt,
2129
+ allowedTools: BOOTSTRAP_TOOLS,
2130
+ cwd: repoRoot,
2131
+ model: options.model,
2132
+ onMessage
2133
+ });
2134
+ } finally {
2135
+ await closeStream(logStream);
2136
+ }
2137
+ const finalLine = formatFinalLine(result, logPath, repoRoot);
2138
+ if (result.success) {
2139
+ return {
2140
+ stdout: `${finalLine}
2141
+ `,
2142
+ stderr: "",
2143
+ exitCode: 0
2144
+ };
2145
+ }
2146
+ return {
2147
+ stdout: options.quiet === true ? "" : `${finalLine}
2148
+ `,
2149
+ stderr: `almanac: bootstrap failed: ${result.error ?? "unknown error"}
2150
+ `,
2151
+ exitCode: 1
2152
+ };
2153
+ }
2154
+ function formatFinalLine(result, logPath, repoRoot) {
2155
+ const status = result.success ? "done" : "failed";
2156
+ const rel = relative(repoRoot, logPath);
2157
+ const cost = `$${result.cost.toFixed(3)}`;
2158
+ return `[${status}] cost: ${cost}, turns: ${result.turns} (transcript: ${rel})`;
2159
+ }
2160
+ async function countMarkdownPages(pagesDir) {
2161
+ try {
2162
+ const entries = await readdir(pagesDir, { withFileTypes: true });
2163
+ return entries.filter((e) => e.isFile() && e.name.endsWith(".md")).length;
2164
+ } catch {
2165
+ return 0;
2166
+ }
2167
+ }
2168
+ function closeStream(stream) {
2169
+ return new Promise((resolve) => {
2170
+ stream.end(() => resolve());
2171
+ });
2172
+ }
2173
+ function formatTimestamp(d) {
2174
+ const pad = (n) => n.toString().padStart(2, "0");
2175
+ const y = d.getFullYear();
2176
+ const mo = pad(d.getMonth() + 1);
2177
+ const da = pad(d.getDate());
2178
+ const h = pad(d.getHours());
2179
+ const mi = pad(d.getMinutes());
2180
+ const s = pad(d.getSeconds());
2181
+ return `${y}${mo}${da}-${h}${mi}${s}`;
2182
+ }
2183
+ var StreamingFormatter = class {
2184
+ sink;
2185
+ /**
2186
+ * Current agent label. Starts as "bootstrap"; switches when we see an
2187
+ * `Agent` tool-use (slice 5 will exercise this). We still track it here
2188
+ * so the formatter can stay shared between bootstrap and capture.
2189
+ */
2190
+ currentAgent = "bootstrap";
2191
+ constructor(sink) {
2192
+ this.sink = sink;
2193
+ }
2194
+ /**
2195
+ * Swap the top-level agent label. `capture` uses this to relabel from
2196
+ * the default "bootstrap" to "writer" — otherwise the writer's tool-use
2197
+ * output would render as `[bootstrap] …`, which is confusing when you're
2198
+ * reading capture logs.
2199
+ */
2200
+ setAgent(name) {
2201
+ this.currentAgent = name;
2202
+ }
2203
+ handle(msg) {
2204
+ if (msg.type === "assistant") {
2205
+ for (const block of msg.message.content) {
2206
+ if (block.type !== "tool_use") continue;
2207
+ this.handleToolUse(block.name, block.input);
2208
+ }
2209
+ return;
2210
+ }
2211
+ if (msg.type === "result") {
2212
+ const status = msg.subtype === "success" ? "done" : `failed (${msg.subtype})`;
2213
+ this.sink.write(
2214
+ `[${status}] cost: $${msg.total_cost_usd.toFixed(3)}, turns: ${msg.num_turns}
2215
+ `
2216
+ );
2217
+ return;
2218
+ }
2219
+ }
2220
+ handleToolUse(name, rawInput) {
2221
+ const input = normalizeToolInput(rawInput);
2222
+ if (name === "Agent") {
2223
+ const sub = typeof input.subagent_type === "string" ? input.subagent_type : "subagent";
2224
+ this.currentAgent = sub;
2225
+ this.sink.write(`[${sub}] starting
2226
+ `);
2227
+ return;
2228
+ }
2229
+ const summary = formatToolSummary(name, input);
2230
+ this.sink.write(`[${this.currentAgent}] ${summary}
2231
+ `);
2232
+ }
2233
+ };
2234
+ function normalizeToolInput(raw) {
2235
+ if (typeof raw === "string") {
2236
+ try {
2237
+ const parsed = JSON.parse(raw);
2238
+ if (parsed !== null && typeof parsed === "object") {
2239
+ return parsed;
2240
+ }
2241
+ } catch {
2242
+ }
2243
+ return {};
2244
+ }
2245
+ if (raw !== null && typeof raw === "object") {
2246
+ return raw;
2247
+ }
2248
+ return {};
2249
+ }
2250
+ function formatToolSummary(name, input) {
2251
+ switch (name) {
2252
+ case "Read": {
2253
+ const target = stringField(input, "file_path") ?? "?";
2254
+ return `reading ${target}`;
2255
+ }
2256
+ case "Write": {
2257
+ const target = stringField(input, "file_path") ?? "?";
2258
+ return `writing ${target}`;
2259
+ }
2260
+ case "Edit": {
2261
+ const target = stringField(input, "file_path") ?? "?";
2262
+ return `editing ${target}`;
2263
+ }
2264
+ case "Glob": {
2265
+ const pattern = stringField(input, "pattern") ?? "?";
2266
+ return `glob ${pattern}`;
2267
+ }
2268
+ case "Grep": {
2269
+ const pattern = stringField(input, "pattern") ?? "?";
2270
+ return `grep ${pattern}`;
2271
+ }
2272
+ case "Bash": {
2273
+ const command = stringField(input, "command") ?? "?";
2274
+ const trimmed = command.length > 80 ? `${command.slice(0, 77)}...` : command;
2275
+ return `bash ${trimmed}`;
2276
+ }
2277
+ default: {
2278
+ return name;
2279
+ }
2280
+ }
2281
+ }
2282
+ function stringField(input, key) {
2283
+ const value = input[key];
2284
+ return typeof value === "string" ? value : void 0;
2285
+ }
2286
+
2287
+ // src/commands/capture.ts
2288
+ import { createHash } from "crypto";
2289
+ import {
2290
+ createWriteStream as createWriteStream2,
2291
+ existsSync as existsSync6,
2292
+ statSync
2293
+ } from "fs";
2294
+ import { readFile as readFile6, readdir as readdir2, stat } from "fs/promises";
2295
+ import { homedir } from "os";
2296
+ import { basename as basename3, join as join7, relative as relative2 } from "path";
2297
+ var WRITER_TOOLS = ["Read", "Write", "Edit", "Glob", "Grep", "Bash", "Agent"];
2298
+ var REVIEWER_TOOLS = ["Read", "Grep", "Glob", "Bash"];
2299
+ var REVIEWER_DESCRIPTION = "Reviews proposed wiki changes against the full knowledge base for cohesion, duplication, missing links, notability, and writing conventions.";
2300
+ async function runCapture(options) {
2301
+ try {
2302
+ await assertClaudeAuth(options.spawnCli);
2303
+ } catch (err) {
2304
+ const msg = err instanceof Error ? err.message : String(err);
2305
+ return {
2306
+ stdout: "",
2307
+ stderr: `almanac: ${msg}
2308
+ `,
2309
+ exitCode: 1
2310
+ };
2311
+ }
2312
+ const repoRoot = findNearestAlmanacDir(options.cwd);
2313
+ if (repoRoot === null) {
2314
+ return {
2315
+ stdout: "",
2316
+ stderr: "almanac: no .almanac/ found in this directory or any parent. Run 'almanac bootstrap' first.\n",
2317
+ exitCode: 1
2318
+ };
2319
+ }
2320
+ const almanacDir = getRepoAlmanacDir(repoRoot);
2321
+ const pagesDir = join7(almanacDir, "pages");
2322
+ const transcriptResolution = await resolveTranscript({
2323
+ repoRoot,
2324
+ explicit: options.transcriptPath,
2325
+ sessionId: options.sessionId,
2326
+ claudeProjectsDir: options.claudeProjectsDir
2327
+ });
2328
+ if (!transcriptResolution.ok) {
2329
+ return {
2330
+ stdout: "",
2331
+ stderr: `almanac: ${transcriptResolution.error}
2332
+ `,
2333
+ exitCode: 1
2334
+ };
2335
+ }
2336
+ const transcriptPath = transcriptResolution.path;
2337
+ const snapshotBefore = await snapshotPages(pagesDir);
2338
+ const systemPrompt = await loadPrompt("writer");
2339
+ const reviewerPrompt = await loadPrompt("reviewer");
2340
+ const agents = {
2341
+ reviewer: {
2342
+ description: REVIEWER_DESCRIPTION,
2343
+ prompt: reviewerPrompt,
2344
+ tools: REVIEWER_TOOLS
2345
+ }
2346
+ };
2347
+ const now = options.now?.() ?? /* @__PURE__ */ new Date();
2348
+ const logStem = options.sessionId !== void 0 && options.sessionId.length > 0 ? options.sessionId : formatTimestamp2(now);
2349
+ const logName = `.capture-${logStem}.jsonl`;
2350
+ const logPath = join7(almanacDir, logName);
2351
+ const logStream = createWriteStream2(logPath, { flags: "w" });
2352
+ const out = process.stdout;
2353
+ const formatter = new StreamingFormatter({
2354
+ write: (line) => {
2355
+ if (options.quiet !== true) out.write(line);
2356
+ }
2357
+ });
2358
+ formatter.setAgent("writer");
2359
+ const onMessage = (msg) => {
2360
+ try {
2361
+ logStream.write(`${JSON.stringify(msg)}
2362
+ `);
2363
+ } catch {
2364
+ }
2365
+ formatter.handle(msg);
2366
+ };
2367
+ const userPrompt = `Capture this coding session.
2368
+ Transcript: ${transcriptPath}.
2369
+ Working directory: ${repoRoot}.`;
2370
+ const runner = options.runAgent ?? runAgent;
2371
+ let result;
2372
+ try {
2373
+ result = await runner({
2374
+ systemPrompt,
2375
+ prompt: userPrompt,
2376
+ allowedTools: WRITER_TOOLS,
2377
+ agents,
2378
+ cwd: repoRoot,
2379
+ model: options.model,
2380
+ // Capture sessions can touch many pages; give it more headroom than
2381
+ // bootstrap. The SDK treats `maxTurns` as a hard stop — better to
2382
+ // overshoot than to cut off mid-review.
2383
+ maxTurns: 150,
2384
+ onMessage
2385
+ });
2386
+ } finally {
2387
+ await closeStream2(logStream);
2388
+ }
2389
+ const snapshotAfter = await snapshotPages(pagesDir);
2390
+ const delta = diffSnapshots(snapshotBefore, snapshotAfter);
2391
+ if (!result.success) {
2392
+ return {
2393
+ stdout: "",
2394
+ stderr: `almanac: capture failed: ${result.error ?? "unknown error"}
2395
+ (transcript: ${relative2(repoRoot, logPath)})
2396
+ `,
2397
+ exitCode: 1
2398
+ };
2399
+ }
2400
+ const summary = formatSummary(result, delta, logPath, repoRoot);
2401
+ return {
2402
+ stdout: `${summary}
2403
+ `,
2404
+ stderr: "",
2405
+ exitCode: 0
2406
+ };
2407
+ }
2408
+ async function resolveTranscript(args) {
2409
+ if (args.explicit !== void 0 && args.explicit.length > 0) {
2410
+ if (!existsSync6(args.explicit)) {
2411
+ return {
2412
+ ok: false,
2413
+ error: `transcript not found: ${args.explicit}`
2414
+ };
2415
+ }
2416
+ return { ok: true, path: args.explicit };
2417
+ }
2418
+ const projectsDir = args.claudeProjectsDir ?? join7(homedir(), ".claude", "projects");
2419
+ if (!existsSync6(projectsDir)) {
2420
+ return {
2421
+ ok: false,
2422
+ error: `could not auto-resolve transcript; ${projectsDir} does not exist. Pass --session <id> or <transcript-path>.`
2423
+ };
2424
+ }
2425
+ const allTranscripts = await collectTranscripts(projectsDir);
2426
+ if (args.sessionId !== void 0 && args.sessionId.length > 0) {
2427
+ const expected = `${args.sessionId}.jsonl`;
2428
+ const match = allTranscripts.find((t) => basename3(t.path) === expected);
2429
+ if (match === void 0) {
2430
+ return {
2431
+ ok: false,
2432
+ error: `no transcript found for session ${args.sessionId} under ${projectsDir}`
2433
+ };
2434
+ }
2435
+ return { ok: true, path: match.path };
2436
+ }
2437
+ const matches = await filterTranscriptsByCwd(allTranscripts, args.repoRoot);
2438
+ if (matches.length === 0) {
2439
+ return {
2440
+ ok: false,
2441
+ error: `could not auto-resolve transcript under ${projectsDir}; no session matches cwd ${args.repoRoot}. Pass --session <id> or <transcript-path>.`
2442
+ };
2443
+ }
2444
+ matches.sort((a, b) => b.mtime - a.mtime);
2445
+ return { ok: true, path: matches[0].path };
2446
+ }
2447
+ async function collectTranscripts(projectsDir) {
2448
+ const out = [];
2449
+ let topLevel;
2450
+ try {
2451
+ topLevel = await readdir2(projectsDir);
2452
+ } catch {
2453
+ return out;
2454
+ }
2455
+ for (const name of topLevel) {
2456
+ const projectDir = join7(projectsDir, name);
2457
+ let entries;
2458
+ try {
2459
+ entries = await readdir2(projectDir);
2460
+ } catch {
2461
+ continue;
2462
+ }
2463
+ for (const entry of entries) {
2464
+ if (!entry.endsWith(".jsonl")) continue;
2465
+ const full = join7(projectDir, entry);
2466
+ try {
2467
+ const st = await stat(full);
2468
+ if (st.isFile()) {
2469
+ out.push({ path: full, mtime: st.mtimeMs });
2470
+ }
2471
+ } catch {
2472
+ }
2473
+ }
2474
+ }
2475
+ return out;
2476
+ }
2477
+ async function filterTranscriptsByCwd(transcripts, repoRoot) {
2478
+ const dirHash = `-${repoRoot.replace(/^\/+/, "").replace(/\//g, "-")}`;
2479
+ const byDirName = transcripts.filter((t) => {
2480
+ const parent = basename3(join7(t.path, ".."));
2481
+ return parent === dirHash || parent.endsWith(dirHash);
2482
+ });
2483
+ if (byDirName.length > 0) return byDirName;
2484
+ const needle = `"cwd":"${repoRoot}"`;
2485
+ const hits = [];
2486
+ for (const t of transcripts) {
2487
+ try {
2488
+ const head = await readHead(t.path, 4096);
2489
+ if (head.includes(needle)) hits.push(t);
2490
+ } catch {
2491
+ continue;
2492
+ }
2493
+ }
2494
+ return hits;
2495
+ }
2496
+ async function readHead(path2, bytes) {
2497
+ const content = await readFile6(path2, "utf8");
2498
+ return content.length > bytes ? content.slice(0, bytes) : content;
2499
+ }
2500
+ async function snapshotPages(pagesDir) {
2501
+ const out = /* @__PURE__ */ new Map();
2502
+ if (!existsSync6(pagesDir)) return out;
2503
+ let entries;
2504
+ try {
2505
+ entries = await readdir2(pagesDir);
2506
+ } catch {
2507
+ return out;
2508
+ }
2509
+ for (const entry of entries) {
2510
+ if (!entry.endsWith(".md")) continue;
2511
+ const slug = entry.slice(0, -3);
2512
+ const full = join7(pagesDir, entry);
2513
+ try {
2514
+ const st = statSync(full);
2515
+ if (!st.isFile()) continue;
2516
+ const content = await readFile6(full, "utf8");
2517
+ const hash = createHash("sha256").update(content).digest("hex");
2518
+ const fm = parseFrontmatter(content);
2519
+ out.set(slug, {
2520
+ slug,
2521
+ hash,
2522
+ archived: fm.archived_at !== null
2523
+ });
2524
+ } catch {
2525
+ continue;
2526
+ }
2527
+ }
2528
+ return out;
2529
+ }
2530
+ function diffSnapshots(before, after) {
2531
+ let created = 0;
2532
+ let updated = 0;
2533
+ let archived = 0;
2534
+ for (const [slug, entry] of after) {
2535
+ const prev = before.get(slug);
2536
+ if (prev === void 0) {
2537
+ created += 1;
2538
+ continue;
2539
+ }
2540
+ if (prev.hash !== entry.hash) {
2541
+ if (!prev.archived && entry.archived) {
2542
+ archived += 1;
2543
+ } else {
2544
+ updated += 1;
2545
+ }
2546
+ }
2547
+ }
2548
+ return { created, updated, archived };
2549
+ }
2550
+ function formatSummary(result, delta, logPath, repoRoot) {
2551
+ const rel = relative2(repoRoot, logPath);
2552
+ const cost = `$${result.cost.toFixed(3)}`;
2553
+ const { created, updated, archived } = delta;
2554
+ if (created === 0 && updated === 0 && archived === 0) {
2555
+ return `[capture] no new knowledge met the notability bar (0 pages written), cost: ${cost}, turns: ${result.turns} (transcript: ${rel})`;
2556
+ }
2557
+ return `[done] ${updated} page${updated === 1 ? "" : "s"} updated, ${created} created, ${archived} archived, cost: ${cost}, turns: ${result.turns} (transcript: ${rel})`;
2558
+ }
2559
+ function formatTimestamp2(d) {
2560
+ const pad = (n) => n.toString().padStart(2, "0");
2561
+ return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`;
2562
+ }
2563
+ function closeStream2(stream) {
2564
+ return new Promise((resolve) => {
2565
+ stream.end(() => resolve());
2566
+ });
2567
+ }
2568
+
2569
+ // src/commands/reindex.ts
2570
+ async function runReindex(options) {
2571
+ const repoRoot = await resolveWikiRoot({
2572
+ cwd: options.cwd,
2573
+ wiki: options.wiki
2574
+ });
2575
+ const result = await runIndexer({ repoRoot });
2576
+ const skipSuffix = result.filesSkipped > 0 ? `; ${result.filesSkipped} skipped` : "";
2577
+ const stdout = `reindexed: ${result.pagesIndexed} page${result.pagesIndexed === 1 ? "" : "s"} (${result.changed} updated, ${result.removed} removed${skipSuffix})
2578
+ `;
2579
+ return { result, stdout, exitCode: 0 };
2580
+ }
2581
+
2582
+ // src/cli/register-wiki-lifecycle-commands.ts
2583
+ function registerWikiLifecycleCommands(program) {
2584
+ program.command("bootstrap").description(
2585
+ "scaffold a wiki in this repo via an AI agent (requires ANTHROPIC_API_KEY or Claude subscription)"
2586
+ ).option("--quiet", "suppress per-tool streaming; print only the final line").option("--model <model>", "override the agent model").option("--force", "overwrite an existing populated wiki (default: refuse)").action(
2587
+ async (opts) => {
2588
+ const result = await runBootstrap({
2589
+ cwd: process.cwd(),
2590
+ quiet: opts.quiet,
2591
+ model: opts.model,
2592
+ force: opts.force
2593
+ });
2594
+ emit(result);
2595
+ }
2596
+ );
2597
+ program.command("capture [transcript]").description("run the writer/reviewer pipeline on a session (usually automatic)").option("--session <id>", "target a specific session by ID").option("--quiet", "suppress per-tool streaming; print only the final summary").option("--model <model>", "override the agent model").action(
2598
+ async (transcript, opts) => {
2599
+ await autoRegisterIfNeeded(process.cwd());
2600
+ const result = await runCapture({
2601
+ cwd: process.cwd(),
2602
+ transcriptPath: transcript,
2603
+ sessionId: opts.session,
2604
+ quiet: opts.quiet,
2605
+ model: opts.model
2606
+ });
2607
+ emit(result);
2608
+ }
2609
+ );
2610
+ const hook = program.command("hook").description("manage the SessionEnd auto-capture hook");
2611
+ hook.command("install").description("add a SessionEnd entry that runs 'almanac capture' on session end").action(async () => {
2612
+ const result = await runHookInstall();
2613
+ emit(result);
2614
+ });
2615
+ hook.command("uninstall").description("remove codealmanac's SessionEnd entry; leaves foreign entries alone").action(async () => {
2616
+ const result = await runHookUninstall();
2617
+ emit(result);
2618
+ });
2619
+ hook.command("status").description("report whether the SessionEnd hook is installed").action(async () => {
2620
+ const result = await runHookStatus();
2621
+ emit(result);
2622
+ });
2623
+ program.command("reindex").description("force a full rebuild of .almanac/index.db").option("--wiki <name>", "target a specific registered wiki").action(async (opts) => {
2624
+ await autoRegisterIfNeeded(process.cwd());
2625
+ const result = await runReindex({
2626
+ cwd: process.cwd(),
2627
+ wiki: opts.wiki
2628
+ });
2629
+ process.stdout.write(result.stdout);
2630
+ if (result.exitCode !== 0) process.exitCode = result.exitCode;
2631
+ });
2632
+ }
2633
+
2634
+ // src/cli/register-commands.ts
2635
+ function registerCommands(program) {
2636
+ registerQueryCommands(program);
2637
+ registerEditCommands(program);
2638
+ registerWikiLifecycleCommands(program);
2639
+ registerSetupCommands(program);
2640
+ }
2641
+ export {
2642
+ registerCommands
2643
+ };
2644
+ //# sourceMappingURL=register-commands-PZMQNGCH.js.map