bldr
by tek-cat
README.md
# bldr
A lightweight, language-agnostic build daemon that exposes your project's build
flows as MCP (Model Context Protocol) tools, letting an AI assistant like Claude
drive builds, check status, and react to results without any project-specific
plumbing.
**Homepage:** [bldr-ecbf6d.gitlab.io](https://bldr-ecbf6d.gitlab.io/)
(Next.js site in `site/`, built by bldr itself via `bldr --project site/bldr.toml --flow build` and published to GitLab Pages by `.gitlab-ci.yml`)
---
## What it does
- Runs as a single long-lived process managing one or more projects.
- Each project is described by a `bldr.toml` that declares stages, flows, and
optional capability plugins.
- Stages are shell commands; flows are ordered lists of stages.
- The daemon exposes FastMCP tools so an MCP client can trigger and monitor
builds, manage worktrees, and invoke plugin actions.
- State is persisted in SQLite (XDG data dir) so build history survives
restarts. Any record that was "running" at restart is marked "interrupted" --
the daemon never reports stale in-progress state.
- Per-project async locks mean projects build concurrently without stepping on
each other.
- Projects can be added and removed at runtime via `add_project` / `remove_project`.
---
## Architecture
```
one daemon, many projects
MCP client (Claude) --> FastMCP tools (project-routed) --> ProjectRegistry
| |
/status endpoint per-project:
(GET, JSON) ProjectState + Lock
pipeline (flow runner)
runner (async shell)
plugins (Capabilities)
|
SQLite (XDG data dir)
```
---
## Concepts
### Stages
A stage is a named shell command declared in `[stages.<name>]`:
| Field | Default | Description |
|-------|---------|-------------|
| `cmd` | (required) | Shell command to run. Supports `{{message}}`, `{{branch}}`, `{{project_root}}` templates. |
| `cwd` | project root | Override working directory for this stage only. |
| `interruptible` | `true` | If `false`, a new build request queues behind this stage rather than cancelling it. |
| `timeout` | none | Max seconds before the stage is killed. |
### Flows
A flow is an ordered list of stage names. When `request_build` is called:
- If nothing is running, the flow starts immediately.
- If the current stage is **interruptible**, the running flow is cancelled and
the new flow starts.
- If the current stage is **non-interruptible**, the new request is queued and
starts as soon as that stage finishes.
### Plugins (Capabilities)
A plugin is a Python `Capability` subclass registered under `[plugins.<key>]`
in `bldr.toml`. Plugins can:
- Start and stop background services (`start()` / `stop()`).
- Contribute named stage handlers used inside flows.
- Register MCP tools available to the AI client.
### Worktrees
Worktrees are parallel isolated builds of the same project, backed by Git
worktrees. Each worktree appears in the registry as `<project>::<name>` and
gets its own plugin instances (with ephemeral ports to avoid conflicts).
---
## MCP Tools
All project-scoped tools require a `project` argument (the value of `name` in
`[project]`). There is no single-project mode; projects are always addressed by
name.
### Core tools
| Tool | Arguments | Description |
|------|-----------|-------------|
| `list_projects` | -- | List all registered projects with their flows and status. |
| `request_build` | `project`, `flow`, `message=""` | Start, queue, or restart a flow. Returns `started`, `queued`, or `restarted`. |
| `get_status` | `project` | Return current status, running stage, recent log lines, and build history. |
| `add_project` | `config_path` | Register a new project from a `bldr.toml` path. Starts its plugins. |
| `remove_project` | `project` | Unregister a project, cancel any running flow, stop its plugins. |
### Worktree tools
| Tool | Arguments | Description |
|------|-----------|-------------|
| `create_worktree` | `project`, `name`, `branch=""` | Create a Git worktree of `project`, register it as `project::name`. |
| `list_worktrees` | `project` | List all registered worktrees for a project. |
| `build_worktree` | `project`, `name`, `flow`, `message=""` | Run a flow on a worktree (`project::name`). |
| `remove_worktree` | `project`, `name` | Cancel the worktree's flow, stop its plugins, and remove the Git worktree. |
### Plugin tools
These tools are registered by their respective plugins and are only available
when the project declares that plugin in its `bldr.toml`.
| Tool | Plugin | Arguments | Description |
|------|--------|-----------|-------------|
| `list_artifacts` | `artifact_http` | `project` | List files currently in the artifacts directory. |
| `serve` | `web_serve` | `project` | (Re)start the web server and return its URLs. |
| `screenshot_build` | `screenshot` | `project`, `url`, `wait_seconds=3.0`, `width=0`, `height=0` | Capture a headless screenshot and save it to `out_dir`. |
| `compare_screenshot` | `screenshot` | `project`, `url`, `baseline_name`, `save_baseline=False`, `diff_threshold=None`, `wait_seconds=3.0`, `width=0`, `height=0` | Compare a live screenshot to a saved baseline; returns match/mismatch and diff percent. |
| `request_demo_deploy` | `demo_deploy` | `project`, `push=False` | Copy the build dir into a target git repo, patch `index.html`, commit, and optionally push. |
---
## Configuration
Each project requires a `bldr.toml`. All paths are resolved relative to the
config file unless absolute.
```toml
[project]
name = "my-app"
# root defaults to the directory containing bldr.toml.
root = "."
# Flows are ordered lists of stage names.
[flows]
build = ["clean", "compile", "package"]
test = ["clean", "compile", "test_suite"]
release = ["clean", "compile", "package", "artifact_publish"]
# Each stage must have a cmd. Other fields are optional.
[stages.clean]
cmd = "rm -rf build dist"
interruptible = true # default
[stages.compile]
cmd = "make -j4"
interruptible = false # queues new requests until this stage finishes
timeout = 120 # seconds; omit to disable
[stages.package]
cmd = "make dist"
[stages.test_suite]
cmd = "pytest -q"
timeout = 300
# cwd = "../other-dir" # per-stage working directory override
# -- Plugins --
# artifact_http: copy build outputs to a served directory.
# Adds an "artifact_publish" stage handler and a "list_artifacts" MCP tool.
[plugins.artifact_http]
dir = "artifacts" # relative to project root
port = 9099 # 0 = ephemeral port
bind = ["127.0.0.1"]
keep = 10 # prune oldest beyond this count
artifacts = ["build/my-app.tar.gz", "build/index.html"]
# web_serve: serve a build directory with Cache-Control: no-store.
# Adds a "serve" MCP tool.
[plugins.web_serve]
build_dir = "build"
port = 8100
bind = ["127.0.0.1"]
# screenshot: headless-browser screenshots via Playwright.
# Adds "screenshot_build" and "compare_screenshot" MCP tools.
[plugins.screenshot]
viewport = [412, 915] # [width, height] in pixels (default)
out_dir = "screenshots"
diff_threshold = 0.01 # fraction of changed pixels to count as mismatch
noise = 30 # per-channel delta below which a pixel is "same"
# demo_deploy: copy a build directory into a target git repo and commit.
# Adds a "demo_deploy" stage handler and a "request_demo_deploy" MCP tool.
[plugins.demo_deploy]
build_dir = "build"
target_repo = "/path/to/pages-repo"
target_subdir = "." # subdirectory inside target_repo
base_href = "/my-app/" # patches <base href> in index.html
page_title = "My App" # patches <title>
page_description = "My demo" # patches <meta name="description">
commit_message = "Deploy demo via bldr"
branch = "main"
```
Key rules:
- Every stage name referenced in a flow must have a `[stages.<name>]` entry OR
be provided by a plugin's `stage_handlers()`.
- An unknown or broken plugin is logged and skipped; it does not crash the
daemon.
---
## Built-in Plugins
### `artifact_http`
Copies matched glob patterns from the project root into a served directory,
renames each by the `name` template, prunes the oldest files beyond `keep`, and
serves the directory over plain HTTP (with no `index.html`, the default Python
`http.server` directory listing). Bind it to a LAN/VPN address and any machine
can browse the directory and download artifacts.
| Option | Default | Description |
|--------|---------|-------------|
| `dir` | `"artifacts"` | Directory to publish into (relative to project root). |
| `port` | `9099` | TCP port; `0` picks a free ephemeral port. |
| `bind` | `["127.0.0.1"]` | Addresses to bind (a down interface is skipped). |
| `keep` | `10` | Max files to retain (oldest pruned); `0` keeps everything. |
| `name` | `"{name}"` | Filename template for published artifacts (see below). |
| `artifacts` | `[]` | Glob patterns (relative to project root) to copy on `artifact_publish`. |
The `name` template builds each published filename from these placeholders:
`{name}` (original basename), `{stem}`, `{ext}` (incl. the dot), `{project}`,
`{branch}` (sanitized), `{sha}` (short git hash), `{ts}` (`YYYYMMDD-HHMMSS`).
The default `"{name}"` keeps the basename (each build overwrites the last). A
template like `"{project}-{branch}-{ts}-{sha}{ext}"` makes every build a
distinct file, so the **whole build history accumulates and stays browsable**
over HTTP (bounded by `keep`). Different artifact kinds (e.g. `.apk` and `.aab`)
are kept apart by `{ext}`. The git branch/sha are read from the project root, so
a worktree publishes under its own branch.
**Stage handler:** `artifact_publish` -- copies + renames matching files, logs their URLs.
**MCP tool:** `list_artifacts(project)` -- returns sorted filenames in the directory.
### `web_serve`
Serves a build directory with `Cache-Control: no-store` so browsers always
fetch the latest build. Restarts the server each time the `serve` tool is called.
| Option | Default | Description |
|--------|---------|-------------|
| `build_dir` | `"build"` | Directory to serve (relative to project root). |
| `port` | `8100` | TCP port. |
| `bind` | `["127.0.0.1"]` | List of addresses to bind. |
**MCP tool:** `serve(project)` -- restarts the server and returns its URL(s).
### `screenshot`
Uses Playwright (Chromium, headless) to capture screenshots and run pixel-diff
regression checks. Requires `playwright` and `Pillow` to be installed.
| Option | Default | Description |
|--------|---------|-------------|
| `viewport` | `[412, 915]` | `[width, height]` in pixels. |
| `out_dir` | `"screenshots"` | Directory for captured PNGs. Baselines go in `out_dir/baselines/`. |
| `diff_threshold` | `0.01` | Fraction of pixels that may differ before a comparison is "mismatch". |
| `noise` | `30` | Per-channel delta (0-255) below which a pixel is considered unchanged. |
**MCP tools:**
- `screenshot_build(project, url, wait_seconds, width, height)` -- captures and
saves a PNG.
- `compare_screenshot(project, url, baseline_name, save_baseline, diff_threshold,
wait_seconds, width, height)` -- compares to a saved baseline; saves a diff
image on mismatch.
### `demo_deploy`
Copies a build directory into a separate git repository (e.g. a GitLab Pages
repo), optionally patches `index.html` (base href, title, description), commits
the change, and optionally pushes.
| Option | Default | Description |
|--------|---------|-------------|
| `build_dir` | `"build"` | Source directory (relative to project root). |
| `target_repo` | `""` | Absolute path to the target git repository. |
| `target_subdir` | `"."` | Subdirectory inside `target_repo` to deploy into. |
| `base_href` | none | Replaces `<base href="...">` in `index.html`. |
| `page_title` | none | Replaces `<title>` in `index.html`. |
| `page_description` | none | Replaces `<meta name="description">` in `index.html`. |
| `commit_message` | `"Deploy demo via bldr"` | Git commit message. |
| `branch` | `"main"` | Branch to push to when `push=True`. |
**Stage handler:** `demo_deploy` -- deploys without pushing (commit only).
**MCP tool:** `request_demo_deploy(project, push=False)` -- deploy on demand;
set `push=True` to also push to the remote.
> **Security note:** `artifact_http` and `web_serve` are unauthenticated static
> file servers. They default to loopback (`127.0.0.1`). Setting a non-loopback
> `bind` address (e.g. `0.0.0.0`) exposes served files to anyone on the
> network -- use that option only on trusted networks.
---
## Plugin authoring
Subclass `Capability`, implement the hooks you need, then call
`register_capability` with an optional module-level tool registrar.
```python
from bldr.plugins.base import Capability, register_capability
class MyPlugin(Capability):
name = "my_plugin"
def __init__(self, project_config, options: dict):
super().__init__(project_config, options)
# project_config.root is the absolute path to the project directory.
# options is the dict from [plugins.my_plugin] in bldr.toml.
self.port = int(options.get("port", 8080))
async def start(self) -> None:
# Start a background service (e.g. an HTTP server). Optional.
pass
async def stop(self) -> None:
# Tear down the background service. Optional.
pass
def stage_handlers(self) -> dict:
# Return a mapping of stage name -> async callable.
# The pipeline calls: await handler(registered_project, build_record)
async def my_stage(rp, rec) -> None:
rp.state.log("running my_stage")
return {"my_stage": my_stage}
def register_tools(mcp, lookup):
# MCP tools are registered once per plugin TYPE, not per project instance.
# Use lookup(project, "my_plugin") to resolve the per-project capability.
@mcp.tool()
async def my_tool(project: str) -> dict:
cap = lookup(project, "my_plugin")
if cap is None:
return {"error": f"project '{project}' has no my_plugin"}
return {"port": cap.port}
register_capability("my_plugin", MyPlugin, register_tools)
```
The `register_tools(mcp, lookup)` function is called once at daemon startup. The
`lookup(project, cap_name)` helper resolves the named capability for any
registered project. Tool names must be globally unique across all plugins.
---
## Running
```sh
# Start with one project pre-loaded
bldr --project path/to/bldr.toml
# Start with multiple projects
bldr --project proj-a/bldr.toml --project proj-b/bldr.toml
# Start empty; add projects via the add_project MCP tool at runtime
bldr
# Custom host/port (default: 127.0.0.1:7900)
bldr --host 0.0.0.0 --port 8000 --project bldr.toml
```
The `--project` flag is repeatable and optional. Projects can also be registered
and removed at runtime via the `add_project` and `remove_project` MCP tools.
### TUI monitor
`bldr-tui` is a live, interactive monitor (Textual). It polls the daemon's
`/status` endpoint and shows a multi-pane view for the selected project:
- a **status bar** (stage icon/color, current message, elapsed time, pending),
- a streaming **build log** and **web-server log**,
- a **projects/worktrees** pane (status, port, branch, commit; worktrees nested
under their parent),
- a **build-history** pane.
```sh
bldr-tui # connects to http://127.0.0.1:7900
bldr-tui --url http://host:8000 # custom daemon address
```
Keys: `j`/`k` select project/worktree, `b` build (prompts for flow), `w` new
worktree, `d` remove worktree, `r` refresh, `c` clear, `n` toggle desktop
notifications, `a` toggle browser auto-reload (reloads pages of a Chrome started
with `--remote-debugging-port=9222` when a build finishes), `R` reload now,
`q` quit. Actions are performed over MCP. Requires `textual` (a dependency);
the view refreshes every 1.5 s.
### CLI client (bldr-cli)
`bldr-cli` is a command-line client for the daemon's MCP tools: each tool is a
subcommand, positional args fill the tool's parameters in schema order,
`key=value` sets a named one, and values are JSON-coerced (numbers, booleans,
`null`, arrays, objects). Output is pretty-printed JSON; pass `-j`/`--json` for
compact single-line output.
```sh
bldr-cli --help # usage + all tools + aliases
bldr-cli tools # list tools and their parameters
bldr-cli ls # list_projects
bldr-cli st kairos # get_status kairos (pretty)
bldr-cli build kairos web # request_build project=kairos flow=web
bldr-cli request_demo_deploy kairos push=true
bldr-cli screenshot_build kairos http://127.0.0.1:8010
bldr-cli -j ls # compact JSON (scripting)
```
Aliases: `ls`=list_projects, `st`/`status`=get_status, `build`=request_build,
`add`=add_project, `rm`=remove_project. The daemon URL defaults to `$BLDR_URL`
or `http://127.0.0.1:7900/mcp` (override with `--url`).
### Status endpoint
`GET /status` on the daemon returns a JSON snapshot of all projects -- the same
data the TUI polls. Useful for scripting or health checks.
---
## Bundled example
`example-project/` exercises the full pipeline without any external toolchain:
```
example-project/
bldr.toml # build + release flows, artifact_http plugin
build.sh # copies site/ into build/
site/
index.html
```
Flows:
- `build` -- runs `clean` then `build_site` (produces `build/index.html`).
- `release` -- runs `clean`, `build_site`, then `artifact_publish` (copies the
file into `artifacts/` and serves it over HTTP on an ephemeral port).
```sh
cd example-project
bldr --project bldr.toml
# then call request_build("example-site", "release", "manual test") via MCP
```
---
## Testing
```sh
pip install -e ".[dev]"
pytest
```
70 tests covering the pipeline, registry, plugins, worktrees, CLI, TUI, and
end-to-end integration.
This server cannot be deployed
Maintenance
ActivityStale
ResponsivenessNo issues