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 memberEffect
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 }).

HookWhen 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.