omc-cli.cr
omq — a ZeroMQ command-line tool
omq is a Swiss-army knife for ZeroMQ. One static binary, one socket type per invocation, lines of text in and out — pipe it into your shell, your tests, or another omq. It speaks ZMTP 3.1, so it interoperates with libzmq, JeroMQ, NetMQ, and any other ZeroMQ stack on the wire.
This is the Crystal port of the Ruby omq-cli — same flag set, same behaviour, but compiled to a single static-musl executable with no runtime dependencies (no libzmq, no libsodium, no glibc).
The interesting bit is the -e / -E flags: each message can be transformed by a one-line mruby expression, so omq doubles as a streaming AWK for ZeroMQ traffic.
Quick start
# Terminal A — bind a PULL socket on tcp://*:5555 and print frames.
omq pull -b tcp://*:5555
# Terminal B — push three messages, then exit.
omq push -c tcp://127.0.0.1:5555 --data "hello" -n 3
# Pub/sub with a wall-clock-quantized 1s tick.
omq pub -b tcp://*:5556 --data "tick" -i 1
omq sub -c tcp://127.0.0.1:5556 -s ""
# Inline mruby: uppercase every incoming frame.
omq sub -c tcp://127.0.0.1:5556 -s "" -e 'it.map(&:upcase)'
# Synthesize a counter from nothing — no stdin, BEGIN sets up state.
omq pub -b tcp://*:5557 -i 0.5 \
-E 'BEGIN { $n = 0 } $n += 1; "msg #{$n}"'
What it can do
Socket types
push, pull, pub, sub, req, rep, pair, pipe. Each socket type has its own runner that knows the right send/recv pattern (e.g. req alternates send → receive, pub only sends, pipe is bidirectional).
Endpoints
-c URI,--connect URI— connect to a peer.-b URI,--bind URI— bind a listener.@name(no scheme) — shorthand foripc://@name(Linux abstract namespace), soomq pull -b @ticksJust Works without a filesystem socket path.
Both flags are repeatable. Each socket type has a sensible default direction, so a bare URI passed via the parser falls back to "bind for servers, connect for clients".
Formats (-f / --format)
How message frames are encoded on stdout / decoded from stdin:
ascii(default) — TAB-separated frames, non-printable bytes shown as.. Round-trips text safely; lossy on binary.quoted— TAB-separated with Ruby-style\t \n \r \\ \" \xNNescapes. Round-trips arbitrary bytes through a text pipe.jsonl— one JSON array per line. Reliable structured output for downstream tools.null— drop output entirely (useful for throughput counters and benchmarks).
Send helpers
-D STR,--data STR— send STR as the payload.-F PATH,--file PATH— send the contents of a file.-n N,--count N— send/receive exactly N messages, then exit.-i SEC,--interval SEC— periodic send. Wall-clock quantized: each tick aligns to the next multiple ofSECsince the Unix epoch, not toSECafter the previous send. Twoomq pub -i 1instances on different machines emit ticks at the same wall-clock moments instead of drifting apart.-d SEC,--delay SEC— wait before the first send (so PUB has time to set up before subscribers arrive, etc.).
Socket options
--send-hwm, --recv-hwm, --hwm (sets both), --linger, --identity, --read-timeout, --write-timeout. Default linger is 5000 ms so pipelines like omq push -n N | omq pull -n N don't drop the tail when the sender exits first.
CURVE encryption
ZeroMQ's CurveZMQ over libsodium (via natron.cr):
# Server side — keys come from the environment as Z85.
OMQ_SERVER_PUBLIC=… OMQ_SERVER_SECRET=… \
omq rep -b tcp://*:5555 --curve-server
# Client side — give it the server's Z85 public key.
omq req -c tcp://server:5555 --curve-server-key 'rq:rM…'
Other
-v,-vv,-vvv— verbosity (endpoints / events / message previews on stderr).-q,--quiet— no message output (combine with-nfor a pure counter).--transient— exit when all peers disconnect.
How -e / -E work (the mruby bit)
omq embeds mruby 3.4 — a small, sandboxable Ruby — and lets you transform each message inline.
-e EXPRruns on each incoming message before it's printed.-E EXPRruns on each outgoing message before it's sent.
Inside the expression, the current message is bound as it (a Ruby Array<String>, one element per ZeroMQ frame). Or use an explicit block-arg form:
omq sub -c tcp://localhost:5556 -s "" -e 'it.first.upcase'
omq sub -c tcp://localhost:5556 -s "" -e '|topic, body| "#{topic}: #{body}"'
The return value of the expression decides what happens next:
| Return value | Meaning |
|---|---|
Array of strings |
New multi-frame message. |
| Any other scalar | Single-frame message containing its to_s. |
nil |
Drop / filter this message. |
BEGIN { … } and END { … } blocks run once around the message pump, so you can keep state across messages:
# Count incoming frames, print the total at exit.
omq pull -b @work -e 'BEGIN { $n = 0 } $n += 1; nil' \
-- 'END { puts $n }'
The expression is compiled once at startup; per-message dispatch is just a Proc call, and each iteration runs inside an mruby arena save/restore so transient allocations don't pile up.
Sandbox
The mruby build is intentionally narrow. Compiled in: String, Array, Hash, Range, Time, Math, sprintf, Random, the usual Enumerable / Comparable extras, Array#pack / String#unpack. Deliberately left out: eval, File, Dir, Socket, Process, Kernel#exit, the standalone mruby / mirb binaries. So a stray -e can mangle bytes — but it can't open a file or shell out.
If you need regex or JSON inside expressions, the gem hooks are listed in vendor/build_config.rb (mruby-regexp-pcre, mruby-json) and can be turned on with a recompile.
Architecture
src/omq_cli/
├── parser.cr argv → Config (fails fast on unknown flags)
├── config.cr options + endpoint list
├── runner.cr shared bind/connect, options, send/recv pumps
├── runners/ per-socket-type runners
│ ├── push.cr pull.cr pub.cr sub.cr
│ └── req.cr rep.cr pair.cr pipe.cr
├── formatter.cr ascii / quoted / jsonl / null encode+decode
├── z85.cr Z85 codec (CURVE keys)
└── mruby/
├── lib_mruby.cr Crystal `lib` bindings
├── shim.c thin C wrapper over the parts of mruby's API
│ that are macros (mrb_nil_value, GC arena, …)
├── vm.cr VM lifecycle, compile, eval, type checks
└── expression.cr BEGIN/body/END extraction, `it` rewriting,
Proc pinning across GC, result normalization
The Crystal side talks to mruby through a small C shim (shim.c) that exposes the macro-based pieces of mruby's API as plain functions. libmruby.a is built from vendor/mruby with the gem set in vendor/build_config.rb and statically linked into the final binary.
The ZeroMQ stack is omq.cr — a pure-Crystal ZMTP 3.1 implementation linked in as a shard. There is no libzmq involved, even at build time.
Building
Dev build
make build # builds libmruby + shim, then `shards build`
./bin/omq --help
Requires Crystal ≥ 1.20 and Ruby + rake (only for the mruby build script). The Crystal binary itself does not need Ruby at runtime.
Static-musl release
make release-static # native arch
make release-static PLATFORM=linux/arm64 # cross-compile via QEMU binfmt
make release-static OCI=docker # force docker over podman
Builds inside an Alpine container and emits a self-contained binary at dist/<platform>/omq. No libc, libsodium, libpcre2, or libgmp dependency — runs on Alpine, Debian, RHEL, Amazon Linux, anywhere with a reasonably modern kernel.
Tests
make test
Includes integration tests for push/pull pipes, req/rep round-trips, periodic --interval ticks, and CURVE handshakes.
Status
v0.1 is a partial port of the Ruby omq-cli. The big things still NYI are flagged explicitly by the parser (LZ4/zstd compression, --ffi, --parallel, --require, alternate crypto backends) — running with one of these flags fails with a clear "not yet implemented" error rather than a silent no-op.
Supported socket types are listed above; the rest of the ZeroMQ lineup (dealer, router, xpub, xsub, client, server, …) is parsed but rejected at runner-construction time.
License
ISC. See LICENSE.
omc-cli.cr
- 0
- 0
- 0
- 0
- 1
- about 8 hours ago
- April 24, 2026
Sat, 25 Apr 2026 00:19:31 GMT