forked from bots-garden/ori
| 🎉 Begin a project. | 1 | schemaVersion: "2" |
| 2 | kind: mixin | |
| 3 | name: dev-toolkit | |
| 4 | displayName: Development toolkit (Go + Rust + Node + qlty + skills) | |
| 5 | description: > | |
| 6 | A general-purpose development toolkit. Installs the qlty 0.639.0 code-quality | |
| 7 | engine, which analyses whatever languages a project actually uses, plus three | |
| 8 | pinned language toolchains: Go 1.26.5 with TinyGo 0.41.1 for WebAssembly / WASI | |
| 9 | builds, Rust 1.97.1 via rustup with the `wasm32-wasip2` target (what Zed | |
| 10 | compiles extensions to), and Node.js 24.19.0 with npm, pnpm and the | |
| 11 | `@vscode/vsce` / `ovsx` extension packagers. Ships four Claude Code skills: | |
| 12 | `quality` (self-configures qlty, measures the project, writes a Markdown report, | |
| 13 | tracks progression across runs, and drives refactoring until the quality gate | |
| 14 | passes), `diataxis-doc` (documents a project with the Diátaxis four-quadrant | |
| 15 | method), `methodical-dev` (step-by-step, approval-gated feature development | |
| 16 | that orchestrates the other two, and keeps a committed `.memory/` project | |
| 17 | record), plus a `~/.claude/CLAUDE.md` that makes every session start by reading | |
| 18 | that record, and `turbo-new-editor` (builds a complete terminal IDE for a new | |
| 19 | language on top of the `turbo-core` library, inside `methodical-dev`). Only the | |
| 20 | toolchain binaries are language-specific; the quality, documentation and | |
| 21 | workflow skills are not. | |
| 22 | licenses: | |
| 23 | - MIT | |
| 24 | ||
| 25 | # Deliberately no `requires.agent`: the toolchain is agent-agnostic. The skills | |
| 26 | # under files/home/.claude/skills/ and files/home/.claude/CLAUDE.md are | |
| 27 | # Claude-Code-specific and are simply inert on other agents, which still get the | |
| 28 | # quality workflow and the `.memory/` convention through `agentInstructions` below. | |
| 29 | ||
| 30 | permissions: | |
| 31 | network: | |
| 32 | allow: | |
| 33 | # ---- install-time, one-shot ------------------------------------------- | |
| 34 | # Go toolchain tarball. dl.google.com is the canonical host go.dev/dl | |
| 35 | # redirects to; hitting it directly keeps the allowlist to one entry. | |
| 36 | - dl.google.com | |
| 37 | # TinyGo release tarball. github.com 302-redirects binary downloads to | |
| 38 | # release-assets.githubusercontent.com (covered by the wildcard below). | |
| 39 | - github.com | |
| 40 | # Matches exactly one DNS label, so this single entry covers | |
| 41 | # release-assets. / objects. / raw.githubusercontent.com. | |
| 42 | - "*.githubusercontent.com" | |
| 43 | # qlty CLI tarball (and `qlty upgrade`, if ever run by hand). | |
| 44 | - qlty-releases.s3.amazonaws.com | |
| 45 | # The C/C++ toolchain comes from Ubuntu's own archives. ports.* serves | |
| 46 | # arm64, archive.*/security.* serve amd64 — which one is used depends on the | |
| 47 | # sandbox architecture, so all three are listed. | |
| 48 | - ports.ubuntu.com | |
| 49 | - archive.ubuntu.com | |
| 50 | - security.ubuntu.com | |
| 51 | # Only reached if the cached apt lists ever go stale and the fallback | |
| 52 | # `apt-get update` runs: it re-fetches *every* configured source, and the | |
| 53 | # template configures Docker's as well as Ubuntu's. | |
| 54 | - download.docker.com | |
| 55 | # `rustup-init`, plus every toolchain component rustup resolves. One host | |
| 56 | # serves both the rustup releases and the dist channel, so this covers the | |
| 57 | # install *and* later `rustup target add` / `rustup component add`. | |
| 58 | - static.rust-lang.org | |
| 59 | # The Node.js tarball comes from nodejs.org, already listed under "qlty | |
| 60 | # runtime" below — qlty provisions its own Node for the JS/TS linters, so the | |
| 61 | # host is needed either way. | |
| 62 | ||
| 63 | # ---- qlty runtime ------------------------------------------------------ | |
| 64 | # qlty resolves its default plugin source (qltysh/qlty-plugins) and every | |
| 65 | # linter release through the GitHub API, its own GitHub proxy, and | |
| 66 | # codeload for repository tarballs. | |
| 67 | - api.github.com | |
| 68 | - github-proxy.qlty.sh | |
| 69 | - codeload.github.com | |
| 70 | # radarlint-go — enabled by `qlty init` on Go repos; ships from its own | |
| 71 | # bucket and pulls a Temurin JVM from GitHub releases. | |
| 72 | - radarlint-releases.s3.amazonaws.com | |
| 73 | # osv-scanner — also enabled by `qlty init` on Go repos. | |
| 74 | - api.osv.dev | |
| 75 | - osv-vulnerabilities.storage.googleapis.com | |
| 76 | # Language runtimes qlty provisions on demand for non-Go linters. Trim | |
| 77 | # these if you only ever lint Go — except nodejs.org, which this kit's own | |
| 78 | # Node install step also needs. | |
| 79 | - nodejs.org | |
| 80 | - registry.npmjs.org | |
| 81 | - pypi.org | |
| 82 | - files.pythonhosted.org | |
| 83 | ||
| 84 | # ---- Go module resolution --------------------------------------------- | |
| 85 | - proxy.golang.org | |
| 86 | - sum.golang.org | |
| 87 | ||
| 88 | # ---- Cargo dependency resolution -------------------------------------- | |
| 89 | # Three distinct hosts, all needed: the sparse index cargo reads by | |
| 90 | # default, the CDN the .crate files come from, and the API `cargo add` / | |
| 91 | # `cargo search` / `cargo publish` talk to. Git dependencies go through | |
| 92 | # github.com, already allowed above. | |
| 93 | - index.crates.io | |
| 94 | - static.crates.io | |
| 95 | - crates.io | |
| 96 | ||
| 97 | # ---- VS Code extension tooling ---------------------------------------- | |
| 98 | # `vsce publish` / `vsce ls-publishers`, and the gallery query API that | |
| 99 | # `@vscode/test-electron` uses to resolve extension dependencies. | |
| 100 | - marketplace.visualstudio.com | |
| 101 | # Extension asset (.vsix) download. Each publisher gets its own subdomain, | |
| 102 | # and the wildcard matches exactly one DNS label, so both forms are needed. | |
| 103 | - "*.gallery.vsassets.io" | |
| 104 | - "*.gallerycdn.vsassets.io" | |
| 105 | # `@vscode/test-electron` asks this for the build to test against, then | |
| 106 | # downloads the archive from Microsoft's CDN. | |
| 107 | - update.code.visualstudio.com | |
| 108 | - vscode.download.prss.microsoft.com | |
| 109 | # `ovsx publish` — the Open VSX registry, which is where a VS Code | |
| 110 | # extension has to go to be installable in VSCodium, Cursor or Zed. | |
| 111 | - open-vsx.org | |
| 112 | ||
| 113 | environment: | |
| 114 | variables: | |
| 115 | # Pin the toolchain we actually installed. Without this, Go 1.21+ silently | |
| 116 | # downloads whatever `go`/`toolchain` line go.mod names, which would defeat | |
| 117 | # the point of pinning 1.26.5 (and needs egress we haven't allowed). If a | |
| 118 | # go.mod legitimately requires something newer, this surfaces as a clear | |
| 119 | # error instead of a surprise download. | |
| 120 | GOTOOLCHAIN: local | |
| 121 | # TinyGo resolves its root from the executable path, which already works for | |
| 122 | # the tarball layout. Setting it explicitly wins over any value baked in at | |
| 123 | # release-build time, so `tinygo` keeps working when invoked via the | |
| 124 | # /usr/local/bin symlink. | |
| 125 | TINYGOROOT: /usr/local/tinygo | |
| 126 | # qlty phones home to cdp.customer.io on every command otherwise. That host | |
| 127 | # is not allow-listed above, so the calls would fail anyway — this turns | |
| 128 | # them off at the source instead of relying on the firewall. | |
| 129 | QLTY_TELEMETRY: "off" | |
| 130 | # Rust lives under /usr/local rather than in the agent's home, for the same | |
| 131 | # reason Go and TinyGo do: the install runs once at container creation, and | |
| 132 | # only paths baked into the image are guaranteed to survive a restart. Both | |
| 133 | # directories are made world-writable by the install step, so `rustup target | |
| 134 | # add`, `rustup component add` and `cargo install` all work as the agent | |
| 135 | # user — this is the layout the official `rust` Docker image uses. | |
| 136 | RUSTUP_HOME: /usr/local/rustup | |
| 137 | CARGO_HOME: /usr/local/cargo | |
| 138 | # Deliberately NOT setting npm's prefix here. The base template already | |
| 139 | # exports NPM_CONFIG_PREFIX=/usr/local/share/npm-global, and that directory is | |
| 140 | # agent-owned and already on PATH ahead of /usr/local/bin — so `npm install -g` | |
| 141 | # works for the agent user with no sudo. Overriding it to /usr/local would | |
| 142 | # move global installs into a root-owned tree and break exactly that. The | |
| 143 | # install step below installs its pinned CLIs into the template's prefix and | |
| 144 | # hands ownership back to the agent. | |
| 145 | ||
| 146 | # Package caches. Each of these is repopulated over the network on a cold start, | |
| 147 | # so persisting them across container restarts turns minutes into a no-op. | |
| 148 | # Volumes are fixed at container creation: `sbx kit add` warns and skips them, so | |
| 149 | # use `--kit` at create time to get the caches. | |
| 150 | volumes: | |
| 151 | # qlty downloads a JVM (for radarlint), Node, and every linter binary into | |
| 152 | # ~/.qlty/cache. | |
| 153 | - path: /home/agent/.qlty | |
| 154 | # Cargo's downloaded .crate files and its copy of the sparse index. | |
| 155 | # Deliberately the `registry` subdirectory rather than all of CARGO_HOME: | |
| 156 | # mounting an empty volume over CARGO_HOME would shadow the rustup shims that | |
| 157 | # the install step puts in its `bin`. | |
| 158 | - path: /usr/local/cargo/registry | |
| 159 | # npm's content-addressable cache (`npm config get cache`). | |
| 160 | - path: /home/agent/.npm | |
| 161 | ||
| 162 | setup: | |
| 163 | install: | |
| 164 | # Go 1.26.5, version + digest pinned. The base template already ships a | |
| 165 | # /usr/bin/go (1.26.0 at time of writing); /usr/local/bin precedes /usr/bin | |
| 166 | # on PATH, so the symlinks below shadow it without removing anything. | |
| 167 | # To bump: change GO_VERSION and both SHA256 values (from | |
| 168 | # https://go.dev/dl/?mode=json). | |
| 169 | - command: | | |
| 170 | set -eu | |
| 171 | GO_VERSION=1.26.5 | |
| 172 | ARCH=$(dpkg --print-architecture) | |
| 173 | case "$ARCH" in | |
| 174 | amd64) SHA256=5c2c3b16caefa1d968a94c1daca04a7ca301a496d9b086e17ad77bb81393f053 ;; | |
| 175 | arm64) SHA256=fe4789e92b1f33358680864bbe8704289e7bb5fc207d80623c308935bd696d49 ;; | |
| 176 | *) echo "unsupported sandbox arch: $ARCH (expected amd64 or arm64)" >&2; exit 1 ;; | |
| 177 | esac | |
| 178 | if [ "$(/usr/local/go/bin/go version 2>/dev/null | awk '{print $3}')" != "go${GO_VERSION}" ]; then | |
| 179 | URL="https://dl.google.com/go/go${GO_VERSION}.linux-${ARCH}.tar.gz" | |
| 180 | curl --proto '=https' --tlsv1.2 -fsSL -o /tmp/go.tgz "$URL" | |
| 181 | echo "${SHA256} /tmp/go.tgz" | sha256sum -c - | |
| 182 | rm -rf /usr/local/go | |
| 183 | tar -C /usr/local -xzf /tmp/go.tgz | |
| 184 | rm -f /tmp/go.tgz | |
| 185 | fi | |
| 186 | ln -sf /usr/local/go/bin/go /usr/local/bin/go | |
| 187 | ln -sf /usr/local/go/bin/gofmt /usr/local/bin/gofmt | |
| 188 | /usr/local/bin/go version | |
| 189 | user: "0" | |
| 190 | description: "Install Go 1.26.5 (version + digest pinned), shadowing the image's Go" | |
| 191 | ||
| 192 | # TinyGo 0.41.1. Its supported range is Go 1.19–1.26 and the published | |
| 193 | # binary is itself built with go1.26.2, so it accepts the Go 1.26.5 above | |
| 194 | # (TinyGo also refuses a GOROOT newer than its own build toolchain). | |
| 195 | # Bumping Go to 1.27 would break this pairing — bump TinyGo together with it. | |
| 196 | - command: | | |
| 197 | set -eu | |
| 198 | TINYGO_VERSION=0.41.1 | |
| 199 | ARCH=$(dpkg --print-architecture) | |
| 200 | case "$ARCH" in | |
| 201 | amd64) SHA256=e156d1d93a376eef639a4143d13be07e8c463fb6cf2d7d447698ed4474d23e91 ;; | |
| 202 | arm64) SHA256=789733bc3b5bace0bd1835a267b3ea267804a7ef1cfe69bc522c295f5226d624 ;; | |
| 203 | *) echo "unsupported sandbox arch: $ARCH (expected amd64 or arm64)" >&2; exit 1 ;; | |
| 204 | esac | |
| 205 | if [ "$(/usr/local/tinygo/bin/tinygo version 2>/dev/null | awk '{print $3}')" != "$TINYGO_VERSION" ]; then | |
| 206 | TARBALL="tinygo${TINYGO_VERSION}.linux-${ARCH}.tar.gz" | |
| 207 | URL="https://github.com/tinygo-org/tinygo/releases/download/v${TINYGO_VERSION}/${TARBALL}" | |
| 208 | curl --proto '=https' --tlsv1.2 -fsSL -o /tmp/tinygo.tgz "$URL" | |
| 209 | echo "${SHA256} /tmp/tinygo.tgz" | sha256sum -c - | |
| 210 | rm -rf /usr/local/tinygo | |
| 211 | tar -C /usr/local -xzf /tmp/tinygo.tgz | |
| 212 | rm -f /tmp/tinygo.tgz | |
| 213 | fi | |
| 214 | ln -sf /usr/local/tinygo/bin/tinygo /usr/local/bin/tinygo | |
| 215 | /usr/local/bin/tinygo version | |
| 216 | user: "0" | |
| 217 | description: "Install TinyGo 0.41.1 (version + digest pinned)" | |
| 218 | ||
| 219 | # qlty 0.639.0, pulled straight from the release bucket rather than through | |
| 220 | # `curl https://qlty.sh | sh`. The install script resolves "latest" (not | |
| 221 | # reproducible), POSTs an install event to cdp.customer.io, and rewrites the | |
| 222 | # user's shell rc files — none of which belongs in a sandbox image. | |
| 223 | # The asset is .tar.xz and the base image has no `xz`, so fall back to | |
| 224 | # Python's lzma (stdlib, and python3 is present) instead of pulling xz-utils | |
| 225 | # over apt. This step deliberately stays apt-free: it runs *before* the | |
| 226 | # C-toolchain step below, so it cannot assume anything about apt having been | |
| 227 | # exercised yet, and a self-contained step is one less ordering dependency. | |
| 228 | # To bump: change QLTY_VERSION and both SHA256 values (published as | |
| 229 | # <asset>.sha256 next to each release asset). | |
| 230 | - command: | | |
| 231 | set -eu | |
| 232 | QLTY_VERSION=0.639.0 | |
| 233 | ARCH=$(dpkg --print-architecture) | |
| 234 | case "$ARCH" in | |
| 235 | amd64) | |
| 236 | TARGET=x86_64-unknown-linux-gnu | |
| 237 | SHA256=49f8299d25a1c50cf02af8263483b1a04b8b665d71c5b6512847e08ccb392a90 | |
| 238 | ;; | |
| 239 | arm64) | |
| 240 | TARGET=aarch64-unknown-linux-gnu | |
| 241 | SHA256=8f2f5cd7eb06c3f1ba106866bbf80cfb563a06d70ee495c14391dced37d05a4e | |
| 242 | ;; | |
| 243 | *) echo "unsupported sandbox arch: $ARCH (expected amd64 or arm64)" >&2; exit 1 ;; | |
| 244 | esac | |
| 245 | if [ "$(/usr/local/bin/qlty --version 2>/dev/null | awk '{print $2}')" != "$QLTY_VERSION" ]; then | |
| 246 | URL="https://qlty-releases.s3.amazonaws.com/qlty/v${QLTY_VERSION}/qlty-${TARGET}.tar.xz" | |
| 247 | curl --proto '=https' --tlsv1.2 -fsSL -o /tmp/qlty.tar.xz "$URL" | |
| 248 | echo "${SHA256} /tmp/qlty.tar.xz" | sha256sum -c - | |
| 249 | rm -rf "/tmp/qlty-${TARGET}" | |
| 250 | if command -v xz >/dev/null 2>&1; then | |
| 251 | tar -C /tmp -xJf /tmp/qlty.tar.xz "qlty-${TARGET}/qlty" | |
| 252 | else | |
| 253 | python3 -c 'import lzma,sys; sys.stdout.buffer.write(lzma.open(sys.argv[1],"rb").read())' \ | |
| 254 | /tmp/qlty.tar.xz | tar -C /tmp -xf - "qlty-${TARGET}/qlty" | |
| 255 | fi | |
| 256 | install -m 0755 "/tmp/qlty-${TARGET}/qlty" /usr/local/bin/qlty | |
| 257 | rm -rf /tmp/qlty.tar.xz "/tmp/qlty-${TARGET}" | |
| 258 | fi | |
| 259 | /usr/local/bin/qlty --version | |
| 260 | user: "0" | |
| 261 | description: "Install qlty CLI 0.639.0 (version + digest pinned, no install script)" | |
| 262 | ||
| 263 | # A C/C++ toolchain. Not optional, and not obvious: the base template ships | |
| 264 | # `make` but **no `cc`, `gcc`, `clang`, `ld` or libc headers at all**. Without | |
| 265 | # this step: | |
| 266 | # - `cargo build` / `cargo test` for the host target fail with | |
| 267 | # "error: linker `cc` not found" (measured). Cross-compiling to | |
| 268 | # wasm32-wasip2 still works, because rustc uses its own bundled rust-lld | |
| 269 | # for wasm targets — so a Zed extension builds but cannot be unit-tested. | |
| 270 | # - any crate with a `build.rs` that compiles C, and any npm package with a | |
| 271 | # native addon built through node-gyp, fails the same way. | |
| 272 | # `apt-get update` is deliberately NOT run: the template ships populated | |
| 273 | # /var/lib/apt/lists, so a plain install works, and `update` would re-fetch | |
| 274 | # every configured source (including download.docker.com) and fail if any one | |
| 275 | # of them is not allow-listed. It is kept only as a fallback for the day those | |
| 276 | # cached lists go stale. | |
| 277 | - command: | | |
| 278 | set -eu | |
| 279 | if ! command -v cc >/dev/null 2>&1; then | |
| 280 | export DEBIAN_FRONTEND=noninteractive | |
| 281 | PKGS="gcc g++ libc6-dev pkg-config" | |
| 282 | # `-o DPkg::Lock::Timeout` is not optional here. The claude kit registers | |
| 283 | # a *backgrounded* `apt-get update` as a startup command, so at the moment | |
| 284 | # this step runs (~11s into the install sequence) that update may still | |
| 285 | # hold the dpkg frontend lock. Without the timeout apt does not wait — it | |
| 286 | # gives up at once with exit 100, and the whole kit apply fails. | |
| 287 | APT="apt-get -o DPkg::Lock::Timeout=300" | |
| 288 | $APT install -y --no-install-recommends $PKGS \ | |
| 289 | || { $APT update && $APT install -y --no-install-recommends $PKGS; } | |
| 290 | fi | |
| 291 | cc --version | head -1 | |
| 292 | c++ --version | head -1 | |
| 293 | user: "0" | |
| 294 | description: "Install a C/C++ toolchain (gcc, g++, libc6-dev, pkg-config) — required to link host-target Rust builds and native npm addons" | |
| 295 | ||
| 296 | # Node.js 24.19.0 — "Krypton", the active LTS line. The base template ships a | |
| 297 | # /usr/bin/node (22.22.1 at time of writing); as with Go, /usr/local/bin | |
| 298 | # precedes /usr/bin on PATH, so the symlinks shadow it without removing it. | |
| 299 | # The .tar.gz is used rather than the (smaller) .tar.xz on purpose: the base | |
| 300 | # image has no `xz`, and unlike qlty, Node publishes both formats — so this | |
| 301 | # avoids the python-lzma workaround the qlty step needs. | |
| 302 | # To bump: change NODE_VERSION and both SHA256 values, from | |
| 303 | # https://nodejs.org/dist/v<version>/SHASUMS256.txt. | |
| 304 | - command: | | |
| 305 | set -eu | |
| 306 | NODE_VERSION=24.19.0 | |
| 307 | ARCH=$(dpkg --print-architecture) | |
| 308 | case "$ARCH" in | |
| 309 | amd64) NODE_ARCH=x64; SHA256=f625d97cd707df4ff96254916fbc5ff014f09c09effe5a1e0ca8f6d41a8789d4 ;; | |
| 310 | arm64) NODE_ARCH=arm64; SHA256=d28c8a5bf0a808f0ed434a1dce8c54ae98f0371c0bd86ac58abc613f73e6643f ;; | |
| 311 | *) echo "unsupported sandbox arch: $ARCH (expected amd64 or arm64)" >&2; exit 1 ;; | |
| 312 | esac | |
| 313 | if [ "$(/usr/local/node/bin/node --version 2>/dev/null)" != "v${NODE_VERSION}" ]; then | |
| 314 | TARBALL="node-v${NODE_VERSION}-linux-${NODE_ARCH}.tar.gz" | |
| 315 | URL="https://nodejs.org/dist/v${NODE_VERSION}/${TARBALL}" | |
| 316 | curl --proto '=https' --tlsv1.2 -fsSL -o /tmp/node.tgz "$URL" | |
| 317 | echo "${SHA256} /tmp/node.tgz" | sha256sum -c - | |
| 318 | rm -rf /usr/local/node | |
| 319 | mkdir -p /usr/local/node | |
| 320 | tar -C /usr/local/node --strip-components=1 -xzf /tmp/node.tgz | |
| 321 | rm -f /tmp/node.tgz | |
| 322 | fi | |
| 323 | for b in node npm npx corepack; do | |
| 324 | ln -sf "/usr/local/node/bin/$b" "/usr/local/bin/$b" | |
| 325 | done | |
| 326 | # Global installs go to the template's own prefix, which is already on | |
| 327 | # PATH. --prefix is passed explicitly rather than trusting NPM_CONFIG_PREFIX | |
| 328 | # to be exported into this root-run step. | |
| 329 | NPM_PREFIX=/usr/local/share/npm-global | |
| 330 | mkdir -p "$NPM_PREFIX" | |
| 331 | # Pinned, and deliberately only the tools that belong to the *sandbox* | |
| 332 | # rather than to a project: the two extension packagers, and pnpm. A | |
| 333 | # project's TypeScript, esbuild and test runner belong in its own | |
| 334 | # devDependencies, where the version is committed alongside the code. | |
| 335 | # | |
| 336 | # --allow-scripts is required by npm 11: it otherwise skips these two | |
| 337 | # lifecycle scripts and only warns. keytar builds the native addon vsce | |
| 338 | # uses to store a publishing PAT, and vsce-sign unpacks the signing | |
| 339 | # binary — both are needed by `vsce publish`, and both fail silently | |
| 340 | # (at publish time, not now) if their scripts never run. | |
| 341 | /usr/local/bin/npm install -g --prefix "$NPM_PREFIX" --no-fund --no-audit \ | |
| 342 | --allow-scripts=@vscode/vsce-sign,keytar \ | |
| 343 | @vscode/vsce@3.9.2 \ | |
| 344 | ovsx@1.1.1 \ | |
| 345 | pnpm@11.22.0 | |
| 346 | # Root just wrote into an agent-owned tree; hand it back so the agent can | |
| 347 | # install or update globals later without sudo. | |
| 348 | chown -R agent:agent "$NPM_PREFIX" | |
| 349 | /usr/local/bin/node --version | |
| 350 | /usr/local/bin/npm --version | |
| 351 | "$NPM_PREFIX/bin/vsce" --version | |
| 352 | user: "0" | |
| 353 | description: "Install Node.js 24.19.0 LTS (digest pinned) + pinned vsce, ovsx, pnpm" | |
| 354 | ||
| 355 | # Rust 1.97.1 via a digest-pinned `rustup-init` *binary* — not the | |
| 356 | # `curl https://sh.rustup.rs | sh` bootstrapper, which resolves "latest" and | |
| 357 | # rewrites the user's shell rc files. rustup rather than the standalone | |
| 358 | # tarballs because it is what Zed shells out to when an extension needs a | |
| 359 | # target that is not installed yet (`rustup target add wasm32-wasip2`). | |
| 360 | # `--profile minimal` skips the ~500 MB of offline docs; clippy and rustfmt | |
| 361 | # are added explicitly because qlty's Rust plugins invoke them. | |
| 362 | # To bump: change RUSTUP_VERSION and both SHA256 values (published at | |
| 363 | # static.rust-lang.org/rustup/archive/<ver>/<triple>/rustup-init.sha256), | |
| 364 | # and/or RUST_VERSION on its own (rustup resolves that one itself). | |
| 365 | - command: | | |
| 366 | set -eu | |
| 367 | RUSTUP_VERSION=1.29.0 | |
| 368 | RUST_VERSION=1.97.1 | |
| 369 | ARCH=$(dpkg --print-architecture) | |
| 370 | case "$ARCH" in | |
| 371 | amd64) | |
| 372 | TRIPLE=x86_64-unknown-linux-gnu | |
| 373 | SHA256=4acc9acc76d5079515b46346a485974457b5a79893cfb01112423c89aeb5aa10 | |
| 374 | ;; | |
| 375 | arm64) | |
| 376 | TRIPLE=aarch64-unknown-linux-gnu | |
| 377 | SHA256=9732d6c5e2a098d3521fca8145d826ae0aaa067ef2385ead08e6feac88fa5792 | |
| 378 | ;; | |
| 379 | *) echo "unsupported sandbox arch: $ARCH (expected amd64 or arm64)" >&2; exit 1 ;; | |
| 380 | esac | |
| 381 | export RUSTUP_HOME=/usr/local/rustup | |
| 382 | export CARGO_HOME=/usr/local/cargo | |
| 383 | if [ "$(/usr/local/cargo/bin/rustc --version 2>/dev/null | awk '{print $2}')" != "$RUST_VERSION" ]; then | |
| 384 | URL="https://static.rust-lang.org/rustup/archive/${RUSTUP_VERSION}/${TRIPLE}/rustup-init" | |
| 385 | curl --proto '=https' --tlsv1.2 -fsSL -o /tmp/rustup-init "$URL" | |
| 386 | echo "${SHA256} /tmp/rustup-init" | sha256sum -c - | |
| 387 | chmod 0755 /tmp/rustup-init | |
| 388 | # --no-modify-path: PATH comes from the /usr/local/bin symlinks below, | |
| 389 | # not from an appended line in a shell rc file. | |
| 390 | /tmp/rustup-init -y --no-modify-path --profile minimal \ | |
| 391 | --default-toolchain "$RUST_VERSION" \ | |
| 392 | -c clippy -c rustfmt \ | |
| 393 | -t wasm32-wasip2 | |
| 394 | rm -f /tmp/rustup-init | |
| 395 | fi | |
| 396 | for b in rustc cargo rustup rustfmt cargo-fmt cargo-clippy clippy-driver; do | |
| 397 | ln -sf "/usr/local/cargo/bin/$b" "/usr/local/bin/$b" | |
| 398 | done | |
| 399 | # The agent runs as a non-root user, and `rustup target add`, | |
| 400 | # `rustup component add` and `cargo install` all write inside these two | |
| 401 | # trees. Same approach as the official `rust` image: make them writable | |
| 402 | # rather than forcing every Rust command through sudo. | |
| 403 | chmod -R a+w /usr/local/rustup /usr/local/cargo | |
| 404 | /usr/local/bin/rustc --version | |
| 405 | /usr/local/bin/cargo --version | |
| 406 | /usr/local/bin/rustup target list --installed | |
| 407 | user: "0" | |
| 408 | description: "Install Rust 1.97.1 via digest-pinned rustup-init, with clippy, rustfmt and the wasm32-wasip2 target" | |
| 409 | ||
| 410 | startup: | |
| 411 | # Every cache volume mounts with a root-owned root because the runtime formats | |
| 412 | # block volumes as ext4, which shadows the image's directory ownership. | |
| 413 | # Without this the agent user cannot populate the caches — and for | |
| 414 | # /usr/local/cargo/registry the failure is a confusing "permission denied" | |
| 415 | # from the middle of a `cargo build`, not from anything the agent typed. | |
| 416 | # Idempotent by construction — startup commands run on every container start. | |
| 417 | - command: | |
| 418 | - "sh" | |
| 419 | - "-c" | |
| 420 | - | | |
| 421 | for d in /home/agent/.qlty /home/agent/.npm; do | |
| 422 | mkdir -p "$d" && chown -R agent:agent "$d" 2>/dev/null || true | |
| 423 | done | |
| 424 | # Left group/world-writable rather than chowned, to stay consistent | |
| 425 | # with the `chmod -R a+w` the Rust install applies to the rest of | |
| 426 | # CARGO_HOME: root installs into it, the agent writes to it. | |
| 427 | mkdir -p /usr/local/cargo/registry && chmod -R a+w /usr/local/cargo/registry 2>/dev/null || true | |
| 428 | user: "0" | |
| 429 | description: "Re-own the qlty, npm and cargo cache volumes so the agent user can write to them" | |
| 430 | ||
| 431 | agentInstructions: | |
| 432 | # `filename` is deliberately absent: it is ignored (with a warning) for a | |
| 433 | # mixin, whose content the engine writes to kits-memory/<kit-name>.md. | |
| 434 | content: | | |
| 435 | ## Development toolkit (kit `dev-toolkit`) | |
| 436 | ||
| 437 | This sandbox has a pinned toolchain installed at container creation: | |
| 438 | ||
| 439 | | Tool | Version | Location | Notes | | |
| 440 | | -------- | ------- | ------------------- | ----- | | |
| 441 | | qlty | 0.639.0 | `/usr/local/bin/qlty` | Lint + smells + metrics, for **any** language the project uses. | | |
| 442 | | Go | 1.26.5 | `/usr/local/go` | Symlinked to `/usr/local/bin/go`; shadows the image's own `/usr/bin/go`. | | |
| 443 | | TinyGo | 0.41.1 | `/usr/local/tinygo` | `TINYGOROOT` is set; use for `wasm`, `wasip1`, `wasip2` targets. | | |
| 444 | | Rust | 1.97.1 | `/usr/local/rustup`, `/usr/local/cargo` | Via rustup. `clippy`, `rustfmt` and the `wasm32-wasip2` target are installed. | | |
| 445 | | Node.js | 24.19.0 | `/usr/local/node` | LTS "Krypton". Shadows the image's own `/usr/bin/node` (22.x). | | |
| 446 | | npm | 11.17.0 (bundled) | `/usr/local/bin/npm` | Globals go to `/usr/local/share/npm-global`, agent-owned and on PATH. | | |
| 447 | | pnpm | 11.22.0 | `/usr/local/share/npm-global/bin` | Also `corepack`, if a project pins a different version via `packageManager`. | | |
| 448 | | vsce / ovsx | 3.9.2 / 1.1.1 | `/usr/local/share/npm-global/bin` | Package and publish VS Code extensions (Marketplace / Open VSX). | | |
| 449 | | gcc / g++ | Ubuntu's | `/usr/bin` | Installed by this kit — the base image has **no** C compiler at all. | | |
| 450 | ||
| 451 | Verify with | |
| 452 | `qlty --version && go version && tinygo version && rustc --version && node --version`. | |
| 453 | ||
| 454 | qlty is **not** language-specific: `qlty init` detects the languages present | |
| 455 | and enables the matching linters (on a Go repo that is gofmt, golangci-lint, | |
| 456 | osv-scanner, radarlint-go, ripgrep and trufflehog; on a TypeScript or Rust repo | |
| 457 | it is an entirely different set). Only the toolchain binaries above are | |
| 458 | language-specific — the three skills are not. | |
| 459 | ||
| 460 | ### Toolchain constraints worth knowing | |
| 461 | ||
| 462 | - `GOTOOLCHAIN=local`. Go will **not** auto-download a different toolchain. | |
| 463 | If a build fails with "go.mod requires go >= X", that is real — either lower | |
| 464 | the requirement or bump this kit's pinned Go and TinyGo together. | |
| 465 | - TinyGo 0.41.1 supports Go 1.19 through 1.26 **and** refuses a GOROOT newer | |
| 466 | than the Go it was itself built with (1.26.2). Go 1.26.5 is inside both | |
| 467 | bounds. Do not bump Go past 1.26 without bumping TinyGo. | |
| 468 | - `QLTY_TELEMETRY=off`, and qlty's telemetry host is not allow-listed. Pass | |
| 469 | `--no-upgrade-check` to qlty commands to skip its update probe as well. | |
| 470 | - Rust lives under `/usr/local`, not in the home directory, and both | |
| 471 | `RUSTUP_HOME` and `CARGO_HOME` are world-writable — so `rustup target add`, | |
| 472 | `rustup component add` and `cargo install` work **without** sudo. Do not | |
| 473 | re-run `rustup-init`; if a toolchain or target is missing, add it with | |
| 474 | `rustup`. | |
| 475 | - `wasm32-wasip2` is the only cross-compilation target preinstalled. Others | |
| 476 | (`wasm32-unknown-unknown` for wasm-bindgen, say) are one | |
| 477 | `rustup target add <target>` away — `static.rust-lang.org` is allow-listed. | |
| 478 | - Rust needs a C linker for **host** builds, and the base image ships none — | |
| 479 | this kit installs `gcc`/`g++` for that reason. If you ever see | |
| 480 | "error: linker `cc` not found", the C toolchain step did not run; that is a | |
| 481 | kit problem, not a project problem. Note the asymmetry: | |
| 482 | `cargo build --target wasm32-wasip2` works *without* `cc` (rustc uses its own | |
| 483 | bundled `rust-lld` for wasm), so a missing compiler breaks `cargo test` while | |
| 484 | leaving the wasm build green. | |
| 485 | - Prefer a project `devDependency` plus `npx` over `npm install -g`: the | |
| 486 | version then lives in the repository instead of in this container. Globals do | |
| 487 | work without sudo if you need them. | |
| 488 | ||
| 489 | ### Building editor extensions | |
| 490 | ||
| 491 | Both extension ecosystems are supported, and they use different toolchains: | |
| 492 | ||
| 493 | | | VS Code | Zed | | |
| 494 | | --- | --- | --- | | |
| 495 | | Language | TypeScript / JavaScript on Node | Rust compiled to WebAssembly | | |
| 496 | | Manifest | `package.json` (`contributes`, `engines.vscode`) | `extension.toml` (+ `Cargo.toml`) | | |
| 497 | | Build | the project's own script — `npm run compile`, usually tsc or esbuild | `cargo build --release --target wasm32-wasip2` | | |
| 498 | | Package | `vsce package` → a `.vsix` | Zed's own extension builder, run by the editor | | |
| 499 | | Publish | `vsce publish` (Marketplace), `ovsx publish` (Open VSX) | a PR to `zed-industries/extensions` | | |
| 500 | ||
| 501 | Two things that genuinely do not work in this sandbox, so plan around them | |
| 502 | rather than debugging them: | |
| 503 | ||
| 504 | - **No editor runs here.** There is no display, so neither VS Code's F5 | |
| 505 | "Extension Development Host" nor Zed's *Install Dev Extension* can be used | |
| 506 | from inside the sandbox. Both are host-side actions on the user's machine. | |
| 507 | What you *can* do here is compile, unit-test, lint and package. | |
| 508 | - **`@vscode/test-electron` needs a display.** Its download hosts are | |
| 509 | allow-listed and `--extensionDevelopmentPath` runs will fetch a build, but | |
| 510 | launching it needs an X server (`xvfb-run`), which this kit does not install. | |
| 511 | Write the extension so its logic is testable without the `vscode` module — | |
| 512 | keep the `vscode` API calls in a thin activation layer and unit-test the rest | |
| 513 | — and say so plainly rather than reporting an untested integration path as | |
| 514 | verified. | |
| 515 | ||
| 516 | For a Zed extension, `wasm32-wasip2` is the target Zed itself compiles with; | |
| 517 | building for anything else produces an artifact Zed will refuse. Extensions | |
| 518 | that ship Tree-sitter **grammars** additionally need the wasi-sdk to compile | |
| 519 | the parsers — Zed downloads that itself, on the host, so grammar compilation | |
| 520 | is not reproducible inside this sandbox either. | |
| 521 | ||
| 522 | ### Checking code quality | |
| 523 | ||
| 524 | Use the **`quality` skill** (`~/.claude/skills/quality/SKILL.md`) rather than | |
| 525 | invoking qlty ad hoc. It configures qlty on first use, measures the project, | |
| 526 | writes a timestamped Markdown report under `.quality/`, records a history line | |
| 527 | so progression across runs is visible, and then drives refactoring until the | |
| 528 | gate passes. | |
| 529 | ||
| 530 | Non-Claude agents: run | |
| 531 | `python3 ~/.claude/skills/quality/scripts/quality_report.py --workspace .` | |
| 532 | to get the same report and history, then refactor against its findings. | |
| 533 | ||
| 534 | ### Other skills shipped by this kit | |
| 535 | ||
| 536 | They live under `~/.claude/skills/` and are picked up automatically: | |
| 537 | ||
| 538 | - **`diataxis-doc`** — writes or restructures project documentation with the | |
| 539 | Diátaxis method (tutorials / how-to / reference / explanation). It asks which | |
| 540 | language to produce the docs in before writing anything, unless the invoking | |
| 541 | skill has already fixed it. | |
| 542 | - **`methodical-dev`** — drives a new feature through gathering requirements, | |
| 543 | planning, and implementation, stopping for approval after every step. It is the | |
| 544 | **orchestrator**: it invokes `quality` and `diataxis-doc` as mandatory closing | |
| 545 | phases, requires unit tests in the same step as the code they cover, and keeps a | |
| 546 | committed `.memory/` record (summary, append-only history, handoffs) so work can | |
| 547 | be resumed in a fresh sandbox. Start here for any non-trivial change. | |
| 548 | - **`turbo-new-editor`** — builds a complete terminal IDE for one language, | |
| 549 | `turbo-<language>`, on top of the `turbo-core` library: a command, a profile | |
| 550 | and a syntax scanner, with everything else — windows, menus, dialogs, themes, | |
| 551 | the LSP client, the terminal emulator — coming from the library. It runs | |
| 552 | *inside* `methodical-dev` rather than replacing it, and takes the language and | |
| 553 | the path of a repository the user has already created. | |
| 554 | ||
| 555 | ### `.memory/` — the project record (read it first) | |
| 556 | ||
| 557 | This kit also installs `~/.claude/CLAUDE.md`, which Claude Code loads at the start | |
| 558 | of **every** session: it says to read `.memory/` before planning or asking the user | |
| 559 | anything. On non-Claude agents that file is inert — the convention below applies | |
| 560 | anyway. | |
| 561 | ||
| 562 | `methodical-dev` maintains `.memory/` at the repository root, **committed, not | |
| 563 | gitignored**: | |
| 564 | ||
| 565 | | Path | Contents | | |
| 566 | | ---- | -------- | | |
| 567 | | `summary.md` | Living snapshot: architecture, decisions in force, build/test commands. Edited in place. | | |
| 568 | | `history.md` | Append-only dated log, one entry per completed feature. Never rewritten. | | |
| 569 | | `handoffs/` | Per-session handoff: state, work in flight, next steps, blockers. | | |
| 570 | ||
| 571 | If `.memory/` exists, **read `summary.md` and the latest handoff before starting | |
| 572 | work** — it records the project's state and any unfinished business, and saves you | |
| 573 | from re-deriving it or asking the user what is already written down. | |
| 574 | ||
| 575 | **Every session also ends by bringing all three files up to date** — no exceptions, | |
| 576 | including sessions that fixed one line, only read code, or achieved nothing (those | |
| 577 | last stop the next agent hitting the same wall): | |
| 578 | ||
| 579 | - `summary.md` — **edit in place, never regenerate.** Change only what your session | |
| 580 | establishes or invalidates and leave the rest byte-for-byte alone; rewriting it | |
| 581 | wholesale from one session's slice destroys accurate content you never looked at. | |
| 582 | Only from what you verified, never a plausible guess; what you could not check goes | |
| 583 | under an explicit `## Not yet established` heading. If the project's state did not | |
| 584 | change, leave the file untouched. | |
| 585 | - `history.md` — **append exactly one dated entry.** Never rewrite or delete an | |
| 586 | existing one. | |
| 587 | - `handoffs/YYYY-MM-DD-<slug>.md` — write today's, or update it if it already covers | |
| 588 | this topic. Never overwrite another session's handoff. | |
| 589 | ||
| 590 | Match the length to the work: a one-line fix earns a few lines, not a filled-in | |
| 591 | template — padding with "N/A" makes the record unreadable. Record what the next | |
| 592 | person cannot re-derive (where you stopped, what failed, the trap, the pending | |
| 593 | decision), not a diff summary. If `.memory/` does not exist yet, create the whole | |
| 594 | skeleton: `README.md`, `summary.md`, `history.md` and `handoffs/`. | |
| 595 | ||
| 596 | Keep the boundary clean: `docs/` is for people *using* the project, `.memory/` is | |
| 597 | for whoever *continues building* it. | |
| 598 | ||
| 599 | Two things that make the measurement meaningless — never do either: | |
| 600 | ||
| 601 | - Do not add `exclude_patterns`, disable a plugin, or raise a threshold in | |
| 602 | `.qlty/qlty.toml` to make the gate pass. Fix the code. | |
| 603 | - Do not delete or skip tests to clear a finding. |