Build Flags

Parameterize an elide.pkl manifest from the command line

Build flags parameterize an elide.pkl manifest from the command line. A flag is a name, optionally with a value; the manifest reads it during evaluation and can shape itself around it: pick an entrypoint, add a dependency, stamp a version, switch a toolchain setting.

Passing flags

Flags are spelled --flag=NAME[=VALUE] or -fNAME[=VALUE] and may appear in any position, before or after build targets:

elide build run --args="Hello World 🌎" --flag=RELEASE --flag=VERSION=1.2.3
elide build run --args="Hello World 🌎" -fRELEASE -fVERSION=1.2.3

The detached forms (--flag NAME, -f NAME) work as well. Everything at or after the -- passthrough separator belongs to the script, tool, or subprocess being invoked, and is never read as a build flag.

Every project-aware command accepts flags:

elide run -fRELEASE -fVERSION=1.2.3 -- "Hello World 🌎"
elide install --flag=RELEASE
elide format -fNO_KOTLIN
elide build -fRELEASE
elide test -fFAST
elide manifest -fRELEASE          # handy for inspecting the evaluated result

Rules worth knowing:

  • A flag with no value (-fRELEASE) is a bare flag: it reads as true from bool(...), and as an error from string(...).
  • A value is everything after the first =, verbatim: -fEXPR=a=b is the value a=b, and values may contain spaces, commas, and any Unicode.
  • Passing the same flag twice keeps both values: the scalar accessors read the last one, the list accessors read all of them (see Repeated flags).
  • Nothing needs to be declared ahead of time. An unrecognized name is simply absent, so a manifest decides what a flag means.
  • A flag name may not contain , or =, and may not be empty; anything else is rejected with a message naming the flag.

Reading flags in a manifest

Flags arrive on build.flags, alongside the rest of the build state (see the elide.pkl Reference):

amends "elide:project.pkl"

local isRelease = build.flags.bool("RELEASE")

name = if (isRelease) "demo-release" else "demo-debug"
version = build.flags.stringOr("VERSION", "0.0.0")
AccessorBehavior
has(name)whether the flag was passed at all, with or without a value
count(name)how many times the flag was passed; 0 when absent
string(name)the value; null when absent; throws when the flag was passed bare
stringOr(name, orElse)the value, or orElse when absent
bool(name)false when absent, true when bare; accepts true/false, yes/no, on/off, 1/0, case-insensitively; throws otherwise
int(name)the value as an Int; null when absent; throws when not an integer
strings(name)every value, in command-line order; empty when absent; throws on a bare occurrence
stringsOr(name, orElse)every value, or orElse when absent
ints(name)every value as Ints; empty when absent; throws when any is not an integer
bools(name)every value as Booleans; empty when absent; a bare occurrence reads as true
rawevery flag’s effective value as a Mapping<String, String>; a bare flag maps to the empty string
allevery flag’s values as a Mapping<String, List<String>>

A flag passed once is just a list of one, so strings(...) is the accessor to reach for whenever a manifest wants to accept β€œone or more” of something.

The typed accessors validate, so a typo fails the build with a clear message rather than silently reading as false:

–– Pkl Error ––
build flag `RELEASE` is not a boolean: `perhaps`

Repeated flags

A name repeated on the command line aggregates into a list, in the order it was passed:

elide build -fARG=one -fARG=two -fARG='three, and a half'
local args = build.flags.strings("ARG")  // List("one", "two", "three, and a half")
local first = build.flags.all["ARG"].first
local latest = build.flags.string("ARG") // "three, and a half" β€” scalars read the last value

Each occurrence travels on its own channel, so a value keeps commas, spaces, =, and any Unicode no matter how many times the flag was passed. The typed list accessors validate every element, so one bad occurrence fails the build:

–– Pkl Error ––
build flag `N` is not an integer: `x1`

Flags inside nested blocks

At module level, read flags as build.flags. Inside a nested object body an unqualified build does not resolve due to Pkl scoping rules; bind a module-level local instead:

local wantsExtra = build.flags.bool("EXTRA")

dependencies {
  maven {
    packages {
      "com.google.guava:guava:33.0.0-jre"
      when (wantsExtra) {
        "com.example:extra:1.0.0"
      }
    }
  }
}

Flags apply to the whole build, so a flag that changes a dependency or a source set changes the task graph too; see Build Tasks for how targets and caching work.