Scaffolds custom tools for a devenv environment
README.md

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 init ran, a successful jj git push (not --dry-run) runs bug sync to the same remote (--remote, else jj's git.push, else origin), and a successful jj git fetch runs bug sync --pull from each fetched remote (--remote patterns, --all-remotes, git.fetch, origin). The exit status is jj's: a failed sync only warns. Off with devkit.bug.syncOnPush = false;, or once with DEVKIT_JJ_NO_SYNC=1.
  • Attribution guard. A command whose -m/--message/--stdin message has a Claude-Session:, Co-Authored-By: …Claude or "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 of jj/jj.
  • No git worktrees. jj workspace add is refused (exit 1, nothing created) unless it has --no-colocate: in a colocated repo the new workspace would be a git worktree, which sets extensions.relativeWorktrees in .git/config, and bug can't open the repo after that. workspace add NAME passes 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 left DEVENV_ROOT, red ≠ when the cwd holds another project's devenv.nix, ×N when shells are nested.
  • devenv — wraps the CLI and warns (does not block) when an interactive devenv shell would nest. devenv shell <cmd> and devenv up etc. are untouched. Silence the warning with DEVENV_ALLOW_NESTED=1.
  • devenv-where — prints the active root, profile, nesting depth, and whether the nearest devenv.nix is 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.yaml is 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.
  • enterShell cannot configure an interactive shell: devenv 2.4 runs it non-interactively before spawning the shell, so functions, bind, and shopt there have no effect. Put shell config in the shell's own rc file instead.