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