codealmanac 0.1.2 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +53 -40
- package/dist/codealmanac.js +1315 -787
- package/dist/codealmanac.js.map +1 -1
- package/guides/mini.md +50 -14
- package/guides/reference.md +178 -91
- package/package.json +1 -1
package/dist/codealmanac.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// src/cli.ts
|
|
4
|
-
import { createRequire as
|
|
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
|
|
243
|
+
const path6 = getRegistryPath();
|
|
244
244
|
let raw;
|
|
245
245
|
try {
|
|
246
|
-
raw = await readFile2(
|
|
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 ${
|
|
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 ${
|
|
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
|
|
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 (
|
|
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:
|
|
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
|
|
290
|
-
await mkdir(dirname3(
|
|
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 = `${
|
|
293
|
+
const tmpPath = `${path6}.tmp`;
|
|
294
294
|
await writeFile(tmpPath, body, "utf8");
|
|
295
|
-
await rename(tmpPath,
|
|
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
|
|
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(
|
|
377
|
-
existing = await readFile3(
|
|
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(
|
|
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
|
|
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(
|
|
991
|
-
const content = await readFile4(
|
|
990
|
+
async function readHead(path6, bytes) {
|
|
991
|
+
const content = await readFile4(path6, "utf8");
|
|
992
992
|
return content.length > bytes ? content.slice(0, bytes) : content;
|
|
993
993
|
}
|
|
994
994
|
async function snapshotPages(pagesDir) {
|
|
@@ -1060,278 +1060,174 @@ function closeStream2(stream) {
|
|
|
1060
1060
|
});
|
|
1061
1061
|
}
|
|
1062
1062
|
|
|
1063
|
-
// src/commands/
|
|
1063
|
+
// src/commands/doctor.ts
|
|
1064
|
+
import { existsSync as existsSync12, readdirSync, statSync as statSync3 } from "fs";
|
|
1065
|
+
import { readFile as readFile10 } from "fs/promises";
|
|
1066
|
+
import { createRequire as createRequire3 } from "module";
|
|
1067
|
+
import { homedir as homedir5 } from "os";
|
|
1068
|
+
import path4 from "path";
|
|
1069
|
+
|
|
1070
|
+
// src/indexer/schema.ts
|
|
1071
|
+
import Database from "better-sqlite3";
|
|
1072
|
+
var SCHEMA_DDL = `
|
|
1073
|
+
CREATE TABLE IF NOT EXISTS pages (
|
|
1074
|
+
slug TEXT PRIMARY KEY,
|
|
1075
|
+
title TEXT,
|
|
1076
|
+
file_path TEXT NOT NULL,
|
|
1077
|
+
content_hash TEXT NOT NULL,
|
|
1078
|
+
updated_at INTEGER NOT NULL,
|
|
1079
|
+
archived_at INTEGER,
|
|
1080
|
+
superseded_by TEXT
|
|
1081
|
+
);
|
|
1082
|
+
|
|
1083
|
+
CREATE TABLE IF NOT EXISTS topics (
|
|
1084
|
+
slug TEXT PRIMARY KEY,
|
|
1085
|
+
title TEXT,
|
|
1086
|
+
description TEXT
|
|
1087
|
+
);
|
|
1088
|
+
|
|
1089
|
+
CREATE TABLE IF NOT EXISTS page_topics (
|
|
1090
|
+
page_slug TEXT NOT NULL REFERENCES pages(slug) ON DELETE CASCADE,
|
|
1091
|
+
topic_slug TEXT NOT NULL,
|
|
1092
|
+
PRIMARY KEY (page_slug, topic_slug)
|
|
1093
|
+
);
|
|
1094
|
+
|
|
1095
|
+
CREATE TABLE IF NOT EXISTS topic_parents (
|
|
1096
|
+
child_slug TEXT NOT NULL,
|
|
1097
|
+
parent_slug TEXT NOT NULL,
|
|
1098
|
+
PRIMARY KEY (child_slug, parent_slug),
|
|
1099
|
+
CHECK (child_slug != parent_slug)
|
|
1100
|
+
);
|
|
1101
|
+
|
|
1102
|
+
CREATE TABLE IF NOT EXISTS file_refs (
|
|
1103
|
+
page_slug TEXT NOT NULL REFERENCES pages(slug) ON DELETE CASCADE,
|
|
1104
|
+
path TEXT NOT NULL,
|
|
1105
|
+
original_path TEXT NOT NULL,
|
|
1106
|
+
is_dir INTEGER NOT NULL,
|
|
1107
|
+
PRIMARY KEY (page_slug, path)
|
|
1108
|
+
);
|
|
1109
|
+
CREATE INDEX IF NOT EXISTS idx_file_refs_path ON file_refs(path);
|
|
1110
|
+
|
|
1111
|
+
CREATE TABLE IF NOT EXISTS wikilinks (
|
|
1112
|
+
source_slug TEXT NOT NULL REFERENCES pages(slug) ON DELETE CASCADE,
|
|
1113
|
+
target_slug TEXT NOT NULL,
|
|
1114
|
+
PRIMARY KEY (source_slug, target_slug)
|
|
1115
|
+
);
|
|
1116
|
+
|
|
1117
|
+
CREATE TABLE IF NOT EXISTS cross_wiki_links (
|
|
1118
|
+
source_slug TEXT NOT NULL REFERENCES pages(slug) ON DELETE CASCADE,
|
|
1119
|
+
target_wiki TEXT NOT NULL,
|
|
1120
|
+
target_slug TEXT NOT NULL,
|
|
1121
|
+
PRIMARY KEY (source_slug, target_wiki, target_slug)
|
|
1122
|
+
);
|
|
1123
|
+
|
|
1124
|
+
-- NOTE: virtual FTS5 table \u2014 ON DELETE CASCADE from pages does NOT apply.
|
|
1125
|
+
-- The indexer must explicitly DELETE FROM fts_pages whenever it removes
|
|
1126
|
+
-- or replaces a page row, or we leak orphaned FTS rows.
|
|
1127
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS fts_pages USING fts5(slug, title, content);
|
|
1128
|
+
`;
|
|
1129
|
+
var SCHEMA_VERSION = 2;
|
|
1130
|
+
function openIndex(dbPath) {
|
|
1131
|
+
const db = new Database(dbPath);
|
|
1132
|
+
const mode = db.pragma("journal_mode", { simple: true });
|
|
1133
|
+
if (typeof mode !== "string" || mode.toLowerCase() !== "wal") {
|
|
1134
|
+
db.pragma("journal_mode = WAL");
|
|
1135
|
+
}
|
|
1136
|
+
db.pragma("foreign_keys = ON");
|
|
1137
|
+
const rawVersion = db.pragma("user_version", { simple: true });
|
|
1138
|
+
const currentVersion = typeof rawVersion === "number" ? rawVersion : 0;
|
|
1139
|
+
if (currentVersion < SCHEMA_VERSION) {
|
|
1140
|
+
db.exec("DROP TABLE IF EXISTS file_refs");
|
|
1141
|
+
try {
|
|
1142
|
+
db.exec("UPDATE pages SET content_hash = ''");
|
|
1143
|
+
} catch {
|
|
1144
|
+
}
|
|
1145
|
+
db.pragma(`user_version = ${SCHEMA_VERSION}`);
|
|
1146
|
+
}
|
|
1147
|
+
db.exec(SCHEMA_DDL);
|
|
1148
|
+
return db;
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
// src/commands/health.ts
|
|
1152
|
+
import { existsSync as existsSync9 } from "fs";
|
|
1153
|
+
import { readFile as readFile7 } from "fs/promises";
|
|
1154
|
+
import { basename as basename4, join as join8 } from "path";
|
|
1155
|
+
import fg2 from "fast-glob";
|
|
1156
|
+
|
|
1157
|
+
// src/indexer/duration.ts
|
|
1158
|
+
function parseDuration(input) {
|
|
1159
|
+
const trimmed = input.trim();
|
|
1160
|
+
const m = trimmed.match(/^(\d+)([mhdw])$/);
|
|
1161
|
+
if (m === null) {
|
|
1162
|
+
throw new Error(
|
|
1163
|
+
`invalid duration "${input}" (expected Nw, Nd, Nh, or Nm \u2014 e.g. 2w, 30d)`
|
|
1164
|
+
);
|
|
1165
|
+
}
|
|
1166
|
+
const n = Number.parseInt(m[1] ?? "0", 10);
|
|
1167
|
+
const unit = m[2];
|
|
1168
|
+
switch (unit) {
|
|
1169
|
+
case "m":
|
|
1170
|
+
return n * 60;
|
|
1171
|
+
case "h":
|
|
1172
|
+
return n * 60 * 60;
|
|
1173
|
+
case "d":
|
|
1174
|
+
return n * 60 * 60 * 24;
|
|
1175
|
+
case "w":
|
|
1176
|
+
return n * 60 * 60 * 24 * 7;
|
|
1177
|
+
default:
|
|
1178
|
+
throw new Error(`invalid duration unit "${unit ?? ""}"`);
|
|
1179
|
+
}
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
// src/indexer/index.ts
|
|
1183
|
+
import { createHash as createHash2 } from "crypto";
|
|
1184
|
+
import { existsSync as existsSync7, statSync as statSync2 } from "fs";
|
|
1185
|
+
import { readFile as readFile6, utimes } from "fs/promises";
|
|
1186
|
+
import { basename as basename3, join as join6, relative as relative3 } from "path";
|
|
1187
|
+
import fg from "fast-glob";
|
|
1188
|
+
|
|
1189
|
+
// src/topics/yaml.ts
|
|
1064
1190
|
import { existsSync as existsSync6 } from "fs";
|
|
1065
1191
|
import { mkdir as mkdir3, readFile as readFile5, rename as rename2, writeFile as writeFile3 } from "fs/promises";
|
|
1066
|
-
import {
|
|
1067
|
-
import
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
const script = resolveHookScriptPath(options);
|
|
1072
|
-
if (!script.ok) {
|
|
1073
|
-
return { stdout: "", stderr: `almanac: ${script.error}
|
|
1074
|
-
`, exitCode: 1 };
|
|
1192
|
+
import { dirname as dirname4 } from "path";
|
|
1193
|
+
import yaml2 from "js-yaml";
|
|
1194
|
+
async function loadTopicsFile(path6) {
|
|
1195
|
+
if (!existsSync6(path6)) {
|
|
1196
|
+
return { topics: [] };
|
|
1075
1197
|
}
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
const unrelated = foreignEntries.filter(
|
|
1085
|
-
(e) => !e.command.endsWith("almanac-capture.sh")
|
|
1086
|
-
);
|
|
1087
|
-
if (unrelated.length > 0) {
|
|
1088
|
-
const existingStr = unrelated.map((e) => ` - ${e.command}`).join("\n");
|
|
1089
|
-
return {
|
|
1090
|
-
stdout: "",
|
|
1091
|
-
stderr: `almanac: SessionEnd hook already has a foreign entry:
|
|
1092
|
-
${existingStr}
|
|
1093
|
-
Remove it manually from ${settingsPath} if you want almanac to manage the hook.
|
|
1094
|
-
`,
|
|
1095
|
-
exitCode: 1
|
|
1096
|
-
};
|
|
1198
|
+
let raw;
|
|
1199
|
+
try {
|
|
1200
|
+
raw = await readFile5(path6, "utf8");
|
|
1201
|
+
} catch (err) {
|
|
1202
|
+
if (isNodeError2(err) && err.code === "ENOENT") {
|
|
1203
|
+
return { topics: [] };
|
|
1204
|
+
}
|
|
1205
|
+
throw err;
|
|
1097
1206
|
}
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
`,
|
|
1102
|
-
stderr: "",
|
|
1103
|
-
exitCode: 0
|
|
1104
|
-
};
|
|
1207
|
+
const trimmed = raw.trim();
|
|
1208
|
+
if (trimmed.length === 0) {
|
|
1209
|
+
return { topics: [] };
|
|
1105
1210
|
}
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
}
|
|
1112
|
-
];
|
|
1113
|
-
settings.hooks = { ...settings.hooks ?? {}, SessionEnd: newEntries };
|
|
1114
|
-
await writeSettings(settingsPath, settings);
|
|
1115
|
-
return {
|
|
1116
|
-
stdout: `almanac: SessionEnd hook installed
|
|
1117
|
-
script: ${script.path}
|
|
1118
|
-
settings: ${settingsPath}
|
|
1119
|
-
`,
|
|
1120
|
-
stderr: "",
|
|
1121
|
-
exitCode: 0
|
|
1122
|
-
};
|
|
1123
|
-
}
|
|
1124
|
-
async function runHookUninstall(options = {}) {
|
|
1125
|
-
const settingsPath = resolveSettingsPath(options);
|
|
1126
|
-
if (!existsSync6(settingsPath)) {
|
|
1127
|
-
return {
|
|
1128
|
-
stdout: `almanac: SessionEnd hook not installed (no settings file)
|
|
1129
|
-
`,
|
|
1130
|
-
stderr: "",
|
|
1131
|
-
exitCode: 0
|
|
1132
|
-
};
|
|
1211
|
+
let parsed;
|
|
1212
|
+
try {
|
|
1213
|
+
parsed = yaml2.load(raw);
|
|
1214
|
+
} catch (err) {
|
|
1215
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1216
|
+
throw new Error(`topics.yaml at ${path6} is not valid YAML: ${message}`);
|
|
1133
1217
|
}
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
const kept = existing.filter((e) => !e.command.endsWith("almanac-capture.sh"));
|
|
1137
|
-
const removed = existing.length - kept.length;
|
|
1138
|
-
if (removed === 0) {
|
|
1139
|
-
return {
|
|
1140
|
-
stdout: `almanac: SessionEnd hook not installed
|
|
1141
|
-
`,
|
|
1142
|
-
stderr: "",
|
|
1143
|
-
exitCode: 0
|
|
1144
|
-
};
|
|
1218
|
+
if (parsed === null || parsed === void 0) {
|
|
1219
|
+
return { topics: [] };
|
|
1145
1220
|
}
|
|
1146
|
-
if (
|
|
1147
|
-
|
|
1148
|
-
const { SessionEnd: _dropped, ...rest } = settings.hooks;
|
|
1149
|
-
void _dropped;
|
|
1150
|
-
settings.hooks = rest;
|
|
1151
|
-
} else {
|
|
1152
|
-
settings.hooks = { ...settings.hooks, SessionEnd: kept };
|
|
1153
|
-
}
|
|
1154
|
-
if (Object.keys(settings.hooks).length === 0) {
|
|
1155
|
-
delete settings.hooks;
|
|
1156
|
-
}
|
|
1221
|
+
if (typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
1222
|
+
throw new Error(`topics.yaml at ${path6} must be a mapping`);
|
|
1157
1223
|
}
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
stderr: "",
|
|
1163
|
-
exitCode: 0
|
|
1164
|
-
};
|
|
1165
|
-
}
|
|
1166
|
-
async function runHookStatus(options = {}) {
|
|
1167
|
-
const script = resolveHookScriptPath(options);
|
|
1168
|
-
const settingsPath = resolveSettingsPath(options);
|
|
1169
|
-
if (!existsSync6(settingsPath)) {
|
|
1170
|
-
return {
|
|
1171
|
-
stdout: `SessionEnd hook: not installed
|
|
1172
|
-
settings: ${settingsPath} (does not exist)
|
|
1173
|
-
` + (script.ok ? `script would be: ${script.path}
|
|
1174
|
-
` : ""),
|
|
1175
|
-
stderr: "",
|
|
1176
|
-
exitCode: 0
|
|
1177
|
-
};
|
|
1178
|
-
}
|
|
1179
|
-
const settings = await readSettings(settingsPath);
|
|
1180
|
-
const existing = settings.hooks?.SessionEnd ?? [];
|
|
1181
|
-
const ours = existing.find((e) => e.command.endsWith("almanac-capture.sh"));
|
|
1182
|
-
if (ours === void 0) {
|
|
1183
|
-
const foreign = existing.map((e) => ` - ${e.command}`).join("\n");
|
|
1184
|
-
return {
|
|
1185
|
-
stdout: `SessionEnd hook: not installed
|
|
1186
|
-
settings: ${settingsPath}
|
|
1187
|
-
` + (existing.length > 0 ? `(${existing.length} foreign entr${existing.length === 1 ? "y" : "ies"} present:
|
|
1188
|
-
${foreign})
|
|
1189
|
-
` : "") + (script.ok ? `script would be: ${script.path}
|
|
1190
|
-
` : ""),
|
|
1191
|
-
stderr: "",
|
|
1192
|
-
exitCode: 0
|
|
1193
|
-
};
|
|
1194
|
-
}
|
|
1195
|
-
return {
|
|
1196
|
-
stdout: `SessionEnd hook: installed
|
|
1197
|
-
script: ${ours.command}
|
|
1198
|
-
settings: ${settingsPath}
|
|
1199
|
-
`,
|
|
1200
|
-
stderr: "",
|
|
1201
|
-
exitCode: 0
|
|
1202
|
-
};
|
|
1203
|
-
}
|
|
1204
|
-
function resolveSettingsPath(options) {
|
|
1205
|
-
if (options.settingsPath !== void 0) return options.settingsPath;
|
|
1206
|
-
return path2.join(homedir3(), ".claude", "settings.json");
|
|
1207
|
-
}
|
|
1208
|
-
function resolveHookScriptPath(options) {
|
|
1209
|
-
if (options.hookScriptPath !== void 0) {
|
|
1210
|
-
return { ok: true, path: options.hookScriptPath };
|
|
1211
|
-
}
|
|
1212
|
-
const here = path2.dirname(fileURLToPath2(import.meta.url));
|
|
1213
|
-
const candidates = [
|
|
1214
|
-
// Bundled: `.../codealmanac/dist/codealmanac.js` → `../hooks/…`
|
|
1215
|
-
path2.resolve(here, "..", "hooks", "almanac-capture.sh"),
|
|
1216
|
-
// Source: `.../codealmanac/src/commands/hook.ts` → `../../hooks/…`
|
|
1217
|
-
path2.resolve(here, "..", "..", "hooks", "almanac-capture.sh"),
|
|
1218
|
-
// Defensive nested fallback.
|
|
1219
|
-
path2.resolve(here, "..", "..", "..", "hooks", "almanac-capture.sh")
|
|
1220
|
-
];
|
|
1221
|
-
for (const candidate of candidates) {
|
|
1222
|
-
if (existsSync6(candidate)) {
|
|
1223
|
-
return { ok: true, path: candidate };
|
|
1224
|
-
}
|
|
1225
|
-
}
|
|
1226
|
-
return {
|
|
1227
|
-
ok: false,
|
|
1228
|
-
error: `could not locate hooks/almanac-capture.sh. Tried:
|
|
1229
|
-
` + candidates.map((c) => ` - ${c}`).join("\n")
|
|
1230
|
-
};
|
|
1231
|
-
}
|
|
1232
|
-
async function readSettings(settingsPath) {
|
|
1233
|
-
if (!existsSync6(settingsPath)) return {};
|
|
1234
|
-
try {
|
|
1235
|
-
const raw = await readFile5(settingsPath, "utf8");
|
|
1236
|
-
if (raw.trim().length === 0) return {};
|
|
1237
|
-
const parsed = JSON.parse(raw);
|
|
1238
|
-
if (parsed === null || typeof parsed !== "object") return {};
|
|
1239
|
-
return parsed;
|
|
1240
|
-
} catch (err) {
|
|
1241
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
1242
|
-
throw new Error(`failed to read ${settingsPath}: ${msg}`);
|
|
1243
|
-
}
|
|
1244
|
-
}
|
|
1245
|
-
async function writeSettings(settingsPath, settings) {
|
|
1246
|
-
const dir = path2.dirname(settingsPath);
|
|
1247
|
-
await mkdir3(dir, { recursive: true });
|
|
1248
|
-
const tmp = `${settingsPath}.almanac-tmp-${process.pid}`;
|
|
1249
|
-
const body = `${JSON.stringify(settings, null, 2)}
|
|
1250
|
-
`;
|
|
1251
|
-
await writeFile3(tmp, body, "utf8");
|
|
1252
|
-
await rename2(tmp, settingsPath);
|
|
1253
|
-
}
|
|
1254
|
-
|
|
1255
|
-
// src/commands/health.ts
|
|
1256
|
-
import { existsSync as existsSync10 } from "fs";
|
|
1257
|
-
import { readFile as readFile8 } from "fs/promises";
|
|
1258
|
-
import { basename as basename4, join as join8 } from "path";
|
|
1259
|
-
import fg2 from "fast-glob";
|
|
1260
|
-
|
|
1261
|
-
// src/indexer/duration.ts
|
|
1262
|
-
function parseDuration(input) {
|
|
1263
|
-
const trimmed = input.trim();
|
|
1264
|
-
const m = trimmed.match(/^(\d+)([mhdw])$/);
|
|
1265
|
-
if (m === null) {
|
|
1266
|
-
throw new Error(
|
|
1267
|
-
`invalid duration "${input}" (expected Nw, Nd, Nh, or Nm \u2014 e.g. 2w, 30d)`
|
|
1268
|
-
);
|
|
1269
|
-
}
|
|
1270
|
-
const n = Number.parseInt(m[1] ?? "0", 10);
|
|
1271
|
-
const unit = m[2];
|
|
1272
|
-
switch (unit) {
|
|
1273
|
-
case "m":
|
|
1274
|
-
return n * 60;
|
|
1275
|
-
case "h":
|
|
1276
|
-
return n * 60 * 60;
|
|
1277
|
-
case "d":
|
|
1278
|
-
return n * 60 * 60 * 24;
|
|
1279
|
-
case "w":
|
|
1280
|
-
return n * 60 * 60 * 24 * 7;
|
|
1281
|
-
default:
|
|
1282
|
-
throw new Error(`invalid duration unit "${unit ?? ""}"`);
|
|
1283
|
-
}
|
|
1284
|
-
}
|
|
1285
|
-
|
|
1286
|
-
// src/indexer/index.ts
|
|
1287
|
-
import { createHash as createHash2 } from "crypto";
|
|
1288
|
-
import { existsSync as existsSync8, statSync as statSync2 } from "fs";
|
|
1289
|
-
import { readFile as readFile7, utimes } from "fs/promises";
|
|
1290
|
-
import { basename as basename3, join as join6, relative as relative3 } from "path";
|
|
1291
|
-
import fg from "fast-glob";
|
|
1292
|
-
|
|
1293
|
-
// src/topics/yaml.ts
|
|
1294
|
-
import { existsSync as existsSync7 } from "fs";
|
|
1295
|
-
import { mkdir as mkdir4, readFile as readFile6, rename as rename3, writeFile as writeFile4 } from "fs/promises";
|
|
1296
|
-
import { dirname as dirname4 } from "path";
|
|
1297
|
-
import yaml2 from "js-yaml";
|
|
1298
|
-
async function loadTopicsFile(path5) {
|
|
1299
|
-
if (!existsSync7(path5)) {
|
|
1300
|
-
return { topics: [] };
|
|
1301
|
-
}
|
|
1302
|
-
let raw;
|
|
1303
|
-
try {
|
|
1304
|
-
raw = await readFile6(path5, "utf8");
|
|
1305
|
-
} catch (err) {
|
|
1306
|
-
if (isNodeError2(err) && err.code === "ENOENT") {
|
|
1307
|
-
return { topics: [] };
|
|
1308
|
-
}
|
|
1309
|
-
throw err;
|
|
1310
|
-
}
|
|
1311
|
-
const trimmed = raw.trim();
|
|
1312
|
-
if (trimmed.length === 0) {
|
|
1313
|
-
return { topics: [] };
|
|
1314
|
-
}
|
|
1315
|
-
let parsed;
|
|
1316
|
-
try {
|
|
1317
|
-
parsed = yaml2.load(raw);
|
|
1318
|
-
} catch (err) {
|
|
1319
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
1320
|
-
throw new Error(`topics.yaml at ${path5} is not valid YAML: ${message}`);
|
|
1321
|
-
}
|
|
1322
|
-
if (parsed === null || parsed === void 0) {
|
|
1323
|
-
return { topics: [] };
|
|
1324
|
-
}
|
|
1325
|
-
if (typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
1326
|
-
throw new Error(`topics.yaml at ${path5} must be a mapping`);
|
|
1327
|
-
}
|
|
1328
|
-
const obj = parsed;
|
|
1329
|
-
const rawTopics = obj.topics;
|
|
1330
|
-
if (rawTopics === void 0 || rawTopics === null) {
|
|
1331
|
-
return { topics: [] };
|
|
1224
|
+
const obj = parsed;
|
|
1225
|
+
const rawTopics = obj.topics;
|
|
1226
|
+
if (rawTopics === void 0 || rawTopics === null) {
|
|
1227
|
+
return { topics: [] };
|
|
1332
1228
|
}
|
|
1333
1229
|
if (!Array.isArray(rawTopics)) {
|
|
1334
|
-
throw new Error(`topics.yaml at ${
|
|
1230
|
+
throw new Error(`topics.yaml at ${path6} \u2014 "topics" must be a list`);
|
|
1335
1231
|
}
|
|
1336
1232
|
const topics = [];
|
|
1337
1233
|
for (const item of rawTopics) {
|
|
@@ -1360,7 +1256,7 @@ async function loadTopicsFile(path5) {
|
|
|
1360
1256
|
}
|
|
1361
1257
|
return { topics };
|
|
1362
1258
|
}
|
|
1363
|
-
async function writeTopicsFile(
|
|
1259
|
+
async function writeTopicsFile(path6, file) {
|
|
1364
1260
|
const sorted = [...file.topics].sort((a, b) => a.slug.localeCompare(b.slug));
|
|
1365
1261
|
const doc = {
|
|
1366
1262
|
topics: sorted.map((t) => {
|
|
@@ -1385,13 +1281,13 @@ async function writeTopicsFile(path5, file) {
|
|
|
1385
1281
|
sortKeys: false
|
|
1386
1282
|
});
|
|
1387
1283
|
const content = `${header}${body}`;
|
|
1388
|
-
const tmpPath = `${
|
|
1389
|
-
const parent = dirname4(
|
|
1390
|
-
if (!
|
|
1391
|
-
await
|
|
1284
|
+
const tmpPath = `${path6}.tmp`;
|
|
1285
|
+
const parent = dirname4(path6);
|
|
1286
|
+
if (!existsSync6(parent)) {
|
|
1287
|
+
await mkdir3(parent, { recursive: true });
|
|
1392
1288
|
}
|
|
1393
|
-
await
|
|
1394
|
-
await
|
|
1289
|
+
await writeFile3(tmpPath, content, "utf8");
|
|
1290
|
+
await rename2(tmpPath, path6);
|
|
1395
1291
|
}
|
|
1396
1292
|
function findTopic(file, slug) {
|
|
1397
1293
|
for (const t of file.topics) {
|
|
@@ -1443,87 +1339,6 @@ function looksLikeDir(raw) {
|
|
|
1443
1339
|
return s.endsWith("/");
|
|
1444
1340
|
}
|
|
1445
1341
|
|
|
1446
|
-
// src/indexer/schema.ts
|
|
1447
|
-
import Database from "better-sqlite3";
|
|
1448
|
-
var SCHEMA_DDL = `
|
|
1449
|
-
CREATE TABLE IF NOT EXISTS pages (
|
|
1450
|
-
slug TEXT PRIMARY KEY,
|
|
1451
|
-
title TEXT,
|
|
1452
|
-
file_path TEXT NOT NULL,
|
|
1453
|
-
content_hash TEXT NOT NULL,
|
|
1454
|
-
updated_at INTEGER NOT NULL,
|
|
1455
|
-
archived_at INTEGER,
|
|
1456
|
-
superseded_by TEXT
|
|
1457
|
-
);
|
|
1458
|
-
|
|
1459
|
-
CREATE TABLE IF NOT EXISTS topics (
|
|
1460
|
-
slug TEXT PRIMARY KEY,
|
|
1461
|
-
title TEXT,
|
|
1462
|
-
description TEXT
|
|
1463
|
-
);
|
|
1464
|
-
|
|
1465
|
-
CREATE TABLE IF NOT EXISTS page_topics (
|
|
1466
|
-
page_slug TEXT NOT NULL REFERENCES pages(slug) ON DELETE CASCADE,
|
|
1467
|
-
topic_slug TEXT NOT NULL,
|
|
1468
|
-
PRIMARY KEY (page_slug, topic_slug)
|
|
1469
|
-
);
|
|
1470
|
-
|
|
1471
|
-
CREATE TABLE IF NOT EXISTS topic_parents (
|
|
1472
|
-
child_slug TEXT NOT NULL,
|
|
1473
|
-
parent_slug TEXT NOT NULL,
|
|
1474
|
-
PRIMARY KEY (child_slug, parent_slug),
|
|
1475
|
-
CHECK (child_slug != parent_slug)
|
|
1476
|
-
);
|
|
1477
|
-
|
|
1478
|
-
CREATE TABLE IF NOT EXISTS file_refs (
|
|
1479
|
-
page_slug TEXT NOT NULL REFERENCES pages(slug) ON DELETE CASCADE,
|
|
1480
|
-
path TEXT NOT NULL,
|
|
1481
|
-
original_path TEXT NOT NULL,
|
|
1482
|
-
is_dir INTEGER NOT NULL,
|
|
1483
|
-
PRIMARY KEY (page_slug, path)
|
|
1484
|
-
);
|
|
1485
|
-
CREATE INDEX IF NOT EXISTS idx_file_refs_path ON file_refs(path);
|
|
1486
|
-
|
|
1487
|
-
CREATE TABLE IF NOT EXISTS wikilinks (
|
|
1488
|
-
source_slug TEXT NOT NULL REFERENCES pages(slug) ON DELETE CASCADE,
|
|
1489
|
-
target_slug TEXT NOT NULL,
|
|
1490
|
-
PRIMARY KEY (source_slug, target_slug)
|
|
1491
|
-
);
|
|
1492
|
-
|
|
1493
|
-
CREATE TABLE IF NOT EXISTS cross_wiki_links (
|
|
1494
|
-
source_slug TEXT NOT NULL REFERENCES pages(slug) ON DELETE CASCADE,
|
|
1495
|
-
target_wiki TEXT NOT NULL,
|
|
1496
|
-
target_slug TEXT NOT NULL,
|
|
1497
|
-
PRIMARY KEY (source_slug, target_wiki, target_slug)
|
|
1498
|
-
);
|
|
1499
|
-
|
|
1500
|
-
-- NOTE: virtual FTS5 table \u2014 ON DELETE CASCADE from pages does NOT apply.
|
|
1501
|
-
-- The indexer must explicitly DELETE FROM fts_pages whenever it removes
|
|
1502
|
-
-- or replaces a page row, or we leak orphaned FTS rows.
|
|
1503
|
-
CREATE VIRTUAL TABLE IF NOT EXISTS fts_pages USING fts5(slug, title, content);
|
|
1504
|
-
`;
|
|
1505
|
-
var SCHEMA_VERSION = 2;
|
|
1506
|
-
function openIndex(dbPath) {
|
|
1507
|
-
const db = new Database(dbPath);
|
|
1508
|
-
const mode = db.pragma("journal_mode", { simple: true });
|
|
1509
|
-
if (typeof mode !== "string" || mode.toLowerCase() !== "wal") {
|
|
1510
|
-
db.pragma("journal_mode = WAL");
|
|
1511
|
-
}
|
|
1512
|
-
db.pragma("foreign_keys = ON");
|
|
1513
|
-
const rawVersion = db.pragma("user_version", { simple: true });
|
|
1514
|
-
const currentVersion = typeof rawVersion === "number" ? rawVersion : 0;
|
|
1515
|
-
if (currentVersion < SCHEMA_VERSION) {
|
|
1516
|
-
db.exec("DROP TABLE IF EXISTS file_refs");
|
|
1517
|
-
try {
|
|
1518
|
-
db.exec("UPDATE pages SET content_hash = ''");
|
|
1519
|
-
} catch {
|
|
1520
|
-
}
|
|
1521
|
-
db.pragma(`user_version = ${SCHEMA_VERSION}`);
|
|
1522
|
-
}
|
|
1523
|
-
db.exec(SCHEMA_DDL);
|
|
1524
|
-
return db;
|
|
1525
|
-
}
|
|
1526
|
-
|
|
1527
1342
|
// src/indexer/wikilinks.ts
|
|
1528
1343
|
function classifyWikilink(raw) {
|
|
1529
1344
|
const pipe = raw.indexOf("|");
|
|
@@ -1540,10 +1355,10 @@ function classifyWikilink(raw) {
|
|
|
1540
1355
|
}
|
|
1541
1356
|
if (firstSlash !== -1) {
|
|
1542
1357
|
const isDir = looksLikeDir(body);
|
|
1543
|
-
const
|
|
1358
|
+
const path6 = normalizePath(body, isDir);
|
|
1544
1359
|
const originalPath = normalizePathPreservingCase(body, isDir);
|
|
1545
|
-
if (
|
|
1546
|
-
return isDir ? { kind: "folder", path:
|
|
1360
|
+
if (path6.length === 0) return null;
|
|
1361
|
+
return isDir ? { kind: "folder", path: path6, originalPath } : { kind: "file", path: path6, originalPath };
|
|
1547
1362
|
}
|
|
1548
1363
|
const target = toKebabCase(body);
|
|
1549
1364
|
if (target.length === 0) return null;
|
|
@@ -1567,12 +1382,12 @@ async function ensureFreshIndex(ctx) {
|
|
|
1567
1382
|
const almanacDir = join6(ctx.repoRoot, ".almanac");
|
|
1568
1383
|
const dbPath = join6(almanacDir, "index.db");
|
|
1569
1384
|
const pagesDir = join6(almanacDir, "pages");
|
|
1570
|
-
if (!
|
|
1385
|
+
if (!existsSync7(pagesDir)) {
|
|
1571
1386
|
const db = openIndex(dbPath);
|
|
1572
1387
|
db.close();
|
|
1573
1388
|
return emptyResult();
|
|
1574
1389
|
}
|
|
1575
|
-
if (!
|
|
1390
|
+
if (!existsSync7(dbPath) || pagesNewerThan(pagesDir, dbPath) || topicsYamlNewerThan(almanacDir, dbPath)) {
|
|
1576
1391
|
return runIndexer(ctx);
|
|
1577
1392
|
}
|
|
1578
1393
|
return emptyResult();
|
|
@@ -1649,7 +1464,7 @@ async function indexPagesInto(db, pagesDir) {
|
|
|
1649
1464
|
let raw;
|
|
1650
1465
|
try {
|
|
1651
1466
|
st = statSync2(fullPath);
|
|
1652
|
-
raw = await
|
|
1467
|
+
raw = await readFile6(fullPath, "utf8");
|
|
1653
1468
|
} catch (err) {
|
|
1654
1469
|
if (err instanceof Error && "code" in err && (err.code === "ENOENT" || err.code === "EACCES")) {
|
|
1655
1470
|
process.stderr.write(
|
|
@@ -1763,10 +1578,10 @@ async function indexPagesInto(db, pagesDir) {
|
|
|
1763
1578
|
}
|
|
1764
1579
|
for (const raw of p.frontmatterFiles) {
|
|
1765
1580
|
const isDir = looksLikeDir(raw);
|
|
1766
|
-
const
|
|
1581
|
+
const path6 = normalizePath(raw, isDir);
|
|
1767
1582
|
const originalPath = normalizePathPreservingCase(raw, isDir);
|
|
1768
|
-
if (
|
|
1769
|
-
insertFileRef.run(p.slug,
|
|
1583
|
+
if (path6.length === 0) continue;
|
|
1584
|
+
insertFileRef.run(p.slug, path6, originalPath, isDir ? 1 : 0);
|
|
1770
1585
|
}
|
|
1771
1586
|
for (const ref of p.wikilinks) {
|
|
1772
1587
|
switch (ref.kind) {
|
|
@@ -1822,8 +1637,8 @@ function hashContent(raw) {
|
|
|
1822
1637
|
return createHash2("sha256").update(raw).digest("hex");
|
|
1823
1638
|
}
|
|
1824
1639
|
function topicsYamlNewerThan(almanacDir, dbPath) {
|
|
1825
|
-
const
|
|
1826
|
-
if (!
|
|
1640
|
+
const path6 = join6(almanacDir, "topics.yaml");
|
|
1641
|
+
if (!existsSync7(path6)) return false;
|
|
1827
1642
|
let dbMtime;
|
|
1828
1643
|
try {
|
|
1829
1644
|
dbMtime = statSync2(dbPath).mtimeMs;
|
|
@@ -1831,14 +1646,14 @@ function topicsYamlNewerThan(almanacDir, dbPath) {
|
|
|
1831
1646
|
return true;
|
|
1832
1647
|
}
|
|
1833
1648
|
try {
|
|
1834
|
-
const st = statSync2(
|
|
1649
|
+
const st = statSync2(path6);
|
|
1835
1650
|
return st.mtimeMs > dbMtime;
|
|
1836
1651
|
} catch {
|
|
1837
1652
|
return false;
|
|
1838
1653
|
}
|
|
1839
1654
|
}
|
|
1840
1655
|
async function applyTopicsYaml(db, topicsYamlPath2) {
|
|
1841
|
-
if (!
|
|
1656
|
+
if (!existsSync7(topicsYamlPath2)) return;
|
|
1842
1657
|
let file;
|
|
1843
1658
|
try {
|
|
1844
1659
|
file = await loadTopicsFile(topicsYamlPath2);
|
|
@@ -1894,7 +1709,7 @@ async function applyTopicsYaml(db, topicsYamlPath2) {
|
|
|
1894
1709
|
}
|
|
1895
1710
|
|
|
1896
1711
|
// src/indexer/resolveWiki.ts
|
|
1897
|
-
import { existsSync as
|
|
1712
|
+
import { existsSync as existsSync8 } from "fs";
|
|
1898
1713
|
import { join as join7 } from "path";
|
|
1899
1714
|
async function resolveWikiRoot(params) {
|
|
1900
1715
|
if (params.wiki !== void 0) {
|
|
@@ -1902,7 +1717,7 @@ async function resolveWikiRoot(params) {
|
|
|
1902
1717
|
if (entry === null) {
|
|
1903
1718
|
throw new Error(`no registered wiki named "${params.wiki}"`);
|
|
1904
1719
|
}
|
|
1905
|
-
if (!
|
|
1720
|
+
if (!existsSync8(join7(entry.path, ".almanac"))) {
|
|
1906
1721
|
throw new Error(
|
|
1907
1722
|
`wiki "${params.wiki}" path is unreachable (${entry.path})`
|
|
1908
1723
|
);
|
|
@@ -1912,7 +1727,7 @@ async function resolveWikiRoot(params) {
|
|
|
1912
1727
|
const nearest = findNearestAlmanacDir(params.cwd);
|
|
1913
1728
|
if (nearest === null) {
|
|
1914
1729
|
throw new Error(
|
|
1915
|
-
"no .almanac/ found in this directory or any parent; run `almanac
|
|
1730
|
+
"no .almanac/ found in this directory or any parent; run `almanac bootstrap` first"
|
|
1916
1731
|
);
|
|
1917
1732
|
}
|
|
1918
1733
|
return nearest;
|
|
@@ -2068,7 +1883,7 @@ async function findDeadRefs(db, scope, repoRoot) {
|
|
|
2068
1883
|
for (const r of rows) {
|
|
2069
1884
|
if (!inPageScope(scope, r.slug)) continue;
|
|
2070
1885
|
const abs = join8(repoRoot, r.original_path);
|
|
2071
|
-
if (!
|
|
1886
|
+
if (!existsSync9(abs)) {
|
|
2072
1887
|
out.push({ slug: r.slug, path: r.original_path });
|
|
2073
1888
|
}
|
|
2074
1889
|
}
|
|
@@ -2103,7 +1918,7 @@ async function findBrokenXwiki(db, scope) {
|
|
|
2103
1918
|
let ok = reachableCache.get(r.target_wiki);
|
|
2104
1919
|
if (ok === void 0) {
|
|
2105
1920
|
const entry = await findEntry({ name: r.target_wiki });
|
|
2106
|
-
ok = entry !== null &&
|
|
1921
|
+
ok = entry !== null && existsSync9(join8(entry.path, ".almanac"));
|
|
2107
1922
|
reachableCache.set(r.target_wiki, ok);
|
|
2108
1923
|
}
|
|
2109
1924
|
if (!ok) {
|
|
@@ -2138,7 +1953,7 @@ async function findEmptyPages(db, scope, pagesDir) {
|
|
|
2138
1953
|
if (!inPageScope(scope, r.slug)) continue;
|
|
2139
1954
|
let raw;
|
|
2140
1955
|
try {
|
|
2141
|
-
raw = await
|
|
1956
|
+
raw = await readFile7(r.file_path, "utf8");
|
|
2142
1957
|
} catch {
|
|
2143
1958
|
continue;
|
|
2144
1959
|
}
|
|
@@ -2158,7 +1973,7 @@ async function findEmptyPages(db, scope, pagesDir) {
|
|
|
2158
1973
|
return out;
|
|
2159
1974
|
}
|
|
2160
1975
|
async function findSlugCollisions(pagesDir) {
|
|
2161
|
-
if (!
|
|
1976
|
+
if (!existsSync9(pagesDir)) return [];
|
|
2162
1977
|
const files = await fg2("**/*.md", {
|
|
2163
1978
|
cwd: pagesDir,
|
|
2164
1979
|
absolute: false,
|
|
@@ -2253,259 +2068,212 @@ function section(label, count, lines) {
|
|
|
2253
2068
|
${lines.join("\n")}`;
|
|
2254
2069
|
}
|
|
2255
2070
|
|
|
2256
|
-
// src/commands/
|
|
2071
|
+
// src/commands/setup.ts
|
|
2257
2072
|
import { existsSync as existsSync11 } from "fs";
|
|
2258
|
-
|
|
2259
|
-
|
|
2260
|
-
|
|
2261
|
-
|
|
2262
|
-
|
|
2263
|
-
|
|
2264
|
-
|
|
2265
|
-
|
|
2266
|
-
|
|
2073
|
+
import {
|
|
2074
|
+
copyFile,
|
|
2075
|
+
mkdir as mkdir5,
|
|
2076
|
+
readFile as readFile9,
|
|
2077
|
+
writeFile as writeFile5
|
|
2078
|
+
} from "fs/promises";
|
|
2079
|
+
import { createRequire as createRequire2 } from "module";
|
|
2080
|
+
import { homedir as homedir4 } from "os";
|
|
2081
|
+
import path3 from "path";
|
|
2082
|
+
import { fileURLToPath as fileURLToPath3 } from "url";
|
|
2083
|
+
|
|
2084
|
+
// src/commands/hook.ts
|
|
2085
|
+
import { existsSync as existsSync10 } from "fs";
|
|
2086
|
+
import { mkdir as mkdir4, readFile as readFile8, rename as rename3, writeFile as writeFile4 } from "fs/promises";
|
|
2087
|
+
import { homedir as homedir3 } from "os";
|
|
2088
|
+
import path2 from "path";
|
|
2089
|
+
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
2090
|
+
var HOOK_TIMEOUT_SECONDS = 10;
|
|
2091
|
+
async function runHookInstall(options = {}) {
|
|
2092
|
+
const script = resolveHookScriptPath(options);
|
|
2093
|
+
if (!script.ok) {
|
|
2094
|
+
return { stdout: "", stderr: `almanac: ${script.error}
|
|
2095
|
+
`, exitCode: 1 };
|
|
2267
2096
|
}
|
|
2268
|
-
|
|
2269
|
-
|
|
2270
|
-
|
|
2271
|
-
const
|
|
2272
|
-
|
|
2273
|
-
|
|
2274
|
-
|
|
2097
|
+
const settingsPath = resolveSettingsPath(options);
|
|
2098
|
+
const settings = await readSettings(settingsPath);
|
|
2099
|
+
const existing = (settings.hooks?.SessionEnd ?? []).slice();
|
|
2100
|
+
const ourEntries = existing.filter((e) => e.command === script.path);
|
|
2101
|
+
const foreignEntries = existing.filter((e) => e.command !== script.path);
|
|
2102
|
+
const stale = foreignEntries.filter(
|
|
2103
|
+
(e) => e.command.endsWith("almanac-capture.sh")
|
|
2104
|
+
);
|
|
2105
|
+
const unrelated = foreignEntries.filter(
|
|
2106
|
+
(e) => !e.command.endsWith("almanac-capture.sh")
|
|
2107
|
+
);
|
|
2108
|
+
if (unrelated.length > 0) {
|
|
2109
|
+
const existingStr = unrelated.map((e) => ` - ${e.command}`).join("\n");
|
|
2110
|
+
return {
|
|
2111
|
+
stdout: "",
|
|
2112
|
+
stderr: `almanac: SessionEnd hook already has a foreign entry:
|
|
2113
|
+
${existingStr}
|
|
2114
|
+
Remove it manually from ${settingsPath} if you want almanac to manage the hook.
|
|
2275
2115
|
`,
|
|
2276
2116
|
exitCode: 1
|
|
2277
2117
|
};
|
|
2278
2118
|
}
|
|
2119
|
+
if (ourEntries.length > 0 && stale.length === 0) {
|
|
2120
|
+
return {
|
|
2121
|
+
stdout: `almanac: SessionEnd hook already installed at ${script.path}
|
|
2122
|
+
`,
|
|
2123
|
+
stderr: "",
|
|
2124
|
+
exitCode: 0
|
|
2125
|
+
};
|
|
2126
|
+
}
|
|
2127
|
+
const newEntries = [
|
|
2128
|
+
{
|
|
2129
|
+
type: "command",
|
|
2130
|
+
command: script.path,
|
|
2131
|
+
timeout: HOOK_TIMEOUT_SECONDS
|
|
2132
|
+
}
|
|
2133
|
+
];
|
|
2134
|
+
settings.hooks = { ...settings.hooks ?? {}, SessionEnd: newEntries };
|
|
2135
|
+
await writeSettings(settingsPath, settings);
|
|
2279
2136
|
return {
|
|
2280
|
-
stdout: `
|
|
2137
|
+
stdout: `almanac: SessionEnd hook installed
|
|
2138
|
+
script: ${script.path}
|
|
2139
|
+
settings: ${settingsPath}
|
|
2281
2140
|
`,
|
|
2141
|
+
stderr: "",
|
|
2282
2142
|
exitCode: 0
|
|
2283
2143
|
};
|
|
2284
2144
|
}
|
|
2285
|
-
function
|
|
2286
|
-
|
|
2287
|
-
|
|
2288
|
-
|
|
2289
|
-
|
|
2290
|
-
|
|
2291
|
-
|
|
2145
|
+
async function runHookUninstall(options = {}) {
|
|
2146
|
+
const settingsPath = resolveSettingsPath(options);
|
|
2147
|
+
if (!existsSync10(settingsPath)) {
|
|
2148
|
+
return {
|
|
2149
|
+
stdout: `almanac: SessionEnd hook not installed (no settings file)
|
|
2150
|
+
`,
|
|
2151
|
+
stderr: "",
|
|
2152
|
+
exitCode: 0
|
|
2153
|
+
};
|
|
2292
2154
|
}
|
|
2293
|
-
const
|
|
2294
|
-
|
|
2295
|
-
|
|
2296
|
-
|
|
2297
|
-
|
|
2298
|
-
|
|
2299
|
-
|
|
2300
|
-
|
|
2301
|
-
|
|
2302
|
-
|
|
2155
|
+
const settings = await readSettings(settingsPath);
|
|
2156
|
+
const existing = (settings.hooks?.SessionEnd ?? []).slice();
|
|
2157
|
+
const kept = existing.filter((e) => !e.command.endsWith("almanac-capture.sh"));
|
|
2158
|
+
const removed = existing.length - kept.length;
|
|
2159
|
+
if (removed === 0) {
|
|
2160
|
+
return {
|
|
2161
|
+
stdout: `almanac: SessionEnd hook not installed
|
|
2162
|
+
`,
|
|
2163
|
+
stderr: "",
|
|
2164
|
+
exitCode: 0
|
|
2165
|
+
};
|
|
2303
2166
|
}
|
|
2304
|
-
|
|
2305
|
-
|
|
2306
|
-
}
|
|
2307
|
-
|
|
2308
|
-
|
|
2309
|
-
|
|
2310
|
-
|
|
2311
|
-
|
|
2312
|
-
|
|
2313
|
-
|
|
2314
|
-
|
|
2315
|
-
const skipSuffix = result.filesSkipped > 0 ? `; ${result.filesSkipped} skipped` : "";
|
|
2316
|
-
const stdout = `reindexed: ${result.pagesIndexed} page${result.pagesIndexed === 1 ? "" : "s"} (${result.changed} updated, ${result.removed} removed${skipSuffix})
|
|
2317
|
-
`;
|
|
2318
|
-
return { result, stdout, exitCode: 0 };
|
|
2319
|
-
}
|
|
2320
|
-
|
|
2321
|
-
// src/commands/search.ts
|
|
2322
|
-
import { join as join9 } from "path";
|
|
2323
|
-
async function runSearch(options) {
|
|
2324
|
-
const repoRoot = await resolveWikiRoot({
|
|
2325
|
-
cwd: options.cwd,
|
|
2326
|
-
wiki: options.wiki
|
|
2327
|
-
});
|
|
2328
|
-
await ensureFreshIndex({ repoRoot });
|
|
2329
|
-
const dbPath = join9(repoRoot, ".almanac", "index.db");
|
|
2330
|
-
const db = openIndex(dbPath);
|
|
2331
|
-
try {
|
|
2332
|
-
const rows = executeQuery(db, options);
|
|
2333
|
-
const limited = options.limit !== void 0 && options.limit >= 0 ? rows.slice(0, options.limit) : rows;
|
|
2334
|
-
const stdout = formatResults(limited, options);
|
|
2335
|
-
const stderr = buildStderr(limited, options);
|
|
2336
|
-
return { stdout, stderr, exitCode: 0 };
|
|
2337
|
-
} finally {
|
|
2338
|
-
db.close();
|
|
2167
|
+
if (settings.hooks !== void 0) {
|
|
2168
|
+
if (kept.length === 0) {
|
|
2169
|
+
const { SessionEnd: _dropped, ...rest } = settings.hooks;
|
|
2170
|
+
void _dropped;
|
|
2171
|
+
settings.hooks = rest;
|
|
2172
|
+
} else {
|
|
2173
|
+
settings.hooks = { ...settings.hooks, SessionEnd: kept };
|
|
2174
|
+
}
|
|
2175
|
+
if (Object.keys(settings.hooks).length === 0) {
|
|
2176
|
+
delete settings.hooks;
|
|
2177
|
+
}
|
|
2339
2178
|
}
|
|
2179
|
+
await writeSettings(settingsPath, settings);
|
|
2180
|
+
return {
|
|
2181
|
+
stdout: `almanac: SessionEnd hook removed
|
|
2182
|
+
`,
|
|
2183
|
+
stderr: "",
|
|
2184
|
+
exitCode: 0
|
|
2185
|
+
};
|
|
2340
2186
|
}
|
|
2341
|
-
function
|
|
2342
|
-
const
|
|
2343
|
-
const
|
|
2344
|
-
if (
|
|
2345
|
-
|
|
2346
|
-
|
|
2347
|
-
|
|
2187
|
+
async function runHookStatus(options = {}) {
|
|
2188
|
+
const script = resolveHookScriptPath(options);
|
|
2189
|
+
const settingsPath = resolveSettingsPath(options);
|
|
2190
|
+
if (!existsSync10(settingsPath)) {
|
|
2191
|
+
return {
|
|
2192
|
+
stdout: `SessionEnd hook: not installed
|
|
2193
|
+
settings: ${settingsPath} (does not exist)
|
|
2194
|
+
` + (script.ok ? `script would be: ${script.path}
|
|
2195
|
+
` : ""),
|
|
2196
|
+
stderr: "",
|
|
2197
|
+
exitCode: 0
|
|
2198
|
+
};
|
|
2348
2199
|
}
|
|
2349
|
-
|
|
2350
|
-
|
|
2351
|
-
|
|
2352
|
-
|
|
2353
|
-
|
|
2354
|
-
|
|
2355
|
-
|
|
2200
|
+
const settings = await readSettings(settingsPath);
|
|
2201
|
+
const existing = settings.hooks?.SessionEnd ?? [];
|
|
2202
|
+
const ours = existing.find((e) => e.command.endsWith("almanac-capture.sh"));
|
|
2203
|
+
if (ours === void 0) {
|
|
2204
|
+
const foreign = existing.map((e) => ` - ${e.command}`).join("\n");
|
|
2205
|
+
return {
|
|
2206
|
+
stdout: `SessionEnd hook: not installed
|
|
2207
|
+
settings: ${settingsPath}
|
|
2208
|
+
` + (existing.length > 0 ? `(${existing.length} foreign entr${existing.length === 1 ? "y" : "ies"} present:
|
|
2209
|
+
${foreign})
|
|
2210
|
+
` : "") + (script.ok ? `script would be: ${script.path}
|
|
2211
|
+
` : ""),
|
|
2212
|
+
stderr: "",
|
|
2213
|
+
exitCode: 0
|
|
2214
|
+
};
|
|
2356
2215
|
}
|
|
2357
|
-
|
|
2358
|
-
|
|
2359
|
-
|
|
2360
|
-
|
|
2361
|
-
|
|
2362
|
-
|
|
2363
|
-
|
|
2364
|
-
|
|
2365
|
-
|
|
2366
|
-
|
|
2367
|
-
|
|
2368
|
-
|
|
2369
|
-
|
|
2370
|
-
|
|
2371
|
-
|
|
2372
|
-
|
|
2373
|
-
|
|
2374
|
-
|
|
2375
|
-
|
|
2376
|
-
|
|
2377
|
-
|
|
2378
|
-
|
|
2379
|
-
|
|
2380
|
-
|
|
2381
|
-
|
|
2382
|
-
|
|
2383
|
-
|
|
2384
|
-
|
|
2385
|
-
|
|
2386
|
-
AND (
|
|
2387
|
-
r.path = ?
|
|
2388
|
-
OR (r.is_dir = 1 AND r.path IN (${placeholders}))
|
|
2389
|
-
)
|
|
2390
|
-
)`
|
|
2391
|
-
);
|
|
2392
|
-
params.push(norm, ...prefixes);
|
|
2393
|
-
}
|
|
2216
|
+
return {
|
|
2217
|
+
stdout: `SessionEnd hook: installed
|
|
2218
|
+
script: ${ours.command}
|
|
2219
|
+
settings: ${settingsPath}
|
|
2220
|
+
`,
|
|
2221
|
+
stderr: "",
|
|
2222
|
+
exitCode: 0
|
|
2223
|
+
};
|
|
2224
|
+
}
|
|
2225
|
+
function resolveSettingsPath(options) {
|
|
2226
|
+
if (options.settingsPath !== void 0) return options.settingsPath;
|
|
2227
|
+
return path2.join(homedir3(), ".claude", "settings.json");
|
|
2228
|
+
}
|
|
2229
|
+
function resolveHookScriptPath(options) {
|
|
2230
|
+
if (options.hookScriptPath !== void 0) {
|
|
2231
|
+
return { ok: true, path: options.hookScriptPath };
|
|
2232
|
+
}
|
|
2233
|
+
const here = path2.dirname(fileURLToPath2(import.meta.url));
|
|
2234
|
+
const candidates = [
|
|
2235
|
+
// Bundled: `.../codealmanac/dist/codealmanac.js` → `../hooks/…`
|
|
2236
|
+
path2.resolve(here, "..", "hooks", "almanac-capture.sh"),
|
|
2237
|
+
// Source: `.../codealmanac/src/commands/hook.ts` → `../../hooks/…`
|
|
2238
|
+
path2.resolve(here, "..", "..", "hooks", "almanac-capture.sh"),
|
|
2239
|
+
// Defensive nested fallback.
|
|
2240
|
+
path2.resolve(here, "..", "..", "..", "hooks", "almanac-capture.sh")
|
|
2241
|
+
];
|
|
2242
|
+
for (const candidate of candidates) {
|
|
2243
|
+
if (existsSync10(candidate)) {
|
|
2244
|
+
return { ok: true, path: candidate };
|
|
2394
2245
|
}
|
|
2395
2246
|
}
|
|
2396
|
-
|
|
2397
|
-
|
|
2398
|
-
|
|
2399
|
-
|
|
2400
|
-
|
|
2247
|
+
return {
|
|
2248
|
+
ok: false,
|
|
2249
|
+
error: `could not locate hooks/almanac-capture.sh. Tried:
|
|
2250
|
+
` + candidates.map((c) => ` - ${c}`).join("\n")
|
|
2251
|
+
};
|
|
2252
|
+
}
|
|
2253
|
+
async function readSettings(settingsPath) {
|
|
2254
|
+
if (!existsSync10(settingsPath)) return {};
|
|
2255
|
+
try {
|
|
2256
|
+
const raw = await readFile8(settingsPath, "utf8");
|
|
2257
|
+
if (raw.trim().length === 0) return {};
|
|
2258
|
+
const parsed = JSON.parse(raw);
|
|
2259
|
+
if (parsed === null || typeof parsed !== "object") return {};
|
|
2260
|
+
return parsed;
|
|
2261
|
+
} catch (err) {
|
|
2262
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
2263
|
+
throw new Error(`failed to read ${settingsPath}: ${msg}`);
|
|
2401
2264
|
}
|
|
2402
|
-
if (options.stale !== void 0) {
|
|
2403
|
-
const seconds = parseDuration(options.stale);
|
|
2404
|
-
whereClauses.push("p.updated_at < ?");
|
|
2405
|
-
params.push(now - seconds);
|
|
2406
|
-
}
|
|
2407
|
-
if (options.orphan === true) {
|
|
2408
|
-
whereClauses.push(
|
|
2409
|
-
"NOT EXISTS (SELECT 1 FROM page_topics pt WHERE pt.page_slug = p.slug)"
|
|
2410
|
-
);
|
|
2411
|
-
}
|
|
2412
|
-
let sql;
|
|
2413
|
-
if (options.query !== void 0 && options.query.trim().length > 0) {
|
|
2414
|
-
const ftsExpr = buildFtsQuery(options.query);
|
|
2415
|
-
sql = `
|
|
2416
|
-
SELECT p.slug, p.title, p.updated_at, p.archived_at, p.superseded_by
|
|
2417
|
-
FROM pages p
|
|
2418
|
-
JOIN fts_pages f ON f.slug = p.slug
|
|
2419
|
-
WHERE fts_pages MATCH ?
|
|
2420
|
-
${whereClauses.length > 0 ? `AND ${whereClauses.join(" AND ")}` : ""}
|
|
2421
|
-
ORDER BY f.rank ASC, p.updated_at DESC, p.slug ASC
|
|
2422
|
-
`;
|
|
2423
|
-
params.unshift(ftsExpr);
|
|
2424
|
-
} else {
|
|
2425
|
-
sql = buildSql(whereClauses);
|
|
2426
|
-
}
|
|
2427
|
-
const rows = db.prepare(sql).all(...params);
|
|
2428
|
-
const topicStmt = db.prepare(
|
|
2429
|
-
"SELECT topic_slug FROM page_topics WHERE page_slug = ? ORDER BY topic_slug"
|
|
2430
|
-
);
|
|
2431
|
-
const out = rows.map((row) => ({
|
|
2432
|
-
slug: row.slug,
|
|
2433
|
-
title: row.title,
|
|
2434
|
-
updated_at: row.updated_at,
|
|
2435
|
-
archived_at: row.archived_at,
|
|
2436
|
-
superseded_by: row.superseded_by,
|
|
2437
|
-
topics: topicStmt.all(row.slug).map((t) => t.topic_slug)
|
|
2438
|
-
}));
|
|
2439
|
-
return out;
|
|
2440
|
-
}
|
|
2441
|
-
function buildSql(whereClauses) {
|
|
2442
|
-
const where = whereClauses.length > 0 ? `WHERE ${whereClauses.join(" AND ")}` : "";
|
|
2443
|
-
return `
|
|
2444
|
-
SELECT p.slug, p.title, p.updated_at, p.archived_at, p.superseded_by
|
|
2445
|
-
FROM pages p
|
|
2446
|
-
${where}
|
|
2447
|
-
ORDER BY p.updated_at DESC, p.slug ASC
|
|
2448
|
-
`;
|
|
2449
|
-
}
|
|
2450
|
-
function buildFtsQuery(raw) {
|
|
2451
|
-
const trimmed = raw.trim();
|
|
2452
|
-
if (trimmed.length >= 2 && trimmed.startsWith('"') && trimmed.endsWith('"')) {
|
|
2453
|
-
const inner = trimmed.slice(1, -1).toLowerCase().replace(/[^a-z0-9]+/g, " ").trim();
|
|
2454
|
-
if (inner.length === 0) return '""';
|
|
2455
|
-
return `"${inner}"`;
|
|
2456
|
-
}
|
|
2457
|
-
const tokens = trimmed.toLowerCase().split(/[^a-z0-9]+/).filter((t) => t.length > 0);
|
|
2458
|
-
if (tokens.length === 0) return '""';
|
|
2459
|
-
return tokens.map((t) => `${t}*`).join(" AND ");
|
|
2460
|
-
}
|
|
2461
|
-
function slugForTopic(raw) {
|
|
2462
|
-
return raw.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
2463
|
-
}
|
|
2464
|
-
function parentFolderPrefixes(filePath) {
|
|
2465
|
-
const out = [];
|
|
2466
|
-
let cursor = 0;
|
|
2467
|
-
while (true) {
|
|
2468
|
-
const next = filePath.indexOf("/", cursor);
|
|
2469
|
-
if (next === -1) break;
|
|
2470
|
-
out.push(filePath.slice(0, next + 1));
|
|
2471
|
-
cursor = next + 1;
|
|
2472
|
-
}
|
|
2473
|
-
return out;
|
|
2474
|
-
}
|
|
2475
|
-
function escapeGlobMeta(s) {
|
|
2476
|
-
return s.replace(/[\*\?\[]/g, (ch) => `[${ch}]`);
|
|
2477
|
-
}
|
|
2478
|
-
function formatResults(rows, options) {
|
|
2479
|
-
if (options.json === true) {
|
|
2480
|
-
return `${JSON.stringify(rows, null, 2)}
|
|
2481
|
-
`;
|
|
2482
|
-
}
|
|
2483
|
-
if (rows.length === 0) return "";
|
|
2484
|
-
return `${rows.map((r) => r.slug).join("\n")}
|
|
2485
|
-
`;
|
|
2486
2265
|
}
|
|
2487
|
-
function
|
|
2488
|
-
|
|
2489
|
-
|
|
2490
|
-
|
|
2491
|
-
|
|
2266
|
+
async function writeSettings(settingsPath, settings) {
|
|
2267
|
+
const dir = path2.dirname(settingsPath);
|
|
2268
|
+
await mkdir4(dir, { recursive: true });
|
|
2269
|
+
const tmp = `${settingsPath}.almanac-tmp-${process.pid}`;
|
|
2270
|
+
const body = `${JSON.stringify(settings, null, 2)}
|
|
2492
2271
|
`;
|
|
2493
|
-
|
|
2494
|
-
|
|
2272
|
+
await writeFile4(tmp, body, "utf8");
|
|
2273
|
+
await rename3(tmp, settingsPath);
|
|
2495
2274
|
}
|
|
2496
2275
|
|
|
2497
2276
|
// src/commands/setup.ts
|
|
2498
|
-
import { existsSync as existsSync12 } from "fs";
|
|
2499
|
-
import {
|
|
2500
|
-
copyFile,
|
|
2501
|
-
mkdir as mkdir5,
|
|
2502
|
-
readFile as readFile9,
|
|
2503
|
-
writeFile as writeFile5
|
|
2504
|
-
} from "fs/promises";
|
|
2505
|
-
import { createRequire as createRequire2 } from "module";
|
|
2506
|
-
import { homedir as homedir4 } from "os";
|
|
2507
|
-
import path3 from "path";
|
|
2508
|
-
import { fileURLToPath as fileURLToPath3 } from "url";
|
|
2509
2277
|
var RST = "\x1B[0m";
|
|
2510
2278
|
var BOLD = "\x1B[1m";
|
|
2511
2279
|
var DIM = "\x1B[2m";
|
|
@@ -2563,6 +2331,12 @@ async function runSetup(options = {}) {
|
|
|
2563
2331
|
const out = options.stdout ?? process.stdout;
|
|
2564
2332
|
const isTTY = options.isTTY ?? process.stdin.isTTY === true;
|
|
2565
2333
|
const interactive = isTTY && options.yes !== true;
|
|
2334
|
+
if (options.skipHook === true && options.skipGuides === true) {
|
|
2335
|
+
out.write(
|
|
2336
|
+
"codealmanac: nothing to install \u2014 use --help to see what setup does\n"
|
|
2337
|
+
);
|
|
2338
|
+
return { stdout: "", stderr: "", exitCode: 0 };
|
|
2339
|
+
}
|
|
2566
2340
|
printBanner(out);
|
|
2567
2341
|
printBadge(out);
|
|
2568
2342
|
const auth = await safeCheckAuth(options.spawnCli);
|
|
@@ -2649,147 +2423,859 @@ function reportAuth(out, auth) {
|
|
|
2649
2423
|
stepDone(out, `Claude auth: ${WHITE_BOLD}${who}${RST}${plan}`);
|
|
2650
2424
|
return;
|
|
2651
2425
|
}
|
|
2652
|
-
if (process.env.ANTHROPIC_API_KEY !== void 0 && process.env.ANTHROPIC_API_KEY.length > 0) {
|
|
2653
|
-
stepDone(out, `Claude auth: ${WHITE_BOLD}ANTHROPIC_API_KEY${RST} set`);
|
|
2654
|
-
return;
|
|
2426
|
+
if (process.env.ANTHROPIC_API_KEY !== void 0 && process.env.ANTHROPIC_API_KEY.length > 0) {
|
|
2427
|
+
stepDone(out, `Claude auth: ${WHITE_BOLD}ANTHROPIC_API_KEY${RST} set`);
|
|
2428
|
+
return;
|
|
2429
|
+
}
|
|
2430
|
+
stepActive(out, `Claude auth: ${DIM}not signed in${RST}`);
|
|
2431
|
+
for (const line of UNAUTHENTICATED_MESSAGE.split("\n")) {
|
|
2432
|
+
out.write(` ${DIM}\u2502 ${line}${RST}
|
|
2433
|
+
`);
|
|
2434
|
+
}
|
|
2435
|
+
}
|
|
2436
|
+
async function installGuides(options) {
|
|
2437
|
+
await mkdir5(options.claudeDir, { recursive: true });
|
|
2438
|
+
const srcMini = path3.join(options.guidesDir, "mini.md");
|
|
2439
|
+
const srcRef = path3.join(options.guidesDir, "reference.md");
|
|
2440
|
+
if (!existsSync11(srcMini)) {
|
|
2441
|
+
throw new Error(`missing bundled guide: ${srcMini}`);
|
|
2442
|
+
}
|
|
2443
|
+
if (!existsSync11(srcRef)) {
|
|
2444
|
+
throw new Error(`missing bundled guide: ${srcRef}`);
|
|
2445
|
+
}
|
|
2446
|
+
const destMini = path3.join(options.claudeDir, "codealmanac.md");
|
|
2447
|
+
const destRef = path3.join(options.claudeDir, "codealmanac-reference.md");
|
|
2448
|
+
const miniChanged = await copyIfChanged(srcMini, destMini);
|
|
2449
|
+
const refChanged = await copyIfChanged(srcRef, destRef);
|
|
2450
|
+
const claudeMd = path3.join(options.claudeDir, "CLAUDE.md");
|
|
2451
|
+
const importChanged = await ensureImport(claudeMd);
|
|
2452
|
+
const filesWritten = [];
|
|
2453
|
+
if (miniChanged) filesWritten.push("codealmanac.md");
|
|
2454
|
+
if (refChanged) filesWritten.push("codealmanac-reference.md");
|
|
2455
|
+
if (importChanged) filesWritten.push("CLAUDE.md");
|
|
2456
|
+
return { anyChanges: filesWritten.length > 0, filesWritten };
|
|
2457
|
+
}
|
|
2458
|
+
async function copyIfChanged(src, dest) {
|
|
2459
|
+
const srcBytes = await readFile9(src);
|
|
2460
|
+
if (existsSync11(dest)) {
|
|
2461
|
+
try {
|
|
2462
|
+
const destBytes = await readFile9(dest);
|
|
2463
|
+
if (srcBytes.equals(destBytes)) return false;
|
|
2464
|
+
} catch {
|
|
2465
|
+
}
|
|
2466
|
+
}
|
|
2467
|
+
await copyFile(src, dest);
|
|
2468
|
+
return true;
|
|
2469
|
+
}
|
|
2470
|
+
var IMPORT_LINE = "@~/.claude/codealmanac.md";
|
|
2471
|
+
async function ensureImport(claudeMdPath) {
|
|
2472
|
+
let existing = "";
|
|
2473
|
+
if (existsSync11(claudeMdPath)) {
|
|
2474
|
+
existing = await readFile9(claudeMdPath, "utf8");
|
|
2475
|
+
}
|
|
2476
|
+
if (hasImportLine(existing)) return false;
|
|
2477
|
+
const sep = existing.length === 0 ? "" : existing.endsWith("\n") ? "\n" : "\n\n";
|
|
2478
|
+
const body = `${existing}${sep}${IMPORT_LINE}
|
|
2479
|
+
`;
|
|
2480
|
+
await writeFile5(claudeMdPath, body, "utf8");
|
|
2481
|
+
return true;
|
|
2482
|
+
}
|
|
2483
|
+
function hasImportLine(contents) {
|
|
2484
|
+
const lines = contents.split(/\r?\n/).map((l) => l.trim());
|
|
2485
|
+
return lines.some((line) => {
|
|
2486
|
+
if (line === IMPORT_LINE) return true;
|
|
2487
|
+
if (!line.startsWith(IMPORT_LINE)) return false;
|
|
2488
|
+
const next = line[IMPORT_LINE.length];
|
|
2489
|
+
return next === " " || next === " ";
|
|
2490
|
+
});
|
|
2491
|
+
}
|
|
2492
|
+
function confirm(out, question, defaultYes) {
|
|
2493
|
+
return new Promise((resolve2) => {
|
|
2494
|
+
const hint = defaultYes ? "[Y/n]" : "[y/N]";
|
|
2495
|
+
out.write(` ${BLUE}\u25C6${RST} ${question} ${DIM}${hint}${RST} `);
|
|
2496
|
+
let buf = "";
|
|
2497
|
+
const onData = (chunk) => {
|
|
2498
|
+
buf += chunk.toString("utf8");
|
|
2499
|
+
const nl = buf.indexOf("\n");
|
|
2500
|
+
if (nl === -1) return;
|
|
2501
|
+
process.stdin.removeListener("data", onData);
|
|
2502
|
+
process.stdin.pause();
|
|
2503
|
+
const answer = buf.slice(0, nl).trim().toLowerCase();
|
|
2504
|
+
const accepted = answer.length === 0 ? defaultYes : answer === "y" || answer === "yes";
|
|
2505
|
+
resolve2(accepted ? "install" : "skip");
|
|
2506
|
+
};
|
|
2507
|
+
process.stdin.resume();
|
|
2508
|
+
process.stdin.on("data", onData);
|
|
2509
|
+
});
|
|
2510
|
+
}
|
|
2511
|
+
function printNextSteps(out) {
|
|
2512
|
+
const innerW = 62;
|
|
2513
|
+
const vis = (s) => s.replace(/\x1b\[[0-9;]*m/g, "").length;
|
|
2514
|
+
const row = (content) => {
|
|
2515
|
+
const padding = Math.max(0, innerW - vis(content));
|
|
2516
|
+
return ` ${BLUE_DIM}\u2502${RST}${content}${" ".repeat(padding)}${BLUE_DIM}\u2502${RST}
|
|
2517
|
+
`;
|
|
2518
|
+
};
|
|
2519
|
+
const empty = row("");
|
|
2520
|
+
out.write(` ${BLUE_DIM}\u256D${"\u2500".repeat(innerW)}\u256E${RST}
|
|
2521
|
+
`);
|
|
2522
|
+
out.write(empty);
|
|
2523
|
+
out.write(row(` ${WHITE_BOLD}Next steps${RST}`));
|
|
2524
|
+
out.write(empty);
|
|
2525
|
+
out.write(
|
|
2526
|
+
row(` ${BLUE}1.${RST} ${BOLD}cd${RST} into a repo you want to document`)
|
|
2527
|
+
);
|
|
2528
|
+
out.write(
|
|
2529
|
+
row(
|
|
2530
|
+
` ${BLUE}2.${RST} ${BOLD}almanac bootstrap${RST} ${DIM}# scaffold the wiki${RST}`
|
|
2531
|
+
)
|
|
2532
|
+
);
|
|
2533
|
+
out.write(
|
|
2534
|
+
row(
|
|
2535
|
+
` ${BLUE}3.${RST} Work normally \u2014 capture runs on session end`
|
|
2536
|
+
)
|
|
2537
|
+
);
|
|
2538
|
+
out.write(empty);
|
|
2539
|
+
out.write(` ${BLUE_DIM}\u2570${"\u2500".repeat(innerW)}\u256F${RST}
|
|
2540
|
+
|
|
2541
|
+
`);
|
|
2542
|
+
}
|
|
2543
|
+
function resolveGuidesDir() {
|
|
2544
|
+
const here = path3.dirname(fileURLToPath3(import.meta.url));
|
|
2545
|
+
const candidates = [
|
|
2546
|
+
path3.resolve(here, "..", "guides"),
|
|
2547
|
+
// dist layout
|
|
2548
|
+
path3.resolve(here, "..", "..", "guides"),
|
|
2549
|
+
// src layout
|
|
2550
|
+
path3.resolve(here, "..", "..", "..", "guides")
|
|
2551
|
+
];
|
|
2552
|
+
for (const dir of candidates) {
|
|
2553
|
+
if (looksLikeGuidesDir(dir)) return dir;
|
|
2554
|
+
}
|
|
2555
|
+
try {
|
|
2556
|
+
const require2 = createRequire2(import.meta.url);
|
|
2557
|
+
const pkgJson = require2.resolve("codealmanac/package.json");
|
|
2558
|
+
const guides = path3.join(path3.dirname(pkgJson), "guides");
|
|
2559
|
+
if (looksLikeGuidesDir(guides)) return guides;
|
|
2560
|
+
} catch {
|
|
2561
|
+
}
|
|
2562
|
+
throw new Error(
|
|
2563
|
+
"could not locate bundled guides/ directory. Tried:\n" + candidates.map((c) => ` - ${c}`).join("\n")
|
|
2564
|
+
);
|
|
2565
|
+
}
|
|
2566
|
+
function looksLikeGuidesDir(dir) {
|
|
2567
|
+
return existsSync11(path3.join(dir, "mini.md"));
|
|
2568
|
+
}
|
|
2569
|
+
|
|
2570
|
+
// src/commands/doctor.ts
|
|
2571
|
+
var RST2 = "\x1B[0m";
|
|
2572
|
+
var BOLD2 = "\x1B[1m";
|
|
2573
|
+
var DIM2 = "\x1B[2m";
|
|
2574
|
+
var GREEN = "\x1B[38;5;35m";
|
|
2575
|
+
var RED = "\x1B[38;5;167m";
|
|
2576
|
+
var BLUE2 = "\x1B[38;5;75m";
|
|
2577
|
+
async function runDoctor(options) {
|
|
2578
|
+
const version = options.versionOverride ?? readPackageVersion() ?? "unknown";
|
|
2579
|
+
const install = options.wikiOnly === true ? [] : await gatherInstallChecks(options);
|
|
2580
|
+
const wiki = options.installOnly === true ? [] : await gatherWikiChecks(options);
|
|
2581
|
+
const report = { version, install, wiki };
|
|
2582
|
+
if (options.json === true) {
|
|
2583
|
+
return {
|
|
2584
|
+
stdout: `${JSON.stringify(report, null, 2)}
|
|
2585
|
+
`,
|
|
2586
|
+
stderr: "",
|
|
2587
|
+
exitCode: 0
|
|
2588
|
+
};
|
|
2589
|
+
}
|
|
2590
|
+
return {
|
|
2591
|
+
stdout: formatReport2(report, options),
|
|
2592
|
+
stderr: "",
|
|
2593
|
+
exitCode: 0
|
|
2594
|
+
};
|
|
2595
|
+
}
|
|
2596
|
+
async function gatherInstallChecks(options) {
|
|
2597
|
+
const checks = [];
|
|
2598
|
+
const installPath = options.installPath ?? detectInstallPath();
|
|
2599
|
+
checks.push({
|
|
2600
|
+
status: installPath !== null ? "ok" : "problem",
|
|
2601
|
+
key: "install.path",
|
|
2602
|
+
message: installPath !== null ? `codealmanac installed at ${installPath}` : "could not detect codealmanac install path",
|
|
2603
|
+
fix: installPath === null ? "reinstall with: npm install -g codealmanac" : void 0
|
|
2604
|
+
});
|
|
2605
|
+
const nodeVersion = options.nodeVersion ?? process.version;
|
|
2606
|
+
const sqlite = options.sqliteProbe ?? probeBetterSqlite3();
|
|
2607
|
+
checks.push({
|
|
2608
|
+
status: sqlite.ok ? "ok" : "problem",
|
|
2609
|
+
key: "install.sqlite",
|
|
2610
|
+
message: sqlite.ok ? `better-sqlite3 native binding OK (Node ${nodeVersion})` : `better-sqlite3 native binding failed: ${sqlite.summary}`,
|
|
2611
|
+
fix: sqlite.ok ? void 0 : "run: npm rebuild better-sqlite3 (in the install directory)"
|
|
2612
|
+
});
|
|
2613
|
+
const auth = await safeCheckAuth2(options.spawnCli);
|
|
2614
|
+
checks.push(describeAuth(auth));
|
|
2615
|
+
const settingsPath = options.settingsPath ?? path4.join(homedir5(), ".claude", "settings.json");
|
|
2616
|
+
checks.push(await describeHook(settingsPath));
|
|
2617
|
+
const claudeDir = options.claudeDir ?? path4.join(homedir5(), ".claude");
|
|
2618
|
+
checks.push(describeGuides(claudeDir));
|
|
2619
|
+
checks.push(await describeImportLine(claudeDir));
|
|
2620
|
+
return checks;
|
|
2621
|
+
}
|
|
2622
|
+
function describeAuth(auth) {
|
|
2623
|
+
if (auth.loggedIn) {
|
|
2624
|
+
if (auth.authMethod === "apiKey") {
|
|
2625
|
+
return {
|
|
2626
|
+
status: "ok",
|
|
2627
|
+
key: "install.auth",
|
|
2628
|
+
message: "claude auth: ANTHROPIC_API_KEY set"
|
|
2629
|
+
};
|
|
2630
|
+
}
|
|
2631
|
+
const who = auth.email ?? "Claude account";
|
|
2632
|
+
const plan = auth.subscriptionType !== void 0 ? ` (${auth.subscriptionType} subscription)` : "";
|
|
2633
|
+
return {
|
|
2634
|
+
status: "ok",
|
|
2635
|
+
key: "install.auth",
|
|
2636
|
+
message: `claude auth: ${who}${plan}`
|
|
2637
|
+
};
|
|
2638
|
+
}
|
|
2639
|
+
if (process.env.ANTHROPIC_API_KEY !== void 0 && process.env.ANTHROPIC_API_KEY.length > 0) {
|
|
2640
|
+
return {
|
|
2641
|
+
status: "ok",
|
|
2642
|
+
key: "install.auth",
|
|
2643
|
+
message: "claude auth: ANTHROPIC_API_KEY set"
|
|
2644
|
+
};
|
|
2645
|
+
}
|
|
2646
|
+
return {
|
|
2647
|
+
status: "problem",
|
|
2648
|
+
key: "install.auth",
|
|
2649
|
+
message: "claude auth: not signed in",
|
|
2650
|
+
fix: "run: claude auth login --claudeai (or export ANTHROPIC_API_KEY)"
|
|
2651
|
+
};
|
|
2652
|
+
}
|
|
2653
|
+
async function describeHook(settingsPath) {
|
|
2654
|
+
if (!existsSync12(settingsPath)) {
|
|
2655
|
+
return {
|
|
2656
|
+
status: "problem",
|
|
2657
|
+
key: "install.hook",
|
|
2658
|
+
message: "SessionEnd hook not installed",
|
|
2659
|
+
fix: "run: almanac setup --yes"
|
|
2660
|
+
};
|
|
2661
|
+
}
|
|
2662
|
+
try {
|
|
2663
|
+
const raw = await readFile10(settingsPath, "utf8");
|
|
2664
|
+
const parsed = JSON.parse(raw);
|
|
2665
|
+
const entries = parsed.hooks?.SessionEnd ?? [];
|
|
2666
|
+
const ours = entries.find(
|
|
2667
|
+
(e) => typeof e.command === "string" && e.command.endsWith("almanac-capture.sh")
|
|
2668
|
+
);
|
|
2669
|
+
if (ours === void 0) {
|
|
2670
|
+
return {
|
|
2671
|
+
status: "problem",
|
|
2672
|
+
key: "install.hook",
|
|
2673
|
+
message: "SessionEnd hook not installed",
|
|
2674
|
+
fix: "run: almanac setup --yes"
|
|
2675
|
+
};
|
|
2676
|
+
}
|
|
2677
|
+
return {
|
|
2678
|
+
status: "ok",
|
|
2679
|
+
key: "install.hook",
|
|
2680
|
+
message: `SessionEnd hook installed at ${settingsPath}`
|
|
2681
|
+
};
|
|
2682
|
+
} catch (err) {
|
|
2683
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
2684
|
+
return {
|
|
2685
|
+
status: "problem",
|
|
2686
|
+
key: "install.hook",
|
|
2687
|
+
message: `could not read ${settingsPath}: ${msg}`,
|
|
2688
|
+
fix: "check the file for malformed JSON"
|
|
2689
|
+
};
|
|
2690
|
+
}
|
|
2691
|
+
}
|
|
2692
|
+
function describeGuides(claudeDir) {
|
|
2693
|
+
const mini = path4.join(claudeDir, "codealmanac.md");
|
|
2694
|
+
const ref = path4.join(claudeDir, "codealmanac-reference.md");
|
|
2695
|
+
const haveMini = existsSync12(mini);
|
|
2696
|
+
const haveRef = existsSync12(ref);
|
|
2697
|
+
if (haveMini && haveRef) {
|
|
2698
|
+
return {
|
|
2699
|
+
status: "ok",
|
|
2700
|
+
key: "install.guides",
|
|
2701
|
+
message: `Agent guides installed (${path4.basename(mini)}, ${path4.basename(ref)})`
|
|
2702
|
+
};
|
|
2703
|
+
}
|
|
2704
|
+
const missing = [
|
|
2705
|
+
haveMini ? null : "codealmanac.md",
|
|
2706
|
+
haveRef ? null : "codealmanac-reference.md"
|
|
2707
|
+
].filter((s) => s !== null);
|
|
2708
|
+
return {
|
|
2709
|
+
status: "problem",
|
|
2710
|
+
key: "install.guides",
|
|
2711
|
+
message: `Agent guides missing (${missing.join(", ")})`,
|
|
2712
|
+
fix: "run: almanac setup --yes"
|
|
2713
|
+
};
|
|
2714
|
+
}
|
|
2715
|
+
async function describeImportLine(claudeDir) {
|
|
2716
|
+
const claudeMd = path4.join(claudeDir, "CLAUDE.md");
|
|
2717
|
+
if (!existsSync12(claudeMd)) {
|
|
2718
|
+
return {
|
|
2719
|
+
status: "problem",
|
|
2720
|
+
key: "install.import",
|
|
2721
|
+
message: "CLAUDE.md import not present (no ~/.claude/CLAUDE.md)",
|
|
2722
|
+
fix: "run: almanac setup --yes"
|
|
2723
|
+
};
|
|
2724
|
+
}
|
|
2725
|
+
try {
|
|
2726
|
+
const contents = await readFile10(claudeMd, "utf8");
|
|
2727
|
+
const lines = contents.split(/\r?\n/).map((l) => l.trim());
|
|
2728
|
+
const present = lines.some((line) => {
|
|
2729
|
+
if (line === IMPORT_LINE) return true;
|
|
2730
|
+
if (!line.startsWith(IMPORT_LINE)) return false;
|
|
2731
|
+
const next = line[IMPORT_LINE.length];
|
|
2732
|
+
return next === " " || next === " ";
|
|
2733
|
+
});
|
|
2734
|
+
if (present) {
|
|
2735
|
+
return {
|
|
2736
|
+
status: "ok",
|
|
2737
|
+
key: "install.import",
|
|
2738
|
+
message: "CLAUDE.md import present"
|
|
2739
|
+
};
|
|
2740
|
+
}
|
|
2741
|
+
return {
|
|
2742
|
+
status: "problem",
|
|
2743
|
+
key: "install.import",
|
|
2744
|
+
message: "CLAUDE.md import line missing",
|
|
2745
|
+
fix: "run: almanac setup --yes"
|
|
2746
|
+
};
|
|
2747
|
+
} catch (err) {
|
|
2748
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
2749
|
+
return {
|
|
2750
|
+
status: "problem",
|
|
2751
|
+
key: "install.import",
|
|
2752
|
+
message: `could not read ${claudeMd}: ${msg}`
|
|
2753
|
+
};
|
|
2754
|
+
}
|
|
2755
|
+
}
|
|
2756
|
+
async function gatherWikiChecks(options) {
|
|
2757
|
+
const checks = [];
|
|
2758
|
+
const repoRoot = findNearestAlmanacDir(options.cwd);
|
|
2759
|
+
if (repoRoot === null) {
|
|
2760
|
+
checks.push({
|
|
2761
|
+
status: "info",
|
|
2762
|
+
key: "wiki.none",
|
|
2763
|
+
message: "No wiki in current directory",
|
|
2764
|
+
fix: "run: almanac bootstrap (to create one in this repo)"
|
|
2765
|
+
});
|
|
2766
|
+
return checks;
|
|
2767
|
+
}
|
|
2768
|
+
checks.push({
|
|
2769
|
+
status: "info",
|
|
2770
|
+
key: "wiki.repo",
|
|
2771
|
+
message: `repo: ${repoRoot}`
|
|
2772
|
+
});
|
|
2773
|
+
const entry = await findEntry({ path: repoRoot });
|
|
2774
|
+
if (entry !== null) {
|
|
2775
|
+
checks.push({
|
|
2776
|
+
status: "ok",
|
|
2777
|
+
key: "wiki.registered",
|
|
2778
|
+
message: `registered as '${entry.name}'`
|
|
2779
|
+
});
|
|
2780
|
+
} else {
|
|
2781
|
+
checks.push({
|
|
2782
|
+
status: "info",
|
|
2783
|
+
key: "wiki.registered",
|
|
2784
|
+
message: "not yet registered (will register on first command)"
|
|
2785
|
+
});
|
|
2786
|
+
}
|
|
2787
|
+
const almanacDir = path4.join(repoRoot, ".almanac");
|
|
2788
|
+
const dbPath = path4.join(almanacDir, "index.db");
|
|
2789
|
+
let pageCount = null;
|
|
2790
|
+
let topicCount = null;
|
|
2791
|
+
if (existsSync12(dbPath)) {
|
|
2792
|
+
try {
|
|
2793
|
+
const db = openIndex(dbPath);
|
|
2794
|
+
try {
|
|
2795
|
+
pageCount = countRows(db, "pages");
|
|
2796
|
+
topicCount = countRows(db, "topics");
|
|
2797
|
+
} finally {
|
|
2798
|
+
db.close();
|
|
2799
|
+
}
|
|
2800
|
+
} catch {
|
|
2801
|
+
pageCount = null;
|
|
2802
|
+
}
|
|
2803
|
+
}
|
|
2804
|
+
if (pageCount !== null) {
|
|
2805
|
+
checks.push({
|
|
2806
|
+
status: "info",
|
|
2807
|
+
key: "wiki.pages",
|
|
2808
|
+
message: `pages: ${pageCount}`
|
|
2809
|
+
});
|
|
2810
|
+
}
|
|
2811
|
+
if (topicCount !== null) {
|
|
2812
|
+
checks.push({
|
|
2813
|
+
status: "info",
|
|
2814
|
+
key: "wiki.topics",
|
|
2815
|
+
message: `topics: ${topicCount}`
|
|
2816
|
+
});
|
|
2817
|
+
}
|
|
2818
|
+
checks.push(describeIndexFreshness(dbPath));
|
|
2819
|
+
checks.push(describeLastCapture(almanacDir, options.now));
|
|
2820
|
+
const healthFn = options.runHealthFn ?? runHealth;
|
|
2821
|
+
try {
|
|
2822
|
+
const healthRes = await healthFn({
|
|
2823
|
+
cwd: repoRoot,
|
|
2824
|
+
json: true
|
|
2825
|
+
});
|
|
2826
|
+
const problems = countHealthProblems(healthRes.stdout);
|
|
2827
|
+
if (problems === 0) {
|
|
2828
|
+
checks.push({
|
|
2829
|
+
status: "ok",
|
|
2830
|
+
key: "wiki.health",
|
|
2831
|
+
message: "almanac health reports 0 problems"
|
|
2832
|
+
});
|
|
2833
|
+
} else {
|
|
2834
|
+
checks.push({
|
|
2835
|
+
status: "problem",
|
|
2836
|
+
key: "wiki.health",
|
|
2837
|
+
message: `almanac health reports ${problems} problem${problems === 1 ? "" : "s"}`,
|
|
2838
|
+
fix: "run: almanac health"
|
|
2839
|
+
});
|
|
2840
|
+
}
|
|
2841
|
+
} catch (err) {
|
|
2842
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
2843
|
+
checks.push({
|
|
2844
|
+
status: "info",
|
|
2845
|
+
key: "wiki.health",
|
|
2846
|
+
message: `could not run almanac health: ${msg}`
|
|
2847
|
+
});
|
|
2848
|
+
}
|
|
2849
|
+
return checks;
|
|
2850
|
+
}
|
|
2851
|
+
function countRows(db, table) {
|
|
2852
|
+
const row = db.prepare(`SELECT COUNT(*) AS n FROM ${table}`).get();
|
|
2853
|
+
return row?.n ?? 0;
|
|
2854
|
+
}
|
|
2855
|
+
function describeIndexFreshness(dbPath) {
|
|
2856
|
+
if (!existsSync12(dbPath)) {
|
|
2857
|
+
return {
|
|
2858
|
+
status: "info",
|
|
2859
|
+
key: "wiki.index",
|
|
2860
|
+
message: "index: not built yet (run any query command)"
|
|
2861
|
+
};
|
|
2862
|
+
}
|
|
2863
|
+
try {
|
|
2864
|
+
const dbMtime = statSync3(dbPath).mtimeMs;
|
|
2865
|
+
const age = Date.now() - dbMtime;
|
|
2866
|
+
return {
|
|
2867
|
+
status: "info",
|
|
2868
|
+
key: "wiki.index",
|
|
2869
|
+
message: `index: rebuilt ${formatDuration(age)} ago`
|
|
2870
|
+
};
|
|
2871
|
+
} catch {
|
|
2872
|
+
return {
|
|
2873
|
+
status: "info",
|
|
2874
|
+
key: "wiki.index",
|
|
2875
|
+
message: "index: present"
|
|
2876
|
+
};
|
|
2877
|
+
}
|
|
2878
|
+
}
|
|
2879
|
+
function describeLastCapture(almanacDir, nowFn) {
|
|
2880
|
+
if (!existsSync12(almanacDir)) {
|
|
2881
|
+
return {
|
|
2882
|
+
status: "info",
|
|
2883
|
+
key: "wiki.capture",
|
|
2884
|
+
message: "last capture: never"
|
|
2885
|
+
};
|
|
2886
|
+
}
|
|
2887
|
+
let entries;
|
|
2888
|
+
try {
|
|
2889
|
+
entries = readdirSync(almanacDir);
|
|
2890
|
+
} catch {
|
|
2891
|
+
return {
|
|
2892
|
+
status: "info",
|
|
2893
|
+
key: "wiki.capture",
|
|
2894
|
+
message: "last capture: unknown"
|
|
2895
|
+
};
|
|
2896
|
+
}
|
|
2897
|
+
const captures = entries.filter((e) => e.startsWith(".capture-") && e.endsWith(".log")).map((e) => {
|
|
2898
|
+
try {
|
|
2899
|
+
return {
|
|
2900
|
+
name: e,
|
|
2901
|
+
mtime: statSync3(path4.join(almanacDir, e)).mtimeMs
|
|
2902
|
+
};
|
|
2903
|
+
} catch {
|
|
2904
|
+
return null;
|
|
2905
|
+
}
|
|
2906
|
+
}).filter((e) => e !== null);
|
|
2907
|
+
if (captures.length === 0) {
|
|
2908
|
+
return {
|
|
2909
|
+
status: "info",
|
|
2910
|
+
key: "wiki.capture",
|
|
2911
|
+
message: "last capture: never"
|
|
2912
|
+
};
|
|
2913
|
+
}
|
|
2914
|
+
captures.sort((a, b) => b.mtime - a.mtime);
|
|
2915
|
+
const latest = captures[0];
|
|
2916
|
+
const now = (nowFn?.() ?? /* @__PURE__ */ new Date()).getTime();
|
|
2917
|
+
const age = now - latest.mtime;
|
|
2918
|
+
return {
|
|
2919
|
+
status: "info",
|
|
2920
|
+
key: "wiki.capture",
|
|
2921
|
+
message: `last capture: ${formatDuration(age)} ago (${latest.name})`
|
|
2922
|
+
};
|
|
2923
|
+
}
|
|
2924
|
+
var req = createRequire3(import.meta.url);
|
|
2925
|
+
function countHealthProblems(jsonStdout) {
|
|
2926
|
+
try {
|
|
2927
|
+
const report = JSON.parse(jsonStdout);
|
|
2928
|
+
let total = 0;
|
|
2929
|
+
for (const arr of Object.values(report)) {
|
|
2930
|
+
if (Array.isArray(arr)) total += arr.length;
|
|
2931
|
+
}
|
|
2932
|
+
return total;
|
|
2933
|
+
} catch {
|
|
2934
|
+
return 0;
|
|
2935
|
+
}
|
|
2936
|
+
}
|
|
2937
|
+
function detectInstallPath() {
|
|
2938
|
+
try {
|
|
2939
|
+
const entry = req.resolve("codealmanac");
|
|
2940
|
+
return path4.dirname(path4.dirname(entry));
|
|
2941
|
+
} catch {
|
|
2942
|
+
return null;
|
|
2943
|
+
}
|
|
2944
|
+
}
|
|
2945
|
+
function probeBetterSqlite3() {
|
|
2946
|
+
try {
|
|
2947
|
+
const Database2 = req("better-sqlite3");
|
|
2948
|
+
const db = new Database2(":memory:");
|
|
2949
|
+
db.close();
|
|
2950
|
+
return { ok: true, summary: "native binding loads cleanly" };
|
|
2951
|
+
} catch (err) {
|
|
2952
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
2953
|
+
const firstLine = msg.split("\n")[0] ?? msg;
|
|
2954
|
+
return { ok: false, summary: firstLine };
|
|
2955
|
+
}
|
|
2956
|
+
}
|
|
2957
|
+
async function safeCheckAuth2(spawnCli) {
|
|
2958
|
+
try {
|
|
2959
|
+
return await checkClaudeAuth(spawnCli);
|
|
2960
|
+
} catch {
|
|
2961
|
+
return { loggedIn: false };
|
|
2962
|
+
}
|
|
2963
|
+
}
|
|
2964
|
+
function readPackageVersion() {
|
|
2965
|
+
try {
|
|
2966
|
+
const pkg = req("../../package.json");
|
|
2967
|
+
if (typeof pkg.version === "string" && pkg.version.length > 0) {
|
|
2968
|
+
return pkg.version;
|
|
2969
|
+
}
|
|
2970
|
+
} catch {
|
|
2971
|
+
}
|
|
2972
|
+
try {
|
|
2973
|
+
const pkg = req("../package.json");
|
|
2974
|
+
if (typeof pkg.version === "string" && pkg.version.length > 0) {
|
|
2975
|
+
return pkg.version;
|
|
2976
|
+
}
|
|
2977
|
+
} catch {
|
|
2978
|
+
}
|
|
2979
|
+
return null;
|
|
2980
|
+
}
|
|
2981
|
+
function formatReport2(report, options) {
|
|
2982
|
+
const color = options.stdout === void 0 && process.stdout.isTTY === true;
|
|
2983
|
+
const lines = [];
|
|
2984
|
+
lines.push(`codealmanac v${report.version}`);
|
|
2985
|
+
lines.push("");
|
|
2986
|
+
if (report.install.length > 0) {
|
|
2987
|
+
lines.push(color ? `${BOLD2}## Install${RST2}` : "## Install");
|
|
2988
|
+
for (const c of report.install) {
|
|
2989
|
+
lines.push(formatCheck(c, color));
|
|
2990
|
+
}
|
|
2991
|
+
lines.push("");
|
|
2992
|
+
}
|
|
2993
|
+
if (report.wiki.length > 0) {
|
|
2994
|
+
lines.push(color ? `${BOLD2}## Current wiki${RST2}` : "## Current wiki");
|
|
2995
|
+
for (const c of report.wiki) {
|
|
2996
|
+
lines.push(formatCheck(c, color));
|
|
2997
|
+
}
|
|
2998
|
+
lines.push("");
|
|
2999
|
+
}
|
|
3000
|
+
return `${lines.join("\n")}
|
|
3001
|
+
`;
|
|
3002
|
+
}
|
|
3003
|
+
function formatCheck(c, color) {
|
|
3004
|
+
const { icon, tint } = iconFor(c.status, color);
|
|
3005
|
+
const head = ` ${tint}${icon}${color ? RST2 : ""} ${c.message}`;
|
|
3006
|
+
if (c.fix === void 0) return head;
|
|
3007
|
+
const fixLine = color ? ` ${DIM2}${c.fix}${RST2}` : ` ${c.fix}`;
|
|
3008
|
+
return `${head}
|
|
3009
|
+
${fixLine}`;
|
|
3010
|
+
}
|
|
3011
|
+
function iconFor(status, color) {
|
|
3012
|
+
switch (status) {
|
|
3013
|
+
case "ok":
|
|
3014
|
+
return { icon: "\u2713", tint: color ? GREEN : "" };
|
|
3015
|
+
case "problem":
|
|
3016
|
+
return { icon: "\u2717", tint: color ? RED : "" };
|
|
3017
|
+
case "info":
|
|
3018
|
+
return { icon: "\u25C7", tint: color ? BLUE2 : "" };
|
|
3019
|
+
}
|
|
3020
|
+
}
|
|
3021
|
+
function formatDuration(ms) {
|
|
3022
|
+
if (ms < 0) return "just now";
|
|
3023
|
+
const seconds = Math.floor(ms / 1e3);
|
|
3024
|
+
if (seconds < 60) return `${seconds}s`;
|
|
3025
|
+
const minutes = Math.floor(seconds / 60);
|
|
3026
|
+
if (minutes < 60) return `${minutes}m`;
|
|
3027
|
+
const hours = Math.floor(minutes / 60);
|
|
3028
|
+
if (hours < 48) return `${hours}h`;
|
|
3029
|
+
const days = Math.floor(hours / 24);
|
|
3030
|
+
return `${days}d`;
|
|
3031
|
+
}
|
|
3032
|
+
|
|
3033
|
+
// src/commands/list.ts
|
|
3034
|
+
import { existsSync as existsSync13 } from "fs";
|
|
3035
|
+
async function listWikis(options) {
|
|
3036
|
+
if (options.drop !== void 0) {
|
|
3037
|
+
return handleDrop(options.drop);
|
|
3038
|
+
}
|
|
3039
|
+
const entries = await readRegistry();
|
|
3040
|
+
const reachable = entries.filter((e) => isReachable(e));
|
|
3041
|
+
if (options.json === true) {
|
|
3042
|
+
return { stdout: `${JSON.stringify(reachable, null, 2)}
|
|
3043
|
+
`, exitCode: 0 };
|
|
3044
|
+
}
|
|
3045
|
+
return { stdout: formatPretty(reachable), exitCode: 0 };
|
|
3046
|
+
}
|
|
3047
|
+
async function handleDrop(name) {
|
|
3048
|
+
const removed = await dropEntry(name);
|
|
3049
|
+
if (removed === null) {
|
|
3050
|
+
return {
|
|
3051
|
+
stdout: `no registry entry named "${name}"
|
|
3052
|
+
`,
|
|
3053
|
+
exitCode: 1
|
|
3054
|
+
};
|
|
3055
|
+
}
|
|
3056
|
+
return {
|
|
3057
|
+
stdout: `removed "${removed.name}" (${removed.path})
|
|
3058
|
+
`,
|
|
3059
|
+
exitCode: 0
|
|
3060
|
+
};
|
|
3061
|
+
}
|
|
3062
|
+
function isReachable(entry) {
|
|
3063
|
+
if (entry.path.length === 0) return false;
|
|
3064
|
+
return existsSync13(entry.path);
|
|
3065
|
+
}
|
|
3066
|
+
function formatPretty(entries) {
|
|
3067
|
+
if (entries.length === 0) {
|
|
3068
|
+
return "no wikis registered. run `almanac bootstrap` in a repo to create one.\n";
|
|
3069
|
+
}
|
|
3070
|
+
const nameWidth = Math.min(
|
|
3071
|
+
30,
|
|
3072
|
+
entries.reduce((w, e) => Math.max(w, e.name.length), 0)
|
|
3073
|
+
);
|
|
3074
|
+
const lines = [];
|
|
3075
|
+
for (const entry of entries) {
|
|
3076
|
+
const name = entry.name.padEnd(nameWidth);
|
|
3077
|
+
const desc = entry.description.length > 0 ? entry.description : "\u2014";
|
|
3078
|
+
lines.push(`${name} ${desc}`);
|
|
3079
|
+
lines.push(`${" ".repeat(nameWidth)} ${entry.path}`);
|
|
3080
|
+
}
|
|
3081
|
+
return `${lines.join("\n")}
|
|
3082
|
+
`;
|
|
3083
|
+
}
|
|
3084
|
+
|
|
3085
|
+
// src/commands/reindex.ts
|
|
3086
|
+
async function runReindex(options) {
|
|
3087
|
+
const repoRoot = await resolveWikiRoot({
|
|
3088
|
+
cwd: options.cwd,
|
|
3089
|
+
wiki: options.wiki
|
|
3090
|
+
});
|
|
3091
|
+
const result = await runIndexer({ repoRoot });
|
|
3092
|
+
const skipSuffix = result.filesSkipped > 0 ? `; ${result.filesSkipped} skipped` : "";
|
|
3093
|
+
const stdout = `reindexed: ${result.pagesIndexed} page${result.pagesIndexed === 1 ? "" : "s"} (${result.changed} updated, ${result.removed} removed${skipSuffix})
|
|
3094
|
+
`;
|
|
3095
|
+
return { result, stdout, exitCode: 0 };
|
|
3096
|
+
}
|
|
3097
|
+
|
|
3098
|
+
// src/commands/search.ts
|
|
3099
|
+
import { join as join9 } from "path";
|
|
3100
|
+
async function runSearch(options) {
|
|
3101
|
+
const repoRoot = await resolveWikiRoot({
|
|
3102
|
+
cwd: options.cwd,
|
|
3103
|
+
wiki: options.wiki
|
|
3104
|
+
});
|
|
3105
|
+
await ensureFreshIndex({ repoRoot });
|
|
3106
|
+
const dbPath = join9(repoRoot, ".almanac", "index.db");
|
|
3107
|
+
const db = openIndex(dbPath);
|
|
3108
|
+
try {
|
|
3109
|
+
const rows = executeQuery(db, options);
|
|
3110
|
+
const limited = options.limit !== void 0 && options.limit >= 0 ? rows.slice(0, options.limit) : rows;
|
|
3111
|
+
const stdout = formatResults(limited, options);
|
|
3112
|
+
const stderr = buildStderr(limited, options);
|
|
3113
|
+
return { stdout, stderr, exitCode: 0 };
|
|
3114
|
+
} finally {
|
|
3115
|
+
db.close();
|
|
3116
|
+
}
|
|
3117
|
+
}
|
|
3118
|
+
function executeQuery(db, options) {
|
|
3119
|
+
const whereClauses = [];
|
|
3120
|
+
const params = [];
|
|
3121
|
+
if (options.archived === true) {
|
|
3122
|
+
whereClauses.push("p.archived_at IS NOT NULL");
|
|
3123
|
+
} else if (options.includeArchive !== true) {
|
|
3124
|
+
whereClauses.push("p.archived_at IS NULL");
|
|
3125
|
+
}
|
|
3126
|
+
for (const rawTopic of options.topics) {
|
|
3127
|
+
const topicSlug = slugForTopic(rawTopic);
|
|
3128
|
+
if (topicSlug.length === 0) continue;
|
|
3129
|
+
whereClauses.push(
|
|
3130
|
+
"EXISTS (SELECT 1 FROM page_topics pt WHERE pt.page_slug = p.slug AND pt.topic_slug = ?)"
|
|
3131
|
+
);
|
|
3132
|
+
params.push(topicSlug);
|
|
3133
|
+
}
|
|
3134
|
+
if (options.mentions !== void 0 && options.mentions.length > 0) {
|
|
3135
|
+
const isDir = looksLikeDir(options.mentions);
|
|
3136
|
+
const norm = normalizePath(options.mentions, isDir);
|
|
3137
|
+
if (isDir) {
|
|
3138
|
+
const escaped = escapeGlobMeta(norm);
|
|
3139
|
+
whereClauses.push(
|
|
3140
|
+
`EXISTS (
|
|
3141
|
+
SELECT 1 FROM file_refs r
|
|
3142
|
+
WHERE r.page_slug = p.slug
|
|
3143
|
+
AND (r.path = ? OR r.path GLOB ?)
|
|
3144
|
+
)`
|
|
3145
|
+
);
|
|
3146
|
+
params.push(norm, `${escaped}*`);
|
|
3147
|
+
} else {
|
|
3148
|
+
const prefixes = parentFolderPrefixes(norm);
|
|
3149
|
+
if (prefixes.length === 0) {
|
|
3150
|
+
whereClauses.push(
|
|
3151
|
+
`EXISTS (
|
|
3152
|
+
SELECT 1 FROM file_refs r
|
|
3153
|
+
WHERE r.page_slug = p.slug AND r.path = ?
|
|
3154
|
+
)`
|
|
3155
|
+
);
|
|
3156
|
+
params.push(norm);
|
|
3157
|
+
} else {
|
|
3158
|
+
const placeholders = prefixes.map(() => "?").join(", ");
|
|
3159
|
+
whereClauses.push(
|
|
3160
|
+
`EXISTS (
|
|
3161
|
+
SELECT 1 FROM file_refs r
|
|
3162
|
+
WHERE r.page_slug = p.slug
|
|
3163
|
+
AND (
|
|
3164
|
+
r.path = ?
|
|
3165
|
+
OR (r.is_dir = 1 AND r.path IN (${placeholders}))
|
|
3166
|
+
)
|
|
3167
|
+
)`
|
|
3168
|
+
);
|
|
3169
|
+
params.push(norm, ...prefixes);
|
|
3170
|
+
}
|
|
3171
|
+
}
|
|
3172
|
+
}
|
|
3173
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
3174
|
+
if (options.since !== void 0) {
|
|
3175
|
+
const seconds = parseDuration(options.since);
|
|
3176
|
+
whereClauses.push("p.updated_at >= ?");
|
|
3177
|
+
params.push(now - seconds);
|
|
2655
3178
|
}
|
|
2656
|
-
|
|
2657
|
-
|
|
2658
|
-
|
|
2659
|
-
|
|
3179
|
+
if (options.stale !== void 0) {
|
|
3180
|
+
const seconds = parseDuration(options.stale);
|
|
3181
|
+
whereClauses.push("p.updated_at < ?");
|
|
3182
|
+
params.push(now - seconds);
|
|
2660
3183
|
}
|
|
2661
|
-
|
|
2662
|
-
|
|
2663
|
-
|
|
2664
|
-
|
|
2665
|
-
const srcRef = path3.join(options.guidesDir, "reference.md");
|
|
2666
|
-
if (!existsSync12(srcMini)) {
|
|
2667
|
-
throw new Error(`missing bundled guide: ${srcMini}`);
|
|
3184
|
+
if (options.orphan === true) {
|
|
3185
|
+
whereClauses.push(
|
|
3186
|
+
"NOT EXISTS (SELECT 1 FROM page_topics pt WHERE pt.page_slug = p.slug)"
|
|
3187
|
+
);
|
|
2668
3188
|
}
|
|
2669
|
-
|
|
2670
|
-
|
|
3189
|
+
let sql;
|
|
3190
|
+
if (options.query !== void 0 && options.query.trim().length > 0) {
|
|
3191
|
+
const ftsExpr = buildFtsQuery(options.query);
|
|
3192
|
+
sql = `
|
|
3193
|
+
SELECT p.slug, p.title, p.updated_at, p.archived_at, p.superseded_by
|
|
3194
|
+
FROM pages p
|
|
3195
|
+
JOIN fts_pages f ON f.slug = p.slug
|
|
3196
|
+
WHERE fts_pages MATCH ?
|
|
3197
|
+
${whereClauses.length > 0 ? `AND ${whereClauses.join(" AND ")}` : ""}
|
|
3198
|
+
ORDER BY f.rank ASC, p.updated_at DESC, p.slug ASC
|
|
3199
|
+
`;
|
|
3200
|
+
params.unshift(ftsExpr);
|
|
3201
|
+
} else {
|
|
3202
|
+
sql = buildSql(whereClauses);
|
|
2671
3203
|
}
|
|
2672
|
-
const
|
|
2673
|
-
const
|
|
2674
|
-
|
|
2675
|
-
|
|
2676
|
-
const
|
|
2677
|
-
|
|
2678
|
-
|
|
2679
|
-
|
|
2680
|
-
|
|
2681
|
-
|
|
2682
|
-
|
|
3204
|
+
const rows = db.prepare(sql).all(...params);
|
|
3205
|
+
const topicStmt = db.prepare(
|
|
3206
|
+
"SELECT topic_slug FROM page_topics WHERE page_slug = ? ORDER BY topic_slug"
|
|
3207
|
+
);
|
|
3208
|
+
const out = rows.map((row) => ({
|
|
3209
|
+
slug: row.slug,
|
|
3210
|
+
title: row.title,
|
|
3211
|
+
updated_at: row.updated_at,
|
|
3212
|
+
archived_at: row.archived_at,
|
|
3213
|
+
superseded_by: row.superseded_by,
|
|
3214
|
+
topics: topicStmt.all(row.slug).map((t) => t.topic_slug)
|
|
3215
|
+
}));
|
|
3216
|
+
return out;
|
|
2683
3217
|
}
|
|
2684
|
-
|
|
2685
|
-
const
|
|
2686
|
-
|
|
2687
|
-
|
|
2688
|
-
|
|
2689
|
-
|
|
2690
|
-
|
|
2691
|
-
|
|
2692
|
-
}
|
|
2693
|
-
await copyFile(src, dest);
|
|
2694
|
-
return true;
|
|
3218
|
+
function buildSql(whereClauses) {
|
|
3219
|
+
const where = whereClauses.length > 0 ? `WHERE ${whereClauses.join(" AND ")}` : "";
|
|
3220
|
+
return `
|
|
3221
|
+
SELECT p.slug, p.title, p.updated_at, p.archived_at, p.superseded_by
|
|
3222
|
+
FROM pages p
|
|
3223
|
+
${where}
|
|
3224
|
+
ORDER BY p.updated_at DESC, p.slug ASC
|
|
3225
|
+
`;
|
|
2695
3226
|
}
|
|
2696
|
-
|
|
2697
|
-
|
|
2698
|
-
|
|
2699
|
-
|
|
2700
|
-
|
|
3227
|
+
function buildFtsQuery(raw) {
|
|
3228
|
+
const trimmed = raw.trim();
|
|
3229
|
+
if (trimmed.length >= 2 && trimmed.startsWith('"') && trimmed.endsWith('"')) {
|
|
3230
|
+
const inner = trimmed.slice(1, -1).toLowerCase().replace(/[^a-z0-9]+/g, " ").trim();
|
|
3231
|
+
if (inner.length === 0) return '""';
|
|
3232
|
+
return `"${inner}"`;
|
|
2701
3233
|
}
|
|
2702
|
-
|
|
2703
|
-
|
|
2704
|
-
|
|
2705
|
-
`;
|
|
2706
|
-
await writeFile5(claudeMdPath, body, "utf8");
|
|
2707
|
-
return true;
|
|
3234
|
+
const tokens = trimmed.toLowerCase().split(/[^a-z0-9]+/).filter((t) => t.length > 0);
|
|
3235
|
+
if (tokens.length === 0) return '""';
|
|
3236
|
+
return tokens.map((t) => `${t}*`).join(" AND ");
|
|
2708
3237
|
}
|
|
2709
|
-
function
|
|
2710
|
-
|
|
2711
|
-
return lines.includes(IMPORT_LINE);
|
|
3238
|
+
function slugForTopic(raw) {
|
|
3239
|
+
return raw.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
2712
3240
|
}
|
|
2713
|
-
function
|
|
2714
|
-
|
|
2715
|
-
|
|
2716
|
-
|
|
2717
|
-
|
|
2718
|
-
|
|
2719
|
-
|
|
2720
|
-
|
|
2721
|
-
|
|
2722
|
-
|
|
2723
|
-
process.stdin.pause();
|
|
2724
|
-
const answer = buf.slice(0, nl).trim().toLowerCase();
|
|
2725
|
-
const accepted = answer.length === 0 ? defaultYes : answer === "y" || answer === "yes";
|
|
2726
|
-
resolve2(accepted ? "install" : "skip");
|
|
2727
|
-
};
|
|
2728
|
-
process.stdin.resume();
|
|
2729
|
-
process.stdin.on("data", onData);
|
|
2730
|
-
});
|
|
3241
|
+
function parentFolderPrefixes(filePath) {
|
|
3242
|
+
const out = [];
|
|
3243
|
+
let cursor = 0;
|
|
3244
|
+
while (true) {
|
|
3245
|
+
const next = filePath.indexOf("/", cursor);
|
|
3246
|
+
if (next === -1) break;
|
|
3247
|
+
out.push(filePath.slice(0, next + 1));
|
|
3248
|
+
cursor = next + 1;
|
|
3249
|
+
}
|
|
3250
|
+
return out;
|
|
2731
3251
|
}
|
|
2732
|
-
function
|
|
2733
|
-
|
|
2734
|
-
|
|
2735
|
-
|
|
2736
|
-
|
|
2737
|
-
return
|
|
3252
|
+
function escapeGlobMeta(s) {
|
|
3253
|
+
return s.replace(/[\*\?\[]/g, (ch) => `[${ch}]`);
|
|
3254
|
+
}
|
|
3255
|
+
function formatResults(rows, options) {
|
|
3256
|
+
if (options.json === true) {
|
|
3257
|
+
return `${JSON.stringify(rows, null, 2)}
|
|
3258
|
+
`;
|
|
3259
|
+
}
|
|
3260
|
+
if (rows.length === 0) return "";
|
|
3261
|
+
return `${rows.map((r) => r.slug).join("\n")}
|
|
2738
3262
|
`;
|
|
2739
|
-
};
|
|
2740
|
-
const empty = row("");
|
|
2741
|
-
out.write(` ${BLUE_DIM}\u256D${"\u2500".repeat(innerW)}\u256E${RST}
|
|
2742
|
-
`);
|
|
2743
|
-
out.write(empty);
|
|
2744
|
-
out.write(row(` ${WHITE_BOLD}Next steps${RST}`));
|
|
2745
|
-
out.write(empty);
|
|
2746
|
-
out.write(
|
|
2747
|
-
row(` ${BLUE}1.${RST} ${BOLD}cd${RST} into a repo you want to document`)
|
|
2748
|
-
);
|
|
2749
|
-
out.write(
|
|
2750
|
-
row(
|
|
2751
|
-
` ${BLUE}2.${RST} ${BOLD}almanac bootstrap${RST} ${DIM}# scaffold the wiki${RST}`
|
|
2752
|
-
)
|
|
2753
|
-
);
|
|
2754
|
-
out.write(
|
|
2755
|
-
row(
|
|
2756
|
-
` ${BLUE}3.${RST} Work normally \u2014 capture runs on session end`
|
|
2757
|
-
)
|
|
2758
|
-
);
|
|
2759
|
-
out.write(empty);
|
|
2760
|
-
out.write(` ${BLUE_DIM}\u2570${"\u2500".repeat(innerW)}\u256F${RST}
|
|
2761
|
-
|
|
2762
|
-
`);
|
|
2763
3263
|
}
|
|
2764
|
-
function
|
|
2765
|
-
|
|
2766
|
-
|
|
2767
|
-
|
|
2768
|
-
// dist layout
|
|
2769
|
-
path3.resolve(here, "..", "..", "guides"),
|
|
2770
|
-
// src layout
|
|
2771
|
-
path3.resolve(here, "..", "..", "..", "guides")
|
|
2772
|
-
];
|
|
2773
|
-
for (const dir of candidates) {
|
|
2774
|
-
if (looksLikeGuidesDir(dir)) return dir;
|
|
3264
|
+
function buildStderr(rows, options) {
|
|
3265
|
+
if (options.json === true) return "";
|
|
3266
|
+
if (rows.length === 0) {
|
|
3267
|
+
return "# 0 results\n";
|
|
2775
3268
|
}
|
|
2776
|
-
|
|
2777
|
-
|
|
2778
|
-
|
|
2779
|
-
|
|
2780
|
-
if (looksLikeGuidesDir(guides)) return guides;
|
|
2781
|
-
} catch {
|
|
3269
|
+
if (options.limit !== void 0) return "";
|
|
3270
|
+
if (rows.length > 50) {
|
|
3271
|
+
return `almanac: ${rows.length} results \u2014 consider --limit or a narrower query
|
|
3272
|
+
`;
|
|
2782
3273
|
}
|
|
2783
|
-
|
|
2784
|
-
"could not locate bundled guides/ directory. Tried:\n" + candidates.map((c) => ` - ${c}`).join("\n")
|
|
2785
|
-
);
|
|
2786
|
-
}
|
|
2787
|
-
function looksLikeGuidesDir(dir) {
|
|
2788
|
-
return existsSync12(path3.join(dir, "mini.md"));
|
|
3274
|
+
return "";
|
|
2789
3275
|
}
|
|
2790
3276
|
|
|
2791
3277
|
// src/commands/show.ts
|
|
2792
|
-
import { readFile as
|
|
3278
|
+
import { readFile as readFile11 } from "fs/promises";
|
|
2793
3279
|
import { join as join10 } from "path";
|
|
2794
3280
|
async function runShow(options) {
|
|
2795
3281
|
const repoRoot = await resolveWikiRoot({
|
|
@@ -2856,7 +3342,7 @@ async function fetchRecord(db, slug) {
|
|
|
2856
3342
|
).all(slug).map((r) => r.slug);
|
|
2857
3343
|
let body = "";
|
|
2858
3344
|
try {
|
|
2859
|
-
body = stripFrontmatter(await
|
|
3345
|
+
body = stripFrontmatter(await readFile11(pageRow.file_path, "utf8"));
|
|
2860
3346
|
} catch {
|
|
2861
3347
|
}
|
|
2862
3348
|
return {
|
|
@@ -2907,7 +3393,9 @@ function selectedFields(options) {
|
|
|
2907
3393
|
}
|
|
2908
3394
|
function formatRecord(rec, options) {
|
|
2909
3395
|
if (options.raw === true) {
|
|
2910
|
-
|
|
3396
|
+
if (rec.body.length === 0) return "";
|
|
3397
|
+
return rec.body.endsWith("\n") ? rec.body : `${rec.body}
|
|
3398
|
+
`;
|
|
2911
3399
|
}
|
|
2912
3400
|
const fields = selectedFields(options);
|
|
2913
3401
|
if (fields.length > 0) {
|
|
@@ -2975,7 +3463,7 @@ function labeledFields(rec, fields) {
|
|
|
2975
3463
|
for (const f of fields) {
|
|
2976
3464
|
parts.push(labeledSection(rec, f));
|
|
2977
3465
|
}
|
|
2978
|
-
return parts.join("\n")
|
|
3466
|
+
return parts.join("\n");
|
|
2979
3467
|
}
|
|
2980
3468
|
function labeledSection(rec, field) {
|
|
2981
3469
|
switch (field) {
|
|
@@ -3075,10 +3563,10 @@ function metadataHeader(rec) {
|
|
|
3075
3563
|
}
|
|
3076
3564
|
function stripFrontmatter(src) {
|
|
3077
3565
|
if (!src.startsWith("---\n") && !src.startsWith("---\r\n")) return src;
|
|
3078
|
-
const
|
|
3079
|
-
const endMatch =
|
|
3566
|
+
const afterOpen = src.replace(/^---\r?\n/, "");
|
|
3567
|
+
const endMatch = afterOpen.match(/^---[ \t]*\r?\n/m);
|
|
3080
3568
|
if (endMatch === null || endMatch.index === void 0) return src;
|
|
3081
|
-
return
|
|
3569
|
+
return afterOpen.slice(endMatch.index + endMatch[0].length);
|
|
3082
3570
|
}
|
|
3083
3571
|
function firstParagraph(body) {
|
|
3084
3572
|
let src = body.trimStart();
|
|
@@ -3101,10 +3589,10 @@ function collectSlugs(options) {
|
|
|
3101
3589
|
}
|
|
3102
3590
|
|
|
3103
3591
|
// src/topics/frontmatterRewrite.ts
|
|
3104
|
-
import { readFile as
|
|
3592
|
+
import { readFile as readFile12, rename as rename4, writeFile as writeFile6 } from "fs/promises";
|
|
3105
3593
|
import yaml3 from "js-yaml";
|
|
3106
3594
|
async function rewritePageTopics(filePath, transform) {
|
|
3107
|
-
const raw = await
|
|
3595
|
+
const raw = await readFile12(filePath, "utf8");
|
|
3108
3596
|
const { before, after, output, changed } = applyTopicsTransform(
|
|
3109
3597
|
raw,
|
|
3110
3598
|
transform
|
|
@@ -3482,7 +3970,7 @@ async function runUntag(options) {
|
|
|
3482
3970
|
}
|
|
3483
3971
|
|
|
3484
3972
|
// src/commands/topics.ts
|
|
3485
|
-
import { readFile as
|
|
3973
|
+
import { readFile as readFile13 } from "fs/promises";
|
|
3486
3974
|
import { join as join12 } from "path";
|
|
3487
3975
|
import fg3 from "fast-glob";
|
|
3488
3976
|
async function runTopicsList(options) {
|
|
@@ -3963,7 +4451,7 @@ async function rewriteTopicOnPages(repoRoot, transform) {
|
|
|
3963
4451
|
});
|
|
3964
4452
|
let changed = 0;
|
|
3965
4453
|
for (const filePath of files) {
|
|
3966
|
-
const raw = await
|
|
4454
|
+
const raw = await readFile13(filePath, "utf8");
|
|
3967
4455
|
const applied = applyTopicsTransform(raw, transform);
|
|
3968
4456
|
if (!applied.changed) continue;
|
|
3969
4457
|
await rewritePageTopics(filePath, transform);
|
|
@@ -3973,18 +4461,18 @@ async function rewriteTopicOnPages(repoRoot, transform) {
|
|
|
3973
4461
|
}
|
|
3974
4462
|
|
|
3975
4463
|
// src/commands/uninstall.ts
|
|
3976
|
-
import { existsSync as
|
|
3977
|
-
import { readFile as
|
|
3978
|
-
import { homedir as
|
|
3979
|
-
import
|
|
3980
|
-
var
|
|
3981
|
-
var
|
|
3982
|
-
var
|
|
4464
|
+
import { existsSync as existsSync14 } from "fs";
|
|
4465
|
+
import { readFile as readFile14, rm, writeFile as writeFile7 } from "fs/promises";
|
|
4466
|
+
import { homedir as homedir6 } from "os";
|
|
4467
|
+
import path5 from "path";
|
|
4468
|
+
var BLUE3 = "\x1B[38;5;75m";
|
|
4469
|
+
var DIM3 = "\x1B[2m";
|
|
4470
|
+
var RST3 = "\x1B[0m";
|
|
3983
4471
|
async function runUninstall(options = {}) {
|
|
3984
4472
|
const out = options.stdout ?? process.stdout;
|
|
3985
4473
|
const isTTY = options.isTTY ?? process.stdin.isTTY === true;
|
|
3986
4474
|
const interactive = isTTY && options.yes !== true;
|
|
3987
|
-
const claudeDir = options.claudeDir ??
|
|
4475
|
+
const claudeDir = options.claudeDir ?? path5.join(homedir6(), ".claude");
|
|
3988
4476
|
out.write("\n");
|
|
3989
4477
|
let removeHook = true;
|
|
3990
4478
|
if (options.keepHook === true) {
|
|
@@ -4004,10 +4492,10 @@ async function runUninstall(options = {}) {
|
|
|
4004
4492
|
if (res.exitCode !== 0) {
|
|
4005
4493
|
return { stdout: "", stderr: res.stderr, exitCode: res.exitCode };
|
|
4006
4494
|
}
|
|
4007
|
-
out.write(` ${
|
|
4495
|
+
out.write(` ${BLUE3}\u25C7${RST3} ${res.stdout.trim()}
|
|
4008
4496
|
`);
|
|
4009
4497
|
} else {
|
|
4010
|
-
out.write(` ${
|
|
4498
|
+
out.write(` ${DIM3}\u25CB Hook kept${RST3}
|
|
4011
4499
|
`);
|
|
4012
4500
|
}
|
|
4013
4501
|
let removeGuides = true;
|
|
@@ -4024,38 +4512,38 @@ async function runUninstall(options = {}) {
|
|
|
4024
4512
|
const summary = await removeGuideFiles(claudeDir);
|
|
4025
4513
|
if (summary.anyChanges) {
|
|
4026
4514
|
out.write(
|
|
4027
|
-
` ${
|
|
4515
|
+
` ${BLUE3}\u25C7${RST3} Guides removed (${summary.filesTouched.join(", ")})
|
|
4028
4516
|
`
|
|
4029
4517
|
);
|
|
4030
4518
|
} else {
|
|
4031
|
-
out.write(` ${
|
|
4519
|
+
out.write(` ${DIM3}\u25CB Guides not installed${RST3}
|
|
4032
4520
|
`);
|
|
4033
4521
|
}
|
|
4034
4522
|
} else {
|
|
4035
|
-
out.write(` ${
|
|
4523
|
+
out.write(` ${DIM3}\u25CB Guides kept${RST3}
|
|
4036
4524
|
`);
|
|
4037
4525
|
}
|
|
4038
4526
|
out.write(`
|
|
4039
|
-
${
|
|
4527
|
+
${BLUE3}\u25C7${RST3} ${BLUE3}Uninstall complete${RST3}
|
|
4040
4528
|
|
|
4041
4529
|
`);
|
|
4042
4530
|
return { stdout: "", stderr: "", exitCode: 0 };
|
|
4043
4531
|
}
|
|
4044
4532
|
async function removeGuideFiles(claudeDir) {
|
|
4045
4533
|
const touched = [];
|
|
4046
|
-
const mini =
|
|
4047
|
-
const ref =
|
|
4048
|
-
const claudeMd =
|
|
4049
|
-
if (
|
|
4534
|
+
const mini = path5.join(claudeDir, "codealmanac.md");
|
|
4535
|
+
const ref = path5.join(claudeDir, "codealmanac-reference.md");
|
|
4536
|
+
const claudeMd = path5.join(claudeDir, "CLAUDE.md");
|
|
4537
|
+
if (existsSync14(mini)) {
|
|
4050
4538
|
await rm(mini, { force: true });
|
|
4051
4539
|
touched.push("codealmanac.md");
|
|
4052
4540
|
}
|
|
4053
|
-
if (
|
|
4541
|
+
if (existsSync14(ref)) {
|
|
4054
4542
|
await rm(ref, { force: true });
|
|
4055
4543
|
touched.push("codealmanac-reference.md");
|
|
4056
4544
|
}
|
|
4057
|
-
if (
|
|
4058
|
-
const existing = await
|
|
4545
|
+
if (existsSync14(claudeMd)) {
|
|
4546
|
+
const existing = await readFile14(claudeMd, "utf8");
|
|
4059
4547
|
const { changed, body } = removeImportLine(existing);
|
|
4060
4548
|
if (changed) {
|
|
4061
4549
|
if (body.trim().length === 0) {
|
|
@@ -4087,7 +4575,7 @@ function removeImportLine(contents) {
|
|
|
4087
4575
|
function confirm2(out, question, defaultYes) {
|
|
4088
4576
|
return new Promise((resolve2) => {
|
|
4089
4577
|
const hint = defaultYes ? "[Y/n]" : "[y/N]";
|
|
4090
|
-
out.write(` ${
|
|
4578
|
+
out.write(` ${BLUE3}\u25C6${RST3} ${question} ${DIM3}${hint}${RST3} `);
|
|
4091
4579
|
let buf = "";
|
|
4092
4580
|
const onData = (chunk) => {
|
|
4093
4581
|
buf += chunk.toString("utf8");
|
|
@@ -4105,13 +4593,13 @@ function confirm2(out, question, defaultYes) {
|
|
|
4105
4593
|
}
|
|
4106
4594
|
|
|
4107
4595
|
// src/registry/autoregister.ts
|
|
4108
|
-
import { existsSync as
|
|
4596
|
+
import { existsSync as existsSync15 } from "fs";
|
|
4109
4597
|
import { basename as basename5 } from "path";
|
|
4110
4598
|
async function autoRegisterIfNeeded(cwd) {
|
|
4111
4599
|
try {
|
|
4112
4600
|
const repoRoot = findNearestAlmanacDir(cwd);
|
|
4113
4601
|
if (repoRoot === null) return null;
|
|
4114
|
-
if (!
|
|
4602
|
+
if (!existsSync15(repoRoot)) return null;
|
|
4115
4603
|
const entries = await readRegistry();
|
|
4116
4604
|
const existing = entries.find((e) => samePath(e.path, repoRoot));
|
|
4117
4605
|
if (existing !== void 0) return existing;
|
|
@@ -4161,13 +4649,16 @@ async function run(argv) {
|
|
|
4161
4649
|
const program = new Command();
|
|
4162
4650
|
program.name(programName).description(
|
|
4163
4651
|
"codealmanac \u2014 a living wiki for codebases, maintained by AI agents"
|
|
4164
|
-
).version(
|
|
4165
|
-
if (programName === "codealmanac"
|
|
4166
|
-
const
|
|
4167
|
-
if (
|
|
4168
|
-
|
|
4169
|
-
|
|
4170
|
-
|
|
4652
|
+
).version(readPackageVersion2(), "-v, --version", "print version");
|
|
4653
|
+
if (programName === "codealmanac") {
|
|
4654
|
+
const setupInvocation = tryParseSetupShortcut(argv.slice(2));
|
|
4655
|
+
if (setupInvocation !== null) {
|
|
4656
|
+
const result = await runSetup(setupInvocation);
|
|
4657
|
+
if (result.stderr.length > 0) process.stderr.write(result.stderr);
|
|
4658
|
+
if (result.stdout.length > 0) process.stdout.write(result.stdout);
|
|
4659
|
+
if (result.exitCode !== 0) process.exitCode = result.exitCode;
|
|
4660
|
+
return;
|
|
4661
|
+
}
|
|
4171
4662
|
}
|
|
4172
4663
|
program.command("search [query]").description("find pages by text, topic, file mentions, freshness").option(
|
|
4173
4664
|
"--topic <name...>",
|
|
@@ -4176,7 +4667,7 @@ async function run(argv) {
|
|
|
4176
4667
|
[]
|
|
4177
4668
|
).option(
|
|
4178
4669
|
"--mentions <path>",
|
|
4179
|
-
"pages referencing this
|
|
4670
|
+
"pages referencing this path; matches exact file, trailing-slash folders, and any file under a folder prefix"
|
|
4180
4671
|
).option(
|
|
4181
4672
|
"--since <duration>",
|
|
4182
4673
|
"updated within duration, by file mtime (e.g. 2w, 30d)"
|
|
@@ -4452,7 +4943,24 @@ async function run(argv) {
|
|
|
4452
4943
|
emit(result);
|
|
4453
4944
|
}
|
|
4454
4945
|
);
|
|
4455
|
-
program.command("
|
|
4946
|
+
program.command("doctor").description("report on the codealmanac install + current wiki health").option("--json", "emit structured JSON").option("--install-only", "report only on the install (skip wiki checks)").option("--wiki-only", "report only on the current wiki (skip install checks)").action(
|
|
4947
|
+
async (opts) => {
|
|
4948
|
+
const result = await runDoctor({
|
|
4949
|
+
cwd: process.cwd(),
|
|
4950
|
+
json: opts.json,
|
|
4951
|
+
installOnly: opts.installOnly,
|
|
4952
|
+
wikiOnly: opts.wikiOnly
|
|
4953
|
+
});
|
|
4954
|
+
emit(result);
|
|
4955
|
+
}
|
|
4956
|
+
);
|
|
4957
|
+
program.command("uninstall").description("remove the hook + guides + import line").option("-y, --yes", "skip confirmations; remove everything").option(
|
|
4958
|
+
"--keep-hook",
|
|
4959
|
+
"don't remove the SessionEnd hook (guides still prompted unless --yes)"
|
|
4960
|
+
).option(
|
|
4961
|
+
"--keep-guides",
|
|
4962
|
+
"don't remove the guides or CLAUDE.md import (hook still prompted unless --yes)"
|
|
4963
|
+
).action(
|
|
4456
4964
|
async (opts) => {
|
|
4457
4965
|
const result = await runUninstall({
|
|
4458
4966
|
yes: opts.yes,
|
|
@@ -4480,7 +4988,7 @@ var HELP_GROUPS = [
|
|
|
4480
4988
|
},
|
|
4481
4989
|
{
|
|
4482
4990
|
title: "Setup",
|
|
4483
|
-
commands: ["setup", "uninstall"]
|
|
4991
|
+
commands: ["setup", "uninstall", "doctor"]
|
|
4484
4992
|
}
|
|
4485
4993
|
];
|
|
4486
4994
|
function configureGroupedHelp(program) {
|
|
@@ -4581,9 +5089,9 @@ function renderDefault(cmd, helper) {
|
|
|
4581
5089
|
}
|
|
4582
5090
|
return lines.join("\n");
|
|
4583
5091
|
}
|
|
4584
|
-
function
|
|
5092
|
+
function readPackageVersion2() {
|
|
4585
5093
|
try {
|
|
4586
|
-
const require2 =
|
|
5094
|
+
const require2 = createRequire4(import.meta.url);
|
|
4587
5095
|
const pkg = require2("../package.json");
|
|
4588
5096
|
if (typeof pkg.version === "string" && pkg.version.length > 0) {
|
|
4589
5097
|
return pkg.version;
|
|
@@ -4615,6 +5123,26 @@ async function readStdin() {
|
|
|
4615
5123
|
}
|
|
4616
5124
|
return Buffer.concat(chunks).toString("utf8");
|
|
4617
5125
|
}
|
|
5126
|
+
function tryParseSetupShortcut(args) {
|
|
5127
|
+
if (args.length === 0) return {};
|
|
5128
|
+
const opts = {};
|
|
5129
|
+
for (const arg of args) {
|
|
5130
|
+
if (arg === "--yes" || arg === "-y") {
|
|
5131
|
+
opts.yes = true;
|
|
5132
|
+
continue;
|
|
5133
|
+
}
|
|
5134
|
+
if (arg === "--skip-hook") {
|
|
5135
|
+
opts.skipHook = true;
|
|
5136
|
+
continue;
|
|
5137
|
+
}
|
|
5138
|
+
if (arg === "--skip-guides") {
|
|
5139
|
+
opts.skipGuides = true;
|
|
5140
|
+
continue;
|
|
5141
|
+
}
|
|
5142
|
+
return null;
|
|
5143
|
+
}
|
|
5144
|
+
return opts;
|
|
5145
|
+
}
|
|
4618
5146
|
|
|
4619
5147
|
// bin/codealmanac.ts
|
|
4620
5148
|
run(process.argv).catch((err) => {
|