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.
Vocabulary
Section titled “Vocabulary”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'}argv versus display
Section titled “argv versus display”These are two different strings on purpose, and a consumer should never build one from the other.
argvis 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.displayis 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*flagsis a glob.
The rest of the fields
Section titled “The rest of the fields”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), alsopath[0].path— the display/search chain, root-first, withrunnerat index 0.cwd— the directory the command expects, relative to the catalog root;"."means the root.parameters— aParam[]; 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 clippyis not written down anywhere — it is what you run when aCargo.tomlexists). A consumer that wants “what did this repo actually define” filters on this, or passes--declaredon 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 fromdisplay(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 }.valueis whether the task keeps running until stopped (a dev server, a watcher).declareddistinguishes a runner stating that itself (moon’spersistentoption) from the library guessing it from the task’s name, so a consumer that wants only authoritative answers can checkdeclaredfirst.executes— where running the task puts the work:"local","remote"or"container". See below.
executes is not kind
Section titled “executes is not kind”executes and a provider’s kind ("parse" or "exec") answer two
different questions, and blurring them will lead a consumer astray.
- A provider’s
kindis a detection posture: what detect-runners must do to read the repository and learn the task exists.parsereads files;execshells out to the tool. - A task’s
executesis 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}The JSON envelope
Section titled “The JSON envelope”--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; everycwdandsourcein the catalog is relative to it.generatedAt— an ISO timestamp, because collection is a function, not a cached snapshot — every call re-probes. Under--cachedit 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 (gitlists tracked and untracked-but-not-ignored files;walkwalks 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).
The cache array
Section titled “The cache array”--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.
The tool block
Section titled “The tool block”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 staticbinaryname;collect()resolves it withctx.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 aPATHlookup with no subprocess, so fillingtoolis safe even underallowExec: false. That is precisely whywhichexists on the context separately fromrunner, 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-scriptspicks 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.
A real example
Section titled “A real example”This is trimmed output from an actual run, not hand-written. The command:
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.