@tikoci/rosetta 0.4.2 → 0.4.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/browse.ts ADDED
@@ -0,0 +1,1234 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * browse.ts — Interactive terminal browser for RouterOS documentation.
4
+ *
5
+ * Card-catalog REPL: search → numbered results → select to drill in → hints lead to next query.
6
+ * Wraps all query functions from query.ts — no new SQL, purely a presentation layer.
7
+ *
8
+ * Usage:
9
+ * bun run src/browse.ts Interactive mode
10
+ * bun run src/browse.ts "firewall filter" Search then interactive
11
+ * bun run src/browse.ts --once "dhcp" Search, print, exit (for piping)
12
+ */
13
+
14
+ import * as readline from "node:readline";
15
+ import { db, getDbStats, initDb } from "./db.ts";
16
+ import { resolveVersion } from "./paths.ts";
17
+ import type {
18
+ ChangelogResult,
19
+ DeviceResult,
20
+ DeviceTestRow,
21
+ SearchResponse,
22
+ SearchResult,
23
+ SectionTocEntry,
24
+ VideoSearchResult,
25
+ } from "./query.ts";
26
+ import {
27
+ browseCommands,
28
+ checkCommandVersions,
29
+ diffCommandVersions,
30
+ fetchCurrentVersions,
31
+ getPage,
32
+ getTestResultMeta,
33
+ lookupProperty,
34
+ searchCallouts,
35
+ searchChangelogs,
36
+ searchDevices,
37
+ searchDeviceTests,
38
+ searchPages,
39
+ searchProperties,
40
+ searchVideos,
41
+ } from "./query.ts";
42
+
43
+ // ── ANSI utilities (zero deps) ──
44
+
45
+ const ESC = "\x1b";
46
+ const bold = (s: string) => `${ESC}[1m${s}${ESC}[0m`;
47
+ const dim = (s: string) => `${ESC}[2m${s}${ESC}[0m`;
48
+ const _italic = (s: string) => `${ESC}[3m${s}${ESC}[0m`;
49
+ const _underline = (s: string) => `${ESC}[4m${s}${ESC}[0m`;
50
+ const cyan = (s: string) => `${ESC}[36m${s}${ESC}[0m`;
51
+ const yellow = (s: string) => `${ESC}[33m${s}${ESC}[0m`;
52
+ const green = (s: string) => `${ESC}[32m${s}${ESC}[0m`;
53
+ const red = (s: string) => `${ESC}[31m${s}${ESC}[0m`;
54
+ const magenta = (s: string) => `${ESC}[35m${s}${ESC}[0m`;
55
+ const blue = (s: string) => `${ESC}[34m${s}${ESC}[0m`;
56
+ const _bgDim = (s: string) => `${ESC}[48;5;236m${s}${ESC}[0m`;
57
+
58
+ /** OSC 8 clickable hyperlink (iTerm2, macOS Terminal, Windows Terminal, etc.) */
59
+ function link(url: string, display?: string): string {
60
+ return `${ESC}]8;;${url}\x07${display ?? url}${ESC}]8;;\x07`;
61
+ }
62
+
63
+ /** Terminal width, with fallback */
64
+ function termWidth(): number {
65
+ return process.stdout.columns || 80;
66
+ }
67
+
68
+ /** Terminal height, with fallback */
69
+ function termHeight(): number {
70
+ return process.stdout.rows || 24;
71
+ }
72
+
73
+ /** Truncate string to width, adding … if needed */
74
+ function truncate(s: string, max: number): string {
75
+ if (s.length <= max) return s;
76
+ return `${s.slice(0, max - 1)}…`;
77
+ }
78
+
79
+ /** Right-pad to width */
80
+ function pad(s: string, w: number): string {
81
+ const visible = stripAnsi(s);
82
+ return visible.length >= w ? s : s + " ".repeat(w - visible.length);
83
+ }
84
+
85
+ /** Strip ANSI escape codes for length calculation */
86
+ function stripAnsi(s: string): string {
87
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: ANSI escape code matching requires \x1b and \x07
88
+ return s.replace(/\x1b\][^\x07]*\x07/g, "").replace(/\x1b\[[0-9;]*m/g, "");
89
+ }
90
+
91
+ /** Format number with comma separators */
92
+ function fmt(n: number): string {
93
+ return n.toLocaleString("en-US");
94
+ }
95
+
96
+ /** Draw a box with unicode box-drawing characters */
97
+ function box(lines: string[], title?: string): string {
98
+ const w = termWidth();
99
+ const inner = w - 4;
100
+ const top = title
101
+ ? `╭─ ${bold(title)} ${"─".repeat(Math.max(0, inner - stripAnsi(title).length - 2))}╮`
102
+ : `╭${"─".repeat(w - 2)}╮`;
103
+ const bottom = `╰${"─".repeat(w - 2)}╯`;
104
+ const body = lines.map((l) => {
105
+ const visible = stripAnsi(l);
106
+ const padding = Math.max(0, inner - visible.length);
107
+ return `│ ${l}${" ".repeat(padding)} │`;
108
+ });
109
+ return [top, ...body, bottom].join("\n");
110
+ }
111
+
112
+ /** Format a horizontal rule */
113
+ function hr(): string {
114
+ return dim("─".repeat(termWidth()));
115
+ }
116
+
117
+ /** Callout type icon + color */
118
+ function calloutPrefix(type: string): string {
119
+ switch (type.toLowerCase()) {
120
+ case "warning": return yellow("⚠ Warning:");
121
+ case "note": return blue("📝 Note:");
122
+ case "info": return cyan("ℹ Info:");
123
+ case "tip": return green("✓ Tip:");
124
+ default: return dim(`[${type}]`);
125
+ }
126
+ }
127
+
128
+ /** Format seconds as HH:MM:SS or MM:SS */
129
+ function formatTime(seconds: number): string {
130
+ const h = Math.floor(seconds / 3600);
131
+ const m = Math.floor((seconds % 3600) / 60);
132
+ const s = seconds % 60;
133
+ if (h > 0) return `${h}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
134
+ return `${m}:${String(s).padStart(2, "0")}`;
135
+ }
136
+
137
+ // ── Pager ──
138
+
139
+ /** Output lines with paging. Returns true if user quit early. */
140
+ async function paged(output: string): Promise<boolean> {
141
+ const lines = output.split("\n");
142
+ const pageSize = termHeight() - 2;
143
+ if (lines.length <= pageSize || !process.stdout.isTTY) {
144
+ process.stdout.write(`${output}\n`);
145
+ return false;
146
+ }
147
+ let offset = 0;
148
+ while (offset < lines.length) {
149
+ const chunk = lines.slice(offset, offset + pageSize);
150
+ process.stdout.write(`${chunk.join("\n")}\n`);
151
+ offset += pageSize;
152
+ if (offset < lines.length) {
153
+ const remaining = lines.length - offset;
154
+ process.stdout.write(dim(`── ${remaining} more lines (Enter=next, q=stop) ──`));
155
+ const key = await waitForKey();
156
+ // Clear the "more" line
157
+ process.stdout.write(`\r${" ".repeat(termWidth())}\r`);
158
+ if (key === "q" || key === "Q") return true;
159
+ }
160
+ }
161
+ return false;
162
+ }
163
+
164
+ function waitForKey(): Promise<string> {
165
+ return new Promise((resolve) => {
166
+ if (!process.stdin.isTTY) { resolve("\n"); return; }
167
+ const wasRaw = process.stdin.isRaw;
168
+ process.stdin.setRawMode(true);
169
+ process.stdin.resume();
170
+ process.stdin.once("data", (data) => {
171
+ process.stdin.setRawMode(wasRaw ?? false);
172
+ const ch = data.toString();
173
+ // Ctrl-C / Ctrl-D
174
+ if (ch === "\x03" || ch === "\x04") { process.exit(0); }
175
+ resolve(ch);
176
+ });
177
+ });
178
+ }
179
+
180
+ // ── REPL State ──
181
+
182
+ type Context =
183
+ | { type: "home" }
184
+ | { type: "search"; response: SearchResponse; results: SearchResult[] }
185
+ | { type: "page"; pageId: number; title: string; commandPath?: string }
186
+ | { type: "sections"; pageId: number; title: string; sections: SectionTocEntry[] }
187
+ | { type: "properties"; query: string; pageId?: number }
188
+ | { type: "commands"; path: string }
189
+ | { type: "devices"; query: string; results: DeviceResult[] }
190
+ | { type: "device"; device: DeviceResult }
191
+ | { type: "tests" }
192
+ | { type: "callouts"; query: string }
193
+ | { type: "changelogs" }
194
+ | { type: "videos"; query: string }
195
+ | { type: "diff" }
196
+ | { type: "vcheck"; path: string };
197
+
198
+ let ctx: Context = { type: "home" };
199
+ const history: Context[] = [];
200
+
201
+ function pushCtx(next: Context) {
202
+ history.push(ctx);
203
+ ctx = next;
204
+ }
205
+
206
+ function popCtx(): boolean {
207
+ const prev = history.pop();
208
+ if (prev) { ctx = prev; return true; }
209
+ return false;
210
+ }
211
+
212
+ // ── Renderers ──
213
+
214
+ function renderWelcome(): string {
215
+ const stats = getDbStats();
216
+ const version = resolveVersion(import.meta.dirname);
217
+ const lines = [
218
+ `RouterOS Documentation Browser ${dim(`v${version}`)}`,
219
+ `${fmt(stats.pages)} pages · ${fmt(stats.properties)} properties · ${fmt(stats.commands)} commands`,
220
+ `${fmt(stats.devices)} devices · ${fmt(stats.callouts)} callouts · ${fmt(stats.ros_versions)} versions`,
221
+ ...(stats.videos > 0 ? [`${fmt(stats.videos)} videos · ${fmt(stats.video_segments)} transcript segments`] : []),
222
+ "",
223
+ `Type a search query, or ${bold("help")} for commands.`,
224
+ ];
225
+ return box(lines, "rosetta");
226
+ }
227
+
228
+ function renderSearchResults(resp: SearchResponse): string {
229
+ const out: string[] = [];
230
+ const modeNote = resp.fallbackMode === "or" ? dim(" (OR fallback)") : "";
231
+ out.push(` ${bold(String(resp.results.length))} of ${resp.total} results for ${cyan(`"${resp.query}"`)}${modeNote}`);
232
+ out.push("");
233
+
234
+ const w = termWidth();
235
+ for (let i = 0; i < resp.results.length; i++) {
236
+ const r = resp.results[i];
237
+ const num = dim(`${String(i + 1).padStart(3)} `);
238
+ const title = bold(truncate(r.title, 30));
239
+ const path = dim(truncate(r.path, Math.max(20, w - 55)));
240
+ const meta = dim(`${fmt(r.word_count)}w`);
241
+ out.push(`${num}${pad(title, 32)} ${pad(path, Math.max(20, w - 55))} ${meta}`);
242
+ if (r.url) {
243
+ out.push(` ${cyan(link(r.url, dim(r.url)))}`);
244
+ }
245
+ // Show excerpt with highlight markers converted to bold
246
+ const excerpt = r.excerpt.replace(/>>>/g, `${ESC}[1m`).replace(/<<</g, `${ESC}[0m`);
247
+ out.push(` ${dim(truncate(excerpt, w - 8))}`);
248
+ out.push("");
249
+ }
250
+
251
+ // Navigation hints
252
+ const hints = [
253
+ `${cyan("[N]")} view page`,
254
+ `${cyan("[s <query>]")} search`,
255
+ `${cyan("[p <query>]")} properties`,
256
+ `${cyan("[cmd <path>]")} commands`,
257
+ ];
258
+ out.push(` ${hints.join(" ")}`);
259
+ return out.join("\n");
260
+ }
261
+
262
+ function renderPage(page: NonNullable<ReturnType<typeof getPage>>): string {
263
+ const out: string[] = [];
264
+ const w = termWidth();
265
+
266
+ out.push("");
267
+ out.push(` ${bold("══")} ${bold(page.title)} ${bold("═".repeat(Math.max(0, w - stripAnsi(page.title).length - 8)))}`);
268
+ out.push(` ${dim(page.path)}`);
269
+ out.push(` ${cyan(link(page.url))}`);
270
+
271
+ const meta: string[] = [`${fmt(page.word_count)} words`];
272
+ if (page.code_lines) meta.push(`${page.code_lines} code lines`);
273
+ if (page.callouts.length > 0) {
274
+ const types: Record<string, number> = {};
275
+ for (const c of page.callouts) types[c.type] = (types[c.type] || 0) + 1;
276
+ const parts = Object.entries(types).map(([t, n]) => {
277
+ const icon = t === "Warning" ? "⚠" : t === "Note" ? "📝" : t === "Info" ? "ℹ" : "✓";
278
+ return `${n}${icon}`;
279
+ });
280
+ meta.push(`${page.callouts.length} callouts (${parts.join(" ")})`);
281
+ }
282
+ out.push(` ${dim(meta.join(" · "))}`);
283
+ out.push("");
284
+
285
+ // Sections TOC (if available)
286
+ if (page.sections && page.sections.length > 0) {
287
+ out.push(` ${bold("Sections:")}`);
288
+ for (let i = 0; i < page.sections.length; i++) {
289
+ const s = page.sections[i];
290
+ const indent = " ".repeat(Math.max(0, s.level - 1));
291
+ const num = dim(`${String(i + 1).padStart(3)} `);
292
+ const chars = dim(`(${fmt(s.char_count)} chars)`);
293
+ out.push(` ${num}${indent}${s.heading} ${chars}`);
294
+ }
295
+ out.push("");
296
+ if (page.note) out.push(` ${dim(page.note)}`);
297
+ }
298
+
299
+ // Callout summary or full callouts
300
+ if (page.callout_summary) {
301
+ const cs = page.callout_summary;
302
+ const typeParts = Object.entries(cs.types).map(([t, n]) => `${n} ${t}`).join(", ");
303
+ out.push(` ${dim(`Callouts: ${cs.count} total (${typeParts}) — view with`)} ${cyan("cal")}`);
304
+ out.push("");
305
+ } else if (page.callouts.length > 0) {
306
+ for (const c of page.callouts) {
307
+ const prefix = calloutPrefix(c.type);
308
+ const content = truncate(c.content, w - 6);
309
+ out.push(` ${prefix} ${content}`);
310
+ }
311
+ out.push("");
312
+ }
313
+
314
+ // Text content (if present and not a TOC-only view)
315
+ if (page.text && !page.sections) {
316
+ out.push(hr());
317
+ out.push(page.text);
318
+ if (page.code) {
319
+ out.push("");
320
+ out.push(dim("── code ──"));
321
+ out.push(page.code);
322
+ }
323
+ }
324
+
325
+ if (page.truncated) {
326
+ out.push("");
327
+ out.push(dim(` [truncated: ${fmt(page.truncated.text_total)} text chars, ${fmt(page.truncated.code_total)} code chars]`));
328
+ }
329
+
330
+ // Section content (when section was requested)
331
+ if (page.section) {
332
+ out.push(hr());
333
+ out.push(` ${bold(`§ ${page.section.heading}`)} ${dim(`(level ${page.section.level})`)}`);
334
+ out.push("");
335
+ if (page.text) out.push(page.text);
336
+ if (page.code) {
337
+ out.push("");
338
+ out.push(dim("── code ──"));
339
+ out.push(page.code);
340
+ }
341
+ }
342
+
343
+ // Navigation hints
344
+ out.push("");
345
+ const hints: string[] = [];
346
+ if (page.sections && page.sections.length > 0) hints.push(`${cyan("[N]")} section`);
347
+ hints.push(`${cyan("[p]")} properties`);
348
+ hints.push(`${cyan("[cmd]")} command tree`);
349
+ hints.push(`${cyan("[cal]")} callouts`);
350
+ hints.push(`${cyan("[b]")} back`);
351
+ out.push(` ${hints.join(" ")}`);
352
+
353
+ return out.join("\n");
354
+ }
355
+
356
+ function renderProperties(results: Array<{
357
+ name: string;
358
+ type: string | null;
359
+ default_val: string | null;
360
+ description: string;
361
+ section: string | null;
362
+ page_title: string;
363
+ page_url: string;
364
+ excerpt?: string;
365
+ }>): string {
366
+ const out: string[] = [];
367
+ if (results.length === 0) {
368
+ out.push(` ${dim("No properties found.")}`);
369
+ return out.join("\n");
370
+ }
371
+ const w = termWidth();
372
+
373
+ for (let i = 0; i < results.length; i++) {
374
+ const p = results[i];
375
+ const num = dim(`${String(i + 1).padStart(3)} `);
376
+ out.push(`${num}${bold(p.name)} ${dim(p.type ?? "")} ${p.default_val ? dim(`default: ${p.default_val}`) : ""}`);
377
+ const desc = truncate(p.description, w - 8);
378
+ out.push(` ${desc}`);
379
+ out.push(` ${dim(p.page_title)} ${cyan(link(p.page_url, dim("→")))}`);
380
+ out.push("");
381
+ }
382
+ return out.join("\n");
383
+ }
384
+
385
+ function renderCommandTree(path: string, children: Array<{
386
+ path: string;
387
+ name: string;
388
+ type: string;
389
+ description: string | null;
390
+ page_title: string | null;
391
+ page_url: string | null;
392
+ }>): string {
393
+ const out: string[] = [];
394
+ out.push(` ${bold(path || "/")} ${dim(`(${children.length} children)`)}`);
395
+ out.push("");
396
+
397
+ const dirs = children.filter((c) => c.type === "dir");
398
+ const cmds = children.filter((c) => c.type === "cmd");
399
+ const args = children.filter((c) => c.type === "arg");
400
+
401
+ for (const group of [
402
+ { items: dirs, icon: "📁", label: "directories" },
403
+ { items: cmds, icon: "⚡", label: "commands" },
404
+ { items: args, icon: " ", label: "arguments" },
405
+ ]) {
406
+ if (group.items.length === 0) continue;
407
+ for (const c of group.items) {
408
+ const icon = group.icon;
409
+ const name = c.type === "dir" ? bold(c.name) : c.name;
410
+ const desc = c.description ? dim(` — ${truncate(c.description, 50)}`) : "";
411
+ const pageLink = c.page_url ? ` ${cyan(link(c.page_url, dim("📄")))}` : "";
412
+ out.push(` ${icon} ${name}${desc}${pageLink}`);
413
+ }
414
+ }
415
+
416
+ out.push("");
417
+ const hints: string[] = [
418
+ `${cyan("[cmd <child>]")} drill down`,
419
+ `${cyan("[page <id>]")} view linked page`,
420
+ `${cyan("[b]")} back`,
421
+ ];
422
+ out.push(` ${hints.join(" ")}`);
423
+ return out.join("\n");
424
+ }
425
+
426
+ function renderDeviceResults(results: DeviceResult[], mode: string, total: number): string {
427
+ const out: string[] = [];
428
+ out.push(` ${bold(String(results.length))} of ${total} devices ${dim(`(${mode})`)}`);
429
+ out.push("");
430
+
431
+ if (results.length === 1) {
432
+ return out.join("\n") + renderDeviceCard(results[0]);
433
+ }
434
+
435
+ for (let i = 0; i < results.length; i++) {
436
+ const d = results[i];
437
+ const num = dim(`${String(i + 1).padStart(3)} `);
438
+ const name = bold(d.product_name);
439
+ const arch = dim(d.architecture ?? "");
440
+ const ram = d.ram_mb ? dim(`${d.ram_mb}MB`) : "";
441
+ const price = d.msrp_usd ? green(`$${d.msrp_usd}`) : "";
442
+ out.push(`${num}${name} ${arch} ${ram} ${price}`);
443
+ const parts: string[] = [];
444
+ if (d.cpu) parts.push(d.cpu);
445
+ if (d.eth_gigabit) parts.push(`${d.eth_gigabit}×GbE`);
446
+ if (d.sfp_plus_ports) parts.push(`${d.sfp_plus_ports}×SFP+`);
447
+ if (d.wireless_5_chains) parts.push(`Wi-Fi`);
448
+ if (parts.length > 0) out.push(` ${dim(parts.join(" · "))}`);
449
+ out.push("");
450
+ }
451
+
452
+ out.push(` ${cyan("[N]")} view device ${cyan("[tests]")} benchmarks ${cyan("[b]")} back`);
453
+ return out.join("\n");
454
+ }
455
+
456
+ function renderDeviceCard(d: DeviceResult): string {
457
+ const out: string[] = [];
458
+ out.push(` ${bold("══")} ${bold(d.product_name)} ${bold("══")}`);
459
+ if (d.product_code) out.push(` ${dim(`Code: ${d.product_code}`)}`);
460
+ if (d.product_url) out.push(` ${cyan(link(d.product_url))}`);
461
+ out.push("");
462
+
463
+ const kv = (label: string, value: string | number | null | undefined) => {
464
+ if (value === null || value === undefined) return;
465
+ out.push(` ${dim(pad(label, 20))} ${value}`);
466
+ };
467
+
468
+ kv("Architecture", d.architecture);
469
+ kv("CPU", d.cpu);
470
+ if (d.cpu_cores) kv("Cores / Freq", `${d.cpu_cores} × ${d.cpu_frequency ?? "?"}`);
471
+ kv("License Level", d.license_level);
472
+ kv("RAM", d.ram ? `${d.ram} (${d.ram_mb}MB)` : null);
473
+ kv("Storage", d.storage ? `${d.storage} (${d.storage_mb}MB)` : null);
474
+ if (d.eth_fast || d.eth_gigabit || d.eth_2500) {
475
+ const ports: string[] = [];
476
+ if (d.eth_fast) ports.push(`${d.eth_fast}×100M`);
477
+ if (d.eth_gigabit) ports.push(`${d.eth_gigabit}×1G`);
478
+ if (d.eth_2500) ports.push(`${d.eth_2500}×2.5G`);
479
+ kv("Ethernet", ports.join(" + "));
480
+ }
481
+ if (d.sfp_ports || d.sfp_plus_ports) {
482
+ const sfp: string[] = [];
483
+ if (d.sfp_ports) sfp.push(`${d.sfp_ports}×SFP`);
484
+ if (d.sfp_plus_ports) sfp.push(`${d.sfp_plus_ports}×SFP+`);
485
+ kv("SFP", sfp.join(" + "));
486
+ }
487
+ kv("PoE In", d.poe_in);
488
+ kv("PoE Out", d.poe_out);
489
+ kv("Max Power", d.max_power_w ? `${d.max_power_w}W` : null);
490
+ if (d.wireless_24_chains || d.wireless_5_chains) {
491
+ kv("Wireless", `2.4GHz: ${d.wireless_24_chains ?? 0} chains, 5GHz: ${d.wireless_5_chains ?? 0} chains`);
492
+ }
493
+ if (d.usb_ports) kv("USB", d.usb_ports);
494
+ if (d.sim_slots) kv("SIM Slots", d.sim_slots);
495
+ if (d.msrp_usd) kv("MSRP", green(`$${d.msrp_usd}`));
496
+ if (d.block_diagram_url) {
497
+ out.push("");
498
+ out.push(` ${dim("Block diagram:")} ${cyan(link(d.block_diagram_url, "view"))}`);
499
+ }
500
+
501
+ // Test results (attached for exact matches)
502
+ if (d.test_results && d.test_results.length > 0) {
503
+ out.push("");
504
+ out.push(` ${bold("Benchmarks:")} ${dim(`(${d.test_results.length} tests)`)}`);
505
+ for (const t of d.test_results.slice(0, 12)) {
506
+ const mbps = t.throughput_mbps ? `${fmt(t.throughput_mbps)} Mbps` : "";
507
+ const kpps = t.throughput_kpps ? `${fmt(t.throughput_kpps)} Kpps` : "";
508
+ out.push(` ${dim(pad(t.test_type, 9))} ${pad(t.mode, 16)} ${dim(pad(t.configuration, 28))} ${pad(`${t.packet_size}B`, 6)} ${bold(mbps)} ${dim(kpps)}`);
509
+ }
510
+ if (d.test_results.length > 12) {
511
+ out.push(` ${dim(`... and ${d.test_results.length - 12} more (use`)} ${cyan("tests")} ${dim("for full listing)")}`);
512
+ }
513
+ }
514
+
515
+ out.push("");
516
+ out.push(` ${cyan("[tests]")} benchmarks ${cyan("[s <query>]")} search docs ${cyan("[b]")} back`);
517
+ return out.join("\n");
518
+ }
519
+
520
+ function renderTests(results: DeviceTestRow[], total: number): string {
521
+ const out: string[] = [];
522
+ out.push(` ${bold(String(results.length))} of ${total} test results`);
523
+ out.push("");
524
+
525
+ // Header
526
+ out.push(` ${dim(pad("Device", 24))} ${dim(pad("Type", 9))} ${dim(pad("Mode", 16))} ${dim(pad("Config", 28))} ${dim(pad("Pkt", 6))} ${dim(pad("Mbps", 10))} ${dim("Kpps")}`);
527
+ out.push(` ${dim("─".repeat(Math.min(termWidth() - 4, 105)))}`);
528
+
529
+ for (const t of results) {
530
+ const mbps = t.throughput_mbps != null ? fmt(t.throughput_mbps) : "—";
531
+ const kpps = t.throughput_kpps != null ? fmt(t.throughput_kpps) : "—";
532
+ out.push(` ${pad(truncate(t.product_name, 24), 24)} ${dim(pad(t.test_type, 9))} ${pad(t.mode, 16)} ${dim(pad(truncate(t.configuration, 28), 28))} ${pad(`${t.packet_size}B`, 6)} ${bold(pad(mbps, 10))} ${dim(kpps)}`);
533
+ }
534
+
535
+ if (total > results.length) {
536
+ out.push(` ${dim(`... ${total - results.length} more results`)}`);
537
+ }
538
+
539
+ out.push("");
540
+ out.push(` ${cyan("[dev <name>]")} device details ${cyan("[b]")} back`);
541
+ return out.join("\n");
542
+ }
543
+
544
+ function renderCallouts(results: Array<{
545
+ type: string;
546
+ content: string;
547
+ page_title: string;
548
+ page_url: string;
549
+ page_id: number;
550
+ excerpt: string;
551
+ }>): string {
552
+ const out: string[] = [];
553
+ if (results.length === 0) {
554
+ out.push(` ${dim("No callouts found.")}`);
555
+ return out.join("\n");
556
+ }
557
+ const w = termWidth();
558
+
559
+ for (let i = 0; i < results.length; i++) {
560
+ const c = results[i];
561
+ const num = dim(`${String(i + 1).padStart(3)} `);
562
+ const prefix = calloutPrefix(c.type);
563
+ // Use excerpt if it has highlights, otherwise truncate content
564
+ const text = c.excerpt.includes("**")
565
+ ? c.excerpt.replace(/\*\*/g, `${ESC}[1m`)
566
+ : truncate(c.content, w - 12);
567
+ out.push(`${num}${prefix}`);
568
+ out.push(` ${text}`);
569
+ out.push(` ${dim(c.page_title)} ${cyan(link(c.page_url, dim(`[${c.page_id}]`)))}`);
570
+ out.push("");
571
+ }
572
+
573
+ out.push(` ${cyan("[page <id>]")} view page ${cyan("[s <query>]")} search ${cyan("[b]")} back`);
574
+ return out.join("\n");
575
+ }
576
+
577
+ function renderChangelogs(results: ChangelogResult[]): string {
578
+ const out: string[] = [];
579
+ if (results.length === 0) {
580
+ out.push(` ${dim("No changelog entries found.")}`);
581
+ return out.join("\n");
582
+ }
583
+
584
+ let lastVersion = "";
585
+ for (const c of results) {
586
+ if (c.version !== lastVersion) {
587
+ out.push("");
588
+ out.push(` ${bold(c.version)} ${dim(c.released ?? "")}`);
589
+ lastVersion = c.version;
590
+ }
591
+ const breaking = c.is_breaking ? red("⚠ ") : " ";
592
+ const cat = dim(pad(c.category, 14));
593
+ const desc = c.excerpt.includes("**")
594
+ ? c.excerpt.replace(/\*\*/g, `${ESC}[1m`)
595
+ : truncate(c.description, termWidth() - 22);
596
+ out.push(` ${breaking}${cat} ${desc}`);
597
+ }
598
+
599
+ out.push("");
600
+ out.push(` ${cyan("[cl breaking]")} breaking only ${cyan("[cl <ver>]")} specific version ${cyan("[b]")} back`);
601
+ return out.join("\n");
602
+ }
603
+
604
+ function renderVideos(results: VideoSearchResult[]): string {
605
+ const out: string[] = [];
606
+ if (results.length === 0) {
607
+ out.push(` ${dim("No video results found.")}`);
608
+ return out.join("\n");
609
+ }
610
+
611
+ for (let i = 0; i < results.length; i++) {
612
+ const v = results[i];
613
+ const num = dim(`${String(i + 1).padStart(3)} `);
614
+ const title = bold(truncate(v.title, 60));
615
+ const date = v.upload_date ? dim(v.upload_date.replace(/(\d{4})(\d{2})(\d{2})/, "$1-$2-$3")) : "";
616
+ out.push(`${num}${title} ${date}`);
617
+ if (v.chapter_title) {
618
+ const ts = formatTime(v.start_s);
619
+ out.push(` ${magenta(`§ ${v.chapter_title}`)} ${dim(`@ ${ts}`)}`);
620
+ }
621
+ const timeUrl = v.start_s > 0 ? `${v.url}&t=${v.start_s}` : v.url;
622
+ out.push(` ${cyan(link(timeUrl))}`);
623
+ const excerpt = v.excerpt.replace(/\*\*/g, `${ESC}[1m`);
624
+ out.push(` ${dim(truncate(excerpt, termWidth() - 8))}`);
625
+ out.push("");
626
+ }
627
+
628
+ out.push(` ${cyan("[s <query>]")} search docs ${cyan("[b]")} back`);
629
+ return out.join("\n");
630
+ }
631
+
632
+ function renderDiff(result: ReturnType<typeof diffCommandVersions>): string {
633
+ const out: string[] = [];
634
+ out.push(` ${bold("Command diff:")} ${result.from_version} → ${result.to_version}`);
635
+ if (result.path_prefix) out.push(` ${dim(`Scope: ${result.path_prefix}`)}`);
636
+ out.push("");
637
+
638
+ if (result.added.length > 0) {
639
+ out.push(` ${green(`+ ${result.added_count} added:`)}`);
640
+ for (const p of result.added.slice(0, 30)) out.push(` ${green("+")} ${p}`);
641
+ if (result.added.length > 30) out.push(` ${dim(`... and ${result.added.length - 30} more`)}`);
642
+ out.push("");
643
+ }
644
+ if (result.removed.length > 0) {
645
+ out.push(` ${red(`- ${result.removed_count} removed:`)}`);
646
+ for (const p of result.removed.slice(0, 30)) out.push(` ${red("-")} ${p}`);
647
+ if (result.removed.length > 30) out.push(` ${dim(`... and ${result.removed.length - 30} more`)}`);
648
+ out.push("");
649
+ }
650
+ if (result.added.length === 0 && result.removed.length === 0) {
651
+ out.push(` ${dim("No structural differences found.")}`);
652
+ }
653
+ if (result.note) out.push(` ${dim(result.note)}`);
654
+
655
+ out.push("");
656
+ out.push(` ${cyan("[cl <from>..<to>]")} changelogs ${cyan("[vc <path>]")} version check ${cyan("[b]")} back`);
657
+ return out.join("\n");
658
+ }
659
+
660
+ function renderVersionCheck(result: ReturnType<typeof checkCommandVersions>): string {
661
+ const out: string[] = [];
662
+ out.push(` ${bold(result.command_path)}`);
663
+ out.push("");
664
+
665
+ if (result.versions.length === 0) {
666
+ out.push(` ${dim("No version data found.")}`);
667
+ } else {
668
+ out.push(` ${dim("First seen:")} ${bold(result.first_seen ?? "?")} ${dim("Last seen:")} ${bold(result.last_seen ?? "?")}`);
669
+ out.push(` ${dim("Present in")} ${bold(String(result.versions.length))} ${dim("versions")}`);
670
+ // Show version range compactly
671
+ const display = result.versions.length <= 10
672
+ ? result.versions.join(", ")
673
+ : `${result.versions.slice(0, 5).join(", ")} … ${result.versions.slice(-3).join(", ")}`;
674
+ out.push(` ${dim(display)}`);
675
+ }
676
+ if (result.note) {
677
+ out.push("");
678
+ out.push(` ${dim(result.note)}`);
679
+ }
680
+
681
+ out.push("");
682
+ out.push(` ${cyan("[diff <from> <to>]")} version diff ${cyan("[cmd <path>]")} command tree ${cyan("[b]")} back`);
683
+ return out.join("\n");
684
+ }
685
+
686
+ function renderStats(): string {
687
+ const stats = getDbStats();
688
+ const out: string[] = [];
689
+ out.push(` ${bold("Database Statistics")}`);
690
+ out.push(` ${dim("Path:")} ${stats.db_path}`);
691
+ out.push(` ${dim("Export:")} ${stats.doc_export}`);
692
+ out.push("");
693
+
694
+ const kv = (label: string, value: string | number) => {
695
+ out.push(` ${dim(pad(label, 24))} ${bold(String(typeof value === "number" ? fmt(value) : value))}`);
696
+ };
697
+
698
+ kv("Pages", stats.pages);
699
+ kv("Sections", stats.sections);
700
+ kv("Properties", stats.properties);
701
+ kv("Callouts", stats.callouts);
702
+ kv("Commands", stats.commands);
703
+ kv("Commands linked", stats.commands_linked);
704
+ kv("Devices", stats.devices);
705
+ kv("Device test results", stats.device_test_results);
706
+ kv("Devices with tests", stats.devices_with_tests);
707
+ kv("Changelogs", stats.changelogs);
708
+ kv("Changelog versions", stats.changelog_versions);
709
+ kv("RouterOS versions", stats.ros_versions);
710
+ kv("Videos", stats.videos);
711
+ kv("Video segments", stats.video_segments);
712
+ kv("Version range", `${stats.ros_version_min ?? "?"}–${stats.ros_version_max ?? "?"}`);
713
+
714
+ return out.join("\n");
715
+ }
716
+
717
+ function renderHelp(): string {
718
+ const out: string[] = [];
719
+ out.push(` ${bold("Commands")} ${dim("(bare text = search)")}`);
720
+ out.push("");
721
+
722
+ const cmd = (name: string, alias: string, desc: string) => {
723
+ out.push(` ${cyan(pad(name, 26))} ${dim(pad(alias, 6))} ${desc}`);
724
+ };
725
+
726
+ cmd("<query>", "", "Search pages (default action)");
727
+ cmd("search <query>", "s", "Explicit page search");
728
+ cmd("page <id|title>", "", "View full page");
729
+ cmd("prop <name>", "p", "Look up property (scoped to current page)");
730
+ cmd("props <query>", "sp", "Search properties by FTS");
731
+ cmd("cmd [path]", "tree", "Browse command tree");
732
+ cmd("device <query>", "dev", "Look up device specs");
733
+ cmd("tests [type] [mode]", "", "Cross-device benchmarks");
734
+ cmd("callouts [query]", "cal", "Search callouts (type filter: cal warning)");
735
+ cmd("changelog [query]", "cl", "Search changelogs (cl 7.22, cl breaking)");
736
+ cmd("videos <query>", "vid", "Search video transcripts");
737
+ cmd("diff <from> <to> [path]", "", "Command tree diff between versions");
738
+ cmd("vcheck <path>", "vc", "Version range for a command path");
739
+ cmd("versions", "ver", "Live-fetch current RouterOS versions");
740
+ cmd("stats", "", "Database health / counts");
741
+ cmd("back", "b", "Go to previous view");
742
+ cmd("help", "?", "This help");
743
+ cmd("quit", "q", "Exit");
744
+
745
+ out.push("");
746
+ out.push(` ${dim("Navigation: type a number to select from results.")}`);
747
+ out.push(` ${dim("After viewing a page, [p] = properties for that page.")}`);
748
+ out.push(` ${dim("URLs are clickable in supported terminals (iTerm2, etc.).")}`);
749
+
750
+ return out.join("\n");
751
+ }
752
+
753
+ // ── Command dispatcher ──
754
+
755
+ async function dispatch(input: string): Promise<void> {
756
+ const trimmed = input.trim();
757
+ if (!trimmed) return;
758
+
759
+ // Parse command + args
760
+ const parts = trimmed.split(/\s+/);
761
+ const command = parts[0].toLowerCase();
762
+ const rest = parts.slice(1).join(" ");
763
+
764
+ // ── Bare number: select from current results ──
765
+ if (/^\d+$/.test(trimmed)) {
766
+ const idx = Number.parseInt(trimmed, 10) - 1;
767
+ await handleNumberSelect(idx);
768
+ return;
769
+ }
770
+
771
+ // ── Commands ──
772
+ switch (command) {
773
+ case "q":
774
+ case "quit":
775
+ case "exit":
776
+ process.exit(0);
777
+ return; // unreachable — satisfies no-fallthrough lint
778
+
779
+ case "?":
780
+ case "help":
781
+ await paged(renderHelp());
782
+ return;
783
+
784
+ case "b":
785
+ case "back":
786
+ if (!popCtx()) {
787
+ console.log(dim(" Already at top."));
788
+ } else {
789
+ console.log(dim(` ← back to ${ctx.type}`));
790
+ }
791
+ return;
792
+
793
+ case "stats":
794
+ await paged(renderStats());
795
+ return;
796
+
797
+ case "s":
798
+ case "search":
799
+ if (!rest) { console.log(dim(" Usage: search <query>")); return; }
800
+ await doSearch(rest);
801
+ return;
802
+
803
+ case "page": {
804
+ if (!rest) { console.log(dim(" Usage: page <id|title>")); return; }
805
+ await doPage(rest);
806
+ return;
807
+ }
808
+
809
+ case "p":
810
+ case "prop": {
811
+ if (!rest) {
812
+ // Context-scoped: show properties for current page
813
+ if (ctx.type === "page") {
814
+ const page = getPage(ctx.pageId, 0); // just get metadata
815
+ if (page) {
816
+ await doPropsForPage(ctx.pageId, ctx.title);
817
+ return;
818
+ }
819
+ }
820
+ console.log(dim(" Usage: prop <name> — or navigate to a page first"));
821
+ return;
822
+ }
823
+ await doLookupProperty(rest);
824
+ return;
825
+ }
826
+
827
+ case "sp":
828
+ case "props": {
829
+ if (!rest) { console.log(dim(" Usage: props <query>")); return; }
830
+ await doSearchProperties(rest);
831
+ return;
832
+ }
833
+
834
+ case "cmd":
835
+ case "tree": {
836
+ const path = rest || (ctx.type === "commands" ? ctx.path : "");
837
+ await doCommandTree(path);
838
+ return;
839
+ }
840
+
841
+ case "dev":
842
+ case "device": {
843
+ if (!rest) { console.log(dim(" Usage: device <query>")); return; }
844
+ await doDeviceLookup(rest);
845
+ return;
846
+ }
847
+
848
+ case "tests": {
849
+ await doTests(rest);
850
+ return;
851
+ }
852
+
853
+ case "cal":
854
+ case "callouts": {
855
+ if (!rest && ctx.type === "page") {
856
+ // Show callouts for current page
857
+ const results = searchCallouts("", undefined, 50);
858
+ const pageCallouts = results.filter((c) => c.page_id === (ctx as { pageId: number }).pageId);
859
+ if (pageCallouts.length > 0) {
860
+ await paged(renderCallouts(pageCallouts));
861
+ pushCtx({ type: "callouts", query: "" });
862
+ return;
863
+ }
864
+ }
865
+ await doSearchCallouts(rest);
866
+ return;
867
+ }
868
+
869
+ case "cl":
870
+ case "changelog": {
871
+ await doSearchChangelogs(rest);
872
+ return;
873
+ }
874
+
875
+ case "vid":
876
+ case "videos": {
877
+ if (!rest) { console.log(dim(" Usage: videos <query>")); return; }
878
+ await doSearchVideos(rest);
879
+ return;
880
+ }
881
+
882
+ case "diff": {
883
+ const diffParts = rest.split(/\s+/);
884
+ if (diffParts.length < 2) {
885
+ console.log(dim(" Usage: diff <from_version> <to_version> [path_prefix]"));
886
+ return;
887
+ }
888
+ await doDiff(diffParts[0], diffParts[1], diffParts[2]);
889
+ return;
890
+ }
891
+
892
+ case "vc":
893
+ case "vcheck": {
894
+ if (!rest) { console.log(dim(" Usage: vcheck <command_path>")); return; }
895
+ await doVersionCheck(rest);
896
+ return;
897
+ }
898
+
899
+ case "ver":
900
+ case "versions": {
901
+ await doCurrentVersions();
902
+ return;
903
+ }
904
+
905
+ default:
906
+ // Bare text = search
907
+ await doSearch(trimmed);
908
+ return;
909
+ }
910
+ }
911
+
912
+ // ── Number selection handler ──
913
+
914
+ async function handleNumberSelect(idx: number): Promise<void> {
915
+ if (ctx.type === "search" && ctx.results[idx]) {
916
+ const r = ctx.results[idx];
917
+ await doPage(String(r.id));
918
+ return;
919
+ }
920
+ if (ctx.type === "sections" && ctx.sections[idx]) {
921
+ const s = ctx.sections[idx];
922
+ const page = getPage(ctx.pageId, undefined, s.anchor_id || s.heading);
923
+ if (page) {
924
+ await paged(renderPage(page));
925
+ pushCtx({ type: "page", pageId: ctx.pageId, title: ctx.title });
926
+ }
927
+ return;
928
+ }
929
+ if (ctx.type === "devices" && ctx.results[idx]) {
930
+ const d = ctx.results[idx];
931
+ const lookup = searchDevices(d.product_name, {}, 1);
932
+ if (lookup.results.length > 0) {
933
+ await paged(renderDeviceCard(lookup.results[0]));
934
+ pushCtx({ type: "device", device: lookup.results[0] });
935
+ }
936
+ return;
937
+ }
938
+ console.log(dim(` No item #${idx + 1} in current view.`));
939
+ }
940
+
941
+ // ── Action functions ──
942
+
943
+ async function doSearch(query: string): Promise<void> {
944
+ const resp = searchPages(query);
945
+ if (resp.results.length === 0) {
946
+ console.log(` ${dim("No results.")} Try: ${cyan("props")} ${query}, ${cyan("cal")} ${query}, ${cyan("vid")} ${query}`);
947
+ return;
948
+ }
949
+ await paged(renderSearchResults(resp));
950
+ pushCtx({ type: "search", response: resp, results: resp.results });
951
+ }
952
+
953
+ async function doPage(idOrTitle: string, sectionName?: string): Promise<void> {
954
+ const page = getPage(
955
+ /^\d+$/.test(idOrTitle) ? Number.parseInt(idOrTitle, 10) : idOrTitle,
956
+ undefined,
957
+ sectionName,
958
+ );
959
+ if (!page) {
960
+ console.log(dim(` Page not found: ${idOrTitle}`));
961
+ return;
962
+ }
963
+ await paged(renderPage(page));
964
+
965
+ // Determine linked command path (if any)
966
+ let commandPath: string | undefined;
967
+ try {
968
+ const row = db.prepare("SELECT path FROM commands WHERE page_id = ? LIMIT 1").get(page.id) as { path: string } | null;
969
+ if (row) commandPath = row.path;
970
+ } catch {
971
+ // commands table may not exist
972
+ }
973
+
974
+ if (page.sections && page.sections.length > 0) {
975
+ pushCtx({ type: "sections", pageId: page.id, title: page.title, sections: page.sections });
976
+ } else {
977
+ pushCtx({ type: "page", pageId: page.id, title: page.title, commandPath });
978
+ }
979
+ }
980
+
981
+ async function doPropsForPage(pageId: number, title: string): Promise<void> {
982
+ // Get all properties for this page
983
+ const results = searchProperties(title, 50);
984
+ const pageProps = results.filter((p) => p.page_title === title);
985
+ if (pageProps.length === 0) {
986
+ console.log(dim(` No properties found for "${title}".`));
987
+ return;
988
+ }
989
+ await paged(` ${bold("Properties for")} ${bold(title)}\n\n${renderProperties(pageProps)}`);
990
+ pushCtx({ type: "properties", query: title, pageId });
991
+ }
992
+
993
+ async function doLookupProperty(name: string): Promise<void> {
994
+ const commandPath = ctx.type === "page" ? (ctx as { commandPath?: string }).commandPath : undefined;
995
+ const results = lookupProperty(name, commandPath);
996
+ if (results.length === 0) {
997
+ console.log(dim(` Property "${name}" not found.`));
998
+ console.log(` Try: ${cyan("props")} ${name}`);
999
+ return;
1000
+ }
1001
+ await paged(renderProperties(results));
1002
+ pushCtx({ type: "properties", query: name });
1003
+ }
1004
+
1005
+ async function doSearchProperties(query: string): Promise<void> {
1006
+ const results = searchProperties(query);
1007
+ if (results.length === 0) {
1008
+ console.log(dim(` No properties found for "${query}".`));
1009
+ return;
1010
+ }
1011
+ await paged(` ${bold(String(results.length))} properties matching ${cyan(`"${query}"`)}\n\n${renderProperties(results)}`);
1012
+ pushCtx({ type: "properties", query });
1013
+ }
1014
+
1015
+ async function doCommandTree(path: string): Promise<void> {
1016
+ const normalized = path.startsWith("/") ? path : path ? `/${path}` : "";
1017
+ // If at a page context and no explicit path, try linked command
1018
+ const cmdPath = !path && ctx.type === "page" && (ctx as { commandPath?: string }).commandPath
1019
+ // biome-ignore lint/style/noNonNullAssertion: narrowed by the truthiness check above
1020
+ ? (ctx as { commandPath?: string }).commandPath!
1021
+ : normalized;
1022
+
1023
+ const children = browseCommands(cmdPath);
1024
+ if (children.length === 0) {
1025
+ console.log(dim(` No children at "${cmdPath}".`));
1026
+ // Try as a command version check instead
1027
+ if (cmdPath) {
1028
+ console.log(` Try: ${cyan("vc")} ${cmdPath}`);
1029
+ }
1030
+ return;
1031
+ }
1032
+ await paged(renderCommandTree(cmdPath, children));
1033
+ pushCtx({ type: "commands", path: cmdPath });
1034
+ }
1035
+
1036
+ async function doDeviceLookup(query: string): Promise<void> {
1037
+ const result = searchDevices(query, {});
1038
+ if (result.results.length === 0) {
1039
+ console.log(dim(` No devices found for "${query}".`));
1040
+ return;
1041
+ }
1042
+ await paged(renderDeviceResults(result.results, result.mode, result.total));
1043
+ if (result.results.length === 1) {
1044
+ pushCtx({ type: "device", device: result.results[0] });
1045
+ } else {
1046
+ pushCtx({ type: "devices", query, results: result.results });
1047
+ }
1048
+ }
1049
+
1050
+ async function doTests(argsStr: string): Promise<void> {
1051
+ if (!argsStr) {
1052
+ // Show available filter values
1053
+ const meta = getTestResultMeta();
1054
+ console.log(` ${bold("Test filters:")}`);
1055
+ console.log(` ${dim("Types:")} ${meta.test_types.join(", ")}`);
1056
+ console.log(` ${dim("Modes:")} ${meta.modes.join(", ")}`);
1057
+ console.log(` ${dim("Packet sizes:")} ${meta.packet_sizes.join(", ")}`);
1058
+ console.log("");
1059
+ console.log(` ${dim("Usage: tests <type> [mode] [packet_size]")}`);
1060
+ console.log(` ${dim("Example: tests ethernet Routing 1518")}`);
1061
+ return;
1062
+ }
1063
+
1064
+ const parts = argsStr.split(/\s+/);
1065
+ const filters: Record<string, string | number> = {};
1066
+ if (parts[0]) filters.test_type = parts[0];
1067
+ if (parts[1]) filters.mode = parts[1];
1068
+ if (parts[2] && /^\d+$/.test(parts[2])) filters.packet_size = Number.parseInt(parts[2], 10);
1069
+
1070
+ const result = searchDeviceTests(filters);
1071
+ if (result.results.length === 0) {
1072
+ console.log(dim(" No test results matching those filters."));
1073
+ return;
1074
+ }
1075
+ await paged(renderTests(result.results, result.total));
1076
+ pushCtx({ type: "tests" });
1077
+ }
1078
+
1079
+ async function doSearchCallouts(query: string): Promise<void> {
1080
+ // Parse type filter: "cal warning" or "cal warning dhcp"
1081
+ const types = ["note", "warning", "info", "tip"];
1082
+ const parts = query.split(/\s+/);
1083
+ let type: string | undefined;
1084
+ let searchQuery = query;
1085
+
1086
+ if (parts[0] && types.includes(parts[0].toLowerCase())) {
1087
+ type = parts[0].charAt(0).toUpperCase() + parts[0].slice(1).toLowerCase();
1088
+ searchQuery = parts.slice(1).join(" ");
1089
+ }
1090
+
1091
+ const results = searchCallouts(searchQuery, type);
1092
+ if (results.length === 0) {
1093
+ console.log(dim(` No callouts found.`));
1094
+ return;
1095
+ }
1096
+ await paged(` ${bold(String(results.length))} callouts${type ? ` (${type})` : ""}\n\n${renderCallouts(results)}`);
1097
+ pushCtx({ type: "callouts", query });
1098
+ }
1099
+
1100
+ async function doSearchChangelogs(query: string): Promise<void> {
1101
+ const parts = query.split(/\s+/).filter(Boolean);
1102
+
1103
+ // Parse options
1104
+ let breakingOnly = false;
1105
+ let version: string | undefined;
1106
+ let fromVersion: string | undefined;
1107
+ let toVersion: string | undefined;
1108
+ let category: string | undefined;
1109
+ const searchTerms: string[] = [];
1110
+
1111
+ for (const part of parts) {
1112
+ if (part.toLowerCase() === "breaking") { breakingOnly = true; continue; }
1113
+ // Version range: "7.20..7.22"
1114
+ const rangeMatch = part.match(/^(\d+\.\d+[.\w]*)\.\.(\d+\.\d+[.\w]*)$/);
1115
+ if (rangeMatch) { fromVersion = rangeMatch[1]; toVersion = rangeMatch[2]; continue; }
1116
+ // Single version: "7.22"
1117
+ if (/^\d+\.\d+/.test(part)) { version = part; continue; }
1118
+ searchTerms.push(part);
1119
+ }
1120
+
1121
+ // If we only got a version and nothing else, browse that version
1122
+ const searchQuery = searchTerms.join(" ");
1123
+
1124
+ const results = searchChangelogs(searchQuery, {
1125
+ version,
1126
+ fromVersion,
1127
+ toVersion,
1128
+ category,
1129
+ breakingOnly,
1130
+ limit: 50,
1131
+ });
1132
+
1133
+ if (results.length === 0) {
1134
+ console.log(dim(" No changelog entries found."));
1135
+ return;
1136
+ }
1137
+ await paged(` ${bold("Changelogs")}${version ? ` for ${bold(version)}` : ""}${breakingOnly ? ` ${red("(breaking only)")}` : ""}\n\n${renderChangelogs(results)}`);
1138
+ pushCtx({ type: "changelogs" });
1139
+ }
1140
+
1141
+ async function doSearchVideos(query: string): Promise<void> {
1142
+ const results = searchVideos(query, 10);
1143
+ if (results.length === 0) {
1144
+ console.log(dim(` No video results for "${query}".`));
1145
+ return;
1146
+ }
1147
+ await paged(` ${bold(String(results.length))} video results for ${cyan(`"${query}"`)}\n\n${renderVideos(results)}`);
1148
+ pushCtx({ type: "videos", query });
1149
+ }
1150
+
1151
+ async function doDiff(from: string, to: string, pathPrefix?: string): Promise<void> {
1152
+ const result = diffCommandVersions(from, to, pathPrefix);
1153
+ await paged(renderDiff(result));
1154
+ pushCtx({ type: "diff" });
1155
+ }
1156
+
1157
+ async function doVersionCheck(cmdPath: string): Promise<void> {
1158
+ const normalized = cmdPath.startsWith("/") ? cmdPath : `/${cmdPath}`;
1159
+ const result = checkCommandVersions(normalized);
1160
+ await paged(renderVersionCheck(result));
1161
+ pushCtx({ type: "vcheck", path: normalized });
1162
+ }
1163
+
1164
+ async function doCurrentVersions(): Promise<void> {
1165
+ console.log(dim(" Fetching current versions from MikroTik..."));
1166
+ const result = await fetchCurrentVersions();
1167
+ const out: string[] = [];
1168
+ out.push(` ${bold("Current RouterOS Versions")} ${dim(`(${result.fetched_at})`)}`);
1169
+ out.push("");
1170
+ for (const [channel, version] of Object.entries(result.channels)) {
1171
+ const v = version ?? dim("unavailable");
1172
+ const ch = pad(channel, 14);
1173
+ out.push(` ${dim(ch)} ${bold(String(v))}`);
1174
+ }
1175
+ console.log(out.join("\n"));
1176
+ }
1177
+
1178
+ // ── Main REPL ──
1179
+
1180
+ async function main() {
1181
+ initDb();
1182
+
1183
+ const args = process.argv.slice(2);
1184
+ const onceMode = args.includes("--once");
1185
+ const queryArgs = args.filter((a) => a !== "--once" && a !== "browse");
1186
+ const initialQuery = queryArgs.join(" ");
1187
+
1188
+ // Welcome banner (only in interactive mode)
1189
+ if (process.stdout.isTTY && !onceMode) {
1190
+ console.log(renderWelcome());
1191
+ console.log("");
1192
+ }
1193
+
1194
+ // Initial query from CLI args
1195
+ if (initialQuery) {
1196
+ await doSearch(initialQuery);
1197
+ if (onceMode) process.exit(0);
1198
+ }
1199
+
1200
+ // Non-TTY: exit after initial query or do nothing
1201
+ if (!process.stdout.isTTY) {
1202
+ if (!initialQuery) {
1203
+ console.error("Non-interactive mode requires a query argument.");
1204
+ process.exit(1);
1205
+ }
1206
+ process.exit(0);
1207
+ }
1208
+
1209
+ // Interactive REPL
1210
+ const rl = readline.createInterface({
1211
+ input: process.stdin,
1212
+ output: process.stdout,
1213
+ prompt: `${cyan("rosetta")}${dim(">")} `,
1214
+ terminal: true,
1215
+ });
1216
+
1217
+ rl.prompt();
1218
+
1219
+ rl.on("line", async (line) => {
1220
+ try {
1221
+ await dispatch(line);
1222
+ } catch (err) {
1223
+ console.error(red(` Error: ${err instanceof Error ? err.message : String(err)}`));
1224
+ }
1225
+ rl.prompt();
1226
+ });
1227
+
1228
+ rl.on("close", () => {
1229
+ console.log("");
1230
+ process.exit(0);
1231
+ });
1232
+ }
1233
+
1234
+ main();