- Shell 83%
- Nix 17%
devkit #
Shared devenv module: my personal dev tools (bug,
secrets, local-code-review) and a jujutsu toolchain.
Importing devkit keeps every project's toolchain identical and lets a single edit
here propagate to all of them. Shell-side helpers (prompt indicator, nested-shell
warning) live in fish/ and are installed once per machine.
Use it in a project #
devenv.yaml:
inputs:
nixpkgs:
url: github:cachix/devenv-nixpkgs/rolling
devkit:
url: git+https://knot.rtw.run/rtw.run/devkit
imports:
- devkit
That's the whole wiring. devkit pins the tools (bug, secrets,
local-code-review) in its own flake.lock and hands them to the project
through the devkit input, so a project declares no tool inputs of its own
and its devenv.lock records them as devkit's. (devenv#2205: an imported
input's devenv.yaml isn't evaluated, which is why the pins live in devkit's
flake, not its devenv.yaml.) A project's own devenv.nix only needs its
project-specific packages/env.
To try an unreleased tool in one project, declare that tool's input in the
project's devenv.yaml: a project-level input wins over devkit's pin. Disable
a tool with, e.g., devkit.bug.enable = false; (used by a project that ships
its own wrapper, like sovrn).
bug agent skill #
When bug is enabled, entering the shell copies the SKILL.md shipped with
the pinned bug package to ~/.agents/skills/working-with-bug/ (Claude) and
~/.agents/skills/superpowers/working-with-bug/ (OpenCode with superpowers).
The file is rewritten only when it differs, so devenv update bug followed by
the next devenv shell is all it takes to update the skill. It is a copy, not
a symlink, so garbage collection can't leave it dangling. Turn it off with
devkit.bug.skill.enable = false; or change targets with
devkit.bug.skill.dirs. Pins of bug older than v1.3.0 don't ship the
skill and are skipped.
jj: bugs sync with the code; no Claude attribution; no git worktrees #
devkit puts a jj wrapper (jj/jj) ahead of jujutsu on the shell's PATH.
Every command runs unchanged, plus:
- Bug sync. In a repo where
bug initran, a successfuljj git push(not--dry-run) runsbug syncto the same remote (--remote, else jj'sgit.push, elseorigin), and a successfuljj git fetchrunsbug sync --pullfrom each fetched remote (--remotepatterns,--all-remotes,git.fetch,origin). The exit status is jj's: a failed sync only warns. Off withdevkit.bug.syncOnPush = false;, or once withDEVKIT_JJ_NO_SYNC=1. - Attribution guard. A command whose
-m/--message/--stdinmessage has aClaude-Session:,Co-Authored-By: …Claudeor "Generated with Claude Code" line is refused before it runs (exit 1, nothing written). After commit/describe/new/split/squash/metaedit, and before a push, mutable or unpushed changes carrying one fail the command with their change ids. No opt-out; the patterns are at the top ofjj/jj. - No git worktrees.
jj workspace addis refused (exit 1, nothing created) unless it has--no-colocate: in a colocated repo the new workspace would be a git worktree, which setsextensions.relativeWorktreesin.git/config, and bug can't open the repo after that.workspace add NAMEpasses it for you.
Only inside the devenv shell; CI and other shells still need an explicit
bug sync.
devkit agent skill #
Entering the shell also installs skills/working-with-devkit/SKILL.md, the
agents' catalogue of what devkit provides, the version that added each piece,
and where shared code goes, to ~/.agents/skills/working-with-devkit/ and
~/.agents/skills/superpowers/working-with-devkit/ (rewritten only when it
differs). Claude reads ~/.claude/skills, so link it there as for
working-with-bug. Options: devkit.skill.enable, devkit.skill.dirs. The
skill describes main; add each new building block to it, with its version.
The shell exports the release as $DEVKIT_VERSION; bump version in
flake.nix with each tag.
Parallel checkouts: workspace #
Every project makes its extra checkouts the same way, as jj workspaces:
workspace add NAME [--from REV] [--slot | --no-slot] # ../<project>-NAME; prints its root
workspace list [--porcelain] # NAME SLOT ROOT
workspace remove NAME [--abandon] # stop, forget, delete
workspace check # refuse a missing or shared slot
add always passes --no-colocate: a git worktree would enable
extensions.relativeWorktrees in the shared .git/config, which bug can't
read. So a workspace has no .git (jj only; bug finds the repo from any
workspace). The new change starts on the main checkout's @-, or on
--from REV. add also links the workspace's Claude memory folder to the
main checkout's, because Claude Code keys memory by directory and doesn't
recognise a workspace without .git as the same repo.
Port slots, for projects whose dev processes listen on ports. The main
checkout is slot 0; a workspace gets the lowest free slot 1-9, stored in its
gitignored devenv.local.nix ({ devkit.workspace.slot = N; }). The project
derives every dev port from it:
devkit.workspace.slots.enable = true; # add gives slots by default
env.MYAPP_PORT = toString (config.devkit.workspace.port 8080); # 8080 + 100 × slot
and its dev command runs workspace check before starting processes. Without
slots.enable a workspace gets a slot only with --slot; --no-slot never
gets one (list shows -, and check refuses there). The shell exports
$DEVKIT_WORKSPACE_SLOT (unset without a slot). Slot ports are fixed, so
scripts, generated config files and tests outside devenv's processes agree on
them; devenv's own processes.<name>.ports.<p>.allocate picks the next free
port at evaluation instead, which can land on another port's base and differs
between runs.
Two of a project's base ports collide when they differ by 100, 200, … or 900
(8080 and 8180 meet in slot 1; 8080 and 8081 never do), and a base port must
be at most 64635. devkit.workspace.port fails evaluation above 65535. Slots only separate one project's workspaces; two projects that use the
same base port still clash.
remove refuses (exit 4) while the workspace holds work nothing else does:
uncommitted edits, or changes that no bookmark, trunk or other workspace
reaches. --abandon abandons those changes instead (a workspace stacked on
this one keeps what it builds on). Exit codes for scripts: 2 usage, 3 no such
workspace, 4 refused, 5 already exists, 6 no free slot. devkit.workspace.enable = false; leaves it out.
Delegating to OpenCode subagents: delegate (opt-in, beta) #
devkit.delegate.enable = true; # the only setting needed; the rest have defaults:
devkit.delegate = {
check = "just test"; # preflight on trunk, the subagent, and land; "" = none
model = "opencode-go/deepseek-v4.1-flash"; # always passed, and verified
timeout = "20m"; # per run
trunk = "main"; # local bookmark tasks start on and land moves
sandbox = {
enable = true;
extraArgs = [ ]; # more bwrap arguments, one per item
};
postLand = {
fix = ""; # e.g. "just update-deps-hash"; folded into the landed change
check = ""; # e.g. "just packages"; what the sandbox can't run
};
};
A coordinating agent (Claude, in the main checkout) splits work into small tasks and hands each one to an OpenCode subagent in its own workspace, then reviews the results and lands the accepted ones on trunk one at a time, each task as one change:
delegate start SLUG [--on REV] [--slot] [--no-preflight] < spec.md # workspace t-SLUG; prints its root
delegate run SLUG [--model M] [--timeout T] [< fix.md] # one fresh session = one round; report on stdout
delegate status [--files] # tasks, rounds, behind/conflicted, file counts, overlaps
delegate diff SLUG [--last] [--stat|--summary|--name-only] [[--] PATH...]
delegate report SLUG [--round N] # a round's report, kept in the repo
delegate land SLUG -m MSG # rebase, check, squash, move the bookmark, post-land
delegate drop SLUG
delegate commit -m MSG [PATH...] # commit your own working copy on trunk
delegate preflight [--force] # does the check pass on trunk in the sandbox?
delegate keeps no state of its own. A task is a t-SLUG workspace made by
workspace; its root change holds only the spec as its description, ending
with a Delegate-Task: t-SLUG trailer; each round is a change of its own on
top, whose description keeps the round's fix and the subagent's report; a
running task holds a flock in $XDG_RUNTIME_DIR/delegate. land rebases
onto the local trunk bookmark (devkit.delegate.trunk, default main, not
trunk(), which usually tracks the remote), runs the check, squashes the
spec and every round into one change with your message, moves the bookmark
(it never pushes), moves your working copy along when it's an empty change
on the old trunk, runs postLand.fix there and folds its changes into the
landed change, rebases the other idle tasks, removes the workspace and runs
postLand.check last. The post-land steps run in the main checkout outside
the sandbox, because only it has the git repo nix needs. Your working copy
must be directly on trunk; edits in it are carried along (rebased, never
landed) unless they touch the task's files, in which case land with
post-land steps refuses up front. Only the files the fix changes are
folded. A round is always sealed with the subagent's last message as its
report, even when delegate is interrupted (or, if it was killed outright,
at the next run). Edits you make by hand in a task's workspace between
rounds become a change of their own (edits by hand between rounds, with a
Delegate-Edits: t-SLUG trailer) at the next run, so the round lists
only the subagent's changes; land squashes them in with the rest. The settings are baked into the shell's delegate;
when the project's devenv files changed since, it re-runs itself in a fresh
devenv shell. A round that changes what the devenv shell is built from
(a .nix file, devenv.yaml, devenv.lock, flake.lock) is followed by a
check that the task's shell still evaluates (the subagent's own shell
predates its edits); if it doesn't, the round says Shell broken:, and the
next round runs without the shell, told to fix it first. Failure output
(a check, a broken shell, a post-land step) is shown as its last 30 lines,
without colour codes, with the path of the full log. File lists show
renames as R old -> new, and overlaps count both paths. Exit codes: 1
error, 2 usage, 3 conflicts or no such task, 4 check failed or the task's
devenv shell doesn't evaluate, 5 the check fails on trunk itself
in the sandbox, 6 landed but a post-land step failed or couldn't run, 75
running, 124 timed out, 130/143 interrupted.
Preflight. The first start on a trunk commit runs the check in the new
task's workspace before anything else, and refuses (exit 5, workspace
removed) when it fails there: no task could pass it. The result is cached
per trunk commit and configuration; a land records a pass for the commit it
makes, so the next start needn't run it. --no-preflight is for a task
that's meant to fix the failure.
Each round runs opencode run --agent delegate --auto --standalone with the
spec, fixed rules and the fix on stdin, in the task workspace's
devenv shell, inside bwrap (devkit.delegate.sandbox.enable, on by
default). The sandbox is a guardrail against a subagent writing outside its
task or touching the repo, not a way to hide context: the filesystem is
read-only except the workspace, a private /tmp and the shared language
caches; the projects directory (the main checkout, sibling projects) is
readable; $HOME is hidden except OpenCode's config, data, state and cache
and those caches; the repo's other workspaces are hidden; and .jj/.git
in the workspace and the main checkout are empty directories, so jj and git
find no repository even through sh -c. The network stays open for the
model. --standalone matters: plain opencode run is a client, and its
tools would run in the background server, outside the sandbox. Add binds a
build needs with devkit.delegate.sandbox.extraArgs.
The language caches are fixed, for Go, Gleam, JavaScript (pnpm) and
ReScript: go env GOMODCACHE/GOCACHE (exported into the sandbox, so
every task shares the coordinator's caches even where languages.go puts
GOPATH inside each checkout), $XDG_CACHE_HOME/gleam, …/rebar3,
…/pnpm, …/node/corepack and $XDG_DATA_HOME/pnpm/store, each bound
writable when it exists.
Before every run, delegate installs (or refreshes) its OpenCode agent at
~/.config/opencode/agents/delegate.md, so --agent delegate is always
devkit's definition. Its permissions deny jj, git, curl, wget, web fetch and
search, sub-agents and questions (so nothing waits for an answer). Those
rules are a guardrail; the sandbox is the boundary.
The model is always explicit. Every run passes --model
(delegate run --model, else devkit.delegate.model) and never falls back
to OpenCode's default, which OpenCode may change. After the run, delegate
reads the session back and fails (exit 1) if any reply came from another
model; the round's changes are kept (its description says Untrusted:),
but treat them as untrusted.
Tests: bash delegate/test.
SSH with a security key (opt-in) #
devkit.ssh.enable = true; # in the project's devenv.nix
adds three tools that hold one authenticated SSH master per host, so a
YubiKey-backed host needs one touch, and every later ssh, scp, rsync or
git to it reuses the connection:
ssh-open [-t TTL] HOST... # open (jump hosts first), then show status
ssh-status [HOST...] # exit 1 if any is closed
ssh-close [HOST...] # no HOST: everything ssh-open opened
Hosts need ControlMaster auto and ControlPath ~/.ssh/cm-%C in
~/.ssh/config; -t overrides ControlPersist (yes = until closed).
Without a terminal (an agent's shell), ssh-open uses ssh-touch-notify as
SSH_ASKPASS, so the touch request shows as a desktop notification and an
agent can open the session itself. A PIN prompt can't be answered that way: it
goes to $DEVKIT_SSH_ASKPASS or a known graphical askpass, else fails. The
tools use the system's ssh, not a pinned one.
Updating #
A tool release: bump it here, tag devkit, then in each project
devenv update devkit # moves every tool to devkit's pins
and commit devenv.lock.
For live, unpinned edits while iterating on devkit, temporarily point devkit.url
at a sibling clone (url: path:../devkit); edits then take effect on the next
devenv shell with no update (and the update check below is off).
The update check #
On shell entry devkit compares the release a project pins (its version in
flake.nix) with the newest vX.Y.Z tag, and when a newer one is out, says so:
devkit: you have v1.6.0, v1.7.1 is available: run `devenv update devkit` and commit devenv.lock
Releases bump version first and are tagged once ready, so a pin between the
two (a pre-release) is quiet until its release is tagged, then hears:
devkit: you have a v1.7.0 pre-release (3b5d0937), v1.7.0 is released: run `devenv update devkit` and commit devenv.lock
Commits on main past the newest release don't prompt. Shell entry never
waits on the network: it reads the tags as last seen
(~/.cache/devkit/releases, a <sha> <version> table shared by every
project), and when that is over a day old a detached job asks the knot again
(one git ls-remote, one at a time, 15 s at most) for the next shell. Offline
it says nothing. It's said once per shell: a shell nested in one that said it
(and every shell delegate starts) stays quiet.
Turn it off with
devkit.updateCheck.enable = false;; it is off by itself for a devkit input
without a revision (a local path: checkout).
New project scaffold #
devenv init has no template flag (devenv #1718); its --from flag only
affects devenv shell/test/build. Use the Nix flake template instead:
mkdir ~/projects/newthing && cd ~/projects/newthing
nix flake init --refresh -t git+https://knot.rtw.run/rtw.run/devkit
(Local clone: nix flake init -t path:/home/btburke/projects/devkit.)
--refresh picks up the latest template commit; without it nix reuses the
cached branch ref for tarball-ttl (default 1h). proj init always passes it.
Fish helpers #
Locally, fish pins the shell devenv launches so a stale $SHELL (editor or
tmux pane) cannot drop you into another shell:
# ~/.config/fish/config.fish
set -gx DEVENV_SHELL_TYPE fish
This stays out of the templated devenv.yaml on purpose: CI runners have no
fish and should keep using bash.
Three functions in fish/functions/ make the active environment visible:
fish_right_prompt—[devenv:<project>]next to the prompt while a devenv shell is active. Yellow!when the cwd has leftDEVENV_ROOT, red≠when the cwd holds another project'sdevenv.nix,×Nwhen shells are nested.devenv— wraps the CLI and warns (does not block) when an interactivedevenv shellwould nest.devenv shell <cmd>anddevenv upetc. are untouched. Silence the warning withDEVENV_ALLOW_NESTED=1.devenv-where— prints the active root, profile, nesting depth, and whether the nearestdevenv.nixis the active one.
Install by symlinking into fish's autoload directory (or wire them into your dotfiles):
mkdir -p ~/.config/fish/functions
for f in fish_right_prompt devenv devenv-where; do
ln -sf ~/projects/devkit/fish/functions/$f.fish ~/.config/fish/functions/$f.fish
done
proj init also scaffolds projects; see below.
Scaffolding with proj #
fish/functions/proj.fish defines a proj function wrapping the scaffold:
proj init # scaffold in the current directory
proj init newthing # mkdir, cd, then scaffold
Install by symlinking into fish's autoload directory (or wire it into your dotfiles):
mkdir -p ~/.config/fish/functions
ln -sf ~/projects/devkit/fish/functions/proj.fish ~/.config/fish/functions/proj.fish
Gotchas #
- Not for the tool repos themselves (
bug,secrets,local-code-review): importing devkit there would be circular. - devkit's own
devenv.yamlis only for standalone use in this directory; it is ignored when devkit is imported (devenv#2205). It takes the tools from this flake (devkit: path:.), as a project does. enterShellcannot configure an interactive shell: devenv 2.4 runs it non-interactively before spawning the shell, so functions,bind, andshoptthere have no effect. Put shell config in the shell's own rc file instead.