@tikoci/rosetta 0.5.1 → 0.6.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/README.md CHANGED
@@ -269,7 +269,7 @@ Ask your AI assistant questions like:
269
269
 
270
270
  ## MCP Tools
271
271
 
272
- The server exposes 14 tools designed to work together — agents start with `routeros_search` and drill into specific data as needed:
272
+ The server exposes 16 tools designed to work together — agents start with `routeros_search` and drill into specific data as needed:
273
273
 
274
274
  | Tool | What it does |
275
275
  |------|-------------|
@@ -285,6 +285,8 @@ The server exposes 14 tools designed to work together — agents start with `rou
285
285
  | `routeros_device_lookup` | Hardware specs — filter by architecture, RAM, PoE, wireless, etc. |
286
286
  | `routeros_search_tests` | Cross-device ethernet and IPSec benchmarks |
287
287
  | `routeros_search_videos` | YouTube transcript search with chapter timestamps |
288
+ | `routeros_dude_search` | FTS across archived Dude wiki docs (separate from RouterOS search) |
289
+ | `routeros_dude_get_page` | Full Dude wiki page by ID or title, with screenshot metadata |
288
290
  | `routeros_stats` | Database health and coverage stats |
289
291
  | `routeros_current_versions` | Live-fetch current RouterOS versions from MikroTik |
290
292
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tikoci/rosetta",
3
- "version": "0.5.1",
3
+ "version": "0.6.0",
4
4
  "description": "RouterOS documentation as SQLite FTS5 — RAG search + command glossary via MCP",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/browse.ts CHANGED
@@ -19,6 +19,7 @@ import type {
19
19
  ChangelogResult,
20
20
  DeviceResult,
21
21
  DeviceTestRow,
22
+ DudeSearchResult,
22
23
  SearchResponse,
23
24
  SearchResult,
24
25
  SectionTocEntry,
@@ -29,6 +30,7 @@ import {
29
30
  checkCommandVersions,
30
31
  diffCommandVersions,
31
32
  fetchCurrentVersions,
33
+ getDudePage,
32
34
  getPage,
33
35
  getTestResultMeta,
34
36
  lookupProperty,
@@ -36,6 +38,7 @@ import {
36
38
  searchChangelogs,
37
39
  searchDevices,
38
40
  searchDeviceTests,
41
+ searchDude,
39
42
  searchPages,
40
43
  searchProperties,
41
44
  searchVideos,
@@ -196,6 +199,7 @@ type Context =
196
199
  | { type: "callouts"; query: string; results: CalloutResult[] }
197
200
  | { type: "changelogs"; results: ChangelogResult[] }
198
201
  | { type: "videos"; query: string; results: VideoSearchResult[] }
202
+ | { type: "dude"; query: string; results: DudeSearchResult[] }
199
203
  | { type: "properties"; query: string; pageId?: number; results: Array<{ name: string; page_id: number; page_title: string }> }
200
204
  | { type: "diff" }
201
205
  | { type: "vcheck"; path: string };
@@ -235,6 +239,7 @@ function contextLabel(c: Context): string {
235
239
  case "callouts": return c.query ? truncate(`cal: "${c.query}"`, 30) : "callouts";
236
240
  case "changelogs": return "changelogs";
237
241
  case "videos": return truncate(`vid: "${c.query}"`, 30);
242
+ case "dude": return truncate(`dude: "${c.query}"`, 30);
238
243
  case "diff": return "diff";
239
244
  case "vcheck": return truncate(`vc: ${c.path}`, 30);
240
245
  }
@@ -281,7 +286,7 @@ function renderSearchResults(resp: SearchResponse): string {
281
286
 
282
287
  // Navigation hints
283
288
  const hints = [
284
- `${cyan("#N")} view page`,
289
+ `${cyan("[N]")} view page`,
285
290
  `${cyan("[s <query>]")} search`,
286
291
  `${cyan("[p <query>]")} properties`,
287
292
  `${cyan("[cmd <path>]")} commands`,
@@ -374,7 +379,7 @@ function renderPage(page: NonNullable<ReturnType<typeof getPage>>): string {
374
379
  // Navigation hints
375
380
  out.push("");
376
381
  const hints: string[] = [];
377
- if (page.sections && page.sections.length > 0) hints.push(`${cyan("#N")} section`);
382
+ if (page.sections && page.sections.length > 0) hints.push(`${cyan("[N]")} section`);
378
383
  hints.push(`${cyan("[p]")} properties`);
379
384
  hints.push(`${cyan("[cmd]")} command tree`);
380
385
  hints.push(`${cyan("[cal]")} callouts`);
@@ -480,7 +485,7 @@ function renderDeviceResults(results: DeviceResult[], mode: string, total: numbe
480
485
  out.push("");
481
486
  }
482
487
 
483
- out.push(` ${cyan("#N")} view device ${cyan("[tests]")} benchmarks ${cyan("[b]")} back`);
488
+ out.push(` ${cyan("[N]")} view device ${cyan("[tests]")} benchmarks ${cyan("[b]")} back`);
484
489
  return out.join("\n");
485
490
  }
486
491
 
@@ -529,24 +534,17 @@ function renderDeviceCard(d: DeviceResult): string {
529
534
  out.push(` ${dim("Block diagram:")} ${cyan(link(d.block_diagram_url, "view"))}`);
530
535
  }
531
536
 
532
- // Test results (attached for exact matches) — default to 512B for a useful baseline
537
+ // Test results (attached for exact matches)
533
538
  if (d.test_results && d.test_results.length > 0) {
534
- const pkt512 = d.test_results.filter((t) => t.packet_size === 512);
535
- const displayTests = pkt512.length > 0 ? pkt512 : d.test_results.slice(0, 20);
536
- const pktNote = pkt512.length > 0 ? " (512B baseline)" : "";
537
539
  out.push("");
538
- out.push(` ${bold("Benchmarks:")} ${dim(`(${displayTests.length} of ${d.test_results.length} tests${pktNote})`)}`);
539
- for (const t of displayTests) {
540
+ out.push(` ${bold("Benchmarks:")} ${dim(`(${d.test_results.length} tests)`)}`);
541
+ for (const t of d.test_results.slice(0, 12)) {
540
542
  const mbps = t.throughput_mbps ? `${fmt(t.throughput_mbps)} Mbps` : "";
541
543
  const kpps = t.throughput_kpps ? `${fmt(t.throughput_kpps)} Kpps` : "";
542
544
  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)}`);
