Skip to content

The task model & JSON

Every provider — moon, just, cargo, whatever you add yourself — produces the same shape. A consumer that understands Task and Catalog once understands every runner detect-runners finds, without knowing anything about moon or just individually.

Three words, fixed by the library so they stop drifting:

  • Runner — a tool that runs things: moon, just, cargo, make.
  • Task — one runnable command a runner exposes, e.g. moon run web:dev.
  • Provider — the adapter that finds a runner and enumerates its tasks.
interface Task {
id: string
runner: string
path: string[]
argv: string[]
cwd: string
display: string
parameters: Param[]
source: string
origin: 'declared' | 'synthesized'
doc?: string
detail?: string
tags: string[]
persistent: { value: boolean; declared: boolean }
executes: 'local' | 'remote' | 'container'
}

These are two different strings on purpose, and a consumer should never build one from the other.

  • argv is the executable form: a string array, spawn it directly, never through a shell and never after re-joining it into one string. It carries no parameter placeholders.
  • display is the human form: for showing and for copy-paste. It may contain placeholders like <required>, [optional], *variadic, which is exactly why it is not executable — handing <lib> to a shell is an input redirection, and *flags is a glob.
  • id — stable across a re-collect, so a UI can keep its selection when the catalog refreshes underneath it, and so a caller can hold a reference to a task rather than an index into a list about to change.
  • runner — the provider that owns the task (provider.id), also path[0].
  • path — the display/search chain, root-first, with runner at index 0.
  • cwd — the directory the command expects, relative to the catalog root; "." means the root.
  • parameters — a Param[]; see below. Empty when the task takes none.
  • source — the file that declares the task, relative to the catalog root.
  • origin — "declared" when the task exists in a config file (a moon task, a just recipe); "synthesized" when the provider invented it from a convention (cargo clippy is not written down anywhere — it is what you run when a Cargo.toml exists). A consumer that wants “what did this repo actually define” filters on this, or passes --declared on the CLI.
  • doc — a summary, where the runner supplies one (just’s [doc] attribute); absent otherwise.
  • detail — what the task actually executes, when that differs from display (a just recipe’s body, an npm script’s command line).
  • tags — free-text facets for search and filtering (layer, stack, language, …).
  • persistent — { value, declared }. value is whether the task keeps running until stopped (a dev server, a watcher). declared distinguishes a runner stating that itself (moon’s persistent option) from the library guessing it from the task’s name, so a consumer that wants only authoritative answers can check declared first.
  • executes — where running the task puts the work: "local", "remote" or "container". See below.

executes and a provider’s kind ("parse" or "exec") answer two different questions, and blurring them will lead a consumer astray.

  • A provider’s kind is a detection posture: what detect-runners must do to read the repository and learn the task exists. parse reads files; exec shells out to the tool.
  • A task’s executes is a property of the task itself: what happens when a human or a caller runs it.

The two axes are independent. A GitHub Actions workflow file is parse-detected — you learn about it by reading YAML — but remote to run, because GitHub’s runners execute it and not this machine. A docker-compose service is container whichever way its definition was found, parsed or shelled out to.

Neither of those is hypothetical: github-actions and docker-compose ship, and they are why the field exists. Every other provider builds "local" tasks, which makes "local" the value you will see most and the one it is tempting to assume — branch on the value, do not hardcode it.

A task parameter as data, not text baked into a command line — the reason argv and display are separate fields in the first place:

interface Param {
name: string
required: boolean
variadic: boolean
default?: string
}

--json prints a Catalog, not a bare task list:

