Something went wrong. Try again.
Scaffolds custom tools for a devenv environment
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397# devkit — shared devenv module: personal tools and a jujutsu toolchain.## A project imports it from its devenv.yaml, and declares nothing else of it:## inputs:# devkit:# url: git+https://knot.rtw.run/rtw.run/devkit# imports:# - devkit## The tools (bug, secrets, local-code-review) are pinned in devkit's own# flake.lock and reach the project through the devkit input, so one# `devenv update devkit` moves them all. A project that declares a tool's# input itself gets that one instead (to try an unreleased version); disable a# tool with e.g. `devkit.bug.enable = false` (a project with its own wrapper).## On shell entry devkit says when a newer devkit release is tagged than the# one the project pins (devkit.updateCheck); it never waits on the network. It also# installs the agent skills (bug's, and working-with-devkit, which says what# devkit provides), and wraps jj: `jj git push`/`fetch` also sync bugs# (devkit.bug.syncOnPush), and messages with Claude attribution are refused# (always). `workspace` makes, lists and removes parallel jj checkouts, each# with an optional dev port slot (devkit.workspace.*). Opt-in building# blocks: `devkit.ssh.enable` (ssh-open, ssh-status, ssh-close for# security-key hosts), `devkit.delegate.enable` (hand small tasks to# sandboxed OpenCode subagents, each in a workspace, and land them).{ pkgs, lib, config, inputs, ... }:
let cfg = config.devkit; system = pkgs.stdenv.hostPlatform.system; # A tool comes from the project's own input when it declares one (an # override), else from devkit's flake.lock (inputs.devkit, flake: true). pinned = (inputs.devkit or { }).packages.${system} or { }; tool = name: attr: if inputs ? ${name} then inputs.${name}.packages.${system}.${attr} or null else pinned.${name} or null; hasTool = name: attr: tool name attr != null; bugPkg = tool "bug" "bug";
# The update check, said once per shell. It compares release numbers: the # pinned devkit's release (its tag when the pinned rev is tagged, else its # lib.version, known at evaluation) with the newest vX.Y.Z tag on the knot. Shell entry never waits on the network: it # reads the tags as last seen (~/.cache/devkit/releases, shared by every # project, one "<sha>\t<version>" line per tag plus "<sha>\tmain"), and when # that is over a day old a detached job asks the knot again for the next # shell. Offline, it says nothing. A stale cache can only lack releases, so # it never claims an update the pin is already past. # # Releases are made by bumping version first and tagging later, so an # untagged pin whose version equals the newest tag is a pre-release of it, # and is told the release is out. locked = (inputs.devkit or { }).rev or ""; refresh = pkgs.writeShellScript "devkit-refresh-releases" '' cache=$1 url=$2 ${pkgs.coreutils}/bin/mkdir -p "$(${pkgs.coreutils}/bin/dirname "$cache")" # One refresh at a time: shells entered while one runs leave it be. exec 9>"$cache.lock" ${pkgs.util-linux}/bin/flock -n 9 || exit 0 refs="$(GIT_TERMINAL_PROMPT=0 ${pkgs.coreutils}/bin/timeout 15 ${pkgs.git}/bin/git ls-remote "$url" refs/heads/main 'refs/tags/v*')" || exit 0 # An annotated tag's peeled line (^{}) follows it and names the commit, so # the last line for a version wins. Only plain X.Y.Z tags are releases. printf '%s\n' "$refs" | ${pkgs.gawk}/bin/awk -F'\t' ' $2 == "refs/heads/main" { main = $1; next } { v = $2; sub(/^refs\/tags\/v/, "", v); sub(/\^\{\}$/, "", v) if (v ~ /^[0-9]+\.[0-9]+\.[0-9]+$/) { if (!(v in sha)) order[++n] = v; sha[v] = $1 } } END { for (i = 1; i <= n; i++) print sha[order[i]] "\t" order[i] if (main != "") print main "\tmain" }' >"$cache.tmp" [ -s "$cache.tmp" ] && ${pkgs.coreutils}/bin/mv "$cache.tmp" "$cache" ''; updateCheck = '' _devkit_cache="''${XDG_CACHE_HOME:-$HOME/.cache}/devkit/releases" if [ -z "$(${pkgs.findutils}/bin/find "$_devkit_cache" -mmin -1440 2>/dev/null)" ]; then ${pkgs.util-linux}/bin/setsid -f ${refresh} "$_devkit_cache" ${lib.escapeShellArg cfg.updateCheck.url} </dev/null >/dev/null 2>&1 fi _devkit_latest="$(${pkgs.gawk}/bin/awk -F'\t' '$2 != "main" { print $2 }' "$_devkit_cache" 2>/dev/null | ${pkgs.coreutils}/bin/sort -V | ${pkgs.coreutils}/bin/tail -n1)" # A tagged pin is that release, whatever its flake.nix says (a tag can # land on a commit whose version wasn't bumped). _devkit_released="$(${pkgs.gawk}/bin/awk -F'\t' -v s=${lib.escapeShellArg locked} '$1 == s && $2 != "main" { print $2 }' "$_devkit_cache" 2>/dev/null | ${pkgs.coreutils}/bin/sort -V | ${pkgs.coreutils}/bin/tail -n1)" _devkit_msg="" if [ -z "$_devkit_latest" ]; then : elif [ -n "$_devkit_released" ]; then [ "$_devkit_released" = "$_devkit_latest" ] \ || _devkit_msg="you have v$_devkit_released, v$_devkit_latest is available" else # Untagged: a pre-release of its flake.nix version. _devkit_have=${lib.escapeShellArg version} if [ "$_devkit_have" = "$_devkit_latest" ]; then _devkit_msg="you have a v$_devkit_have pre-release (${builtins.substring 0 8 locked}), v$_devkit_latest is released" elif [ "$(printf '%s\n%s\n' "$_devkit_have" "$_devkit_latest" | ${pkgs.coreutils}/bin/sort -V | ${pkgs.coreutils}/bin/tail -n1)" = "$_devkit_latest" ]; then _devkit_msg="you have a v$_devkit_have pre-release (${builtins.substring 0 8 locked}), v$_devkit_latest is available" fi # else it is ahead of every release (a pre-release of the next): quiet. fi # Once per shell: nested shells inherit the export and stay quiet, and so # do the shells delegate starts for tasks and checks (it sets "nested"). if [ -n "$_devkit_msg" ] \ && [ "''${DEVKIT_UPDATE_WARNED:-}" != "$_devkit_latest-${locked}" ] \ && [ "''${DEVKIT_UPDATE_WARNED:-}" != nested ]; then echo "devkit: $_devkit_msg: run \`devenv update devkit\` and commit devenv.lock" >&2 export DEVKIT_UPDATE_WARNED="$_devkit_latest-${locked}" fi unset _devkit_cache _devkit_have _devkit_latest _devkit_released _devkit_msg ''; # This devkit's release, from flake.nix (the one place to bump it). Projects # see it as $DEVKIT_VERSION (unset before v1.3.0). version = (inputs.devkit or { }).lib.version or "unknown";
# Shipped by bug's flake since v1.3.0; older pins have no skill to install bugSkill = "${bugPkg}/share/bug/skills/working-with-bug/SKILL.md"; devkitSkill = ./skills/working-with-devkit/SKILL.md;
# Copy a SKILL.md into each skill dir, rewriting it only when it differs. # Copies (not symlinks) so a garbage-collected store path never leaves a # dangling skill. installSkill = name: src: dirs: '' if [ -f "${src}" ]; then for _skill_dir in ${lib.concatMapStringsSep " " (d: "\"${d}\"") dirs}; do if ! ${pkgs.diffutils}/bin/cmp -s "${src}" "$_skill_dir/SKILL.md"; then ${pkgs.coreutils}/bin/install -Dm644 "${src}" "$_skill_dir/SKILL.md" \ && echo "devkit: installed ${name} agent skill to $_skill_dir" fi done unset _skill_dir fi '';
# devkit's own small tools, one script each under ./<dir>/<name>, checked # by shellcheck at build. script = dir: name: deps: { preamble ? "" }: pkgs.writeShellApplication { inherit name; runtimeInputs = [ pkgs.coreutils pkgs.gawk pkgs.gnugrep pkgs.gnused ] ++ deps; text = preamble + builtins.readFile (./. + "/${dir}/${name}"); };
# ssh-open / ssh-status / ssh-close / ssh-touch-notify (./ssh). They use # the system's ssh and ~/.ssh/config, not a pinned openssh. sshTool = name: deps: script "ssh" name deps { }; sshTools = let notify = sshTool "ssh-touch-notify" [ ]; status = sshTool "ssh-status" [ ]; in pkgs.symlinkJoin { name = "devkit-ssh-${version}"; paths = [ notify status (sshTool "ssh-open" [ notify status ]) (sshTool "ssh-close" [ ]) ]; };
# jj wrapper (./jj/jj): the real jj, plus bug sync after push/fetch and # the attribution guard. Always installed (the guard has no opt-out); # syncOnPush = false only bakes in DEVKIT_JJ_NO_SYNC. High priority so it # wins bin/jj over pkgs.jujutsu in the profile. # workspace (./workspace/workspace): add/list/remove jj workspaces, always # --no-colocate, each with an optional dev port slot. It runs the jj on # PATH (the wrapper above), so it has no jj of its own. workspaceTool = script "workspace" "workspace" [ ] { preamble = lib.optionalString cfg.workspace.slots.enable '' DEVKIT_WS_SLOTS=1 ''; }; # A checkout's slot when its devenv.local.nix sets none: 0 for the one that # holds the repo (.jj/repo is a directory there), none for other jj # workspaces (.jj/repo is a file). `workspace` decides the same way. jjRepo = "${config.devenv.root}/.jj/repo"; defaultSlot = if builtins.pathExists jjRepo && builtins.readFileType jjRepo == "regular" then null else 0; slot = cfg.workspace.slot;
# delegate (./delegate/delegate): OpenCode subagents in t-* workspaces, # sandboxed with bwrap. Builds on `workspace` (always on its PATH) and the # jj on PATH; opencode is the system's. The options are baked in as # defaults the environment can override. It installs its OpenCode agent # (./delegate/delegate.md) itself before every run. dcfg = cfg.delegate; bake = var: value: '' [ -n "''${${var}+x}" ] || ${var}=${lib.escapeShellArg value} ''; # What the baked settings came from: delegate compares it with the files # at run time and re-runs itself in a fresh devenv shell when they changed # (sha256 of each file's sha256, or "-", one per line). delegateConfigHash = builtins.hashString "sha256" (lib.concatMapStrings (f: let p = "${config.devenv.root}/${f}"; in (if builtins.pathExists p then builtins.hashFile "sha256" p else "-") + "\n") [ "devenv.nix" "devenv.yaml" "devenv.local.nix" "devenv.lock" ]); delegateTool = script "delegate" "delegate" [ workspaceTool pkgs.util-linux pkgs.jq pkgs.bubblewrap pkgs.diffutils ] { preamble = lib.concatStrings [ (bake "DELEGATE_TRUNK" dcfg.trunk) (bake "DELEGATE_CHECK" dcfg.check) (bake "DELEGATE_MODEL" dcfg.model) (bake "DELEGATE_TIMEOUT" dcfg.timeout) (bake "DELEGATE_SANDBOX" (if dcfg.sandbox.enable then "1" else "0")) (bake "DELEGATE_SANDBOX_EXTRA" (lib.concatStringsSep "\n" dcfg.sandbox.extraArgs)) (bake "DELEGATE_POST_FIX" dcfg.postLand.fix) (bake "DELEGATE_POST_CHECK" dcfg.postLand.check) (bake "DELEGATE_CONFIG_HASH" delegateConfigHash) (bake "DELEGATE_AGENT" "${./delegate/delegate.md}") ]; };
jjWrapper = lib.hiPrio (script "jj" "jj" [ pkgs.git ] { preamble = '' : "''${DEVKIT_REAL_JJ:=${pkgs.jujutsu}/bin/jj}" '' + lib.optionalString (!cfg.bug.syncOnPush) '' DEVKIT_JJ_NO_SYNC=1 ''; });in
{ options.devkit = { enable = lib.mkOption { type = lib.types.bool; default = true; description = "Add the devkit toolchain."; }; bug.enable = lib.mkOption { type = lib.types.bool; default = hasTool "bug" "bug"; description = "Add the `bug` CLI (set false if the project ships its own wrapper)."; }; bug.skill.enable = lib.mkOption { type = lib.types.bool; default = cfg.bug.enable; description = "Install bug's agent skill (SKILL.md) for Claude and OpenCode on shell entry."; }; bug.skill.dirs = lib.mkOption { type = lib.types.listOf lib.types.str; default = [ "$HOME/.agents/skills/working-with-bug" "$HOME/.agents/skills/superpowers/working-with-bug" ]; description = "Directories that receive bug's SKILL.md (shell variables are expanded)."; }; skill.enable = lib.mkOption { type = lib.types.bool; default = true; description = "Install the working-with-devkit agent skill (what devkit provides, how to use it) on shell entry."; }; skill.dirs = lib.mkOption { type = lib.types.listOf lib.types.str; default = [ "$HOME/.agents/skills/working-with-devkit" "$HOME/.agents/skills/superpowers/working-with-devkit" ]; description = "Directories that receive devkit's SKILL.md (shell variables are expanded)."; }; ssh.enable = lib.mkOption { type = lib.types.bool; default = false; description = "Add ssh-open, ssh-status and ssh-close: hold one authenticated SSH master per host (one security-key touch) for later commands to reuse."; }; # Opt in with `devkit.delegate.enable = true;`; the rest has defaults. delegate = { enable = lib.mkOption { type = lib.types.bool; default = false; description = "Add `delegate` (beta: start, run, status, diff, report, land, drop, commit, preflight): hand small tasks to OpenCode subagents, each in its own t-* workspace and a bwrap sandbox, and land each accepted task on trunk as one change."; }; check = lib.mkOption { type = lib.types.str; default = "just test"; description = "The project's verification command, run in the task's workspace (devenv shell, sandbox): once per trunk commit before tasks start (`delegate start` refuses when it fails on trunk), by the subagent, and by `delegate land`, which refuses when it fails. Empty: no check."; }; model = lib.mkOption { type = lib.types.strMatching "[^/#]+/[^#]+(#[^#]+)?"; default = "opencode-go/deepseek-v4.1-flash"; description = "The subagent's model (provider/model#variant). Always passed to `opencode run`, never left to OpenCode's default, and checked against the model that answered (a mismatch fails the run). `delegate run --model` overrides it for one round."; }; timeout = lib.mkOption { type = lib.types.str; default = "20m"; description = "Default time limit of one `delegate run` (a timeout(1) duration)."; }; trunk = lib.mkOption { type = lib.types.str; default = "main"; description = "The local bookmark tasks start on and `delegate land` moves (never pushed)."; }; sandbox = { enable = lib.mkOption { type = lib.types.bool; default = true; description = "Run the subagent and the check in bwrap: read-only filesystem except the task's workspace, a private /tmp and the shared Go/Gleam/rebar3/pnpm/corepack caches; the projects dir (main checkout, siblings) readable; $HOME hidden except OpenCode's dirs and those caches; other workspaces and every .jj/.git hidden, so no repository. The network stays open."; }; extraArgs = lib.mkOption { type = lib.types.listOf lib.types.str; default = [ ]; example = [ "--ro-bind" "/home/me/.cargo" "/home/me/.cargo" ]; description = "More bwrap arguments, one list item per argument (paths may hold spaces), e.g. a cache or toolchain dir under $HOME the project's build needs."; }; }; postLand = { fix = lib.mkOption { type = lib.types.str; default = ""; example = "just update-deps-hash"; description = "Run by `delegate land` after trunk moved, in the main checkout (its devenv shell, no sandbox: it has the git repo nix needs), with the coordinator's working copy an empty change on the landed one. What it changes is folded into the landed change, so it builds on its own. Empty: none."; }; check = lib.mkOption { type = lib.types.str; default = ""; example = "just packages"; description = "Run by `delegate land` last, in the main checkout like `fix`: work the sandbox can't do (nix builds). A failure exits 6: trunk has moved and needs a fix. Empty: none."; }; }; }; workspace.enable = lib.mkOption { type = lib.types.bool; default = true; description = "Add `workspace` (add, list, remove, check): parallel jj checkouts at ../<project>-NAME, never colocated."; }; workspace.slots.enable = lib.mkOption { type = lib.types.bool; default = false; description = "`workspace add` gives each new workspace a dev port slot (1-9) unless --no-slot. Set it in projects that run dev processes; without it, only `--slot` gives one."; }; workspace.slot = lib.mkOption { type = lib.types.nullOr (lib.types.ints.between 0 9); default = defaultSlot; defaultText = lib.literalMD "0 in the checkout that holds the repo, else null"; description = "This checkout's dev port slot, null for none. `workspace add` sets it in the workspace's devenv.local.nix; don't set it in devenv.nix. Exported as $DEVKIT_WORKSPACE_SLOT (unset when null)."; }; workspace.port = lib.mkOption { type = lib.types.functionTo lib.types.port; readOnly = true; default = base: let p = base + 100 * (if slot == null then 0 else slot); in assert lib.assertMsg (p <= 65535) "devkit.workspace.port ${toString base}: ${toString p} is above 65535 in slot ${toString slot}"; p; defaultText = lib.literalMD "`base: base + 100 * slot`"; description = '' This checkout's port for a dev port whose slot-0 value is `base`: `base + 100 * slot` (slot null counts as 0; `workspace check` refuses to start processes there). Use it for every dev port, e.g. `env.MYAPP_PORT = toString (config.devkit.workspace.port 8080);`. ''; }; bug.syncOnPush = lib.mkOption { type = lib.types.bool; default = cfg.bug.enable; description = "After a successful `jj git push` / `jj git fetch` in a repo with bug set up, run `bug sync` / `bug sync --pull` with the same remotes (set false for a remote that doesn't take bug refs; jj stays wrapped for the attribution guard)."; }; updateCheck.enable = lib.mkOption { type = lib.types.bool; default = (inputs.devkit or { }) ? rev; description = "On shell entry, say when a devkit release newer than the one this project pins is tagged (checked once a day, never blocks)."; }; updateCheck.url = lib.mkOption { type = lib.types.str; default = "https://knot.rtw.run/rtw.run/devkit"; description = "Where devkit's release tags are read from."; }; local-code-review.enable = lib.mkOption { type = lib.types.bool; default = hasTool "local-code-review" "default"; description = "Add the `lcr` CLI."; }; secrets.enable = lib.mkOption { type = lib.types.bool; default = hasTool "secrets" "default"; description = "Add the `secrets` CLI."; }; };
config = lib.mkIf cfg.enable { packages = [ # Toolchain shared by every project. pkgs.jujutsu ] # tools; the inputs are declared by the consumer (see header), so only add # each one when it is present and not explicitly disabled. ++ lib.optional cfg.bug.enable bugPkg ++ lib.optional cfg.local-code-review.enable (tool "local-code-review" "default") ++ lib.optional cfg.secrets.enable (tool "secrets" "default") ++ [ jjWrapper ] ++ lib.optional cfg.ssh.enable sshTools ++ lib.optional cfg.workspace.enable workspaceTool ++ lib.optional dcfg.enable delegateTool;
# (optionalAttrs, not mkIf: env is lazy, so mkIf false would leave the # name in place and fail when the shell exports it.) env = { DEVKIT_VERSION = version; } // lib.optionalAttrs (slot != null) { DEVKIT_WORKSPACE_SLOT = toString slot; };
# Keep the agent skills in step with the pinned devkit and bug. enterShell = lib.mkMerge [ (lib.mkIf cfg.updateCheck.enable updateCheck) (lib.mkIf cfg.bug.skill.enable (installSkill "bug" bugSkill cfg.bug.skill.dirs)) (lib.mkIf cfg.skill.enable (installSkill "devkit" devkitSkill cfg.skill.dirs)) ]; };}