@tikoci/rosetta 0.8.4 → 0.8.5
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/package.json +1 -1
- package/src/browse.ts +136 -43
- package/src/mcp.ts +5 -0
- package/src/query.test.ts +24 -0
- package/src/query.ts +11 -1
- package/src/release.test.ts +45 -0
package/package.json
CHANGED
package/src/browse.ts
CHANGED
|
@@ -200,7 +200,6 @@ function formatTime(seconds: number): string {
|
|
|
200
200
|
|
|
201
201
|
// ── Pager ──
|
|
202
202
|
|
|
203
|
-
/** Output lines with paging. Returns true if user quit early. */
|
|
204
203
|
/**
|
|
205
204
|
* Interactive pager.
|
|
206
205
|
*
|
|
@@ -210,17 +209,26 @@ function formatTime(seconds: number): string {
|
|
|
210
209
|
* b / < previous page
|
|
211
210
|
* g jump to top
|
|
212
211
|
* G jump to bottom
|
|
213
|
-
* q quit pager
|
|
212
|
+
* q quit pager
|
|
213
|
+
* 1-9 If `selectCount` is set and digit ≤ selectCount, **select**
|
|
214
|
+
* that result (returns { selected: N }). Otherwise, jump to
|
|
215
|
+
* page N within the pager.
|
|
214
216
|
*
|
|
215
|
-
* Shows a status line with page X/Y and line N/M. Returns
|
|
216
|
-
* before EOF
|
|
217
|
+
* Shows a status line with page X/Y and line N/M. Returns:
|
|
218
|
+
* - { quit: true } user pressed q before EOF
|
|
219
|
+
* - { quit: false } reached EOF naturally
|
|
220
|
+
* - { quit: true, selected: N } user picked result N from the displayed list
|
|
217
221
|
*/
|
|
218
|
-
|
|
222
|
+
type PagerResult = { quit: boolean; selected?: number };
|
|
223
|
+
|
|
224
|
+
async function paged(output: string, opts?: { selectCount?: number }): Promise<PagerResult> {
|
|
225
|
+
const selectCount = opts?.selectCount ?? 0;
|
|
219
226
|
const lines = output.split("\n");
|
|
220
227
|
const pageSize = Math.max(5, termHeight() - 2);
|
|
221
228
|
if (lines.length <= pageSize || !process.stdout.isTTY) {
|
|
222
229
|
process.stdout.write(`${output}\n`);
|
|
223
|
-
|
|
230
|
+
// Non-TTY (or single screen) — no interactive selection possible.
|
|
231
|
+
return { quit: false };
|
|
224
232
|
}
|
|
225
233
|
const totalPages = Math.ceil(lines.length / pageSize);
|
|
226
234
|
let offset = 0;
|
|
@@ -228,15 +236,25 @@ async function paged(output: string): Promise<boolean> {
|
|
|
228
236
|
const chunk = lines.slice(offset, offset + pageSize);
|
|
229
237
|
process.stdout.write(`${chunk.join("\n")}\n`);
|
|
230
238
|
const newOffset = offset + pageSize;
|
|
231
|
-
if (newOffset >= lines.length) return false;
|
|
239
|
+
if (newOffset >= lines.length) return { quit: false };
|
|
232
240
|
const curPage = Math.floor(newOffset / pageSize);
|
|
241
|
+
// Cap selectable digits at 9 (single keystroke).
|
|
242
|
+
const maxSel = Math.min(9, selectCount);
|
|
243
|
+
const selectHint = maxSel > 0
|
|
244
|
+
? ` | 1-${maxSel} open #N`
|
|
245
|
+
: "";
|
|
246
|
+
const jumpHint = totalPages > 1 && maxSel < 9
|
|
247
|
+
? ` | ${maxSel + 1}-${Math.min(9, totalPages)} jump page`
|
|
248
|
+
: totalPages > 1 && maxSel === 0
|
|
249
|
+
? ` | 1-${Math.min(9, totalPages)} jump page`
|
|
250
|
+
: "";
|
|
233
251
|
const prompt = dim(
|
|
234
|
-
`── page ${curPage}/${totalPages} line ${newOffset}/${lines.length} [SPACE next | ENTER line | b back
|
|
252
|
+
`── page ${curPage}/${totalPages} line ${newOffset}/${lines.length} [SPACE next | ENTER line | b back${selectHint}${jumpHint} | g/G top/end | q quit]`,
|
|
235
253
|
);
|
|
236
254
|
process.stdout.write(prompt);
|
|
237
255
|
const key = await waitForKey();
|
|
238
256
|
process.stdout.write(`\r${" ".repeat(Math.min(termWidth(), stripAnsi(prompt).length))}\r`);
|
|
239
|
-
if (key === "q" || key === "Q") return true;
|
|
257
|
+
if (key === "q" || key === "Q") return { quit: true };
|
|
240
258
|
if (key === "g") { offset = 0; continue; }
|
|
241
259
|
if (key === "G") { offset = Math.max(0, lines.length - pageSize); continue; }
|
|
242
260
|
if (key === "b" || key === "<" || key === "\x1b[D") {
|
|
@@ -247,18 +265,26 @@ async function paged(output: string): Promise<boolean> {
|
|
|
247
265
|
offset += 1;
|
|
248
266
|
continue;
|
|
249
267
|
}
|
|
250
|
-
// Digit 1-9 jumps to that page (1-indexed). If the page is past EOF,
|
|
251
|
-
// clamp to the last page.
|
|
252
268
|
if (/^[1-9]$/.test(key)) {
|
|
253
|
-
const
|
|
254
|
-
|
|
269
|
+
const n = Number.parseInt(key, 10);
|
|
270
|
+
// Digits 1..selectCount → SELECT the displayed result and end paging.
|
|
271
|
+
if (selectCount > 0 && n <= selectCount) {
|
|
272
|
+
return { quit: true, selected: n };
|
|
273
|
+
}
|
|
274
|
+
// Digits beyond selectCount → page jump (1-indexed). Clamp to last page.
|
|
275
|
+
offset = Math.min(lines.length - pageSize, (n - 1) * pageSize);
|
|
255
276
|
if (offset < 0) offset = 0;
|
|
256
277
|
continue;
|
|
257
278
|
}
|
|
258
279
|
// SPACE / f / anything else → next page
|
|
259
280
|
offset = newOffset;
|
|
260
281
|
}
|
|
261
|
-
return false;
|
|
282
|
+
return { quit: false };
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
async function selectFromPager(output: string, selectCount: number): Promise<void> {
|
|
286
|
+
const result = await paged(output, { selectCount });
|
|
287
|
+
if (result.selected !== undefined) await handleNumberSelect(result.selected - 1);
|
|
262
288
|
}
|
|
263
289
|
|
|
264
290
|
function waitForKey(): Promise<string> {
|
|
@@ -1053,7 +1079,8 @@ function renderHelp(): string {
|
|
|
1053
1079
|
|
|
1054
1080
|
cmd("<query>", "", "Search pages (default action)", "routeros_search");
|
|
1055
1081
|
cmd("search <query>", "s", "Explicit page search", "routeros_search");
|
|
1056
|
-
cmd("page <id|title>", "", "View full page", "routeros_get_page");
|
|
1082
|
+
cmd("page <id|title>", "", "View full page (no args = re-render current)", "routeros_get_page");
|
|
1083
|
+
cmd("view", "v", "Re-render current view (without popping the stack like `b`)");
|
|
1057
1084
|
cmd("prop <name>", "p", "Look up property (scoped to current page)", "routeros_lookup_property");
|
|
1058
1085
|
cmd("props <query>", "sp", "Search properties by FTS");
|
|
1059
1086
|
cmd("glossary [term]", "g", "Look up RouterOS jargon / list glossary");
|
|
@@ -1081,8 +1108,8 @@ function renderHelp(): string {
|
|
|
1081
1108
|
out.push(` ${cyan(pad(".resources", 38))} ${dim("Registered MCP resources (rosetta:// URIs)")}`);
|
|
1082
1109
|
out.push(` ${cyan(pad(".routeros_search <q> [limit=N]", 38))} ${dim("Raw JSON output, same query path as MCP")}`);
|
|
1083
1110
|
out.push("");
|
|
1084
|
-
out.push(` ${dim("Navigation: type a number to select from results; in pager, 1–
|
|
1085
|
-
out.push(` ${dim("After viewing a page, [p] = properties, [cal] =
|
|
1111
|
+
out.push(` ${dim("Navigation: type a number to select from results; in pager, 1–N opens result #N (1-indexed).")}`);
|
|
1112
|
+
out.push(` ${dim("After viewing a page, [v] = re-render, [N] = go to section N, [p] = properties, [cal] = callouts, [b] = back.")}`);
|
|
1086
1113
|
out.push(` ${dim("URLs are clickable in supported terminals (iTerm2, etc.).")}`);
|
|
1087
1114
|
out.push(` ${dim("cmd supports @version suffix: cmd /ip/address @7.15")}`);
|
|
1088
1115
|
out.push("");
|
|
@@ -1358,6 +1385,27 @@ async function dispatchDotCommand(input: string): Promise<void> {
|
|
|
1358
1385
|
}
|
|
1359
1386
|
try {
|
|
1360
1387
|
const args = parseDotArgs(m[2] ?? "", tool.primary, tool.aliases);
|
|
1388
|
+
// If the tool has a primary arg and the user supplied no value for it
|
|
1389
|
+
// (and no key=value args at all), show a usage hint instead of running
|
|
1390
|
+
// the tool against an empty/null query — calling with no args usually
|
|
1391
|
+
// returns null or an empty object, which is confusing for users probing
|
|
1392
|
+
// the MCP surface for the first time.
|
|
1393
|
+
if (
|
|
1394
|
+
tool.primary &&
|
|
1395
|
+
(args[tool.primary] === undefined || args[tool.primary] === "") &&
|
|
1396
|
+
Object.keys(args).length === 0
|
|
1397
|
+
) {
|
|
1398
|
+
const aliasStr = tool.aliases
|
|
1399
|
+
? Object.keys(tool.aliases).map((a) => `${a}=`).join(" | ")
|
|
1400
|
+
: "";
|
|
1401
|
+
const primaryHint = aliasStr
|
|
1402
|
+
? `${tool.primary}= (or ${aliasStr})`
|
|
1403
|
+
: `${tool.primary}=`;
|
|
1404
|
+
console.log(` ${dim("Usage:")} ${cyan(`.${name}`)} ${dim(`<${tool.primary}> | ${primaryHint}<value> [key=value ...]`)}`);
|
|
1405
|
+
console.log(` ${dim(tool.desc)}`);
|
|
1406
|
+
if (tool.tui) console.log(` ${dim("TUI equivalent:")} ${cyan(tool.tui)}`);
|
|
1407
|
+
return;
|
|
1408
|
+
}
|
|
1361
1409
|
const result = await tool.run(args);
|
|
1362
1410
|
const json = JSON.stringify(result, null, 2);
|
|
1363
1411
|
const banner = dim(`── .${name} args=${JSON.stringify(args)}`);
|
|
@@ -1426,16 +1474,38 @@ async function dispatch(input: string): Promise<void> {
|
|
|
1426
1474
|
return;
|
|
1427
1475
|
|
|
1428
1476
|
case "page": {
|
|
1429
|
-
if (!rest) {
|
|
1477
|
+
if (!rest) {
|
|
1478
|
+
// No args: re-render current page if we're already viewing one.
|
|
1479
|
+
if (ctx.type === "page" || ctx.type === "sections") {
|
|
1480
|
+
await renderCurrentContext();
|
|
1481
|
+
return;
|
|
1482
|
+
}
|
|
1483
|
+
console.log(dim(" Usage: page <id|title>"));
|
|
1484
|
+
return;
|
|
1485
|
+
}
|
|
1430
1486
|
await doPage(rest);
|
|
1431
1487
|
return;
|
|
1432
1488
|
}
|
|
1433
1489
|
|
|
1490
|
+
case "v":
|
|
1491
|
+
case "view": {
|
|
1492
|
+
// Re-render the current view — useful after exiting the pager (q) and
|
|
1493
|
+
// wanting to see the results/page again without losing breadcrumb state
|
|
1494
|
+
// (which `b` would do by popping one level).
|
|
1495
|
+
if (ctx.type === "home") {
|
|
1496
|
+
console.log(dim(" Nothing to view yet. Try: help"));
|
|
1497
|
+
return;
|
|
1498
|
+
}
|
|
1499
|
+
await renderCurrentContext();
|
|
1500
|
+
return;
|
|
1501
|
+
}
|
|
1502
|
+
|
|
1434
1503
|
case "p":
|
|
1435
1504
|
case "prop": {
|
|
1436
1505
|
if (!rest) {
|
|
1437
1506
|
// Context-scoped: show properties for current page
|
|
1438
|
-
|
|
1507
|
+
// "sections" context is also a page view (pages with headings push sections, not page)
|
|
1508
|
+
if (ctx.type === "page" || ctx.type === "sections") {
|
|
1439
1509
|
const page = getPage(ctx.pageId, 0); // just get metadata
|
|
1440
1510
|
if (page) {
|
|
1441
1511
|
await doPropsForPage(ctx.pageId, ctx.title);
|
|
@@ -1483,13 +1553,14 @@ async function dispatch(input: string): Promise<void> {
|
|
|
1483
1553
|
|
|
1484
1554
|
case "cal":
|
|
1485
1555
|
case "callouts": {
|
|
1486
|
-
|
|
1556
|
+
// "sections" context is also a page view (pages with headings push sections, not page)
|
|
1557
|
+
if (!rest && (ctx.type === "page" || ctx.type === "sections")) {
|
|
1487
1558
|
// Page-scoped: query callouts directly by page_id (the
|
|
1488
1559
|
// FTS-with-empty-query path always returned [] before).
|
|
1489
|
-
const pageCallouts = getPageCallouts(
|
|
1560
|
+
const pageCallouts = getPageCallouts(ctx.pageId);
|
|
1490
1561
|
if (pageCallouts.length > 0) {
|
|
1491
|
-
await paged(renderCallouts(pageCallouts));
|
|
1492
1562
|
pushCtx({ type: "callouts", query: "", results: pageCallouts });
|
|
1563
|
+
await selectFromPager(renderCallouts(pageCallouts), pageCallouts.length);
|
|
1493
1564
|
} else {
|
|
1494
1565
|
console.log(dim(" This page has no callouts."));
|
|
1495
1566
|
}
|
|
@@ -1666,36 +1737,36 @@ async function renderCurrentContext(): Promise<void> {
|
|
|
1666
1737
|
console.log(dim(" ← back to home. Type 'help' for commands."));
|
|
1667
1738
|
return;
|
|
1668
1739
|
case "search":
|
|
1669
|
-
await
|
|
1740
|
+
await selectFromPager(renderSearchResults(ctx.response), ctx.results.length);
|
|
1670
1741
|
return;
|
|
1671
1742
|
case "page":
|
|
1672
1743
|
case "sections": {
|
|
1673
1744
|
const page = getPage(ctx.pageId);
|
|
1674
|
-
if (page) await
|
|
1745
|
+
if (page) await selectFromPager(renderPage(page), page.sections?.length ?? 0);
|
|
1675
1746
|
return;
|
|
1676
1747
|
}
|
|
1677
1748
|
case "commands": {
|
|
1678
1749
|
const children = browseCommands(ctx.path);
|
|
1679
|
-
await
|
|
1750
|
+
await selectFromPager(renderCommandTree(ctx.path, children), children.length);
|
|
1680
1751
|
return;
|
|
1681
1752
|
}
|
|
1682
1753
|
case "devices":
|
|
1683
|
-
await
|
|
1754
|
+
await selectFromPager(renderDeviceResults(ctx.results, "search", ctx.results.length), ctx.results.length);
|
|
1684
1755
|
return;
|
|
1685
1756
|
case "device":
|
|
1686
1757
|
await paged(renderDeviceCard(ctx.device));
|
|
1687
1758
|
return;
|
|
1688
1759
|
case "callouts":
|
|
1689
|
-
await
|
|
1760
|
+
await selectFromPager(renderCallouts(ctx.results), ctx.results.length);
|
|
1690
1761
|
return;
|
|
1691
1762
|
case "changelogs":
|
|
1692
|
-
await
|
|
1763
|
+
await selectFromPager(renderChangelogs(ctx.results), ctx.results.length);
|
|
1693
1764
|
return;
|
|
1694
1765
|
case "videos":
|
|
1695
|
-
await
|
|
1766
|
+
await selectFromPager(renderVideos(ctx.results), ctx.results.length);
|
|
1696
1767
|
return;
|
|
1697
1768
|
case "dude":
|
|
1698
|
-
await
|
|
1769
|
+
await selectFromPager(renderDudeResults(ctx.results), ctx.results.length);
|
|
1699
1770
|
return;
|
|
1700
1771
|
case "skills":
|
|
1701
1772
|
await doListSkills();
|
|
@@ -1706,7 +1777,7 @@ async function renderCurrentContext(): Promise<void> {
|
|
|
1706
1777
|
const p = ctx.results[i];
|
|
1707
1778
|
lines.push(` ${cyan(String(i + 1).padStart(2))}. ${bold(p.name)} ${dim(`@ ${p.page_title}`)}`);
|
|
1708
1779
|
}
|
|
1709
|
-
await
|
|
1780
|
+
await selectFromPager(lines.join("\n"), ctx.results.length);
|
|
1710
1781
|
return;
|
|
1711
1782
|
}
|
|
1712
1783
|
case "tests":
|
|
@@ -1719,8 +1790,10 @@ async function renderCurrentContext(): Promise<void> {
|
|
|
1719
1790
|
|
|
1720
1791
|
async function doSearch(query: string): Promise<void> {
|
|
1721
1792
|
const resp = searchAll(query);
|
|
1722
|
-
|
|
1793
|
+
// Push context BEFORE paging so digit-selection in the pager (and the
|
|
1794
|
+
// post-pager handleNumberSelect call) both see the right ctx.
|
|
1723
1795
|
pushCtx({ type: "search", response: resp, results: resp.pages });
|
|
1796
|
+
await selectFromPager(renderSearchResults(resp), resp.pages.length);
|
|
1724
1797
|
}
|
|
1725
1798
|
|
|
1726
1799
|
async function doPage(idOrTitle: string, sectionName?: string): Promise<void> {
|
|
@@ -1733,7 +1806,6 @@ async function doPage(idOrTitle: string, sectionName?: string): Promise<void> {
|
|
|
1733
1806
|
console.log(dim(` Page not found: ${idOrTitle}`));
|
|
1734
1807
|
return;
|
|
1735
1808
|
}
|
|
1736
|
-
await paged(renderPage(page));
|
|
1737
1809
|
|
|
1738
1810
|
// Determine linked command path (if any)
|
|
1739
1811
|
let commandPath: string | undefined;
|
|
@@ -1749,6 +1821,7 @@ async function doPage(idOrTitle: string, sectionName?: string): Promise<void> {
|
|
|
1749
1821
|
} else {
|
|
1750
1822
|
pushCtx({ type: "page", pageId: page.id, title: page.title, commandPath });
|
|
1751
1823
|
}
|
|
1824
|
+
await selectFromPager(renderPage(page), page.sections?.length ?? 0);
|
|
1752
1825
|
}
|
|
1753
1826
|
|
|
1754
1827
|
async function doPropsForPage(pageId: number, title: string): Promise<void> {
|
|
@@ -1759,8 +1832,8 @@ async function doPropsForPage(pageId: number, title: string): Promise<void> {
|
|
|
1759
1832
|
console.log(dim(` No properties found for "${title}".`));
|
|
1760
1833
|
return;
|
|
1761
1834
|
}
|
|
1762
|
-
await paged(` ${bold("Properties for")} ${bold(title)}\n\n${renderProperties(pageProps)}`);
|
|
1763
1835
|
pushCtx({ type: "properties", query: title, pageId, results: pageProps.map(p => ({ name: p.name, page_id: p.page_id ?? pageId, page_title: p.page_title })) });
|
|
1836
|
+
await selectFromPager(` ${bold("Properties for")} ${bold(title)}\n\n${renderProperties(pageProps)}`, pageProps.length);
|
|
1764
1837
|
}
|
|
1765
1838
|
|
|
1766
1839
|
async function doLookupProperty(name: string): Promise<void> {
|
|
@@ -1771,8 +1844,8 @@ async function doLookupProperty(name: string): Promise<void> {
|
|
|
1771
1844
|
console.log(` Try: ${cyan("props")} ${name}`);
|
|
1772
1845
|
return;
|
|
1773
1846
|
}
|
|
1774
|
-
await paged(renderProperties(results));
|
|
1775
1847
|
pushCtx({ type: "properties", query: name, results: results.map(p => ({ name: p.name, page_id: p.page_id, page_title: p.page_title })) });
|
|
1848
|
+
await selectFromPager(renderProperties(results), results.length);
|
|
1776
1849
|
}
|
|
1777
1850
|
|
|
1778
1851
|
async function doSearchProperties(query: string): Promise<void> {
|
|
@@ -1781,8 +1854,8 @@ async function doSearchProperties(query: string): Promise<void> {
|
|
|
1781
1854
|
console.log(dim(` No properties found for "${query}".`));
|
|
1782
1855
|
return;
|
|
1783
1856
|
}
|
|
1784
|
-
await paged(` ${bold(String(results.length))} properties matching ${cyan(`"${query}"`)}\n\n${renderProperties(results)}`);
|
|
1785
1857
|
pushCtx({ type: "properties", query, results: results.map(p => ({ name: p.name, page_id: p.page_id, page_title: p.page_title })) });
|
|
1858
|
+
await selectFromPager(` ${bold(String(results.length))} properties matching ${cyan(`"${query}"`)}\n\n${renderProperties(results)}`, results.length);
|
|
1786
1859
|
}
|
|
1787
1860
|
|
|
1788
1861
|
async function doGlossary(rest: string): Promise<void> {
|
|
@@ -1875,8 +1948,8 @@ async function doCommandTree(path: string): Promise<void> {
|
|
|
1875
1948
|
return;
|
|
1876
1949
|
}
|
|
1877
1950
|
const label = version ? `${cmdPath} @${version}` : cmdPath;
|
|
1878
|
-
await paged(renderCommandTree(label, children));
|
|
1879
1951
|
pushCtx({ type: "commands", path: cmdPath });
|
|
1952
|
+
await selectFromPager(renderCommandTree(label, children), children.length);
|
|
1880
1953
|
}
|
|
1881
1954
|
|
|
1882
1955
|
async function doDeviceLookup(query: string): Promise<void> {
|
|
@@ -1885,12 +1958,15 @@ async function doDeviceLookup(query: string): Promise<void> {
|
|
|
1885
1958
|
console.log(dim(` No devices found for "${query}".`));
|
|
1886
1959
|
return;
|
|
1887
1960
|
}
|
|
1888
|
-
await paged(renderDeviceResults(result.results, result.mode, result.total));
|
|
1889
1961
|
if (result.results.length === 1) {
|
|
1890
1962
|
pushCtx({ type: "device", device: result.results[0] });
|
|
1891
1963
|
} else {
|
|
1892
1964
|
pushCtx({ type: "devices", query, results: result.results });
|
|
1893
1965
|
}
|
|
1966
|
+
await selectFromPager(
|
|
1967
|
+
renderDeviceResults(result.results, result.mode, result.total),
|
|
1968
|
+
result.results.length > 1 ? result.results.length : 0,
|
|
1969
|
+
);
|
|
1894
1970
|
}
|
|
1895
1971
|
|
|
1896
1972
|
async function doTests(argsStr: string): Promise<void> {
|
|
@@ -1948,8 +2024,11 @@ async function doSearchCallouts(query: string): Promise<void> {
|
|
|
1948
2024
|
console.log(dim(` No callouts found.`));
|
|
1949
2025
|
return;
|
|
1950
2026
|
}
|
|
1951
|
-
await paged(` ${bold(String(results.length))} callouts${type ? ` (${type})` : ""}\n\n${renderCallouts(results)}`);
|
|
1952
2027
|
pushCtx({ type: "callouts", query, results });
|
|
2028
|
+
await selectFromPager(
|
|
2029
|
+
` ${bold(String(results.length))} callouts${type ? ` (${type})` : ""}\n\n${renderCallouts(results)}`,
|
|
2030
|
+
results.length,
|
|
2031
|
+
);
|
|
1953
2032
|
}
|
|
1954
2033
|
|
|
1955
2034
|
async function doSearchChangelogs(query: string): Promise<void> {
|
|
@@ -1997,8 +2076,8 @@ async function doSearchChangelogs(query: string): Promise<void> {
|
|
|
1997
2076
|
console.log(dim(" No changelog entries found."));
|
|
1998
2077
|
return;
|
|
1999
2078
|
}
|
|
2000
|
-
await paged(` ${bold("Changelogs")}${version ? ` for ${bold(version)}` : ""}${breakingOnly ? ` ${red("(breaking only)")}` : ""}\n\n${renderChangelogs(results)}`);
|
|
2001
2079
|
pushCtx({ type: "changelogs", results });
|
|
2080
|
+
await selectFromPager(` ${bold("Changelogs")}${version ? ` for ${bold(version)}` : ""}${breakingOnly ? ` ${red("(breaking only)")}` : ""}\n\n${renderChangelogs(results)}`, results.length);
|
|
2002
2081
|
}
|
|
2003
2082
|
|
|
2004
2083
|
async function doSearchVideos(query: string): Promise<void> {
|
|
@@ -2007,8 +2086,11 @@ async function doSearchVideos(query: string): Promise<void> {
|
|
|
2007
2086
|
console.log(dim(` No video results for "${query}".`));
|
|
2008
2087
|
return;
|
|
2009
2088
|
}
|
|
2010
|
-
await paged(` ${bold(String(results.length))} video results for ${cyan(`"${query}"`)}\n\n${renderVideos(results)}`);
|
|
2011
2089
|
pushCtx({ type: "videos", query, results });
|
|
2090
|
+
await selectFromPager(
|
|
2091
|
+
` ${bold(String(results.length))} video results for ${cyan(`"${query}"`)}\n\n${renderVideos(results)}`,
|
|
2092
|
+
results.length,
|
|
2093
|
+
);
|
|
2012
2094
|
}
|
|
2013
2095
|
|
|
2014
2096
|
async function doSearchDude(query: string): Promise<void> {
|
|
@@ -2017,8 +2099,11 @@ async function doSearchDude(query: string): Promise<void> {
|
|
|
2017
2099
|
console.log(dim(` No Dude wiki results for "${query}".`));
|
|
2018
2100
|
return;
|
|
2019
2101
|
}
|
|
2020
|
-
await paged(` ${bold(String(results.length))} Dude wiki results for ${cyan(`"${query}"`)}\n\n${renderDudeResults(results)}`);
|
|
2021
2102
|
pushCtx({ type: "dude", query, results });
|
|
2103
|
+
await selectFromPager(
|
|
2104
|
+
` ${bold(String(results.length))} Dude wiki results for ${cyan(`"${query}"`)}\n\n${renderDudeResults(results)}`,
|
|
2105
|
+
results.length,
|
|
2106
|
+
);
|
|
2022
2107
|
}
|
|
2023
2108
|
|
|
2024
2109
|
async function doListSkills(): Promise<void> {
|
|
@@ -2037,8 +2122,8 @@ async function doListSkills(): Promise<void> {
|
|
|
2037
2122
|
});
|
|
2038
2123
|
out.push("");
|
|
2039
2124
|
out.push(` ${dim("Type a number to view, or: skill <name>")}`);
|
|
2040
|
-
await paged(out.join("\n"));
|
|
2041
2125
|
pushCtx({ type: "skills" });
|
|
2126
|
+
await selectFromPager(out.join("\n"), skills.length);
|
|
2042
2127
|
}
|
|
2043
2128
|
|
|
2044
2129
|
async function doViewSkill(name: string): Promise<void> {
|
|
@@ -2156,6 +2241,14 @@ async function main() {
|
|
|
2156
2241
|
} catch (err) {
|
|
2157
2242
|
console.error(red(` Error: ${err instanceof Error ? err.message : String(err)}`));
|
|
2158
2243
|
}
|
|
2244
|
+
// Discard any keystrokes that leaked into readline's internal line buffer
|
|
2245
|
+
// while the pager was running in raw mode. readline's data handler stays
|
|
2246
|
+
// active during paging, so each pager keystroke (digits, q, space, b…)
|
|
2247
|
+
// accumulates in rl.line and reappears echoed after the next prompt via
|
|
2248
|
+
// _refreshLine(). Clear before re-prompting to keep the prompt clean.
|
|
2249
|
+
const rlBuf = rl as unknown as { line: string; cursor: number };
|
|
2250
|
+
rlBuf.line = "";
|
|
2251
|
+
rlBuf.cursor = 0;
|
|
2159
2252
|
rl.setPrompt(buildPrompt());
|
|
2160
2253
|
rl.prompt();
|
|
2161
2254
|
});
|
package/src/mcp.ts
CHANGED
|
@@ -1288,6 +1288,11 @@ what would otherwise require 125+ individual device lookups.
|
|
|
1288
1288
|
**Configuration matching:** Uses LIKE (substring) — "25 ip filter" matches "25 ip filter rules".
|
|
1289
1289
|
Note: some devices use slightly different names (e.g., "25 bridge filter" vs "25 bridge filter rules").
|
|
1290
1290
|
|
|
1291
|
+
**Default ordering:** Results are sorted by throughput DESC. When \`packet_size\` is NOT specified,
|
|
1292
|
+
512-byte rows are surfaced first (within the LIMIT) before other sizes — 512B is the conventional
|
|
1293
|
+
mid-size benchmark RouterOS admins compare on, so this keeps comparable values from being crowded
|
|
1294
|
+
out by 1518B "best case" rows. Pin \`packet_size\` to override.
|
|
1295
|
+
|
|
1291
1296
|
**Tip:** Call with no filters first to see available test_types, modes, configurations, and packet_sizes via the metadata field.
|
|
1292
1297
|
|
|
1293
1298
|
Results include product_name, product_code, architecture — use routeros_device_lookup for full specs (CPU, RAM, ports, etc.).
|
package/src/query.test.ts
CHANGED
|
@@ -1375,6 +1375,30 @@ describe("searchDeviceTests", () => {
|
|
|
1375
1375
|
expect(res.results).toHaveLength(1);
|
|
1376
1376
|
expect(res.total).toBe(4);
|
|
1377
1377
|
});
|
|
1378
|
+
|
|
1379
|
+
test("prioritizes 512-byte rows when packet_size is unspecified", () => {
|
|
1380
|
+
// Fixtures: two 512B ethernet rows + one 1518B ethernet row (higher mbps) + one 1400B ipsec row.
|
|
1381
|
+
// Without 512B priority, the 1518B/19577 mbps row would lead. With priority, 512B rows lead.
|
|
1382
|
+
const res = searchDeviceTests({ test_type: "ethernet" });
|
|
1383
|
+
expect(res.results.length).toBeGreaterThanOrEqual(3);
|
|
1384
|
+
// All 512B rows must precede any non-512B row
|
|
1385
|
+
let sawNon512 = false;
|
|
1386
|
+
for (const row of res.results) {
|
|
1387
|
+
if (row.packet_size !== 512) sawNon512 = true;
|
|
1388
|
+
else if (sawNon512) {
|
|
1389
|
+
throw new Error(`512B row appeared after a non-512B row at packet_size=${row.packet_size}`);
|
|
1390
|
+
}
|
|
1391
|
+
}
|
|
1392
|
+
// Within the 512B bucket, mbps descending
|
|
1393
|
+
expect(res.results[0].packet_size).toBe(512);
|
|
1394
|
+
expect(res.results[0].throughput_mbps).toBe(9551.9);
|
|
1395
|
+
});
|
|
1396
|
+
|
|
1397
|
+
test("explicit packet_size filter overrides 512B priority", () => {
|
|
1398
|
+
const res = searchDeviceTests({ packet_size: 1518 });
|
|
1399
|
+
expect(res.results).toHaveLength(1);
|
|
1400
|
+
expect(res.results[0].packet_size).toBe(1518);
|
|
1401
|
+
});
|
|
1378
1402
|
});
|
|
1379
1403
|
|
|
1380
1404
|
describe("dataset CSV exports", () => {
|
package/src/query.ts
CHANGED
|
@@ -1701,13 +1701,23 @@ export function searchDeviceTests(
|
|
|
1701
1701
|
JOIN devices d ON d.id = t.device_id ${whereClause}`;
|
|
1702
1702
|
const total = Number((db.prepare(totalSql).get(...params) as { c: number }).c);
|
|
1703
1703
|
|
|
1704
|
+
// When the caller did NOT pin a packet size, prioritise 512-byte results.
|
|
1705
|
+
// 512B is the conventional "mid-size" benchmark RouterOS admins compare on
|
|
1706
|
+
// — it best represents real-world traffic mixes. Without this, the default
|
|
1707
|
+
// throughput-DESC sort surfaces 1518B "best case" rows first, crowding out
|
|
1708
|
+
// the comparison values within the LIMIT. Within each priority bucket we
|
|
1709
|
+
// still sort by throughput DESC so the fastest devices float to the top.
|
|
1710
|
+
const orderBy = filters.packet_size === undefined
|
|
1711
|
+
? `(CASE WHEN t.packet_size = 512 THEN 0 ELSE 1 END), ${orderCol} DESC NULLS LAST`
|
|
1712
|
+
: `${orderCol} DESC NULLS LAST`;
|
|
1713
|
+
|
|
1704
1714
|
const sql = `SELECT d.product_name, d.product_code, d.architecture,
|
|
1705
1715
|
t.test_type, t.mode, t.configuration, t.packet_size,
|
|
1706
1716
|
t.throughput_kpps, t.throughput_mbps
|
|
1707
1717
|
FROM device_test_results t
|
|
1708
1718
|
JOIN devices d ON d.id = t.device_id
|
|
1709
1719
|
${whereClause}
|
|
1710
|
-
ORDER BY ${
|
|
1720
|
+
ORDER BY ${orderBy}
|
|
1711
1721
|
LIMIT ?`;
|
|
1712
1722
|
|
|
1713
1723
|
const results = db.prepare(sql).all(...params, limit) as DeviceTestRow[];
|
package/src/release.test.ts
CHANGED
|
@@ -454,6 +454,51 @@ describe("CLI flags", () => {
|
|
|
454
454
|
});
|
|
455
455
|
});
|
|
456
456
|
|
|
457
|
+
// ---------------------------------------------------------------------------
|
|
458
|
+
// Browse TUI structural checks — catch pager/navigation regressions at build time
|
|
459
|
+
// ---------------------------------------------------------------------------
|
|
460
|
+
|
|
461
|
+
describe("browse TUI structure", () => {
|
|
462
|
+
const src = readText("src/browse.ts");
|
|
463
|
+
const changelogPrefix = 'await selectFromPager(` ' + "$" + '{bold("Changelogs")}';
|
|
464
|
+
|
|
465
|
+
test("defines helper to route pager digit selections back into current context", () => {
|
|
466
|
+
expect(src).toContain("async function selectFromPager");
|
|
467
|
+
expect(src).toContain("handleNumberSelect(result.selected - 1)");
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
test("re-rendered result views preserve pager selection", () => {
|
|
471
|
+
expect(src).toContain('await selectFromPager(renderSearchResults(ctx.response), ctx.results.length)');
|
|
472
|
+
expect(src).toContain('await selectFromPager(renderCommandTree(ctx.path, children), children.length)');
|
|
473
|
+
expect(src).toContain('await selectFromPager(renderCallouts(ctx.results), ctx.results.length)');
|
|
474
|
+
expect(src).toContain('await selectFromPager(renderChangelogs(ctx.results), ctx.results.length)');
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
test("initial changelog and page-callout views push context before pager selection", () => {
|
|
478
|
+
expect(src).toContain('pushCtx({ type: "changelogs", results });');
|
|
479
|
+
expect(src).toContain(changelogPrefix);
|
|
480
|
+
expect(src).toContain('pushCtx({ type: "callouts", query: "", results: pageCallouts });');
|
|
481
|
+
expect(src).toContain('await selectFromPager(renderCallouts(pageCallouts), pageCallouts.length);');
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
test("page-scoped [p] and [cal] work on both page and sections contexts", () => {
|
|
485
|
+
// Pages with headings push type:"sections" not type:"page", so both handlers
|
|
486
|
+
// must check for sections context to make the hints visible on the page work.
|
|
487
|
+
expect(src).toContain('ctx.type === "page" || ctx.type === "sections"');
|
|
488
|
+
// Both handlers must appear at least twice (one per fix)
|
|
489
|
+
const matches = src.split('ctx.type === "page" || ctx.type === "sections"').length - 1;
|
|
490
|
+
expect(matches).toBeGreaterThanOrEqual(2);
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
test("pager keystrokes cleared from readline buffer before re-prompt", () => {
|
|
494
|
+
// readline's data handler stays active while pager runs in raw mode, so
|
|
495
|
+
// pager keystrokes accumulate in rl.line and appear after the next prompt.
|
|
496
|
+
// The REPL line handler must clear rl.line/cursor before calling rl.prompt().
|
|
497
|
+
expect(src).toContain('rlBuf.line = ""');
|
|
498
|
+
expect(src).toContain("rlBuf.cursor = 0");
|
|
499
|
+
});
|
|
500
|
+
});
|
|
501
|
+
|
|
457
502
|
// ---------------------------------------------------------------------------
|
|
458
503
|
// HTTP transport structural checks — catch per-session breakage at build time
|
|
459
504
|
// ---------------------------------------------------------------------------
|