@tikoci/rosetta 0.8.1 → 0.8.2

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tikoci/rosetta",
3
- "version": "0.8.1",
3
+ "version": "0.8.2",
4
4
  "description": "RouterOS documentation as SQLite FTS5 — RAG search + command glossary via MCP",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/browse.ts CHANGED
@@ -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 commands.`,
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", "routeros_search_properties");
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 (type filter: cal warning)", "routeros_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", "routeros_search_videos");
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", "Go to previous view");
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(".routeros_search <q> [limit=N]", 38))} ${dim("Same query path as MCP, raw JSON output")}`);
1065
- out.push(` ${cyan(pad(".routeros_get_page <id> [section=]", 38))} ${dim("Probe page response with JSON")}`);
1066
- out.push(` ${cyan(pad(".routeros_lookup_property name= ...", 38))} ${dim("Direct property lookup")}`);
1067
- out.push(` ${cyan(pad(".help", 38))} ${dim("Full list of dot-commands")}`);
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 for that page.")}`);
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("")} Search once and exit (for piping)`);
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
- args[m[1]] = v;
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
- desc: "Full page by id or title (args: page=, max_length=, section=)",
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 available probes
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
- out.push(` ${cyan(`.${name}`)}`);
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
- } else {
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
- // Show callouts for current page
1392
- const results = searchCallouts("", undefined, 50);
1393
- const pageCallouts = results.filter((c) => c.page_id === (ctx as { pageId: number }).pageId);
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
- return;
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 query from CLI args
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 doSearch(initialQuery);
2121
+ await dispatch(initialQuery);
1958
2122
  if (onceMode) process.exit(0);
1959
2123
  }
1960
2124
 
@@ -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!.subshell).toBe(true);
219
- expect(findCmd!.path).toBe('/ip/address');
218
+ expect(findCmd?.subshell).toBe(true);
219
+ expect(findCmd?.path).toBe('/ip/address');
220
220
  expect(setCmd).toBeDefined();
221
- expect(setCmd!.path).toBe('/ip/address');
222
- expect(setCmd!.verb).toBe('set');
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!.path).toBe('/ip/route');
232
- expect(findCmd!.subshell).toBe(true);
231
+ expect(findCmd?.path).toBe('/ip/route');
232
+ expect(findCmd?.subshell).toBe(true);
233
233
  expect(getCmd).toBeDefined();
234
- expect(getCmd!.path).toBe('/ip/route');
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!.path).toBe('/ip/address');
242
- expect(findCmd!.subshell).toBe(true);
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!.path).toBe('/interface/bridge');
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!.subshell).toBe(true);
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!.path).toBe('/ip/address');
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!.path).toBe('/ip/address');
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!.path).toBe('/system/resource');
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!.path).toBe('/ip/address');
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!.path).toBe('/ip/firewall/filter');
397
- expect(addCmd!.args.some(a => a.startsWith('chain='))).toBe(true);
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!.subshell).toBe(true);
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!.path).toBe('/ip/dhcp-server');
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!.path).toBe('/ip/route');
426
- expect(getCmd!.path).toBe('/ip/route');
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!.path).toBe('/ip/address');
436
+ expect(setCmd?.path).toBe('/ip/address');
437
437
  expect(findCmd).toBeDefined();
438
- expect(findCmd!.path).toBe('/ip/address');
439
- expect(findCmd!.subshell).toBe(true);
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!.path).toBe('/ip/address');
459
+ expect(printCmd?.path).toBe('/ip/address');
460
460
  expect(addCmd).toBeDefined();
461
- expect(addCmd!.path).toBe('/ip/route');
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!.path).toBe('/interface/bridge');
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!.path).toBe('/interface');
518
+ expect(printCmd?.path).toBe('/interface');
519
519
  });
520
520
  });
@@ -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: "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.",
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!.term).toBe("chr");
2051
- expect(result!.definition).toContain("Cloud Hosted Router");
2052
- expect(result!.category).toBe("product");
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!.term).toBe("chr");
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!.term).toBe("ovpn");
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!.search_hint).toBeTruthy();
2079
- expect(result!.search_hint).toContain("CAPsMAN");
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;