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.
- package/dist/chunk-2JJTTN7P.js +539 -0
- package/dist/chunk-2JJTTN7P.js.map +1 -0
- package/dist/chunk-3C5SY5SE.js +1239 -0
- package/dist/chunk-3C5SY5SE.js.map +1 -0
- package/dist/chunk-4CODZRHH.js +19 -0
- package/dist/chunk-4CODZRHH.js.map +1 -0
- package/dist/chunk-7JUX4ADQ.js +38 -0
- package/dist/chunk-7JUX4ADQ.js.map +1 -0
- package/dist/chunk-A6PUCAVJ.js +145 -0
- package/dist/chunk-A6PUCAVJ.js.map +1 -0
- package/dist/chunk-AXFPUHBN.js +227 -0
- package/dist/chunk-AXFPUHBN.js.map +1 -0
- package/dist/chunk-FM3VRDK7.js +20 -0
- package/dist/chunk-FM3VRDK7.js.map +1 -0
- package/dist/chunk-H6WU6PYH.js +441 -0
- package/dist/chunk-H6WU6PYH.js.map +1 -0
- package/dist/chunk-P3LDTCLB.js +34 -0
- package/dist/chunk-P3LDTCLB.js.map +1 -0
- package/dist/chunk-QHQ6YH7U.js +81 -0
- package/dist/chunk-QHQ6YH7U.js.map +1 -0
- package/dist/chunk-Z4MWLVS2.js +355 -0
- package/dist/chunk-Z4MWLVS2.js.map +1 -0
- package/dist/chunk-Z6MBJ3D2.js +203 -0
- package/dist/chunk-Z6MBJ3D2.js.map +1 -0
- package/dist/cli-AIH5QQ5H.js +393 -0
- package/dist/cli-AIH5QQ5H.js.map +1 -0
- package/dist/codealmanac.js +68 -5954
- package/dist/codealmanac.js.map +1 -1
- package/dist/doctor-6FN5JO5F.js +15 -0
- package/dist/doctor-6FN5JO5F.js.map +1 -0
- package/dist/hook-CRJMWSSO.js +12 -0
- package/dist/hook-CRJMWSSO.js.map +1 -0
- package/dist/register-commands-PZMQNGCH.js +2644 -0
- package/dist/register-commands-PZMQNGCH.js.map +1 -0
- package/dist/uninstall-NBEZNNKM.js +12 -0
- package/dist/uninstall-NBEZNNKM.js.map +1 -0
- package/dist/update-IL243I4E.js +10 -0
- package/dist/update-IL243I4E.js.map +1 -0
- package/dist/wiki-EHZ7LG7R.js +238 -0
- package/dist/wiki-EHZ7LG7R.js.map +1 -0
- package/guides/processing/claude-code.md +152 -0
- package/guides/processing/codex.md +214 -0
- package/guides/processing/generic.md +128 -0
- 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
|