Status: draft / MVP design. This describes the intended MVP and flags deliberately-deferred features. It is a direction, not a frozen spec.
marathon is a CLI/TUI for viewing, validating, and
running markdown runbooks — markdown documents whose fenced code blocks can be
executed in order.
The guiding constraint: a runbook is just a markdown file. You can write one in any editor, and it renders cleanly in any markdown tool (GitHub, Pandoc, glow, …). Marathon-specific behavior is layered on top of standard markdown, never via syntax that breaks other renderers.
The mental model for the TUI is glow + Jupyter: a rendered markdown document that is also a sequence of runnable cells.
Design value: lean simple. Prefer the least machinery that is still useful. Reach for new mechanisms only when an existing one genuinely can't carry the weight.
Marathon adds configuration in three layers, in order of preference. Always prefer the earliest layer that works:
- Frontmatter — YAML at the top of the file. Document-level config.
- Code-block info string —
key=valuepairs after the language token. Per-cell config. Standard markdown renderers use only the first token (the language) for highlighting and ignore the rest, so this stays compatible. - Special code blocks (last resort) — a normal
jsonblock tagged with a marathon role. Used only when 1 and 2 can't express it (notably: prompting the user for input). Kept asjson(not a custom fence) so other tools still highlight it.
Here's some markdown prelude.
```sh skip=true foo=bar
echo "$GREETING"
```
More markdown text.
- The first token (
sh) is the language → other renderers highlight it as shell. - Everything after is
key=valuepairs, parsed with theserde-kvcrate intoCodeBlockMeta. - Compatibility note: Pandoc has a formal
{.sh key=value}attribute form, but GitHub does not understand it and would show the braces as the language. Barekey=valueafter the language is the more broadly compatible choice, so that is what marathon uses.
- The special-block role key is
mrthn:```json mrthn=input. (A short, unobtrusive namespace that leaves room for future roles likemrthn=table.) - Ordinary per-cell options are bare keys:
skip=true.
A runbook parses (via the markdown crate → mdast) into a flat, ordered list of
cells. Markdown prose between code blocks is rendered as-is; fenced code blocks
are the runnable/interactive cells. There is no container/nesting — the document
is a flat sequence.
- MVP runners: shell only —
sh/bash/zsh. - Recognized shell languages default to runnable; opt out per cell with
skip=true. - Unknown languages are display-only (rendered, never executed) in the MVP.
- Frontmatter may remap a language to an actual binary, shebang-style — e.g.
"when you see
sh, actually run/usr/bin/env zsh."
Each runnable cell is executed as its own process (tokio::process::Command)
against the configured shell. Cells do not share an in-process shell session in
the MVP (no persisted shell functions, cd, or unexported vars). They share state
two ways only:
- The environment map marathon injects at spawn (see §4).
- Files, via the shared
TMP_DIR(see §4).
- Working directory: the current working directory (where the user invoked
marathon).
TMP_DIRis separate scratch space, not the run dir.
Marathon owns an environment map that accumulates over the run and is injected into every shell cell at spawn. There is no stdout-into-variable capture in the MVP. The map is populated from exactly three sources:
- Frontmatter
env— static key/values; global, available from the first cell. TMP_DIR— auto-injected, set to a freshmktemp -d. Shared for the entire run; cells write/read files here to pass durable state. Cleaned up at the end of the run unless retention is requested (CLI flag or frontmatter).- Input cells — a
json mrthn=inputcell with atargetfield. When the cell runs, the user's choice is stored in the env map under the name given bytarget, and is then visible to all subsequent cells. Frontmatter env is global; input-cell values depend on execution order.
Deferred (not MVP): GitHub-Actions-style explicit capture (e.g. a cell writing
KEY=VALUEto$MRTHN_ENVto export into the env map). Worth doing later; left out of the MVP to keep things simple.
A json block tagged mrthn=input. Marathon renders a prompt, collects the user's
choice, and writes it into the env map under target. Illustrative shape (subject
to change):
{
"type": "select",
"multiple": false,
"options": "./choices.txt",
"target": "CHOICE"
}A preceding sh cell can produce choices.txt (under TMP_DIR or cwd); a
following sh cell can use "$CHOICE". To other markdown tools this is just a
highlighted JSON block.
Future input/render types (other than
input) are possible but out of MVP scope.
marathon run <file>— execute a runbook cell by cell.marathon validate <file>— parse + check frontmatter/cell metadata without running anything.marathon new <file>— scaffold a minimal runbook.marathon export <file>— backburner. An "eject" that lowers the runbook to a shell script. Explicitly best-effort: it won't be pretty, interactive input cells can't lower cleanly, but it should roughly run and be cleanable by hand. Not an MVP priority.
Run at your own peril — running a runbook executes arbitrary code by design.
- Default
rungoes cell by cell with enter-to-confirm before each cell. This is the natural safety gate. --yes(or similar) runs straight through without per-cell confirmation.- The TUI is inherently safer (you step through); the CLI
--yespath is the sharp edge, and that's accepted.
Combination of glow (rendered markdown) and a Jupyter notebook (ordered, runnable cells). Renders the document, lets the user move between runnable cells, run them, see output inline, and respond to input cells. Built on ratatui (0.30) + ratatui-textarea + crossterm.
Implementation note: use the ratatui skill — the 0.30 API differs substantially from pre-0.30 material in model training data.
Status: not fixed. Captured below as a future consideration / working direction. Needs further review before it's settled.
Cell output is captured for display (and exit code for control flow); output is not bound to variables in the MVP (see §4, and the GitHub-Actions capture deferral in §8). Tentative direction:
- Streaming, merged by default. Stream stdout+stderr live (piped
tokio::process::Command), merged into one stream by default. Per-cell sinks are configurable, e.g.stdout=/dev/null/stderr=.... Color is a configurable toggle. - Merge tradeoff (no pty in MVP). Merging at the source (
2>&1/ shared fd) gives true ordering but loses which-stream-is-which; two pipes into one sink keep the distinction but order is best-effort. A pty (deferred, see §8) is what closes that gap. Leaning: two-pipe/tagged for the TUI so stderr can be styled; a mergedrun.logis a possible later add. - Sanitize at the TUI boundary only. In CLI mode, pass raw bytes through (the
real terminal interprets ANSI). In TUI mode, ratatui does not interpret ANSI,
so sanitize when rendering:
- SGR color/style (
\x1b[…m) → parse to ratatui styles when color is on, strip when off. - All other escapes (cursor moves,
\x1b[2J/\x1b[K, OSC title, C0/C1 controls) → always strip — these corrupt the TUI. - Normalize
\r(collapse progress-bar rewrites to the final segment) and expand tabs.
- SGR color/style (
- Color at the source. Color-off can also set
NO_COLOR=1in the child env (force-on viaCLICOLOR_FORCE=1) so many tools emit no SGR at all; still strip defensively. - Candidate crates:
strip-ansi-escapes(color-off path),ansi-to-tui(color-on path). Full fidelity (vt100/vte/tui-termscreen grid) is the pty upgrade, not MVP.
Kept here so the MVP stays small but the door stays open:
- Multiple kernels/runners — Python, JS, SQL, etc. beyond shell.
- Persistent shared session — a pty-backed runner so cells share real shell
state (vars, functions,
cd), instead of separate processes + env map + files. - GitHub-Actions-style env capture — cells exporting values into the env map.
- Templating — minijinja-style (à la dbt). Deferred; the env map already covers
most of the need via plain
$VARS, and templating muddies the "just shell + env" model. - Richer special blocks — additional
mrthn=render/input types. TMP_DIRretention / run dir options beyond the basic flag.
Early scaffold. cli::App has no subcommands yet; book::BookFrontmatter is empty
and book::CodeBlockMeta has only skip; widget_markdown::render_md_node is a
todo!() match over every mdast node; term.rs and tui.rs are empty. This
document is the target these grow toward.