mdtask-core 0.2.0

Embeddable, execution-capable Rust task runner whose tasks are defined in markdown (heading = task, fenced block = script, Key: value metadata).
Documentation
# 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. `inherit-cwd` runs it where you invoke it, so
`mdtask pdf notes/a.md` resolves the path relative to your current directory.

Args: file
Opts: inherit-cwd

```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, and promises only a little. It
borrows conventions (xc's clean `Key: value` metadata vocabulary, mask's per-fence
interpreter and positional args), so the simplest xc or mask files may parse, but
it owns its grammar and makes no compatibility or round-trip promise. Treat that
overlap as a convenience, not a contract.

## 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`.
    **`{{ name }}` is raw text substitution**, spliced in before the interpreter
    parses the script, so `{{ name }}` is *not* safe for untrusted values in any
    language. The safe form is to **read the argument from the environment**, never
    to template it: `"$name"` in a shell (the shell quotes it, and `${name%md}`
    works), `os.environ["name"]` in Python, `process.env.name` in Node, and so on.
    Reserve `{{ }}` for developer-authored templates.
  - `Opts:` carries per-task flags, space-separated. The one flag today is
    **`inherit-cwd`**: run the task in the directory you invoked mdtask from,
    instead of the default. Use it for a carry-around task that operates on a path
    relative to where you are (this is just's `[no-cd]`). An unrecognized flag is
    reported as a warning and ignored.
  - `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.

### Working directory

A task runs in **the directory of the file that defines it** by default, the way
`just` runs a recipe from its justfile's directory. A task script is written
against its project's layout, so it runs from that project's root, even when you
invoke mdtask from a subdirectory (and, for a task reached by the tree-walk
below, from the directory of the ancestor file that defined it). Add
`Opts: inherit-cwd` for the exception: a carry-around task that should operate on
a path relative to wherever you are. Anything more specific than those two anchors
is a `cd` in the script.

### 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 substitution, so an agent-supplied value would be
  injectable. Expose such a task only after switching it to read the value from
  the environment (`"$arg"` in a shell, `os.environ["arg"]` in Python, and so on).
  (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.