interface Catalog {
root: string
generatedAt: string
discovery: 'git' | 'walk'
allowExec: boolean
reports: RunnerReport[]
}
interface RunnerReport {
runner: string
chain: ChainSegment[]
brand?: RunnerBrand
tool?: { binary: string; found: boolean; path?: string }
skippedForExec: boolean
status: 'absent' | 'ok' | 'error'
aspect?: Aspect
tasks: Task[]
diagnostics: Diagnostic[]
}
  • root — the absolute path collection ran against; every cwd and source in the catalog is relative to it.
  • generatedAt — an ISO timestamp, because collection is a function, not a cached snapshot — every call re-probes. Under --cached it is the OLDEST contributing probe time, not the moment the answer was assembled: a catalog is only as fresh as its stalest row.
  • discovery — how files were found for this run (git lists tracked and untracked-but-not-ignored files; walk walks the tree). Recorded because the two genuinely disagree about what exists.
  • allowExec — whether providers were permitted to run subprocesses.
  • reports — one entry per provider that ran, in report order (see below), whether or not it found anything.

Each RunnerReport is a provider’s slice of the catalog: chain explains what each position of that runner’s Task.path means (for tooltips and column headers); status is "absent" when the runner was not found (not an error — most providers report absent on most repositories), "ok" when it ran and produced tasks (possibly zero), or "error" when it threw; skippedForExec is true when the provider needed a subprocess and execution was not allowed; aspect names the evidence for why the runner was judged present, when it is; diagnostics carries anything else worth surfacing that isn’t a task (a missing binary, a malformed config file).

--cached adds one more key beside reports, and the web view’s catalog response always carries it:

interface RunnerCacheState {
runner: string
state: 'fresh' | 'stale' | 'miss'
reason?: 'changed' | 'max-age'
ageMs?: number
estimateMs: number | null
slow: boolean
}

One entry per selected runner, describing where that runner’s rows came from. "fresh" means the stored answer still holds; "stale" means it was used and is out of date; "miss" means nothing was stored, so that runner was probed for real and its rows are current. reason appears on a stale row only — "changed" when the files the probe read have moved, "max-age" when an exec provider’s entry outlived the window a fingerprint cannot vouch for. ageMs is absent on a miss, which has no age.

estimateMs is the median cost of re-probing that runner, from THIS repository’s own measurements, or null when it has never been measured — null is not zero, and a consumer must not print a cost it does not have. slow is those measurements saying the runner is slow to probe here: measured, never declared, so a runner that is fast in your repository is not labelled slow because it is slow in someone else’s.

Plain --json does not carry the key. A freshly probed catalog has no staleness to describe, and a key that is always present and always says fresh teaches a consumer to ignore it.

tool answers one narrow question: is the binary this runner’s tasks execute through on the PATH right now, and where.

"tool": { "binary": "just", "found": true, "path": "/Users/me/.cargo/bin/just" }

Four things about it are worth knowing.

  • collect() fills it, never the provider. The provider only declares a static binary name; collect() resolves it with ctx.which() and writes the result. A provider therefore cannot misreport whether its own tool is installed — the presence fact does not pass through provider code at all.
  • ctx.which() is a PATH lookup with no subprocess, so filling tool is safe even under allowExec: false. That is precisely why which exists on the context separately from runner, which does spawn.
  • It is absent entirely when the provider declares no binary. Some runners resolve their binary per directory rather than from one fixed name — node-scripts picks npm, pnpm, yarn or bun per package — so no single static name would be true, and the honest shape is no key at all.
  • It is filled independently of whether the provider ran. A report skipped for exec, or one that errored, still carries an accurate tool. “just is installed but we were not allowed to run it” and “just is not installed” are different situations, and a UI can tell them apart.

This is trimmed output from an actual run, not hand-written. The command:

Terminal window
bun packages/ts/detect-runners/src/cli.ts --json --exec -C <fixture>

against a throwaway directory containing only two files:

justfile:
build:
echo build
release-lib lib bump *flags:
echo release {{lib}} {{bump}} {{flags}}
package.json:
{ "name": "demo", "scripts": { "test": "node --test", "dev": "node server.js" } }

The catalog carries one report per built-in provider. Everything but just and node-scripts came back "status": "absent" with an empty tasks array, so this excerpt elides those and keeps only the two that found something. Each report’s brand is elided too — the inline SVG is long and says nothing about the shape.

