skillwiki 0.0.1 → 0.2.0-beta.2

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/dist/cli.js ADDED
@@ -0,0 +1,1350 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/cli.ts
4
+ import { Command } from "commander";
5
+
6
+ // src/utils/output.ts
7
+ function printJson(r) {
8
+ process.stdout.write(JSON.stringify(r) + "\n");
9
+ }
10
+ function printHuman(r) {
11
+ if (r.ok) {
12
+ process.stdout.write(`OK
13
+ ${formatData(r.data)}
14
+ `);
15
+ } else {
16
+ process.stdout.write(`ERR ${r.error}
17
+ ${r.detail !== void 0 ? formatData(r.detail) + "\n" : ""}`);
18
+ }
19
+ }
20
+ function formatData(d) {
21
+ if (d == null) return "";
22
+ if (typeof d === "string") return d;
23
+ return JSON.stringify(d, null, 2);
24
+ }
25
+
26
+ // src/commands/hash.ts
27
+ import { readFile } from "fs/promises";
28
+ import { createHash } from "crypto";
29
+
30
+ // ../shared/src/exit-codes.ts
31
+ var ExitCode = {
32
+ OK: 0,
33
+ FILE_NOT_FOUND: 2,
34
+ MISSING_CLOSING_DELIMITER: 3,
35
+ SCHEME_REJECTED: 4,
36
+ HOST_BLOCKED: 5,
37
+ MALFORMED_URL: 6,
38
+ INVALID_FRONTMATTER: 7,
39
+ SCHEMA_NOT_DETECTED: 8,
40
+ VAULT_PATH_INVALID: 9,
41
+ WRITE_FAILED: 10,
42
+ UNRESOLVED_MARKERS: 11,
43
+ SOURCES_INCONSISTENT: 12,
44
+ PREFLIGHT_FAILED: 13,
45
+ ATOMIC_COPY_FAILED: 14,
46
+ INIT_TARGET_NOT_EMPTY: 15,
47
+ BROKEN_WIKILINKS: 16,
48
+ TAG_NOT_IN_TAXONOMY: 17,
49
+ INDEX_INCOMPLETE: 18,
50
+ STALE_PAGE: 19,
51
+ PAGE_TOO_LARGE: 20,
52
+ LOG_ROTATE_NEEDED: 21,
53
+ LINT_HAS_WARNINGS: 22,
54
+ LINT_HAS_ERRORS: 23,
55
+ ENV_WRITE_CONFLICT: 24,
56
+ NO_VAULT_CONFIGURED: 25
57
+ };
58
+
59
+ // ../shared/src/json-output.ts
60
+ function ok(data) {
61
+ return { ok: true, data };
62
+ }
63
+ function err(error, detail) {
64
+ return detail === void 0 ? { ok: false, error } : { ok: false, error, detail };
65
+ }
66
+
67
+ // ../shared/src/schemas.ts
68
+ import { z } from "zod";
69
+ var isoDate = z.string().refine((s) => {
70
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(s)) return false;
71
+ const d = /* @__PURE__ */ new Date(s + "T00:00:00Z");
72
+ return !Number.isNaN(d.getTime()) && s === d.toISOString().slice(0, 10);
73
+ }, { message: "must be YYYY-MM-DD" });
74
+ var wikilink = z.string().regex(/^\[\[[^\[\]]+\]\]$/, 'must be "[[name]]"');
75
+ var TypedKnowledgeSchema = z.object({
76
+ title: z.string().min(1),
77
+ aliases: z.array(z.string()).optional(),
78
+ created: isoDate,
79
+ updated: isoDate,
80
+ type: z.enum(["entity", "concept", "comparison", "query", "summary"]),
81
+ tags: z.array(z.string()),
82
+ sources: z.array(z.string()).min(1),
83
+ confidence: z.enum(["high", "medium", "low"]).optional(),
84
+ contested: z.boolean().optional(),
85
+ contradictions: z.array(z.string()).optional(),
86
+ provenance: z.enum(["research", "project", "mixed"]).optional(),
87
+ provenance_projects: z.array(wikilink).optional(),
88
+ work_items: z.array(wikilink).optional()
89
+ }).superRefine((v, ctx) => {
90
+ if (v.provenance && v.provenance !== "research" && (!v.provenance_projects || v.provenance_projects.length === 0)) {
91
+ ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["provenance_projects"], message: "required when provenance != research" });
92
+ }
93
+ });
94
+ var sha256Hex = z.string().regex(/^[0-9a-f]{64}$/);
95
+ var RawSourceSchema = z.object({
96
+ title: z.string().min(1),
97
+ source_url: z.string().url().nullable(),
98
+ ingested: isoDate,
99
+ ingested_by: z.enum(["wiki-ingest", "proj-work", "manual"]),
100
+ sha256: sha256Hex,
101
+ project: wikilink.optional(),
102
+ work_item: wikilink.optional(),
103
+ kind: z.enum(["postmortem", "session-log", "meeting-notes", "other"]).optional()
104
+ }).superRefine((v, ctx) => {
105
+ const projectFields = [v.project, v.work_item, v.kind];
106
+ const present = projectFields.filter((x) => x !== void 0).length;
107
+ if (present !== 0 && present !== 3) {
108
+ ctx.addIssue({ code: z.ZodIssueCode.custom, message: "project, work_item, kind must all be set together" });
109
+ }
110
+ });
111
+ var WorkItemSchema = z.object({
112
+ title: z.string().min(1),
113
+ aliases: z.array(z.string()).optional(),
114
+ created: isoDate,
115
+ updated: isoDate,
116
+ started: isoDate,
117
+ completed: isoDate.optional(),
118
+ kind: z.enum(["feature", "issue", "refactor", "decision"]),
119
+ status: z.enum(["planned", "in-progress", "completed", "abandoned"]),
120
+ priority: z.enum(["high", "medium", "low"]),
121
+ project: wikilink,
122
+ owner: wikilink.optional(),
123
+ parent: wikilink.optional(),
124
+ related: z.array(wikilink).optional(),
125
+ sources: z.array(z.string()).optional()
126
+ }).superRefine((v, ctx) => {
127
+ if (v.status === "completed" && !v.completed) {
128
+ ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["completed"], message: "required when status is completed" });
129
+ }
130
+ });
131
+ var CompoundSchema = z.object({
132
+ title: z.string().min(1),
133
+ aliases: z.array(z.string()).optional(),
134
+ created: isoDate,
135
+ updated: isoDate,
136
+ type: z.enum(["lesson", "pattern", "antipattern", "gotcha"]),
137
+ tags: z.array(z.string()),
138
+ confidence: z.enum(["high", "medium", "low"]),
139
+ contradicts: z.array(z.string()).optional(),
140
+ project: wikilink,
141
+ work_items: z.array(wikilink).min(1),
142
+ promoted_to: wikilink.optional(),
143
+ cssclasses: z.array(z.string()).optional()
144
+ });
145
+ function detectSchema(fm) {
146
+ const COMPOUND_TYPES = /* @__PURE__ */ new Set(["lesson", "pattern", "antipattern", "gotcha"]);
147
+ if (typeof fm.type === "string" && COMPOUND_TYPES.has(fm.type) && "project" in fm) return { schema: "compound" };
148
+ if ("type" in fm && "sources" in fm) return { schema: "typed-knowledge" };
149
+ if (typeof fm.sha256 === "string" && "ingested" in fm) return { schema: "raw" };
150
+ if ("kind" in fm && "status" in fm) return { schema: "work-item" };
151
+ return { schema: null };
152
+ }
153
+
154
+ // ../shared/src/blocked-hosts.ts
155
+ var METADATA_HOSTS = [
156
+ "metadata.google.internal",
157
+ "metadata"
158
+ ];
159
+ var METADATA_IPS = /* @__PURE__ */ new Set(["169.254.169.254"]);
160
+ function ipv4ToInt(ip) {
161
+ const parts = ip.split(".");
162
+ if (parts.length !== 4) return null;
163
+ let n = 0;
164
+ for (const p of parts) {
165
+ const v = Number(p);
166
+ if (!Number.isInteger(v) || v < 0 || v > 255) return null;
167
+ n = (n << 8) + v;
168
+ }
169
+ return n >>> 0;
170
+ }
171
+ function inRange(ip, baseStr, prefix) {
172
+ const ipN = ipv4ToInt(ip);
173
+ const baseN = ipv4ToInt(baseStr);
174
+ if (ipN === null || baseN === null) return false;
175
+ const mask = prefix === 0 ? 0 : ~0 << 32 - prefix >>> 0;
176
+ return (ipN & mask) === (baseN & mask);
177
+ }
178
+ function isBlockedHost(host) {
179
+ const lower = host.toLowerCase();
180
+ if (METADATA_HOSTS.includes(lower)) return true;
181
+ if (METADATA_IPS.has(host)) return true;
182
+ if (lower === "::1") return true;
183
+ if (lower.startsWith("fe80:")) return true;
184
+ if (ipv4ToInt(host) === null) return false;
185
+ if (inRange(host, "10.0.0.0", 8)) return true;
186
+ if (inRange(host, "172.16.0.0", 12)) return true;
187
+ if (inRange(host, "192.168.0.0", 16)) return true;
188
+ if (inRange(host, "169.254.0.0", 16)) return true;
189
+ if (inRange(host, "127.0.0.0", 8)) return true;
190
+ return false;
191
+ }
192
+
193
+ // src/parsers/frontmatter.ts
194
+ import yaml from "js-yaml";
195
+ var FM_OPEN = /^---\r?\n/;
196
+ function splitFrontmatter(text) {
197
+ if (!FM_OPEN.test(text)) return ok({ rawFrontmatter: "", body: text, bodyStart: 0 });
198
+ const afterOpen = text.replace(FM_OPEN, "");
199
+ const closeIdx = afterOpen.search(/\r?\n---\r?\n/);
200
+ if (closeIdx === -1) return err("MISSING_CLOSING_DELIMITER");
201
+ const rawFrontmatter = afterOpen.slice(0, closeIdx);
202
+ const closeMatch = afterOpen.slice(closeIdx).match(/\r?\n---\r?\n/);
203
+ const bodyStart = text.length - (afterOpen.length - closeIdx - closeMatch[0].length);
204
+ const body = text.slice(bodyStart);
205
+ return ok({ rawFrontmatter, body, bodyStart });
206
+ }
207
+ function extractFrontmatter(text) {
208
+ const split = splitFrontmatter(text);
209
+ if (!split.ok) return split;
210
+ if (!split.data.rawFrontmatter) return ok({});
211
+ try {
212
+ const parsed = yaml.load(split.data.rawFrontmatter, { schema: yaml.JSON_SCHEMA });
213
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return ok({});
214
+ return ok(parsed);
215
+ } catch (e) {
216
+ return err("INVALID_FRONTMATTER", { message: e.message });
217
+ }
218
+ }
219
+
220
+ // src/commands/hash.ts
221
+ async function runHash(input) {
222
+ let text;
223
+ try {
224
+ text = await readFile(input.file, "utf8");
225
+ } catch {
226
+ return { exitCode: ExitCode.FILE_NOT_FOUND, result: err("FILE_NOT_FOUND", { path: input.file }) };
227
+ }
228
+ const split = splitFrontmatter(text);
229
+ if (!split.ok) return { exitCode: ExitCode.MISSING_CLOSING_DELIMITER, result: split };
230
+ const bodyBytes = Buffer.from(split.data.body, "utf8");
231
+ const sha256 = createHash("sha256").update(bodyBytes).digest("hex");
232
+ return {
233
+ exitCode: ExitCode.OK,
234
+ result: ok({ path: input.file, sha256, byte_count: bodyBytes.byteLength })
235
+ };
236
+ }
237
+
238
+ // src/commands/fetch-guard.ts
239
+ var REDACT_PARAMS = /* @__PURE__ */ new Set(["api_key", "token", "key", "auth", "password", "secret", "access_token"]);
240
+ var PATH_TOKEN_RE = /[A-Fa-f0-9]{32,}|[A-Za-z0-9_\-]{40,}/g;
241
+ function runFetchGuard(input) {
242
+ return Promise.resolve(runFetchGuardSync(input));
243
+ }
244
+ function runFetchGuardSync(input) {
245
+ let u;
246
+ try {
247
+ u = new URL(input.url);
248
+ } catch {
249
+ return { exitCode: ExitCode.MALFORMED_URL, result: err("MALFORMED_URL", { url: input.url }) };
250
+ }
251
+ const sanitized = sanitizeUrl(u);
252
+ if (u.protocol !== "https:") {
253
+ return {
254
+ exitCode: ExitCode.SCHEME_REJECTED,
255
+ result: err("SCHEME_REJECTED", { sanitized_url: sanitized, scheme: u.protocol })
256
+ };
257
+ }
258
+ if (isBlockedHost(u.hostname)) {
259
+ return {
260
+ exitCode: ExitCode.HOST_BLOCKED,
261
+ result: err("HOST_BLOCKED", { sanitized_url: sanitized, host: u.hostname })
262
+ };
263
+ }
264
+ return { exitCode: ExitCode.OK, result: ok({ allowed: true, sanitized_url: sanitized }) };
265
+ }
266
+ function sanitizeUrl(u) {
267
+ const clone = new URL(u.toString());
268
+ if (clone.username || clone.password) {
269
+ clone.username = "";
270
+ clone.password = "";
271
+ }
272
+ for (const k of Array.from(clone.searchParams.keys())) {
273
+ if (REDACT_PARAMS.has(k.toLowerCase())) clone.searchParams.set(k, "REDACTED");
274
+ }
275
+ let s = clone.toString();
276
+ s = s.replace(PATH_TOKEN_RE, "REDACTED");
277
+ return s;
278
+ }
279
+
280
+ // src/commands/validate.ts
281
+ import { readFile as readFile2 } from "fs/promises";
282
+ var SCHEMAS = {
283
+ "typed-knowledge": TypedKnowledgeSchema,
284
+ "raw": RawSourceSchema,
285
+ "work-item": WorkItemSchema,
286
+ "compound": CompoundSchema
287
+ };
288
+ async function runValidate(input) {
289
+ let text;
290
+ try {
291
+ text = await readFile2(input.file, "utf8");
292
+ } catch {
293
+ return { exitCode: ExitCode.FILE_NOT_FOUND, result: err("FILE_NOT_FOUND", { path: input.file }) };
294
+ }
295
+ const fm = extractFrontmatter(text);
296
+ if (!fm.ok) {
297
+ if (fm.error === "MISSING_CLOSING_DELIMITER") {
298
+ return { exitCode: ExitCode.MISSING_CLOSING_DELIMITER, result: fm };
299
+ }
300
+ return { exitCode: ExitCode.INVALID_FRONTMATTER, result: fm };
301
+ }
302
+ const det = detectSchema(fm.data);
303
+ if (!det.schema) {
304
+ return { exitCode: ExitCode.SCHEMA_NOT_DETECTED, result: ok({ schema: null, valid: false, errors: [] }) };
305
+ }
306
+ const parsed = SCHEMAS[det.schema].safeParse(fm.data);
307
+ if (!parsed.success) {
308
+ const errors = parsed.error.issues.map((i) => ({ path: i.path.join("."), message: i.message }));
309
+ return {
310
+ exitCode: ExitCode.INVALID_FRONTMATTER,
311
+ result: ok({ schema: det.schema, valid: false, errors })
312
+ };
313
+ }
314
+ return { exitCode: ExitCode.OK, result: ok({ schema: det.schema, valid: true, errors: [] }) };
315
+ }
316
+
317
+ // src/commands/graph.ts
318
+ import { writeFile, mkdir } from "fs/promises";
319
+ import { dirname } from "path";
320
+
321
+ // src/utils/vault.ts
322
+ import { readFile as readFile3, readdir, stat } from "fs/promises";
323
+ import { join, relative, sep } from "path";
324
+ var TYPED_DIRS = ["entities", "concepts", "comparisons", "queries"];
325
+ async function scanVault(root) {
326
+ try {
327
+ await stat(join(root, "SCHEMA.md"));
328
+ } catch {
329
+ return err("VAULT_PATH_INVALID", { root, reason: "SCHEMA.md missing" });
330
+ }
331
+ const all = await walk(root);
332
+ const rels = all.map((p) => ({ absPath: p, relPath: relative(root, p).split(sep).join("/") }));
333
+ return ok({
334
+ root,
335
+ typedKnowledge: rels.filter((p) => TYPED_DIRS.some((d) => p.relPath.startsWith(d + "/"))),
336
+ raw: rels.filter((p) => p.relPath.startsWith("raw/")),
337
+ workItems: rels.filter((p) => /^projects\/[^/]+\/work\/[^/]+\/(spec|plan|log)\.md$/.test(p.relPath)),
338
+ compound: rels.filter((p) => /^projects\/[^/]+\/compound\//.test(p.relPath))
339
+ });
340
+ }
341
+ async function walk(dir) {
342
+ const entries = await readdir(dir, { withFileTypes: true });
343
+ const out = [];
344
+ for (const e of entries) {
345
+ const p = join(dir, e.name);
346
+ if (e.isDirectory()) out.push(...await walk(p));
347
+ else if (e.isFile() && e.name.endsWith(".md")) out.push(p);
348
+ }
349
+ return out;
350
+ }
351
+ async function readPage(p) {
352
+ return readFile3(p.absPath, "utf8");
353
+ }
354
+
355
+ // src/parsers/wikilinks.ts
356
+ var FENCE = /`[^`]*`|```[\s\S]*?```/g;
357
+ function extractBodyWikilinks(body) {
358
+ const stripped = body.replace(FENCE, "");
359
+ const seen = /* @__PURE__ */ new Set();
360
+ const out = [];
361
+ const re = /\[\[([^\[\]|]+)(?:\|[^\[\]]*)?\]\]/g;
362
+ let m;
363
+ while ((m = re.exec(stripped)) !== null) {
364
+ const target = m[1].trim();
365
+ if (!seen.has(target)) {
366
+ seen.add(target);
367
+ out.push(target);
368
+ }
369
+ }
370
+ return out;
371
+ }
372
+
373
+ // src/commands/graph.ts
374
+ async function runGraphBuild(input) {
375
+ const scan = await scanVault(input.vault);
376
+ if (!scan.ok) return { exitCode: ExitCode.VAULT_PATH_INVALID, result: scan };
377
+ const adjacency = {};
378
+ const slugToPath = {};
379
+ for (const p of scan.data.typedKnowledge) {
380
+ const slug = p.relPath.replace(/\.md$/, "").split("/").pop();
381
+ slugToPath[slug] = p.relPath;
382
+ }
383
+ for (const p of scan.data.typedKnowledge) {
384
+ const text = await readPage(p);
385
+ const split = splitFrontmatter(text);
386
+ const body = split.ok ? split.data.body : text;
387
+ const links = extractBodyWikilinks(body);
388
+ adjacency[p.relPath] = links.map((slug) => slugToPath[slug.split("/").pop()]).filter((x) => Boolean(x));
389
+ }
390
+ const adamicAdar = computeAdamicAdar(adjacency);
391
+ const edge_count = Object.values(adjacency).reduce((acc, arr) => acc + arr.length, 0);
392
+ try {
393
+ await mkdir(dirname(input.out), { recursive: true });
394
+ await writeFile(input.out, JSON.stringify({ adjacency, adamicAdar }, null, 2));
395
+ } catch (e) {
396
+ return { exitCode: ExitCode.WRITE_FAILED, result: err("WRITE_FAILED", { message: String(e) }) };
397
+ }
398
+ return {
399
+ exitCode: ExitCode.OK,
400
+ result: ok({ out_path: input.out, node_count: scan.data.typedKnowledge.length, edge_count })
401
+ };
402
+ }
403
+ function computeAdamicAdar(adj) {
404
+ const undirected = {};
405
+ for (const [a, neighbors] of Object.entries(adj)) {
406
+ undirected[a] ??= /* @__PURE__ */ new Set();
407
+ for (const b of neighbors) {
408
+ undirected[a].add(b);
409
+ undirected[b] ??= /* @__PURE__ */ new Set();
410
+ undirected[b].add(a);
411
+ }
412
+ }
413
+ const nodes = Object.keys(undirected);
414
+ const out = {};
415
+ for (let i = 0; i < nodes.length; i++) {
416
+ for (let j = i + 1; j < nodes.length; j++) {
417
+ const a = nodes[i], b = nodes[j];
418
+ const common = [...undirected[a]].filter((x) => undirected[b].has(x));
419
+ let score = 0;
420
+ for (const c of common) {
421
+ const deg = undirected[c].size;
422
+ if (deg > 1) score += 1 / Math.log(deg);
423
+ }
424
+ if (score > 0) {
425
+ out[a] ??= {};
426
+ out[a][b] = score;
427
+ out[b] ??= {};
428
+ out[b][a] = score;
429
+ }
430
+ }
431
+ }
432
+ return out;
433
+ }
434
+
435
+ // src/commands/overlap.ts
436
+ async function runOverlap(input) {
437
+ const scan = await scanVault(input.vault);
438
+ if (!scan.ok) return { exitCode: ExitCode.VAULT_PATH_INVALID, result: scan };
439
+ const sourcesByPage = {};
440
+ for (const p of scan.data.typedKnowledge) {
441
+ const fm = extractFrontmatter(await readPage(p));
442
+ if (!fm.ok) continue;
443
+ const srcs = fm.data.sources ?? [];
444
+ sourcesByPage[p.relPath] = new Set(srcs);
445
+ }
446
+ const parent = {};
447
+ for (const k of Object.keys(sourcesByPage)) parent[k] = k;
448
+ const find = (x) => parent[x] === x ? x : parent[x] = find(parent[x]);
449
+ const union = (a, b) => {
450
+ const ra = find(a), rb = find(b);
451
+ if (ra !== rb) parent[ra] = rb;
452
+ };
453
+ const pages = Object.keys(sourcesByPage);
454
+ for (let i = 0; i < pages.length; i++) {
455
+ for (let j = i + 1; j < pages.length; j++) {
456
+ const sa = sourcesByPage[pages[i]], sb = sourcesByPage[pages[j]];
457
+ const shared = [...sa].filter((x) => sb.has(x)).length;
458
+ if (shared > 0) union(pages[i], pages[j]);
459
+ }
460
+ }
461
+ const groups = {};
462
+ for (const p of pages) {
463
+ const r = find(p);
464
+ (groups[r] ??= []).push(p);
465
+ }
466
+ const clusters = Object.entries(groups).filter(([, m]) => m.length > 1).map(([id, members]) => {
467
+ let score = 0;
468
+ for (let i = 0; i < members.length; i++)
469
+ for (let j = i + 1; j < members.length; j++) {
470
+ const sa = sourcesByPage[members[i]], sb = sourcesByPage[members[j]];
471
+ score += [...sa].filter((x) => sb.has(x)).length;
472
+ }
473
+ return { id, members, score };
474
+ });
475
+ return { exitCode: ExitCode.OK, result: ok({ clusters }) };
476
+ }
477
+
478
+ // src/utils/wiki-path.ts
479
+ import { join as join2 } from "path";
480
+
481
+ // src/utils/dotenv.ts
482
+ import { readFile as readFile4 } from "fs/promises";
483
+ var WHITELIST = /* @__PURE__ */ new Set(["WIKI_PATH", "WIKI_LANG"]);
484
+ async function parseDotenvFile(path) {
485
+ let text;
486
+ try {
487
+ text = await readFile4(path, "utf8");
488
+ } catch {
489
+ return {};
490
+ }
491
+ const out = {};
492
+ for (const rawLine of text.split(/\r?\n/)) {
493
+ const line = rawLine.trim();
494
+ if (line.length === 0 || line.startsWith("#")) continue;
495
+ const eq = line.indexOf("=");
496
+ if (eq <= 0) continue;
497
+ const key = line.slice(0, eq).trim();
498
+ const value = line.slice(eq + 1).trim();
499
+ if (!WHITELIST.has(key)) continue;
500
+ if (value.length === 0) continue;
501
+ out[key] = value;
502
+ }
503
+ return out;
504
+ }
505
+
506
+ // src/utils/wiki-path.ts
507
+ async function resolveInitTimePath(input) {
508
+ const chain = [];
509
+ if (input.flag !== void 0 && input.flag.length > 0) {
510
+ if (input.explain) chain.push({ source: "flag", matched: true, value: input.flag });
511
+ return { path: input.flag, source: "flag", ...input.explain ? { chain } : {} };
512
+ }
513
+ if (input.explain) chain.push({ source: "flag", matched: false });
514
+ if (input.envValue !== void 0 && input.envValue.length > 0) {
515
+ if (input.explain) chain.push({ source: "env", matched: true, value: input.envValue });
516
+ return { path: input.envValue, source: "env", ...input.explain ? { chain } : {} };
517
+ }
518
+ if (input.explain) chain.push({ source: "env", matched: false });
519
+ const sw = await parseDotenvFile(join2(input.home, ".skillwiki", ".env"));
520
+ if (sw.WIKI_PATH !== void 0) {
521
+ if (input.explain) chain.push({ source: "skillwiki-dotenv", matched: true, value: sw.WIKI_PATH });
522
+ return { path: sw.WIKI_PATH, source: "skillwiki-dotenv", ...input.explain ? { chain } : {} };
523
+ }
524
+ if (input.explain) chain.push({ source: "skillwiki-dotenv", matched: false });
525
+ const hermes = await parseDotenvFile(join2(input.home, ".hermes", ".env"));
526
+ if (hermes.WIKI_PATH !== void 0) {
527
+ if (input.explain) chain.push({ source: "hermes-dotenv", matched: true, value: hermes.WIKI_PATH });
528
+ return { path: hermes.WIKI_PATH, source: "hermes-dotenv", ...input.explain ? { chain } : {} };
529
+ }
530
+ if (input.explain) chain.push({ source: "hermes-dotenv", matched: false });
531
+ const fallback = join2(input.home, "wiki");
532
+ if (input.explain) chain.push({ source: "default", matched: true, value: fallback });
533
+ return { path: fallback, source: "default", ...input.explain ? { chain } : {} };
534
+ }
535
+ async function resolveRuntimePath(input) {
536
+ const chain = [];
537
+ if (input.flag !== void 0 && input.flag.length > 0) {
538
+ if (input.explain) chain.push({ source: "flag", matched: true, value: input.flag });
539
+ return ok({ path: input.flag, source: "flag", ...input.explain ? { chain } : {} });
540
+ }
541
+ if (input.explain) chain.push({ source: "flag", matched: false });
542
+ if (input.envValue !== void 0 && input.envValue.length > 0) {
543
+ if (input.explain) chain.push({ source: "env", matched: true, value: input.envValue });
544
+ return ok({ path: input.envValue, source: "env", ...input.explain ? { chain } : {} });
545
+ }
546
+ if (input.explain) chain.push({ source: "env", matched: false });
547
+ const sw = await parseDotenvFile(join2(input.home, ".skillwiki", ".env"));
548
+ if (sw.WIKI_PATH !== void 0) {
549
+ if (input.explain) chain.push({ source: "skillwiki-dotenv", matched: true, value: sw.WIKI_PATH });
550
+ return ok({ path: sw.WIKI_PATH, source: "skillwiki-dotenv", ...input.explain ? { chain } : {} });
551
+ }
552
+ if (input.explain) chain.push({ source: "skillwiki-dotenv", matched: false });
553
+ return err("NO_VAULT_CONFIGURED", {
554
+ message: "No vault configured. Run `skillwiki init` to bootstrap one, or pass `--vault <dir>`."
555
+ });
556
+ }
557
+
558
+ // src/commands/orphans.ts
559
+ async function runOrphans(input) {
560
+ let vault;
561
+ if (input.vault) {
562
+ vault = input.vault;
563
+ } else {
564
+ const r = await resolveRuntimePath({ flag: void 0, envValue: input.envValue, home: input.home ?? "" });
565
+ if (!r.ok) return { exitCode: ExitCode.NO_VAULT_CONFIGURED, result: r };
566
+ vault = r.data.path;
567
+ }
568
+ const scan = await scanVault(vault);
569
+ if (!scan.ok) return { exitCode: ExitCode.VAULT_PATH_INVALID, result: scan };
570
+ const slugToPath = {};
571
+ for (const p of scan.data.typedKnowledge) {
572
+ slugToPath[p.relPath.replace(/\.md$/, "").split("/").pop()] = p.relPath;
573
+ }
574
+ const adj = {};
575
+ for (const p of scan.data.typedKnowledge) adj[p.relPath] = /* @__PURE__ */ new Set();
576
+ for (const p of scan.data.typedKnowledge) {
577
+ const text = await readPage(p);
578
+ const split = splitFrontmatter(text);
579
+ const body = split.ok ? split.data.body : text;
580
+ for (const slug of extractBodyWikilinks(body)) {
581
+ const tgt = slugToPath[slug.split("/").pop()];
582
+ if (tgt) {
583
+ adj[p.relPath].add(tgt);
584
+ adj[tgt].add(p.relPath);
585
+ }
586
+ }
587
+ }
588
+ const orphans = Object.keys(adj).filter((k) => adj[k].size === 0);
589
+ const componentOf = {};
590
+ let cid = 0;
591
+ for (const node of Object.keys(adj)) {
592
+ if (componentOf[node] !== void 0) continue;
593
+ const stack = [node];
594
+ while (stack.length) {
595
+ const n = stack.pop();
596
+ if (componentOf[n] !== void 0) continue;
597
+ componentOf[n] = cid;
598
+ for (const nb of adj[n]) stack.push(nb);
599
+ }
600
+ cid++;
601
+ }
602
+ const bridges = [];
603
+ for (const node of Object.keys(adj)) {
604
+ const neighborComps = new Set([...adj[node]].map((n) => componentOf[n]));
605
+ if (adj[node].size >= 2 && neighborComps.size === 1) {
606
+ const without = simulateRemoval(adj, node);
607
+ if (without > Object.values(componentOf).filter((v, i, a) => a.indexOf(v) === i).length) {
608
+ bridges.push({ path: node, connects: [...adj[node]] });
609
+ }
610
+ }
611
+ }
612
+ return { exitCode: ExitCode.OK, result: ok({ orphans, bridges }) };
613
+ }
614
+ function simulateRemoval(adj, removed) {
615
+ const seen = /* @__PURE__ */ new Set();
616
+ let comps = 0;
617
+ for (const start of Object.keys(adj)) {
618
+ if (start === removed || seen.has(start)) continue;
619
+ comps++;
620
+ const stack = [start];
621
+ while (stack.length) {
622
+ const n = stack.pop();
623
+ if (seen.has(n) || n === removed) continue;
624
+ seen.add(n);
625
+ for (const nb of adj[n]) if (nb !== removed) stack.push(nb);
626
+ }
627
+ }
628
+ return comps;
629
+ }
630
+
631
+ // src/commands/audit.ts
632
+ import { readFile as readFile5, stat as stat2 } from "fs/promises";
633
+ import { dirname as dirname2, resolve, join as join3 } from "path";
634
+
635
+ // src/parsers/citations.ts
636
+ var FENCE2 = /```[\s\S]*?```/g;
637
+ function extractCitationMarkers(body) {
638
+ const stripped = body.replace(FENCE2, "");
639
+ const out = [];
640
+ const re = /\^\[(raw\/[^\]]+)\]/g;
641
+ let m;
642
+ while ((m = re.exec(stripped)) !== null) {
643
+ out.push({ marker: m[0], target: m[1] });
644
+ }
645
+ return out;
646
+ }
647
+
648
+ // src/commands/audit.ts
649
+ async function runAudit(input) {
650
+ let text;
651
+ try {
652
+ text = await readFile5(input.file, "utf8");
653
+ } catch {
654
+ return { exitCode: ExitCode.FILE_NOT_FOUND, result: err("FILE_NOT_FOUND", { path: input.file }) };
655
+ }
656
+ const fm = extractFrontmatter(text);
657
+ if (!fm.ok) return { exitCode: ExitCode.INVALID_FRONTMATTER, result: fm };
658
+ const split = splitFrontmatter(text);
659
+ const body = split.ok ? split.data.body : text;
660
+ const vault = await findVaultRoot(dirname2(resolve(input.file)));
661
+ if (!vault) return { exitCode: ExitCode.VAULT_PATH_INVALID, result: err("VAULT_PATH_INVALID") };
662
+ const markers = extractCitationMarkers(body);
663
+ const resolved = await Promise.all(markers.map(async (m) => {
664
+ try {
665
+ await stat2(join3(vault, m.target));
666
+ return { ...m, resolved: true };
667
+ } catch {
668
+ return { ...m, resolved: false };
669
+ }
670
+ }));
671
+ const sources = fm.data.sources ?? [];
672
+ const referenced = new Set(resolved.map((m) => m.target));
673
+ const unused_sources = sources.filter((s) => !referenced.has(s));
674
+ const missing_from_sources = [...referenced].filter((t) => !sources.includes(t));
675
+ if (resolved.some((m) => !m.resolved)) {
676
+ return { exitCode: ExitCode.UNRESOLVED_MARKERS, result: ok({ markers: resolved, sources_consistency: { unused_sources, missing_from_sources } }) };
677
+ }
678
+ if (unused_sources.length > 0 || missing_from_sources.length > 0) {
679
+ return { exitCode: ExitCode.SOURCES_INCONSISTENT, result: ok({ markers: resolved, sources_consistency: { unused_sources, missing_from_sources } }) };
680
+ }
681
+ return { exitCode: ExitCode.OK, result: ok({ markers: resolved, sources_consistency: { unused_sources, missing_from_sources } }) };
682
+ }
683
+ async function findVaultRoot(start) {
684
+ let cur = start;
685
+ for (let i = 0; i < 20; i++) {
686
+ try {
687
+ await stat2(join3(cur, "SCHEMA.md"));
688
+ return cur;
689
+ } catch {
690
+ }
691
+ const parent = dirname2(cur);
692
+ if (parent === cur) return null;
693
+ cur = parent;
694
+ }
695
+ return null;
696
+ }
697
+
698
+ // src/commands/install.ts
699
+ import { readdir as readdir2, stat as stat4 } from "fs/promises";
700
+ import { join as join4 } from "path";
701
+
702
+ // src/utils/install-fs.ts
703
+ import { copyFile, mkdir as mkdir2, rename, writeFile as writeFile2, stat as stat3 } from "fs/promises";
704
+ import { dirname as dirname3 } from "path";
705
+ async function atomicCopyWithBackup(src, dst) {
706
+ await mkdir2(dirname3(dst), { recursive: true });
707
+ let backupPath = null;
708
+ try {
709
+ await stat3(dst);
710
+ backupPath = `${dst}.bak`;
711
+ await copyFile(dst, backupPath);
712
+ } catch {
713
+ }
714
+ const tmp = `${dst}.tmp.${process.pid}`;
715
+ try {
716
+ await copyFile(src, tmp);
717
+ await rename(tmp, dst);
718
+ } catch (e) {
719
+ return err("ATOMIC_COPY_FAILED", { message: String(e) });
720
+ }
721
+ return ok({ copied: true, backupPath });
722
+ }
723
+ async function writeManifest(path, m) {
724
+ await mkdir2(dirname3(path), { recursive: true });
725
+ const enriched = { installed_at: (/* @__PURE__ */ new Date()).toISOString(), ...m };
726
+ await writeFile2(path, JSON.stringify(enriched, null, 2));
727
+ }
728
+
729
+ // src/commands/install.ts
730
+ async function runInstall(input) {
731
+ let entries;
732
+ try {
733
+ entries = (await readdir2(input.skillsRoot, { withFileTypes: true })).filter((d) => d.isDirectory() && (d.name.startsWith("wiki-") || d.name.startsWith("proj-"))).map((d) => d.name);
734
+ } catch (e) {
735
+ return { exitCode: ExitCode.PREFLIGHT_FAILED, result: err("PREFLIGHT_FAILED", { message: String(e) }) };
736
+ }
737
+ if (entries.length === 0) {
738
+ return { exitCode: ExitCode.PREFLIGHT_FAILED, result: err("PREFLIGHT_FAILED", { reason: "no skills found" }) };
739
+ }
740
+ const installed = [];
741
+ const backed_up = [];
742
+ for (const name of entries) {
743
+ const src = join4(input.skillsRoot, name, "SKILL.md");
744
+ const dst = join4(input.target, name, "SKILL.md");
745
+ try {
746
+ await stat4(src);
747
+ } catch {
748
+ return { exitCode: ExitCode.PREFLIGHT_FAILED, result: err("PREFLIGHT_FAILED", { missing: src }) };
749
+ }
750
+ if (input.dryRun) {
751
+ installed.push(dst);
752
+ continue;
753
+ }
754
+ const r = await atomicCopyWithBackup(src, dst);
755
+ if (!r.ok) return { exitCode: ExitCode.ATOMIC_COPY_FAILED, result: r };
756
+ installed.push(dst);
757
+ if (r.data.backupPath) backed_up.push(r.data.backupPath);
758
+ }
759
+ const manifest_path = join4(input.target, "wiki-manifest.json");
760
+ if (!input.dryRun) await writeManifest(manifest_path, { installed, backed_up });
761
+ return { exitCode: ExitCode.OK, result: ok({ installed, backed_up, manifest_path }) };
762
+ }
763
+
764
+ // src/commands/path.ts
765
+ async function runPath(input) {
766
+ if (input.initTime) {
767
+ const r2 = await resolveInitTimePath({
768
+ flag: input.flag,
769
+ envValue: input.envValue,
770
+ home: input.home,
771
+ explain: input.explain
772
+ });
773
+ return { exitCode: ExitCode.OK, result: ok({ path: r2.path, source: r2.source, ...r2.chain ? { chain: r2.chain } : {} }) };
774
+ }
775
+ const r = await resolveRuntimePath({
776
+ flag: input.flag,
777
+ envValue: input.envValue,
778
+ home: input.home,
779
+ explain: input.explain
780
+ });
781
+ if (!r.ok) return { exitCode: ExitCode.NO_VAULT_CONFIGURED, result: r };
782
+ return { exitCode: ExitCode.OK, result: ok({ path: r.data.path, source: r.data.source, ...r.data.chain ? { chain: r.data.chain } : {} }) };
783
+ }
784
+
785
+ // src/utils/lang.ts
786
+ import { join as join5 } from "path";
787
+ var ALIASES = {
788
+ english: "en",
789
+ en: "en",
790
+ "chinese-traditional": "zh-Hant",
791
+ "zh-hant": "zh-Hant",
792
+ "zh-tw": "zh-Hant",
793
+ "chinese-simplified": "zh-Hans",
794
+ "zh-hans": "zh-Hans",
795
+ "zh-cn": "zh-Hans"
796
+ };
797
+ function normalizeLang(input) {
798
+ const trimmed = input.trim();
799
+ const key = trimmed.toLowerCase();
800
+ return ALIASES[key] ?? trimmed;
801
+ }
802
+ async function resolveLang(input) {
803
+ if (input.flag !== void 0 && input.flag.length > 0) {
804
+ return { value: input.flag, source: "flag", canonical: normalizeLang(input.flag) };
805
+ }
806
+ if (input.envValue !== void 0 && input.envValue.length > 0) {
807
+ return { value: input.envValue, source: "env", canonical: normalizeLang(input.envValue) };
808
+ }
809
+ const dotenv = await parseDotenvFile(join5(input.home, ".skillwiki", ".env"));
810
+ if (dotenv.WIKI_LANG !== void 0) {
811
+ return { value: dotenv.WIKI_LANG, source: "skillwiki-dotenv", canonical: normalizeLang(dotenv.WIKI_LANG) };
812
+ }
813
+ return { value: "en", source: "default", canonical: "en" };
814
+ }
815
+
816
+ // src/commands/lang.ts
817
+ import { join as join6 } from "path";
818
+ async function runLang(input) {
819
+ const resolved = await resolveLang({ flag: input.flag, envValue: input.envValue, home: input.home });
820
+ let chain;
821
+ if (input.explain) {
822
+ chain = [
823
+ { source: "flag", matched: input.flag !== void 0 && input.flag.length > 0, value: input.flag },
824
+ { source: "env", matched: input.envValue !== void 0 && input.envValue.length > 0, value: input.envValue }
825
+ ];
826
+ const sw = await parseDotenvFile(join6(input.home, ".skillwiki", ".env"));
827
+ chain.push({ source: "skillwiki-dotenv", matched: sw.WIKI_LANG !== void 0, value: sw.WIKI_LANG });
828
+ chain.push({ source: "default", matched: resolved.source === "default", value: "en" });
829
+ }
830
+ return {
831
+ exitCode: ExitCode.OK,
832
+ result: ok({
833
+ value: resolved.value,
834
+ source: resolved.source,
835
+ canonical: resolved.canonical,
836
+ ...chain ? { chain } : {}
837
+ })
838
+ };
839
+ }
840
+
841
+ // src/commands/init.ts
842
+ import { mkdir as mkdir3, readFile as readFile6, stat as stat5, writeFile as writeFile3 } from "fs/promises";
843
+ import { join as join7, dirname as dirname4 } from "path";
844
+ var DEFAULT_TAXONOMY = [
845
+ "research",
846
+ "comparison",
847
+ "timeline",
848
+ "summary",
849
+ "person",
850
+ "organization",
851
+ "concept",
852
+ "technique",
853
+ "tool",
854
+ "model"
855
+ ];
856
+ var VAULT_DIRS = [
857
+ "raw/articles",
858
+ "raw/papers",
859
+ "raw/transcripts",
860
+ "raw/assets",
861
+ "entities",
862
+ "concepts",
863
+ "comparisons",
864
+ "queries",
865
+ "meta",
866
+ "projects"
867
+ ];
868
+ async function runInit(input) {
869
+ const pathRes = await resolveInitTimePath({ flag: input.flag, envValue: input.envValue, home: input.home });
870
+ const target = pathRes.path;
871
+ const langRes = await resolveLang({ flag: input.lang, envValue: void 0, home: input.home });
872
+ const canonicalLang = langRes.canonical;
873
+ let hasSchema = false;
874
+ try {
875
+ await stat5(join7(target, "SCHEMA.md"));
876
+ hasSchema = true;
877
+ } catch {
878
+ }
879
+ if (hasSchema && !input.force) {
880
+ return {
881
+ exitCode: ExitCode.INIT_TARGET_NOT_EMPTY,
882
+ result: err("INIT_TARGET_NOT_EMPTY", { target })
883
+ };
884
+ }
885
+ const envPath = join7(input.home, ".skillwiki", ".env");
886
+ const existingEnv = await parseDotenvFile(envPath);
887
+ const swDotenvHadPath = existingEnv.WIKI_PATH !== void 0;
888
+ if (existingEnv.WIKI_PATH !== void 0 && existingEnv.WIKI_PATH !== target && !input.force) {
889
+ return {
890
+ exitCode: ExitCode.ENV_WRITE_CONFLICT,
891
+ result: err("ENV_WRITE_CONFLICT", { key: "WIKI_PATH", existing: existingEnv.WIKI_PATH, attempted: target })
892
+ };
893
+ }
894
+ if (existingEnv.WIKI_LANG !== void 0 && existingEnv.WIKI_LANG !== canonicalLang && !input.force) {
895
+ return {
896
+ exitCode: ExitCode.ENV_WRITE_CONFLICT,
897
+ result: err("ENV_WRITE_CONFLICT", { key: "WIKI_LANG", existing: existingEnv.WIKI_LANG, attempted: canonicalLang })
898
+ };
899
+ }
900
+ const created = [];
901
+ try {
902
+ await mkdir3(target, { recursive: true });
903
+ for (const d of VAULT_DIRS) {
904
+ await mkdir3(join7(target, d), { recursive: true });
905
+ created.push(d + "/");
906
+ }
907
+ } catch (e) {
908
+ return { exitCode: ExitCode.WRITE_FAILED, result: err("WRITE_FAILED", { message: String(e) }) };
909
+ }
910
+ const today = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
911
+ const taxonomy = input.taxonomy && input.taxonomy.length > 0 ? input.taxonomy : DEFAULT_TAXONOMY;
912
+ const taxonomyYaml = taxonomy.map((t) => ` - ${t}`).join("\n");
913
+ try {
914
+ const schemaTpl = await readFile6(join7(input.templates, "SCHEMA.md"), "utf8");
915
+ const schema = schemaTpl.replace("{{DOMAIN}}", input.domain).replace("{{WIKI_LANG}}", canonicalLang).replace("{{TAXONOMY_YAML}}", taxonomyYaml);
916
+ await writeFile3(join7(target, "SCHEMA.md"), schema, "utf8");
917
+ created.push("SCHEMA.md");
918
+ } catch (e) {
919
+ return { exitCode: ExitCode.WRITE_FAILED, result: err("WRITE_FAILED", { file: "SCHEMA.md", message: String(e) }) };
920
+ }
921
+ try {
922
+ const idxTpl = await readFile6(join7(input.templates, "index.md"), "utf8");
923
+ const idx = idxTpl.replace("{{INIT_DATE}}", today);
924
+ await writeFile3(join7(target, "index.md"), idx, "utf8");
925
+ created.push("index.md");
926
+ } catch (e) {
927
+ return { exitCode: ExitCode.WRITE_FAILED, result: err("WRITE_FAILED", { file: "index.md", message: String(e) }) };
928
+ }
929
+ try {
930
+ const logTpl = await readFile6(join7(input.templates, "log.md"), "utf8");
931
+ const log = logTpl.replace(/\{\{INIT_DATE\}\}/g, today).replace("{{DOMAIN}}", input.domain).replace("{{WIKI_LANG}}", canonicalLang);
932
+ await writeFile3(join7(target, "log.md"), log, "utf8");
933
+ created.push("log.md");
934
+ } catch (e) {
935
+ return { exitCode: ExitCode.WRITE_FAILED, result: err("WRITE_FAILED", { file: "log.md", message: String(e) }) };
936
+ }
937
+ try {
938
+ await mkdir3(dirname4(envPath), { recursive: true });
939
+ const envBody = `WIKI_PATH=${target}
940
+ WIKI_LANG=${canonicalLang}
941
+ `;
942
+ await writeFile3(envPath, envBody, "utf8");
943
+ } catch (e) {
944
+ return { exitCode: ExitCode.WRITE_FAILED, result: err("WRITE_FAILED", { file: envPath, message: String(e) }) };
945
+ }
946
+ const importedFromHermes = pathRes.source === "hermes-dotenv" && !swDotenvHadPath;
947
+ return {
948
+ exitCode: ExitCode.OK,
949
+ result: ok({
950
+ vault: target,
951
+ domain: input.domain,
952
+ taxonomy,
953
+ lang: canonicalLang,
954
+ created,
955
+ env_written: envPath,
956
+ imported_from_hermes: importedFromHermes
957
+ })
958
+ };
959
+ }
960
+
961
+ // src/commands/links.ts
962
+ async function runLinks(input) {
963
+ const scan = await scanVault(input.vault);
964
+ if (!scan.ok) return { exitCode: ExitCode.VAULT_PATH_INVALID, result: scan };
965
+ const slugs = /* @__PURE__ */ new Set();
966
+ for (const p of scan.data.typedKnowledge) {
967
+ slugs.add(p.relPath.replace(/\.md$/, "").split("/").pop());
968
+ }
969
+ const broken = [];
970
+ for (const p of scan.data.typedKnowledge) {
971
+ const text = await readPage(p);
972
+ const split = splitFrontmatter(text);
973
+ const body = split.ok ? split.data.body : text;
974
+ const lines = body.split("\n");
975
+ for (const slug of extractBodyWikilinks(body)) {
976
+ const tail = slug.split("/").pop();
977
+ if (!slugs.has(tail)) {
978
+ const line = lines.findIndex((l) => l.includes(`[[${slug}`));
979
+ broken.push({ page: p.relPath, slug, line: line >= 0 ? line + 1 : 0 });
980
+ }
981
+ }
982
+ }
983
+ if (broken.length > 0) {
984
+ return { exitCode: ExitCode.BROKEN_WIKILINKS, result: ok({ broken }) };
985
+ }
986
+ return { exitCode: ExitCode.OK, result: ok({ broken }) };
987
+ }
988
+
989
+ // src/commands/tag-audit.ts
990
+ import { readFile as readFile7 } from "fs/promises";
991
+ import { join as join8 } from "path";
992
+
993
+ // src/parsers/taxonomy.ts
994
+ import yaml2 from "js-yaml";
995
+ var FENCE_RE = /^##\s+Tag Taxonomy\s*$[\s\S]*?```yaml\s*\n([\s\S]*?)\n```/m;
996
+ function extractTaxonomy(schemaText) {
997
+ const m = schemaText.match(FENCE_RE);
998
+ if (!m) return ok([]);
999
+ let parsed;
1000
+ try {
1001
+ parsed = yaml2.load(m[1], { schema: yaml2.JSON_SCHEMA });
1002
+ } catch (e) {
1003
+ return err("INVALID_FRONTMATTER", { message: e.message });
1004
+ }
1005
+ if (parsed === null || typeof parsed !== "object") {
1006
+ return err("INVALID_FRONTMATTER", { message: "taxonomy block is not an object" });
1007
+ }
1008
+ const tax = parsed.taxonomy;
1009
+ if (!Array.isArray(tax)) {
1010
+ return err("INVALID_FRONTMATTER", { message: "taxonomy key missing or not an array" });
1011
+ }
1012
+ if (!tax.every((x) => typeof x === "string")) {
1013
+ return err("INVALID_FRONTMATTER", { message: "taxonomy must be a list of strings" });
1014
+ }
1015
+ return ok(tax);
1016
+ }
1017
+
1018
+ // src/commands/tag-audit.ts
1019
+ async function runTagAudit(input) {
1020
+ const scan = await scanVault(input.vault);
1021
+ if (!scan.ok) return { exitCode: ExitCode.VAULT_PATH_INVALID, result: scan };
1022
+ const schemaText = await readFile7(join8(input.vault, "SCHEMA.md"), "utf8");
1023
+ const tax = extractTaxonomy(schemaText);
1024
+ if (!tax.ok) return { exitCode: ExitCode.INVALID_FRONTMATTER, result: tax };
1025
+ const allowed = new Set(tax.data);
1026
+ const violations = [];
1027
+ for (const p of scan.data.typedKnowledge) {
1028
+ const text = await readPage(p);
1029
+ const fm = extractFrontmatter(text);
1030
+ if (!fm.ok) return { exitCode: ExitCode.INVALID_FRONTMATTER, result: fm };
1031
+ const tags = fm.data.tags;
1032
+ if (!Array.isArray(tags)) continue;
1033
+ for (const t of tags) {
1034
+ if (typeof t === "string" && !allowed.has(t)) {
1035
+ violations.push({ page: p.relPath, tag: t });
1036
+ }
1037
+ }
1038
+ }
1039
+ if (violations.length > 0) {
1040
+ return { exitCode: ExitCode.TAG_NOT_IN_TAXONOMY, result: ok({ violations, taxonomy: tax.data }) };
1041
+ }
1042
+ return { exitCode: ExitCode.OK, result: ok({ violations, taxonomy: tax.data }) };
1043
+ }
1044
+
1045
+ // src/commands/index-check.ts
1046
+ import { readFile as readFile8 } from "fs/promises";
1047
+ import { join as join9 } from "path";
1048
+ async function runIndexCheck(input) {
1049
+ const scan = await scanVault(input.vault);
1050
+ if (!scan.ok) return { exitCode: ExitCode.VAULT_PATH_INVALID, result: scan };
1051
+ let indexText = "";
1052
+ try {
1053
+ indexText = await readFile8(join9(input.vault, "index.md"), "utf8");
1054
+ } catch {
1055
+ }
1056
+ const indexSlugs = new Set(extractBodyWikilinks(indexText).map((s) => s.split("/").pop()));
1057
+ const fileSlugs = /* @__PURE__ */ new Map();
1058
+ for (const p of scan.data.typedKnowledge) {
1059
+ const slug = p.relPath.replace(/\.md$/, "").split("/").pop();
1060
+ fileSlugs.set(slug, p.relPath);
1061
+ }
1062
+ const missing_from_index = [];
1063
+ for (const [slug, relPath] of fileSlugs.entries()) {
1064
+ if (!indexSlugs.has(slug)) missing_from_index.push(relPath);
1065
+ }
1066
+ const ghost_entries = [];
1067
+ for (const slug of indexSlugs) {
1068
+ if (!fileSlugs.has(slug)) ghost_entries.push(slug);
1069
+ }
1070
+ if (missing_from_index.length > 0 || ghost_entries.length > 0) {
1071
+ return { exitCode: ExitCode.INDEX_INCOMPLETE, result: ok({ missing_from_index, ghost_entries }) };
1072
+ }
1073
+ return { exitCode: ExitCode.OK, result: ok({ missing_from_index, ghost_entries }) };
1074
+ }
1075
+
1076
+ // src/commands/stale.ts
1077
+ import { readFile as readFile9 } from "fs/promises";
1078
+ import { join as join10 } from "path";
1079
+ function dayDiff(a, b) {
1080
+ const da = Date.parse(a);
1081
+ const db = Date.parse(b);
1082
+ return Math.round((db - da) / 864e5);
1083
+ }
1084
+ async function runStale(input) {
1085
+ const scan = await scanVault(input.vault);
1086
+ if (!scan.ok) return { exitCode: ExitCode.VAULT_PATH_INVALID, result: scan };
1087
+ const stale = [];
1088
+ for (const p of scan.data.typedKnowledge) {
1089
+ const fm = extractFrontmatter(await readPage(p));
1090
+ if (!fm.ok) continue;
1091
+ const updated = typeof fm.data.updated === "string" ? fm.data.updated : void 0;
1092
+ const sources = Array.isArray(fm.data.sources) ? fm.data.sources.filter((s) => typeof s === "string") : [];
1093
+ if (!updated || sources.length === 0) continue;
1094
+ let newest;
1095
+ for (const rel of sources) {
1096
+ let raw;
1097
+ try {
1098
+ raw = await readFile9(join10(input.vault, rel), "utf8");
1099
+ } catch {
1100
+ continue;
1101
+ }
1102
+ const rfm = extractFrontmatter(raw);
1103
+ if (!rfm.ok) continue;
1104
+ const ing = typeof rfm.data.ingested === "string" ? rfm.data.ingested : void 0;
1105
+ if (ing && (!newest || Date.parse(ing) > Date.parse(newest))) newest = ing;
1106
+ }
1107
+ if (!newest) continue;
1108
+ const gap = dayDiff(updated, newest);
1109
+ if (gap > input.days) {
1110
+ stale.push({ page: p.relPath, page_updated: updated, newest_source_ingested: newest, gap_days: gap });
1111
+ }
1112
+ }
1113
+ if (stale.length > 0) return { exitCode: ExitCode.STALE_PAGE, result: ok({ stale }) };
1114
+ return { exitCode: ExitCode.OK, result: ok({ stale }) };
1115
+ }
1116
+
1117
+ // src/commands/pagesize.ts
1118
+ async function runPagesize(input) {
1119
+ const scan = await scanVault(input.vault);
1120
+ if (!scan.ok) return { exitCode: ExitCode.VAULT_PATH_INVALID, result: scan };
1121
+ const oversized = [];
1122
+ for (const p of scan.data.typedKnowledge) {
1123
+ const text = await readPage(p);
1124
+ const split = splitFrontmatter(text);
1125
+ const body = split.ok ? split.data.body : text;
1126
+ const count = body.split("\n").length;
1127
+ if (count > input.lines) oversized.push({ page: p.relPath, lines: count });
1128
+ }
1129
+ if (oversized.length > 0) return { exitCode: ExitCode.PAGE_TOO_LARGE, result: ok({ oversized }) };
1130
+ return { exitCode: ExitCode.OK, result: ok({ oversized }) };
1131
+ }
1132
+
1133
+ // src/commands/log-rotate.ts
1134
+ import { readFile as readFile10, rename as rename2, writeFile as writeFile4, stat as stat6 } from "fs/promises";
1135
+ import { join as join11 } from "path";
1136
+ var ENTRY_RE = /^## \[(\d{4})-\d{2}-\d{2}\]/gm;
1137
+ async function runLogRotate(input) {
1138
+ try {
1139
+ await stat6(join11(input.vault, "SCHEMA.md"));
1140
+ } catch {
1141
+ return { exitCode: ExitCode.VAULT_PATH_INVALID, result: err("VAULT_PATH_INVALID", { vault: input.vault }) };
1142
+ }
1143
+ const logPath = join11(input.vault, "log.md");
1144
+ let logText;
1145
+ try {
1146
+ logText = await readFile10(logPath, "utf8");
1147
+ } catch {
1148
+ return { exitCode: ExitCode.FILE_NOT_FOUND, result: err("FILE_NOT_FOUND", { path: logPath }) };
1149
+ }
1150
+ const matches = [...logText.matchAll(ENTRY_RE)];
1151
+ const entries = matches.length;
1152
+ if (entries < input.threshold) {
1153
+ return { exitCode: ExitCode.OK, result: ok({ entries, threshold: input.threshold, rotated: false }) };
1154
+ }
1155
+ if (!input.apply) {
1156
+ return {
1157
+ exitCode: ExitCode.LOG_ROTATE_NEEDED,
1158
+ result: ok({ entries, threshold: input.threshold, rotated: false })
1159
+ };
1160
+ }
1161
+ const newestYear = matches[matches.length - 1][1];
1162
+ const rotatedName = `log-${newestYear}.md`;
1163
+ const rotatedPath = join11(input.vault, rotatedName);
1164
+ try {
1165
+ await rename2(logPath, rotatedPath);
1166
+ const today = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
1167
+ const fresh = `# Vault Log
1168
+
1169
+ Chronological action log. Newest entries last. Skill writes append entries; lint may rotate.
1170
+
1171
+ ## [${today}] rotate | Log rotated from ${entries} entries
1172
+
1173
+ - Previous log moved to ${rotatedName}
1174
+ `;
1175
+ await writeFile4(logPath, fresh, "utf8");
1176
+ } catch (e) {
1177
+ return { exitCode: ExitCode.WRITE_FAILED, result: err("WRITE_FAILED", { message: String(e) }) };
1178
+ }
1179
+ return { exitCode: ExitCode.OK, result: ok({ entries, threshold: input.threshold, rotated: true, rotated_to: rotatedName }) };
1180
+ }
1181
+
1182
+ // src/commands/lint.ts
1183
+ var ERROR_ORDER = ["broken_wikilinks", "invalid_frontmatter", "raw_drift", "tag_not_in_taxonomy"];
1184
+ var WARNING_ORDER = ["index_incomplete", "stale_page", "page_too_large", "log_rotate_needed", "contested", "orphans"];
1185
+ var INFO_ORDER = ["bridges", "low_confidence_single_source"];
1186
+ async function runLint(input) {
1187
+ const buckets = {};
1188
+ const links = await runLinks({ vault: input.vault });
1189
+ if (links.result.ok && links.result.data.broken.length > 0) buckets.broken_wikilinks = links.result.data.broken;
1190
+ if (!links.result.ok && links.result.error === "INVALID_FRONTMATTER") {
1191
+ buckets.invalid_frontmatter = [links.result.detail ?? {}];
1192
+ }
1193
+ const tags = await runTagAudit({ vault: input.vault });
1194
+ if (tags.result.ok && tags.result.data.violations.length > 0) buckets.tag_not_in_taxonomy = tags.result.data.violations;
1195
+ if (!tags.result.ok && tags.result.error === "INVALID_FRONTMATTER") {
1196
+ buckets.invalid_frontmatter = [...buckets.invalid_frontmatter ?? [], tags.result.detail ?? {}];
1197
+ }
1198
+ const idx = await runIndexCheck({ vault: input.vault });
1199
+ if (idx.result.ok && (idx.result.data.missing_from_index.length > 0 || idx.result.data.ghost_entries.length > 0)) {
1200
+ buckets.index_incomplete = [{
1201
+ missing_from_index: idx.result.data.missing_from_index,
1202
+ ghost_entries: idx.result.data.ghost_entries
1203
+ }];
1204
+ }
1205
+ const stale = await runStale({ vault: input.vault, days: input.days });
1206
+ if (stale.result.ok && stale.result.data.stale.length > 0) buckets.stale_page = stale.result.data.stale;
1207
+ const pagesize = await runPagesize({ vault: input.vault, lines: input.lines });
1208
+ if (pagesize.result.ok && pagesize.result.data.oversized.length > 0) buckets.page_too_large = pagesize.result.data.oversized;
1209
+ const rotate = await runLogRotate({ vault: input.vault, threshold: input.logThreshold, apply: false });
1210
+ if (rotate.result.ok && rotate.exitCode === ExitCode.LOG_ROTATE_NEEDED) {
1211
+ buckets.log_rotate_needed = [{ entries: rotate.result.data.entries, threshold: rotate.result.data.threshold }];
1212
+ }
1213
+ const orphans = await runOrphans({ vault: input.vault });
1214
+ if (orphans.result.ok) {
1215
+ if (orphans.result.data.orphans.length > 0) buckets.orphans = orphans.result.data.orphans;
1216
+ if (orphans.result.data.bridges.length > 0) buckets.bridges = orphans.result.data.bridges;
1217
+ }
1218
+ const errorOut = ERROR_ORDER.flatMap((k) => buckets[k] ? [{ kind: k, items: buckets[k] }] : []);
1219
+ const warningOut = WARNING_ORDER.flatMap((k) => buckets[k] ? [{ kind: k, items: buckets[k] }] : []);
1220
+ const infoOut = INFO_ORDER.flatMap((k) => buckets[k] ? [{ kind: k, items: buckets[k] }] : []);
1221
+ const summary = {
1222
+ errors: errorOut.reduce((n, b) => n + b.items.length, 0),
1223
+ warnings: warningOut.reduce((n, b) => n + b.items.length, 0),
1224
+ info: infoOut.reduce((n, b) => n + b.items.length, 0)
1225
+ };
1226
+ let exitCode = ExitCode.OK;
1227
+ if (summary.errors > 0) exitCode = ExitCode.LINT_HAS_ERRORS;
1228
+ else if (summary.warnings > 0 || summary.info > 0) exitCode = ExitCode.LINT_HAS_WARNINGS;
1229
+ return {
1230
+ exitCode,
1231
+ result: ok({
1232
+ vault: { path: input.vault, source: input.source ?? "resolved" },
1233
+ summary,
1234
+ by_severity: { error: errorOut, warning: warningOut, info: infoOut }
1235
+ })
1236
+ };
1237
+ }
1238
+
1239
+ // src/cli.ts
1240
+ var program = new Command();
1241
+ program.name("skillwiki").description("Deterministic helpers for CodeWiki skills").version("0.2.0-beta.2");
1242
+ program.option("--human", "render terminal-readable output instead of JSON");
1243
+ function emit(r) {
1244
+ if (program.opts().human) printHuman(r.result);
1245
+ else printJson(r.result);
1246
+ process.exit(r.exitCode);
1247
+ }
1248
+ program.command("hash <file>").action(async (file) => emit(await runHash({ file })));
1249
+ program.command("fetch-guard <url>").action(async (url) => emit(await runFetchGuard({ url })));
1250
+ program.command("validate <file>").action(async (file) => emit(await runValidate({ file })));
1251
+ program.command("graph").description("graph subcommands").command("build <vault>").option("--out <path>", "graph output path", ".skillwiki/graph.json").action(async (vault, opts) => emit(await runGraphBuild({ vault, out: opts.out })));
1252
+ program.command("overlap <vault>").action(async (vault) => emit(await runOverlap({ vault })));
1253
+ program.command("orphans [vault]").action(async (vault) => emit(await runOrphans({
1254
+ vault,
1255
+ envValue: process.env.WIKI_PATH,
1256
+ home: process.env.HOME ?? ""
1257
+ })));
1258
+ program.command("audit <file>").action(async (file) => emit(await runAudit({ file })));
1259
+ program.command("install").option("--target <dir>", "target install directory", `${process.env.HOME ?? ""}/.claude/skills/`).option("--dry-run", "preview only", false).option("--skills-root <dir>", "source skills directory (defaults to packaged)").action(async (opts) => {
1260
+ const skillsRoot = opts.skillsRoot ?? new URL("../../skills/", import.meta.url).pathname;
1261
+ emit(await runInstall({ skillsRoot, target: opts.target, dryRun: !!opts.dryRun }));
1262
+ });
1263
+ program.command("path").option("--vault <dir>", "explicit vault override (runtime)").option("--target <dir>", "explicit target override (init-time)").option("--init-time", "use init-time chain instead of runtime", false).option("--explain", "include resolution chain in output", false).action(async (opts) => {
1264
+ const initTime = !!opts.initTime;
1265
+ const flag = initTime ? opts.target : opts.vault;
1266
+ emit(await runPath({
1267
+ flag,
1268
+ envValue: process.env.WIKI_PATH,
1269
+ home: process.env.HOME ?? "",
1270
+ initTime,
1271
+ explain: !!opts.explain
1272
+ }));
1273
+ });
1274
+ program.command("lang").option("--lang <code>", "explicit language override").option("--explain", "include resolution chain in output", false).action(async (opts) => {
1275
+ emit(await runLang({
1276
+ flag: opts.lang,
1277
+ envValue: process.env.WIKI_LANG,
1278
+ home: process.env.HOME ?? "",
1279
+ explain: !!opts.explain
1280
+ }));
1281
+ });
1282
+ program.command("init").option("--target <dir>", "explicit target directory").requiredOption("--domain <text>", "knowledge domain seed").option("--taxonomy <csv>", "comma-separated tag list").option("--lang <code>", "output language (BCP 47 or alias)").option("--force", "override existing target / env conflict", false).action(async (opts) => {
1283
+ const templates = new URL("../templates/", import.meta.url).pathname;
1284
+ const taxonomy = typeof opts.taxonomy === "string" ? opts.taxonomy.split(",").map((s) => s.trim()).filter((s) => s.length > 0) : void 0;
1285
+ emit(await runInit({
1286
+ flag: opts.target,
1287
+ envValue: process.env.WIKI_PATH,
1288
+ home: process.env.HOME ?? "",
1289
+ templates,
1290
+ domain: opts.domain,
1291
+ taxonomy,
1292
+ lang: opts.lang,
1293
+ force: !!opts.force
1294
+ }));
1295
+ });
1296
+ async function resolveVaultArg(arg) {
1297
+ if (arg) return { ok: true, vault: arg };
1298
+ const r = await resolveRuntimePath({
1299
+ flag: void 0,
1300
+ envValue: process.env.WIKI_PATH,
1301
+ home: process.env.HOME ?? ""
1302
+ });
1303
+ if (!r.ok) return { ok: false, exitCode: 25, payload: r };
1304
+ return { ok: true, vault: r.data.path };
1305
+ }
1306
+ program.command("links [vault]").action(async (vault) => {
1307
+ const v = await resolveVaultArg(vault);
1308
+ if (!v.ok) emit({ exitCode: v.exitCode, result: v.payload });
1309
+ else emit(await runLinks({ vault: v.vault }));
1310
+ });
1311
+ program.command("tag-audit [vault]").action(async (vault) => {
1312
+ const v = await resolveVaultArg(vault);
1313
+ if (!v.ok) emit({ exitCode: v.exitCode, result: v.payload });
1314
+ else emit(await runTagAudit({ vault: v.vault }));
1315
+ });
1316
+ program.command("index-check [vault]").action(async (vault) => {
1317
+ const v = await resolveVaultArg(vault);
1318
+ if (!v.ok) emit({ exitCode: v.exitCode, result: v.payload });
1319
+ else emit(await runIndexCheck({ vault: v.vault }));
1320
+ });
1321
+ program.command("stale [vault]").option("--days <n>", "staleness threshold in days", (s) => parseInt(s, 10), 90).action(async (vault, opts) => {
1322
+ const v = await resolveVaultArg(vault);
1323
+ if (!v.ok) emit({ exitCode: v.exitCode, result: v.payload });
1324
+ else emit(await runStale({ vault: v.vault, days: opts.days }));
1325
+ });
1326
+ program.command("pagesize [vault]").option("--lines <n>", "max body lines", (s) => parseInt(s, 10), 200).action(async (vault, opts) => {
1327
+ const v = await resolveVaultArg(vault);
1328
+ if (!v.ok) emit({ exitCode: v.exitCode, result: v.payload });
1329
+ else emit(await runPagesize({ vault: v.vault, lines: opts.lines }));
1330
+ });
1331
+ program.command("log-rotate [vault]").option("--threshold <n>", "entry count threshold", (s) => parseInt(s, 10), 500).option("--apply", "actually rotate", false).action(async (vault, opts) => {
1332
+ const v = await resolveVaultArg(vault);
1333
+ if (!v.ok) emit({ exitCode: v.exitCode, result: v.payload });
1334
+ else emit(await runLogRotate({ vault: v.vault, threshold: opts.threshold, apply: !!opts.apply }));
1335
+ });
1336
+ program.command("lint [vault]").option("--days <n>", "stale threshold", (s) => parseInt(s, 10), 90).option("--lines <n>", "pagesize threshold", (s) => parseInt(s, 10), 200).option("--log-threshold <n>", "log rotation threshold", (s) => parseInt(s, 10), 500).action(async (vault, opts) => {
1337
+ const v = await resolveVaultArg(vault);
1338
+ if (!v.ok) emit({ exitCode: v.exitCode, result: v.payload });
1339
+ else emit(await runLint({
1340
+ vault: v.vault,
1341
+ source: vault ? "flag" : void 0,
1342
+ days: opts.days,
1343
+ lines: opts.lines,
1344
+ logThreshold: opts.logThreshold
1345
+ }));
1346
+ });
1347
+ program.parseAsync(process.argv).catch((e) => {
1348
+ process.stdout.write(JSON.stringify({ ok: false, error: "INTERNAL", detail: { message: String(e) } }) + "\n");
1349
+ process.exit(1);
1350
+ });