Skip to content

Extending

A provider adapts one runner (moon, just, cargo, your own in-house tool) to the shared model. The built-ins are ordinary providers with no privileges — anything they can do, your own provider can do too.

interface RunnerProvider {
id: string
kind: 'parse' | 'exec'
chain: ChainSegment[]
brand?: RunnerBrand
binary?: string
watch?: string[]
collect(ctx: ProviderContext): Promise<ProviderResult>
}
  • id — a stable id; becomes Task.runner and path[0].
  • kind — "parse" reads files only; "exec" must spawn a subprocess. See parse and exec providers for why this distinction exists.
  • chain — what each position of this runner’s Task.path means, as { label: string; hint: string }[] — a one-word column name and a sentence for tooltips and help text.
  • brand — optional visual identity ({ icon?: string; color?: string }, an inline SVG and a hex color), copied onto the report. A provider is complete without one, and no consumer may require it.
  • binary — optional; the binary this runner’s tasks execute through, e.g. "moon", "just". Like brand it is a static declaration: the provider only names it and never reports presence itself. collect() runs ctx.which() on that name and writes the answer to RunnerReport.tool, so a provider cannot lie about whether its tool is installed. Omit it when the binary is resolved per directory rather than fixed — node-scripts picks npm, pnpm, yarn or bun per package, and one static name would be wrong for all but one of them. binary is orthogonal to kind: just is kind: "parse" — it reads justfiles rather than shelling out — and still declares binary: "just", because that is what its tasks run.
  • watch — optional; basenames whose contents decide whether a cached answer for this runner still holds. Only an exec provider needs one; see watch and the cache below.
  • collect(ctx) — find the runner and enumerate its tasks. Returns { status: "absent", tasks: [], diagnostics: [] } when the runner is not present — that is a value, not an error. Providers are expected to bail cheaply: check the marker file first, before any real work.

A provider never spawns a process directly. ctx.runner(binary) hands back an injected runner, so a test supplies canned output instead of needing the real binary on the PATH.

A provider never reads the filesystem for discovery. ctx.files() applies the caller’s chosen strategy — git-tracked files versus a directory walk. A provider reaching past it (calling readdir itself, say) would silently disagree with every other provider about which files exist, since the two strategies genuinely see different files for the same directory.

Both rules exist for the same reason: they keep a provider honest and testable. Everything a provider needs comes through ctx:

interface ProviderContext {
root: string
runner(binary: string): AsyncCommandRunner
files(pattern: string): Promise<string[]>
readJson(relPath: string): Promise<unknown>
readText(relPath: string): Promise<string | null>
exists(relPath: string): Promise<boolean>
which(binary: string): string | null
allowExec: boolean
discovery: 'git' | 'walk'
signal: AbortSignal
}

root is absolute; every path a provider returns is relative to it. runner is a factory rather than a single CommandRunner because different providers need different binaries (moon, just, git). files(pattern) takes a basename ("justfile") or a *-prefixed suffix ("*.mk"), honors the resolved discovery mode, and is memoized for the life of one collect() call — as are readJson and readText. which(binary) returns the resolved path of a binary on the PATH, or null: a lookup with no subprocess, which is exactly why it exists separately from ctx.runner, which spawns. It is therefore safe under allowExec: false, and it is how collect() fills RunnerReport.tool. allowExec is the resolved trust gate, and a parse provider may read it: several check ctx.allowExec && ctx.which(binary) !== null and, when both hold, ask the tool for a better answer than their file parsing can give. What a parse provider must never do is spawn when allowExec is false. signal is aborted when the caller cancels; pass it to any long-running work.

Results are cached, and a stored answer is invalidated by a content hash over the files the probe depended on — never by an mtime.

A parse provider needs no watch at all, and gets two things for free. Every read it makes goes through ctx, so recording what it touched covers exactly what it read. And every pattern it passes to ctx.files() is recorded too and re-expanded on each cache read, so a file that appears later matching the same pattern invalidates the entry — which matters, because a provider that discovers by listing depends on the answer to its query, and a file that did not exist when the probe ran was never read and moves no recorded hash.

An exec provider does need one, because it reads only its markers while its TOOL merges files it never opens — moon checks .moon/workspace.yml while moon itself merges .moon/tasks/** and every project’s own moon.yml.

const moonProvider: RunnerProvider = {
id: 'moon',
kind: 'exec',
watch: ['moon.yml'],
// …
}

Basenames only, in the same pattern language ctx.files() already speaks: an exact basename, or *suffix matched against the basename. Not a path glob. One pattern language shared with discovery is worth more than exact invalidation for a rare edit, and the cost is stated rather than hidden — a directory whose contents matter but whose filenames are not distinctive is simply not covered, and an edit there goes unnoticed until the exec entry’s four-hour max age expires it. Patterns are re-expanded on every read, never taken from the stored entry, so a file that did not exist when the entry was written still invalidates it.

watch is deliberately the ONLY cache-related field on this contract. There is no slow: slowness is measured per repository, and a static default would only ever apply on the very first run, when there is nothing cached to be stale about. There is no TTL knob, cache-policy field or invalidation-strategy enum either — kind already separates the exec providers, whose fingerprint cannot see their tool’s merge inputs, from the parse providers, whose fingerprint is exact.

A provider is never told it is being cached. Timings and recorded reads reach the cache through CollectOptions.probeObserver, an option on the CALLER: a provider that could observe its own timings could behave differently when watched.

import type { ProviderContext, RunnerProvider } from '@sksizer/detect-runners'
import { collect, builtinProviders } from '@sksizer/detect-runners'
const houseProvider: RunnerProvider = {
id: 'house',
kind: 'parse',
binary: 'house',
chain: [
{ label: 'runner', hint: 'our in-house runner' },
{ label: 'task', hint: 'an entry in house.toml' },
],
async collect(ctx: ProviderContext) {
const found = await ctx.files('house.toml')
if (found.length === 0) {
return { status: 'absent', tasks: [], diagnostics: [] }
}
return {
status: 'ok',
aspect: { id: 'house', evidence: [{ kind: 'files', detail: found[0]! }] },
tasks: [
{
id: `house:${found[0]}:build`,
runner: 'house',
path: ['house', '.', 'build'],
argv: ['house', 'build'],
cwd: '.',
display: 'house build',
parameters: [],
source: found[0]!,
origin: 'declared',
tags: ['house'],
persistent: { value: false, declared: false },
executes: 'local',
},
],
diagnostics: [],
}
},
}
await collect(process.cwd(), { providers: [...builtinProviders, houseProvider] })

This example is verified against the real package types — bunx tsc --noEmit on it is clean.

There are two hook points, and they compose:

CollectOptions.providers — pass your own array, typically the built-ins plus yours:

await collect(root, { providers: [...builtinProviders, houseProvider] })

Any caller that constructs a Catalog itself uses this.

--config on the CLI — a module path whose export is merged with the built-ins:

Terminal window
detect-runners --config ./house-provider.js

The module must export providers (an array), or a default export with a providers property:

export const providers = [houseProvider]

The CLI loads it with a dynamic import(), validates that providers is an array, and runs [...builtinProviders, ...providers] — so a house runner needs no fork of detect-runners and no rebuild, just a config flag.