Programmatic API

A zuke.ts is usually driven by the CLI. But @zuke/core exports the same building blocks the CLI uses, so you can drive a build yourself — run a target from another program, assert on the result in a test, or introspect a build's command surface without shelling out and parsing --help. Every name below is a real export of mod.ts; the CLI is just a thin layer over them.

The building blocks

run is the top of the stack — the one call a build file makes. Underneath it, execute runs a resolved plan, and a family of pure functions inspect the dependency graph:

import {
  discoverTargets, // (build) => Map<string, TargetBuilder>
  execute, // (build, rootTarget, options?) => Promise<BuildResult>
  executionSet, // (rootTarget) => Set<TargetBuilder>
  findCycle, // (targets) => string[] | null
  GraphError,
  plan, // (rootTarget) => TargetBuilder[]  (topological order)
  validateGraph, // (targets) => void  (throws GraphError)
} from "jsr:@zuke/core";

run — the entry point

run(BuildClass, options?) is what you call at the bottom of a build file. It instantiates the build, parses Deno.args, runs the requested command, and sets the process exit code. It returns Promise<void> and calls Deno.exit itself, so it's the launcher, not the seam you drive a build through in code — for that, reach for execute.

import { run } from "jsr:@zuke/core";
import { consoleRenderer } from "jsr:@zuke/console";

class MyBuild extends Build { /* … */ }

// The same entry point the ./zuke launcher uses, with a plugin and a renderer.
await run(MyBuild, {
  plugins: [timingPlugin],
  renderer: consoleRenderer,
});

run acts only when its module is the program's entry point; imported elsewhere (for example under test) it does nothing, so no import.meta.main guard is needed. Its RunOptions is deliberately small — { args?, plugins?, renderer? }. The richer knobs (cache, parallel, remoteCache, state, …) live on ExecuteOptions, because on the CLI they come from flags; drive execute directly to set them in code.

execute — run a plan in code

execute(build, rootTarget, options?) is the workhorse. It resolves the build's parameters, plans the graph from rootTarget, runs each target body in order (or concurrently with parallel), and resolves to a BuildResult — it never calls Deno.exit, which is exactly what makes it testable. The rootTarget is a TargetBuilder, which you get from discoverTargets.

import { discoverTargets, execute } from "jsr:@zuke/core";
import { MyBuild } from "./zuke.ts";

const build = new MyBuild();
const target = discoverTargets(build).get("test");
if (target) {
  const result = await execute(build, target, { parallel: true, cache: false });
  console.log(result.ok ? "green" : "red");
}

The result carries everything the caller needs to react to the run:

interface BuildResult {
  ok: boolean; // every executed target passed (also true for a suspended run)
  executed: string[]; // target names, in execution order
  error?: unknown; // the error that aborted the run, if any
  suspended?: boolean; // parked at a .waitsFor(...) gate; state was saved
  cancelled?: boolean; // stopped via signal / `zuke cancel`; ok is false
  runId?: string; // this run's id, for `zuke runs show` / `zuke cancel`
}

ok is true when every executed target passed — including a run that suspended at a .waitsFor(...) gate (the process would exit 0). A cancelled run — via options.signal / Ctrl-C, or another process running zuke cancel — is ok: false with cancelled: true, after its compensations have run.

execute options

ExecuteOptions mirrors the CLI flags, plus a few seams that exist only for embedding and testing. Every field is optional.

ExecuteOptionsTypeEffect
silentbooleanSuppress all banner/summary output.
reporterReporterCustom output sink ({ info(line), error(line) }); overrides silent.
rendererRendererRestyle the per-target banners and summary (see below).
pluginsPlugin[]Lifecycle observers invoked alongside the build's hooks.
skipstring[]Target names to skip even if planned (--skip).
parallelboolean | numberRun independent targets concurrently; a number caps concurrency, true uses the CPU count (--parallel).
cacheboolean | BuildCacheIncremental caching; false disables it (--no-cache). A BuildCache is used directly.
remoteCacheRemoteCacheStore | falseShare outputs across machines; false uses the local cache only (--no-remote-cache). Ignored when cache is false or a supplied BuildCache.
paramsRecord<string, string>Raw parameter values, keyed by property name.
dryRunbooleanPrint the plan without running any body or touching the cache (--dry-run).
affectedAffectedOptionsRestrict the run to targets affected by files changed since a git base (--affected).
statebooleanPersist durable run state under .zuke/runs when nothing else configures a store (--state).
stateStoreStateStore | falseSupply the durable state store directly; false disables state entirely.
actorstringWho to attribute the run to in its state record (--actor).
signalAbortSignalCancel the run when this aborts, running the succeeded targets' compensations.
readEnv(name) => string | undefinedEnvironment reader for parameter fallback; defaults to Deno.env.get (a test seam).
prompt(flag, desc) => string | undefinedPrompt for a missing required parameter; defaults to an interactive TTY prompt off CI (a test seam).
githubbooleanForce GitHub Actions output formatting on/off (auto-detected otherwise).
colorbooleanForce ANSI colour on/off (auto-detected otherwise).

