@djolex999/vir-cli 0.1.0

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.
Files changed (69) hide show
  1. package/CLAUDE.md +149 -0
  2. package/LICENSE +21 -0
  3. package/README.md +155 -0
  4. package/dist/claude/updater.js +230 -0
  5. package/dist/claude/updater.js.map +1 -0
  6. package/dist/cli.js +779 -0
  7. package/dist/cli.js.map +1 -0
  8. package/dist/config.js +82 -0
  9. package/dist/config.js.map +1 -0
  10. package/dist/daemon/launchd.js +93 -0
  11. package/dist/daemon/launchd.js.map +1 -0
  12. package/dist/dedupe/detector.js +159 -0
  13. package/dist/dedupe/detector.js.map +1 -0
  14. package/dist/dedupe/merger.js +116 -0
  15. package/dist/dedupe/merger.js.map +1 -0
  16. package/dist/lint/linter.js +224 -0
  17. package/dist/lint/linter.js.map +1 -0
  18. package/dist/pipeline/distiller.js +208 -0
  19. package/dist/pipeline/distiller.js.map +1 -0
  20. package/dist/pipeline/filter.js +28 -0
  21. package/dist/pipeline/filter.js.map +1 -0
  22. package/dist/pipeline/parser.js +109 -0
  23. package/dist/pipeline/parser.js.map +1 -0
  24. package/dist/pipeline/run.js +312 -0
  25. package/dist/pipeline/run.js.map +1 -0
  26. package/dist/pipeline/scanner.js +47 -0
  27. package/dist/pipeline/scanner.js.map +1 -0
  28. package/dist/pipeline/scrubber.js +51 -0
  29. package/dist/pipeline/scrubber.js.map +1 -0
  30. package/dist/pipeline/summarizer.js +162 -0
  31. package/dist/pipeline/summarizer.js.map +1 -0
  32. package/dist/pipeline/types.js +2 -0
  33. package/dist/pipeline/types.js.map +1 -0
  34. package/dist/pipeline/writer.js +195 -0
  35. package/dist/pipeline/writer.js.map +1 -0
  36. package/dist/search/embedder.js +93 -0
  37. package/dist/search/embedder.js.map +1 -0
  38. package/dist/search/retriever.js +212 -0
  39. package/dist/search/retriever.js.map +1 -0
  40. package/dist/search/synthesizer.js +26 -0
  41. package/dist/search/synthesizer.js.map +1 -0
  42. package/dist/state/db.js +309 -0
  43. package/dist/state/db.js.map +1 -0
  44. package/dist/ui/display.js +148 -0
  45. package/dist/ui/display.js.map +1 -0
  46. package/package.json +50 -0
  47. package/src/claude/updater.ts +273 -0
  48. package/src/cli.ts +953 -0
  49. package/src/config.ts +89 -0
  50. package/src/daemon/launchd.ts +115 -0
  51. package/src/dedupe/detector.ts +197 -0
  52. package/src/dedupe/merger.ts +172 -0
  53. package/src/lint/linter.ts +286 -0
  54. package/src/pipeline/distiller.ts +280 -0
  55. package/src/pipeline/filter.ts +43 -0
  56. package/src/pipeline/parser.ts +118 -0
  57. package/src/pipeline/run.ts +378 -0
  58. package/src/pipeline/scanner.ts +51 -0
  59. package/src/pipeline/scrubber.ts +55 -0
  60. package/src/pipeline/summarizer.ts +204 -0
  61. package/src/pipeline/types.ts +41 -0
  62. package/src/pipeline/writer.ts +242 -0
  63. package/src/search/embedder.ts +88 -0
  64. package/src/search/retriever.ts +255 -0
  65. package/src/search/synthesizer.ts +45 -0
  66. package/src/state/db.ts +451 -0
  67. package/src/ui/display.ts +184 -0
  68. package/tsconfig.json +23 -0
  69. package/vir-flow.html +708 -0
