# 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.
```markdown
# Tasks
Env: PKGX_DISABLE_UPDATE=1 # hoisted to every task
## build
Build the release binary.
```sh
cargo build --release
```
## pdf
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
```sh
pandoc -t pdf -o "${file%md}pdf" "$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 as `sh`).
- **A heading with no script is a section**, not a task, so a `# Tasks` container
is fine.
- **Metadata** is `Key: value` lines in the task body (case-insensitive):
- `Args:` declares positional arguments in just's syntax. A bare `name` is
**required**, `name='default'` is **optional** (that value when omitted), and
a trailing `*name` is **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
**`$name`** in 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 `{{ }}` for `Dir:` 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`). An `Env:` 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 no `make`-style
"already satisfied" mtime or hash check, so `Requires:` is for ordering, not
for skipping work that is already done.
- `Agent: allow` opts a task in to an MCP or agent surface. It is **off by
default**, and a caller must filter with `TaskFile::agent_tasks()` (the
enforcement point), so handing a task file to an agent never exposes ungated
shell. See [MCP](#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](https://www.shellcheck.net/) 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_tasks` enumerates **only** the allowed tasks, so the rest are not even
discoverable.
- `run_task` re-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, untrusted `tasks.md` in the invocation directory **cannot** shadow a
dependency and run its own code through an allowed entry point. The author who
wrote `Agent: allow` vouched 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's `args`; dependencies run with author-controlled defaults.)
The `mcp` feature is off by default, so a plain build pulls no JSON or server
dependencies.
## Embedding
```rust
use std::path::Path;
let tf = mdtask_core::parse(&std::fs::read_to_string("tasks.md")?);
let task = tf.task("pdf").expect("a pdf task");
// Bind positional values to the task's Args (defaults + variadic), or build
// the map yourself from an embedder's prompts.
let args = mdtask_core::TaskFile::bind(task, &["notes/a.md".into()])?;
// `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 = std::env::current_dir()?;
let inv = tf.invocation(task, &args, &cwd, Some(Path::new(".")))?;
// `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](https://github.com/jhheider/gloaming) and penknife.
## Credits
The maskfile format is by [@jacobdeichert](https://github.com/jacobdeichert/mask);
the xc format and its metadata conventions are by
[@joerdav](https://github.com/joerdav/xc). `mdtask` is an independent
implementation that drew on both; it is not affiliated with or compatible with
either.
## License
MIT OR Apache-2.0.