Translations: العربية · Español · Français · हिन्दी · 日本語 · 한국어 · Português (BR) · Русский · 简体中文
Each is a translation of this file, which is authoritative where they disagree.
rune is a Ruby CLI and library built to be equally usable by a human at a terminal and an AI
agent driving it programmatically. Every command returns the same structured Result — only the
rendering changes based on how you're calling it.
The unqualified rune gem name is already taken on the public RubyGems.org registry by an
unrelated package, so gem install rune there installs the wrong thing. The supported end-user
installation path is the checksum-pinned formula in the CorvidLabs Homebrew tap:
brew install corvidlabs/tap/rune
rune version --jsonHomebrew automatically adds the tap on first install. Upgrade Rune with:
brew upgrade corvidlabs/tap/runeClone the source only when developing Rune itself:
git clone https://github.com/CorvidLabs/rune.git
cd rune
bundle install
ruby bin/rune versionOr as a fledge plugin:
fledge plugins install CorvidLabs/rune
fledge rune run --json -- git statusrune --help # or -h, or `rune help`
rune run --help # or `rune help run`, or `rune run -h`Command help lists that command's own flags — --timeout=SECONDS for rune run, --log=PATH for
rune watch — alongside the global ones. It is structured in agent mode too, so discovery does not
require parsing the human rendering:
$ rune run --help --json | jq -c '.data.flags'
[{"flag":"--timeout=SECONDS","description":"Kill the wrapped command after N seconds (default 30). Before `--` only."},{"flag":"--max-output=BYTES","description":"Bound clean_output/raw_output to BYTES each, keeping head+tail and marking the join with a `[rune] ==== N bytes omitted by --max-output ====` line. Mutually exclusive with --tail. Before `--` only."},{"flag":"--tail=N","description":"Keep only the last N lines of clean_output/raw_output. Mutually exclusive with --max-output. Before `--` only."},{"flag":"--separate-streams","description":"Adds clean_stdout/clean_stderr (stderr on a pipe, not the pty) alongside the merged view. Before `--` only."}]Help flags follow the same separator rule as everything else (below): rune run -- mytool --help
passes --help to mytool.
rune picks a rendering mode automatically based on how it's invoked, or you can force one
explicitly with a flag. All three modes execute the exact same command logic — only the output
format differs.
When stdout is a real terminal and no --json/--ndjson flag is given, rune prints
colorized, human-formatted output:
$ rune version
rune v0.9.0
Ruby 4.0.6 (arm64-darwin25)
fledge: ✓ available
spec-sync: ✓ available$ rune run -- echo "hello"
✓ echo hello (6.2ms, exit 0)
helloPass --json explicitly, or simply pipe/redirect rune's output — a non-TTY stdout switches
rendering to JSON automatically, no flag required:
$ ruby bin/rune run --json -- echo "hello agent"
{"status":"ok","data":{"command":"echo hello\\ agent","exit_code":0,"clean_output":"hello agent\n","raw_output":"hello agent\r\n","prompt_detected":false,"duration_ms":5.27}}$ ruby bin/rune version | cat
{"status":"ok","data":{"name":"rune","version":"0.7.0","ruby":"4.0.6","ruby_platform":"arm64-darwin25","fledge":true,"specsync":true}}
exit_codeis the wrapped process's exit status, not a verdict on the work. It answers "did the process end, and how", which for an agent CLI is almost always0— including runs whose output was wrong. One caller had eight consecutiverune rundispatches return0, several of which produced conclusions they later had to correct. If you need to know whether the work succeeded, that has to come from the output, not from this field.124is the exception worth knowing: it means rune killed the process on--timeout.
Every JSON response has the same envelope: {"status": "ok"|"error", "data": {...}} (or
{"status": "error", "error": "..."} on failure).
Rune writes the final envelope to stdout for both success and failure. That gives agents one
parseable result channel, but it also means a human redirecting stdout redirects Rune-level error
messages too. Stderr is reserved for operational announcements and live rune watch passthrough
that must not corrupt structured stdout.
Global output flags are recognized only before the first -- separator. Tokens after it belong to
the wrapped command and are preserved, so rune run -- tool --json passes --json to tool.
A --flag rune does not know, in the position where rune's own flags go, is an error rather than
something silently passed on: rune run --tiemout=5 -- echo hi used to try to execute the
misspelled flag and answer status: ok with exit_code: 127. Only the tokens before the wrapped
command are checked, so rune run cargo clippy --tests and rune run -- mytool --tiemout=5 are
untouched — once the command name has been seen, every later --flag belongs to it.
--ndjson wraps the same result in an {"event": "result"|"error", ...} envelope instead of the
plain {"status": ...} shape --json uses — a format some agent harnesses expect uniformly for
every command, rune run included:
$ ruby bin/rune run --ndjson -- echo "hello stream"
{"event":"result","status":"ok","data":{"command":"echo hello\\ stream","exit_code":0,"clean_output":"hello stream\n","raw_output":"hello stream\r\n","prompt_detected":false,"duration_ms":11.45}}For rune run, this is still exactly one line, emitted once the command finishes — PTYRunner
buffers the whole run and returns a single Result, so --ndjson here is an envelope choice, not
incremental streaming. For an actual live event stream as a long-running or interactive command
progresses, see rune watch below, which emits one
NDJSON line per output chunk as it happens.
rune run spawns any CLI command or interactive TUI inside a real PTY, strips ANSI escape
sequences, disables pagers, and measures execution time:
rune run -- git status
rune run --json -- npm test
rune run --ndjson -- fledge lanes run checkEvery rune run invocation has a 30-second default timeout. Override it with --timeout=SECONDS,
placed before the -- separator so it isn't mistaken for a flag belonging to the wrapped
command:
$ ruby bin/rune run --json --timeout=1 -- sleep 3
{"status":"ok","data":{"command":"sleep 3","exit_code":124,"clean_output":"\n[rune] Execution timed out after 1 seconds","raw_output":"\n[rune] Execution timed out after 1 seconds","prompt_detected":false,"duration_ms":1005.32}}A timed-out command returns exit code 124 with a [rune] Execution timed out after N seconds
message appended to the captured output — it's still a normal Result, not an exception.
Output captured before the kill is always returned, so a child that printed and then hung shows what it printed. If the output is empty, the child genuinely printed nothing, and rune says so along with the most common reason.
rune run does not forward its own stdin to the child. A tty belongs to the human — taking it
is rune watch's job — and forwarding a pipe would echo the caller's own input back through the pty
and into clean_output. So echo hi | rune run -- cat times out: cat is waiting for input that
never arrives. Put the redirect inside the command instead, where the shell performs it in the pty:
$ rune run -- sh -c 'claude -p --output-format text < prompt.md'That works, and so does passing a multi-paragraph prompt as a single argument — newlines survive
argv intact. The command field in the reply is a shell-escaped display reconstruction for
humans, not what the child received; do not diagnose quoting from it.
Three more flags, all before the -- separator, all changing the shape of the result:
--max-output=BYTESboundsclean_outputandraw_outputto BYTES each, keeping the head and the tail, and addstruncated: truewithomitted_bytes. Two things follow from "each": the fields are bounded separately, so under this flag they describe different windows of the run andclean_outputis notstrip_ansi(raw_output)—omitted_bytesisclean_output's count andraw_outputcarries its own marker with a different one. Andomitted_bytesis measured in offsets into the original, so it reconciles exactly on ASCII but drifts by a few bytes on multi-byte text, where a cut can split a character. The two halves are joined by a[rune] ==== N bytes omitted by --max-output ====line rather than spliced, so the returned text never reads as something the command printed: without it, a 201-byte transcript at--max-output=200dropped exactly the byte that turnedchsh -s /bin/zshintochsh -s bin/zsh. That marker is rune's annotation rather than the command's output, so it is not charged against BYTES and a reply can run a little over the budget.--tail=Nkeeps only the last N lines, addingtruncated: truewithomitted_lines. Mutually exclusive with--max-output; passing both is an error rather than a silent precedence.--separate-streamsaddsclean_stdoutandclean_stderralongside the mergedclean_output, rather than replacing it.
--separate-streams has a real cost, which is why it is opt-in rather than the default: a pty has
one stream, so separating them means giving stderr its own pipe. The child then no longer sees a
single controlling terminal for both, and a program that checks isatty(2) will behave as though
its errors are being redirected — which for many CLIs means dropping colour, or switching to a
non-interactive mode entirely. Use it when you need the split more than you need the child to
believe it is on a terminal.
rune run buffers a command's entire output and only returns it once the command finishes — great
for scripting and capture, but no good if you actually want to sit at the keyboard and drive an
interactive program while something else observes the session. rune watch is built for that: it
puts your terminal in raw mode, forwards every keystroke you type to the child live — including
raw escape sequences like arrow keys, not just whole lines — streams the child's output to your
screen as it happens (not at the end), and simultaneously logs every chunk as an NDJSON event — so
an AI agent can tail the session in real time while a human drives it.
# A small interactive demo program ships with rune specifically to try this against:
rune watch -- ruby examples/humans/demo_tui.rbThe event log defaults to a collision-safe, owner-only (0600) temp file, not stderr — mixing
NDJSON events into the same terminal as the live passthrough was the original design, and real
usage immediately showed it was the wrong default (the interleaved JSON made the session
unreadable). The path is announced once, up front:
[rune watch] live event log: /tmp/rune-watch-20260728-12345-abcd.ndjson
tail -f that path from another pane (or have an agent tail it) to watch the session live, with
your own terminal staying clean. Point it somewhere specific instead with --log=PATH:
rune watch --log=/tmp/session.ndjson -- ruby examples/humans/demo_tui.rbEach log line is a JSON object: {"event":"start","command":"...","pid":...}, then one
{"event":"output","bytes":N,"text":"..."} per chunk as it streams, then
{"event":"exit","exit_code":N} when the child exits.
rune watch follows the same output-mode rules as every other command. Under --json, --ndjson,
or any time stdout isn't a terminal, the live passthrough moves to stderr and stdout carries
only the result envelope — so a wrapping program can parse stdout directly while the human at the
keyboard still sees their session:
rune watch --json -- ruby examples/humans/demo_tui.rb 2>/dev/null | jq .{
"status": "ok",
"data": {
"command": "ruby examples/humans/demo_tui.rb",
"exit_code": 0,
"duration_ms": 4820.11,
"log_path": "/tmp/rune-watch-20260728-12345-abcd.ndjson"
}
}Drop the 2>/dev/null to keep watching the session yourself while the JSON is captured elsewhere.
rune watch requires a real terminal (it refuses to run if stdin isn't a TTY — there's no
meaningful non-interactive mode) and won't work over rune run's own PTY inception, so it can't be
demonstrated in a piped example the way the rest of this guide is. examples/humans/demo_tui.rb's top-level
menu is a real arrow-key selector (↑/↓ + Enter, or q to quit) rather than type-a-number-and-press-
Enter, specifically to exercise raw single-byte and escape-sequence forwarding — the thing a purely
line-buffered menu never touches. examples/humans/demo_tui.rb's own header comment has copy-pasteable
commands, and spec/rune/pty_watcher_spec.rb shows how the underlying forwarding/logging mechanics
are unit-tested, including a test that drives the arrow-key menu itself end-to-end (a fake terminal
object plus IO.pipes drives a real interactive child process without needing an actual controlling
terminal).
Two independent limits, both before the -- separator, both off by default:
--timeout=SECONDSkills the session after N seconds of wall clock, however busy it is.--idle-timeout=SECONDSkills it after N seconds with no output and no input — the one you want for "this agent has stopped doing anything", since a long build is not idle.
Either gives exit code 124, with timed_out: true and a timeout_kind of "timeout" or
"idle_timeout" saying which fired.
Rune::Parsers::TableParser and Rune::Parsers::KeyValueParser turn unstructured terminal output
into Ruby hashes:
require 'rune'
Rune::Parsers::TableParser.parse(<<~TABLE)
NAME STATUS VERSION
fledge-plugin active 1.0.0
TABLE
# => [{ name: 'fledge-plugin', status: 'active', version: '1.0.0' }]TableParser.parse accepts a format: keyword (:auto by default, or :pipe/:space to force
a parsing mode) — see specs/parsers/parsers.spec.md for the
heuristic's known limitations before relying on :auto against unfamiliar output.
examples/smoke_test.rb—ruby examples/smoke_test.rborfledge run smoke-test. A standalone, assertion-based tour of real behavior (no bundler/rspec required): output modes,--timeoutvalidation, parsers,Script, signal forwarding, prompt detection.examples/humans/demo_tui.rb— the interactive demo used throughout therune watchsection above.examples/agents/pty_runner_example.rb,table_parser_example.rb, andscript_automation_example.rbare smaller, single-concept scripts — each runnable directly (ruby examples/agents/<name>.rb) with no setup beyondrequire_relative '../lib/rune'.- PTY Architecture Guide — how the PTY runner, stream reading, prompt
detection, and
rune watch's live passthrough work internally. specs/— machine-checked module contracts (spec-sync) forcli,parsers,pty_runner,session, andwatch.AGENTS.md— conventions for adding new commands and working with the trust toolchain.