package/dist/cli.js ADDED
@@ -0,0 +1,779 @@
1
+ #!/usr/bin/env node
2
+ import confirm from "@inquirer/confirm";
3
+ import input from "@inquirer/input";
4
+ import select from "@inquirer/select";
5
+ import chalk from "chalk";
6
+ import { Command } from "commander";
7
+ import { createInterface } from "node:readline/promises";
8
+ import { stdin, stdout, argv, exit } from "node:process";
9
+ import { existsSync, mkdirSync, realpathSync } from "node:fs";
10
+ import { homedir } from "node:os";
11
+ import { join } from "node:path";
12
+ import { CONFIG_PATH, ConfigSchema, configExists, ensureVirDir, expandHome, loadConfig, saveConfig, } from "./config.js";
13
+ import { applyPlan, planUpdates } from "./claude/updater.js";
14
+ import { detectDuplicates } from "./dedupe/detector.js";
15
+ import { mergeNotes } from "./dedupe/merger.js";
16
+ import { contradictionCheck, orphanCheck, stalenessCheck, } from "./lint/linter.js";
17
+ import { runPipeline } from "./pipeline/run.js";
18
+ import { summarizeAll, summarizeProject } from "./pipeline/summarizer.js";
19
+ import { embeddingForNote, isOllamaAvailable, } from "./search/embedder.js";
20
+ import { search } from "./search/retriever.js";
21
+ import { synthesize } from "./search/synthesizer.js";
22
+ import { daemonStatus, installPlist, plistPath, uninstallPlist, } from "./daemon/launchd.js";
23
+ import { StateDb } from "./state/db.js";
24
+ import * as ui from "./ui/display.js";
25
+ import { VaultWriter } from "./pipeline/writer.js";
26
+ const program = new Command();
27
+ program
28
+ .name("vir")
29
+ .description("Distill Claude Code sessions into an Obsidian vault")
30
+ .version("0.1.0");
31
+ program
32
+ .command("init")
33
+ .description("Interactive setup")
34
+ .action(async () => {
35
+ await cmdInit();
36
+ });
37
+ program
38
+ .command("run")
39
+ .description("Run pipeline once")
40
+ .option("--full", "Re-process all sessions, ignoring state cache")
41
+ .option("--daemon", "Quiet output, write to daemon log file")
42
+ .option("--rewrite-only", "Skip scan/filter/LLM; re-render stored notes from SQLite")
43
+ .option("--yes", "Skip the cost confirmation prompt")
44
+ .action(async (opts) => {
45
+ const cfg = loadConfig();
46
+ const daemon = opts.daemon === true;
47
+ const rewriteOnly = opts.rewriteOnly === true;
48
+ const skipPrompt = opts.yes === true || daemon || rewriteOnly;
49
+ await runPipeline(cfg, {
50
+ full: opts.full,
51
+ quiet: daemon,
52
+ logToFile: daemon,
53
+ rewriteOnly,
54
+ onConfirm: skipPrompt
55
+ ? undefined
56
+ : async (newCount) => confirmCostIfNeeded(cfg, newCount),
57
+ });
58
+ });
59
+ async function confirmCostIfNeeded(cfg, newCount) {
60
+ if (newCount <= 20)
61
+ return true;
62
+ ui.box([
63
+ `${ui.text(String(newCount))} ${ui.dim("new sessions to process")}`,
64
+ `${ui.dim("estimated:")} ${ui.warn("$1–5")} ${ui.dim("depending on session")}`,
65
+ `${ui.dim("depth (deep code reviews cost more)")}`,
66
+ `${ui.dim("provider:")} ${ui.accent(cfg.provider)}`,
67
+ ], { title: "cost estimate" });
68
+ const rl = createInterface({ input: stdin, output: stdout });
69
+ const ans = (await rl.question(ui.muted("continue? (y/n) ")))
70
+ .trim()
71
+ .toLowerCase();
72
+ rl.close();
73
+ return ans === "y" || ans === "yes";
74
+ }
75
+ const schedule = program.command("schedule").description("Manage launchd daemon");
76
+ schedule
77
+ .command("install")
78
+ .description("Install + load launchd plist")
79
+ .action(() => {
80
+ const cfg = loadConfig();
81
+ const nodePath = process.execPath;
82
+ const cliPath = realpathSync(argv[1] ?? "");
83
+ const res = installPlist({
84
+ nodePath,
85
+ cliPath,
86
+ cadenceHours: cfg.cadenceHours,
87
+ });
88
+ console.log(chalk.green(`installed: ${res.plistPath} (loaded=${res.loaded})`));
89
+ });
90
+ schedule
91
+ .command("uninstall")
92
+ .description("Unload + remove launchd plist")
93
+ .action(() => {
94
+ const res = uninstallPlist();
95
+ if (res.removed)
96
+ console.log(chalk.green(`removed ${plistPath()}`));
97
+ else
98
+ console.log(chalk.yellow(`no plist found at ${plistPath()}`));
99
+ });
100
+ program
101
+ .command("sync-claude [project]")
102
+ .description("Update Vir blocks in CLAUDE.md files (global + per-project)")
103
+ .option("--dry-run", "Show diff only, never write")
104
+ .option("--force", "Apply without confirmation")
105
+ .option("--global", "Only update ~/.claude/CLAUDE.md")
106
+ .action(async (projectArg, opts) => {
107
+ const cfg = loadConfig();
108
+ const db = new StateDb();
109
+ try {
110
+ const plans = planUpdates(cfg, db, {
111
+ project: projectArg,
112
+ globalOnly: opts.global === true,
113
+ });
114
+ ui.header(`sync-claude${opts.dryRun ? " --dry-run" : opts.force ? " --force" : ""}`);
115
+ ui.blank();
116
+ if (plans.length === 0) {
117
+ ui.row(ui.warn(ui.WARN_GLYPH), ui.text("nothing to plan"));
118
+ return;
119
+ }
120
+ for (const p of plans) {
121
+ renderPlan(p);
122
+ ui.blank();
123
+ }
124
+ if (opts.dryRun) {
125
+ ui.line(ui.dim("run without --dry-run to apply"));
126
+ return;
127
+ }
128
+ let proceed = opts.force === true;
129
+ if (!proceed) {
130
+ const rl = createInterface({ input: stdin, output: stdout });
131
+ const ans = (await rl.question(ui.dim("apply these changes? (y/n) ")))
132
+ .trim()
133
+ .toLowerCase();
134
+ rl.close();
135
+ proceed = ans === "y" || ans === "yes";
136
+ }
137
+ if (!proceed) {
138
+ ui.line(ui.dim("aborted"));
139
+ return;
140
+ }
141
+ for (const p of plans) {
142
+ if (!p.exists) {
143
+ ui.row(ui.warn(ui.WARN_GLYPH), ui.text(`skipped ${collapseHome(p.target)}`));
144
+ continue;
145
+ }
146
+ const ok = applyPlan(p);
147
+ ui.row(ok ? ui.success(ui.CHECK) : ui.errorColor(ui.CROSS), ui.text(collapseHome(p.target)));
148
+ }
149
+ }
150
+ finally {
151
+ db.close();
152
+ }
153
+ });
154
+ program
155
+ .command("dedupe")
156
+ .description("Interactive duplicate detection + merge")
157
+ .action(async () => {
158
+ const cfg = loadConfig();
159
+ const db = new StateDb();
160
+ try {
161
+ console.log("scanning for duplicate candidates...");
162
+ const result = await detectDuplicates(cfg, db);
163
+ console.log(`${result.checked} candidate pairs checked, ${result.duplicates.length} flagged as duplicates`);
164
+ if (result.duplicates.length === 0) {
165
+ return;
166
+ }
167
+ const rl = createInterface({ input: stdin, output: stdout });
168
+ let merged = 0;
169
+ let skipped = 0;
170
+ for (const dup of result.duplicates) {
171
+ console.log("\nDuplicate found:");
172
+ console.log(`A: ${noteRefOf(dup.a)} (conf: ${dup.a.confidence.toFixed(2)}, ${dup.a.startedAt?.slice(0, 10) ?? "?"})`);
173
+ console.log(` "${preview(dup.a.content)}"`);
174
+ console.log(`B: ${noteRefOf(dup.b)} (conf: ${dup.b.confidence.toFixed(2)}, ${dup.b.startedAt?.slice(0, 10) ?? "?"})`);
175
+ console.log(` "${preview(dup.b.content)}"`);
176
+ console.log(`Reason: ${dup.reason}`);
177
+ const suggestion = dup.keepWhich === "merge"
178
+ ? "merge both"
179
+ : `keep ${dup.keepWhich}`;
180
+ console.log(`Suggested: ${suggestion}`);
181
+ const ans = (await rl.question("[k]eep suggestion / [s]wap / [m]erge / [x] skip: "))
182
+ .trim()
183
+ .toLowerCase();
184
+ let action = null;
185
+ if (ans === "k" || ans === "") {
186
+ action = dup.keepWhich;
187
+ }
188
+ else if (ans === "s") {
189
+ action =
190
+ dup.keepWhich === "A"
191
+ ? "B"
192
+ : dup.keepWhich === "B"
193
+ ? "A"
194
+ : "merge";
195
+ }
196
+ else if (ans === "m") {
197
+ action = "merge";
198
+ }
199
+ else if (ans === "x") {
200
+ skipped += 1;
201
+ continue;
202
+ }
203
+ else {
204
+ console.log(chalk.yellow("unknown input — skipping"));
205
+ skipped += 1;
206
+ continue;
207
+ }
208
+ try {
209
+ const outcome = await mergeNotes(cfg, db, dup.a, dup.b, action);
210
+ merged += 1;
211
+ console.log(chalk.green(`merged (${outcome.action}): winner=${outcome.winnerPath} archived=${outcome.archivedPath}`));
212
+ }
213
+ catch (err) {
214
+ console.error(chalk.red(`merge failed: ${err.message}`));
215
+ }
216
+ }
217
+ rl.close();
218
+ console.log(`\n${result.duplicates.length} pairs reviewed, ${merged} merged, ${skipped} skipped.`);
219
+ }
220
+ finally {
221
+ db.close();
222
+ }
223
+ });
224
+ program
225
+ .command("lint")
226
+ .description("Run orphan, staleness, and contradiction checks on the vault")
227
+ .option("--orphans", "Run only the orphan check (free)")
228
+ .option("--stale", "Run only the staleness check (free)")
229
+ .option("--contradictions", "Run only the contradiction check (Haiku tokens)")
230
+ .action(async (opts) => {
231
+ const cfg = loadConfig();
232
+ const db = new StateDb();
233
+ try {
234
+ const runAll = !opts.orphans && !opts.stale && !opts.contradictions;
235
+ const checks = [];
236
+ if (runAll || opts.orphans)
237
+ checks.push("orphans");
238
+ if (runAll || opts.stale)
239
+ checks.push("stale");
240
+ if (runAll || opts.contradictions)
241
+ checks.push("contradictions");
242
+ ui.header("lint");
243
+ ui.blank();
244
+ let orphanCount = 0;
245
+ let staleCount = 0;
246
+ let contradictionCount = 0;
247
+ let issues = 0;
248
+ if (runAll || opts.orphans) {
249
+ const sp = ui.spinner("checking orphans").start();
250
+ const r = orphanCheck(cfg);
251
+ sp.stop();
252
+ orphanCount = r.orphans.length;
253
+ issues += orphanCount;
254
+ if (orphanCount === 0) {
255
+ ui.row(ui.success(ui.CHECK), `${ui.text("orphans")} ${ui.dim("none")}`);
256
+ }
257
+ else {
258
+ ui.row(ui.errorColor(ui.CROSS), `${ui.text("orphans")} ${ui.dim("(" + orphanCount + ")")}`);
259
+ for (const o of r.orphans) {
260
+ console.log(` ${ui.dim(ui.BULLET)} ${ui.text(ui.shortNotePath(o))}`);
261
+ }
262
+ }
263
+ }
264
+ if (runAll || opts.stale) {
265
+ const sp = ui.spinner("checking staleness").start();
266
+ const stale = stalenessCheck(cfg, db);
267
+ sp.stop();
268
+ staleCount = stale.length;
269
+ issues += staleCount;
270
+ if (staleCount === 0) {
271
+ ui.row(ui.success(ui.CHECK), `${ui.text("stale")} ${ui.dim("none")}`);
272
+ }
273
+ else {
274
+ ui.row(ui.errorColor(ui.CROSS), `${ui.text("stale")} ${ui.dim("(" + staleCount + ")")}`);
275
+ for (const s of stale) {
276
+ console.log(` ${ui.dim(ui.BULLET)} ${ui.text(ui.shortNotePath(s.relPath))} ${ui.muted(`${s.ageDays}d`)} ${ui.dim(`${s.newerSameProjectCount} newer ${s.project} sessions`)}`);
277
+ }
278
+ }
279
+ }
280
+ if (runAll || opts.contradictions) {
281
+ const sp = ui.spinner("checking contradictions (haiku)").start();
282
+ const c = await contradictionCheck(cfg, db);
283
+ sp.stop();
284
+ contradictionCount = c.contradictions.length;
285
+ issues += contradictionCount;
286
+ if (contradictionCount === 0) {
287
+ ui.row(ui.success(ui.CHECK), `${ui.text("contradictions")} ${ui.dim(`none found in ${c.checked} pairs`)}`);
288
+ }
289
+ else {
290
+ ui.row(ui.errorColor(ui.CROSS), `${ui.text("contradictions")} ${ui.dim("(" + contradictionCount + ")")}`);
291
+ for (const x of c.contradictions) {
292
+ console.log(` ${ui.dim(ui.BULLET)} ${ui.text(ui.shortNotePath(x.a))} ${ui.dim("vs")} ${ui.text(ui.shortNotePath(x.b))}`);
293
+ console.log(` ${ui.muted(x.reason)}`);
294
+ }
295
+ }
296
+ }
297
+ ui.blank();
298
+ ui.divider();
299
+ ui.summary({
300
+ issues: {
301
+ value: issues,
302
+ color: issues > 0 ? ui.errorColor : ui.success,
303
+ },
304
+ orphans: { value: orphanCount, color: ui.muted },
305
+ stale: { value: staleCount, color: ui.muted },
306
+ contradictions: { value: contradictionCount, color: ui.muted },
307
+ });
308
+ ui.divider();
309
+ }
310
+ finally {
311
+ db.close();
312
+ }
313
+ });
314
+ program
315
+ .command("summarize [project]")
316
+ .description("Generate or regenerate a project knowledge summary")
317
+ .option("--all", "Regenerate summaries for every project with notes")
318
+ .action(async (project, opts) => {
319
+ const cfg = loadConfig();
320
+ const db = new StateDb();
321
+ try {
322
+ if (opts.all) {
323
+ const results = await summarizeAll(cfg, db);
324
+ if (results.length === 0) {
325
+ console.log(chalk.yellow("no projects with notes"));
326
+ return;
327
+ }
328
+ for (const r of results) {
329
+ console.log(chalk.green(`summarized project/${r.slug}`) +
330
+ ` (${r.counts.total} sessions)`);
331
+ }
332
+ return;
333
+ }
334
+ if (!project) {
335
+ console.error(chalk.red("usage: vir summarize <project> | --all"));
336
+ exit(1);
337
+ }
338
+ const res = await summarizeProject(cfg, project, db);
339
+ if (!res) {
340
+ console.log(chalk.yellow(`no distilled notes for project '${project}'`));
341
+ return;
342
+ }
343
+ console.log(chalk.green(`summarized project/${res.slug}`) +
344
+ ` (${res.counts.total} sessions) → ${res.path}`);
345
+ }
346
+ finally {
347
+ db.close();
348
+ }
349
+ });
350
+ program
351
+ .command("embed")
352
+ .description("Generate Ollama embeddings for distilled notes")
353
+ .option("--force", "Regenerate even if embedding already exists")
354
+ .action(async (opts) => {
355
+ const cfg = loadConfig();
356
+ ui.header("embed");
357
+ ui.blank();
358
+ if (!(await isOllamaAvailable())) {
359
+ ui.row(ui.errorColor(ui.CROSS), ui.text("Ollama not running"));
360
+ ui.line(ui.dim(" brew install ollama"));
361
+ ui.line(ui.dim(" ollama pull nomic-embed-text"));
362
+ ui.line(ui.dim(" ollama serve"));
363
+ exit(1);
364
+ }
365
+ const db = new StateDb();
366
+ try {
367
+ const rows = db.listDistilled();
368
+ const root = join(cfg.vaultPath, cfg.outputDir);
369
+ const existing = new Set(db.getEmbeddings(root).map((r) => r.sessionId));
370
+ const target = opts.force
371
+ ? rows
372
+ : rows.filter((r) => !existing.has(r.sessionId));
373
+ if (target.length === 0) {
374
+ ui.row(ui.success(ui.CHECK), ui.text("all notes already embedded"));
375
+ return;
376
+ }
377
+ const sp = ui.spinner(`embedding notes (0/${target.length})`).start();
378
+ let embedded = 0;
379
+ let skipped = 0;
380
+ let errors = 0;
381
+ for (let i = 0; i < target.length; i += 1) {
382
+ const r = target[i];
383
+ if (!r)
384
+ continue;
385
+ if (r.content.trim().length === 0) {
386
+ skipped += 1;
387
+ continue;
388
+ }
389
+ const vec = await embeddingForNote(r.content);
390
+ if (!vec) {
391
+ errors += 1;
392
+ continue;
393
+ }
394
+ db.storeEmbedding(r.sessionId, vec);
395
+ embedded += 1;
396
+ sp.text = ui.dim(`embedding notes (${embedded}/${target.length})`);
397
+ }
398
+ sp.succeed(ui.text(`embedded ${embedded} notes`));
399
+ ui.blank();
400
+ ui.divider();
401
+ ui.summary({
402
+ embedded: { value: embedded, color: ui.success },
403
+ skipped: { value: skipped, color: ui.muted },
404
+ errors: {
405
+ value: errors,
406
+ color: errors > 0 ? ui.errorColor : ui.dim,
407
+ },
408
+ });
409
+ ui.divider();
410
+ }
411
+ finally {
412
+ db.close();
413
+ }
414
+ });
415
+ program
416
+ .command("query <question>")
417
+ .description("Search the vault: embedding/TF-IDF retrieval + Claude synthesis")
418
+ .action(async (question) => {
419
+ const cfg = loadConfig();
420
+ const db = new StateDb();
421
+ try {
422
+ ui.header("query");
423
+ ui.divider();
424
+ console.log(ui.text(question));
425
+ ui.divider();
426
+ ui.blank();
427
+ const ollamaUp = await isOllamaAvailable();
428
+ const sp = ui
429
+ .spinner(`searching vault (${ollamaUp ? "embeddings" : "tfidf"})`)
430
+ .start();
431
+ let hits;
432
+ try {
433
+ hits = await search(cfg, db, question, 8);
434
+ sp.stop();
435
+ }
436
+ catch (err) {
437
+ sp.fail(ui.errorColor(err.message));
438
+ return;
439
+ }
440
+ if (hits.length === 0) {
441
+ ui.row(ui.warn(ui.WARN_GLYPH), ui.text("no documents matched"));
442
+ return;
443
+ }
444
+ const answer = await synthesize(cfg, question, hits);
445
+ ui.blank();
446
+ console.log(ui.text(ui.wrap(answer.trim(), 60)));
447
+ ui.blank();
448
+ const method = hits[0]?.method ?? "tfidf";
449
+ const relevant = hits.filter((h) => h.score > 0).slice(0, 3);
450
+ ui.divider();
451
+ for (const h of relevant)
452
+ ui.sourceRow(h.title, h.score);
453
+ ui.divider();
454
+ const totalNotes = method === "embedding"
455
+ ? db.getEmbeddings(join(cfg.vaultPath, cfg.outputDir)).length
456
+ : new VaultWriter(cfg).noteCount();
457
+ ui.summary({
458
+ sources: { value: relevant.length, color: ui.info },
459
+ via: { value: method, color: ui.accent },
460
+ searched: { value: totalNotes, color: ui.muted },
461
+ });
462
+ }
463
+ finally {
464
+ db.close();
465
+ }
466
+ });
467
+ program
468
+ .command("status")
469
+ .description("Show processing status + knowledge base breakdown")
470
+ .action(() => {
471
+ const cfg = configExists() ? loadConfig() : null;
472
+ if (!cfg) {
473
+ ui.header("status");
474
+ ui.row(ui.warn(ui.WARN_GLYPH), ui.text("not configured — run `vir init`"));
475
+ return;
476
+ }
477
+ const db = new StateDb();
478
+ const knowledge = db.getStats();
479
+ db.close();
480
+ const ds = daemonStatus();
481
+ ui.header("status");
482
+ ui.blank();
483
+ renderKnowledge(knowledge);
484
+ ui.blank();
485
+ renderDaemon(ds, cfg.cadenceHours);
486
+ });
487
+ function renderKnowledge(k) {
488
+ if (k.total === 0) {
489
+ ui.box([
490
+ ui.text("no distilled notes yet"),
491
+ ui.dim("run `vir run --full` to populate"),
492
+ ], { title: "knowledge" });
493
+ return;
494
+ }
495
+ const lines = [];
496
+ lines.push(`${ui.dim("notes")} ${ui.text(String(k.total).padStart(3))} ${ui.dim("avg conf")} ${ui.info(k.avgConfidence.toFixed(2))}`);
497
+ lines.push(`${ui.dim("high signal")} ${ui.success(String(k.highConf).padStart(2))} ${ui.dim("low signal")} ${ui.errorColor(String(k.lowConf).padStart(2))}`);
498
+ const oldest = (k.oldestNote || "?").slice(0, 10);
499
+ const newest = (k.newestNote || "?").slice(0, 10);
500
+ lines.push(`${ui.muted(oldest)} ${ui.dim(ui.ARROW)} ${ui.muted(newest)}`);
501
+ ui.box(lines, { title: "knowledge" });
502
+ ui.blank();
503
+ const entries = [
504
+ ["pattern", k.byCategory.pattern ?? 0],
505
+ ["decision", k.byCategory.decision ?? 0],
506
+ ["gotcha", k.byCategory.gotcha ?? 0],
507
+ ["tool", k.byCategory.tool ?? 0],
508
+ ];
509
+ const maxCount = Math.max(1, ...entries.map(([, c]) => c));
510
+ for (const [label, count] of entries) {
511
+ const w = 16;
512
+ const filled = Math.round((count / maxCount) * w);
513
+ const bar = "█".repeat(filled) + "░".repeat(w - filled);
514
+ const pct = k.total > 0 ? Math.round((count / k.total) * 100) : 0;
515
+ const color = ui.colorForCategory[label] ?? ui.text;
516
+ console.log(`${color(label.padEnd(9))} ${color(bar)} ${ui.text(String(count).padStart(3))} ${ui.dim(String(pct).padStart(3) + "%")}`);
517
+ }
518
+ ui.blank();
519
+ const projectLines = [];
520
+ const projects = Object.entries(k.byProject).sort((a, b) => b[1].total - a[1].total);
521
+ for (const [name, p] of projects) {
522
+ const last = p.lastSeen ? p.lastSeen.slice(0, 10) : "—";
523
+ const conf = p.avgConfidence.toFixed(2);
524
+ projectLines.push(`${ui.text(name.padEnd(10).slice(0, 10))} ${ui.info(String(p.total).padStart(3))} ` +
525
+ `${ui.dim("P")}${ui.text(String(p.patterns).padStart(2))} ` +
526
+ `${ui.dim("G")}${ui.text(String(p.gotchas).padStart(2))} ` +
527
+ `${ui.dim("D")}${ui.text(String(p.decisions).padStart(2))} ` +
528
+ `${ui.dim("T")}${ui.text(String(p.tools).padStart(2))} ` +
529
+ `${ui.info(conf)} ${ui.muted(last)}`);
530
+ }
531
+ ui.box(projectLines, { title: "projects", width: 52 });
532
+ ui.blank();
533
+ for (const [name, p] of projects) {
534
+ if (p.total === 0)
535
+ continue;
536
+ if (p.gotchas === 0) {
537
+ ui.row(ui.warn(ui.WARN_GLYPH), ui.text(`${name} — no gotchas recorded`));
538
+ }
539
+ if (p.decisions === 0) {
540
+ ui.row(ui.warn(ui.WARN_GLYPH), ui.text(`${name} — no architecture decisions`));
541
+ }
542
+ if (p.avgConfidence < 0.65) {
543
+ ui.row(ui.warn(ui.WARN_GLYPH), ui.text(`${name} — low avg confidence (${p.avgConfidence.toFixed(2)})`));
544
+ }
545
+ }
546
+ }
547
+ function renderDaemon(ds, cadenceHours) {
548
+ const status = ds.loaded ? "running" : ds.installed ? "loaded" : "off";
549
+ const statusColor = ds.loaded
550
+ ? ui.success
551
+ : ds.installed
552
+ ? ui.warn
553
+ : ui.dim;
554
+ ui.box([
555
+ `${ui.dim("status")} ${statusColor(status)}`,
556
+ `${ui.dim("cadence")} ${ui.text(`every ${cadenceHours}h`)}`,
557
+ `${ui.dim("plist")} ${ui.muted(collapseHome(ds.plistPath))}`,
558
+ ], { title: "daemon", width: 52 });
559
+ }
560
+ async function cmdInit() {
561
+ ensureVirDir();
562
+ const existing = configExists() ? safeLoad() : null;
563
+ ui.header("init");
564
+ ui.blank();
565
+ // ── vault path ──────────────────────────────────────────────────────────
566
+ let vaultPath = "";
567
+ for (;;) {
568
+ vaultPath = await input({
569
+ message: "Obsidian vault path",
570
+ default: existing?.vaultPath ??
571
+ join(homedir(), "Documents", "Obsidian", "MyVault"),
572
+ });
573
+ const expanded = expandHome(vaultPath);
574
+ if (existsSync(expanded))
575
+ break;
576
+ const create = await confirm({
577
+ message: `Vault path does not exist (${expanded}). Create it?`,
578
+ default: true,
579
+ });
580
+ if (create) {
581
+ try {
582
+ mkdirSync(expanded, { recursive: true });
583
+ break;
584
+ }
585
+ catch (err) {
586
+ console.error(chalk.red(`failed to create: ${err.message}`));
587
+ }
588
+ }
589
+ }
590
+ const outputDir = await input({
591
+ message: "Output subdir inside vault",
592
+ default: existing?.outputDir ?? "vir",
593
+ });
594
+ // ── claude projects dir ─────────────────────────────────────────────────
595
+ let claudeProjectsDir = "";
596
+ for (;;) {
597
+ claudeProjectsDir = await input({
598
+ message: "Claude Code projects dir",
599
+ default: existing?.claudeProjectsDir ?? join(homedir(), ".claude", "projects"),
600
+ });
601
+ const expanded = expandHome(claudeProjectsDir);
602
+ if (existsSync(expanded))
603
+ break;
604
+ console.warn(chalk.yellow("directory not found — Claude Code sessions may not exist yet"));
605
+ const cont = await confirm({
606
+ message: "continue anyway?",
607
+ default: false,
608
+ });
609
+ if (cont)
610
+ break;
611
+ }
612
+ const cadenceHours = Number(await input({
613
+ message: "Cadence (hours)",
614
+ default: String(existing?.cadenceHours ?? 4),
615
+ validate: (v) => {
616
+ const n = Number(v);
617
+ return Number.isFinite(n) && n > 0 ? true : "must be a positive number";
618
+ },
619
+ }));
620
+ // ── provider picker ─────────────────────────────────────────────────────
621
+ const provider = (await select({
622
+ message: "Provider",
623
+ default: existing?.provider ?? "anthropic",
624
+ choices: [
625
+ {
626
+ name: "Anthropic (direct, official pricing)",
627
+ value: "anthropic",
628
+ },
629
+ {
630
+ name: "Kie.ai (same Claude models, ~72% cheaper)",
631
+ value: "kie",
632
+ },
633
+ ],
634
+ }));
635
+ let anthropicApiKey;
636
+ let kieApiKey;
637
+ if (provider === "anthropic") {
638
+ anthropicApiKey = await input({
639
+ message: "Anthropic API key",
640
+ default: existing?.anthropicApiKey ?? process.env.ANTHROPIC_API_KEY ?? "",
641
+ validate: (v) => v.startsWith("sk-ant-") ? true : "key should start with sk-ant-",
642
+ });
643
+ }
644
+ else {
645
+ kieApiKey = await input({
646
+ message: "Kie.ai API key",
647
+ default: existing?.kieApiKey ?? process.env.KIE_API_KEY ?? "",
648
+ validate: (v) => v.length > 10 ? true : "enter a valid Kie.ai API key",
649
+ });
650
+ }
651
+ // ── model pickers (provider-aware) ──────────────────────────────────────
652
+ const classifyChoices = provider === "anthropic"
653
+ ? [
654
+ {
655
+ name: "claude-haiku-4-5-20251001 (recommended)",
656
+ value: "claude-haiku-4-5-20251001",
657
+ },
658
+ { name: "claude-sonnet-4-6", value: "claude-sonnet-4-6" },
659
+ ]
660
+ : [
661
+ {
662
+ name: "claude-haiku-4-5 (recommended)",
663
+ value: "claude-haiku-4-5",
664
+ },
665
+ { name: "claude-sonnet-4-6", value: "claude-sonnet-4-6" },
666
+ ];
667
+ const classifyModel = await select({
668
+ message: "Classify model (fast pass)",
669
+ choices: classifyChoices,
670
+ });
671
+ const distillChoices = provider === "anthropic"
672
+ ? [
673
+ {
674
+ name: "claude-sonnet-4-6 (recommended)",
675
+ value: "claude-sonnet-4-6",
676
+ },
677
+ {
678
+ name: "claude-haiku-4-5-20251001 (faster, cheaper)",
679
+ value: "claude-haiku-4-5-20251001",
680
+ },
681
+ ]
682
+ : [
683
+ {
684
+ name: "claude-sonnet-4-6 (recommended)",
685
+ value: "claude-sonnet-4-6",
686
+ },
687
+ {
688
+ name: "claude-haiku-4-5 (faster, cheaper)",
689
+ value: "claude-haiku-4-5",
690
+ },
691
+ ];
692
+ const distillModel = await select({
693
+ message: "Distill model (deep extraction)",
694
+ choices: distillChoices,
695
+ });
696
+ const filterThreshold = Number(await input({
697
+ message: "Filter threshold (0..1)",
698
+ default: String(existing?.filterThreshold ?? 0.4),
699
+ validate: (v) => {
700
+ const n = Number(v);
701
+ return Number.isFinite(n) && n >= 0 && n <= 1
702
+ ? true
703
+ : "must be between 0 and 1";
704
+ },
705
+ }));
706
+ const parsed = ConfigSchema.safeParse({
707
+ vaultPath,
708
+ outputDir,
709
+ claudeProjectsDir,
710
+ cadenceHours,
711
+ provider,
712
+ anthropicApiKey,
713
+ kieApiKey,
714
+ filterThreshold,
715
+ models: { classify: classifyModel, distill: distillModel },
716
+ });
717
+ if (!parsed.success) {
718
+ console.error(chalk.red("invalid config:"));
719
+ for (const issue of parsed.error.issues) {
720
+ console.error(` - ${issue.path.join(".")}: ${issue.message}`);
721
+ }
722
+ exit(1);
723
+ }
724
+ saveConfig(parsed.data);
725
+ ui.blank();
726
+ ui.row(ui.success(ui.CHECK), ui.text(`saved ${CONFIG_PATH}`));
727
+ ui.line(ui.dim("next: `vir run` to test once, then `vir schedule install`"));
728
+ }
729
+ function renderPlan(p) {
730
+ const title = collapseHome(p.target);
731
+ if (!p.exists) {
732
+ ui.box([ui.dim("no CLAUDE.md found — would be skipped")], { title });
733
+ return;
734
+ }
735
+ const lines = [];
736
+ for (const e of p.diff.added) {
737
+ lines.push(`${ui.success("+")} ${ui.text(e.slug)}`);
738
+ }
739
+ for (const u of p.diff.upgraded) {
740
+ lines.push(`${ui.info(ui.UP_ARROW)} ${ui.text(u.slug)} ${ui.dim(`${u.oldConf.toFixed(2)}${ui.ARROW}${u.newConf.toFixed(2)}`)}`);
741
+ }
742
+ for (const r of p.diff.removed) {
743
+ lines.push(`${ui.warn("-")} ${ui.text(r.slug)}`);
744
+ }
745
+ if (p.diff.unchanged.length > 0) {
746
+ lines.push(`${ui.dim("~")} ${ui.dim(`${p.diff.unchanged.length} entries unchanged`)}`);
747
+ }
748
+ if (lines.length === 0)
749
+ lines.push(ui.dim("no changes"));
750
+ ui.box(lines, { title });
751
+ }
752
+ function collapseHome(p) {
753
+ const h = homedir();
754
+ return p.startsWith(h) ? "~" + p.slice(h.length) : p;
755
+ }
756
+ function noteRefOf(r) {
757
+ const dir = `${r.category}s`;
758
+ const slug = r.topic
759
+ .toLowerCase()
760
+ .replace(/[^a-z0-9]+/g, "-")
761
+ .replace(/^-+|-+$/g, "");
762
+ return `${dir}/${slug}-${r.sessionId.slice(0, 8)}`;
763
+ }
764
+ function preview(s) {
765
+ return s.replace(/\s+/g, " ").trim().slice(0, 80);
766
+ }
767
+ function safeLoad() {
768
+ try {
769
+ return loadConfig();
770
+ }
771
+ catch {
772
+ return null;
773
+ }
774
+ }
775
+ program.parseAsync(argv).catch((err) => {
776
+ console.error(chalk.red(err.message ?? String(err)));
777
+ exit(1);
778
+ });
779
+ //# sourceMappingURL=cli.js.map