543
545
  }
544
- if (d.test_results.length > displayTests.length) {
545
- const otherSizes = [...new Set(
546
- d.test_results.filter((t) => t.packet_size !== 512).map((t) => t.packet_size),
547
- )].sort((a, b) => a - b);
548
- const sizesStr = otherSizes.map((s) => `${s}B`).join(", ");
549
- out.push(` ${dim(`Also at ${sizesStr} — use`)} ${cyan("tests")} ${dim("<type> <size> for other packet sizes")}`);
546
+ if (d.test_results.length > 12) {
547
+ out.push(` ${dim(`... and ${d.test_results.length - 12} more (use`)} ${cyan("tests")} ${dim("for full listing)")}`);
550
548
  }
551
549
  }
552
550
 
@@ -637,7 +635,7 @@ function renderChangelogs(results: ChangelogResult[]): string {
637
635
  }
638
636
 
639
637
  out.push("");
640
- out.push(` ${cyan("[cl 7.22 bgp]")} version + topic ${cyan("[cl 7.21..7.22]")} range ${cyan("[cl breaking]")} breaking only ${cyan("[b]")} back`);
638
+ out.push(` ${cyan("[cl breaking]")} breaking only ${cyan("[cl <ver>]")} specific version ${cyan("[b]")} back`);
641
639
  return out.join("\n");
642
640
  }
643
641
 
@@ -669,6 +667,65 @@ function renderVideos(results: VideoSearchResult[]): string {
669
667
  return out.join("\n");
670
668
  }
671
669
 
