@tikoci/rosetta 0.8.3 → 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 +146 -44
- package/src/mcp.ts +5 -0
- package/src/query.test.ts +24 -0
- package/src/query.ts +43 -15
- package/src/release.test.ts +45 -0
package/package.json
CHANGED
package/src/browse.ts
CHANGED
|
@@ -31,6 +31,7 @@ import {
|
|
|
31
31
|
browseCommands,
|
|
32
32
|
browseCommandsAtVersion,
|
|
33
33
|
checkCommandVersions,
|
|
34
|
+
compareVersions,
|
|
34
35
|
diffCommandVersions,
|
|
35
36
|
fetchCurrentVersions,
|
|
36
37
|
getDudePage,
|
|
@@ -199,7 +200,6 @@ function formatTime(seconds: number): string {
|
|
|
199
200
|
|
|
200
201
|
// ── Pager ──
|
|
201
202
|
|
|
202
|
-
/** Output lines with paging. Returns true if user quit early. */
|
|
203
203
|
/**
|
|
204
204
|
* Interactive pager.
|
|
205
205
|
*
|
|
@@ -209,17 +209,26 @@ function formatTime(seconds: number): string {
|
|
|
209
209
|
* b / < previous page
|
|
210
210
|
* g jump to top
|
|
211
211
|
* G jump to bottom
|
|
212
|
-
* 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.
|
|
213
216
|
*
|
|
214
|
-
* Shows a status line with page X/Y and line N/M. Returns
|
|
215
|
-
* 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
|
|
216
221
|
*/
|
|
217
|
-
|
|
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;
|
|
218
226
|
const lines = output.split("\n");
|
|
219
227
|
const pageSize = Math.max(5, termHeight() - 2);
|
|
220
228
|
if (lines.length <= pageSize || !process.stdout.isTTY) {
|
|
221
229
|
process.stdout.write(`${output}\n`);
|
|
222
|
-
|
|
230
|
+
// Non-TTY (or single screen) — no interactive selection possible.
|
|
231
|
+
return { quit: false };
|
|
223
232
|
}
|
|
224
233
|
const totalPages = Math.ceil(lines.length / pageSize);
|
|
225
234
|
let offset = 0;
|
|
@@ -227,15 +236,25 @@ async function paged(output: string): Promise<boolean> {
|
|
|
227
236
|
const chunk = lines.slice(offset, offset + pageSize);
|
|
228
237
|
process.stdout.write(`${chunk.join("\n")}\n`);
|
|
229
238
|
const newOffset = offset + pageSize;
|
|
230
|
-
if (newOffset >= lines.length) return false;
|
|
239
|
+
if (newOffset >= lines.length) return { quit: false };
|
|
231
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
|
+
: "";
|
|
232
251
|
const prompt = dim(
|
|
233
|
-
`── 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]`,
|
|
234
253
|
);
|
|
235
254
|
process.stdout.write(prompt);
|
|
236
255
|
const key = await waitForKey();
|
|
237
256
|
process.stdout.write(`\r${" ".repeat(Math.min(termWidth(), stripAnsi(prompt).length))}\r`);
|
|
238
|
-
if (key === "q" || key === "Q") return true;
|
|
257
|
+
if (key === "q" || key === "Q") return { quit: true };
|
|
239
258
|
if (key === "g") { offset = 0; continue; }
|
|
240
259
|
if (key === "G") { offset = Math.max(0, lines.length - pageSize); continue; }
|
|
241
260
|
if (key === "b" || key === "<" || key === "\x1b[D") {
|
|
@@ -246,18 +265,26 @@ async function paged(output: string): Promise<boolean> {
|
|
|
246
265
|
offset += 1;
|
|
247
266
|
continue;
|
|
248
267
|
}
|
|
249
|
-
// Digit 1-9 jumps to that page (1-indexed). If the page is past EOF,
|
|
250
|
-
// clamp to the last page.
|
|
251
268
|
if (/^[1-9]$/.test(key)) {
|
|
252
|
-
const
|
|
253
|
-
|
|
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);
|
|
254
276
|
if (offset < 0) offset = 0;
|
|
255
277
|
continue;
|
|
256
278
|
}
|
|
257
279
|
// SPACE / f / anything else → next page
|
|
258
280
|
offset = newOffset;
|
|
259
281
|
}
|
|
260
|
-
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);
|
|
261
288
|
}
|
|
262
289
|
|
|
263
290
|
function waitForKey(): Promise<string> {
|
|
@@ -1052,7 +1079,8 @@ function renderHelp(): string {
|
|
|
1052
1079
|
|
|
1053
1080
|
cmd("<query>", "", "Search pages (default action)", "routeros_search");
|
|
1054
1081
|
cmd("search <query>", "s", "Explicit page search", "routeros_search");
|
|
1055
|
-
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`)");
|
|
1056
1084
|
cmd("prop <name>", "p", "Look up property (scoped to current page)", "routeros_lookup_property");
|
|
1057
1085
|
cmd("props <query>", "sp", "Search properties by FTS");
|
|
1058
1086
|
cmd("glossary [term]", "g", "Look up RouterOS jargon / list glossary");
|
|
@@ -1080,8 +1108,8 @@ function renderHelp(): string {
|
|
|
1080
1108
|
out.push(` ${cyan(pad(".resources", 38))} ${dim("Registered MCP resources (rosetta:// URIs)")}`);
|
|
1081
1109
|
out.push(` ${cyan(pad(".routeros_search <q> [limit=N]", 38))} ${dim("Raw JSON output, same query path as MCP")}`);
|
|
1082
1110
|
out.push("");
|
|
1083
|
-
out.push(` ${dim("Navigation: type a number to select from results; in pager, 1–
|
|
1084
|
-
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.")}`);
|
|
1085
1113
|
out.push(` ${dim("URLs are clickable in supported terminals (iTerm2, etc.).")}`);
|
|
1086
1114
|
out.push(` ${dim("cmd supports @version suffix: cmd /ip/address @7.15")}`);
|
|
1087
1115
|
out.push("");
|
|
@@ -1357,6 +1385,27 @@ async function dispatchDotCommand(input: string): Promise<void> {
|
|
|
1357
1385
|
}
|
|
1358
1386
|
try {
|
|
1359
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
|
+
}
|
|
1360
1409
|
const result = await tool.run(args);
|
|
1361
1410
|
const json = JSON.stringify(result, null, 2);
|
|
1362
1411
|
const banner = dim(`── .${name} args=${JSON.stringify(args)}`);
|
|
@@ -1425,16 +1474,38 @@ async function dispatch(input: string): Promise<void> {
|
|
|
1425
1474
|
return;
|
|
1426
1475
|
|
|
1427
1476
|
case "page": {
|
|
1428
|
-
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
|
+
}
|
|
1429
1486
|
await doPage(rest);
|
|
1430
1487
|
return;
|
|
1431
1488
|
}
|
|
1432
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
|
+
|
|
1433
1503
|
case "p":
|
|
1434
1504
|
case "prop": {
|
|
1435
1505
|
if (!rest) {
|
|
1436
1506
|
// Context-scoped: show properties for current page
|
|
1437
|
-
|
|
1507
|
+
// "sections" context is also a page view (pages with headings push sections, not page)
|
|
1508
|
+
if (ctx.type === "page" || ctx.type === "sections") {
|
|
1438
1509
|
const page = getPage(ctx.pageId, 0); // just get metadata
|
|
1439
1510
|
if (page) {
|
|
1440
1511
|
await doPropsForPage(ctx.pageId, ctx.title);
|
|
@@ -1482,13 +1553,14 @@ async function dispatch(input: string): Promise<void> {
|
|
|
1482
1553
|
|
|
1483
1554
|
case "cal":
|
|
1484
1555
|
case "callouts": {
|
|
1485
|
-
|
|
1556
|
+
// "sections" context is also a page view (pages with headings push sections, not page)
|
|
1557
|
+
if (!rest && (ctx.type === "page" || ctx.type === "sections")) {
|
|
1486
1558
|
// Page-scoped: query callouts directly by page_id (the
|
|
1487
1559
|
// FTS-with-empty-query path always returned [] before).
|
|
1488
|
-
const pageCallouts = getPageCallouts(
|
|
1560
|
+
const pageCallouts = getPageCallouts(ctx.pageId);
|
|
1489
1561
|
if (pageCallouts.length > 0) {
|
|
1490
|
-
await paged(renderCallouts(pageCallouts));
|
|
1491
1562
|
pushCtx({ type: "callouts", query: "", results: pageCallouts });
|
|
1563
|
+
await selectFromPager(renderCallouts(pageCallouts), pageCallouts.length);
|
|
1492
1564
|
} else {
|
|
1493
1565
|
console.log(dim(" This page has no callouts."));
|
|
1494
1566
|
}
|
|
@@ -1665,36 +1737,36 @@ async function renderCurrentContext(): Promise<void> {
|
|
|
1665
1737
|
console.log(dim(" ← back to home. Type 'help' for commands."));
|
|
1666
1738
|
return;
|
|
1667
1739
|
case "search":
|
|
1668
|
-
await
|
|
1740
|
+
await selectFromPager(renderSearchResults(ctx.response), ctx.results.length);
|
|
1669
1741
|
return;
|
|
1670
1742
|
case "page":
|
|
1671
1743
|
case "sections": {
|
|
1672
1744
|
const page = getPage(ctx.pageId);
|
|
1673
|
-
if (page) await
|
|
1745
|
+
if (page) await selectFromPager(renderPage(page), page.sections?.length ?? 0);
|
|
1674
1746
|
return;
|
|
1675
1747
|
}
|
|
1676
1748
|
case "commands": {
|
|
1677
1749
|
const children = browseCommands(ctx.path);
|
|
1678
|
-
await
|
|
1750
|
+
await selectFromPager(renderCommandTree(ctx.path, children), children.length);
|
|
1679
1751
|
return;
|
|
1680
1752
|
}
|
|
1681
1753
|
case "devices":
|
|
1682
|
-
await
|
|
1754
|
+
await selectFromPager(renderDeviceResults(ctx.results, "search", ctx.results.length), ctx.results.length);
|
|
1683
1755
|
return;
|
|
1684
1756
|
case "device":
|
|
1685
1757
|
await paged(renderDeviceCard(ctx.device));
|
|
1686
1758
|
return;
|
|
1687
1759
|
case "callouts":
|
|
1688
|
-
await
|
|
1760
|
+
await selectFromPager(renderCallouts(ctx.results), ctx.results.length);
|
|
1689
1761
|
return;
|
|
1690
1762
|
case "changelogs":
|
|
1691
|
-
await
|
|
1763
|
+
await selectFromPager(renderChangelogs(ctx.results), ctx.results.length);
|
|
1692
1764
|
return;
|
|
1693
1765
|
case "videos":
|
|
1694
|
-
await
|
|
1766
|
+
await selectFromPager(renderVideos(ctx.results), ctx.results.length);
|
|
1695
1767
|
return;
|
|
1696
1768
|
case "dude":
|
|
1697
|
-
await
|
|
1769
|
+
await selectFromPager(renderDudeResults(ctx.results), ctx.results.length);
|
|
1698
1770
|
return;
|
|
1699
1771
|
case "skills":
|
|
1700
1772
|
await doListSkills();
|
|
@@ -1705,7 +1777,7 @@ async function renderCurrentContext(): Promise<void> {
|
|
|
1705
1777
|
const p = ctx.results[i];
|
|
1706
1778
|
lines.push(` ${cyan(String(i + 1).padStart(2))}. ${bold(p.name)} ${dim(`@ ${p.page_title}`)}`);
|
|
1707
1779
|
}
|
|
1708
|
-
await
|
|
1780
|
+
await selectFromPager(lines.join("\n"), ctx.results.length);
|
|
1709
1781
|
return;
|
|
1710
1782
|
}
|
|
1711
1783
|
case "tests":
|
|
@@ -1718,8 +1790,10 @@ async function renderCurrentContext(): Promise<void> {
|
|
|
1718
1790
|
|
|
1719
1791
|
async function doSearch(query: string): Promise<void> {
|
|
1720
1792
|
const resp = searchAll(query);
|
|
1721
|
-
|
|
1793
|
+
// Push context BEFORE paging so digit-selection in the pager (and the
|
|
1794
|
+
// post-pager handleNumberSelect call) both see the right ctx.
|
|
1722
1795
|
pushCtx({ type: "search", response: resp, results: resp.pages });
|
|
1796
|
+
await selectFromPager(renderSearchResults(resp), resp.pages.length);
|
|
1723
1797
|
}
|
|
1724
1798
|
|
|
1725
1799
|
async function doPage(idOrTitle: string, sectionName?: string): Promise<void> {
|
|
@@ -1732,7 +1806,6 @@ async function doPage(idOrTitle: string, sectionName?: string): Promise<void> {
|
|
|
1732
1806
|
console.log(dim(` Page not found: ${idOrTitle}`));
|
|
1733
1807
|
return;
|
|
1734
1808
|
}
|
|
1735
|
-
await paged(renderPage(page));
|
|
1736
1809
|
|
|
1737
1810
|
// Determine linked command path (if any)
|
|
1738
1811
|
let commandPath: string | undefined;
|
|
@@ -1748,6 +1821,7 @@ async function doPage(idOrTitle: string, sectionName?: string): Promise<void> {
|
|
|
1748
1821
|
} else {
|
|
1749
1822
|
pushCtx({ type: "page", pageId: page.id, title: page.title, commandPath });
|
|
1750
1823
|
}
|
|
1824
|
+
await selectFromPager(renderPage(page), page.sections?.length ?? 0);
|
|
1751
1825
|
}
|
|
1752
1826
|
|
|
1753
1827
|
async function doPropsForPage(pageId: number, title: string): Promise<void> {
|
|
@@ -1758,8 +1832,8 @@ async function doPropsForPage(pageId: number, title: string): Promise<void> {
|
|
|
1758
1832
|
console.log(dim(` No properties found for "${title}".`));
|
|
1759
1833
|
return;
|
|
1760
1834
|
}
|
|
1761
|
-
await paged(` ${bold("Properties for")} ${bold(title)}\n\n${renderProperties(pageProps)}`);
|
|
1762
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);
|
|
1763
1837
|
}
|
|
1764
1838
|
|
|
1765
1839
|
async function doLookupProperty(name: string): Promise<void> {
|
|
@@ -1770,8 +1844,8 @@ async function doLookupProperty(name: string): Promise<void> {
|
|
|
1770
1844
|
console.log(` Try: ${cyan("props")} ${name}`);
|
|
1771
1845
|
return;
|
|
1772
1846
|
}
|
|
1773
|
-
await paged(renderProperties(results));
|
|
1774
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);
|
|
1775
1849
|
}
|
|
1776
1850
|
|
|
1777
1851
|
async function doSearchProperties(query: string): Promise<void> {
|
|
@@ -1780,8 +1854,8 @@ async function doSearchProperties(query: string): Promise<void> {
|
|
|
1780
1854
|
console.log(dim(` No properties found for "${query}".`));
|
|
1781
1855
|
return;
|
|
1782
1856
|
}
|
|
1783
|
-
await paged(` ${bold(String(results.length))} properties matching ${cyan(`"${query}"`)}\n\n${renderProperties(results)}`);
|
|
1784
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);
|
|
1785
1859
|
}
|
|
1786
1860
|
|
|
1787
1861
|
async function doGlossary(rest: string): Promise<void> {
|
|
@@ -1874,8 +1948,8 @@ async function doCommandTree(path: string): Promise<void> {
|
|
|
1874
1948
|
return;
|
|
1875
1949
|
}
|
|
1876
1950
|
const label = version ? `${cmdPath} @${version}` : cmdPath;
|
|
1877
|
-
await paged(renderCommandTree(label, children));
|
|
1878
1951
|
pushCtx({ type: "commands", path: cmdPath });
|
|
1952
|
+
await selectFromPager(renderCommandTree(label, children), children.length);
|
|
1879
1953
|
}
|
|
1880
1954
|
|
|
1881
1955
|
async function doDeviceLookup(query: string): Promise<void> {
|
|
@@ -1884,12 +1958,15 @@ async function doDeviceLookup(query: string): Promise<void> {
|
|
|
1884
1958
|
console.log(dim(` No devices found for "${query}".`));
|
|
1885
1959
|
return;
|
|
1886
1960
|
}
|
|
1887
|
-
await paged(renderDeviceResults(result.results, result.mode, result.total));
|
|
1888
1961
|
if (result.results.length === 1) {
|
|
1889
1962
|
pushCtx({ type: "device", device: result.results[0] });
|
|
1890
1963
|
} else {
|
|
1891
1964
|
pushCtx({ type: "devices", query, results: result.results });
|
|
1892
1965
|
}
|
|
1966
|
+
await selectFromPager(
|
|
1967
|
+
renderDeviceResults(result.results, result.mode, result.total),
|
|
1968
|
+
result.results.length > 1 ? result.results.length : 0,
|
|
1969
|
+
);
|
|
1893
1970
|
}
|
|
1894
1971
|
|
|
1895
1972
|
async function doTests(argsStr: string): Promise<void> {
|
|
@@ -1947,8 +2024,11 @@ async function doSearchCallouts(query: string): Promise<void> {
|
|
|
1947
2024
|
console.log(dim(` No callouts found.`));
|
|
1948
2025
|
return;
|
|
1949
2026
|
}
|
|
1950
|
-
await paged(` ${bold(String(results.length))} callouts${type ? ` (${type})` : ""}\n\n${renderCallouts(results)}`);
|
|
1951
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
|
+
);
|
|
1952
2032
|
}
|
|
1953
2033
|
|
|
1954
2034
|
async function doSearchChangelogs(query: string): Promise<void> {
|
|
@@ -1966,7 +2046,13 @@ async function doSearchChangelogs(query: string): Promise<void> {
|
|
|
1966
2046
|
if (part.toLowerCase() === "breaking") { breakingOnly = true; continue; }
|
|
1967
2047
|
// Version range: "7.20..7.22"
|
|
1968
2048
|
const rangeMatch = part.match(/^(\d+\.\d+[.\w]*)\.\.(\d+\.\d+[.\w]*)$/);
|
|
1969
|
-
if (rangeMatch) {
|
|
2049
|
+
if (rangeMatch) {
|
|
2050
|
+
const [a, b] = [rangeMatch[1], rangeMatch[2]];
|
|
2051
|
+
// Normalise: always put the lower version first
|
|
2052
|
+
if (compareVersions(a, b) <= 0) { fromVersion = a; toVersion = b; }
|
|
2053
|
+
else { fromVersion = b; toVersion = a; }
|
|
2054
|
+
continue;
|
|
2055
|
+
}
|
|
1970
2056
|
// Single version: "7.22"
|
|
1971
2057
|
if (/^\d+\.\d+/.test(part)) { version = part; continue; }
|
|
1972
2058
|
searchTerms.push(part);
|
|
@@ -1979,6 +2065,8 @@ async function doSearchChangelogs(query: string): Promise<void> {
|
|
|
1979
2065
|
version,
|
|
1980
2066
|
fromVersion,
|
|
1981
2067
|
toVersion,
|
|
2068
|
+
inclusiveFrom: fromVersion !== undefined, // X..Y range is [X, Y] inclusive on both ends
|
|
2069
|
+
sortAscending: fromVersion !== undefined, // range queries read chronologically (oldest first)
|
|
1982
2070
|
category,
|
|
1983
2071
|
breakingOnly,
|
|
1984
2072
|
limit: 50,
|
|
@@ -1988,8 +2076,8 @@ async function doSearchChangelogs(query: string): Promise<void> {
|
|
|
1988
2076
|
console.log(dim(" No changelog entries found."));
|
|
1989
2077
|
return;
|
|
1990
2078
|
}
|
|
1991
|
-
await paged(` ${bold("Changelogs")}${version ? ` for ${bold(version)}` : ""}${breakingOnly ? ` ${red("(breaking only)")}` : ""}\n\n${renderChangelogs(results)}`);
|
|
1992
2079
|
pushCtx({ type: "changelogs", results });
|
|
2080
|
+
await selectFromPager(` ${bold("Changelogs")}${version ? ` for ${bold(version)}` : ""}${breakingOnly ? ` ${red("(breaking only)")}` : ""}\n\n${renderChangelogs(results)}`, results.length);
|
|
1993
2081
|
}
|
|
1994
2082
|
|
|
1995
2083
|
async function doSearchVideos(query: string): Promise<void> {
|
|
@@ -1998,8 +2086,11 @@ async function doSearchVideos(query: string): Promise<void> {
|
|
|
1998
2086
|
console.log(dim(` No video results for "${query}".`));
|
|
1999
2087
|
return;
|
|
2000
2088
|
}
|
|
2001
|
-
await paged(` ${bold(String(results.length))} video results for ${cyan(`"${query}"`)}\n\n${renderVideos(results)}`);
|
|
2002
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
|
+
);
|
|
2003
2094
|
}
|
|
2004
2095
|
|
|
2005
2096
|
async function doSearchDude(query: string): Promise<void> {
|
|
@@ -2008,8 +2099,11 @@ async function doSearchDude(query: string): Promise<void> {
|
|
|
2008
2099
|
console.log(dim(` No Dude wiki results for "${query}".`));
|
|
2009
2100
|
return;
|
|
2010
2101
|
}
|
|
2011
|
-
await paged(` ${bold(String(results.length))} Dude wiki results for ${cyan(`"${query}"`)}\n\n${renderDudeResults(results)}`);
|
|
2012
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
|
+
);
|
|
2013
2107
|
}
|
|
2014
2108
|
|
|
2015
2109
|
async function doListSkills(): Promise<void> {
|
|
@@ -2028,8 +2122,8 @@ async function doListSkills(): Promise<void> {
|
|
|
2028
2122
|
});
|
|
2029
2123
|
out.push("");
|
|
2030
2124
|
out.push(` ${dim("Type a number to view, or: skill <name>")}`);
|
|
2031
|
-
await paged(out.join("\n"));
|
|
2032
2125
|
pushCtx({ type: "skills" });
|
|
2126
|
+
await selectFromPager(out.join("\n"), skills.length);
|
|
2033
2127
|
}
|
|
2034
2128
|
|
|
2035
2129
|
async function doViewSkill(name: string): Promise<void> {
|
|
@@ -2147,6 +2241,14 @@ async function main() {
|
|
|
2147
2241
|
} catch (err) {
|
|
2148
2242
|
console.error(red(` Error: ${err instanceof Error ? err.message : String(err)}`));
|
|
2149
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;
|
|
2150
2252
|
rl.setPrompt(buildPrompt());
|
|
2151
2253
|
rl.prompt();
|
|
2152
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[];
|
|
@@ -1852,6 +1862,7 @@ export type ChangelogResult = {
|
|
|
1852
1862
|
is_breaking: number;
|
|
1853
1863
|
description: string;
|
|
1854
1864
|
excerpt: string;
|
|
1865
|
+
sort_order: number;
|
|
1855
1866
|
};
|
|
1856
1867
|
|
|
1857
1868
|
/** Get all versions that have changelog data, sorted numerically. */
|
|
@@ -1862,14 +1873,22 @@ function getChangelogVersions(): string[] {
|
|
|
1862
1873
|
return rows.map((r) => r.version).sort(compareVersions);
|
|
1863
1874
|
}
|
|
1864
1875
|
|
|
1865
|
-
/**
|
|
1876
|
+
/**
|
|
1877
|
+
* Filter versions to those within a range.
|
|
1878
|
+
* Default: (fromVersion, toVersion] — fromVersion exclusive, toVersion inclusive.
|
|
1879
|
+
* Pass inclusiveFrom=true for [fromVersion, toVersion] (both ends inclusive).
|
|
1880
|
+
*/
|
|
1866
1881
|
function filterVersionRange(
|
|
1867
1882
|
versions: string[],
|
|
1868
1883
|
fromVersion?: string,
|
|
1869
1884
|
toVersion?: string,
|
|
1885
|
+
inclusiveFrom = false,
|
|
1870
1886
|
): string[] {
|
|
1871
1887
|
return versions.filter((v) => {
|
|
1872
|
-
if (fromVersion
|
|
1888
|
+
if (fromVersion) {
|
|
1889
|
+
const cmp = compareVersions(v, fromVersion);
|
|
1890
|
+
if (inclusiveFrom ? cmp < 0 : cmp <= 0) return false;
|
|
1891
|
+
}
|
|
1873
1892
|
if (toVersion && compareVersions(v, toVersion) > 0) return false;
|
|
1874
1893
|
return true;
|
|
1875
1894
|
});
|
|
@@ -1882,6 +1901,8 @@ export function searchChangelogs(
|
|
|
1882
1901
|
version?: string;
|
|
1883
1902
|
fromVersion?: string;
|
|
1884
1903
|
toVersion?: string;
|
|
1904
|
+
inclusiveFrom?: boolean;
|
|
1905
|
+
sortAscending?: boolean;
|
|
1885
1906
|
category?: string;
|
|
1886
1907
|
breakingOnly?: boolean;
|
|
1887
1908
|
limit?: number;
|
|
@@ -1896,23 +1917,30 @@ export function searchChangelogs(
|
|
|
1896
1917
|
versionList = [options.version];
|
|
1897
1918
|
} else if (options.fromVersion || options.toVersion) {
|
|
1898
1919
|
const all = getChangelogVersions();
|
|
1899
|
-
versionList = filterVersionRange(all, options.fromVersion, options.toVersion);
|
|
1920
|
+
versionList = filterVersionRange(all, options.fromVersion, options.toVersion, options.inclusiveFrom);
|
|
1900
1921
|
if (versionList.length === 0) return [];
|
|
1901
1922
|
}
|
|
1902
1923
|
|
|
1924
|
+
let results: ChangelogResult[];
|
|
1925
|
+
|
|
1903
1926
|
// No FTS query — browse by filters only
|
|
1904
1927
|
if (terms.length === 0) {
|
|
1905
|
-
|
|
1906
|
-
}
|
|
1928
|
+
results = browseChangelogs(versionList, options.category, options.breakingOnly, limit);
|
|
1929
|
+
} else {
|
|
1930
|
+
// FTS search with AND, then fallback to OR
|
|
1931
|
+
let ftsQuery = buildFtsQuery(terms, "AND");
|
|
1932
|
+
if (!ftsQuery) return [];
|
|
1933
|
+
results = runChangelogFtsQuery(ftsQuery, versionList, options.category, options.breakingOnly, limit);
|
|
1907
1934
|
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1935
|
+
if (results.length === 0 && terms.length > 1) {
|
|
1936
|
+
ftsQuery = buildFtsQuery(terms, "OR");
|
|
1937
|
+
results = runChangelogFtsQuery(ftsQuery, versionList, options.category, options.breakingOnly, limit);
|
|
1938
|
+
}
|
|
1939
|
+
}
|
|
1912
1940
|
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
results
|
|
1941
|
+
// Re-sort by version when caller wants chronological order (oldest first)
|
|
1942
|
+
if (options.sortAscending) {
|
|
1943
|
+
results.sort((a, b) => compareVersions(a.version, b.version) || a.sort_order - b.sort_order);
|
|
1916
1944
|
}
|
|
1917
1945
|
|
|
1918
1946
|
return results;
|
|
@@ -1946,7 +1974,7 @@ function browseChangelogs(
|
|
|
1946
1974
|
}
|
|
1947
1975
|
|
|
1948
1976
|
const sql = `SELECT c.version, c.released, c.category, c.is_breaking,
|
|
1949
|
-
c.description, substr(c.description, 1, 200) as excerpt
|
|
1977
|
+
c.description, substr(c.description, 1, 200) as excerpt, c.sort_order
|
|
1950
1978
|
FROM changelogs c
|
|
1951
1979
|
WHERE ${where.join(" AND ")}
|
|
1952
1980
|
ORDER BY c.sort_order
|
|
@@ -1982,7 +2010,7 @@ function runChangelogFtsQuery(
|
|
|
1982
2010
|
}
|
|
1983
2011
|
|
|
1984
2012
|
const sql = `SELECT c.version, c.released, c.category, c.is_breaking,
|
|
1985
|
-
c.description,
|
|
2013
|
+
c.description, c.sort_order,
|
|
1986
2014
|
snippet(changelogs_fts, 1, '**', '**', '...', 25) as excerpt
|
|
1987
2015
|
FROM changelogs_fts fts
|
|
1988
2016
|
JOIN changelogs c ON c.id = fts.rowid
|
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
|
// ---------------------------------------------------------------------------
|