omc-cli.cr

OMQ CLI in pure Crystal (mruby for eval scripts)

omq — a ZeroMQ command-line tool

License: ISC Crystal

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 for ipc://@name (Linux abstract namespace), so omq pull -b @ticks Just 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 \\ \" \xNN escapes. 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 of SEC since the Unix epoch, not to SEC after the previous send. Two omq pub -i 1 instances 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 -n for 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 EXPR runs on each incoming message before it's printed.
  • -E EXPR runs 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.

Repository

omc-cli.cr

Owner
Statistic
  • 0
  • 0
  • 0
  • 0
  • 1
  • about 8 hours ago
  • April 24, 2026
License

Links
Synced at

Sat, 25 Apr 2026 00:19:31 GMT

Languages