codealmanac 0.1.2 → 0.1.4

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,175 @@ function closeStream2(stream) {
1060
1060
  });
1061
1061
  }
1062
1062
 
1063
- // src/commands/hook.ts
1063
+ // src/commands/doctor.ts
1064
+ import { existsSync as existsSync12, readFileSync, 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
+ import { fileURLToPath as fileURLToPath4 } from "url";
1070
+
1071
+ // src/indexer/schema.ts
1072
+ import Database from "better-sqlite3";
1073
+ var SCHEMA_DDL = `
1074
+ CREATE TABLE IF NOT EXISTS pages (
1075
+ slug TEXT PRIMARY KEY,
1076
+ title TEXT,
1077
+ file_path TEXT NOT NULL,
1078
+ content_hash TEXT NOT NULL,
1079
+ updated_at INTEGER NOT NULL,
1080
+ archived_at INTEGER,
1081
+ superseded_by TEXT
1082
+ );
1083
+
1084
+ CREATE TABLE IF NOT EXISTS topics (
1085
+ slug TEXT PRIMARY KEY,
1086
+ title TEXT,
1087
+ description TEXT
1088
+ );
1089
+
1090
+ CREATE TABLE IF NOT EXISTS page_topics (
1091
+ page_slug TEXT NOT NULL REFERENCES pages(slug) ON DELETE CASCADE,
1092
+ topic_slug TEXT NOT NULL,
1093
+ PRIMARY KEY (page_slug, topic_slug)
1094
+ );
1095
+
1096
+ CREATE TABLE IF NOT EXISTS topic_parents (
1097
+ child_slug TEXT NOT NULL,
1098
+ parent_slug TEXT NOT NULL,
1099
+ PRIMARY KEY (child_slug, parent_slug),
1100
+ CHECK (child_slug != parent_slug)
1101
+ );
1102
+
1103
+ CREATE TABLE IF NOT EXISTS file_refs (
1104
+ page_slug TEXT NOT NULL REFERENCES pages(slug) ON DELETE CASCADE,
1105
+ path TEXT NOT NULL,
1106
+ original_path TEXT NOT NULL,
1107
+ is_dir INTEGER NOT NULL,
1108
+ PRIMARY KEY (page_slug, path)
1109
+ );
1110
+ CREATE INDEX IF NOT EXISTS idx_file_refs_path ON file_refs(path);
1111
+
1112
+ CREATE TABLE IF NOT EXISTS wikilinks (
1113
+ source_slug TEXT NOT NULL REFERENCES pages(slug) ON DELETE CASCADE,
1114
+ target_slug TEXT NOT NULL,
1115
+ PRIMARY KEY (source_slug, target_slug)
1116
+ );
1117
+
1118
+ CREATE TABLE IF NOT EXISTS cross_wiki_links (
1119
+ source_slug TEXT NOT NULL REFERENCES pages(slug) ON DELETE CASCADE,
1120
+ target_wiki TEXT NOT NULL,
1121
+ target_slug TEXT NOT NULL,
1122
+ PRIMARY KEY (source_slug, target_wiki, target_slug)
1123
+ );
1124
+
1125
+ -- NOTE: virtual FTS5 table \u2014 ON DELETE CASCADE from pages does NOT apply.
1126
+ -- The indexer must explicitly DELETE FROM fts_pages whenever it removes
1127
+ -- or replaces a page row, or we leak orphaned FTS rows.
1128
+ CREATE VIRTUAL TABLE IF NOT EXISTS fts_pages USING fts5(slug, title, content);
1129
+ `;
1130
+ var SCHEMA_VERSION = 2;
1131
+ function openIndex(dbPath) {
1132
+ const db = new Database(dbPath);
1133
+ const mode = db.pragma("journal_mode", { simple: true });
1134
+ if (typeof mode !== "string" || mode.toLowerCase() !== "wal") {
1135
+ db.pragma("journal_mode = WAL");
1136
+ }
1137
+ db.pragma("foreign_keys = ON");
1138
+ const rawVersion = db.pragma("user_version", { simple: true });
1139
+ const currentVersion = typeof rawVersion === "number" ? rawVersion : 0;
1140
+ if (currentVersion < SCHEMA_VERSION) {
1141
+ db.exec("DROP TABLE IF EXISTS file_refs");
1142
+ try {
1143
+ db.exec("UPDATE pages SET content_hash = ''");
1144
+ } catch {
1145
+ }
1146
+ db.pragma(`user_version = ${SCHEMA_VERSION}`);
1147
+ }
1148
+ db.exec(SCHEMA_DDL);
1149
+ return db;
1150
+ }
1151
+
1152
+ // src/commands/health.ts
1153
+ import { existsSync as existsSync9 } from "fs";
1154
+ import { readFile as readFile7 } from "fs/promises";
1155
+ import { basename as basename4, join as join8 } from "path";
1156
+ import fg2 from "fast-glob";
1157
+
1158
+ // src/indexer/duration.ts
1159
+ function parseDuration(input) {
1160
+ const trimmed = input.trim();
1161
+ const m = trimmed.match(/^(\d+)([mhdw])$/);
1162
+ if (m === null) {
1163
+ throw new Error(
1164
+ `invalid duration "${input}" (expected Nw, Nd, Nh, or Nm \u2014 e.g. 2w, 30d)`
1165
+ );
1166
+ }
1167
+ const n = Number.parseInt(m[1] ?? "0", 10);
1168
+ const unit = m[2];
1169
+ switch (unit) {
1170
+ case "m":
1171
+ return n * 60;
1172
+ case "h":
1173
+ return n * 60 * 60;
1174
+ case "d":
1175
+ return n * 60 * 60 * 24;
1176
+ case "w":
1177
+ return n * 60 * 60 * 24 * 7;
1178
+ default:
1179
+ throw new Error(`invalid duration unit "${unit ?? ""}"`);
1180
+ }
1181
+ }
1182
+
1183
+ // src/indexer/index.ts
1184
+ import { createHash as createHash2 } from "crypto";
1185
+ import { existsSync as existsSync7, statSync as statSync2 } from "fs";
1186
+ import { readFile as readFile6, utimes } from "fs/promises";
1187
+ import { basename as basename3, join as join6, relative as relative3 } from "path";
1188
+ import fg from "fast-glob";
1189
+
1190
+ // src/topics/yaml.ts
1064
1191
  import { existsSync as existsSync6 } from "fs";
1065
1192
  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 };
1193
+ import { dirname as dirname4 } from "path";
1194
+ import yaml2 from "js-yaml";
1195
+ async function loadTopicsFile(path6) {
1196
+ if (!existsSync6(path6)) {
1197
+ return { topics: [] };
1075
1198
  }
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
- };
1199
+ let raw;
1200
+ try {
1201
+ raw = await readFile5(path6, "utf8");
1202
+ } catch (err) {
1203
+ if (isNodeError2(err) && err.code === "ENOENT") {
1204
+ return { topics: [] };
1205
+ }
1206
+ throw err;
1097
1207
  }
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
- };
1208
+ const trimmed = raw.trim();
1209
+ if (trimmed.length === 0) {
1210
+ return { topics: [] };
1105
1211
  }
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
- };
1212
+ let parsed;
1213
+ try {
1214
+ parsed = yaml2.load(raw);
1215
+ } catch (err) {
1216
+ const message = err instanceof Error ? err.message : String(err);
1217
+ throw new Error(`topics.yaml at ${path6} is not valid YAML: ${message}`);
1133
1218
  }
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
- };
1219
+ if (parsed === null || parsed === void 0) {
1220
+ return { topics: [] };
1145
1221
  }
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
- }
1222
+ if (typeof parsed !== "object" || Array.isArray(parsed)) {
1223
+ throw new Error(`topics.yaml at ${path6} must be a mapping`);
1157
1224
  }
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: [] };
1225
+ const obj = parsed;
1226
+ const rawTopics = obj.topics;
1227
+ if (rawTopics === void 0 || rawTopics === null) {
1228
+ return { topics: [] };
1332
1229
  }
1333
1230
  if (!Array.isArray(rawTopics)) {
1334
- throw new Error(`topics.yaml at ${path5} \u2014 "topics" must be a list`);
1231
+ throw new Error(`topics.yaml at ${path6} \u2014 "topics" must be a list`);
1335
1232
  }
1336
1233
  const topics = [];
1337
1234
  for (const item of rawTopics) {
@@ -1360,7 +1257,7 @@ async function loadTopicsFile(path5) {
1360
1257
  }
1361
1258
  return { topics };
1362
1259
  }
