A stateless CLI that posts any repo's devcontainer to a shared cloud Host — from the command line.
billet provisions and drives an Azure VM (a Host), then starts, stops, and connects
to one or more repositories' devcontainers (Workspaces) running on it. Each repository
keeps owning its own .devcontainer/; billet owns the VM lifecycle, connectivity, and a
registry-driven dispatcher. It is decomposed by volatility (Löwy closed architecture); the
swappable HostProvider is the load-bearing seam (Azure VM today; DevPod / Dev Box later).
Stateless by design: operator intent lives in one config.toml; IP address, power state,
and the host↔workspace mapping are derived live from Azure and resource tags.
Both subsystems ship in Python. The Host subsystem drives the VM behind the
HostProvider seam (billet host up|stop|pin-ip|specs), with a dry-run plan and a confirm gate on
billable cold-create. The Workspace subsystem clones, builds, bootstraps, and connects a
repo's devcontainer on a Host (billet add|ls|doctor|start|stop|connect|ssh-config|rm), reading each
repo's .devcontainer/devcontainer.json as a read-only data contract. The Python tool now
fully replaces the original shell scripts lifted from gswa-backend, which have been removed. The
architecture is recorded in
ADR-0001 and
ADR-0002.
uv tool install git+https://github.com/rinman24/billet(PyPI publication is deferred; install from GitHub for now.)
uv tool install places the billet executable in ~/.local/bin. If your shell
reports billet: command not found, that directory is not on your PATH. Fix it with
uv's own helper, then open a new terminal:
uv tool update-shellOr add the directory to your shell profile manually (zsh shown):
echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.zshrcIf billet is still not found after opening a new terminal, your shell may not be
sourcing the file that was edited — check echo $ZDOTDIR and inspect your .zshenv,
.zprofile, or /etc/zshenv for a config-directory redirect.
Move an existing install to the latest main by re-running the install with
--force --reinstall, then check what you actually got:
uv tool install --force --reinstall git+https://github.com/rinman24/billet
billet versionuv tool upgrade billet is the shorter form and often works, but billet installs from an
unpinned git URL whose requirement string is identical from one release to the next, so
the upgrade can resolve out of uv's git cache and report success without changing
anything. --reinstall ignores that cache, and --force lets the new build replace the
existing ~/.local/bin/billet. If billet version still reports the old number, run
uv cache clean billet and install again.
mkdir -p ~/.config/billet && cp config.example.toml ~/.config/billet/config.toml
# edit config.toml: subscription, [hosts.<key>], [workspaces.<key>]
billet host up --dry-run # show the plan (cold-create / resume, auto-detected)
billet host up # create or resume the VM (cold-create asks to confirm)
billet host pin-ip # re-pin inbound SSH to your current egress IP/32
billet host specs # live CPU / memory / disk / container usage on the host
billet host stop # deallocate the VM (stops compute billing)Then run a repository's devcontainer Workspace on the Host:
billet add gswa-backend # validate the [workspaces.<key>] block
billet start gswa-backend # bring the Host up, then clone + compose up + bootstrap
billet ssh-config # write ~/.ssh/config.d/billet.conf (+ one Include line)
billet connect gswa-backend # ssh in and attach to the tmux session
billet ls # show each Workspace and whether it is running
billet doctor # report each Workspace's Berth drift (read-only, exit 0)
billet stop gswa-backend # stop the container (non-destructive)billet doctor [--host <key>] [--workspace <key>] compares each Workspace's Host checkout
with the Berth the installed billet ships: the berth.version stamp, and
dev-entrypoint.sh, sshd.conf and authorized_keys-stub by directive hash (comments and
whitespace ignored). It reads over one SSH session per Host, shows each checkout's short HEAD,
skips an unreachable Host without starting it, reports a Host whose probe outlasts 30 s as
timed out, and always exits 0
(ADR-0015). The templates ship inside the wheel, so run
it from an installed billet; an editable checkout (uv run) does not carry them.
The compose service, compose file(s), workspaceFolder, remoteUser, and
postCreateCommand are read live from each repo's .devcontainer/devcontainer.json — billet
does not duplicate them in config.toml.
Because remoteUser is read live, billet ssh-config can only render a Workspace that is
already cloned on a running Host. Any other is skipped with a warning and the remaining
aliases are still written, so one un-started Workspace never costs you connectivity to the
rest — billet start <key>, then re-run ssh-config, to pick up its alias. See
ADR-0010.
Several repos can share one VM. Give each a distinct container_ssh_port
(billet add validates per-host uniqueness), and have each repo's compose bind its sshd to
billet's assigned port — billet exports BILLET_CONTAINER_SSH_PORT before every
docker compose, so the repo publishes:
services:
<service>:
ports:
- "127.0.0.1:${BILLET_CONTAINER_SSH_PORT:-2222}:22"The :-2222 default keeps an un-adopted repo working unchanged; only the second repo onward
must parameterize its port. billet ssh-config then renders both containers behind the one
Host (ProxyJump), each with its own port and a collision-free HostKeyAlias, and the Host
still needs a single NSG rule. See
ADR-0003.
Onboarding a new repo is a checklist plus copyable template files: see
Adopting a repo as a Workspace and
templates/workspace/.
Some Hosts are managed by billet purely for their VM lifecycle and carry no Workspaces — for
example a fleet-host whose runtime is owned elsewhere. Declare such a Host with
manages_workspaces = false:
[hosts.fleet]
resource_group = "GSWA-FLEET-HOST-RG"
vm_name = "gswa-fleet-host"
location = "westus3"
admin_user = "azureuser"
vm_size = "Standard_D4s_v5"
manages_workspaces = false
# ... vm_image / public_ip_sku / os_disk_gb / storage_sku as for any hostIts VM lifecycle works like any other Host:
billet host up --host fleet --dry-run # adopt → pin → start → wait
billet host stop --host fleet # deallocateBut it registers no [workspaces.*]. The workspace verbs (add/start/stop/connect/
ssh-config) refuse a Host with manages_workspaces = false, and billet ls flags any
Workspace wrongly placed on one as INVALID rather than probing it. See
ADR-0004.
- Host — a cloud VM that runs containers.
- Workspace — a repository's devcontainer running on a Host.
- HostProvider — the backend seam that implements Host lifecycle (Azure VM today).
- Berth — the Workspace runtime contract billet publishes under
templates/workspace/(sshd on the assigned loopback port,devat uid/gid 1000, the entrypoint's behaviors), versioned independently of billet byberth.version. - Locker — one named compose volume persisting one tool's state under the login user's
home (
<service>_claude_home:/home/dev/.claude), declared only in the consumer's compose file.
The full glossary and the map of the repositories billet collaborates with are in the context map.
uv sync # create .venv and install dev tooling
make lint # ruff + pyright (strict)
make imports # import-linter layer contract
make test # pytestMIT © 2026 Rich Inman, PhD