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.
The RunnerProvider contract
Section titled “The RunnerProvider contract”interface RunnerProvider { id: string kind: 'parse' | 'exec' chain: ChainSegment[] brand?: RunnerBrand binary?: string watch?: string[] collect(ctx: ProviderContext): Promise<ProviderResult>}id— a stable id; becomesTask.runnerandpath[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’sTask.pathmeans, 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". Likebrandit is a static declaration: the provider only names it and never reports presence itself.collect()runsctx.which()on that name and writes the answer toRunnerReport.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-scriptspicks npm, pnpm, yarn or bun per package, and one static name would be wrong for all but one of them.binaryis orthogonal tokind:justiskind: "parse"— it reads justfiles rather than shelling out — and still declaresbinary: "just", because that is what its tasks run.watch— optional; basenames whose contents decide whether a cached answer for this runner still holds. Only anexecprovider needs one; seewatchand 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.
The two rules
Section titled “The two rules”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.
watch and the cache
Section titled “watch and the cache”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.
A minimal provider
Section titled “A minimal provider”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.
Registering a provider
Section titled “Registering a provider”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:
detect-runners --config ./house-provider.jsThe 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.