Parameters
Parameters are typed inputs to a build. You declare them as
class fields — exactly like targets — and
read the resolved value inside a target with this.<name>.value.
The execution engine resolves every parameter before any target runs,
so a build never starts with a missing or malformed input. The
Concepts page introduces them in brief;
this is the full reference.
import { Build, parameter, run, target } from "jsr:@zuke/core";
class Deploy extends Build {
environment = parameter("Target environment")
.options("dev", "staging", "production")
.required();
workers = parameter("Parallel upload workers").number().default(4);
dryRun = parameter("Print actions without performing them").boolean();
deploy = target().executes(() => {
if (this.dryRun.value) console.log("(dry run)");
console.log(
`Deploying to ${this.environment.value} with ${this.workers.value} workers`,
);
});
}
await run(Deploy); Invoke it with flags:
./zuke deploy --environment production --workers 8 --dry-run Or supply a value from the environment:
ENVIRONMENT=staging ./zuke deploy
Each fluent method returns a new parameter whose
value type reflects the configuration — the builder narrows the
type as you go, so value is exactly as strong as the declaration.
Declaring a parameter
parameter(description?) starts an optional string
parameter. The optional description is shown in --help and
--list. From there, configure it fluently:
| Method | Effect | value type |
|---|---|---|
parameter("…") | optional string | string | undefined |
.number() | parse as a number | number | undefined |
.boolean() | a flag; defaults to false | boolean |
.options("a", "b") | restrict a string to choices | unchanged |
.default(v) | provide a default | non-optional (T) |
.required() | must be supplied | non-optional (T) |
.env("NAME") | override the env var name | unchanged |
.secret() | mark sensitive (masked everywhere) | unchanged |
.from(source) | resolve from a secret manager | unchanged |
.array() | a comma-separated / repeatable list | T[] |
Ordering matters: .number() and .boolean() come
first (they change the kind and reset the default), .options()
applies to strings, .required()/.default() set
optionality, and .array() comes last so it
composes with everything before it — so a required list is
.required().array(), in that order.
There is no .choices(...) — the method that restricts a string
to a fixed set is .options(...). Three field names are also
reserved: dryRun, confirm, and
operatorToken collide with control keys Zuke's
MCP server injects, so a parameter using one is
rejected at discovery. Rename it.
Flags and environment variables
The command-line flag and environment variable are derived from the
property name: the flag is kebab-case, the env var is
SCREAMING_SNAKE_CASE. A camelCase field like targetEnv becomes
--target-env and TARGET_ENV. Override just the
environment variable with .env("NAME") — that half is derived
separately and is unaffected by a declared flag.
retries = parameter().number(); // --retries / RETRIES
targetEnv = parameter(); // --target-env / TARGET_ENV
apiKey = parameter().env("CI_API_KEY"); // --api-key / CI_API_KEY
A flag accepts both --environment production and
--environment=production. A boolean is set simply by naming the
flag (--dry-run).
The derivation rule is one dash (or underscore) at each lower-to-upper transition. A run of capitals has no internal transition and so stays together — but a digit ends a run, which is where the rule surprises people:
| Property name | Flag | Environment variable |
|---|---|---|
apiURL | --api-url | API_URL |
useHTTPS | --use-https | USE_HTTPS |
skipE2E | --skip-e2-e | SKIP_E2_E |
skipE2E gives --skip-e2-e because the
2 ends the run of capitals, making 2E a transition
of its own. Two ways out, and the first is often enough: name it so
the rule agrees with you (skipE2e →
--skip-e2e), or declare the flag with
.flag("--skip-e2e"), where the leading dashes are optional.
class CI extends Build {
skipE2E = parameter("skip the E2E suite").flag("--skip-e2e").boolean();
}
A declared flag replaces the derived one: only it is
accepted on the command line, and it is what --help,
--list --json, shell completions and a
registered build's descriptor all show. It must
be lowercase letters, digits and dashes starting with a letter, it may not be
a built-in flag, and no two parameters may claim the same one — each is a
ParameterError when the build loads, naming the field.
Names a parameter may not use
Two sets of names are refused when the build is loaded, with a
ParameterError naming the field:
- A name that renders as a built-in CLI flag —
actor→--actor,actorKind→--actor-kind,limit→--limit, and so on for every flagzuke --helplists. The parser matches a built-in first, so the flag means the built-in and not the parameter:--actor=aliceattributes the run and leaves the parameter unresolved. Such a parameter is not wholly dead — its environment variable still resolves it, as does an MCPrun:<target>call — and that is exactly why it is refused rather than merely documented. It works until someone reaches for its flag, and then the value quietly does something else. -
dryRun,confirmandoperatorToken— the control keys an MCPrun:<target>tool adds to its input schema alongside the build's parameters, which would shadow a parameter of the same name.
Only the rendered flag matters, so a longer or nested name is fine:
actorName → --actor-name and a grouped
runs.limit → --runs-limit both stay usable. Rename
the field, or declare a different flag with .flag("--…"). What
is not available is keeping the colliding spelling, because the alternative
is a flag that silently belongs to something else.
Typing the value
A bare parameter() is a string. .number() and
.boolean() change the kind; .options() keeps it a
string but restricts the accepted values.
// string (the default): value is `string | undefined`
region = parameter("Deploy region");
// number: parsed with Number(); empty or non-finite input is rejected.
workers = parameter("Upload workers").number(); // number | undefined
// boolean: a flag. Present (--verbose) → true; absent → false.
verbose = parameter("Verbose logging").boolean(); // boolean
// string restricted to a fixed set of choices.
level = parameter("Log level").options("debug", "info", "warn"); .number()parses withNumber(); an empty string or a non-finite result ("eight","") is rejected with a parse error..boolean()acceptstrue/1/yesandfalse/0/no(any casing) from a flag value or env var; anything else is rejected. As a flag with no value it istrue, and it defaults tofalsewhen absent..options("a", "b")restricts the string to the listed choices; any other value is rejected naming the allowed set.
Lists with .array()
.array() turns a parameter into a list. On the command line, a
comma separates values or the flag is repeated — the two are
equivalent. Blank entries are dropped, and an unsupplied optional
list defaults to [] (never undefined). Declare it
.required().array() to reject a missing value instead of
silently yielding an empty list — the required flag carries through
.array().
tags = parameter("Image tags").array(); // string[]
// deploy = target().executes(() => console.log(this.tags.value)); Comma-separated:
./zuke deploy --tags latest,canary Repeated — the same result:
./zuke deploy --tags latest --tags canary From the environment:
TAGS=latest,canary ./zuke deploy
Because .array() reuses the element parser of the parameter it is
applied to, it composes with the kind and choices declared before it —
every element is validated, not just the raw string:
// number[]: each entry parsed as a number; "1,x" is rejected.
workers = parameter("Worker ids").number().array();
// each element must be one of the choices; "api,nope" is rejected.
services = parameter("Services").options("api", "web", "worker").array(); .required() and .default()
By default a parameter is optional and its value is
T | undefined. Two methods make it non-optional:
// Required: value is non-optional; the build fails before any target runs
// if neither a flag nor the env var (nor an interactive prompt) supplies it.
environment = parameter("Target environment").required(); // string
// Default: value is non-optional and falls back to the given value.
workers = parameter("Upload workers").number().default(4); // number
// A boolean is implicitly defaulted to false, so it is always non-optional.
dryRun = parameter("Dry run").boolean(); // boolean .required()demands a value. If none is supplied — and no default exists and no interactive prompt answers it — the build fails before any target runs, with a message naming the flag and env var..default(v)supplies a fallback, sovalueis always the declared type. A boolean is implicitly defaulted tofalse.
When a required parameter is missing and the build runs at an interactive terminal (a TTY, not CI), Zuke prompts for the value instead of failing. On CI or non-interactive runs it still errors, so automation stays deterministic.
.secret() and .from()
.secret() marks a value sensitive: its resolved value is
redacted from all of Zuke's output — every banner, target
status, summary, and error message — and, under GitHub Actions, Zuke emits an
::add-mask:: so the runner masks it in its own logs.
.from(source) resolves the value from a secret manager (1Password,
Vault, a mounted file, …) when neither a flag nor an env var supplied one. It is
a fallback provider, not an override, and is typically paired
with .secret() so the fetched value is redacted.
import { execSecret } from "jsr:@zuke/core";
// .secret() masks the resolved value everywhere Zuke prints.
token = parameter("Deploy token").secret().required();
// .from(source) pulls the value from a secret manager when neither a flag
// nor an env var supplied one — a fallback provider, not an override.
registryToken = parameter("Registry token")
.secret()
.from(
execSecret((s) => s.command("op").arg("read", "op://ci/registry/token")),
);
The Secrets guide covers sources
(execSecret, fileSecret), the exact redaction
guarantee and its boundary, and the SecretError surface in full.
Resolution precedence
Every parameter is resolved before the first target executes, from — in order of precedence:
- a command-line flag —
--environment productionor--environment=production - the environment variable —
ENVIRONMENT(or the.env("NAME")override) - the
.from(...)secret source, if declared - the declared default
Each level is consulted only when the ones above it produced nothing, so the source is invoked only when neither a flag nor an environment variable supplied a value. This is what keeps a build portable: in CI a value is usually injected as an environment variable (and the source is never touched); on a developer's machine the source pulls it from their secret manager — no code change either way. For a missing required value, an interactive prompt (at a TTY) is tried before the source.
Validation and errors
Resolution collects all parameter problems and reports them together, before any target runs — a missing required value, or a value that fails to parse (a non-number, a bad boolean, an off-list choice). The failure is a single block naming each offending flag:
Invalid or missing parameters:
--environment is required (or set ENVIRONMENT).
--workers: expected a number, got "eight"
--level: expected one of debug, info, warn, got "trace"
The error class is ParameterError, exported for programmatic use.
Reading .value before the build has resolved parameters (for
example at construction time) also throws a ParameterError — read
parameters inside a target body, not in a field initializer. For a
.secret() parameter, the raw value is registered for redaction
before it is parsed, so even a parse error on a malformed
secret is masked rather than echoed.
Reading a value
Read a parameter inside a target via this.<name>.value. The
value is fully typed by the declaration: a .required() or
.default() parameter is non-optional, while a plain optional
parameter is T | undefined.
deploy = target().executes(() => {
const env = this.environment.value; // string (.required())
const n = this.workers.value; // number (.default(4))
const region = this.region.value; // string | undefined (plain optional)
});
Resolution lives in the execution engine, not the CLI, so a
programmatic execute call
resolves parameters too — pass raw values via params and/or rely on
environment variables. See Concepts for
the short version and Secrets for sensitive inputs.