A Make-like build tool written in Go for organizing command executions across platforms.
go build -o constructRequires Go 1.26 or later.
construct [options] [Constfile] [commands...]| Command | Description |
|---|---|
list |
List all available commands |
init [template] |
Scaffold a Constfile (minimal, go, python, node, rust, monorepo; --force to overwrite) |
import [Makefile] [out] |
Convert a Makefile to a Constfile (best-effort; --force to overwrite) |
import update [specs...] |
Refresh remote recipe imports to their ref's latest commit |
dev [services...] |
Supervise long-running service commands (restart, ports, Ctrl-C stops all) |
shell [command] |
Start a shell with a command's env block, workdir, or container (--container IMG for ad-hoc) |
doctor |
Diagnose the environment, Constfile, tools, and cloud file |
stats |
Show per-command timing history from .construct-cache/run-state.json |
cloud list|pull|push |
Manage cloud command definitions (see Cloud Commands) |
cloud submit [targets...] |
Dispatch a build to GitHub Actions (--wait follows it) |
cloud status|logs|cancel <run-id> |
Inspect or cancel a dispatched run |
cloud init-actions |
Create .github/workflows/construct.yml |
clean [targets...] |
Remove files declared in produces (--cache also removes .construct-cache; respects --dry-run) |
lint [file] |
Static checks shared with the editor (--strict fails on warnings, --json for tools) |
graph [targets...] |
Print the dependency tree (--dot for Graphviz, --json for tools) |
completion <shell> |
Emit bash/zsh/fish completion (flags + your Constfile's commands) |
fmt [files...] |
Canonicalize Constfile indentation (--check for CI) |
runs [Constfile] |
Browse run history: list, show <cmd> [n], diff <cmd> [a b] |
mcp [Constfile] |
Serve build tools to MCP clients (AI agents) over stdio |
learn [Constfile] [targets...] |
Discover file deps: trace reads under strace, or list unwatched files |
install |
Install shell completions (--hook NAME [--] targets for git hooks; --uninstall) |
ui [Constfile] |
Edit the Constfile (and its imports) in a drag-and-drop browser editor |
| Flag | Description |
|---|---|
-h, --help |
Show help message |
-v, --version |
Show version information |
--debug |
Enable debug mode for verbose output |
--concurrent |
Execute commands and their prerequisites concurrently (DAG-parallel) |
--jobs N |
Cap parallel commands (implies --concurrent) |
-k, --keep-going |
Continue other targets when one fails; report all failures |
--no-cache |
Ignore the file-dep cache and run everything |
--quiet, -q |
Suppress command output, keep errors |
--explain |
Print why commands run or are skipped |
--json |
Machine-readable output (with --list) |
--shell PATH |
Shell to run statements with (default: $SHELL) |
--watch |
Rerun when the Constfile, its imports, or its dependencies change |
--choose |
Interactively select targets (arrow-key menu; type to filter) |
--timing |
Print per-command elapsed time |
--dry-run |
Show commands without executing them |
--list |
List all available commands |
--env-file PATH |
Load environment variables from a dotenv-style file |
--resume, --only-failed |
Rerun only the commands that failed in the last run |
--repeat N |
Run the whole build N times (flaky detection) |
--flame |
Print a per-statement flame graph after the run |
--github-actions |
GitHub Actions native output (auto-enabled under GITHUB_ACTIONS) |
--yes |
Auto-approve confirm statements |
--force, -f |
Overwrite files (init) |
--notify |
Desktop notification when the run finishes (works with --watch and --repeat) |
--since REF |
Only run targets affected by changes since a git ref (e.g. origin/main) |
--tui |
Live dashboard for the run (q detaches, Ctrl-C cancels) |
--container IMG |
shell: run in this container image instead of the command's |
--strict |
lint: fail on warnings too |
--cache |
clean: also remove .construct-cache |
--dot |
graph: emit Graphviz DOT |
--check |
fmt: exit 1 when files are not formatted |
--port N |
ui: serve on this port (default: a random free port) |
--no-open |
ui: print the URL without opening a browser |
construct # Run default command from Constfile
construct build test # Run 'build' and 'test' commands
construct MyFile build # Run 'build' from MyFile
construct --list # List available commands
construct --dry-run build # Show what 'build' would execute
construct --debug build # Run 'build' with debug output
construct --jobs 16 build # Parallel build (independent commands run concurrently)var name = value
var envVar = @ENVIRONMENT_VAR # Environment variable
var envOr = @PORT:-8080 # Env var, with a default when unset
var ref = &otherVar # Reference another variable
@ENV:-default also works in shell lines and conditions: an unset variable
expands to the default instead of staying literal or becoming empty.
A variable can hold a list of values, created with [...]:
var platforms = [linux, windows]
var more = &platforms + [macos] # list concatenation
var first = &platforms.0 # index into a list
var count = len(&platforms) # list length
var sorted = sort([b, a, c]) # sort and uniq
var joined = join(&platforms, "+") # join/split
- In shell lines a list expands space-joined (
&platforms→linux windows); inforloops,inconditions, and variable values it behaves as a list. for x in &platformsiterates the items;"&x" in &platformstests membership.
Variable values are evaluated as expressions when they contain operators:
var total = &count * 2 + 1 # arithmetic: + - * / %
var tag = &count >= 5 ? "big" : "small" # ternary
var combined = &a + &b # numbers add, strings concatenate
var ok = &env == "prod" && &count > 0 # logical operators
Values without operators keep the literal substitution behavior.
Available in variable values, env blocks, switch expressions, and
for ... in item lists (use them in conditions where exists/missing/
glob/require already work):
| Function | Returns |
|---|---|
upper(s), lower(s), trim(s), replace(s, old, new) |
string helpers |
sprintf(fmt, args...) |
printf-style formatting |
basename(s), dirname(s), ext(s), stem(s) |
path helpers |
length(s), len(x) |
rune count or list length |
abs(n), min(a, b, ...), max(a, b, ...) |
numeric helpers |
date("2006-01-02") |
current time (Go layout; defaults to 2006-01-02) |
uuid() |
a random v4 UUID |
file(path) |
the file's contents (trailing newline trimmed) |
lines(path) |
the file's non-empty lines as a list |
sha256(path) |
hex digest of a file |
glob(pattern) |
matching files as a list |
sort(list), uniq(list), join(list, sep), split(s, sep) |
list helpers |
env("NAME") |
an environment variable's value |
os() / arch() |
the platform (darwin, linux, windows) and architecture (amd64, arm64) |
state("name") / @state("name") |
a value persisted by a state declaration |
exists(path), missing(path), require(tool) |
"true"/"false" checks |
Paths resolve relative to the Constfile's directory.
state name = value declares a variable that persists across runs in
.construct-cache/state.json:
state last_release = "0.0.0"
release {
$ echo "previous: @state("last_release")"
state last_release = "1.0.0" # written to state.json
}
state declarations may appear at the top level or inside command bodies;
@state("name") and state("name") read the persisted value.
commandName (args) < prerequisites {
body
}
|cloudcmd|- Cloud-accessible command (can fetch remote definitions)_- Default command (runs first when no commands specified)- Arguments:
arg1(required),opt arg2(optional),opt env=prod(optional with default)
Arguments are passed as flags and referenced with the same & marker as variables:
construct deploy --deploy:env=prod --deploy:region=us-east
construct deploy env=prod region=us-east # bare key=value shorthanddeploy (env, opt region) {
$ echo "Deploying &env to ®ion"
}
An argument that isn't provided substitutes its default (if declared) or the empty string, so references never leak into the shell.
Literal &/@/$ text can be emitted with a backslash escape: \&foo,
\@VAR, \$ are not substituted.
A &ref followed by a dot only resolves as a prereq output (or namespaced
variable) when the full dotted name exists; otherwise the first segment
resolves as a plain variable and the rest stays literal — so &version.vsix
with var version = 0.2.0 produces 0.2.0.vsix.
- Prerequisites:
cmd1, cmd2 - A prerequisite can run in its own directory at the call site:
main < gen in src/ - Error tolerance: prefix a body statement with
!to let it fail without aborting the build - A command body may be written on a single line — including an empty one:
|gen| { }orclean { rm -rf dist }(nested single-line blocks work too)
Split a project across multiple Constfiles with import:
# lib.constfile
var version = 2
build {
$ make
}
# Constfile
import "lib.constfile"
deploy < build {
$ deploy --version &version
}
Import paths resolve relative to the importing file. Imports can import other files; circular imports and duplicate command names are errors.
To merge two Constfiles that define same-named commands, give the import a
namespace. Every command, variable, and reference inside becomes
ns.name:
import "lib.constfile" as lib
import "other.constfile" as other
deploy < lib.build {
$ echo "lib version: &lib.version"
$ echo "other version: &other.version"
}
- Commands are renamed (
lib.build), so both files can definebuild. - Global variables are renamed (
lib.version); reference them as&lib.version. - Prerequisites,
invoketargets, and prereq outputs inside the imported file are rewritten automatically (&gen.0becomes&lib.gen.0). - References to local variables (scoped to a command or a loop) are left alone, so shadowing keeps working.
- The same file may be imported twice under different namespaces.
- Nested namespaces compose:
libimportingsubassubyieldslib.sub.*.
An import can carry a condition; when it evaluates false, the import is
skipped entirely. on matches platforms (darwin, linux, windows,
macos as an alias for darwin, comma-separated for several); if accepts the
full condition language (exists, glob, require, os("..."),
arch("..."), @ENV refs, and variables defined above the import):
import "mac.constfile" on macos
import "linux.constfile" on linux, windows
import "local.constfile" if exists("local.constfile")
import "ci.constfile" if "@CI" == "true" && require("docker")
import git fetches a Constfile from a git repository — shared build recipes
without a package manager:
import git "acme/const-recipes" # GitHub shorthand
import git "github.com/acme/recipes/tools@v2" # subdir + tag/branch/SHA pin
import git "gitlab.com/acme/recipes" as recipes
- The repo's
Constfile(at the root or the given subpath) is imported like a local file, with all namespacing rules. Withoutas, the import is namespaced by the repository name. - The first fetch records the commit in
.construct.locknext to your Constfile; after that, builds use the pinned copy from.construct-cache/imports/and work offline. Commit the lock file. construct import update [specs...]re-fetches at the pinned ref's latest commit (or the default branch for unpinned imports) and rewrites the lock; imports pinned to a commit SHA never move.- Private repos use your local git credentials (ssh remotes like
git@github.com:acme/recipes.gitwork as-is).
Commands declared with service are long-running processes that
construct dev supervises — a Procfile runner built into the build tool:
service api {
port 8080
$ go run ./api
}
service web < api {
port 5173
onchange src/**.tsx
$ npm run dev
}
dev < api, web {
$ docker compose up -d # runs once as setup
}
construct devruns the aggregator's (or services') non-service prerequisites to completion first, runs the aggregator body once as setup, then starts the services in dependency order.port Nmarks readiness: dependents start only once the port accepts connections (90s timeout, then a warning).- Crashed services restart with backoff (1s doubling to 10s, reset after a
30s run);
onchangeglobs restart a service the moment its inputs change. - Output is interleaved with
[service]prefixes. Ctrl-C stops everything (twice to force). construct dev apistarts a subset;construct dev <cmd>treats any non-service command as the aggregator (its service prereqs are supervised, its body is the setup). A regular command that depends on a service is an error — invert the dependency. Running a service directly (construct api) still just runs it once.
A # or // comment directly above a command becomes its description,
shown by construct --list:
# Builds the app and bundles assets
build {
$ go build -o app .
}
Set environment variables for the rest of the enclosing command body with an
env block — single-line (comma-separated) or multi-line:
test {
env { CI=true, RETRIES=3 }
$ pytest
}
deploy {
env {
REGION=us-east-1
PROFILE=prod
}
if "@REGION" == "us-east-1" {
$ aws deploy --region ®ION
}
}
Values may reference &variables and @env refs; they are visible both to
child processes and to later @NAME references and conditions in the same
command. Env-block variables also resolve as &NAME refs in subsequent
statements (including inside loops, e.g. for i in 1..&N). env blocks
apply from their position to the end of the command body (including nested
if/for blocks).
invoke <command> runs another command's body inline, imperatively — unlike a
prerequisite, no dependency tracking happens and it can be placed anywhere,
including inside conditionals. The invoked body executes in the caller's
scope, so it can see the caller's variables, arguments, and loop variables:
gen {
$ echo line-one
$ echo line-two
}
use {
invoke gen # streams to stdout
invoke gen as lines # captures output into &lines
invoke gen arg="value" # passes args as variables in the caller's scope
for l in &lines {
$ echo "captured: &l"
}
}
invoke <cmd> key=value, key2="value 2" passes arguments to the invoked body;
declared arguments (arg="default") use their default when not passed.
invoke only runs the body; prerequisites, file deps, and up-to-date checks
of the invoked command are ignored. Self-referential or circular invokes are
rejected.
deploy {
onfail {
$ echo "rolling back"
}
if &env == "prod" {
fail "refusing to deploy to prod"
}
$ echo "deploying"
}
fail "message"aborts the command with a readable error (fail: message (file:line)).require_env KEY "message"fails the command when an environment variable is unset.global name = valuewrites a global variable from inside a command body.retry<3> $ cmdreruns a statement up to 3 extra times before failing.retry<3, 2s>also waits between attempts, doubling each time (2s, 4s, 8s, ...).onfail { ... }registers a block that runs once if any later statement in the command fails — handy for cleanup and rollback. It runs after the failure; the original error is still reported. Inside the block,&fail.message,&fail.line, and&fail.exit(when the failure was a non-zero exit) describe what went wrong.onfailalso runs when the build is interrupted (Ctrl-C) before exiting.
Variables can be global (defined outside commands) or local (defined inside commands):
var global = value
mycommand {
var local = &global # Can reference global vars
$ echo &local
}
Access prerequisite output using &prereqName.index or named outputs:
prereq {
$ echo "hello" as greeting
}
main < prereq {
$ echo &prereq.0 # Outputs: hello (positional)
$ echo &prereq.greeting # Outputs: hello (named)
}
Iterate over comma-separated lists or file globs:
vet {
for f in *.go {
$ go vet &f
}
}
deploy {
for svc in api, web, worker {
$ docker build -t &svc ./&svc
}
}
The loop variable (&f, &svc) is available inside the loop body. Globs are expanded relative to the command's working directory.
Commands with file dependencies skip execution if nothing changed since the last run:
build < main.go, pkg/*.go {
$ go build -o app .
}
File patterns (containing /, *, or .) are tracked separately from command prerequisites. A manifest is stored in .construct-cache/. Touch a file and re-run to trigger a rebuild.
An onchange header modifier adds globs to the --watch set without
participating in the skip-cache:
gen in src onchange src/**.c, src/**.h {
$ make generate
}
Instead of hashing dependencies, declare what a command produces — the command skips when every artifact exists and no dependency is newer:
build produces dist/app < src/*.go {
$ go build -o dist/app .
}
The produced files may be globs, and the check is mtime-based like make.
--since REF runs only the requested targets whose inputs changed since a git
ref — tracked modifications (staged or not), deletions, and untracked files
all count:
construct --since origin/main build test # run build/test only if affected
construct --since HEAD~2 --dry-run build # preview what a change would triggerA target is affected when any changed file matches its file deps, onchange
globs, or produces, when its declaring Constfile changed, or when any
prerequisite is affected. Unaffected requested targets are skipped with a
note ((build not affected since origin/main — skipping)); with no explicit
targets the default command's closure is filtered. --since composes with
--dry-run, --explain, --watch, --resume, and --choose.
construct loads KEY=VALUE entries from a .env file next to the Constfile
(or from a path given with --env-file PATH). Existing environment variables
take precedence, # comments and blank lines are ignored, and values may be
quoted. Loaded variables are visible to @VAR refs and child processes.
Override any variable from the command line:
construct deploy -e env=prod -e region=us-eastbuild {
if "&version" >= "2" {
$ make modern
} else {
$ make legacy
}
}
Conditions support the comparison operators ==, !=, >, >=, <, <=
(numeric when both sides are integers, otherwise lexicographic), plus the
string operators contains, starts_with, ends_with, and matches (regular
expression), the logical operators &&, ||, !, and parentheses. @ENV
references resolve in shell lines and conditions; in shell lines an unset
variable stays literal (so scoped package names like @vscode/vsce survive),
while in variable values it resolves to empty.
deploy {
if "&target" contains "windows" && "&version" >= "3" {
$ build.exe
} else if "&target" contains "linux" {
$ build-linux
} else {
$ build
}
}
else if chains are fully supported. Conditionals can be nested inside for
loops, where they're re-evaluated on each iteration with the current loop
variable:
for spec in darwin/arm64, windows/amd64 {
if "&spec" contains "windows" {
$ echo "windows target: &spec"
}
}
Built-in file-test functions evaluate inside conditions (they are construct functions, never passed to the shell):
deploy {
if exists("dist/app.exe") {
$ echo "artifact already built"
}
if missing("dist/app.exe") {
$ go build -o dist/app.exe .
}
if glob("dist/*.exe") {
$ echo "found a built binary"
}
if require("docker") {
$ docker build -t app .
}
}
require("tool") checks that a binary is available on PATH.
List membership uses the in operator — exact match against a comma-separated
list:
if "&os" in "windows, linux" {
$ echo "supported platform"
}
Blocks may be written on a single line, which pairs naturally with loop control:
for f in *.go {
if "&f" == "main.go" { continue }
$ go vet &f
}
for i, f in *.go { # "for index, value in ..." — 0-based index variable
$ echo "[&i] vetting &f"
}
for i in 1..10 { # numeric ranges: N..M (ascending or descending)
$ echo "attempt $i"
}
A prereq's captured outputs can be iterated with the &prereq.* reference
(a command with no outputs yields zero iterations):
gen {
$ echo line-one
$ echo line-two
}
use < gen {
for line in &gen.* {
$ echo "captured: &line"
}
}
Working directories and file dependencies resolve relative to the Constfile's
directory, so construct /path/to/Constfile behaves the same from anywhere.
parallel for runs the loop body for every item concurrently — each
iteration gets its own variable scope, environment, and output prefix
([cmd/item]):
build {
parallel for spec in darwin/arm64, windows/amd64, linux/arm64 {
$ go build -o dist/construct-&spec .
}
}
- The iteration cap defaults to
--jobs(or the CPU count); an explicit cap can be given with the<N>keyword modifier:parallel<4> for x in ....--jobsstill bounds global process concurrency, so the two compose. continue(andcontinue if) skips a single iteration;breakis an error — it cannot stop other in-flight iterations.- Variables assigned inside a parallel body are shared across iterations; the loop variable itself is always iteration-local.
- On failure the loop stops launching new iterations, waits for in-flight ones, and reports the first error.
parallel matrix parallelizes across the cross product (the outer axis
varies concurrently, inner axes stay nested):
build_matrix {
parallel matrix os in windows, linux; arch in amd64, arm64 {
continue if "&os" == "windows" && "&arch" == "arm64"
$ echo "building &os/&arch"
}
}
The <...> modifier slot is shared by keyword modifiers: parallel<N>
(iteration cap), lock<5m> (bounded lock wait), retry<3, 2s> (retry
count plus optional backoff base), switch<strict> (fail on no match),
and timeout<30s> (statement timeout).
For multi-axis builds, matrix iterates the cross product of several
variable lists (it expands to nested for loops; the last variable varies
fastest):
build_matrix {
matrix os in windows, linux; arch in amd64, arm64 {
if "&os" == "windows" && "&arch" == "arm64" {
$ echo "skip &os/&arch"
} else {
$ echo "building &os/&arch"
}
}
}
Each clause is var in items separated by ;, and any axis supports globs.
Combinations can be filtered declaratively with continue if / break if
(also usable inside regular for loops):
build_matrix {
matrix os in windows, linux; arch in amd64, arm64 {
continue if "&os" == "windows" && "&arch" == "arm64"
$ echo "building &os/&arch"
}
}
Bare continue (skip the rest of this iteration) and break (exit the loop)
work inside any for or matrix body, including nested in if blocks. Use
$ continue if you actually need the shell builtin.
Multi-arm dispatch over an expression; the first matching case runs:
deploy {
switch "&env" {
case "prod", "staging" {
$ aws deploy
}
case "dev" {
$ local-deploy
}
default {
$ echo "unknown env &env"
}
}
}
Case values are comma-separated and matched exactly; default runs when
nothing matches. switch<strict> fails the command when nothing matches
and there is no default — useful when a missing case is a configuration
error. case/default outside a switch is a parse error.
in <dir> { ... } runs a block with its working directory set (created if
missing), returning afterwards:
test {
in api {
$ go test ./...
}
in web {
$ npm test
}
}
lock "name" { ... } holds an exclusive advisory lock (stored in
.construct-cache/locks/) while the block runs. Concurrent construct
processes wait for it:
deploy {
lock "deploy" {
$ aws deploy
}
}
The wait can be bounded with a duration modifier — when it expires, the command fails instead of hanging:
deploy {
lock<5m> "deploy" {
$ aws deploy
}
}
confirm "deploy to prod?"— asks y/N and aborts the command when declined.--yesauto-approves; non-TTY stdin aborts unless--yesis given.prompt "press enter"— prints the message and waits for Enter on a TTY (skips the wait otherwise).input name "question?"— reads a line from stdin into the variablename.
A command or a single statement can be capped with a duration, written with
the timeout<...> modifier in both places - the header caps the whole
command, a body statement caps just that line:
build timeout<120s> {
$ go build
timeout<30s> $ go test
}
A hit kills the statement's process group and reports
command '...' timed out after 30s (exit 124).
A command can run its shell statements inside a container image (docker, or podman when docker is absent):
build container "golang:1.26" < **.go {
$ go build ./...
$ go test ./...
}
The Constfile's directory is mounted at /work and statements run there with
/bin/sh; the environment is passed through via --env-file, and the
command's in <dir> workdir maps under /work. Builtins (cp, rm,
download, ...) still run on the host. Statement timeouts kill the container
CLI; the container itself is removed via --rm.
construct lint runs the same checks the editor shows inline: out-of-bounds
&cmd.N indexes, unknown named outputs, duplicate prerequisites, missing
file dependencies, unused globals, and unreferenced commands. It also flags
misused keywords before they silently misparse: produces/container/
timeout written after the prerequisite list (where they are treated as
prerequisites instead of modifiers), timeouts written with the removed
space form (timeout 30s $ cmd or build timeout 30s {), break/continue outside a loop, break inside a parallel
loop (it cannot stop concurrent iterations), references like &svc-
whose trailing - is swallowed into the name, &name references that
resolve to nothing (typos — they substitute to empty silently), case
arms without values, and duplicate named outputs. parallel<4> on a
non-loop statement is a parse error. Exit code 1 on errors (or warnings
with --strict); --json emits machine-readable issues — both are
CI-friendly.
Commands that are meant to be invoked by hand (or via --choose) can opt out
of the unreferenced check with the manual marker:
manual build-construct {
$ go build -o construct .
}
The classic make-likes failure is a forgotten file dep: the command runs
stale because the cache never sees the file it actually reads. construct learn finds those gaps:
construct learn build # run 'build' traced, report uncovered reads
construct learn # no tracer: list files nothing watches
construct learn --json # machine-readable- On Linux with
straceavailable, learn re-runs the targets understrace -f -e trace=%file, collects successful file reads (andexecve) inside the repo, and reports — per command that ran — files read but not covered by that command's file deps..git,.construct-cache, and other commands'producesartifacts are excluded. - Without a tracer (macOS, or Linux without strace), learn falls back to
static analysis: repo files matched by no command's deps,
onchange, orproduces— files whose edits trigger nothing.
Suggestions are prints only; learn never edits the Constfile.
construct clean [targets...] deletes the files a command declares in
produces (globs expand, --dry-run previews) and refuses to delete
directories or anything outside the Constfile's directory. Add --cache to
also drop .construct-cache (file-dep hashes, run state, locks).
construct graph [targets...] prints the transitive prerequisite tree with
file dependencies marked; --dot emits Graphviz DOT for
construct graph --dot | dot -Tsvg -o graph.svg, and --json for tooling.
construct fmt re-indents Constfiles to four spaces per nesting level,
trims trailing whitespace, and normalizes the trailing newline — statement
text (shell lines especially) is preserved byte-for-byte. construct fmt --check exits non-zero on unformatted files for CI.
Every run records per-command status, exit code, duration, and a bounded
capture of the command's streamed output (statement headers included) in
.construct-cache/run-state.json. Browse it with construct runs:
construct runs # recent records across all commands
construct runs show build # latest record for 'build', with its log
construct runs show build 3 # third-most-recent record
construct runs diff build # diff the last two captured logs
construct runs show test --json # machine-readablediff shows +/- line changes between two runs of the same command —
handy for "did the failing output change?" Logs are capped per record, so
history stays small.
construct completion bash|zsh|fish prints a completion script that
completes flags and your Constfile's command names (via a hidden
construct __targets helper). Source it from your shell profile — or use
construct install to do it for you:
construct install # completions for your $SHELL
construct install --shell fish # pick a shell explicitly
construct install --uninstall # remove them againzsh/bash completions land in ~/.construct/completions/ with a source line
appended to your rc (idempotently); fish completions go to
~/.config/fish/completions/ and load automatically.
install also manages shared git hooks:
construct install --hook pre-push -- build test
construct install --hook pre-commit # runs the default command
construct install --uninstall --hook pre-pushHooks are scripts in .construct-hooks/ at the repo root with
core.hooksPath pointed at the directory — commit it and the whole team
runs the same checks.
--notify sends a desktop notification (macOS Notification Center, Linux
notify-send, Windows balloon tips) when a run finishes — including each
--watch rerun — with the duration or the first line of the failure.
Bare lines starting with a builtin name run cross-platform (use $ cp ... to
force the shell version):
provision {
mkdir dist
cp src/app dist/app
touch dist/version.txt
download "https://example.com/data.zip" dist/data.zip
extract dist/data.zip dist/data
rm -rf dist/tmp
}
cp src dst— copies a file or directory (recursive)rm path...— removes recursively; refuses to remove the base directory or its ancestors.rm<kill> path...also terminates any process still running from the file first (Windows refuses to delete a running executable; elsewhere the modifier is a no-op)mkdir path...,touch path...— directory/file helpersdownload url dst— fetches with a progress bar on TTYsextract archive dir—.zip,.tar,.tar.gz/.tgz,.tar.bz2(path-traversal entries are refused)
A leading ! makes a builtin error-tolerant, and &last.exit reports the
result.
After every shell or builtin statement, &last.exit and &last.output hold
the previous statement's exit code and captured output — most useful after an
error-tolerant ! statement:
deploy {
! $ aws cloudfront create-invalidation
if "&last.exit" != "0" {
$ echo "invalidation failed, continuing anyway"
}
}
Commands marked |name| can fetch their body from a cloud definitions file
(JSON map of name → command). The file comes from $CONSTRUCT_CLOUD_FILE,
or construct-cloud.json next to the Constfile:
|deploy| {
$ echo "local fallback body"
}
use {
invoke deploy # uses the remote body when the local body is empty
}
- A cloud-accessible command with an empty local body runs the remote
definition;
invokefalls back to the cloud when the command isn't local; runningconstruct namealso works for pure cloud commands. construct cloud list— list definitions.construct cloud pull [names...]— write definitions intoconstruct-cloud.json(auto-loaded).construct cloud push [names...]— upload local (cloud-accessible) bodies into the cloud file (--fileto choose the target).
construct cloud submit runs a build on GitHub Actions instead of locally:
construct cloud submit build # dispatch
construct cloud submit --wait test # dispatch + follow logs
construct cloud submit --wait -e CI=true deploy --deploy:env=prod
construct cloud status 12345
construct cloud logs 12345
construct cloud cancel 12345- The repository is inferred from
git remote get-url origin(override with--repo owner/repo), the branch from the current checkout (--refto override), and the workflow defaults to.github/workflows/construct.yml. - If the workflow file doesn't exist,
cloud submitcreates it (aworkflow_dispatch-driven runner that installs construct, restores the previous.construct-cacheartifact, runs the targets, and uploads the new state). Commit and push it, then re-submit. - Authentication:
GITHUB_TOKEN(orCONSTRUCT_GITHUB_TOKEN), falling back togh auth token. The API base is overridable withCONSTRUCT_GITHUB_API. -e KEY=valueoverrides travel to the runner as workflow inputs. Keys that look like secrets (token, password, api_key, ...) are warned about and their values are redacted from locally streamed logs — but workflow inputs are visible to repo collaborators, so prefer GitHub repo secrets for real secrets.--waitpolls the run, streams new job logs with secrets redacted, and exits non-zero when the run fails.--jsonprints machine-readable dispatch/status output.- Run records,
--resume,--repeat, and--flameall behave the same on the runner as locally; the runner's.construct-cacheis uploaded as an artifact between runs.
var name = @USER
setup {
$ pip install requests
}
test < setup {
$ pytest tests/
}
build (env, opt region) < test {
$ echo "Building for &env"
$ echo "Region: ®ion"
}
_ {
$ echo "Welcome &name!"
}
construct import [Makefile] [output] converts an existing Makefile to a
Constfile (defaults: Makefile → Constfile, refusing to overwrite without
--force):
construct import # Makefile -> Constfile
construct import GNUmakefile # explicit input
construct import Makefile lib/ConstfileWhat converts mechanically:
VAR = .../:=/::=/?=→var NAME = ...with$(VAR)refs rewritten to&NAME(forward references included)- rules → commands; prereqs keep make ordering;
.PHONYtargets keep their names; file-like targets (main.o,dist/app) get a safe command name plusproduces <original>so the up-to-date check covers the file - automatic variables:
$@→ the target,$<→ the first prereq,$^/$?→ all prereqs,$(MAKE)→construct,$$→$ - recipe prefixes:
@is dropped (construct never echoes commands),-becomes the error-tolerant! include→import, doc comments above rules become command descriptions,.DEFAULT_GOAL(or the first target) becomes_
What is flagged instead of guessed: conditionals (ifeq/ifdef/...),
$(shell ...), pattern rules (%.o: %.c), define blocks, export,
+= on existing variables, target-specific variables, order-only
prereqs, and double-colon rules each get a
# construct-import: ... comment at the site, and the summary counts
them. The generated file is formatted and parse-checked; run
construct lint after importing.
construct shell [command] starts an interactive shell with that
command's environment — the resolved env block, header workdir
(created if missing), and container image when one is declared:
construct shell dev # env + workdir of 'dev'
construct shell --container golang:1.26 # ad-hoc container shell
construct shell # bare shell at the Constfile's dirWith container "image" (or --container), construct runs
docker/podman run --rm -it with the Constfile mounted at /work, the
environment passed via --env-file, and bash if the image has it
(falling back to sh). Without a container, the shell is CONSTRUCT_SHELL,
$SHELL, or /bin/sh, and the child's exit code becomes construct's.
--tui replaces the scrolling output with a full-screen dashboard while
the build runs: one row per command with a live spinner, duration, and
exit codes, plus a rolling log pane for the selected command.
construct --tui --jobs 8 build
j/k(or arrows) select a command to watch;ffollows the currently running one (the default)qdetaches — the terminal is restored and the run continues with normal output; nothing is killedCtrl-Ccancels the run as usual (exit 130)- when commands fail, their captured output tails print after the dashboard closes so the error context isn't lost
The dashboard requires a terminal and is disabled automatically (with a
notice) for --watch, --repeat, --choose, --dry-run, --debug,
--explain, --quiet, --resume, GitHub Actions output, or Constfiles
using confirm/prompt/input.
construct ui opens a drag-and-drop editor for the Constfile and its whole
import closure in your browser:
construct ui # edit Constfile (or Constfile-$GOOS)
construct ui --port 8080 # pick the port
construct ui --no-open # print the URL without launching a browser- Commands render as cards — drag to reorder, drop one card onto another's
prerequisite area to connect them (
deploy < buildwithout touching the text), edit headers as forms (arguments, prerequisites,produces,container,timeout,onchange, workdir), and edit bodies in a monospace editor with Constfile syntax highlighting and a statement palette (if,for,parallel for,env,invoke,retry<3>, …). - Bodies also have a Structure view: the parsed statement tree as an
outline — drag statements to reorder them, drop one onto a block's
"nest inside" zone to move it into
if/for/env/… bodies, click a row to jump to its code. - Imported files get their own tab and are fully editable; namespaced imports show the name they are referenced by. Adding an import line registers the file immediately.
- The graph view lays out the dependency DAG per file — drag nodes to arrange them, drag the ● handle of one node onto another to connect a prerequisite, click an edge and press Delete to remove it.
- A lint panel surfaces the same checks as
construct lintwith click-to-jump. - Variables, state, and import lines keep their raw text — the editor never
re-serializes lines it did not change, so comments and expressions are
preserved byte-for-byte. Saving formats the file like
construct fmt. - Every edit is validated through the real parser before it applies
(circular dependencies, duplicate names, and broken references are
rejected with the parse error), with undo/redo and Ctrl-S to save.
Dry-runon a command shows what it would execute without running it. - The server binds to 127.0.0.1 only and requires a per-session token from the printed URL; it refuses to overwrite files that changed on disk since they were loaded.
construct mcp speaks the Model Context Protocol over stdio, so AI agents
and editors can discover and drive the build without guessing:
Register it with any MCP client by pointing it at the binary:
{
"mcpServers": {
"construct": { "command": "/usr/local/bin/construct", "args": ["mcp"] }
}
}Tools exposed (each shells out to the construct binary in the workspace, so
behavior matches the CLI exactly): list_targets (commands, descriptions,
args, prereqs as JSON), run_targets (with dry_run, explain, jobs,
keep_going, variable overrides, and a timeout), graph, lint, and
run_history. All tools accept file and cwd arguments. The server is
newline-delimited JSON-RPC 2.0 — no extra dependencies, one binary.
Construct automatically looks for platform-specific Constfiles:
Constfile-darwin(macOS)Constfile-linux(Linux)Constfile-windows(Windows)
If Constfile doesn't exist but a platform-specific file does, it will be used automatically.
Construct runs each command body through a shell. The shell is chosen automatically (cmd.exe on Windows, otherwise the SHELL environment variable, falling back to /bin/sh), but you can override it. When zsh or bash is used, startup files (.zshrc, .bashrc, etc.) are skipped — command bodies are batch scripts, and this keeps per-statement startup in the single-digit milliseconds. If a command needs aliases or functions from your rc files, set CONSTRUCT_SHELL to a plain sh or export the setup yourself.
| Env var | Default | Purpose |
|---|---|---|
CONSTRUCT_SHELL |
platform default | Override the shell binary used to run command bodies (e.g. bash, /usr/local/bin/zsh) |
CONSTRUCT_CLOUD_FILE |
construct-cloud.json |
Path to the JSON file holding cloud-accessible command definitions |
SHELL |
(system) | Used as the non-Windows shell when CONSTRUCT_SHELL is unset |
Construct validates:
- Circular dependencies in prerequisites
- Missing prerequisite commands
- Unclosed command blocks
case/defaultoutside aswitch, duplicate case values, switches without cases- Header-only keywords (
manual,produces,container,onchange,import) used as body statements — with a hint to prefix$when a shell command of that name was intended - Unrecognized top-level statements (anything that is not
var,import,state, or a command header) instead of silently dropping the line - Top-level blocks like
env { ... }oronfail { ... }that would parse as commands literally named after the statement keyword (a lint error, since renaming the command is the fix)
Failures are reported with their source location, e.g.
Constfile:42: command '...' failed (exit 1) — the file and line of the
failing statement — and parse errors include the offending line.
The VSCode extension (editor/vscode) ships a language server with hover
(variables, arguments, prereq outputs, keywords, builtins, functions, and
&fail.*/&last.* context refs), go-to-definition (variables, commands,
prereqs, file deps, workdirs, and state("...") refs), completions
(variables, command names, statement keywords, builtins, and functions),
document symbols (commands — services marked with their port — and state
declarations), and diagnostics (parse errors, duplicate prereqs,
out-of-range output indexes, named-output hints). The TextMate grammar
highlights all statements, blocks, list literals, and expressions,
including service/port declarations and import git / conditional
import lines.