@tikoci/rosetta 0.6.7 → 0.6.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -0
- package/package.json +1 -1
- package/src/browse.ts +211 -21
- package/src/db.ts +113 -0
- package/src/extract-all-versions.ts +83 -21
- package/src/extract-schema.ts +596 -0
- package/src/extract-skills.ts +390 -0
- package/src/mcp.ts +107 -8
- package/src/paths.ts +9 -2
- package/src/query.test.ts +128 -1
- package/src/query.ts +157 -9
- package/src/release.test.ts +29 -0
- package/src/schema-roundtrip.test.ts +373 -0
package/README.md
CHANGED
|
@@ -298,6 +298,8 @@ The server exposes 16 tools designed to work together — agents start with `rou
|
|
|
298
298
|
|
|
299
299
|
Each tool description includes workflow arrows (`→ next_tool`) and empty-result hints so agents chain tools effectively.
|
|
300
300
|
|
|
301
|
+
The server also exposes **MCP Resources** for bulk data and supplemental content — CSV datasets (`rosetta://datasets/...`), schema documentation (`rosetta://schema...`), and **agent skill guides** (`rosetta://skills/{name}`) from [tikoci/routeros-skills](https://github.com/tikoci/routeros-skills). Skills are community-created, human-reviewed guides served with provenance attribution. See [MANUAL.md](MANUAL.md) for details.
|
|
302
|
+
|
|
301
303
|
|
|
302
304
|
## RTFM for Details
|
|
303
305
|
For additional install options, HTTP transport configuration, data source details, and the database schema, see [MANUAL.md](MANUAL.md).
|
package/package.json
CHANGED
package/src/browse.ts
CHANGED
|
@@ -27,12 +27,15 @@ import type {
|
|
|
27
27
|
} from "./query.ts";
|
|
28
28
|
import {
|
|
29
29
|
browseCommands,
|
|
30
|
+
browseCommandsAtVersion,
|
|
30
31
|
checkCommandVersions,
|
|
31
32
|
diffCommandVersions,
|
|
32
33
|
fetchCurrentVersions,
|
|
33
34
|
getDudePage,
|
|
34
35
|
getPage,
|
|
36
|
+
getSkill,
|
|
35
37
|
getTestResultMeta,
|
|
38
|
+
listSkills,
|
|
36
39
|
lookupProperty,
|
|
37
40
|
searchCallouts,
|
|
38
41
|
searchChangelogs,
|
|
@@ -200,6 +203,7 @@ type Context =
|
|
|
200
203
|
| { type: "changelogs"; results: ChangelogResult[] }
|
|
201
204
|
| { type: "videos"; query: string; results: VideoSearchResult[] }
|
|
202
205
|
| { type: "dude"; query: string; results: DudeSearchResult[] }
|
|
206
|
+
| { type: "skills" }
|
|
203
207
|
| { type: "properties"; query: string; pageId?: number; results: Array<{ name: string; page_id: number; page_title: string }> }
|
|
204
208
|
| { type: "diff" }
|
|
205
209
|
| { type: "vcheck"; path: string };
|
|
@@ -240,6 +244,7 @@ function contextLabel(c: Context): string {
|
|
|
240
244
|
case "changelogs": return "changelogs";
|
|
241
245
|
case "videos": return truncate(`vid: "${c.query}"`, 30);
|
|
242
246
|
case "dude": return truncate(`dude: "${c.query}"`, 30);
|
|
247
|
+
case "skills": return "skills";
|
|
243
248
|
case "diff": return "diff";
|
|
244
249
|
case "vcheck": return truncate(`vc: ${c.path}`, 30);
|
|
245
250
|
}
|
|
@@ -250,12 +255,17 @@ function contextLabel(c: Context): string {
|
|
|
250
255
|
function renderWelcome(): string {
|
|
251
256
|
const stats = getDbStats();
|
|
252
257
|
const version = resolveVersion(import.meta.dirname);
|
|
258
|
+
const dbWarning = stats.commands < 1000
|
|
259
|
+
? yellow(`⚠ DB has only ${fmt(stats.commands)} commands — use real DB: --db ~/.rosetta/ros-help.db`)
|
|
260
|
+
: null;
|
|
253
261
|
const lines = [
|
|
254
262
|
`RouterOS Documentation Browser ${dim(`v${version}`)}`,
|
|
255
263
|
`${fmt(stats.pages)} pages · ${fmt(stats.properties)} properties · ${fmt(stats.commands)} commands`,
|
|
256
264
|
`${fmt(stats.devices)} devices · ${fmt(stats.callouts)} callouts · ${fmt(stats.ros_versions)} versions`,
|
|
257
265
|
...(stats.videos > 0 ? [`${fmt(stats.videos)} videos · ${fmt(stats.video_segments)} transcript segments`] : []),
|
|
266
|
+
...(stats.skills > 0 ? [`${fmt(stats.skills)} agent skills ${dim("(tikoci — community content)")}`] : []),
|
|
258
267
|
"",
|
|
268
|
+
...(dbWarning ? [dbWarning, ""] : []),
|
|
259
269
|
`Type a search query, or ${bold("help")} for commands.`,
|
|
260
270
|
];
|
|
261
271
|
return box(lines, "rosetta");
|
|
@@ -432,6 +442,11 @@ function renderCommandTree(path: string, children: Array<{
|
|
|
432
442
|
description: string | null;
|
|
433
443
|
page_title: string | null;
|
|
434
444
|
page_url: string | null;
|
|
445
|
+
dir_role?: string | null;
|
|
446
|
+
data_type?: string | null;
|
|
447
|
+
enum_values?: string | null;
|
|
448
|
+
_arch?: string | null;
|
|
449
|
+
completion?: Record<string, { style?: string; preference?: number; desc?: string }> | null;
|
|
435
450
|
}>): string {
|
|
436
451
|
const out: string[] = [];
|
|
437
452
|
out.push(` ${bold(path || "/")} ${dim(`(${children.length} children)`)}`);
|
|
@@ -441,23 +456,58 @@ function renderCommandTree(path: string, children: Array<{
|
|
|
441
456
|
const cmds = children.filter((c) => c.type === "cmd");
|
|
442
457
|
const args = children.filter((c) => c.type === "arg");
|
|
443
458
|
|
|
459
|
+
let globalIdx = 0;
|
|
444
460
|
for (const group of [
|
|
445
|
-
{ items: dirs, icon: "📁"
|
|
446
|
-
{ items: cmds, icon: "⚡"
|
|
447
|
-
{ items: args, icon: "
|
|
461
|
+
{ items: dirs, icon: "📁" },
|
|
462
|
+
{ items: cmds, icon: "⚡" },
|
|
463
|
+
{ items: args, icon: dim("·") },
|
|
448
464
|
]) {
|
|
449
465
|
if (group.items.length === 0) continue;
|
|
450
|
-
for (
|
|
466
|
+
for (let i = 0; i < group.items.length; i++) {
|
|
467
|
+
const c = group.items[i];
|
|
468
|
+
globalIdx++;
|
|
469
|
+
const num = dim(`${String(globalIdx).padStart(3)} `);
|
|
451
470
|
const icon = group.icon;
|
|
452
471
|
const name = c.type === "dir" ? bold(c.name) : c.name;
|
|
453
|
-
|
|
472
|
+
|
|
473
|
+
// Build type hint: enum values, data_type, or completion-derived values
|
|
474
|
+
let typeHint = "";
|
|
475
|
+
if (c.data_type === "enum" && c.enum_values) {
|
|
476
|
+
try {
|
|
477
|
+
const vals = JSON.parse(c.enum_values) as string[];
|
|
478
|
+
const shown = vals.length > 6 ? `${vals.slice(0, 6).join("|")}|…` : vals.join("|");
|
|
479
|
+
typeHint = shown;
|
|
480
|
+
} catch {
|
|
481
|
+
typeHint = `<${c.data_type}>`;
|
|
482
|
+
}
|
|
483
|
+
} else if (c.data_type === "time" || c.data_type === "integer" || c.data_type === "range") {
|
|
484
|
+
// Already visible via description range — just tag the type
|
|
485
|
+
typeHint = `<${c.data_type}>`;
|
|
486
|
+
} else if (c.data_type) {
|
|
487
|
+
typeHint = `<${c.data_type}>`;
|
|
488
|
+
} else if (c.completion && Object.keys(c.completion).length > 0) {
|
|
489
|
+
// No parsed data_type but completion values exist — surface them
|
|
490
|
+
const keys = Object.keys(c.completion).filter((k) => k !== "");
|
|
491
|
+
if (keys.length > 0) {
|
|
492
|
+
const shown = keys.length > 6 ? `${keys.slice(0, 6).join("|")}|…` : keys.join("|");
|
|
493
|
+
typeHint = shown;
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
const parts: string[] = [];
|
|
498
|
+
if (c.description) parts.push(truncate(c.description, 40));
|
|
499
|
+
if (typeHint) parts.push(typeHint);
|
|
500
|
+
if (c.dir_role && c.dir_role !== "namespace") parts.push(`[${c.dir_role}]`);
|
|
501
|
+
const desc = parts.length > 0 ? dim(` — ${parts.join(" ")}`) : "";
|
|
502
|
+
const archTag = c._arch ? yellow(` [${c._arch}]`) : "";
|
|
454
503
|
const pageLink = c.page_url ? ` ${cyan(link(c.page_url, dim("📄")))}` : "";
|
|
455
|
-
out.push(` ${icon} ${name}${desc}${pageLink}`);
|
|
504
|
+
out.push(` ${num}${icon} ${name}${desc}${archTag}${pageLink}`);
|
|
456
505
|
}
|
|
506
|
+
out.push("");
|
|
457
507
|
}
|
|
458
508
|
|
|
459
|
-
out.push("");
|
|
460
509
|
const hints: string[] = [
|
|
510
|
+
`${cyan("[N]")} select`,
|
|
461
511
|
`${cyan("[cmd <child>]")} drill down`,
|
|
462
512
|
`${cyan("[page <id>]")} view linked page`,
|
|
463
513
|
`${cyan("[b]")} back`,
|
|
@@ -847,6 +897,8 @@ function renderHelp(): string {
|
|
|
847
897
|
cmd("changelog [query]", "cl", "Search changelogs (cl 7.22, cl breaking)", "routeros_search_changelogs");
|
|
848
898
|
cmd("videos <query>", "vid", "Search video transcripts", "routeros_search_videos");
|
|
849
899
|
cmd("dude <query>", "", "Search archived Dude wiki docs", "routeros_dude_search");
|
|
900
|
+
cmd("skills", "", "List agent skill guides", "rosetta://skills");
|
|
901
|
+
cmd("skill <name>", "", "View a skill guide", "rosetta://skills/{name}");
|
|
850
902
|
cmd("diff <from> <to> [path]", "", "Command tree diff between versions", "routeros_command_diff");
|
|
851
903
|
cmd("vcheck <path>", "vc", "Version range for a command path", "routeros_command_version_check");
|
|
852
904
|
cmd("versions", "ver", "Live-fetch current RouterOS versions", "routeros_current_versions");
|
|
@@ -859,6 +911,11 @@ function renderHelp(): string {
|
|
|
859
911
|
out.push(` ${dim("Navigation: type a number to select from results.")}`);
|
|
860
912
|
out.push(` ${dim("After viewing a page, [p] = properties for that page.")}`);
|
|
861
913
|
out.push(` ${dim("URLs are clickable in supported terminals (iTerm2, etc.).")}`);
|
|
914
|
+
out.push(` ${dim("cmd supports @version suffix: cmd /ip/address @7.15")}`);
|
|
915
|
+
out.push("");
|
|
916
|
+
out.push(` ${bold("CLI flags")}`);
|
|
917
|
+
out.push(` ${cyan(pad("--db <path>", 26))} ${dim("")} Use a specific database file`);
|
|
918
|
+
out.push(` ${cyan(pad("--once", 26))} ${dim("")} Search once and exit (for piping)`);
|
|
862
919
|
|
|
863
920
|
return out.join("\n");
|
|
864
921
|
}
|
|
@@ -998,6 +1055,17 @@ async function dispatch(input: string): Promise<void> {
|
|
|
998
1055
|
return;
|
|
999
1056
|
}
|
|
1000
1057
|
|
|
1058
|
+
case "skills": {
|
|
1059
|
+
await doListSkills();
|
|
1060
|
+
return;
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
case "skill": {
|
|
1064
|
+
if (!rest) { console.log(dim(" Usage: skill <name>")); return; }
|
|
1065
|
+
await doViewSkill(rest);
|
|
1066
|
+
return;
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1001
1069
|
case "diff": {
|
|
1002
1070
|
const diffParts = rest.split(/\s+/);
|
|
1003
1071
|
if (diffParts.length < 2) {
|
|
@@ -1076,6 +1144,38 @@ async function handleNumberSelect(idx: number): Promise<void> {
|
|
|
1076
1144
|
}
|
|
1077
1145
|
return;
|
|
1078
1146
|
}
|
|
1147
|
+
if (ctx.type === "skills") {
|
|
1148
|
+
const skills = listSkills();
|
|
1149
|
+
if (skills[idx]) {
|
|
1150
|
+
await doViewSkill(skills[idx].name);
|
|
1151
|
+
}
|
|
1152
|
+
return;
|
|
1153
|
+
}
|
|
1154
|
+
if (ctx.type === "commands") {
|
|
1155
|
+
// Re-query and navigate to the Nth child (dirs and cmds drill in; args show inline)
|
|
1156
|
+
const children = browseCommands(ctx.path);
|
|
1157
|
+
const child = children[idx];
|
|
1158
|
+
if (child) {
|
|
1159
|
+
if (child.type === "dir" || child.type === "cmd") {
|
|
1160
|
+
await doCommandTree(child.path);
|
|
1161
|
+
} else {
|
|
1162
|
+
// arg — display its details inline
|
|
1163
|
+
const parts: string[] = [];
|
|
1164
|
+
if (child.description) parts.push(child.description);
|
|
1165
|
+
if (child.data_type) parts.push(`type: ${child.data_type}`);
|
|
1166
|
+
if (child.enum_values) {
|
|
1167
|
+
try { parts.push(`values: ${JSON.parse(child.enum_values).join("|")}`); } catch { /* ignore */ }
|
|
1168
|
+
} else if (child.completion) {
|
|
1169
|
+
const keys = Object.keys(child.completion).filter((k) => k !== "");
|
|
1170
|
+
if (keys.length > 0) parts.push(`values: ${keys.join("|")}`);
|
|
1171
|
+
}
|
|
1172
|
+
console.log(`\n ${bold(child.name)} ${dim(`(${child.path})`)}`);
|
|
1173
|
+
if (parts.length > 0) console.log(` ${parts.join(" · ")}`);
|
|
1174
|
+
console.log("");
|
|
1175
|
+
}
|
|
1176
|
+
return;
|
|
1177
|
+
}
|
|
1178
|
+
}
|
|
1079
1179
|
if (ctx.type === "properties" && ctx.results[idx]) {
|
|
1080
1180
|
const p = ctx.results[idx];
|
|
1081
1181
|
await doPage(String(p.page_id));
|
|
@@ -1166,23 +1266,58 @@ async function doSearchProperties(query: string): Promise<void> {
|
|
|
1166
1266
|
}
|
|
1167
1267
|
|
|
1168
1268
|
async function doCommandTree(path: string): Promise<void> {
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1269
|
+
// Extract optional @version suffix: "cmd /ip/address @7.15" or "cmd /ip add @7.20"
|
|
1270
|
+
let version: string | undefined;
|
|
1271
|
+
let pathArg = path;
|
|
1272
|
+
const versionMatch = path.match(/^(.*?)\s*@(\S+)$/);
|
|
1273
|
+
if (versionMatch) {
|
|
1274
|
+
pathArg = versionMatch[1].trim();
|
|
1275
|
+
version = versionMatch[2];
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
let cmdPath: string;
|
|
1279
|
+
if (!pathArg) {
|
|
1280
|
+
if (ctx.type === "page" && (ctx as { commandPath?: string }).commandPath) {
|
|
1281
|
+
// biome-ignore lint/style/noNonNullAssertion: narrowed above
|
|
1282
|
+
cmdPath = (ctx as { commandPath?: string }).commandPath!;
|
|
1283
|
+
} else if (ctx.type === "commands") {
|
|
1284
|
+
cmdPath = ctx.path;
|
|
1285
|
+
} else {
|
|
1286
|
+
cmdPath = "";
|
|
1287
|
+
}
|
|
1288
|
+
} else if (pathArg.startsWith("/")) {
|
|
1289
|
+
cmdPath = pathArg;
|
|
1290
|
+
} else if (ctx.type === "commands") {
|
|
1291
|
+
// Relative segment: "cmd add" at /ip/address → /ip/address/add
|
|
1292
|
+
cmdPath = `${ctx.path}/${pathArg}`;
|
|
1293
|
+
} else {
|
|
1294
|
+
cmdPath = `/${pathArg}`;
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
const children = version
|
|
1298
|
+
? browseCommandsAtVersion(cmdPath, version)
|
|
1299
|
+
: browseCommands(cmdPath);
|
|
1300
|
+
|
|
1177
1301
|
if (children.length === 0) {
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
if (
|
|
1181
|
-
console.log(`
|
|
1302
|
+
// Check if path itself exists as a leaf node
|
|
1303
|
+
const vcResult = cmdPath ? checkCommandVersions(cmdPath) : null;
|
|
1304
|
+
if (vcResult && vcResult.versions.length > 0) {
|
|
1305
|
+
console.log(` ${bold(cmdPath)} ${dim("(leaf — no children)")}`);
|
|
1306
|
+
console.log(` ${dim("First seen:")} ${bold(vcResult.first_seen ?? "?")} ${dim("Last seen:")} ${bold(vcResult.last_seen ?? "?")}`);
|
|
1307
|
+
console.log(` ${dim("Present in")} ${bold(String(vcResult.versions.length))} ${dim("versions")}`);
|
|
1308
|
+
if (vcResult.note) console.log(` ${dim(vcResult.note)}`);
|
|
1309
|
+
console.log(` ${dim("Try:")} ${cyan("b")} ${dim("to go up, or")} ${cyan(`s ${cmdPath.split("/").pop() ?? ""}`)} ${dim("to search docs")}`);
|
|
1310
|
+
} else {
|
|
1311
|
+
console.log(dim(` No results for "${cmdPath}".`));
|
|
1312
|
+
const term = cmdPath.split("/").filter(Boolean).pop() ?? "";
|
|
1313
|
+
if (term) {
|
|
1314
|
+
console.log(` ${dim("Try:")} ${cyan(`s ${term}`)} ${dim("(search docs) or")} ${cyan(`vc ${cmdPath}`)} ${dim("(version check)")}`);
|
|
1315
|
+
}
|
|
1182
1316
|
}
|
|
1183
1317
|
return;
|
|
1184
1318
|
}
|
|
1185
|
-
|
|
1319
|
+
const label = version ? `${cmdPath} @${version}` : cmdPath;
|
|
1320
|
+
await paged(renderCommandTree(label, children));
|
|
1186
1321
|
pushCtx({ type: "commands", path: cmdPath });
|
|
1187
1322
|
}
|
|
1188
1323
|
|
|
@@ -1320,6 +1455,54 @@ async function doSearchDude(query: string): Promise<void> {
|
|
|
1320
1455
|
pushCtx({ type: "dude", query, results });
|
|
1321
1456
|
}
|
|
1322
1457
|
|
|
1458
|
+
async function doListSkills(): Promise<void> {
|
|
1459
|
+
const skills = listSkills();
|
|
1460
|
+
if (skills.length === 0) {
|
|
1461
|
+
console.log(dim(" No skills available. Run: make extract-skills"));
|
|
1462
|
+
return;
|
|
1463
|
+
}
|
|
1464
|
+
const out: string[] = [];
|
|
1465
|
+
out.push(` ${bold(`${skills.length} Agent Skills`)} ${dim("(tikoci/routeros-skills — community content)")}`);
|
|
1466
|
+
out.push(` ${yellow("⚠ AI-generated, human-reviewed. NOT official MikroTik docs.")}`);
|
|
1467
|
+
out.push("");
|
|
1468
|
+
skills.forEach((s, i) => {
|
|
1469
|
+
out.push(` ${cyan(String(i + 1).padStart(2))}. ${bold(s.name)} ${dim(`${s.word_count} words, ${s.ref_count} refs`)}`);
|
|
1470
|
+
out.push(` ${s.description}`);
|
|
1471
|
+
});
|
|
1472
|
+
out.push("");
|
|
1473
|
+
out.push(` ${dim("Type a number to view, or: skill <name>")}`);
|
|
1474
|
+
await paged(out.join("\n"));
|
|
1475
|
+
pushCtx({ type: "skills" });
|
|
1476
|
+
}
|
|
1477
|
+
|
|
1478
|
+
async function doViewSkill(name: string): Promise<void> {
|
|
1479
|
+
const skill = getSkill(name);
|
|
1480
|
+
if (!skill) {
|
|
1481
|
+
console.log(dim(` Skill "${name}" not found. Use 'skills' to list available skills.`));
|
|
1482
|
+
return;
|
|
1483
|
+
}
|
|
1484
|
+
const out: string[] = [];
|
|
1485
|
+
out.push(` ${yellow("⚠ PROVENANCE: Community content from tikoci/routeros-skills")}`);
|
|
1486
|
+
out.push(` ${yellow(" NOT official MikroTik documentation. May contain errors.")}`);
|
|
1487
|
+
out.push(` ${dim(`Source: ${link(skill.source_url)}`)}`);
|
|
1488
|
+
out.push("");
|
|
1489
|
+
out.push(` ${bold(skill.name)} ${dim(`${skill.word_count} words`)}`);
|
|
1490
|
+
out.push(` ${skill.description}`);
|
|
1491
|
+
out.push("");
|
|
1492
|
+
out.push(skill.content);
|
|
1493
|
+
if (skill.references.length > 0) {
|
|
1494
|
+
out.push("");
|
|
1495
|
+
out.push(` ${bold("Reference Files")} ${dim(`(${skill.references.length} files)`)}`);
|
|
1496
|
+
out.push("");
|
|
1497
|
+
for (const ref of skill.references) {
|
|
1498
|
+
out.push(` ${cyan("─")} ${bold(ref.filename)} ${dim(`${ref.word_count} words`)}`);
|
|
1499
|
+
}
|
|
1500
|
+
out.push("");
|
|
1501
|
+
out.push(` ${dim("To view a reference: skill <name> refs")}`);
|
|
1502
|
+
}
|
|
1503
|
+
await paged(out.join("\n"));
|
|
1504
|
+
}
|
|
1505
|
+
|
|
1323
1506
|
async function doDiff(from: string, to: string, pathPrefix?: string): Promise<void> {
|
|
1324
1507
|
const result = diffCommandVersions(from, to, pathPrefix);
|
|
1325
1508
|
await paged(renderDiff(result));
|
|
@@ -1355,7 +1538,14 @@ async function main() {
|
|
|
1355
1538
|
|
|
1356
1539
|
const args = process.argv.slice(2);
|
|
1357
1540
|
const onceMode = args.includes("--once");
|
|
1358
|
-
|
|
1541
|
+
// Filter out --once, browse, and --db <path> so they don't become search queries
|
|
1542
|
+
const dbArgIdx = args.indexOf("--db");
|
|
1543
|
+
const queryArgs = args.filter((a, i) => {
|
|
1544
|
+
if (a === "--once" || a === "browse") return false;
|
|
1545
|
+
if (a === "--db") return false;
|
|
1546
|
+
if (dbArgIdx !== -1 && i === dbArgIdx + 1) return false;
|
|
1547
|
+
return true;
|
|
1548
|
+
});
|
|
1359
1549
|
const initialQuery = queryArgs.join(" ");
|
|
1360
1550
|
|
|
1361
1551
|
// Welcome banner (only in interactive mode)
|
package/src/db.ts
CHANGED
|
@@ -13,6 +13,8 @@
|
|
|
13
13
|
* commands — RouterOS command tree from inspect.json (latest version)
|
|
14
14
|
* command_versions — junction: which commands exist in which RouterOS versions
|
|
15
15
|
* ros_versions — metadata for each extracted RouterOS version
|
|
16
|
+
* schema_nodes — structured command tree from deep-inspect.json (richer desc, arch, completion)
|
|
17
|
+
* schema_node_presence — junction: which schema_nodes exist in which versions
|
|
16
18
|
* devices — MikroTik product hardware specs from product matrix CSV
|
|
17
19
|
* devices_fts — FTS5 over product name, code, architecture, CPU
|
|
18
20
|
* changelogs — parsed changelog entries per RouterOS version
|
|
@@ -24,6 +26,9 @@
|
|
|
24
26
|
* dude_pages — The Dude documentation pages (archived from wiki.mikrotik.com via Wayback Machine)
|
|
25
27
|
* dude_pages_fts — FTS5 over title, path, text, code
|
|
26
28
|
* dude_images — screenshot images from The Dude wiki pages
|
|
29
|
+
* skills — agent skill guides (from tikoci/routeros-skills, community content)
|
|
30
|
+
* skills_fts — FTS5 over name, description, content
|
|
31
|
+
* skill_references — reference documents for each skill
|
|
27
32
|
*/
|
|
28
33
|
|
|
29
34
|
import sqlite from "bun:sqlite";
|
|
@@ -282,6 +287,20 @@ export function initDb() {
|
|
|
282
287
|
PRIMARY KEY (version, arch)
|
|
283
288
|
);`);
|
|
284
289
|
|
|
290
|
+
// Migration: add deep-inspect _meta provenance columns if missing
|
|
291
|
+
{
|
|
292
|
+
const rvCols2 = db.prepare("PRAGMA table_info(ros_versions)").all() as Array<{ name: string }>;
|
|
293
|
+
if (rvCols2.length > 0 && !rvCols2.some((c) => c.name === "api_transport")) {
|
|
294
|
+
db.run("ALTER TABLE ros_versions ADD COLUMN api_transport TEXT;");
|
|
295
|
+
}
|
|
296
|
+
if (rvCols2.length > 0 && !rvCols2.some((c) => c.name === "enrichment_duration_ms")) {
|
|
297
|
+
db.run("ALTER TABLE ros_versions ADD COLUMN enrichment_duration_ms INTEGER;");
|
|
298
|
+
}
|
|
299
|
+
if (rvCols2.length > 0 && !rvCols2.some((c) => c.name === "crash_paths_safe")) {
|
|
300
|
+
db.run("ALTER TABLE ros_versions ADD COLUMN crash_paths_safe TEXT;");
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
285
304
|
db.run(`CREATE TABLE IF NOT EXISTS command_versions (
|
|
286
305
|
command_path TEXT NOT NULL,
|
|
287
306
|
ros_version TEXT NOT NULL,
|
|
@@ -290,6 +309,53 @@ export function initDb() {
|
|
|
290
309
|
|
|
291
310
|
db.run(`CREATE INDEX IF NOT EXISTS idx_cmdver_version ON command_versions(ros_version);`);
|
|
292
311
|
|
|
312
|
+
// -- Schema nodes (structured command tree from deep-inspect.json) --
|
|
313
|
+
//
|
|
314
|
+
// schema_nodes replaces the flat commands table with richer structure:
|
|
315
|
+
// parsed desc fields (data_type, enum_values, range), arch tagging,
|
|
316
|
+
// dir_role classification, and a JSON _attrs catch-all for completion
|
|
317
|
+
// data and future metadata like _package.
|
|
318
|
+
//
|
|
319
|
+
// The `commands` table is regenerated from schema_nodes at import time
|
|
320
|
+
// by extract-schema.ts — existing queries continue to read `commands`
|
|
321
|
+
// with zero downstream churn.
|
|
322
|
+
|
|
323
|
+
db.run(`CREATE TABLE IF NOT EXISTS schema_nodes (
|
|
324
|
+
id INTEGER PRIMARY KEY,
|
|
325
|
+
path TEXT NOT NULL,
|
|
326
|
+
name TEXT NOT NULL,
|
|
327
|
+
type TEXT NOT NULL,
|
|
328
|
+
parent_id INTEGER REFERENCES schema_nodes(id),
|
|
329
|
+
parent_path TEXT,
|
|
330
|
+
dir_role TEXT,
|
|
331
|
+
desc_raw TEXT,
|
|
332
|
+
data_type TEXT,
|
|
333
|
+
enum_values TEXT,
|
|
334
|
+
enum_multi INTEGER,
|
|
335
|
+
type_tag TEXT,
|
|
336
|
+
range_min TEXT,
|
|
337
|
+
range_max TEXT,
|
|
338
|
+
max_length INTEGER,
|
|
339
|
+
_arch TEXT,
|
|
340
|
+
_package TEXT,
|
|
341
|
+
_attrs TEXT,
|
|
342
|
+
page_id INTEGER REFERENCES pages(id),
|
|
343
|
+
UNIQUE(path, type)
|
|
344
|
+
);`);
|
|
345
|
+
|
|
346
|
+
db.run(`CREATE INDEX IF NOT EXISTS idx_sn_parent ON schema_nodes(parent_path);`);
|
|
347
|
+
db.run(`CREATE INDEX IF NOT EXISTS idx_sn_type ON schema_nodes(type);`);
|
|
348
|
+
db.run(`CREATE INDEX IF NOT EXISTS idx_sn_path ON schema_nodes(path);`);
|
|
349
|
+
db.run(`CREATE INDEX IF NOT EXISTS idx_sn_page ON schema_nodes(page_id);`);
|
|
350
|
+
|
|
351
|
+
db.run(`CREATE TABLE IF NOT EXISTS schema_node_presence (
|
|
352
|
+
node_id INTEGER NOT NULL REFERENCES schema_nodes(id),
|
|
353
|
+
version TEXT NOT NULL,
|
|
354
|
+
PRIMARY KEY (node_id, version)
|
|
355
|
+
);`);
|
|
356
|
+
|
|
357
|
+
db.run(`CREATE INDEX IF NOT EXISTS idx_snp_version ON schema_node_presence(version);`);
|
|
358
|
+
|
|
293
359
|
// -- Devices (MikroTik product matrix) --
|
|
294
360
|
|
|
295
361
|
db.run(`CREATE TABLE IF NOT EXISTS devices (
|
|
@@ -547,6 +613,49 @@ export function initDb() {
|
|
|
547
613
|
);`);
|
|
548
614
|
|
|
549
615
|
db.run(`CREATE INDEX IF NOT EXISTS idx_dude_images_page ON dude_images(page_id);`);
|
|
616
|
+
|
|
617
|
+
// -- Skills (agent guides from tikoci/routeros-skills — community content, NOT official MikroTik docs) --
|
|
618
|
+
|
|
619
|
+
db.run(`CREATE TABLE IF NOT EXISTS skills (
|
|
620
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
621
|
+
name TEXT NOT NULL UNIQUE,
|
|
622
|
+
description TEXT,
|
|
623
|
+
content TEXT NOT NULL,
|
|
624
|
+
source_repo TEXT NOT NULL DEFAULT 'tikoci/routeros-skills',
|
|
625
|
+
source_sha TEXT,
|
|
626
|
+
source_url TEXT,
|
|
627
|
+
word_count INTEGER,
|
|
628
|
+
extracted_at TEXT
|
|
629
|
+
);`);
|
|
630
|
+
|
|
631
|
+
db.run(`CREATE VIRTUAL TABLE IF NOT EXISTS skills_fts USING fts5(
|
|
632
|
+
name, description, content,
|
|
633
|
+
content=skills, content_rowid=id,
|
|
634
|
+
tokenize='porter unicode61'
|
|
635
|
+
);`);
|
|
636
|
+
|
|
637
|
+
db.run(`CREATE TRIGGER IF NOT EXISTS skills_ai AFTER INSERT ON skills BEGIN
|
|
638
|
+
INSERT INTO skills_fts(rowid, name, description, content) VALUES (new.id, new.name, new.description, new.content);
|
|
639
|
+
END;`);
|
|
640
|
+
db.run(`CREATE TRIGGER IF NOT EXISTS skills_ad AFTER DELETE ON skills BEGIN
|
|
641
|
+
INSERT INTO skills_fts(skills_fts, rowid, name, description, content) VALUES ('delete', old.id, old.name, old.description, old.content);
|
|
642
|
+
END;`);
|
|
643
|
+
db.run(`CREATE TRIGGER IF NOT EXISTS skills_au AFTER UPDATE ON skills BEGIN
|
|
644
|
+
INSERT INTO skills_fts(skills_fts, rowid, name, description, content) VALUES ('delete', old.id, old.name, old.description, old.content);
|
|
645
|
+
INSERT INTO skills_fts(rowid, name, description, content) VALUES (new.id, new.name, new.description, new.content);
|
|
646
|
+
END;`);
|
|
647
|
+
|
|
648
|
+
db.run(`CREATE TABLE IF NOT EXISTS skill_references (
|
|
649
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
650
|
+
skill_id INTEGER NOT NULL REFERENCES skills(id),
|
|
651
|
+
path TEXT NOT NULL,
|
|
652
|
+
filename TEXT NOT NULL,
|
|
653
|
+
content TEXT NOT NULL,
|
|
654
|
+
word_count INTEGER,
|
|
655
|
+
UNIQUE(skill_id, path)
|
|
656
|
+
);`);
|
|
657
|
+
|
|
658
|
+
db.run(`CREATE INDEX IF NOT EXISTS idx_skill_refs_skill ON skill_references(skill_id);`);
|
|
550
659
|
}
|
|
551
660
|
|
|
552
661
|
/**
|
|
@@ -596,6 +705,10 @@ export function getDbStats() {
|
|
|
596
705
|
video_segments: count("SELECT COUNT(*) AS c FROM video_segments"),
|
|
597
706
|
dude_pages: count("SELECT COUNT(*) AS c FROM dude_pages"),
|
|
598
707
|
dude_images: count("SELECT COUNT(*) AS c FROM dude_images"),
|
|
708
|
+
skills: count("SELECT COUNT(*) AS c FROM skills"),
|
|
709
|
+
skill_references: count("SELECT COUNT(*) AS c FROM skill_references"),
|
|
710
|
+
schema_nodes: count("SELECT COUNT(*) AS c FROM schema_nodes"),
|
|
711
|
+
schema_node_presence: count("SELECT COUNT(*) AS c FROM schema_node_presence"),
|
|
599
712
|
...(() => {
|
|
600
713
|
// Semantic version sort — SQL MIN/MAX is lexicographic ("7.10" < "7.9")
|
|
601
714
|
const versions = (db.prepare("SELECT DISTINCT version FROM ros_versions").all() as Array<{ version: string }>).map((r) => r.version);
|