Examples

Real builds, start to finish.

Each example is a complete zuke.ts you can drop into a project and run with ./zuke <target>. They show targets, typed dependencies, tool wrappers, and the $ shell working together.

Node / Astro

Static site build

Drive an npm-based static site through Zuke — this very website is built exactly like this.

zuke.ts
import { Build, run, target } from "jsr:@zuke/core";
import { $ } from "jsr:@zuke/core/shell";
import { NpmTasks } from "jsr:@zuke/npm";

class SiteBuild extends Build {
  clean = target()
    .description("Remove previous build output")
    .executes(async () => { await $`rm -rf dist`.noThrow(); });

  install = target()
    .description("Clean-install from the lockfile")
    .executes(async () => { await NpmTasks.ci(); });

  check = target()
    .description("Type-check the Astro project")
    .dependsOn(this.install)
    .executes(async () => { await NpmTasks.run((s) => s.script("check")); });

  build = target()
    .description("Build the static site into dist/")
    .dependsOn(this.clean, this.check)
    .executes(async () => { await NpmTasks.run((s) => s.script("build")); });
}

await run(SiteBuild);
Node / monorepo

Drive an npm workspace — no npx, no global installs

Zuke drops into a Node monorepo without a rewrite. The JS-ecosystem wrappers resolve node_modules/.bin npx-style — walking up to the hoisted binary, falling back to PATH — so a workspace package needs no .toolPath(). NpmTasks.run().workspaces() runs one script across every workspace (.ifPresent() skips those that don't define it). And toolchain().npm() provisions a version-pinned CLI straight from the npm registry — cached, no ambient npm ci — so the tool that isn't installed locally is still hermetic. Set ZUKE_TOOL_RESOLUTION=node_modules to flip every wrapper repo-wide.

zuke.ts
import { Build, run, target, toolchain } from "jsr:@zuke/core";
import { NpmTasks } from "jsr:@zuke/npm";
import { OxlintTasks } from "jsr:@zuke/oxlint";
import { VitestTasks } from "jsr:@zuke/vitest";

class Monorepo extends Build {
  // A CLI that ships on npm, not as a release binary — provisioned and
  // pinned under .zuke/tools, cached by a marker file. No ambient 'npm ci'.
  tools = toolchain((t) => t.npm({ name: "vitest", version: "4.1.9" }));

  install = target()
    .description("Clean-install the workspace from the lockfile")
    .executes(() => NpmTasks.ci());

  // JS wrappers resolve node_modules/.bin first — no .toolPath() needed when
  // the binary is hoisted to the repo root. PATH is the fallback on a miss.
  lint = target()
    .description("Lint with the workspace's hoisted oxlint")
    .dependsOn(this.install)
    .executes(() => OxlintTasks.lint());

  // One script across every workspace; .ifPresent() skips packages missing it.
  build = target()
    .description("Build every workspace package")
    .dependsOn(this.install)
    .executes(() =>
      NpmTasks.run((s) => s.script("build").workspaces().ifPresent())
    );

  // An explicit .toolPath() from the toolchain always wins over node_modules
  // resolution — the pin stays hermetic.
  test = target()
    .description("Test with the pinned, provisioned vitest")
    .dependsOn(this.build)
    .executes(async () => {
      const bins = await this.tools.install();
      await VitestTasks.run((s) => s.toolPath(bins.get("vitest")));
    });
}

await run(Monorepo);
Deno library

CI gate for a Deno package

Format, lint, type-check, and test with a coverage gate — the kind of pipeline you'd run on every pull request.

zuke.ts
import { Build, run, target } from "jsr:@zuke/core";
import { DenoTasks } from "jsr:@zuke/deno";

class CiBuild extends Build {
  format = target()
    .description("Verify formatting")
    .executes(async () => { await DenoTasks.fmt((s) => s.check()); });

  lint = target()
    .description("Lint sources")
    .executes(async () => { await DenoTasks.lint(); });

  typecheck = target()
    .description("Type-check the public entrypoint")
    .executes(async () => { await DenoTasks.check((s) => s.paths("mod.ts")); });

  test = target()
    .description("Run tests with coverage")
    .dependsOn(this.typecheck)
    .executes(async () => { await DenoTasks.test((s) => s.coverage("cov")); });

  // 'ci' fans out to everything; Zuke runs each prerequisite once.
  ci = target()
    .description("Full CI gate")
    .dependsOn(this.format, this.lint, this.test)
    .executes(() => {});
}

await run(CiBuild);
Service targets

Spin up services for end-to-end tests

Declare a long-lived process a build depends on with service() — a dev server, a database, a mock API. Zuke starts it, polls .readyWhen() (tcpReachable, or your own health probe) until it's ready, keeps it alive while dependents run, then stops it in a finally so a failed test never leaks a process. It replaces the start-in-background, sleep-and-hope, remember-to-kill shell dance. $`…`.spawn() starts a process without awaiting it and returns a handle whose .stop() sends SIGTERM.

zuke.ts
import { Build, run, service, target, tcpReachable } from "jsr:@zuke/core";
import { $ } from "jsr:@zuke/core/shell";
import { DenoTasks } from "jsr:@zuke/deno";

class E2E extends Build {
  // A database container — ready once the port accepts connections.
  db = service()
    .description("Postgres for the test suite")
    .start(() => $`docker compose up db`.spawn())   // spawn; don't await
    .readyWhen(() => tcpReachable("localhost:5432"))
    .readyTimeout(60_000);                           // give it up to 60s

  // The API under test — waits on its own health endpoint.
  api = service()
    .description("API under test")
    .dependsOn(this.db)
    .start(() => $`deno run -A server.ts`.spawn())
    .readyWhen(async () => {
      try {
        return (await fetch("http://localhost:8080/health")).ok;
      } catch {
        return false; // not up yet
      }
    });

  // Both services are started + ready before the tests run, and are torn down
  // in reverse order when the build finishes — pass or fail.
  test = target()
    .description("Run e2e tests against the running services")
    .dependsOn(this.api)
    .executes(() => DenoTasks.test((s) => s.allowAll()));
}

await run(E2E);
Incremental caching

Skip unchanged work with the build cache

Declare a target's .inputs() and .outputs() to make it incremental — Zuke fingerprints the inputs (SHA-256, directories hashed recursively), skips the target when nothing changed and every output still exists, and reports it `cached`. .cacheKey() folds in non-file values like a parameter or the toolchain version. Fingerprints live in .zuke/cache.json; pass --no-cache to rebuild everything. @zuke/ai adds a separate aiCache for review/fix responses.

zuke.ts
import { Build, parameter, run, target } from "jsr:@zuke/core";
import { DenoTasks } from "jsr:@zuke/deno";

class CachedBuild extends Build {
  mode = parameter("Build mode").default("release");

  // Incremental: fingerprint the inputs, skip when nothing changed and every
  // declared output still exists — the run reports this target as `cached`.
  bundle = target()
    .description("Bundle the app — only when sources or config change")
    .inputs("src", "deno.json")          // re-run when these change…
    .outputs("dist/app.js")              // …or when the output goes missing
    .cacheKey(() => this.mode.value)     // …or when the build mode flips
    .cacheKey(() => Deno.version.deno)   // …or the toolchain moves
    .executes(async () => {
      await DenoTasks.run((s) => s.script("build.ts"));
    });

  // No .inputs()/.cacheKey() → never cached, always runs. A cached
  // dependency still counts as satisfied, so this runs on top of a hit.
  test = target()
    .description("Always run the test suite")
    .dependsOn(this.bundle)
    .executes(async () => { await DenoTasks.test((s) => s.allowAll()); });
}

await run(CachedBuild);
Distributed builds

Remote cache & CI fan-out

Scale the cache past one machine. A remoteCache() override shares a target's built outputs across machines — a local miss restores them from the store instead of rebuilding, and a successful run uploads them for the next machine (reported cached, just like a local hit). Two backends ship: a mounted directory (FileSystemCacheStore) or any HTTP object store like S3/GCS/R2 (HttpCacheStore) — or set ZUKE_REMOTE_CACHE_* in CI with no code change. cicd({ fanOut: true }) then emits one CI job per target, wired by dependsOn so independent targets run in parallel and share work through the store. Narrow a run to what changed with `zuke ci --affected=origin/main`.

zuke.ts
import { Build, HttpCacheStore, cicd, parameter, run, target } from "jsr:@zuke/core";
import { DenoTasks } from "jsr:@zuke/deno";

class CI extends Build {
  cacheToken = parameter("Cache auth token").secret().env("CACHE_TOKEN");

  // Share built outputs across machines. On a local miss Zuke restores from the
  // store; after a successful run it uploads for the next machine or CI job.
  override remoteCache() {
    return new HttpCacheStore({
      url: "https://cache.example.com",   // an S3/GCS/R2 bucket or a cache server
      token: this.cacheToken.value,       // trusted config — from a secret param
    });
  }

  lint = target()
    .description("Lint")
    .inputs("src").outputs("dist/lint")   // both declared → shareable outputs
    .executes(async () => { await DenoTasks.lint(); });

  build = target()
    .description("Type-check & bundle")
    .dependsOn(this.lint)
    .inputs("src", "deno.json").outputs("dist")
    .executes(async () => { await DenoTasks.run((s) => s.script("build.ts")); });

  // One CI job per target, wired by dependsOn — independent jobs run in
  // parallel and reuse each other's outputs through the remote cache above.
  //   Run only what a change reaches:  ./zuke build --affected=origin/main
  pipeline = cicd({ provider: "github", fanOut: true });
}

await run(CI);
Orchestration / CD

A durable deploy → wait → promote state machine

A cross-run CD pipeline that survives the process exiting between steps. deployToSit takes a cross-run lock (one deploy of a repo at a time), records what it did in ctx.state, and registers a rollback. awaitTesting suspends the run on an external signal — the process exits 0 and the run parks in the state store. Days later, zuke resume <id> --signal testing-approved continues in a fresh process and promoteToProd reads the approval payload. zuke cancel runs the compensation in reverse order; a state store makes it all durable and inspectable with zuke runs show <id>.

zuke.ts
import { Build, externalSignal, parameter, run, target } from "jsr:@zuke/core";

class Deploy extends Build {
  repo = parameter("service to deploy").required();

  // 1. Deploy under a cross-run lock — only one deploy of this repo at a time.
  //    The lock auto-renews while the body runs and releases in a finally; a
  //    killed process frees it after the TTL.
  deployToSit = target()
    .lock((s) =>
      s.lockKey("deploy", this.repo.value).withTtl("2h")
        .onConflict((h) =>
          `${this.repo.value} is being deployed by ${h.actor} (run ${h.runId}).`))
    .executes(async (ctx) => {
      await applyToSit(this.repo.value);
      await ctx.state.set({ slot: "sit-7" });   // survives the suspend below
    })
    .onCancel(() => this.rollback);              // undo iff this target succeeded

  // 2. Suspend until a human approves. The process exits 0 and the run parks in
  //    the store. Resume later:
  //    zuke resume <run-id> --signal testing-approved --data '{"by":"qa"}'
  awaitTesting = target()
    .dependsOn(this.deployToSit)
    .waitsFor((s) =>
      s.on(externalSignal("testing-approved"))
        .timeout("72h")
        .onTimeout(() => this.rollback));

  // 3. Promote — runs in the resuming process, reading the approval payload.
  promoteToProd = target()
    .dependsOn(this.awaitTesting)
    .executes((ctx) => {
      const approval = ctx.signals.get("testing-approved");
      promote(this.repo.value, approval?.data);
    });

  // Compensation: reads the slot deployToSit persisted, tears it down on cancel.
  rollback = target().executes((ctx) => tearDownSit(ctx.state.get().slot));
}

await run(Deploy);
Fan-out

Deploy a batch of repos with per-item isolation

Run the same ordered pipeline over a runtime list with .forEach() — items concurrent (up to concurrency), stages sequential per item. One repo failing doesn't stop the others with continueOnItemFailure(); the batch fails at the end if any item did. Every item is first-class: sub-targets named deployBatch[api].deploy get their own summary row and run-record entry, so zuke runs show and MCP report a verdict per repo. The list is an .array() parameter, validated element-by-element before anything runs.

zuke.ts
import { Build, parameter, run, target } from "jsr:@zuke/core";

class CD extends Build {
  // A validated list — each element checked before any target runs.
  repos = parameter("services to deploy").array().required();

  deployBatch = target().forEach(
    () => this.repos.value,                    // items thunk (string[])
    (repo) => ({                               // ordered per-item pipeline
      checks: target().executes(() => checkDeployable(repo)),
      fork:   target().executes(() => forkImage(repo)),
      deploy: target().executes((ctx) => applyToSit(repo, ctx)),
    }),
    (s) => s.concurrency(3).continueOnItemFailure(),
  );
}

await run(CD);
Scheduled CI

A timezone-aware scheduled pipeline

Declare the schedule in code, in local time. GitHub Actions only understands UTC cron, so Zuke compiles { cron, tz } into the right UTC cron(s) — and for a daylight-saving zone, generates a wall-clock guard step so the job fires at the intended local time year-round, across the DST switch. No hand-maintained UTC math, no external Cloud Scheduler. zuke generate-ci --check keeps the committed workflow in sync.

zuke.ts
import { Build, cicd, run, target } from "jsr:@zuke/core";
import { DenoTasks } from "jsr:@zuke/deno";

class Nightly extends Build {
  test = target()
    .description("Full test suite")
    .executes(async () => { await DenoTasks.test((s) => s.allowAll()); });

  // Weekday mornings and afternoons, Europe/Sofia local time. Zuke emits the two
  // UTC crons the zone needs across DST plus a guard so only the correct one fires.
  ci = cicd({
    provider: "github",
    pipeline: {
      triggers: {
        push: ["main"],
        schedule: [{ cron: "30 9,13,15 * * 1-4", tz: "Europe/Sofia" }],
      },
    },
  });
}

await run(Nightly);
Observability

Export runs to OpenTelemetry

One plugin turns every run into OpenTelemetry traces and metrics over OTLP/HTTP, into your existing Grafana/Tempo stack — @zuke/otel is dependency-free and hand-rolls the OTLP JSON so core stays OTel-free. You get a run span with a child span per target, plus counters for runs started, suspended, and settled (succeeded/failed/cancelled). A run that suspends and resumes days later in another process still lands as one trace — the trace id derives from the run id — with the wait visible as the gap. Requires a state store; otel() with no endpoint is inert, so it's safe to leave in everywhere.

zuke.ts
import { run } from "jsr:@zuke/core";
import { otel } from "jsr:@zuke/otel";
import { MyBuild } from "./build.ts";

await run(MyBuild, {
  plugins: [
    otel((s) =>
      s.endpoint("http://localhost:4318")   // or OTEL_EXPORTER_OTLP_ENDPOINT
        .serviceName("payments-cd")
        .header("authorization", "Bearer …")
    ),
  ],
});

// Config can come entirely from OTEL_* env — otel() with no endpoint is inert,
// so the same registration is safe to ship everywhere:
//   await run(MyBuild, { plugins: [otel()] });
Build registry

Drive agents from a build registry

Record a build in a shared registry so a registry-backed MCP server can discover and run it — no redeploy, no new instructions. zuke register writes a secret-free BuildDescriptor (its targets, parameters, and launch location); zuke mcp --registry serves every registered pipeline as tools, re-reading the catalog on each call so a build registered later — even by another repo's CI — appears without restarting the server. Execution stays behind the same allow-list, operator-token, and audit-log gates as the single-build server.

zuke.ts
// Record this build (writes .zuke/builds/<id>.json, or your HTTP registry):
//   deno run -A zuke.ts register
//
// Serve every registered pipeline; new registrations appear with no restart:
//   zuke mcp --registry --allow-run --http 0.0.0.0:7777
//
// Point builds at a shared HTTP registry so the catalog spans machines:
import { Build, HttpBuildRegistry, parameter, run, target } from "jsr:@zuke/core";

class Api extends Build {
  registryUrl = parameter("registry URL").required();
  registryToken = parameter("registry token").secret();

  override registry() {
    return new HttpBuildRegistry({
      url: this.registryUrl.value,
      token: this.registryToken.value,
    });
  }

  deploy = target().executes(() => {/* … */});
}

await run(Api);
Coverage / Codecov

Upload coverage to Codecov

Run tests with coverage, turn the profile into an lcov report, and ship it to Codecov with @zuke/codecov. CodecovTasks wraps codecovcli upload-process — the token is read from CODECOV_TOKEN, so it never lands in argv.

zuke.ts
import { Build, run, target } from "jsr:@zuke/core";
import { $ } from "jsr:@zuke/core/shell";
import { DenoTasks } from "jsr:@zuke/deno";
import { CodecovTasks } from "jsr:@zuke/codecov";

class CoverageBuild extends Build {
  test = target()
    .description("Run tests and collect a coverage profile")
    .executes(async () => {
      await DenoTasks.test((s) => s.coverage("cov"));
    });

  report = target()
    .description("Turn the profile into an lcov report")
    .dependsOn(this.test)
    .executes(async () => {
      await $`deno coverage cov --lcov --output=cov.lcov`;
    });

  upload = target()
    .description("Upload the report to Codecov")
    .dependsOn(this.report)
    .executes(async () => {
      // CODECOV_TOKEN is read from the environment, never passed in argv.
      await CodecovTasks.upload((s) =>
        s.files("cov.lcov")        // upload this named report…
          .disableSearch()         // …and don't scan for others
          .flags("unit")           // tag it so the dashboard can split suites
          .failOnError()           // a failed upload breaks the build
      );
    });
}

await run(CoverageBuild);
Docker / release

Build & publish a container

Stamp an image with the current git SHA, build it, and push to a registry — mixing tool wrappers with the $ shell.

zuke.ts
import { Build, run, target } from "jsr:@zuke/core";
import { $ } from "jsr:@zuke/core/shell";
import { DockerTasks } from "jsr:@zuke/docker";

class ReleaseBuild extends Build {
  image = target()
    .description("Build the application image")
    .executes(async () => {
      const sha = await $`git rev-parse --short HEAD`.text();
      await DockerTasks.build((s) =>
        s.tag(`registry.example.com/app:${sha}`).file("Dockerfile")
      );
    });

  publish = target()
    .description("Push the image to the registry")
    .dependsOn(this.image)
    .executes(async () => {
      const sha = await $`git rev-parse --short HEAD`.text();
      await DockerTasks.push((s) => s.image(`registry.example.com/app:${sha}`));
    });
}

await run(ReleaseBuild);
Hermetic toolchain

Provision a pinned toolchain

Declare the CLIs a build drives with toolchain() and Zuke fetches them on first use — concurrently, pinned by SHA-256, verified before anything runs, and cached under .zuke/tools. A fresh clone or a bare CI runner needs no 'install helm/kubectl first' step, and every machine runs the exact same binary. ToolTasks.install() does the same for a single tool.

zuke.ts
import { Build, run, target, toolchain } from "jsr:@zuke/core";
import { HelmTasks } from "jsr:@zuke/helm";
import { KubectlTasks } from "jsr:@zuke/kubectl";

class Deploy extends Build {
  // The CLIs this build drives. install() fetches them concurrently — pinned by
  // SHA-256, verified before use, and cached under .zuke/tools (git-ignored).
  tools = toolchain((t) =>
    t
      .tool((s) =>
        s.name("helm").archive("tar.gz").binaryPath("linux-amd64/helm")
          .checksum("f43e1c3…")   // the archive's published sha256
          .url(() => "https://get.helm.sh/helm-v3.15.2-linux-amd64.tar.gz")
      )
      .tool((s) =>
        s.name("kubectl")
          .checksum("2a9e0b1…")   // the binary's published sha256
          .url(() => "https://dl.k8s.io/release/v1.30.2/bin/linux/amd64/kubectl")
      )
  );

  deploy = target()
    .description("Install the toolchain on first use, then deploy")
    .executes(async () => {
      const bin = await this.tools.install();   // Map<name, AbsolutePath>
      await KubectlTasks.apply((s) => s.toolPath(bin.get("kubectl")).file("k8s/"));
      await HelmTasks.upgrade((s) =>
        s.toolPath(bin.get("helm")).release("api").chart("./charts/api").install().wait()
      );
    });
}

await run(Deploy);
Secrets

Source secrets from a manager — redacted everywhere

Mark a parameter .secret() and Zuke redacts its resolved value from every log, summary, and error (and emits ::add-mask:: on GitHub Actions). Add .from(execSecret(...)) or .from(fileSecret(...)) to pull the value from a secret manager — 1Password, Vault, gcloud, or a mounted file — but only when a flag or env var didn't already supply it. The same build stays portable: an env var in CI, your vault locally, no code change.

zuke.ts
import { Build, execSecret, fileSecret, parameter, run, target } from "jsr:@zuke/core";

class Release extends Build {
  // From 1Password locally; from the REGISTRY_TOKEN env var in CI (env wins).
  registryToken = parameter("Container registry token")
    .secret()
    .from(execSecret((s) => s.command("op").arg("read", "op://ci/registry/token")));

  // A cluster token mounted into the job as a file.
  clusterToken = parameter("Cluster token")
    .secret()
    .from(fileSecret((s) => s.path("/run/secrets/cluster_token")));

  publish = target()
    .description("Publish with a sourced token — never printed")
    .executes(async () => {
      // Prefer headers/env over argv so the secret never lands in a process list.
      await fetch("https://registry.example.com/publish", {
        method: "POST",
        headers: { authorization: `Bearer ${this.registryToken.value}` },
      });
    });
}

await run(Release);
Notifications

Announce status to Slack, Teams & Discord

Post build status to a chat channel with AnnounceTasks — a typed message with a level, detail fields, and an action link. The webhook URL rides through a masked secret parameter.

zuke.ts
import { AnnounceTasks, Build, parameter, run, target } from "jsr:@zuke/core";
import { DockerTasks } from "jsr:@zuke/docker";

class DeployBuild extends Build {
  // the webhook embeds a secret — source it, never hard-code it
  slack = parameter("Slack incoming-webhook URL").secret().required();

  deploy = target()
    .description("Deploy, then announce the result to Slack")
    .requires(this.slack)
    .executes(async () => {
      await DockerTasks.push((s) => s.image("registry.example.com/api:1.4.0"));

      await AnnounceTasks.slack((s) =>
        s.webhook(this.slack.value)
          .title("Deploy")
          .text("Shipped api@1.4.0 to production.")
          .success()                                   // accent colour + ✅
          .field("Service", "api")
          .field("Environment", "production")
          .link("Release notes", "https://example.com/r/1.4.0")
      );
    });

  // Same shape for the other platforms — webhook or .bot() API mode:
  //   await AnnounceTasks.teams((s) => s.webhook(url).text("…").success());
  //   await AnnounceTasks.discord((s) => s.webhook(url).text("…").failure());
}

await run(DeployBuild);
AI / review

AI code-review gate

Gate a target on an AI security review with @zuke/ai. The reviewer reads the diff, asks the model for a structured risk score, breaks the build when it crosses your threshold, and upserts the verdict as a PR comment. aiReviewWorkflow() generates the matching CI workflow from the reviewer.

zuke.ts
import { Build, parameter, run, target } from "jsr:@zuke/core";
import { DenoTasks } from "jsr:@zuke/deno";
import { aiReviewWorkflow, securityReviewer } from "jsr:@zuke/ai";

class ReviewBuild extends Build {
  key = parameter("OpenAI API key").secret().env("OPENAI_API_KEY");

  // A reviewer is a Validation — provider + key is all that's required.
  security = securityReviewer((r) =>
    r.provider("openai")
      .apiKey(this.key)
      .diff((d) => d.base("origin/main"))   // review the PR diff
      .maxDiffTokens(20_000)                 // keep cost bounded
      .failWhen((g) => g.scoreAbove(7))      // break the build above 7/10
      .skipIfKeyMissing()                    // no key (forks) → skip + announce
      .comment()                             // upsert the verdict as a PR/MR comment
  );

  test = target()
    .description("Run the test suite")
    .executes(async () => { await DenoTasks.test(); });

  deploy = target()
    .description("Deploy only after tests pass and the review clears")
    .dependsOn(this.test)
    .validateBefore(this.security)           // gate before the body runs
    .executes(async () => {/* … */});

  // Generates .github/workflows/ai-review.yml from the reviewer above —
  // pass host: "gitlab" | "azure" | "bitbucket" for the other platforms.
  reviewWorkflow = aiReviewWorkflow({ reviewers: [this.security] });
}

await run(ReviewBuild);
AI / self-healing

Self-healing build

Attach an aiFixer to a target with .recoverWith(). When it fails, @zuke/ai diagnoses the failure from the command, its stderr, and the diff — then posts a committable suggestion, or applies the fix, commits, and re-runs the real command to verify it goes green. agentFixer can instead hand the failure to a coding agent that edits files itself.

zuke.ts
import { Build, parameter, run, target } from "jsr:@zuke/core";
import { DenoTasks } from "jsr:@zuke/deno";
import { agentFixer, aiFixer } from "jsr:@zuke/ai";
import { ClaudeTasks } from "jsr:@zuke/claude";

class CiBuild extends Build {
  key = parameter("OpenAI API key").secret().env("OPENAI_API_KEY");

  test = target()
    .description("Run tests; on failure, let @zuke/ai heal the build")
    .executes(async () => { await DenoTasks.test((s) => s.allowAll()); })
    .recoverWith(
      aiFixer((f) =>
        f.provider("openai").apiKey(this.key)
          .diff((d) => d.fetchBase())   // fetch the PR base for context
          .autoApply()                  // write the fix to the working tree
          .allowPaths("src/**")         // …inside an allowlist
          .maxEdits(5)                  // …and a file cap
          .allowCI()                    // opt in to acting on CI
          .commitFixes()                // stage, commit & push to the PR branch
      ),
    )
    .recoverAttempts(2);                // up to two fix-then-rerun cycles

  // Diagnose-only (the default): provider + key, no files touched — it posts a
  // committable, Copilot-style suggestion to the PR.
  lint = target()
    .description("Lint; on failure, suggest a fix on the PR")
    .executes(async () => { await DenoTasks.lint(); })
    .recoverWith(aiFixer((f) => f.provider("openai").apiKey(this.key)));

  // For open-ended fixes, hand the failure to a coding agent you inject —
  // Claude Code, Codex, or Gemini CLI — which edits files itself, then verifies.
  build = target()
    .description("Build; on failure, delegate to a coding agent")
    .dependsOn(this.test)
    .executes(async () => { await DenoTasks.run((s) => s.script("build")); })
    .recoverWith(
      agentFixer((ctx) =>
        ClaudeTasks.run((s) =>
          s.prompt(ctx.prompt).permissionMode("acceptEdits")
        )
      ),
    );
}

await run(CiBuild);
CI/CD

Generate your CI pipeline

Declare the pipeline in the build with cicd(). Zuke writes the provider YAML and verifies it stays in sync — no hand-edited workflow files.

zuke.ts
import { Build, cicd, run, target } from "jsr:@zuke/core";
import { DenoTasks } from "jsr:@zuke/deno";

class CiBuild extends Build {
  // → generates .github/workflows on `zuke generate-ci`
  pipeline = cicd({
    provider: "github",                    // or "gitlab" | "azure"
    pipeline: {
      jobs: [{
        matrix: { os: ["ubuntu-latest", "macos-latest"] },
        steps: [
          { uses: "denoland/setup-deno@v2" },
          { name: "Gate", run: "./zuke ci" },
        ],
      }],
    },
  });

  ci = target()
    .description("Format, lint, type-check, and test")
    .executes(async () => {
      await DenoTasks.fmt((s) => s.check());
      await DenoTasks.lint();
      await DenoTasks.test((s) => s.coverage("cov"));
    });
}

await run(CiBuild);
Visualize

Generate an HTML dependency graph

Run zuke graph --output=html to render your build's dependency graph as a self-contained, interactive page in .zuke/graph.html and open it — pan, zoom, and click a target to highlight everything it connects to. Below is the graph of Zuke's own build, with nodes coloured by dependency depth (roots in teal through to leaves in pink).

.zuke/graph.html
Interactive HTML dependency graph of Zuke's own build, generated by `zuke graph --output=html` — 15 targets coloured by dependency depth.

Make it your own

Scaffold a build and start from a working template.