@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
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* extract-skills.ts — Extract agent skill guides from tikoci/routeros-skills.
|
|
4
|
+
*
|
|
5
|
+
* Fetches SKILL.md files and reference documents from GitHub (or a local path),
|
|
6
|
+
* parses YAML frontmatter, and populates skills + skill_references tables.
|
|
7
|
+
*
|
|
8
|
+
* Usage:
|
|
9
|
+
* bun run src/extract-skills.ts # Fetch from GitHub API
|
|
10
|
+
* bun run src/extract-skills.ts /path/to/routeros-skills # Use local directory
|
|
11
|
+
* bun run src/extract-skills.ts --from-cache # Re-extract from cached skills/ dir
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
15
|
+
import { join } from "node:path";
|
|
16
|
+
import { db, initDb } from "./db.ts";
|
|
17
|
+
|
|
18
|
+
// ── Configuration ──
|
|
19
|
+
|
|
20
|
+
const PROJECT_ROOT = join(import.meta.dirname, "..");
|
|
21
|
+
const CACHE_DIR = join(PROJECT_ROOT, "skills");
|
|
22
|
+
|
|
23
|
+
const GITHUB_REPO = "tikoci/routeros-skills";
|
|
24
|
+
const GITHUB_API_BASE = `https://api.github.com/repos/${GITHUB_REPO}`;
|
|
25
|
+
const GITHUB_RAW_BASE = `https://raw.githubusercontent.com/${GITHUB_REPO}`;
|
|
26
|
+
|
|
27
|
+
const FROM_CACHE = process.argv.includes("--from-cache");
|
|
28
|
+
|
|
29
|
+
/** Local path override: first non-flag CLI arg */
|
|
30
|
+
const localPath = process.argv.slice(2).find((a) => !a.startsWith("--"));
|
|
31
|
+
|
|
32
|
+
// ── Types ──
|
|
33
|
+
|
|
34
|
+
interface SkillData {
|
|
35
|
+
name: string;
|
|
36
|
+
description: string;
|
|
37
|
+
content: string;
|
|
38
|
+
sourceUrl: string;
|
|
39
|
+
references: Array<{
|
|
40
|
+
path: string;
|
|
41
|
+
filename: string;
|
|
42
|
+
content: string;
|
|
43
|
+
}>;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ── YAML frontmatter parsing (minimal — no dependency) ──
|
|
47
|
+
|
|
48
|
+
function parseFrontmatter(markdown: string): { meta: Record<string, string>; content: string } {
|
|
49
|
+
const match = markdown.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/);
|
|
50
|
+
if (!match) return { meta: {}, content: markdown };
|
|
51
|
+
|
|
52
|
+
const meta: Record<string, string> = {};
|
|
53
|
+
for (const line of match[1].split("\n")) {
|
|
54
|
+
const colonIdx = line.indexOf(":");
|
|
55
|
+
if (colonIdx === -1) continue;
|
|
56
|
+
const key = line.slice(0, colonIdx).trim();
|
|
57
|
+
let value = line.slice(colonIdx + 1).trim();
|
|
58
|
+
// Strip surrounding quotes
|
|
59
|
+
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
|
60
|
+
value = value.slice(1, -1);
|
|
61
|
+
}
|
|
62
|
+
meta[key] = value;
|
|
63
|
+
}
|
|
64
|
+
return { meta, content: match[2] };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function countWords(text: string): number {
|
|
68
|
+
return text.split(/\s+/).filter(Boolean).length;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ── GitHub API fetching ──
|
|
72
|
+
|
|
73
|
+
async function getDefaultBranchSha(): Promise<string> {
|
|
74
|
+
const res = await fetch(`${GITHUB_API_BASE}/commits/HEAD`, {
|
|
75
|
+
headers: { Accept: "application/vnd.github.v3+json" },
|
|
76
|
+
});
|
|
77
|
+
if (!res.ok) throw new Error(`Failed to get HEAD SHA: HTTP ${res.status}`);
|
|
78
|
+
const data = (await res.json()) as { sha: string };
|
|
79
|
+
return data.sha;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function listSkillDirs(sha: string): Promise<string[]> {
|
|
83
|
+
const res = await fetch(`${GITHUB_API_BASE}/contents/?ref=${sha}`, {
|
|
84
|
+
headers: { Accept: "application/vnd.github.v3+json" },
|
|
85
|
+
});
|
|
86
|
+
if (!res.ok) throw new Error(`Failed to list repo contents: HTTP ${res.status}`);
|
|
87
|
+
const entries = (await res.json()) as Array<{ name: string; type: string }>;
|
|
88
|
+
return entries
|
|
89
|
+
.filter((e) => e.type === "dir" && e.name.startsWith("routeros-"))
|
|
90
|
+
.map((e) => e.name);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function fetchRawFile(sha: string, path: string): Promise<string | null> {
|
|
94
|
+
const url = `${GITHUB_RAW_BASE}/${sha}/${path}`;
|
|
95
|
+
const res = await fetch(url);
|
|
96
|
+
if (!res.ok) {
|
|
97
|
+
if (res.status === 404) return null;
|
|
98
|
+
throw new Error(`Failed to fetch ${path}: HTTP ${res.status}`);
|
|
99
|
+
}
|
|
100
|
+
return res.text();
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function listReferences(sha: string, skillName: string): Promise<string[]> {
|
|
104
|
+
const res = await fetch(`${GITHUB_API_BASE}/contents/${skillName}/references?ref=${sha}`, {
|
|
105
|
+
headers: { Accept: "application/vnd.github.v3+json" },
|
|
106
|
+
});
|
|
107
|
+
if (!res.ok) {
|
|
108
|
+
if (res.status === 404) return [];
|
|
109
|
+
throw new Error(`Failed to list references for ${skillName}: HTTP ${res.status}`);
|
|
110
|
+
}
|
|
111
|
+
const entries = (await res.json()) as Array<{ name: string; type: string }>;
|
|
112
|
+
return entries.filter((e) => e.type === "file" && e.name.endsWith(".md")).map((e) => e.name);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function extractFromGitHub(): Promise<{ skills: SkillData[]; sha: string }> {
|
|
116
|
+
console.log(`Fetching skills from GitHub: ${GITHUB_REPO}`);
|
|
117
|
+
const sha = await getDefaultBranchSha();
|
|
118
|
+
console.log(` HEAD SHA: ${sha.slice(0, 12)}`);
|
|
119
|
+
|
|
120
|
+
const skillDirs = await listSkillDirs(sha);
|
|
121
|
+
console.log(` Found ${skillDirs.length} skill directories`);
|
|
122
|
+
|
|
123
|
+
const skills: SkillData[] = [];
|
|
124
|
+
|
|
125
|
+
for (const dir of skillDirs) {
|
|
126
|
+
const skillMd = await fetchRawFile(sha, `${dir}/SKILL.md`);
|
|
127
|
+
if (!skillMd) {
|
|
128
|
+
console.warn(` ⚠ ${dir}/SKILL.md not found, skipping`);
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const { meta, content } = parseFrontmatter(skillMd);
|
|
133
|
+
const skill: SkillData = {
|
|
134
|
+
name: meta.name || dir,
|
|
135
|
+
description: meta.description || "",
|
|
136
|
+
content,
|
|
137
|
+
sourceUrl: `https://github.com/${GITHUB_REPO}/blob/${sha}/${dir}/SKILL.md`,
|
|
138
|
+
references: [],
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
// Fetch references
|
|
142
|
+
const refFiles = await listReferences(sha, dir);
|
|
143
|
+
for (const refFile of refFiles) {
|
|
144
|
+
const refContent = await fetchRawFile(sha, `${dir}/references/${refFile}`);
|
|
145
|
+
if (refContent) {
|
|
146
|
+
skill.references.push({
|
|
147
|
+
path: `references/${refFile}`,
|
|
148
|
+
filename: refFile,
|
|
149
|
+
content: refContent,
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
skills.push(skill);
|
|
155
|
+
console.log(` ✓ ${skill.name} (${countWords(content)} words, ${skill.references.length} refs)`);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Cache to skills/ directory
|
|
159
|
+
cacheSkills(skills, sha);
|
|
160
|
+
|
|
161
|
+
return { skills, sha };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// ── Local path extraction ──
|
|
165
|
+
|
|
166
|
+
function extractFromLocal(dirPath: string): { skills: SkillData[]; sha: string } {
|
|
167
|
+
console.log(`Extracting skills from local path: ${dirPath}`);
|
|
168
|
+
|
|
169
|
+
// Try to get git SHA
|
|
170
|
+
let sha = "local";
|
|
171
|
+
try {
|
|
172
|
+
const gitResult = Bun.spawnSync(["git", "rev-parse", "HEAD"], { cwd: dirPath });
|
|
173
|
+
if (gitResult.exitCode === 0) {
|
|
174
|
+
sha = gitResult.stdout.toString().trim();
|
|
175
|
+
console.log(` Git SHA: ${sha.slice(0, 12)}`);
|
|
176
|
+
}
|
|
177
|
+
} catch { /* not a git repo, that's fine */ }
|
|
178
|
+
|
|
179
|
+
const entries = readdirSync(dirPath, { withFileTypes: true });
|
|
180
|
+
const skillDirs = entries.filter((e) => e.isDirectory() && e.name.startsWith("routeros-")).map((e) => e.name);
|
|
181
|
+
console.log(` Found ${skillDirs.length} skill directories`);
|
|
182
|
+
|
|
183
|
+
const skills: SkillData[] = [];
|
|
184
|
+
|
|
185
|
+
for (const dir of skillDirs) {
|
|
186
|
+
const skillPath = join(dirPath, dir, "SKILL.md");
|
|
187
|
+
if (!existsSync(skillPath)) {
|
|
188
|
+
console.warn(` ⚠ ${dir}/SKILL.md not found, skipping`);
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const skillMd = readFileSync(skillPath, "utf-8");
|
|
193
|
+
const { meta, content } = parseFrontmatter(skillMd);
|
|
194
|
+
const skill: SkillData = {
|
|
195
|
+
name: meta.name || dir,
|
|
196
|
+
description: meta.description || "",
|
|
197
|
+
content,
|
|
198
|
+
sourceUrl: `https://github.com/${GITHUB_REPO}/blob/${sha}/${dir}/SKILL.md`,
|
|
199
|
+
references: [],
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
// Load references
|
|
203
|
+
const refsDir = join(dirPath, dir, "references");
|
|
204
|
+
if (existsSync(refsDir)) {
|
|
205
|
+
const refEntries = readdirSync(refsDir, { withFileTypes: true });
|
|
206
|
+
for (const ref of refEntries) {
|
|
207
|
+
if (ref.isFile() && ref.name.endsWith(".md")) {
|
|
208
|
+
skill.references.push({
|
|
209
|
+
path: `references/${ref.name}`,
|
|
210
|
+
filename: ref.name,
|
|
211
|
+
content: readFileSync(join(refsDir, ref.name), "utf-8"),
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
skills.push(skill);
|
|
218
|
+
console.log(` ✓ ${skill.name} (${countWords(content)} words, ${skill.references.length} refs)`);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Cache to skills/ directory
|
|
222
|
+
cacheSkills(skills, sha);
|
|
223
|
+
|
|
224
|
+
return { skills, sha };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// ── Cache management ──
|
|
228
|
+
|
|
229
|
+
function cacheSkills(skills: SkillData[], sha: string) {
|
|
230
|
+
mkdirSync(CACHE_DIR, { recursive: true });
|
|
231
|
+
|
|
232
|
+
// Write metadata
|
|
233
|
+
const metadata = {
|
|
234
|
+
sha,
|
|
235
|
+
extracted_at: new Date().toISOString(),
|
|
236
|
+
skills: skills.map((s) => ({
|
|
237
|
+
name: s.name,
|
|
238
|
+
description: s.description,
|
|
239
|
+
word_count: countWords(s.content),
|
|
240
|
+
ref_count: s.references.length,
|
|
241
|
+
})),
|
|
242
|
+
};
|
|
243
|
+
writeFileSync(join(CACHE_DIR, "metadata.json"), JSON.stringify(metadata, null, 2));
|
|
244
|
+
|
|
245
|
+
// Write each skill
|
|
246
|
+
for (const skill of skills) {
|
|
247
|
+
const skillDir = join(CACHE_DIR, skill.name);
|
|
248
|
+
mkdirSync(skillDir, { recursive: true });
|
|
249
|
+
|
|
250
|
+
// Write SKILL.md with frontmatter reconstructed
|
|
251
|
+
const frontmatter = `---\nname: ${skill.name}\ndescription: "${skill.description}"\n---\n`;
|
|
252
|
+
writeFileSync(join(skillDir, "SKILL.md"), frontmatter + skill.content);
|
|
253
|
+
|
|
254
|
+
// Write references
|
|
255
|
+
if (skill.references.length > 0) {
|
|
256
|
+
const refsDir = join(skillDir, "references");
|
|
257
|
+
mkdirSync(refsDir, { recursive: true });
|
|
258
|
+
for (const ref of skill.references) {
|
|
259
|
+
writeFileSync(join(refsDir, ref.filename), ref.content);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
console.log(` Cached ${skills.length} skills to ${CACHE_DIR}/`);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function extractFromCache(): { skills: SkillData[]; sha: string } {
|
|
268
|
+
console.log(`Extracting skills from cache: ${CACHE_DIR}/`);
|
|
269
|
+
|
|
270
|
+
if (!existsSync(join(CACHE_DIR, "metadata.json"))) {
|
|
271
|
+
throw new Error(`No cached skills found at ${CACHE_DIR}/metadata.json — run without --from-cache first`);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const metadata = JSON.parse(readFileSync(join(CACHE_DIR, "metadata.json"), "utf-8"));
|
|
275
|
+
const sha = metadata.sha || "cached";
|
|
276
|
+
|
|
277
|
+
const skills: SkillData[] = [];
|
|
278
|
+
const entries = readdirSync(CACHE_DIR, { withFileTypes: true });
|
|
279
|
+
|
|
280
|
+
for (const entry of entries) {
|
|
281
|
+
if (!entry.isDirectory() || !entry.name.startsWith("routeros-")) continue;
|
|
282
|
+
|
|
283
|
+
const skillPath = join(CACHE_DIR, entry.name, "SKILL.md");
|
|
284
|
+
if (!existsSync(skillPath)) continue;
|
|
285
|
+
|
|
286
|
+
const skillMd = readFileSync(skillPath, "utf-8");
|
|
287
|
+
const { meta, content } = parseFrontmatter(skillMd);
|
|
288
|
+
const skill: SkillData = {
|
|
289
|
+
name: meta.name || entry.name,
|
|
290
|
+
description: meta.description || "",
|
|
291
|
+
content,
|
|
292
|
+
sourceUrl: `https://github.com/${GITHUB_REPO}/blob/${sha}/${entry.name}/SKILL.md`,
|
|
293
|
+
references: [],
|
|
294
|
+
};
|
|
295
|
+
|
|
296
|
+
// Load cached references
|
|
297
|
+
const refsDir = join(CACHE_DIR, entry.name, "references");
|
|
298
|
+
if (existsSync(refsDir)) {
|
|
299
|
+
const refEntries = readdirSync(refsDir, { withFileTypes: true });
|
|
300
|
+
for (const ref of refEntries) {
|
|
301
|
+
if (ref.isFile() && ref.name.endsWith(".md")) {
|
|
302
|
+
skill.references.push({
|
|
303
|
+
path: `references/${ref.name}`,
|
|
304
|
+
filename: ref.name,
|
|
305
|
+
content: readFileSync(join(refsDir, ref.name), "utf-8"),
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
skills.push(skill);
|
|
312
|
+
console.log(` ✓ ${skill.name} (${countWords(content)} words, ${skill.references.length} refs)`);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
return { skills, sha };
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// ── Database population ──
|
|
319
|
+
|
|
320
|
+
function populateDb(skills: SkillData[], sha: string) {
|
|
321
|
+
const now = new Date().toISOString();
|
|
322
|
+
|
|
323
|
+
// Idempotent: delete existing data (respect FK order)
|
|
324
|
+
db.run("DELETE FROM skill_references");
|
|
325
|
+
db.run("DELETE FROM skills");
|
|
326
|
+
|
|
327
|
+
const insertSkill = db.prepare(`
|
|
328
|
+
INSERT INTO skills (name, description, content, source_repo, source_sha, source_url, word_count, extracted_at)
|
|
329
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
330
|
+
`);
|
|
331
|
+
|
|
332
|
+
const insertRef = db.prepare(`
|
|
333
|
+
INSERT INTO skill_references (skill_id, path, filename, content, word_count)
|
|
334
|
+
VALUES (?, ?, ?, ?, ?)
|
|
335
|
+
`);
|
|
336
|
+
|
|
337
|
+
const getSkillId = db.prepare("SELECT id FROM skills WHERE name = ?");
|
|
338
|
+
|
|
339
|
+
let totalWords = 0;
|
|
340
|
+
let totalRefs = 0;
|
|
341
|
+
|
|
342
|
+
for (const skill of skills) {
|
|
343
|
+
const wordCount = countWords(skill.content);
|
|
344
|
+
totalWords += wordCount;
|
|
345
|
+
|
|
346
|
+
insertSkill.run(
|
|
347
|
+
skill.name,
|
|
348
|
+
skill.description,
|
|
349
|
+
skill.content,
|
|
350
|
+
GITHUB_REPO,
|
|
351
|
+
sha,
|
|
352
|
+
skill.sourceUrl,
|
|
353
|
+
wordCount,
|
|
354
|
+
now,
|
|
355
|
+
);
|
|
356
|
+
|
|
357
|
+
const row = getSkillId.get(skill.name) as { id: number };
|
|
358
|
+
const skillId = row.id;
|
|
359
|
+
|
|
360
|
+
for (const ref of skill.references) {
|
|
361
|
+
const refWordCount = countWords(ref.content);
|
|
362
|
+
totalWords += refWordCount;
|
|
363
|
+
totalRefs++;
|
|
364
|
+
insertRef.run(skillId, ref.path, ref.filename, ref.content, refWordCount);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
console.log(`\nPopulated DB: ${skills.length} skills, ${totalRefs} references, ${totalWords} total words`);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// ── Main ──
|
|
372
|
+
|
|
373
|
+
async function main() {
|
|
374
|
+
initDb();
|
|
375
|
+
|
|
376
|
+
let skills: SkillData[];
|
|
377
|
+
let sha: string;
|
|
378
|
+
|
|
379
|
+
if (FROM_CACHE) {
|
|
380
|
+
({ skills, sha } = extractFromCache());
|
|
381
|
+
} else if (localPath) {
|
|
382
|
+
({ skills, sha } = extractFromLocal(localPath));
|
|
383
|
+
} else {
|
|
384
|
+
({ skills, sha } = await extractFromGitHub());
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
populateDb(skills, sha);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
await main();
|
package/src/mcp.ts
CHANGED
|
@@ -183,6 +183,8 @@ const {
|
|
|
183
183
|
exportDeviceTestsCsv,
|
|
184
184
|
fetchCurrentVersions,
|
|
185
185
|
getPage,
|
|
186
|
+
getSkill,
|
|
187
|
+
listSkills,
|
|
186
188
|
lookupProperty,
|
|
187
189
|
searchCallouts,
|
|
188
190
|
searchChangelogs,
|
|
@@ -437,6 +439,81 @@ ORDER BY version, sort_order;
|
|
|
437
439
|
}),
|
|
438
440
|
);
|
|
439
441
|
|
|
442
|
+
// ── Skills resources (community-created agent guides from tikoci/routeros-skills) ──
|
|
443
|
+
|
|
444
|
+
server.registerResource(
|
|
445
|
+
"skills-list",
|
|
446
|
+
"rosetta://skills",
|
|
447
|
+
{
|
|
448
|
+
title: "RouterOS Agent Skills",
|
|
449
|
+
description: "List of available RouterOS agent skill guides — community-created, AI-generated/human-reviewed supplemental content from tikoci/routeros-skills. NOT official MikroTik documentation.",
|
|
450
|
+
mimeType: "text/markdown",
|
|
451
|
+
},
|
|
452
|
+
async () => {
|
|
453
|
+
const skills = listSkills();
|
|
454
|
+
const lines = [
|
|
455
|
+
"# RouterOS Agent Skills",
|
|
456
|
+
"",
|
|
457
|
+
"⚠️ Community-created content from tikoci/routeros-skills — NOT official MikroTik documentation.",
|
|
458
|
+
"AI-generated, human-reviewed. May contain errors. Verify with routeros_search/routeros_get_page.",
|
|
459
|
+
"",
|
|
460
|
+
`${skills.length} skills available:`,
|
|
461
|
+
"",
|
|
462
|
+
...skills.map(s => `- **${s.name}** — ${s.description} (${s.word_count} words, ${s.ref_count} refs) → \`rosetta://skills/${s.name}\``),
|
|
463
|
+
];
|
|
464
|
+
return {
|
|
465
|
+
contents: [{
|
|
466
|
+
uri: "rosetta://skills",
|
|
467
|
+
mimeType: "text/markdown",
|
|
468
|
+
text: lines.join("\n"),
|
|
469
|
+
}],
|
|
470
|
+
};
|
|
471
|
+
},
|
|
472
|
+
);
|
|
473
|
+
|
|
474
|
+
// Register individual skill resources using resource templates
|
|
475
|
+
// MCP resource templates allow `rosetta://skills/{name}` pattern matching
|
|
476
|
+
{
|
|
477
|
+
const skills = listSkills();
|
|
478
|
+
for (const skill of skills) {
|
|
479
|
+
server.registerResource(
|
|
480
|
+
`skill-${skill.name}`,
|
|
481
|
+
`rosetta://skills/${skill.name}`,
|
|
482
|
+
{
|
|
483
|
+
title: `Skill: ${skill.name}`,
|
|
484
|
+
description: skill.description,
|
|
485
|
+
mimeType: "text/markdown",
|
|
486
|
+
},
|
|
487
|
+
async () => {
|
|
488
|
+
const detail = getSkill(skill.name);
|
|
489
|
+
if (!detail) {
|
|
490
|
+
return { contents: [{ uri: `rosetta://skills/${skill.name}`, mimeType: "text/plain", text: `Skill '${skill.name}' not found.` }] };
|
|
491
|
+
}
|
|
492
|
+
const lines = [
|
|
493
|
+
detail.provenance,
|
|
494
|
+
"",
|
|
495
|
+
`# ${detail.name}`,
|
|
496
|
+
"",
|
|
497
|
+
detail.content,
|
|
498
|
+
];
|
|
499
|
+
if (detail.references.length > 0) {
|
|
500
|
+
lines.push("", "---", "", "## Reference Files", "");
|
|
501
|
+
for (const ref of detail.references) {
|
|
502
|
+
lines.push(`### ${ref.filename}`, "", ref.content, "");
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
return {
|
|
506
|
+
contents: [{
|
|
507
|
+
uri: `rosetta://skills/${detail.name}`,
|
|
508
|
+
mimeType: "text/markdown",
|
|
509
|
+
text: lines.join("\n"),
|
|
510
|
+
}],
|
|
511
|
+
};
|
|
512
|
+
},
|
|
513
|
+
);
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
|
|
440
517
|
// ---- routeros_search ----
|
|
441
518
|
|
|
442
519
|
server.registerTool(
|
|
@@ -651,6 +728,7 @@ arguments). Each child includes its type and linked documentation page if availa
|
|
|
651
728
|
Useful for discovering what's available under a command path.
|
|
652
729
|
|
|
653
730
|
Optionally filter by RouterOS version to check what exists in a specific release.
|
|
731
|
+
Optionally filter by CPU architecture (x86/arm64) to see platform-specific commands.
|
|
654
732
|
Command data covers versions 7.9–7.23beta2. No v6 data.
|
|
655
733
|
|
|
656
734
|
Workflow — combine with other tools:
|
|
@@ -662,7 +740,8 @@ Workflow — combine with other tools:
|
|
|
662
740
|
Examples:
|
|
663
741
|
- path: "/ip" → address, arp, dhcp-client, dhcp-server, firewall, route, etc.
|
|
664
742
|
- path: "/ip/firewall" → filter, nat, mangle, raw, address-list, etc.
|
|
665
|
-
- path: "", version: "7.15" → top-level menus as of RouterOS 7.15
|
|
743
|
+
- path: "", version: "7.15" → top-level menus as of RouterOS 7.15
|
|
744
|
+
- path: "/interface", arch: "arm64" → shows arm64-specific interfaces (wifi-qcom, ethernet/switch)`,
|
|
666
745
|
inputSchema: {
|
|
667
746
|
path: z
|
|
668
747
|
.string()
|
|
@@ -673,13 +752,17 @@ Examples:
|
|
|
673
752
|
.string()
|
|
674
753
|
.optional()
|
|
675
754
|
.describe("RouterOS version to filter by (e.g., '7.15'). Omit for latest."),
|
|
755
|
+
arch: z
|
|
756
|
+
.string()
|
|
757
|
+
.optional()
|
|
758
|
+
.describe("Filter by CPU architecture: 'x86' or 'arm64'. Omit to show all (including arch-specific nodes)."),
|
|
676
759
|
},
|
|
677
760
|
},
|
|
678
|
-
async ({ path, version }) => {
|
|
761
|
+
async ({ path, version, arch }) => {
|
|
679
762
|
const cmdPath = path?.trim() || "";
|
|
680
763
|
const results = version
|
|
681
|
-
? browseCommandsAtVersion(cmdPath, version)
|
|
682
|
-
: browseCommands(cmdPath);
|
|
764
|
+
? browseCommandsAtVersion(cmdPath, version, arch)
|
|
765
|
+
: browseCommands(cmdPath, arch);
|
|
683
766
|
|
|
684
767
|
if (results.length === 0) {
|
|
685
768
|
return {
|
|
@@ -700,7 +783,10 @@ server.registerTool(
|
|
|
700
783
|
description: `Get database statistics for the RouterOS documentation index.
|
|
701
784
|
|
|
702
785
|
Returns page count, property count, callout count, changelog count, command count, link coverage,
|
|
703
|
-
version range,
|
|
786
|
+
version range, documentation export date, and available agent skills.
|
|
787
|
+
|
|
788
|
+
Skills: Community-created agent guides from tikoci/routeros-skills are available as MCP resources
|
|
789
|
+
at rosetta://skills/{name}. Use the resource listing to browse available skills.
|
|
704
790
|
|
|
705
791
|
Knowledge boundaries:
|
|
706
792
|
- Documentation: March 2026 Confluence HTML export (317 pages), aligned with long-term ~7.22
|
|
@@ -713,8 +799,17 @@ Knowledge boundaries:
|
|
|
713
799
|
},
|
|
714
800
|
async () => {
|
|
715
801
|
const stats = getDbStats();
|
|
802
|
+
const skills = listSkills();
|
|
803
|
+
const statsWithSkills = {
|
|
804
|
+
...stats,
|
|
805
|
+
skills: {
|
|
806
|
+
count: skills.length,
|
|
807
|
+
available: skills.map(s => s.name),
|
|
808
|
+
note: "Community-created agent guides from tikoci/routeros-skills. Access via rosetta://skills/{name} resources.",
|
|
809
|
+
},
|
|
810
|
+
};
|
|
716
811
|
return {
|
|
717
|
-
content: [{ type: "text", text: JSON.stringify(
|
|
812
|
+
content: [{ type: "text", text: JSON.stringify(statsWithSkills, null, 2) }],
|
|
718
813
|
};
|
|
719
814
|
},
|
|
720
815
|
);
|
|
@@ -1103,10 +1198,14 @@ Without a prefix, a major-version diff can list hundreds of added paths.
|
|
|
1103
1198
|
.string()
|
|
1104
1199
|
.optional()
|
|
1105
1200
|
.describe("Optional: scope the diff to a command subtree (e.g., '/ip/firewall', '/routing/bgp', '/interface/bridge')"),
|
|
1201
|
+
arch: z
|
|
1202
|
+
.string()
|
|
1203
|
+
.optional()
|
|
1204
|
+
.describe("Filter by CPU architecture: 'x86' or 'arm64'. Omit to diff all commands regardless of architecture."),
|
|
1106
1205
|
},
|
|
1107
1206
|
},
|
|
1108
|
-
async ({ from_version, to_version, path_prefix }) => {
|
|
1109
|
-
const result = diffCommandVersions(from_version, to_version, path_prefix);
|
|
1207
|
+
async ({ from_version, to_version, path_prefix, arch }) => {
|
|
1208
|
+
const result = diffCommandVersions(from_version, to_version, path_prefix, arch);
|
|
1110
1209
|
if (result.added_count === 0 && result.removed_count === 0) {
|
|
1111
1210
|
const hint = [
|
|
1112
1211
|
result.note ?? null,
|
package/src/paths.ts
CHANGED
|
@@ -55,11 +55,18 @@ export function resolveBaseDir(srcDir: string): string {
|
|
|
55
55
|
|
|
56
56
|
/**
|
|
57
57
|
* Resolve the full path to ros-help.db.
|
|
58
|
-
* DB_PATH env var
|
|
58
|
+
* Priority: DB_PATH env var → --db CLI flag → auto-detect.
|
|
59
59
|
*/
|
|
60
60
|
export function resolveDbPath(srcDir: string): string {
|
|
61
61
|
const envPath = process.env.DB_PATH?.trim();
|
|
62
62
|
if (envPath) return envPath;
|
|
63
|
+
|
|
64
|
+
// --db flag parsed at init time so ESM static imports don't race it
|
|
65
|
+
const dbArgIdx = process.argv.indexOf("--db");
|
|
66
|
+
if (dbArgIdx !== -1 && process.argv[dbArgIdx + 1]) {
|
|
67
|
+
return process.argv[dbArgIdx + 1];
|
|
68
|
+
}
|
|
69
|
+
|
|
63
70
|
return path.join(resolveBaseDir(srcDir), "ros-help.db");
|
|
64
71
|
}
|
|
65
72
|
|
|
@@ -79,7 +86,7 @@ export function detectMode(srcDir: string): InvocationMode {
|
|
|
79
86
|
* Stamped into the DB via `PRAGMA user_version` by initDb() and checked at MCP
|
|
80
87
|
* startup to detect stale DBs for bunx users who auto-update the package.
|
|
81
88
|
*/
|
|
82
|
-
export const SCHEMA_VERSION =
|
|
89
|
+
export const SCHEMA_VERSION = 4;
|
|
83
90
|
|
|
84
91
|
/**
|
|
85
92
|
* Resolve the version string.
|