1363
- async function writeTopicsFile(path5, file) {
1260
+ async function writeTopicsFile(path6, file) {
1364
1261
  const sorted = [...file.topics].sort((a, b) => a.slug.localeCompare(b.slug));
1365
1262
  const doc = {
1366
1263
  topics: sorted.map((t) => {
@@ -1385,13 +1282,13 @@ async function writeTopicsFile(path5, file) {
1385
1282
  sortKeys: false
1386
1283
  });
1387
1284
  const content = `${header}${body}`;
1388
- const tmpPath = `${path5}.tmp`;
1389
- const parent = dirname4(path5);
1390
- if (!existsSync7(parent)) {
1391
- await mkdir4(parent, { recursive: true });
1285
+ const tmpPath = `${path6}.tmp`;
1286
+ const parent = dirname4(path6);
1287
+ if (!existsSync6(parent)) {
1288
+ await mkdir3(parent, { recursive: true });
1392
1289
  }
1393
- await writeFile4(tmpPath, content, "utf8");
1394
- await rename3(tmpPath, path5);
1290
+ await writeFile3(tmpPath, content, "utf8");
1291
+ await rename2(tmpPath, path6);
1395
1292
  }
1396
1293
  function findTopic(file, slug) {
1397
1294
  for (const t of file.topics) {
@@ -1443,87 +1340,6 @@ function looksLikeDir(raw) {
1443
1340
  return s.endsWith("/");
1444
1341
  }
1445
1342
 
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
1343
  // src/indexer/wikilinks.ts
1528
1344
  function classifyWikilink(raw) {
1529
1345
  const pipe = raw.indexOf("|");
@@ -1540,10 +1356,10 @@ function classifyWikilink(raw) {
1540
1356
  }
1541
1357
  if (firstSlash !== -1) {
1542
1358
  const isDir = looksLikeDir(body);
1543
- const path5 = normalizePath(body, isDir);
1359
+ const path6 = normalizePath(body, isDir);
1544
1360
  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 };
1361
+ if (path6.length === 0) return null;
1362
+ return isDir ? { kind: "folder", path: path6, originalPath } : { kind: "file", path: path6, originalPath };
1547
1363
  }
1548
1364
  const target = toKebabCase(body);
1549
1365
  if (target.length === 0) return null;
@@ -1567,12 +1383,12 @@ async function ensureFreshIndex(ctx) {
1567
1383
  const almanacDir = join6(ctx.repoRoot, ".almanac");
1568
1384
  const dbPath = join6(almanacDir, "index.db");
1569
1385
  const pagesDir = join6(almanacDir, "pages");
1570
- if (!existsSync8(pagesDir)) {
1386
+ if (!existsSync7(pagesDir)) {
1571
1387
  const db = openIndex(dbPath);
1572
1388
  db.close();
1573
1389
  return emptyResult();
1574
1390
  }
1575
- if (!existsSync8(dbPath) || pagesNewerThan(pagesDir, dbPath) || topicsYamlNewerThan(almanacDir, dbPath)) {
1391
+ if (!existsSync7(dbPath) || pagesNewerThan(pagesDir, dbPath) || topicsYamlNewerThan(almanacDir, dbPath)) {
1576
1392
  return runIndexer(ctx);
1577
1393
  }
1578
1394
  return emptyResult();
@@ -1649,7 +1465,7 @@ async function indexPagesInto(db, pagesDir) {
1649
1465
  let raw;
1650
1466
  try {
1651
1467
  st = statSync2(fullPath);
1652
- raw = await readFile7(fullPath, "utf8");
1468
+ raw = await readFile6(fullPath, "utf8");
1653
1469
  } catch (err) {
1654
1470
  if (err instanceof Error && "code" in err && (err.code === "ENOENT" || err.code === "EACCES")) {
1655
1471
  process.stderr.write(
@@ -1763,10 +1579,10 @@ async function indexPagesInto(db, pagesDir) {
1763
1579
  }
1764
1580
  for (const raw of p.frontmatterFiles) {
1765
1581
  const isDir = looksLikeDir(raw);
1766
- const path5 = normalizePath(raw, isDir);
1582
+ const path6 = normalizePath(raw, isDir);
1767
1583
  const originalPath = normalizePathPreservingCase(raw, isDir);
1768
- if (path5.length === 0) continue;
1769
- insertFileRef.run(p.slug, path5, originalPath, isDir ? 1 : 0);
1584
+ if (path6.length === 0) continue;
1585
+ insertFileRef.run(p.slug, path6, originalPath, isDir ? 1 : 0);
1770
1586
  }
1771
1587
  for (const ref of p.wikilinks) {
1772
1588
  switch (ref.kind) {
@@ -1822,8 +1638,8 @@ function hashContent(raw) {
1822
1638
  return createHash2("sha256").update(raw).digest("hex");
1823
1639
  }
1824
1640
  function topicsYamlNewerThan(almanacDir, dbPath) {
1825
- const path5 = join6(almanacDir, "topics.yaml");
1826
- if (!existsSync8(path5)) return false;
1641
+ const path6 = join6(almanacDir, "topics.yaml");
1642
+ if (!existsSync7(path6)) return false;
1827
1643
  let dbMtime;
1828
1644
  try {
1829
1645
  dbMtime = statSync2(dbPath).mtimeMs;
@@ -1831,14 +1647,14 @@ function topicsYamlNewerThan(almanacDir, dbPath) {
1831
1647
  return true;
1832
1648
  }
1833
1649
  try {
1834
- const st = statSync2(path5);
1650
+ const st = statSync2(path6);
1835
1651
  return st.mtimeMs > dbMtime;
1836
1652
  } catch {
1837
1653
  return false;
1838
1654
  }
1839
1655
  }
1840
1656
  async function applyTopicsYaml(db, topicsYamlPath2) {
1841
- if (!existsSync8(topicsYamlPath2)) return;
1657
+ if (!existsSync7(topicsYamlPath2)) return;
1842
1658
  let file;
1843
1659
  try {
1844
1660
  file = await loadTopicsFile(topicsYamlPath2);
@@ -1894,7 +1710,7 @@ async function applyTopicsYaml(db, topicsYamlPath2) {
1894
1710
  }
1895
1711
 
1896
1712
  // src/indexer/resolveWiki.ts
1897
- import { existsSync as existsSync9 } from "fs";
1713
+ import { existsSync as existsSync8 } from "fs";
1898
1714
  import { join as join7 } from "path";
1899
1715
  async function resolveWikiRoot(params) {
1900
1716
  if (params.wiki !== void 0) {
@@ -1902,7 +1718,7 @@ async function resolveWikiRoot(params) {
1902
1718
  if (entry === null) {
1903
1719
  throw new Error(`no registered wiki named "${params.wiki}"`);
1904
1720
  }
1905
- if (!existsSync9(join7(entry.path, ".almanac"))) {
1721
+ if (!existsSync8(join7(entry.path, ".almanac"))) {
1906
1722
  throw new Error(
1907
1723
  `wiki "${params.wiki}" path is unreachable (${entry.path})`
1908
1724
  );
@@ -1912,7 +1728,7 @@ async function resolveWikiRoot(params) {
1912
1728
  const nearest = findNearestAlmanacDir(params.cwd);
1913
1729
  if (nearest === null) {
1914
1730
  throw new Error(
1915
- "no .almanac/ found in this directory or any parent; run `almanac init` first"
1731
+ "no .almanac/ found in this directory or any parent; run `almanac bootstrap` first"
1916
1732
  );
1917
1733
  }
1918
1734
  return nearest;
@@ -2068,7 +1884,7 @@ async function findDeadRefs(db, scope, repoRoot) {
2068
1884
  for (const r of rows) {
2069
1885
  if (!inPageScope(scope, r.slug)) continue;
2070
1886
  const abs = join8(repoRoot, r.original_path);
2071
- if (!existsSync10(abs)) {
1887
+ if (!existsSync9(abs)) {
2072
1888
  out.push({ slug: r.slug, path: r.original_path });
2073
1889
  }
2074
1890
  }
@@ -2103,7 +1919,7 @@ async function findBrokenXwiki(db, scope) {
2103
1919
  let ok = reachableCache.get(r.target_wiki);
2104
1920
  if (ok === void 0) {
2105
1921
  const entry = await findEntry({ name: r.target_wiki });
2106
- ok = entry !== null && existsSync10(join8(entry.path, ".almanac"));
1922
+ ok = entry !== null && existsSync9(join8(entry.path, ".almanac"));
2107
1923
  reachableCache.set(r.target_wiki, ok);
2108
1924
  }
2109
1925
  if (!ok) {
@@ -2138,7 +1954,7 @@ async function findEmptyPages(db, scope, pagesDir) {
2138
1954
  if (!inPageScope(scope, r.slug)) continue;
2139
1955
  let raw;
2140
1956
  try {
2141
- raw = await readFile8(r.file_path, "utf8");
1957
+ raw = await readFile7(r.file_path, "utf8");
2142
1958
  } catch {
2143
1959
  continue;
2144
1960
  }
@@ -2158,7 +1974,7 @@ async function findEmptyPages(db, scope, pagesDir) {
2158
1974
  return out;
2159
1975
  }
2160
1976
  async function findSlugCollisions(pagesDir) {
2161
- if (!existsSync10(pagesDir)) return [];
1977
+ if (!existsSync9(pagesDir)) return [];
2162
1978
  const files = await fg2("**/*.md", {
2163
1979
  cwd: pagesDir,
2164
1980
  absolute: false,
@@ -2253,259 +2069,212 @@ function section(label, count, lines) {
2253
2069
  ${lines.join("\n")}`;
2254
2070
  }
2255
2071
 
2256
- // src/commands/list.ts
2072
+ // src/commands/setup.ts
2257
2073
  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 };
2074
+ import {
2075
+ copyFile,
2076
+ mkdir as mkdir5,
2077
+ readFile as readFile9,
2078
+ writeFile as writeFile5
2079
+ } from "fs/promises";
2080
+ import { createRequire as createRequire2 } from "module";
2081
+ import { homedir as homedir4 } from "os";
2082
+ import path3 from "path";
2083
+ import { fileURLToPath as fileURLToPath3 } from "url";
2084
+
2085
+ // src/commands/hook.ts
2086
+ import { existsSync as existsSync10 } from "fs";
2087
+ import { mkdir as mkdir4, readFile as readFile8, rename as rename3, writeFile as writeFile4 } from "fs/promises";
2088
+ import { homedir as homedir3 } from "os";
2089
+ import path2 from "path";
2090
+ import { fileURLToPath as fileURLToPath2 } from "url";
2091
+ var HOOK_TIMEOUT_SECONDS = 10;
2092
+ async function runHookInstall(options = {}) {
2093
+ const script = resolveHookScriptPath(options);
2094
+ if (!script.ok) {
2095
+ return { stdout: "", stderr: `almanac: ${script.error}
2096
+ `, exitCode: 1 };
2267
2097
  }
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}"
2098
+ const settingsPath = resolveSettingsPath(options);
2099
+ const settings = await readSettings(settingsPath);
2100
+ const existing = (settings.hooks?.SessionEnd ?? []).slice();
2101
+ const ourEntries = existing.filter((e) => e.command === script.path);
2102
+ const foreignEntries = existing.filter((e) => e.command !== script.path);
2103
+ const stale = foreignEntries.filter(
2104
+ (e) => e.command.endsWith("almanac-capture.sh")
2105
+ );
2106
+ const unrelated = foreignEntries.filter(
2107
+ (e) => !e.command.endsWith("almanac-capture.sh")
2108
+ );
2109
+ if (unrelated.length > 0) {
2110
+ const existingStr = unrelated.map((e) => ` - ${e.command}`).join("\n");
2111
+ return {
2112
+ stdout: "",
2113
+ stderr: `almanac: SessionEnd hook already has a foreign entry:
2114
+ ${existingStr}
2115
+ Remove it manually from ${settingsPath} if you want almanac to manage the hook.
2275
2116
  `,
2276
2117
  exitCode: 1
2277
2118
  };
2278
2119
  }
2120
+ if (ourEntries.length > 0 && stale.length === 0) {
2121
+ return {
2122
+ stdout: `almanac: SessionEnd hook already installed at ${script.path}
2123
+ `,
2124
+ stderr: "",
2125
+ exitCode: 0
2126
+ };
2127
+ }
2128
+ const newEntries = [
2129
+ {
2130
+ type: "command",
2131
+ command: script.path,
2132
+ timeout: HOOK_TIMEOUT_SECONDS
2133
+ }
2134
+ ];
2135
+ settings.hooks = { ...settings.hooks ?? {}, SessionEnd: newEntries };
2136
+ await writeSettings(settingsPath, settings);
2279
2137
  return {
2280
- stdout: `removed "${removed.name}" (${removed.path})
2138
+ stdout: `almanac: SessionEnd hook installed
2139
+ script: ${script.path}
2140
+ settings: ${settingsPath}
2281
2141
  `,
2142
+ stderr: "",
2282
2143
  exitCode: 0
2283
2144
  };
2284
2145
  }
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";
2146
+ async function runHookUninstall(options = {}) {
2147
+ const settingsPath = resolveSettingsPath(options);
2148
+ if (!existsSync10(settingsPath)) {
2149
+ return {
2150
+ stdout: `almanac: SessionEnd hook not installed (no settings file)
2151
+ `,
2152
+ stderr: "",
2153
+ exitCode: 0
2154
+ };
2292
2155
  }
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}`);
2156
+ const settings = await readSettings(settingsPath);
2157
+ const existing = (settings.hooks?.SessionEnd ?? []).slice();
2158
+ const kept = existing.filter((e) => !e.command.endsWith("almanac-capture.sh"));
2159
+ const removed = existing.length - kept.length;
2160
+ if (removed === 0) {
2161
+ return {
2162
+ stdout: `almanac: SessionEnd hook not installed
2163
+ `,
2164
+ stderr: "",
2165
+ exitCode: 0
2166
+ };
2303
2167
  }
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();
2168
+ if (settings.hooks !== void 0) {
2169
+ if (kept.length === 0) {
2170
+ const { SessionEnd: _dropped, ...rest } = settings.hooks;
2171
+ void _dropped;
2172
+ settings.hooks = rest;
2173
+ } else {
2174
+ settings.hooks = { ...settings.hooks, SessionEnd: kept };
2175
+ }
2176
+ if (Object.keys(settings.hooks).length === 0) {
2177
+ delete settings.hooks;
2178
+ }
2339
2179
  }
2180
+ await writeSettings(settingsPath, settings);
2181
+ return {
2182
+ stdout: `almanac: SessionEnd hook removed
2183
+ `,
2184
+ stderr: "",
2185
+ exitCode: 0
2186
+ };
2340
2187
  }
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");
2188
+ async function runHookStatus(options = {}) {
2189
+ const script = resolveHookScriptPath(options);
2190
+ const settingsPath = resolveSettingsPath(options);
2191
+ if (!existsSync10(settingsPath)) {
2192
+ return {
2193
+ stdout: `SessionEnd hook: not installed
2194
+ settings: ${settingsPath} (does not exist)
2195
+ ` + (script.ok ? `script would be: ${script.path}
2196
+ ` : ""),
2197
+ stderr: "",
2198
+ exitCode: 0
2199
+ };
2348
2200
  }
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);
2201
+ const settings = await readSettings(settingsPath);
2202
+ const existing = settings.hooks?.SessionEnd ?? [];
2203
+ const ours = existing.find((e) => e.command.endsWith("almanac-capture.sh"));
2204
+ if (ours === void 0) {
2205
+ const foreign = existing.map((e) => ` - ${e.command}`).join("\n");
2206
+ return {
2207
+ stdout: `SessionEnd hook: not installed
2208
+ settings: ${settingsPath}
2209
+ ` + (existing.length > 0 ? `(${existing.length} foreign entr${existing.length === 1 ? "y" : "ies"} present:
2210
+ ${foreign})
2211
+ ` : "") + (script.ok ? `script would be: ${script.path}
2212
+ ` : ""),
2213
+ stderr: "",
2214
+ exitCode: 0
2215
+ };
2356
2216
  }
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
- }
2217
+ return {
2218
+ stdout: `SessionEnd hook: installed
2219
+ script: ${ours.command}
2220
+ settings: ${settingsPath}
2221
+ `,
2222
+ stderr: "",
2223
+ exitCode: 0
2224
+ };
2225
+ }
2226
+ function resolveSettingsPath(options) {
2227
+ if (options.settingsPath !== void 0) return options.settingsPath;
2228
+ return path2.join(homedir3(), ".claude", "settings.json");
2229
+ }
2230
+ function resolveHookScriptPath(options) {
2231
+ if (options.hookScriptPath !== void 0) {
2232
+ return { ok: true, path: options.hookScriptPath };
2233
+ }
2234
+ const here = path2.dirname(fileURLToPath2(import.meta.url));
2235
+ const candidates = [
2236
+ // Bundled: `.../codealmanac/dist/codealmanac.js` `../hooks/…`
2237
+ path2.resolve(here, "..", "hooks", "almanac-capture.sh"),
2238
+ // Source: `.../codealmanac/src/commands/hook.ts` → `../../hooks/…`
2239
+ path2.resolve(here, "..", "..", "hooks", "almanac-capture.sh"),
2240
+ // Defensive nested fallback.
2241
+ path2.resolve(here, "..", "..", "..", "hooks", "almanac-capture.sh")
2242
+ ];
2243
+ for (const candidate of candidates) {
2244
+ if (existsSync10(candidate)) {
2245
+ return { ok: true, path: candidate };
2394
2246
  }
2395
2247
  }
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);
2248
+ return {
2249
+ ok: false,
2250
+ error: `could not locate hooks/almanac-capture.sh. Tried:
2251
+ ` + candidates.map((c) => ` - ${c}`).join("\n")
2252
+ };
2253
+ }
2254
+ async function readSettings(settingsPath) {
2255
+ if (!existsSync10(settingsPath)) return {};
2256
+ try {
2257
+ const raw = await readFile8(settingsPath, "utf8");
2258
+ if (raw.trim().length === 0) return {};
2259
+ const parsed = JSON.parse(raw);
2260
+ if (parsed === null || typeof parsed !== "object") return {};
2261
+ return parsed;
2262
+ } catch (err) {
2263
+ const msg = err instanceof Error ? err.message : String(err);
2264
+ throw new Error(`failed to read ${settingsPath}: ${msg}`);
2401
2265
  }
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
2266
  }
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
2267
+ async function writeSettings(settingsPath, settings) {
2268
+ const dir = path2.dirname(settingsPath);
2269
+ await mkdir4(dir, { recursive: true });
2270
+ const tmp = `${settingsPath}.almanac-tmp-${process.pid}`;
2271
+ const body = `${JSON.stringify(settings, null, 2)}
2492
2272
  `;
2493
- }
2494
- return "";
2273
+ await writeFile4(tmp, body, "utf8");
2274
+ await rename3(tmp, settingsPath);
2495
2275
  }
2496
2276
 
2497
2277
  // 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
2278
  var RST = "\x1B[0m";
2510
2279
  var BOLD = "\x1B[1m";
2511
2280
  var DIM = "\x1B[2m";
@@ -2563,6 +2332,12 @@ async function runSetup(options = {}) {
2563
2332
  const out = options.stdout ?? process.stdout;
2564
2333
  const isTTY = options.isTTY ?? process.stdin.isTTY === true;
2565
2334
  const interactive = isTTY && options.yes !== true;
2335
+ if (options.skipHook === true && options.skipGuides === true) {
2336
+ out.write(
2337
+ "codealmanac: nothing to install \u2014 use --help to see what setup does\n"
2338
+ );
2339
+ return { stdout: "", stderr: "", exitCode: 0 };
2340
+ }
2566
2341
  printBanner(out);
2567
2342
  printBadge(out);
2568
2343
  const auth = await safeCheckAuth(options.spawnCli);
@@ -2649,147 +2424,874 @@ function reportAuth(out, auth) {
2649
2424
  stepDone(out, `Claude auth: ${WHITE_BOLD}${who}${RST}${plan}`);
2650
2425
  return;
2651
2426
  }
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;
2427
+ if (process.env.ANTHROPIC_API_KEY !== void 0 && process.env.ANTHROPIC_API_KEY.length > 0) {
2428
+ stepDone(out, `Claude auth: ${WHITE_BOLD}ANTHROPIC_API_KEY${RST} set`);
2429
+ return;
2430
+ }
2431
+ stepActive(out, `Claude auth: ${DIM}not signed in${RST}`);
2432
+ for (const line of UNAUTHENTICATED_MESSAGE.split("\n")) {
2433
+ out.write(` ${DIM}\u2502 ${line}${RST}
2434
+ `);
2435
+ }
2436
+ }
2437
+ async function installGuides(options) {
2438
+ await mkdir5(options.claudeDir, { recursive: true });
2439
+ const srcMini = path3.join(options.guidesDir, "mini.md");
2440
+ const srcRef = path3.join(options.guidesDir, "reference.md");
2441
+ if (!existsSync11(srcMini)) {
2442
+ throw new Error(`missing bundled guide: ${srcMini}`);
2443
+ }
2444
+ if (!existsSync11(srcRef)) {
2445
+ throw new Error(`missing bundled guide: ${srcRef}`);
2446
+ }
2447
+ const destMini = path3.join(options.claudeDir, "codealmanac.md");
2448
+ const destRef = path3.join(options.claudeDir, "codealmanac-reference.md");
2449
+ const miniChanged = await copyIfChanged(srcMini, destMini);
2450
+ const refChanged = await copyIfChanged(srcRef, destRef);
2451
+ const claudeMd = path3.join(options.claudeDir, "CLAUDE.md");
2452
+ const importChanged = await ensureImport(claudeMd);
2453
+ const filesWritten = [];
2454
+ if (miniChanged) filesWritten.push("codealmanac.md");
2455
+ if (refChanged) filesWritten.push("codealmanac-reference.md");
2456
+ if (importChanged) filesWritten.push("CLAUDE.md");
2457
+ return { anyChanges: filesWritten.length > 0, filesWritten };
2458
+ }
2459
+ async function copyIfChanged(src, dest) {
2460
+ const srcBytes = await readFile9(src);
2461
+ if (existsSync11(dest)) {
2462
+ try {
2463
+ const destBytes = await readFile9(dest);
2464
+ if (srcBytes.equals(destBytes)) return false;
2465
+ } catch {
2466
+ }
2467
+ }
2468
+ await copyFile(src, dest);
2469
+ return true;
2470
+ }
2471
+ var IMPORT_LINE = "@~/.claude/codealmanac.md";
2472
+ async function ensureImport(claudeMdPath) {
2473
+ let existing = "";
2474
+ if (existsSync11(claudeMdPath)) {
2475
+ existing = await readFile9(claudeMdPath, "utf8");
2476
+ }
2477
+ if (hasImportLine(existing)) return false;
2478
+ const sep = existing.length === 0 ? "" : existing.endsWith("\n") ? "\n" : "\n\n";
2479
+ const body = `${existing}${sep}${IMPORT_LINE}
2480
+ `;
2481
+ await writeFile5(claudeMdPath, body, "utf8");
2482
+ return true;
2483
+ }
2484
+ function hasImportLine(contents) {
2485
+ const lines = contents.split(/\r?\n/).map((l) => l.trim());
2486
+ return lines.some((line) => {
2487
+ if (line === IMPORT_LINE) return true;
2488
+ if (!line.startsWith(IMPORT_LINE)) return false;
2489
+ const next = line[IMPORT_LINE.length];
2490
+ return next === " " || next === " ";
2491
+ });
2492
+ }
2493
+ function confirm(out, question, defaultYes) {
2494
+ return new Promise((resolve2) => {
2495
+ const hint = defaultYes ? "[Y/n]" : "[y/N]";
2496
+ out.write(` ${BLUE}\u25C6${RST} ${question} ${DIM}${hint}${RST} `);
2497
+ let buf = "";
2498
+ const onData = (chunk) => {
2499
+ buf += chunk.toString("utf8");
2500
+ const nl = buf.indexOf("\n");
2501
+ if (nl === -1) return;
2502
+ process.stdin.removeListener("data", onData);
2503
+ process.stdin.pause();
2504
+ const answer = buf.slice(0, nl).trim().toLowerCase();
2505
+ const accepted = answer.length === 0 ? defaultYes : answer === "y" || answer === "yes";
2506
+ resolve2(accepted ? "install" : "skip");
2507
+ };
2508
+ process.stdin.resume();
2509
+ process.stdin.on("data", onData);
2510
+ });
2511
+ }
2512
+ function printNextSteps(out) {
2513
+ const innerW = 62;
2514
+ const vis = (s) => s.replace(/\x1b\[[0-9;]*m/g, "").length;
2515
+ const row = (content) => {
2516
+ const padding = Math.max(0, innerW - vis(content));
2517
+ return ` ${BLUE_DIM}\u2502${RST}${content}${" ".repeat(padding)}${BLUE_DIM}\u2502${RST}
2518
+ `;
2519
+ };
2520
+ const empty = row("");
2521
+ out.write(` ${BLUE_DIM}\u256D${"\u2500".repeat(innerW)}\u256E${RST}
2522
+ `);
2523
+ out.write(empty);
2524
+ out.write(row(` ${WHITE_BOLD}Next steps${RST}`));
2525
+ out.write(empty);
2526
+ out.write(
2527
+ row(` ${BLUE}1.${RST} ${BOLD}cd${RST} into a repo you want to document`)
2528
+ );
2529
+ out.write(
2530
+ row(
2531
+ ` ${BLUE}2.${RST} ${BOLD}almanac bootstrap${RST} ${DIM}# scaffold the wiki${RST}`
2532
+ )
2533
+ );
2534
+ out.write(
2535
+ row(
2536
+ ` ${BLUE}3.${RST} Work normally \u2014 capture runs on session end`
2537
+ )
2538
+ );
2539
+ out.write(empty);
2540
+ out.write(` ${BLUE_DIM}\u2570${"\u2500".repeat(innerW)}\u256F${RST}
2541
+
2542
+ `);
2543
+ }
2544
+ function resolveGuidesDir() {
2545
+ const here = path3.dirname(fileURLToPath3(import.meta.url));
2546
+ const candidates = [
2547
+ path3.resolve(here, "..", "guides"),
2548
+ // dist layout
2549
+ path3.resolve(here, "..", "..", "guides"),
2550
+ // src layout
2551
+ path3.resolve(here, "..", "..", "..", "guides")
2552
+ ];
2553
+ for (const dir of candidates) {
2554
+ if (looksLikeGuidesDir(dir)) return dir;
2555
+ }
2556
+ try {
2557
+ const require2 = createRequire2(import.meta.url);
2558
+ const pkgJson = require2.resolve("codealmanac/package.json");
2559
+ const guides = path3.join(path3.dirname(pkgJson), "guides");
2560
+ if (looksLikeGuidesDir(guides)) return guides;
2561
+ } catch {
2562
+ }
2563
+ throw new Error(
2564
+ "could not locate bundled guides/ directory. Tried:\n" + candidates.map((c) => ` - ${c}`).join("\n")
2565
+ );
2566
+ }
2567
+ function looksLikeGuidesDir(dir) {
2568
+ return existsSync11(path3.join(dir, "mini.md"));
2569
+ }
2570
+
2571
+ // src/commands/doctor.ts
2572
+ var RST2 = "\x1B[0m";
2573
+ var BOLD2 = "\x1B[1m";
2574
+ var DIM2 = "\x1B[2m";
2575
+ var GREEN = "\x1B[38;5;35m";
2576
+ var RED = "\x1B[38;5;167m";
2577
+ var BLUE2 = "\x1B[38;5;75m";
2578
+ async function runDoctor(options) {
2579
+ const version = options.versionOverride ?? readPackageVersion() ?? "unknown";
2580
+ const install = options.wikiOnly === true ? [] : await gatherInstallChecks(options);
2581
+ const wiki = options.installOnly === true ? [] : await gatherWikiChecks(options);
2582
+ const report = { version, install, wiki };
2583
+ if (options.json === true) {
2584
+ return {
2585
+ stdout: `${JSON.stringify(report, null, 2)}
2586
+ `,
2587
+ stderr: "",
2588
+ exitCode: 0
2589
+ };
2590
+ }
2591
+ return {
2592
+ stdout: formatReport2(report, options),
2593
+ stderr: "",
2594
+ exitCode: 0
2595
+ };
2596
+ }
2597
+ async function gatherInstallChecks(options) {
2598
+ const checks = [];
2599
+ const installPath = options.installPath ?? detectInstallPath();
2600
+ checks.push({
2601
+ status: installPath !== null ? "ok" : "problem",
2602
+ key: "install.path",
2603
+ message: installPath !== null ? `codealmanac installed at ${installPath}` : "could not detect codealmanac install path",
2604
+ fix: installPath === null ? "reinstall with: npm install -g codealmanac" : void 0
2605
+ });
2606
+ const nodeVersion = options.nodeVersion ?? process.version;
2607
+ const sqlite = options.sqliteProbe ?? probeBetterSqlite3();
2608
+ checks.push({
2609
+ status: sqlite.ok ? "ok" : "problem",
2610
+ key: "install.sqlite",
2611
+ message: sqlite.ok ? `better-sqlite3 native binding OK (Node ${nodeVersion})` : `better-sqlite3 native binding failed: ${sqlite.summary}`,
2612
+ fix: sqlite.ok ? void 0 : "run: npm rebuild better-sqlite3 (in the install directory)"
2613
+ });
2614
+ const auth = await safeCheckAuth2(options.spawnCli);
2615
+ checks.push(describeAuth(auth));
2616
+ const settingsPath = options.settingsPath ?? path4.join(homedir5(), ".claude", "settings.json");
2617
+ checks.push(await describeHook(settingsPath));
2618
+ const claudeDir = options.claudeDir ?? path4.join(homedir5(), ".claude");
2619
+ checks.push(describeGuides(claudeDir));
2620
+ checks.push(await describeImportLine(claudeDir));
2621
+ return checks;
2622
+ }
2623
+ function describeAuth(auth) {
2624
+ if (auth.loggedIn) {
2625
+ if (auth.authMethod === "apiKey") {
2626
+ return {
2627
+ status: "ok",
2628
+ key: "install.auth",
2629
+ message: "claude auth: ANTHROPIC_API_KEY set"
2630
+ };
2631
+ }
2632
+ const who = auth.email ?? "Claude account";
2633
+ const plan = auth.subscriptionType !== void 0 ? ` (${auth.subscriptionType} subscription)` : "";
2634
+ return {
2635
+ status: "ok",
2636
+ key: "install.auth",
2637
+ message: `claude auth: ${who}${plan}`
2638
+ };
2639
+ }
2640
+ if (process.env.ANTHROPIC_API_KEY !== void 0 && process.env.ANTHROPIC_API_KEY.length > 0) {
2641
+ return {
2642
+ status: "ok",
2643
+ key: "install.auth",
2644
+ message: "claude auth: ANTHROPIC_API_KEY set"
2645
+ };
2646
+ }
2647
+ return {
2648
+ status: "problem",
2649
+ key: "install.auth",
2650
+ message: "claude auth: not signed in",
2651
+ fix: "run: claude auth login --claudeai (or export ANTHROPIC_API_KEY)"
2652
+ };
2653
+ }
2654
+ async function describeHook(settingsPath) {
2655
+ if (!existsSync12(settingsPath)) {
2656
+ return {
2657
+ status: "problem",
2658
+ key: "install.hook",
2659
+ message: "SessionEnd hook not installed",
2660
+ fix: "run: almanac setup --yes"
2661
+ };
2662
+ }
2663
+ try {
2664
+ const raw = await readFile10(settingsPath, "utf8");
2665
+ const parsed = JSON.parse(raw);
2666
+ const entries = parsed.hooks?.SessionEnd ?? [];
2667
+ const ours = entries.find(
2668
+ (e) => typeof e.command === "string" && e.command.endsWith("almanac-capture.sh")
2669
+ );
2670
+ if (ours === void 0) {
2671
+ return {
2672
+ status: "problem",
2673
+ key: "install.hook",
2674
+ message: "SessionEnd hook not installed",
2675
+ fix: "run: almanac setup --yes"
2676
+ };
2677
+ }
2678
+ return {
2679
+ status: "ok",
2680
+ key: "install.hook",
2681
+ message: `SessionEnd hook installed at ${settingsPath}`
2682
+ };
2683
+ } catch (err) {
2684
+ const msg = err instanceof Error ? err.message : String(err);
2685
+ return {
2686
+ status: "problem",
2687
+ key: "install.hook",
2688
+ message: `could not read ${settingsPath}: ${msg}`,
2689
+ fix: "check the file for malformed JSON"
2690
+ };
2691
+ }
2692
+ }
2693
+ function describeGuides(claudeDir) {
2694
+ const mini = path4.join(claudeDir, "codealmanac.md");
2695
+ const ref = path4.join(claudeDir, "codealmanac-reference.md");
2696
+ const haveMini = existsSync12(mini);
2697
+ const haveRef = existsSync12(ref);
2698
+ if (haveMini && haveRef) {
2699
+ return {
2700
+ status: "ok",
2701
+ key: "install.guides",
2702
+ message: `Agent guides installed (${path4.basename(mini)}, ${path4.basename(ref)})`
2703
+ };
2704
+ }
2705
+ const missing = [
2706
+ haveMini ? null : "codealmanac.md",
2707
+ haveRef ? null : "codealmanac-reference.md"
2708
+ ].filter((s) => s !== null);
2709
+ return {
2710
+ status: "problem",
2711
+ key: "install.guides",
2712
+ message: `Agent guides missing (${missing.join(", ")})`,
2713
+ fix: "run: almanac setup --yes"
2714
+ };
2715
+ }
2716
+ async function describeImportLine(claudeDir) {
2717
+ const claudeMd = path4.join(claudeDir, "CLAUDE.md");
2718
+ if (!existsSync12(claudeMd)) {
2719
+ return {
2720
+ status: "problem",
2721
+ key: "install.import",
2722
+ message: "CLAUDE.md import not present (no ~/.claude/CLAUDE.md)",
2723
+ fix: "run: almanac setup --yes"
2724
+ };
2725
+ }
2726
+ try {
2727
+ const contents = await readFile10(claudeMd, "utf8");
2728
+ const lines = contents.split(/\r?\n/).map((l) => l.trim());
2729
+ const present = lines.some((line) => {
2730
+ if (line === IMPORT_LINE) return true;
2731
+ if (!line.startsWith(IMPORT_LINE)) return false;
2732
+ const next = line[IMPORT_LINE.length];
2733
+ return next === " " || next === " ";
2734
+ });
2735
+ if (present) {
2736
+ return {
2737
+ status: "ok",
2738
+ key: "install.import",
2739
+ message: "CLAUDE.md import present"
2740
+ };
2741
+ }
2742
+ return {
2743
+ status: "problem",
2744
+ key: "install.import",
2745
+ message: "CLAUDE.md import line missing",
2746
+ fix: "run: almanac setup --yes"
2747
+ };
2748
+ } catch (err) {
2749
+ const msg = err instanceof Error ? err.message : String(err);
2750
+ return {
2751
+ status: "problem",
2752
+ key: "install.import",
2753
+ message: `could not read ${claudeMd}: ${msg}`
2754
+ };
2755
+ }
2756
+ }
2757
+ async function gatherWikiChecks(options) {
2758
+ const checks = [];
2759
+ const repoRoot = findNearestAlmanacDir(options.cwd);
2760
+ if (repoRoot === null) {
2761
+ checks.push({
2762
+ status: "info",
2763
+ key: "wiki.none",
2764
+ message: "No wiki in current directory",
2765
+ fix: "run: almanac bootstrap (to create one in this repo)"
2766
+ });
2767
+ return checks;
2768
+ }
2769
+ checks.push({
2770
+ status: "info",
2771
+ key: "wiki.repo",
2772
+ message: `repo: ${repoRoot}`
2773
+ });
2774
+ const entry = await findEntry({ path: repoRoot });
2775
+ if (entry !== null) {
2776
+ checks.push({
2777
+ status: "ok",
2778
+ key: "wiki.registered",
2779
+ message: `registered as '${entry.name}'`
2780
+ });
2781
+ } else {
2782
+ checks.push({
2783
+ status: "info",
2784
+ key: "wiki.registered",
2785
+ message: "not yet registered (will register on first command)"
2786
+ });
2787
+ }
2788
+ const almanacDir = path4.join(repoRoot, ".almanac");
2789
+ const dbPath = path4.join(almanacDir, "index.db");
2790
+ let pageCount = null;
2791
+ let topicCount = null;
2792
+ if (existsSync12(dbPath)) {
2793
+ try {
2794
+ const db = openIndex(dbPath);
2795
+ try {
2796
+ pageCount = countRows(db, "pages");
2797
+ topicCount = countRows(db, "topics");
2798
+ } finally {
2799
+ db.close();
2800
+ }
2801
+ } catch {
2802
+ pageCount = null;
2803
+ }
2804
+ }
2805
+ if (pageCount !== null) {
2806
+ checks.push({
2807
+ status: "info",
2808
+ key: "wiki.pages",
2809
+ message: `pages: ${pageCount}`
2810
+ });
2811
+ }
2812
+ if (topicCount !== null) {
2813
+ checks.push({
2814
+ status: "info",
2815
+ key: "wiki.topics",
2816
+ message: `topics: ${topicCount}`
2817
+ });
2818
+ }
2819
+ checks.push(describeIndexFreshness(dbPath));
2820
+ checks.push(describeLastCapture(almanacDir, options.now));
2821
+ const healthFn = options.runHealthFn ?? runHealth;
2822
+ try {
2823
+ const healthRes = await healthFn({
2824
+ cwd: repoRoot,
2825
+ json: true
2826
+ });
2827
+ const problems = countHealthProblems(healthRes.stdout);
2828
+ if (problems === 0) {
2829
+ checks.push({
2830
+ status: "ok",
2831
+ key: "wiki.health",
2832
+ message: "almanac health reports 0 problems"
2833
+ });
2834
+ } else {
2835
+ checks.push({
2836
+ status: "problem",
2837
+ key: "wiki.health",
2838
+ message: `almanac health reports ${problems} problem${problems === 1 ? "" : "s"}`,
2839
+ fix: "run: almanac health"
2840
+ });
2841
+ }
2842
+ } catch (err) {
2843
+ const msg = err instanceof Error ? err.message : String(err);
2844
+ checks.push({
2845
+ status: "info",
2846
+ key: "wiki.health",
2847
+ message: `could not run almanac health: ${msg}`
2848
+ });
2849
+ }
2850
+ return checks;
2851
+ }
2852
+ function countRows(db, table) {
2853
+ const row = db.prepare(`SELECT COUNT(*) AS n FROM ${table}`).get();
2854
+ return row?.n ?? 0;
2855
+ }
2856
+ function describeIndexFreshness(dbPath) {
2857
+ if (!existsSync12(dbPath)) {
2858
+ return {
2859
+ status: "info",
2860
+ key: "wiki.index",
2861
+ message: "index: not built yet (run any query command)"
2862
+ };
2863
+ }
2864
+ try {
2865
+ const dbMtime = statSync3(dbPath).mtimeMs;
2866
+ const age = Date.now() - dbMtime;
2867
+ return {
2868
+ status: "info",
2869
+ key: "wiki.index",
2870
+ message: `index: rebuilt ${formatDuration(age)} ago`
2871
+ };
2872
+ } catch {
2873
+ return {
2874
+ status: "info",
2875
+ key: "wiki.index",
2876
+ message: "index: present"
2877
+ };
2878
+ }
2879
+ }
2880
+ function describeLastCapture(almanacDir, nowFn) {
2881
+ if (!existsSync12(almanacDir)) {
2882
+ return {
2883
+ status: "info",
2884
+ key: "wiki.capture",
2885
+ message: "last capture: never"
2886
+ };
2887
+ }
2888
+ let entries;
2889
+ try {
2890
+ entries = readdirSync(almanacDir);
2891
+ } catch {
2892
+ return {
2893
+ status: "info",
2894
+ key: "wiki.capture",
2895
+ message: "last capture: unknown"
2896
+ };
2897
+ }
2898
+ const captures = entries.filter((e) => e.startsWith(".capture-") && e.endsWith(".log")).map((e) => {
2899
+ try {
2900
+ return {
2901
+ name: e,
2902
+ mtime: statSync3(path4.join(almanacDir, e)).mtimeMs
2903
+ };
2904
+ } catch {
2905
+ return null;
2906
+ }
2907
+ }).filter((e) => e !== null);
2908
+ if (captures.length === 0) {
2909
+ return {
2910
+ status: "info",
2911
+ key: "wiki.capture",
2912
+ message: "last capture: never"
2913
+ };
2914
+ }
2915
+ captures.sort((a, b) => b.mtime - a.mtime);
2916
+ const latest = captures[0];
2917
+ const now = (nowFn?.() ?? /* @__PURE__ */ new Date()).getTime();
2918
+ const age = now - latest.mtime;
2919
+ return {
2920
+ status: "info",
2921
+ key: "wiki.capture",
2922
+ message: `last capture: ${formatDuration(age)} ago (${latest.name})`
2923
+ };
2924
+ }
2925
+ var req = createRequire3(import.meta.url);
2926
+ function countHealthProblems(jsonStdout) {
2927
+ try {
2928
+ const report = JSON.parse(jsonStdout);
2929
+ let total = 0;
2930
+ for (const arr of Object.values(report)) {
2931
+ if (Array.isArray(arr)) total += arr.length;
2932
+ }
2933
+ return total;
2934
+ } catch {
2935
+ return 0;
2936
+ }
2937
+ }
2938
+ function detectInstallPath() {
2939
+ try {
2940
+ const here = fileURLToPath4(import.meta.url);
2941
+ let dir = path4.dirname(here);
2942
+ for (let i = 0; i < 5; i++) {
2943
+ const pkgPath = path4.join(dir, "package.json");
2944
+ if (existsSync12(pkgPath)) {
2945
+ try {
2946
+ const raw = readFileSync(pkgPath, "utf-8");
2947
+ const pkg = JSON.parse(raw);
2948
+ if (pkg.name === "codealmanac") return dir;
2949
+ } catch {
2950
+ }
2951
+ }
2952
+ const parent = path4.dirname(dir);
2953
+ if (parent === dir) break;
2954
+ dir = parent;
2955
+ }
2956
+ return null;
2957
+ } catch {
2958
+ return null;
2959
+ }
2960
+ }
2961
+ function probeBetterSqlite3() {
2962
+ try {
2963
+ const Database2 = req("better-sqlite3");
2964
+ const db = new Database2(":memory:");
2965
+ db.close();
2966
+ return { ok: true, summary: "native binding loads cleanly" };
2967
+ } catch (err) {
2968
+ const msg = err instanceof Error ? err.message : String(err);
2969
+ const firstLine = msg.split("\n")[0] ?? msg;
2970
+ return { ok: false, summary: firstLine };
2971
+ }
2972
+ }
2973
+ async function safeCheckAuth2(spawnCli) {
2974
+ try {
2975
+ return await checkClaudeAuth(spawnCli);
2976
+ } catch {
2977
+ return { loggedIn: false };
2978
+ }
2979
+ }
2980
+ function readPackageVersion() {
2981
+ try {
2982
+ const pkg = req("../../package.json");
2983
+ if (typeof pkg.version === "string" && pkg.version.length > 0) {
2984
+ return pkg.version;
2985
+ }
2986
+ } catch {
2987
+ }
2988
+ try {
2989
+ const pkg = req("../package.json");
2990
+ if (typeof pkg.version === "string" && pkg.version.length > 0) {
2991
+ return pkg.version;
2992
+ }
2993
+ } catch {
2994
+ }
2995
+ return null;
2996
+ }
2997
+ function formatReport2(report, options) {
2998
+ const color = options.stdout === void 0 && process.stdout.isTTY === true;
2999
+ const lines = [];
3000
+ lines.push(`codealmanac v${report.version}`);
3001
+ lines.push("");
3002
+ if (report.install.length > 0) {
3003
+ lines.push(color ? `${BOLD2}## Install${RST2}` : "## Install");
3004
+ for (const c of report.install) {
3005
+ lines.push(formatCheck(c, color));
3006
+ }
3007
+ lines.push("");
3008
+ }
3009
+ if (report.wiki.length > 0) {
3010
+ lines.push(color ? `${BOLD2}## Current wiki${RST2}` : "## Current wiki");
3011
+ for (const c of report.wiki) {
3012
+ lines.push(formatCheck(c, color));
3013
+ }
3014
+ lines.push("");
3015
+ }
3016
+ return `${lines.join("\n")}
3017
+ `;
3018
+ }
3019
+ function formatCheck(c, color) {
3020
+ const { icon, tint } = iconFor(c.status, color);
3021
+ const head = ` ${tint}${icon}${color ? RST2 : ""} ${c.message}`;
3022
+ if (c.fix === void 0) return head;
3023
+ const fixLine = color ? ` ${DIM2}${c.fix}${RST2}` : ` ${c.fix}`;
3024
+ return `${head}
3025
+ ${fixLine}`;
3026
+ }
3027
+ function iconFor(status, color) {
3028
+ switch (status) {
3029
+ case "ok":
3030
+ return { icon: "\u2713", tint: color ? GREEN : "" };
3031
+ case "problem":
3032
+ return { icon: "\u2717", tint: color ? RED : "" };
3033
+ case "info":
3034
+ return { icon: "\u25C7", tint: color ? BLUE2 : "" };
3035
+ }
3036
+ }
3037
+ function formatDuration(ms) {
3038
+ if (ms < 0) return "just now";
3039
+ const seconds = Math.floor(ms / 1e3);
3040
+ if (seconds < 60) return `${seconds}s`;
3041
+ const minutes = Math.floor(seconds / 60);
3042
+ if (minutes < 60) return `${minutes}m`;
3043
+ const hours = Math.floor(minutes / 60);
3044
+ if (hours < 48) return `${hours}h`;
3045
+ const days = Math.floor(hours / 24);
3046
+ return `${days}d`;
3047
+ }
3048
+
3049
+ // src/commands/list.ts
3050
+ import { existsSync as existsSync13 } from "fs";
3051
+ async function listWikis(options) {
3052
+ if (options.drop !== void 0) {
3053
+ return handleDrop(options.drop);
3054
+ }
3055
+ const entries = await readRegistry();
3056
+ const reachable = entries.filter((e) => isReachable(e));
3057
+ if (options.json === true) {
3058
+ return { stdout: `${JSON.stringify(reachable, null, 2)}
3059
+ `, exitCode: 0 };
3060
+ }
3061
+ return { stdout: formatPretty(reachable), exitCode: 0 };
3062
+ }
3063
+ async function handleDrop(name) {
3064
+ const removed = await dropEntry(name);
3065
+ if (removed === null) {
3066
+ return {
3067
+ stdout: `no registry entry named "${name}"
3068
+ `,
3069
+ exitCode: 1
3070
+ };
3071
+ }
3072
+ return {
3073
+ stdout: `removed "${removed.name}" (${removed.path})
3074
+ `,
3075
+ exitCode: 0
3076
+ };
3077
+ }
3078
+ function isReachable(entry) {
3079
+ if (entry.path.length === 0) return false;
3080
+ return existsSync13(entry.path);
3081
+ }
3082
+ function formatPretty(entries) {
3083
+ if (entries.length === 0) {
3084
+ return "no wikis registered. run `almanac bootstrap` in a repo to create one.\n";
3085
+ }
3086
+ const nameWidth = Math.min(
3087
+ 30,
3088
+ entries.reduce((w, e) => Math.max(w, e.name.length), 0)
3089
+ );
3090
+ const lines = [];
3091
+ for (const entry of entries) {
3092
+ const name = entry.name.padEnd(nameWidth);
3093
+ const desc = entry.description.length > 0 ? entry.description : "\u2014";
3094
+ lines.push(`${name} ${desc}`);
3095
+ lines.push(`${" ".repeat(nameWidth)} ${entry.path}`);
3096
+ }
3097
+ return `${lines.join("\n")}
3098
+ `;
3099
+ }
3100
+
3101
+ // src/commands/reindex.ts
3102
+ async function runReindex(options) {
3103
+ const repoRoot = await resolveWikiRoot({
3104
+ cwd: options.cwd,
3105
+ wiki: options.wiki
3106
+ });
3107
+ const result = await runIndexer({ repoRoot });
3108
+ const skipSuffix = result.filesSkipped > 0 ? `; ${result.filesSkipped} skipped` : "";
3109
+ const stdout = `reindexed: ${result.pagesIndexed} page${result.pagesIndexed === 1 ? "" : "s"} (${result.changed} updated, ${result.removed} removed${skipSuffix})
3110
+ `;
3111
+ return { result, stdout, exitCode: 0 };
3112
+ }
3113
+
3114
+ // src/commands/search.ts
3115
+ import { join as join9 } from "path";
3116
+ async function runSearch(options) {
3117
+ const repoRoot = await resolveWikiRoot({
3118
+ cwd: options.cwd,
3119
+ wiki: options.wiki
3120
+ });
3121
+ await ensureFreshIndex({ repoRoot });
3122
+ const dbPath = join9(repoRoot, ".almanac", "index.db");
3123
+ const db = openIndex(dbPath);
3124
+ try {
3125
+ const rows = executeQuery(db, options);
3126
+ const limited = options.limit !== void 0 && options.limit >= 0 ? rows.slice(0, options.limit) : rows;
3127
+ const stdout = formatResults(limited, options);
3128
+ const stderr = buildStderr(limited, options);
3129
+ return { stdout, stderr, exitCode: 0 };
3130
+ } finally {
3131
+ db.close();
3132
+ }
3133
+ }
3134
+ function executeQuery(db, options) {
3135
+ const whereClauses = [];
3136
+ const params = [];
3137
+ if (options.archived === true) {
3138
+ whereClauses.push("p.archived_at IS NOT NULL");
3139
+ } else if (options.includeArchive !== true) {
3140
+ whereClauses.push("p.archived_at IS NULL");
3141
+ }
3142
+ for (const rawTopic of options.topics) {
3143
+ const topicSlug = slugForTopic(rawTopic);
3144
+ if (topicSlug.length === 0) continue;
3145
+ whereClauses.push(
3146
+ "EXISTS (SELECT 1 FROM page_topics pt WHERE pt.page_slug = p.slug AND pt.topic_slug = ?)"
3147
+ );
3148
+ params.push(topicSlug);
3149
+ }
3150
+ if (options.mentions !== void 0 && options.mentions.length > 0) {
3151
+ const isDir = looksLikeDir(options.mentions);
3152
+ const norm = normalizePath(options.mentions, isDir);
3153
+ if (isDir) {
3154
+ const escaped = escapeGlobMeta(norm);
3155
+ whereClauses.push(
3156
+ `EXISTS (
3157
+ SELECT 1 FROM file_refs r
3158
+ WHERE r.page_slug = p.slug
3159
+ AND (r.path = ? OR r.path GLOB ?)
3160
+ )`
3161
+ );
3162
+ params.push(norm, `${escaped}*`);
3163
+ } else {
3164
+ const prefixes = parentFolderPrefixes(norm);
3165
+ if (prefixes.length === 0) {
3166
+ whereClauses.push(
3167
+ `EXISTS (
3168
+ SELECT 1 FROM file_refs r
3169
+ WHERE r.page_slug = p.slug AND r.path = ?
3170
+ )`
3171
+ );
3172
+ params.push(norm);
3173
+ } else {
3174
+ const placeholders = prefixes.map(() => "?").join(", ");
3175
+ whereClauses.push(
3176
+ `EXISTS (
3177
+ SELECT 1 FROM file_refs r
3178
+ WHERE r.page_slug = p.slug
3179
+ AND (
3180
+ r.path = ?
3181
+ OR (r.is_dir = 1 AND r.path IN (${placeholders}))
3182
+ )
3183
+ )`
3184
+ );
3185
+ params.push(norm, ...prefixes);
3186
+ }
3187
+ }
3188
+ }
3189
+ const now = Math.floor(Date.now() / 1e3);
3190
+ if (options.since !== void 0) {
3191
+ const seconds = parseDuration(options.since);
3192
+ whereClauses.push("p.updated_at >= ?");
3193
+ params.push(now - seconds);
2655
3194
  }
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
- `);
3195
+ if (options.stale !== void 0) {
3196
+ const seconds = parseDuration(options.stale);
3197
+ whereClauses.push("p.updated_at < ?");
3198
+ params.push(now - seconds);
2660
3199
  }
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}`);
3200
+ if (options.orphan === true) {
3201
+ whereClauses.push(
3202
+ "NOT EXISTS (SELECT 1 FROM page_topics pt WHERE pt.page_slug = p.slug)"
3203
+ );
2668
3204
  }
2669
- if (!existsSync12(srcRef)) {
2670
- throw new Error(`missing bundled guide: ${srcRef}`);
3205
+ let sql;
3206
+ if (options.query !== void 0 && options.query.trim().length > 0) {
3207
+ const ftsExpr = buildFtsQuery(options.query);
3208
+ sql = `
3209
+ SELECT p.slug, p.title, p.updated_at, p.archived_at, p.superseded_by
3210
+ FROM pages p
3211
+ JOIN fts_pages f ON f.slug = p.slug
3212
+ WHERE fts_pages MATCH ?
3213
+ ${whereClauses.length > 0 ? `AND ${whereClauses.join(" AND ")}` : ""}
3214
+ ORDER BY f.rank ASC, p.updated_at DESC, p.slug ASC
3215
+ `;
3216
+ params.unshift(ftsExpr);
3217
+ } else {
3218
+ sql = buildSql(whereClauses);
2671
3219
  }
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 };
3220
+ const rows = db.prepare(sql).all(...params);
3221
+ const topicStmt = db.prepare(
3222
+ "SELECT topic_slug FROM page_topics WHERE page_slug = ? ORDER BY topic_slug"
3223
+ );
3224
+ const out = rows.map((row) => ({
3225
+ slug: row.slug,
3226
+ title: row.title,
3227
+ updated_at: row.updated_at,
3228
+ archived_at: row.archived_at,
3229
+ superseded_by: row.superseded_by,
3230
+ topics: topicStmt.all(row.slug).map((t) => t.topic_slug)
3231
+ }));
3232
+ return out;
2683
3233
  }
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;
3234
+ function buildSql(whereClauses) {
3235
+ const where = whereClauses.length > 0 ? `WHERE ${whereClauses.join(" AND ")}` : "";
3236
+ return `
3237
+ SELECT p.slug, p.title, p.updated_at, p.archived_at, p.superseded_by
3238
+ FROM pages p
3239
+ ${where}
3240
+ ORDER BY p.updated_at DESC, p.slug ASC
3241
+ `;
2695
3242
  }
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");
3243
+ function buildFtsQuery(raw) {
3244
+ const trimmed = raw.trim();
3245
+ if (trimmed.length >= 2 && trimmed.startsWith('"') && trimmed.endsWith('"')) {
3246
+ const inner = trimmed.slice(1, -1).toLowerCase().replace(/[^a-z0-9]+/g, " ").trim();
3247
+ if (inner.length === 0) return '""';
3248
+ return `"${inner}"`;
2701
3249
  }
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;
3250
+ const tokens = trimmed.toLowerCase().split(/[^a-z0-9]+/).filter((t) => t.length > 0);
3251
+ if (tokens.length === 0) return '""';
3252
+ return tokens.map((t) => `${t}*`).join(" AND ");
2708
3253
  }
2709
- function hasImportLine(contents) {
2710
- const lines = contents.split(/\r?\n/).map((l) => l.trim());
2711
- return lines.includes(IMPORT_LINE);
3254
+ function slugForTopic(raw) {
3255
+ return raw.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
2712
3256
  }
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
- });
3257
+ function parentFolderPrefixes(filePath) {
3258
+ const out = [];
3259
+ let cursor = 0;
3260
+ while (true) {
3261
+ const next = filePath.indexOf("/", cursor);
3262
+ if (next === -1) break;
3263
+ out.push(filePath.slice(0, next + 1));
3264
+ cursor = next + 1;
3265
+ }
3266
+ return out;
2731
3267
  }
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}
3268
+ function escapeGlobMeta(s) {
3269
+ return s.replace(/[\*\?\[]/g, (ch) => `[${ch}]`);
3270
+ }
3271
+ function formatResults(rows, options) {
3272
+ if (options.json === true) {
3273
+ return `${JSON.stringify(rows, null, 2)}
3274
+ `;
3275
+ }
3276
+ if (rows.length === 0) return "";
3277
+ return `${rows.map((r) => r.slug).join("\n")}
2738
3278
  `;
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
3279
  }
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;
3280
+ function buildStderr(rows, options) {
3281
+ if (options.json === true) return "";
3282
+ if (rows.length === 0) {
3283
+ return "# 0 results\n";
2775
3284
  }
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 {
3285
+ if (options.limit !== void 0) return "";
3286
+ if (rows.length > 50) {
3287
+ return `almanac: ${rows.length} results \u2014 consider --limit or a narrower query
3288
+ `;
2782
3289
  }
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"));
3290
+ return "";
2789
3291
  }
2790
3292
 
2791
3293
  // src/commands/show.ts
2792
- import { readFile as readFile10 } from "fs/promises";
3294
+ import { readFile as readFile11 } from "fs/promises";
2793
3295
  import { join as join10 } from "path";
2794
3296
  async function runShow(options) {
2795
3297
  const repoRoot = await resolveWikiRoot({
@@ -2856,7 +3358,7 @@ async function fetchRecord(db, slug) {
2856
3358
  ).all(slug).map((r) => r.slug);
2857
3359
  let body = "";
2858
3360
  try {
2859
- body = stripFrontmatter(await readFile10(pageRow.file_path, "utf8"));
3361
+ body = stripFrontmatter(await readFile11(pageRow.file_path, "utf8"));
2860
3362
  } catch {
2861
3363
  }
2862
3364
  return {
@@ -2907,7 +3409,9 @@ function selectedFields(options) {
2907
3409
  }
2908
3410
  function formatRecord(rec, options) {
2909
3411
  if (options.raw === true) {
2910
- return rec.body;
3412
+ if (rec.body.length === 0) return "";
3413
+ return rec.body.endsWith("\n") ? rec.body : `${rec.body}
3414
+ `;
2911
3415
  }
2912
3416
  const fields = selectedFields(options);
2913
3417
  if (fields.length > 0) {
@@ -2975,7 +3479,7 @@ function labeledFields(rec, fields) {
2975
3479
  for (const f of fields) {
2976
3480
  parts.push(labeledSection(rec, f));
2977
3481
  }
2978
- return parts.join("\n") + (parts.length > 0 ? "" : "");
3482
+ return parts.join("\n");
2979
3483
  }
2980
3484
  function labeledSection(rec, field) {
2981
3485
  switch (field) {
@@ -3075,10 +3579,10 @@ function metadataHeader(rec) {
3075
3579
  }
3076
3580
  function stripFrontmatter(src) {
3077
3581
  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);
3582
+ const afterOpen = src.replace(/^---\r?\n/, "");
3583
+ const endMatch = afterOpen.match(/^---[ \t]*\r?\n/m);
3080
3584
  if (endMatch === null || endMatch.index === void 0) return src;
3081
- return rest.slice(endMatch.index + endMatch[0].length);
3585
+ return afterOpen.slice(endMatch.index + endMatch[0].length);
3082
3586
  }
3083
3587
  function firstParagraph(body) {
3084
3588
  let src = body.trimStart();
@@ -3101,10 +3605,10 @@ function collectSlugs(options) {
3101
3605
  }
3102
3606
 
3103
3607
  // src/topics/frontmatterRewrite.ts
3104
- import { readFile as readFile11, rename as rename4, writeFile as writeFile6 } from "fs/promises";
3608
+ import { readFile as readFile12, rename as rename4, writeFile as writeFile6 } from "fs/promises";
3105
3609
  import yaml3 from "js-yaml";
3106
3610
  async function rewritePageTopics(filePath, transform) {
3107
- const raw = await readFile11(filePath, "utf8");
3611
+ const raw = await readFile12(filePath, "utf8");
3108
3612
  const { before, after, output, changed } = applyTopicsTransform(
3109
3613
  raw,
3110
3614
  transform
@@ -3482,7 +3986,7 @@ async function runUntag(options) {
3482
3986
  }
3483
3987
 
3484
3988
  // src/commands/topics.ts
3485
- import { readFile as readFile12 } from "fs/promises";
3989
+ import { readFile as readFile13 } from "fs/promises";
3486
3990
  import { join as join12 } from "path";
3487
3991
  import fg3 from "fast-glob";
3488
3992
  async function runTopicsList(options) {
@@ -3963,7 +4467,7 @@ async function rewriteTopicOnPages(repoRoot, transform) {
3963
4467
  });
3964
4468
  let changed = 0;
3965
4469
  for (const filePath of files) {
3966
- const raw = await readFile12(filePath, "utf8");
4470
+ const raw = await readFile13(filePath, "utf8");
3967
4471
  const applied = applyTopicsTransform(raw, transform);
3968
4472
  if (!applied.changed) continue;
3969
4473
  await rewritePageTopics(filePath, transform);
@@ -3973,18 +4477,18 @@ async function rewriteTopicOnPages(repoRoot, transform) {
3973
4477
  }
3974
4478
 
3975
4479
  // 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";
4480
+ import { existsSync as existsSync14 } from "fs";
4481
+ import { readFile as readFile14, rm, writeFile as writeFile7 } from "fs/promises";
4482
+ import { homedir as homedir6 } from "os";
4483
+ import path5 from "path";
4484
+ var BLUE3 = "\x1B[38;5;75m";
4485
+ var DIM3 = "\x1B[2m";
4486
+ var RST3 = "\x1B[0m";
3983
4487
  async function runUninstall(options = {}) {
3984
4488
  const out = options.stdout ?? process.stdout;
3985
4489
  const isTTY = options.isTTY ?? process.stdin.isTTY === true;
3986
4490
  const interactive = isTTY && options.yes !== true;
3987
- const claudeDir = options.claudeDir ?? path4.join(homedir5(), ".claude");
4491
+ const claudeDir = options.claudeDir ?? path5.join(homedir6(), ".claude");
3988
4492
  out.write("\n");
3989
4493
  let removeHook = true;
3990
4494
  if (options.keepHook === true) {
@@ -4004,10 +4508,10 @@ async function runUninstall(options = {}) {
4004
4508
  if (res.exitCode !== 0) {
4005
4509
  return { stdout: "", stderr: res.stderr, exitCode: res.exitCode };
4006
4510
  }
4007
- out.write(` ${BLUE2}\u25C7${RST2} ${res.stdout.trim()}
4511
+ out.write(` ${BLUE3}\u25C7${RST3} ${res.stdout.trim()}
4008
4512
  `);
4009
4513
  } else {
4010
- out.write(` ${DIM2}\u25CB Hook kept${RST2}
4514
+ out.write(` ${DIM3}\u25CB Hook kept${RST3}
4011
4515
  `);
4012
4516
  }
4013
4517
  let removeGuides = true;
@@ -4024,38 +4528,38 @@ async function runUninstall(options = {}) {
4024
4528
  const summary = await removeGuideFiles(claudeDir);
4025
4529
  if (summary.anyChanges) {
4026
4530
  out.write(
4027
- ` ${BLUE2}\u25C7${RST2} Guides removed (${summary.filesTouched.join(", ")})
4531
+ ` ${BLUE3}\u25C7${RST3} Guides removed (${summary.filesTouched.join(", ")})
4028
4532
  `
4029
4533
  );
4030
4534
  } else {
4031
- out.write(` ${DIM2}\u25CB Guides not installed${RST2}
4535
+ out.write(` ${DIM3}\u25CB Guides not installed${RST3}
4032
4536
  `);
4033
4537
  }
4034
4538
  } else {
4035
- out.write(` ${DIM2}\u25CB Guides kept${RST2}
4539
+ out.write(` ${DIM3}\u25CB Guides kept${RST3}
4036
4540
  `);
4037
4541
  }
4038
4542
  out.write(`
4039
- ${BLUE2}\u25C7${RST2} ${BLUE2}Uninstall complete${RST2}
4543
+ ${BLUE3}\u25C7${RST3} ${BLUE3}Uninstall complete${RST3}
4040
4544
 
4041
4545
  `);
4042
4546
  return { stdout: "", stderr: "", exitCode: 0 };
4043
4547
  }
4044
4548
  async function removeGuideFiles(claudeDir) {
4045
4549
  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)) {
4550
+ const mini = path5.join(claudeDir, "codealmanac.md");
4551
+ const ref = path5.join(claudeDir, "codealmanac-reference.md");
4552
+ const claudeMd = path5.join(claudeDir, "CLAUDE.md");
4553
+ if (existsSync14(mini)) {
4050
4554
  await rm(mini, { force: true });
4051
4555
  touched.push("codealmanac.md");
4052
4556
  }
4053
- if (existsSync13(ref)) {
4557
+ if (existsSync14(ref)) {
4054
4558
  await rm(ref, { force: true });
4055
4559
  touched.push("codealmanac-reference.md");
4056
4560
  }
4057
- if (existsSync13(claudeMd)) {
4058
- const existing = await readFile13(claudeMd, "utf8");
4561
+ if (existsSync14(claudeMd)) {
4562
+ const existing = await readFile14(claudeMd, "utf8");
4059
4563
  const { changed, body } = removeImportLine(existing);
4060
4564
  if (changed) {
4061
4565
  if (body.trim().length === 0) {
@@ -4087,7 +4591,7 @@ function removeImportLine(contents) {
4087
4591
  function confirm2(out, question, defaultYes) {
4088
4592
  return new Promise((resolve2) => {
4089
4593
  const hint = defaultYes ? "[Y/n]" : "[y/N]";
4090
- out.write(` ${BLUE2}\u25C6${RST2} ${question} ${DIM2}${hint}${RST2} `);
4594
+ out.write(` ${BLUE3}\u25C6${RST3} ${question} ${DIM3}${hint}${RST3} `);
4091
4595
  let buf = "";
4092
4596
  const onData = (chunk) => {
4093
4597
  buf += chunk.toString("utf8");
@@ -4105,13 +4609,13 @@ function confirm2(out, question, defaultYes) {
4105
4609
  }
4106
4610
 
4107
4611
  // src/registry/autoregister.ts
4108
- import { existsSync as existsSync14 } from "fs";
4612
+ import { existsSync as existsSync15 } from "fs";
4109
4613
  import { basename as basename5 } from "path";
4110
4614
  async function autoRegisterIfNeeded(cwd) {
4111
4615
  try {
4112
4616
  const repoRoot = findNearestAlmanacDir(cwd);
4113
4617
  if (repoRoot === null) return null;
4114
- if (!existsSync14(repoRoot)) return null;
4618
+ if (!existsSync15(repoRoot)) return null;
4115
4619
  const entries = await readRegistry();
4116
4620
  const existing = entries.find((e) => samePath(e.path, repoRoot));
4117
4621
  if (existing !== void 0) return existing;
@@ -4161,13 +4665,16 @@ async function run(argv) {
4161
4665
  const program = new Command();
4162
4666
  program.name(programName).description(
4163
4667
  "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;
4668
+ ).version(readPackageVersion2(), "-v, --version", "print version");
4669
+ if (programName === "codealmanac") {
4670
+ const setupInvocation = tryParseSetupShortcut(argv.slice(2));
4671
+ if (setupInvocation !== null) {
4672
+ const result = await runSetup(setupInvocation);
4673
+ if (result.stderr.length > 0) process.stderr.write(result.stderr);
4674
+ if (result.stdout.length > 0) process.stdout.write(result.stdout);
4675
+ if (result.exitCode !== 0) process.exitCode = result.exitCode;
4676
+ return;
4677
+ }
4171
4678
  }
4172
4679
  program.command("search [query]").description("find pages by text, topic, file mentions, freshness").option(
4173
4680
  "--topic <name...>",
@@ -4176,7 +4683,7 @@ async function run(argv) {
4176
4683
  []
4177
4684
  ).option(
4178
4685
  "--mentions <path>",
4179
- "pages referencing this file or folder (trailing / = folder)"
4686
+ "pages referencing this path; matches exact file, trailing-slash folders, and any file under a folder prefix"
4180
4687
  ).option(
4181
4688
  "--since <duration>",
4182
4689
  "updated within duration, by file mtime (e.g. 2w, 30d)"
@@ -4452,7 +4959,24 @@ async function run(argv) {
4452
4959
  emit(result);
4453
4960
  }
4454
4961
  );
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(
4962
+ 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(
4963
+ async (opts) => {
4964
+ const result = await runDoctor({
4965
+ cwd: process.cwd(),
4966
+ json: opts.json,
4967
+ installOnly: opts.installOnly,
4968
+ wikiOnly: opts.wikiOnly
4969
+ });
4970
+ emit(result);
4971
+ }
4972
+ );
4973
+ program.command("uninstall").description("remove the hook + guides + import line").option("-y, --yes", "skip confirmations; remove everything").option(
4974
+ "--keep-hook",
4975
+ "don't remove the SessionEnd hook (guides still prompted unless --yes)"
4976
+ ).option(
4977
+ "--keep-guides",
4978
+ "don't remove the guides or CLAUDE.md import (hook still prompted unless --yes)"
4979
+ ).action(
4456
4980
  async (opts) => {
4457
4981
  const result = await runUninstall({
4458
4982
  yes: opts.yes,
@@ -4480,7 +5004,7 @@ var HELP_GROUPS = [
4480
5004
  },
4481
5005
  {
4482
5006
  title: "Setup",
4483
- commands: ["setup", "uninstall"]
5007
+ commands: ["setup", "uninstall", "doctor"]
4484
5008
  }
4485
5009
  ];
4486
5010
  function configureGroupedHelp(program) {
@@ -4581,9 +5105,9 @@ function renderDefault(cmd, helper) {
4581
5105
  }
4582
5106
  return lines.join("\n");
4583
5107
  }
4584
- function readPackageVersion() {
5108
+ function readPackageVersion2() {
4585
5109
  try {
4586
- const require2 = createRequire3(import.meta.url);
5110
+ const require2 = createRequire4(import.meta.url);
4587
5111
  const pkg = require2("../package.json");
4588
5112
  if (typeof pkg.version === "string" && pkg.version.length > 0) {
4589
5113
  return pkg.version;
@@ -4615,6 +5139,26 @@ async function readStdin() {
4615
5139
  }
4616
5140
  return Buffer.concat(chunks).toString("utf8");
4617
5141
  }
5142
+ function tryParseSetupShortcut(args) {
5143
+ if (args.length === 0) return {};
5144
+ const opts = {};
5145
+ for (const arg of args) {
5146
+ if (arg === "--yes" || arg === "-y") {
5147
+ opts.yes = true;
5148
+ continue;
5149
+ }
5150
+ if (arg === "--skip-hook") {
5151
+ opts.skipHook = true;
5152
+ continue;
5153
+ }
5154
+ if (arg === "--skip-guides") {
5155
+ opts.skipGuides = true;
5156
+ continue;
5157
+ }
5158
+ return null;
5159
+ }
5160
+ return opts;
5161
+ }
4618
5162
 
4619
5163
  // bin/codealmanac.ts
4620
5164
  run(process.argv).catch((err) => {