@tikoci/rosetta 0.8.1 → 0.8.3
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 +195 -31
- package/src/canonicalize.test.ts +28 -28
- package/src/mcp-meta.ts +57 -0
- package/src/mcp.ts +7 -1
- package/src/query.test.ts +7 -7
- package/src/query.ts +21 -0
package/package.json
CHANGED
package/src/browse.ts
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
|
|
14
14
|
import * as readline from "node:readline";
|
|
15
15
|
import { db, getDbStats, initDb } from "./db.ts";
|
|
16
|
+
import { MCP_INSTRUCTIONS, MCP_STATIC_RESOURCES } from "./mcp-meta.ts";
|
|
16
17
|
import { resolveVersion } from "./paths.ts";
|
|
17
18
|
import type {
|
|
18
19
|
CalloutResult,
|
|
@@ -34,6 +35,7 @@ import {
|
|
|
34
35
|
fetchCurrentVersions,
|
|
35
36
|
getDudePage,
|
|
36
37
|
getPage,
|
|
38
|
+
getPageCallouts,
|
|
37
39
|
getSkill,
|
|
38
40
|
getTestResultMeta,
|
|
39
41
|
listGlossary,
|
|
@@ -100,9 +102,13 @@ function mdToAnsi(s: string): string {
|
|
|
100
102
|
if (h) {
|
|
101
103
|
const level = h[1].length;
|
|
102
104
|
const text = h[2];
|
|
105
|
+
// Insert a blank line before headings (unless one already precedes
|
|
106
|
+
// them) so `══ Summary` doesn't glue onto the previous paragraph.
|
|
107
|
+
if (out.length > 0 && out[out.length - 1].trim() !== "") out.push("");
|
|
103
108
|
if (level === 1) out.push(bold(`══ ${text}`));
|
|
104
109
|
else if (level === 2) out.push(bold(`── ${text}`));
|
|
105
110
|
else out.push(bold(text));
|
|
111
|
+
out.push("");
|
|
106
112
|
continue;
|
|
107
113
|
}
|
|
108
114
|
// Bullets
|
|
@@ -215,16 +221,16 @@ async function paged(output: string): Promise<boolean> {
|
|
|
215
221
|
process.stdout.write(`${output}\n`);
|
|
216
222
|
return false;
|
|
217
223
|
}
|
|
224
|
+
const totalPages = Math.ceil(lines.length / pageSize);
|
|
218
225
|
let offset = 0;
|
|
219
226
|
while (offset < lines.length) {
|
|
220
227
|
const chunk = lines.slice(offset, offset + pageSize);
|
|
221
228
|
process.stdout.write(`${chunk.join("\n")}\n`);
|
|
222
229
|
const newOffset = offset + pageSize;
|
|
223
230
|
if (newOffset >= lines.length) return false;
|
|
224
|
-
const totalPages = Math.ceil(lines.length / pageSize);
|
|
225
231
|
const curPage = Math.floor(newOffset / pageSize);
|
|
226
232
|
const prompt = dim(
|
|
227
|
-
`── page ${curPage}/${totalPages} line ${newOffset}/${lines.length} [SPACE next | ENTER line | b back | g/G top/end | q quit]`,
|
|
233
|
+
`── page ${curPage}/${totalPages} line ${newOffset}/${lines.length} [SPACE next | ENTER line | b back | 1-${Math.min(9, totalPages)} jump | g/G top/end | q quit]`,
|
|
228
234
|
);
|
|
229
235
|
process.stdout.write(prompt);
|
|
230
236
|
const key = await waitForKey();
|
|
@@ -240,6 +246,14 @@ async function paged(output: string): Promise<boolean> {
|
|
|
240
246
|
offset += 1;
|
|
241
247
|
continue;
|
|
242
248
|
}
|
|
249
|
+
// Digit 1-9 jumps to that page (1-indexed). If the page is past EOF,
|
|
250
|
+
// clamp to the last page.
|
|
251
|
+
if (/^[1-9]$/.test(key)) {
|
|
252
|
+
const target = Number.parseInt(key, 10);
|
|
253
|
+
offset = Math.min(lines.length - pageSize, (target - 1) * pageSize);
|
|
254
|
+
if (offset < 0) offset = 0;
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
243
257
|
// SPACE / f / anything else → next page
|
|
244
258
|
offset = newOffset;
|
|
245
259
|
}
|
|
@@ -340,7 +354,7 @@ function renderWelcome(): string {
|
|
|
340
354
|
...(stats.skills > 0 ? [`${fmt(stats.skills)} agent skills ${dim("(tikoci — community content)")}`] : []),
|
|
341
355
|
"",
|
|
342
356
|
...(dbWarning ? [dbWarning, ""] : []),
|
|
343
|
-
`Type a search query, or ${bold("help")} for
|
|
357
|
+
`Type a search query, command, or ${bold("help")} for full list.`,
|
|
344
358
|
];
|
|
345
359
|
return box(lines, "rosetta");
|
|
346
360
|
}
|
|
@@ -1040,14 +1054,14 @@ function renderHelp(): string {
|
|
|
1040
1054
|
cmd("search <query>", "s", "Explicit page search", "routeros_search");
|
|
1041
1055
|
cmd("page <id|title>", "", "View full page", "routeros_get_page");
|
|
1042
1056
|
cmd("prop <name>", "p", "Look up property (scoped to current page)", "routeros_lookup_property");
|
|
1043
|
-
cmd("props <query>", "sp", "Search properties by FTS"
|
|
1057
|
+
cmd("props <query>", "sp", "Search properties by FTS");
|
|
1044
1058
|
cmd("glossary [term]", "g", "Look up RouterOS jargon / list glossary");
|
|
1045
1059
|
cmd("cmd [path]", "tree", "Browse command tree", "routeros_command_tree");
|
|
1046
1060
|
cmd("device <query>", "dev", "Look up device specs", "routeros_device_lookup");
|
|
1047
1061
|
cmd("tests [device] [type]", "", "Cross-device benchmarks", "routeros_search_tests");
|
|
1048
|
-
cmd("callouts [query]", "cal", "Search callouts (
|
|
1062
|
+
cmd("callouts [query]", "cal", "Search callouts (cal warning, or `cal` on a page)");
|
|
1049
1063
|
cmd("changelog [query]", "cl", "Search changelogs (cl 7.22, cl breaking)", "routeros_search_changelogs");
|
|
1050
|
-
cmd("videos <query>", "vid", "Search video transcripts"
|
|
1064
|
+
cmd("videos <query>", "vid", "Search video transcripts");
|
|
1051
1065
|
cmd("dude <query>", "", "Search archived Dude wiki docs", "routeros_dude_search");
|
|
1052
1066
|
cmd("skills", "", "List agent skill guides", "rosetta://skills");
|
|
1053
1067
|
cmd("skill <name>", "", "View a skill guide", "rosetta://skills/{name}");
|
|
@@ -1055,25 +1069,29 @@ function renderHelp(): string {
|
|
|
1055
1069
|
cmd("vcheck <path>", "vc", "Version range for a command path", "routeros_command_version_check");
|
|
1056
1070
|
cmd("versions", "ver", "Live-fetch current RouterOS versions", "routeros_current_versions");
|
|
1057
1071
|
cmd("stats", "", "Database health / counts", "routeros_stats");
|
|
1058
|
-
cmd("back", "b", "
|
|
1072
|
+
cmd("back", "b", "Re-render previous view (not just print a breadcrumb)");
|
|
1059
1073
|
cmd("help", "?", "This help");
|
|
1060
1074
|
cmd("quit", "q", "Exit");
|
|
1061
1075
|
|
|
1062
1076
|
out.push("");
|
|
1063
|
-
out.push(` ${bold("MCP probe (dot-commands)")} ${dim("— see what an agent sees")}`);
|
|
1064
|
-
out.push(` ${cyan(pad(".
|
|
1065
|
-
out.push(` ${cyan(pad(".
|
|
1066
|
-
out.push(` ${cyan(pad(".
|
|
1067
|
-
out.push(` ${cyan(pad(".
|
|
1077
|
+
out.push(` ${bold("MCP probe (dot-commands)")} ${dim("— see exactly what an agent sees")}`);
|
|
1078
|
+
out.push(` ${cyan(pad(".help", 38))} ${dim("Full list of all 13 tool dot-commands + meta")}`);
|
|
1079
|
+
out.push(` ${cyan(pad(".instructions", 38))} ${dim("MCP server instructions string (sent on init)")}`);
|
|
1080
|
+
out.push(` ${cyan(pad(".resources", 38))} ${dim("Registered MCP resources (rosetta:// URIs)")}`);
|
|
1081
|
+
out.push(` ${cyan(pad(".routeros_search <q> [limit=N]", 38))} ${dim("Raw JSON output, same query path as MCP")}`);
|
|
1068
1082
|
out.push("");
|
|
1069
|
-
out.push(` ${dim("Navigation: type a number to select from results.")}`);
|
|
1070
|
-
out.push(` ${dim("After viewing a page, [p] = properties
|
|
1083
|
+
out.push(` ${dim("Navigation: type a number to select from results; in pager, 1–9 jumps to page N.")}`);
|
|
1084
|
+
out.push(` ${dim("After viewing a page, [p] = properties, [cal] = page callouts, [b] = re-render previous view.")}`);
|
|
1071
1085
|
out.push(` ${dim("URLs are clickable in supported terminals (iTerm2, etc.).")}`);
|
|
1072
1086
|
out.push(` ${dim("cmd supports @version suffix: cmd /ip/address @7.15")}`);
|
|
1073
1087
|
out.push("");
|
|
1074
1088
|
out.push(` ${bold("CLI flags")}`);
|
|
1075
1089
|
out.push(` ${cyan(pad("--db <path>", 26))} ${dim("")} Use a specific database file`);
|
|
1076
|
-
out.push(` ${cyan(pad("--once", 26))} ${dim("")}
|
|
1090
|
+
out.push(` ${cyan(pad("--once", 26))} ${dim("")} Execute any command once and exit (for piping)`);
|
|
1091
|
+
out.push(` ${cyan(pad("browse <cmd> [args]", 26))} ${dim("")} Pass any TUI command directly from the shell:`)
|
|
1092
|
+
out.push(` ${cyan(pad("", 26))} ${dim("")} ${dim("browse changelog 7.20..7.22")}`);
|
|
1093
|
+
out.push(` ${cyan(pad("", 26))} ${dim("")} ${dim("browse cmd /ip/firewall")}`);
|
|
1094
|
+
out.push(` ${cyan(pad("", 26))} ${dim("")} ${dim("browse .routeros_search vrrp")}`);
|
|
1077
1095
|
|
|
1078
1096
|
return out.join("\n");
|
|
1079
1097
|
}
|
|
@@ -1091,7 +1109,7 @@ function renderHelp(): string {
|
|
|
1091
1109
|
|
|
1092
1110
|
type DotArgs = Record<string, string | number | boolean>;
|
|
1093
1111
|
|
|
1094
|
-
function parseDotArgs(rest: string, primary?: string): DotArgs {
|
|
1112
|
+
function parseDotArgs(rest: string, primary?: string, aliases?: Record<string, string>): DotArgs {
|
|
1095
1113
|
const args: DotArgs = {};
|
|
1096
1114
|
const positional: string[] = [];
|
|
1097
1115
|
// Split on whitespace but allow key="quoted value"
|
|
@@ -1103,7 +1121,10 @@ function parseDotArgs(rest: string, primary?: string): DotArgs {
|
|
|
1103
1121
|
if (v === "true") v = true;
|
|
1104
1122
|
else if (v === "false") v = false;
|
|
1105
1123
|
else if (/^-?\d+$/.test(v)) v = Number.parseInt(v, 10);
|
|
1106
|
-
|
|
1124
|
+
// Normalize alias keys to canonical names so callers can paste the
|
|
1125
|
+
// exact form printed in `next_steps` (e.g. `id=N` for get_page).
|
|
1126
|
+
const key = aliases?.[m[1]] ?? m[1];
|
|
1127
|
+
args[key] = v;
|
|
1107
1128
|
} else {
|
|
1108
1129
|
positional.push(t.replace(/^"|"$/g, ""));
|
|
1109
1130
|
}
|
|
@@ -1119,6 +1140,11 @@ type DotTool = {
|
|
|
1119
1140
|
primary?: string;
|
|
1120
1141
|
/** Short one-line description shown by `.help`. */
|
|
1121
1142
|
desc: string;
|
|
1143
|
+
/** Optional alias→canonical key map (e.g. `{ id: "page" }` so the form
|
|
1144
|
+
* printed in `next_steps` works verbatim). */
|
|
1145
|
+
aliases?: Record<string, string>;
|
|
1146
|
+
/** TUI command this tool maps to, for cross-referencing in `.help`. */
|
|
1147
|
+
tui?: string;
|
|
1122
1148
|
/** Run the tool — return any JSON-serializable object. */
|
|
1123
1149
|
run: (args: DotArgs) => unknown;
|
|
1124
1150
|
};
|
|
@@ -1126,12 +1152,15 @@ type DotTool = {
|
|
|
1126
1152
|
const dotTools: Record<string, DotTool> = {
|
|
1127
1153
|
routeros_search: {
|
|
1128
1154
|
primary: "query",
|
|
1155
|
+
tui: "s <query>",
|
|
1129
1156
|
desc: "Unified search — same as `s <query>` but with raw JSON response",
|
|
1130
1157
|
run: (a) => searchAll(String(a.query ?? ""), a.limit ? Number(a.limit) : undefined),
|
|
1131
1158
|
},
|
|
1132
1159
|
routeros_get_page: {
|
|
1133
1160
|
primary: "page",
|
|
1134
|
-
|
|
1161
|
+
aliases: { id: "page" },
|
|
1162
|
+
tui: "page <id|title>",
|
|
1163
|
+
desc: "Full page by id or title (args: page= or id=, max_length=, section=)",
|
|
1135
1164
|
run: (a) => {
|
|
1136
1165
|
const p = a.page;
|
|
1137
1166
|
const id = typeof p === "number" ? p : /^\d+$/.test(String(p)) ? Number.parseInt(String(p), 10) : String(p);
|
|
@@ -1144,11 +1173,13 @@ const dotTools: Record<string, DotTool> = {
|
|
|
1144
1173
|
},
|
|
1145
1174
|
routeros_lookup_property: {
|
|
1146
1175
|
primary: "name",
|
|
1176
|
+
tui: "p <name>",
|
|
1147
1177
|
desc: "Property by exact name (args: name=, command_path=)",
|
|
1148
1178
|
run: (a) => lookupProperty(String(a.name ?? ""), a.command_path ? String(a.command_path) : undefined),
|
|
1149
1179
|
},
|
|
1150
1180
|
routeros_command_tree: {
|
|
1151
1181
|
primary: "path",
|
|
1182
|
+
tui: "cmd [path]",
|
|
1152
1183
|
desc: "Browse command tree (args: path=, version=, arch=)",
|
|
1153
1184
|
run: (a) => {
|
|
1154
1185
|
const path = String(a.path ?? "");
|
|
@@ -1158,6 +1189,7 @@ const dotTools: Record<string, DotTool> = {
|
|
|
1158
1189
|
},
|
|
1159
1190
|
routeros_search_changelogs: {
|
|
1160
1191
|
primary: "query",
|
|
1192
|
+
tui: "cl [query]",
|
|
1161
1193
|
desc: "Search changelogs (args: query=, version=, from_version=, to_version=, category=, breaking_only=, limit=)",
|
|
1162
1194
|
run: (a) => searchChangelogs(String(a.query ?? ""), {
|
|
1163
1195
|
version: a.version ? String(a.version) : undefined,
|
|
@@ -1170,6 +1202,7 @@ const dotTools: Record<string, DotTool> = {
|
|
|
1170
1202
|
},
|
|
1171
1203
|
routeros_command_version_check: {
|
|
1172
1204
|
primary: "command_path",
|
|
1205
|
+
tui: "vc <command_path>",
|
|
1173
1206
|
desc: "Version range for a command path (args: command_path=)",
|
|
1174
1207
|
run: (a) => {
|
|
1175
1208
|
const p = String(a.command_path ?? "");
|
|
@@ -1177,6 +1210,7 @@ const dotTools: Record<string, DotTool> = {
|
|
|
1177
1210
|
},
|
|
1178
1211
|
},
|
|
1179
1212
|
routeros_command_diff: {
|
|
1213
|
+
tui: "diff <from> <to> [path]",
|
|
1180
1214
|
desc: "Diff command tree between versions (args: from_version=, to_version=, path_prefix=, arch=)",
|
|
1181
1215
|
run: (a) => diffCommandVersions(
|
|
1182
1216
|
String(a.from_version ?? ""),
|
|
@@ -1187,6 +1221,7 @@ const dotTools: Record<string, DotTool> = {
|
|
|
1187
1221
|
},
|
|
1188
1222
|
routeros_device_lookup: {
|
|
1189
1223
|
primary: "query",
|
|
1224
|
+
tui: "device <query>",
|
|
1190
1225
|
desc: "Device lookup with FTS+filters (args: query=, architecture=, license_level=, has_wireless=, ...)",
|
|
1191
1226
|
run: (a) => searchDevices(String(a.query ?? ""), {
|
|
1192
1227
|
architecture: a.architecture ? String(a.architecture) : undefined,
|
|
@@ -1199,6 +1234,7 @@ const dotTools: Record<string, DotTool> = {
|
|
|
1199
1234
|
}, a.limit ? Number(a.limit) : undefined),
|
|
1200
1235
|
},
|
|
1201
1236
|
routeros_search_tests: {
|
|
1237
|
+
tui: "tests [device] [type]",
|
|
1202
1238
|
desc: "Cross-device benchmarks (args: device=, test_type=, mode=, configuration=, packet_size=, sort_by=, limit=)",
|
|
1203
1239
|
run: (a) => searchDeviceTests({
|
|
1204
1240
|
device: a.device ? String(a.device) : undefined,
|
|
@@ -1211,6 +1247,7 @@ const dotTools: Record<string, DotTool> = {
|
|
|
1211
1247
|
},
|
|
1212
1248
|
routeros_dude_search: {
|
|
1213
1249
|
primary: "query",
|
|
1250
|
+
tui: "dude <query>",
|
|
1214
1251
|
desc: "Search archived Dude wiki (args: query=, limit=)",
|
|
1215
1252
|
run: (a) => searchDude(String(a.query ?? ""), a.limit ? Number(a.limit) : undefined),
|
|
1216
1253
|
},
|
|
@@ -1223,34 +1260,90 @@ const dotTools: Record<string, DotTool> = {
|
|
|
1223
1260
|
},
|
|
1224
1261
|
},
|
|
1225
1262
|
routeros_stats: {
|
|
1263
|
+
tui: "stats",
|
|
1226
1264
|
desc: "DB health / counts",
|
|
1227
1265
|
run: () => getDbStats(),
|
|
1228
1266
|
},
|
|
1229
1267
|
routeros_current_versions: {
|
|
1268
|
+
tui: "versions",
|
|
1230
1269
|
desc: "Live-fetch RouterOS versions per channel",
|
|
1231
1270
|
run: async () => await fetchCurrentVersions(),
|
|
1232
1271
|
},
|
|
1233
1272
|
};
|
|
1234
1273
|
|
|
1274
|
+
/** Convert FTS5 snippet markers `**word**` inside JSON string values to ANSI
|
|
1275
|
+
* bold so dot-command output highlights matched terms (matching what the
|
|
1276
|
+
* TUI search/changelog/video renderers do). Operates only on quoted JSON
|
|
1277
|
+
* string contents — keys and structure use their own quoting. */
|
|
1278
|
+
function highlightSnippetMarkers(json: string): string {
|
|
1279
|
+
return json.replace(/"((?:\\.|[^"\\])*)"/g, (full, body) => {
|
|
1280
|
+
if (!body.includes("**")) return full;
|
|
1281
|
+
const replaced = body.replace(/\*\*([^*"]+)\*\*/g, (_m: string, t: string) => `${ESC}[1m${t}${ESC}[0m`);
|
|
1282
|
+
return `"${replaced}"`;
|
|
1283
|
+
});
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1235
1286
|
async function dispatchDotCommand(input: string): Promise<void> {
|
|
1236
|
-
// .help / .tools — list
|
|
1287
|
+
// .help / .tools — list all dot-commands
|
|
1237
1288
|
if (input === ".help" || input === ".?" || input === ".tools") {
|
|
1238
1289
|
const out: string[] = [];
|
|
1239
1290
|
out.push(` ${bold("MCP probe — direct tool invocation")}`);
|
|
1240
1291
|
out.push(` ${dim("Format: .<tool_name> [positional...] [key=value ...]")}`);
|
|
1241
1292
|
out.push(` ${dim("Output: raw JSON, exactly what an MCP client would receive.")}`);
|
|
1242
1293
|
out.push("");
|
|
1294
|
+
out.push(` ${bold("Tool dot-commands")} ${dim(`(${Object.keys(dotTools).length} tools)`)}`);
|
|
1243
1295
|
for (const [name, t] of Object.entries(dotTools)) {
|
|
1244
|
-
|
|
1296
|
+
const tuiHint = t.tui ? ` ${dim(`= ${t.tui}`)}` : "";
|
|
1297
|
+
out.push(` ${cyan(`.${name}`)}${tuiHint}`);
|
|
1245
1298
|
out.push(` ${dim(t.desc)}`);
|
|
1246
1299
|
}
|
|
1247
1300
|
out.push("");
|
|
1301
|
+
out.push(` ${bold("Meta dot-commands")}`);
|
|
1302
|
+
out.push(` ${cyan(".instructions")} ${dim("Show MCP server instructions string sent to clients on init")}`);
|
|
1303
|
+
out.push(` ${cyan(".resources")} ${dim("List MCP resources (rosetta:// URIs)")}`);
|
|
1304
|
+
out.push(` ${cyan(".help")} / ${cyan(".tools")} / ${cyan(".?")} ${dim("This list")}`);
|
|
1305
|
+
out.push("");
|
|
1248
1306
|
out.push(` ${dim("Example: .routeros_search firewall filter limit=20")}`);
|
|
1249
1307
|
out.push(` ${dim("Example: .routeros_get_page 28282 max_length=4000")}`);
|
|
1308
|
+
out.push(` ${dim("Example: .routeros_get_page id=81362945 (paste from next_steps)")}`);
|
|
1250
1309
|
out.push(` ${dim("Example: .routeros_lookup_property name=disabled command_path=/ip/firewall/filter")}`);
|
|
1251
1310
|
await paged(out.join("\n"));
|
|
1252
1311
|
return;
|
|
1253
1312
|
}
|
|
1313
|
+
// .instructions — print MCP server `instructions` string
|
|
1314
|
+
if (input === ".instructions") {
|
|
1315
|
+
const out: string[] = [];
|
|
1316
|
+
out.push(` ${bold("MCP server instructions")} ${dim("(sent to clients on initialize)")}`);
|
|
1317
|
+
out.push("");
|
|
1318
|
+
out.push(MCP_INSTRUCTIONS);
|
|
1319
|
+
await paged(out.join("\n"));
|
|
1320
|
+
return;
|
|
1321
|
+
}
|
|
1322
|
+
// .resources — list registered MCP resources
|
|
1323
|
+
if (input === ".resources" || input === ".refs") {
|
|
1324
|
+
const out: string[] = [];
|
|
1325
|
+
out.push(` ${bold("MCP resources")} ${dim("(static + per-skill)")}`);
|
|
1326
|
+
out.push("");
|
|
1327
|
+
for (const r of MCP_STATIC_RESOURCES) {
|
|
1328
|
+
out.push(` ${cyan(r.uri)}`);
|
|
1329
|
+
out.push(` ${bold(r.title)} ${dim(`[${r.mimeType}]`)}`);
|
|
1330
|
+
out.push(` ${dim(r.description)}`);
|
|
1331
|
+
}
|
|
1332
|
+
try {
|
|
1333
|
+
const skills = listSkills();
|
|
1334
|
+
if (skills.length > 0) {
|
|
1335
|
+
out.push("");
|
|
1336
|
+
out.push(` ${bold(`Skill resources`)} ${dim(`(${skills.length} from tikoci/routeros-skills — community content, NOT official MikroTik docs)`)}`);
|
|
1337
|
+
for (const s of skills) {
|
|
1338
|
+
out.push(` ${cyan(`rosetta://skills/${s.name}`)} ${dim(`${s.word_count}w`)} ${dim(s.description)}`);
|
|
1339
|
+
}
|
|
1340
|
+
}
|
|
1341
|
+
} catch {
|
|
1342
|
+
// skills table may not exist
|
|
1343
|
+
}
|
|
1344
|
+
await paged(out.join("\n"));
|
|
1345
|
+
return;
|
|
1346
|
+
}
|
|
1254
1347
|
const m = input.match(/^\.([a-z_]\w*)\s*(.*)$/i);
|
|
1255
1348
|
if (!m) {
|
|
1256
1349
|
console.log(dim(` Bad dot-command syntax. Try '.help' for the list.`));
|
|
@@ -1263,11 +1356,11 @@ async function dispatchDotCommand(input: string): Promise<void> {
|
|
|
1263
1356
|
return;
|
|
1264
1357
|
}
|
|
1265
1358
|
try {
|
|
1266
|
-
const args = parseDotArgs(m[2] ?? "", tool.primary);
|
|
1359
|
+
const args = parseDotArgs(m[2] ?? "", tool.primary, tool.aliases);
|
|
1267
1360
|
const result = await tool.run(args);
|
|
1268
1361
|
const json = JSON.stringify(result, null, 2);
|
|
1269
1362
|
const banner = dim(`── .${name} args=${JSON.stringify(args)}`);
|
|
1270
|
-
await paged(`${banner}\n${json}`);
|
|
1363
|
+
await paged(`${banner}\n${highlightSnippetMarkers(json)}`);
|
|
1271
1364
|
} catch (e) {
|
|
1272
1365
|
console.log(red(` Error invoking .${name}: ${(e as Error).message}`));
|
|
1273
1366
|
}
|
|
@@ -1314,9 +1407,11 @@ async function dispatch(input: string): Promise<void> {
|
|
|
1314
1407
|
case "back":
|
|
1315
1408
|
if (!popCtx()) {
|
|
1316
1409
|
console.log(dim(" Already at top."));
|
|
1317
|
-
|
|
1318
|
-
console.log(dim(` ← back to ${ctx.type}`));
|
|
1410
|
+
return;
|
|
1319
1411
|
}
|
|
1412
|
+
// Re-render the now-current context so the user lands back where
|
|
1413
|
+
// they came from instead of dropping to a bare prompt.
|
|
1414
|
+
await renderCurrentContext();
|
|
1320
1415
|
return;
|
|
1321
1416
|
|
|
1322
1417
|
case "stats":
|
|
@@ -1388,14 +1483,16 @@ async function dispatch(input: string): Promise<void> {
|
|
|
1388
1483
|
case "cal":
|
|
1389
1484
|
case "callouts": {
|
|
1390
1485
|
if (!rest && ctx.type === "page") {
|
|
1391
|
-
//
|
|
1392
|
-
|
|
1393
|
-
const pageCallouts =
|
|
1486
|
+
// Page-scoped: query callouts directly by page_id (the
|
|
1487
|
+
// FTS-with-empty-query path always returned [] before).
|
|
1488
|
+
const pageCallouts = getPageCallouts((ctx as { pageId: number }).pageId);
|
|
1394
1489
|
if (pageCallouts.length > 0) {
|
|
1395
1490
|
await paged(renderCallouts(pageCallouts));
|
|
1396
1491
|
pushCtx({ type: "callouts", query: "", results: pageCallouts });
|
|
1397
|
-
|
|
1492
|
+
} else {
|
|
1493
|
+
console.log(dim(" This page has no callouts."));
|
|
1398
1494
|
}
|
|
1495
|
+
return;
|
|
1399
1496
|
}
|
|
1400
1497
|
await doSearchCallouts(rest);
|
|
1401
1498
|
return;
|
|
@@ -1557,6 +1654,68 @@ async function handleNumberSelect(idx: number): Promise<void> {
|
|
|
1557
1654
|
|
|
1558
1655
|
// ── Action functions ──
|
|
1559
1656
|
|
|
1657
|
+
/**
|
|
1658
|
+
* Re-render the current context, used by `b` / `back` so users land back on
|
|
1659
|
+
* the parent view instead of dropping to a bare prompt. Pure rendering — no
|
|
1660
|
+
* pushCtx (we just popped).
|
|
1661
|
+
*/
|
|
1662
|
+
async function renderCurrentContext(): Promise<void> {
|
|
1663
|
+
switch (ctx.type) {
|
|
1664
|
+
case "home":
|
|
1665
|
+
console.log(dim(" ← back to home. Type 'help' for commands."));
|
|
1666
|
+
return;
|
|
1667
|
+
case "search":
|
|
1668
|
+
await paged(renderSearchResults(ctx.response));
|
|
1669
|
+
return;
|
|
1670
|
+
case "page":
|
|
1671
|
+
case "sections": {
|
|
1672
|
+
const page = getPage(ctx.pageId);
|
|
1673
|
+
if (page) await paged(renderPage(page));
|
|
1674
|
+
return;
|
|
1675
|
+
}
|
|
1676
|
+
case "commands": {
|
|
1677
|
+
const children = browseCommands(ctx.path);
|
|
1678
|
+
await paged(renderCommandTree(ctx.path, children));
|
|
1679
|
+
return;
|
|
1680
|
+
}
|
|
1681
|
+
case "devices":
|
|
1682
|
+
await paged(renderDeviceResults(ctx.results, "search", ctx.results.length));
|
|
1683
|
+
return;
|
|
1684
|
+
case "device":
|
|
1685
|
+
await paged(renderDeviceCard(ctx.device));
|
|
1686
|
+
return;
|
|
1687
|
+
case "callouts":
|
|
1688
|
+
await paged(renderCallouts(ctx.results));
|
|
1689
|
+
return;
|
|
1690
|
+
case "changelogs":
|
|
1691
|
+
await paged(renderChangelogs(ctx.results));
|
|
1692
|
+
return;
|
|
1693
|
+
case "videos":
|
|
1694
|
+
await paged(renderVideos(ctx.results));
|
|
1695
|
+
return;
|
|
1696
|
+
case "dude":
|
|
1697
|
+
await paged(renderDudeResults(ctx.results));
|
|
1698
|
+
return;
|
|
1699
|
+
case "skills":
|
|
1700
|
+
await doListSkills();
|
|
1701
|
+
return;
|
|
1702
|
+
case "properties": {
|
|
1703
|
+
const lines: string[] = [` ${bold("Properties")}`, ""];
|
|
1704
|
+
for (let i = 0; i < ctx.results.length; i++) {
|
|
1705
|
+
const p = ctx.results[i];
|
|
1706
|
+
lines.push(` ${cyan(String(i + 1).padStart(2))}. ${bold(p.name)} ${dim(`@ ${p.page_title}`)}`);
|
|
1707
|
+
}
|
|
1708
|
+
await paged(lines.join("\n"));
|
|
1709
|
+
return;
|
|
1710
|
+
}
|
|
1711
|
+
case "tests":
|
|
1712
|
+
case "diff":
|
|
1713
|
+
case "vcheck":
|
|
1714
|
+
console.log(dim(` ← back to ${ctx.type}. Re-run the command to see the result again.`));
|
|
1715
|
+
return;
|
|
1716
|
+
}
|
|
1717
|
+
}
|
|
1718
|
+
|
|
1560
1719
|
async function doSearch(query: string): Promise<void> {
|
|
1561
1720
|
const resp = searchAll(query);
|
|
1562
1721
|
await paged(renderSearchResults(resp));
|
|
@@ -1660,7 +1819,7 @@ async function doGlossary(rest: string): Promise<void> {
|
|
|
1660
1819
|
}
|
|
1661
1820
|
const aliases = entry.aliases ? `\n ${dim("Aliases:")} ${entry.aliases}` : "";
|
|
1662
1821
|
const hint = entry.search_hint ? `\n ${dim("Search:")} ${cyan(entry.search_hint)}` : "";
|
|
1663
|
-
console.log(`\n ${bold(entry.term)} ${dim(`[${entry.category}]`)}\n ${entry.definition}${aliases}${hint}\n`);
|
|
1822
|
+
console.log(`\n ${bold(entry.term)} ${dim(`[${entry.category}]`)}\n ${mdToAnsi(entry.definition)}${aliases}${hint}\n`);
|
|
1664
1823
|
}
|
|
1665
1824
|
|
|
1666
1825
|
async function doCommandTree(path: string): Promise<void> {
|
|
@@ -1952,9 +2111,14 @@ async function main() {
|
|
|
1952
2111
|
console.log("");
|
|
1953
2112
|
}
|
|
1954
2113
|
|
|
1955
|
-
// Initial
|
|
2114
|
+
// Initial command from CLI args — run through the same dispatch as the REPL
|
|
2115
|
+
// so any TUI command works, not just searches:
|
|
2116
|
+
// browse changelog 7.20..7.22 → shows changelog range
|
|
2117
|
+
// browse cmd /ip/firewall → shows command tree
|
|
2118
|
+
// browse .routeros_search vrrp → MCP probe output
|
|
2119
|
+
// browse dhcp server → (bare text) search as before
|
|
1956
2120
|
if (initialQuery) {
|
|
1957
|
-
await
|
|
2121
|
+
await dispatch(initialQuery);
|
|
1958
2122
|
if (onceMode) process.exit(0);
|
|
1959
2123
|
}
|
|
1960
2124
|
|
package/src/canonicalize.test.ts
CHANGED
|
@@ -215,11 +215,11 @@ describe('subshells [...]', () => {
|
|
|
215
215
|
const findCmd = result.commands.find(c => c.verb === 'find');
|
|
216
216
|
const setCmd = result.commands.find(c => c.verb === 'set');
|
|
217
217
|
expect(findCmd).toBeDefined();
|
|
218
|
-
expect(findCmd
|
|
219
|
-
expect(findCmd
|
|
218
|
+
expect(findCmd?.subshell).toBe(true);
|
|
219
|
+
expect(findCmd?.path).toBe('/ip/address');
|
|
220
220
|
expect(setCmd).toBeDefined();
|
|
221
|
-
expect(setCmd
|
|
222
|
-
expect(setCmd
|
|
221
|
+
expect(setCmd?.path).toBe('/ip/address');
|
|
222
|
+
expect(setCmd?.verb).toBe('set');
|
|
223
223
|
});
|
|
224
224
|
|
|
225
225
|
test('nested subshells: /ip route get [find gateway=1.1.1.1]', () => {
|
|
@@ -228,18 +228,18 @@ describe('subshells [...]', () => {
|
|
|
228
228
|
const findCmd = result.commands.find(c => c.verb === 'find');
|
|
229
229
|
const getCmd = result.commands.find(c => c.verb === 'get');
|
|
230
230
|
expect(findCmd).toBeDefined();
|
|
231
|
-
expect(findCmd
|
|
232
|
-
expect(findCmd
|
|
231
|
+
expect(findCmd?.path).toBe('/ip/route');
|
|
232
|
+
expect(findCmd?.subshell).toBe(true);
|
|
233
233
|
expect(getCmd).toBeDefined();
|
|
234
|
-
expect(getCmd
|
|
234
|
+
expect(getCmd?.path).toBe('/ip/route');
|
|
235
235
|
});
|
|
236
236
|
|
|
237
237
|
test('subshell with absolute path inside', () => {
|
|
238
238
|
const result = canonicalize('/interface set [/ip address find] name=test', '/system');
|
|
239
239
|
const findCmd = result.commands.find(c => c.verb === 'find');
|
|
240
240
|
expect(findCmd).toBeDefined();
|
|
241
|
-
expect(findCmd
|
|
242
|
-
expect(findCmd
|
|
241
|
+
expect(findCmd?.path).toBe('/ip/address');
|
|
242
|
+
expect(findCmd?.subshell).toBe(true);
|
|
243
243
|
});
|
|
244
244
|
|
|
245
245
|
test('complex bridge example with nested subshells', () => {
|
|
@@ -249,12 +249,12 @@ describe('subshells [...]', () => {
|
|
|
249
249
|
);
|
|
250
250
|
const setCmd = result.commands.find(c => c.verb === 'set' && !c.subshell);
|
|
251
251
|
expect(setCmd).toBeDefined();
|
|
252
|
-
expect(setCmd
|
|
252
|
+
expect(setCmd?.path).toBe('/interface/bridge');
|
|
253
253
|
|
|
254
254
|
// The subshell commands should inherit /interface/bridge context
|
|
255
255
|
const findCmd = result.commands.find(c => c.verb === 'find');
|
|
256
256
|
expect(findCmd).toBeDefined();
|
|
257
|
-
expect(findCmd
|
|
257
|
+
expect(findCmd?.subshell).toBe(true);
|
|
258
258
|
});
|
|
259
259
|
});
|
|
260
260
|
|
|
@@ -266,7 +266,7 @@ describe('{ } blocks', () => {
|
|
|
266
266
|
const result = canonicalize('/ip/address { print }');
|
|
267
267
|
const printCmd = result.commands.find(c => c.verb === 'print');
|
|
268
268
|
expect(printCmd).toBeDefined();
|
|
269
|
-
expect(printCmd
|
|
269
|
+
expect(printCmd?.path).toBe('/ip/address');
|
|
270
270
|
});
|
|
271
271
|
|
|
272
272
|
test('path persists after block exit', () => {
|
|
@@ -281,7 +281,7 @@ describe('{ } blocks', () => {
|
|
|
281
281
|
const result = canonicalize('{\n :local a 3;\n /ip/address/print\n}');
|
|
282
282
|
const printCmd = result.commands.find(c => c.verb === 'print');
|
|
283
283
|
expect(printCmd).toBeDefined();
|
|
284
|
-
expect(printCmd
|
|
284
|
+
expect(printCmd?.path).toBe('/ip/address');
|
|
285
285
|
});
|
|
286
286
|
});
|
|
287
287
|
|
|
@@ -314,14 +314,14 @@ describe('ICE commands', () => {
|
|
|
314
314
|
const result = canonicalize(':put [/system/resource/get version]');
|
|
315
315
|
const getCmd = result.commands.find(c => c.verb === 'get');
|
|
316
316
|
expect(getCmd).toBeDefined();
|
|
317
|
-
expect(getCmd
|
|
317
|
+
expect(getCmd?.path).toBe('/system/resource');
|
|
318
318
|
});
|
|
319
319
|
|
|
320
320
|
test(':local and :global are skipped', () => {
|
|
321
321
|
const result = canonicalize(':local myVar "test"\n/ip/address/print');
|
|
322
322
|
const printCmd = result.commands.find(c => c.verb === 'print');
|
|
323
323
|
expect(printCmd).toBeDefined();
|
|
324
|
-
expect(printCmd
|
|
324
|
+
expect(printCmd?.path).toBe('/ip/address');
|
|
325
325
|
});
|
|
326
326
|
});
|
|
327
327
|
|
|
@@ -393,12 +393,12 @@ describe('real-world examples', () => {
|
|
|
393
393
|
);
|
|
394
394
|
const addCmd = result.commands.find(c => c.verb === 'add' && !c.subshell);
|
|
395
395
|
expect(addCmd).toBeDefined();
|
|
396
|
-
expect(addCmd
|
|
397
|
-
expect(addCmd
|
|
396
|
+
expect(addCmd?.path).toBe('/ip/firewall/filter');
|
|
397
|
+
expect(addCmd?.args.some(a => a.startsWith('chain='))).toBe(true);
|
|
398
398
|
|
|
399
399
|
const findCmd = result.commands.find(c => c.verb === 'find');
|
|
400
400
|
expect(findCmd).toBeDefined();
|
|
401
|
-
expect(findCmd
|
|
401
|
+
expect(findCmd?.subshell).toBe(true);
|
|
402
402
|
});
|
|
403
403
|
|
|
404
404
|
test('from Console page: /ip/firewall/filter/add chain=forward', () => {
|
|
@@ -413,7 +413,7 @@ describe('real-world examples', () => {
|
|
|
413
413
|
const result = canonicalize('/ip dhcp-server set myServer lease-script=myLeaseScript');
|
|
414
414
|
const setCmd = result.commands.find(c => c.verb === 'set');
|
|
415
415
|
expect(setCmd).toBeDefined();
|
|
416
|
-
expect(setCmd
|
|
416
|
+
expect(setCmd?.path).toBe('/ip/dhcp-server');
|
|
417
417
|
});
|
|
418
418
|
|
|
419
419
|
test('scripting: :put with nested subshells', () => {
|
|
@@ -422,8 +422,8 @@ describe('real-world examples', () => {
|
|
|
422
422
|
const getCmd = result.commands.find(c => c.verb === 'get');
|
|
423
423
|
expect(findCmd).toBeDefined();
|
|
424
424
|
expect(getCmd).toBeDefined();
|
|
425
|
-
expect(findCmd
|
|
426
|
-
expect(getCmd
|
|
425
|
+
expect(findCmd?.path).toBe('/ip/route');
|
|
426
|
+
expect(getCmd?.path).toBe('/ip/route');
|
|
427
427
|
});
|
|
428
428
|
|
|
429
429
|
test('firewall rule with find subshell', () => {
|
|
@@ -433,10 +433,10 @@ describe('real-world examples', () => {
|
|
|
433
433
|
const setCmd = result.commands.find(c => c.verb === 'set' && !c.subshell);
|
|
434
434
|
const findCmd = result.commands.find(c => c.verb === 'find');
|
|
435
435
|
expect(setCmd).toBeDefined();
|
|
436
|
-
expect(setCmd
|
|
436
|
+
expect(setCmd?.path).toBe('/ip/address');
|
|
437
437
|
expect(findCmd).toBeDefined();
|
|
438
|
-
expect(findCmd
|
|
439
|
-
expect(findCmd
|
|
438
|
+
expect(findCmd?.path).toBe('/ip/address');
|
|
439
|
+
expect(findCmd?.subshell).toBe(true);
|
|
440
440
|
});
|
|
441
441
|
|
|
442
442
|
test('interface set with name', () => {
|
|
@@ -456,9 +456,9 @@ describe('real-world examples', () => {
|
|
|
456
456
|
const printCmd = result.commands.find(c => c.verb === 'print');
|
|
457
457
|
const addCmd = result.commands.find(c => c.verb === 'add');
|
|
458
458
|
expect(printCmd).toBeDefined();
|
|
459
|
-
expect(printCmd
|
|
459
|
+
expect(printCmd?.path).toBe('/ip/address');
|
|
460
460
|
expect(addCmd).toBeDefined();
|
|
461
|
-
expect(addCmd
|
|
461
|
+
expect(addCmd?.path).toBe('/ip/route');
|
|
462
462
|
});
|
|
463
463
|
|
|
464
464
|
test('user example: bridge pvid subshell', () => {
|
|
@@ -471,7 +471,7 @@ describe('real-world examples', () => {
|
|
|
471
471
|
);
|
|
472
472
|
const setCmd = result.commands.find(c => c.verb === 'set' && !c.subshell);
|
|
473
473
|
expect(setCmd).toBeDefined();
|
|
474
|
-
expect(setCmd
|
|
474
|
+
expect(setCmd?.path).toBe('/interface/bridge');
|
|
475
475
|
});
|
|
476
476
|
});
|
|
477
477
|
|
|
@@ -515,6 +515,6 @@ describe('edge cases', () => {
|
|
|
515
515
|
const result = canonicalize('/interface/print where name~"ether"');
|
|
516
516
|
const printCmd = result.commands.find(c => c.verb === 'print');
|
|
517
517
|
expect(printCmd).toBeDefined();
|
|
518
|
-
expect(printCmd
|
|
518
|
+
expect(printCmd?.path).toBe('/interface');
|
|
519
519
|
});
|
|
520
520
|
});
|
package/src/mcp-meta.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* mcp-meta.ts — small constants shared between the MCP server (`mcp.ts`)
|
|
3
|
+
* and the TUI dot-commands (`browse.ts`).
|
|
4
|
+
*
|
|
5
|
+
* Lives in its own module so importing it from `browse.ts` does not pull
|
|
6
|
+
* in `mcp.ts`'s top-level CLI dispatch / async IIFE (which would try to
|
|
7
|
+
* launch the MCP server as a side effect of importing).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** MCP `instructions` string sent to clients on init. The TUI's
|
|
11
|
+
* `.instructions` dot-command shows the same text an agent sees. */
|
|
12
|
+
export const MCP_INSTRUCTIONS =
|
|
13
|
+
"RouterOS documentation search. Start with routeros_search for any RouterOS question — it runs a classifier (detects command paths, versions, devices, topics) + BM25 FTS, and returns pages plus a `related` block (command_node, properties, devices, callouts, videos, changelogs, skills) + next-step hints. One call usually answers the question. Drill into specific pages with routeros_get_page; for hardware specs use routeros_device_lookup; for version-specific command changes use routeros_command_diff. Only v7 data exists (7.9+) — v6 is out of scope.";
|
|
14
|
+
|
|
15
|
+
/** Static metadata for the registered MCP resources. Kept in sync with the
|
|
16
|
+
* `server.registerResource` calls in `mcp.ts`; exposed for the TUI
|
|
17
|
+
* `.resources` dot-command. Per-skill resources are discovered dynamically
|
|
18
|
+
* via `listSkills()` at call time. */
|
|
19
|
+
export const MCP_STATIC_RESOURCES: ReadonlyArray<{
|
|
20
|
+
uri: string;
|
|
21
|
+
title: string;
|
|
22
|
+
description: string;
|
|
23
|
+
mimeType: string;
|
|
24
|
+
}> = [
|
|
25
|
+
{
|
|
26
|
+
uri: "rosetta://datasets/device-test-results.csv",
|
|
27
|
+
title: "Device Test Results CSV",
|
|
28
|
+
description: "Full joined benchmark dataset as CSV.",
|
|
29
|
+
mimeType: "text/csv",
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
uri: "rosetta://datasets/devices.csv",
|
|
33
|
+
title: "Devices CSV",
|
|
34
|
+
description: "Device catalog with normalized RAM/storage and URLs.",
|
|
35
|
+
mimeType: "text/csv",
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
uri: "rosetta://schema.sql",
|
|
39
|
+
title: "Database Schema DDL",
|
|
40
|
+
description: "Full SQLite DDL for ros-help.db.",
|
|
41
|
+
mimeType: "application/sql",
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
uri: "rosetta://schema-guide.md",
|
|
45
|
+
title: "Schema Guide",
|
|
46
|
+
description:
|
|
47
|
+
"Table relationships, FTS5 quirks, BM25 weights, query patterns.",
|
|
48
|
+
mimeType: "text/markdown",
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
uri: "rosetta://skills",
|
|
52
|
+
title: "RouterOS Agent Skills",
|
|
53
|
+
description:
|
|
54
|
+
"List of community-created agent skill guides (tikoci/routeros-skills).",
|
|
55
|
+
mimeType: "text/markdown",
|
|
56
|
+
},
|
|
57
|
+
];
|
package/src/mcp.ts
CHANGED
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
* TLS_KEY_PATH — TLS private key path (lower precedence than --tls-key)
|
|
27
27
|
*/
|
|
28
28
|
|
|
29
|
+
import { MCP_INSTRUCTIONS } from "./mcp-meta.ts";
|
|
29
30
|
import { resolveVersion } from "./paths.ts";
|
|
30
31
|
|
|
31
32
|
const RESOLVED_VERSION = resolveVersion(import.meta.dirname);
|
|
@@ -243,6 +244,11 @@ const {
|
|
|
243
244
|
|
|
244
245
|
initDb();
|
|
245
246
|
|
|
247
|
+
/** MCP `instructions` string sent to clients on init. Exported so the TUI's
|
|
248
|
+
* `.instructions` dot-command can show the same text an agent sees. */
|
|
249
|
+
// MCP_INSTRUCTIONS now lives in mcp-meta.ts (importing it from browse.ts
|
|
250
|
+
// must not trigger this file's top-level CLI dispatch IIFE).
|
|
251
|
+
|
|
246
252
|
/** Factory: create a new McpServer with all tools registered.
|
|
247
253
|
* Called once for stdio, or per-session for HTTP transport. */
|
|
248
254
|
function createServer() {
|
|
@@ -251,7 +257,7 @@ const server = new McpServer({
|
|
|
251
257
|
name: "rosetta",
|
|
252
258
|
version: RESOLVED_VERSION,
|
|
253
259
|
}, {
|
|
254
|
-
instructions:
|
|
260
|
+
instructions: MCP_INSTRUCTIONS,
|
|
255
261
|
});
|
|
256
262
|
|
|
257
263
|
server.registerResource(
|
package/src/query.test.ts
CHANGED
|
@@ -2047,21 +2047,21 @@ describe("lookupGlossary", () => {
|
|
|
2047
2047
|
test("finds exact term match", () => {
|
|
2048
2048
|
const result = lookupGlossary("chr");
|
|
2049
2049
|
expect(result).not.toBeNull();
|
|
2050
|
-
expect(result
|
|
2051
|
-
expect(result
|
|
2052
|
-
expect(result
|
|
2050
|
+
expect(result?.term).toBe("chr");
|
|
2051
|
+
expect(result?.definition).toContain("Cloud Hosted Router");
|
|
2052
|
+
expect(result?.category).toBe("product");
|
|
2053
2053
|
});
|
|
2054
2054
|
|
|
2055
2055
|
test("case-insensitive lookup", () => {
|
|
2056
2056
|
const result = lookupGlossary("CHR");
|
|
2057
2057
|
expect(result).not.toBeNull();
|
|
2058
|
-
expect(result
|
|
2058
|
+
expect(result?.term).toBe("chr");
|
|
2059
2059
|
});
|
|
2060
2060
|
|
|
2061
2061
|
test("finds term by alias", () => {
|
|
2062
2062
|
const result = lookupGlossary("openvpn");
|
|
2063
2063
|
expect(result).not.toBeNull();
|
|
2064
|
-
expect(result
|
|
2064
|
+
expect(result?.term).toBe("ovpn");
|
|
2065
2065
|
});
|
|
2066
2066
|
|
|
2067
2067
|
test("returns null for unknown term", () => {
|
|
@@ -2075,8 +2075,8 @@ describe("lookupGlossary", () => {
|
|
|
2075
2075
|
test("search_hint field is populated", () => {
|
|
2076
2076
|
const result = lookupGlossary("capsman");
|
|
2077
2077
|
expect(result).not.toBeNull();
|
|
2078
|
-
expect(result
|
|
2079
|
-
expect(result
|
|
2078
|
+
expect(result?.search_hint).toBeTruthy();
|
|
2079
|
+
expect(result?.search_hint).toContain("CAPsMAN");
|
|
2080
2080
|
});
|
|
2081
2081
|
});
|
|
2082
2082
|
|
package/src/query.ts
CHANGED
|
@@ -1106,6 +1106,27 @@ function runCalloutsFtsQuery(
|
|
|
1106
1106
|
}
|
|
1107
1107
|
}
|
|
1108
1108
|
|
|
1109
|
+
/**
|
|
1110
|
+
* All callouts for a given page, sorted by source order. TUI-facing helper
|
|
1111
|
+
* for `cal` invoked inside a page context — replaces the broken
|
|
1112
|
+
* `searchCallouts("", undefined, …)` empty-FTS path which always returns [].
|
|
1113
|
+
*
|
|
1114
|
+
* Page metadata (`page_title`, `page_url`) is included so the rendering path
|
|
1115
|
+
* can reuse `renderCallouts()` without a second join.
|
|
1116
|
+
*/
|
|
1117
|
+
export function getPageCallouts(pageId: number, limit = 100): CalloutResult[] {
|
|
1118
|
+
return db
|
|
1119
|
+
.prepare(
|
|
1120
|
+
`SELECT c.type, c.content, pg.title as page_title, pg.url as page_url,
|
|
1121
|
+
pg.id as page_id, substr(c.content, 1, 200) as excerpt
|
|
1122
|
+
FROM callouts c
|
|
1123
|
+
JOIN pages pg ON pg.id = c.page_id
|
|
1124
|
+
WHERE c.page_id = ?
|
|
1125
|
+
ORDER BY c.sort_order LIMIT ?`,
|
|
1126
|
+
)
|
|
1127
|
+
.all(pageId, limit) as CalloutResult[];
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1109
1130
|
/** Diff two RouterOS versions — which command paths were added/removed between them. */
|
|
1110
1131
|
export type CommandDiffResult = {
|
|
1111
1132
|
from_version: string;
|