Extending Zuke
Zuke is built to be extended without forking. Two of its
stable extension points are small public contracts a third-party package can
build against: a tool wrapper adds a typed, fluent CLI task
by extending ToolSettings; a plugin observes the
build lifecycle by implementing a few optional hooks. Both are plain values —
no subclassing Build, no runtime dependency
beyond @zuke/core.
Custom tool wrappers
A wrapper turns a CLI into a typed, discoverable task in the same
settings-lambda style as the built-in
@zuke/* wrappers. A distributable wrapper
extends the abstract ToolSettings class from
@zuke/core/tooling: you implement two methods —
defaultTool() (the binary to spawn) and buildArgs()
(the subcommand argv) — and the base contributes the shared fluent chainers
and the execution logic.
import {
type Configure,
runSettings,
ToolSettings,
} from "jsr:@zuke/core/tooling";
// One settings class per subcommand. buildArgs() assembles the argv purely.
class MyToolSettings extends ToolSettings {
#args: string[] = [];
fast(): this {
this.#args.push("--fast");
return this;
}
protected override defaultTool(): string {
return "mytool";
}
protected override buildArgs(): string[] {
return ["build", ...this.#args];
}
}
// The task function: construct, configure, run. This is the shape of every
// built-in @zuke/* wrapper.
export const MyToolTasks = {
build: (configure?: Configure<MyToolSettings>) =>
runSettings(new MyToolSettings(), configure),
};
// In a target:
await MyToolTasks.build((s) => s.fast().cwd("app"));
// → runs: mytool build --fast (in ./app)
Each subcommand is its own settings class, and each task function follows the
construct-configure-run shape:
runSettings(new S(), configure) applies the optional
Configure<S> lambda — a (settings: S) => S
— then calls .run(), which resolves to a CommandOutput
(the same result the $ shell returns).
buildArgs() must stay pure — no I/O, no
environment reads — so argv construction is unit-testable and deterministic.
The base assembles argv as an array end-to-end and runs it through
Zuke's Command: there is no shell string and no injection
surface.
// buildArgs() is pure, so the full argv is unit-testable without spawning:
const s = new MyToolSettings().fast().args("--verbose");
assertEquals(s.argv(), ["mytool", "build", "--fast", "--verbose"]); The ToolSettings contract
You override the two abstract methods; the base gives every wrapper the same inherited chainers for free, so a custom wrapper behaves exactly like a built-in one.
ToolSettings member | Effect |
|---|---|
defaultTool() | the binary to spawn — abstract, you implement |
buildArgs() | the subcommand argv — abstract, you implement; must be pure |
defaultResolution() | override to return "node_modules" for a JS-ecosystem tool (default "path") |
.env(record) | merge extra environment variables for the process |
.cwd(path) | working directory for the process |
.noThrow() | don't throw on a non-zero exit; inspect code on the output instead |
.quiet() | suppress live stdout/stderr streaming to the terminal |
.killAfter(ms) | kill the tool after ms, raising a CommandTimeoutError |
.toolPath(path) | override the binary (an explicit path always wins) |
.fromNodeModules() / .fromPath() | per-call binary resolution: node_modules/.bin walk vs. bare PATH |
.args(...extra) | escape hatch: append raw arguments after all typed options |
.argv() | the full argv (binary first), pure — for tests and diagnostics |
.resolvedArgv() | like argv() but with node_modules/.bin resolution applied (does I/O) |
A wrapper package is a workspace sibling that depends only on
@zuke/core — the existing @zuke/* wrappers are the
template. See Installing tools for how
fromNodeModules() and toolPath() line up with a
provisioned binary.
defineTool — no class needed
For a one-off CLI that doesn't warrant a dedicated wrapper package,
defineTool returns a ready-to-run task with no class. It uses the
same settings-lambda style — a DynamicToolSettings with
arg / flag / option for argv (applied in
call order) plus all the shared chainers above.
import { defineTool } from "jsr:@zuke/core/tooling";
const terraform = defineTool("terraform");
await terraform((s) => s.arg("plan").option("out", "plan.tfplan"));
// → terraform plan --out plan.tfplan
const helmUpgrade = defineTool("helm", { subcommand: "upgrade" });
await helmUpgrade((s) => s.arg("api", "./chart").flag("install"));
// → helm upgrade api ./chart --install
An optional subcommand (a string or string array) is prepended to
every invocation. Reach for a full ToolSettings subclass only when
you want named, typed methods (.fast()) instead of raw
.arg() tokens, or when you're distributing the wrapper.
Plugins: observing the lifecycle
A plugin is a plain object implementing any of the lifecycle
hooks. Register one — or several — by passing them to run (or
execute) via { plugins: [...] }; each hook runs
alongside the build's own lifecycle method, in registration order. Plugins
observe — they report, time, or notify — they don't change
the plan or a target's result.
import { type Plugin, run } from "jsr:@zuke/core";
const timing: Plugin = {
name: "timing",
onTargetEnd: (target, status, { runId, durationMs }) =>
console.log(`${runId} ${target}: ${status} in ${durationMs}ms`),
onFinish: (result) => console.log(`build ${result.ok ? "ok" : "failed"}`),
};
await run(MyBuild, { plugins: [timing] }); Every hook is optional and may be async — the executor awaits each before continuing. Because a plugin is strictly an observer, a hook that throws is caught, reported, and swallowed; it never breaks the run. (The build's own overridden lifecycle methods, by contrast, are the build's logic and still propagate.)
The extra hook arguments are additive: a plugin written against the old two-argument signatures keeps working unchanged, since a function that ignores its trailing arguments is still assignable to the enriched type.
// The old two-argument shape still type-checks and still runs — a function
// that ignores its trailing arguments is assignable to the enriched signature.
const legacy: Plugin = {
onTargetEnd: (target, status) => console.log(`${target}: ${status}`),
}; The plugin hooks
Every hook receives context beyond the bare names. run is a
RunInfo ({ runId, dryRun }) whose
runId is stable across a suspend/resume boundary,
so an exporter can group a run's events under one id; timing is a
TargetTiming ({ runId, durationMs }).
| Hook | When it runs |
|---|---|
onStart(run) | Once, before any target runs. |
onTargetStart(target, run) | Just before a target's body executes — not for skipped or cached targets. |
onTargetEnd(target, status, timing) | After each target settles, with its TargetStatus and TargetTiming (durationMs is 0 for skipped/cached). |
onFinish(result, run) | Once, after the run completes — success or failure; the outcome is result.ok. |
onRunStateChange(record) | On each run-level durable status change (running, suspended, succeeded, failed, cancelling, cancelled). |
onRunStateChange only fires when a
state store is configured — a plain build
with no store never produces a RunRecord, and the hook stays
silent. The record it receives is the secret-free projection:
secret() parameters are omitted and errors, state metadata, and
audit arguments are run through the redactor first, so it is safe to export.
It carries per-target timings, waits, and the audit trail in one payload —
which is why a metrics exporter subscribes here rather than to the per-target
hooks.
Packaging a plugin
A plugin is just a value, so a package can export a factory that returns one — configured however it likes:
// @acme/zuke-slack — a package exports a factory that returns a Plugin.
import type { Plugin } from "jsr:@zuke/core";
export function slack(opts: { webhook: string }): Plugin {
return {
name: "slack",
onFinish: async (result) => {
if (!result.ok) await notify(opts.webhook, "build failed");
},
};
}
// Consumer's build file — register one or several plugins at once:
await run(MyBuild, { plugins: [slack({ webhook: Deno.env.get("SLACK_URL")! })] }); @zuke/otel is the full worked example of this
pattern: a factory returning a Plugin that turns
onRunStateChange records into OpenTelemetry spans and counters —
including trace continuity across a suspend/resume — with no runtime
dependencies. For the third extension seam, reusable bundles of targets, see
reusable components in Orchestration.