routeros-tikoci-quickchr-cli
Answer a RouterOS question by asking RouterOS. quickchr boots a real, disposable MikroTik CHR router on the local machine under QEMU — no hardware, no cloud, gone when you remove it — and centrs drive
SKILL.mdrouteros-tikoci-quickchr-cli/SKILL.md
GitHubCHR from the shell: quickchr CLI + centrs
quickchr (@tikoci/quickchr, CLI + Bun/TS
library) downloads a MikroTik CHR image, boots it under QEMU, and hands you a
router. This skill is the shell-first path: you have a terminal, you want a
router to interrogate, and the answer will be quoted as shell + RouterOS
commands (e.g. in a forum.mikrotik.com reply). If you are writing a
bun:test harness that imports QuickCHR, use the
routeros-tikoci-quickchr skill instead — one scheme per task, never both.[^split]
Behavior below is pinned to 0.5.2. Install with
bun install -g @tikoci/quickchr@0.5.2, or invoke
bunx @tikoci/quickchr@0.5.2 <command>. Check yours with
quickchr --version. Where 0.4.8 and earlier differ in a way that can
hurt you, the text says so inline; the rest of the version history
lives in the footnotes.[^pin]
One limit to keep in view: a CHR booted this way runs the free
license (quickchr get <name> shows Level: free), which MikroTik caps
at 1 Mbps upload per interface — every other RouterOS feature is
unrestricted. Config, API and CLI grounding are unaffected; a throughput
or queue number measured here is the license talking, not the
feature.[^license]
Host prerequisites and storage
Use Bun 1.4.4, the tested runtime shipped in 0.5.2. Run
quickchr doctor for missing dependencies and install hints. The old
start --install-deps flag now errors; it never installed dependencies.
The CLI defaults to qcow2, requiring qemu-img; the library defaults
to raw unless bootSize implies qcow2. Extra disks also need
qemu-img.[^release052]
Storage admission checks the bytes the operation will write plus a
2 GiB reserve on each filesystem it uses, accounting for concurrent
operations. The old 10 GiB floor is gone. QUICKCHR_STORAGE_RESERVE
(or storage-reserve) adjusts the reserve; cache eviction precedes an
INSUFFICIENT_DISK_SPACE refusal. Cache limits now include packages and
partial downloads, so the first prune after upgrading can evict more
than before.[^release052]
Lifecycle: add is not start
quickchr add --name lab-a --version 7.24.4 --arch x86 --add-network user
quickchr start lab-a
quickchr exec lab-a "/system/resource/print"
quickchr stop lab-a
quickchr remove lab-a
addonly writes config (0s on a warm cache) and printsquickchr start <name>as its tip. It never boots — the single most common wrong assumption about this CLI.[^ground-add]startblocks until the guest is REST-ready, then prints the REST/SSH/WinBox endpoints (≈22–25s first boot, ≈11–20s restart, warm-cache HVF).--no-waitreturns as soon as QEMU is running (next section). Under TCG software emulation expect minutes, not seconds — a sandboxed field lab saw ~4 min boots and a realBOOT_TIMEOUTat 480s. Size harness timeouts from the slow end.[^ground-timings]starton a name that does not exist creates it — the one-command flow. A typo'd name therefore boots a factory machine on fresh ports instead of erroring, which after a failed start can look like your machine came back wrong. Checkquickchr listbefore retrying a name. Since 0.5.1 the status says "created and started" ("created — not waiting for RouterOS" with--no-wait); resuming an existing machine says "started".[^release052]exec <name> <command…>runs one RouterOS command over REST.--viaisauto|rest|qgahere, andqgais narrower than it looks: RouterOS only starts its guest agent for an x86 CHR under Linux KVM. Under macOS HVF or TCG the port is presented but the guest never opens it, and ARM64 CHR has no QGA at all — both time out. Stay on REST unless you are on Linux/KVM with an x86 guest. (The library'sExecTransporttype also listssshandconsole; those are not CLI surface.)removedeletes the machine.listshows state and PIDs.[^ground-exec]stopis a power cut: SIGTERM to QEMU, then SIGKILL after 5s. It does not ask RouterOS to shut down, so unsaved guest state can be lost. That is fine for a disposable lab. For a graceful exit on RouterOS 7,quickchr shutdown <name> [--timeout <s>]sends/system/shutdownover REST, waits up to 10s by default (QUICKCHR_SHUTDOWN_TIMEOUT) for QEMU to exit, and falls back to a power cut it reports as "forced". On RouterOS 6 the REST request fails at once, soshutdownsits out the whole timeout and then cuts power ("forced", exit 0) — usestopthere. Disks and credentials survive either way.[^shutdown]start --allstarts every stopped machine in the background. It accepts only--no-wait,--bg/--background,--accel,--timeout-extraand--[no-]secure-login; any other option fails withINVALID_ARGUMENTrather than being silently dropped (0.5.0+). Start a machine by name to pass anything else.[^start-all]- Version selection:
--version 7.24.4pins;--channel stable|long-term|testing|developmentresolves the newest of a channel. Barequickchr --versionprints what each channel currently resolves to.[^ground-channel] - Ports are auto-allocated from 9100 in per-machine blocks of ten
(9140, 9150, 9160 …), so machines created one after another do not
collide and you need no port bookkeeping.
--port-baseoverrides;--forward name:host:guest/protoadds extras, ranges included.[^ground-ports] - Creating several at once (
quickchr start a & quickchr start b &) is safe: concurrent creations get distinct port blocks, an overlapping--port-baseis refused withPORT_CONFLICT, andstop/removewait for astartof the same machine in progress. A foregroundstartholds the machine until its VM exits, so they fail withMACHINE_LOCKEDinstead of waiting. (Before 0.5.0 both could pick the same block and one failed withSPAWN_FAILED— start them one at a time there.)[^ground-concurrent] - Pre-warm without booting:
quickchr cache add --version 7.24.4 --arch x86resolves and downloads one image, no QEMU and no machine required.cache keyprintsdir=/version=/arch=for CI.[^ground-cache] - Never hand-edit
machine.jsonto change networks or options — it breaks boot. Everything is reachable throughaddflags.[^field] - Keep
QUICKCHR_DATA_DIRshort on macOS. QEMU's monitor socket lives under it, and a socket path of 104 bytes or more makesstartfail withSPAWN_FAILED("UNIX socket path … is too long").[^socket-path]
Getting the shell back: --no-wait, not --bg
--bg never returned early. Background is already the default. The
flag only failed to select the foreground mode you did not ask for, and
start waited for REST-readiness either way. It is now deprecated:
it does nothing, and prints a warning on stderr. (On 0.4.8 it was
silently harmless.) Leave it out of scripts. --fg/--foreground is
the switch that does something.[^bg]
--no-wait. start returns once QEMU is running (about
2s), with state and ports already saved, and readiness is yours. centrs
0.1.8+ waits for it with a bound:
quickchr start lab-a --no-wait
centrs retrieve --quickchr lab-a /system/resource --wait 5m
It is refused (INVALID_ARGUMENT, nothing booted) when combined with
--fg, or with any provisioning flag. That includes provisioning that
add recorded and the first start has not applied yet. Provisioning
runs against the booted guest, so that first start has to wait. Later
starts can use --no-wait.[^nowait]
On 0.4.8 there is no --no-wait: background start yourself
(nohup quickchr start lab-a >lab-a.start.log 2>&1 &) and poll
readiness with a bound, as in the next paragraph.
Bound the wait. start can fail outright — MISSING_QEMU,
SPAWN_FAILED, BOOT_TIMEOUT — and an unbounded until loop then
polls forever, which in CI is a hung job with no diagnostic. Size the
bound off the slow end (TCG minutes, not HVF seconds) and on timeout
print quickchr list plus the start log rather than just
exiting.[^ground-poll]
A killed start does not stop the VM. QEMU is spawned
detached (its own session), so even a harness that SIGKILLs the whole
process group leaves the guest booting — it comes up REST-ready with
nothing left to report it. Verified against both published releases:
group-SIGKILL 8s into a first boot took the VM down on 0.4.7, and
left it running and REST-ready on 0.4.8 and later. So never infer "start
failed" from a killed CLI — check quickchr list and poll REST, and
stop/remove to clean up. If you are pinned below 0.4.8, that kill
does take the VM with it — upgrade rather than work around it.[^ground-bg]
A BOOT_TIMEOUT cleans up after itself. quickchr stops QEMU
first, then:[^boot-timeout]
- created by this
start— removed; - created by
add, never booted — reset to the factory disk,add's options kept; - booted before (a restart) — preserved: retry with
start.
On 0.4.8 and earlier the restart case was removed too, unless
QUICKCHR_PRESERVE_ON_FAILURE=1 was set first — a slow boot deleted a
machine you had configured by hand. On 0.5.0 the flag still keeps the
first two cases for inspection.
NIC order and the two-VM link
- Put
userfirst:--add-network user --add-network 'socket::lab-link'. RouterOS auto-DHCPs only ether1 (observed: dynamic10.0.2.15/24), and the host port-forwards assume it.[^ground-nic] - For an L2 link between two VMs, prefer a named socket
socket::<name>. It resolves through a registry entry, defaults todgram(a unix datagram pair) off Windows, and either machine may start first — there are no listener/connector roles to get wrong.startprints the transport each link resolved to. Verified: two machines on onesocket::link, started in arbitrary order, static/24s on ether2, ping 3/3 on a macOS host.[^ground-l2] - The raw
socket:listen:<port>/socket:connect:<port>pair is the ordering-sensitive form — a TCP pair where the listener must start first. Reach for it only when you need those explicit roles;socket::<name>exists to avoid the ordering problem.[^ground-forms] - A third machine on a two-machine link is refused at
start(not atadd), naming the two holders and pointing at--mode mcast. That refusal is the configuration check working.[^ground-third]
Which transport: default dgram, opt-in mcast
quickchr networks sockets create <name> takes --mode dgram|listen-connect|mcast and prints the transport it created —
believe the output, not the name. Default is dgram (unix datagram
pair, filesystem-confined, two machines) on macOS/Linux and
listen-connect (TCP pair) on Windows, which has no AF_UNIX datagram
socket. --mode mcast is UDP multicast 230.0.0.1 — the only N-way
transport, and the only one that fails silently.[^ground-sock]
quickchr prints all three hazards below as a warning when you create an
mcast link. Two of them fail silently — same signature each time:
interfaces up, addresses assigned, 100% loss, nothing logged:[^mcast]
- No macOS delivery — QEMU's multicast socket omits
SO_REUSEPORT, which BSD/macOS require before two sockets on one group both receive (tikoci/quickchr#167). - Refused where unconnected UDP sends are blocked —
seccomp-filtered sandboxes and some Linux CI return
EPERMonsendto()while the group join succeeds (tikoci/quickchr#169).
The third is the opposite failure — traffic flows where it should not:
- Not confined to your machine — quickchr passes no
localaddr=, so the group rides the host's default multicast interface. Another host on your LAN using the same group joins your segment, and the link keeps working, so nothing looks wrong. That is an isolation failure, not a loss one. Give an N-way link a group of its own, or stay ondgramfor two machines.
Driving the router: centrs --quickchr
Once the machine is up, reads and writes go through centrs
(@tikoci/centrs) targeting the
machine by name — it resolves the endpoint through quickchr inspect,
so there is no credential plumbing:[^ground-drive]
centrs retrieve --quickchr lab-a /system/resource
centrs execute --quickchr lab-a --yes '/ip/address/add interface=ether2 address=198.51.100.1/24'
Reads need no flag; writes refuse without --yes
([usage/confirmation-required]). One RouterOS statement per call.
Prefer it over quickchr exec: quickchr itself says so, in
exec --help and in a stderr tip on a bare quickchr exec
(suppressible with QUICKCHR_NO_TIPS=1; tips never touch stdout, so
--json stays parseable). quickchr exec is a raw /rest/execute
pipe with no validation — whatever you type is what RouterOS is asked
to run, and its output is screen-scraped text where centrs returns
typed JSON.[^ground-tips]
Need the raw endpoint instead — curl, or some other tool?
quickchr inspect <name> --json carries the resolved URL and
ready-to-use credentials. CHR here is admin with an empty
password, and inspect hands you the Basic header already encoded:
"rest-api": { "url": "http://127.0.0.1:9150/rest", "port": 9150,
"auth": { "username": "admin", "password": "", "basic": "admin:" } }
(auth also carries a pre-encoded header if you would rather not
build the Basic value yourself.) Straight to curl:
url=$(quickchr inspect lab-a --json | jq -r '.services["rest-api"].url')
curl -s -u admin: "$url/system/resource" | jq .
That is the same door centrs uses — --quickchr resolves through
quickchr inspect, never by reading machine.json — which is why it
needs no credential configuration from you.[^ground-inspect]
Device-mode: the window closes at first boot
Two traps, and agents hit them in this order.
The guest-side route does not work. /system/device-mode/update container=yes over REST or API does not complete — RouterOS is waiting
for a power cycle it cannot perform on itself — and the feature stays
false. Observed live: the call did not return within the 10s client
timeout and container was unchanged afterwards. (Whether it would
ever return is not established; a field lab reported waiting ~5 min.)
Do not reach for this, and do not read the timeout as a transport
problem.[^dm-trap]
Provisioning flags apply at a machine's first boot. quickchr
applies device-mode by provisioning: boot the guest, set the mode,
power-cycle QEMU via the monitor, re-read to confirm (≈45s vs ≈22s
without). That runs on the first boot, so pass it to add, or to the
first start of a machine add created plain. After that boot:[^dm-window]
start --device-mode-enable container on… |
0.5.2 |
|---|---|
| a machine that never started | applies — "Device-mode verified: mode=rose container=yes" |
| a machine that has booted | refused, PROVISIONING_WINDOW_CLOSED, with the quickchr set … command to run |
| a machine that already has it | "already applied — nothing to do", and it starts |
(On 0.4.8 an already-booted machine silently ignored the request:
container=false stayed false; an already-enabled container stayed enabled.)
Changing it later. quickchr set <name> --device-mode-enable container works on a running machine. It prints each setting that will
move, power-cycles once, verifies, and records the change. Read it back
with quickchr get <name> device-mode. Observed: 21s, then container
listed under Enabled.[^dm-set]
Before 0.5.0 the CLI had no post-boot route: remove, then
add --device-mode-enable …, then start was the only CLI path. The
library's instance.setDeviceMode() power-cycles for you on both
versions (see routeros-tikoci-quickchr).
The other provisioning flags also refuse after first boot
(--add-package, --add-user, --disable-admin, --secure-login,
--license-*, --install-all-packages). The refusal names the route
that still works. For anything without one, quickchr clean <name>
resets the disk and reopens the window, at first-boot cost. Then repeat
the original command, because a refused request is not saved.[^window]
The refusal tells you which side of the line a machine is on — no detective work needed before relying on a flag:
quickchr get <name> # live Device Mode / License / Admin Users
Enabling container moves the mode itself too (advanced → rose).
Provisioning needs RouterOS 7.20.8+ and a user-mode NIC — with
only socket NICs it fails NETWORK_UNAVAILABLE; older 7.x is
boot-only.[^device-mode]
RouterOS 6: boot-only
quickchr boots RouterOS 6 CHR for v6→v7 migration work (0.5.0+). It is best-effort, CLI-only, and x86 only. quickchr runs no REST and no provisioning against it:[^v6]
quickchr add --name lab6 --version 6.49.23 --arch x86
quickchr start lab6 # ready on a native-API login, ~27s on HVF
centrs retrieve --quickchr lab6 /system/resource --via native-api --no-validate
--arch arm64is refused withINVALID_ARCH, because MikroTik publishes no arm64 CHR for 6.x.- Every provisioning flag, and the
setroutes, fail withPROVISIONING_VERSION_UNSUPPORTED. quickchr exec,get's live groups and REST calls fail at once withREST_UNAVAILABLEand do not time out. Use the native API, as above.--no-validateis needed because centrs validates against the 7.x command tree.list,inspect,stop,removeandstart --no-waitwork as on 7.x.shutdowncannot ask a 6.x guest to halt: it waits out its timeout and reports a forced power cut, sostopis the faster equivalent.
On 0.4.8 and earlier, a 6.x start waits for a REST API that 6.x does
not have, and times out. Use 7.x there, or upgrade.
Grounding behind this skill
references/cli-grounding.md— the live runs (commands, outputs, timings, environment) and an explicit list of what was not re-verified there.- quickchr
MANUAL.md,DESIGN.md,docs/networking.md,CHANGELOG.md— authoritative behavior reference; the skill favors stable CLI concepts over version-specific flags. - Field lab that motivated this skill: external 3-CHR lab notes
(muse-app, 2026-09-19/20) —
add-doesn't-start, detached starts + REST polling, cold power-cycle for device-mode, cache-warming gap.
[^split]: Split rationale: tikoci/routeros-skills#21. Library consumers
stay on routeros-tikoci-quickchr; shell-first agents come here.
[^release052]: quickchr 0.5.2 CHANGELOG
[0.5.1] and [0.5.2];
package review.
[^pin]: The 0.5.2 changes were checked against the published package
(package review).
Pinned observations: the 0.5.0 claims were re-run against
published @tikoci/quickchr@0.5.0 and @tikoci/centrs@0.1.8,
CHR 7.24.5 and 6.49.23 x86
(references/cli-grounding.md §quickchr 0.5.0 re-check), and
checked against quickchr's
CHANGELOG.md
[0.5.0] section. Underneath that pass sits the 0.4.8 one (CHR
7.24.4 x86, QEMU 11.1.1 + HVF on macOS Intel, 0.4.7 as a control):
the named-socket dgram default, the printed transport, the
three-machine refusal and the POSIX detach all shipped in 0.4.8
(quickchr #158, #159) and are absent from 0.4.7.
[^ground-add]: references/cli-grounding.md §Lifecycle timings.
[^ground-timings]: Same (0.4.8: 22s / 11s); §quickchr 0.5.0 re-check
(0.5.0: 25s / 20s, CHR 7.24.5). TCG band and the 480s
BOOT_TIMEOUT from the field lab notes.
[^ground-exec]: Same, §Lifecycle timings.
[^ground-cache]: references/cli-grounding.md §Cache; quickchr
CHANGELOG 0.4.8 "Added" (cache add, cache key).
[^field]: Field lab notes: --add-network at add time, never a
hand-edited machine.json.
[^bg]: 0.4.8: src/cli/index.ts — "Background default:
true. Explicitly foreground only with --fg / --foreground /
--no-background / --no-bg", and quickchr start --help renders
--bg / --background Run in background (default). 0.5.0:
CHANGELOG [0.5.0] Breaking, "start --bg / --background are
deprecated and do nothing" (tikoci/quickchr#159). The warning text
is quoted in references/cli-grounding.md §quickchr 0.5.0
re-check.
[^ground-bg]: references/cli-grounding.md §Group-kill, which records
the 0.4.7-vs-0.4.8 before/after. Unchanged in 0.5.0: MANUAL.md
start — "QEMU runs detached in its own session either way".
[^nowait]: quickchr MANUAL.md start (--no-wait row and the centrs
0.1.8+ example); CHANGELOG [0.5.0] Added (#159). All three
refusals reproduced in references/cli-grounding.md §quickchr
0.5.0 re-check.
[^shutdown]: quickchr MANUAL.md stop ("Power cut: sends SIGTERM to
QEMU, waits up to 5 seconds, then SIGKILL") and shutdown;
CHANGELOG [0.5.0] Added and Fixed (#143). The "guest exited" and
"already stopped" outcomes were observed on 7.x. The forced
fallback was observed on RouterOS 6.49.23 with 0.5.2
(references/cli-grounding.md §quickchr 0.5.2 package review); it was not
forced on 7.x.
[^start-all]: quickchr MANUAL.md start (--all row); CHANGELOG
[0.5.0]; the batchFlags check in cmdStart(),
src/cli/index.ts at v0.5.2. The refusal was reproduced in
references/cli-grounding.md §quickchr 0.5.2 package review.
[^socket-path]: Observed on macOS while setting up the 0.5.0 re-check
(references/cli-grounding.md); QEMU reports "Path must be less
than 104 bytes".
[^boot-timeout]: 0.5.0: quickchr MANUAL.md §"Auto-cleanup on boot
timeout"; CHANGELOG [0.5.0] Breaking (#182) and Fixed (#189).
0.4.8: cleanupAfterBootFailure() in src/lib/quickchr.ts at tag
v0.4.8 calls stop(), then remove() unless
QUICKCHR_PRESERVE_ON_FAILURE=1. Not forced live in either
version.
[^dm-set]: quickchr MANUAL.md set; reproduced in
references/cli-grounding.md §quickchr 0.5.0 re-check.
[^window]: quickchr MANUAL.md §5 "Provisioning pipeline" (the window,
the routes table, and clean() retaining intent); CHANGELOG
[0.5.0] Fixed (#176). clean was not run in the re-check.
[^v6]: quickchr MANUAL.md §"RouterOS 6 (boot-only)"; CHANGELOG
[0.5.0] Added (#182), which also gives the 0.4.8 behavior ("ready
in ~25 s under HVF instead of timing out against a REST API it does
not have"). The arm64 refusal, the provisioning
refusal, the 27s boot, REST_UNAVAILABLE and the native-API read
are in references/cli-grounding.md §quickchr 0.5.0 re-check.
[^ground-nic]: references/cli-grounding.md §First NIC.
[^ground-l2]: references/cli-grounding.md §Named-socket link.
[^ground-forms]: parseSocketSpecifier() in quickchr
src/lib/network.ts accepts socket::<name>,
socket:listen:<port>, socket:connect:<port>,
socket:mcast:<group>:<port>; docs/networking.md — "Named
sockets avoid the listen/connect ordering problem".
[^ground-third]: references/cli-grounding.md §Third machine refused.
[^ground-sock]: references/cli-grounding.md §Socket create output;
defaultSocketMode() in quickchr src/lib/socket-registry.ts.
[^mcast]: quickchr DESIGN.md "A named socket says what it is";
tikoci/quickchr#167 (delivery) and #169 (permission); warning text
quoted in references/cli-grounding.md.
[^ground-drive]: references/cli-grounding.md §Driving with centrs
(retrieve, execute --yes, and the refusal without --yes).
[^ground-tips]: quickchr exec --help and bare quickchr exec on
0.4.8, quoted in references/cli-grounding.md §Driving with
centrs; CHANGELOG 0.4.8 "Added" (tips).
[^dm-trap]: references/cli-grounding.md §Device-mode — the guest-side
/system/device-mode/update container=yes timing out with
container unchanged, reproduced on 0.4.8 / CHR 7.24.4.
[^dm-window]: 0.4.8: references/cli-grounding.md §Device-mode —
the never-started machine applied and verified the change, the
already-booted one silently did not. The gate was
!existing.lastStartedAt in QuickCHR.start()
(src/lib/quickchr.ts). 0.5.0: tikoci/quickchr#176 shipped. The
gate is the new MachineState.provisioning record, and the refusal
and the "already applied" path are in §quickchr 0.5.0 re-check.
[^ground-inspect]: references/cli-grounding.md §Endpoints and
credentials (quickchr inspect --json, quickchr get).
[^ground-poll]: Failure codes from quickchr's ErrorCode union
(src/lib/types.ts); the bound-and-diagnose shape is this skill's
recommendation, verified as a script in
references/cli-grounding.md §Bounded readiness poll.
[^ground-channel]: quickchr add --help (--version, --channel);
quickchr --version prints the resolved stable/long-term versions.
[^ground-concurrent]: tikoci/quickchr#140
(two concurrent starts on 0.4.7 both chose 9140; the second QEMU failed
its hostfwd bind) and the fix,
tikoci/quickchr#190,
released in 0.5.0 (CHANGELOG [0.5.0] Fixed, #140 and #181) and
covered by quickchr's concurrency unit tests and a live two-CHR start.
See the routeros-tikoci-quickchr skill, "Running several CHRs at once".
[^ground-ports]: quickchr add --help — "--port-base <port> Starting
port number (default: auto-allocated from 9100)"; successive
machines observed at 9140/9150/9160 in
references/cli-grounding.md.
[^license]: MikroTik, CHR: Licensing → CHR License
Levels:
"The Free license level allows CHR to run indefinitely with a 1 Mbps
upload limit per interface. All other RouterOS features are available
without restrictions." Paid tiers raise the cap (P1 1 Gbit, P10
10 Gbit, P-Unlimited), and a 60-day trial of a paid tier is free with
a MikroTik account. This is MikroTik licensing, not a quickchr
behavior; quickchr get <name> reports License Level: free on a
default machine.
[^device-mode]: quickchr MANUAL.md "Order of operations"
(_provisionInstance); --device-mode-enable in
src/cli/flags.ts (accepted by both add and start, though the
condensed add --help lists only --device-mode <m>); the
user-mode-NIC requirement is the NETWORK_UNAVAILABLE guard in
QuickCHR.start().