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.
@@ -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/init.ts
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 path3 = getRegistryPath();
243
+ const path6 = getRegistryPath();
243
244
  let raw;
244
245
  try {
245
- raw = await readFile2(path3, "utf8");
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 ${path3} is not valid JSON: ${message}`);
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 ${path3} must be a JSON array`);
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 path4 = typeof e.path === "string" ? e.path : "";
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 (path4.length === 0) {
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: path4,
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 path3 = getRegistryPath();
289
- await mkdir(dirname3(path3), { recursive: true });
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 = `${path3}.tmp`;
293
+ const tmpPath = `${path6}.tmp`;
293
294
  await writeFile(tmpPath, body, "utf8");
294
- await rename(tmpPath, path3);
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/init.ts
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 path3 = join3(cwd, ".gitignore");
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(path3)) {
376
- existing = await readFile3(path3, "utf8");
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(path3, `${existing}${sep}${block}`, "utf8");
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 init' or 'almanac bootstrap' first.\n",
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(path3, bytes) {
990
- const content = await readFile4(path3, "utf8");
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/hook.ts
1063
- import { existsSync as existsSync6 } from "fs";
1064
- import { mkdir as mkdir3, readFile as readFile5, rename as rename2, writeFile as writeFile3 } from "fs/promises";
1065
- import { homedir as homedir3 } from "os";
1066
- import path2 from "path";
1067
- import { fileURLToPath as fileURLToPath2 } from "url";
1068
- var HOOK_TIMEOUT_SECONDS = 10;
1069
- async function runHookInstall(options = {}) {
1070
- const script = resolveHookScriptPath(options);
1071
- if (!script.ok) {
1072
- return { stdout: "", stderr: `almanac: ${script.error}
1073
- `, exitCode: 1 };
1074
- }
1075
- const settingsPath = resolveSettingsPath(options);
1076
- const settings = await readSettings(settingsPath);
1077
- const existing = (settings.hooks?.SessionEnd ?? []).slice();
1078
- const ourEntries = existing.filter((e) => e.command === script.path);
1079
- const foreignEntries = existing.filter((e) => e.command !== script.path);
1080
- const stale = foreignEntries.filter(
1081
- (e) => e.command.endsWith("almanac-capture.sh")
1082
- );
1083
- const unrelated = foreignEntries.filter(
1084
- (e) => !e.command.endsWith("almanac-capture.sh")
1085
- );
1086
- if (unrelated.length > 0) {
1087
- const existingStr = unrelated.map((e) => ` - ${e.command}`).join("\n");
1088
- return {
1089
- stdout: "",
1090
- stderr: `almanac: SessionEnd hook already has a foreign entry:
1091
- ${existingStr}
1092
- Remove it manually from ${settingsPath} if you want almanac to manage the hook.
1093
- `,
1094
- exitCode: 1
1095
- };
1096
- }
1097
- if (ourEntries.length > 0 && stale.length === 0) {
1098
- return {
1099
- stdout: `almanac: SessionEnd hook already installed at ${script.path}
1100
- `,
1101
- stderr: "",
1102
- exitCode: 0
1103
- };
1104
- }
1105
- const newEntries = [
1106
- {
1107
- type: "command",
1108
- command: script.path,
1109
- timeout: HOOK_TIMEOUT_SECONDS
1110
- }
1111
- ];
1112
- settings.hooks = { ...settings.hooks ?? {}, SessionEnd: newEntries };
1113
- await writeSettings(settingsPath, settings);
1114
- return {
1115
- stdout: `almanac: SessionEnd hook installed
1116
- script: ${script.path}
1117
- settings: ${settingsPath}
1118
- `,
1119
- stderr: "",
1120
- exitCode: 0
1121
- };
1122
- }
1123
- async function runHookUninstall(options = {}) {
1124
- const settingsPath = resolveSettingsPath(options);
1125
- if (!existsSync6(settingsPath)) {
1126
- return {
1127
- stdout: `almanac: SessionEnd hook not installed (no settings file)
1128
- `,
1129
- stderr: "",
1130
- exitCode: 0
1131
- };
1132
- }
1133
- const settings = await readSettings(settingsPath);
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
- const here = path2.dirname(fileURLToPath2(import.meta.url));
1212
- const candidates = [
1213
- // Bundled: `.../codealmanac/dist/codealmanac.js` `../hooks/…`
1214
- path2.resolve(here, "..", "hooks", "almanac-capture.sh"),
1215
- // Source: `.../codealmanac/src/commands/hook.ts` `../../hooks/…`
1216
- path2.resolve(here, "..", "..", "hooks", "almanac-capture.sh"),
1217
- // Defensive nested fallback.
1218
- path2.resolve(here, "..", "..", "..", "hooks", "almanac-capture.sh")
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
- return {
1226
- ok: false,
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 existsSync10 } from "fs";
1256
- import { readFile as readFile8 } from "fs/promises";
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 existsSync8, statSync as statSync2 } from "fs";
1288
- import { readFile as readFile7, utimes } from "fs/promises";
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 existsSync7 } from "fs";
1294
- import { mkdir as mkdir4, readFile as readFile6, rename as rename3, writeFile as writeFile4 } from "fs/promises";
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(path3) {
1298
- if (!existsSync7(path3)) {
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 readFile6(path3, "utf8");
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
- parsed = yaml2.load(raw);
1317
- } catch (err) {
1318
- const message = err instanceof Error ? err.message : String(err);
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
- const topics = [];
1336
- for (const item of rawTopics) {
1337
- if (typeof item !== "object" || item === null || Array.isArray(item)) {
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
- const entry = item;
1341
- const slugRaw = entry.slug;
1342
- if (typeof slugRaw !== "string" || slugRaw.trim().length === 0) continue;
1343
- const slug = toKebabCase(slugRaw);
1344
- if (slug.length === 0) continue;
1345
- const title = typeof entry.title === "string" && entry.title.trim().length > 0 ? entry.title.trim() : titleCase(slug);
1346
- const description = typeof entry.description === "string" && entry.description.trim().length > 0 ? entry.description.trim() : null;
1347
- const parents = [];
1348
- if (Array.isArray(entry.parents)) {
1349
- for (const p of entry.parents) {
1350
- if (typeof p === "string" && p.trim().length > 0) {
1351
- const ps = toKebabCase(p);
1352
- if (ps.length > 0 && ps !== slug && !parents.includes(ps)) {
1353
- parents.push(ps);
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
- topics.push({ slug, title, description, parents });
1359
- }
1360
- return { topics };
1361
- }
1362
- async function writeTopicsFile(path3, file) {
1363
- const sorted = [...file.topics].sort((a, b) => a.slug.localeCompare(b.slug));
1364
- const doc = {
1365
- topics: sorted.map((t) => {
1366
- return {
1367
- slug: t.slug,
1368
- title: t.title,
1369
- description: t.description,
1370
- parents: t.parents
1371
- };
1372
- })
1373
- };
1374
- const header = `# .almanac/topics.yaml \u2014 source of truth for topic metadata.
1375
- # Managed by \`almanac topics\` commands. User-added comments
1376
- # between entries will be stripped on the next write (js-yaml
1377
- # doesn't round-trip comments). Edit at your own risk \u2014 or use the
1378
- # CLI (\`almanac topics create|link|describe|rename|delete\`)
1379
- # which preserves the structure correctly.
1380
- `;
1381
- const body = yaml2.dump(doc, {
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
- await writeFile4(tmpPath, content, "utf8");
1393
- await rename3(tmpPath, path3);
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
- return null;
1400
- }
1401
- function ensureTopic(file, slug) {
1402
- const existing = findTopic(file, slug);
1403
- if (existing !== null) return existing;
1404
- const entry = {
1405
- slug,
1406
- title: titleCase(slug),
1407
- description: null,
1408
- parents: []
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
- // src/indexer/paths.ts
1422
- function normalizePath(raw, isDir) {
1423
- const normalized = normalizeShape(raw, isDir);
1424
- return normalized.toLowerCase();
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 normalizePathPreservingCase(raw, isDir) {
1427
- return normalizeShape(raw, isDir);
1636
+ function hashContent(raw) {
1637
+ return createHash2("sha256").update(raw).digest("hex");
1428
1638
  }
1429
- function normalizeShape(raw, isDir) {
1430
- let s = raw.trim();
1431
- s = s.replace(/\\+/g, "/");
1432
- while (s.startsWith("./")) s = s.slice(2);
1433
- s = s.replace(/\/+/g, "/");
1434
- s = s.replace(/\/+$/, "");
1435
- if (isDir) {
1436
- return `${s}/`;
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
- // src/indexer/schema.ts
1446
- import Database from "better-sqlite3";
1447
- var SCHEMA_DDL = `
1448
- CREATE TABLE IF NOT EXISTS pages (
1449
- slug TEXT PRIMARY KEY,
1450
- title TEXT,
1451
- file_path TEXT NOT NULL,
1452
- content_hash TEXT NOT NULL,
1453
- updated_at INTEGER NOT NULL,
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.pragma("foreign_keys = ON");
1512
- const rawVersion = db.pragma("user_version", { simple: true });
1513
- const currentVersion = typeof rawVersion === "number" ? rawVersion : 0;
1514
- if (currentVersion < SCHEMA_VERSION) {
1515
- db.exec("DROP TABLE IF EXISTS file_refs");
1516
- try {
1517
- db.exec("UPDATE pages SET content_hash = ''");
1518
- } catch {
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.pragma(`user_version = ${SCHEMA_VERSION}`);
1521
- }
1522
- db.exec(SCHEMA_DDL);
1523
- return db;
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/wikilinks.ts
1527
- function classifyWikilink(raw) {
1528
- const pipe = raw.indexOf("|");
1529
- let body = pipe === -1 ? raw : raw.slice(0, pipe);
1530
- body = body.trim();
1531
- if (body.length === 0) return null;
1532
- const firstColon = body.indexOf(":");
1533
- const firstSlash = body.indexOf("/");
1534
- if (firstColon !== -1 && (firstSlash === -1 || firstColon < firstSlash)) {
1535
- const wiki = body.slice(0, firstColon).trim();
1536
- const target2 = body.slice(firstColon + 1).trim();
1537
- if (wiki.length === 0 || target2.length === 0) return null;
1538
- return { kind: "xwiki", wiki, target: target2 };
1539
- }
1540
- if (firstSlash !== -1) {
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 target = toKebabCase(body);
1548
- if (target.length === 0) return null;
1549
- return { kind: "page", target };
1550
- }
1551
- function extractWikilinks(body) {
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 out;
1733
+ return nearest;
1560
1734
  }
1561
1735
 
1562
- // src/indexer/index.ts
1563
- var TOPICS_YAML_FILENAME = "topics.yaml";
1564
- var PAGES_GLOB = "**/*.md";
1565
- async function ensureFreshIndex(ctx) {
1566
- const almanacDir = join6(ctx.repoRoot, ".almanac");
1567
- const dbPath = join6(almanacDir, "index.db");
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
- if (!existsSync8(dbPath) || pagesNewerThan(pagesDir, dbPath) || topicsYamlNewerThan(almanacDir, dbPath)) {
1575
- return runIndexer(ctx);
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 emptyResult();
1757
+ return ancestors;
1578
1758
  }
1579
- function emptyResult() {
1580
- return {
1581
- changed: 0,
1582
- removed: 0,
1583
- total: 0,
1584
- pagesIndexed: 0,
1585
- filesSeen: 0,
1586
- filesSkipped: 0
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
- async function runIndexer(ctx) {
1590
- const almanacDir = join6(ctx.repoRoot, ".almanac");
1591
- const dbPath = join6(almanacDir, "index.db");
1592
- const pagesDir = join6(almanacDir, "pages");
1593
- const db = openIndex(dbPath);
1594
- let result;
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
- result = await indexPagesInto(db, pagesDir);
1597
- await applyTopicsYaml(db, join6(almanacDir, TOPICS_YAML_FILENAME));
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
- try {
1602
- const now = /* @__PURE__ */ new Date();
1603
- await utimes(dbPath, now, now);
1604
- } catch {
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
- return result;
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
- async function indexPagesInto(db, pagesDir) {
1609
- const files = await fg(PAGES_GLOB, {
1610
- cwd: pagesDir,
1611
- absolute: false,
1612
- onlyFiles: true,
1613
- caseSensitiveMatch: true
1614
- });
1615
- const existingRows = db.prepare("SELECT slug, content_hash, file_path FROM pages").all();
1616
- const existingBySlug = /* @__PURE__ */ new Map();
1617
- for (const row of existingRows) existingBySlug.set(row.slug, row);
1618
- const planned = [];
1619
- const seenSlugs = /* @__PURE__ */ new Set();
1620
- let filesSkipped = 0;
1621
- for (const rel of files) {
1622
- const fullPath = join6(pagesDir, rel);
1623
- const base = basename3(rel, ".md");
1624
- const slug = toKebabCase(base);
1625
- if (slug.length === 0) {
1626
- process.stderr.write(
1627
- `almanac: skipping "${rel}" \u2014 filename has no slug-able characters
1628
- `
1629
- );
1630
- filesSkipped++;
1631
- continue;
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
- if (slug !== base) {
1634
- process.stderr.write(
1635
- `almanac: warning \u2014 "${rel}" is not canonical; indexed as slug "${slug}"
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 (seenSlugs.has(slug)) {
1640
- process.stderr.write(
1641
- `almanac: warning \u2014 slug "${slug}" collides with an earlier file; skipping "${rel}"
1642
- `
1643
- );
1644
- filesSkipped++;
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
- let st;
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
- st = statSync2(fullPath);
1651
- raw = await readFile7(fullPath, "utf8");
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 fm = parseFrontmatter(raw);
1671
- const title = fm.title ?? firstH1(fm.body) ?? base;
1672
- const links = extractWikilinks(fm.body);
1673
- planned.push({
1674
- slug,
1675
- title,
1676
- filePath: rel,
1677
- fullPath,
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
- const toDelete = [];
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
- const entries = fg.sync(PAGES_GLOB, {
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: true,
1979
+ absolute: false,
1811
1980
  onlyFiles: true,
1812
- stats: true
1981
+ caseSensitiveMatch: true
1813
1982
  });
1814
- for (const entry of entries) {
1815
- const mtime = entry.stats?.mtimeMs;
1816
- if (mtime !== void 0 && mtime > dbMtime) return true;
1817
- }
1818
- return false;
1819
- }
1820
- function hashContent(raw) {
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
- try {
1833
- const st = statSync2(path3);
1834
- return st.mtimeMs > dbMtime;
1835
- } catch {
1836
- return false;
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
- async function applyTopicsYaml(db, topicsYamlPath2) {
1840
- if (!existsSync8(topicsYamlPath2)) return;
1841
- let file;
1842
- try {
1843
- file = await loadTopicsFile(topicsYamlPath2);
1844
- } catch (err) {
1845
- const message = err instanceof Error ? err.message : String(err);
1846
- process.stderr.write(`almanac: ${message}
1847
- `);
1848
- return;
1849
- }
1850
- const upsertTopic = db.prepare(
1851
- `INSERT INTO topics (slug, title, description) VALUES (?, ?, ?)
1852
- ON CONFLICT(slug) DO UPDATE SET
1853
- title = excluded.title,
1854
- description = excluded.description`
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
- const clearParents = db.prepare(
1857
- "DELETE FROM topic_parents WHERE child_slug = ?"
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
- const insertParent = db.prepare(
1860
- "INSERT OR IGNORE INTO topic_parents (child_slug, parent_slug) VALUES (?, ?)"
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
- const declared = /* @__PURE__ */ new Set();
1863
- for (const t of file.topics) declared.add(t.slug);
1864
- const adHoc = db.prepare(
1865
- "SELECT DISTINCT topic_slug FROM page_topics"
1866
- ).all();
1867
- for (const r of adHoc) declared.add(r.topic_slug);
1868
- const apply = db.transaction(() => {
1869
- for (const t of file.topics) {
1870
- upsertTopic.run(t.slug, t.title, t.description);
1871
- clearParents.run(t.slug);
1872
- for (const parent of t.parents) {
1873
- if (parent === t.slug) continue;
1874
- insertParent.run(t.slug, parent);
1875
- }
1876
- }
1877
- const existing = db.prepare("SELECT slug FROM topics").all();
1878
- const deleteTopic = db.prepare("DELETE FROM topics WHERE slug = ?");
1879
- const deleteEdgesByChild = db.prepare(
1880
- "DELETE FROM topic_parents WHERE child_slug = ?"
1881
- );
1882
- const deleteEdgesByParent = db.prepare(
1883
- "DELETE FROM topic_parents WHERE parent_slug = ?"
1884
- );
1885
- for (const r of existing) {
1886
- if (declared.has(r.slug)) continue;
1887
- deleteEdgesByChild.run(r.slug);
1888
- deleteEdgesByParent.run(r.slug);
1889
- deleteTopic.run(r.slug);
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/indexer/resolveWiki.ts
1896
- import { existsSync as existsSync9 } from "fs";
1897
- import { join as join7 } from "path";
1898
- async function resolveWikiRoot(params) {
1899
- if (params.wiki !== void 0) {
1900
- const entry = await findEntry({ name: params.wiki });
1901
- if (entry === null) {
1902
- throw new Error(`no registered wiki named "${params.wiki}"`);
1903
- }
1904
- if (!existsSync9(join7(entry.path, ".almanac"))) {
1905
- throw new Error(
1906
- `wiki "${params.wiki}" path is unreachable (${entry.path})`
1907
- );
1908
- }
1909
- return entry.path;
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 nearest = findNearestAlmanacDir(params.cwd);
1912
- if (nearest === null) {
1913
- throw new Error(
1914
- "no .almanac/ found in this directory or any parent; run `almanac init` first"
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
- return nearest;
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
- // src/topics/dag.ts
1921
- var DAG_DEPTH_CAP = 32;
1922
- function ancestorsInFile(file, slug) {
1923
- const parentsOf = /* @__PURE__ */ new Map();
1924
- for (const t of file.topics) {
1925
- parentsOf.set(t.slug, t.parents);
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 ancestors = /* @__PURE__ */ new Set();
1928
- let frontier = parentsOf.get(slug) ?? [];
1929
- let depth = 0;
1930
- while (frontier.length > 0 && depth < DAG_DEPTH_CAP) {
1931
- const next = [];
1932
- for (const node of frontier) {
1933
- if (ancestors.has(node)) continue;
1934
- ancestors.add(node);
1935
- const ps = parentsOf.get(node);
1936
- if (ps !== void 0) next.push(...ps);
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
- return ancestors;
1942
- }
1943
- function descendantsInDb(db, slug) {
1944
- const rows = db.prepare(
1945
- `WITH RECURSIVE desc(slug, depth) AS (
1946
- SELECT child_slug, 1 FROM topic_parents WHERE parent_slug = ?
1947
- UNION
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
- // src/commands/health.ts
1962
- var DEFAULT_STALE_SECONDS = 90 * 24 * 60 * 60;
1963
- async function runHealth(options) {
1964
- const repoRoot = await resolveWikiRoot({ cwd: options.cwd, wiki: options.wiki });
1965
- await ensureFreshIndex({ repoRoot });
1966
- const almanacDir = join8(repoRoot, ".almanac");
1967
- const pagesDir = join8(almanacDir, "pages");
1968
- const db = openIndex(join8(almanacDir, "index.db"));
1969
- try {
1970
- const staleSeconds = options.stale !== void 0 ? parseDuration(options.stale) : DEFAULT_STALE_SECONDS;
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
- if (options.json === true) {
1983
- return {
1984
- stdout: `${JSON.stringify(report, null, 2)}
1985
- `,
1986
- stderr: "",
1987
- exitCode: 0
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: formatReport(report),
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 resolveScope(db, options) {
2000
- let pages = null;
2001
- let topics = null;
2002
- if (options.topic !== void 0) {
2003
- const rootSlug = toKebabCase(options.topic);
2004
- if (rootSlug.length > 0) {
2005
- const subtree = subtreeInDb(db, rootSlug);
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
- if (options.stdin === true && options.stdinInput !== void 0) {
2016
- const stdinPages = /* @__PURE__ */ new Set();
2017
- for (const line of options.stdinInput.split(/\r?\n/)) {
2018
- const s = line.trim();
2019
- if (s.length > 0) stdinPages.add(s);
2020
- }
2021
- if (pages === null) pages = stdinPages;
2022
- else {
2023
- const out = /* @__PURE__ */ new Set();
2024
- for (const s of stdinPages) if (pages.has(s)) out.add(s);
2025
- pages = out;
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 { pages, topics };
2029
- }
2030
- function inPageScope(scope, slug) {
2031
- if (scope.pages === null) return true;
2032
- return scope.pages.has(slug);
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 findOrphans(db, scope) {
2035
- const rows = db.prepare(
2036
- `SELECT p.slug FROM pages p
2037
- WHERE p.archived_at IS NULL
2038
- AND NOT EXISTS (
2039
- SELECT 1 FROM page_topics pt WHERE pt.page_slug = p.slug
2040
- )
2041
- ORDER BY p.slug`
2042
- ).all();
2043
- return rows.filter((r) => inPageScope(scope, r.slug));
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 findStale(db, scope, staleSeconds) {
2046
- const now = Math.floor(Date.now() / 1e3);
2047
- const threshold = now - staleSeconds;
2048
- const rows = db.prepare(
2049
- `SELECT slug, updated_at FROM pages
2050
- WHERE archived_at IS NULL AND updated_at < ?
2051
- ORDER BY updated_at ASC`
2052
- ).all(threshold);
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
- async function findDeadRefs(db, scope, repoRoot) {
2059
- const rows = db.prepare(
2060
- `SELECT p.slug, r.path, r.original_path, r.is_dir
2061
- FROM file_refs r
2062
- JOIN pages p ON p.slug = r.page_slug
2063
- WHERE p.archived_at IS NULL
2064
- ORDER BY p.slug, r.path`
2065
- ).all();
2066
- const out = [];
2067
- for (const r of rows) {
2068
- if (!inPageScope(scope, r.slug)) continue;
2069
- const abs = join8(repoRoot, r.original_path);
2070
- if (!existsSync10(abs)) {
2071
- out.push({ slug: r.slug, path: r.original_path });
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
- return out;
2308
+ out.write(`
2309
+ ${WHITE_BOLD} Install the hook + agent guides${RST}
2310
+ `);
2075
2311
  }
2076
- function findBrokenLinks(db, scope) {
2077
- const rows = db.prepare(
2078
- `SELECT w.source_slug, w.target_slug
2079
- FROM wikilinks w
2080
- JOIN pages src ON src.slug = w.source_slug
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
- async function findBrokenXwiki(db, scope) {
2088
- const rows = db.prepare(
2089
- // Same archived-source filter as `findBrokenLinks`. Retired pages
2090
- // shouldn't spam the report with links to wikis that may have
2091
- // been intentionally retired too.
2092
- `SELECT x.source_slug, x.target_wiki, x.target_slug
2093
- FROM cross_wiki_links x
2094
- JOIN pages src ON src.slug = x.source_slug
2095
- WHERE src.archived_at IS NULL
2096
- ORDER BY x.source_slug, x.target_wiki, x.target_slug`
2097
- ).all();
2098
- const out = [];
2099
- const reachableCache = /* @__PURE__ */ new Map();
2100
- for (const r of rows) {
2101
- if (!inPageScope(scope, r.source_slug)) continue;
2102
- let ok = reachableCache.get(r.target_wiki);
2103
- if (ok === void 0) {
2104
- const entry = await findEntry({ name: r.target_wiki });
2105
- ok = entry !== null && existsSync10(join8(entry.path, ".almanac"));
2106
- reachableCache.set(r.target_wiki, ok);
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
- if (!ok) {
2109
- out.push({
2110
- source_slug: r.source_slug,
2111
- target_wiki: r.target_wiki,
2112
- target_slug: r.target_slug
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
- return out;
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 findEmptyTopics(db, scope) {
2119
- const rows = db.prepare(
2120
- `SELECT t.slug FROM topics t
2121
- WHERE NOT EXISTS (
2122
- SELECT 1 FROM page_topics pt WHERE pt.topic_slug = t.slug
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
- async function findEmptyPages(db, scope, pagesDir) {
2130
- const rows = db.prepare(
2131
- `SELECT slug, file_path FROM pages
2132
- WHERE archived_at IS NULL
2133
- ORDER BY slug`
2134
- ).all();
2135
- const out = [];
2136
- for (const r of rows) {
2137
- if (!inPageScope(scope, r.slug)) continue;
2138
- let raw;
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
- return out;
2158
- }
2159
- async function findSlugCollisions(pagesDir) {
2160
- if (!existsSync10(pagesDir)) return [];
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
- const out = [];
2176
- for (const [slug, paths] of bySlug.entries()) {
2177
- if (paths.length > 1) {
2178
- out.push({ slug, paths: paths.sort() });
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
- out.sort((a, b) => a.slug.localeCompare(b.slug));
2182
- return out;
2467
+ await copyFile(src, dest);
2468
+ return true;
2183
2469
  }
2184
- function formatReport(r) {
2185
- const sections = [];
2186
- sections.push(
2187
- section(
2188
- "orphans",
2189
- r.orphans.length,
2190
- r.orphans.map((o) => ` ${o.slug}`)
2191
- )
2192
- );
2193
- sections.push(
2194
- section(
2195
- "stale",
2196
- r.stale.length,
2197
- r.stale.map((s) => ` ${s.slug} (${s.days_since_update} days)`)
2198
- )
2199
- );
2200
- sections.push(
2201
- section(
2202
- "dead-refs",
2203
- r.dead_refs.length,
2204
- r.dead_refs.map((d) => ` ${d.slug} references ${d.path} (missing)`)
2205
- )
2206
- );
2207
- sections.push(
2208
- section(
2209
- "broken-links",
2210
- r.broken_links.length,
2211
- r.broken_links.map(
2212
- (b) => ` ${b.source_slug} \u2192 ${b.target_slug} (target does not exist)`
2213
- )
2214
- )
2215
- );
2216
- sections.push(
2217
- section(
2218
- "broken-xwiki",
2219
- r.broken_xwiki.length,
2220
- r.broken_xwiki.map(
2221
- (b) => ` ${b.source_slug} \u2192 ${b.target_wiki}:${b.target_slug} (wiki unregistered or unreachable)`
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
- sections.push(
2226
- section(
2227
- "empty-topics",
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
- sections.push(
2233
- section(
2234
- "empty-pages",
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
- sections.push(
2240
- section(
2241
- "slug-collisions",
2242
- r.slug_collisions.length,
2243
- r.slug_collisions.map((c) => ` ${c.slug}: ${c.paths.join(", ")}`)
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 section(label, count, lines) {
2250
- if (count === 0) return `${label} (0): (ok)`;
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/info.ts
2256
- import { join as join9 } from "path";
2257
- async function runInfo(options) {
2258
- const repoRoot = await resolveWikiRoot({
2259
- cwd: options.cwd,
2260
- wiki: options.wiki
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
- await ensureFreshIndex({ repoRoot });
2263
- const dbPath = join9(repoRoot, ".almanac", "index.db");
2264
- const db = openIndex(dbPath);
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 slugs = collectSlugs(options);
2267
- if (slugs.length === 0) {
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
- stdout: "",
2270
- stderr: "almanac: info requires a slug (or --stdin)\n",
2271
- exitCode: 1
2671
+ status: "problem",
2672
+ key: "install.hook",
2673
+ message: "SessionEnd hook not installed",
2674
+ fix: "run: almanac setup --yes"
2272
2675
  };
2273
2676
  }
2274
- const records = [];
2275
- const missing = [];
2276
- for (const slug of slugs) {
2277
- const rec = fetchInfo(db, slug);
2278
- if (rec === null) {
2279
- missing.push(slug);
2280
- continue;
2281
- }
2282
- records.push(rec);
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
- const bulk = options.stdin === true;
2285
- const jsonOut = options.json === true || bulk;
2286
- let stdout;
2287
- if (jsonOut) {
2288
- if (bulk) {
2289
- stdout = `${JSON.stringify(records, null, 2)}
2290
- `;
2291
- } else {
2292
- const only = records[0] ?? null;
2293
- stdout = `${JSON.stringify(only, null, 2)}
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
- stdout = records.map(formatHumanReadable).join("\n");
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
- const stderr = missing.map((s) => `almanac: no such page "${s}"
2300
- `).join("");
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
- stdout,
2303
- stderr,
2304
- exitCode: missing.length > 0 ? 1 : 0
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
- function fetchInfo(db, slug) {
2311
- const pageRow = db.prepare(
2312
- "SELECT slug, title, file_path, updated_at, archived_at, superseded_by FROM pages WHERE slug = ?"
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
- slug: pageRow.slug,
2338
- title: pageRow.title,
2339
- file_path: pageRow.file_path,
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
- function collectSlugs(options) {
2352
- if (options.stdin === true && options.stdinInput !== void 0) {
2353
- return options.stdinInput.split(/\r?\n/).map((s) => s.trim()).filter((s) => s.length > 0);
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
- if (options.slug !== void 0 && options.slug.length > 0) {
2356
- return [options.slug];
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 formatHumanReadable(rec) {
2361
- const lines = [];
2362
- lines.push(`slug: ${rec.slug}`);
2363
- lines.push(`title: ${rec.title ?? "\u2014"}`);
2364
- lines.push(`file: ${rec.file_path}`);
2365
- lines.push(`updated_at: ${new Date(rec.updated_at * 1e3).toISOString()}`);
2366
- if (rec.archived_at !== null) {
2367
- lines.push(
2368
- `archived_at: ${new Date(rec.archived_at * 1e3).toISOString()}`
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
- if (rec.superseded_by !== null) {
2372
- lines.push(`superseded_by: ${rec.superseded_by}`);
2956
+ }
2957
+ async function safeCheckAuth2(spawnCli) {
2958
+ try {
2959
+ return await checkClaudeAuth(spawnCli);
2960
+ } catch {
2961
+ return { loggedIn: false };
2373
2962
  }
2374
- if (rec.supersedes.length > 0) {
2375
- lines.push(`supersedes: ${rec.supersedes.join(", ")}`);
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
- lines.push(`topics: ${rec.topics.length > 0 ? rec.topics.join(", ") : "\u2014"}`);
2378
- lines.push("file_refs:");
2379
- if (rec.file_refs.length === 0) {
2380
- lines.push(" \u2014");
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
- lines.push("wikilinks_out:");
2387
- if (rec.wikilinks_out.length === 0) lines.push(" \u2014");
2388
- else for (const t of rec.wikilinks_out) lines.push(` ${t}`);
2389
- lines.push("wikilinks_in:");
2390
- if (rec.wikilinks_in.length === 0) lines.push(" \u2014");
2391
- else for (const s of rec.wikilinks_in) lines.push(` ${s}`);
2392
- if (rec.cross_wiki_links.length > 0) {
2393
- lines.push("cross_wiki_links:");
2394
- for (const x of rec.cross_wiki_links) {
2395
- lines.push(` ${x.wiki}:${x.target}`);
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 existsSync11 } from "fs";
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 existsSync11(entry.path);
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 init` in a repo to create one.\n";
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 join11 } from "path";
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 = join11(repoRoot, ".almanac", "index.db");
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 readFile9 } from "fs/promises";
2700
- import { join as join12 } from "path";
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 = join12(repoRoot, ".almanac", "index.db");
3286
+ const dbPath = join10(repoRoot, ".almanac", "index.db");
2708
3287
  const db = openIndex(dbPath);
2709
3288
  try {
2710
- const slugs = collectSlugs3(options);
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 stmt = db.prepare(
2719
- "SELECT file_path FROM pages WHERE slug = ?"
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 collectSlugs3(options) {
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 readFile10, rename as rename4, writeFile as writeFile5 } from "fs/promises";
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 readFile10(filePath, "utf8");
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 writeFile5(tmp, output, "utf8");
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 join13 } from "path";
3809
+ import { join as join11 } from "path";
2984
3810
  function topicsYamlPath(repoRoot) {
2985
- return join13(repoRoot, ".almanac", "topics.yaml");
3811
+ return join11(repoRoot, ".almanac", "topics.yaml");
2986
3812
  }
2987
3813
  function indexDbPath(repoRoot) {
2988
- return join13(repoRoot, ".almanac", "index.db");
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 readFile11 } from "fs/promises";
3148
- import { join as join14 } from "path";
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 = join14(repoRoot, ".almanac", "pages");
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 readFile11(filePath, "utf8");
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 existsSync12 } from "fs";
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 (!existsSync12(repoRoot)) return null;
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("0.1.0", "-v, --version", "print version");
3695
- program.command("init").description("scaffold .almanac/ in the current directory and register it").option("--name <name>", "wiki name (defaults to the directory name)").option("--description <text>", "one-line description of this wiki").action(async (opts) => {
3696
- const result = await initWiki({
3697
- cwd: process.cwd(),
3698
- name: opts.name,
3699
- description: opts.description
3700
- });
3701
- const verb = result.created ? "initialized" : "updated";
3702
- process.stdout.write(
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("query pages by text, topic, file mentions, or freshness").option(
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 file or folder (trailing / = folder)"
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
- if (result.stderr.length > 0) process.stderr.write(result.stderr);
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 the markdown content of a page").option("--stdin", "read slugs from stdin (one per line)").option("--wiki <name>", "target a specific registered wiki").action(
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
- if (result.stderr.length > 0) process.stderr.write(result.stderr);
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("path [slug]").description("resolve a slug to its absolute file path").option("--stdin", "read slugs from stdin (one per line)").option("--wiki <name>", "target a specific registered wiki").action(
3772
- async (slug, opts) => {
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 runPath({
4729
+ const result = await runHealth({
3775
4730
  cwd: process.cwd(),
3776
- slug,
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
- if (result.stderr.length > 0) process.stderr.write(result.stderr);
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("info [slug]").description("print metadata for a page (topics, refs, links, lineage)").option("--stdin", "read slugs from stdin (one per line)").option("--json", "emit structured JSON").option("--wiki <name>", "target a specific registered wiki").action(
3787
- async (slug, opts) => {
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 result = await runInfo({
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
- slug,
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
- if (result.stderr.length > 0) process.stderr.write(result.stderr);
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("reindex").description("force a full rebuild of .almanac/index.db").option("--wiki <name>", "target a specific registered wiki").action(async (opts) => {
3803
- await autoRegisterIfNeeded(process.cwd());
3804
- const result = await runReindex({
3805
- cwd: process.cwd(),
3806
- wiki: opts.wiki
3807
- });
3808
- process.stdout.write(result.stdout);
3809
- if (result.exitCode !== 0) process.exitCode = result.exitCode;
3810
- });
3811
- const topics = program.command("topics").description("manage the topic DAG (list, create, link, rename, delete)");
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
- "spawn an agent to scan the repo and create initial wiki stubs (requires ANTHROPIC_API_KEY)"
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
- "capture knowledge from a Claude Code session transcript (auto-resolves the most recent session for this repo when no path is given; requires ANTHROPIC_API_KEY)"
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("health").description("report wiki problems (orphans, dead refs, broken links, \u2026)").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(
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 autoRegisterIfNeeded(process.cwd());
3989
- const result = await runHealth({
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
- wiki: opts.wiki
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) => {