{
"root": "/…/fixture-demo",
"generatedAt": "2026-08-30T19:43:31.935Z",
"discovery": "git",
"allowExec": true,
"reports": [
// … the remaining reports, each "status": "absent", "tasks": [] …
{
"runner": "just",
"chain": [
{ "label": "runner", "hint": "just runs recipes declared in a justfile — …" },
{ "label": "justfile", "hint": "Which justfile declares the recipe. …" },
{
"label": "recipe",
"hint": "The recipe name. Any <required> / [optional] / *variadic parameters follow it.",
},
],
"tool": { "binary": "just", "found": true, "path": "/Users/me/.cargo/bin/just" },
"status": "ok",
"aspect": { "id": "just", "evidence": [{ "kind": "files", "detail": "justfile" }] },
"tasks": [
{
"id": "just:justfile:build",
"runner": "just",
"path": ["just", ".", "build"],
"argv": ["just", "build"],
"cwd": ".",
"display": "just build",
"parameters": [],
"source": "justfile",
"origin": "declared",
"detail": "echo build",
"tags": ["just", "root"],
"persistent": { "value": false, "declared": false },
"executes": "local",
},
{
"id": "just:justfile:release-lib",
"runner": "just",
"path": ["just", ".", "release-lib"],
"argv": ["just", "release-lib"],
"cwd": ".",
"display": "just release-lib <lib> <bump> *flags",
"parameters": [
{ "name": "lib", "required": true, "variadic": false },
{ "name": "bump", "required": true, "variadic": false },
{ "name": "flags", "required": false, "variadic": true },
],
"source": "justfile",
"origin": "declared",
"detail": "echo release {{lib}} {{bump}} {{flags}}",
"tags": ["just", "root"],
"persistent": { "value": false, "declared": false },
"executes": "local",
},
],
"diagnostics": [],
"skippedForExec": false,
},
{
"runner": "node-scripts",
"chain": [
{
"label": "runner",
"hint": "The package manager runs the `scripts` entries declared in a package.json.",
},
{ "label": "directory", "hint": "The directory the package sits in. …" },
{
"label": "package",
"hint": "The package's declared name, or its path when it has none.",
},
{ "label": "script", "hint": "The key under `scripts` in that package.json." },
],
// No "tool" key: node-scripts declares no static binary — the run verb
// is npm, pnpm, yarn or bun depending on the package.
"status": "ok",
"aspect": {
"id": "node-scripts",
"evidence": [{ "kind": "manifest", "detail": "package.json#scripts" }],
},
"tasks": [
{
"id": "node-scripts:package.json:test",
"runner": "node-scripts",
"path": ["node-scripts", ".", "demo", "test"],
"argv": ["npm", "run", "test"],
"cwd": ".",
"display": "npm run test",
"parameters": [],
"source": "package.json",
"origin": "declared",
"detail": "node --test",
"tags": ["node-scripts", "npm", "."],
"persistent": { "value": false, "declared": false },
"executes": "local",
},
{
"id": "node-scripts:package.json:dev",
"runner": "node-scripts",
"path": ["node-scripts", ".", "demo", "dev"],
"argv": ["npm", "run", "dev"],
"cwd": ".",
"display": "npm run dev",
"parameters": [],
"source": "package.json",
"origin": "declared",
"detail": "node server.js",
"tags": ["node-scripts", "npm", "."],
"persistent": { "value": true, "declared": false },
"executes": "local",
},
],
"diagnostics": [],
"skippedForExec": false,
},
],
}

Two details worth noticing: release-lib’s display carries the <lib> <bump> *flags placeholders while argv stops at ["just", "release-lib"] — the parameters are data, not string-glued onto the command. And dev’s persistent.value is true with declared: false: nothing in package.json says this script keeps running, the library guessed it from the name. Note also that every task here reads "executes": "local" — everything just and node-scripts find runs on this machine, and it takes a docker-compose or github-actions row to see another value — and that just carries a tool block while node-scripts has none, because only the first declares a fixed binary.

Each provider’s chain and brand (an inline SVG icon plus a hex color, on the report rather than per task) are covered in the library.