@djolex999/vir-cli 0.7.1 → 0.8.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.
package/dist/cli.js CHANGED
@@ -5,7 +5,7 @@ import select from "@inquirer/select";
5
5
  import chalk from "chalk";
6
6
  import { Command } from "commander";
7
7
  import { createInterface } from "node:readline/promises";
8
- import { stdin, stdout, argv, exit } from "node:process";
8
+ import { stdin, stdout, argv } from "node:process";
9
9
  import { existsSync, mkdirSync, readFileSync } from "node:fs";
10
10
  import { homedir } from "node:os";
11
11
  import { basename, dirname, join } from "node:path";
@@ -21,7 +21,9 @@ import { parseSession } from "./pipeline/parser.js";
21
21
  import { scoreSession } from "./pipeline/filter.js";
22
22
  import { scrub } from "./pipeline/scrubber.js";
23
23
  import { filterToolCalls } from "./pipeline/toolCallFilter.js";
24
- import { Distiller } from "./pipeline/distiller.js";
24
+ import { Distiller, normalizeModelName, resolveModelShorthand, } from "./pipeline/distiller.js";
25
+ import { composeFromSources, estimateComposeCostTokens, gatherSources, } from "./pipeline/composer.js";
26
+ import { computeCost } from "./cost/pricing.js";
25
27
  import { summarizeAll, summarizeProject } from "./pipeline/summarizer.js";
26
28
  import { embeddingForNote, isOllamaAvailable, } from "./search/embedder.js";
27
29
  import { search, vaultRoot } from "./search/retriever.js";
@@ -29,6 +31,8 @@ import { buildQueryResults, errorPayload } from "./output/json.js";
29
31
  import { synthesize } from "./search/synthesizer.js";
30
32
  import { runMcpServer } from "./mcp/server.js";
31
33
  import { runReview } from "./cli/review.js";
34
+ import { runAction } from "./cli/runAction.js";
35
+ import { runReconcile } from "./cli/reconcile.js";
32
36
  import { installToClaudeCode, isClaudeAvailable, isInstalled, uninstallFromClaudeCode, } from "./mcp/install.js";
33
37
  import { install as installDaemon, status as daemonStatus, uninstall as uninstallDaemon, } from "./daemon/index.js";
34
38
  import { StateDb } from "./state/db.js";
@@ -49,9 +53,9 @@ program
49
53
  program
50
54
  .command("init")
51
55
  .description("Interactive setup")
52
- .action(async () => {
56
+ .action(runAction(async () => {
53
57
  await cmdInit();
54
- });
58
+ }));
55
59
  program
56
60
  .command("run")
57
61
  .description("Run pipeline once")
@@ -62,7 +66,7 @@ program
62
66
  .option("--yes", "Skip the cost confirmation prompt")
63
67
  .option("--force-model <model>", "Override the distill model for this run only: haiku | sonnet")
64
68
  .option("--dry-run", "Estimate per-session cost after filtering, then exit before any LLM call")
