codealmanac 0.1.2 → 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,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/cli.ts
4
- import { createRequire as createRequire3 } from "module";
4
+ import { createRequire as createRequire4 } from "module";
5
5
  import { basename as basename6 } from "path";
6
6
  import { Command } from "commander";
7
7
 
@@ -240,10 +240,10 @@ function toKebabCase(input) {
240
240
  import { mkdir, readFile as readFile2, rename, writeFile } from "fs/promises";
241
241
  import { dirname as dirname3 } from "path";
242
242
  async function readRegistry() {
243
- const path5 = getRegistryPath();
243
+ const path6 = getRegistryPath();
244
244
  let raw;
245
245
  try {
246
- raw = await readFile2(path5, "utf8");
246
+ raw = await readFile2(path6, "utf8");
247
247
  } catch (err) {
248
248
  if (isNodeError(err) && err.code === "ENOENT") {
249
249
  return [];
@@ -259,10 +259,10 @@ async function readRegistry() {
259
259
  parsed = JSON.parse(trimmed);
260
260
  } catch (err) {
261
261
  const message = err instanceof Error ? err.message : String(err);
262
- throw new Error(`registry at ${path5} is not valid JSON: ${message}`);
262
+ throw new Error(`registry at ${path6} is not valid JSON: ${message}`);
263
263
  }
264
264
  if (!Array.isArray(parsed)) {
265
- throw new Error(`registry at ${path5} must be a JSON array`);
265
+ throw new Error(`registry at ${path6} must be a JSON array`);
266
266
  }
267
267
  return parsed.map((item, idx) => {
268
268
  if (typeof item !== "object" || item === null) {
@@ -270,29 +270,29 @@ async function readRegistry() {
270
270
  }
271
271
  const e = item;
272
272
  const name = typeof e.name === "string" ? e.name : "";
273
- const path6 = typeof e.path === "string" ? e.path : "";
273
+ const path7 = typeof e.path === "string" ? e.path : "";
274
274
  if (name.length === 0) {
275
275
  throw new Error(`registry entry ${idx} is missing a non-empty "name"`);
276
276
  }
277
- if (path6.length === 0) {
277
+ if (path7.length === 0) {
278
278
  throw new Error(`registry entry ${idx} is missing a non-empty "path"`);
279
279
  }
280
280
  return {
281
281
  name,
282
282
  description: typeof e.description === "string" ? e.description : "",
283
- path: path6,
283
+ path: path7,
284
284
  registered_at: typeof e.registered_at === "string" ? e.registered_at : ""
285
285
  };
286
286
  });
287
287
  }
288
288
  async function writeRegistry(entries) {
289
- const path5 = getRegistryPath();
290
- await mkdir(dirname3(path5), { recursive: true });
289
+ const path6 = getRegistryPath();
290
+ await mkdir(dirname3(path6), { recursive: true });
291
291
  const body = `${JSON.stringify(entries, null, 2)}
292
292
  `;
293
- const tmpPath = `${path5}.tmp`;
293
+ const tmpPath = `${path6}.tmp`;
294
294
  await writeFile(tmpPath, body, "utf8");
295
- await rename(tmpPath, path5);
295
+ await rename(tmpPath, path6);
296
296
  }
297
297
  function pathsEqual(a, b) {
298
298
  if (process.platform === "darwin" || process.platform === "win32") {
@@ -366,15 +366,15 @@ async function initWiki(options) {
366
366
  return { entry, almanacDir, created: !alreadyExisted };
367
367
  }
368
368
  async function ensureGitignoreHasIndexDb(cwd) {
369
- const path5 = join3(cwd, ".gitignore");
369
+ const path6 = join3(cwd, ".gitignore");
370
370
  const targets = [
371
371
  ".almanac/index.db",
372
372
  ".almanac/index.db-wal",
373
373
  ".almanac/index.db-shm"
374
374
  ];
375
375
  let existing = "";
376
- if (existsSync3(path5)) {
377
- existing = await readFile3(path5, "utf8");
376
+ if (existsSync3(path6)) {
377
+ existing = await readFile3(path6, "utf8");
378
378
  }
379
379
  const lines = existing.split(/\r?\n/).map((l) => l.trim());
380
380
  const missing = targets.filter((t) => !lines.includes(t));
@@ -384,7 +384,7 @@ async function ensureGitignoreHasIndexDb(cwd) {
384
384
  ${missing.join("\n")}
385
385
  `;
386
386
  const sep = existing.length === 0 ? "" : existing.endsWith("\n") ? "\n" : "\n\n";
387
- await writeFile2(path5, `${existing}${sep}${block}`, "utf8");
387
+ await writeFile2(path6, `${existing}${sep}${block}`, "utf8");
388
388
  }
389
389
  function starterReadme() {
390
390
  return `# Wiki
@@ -808,7 +808,7 @@ async function runCapture(options) {
808
808
  if (repoRoot === null) {
809
809
  return {
810
810
  stdout: "",
811
- 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",
812
812
  exitCode: 1
813
813
  };
814
814
  }
@@ -987,8 +987,8 @@ async function filterTranscriptsByCwd(transcripts, repoRoot) {
987
987
  }
988
988
  return hits;
989
989
  }
990
- async function readHead(path5, bytes) {
991
- const content = await readFile4(path5, "utf8");
990
+ async function readHead(path6, bytes) {
991
+ const content = await readFile4(path6, "utf8");
992
992
  return content.length > bytes ? content.slice(0, bytes) : content;
993
993
  }
994
994
  async function snapshotPages(pagesDir) {
@@ -1060,278 +1060,174 @@ function closeStream2(stream) {
1060
1060
  });
1061
1061
  }
1062
1062
 
1063
- // src/commands/hook.ts
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");
1135
+ }
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 {
1144
+ }
1145
+ db.pragma(`user_version = ${SCHEMA_VERSION}`);
1146
+ }
1147
+ db.exec(SCHEMA_DDL);
1148
+ return db;
1149
+ }
1150
+
1151
+ // src/commands/health.ts
1152
+ import { existsSync as existsSync9 } from "fs";
1153
+ import { readFile as readFile7 } from "fs/promises";
1154
+ import { basename as basename4, join as join8 } from "path";
1155
+ import fg2 from "fast-glob";
1156
+
1157
+ // src/indexer/duration.ts
1158
+ function parseDuration(input) {
1159
+ const trimmed = input.trim();
1160
+ const m = trimmed.match(/^(\d+)([mhdw])$/);
1161
+ if (m === null) {
1162
+ throw new Error(
1163
+ `invalid duration "${input}" (expected Nw, Nd, Nh, or Nm \u2014 e.g. 2w, 30d)`
1164
+ );
1165
+ }
1166
+ const n = Number.parseInt(m[1] ?? "0", 10);
1167
+ const unit = m[2];
1168
+ switch (unit) {
1169
+ case "m":
1170
+ return n * 60;
1171
+ case "h":
1172
+ return n * 60 * 60;
1173
+ case "d":
1174
+ return n * 60 * 60 * 24;
1175
+ case "w":
1176
+ return n * 60 * 60 * 24 * 7;
1177
+ default:
1178
+ throw new Error(`invalid duration unit "${unit ?? ""}"`);
1179
+ }
1180
+ }
1181
+
1182
+ // src/indexer/index.ts
1183
+ import { createHash as createHash2 } from "crypto";
1184
+ import { existsSync as existsSync7, statSync as statSync2 } from "fs";
1185
+ import { readFile as readFile6, utimes } from "fs/promises";
1186
+ import { basename as basename3, join as join6, relative as relative3 } from "path";
1187
+ import fg from "fast-glob";
1188
+
1189
+ // src/topics/yaml.ts
1064
1190
  import { existsSync as existsSync6 } from "fs";
1065
1191
  import { mkdir as mkdir3, readFile as readFile5, rename as rename2, writeFile as writeFile3 } from "fs/promises";
1066
- import { homedir as homedir3 } from "os";
1067
- import path2 from "path";
1068
- import { fileURLToPath as fileURLToPath2 } from "url";
1069
- var HOOK_TIMEOUT_SECONDS = 10;
1070
- async function runHookInstall(options = {}) {
1071
- const script = resolveHookScriptPath(options);
1072
- if (!script.ok) {
1073
- return { stdout: "", stderr: `almanac: ${script.error}
1074
- `, exitCode: 1 };
1192
+ import { dirname as dirname4 } from "path";
1193
+ import yaml2 from "js-yaml";
1194
+ async function loadTopicsFile(path6) {
1195
+ if (!existsSync6(path6)) {
1196
+ return { topics: [] };
1075
1197
  }
1076
- const settingsPath = resolveSettingsPath(options);
1077
- const settings = await readSettings(settingsPath);
1078
- const existing = (settings.hooks?.SessionEnd ?? []).slice();
1079
- const ourEntries = existing.filter((e) => e.command === script.path);
1080
- const foreignEntries = existing.filter((e) => e.command !== script.path);
1081
- const stale = foreignEntries.filter(
1082
- (e) => e.command.endsWith("almanac-capture.sh")
1083
- );
1084
- const unrelated = foreignEntries.filter(
1085
- (e) => !e.command.endsWith("almanac-capture.sh")
1086
- );
1087
- if (unrelated.length > 0) {
1088
- const existingStr = unrelated.map((e) => ` - ${e.command}`).join("\n");
1089
- return {
1090
- stdout: "",
1091
- stderr: `almanac: SessionEnd hook already has a foreign entry:
1092
- ${existingStr}
1093
- Remove it manually from ${settingsPath} if you want almanac to manage the hook.
1094
- `,
1095
- exitCode: 1
1096
- };
1198
+ let raw;
1199
+ try {
1200
+ raw = await readFile5(path6, "utf8");
1201
+ } catch (err) {
1202
+ if (isNodeError2(err) && err.code === "ENOENT") {
1203
+ return { topics: [] };
1204
+ }
1205
+ throw err;
1097
1206
  }
1098
- if (ourEntries.length > 0 && stale.length === 0) {
1099
- return {
1100
- stdout: `almanac: SessionEnd hook already installed at ${script.path}
1101
- `,
1102
- stderr: "",
1103
- exitCode: 0
1104
- };
1207
+ const trimmed = raw.trim();
1208
+ if (trimmed.length === 0) {
1209
+ return { topics: [] };
1105
1210
  }
1106
- const newEntries = [
1107
- {
1108
- type: "command",
1109
- command: script.path,
1110
- timeout: HOOK_TIMEOUT_SECONDS
1111
- }
1112
- ];
1113
- settings.hooks = { ...settings.hooks ?? {}, SessionEnd: newEntries };
1114
- await writeSettings(settingsPath, settings);
1115
- return {
1116
- stdout: `almanac: SessionEnd hook installed
1117
- script: ${script.path}
1118
- settings: ${settingsPath}
1119
- `,
1120
- stderr: "",
1121
- exitCode: 0
1122
- };
1123
- }
1124
- async function runHookUninstall(options = {}) {
1125
- const settingsPath = resolveSettingsPath(options);
1126
- if (!existsSync6(settingsPath)) {
1127
- return {
1128
- stdout: `almanac: SessionEnd hook not installed (no settings file)
1129
- `,
1130
- stderr: "",
1131
- exitCode: 0
1132
- };
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}`);
1133
1217
  }
1134
- const settings = await readSettings(settingsPath);
1135
- const existing = (settings.hooks?.SessionEnd ?? []).slice();
1136
- const kept = existing.filter((e) => !e.command.endsWith("almanac-capture.sh"));
1137
- const removed = existing.length - kept.length;
1138
- if (removed === 0) {
1139
- return {
1140
- stdout: `almanac: SessionEnd hook not installed
1141
- `,
1142
- stderr: "",
1143
- exitCode: 0
1144
- };
1218
+ if (parsed === null || parsed === void 0) {
1219
+ return { topics: [] };
1145
1220
  }
1146
- if (settings.hooks !== void 0) {
1147
- if (kept.length === 0) {
1148
- const { SessionEnd: _dropped, ...rest } = settings.hooks;
1149
- void _dropped;
1150
- settings.hooks = rest;
1151
- } else {
1152
- settings.hooks = { ...settings.hooks, SessionEnd: kept };
1153
- }
1154
- if (Object.keys(settings.hooks).length === 0) {
1155
- delete settings.hooks;
1156
- }
1221
+ if (typeof parsed !== "object" || Array.isArray(parsed)) {
1222
+ throw new Error(`topics.yaml at ${path6} must be a mapping`);
1157
1223
  }
1158
- await writeSettings(settingsPath, settings);
1159
- return {
1160
- stdout: `almanac: SessionEnd hook removed
1161
- `,
1162
- stderr: "",
1163
- exitCode: 0
1164
- };
1165
- }
1166
- async function runHookStatus(options = {}) {
1167
- const script = resolveHookScriptPath(options);
1168
- const settingsPath = resolveSettingsPath(options);
1169
- if (!existsSync6(settingsPath)) {
1170
- return {
1171
- stdout: `SessionEnd hook: not installed
1172
- settings: ${settingsPath} (does not exist)
1173
- ` + (script.ok ? `script would be: ${script.path}
1174
- ` : ""),
1175
- stderr: "",
1176
- exitCode: 0
1177
- };
1178
- }
1179
- const settings = await readSettings(settingsPath);
1180
- const existing = settings.hooks?.SessionEnd ?? [];
1181
- const ours = existing.find((e) => e.command.endsWith("almanac-capture.sh"));
1182
- if (ours === void 0) {
1183
- const foreign = existing.map((e) => ` - ${e.command}`).join("\n");
1184
- return {
1185
- stdout: `SessionEnd hook: not installed
1186
- settings: ${settingsPath}
1187
- ` + (existing.length > 0 ? `(${existing.length} foreign entr${existing.length === 1 ? "y" : "ies"} present:
1188
- ${foreign})
1189
- ` : "") + (script.ok ? `script would be: ${script.path}
1190
- ` : ""),
1191
- stderr: "",
1192
- exitCode: 0
1193
- };
1194
- }
1195
- return {
1196
- stdout: `SessionEnd hook: installed
1197
- script: ${ours.command}
1198
- settings: ${settingsPath}
1199
- `,
1200
- stderr: "",
1201
- exitCode: 0
1202
- };
1203
- }
1204
- function resolveSettingsPath(options) {
1205
- if (options.settingsPath !== void 0) return options.settingsPath;
1206
- return path2.join(homedir3(), ".claude", "settings.json");
1207
- }
1208
- function resolveHookScriptPath(options) {
1209
- if (options.hookScriptPath !== void 0) {
1210
- return { ok: true, path: options.hookScriptPath };
1211
- }
1212
- const here = path2.dirname(fileURLToPath2(import.meta.url));
1213
- const candidates = [
1214
- // Bundled: `.../codealmanac/dist/codealmanac.js` → `../hooks/…`
1215
- path2.resolve(here, "..", "hooks", "almanac-capture.sh"),
1216
- // Source: `.../codealmanac/src/commands/hook.ts` → `../../hooks/…`
1217
- path2.resolve(here, "..", "..", "hooks", "almanac-capture.sh"),
1218
- // Defensive nested fallback.
1219
- path2.resolve(here, "..", "..", "..", "hooks", "almanac-capture.sh")
1220
- ];
1221
- for (const candidate of candidates) {
1222
- if (existsSync6(candidate)) {
1223
- return { ok: true, path: candidate };
1224
- }
1225
- }
1226
- return {
1227
- ok: false,
1228
- error: `could not locate hooks/almanac-capture.sh. Tried:
1229
- ` + candidates.map((c) => ` - ${c}`).join("\n")
1230
- };
1231
- }
1232
- async function readSettings(settingsPath) {
1233
- if (!existsSync6(settingsPath)) return {};
1234
- try {
1235
- const raw = await readFile5(settingsPath, "utf8");
1236
- if (raw.trim().length === 0) return {};
1237
- const parsed = JSON.parse(raw);
1238
- if (parsed === null || typeof parsed !== "object") return {};
1239
- return parsed;
1240
- } catch (err) {
1241
- const msg = err instanceof Error ? err.message : String(err);
1242
- throw new Error(`failed to read ${settingsPath}: ${msg}`);
1243
- }
1244
- }
1245
- async function writeSettings(settingsPath, settings) {
1246
- const dir = path2.dirname(settingsPath);
1247
- await mkdir3(dir, { recursive: true });
1248
- const tmp = `${settingsPath}.almanac-tmp-${process.pid}`;
1249
- const body = `${JSON.stringify(settings, null, 2)}
1250
- `;
1251
- await writeFile3(tmp, body, "utf8");
1252
- await rename2(tmp, settingsPath);
1253
- }
1254
-
1255
- // src/commands/health.ts
1256
- import { existsSync as existsSync10 } from "fs";
1257
- import { readFile as readFile8 } from "fs/promises";
1258
- import { basename as basename4, join as join8 } from "path";
1259
- import fg2 from "fast-glob";
1260
-
1261
- // src/indexer/duration.ts
1262
- function parseDuration(input) {
1263
- const trimmed = input.trim();
1264
- const m = trimmed.match(/^(\d+)([mhdw])$/);
1265
- if (m === null) {
1266
- throw new Error(
1267
- `invalid duration "${input}" (expected Nw, Nd, Nh, or Nm \u2014 e.g. 2w, 30d)`
1268
- );
1269
- }
1270
- const n = Number.parseInt(m[1] ?? "0", 10);
1271
- const unit = m[2];
1272
- switch (unit) {
1273
- case "m":
1274
- return n * 60;
1275
- case "h":
1276
- return n * 60 * 60;
1277
- case "d":
1278
- return n * 60 * 60 * 24;
1279
- case "w":
1280
- return n * 60 * 60 * 24 * 7;
1281
- default:
1282
- throw new Error(`invalid duration unit "${unit ?? ""}"`);
1283
- }
1284
- }
1285
-
1286
- // src/indexer/index.ts
1287
- import { createHash as createHash2 } from "crypto";
1288
- import { existsSync as existsSync8, statSync as statSync2 } from "fs";
1289
- import { readFile as readFile7, utimes } from "fs/promises";
1290
- import { basename as basename3, join as join6, relative as relative3 } from "path";
1291
- import fg from "fast-glob";
1292
-
1293
- // src/topics/yaml.ts
1294
- import { existsSync as existsSync7 } from "fs";
1295
- import { mkdir as mkdir4, readFile as readFile6, rename as rename3, writeFile as writeFile4 } from "fs/promises";
1296
- import { dirname as dirname4 } from "path";
1297
- import yaml2 from "js-yaml";
1298
- async function loadTopicsFile(path5) {
1299
- if (!existsSync7(path5)) {
1300
- return { topics: [] };
1301
- }
1302
- let raw;
1303
- try {
1304
- raw = await readFile6(path5, "utf8");
1305
- } catch (err) {
1306
- if (isNodeError2(err) && err.code === "ENOENT") {
1307
- return { topics: [] };
1308
- }
1309
- throw err;
1310
- }
1311
- const trimmed = raw.trim();
1312
- if (trimmed.length === 0) {
1313
- return { topics: [] };
1314
- }
1315
- let parsed;
1316
- try {
1317
- parsed = yaml2.load(raw);
1318
- } catch (err) {
1319
- const message = err instanceof Error ? err.message : String(err);
1320
- throw new Error(`topics.yaml at ${path5} is not valid YAML: ${message}`);
1321
- }
1322
- if (parsed === null || parsed === void 0) {
1323
- return { topics: [] };
1324
- }
1325
- if (typeof parsed !== "object" || Array.isArray(parsed)) {
1326
- throw new Error(`topics.yaml at ${path5} must be a mapping`);
1327
- }
1328
- const obj = parsed;
1329
- const rawTopics = obj.topics;
1330
- if (rawTopics === void 0 || rawTopics === null) {
1331
- return { topics: [] };
1224
+ const obj = parsed;
1225
+ const rawTopics = obj.topics;
1226
+ if (rawTopics === void 0 || rawTopics === null) {
1227
+ return { topics: [] };
1332
1228
  }
1333
1229
  if (!Array.isArray(rawTopics)) {
1334
- throw new Error(`topics.yaml at ${path5} \u2014 "topics" must be a list`);
1230
+ throw new Error(`topics.yaml at ${path6} \u2014 "topics" must be a list`);
1335
1231
  }
1336
1232
  const topics = [];
1337
1233
  for (const item of rawTopics) {
@@ -1360,7 +1256,7 @@ async function loadTopicsFile(path5) {
1360
1256
  }
1361
1257
  return { topics };
1362
1258
  }
1363
- async function writeTopicsFile(path5, file) {
1259
+ async function writeTopicsFile(path6, file) {
1364
1260
  const sorted = [...file.topics].sort((a, b) => a.slug.localeCompare(b.slug));
1365
1261
  const doc = {
1366
1262
  topics: sorted.map((t) => {
@@ -1385,13 +1281,13 @@ async function writeTopicsFile(path5, file) {
1385
1281
  sortKeys: false
1386
1282
  });
1387
1283
  const content = `${header}${body}`;
1388
- const tmpPath = `${path5}.tmp`;
1389
- const parent = dirname4(path5);
1390
- if (!existsSync7(parent)) {
1391
- await mkdir4(parent, { recursive: true });
1284
+ const tmpPath = `${path6}.tmp`;
1285
+ const parent = dirname4(path6);
1286
+ if (!existsSync6(parent)) {
1287
+ await mkdir3(parent, { recursive: true });
1392
1288
  }
1393
- await writeFile4(tmpPath, content, "utf8");
1394
- await rename3(tmpPath, path5);
1289
+ await writeFile3(tmpPath, content, "utf8");
1290
+ await rename2(tmpPath, path6);
1395
1291
  }
1396
1292
  function findTopic(file, slug) {
1397
1293
  for (const t of file.topics) {
@@ -1443,87 +1339,6 @@ function looksLikeDir(raw) {
1443
1339
  return s.endsWith("/");
1444
1340
  }
1445
1341
 
1446
- // src/indexer/schema.ts
1447
- import Database from "better-sqlite3";
1448
- var SCHEMA_DDL = `
1449
- CREATE TABLE IF NOT EXISTS pages (
1450
- slug TEXT PRIMARY KEY,
1451
- title TEXT,
1452
- file_path TEXT NOT NULL,
1453
- content_hash TEXT NOT NULL,
1454
- updated_at INTEGER NOT NULL,
1455
- archived_at INTEGER,
1456
- superseded_by TEXT
1457
- );
1458
-
1459
- CREATE TABLE IF NOT EXISTS topics (
1460
- slug TEXT PRIMARY KEY,
1461
- title TEXT,
1462
- description TEXT
1463
- );
1464
-
1465
- CREATE TABLE IF NOT EXISTS page_topics (
1466
- page_slug TEXT NOT NULL REFERENCES pages(slug) ON DELETE CASCADE,
1467
- topic_slug TEXT NOT NULL,
1468
- PRIMARY KEY (page_slug, topic_slug)
1469
- );
1470
-
1471
- CREATE TABLE IF NOT EXISTS topic_parents (
1472
- child_slug TEXT NOT NULL,
1473
- parent_slug TEXT NOT NULL,
1474
- PRIMARY KEY (child_slug, parent_slug),
1475
- CHECK (child_slug != parent_slug)
1476
- );
1477
-
1478
- CREATE TABLE IF NOT EXISTS file_refs (
1479
- page_slug TEXT NOT NULL REFERENCES pages(slug) ON DELETE CASCADE,
1480
- path TEXT NOT NULL,
1481
- original_path TEXT NOT NULL,
1482
- is_dir INTEGER NOT NULL,
1483
- PRIMARY KEY (page_slug, path)
1484
- );
1485
- CREATE INDEX IF NOT EXISTS idx_file_refs_path ON file_refs(path);
1486
-
1487
- CREATE TABLE IF NOT EXISTS wikilinks (
1488
- source_slug TEXT NOT NULL REFERENCES pages(slug) ON DELETE CASCADE,
1489
- target_slug TEXT NOT NULL,
1490
- PRIMARY KEY (source_slug, target_slug)
1491
- );
1492
-
1493
- CREATE TABLE IF NOT EXISTS cross_wiki_links (
1494
- source_slug TEXT NOT NULL REFERENCES pages(slug) ON DELETE CASCADE,
1495
- target_wiki TEXT NOT NULL,
1496
- target_slug TEXT NOT NULL,
1497
- PRIMARY KEY (source_slug, target_wiki, target_slug)
1498
- );
1499
-
1500
- -- NOTE: virtual FTS5 table \u2014 ON DELETE CASCADE from pages does NOT apply.
1501
- -- The indexer must explicitly DELETE FROM fts_pages whenever it removes
1502
- -- or replaces a page row, or we leak orphaned FTS rows.
1503
- CREATE VIRTUAL TABLE IF NOT EXISTS fts_pages USING fts5(slug, title, content);
1504
- `;
1505
- var SCHEMA_VERSION = 2;
1506
- function openIndex(dbPath) {
1507
- const db = new Database(dbPath);
1508
- const mode = db.pragma("journal_mode", { simple: true });
1509
- if (typeof mode !== "string" || mode.toLowerCase() !== "wal") {
1510
- db.pragma("journal_mode = WAL");
1511
- }
1512
- db.pragma("foreign_keys = ON");
1513
- const rawVersion = db.pragma("user_version", { simple: true });
1514
- const currentVersion = typeof rawVersion === "number" ? rawVersion : 0;
1515
- if (currentVersion < SCHEMA_VERSION) {
1516
- db.exec("DROP TABLE IF EXISTS file_refs");
1517
- try {
1518
- db.exec("UPDATE pages SET content_hash = ''");
1519
- } catch {
1520
- }
1521
- db.pragma(`user_version = ${SCHEMA_VERSION}`);
1522
- }
1523
- db.exec(SCHEMA_DDL);
1524
- return db;
1525
- }
1526
-
1527
1342
  // src/indexer/wikilinks.ts
1528
1343
  function classifyWikilink(raw) {
1529
1344
  const pipe = raw.indexOf("|");
@@ -1540,10 +1355,10 @@ function classifyWikilink(raw) {
1540
1355
  }
1541
1356
  if (firstSlash !== -1) {
1542
1357
  const isDir = looksLikeDir(body);
1543
- const path5 = normalizePath(body, isDir);
1358
+ const path6 = normalizePath(body, isDir);
1544
1359
  const originalPath = normalizePathPreservingCase(body, isDir);
1545
- if (path5.length === 0) return null;
1546
- return isDir ? { kind: "folder", path: path5, originalPath } : { kind: "file", path: path5, originalPath };
1360
+ if (path6.length === 0) return null;
1361
+ return isDir ? { kind: "folder", path: path6, originalPath } : { kind: "file", path: path6, originalPath };
1547
1362
  }
1548
1363
  const target = toKebabCase(body);
1549
1364
  if (target.length === 0) return null;
@@ -1567,12 +1382,12 @@ async function ensureFreshIndex(ctx) {
1567
1382
  const almanacDir = join6(ctx.repoRoot, ".almanac");
1568
1383
  const dbPath = join6(almanacDir, "index.db");
1569
1384
  const pagesDir = join6(almanacDir, "pages");
1570
- if (!existsSync8(pagesDir)) {
1385
+ if (!existsSync7(pagesDir)) {
1571
1386
  const db = openIndex(dbPath);
1572
1387
  db.close();
1573
1388
  return emptyResult();
1574
1389
  }
1575
- if (!existsSync8(dbPath) || pagesNewerThan(pagesDir, dbPath) || topicsYamlNewerThan(almanacDir, dbPath)) {
1390
+ if (!existsSync7(dbPath) || pagesNewerThan(pagesDir, dbPath) || topicsYamlNewerThan(almanacDir, dbPath)) {
1576
1391
  return runIndexer(ctx);
1577
1392
  }
1578
1393
  return emptyResult();
@@ -1649,7 +1464,7 @@ async function indexPagesInto(db, pagesDir) {
1649
1464
  let raw;
1650
1465
  try {
1651
1466
  st = statSync2(fullPath);
1652
- raw = await readFile7(fullPath, "utf8");
1467
+ raw = await readFile6(fullPath, "utf8");
1653
1468
  } catch (err) {
1654
1469
  if (err instanceof Error && "code" in err && (err.code === "ENOENT" || err.code === "EACCES")) {
1655
1470
  process.stderr.write(
@@ -1763,10 +1578,10 @@ async function indexPagesInto(db, pagesDir) {
1763
1578
  }
1764
1579
  for (const raw of p.frontmatterFiles) {
1765
1580
  const isDir = looksLikeDir(raw);
1766
- const path5 = normalizePath(raw, isDir);
1581
+ const path6 = normalizePath(raw, isDir);
1767
1582
  const originalPath = normalizePathPreservingCase(raw, isDir);
1768
- if (path5.length === 0) continue;
1769
- insertFileRef.run(p.slug, path5, originalPath, isDir ? 1 : 0);
1583
+ if (path6.length === 0) continue;
1584
+ insertFileRef.run(p.slug, path6, originalPath, isDir ? 1 : 0);
1770
1585
  }
1771
1586
  for (const ref of p.wikilinks) {
1772
1587
  switch (ref.kind) {
@@ -1822,8 +1637,8 @@ function hashContent(raw) {
1822
1637
  return createHash2("sha256").update(raw).digest("hex");
1823
1638
  }
1824
1639
  function topicsYamlNewerThan(almanacDir, dbPath) {
1825
- const path5 = join6(almanacDir, "topics.yaml");
1826
- if (!existsSync8(path5)) return false;
1640
+ const path6 = join6(almanacDir, "topics.yaml");
1641
+ if (!existsSync7(path6)) return false;
1827
1642
  let dbMtime;
1828
1643
  try {
1829
1644
  dbMtime = statSync2(dbPath).mtimeMs;
@@ -1831,14 +1646,14 @@ function topicsYamlNewerThan(almanacDir, dbPath) {
1831
1646
  return true;
1832
1647
  }
1833
1648
  try {
1834
- const st = statSync2(path5);
1649
+ const st = statSync2(path6);
1835
1650
  return st.mtimeMs > dbMtime;
1836
1651
  } catch {
1837
1652
  return false;
1838
1653
  }
1839
1654
  }
1840
1655
  async function applyTopicsYaml(db, topicsYamlPath2) {
1841
- if (!existsSync8(topicsYamlPath2)) return;
1656
+ if (!existsSync7(topicsYamlPath2)) return;
1842
1657
  let file;
1843
1658
  try {
1844
1659
  file = await loadTopicsFile(topicsYamlPath2);
@@ -1894,7 +1709,7 @@ async function applyTopicsYaml(db, topicsYamlPath2) {
1894
1709
  }
1895
1710
 
1896
1711
  // src/indexer/resolveWiki.ts
1897
- import { existsSync as existsSync9 } from "fs";
1712
+ import { existsSync as existsSync8 } from "fs";
1898
1713
  import { join as join7 } from "path";
1899
1714
  async function resolveWikiRoot(params) {
1900
1715
  if (params.wiki !== void 0) {
@@ -1902,7 +1717,7 @@ async function resolveWikiRoot(params) {
1902
1717
  if (entry === null) {
1903
1718
  throw new Error(`no registered wiki named "${params.wiki}"`);
1904
1719
  }
1905
- if (!existsSync9(join7(entry.path, ".almanac"))) {
1720
+ if (!existsSync8(join7(entry.path, ".almanac"))) {
1906
1721
  throw new Error(
1907
1722
  `wiki "${params.wiki}" path is unreachable (${entry.path})`
1908
1723
  );
@@ -1912,7 +1727,7 @@ async function resolveWikiRoot(params) {
1912
1727
  const nearest = findNearestAlmanacDir(params.cwd);
1913
1728
  if (nearest === null) {
1914
1729
  throw new Error(
1915
- "no .almanac/ found in this directory or any parent; run `almanac init` first"
1730
+ "no .almanac/ found in this directory or any parent; run `almanac bootstrap` first"
1916
1731
  );
1917
1732
  }
1918
1733
  return nearest;
@@ -2068,7 +1883,7 @@ async function findDeadRefs(db, scope, repoRoot) {
2068
1883
  for (const r of rows) {
2069
1884
  if (!inPageScope(scope, r.slug)) continue;
2070
1885
  const abs = join8(repoRoot, r.original_path);
2071
- if (!existsSync10(abs)) {
1886
+ if (!existsSync9(abs)) {
2072
1887
  out.push({ slug: r.slug, path: r.original_path });
2073
1888
  }
2074
1889
  }
@@ -2103,7 +1918,7 @@ async function findBrokenXwiki(db, scope) {
2103
1918
  let ok = reachableCache.get(r.target_wiki);
2104
1919
  if (ok === void 0) {
2105
1920
  const entry = await findEntry({ name: r.target_wiki });
2106
- ok = entry !== null && existsSync10(join8(entry.path, ".almanac"));
1921
+ ok = entry !== null && existsSync9(join8(entry.path, ".almanac"));
2107
1922
  reachableCache.set(r.target_wiki, ok);
2108
1923
  }
2109
1924
  if (!ok) {
@@ -2138,7 +1953,7 @@ async function findEmptyPages(db, scope, pagesDir) {
2138
1953
  if (!inPageScope(scope, r.slug)) continue;
2139
1954
  let raw;
2140
1955
  try {
2141
- raw = await readFile8(r.file_path, "utf8");
1956
+ raw = await readFile7(r.file_path, "utf8");
2142
1957
  } catch {
2143
1958
  continue;
2144
1959
  }
@@ -2158,7 +1973,7 @@ async function findEmptyPages(db, scope, pagesDir) {
2158
1973
  return out;
2159
1974
  }
2160
1975
  async function findSlugCollisions(pagesDir) {
2161
- if (!existsSync10(pagesDir)) return [];
1976
+ if (!existsSync9(pagesDir)) return [];
2162
1977
  const files = await fg2("**/*.md", {
2163
1978
  cwd: pagesDir,
2164
1979
  absolute: false,
@@ -2253,259 +2068,212 @@ function section(label, count, lines) {
2253
2068
  ${lines.join("\n")}`;
2254
2069
  }
2255
2070
 
2256
- // src/commands/list.ts
2071
+ // src/commands/setup.ts
2257
2072
  import { existsSync as existsSync11 } from "fs";
2258
- async function listWikis(options) {
2259
- if (options.drop !== void 0) {
2260
- return handleDrop(options.drop);
2261
- }
2262
- const entries = await readRegistry();
2263
- const reachable = entries.filter((e) => isReachable(e));
2264
- if (options.json === true) {
2265
- return { stdout: `${JSON.stringify(reachable, null, 2)}
2266
- `, exitCode: 0 };
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 };
2267
2096
  }
2268
- return { stdout: formatPretty(reachable), exitCode: 0 };
2269
- }
2270
- async function handleDrop(name) {
2271
- const removed = await dropEntry(name);
2272
- if (removed === null) {
2273
- return {
2274
- stdout: `no registry entry named "${name}"
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.
2275
2115
  `,
2276
2116
  exitCode: 1
2277
2117
  };
2278
2118
  }
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);
2279
2136
  return {
2280
- stdout: `removed "${removed.name}" (${removed.path})
2137
+ stdout: `almanac: SessionEnd hook installed
2138
+ script: ${script.path}
2139
+ settings: ${settingsPath}
2281
2140
  `,
2141
+ stderr: "",
2282
2142
  exitCode: 0
2283
2143
  };
2284
2144
  }
2285
- function isReachable(entry) {
2286
- if (entry.path.length === 0) return false;
2287
- return existsSync11(entry.path);
2288
- }
2289
- function formatPretty(entries) {
2290
- if (entries.length === 0) {
2291
- return "no wikis registered. run `almanac init` in a repo to create one.\n";
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
+ };
2292
2154
  }
2293
- const nameWidth = Math.min(
2294
- 30,
2295
- entries.reduce((w, e) => Math.max(w, e.name.length), 0)
2296
- );
2297
- const lines = [];
2298
- for (const entry of entries) {
2299
- const name = entry.name.padEnd(nameWidth);
2300
- const desc = entry.description.length > 0 ? entry.description : "\u2014";
2301
- lines.push(`${name} ${desc}`);
2302
- lines.push(`${" ".repeat(nameWidth)} ${entry.path}`);
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
+ };
2303
2166
  }
2304
- return `${lines.join("\n")}
2305
- `;
2306
- }
2307
-
2308
- // src/commands/reindex.ts
2309
- async function runReindex(options) {
2310
- const repoRoot = await resolveWikiRoot({
2311
- cwd: options.cwd,
2312
- wiki: options.wiki
2313
- });
2314
- const result = await runIndexer({ repoRoot });
2315
- const skipSuffix = result.filesSkipped > 0 ? `; ${result.filesSkipped} skipped` : "";
2316
- const stdout = `reindexed: ${result.pagesIndexed} page${result.pagesIndexed === 1 ? "" : "s"} (${result.changed} updated, ${result.removed} removed${skipSuffix})
2317
- `;
2318
- return { result, stdout, exitCode: 0 };
2319
- }
2320
-
2321
- // src/commands/search.ts
2322
- import { join as join9 } from "path";
2323
- async function runSearch(options) {
2324
- const repoRoot = await resolveWikiRoot({
2325
- cwd: options.cwd,
2326
- wiki: options.wiki
2327
- });
2328
- await ensureFreshIndex({ repoRoot });
2329
- const dbPath = join9(repoRoot, ".almanac", "index.db");
2330
- const db = openIndex(dbPath);
2331
- try {
2332
- const rows = executeQuery(db, options);
2333
- const limited = options.limit !== void 0 && options.limit >= 0 ? rows.slice(0, options.limit) : rows;
2334
- const stdout = formatResults(limited, options);
2335
- const stderr = buildStderr(limited, options);
2336
- return { stdout, stderr, exitCode: 0 };
2337
- } finally {
2338
- db.close();
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;
2177
+ }
2339
2178
  }
2179
+ await writeSettings(settingsPath, settings);
2180
+ return {
2181
+ stdout: `almanac: SessionEnd hook removed
2182
+ `,
2183
+ stderr: "",
2184
+ exitCode: 0
2185
+ };
2340
2186
  }
2341
- function executeQuery(db, options) {
2342
- const whereClauses = [];
2343
- const params = [];
2344
- if (options.archived === true) {
2345
- whereClauses.push("p.archived_at IS NOT NULL");
2346
- } else if (options.includeArchive !== true) {
2347
- whereClauses.push("p.archived_at IS NULL");
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
2198
+ };
2348
2199
  }
2349
- for (const rawTopic of options.topics) {
2350
- const topicSlug = slugForTopic(rawTopic);
2351
- if (topicSlug.length === 0) continue;
2352
- whereClauses.push(
2353
- "EXISTS (SELECT 1 FROM page_topics pt WHERE pt.page_slug = p.slug AND pt.topic_slug = ?)"
2354
- );
2355
- params.push(topicSlug);
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");
2205
+ return {
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
+ ` : ""),
2212
+ stderr: "",
2213
+ exitCode: 0
2214
+ };
2356
2215
  }
2357
- if (options.mentions !== void 0 && options.mentions.length > 0) {
2358
- const isDir = looksLikeDir(options.mentions);
2359
- const norm = normalizePath(options.mentions, isDir);
2360
- if (isDir) {
2361
- const escaped = escapeGlobMeta(norm);
2362
- whereClauses.push(
2363
- `EXISTS (
2364
- SELECT 1 FROM file_refs r
2365
- WHERE r.page_slug = p.slug
2366
- AND (r.path = ? OR r.path GLOB ?)
2367
- )`
2368
- );
2369
- params.push(norm, `${escaped}*`);
2370
- } else {
2371
- const prefixes = parentFolderPrefixes(norm);
2372
- if (prefixes.length === 0) {
2373
- whereClauses.push(
2374
- `EXISTS (
2375
- SELECT 1 FROM file_refs r
2376
- WHERE r.page_slug = p.slug AND r.path = ?
2377
- )`
2378
- );
2379
- params.push(norm);
2380
- } else {
2381
- const placeholders = prefixes.map(() => "?").join(", ");
2382
- whereClauses.push(
2383
- `EXISTS (
2384
- SELECT 1 FROM file_refs r
2385
- WHERE r.page_slug = p.slug
2386
- AND (
2387
- r.path = ?
2388
- OR (r.is_dir = 1 AND r.path IN (${placeholders}))
2389
- )
2390
- )`
2391
- );
2392
- params.push(norm, ...prefixes);
2393
- }
2216
+ return {
2217
+ stdout: `SessionEnd hook: installed
2218
+ script: ${ours.command}
2219
+ settings: ${settingsPath}
2220
+ `,
2221
+ stderr: "",
2222
+ exitCode: 0
2223
+ };
2224
+ }
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 };
2232
+ }
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 };
2394
2245
  }
2395
2246
  }
2396
- const now = Math.floor(Date.now() / 1e3);
2397
- if (options.since !== void 0) {
2398
- const seconds = parseDuration(options.since);
2399
- whereClauses.push("p.updated_at >= ?");
2400
- params.push(now - seconds);
2247
+ return {
2248
+ ok: false,
2249
+ error: `could not locate hooks/almanac-capture.sh. Tried:
2250
+ ` + candidates.map((c) => ` - ${c}`).join("\n")
2251
+ };
2252
+ }
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}`);
2401
2264
  }
2402
- if (options.stale !== void 0) {
2403
- const seconds = parseDuration(options.stale);
2404
- whereClauses.push("p.updated_at < ?");
2405
- params.push(now - seconds);
2406
- }
2407
- if (options.orphan === true) {
2408
- whereClauses.push(
2409
- "NOT EXISTS (SELECT 1 FROM page_topics pt WHERE pt.page_slug = p.slug)"
2410
- );
2411
- }
2412
- let sql;
2413
- if (options.query !== void 0 && options.query.trim().length > 0) {
2414
- const ftsExpr = buildFtsQuery(options.query);
2415
- sql = `
2416
- SELECT p.slug, p.title, p.updated_at, p.archived_at, p.superseded_by
2417
- FROM pages p
2418
- JOIN fts_pages f ON f.slug = p.slug
2419
- WHERE fts_pages MATCH ?
2420
- ${whereClauses.length > 0 ? `AND ${whereClauses.join(" AND ")}` : ""}
2421
- ORDER BY f.rank ASC, p.updated_at DESC, p.slug ASC
2422
- `;
2423
- params.unshift(ftsExpr);
2424
- } else {
2425
- sql = buildSql(whereClauses);
2426
- }
2427
- const rows = db.prepare(sql).all(...params);
2428
- const topicStmt = db.prepare(
2429
- "SELECT topic_slug FROM page_topics WHERE page_slug = ? ORDER BY topic_slug"
2430
- );
2431
- const out = rows.map((row) => ({
2432
- slug: row.slug,
2433
- title: row.title,
2434
- updated_at: row.updated_at,
2435
- archived_at: row.archived_at,
2436
- superseded_by: row.superseded_by,
2437
- topics: topicStmt.all(row.slug).map((t) => t.topic_slug)
2438
- }));
2439
- return out;
2440
- }
2441
- function buildSql(whereClauses) {
2442
- const where = whereClauses.length > 0 ? `WHERE ${whereClauses.join(" AND ")}` : "";
2443
- return `
2444
- SELECT p.slug, p.title, p.updated_at, p.archived_at, p.superseded_by
2445
- FROM pages p
2446
- ${where}
2447
- ORDER BY p.updated_at DESC, p.slug ASC
2448
- `;
2449
- }
2450
- function buildFtsQuery(raw) {
2451
- const trimmed = raw.trim();
2452
- if (trimmed.length >= 2 && trimmed.startsWith('"') && trimmed.endsWith('"')) {
2453
- const inner = trimmed.slice(1, -1).toLowerCase().replace(/[^a-z0-9]+/g, " ").trim();
2454
- if (inner.length === 0) return '""';
2455
- return `"${inner}"`;
2456
- }
2457
- const tokens = trimmed.toLowerCase().split(/[^a-z0-9]+/).filter((t) => t.length > 0);
2458
- if (tokens.length === 0) return '""';
2459
- return tokens.map((t) => `${t}*`).join(" AND ");
2460
- }
2461
- function slugForTopic(raw) {
2462
- return raw.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
2463
- }
2464
- function parentFolderPrefixes(filePath) {
2465
- const out = [];
2466
- let cursor = 0;
2467
- while (true) {
2468
- const next = filePath.indexOf("/", cursor);
2469
- if (next === -1) break;
2470
- out.push(filePath.slice(0, next + 1));
2471
- cursor = next + 1;
2472
- }
2473
- return out;
2474
- }
2475
- function escapeGlobMeta(s) {
2476
- return s.replace(/[\*\?\[]/g, (ch) => `[${ch}]`);
2477
- }
2478
- function formatResults(rows, options) {
2479
- if (options.json === true) {
2480
- return `${JSON.stringify(rows, null, 2)}
2481
- `;
2482
- }
2483
- if (rows.length === 0) return "";
2484
- return `${rows.map((r) => r.slug).join("\n")}
2485
- `;
2486
2265
  }
2487
- function buildStderr(rows, options) {
2488
- if (options.json === true) return "";
2489
- if (options.limit !== void 0) return "";
2490
- if (rows.length > 50) {
2491
- return `almanac: ${rows.length} results \u2014 consider --limit or a narrower query
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)}
2492
2271
  `;
2493
- }
2494
- return "";
2272
+ await writeFile4(tmp, body, "utf8");
2273
+ await rename3(tmp, settingsPath);
2495
2274
  }
2496
2275
 
2497
2276
  // src/commands/setup.ts
2498
- import { existsSync as existsSync12 } from "fs";
2499
- import {
2500
- copyFile,
2501
- mkdir as mkdir5,
2502
- readFile as readFile9,
2503
- writeFile as writeFile5
2504
- } from "fs/promises";
2505
- import { createRequire as createRequire2 } from "module";
2506
- import { homedir as homedir4 } from "os";
2507
- import path3 from "path";
2508
- import { fileURLToPath as fileURLToPath3 } from "url";
2509
2277
  var RST = "\x1B[0m";
2510
2278
  var BOLD = "\x1B[1m";
2511
2279
  var DIM = "\x1B[2m";
@@ -2563,6 +2331,12 @@ async function runSetup(options = {}) {
2563
2331
  const out = options.stdout ?? process.stdout;
2564
2332
  const isTTY = options.isTTY ?? process.stdin.isTTY === true;
2565
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
+ }
2566
2340
  printBanner(out);
2567
2341
  printBadge(out);
2568
2342
  const auth = await safeCheckAuth(options.spawnCli);
@@ -2649,147 +2423,859 @@ function reportAuth(out, auth) {
2649
2423
  stepDone(out, `Claude auth: ${WHITE_BOLD}${who}${RST}${plan}`);
2650
2424
  return;
2651
2425
  }
2652
- if (process.env.ANTHROPIC_API_KEY !== void 0 && process.env.ANTHROPIC_API_KEY.length > 0) {
2653
- stepDone(out, `Claude auth: ${WHITE_BOLD}ANTHROPIC_API_KEY${RST} set`);
2654
- return;
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;
2429
+ }
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
+ `);
2434
+ }
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 {
2465
+ }
2466
+ }
2467
+ await copyFile(src, dest);
2468
+ return true;
2469
+ }
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`)
2527
+ );
2528
+ out.write(
2529
+ row(
2530
+ ` ${BLUE}2.${RST} ${BOLD}almanac bootstrap${RST} ${DIM}# scaffold the wiki${RST}`
2531
+ )
2532
+ );
2533
+ out.write(
2534
+ row(
2535
+ ` ${BLUE}3.${RST} Work normally \u2014 capture runs on session end`
2536
+ )
2537
+ );
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")
2564
+ );
2565
+ }
2566
+ function looksLikeGuidesDir(dir) {
2567
+ return existsSync11(path3.join(dir, "mini.md"));
2568
+ }
2569
+
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
2604
+ });
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
+ }
2662
+ try {
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) {
2670
+ return {
2671
+ status: "problem",
2672
+ key: "install.hook",
2673
+ message: "SessionEnd hook not installed",
2674
+ fix: "run: almanac setup --yes"
2675
+ };
2676
+ }
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
+ };
2740
+ }
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();
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
+ });
2833
+ } else {
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
+ });
2840
+ }
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)) {
2857
+ return {
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"
2912
+ };
2913
+ }
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;
2918
+ return {
2919
+ status: "info",
2920
+ key: "wiki.capture",
2921
+ message: `last capture: ${formatDuration(age)} ago (${latest.name})`
2922
+ };
2923
+ }
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;
2935
+ }
2936
+ }
2937
+ function detectInstallPath() {
2938
+ try {
2939
+ const entry = req.resolve("codealmanac");
2940
+ return path4.dirname(path4.dirname(entry));
2941
+ } catch {
2942
+ return null;
2943
+ }
2944
+ }
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 };
2955
+ }
2956
+ }
2957
+ async function safeCheckAuth2(spawnCli) {
2958
+ try {
2959
+ return await checkClaudeAuth(spawnCli);
2960
+ } catch {
2961
+ return { loggedIn: false };
2962
+ }
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 {
2971
+ }
2972
+ try {
2973
+ const pkg = req("../package.json");
2974
+ if (typeof pkg.version === "string" && pkg.version.length > 0) {
2975
+ return pkg.version;
2976
+ }
2977
+ } catch {
2978
+ }
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));
2997
+ }
2998
+ lines.push("");
2999
+ }
3000
+ return `${lines.join("\n")}
3001
+ `;
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
+ }
3032
+
3033
+ // src/commands/list.ts
3034
+ import { existsSync as existsSync13 } from "fs";
3035
+ async function listWikis(options) {
3036
+ if (options.drop !== void 0) {
3037
+ return handleDrop(options.drop);
3038
+ }
3039
+ const entries = await readRegistry();
3040
+ const reachable = entries.filter((e) => isReachable(e));
3041
+ if (options.json === true) {
3042
+ return { stdout: `${JSON.stringify(reachable, null, 2)}
3043
+ `, exitCode: 0 };
3044
+ }
3045
+ return { stdout: formatPretty(reachable), exitCode: 0 };
3046
+ }
3047
+ async function handleDrop(name) {
3048
+ const removed = await dropEntry(name);
3049
+ if (removed === null) {
3050
+ return {
3051
+ stdout: `no registry entry named "${name}"
3052
+ `,
3053
+ exitCode: 1
3054
+ };
3055
+ }
3056
+ return {
3057
+ stdout: `removed "${removed.name}" (${removed.path})
3058
+ `,
3059
+ exitCode: 0
3060
+ };
3061
+ }
3062
+ function isReachable(entry) {
3063
+ if (entry.path.length === 0) return false;
3064
+ return existsSync13(entry.path);
3065
+ }
3066
+ function formatPretty(entries) {
3067
+ if (entries.length === 0) {
3068
+ return "no wikis registered. run `almanac bootstrap` in a repo to create one.\n";
3069
+ }
3070
+ const nameWidth = Math.min(
3071
+ 30,
3072
+ entries.reduce((w, e) => Math.max(w, e.name.length), 0)
3073
+ );
3074
+ const lines = [];
3075
+ for (const entry of entries) {
3076
+ const name = entry.name.padEnd(nameWidth);
3077
+ const desc = entry.description.length > 0 ? entry.description : "\u2014";
3078
+ lines.push(`${name} ${desc}`);
3079
+ lines.push(`${" ".repeat(nameWidth)} ${entry.path}`);
3080
+ }
3081
+ return `${lines.join("\n")}
3082
+ `;
3083
+ }
3084
+
3085
+ // src/commands/reindex.ts
3086
+ async function runReindex(options) {
3087
+ const repoRoot = await resolveWikiRoot({
3088
+ cwd: options.cwd,
3089
+ wiki: options.wiki
3090
+ });
3091
+ const result = await runIndexer({ repoRoot });
3092
+ const skipSuffix = result.filesSkipped > 0 ? `; ${result.filesSkipped} skipped` : "";
3093
+ const stdout = `reindexed: ${result.pagesIndexed} page${result.pagesIndexed === 1 ? "" : "s"} (${result.changed} updated, ${result.removed} removed${skipSuffix})
3094
+ `;
3095
+ return { result, stdout, exitCode: 0 };
3096
+ }
3097
+
3098
+ // src/commands/search.ts
3099
+ import { join as join9 } from "path";
3100
+ async function runSearch(options) {
3101
+ const repoRoot = await resolveWikiRoot({
3102
+ cwd: options.cwd,
3103
+ wiki: options.wiki
3104
+ });
3105
+ await ensureFreshIndex({ repoRoot });
3106
+ const dbPath = join9(repoRoot, ".almanac", "index.db");
3107
+ const db = openIndex(dbPath);
3108
+ try {
3109
+ const rows = executeQuery(db, options);
3110
+ const limited = options.limit !== void 0 && options.limit >= 0 ? rows.slice(0, options.limit) : rows;
3111
+ const stdout = formatResults(limited, options);
3112
+ const stderr = buildStderr(limited, options);
3113
+ return { stdout, stderr, exitCode: 0 };
3114
+ } finally {
3115
+ db.close();
3116
+ }
3117
+ }
3118
+ function executeQuery(db, options) {
3119
+ const whereClauses = [];
3120
+ const params = [];
3121
+ if (options.archived === true) {
3122
+ whereClauses.push("p.archived_at IS NOT NULL");
3123
+ } else if (options.includeArchive !== true) {
3124
+ whereClauses.push("p.archived_at IS NULL");
3125
+ }
3126
+ for (const rawTopic of options.topics) {
3127
+ const topicSlug = slugForTopic(rawTopic);
3128
+ if (topicSlug.length === 0) continue;
3129
+ whereClauses.push(
3130
+ "EXISTS (SELECT 1 FROM page_topics pt WHERE pt.page_slug = p.slug AND pt.topic_slug = ?)"
3131
+ );
3132
+ params.push(topicSlug);
3133
+ }
3134
+ if (options.mentions !== void 0 && options.mentions.length > 0) {
3135
+ const isDir = looksLikeDir(options.mentions);
3136
+ const norm = normalizePath(options.mentions, isDir);
3137
+ if (isDir) {
3138
+ const escaped = escapeGlobMeta(norm);
3139
+ whereClauses.push(
3140
+ `EXISTS (
3141
+ SELECT 1 FROM file_refs r
3142
+ WHERE r.page_slug = p.slug
3143
+ AND (r.path = ? OR r.path GLOB ?)
3144
+ )`
3145
+ );
3146
+ params.push(norm, `${escaped}*`);
3147
+ } else {
3148
+ const prefixes = parentFolderPrefixes(norm);
3149
+ if (prefixes.length === 0) {
3150
+ whereClauses.push(
3151
+ `EXISTS (
3152
+ SELECT 1 FROM file_refs r
3153
+ WHERE r.page_slug = p.slug AND r.path = ?
3154
+ )`
3155
+ );
3156
+ params.push(norm);
3157
+ } else {
3158
+ const placeholders = prefixes.map(() => "?").join(", ");
3159
+ whereClauses.push(
3160
+ `EXISTS (
3161
+ SELECT 1 FROM file_refs r
3162
+ WHERE r.page_slug = p.slug
3163
+ AND (
3164
+ r.path = ?
3165
+ OR (r.is_dir = 1 AND r.path IN (${placeholders}))
3166
+ )
3167
+ )`
3168
+ );
3169
+ params.push(norm, ...prefixes);
3170
+ }
3171
+ }
3172
+ }
3173
+ const now = Math.floor(Date.now() / 1e3);
3174
+ if (options.since !== void 0) {
3175
+ const seconds = parseDuration(options.since);
3176
+ whereClauses.push("p.updated_at >= ?");
3177
+ params.push(now - seconds);
2655
3178
  }
2656
- stepActive(out, `Claude auth: ${DIM}not signed in${RST}`);
2657
- for (const line of UNAUTHENTICATED_MESSAGE.split("\n")) {
2658
- out.write(` ${DIM}\u2502 ${line}${RST}
2659
- `);
3179
+ if (options.stale !== void 0) {
3180
+ const seconds = parseDuration(options.stale);
3181
+ whereClauses.push("p.updated_at < ?");
3182
+ params.push(now - seconds);
2660
3183
  }
2661
- }
2662
- async function installGuides(options) {
2663
- await mkdir5(options.claudeDir, { recursive: true });
2664
- const srcMini = path3.join(options.guidesDir, "mini.md");
2665
- const srcRef = path3.join(options.guidesDir, "reference.md");
2666
- if (!existsSync12(srcMini)) {
2667
- throw new Error(`missing bundled guide: ${srcMini}`);
3184
+ if (options.orphan === true) {
3185
+ whereClauses.push(
3186
+ "NOT EXISTS (SELECT 1 FROM page_topics pt WHERE pt.page_slug = p.slug)"
3187
+ );
2668
3188
  }
2669
- if (!existsSync12(srcRef)) {
2670
- throw new Error(`missing bundled guide: ${srcRef}`);
3189
+ let sql;
3190
+ if (options.query !== void 0 && options.query.trim().length > 0) {
3191
+ const ftsExpr = buildFtsQuery(options.query);
3192
+ sql = `
3193
+ SELECT p.slug, p.title, p.updated_at, p.archived_at, p.superseded_by
3194
+ FROM pages p
3195
+ JOIN fts_pages f ON f.slug = p.slug
3196
+ WHERE fts_pages MATCH ?
3197
+ ${whereClauses.length > 0 ? `AND ${whereClauses.join(" AND ")}` : ""}
3198
+ ORDER BY f.rank ASC, p.updated_at DESC, p.slug ASC
3199
+ `;
3200
+ params.unshift(ftsExpr);
3201
+ } else {
3202
+ sql = buildSql(whereClauses);
2671
3203
  }
2672
- const destMini = path3.join(options.claudeDir, "codealmanac.md");
2673
- const destRef = path3.join(options.claudeDir, "codealmanac-reference.md");
2674
- const miniChanged = await copyIfChanged(srcMini, destMini);
2675
- const refChanged = await copyIfChanged(srcRef, destRef);
2676
- const claudeMd = path3.join(options.claudeDir, "CLAUDE.md");
2677
- const importChanged = await ensureImport(claudeMd);
2678
- const filesWritten = [];
2679
- if (miniChanged) filesWritten.push("codealmanac.md");
2680
- if (refChanged) filesWritten.push("codealmanac-reference.md");
2681
- if (importChanged) filesWritten.push("CLAUDE.md");
2682
- return { anyChanges: filesWritten.length > 0, filesWritten };
3204
+ const rows = db.prepare(sql).all(...params);
3205
+ const topicStmt = db.prepare(
3206
+ "SELECT topic_slug FROM page_topics WHERE page_slug = ? ORDER BY topic_slug"
3207
+ );
3208
+ const out = rows.map((row) => ({
3209
+ slug: row.slug,
3210
+ title: row.title,
3211
+ updated_at: row.updated_at,
3212
+ archived_at: row.archived_at,
3213
+ superseded_by: row.superseded_by,
3214
+ topics: topicStmt.all(row.slug).map((t) => t.topic_slug)
3215
+ }));
3216
+ return out;
2683
3217
  }
2684
- async function copyIfChanged(src, dest) {
2685
- const srcBytes = await readFile9(src);
2686
- if (existsSync12(dest)) {
2687
- try {
2688
- const destBytes = await readFile9(dest);
2689
- if (srcBytes.equals(destBytes)) return false;
2690
- } catch {
2691
- }
2692
- }
2693
- await copyFile(src, dest);
2694
- return true;
3218
+ function buildSql(whereClauses) {
3219
+ const where = whereClauses.length > 0 ? `WHERE ${whereClauses.join(" AND ")}` : "";
3220
+ return `
3221
+ SELECT p.slug, p.title, p.updated_at, p.archived_at, p.superseded_by
3222
+ FROM pages p
3223
+ ${where}
3224
+ ORDER BY p.updated_at DESC, p.slug ASC
3225
+ `;
2695
3226
  }
2696
- var IMPORT_LINE = "@~/.claude/codealmanac.md";
2697
- async function ensureImport(claudeMdPath) {
2698
- let existing = "";
2699
- if (existsSync12(claudeMdPath)) {
2700
- existing = await readFile9(claudeMdPath, "utf8");
3227
+ function buildFtsQuery(raw) {
3228
+ const trimmed = raw.trim();
3229
+ if (trimmed.length >= 2 && trimmed.startsWith('"') && trimmed.endsWith('"')) {
3230
+ const inner = trimmed.slice(1, -1).toLowerCase().replace(/[^a-z0-9]+/g, " ").trim();
3231
+ if (inner.length === 0) return '""';
3232
+ return `"${inner}"`;
2701
3233
  }
2702
- if (hasImportLine(existing)) return false;
2703
- const sep = existing.length === 0 ? "" : existing.endsWith("\n") ? "\n" : "\n\n";
2704
- const body = `${existing}${sep}${IMPORT_LINE}
2705
- `;
2706
- await writeFile5(claudeMdPath, body, "utf8");
2707
- return true;
3234
+ const tokens = trimmed.toLowerCase().split(/[^a-z0-9]+/).filter((t) => t.length > 0);
3235
+ if (tokens.length === 0) return '""';
3236
+ return tokens.map((t) => `${t}*`).join(" AND ");
2708
3237
  }
2709
- function hasImportLine(contents) {
2710
- const lines = contents.split(/\r?\n/).map((l) => l.trim());
2711
- return lines.includes(IMPORT_LINE);
3238
+ function slugForTopic(raw) {
3239
+ return raw.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
2712
3240
  }
2713
- function confirm(out, question, defaultYes) {
2714
- return new Promise((resolve2) => {
2715
- const hint = defaultYes ? "[Y/n]" : "[y/N]";
2716
- out.write(` ${BLUE}\u25C6${RST} ${question} ${DIM}${hint}${RST} `);
2717
- let buf = "";
2718
- const onData = (chunk) => {
2719
- buf += chunk.toString("utf8");
2720
- const nl = buf.indexOf("\n");
2721
- if (nl === -1) return;
2722
- process.stdin.removeListener("data", onData);
2723
- process.stdin.pause();
2724
- const answer = buf.slice(0, nl).trim().toLowerCase();
2725
- const accepted = answer.length === 0 ? defaultYes : answer === "y" || answer === "yes";
2726
- resolve2(accepted ? "install" : "skip");
2727
- };
2728
- process.stdin.resume();
2729
- process.stdin.on("data", onData);
2730
- });
3241
+ function parentFolderPrefixes(filePath) {
3242
+ const out = [];
3243
+ let cursor = 0;
3244
+ while (true) {
3245
+ const next = filePath.indexOf("/", cursor);
3246
+ if (next === -1) break;
3247
+ out.push(filePath.slice(0, next + 1));
3248
+ cursor = next + 1;
3249
+ }
3250
+ return out;
2731
3251
  }
2732
- function printNextSteps(out) {
2733
- const innerW = 62;
2734
- const vis = (s) => s.replace(/\x1b\[[0-9;]*m/g, "").length;
2735
- const row = (content) => {
2736
- const padding = Math.max(0, innerW - vis(content));
2737
- return ` ${BLUE_DIM}\u2502${RST}${content}${" ".repeat(padding)}${BLUE_DIM}\u2502${RST}
3252
+ function escapeGlobMeta(s) {
3253
+ return s.replace(/[\*\?\[]/g, (ch) => `[${ch}]`);
3254
+ }
3255
+ function formatResults(rows, options) {
3256
+ if (options.json === true) {
3257
+ return `${JSON.stringify(rows, null, 2)}
3258
+ `;
3259
+ }
3260
+ if (rows.length === 0) return "";
3261
+ return `${rows.map((r) => r.slug).join("\n")}
2738
3262
  `;
2739
- };
2740
- const empty = row("");
2741
- out.write(` ${BLUE_DIM}\u256D${"\u2500".repeat(innerW)}\u256E${RST}
2742
- `);
2743
- out.write(empty);
2744
- out.write(row(` ${WHITE_BOLD}Next steps${RST}`));
2745
- out.write(empty);
2746
- out.write(
2747
- row(` ${BLUE}1.${RST} ${BOLD}cd${RST} into a repo you want to document`)
2748
- );
2749
- out.write(
2750
- row(
2751
- ` ${BLUE}2.${RST} ${BOLD}almanac bootstrap${RST} ${DIM}# scaffold the wiki${RST}`
2752
- )
2753
- );
2754
- out.write(
2755
- row(
2756
- ` ${BLUE}3.${RST} Work normally \u2014 capture runs on session end`
2757
- )
2758
- );
2759
- out.write(empty);
2760
- out.write(` ${BLUE_DIM}\u2570${"\u2500".repeat(innerW)}\u256F${RST}
2761
-
2762
- `);
2763
3263
  }
2764
- function resolveGuidesDir() {
2765
- const here = path3.dirname(fileURLToPath3(import.meta.url));
2766
- const candidates = [
2767
- path3.resolve(here, "..", "guides"),
2768
- // dist layout
2769
- path3.resolve(here, "..", "..", "guides"),
2770
- // src layout
2771
- path3.resolve(here, "..", "..", "..", "guides")
2772
- ];
2773
- for (const dir of candidates) {
2774
- if (looksLikeGuidesDir(dir)) return dir;
3264
+ function buildStderr(rows, options) {
3265
+ if (options.json === true) return "";
3266
+ if (rows.length === 0) {
3267
+ return "# 0 results\n";
2775
3268
  }
2776
- try {
2777
- const require2 = createRequire2(import.meta.url);
2778
- const pkgJson = require2.resolve("codealmanac/package.json");
2779
- const guides = path3.join(path3.dirname(pkgJson), "guides");
2780
- if (looksLikeGuidesDir(guides)) return guides;
2781
- } catch {
3269
+ if (options.limit !== void 0) return "";
3270
+ if (rows.length > 50) {
3271
+ return `almanac: ${rows.length} results \u2014 consider --limit or a narrower query
3272
+ `;
2782
3273
  }
2783
- throw new Error(
2784
- "could not locate bundled guides/ directory. Tried:\n" + candidates.map((c) => ` - ${c}`).join("\n")
2785
- );
2786
- }
2787
- function looksLikeGuidesDir(dir) {
2788
- return existsSync12(path3.join(dir, "mini.md"));
3274
+ return "";
2789
3275
  }
2790
3276
 
2791
3277
  // src/commands/show.ts
2792
- import { readFile as readFile10 } from "fs/promises";
3278
+ import { readFile as readFile11 } from "fs/promises";
2793
3279
  import { join as join10 } from "path";
2794
3280
  async function runShow(options) {
2795
3281
  const repoRoot = await resolveWikiRoot({
@@ -2856,7 +3342,7 @@ async function fetchRecord(db, slug) {
2856
3342
  ).all(slug).map((r) => r.slug);
2857
3343
  let body = "";
2858
3344
  try {
2859
- body = stripFrontmatter(await readFile10(pageRow.file_path, "utf8"));
3345
+ body = stripFrontmatter(await readFile11(pageRow.file_path, "utf8"));
2860
3346
  } catch {
2861
3347
  }
2862
3348
  return {
@@ -2907,7 +3393,9 @@ function selectedFields(options) {
2907
3393
  }
2908
3394
  function formatRecord(rec, options) {
2909
3395
  if (options.raw === true) {
2910
- return rec.body;
3396
+ if (rec.body.length === 0) return "";
3397
+ return rec.body.endsWith("\n") ? rec.body : `${rec.body}
3398
+ `;
2911
3399
  }
2912
3400
  const fields = selectedFields(options);
2913
3401
  if (fields.length > 0) {
@@ -2975,7 +3463,7 @@ function labeledFields(rec, fields) {
2975
3463
  for (const f of fields) {
2976
3464
  parts.push(labeledSection(rec, f));
2977
3465
  }
2978
- return parts.join("\n") + (parts.length > 0 ? "" : "");
3466
+ return parts.join("\n");
2979
3467
  }
2980
3468
  function labeledSection(rec, field) {
2981
3469
  switch (field) {
@@ -3075,10 +3563,10 @@ function metadataHeader(rec) {
3075
3563
  }
3076
3564
  function stripFrontmatter(src) {
3077
3565
  if (!src.startsWith("---\n") && !src.startsWith("---\r\n")) return src;
3078
- const rest = src.slice(src.indexOf("\n") + 1);
3079
- const endMatch = rest.match(/^---[ \t]*\r?\n/m);
3566
+ const afterOpen = src.replace(/^---\r?\n/, "");
3567
+ const endMatch = afterOpen.match(/^---[ \t]*\r?\n/m);
3080
3568
  if (endMatch === null || endMatch.index === void 0) return src;
3081
- return rest.slice(endMatch.index + endMatch[0].length);
3569
+ return afterOpen.slice(endMatch.index + endMatch[0].length);
3082
3570
  }
3083
3571
  function firstParagraph(body) {
3084
3572
  let src = body.trimStart();
@@ -3101,10 +3589,10 @@ function collectSlugs(options) {
3101
3589
  }
3102
3590
 
3103
3591
  // src/topics/frontmatterRewrite.ts
3104
- import { readFile as readFile11, rename as rename4, writeFile as writeFile6 } from "fs/promises";
3592
+ import { readFile as readFile12, rename as rename4, writeFile as writeFile6 } from "fs/promises";
3105
3593
  import yaml3 from "js-yaml";
3106
3594
  async function rewritePageTopics(filePath, transform) {
3107
- const raw = await readFile11(filePath, "utf8");
3595
+ const raw = await readFile12(filePath, "utf8");
3108
3596
  const { before, after, output, changed } = applyTopicsTransform(
3109
3597
  raw,
3110
3598
  transform
@@ -3482,7 +3970,7 @@ async function runUntag(options) {
3482
3970
  }
3483
3971
 
3484
3972
  // src/commands/topics.ts
3485
- import { readFile as readFile12 } from "fs/promises";
3973
+ import { readFile as readFile13 } from "fs/promises";
3486
3974
  import { join as join12 } from "path";
3487
3975
  import fg3 from "fast-glob";
3488
3976
  async function runTopicsList(options) {
@@ -3963,7 +4451,7 @@ async function rewriteTopicOnPages(repoRoot, transform) {
3963
4451
  });
3964
4452
  let changed = 0;
3965
4453
  for (const filePath of files) {
3966
- const raw = await readFile12(filePath, "utf8");
4454
+ const raw = await readFile13(filePath, "utf8");
3967
4455
  const applied = applyTopicsTransform(raw, transform);
3968
4456
  if (!applied.changed) continue;
3969
4457
  await rewritePageTopics(filePath, transform);
@@ -3973,18 +4461,18 @@ async function rewriteTopicOnPages(repoRoot, transform) {
3973
4461
  }
3974
4462
 
3975
4463
  // src/commands/uninstall.ts
3976
- import { existsSync as existsSync13 } from "fs";
3977
- import { readFile as readFile13, rm, writeFile as writeFile7 } from "fs/promises";
3978
- import { homedir as homedir5 } from "os";
3979
- import path4 from "path";
3980
- var BLUE2 = "\x1B[38;5;75m";
3981
- var DIM2 = "\x1B[2m";
3982
- var RST2 = "\x1B[0m";
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";
3983
4471
  async function runUninstall(options = {}) {
3984
4472
  const out = options.stdout ?? process.stdout;
3985
4473
  const isTTY = options.isTTY ?? process.stdin.isTTY === true;
3986
4474
  const interactive = isTTY && options.yes !== true;
3987
- const claudeDir = options.claudeDir ?? path4.join(homedir5(), ".claude");
4475
+ const claudeDir = options.claudeDir ?? path5.join(homedir6(), ".claude");
3988
4476
  out.write("\n");
3989
4477
  let removeHook = true;
3990
4478
  if (options.keepHook === true) {
@@ -4004,10 +4492,10 @@ async function runUninstall(options = {}) {
4004
4492
  if (res.exitCode !== 0) {
4005
4493
  return { stdout: "", stderr: res.stderr, exitCode: res.exitCode };
4006
4494
  }
4007
- out.write(` ${BLUE2}\u25C7${RST2} ${res.stdout.trim()}
4495
+ out.write(` ${BLUE3}\u25C7${RST3} ${res.stdout.trim()}
4008
4496
  `);
4009
4497
  } else {
4010
- out.write(` ${DIM2}\u25CB Hook kept${RST2}
4498
+ out.write(` ${DIM3}\u25CB Hook kept${RST3}
4011
4499
  `);
4012
4500
  }
4013
4501
  let removeGuides = true;
@@ -4024,38 +4512,38 @@ async function runUninstall(options = {}) {
4024
4512
  const summary = await removeGuideFiles(claudeDir);
4025
4513
  if (summary.anyChanges) {
4026
4514
  out.write(
4027
- ` ${BLUE2}\u25C7${RST2} Guides removed (${summary.filesTouched.join(", ")})
4515
+ ` ${BLUE3}\u25C7${RST3} Guides removed (${summary.filesTouched.join(", ")})
4028
4516
  `
4029
4517
  );
4030
4518
  } else {
4031
- out.write(` ${DIM2}\u25CB Guides not installed${RST2}
4519
+ out.write(` ${DIM3}\u25CB Guides not installed${RST3}
4032
4520
  `);
4033
4521
  }
4034
4522
  } else {
4035
- out.write(` ${DIM2}\u25CB Guides kept${RST2}
4523
+ out.write(` ${DIM3}\u25CB Guides kept${RST3}
4036
4524
  `);
4037
4525
  }
4038
4526
  out.write(`
4039
- ${BLUE2}\u25C7${RST2} ${BLUE2}Uninstall complete${RST2}
4527
+ ${BLUE3}\u25C7${RST3} ${BLUE3}Uninstall complete${RST3}
4040
4528
 
4041
4529
  `);
4042
4530
  return { stdout: "", stderr: "", exitCode: 0 };
4043
4531
  }
4044
4532
  async function removeGuideFiles(claudeDir) {
4045
4533
  const touched = [];
4046
- const mini = path4.join(claudeDir, "codealmanac.md");
4047
- const ref = path4.join(claudeDir, "codealmanac-reference.md");
4048
- const claudeMd = path4.join(claudeDir, "CLAUDE.md");
4049
- if (existsSync13(mini)) {
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)) {
4050
4538
  await rm(mini, { force: true });
4051
4539
  touched.push("codealmanac.md");
4052
4540
  }
4053
- if (existsSync13(ref)) {
4541
+ if (existsSync14(ref)) {
4054
4542
  await rm(ref, { force: true });
4055
4543
  touched.push("codealmanac-reference.md");
4056
4544
  }
4057
- if (existsSync13(claudeMd)) {
4058
- const existing = await readFile13(claudeMd, "utf8");
4545
+ if (existsSync14(claudeMd)) {
4546
+ const existing = await readFile14(claudeMd, "utf8");
4059
4547
  const { changed, body } = removeImportLine(existing);
4060
4548
  if (changed) {
4061
4549
  if (body.trim().length === 0) {
@@ -4087,7 +4575,7 @@ function removeImportLine(contents) {
4087
4575
  function confirm2(out, question, defaultYes) {
4088
4576
  return new Promise((resolve2) => {
4089
4577
  const hint = defaultYes ? "[Y/n]" : "[y/N]";
4090
- out.write(` ${BLUE2}\u25C6${RST2} ${question} ${DIM2}${hint}${RST2} `);
4578
+ out.write(` ${BLUE3}\u25C6${RST3} ${question} ${DIM3}${hint}${RST3} `);
4091
4579
  let buf = "";
4092
4580
  const onData = (chunk) => {
4093
4581
  buf += chunk.toString("utf8");
@@ -4105,13 +4593,13 @@ function confirm2(out, question, defaultYes) {
4105
4593
  }
4106
4594
 
4107
4595
  // src/registry/autoregister.ts
4108
- import { existsSync as existsSync14 } from "fs";
4596
+ import { existsSync as existsSync15 } from "fs";
4109
4597
  import { basename as basename5 } from "path";
4110
4598
  async function autoRegisterIfNeeded(cwd) {
4111
4599
  try {
4112
4600
  const repoRoot = findNearestAlmanacDir(cwd);
4113
4601
  if (repoRoot === null) return null;
4114
- if (!existsSync14(repoRoot)) return null;
4602
+ if (!existsSync15(repoRoot)) return null;
4115
4603
  const entries = await readRegistry();
4116
4604
  const existing = entries.find((e) => samePath(e.path, repoRoot));
4117
4605
  if (existing !== void 0) return existing;
@@ -4161,13 +4649,16 @@ async function run(argv) {
4161
4649
  const program = new Command();
4162
4650
  program.name(programName).description(
4163
4651
  "codealmanac \u2014 a living wiki for codebases, maintained by AI agents"
4164
- ).version(readPackageVersion(), "-v, --version", "print version");
4165
- if (programName === "codealmanac" && argv.length === 2) {
4166
- const result = await runSetup({});
4167
- if (result.stderr.length > 0) process.stderr.write(result.stderr);
4168
- if (result.stdout.length > 0) process.stdout.write(result.stdout);
4169
- if (result.exitCode !== 0) process.exitCode = result.exitCode;
4170
- return;
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;
4661
+ }
4171
4662
  }
4172
4663
  program.command("search [query]").description("find pages by text, topic, file mentions, freshness").option(
4173
4664
  "--topic <name...>",
@@ -4176,7 +4667,7 @@ async function run(argv) {
4176
4667
  []
4177
4668
  ).option(
4178
4669
  "--mentions <path>",
4179
- "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"
4180
4671
  ).option(
4181
4672
  "--since <duration>",
4182
4673
  "updated within duration, by file mtime (e.g. 2w, 30d)"
@@ -4452,7 +4943,24 @@ async function run(argv) {
4452
4943
  emit(result);
4453
4944
  }
4454
4945
  );
4455
- program.command("uninstall").description("remove the hook + guides + import line").option("-y, --yes", "skip confirmations; remove everything").option("--keep-hook", "leave the hook alone").option("--keep-guides", "leave the guides + CLAUDE.md import alone").action(
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({
4949
+ cwd: process.cwd(),
4950
+ json: opts.json,
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(
4456
4964
  async (opts) => {
4457
4965
  const result = await runUninstall({
4458
4966
  yes: opts.yes,
@@ -4480,7 +4988,7 @@ var HELP_GROUPS = [
4480
4988
  },
4481
4989
  {
4482
4990
  title: "Setup",
4483
- commands: ["setup", "uninstall"]
4991
+ commands: ["setup", "uninstall", "doctor"]
4484
4992
  }
4485
4993
  ];
4486
4994
  function configureGroupedHelp(program) {
@@ -4581,9 +5089,9 @@ function renderDefault(cmd, helper) {
4581
5089
  }
4582
5090
  return lines.join("\n");
4583
5091
  }
4584
- function readPackageVersion() {
5092
+ function readPackageVersion2() {
4585
5093
  try {
4586
- const require2 = createRequire3(import.meta.url);
5094
+ const require2 = createRequire4(import.meta.url);
4587
5095
  const pkg = require2("../package.json");
4588
5096
  if (typeof pkg.version === "string" && pkg.version.length > 0) {
4589
5097
  return pkg.version;
@@ -4615,6 +5123,26 @@ async function readStdin() {
4615
5123
  }
4616
5124
  return Buffer.concat(chunks).toString("utf8");
4617
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
+ }
4618
5146
 
4619
5147
  // bin/codealmanac.ts
4620
5148
  run(process.argv).catch((err) => {