forked from bots-garden/ori
dev-toolkit kit
A Docker Sandboxes v2 mixin kit that provisions a general-purpose development toolkit:
a code-quality engine, pinned Go / Rust / Node toolchains, and three Claude Code skills
covering code quality, documentation, and a controlled feature-development workflow.
| Tool | Version | Installed at | Notes |
|---|---|---|---|
| qlty | 0.639.0 | /usr/local/bin/qlty |
Lint + smells + metrics engine, for any language. |
| Go | 1.26.5 | /usr/local/go |
Symlinked into /usr/local/bin, shadowing the template's own /usr/bin/go. |
| TinyGo | 0.41.1 | /usr/local/tinygo |
TINYGOROOT set; for wasm, wasip1, wasip2 targets. |
| Rust | 1.97.1 | /usr/local/rustup, /usr/local/cargo |
Via rustup, minimal profile + clippy + rustfmt, with the wasm32-wasip2 target. |
| Node.js | 24.19.0 | /usr/local/node |
LTS "Krypton". Shadows the template's own /usr/bin/node (22.x). |
| pnpm | 11.22.0 | /usr/local/share/npm-global/bin |
Plus bundled corepack, for projects that pin a version via packageManager. |
| vsce / ovsx | 3.9.2 / 1.1.1 | /usr/local/share/npm-global/bin |
Package and publish VS Code extensions to the Marketplace / Open VSX. |
| gcc, g++ | Ubuntu's | /usr/bin |
The base template has no C compiler; Rust and node-gyp both need one. |
| Skill | Purpose |
|---|---|
quality |
Configures qlty, measures the project, writes a report, tracks progression, refactors until the gate passes. |
diataxis-doc |
Writes or restructures documentation with the Diátaxis four-quadrant method. |
methodical-dev |
Orchestrator. Drives a feature through requirements → plan → implementation → quality gate → bilingual docs → project memory, stopping for approval at every step. Invokes the other two. |
Plus a ~/.claude/CLAUDE.md, loaded at the start of every session, which brackets the work at
both ends: read the project's committed .memory/ record — summary, history, latest
handoff — before planning or asking the user anything, and update all three before handing
control back, unconditionally, however small the session. summary.md is edited in place and
never regenerated from one session's partial view; history.md gets exactly one appended
entry; handoffs/ gets today's file. methodical-dev (Phase 7) holds the full templates —
CLAUDE.md is what makes the same discipline apply to ad-hoc work that never invokes the skill.
Only the toolchain binaries are language-specific. qlty is not a Go tool: qlty init
detects the languages present and enables the matching linters, and all three skills are
language-agnostic. Drop the toolchain install steps from spec.yaml and the kit is a pure
quality/docs/workflow mixin for any stack. The install steps are independent of each other,
so a Go-only or Rust-only sandbox is a matter of deleting the ones you do not want — except
the C-toolchain step, which Rust and native npm addons both depend on.
Project-specific skills do not belong here. builtin-parity, which describes the
GoloScript repository in particular, lives in the sibling golo-toolkit
kit and is composed in only where it applies.
Layout
kits/dev-toolkit/
├── spec.yaml # schemaVersion "2", kind: mixin
├── README.md
└── files/home/.claude/
├── CLAUDE.md # loaded every session: read .memory/ first
└── skills/
├── quality/
│ ├── SKILL.md # the workflow the agent follows
│ └── scripts/quality_report.py # measurement, report, history, gate verdict
├── diataxis-doc/SKILL.md
└── methodical-dev/SKILL.md
files/home/** is copied into the agent's home at container start, so the skills land
under ~/.claude/skills/ and Claude Code picks them up as personal skills, and CLAUDE.md
lands at ~/.claude/CLAUDE.md — the user-level memory file, prepended to every Claude
Code session in the sandbox regardless of which project is open.
Why both a CLAUDE.md and a skill say to read .memory/. methodical-dev's Phase 0
only runs when that skill is invoked. ~/.claude/CLAUDE.md is loaded unconditionally, so an
ad-hoc request ("fix this bug") also starts by reading the project record instead of
re-deriving state that is already written down. Keep the file short — it costs tokens on
every single session; the detail belongs in the skills and in the project's own
.memory/README.md.
Everything in that tree is packed into the kit and shipped into the sandbox, so keep it to
the skills themselves — a stray __pycache__/ would be shipped alongside them. The
repository .gitignore guards against that.
Usage
# create the sandbox with the kit — `--kit` at create time, not `sbx kit add`,
# so the cache volumes are attached (see Known limitations)
sbx run claude --kit ./kits/dev-toolkit
Then, in the agent session: "run a quality report", "document this project with
Diátaxis", or "let's build this feature methodically".
Editor extensions
The two ecosystems use different toolchains, and both are provisioned:
| VS Code | Zed | |
|---|---|---|
| Language | TypeScript / JavaScript on Node | Rust compiled to WebAssembly |
| Manifest | package.json (contributes, engines.vscode) |
extension.toml + Cargo.toml |
| Build | the project's own script (tsc, esbuild) | cargo build --release --target wasm32-wasip2 |
| Package | vsce package → a .vsix |
Zed's own extension builder, run by the editor |
| Publish | vsce publish, ovsx publish |
a PR to zed-industries/extensions |
wasm32-wasip2 is preinstalled because that is the target Zed compiles extensions with;
building for anything else produces an artifact it refuses. Both flows were verified inside
the sandbox end to end — npm install → tsc → vsce package → .vsix, and
zed_extension_api → cargo build --target wasm32-wasip2 → a WASM component (layer
0100, not a core module).
What does not work here, by construction: no editor runs in the sandbox, so VS Code's F5
Extension Development Host and Zed's Install Dev Extension are host-side actions, and
@vscode/test-electron needs an X server this kit does not install. Compile, unit-test, lint
and package in the sandbox; drive the editor on the host.
Design notes
Why a mixin and not a kind: sandbox kit. The kit adds tooling to whatever agent you
are already using rather than defining its own image, so it composes: --kit it onto
claude, codex, shell, or anything else. Exactly one sandbox kit is allowed per
composition; mixins stack freely.
Why pinned tarballs instead of the official installers. curl https://qlty.sh | sh
resolves latest (so two sandboxes built a day apart differ), POSTs an install event to
cdp.customer.io, and rewrites the user's shell rc files. Every artifact here is pinned
by version and SHA256, verified with sha256sum -c before it is unpacked — the same
pattern the official trivy, vale, and mise kits use. qlty's own published
.sha256 files match the digests in spec.yaml.
Why Python unpacks the qlty archive. qlty ships only .tar.xz and the sandbox
template has no xz binary. Installing xz-utils would mean running apt-get update,
which re-fetches every configured apt source — including the template's
download.docker.com — and fails if any one of them is not allow-listed. python3 is
present, and lzma is in its standard library, so the archive is decompressed with no
extra egress at all.
Toolchain pairing is load-bearing. TinyGo 0.41.1 accepts Go 1.19–1.26 and refuses a
GOROOT newer than the Go it was itself built with (1.26.2). Go 1.26.5 satisfies both
bounds. Bumping Go to 1.27 breaks TinyGo — bump the two together. GOTOOLCHAIN=local
prevents Go from silently downloading a different toolchain behind your back.
Why a C compiler is a hard requirement, not a nicety. The base template ships make
but no cc, gcc, clang, ld or libc headers. That produces a genuinely misleading
failure: cargo build --target wasm32-wasip2 succeeds without a C compiler, because
rustc links wasm targets with its own bundled rust-lld — while cargo test, which builds
for the host, dies with error: linker `cc` not found. A Zed extension therefore appears
to build fine and cannot be unit-tested, which is the worst possible shape for a missing
dependency. The same gap breaks any crate with a C-compiling build.rs, and any npm package
with a native addon built through node-gyp. apt-get update is deliberately not run: the
template ships populated /var/lib/apt/lists, so a plain apt-get install works, whereas
update re-fetches every configured source (Docker's included) and fails if one is not
allow-listed. It stays only as a fallback for the day those lists go stale.
Why rustup rather than the standalone Rust tarballs. A pinned rustup-init binary
(digest-verified, not the curl https://sh.rustup.rs | sh bootstrapper, which resolves
latest and edits shell rc files) buys a working rustup target add — which matters because
Zed itself shells out to exactly that when an extension needs a target that is not installed.
Rust lives under /usr/local rather than in the home directory, for the same reason Go does:
the install runs once at container creation, and only paths baked into the image survive a
restart. Both RUSTUP_HOME and CARGO_HOME are left world-writable — the layout the
official rust Docker image uses — so rustup target add, rustup component add and
cargo install work as the agent user without sudo.
Why the kit does not set npm's prefix. The obvious move — npm_config_prefix=/usr/local,
so global installs land in /usr/local/bin — is wrong here. The base template already exports
NPM_CONFIG_PREFIX=/usr/local/share/npm-global, and that directory is agent-owned and
already on PATH ahead of /usr/local/bin. Overriding it would move global installs into a
root-owned tree and break npm install -g for the agent user, which currently works with no
sudo. The install step passes --prefix explicitly (rather than trusting the variable to be
exported into a root-run step) and then chowns the tree back to the agent.
--allow-scripts is not optional for vsce. npm 11 skips unrecognised lifecycle scripts
and merely warns. Two of vsce's dependencies need theirs: keytar builds the native addon
that stores a publishing PAT, and @vscode/vsce-sign unpacks the signing binary. Skipped,
they fail at vsce publish time — long after the install that caused it.
The cache volumes. Three, all of them things that are otherwise re-downloaded on a cold
start: ~/.qlty (qlty provisions a JVM for radarlint-go, Node, and each linter binary on
demand), /usr/local/cargo/registry (Cargo's .crate files and sparse index) and ~/.npm.
The Cargo one is deliberately the registry subdirectory rather than all of CARGO_HOME:
mounting an empty volume over CARGO_HOME would shadow the rustup shims in its bin. Volumes
are fixed at container creation, so sbx kit add warns and skips them — use --kit at create
time to get the caches.
Maintenance
Bumping a version. Change the version and both per-arch SHA256 values in the
matching setup.install entry in spec.yaml. Sources:
- Go — https://go.dev/dl/?mode=json (
files[].sha256) - TinyGo — compute from the release tarball; the project publishes no checksums file
- qlty — the
<asset>.tar.xz.sha256file next to each release asset - Node —
https://nodejs.org/dist/v<version>/SHASUMS256.txt. Pick from the active LTS
line;https://nodejs.org/dist/index.jsonmarks it with a non-falseltsfield. Note the
.tar.gzdigest, not.tar.xz: the template has noxz, and unlike qlty, Node publishes
both — which is why this step needs no python-lzma workaround - rustup —
https://static.rust-lang.org/rustup/archive/<ver>/<triple>/rustup-init.sha256,
with the current version athttps://static.rust-lang.org/rustup/release-stable.toml - Rust itself — no digest to update; rustup resolves and verifies the toolchain. The current
stable version is the[pkg.rust] versionin
https://static.rust-lang.org/dist/channel-rust-stable.toml - vsce / ovsx / pnpm —
npm view <pkg> version
Before bumping the Zed target. wasm32-wasip2 is not a preference — it is read from Zed's
own extension builder (RUST_TARGET in crates/extension/src/extension_builder.rs). Confirm
there before changing it; an extension built for another target is one Zed refuses to load.
Validating a change.
sbx kit validate ./kits/dev-toolkit
sbx kit inspect ./kits/dev-toolkit --json | jq '.warnings' # expect null or []
Empty warnings is the green light — a non-empty list means a v1 surface crept in that
will stop loading at the spec's Phase 6 cutover.
Install commands completed only means the commands exited 0. Verify the outcome:
sbx exec <sandbox> -- sh -lc '
go version && tinygo version && qlty --version &&
rustc --version && cargo --version && rustup target list --installed &&
node --version && npm --version && pnpm --version && vsce --version && ovsx --version &&
cc --version | head -1'
And verify the thing versions cannot tell you — that host-target Rust actually links,
which is what the C-toolchain step exists for:
sbx exec <sandbox> -- sh -lc '
cd "$(mktemp -d)" && cargo init --lib -q . && cargo test 2>&1 | tail -3'
Extending the network allowlist. permissions.network.allow covers the install downloads,
qlty's plugin/linter resolution, the Go module proxy, Cargo's three hosts (the sparse index
index.crates.io, the CDN static.crates.io, and the API crates.io — all three are needed),
Ubuntu's archives for the C toolchain, and the VS Code Marketplace / Open VSX publishing paths.
A linter for another language will need its registry added. Find out what is missing rather
than guessing:
sbx policy log <sandbox>
That prints what the proxy actually allowed and blocked. Add blocked hosts one at a time.
Known limitations
sbx kit adddoes not write mixin context. The engine gates the kit-memory write on
the artifact's own AI-profile filename, which mixins deliberately do not carry (agentInstructions.filenameis ignored for a mixin), so adding this
kit at runtime silently skipskits-memory/dev-toolkit.md. Create the sandbox with
--kitinstead. The same applies to the~/.qltyvolume.- The skills and
CLAUDE.mdare Claude Code-specific. The toolchain is not. The kit sets
norequires.agent, so it works with any agent; on non-Claude agents those files are inert
andagentInstructionscarries the same instructions — the.memory/-first rule and the
qualityscript invocation. - Only
linux/amd64andlinux/arm64are supported. Other architectures fail the
install with an explicit error rather than installing something wrong. - No editor, and no display. Nothing in this kit can run VS Code or Zed. The
Extension Development Host (F5), Zed's Install Dev Extension, and any
@vscode/test-electronrun all need a GUI session on the user's machine; there is no X
server here and the kit does not installxvfb. The sandbox covers compile, unit-test, lint
and package — write extensions so their logic is testable without thevscodemodule and
the split costs you nothing. - Tree-sitter grammars in Zed extensions are not reproducible here. Compiling a parser
needs the wasi-sdk, which Zed downloads itself, on the host. The Rust half of such an
extension builds fine; the grammar half does not happen in this sandbox. - The C toolchain is the one unpinned install. Go, TinyGo, qlty, Node and rustup are all
version-and-digest pinned;gcccomes from Ubuntu's archives at whatever version they serve
(15.2.0 at time of writing). Pinning it would mean pinning a.debset and its transitive
closure, which is a much larger commitment than it is worth for a linker driver — but it does
mean two sandboxes built months apart can differ here.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 |
|