65
- .action(async (opts) => {
69
+ .action(runAction(async (opts) => {
66
70
  const cfg = loadConfig();
67
71
  const daemon = opts.daemon === true;
68
72
  const rewriteOnly = opts.rewriteOnly === true;
@@ -70,10 +74,11 @@ program
70
74
  const dryRun = opts.dryRun === true;
71
75
  if (opts.forceModel && !["haiku", "sonnet"].includes(opts.forceModel)) {
72
76
  console.error(chalk.red(`--force-model must be 'haiku' or 'sonnet', got '${opts.forceModel}'`));
73
- exit(1);
77
+ process.exitCode = 1;
78
+ return;
74
79
  }
75
80
  const skipPrompt = opts.yes === true || daemon || rewriteOnly || articlesOnly || dryRun;
76
- await runPipeline(cfg, {
81
+ const summary = await runPipeline(cfg, {
77
82
  full: opts.full,
78
83
  quiet: daemon,
79
84
  logToFile: daemon,
@@ -85,7 +90,13 @@ program
85
90
  ? undefined
86
91
  : async (newCount) => confirmCostIfNeeded(cfg, newCount),
87
92
  });
88
- });
93
+ // Surface per-session distill failures via a non-zero exit so external
94
+ // callers (and the user) don't get false "success" — the silent-success
95
+ // bug that hid Kie's 200-with-error responses pre-0.7.2.
96
+ if (summary.errored > 0 || summary.articlesErrored > 0) {
97
+ process.exitCode = 1;
98
+ }
99
+ }));
89
100
  async function confirmCostIfNeeded(cfg, newCount) {
90
101
  if (newCount <= 20)
91
102
  return true;
@@ -108,7 +119,7 @@ program
108
119
  .option("--since <duration>", "Time window, e.g. 7d, 24h, 2w", "7d")
109
120
  .option("--by-session", "Show the full per-session distribution")
110
121
  .option("--top <n>", "How many top sessions to show (default 5)", "5")
111
- .action((opts) => {
122
+ .action(runAction(async (opts) => {
112
123
  ui.header("cost");
113
124
  ui.blank();
114
125
  const since = opts.since ?? "7d";
@@ -118,7 +129,8 @@ program
118
129
  }
119
130
  catch {
120
131
  console.error(chalk.red(`invalid --since value: ${since}`));
121
- exit(1);
132
+ process.exitCode = 1;
133
+ return;
122
134
  }
123
135
  const records = readCostLog(cutoffMs);
124
136
  if (records.length === 0) {
@@ -146,22 +158,24 @@ program
146
158
  const label = s.project ? `${s.project}/${id}` : id;
147
159
  ui.line(` ${ui.dim(ui.BULLET)} ${ui.text(label.padEnd(42))} ${ui.dim(`${s.calls}×`)} ${ui.warn(ui.formatUsd(s.cost))}`);
148
160
  }
149
- });
161
+ }));
150
162
  program
151
163
  .command("calibrate <sessionId>")
152
164
  .description("Distill ONE session to stdout for A/B model comparison — never writes vault or DB")
153
165
  .option("--model <model>", "Distill model: haiku | sonnet", "sonnet")
154
- .action(async (sessionId, opts) => {
166
+ .action(runAction(async (sessionId, opts) => {
155
167
  const cfg = loadConfig();
156
168
  const model = opts.model ?? "sonnet";
157
169
  if (!["haiku", "sonnet"].includes(model)) {
158
170
  console.error(chalk.red(`--model must be 'haiku' or 'sonnet', got '${model}'`));
159
- exit(1);
171
+ process.exitCode = 1;
172
+ return;
160
173
  }
161
174
  const found = scanSessions(cfg.claudeProjectsDir).find((s) => basename(s.path, ".jsonl") === sessionId);
162
175
  if (!found) {
163
176
  console.error(chalk.red(`session not found under ${cfg.claudeProjectsDir}: ${sessionId}`));
164
- exit(1);
177
+ process.exitCode = 1;
178
+ return;
165
179
  }
166
180
  // Same pipeline as production up to (but NOT including) writer.write / db.record.
167
181
  // classify always runs on Haiku (matches production); only distill varies.
@@ -190,23 +204,23 @@ program
190
204
  else {
191
205
  console.log(`\n## cost\n(no distill cost record found for ${sessionId})`);
192
206
  }
193
- });
207
+ }));
194
208
  const schedule = program
195
209
  .command("schedule")
196
210
  .description("Manage the background daemon (launchd / systemd / cron)");
197
211
  schedule
198
212
  .command("install")
199
213
  .description("Install + start the scheduled daemon")
200
- .action(async () => {
214
+ .action(runAction(async () => {
201
215
  const cfg = loadConfig();
202
216
  await installDaemon(cfg);
203
217
  const ds = await daemonStatus();
204
218
  console.log(chalk.green(`installed via ${ds.method}: ${ds.configPath ?? "(no path)"} (active=${ds.active})`));
205
- });
219
+ }));
206
220
  schedule
207
221
  .command("uninstall")
208
222
  .description("Stop + remove the scheduled daemon")
209
- .action(async () => {
223
+ .action(runAction(async () => {
210
224
  const before = await daemonStatus();
211
225
  await uninstallDaemon();
212
226
  if (before.installed) {
@@ -215,14 +229,14 @@ schedule
215
229
  else {
216
230
  console.log(chalk.yellow("no vir daemon found"));
217
231
  }
218
- });
232
+ }));
219
233
  program
220
234
  .command("sync-claude [project]")
221
235
  .description("Update Vir blocks in CLAUDE.md files (global + per-project)")
222
236
  .option("--dry-run", "Show diff only, never write")
223
237
  .option("--force", "Apply without confirmation")
224
238
  .option("--global", "Only update ~/.claude/CLAUDE.md")
225
- .action(async (projectArg, opts) => {
239
+ .action(runAction(async (projectArg, opts) => {
226
240
  const cfg = loadConfig();
227
241
  const db = new StateDb();
228
242
  try {
@@ -269,11 +283,11 @@ program
269
283
  finally {
270
284
  db.close();
271
285
  }
272
- });
286
+ }));
273
287
  program
274
288
  .command("dedupe")
275
289
  .description("Interactive duplicate detection + merge")
276
- .action(async () => {
290
+ .action(runAction(async () => {
277
291
  const cfg = loadConfig();
278
292
  const db = new StateDb();
279
293
  try {
@@ -339,14 +353,14 @@ program
339
353
  finally {
340
354
  db.close();
341
355
  }
342
- });
356
+ }));
343
357
  program
344
358
  .command("lint")
345
359
  .description("Run orphan, staleness, and contradiction checks on the vault")
346
360
  .option("--orphans", "Run only the orphan check (free)")
347
361
  .option("--stale", "Run only the staleness check (free)")
348
362
  .option("--contradictions", "Run only the contradiction check (Haiku tokens)")
349
- .action(async (opts) => {
363
+ .action(runAction(async (opts) => {
350
364
  const cfg = loadConfig();
351
365
  const db = new StateDb();
352
366
  try {
@@ -429,12 +443,12 @@ program
429
443
  finally {
430
444
  db.close();
431
445
  }
432
- });
446
+ }));
433
447
  program
434
448
  .command("summarize [project]")
435
449
  .description("Generate or regenerate a project knowledge summary")
436
450
  .option("--all", "Regenerate summaries for every project with notes")
437
- .action(async (project, opts) => {
451
+ .action(runAction(async (project, opts) => {
438
452
  const cfg = loadConfig();
439
453
  const db = new StateDb();
440
454
  try {
@@ -452,7 +466,8 @@ program
452
466
  }
453
467
  if (!project) {
454
468
  console.error(chalk.red("usage: vir summarize <project> | --all"));
455
- exit(1);
469
+ process.exitCode = 1;
470
+ return;
456
471
  }
457
472
  const res = await summarizeProject(cfg, project, db);
458
473
  if (!res) {
@@ -465,12 +480,12 @@ program
465
480
  finally {
466
481
  db.close();
467
482
  }
468
- });
483
+ }));
469
484
  program
470
485
  .command("embed")
471
486
  .description("Generate Ollama embeddings for distilled notes")
472
487
  .option("--force", "Regenerate even if embedding already exists")
473
- .action(async (opts) => {
488
+ .action(runAction(async (opts) => {
474
489
  const cfg = loadConfig();
475
490
  ui.header("embed");
476
491
  ui.blank();
@@ -479,7 +494,8 @@ program
479
494
  ui.line(ui.dim(" brew install ollama"));
480
495
  ui.line(ui.dim(" ollama pull nomic-embed-text"));
481
496
  ui.line(ui.dim(" ollama serve"));
482
- exit(1);
497
+ process.exitCode = 1;
498
+ return;
483
499
  }
484
500
  const db = new StateDb();
485
501
  try {
@@ -530,7 +546,7 @@ program
530
546
  finally {
531
547
  db.close();
532
548
  }
533
- });
549
+ }));
534
550
  // JSON path for `vir query --json`: stdout gets a single JSON array on success
535
551
  // (`[]` when nothing matched), exit 0. On failure stdout stays EMPTY so the
536
552
  // plugin can `JSON.parse(stdout)` unguarded — the error goes to stderr as a
@@ -570,7 +586,7 @@ program
570
586
  .description("Search the vault: embedding/TF-IDF retrieval + Claude synthesis")
571
587
  .option("--json", "Emit machine-readable JSON for programmatic consumers")
572
588
  .option("--limit <n>", "Number of notes to retrieve", "8")
573
- .action(async (question, opts) => {
589
+ .action(runAction(async (question, opts) => {
574
590
  const limit = Math.max(1, Number.parseInt(opts.limit ?? "8", 10) || 8);
575
591
  if (opts.json) {
576
592
  await runQueryJson(question, limit);
@@ -623,11 +639,100 @@ program
623
639
  finally {
624
640
  db.close();
625
641
  }
626
- });
642
+ }));
643
+ program
644
+ .command("compose <topic>")
645
+ .description("Synthesize a topic page from related vault notes")
646
+ .option("--limit <n>", "Top N notes to synthesize from (max 50)", "20")
647
+ .option("--model <model>", "Synthesis model: haiku | sonnet")
648
+ .option("--dry-run", "Show top sources + estimated cost, exit before LLM")
649
+ .option("--yes", "Skip the cost confirmation prompt")
650
+ .action(runAction(async (topic, opts) => {
651
+ const cfg = loadConfig();
652
+ if (opts.model && !["haiku", "sonnet"].includes(opts.model)) {
653
+ console.error(chalk.red(`--model must be 'haiku' or 'sonnet', got '${opts.model}'`));
654
+ process.exitCode = 1;
655
+ return;
656
+ }
657
+ const limit = Math.min(50, Math.max(1, Number.parseInt(opts.limit ?? "20", 10) || 20));
658
+ const db = new StateDb();
659
+ try {
660
+ ui.header("compose");
661
+ ui.divider();
662
+ console.log(ui.text(topic));
663
+ ui.divider();
664
+ ui.blank();
665
+ const sp = ui.spinner("searching vault for related notes").start();
666
+ const sources = await gatherSources(cfg, db, topic, limit);
667
+ sp.stop();
668
+ if (sources.length === 0) {
669
+ ui.row(ui.warn(ui.WARN_GLYPH), ui.text("no related notes found — nothing to synthesize"));
670
+ ui.line(ui.dim(" run `vir run` to distill more sessions first"));
671
+ return;
672
+ }
673
+ for (const s of sources.slice(0, 10))
674
+ ui.sourceRow(s.title, s.score);
675
+ ui.divider();
676
+ const model = normalizeModelName(resolveModelShorthand(opts.model ?? cfg.models.distill), cfg.provider);
677
+ const { inputTokens, outputTokens } = estimateComposeCostTokens(topic, sources);
678
+ const estCost = computeCost(cfg.provider, model, inputTokens, outputTokens, cfg.pricing);
679
+ ui.summary({
680
+ sources: { value: sources.length, color: ui.info },
681
+ model: { value: model, color: ui.accent },
682
+ "est. cost": { value: ui.formatUsd(estCost), color: ui.warn },
683
+ });
684
+ ui.divider();
685
+ if (opts.dryRun) {
686
+ ui.line(ui.dim(" dry run — no synthesis performed; actuals may vary ±30%"));
687
+ return;
688
+ }
689
+ if (opts.yes !== true) {
690
+ const proceed = await confirm({
691
+ message: `synthesize with ${model} (~${ui.formatUsd(estCost)})?`,
692
+ default: true,
693
+ });
694
+ if (!proceed) {
695
+ ui.line(ui.dim("aborted"));
696
+ return;
697
+ }
698
+ }
699
+ const writer = new VaultWriter(cfg, db);
700
+ const sp2 = ui.spinner("synthesizing topic page").start();
701
+ let result;
702
+ try {
703
+ result = await composeFromSources(cfg, db, topic, sources, writer, {
704
+ forceModel: opts.model,
705
+ });
706
+ sp2.stop();
707
+ }
708
+ catch (err) {
709
+ sp2.fail(ui.errorColor(err.message));
710
+ process.exitCode = 1;
711
+ return;
712
+ }
713
+ ui.row(ui.success(ui.CHECK), ui.text(`wrote ${result.relPath}`));
714
+ ui.blank();
715
+ // Actual cost from the record callLLM just appended for this compose.
716
+ const rec = [...readCostLog()]
717
+ .reverse()
718
+ .find((r) => r.stage === "compose" && r.session === result.slug);
719
+ ui.summary({
720
+ title: { value: result.title, color: ui.text },
721
+ sources: { value: result.sourceCount, color: ui.info },
722
+ confidence: { value: result.confidence.toFixed(2), color: ui.info },
723
+ ...(rec
724
+ ? { cost: { value: ui.formatUsd(rec.estimated_cost_usd), color: ui.warn } }
725
+ : {}),
726
+ });
727
+ }
728
+ finally {
729
+ db.close();
730
+ }
731
+ }));
627
732
  program
628
733
  .command("status")
629
734
  .description("Show processing status + knowledge base breakdown")
630
- .action(async () => {
735
+ .action(runAction(async () => {
631
736
  const cfg = configExists() ? loadConfig() : null;
632
737
  if (!cfg) {
633
738
  ui.header("status");
@@ -643,25 +748,34 @@ program
643
748
  renderKnowledge(knowledge);
644
749
  ui.blank();
645
750
  renderDaemon(ds, cfg.cadenceHours);
646
- });
751
+ }));
752
+ program
753
+ .command("reconcile")
754
+ .description("Retry sessions that silently failed pre-0.7.2 (null/empty content despite skipped=0)")
755
+ .option("--dry-run", "Report recoverable count + estimated cost + false-cost collateral; exit before any LLM call")
756
+ .option("--yes", "Skip the cost confirmation prompt")
757
+ .action(runAction(async (opts) => {
758
+ const cfg = loadConfig();
759
+ await runReconcile(cfg, { dryRun: opts.dryRun, yes: opts.yes });
760
+ }));
647
761
  program
648
762
  .command("review")
649
763
  .description("Walk through new distilled notes and approve/edit/reject")
650
764
  .option("--all", "Review all notes, including verified ones")
651
765
  .option("--project <slug>", "Filter by project")
652
766
  .option("--limit <n>", "Max notes to review in this session", "50")
653
- .action(runReview);
767
+ .action(runAction(runReview));
654
768
  program
655
769
  .command("doctor")
656
770
  .description("Run diagnostic checks on Vir installation")
657
771
  .option("--json", "Emit machine-readable JSON for programmatic consumers")
658
- .action(async (opts) => {
772
+ .action(runAction(async (opts) => {
659
773
  if (opts.json) {
660
774
  await runDoctorJson();
661
775
  return;
662
776
  }
663
777
  await runDoctor();
664
- });
778
+ }));
665
779
  const mcpCmd = program
666
780
  .command("mcp")
667
781
  .description("MCP server + Claude Code registration")
@@ -685,35 +799,35 @@ const runMcp = async () => {
685
799
  mcpCmd
686
800
  .command("run")
687
801
  .description("Run the MCP server over stdio")
688
- .action(runMcp);
802
+ .action(runAction(runMcp));
689
803
  mcpCmd
690
804
  .command("install")
691
805
  .description("Register Vir with Claude Code")
692
806
  .option("--scope <scope>", "user or project", "user")
693
- .action(async (opts) => {
807
+ .action(runAction(async (opts) => {
694
808
  await installToClaudeCode(opts.scope);
695
- });
809
+ }));
696
810
  mcpCmd
697
811
  .command("uninstall")
698
812
  .description("Unregister Vir from Claude Code")
699
- .action(async () => {
813
+ .action(runAction(async () => {
700
814
  await uninstallFromClaudeCode();
701
- });
815
+ }));
702
816
  mcpCmd
703
817
  .command("status")
704
818
  .description("Check Vir MCP registration")
705
- .action(async () => {
819
+ .action(runAction(async () => {
706
820
  if (!(await isClaudeAvailable())) {
707
821
  ui.row(ui.warn(ui.WARN_GLYPH), ui.text("claude CLI not detected"), "install: https://claude.com/claude-code");
708
822
  return;
709
823
  }
710
824
  const installed = await isInstalled();
711
825
  ui.row(installed ? ui.success(ui.CHECK) : ui.errorColor(ui.CROSS), ui.text(installed ? "registered with Claude Code" : "not registered"), installed ? undefined : "run: vir mcp install");
712
- });
826
+ }));
713
827
  // Backwards compat: `vir mcp` with no subcommand runs the server. The MCP
714
828
  // registration (`claude mcp add vir vir mcp`) invokes exactly this, so it must
715
829
  // keep launching the stdio server — don't change it to print help.
716
- mcpCmd.action(runMcp);
830
+ mcpCmd.action(runAction(runMcp));
717
831
  function renderKnowledge(k) {
718
832
  if (k.total === 0) {
719
833
  ui.box([
@@ -994,7 +1108,8 @@ async function cmdInit() {
994
1108
  for (const issue of parsed.error.issues) {
995
1109
  console.error(` - ${issue.path.join(".")}: ${issue.message}`);
996
1110
  }
997
- exit(1);
1111
+ process.exitCode = 1;
1112
+ return;
998
1113
  }
999
1114
  saveConfig(parsed.data);
1000
1115
  ui.blank();
@@ -1056,8 +1171,12 @@ function safeLoad() {
1056
1171
  return null;
1057
1172
  }
1058
1173
  }
1174
+ // Safety net for anything that escapes the per-action `runAction` wrapper (e.g.
1175
+ // commander-internal rejections before the action handler is reached). Set
1176
+ // `process.exitCode` instead of calling `process.exit` so buffered stdout/stderr
1177
+ // can drain before the process exits.
1059
1178
  program.parseAsync(argv).catch((err) => {
1060
1179
  console.error(chalk.red(err.message ?? String(err)));
1061
- exit(1);
1180
+ process.exitCode = 1;
1062
1181
  });
1063
1182
  //# sourceMappingURL=cli.js.map