codealmanac 0.1.1 → 0.1.3
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/README.md +53 -40
- package/dist/codealmanac.js +2522 -1402
- package/dist/codealmanac.js.map +1 -1
- package/guides/mini.md +223 -0
- package/guides/reference.md +686 -0
- package/package.json +2 -1
- package/prompts/bootstrap.md +55 -10
package/dist/codealmanac.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// src/cli.ts
|
|
4
|
+
import { createRequire as createRequire4 } from "module";
|
|
4
5
|
import { basename as basename6 } from "path";
|
|
5
6
|
import { Command } from "commander";
|
|
6
7
|
|
|
@@ -225,7 +226,7 @@ function findNearestAlmanacDir(startDir) {
|
|
|
225
226
|
}
|
|
226
227
|
}
|
|
227
228
|
|
|
228
|
-
// src/commands/
|
|
229
|
+
// src/commands/_init.ts
|
|
229
230
|
import { existsSync as existsSync3 } from "fs";
|
|
230
231
|
import { mkdir as mkdir2, readFile as readFile3, writeFile as writeFile2 } from "fs/promises";
|
|
231
232
|
import { basename, join as join3 } from "path";
|
|
@@ -239,10 +240,10 @@ function toKebabCase(input) {
|
|
|
239
240
|
import { mkdir, readFile as readFile2, rename, writeFile } from "fs/promises";
|
|
240
241
|
import { dirname as dirname3 } from "path";
|
|
241
242
|
async function readRegistry() {
|
|
242
|
-
const
|
|
243
|
+
const path6 = getRegistryPath();
|
|
243
244
|
let raw;
|
|
244
245
|
try {
|
|
245
|
-
raw = await readFile2(
|
|
246
|
+
raw = await readFile2(path6, "utf8");
|
|
246
247
|
} catch (err) {
|
|
247
248
|
if (isNodeError(err) && err.code === "ENOENT") {
|
|
248
249
|
return [];
|
|
@@ -258,10 +259,10 @@ async function readRegistry() {
|
|
|
258
259
|
parsed = JSON.parse(trimmed);
|
|
259
260
|
} catch (err) {
|
|
260
261
|
const message = err instanceof Error ? err.message : String(err);
|
|
261
|
-
throw new Error(`registry at ${
|
|
262
|
+
throw new Error(`registry at ${path6} is not valid JSON: ${message}`);
|
|
262
263
|
}
|
|
263
264
|
if (!Array.isArray(parsed)) {
|
|
264
|
-
throw new Error(`registry at ${
|
|
265
|
+
throw new Error(`registry at ${path6} must be a JSON array`);
|
|
265
266
|
}
|
|
266
267
|
return parsed.map((item, idx) => {
|
|
267
268
|
if (typeof item !== "object" || item === null) {
|
|
@@ -269,29 +270,29 @@ async function readRegistry() {
|
|
|
269
270
|
}
|
|
270
271
|
const e = item;
|
|
271
272
|
const name = typeof e.name === "string" ? e.name : "";
|
|
272
|
-
const
|
|
273
|
+
const path7 = typeof e.path === "string" ? e.path : "";
|
|
273
274
|
if (name.length === 0) {
|
|
274
275
|
throw new Error(`registry entry ${idx} is missing a non-empty "name"`);
|
|
275
276
|
}
|
|
276
|
-
if (
|
|
277
|
+
if (path7.length === 0) {
|
|
277
278
|
throw new Error(`registry entry ${idx} is missing a non-empty "path"`);
|
|
278
279
|
}
|
|
279
280
|
return {
|
|
280
281
|
name,
|
|
281
282
|
description: typeof e.description === "string" ? e.description : "",
|
|
282
|
-
path:
|
|
283
|
+
path: path7,
|
|
283
284
|
registered_at: typeof e.registered_at === "string" ? e.registered_at : ""
|
|
284
285
|
};
|
|
285
286
|
});
|
|
286
287
|
}
|
|
287
288
|
async function writeRegistry(entries) {
|
|
288
|
-
const
|
|
289
|
-
await mkdir(dirname3(
|
|
289
|
+
const path6 = getRegistryPath();
|
|
290
|
+
await mkdir(dirname3(path6), { recursive: true });
|
|
290
291
|
const body = `${JSON.stringify(entries, null, 2)}
|
|
291
292
|
`;
|
|
292
|
-
const tmpPath = `${
|
|
293
|
+
const tmpPath = `${path6}.tmp`;
|
|
293
294
|
await writeFile(tmpPath, body, "utf8");
|
|
294
|
-
await rename(tmpPath,
|
|
295
|
+
await rename(tmpPath, path6);
|
|
295
296
|
}
|
|
296
297
|
function pathsEqual(a, b) {
|
|
297
298
|
if (process.platform === "darwin" || process.platform === "win32") {
|
|
@@ -335,7 +336,7 @@ function isNodeError(err) {
|
|
|
335
336
|
return err instanceof Error && "code" in err;
|
|
336
337
|
}
|
|
337
338
|
|
|
338
|
-
// src/commands/
|
|
339
|
+
// src/commands/_init.ts
|
|
339
340
|
async function initWiki(options) {
|
|
340
341
|
const repoRoot = findNearestAlmanacDir(options.cwd) ?? options.cwd;
|
|
341
342
|
const almanacDir = getRepoAlmanacDir(repoRoot);
|
|
@@ -365,15 +366,15 @@ async function initWiki(options) {
|
|
|
365
366
|
return { entry, almanacDir, created: !alreadyExisted };
|
|
366
367
|
}
|
|
367
368
|
async function ensureGitignoreHasIndexDb(cwd) {
|
|
368
|
-
const
|
|
369
|
+
const path6 = join3(cwd, ".gitignore");
|
|
369
370
|
const targets = [
|
|
370
371
|
".almanac/index.db",
|
|
371
372
|
".almanac/index.db-wal",
|
|
372
373
|
".almanac/index.db-shm"
|
|
373
374
|
];
|
|
374
375
|
let existing = "";
|
|
375
|
-
if (existsSync3(
|
|
376
|
-
existing = await readFile3(
|
|
376
|
+
if (existsSync3(path6)) {
|
|
377
|
+
existing = await readFile3(path6, "utf8");
|
|
377
378
|
}
|
|
378
379
|
const lines = existing.split(/\r?\n/).map((l) => l.trim());
|
|
379
380
|
const missing = targets.filter((t) => !lines.includes(t));
|
|
@@ -383,7 +384,7 @@ async function ensureGitignoreHasIndexDb(cwd) {
|
|
|
383
384
|
${missing.join("\n")}
|
|
384
385
|
`;
|
|
385
386
|
const sep = existing.length === 0 ? "" : existing.endsWith("\n") ? "\n" : "\n\n";
|
|
386
|
-
await writeFile2(
|
|
387
|
+
await writeFile2(path6, `${existing}${sep}${block}`, "utf8");
|
|
387
388
|
}
|
|
388
389
|
function starterReadme() {
|
|
389
390
|
return `# Wiki
|
|
@@ -807,7 +808,7 @@ async function runCapture(options) {
|
|
|
807
808
|
if (repoRoot === null) {
|
|
808
809
|
return {
|
|
809
810
|
stdout: "",
|
|
810
|
-
stderr: "almanac: no .almanac/ found in this directory or any parent. Run 'almanac
|
|
811
|
+
stderr: "almanac: no .almanac/ found in this directory or any parent. Run 'almanac bootstrap' first.\n",
|
|
811
812
|
exitCode: 1
|
|
812
813
|
};
|
|
813
814
|
}
|
|
@@ -986,8 +987,8 @@ async function filterTranscriptsByCwd(transcripts, repoRoot) {
|
|
|
986
987
|
}
|
|
987
988
|
return hits;
|
|
988
989
|
}
|
|
989
|
-
async function readHead(
|
|
990
|
-
const content = await readFile4(
|
|
990
|
+
async function readHead(path6, bytes) {
|
|
991
|
+
const content = await readFile4(path6, "utf8");
|
|
991
992
|
return content.length > bytes ? content.slice(0, bytes) : content;
|
|
992
993
|
}
|
|
993
994
|
async function snapshotPages(pagesDir) {
|
|
@@ -1059,201 +1060,97 @@ function closeStream2(stream) {
|
|
|
1059
1060
|
});
|
|
1060
1061
|
}
|
|
1061
1062
|
|
|
1062
|
-
// src/commands/
|
|
1063
|
-
import { existsSync as
|
|
1064
|
-
import {
|
|
1065
|
-
import {
|
|
1066
|
-
import
|
|
1067
|
-
import
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
const existing = (settings.hooks?.SessionEnd ?? []).slice();
|
|
1135
|
-
const kept = existing.filter((e) => !e.command.endsWith("almanac-capture.sh"));
|
|
1136
|
-
const removed = existing.length - kept.length;
|
|
1137
|
-
if (removed === 0) {
|
|
1138
|
-
return {
|
|
1139
|
-
stdout: `almanac: SessionEnd hook not installed
|
|
1140
|
-
`,
|
|
1141
|
-
stderr: "",
|
|
1142
|
-
exitCode: 0
|
|
1143
|
-
};
|
|
1144
|
-
}
|
|
1145
|
-
if (settings.hooks !== void 0) {
|
|
1146
|
-
if (kept.length === 0) {
|
|
1147
|
-
const { SessionEnd: _dropped, ...rest } = settings.hooks;
|
|
1148
|
-
void _dropped;
|
|
1149
|
-
settings.hooks = rest;
|
|
1150
|
-
} else {
|
|
1151
|
-
settings.hooks = { ...settings.hooks, SessionEnd: kept };
|
|
1152
|
-
}
|
|
1153
|
-
if (Object.keys(settings.hooks).length === 0) {
|
|
1154
|
-
delete settings.hooks;
|
|
1155
|
-
}
|
|
1156
|
-
}
|
|
1157
|
-
await writeSettings(settingsPath, settings);
|
|
1158
|
-
return {
|
|
1159
|
-
stdout: `almanac: SessionEnd hook removed
|
|
1160
|
-
`,
|
|
1161
|
-
stderr: "",
|
|
1162
|
-
exitCode: 0
|
|
1163
|
-
};
|
|
1164
|
-
}
|
|
1165
|
-
async function runHookStatus(options = {}) {
|
|
1166
|
-
const script = resolveHookScriptPath(options);
|
|
1167
|
-
const settingsPath = resolveSettingsPath(options);
|
|
1168
|
-
if (!existsSync6(settingsPath)) {
|
|
1169
|
-
return {
|
|
1170
|
-
stdout: `SessionEnd hook: not installed
|
|
1171
|
-
settings: ${settingsPath} (does not exist)
|
|
1172
|
-
` + (script.ok ? `script would be: ${script.path}
|
|
1173
|
-
` : ""),
|
|
1174
|
-
stderr: "",
|
|
1175
|
-
exitCode: 0
|
|
1176
|
-
};
|
|
1177
|
-
}
|
|
1178
|
-
const settings = await readSettings(settingsPath);
|
|
1179
|
-
const existing = settings.hooks?.SessionEnd ?? [];
|
|
1180
|
-
const ours = existing.find((e) => e.command.endsWith("almanac-capture.sh"));
|
|
1181
|
-
if (ours === void 0) {
|
|
1182
|
-
const foreign = existing.map((e) => ` - ${e.command}`).join("\n");
|
|
1183
|
-
return {
|
|
1184
|
-
stdout: `SessionEnd hook: not installed
|
|
1185
|
-
settings: ${settingsPath}
|
|
1186
|
-
` + (existing.length > 0 ? `(${existing.length} foreign entr${existing.length === 1 ? "y" : "ies"} present:
|
|
1187
|
-
${foreign})
|
|
1188
|
-
` : "") + (script.ok ? `script would be: ${script.path}
|
|
1189
|
-
` : ""),
|
|
1190
|
-
stderr: "",
|
|
1191
|
-
exitCode: 0
|
|
1192
|
-
};
|
|
1193
|
-
}
|
|
1194
|
-
return {
|
|
1195
|
-
stdout: `SessionEnd hook: installed
|
|
1196
|
-
script: ${ours.command}
|
|
1197
|
-
settings: ${settingsPath}
|
|
1198
|
-
`,
|
|
1199
|
-
stderr: "",
|
|
1200
|
-
exitCode: 0
|
|
1201
|
-
};
|
|
1202
|
-
}
|
|
1203
|
-
function resolveSettingsPath(options) {
|
|
1204
|
-
if (options.settingsPath !== void 0) return options.settingsPath;
|
|
1205
|
-
return path2.join(homedir3(), ".claude", "settings.json");
|
|
1206
|
-
}
|
|
1207
|
-
function resolveHookScriptPath(options) {
|
|
1208
|
-
if (options.hookScriptPath !== void 0) {
|
|
1209
|
-
return { ok: true, path: options.hookScriptPath };
|
|
1063
|
+
// src/commands/doctor.ts
|
|
1064
|
+
import { existsSync as existsSync12, readdirSync, statSync as statSync3 } from "fs";
|
|
1065
|
+
import { readFile as readFile10 } from "fs/promises";
|
|
1066
|
+
import { createRequire as createRequire3 } from "module";
|
|
1067
|
+
import { homedir as homedir5 } from "os";
|
|
1068
|
+
import path4 from "path";
|
|
1069
|
+
|
|
1070
|
+
// src/indexer/schema.ts
|
|
1071
|
+
import Database from "better-sqlite3";
|
|
1072
|
+
var SCHEMA_DDL = `
|
|
1073
|
+
CREATE TABLE IF NOT EXISTS pages (
|
|
1074
|
+
slug TEXT PRIMARY KEY,
|
|
1075
|
+
title TEXT,
|
|
1076
|
+
file_path TEXT NOT NULL,
|
|
1077
|
+
content_hash TEXT NOT NULL,
|
|
1078
|
+
updated_at INTEGER NOT NULL,
|
|
1079
|
+
archived_at INTEGER,
|
|
1080
|
+
superseded_by TEXT
|
|
1081
|
+
);
|
|
1082
|
+
|
|
1083
|
+
CREATE TABLE IF NOT EXISTS topics (
|
|
1084
|
+
slug TEXT PRIMARY KEY,
|
|
1085
|
+
title TEXT,
|
|
1086
|
+
description TEXT
|
|
1087
|
+
);
|
|
1088
|
+
|
|
1089
|
+
CREATE TABLE IF NOT EXISTS page_topics (
|
|
1090
|
+
page_slug TEXT NOT NULL REFERENCES pages(slug) ON DELETE CASCADE,
|
|
1091
|
+
topic_slug TEXT NOT NULL,
|
|
1092
|
+
PRIMARY KEY (page_slug, topic_slug)
|
|
1093
|
+
);
|
|
1094
|
+
|
|
1095
|
+
CREATE TABLE IF NOT EXISTS topic_parents (
|
|
1096
|
+
child_slug TEXT NOT NULL,
|
|
1097
|
+
parent_slug TEXT NOT NULL,
|
|
1098
|
+
PRIMARY KEY (child_slug, parent_slug),
|
|
1099
|
+
CHECK (child_slug != parent_slug)
|
|
1100
|
+
);
|
|
1101
|
+
|
|
1102
|
+
CREATE TABLE IF NOT EXISTS file_refs (
|
|
1103
|
+
page_slug TEXT NOT NULL REFERENCES pages(slug) ON DELETE CASCADE,
|
|
1104
|
+
path TEXT NOT NULL,
|
|
1105
|
+
original_path TEXT NOT NULL,
|
|
1106
|
+
is_dir INTEGER NOT NULL,
|
|
1107
|
+
PRIMARY KEY (page_slug, path)
|
|
1108
|
+
);
|
|
1109
|
+
CREATE INDEX IF NOT EXISTS idx_file_refs_path ON file_refs(path);
|
|
1110
|
+
|
|
1111
|
+
CREATE TABLE IF NOT EXISTS wikilinks (
|
|
1112
|
+
source_slug TEXT NOT NULL REFERENCES pages(slug) ON DELETE CASCADE,
|
|
1113
|
+
target_slug TEXT NOT NULL,
|
|
1114
|
+
PRIMARY KEY (source_slug, target_slug)
|
|
1115
|
+
);
|
|
1116
|
+
|
|
1117
|
+
CREATE TABLE IF NOT EXISTS cross_wiki_links (
|
|
1118
|
+
source_slug TEXT NOT NULL REFERENCES pages(slug) ON DELETE CASCADE,
|
|
1119
|
+
target_wiki TEXT NOT NULL,
|
|
1120
|
+
target_slug TEXT NOT NULL,
|
|
1121
|
+
PRIMARY KEY (source_slug, target_wiki, target_slug)
|
|
1122
|
+
);
|
|
1123
|
+
|
|
1124
|
+
-- NOTE: virtual FTS5 table \u2014 ON DELETE CASCADE from pages does NOT apply.
|
|
1125
|
+
-- The indexer must explicitly DELETE FROM fts_pages whenever it removes
|
|
1126
|
+
-- or replaces a page row, or we leak orphaned FTS rows.
|
|
1127
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS fts_pages USING fts5(slug, title, content);
|
|
1128
|
+
`;
|
|
1129
|
+
var SCHEMA_VERSION = 2;
|
|
1130
|
+
function openIndex(dbPath) {
|
|
1131
|
+
const db = new Database(dbPath);
|
|
1132
|
+
const mode = db.pragma("journal_mode", { simple: true });
|
|
1133
|
+
if (typeof mode !== "string" || mode.toLowerCase() !== "wal") {
|
|
1134
|
+
db.pragma("journal_mode = WAL");
|
|
1210
1135
|
}
|
|
1211
|
-
|
|
1212
|
-
const
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
];
|
|
1220
|
-
for (const candidate of candidates) {
|
|
1221
|
-
if (existsSync6(candidate)) {
|
|
1222
|
-
return { ok: true, path: candidate };
|
|
1136
|
+
db.pragma("foreign_keys = ON");
|
|
1137
|
+
const rawVersion = db.pragma("user_version", { simple: true });
|
|
1138
|
+
const currentVersion = typeof rawVersion === "number" ? rawVersion : 0;
|
|
1139
|
+
if (currentVersion < SCHEMA_VERSION) {
|
|
1140
|
+
db.exec("DROP TABLE IF EXISTS file_refs");
|
|
1141
|
+
try {
|
|
1142
|
+
db.exec("UPDATE pages SET content_hash = ''");
|
|
1143
|
+
} catch {
|
|
1223
1144
|
}
|
|
1145
|
+
db.pragma(`user_version = ${SCHEMA_VERSION}`);
|
|
1224
1146
|
}
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
error: `could not locate hooks/almanac-capture.sh. Tried:
|
|
1228
|
-
` + candidates.map((c) => ` - ${c}`).join("\n")
|
|
1229
|
-
};
|
|
1230
|
-
}
|
|
1231
|
-
async function readSettings(settingsPath) {
|
|
1232
|
-
if (!existsSync6(settingsPath)) return {};
|
|
1233
|
-
try {
|
|
1234
|
-
const raw = await readFile5(settingsPath, "utf8");
|
|
1235
|
-
if (raw.trim().length === 0) return {};
|
|
1236
|
-
const parsed = JSON.parse(raw);
|
|
1237
|
-
if (parsed === null || typeof parsed !== "object") return {};
|
|
1238
|
-
return parsed;
|
|
1239
|
-
} catch (err) {
|
|
1240
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
1241
|
-
throw new Error(`failed to read ${settingsPath}: ${msg}`);
|
|
1242
|
-
}
|
|
1243
|
-
}
|
|
1244
|
-
async function writeSettings(settingsPath, settings) {
|
|
1245
|
-
const dir = path2.dirname(settingsPath);
|
|
1246
|
-
await mkdir3(dir, { recursive: true });
|
|
1247
|
-
const tmp = `${settingsPath}.almanac-tmp-${process.pid}`;
|
|
1248
|
-
const body = `${JSON.stringify(settings, null, 2)}
|
|
1249
|
-
`;
|
|
1250
|
-
await writeFile3(tmp, body, "utf8");
|
|
1251
|
-
await rename2(tmp, settingsPath);
|
|
1147
|
+
db.exec(SCHEMA_DDL);
|
|
1148
|
+
return db;
|
|
1252
1149
|
}
|
|
1253
1150
|
|
|
1254
1151
|
// src/commands/health.ts
|
|
1255
|
-
import { existsSync as
|
|
1256
|
-
import { readFile as
|
|
1152
|
+
import { existsSync as existsSync9 } from "fs";
|
|
1153
|
+
import { readFile as readFile7 } from "fs/promises";
|
|
1257
1154
|
import { basename as basename4, join as join8 } from "path";
|
|
1258
1155
|
import fg2 from "fast-glob";
|
|
1259
1156
|
|
|
@@ -1284,1123 +1181,1857 @@ function parseDuration(input) {
|
|
|
1284
1181
|
|
|
1285
1182
|
// src/indexer/index.ts
|
|
1286
1183
|
import { createHash as createHash2 } from "crypto";
|
|
1287
|
-
import { existsSync as
|
|
1288
|
-
import { readFile as
|
|
1184
|
+
import { existsSync as existsSync7, statSync as statSync2 } from "fs";
|
|
1185
|
+
import { readFile as readFile6, utimes } from "fs/promises";
|
|
1289
1186
|
import { basename as basename3, join as join6, relative as relative3 } from "path";
|
|
1290
1187
|
import fg from "fast-glob";
|
|
1291
1188
|
|
|
1292
1189
|
// src/topics/yaml.ts
|
|
1293
|
-
import { existsSync as
|
|
1294
|
-
import { mkdir as
|
|
1190
|
+
import { existsSync as existsSync6 } from "fs";
|
|
1191
|
+
import { mkdir as mkdir3, readFile as readFile5, rename as rename2, writeFile as writeFile3 } from "fs/promises";
|
|
1295
1192
|
import { dirname as dirname4 } from "path";
|
|
1296
1193
|
import yaml2 from "js-yaml";
|
|
1297
|
-
async function loadTopicsFile(
|
|
1298
|
-
if (!
|
|
1194
|
+
async function loadTopicsFile(path6) {
|
|
1195
|
+
if (!existsSync6(path6)) {
|
|
1299
1196
|
return { topics: [] };
|
|
1300
1197
|
}
|
|
1301
1198
|
let raw;
|
|
1302
1199
|
try {
|
|
1303
|
-
raw = await
|
|
1304
|
-
} catch (err) {
|
|
1305
|
-
if (isNodeError2(err) && err.code === "ENOENT") {
|
|
1306
|
-
return { topics: [] };
|
|
1307
|
-
}
|
|
1308
|
-
throw err;
|
|
1309
|
-
}
|
|
1310
|
-
const trimmed = raw.trim();
|
|
1311
|
-
if (trimmed.length === 0) {
|
|
1312
|
-
return { topics: [] };
|
|
1200
|
+
raw = await readFile5(path6, "utf8");
|
|
1201
|
+
} catch (err) {
|
|
1202
|
+
if (isNodeError2(err) && err.code === "ENOENT") {
|
|
1203
|
+
return { topics: [] };
|
|
1204
|
+
}
|
|
1205
|
+
throw err;
|
|
1206
|
+
}
|
|
1207
|
+
const trimmed = raw.trim();
|
|
1208
|
+
if (trimmed.length === 0) {
|
|
1209
|
+
return { topics: [] };
|
|
1210
|
+
}
|
|
1211
|
+
let parsed;
|
|
1212
|
+
try {
|
|
1213
|
+
parsed = yaml2.load(raw);
|
|
1214
|
+
} catch (err) {
|
|
1215
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1216
|
+
throw new Error(`topics.yaml at ${path6} is not valid YAML: ${message}`);
|
|
1217
|
+
}
|
|
1218
|
+
if (parsed === null || parsed === void 0) {
|
|
1219
|
+
return { topics: [] };
|
|
1220
|
+
}
|
|
1221
|
+
if (typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
1222
|
+
throw new Error(`topics.yaml at ${path6} must be a mapping`);
|
|
1223
|
+
}
|
|
1224
|
+
const obj = parsed;
|
|
1225
|
+
const rawTopics = obj.topics;
|
|
1226
|
+
if (rawTopics === void 0 || rawTopics === null) {
|
|
1227
|
+
return { topics: [] };
|
|
1228
|
+
}
|
|
1229
|
+
if (!Array.isArray(rawTopics)) {
|
|
1230
|
+
throw new Error(`topics.yaml at ${path6} \u2014 "topics" must be a list`);
|
|
1231
|
+
}
|
|
1232
|
+
const topics = [];
|
|
1233
|
+
for (const item of rawTopics) {
|
|
1234
|
+
if (typeof item !== "object" || item === null || Array.isArray(item)) {
|
|
1235
|
+
continue;
|
|
1236
|
+
}
|
|
1237
|
+
const entry = item;
|
|
1238
|
+
const slugRaw = entry.slug;
|
|
1239
|
+
if (typeof slugRaw !== "string" || slugRaw.trim().length === 0) continue;
|
|
1240
|
+
const slug = toKebabCase(slugRaw);
|
|
1241
|
+
if (slug.length === 0) continue;
|
|
1242
|
+
const title = typeof entry.title === "string" && entry.title.trim().length > 0 ? entry.title.trim() : titleCase(slug);
|
|
1243
|
+
const description = typeof entry.description === "string" && entry.description.trim().length > 0 ? entry.description.trim() : null;
|
|
1244
|
+
const parents = [];
|
|
1245
|
+
if (Array.isArray(entry.parents)) {
|
|
1246
|
+
for (const p of entry.parents) {
|
|
1247
|
+
if (typeof p === "string" && p.trim().length > 0) {
|
|
1248
|
+
const ps = toKebabCase(p);
|
|
1249
|
+
if (ps.length > 0 && ps !== slug && !parents.includes(ps)) {
|
|
1250
|
+
parents.push(ps);
|
|
1251
|
+
}
|
|
1252
|
+
}
|
|
1253
|
+
}
|
|
1254
|
+
}
|
|
1255
|
+
topics.push({ slug, title, description, parents });
|
|
1256
|
+
}
|
|
1257
|
+
return { topics };
|
|
1258
|
+
}
|
|
1259
|
+
async function writeTopicsFile(path6, file) {
|
|
1260
|
+
const sorted = [...file.topics].sort((a, b) => a.slug.localeCompare(b.slug));
|
|
1261
|
+
const doc = {
|
|
1262
|
+
topics: sorted.map((t) => {
|
|
1263
|
+
return {
|
|
1264
|
+
slug: t.slug,
|
|
1265
|
+
title: t.title,
|
|
1266
|
+
description: t.description,
|
|
1267
|
+
parents: t.parents
|
|
1268
|
+
};
|
|
1269
|
+
})
|
|
1270
|
+
};
|
|
1271
|
+
const header = `# .almanac/topics.yaml \u2014 source of truth for topic metadata.
|
|
1272
|
+
# Managed by \`almanac topics\` commands. User-added comments
|
|
1273
|
+
# between entries will be stripped on the next write (js-yaml
|
|
1274
|
+
# doesn't round-trip comments). Edit at your own risk \u2014 or use the
|
|
1275
|
+
# CLI (\`almanac topics create|link|describe|rename|delete\`)
|
|
1276
|
+
# which preserves the structure correctly.
|
|
1277
|
+
`;
|
|
1278
|
+
const body = yaml2.dump(doc, {
|
|
1279
|
+
lineWidth: 100,
|
|
1280
|
+
noRefs: true,
|
|
1281
|
+
sortKeys: false
|
|
1282
|
+
});
|
|
1283
|
+
const content = `${header}${body}`;
|
|
1284
|
+
const tmpPath = `${path6}.tmp`;
|
|
1285
|
+
const parent = dirname4(path6);
|
|
1286
|
+
if (!existsSync6(parent)) {
|
|
1287
|
+
await mkdir3(parent, { recursive: true });
|
|
1288
|
+
}
|
|
1289
|
+
await writeFile3(tmpPath, content, "utf8");
|
|
1290
|
+
await rename2(tmpPath, path6);
|
|
1291
|
+
}
|
|
1292
|
+
function findTopic(file, slug) {
|
|
1293
|
+
for (const t of file.topics) {
|
|
1294
|
+
if (t.slug === slug) return t;
|
|
1295
|
+
}
|
|
1296
|
+
return null;
|
|
1297
|
+
}
|
|
1298
|
+
function ensureTopic(file, slug) {
|
|
1299
|
+
const existing = findTopic(file, slug);
|
|
1300
|
+
if (existing !== null) return existing;
|
|
1301
|
+
const entry = {
|
|
1302
|
+
slug,
|
|
1303
|
+
title: titleCase(slug),
|
|
1304
|
+
description: null,
|
|
1305
|
+
parents: []
|
|
1306
|
+
};
|
|
1307
|
+
file.topics.push(entry);
|
|
1308
|
+
return entry;
|
|
1309
|
+
}
|
|
1310
|
+
function titleCase(slug) {
|
|
1311
|
+
if (slug.length === 0) return slug;
|
|
1312
|
+
return slug.split("-").filter((s) => s.length > 0).map((s) => `${s[0]?.toUpperCase() ?? ""}${s.slice(1)}`).join(" ");
|
|
1313
|
+
}
|
|
1314
|
+
function isNodeError2(err) {
|
|
1315
|
+
return err instanceof Error && "code" in err;
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1318
|
+
// src/indexer/paths.ts
|
|
1319
|
+
function normalizePath(raw, isDir) {
|
|
1320
|
+
const normalized = normalizeShape(raw, isDir);
|
|
1321
|
+
return normalized.toLowerCase();
|
|
1322
|
+
}
|
|
1323
|
+
function normalizePathPreservingCase(raw, isDir) {
|
|
1324
|
+
return normalizeShape(raw, isDir);
|
|
1325
|
+
}
|
|
1326
|
+
function normalizeShape(raw, isDir) {
|
|
1327
|
+
let s = raw.trim();
|
|
1328
|
+
s = s.replace(/\\+/g, "/");
|
|
1329
|
+
while (s.startsWith("./")) s = s.slice(2);
|
|
1330
|
+
s = s.replace(/\/+/g, "/");
|
|
1331
|
+
s = s.replace(/\/+$/, "");
|
|
1332
|
+
if (isDir) {
|
|
1333
|
+
return `${s}/`;
|
|
1334
|
+
}
|
|
1335
|
+
return s;
|
|
1336
|
+
}
|
|
1337
|
+
function looksLikeDir(raw) {
|
|
1338
|
+
const s = raw.trim().replace(/\\+/g, "/");
|
|
1339
|
+
return s.endsWith("/");
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
// src/indexer/wikilinks.ts
|
|
1343
|
+
function classifyWikilink(raw) {
|
|
1344
|
+
const pipe = raw.indexOf("|");
|
|
1345
|
+
let body = pipe === -1 ? raw : raw.slice(0, pipe);
|
|
1346
|
+
body = body.trim();
|
|
1347
|
+
if (body.length === 0) return null;
|
|
1348
|
+
const firstColon = body.indexOf(":");
|
|
1349
|
+
const firstSlash = body.indexOf("/");
|
|
1350
|
+
if (firstColon !== -1 && (firstSlash === -1 || firstColon < firstSlash)) {
|
|
1351
|
+
const wiki = body.slice(0, firstColon).trim();
|
|
1352
|
+
const target2 = body.slice(firstColon + 1).trim();
|
|
1353
|
+
if (wiki.length === 0 || target2.length === 0) return null;
|
|
1354
|
+
return { kind: "xwiki", wiki, target: target2 };
|
|
1355
|
+
}
|
|
1356
|
+
if (firstSlash !== -1) {
|
|
1357
|
+
const isDir = looksLikeDir(body);
|
|
1358
|
+
const path6 = normalizePath(body, isDir);
|
|
1359
|
+
const originalPath = normalizePathPreservingCase(body, isDir);
|
|
1360
|
+
if (path6.length === 0) return null;
|
|
1361
|
+
return isDir ? { kind: "folder", path: path6, originalPath } : { kind: "file", path: path6, originalPath };
|
|
1362
|
+
}
|
|
1363
|
+
const target = toKebabCase(body);
|
|
1364
|
+
if (target.length === 0) return null;
|
|
1365
|
+
return { kind: "page", target };
|
|
1366
|
+
}
|
|
1367
|
+
function extractWikilinks(body) {
|
|
1368
|
+
const out = [];
|
|
1369
|
+
const re = /\[\[([^\]\n]+)\]\]/g;
|
|
1370
|
+
let m;
|
|
1371
|
+
while ((m = re.exec(body)) !== null) {
|
|
1372
|
+
const ref = classifyWikilink(m[1] ?? "");
|
|
1373
|
+
if (ref !== null) out.push(ref);
|
|
1374
|
+
}
|
|
1375
|
+
return out;
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1378
|
+
// src/indexer/index.ts
|
|
1379
|
+
var TOPICS_YAML_FILENAME = "topics.yaml";
|
|
1380
|
+
var PAGES_GLOB = "**/*.md";
|
|
1381
|
+
async function ensureFreshIndex(ctx) {
|
|
1382
|
+
const almanacDir = join6(ctx.repoRoot, ".almanac");
|
|
1383
|
+
const dbPath = join6(almanacDir, "index.db");
|
|
1384
|
+
const pagesDir = join6(almanacDir, "pages");
|
|
1385
|
+
if (!existsSync7(pagesDir)) {
|
|
1386
|
+
const db = openIndex(dbPath);
|
|
1387
|
+
db.close();
|
|
1388
|
+
return emptyResult();
|
|
1389
|
+
}
|
|
1390
|
+
if (!existsSync7(dbPath) || pagesNewerThan(pagesDir, dbPath) || topicsYamlNewerThan(almanacDir, dbPath)) {
|
|
1391
|
+
return runIndexer(ctx);
|
|
1392
|
+
}
|
|
1393
|
+
return emptyResult();
|
|
1394
|
+
}
|
|
1395
|
+
function emptyResult() {
|
|
1396
|
+
return {
|
|
1397
|
+
changed: 0,
|
|
1398
|
+
removed: 0,
|
|
1399
|
+
total: 0,
|
|
1400
|
+
pagesIndexed: 0,
|
|
1401
|
+
filesSeen: 0,
|
|
1402
|
+
filesSkipped: 0
|
|
1403
|
+
};
|
|
1404
|
+
}
|
|
1405
|
+
async function runIndexer(ctx) {
|
|
1406
|
+
const almanacDir = join6(ctx.repoRoot, ".almanac");
|
|
1407
|
+
const dbPath = join6(almanacDir, "index.db");
|
|
1408
|
+
const pagesDir = join6(almanacDir, "pages");
|
|
1409
|
+
const db = openIndex(dbPath);
|
|
1410
|
+
let result;
|
|
1411
|
+
try {
|
|
1412
|
+
result = await indexPagesInto(db, pagesDir);
|
|
1413
|
+
await applyTopicsYaml(db, join6(almanacDir, TOPICS_YAML_FILENAME));
|
|
1414
|
+
} finally {
|
|
1415
|
+
db.close();
|
|
1313
1416
|
}
|
|
1314
|
-
let parsed;
|
|
1315
1417
|
try {
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
throw new Error(`topics.yaml at ${path3} is not valid YAML: ${message}`);
|
|
1320
|
-
}
|
|
1321
|
-
if (parsed === null || parsed === void 0) {
|
|
1322
|
-
return { topics: [] };
|
|
1323
|
-
}
|
|
1324
|
-
if (typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
1325
|
-
throw new Error(`topics.yaml at ${path3} must be a mapping`);
|
|
1326
|
-
}
|
|
1327
|
-
const obj = parsed;
|
|
1328
|
-
const rawTopics = obj.topics;
|
|
1329
|
-
if (rawTopics === void 0 || rawTopics === null) {
|
|
1330
|
-
return { topics: [] };
|
|
1331
|
-
}
|
|
1332
|
-
if (!Array.isArray(rawTopics)) {
|
|
1333
|
-
throw new Error(`topics.yaml at ${path3} \u2014 "topics" must be a list`);
|
|
1418
|
+
const now = /* @__PURE__ */ new Date();
|
|
1419
|
+
await utimes(dbPath, now, now);
|
|
1420
|
+
} catch {
|
|
1334
1421
|
}
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1422
|
+
return result;
|
|
1423
|
+
}
|
|
1424
|
+
async function indexPagesInto(db, pagesDir) {
|
|
1425
|
+
const files = await fg(PAGES_GLOB, {
|
|
1426
|
+
cwd: pagesDir,
|
|
1427
|
+
absolute: false,
|
|
1428
|
+
onlyFiles: true,
|
|
1429
|
+
caseSensitiveMatch: true
|
|
1430
|
+
});
|
|
1431
|
+
const existingRows = db.prepare("SELECT slug, content_hash, file_path FROM pages").all();
|
|
1432
|
+
const existingBySlug = /* @__PURE__ */ new Map();
|
|
1433
|
+
for (const row of existingRows) existingBySlug.set(row.slug, row);
|
|
1434
|
+
const planned = [];
|
|
1435
|
+
const seenSlugs = /* @__PURE__ */ new Set();
|
|
1436
|
+
let filesSkipped = 0;
|
|
1437
|
+
for (const rel of files) {
|
|
1438
|
+
const fullPath = join6(pagesDir, rel);
|
|
1439
|
+
const base = basename3(rel, ".md");
|
|
1440
|
+
const slug = toKebabCase(base);
|
|
1441
|
+
if (slug.length === 0) {
|
|
1442
|
+
process.stderr.write(
|
|
1443
|
+
`almanac: skipping "${rel}" \u2014 filename has no slug-able characters
|
|
1444
|
+
`
|
|
1445
|
+
);
|
|
1446
|
+
filesSkipped++;
|
|
1338
1447
|
continue;
|
|
1339
1448
|
}
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1449
|
+
if (slug !== base) {
|
|
1450
|
+
process.stderr.write(
|
|
1451
|
+
`almanac: warning \u2014 "${rel}" is not canonical; indexed as slug "${slug}"
|
|
1452
|
+
`
|
|
1453
|
+
);
|
|
1454
|
+
}
|
|
1455
|
+
if (seenSlugs.has(slug)) {
|
|
1456
|
+
process.stderr.write(
|
|
1457
|
+
`almanac: warning \u2014 slug "${slug}" collides with an earlier file; skipping "${rel}"
|
|
1458
|
+
`
|
|
1459
|
+
);
|
|
1460
|
+
filesSkipped++;
|
|
1461
|
+
continue;
|
|
1462
|
+
}
|
|
1463
|
+
let st;
|
|
1464
|
+
let raw;
|
|
1465
|
+
try {
|
|
1466
|
+
st = statSync2(fullPath);
|
|
1467
|
+
raw = await readFile6(fullPath, "utf8");
|
|
1468
|
+
} catch (err) {
|
|
1469
|
+
if (err instanceof Error && "code" in err && (err.code === "ENOENT" || err.code === "EACCES")) {
|
|
1470
|
+
process.stderr.write(
|
|
1471
|
+
`almanac: skipping "${rel}" \u2014 ${err.message}
|
|
1472
|
+
`
|
|
1473
|
+
);
|
|
1474
|
+
filesSkipped++;
|
|
1475
|
+
continue;
|
|
1356
1476
|
}
|
|
1477
|
+
throw err;
|
|
1357
1478
|
}
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
lineWidth: 100,
|
|
1383
|
-
noRefs: true,
|
|
1384
|
-
sortKeys: false
|
|
1385
|
-
});
|
|
1386
|
-
const content = `${header}${body}`;
|
|
1387
|
-
const tmpPath = `${path3}.tmp`;
|
|
1388
|
-
const parent = dirname4(path3);
|
|
1389
|
-
if (!existsSync7(parent)) {
|
|
1390
|
-
await mkdir4(parent, { recursive: true });
|
|
1479
|
+
seenSlugs.add(slug);
|
|
1480
|
+
const updatedAt = Math.floor(st.mtimeMs / 1e3);
|
|
1481
|
+
const contentHash = hashContent(raw);
|
|
1482
|
+
const existing = existingBySlug.get(slug);
|
|
1483
|
+
if (existing !== void 0 && existing.content_hash === contentHash && existing.file_path === fullPath) {
|
|
1484
|
+
continue;
|
|
1485
|
+
}
|
|
1486
|
+
const fm = parseFrontmatter(raw);
|
|
1487
|
+
const title = fm.title ?? firstH1(fm.body) ?? base;
|
|
1488
|
+
const links = extractWikilinks(fm.body);
|
|
1489
|
+
planned.push({
|
|
1490
|
+
slug,
|
|
1491
|
+
title,
|
|
1492
|
+
filePath: rel,
|
|
1493
|
+
fullPath,
|
|
1494
|
+
contentHash,
|
|
1495
|
+
updatedAt,
|
|
1496
|
+
archivedAt: fm.archived_at,
|
|
1497
|
+
supersededBy: fm.superseded_by,
|
|
1498
|
+
topics: fm.topics,
|
|
1499
|
+
frontmatterFiles: fm.files,
|
|
1500
|
+
wikilinks: links,
|
|
1501
|
+
content: fm.body
|
|
1502
|
+
});
|
|
1391
1503
|
}
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
function findTopic(file, slug) {
|
|
1396
|
-
for (const t of file.topics) {
|
|
1397
|
-
if (t.slug === slug) return t;
|
|
1504
|
+
const toDelete = [];
|
|
1505
|
+
for (const slug of existingBySlug.keys()) {
|
|
1506
|
+
if (!seenSlugs.has(slug)) toDelete.push(slug);
|
|
1398
1507
|
}
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1508
|
+
const deleteByPage = db.prepare("DELETE FROM pages WHERE slug = ?");
|
|
1509
|
+
const deleteFtsByPage = db.prepare(
|
|
1510
|
+
"DELETE FROM fts_pages WHERE slug = ?"
|
|
1511
|
+
);
|
|
1512
|
+
const replacePage = db.prepare(
|
|
1513
|
+
`INSERT INTO pages (slug, title, file_path, content_hash, updated_at, archived_at, superseded_by)
|
|
1514
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
1515
|
+
ON CONFLICT(slug) DO UPDATE SET
|
|
1516
|
+
title = excluded.title,
|
|
1517
|
+
file_path = excluded.file_path,
|
|
1518
|
+
content_hash = excluded.content_hash,
|
|
1519
|
+
updated_at = excluded.updated_at,
|
|
1520
|
+
archived_at = excluded.archived_at,
|
|
1521
|
+
superseded_by = excluded.superseded_by`
|
|
1522
|
+
);
|
|
1523
|
+
const deletePageTopics = db.prepare(
|
|
1524
|
+
"DELETE FROM page_topics WHERE page_slug = ?"
|
|
1525
|
+
);
|
|
1526
|
+
const insertPageTopic = db.prepare(
|
|
1527
|
+
"INSERT OR IGNORE INTO page_topics (page_slug, topic_slug) VALUES (?, ?)"
|
|
1528
|
+
);
|
|
1529
|
+
const insertTopic = db.prepare(
|
|
1530
|
+
"INSERT OR IGNORE INTO topics (slug, title) VALUES (?, ?)"
|
|
1531
|
+
);
|
|
1532
|
+
const deleteFileRefs = db.prepare(
|
|
1533
|
+
"DELETE FROM file_refs WHERE page_slug = ?"
|
|
1534
|
+
);
|
|
1535
|
+
const insertFileRef = db.prepare(
|
|
1536
|
+
"INSERT OR IGNORE INTO file_refs (page_slug, path, original_path, is_dir) VALUES (?, ?, ?, ?)"
|
|
1537
|
+
);
|
|
1538
|
+
const deleteWikilinks = db.prepare(
|
|
1539
|
+
"DELETE FROM wikilinks WHERE source_slug = ?"
|
|
1540
|
+
);
|
|
1541
|
+
const insertWikilink = db.prepare(
|
|
1542
|
+
"INSERT OR IGNORE INTO wikilinks (source_slug, target_slug) VALUES (?, ?)"
|
|
1543
|
+
);
|
|
1544
|
+
const deleteXwiki = db.prepare(
|
|
1545
|
+
"DELETE FROM cross_wiki_links WHERE source_slug = ?"
|
|
1546
|
+
);
|
|
1547
|
+
const insertXwiki = db.prepare(
|
|
1548
|
+
"INSERT OR IGNORE INTO cross_wiki_links (source_slug, target_wiki, target_slug) VALUES (?, ?, ?)"
|
|
1549
|
+
);
|
|
1550
|
+
const insertFts = db.prepare(
|
|
1551
|
+
"INSERT INTO fts_pages (slug, title, content) VALUES (?, ?, ?)"
|
|
1552
|
+
);
|
|
1553
|
+
const apply = db.transaction(() => {
|
|
1554
|
+
for (const slug of toDelete) {
|
|
1555
|
+
deleteFtsByPage.run(slug);
|
|
1556
|
+
deleteByPage.run(slug);
|
|
1557
|
+
}
|
|
1558
|
+
for (const p of planned) {
|
|
1559
|
+
deletePageTopics.run(p.slug);
|
|
1560
|
+
deleteFileRefs.run(p.slug);
|
|
1561
|
+
deleteWikilinks.run(p.slug);
|
|
1562
|
+
deleteXwiki.run(p.slug);
|
|
1563
|
+
deleteFtsByPage.run(p.slug);
|
|
1564
|
+
replacePage.run(
|
|
1565
|
+
p.slug,
|
|
1566
|
+
p.title,
|
|
1567
|
+
p.fullPath,
|
|
1568
|
+
p.contentHash,
|
|
1569
|
+
p.updatedAt,
|
|
1570
|
+
p.archivedAt,
|
|
1571
|
+
p.supersededBy
|
|
1572
|
+
);
|
|
1573
|
+
for (const topic of p.topics) {
|
|
1574
|
+
const topicSlug = toKebabCase(topic);
|
|
1575
|
+
if (topicSlug.length === 0) continue;
|
|
1576
|
+
insertTopic.run(topicSlug, titleCase(topicSlug));
|
|
1577
|
+
insertPageTopic.run(p.slug, topicSlug);
|
|
1578
|
+
}
|
|
1579
|
+
for (const raw of p.frontmatterFiles) {
|
|
1580
|
+
const isDir = looksLikeDir(raw);
|
|
1581
|
+
const path6 = normalizePath(raw, isDir);
|
|
1582
|
+
const originalPath = normalizePathPreservingCase(raw, isDir);
|
|
1583
|
+
if (path6.length === 0) continue;
|
|
1584
|
+
insertFileRef.run(p.slug, path6, originalPath, isDir ? 1 : 0);
|
|
1585
|
+
}
|
|
1586
|
+
for (const ref of p.wikilinks) {
|
|
1587
|
+
switch (ref.kind) {
|
|
1588
|
+
case "page":
|
|
1589
|
+
insertWikilink.run(p.slug, ref.target);
|
|
1590
|
+
break;
|
|
1591
|
+
case "file":
|
|
1592
|
+
insertFileRef.run(p.slug, ref.path, ref.originalPath, 0);
|
|
1593
|
+
break;
|
|
1594
|
+
case "folder":
|
|
1595
|
+
insertFileRef.run(p.slug, ref.path, ref.originalPath, 1);
|
|
1596
|
+
break;
|
|
1597
|
+
case "xwiki":
|
|
1598
|
+
insertXwiki.run(p.slug, ref.wiki, ref.target);
|
|
1599
|
+
break;
|
|
1600
|
+
}
|
|
1601
|
+
}
|
|
1602
|
+
insertFts.run(p.slug, p.title, p.content);
|
|
1603
|
+
}
|
|
1604
|
+
});
|
|
1605
|
+
apply();
|
|
1606
|
+
void relative3;
|
|
1607
|
+
const pagesIndexed = seenSlugs.size;
|
|
1608
|
+
return {
|
|
1609
|
+
changed: planned.length,
|
|
1610
|
+
removed: toDelete.length,
|
|
1611
|
+
total: pagesIndexed,
|
|
1612
|
+
pagesIndexed,
|
|
1613
|
+
filesSeen: files.length,
|
|
1614
|
+
filesSkipped
|
|
1409
1615
|
};
|
|
1410
|
-
file.topics.push(entry);
|
|
1411
|
-
return entry;
|
|
1412
|
-
}
|
|
1413
|
-
function titleCase(slug) {
|
|
1414
|
-
if (slug.length === 0) return slug;
|
|
1415
|
-
return slug.split("-").filter((s) => s.length > 0).map((s) => `${s[0]?.toUpperCase() ?? ""}${s.slice(1)}`).join(" ");
|
|
1416
|
-
}
|
|
1417
|
-
function isNodeError2(err) {
|
|
1418
|
-
return err instanceof Error && "code" in err;
|
|
1419
1616
|
}
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1617
|
+
function pagesNewerThan(pagesDir, dbPath) {
|
|
1618
|
+
let dbMtime;
|
|
1619
|
+
try {
|
|
1620
|
+
dbMtime = statSync2(dbPath).mtimeMs;
|
|
1621
|
+
} catch {
|
|
1622
|
+
return true;
|
|
1623
|
+
}
|
|
1624
|
+
const entries = fg.sync(PAGES_GLOB, {
|
|
1625
|
+
cwd: pagesDir,
|
|
1626
|
+
absolute: true,
|
|
1627
|
+
onlyFiles: true,
|
|
1628
|
+
stats: true
|
|
1629
|
+
});
|
|
1630
|
+
for (const entry of entries) {
|
|
1631
|
+
const mtime = entry.stats?.mtimeMs;
|
|
1632
|
+
if (mtime !== void 0 && mtime > dbMtime) return true;
|
|
1633
|
+
}
|
|
1634
|
+
return false;
|
|
1425
1635
|
}
|
|
1426
|
-
function
|
|
1427
|
-
return
|
|
1636
|
+
function hashContent(raw) {
|
|
1637
|
+
return createHash2("sha256").update(raw).digest("hex");
|
|
1428
1638
|
}
|
|
1429
|
-
function
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
return
|
|
1639
|
+
function topicsYamlNewerThan(almanacDir, dbPath) {
|
|
1640
|
+
const path6 = join6(almanacDir, "topics.yaml");
|
|
1641
|
+
if (!existsSync7(path6)) return false;
|
|
1642
|
+
let dbMtime;
|
|
1643
|
+
try {
|
|
1644
|
+
dbMtime = statSync2(dbPath).mtimeMs;
|
|
1645
|
+
} catch {
|
|
1646
|
+
return true;
|
|
1647
|
+
}
|
|
1648
|
+
try {
|
|
1649
|
+
const st = statSync2(path6);
|
|
1650
|
+
return st.mtimeMs > dbMtime;
|
|
1651
|
+
} catch {
|
|
1652
|
+
return false;
|
|
1437
1653
|
}
|
|
1438
|
-
return s;
|
|
1439
|
-
}
|
|
1440
|
-
function looksLikeDir(raw) {
|
|
1441
|
-
const s = raw.trim().replace(/\\+/g, "/");
|
|
1442
|
-
return s.endsWith("/");
|
|
1443
1654
|
}
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
archived_at INTEGER,
|
|
1455
|
-
superseded_by TEXT
|
|
1456
|
-
);
|
|
1457
|
-
|
|
1458
|
-
CREATE TABLE IF NOT EXISTS topics (
|
|
1459
|
-
slug TEXT PRIMARY KEY,
|
|
1460
|
-
title TEXT,
|
|
1461
|
-
description TEXT
|
|
1462
|
-
);
|
|
1463
|
-
|
|
1464
|
-
CREATE TABLE IF NOT EXISTS page_topics (
|
|
1465
|
-
page_slug TEXT NOT NULL REFERENCES pages(slug) ON DELETE CASCADE,
|
|
1466
|
-
topic_slug TEXT NOT NULL,
|
|
1467
|
-
PRIMARY KEY (page_slug, topic_slug)
|
|
1468
|
-
);
|
|
1469
|
-
|
|
1470
|
-
CREATE TABLE IF NOT EXISTS topic_parents (
|
|
1471
|
-
child_slug TEXT NOT NULL,
|
|
1472
|
-
parent_slug TEXT NOT NULL,
|
|
1473
|
-
PRIMARY KEY (child_slug, parent_slug),
|
|
1474
|
-
CHECK (child_slug != parent_slug)
|
|
1475
|
-
);
|
|
1476
|
-
|
|
1477
|
-
CREATE TABLE IF NOT EXISTS file_refs (
|
|
1478
|
-
page_slug TEXT NOT NULL REFERENCES pages(slug) ON DELETE CASCADE,
|
|
1479
|
-
path TEXT NOT NULL,
|
|
1480
|
-
original_path TEXT NOT NULL,
|
|
1481
|
-
is_dir INTEGER NOT NULL,
|
|
1482
|
-
PRIMARY KEY (page_slug, path)
|
|
1483
|
-
);
|
|
1484
|
-
CREATE INDEX IF NOT EXISTS idx_file_refs_path ON file_refs(path);
|
|
1485
|
-
|
|
1486
|
-
CREATE TABLE IF NOT EXISTS wikilinks (
|
|
1487
|
-
source_slug TEXT NOT NULL REFERENCES pages(slug) ON DELETE CASCADE,
|
|
1488
|
-
target_slug TEXT NOT NULL,
|
|
1489
|
-
PRIMARY KEY (source_slug, target_slug)
|
|
1490
|
-
);
|
|
1491
|
-
|
|
1492
|
-
CREATE TABLE IF NOT EXISTS cross_wiki_links (
|
|
1493
|
-
source_slug TEXT NOT NULL REFERENCES pages(slug) ON DELETE CASCADE,
|
|
1494
|
-
target_wiki TEXT NOT NULL,
|
|
1495
|
-
target_slug TEXT NOT NULL,
|
|
1496
|
-
PRIMARY KEY (source_slug, target_wiki, target_slug)
|
|
1497
|
-
);
|
|
1498
|
-
|
|
1499
|
-
-- NOTE: virtual FTS5 table \u2014 ON DELETE CASCADE from pages does NOT apply.
|
|
1500
|
-
-- The indexer must explicitly DELETE FROM fts_pages whenever it removes
|
|
1501
|
-
-- or replaces a page row, or we leak orphaned FTS rows.
|
|
1502
|
-
CREATE VIRTUAL TABLE IF NOT EXISTS fts_pages USING fts5(slug, title, content);
|
|
1503
|
-
`;
|
|
1504
|
-
var SCHEMA_VERSION = 2;
|
|
1505
|
-
function openIndex(dbPath) {
|
|
1506
|
-
const db = new Database(dbPath);
|
|
1507
|
-
const mode = db.pragma("journal_mode", { simple: true });
|
|
1508
|
-
if (typeof mode !== "string" || mode.toLowerCase() !== "wal") {
|
|
1509
|
-
db.pragma("journal_mode = WAL");
|
|
1655
|
+
async function applyTopicsYaml(db, topicsYamlPath2) {
|
|
1656
|
+
if (!existsSync7(topicsYamlPath2)) return;
|
|
1657
|
+
let file;
|
|
1658
|
+
try {
|
|
1659
|
+
file = await loadTopicsFile(topicsYamlPath2);
|
|
1660
|
+
} catch (err) {
|
|
1661
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1662
|
+
process.stderr.write(`almanac: ${message}
|
|
1663
|
+
`);
|
|
1664
|
+
return;
|
|
1510
1665
|
}
|
|
1511
|
-
db.
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1666
|
+
const upsertTopic = db.prepare(
|
|
1667
|
+
`INSERT INTO topics (slug, title, description) VALUES (?, ?, ?)
|
|
1668
|
+
ON CONFLICT(slug) DO UPDATE SET
|
|
1669
|
+
title = excluded.title,
|
|
1670
|
+
description = excluded.description`
|
|
1671
|
+
);
|
|
1672
|
+
const clearParents = db.prepare(
|
|
1673
|
+
"DELETE FROM topic_parents WHERE child_slug = ?"
|
|
1674
|
+
);
|
|
1675
|
+
const insertParent = db.prepare(
|
|
1676
|
+
"INSERT OR IGNORE INTO topic_parents (child_slug, parent_slug) VALUES (?, ?)"
|
|
1677
|
+
);
|
|
1678
|
+
const declared = /* @__PURE__ */ new Set();
|
|
1679
|
+
for (const t of file.topics) declared.add(t.slug);
|
|
1680
|
+
const adHoc = db.prepare(
|
|
1681
|
+
"SELECT DISTINCT topic_slug FROM page_topics"
|
|
1682
|
+
).all();
|
|
1683
|
+
for (const r of adHoc) declared.add(r.topic_slug);
|
|
1684
|
+
const apply = db.transaction(() => {
|
|
1685
|
+
for (const t of file.topics) {
|
|
1686
|
+
upsertTopic.run(t.slug, t.title, t.description);
|
|
1687
|
+
clearParents.run(t.slug);
|
|
1688
|
+
for (const parent of t.parents) {
|
|
1689
|
+
if (parent === t.slug) continue;
|
|
1690
|
+
insertParent.run(t.slug, parent);
|
|
1691
|
+
}
|
|
1519
1692
|
}
|
|
1520
|
-
db.
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1693
|
+
const existing = db.prepare("SELECT slug FROM topics").all();
|
|
1694
|
+
const deleteTopic = db.prepare("DELETE FROM topics WHERE slug = ?");
|
|
1695
|
+
const deleteEdgesByChild = db.prepare(
|
|
1696
|
+
"DELETE FROM topic_parents WHERE child_slug = ?"
|
|
1697
|
+
);
|
|
1698
|
+
const deleteEdgesByParent = db.prepare(
|
|
1699
|
+
"DELETE FROM topic_parents WHERE parent_slug = ?"
|
|
1700
|
+
);
|
|
1701
|
+
for (const r of existing) {
|
|
1702
|
+
if (declared.has(r.slug)) continue;
|
|
1703
|
+
deleteEdgesByChild.run(r.slug);
|
|
1704
|
+
deleteEdgesByParent.run(r.slug);
|
|
1705
|
+
deleteTopic.run(r.slug);
|
|
1706
|
+
}
|
|
1707
|
+
});
|
|
1708
|
+
apply();
|
|
1524
1709
|
}
|
|
1525
1710
|
|
|
1526
|
-
// src/indexer/
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
const isDir = looksLikeDir(body);
|
|
1542
|
-
const path3 = normalizePath(body, isDir);
|
|
1543
|
-
const originalPath = normalizePathPreservingCase(body, isDir);
|
|
1544
|
-
if (path3.length === 0) return null;
|
|
1545
|
-
return isDir ? { kind: "folder", path: path3, originalPath } : { kind: "file", path: path3, originalPath };
|
|
1711
|
+
// src/indexer/resolveWiki.ts
|
|
1712
|
+
import { existsSync as existsSync8 } from "fs";
|
|
1713
|
+
import { join as join7 } from "path";
|
|
1714
|
+
async function resolveWikiRoot(params) {
|
|
1715
|
+
if (params.wiki !== void 0) {
|
|
1716
|
+
const entry = await findEntry({ name: params.wiki });
|
|
1717
|
+
if (entry === null) {
|
|
1718
|
+
throw new Error(`no registered wiki named "${params.wiki}"`);
|
|
1719
|
+
}
|
|
1720
|
+
if (!existsSync8(join7(entry.path, ".almanac"))) {
|
|
1721
|
+
throw new Error(
|
|
1722
|
+
`wiki "${params.wiki}" path is unreachable (${entry.path})`
|
|
1723
|
+
);
|
|
1724
|
+
}
|
|
1725
|
+
return entry.path;
|
|
1546
1726
|
}
|
|
1547
|
-
const
|
|
1548
|
-
if (
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
const out = [];
|
|
1553
|
-
const re = /\[\[([^\]\n]+)\]\]/g;
|
|
1554
|
-
let m;
|
|
1555
|
-
while ((m = re.exec(body)) !== null) {
|
|
1556
|
-
const ref = classifyWikilink(m[1] ?? "");
|
|
1557
|
-
if (ref !== null) out.push(ref);
|
|
1727
|
+
const nearest = findNearestAlmanacDir(params.cwd);
|
|
1728
|
+
if (nearest === null) {
|
|
1729
|
+
throw new Error(
|
|
1730
|
+
"no .almanac/ found in this directory or any parent; run `almanac bootstrap` first"
|
|
1731
|
+
);
|
|
1558
1732
|
}
|
|
1559
|
-
return
|
|
1733
|
+
return nearest;
|
|
1560
1734
|
}
|
|
1561
1735
|
|
|
1562
|
-
// src/
|
|
1563
|
-
var
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
const
|
|
1567
|
-
|
|
1568
|
-
const pagesDir = join6(almanacDir, "pages");
|
|
1569
|
-
if (!existsSync8(pagesDir)) {
|
|
1570
|
-
const db = openIndex(dbPath);
|
|
1571
|
-
db.close();
|
|
1572
|
-
return emptyResult();
|
|
1736
|
+
// src/topics/dag.ts
|
|
1737
|
+
var DAG_DEPTH_CAP = 32;
|
|
1738
|
+
function ancestorsInFile(file, slug) {
|
|
1739
|
+
const parentsOf = /* @__PURE__ */ new Map();
|
|
1740
|
+
for (const t of file.topics) {
|
|
1741
|
+
parentsOf.set(t.slug, t.parents);
|
|
1573
1742
|
}
|
|
1574
|
-
|
|
1575
|
-
|
|
1743
|
+
const ancestors = /* @__PURE__ */ new Set();
|
|
1744
|
+
let frontier = parentsOf.get(slug) ?? [];
|
|
1745
|
+
let depth = 0;
|
|
1746
|
+
while (frontier.length > 0 && depth < DAG_DEPTH_CAP) {
|
|
1747
|
+
const next = [];
|
|
1748
|
+
for (const node of frontier) {
|
|
1749
|
+
if (ancestors.has(node)) continue;
|
|
1750
|
+
ancestors.add(node);
|
|
1751
|
+
const ps = parentsOf.get(node);
|
|
1752
|
+
if (ps !== void 0) next.push(...ps);
|
|
1753
|
+
}
|
|
1754
|
+
frontier = next;
|
|
1755
|
+
depth += 1;
|
|
1576
1756
|
}
|
|
1577
|
-
return
|
|
1757
|
+
return ancestors;
|
|
1578
1758
|
}
|
|
1579
|
-
function
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1759
|
+
function descendantsInDb(db, slug) {
|
|
1760
|
+
const rows = db.prepare(
|
|
1761
|
+
`WITH RECURSIVE desc(slug, depth) AS (
|
|
1762
|
+
SELECT child_slug, 1 FROM topic_parents WHERE parent_slug = ?
|
|
1763
|
+
UNION
|
|
1764
|
+
SELECT tp.child_slug, d.depth + 1
|
|
1765
|
+
FROM topic_parents tp
|
|
1766
|
+
JOIN desc d ON tp.parent_slug = d.slug
|
|
1767
|
+
WHERE d.depth < ?
|
|
1768
|
+
)
|
|
1769
|
+
SELECT DISTINCT slug FROM desc ORDER BY slug`
|
|
1770
|
+
).all(slug, DAG_DEPTH_CAP).map((r) => r.slug);
|
|
1771
|
+
return rows;
|
|
1588
1772
|
}
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1773
|
+
function subtreeInDb(db, slug) {
|
|
1774
|
+
return [slug, ...descendantsInDb(db, slug)];
|
|
1775
|
+
}
|
|
1776
|
+
|
|
1777
|
+
// src/commands/health.ts
|
|
1778
|
+
var DEFAULT_STALE_SECONDS = 90 * 24 * 60 * 60;
|
|
1779
|
+
async function runHealth(options) {
|
|
1780
|
+
const repoRoot = await resolveWikiRoot({ cwd: options.cwd, wiki: options.wiki });
|
|
1781
|
+
await ensureFreshIndex({ repoRoot });
|
|
1782
|
+
const almanacDir = join8(repoRoot, ".almanac");
|
|
1783
|
+
const pagesDir = join8(almanacDir, "pages");
|
|
1784
|
+
const db = openIndex(join8(almanacDir, "index.db"));
|
|
1595
1785
|
try {
|
|
1596
|
-
|
|
1597
|
-
|
|
1786
|
+
const staleSeconds = options.stale !== void 0 ? parseDuration(options.stale) : DEFAULT_STALE_SECONDS;
|
|
1787
|
+
const scope = resolveScope(db, options);
|
|
1788
|
+
const report = {
|
|
1789
|
+
orphans: findOrphans(db, scope),
|
|
1790
|
+
stale: findStale(db, scope, staleSeconds),
|
|
1791
|
+
dead_refs: await findDeadRefs(db, scope, repoRoot),
|
|
1792
|
+
broken_links: findBrokenLinks(db, scope),
|
|
1793
|
+
broken_xwiki: await findBrokenXwiki(db, scope),
|
|
1794
|
+
empty_topics: findEmptyTopics(db, scope),
|
|
1795
|
+
empty_pages: await findEmptyPages(db, scope, pagesDir),
|
|
1796
|
+
slug_collisions: await findSlugCollisions(pagesDir)
|
|
1797
|
+
};
|
|
1798
|
+
if (options.json === true) {
|
|
1799
|
+
return {
|
|
1800
|
+
stdout: `${JSON.stringify(report, null, 2)}
|
|
1801
|
+
`,
|
|
1802
|
+
stderr: "",
|
|
1803
|
+
exitCode: 0
|
|
1804
|
+
};
|
|
1805
|
+
}
|
|
1806
|
+
return {
|
|
1807
|
+
stdout: formatReport(report),
|
|
1808
|
+
stderr: "",
|
|
1809
|
+
exitCode: 0
|
|
1810
|
+
};
|
|
1598
1811
|
} finally {
|
|
1599
1812
|
db.close();
|
|
1600
1813
|
}
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1814
|
+
}
|
|
1815
|
+
function resolveScope(db, options) {
|
|
1816
|
+
let pages = null;
|
|
1817
|
+
let topics = null;
|
|
1818
|
+
if (options.topic !== void 0) {
|
|
1819
|
+
const rootSlug = toKebabCase(options.topic);
|
|
1820
|
+
if (rootSlug.length > 0) {
|
|
1821
|
+
const subtree = subtreeInDb(db, rootSlug);
|
|
1822
|
+
topics = new Set(subtree);
|
|
1823
|
+
const placeholders = subtree.map(() => "?").join(", ");
|
|
1824
|
+
const rows = db.prepare(
|
|
1825
|
+
`SELECT DISTINCT page_slug FROM page_topics
|
|
1826
|
+
WHERE topic_slug IN (${placeholders})`
|
|
1827
|
+
).all(...subtree);
|
|
1828
|
+
pages = new Set(rows.map((r) => r.page_slug));
|
|
1829
|
+
}
|
|
1605
1830
|
}
|
|
1606
|
-
|
|
1831
|
+
if (options.stdin === true && options.stdinInput !== void 0) {
|
|
1832
|
+
const stdinPages = /* @__PURE__ */ new Set();
|
|
1833
|
+
for (const line of options.stdinInput.split(/\r?\n/)) {
|
|
1834
|
+
const s = line.trim();
|
|
1835
|
+
if (s.length > 0) stdinPages.add(s);
|
|
1836
|
+
}
|
|
1837
|
+
if (pages === null) pages = stdinPages;
|
|
1838
|
+
else {
|
|
1839
|
+
const out = /* @__PURE__ */ new Set();
|
|
1840
|
+
for (const s of stdinPages) if (pages.has(s)) out.add(s);
|
|
1841
|
+
pages = out;
|
|
1842
|
+
}
|
|
1843
|
+
}
|
|
1844
|
+
return { pages, topics };
|
|
1607
1845
|
}
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1846
|
+
function inPageScope(scope, slug) {
|
|
1847
|
+
if (scope.pages === null) return true;
|
|
1848
|
+
return scope.pages.has(slug);
|
|
1849
|
+
}
|
|
1850
|
+
function findOrphans(db, scope) {
|
|
1851
|
+
const rows = db.prepare(
|
|
1852
|
+
`SELECT p.slug FROM pages p
|
|
1853
|
+
WHERE p.archived_at IS NULL
|
|
1854
|
+
AND NOT EXISTS (
|
|
1855
|
+
SELECT 1 FROM page_topics pt WHERE pt.page_slug = p.slug
|
|
1856
|
+
)
|
|
1857
|
+
ORDER BY p.slug`
|
|
1858
|
+
).all();
|
|
1859
|
+
return rows.filter((r) => inPageScope(scope, r.slug));
|
|
1860
|
+
}
|
|
1861
|
+
function findStale(db, scope, staleSeconds) {
|
|
1862
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
1863
|
+
const threshold = now - staleSeconds;
|
|
1864
|
+
const rows = db.prepare(
|
|
1865
|
+
`SELECT slug, updated_at FROM pages
|
|
1866
|
+
WHERE archived_at IS NULL AND updated_at < ?
|
|
1867
|
+
ORDER BY updated_at ASC`
|
|
1868
|
+
).all(threshold);
|
|
1869
|
+
return rows.filter((r) => inPageScope(scope, r.slug)).map((r) => ({
|
|
1870
|
+
slug: r.slug,
|
|
1871
|
+
days_since_update: Math.floor((now - r.updated_at) / (60 * 60 * 24))
|
|
1872
|
+
}));
|
|
1873
|
+
}
|
|
1874
|
+
async function findDeadRefs(db, scope, repoRoot) {
|
|
1875
|
+
const rows = db.prepare(
|
|
1876
|
+
`SELECT p.slug, r.path, r.original_path, r.is_dir
|
|
1877
|
+
FROM file_refs r
|
|
1878
|
+
JOIN pages p ON p.slug = r.page_slug
|
|
1879
|
+
WHERE p.archived_at IS NULL
|
|
1880
|
+
ORDER BY p.slug, r.path`
|
|
1881
|
+
).all();
|
|
1882
|
+
const out = [];
|
|
1883
|
+
for (const r of rows) {
|
|
1884
|
+
if (!inPageScope(scope, r.slug)) continue;
|
|
1885
|
+
const abs = join8(repoRoot, r.original_path);
|
|
1886
|
+
if (!existsSync9(abs)) {
|
|
1887
|
+
out.push({ slug: r.slug, path: r.original_path });
|
|
1632
1888
|
}
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1889
|
+
}
|
|
1890
|
+
return out;
|
|
1891
|
+
}
|
|
1892
|
+
function findBrokenLinks(db, scope) {
|
|
1893
|
+
const rows = db.prepare(
|
|
1894
|
+
`SELECT w.source_slug, w.target_slug
|
|
1895
|
+
FROM wikilinks w
|
|
1896
|
+
JOIN pages src ON src.slug = w.source_slug
|
|
1897
|
+
LEFT JOIN pages tgt ON tgt.slug = w.target_slug
|
|
1898
|
+
WHERE tgt.slug IS NULL AND src.archived_at IS NULL
|
|
1899
|
+
ORDER BY w.source_slug, w.target_slug`
|
|
1900
|
+
).all();
|
|
1901
|
+
return rows.filter((r) => inPageScope(scope, r.source_slug));
|
|
1902
|
+
}
|
|
1903
|
+
async function findBrokenXwiki(db, scope) {
|
|
1904
|
+
const rows = db.prepare(
|
|
1905
|
+
// Same archived-source filter as `findBrokenLinks`. Retired pages
|
|
1906
|
+
// shouldn't spam the report with links to wikis that may have
|
|
1907
|
+
// been intentionally retired too.
|
|
1908
|
+
`SELECT x.source_slug, x.target_wiki, x.target_slug
|
|
1909
|
+
FROM cross_wiki_links x
|
|
1910
|
+
JOIN pages src ON src.slug = x.source_slug
|
|
1911
|
+
WHERE src.archived_at IS NULL
|
|
1912
|
+
ORDER BY x.source_slug, x.target_wiki, x.target_slug`
|
|
1913
|
+
).all();
|
|
1914
|
+
const out = [];
|
|
1915
|
+
const reachableCache = /* @__PURE__ */ new Map();
|
|
1916
|
+
for (const r of rows) {
|
|
1917
|
+
if (!inPageScope(scope, r.source_slug)) continue;
|
|
1918
|
+
let ok = reachableCache.get(r.target_wiki);
|
|
1919
|
+
if (ok === void 0) {
|
|
1920
|
+
const entry = await findEntry({ name: r.target_wiki });
|
|
1921
|
+
ok = entry !== null && existsSync9(join8(entry.path, ".almanac"));
|
|
1922
|
+
reachableCache.set(r.target_wiki, ok);
|
|
1638
1923
|
}
|
|
1639
|
-
if (
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
continue;
|
|
1924
|
+
if (!ok) {
|
|
1925
|
+
out.push({
|
|
1926
|
+
source_slug: r.source_slug,
|
|
1927
|
+
target_wiki: r.target_wiki,
|
|
1928
|
+
target_slug: r.target_slug
|
|
1929
|
+
});
|
|
1646
1930
|
}
|
|
1647
|
-
|
|
1931
|
+
}
|
|
1932
|
+
return out;
|
|
1933
|
+
}
|
|
1934
|
+
function findEmptyTopics(db, scope) {
|
|
1935
|
+
const rows = db.prepare(
|
|
1936
|
+
`SELECT t.slug FROM topics t
|
|
1937
|
+
WHERE NOT EXISTS (
|
|
1938
|
+
SELECT 1 FROM page_topics pt WHERE pt.topic_slug = t.slug
|
|
1939
|
+
)
|
|
1940
|
+
ORDER BY t.slug`
|
|
1941
|
+
).all();
|
|
1942
|
+
if (scope.topics === null) return rows;
|
|
1943
|
+
return rows.filter((r) => scope.topics.has(r.slug));
|
|
1944
|
+
}
|
|
1945
|
+
async function findEmptyPages(db, scope, pagesDir) {
|
|
1946
|
+
const rows = db.prepare(
|
|
1947
|
+
`SELECT slug, file_path FROM pages
|
|
1948
|
+
WHERE archived_at IS NULL
|
|
1949
|
+
ORDER BY slug`
|
|
1950
|
+
).all();
|
|
1951
|
+
const out = [];
|
|
1952
|
+
for (const r of rows) {
|
|
1953
|
+
if (!inPageScope(scope, r.slug)) continue;
|
|
1648
1954
|
let raw;
|
|
1649
1955
|
try {
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
} catch (err) {
|
|
1653
|
-
if (err instanceof Error && "code" in err && (err.code === "ENOENT" || err.code === "EACCES")) {
|
|
1654
|
-
process.stderr.write(
|
|
1655
|
-
`almanac: skipping "${rel}" \u2014 ${err.message}
|
|
1656
|
-
`
|
|
1657
|
-
);
|
|
1658
|
-
filesSkipped++;
|
|
1659
|
-
continue;
|
|
1660
|
-
}
|
|
1661
|
-
throw err;
|
|
1662
|
-
}
|
|
1663
|
-
seenSlugs.add(slug);
|
|
1664
|
-
const updatedAt = Math.floor(st.mtimeMs / 1e3);
|
|
1665
|
-
const contentHash = hashContent(raw);
|
|
1666
|
-
const existing = existingBySlug.get(slug);
|
|
1667
|
-
if (existing !== void 0 && existing.content_hash === contentHash && existing.file_path === fullPath) {
|
|
1956
|
+
raw = await readFile7(r.file_path, "utf8");
|
|
1957
|
+
} catch {
|
|
1668
1958
|
continue;
|
|
1669
1959
|
}
|
|
1670
|
-
const
|
|
1671
|
-
const
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
contentHash,
|
|
1679
|
-
updatedAt,
|
|
1680
|
-
archivedAt: fm.archived_at,
|
|
1681
|
-
supersededBy: fm.superseded_by,
|
|
1682
|
-
topics: fm.topics,
|
|
1683
|
-
frontmatterFiles: fm.files,
|
|
1684
|
-
wikilinks: links,
|
|
1685
|
-
content: fm.body
|
|
1960
|
+
const m = raw.match(/^---\r?\n[\s\S]*?\r?\n---\r?\n?([\s\S]*)$/);
|
|
1961
|
+
const body = m !== null ? m[1] ?? "" : raw;
|
|
1962
|
+
void pagesDir;
|
|
1963
|
+
const hasSubstance = body.split(/\r?\n/).some((l) => {
|
|
1964
|
+
const t = l.trim();
|
|
1965
|
+
if (t.length === 0) return false;
|
|
1966
|
+
if (t.startsWith("#")) return false;
|
|
1967
|
+
return true;
|
|
1686
1968
|
});
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
for (const slug of existingBySlug.keys()) {
|
|
1690
|
-
if (!seenSlugs.has(slug)) toDelete.push(slug);
|
|
1691
|
-
}
|
|
1692
|
-
const deleteByPage = db.prepare("DELETE FROM pages WHERE slug = ?");
|
|
1693
|
-
const deleteFtsByPage = db.prepare(
|
|
1694
|
-
"DELETE FROM fts_pages WHERE slug = ?"
|
|
1695
|
-
);
|
|
1696
|
-
const replacePage = db.prepare(
|
|
1697
|
-
`INSERT INTO pages (slug, title, file_path, content_hash, updated_at, archived_at, superseded_by)
|
|
1698
|
-
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
1699
|
-
ON CONFLICT(slug) DO UPDATE SET
|
|
1700
|
-
title = excluded.title,
|
|
1701
|
-
file_path = excluded.file_path,
|
|
1702
|
-
content_hash = excluded.content_hash,
|
|
1703
|
-
updated_at = excluded.updated_at,
|
|
1704
|
-
archived_at = excluded.archived_at,
|
|
1705
|
-
superseded_by = excluded.superseded_by`
|
|
1706
|
-
);
|
|
1707
|
-
const deletePageTopics = db.prepare(
|
|
1708
|
-
"DELETE FROM page_topics WHERE page_slug = ?"
|
|
1709
|
-
);
|
|
1710
|
-
const insertPageTopic = db.prepare(
|
|
1711
|
-
"INSERT OR IGNORE INTO page_topics (page_slug, topic_slug) VALUES (?, ?)"
|
|
1712
|
-
);
|
|
1713
|
-
const insertTopic = db.prepare(
|
|
1714
|
-
"INSERT OR IGNORE INTO topics (slug, title) VALUES (?, ?)"
|
|
1715
|
-
);
|
|
1716
|
-
const deleteFileRefs = db.prepare(
|
|
1717
|
-
"DELETE FROM file_refs WHERE page_slug = ?"
|
|
1718
|
-
);
|
|
1719
|
-
const insertFileRef = db.prepare(
|
|
1720
|
-
"INSERT OR IGNORE INTO file_refs (page_slug, path, original_path, is_dir) VALUES (?, ?, ?, ?)"
|
|
1721
|
-
);
|
|
1722
|
-
const deleteWikilinks = db.prepare(
|
|
1723
|
-
"DELETE FROM wikilinks WHERE source_slug = ?"
|
|
1724
|
-
);
|
|
1725
|
-
const insertWikilink = db.prepare(
|
|
1726
|
-
"INSERT OR IGNORE INTO wikilinks (source_slug, target_slug) VALUES (?, ?)"
|
|
1727
|
-
);
|
|
1728
|
-
const deleteXwiki = db.prepare(
|
|
1729
|
-
"DELETE FROM cross_wiki_links WHERE source_slug = ?"
|
|
1730
|
-
);
|
|
1731
|
-
const insertXwiki = db.prepare(
|
|
1732
|
-
"INSERT OR IGNORE INTO cross_wiki_links (source_slug, target_wiki, target_slug) VALUES (?, ?, ?)"
|
|
1733
|
-
);
|
|
1734
|
-
const insertFts = db.prepare(
|
|
1735
|
-
"INSERT INTO fts_pages (slug, title, content) VALUES (?, ?, ?)"
|
|
1736
|
-
);
|
|
1737
|
-
const apply = db.transaction(() => {
|
|
1738
|
-
for (const slug of toDelete) {
|
|
1739
|
-
deleteFtsByPage.run(slug);
|
|
1740
|
-
deleteByPage.run(slug);
|
|
1741
|
-
}
|
|
1742
|
-
for (const p of planned) {
|
|
1743
|
-
deletePageTopics.run(p.slug);
|
|
1744
|
-
deleteFileRefs.run(p.slug);
|
|
1745
|
-
deleteWikilinks.run(p.slug);
|
|
1746
|
-
deleteXwiki.run(p.slug);
|
|
1747
|
-
deleteFtsByPage.run(p.slug);
|
|
1748
|
-
replacePage.run(
|
|
1749
|
-
p.slug,
|
|
1750
|
-
p.title,
|
|
1751
|
-
p.fullPath,
|
|
1752
|
-
p.contentHash,
|
|
1753
|
-
p.updatedAt,
|
|
1754
|
-
p.archivedAt,
|
|
1755
|
-
p.supersededBy
|
|
1756
|
-
);
|
|
1757
|
-
for (const topic of p.topics) {
|
|
1758
|
-
const topicSlug = toKebabCase(topic);
|
|
1759
|
-
if (topicSlug.length === 0) continue;
|
|
1760
|
-
insertTopic.run(topicSlug, titleCase(topicSlug));
|
|
1761
|
-
insertPageTopic.run(p.slug, topicSlug);
|
|
1762
|
-
}
|
|
1763
|
-
for (const raw of p.frontmatterFiles) {
|
|
1764
|
-
const isDir = looksLikeDir(raw);
|
|
1765
|
-
const path3 = normalizePath(raw, isDir);
|
|
1766
|
-
const originalPath = normalizePathPreservingCase(raw, isDir);
|
|
1767
|
-
if (path3.length === 0) continue;
|
|
1768
|
-
insertFileRef.run(p.slug, path3, originalPath, isDir ? 1 : 0);
|
|
1769
|
-
}
|
|
1770
|
-
for (const ref of p.wikilinks) {
|
|
1771
|
-
switch (ref.kind) {
|
|
1772
|
-
case "page":
|
|
1773
|
-
insertWikilink.run(p.slug, ref.target);
|
|
1774
|
-
break;
|
|
1775
|
-
case "file":
|
|
1776
|
-
insertFileRef.run(p.slug, ref.path, ref.originalPath, 0);
|
|
1777
|
-
break;
|
|
1778
|
-
case "folder":
|
|
1779
|
-
insertFileRef.run(p.slug, ref.path, ref.originalPath, 1);
|
|
1780
|
-
break;
|
|
1781
|
-
case "xwiki":
|
|
1782
|
-
insertXwiki.run(p.slug, ref.wiki, ref.target);
|
|
1783
|
-
break;
|
|
1784
|
-
}
|
|
1785
|
-
}
|
|
1786
|
-
insertFts.run(p.slug, p.title, p.content);
|
|
1969
|
+
if (!hasSubstance) {
|
|
1970
|
+
out.push({ slug: r.slug });
|
|
1787
1971
|
}
|
|
1788
|
-
});
|
|
1789
|
-
apply();
|
|
1790
|
-
void relative3;
|
|
1791
|
-
const pagesIndexed = seenSlugs.size;
|
|
1792
|
-
return {
|
|
1793
|
-
changed: planned.length,
|
|
1794
|
-
removed: toDelete.length,
|
|
1795
|
-
total: pagesIndexed,
|
|
1796
|
-
pagesIndexed,
|
|
1797
|
-
filesSeen: files.length,
|
|
1798
|
-
filesSkipped
|
|
1799
|
-
};
|
|
1800
|
-
}
|
|
1801
|
-
function pagesNewerThan(pagesDir, dbPath) {
|
|
1802
|
-
let dbMtime;
|
|
1803
|
-
try {
|
|
1804
|
-
dbMtime = statSync2(dbPath).mtimeMs;
|
|
1805
|
-
} catch {
|
|
1806
|
-
return true;
|
|
1807
1972
|
}
|
|
1808
|
-
|
|
1973
|
+
return out;
|
|
1974
|
+
}
|
|
1975
|
+
async function findSlugCollisions(pagesDir) {
|
|
1976
|
+
if (!existsSync9(pagesDir)) return [];
|
|
1977
|
+
const files = await fg2("**/*.md", {
|
|
1809
1978
|
cwd: pagesDir,
|
|
1810
|
-
absolute:
|
|
1979
|
+
absolute: false,
|
|
1811
1980
|
onlyFiles: true,
|
|
1812
|
-
|
|
1981
|
+
caseSensitiveMatch: true
|
|
1813
1982
|
});
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
return createHash2("sha256").update(raw).digest("hex");
|
|
1822
|
-
}
|
|
1823
|
-
function topicsYamlNewerThan(almanacDir, dbPath) {
|
|
1824
|
-
const path3 = join6(almanacDir, "topics.yaml");
|
|
1825
|
-
if (!existsSync8(path3)) return false;
|
|
1826
|
-
let dbMtime;
|
|
1827
|
-
try {
|
|
1828
|
-
dbMtime = statSync2(dbPath).mtimeMs;
|
|
1829
|
-
} catch {
|
|
1830
|
-
return true;
|
|
1983
|
+
const bySlug = /* @__PURE__ */ new Map();
|
|
1984
|
+
for (const rel of files) {
|
|
1985
|
+
const slug = toKebabCase(basename4(rel, ".md"));
|
|
1986
|
+
if (slug.length === 0) continue;
|
|
1987
|
+
const list = bySlug.get(slug) ?? [];
|
|
1988
|
+
list.push(rel);
|
|
1989
|
+
bySlug.set(slug, list);
|
|
1831
1990
|
}
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1991
|
+
const out = [];
|
|
1992
|
+
for (const [slug, paths] of bySlug.entries()) {
|
|
1993
|
+
if (paths.length > 1) {
|
|
1994
|
+
out.push({ slug, paths: paths.sort() });
|
|
1995
|
+
}
|
|
1837
1996
|
}
|
|
1997
|
+
out.sort((a, b) => a.slug.localeCompare(b.slug));
|
|
1998
|
+
return out;
|
|
1838
1999
|
}
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
|
|
2000
|
+
function formatReport(r) {
|
|
2001
|
+
const sections = [];
|
|
2002
|
+
sections.push(
|
|
2003
|
+
section(
|
|
2004
|
+
"orphans",
|
|
2005
|
+
r.orphans.length,
|
|
2006
|
+
r.orphans.map((o) => ` ${o.slug}`)
|
|
2007
|
+
)
|
|
2008
|
+
);
|
|
2009
|
+
sections.push(
|
|
2010
|
+
section(
|
|
2011
|
+
"stale",
|
|
2012
|
+
r.stale.length,
|
|
2013
|
+
r.stale.map((s) => ` ${s.slug} (${s.days_since_update} days)`)
|
|
2014
|
+
)
|
|
2015
|
+
);
|
|
2016
|
+
sections.push(
|
|
2017
|
+
section(
|
|
2018
|
+
"dead-refs",
|
|
2019
|
+
r.dead_refs.length,
|
|
2020
|
+
r.dead_refs.map((d) => ` ${d.slug} references ${d.path} (missing)`)
|
|
2021
|
+
)
|
|
1855
2022
|
);
|
|
1856
|
-
|
|
1857
|
-
|
|
2023
|
+
sections.push(
|
|
2024
|
+
section(
|
|
2025
|
+
"broken-links",
|
|
2026
|
+
r.broken_links.length,
|
|
2027
|
+
r.broken_links.map(
|
|
2028
|
+
(b) => ` ${b.source_slug} \u2192 ${b.target_slug} (target does not exist)`
|
|
2029
|
+
)
|
|
2030
|
+
)
|
|
1858
2031
|
);
|
|
1859
|
-
|
|
1860
|
-
|
|
2032
|
+
sections.push(
|
|
2033
|
+
section(
|
|
2034
|
+
"broken-xwiki",
|
|
2035
|
+
r.broken_xwiki.length,
|
|
2036
|
+
r.broken_xwiki.map(
|
|
2037
|
+
(b) => ` ${b.source_slug} \u2192 ${b.target_wiki}:${b.target_slug} (wiki unregistered or unreachable)`
|
|
2038
|
+
)
|
|
2039
|
+
)
|
|
1861
2040
|
);
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
)
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
}
|
|
1891
|
-
});
|
|
1892
|
-
apply();
|
|
2041
|
+
sections.push(
|
|
2042
|
+
section(
|
|
2043
|
+
"empty-topics",
|
|
2044
|
+
r.empty_topics.length,
|
|
2045
|
+
r.empty_topics.map((e) => ` ${e.slug}`)
|
|
2046
|
+
)
|
|
2047
|
+
);
|
|
2048
|
+
sections.push(
|
|
2049
|
+
section(
|
|
2050
|
+
"empty-pages",
|
|
2051
|
+
r.empty_pages.length,
|
|
2052
|
+
r.empty_pages.map((e) => ` ${e.slug}`)
|
|
2053
|
+
)
|
|
2054
|
+
);
|
|
2055
|
+
sections.push(
|
|
2056
|
+
section(
|
|
2057
|
+
"slug-collisions",
|
|
2058
|
+
r.slug_collisions.length,
|
|
2059
|
+
r.slug_collisions.map((c) => ` ${c.slug}: ${c.paths.join(", ")}`)
|
|
2060
|
+
)
|
|
2061
|
+
);
|
|
2062
|
+
return `${sections.join("\n\n")}
|
|
2063
|
+
`;
|
|
2064
|
+
}
|
|
2065
|
+
function section(label, count, lines) {
|
|
2066
|
+
if (count === 0) return `${label} (0): (ok)`;
|
|
2067
|
+
return `${label} (${count}):
|
|
2068
|
+
${lines.join("\n")}`;
|
|
1893
2069
|
}
|
|
1894
2070
|
|
|
1895
|
-
// src/
|
|
1896
|
-
import { existsSync as
|
|
1897
|
-
import {
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
2071
|
+
// src/commands/setup.ts
|
|
2072
|
+
import { existsSync as existsSync11 } from "fs";
|
|
2073
|
+
import {
|
|
2074
|
+
copyFile,
|
|
2075
|
+
mkdir as mkdir5,
|
|
2076
|
+
readFile as readFile9,
|
|
2077
|
+
writeFile as writeFile5
|
|
2078
|
+
} from "fs/promises";
|
|
2079
|
+
import { createRequire as createRequire2 } from "module";
|
|
2080
|
+
import { homedir as homedir4 } from "os";
|
|
2081
|
+
import path3 from "path";
|
|
2082
|
+
import { fileURLToPath as fileURLToPath3 } from "url";
|
|
2083
|
+
|
|
2084
|
+
// src/commands/hook.ts
|
|
2085
|
+
import { existsSync as existsSync10 } from "fs";
|
|
2086
|
+
import { mkdir as mkdir4, readFile as readFile8, rename as rename3, writeFile as writeFile4 } from "fs/promises";
|
|
2087
|
+
import { homedir as homedir3 } from "os";
|
|
2088
|
+
import path2 from "path";
|
|
2089
|
+
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
2090
|
+
var HOOK_TIMEOUT_SECONDS = 10;
|
|
2091
|
+
async function runHookInstall(options = {}) {
|
|
2092
|
+
const script = resolveHookScriptPath(options);
|
|
2093
|
+
if (!script.ok) {
|
|
2094
|
+
return { stdout: "", stderr: `almanac: ${script.error}
|
|
2095
|
+
`, exitCode: 1 };
|
|
1910
2096
|
}
|
|
1911
|
-
const
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
2097
|
+
const settingsPath = resolveSettingsPath(options);
|
|
2098
|
+
const settings = await readSettings(settingsPath);
|
|
2099
|
+
const existing = (settings.hooks?.SessionEnd ?? []).slice();
|
|
2100
|
+
const ourEntries = existing.filter((e) => e.command === script.path);
|
|
2101
|
+
const foreignEntries = existing.filter((e) => e.command !== script.path);
|
|
2102
|
+
const stale = foreignEntries.filter(
|
|
2103
|
+
(e) => e.command.endsWith("almanac-capture.sh")
|
|
2104
|
+
);
|
|
2105
|
+
const unrelated = foreignEntries.filter(
|
|
2106
|
+
(e) => !e.command.endsWith("almanac-capture.sh")
|
|
2107
|
+
);
|
|
2108
|
+
if (unrelated.length > 0) {
|
|
2109
|
+
const existingStr = unrelated.map((e) => ` - ${e.command}`).join("\n");
|
|
2110
|
+
return {
|
|
2111
|
+
stdout: "",
|
|
2112
|
+
stderr: `almanac: SessionEnd hook already has a foreign entry:
|
|
2113
|
+
${existingStr}
|
|
2114
|
+
Remove it manually from ${settingsPath} if you want almanac to manage the hook.
|
|
2115
|
+
`,
|
|
2116
|
+
exitCode: 1
|
|
2117
|
+
};
|
|
1916
2118
|
}
|
|
1917
|
-
|
|
2119
|
+
if (ourEntries.length > 0 && stale.length === 0) {
|
|
2120
|
+
return {
|
|
2121
|
+
stdout: `almanac: SessionEnd hook already installed at ${script.path}
|
|
2122
|
+
`,
|
|
2123
|
+
stderr: "",
|
|
2124
|
+
exitCode: 0
|
|
2125
|
+
};
|
|
2126
|
+
}
|
|
2127
|
+
const newEntries = [
|
|
2128
|
+
{
|
|
2129
|
+
type: "command",
|
|
2130
|
+
command: script.path,
|
|
2131
|
+
timeout: HOOK_TIMEOUT_SECONDS
|
|
2132
|
+
}
|
|
2133
|
+
];
|
|
2134
|
+
settings.hooks = { ...settings.hooks ?? {}, SessionEnd: newEntries };
|
|
2135
|
+
await writeSettings(settingsPath, settings);
|
|
2136
|
+
return {
|
|
2137
|
+
stdout: `almanac: SessionEnd hook installed
|
|
2138
|
+
script: ${script.path}
|
|
2139
|
+
settings: ${settingsPath}
|
|
2140
|
+
`,
|
|
2141
|
+
stderr: "",
|
|
2142
|
+
exitCode: 0
|
|
2143
|
+
};
|
|
1918
2144
|
}
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
2145
|
+
async function runHookUninstall(options = {}) {
|
|
2146
|
+
const settingsPath = resolveSettingsPath(options);
|
|
2147
|
+
if (!existsSync10(settingsPath)) {
|
|
2148
|
+
return {
|
|
2149
|
+
stdout: `almanac: SessionEnd hook not installed (no settings file)
|
|
2150
|
+
`,
|
|
2151
|
+
stderr: "",
|
|
2152
|
+
exitCode: 0
|
|
2153
|
+
};
|
|
1926
2154
|
}
|
|
1927
|
-
const
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
|
|
2155
|
+
const settings = await readSettings(settingsPath);
|
|
2156
|
+
const existing = (settings.hooks?.SessionEnd ?? []).slice();
|
|
2157
|
+
const kept = existing.filter((e) => !e.command.endsWith("almanac-capture.sh"));
|
|
2158
|
+
const removed = existing.length - kept.length;
|
|
2159
|
+
if (removed === 0) {
|
|
2160
|
+
return {
|
|
2161
|
+
stdout: `almanac: SessionEnd hook not installed
|
|
2162
|
+
`,
|
|
2163
|
+
stderr: "",
|
|
2164
|
+
exitCode: 0
|
|
2165
|
+
};
|
|
2166
|
+
}
|
|
2167
|
+
if (settings.hooks !== void 0) {
|
|
2168
|
+
if (kept.length === 0) {
|
|
2169
|
+
const { SessionEnd: _dropped, ...rest } = settings.hooks;
|
|
2170
|
+
void _dropped;
|
|
2171
|
+
settings.hooks = rest;
|
|
2172
|
+
} else {
|
|
2173
|
+
settings.hooks = { ...settings.hooks, SessionEnd: kept };
|
|
2174
|
+
}
|
|
2175
|
+
if (Object.keys(settings.hooks).length === 0) {
|
|
2176
|
+
delete settings.hooks;
|
|
1937
2177
|
}
|
|
1938
|
-
frontier = next;
|
|
1939
|
-
depth += 1;
|
|
1940
2178
|
}
|
|
1941
|
-
|
|
1942
|
-
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
SELECT tp.child_slug, d.depth + 1
|
|
1949
|
-
FROM topic_parents tp
|
|
1950
|
-
JOIN desc d ON tp.parent_slug = d.slug
|
|
1951
|
-
WHERE d.depth < ?
|
|
1952
|
-
)
|
|
1953
|
-
SELECT DISTINCT slug FROM desc ORDER BY slug`
|
|
1954
|
-
).all(slug, DAG_DEPTH_CAP).map((r) => r.slug);
|
|
1955
|
-
return rows;
|
|
1956
|
-
}
|
|
1957
|
-
function subtreeInDb(db, slug) {
|
|
1958
|
-
return [slug, ...descendantsInDb(db, slug)];
|
|
2179
|
+
await writeSettings(settingsPath, settings);
|
|
2180
|
+
return {
|
|
2181
|
+
stdout: `almanac: SessionEnd hook removed
|
|
2182
|
+
`,
|
|
2183
|
+
stderr: "",
|
|
2184
|
+
exitCode: 0
|
|
2185
|
+
};
|
|
1959
2186
|
}
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
const scope = resolveScope(db, options);
|
|
1972
|
-
const report = {
|
|
1973
|
-
orphans: findOrphans(db, scope),
|
|
1974
|
-
stale: findStale(db, scope, staleSeconds),
|
|
1975
|
-
dead_refs: await findDeadRefs(db, scope, repoRoot),
|
|
1976
|
-
broken_links: findBrokenLinks(db, scope),
|
|
1977
|
-
broken_xwiki: await findBrokenXwiki(db, scope),
|
|
1978
|
-
empty_topics: findEmptyTopics(db, scope),
|
|
1979
|
-
empty_pages: await findEmptyPages(db, scope, pagesDir),
|
|
1980
|
-
slug_collisions: await findSlugCollisions(pagesDir)
|
|
2187
|
+
async function runHookStatus(options = {}) {
|
|
2188
|
+
const script = resolveHookScriptPath(options);
|
|
2189
|
+
const settingsPath = resolveSettingsPath(options);
|
|
2190
|
+
if (!existsSync10(settingsPath)) {
|
|
2191
|
+
return {
|
|
2192
|
+
stdout: `SessionEnd hook: not installed
|
|
2193
|
+
settings: ${settingsPath} (does not exist)
|
|
2194
|
+
` + (script.ok ? `script would be: ${script.path}
|
|
2195
|
+
` : ""),
|
|
2196
|
+
stderr: "",
|
|
2197
|
+
exitCode: 0
|
|
1981
2198
|
};
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
|
|
1986
|
-
|
|
1987
|
-
|
|
1988
|
-
};
|
|
1989
|
-
}
|
|
2199
|
+
}
|
|
2200
|
+
const settings = await readSettings(settingsPath);
|
|
2201
|
+
const existing = settings.hooks?.SessionEnd ?? [];
|
|
2202
|
+
const ours = existing.find((e) => e.command.endsWith("almanac-capture.sh"));
|
|
2203
|
+
if (ours === void 0) {
|
|
2204
|
+
const foreign = existing.map((e) => ` - ${e.command}`).join("\n");
|
|
1990
2205
|
return {
|
|
1991
|
-
stdout:
|
|
2206
|
+
stdout: `SessionEnd hook: not installed
|
|
2207
|
+
settings: ${settingsPath}
|
|
2208
|
+
` + (existing.length > 0 ? `(${existing.length} foreign entr${existing.length === 1 ? "y" : "ies"} present:
|
|
2209
|
+
${foreign})
|
|
2210
|
+
` : "") + (script.ok ? `script would be: ${script.path}
|
|
2211
|
+
` : ""),
|
|
1992
2212
|
stderr: "",
|
|
1993
2213
|
exitCode: 0
|
|
1994
2214
|
};
|
|
1995
|
-
} finally {
|
|
1996
|
-
db.close();
|
|
1997
2215
|
}
|
|
2216
|
+
return {
|
|
2217
|
+
stdout: `SessionEnd hook: installed
|
|
2218
|
+
script: ${ours.command}
|
|
2219
|
+
settings: ${settingsPath}
|
|
2220
|
+
`,
|
|
2221
|
+
stderr: "",
|
|
2222
|
+
exitCode: 0
|
|
2223
|
+
};
|
|
1998
2224
|
}
|
|
1999
|
-
function
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
|
|
2003
|
-
|
|
2004
|
-
|
|
2005
|
-
|
|
2006
|
-
topics = new Set(subtree);
|
|
2007
|
-
const placeholders = subtree.map(() => "?").join(", ");
|
|
2008
|
-
const rows = db.prepare(
|
|
2009
|
-
`SELECT DISTINCT page_slug FROM page_topics
|
|
2010
|
-
WHERE topic_slug IN (${placeholders})`
|
|
2011
|
-
).all(...subtree);
|
|
2012
|
-
pages = new Set(rows.map((r) => r.page_slug));
|
|
2013
|
-
}
|
|
2225
|
+
function resolveSettingsPath(options) {
|
|
2226
|
+
if (options.settingsPath !== void 0) return options.settingsPath;
|
|
2227
|
+
return path2.join(homedir3(), ".claude", "settings.json");
|
|
2228
|
+
}
|
|
2229
|
+
function resolveHookScriptPath(options) {
|
|
2230
|
+
if (options.hookScriptPath !== void 0) {
|
|
2231
|
+
return { ok: true, path: options.hookScriptPath };
|
|
2014
2232
|
}
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
|
|
2018
|
-
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
|
|
2022
|
-
|
|
2023
|
-
|
|
2024
|
-
|
|
2025
|
-
|
|
2233
|
+
const here = path2.dirname(fileURLToPath2(import.meta.url));
|
|
2234
|
+
const candidates = [
|
|
2235
|
+
// Bundled: `.../codealmanac/dist/codealmanac.js` → `../hooks/…`
|
|
2236
|
+
path2.resolve(here, "..", "hooks", "almanac-capture.sh"),
|
|
2237
|
+
// Source: `.../codealmanac/src/commands/hook.ts` → `../../hooks/…`
|
|
2238
|
+
path2.resolve(here, "..", "..", "hooks", "almanac-capture.sh"),
|
|
2239
|
+
// Defensive nested fallback.
|
|
2240
|
+
path2.resolve(here, "..", "..", "..", "hooks", "almanac-capture.sh")
|
|
2241
|
+
];
|
|
2242
|
+
for (const candidate of candidates) {
|
|
2243
|
+
if (existsSync10(candidate)) {
|
|
2244
|
+
return { ok: true, path: candidate };
|
|
2026
2245
|
}
|
|
2027
2246
|
}
|
|
2028
|
-
return {
|
|
2029
|
-
|
|
2030
|
-
|
|
2031
|
-
|
|
2032
|
-
|
|
2247
|
+
return {
|
|
2248
|
+
ok: false,
|
|
2249
|
+
error: `could not locate hooks/almanac-capture.sh. Tried:
|
|
2250
|
+
` + candidates.map((c) => ` - ${c}`).join("\n")
|
|
2251
|
+
};
|
|
2033
2252
|
}
|
|
2034
|
-
function
|
|
2035
|
-
|
|
2036
|
-
|
|
2037
|
-
|
|
2038
|
-
|
|
2039
|
-
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
|
|
2253
|
+
async function readSettings(settingsPath) {
|
|
2254
|
+
if (!existsSync10(settingsPath)) return {};
|
|
2255
|
+
try {
|
|
2256
|
+
const raw = await readFile8(settingsPath, "utf8");
|
|
2257
|
+
if (raw.trim().length === 0) return {};
|
|
2258
|
+
const parsed = JSON.parse(raw);
|
|
2259
|
+
if (parsed === null || typeof parsed !== "object") return {};
|
|
2260
|
+
return parsed;
|
|
2261
|
+
} catch (err) {
|
|
2262
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
2263
|
+
throw new Error(`failed to read ${settingsPath}: ${msg}`);
|
|
2264
|
+
}
|
|
2044
2265
|
}
|
|
2045
|
-
function
|
|
2046
|
-
const
|
|
2047
|
-
|
|
2048
|
-
const
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
2053
|
-
return rows.filter((r) => inPageScope(scope, r.slug)).map((r) => ({
|
|
2054
|
-
slug: r.slug,
|
|
2055
|
-
days_since_update: Math.floor((now - r.updated_at) / (60 * 60 * 24))
|
|
2056
|
-
}));
|
|
2266
|
+
async function writeSettings(settingsPath, settings) {
|
|
2267
|
+
const dir = path2.dirname(settingsPath);
|
|
2268
|
+
await mkdir4(dir, { recursive: true });
|
|
2269
|
+
const tmp = `${settingsPath}.almanac-tmp-${process.pid}`;
|
|
2270
|
+
const body = `${JSON.stringify(settings, null, 2)}
|
|
2271
|
+
`;
|
|
2272
|
+
await writeFile4(tmp, body, "utf8");
|
|
2273
|
+
await rename3(tmp, settingsPath);
|
|
2057
2274
|
}
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
|
|
2062
|
-
|
|
2063
|
-
|
|
2064
|
-
|
|
2065
|
-
|
|
2066
|
-
|
|
2067
|
-
|
|
2068
|
-
|
|
2069
|
-
|
|
2070
|
-
|
|
2071
|
-
|
|
2072
|
-
|
|
2275
|
+
|
|
2276
|
+
// src/commands/setup.ts
|
|
2277
|
+
var RST = "\x1B[0m";
|
|
2278
|
+
var BOLD = "\x1B[1m";
|
|
2279
|
+
var DIM = "\x1B[2m";
|
|
2280
|
+
var WHITE_BOLD = "\x1B[1;37m";
|
|
2281
|
+
var BLUE = "\x1B[38;5;75m";
|
|
2282
|
+
var BLUE_DIM = "\x1B[38;5;69m";
|
|
2283
|
+
var ACCENT_BG = "\x1B[48;5;252m\x1B[38;5;16m";
|
|
2284
|
+
var GRADIENT = [
|
|
2285
|
+
"\x1B[38;5;255m",
|
|
2286
|
+
"\x1B[38;5;253m",
|
|
2287
|
+
"\x1B[38;5;251m",
|
|
2288
|
+
"\x1B[38;5;249m",
|
|
2289
|
+
"\x1B[38;5;246m",
|
|
2290
|
+
"\x1B[38;5;243m"
|
|
2291
|
+
];
|
|
2292
|
+
var LOGO_LINES = [
|
|
2293
|
+
" ___ ___ ___ ___ _ _ __ __ _ _ _ _ ___ ",
|
|
2294
|
+
" / __/ _ \\| \\| __| /_\\ | | | \\/ | /_\\ | \\| | /_\\ / __|",
|
|
2295
|
+
"| (_| (_) | |) | _| / _ \\| |__| |\\/| |/ _ \\| .` |/ _ \\ (__ ",
|
|
2296
|
+
" \\___\\___/|___/|___/_/ \\_\\____|_| |_/_/ \\_\\_|\\_/_/ \\_\\___|",
|
|
2297
|
+
" ",
|
|
2298
|
+
" a living wiki for codebases, for your agent "
|
|
2299
|
+
];
|
|
2300
|
+
var BAR = ` ${DIM}\u2502${RST}`;
|
|
2301
|
+
function printBanner(out) {
|
|
2302
|
+
out.write("\n");
|
|
2303
|
+
for (let i = 0; i < LOGO_LINES.length; i++) {
|
|
2304
|
+
const color = GRADIENT[Math.min(i, GRADIENT.length - 1)] ?? "";
|
|
2305
|
+
out.write(`${color}${LOGO_LINES[i]}${RST}
|
|
2306
|
+
`);
|
|
2073
2307
|
}
|
|
2074
|
-
|
|
2308
|
+
out.write(`
|
|
2309
|
+
${WHITE_BOLD} Install the hook + agent guides${RST}
|
|
2310
|
+
`);
|
|
2075
2311
|
}
|
|
2076
|
-
function
|
|
2077
|
-
|
|
2078
|
-
|
|
2079
|
-
|
|
2080
|
-
|
|
2081
|
-
LEFT JOIN pages tgt ON tgt.slug = w.target_slug
|
|
2082
|
-
WHERE tgt.slug IS NULL AND src.archived_at IS NULL
|
|
2083
|
-
ORDER BY w.source_slug, w.target_slug`
|
|
2084
|
-
).all();
|
|
2085
|
-
return rows.filter((r) => inPageScope(scope, r.source_slug));
|
|
2312
|
+
function printBadge(out) {
|
|
2313
|
+
out.write(`
|
|
2314
|
+
${ACCENT_BG} codealmanac ${RST}
|
|
2315
|
+
|
|
2316
|
+
`);
|
|
2086
2317
|
}
|
|
2087
|
-
|
|
2088
|
-
|
|
2089
|
-
|
|
2090
|
-
|
|
2091
|
-
|
|
2092
|
-
|
|
2093
|
-
|
|
2094
|
-
|
|
2095
|
-
|
|
2096
|
-
|
|
2097
|
-
|
|
2098
|
-
|
|
2099
|
-
|
|
2100
|
-
|
|
2101
|
-
|
|
2102
|
-
|
|
2103
|
-
|
|
2104
|
-
|
|
2105
|
-
|
|
2106
|
-
|
|
2318
|
+
function stepDone(out, msg) {
|
|
2319
|
+
out.write(` ${BLUE}\u25C7${RST} ${msg}
|
|
2320
|
+
`);
|
|
2321
|
+
}
|
|
2322
|
+
function stepActive(out, msg) {
|
|
2323
|
+
out.write(` ${BLUE}\u25C6${RST} ${msg}
|
|
2324
|
+
`);
|
|
2325
|
+
}
|
|
2326
|
+
function stepSkipped(out, msg) {
|
|
2327
|
+
out.write(` ${DIM}\u25CB ${msg}${RST}
|
|
2328
|
+
`);
|
|
2329
|
+
}
|
|
2330
|
+
async function runSetup(options = {}) {
|
|
2331
|
+
const out = options.stdout ?? process.stdout;
|
|
2332
|
+
const isTTY = options.isTTY ?? process.stdin.isTTY === true;
|
|
2333
|
+
const interactive = isTTY && options.yes !== true;
|
|
2334
|
+
if (options.skipHook === true && options.skipGuides === true) {
|
|
2335
|
+
out.write(
|
|
2336
|
+
"codealmanac: nothing to install \u2014 use --help to see what setup does\n"
|
|
2337
|
+
);
|
|
2338
|
+
return { stdout: "", stderr: "", exitCode: 0 };
|
|
2339
|
+
}
|
|
2340
|
+
printBanner(out);
|
|
2341
|
+
printBadge(out);
|
|
2342
|
+
const auth = await safeCheckAuth(options.spawnCli);
|
|
2343
|
+
reportAuth(out, auth);
|
|
2344
|
+
out.write(BAR + "\n");
|
|
2345
|
+
let hookAction = "install";
|
|
2346
|
+
if (options.skipHook === true) {
|
|
2347
|
+
hookAction = "skip";
|
|
2348
|
+
} else if (interactive) {
|
|
2349
|
+
hookAction = await confirm(
|
|
2350
|
+
out,
|
|
2351
|
+
"Install the SessionEnd hook so capture runs at the end of every Claude Code session?",
|
|
2352
|
+
true
|
|
2353
|
+
);
|
|
2354
|
+
}
|
|
2355
|
+
let hookResultLine = "";
|
|
2356
|
+
if (hookAction === "install") {
|
|
2357
|
+
const res = await runHookInstall({
|
|
2358
|
+
settingsPath: options.settingsPath,
|
|
2359
|
+
hookScriptPath: options.hookScriptPath
|
|
2360
|
+
});
|
|
2361
|
+
if (res.exitCode !== 0) {
|
|
2362
|
+
stepActive(out, `SessionEnd hook: ${res.stderr.trim()}`);
|
|
2363
|
+
return {
|
|
2364
|
+
stdout: "",
|
|
2365
|
+
stderr: res.stderr,
|
|
2366
|
+
exitCode: res.exitCode
|
|
2367
|
+
};
|
|
2107
2368
|
}
|
|
2108
|
-
|
|
2109
|
-
|
|
2110
|
-
|
|
2111
|
-
|
|
2112
|
-
|
|
2369
|
+
hookResultLine = res.stdout.includes("already installed") ? `SessionEnd hook ${DIM}already installed${RST}` : `SessionEnd hook installed`;
|
|
2370
|
+
stepDone(out, hookResultLine);
|
|
2371
|
+
} else {
|
|
2372
|
+
stepSkipped(out, `SessionEnd hook ${DIM}skipped${RST}`);
|
|
2373
|
+
}
|
|
2374
|
+
out.write(BAR + "\n");
|
|
2375
|
+
let guidesAction = "install";
|
|
2376
|
+
if (options.skipGuides === true) {
|
|
2377
|
+
guidesAction = "skip";
|
|
2378
|
+
} else if (interactive) {
|
|
2379
|
+
guidesAction = await confirm(
|
|
2380
|
+
out,
|
|
2381
|
+
"Install the codealmanac usage guides into ~/.claude/ and import them from CLAUDE.md?",
|
|
2382
|
+
true
|
|
2383
|
+
);
|
|
2384
|
+
}
|
|
2385
|
+
let guidesSummary;
|
|
2386
|
+
if (guidesAction === "install") {
|
|
2387
|
+
try {
|
|
2388
|
+
const summary = await installGuides({
|
|
2389
|
+
claudeDir: options.claudeDir ?? path3.join(homedir4(), ".claude"),
|
|
2390
|
+
guidesDir: options.guidesDir ?? resolveGuidesDir()
|
|
2113
2391
|
});
|
|
2392
|
+
guidesSummary = summary.anyChanges ? `Guides installed (${summary.filesWritten.join(", ")})` : `Guides ${DIM}already installed${RST}`;
|
|
2393
|
+
stepDone(out, guidesSummary);
|
|
2394
|
+
} catch (err) {
|
|
2395
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
2396
|
+
return {
|
|
2397
|
+
stdout: "",
|
|
2398
|
+
stderr: `almanac: guide install failed: ${msg}
|
|
2399
|
+
`,
|
|
2400
|
+
exitCode: 1
|
|
2401
|
+
};
|
|
2114
2402
|
}
|
|
2403
|
+
} else {
|
|
2404
|
+
stepSkipped(out, `Guides ${DIM}skipped${RST}`);
|
|
2115
2405
|
}
|
|
2116
|
-
|
|
2406
|
+
out.write(BAR + "\n");
|
|
2407
|
+
stepDone(out, `${BLUE}Setup complete${RST}`);
|
|
2408
|
+
out.write("\n");
|
|
2409
|
+
printNextSteps(out);
|
|
2410
|
+
return { stdout: "", stderr: "", exitCode: 0 };
|
|
2117
2411
|
}
|
|
2118
|
-
function
|
|
2119
|
-
|
|
2120
|
-
|
|
2121
|
-
|
|
2122
|
-
|
|
2123
|
-
|
|
2124
|
-
ORDER BY t.slug`
|
|
2125
|
-
).all();
|
|
2126
|
-
if (scope.topics === null) return rows;
|
|
2127
|
-
return rows.filter((r) => scope.topics.has(r.slug));
|
|
2412
|
+
async function safeCheckAuth(spawnCli) {
|
|
2413
|
+
try {
|
|
2414
|
+
return await checkClaudeAuth(spawnCli);
|
|
2415
|
+
} catch {
|
|
2416
|
+
return { loggedIn: false };
|
|
2417
|
+
}
|
|
2128
2418
|
}
|
|
2129
|
-
|
|
2130
|
-
|
|
2131
|
-
|
|
2132
|
-
|
|
2133
|
-
|
|
2134
|
-
|
|
2135
|
-
|
|
2136
|
-
|
|
2137
|
-
|
|
2138
|
-
|
|
2139
|
-
try {
|
|
2140
|
-
raw = await readFile8(r.file_path, "utf8");
|
|
2141
|
-
} catch {
|
|
2142
|
-
continue;
|
|
2143
|
-
}
|
|
2144
|
-
const m = raw.match(/^---\r?\n[\s\S]*?\r?\n---\r?\n?([\s\S]*)$/);
|
|
2145
|
-
const body = m !== null ? m[1] ?? "" : raw;
|
|
2146
|
-
void pagesDir;
|
|
2147
|
-
const hasSubstance = body.split(/\r?\n/).some((l) => {
|
|
2148
|
-
const t = l.trim();
|
|
2149
|
-
if (t.length === 0) return false;
|
|
2150
|
-
if (t.startsWith("#")) return false;
|
|
2151
|
-
return true;
|
|
2152
|
-
});
|
|
2153
|
-
if (!hasSubstance) {
|
|
2154
|
-
out.push({ slug: r.slug });
|
|
2155
|
-
}
|
|
2419
|
+
function reportAuth(out, auth) {
|
|
2420
|
+
if (auth.loggedIn) {
|
|
2421
|
+
const who = auth.email ?? "Claude account";
|
|
2422
|
+
const plan = auth.subscriptionType !== void 0 ? ` ${DIM}(${auth.subscriptionType})${RST}` : "";
|
|
2423
|
+
stepDone(out, `Claude auth: ${WHITE_BOLD}${who}${RST}${plan}`);
|
|
2424
|
+
return;
|
|
2425
|
+
}
|
|
2426
|
+
if (process.env.ANTHROPIC_API_KEY !== void 0 && process.env.ANTHROPIC_API_KEY.length > 0) {
|
|
2427
|
+
stepDone(out, `Claude auth: ${WHITE_BOLD}ANTHROPIC_API_KEY${RST} set`);
|
|
2428
|
+
return;
|
|
2156
2429
|
}
|
|
2157
|
-
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
const files = await fg2("**/*.md", {
|
|
2162
|
-
cwd: pagesDir,
|
|
2163
|
-
absolute: false,
|
|
2164
|
-
onlyFiles: true,
|
|
2165
|
-
caseSensitiveMatch: true
|
|
2166
|
-
});
|
|
2167
|
-
const bySlug = /* @__PURE__ */ new Map();
|
|
2168
|
-
for (const rel of files) {
|
|
2169
|
-
const slug = toKebabCase(basename4(rel, ".md"));
|
|
2170
|
-
if (slug.length === 0) continue;
|
|
2171
|
-
const list = bySlug.get(slug) ?? [];
|
|
2172
|
-
list.push(rel);
|
|
2173
|
-
bySlug.set(slug, list);
|
|
2430
|
+
stepActive(out, `Claude auth: ${DIM}not signed in${RST}`);
|
|
2431
|
+
for (const line of UNAUTHENTICATED_MESSAGE.split("\n")) {
|
|
2432
|
+
out.write(` ${DIM}\u2502 ${line}${RST}
|
|
2433
|
+
`);
|
|
2174
2434
|
}
|
|
2175
|
-
|
|
2176
|
-
|
|
2177
|
-
|
|
2178
|
-
|
|
2435
|
+
}
|
|
2436
|
+
async function installGuides(options) {
|
|
2437
|
+
await mkdir5(options.claudeDir, { recursive: true });
|
|
2438
|
+
const srcMini = path3.join(options.guidesDir, "mini.md");
|
|
2439
|
+
const srcRef = path3.join(options.guidesDir, "reference.md");
|
|
2440
|
+
if (!existsSync11(srcMini)) {
|
|
2441
|
+
throw new Error(`missing bundled guide: ${srcMini}`);
|
|
2442
|
+
}
|
|
2443
|
+
if (!existsSync11(srcRef)) {
|
|
2444
|
+
throw new Error(`missing bundled guide: ${srcRef}`);
|
|
2445
|
+
}
|
|
2446
|
+
const destMini = path3.join(options.claudeDir, "codealmanac.md");
|
|
2447
|
+
const destRef = path3.join(options.claudeDir, "codealmanac-reference.md");
|
|
2448
|
+
const miniChanged = await copyIfChanged(srcMini, destMini);
|
|
2449
|
+
const refChanged = await copyIfChanged(srcRef, destRef);
|
|
2450
|
+
const claudeMd = path3.join(options.claudeDir, "CLAUDE.md");
|
|
2451
|
+
const importChanged = await ensureImport(claudeMd);
|
|
2452
|
+
const filesWritten = [];
|
|
2453
|
+
if (miniChanged) filesWritten.push("codealmanac.md");
|
|
2454
|
+
if (refChanged) filesWritten.push("codealmanac-reference.md");
|
|
2455
|
+
if (importChanged) filesWritten.push("CLAUDE.md");
|
|
2456
|
+
return { anyChanges: filesWritten.length > 0, filesWritten };
|
|
2457
|
+
}
|
|
2458
|
+
async function copyIfChanged(src, dest) {
|
|
2459
|
+
const srcBytes = await readFile9(src);
|
|
2460
|
+
if (existsSync11(dest)) {
|
|
2461
|
+
try {
|
|
2462
|
+
const destBytes = await readFile9(dest);
|
|
2463
|
+
if (srcBytes.equals(destBytes)) return false;
|
|
2464
|
+
} catch {
|
|
2179
2465
|
}
|
|
2180
2466
|
}
|
|
2181
|
-
|
|
2182
|
-
return
|
|
2467
|
+
await copyFile(src, dest);
|
|
2468
|
+
return true;
|
|
2183
2469
|
}
|
|
2184
|
-
|
|
2185
|
-
|
|
2186
|
-
|
|
2187
|
-
|
|
2188
|
-
|
|
2189
|
-
|
|
2190
|
-
|
|
2191
|
-
|
|
2192
|
-
|
|
2193
|
-
|
|
2194
|
-
|
|
2195
|
-
|
|
2196
|
-
|
|
2197
|
-
|
|
2198
|
-
|
|
2199
|
-
)
|
|
2200
|
-
|
|
2201
|
-
|
|
2202
|
-
|
|
2203
|
-
|
|
2204
|
-
|
|
2205
|
-
|
|
2206
|
-
|
|
2207
|
-
|
|
2208
|
-
|
|
2209
|
-
|
|
2210
|
-
|
|
2211
|
-
|
|
2212
|
-
|
|
2213
|
-
)
|
|
2214
|
-
|
|
2215
|
-
|
|
2216
|
-
|
|
2217
|
-
|
|
2218
|
-
"
|
|
2219
|
-
|
|
2220
|
-
|
|
2221
|
-
|
|
2222
|
-
|
|
2223
|
-
|
|
2470
|
+
var IMPORT_LINE = "@~/.claude/codealmanac.md";
|
|
2471
|
+
async function ensureImport(claudeMdPath) {
|
|
2472
|
+
let existing = "";
|
|
2473
|
+
if (existsSync11(claudeMdPath)) {
|
|
2474
|
+
existing = await readFile9(claudeMdPath, "utf8");
|
|
2475
|
+
}
|
|
2476
|
+
if (hasImportLine(existing)) return false;
|
|
2477
|
+
const sep = existing.length === 0 ? "" : existing.endsWith("\n") ? "\n" : "\n\n";
|
|
2478
|
+
const body = `${existing}${sep}${IMPORT_LINE}
|
|
2479
|
+
`;
|
|
2480
|
+
await writeFile5(claudeMdPath, body, "utf8");
|
|
2481
|
+
return true;
|
|
2482
|
+
}
|
|
2483
|
+
function hasImportLine(contents) {
|
|
2484
|
+
const lines = contents.split(/\r?\n/).map((l) => l.trim());
|
|
2485
|
+
return lines.some((line) => {
|
|
2486
|
+
if (line === IMPORT_LINE) return true;
|
|
2487
|
+
if (!line.startsWith(IMPORT_LINE)) return false;
|
|
2488
|
+
const next = line[IMPORT_LINE.length];
|
|
2489
|
+
return next === " " || next === " ";
|
|
2490
|
+
});
|
|
2491
|
+
}
|
|
2492
|
+
function confirm(out, question, defaultYes) {
|
|
2493
|
+
return new Promise((resolve2) => {
|
|
2494
|
+
const hint = defaultYes ? "[Y/n]" : "[y/N]";
|
|
2495
|
+
out.write(` ${BLUE}\u25C6${RST} ${question} ${DIM}${hint}${RST} `);
|
|
2496
|
+
let buf = "";
|
|
2497
|
+
const onData = (chunk) => {
|
|
2498
|
+
buf += chunk.toString("utf8");
|
|
2499
|
+
const nl = buf.indexOf("\n");
|
|
2500
|
+
if (nl === -1) return;
|
|
2501
|
+
process.stdin.removeListener("data", onData);
|
|
2502
|
+
process.stdin.pause();
|
|
2503
|
+
const answer = buf.slice(0, nl).trim().toLowerCase();
|
|
2504
|
+
const accepted = answer.length === 0 ? defaultYes : answer === "y" || answer === "yes";
|
|
2505
|
+
resolve2(accepted ? "install" : "skip");
|
|
2506
|
+
};
|
|
2507
|
+
process.stdin.resume();
|
|
2508
|
+
process.stdin.on("data", onData);
|
|
2509
|
+
});
|
|
2510
|
+
}
|
|
2511
|
+
function printNextSteps(out) {
|
|
2512
|
+
const innerW = 62;
|
|
2513
|
+
const vis = (s) => s.replace(/\x1b\[[0-9;]*m/g, "").length;
|
|
2514
|
+
const row = (content) => {
|
|
2515
|
+
const padding = Math.max(0, innerW - vis(content));
|
|
2516
|
+
return ` ${BLUE_DIM}\u2502${RST}${content}${" ".repeat(padding)}${BLUE_DIM}\u2502${RST}
|
|
2517
|
+
`;
|
|
2518
|
+
};
|
|
2519
|
+
const empty = row("");
|
|
2520
|
+
out.write(` ${BLUE_DIM}\u256D${"\u2500".repeat(innerW)}\u256E${RST}
|
|
2521
|
+
`);
|
|
2522
|
+
out.write(empty);
|
|
2523
|
+
out.write(row(` ${WHITE_BOLD}Next steps${RST}`));
|
|
2524
|
+
out.write(empty);
|
|
2525
|
+
out.write(
|
|
2526
|
+
row(` ${BLUE}1.${RST} ${BOLD}cd${RST} into a repo you want to document`)
|
|
2224
2527
|
);
|
|
2225
|
-
|
|
2226
|
-
|
|
2227
|
-
|
|
2228
|
-
r.empty_topics.length,
|
|
2229
|
-
r.empty_topics.map((e) => ` ${e.slug}`)
|
|
2528
|
+
out.write(
|
|
2529
|
+
row(
|
|
2530
|
+
` ${BLUE}2.${RST} ${BOLD}almanac bootstrap${RST} ${DIM}# scaffold the wiki${RST}`
|
|
2230
2531
|
)
|
|
2231
2532
|
);
|
|
2232
|
-
|
|
2233
|
-
|
|
2234
|
-
|
|
2235
|
-
r.empty_pages.length,
|
|
2236
|
-
r.empty_pages.map((e) => ` ${e.slug}`)
|
|
2533
|
+
out.write(
|
|
2534
|
+
row(
|
|
2535
|
+
` ${BLUE}3.${RST} Work normally \u2014 capture runs on session end`
|
|
2237
2536
|
)
|
|
2238
2537
|
);
|
|
2239
|
-
|
|
2240
|
-
|
|
2241
|
-
|
|
2242
|
-
|
|
2243
|
-
|
|
2244
|
-
|
|
2538
|
+
out.write(empty);
|
|
2539
|
+
out.write(` ${BLUE_DIM}\u2570${"\u2500".repeat(innerW)}\u256F${RST}
|
|
2540
|
+
|
|
2541
|
+
`);
|
|
2542
|
+
}
|
|
2543
|
+
function resolveGuidesDir() {
|
|
2544
|
+
const here = path3.dirname(fileURLToPath3(import.meta.url));
|
|
2545
|
+
const candidates = [
|
|
2546
|
+
path3.resolve(here, "..", "guides"),
|
|
2547
|
+
// dist layout
|
|
2548
|
+
path3.resolve(here, "..", "..", "guides"),
|
|
2549
|
+
// src layout
|
|
2550
|
+
path3.resolve(here, "..", "..", "..", "guides")
|
|
2551
|
+
];
|
|
2552
|
+
for (const dir of candidates) {
|
|
2553
|
+
if (looksLikeGuidesDir(dir)) return dir;
|
|
2554
|
+
}
|
|
2555
|
+
try {
|
|
2556
|
+
const require2 = createRequire2(import.meta.url);
|
|
2557
|
+
const pkgJson = require2.resolve("codealmanac/package.json");
|
|
2558
|
+
const guides = path3.join(path3.dirname(pkgJson), "guides");
|
|
2559
|
+
if (looksLikeGuidesDir(guides)) return guides;
|
|
2560
|
+
} catch {
|
|
2561
|
+
}
|
|
2562
|
+
throw new Error(
|
|
2563
|
+
"could not locate bundled guides/ directory. Tried:\n" + candidates.map((c) => ` - ${c}`).join("\n")
|
|
2245
2564
|
);
|
|
2246
|
-
return `${sections.join("\n\n")}
|
|
2247
|
-
`;
|
|
2248
2565
|
}
|
|
2249
|
-
function
|
|
2250
|
-
|
|
2251
|
-
return `${label} (${count}):
|
|
2252
|
-
${lines.join("\n")}`;
|
|
2566
|
+
function looksLikeGuidesDir(dir) {
|
|
2567
|
+
return existsSync11(path3.join(dir, "mini.md"));
|
|
2253
2568
|
}
|
|
2254
2569
|
|
|
2255
|
-
// src/commands/
|
|
2256
|
-
|
|
2257
|
-
|
|
2258
|
-
|
|
2259
|
-
|
|
2260
|
-
|
|
2570
|
+
// src/commands/doctor.ts
|
|
2571
|
+
var RST2 = "\x1B[0m";
|
|
2572
|
+
var BOLD2 = "\x1B[1m";
|
|
2573
|
+
var DIM2 = "\x1B[2m";
|
|
2574
|
+
var GREEN = "\x1B[38;5;35m";
|
|
2575
|
+
var RED = "\x1B[38;5;167m";
|
|
2576
|
+
var BLUE2 = "\x1B[38;5;75m";
|
|
2577
|
+
async function runDoctor(options) {
|
|
2578
|
+
const version = options.versionOverride ?? readPackageVersion() ?? "unknown";
|
|
2579
|
+
const install = options.wikiOnly === true ? [] : await gatherInstallChecks(options);
|
|
2580
|
+
const wiki = options.installOnly === true ? [] : await gatherWikiChecks(options);
|
|
2581
|
+
const report = { version, install, wiki };
|
|
2582
|
+
if (options.json === true) {
|
|
2583
|
+
return {
|
|
2584
|
+
stdout: `${JSON.stringify(report, null, 2)}
|
|
2585
|
+
`,
|
|
2586
|
+
stderr: "",
|
|
2587
|
+
exitCode: 0
|
|
2588
|
+
};
|
|
2589
|
+
}
|
|
2590
|
+
return {
|
|
2591
|
+
stdout: formatReport2(report, options),
|
|
2592
|
+
stderr: "",
|
|
2593
|
+
exitCode: 0
|
|
2594
|
+
};
|
|
2595
|
+
}
|
|
2596
|
+
async function gatherInstallChecks(options) {
|
|
2597
|
+
const checks = [];
|
|
2598
|
+
const installPath = options.installPath ?? detectInstallPath();
|
|
2599
|
+
checks.push({
|
|
2600
|
+
status: installPath !== null ? "ok" : "problem",
|
|
2601
|
+
key: "install.path",
|
|
2602
|
+
message: installPath !== null ? `codealmanac installed at ${installPath}` : "could not detect codealmanac install path",
|
|
2603
|
+
fix: installPath === null ? "reinstall with: npm install -g codealmanac" : void 0
|
|
2261
2604
|
});
|
|
2262
|
-
|
|
2263
|
-
const
|
|
2264
|
-
|
|
2605
|
+
const nodeVersion = options.nodeVersion ?? process.version;
|
|
2606
|
+
const sqlite = options.sqliteProbe ?? probeBetterSqlite3();
|
|
2607
|
+
checks.push({
|
|
2608
|
+
status: sqlite.ok ? "ok" : "problem",
|
|
2609
|
+
key: "install.sqlite",
|
|
2610
|
+
message: sqlite.ok ? `better-sqlite3 native binding OK (Node ${nodeVersion})` : `better-sqlite3 native binding failed: ${sqlite.summary}`,
|
|
2611
|
+
fix: sqlite.ok ? void 0 : "run: npm rebuild better-sqlite3 (in the install directory)"
|
|
2612
|
+
});
|
|
2613
|
+
const auth = await safeCheckAuth2(options.spawnCli);
|
|
2614
|
+
checks.push(describeAuth(auth));
|
|
2615
|
+
const settingsPath = options.settingsPath ?? path4.join(homedir5(), ".claude", "settings.json");
|
|
2616
|
+
checks.push(await describeHook(settingsPath));
|
|
2617
|
+
const claudeDir = options.claudeDir ?? path4.join(homedir5(), ".claude");
|
|
2618
|
+
checks.push(describeGuides(claudeDir));
|
|
2619
|
+
checks.push(await describeImportLine(claudeDir));
|
|
2620
|
+
return checks;
|
|
2621
|
+
}
|
|
2622
|
+
function describeAuth(auth) {
|
|
2623
|
+
if (auth.loggedIn) {
|
|
2624
|
+
if (auth.authMethod === "apiKey") {
|
|
2625
|
+
return {
|
|
2626
|
+
status: "ok",
|
|
2627
|
+
key: "install.auth",
|
|
2628
|
+
message: "claude auth: ANTHROPIC_API_KEY set"
|
|
2629
|
+
};
|
|
2630
|
+
}
|
|
2631
|
+
const who = auth.email ?? "Claude account";
|
|
2632
|
+
const plan = auth.subscriptionType !== void 0 ? ` (${auth.subscriptionType} subscription)` : "";
|
|
2633
|
+
return {
|
|
2634
|
+
status: "ok",
|
|
2635
|
+
key: "install.auth",
|
|
2636
|
+
message: `claude auth: ${who}${plan}`
|
|
2637
|
+
};
|
|
2638
|
+
}
|
|
2639
|
+
if (process.env.ANTHROPIC_API_KEY !== void 0 && process.env.ANTHROPIC_API_KEY.length > 0) {
|
|
2640
|
+
return {
|
|
2641
|
+
status: "ok",
|
|
2642
|
+
key: "install.auth",
|
|
2643
|
+
message: "claude auth: ANTHROPIC_API_KEY set"
|
|
2644
|
+
};
|
|
2645
|
+
}
|
|
2646
|
+
return {
|
|
2647
|
+
status: "problem",
|
|
2648
|
+
key: "install.auth",
|
|
2649
|
+
message: "claude auth: not signed in",
|
|
2650
|
+
fix: "run: claude auth login --claudeai (or export ANTHROPIC_API_KEY)"
|
|
2651
|
+
};
|
|
2652
|
+
}
|
|
2653
|
+
async function describeHook(settingsPath) {
|
|
2654
|
+
if (!existsSync12(settingsPath)) {
|
|
2655
|
+
return {
|
|
2656
|
+
status: "problem",
|
|
2657
|
+
key: "install.hook",
|
|
2658
|
+
message: "SessionEnd hook not installed",
|
|
2659
|
+
fix: "run: almanac setup --yes"
|
|
2660
|
+
};
|
|
2661
|
+
}
|
|
2265
2662
|
try {
|
|
2266
|
-
const
|
|
2267
|
-
|
|
2663
|
+
const raw = await readFile10(settingsPath, "utf8");
|
|
2664
|
+
const parsed = JSON.parse(raw);
|
|
2665
|
+
const entries = parsed.hooks?.SessionEnd ?? [];
|
|
2666
|
+
const ours = entries.find(
|
|
2667
|
+
(e) => typeof e.command === "string" && e.command.endsWith("almanac-capture.sh")
|
|
2668
|
+
);
|
|
2669
|
+
if (ours === void 0) {
|
|
2268
2670
|
return {
|
|
2269
|
-
|
|
2270
|
-
|
|
2271
|
-
|
|
2671
|
+
status: "problem",
|
|
2672
|
+
key: "install.hook",
|
|
2673
|
+
message: "SessionEnd hook not installed",
|
|
2674
|
+
fix: "run: almanac setup --yes"
|
|
2272
2675
|
};
|
|
2273
2676
|
}
|
|
2274
|
-
|
|
2275
|
-
|
|
2276
|
-
|
|
2277
|
-
|
|
2278
|
-
|
|
2279
|
-
|
|
2280
|
-
|
|
2281
|
-
|
|
2282
|
-
|
|
2677
|
+
return {
|
|
2678
|
+
status: "ok",
|
|
2679
|
+
key: "install.hook",
|
|
2680
|
+
message: `SessionEnd hook installed at ${settingsPath}`
|
|
2681
|
+
};
|
|
2682
|
+
} catch (err) {
|
|
2683
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
2684
|
+
return {
|
|
2685
|
+
status: "problem",
|
|
2686
|
+
key: "install.hook",
|
|
2687
|
+
message: `could not read ${settingsPath}: ${msg}`,
|
|
2688
|
+
fix: "check the file for malformed JSON"
|
|
2689
|
+
};
|
|
2690
|
+
}
|
|
2691
|
+
}
|
|
2692
|
+
function describeGuides(claudeDir) {
|
|
2693
|
+
const mini = path4.join(claudeDir, "codealmanac.md");
|
|
2694
|
+
const ref = path4.join(claudeDir, "codealmanac-reference.md");
|
|
2695
|
+
const haveMini = existsSync12(mini);
|
|
2696
|
+
const haveRef = existsSync12(ref);
|
|
2697
|
+
if (haveMini && haveRef) {
|
|
2698
|
+
return {
|
|
2699
|
+
status: "ok",
|
|
2700
|
+
key: "install.guides",
|
|
2701
|
+
message: `Agent guides installed (${path4.basename(mini)}, ${path4.basename(ref)})`
|
|
2702
|
+
};
|
|
2703
|
+
}
|
|
2704
|
+
const missing = [
|
|
2705
|
+
haveMini ? null : "codealmanac.md",
|
|
2706
|
+
haveRef ? null : "codealmanac-reference.md"
|
|
2707
|
+
].filter((s) => s !== null);
|
|
2708
|
+
return {
|
|
2709
|
+
status: "problem",
|
|
2710
|
+
key: "install.guides",
|
|
2711
|
+
message: `Agent guides missing (${missing.join(", ")})`,
|
|
2712
|
+
fix: "run: almanac setup --yes"
|
|
2713
|
+
};
|
|
2714
|
+
}
|
|
2715
|
+
async function describeImportLine(claudeDir) {
|
|
2716
|
+
const claudeMd = path4.join(claudeDir, "CLAUDE.md");
|
|
2717
|
+
if (!existsSync12(claudeMd)) {
|
|
2718
|
+
return {
|
|
2719
|
+
status: "problem",
|
|
2720
|
+
key: "install.import",
|
|
2721
|
+
message: "CLAUDE.md import not present (no ~/.claude/CLAUDE.md)",
|
|
2722
|
+
fix: "run: almanac setup --yes"
|
|
2723
|
+
};
|
|
2724
|
+
}
|
|
2725
|
+
try {
|
|
2726
|
+
const contents = await readFile10(claudeMd, "utf8");
|
|
2727
|
+
const lines = contents.split(/\r?\n/).map((l) => l.trim());
|
|
2728
|
+
const present = lines.some((line) => {
|
|
2729
|
+
if (line === IMPORT_LINE) return true;
|
|
2730
|
+
if (!line.startsWith(IMPORT_LINE)) return false;
|
|
2731
|
+
const next = line[IMPORT_LINE.length];
|
|
2732
|
+
return next === " " || next === " ";
|
|
2733
|
+
});
|
|
2734
|
+
if (present) {
|
|
2735
|
+
return {
|
|
2736
|
+
status: "ok",
|
|
2737
|
+
key: "install.import",
|
|
2738
|
+
message: "CLAUDE.md import present"
|
|
2739
|
+
};
|
|
2283
2740
|
}
|
|
2284
|
-
|
|
2285
|
-
|
|
2286
|
-
|
|
2287
|
-
|
|
2288
|
-
|
|
2289
|
-
|
|
2290
|
-
|
|
2291
|
-
|
|
2292
|
-
|
|
2293
|
-
|
|
2294
|
-
|
|
2741
|
+
return {
|
|
2742
|
+
status: "problem",
|
|
2743
|
+
key: "install.import",
|
|
2744
|
+
message: "CLAUDE.md import line missing",
|
|
2745
|
+
fix: "run: almanac setup --yes"
|
|
2746
|
+
};
|
|
2747
|
+
} catch (err) {
|
|
2748
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
2749
|
+
return {
|
|
2750
|
+
status: "problem",
|
|
2751
|
+
key: "install.import",
|
|
2752
|
+
message: `could not read ${claudeMd}: ${msg}`
|
|
2753
|
+
};
|
|
2754
|
+
}
|
|
2755
|
+
}
|
|
2756
|
+
async function gatherWikiChecks(options) {
|
|
2757
|
+
const checks = [];
|
|
2758
|
+
const repoRoot = findNearestAlmanacDir(options.cwd);
|
|
2759
|
+
if (repoRoot === null) {
|
|
2760
|
+
checks.push({
|
|
2761
|
+
status: "info",
|
|
2762
|
+
key: "wiki.none",
|
|
2763
|
+
message: "No wiki in current directory",
|
|
2764
|
+
fix: "run: almanac bootstrap (to create one in this repo)"
|
|
2765
|
+
});
|
|
2766
|
+
return checks;
|
|
2767
|
+
}
|
|
2768
|
+
checks.push({
|
|
2769
|
+
status: "info",
|
|
2770
|
+
key: "wiki.repo",
|
|
2771
|
+
message: `repo: ${repoRoot}`
|
|
2772
|
+
});
|
|
2773
|
+
const entry = await findEntry({ path: repoRoot });
|
|
2774
|
+
if (entry !== null) {
|
|
2775
|
+
checks.push({
|
|
2776
|
+
status: "ok",
|
|
2777
|
+
key: "wiki.registered",
|
|
2778
|
+
message: `registered as '${entry.name}'`
|
|
2779
|
+
});
|
|
2780
|
+
} else {
|
|
2781
|
+
checks.push({
|
|
2782
|
+
status: "info",
|
|
2783
|
+
key: "wiki.registered",
|
|
2784
|
+
message: "not yet registered (will register on first command)"
|
|
2785
|
+
});
|
|
2786
|
+
}
|
|
2787
|
+
const almanacDir = path4.join(repoRoot, ".almanac");
|
|
2788
|
+
const dbPath = path4.join(almanacDir, "index.db");
|
|
2789
|
+
let pageCount = null;
|
|
2790
|
+
let topicCount = null;
|
|
2791
|
+
if (existsSync12(dbPath)) {
|
|
2792
|
+
try {
|
|
2793
|
+
const db = openIndex(dbPath);
|
|
2794
|
+
try {
|
|
2795
|
+
pageCount = countRows(db, "pages");
|
|
2796
|
+
topicCount = countRows(db, "topics");
|
|
2797
|
+
} finally {
|
|
2798
|
+
db.close();
|
|
2295
2799
|
}
|
|
2800
|
+
} catch {
|
|
2801
|
+
pageCount = null;
|
|
2802
|
+
}
|
|
2803
|
+
}
|
|
2804
|
+
if (pageCount !== null) {
|
|
2805
|
+
checks.push({
|
|
2806
|
+
status: "info",
|
|
2807
|
+
key: "wiki.pages",
|
|
2808
|
+
message: `pages: ${pageCount}`
|
|
2809
|
+
});
|
|
2810
|
+
}
|
|
2811
|
+
if (topicCount !== null) {
|
|
2812
|
+
checks.push({
|
|
2813
|
+
status: "info",
|
|
2814
|
+
key: "wiki.topics",
|
|
2815
|
+
message: `topics: ${topicCount}`
|
|
2816
|
+
});
|
|
2817
|
+
}
|
|
2818
|
+
checks.push(describeIndexFreshness(dbPath));
|
|
2819
|
+
checks.push(describeLastCapture(almanacDir, options.now));
|
|
2820
|
+
const healthFn = options.runHealthFn ?? runHealth;
|
|
2821
|
+
try {
|
|
2822
|
+
const healthRes = await healthFn({
|
|
2823
|
+
cwd: repoRoot,
|
|
2824
|
+
json: true
|
|
2825
|
+
});
|
|
2826
|
+
const problems = countHealthProblems(healthRes.stdout);
|
|
2827
|
+
if (problems === 0) {
|
|
2828
|
+
checks.push({
|
|
2829
|
+
status: "ok",
|
|
2830
|
+
key: "wiki.health",
|
|
2831
|
+
message: "almanac health reports 0 problems"
|
|
2832
|
+
});
|
|
2296
2833
|
} else {
|
|
2297
|
-
|
|
2834
|
+
checks.push({
|
|
2835
|
+
status: "problem",
|
|
2836
|
+
key: "wiki.health",
|
|
2837
|
+
message: `almanac health reports ${problems} problem${problems === 1 ? "" : "s"}`,
|
|
2838
|
+
fix: "run: almanac health"
|
|
2839
|
+
});
|
|
2298
2840
|
}
|
|
2299
|
-
|
|
2300
|
-
|
|
2841
|
+
} catch (err) {
|
|
2842
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
2843
|
+
checks.push({
|
|
2844
|
+
status: "info",
|
|
2845
|
+
key: "wiki.health",
|
|
2846
|
+
message: `could not run almanac health: ${msg}`
|
|
2847
|
+
});
|
|
2848
|
+
}
|
|
2849
|
+
return checks;
|
|
2850
|
+
}
|
|
2851
|
+
function countRows(db, table) {
|
|
2852
|
+
const row = db.prepare(`SELECT COUNT(*) AS n FROM ${table}`).get();
|
|
2853
|
+
return row?.n ?? 0;
|
|
2854
|
+
}
|
|
2855
|
+
function describeIndexFreshness(dbPath) {
|
|
2856
|
+
if (!existsSync12(dbPath)) {
|
|
2301
2857
|
return {
|
|
2302
|
-
|
|
2303
|
-
|
|
2304
|
-
|
|
2858
|
+
status: "info",
|
|
2859
|
+
key: "wiki.index",
|
|
2860
|
+
message: "index: not built yet (run any query command)"
|
|
2861
|
+
};
|
|
2862
|
+
}
|
|
2863
|
+
try {
|
|
2864
|
+
const dbMtime = statSync3(dbPath).mtimeMs;
|
|
2865
|
+
const age = Date.now() - dbMtime;
|
|
2866
|
+
return {
|
|
2867
|
+
status: "info",
|
|
2868
|
+
key: "wiki.index",
|
|
2869
|
+
message: `index: rebuilt ${formatDuration(age)} ago`
|
|
2870
|
+
};
|
|
2871
|
+
} catch {
|
|
2872
|
+
return {
|
|
2873
|
+
status: "info",
|
|
2874
|
+
key: "wiki.index",
|
|
2875
|
+
message: "index: present"
|
|
2876
|
+
};
|
|
2877
|
+
}
|
|
2878
|
+
}
|
|
2879
|
+
function describeLastCapture(almanacDir, nowFn) {
|
|
2880
|
+
if (!existsSync12(almanacDir)) {
|
|
2881
|
+
return {
|
|
2882
|
+
status: "info",
|
|
2883
|
+
key: "wiki.capture",
|
|
2884
|
+
message: "last capture: never"
|
|
2885
|
+
};
|
|
2886
|
+
}
|
|
2887
|
+
let entries;
|
|
2888
|
+
try {
|
|
2889
|
+
entries = readdirSync(almanacDir);
|
|
2890
|
+
} catch {
|
|
2891
|
+
return {
|
|
2892
|
+
status: "info",
|
|
2893
|
+
key: "wiki.capture",
|
|
2894
|
+
message: "last capture: unknown"
|
|
2895
|
+
};
|
|
2896
|
+
}
|
|
2897
|
+
const captures = entries.filter((e) => e.startsWith(".capture-") && e.endsWith(".log")).map((e) => {
|
|
2898
|
+
try {
|
|
2899
|
+
return {
|
|
2900
|
+
name: e,
|
|
2901
|
+
mtime: statSync3(path4.join(almanacDir, e)).mtimeMs
|
|
2902
|
+
};
|
|
2903
|
+
} catch {
|
|
2904
|
+
return null;
|
|
2905
|
+
}
|
|
2906
|
+
}).filter((e) => e !== null);
|
|
2907
|
+
if (captures.length === 0) {
|
|
2908
|
+
return {
|
|
2909
|
+
status: "info",
|
|
2910
|
+
key: "wiki.capture",
|
|
2911
|
+
message: "last capture: never"
|
|
2305
2912
|
};
|
|
2306
|
-
} finally {
|
|
2307
|
-
db.close();
|
|
2308
2913
|
}
|
|
2309
|
-
|
|
2310
|
-
|
|
2311
|
-
const
|
|
2312
|
-
|
|
2313
|
-
).get(slug);
|
|
2314
|
-
if (pageRow === void 0) return null;
|
|
2315
|
-
const topics = db.prepare(
|
|
2316
|
-
"SELECT topic_slug FROM page_topics WHERE page_slug = ? ORDER BY topic_slug"
|
|
2317
|
-
).all(slug).map((r) => r.topic_slug);
|
|
2318
|
-
const refs = db.prepare(
|
|
2319
|
-
// Display the author's casing (`original_path`), not the
|
|
2320
|
-
// lowercased lookup form. The lowercased `path` column is the
|
|
2321
|
-
// query key for `--mentions`; it's not a user-facing string.
|
|
2322
|
-
"SELECT original_path, is_dir FROM file_refs WHERE page_slug = ? ORDER BY original_path"
|
|
2323
|
-
).all(slug).map((r) => ({ path: r.original_path, is_dir: r.is_dir === 1 }));
|
|
2324
|
-
const linksOut = db.prepare(
|
|
2325
|
-
"SELECT target_slug FROM wikilinks WHERE source_slug = ? ORDER BY target_slug"
|
|
2326
|
-
).all(slug).map((r) => r.target_slug);
|
|
2327
|
-
const linksIn = db.prepare(
|
|
2328
|
-
"SELECT source_slug FROM wikilinks WHERE target_slug = ? ORDER BY source_slug"
|
|
2329
|
-
).all(slug).map((r) => r.source_slug);
|
|
2330
|
-
const xwiki = db.prepare(
|
|
2331
|
-
"SELECT target_wiki, target_slug FROM cross_wiki_links WHERE source_slug = ? ORDER BY target_wiki, target_slug"
|
|
2332
|
-
).all(slug).map((r) => ({ wiki: r.target_wiki, target: r.target_slug }));
|
|
2333
|
-
const supersedesRows = db.prepare(
|
|
2334
|
-
"SELECT slug FROM pages WHERE superseded_by = ? ORDER BY slug"
|
|
2335
|
-
).all(slug).map((r) => r.slug);
|
|
2914
|
+
captures.sort((a, b) => b.mtime - a.mtime);
|
|
2915
|
+
const latest = captures[0];
|
|
2916
|
+
const now = (nowFn?.() ?? /* @__PURE__ */ new Date()).getTime();
|
|
2917
|
+
const age = now - latest.mtime;
|
|
2336
2918
|
return {
|
|
2337
|
-
|
|
2338
|
-
|
|
2339
|
-
|
|
2340
|
-
updated_at: pageRow.updated_at,
|
|
2341
|
-
archived_at: pageRow.archived_at,
|
|
2342
|
-
superseded_by: pageRow.superseded_by,
|
|
2343
|
-
supersedes: supersedesRows,
|
|
2344
|
-
topics,
|
|
2345
|
-
file_refs: refs,
|
|
2346
|
-
wikilinks_out: linksOut,
|
|
2347
|
-
wikilinks_in: linksIn,
|
|
2348
|
-
cross_wiki_links: xwiki
|
|
2919
|
+
status: "info",
|
|
2920
|
+
key: "wiki.capture",
|
|
2921
|
+
message: `last capture: ${formatDuration(age)} ago (${latest.name})`
|
|
2349
2922
|
};
|
|
2350
2923
|
}
|
|
2351
|
-
|
|
2352
|
-
|
|
2353
|
-
|
|
2924
|
+
var req = createRequire3(import.meta.url);
|
|
2925
|
+
function countHealthProblems(jsonStdout) {
|
|
2926
|
+
try {
|
|
2927
|
+
const report = JSON.parse(jsonStdout);
|
|
2928
|
+
let total = 0;
|
|
2929
|
+
for (const arr of Object.values(report)) {
|
|
2930
|
+
if (Array.isArray(arr)) total += arr.length;
|
|
2931
|
+
}
|
|
2932
|
+
return total;
|
|
2933
|
+
} catch {
|
|
2934
|
+
return 0;
|
|
2354
2935
|
}
|
|
2355
|
-
|
|
2356
|
-
|
|
2936
|
+
}
|
|
2937
|
+
function detectInstallPath() {
|
|
2938
|
+
try {
|
|
2939
|
+
const entry = req.resolve("codealmanac");
|
|
2940
|
+
return path4.dirname(path4.dirname(entry));
|
|
2941
|
+
} catch {
|
|
2942
|
+
return null;
|
|
2357
2943
|
}
|
|
2358
|
-
return [];
|
|
2359
2944
|
}
|
|
2360
|
-
function
|
|
2361
|
-
|
|
2362
|
-
|
|
2363
|
-
|
|
2364
|
-
|
|
2365
|
-
|
|
2366
|
-
|
|
2367
|
-
|
|
2368
|
-
|
|
2369
|
-
|
|
2945
|
+
function probeBetterSqlite3() {
|
|
2946
|
+
try {
|
|
2947
|
+
const Database2 = req("better-sqlite3");
|
|
2948
|
+
const db = new Database2(":memory:");
|
|
2949
|
+
db.close();
|
|
2950
|
+
return { ok: true, summary: "native binding loads cleanly" };
|
|
2951
|
+
} catch (err) {
|
|
2952
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
2953
|
+
const firstLine = msg.split("\n")[0] ?? msg;
|
|
2954
|
+
return { ok: false, summary: firstLine };
|
|
2370
2955
|
}
|
|
2371
|
-
|
|
2372
|
-
|
|
2956
|
+
}
|
|
2957
|
+
async function safeCheckAuth2(spawnCli) {
|
|
2958
|
+
try {
|
|
2959
|
+
return await checkClaudeAuth(spawnCli);
|
|
2960
|
+
} catch {
|
|
2961
|
+
return { loggedIn: false };
|
|
2373
2962
|
}
|
|
2374
|
-
|
|
2375
|
-
|
|
2963
|
+
}
|
|
2964
|
+
function readPackageVersion() {
|
|
2965
|
+
try {
|
|
2966
|
+
const pkg = req("../../package.json");
|
|
2967
|
+
if (typeof pkg.version === "string" && pkg.version.length > 0) {
|
|
2968
|
+
return pkg.version;
|
|
2969
|
+
}
|
|
2970
|
+
} catch {
|
|
2376
2971
|
}
|
|
2377
|
-
|
|
2378
|
-
|
|
2379
|
-
|
|
2380
|
-
|
|
2381
|
-
} else {
|
|
2382
|
-
for (const r of rec.file_refs) {
|
|
2383
|
-
lines.push(` ${r.path}${r.is_dir ? " (dir)" : ""}`);
|
|
2972
|
+
try {
|
|
2973
|
+
const pkg = req("../package.json");
|
|
2974
|
+
if (typeof pkg.version === "string" && pkg.version.length > 0) {
|
|
2975
|
+
return pkg.version;
|
|
2384
2976
|
}
|
|
2977
|
+
} catch {
|
|
2385
2978
|
}
|
|
2386
|
-
|
|
2387
|
-
|
|
2388
|
-
|
|
2389
|
-
|
|
2390
|
-
|
|
2391
|
-
|
|
2392
|
-
|
|
2393
|
-
|
|
2394
|
-
|
|
2395
|
-
|
|
2979
|
+
return null;
|
|
2980
|
+
}
|
|
2981
|
+
function formatReport2(report, options) {
|
|
2982
|
+
const color = options.stdout === void 0 && process.stdout.isTTY === true;
|
|
2983
|
+
const lines = [];
|
|
2984
|
+
lines.push(`codealmanac v${report.version}`);
|
|
2985
|
+
lines.push("");
|
|
2986
|
+
if (report.install.length > 0) {
|
|
2987
|
+
lines.push(color ? `${BOLD2}## Install${RST2}` : "## Install");
|
|
2988
|
+
for (const c of report.install) {
|
|
2989
|
+
lines.push(formatCheck(c, color));
|
|
2990
|
+
}
|
|
2991
|
+
lines.push("");
|
|
2992
|
+
}
|
|
2993
|
+
if (report.wiki.length > 0) {
|
|
2994
|
+
lines.push(color ? `${BOLD2}## Current wiki${RST2}` : "## Current wiki");
|
|
2995
|
+
for (const c of report.wiki) {
|
|
2996
|
+
lines.push(formatCheck(c, color));
|
|
2396
2997
|
}
|
|
2998
|
+
lines.push("");
|
|
2397
2999
|
}
|
|
2398
3000
|
return `${lines.join("\n")}
|
|
2399
3001
|
`;
|
|
2400
3002
|
}
|
|
3003
|
+
function formatCheck(c, color) {
|
|
3004
|
+
const { icon, tint } = iconFor(c.status, color);
|
|
3005
|
+
const head = ` ${tint}${icon}${color ? RST2 : ""} ${c.message}`;
|
|
3006
|
+
if (c.fix === void 0) return head;
|
|
3007
|
+
const fixLine = color ? ` ${DIM2}${c.fix}${RST2}` : ` ${c.fix}`;
|
|
3008
|
+
return `${head}
|
|
3009
|
+
${fixLine}`;
|
|
3010
|
+
}
|
|
3011
|
+
function iconFor(status, color) {
|
|
3012
|
+
switch (status) {
|
|
3013
|
+
case "ok":
|
|
3014
|
+
return { icon: "\u2713", tint: color ? GREEN : "" };
|
|
3015
|
+
case "problem":
|
|
3016
|
+
return { icon: "\u2717", tint: color ? RED : "" };
|
|
3017
|
+
case "info":
|
|
3018
|
+
return { icon: "\u25C7", tint: color ? BLUE2 : "" };
|
|
3019
|
+
}
|
|
3020
|
+
}
|
|
3021
|
+
function formatDuration(ms) {
|
|
3022
|
+
if (ms < 0) return "just now";
|
|
3023
|
+
const seconds = Math.floor(ms / 1e3);
|
|
3024
|
+
if (seconds < 60) return `${seconds}s`;
|
|
3025
|
+
const minutes = Math.floor(seconds / 60);
|
|
3026
|
+
if (minutes < 60) return `${minutes}m`;
|
|
3027
|
+
const hours = Math.floor(minutes / 60);
|
|
3028
|
+
if (hours < 48) return `${hours}h`;
|
|
3029
|
+
const days = Math.floor(hours / 24);
|
|
3030
|
+
return `${days}d`;
|
|
3031
|
+
}
|
|
2401
3032
|
|
|
2402
3033
|
// src/commands/list.ts
|
|
2403
|
-
import { existsSync as
|
|
3034
|
+
import { existsSync as existsSync13 } from "fs";
|
|
2404
3035
|
async function listWikis(options) {
|
|
2405
3036
|
if (options.drop !== void 0) {
|
|
2406
3037
|
return handleDrop(options.drop);
|
|
@@ -2430,11 +3061,11 @@ async function handleDrop(name) {
|
|
|
2430
3061
|
}
|
|
2431
3062
|
function isReachable(entry) {
|
|
2432
3063
|
if (entry.path.length === 0) return false;
|
|
2433
|
-
return
|
|
3064
|
+
return existsSync13(entry.path);
|
|
2434
3065
|
}
|
|
2435
3066
|
function formatPretty(entries) {
|
|
2436
3067
|
if (entries.length === 0) {
|
|
2437
|
-
return "no wikis registered. run `almanac
|
|
3068
|
+
return "no wikis registered. run `almanac bootstrap` in a repo to create one.\n";
|
|
2438
3069
|
}
|
|
2439
3070
|
const nameWidth = Math.min(
|
|
2440
3071
|
30,
|
|
@@ -2451,61 +3082,6 @@ function formatPretty(entries) {
|
|
|
2451
3082
|
`;
|
|
2452
3083
|
}
|
|
2453
3084
|
|
|
2454
|
-
// src/commands/path.ts
|
|
2455
|
-
import { join as join10 } from "path";
|
|
2456
|
-
async function runPath(options) {
|
|
2457
|
-
const repoRoot = await resolveWikiRoot({
|
|
2458
|
-
cwd: options.cwd,
|
|
2459
|
-
wiki: options.wiki
|
|
2460
|
-
});
|
|
2461
|
-
await ensureFreshIndex({ repoRoot });
|
|
2462
|
-
const dbPath = join10(repoRoot, ".almanac", "index.db");
|
|
2463
|
-
const db = openIndex(dbPath);
|
|
2464
|
-
try {
|
|
2465
|
-
const slugs = collectSlugs2(options);
|
|
2466
|
-
if (slugs.length === 0) {
|
|
2467
|
-
return {
|
|
2468
|
-
stdout: "",
|
|
2469
|
-
stderr: "almanac: path requires a slug (or --stdin)\n",
|
|
2470
|
-
exitCode: 1
|
|
2471
|
-
};
|
|
2472
|
-
}
|
|
2473
|
-
const stmt = db.prepare(
|
|
2474
|
-
"SELECT file_path FROM pages WHERE slug = ?"
|
|
2475
|
-
);
|
|
2476
|
-
const resolved = [];
|
|
2477
|
-
const missing = [];
|
|
2478
|
-
for (const slug of slugs) {
|
|
2479
|
-
const row = stmt.get(slug);
|
|
2480
|
-
if (row === void 0) {
|
|
2481
|
-
missing.push(slug);
|
|
2482
|
-
continue;
|
|
2483
|
-
}
|
|
2484
|
-
resolved.push(row.file_path);
|
|
2485
|
-
}
|
|
2486
|
-
const stdout = resolved.length > 0 ? `${resolved.join("\n")}
|
|
2487
|
-
` : "";
|
|
2488
|
-
const stderr = missing.map((s) => `almanac: no such page "${s}"
|
|
2489
|
-
`).join("");
|
|
2490
|
-
return {
|
|
2491
|
-
stdout,
|
|
2492
|
-
stderr,
|
|
2493
|
-
exitCode: missing.length > 0 ? 1 : 0
|
|
2494
|
-
};
|
|
2495
|
-
} finally {
|
|
2496
|
-
db.close();
|
|
2497
|
-
}
|
|
2498
|
-
}
|
|
2499
|
-
function collectSlugs2(options) {
|
|
2500
|
-
if (options.stdin === true && options.stdinInput !== void 0) {
|
|
2501
|
-
return options.stdinInput.split(/\r?\n/).map((s) => s.trim()).filter((s) => s.length > 0);
|
|
2502
|
-
}
|
|
2503
|
-
if (options.slug !== void 0 && options.slug.length > 0) {
|
|
2504
|
-
return [options.slug];
|
|
2505
|
-
}
|
|
2506
|
-
return [];
|
|
2507
|
-
}
|
|
2508
|
-
|
|
2509
3085
|
// src/commands/reindex.ts
|
|
2510
3086
|
async function runReindex(options) {
|
|
2511
3087
|
const repoRoot = await resolveWikiRoot({
|
|
@@ -2520,14 +3096,14 @@ async function runReindex(options) {
|
|
|
2520
3096
|
}
|
|
2521
3097
|
|
|
2522
3098
|
// src/commands/search.ts
|
|
2523
|
-
import { join as
|
|
3099
|
+
import { join as join9 } from "path";
|
|
2524
3100
|
async function runSearch(options) {
|
|
2525
3101
|
const repoRoot = await resolveWikiRoot({
|
|
2526
3102
|
cwd: options.cwd,
|
|
2527
3103
|
wiki: options.wiki
|
|
2528
3104
|
});
|
|
2529
3105
|
await ensureFreshIndex({ repoRoot });
|
|
2530
|
-
const dbPath =
|
|
3106
|
+
const dbPath = join9(repoRoot, ".almanac", "index.db");
|
|
2531
3107
|
const db = openIndex(dbPath);
|
|
2532
3108
|
try {
|
|
2533
3109
|
const rows = executeQuery(db, options);
|
|
@@ -2687,6 +3263,9 @@ function formatResults(rows, options) {
|
|
|
2687
3263
|
}
|
|
2688
3264
|
function buildStderr(rows, options) {
|
|
2689
3265
|
if (options.json === true) return "";
|
|
3266
|
+
if (rows.length === 0) {
|
|
3267
|
+
return "# 0 results\n";
|
|
3268
|
+
}
|
|
2690
3269
|
if (options.limit !== void 0) return "";
|
|
2691
3270
|
if (rows.length > 50) {
|
|
2692
3271
|
return `almanac: ${rows.length} results \u2014 consider --limit or a narrower query
|
|
@@ -2696,18 +3275,18 @@ function buildStderr(rows, options) {
|
|
|
2696
3275
|
}
|
|
2697
3276
|
|
|
2698
3277
|
// src/commands/show.ts
|
|
2699
|
-
import { readFile as
|
|
2700
|
-
import { join as
|
|
3278
|
+
import { readFile as readFile11 } from "fs/promises";
|
|
3279
|
+
import { join as join10 } from "path";
|
|
2701
3280
|
async function runShow(options) {
|
|
2702
3281
|
const repoRoot = await resolveWikiRoot({
|
|
2703
3282
|
cwd: options.cwd,
|
|
2704
3283
|
wiki: options.wiki
|
|
2705
3284
|
});
|
|
2706
3285
|
await ensureFreshIndex({ repoRoot });
|
|
2707
|
-
const dbPath =
|
|
3286
|
+
const dbPath = join10(repoRoot, ".almanac", "index.db");
|
|
2708
3287
|
const db = openIndex(dbPath);
|
|
2709
3288
|
try {
|
|
2710
|
-
const slugs =
|
|
3289
|
+
const slugs = collectSlugs(options);
|
|
2711
3290
|
if (slugs.length === 0) {
|
|
2712
3291
|
return {
|
|
2713
3292
|
stdout: "",
|
|
@@ -2715,44 +3294,291 @@ async function runShow(options) {
|
|
|
2715
3294
|
exitCode: 1
|
|
2716
3295
|
};
|
|
2717
3296
|
}
|
|
2718
|
-
const
|
|
2719
|
-
|
|
3297
|
+
const records = [];
|
|
3298
|
+
const missing = [];
|
|
3299
|
+
for (const slug of slugs) {
|
|
3300
|
+
const rec = await fetchRecord(db, slug);
|
|
3301
|
+
if (rec === null) {
|
|
3302
|
+
missing.push(slug);
|
|
3303
|
+
continue;
|
|
3304
|
+
}
|
|
3305
|
+
records.push(rec);
|
|
3306
|
+
}
|
|
3307
|
+
const bulk = options.stdin === true;
|
|
3308
|
+
const stdout = bulk ? formatBulk(records) : formatSingle(records, options);
|
|
3309
|
+
const stderr = missing.map((s) => `almanac: no such page "${s}"
|
|
3310
|
+
`).join("");
|
|
3311
|
+
return {
|
|
3312
|
+
stdout,
|
|
3313
|
+
stderr,
|
|
3314
|
+
exitCode: missing.length > 0 ? 1 : 0
|
|
3315
|
+
};
|
|
3316
|
+
} finally {
|
|
3317
|
+
db.close();
|
|
3318
|
+
}
|
|
3319
|
+
}
|
|
3320
|
+
async function fetchRecord(db, slug) {
|
|
3321
|
+
const pageRow = db.prepare(
|
|
3322
|
+
"SELECT slug, title, file_path, updated_at, archived_at, superseded_by FROM pages WHERE slug = ?"
|
|
3323
|
+
).get(slug);
|
|
3324
|
+
if (pageRow === void 0) return null;
|
|
3325
|
+
const topics = db.prepare(
|
|
3326
|
+
"SELECT topic_slug FROM page_topics WHERE page_slug = ? ORDER BY topic_slug"
|
|
3327
|
+
).all(slug).map((r) => r.topic_slug);
|
|
3328
|
+
const refs = db.prepare(
|
|
3329
|
+
"SELECT original_path, is_dir FROM file_refs WHERE page_slug = ? ORDER BY original_path"
|
|
3330
|
+
).all(slug).map((r) => ({ path: r.original_path, is_dir: r.is_dir === 1 }));
|
|
3331
|
+
const linksOut = db.prepare(
|
|
3332
|
+
"SELECT target_slug FROM wikilinks WHERE source_slug = ? ORDER BY target_slug"
|
|
3333
|
+
).all(slug).map((r) => r.target_slug);
|
|
3334
|
+
const linksIn = db.prepare(
|
|
3335
|
+
"SELECT source_slug FROM wikilinks WHERE target_slug = ? ORDER BY source_slug"
|
|
3336
|
+
).all(slug).map((r) => r.source_slug);
|
|
3337
|
+
const xwiki = db.prepare(
|
|
3338
|
+
"SELECT target_wiki, target_slug FROM cross_wiki_links WHERE source_slug = ? ORDER BY target_wiki, target_slug"
|
|
3339
|
+
).all(slug).map((r) => ({ wiki: r.target_wiki, target: r.target_slug }));
|
|
3340
|
+
const supersedesRows = db.prepare(
|
|
3341
|
+
"SELECT slug FROM pages WHERE superseded_by = ? ORDER BY slug"
|
|
3342
|
+
).all(slug).map((r) => r.slug);
|
|
3343
|
+
let body = "";
|
|
3344
|
+
try {
|
|
3345
|
+
body = stripFrontmatter(await readFile11(pageRow.file_path, "utf8"));
|
|
3346
|
+
} catch {
|
|
3347
|
+
}
|
|
3348
|
+
return {
|
|
3349
|
+
slug: pageRow.slug,
|
|
3350
|
+
title: pageRow.title,
|
|
3351
|
+
file_path: pageRow.file_path,
|
|
3352
|
+
updated_at: pageRow.updated_at,
|
|
3353
|
+
archived_at: pageRow.archived_at,
|
|
3354
|
+
superseded_by: pageRow.superseded_by,
|
|
3355
|
+
supersedes: supersedesRows,
|
|
3356
|
+
topics,
|
|
3357
|
+
file_refs: refs,
|
|
3358
|
+
wikilinks_out: linksOut,
|
|
3359
|
+
wikilinks_in: linksIn,
|
|
3360
|
+
cross_wiki_links: xwiki,
|
|
3361
|
+
body
|
|
3362
|
+
};
|
|
3363
|
+
}
|
|
3364
|
+
function formatBulk(records) {
|
|
3365
|
+
if (records.length === 0) return "";
|
|
3366
|
+
return records.map((r) => JSON.stringify(r)).join("\n") + "\n";
|
|
3367
|
+
}
|
|
3368
|
+
function formatSingle(records, options) {
|
|
3369
|
+
if (options.json === true) {
|
|
3370
|
+
const only = records[0] ?? null;
|
|
3371
|
+
return `${JSON.stringify(only, null, 2)}
|
|
3372
|
+
`;
|
|
3373
|
+
}
|
|
3374
|
+
return records.map((r) => formatRecord(r, options)).join("");
|
|
3375
|
+
}
|
|
3376
|
+
var FIELD_ORDER = [
|
|
3377
|
+
"title",
|
|
3378
|
+
"topics",
|
|
3379
|
+
"files",
|
|
3380
|
+
"links",
|
|
3381
|
+
"backlinks",
|
|
3382
|
+
"xwiki",
|
|
3383
|
+
"lineage",
|
|
3384
|
+
"updated",
|
|
3385
|
+
"path"
|
|
3386
|
+
];
|
|
3387
|
+
function selectedFields(options) {
|
|
3388
|
+
const selected = [];
|
|
3389
|
+
for (const f of FIELD_ORDER) {
|
|
3390
|
+
if (options[f] === true) selected.push(f);
|
|
3391
|
+
}
|
|
3392
|
+
return selected;
|
|
3393
|
+
}
|
|
3394
|
+
function formatRecord(rec, options) {
|
|
3395
|
+
if (options.raw === true) {
|
|
3396
|
+
if (rec.body.length === 0) return "";
|
|
3397
|
+
return rec.body.endsWith("\n") ? rec.body : `${rec.body}
|
|
3398
|
+
`;
|
|
3399
|
+
}
|
|
3400
|
+
const fields = selectedFields(options);
|
|
3401
|
+
if (fields.length > 0) {
|
|
3402
|
+
if (fields.length === 1) {
|
|
3403
|
+
return bareField(rec, fields[0]);
|
|
3404
|
+
}
|
|
3405
|
+
return labeledFields(rec, fields);
|
|
3406
|
+
}
|
|
3407
|
+
if (options.meta === true) {
|
|
3408
|
+
return metadataHeader(rec) + "\n";
|
|
3409
|
+
}
|
|
3410
|
+
if (options.lead === true) {
|
|
3411
|
+
return firstParagraph(rec.body) + "\n";
|
|
3412
|
+
}
|
|
3413
|
+
const header = metadataHeader(rec);
|
|
3414
|
+
const body = rec.body;
|
|
3415
|
+
const sep = body.length > 0 ? "\n\n---\n\n" : "\n";
|
|
3416
|
+
return header + sep + body;
|
|
3417
|
+
}
|
|
3418
|
+
function bareField(rec, field) {
|
|
3419
|
+
switch (field) {
|
|
3420
|
+
case "title":
|
|
3421
|
+
return (rec.title ?? "") + "\n";
|
|
3422
|
+
case "topics":
|
|
3423
|
+
return rec.topics.map((t) => `${t}
|
|
3424
|
+
`).join("");
|
|
3425
|
+
case "files":
|
|
3426
|
+
return rec.file_refs.map((r) => `${r.path}
|
|
3427
|
+
`).join("");
|
|
3428
|
+
case "links":
|
|
3429
|
+
return rec.wikilinks_out.map((t) => `${t}
|
|
3430
|
+
`).join("");
|
|
3431
|
+
case "backlinks":
|
|
3432
|
+
return rec.wikilinks_in.map((t) => `${t}
|
|
3433
|
+
`).join("");
|
|
3434
|
+
case "xwiki":
|
|
3435
|
+
return rec.cross_wiki_links.map((x) => `${x.wiki}:${x.target}
|
|
3436
|
+
`).join("");
|
|
3437
|
+
case "lineage": {
|
|
3438
|
+
const lines = [];
|
|
3439
|
+
if (rec.archived_at !== null) {
|
|
3440
|
+
lines.push(
|
|
3441
|
+
`archived_at: ${new Date(rec.archived_at * 1e3).toISOString()}`
|
|
3442
|
+
);
|
|
3443
|
+
}
|
|
3444
|
+
if (rec.superseded_by !== null) {
|
|
3445
|
+
lines.push(`superseded_by: ${rec.superseded_by}`);
|
|
3446
|
+
}
|
|
3447
|
+
if (rec.supersedes.length > 0) {
|
|
3448
|
+
lines.push(`supersedes: ${rec.supersedes.join(", ")}`);
|
|
3449
|
+
}
|
|
3450
|
+
return lines.length > 0 ? `${lines.join("\n")}
|
|
3451
|
+
` : "";
|
|
3452
|
+
}
|
|
3453
|
+
case "updated":
|
|
3454
|
+
return `${new Date(rec.updated_at * 1e3).toISOString()}
|
|
3455
|
+
`;
|
|
3456
|
+
case "path":
|
|
3457
|
+
return `${rec.file_path}
|
|
3458
|
+
`;
|
|
3459
|
+
}
|
|
3460
|
+
}
|
|
3461
|
+
function labeledFields(rec, fields) {
|
|
3462
|
+
const parts = [];
|
|
3463
|
+
for (const f of fields) {
|
|
3464
|
+
parts.push(labeledSection(rec, f));
|
|
3465
|
+
}
|
|
3466
|
+
return parts.join("\n");
|
|
3467
|
+
}
|
|
3468
|
+
function labeledSection(rec, field) {
|
|
3469
|
+
switch (field) {
|
|
3470
|
+
case "title":
|
|
3471
|
+
return `title: ${rec.title ?? "\u2014"}
|
|
3472
|
+
`;
|
|
3473
|
+
case "topics":
|
|
3474
|
+
return rec.topics.length > 0 ? `topics: ${rec.topics.join(", ")}
|
|
3475
|
+
` : `topics: \u2014
|
|
3476
|
+
`;
|
|
3477
|
+
case "files":
|
|
3478
|
+
return formatListSection(
|
|
3479
|
+
"files",
|
|
3480
|
+
rec.file_refs.map((r) => `${r.path}`)
|
|
3481
|
+
);
|
|
3482
|
+
case "links":
|
|
3483
|
+
return formatListSection("links", rec.wikilinks_out);
|
|
3484
|
+
case "backlinks":
|
|
3485
|
+
return formatListSection("backlinks", rec.wikilinks_in);
|
|
3486
|
+
case "xwiki":
|
|
3487
|
+
return formatListSection(
|
|
3488
|
+
"xwiki",
|
|
3489
|
+
rec.cross_wiki_links.map((x) => `${x.wiki}:${x.target}`)
|
|
3490
|
+
);
|
|
3491
|
+
case "lineage": {
|
|
3492
|
+
const lines = ["lineage:"];
|
|
3493
|
+
if (rec.archived_at !== null) {
|
|
3494
|
+
lines.push(
|
|
3495
|
+
` archived_at: ${new Date(rec.archived_at * 1e3).toISOString()}`
|
|
3496
|
+
);
|
|
3497
|
+
}
|
|
3498
|
+
if (rec.superseded_by !== null) {
|
|
3499
|
+
lines.push(` superseded_by: ${rec.superseded_by}`);
|
|
3500
|
+
}
|
|
3501
|
+
if (rec.supersedes.length > 0) {
|
|
3502
|
+
lines.push(` supersedes: ${rec.supersedes.join(", ")}`);
|
|
3503
|
+
}
|
|
3504
|
+
if (lines.length === 1) lines.push(" \u2014");
|
|
3505
|
+
return lines.join("\n") + "\n";
|
|
3506
|
+
}
|
|
3507
|
+
case "updated":
|
|
3508
|
+
return `updated: ${new Date(rec.updated_at * 1e3).toISOString()}
|
|
3509
|
+
`;
|
|
3510
|
+
case "path":
|
|
3511
|
+
return `path: ${rec.file_path}
|
|
3512
|
+
`;
|
|
3513
|
+
}
|
|
3514
|
+
}
|
|
3515
|
+
function formatListSection(label, items) {
|
|
3516
|
+
if (items.length === 0) return `${label}: \u2014
|
|
3517
|
+
`;
|
|
3518
|
+
if (items.length <= 3) return `${label}: ${items.join(", ")}
|
|
3519
|
+
`;
|
|
3520
|
+
return `${label}:
|
|
3521
|
+
${items.map((i) => ` ${i}`).join("\n")}
|
|
3522
|
+
`;
|
|
3523
|
+
}
|
|
3524
|
+
function metadataHeader(rec) {
|
|
3525
|
+
const lines = [];
|
|
3526
|
+
lines.push(`slug: ${rec.slug}`);
|
|
3527
|
+
lines.push(`title: ${rec.title ?? "\u2014"}`);
|
|
3528
|
+
lines.push(
|
|
3529
|
+
`topics: ${rec.topics.length > 0 ? rec.topics.join(", ") : "\u2014"}`
|
|
3530
|
+
);
|
|
3531
|
+
if (rec.file_refs.length > 0) {
|
|
3532
|
+
const parts = rec.file_refs.map(
|
|
3533
|
+
(r) => `${r.path}`
|
|
3534
|
+
);
|
|
3535
|
+
lines.push(`files: ${parts.join(", ")}`);
|
|
3536
|
+
}
|
|
3537
|
+
lines.push(
|
|
3538
|
+
`updated: ${new Date(rec.updated_at * 1e3).toISOString()}`
|
|
3539
|
+
);
|
|
3540
|
+
if (rec.wikilinks_out.length > 0) {
|
|
3541
|
+
lines.push(`links: ${rec.wikilinks_out.join(", ")}`);
|
|
3542
|
+
}
|
|
3543
|
+
if (rec.wikilinks_in.length > 0) {
|
|
3544
|
+
lines.push(`backlinks: ${rec.wikilinks_in.join(", ")}`);
|
|
3545
|
+
}
|
|
3546
|
+
if (rec.cross_wiki_links.length > 0) {
|
|
3547
|
+
lines.push(
|
|
3548
|
+
`xwiki: ${rec.cross_wiki_links.map((x) => `${x.wiki}:${x.target}`).join(", ")}`
|
|
2720
3549
|
);
|
|
2721
|
-
const records = [];
|
|
2722
|
-
const missing = [];
|
|
2723
|
-
for (const slug of slugs) {
|
|
2724
|
-
const row = stmt.get(slug);
|
|
2725
|
-
if (row === void 0) {
|
|
2726
|
-
missing.push(slug);
|
|
2727
|
-
continue;
|
|
2728
|
-
}
|
|
2729
|
-
try {
|
|
2730
|
-
records.push({ slug, content: await readFile9(row.file_path, "utf8") });
|
|
2731
|
-
} catch (err) {
|
|
2732
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
2733
|
-
missing.push(`${slug} (${message})`);
|
|
2734
|
-
}
|
|
2735
|
-
}
|
|
2736
|
-
const bulk = options.stdin === true;
|
|
2737
|
-
let stdout;
|
|
2738
|
-
if (bulk) {
|
|
2739
|
-
stdout = records.map((r) => JSON.stringify(r)).join("\n");
|
|
2740
|
-
if (stdout.length > 0) stdout += "\n";
|
|
2741
|
-
} else {
|
|
2742
|
-
stdout = records.map((r) => r.content).join("");
|
|
2743
|
-
}
|
|
2744
|
-
const stderr = missing.map((s) => `almanac: no such page "${s}"
|
|
2745
|
-
`).join("");
|
|
2746
|
-
return {
|
|
2747
|
-
stdout,
|
|
2748
|
-
stderr,
|
|
2749
|
-
exitCode: missing.length > 0 ? 1 : 0
|
|
2750
|
-
};
|
|
2751
|
-
} finally {
|
|
2752
|
-
db.close();
|
|
2753
3550
|
}
|
|
3551
|
+
if (rec.archived_at !== null) {
|
|
3552
|
+
lines.push(
|
|
3553
|
+
`archived: ${new Date(rec.archived_at * 1e3).toISOString()}`
|
|
3554
|
+
);
|
|
3555
|
+
}
|
|
3556
|
+
if (rec.superseded_by !== null) {
|
|
3557
|
+
lines.push(`superseded_by: ${rec.superseded_by}`);
|
|
3558
|
+
}
|
|
3559
|
+
if (rec.supersedes.length > 0) {
|
|
3560
|
+
lines.push(`supersedes: ${rec.supersedes.join(", ")}`);
|
|
3561
|
+
}
|
|
3562
|
+
return lines.join("\n");
|
|
2754
3563
|
}
|
|
2755
|
-
function
|
|
3564
|
+
function stripFrontmatter(src) {
|
|
3565
|
+
if (!src.startsWith("---\n") && !src.startsWith("---\r\n")) return src;
|
|
3566
|
+
const afterOpen = src.replace(/^---\r?\n/, "");
|
|
3567
|
+
const endMatch = afterOpen.match(/^---[ \t]*\r?\n/m);
|
|
3568
|
+
if (endMatch === null || endMatch.index === void 0) return src;
|
|
3569
|
+
return afterOpen.slice(endMatch.index + endMatch[0].length);
|
|
3570
|
+
}
|
|
3571
|
+
function firstParagraph(body) {
|
|
3572
|
+
let src = body.trimStart();
|
|
3573
|
+
if (src.startsWith("# ")) {
|
|
3574
|
+
const nl = src.indexOf("\n");
|
|
3575
|
+
src = nl === -1 ? "" : src.slice(nl + 1).trimStart();
|
|
3576
|
+
}
|
|
3577
|
+
const blank = src.search(/\n[ \t]*\n/);
|
|
3578
|
+
if (blank === -1) return src.trimEnd();
|
|
3579
|
+
return src.slice(0, blank).trimEnd();
|
|
3580
|
+
}
|
|
3581
|
+
function collectSlugs(options) {
|
|
2756
3582
|
if (options.stdin === true && options.stdinInput !== void 0) {
|
|
2757
3583
|
return options.stdinInput.split(/\r?\n/).map((s) => s.trim()).filter((s) => s.length > 0);
|
|
2758
3584
|
}
|
|
@@ -2763,17 +3589,17 @@ function collectSlugs3(options) {
|
|
|
2763
3589
|
}
|
|
2764
3590
|
|
|
2765
3591
|
// src/topics/frontmatterRewrite.ts
|
|
2766
|
-
import { readFile as
|
|
3592
|
+
import { readFile as readFile12, rename as rename4, writeFile as writeFile6 } from "fs/promises";
|
|
2767
3593
|
import yaml3 from "js-yaml";
|
|
2768
3594
|
async function rewritePageTopics(filePath, transform) {
|
|
2769
|
-
const raw = await
|
|
3595
|
+
const raw = await readFile12(filePath, "utf8");
|
|
2770
3596
|
const { before, after, output, changed } = applyTopicsTransform(
|
|
2771
3597
|
raw,
|
|
2772
3598
|
transform
|
|
2773
3599
|
);
|
|
2774
3600
|
if (changed) {
|
|
2775
3601
|
const tmp = `${filePath}.tmp`;
|
|
2776
|
-
await
|
|
3602
|
+
await writeFile6(tmp, output, "utf8");
|
|
2777
3603
|
await rename4(tmp, filePath);
|
|
2778
3604
|
}
|
|
2779
3605
|
return { before, after, changed };
|
|
@@ -2980,12 +3806,12 @@ function arraysEqual(a, b) {
|
|
|
2980
3806
|
}
|
|
2981
3807
|
|
|
2982
3808
|
// src/topics/paths.ts
|
|
2983
|
-
import { join as
|
|
3809
|
+
import { join as join11 } from "path";
|
|
2984
3810
|
function topicsYamlPath(repoRoot) {
|
|
2985
|
-
return
|
|
3811
|
+
return join11(repoRoot, ".almanac", "topics.yaml");
|
|
2986
3812
|
}
|
|
2987
3813
|
function indexDbPath(repoRoot) {
|
|
2988
|
-
return
|
|
3814
|
+
return join11(repoRoot, ".almanac", "index.db");
|
|
2989
3815
|
}
|
|
2990
3816
|
|
|
2991
3817
|
// src/commands/tag.ts
|
|
@@ -3144,8 +3970,8 @@ async function runUntag(options) {
|
|
|
3144
3970
|
}
|
|
3145
3971
|
|
|
3146
3972
|
// src/commands/topics.ts
|
|
3147
|
-
import { readFile as
|
|
3148
|
-
import { join as
|
|
3973
|
+
import { readFile as readFile13 } from "fs/promises";
|
|
3974
|
+
import { join as join12 } from "path";
|
|
3149
3975
|
import fg3 from "fast-glob";
|
|
3150
3976
|
async function runTopicsList(options) {
|
|
3151
3977
|
const repoRoot = await resolveWikiRoot({ cwd: options.cwd, wiki: options.wiki });
|
|
@@ -3617,7 +4443,7 @@ async function runTopicsDescribe(options) {
|
|
|
3617
4443
|
};
|
|
3618
4444
|
}
|
|
3619
4445
|
async function rewriteTopicOnPages(repoRoot, transform) {
|
|
3620
|
-
const pagesDir =
|
|
4446
|
+
const pagesDir = join12(repoRoot, ".almanac", "pages");
|
|
3621
4447
|
const files = await fg3("**/*.md", {
|
|
3622
4448
|
cwd: pagesDir,
|
|
3623
4449
|
absolute: true,
|
|
@@ -3625,7 +4451,7 @@ async function rewriteTopicOnPages(repoRoot, transform) {
|
|
|
3625
4451
|
});
|
|
3626
4452
|
let changed = 0;
|
|
3627
4453
|
for (const filePath of files) {
|
|
3628
|
-
const raw = await
|
|
4454
|
+
const raw = await readFile13(filePath, "utf8");
|
|
3629
4455
|
const applied = applyTopicsTransform(raw, transform);
|
|
3630
4456
|
if (!applied.changed) continue;
|
|
3631
4457
|
await rewritePageTopics(filePath, transform);
|
|
@@ -3634,14 +4460,146 @@ async function rewriteTopicOnPages(repoRoot, transform) {
|
|
|
3634
4460
|
return changed;
|
|
3635
4461
|
}
|
|
3636
4462
|
|
|
4463
|
+
// src/commands/uninstall.ts
|
|
4464
|
+
import { existsSync as existsSync14 } from "fs";
|
|
4465
|
+
import { readFile as readFile14, rm, writeFile as writeFile7 } from "fs/promises";
|
|
4466
|
+
import { homedir as homedir6 } from "os";
|
|
4467
|
+
import path5 from "path";
|
|
4468
|
+
var BLUE3 = "\x1B[38;5;75m";
|
|
4469
|
+
var DIM3 = "\x1B[2m";
|
|
4470
|
+
var RST3 = "\x1B[0m";
|
|
4471
|
+
async function runUninstall(options = {}) {
|
|
4472
|
+
const out = options.stdout ?? process.stdout;
|
|
4473
|
+
const isTTY = options.isTTY ?? process.stdin.isTTY === true;
|
|
4474
|
+
const interactive = isTTY && options.yes !== true;
|
|
4475
|
+
const claudeDir = options.claudeDir ?? path5.join(homedir6(), ".claude");
|
|
4476
|
+
out.write("\n");
|
|
4477
|
+
let removeHook = true;
|
|
4478
|
+
if (options.keepHook === true) {
|
|
4479
|
+
removeHook = false;
|
|
4480
|
+
} else if (interactive) {
|
|
4481
|
+
removeHook = await confirm2(
|
|
4482
|
+
out,
|
|
4483
|
+
"Remove the SessionEnd hook from ~/.claude/settings.json?",
|
|
4484
|
+
true
|
|
4485
|
+
);
|
|
4486
|
+
}
|
|
4487
|
+
if (removeHook) {
|
|
4488
|
+
const res = await runHookUninstall({
|
|
4489
|
+
settingsPath: options.settingsPath,
|
|
4490
|
+
hookScriptPath: options.hookScriptPath
|
|
4491
|
+
});
|
|
4492
|
+
if (res.exitCode !== 0) {
|
|
4493
|
+
return { stdout: "", stderr: res.stderr, exitCode: res.exitCode };
|
|
4494
|
+
}
|
|
4495
|
+
out.write(` ${BLUE3}\u25C7${RST3} ${res.stdout.trim()}
|
|
4496
|
+
`);
|
|
4497
|
+
} else {
|
|
4498
|
+
out.write(` ${DIM3}\u25CB Hook kept${RST3}
|
|
4499
|
+
`);
|
|
4500
|
+
}
|
|
4501
|
+
let removeGuides = true;
|
|
4502
|
+
if (options.keepGuides === true) {
|
|
4503
|
+
removeGuides = false;
|
|
4504
|
+
} else if (interactive) {
|
|
4505
|
+
removeGuides = await confirm2(
|
|
4506
|
+
out,
|
|
4507
|
+
"Remove the guides + CLAUDE.md import line?",
|
|
4508
|
+
true
|
|
4509
|
+
);
|
|
4510
|
+
}
|
|
4511
|
+
if (removeGuides) {
|
|
4512
|
+
const summary = await removeGuideFiles(claudeDir);
|
|
4513
|
+
if (summary.anyChanges) {
|
|
4514
|
+
out.write(
|
|
4515
|
+
` ${BLUE3}\u25C7${RST3} Guides removed (${summary.filesTouched.join(", ")})
|
|
4516
|
+
`
|
|
4517
|
+
);
|
|
4518
|
+
} else {
|
|
4519
|
+
out.write(` ${DIM3}\u25CB Guides not installed${RST3}
|
|
4520
|
+
`);
|
|
4521
|
+
}
|
|
4522
|
+
} else {
|
|
4523
|
+
out.write(` ${DIM3}\u25CB Guides kept${RST3}
|
|
4524
|
+
`);
|
|
4525
|
+
}
|
|
4526
|
+
out.write(`
|
|
4527
|
+
${BLUE3}\u25C7${RST3} ${BLUE3}Uninstall complete${RST3}
|
|
4528
|
+
|
|
4529
|
+
`);
|
|
4530
|
+
return { stdout: "", stderr: "", exitCode: 0 };
|
|
4531
|
+
}
|
|
4532
|
+
async function removeGuideFiles(claudeDir) {
|
|
4533
|
+
const touched = [];
|
|
4534
|
+
const mini = path5.join(claudeDir, "codealmanac.md");
|
|
4535
|
+
const ref = path5.join(claudeDir, "codealmanac-reference.md");
|
|
4536
|
+
const claudeMd = path5.join(claudeDir, "CLAUDE.md");
|
|
4537
|
+
if (existsSync14(mini)) {
|
|
4538
|
+
await rm(mini, { force: true });
|
|
4539
|
+
touched.push("codealmanac.md");
|
|
4540
|
+
}
|
|
4541
|
+
if (existsSync14(ref)) {
|
|
4542
|
+
await rm(ref, { force: true });
|
|
4543
|
+
touched.push("codealmanac-reference.md");
|
|
4544
|
+
}
|
|
4545
|
+
if (existsSync14(claudeMd)) {
|
|
4546
|
+
const existing = await readFile14(claudeMd, "utf8");
|
|
4547
|
+
const { changed, body } = removeImportLine(existing);
|
|
4548
|
+
if (changed) {
|
|
4549
|
+
if (body.trim().length === 0) {
|
|
4550
|
+
await rm(claudeMd, { force: true });
|
|
4551
|
+
touched.push("CLAUDE.md (deleted)");
|
|
4552
|
+
} else {
|
|
4553
|
+
await writeFile7(claudeMd, body, "utf8");
|
|
4554
|
+
touched.push("CLAUDE.md");
|
|
4555
|
+
}
|
|
4556
|
+
}
|
|
4557
|
+
}
|
|
4558
|
+
return { anyChanges: touched.length > 0, filesTouched: touched };
|
|
4559
|
+
}
|
|
4560
|
+
function removeImportLine(contents) {
|
|
4561
|
+
const eol = contents.includes("\r\n") ? "\r\n" : "\n";
|
|
4562
|
+
const lines = contents.split(/\r?\n/);
|
|
4563
|
+
const indices = [];
|
|
4564
|
+
for (let i = 0; i < lines.length; i++) {
|
|
4565
|
+
if (lines[i].trim() === IMPORT_LINE) indices.push(i);
|
|
4566
|
+
}
|
|
4567
|
+
if (indices.length === 0) return { changed: false, body: contents };
|
|
4568
|
+
for (let i = indices.length - 1; i >= 0; i--) {
|
|
4569
|
+
lines.splice(indices[i], 1);
|
|
4570
|
+
}
|
|
4571
|
+
let body = lines.join(eol);
|
|
4572
|
+
body = body.replace(/\n\n\n+/g, "\n\n");
|
|
4573
|
+
return { changed: true, body };
|
|
4574
|
+
}
|
|
4575
|
+
function confirm2(out, question, defaultYes) {
|
|
4576
|
+
return new Promise((resolve2) => {
|
|
4577
|
+
const hint = defaultYes ? "[Y/n]" : "[y/N]";
|
|
4578
|
+
out.write(` ${BLUE3}\u25C6${RST3} ${question} ${DIM3}${hint}${RST3} `);
|
|
4579
|
+
let buf = "";
|
|
4580
|
+
const onData = (chunk) => {
|
|
4581
|
+
buf += chunk.toString("utf8");
|
|
4582
|
+
const nl = buf.indexOf("\n");
|
|
4583
|
+
if (nl === -1) return;
|
|
4584
|
+
process.stdin.removeListener("data", onData);
|
|
4585
|
+
process.stdin.pause();
|
|
4586
|
+
const answer = buf.slice(0, nl).trim().toLowerCase();
|
|
4587
|
+
const accepted = answer.length === 0 ? defaultYes : answer === "y" || answer === "yes";
|
|
4588
|
+
resolve2(accepted);
|
|
4589
|
+
};
|
|
4590
|
+
process.stdin.resume();
|
|
4591
|
+
process.stdin.on("data", onData);
|
|
4592
|
+
});
|
|
4593
|
+
}
|
|
4594
|
+
|
|
3637
4595
|
// src/registry/autoregister.ts
|
|
3638
|
-
import { existsSync as
|
|
4596
|
+
import { existsSync as existsSync15 } from "fs";
|
|
3639
4597
|
import { basename as basename5 } from "path";
|
|
3640
4598
|
async function autoRegisterIfNeeded(cwd) {
|
|
3641
4599
|
try {
|
|
3642
4600
|
const repoRoot = findNearestAlmanacDir(cwd);
|
|
3643
4601
|
if (repoRoot === null) return null;
|
|
3644
|
-
if (!
|
|
4602
|
+
if (!existsSync15(repoRoot)) return null;
|
|
3645
4603
|
const entries = await readRegistry();
|
|
3646
4604
|
const existing = entries.find((e) => samePath(e.path, repoRoot));
|
|
3647
4605
|
if (existing !== void 0) return existing;
|
|
@@ -3686,45 +4644,30 @@ function samePath(a, b) {
|
|
|
3686
4644
|
|
|
3687
4645
|
// src/cli.ts
|
|
3688
4646
|
async function run(argv) {
|
|
3689
|
-
const program = new Command();
|
|
3690
4647
|
const invoked = argv[1] !== void 0 ? basename6(argv[1]) : "almanac";
|
|
3691
4648
|
const programName = invoked === "codealmanac" ? "codealmanac" : "almanac";
|
|
4649
|
+
const program = new Command();
|
|
3692
4650
|
program.name(programName).description(
|
|
3693
4651
|
"codealmanac \u2014 a living wiki for codebases, maintained by AI agents"
|
|
3694
|
-
).version(
|
|
3695
|
-
|
|
3696
|
-
const
|
|
3697
|
-
|
|
3698
|
-
|
|
3699
|
-
|
|
3700
|
-
|
|
3701
|
-
|
|
3702
|
-
|
|
3703
|
-
`${verb} wiki "${result.entry.name}" at ${result.almanacDir}
|
|
3704
|
-
`
|
|
3705
|
-
);
|
|
3706
|
-
});
|
|
3707
|
-
program.command("list").description("list registered wikis").option("--json", "emit structured JSON").option(
|
|
3708
|
-
"--drop <name>",
|
|
3709
|
-
"remove a wiki from the registry (the only way entries are ever removed)"
|
|
3710
|
-
).action(async (opts) => {
|
|
3711
|
-
if (opts.drop === void 0) {
|
|
3712
|
-
await autoRegisterIfNeeded(process.cwd());
|
|
3713
|
-
}
|
|
3714
|
-
const result = await listWikis(opts);
|
|
3715
|
-
process.stdout.write(result.stdout);
|
|
3716
|
-
if (result.exitCode !== 0) {
|
|
3717
|
-
process.exitCode = result.exitCode;
|
|
4652
|
+
).version(readPackageVersion2(), "-v, --version", "print version");
|
|
4653
|
+
if (programName === "codealmanac") {
|
|
4654
|
+
const setupInvocation = tryParseSetupShortcut(argv.slice(2));
|
|
4655
|
+
if (setupInvocation !== null) {
|
|
4656
|
+
const result = await runSetup(setupInvocation);
|
|
4657
|
+
if (result.stderr.length > 0) process.stderr.write(result.stderr);
|
|
4658
|
+
if (result.stdout.length > 0) process.stdout.write(result.stdout);
|
|
4659
|
+
if (result.exitCode !== 0) process.exitCode = result.exitCode;
|
|
4660
|
+
return;
|
|
3718
4661
|
}
|
|
3719
|
-
}
|
|
3720
|
-
program.command("search [query]").description("
|
|
4662
|
+
}
|
|
4663
|
+
program.command("search [query]").description("find pages by text, topic, file mentions, freshness").option(
|
|
3721
4664
|
"--topic <name...>",
|
|
3722
4665
|
"filter by topic (repeat for intersection)",
|
|
3723
4666
|
collectOption,
|
|
3724
4667
|
[]
|
|
3725
4668
|
).option(
|
|
3726
4669
|
"--mentions <path>",
|
|
3727
|
-
"pages referencing this
|
|
4670
|
+
"pages referencing this path; matches exact file, trailing-slash folders, and any file under a folder prefix"
|
|
3728
4671
|
).option(
|
|
3729
4672
|
"--since <duration>",
|
|
3730
4673
|
"updated within duration, by file mtime (e.g. 2w, 30d)"
|
|
@@ -3748,12 +4691,10 @@ async function run(argv) {
|
|
|
3748
4691
|
json: opts.json,
|
|
3749
4692
|
limit: opts.limit
|
|
3750
4693
|
});
|
|
3751
|
-
|
|
3752
|
-
process.stdout.write(result.stdout);
|
|
3753
|
-
if (result.exitCode !== 0) process.exitCode = result.exitCode;
|
|
4694
|
+
emit(result);
|
|
3754
4695
|
}
|
|
3755
4696
|
);
|
|
3756
|
-
program.command("show [slug]").description("print
|
|
4697
|
+
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(
|
|
3757
4698
|
async (slug, opts) => {
|
|
3758
4699
|
await autoRegisterIfNeeded(process.cwd());
|
|
3759
4700
|
const result = await runShow({
|
|
@@ -3761,54 +4702,85 @@ async function run(argv) {
|
|
|
3761
4702
|
slug,
|
|
3762
4703
|
stdin: opts.stdin,
|
|
3763
4704
|
stdinInput: opts.stdin === true ? await readStdin() : void 0,
|
|
3764
|
-
wiki: opts.wiki
|
|
4705
|
+
wiki: opts.wiki,
|
|
4706
|
+
json: opts.json,
|
|
4707
|
+
// `--body` is a surface alias for `--raw`. Fold it into one
|
|
4708
|
+
// field at the CLI boundary so the implementation never has
|
|
4709
|
+
// to care which spelling the user typed.
|
|
4710
|
+
raw: opts.raw === true || opts.body === true,
|
|
4711
|
+
meta: opts.meta,
|
|
4712
|
+
lead: opts.lead,
|
|
4713
|
+
title: opts.title,
|
|
4714
|
+
topics: opts.topics,
|
|
4715
|
+
files: opts.files,
|
|
4716
|
+
links: opts.links,
|
|
4717
|
+
backlinks: opts.backlinks,
|
|
4718
|
+
xwiki: opts.xwiki,
|
|
4719
|
+
lineage: opts.lineage,
|
|
4720
|
+
updated: opts.updated,
|
|
4721
|
+
path: opts.path
|
|
3765
4722
|
});
|
|
3766
|
-
|
|
3767
|
-
process.stdout.write(result.stdout);
|
|
3768
|
-
if (result.exitCode !== 0) process.exitCode = result.exitCode;
|
|
4723
|
+
emit(result);
|
|
3769
4724
|
}
|
|
3770
4725
|
);
|
|
3771
|
-
program.command("
|
|
3772
|
-
async (
|
|
4726
|
+
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(
|
|
4727
|
+
async (opts) => {
|
|
3773
4728
|
await autoRegisterIfNeeded(process.cwd());
|
|
3774
|
-
const result = await
|
|
4729
|
+
const result = await runHealth({
|
|
3775
4730
|
cwd: process.cwd(),
|
|
3776
|
-
|
|
4731
|
+
topic: opts.topic,
|
|
4732
|
+
stale: opts.stale,
|
|
3777
4733
|
stdin: opts.stdin,
|
|
3778
4734
|
stdinInput: opts.stdin === true ? await readStdin() : void 0,
|
|
4735
|
+
json: opts.json,
|
|
3779
4736
|
wiki: opts.wiki
|
|
3780
4737
|
});
|
|
3781
|
-
|
|
3782
|
-
process.stdout.write(result.stdout);
|
|
3783
|
-
if (result.exitCode !== 0) process.exitCode = result.exitCode;
|
|
4738
|
+
emit(result);
|
|
3784
4739
|
}
|
|
3785
4740
|
);
|
|
3786
|
-
program.command("
|
|
3787
|
-
|
|
4741
|
+
program.command("list").description("list registered wikis").option("--json", "emit structured JSON").option(
|
|
4742
|
+
"--drop <name>",
|
|
4743
|
+
"remove a wiki from the registry (the only way entries are ever removed)"
|
|
4744
|
+
).action(async (opts) => {
|
|
4745
|
+
if (opts.drop === void 0) {
|
|
4746
|
+
await autoRegisterIfNeeded(process.cwd());
|
|
4747
|
+
}
|
|
4748
|
+
const result = await listWikis(opts);
|
|
4749
|
+
process.stdout.write(result.stdout);
|
|
4750
|
+
if (result.exitCode !== 0) {
|
|
4751
|
+
process.exitCode = result.exitCode;
|
|
4752
|
+
}
|
|
4753
|
+
});
|
|
4754
|
+
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(
|
|
4755
|
+
async (page, topicsArg, opts) => {
|
|
3788
4756
|
await autoRegisterIfNeeded(process.cwd());
|
|
3789
|
-
const
|
|
4757
|
+
const resolvedTopics = opts.stdin === true ? [page, ...topicsArg].filter(
|
|
4758
|
+
(t) => typeof t === "string" && t.length > 0
|
|
4759
|
+
) : topicsArg;
|
|
4760
|
+
const result = await runTag({
|
|
3790
4761
|
cwd: process.cwd(),
|
|
3791
|
-
|
|
4762
|
+
page: opts.stdin === true ? void 0 : page,
|
|
4763
|
+
topics: resolvedTopics,
|
|
3792
4764
|
stdin: opts.stdin,
|
|
3793
4765
|
stdinInput: opts.stdin === true ? await readStdin() : void 0,
|
|
3794
|
-
json: opts.json,
|
|
3795
4766
|
wiki: opts.wiki
|
|
3796
4767
|
});
|
|
3797
|
-
|
|
3798
|
-
process.stdout.write(result.stdout);
|
|
3799
|
-
if (result.exitCode !== 0) process.exitCode = result.exitCode;
|
|
4768
|
+
emit(result);
|
|
3800
4769
|
}
|
|
3801
4770
|
);
|
|
3802
|
-
program.command("
|
|
3803
|
-
|
|
3804
|
-
|
|
3805
|
-
|
|
3806
|
-
|
|
3807
|
-
|
|
3808
|
-
|
|
3809
|
-
|
|
3810
|
-
|
|
3811
|
-
|
|
4771
|
+
program.command("untag <page> <topic>").description("remove a topic from a page's frontmatter").option("--wiki <name>", "target a specific registered wiki").action(
|
|
4772
|
+
async (page, topic, opts) => {
|
|
4773
|
+
await autoRegisterIfNeeded(process.cwd());
|
|
4774
|
+
const result = await runUntag({
|
|
4775
|
+
cwd: process.cwd(),
|
|
4776
|
+
page,
|
|
4777
|
+
topic,
|
|
4778
|
+
wiki: opts.wiki
|
|
4779
|
+
});
|
|
4780
|
+
emit(result);
|
|
4781
|
+
}
|
|
4782
|
+
);
|
|
4783
|
+
const topics = program.command("topics").description("manage the topic DAG");
|
|
3812
4784
|
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) => {
|
|
3813
4785
|
await autoRegisterIfNeeded(process.cwd());
|
|
3814
4786
|
const result = await runTopicsList({
|
|
@@ -3905,37 +4877,8 @@ async function run(argv) {
|
|
|
3905
4877
|
emit(result);
|
|
3906
4878
|
}
|
|
3907
4879
|
);
|
|
3908
|
-
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(
|
|
3909
|
-
async (page, topicsArg, opts) => {
|
|
3910
|
-
await autoRegisterIfNeeded(process.cwd());
|
|
3911
|
-
const resolvedTopics = opts.stdin === true ? [page, ...topicsArg].filter(
|
|
3912
|
-
(t) => typeof t === "string" && t.length > 0
|
|
3913
|
-
) : topicsArg;
|
|
3914
|
-
const result = await runTag({
|
|
3915
|
-
cwd: process.cwd(),
|
|
3916
|
-
page: opts.stdin === true ? void 0 : page,
|
|
3917
|
-
topics: resolvedTopics,
|
|
3918
|
-
stdin: opts.stdin,
|
|
3919
|
-
stdinInput: opts.stdin === true ? await readStdin() : void 0,
|
|
3920
|
-
wiki: opts.wiki
|
|
3921
|
-
});
|
|
3922
|
-
emit(result);
|
|
3923
|
-
}
|
|
3924
|
-
);
|
|
3925
|
-
program.command("untag <page> <topic>").description("remove a topic from a page's frontmatter").option("--wiki <name>", "target a specific registered wiki").action(
|
|
3926
|
-
async (page, topic, opts) => {
|
|
3927
|
-
await autoRegisterIfNeeded(process.cwd());
|
|
3928
|
-
const result = await runUntag({
|
|
3929
|
-
cwd: process.cwd(),
|
|
3930
|
-
page,
|
|
3931
|
-
topic,
|
|
3932
|
-
wiki: opts.wiki
|
|
3933
|
-
});
|
|
3934
|
-
emit(result);
|
|
3935
|
-
}
|
|
3936
|
-
);
|
|
3937
4880
|
program.command("bootstrap").description(
|
|
3938
|
-
"
|
|
4881
|
+
"scaffold a wiki in this repo via an AI agent (requires ANTHROPIC_API_KEY or Claude subscription)"
|
|
3939
4882
|
).option("--quiet", "suppress per-tool streaming; print only the final line").option("--model <model>", "override the agent model").option(
|
|
3940
4883
|
"--force",
|
|
3941
4884
|
"overwrite an existing populated wiki (default: refuse)"
|
|
@@ -3951,7 +4894,7 @@ async function run(argv) {
|
|
|
3951
4894
|
}
|
|
3952
4895
|
);
|
|
3953
4896
|
program.command("capture [transcript]").description(
|
|
3954
|
-
"
|
|
4897
|
+
"run the writer/reviewer pipeline on a session (usually automatic)"
|
|
3955
4898
|
).option("--session <id>", "target a specific session by ID").option(
|
|
3956
4899
|
"--quiet",
|
|
3957
4900
|
"suppress per-tool streaming; print only the final summary"
|
|
@@ -3968,9 +4911,7 @@ async function run(argv) {
|
|
|
3968
4911
|
emit(result);
|
|
3969
4912
|
}
|
|
3970
4913
|
);
|
|
3971
|
-
const hook = program.command("hook").description(
|
|
3972
|
-
"install, uninstall, or inspect the SessionEnd hook in ~/.claude/settings.json"
|
|
3973
|
-
);
|
|
4914
|
+
const hook = program.command("hook").description("manage the SessionEnd auto-capture hook");
|
|
3974
4915
|
hook.command("install").description("add a SessionEnd entry that runs 'almanac capture' on session end").action(async () => {
|
|
3975
4916
|
const result = await runHookInstall();
|
|
3976
4917
|
emit(result);
|
|
@@ -3983,23 +4924,182 @@ async function run(argv) {
|
|
|
3983
4924
|
const result = await runHookStatus();
|
|
3984
4925
|
emit(result);
|
|
3985
4926
|
});
|
|
3986
|
-
program.command("
|
|
4927
|
+
program.command("reindex").description("force a full rebuild of .almanac/index.db").option("--wiki <name>", "target a specific registered wiki").action(async (opts) => {
|
|
4928
|
+
await autoRegisterIfNeeded(process.cwd());
|
|
4929
|
+
const result = await runReindex({
|
|
4930
|
+
cwd: process.cwd(),
|
|
4931
|
+
wiki: opts.wiki
|
|
4932
|
+
});
|
|
4933
|
+
process.stdout.write(result.stdout);
|
|
4934
|
+
if (result.exitCode !== 0) process.exitCode = result.exitCode;
|
|
4935
|
+
});
|
|
4936
|
+
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(
|
|
3987
4937
|
async (opts) => {
|
|
3988
|
-
await
|
|
3989
|
-
|
|
4938
|
+
const result = await runSetup({
|
|
4939
|
+
yes: opts.yes,
|
|
4940
|
+
skipHook: opts.skipHook,
|
|
4941
|
+
skipGuides: opts.skipGuides
|
|
4942
|
+
});
|
|
4943
|
+
emit(result);
|
|
4944
|
+
}
|
|
4945
|
+
);
|
|
4946
|
+
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(
|
|
4947
|
+
async (opts) => {
|
|
4948
|
+
const result = await runDoctor({
|
|
3990
4949
|
cwd: process.cwd(),
|
|
3991
|
-
topic: opts.topic,
|
|
3992
|
-
stale: opts.stale,
|
|
3993
|
-
stdin: opts.stdin,
|
|
3994
|
-
stdinInput: opts.stdin === true ? await readStdin() : void 0,
|
|
3995
4950
|
json: opts.json,
|
|
3996
|
-
|
|
4951
|
+
installOnly: opts.installOnly,
|
|
4952
|
+
wikiOnly: opts.wikiOnly
|
|
4953
|
+
});
|
|
4954
|
+
emit(result);
|
|
4955
|
+
}
|
|
4956
|
+
);
|
|
4957
|
+
program.command("uninstall").description("remove the hook + guides + import line").option("-y, --yes", "skip confirmations; remove everything").option(
|
|
4958
|
+
"--keep-hook",
|
|
4959
|
+
"don't remove the SessionEnd hook (guides still prompted unless --yes)"
|
|
4960
|
+
).option(
|
|
4961
|
+
"--keep-guides",
|
|
4962
|
+
"don't remove the guides or CLAUDE.md import (hook still prompted unless --yes)"
|
|
4963
|
+
).action(
|
|
4964
|
+
async (opts) => {
|
|
4965
|
+
const result = await runUninstall({
|
|
4966
|
+
yes: opts.yes,
|
|
4967
|
+
keepHook: opts.keepHook,
|
|
4968
|
+
keepGuides: opts.keepGuides
|
|
3997
4969
|
});
|
|
3998
4970
|
emit(result);
|
|
3999
4971
|
}
|
|
4000
4972
|
);
|
|
4973
|
+
configureGroupedHelp(program);
|
|
4001
4974
|
await program.parseAsync(argv);
|
|
4002
4975
|
}
|
|
4976
|
+
var HELP_GROUPS = [
|
|
4977
|
+
{
|
|
4978
|
+
title: "Query",
|
|
4979
|
+
commands: ["search", "show", "health", "list"]
|
|
4980
|
+
},
|
|
4981
|
+
{
|
|
4982
|
+
title: "Edit",
|
|
4983
|
+
commands: ["tag", "untag", "topics"]
|
|
4984
|
+
},
|
|
4985
|
+
{
|
|
4986
|
+
title: "Wiki lifecycle",
|
|
4987
|
+
commands: ["bootstrap", "capture", "hook", "reindex"]
|
|
4988
|
+
},
|
|
4989
|
+
{
|
|
4990
|
+
title: "Setup",
|
|
4991
|
+
commands: ["setup", "uninstall", "doctor"]
|
|
4992
|
+
}
|
|
4993
|
+
];
|
|
4994
|
+
function configureGroupedHelp(program) {
|
|
4995
|
+
program.configureHelp({
|
|
4996
|
+
formatHelp(cmd, helper) {
|
|
4997
|
+
if (cmd.parent !== null) {
|
|
4998
|
+
return renderDefault(cmd, helper);
|
|
4999
|
+
}
|
|
5000
|
+
const termWidth = helper.padWidth(cmd, helper);
|
|
5001
|
+
const helpWidth = helper.helpWidth ?? process.stdout.columns ?? 80;
|
|
5002
|
+
const itemSepWidth = 2;
|
|
5003
|
+
const out = [];
|
|
5004
|
+
out.push(`Usage: ${helper.commandUsage(cmd)}
|
|
5005
|
+
`);
|
|
5006
|
+
const description = helper.commandDescription(cmd);
|
|
5007
|
+
if (description.length > 0) {
|
|
5008
|
+
out.push(
|
|
5009
|
+
helper.wrap(description, helpWidth, 0) + "\n"
|
|
5010
|
+
);
|
|
5011
|
+
}
|
|
5012
|
+
const optionList = helper.visibleOptions(cmd).map(
|
|
5013
|
+
(o) => `${helper.optionTerm(o)}${" ".repeat(Math.max(0, termWidth - helper.optionTerm(o).length) + itemSepWidth)}${helper.optionDescription(o)}`
|
|
5014
|
+
);
|
|
5015
|
+
if (optionList.length > 0) {
|
|
5016
|
+
out.push("Options:");
|
|
5017
|
+
for (const l of optionList) out.push(` ${l}`);
|
|
5018
|
+
out.push("");
|
|
5019
|
+
}
|
|
5020
|
+
const visible = helper.visibleCommands(cmd);
|
|
5021
|
+
const byName = /* @__PURE__ */ new Map();
|
|
5022
|
+
for (const c of visible) byName.set(c.name(), c);
|
|
5023
|
+
for (const group of HELP_GROUPS) {
|
|
5024
|
+
const members = group.commands.map((n) => byName.get(n)).filter((c) => c !== void 0);
|
|
5025
|
+
if (members.length === 0) continue;
|
|
5026
|
+
out.push(`${group.title}:`);
|
|
5027
|
+
for (const c of members) {
|
|
5028
|
+
const term = helper.subcommandTerm(c);
|
|
5029
|
+
const desc = helper.subcommandDescription(c);
|
|
5030
|
+
const padding = Math.max(
|
|
5031
|
+
0,
|
|
5032
|
+
termWidth - term.length + itemSepWidth
|
|
5033
|
+
);
|
|
5034
|
+
out.push(` ${term}${" ".repeat(padding)}${desc}`);
|
|
5035
|
+
byName.delete(c.name());
|
|
5036
|
+
}
|
|
5037
|
+
out.push("");
|
|
5038
|
+
}
|
|
5039
|
+
if (byName.size > 0) {
|
|
5040
|
+
out.push("Other:");
|
|
5041
|
+
for (const c of byName.values()) {
|
|
5042
|
+
const term = helper.subcommandTerm(c);
|
|
5043
|
+
const desc = helper.subcommandDescription(c);
|
|
5044
|
+
const padding = Math.max(
|
|
5045
|
+
0,
|
|
5046
|
+
termWidth - term.length + itemSepWidth
|
|
5047
|
+
);
|
|
5048
|
+
out.push(` ${term}${" ".repeat(padding)}${desc}`);
|
|
5049
|
+
}
|
|
5050
|
+
out.push("");
|
|
5051
|
+
}
|
|
5052
|
+
return out.join("\n");
|
|
5053
|
+
}
|
|
5054
|
+
});
|
|
5055
|
+
}
|
|
5056
|
+
function renderDefault(cmd, helper) {
|
|
5057
|
+
const termWidth = helper.padWidth(cmd, helper);
|
|
5058
|
+
const helpWidth = helper.helpWidth ?? process.stdout.columns ?? 80;
|
|
5059
|
+
const itemSepWidth = 2;
|
|
5060
|
+
const lines = [`Usage: ${helper.commandUsage(cmd)}
|
|
5061
|
+
`];
|
|
5062
|
+
const description = helper.commandDescription(cmd);
|
|
5063
|
+
if (description.length > 0) {
|
|
5064
|
+
lines.push(helper.wrap(description, helpWidth, 0) + "\n");
|
|
5065
|
+
}
|
|
5066
|
+
const args = helper.visibleArguments(cmd).map(
|
|
5067
|
+
(a) => `${helper.argumentTerm(a)}${" ".repeat(Math.max(0, termWidth - helper.argumentTerm(a).length) + itemSepWidth)}${helper.argumentDescription(a)}`
|
|
5068
|
+
);
|
|
5069
|
+
if (args.length > 0) {
|
|
5070
|
+
lines.push("Arguments:");
|
|
5071
|
+
for (const a of args) lines.push(` ${a}`);
|
|
5072
|
+
lines.push("");
|
|
5073
|
+
}
|
|
5074
|
+
const opts = helper.visibleOptions(cmd).map(
|
|
5075
|
+
(o) => `${helper.optionTerm(o)}${" ".repeat(Math.max(0, termWidth - helper.optionTerm(o).length) + itemSepWidth)}${helper.optionDescription(o)}`
|
|
5076
|
+
);
|
|
5077
|
+
if (opts.length > 0) {
|
|
5078
|
+
lines.push("Options:");
|
|
5079
|
+
for (const o of opts) lines.push(` ${o}`);
|
|
5080
|
+
lines.push("");
|
|
5081
|
+
}
|
|
5082
|
+
const subs = helper.visibleCommands(cmd).map(
|
|
5083
|
+
(c) => `${helper.subcommandTerm(c)}${" ".repeat(Math.max(0, termWidth - helper.subcommandTerm(c).length) + itemSepWidth)}${helper.subcommandDescription(c)}`
|
|
5084
|
+
);
|
|
5085
|
+
if (subs.length > 0) {
|
|
5086
|
+
lines.push("Commands:");
|
|
5087
|
+
for (const s of subs) lines.push(` ${s}`);
|
|
5088
|
+
lines.push("");
|
|
5089
|
+
}
|
|
5090
|
+
return lines.join("\n");
|
|
5091
|
+
}
|
|
5092
|
+
function readPackageVersion2() {
|
|
5093
|
+
try {
|
|
5094
|
+
const require2 = createRequire4(import.meta.url);
|
|
5095
|
+
const pkg = require2("../package.json");
|
|
5096
|
+
if (typeof pkg.version === "string" && pkg.version.length > 0) {
|
|
5097
|
+
return pkg.version;
|
|
5098
|
+
}
|
|
5099
|
+
} catch {
|
|
5100
|
+
}
|
|
5101
|
+
return "unknown";
|
|
5102
|
+
}
|
|
4003
5103
|
function emit(result) {
|
|
4004
5104
|
if (result.stderr.length > 0) process.stderr.write(result.stderr);
|
|
4005
5105
|
if (result.stdout.length > 0) process.stdout.write(result.stdout);
|
|
@@ -4023,6 +5123,26 @@ async function readStdin() {
|
|
|
4023
5123
|
}
|
|
4024
5124
|
return Buffer.concat(chunks).toString("utf8");
|
|
4025
5125
|
}
|
|
5126
|
+
function tryParseSetupShortcut(args) {
|
|
5127
|
+
if (args.length === 0) return {};
|
|
5128
|
+
const opts = {};
|
|
5129
|
+
for (const arg of args) {
|
|
5130
|
+
if (arg === "--yes" || arg === "-y") {
|
|
5131
|
+
opts.yes = true;
|
|
5132
|
+
continue;
|
|
5133
|
+
}
|
|
5134
|
+
if (arg === "--skip-hook") {
|
|
5135
|
+
opts.skipHook = true;
|
|
5136
|
+
continue;
|
|
5137
|
+
}
|
|
5138
|
+
if (arg === "--skip-guides") {
|
|
5139
|
+
opts.skipGuides = true;
|
|
5140
|
+
continue;
|
|
5141
|
+
}
|
|
5142
|
+
return null;
|
|
5143
|
+
}
|
|
5144
|
+
return opts;
|
|
5145
|
+
}
|
|
4026
5146
|
|
|
4027
5147
|
// bin/codealmanac.ts
|
|
4028
5148
|
run(process.argv).catch((err) => {
|