One field is intentionally absent above: resume. It carries the continuation state of a suspended run and is set by resumeRun, not passed by hand — call resumeRun to continue a suspended run rather than constructing it yourself.

discoverTargets — find targets

discoverTargets(build) introspects a build instance and returns a Map<string, TargetBuilder> of every declared target, keyed by its property name. Discovery recurses into plain object fields, so a reusable component contributes its targets under a dotted path (release.publish). This is how you turn a target name into the rootTarget that execute needs.

import { discoverGroups, discoverTargets } from "jsr:@zuke/core";

const build = new MyBuild();
const targets = discoverTargets(build); // Map<string, TargetBuilder>

targets.get("test"); // a top-level target
targets.get("release.publish"); // a component target, under its dotted path

// discoverGroups(build) does the same for declared parallel groups.

describeCli — inspect the surface

describeCli(build) returns a structured CliDescription of a build's whole command surface — its reserved commands, option flags, targets (with descriptions and dependencies), and parameters — the same data that backs zuke --list --json. Use it to build tooling around a build in code, with no --help text to parse.

import { describeCli } from "jsr:@zuke/core";

const cli = describeCli(new MyBuild());
for (const t of cli.targets) console.log(t.name, "", t.dependsOn.join(", "));

// Drop .secret() parameters from the surface (what `zuke register` writes):
const safe = describeCli(new MyBuild(), { omitSecrets: true });

Each CliTargetInfo carries name, description, dependsOn, default, and unlisted; each CliParameterInfo distinguishes the property name (the key execute's params map and MCP tool calls use) from its kebab-case flag. Pass { omitSecrets: true } to drop .secret() parameters — the posture the build registry uses so a secret never crosses the run boundary.

Renderer — custom output

The per-target banners and the end-of-build summary are produced by a Renderer. Each method is pure — it returns the lines to print rather than writing them — so the executor keeps control of the output streams and a custom renderer stays unit-testable. Zuke ships defaultRenderer; spread it and override only the hooks you care about, or inject consoleRenderer from @zuke/console for an alternative look.

import { defaultRenderer, execute, type Renderer } from "jsr:@zuke/core";

const quiet: Renderer = {
  ...defaultRenderer,
  // Override only the hooks you want to change…
  targetHeader: () => [],
};
await execute(build, target, { renderer: quiet });
Renderer methodProduces
targetHeader(style, name)the banner that opens a target's section
targetPassFooter(style, name, ms)the footer after a target body succeeds
targetFailFooter(style, name, ms, error)the failure footer, split into { info, error } lines
targetDryRunFooter(style, name)the footer for a dry-run target that never executed
summaryBlock(style, reports, totalMs, ok)the end-of-build summary table and verdict
jobSummaryMarkdown(reports, totalMs, ok)the GitHub Actions job-summary Markdown

A renderer receives each TargetReport and the Style palette, so custom rendering doesn't have to reimplement colour handling.

BuildCache — inject a cache

The cache option normally takes a boolean, but it also accepts a BuildCache instance directly. This is mainly a test seam: supply a pre-seeded or isolated cache so a run's cache behaviour is deterministic without touching .zuke/cache.json.

import { execute, type BuildCache } from "jsr:@zuke/core";

// A minimal in-memory cache — never touches .zuke/cache.json.
const cache: BuildCache = {
  upToDate: async () => false,
  record: async () => {},
  save: async () => {},
};
await execute(build, target, { cache });

The interface is three methods — upToDate(target) (is a target's fingerprint unchanged and its outputs still present?), record(target) (store the fingerprint after a successful run), and save() (persist the store) — so a custom in-memory implementation is a few lines when you need one.

Graph inspection

The planning primitives are exported as pure functions, so you can reason about a build's graph without running it:

FunctionReturns
plan(root)TargetBuilder[] — the deduplicated topological order a run would execute
executionSet(root)Set<TargetBuilder>root plus the transitive closure of its dependencies
findCycle(targets)string[] | null — the names forming a dependency cycle, or null
validateGraph(targets)void — throws GraphError on a missing reference or a cycle
import {
  discoverTargets,
  GraphError,
  plan,
  validateGraph,
} from "jsr:@zuke/core";

const build = new MyBuild();
const targets = discoverTargets(build);

try {
  validateGraph(targets); // throws GraphError on a missing ref or a cycle
} catch (error) {
  if (error instanceof GraphError) console.error(error.message);
}

// The topological order a run would execute, without running anything:
const order = plan(targets.get("release"));
console.log(`${order.length} targets, in execution order`);

See Using Zuke as a library for the file, HTTP, and install helpers a build shares with these, and Extending Zuke for the Plugin lifecycle that run and execute both accept.