mikrot is a small, cross-platform Python CLI for managing MikroTik (RouterOS) routers
through their REST API. It is built to be automation- and AI-friendly: every command
speaks --json, errors come back as structured data (stable codes with machine-actionable
fixes), and mikrot manifest publishes a machine-readable self-description (commands,
response shapes, error and exit codes). That lets scripts, CI pipelines, and AI agents
drive it reliably -- while it stays an ergonomic CLI for people.
Standalone, single-package tool (RouterOS 7.1+). Stack: Python 3.12, UV, Typer, Rich, httpx, Pydantic. The project is small and simple, so the AI-friendly features are provided in a lightweight form.
| Command | Purpose |
|---|---|
mikrot doctor |
Connection diagnostics (ok/warn/fail + overall_status) + baseline router info (identity / resource / routerboard). |
mikrot dhcp-leases |
List DHCP leases (/ip/dhcp-server/lease) with --mac (= -m) / --name (= --host/-n) / --status (= -s) / --comment (= -c) / --address (= --ip/-a/-i) filters (repeatable: OR within an option). Aliases: l, d. |
mikrot make-static <ip> |
Convert a dynamic lease into a static reservation (dry-run by default; --commit applies). |
mikrot make-dynamic <ip> |
The reverse operation via DELETE (dry-run by default; --commit applies). |
mikrot manifest |
Machine-readable self-description of the CLI for AI (--json). |
mikrot is built and run with UV. Install UV first (it is the only prerequisite -- it manages the Python toolchain itself):
# Windows (PowerShell)
powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"# macOS / Linux (bash/zsh)
curl -LsSf https://astral.sh/uv/install.sh | shRestart the terminal afterwards so uv is on PATH. See the
UV install docs for alternatives
(Homebrew, pipx, standalone installers). Then install mikrot:
# Production install (CLI into the OS home):
uv tool install git+https://github.com/grase123/mikrot
uv tool update-shell # once; then restart the terminal
mikrot --version
# Dev (from the repository):
git clone https://github.com/grase123/mikrot.git
cd mikrot
uv sync --extra dev # venv for tests/lint and `uv run`
uv run mikrot --version
# Run `mikrot` directly (no `uv run`) -- editable OS-level install:
uv tool install --editable . # links the source; picks up your edits
uv tool update-shell # once; then restart the terminal
mikrot --versionmikrot finds the nearest .env by walking up from the current directory. Create the repo's
.env once:
# Windows (PowerShell)
Copy-Item .env.example .env # then set MIKROT_PASSWORD# macOS / Linux (bash/zsh)
cp .env.example .env # then set MIKROT_PASSWORDAny mikrot run from inside the cloned repo (or a subdirectory) then uses that .env --
even with the OS-level install, because discovery is based on the working directory, not on
where the tool is installed. Run it from elsewhere and it falls back to the global
~/.config/mikrot/.env and the shell environment (see Configuration).
Settings come from environment variables prefixed MIKROT_, loaded via
pydantic-settings. There is no YAML layer. mikrot reads two .env files and
merges them with the shell environment.
Higher wins:
- Shell environment --
MIKROT_*variables already set in your shell (or by CI / a container / a secrets tool). These always win. - Project-local
.env-- the nearest.envfound by walking up from the current directory. Use this for per-project overrides. - Global
.env--~/.config/mikrot/.env(see below). Use this for your personal, machine-wide defaults. - Built-in defaults -- the values in the table below.
Both .env files are loaded without overriding what is already set, so the shell
always beats the files and a project-local .env beats the global one.
On first run mikrot creates ~/.config/mikrot/.env if it does not exist (the
directory is created too). It is written as a fully commented scaffold: every
variable is present but commented out, so it sets nothing until you edit it.
Uncomment and edit only the lines you want. An existing file is never overwritten,
and if the home directory is not writable the step is skipped silently.
- The path is
~/.config/mikrot/on every OS (Linux, macOS, Windows). - Override the directory with
MIKROT_CONFIG_HOME-- the file is then$MIKROT_CONFIG_HOME/.env. It is read from the shell environment only (it decides where the files live) and is handy for tests, CI, or keeping config outside$HOME.
| Variable | Default | Purpose |
|---|---|---|
MIKROT_HOST |
192.168.88.1 |
Router host |
MIKROT_PORT |
80 |
REST API port |
MIKROT_SCHEME |
http |
http / https |
MIKROT_USER |
admin |
Login (HTTP Basic) |
MIKROT_PASSWORD |
-- (required) | Password; without it any command to the router returns code="config_missing" |
MIKROT_TIMEOUT |
5.0 |
HTTP timeout, seconds |
Only MIKROT_PASSWORD is required -- set it in any of the layers above. Every
other variable is optional: leave it out to use the default. mikrot doctor
reports if MIKROT_PASSWORD is not set.
To set a variable directly in the shell (precedence layer 1, beats both .env
files) for the current session:
# Windows (PowerShell)
$env:MIKROT_PASSWORD = "secret"# macOS / Linux (bash/zsh)
export MIKROT_PASSWORD=secretTo start from a template, copy the repo's example into a project-local .env:
# Windows (PowerShell)
Copy-Item .env.example .env # then set MIKROT_PASSWORD# macOS / Linux (bash/zsh)
cp .env.example .env # then set MIKROT_PASSWORDInstead of a literal password, any MIKROT_* value may be a 1Password secret
reference:
MIKROT_PASSWORD=op://Vault/MikroTik/password
On startup mikrot resolves such references with the 1Password CLI (op read), so the
secret never lives in a file. Requirements:
- the
opCLI is installed and onPATH; - you are signed in -- interactively (1Password desktop app integration), or for
headless/CI via a service-account token in
OP_SERVICE_ACCOUNT_TOKEN(read byop).
Only reference-shaped values are touched; a literal value still works unchanged. If op
is missing or a reference cannot be resolved, the command fails with
code="secret_resolution_failed".
More password managers can be added without code via the SECRETREF_PROVIDERS env var (a
JSON array of providers). A built-in env://${VAR:-default} reference resolves environment
variables in-process. See src/mikrot/secretref/README.md.
mikrot doctor # preflight + baseline router info
mikrot doctor --strict # exit 1 if overall_status != ok
mikrot dhcp-leases # all leases (Rich table; waiting dimmed)
mikrot l --status bound # alias for dhcp-leases (also `d`)
mikrot dhcp-leases --mac DC:2C:6E # MAC substring; ':' / '-' / no separator all match (e.g. vendor OUI)
mikrot dhcp-leases --comment printer # filter by comment substring
mikrot dhcp-leases -a 192.168.88.50 # IP substring (matches address or active-address)
mikrot l -a 10.0.0.5 -a 10.0.0.7 # repeat an option: match ANY of several IPs (OR)
mikrot dhcp-leases --status bound # online clients only
mikrot --json dhcp-leases # full lease (~19 fields) per record
mikrot make-static 192.168.88.50 # dry-run: shows the plan, changes nothing
mikrot make-static 192.168.88.50 --commit # apply
mikrot make-dynamic 192.168.88.50 --commit # reverse (via DELETE)Tip against a wide Rich table being truncated: set
COLUMNSwider --$env:COLUMNS=250(PowerShell) orexport COLUMNS=250(bash/zsh).
The global --json flag switches output to a single JSON document per call.
The flag position is flexible: mikrot --json dhcp-leases is equivalent to
mikrot dhcp-leases --json.
mikrot --json doctor # {"checks": [...], "overall_status": "...", "baseline": {...}|null}
mikrot --json dhcp-leases # array of full lease objects
mikrot --json make-static IP # {"action","ip","lease_id","committed","changed","note","before","after"}
mikrot --json manifest # full CLI self-descriptionErrors are data too (errors-as-data). A structured envelope with a stable code:
{"error": "no DHCP lease found for address 1.2.3.4",
"code": "lease_not_found", "fix": null, "fix_description": null, "ip": "1.2.3.4"}fix is a literal mikrot ... command (or null); fix_description is free text for
cases where the fix is not a single command. Both keys are always present (may be null).
mikrot preserves meaningful exit codes (rather than always returning 0) so that
scripts can branch on the exit status:
code |
exit | When |
|---|---|---|
router_unreachable |
1 | Could not reach the router (connect/timeout) |
http_error |
2 | Router answered with an HTTP error (including insufficient permissions) |
lease_not_found |
3 | make-*: no lease with that IP |
lease_ambiguous |
4 | make-*: several leases with that IP |
config_missing |
1 | MIKROT_PASSWORD not set |
mikrot doctor is the exception: always exit 0 (errors-as-data); --strict switches
it to exit 1 on warn/fail.
mikrot manifest --json is a machine-readable description of the commands: for each one,
response_shape ({kind, model}) and error_codes; at the top level,
infrastructure_errors and contract_version. Lightweight set: no schema, synthetic
envelopes, drift tests, or heavy contract machinery.
- Endpoint:
http://<host>:<port>/rest/<menu>. Auth is HTTP Basic. Thewwwservice must be enabled on the router (/ip service www). - Single-object menus (
/system/identity, ...) sometimes return a dict, sometimes a one-item list -- the client normalizes both forms. - Encoding: hostname/comment may arrive as cp1251 (legacy Windows clients); the decoder is cascading (utf-8 -> cp1251 -> lossy). Non-ASCII text is preserved.
- REST asymmetry:
make-staticexists as an action endpoint, butmake-dynamicdoes not (RouterOS does not expose it; a PATCH of thedynamicfield is rejected too). So "make dynamic" =DELETEof the static entry. make-*require thepolicyflag on the router user -- otherwiseHTTP 500 / not enough permissions. Read-only GETs work without it.- Liveness: a record in the DHCP table != the VM is alive. Alive <=>
status == "bound";waitingrecords accumulate for years.
Details and heuristics are in docs/requirements.md
and docs/decisions.md.
docs/requirements.md-- requirements (source of truth).docs/decisions.md-- accepted decisions (DEC-001..).
Dev and CI commands are defined as Taskfile targets, so the pipeline is decoupled from any specific CI provider and local and CI runs stay identical. Install go-task first:
# Windows (PowerShell)
winget install Task.Task# macOS (Homebrew)
brew install go-task/tap/go-task
# Linux
curl -sL https://taskfile.dev/install.sh | shSee the go-task install docs for alternatives. Then
use the targets (task --list shows them all):
task setup # install dependencies (incl. dev extras)
task ci # full gate: lint + types + test
task test # run the test suite (no live router: httpx mocked)
task lint # ruff check
task format # ruff format (use `task format:check` to verify only)
task types # mypy src
task build # build the wheel and sdist
task run -- doctor # run the CLI with args (here: a live check against .env)go-task runs commands through a built-in POSIX sh interpreter on every OS, so the targets
work the same on Windows, macOS, and Linux. Each target just wraps the underlying uv
command, so you can run them directly without go-task if you prefer:
uv sync --extra dev # = task setup
uv run pytest # = task test
uv run ruff check . # = task lint
uv run mypy src # = task types
uv run mikrot doctor # live check against the router from .envCode and identifiers are English (ASCII); documentation is English.