670
+ function renderDudeResults(results: DudeSearchResult[]): string {
671
+ const out: string[] = [];
672
+ if (results.length === 0) {
673
+ out.push(` ${dim("No Dude wiki results found.")}`);
674
+ return out.join("\n");
675
+ }
676
+
677
+ for (let i = 0; i < results.length; i++) {
678
+ const d = results[i];
679
+ const num = dim(`${String(i + 1).padStart(3)} `);
680
+ const title = bold(truncate(d.title, 60));
681
+ const ver = dim(`[${d.version}]`);
682
+ const imgs = d.image_count > 0 ? dim(`📷 ${d.image_count}`) : "";
683
+ out.push(`${num}${title} ${ver} ${imgs}`);
684
+ out.push(` ${dim(d.path)}`);
685
+ const excerpt = d.excerpt.replace(/\*\*/g, `${ESC}[1m`);
686
+ out.push(` ${dim(truncate(excerpt, termWidth() - 8))}`);
687
+ out.push("");
688
+ }
689
+
690
+ out.push(` ${cyan("[<n>]")} view page ${cyan("[s <query>]")} search docs ${cyan("[b]")} back`);
691
+ return out.join("\n");
692
+ }
693
+
694
+ function renderDudePage(page: import("./query.ts").DudePageResult): string {
695
+ const out: string[] = [];
696
+ out.push(` ${bold(page.title)} ${dim(`[${page.version}]`)}`);
697
+ out.push(` ${dim(page.path)}`);
698
+ out.push(` ${cyan(link(page.url))}`);
699
+ if (page.wayback_url) out.push(` ${dim(`Archived: ${page.wayback_url}`)}`);
700
+ out.push("");
701
+
702
+ if (page.images.length > 0) {
703
+ out.push(` ${bold("Screenshots:")} ${page.images.length}`);
704
+ for (const img of page.images) {
705
+ out.push(` ${dim("•")} ${img.filename}${img.caption ? ` ${dim(img.caption)}` : ""}`);
706
+ out.push(` ${dim(img.local_path)}`);
707
+ }
708
+ out.push("");
709
+ }
710
+
711
+ // Truncate long pages for TUI display
712
+ const maxChars = 8000;
713
+ const text = page.text.length > maxChars
714
+ ? `${page.text.slice(0, maxChars)}\n\n ${dim(`... truncated (${page.text.length} chars total)`)}`
715
+ : page.text;
716
+ out.push(text);
717
+
718
+ if (page.code) {
719
+ out.push("");
720
+ out.push(` ${bold("Code:")}`);
721
+ out.push(page.code.length > 2000 ? `${page.code.slice(0, 2000)}\n ${dim("... truncated")}` : page.code);
722
+ }
723
+
724
+ out.push("");
725
+ out.push(` ${cyan("[dude <query>]")} search dude ${cyan("[s <query>]")} search docs ${cyan("[b]")} back`);
726
+ return out.join("\n");
727
+ }
728
+
672
729
  function renderDiff(result: ReturnType<typeof diffCommandVersions>): string {
673
730
  const out: string[] = [];
674
731
  out.push(` ${bold("Command diff:")} ${result.from_version} → ${result.to_version}`);
@@ -728,6 +785,13 @@ function renderStats(): string {
728
785
  const out: string[] = [];
729
786
  out.push(` ${bold("Database Statistics")}`);
730
787
  out.push(` ${dim("Path:")} ${stats.db_path}`);
788
+ if (stats.db_size_bytes != null) {
789
+ const mb = stats.db_size_bytes / (1024 * 1024);
790
+ out.push(` ${dim("Size:")} ${mb.toFixed(1)} MB`);
791
+ }
792
+ if (stats.schema_version != null) {
793
+ out.push(` ${dim("Schema:")} v${stats.schema_version}`);
794
+ }
731
795
  out.push(` ${dim("Export:")} ${stats.doc_export}`);
732
796
  out.push("");
733
797
 
@@ -771,10 +835,11 @@ function renderHelp(): string {
771
835
  cmd("props <query>", "sp", "Search properties by FTS", "routeros_search_properties");
772
836
  cmd("cmd [path]", "tree", "Browse command tree", "routeros_command_tree");
773
837
  cmd("device <query>", "dev", "Look up device specs", "routeros_device_lookup");
774
- cmd("tests [device] <type> [packet_size] [mode]", "", "Cross-device benchmarks (512B default)", "routeros_search_tests");
838
+ cmd("tests [device] [type]", "", "Cross-device benchmarks", "routeros_search_tests");
775
839
  cmd("callouts [query]", "cal", "Search callouts (type filter: cal warning)", "routeros_search_callouts");
776
840
  cmd("changelog [query]", "cl", "Search changelogs (cl 7.22, cl breaking)", "routeros_search_changelogs");
777
841
  cmd("videos <query>", "vid", "Search video transcripts", "routeros_search_videos");
842
+ cmd("dude <query>", "", "Search archived Dude wiki docs", "routeros_dude_search");
778
843
  cmd("diff <from> <to> [path]", "", "Command tree diff between versions", "routeros_command_diff");
779
844
  cmd("vcheck <path>", "vc", "Version range for a command path", "routeros_command_version_check");
780
845
  cmd("versions", "ver", "Live-fetch current RouterOS versions", "routeros_current_versions");
@@ -791,69 +856,8 @@ function renderHelp(): string {
791
856
  return out.join("\n");
792
857
  }
793
858
 
794
- /** Fetch the full transcript text for a specific video segment. */
795
- function getVideoSegmentTranscript(youtubeVideoId: string, startS: number): string | null {
796
- try {
797
- const row = db
798
- .prepare(
799
- `SELECT vs.transcript FROM video_segments vs
800
- JOIN videos v ON v.id = vs.video_id
801
- WHERE v.video_id = ? AND vs.start_s = ? LIMIT 1`,
802
- )
803
- .get(youtubeVideoId, startS) as { transcript: string } | null;
804
- return row?.transcript ?? null;
805
- } catch {
806
- return null;
807
- }
808
- }
809
-
810
859
  // ── Command dispatcher ──
811
860
 
812
- /** Re-render the current context after going back — uses cached results where possible. */
813
- async function renderCurrentCtx(): Promise<void> {
814
- switch (ctx.type) {
815
- case "home":
816
- console.log(renderWelcome());
817
- return;
818
- case "search":
819
- await paged(renderSearchResults(ctx.response));
820
- return;
821
- case "commands": {
822
- const children = browseCommands(ctx.path);
823
- if (children.length > 0) await paged(renderCommandTree(ctx.path, children));
824
- else console.log(dim(` No children at "${ctx.path}".`));
825
- return;
826
- }
827
- case "devices":
828
- await paged(renderDeviceResults(ctx.results, "cached", ctx.results.length));
829
- return;
830
- case "device":
831
- await paged(renderDeviceCard(ctx.device));
832
- return;
833
- case "callouts":
834
- if (ctx.results.length > 0) await paged(renderCallouts(ctx.results));
835
- return;
836
- case "changelogs":
837
- if (ctx.results.length > 0) await paged(renderChangelogs(ctx.results));
838
- return;
839
- case "videos":
840
- if (ctx.results.length > 0) await paged(renderVideos(ctx.results));
841
- return;
842
- case "page": {
843
- const page = getPage(ctx.pageId);
844
- if (page) await paged(renderPage(page));
845
- return;
846
- }
847
- case "sections": {
848
- const page = getPage(ctx.pageId);
849
- if (page) await paged(renderPage(page));
850
- return;
851
- }
852
- default:
853
- console.log(dim(` In ${ctx.type} context. Type a command or help.`));
854
- }
855
- }
856
-
857
861
  async function dispatch(input: string): Promise<void> {
858
862
  const trimmed = input.trim();
859
863
  if (!trimmed) return;
@@ -888,8 +892,7 @@ async function dispatch(input: string): Promise<void> {
888
892
  if (!popCtx()) {
889
893
  console.log(dim(" Already at top."));
890
894
  } else {
891
- console.log(dim(` ← ${ctx.type}`));
892
- await renderCurrentCtx();
895
+ console.log(dim(` ← back to ${ctx.type}`));
893
896
  }
894
897
  return;
895
898
 
@@ -904,20 +907,7 @@ async function dispatch(input: string): Promise<void> {
904
907
  return;
905
908
 
906
909
  case "page": {
907
- if (!rest) {
908
- // From commands context: navigate to the linked page
909
- if (ctx.type === "commands") {
910
- const row = db.prepare("SELECT page_id FROM commands WHERE path = ? LIMIT 1").get(ctx.path) as { page_id: number | null } | null;
911
- if (row?.page_id) {
912
- await doPage(String(row.page_id));
913
- return;
914
- }
915
- console.log(dim(` No linked page for ${ctx.path}.`));
916
- return;
917
- }
918
- console.log(dim(" Usage: page <id|title>"));
919
- return;
920
- }
910
+ if (!rest) { console.log(dim(" Usage: page <id|title>")); return; }
921
911
  await doPage(rest);
922
912
  return;
923
913
  }
@@ -925,12 +915,13 @@ async function dispatch(input: string): Promise<void> {
925
915
  case "p":
926
916
  case "prop": {
927
917
  if (!rest) {
928
- // Context-scoped: show properties for current page or sections context
929
- const pageId = ctx.type === "page" ? ctx.pageId : ctx.type === "sections" ? ctx.pageId : null;
930
- const title = ctx.type === "page" ? ctx.title : ctx.type === "sections" ? ctx.title : null;
931
- if (pageId !== null && title !== null) {
932
- await doPropsForPage(pageId, title);
933
- return;
918
+ // Context-scoped: show properties for current page
919
+ if (ctx.type === "page") {
920
+ const page = getPage(ctx.pageId, 0); // just get metadata
921
+ if (page) {
922
+ await doPropsForPage(ctx.pageId, ctx.title);
923
+ return;
924
+ }
934
925
  }
935
926
  console.log(dim(" Usage: prop <name> — or navigate to a page first"));
936
927
  return;
@@ -948,15 +939,8 @@ async function dispatch(input: string): Promise<void> {
948
939
 
949
940
  case "cmd":
950
941
  case "tree": {
951
- let cmdPath = rest;
952
- if (!cmdPath && ctx.type === "commands") {
953
- // Re-display current commands context
954
- cmdPath = ctx.path;
955
- } else if (cmdPath && !cmdPath.startsWith("/") && ctx.type === "commands") {
956
- // Relative path: resolve against current commands path
957
- cmdPath = `${ctx.path}/${cmdPath}`;
958
- }
959
- await doCommandTree(cmdPath);
942
+ const path = rest || (ctx.type === "commands" ? ctx.path : "");
943
+ await doCommandTree(path);
960
944
  return;
961
945
  }
962
946
 
@@ -974,24 +958,13 @@ async function dispatch(input: string): Promise<void> {
974
958
 
975
959
  case "cal":
976
960
  case "callouts": {
977
- if (!rest) {
978
- // Context-aware: show callouts for the current page directly
979
- const pageId = ctx.type === "page" ? ctx.pageId
980
- : ctx.type === "sections" ? ctx.pageId
981
- : null;
982
- if (pageId !== null) {
983
- const pageCallouts = db.prepare(
984
- `SELECT c.type, c.content, p.title as page_title, p.url as page_url,
985
- c.page_id, c.content as excerpt
986
- FROM callouts c JOIN pages p ON p.id = c.page_id
987
- WHERE c.page_id = ? ORDER BY c.sort_order`,
988
- ).all(pageId) as CalloutResult[];
989
- if (pageCallouts.length > 0) {
990
- await paged(renderCallouts(pageCallouts));
991
- pushCtx({ type: "callouts", query: "", results: pageCallouts });
992
- } else {
993
- console.log(dim(" No callouts for this page."));
994
- }
961
+ if (!rest && ctx.type === "page") {
962
+ // Show callouts for current page
963
+ const results = searchCallouts("", undefined, 50);
964
+ const pageCallouts = results.filter((c) => c.page_id === (ctx as { pageId: number }).pageId);
965
+ if (pageCallouts.length > 0) {
966
+ await paged(renderCallouts(pageCallouts));
967
+ pushCtx({ type: "callouts", query: "", results: pageCallouts });
995
968
  return;
996
969
  }
997
970
  }
@@ -1006,13 +979,18 @@ async function dispatch(input: string): Promise<void> {
1006
979
  }
1007
980
 
1008
981
  case "vid":
1009
- case "video":
1010
982
  case "videos": {
1011
983
  if (!rest) { console.log(dim(" Usage: videos <query>")); return; }
1012
984
  await doSearchVideos(rest);
1013
985
  return;
1014
986
  }
1015
987
 
988
+ case "dude": {
989
+ if (!rest) { console.log(dim(" Usage: dude <query>")); return; }
990
+ await doSearchDude(rest);
991
+ return;
992
+ }
993
+
1016
994
  case "diff": {
1017
995
  const diffParts = rest.split(/\s+/);
1018
996
  if (diffParts.length < 2) {
@@ -1077,18 +1055,18 @@ async function handleNumberSelect(idx: number): Promise<void> {
1077
1055
  if (ctx.type === "videos" && ctx.results[idx]) {
1078
1056
  const v = ctx.results[idx];
1079
1057
  const timeUrl = v.start_s > 0 ? `${v.url}&t=${v.start_s}` : v.url;
1080
- const out: string[] = [];
1081
- out.push(`\n ${bold(v.title)}`);
1082
- if (v.chapter_title) out.push(` ${magenta(`§ ${v.chapter_title}`)} ${dim(`@ ${formatTime(v.start_s)}`)}`);
1083
- out.push(` ${cyan(link(timeUrl))}`);
1084
- // Fetch and show full transcript for this segment
1085
- const transcript = getVideoSegmentTranscript(v.video_id, v.start_s);
1086
- if (transcript) {
1087
- out.push("");
1088
- out.push(` ${dim("── transcript ──")}`);
1089
- out.push(transcript.split("\n").map((l) => ` ${l}`).join("\n"));
1058
+ console.log(`\n ${bold(v.title)}`);
1059
+ if (v.chapter_title) console.log(` ${magenta(`§ ${v.chapter_title}`)} ${dim(`@ ${formatTime(v.start_s)}`)}`);
1060
+ console.log(` ${cyan(link(timeUrl))}\n`);
1061
+ return;
1062
+ }
1063
+ if (ctx.type === "dude" && ctx.results[idx]) {
1064
+ const d = ctx.results[idx];
1065
+ const page = getDudePage(d.id);
1066
+ if (page) {
1067
+ await paged(renderDudePage(page));
1068
+ pushCtx({ type: "dude", query: ctx.query, results: ctx.results });
1090
1069
  }
1091
- await paged(out.join("\n"));
1092
1070
  return;
1093
1071
  }
1094
1072
  if (ctx.type === "properties" && ctx.results[idx]) {
@@ -1215,64 +1193,33 @@ async function doDeviceLookup(query: string): Promise<void> {
1215
1193
  }
1216
1194
 
1217
1195
  async function doTests(argsStr: string): Promise<void> {
1218
- const parts = argsStr.split(/\s+/).filter(Boolean);
1219
- const filters: Record<string, string | number> = {};
1220
-
1221
- // Parse args if provided
1222
- if (argsStr) {
1223
- const knownTypes = ["ethernet", "ipsec"];
1224
- let offset = 0;
1225
- if (parts[0] && !knownTypes.includes(parts[0].toLowerCase())) {
1226
- filters.device = parts[0];
1227
- offset = 1;
1228
- }
1229
- if (parts[offset]) filters.test_type = parts[offset];
1230
- // packet_size is always numeric; mode can be multi-word — identify by type
1231
- const remaining = parts.slice(offset + 1);
1232
- const pktIdx = remaining.findIndex((p) => /^\d+$/.test(p));
1233
- if (pktIdx !== -1) {
1234
- filters.packet_size = Number.parseInt(remaining[pktIdx], 10);
1235
- const modeParts = [...remaining.slice(0, pktIdx), ...remaining.slice(pktIdx + 1)];
1236
- if (modeParts.length > 0) filters.mode = modeParts.join(" ");
1237
- } else if (remaining.length > 0) {
1238
- filters.mode = remaining.join(" ");
1239
- }
1240
- }
1241
-
1242
- // Auto-inject device name from context when in a device view and none specified
1243
- if (!filters.device && ctx.type === "device") {
1244
- filters.device = ctx.device.product_name;
1245
- }
1246
-
1247
- // Show help if no filters could be determined
1248
- if (!argsStr && !filters.device) {
1196
+ if (!argsStr) {
1197
+ // Show available filter values
1249
1198
  const meta = getTestResultMeta();
1250
1199
  console.log(` ${bold("Test filters:")}`);
1251
1200
  console.log(` ${dim("Types:")} ${meta.test_types.join(", ")}`);
1252
1201
  console.log(` ${dim("Modes:")} ${meta.modes.join(", ")}`);
1253
1202
  console.log(` ${dim("Packet sizes:")} ${meta.packet_sizes.join(", ")}`);
1254
1203
  console.log("");
1255
- console.log(` ${dim("Usage: tests [device] <type> [packet_size] [mode]")}`);
1204
+ console.log(` ${dim("Usage: tests [device] <type> [mode] [packet_size]")}`);
1256
1205
  console.log(` ${dim("Example: tests ethernet Routing 1518")}`);
1257
1206
  console.log(` ${dim("Example: tests rb5009 ethernet 1518")}`);
1258
1207
  return;
1259
1208
  }
1260
1209
 
1261
- // Round packet_size to the nearest valid test packet size
1262
- if (filters.packet_size) {
1263
- const validSizes = [64, 512, 1400, 1518];
1264
- const size = Number(filters.packet_size);
1265
- const rounded = validSizes.reduce((prev, curr) =>
1266
- Math.abs(curr - size) < Math.abs(prev - size) ? curr : prev,
1267
- );
1268
- if (rounded !== size) {
1269
- console.log(dim(` Rounding ${size}B → ${rounded}B (nearest test packet size)`));
1270
- filters.packet_size = rounded;
1271
- }
1272
- }
1210
+ const parts = argsStr.split(/\s+/);
1211
+ const filters: Record<string, string | number> = {};
1273
1212
 
1274
- // Default packet_size to 512 for a focused baseline comparison
1275
- if (!filters.packet_size && !filters.mode) filters.packet_size = 512;
1213
+ // Known test types if parts[0] is not a known type, treat it as a device filter
1214
+ const knownTypes = ["ethernet", "ipsec"];
1215
+ let offset = 0;
1216
+ if (parts[0] && !knownTypes.includes(parts[0].toLowerCase())) {
1217
+ filters.device = parts[0];
1218
+ offset = 1;
1219
+ }
1220
+ if (parts[offset]) filters.test_type = parts[offset];
1221
+ if (parts[offset + 1]) filters.mode = parts[offset + 1];
1222
+ if (parts[offset + 2] && /^\d+$/.test(parts[offset + 2])) filters.packet_size = Number.parseInt(parts[offset + 2], 10);
1276
1223
 
1277
1224
  const result = searchDeviceTests(filters);
1278
1225
  if (result.results.length === 0) {
@@ -1355,6 +1302,16 @@ async function doSearchVideos(query: string): Promise<void> {
1355
1302
  pushCtx({ type: "videos", query, results });
1356
1303
  }
1357
1304
 
1305
+ async function doSearchDude(query: string): Promise<void> {
1306
+ const results = searchDude(query, 10);
1307
+ if (results.length === 0) {
1308
+ console.log(dim(` No Dude wiki results for "${query}".`));
1309
+ return;
1310
+ }
1311
+ await paged(` ${bold(String(results.length))} Dude wiki results for ${cyan(`"${query}"`)}\n\n${renderDudeResults(results)}`);
1312
+ pushCtx({ type: "dude", query, results });
1313
+ }
1314
+
1358
1315
  async function doDiff(from: string, to: string, pathPrefix?: string): Promise<void> {
1359
1316
  const result = diffCommandVersions(from, to, pathPrefix);
1360
1317
  await paged(renderDiff(result));
@@ -1379,6 +1336,7 @@ async function doCurrentVersions(): Promise<void> {
1379
1336
  const ch = pad(channel, 14);
1380
1337
  out.push(` ${dim(ch)} ${bold(String(v))}`);
1381
1338
  }
1339
+ out.push(` ${dim(pad("winbox 4", 14))} ${bold(String(result.winbox ?? dim("unavailable")))}`);
1382
1340
  console.log(out.join("\n"));
1383
1341
  }
1384
1342
 
@@ -1429,9 +1387,6 @@ async function main() {
1429
1387
  } catch (err) {
1430
1388
  console.error(red(` Error: ${err instanceof Error ? err.message : String(err)}`));
1431
1389
  }
1432
- // Clear any keys that leaked from the pager into readline's internal line buffer
1433
- // (the key pressed to exit paging — q/SPACE/ENTER — can appear in the next prompt)
1434
- rl.write(null as unknown as string, { ctrl: true, name: "u" });
1435
1390
  rl.setPrompt(buildPrompt());
1436
1391
  rl.prompt();
1437
1392
  });