@tikoci/rosetta 0.5.0 → 0.5.1
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 +64 -147
- package/package.json +1 -1
- package/src/browse.ts +281 -70
- package/src/extract-html.ts +7 -0
- package/src/mcp.ts +19 -3
- package/src/query.test.ts +36 -5
- package/src/query.ts +10 -2
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
MCP server that gives AI assistants searchable access to the complete [MikroTik RouterOS documentation](https://help.mikrotik.com/docs/spaces/ROS/overview) — 317 pages, 4,860 properties, 40,000-entry command tree, hardware specs for 144 products, 518 YouTube video transcripts, and direct links to help.mikrotik.com.
|
|
4
4
|
|
|
5
|
-
If you need MikroTik docs, you likely have a MikroTik. Install rosetta once as a container on your router using [RouterOS /app](#install-on-mikrotik-app), and any AI assistant on the network can use it. Or [run it locally](#install-locally-with-bun) on your workstation.
|
|
5
|
+
If you need MikroTik docs, you likely have a MikroTik. Install rosetta once as a container on your router using [RouterOS /app](#install-on-mikrotik-app), and any AI assistant on the network can use it. Or [run it locally](#install-locally-with-bun) on your workstation. **No AI required** — rosetta includes a [terminal browser](#browse-without-ai) for searching the database directly.
|
|
6
6
|
|
|
7
7
|
### SQL-as-RAG
|
|
8
8
|
|
|
@@ -10,16 +10,18 @@ Instead of vector embeddings, rosetta uses **SQLite [FTS5](https://www.sqlite.or
|
|
|
10
10
|
|
|
11
11
|
### What's Inside
|
|
12
12
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
13
|
+
| Data Source | Coverage |
|
|
14
|
+
|-------------|----------|
|
|
15
|
+
| Documentation pages | 317 pages (~515K words) from help.mikrotik.com |
|
|
16
|
+
| Property definitions | 4,860 with types, defaults, descriptions |
|
|
17
|
+
| Command tree | 5,114 commands, 551 dirs, 34K arguments |
|
|
18
|
+
| Version history | 46 RouterOS versions tracked (7.9–7.23beta2) |
|
|
19
|
+
| Hardware products | 144 devices — specs, pricing, block diagrams |
|
|
20
|
+
| Performance benchmarks | 2,874 tests across 125 devices (ethernet + IPSec) |
|
|
21
|
+
| YouTube transcripts | 518 videos, ~1,890 chapter-level segments |
|
|
22
|
+
| Callout blocks | 1,034 warnings, notes, and tips |
|
|
23
|
+
|
|
24
|
+
Documentation covers RouterOS **v7 only**, aligned with the long-term release (~7.22) at export time.
|
|
23
25
|
|
|
24
26
|
---
|
|
25
27
|
|
|
@@ -91,7 +93,13 @@ Point any HTTP-capable MCP client at the URL from the previous step:
|
|
|
91
93
|
|
|
92
94
|
> **CHR note:** Cloud Hosted Router in free or trial mode does not include the `/ip/cloud` service needed for HTTPS certificates. Set `use-https=no` on the /app — the URL will use HTTP instead. The UI URL always reflects the correct protocol.
|
|
93
95
|
|
|
94
|
-
> **HTTP option:** On any platform, you may choose `use-https=no` if you prefer HTTP or are on an isolated network.
|
|
96
|
+
> **HTTP option:** On any platform, you may choose `use-https=no` if you prefer HTTP or are on an isolated network.
|
|
97
|
+
|
|
98
|
+
> **Browse the database from the router:** If rosetta is running as a `/app`, you can use `/container/shell` to access the TUI browser directly:
|
|
99
|
+
> ```routeros
|
|
100
|
+
> /container/shell app-rosetta
|
|
101
|
+
> # /app/rosetta browse
|
|
102
|
+
> ```
|
|
95
103
|
|
|
96
104
|
---
|
|
97
105
|
|
|
@@ -107,7 +115,7 @@ bunx @tikoci/rosetta --setup
|
|
|
107
115
|
|
|
108
116
|
This downloads the database and prints config snippets for all supported MCP clients. Copy-paste the config for your client and you're done.
|
|
109
117
|
|
|
110
|
-
###
|
|
118
|
+
### Configure your MCP client
|
|
111
119
|
|
|
112
120
|
<details>
|
|
113
121
|
<summary><b>VS Code Copilot</b></summary>
|
|
@@ -214,34 +222,36 @@ powershell -c "irm bun.sh/install.ps1 | iex"
|
|
|
214
222
|
|
|
215
223
|
> **Auto-update:** `bunx` checks the npm registry each session and uses the latest published version automatically. The database in `~/.rosetta/ros-help.db` persists across updates.
|
|
216
224
|
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
If your MCP client supports resources, rosetta also exposes two read-only CSV datasets for bulk analysis and reporting:
|
|
225
|
+
---
|
|
220
226
|
|
|
221
|
-
|
|
222
|
-
- `rosetta://datasets/devices.csv`
|
|
227
|
+
## Browse Without AI
|
|
223
228
|
|
|
224
|
-
|
|
229
|
+
Rosetta includes a terminal-based "card catalog" browser — **no AI assistant or MCP client required**. It searches the same database the MCP tools use, with a keyboard-driven REPL modeled after a 1980s library terminal.
|
|
225
230
|
|
|
226
|
-
|
|
231
|
+
```sh
|
|
232
|
+
bunx @tikoci/rosetta browse
|
|
233
|
+
```
|
|
227
234
|
|
|
228
|
-
|
|
235
|
+
Type a search query to find documentation pages, then select a numbered result to drill in. Beyond page search, the browser covers every data source in the database:
|
|
229
236
|
|
|
230
|
-
|
|
237
|
+
| Command | What it searches |
|
|
238
|
+
|---------|-----------------|
|
|
239
|
+
| *(bare text)* | Documentation pages (default) |
|
|
240
|
+
| `dev <query>` | Device hardware specs, block diagrams, benchmarks |
|
|
241
|
+
| `cmd [path]` | Command tree hierarchy |
|
|
242
|
+
| `prop <name>` | Property definitions (scoped to current page when viewing one) |
|
|
243
|
+
| `cal [query]` | Warnings, notes, and tips |
|
|
244
|
+
| `cl [version]` | Changelogs — `cl breaking` for breaking changes only |
|
|
245
|
+
| `vid <query>` | YouTube video transcripts with timestamped chapter links |
|
|
246
|
+
| `diff <from> <to>` | Command tree diff between RouterOS versions |
|
|
247
|
+
| `tests [type]` | Cross-device performance benchmarks |
|
|
248
|
+
| `ver` | Live-fetch current RouterOS versions |
|
|
231
249
|
|
|
232
|
-
|
|
233
|
-
|----------|------|
|
|
234
|
-
| macOS (Apple Silicon) | `rosetta-macos-arm64.zip` |
|
|
235
|
-
| macOS (Intel) | `rosetta-macos-x64.zip` |
|
|
236
|
-
| Windows | `rosetta-windows-x64.zip` |
|
|
237
|
-
| Linux | `rosetta-linux-x64.zip` |
|
|
250
|
+
Type `help` for the full command list. URLs are clickable in terminals that support OSC 8 hyperlinks (iTerm2, Windows Terminal, GNOME Terminal, etc.).
|
|
238
251
|
|
|
239
|
-
|
|
240
|
-
./rosetta --setup # downloads DB + prints MCP client config
|
|
241
|
-
```
|
|
252
|
+
The browser is also useful as a test harness — it interacts with the data the same way an AI agent would through MCP, so gaps or rough edges visible here often point to MCP tool improvements too.
|
|
242
253
|
|
|
243
|
-
> **
|
|
244
|
-
> **Windows SmartScreen:** Click **More info → Run anyway**.
|
|
254
|
+
> **From a router:** If rosetta is installed as a `/app`, access the browser via `/container/shell app-rosetta` then `/app/rosetta browse`.
|
|
245
255
|
|
|
246
256
|
---
|
|
247
257
|
|
|
@@ -252,7 +262,6 @@ Ask your AI assistant questions like:
|
|
|
252
262
|
- *"What are the DHCP server properties in RouterOS?"*
|
|
253
263
|
- *"How do I set up a bridge VLAN?"*
|
|
254
264
|
- *"Is the /container command available in RouterOS 7.12?"*
|
|
255
|
-
- *"What are the firewall filter default chains?"*
|
|
256
265
|
- *"Show me warnings about hardware offloading"*
|
|
257
266
|
- *"Which MikroTik routers have L3HW offload, and more than 8 ports of 48V PoE? Include cost."*
|
|
258
267
|
- *"Compare the RB5009 and CCR2004 IPSec throughput at 1518-byte packets."*
|
|
@@ -260,122 +269,30 @@ Ask your AI assistant questions like:
|
|
|
260
269
|
|
|
261
270
|
## MCP Tools
|
|
262
271
|
|
|
263
|
-
The server
|
|
272
|
+
The server exposes 14 tools designed to work together — agents start with `routeros_search` and drill into specific data as needed:
|
|
264
273
|
|
|
265
274
|
| Tool | What it does |
|
|
266
275
|
|------|-------------|
|
|
267
|
-
| `routeros_search` | **Start here.** Full-text search across all pages
|
|
268
|
-
| `routeros_get_page` |
|
|
269
|
-
| `routeros_lookup_property` |
|
|
270
|
-
| `routeros_search_properties` |
|
|
271
|
-
| `routeros_command_tree` | Browse the `/ip/firewall/filter` style
|
|
272
|
-
| `routeros_search_callouts` | Search warnings, notes, and tips
|
|
273
|
-
| `routeros_search_changelogs` |
|
|
274
|
-
| `routeros_command_version_check` |
|
|
275
|
-
| `routeros_command_diff` |
|
|
276
|
-
| `routeros_device_lookup` | Hardware specs
|
|
277
|
-
| `routeros_search_tests` | Cross-device
|
|
278
|
-
| `routeros_search_videos` |
|
|
279
|
-
| `routeros_stats` | Database health
|
|
280
|
-
| `routeros_current_versions` |
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|-------|----------|
|
|
288
|
-
| **First launch is slow** | One-time database download (~50 MB). Subsequent starts are instant. |
|
|
289
|
-
| **`npx @tikoci/rosetta` fails** | This package requires Bun, not Node.js. Use `bunx` instead of `npx`. |
|
|
290
|
-
| **`npm install -g` then `rosetta` fails** | Global npm install works if Bun is on PATH — it delegates to `bun` at runtime. But prefer `bunx` — it's simpler and auto-updates. |
|
|
291
|
-
| **ChatGPT Apps can't connect** | ChatGPT Apps require a remote HTTPS MCP endpoint. Use the [MikroTik /app install](#install-on-mikrotik-app) for a hosted endpoint, or Codex CLI for local stdio. |
|
|
292
|
-
| **Claude Desktop can't find `bunx`** | Claude Desktop on macOS may not inherit shell PATH. Use the full path to bunx (run `which bunx` to find it, typically `~/.bun/bin/bunx`). `bunx @tikoci/rosetta --setup` prints the full-path config. |
|
|
293
|
-
| **macOS Gatekeeper blocks binary** | Use `bunx` install (no Gatekeeper issues), or: `xattr -d com.apple.quarantine ./rosetta` |
|
|
294
|
-
| **Windows SmartScreen warning** | Use `bunx` install (no SmartScreen issues), or click **More info → Run anyway** |
|
|
295
|
-
| **How to update** | `bunx` always uses the latest published version. For binaries, re-download from [Releases](https://github.com/tikoci/rosetta/releases/latest). MikroTik /app with `auto-update: true` pulls the latest image on each boot. |
|
|
296
|
-
|
|
297
|
-
## HTTP Transport
|
|
298
|
-
|
|
299
|
-
The [MikroTik /app install](#install-on-mikrotik-app) is the easiest way to get an HTTP endpoint. For other setups, rosetta supports the [MCP Streamable HTTP transport](https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#streamable-http) via `--http`:
|
|
300
|
-
|
|
301
|
-
```sh
|
|
302
|
-
rosetta --http # http://localhost:8080/mcp
|
|
303
|
-
rosetta --http --port 9090 # custom port
|
|
304
|
-
rosetta --http --host 0.0.0.0 # accessible from LAN
|
|
305
|
-
```
|
|
306
|
-
|
|
307
|
-
Then point your MCP client at the URL:
|
|
308
|
-
|
|
309
|
-
```json
|
|
310
|
-
{ "url": "http://localhost:8080/mcp" }
|
|
311
|
-
```
|
|
312
|
-
|
|
313
|
-
- **Read-only** — queries a local SQLite database, stores nothing.
|
|
314
|
-
- **No authentication** — designed for local/trusted-network use. Use a reverse proxy for public exposure.
|
|
315
|
-
- **TLS built-in** — `--tls-cert cert.pem --tls-key key.pem` for direct HTTPS without a proxy.
|
|
316
|
-
- **Defaults to localhost** — LAN binding (`--host 0.0.0.0`) requires an explicit flag.
|
|
317
|
-
|
|
318
|
-
## Container Images
|
|
319
|
-
|
|
320
|
-
Multi-arch OCI images (linux/amd64 + linux/arm64) are published with each release:
|
|
321
|
-
|
|
322
|
-
- `ghcr.io/tikoci/rosetta` (GitHub Container Registry)
|
|
323
|
-
- `ammo74/rosetta` (Docker Hub)
|
|
324
|
-
|
|
325
|
-
```sh
|
|
326
|
-
docker run --rm -p 8080:8080 ghcr.io/tikoci/rosetta:latest
|
|
327
|
-
```
|
|
328
|
-
|
|
329
|
-
These are the same images used by the [MikroTik /app install](#install-on-mikrotik-app). Tags: `latest`, version (e.g., `v0.2.1`), and `sha-<commit>`.
|
|
330
|
-
|
|
331
|
-
## Data Sources
|
|
332
|
-
|
|
333
|
-
The database combines multiple MikroTik data sources into a single SQLite file with [FTS5](https://www.sqlite.org/fts5.html) full-text search, [porter stemming](https://www.sqlite.org/fts5.html#porter_tokenizer), and [BM25 ranking](https://www.sqlite.org/fts5.html#the_bm25_function):
|
|
334
|
-
|
|
335
|
-
- **HTML Documentation** — Confluence space export from help.mikrotik.com (March 2026). 317 pages broken into sections, callouts, and property tables (~515K words) with links back to help.mikrotik.com.
|
|
336
|
-
|
|
337
|
-
- **Command Tree** — `inspect.json` from [tikoci/restraml](https://github.com/tikoci/restraml), generated by running `/console/inspect` against RouterOS CHR under QEMU for every version since 7.9 (46 versions tracked: 7.9–7.23beta2).
|
|
338
|
-
|
|
339
|
-
- **Product Matrix** — CSV export from mikrotik.com/products/matrix (144 products, 34 columns). Hardware specs, license levels, and pricing.
|
|
340
|
-
|
|
341
|
-
- **Device Benchmarks** — Ethernet bridging/routing and IPSec throughput test results scraped from individual product pages on mikrotik.com (2,874 measurements across 125 devices; 64/512/1518-byte packets, multiple configurations). Also captures block diagram image URLs for 110 devices.
|
|
342
|
-
|
|
343
|
-
- **YouTube Transcripts** — Auto-generated English transcripts from the official [MikroTik YouTube channel](https://www.youtube.com/@MikroTik/videos) (518 videos, ~1,800 transcript segments). Split by chapter when available, with timestamps for deep linking. MUM conference talks excluded. Extracted via yt-dlp, cached as NDJSON in the repo for reproducible CI builds.
|
|
344
|
-
|
|
345
|
-
Documentation covers RouterOS **v7 only** and aligns with the long-term release (~7.22) at export time. v6 had different syntax and major subsystems — answers for v6 are unreliable.
|
|
346
|
-
|
|
347
|
-
## Database (Standalone)
|
|
348
|
-
|
|
349
|
-
The SQLite database is downloadable on its own from [GitHub Releases](https://github.com/tikoci/rosetta/releases):
|
|
350
|
-
|
|
351
|
-
```text
|
|
352
|
-
https://github.com/tikoci/rosetta/releases/latest/download/ros-help.db.gz
|
|
353
|
-
```
|
|
354
|
-
|
|
355
|
-
Use it with any SQLite client:
|
|
356
|
-
|
|
357
|
-
```sh
|
|
358
|
-
sqlite3 ros-help.db "SELECT title, url FROM pages_fts WHERE pages_fts MATCH 'DHCP lease' ORDER BY rank LIMIT 5;"
|
|
359
|
-
```
|
|
360
|
-
|
|
361
|
-
### Tables
|
|
362
|
-
|
|
363
|
-
| Table | Rows | What's in it |
|
|
364
|
-
|-------|------|-------------|
|
|
365
|
-
| `pages` | 317 | Documentation pages — title, breadcrumb path, full text, code blocks, help.mikrotik.com URL |
|
|
366
|
-
| `sections` | 2,984 | Page chunks split by h1–h3 headings, with anchor IDs for deep linking |
|
|
367
|
-
| `callouts` | 1,034 | Warning/Note/Info/Tip boxes extracted from Confluence callout macros |
|
|
368
|
-
| `properties` | 4,860 | Command properties — name, type, default value, description (from doc tables) |
|
|
369
|
-
| `commands` | 40K+ | RouterOS command hierarchy — dirs, commands, arguments from `/console/inspect` |
|
|
370
|
-
| `command_versions` | 1.67M | Junction table: which command paths exist in which RouterOS versions (7.9–7.23beta2) |
|
|
371
|
-
| `ros_versions` | 46 | Tracked RouterOS versions with channel (stable/development) |
|
|
372
|
-
| `devices` | 144 | MikroTik hardware — CPU, RAM, storage, ports, PoE, wireless, license level, MSRP |
|
|
373
|
-
| `device_test_results` | 2,874 | Ethernet and IPSec throughput benchmarks for 125 devices — packet sizes, modes, Mbps/Kpps |
|
|
374
|
-
| `changelogs` | varies | Parsed changelog entries per RouterOS version — category, description, breaking flag |
|
|
375
|
-
| `videos` | 518 | MikroTik YouTube video metadata — title, description, duration, chapters |
|
|
376
|
-
| `video_segments` | ~1,890 | Chapter-level transcript segments with timestamps for deep linking |
|
|
377
|
-
|
|
378
|
-
Each content table has a corresponding FTS5 index (e.g., `pages_fts`, `properties_fts`, `devices_fts`, `video_segments_fts`).
|
|
276
|
+
| `routeros_search` | **Start here.** Full-text search across all documentation pages |
|
|
277
|
+
| `routeros_get_page` | Full page content by ID or title, section-aware for large pages |
|
|
278
|
+
| `routeros_lookup_property` | Property by exact name — type, default, description |
|
|
279
|
+
| `routeros_search_properties` | FTS across 4,860 property names and descriptions |
|
|
280
|
+
| `routeros_command_tree` | Browse the command hierarchy (`/ip/firewall/filter` style) |
|
|
281
|
+
| `routeros_search_callouts` | Search warnings, notes, and tips |
|
|
282
|
+
| `routeros_search_changelogs` | Changelogs filtered by version range, category, breaking flag |
|
|
283
|
+
| `routeros_command_version_check` | Which RouterOS versions include a command path |
|
|
284
|
+
| `routeros_command_diff` | Added/removed commands between two RouterOS versions |
|
|
285
|
+
| `routeros_device_lookup` | Hardware specs — filter by architecture, RAM, PoE, wireless, etc. |
|
|
286
|
+
| `routeros_search_tests` | Cross-device ethernet and IPSec benchmarks |
|
|
287
|
+
| `routeros_search_videos` | YouTube transcript search with chapter timestamps |
|
|
288
|
+
| `routeros_stats` | Database health and coverage stats |
|
|
289
|
+
| `routeros_current_versions` | Live-fetch current RouterOS versions from MikroTik |
|
|
290
|
+
|
|
291
|
+
Each tool description includes workflow arrows (`→ next_tool`) and empty-result hints so agents chain tools effectively.
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
## RTFM for Details
|
|
295
|
+
For additional install options, HTTP transport configuration, data source details, and the database schema, see [MANUAL.md](MANUAL.md).
|
|
379
296
|
|
|
380
297
|
## Contributing
|
|
381
298
|
|
package/package.json
CHANGED
package/src/browse.ts
CHANGED
|
@@ -15,6 +15,7 @@ import * as readline from "node:readline";
|
|
|
15
15
|
import { db, getDbStats, initDb } from "./db.ts";
|
|
16
16
|
import { resolveVersion } from "./paths.ts";
|
|
17
17
|
import type {
|
|
18
|
+
CalloutResult,
|
|
18
19
|
ChangelogResult,
|
|
19
20
|
DeviceResult,
|
|
20
21
|
DeviceTestRow,
|
|
@@ -151,11 +152,15 @@ async function paged(output: string): Promise<boolean> {
|
|
|
151
152
|
offset += pageSize;
|
|
152
153
|
if (offset < lines.length) {
|
|
153
154
|
const remaining = lines.length - offset;
|
|
154
|
-
process.stdout.write(dim(
|
|
155
|
+
process.stdout.write(dim(`-- ${remaining} more lines [Q quit | SPACE next page | ENTER next line]`));
|
|
155
156
|
const key = await waitForKey();
|
|
156
157
|
// Clear the "more" line
|
|
157
158
|
process.stdout.write(`\r${" ".repeat(termWidth())}\r`);
|
|
158
159
|
if (key === "q" || key === "Q") return true;
|
|
160
|
+
// ENTER or arrow-down → advance one line; SPACE → advance one page
|
|
161
|
+
if (key === "\r" || key === "\n" || key === "\x1b[B") {
|
|
162
|
+
offset -= pageSize - 1; // back up so only 1 line advances
|
|
163
|
+
}
|
|
159
164
|
}
|
|
160
165
|
}
|
|
161
166
|
return false;
|
|
@@ -184,14 +189,14 @@ type Context =
|
|
|
184
189
|
| { type: "search"; response: SearchResponse; results: SearchResult[] }
|
|
185
190
|
| { type: "page"; pageId: number; title: string; commandPath?: string }
|
|
186
191
|
| { type: "sections"; pageId: number; title: string; sections: SectionTocEntry[] }
|
|
187
|
-
| { type: "properties"; query: string; pageId?: number }
|
|
188
192
|
| { type: "commands"; path: string }
|
|
189
193
|
| { type: "devices"; query: string; results: DeviceResult[] }
|
|
190
194
|
| { type: "device"; device: DeviceResult }
|
|
191
195
|
| { type: "tests" }
|
|
192
|
-
| { type: "callouts"; query: string }
|
|
193
|
-
| { type: "changelogs" }
|
|
194
|
-
| { type: "videos"; query: string }
|
|
196
|
+
| { type: "callouts"; query: string; results: CalloutResult[] }
|
|
197
|
+
| { type: "changelogs"; results: ChangelogResult[] }
|
|
198
|
+
| { type: "videos"; query: string; results: VideoSearchResult[] }
|
|
199
|
+
| { type: "properties"; query: string; pageId?: number; results: Array<{ name: string; page_id: number; page_title: string }> }
|
|
195
200
|
| { type: "diff" }
|
|
196
201
|
| { type: "vcheck"; path: string };
|
|
197
202
|
|
|
@@ -209,6 +214,32 @@ function popCtx(): boolean {
|
|
|
209
214
|
return false;
|
|
210
215
|
}
|
|
211
216
|
|
|
217
|
+
/** Build a context-aware prompt string, mirroring RouterOS [admin@router]> style. */
|
|
218
|
+
function buildPrompt(): string {
|
|
219
|
+
const label = contextLabel(ctx);
|
|
220
|
+
if (label) return `${cyan("rosetta")}${dim("[")}${label}${dim("]>")} `;
|
|
221
|
+
return `${cyan("rosetta")}${dim(">")} `;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function contextLabel(c: Context): string {
|
|
225
|
+
switch (c.type) {
|
|
226
|
+
case "home": return "";
|
|
227
|
+
case "search": return truncate(`search: "${c.response.query}"`, 30);
|
|
228
|
+
case "page": return truncate(c.title, 30);
|
|
229
|
+
case "sections": return truncate(`${c.title} §`, 30);
|
|
230
|
+
case "properties": return truncate(`prop: "${c.query}"`, 30);
|
|
231
|
+
case "commands": return truncate(`cmd: ${c.path || "/"}`, 30);
|
|
232
|
+
case "devices": return truncate(`dev: "${c.query}"`, 30);
|
|
233
|
+
case "device": return truncate(c.device.product_name, 30);
|
|
234
|
+
case "tests": return "tests";
|
|
235
|
+
case "callouts": return c.query ? truncate(`cal: "${c.query}"`, 30) : "callouts";
|
|
236
|
+
case "changelogs": return "changelogs";
|
|
237
|
+
case "videos": return truncate(`vid: "${c.query}"`, 30);
|
|
238
|
+
case "diff": return "diff";
|
|
239
|
+
case "vcheck": return truncate(`vc: ${c.path}`, 30);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
212
243
|
// ── Renderers ──
|
|
213
244
|
|
|
214
245
|
function renderWelcome(): string {
|
|
@@ -250,7 +281,7 @@ function renderSearchResults(resp: SearchResponse): string {
|
|
|
250
281
|
|
|
251
282
|
// Navigation hints
|
|
252
283
|
const hints = [
|
|
253
|
-
`${cyan("
|
|
284
|
+
`${cyan("#N")} view page`,
|
|
254
285
|
`${cyan("[s <query>]")} search`,
|
|
255
286
|
`${cyan("[p <query>]")} properties`,
|
|
256
287
|
`${cyan("[cmd <path>]")} commands`,
|
|
@@ -318,7 +349,7 @@ function renderPage(page: NonNullable<ReturnType<typeof getPage>>): string {
|
|
|
318
349
|
if (page.code) {
|
|
319
350
|
out.push("");
|
|
320
351
|
out.push(dim("── code ──"));
|
|
321
|
-
out.push(page.code);
|
|
352
|
+
out.push(page.code.split("\n").map((l) => ` ${l}`).join("\n"));
|
|
322
353
|
}
|
|
323
354
|
}
|
|
324
355
|
|
|
@@ -336,14 +367,14 @@ function renderPage(page: NonNullable<ReturnType<typeof getPage>>): string {
|
|
|
336
367
|
if (page.code) {
|
|
337
368
|
out.push("");
|
|
338
369
|
out.push(dim("── code ──"));
|
|
339
|
-
out.push(page.code);
|
|
370
|
+
out.push(page.code.split("\n").map((l) => ` ${l}`).join("\n"));
|
|
340
371
|
}
|
|
341
372
|
}
|
|
342
373
|
|
|
343
374
|
// Navigation hints
|
|
344
375
|
out.push("");
|
|
345
376
|
const hints: string[] = [];
|
|
346
|
-
if (page.sections && page.sections.length > 0) hints.push(`${cyan("
|
|
377
|
+
if (page.sections && page.sections.length > 0) hints.push(`${cyan("#N")} section`);
|
|
347
378
|
hints.push(`${cyan("[p]")} properties`);
|
|
348
379
|
hints.push(`${cyan("[cmd]")} command tree`);
|
|
349
380
|
hints.push(`${cyan("[cal]")} callouts`);
|
|
@@ -449,7 +480,7 @@ function renderDeviceResults(results: DeviceResult[], mode: string, total: numbe
|
|
|
449
480
|
out.push("");
|
|
450
481
|
}
|
|
451
482
|
|
|
452
|
-
out.push(` ${cyan("
|
|
483
|
+
out.push(` ${cyan("#N")} view device ${cyan("[tests]")} benchmarks ${cyan("[b]")} back`);
|
|
453
484
|
return out.join("\n");
|
|
454
485
|
}
|
|
455
486
|
|
|
@@ -498,17 +529,24 @@ function renderDeviceCard(d: DeviceResult): string {
|
|
|
498
529
|
out.push(` ${dim("Block diagram:")} ${cyan(link(d.block_diagram_url, "view"))}`);
|
|
499
530
|
}
|
|
500
531
|
|
|
501
|
-
// Test results (attached for exact matches)
|
|
532
|
+
// Test results (attached for exact matches) — default to 512B for a useful baseline
|
|
502
533
|
if (d.test_results && d.test_results.length > 0) {
|
|
534
|
+
const pkt512 = d.test_results.filter((t) => t.packet_size === 512);
|
|
535
|
+
const displayTests = pkt512.length > 0 ? pkt512 : d.test_results.slice(0, 20);
|
|
536
|
+
const pktNote = pkt512.length > 0 ? " (512B baseline)" : "";
|
|
503
537
|
out.push("");
|
|
504
|
-
out.push(` ${bold("Benchmarks:")} ${dim(`(${d.test_results.length} tests)`)}`);
|
|
505
|
-
for (const t of
|
|
538
|
+
out.push(` ${bold("Benchmarks:")} ${dim(`(${displayTests.length} of ${d.test_results.length} tests${pktNote})`)}`);
|
|
539
|
+
for (const t of displayTests) {
|
|
506
540
|
const mbps = t.throughput_mbps ? `${fmt(t.throughput_mbps)} Mbps` : "";
|
|
507
541
|
const kpps = t.throughput_kpps ? `${fmt(t.throughput_kpps)} Kpps` : "";
|
|
508
542
|
out.push(` ${dim(pad(t.test_type, 9))} ${pad(t.mode, 16)} ${dim(pad(t.configuration, 28))} ${pad(`${t.packet_size}B`, 6)} ${bold(mbps)} ${dim(kpps)}`);
|
|
509
543
|
}
|
|
510
|
-
if (d.test_results.length >
|
|
511
|
-
|
|
544
|
+
if (d.test_results.length > displayTests.length) {
|
|
545
|
+
const otherSizes = [...new Set(
|
|
546
|
+
d.test_results.filter((t) => t.packet_size !== 512).map((t) => t.packet_size),
|
|
547
|
+
)].sort((a, b) => a - b);
|
|
548
|
+
const sizesStr = otherSizes.map((s) => `${s}B`).join(", ");
|
|
549
|
+
out.push(` ${dim(`Also at ${sizesStr} — use`)} ${cyan("tests")} ${dim("<type> <size> for other packet sizes")}`);
|
|
512
550
|
}
|
|
513
551
|
}
|
|
514
552
|
|
|
@@ -582,22 +620,24 @@ function renderChangelogs(results: ChangelogResult[]): string {
|
|
|
582
620
|
}
|
|
583
621
|
|
|
584
622
|
let lastVersion = "";
|
|
585
|
-
for (
|
|
623
|
+
for (let i = 0; i < results.length; i++) {
|
|
624
|
+
const c = results[i];
|
|
586
625
|
if (c.version !== lastVersion) {
|
|
587
626
|
out.push("");
|
|
588
627
|
out.push(` ${bold(c.version)} ${dim(c.released ?? "")}`);
|
|
589
628
|
lastVersion = c.version;
|
|
590
629
|
}
|
|
591
|
-
const
|
|
630
|
+
const num = dim(`${String(i + 1).padStart(3)} `);
|
|
631
|
+
const breaking = c.is_breaking ? red("⚠ ") : "";
|
|
592
632
|
const cat = dim(pad(c.category, 14));
|
|
593
633
|
const desc = c.excerpt.includes("**")
|
|
594
634
|
? c.excerpt.replace(/\*\*/g, `${ESC}[1m`)
|
|
595
|
-
: truncate(c.description, termWidth() -
|
|
596
|
-
out.push(
|
|
635
|
+
: truncate(c.description, termWidth() - 26);
|
|
636
|
+
out.push(`${num}${breaking}${cat} ${desc}`);
|
|
597
637
|
}
|
|
598
638
|
|
|
599
639
|
out.push("");
|
|
600
|
-
out.push(` ${cyan("[cl
|
|
640
|
+
out.push(` ${cyan("[cl 7.22 bgp]")} version + topic ${cyan("[cl 7.21..7.22]")} range ${cyan("[cl breaking]")} breaking only ${cyan("[b]")} back`);
|
|
601
641
|
return out.join("\n");
|
|
602
642
|
}
|
|
603
643
|
|
|
@@ -719,25 +759,26 @@ function renderHelp(): string {
|
|
|
719
759
|
out.push(` ${bold("Commands")} ${dim("(bare text = search)")}`);
|
|
720
760
|
out.push("");
|
|
721
761
|
|
|
722
|
-
const cmd = (name: string, alias: string, desc: string) => {
|
|
723
|
-
|
|
762
|
+
const cmd = (name: string, alias: string, desc: string, mcp?: string) => {
|
|
763
|
+
const mcpHint = mcp ? ` ${dim(`(${mcp})`)}` : "";
|
|
764
|
+
out.push(` ${cyan(pad(name, 26))} ${dim(pad(alias, 6))} ${desc}${mcpHint}`);
|
|
724
765
|
};
|
|
725
766
|
|
|
726
|
-
cmd("<query>", "", "Search pages (default action)");
|
|
727
|
-
cmd("search <query>", "s", "Explicit page search");
|
|
728
|
-
cmd("page <id|title>", "", "View full page");
|
|
729
|
-
cmd("prop <name>", "p", "Look up property (scoped to current page)");
|
|
730
|
-
cmd("props <query>", "sp", "Search properties by FTS");
|
|
731
|
-
cmd("cmd [path]", "tree", "Browse command tree");
|
|
732
|
-
cmd("device <query>", "dev", "Look up device specs");
|
|
733
|
-
cmd("tests [type] [mode]", "", "Cross-device benchmarks");
|
|
734
|
-
cmd("callouts [query]", "cal", "Search callouts (type filter: cal warning)");
|
|
735
|
-
cmd("changelog [query]", "cl", "Search changelogs (cl 7.22, cl breaking)");
|
|
736
|
-
cmd("videos <query>", "vid", "Search video transcripts");
|
|
737
|
-
cmd("diff <from> <to> [path]", "", "Command tree diff between versions");
|
|
738
|
-
cmd("vcheck <path>", "vc", "Version range for a command path");
|
|
739
|
-
cmd("versions", "ver", "Live-fetch current RouterOS versions");
|
|
740
|
-
cmd("stats", "", "Database health / counts");
|
|
767
|
+
cmd("<query>", "", "Search pages (default action)", "routeros_search");
|
|
768
|
+
cmd("search <query>", "s", "Explicit page search", "routeros_search");
|
|
769
|
+
cmd("page <id|title>", "", "View full page", "routeros_get_page");
|
|
770
|
+
cmd("prop <name>", "p", "Look up property (scoped to current page)", "routeros_lookup_property");
|
|
771
|
+
cmd("props <query>", "sp", "Search properties by FTS", "routeros_search_properties");
|
|
772
|
+
cmd("cmd [path]", "tree", "Browse command tree", "routeros_command_tree");
|
|
773
|
+
cmd("device <query>", "dev", "Look up device specs", "routeros_device_lookup");
|
|
774
|
+
cmd("tests [device] <type> [packet_size] [mode]", "", "Cross-device benchmarks (512B default)", "routeros_search_tests");
|
|
775
|
+
cmd("callouts [query]", "cal", "Search callouts (type filter: cal warning)", "routeros_search_callouts");
|
|
776
|
+
cmd("changelog [query]", "cl", "Search changelogs (cl 7.22, cl breaking)", "routeros_search_changelogs");
|
|
777
|
+
cmd("videos <query>", "vid", "Search video transcripts", "routeros_search_videos");
|
|
778
|
+
cmd("diff <from> <to> [path]", "", "Command tree diff between versions", "routeros_command_diff");
|
|
779
|
+
cmd("vcheck <path>", "vc", "Version range for a command path", "routeros_command_version_check");
|
|
780
|
+
cmd("versions", "ver", "Live-fetch current RouterOS versions", "routeros_current_versions");
|
|
781
|
+
cmd("stats", "", "Database health / counts", "routeros_stats");
|
|
741
782
|
cmd("back", "b", "Go to previous view");
|
|
742
783
|
cmd("help", "?", "This help");
|
|
743
784
|
cmd("quit", "q", "Exit");
|
|
@@ -750,8 +791,69 @@ function renderHelp(): string {
|
|
|
750
791
|
return out.join("\n");
|
|
751
792
|
}
|
|
752
793
|
|
|
794
|
+
/** Fetch the full transcript text for a specific video segment. */
|
|
795
|
+
function getVideoSegmentTranscript(youtubeVideoId: string, startS: number): string | null {
|
|
796
|
+
try {
|
|
797
|
+
const row = db
|
|
798
|
+
.prepare(
|
|
799
|
+
`SELECT vs.transcript FROM video_segments vs
|
|
800
|
+
JOIN videos v ON v.id = vs.video_id
|
|
801
|
+
WHERE v.video_id = ? AND vs.start_s = ? LIMIT 1`,
|
|
802
|
+
)
|
|
803
|
+
.get(youtubeVideoId, startS) as { transcript: string } | null;
|
|
804
|
+
return row?.transcript ?? null;
|
|
805
|
+
} catch {
|
|
806
|
+
return null;
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
|
|
753
810
|
// ── Command dispatcher ──
|
|
754
811
|
|
|
812
|
+
/** Re-render the current context after going back — uses cached results where possible. */
|
|
813
|
+
async function renderCurrentCtx(): Promise<void> {
|
|
814
|
+
switch (ctx.type) {
|
|
815
|
+
case "home":
|
|
816
|
+
console.log(renderWelcome());
|
|
817
|
+
return;
|
|
818
|
+
case "search":
|
|
819
|
+
await paged(renderSearchResults(ctx.response));
|
|
820
|
+
return;
|
|
821
|
+
case "commands": {
|
|
822
|
+
const children = browseCommands(ctx.path);
|
|
823
|
+
if (children.length > 0) await paged(renderCommandTree(ctx.path, children));
|
|
824
|
+
else console.log(dim(` No children at "${ctx.path}".`));
|
|
825
|
+
return;
|
|
826
|
+
}
|
|
827
|
+
case "devices":
|
|
828
|
+
await paged(renderDeviceResults(ctx.results, "cached", ctx.results.length));
|
|
829
|
+
return;
|
|
830
|
+
case "device":
|
|
831
|
+
await paged(renderDeviceCard(ctx.device));
|
|
832
|
+
return;
|
|
833
|
+
case "callouts":
|
|
834
|
+
if (ctx.results.length > 0) await paged(renderCallouts(ctx.results));
|
|
835
|
+
return;
|
|
836
|
+
case "changelogs":
|
|
837
|
+
if (ctx.results.length > 0) await paged(renderChangelogs(ctx.results));
|
|
838
|
+
return;
|
|
839
|
+
case "videos":
|
|
840
|
+
if (ctx.results.length > 0) await paged(renderVideos(ctx.results));
|
|
841
|
+
return;
|
|
842
|
+
case "page": {
|
|
843
|
+
const page = getPage(ctx.pageId);
|
|
844
|
+
if (page) await paged(renderPage(page));
|
|
845
|
+
return;
|
|
846
|
+
}
|
|
847
|
+
case "sections": {
|
|
848
|
+
const page = getPage(ctx.pageId);
|
|
849
|
+
if (page) await paged(renderPage(page));
|
|
850
|
+
return;
|
|
851
|
+
}
|
|
852
|
+
default:
|
|
853
|
+
console.log(dim(` In ${ctx.type} context. Type a command or help.`));
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
|
|
755
857
|
async function dispatch(input: string): Promise<void> {
|
|
756
858
|
const trimmed = input.trim();
|
|
757
859
|
if (!trimmed) return;
|
|
@@ -786,7 +888,8 @@ async function dispatch(input: string): Promise<void> {
|
|
|
786
888
|
if (!popCtx()) {
|
|
787
889
|
console.log(dim(" Already at top."));
|
|
788
890
|
} else {
|
|
789
|
-
console.log(dim(` ←
|
|
891
|
+
console.log(dim(` ← ${ctx.type}`));
|
|
892
|
+
await renderCurrentCtx();
|
|
790
893
|
}
|
|
791
894
|
return;
|
|
792
895
|
|
|
@@ -801,7 +904,20 @@ async function dispatch(input: string): Promise<void> {
|
|
|
801
904
|
return;
|
|
802
905
|
|
|
803
906
|
case "page": {
|
|
804
|
-
if (!rest) {
|
|
907
|
+
if (!rest) {
|
|
908
|
+
// From commands context: navigate to the linked page
|
|
909
|
+
if (ctx.type === "commands") {
|
|
910
|
+
const row = db.prepare("SELECT page_id FROM commands WHERE path = ? LIMIT 1").get(ctx.path) as { page_id: number | null } | null;
|
|
911
|
+
if (row?.page_id) {
|
|
912
|
+
await doPage(String(row.page_id));
|
|
913
|
+
return;
|
|
914
|
+
}
|
|
915
|
+
console.log(dim(` No linked page for ${ctx.path}.`));
|
|
916
|
+
return;
|
|
917
|
+
}
|
|
918
|
+
console.log(dim(" Usage: page <id|title>"));
|
|
919
|
+
return;
|
|
920
|
+
}
|
|
805
921
|
await doPage(rest);
|
|
806
922
|
return;
|
|
807
923
|
}
|
|
@@ -809,13 +925,12 @@ async function dispatch(input: string): Promise<void> {
|
|
|
809
925
|
case "p":
|
|
810
926
|
case "prop": {
|
|
811
927
|
if (!rest) {
|
|
812
|
-
// Context-scoped: show properties for current page
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
}
|
|
928
|
+
// Context-scoped: show properties for current page or sections context
|
|
929
|
+
const pageId = ctx.type === "page" ? ctx.pageId : ctx.type === "sections" ? ctx.pageId : null;
|
|
930
|
+
const title = ctx.type === "page" ? ctx.title : ctx.type === "sections" ? ctx.title : null;
|
|
931
|
+
if (pageId !== null && title !== null) {
|
|
932
|
+
await doPropsForPage(pageId, title);
|
|
933
|
+
return;
|
|
819
934
|
}
|
|
820
935
|
console.log(dim(" Usage: prop <name> — or navigate to a page first"));
|
|
821
936
|
return;
|
|
@@ -833,8 +948,15 @@ async function dispatch(input: string): Promise<void> {
|
|
|
833
948
|
|
|
834
949
|
case "cmd":
|
|
835
950
|
case "tree": {
|
|
836
|
-
|
|
837
|
-
|
|
951
|
+
let cmdPath = rest;
|
|
952
|
+
if (!cmdPath && ctx.type === "commands") {
|
|
953
|
+
// Re-display current commands context
|
|
954
|
+
cmdPath = ctx.path;
|
|
955
|
+
} else if (cmdPath && !cmdPath.startsWith("/") && ctx.type === "commands") {
|
|
956
|
+
// Relative path: resolve against current commands path
|
|
957
|
+
cmdPath = `${ctx.path}/${cmdPath}`;
|
|
958
|
+
}
|
|
959
|
+
await doCommandTree(cmdPath);
|
|
838
960
|
return;
|
|
839
961
|
}
|
|
840
962
|
|
|
@@ -852,13 +974,24 @@ async function dispatch(input: string): Promise<void> {
|
|
|
852
974
|
|
|
853
975
|
case "cal":
|
|
854
976
|
case "callouts": {
|
|
855
|
-
if (!rest
|
|
856
|
-
//
|
|
857
|
-
const
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
977
|
+
if (!rest) {
|
|
978
|
+
// Context-aware: show callouts for the current page directly
|
|
979
|
+
const pageId = ctx.type === "page" ? ctx.pageId
|
|
980
|
+
: ctx.type === "sections" ? ctx.pageId
|
|
981
|
+
: null;
|
|
982
|
+
if (pageId !== null) {
|
|
983
|
+
const pageCallouts = db.prepare(
|
|
984
|
+
`SELECT c.type, c.content, p.title as page_title, p.url as page_url,
|
|
985
|
+
c.page_id, c.content as excerpt
|
|
986
|
+
FROM callouts c JOIN pages p ON p.id = c.page_id
|
|
987
|
+
WHERE c.page_id = ? ORDER BY c.sort_order`,
|
|
988
|
+
).all(pageId) as CalloutResult[];
|
|
989
|
+
if (pageCallouts.length > 0) {
|
|
990
|
+
await paged(renderCallouts(pageCallouts));
|
|
991
|
+
pushCtx({ type: "callouts", query: "", results: pageCallouts });
|
|
992
|
+
} else {
|
|
993
|
+
console.log(dim(" No callouts for this page."));
|
|
994
|
+
}
|
|
862
995
|
return;
|
|
863
996
|
}
|
|
864
997
|
}
|
|
@@ -873,6 +1006,7 @@ async function dispatch(input: string): Promise<void> {
|
|
|
873
1006
|
}
|
|
874
1007
|
|
|
875
1008
|
case "vid":
|
|
1009
|
+
case "video":
|
|
876
1010
|
case "videos": {
|
|
877
1011
|
if (!rest) { console.log(dim(" Usage: videos <query>")); return; }
|
|
878
1012
|
await doSearchVideos(rest);
|
|
@@ -935,6 +1069,39 @@ async function handleNumberSelect(idx: number): Promise<void> {
|
|
|
935
1069
|
}
|
|
936
1070
|
return;
|
|
937
1071
|
}
|
|
1072
|
+
if (ctx.type === "callouts" && ctx.results[idx]) {
|
|
1073
|
+
const c = ctx.results[idx];
|
|
1074
|
+
await doPage(String(c.page_id));
|
|
1075
|
+
return;
|
|
1076
|
+
}
|
|
1077
|
+
if (ctx.type === "videos" && ctx.results[idx]) {
|
|
1078
|
+
const v = ctx.results[idx];
|
|
1079
|
+
const timeUrl = v.start_s > 0 ? `${v.url}&t=${v.start_s}` : v.url;
|
|
1080
|
+
const out: string[] = [];
|
|
1081
|
+
out.push(`\n ${bold(v.title)}`);
|
|
1082
|
+
if (v.chapter_title) out.push(` ${magenta(`§ ${v.chapter_title}`)} ${dim(`@ ${formatTime(v.start_s)}`)}`);
|
|
1083
|
+
out.push(` ${cyan(link(timeUrl))}`);
|
|
1084
|
+
// Fetch and show full transcript for this segment
|
|
1085
|
+
const transcript = getVideoSegmentTranscript(v.video_id, v.start_s);
|
|
1086
|
+
if (transcript) {
|
|
1087
|
+
out.push("");
|
|
1088
|
+
out.push(` ${dim("── transcript ──")}`);
|
|
1089
|
+
out.push(transcript.split("\n").map((l) => ` ${l}`).join("\n"));
|
|
1090
|
+
}
|
|
1091
|
+
await paged(out.join("\n"));
|
|
1092
|
+
return;
|
|
1093
|
+
}
|
|
1094
|
+
if (ctx.type === "properties" && ctx.results[idx]) {
|
|
1095
|
+
const p = ctx.results[idx];
|
|
1096
|
+
await doPage(String(p.page_id));
|
|
1097
|
+
return;
|
|
1098
|
+
}
|
|
1099
|
+
if (ctx.type === "changelogs" && ctx.results[idx]) {
|
|
1100
|
+
const c = ctx.results[idx];
|
|
1101
|
+
console.log(`\n ${bold(c.version)} ${dim(c.released ?? "")} ${dim(c.category)}${c.is_breaking ? ` ${red("⚠ BREAKING")}` : ""}`);
|
|
1102
|
+
console.log(` ${c.description}\n`);
|
|
1103
|
+
return;
|
|
1104
|
+
}
|
|
938
1105
|
console.log(dim(` No item #${idx + 1} in current view.`));
|
|
939
1106
|
}
|
|
940
1107
|
|
|
@@ -987,7 +1154,7 @@ async function doPropsForPage(pageId: number, title: string): Promise<void> {
|
|
|
987
1154
|
return;
|
|
988
1155
|
}
|
|
989
1156
|
await paged(` ${bold("Properties for")} ${bold(title)}\n\n${renderProperties(pageProps)}`);
|
|
990
|
-
pushCtx({ type: "properties", query: title, pageId });
|
|
1157
|
+
pushCtx({ type: "properties", query: title, pageId, results: pageProps.map(p => ({ name: p.name, page_id: p.page_id ?? pageId, page_title: p.page_title })) });
|
|
991
1158
|
}
|
|
992
1159
|
|
|
993
1160
|
async function doLookupProperty(name: string): Promise<void> {
|
|
@@ -999,7 +1166,7 @@ async function doLookupProperty(name: string): Promise<void> {
|
|
|
999
1166
|
return;
|
|
1000
1167
|
}
|
|
1001
1168
|
await paged(renderProperties(results));
|
|
1002
|
-
pushCtx({ type: "properties", query: name });
|
|
1169
|
+
pushCtx({ type: "properties", query: name, results: results.map(p => ({ name: p.name, page_id: p.page_id, page_title: p.page_title })) });
|
|
1003
1170
|
}
|
|
1004
1171
|
|
|
1005
1172
|
async function doSearchProperties(query: string): Promise<void> {
|
|
@@ -1009,7 +1176,7 @@ async function doSearchProperties(query: string): Promise<void> {
|
|
|
1009
1176
|
return;
|
|
1010
1177
|
}
|
|
1011
1178
|
await paged(` ${bold(String(results.length))} properties matching ${cyan(`"${query}"`)}\n\n${renderProperties(results)}`);
|
|
1012
|
-
pushCtx({ type: "properties", query });
|
|
1179
|
+
pushCtx({ type: "properties", query, results: results.map(p => ({ name: p.name, page_id: p.page_id, page_title: p.page_title })) });
|
|
1013
1180
|
}
|
|
1014
1181
|
|
|
1015
1182
|
async function doCommandTree(path: string): Promise<void> {
|
|
@@ -1048,24 +1215,64 @@ async function doDeviceLookup(query: string): Promise<void> {
|
|
|
1048
1215
|
}
|
|
1049
1216
|
|
|
1050
1217
|
async function doTests(argsStr: string): Promise<void> {
|
|
1051
|
-
|
|
1052
|
-
|
|
1218
|
+
const parts = argsStr.split(/\s+/).filter(Boolean);
|
|
1219
|
+
const filters: Record<string, string | number> = {};
|
|
1220
|
+
|
|
1221
|
+
// Parse args if provided
|
|
1222
|
+
if (argsStr) {
|
|
1223
|
+
const knownTypes = ["ethernet", "ipsec"];
|
|
1224
|
+
let offset = 0;
|
|
1225
|
+
if (parts[0] && !knownTypes.includes(parts[0].toLowerCase())) {
|
|
1226
|
+
filters.device = parts[0];
|
|
1227
|
+
offset = 1;
|
|
1228
|
+
}
|
|
1229
|
+
if (parts[offset]) filters.test_type = parts[offset];
|
|
1230
|
+
// packet_size is always numeric; mode can be multi-word — identify by type
|
|
1231
|
+
const remaining = parts.slice(offset + 1);
|
|
1232
|
+
const pktIdx = remaining.findIndex((p) => /^\d+$/.test(p));
|
|
1233
|
+
if (pktIdx !== -1) {
|
|
1234
|
+
filters.packet_size = Number.parseInt(remaining[pktIdx], 10);
|
|
1235
|
+
const modeParts = [...remaining.slice(0, pktIdx), ...remaining.slice(pktIdx + 1)];
|
|
1236
|
+
if (modeParts.length > 0) filters.mode = modeParts.join(" ");
|
|
1237
|
+
} else if (remaining.length > 0) {
|
|
1238
|
+
filters.mode = remaining.join(" ");
|
|
1239
|
+
}
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
// Auto-inject device name from context when in a device view and none specified
|
|
1243
|
+
if (!filters.device && ctx.type === "device") {
|
|
1244
|
+
filters.device = ctx.device.product_name;
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
// Show help if no filters could be determined
|
|
1248
|
+
if (!argsStr && !filters.device) {
|
|
1053
1249
|
const meta = getTestResultMeta();
|
|
1054
1250
|
console.log(` ${bold("Test filters:")}`);
|
|
1055
1251
|
console.log(` ${dim("Types:")} ${meta.test_types.join(", ")}`);
|
|
1056
1252
|
console.log(` ${dim("Modes:")} ${meta.modes.join(", ")}`);
|
|
1057
1253
|
console.log(` ${dim("Packet sizes:")} ${meta.packet_sizes.join(", ")}`);
|
|
1058
1254
|
console.log("");
|
|
1059
|
-
console.log(` ${dim("Usage: tests <type> [
|
|
1255
|
+
console.log(` ${dim("Usage: tests [device] <type> [packet_size] [mode]")}`);
|
|
1060
1256
|
console.log(` ${dim("Example: tests ethernet Routing 1518")}`);
|
|
1257
|
+
console.log(` ${dim("Example: tests rb5009 ethernet 1518")}`);
|
|
1061
1258
|
return;
|
|
1062
1259
|
}
|
|
1063
1260
|
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1261
|
+
// Round packet_size to the nearest valid test packet size
|
|
1262
|
+
if (filters.packet_size) {
|
|
1263
|
+
const validSizes = [64, 512, 1400, 1518];
|
|
1264
|
+
const size = Number(filters.packet_size);
|
|
1265
|
+
const rounded = validSizes.reduce((prev, curr) =>
|
|
1266
|
+
Math.abs(curr - size) < Math.abs(prev - size) ? curr : prev,
|
|
1267
|
+
);
|
|
1268
|
+
if (rounded !== size) {
|
|
1269
|
+
console.log(dim(` Rounding ${size}B → ${rounded}B (nearest test packet size)`));
|
|
1270
|
+
filters.packet_size = rounded;
|
|
1271
|
+
}
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
// Default packet_size to 512 for a focused baseline comparison
|
|
1275
|
+
if (!filters.packet_size && !filters.mode) filters.packet_size = 512;
|
|
1069
1276
|
|
|
1070
1277
|
const result = searchDeviceTests(filters);
|
|
1071
1278
|
if (result.results.length === 0) {
|
|
@@ -1094,7 +1301,7 @@ async function doSearchCallouts(query: string): Promise<void> {
|
|
|
1094
1301
|
return;
|
|
1095
1302
|
}
|
|
1096
1303
|
await paged(` ${bold(String(results.length))} callouts${type ? ` (${type})` : ""}\n\n${renderCallouts(results)}`);
|
|
1097
|
-
pushCtx({ type: "callouts", query });
|
|
1304
|
+
pushCtx({ type: "callouts", query, results });
|
|
1098
1305
|
}
|
|
1099
1306
|
|
|
1100
1307
|
async function doSearchChangelogs(query: string): Promise<void> {
|
|
@@ -1135,7 +1342,7 @@ async function doSearchChangelogs(query: string): Promise<void> {
|
|
|
1135
1342
|
return;
|
|
1136
1343
|
}
|
|
1137
1344
|
await paged(` ${bold("Changelogs")}${version ? ` for ${bold(version)}` : ""}${breakingOnly ? ` ${red("(breaking only)")}` : ""}\n\n${renderChangelogs(results)}`);
|
|
1138
|
-
pushCtx({ type: "changelogs" });
|
|
1345
|
+
pushCtx({ type: "changelogs", results });
|
|
1139
1346
|
}
|
|
1140
1347
|
|
|
1141
1348
|
async function doSearchVideos(query: string): Promise<void> {
|
|
@@ -1145,7 +1352,7 @@ async function doSearchVideos(query: string): Promise<void> {
|
|
|
1145
1352
|
return;
|
|
1146
1353
|
}
|
|
1147
1354
|
await paged(` ${bold(String(results.length))} video results for ${cyan(`"${query}"`)}\n\n${renderVideos(results)}`);
|
|
1148
|
-
pushCtx({ type: "videos", query });
|
|
1355
|
+
pushCtx({ type: "videos", query, results });
|
|
1149
1356
|
}
|
|
1150
1357
|
|
|
1151
1358
|
async function doDiff(from: string, to: string, pathPrefix?: string): Promise<void> {
|
|
@@ -1210,7 +1417,7 @@ async function main() {
|
|
|
1210
1417
|
const rl = readline.createInterface({
|
|
1211
1418
|
input: process.stdin,
|
|
1212
1419
|
output: process.stdout,
|
|
1213
|
-
prompt:
|
|
1420
|
+
prompt: buildPrompt(),
|
|
1214
1421
|
terminal: true,
|
|
1215
1422
|
});
|
|
1216
1423
|
|
|
@@ -1222,6 +1429,10 @@ async function main() {
|
|
|
1222
1429
|
} catch (err) {
|
|
1223
1430
|
console.error(red(` Error: ${err instanceof Error ? err.message : String(err)}`));
|
|
1224
1431
|
}
|
|
1432
|
+
// Clear any keys that leaked from the pager into readline's internal line buffer
|
|
1433
|
+
// (the key pressed to exit paging — q/SPACE/ENTER — can appear in the next prompt)
|
|
1434
|
+
rl.write(null as unknown as string, { ctrl: true, name: "u" });
|
|
1435
|
+
rl.setPrompt(buildPrompt());
|
|
1225
1436
|
rl.prompt();
|
|
1226
1437
|
});
|
|
1227
1438
|
|
package/src/extract-html.ts
CHANGED
|
@@ -179,6 +179,13 @@ function extractPage(file: string, html: string): (PageRow & { callouts: Callout
|
|
|
179
179
|
const codeLang = codeLangs.size > 0 ? [...codeLangs].join(",") : null;
|
|
180
180
|
const codeLines = code.split("\n").filter((l) => l.trim()).length;
|
|
181
181
|
|
|
182
|
+
// Remove <style> elements before extracting text — they contain Confluence
|
|
183
|
+
// table-of-contents CSS (CDATA blocks like div.rbtoc... {padding:0}) that
|
|
184
|
+
// otherwise pollute the plain-text index and appear in page views.
|
|
185
|
+
for (const styleEl of mainContent?.querySelectorAll("style") ?? []) {
|
|
186
|
+
styleEl.remove();
|
|
187
|
+
}
|
|
188
|
+
|
|
182
189
|
// Plain text from main content (includes code block text too, which is fine for FTS)
|
|
183
190
|
const text = mainContent?.textContent?.trim() || "";
|
|
184
191
|
const wordCount = text.split(/\s+/).filter(Boolean).length;
|
package/src/mcp.ts
CHANGED
|
@@ -978,6 +978,10 @@ Workflow:
|
|
|
978
978
|
→ routeros_device_lookup: get full specs (CPU, RAM, pricing) + block diagram for a specific device
|
|
979
979
|
→ routeros_search: find documentation about features relevant to the test type`,
|
|
980
980
|
inputSchema: {
|
|
981
|
+
device: z
|
|
982
|
+
.string()
|
|
983
|
+
.optional()
|
|
984
|
+
.describe("Filter by device product name (substring match, e.g., 'RB5009', 'hAP', 'CCR2216')"),
|
|
981
985
|
test_type: z
|
|
982
986
|
.string()
|
|
983
987
|
.optional()
|
|
@@ -1010,8 +1014,8 @@ Workflow:
|
|
|
1010
1014
|
.describe("Max results (default 50, max 200)"),
|
|
1011
1015
|
},
|
|
1012
1016
|
},
|
|
1013
|
-
async ({ test_type, mode, configuration, packet_size, sort_by, limit }) => {
|
|
1014
|
-
const hasFilters = test_type || mode || configuration || packet_size;
|
|
1017
|
+
async ({ device, test_type, mode, configuration, packet_size, sort_by, limit }) => {
|
|
1018
|
+
const hasFilters = device || test_type || mode || configuration || packet_size;
|
|
1015
1019
|
|
|
1016
1020
|
if (!hasFilters) {
|
|
1017
1021
|
// Discovery mode: return available filter values
|
|
@@ -1028,8 +1032,17 @@ Workflow:
|
|
|
1028
1032
|
};
|
|
1029
1033
|
}
|
|
1030
1034
|
|
|
1035
|
+
// Round packet_size to the nearest valid test size (64, 512, 1400, 1518)
|
|
1036
|
+
let effectivePacketSize = packet_size;
|
|
1037
|
+
if (packet_size !== undefined) {
|
|
1038
|
+
const validSizes = [64, 512, 1400, 1518];
|
|
1039
|
+
effectivePacketSize = validSizes.reduce((prev, curr) =>
|
|
1040
|
+
Math.abs(curr - packet_size) < Math.abs(prev - packet_size) ? curr : prev,
|
|
1041
|
+
);
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1031
1044
|
const result = searchDeviceTests(
|
|
1032
|
-
{ test_type, mode, configuration, packet_size, sort_by },
|
|
1045
|
+
{ device, test_type, mode, configuration, packet_size: effectivePacketSize, sort_by },
|
|
1033
1046
|
limit,
|
|
1034
1047
|
);
|
|
1035
1048
|
|
|
@@ -1052,6 +1065,9 @@ Workflow:
|
|
|
1052
1065
|
text: JSON.stringify({
|
|
1053
1066
|
...result,
|
|
1054
1067
|
has_more: result.total > result.results.length,
|
|
1068
|
+
...(effectivePacketSize !== packet_size && packet_size !== undefined
|
|
1069
|
+
? { packet_size_rounded: `${packet_size} → ${effectivePacketSize} (nearest valid test size)` }
|
|
1070
|
+
: {}),
|
|
1055
1071
|
}, null, 2),
|
|
1056
1072
|
}],
|
|
1057
1073
|
};
|
package/src/query.test.ts
CHANGED
|
@@ -267,6 +267,10 @@ beforeAll(() => {
|
|
|
267
267
|
db.run(`INSERT INTO device_test_results
|
|
268
268
|
(device_id, test_type, mode, configuration, packet_size, throughput_kpps, throughput_mbps)
|
|
269
269
|
VALUES (1, 'ipsec', 'Single tunnel', 'AES-128-CBC + SHA1', 1400, 120.9, 1354.1)`);
|
|
270
|
+
// RB5009UG+S+IN = id 9 (9th device inserted)
|
|
271
|
+
db.run(`INSERT INTO device_test_results
|
|
272
|
+
(device_id, test_type, mode, configuration, packet_size, throughput_kpps, throughput_mbps)
|
|
273
|
+
VALUES (9, 'ethernet', 'Routing', 'none (fast path)', 1518, 1613.0, 19577.3)`);
|
|
270
274
|
|
|
271
275
|
// Page 3: a "large" page with sections for TOC testing
|
|
272
276
|
// Text is ~200 chars to keep fixture small, but we'll use max_length=50 to trigger truncation
|
|
@@ -1154,14 +1158,14 @@ describe("searchDevices", () => {
|
|
|
1154
1158
|
describe("searchDeviceTests", () => {
|
|
1155
1159
|
test("returns all test results with no filters", () => {
|
|
1156
1160
|
const res = searchDeviceTests({});
|
|
1157
|
-
expect(res.results.length).toBe(
|
|
1158
|
-
expect(res.total).toBe(
|
|
1161
|
+
expect(res.results.length).toBe(4);
|
|
1162
|
+
expect(res.total).toBe(4);
|
|
1159
1163
|
});
|
|
1160
1164
|
|
|
1161
1165
|
test("filters by test_type", () => {
|
|
1162
1166
|
const res = searchDeviceTests({ test_type: "ethernet" });
|
|
1163
1167
|
expect(res.results.every((r) => r.test_type === "ethernet")).toBe(true);
|
|
1164
|
-
expect(res.results.length).toBe(
|
|
1168
|
+
expect(res.results.length).toBe(3);
|
|
1165
1169
|
});
|
|
1166
1170
|
|
|
1167
1171
|
test("filters by test_type and mode", () => {
|
|
@@ -1181,6 +1185,32 @@ describe("searchDeviceTests", () => {
|
|
|
1181
1185
|
expect(res.results.every((r) => r.packet_size === 512)).toBe(true);
|
|
1182
1186
|
});
|
|
1183
1187
|
|
|
1188
|
+
test("filters by device name (substring match)", () => {
|
|
1189
|
+
const res = searchDeviceTests({ device: "hAP" });
|
|
1190
|
+
expect(res.results).toHaveLength(3);
|
|
1191
|
+
expect(res.results.every((r) => r.product_name.includes("hAP"))).toBe(true);
|
|
1192
|
+
});
|
|
1193
|
+
|
|
1194
|
+
test("device filter combined with test_type", () => {
|
|
1195
|
+
const res = searchDeviceTests({ device: "hAP", test_type: "ethernet" });
|
|
1196
|
+
expect(res.results).toHaveLength(2);
|
|
1197
|
+
expect(res.results.every((r) => r.test_type === "ethernet")).toBe(true);
|
|
1198
|
+
expect(res.results.every((r) => r.product_name.includes("hAP"))).toBe(true);
|
|
1199
|
+
});
|
|
1200
|
+
|
|
1201
|
+
test("device filter with no matches returns empty", () => {
|
|
1202
|
+
const res = searchDeviceTests({ device: "Audience" });
|
|
1203
|
+
expect(res.results).toHaveLength(0);
|
|
1204
|
+
expect(res.total).toBe(0);
|
|
1205
|
+
});
|
|
1206
|
+
|
|
1207
|
+
test("device filter for RB5009 returns its test results", () => {
|
|
1208
|
+
const res = searchDeviceTests({ device: "RB5009" });
|
|
1209
|
+
expect(res.results).toHaveLength(1);
|
|
1210
|
+
expect(res.results[0].product_name).toBe("RB5009UG+S+IN");
|
|
1211
|
+
expect(res.results[0].packet_size).toBe(1518);
|
|
1212
|
+
});
|
|
1213
|
+
|
|
1184
1214
|
test("sorts by mbps descending by default", () => {
|
|
1185
1215
|
const res = searchDeviceTests({ test_type: "ethernet" });
|
|
1186
1216
|
if (res.results.length >= 2) {
|
|
@@ -1210,7 +1240,7 @@ describe("searchDeviceTests", () => {
|
|
|
1210
1240
|
test("respects limit", () => {
|
|
1211
1241
|
const res = searchDeviceTests({}, 1);
|
|
1212
1242
|
expect(res.results).toHaveLength(1);
|
|
1213
|
-
expect(res.total).toBe(
|
|
1243
|
+
expect(res.total).toBe(4);
|
|
1214
1244
|
});
|
|
1215
1245
|
});
|
|
1216
1246
|
|
|
@@ -1220,9 +1250,10 @@ describe("dataset CSV exports", () => {
|
|
|
1220
1250
|
const lines = csv.trim().split("\n");
|
|
1221
1251
|
|
|
1222
1252
|
expect(lines[0]).toBe("product_name,product_code,architecture,cpu,cpu_cores,cpu_frequency,test_type,mode,configuration,packet_size,throughput_kpps,throughput_mbps,product_url");
|
|
1223
|
-
expect(lines).toHaveLength(
|
|
1253
|
+
expect(lines).toHaveLength(5);
|
|
1224
1254
|
expect(csv).toContain("hAP ax3");
|
|
1225
1255
|
expect(csv).toContain("IPQ-6010");
|
|
1256
|
+
expect(csv).toContain("RB5009UG+S+IN");
|
|
1226
1257
|
expect(csv).toContain("https://mikrotik.com/product/hap_ax3");
|
|
1227
1258
|
});
|
|
1228
1259
|
|
package/src/query.ts
CHANGED
|
@@ -458,6 +458,7 @@ export function searchProperties(
|
|
|
458
458
|
default_val: string | null;
|
|
459
459
|
description: string;
|
|
460
460
|
section: string | null;
|
|
461
|
+
page_id: number;
|
|
461
462
|
page_title: string;
|
|
462
463
|
page_url: string;
|
|
463
464
|
excerpt: string;
|
|
@@ -486,6 +487,7 @@ function runPropertiesFtsQuery(
|
|
|
486
487
|
default_val: string | null;
|
|
487
488
|
description: string;
|
|
488
489
|
section: string | null;
|
|
490
|
+
page_id: number;
|
|
489
491
|
page_title: string;
|
|
490
492
|
page_url: string;
|
|
491
493
|
excerpt: string;
|
|
@@ -495,7 +497,7 @@ function runPropertiesFtsQuery(
|
|
|
495
497
|
return db
|
|
496
498
|
.prepare(
|
|
497
499
|
`SELECT p.name, p.type, p.default_val, p.description, p.section,
|
|
498
|
-
pg.title as page_title, pg.url as page_url,
|
|
500
|
+
pg.id as page_id, pg.title as page_title, pg.url as page_url,
|
|
499
501
|
snippet(properties_fts, 1, '**', '**', '...', 20) as excerpt
|
|
500
502
|
FROM properties_fts fts
|
|
501
503
|
JOIN properties p ON p.id = fts.rowid
|
|
@@ -509,6 +511,7 @@ function runPropertiesFtsQuery(
|
|
|
509
511
|
default_val: string | null;
|
|
510
512
|
description: string;
|
|
511
513
|
section: string | null;
|
|
514
|
+
page_id: number;
|
|
512
515
|
page_title: string;
|
|
513
516
|
page_url: string;
|
|
514
517
|
excerpt: string;
|
|
@@ -518,7 +521,7 @@ function runPropertiesFtsQuery(
|
|
|
518
521
|
}
|
|
519
522
|
}
|
|
520
523
|
|
|
521
|
-
type CalloutResult = {
|
|
524
|
+
export type CalloutResult = {
|
|
522
525
|
type: string;
|
|
523
526
|
content: string;
|
|
524
527
|
page_title: string;
|
|
@@ -1088,6 +1091,7 @@ export type DeviceTestRow = {
|
|
|
1088
1091
|
};
|
|
1089
1092
|
|
|
1090
1093
|
type DeviceTestFilters = {
|
|
1094
|
+
device?: string;
|
|
1091
1095
|
test_type?: string;
|
|
1092
1096
|
mode?: string;
|
|
1093
1097
|
configuration?: string;
|
|
@@ -1099,6 +1103,10 @@ function buildTestWhereClause(filters: DeviceTestFilters): { whereClause: string
|
|
|
1099
1103
|
const where: string[] = [];
|
|
1100
1104
|
const params: (string | number)[] = [];
|
|
1101
1105
|
|
|
1106
|
+
if (filters.device) {
|
|
1107
|
+
where.push("d.product_name LIKE ?");
|
|
1108
|
+
params.push(`%${filters.device}%`);
|
|
1109
|
+
}
|
|
1102
1110
|
if (filters.test_type) {
|
|
1103
1111
|
where.push("t.test_type = ?");
|
|
1104
1112
|
params.push(filters.test_type);
|