mdtask
An embeddable, execution-capable, markdown task runner for Rust. Define your
tasks in the same markdown you already write, whether a tasks.md, a
maskfile.md, or a project README.md, and run them from a library, a CLI, or
an agent-safe MCP surface.
Env: PKGX_DISABLE_UPDATE=1 # hoisted to every task
Build the release binary.
```sh
cargo build --release
Render a note to PDF. It runs where you invoke it, so mdtask pdf notes/a.md
writes next to the note, with no Dir: needed.
Args: file
```console
$ mdtask # list tasks (walks up the tree; see below)
$ mdtask build # run one
$ mdtask pdf notes/a.md # positional args fill `Args:` in order
$ mdtask lint # shellcheck the shell scripts it finds
Why this exists (honestly)
I wanted an embeddable Rust library that parses and runs markdown-defined tasks, so that an editor, a TUI, or an agent host could offer "run the tasks in this file" without shelling out to another tool. I went looking, and found nothing that fit:
- xc, Task, and runme are excellent, but they are Go, so there is no Rust you can link.
- just is Rust, but it uses a Makefile-style DSL, not markdown.
- mask has a Rust crate (
mask-parser), but it is parse-only, maskfile-locked, and untouched since 2024.
So mdtask is the million-and-first task runner, with no apology. It collects
the features I liked best from the others into a small, dependency-light core
library (mdtask-core) that anything can embed. The CLI and the feature-gated
MCP server are thin wrappers over it. The library is the point.
It is not compatible with any one of them. It borrows conventions (xc's clean
Key: value metadata vocabulary, mask's per-fence interpreter and positional
args), and a simple xc or mask task file often parses without changes, but it
owns its grammar and makes no compatibility or round-trip promise.
The format
- A task is a heading (
## name); the first fenced block under it is the script, and the fence language picks the interpreter (sh,bash,zsh,fish,python,ruby,node, with an unlabeled fence running assh). - A heading with no script is a section, not a task, so a
# Taskscontainer is fine. - Metadata is
Key: valuelines in the task body (case-insensitive):Args:declares positional arguments in just's syntax. A barenameis required,name='default'is optional (that value when omitted), and a trailing*nameis variadic (it collects the rest, space-joined). They are passed on the CLI in order, or prompted by an embedder. Each is substituted as{{ name }}in the script and exported as$name. Prefer$namein scripts, because the shell quotes it ("$name"is injection-safe, and${name%md}works).{{ name }}is raw text substitution, applied before the shell parses the script, so"{{ name }}"is not safe for untrusted values. Reserve{{ }}forDir:and developer-authored templates.Dir:overrides the working directory. Without it, a task runs in the directory you invoke it from, so an inherited or global task acts on your current project rather than on wherever the task file happens to live. A relative value resolves against the task file's own directory (Dir: .pins the task there, the inverse of just's[no-cd]); an absolute value is used verbatim. It may use{{ arg }}, and resolution never touches the filesystem.Env:adds environment (KEY=VALUE, KEY2=VALUE2). AnEnv:under a section heading is hoisted to every task, regardless of position.Requires:lists task dependencies. The CLI runs them first, resolved across the layered files, dependencies before dependents, each once (a diamond runs its shared dependency once), aborting on the first failure. A missing or cyclic dependency is a hard error. Dependencies run with no positional args (their defaults fill in); only the named target receives the CLI args. Note that dependencies re-run on every invocation: there is nomake-style "already satisfied" mtime or hash check, soRequires:is for ordering, not for skipping work that is already done.Agent: allowopts a task in to an MCP or agent surface. It is off by default, and a caller must filter withTaskFile::agent_tasks()(the enforcement point), so handing a task file to an agent never exposes ungated shell. See MCP.
The parser is line-based (no CommonMark dependency), so a # or Key: inside a
fenced block is never mistaken for structure. Parsing is infallible but records
problems (an unterminated fence, a duplicate task, an unknown interpreter) in
TaskFile::warnings; surface them rather than trusting silence.
Finding task files
The CLI walks up from the current directory (like make, just, and xc),
taking the first tasks.md, maskfile.md, or README.md in each ancestor that
defines a task. Nearer files shadow farther ones by task name, like just's
set fallback, so a project inherits a baseline of tasks from its parents and
overrides them where it wants. (mdtask-core::parse itself does no filesystem
access; an embedder with its own project root just calls it directly.)
Linting
mdtask lint [TASK] runs shellcheck over the
shell scripts it finds (all tasks, or one named task). It only checks POSIX-sh
family fences (sh, bash), since shellcheck cannot analyze zsh, fish, or a
non-shell interpreter; those are skipped with a note. mdtask does not bundle
shellcheck: it finds one on your PATH, falls back to pkgx shellcheck, and
otherwise tells you to install it. SC2154 (referenced but not assigned) is
suppressed, because mdtask exports args and Env: as shell variables.
MCP
Built with --features mcp, mdtask mcp serves the working set to an MCP client
(Claude Desktop or Code) over stdio, so an agent can run your tasks. It is fail
closed: only tasks marked Agent: allow are exposed, everything else is
invisible and unrunnable.
list_tasksenumerates only the allowed tasks, so the rest are not even discoverable.run_taskre-checks the allowlist before running, so naming a hidden task fails too. Its output is captured and returned as the tool result.- A
Requires:dependency of an allowed task still runs, but is never listed and never independently callable. Crucially, the chain is resolved within the allowed task's own file, not by the global nearest-wins layering the CLI uses: a nearer, untrustedtasks.mdin the invocation directory cannot shadow a dependency and run its own code through an allowed entry point. The author who wroteAgent: allowvouched for their file's tasks, and only those run. - A task that interpolates an argument into its script via
{{ arg }}is refused:{{ }}is raw, unquoted substitution, so an agent-supplied value would be shell-injectable. Expose such a task only after switching it to"$arg", which the shell quotes. (This applies to the target that receives the agent'sargs; dependencies run with author-controlled defaults.)
The mcp feature is off by default, so a plain build pulls no JSON or server
dependencies.
Embedding
use Path;
let tf = parse;
let task = tf.task.expect;
// Bind positional values to the task's Args (defaults + variadic), or build
// the map yourself from an embedder's prompts.
let args = bind?;
// `cwd` is where the task runs by default; the second path anchors a relative
// `Dir:` (the task file's own directory). Pass `None` to fall back to cwd.
let cwd = current_dir?;
let inv = tf.invocation?;
// `inv` is { program, args, env, cwd }. Run it on your own worker or thread,
// or call `inv.run()` for a blocking convenience.
mdtask-core builds the [Invocation] for you and stays out of the way of
when and how you run it, which is what lets a TUI keep execution off its
render thread.
Status
Early. The core parser and invocation builder are solid and tested; the CLI runs, lints, and serves MCP; a crates.io release is still pending. Intended consumers: gloaming and penknife.
Credits
The maskfile format is by @jacobdeichert;
the xc format and its metadata conventions are by
@joerdav. mdtask is an independent
implementation that drew on both; it is not affiliated with or compatible with
either.
License
MIT OR Apache-2.0.